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

Add disabled to listbox #229

Merged
merged 2 commits into from
Feb 5, 2021
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
74 changes: 56 additions & 18 deletions packages/@headlessui-react/src/components/listbox/listbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,44 @@ describe('Rendering', () => {
assertListbox({ state: ListboxState.Visible })
})
)

it(
'should be possible to disable a Listbox',
suppressConsoleLogs(async () => {
render(
<Listbox value={undefined} onChange={console.log} disabled>
<Listbox.Button>Trigger</Listbox.Button>
<Listbox.Options>
<Listbox.Option value="a">Option A</Listbox.Option>
<Listbox.Option value="b">Option B</Listbox.Option>
<Listbox.Option value="c">Option C</Listbox.Option>
</Listbox.Options>
</Listbox>
)

assertListboxButton({
state: ListboxState.InvisibleUnmounted,
attributes: { id: 'headlessui-listbox-button-1' },
})
assertListbox({ state: ListboxState.InvisibleUnmounted })

await click(getListboxButton())

assertListboxButton({
state: ListboxState.InvisibleUnmounted,
attributes: { id: 'headlessui-listbox-button-1' },
})
assertListbox({ state: ListboxState.InvisibleUnmounted })

await press(Keys.Enter, getListboxButton())

assertListboxButton({
state: ListboxState.InvisibleUnmounted,
attributes: { id: 'headlessui-listbox-button-1' },
})
assertListbox({ state: ListboxState.InvisibleUnmounted })
})
)
})

