Skip to content

Commit e9c9292

Browse files
authored
Add disabled to listbox (#229)
* allow to press on an element without focusing it first * add disabled option to the Listbox component
1 parent 9e0df9e commit e9c9292

File tree

6 files changed

+200
-77
lines changed

6 files changed

+200
-77
lines changed

packages/@headlessui-react/src/components/listbox/listbox.test.tsx

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,44 @@ describe('Rendering', () => {
120120
assertListbox({ state: ListboxState.Visible })
121121
})
122122
)
123+
124+
it(
125+
'should be possible to disable a Listbox',
126+
suppressConsoleLogs(async () => {
127+
render(
128+
<Listbox value={undefined} onChange={console.log} disabled>
129+
<Listbox.Button>Trigger</Listbox.Button>
130+
<Listbox.Options>
131+
<Listbox.Option value="a">Option A</Listbox.Option>
132+
<Listbox.Option value="b">Option B</Listbox.Option>
133+
<Listbox.Option value="c">Option C</Listbox.Option>
134+
</Listbox.Options>
135+
</Listbox>
136+
)
137+
138+
assertListboxButton({
139+
state: ListboxState.InvisibleUnmounted,
140+
attributes: { id: 'headlessui-listbox-button-1' },
141+
})
142+
assertListbox({ state: ListboxState.InvisibleUnmounted })
143+
144+
await click(getListboxButton())
145+
146+
assertListboxButton({
147+
state: ListboxState.InvisibleUnmounted,
148+
attributes: { id: 'headlessui-listbox-button-1' },
149+
})
150+
assertListbox({ state: ListboxState.InvisibleUnmounted })
151+
152+
await press(Keys.Enter, getListboxButton())
153+
154+
assertListboxButton({
155+
state: ListboxState.InvisibleUnmounted,
156+
attributes: { id: 'headlessui-listbox-button-1' },
157+
})
158+
assertListbox({ state: ListboxState.InvisibleUnmounted })
159+
})
160+
)
123161
})
124162

