diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 4c69e2df21..b27cc57231 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -37,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure enter transitions work when using `unmount={false}` ([#1811](https://github.com/tailwindlabs/headlessui/pull/1811)) - Improve accessibility when announcing `Listbox.Option` and `Combobox.Option` components ([#1812](https://github.com/tailwindlabs/headlessui/pull/1812)) - Fix `ref` stealing from children ([#1820](https://github.com/tailwindlabs/headlessui/pull/1820)) +- Expose the `value` from the `Combobox` and `Listbox` components render prop ([#1822](https://github.com/tailwindlabs/headlessui/pull/1822)) ## [1.6.6] - 2022-07-07 diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx index 05946bfa17..cc67bc71bf 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx @@ -684,7 +684,7 @@ describe('Rendering', () => { assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, - textContent: JSON.stringify({ open: false, disabled: false }), + textContent: JSON.stringify({ open: false, disabled: false, value: 'test' }), }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) @@ -693,7 +693,7 @@ describe('Rendering', () => { assertComboboxButton({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-button-2' }, - textContent: JSON.stringify({ open: true, disabled: false }), + textContent: JSON.stringify({ open: true, disabled: false, value: 'test' }), }) assertComboboxList({ state: ComboboxState.Visible }) }) @@ -719,7 +719,7 @@ describe('Rendering', () => { assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, - textContent: JSON.stringify({ open: false, disabled: false }), + textContent: JSON.stringify({ open: false, disabled: false, value: 'test' }), }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) @@ -728,7 +728,7 @@ describe('Rendering', () => { assertComboboxButton({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-button-2' }, - textContent: JSON.stringify({ open: true, disabled: false }), + textContent: JSON.stringify({ open: true, disabled: false, value: 'test' }), }) assertComboboxList({ state: ComboboxState.Visible }) }) @@ -1036,6 +1036,75 @@ describe('Rendering', () => { expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'charlie' }) }) + it('should expose the value via the render prop', async () => { + let handleSubmission = jest.fn() + + let { getByTestId } = render( +
{ + e.preventDefault() + handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement))) + }} + > + + {({ value }) => ( + <> +
{value}
+ + + {({ value }) => ( + <> + Trigger +
{value}
+ + )} +
+ + Alice + Bob + Charlie + + + )} +
+ +
+ ) + + await click(document.getElementById('submit')) + + // No values + expect(handleSubmission).toHaveBeenLastCalledWith({}) + + // Open combobox + await click(getComboboxButton()) + + // Choose alice + await click(getComboboxOptions()[0]) + expect(getByTestId('value')).toHaveTextContent('alice') + expect(getByTestId('value-2')).toHaveTextContent('alice') + + // Submit + await click(document.getElementById('submit')) + + // Alice should be submitted + expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'alice' }) + + // Open combobox + await click(getComboboxButton()) + + // Choose charlie + await click(getComboboxOptions()[2]) + expect(getByTestId('value')).toHaveTextContent('charlie') + expect(getByTestId('value-2')).toHaveTextContent('charlie') + + // Submit + await click(document.getElementById('submit')) + + // Charlie should be submitted + expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'charlie' }) + }) + it('should be possible to provide a default value', async () => { let handleSubmission = jest.fn() diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 9e03a76d5d..c2c1338838 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -815,6 +815,7 @@ let DEFAULT_BUTTON_TAG = 'button' as const interface ButtonRenderPropArg { open: boolean disabled: boolean + value: any } type ButtonPropsWeControl = | 'id' @@ -896,7 +897,11 @@ let Button = forwardRefWithAs(function Button( - () => ({ open: data.comboboxState === ComboboxState.Open, disabled: data.disabled }), + () => ({ + open: data.comboboxState === ComboboxState.Open, + disabled: data.disabled, + value: data.value, + }), [data] ) let theirProps = props diff --git a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx index 1cb824903c..94742c0bdd 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx @@ -864,6 +864,74 @@ describe('Rendering', () => { expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'charlie' }) }) + it('should expose the value via the render prop', async () => { + let handleSubmission = jest.fn() + + let { getByTestId } = render( +
{ + e.preventDefault() + handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement))) + }} + > + + {({ value }) => ( + <> +
{value}
+ + {({ value }) => ( + <> + Trigger +
{value}
+ + )} +
+ + Alice + Bob + Charlie + + + )} +
+ +
+ ) + + await click(document.getElementById('submit')) + + // No values + expect(handleSubmission).toHaveBeenLastCalledWith({}) + + // Open listbox + await click(getListboxButton()) + + // Choose alice + await click(getListboxOptions()[0]) + expect(getByTestId('value')).toHaveTextContent('alice') + expect(getByTestId('value-2')).toHaveTextContent('alice') + + // Submit + await click(document.getElementById('submit')) + + // Alice should be submitted + expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'alice' }) + + // Open listbox + await click(getListboxButton()) + + // Choose charlie + await click(getListboxOptions()[2]) + expect(getByTestId('value')).toHaveTextContent('charlie') + expect(getByTestId('value-2')).toHaveTextContent('charlie') + + // Submit + await click(document.getElementById('submit')) + + // Charlie should be submitted + expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'charlie' }) + }) + it('should be possible to provide a default value', async () => { let handleSubmission = jest.fn() diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 2cfcf032fc..6d9250daab 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -299,10 +299,10 @@ function stateReducer(state: StateDefinition, action: Actions) { // --- let DEFAULT_LISTBOX_TAG = Fragment -interface ListboxRenderPropArg { +interface ListboxRenderPropArg { open: boolean disabled: boolean - value: TType + value: T } let ListboxRoot = forwardRefWithAs(function Listbox< @@ -461,6 +461,7 @@ let DEFAULT_BUTTON_TAG = 'button' as const interface ButtonRenderPropArg { open: boolean disabled: boolean + value: any } type ButtonPropsWeControl = | 'id' @@ -537,7 +538,11 @@ let Button = forwardRefWithAs(function Button( - () => ({ open: state.listboxState === ListboxStates.Open, disabled: state.disabled }), + () => ({ + open: state.listboxState === ListboxStates.Open, + disabled: state.disabled, + value: state.propsRef.current.value, + }), [state] ) let theirProps = props diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index cd7fdf2af2..12ec43a78a 100644 --- a/packages/@headlessui-vue/CHANGELOG.md +++ b/packages/@headlessui-vue/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Only restore focus to the `MenuButton` if necessary when activating a `MenuOption` ([#1782](https://github.com/tailwindlabs/headlessui/pull/1782)) - Don't scroll when wrapping around in focus trap ([#1789](https://github.com/tailwindlabs/headlessui/pull/1789)) - Improve accessibility when announcing `ListboxOption` and `ComboboxOption` components ([#1812](https://github.com/tailwindlabs/headlessui/pull/1812)) +- Expose the `value` from the `Combobox` and `Listbox` components slot ([#1822](https://github.com/tailwindlabs/headlessui/pull/1822)) ## [1.6.7] - 2022-07-12 diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts index 9b307d5eab..014c15ed55 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts @@ -713,7 +713,7 @@ describe('Rendering', () => { assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, - textContent: JSON.stringify({ open: false, disabled: false }), + textContent: JSON.stringify({ open: false, disabled: false, value: null }), }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) @@ -722,7 +722,7 @@ describe('Rendering', () => { assertComboboxButton({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-button-2' }, - textContent: JSON.stringify({ open: true, disabled: false }), + textContent: JSON.stringify({ open: true, disabled: false, value: null }), }) assertComboboxList({ state: ComboboxState.Visible }) }) @@ -751,7 +751,7 @@ describe('Rendering', () => { assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, - textContent: JSON.stringify({ open: false, disabled: false }), + textContent: JSON.stringify({ open: false, disabled: false, value: null }), }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) @@ -760,7 +760,7 @@ describe('Rendering', () => { assertComboboxButton({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-button-2' }, - textContent: JSON.stringify({ open: true, disabled: false }), + textContent: JSON.stringify({ open: true, disabled: false, value: null }), }) assertComboboxList({ state: ComboboxState.Visible }) }) @@ -1125,6 +1125,70 @@ describe('Rendering', () => { expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'charlie' }) }) + it('should expose the value via the render prop', async () => { + let handleSubmission = jest.fn() + + renderTemplate({ + template: html` +
+ +
{{value}}
+ + + Trigger +
{{value}}
+
+ + Alice + Bob + Charlie + +
+ +
+ `, + setup: () => ({ + handleSubmit(e: SubmitEvent) { + e.preventDefault() + handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement))) + }, + }), + }) + + await click(document.getElementById('submit')) + + // No values + expect(handleSubmission).toHaveBeenLastCalledWith({}) + + // Open combobox + await click(getComboboxButton()) + + // Choose alice + await click(getComboboxOptions()[0]) + expect(document.querySelector('[data-testid="value"]')).toHaveTextContent('alice') + expect(document.querySelector('[data-testid="value-2"]')).toHaveTextContent('alice') + + // Submit + await click(document.getElementById('submit')) + + // Alice should be submitted + expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'alice' }) + + // Open combobox + await click(getComboboxButton()) + + // Choose charlie + await click(getComboboxOptions()[2]) + expect(document.querySelector('[data-testid="value"]')).toHaveTextContent('charlie') + expect(document.querySelector('[data-testid="value-2"]')).toHaveTextContent('charlie') + + // Submit + await click(document.getElementById('submit')) + + // Charlie should be submitted + expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'charlie' }) + }) + it('should be possible to provide a default value', async () => { let handleSubmission = jest.fn() diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index 159a8d3b6c..b9c73b7127 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -413,6 +413,7 @@ export let Combobox = defineComponent({ disabled, activeIndex: api.activeOptionIndex.value, activeOption: activeOption.value, + value: value.value, } return h(Fragment, [ @@ -563,6 +564,7 @@ export let ComboboxButton = defineComponent({ let slot = { open: api.comboboxState.value === ComboboxStates.Open, disabled: api.disabled.value, + value: api.value.value, } let ourProps = { ref: api.buttonRef, diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx index c09f101f52..b9eb47d135 100644 --- a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx +++ b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx @@ -556,7 +556,7 @@ describe('Rendering', () => { assertListboxButton({ state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, - textContent: JSON.stringify({ open: false, disabled: false }), + textContent: JSON.stringify({ open: false, disabled: false, value: null }), }) assertListbox({ state: ListboxState.InvisibleUnmounted }) @@ -565,7 +565,7 @@ describe('Rendering', () => { assertListboxButton({ state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-button-1' }, - textContent: JSON.stringify({ open: true, disabled: false }), + textContent: JSON.stringify({ open: true, disabled: false, value: null }), }) assertListbox({ state: ListboxState.Visible }) }) @@ -593,7 +593,7 @@ describe('Rendering', () => { assertListboxButton({ state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, - textContent: JSON.stringify({ open: false, disabled: false }), + textContent: JSON.stringify({ open: false, disabled: false, value: null }), }) assertListbox({ state: ListboxState.InvisibleUnmounted }) @@ -602,7 +602,7 @@ describe('Rendering', () => { assertListboxButton({ state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-button-1' }, - textContent: JSON.stringify({ open: true, disabled: false }), + textContent: JSON.stringify({ open: true, disabled: false, value: null }), }) assertListbox({ state: ListboxState.Visible }) }) @@ -952,6 +952,69 @@ describe('Rendering', () => { expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'charlie' }) }) + it('should expose the value via the render prop', async () => { + let handleSubmission = jest.fn() + + renderTemplate({ + template: html` +
+ +
{{value}}
+ + Trigger +
{{value}}
+
+ + Alice + Bob + Charlie + +
+ +
+ `, + setup: () => ({ + handleSubmit(e: SubmitEvent) { + e.preventDefault() + handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement))) + }, + }), + }) + + await click(document.getElementById('submit')) + + // No values + expect(handleSubmission).toHaveBeenLastCalledWith({}) + + // Open listbox + await click(getListboxButton()) + + // Choose alice + await click(getListboxOptions()[0]) + expect(document.querySelector('[data-testid="value"]')).toHaveTextContent('alice') + expect(document.querySelector('[data-testid="value-2"]')).toHaveTextContent('alice') + + // Submit + await click(document.getElementById('submit')) + + // Alice should be submitted + expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'alice' }) + + // Open listbox + await click(getListboxButton()) + + // Choose charlie + await click(getListboxOptions()[2]) + expect(document.querySelector('[data-testid="value"]')).toHaveTextContent('charlie') + expect(document.querySelector('[data-testid="value-2"]')).toHaveTextContent('charlie') + + // Submit + await click(document.getElementById('submit')) + + // Charlie should be submitted + expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'charlie' }) + }) + it('should be possible to provide a default value', async () => { let handleSubmission = jest.fn() diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.ts b/packages/@headlessui-vue/src/components/listbox/listbox.ts index 44cb3b1c0a..70bacc15c2 100644 --- a/packages/@headlessui-vue/src/components/listbox/listbox.ts +++ b/packages/@headlessui-vue/src/components/listbox/listbox.ts @@ -330,7 +330,7 @@ export let Listbox = defineComponent({ return () => { let { name, modelValue, disabled, ...theirProps } = props - let slot = { open: listboxState.value === ListboxStates.Open, disabled } + let slot = { open: listboxState.value === ListboxStates.Open, disabled, value: value.value } return h(Fragment, [ ...(name != null && value.value != null @@ -475,7 +475,9 @@ export let ListboxButton = defineComponent({ let slot = { open: api.listboxState.value === ListboxStates.Open, disabled: api.disabled.value, + value: api.value.value, } + let ourProps = { ref: api.buttonRef, id,