describe('Listbox.Label', () => {
Expand All @@ -144,15 +182,15 @@ describe('Rendering', () => {
})
assertListboxLabel({
attributes: { id: 'headlessui-listbox-label-1' },
textContent: JSON.stringify({ open: false }),
textContent: JSON.stringify({ open: false, disabled: false }),
})
assertListbox({ state: ListboxState.InvisibleUnmounted })

await click(getListboxButton())

assertListboxLabel({
attributes: { id: 'headlessui-listbox-label-1' },
textContent: JSON.stringify({ open: true }),
textContent: JSON.stringify({ open: true, disabled: false }),
})
assertListbox({ state: ListboxState.Visible })
assertListboxLabelLinkedWithListbox()
Expand All @@ -177,15 +215,15 @@ describe('Rendering', () => {

assertListboxLabel({
attributes: { id: 'headlessui-listbox-label-1' },
textContent: JSON.stringify({ open: false }),
textContent: JSON.stringify({ open: false, disabled: false }),
tag: 'p',
})
assertListbox({ state: ListboxState.InvisibleUnmounted })

await click(getListboxButton())
assertListboxLabel({
attributes: { id: 'headlessui-listbox-label-1' },
textContent: JSON.stringify({ open: true }),
textContent: JSON.stringify({ open: true, disabled: false }),
tag: 'p',
})
assertListbox({ state: ListboxState.Visible })
Expand All @@ -211,7 +249,7 @@ describe('Rendering', () => {
assertListboxButton({
state: ListboxState.InvisibleUnmounted,
attributes: { id: 'headlessui-listbox-button-1' },
textContent: JSON.stringify({ open: false }),
textContent: JSON.stringify({ open: false, disabled: false }),
})
assertListbox({ state: ListboxState.InvisibleUnmounted })

Expand All @@ -220,7 +258,7 @@ describe('Rendering', () => {
assertListboxButton({
state: ListboxState.Visible,
attributes: { id: 'headlessui-listbox-button-1' },
textContent: JSON.stringify({ open: true }),
textContent: JSON.stringify({ open: true, disabled: false }),
})
assertListbox({ state: ListboxState.Visible })
})
Expand All @@ -245,7 +283,7 @@ describe('Rendering', () => {
assertListboxButton({
state: ListboxState.InvisibleUnmounted,
attributes: { id: 'headlessui-listbox-button-1' },
textContent: JSON.stringify({ open: false }),
textContent: JSON.stringify({ open: false, disabled: false }),
})
assertListbox({ state: ListboxState.InvisibleUnmounted })

Expand All @@ -254,7 +292,7 @@ describe('Rendering', () => {
assertListboxButton({
state: ListboxState.Visible,
attributes: { id: 'headlessui-listbox-button-1' },
textContent: JSON.stringify({ open: true }),
textContent: JSON.stringify({ open: true, disabled: false }),
})
assertListbox({ state: ListboxState.Visible })
})
Expand Down Expand Up @@ -559,8 +597,8 @@ describe('Keyboard interactions', () => {
'should not be possible to open the listbox with Enter when the button is disabled',
suppressConsoleLogs(async () => {
render(
<Listbox value={undefined} onChange={console.log}>
<Listbox.Button disabled>Trigger</Listbox.Button>
<Listbox value={undefined} onChange={console.log} disabled>
<Listbox.Button>Trigger</Listbox.Button>
<Listbox.Options>
<Listbox.Option value="a">Option A</Listbox.Option>
<Listbox.Option value="b">Option B</Listbox.Option>
Expand Down Expand Up @@ -1034,8 +1072,8 @@ describe('Keyboard interactions', () => {
'should not be possible to open the listbox with Space when the button is disabled',
suppressConsoleLogs(async () => {
render(
<Listbox value={undefined} onChange={console.log}>
<Listbox.Button disabled>Trigger</Listbox.Button>
<Listbox value={undefined} onChange={console.log} disabled>
<Listbox.Button>Trigger</Listbox.Button>
<Listbox.Options>
<Listbox.Option value="a">Option A</Listbox.Option>
<Listbox.Option value="b">Option B</Listbox.Option>
Expand Down Expand Up @@ -1506,8 +1544,8 @@ describe('Keyboard interactions', () => {
'should not be possible to open the listbox with ArrowDown when the button is disabled',
suppressConsoleLogs(async () => {
render(
<Listbox value={undefined} onChange={console.log}>
<Listbox.Button disabled>Trigger</Listbox.Button>
<Listbox value={undefined} onChange={console.log} disabled>
<Listbox.Button>Trigger</Listbox.Button>
<Listbox.Options>
<Listbox.Option value="a">Option A</Listbox.Option>
<Listbox.Option value="b">Option B</Listbox.Option>
Expand Down Expand Up @@ -1781,8 +1819,8 @@ describe('Keyboard interactions', () => {
'should not be possible to open the listbox with ArrowUp and the last option should be active when the button is disabled',
suppressConsoleLogs(async () => {
render(
<Listbox value={undefined} onChange={console.log}>
<Listbox.Button disabled>Trigger</Listbox.Button>
<Listbox value={undefined} onChange={console.log} disabled>
<Listbox.Button>Trigger</Listbox.Button>
<Listbox.Options>
<Listbox.Option value="a">Option A</Listbox.Option>
<Listbox.Option value="b">Option B</Listbox.Option>
Expand Down Expand Up @@ -2852,8 +2890,8 @@ describe('Mouse interactions', () => {
'should not be possible to open the listbox on click when the button is disabled',
suppressConsoleLogs(async () => {
render(
<Listbox value={undefined} onChange={console.log}>
<Listbox.Button disabled>Trigger</Listbox.Button>
<Listbox value={undefined} onChange={console.log} disabled>
<Listbox.Button>Trigger</Listbox.Button>
<Listbox.Options>
<Listbox.Option value="a">Option A</Listbox.Option>
<Listbox.Option value="b">Option B</Listbox.Option>
Expand Down
60 changes: 45 additions & 15 deletions packages/@headlessui-react/src/components/listbox/listbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ interface StateDefinition {
labelRef: MutableRefObject<HTMLLabelElement | null>
buttonRef: MutableRefObject<HTMLButtonElement | null>
optionsRef: MutableRefObject<HTMLUListElement | null>
disabled: boolean
options: { id: string; dataRef: ListboxOptionDataRef }[]
searchQuery: string
activeOptionIndex: number | null
Expand All @@ -58,6 +59,8 @@ enum ActionTypes {
OpenListbox,
CloseListbox,

SetDisabled,

GoToOption,
Search,
ClearSearch,
Expand All @@ -69,6 +72,7 @@ enum ActionTypes {
type Actions =
| { type: ActionTypes.CloseListbox }
| { type: ActionTypes.OpenListbox }
| { type: ActionTypes.SetDisabled; disabled: boolean }
| { type: ActionTypes.GoToOption; focus: Focus.Specific; id: string }
| { type: ActionTypes.GoToOption; focus: Exclude<Focus, Focus.Specific> }
| { type: ActionTypes.Search; value: string }
Expand All @@ -82,13 +86,24 @@ let reducers: {
action: Extract<Actions, { type: P }>
) => StateDefinition
} = {
[ActionTypes.CloseListbox]: state => ({
...state,
activeOptionIndex: null,
listboxState: ListboxStates.Closed,
}),
[ActionTypes.OpenListbox]: state => ({ ...state, listboxState: ListboxStates.Open }),
[ActionTypes.GoToOption]: (state, action) => {
[ActionTypes.CloseListbox](state) {
if (state.disabled) return state
if (state.listboxState === ListboxStates.Closed) return state
return { ...state, activeOptionIndex: null, listboxState: ListboxStates.Closed }
},
[ActionTypes.OpenListbox](state) {
if (state.disabled) return state
if (state.listboxState === ListboxStates.Open) return state
return { ...state, listboxState: ListboxStates.Open }
},
[ActionTypes.SetDisabled](state, action) {
if (state.disabled === action.disabled) return state
return { ...state, disabled: action.disabled }
},
[ActionTypes.GoToOption](state, action) {
if (state.disabled) return state
if (state.listboxState === ListboxStates.Closed) return state

let activeOptionIndex = calculateActiveIndex(action, {
resolveItems: () => state.options,
resolveActiveIndex: () => state.activeOptionIndex,
Expand All @@ -100,6 +115,9 @@ let reducers: {
return { ...state, searchQuery: '', activeOptionIndex }
},
[ActionTypes.Search]: (state, action) => {
if (state.disabled) return state
if (state.listboxState === ListboxStates.Closed) return state

let searchQuery = state.searchQuery + action.value
let match = state.options.findIndex(
option =>
Expand All @@ -110,7 +128,12 @@ let reducers: {
if (match === -1 || match === state.activeOptionIndex) return { ...state, searchQuery }
return { ...state, searchQuery, activeOptionIndex: match }
},
[ActionTypes.ClearSearch]: state => ({ ...state, searchQuery: '' }),
[ActionTypes.ClearSearch](state) {
if (state.disabled) return state
if (state.listboxState === ListboxStates.Closed) return state
if (state.searchQuery === '') return state
return { ...state, searchQuery: '' }
},
[ActionTypes.RegisterOption]: (state, action) => ({
...state,
options: [...state.options, { id: action.id, dataRef: action.dataRef }],
Expand Down Expand Up @@ -161,22 +184,25 @@ function stateReducer(state: StateDefinition, action: Actions) {
let DEFAULT_LISTBOX_TAG = Fragment
interface ListboxRenderPropArg {
open: boolean
disabled: boolean
}

export function Listbox<TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG, TType = string>(
props: Props<TTag, ListboxRenderPropArg, 'value' | 'onChange'> & {
value: TType
onChange(value: TType): void
disabled?: boolean
}
) {
let { value, onChange, ...passThroughProps } = props
let { value, onChange, disabled = false, ...passThroughProps } = props
let d = useDisposables()
let reducerBag = useReducer(stateReducer, {
listboxState: ListboxStates.Closed,
propsRef: { current: { value, onChange } },
labelRef: createRef(),
buttonRef: createRef(),
optionsRef: createRef(),
disabled,
options: [],
searchQuery: '',
activeOptionIndex: null,
Expand All @@ -189,6 +215,7 @@ export function Listbox<TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG, T
useIsoMorphicEffect(() => {
propsRef.current.onChange = onChange
}, [onChange, propsRef])
useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetDisabled, disabled }), [disabled])

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

let propsBag = useMemo<ListboxRenderPropArg>(
() => ({ open: listboxState === ListboxStates.Open }),
[listboxState]
() => ({ open: listboxState === ListboxStates.Open, disabled }),
[listboxState, disabled]
)

return (
Expand All @@ -224,6 +251,7 @@ export function Listbox<TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG, T
let DEFAULT_BUTTON_TAG = 'button' as const
interface ButtonRenderPropArg {
open: boolean
disabled: boolean
}
type ButtonPropsWeControl =
| 'id'
Expand All @@ -232,6 +260,7 @@ type ButtonPropsWeControl =
| 'aria-controls'
| 'aria-expanded'
| 'aria-labelledby'
| 'disabled'
| 'onKeyDown'
| 'onClick'

Expand Down Expand Up @@ -279,7 +308,6 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
let handleClick = useCallback(
(event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
if (props.disabled) return
if (state.listboxState === ListboxStates.Open) {
dispatch({ type: ActionTypes.CloseListbox })
d.nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
Expand All @@ -289,7 +317,7 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
d.nextFrame(() => state.optionsRef.current?.focus({ preventScroll: true }))
}
},
[dispatch, d, state, props.disabled]
[dispatch, d, state]
)

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

let propsBag = useMemo<ButtonRenderPropArg>(
() => ({ open: state.listboxState === ListboxStates.Open }),
() => ({ open: state.listboxState === ListboxStates.Open, disabled: state.disabled }),
[state]
)
let passthroughProps = props
Expand All @@ -310,6 +338,7 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
'aria-controls': state.optionsRef.current?.id,
'aria-expanded': state.listboxState === ListboxStates.Open ? true : undefined,
'aria-labelledby': labelledby,
disabled: state.disabled,
onKeyDown: handleKeyDown,
onClick: handleClick,
}
Expand All @@ -322,6 +351,7 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
let DEFAULT_LABEL_TAG = 'label' as const
interface LabelRenderPropArg {
open: boolean
disabled: boolean
}
type LabelPropsWeControl = 'id' | 'ref' | 'onClick'

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

let propsBag = useMemo<OptionsRenderPropArg>(
() => ({ open: state.listboxState === ListboxStates.Open }),
() => ({ open: state.listboxState === ListboxStates.Open, disabled: state.disabled }),
[state]
)
let propsWeControl = { ref: state.labelRef, id, onClick: handleClick }
Expand Down
4 changes: 2 additions & 2 deletions packages/@headlessui-react/src/test-utils/interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,8 @@ export async function type(events: Partial<KeyboardEvent>[], element = document.
}
}

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

export enum MouseButton {
Expand Down
Loading