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:
Jordan Pittman
2022-10-10 15:17:52 -04:00
committed by GitHub
parent 17de0a29c7
commit ab78fbd91e
6 changed files with 159 additions and 5 deletions
+1
View File
@@ -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)
})
+1
View File
@@ -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)
}