Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fire user’s onChange handler when we update the combobox input value internally #1916

Merged
merged 3 commits into from
Oct 10, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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