Skip to content

Commit

Permalink
Fix: Add arbitrary combobox input hook and fix Autocomplete
Browse files Browse the repository at this point in the history
  • Loading branch information
NicholasBoll committed Oct 13, 2024
1 parent 16dc74c commit b10addb
Show file tree
Hide file tree
Showing 9 changed files with 167 additions and 52 deletions.
4 changes: 2 additions & 2 deletions cypress/component/Autocomplete.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ describe('Autocomplete', () => {
cy.mount(<Autocomplete />);
});

it('should have aria-haspopup set to true', () => {
cy.findByRole('combobox').should('have.attr', 'aria-haspopup', 'true');
it('should have aria-haspopup set to listbox', () => {
cy.findByRole('combobox').should('have.attr', 'aria-haspopup', 'listbox');
});

it('should have aria-expanded set to false', () => {
Expand Down
31 changes: 31 additions & 0 deletions modules/docs/mdx/12.0-UPGRADE-GUIDE.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ A note to the reader:
- [Component Updates](#component-updates)
- [Styling API and CSS Tokens](#styling-api-and-css-tokens)
- [Avatar](#avatar)
- [Combobox](#combmbox)
- [Form Field](#form-field)
- [Form Field Group](#form-field-group)
- [Form Field Field](#form-field-field)
Expand Down Expand Up @@ -252,6 +253,36 @@ The following changes have been made to the API:
> union types. You will see a console warn message while in development if you're still using this.
> Please update your types and usage before v13.
### Combobox

**PR** [#2983](https://github.com/Workday/canvas-kit/pull/2983)

The `useComboboxInput` hook, and the `Combobox.Input` component used to automatically update the
input when an option was selected. This functionality has been split between
`useComboboxInputArbitrary` and `useComboboxInputConstrained` depending on the combobox's value
type. The `useComboboxInput` had the arbitrary functionality built in. This has been separated out.
The `<Select>` component has been updated to use `useComboboxInputConstrained` hook and the
`Autocomplete` example uses the `useComboboxInputArbitrary` hook. This is a breaking change if you
use the `Combobox.Input` component directly. You'll have to either pass the
`useComboboxInputArbitrary` elemProps hook directly to the `<Combbox.Input>` or create a new
component composing them.

```tsx
// v11
<Combobox>
<Combobox.Input />
// ...
</Combobox>

// v12
<Combobox>
<Combobox.Input elemPropsHook={useComboboxInputArbitrary} />
// ...
</Combobox>

// Better - create a specialized component
```

### Form Field

<div style={{display: 'inline-flex', gap: '.5rem'}}>
Expand Down
1 change: 1 addition & 0 deletions modules/react/combobox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ export * from './lib/hooks/useSetPopupWidth';
export * from './lib/hooks/useComboboxKeyboardTypeAhead';
export * from './lib/hooks/useComboboxListKeyboardHandler';
export * from './lib/hooks/useComboboxInput';
export * from './lib/hooks/useComboboxInputArbitrary';
export * from './lib/hooks/useComboboxInputConstrained';
export * from './lib/hooks/useComboboxResetCursorToSelected';
32 changes: 32 additions & 0 deletions modules/react/combobox/lib/hooks/useComboboxInputArbitrary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from 'react';
import {
createElemPropsHook,
useLocalRef,
dispatchInputEvent,
} from '@workday/canvas-kit-react/common';
import {useComboboxModel} from './useComboboxModel';

/**
* An arbitrary combobox can have any value. The list of options are suggestions to aid the user in
* entering values. A Typeahead or Autocomplete are examples are arbitrary value comboboxes.
*/
export const useComboboxInputArbitrary = createElemPropsHook(useComboboxModel)((model, ref) => {
const {elementRef, localRef} = useLocalRef(ref as React.Ref<HTMLInputElement>);

// sync model selection state with inputs
React.useLayoutEffect(() => {
if (localRef.current) {
const formValue = (model.state.selectedIds === 'all' ? [] : model.state.selectedIds).join(
', '
);

if (formValue !== localRef.current.value) {
dispatchInputEvent(localRef.current, formValue);
}
}
}, [model.state.selectedIds, localRef]);

return {
ref: elementRef,
};
});
37 changes: 35 additions & 2 deletions modules/react/combobox/stories/Combobox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,33 @@ Authoring Practices Guide (APG):
Examples of a "combobox" would be date pickers, autocomplete, and select components.

## Combobox value restriction

A Combobox can either be either "constrained" or "arbitrary".

### Constrained

A constrained combobox can only have a value from a set of values presented as options. The user can
pick from these values, but cannot input a value outside the options provided. Constrained
comboboxes use the [useComboboxInputConstrained](#usecomboboxinputconstrained) hook and often have
two separate `input` elements.

- user input - This is the visible input. It should contain user-friend values. Calls to `.focus()`
or `.blur()` are redirected to this input. Any prop passed to the `*.Input` component that is not
directly related to forms will be passed here (i.e. `data-testid`, `aria-*`, etc).
- form input - This input is only visible to forms. The `value` will usually be server IDs. If the
combobox options don't have an ID and only use user values, the value of this input will be the
same as the user input. Any prop related to the function of forms will be passed here. For
exampple, the `name` attribute will be passed here. The `ref` will pointed to this element.

### Arbitrary

An arbitrary combobox allows the user to enter any value. The list of options are presented as
suggestions and selecting an option will prefill the combobox with the value of the option. The user
is still allowed to modify the combobox even after an option is entered. With arbitrary comboboxes,
there is only one `input` element. Arbitrary combobox inputs should use the
[useComboboxInputArbitrary](#usecomboboxinputarbirary) hook.

## Installation

```sh
Expand All @@ -30,12 +57,14 @@ yarn add @workday/canvas-kit-react
### Autocomplete

This example shows an Autocomplete example using `FormField`, `InputGroup`, and the `Combobox`
components to make an autocomplete form field. It uses `useComboboxLoader` to make mock API calls
using `setTimeout`. Your application may use
components to make an autocomplete form field. It uses [useComboboxLoader](#usecomboboxloader) to
make mock API calls using `setTimeout`. Your application may use
[fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API),
[WebSockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API), or other means of
communicating with a server.

An Autocomplete is an example of an arbitrary combobox.

<ExampleCodeBlock code={Autocomplete} />

### Custom Styles
Expand All @@ -51,3 +80,7 @@ our
## Hooks

<SymbolDoc name="useComboboxLoader" fileName="/react/" />

<SymbolDoc name="useComboboxInputConstrained" fileName="/react/" />

<SymbolDoc name="useComboboxInputArbitrary" fileName="/react/" />
74 changes: 41 additions & 33 deletions modules/react/combobox/stories/examples/Autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,20 @@ import {
createElemPropsHook,
createSubcomponent,
composeHooks,
ExtractProps,
} from '@workday/canvas-kit-react/common';
import {LoadReturn} from '@workday/canvas-kit-react/collection';
import {
Combobox,
useComboboxModel,
useComboboxLoader,
useComboboxInput,
useComboboxInputArbitrary,
} from '@workday/canvas-kit-react/combobox';
import {FormField} from '@workday/canvas-kit-react/form-field';
import {StyledMenuItem} from '@workday/canvas-kit-react/menu';
import {LoadingDots} from '@workday/canvas-kit-react/loading-dots';
import {InputGroup, TextInput} from '@workday/canvas-kit-react/text-input';
import {createStyles, px2rem} from '@workday/canvas-kit-styling';
import {createStencil, px2rem} from '@workday/canvas-kit-styling';
import {system} from '@workday/canvas-tokens-web';

const colors = ['Red', 'Blue', 'Purple', 'Green', 'Pink'];
Expand All @@ -36,30 +36,51 @@ const useAutocompleteInput = composeHooks(
},
};
}),
useComboboxInputArbitrary,
useComboboxInput
);

const loadingDotsStencil = createStencil({
base: {
transition: 'opacity 100ms ease',
'& [data-part="loading-dots"]': {
display: 'flex',
transform: 'scale(0.3)',
},
},
modifiers: {
isLoading: {
true: {
opacity: system.opacity.full,
},
false: {
opacity: system.opacity.zero,
},
},
},
});

const AutoCompleteInput = createSubcomponent(TextInput)({
modelHook: useComboboxModel,
elemPropsHook: useAutocompleteInput,
})<ExtractProps<typeof Combobox.Input, never>>((elemProps, Element) => {
return <Combobox.Input as={Element} {...elemProps} />;
})<{isLoading?: boolean}>(({isLoading, ...elemProps}, Element) => {
return (
<InputGroup>
<InputGroup.Input as={Element} {...elemProps} />
<InputGroup.InnerEnd
cs={loadingDotsStencil({isLoading})}
width={px2rem(20)}
data-loading={isLoading}
>
<LoadingDots data-part="loading-dots" />
</InputGroup.InnerEnd>
<InputGroup.InnerEnd>
<InputGroup.ClearButton data-testid="clear" />
</InputGroup.InnerEnd>
</InputGroup>
);
});

const styleOverrides = {
inputGroupInner: createStyles({
width: px2rem(20),
transition: 'opacity 100ms ease',
}),
loadingDots: createStyles({
display: 'flex',
transform: 'scale(0.3)',
}),
comboboxMenuList: createStyles({
maxHeight: px2rem(200),
}),
};

export const Autocomplete = () => {
const {model, loader} = useComboboxLoader(
{
Expand Down Expand Up @@ -109,27 +130,14 @@ export const Autocomplete = () => {
<FormField.Label>Fruit</FormField.Label>
<FormField.Field>
<Combobox model={model} onChange={event => console.log('input', event.currentTarget.value)}>
<InputGroup>
<InputGroup.Input as={FormField.Input.as(AutoCompleteInput)} />
<InputGroup.InnerEnd
cs={styleOverrides.inputGroupInner}
pointerEvents="none"
style={{opacity: loader.isLoading ? system.opacity.full : system.opacity.zero}}
data-loading={loader.isLoading}
>
<LoadingDots cs={styleOverrides.loadingDots} />
</InputGroup.InnerEnd>
<InputGroup.InnerEnd>
<InputGroup.ClearButton data-testid="clear" />
</InputGroup.InnerEnd>
</InputGroup>
<FormField.Input as={AutoCompleteInput} isLoading={loader.isLoading} />
<Combobox.Menu.Popper>
<Combobox.Menu.Card>
{model.state.items.length === 0 && (
<StyledMenuItem as="span">No Results Found</StyledMenuItem>
)}
{model.state.items.length > 0 && (
<Combobox.Menu.List cs={styleOverrides.comboboxMenuList}>
<Combobox.Menu.List maxHeight={px2rem(200)}>
{item => <Combobox.Menu.Item>{item}</Combobox.Menu.Item>}
</Combobox.Menu.List>
)}
Expand Down
7 changes: 7 additions & 0 deletions modules/react/common/lib/utils/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -980,6 +980,13 @@ export function composeHooks<M extends Model<any, any>, P extends {}, O extends
return hook(model, props, props.ref || ref);
}, props);

// remove null props values
for (const key in returnProps) {
if (returnProps[key] === null) {
delete returnProps[key];
}
}

if (!returnProps.hasOwnProperty('ref') && ref) {
// This is the weird "incoming ref isn't in props, but outgoing ref is in props" thing
returnProps.ref = ref;
Expand Down
4 changes: 2 additions & 2 deletions modules/react/common/spec/components.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -623,7 +623,7 @@ describe('composeHooks', () => {
expectTypeOf(props).toEqualTypeOf<Expected>();
});

it('should compose hooks with conflicting types with null values', () => {
it('should compose hooks with conflicting types with null values, removing null values', () => {
const useModel = config => ({state: {}, events: {}});
const useHook1 = createElemPropsHook(useModel)(model => ({foo: 'bar', item: null}));
const useHook2 = createElemPropsHook(useModel)(model => ({bar: 'baz', item: 'test'}));
Expand All @@ -633,7 +633,7 @@ describe('composeHooks', () => {

const elemProps = useHookComposed(useModel({}), {});

expect(elemProps).toEqual({foo: 'bar', bar: 'baz', item: null});
expect(elemProps).toEqual({foo: 'bar', bar: 'baz'});

expectTypeOf(elemProps).toHaveProperty('foo');
expectTypeOf(elemProps.foo).toBeString();
Expand Down
29 changes: 16 additions & 13 deletions modules/styling/lib/cs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,21 @@ type DefaultedVarsShape = Record<string, string> | Record<string, Record<string,
* maybeWrapCSSVariables('calc(--foo)'); // calc(var(--foo))
* ```
*/
export function maybeWrapCSSVariables(input: string): string {
// matches an string starting with `--` that isn't already wrapped in a `var()`. It tries to match
// any character that isn't a valid separator in CSS
return input.replace(
/([a-z]*[ (]*)(--[^\s;,'})]+)/gi,
(match: string, prefix: string, variable: string) => {
if (prefix === 'var(') {
return match;
export function maybeWrapCSSVariables<T>(input: T): T {
if (typeof input === 'string') {
// matches an string starting with `--` that isn't already wrapped in a `var()`. It tries to match
// any character that isn't a valid separator in CSS
return input.replace(
/([a-z]*[ (]*)(--[^\s;,'})]+)/gi,
(match: string, prefix: string, variable: string) => {
if (prefix === 'var(') {
return match;
}
return `${prefix}var(${variable})`;
}
return `${prefix}var(${variable})`;
}
);
) as T;
}
return input;
}

function convertProperty<T>(value: T): T {
Expand Down Expand Up @@ -549,9 +552,9 @@ export function csToProps(input: CSToPropsInput): CsToPropsReturn {
const cssVars: Record<string, string> = {};
for (const key in input) {
if (key.startsWith('--')) {
cssVars[key] = (input as any)[key];
cssVars[key] = maybeWrapCSSVariables((input as any)[key] || '');
} else {
(staticStyles as any)[key] = (input as any)[key];
(staticStyles as any)[key] = maybeWrapCSSVariables((input as any)[key] || '');
hasStaticStyles = true;
}
}
Expand Down

0 comments on commit b10addb

Please sign in to comment.