Repository: AndrewMcLachlan/MooApp
Pull request #301: Made Form.Select a combo box.
Author: login: AndrewMcLachlan
Created: 2025-03-29T01:00:12Z | Merged: 2025-03-29T02:13:04Z



Diff:
diff --git a/demoo/src/App.tsx b/demoo/src/App.tsx
index 13b7e474..272e2685 100644
--- a/demoo/src/App.tsx
+++ b/demoo/src/App.tsx
@@ -23,6 +23,11 @@ const App = () => {
       route: "/components",
       image: <Icon icon={User} />
     },
+    {
+      text: "Forms",
+      route: "/form",
+      image: <Icon icon={User} />
+    },
     {
       text: "Notifications",
       route: "/notifications",
diff --git a/demoo/src/index.tsx b/demoo/src/index.tsx
index 8f440156..152c7a8f 100644
--- a/demoo/src/index.tsx
+++ b/demoo/src/index.tsx
@@ -8,7 +8,7 @@ import { Components } from "./pages/Components";
 import { IconButtonComponent } from "./pages/components/IconButtonComponent";
 import { IconLinkButtonComponent } from "./pages/components/IconLinkButtonComponent";
 import { LinkBoxComponent } from "./pages/components/LinkBoxComponent";
-import { PasswordComponent } from "./pages/components/form/PasswordComponent";
+import { PasswordComponent } from "./pages/form/PasswordComponent";
 import { FormComponent } from "./pages/components/Form";
 import { Home } from "./pages/Home";
 import { Profile } from "./pages/Profile";
@@ -17,6 +17,7 @@ import { TagPanel } from "./pages/components/TagPanel";
 import { error } from "console";
 import { ErrorPage } from "./pages/ErrorPage";
 import { Notifications } from "./pages/Notifications";
+import { FormSample } from "./pages/form/FormSample";
 
 const root = ReactDOM.createRoot(
   document.getElementById("root") as HTMLElement
@@ -38,7 +39,8 @@ const router = createMooAppBrowserRouter({
       iconButton: { path: "/components/icon-button", element: <IconButtonComponent /> },
       linkBox: { path: "/components/link-box", element: <LinkBoxComponent /> },
       form: {
-        path: "/components/form", element: <FormComponent />, children: {
+        path: "/form", element: <FormComponent />, children: {
+          sample: { path: "", element: <FormSample /> },
           password: { path: "password", element: <PasswordComponent /> },
         },
       },
diff --git a/demoo/src/pages/form/FormSample.tsx b/demoo/src/pages/form/FormSample.tsx
new file mode 100644
index 00000000..c0c8cb1f
--- /dev/null
+++ b/demoo/src/pages/form/FormSample.tsx
@@ -0,0 +1,53 @@
+import { Page, Form, SectionForm, FormComboBox } from "@andrewmclachlan/mooapp"
+import { useState } from "react";
+
+export const FormSample = () => {
+
+    const selectItems = [
+        { id: "1", text: "Option 1" },
+        { id: "2", text: "Option 2" },
+        { id: "3", text: "Option 3" },
+        { id: "4", text: "Option 4" },
+        { id: "5", text: "Option 5" },
+        { id: "6", text: "Option 6" },
+        { id: "7", text: "Option 7" },
+        { id: "8", text: "Option 8" },
+        { id: "9", text: "Option 9" },
+        { id: "10", text: "Option 10" },
+    ];
+
+    const [value, setValue] = useState<string | undefined>(undefined);
+
+    return (
+        <Page title="Form Sample" className="form-sample-page">
+            <div>
+                <h2>Form Sample</h2>
+
+                <SectionForm onSubmit={() => { }}>
+                    <Form.Group groupId="group1">
+                        <Form.Label htmlFor="input1">Input 1</Form.Label>
+                        <Form.Input id="input1" name="input1" placeholder="Input 1" required />
+                    </Form.Group>
+                    <Form.Group groupId="group2">
+                        <Form.Label htmlFor="input2">Input 2</Form.Label>
+                        <Form.Input id="input2" name="input2" placeholder="Input 2" required />
+                    </Form.Group>
+                    <Form.Group groupId="group3">
+                        <Form.Label htmlFor="input3">Input 3</Form.Label>
+                        <FormComboBox<{ id: string, text: string }> items={selectItems} labelField={i => i.text} valueField={i => i.id} selectedItems={[]} />
+                    </Form.Group>
+                    <Form.Group groupId="group4">
+                        <Form.Label htmlFor="input4">Input 4</Form.Label>
+                        <Form.Select id="input4" name="input4" required>
+                            <option value="">Select an option</option>
+                            <option value="1">Option 1</option>
+                            <option value="2">Option 2</option>
+                            <option value="3">Option 3</option>
+                            <option value="4">Option 4</option>
+                        </Form.Select>
+                        </Form.Group>
+                </SectionForm>
+            </div>
+        </Page>
+    );
+}
diff --git a/demoo/src/pages/components/form/PasswordComponent.tsx b/demoo/src/pages/form/PasswordComponent.tsx
similarity index 100%
rename from demoo/src/pages/components/form/PasswordComponent.tsx
rename to demoo/src/pages/form/PasswordComponent.tsx
diff --git a/moo-app/src/components/comboBox/ComboBoxContainer.tsx b/moo-app/src/components/comboBox/ComboBoxContainer.tsx
index af080df7..1553da2a 100644
--- a/moo-app/src/components/comboBox/ComboBoxContainer.tsx
+++ b/moo-app/src/components/comboBox/ComboBoxContainer.tsx
@@ -1,17 +1,16 @@
 import React, { useRef } from "react";
 import classNames from "classnames";
 
-import { RefProps } from "../../models";
 import { useClickAway } from "../../hooks";
 
 import { ComboBoxInput as Input } from "./ComboBoxInput";
 import { ComboBoxControls as Controls } from "./ComboBoxControls";
-import { useComboBox } from "./ComboBoxProvider";
+import { ComboBoxProps, useComboBox } from "./ComboBoxProvider";
 import { ComboBoxList as List } from "./ComboBoxList";
 import { ComboBoxSelectedItem as SelectedItem } from "./ComboBoxSelectedItem";
 import { ComboBoxSingleSelectedItem as SingleSelectedItem } from "./ComboBoxSingleSelectedItem";
 
-export const ComboBoxContainer: React.FC<ComboBoxContainerProps> = ({ placeholder = "Select...", readonly = false, hidden = false, ref }) => {
+export const ComboBoxContainer: React.FC<ComboBoxContainerProps> = ({ placeholder = "Select...", readonly = false, hidden = false, id, className, ref }) => {
 
     const { multiSelect, selectedItems, show, setShow, valueField } = useComboBox();
 
@@ -22,7 +21,7 @@ export const ComboBoxContainer: React.FC<ComboBoxContainerProps> = ({ placeholde
     useClickAway(setShow, theRef as React.RefObject<any>);
 
     return (
-        <div className={classNames("combo-box", readonly ? "readonly" : "")} hidden={hidden} ref={theRef} onClick={() => setShow(!show)} onKeyUp={key => key.key === "Escape" && setShow(false)}>
+        <div id={id} className={classNames("combo-box", readonly ? "readonly" : "", className)} hidden={hidden} ref={theRef} onClick={() => setShow(!show)} onKeyUp={key => key.key === "Escape" && setShow(false)}>
             <div>
                 {!!multiSelect && selectedItems.map(item => <SelectedItem key={valueField(item)?.toString()} item={item} />)}
                 {!multiSelect && selectedItems.length > 0 && <SingleSelectedItem />}
@@ -36,8 +35,5 @@ export const ComboBoxContainer: React.FC<ComboBoxContainerProps> = ({ placeholde
 
 ComboBoxContainer.displayName = "ComboBoxContainer";
 
-interface ComboBoxContainerProps extends RefProps<HTMLDivElement> {
-    placeholder?: string;
-    readonly?: boolean;
-    hidden?: boolean;
+interface ComboBoxContainerProps extends Pick<ComboBoxProps<any>, "placeholder" | "readonly" | "hidden" | "id" | "className" | "ref" > {
 }
\ No newline at end of file
diff --git a/moo-app/src/components/comboBox/ComboBoxProvider.tsx b/moo-app/src/components/comboBox/ComboBoxProvider.tsx
index 3e00f796..618f0998 100644
--- a/moo-app/src/components/comboBox/ComboBoxProvider.tsx
+++ b/moo-app/src/components/comboBox/ComboBoxProvider.tsx
@@ -87,6 +87,8 @@ export interface ComboBoxOptions {
 }
 
 export interface ComboBoxProps<TItem> extends RefProps<HTMLDivElement> {
+    id?: string;
+    className?: string;
     search?: (input: string) => TItem[];
     onCreate?: (name: string) => void;
     onAdd?: (item: TItem) => void;
diff --git a/moo-app/src/components/form/FormComboBox.tsx b/moo-app/src/components/form/FormComboBox.tsx
new file mode 100644
index 00000000..9478f197
--- /dev/null
+++ b/moo-app/src/components/form/FormComboBox.tsx
@@ -0,0 +1,15 @@
+import { useFormGroup } from "./FormGroupProvider";
+import { ComboBox, ComboBoxProps } from "../comboBox";
+
+export const FormComboBox = <T,>({ id, ref, ...rest }: ComboBoxProps<T>) => {
+
+    const group = useFormGroup();
+
+    id = id ?? group.groupId;
+
+    return (
+        <ComboBox id={id} {...rest} ref={ref} />
+    );
+};
+
+FormComboBox.displayName = "FormComboBox";
diff --git a/moo-app/src/components/form/Select.tsx b/moo-app/src/components/form/Select.tsx
index 3a7db607..2d6dfaf4 100644
--- a/moo-app/src/components/form/Select.tsx
+++ b/moo-app/src/components/form/Select.tsx
@@ -18,4 +18,4 @@ export const Select: SelectComponent = React.forwardRef(({ className, id, ...res
 Select.displayName = "Select";
 
 export interface SelectProps extends React.DetailedHTMLProps<React.SelectHTMLAttributes<HTMLSelectElement>, HTMLSelectElement> {
-}
+}
\ No newline at end of file
diff --git a/moo-app/src/components/form/index.ts b/moo-app/src/components/form/index.ts
index 452626c4..e82edc0a 100644
--- a/moo-app/src/components/form/index.ts
+++ b/moo-app/src/components/form/index.ts
@@ -1,4 +1,5 @@
 export * from "./Form";
+export * from "./FormComboBox";
 export * from "./Group";
 export * from "./Input";
 export * from "./Label";
diff --git a/moo-app/src/css/_forms.css b/moo-app/src/css/_forms.css
index 2637c762..0b7b06f6 100644
--- a/moo-app/src/css/_forms.css
+++ b/moo-app/src/css/_forms.css
@@ -65,13 +65,30 @@ form .form-group {
     border-color: var(--primary);
 }
 
-input.form-control:read-only {
-    background-color: var(--bs-disabled-bg);
-    cursor: default;
+input.form-control {
+    &:read-only {
+        background-color: var(--bs-disabled-bg);
+        cursor: default;
+
+        &:focus {
+            box-shadow: none;
+            border-color: var(--bs-border-color);
+            background-color: var(--bs-disabled-bg);
+        }
+    }
 
     &:focus {
-        box-shadow: none;
-        border-color: var(--bs-border-color);
-        background-color: var(--bs-disabled-bg);
+        border-color: var(--primary);
+        box-shadow: 0 0 0 0.25rem color-mix(in srgb, var(--primary) 25%, transparent);
+    }
+}
+
+.form-select {
+    padding: var(--input-padding-v) calc(var(--input-padding-h) + 16px) var(--input-padding-v) var(--input-padding-h);
+    background-size: 16px;
+
+    &:focus {
+        border-color: var(--primary);
+        box-shadow: 0 0 0 0.25rem color-mix(in srgb, var(--primary) 25%, transparent);
     }
 }
diff --git a/moo-app/src/css/_variables.css b/moo-app/src/css/_variables.css
index cf9026ff..121ad241 100644
--- a/moo-app/src/css/_variables.css
+++ b/moo-app/src/css/_variables.css
@@ -31,6 +31,9 @@
 
   --nav-link-active: var(--primary);
 
+  --input-padding-v: 0.75rem;
+  --input-padding-h: 0.75rem;
+
   --dark-body-bg: rgb(23, 18, 17);
   --default-primary: #8b0000;
   --light-body-bg: #FFF;
diff --git a/moo-app/src/css/components/_combo-box.css b/moo-app/src/css/components/_combo-box.css
index 39cd9ce1..bf0d715b 100644
--- a/moo-app/src/css/components/_combo-box.css
+++ b/moo-app/src/css/components/_combo-box.css
@@ -7,6 +7,11 @@
         border: var(--bs-border-width) solid var(--bs-border-color);
     }
 
+    :scope:focus-within {
+        border-color: var(--primary);
+        box-shadow: 0 0 0 0.25rem color-mix(in srgb, var(--primary) 25%, transparent);
+    }
+
     &.readonly:scope {
         background-color: transparent;
     }
@@ -28,20 +33,19 @@
     }
 
     >div {
-        padding-left: 8px;
+        padding-left: var(--input-padding-h);
         display: flex;
-        gap: 8px;
+        gap: 0.75rem;
         align-items: center;
     }
 
     input {
-        padding: 8px 8px 8px 0px;
+        padding: var(--input-padding-v) var(--input-padding-h) var(--input-padding-v) 0;
         border: none;
         width: 100%;
         z-index: 10;
         background-color: transparent;
 
-
         &:focus {
             outline: none;
         }
@@ -76,12 +80,8 @@
         align-items: center;
 
         div {
-            margin:8px 0;
-            padding: 0 10px;
-
-            &:last-child {
-                border-left: 1px solid hsl(0, 0%, 80%);
-            }
+            margin: var(--input-padding-v) 0;
+            padding: 0 var(--input-padding-h);
         }
     }
 
diff --git a/moo-app/src/css/components/_input.css b/moo-app/src/css/components/_input.css
index 26de0cc5..290eb8f3 100644
--- a/moo-app/src/css/components/_input.css
+++ b/moo-app/src/css/components/_input.css
@@ -11,4 +11,8 @@ input.clearable {
     input:focus + svg.clear-input {
         fill: hsl(0, 0%, 40%);
     }
+}
+
+input.form-control {
+    padding: var(--input-padding-v) var(--input-padding-h);
 }
\ No newline at end of file


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR converts the Form.Select component from a native select element into a combo box by integrating it with the ComboBox component, updates type definitions and prop interfaces accordingly, and adjusts routing in the demo application.  
- Refactored Form.Select to use ComboBox with generic typing  
- Updated Form and ComboBox component interfaces to support additional props (id, className, and placeholder)  
- Modified routing and navigation for the form sample in the demo app

### Reviewed Changes

Copilot reviewed 8 out of 11 changed files in this pull request and generated 3 comments.

<details>
<summary>Show a summary per file</summary>

| File                                           | Description                                                              |
| ---------------------------------------------- | ------------------------------------------------------------------------ |
| moo-app/src/components/form/Select.tsx         | Updated the Select component to wrap ComboBox and modified its generic type usage |
| moo-app/src/components/form/Form.tsx           | Changed the type signature for Form.Select to introduce generics         |
| moo-app/src/components/comboBox/ComboBoxProvider.tsx | Added id and className props to ComboBoxProps                            |
| moo-app/src/components/comboBox/ComboBoxContainer.tsx | Updated prop destructuring and interface to include id, className, and placeholder |
| demoo/src/pages/form/FormSample.tsx              | Updated sample usage of Form.Select with expected combo box props         |
| demoo/src/index.tsx                            | Adjusted routing for the FormSample and fixed a property naming typo       |
| demoo/src/App.tsx                              | Added a navigation entry for Forms                                      |
</details>


<details>
<summary>Files not reviewed (3)</summary>

* **moo-app/src/css/_variables.css**: Language not supported
* **moo-app/src/css/components/_combo-box.css**: Language not supported
* **moo-app/src/css/components/_input.css**: Language not supported
</details>

<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**moo-app/src/components/form/Form.tsx:12**
* The generic type signature for Form.Select differs from its implementation in Select.tsx, potentially leading to type inconsistencies. Verify that the intended generic usage is preserved throughout.
```
Select: <TItem>() => SelectComponent<TItem>;
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-29T01:01:04Z
user: login: AndrewMcLachlan
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-29T01:04:50Z
user: login: AndrewMcLachlan
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-29T01:05:27Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR updates the form components to make Form.Select behave as a combo box by introducing a new FormComboBox component and updating related combo box props and containers.  
- Added a new FormComboBox component with combo box behavior.  
- Updated the combo box provider and container to accept id and className props.  
- Updated demo and routing to illustrate the new form combo box functionality.

### Reviewed Changes

Copilot reviewed 9 out of 13 changed files in this pull request and generated no comments.

<details>
<summary>Show a summary per file</summary>

| File                                                         | Description                                            |
| ------------------------------------------------------------ | ------------------------------------------------------ |
| moo-app/src/components/form/index.ts                         | Added export for the new FormComboBox component        |
| moo-app/src/components/form/FormComboBox.tsx                   | Introduced a new combo box component with a displayName alias |
| moo-app/src/components/comboBox/ComboBoxProvider.tsx            | Added id and className to ComboBoxProps extension       |
| moo-app/src/components/comboBox/ComboBoxContainer.tsx           | Updated component props and div markup to include id and className |
| demoo/src/pages/form/FormSample.tsx                            | Demonstrated usage of the new FormComboBox alongside Form.Select |
| demoo/src/index.tsx & demoo/src/App.tsx                         | Updated routing and navigation to include the FormSample page |
</details>


<details>
<summary>Files not reviewed (4)</summary>

* **moo-app/src/css/_forms.css**: Language not supported
* **moo-app/src/css/_variables.css**: Language not supported
* **moo-app/src/css/components/_combo-box.css**: Language not supported
* **moo-app/src/css/components/_input.css**: Language not supported
</details>

<details>
<summary>Comments suppressed due to low confidence (2)</summary>

**moo-app/src/components/form/FormComboBox.tsx:15**
* The displayName 'FormSelect' is inconsistent with the component name 'FormComboBox'. Consider updating the displayName to 'FormComboBox' for consistency.
```
FormComboBox.displayName = "FormSelect";
```
**moo-app/src/components/form/FormComboBox.tsx:8**
* Instead of reassigning the prop 'id', consider using a new variable (e.g., 'resolvedId') to avoid potential side effects.
```
id = id ?? group.groupId;
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-29T02:09:52Z

Review comments:
diff_hunk: @@ -1,21 +1,18 @@
-import classNames from "classnames";
 import { useFormGroup } from "./FormGroupProvider";
 import React from "react";
+import { ComboBox, ComboBoxProps } from "../comboBox";
 
-export type SelectComponent = React.FC<SelectProps>;
+export type SelectComponent<T> = React.FC<ComboBoxProps<T>>;
 
-export const Select: SelectComponent = React.forwardRef(({ className, id, ...rest }, ref) => {
+export const Select: SelectComponent<any> = <T,>({ id, ref, ...rest }: ComboBoxProps<T>) => {
 
path: moo-app/src/components/form/Select.tsx
user: login: Copilot
body: Ref forwarding may not work as expected when extracting 'ref' directly from the props. Consider using React.forwardRef to properly forward the ref to the ComboBox component.
```suggestion
export const Select = React.forwardRef(<T,>({ id, ...rest }: ComboBoxProps<T>, ref: React.Ref<HTMLDivElement>) => {
```
created_at: 2025-03-29T01:01:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -36,8 +35,5 @@ export const ComboBoxContainer: React.FC<ComboBoxContainerProps> = ({ placeholde
 
 ComboBoxContainer.displayName = "ComboBoxContainer";
 
-interface ComboBoxContainerProps extends RefProps<HTMLDivElement> {
-    placeholder?: string;
-    readonly?: boolean;
-    hidden?: boolean;
+interface ComboBoxContainerProps extends Pick<ComboBoxProps<any>, "placeholder" | "readonly" | "hidden" | "id" | "className" | "ref" > {
path: moo-app/src/components/comboBox/ComboBoxContainer.tsx
user: login: Copilot
body: The 'placeholder' property is used in ComboBoxContainer but does not appear in ComboBoxProps. Consider adding 'placeholder?: string' to ComboBoxProps to maintain type consistency.
created_at: 2025-03-29T01:01:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -38,7 +39,8 @@ const router = createMooAppBrowserRouter({
       iconButton: { path: "/components/icon-button", element: <IconButtonComponent /> },
       linkBox: { path: "/components/link-box", element: <LinkBoxComponent /> },
       form: {
-        path: "/components/form", element: <FormComponent />, children: {
+        path: "/form", element: <FormComponent />, children: {
+          smaple: { path: "", element: <FormSample /> },
path: demoo/src/index.tsx
user: login: Copilot
body: The property name 'smaple' is misspelled. It should likely be 'sample' to improve clarity.
```suggestion
          sample: { path: "", element: <FormSample /> },
```
created_at: 2025-03-29T01:01:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,21 +1,18 @@
-import classNames from "classnames";
 import { useFormGroup } from "./FormGroupProvider";
 import React from "react";
+import { ComboBox, ComboBoxProps } from "../comboBox";
 
-export type SelectComponent = React.FC<SelectProps>;
+export type SelectComponent<T> = React.FC<ComboBoxProps<T>>;
 
-export const Select: SelectComponent = React.forwardRef(({ className, id, ...rest }, ref) => {
+export const Select: SelectComponent<any> = <T,>({ id, ref, ...rest }: ComboBoxProps<T>) => {
 
path: moo-app/src/components/form/Select.tsx
user: login: AndrewMcLachlan
body: React 19 recommends this.
created_at: 2025-03-29T01:04:50Z
author_association: OWNER
diff_hunk: @@ -36,8 +35,5 @@ export const ComboBoxContainer: React.FC<ComboBoxContainerProps> = ({ placeholde
 
 ComboBoxContainer.displayName = "ComboBoxContainer";
 
-interface ComboBoxContainerProps extends RefProps<HTMLDivElement> {
-    placeholder?: string;
-    readonly?: boolean;
-    hidden?: boolean;
+interface ComboBoxContainerProps extends Pick<ComboBoxProps<any>, "placeholder" | "readonly" | "hidden" | "id" | "className" | "ref" > {
path: moo-app/src/components/comboBox/ComboBoxContainer.tsx
user: login: AndrewMcLachlan
body: Yes it does.
created_at: 2025-03-29T01:05:27Z
author_association: OWNER

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-29T02:09:51Z
body: Azure Static Web Apps: Your stage site is ready! Visit it here: https://salmon-beach-014f72600-301.eastasia.5.azurestaticapps.net
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-29T02:13:23Z
body: Azure Static Web Apps: Your stage site is ready! Visit it here: https://salmon-beach-014f72600-301.eastasia.5.azurestaticapps.net
author_association: CONTRIBUTOR