Skip to content

Commit

Permalink
Fire user’s onChange handler when we update the combobox input value …
Browse files Browse the repository at this point in the history
…internally (#1916)

* Fire user’s onChange handler when we update the input value internally

* Update changelog

* Fix CS
  • Loading branch information
thecrypticace authored Oct 10, 2022
1 parent 17de0a2 commit ab78fbd
Show file tree
Hide file tree
Showing 6 changed files with 159 additions and 5 deletions.
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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>
Expand Down
33 changes: 31 additions & 2 deletions packages/@headlessui-react/src/components/combobox/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) ?? ''
Expand All @@ -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
}
Expand Down Expand Up @@ -787,7 +814,9 @@ let Input = forwardRefWithAs(function Input<
})

let handleChange = useEvent((event: React.ChangeEvent<HTMLInputElement>) => {
actions.openCombobox()
if (!shouldIgnoreOpenOnChange) {
actions.openCombobox()
}
onChange?.(event)
})

Expand Down
1 change: 1 addition & 0 deletions packages/@headlessui-vue/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
54 changes: 54 additions & 0 deletions packages/@headlessui-vue/src/components/combobox/combobox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
23 changes: 21 additions & 2 deletions packages/@headlessui-vue/src/components/combobox/combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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__],
Expand All @@ -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
}
Expand Down Expand Up @@ -756,7 +773,9 @@ export let ComboboxInput = defineComponent({
}

function handleInput(event: Event & { target: HTMLInputElement }) {
api.openCombobox()
if (!shouldIgnoreOpenOnChange) {
api.openCombobox()
}
emit('change', event)
}

Expand Down

0 comments on commit ab78fbd

Please sign in to comment.