-
Notifications
You must be signed in to change notification settings - Fork 221
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor(select): Fix all syncing issues with the Select component (#…
…2983) The `useComboboxInputConstrained` hook was created for Comboboxes that can only have a value from within a set of defined options. The value is constrained by the options passed to it. `useComboboxInputConstrained` assumes there will be multiple inputs where one is presented to the user and the other is for the form/server. The hook keeps the model in sync with external ways to manipulate the input: React `value` prop ([controlled inputs](https://react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable)), browser/extension autofill, and testing tools. `Select` was updated to use `useComboboxInputConstrained` which fixes several bugs at once: - Fixes #2888 - Fixes #2675 - Fixes #2616 - Fixes #2533 [category:Components]
- Loading branch information
1 parent
b361815
commit c1e0252
Showing
21 changed files
with
495 additions
and
236 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
32 changes: 32 additions & 0 deletions
32
modules/react/combobox/lib/hooks/useComboboxInputArbitrary.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; | ||
}); |
157 changes: 157 additions & 0 deletions
157
modules/react/combobox/lib/hooks/useComboboxInputConstrained.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,157 @@ | ||
import React from 'react'; | ||
import { | ||
createElemPropsHook, | ||
useLocalRef, | ||
dispatchInputEvent, | ||
} from '@workday/canvas-kit-react/common'; | ||
import {useComboboxModel} from './useComboboxModel'; | ||
|
||
function onlyDefined<T>(input: T | undefined): input is T { | ||
return !!input; | ||
} | ||
|
||
/** | ||
* A constrained combobox input can only offer values that are part of the provided list of `items`. | ||
* The default is an unconstrained. A constrained input should have both a form input that is hidden | ||
* from the user as well as a user input that will be visible to the user. This hook is in charge of | ||
* keeping the inputs and the model in sync with each other and working with a browser's | ||
* autocomplete, form libraries and the model. | ||
*/ | ||
export const useComboboxInputConstrained = createElemPropsHook(useComboboxModel)( | ||
( | ||
model, | ||
ref, | ||
{ | ||
disabled, | ||
value: reactValue, | ||
onChange, | ||
name, | ||
}: Pick< | ||
React.InputHTMLAttributes<HTMLInputElement>, | ||
'disabled' | 'value' | 'onChange' | 'name' | ||
> = {} | ||
) => { | ||
// The user element is what the user sees | ||
const {elementRef: userElementRef, localRef: userLocalRef} = useLocalRef( | ||
model.state.targetRef as React.Ref<HTMLInputElement> | ||
); | ||
|
||
// The form element is what is seen in `FormData` during for submission to the server | ||
const {elementRef: formElementRef, localRef: formLocalRef} = useLocalRef( | ||
ref as React.Ref<HTMLInputElement> | ||
); | ||
|
||
// Create React refs so we can get the current value inside an Effect without using those values | ||
// as part of the dependency array. | ||
const modelNavigationRef = React.useRef(model.navigation); | ||
modelNavigationRef.current = model.navigation; | ||
const modelStateRef = React.useRef(model.state); | ||
modelStateRef.current = model.state; | ||
|
||
// Watch the `value` prop passed from React props and update the model accordingly | ||
React.useLayoutEffect(() => { | ||
if (formLocalRef.current && typeof reactValue === 'string') { | ||
// const value = formLocalRef.current.value; | ||
if (reactValue !== formLocalRef.current.value) { | ||
model.events.setSelectedIds(reactValue ? reactValue.split(', ') : []); | ||
} | ||
} | ||
}, [reactValue, formLocalRef, model.events]); | ||
|
||
// useImperativeHandle allows us to modify the `ref` before it is sent to the application, | ||
// but after it is defined. We can add value watches, and redirect methods here. | ||
React.useImperativeHandle( | ||
formElementRef, | ||
() => { | ||
if (formLocalRef.current) { | ||
// Hook into the DOM `value` property of the form input element and update the model | ||
// accordingly | ||
Object.defineProperty(formLocalRef.current, 'value', { | ||
get() { | ||
const value = Object.getOwnPropertyDescriptor( | ||
Object.getPrototypeOf(formLocalRef.current), | ||
'value' | ||
)?.get?.call(formLocalRef.current); | ||
return value; | ||
}, | ||
set(value: string) { | ||
if ( | ||
formLocalRef.current && | ||
value !== | ||
(modelStateRef.current.selectedIds === 'all' | ||
? [] | ||
: modelStateRef.current.selectedIds | ||
).join(', ') | ||
) { | ||
model.events.setSelectedIds(value ? value.split(', ') : []); | ||
} | ||
}, | ||
}); | ||
|
||
// forward calls to `.focus()` and `.blur()` to the user input | ||
formLocalRef.current.focus = (options?: FocusOptions) => { | ||
userLocalRef.current!.focus(options); | ||
}; | ||
formLocalRef.current.blur = () => { | ||
userLocalRef.current!.blur(); | ||
}; | ||
} | ||
return formLocalRef.current!; | ||
}, | ||
[formLocalRef, userLocalRef, model.events] | ||
); | ||
|
||
// sync model selection state with inputs | ||
React.useLayoutEffect(() => { | ||
if (userLocalRef.current) { | ||
const userValue = | ||
model.state.items.length === 0 | ||
? '' | ||
: (model.state.selectedIds === 'all' | ||
? [] | ||
: model.state.selectedIds | ||
.map(id => | ||
modelNavigationRef.current.getItem(id, {state: modelStateRef.current}) | ||
) | ||
.filter(onlyDefined) | ||
.map(item => item.textValue) | ||
).join(', '); | ||
|
||
if (userValue !== userLocalRef.current.value) { | ||
dispatchInputEvent(userLocalRef.current, userValue); | ||
} | ||
} | ||
|
||
if (formLocalRef.current) { | ||
const formValue = (model.state.selectedIds === 'all' ? [] : model.state.selectedIds).join( | ||
', ' | ||
); | ||
|
||
if (formValue !== formLocalRef.current.value) { | ||
dispatchInputEvent(formLocalRef.current, formValue); | ||
} | ||
} | ||
}, [model.state.selectedIds, model.state.items, formLocalRef, userLocalRef]); | ||
|
||
// The props here will go to the user input. | ||
return { | ||
ref: userElementRef, | ||
form: '', // We don't want the user input to be part of the form [elements](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/elements) | ||
value: null, | ||
onChange: null, | ||
name: null, | ||
disabled, | ||
/** | ||
* These props should be spread onto the form input. | ||
*/ | ||
formInputProps: { | ||
disabled, | ||
tabIndex: -1, | ||
'aria-hidden': true, | ||
ref: formElementRef, | ||
onChange, | ||
name, | ||
}, | ||
}; | ||
} | ||
); |
Oops, something went wrong.