Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Combobox): add custom formValue prop to ComboboxOption #1264

Merged
merged 3 commits into from
Nov 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/pages/components/Combobox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,38 @@ function ComboboxControlledExample() {
}
```

## Form Values

By default when `name` is set, the value submitted via form submissions for the given field name will be the `value` of the combobox input field or the inner text contents of the combobox option when `value` is omitted.

```jsx example
<FieldWrap>
<Combobox name="fruit" label="Fruit" defaultValue="Peach">
<ComboboxOption value="Apple">🍎 Apple</ComboboxOption>
<ComboboxOption value="Banana">🍌 Banana</ComboboxOption>
<ComboboxOption value="Cucumber">🥒 Cucumber</ComboboxOption>
<ComboboxOption value="Orange">🍊 Orange</ComboboxOption>
<ComboboxOption value="Peach">🍑 Peach</ComboboxOption>
<ComboboxOption value="Pear">🍐 Pear</ComboboxOption>
</Combobox>
</FieldWrap>
```

Alternatively, a custom `formValue` prop can be set for each combobox option to provide an opaque form value for form submissions, while still allowing for a different input value for the combobox input field.

```jsx example
<FieldWrap>
<Combobox name="fruit" label="Fruit" defaultValue="Peach">
<ComboboxOption value="Apple" formValue="1">🍎 Apple</ComboboxOption>
<ComboboxOption value="Banana" formValue="2">🍌 Banana</ComboboxOption>
<ComboboxOption value="Cucumber" formValue="3">🥒 Cucumber</ComboboxOption>
<ComboboxOption value="Orange" formValue="4">🍊 Orange</ComboboxOption>
<ComboboxOption value="Peach" formValue="5">🍑 Peach</ComboboxOption>
<ComboboxOption value="Pear" formValue="6">🍐 Pear</ComboboxOption>
</Combobox>
</FieldWrap>
```

## Autocomplete

There are a couple of different methods to control the auto completion of the options in the listbox. By default, autocomplete is set to "manual" meaning selections will be filtered based on the value of the input, and a value will need to be manually selected via click or keypress to change the value of the input field.
Expand Down Expand Up @@ -323,6 +355,11 @@ When autocomplete is set to "automatic" the listbox will provide a filtered list
type: 'string',
description: 'Value to be applied to the combobox option. When omitted, value will default to the text content of the option.'
},
{
name: 'formValue',
type: 'string',
description: 'An optional custom value to be used for form submissions when the "name" prop is provided for Combobox.'
},
{
name: 'disabled',
type: 'boolean',
Expand Down
48 changes: 48 additions & 0 deletions packages/react/__tests__/src/components/Combobox/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,54 @@ test('should set selected value with "value" prop', () => {
assertOptionIsSelected(1);
});

test('should not render hidden input when name is not provided', () => {
const wrapper = mount(
<Combobox value="Banana">
<ComboboxOption>Apple</ComboboxOption>
<ComboboxOption>Banana</ComboboxOption>
<ComboboxOption>Cantaloupe</ComboboxOption>
</Combobox>
);

expect(wrapper.find('input[type="hidden"]').exists()).toBeFalsy();
});

test('should render hidden input with value from text contents of ComboboxOption', () => {
const wrapper = mount(
<Combobox name="fruit" value="Banana">
<ComboboxOption>Apple</ComboboxOption>
<ComboboxOption>Banana</ComboboxOption>
<ComboboxOption>Cantaloupe</ComboboxOption>
</Combobox>
);

expect(wrapper.find('input[type="hidden"]').prop('value')).toEqual('Banana');
});

test('should render hidden input with value from value from ComboboxOption', () => {
const wrapper = mount(
<Combobox name="fruit" value="Banana">
<ComboboxOption value="Apple">🍎</ComboboxOption>
<ComboboxOption value="Banana">🍌</ComboboxOption>
<ComboboxOption value="Cantaloupe">🍈</ComboboxOption>
</Combobox>
);

expect(wrapper.find('input[type="hidden"]').prop('value')).toEqual('Banana');
});

test('should render hidden input with value from formValue from ComboboxOption', () => {
const wrapper = mount(
<Combobox name="fruit" value="Banana">
<ComboboxOption formValue="1">Apple</ComboboxOption>
<ComboboxOption formValue="2">Banana</ComboboxOption>
<ComboboxOption formValue="3">Cantaloupe</ComboboxOption>
</Combobox>
);

expect(wrapper.find('input[type="hidden"]').prop('value')).toEqual('2');
});

test('should support portal element for combobox listbos', () => {
const element = document.createElement('div');
const wrapper = mount(
Expand Down
10 changes: 8 additions & 2 deletions packages/react/src/components/Combobox/Combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ interface ComboboxOption {
key?: string;
label: string;
value?: ComboboxValue;
formValue?: ComboboxValue;
description?: string;
}

Expand Down Expand Up @@ -91,6 +92,7 @@ const Combobox = forwardRef<HTMLInputElement, ComboboxProps>(
onKeyDown,
onFocus,
onBlur,
name,
renderNoResults,
portal,
...props
Expand All @@ -102,6 +104,7 @@ const Combobox = forwardRef<HTMLInputElement, ComboboxProps>(
Map<HTMLElement, ComboboxOptionState>
>(new Map());
const [selectedValue, setSelectedValue] = useState<string>(value || '');
const [formValue, setFormValue] = useState<string | undefined>('');
const [open, setOpen] = useState(false);
const [activeDescendant, setActiveDescendant] =
useState<ListboxOption | null>(null);
Expand Down Expand Up @@ -153,7 +156,7 @@ const Combobox = forwardRef<HTMLInputElement, ComboboxProps>(
}

if (open && autocomplete === 'automatic' && !selectedValue) {
// Fire an Home keydown event on listbox to ensure the first item is selected
// Fire a Home keydown event on listbox to ensure the first item is selected
triggerListboxKeyDown(Home);
}
}, [open]);
Expand Down Expand Up @@ -371,7 +374,7 @@ const Combobox = forwardRef<HTMLInputElement, ComboboxProps>(
onActiveChange={handleActiveChange}
ref={listboxRef}
tabIndex={undefined}
aria-activedescendant=""
aria-activedescendant={undefined}
>
{comboboxOptions}
{noMatchingOptions}
Expand All @@ -384,6 +387,7 @@ const Combobox = forwardRef<HTMLInputElement, ComboboxProps>(
className={classnames('Combobox', className)}
ref={comboboxRef}
>
{name && <input type="hidden" name={name} value={formValue} />}
<label
className={classnames('Field__label', {
'Field__label--is-required': isRequired,
Expand Down Expand Up @@ -430,10 +434,12 @@ const Combobox = forwardRef<HTMLInputElement, ComboboxProps>(
<ComboboxProvider
autocomplete={autocomplete}
inputValue={value}
formValue={formValue}
selectedValue={selectedValue}
matches={!isAutoComplete || defaultAutoCompleteMatches}
matchingOptions={matchingOptions}
setMatchingOptions={setMatchingOptions}
setFormValue={setFormValue}
>
{portal
? createPortal(
Expand Down
16 changes: 13 additions & 3 deletions packages/react/src/components/Combobox/ComboboxContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import { ComboboxValue } from './ComboboxOption';
type ComboboxContext = {
autocomplete: 'none' | 'manual' | 'automatic';
inputValue: ComboboxValue;
formValue: ComboboxValue;
selectedValue: ComboboxValue;
matchingOptions: Map<HTMLElement, ComboboxOptionState>;
setMatchingOptions: React.Dispatch<
React.SetStateAction<Map<HTMLElement, ComboboxOptionState>>
>;
setFormValue: React.Dispatch<React.SetStateAction<ComboboxValue>>;
matches: (<T extends string = string>(value: T) => boolean) | boolean;
};

Expand All @@ -26,41 +28,49 @@ type ComboboxProvider = {
const ComboboxContext = createContext<ComboboxContext>({
autocomplete: 'manual',
inputValue: undefined,
formValue: undefined,
selectedValue: undefined,
matches: true,
matchingOptions: new Map(),
setMatchingOptions: () => null
setMatchingOptions: () => null,
setFormValue: () => null
});

function ComboboxProvider({
autocomplete,
inputValue,
formValue,
selectedValue,
matches,
matchingOptions,
setMatchingOptions,
setFormValue,
children
}: ComboboxProvider): JSX.Element {
const { Provider } = ComboboxContext as React.Context<ComboboxContext>;
const contextValue: ComboboxContext = useMemo(
() => ({
autocomplete,
inputValue,
formValue,
selectedValue,
matches:
typeof matches === 'function' && !!inputValue
? (value) => matches(inputValue, value)
: true,
matchingOptions,
setMatchingOptions
setMatchingOptions,
setFormValue
}),
[
autocomplete,
inputValue,
formValue,
selectedValue,
matches,
matchingOptions,
setMatchingOptions
setMatchingOptions,
setFormValue
]
);

Expand Down
18 changes: 17 additions & 1 deletion packages/react/src/components/Combobox/ComboboxOption.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type ComboboxValue = Exclude<ListboxValue, number>;
interface ComboboxOptionProps extends React.HTMLAttributes<HTMLLIElement> {
disabled?: boolean;
value?: ComboboxValue;
formValue?: ComboboxValue;
description?: ContentNode;
children: string;
}
Expand Down Expand Up @@ -58,13 +59,15 @@ const ComboboxOption = forwardRef<HTMLLIElement, ComboboxOptionProps>(
id: propId,
description,
value: propValue,
formValue,
...props
},
ref
): JSX.Element | null => {
const [id] = propId ? [propId] : useId(1, 'combobox-option');
const { selected, active } = useListboxContext();
const { matches, setMatchingOptions } = useComboboxContext();
const { selectedValue, matches, setMatchingOptions, setFormValue } =
useComboboxContext();
const comboboxOptionRef = useSharedRef<HTMLElement>(ref);
const intersectionRef = useIntersectionRef<HTMLElement>(comboboxOptionRef, {
root: null,
Expand Down Expand Up @@ -100,6 +103,19 @@ const ComboboxOption = forwardRef<HTMLLIElement, ComboboxOptionProps>(
}
}, [isActive]);

useEffect(() => {
const comboboxValue =
typeof propValue !== 'undefined'
? propValue
: comboboxOptionRef.current?.innerText;

if (selectedValue === comboboxValue) {
setFormValue(
typeof formValue === 'undefined' ? comboboxValue : formValue
);
}
}, [selectedValue, formValue]);
scurker marked this conversation as resolved.
Show resolved Hide resolved

useEffect(() => {
if (isMatching) {
setMatchingOptions((options) => {
Expand Down
Loading