125163
describe('Listbox.Label', () => {
@@ -144,15 +182,15 @@ describe('Rendering', () => {
144182
})
145183
assertListboxLabel({
146184
attributes: { id: 'headlessui-listbox-label-1' },
147-
textContent: JSON.stringify({ open: false }),
185+
textContent: JSON.stringify({ open: false, disabled: false }),
148186
})
149187
assertListbox({ state: ListboxState.InvisibleUnmounted })
150188

151189
await click(getListboxButton())
152190

153191
assertListboxLabel({
154192
attributes: { id: 'headlessui-listbox-label-1' },
155-
textContent: JSON.stringify({ open: true }),
193+
textContent: JSON.stringify({ open: true, disabled: false }),
156194
})
157195
assertListbox({ state: ListboxState.Visible })
158196
assertListboxLabelLinkedWithListbox()
@@ -177,15 +215,15 @@ describe('Rendering', () => {
177215

178216
assertListboxLabel({
179217
attributes: { id: 'headlessui-listbox-label-1' },
180-
textContent: JSON.stringify({ open: false }),
218+
textContent: JSON.stringify({ open: false, disabled: false }),
181219
tag: 'p',
182220
})
183221
assertListbox({ state: ListboxState.InvisibleUnmounted })
184222

185223
await click(getListboxButton())
186224
assertListboxLabel({
187225
attributes: { id: 'headlessui-listbox-label-1' },
188-
textContent: JSON.stringify({ open: true }),
226+
textContent: JSON.stringify({ open: true, disabled: false }),
189227
tag: 'p',
190228
})
191229
assertListbox({ state: ListboxState.Visible })
@@ -211,7 +249,7 @@ describe('Rendering', () => {
211249
assertListboxButton({
212250
state: ListboxState.InvisibleUnmounted,
213251
attributes: { id: 'headlessui-listbox-button-1' },
214-
textContent: JSON.stringify({ open: false }),
252+
textContent: JSON.stringify({ open: false, disabled: false }),
215253
})
216254
assertListbox({ state: ListboxState.InvisibleUnmounted })
217255

@@ -220,7 +258,7 @@ describe('Rendering', () => {
220258
assertListboxButton({
221259
state: ListboxState.Visible,
222260
attributes: { id: 'headlessui-listbox-button-1' },
223-
textContent: JSON.stringify({ open: true }),
261+
textContent: JSON.stringify({ open: true, disabled: false }),
224262
})
225263
assertListbox({ state: ListboxState.Visible })
226264
})
@@ -245,7 +283,7 @@ describe('Rendering', () => {
245283
assertListboxButton({
246284
state: ListboxState.InvisibleUnmounted,
247285
attributes: { id: 'headlessui-listbox-button-1' },
248-
textContent: JSON.stringify({ open: false }),
286+
textContent: JSON.stringify({ open: false, disabled: false }),
249287
})
250288
assertListbox({ state: ListboxState.InvisibleUnmounted })
251289

@@ -254,7 +292,7 @@ describe('Rendering', () => {
254292
assertListboxButton({
255293
state: ListboxState.Visible,
256294
attributes: { id: 'headlessui-listbox-button-1' },
257-
textContent: JSON.stringify({ open: true }),
295+
textContent: JSON.stringify({ open: true, disabled: false }),
258296
})
259297
assertListbox({ state: ListboxState.Visible })
260298
})
@@ -559,8 +597,8 @@ describe('Keyboard interactions', () => {
559597
'should not be possible to open the listbox with Enter when the button is disabled',
560598
suppressConsoleLogs(async () => {
561599
render(
562-
<Listbox value={undefined} onChange={console.log}>
563-
<Listbox.Button disabled>Trigger</Listbox.Button>
600+
<Listbox value={undefined} onChange={console.log} disabled>
601+
<Listbox.Button>Trigger</Listbox.Button>
564602
<Listbox.Options>
565603
<Listbox.Option value="a">Option A</Listbox.Option>
566604
<Listbox.Option value="b">Option B</Listbox.Option>
@@ -1034,8 +1072,8 @@ describe('Keyboard interactions', () => {
10341072
'should not be possible to open the listbox with Space when the button is disabled',
10351073
suppressConsoleLogs(async () => {
10361074
render(
1037-
<Listbox value={undefined} onChange={console.log}>
1038-
<Listbox.Button disabled>Trigger</Listbox.Button>
1075+
<Listbox value={undefined} onChange={console.log} disabled>
1076+
<Listbox.Button>Trigger</Listbox.Button>
10391077
<Listbox.Options>
10401078
<Listbox.Option value="a">Option A</Listbox.Option>
10411079
<Listbox.Option value="b">Option B</Listbox.Option>
@@ -1506,8 +1544,8 @@ describe('Keyboard interactions', () => {
15061544
'should not be possible to open the listbox with ArrowDown when the button is disabled',
15071545
suppressConsoleLogs(async () => {
15081546
render(
1509-
<Listbox value={undefined} onChange={console.log}>
1510-
<Listbox.Button disabled>Trigger</Listbox.Button>
1547+
<Listbox value={undefined} onChange={console.log} disabled>
1548+
<Listbox.Button>Trigger</Listbox.Button>
15111549
<Listbox.Options>
15121550
<Listbox.Option value="a">Option A</Listbox.Option>
15131551
<Listbox.Option value="b">Option B</Listbox.Option>
@@ -1781,8 +1819,8 @@ describe('Keyboard interactions', () => {
17811819
'should not be possible to open the listbox with ArrowUp and the last option should be active when the button is disabled',
17821820
suppressConsoleLogs(async () => {
17831821
render(
1784-
<Listbox value={undefined} onChange={console.log}>
1785-
<Listbox.Button disabled>Trigger</Listbox.Button>
1822+
<Listbox value={undefined} onChange={console.log} disabled>
1823+
<Listbox.Button>Trigger</Listbox.Button>
17861824
<Listbox.Options>
17871825
<Listbox.Option value="a">Option A</Listbox.Option>
17881826
<Listbox.Option value="b">Option B</Listbox.Option>
@@ -2852,8 +2890,8 @@ describe('Mouse interactions', () => {
28522890
'should not be possible to open the listbox on click when the button is disabled',
28532891
suppressConsoleLogs(async () => {
28542892
render(
2855-
<Listbox value={undefined} onChange={console.log}>
2856-
<Listbox.Button disabled>Trigger</Listbox.Button>
2893+
<Listbox value={undefined} onChange={console.log} disabled>
2894+
<Listbox.Button>Trigger</Listbox.Button>
28572895
<Listbox.Options>
28582896
<Listbox.Option value="a">Option A</Listbox.Option>
28592897
<Listbox.Option value="b">Option B</Listbox.Option>

packages/@headlessui-react/src/components/listbox/listbox.tsx

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ interface StateDefinition {
4949
labelRef: MutableRefObject<HTMLLabelElement | null>
5050
buttonRef: MutableRefObject<HTMLButtonElement | null>
5151
optionsRef: MutableRefObject<HTMLUListElement | null>
52+
disabled: boolean
5253
options: { id: string; dataRef: ListboxOptionDataRef }[]
5354
searchQuery: string
5455
activeOptionIndex: number | null
@@ -58,6 +59,8 @@ enum ActionTypes {
5859
OpenListbox,
5960
CloseListbox,
6061

62+
SetDisabled,
63+
6164
GoToOption,
6265
Search,
6366
ClearSearch,
@@ -69,6 +72,7 @@ enum ActionTypes {
6972
type Actions =
7073
| { type: ActionTypes.CloseListbox }
7174
| { type: ActionTypes.OpenListbox }
75+
| { type: ActionTypes.SetDisabled; disabled: boolean }
7276
| { type: ActionTypes.GoToOption; focus: Focus.Specific; id: string }
7377
| { type: ActionTypes.GoToOption; focus: Exclude<Focus, Focus.Specific> }
7478
| { type: ActionTypes.Search; value: string }
@@ -82,13 +86,24 @@ let reducers: {
8286
action: Extract<Actions, { type: P }>
8387
) => StateDefinition
8488
} = {
85-
[ActionTypes.CloseListbox]: state => ({
86-
...state,
87-
activeOptionIndex: null,
88-
listboxState: ListboxStates.Closed,
89-
}),
90-
[ActionTypes.OpenListbox]: state => ({ ...state, listboxState: ListboxStates.Open }),
91-
[ActionTypes.GoToOption]: (state, action) => {
89+
[ActionTypes.CloseListbox](state) {
90+
if (state.disabled) return state
91+
if (state.listboxState === ListboxStates.Closed) return state
92+
return { ...state, activeOptionIndex: null, listboxState: ListboxStates.Closed }
93+
},
94+
[ActionTypes.OpenListbox](state) {
95+
if (state.disabled) return state
96+
if (state.listboxState === ListboxStates.Open) return state
97+
return { ...state, listboxState: ListboxStates.Open }
98+
},
99+
[ActionTypes.SetDisabled](state, action) {
100+
if (state.disabled === action.disabled) return state
101+
return { ...state, disabled: action.disabled }
102+
},
103+
[ActionTypes.GoToOption](state, action) {
104+
if (state.disabled) return state
105+
if (state.listboxState === ListboxStates.Closed) return state
106+
92107
let activeOptionIndex = calculateActiveIndex(action, {
93108
resolveItems: () => state.options,
94109
resolveActiveIndex: () => state.activeOptionIndex,
@@ -100,6 +115,9 @@ let reducers: {
100115
return { ...state, searchQuery: '', activeOptionIndex }
101116
},
102117
[ActionTypes.Search]: (state, action) => {
118+
if (state.disabled) return state
119+
if (state.listboxState === ListboxStates.Closed) return state
120+
103121
let searchQuery = state.searchQuery + action.value
104122
let match = state.options.findIndex(
105123
option =>
@@ -110,7 +128,12 @@ let reducers: {
110128
if (match === -1 || match === state.activeOptionIndex) return { ...state, searchQuery }
111129
return { ...state, searchQuery, activeOptionIndex: match }
112130
},
113-
[ActionTypes.ClearSearch]: state => ({ ...state, searchQuery: '' }),
131+
[ActionTypes.ClearSearch](state) {
132+
if (state.disabled) return state
133+
if (state.listboxState === ListboxStates.Closed) return state
134+
if (state.searchQuery === '') return state
135+
return { ...state, searchQuery: '' }
136+
},
114137
[ActionTypes.RegisterOption]: (state, action) => ({
115138
...state,
116139
options: [...state.options, { id: action.id, dataRef: action.dataRef }],
@@ -161,22 +184,25 @@ function stateReducer(state: StateDefinition, action: Actions) {
161184
let DEFAULT_LISTBOX_TAG = Fragment
162185
interface ListboxRenderPropArg {
163186
open: boolean
187+
disabled: boolean
164188
}
165189

166190
export function Listbox<TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG, TType = string>(
167191
props: Props<TTag, ListboxRenderPropArg, 'value' | 'onChange'> & {
168192
value: TType
169193
onChange(value: TType): void
194+
disabled?: boolean
170195
}
171196
) {
172-
let { value, onChange, ...passThroughProps } = props
197+
let { value, onChange, disabled = false, ...passThroughProps } = props
173198
let d = useDisposables()
174199
let reducerBag = useReducer(stateReducer, {
175200
listboxState: ListboxStates.Closed,
176201
propsRef: { current: { value, onChange } },
177202
labelRef: createRef(),
178203
buttonRef: createRef(),
179204
optionsRef: createRef(),
205+
disabled,
180206
options: [],
181207
searchQuery: '',
182208
activeOptionIndex: null,
@@ -189,6 +215,7 @@ export function Listbox<TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG, T
189215
useIsoMorphicEffect(() => {
190216
propsRef.current.onChange = onChange
191217
}, [onChange, propsRef])
218+
useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetDisabled, disabled }), [disabled])
192219

193220
useEffect(() => {
194221
function handler(event: MouseEvent) {
@@ -208,8 +235,8 @@ export function Listbox<TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG, T
208235
}, [listboxState, optionsRef, buttonRef, d, dispatch])
209236

210237
let propsBag = useMemo<ListboxRenderPropArg>(
211-
() => ({ open: listboxState === ListboxStates.Open }),
212-
[listboxState]
238+
() => ({ open: listboxState === ListboxStates.Open, disabled }),
239+
[listboxState, disabled]
213240
)
214241

215242
return (
@@ -224,6 +251,7 @@ export function Listbox<TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG, T
224251
let DEFAULT_BUTTON_TAG = 'button' as const
225252
interface ButtonRenderPropArg {
226253
open: boolean
254+
disabled: boolean
227255
}
228256
type ButtonPropsWeControl =
229257
| 'id'
@@ -232,6 +260,7 @@ type ButtonPropsWeControl =
232260
| 'aria-controls'
233261
| 'aria-expanded'
234262
| 'aria-labelledby'
263+
| 'disabled'
235264
| 'onKeyDown'
236265
| 'onClick'
237266

@@ -279,7 +308,6 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
279308
let handleClick = useCallback(
280309
(event: ReactMouseEvent) => {
281310
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
282-
if (props.disabled) return
283311
if (state.listboxState === ListboxStates.Open) {
284312
dispatch({ type: ActionTypes.CloseListbox })
285313
d.nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
@@ -289,7 +317,7 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
289317
d.nextFrame(() => state.optionsRef.current?.focus({ preventScroll: true }))
290318
}
291319
},
292-
[dispatch, d, state, props.disabled]
320+
[dispatch, d, state]
293321
)
294322

295323
let labelledby = useComputed(() => {
@@ -298,7 +326,7 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
298326
}, [state.labelRef.current, id])
299327

300328
let propsBag = useMemo<ButtonRenderPropArg>(
301-
() => ({ open: state.listboxState === ListboxStates.Open }),
329+
() => ({ open: state.listboxState === ListboxStates.Open, disabled: state.disabled }),
302330
[state]
303331
)
304332
let passthroughProps = props
@@ -310,6 +338,7 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
310338
'aria-controls': state.optionsRef.current?.id,
311339
'aria-expanded': state.listboxState === ListboxStates.Open ? true : undefined,
312340
'aria-labelledby': labelledby,
341+
disabled: state.disabled,
313342
onKeyDown: handleKeyDown,
314343
onClick: handleClick,
315344
}
@@ -322,6 +351,7 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
322351
let DEFAULT_LABEL_TAG = 'label' as const
323352
interface LabelRenderPropArg {
324353
open: boolean
354+
disabled: boolean
325355
}
326356
type LabelPropsWeControl = 'id' | 'ref' | 'onClick'
327357

@@ -336,7 +366,7 @@ function Label<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
336366
])
337367

338368
let propsBag = useMemo<OptionsRenderPropArg>(
339-
() => ({ open: state.listboxState === ListboxStates.Open }),
369+
() => ({ open: state.listboxState === ListboxStates.Open, disabled: state.disabled }),
340370
[state]
341371
)
342372
let propsWeControl = { ref: state.labelRef, id, onClick: handleClick }

packages/@headlessui-react/src/test-utils/interactions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,8 @@ export async function type(events: Partial<KeyboardEvent>[], element = document.
166166
}
167167
}
168168

169-
export async function press(event: Partial<KeyboardEvent>) {
170-
return type([event])
169+
export async function press(event: Partial<KeyboardEvent>, element = document.activeElement) {
170+
return type([event], element)
171171
}
172172

173173
export enum MouseButton {

0 commit comments

Comments
 (0)