Fire user’s onChange handler when we update the combobox input value internally (#1916)
* Fire user’s onChange handler when we update the input value internally * Update changelog * Fix CS
This commit is contained in:
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Fix `<Popover.Button as={Fragment} />` crash ([#1889](https://github.com/tailwindlabs/headlessui/pull/1889))
|
||||
- Expose `close` function for `Menu` and `Menu.Item` components ([#1897](https://github.com/tailwindlabs/headlessui/pull/1897))
|
||||
- Fix `useOutsideClick`, add improvements for ShadowDOM ([#1914](https://github.com/tailwindlabs/headlessui/pull/1914))
|
||||
- Fire `<Combobox.Input>`'s `onChange` handler when changing the value internally ([#1916](https://github.com/tailwindlabs/headlessui/pull/1916))
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
@@ -2851,6 +2851,56 @@ describe('Keyboard interactions', () => {
|
||||
expect(getComboboxInput()?.value).toBe('option-b')
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'The onChange handler is fired when the input value is changed internally',
|
||||
suppressConsoleLogs(async () => {
|
||||
let currentSearchQuery: string = ''
|
||||
|
||||
render(
|
||||
<Combobox value={null} onChange={console.log}>
|
||||
<Combobox.Input
|
||||
onChange={(event) => {
|
||||
currentSearchQuery = event.target.value
|
||||
}}
|
||||
/>
|
||||
<Combobox.Button>Trigger</Combobox.Button>
|
||||
<Combobox.Options>
|
||||
<Combobox.Option value="option-a">Option A</Combobox.Option>
|
||||
<Combobox.Option value="option-b">Option B</Combobox.Option>
|
||||
<Combobox.Option value="option-c">Option C</Combobox.Option>
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
)
|
||||
|
||||
// Open combobox
|
||||
await click(getComboboxButton())
|
||||
|
||||
// Verify that the current search query is empty
|
||||
expect(currentSearchQuery).toBe('')
|
||||
|
||||
// Look for "Option C"
|
||||
await type(word('Option C'), getComboboxInput())
|
||||
|
||||
// The input should be updated
|
||||
expect(getComboboxInput()?.value).toBe('Option C')
|
||||
|
||||
// The current search query should reflect the input value
|
||||
expect(currentSearchQuery).toBe('Option C')
|
||||
|
||||
// Close combobox
|
||||
await press(Keys.Escape)
|
||||
|
||||
// The input should be empty
|
||||
expect(getComboboxInput()?.value).toBe('')
|
||||
|
||||
// The current search query should be empty like the input
|
||||
expect(currentSearchQuery).toBe('')
|
||||
|
||||
// The combobox should be closed
|
||||
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('`ArrowDown` key', () => {
|
||||
@@ -5501,7 +5551,7 @@ describe('Form compatibility', () => {
|
||||
}}
|
||||
>
|
||||
<Combobox value={value} onChange={setValue} name="delivery">
|
||||
<Combobox.Input onChange={console.log} />
|
||||
<Combobox.Input onChange={NOOP} />
|
||||
<Combobox.Button>Trigger</Combobox.Button>
|
||||
<Combobox.Label>Pizza Delivery</Combobox.Label>
|
||||
<Combobox.Options>
|
||||
|
||||
@@ -666,6 +666,33 @@ let Input = forwardRefWithAs(function Input<
|
||||
let id = `headlessui-combobox-input-${useId()}`
|
||||
let d = useDisposables()
|
||||
|
||||
let shouldIgnoreOpenOnChange = false
|
||||
function updateInputAndNotify(newValue: string) {
|
||||
let input = data.inputRef.current
|
||||
if (!input) {
|
||||
return
|
||||
}
|
||||
|
||||
// The value is already the same, so we can bail out early
|
||||
if (input.value === newValue) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip React's value setting which causes the input event to not be fired because it de-dupes input/change events
|
||||
let descriptor = Object.getOwnPropertyDescriptor(input.constructor.prototype, 'value')
|
||||
descriptor?.set?.call(input, newValue)
|
||||
|
||||
// Fire an input event which causes the browser to trigger the user's `onChange` handler.
|
||||
// We have to prevent the combobox from opening when this happens. Since these events
|
||||
// fire synchronously `shouldIgnoreOpenOnChange` will be correct during `handleChange`
|
||||
shouldIgnoreOpenOnChange = true
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
// Now we can inform react that the input value has changed
|
||||
input.value = newValue
|
||||
shouldIgnoreOpenOnChange = false
|
||||
}
|
||||
|
||||
let currentValue = useMemo(() => {
|
||||
if (typeof displayValue === 'function') {
|
||||
return displayValue(data.value as unknown as TType) ?? ''
|
||||
@@ -682,7 +709,7 @@ let Input = forwardRefWithAs(function Input<
|
||||
([currentValue, state], [oldCurrentValue, oldState]) => {
|
||||
if (!data.inputRef.current) return
|
||||
if (oldState === ComboboxState.Open && state === ComboboxState.Closed) {
|
||||
data.inputRef.current.value = currentValue
|
||||
updateInputAndNotify(currentValue)
|
||||
} else if (currentValue !== oldCurrentValue) {
|
||||
data.inputRef.current.value = currentValue
|
||||
}
|
||||
@@ -787,7 +814,9 @@ let Input = forwardRefWithAs(function Input<
|
||||
})
|
||||
|
||||
let handleChange = useEvent((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
actions.openCombobox()
|
||||
if (!shouldIgnoreOpenOnChange) {
|
||||
actions.openCombobox()
|
||||
}
|
||||
onChange?.(event)
|
||||
})
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Expose `close` function for `Menu` and `MenuItem` components ([#1897](https://github.com/tailwindlabs/headlessui/pull/1897))
|
||||
- Fix `useOutsideClick`, add improvements for ShadowDOM ([#1914](https://github.com/tailwindlabs/headlessui/pull/1914))
|
||||
- Prevent default slot warning when using a component for `as` prop ([#1915](https://github.com/tailwindlabs/headlessui/pull/1915))
|
||||
- Fire `<ComboboxInput>`'s `@change` handler when changing the value internally ([#1916](https://github.com/tailwindlabs/headlessui/pull/1916))
|
||||
|
||||
## [1.7.3] - 2022-09-30
|
||||
|
||||
|
||||
@@ -2931,6 +2931,60 @@ describe('Keyboard interactions', () => {
|
||||
expect(getComboboxInput()?.value).toBe('option-b')
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'The onChange handler is fired when the input value is changed internally',
|
||||
suppressConsoleLogs(async () => {
|
||||
let currentSearchQuery: string = ''
|
||||
|
||||
renderTemplate({
|
||||
template: html`
|
||||
<Combobox v-model="value">
|
||||
<ComboboxInput @change="onChange" />
|
||||
<ComboboxButton>Trigger</ComboboxButton>
|
||||
<ComboboxOptions>
|
||||
<ComboboxOption value="option-a">Option A</ComboboxOption>
|
||||
<ComboboxOption value="option-b">Option B</ComboboxOption>
|
||||
<ComboboxOption value="option-c">Option C</ComboboxOption>
|
||||
</ComboboxOptions>
|
||||
</Combobox>
|
||||
`,
|
||||
setup: () => ({
|
||||
value: ref(null),
|
||||
onChange: (evt: InputEvent & { target: HTMLInputElement }) => {
|
||||
currentSearchQuery = evt.target.value
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
// Open combobox
|
||||
await click(getComboboxButton())
|
||||
|
||||
// Verify that the current search query is empty
|
||||
expect(currentSearchQuery).toBe('')
|
||||
|
||||
// Look for "Option C"
|
||||
await type(word('Option C'), getComboboxInput())
|
||||
|
||||
// The input should be updated
|
||||
expect(getComboboxInput()?.value).toBe('Option C')
|
||||
|
||||
// The current search query should reflect the input value
|
||||
expect(currentSearchQuery).toBe('Option C')
|
||||
|
||||
// Close combobox
|
||||
await press(Keys.Escape)
|
||||
|
||||
// The input should be empty
|
||||
expect(getComboboxInput()?.value).toBe('')
|
||||
|
||||
// The current search query should be empty like the input
|
||||
expect(currentSearchQuery).toBe('')
|
||||
|
||||
// The combobox should be closed
|
||||
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('`ArrowDown` key', () => {
|
||||
|
||||
@@ -632,6 +632,23 @@ export let ComboboxInput = defineComponent({
|
||||
// Workaround Vue bug where watching [ref(undefined)] is not fired immediately even when value is true
|
||||
const __fixVueImmediateWatchBug__ = ref('')
|
||||
|
||||
let shouldIgnoreOpenOnChange = false
|
||||
function updateInputAndNotify(currentValue: string) {
|
||||
let input = dom(api.inputRef)
|
||||
if (!input) {
|
||||
return
|
||||
}
|
||||
|
||||
input.value = currentValue
|
||||
|
||||
// Fire an input event which causes the browser to trigger the user's `onChange` handler.
|
||||
// We have to prevent the combobox from opening when this happens. Since these events
|
||||
// fire synchronously `shouldIgnoreOpenOnChange` will be correct during `handleChange`
|
||||
shouldIgnoreOpenOnChange = true
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
shouldIgnoreOpenOnChange = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
watch(
|
||||
[api.value, __fixVueImmediateWatchBug__],
|
||||
@@ -650,7 +667,7 @@ export let ComboboxInput = defineComponent({
|
||||
let input = dom(api.inputRef)
|
||||
if (!input) return
|
||||
if (oldState === ComboboxStates.Open && state === ComboboxStates.Closed) {
|
||||
input.value = currentValue
|
||||
updateInputAndNotify(currentValue)
|
||||
} else if (currentValue !== oldCurrentValue) {
|
||||
input.value = currentValue
|
||||
}
|
||||
@@ -756,7 +773,9 @@ export let ComboboxInput = defineComponent({
|
||||
}
|
||||
|
||||
function handleInput(event: Event & { target: HTMLInputElement }) {
|
||||
api.openCombobox()
|
||||
if (!shouldIgnoreOpenOnChange) {
|
||||
api.openCombobox()
|
||||
}
|
||||
emit('change', event)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user