Add Combobox component (#1047)

* start of combobox

* start with a copy of the Listbox

* WIP

* Add Vue Combobox

* Update Vue version of combobox

* Update tests

* Fix typescript errors in combobox test

* Fix input label

The spec says that the combobox itself is labelled directly by the associated label. The button can however be labelled by the label or itself.

* Add active descendant to combobox/input

* Add listbox role to comobox options

Right now the option list *is* just a listbox. If we were to allow other types in the future this will need to be changable

* Update tests

* move React playground to dedicated package

* add react playground script to root

* ensure we only open/close the combobox when necessary

* ensure export order is correct

* remove leftover pages directory from React package

* Only add aria controls when combobox is open

* add missing next commands

* make typescript happy

* build @headlessui/react before building playground-react

* add empty public folder

This makes vercel happy

* wip

* Add todo

* Update tests

Still more updates to do but some are blocked on implementation

* change default combobox example slightly

* ensure that we sync the input with new state

When the <Combobox value={...} /> changes, then the input should change
as well.

* only sync the value with the input in a single spot

* WIP: object value to string

* WIP

* WIP

* WIP groups

* Add static search filtering to combobox

* Move mouse leave event to combobox

* Fix use in fragments

* Update

* WIP

* make all tests pass for the combobox in React

* remove unnecessary playground item

* remove listbox wip

* only fire change event on inputs

Potentially we also have to do this for all kinds of form inputs. But
this will do for now.

* disable combobox vue tests

* Fix vue typescript errors

* Vue tests WIP

* improve combobox playgrounds a tiny bit

* ensure to lookup the correct value

* make sure that we are using a div instead of a Fragment

* expose `activeItem`

This will be similar to `yourData[activeIndex]`, but in this case the
active option's data. Can probably rename this if necessary!

* Update comments

* Port react tests to Vue

* Vue tests WIP

* WIP

* Rename activeItem to activeOption

* Move display value to input

* Update playgrounds

* Remove static filtering

* Add tests for display value

* WIP Vue Tests

* WIP

* unfocus suite

* Cleanup react accessibility assertions code

* Vue WIP

* Cleanup errors in react interactions test utils

* Update vue implementation

closer :D

* Fix searching

* Update

* Add display value stubs

* Update tests

* move `<Combobox onSearch={} />` to `<Combobox.Input onChange={} />`

* use `useLatestValue` hook

* make `onChange` explicitly required

* remove unused variables

* move `<Combobox @search="" />` to `<ComboboxInput @change="" />`

* use correct event

* use `let` for consistency

* remove unnecessary hidden check

* implement displayValue for Vue

* update playground to reflect changes

* make sure that the activeOptionIndex stays correct

* update changelog

Co-authored-by: Jordan Pittman <jordan@cryptica.me>
This commit is contained in:
Robin Malfait
2022-01-27 16:42:47 +01:00
committed by GitHub
parent cae976a18b
commit ea26870480
55 changed files with 16186 additions and 104 deletions
+8 -1
View File
@@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improve controlled Tabs behaviour ([#1050](https://github.com/tailwindlabs/headlessui/pull/1050))
- Improve typeahead search logic ([#1051](https://github.com/tailwindlabs/headlessui/pull/1051))
### Added
- Add `Combobox` component ([#1047](https://github.com/tailwindlabs/headlessui/pull/1047))
## [Unreleased - @headlessui/vue]
### Fixed
@@ -20,6 +24,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure correct order when conditionally rendering `MenuItem`, `ListboxOption` and `RadioGroupOption` ([#1045](https://github.com/tailwindlabs/headlessui/pull/1045))
- Improve typeahead search logic ([#1051](https://github.com/tailwindlabs/headlessui/pull/1051))
### Added
- Add `Combobox` component ([#1047](https://github.com/tailwindlabs/headlessui/pull/1047))
## [@headlessui/react@v1.4.3] - 2022-01-14
### Fixes
@@ -88,7 +96,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add `aria-orientation` to `Listbox`, which swaps Up/Down with Left/Right keys ([#683](https://github.com/tailwindlabs/headlessui/pull/683))
- Expose `close` function from the render prop for `Disclosure`, `Disclosure.Panel`, `Popover` and `Popover.Panel` ([#697](https://github.com/tailwindlabs/headlessui/pull/697))
## [@headlessui/vue@v1.4.0] - 2021-07-29
### Added
+2
View File
@@ -11,6 +11,8 @@
],
"scripts": {
"react": "yarn workspace @headlessui/react",
"react-playground": "yarn workspace playground-react dev",
"playground-react": "yarn workspace playground-react dev",
"vue": "yarn workspace @headlessui/vue",
"shared": "yarn workspace @headlessui/shared",
"build": "yarn workspaces run build",
+1
View File
@@ -26,6 +26,7 @@
"prepublishOnly": "npm run build",
"test": "../../scripts/test.sh",
"build": "../../scripts/build.sh",
"watch": "../../scripts/watch.sh",
"lint": "../../scripts/lint.sh"
},
"peerDependencies": {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,924 @@
import React, {
Fragment,
createContext,
createRef,
useCallback,
useContext,
useMemo,
useReducer,
useRef,
// Types
Dispatch,
ElementType,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
MutableRefObject,
Ref,
ContextType,
} from 'react'
import { useDisposables } from '../../hooks/use-disposables'
import { useId } from '../../hooks/use-id'
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
import { useComputed } from '../../hooks/use-computed'
import { useSyncRefs } from '../../hooks/use-sync-refs'
import { Props } from '../../types'
import { Features, forwardRefWithAs, PropsForFeatures, render } from '../../utils/render'
import { match } from '../../utils/match'
import { disposables } from '../../utils/disposables'
import { Keys } from '../keyboard'
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
import { isDisabledReactIssue7711 } from '../../utils/bugs'
import { isFocusableElement, FocusableMode } from '../../utils/focus-management'
import { useWindowEvent } from '../../hooks/use-window-event'
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useLatestValue } from '../../hooks/use-latest-value'
enum ComboboxStates {
Open,
Closed,
}
type ComboboxOptionDataRef = MutableRefObject<{
textValue?: string
disabled: boolean
value: unknown
}>
interface StateDefinition {
comboboxState: ComboboxStates
orientation: 'horizontal' | 'vertical'
propsRef: MutableRefObject<{
value: unknown
onChange(value: unknown): void
}>
inputPropsRef: MutableRefObject<{
displayValue?(item: unknown): string
}>
labelRef: MutableRefObject<HTMLLabelElement | null>
inputRef: MutableRefObject<HTMLInputElement | null>
buttonRef: MutableRefObject<HTMLButtonElement | null>
optionsRef: MutableRefObject<HTMLUListElement | null>
disabled: boolean
options: { id: string; dataRef: ComboboxOptionDataRef }[]
activeOptionIndex: number | null
}
enum ActionTypes {
OpenCombobox,
CloseCombobox,
SetDisabled,
SetOrientation,
GoToOption,
RegisterOption,
UnregisterOption,
}
type Actions =
| { type: ActionTypes.CloseCombobox }
| { type: ActionTypes.OpenCombobox }
| { type: ActionTypes.SetDisabled; disabled: boolean }
| { type: ActionTypes.SetOrientation; orientation: StateDefinition['orientation'] }
| { type: ActionTypes.GoToOption; focus: Focus.Specific; id: string }
| { type: ActionTypes.GoToOption; focus: Exclude<Focus, Focus.Specific> }
| { type: ActionTypes.RegisterOption; id: string; dataRef: ComboboxOptionDataRef }
| { type: ActionTypes.UnregisterOption; id: string }
let reducers: {
[P in ActionTypes]: (
state: StateDefinition,
action: Extract<Actions, { type: P }>
) => StateDefinition
} = {
[ActionTypes.CloseCombobox](state) {
if (state.disabled) return state
if (state.comboboxState === ComboboxStates.Closed) return state
return { ...state, activeOptionIndex: null, comboboxState: ComboboxStates.Closed }
},
[ActionTypes.OpenCombobox](state) {
if (state.disabled) return state
if (state.comboboxState === ComboboxStates.Open) return state
return { ...state, comboboxState: ComboboxStates.Open }
},
[ActionTypes.SetDisabled](state, action) {
if (state.disabled === action.disabled) return state
return { ...state, disabled: action.disabled }
},
[ActionTypes.SetOrientation](state, action) {
if (state.orientation === action.orientation) return state
return { ...state, orientation: action.orientation }
},
[ActionTypes.GoToOption](state, action) {
if (state.disabled) return state
if (state.comboboxState === ComboboxStates.Closed) return state
let activeOptionIndex = calculateActiveIndex(action, {
resolveItems: () => state.options,
resolveActiveIndex: () => state.activeOptionIndex,
resolveId: item => item.id,
resolveDisabled: item => item.dataRef.current.disabled,
})
if (state.activeOptionIndex === activeOptionIndex) return state
return { ...state, activeOptionIndex }
},
[ActionTypes.RegisterOption]: (state, action) => {
let currentActiveOption =
state.activeOptionIndex !== null ? state.options[state.activeOptionIndex] : null
let orderMap = Array.from(
state.optionsRef.current?.querySelectorAll('[id^="headlessui-combobox-option-"]')!
).reduce(
(lookup, element, index) => Object.assign(lookup, { [element.id]: index }),
{}
) as Record<string, number>
let options = [...state.options, { id: action.id, dataRef: action.dataRef }].sort(
(a, z) => orderMap[a.id] - orderMap[z.id]
)
return {
...state,
options,
activeOptionIndex: (() => {
if (currentActiveOption === null) return null
// If we inserted an option before the current active option then the
// active option index would be wrong. To fix this, we will re-lookup
// the correct index.
return options.indexOf(currentActiveOption)
})(),
}
},
[ActionTypes.UnregisterOption]: (state, action) => {
let nextOptions = state.options.slice()
let currentActiveOption =
state.activeOptionIndex !== null ? nextOptions[state.activeOptionIndex] : null
let idx = nextOptions.findIndex(a => a.id === action.id)
if (idx !== -1) nextOptions.splice(idx, 1)
return {
...state,
options: nextOptions,
activeOptionIndex: (() => {
if (idx === state.activeOptionIndex) return null
if (currentActiveOption === null) return null
// If we removed the option before the actual active index, then it would be out of sync. To
// fix this, we will find the correct (new) index position.
return nextOptions.indexOf(currentActiveOption)
})(),
}
},
}
let ComboboxContext = createContext<[StateDefinition, Dispatch<Actions>] | null>(null)
ComboboxContext.displayName = 'ComboboxContext'
function useComboboxContext(component: string) {
let context = useContext(ComboboxContext)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent <${Combobox.name} /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxContext)
throw err
}
return context
}
let ComboboxActions = createContext<{
selectOption(id: string): void
selectActiveOption(): void
} | null>(null)
ComboboxActions.displayName = 'ComboboxActions'
function useComboboxActions() {
let context = useContext(ComboboxActions)
if (context === null) {
let err = new Error(`ComboboxActions is missing a parent <${Combobox.name} /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxActions)
throw err
}
return context
}
function stateReducer(state: StateDefinition, action: Actions) {
return match(action.type, reducers, state, action)
}
// ---
let DEFAULT_COMBOBOX_TAG = Fragment
interface ComboboxRenderPropArg<T> {
open: boolean
disabled: boolean
activeIndex: number | null
activeOption: T | null
}
export function Combobox<TTag extends ElementType = typeof DEFAULT_COMBOBOX_TAG, TType = string>(
props: Props<
TTag,
ComboboxRenderPropArg<TType>,
'value' | 'onChange' | 'disabled' | 'horizontal'
> & {
value: TType
onChange(value: TType): void
disabled?: boolean
horizontal?: boolean
}
) {
let { value, onChange, disabled = false, horizontal = false, ...passThroughProps } = props
const orientation = horizontal ? 'horizontal' : 'vertical'
let reducerBag = useReducer(stateReducer, {
comboboxState: ComboboxStates.Closed,
propsRef: {
current: {
value,
onChange,
},
},
inputPropsRef: {
current: {
displayValue: undefined,
},
},
labelRef: createRef(),
inputRef: createRef(),
buttonRef: createRef(),
optionsRef: createRef(),
disabled,
orientation,
options: [],
activeOptionIndex: null,
} as StateDefinition)
let [
{
comboboxState,
options,
activeOptionIndex,
propsRef,
inputPropsRef,
optionsRef,
inputRef,
buttonRef,
},
dispatch,
] = reducerBag
useIsoMorphicEffect(() => {
propsRef.current.value = value
}, [value, propsRef])
useIsoMorphicEffect(() => {
propsRef.current.onChange = onChange
}, [onChange, propsRef])
useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetDisabled, disabled }), [disabled])
useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetOrientation, orientation }), [
orientation,
])
// Handle outside click
useWindowEvent('mousedown', event => {
let target = event.target as HTMLElement
if (comboboxState !== ComboboxStates.Open) return
if (buttonRef.current?.contains(target)) return
if (inputRef.current?.contains(target)) return
if (optionsRef.current?.contains(target)) return
dispatch({ type: ActionTypes.CloseCombobox })
if (!isFocusableElement(target, FocusableMode.Loose)) {
event.preventDefault()
inputRef.current?.focus()
}
})
let slot = useMemo<ComboboxRenderPropArg<TType>>(
() => ({
open: comboboxState === ComboboxStates.Open,
disabled,
activeIndex: activeOptionIndex,
activeOption:
activeOptionIndex === null
? null
: (options[activeOptionIndex].dataRef.current.value as TType),
}),
[comboboxState, disabled, options, activeOptionIndex]
)
let syncInputValue = useCallback(() => {
if (!inputRef.current) return
if (value === undefined) return
let displayValue = inputPropsRef.current.displayValue
if (typeof displayValue === 'function') {
inputRef.current.value = displayValue(value)
} else if (typeof value === 'string') {
inputRef.current.value = value
}
}, [value, inputRef, inputPropsRef])
let selectOption = useCallback(
(id: string) => {
let option = options.find(item => item.id === id)
if (!option) return
let { dataRef } = option
propsRef.current.onChange(dataRef.current.value)
syncInputValue()
},
[options, propsRef, inputRef]
)
let selectActiveOption = useCallback(() => {
if (activeOptionIndex !== null) {
let { dataRef } = options[activeOptionIndex]
propsRef.current.onChange(dataRef.current.value)
syncInputValue()
}
}, [activeOptionIndex, options, propsRef, inputRef])
let actionsBag = useMemo<ContextType<typeof ComboboxActions>>(
() => ({ selectOption, selectActiveOption }),
[selectOption, selectActiveOption]
)
useIsoMorphicEffect(() => {
if (comboboxState !== ComboboxStates.Closed) {
return
}
syncInputValue()
}, [syncInputValue, comboboxState])
// Ensure that we update the inputRef if the value changes
useIsoMorphicEffect(syncInputValue, [syncInputValue])
return (
<ComboboxActions.Provider value={actionsBag}>
<ComboboxContext.Provider value={reducerBag}>
<OpenClosedProvider
value={match(comboboxState, {
[ComboboxStates.Open]: State.Open,
[ComboboxStates.Closed]: State.Closed,
})}
>
{render({
props: passThroughProps,
slot,
defaultTag: DEFAULT_COMBOBOX_TAG,
name: 'Combobox',
})}
</OpenClosedProvider>
</ComboboxContext.Provider>
</ComboboxActions.Provider>
)
}
// ---
let DEFAULT_INPUT_TAG = 'input' as const
interface InputRenderPropArg {
open: boolean
disabled: boolean
}
type InputPropsWeControl =
| 'id'
| 'role'
| 'type'
| 'aria-labelledby'
| 'aria-expanded'
| 'aria-activedescendant'
| 'onKeyDown'
| 'onChange'
| 'displayValue'
let Input = forwardRefWithAs(function Input<
TTag extends ElementType = typeof DEFAULT_INPUT_TAG,
// TODO: One day we will be able to infer this type from the generic in Combobox itself.
// But today is not that day..
TType = Parameters<typeof Combobox>[0]['value']
>(
props: Props<TTag, InputRenderPropArg, InputPropsWeControl> & {
displayValue?(item: TType): string
onChange(event: React.ChangeEvent<HTMLInputElement>): void
},
ref: Ref<HTMLInputElement>
) {
let { value, onChange, displayValue, ...passThroughProps } = props
let [state, dispatch] = useComboboxContext([Combobox.name, Input.name].join('.'))
let actions = useComboboxActions()
let inputRef = useSyncRefs(state.inputRef, ref)
let inputPropsRef = state.inputPropsRef
let id = `headlessui-combobox-input-${useId()}`
let d = useDisposables()
let onChangeRef = useLatestValue(onChange)
useIsoMorphicEffect(() => {
inputPropsRef.current.displayValue = displayValue
}, [displayValue, inputPropsRef])
let handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLUListElement>) => {
switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
case Keys.Enter:
event.preventDefault()
event.stopPropagation()
actions.selectActiveOption()
dispatch({ type: ActionTypes.CloseCombobox })
break
case match(state.orientation, { vertical: Keys.ArrowDown, horizontal: Keys.ArrowRight }):
event.preventDefault()
event.stopPropagation()
return match(state.comboboxState, {
[ComboboxStates.Open]: () => {
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Next })
},
[ComboboxStates.Closed]: () => {
dispatch({ type: ActionTypes.OpenCombobox })
// TODO: We can't do this outside next frame because the options aren't rendered yet
// But doing this in next frame results in a flicker because the dom mutations are async here
// Basically:
// Sync -> no option list yet
// Next frame -> option list already rendered with selection -> dispatch -> next frame -> now we have the focus on the right element
// TODO: The spec here is underspecified. There's mention of skipping to the next item when autocomplete has suggested something but nothing regarding a non-autocomplete selection/value
d.nextFrame(() => {
if (!state.propsRef.current.value) {
dispatch({ type: ActionTypes.GoToOption, focus: Focus.First })
}
})
},
})
case match(state.orientation, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }):
event.preventDefault()
event.stopPropagation()
return match(state.comboboxState, {
[ComboboxStates.Open]: () => {
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Previous })
},
[ComboboxStates.Closed]: () => {
dispatch({ type: ActionTypes.OpenCombobox })
d.nextFrame(() => {
if (!state.propsRef.current.value) {
dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last })
}
})
},
})
case Keys.Home:
case Keys.PageUp:
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.First })
case Keys.End:
case Keys.PageDown:
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last })
case Keys.Escape:
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.CloseCombobox })
case Keys.Tab:
actions.selectActiveOption()
dispatch({ type: ActionTypes.CloseCombobox })
break
}
},
[d, dispatch, state, actions]
)
let handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
dispatch({ type: ActionTypes.OpenCombobox })
onChangeRef.current(event)
},
[dispatch, onChangeRef]
)
// TODO: Verify this. The spec says that, for the input/combobox, the lebel is the labelling element when present
// Otherwise it's the ID of the non-label element
let labelledby = useComputed(() => {
if (!state.labelRef.current) return undefined
return [state.labelRef.current.id].join(' ')
}, [state.labelRef.current])
let slot = useMemo<InputRenderPropArg>(
() => ({ open: state.comboboxState === ComboboxStates.Open, disabled: state.disabled }),
[state]
)
let propsWeControl = {
ref: inputRef,
id,
role: 'combobox',
type: 'text',
'aria-controls': state.optionsRef.current?.id,
'aria-expanded': state.disabled ? undefined : state.comboboxState === ComboboxStates.Open,
'aria-activedescendant':
state.activeOptionIndex === null ? undefined : state.options[state.activeOptionIndex]?.id,
'aria-labelledby': labelledby,
disabled: state.disabled,
onKeyDown: handleKeyDown,
onChange: handleChange,
}
return render({
props: { ...passThroughProps, ...propsWeControl },
slot,
defaultTag: DEFAULT_INPUT_TAG,
name: 'Combobox.Input',
})
})
// ---
let DEFAULT_BUTTON_TAG = 'button' as const
interface ButtonRenderPropArg {
open: boolean
disabled: boolean
}
type ButtonPropsWeControl =
| 'id'
| 'type'
| 'tabIndex'
| 'aria-haspopup'
| 'aria-controls'
| 'aria-expanded'
| 'aria-labelledby'
| 'disabled'
| 'onClick'
| 'onKeyDown'
let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
props: Props<TTag, ButtonRenderPropArg, ButtonPropsWeControl>,
ref: Ref<HTMLButtonElement>
) {
let [state, dispatch] = useComboboxContext([Combobox.name, Button.name].join('.'))
let actions = useComboboxActions()
let buttonRef = useSyncRefs(state.buttonRef, ref)
let id = `headlessui-combobox-button-${useId()}`
let d = useDisposables()
let handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLUListElement>) => {
switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
case match(state.orientation, { vertical: Keys.ArrowDown, horizontal: Keys.ArrowRight }):
event.preventDefault()
event.stopPropagation()
if (state.comboboxState === ComboboxStates.Closed) {
dispatch({ type: ActionTypes.OpenCombobox })
// TODO: We can't do this outside next frame because the options aren't rendered yet
// But doing this in next frame results in a flicker because the dom mutations are async here
// Basically:
// Sync -> no option list yet
// Next frame -> option list already rendered with selection -> dispatch -> next frame -> now we have the focus on the right element
// TODO: The spec here is underspecified. There's mention of skipping to the next item when autocomplete has suggested something but nothing regarding a non-autocomplete selection/value
d.nextFrame(() => {
if (!state.propsRef.current.value) {
dispatch({ type: ActionTypes.GoToOption, focus: Focus.First })
}
})
}
return d.nextFrame(() => state.inputRef.current?.focus({ preventScroll: true }))
case match(state.orientation, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }):
event.preventDefault()
event.stopPropagation()
if (state.comboboxState === ComboboxStates.Closed) {
dispatch({ type: ActionTypes.OpenCombobox })
d.nextFrame(() => {
if (!state.propsRef.current.value) {
dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last })
}
})
}
return d.nextFrame(() => state.inputRef.current?.focus({ preventScroll: true }))
case Keys.Escape:
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.CloseCombobox })
return d.nextFrame(() => state.inputRef.current?.focus({ preventScroll: true }))
}
},
[d, dispatch, state, actions]
)
let handleClick = useCallback(
(event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
if (state.comboboxState === ComboboxStates.Open) {
dispatch({ type: ActionTypes.CloseCombobox })
} else {
event.preventDefault()
dispatch({ type: ActionTypes.OpenCombobox })
}
d.nextFrame(() => state.inputRef.current?.focus({ preventScroll: true }))
},
[dispatch, d, state]
)
let labelledby = useComputed(() => {
if (!state.labelRef.current) return undefined
return [state.labelRef.current.id, id].join(' ')
}, [state.labelRef.current, id])
let slot = useMemo<ButtonRenderPropArg>(
() => ({ open: state.comboboxState === ComboboxStates.Open, disabled: state.disabled }),
[state]
)
let passthroughProps = props
let propsWeControl = {
ref: buttonRef,
id,
type: useResolveButtonType(props, state.buttonRef),
tabIndex: -1,
'aria-haspopup': true,
'aria-controls': state.optionsRef.current?.id,
'aria-expanded': state.disabled ? undefined : state.comboboxState === ComboboxStates.Open,
'aria-labelledby': labelledby,
disabled: state.disabled,
onClick: handleClick,
onKeyDown: handleKeyDown,
}
return render({
props: { ...passthroughProps, ...propsWeControl },
slot,
defaultTag: DEFAULT_BUTTON_TAG,
name: 'Combobox.Button',
})
})
// ---
let DEFAULT_LABEL_TAG = 'label' as const
interface LabelRenderPropArg {
open: boolean
disabled: boolean
}
type LabelPropsWeControl = 'id' | 'ref' | 'onClick'
function Label<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
props: Props<TTag, LabelRenderPropArg, LabelPropsWeControl>
) {
let [state] = useComboboxContext([Combobox.name, Label.name].join('.'))
let id = `headlessui-combobox-label-${useId()}`
let handleClick = useCallback(() => state.inputRef.current?.focus({ preventScroll: true }), [
state.inputRef,
])
let slot = useMemo<LabelRenderPropArg>(
() => ({ open: state.comboboxState === ComboboxStates.Open, disabled: state.disabled }),
[state]
)
let propsWeControl = { ref: state.labelRef, id, onClick: handleClick }
return render({
props: { ...props, ...propsWeControl },
slot,
defaultTag: DEFAULT_LABEL_TAG,
name: 'Combobox.Label',
})
}
// ---
let DEFAULT_OPTIONS_TAG = 'ul' as const
interface OptionsRenderPropArg {
open: boolean
}
type OptionsPropsWeControl =
| 'aria-activedescendant'
| 'aria-labelledby'
| 'aria-orientation'
| 'id'
| 'onKeyDown'
| 'role'
| 'tabIndex'
let OptionsRenderFeatures = Features.RenderStrategy | Features.Static
let Options = forwardRefWithAs(function Options<
TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG
>(
props: Props<TTag, OptionsRenderPropArg, OptionsPropsWeControl> &
PropsForFeatures<typeof OptionsRenderFeatures>,
ref: Ref<HTMLUListElement>
) {
let [state, dispatch] = useComboboxContext([Combobox.name, Options.name].join('.'))
let optionsRef = useSyncRefs(state.optionsRef, ref)
let id = `headlessui-combobox-options-${useId()}`
let usesOpenClosedState = useOpenClosed()
let visible = (() => {
if (usesOpenClosedState !== null) {
return usesOpenClosedState === State.Open
}
return state.comboboxState === ComboboxStates.Open
})()
let labelledby = useComputed(() => state.labelRef.current?.id ?? state.buttonRef.current?.id, [
state.labelRef.current,
state.buttonRef.current,
])
let handleLeave = useCallback(() => {
if (state.comboboxState !== ComboboxStates.Open) return
if (state.activeOptionIndex === null) return
dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing })
}, [state, dispatch])
let slot = useMemo<OptionsRenderPropArg>(
() => ({ open: state.comboboxState === ComboboxStates.Open }),
[state]
)
let propsWeControl = {
'aria-activedescendant':
state.activeOptionIndex === null ? undefined : state.options[state.activeOptionIndex]?.id,
'aria-labelledby': labelledby,
'aria-orientation': state.orientation,
role: 'listbox',
id,
ref: optionsRef,
onPointerLeave: handleLeave,
onMouseLeave: handleLeave,
}
let passthroughProps = props
return render({
props: { ...passthroughProps, ...propsWeControl },
slot,
defaultTag: DEFAULT_OPTIONS_TAG,
features: OptionsRenderFeatures,
visible,
name: 'Combobox.Options',
})
})
// ---
let DEFAULT_OPTION_TAG = 'li' as const
interface OptionRenderPropArg {
active: boolean
selected: boolean
disabled: boolean
}
type ComboboxOptionPropsWeControl =
| 'id'
| 'role'
| 'tabIndex'
| 'aria-disabled'
| 'aria-selected'
| 'onPointerLeave'
| 'onMouseLeave'
| 'onPointerMove'
| 'onMouseMove'
function Option<
TTag extends ElementType = typeof DEFAULT_OPTION_TAG,
// TODO: One day we will be able to infer this type from the generic in Combobox itself.
// But today is not that day..
TType = Parameters<typeof Combobox>[0]['value']
>(
props: Props<TTag, OptionRenderPropArg, ComboboxOptionPropsWeControl | 'value'> & {
disabled?: boolean
value: TType
}
) {
let { disabled = false, value, ...passthroughProps } = props
let [state, dispatch] = useComboboxContext([Combobox.name, Option.name].join('.'))
let actions = useComboboxActions()
let id = `headlessui-combobox-option-${useId()}`
let active =
state.activeOptionIndex !== null ? state.options[state.activeOptionIndex].id === id : false
let selected = state.propsRef.current.value === value
let bag = useRef<ComboboxOptionDataRef['current']>({ disabled, value })
useIsoMorphicEffect(() => {
bag.current.disabled = disabled
}, [bag, disabled])
useIsoMorphicEffect(() => {
bag.current.value = value
}, [bag, value])
useIsoMorphicEffect(() => {
bag.current.textValue = document.getElementById(id)?.textContent?.toLowerCase()
}, [bag, id])
let select = useCallback(() => actions.selectOption(id), [actions, id])
useIsoMorphicEffect(() => {
dispatch({ type: ActionTypes.RegisterOption, id, dataRef: bag })
return () => dispatch({ type: ActionTypes.UnregisterOption, id })
}, [bag, id])
useIsoMorphicEffect(() => {
if (state.comboboxState !== ComboboxStates.Open) return
if (!selected) return
dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id })
}, [state.comboboxState])
useIsoMorphicEffect(() => {
if (state.comboboxState !== ComboboxStates.Open) return
if (!active) return
let d = disposables()
d.nextFrame(() => document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' }))
return d.dispose
}, [id, active, state.comboboxState])
let handleClick = useCallback(
(event: { preventDefault: Function }) => {
if (disabled) return event.preventDefault()
select()
dispatch({ type: ActionTypes.CloseCombobox })
disposables().nextFrame(() => state.inputRef.current?.focus({ preventScroll: true }))
},
[dispatch, state.inputRef, disabled, select]
)
let handleFocus = useCallback(() => {
if (disabled) return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing })
dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id })
}, [disabled, id, dispatch])
let handleMove = useCallback(() => {
if (disabled) return
if (active) return
dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id })
}, [disabled, active, id, dispatch])
let handleLeave = useCallback(() => {
if (disabled) return
if (!active) return
dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing })
}, [disabled, active, dispatch])
let slot = useMemo<OptionRenderPropArg>(() => ({ active, selected, disabled }), [
active,
selected,
disabled,
])
let propsWeControl = {
id,
role: 'option',
tabIndex: disabled === true ? undefined : -1,
'aria-disabled': disabled === true ? true : undefined,
'aria-selected': selected === true ? true : undefined,
disabled: undefined, // Never forward the `disabled` prop
onClick: handleClick,
onFocus: handleFocus,
onPointerMove: handleMove,
onMouseMove: handleMove,
onPointerLeave: handleLeave,
onMouseLeave: handleLeave,
}
return render({
props: { ...passthroughProps, ...propsWeControl },
slot,
defaultTag: DEFAULT_OPTION_TAG,
name: 'Combobox.Option',
})
}
// ---
Combobox.Input = Input
Combobox.Button = Button
Combobox.Label = Label
Combobox.Options = Options
Combobox.Option = Option
@@ -1,12 +1,10 @@
import { useState, useRef } from 'react'
import { useState } from 'react'
import { useIsoMorphicEffect } from './use-iso-morphic-effect'
import { useLatestValue } from './use-latest-value'
export function useComputed<T>(cb: () => T, dependencies: React.DependencyList) {
let [value, setValue] = useState(cb)
let cbRef = useRef(cb)
useIsoMorphicEffect(() => {
cbRef.current = cb
}, [cb])
let cbRef = useLatestValue(cb)
useIsoMorphicEffect(() => setValue(cbRef.current), [cbRef, setValue, ...dependencies])
return value
}
@@ -0,0 +1,11 @@
import { useRef, useEffect } from 'react'
export function useLatestValue<T>(value: T) {
let cache = useRef(value)
useEffect(() => {
cache.current = value
}, [value])
return cache
}
@@ -6,6 +6,7 @@ import * as HeadlessUI from './index'
*/
it('should expose the correct components', () => {
expect(Object.keys(HeadlessUI)).toEqual([
'Combobox',
'Dialog',
'Disclosure',
'FocusTrap',
+1
View File
@@ -1,3 +1,4 @@
export * from './components/combobox/combobox'
export * from './components/dialog/dialog'
export * from './components/disclosure/disclosure'
export * from './components/focus-trap/focus-trap'
@@ -91,7 +91,7 @@ export function assertMenuButton(
expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
Error.captureStackTrace(err, assertMenuButton)
if (err instanceof Error) Error.captureStackTrace(err, assertMenuButton)
throw err
}
}
@@ -105,7 +105,7 @@ export function assertMenuButtonLinkedWithMenu(button = getMenuButton(), menu =
expect(button).toHaveAttribute('aria-controls', menu.getAttribute('id'))
expect(menu).toHaveAttribute('aria-labelledby', button.getAttribute('id'))
} catch (err) {
Error.captureStackTrace(err, assertMenuButtonLinkedWithMenu)
if (err instanceof Error) Error.captureStackTrace(err, assertMenuButtonLinkedWithMenu)
throw err
}
}
@@ -118,7 +118,7 @@ export function assertMenuLinkedWithMenuItem(item: HTMLElement | null, menu = ge
// Ensure link between menu & menu item is correct
expect(menu).toHaveAttribute('aria-activedescendant', item.getAttribute('id'))
} catch (err) {
Error.captureStackTrace(err, assertMenuLinkedWithMenuItem)
if (err instanceof Error) Error.captureStackTrace(err, assertMenuLinkedWithMenuItem)
throw err
}
}
@@ -130,7 +130,7 @@ export function assertNoActiveMenuItem(menu = getMenu()) {
// Ensure we don't have an active menu
expect(menu).not.toHaveAttribute('aria-activedescendant')
} catch (err) {
Error.captureStackTrace(err, assertNoActiveMenuItem)
if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveMenuItem)
throw err
}
}
@@ -183,7 +183,7 @@ export function assertMenu(
assertNever(options.state)
}
} catch (err) {
Error.captureStackTrace(err, assertMenu)
if (err instanceof Error) Error.captureStackTrace(err, assertMenu)
throw err
}
}
@@ -214,7 +214,393 @@ export function assertMenuItem(
}
}
} catch (err) {
Error.captureStackTrace(err, assertMenuItem)
if (err instanceof Error) Error.captureStackTrace(err, assertMenuItem)
throw err
}
}
// ---
export function getComboboxLabel(): HTMLElement | null {
return document.querySelector('label,[id^="headlessui-combobox-label"]')
}
export function getComboboxButton(): HTMLElement | null {
return document.querySelector('button,[role="button"],[id^="headlessui-combobox-button-"]')
}
export function getComboboxButtons(): HTMLElement[] {
return Array.from(document.querySelectorAll('button,[role="button"]'))
}
export function getComboboxInput(): HTMLInputElement | null {
return document.querySelector('[role="combobox"]')
}
export function getCombobox(): HTMLElement | null {
return document.querySelector('[role="listbox"]')
}
export function getComboboxInputs(): HTMLElement[] {
return Array.from(document.querySelectorAll('[role="combobox"]'))
}
export function getComboboxes(): HTMLElement[] {
return Array.from(document.querySelectorAll('[role="listbox"]'))
}
export function getComboboxOptions(): HTMLElement[] {
return Array.from(document.querySelectorAll('[role="option"]'))
}
// ---
export enum ComboboxState {
/** The combobox is visible to the user. */
Visible,
/** The combobox is **not** visible to the user. It's still in the DOM, but it is hidden. */
InvisibleHidden,
/** The combobox is **not** visible to the user. It's not in the DOM, it is unmounted. */
InvisibleUnmounted,
}
export function assertCombobox(
options: {
attributes?: Record<string, string | null>
textContent?: string
state: ComboboxState
orientation?: 'horizontal' | 'vertical'
},
combobox = getComboboxInput()
) {
let { orientation = 'vertical' } = options
try {
switch (options.state) {
case ComboboxState.InvisibleHidden:
if (combobox === null) return expect(combobox).not.toBe(null)
assertHidden(combobox)
expect(combobox).toHaveAttribute('aria-labelledby')
expect(combobox).toHaveAttribute('aria-orientation', orientation)
expect(combobox).toHaveAttribute('role', 'combobox')
if (options.textContent) expect(combobox).toHaveTextContent(options.textContent)
for (let attributeName in options.attributes) {
expect(combobox).toHaveAttribute(attributeName, options.attributes[attributeName])
}
break
case ComboboxState.Visible:
if (combobox === null) return expect(combobox).not.toBe(null)
assertVisible(combobox)
expect(combobox).toHaveAttribute('aria-labelledby')
expect(combobox).toHaveAttribute('aria-orientation', orientation)
expect(combobox).toHaveAttribute('role', 'combobox')
if (options.textContent) expect(combobox).toHaveTextContent(options.textContent)
for (let attributeName in options.attributes) {
expect(combobox).toHaveAttribute(attributeName, options.attributes[attributeName])
}
break
case ComboboxState.InvisibleUnmounted:
expect(combobox).toBe(null)
break
default:
assertNever(options.state)
}
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, assertCombobox)
throw err
}
}
export function assertComboboxList(
options: {
attributes?: Record<string, string | null>
textContent?: string
state: ComboboxState
orientation?: 'horizontal' | 'vertical'
},
listbox = getCombobox()
) {
let { orientation = 'vertical' } = options
try {
switch (options.state) {
case ComboboxState.InvisibleHidden:
if (listbox === null) return expect(listbox).not.toBe(null)
assertHidden(listbox)
expect(listbox).toHaveAttribute('aria-labelledby')
expect(listbox).toHaveAttribute('aria-orientation', orientation)
expect(listbox).toHaveAttribute('role', 'listbox')
if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
for (let attributeName in options.attributes) {
expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName])
}
break
case ComboboxState.Visible:
if (listbox === null) return expect(listbox).not.toBe(null)
assertVisible(listbox)
expect(listbox).toHaveAttribute('aria-labelledby')
expect(listbox).toHaveAttribute('aria-orientation', orientation)
expect(listbox).toHaveAttribute('role', 'listbox')
if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
for (let attributeName in options.attributes) {
expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName])
}
break
case ComboboxState.InvisibleUnmounted:
expect(listbox).toBe(null)
break
default:
assertNever(options.state)
}
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, assertCombobox)
throw err
}
}
export function assertComboboxButton(
options: {
attributes?: Record<string, string | null>
textContent?: string
state: ComboboxState
},
button = getComboboxButton()
) {
try {
if (button === null) return expect(button).not.toBe(null)
// Ensure menu button have these properties
expect(button).toHaveAttribute('id')
expect(button).toHaveAttribute('aria-haspopup')
switch (options.state) {
case ComboboxState.Visible:
expect(button).toHaveAttribute('aria-controls')
expect(button).toHaveAttribute('aria-expanded', 'true')
break
case ComboboxState.InvisibleHidden:
expect(button).toHaveAttribute('aria-controls')
if (button.hasAttribute('disabled')) {
expect(button).not.toHaveAttribute('aria-expanded')
} else {
expect(button).toHaveAttribute('aria-expanded', 'false')
}
break
case ComboboxState.InvisibleUnmounted:
expect(button).not.toHaveAttribute('aria-controls')
if (button.hasAttribute('disabled')) {
expect(button).not.toHaveAttribute('aria-expanded')
} else {
expect(button).toHaveAttribute('aria-expanded', 'false')
}
break
default:
assertNever(options.state)
}
if (options.textContent) {
expect(button).toHaveTextContent(options.textContent)
}
// Ensure menu button has the following attributes
for (let attributeName in options.attributes) {
expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, assertComboboxButton)
throw err
}
}
export function assertComboboxLabel(
options: {
attributes?: Record<string, string | null>
tag?: string
textContent?: string
},
label = getComboboxLabel()
) {
try {
if (label === null) return expect(label).not.toBe(null)
// Ensure menu button have these properties
expect(label).toHaveAttribute('id')
if (options.textContent) {
expect(label).toHaveTextContent(options.textContent)
}
if (options.tag) {
expect(label.tagName.toLowerCase()).toBe(options.tag)
}
// Ensure menu button has the following attributes
for (let attributeName in options.attributes) {
expect(label).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, assertComboboxLabel)
throw err
}
}
export function assertComboboxButtonLinkedWithCombobox(
button = getComboboxButton(),
combobox = getCombobox()
) {
try {
if (button === null) return expect(button).not.toBe(null)
if (combobox === null) return expect(combobox).not.toBe(null)
// Ensure link between button & combobox is correct
expect(button).toHaveAttribute('aria-controls', combobox.getAttribute('id'))
expect(combobox).toHaveAttribute('aria-labelledby', button.getAttribute('id'))
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, assertComboboxButtonLinkedWithCombobox)
throw err
}
}
export function assertComboboxLabelLinkedWithCombobox(
label = getComboboxLabel(),
combobox = getComboboxInput()
) {
try {
if (label === null) return expect(label).not.toBe(null)
if (combobox === null) return expect(combobox).not.toBe(null)
expect(combobox).toHaveAttribute('aria-labelledby', label.getAttribute('id'))
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, assertComboboxLabelLinkedWithCombobox)
throw err
}
}
export function assertComboboxButtonLinkedWithComboboxLabel(
button = getComboboxButton(),
label = getComboboxLabel()
) {
try {
if (button === null) return expect(button).not.toBe(null)
if (label === null) return expect(label).not.toBe(null)
// Ensure link between button & label is correct
expect(button).toHaveAttribute('aria-labelledby', `${label.id} ${button.id}`)
} catch (err) {
if (err instanceof Error)
Error.captureStackTrace(err, assertComboboxButtonLinkedWithComboboxLabel)
throw err
}
}
export function assertActiveComboboxOption(
item: HTMLElement | null,
combobox = getComboboxInput()
) {
try {
if (combobox === null) return expect(combobox).not.toBe(null)
if (item === null) return expect(item).not.toBe(null)
// Ensure link between combobox & combobox item is correct
expect(combobox).toHaveAttribute('aria-activedescendant', item.getAttribute('id'))
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, assertActiveComboboxOption)
throw err
}
}
export function assertNoActiveComboboxOption(combobox = getComboboxInput()) {
try {
if (combobox === null) return expect(combobox).not.toBe(null)
// Ensure we don't have an active combobox
expect(combobox).not.toHaveAttribute('aria-activedescendant')
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveComboboxOption)
throw err
}
}
export function assertNoSelectedComboboxOption(items = getComboboxOptions()) {
try {
for (let item of items) expect(item).not.toHaveAttribute('aria-selected')
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, assertNoSelectedComboboxOption)
throw err
}
}
export function assertComboboxOption(
item: HTMLElement | null,
options?: {
tag?: string
attributes?: Record<string, string | null>
selected?: boolean
}
) {
try {
if (item === null) return expect(item).not.toBe(null)
// Check that some attributes exists, doesn't really matter what the values are at this point in
// time, we just require them.
expect(item).toHaveAttribute('id')
// Check that we have the correct values for certain attributes
expect(item).toHaveAttribute('role', 'option')
if (!item.getAttribute('aria-disabled')) expect(item).toHaveAttribute('tabindex', '-1')
// Ensure combobox button has the following attributes
if (!options) return
for (let attributeName in options.attributes) {
expect(item).toHaveAttribute(attributeName, options.attributes[attributeName])
}
if (options.tag) {
expect(item.tagName.toLowerCase()).toBe(options.tag)
}
if (options.selected != null) {
switch (options.selected) {
case true:
return expect(item).toHaveAttribute('aria-selected', 'true')
case false:
return expect(item).not.toHaveAttribute('aria-selected')
default:
assertNever(options.selected)
}
}
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, assertComboboxOption)
throw err
}
}
@@ -311,7 +697,7 @@ export function assertListbox(
assertNever(options.state)
}
} catch (err) {
Error.captureStackTrace(err, assertListbox)
if (err instanceof Error) Error.captureStackTrace(err, assertListbox)
throw err
}
}
@@ -368,7 +754,7 @@ export function assertListboxButton(
expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
Error.captureStackTrace(err, assertListboxButton)
if (err instanceof Error) Error.captureStackTrace(err, assertListboxButton)
throw err
}
}
@@ -400,7 +786,7 @@ export function assertListboxLabel(
expect(label).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
Error.captureStackTrace(err, assertListboxLabel)
if (err instanceof Error) Error.captureStackTrace(err, assertListboxLabel)
throw err
}
}
@@ -417,7 +803,7 @@ export function assertListboxButtonLinkedWithListbox(
expect(button).toHaveAttribute('aria-controls', listbox.getAttribute('id'))
expect(listbox).toHaveAttribute('aria-labelledby', button.getAttribute('id'))
} catch (err) {
Error.captureStackTrace(err, assertListboxButtonLinkedWithListbox)
if (err instanceof Error) Error.captureStackTrace(err, assertListboxButtonLinkedWithListbox)
throw err
}
}
@@ -432,7 +818,7 @@ export function assertListboxLabelLinkedWithListbox(
expect(listbox).toHaveAttribute('aria-labelledby', label.getAttribute('id'))
} catch (err) {
Error.captureStackTrace(err, assertListboxLabelLinkedWithListbox)
if (err instanceof Error) Error.captureStackTrace(err, assertListboxLabelLinkedWithListbox)
throw err
}
}
@@ -448,7 +834,8 @@ export function assertListboxButtonLinkedWithListboxLabel(
// Ensure link between button & label is correct
expect(button).toHaveAttribute('aria-labelledby', `${label.id} ${button.id}`)
} catch (err) {
Error.captureStackTrace(err, assertListboxButtonLinkedWithListboxLabel)
if (err instanceof Error)
Error.captureStackTrace(err, assertListboxButtonLinkedWithListboxLabel)
throw err
}
}
@@ -461,7 +848,7 @@ export function assertActiveListboxOption(item: HTMLElement | null, listbox = ge
// Ensure link between listbox & listbox item is correct
expect(listbox).toHaveAttribute('aria-activedescendant', item.getAttribute('id'))
} catch (err) {
Error.captureStackTrace(err, assertActiveListboxOption)
if (err instanceof Error) Error.captureStackTrace(err, assertActiveListboxOption)
throw err
}
}
@@ -473,7 +860,7 @@ export function assertNoActiveListboxOption(listbox = getListbox()) {
// Ensure we don't have an active listbox
expect(listbox).not.toHaveAttribute('aria-activedescendant')
} catch (err) {
Error.captureStackTrace(err, assertNoActiveListboxOption)
if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveListboxOption)
throw err
}
}
@@ -482,7 +869,7 @@ export function assertNoSelectedListboxOption(items = getListboxOptions()) {
try {
for (let item of items) expect(item).not.toHaveAttribute('aria-selected')
} catch (err) {
Error.captureStackTrace(err, assertNoSelectedListboxOption)
if (err instanceof Error) Error.captureStackTrace(err, assertNoSelectedListboxOption)
throw err
}
}
@@ -530,7 +917,7 @@ export function assertListboxOption(
}
}
} catch (err) {
Error.captureStackTrace(err, assertListboxOption)
if (err instanceof Error) Error.captureStackTrace(err, assertListboxOption)
throw err
}
}
@@ -597,7 +984,7 @@ export function assertSwitch(
assertNever(options.state)
}
} catch (err) {
Error.captureStackTrace(err, assertSwitch)
if (err instanceof Error) Error.captureStackTrace(err, assertSwitch)
throw err
}
}
@@ -678,7 +1065,7 @@ export function assertDisclosureButton(
expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
Error.captureStackTrace(err, assertDisclosureButton)
if (err instanceof Error) Error.captureStackTrace(err, assertDisclosureButton)
throw err
}
}
@@ -725,7 +1112,7 @@ export function assertDisclosurePanel(
assertNever(options.state)
}
} catch (err) {
Error.captureStackTrace(err, assertDisclosurePanel)
if (err instanceof Error) Error.captureStackTrace(err, assertDisclosurePanel)
throw err
}
}
@@ -810,7 +1197,7 @@ export function assertPopoverButton(
expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
Error.captureStackTrace(err, assertPopoverButton)
if (err instanceof Error) Error.captureStackTrace(err, assertPopoverButton)
throw err
}
}
@@ -857,7 +1244,7 @@ export function assertPopoverPanel(
assertNever(options.state)
}
} catch (err) {
Error.captureStackTrace(err, assertPopoverPanel)
if (err instanceof Error) Error.captureStackTrace(err, assertPopoverPanel)
throw err
}
}
@@ -984,7 +1371,7 @@ export function assertDialog(
assertNever(options.state)
}
} catch (err) {
Error.captureStackTrace(err, assertDialog)
if (err instanceof Error) Error.captureStackTrace(err, assertDialog)
throw err
}
}
@@ -1040,7 +1427,7 @@ export function assertDialogTitle(
assertNever(options.state)
}
} catch (err) {
Error.captureStackTrace(err, assertDialogTitle)
if (err instanceof Error) Error.captureStackTrace(err, assertDialogTitle)
throw err
}
}
@@ -1096,7 +1483,7 @@ export function assertDialogDescription(
assertNever(options.state)
}
} catch (err) {
Error.captureStackTrace(err, assertDialogDescription)
if (err instanceof Error) Error.captureStackTrace(err, assertDialogDescription)
throw err
}
}
@@ -1143,7 +1530,7 @@ export function assertDialogOverlay(
assertNever(options.state)
}
} catch (err) {
Error.captureStackTrace(err, assertDialogOverlay)
if (err instanceof Error) Error.captureStackTrace(err, assertDialogOverlay)
throw err
}
}
@@ -1185,7 +1572,7 @@ export function assertRadioGroupLabel(
expect(label).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
Error.captureStackTrace(err, assertRadioGroupLabel)
if (err instanceof Error) Error.captureStackTrace(err, assertRadioGroupLabel)
throw err
}
}
@@ -1267,7 +1654,7 @@ export function assertTabs(
}
}
} catch (err) {
Error.captureStackTrace(err, assertTabs)
if (err instanceof Error) Error.captureStackTrace(err, assertTabs)
throw err
}
}
@@ -1287,7 +1674,7 @@ export function assertActiveElement(element: HTMLElement | null) {
expect(document.activeElement?.outerHTML).toBe(element.outerHTML)
}
} catch (err) {
Error.captureStackTrace(err, assertActiveElement)
if (err instanceof Error) Error.captureStackTrace(err, assertActiveElement)
throw err
}
}
@@ -1297,7 +1684,7 @@ export function assertContainsActiveElement(element: HTMLElement | null) {
if (element === null) return expect(element).not.toBe(null)
expect(element.contains(document.activeElement)).toBe(true)
} catch (err) {
Error.captureStackTrace(err, assertContainsActiveElement)
if (err instanceof Error) Error.captureStackTrace(err, assertContainsActiveElement)
throw err
}
}
@@ -1311,7 +1698,7 @@ export function assertHidden(element: HTMLElement | null) {
expect(element).toHaveAttribute('hidden')
expect(element).toHaveStyle({ display: 'none' })
} catch (err) {
Error.captureStackTrace(err, assertHidden)
if (err instanceof Error) Error.captureStackTrace(err, assertHidden)
throw err
}
}
@@ -1323,7 +1710,7 @@ export function assertVisible(element: HTMLElement | null) {
expect(element).not.toHaveAttribute('hidden')
expect(element).not.toHaveStyle({ display: 'none' })
} catch (err) {
Error.captureStackTrace(err, assertVisible)
if (err instanceof Error) Error.captureStackTrace(err, assertVisible)
throw err
}
}
@@ -1336,7 +1723,7 @@ export function assertFocusable(element: HTMLElement | null) {
expect(isFocusableElement(element, FocusableMode.Strict)).toBe(true)
} catch (err) {
Error.captureStackTrace(err, assertFocusable)
if (err instanceof Error) Error.captureStackTrace(err, assertFocusable)
throw err
}
}
@@ -1347,7 +1734,7 @@ export function assertNotFocusable(element: HTMLElement | null) {
expect(isFocusableElement(element, FocusableMode.Strict)).toBe(false)
} catch (err) {
Error.captureStackTrace(err, assertNotFocusable)
if (err instanceof Error) Error.captureStackTrace(err, assertNotFocusable)
throw err
}
}
@@ -1,4 +1,7 @@
import { fireEvent } from '@testing-library/react'
import { disposables } from '../utils/disposables'
let d = disposables()
function nextFrame(cb: Function): void {
setImmediate(() =>
@@ -33,7 +36,19 @@ export function shift(event: Partial<KeyboardEvent>) {
}
export function word(input: string): Partial<KeyboardEvent>[] {
return input.split('').map(key => ({ key }))
let result = input.split('').map(key => ({ key }))
d.enqueue(() => {
let element = document.activeElement
if (element instanceof HTMLInputElement) {
fireEvent.change(element, {
target: Object.assign({}, element, { value: input }),
})
}
})
return result
}
let Default = Symbol()
@@ -76,6 +91,9 @@ let order: Record<
function keypress(element, event) {
return fireEvent.keyPress(element, event)
},
function input(element, event) {
return fireEvent.input(element, event)
},
function keyup(element, event) {
return fireEvent.keyUp(element, event)
},
@@ -159,9 +177,11 @@ export async function type(events: Partial<KeyboardEvent>[], element = document.
// We don't want to actually wait in our tests, so let's advance
jest.runAllTimers()
await d.workQueue()
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, type)
if (err instanceof Error) Error.captureStackTrace(err, type)
throw err
} finally {
jest.useRealTimers()
@@ -224,7 +244,7 @@ export async function click(
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, click)
if (err instanceof Error) Error.captureStackTrace(err, click)
throw err
}
}
@@ -237,7 +257,7 @@ export async function focus(element: Document | Element | Window | Node | null)
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, focus)
if (err instanceof Error) Error.captureStackTrace(err, focus)
throw err
}
}
@@ -251,7 +271,7 @@ export async function mouseEnter(element: Document | Element | Window | null) {
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, mouseEnter)
if (err instanceof Error) Error.captureStackTrace(err, mouseEnter)
throw err
}
}
@@ -265,7 +285,7 @@ export async function mouseMove(element: Document | Element | Window | null) {
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, mouseMove)
if (err instanceof Error) Error.captureStackTrace(err, mouseMove)
throw err
}
}
@@ -281,7 +301,7 @@ export async function mouseLeave(element: Document | Element | Window | null) {
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, mouseLeave)
if (err instanceof Error) Error.captureStackTrace(err, mouseLeave)
throw err
}
}
@@ -1,7 +1,12 @@
export function disposables() {
let disposables: Function[] = []
let queue: Function[] = []
let api = {
enqueue(fn: Function) {
queue.push(fn)
},
requestAnimationFrame(...args: Parameters<typeof requestAnimationFrame>) {
let raf = requestAnimationFrame(...args)
api.add(() => cancelAnimationFrame(raf))
@@ -27,6 +32,12 @@ export function disposables() {
dispose()
}
},
async workQueue() {
for (let handle of queue.splice(0)) {
await handle()
}
},
}
return api
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,686 @@
import {
defineComponent,
ref,
provide,
inject,
onMounted,
onUnmounted,
computed,
nextTick,
InjectionKey,
Ref,
ComputedRef,
watchEffect,
toRaw,
watch,
PropType,
} from 'vue'
import { Features, render, omit } from '../../utils/render'
import { useId } from '../../hooks/use-id'
import { Keys } from '../../keyboard'
import { calculateActiveIndex, Focus } from '../../utils/calculate-active-index'
import { dom } from '../../utils/dom'
import { useWindowEvent } from '../../hooks/use-window-event'
import { useOpenClosed, State, useOpenClosedProvider } from '../../internal/open-closed'
import { match } from '../../utils/match'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
enum ComboboxStates {
Open,
Closed,
}
type ComboboxOptionDataRef = Ref<{ disabled: boolean; value: unknown }>
type StateDefinition = {
// State
ComboboxState: Ref<ComboboxStates>
value: ComputedRef<unknown>
orientation: Ref<'vertical' | 'horizontal'>
labelRef: Ref<HTMLLabelElement | null>
inputRef: Ref<HTMLInputElement | null>
buttonRef: Ref<HTMLButtonElement | null>
optionsRef: Ref<HTMLDivElement | null>
inputPropsRef: Ref<{ displayValue?: (item: unknown) => string }>
disabled: Ref<boolean>
options: Ref<{ id: string; dataRef: ComboboxOptionDataRef }[]>
activeOptionIndex: Ref<number | null>
// State mutators
closeCombobox(): void
openCombobox(): void
goToOption(focus: Focus, id?: string): void
selectOption(id: string): void
selectActiveOption(): void
registerOption(id: string, dataRef: ComboboxOptionDataRef): void
unregisterOption(id: string): void
select(value: unknown): void
}
let ComboboxContext = Symbol('ComboboxContext') as InjectionKey<StateDefinition>
function useComboboxContext(component: string) {
let context = inject(ComboboxContext, null)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent <Combobox /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxContext)
throw err
}
return context
}
// ---
export let Combobox = defineComponent({
name: 'Combobox',
emits: { 'update:modelValue': (_value: any) => true },
props: {
as: { type: [Object, String], default: 'template' },
disabled: { type: [Boolean], default: false },
horizontal: { type: [Boolean], default: false },
modelValue: { type: [Object, String, Number, Boolean] },
},
setup(props, { slots, attrs, emit }) {
let ComboboxState = ref<StateDefinition['ComboboxState']['value']>(ComboboxStates.Closed)
let labelRef = ref<StateDefinition['labelRef']['value']>(null)
let inputRef = ref<StateDefinition['inputRef']['value']>(null) as StateDefinition['inputRef']
let buttonRef = ref<StateDefinition['buttonRef']['value']>(null) as StateDefinition['buttonRef']
let optionsRef = ref<StateDefinition['optionsRef']['value']>(
null
) as StateDefinition['optionsRef']
let options = ref<StateDefinition['options']['value']>([])
let activeOptionIndex = ref<StateDefinition['activeOptionIndex']['value']>(null)
let value = computed(() => props.modelValue)
let api = {
ComboboxState,
value,
orientation: computed(() => (props.horizontal ? 'horizontal' : 'vertical')),
inputRef,
labelRef,
buttonRef,
optionsRef,
disabled: computed(() => props.disabled),
options,
activeOptionIndex,
inputPropsRef: ref<{ displayValue?: (item: unknown) => string }>({ displayValue: undefined }),
closeCombobox() {
if (props.disabled) return
if (ComboboxState.value === ComboboxStates.Closed) return
ComboboxState.value = ComboboxStates.Closed
activeOptionIndex.value = null
},
openCombobox() {
if (props.disabled) return
if (ComboboxState.value === ComboboxStates.Open) return
ComboboxState.value = ComboboxStates.Open
},
goToOption(focus: Focus, id?: string) {
if (props.disabled) return
if (ComboboxState.value === ComboboxStates.Closed) return
let nextActiveOptionIndex = calculateActiveIndex(
focus === Focus.Specific
? { focus: Focus.Specific, id: id! }
: { focus: focus as Exclude<Focus, Focus.Specific> },
{
resolveItems: () => options.value,
resolveActiveIndex: () => activeOptionIndex.value,
resolveId: option => option.id,
resolveDisabled: option => option.dataRef.disabled,
}
)
if (activeOptionIndex.value === nextActiveOptionIndex) return
activeOptionIndex.value = nextActiveOptionIndex
},
syncInputValue() {
let value = api.value.value
if (!dom(api.inputRef)) return
if (value === undefined) return
let displayValue = api.inputPropsRef.value.displayValue
if (typeof displayValue === 'function') {
api.inputRef!.value!.value = displayValue(value)
} else if (typeof value === 'string') {
api.inputRef!.value!.value = value
}
},
selectOption(id: string) {
let option = options.value.find(item => item.id === id)
if (!option) return
let { dataRef } = option
emit('update:modelValue', dataRef.value)
api.syncInputValue()
},
selectActiveOption() {
if (activeOptionIndex.value === null) return
let { dataRef } = options.value[activeOptionIndex.value]
emit('update:modelValue', dataRef.value)
api.syncInputValue()
},
registerOption(id: string, dataRef: ComboboxOptionDataRef) {
let currentActiveOption =
activeOptionIndex.value !== null ? options.value[activeOptionIndex.value] : null
let orderMap = Array.from(
optionsRef.value?.querySelectorAll('[id^="headlessui-combobox-option-"]') ?? []
).reduce(
(lookup, element, index) => Object.assign(lookup, { [element.id]: index }),
{}
) as Record<string, number>
// @ts-expect-error The expected type comes from property 'dataRef' which is declared here on type '{ id: string; dataRef: { textValue: string; disabled: boolean; }; }'
options.value = [...options.value, { id, dataRef }].sort(
(a, z) => orderMap[a.id] - orderMap[z.id]
)
// If we inserted an option before the current active option then the
// active option index would be wrong. To fix this, we will re-lookup
// the correct index.
activeOptionIndex.value = (() => {
if (currentActiveOption === null) return null
return options.value.indexOf(currentActiveOption)
})()
},
unregisterOption(id: string) {
let nextOptions = options.value.slice()
let currentActiveOption =
activeOptionIndex.value !== null ? nextOptions[activeOptionIndex.value] : null
let idx = nextOptions.findIndex(a => a.id === id)
if (idx !== -1) nextOptions.splice(idx, 1)
options.value = nextOptions
activeOptionIndex.value = (() => {
if (idx === activeOptionIndex.value) return null
if (currentActiveOption === null) return null
// If we removed the option before the actual active index, then it would be out of sync. To
// fix this, we will find the correct (new) index position.
return nextOptions.indexOf(currentActiveOption)
})()
},
}
useWindowEvent('mousedown', event => {
let target = event.target as HTMLElement
let active = document.activeElement
if (ComboboxState.value !== ComboboxStates.Open) return
if (dom(inputRef)?.contains(target)) return
if (dom(buttonRef)?.contains(target)) return
if (dom(optionsRef)?.contains(target)) return
api.closeCombobox()
if (active !== document.body && active?.contains(target)) return // Keep focus on newly clicked/focused element
if (!event.defaultPrevented) dom(inputRef)?.focus({ preventScroll: true })
})
watchEffect(() => {
api.syncInputValue()
})
// @ts-expect-error Types of property 'dataRef' are incompatible.
provide(ComboboxContext, api)
useOpenClosedProvider(
computed(() =>
match(ComboboxState.value, {
[ComboboxStates.Open]: State.Open,
[ComboboxStates.Closed]: State.Closed,
})
)
)
return () => {
let slot = { open: ComboboxState.value === ComboboxStates.Open, disabled: props.disabled }
return render({
props: omit(props, ['modelValue', 'onUpdate:modelValue', 'disabled', 'horizontal']),
slot,
slots,
attrs,
name: 'Combobox',
})
}
},
})
// ---
export let ComboboxLabel = defineComponent({
name: 'ComboboxLabel',
props: { as: { type: [Object, String], default: 'label' } },
render() {
let api = useComboboxContext('ComboboxLabel')
let slot = {
open: api.ComboboxState.value === ComboboxStates.Open,
disabled: api.disabled.value,
}
let propsWeControl = { id: this.id, ref: 'el', onClick: this.handleClick }
return render({
props: { ...this.$props, ...propsWeControl },
slot,
attrs: this.$attrs,
slots: this.$slots,
name: 'ComboboxLabel',
})
},
setup() {
let api = useComboboxContext('ComboboxLabel')
let id = `headlessui-combobox-label-${useId()}`
return {
id,
el: api.labelRef,
handleClick() {
dom(api.inputRef)?.focus({ preventScroll: true })
},
}
},
})
// ---
export let ComboboxButton = defineComponent({
name: 'ComboboxButton',
props: {
as: { type: [Object, String], default: 'button' },
},
render() {
let api = useComboboxContext('ComboboxButton')
let slot = {
open: api.ComboboxState.value === ComboboxStates.Open,
disabled: api.disabled.value,
}
let propsWeControl = {
ref: 'el',
id: this.id,
type: this.type,
tabindex: '-1',
'aria-haspopup': true,
'aria-controls': dom(api.optionsRef)?.id,
'aria-expanded': api.disabled.value
? undefined
: api.ComboboxState.value === ComboboxStates.Open,
'aria-labelledby': api.labelRef.value
? [dom(api.labelRef)?.id, this.id].join(' ')
: undefined,
disabled: api.disabled.value === true ? true : undefined,
onKeydown: this.handleKeydown,
onClick: this.handleClick,
}
return render({
props: { ...this.$props, ...propsWeControl },
slot,
attrs: this.$attrs,
slots: this.$slots,
name: 'ComboboxButton',
})
},
setup(props, { attrs }) {
let api = useComboboxContext('ComboboxButton')
let id = `headlessui-combobox-button-${useId()}`
function handleClick(event: MouseEvent) {
if (api.disabled.value) return
if (api.ComboboxState.value === ComboboxStates.Open) {
api.closeCombobox()
} else {
event.preventDefault()
api.openCombobox()
}
nextTick(() => dom(api.inputRef)?.focus({ preventScroll: true }))
}
function handleKeydown(event: KeyboardEvent) {
switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
case match(api.orientation.value, {
vertical: Keys.ArrowDown,
horizontal: Keys.ArrowRight,
}):
event.preventDefault()
event.stopPropagation()
if (api.ComboboxState.value === ComboboxStates.Closed) {
api.openCombobox()
// TODO: We can't do this outside next frame because the options aren't rendered yet
// But doing this in next frame results in a flicker because the dom mutations are async here
// Basically:
// Sync -> no option list yet
// Next frame -> option list already rendered with selection -> dispatch -> next frame -> now we have the focus on the right element
// TODO: The spec here is underspecified. There's mention of skipping to the next item when autocomplete has suggested something but nothing regarding a non-autocomplete selection/value
nextTick(() => {
if (!api.value.value) {
api.goToOption(Focus.First)
}
})
}
nextTick(() => api.inputRef.value?.focus({ preventScroll: true }))
return
case match(api.orientation.value, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }):
event.preventDefault()
event.stopPropagation()
if (api.ComboboxState.value === ComboboxStates.Closed) {
api.openCombobox()
nextTick(() => {
if (!api.value.value) {
api.goToOption(Focus.Last)
}
})
}
nextTick(() => api.inputRef.value?.focus({ preventScroll: true }))
return
case Keys.Escape:
event.preventDefault()
event.stopPropagation()
api.closeCombobox()
nextTick(() => api.inputRef.value?.focus({ preventScroll: true }))
return
}
}
return {
api,
id,
el: api.buttonRef,
type: useResolveButtonType(
computed(() => ({ as: props.as, type: attrs.type })),
api.buttonRef
),
handleClick,
handleKeydown,
}
},
})
// ---
export let ComboboxInput = defineComponent({
name: 'ComboboxInput',
props: {
as: { type: [Object, String], default: 'input' },
static: { type: Boolean, default: false },
unmount: { type: Boolean, default: true },
displayValue: { type: Function as PropType<(item: unknown) => string> },
},
emits: {
change: (_value: Event & { target: HTMLInputElement }) => true,
},
render() {
let api = useComboboxContext('ComboboxInput')
let slot = { open: api.ComboboxState.value === ComboboxStates.Open }
let propsWeControl = {
'aria-activedescendant':
api.activeOptionIndex.value === null
? undefined
: api.options.value[api.activeOptionIndex.value]?.id,
'aria-labelledby': dom(api.labelRef)?.id ?? dom(api.buttonRef)?.id,
'aria-orientation': api.orientation.value,
id: this.id,
onKeydown: this.handleKeyDown,
onChange: this.handleChange,
role: 'combobox',
tabIndex: 0,
ref: 'el',
}
let passThroughProps = this.$props
return render({
props: { ...passThroughProps, ...propsWeControl },
slot,
attrs: this.$attrs,
slots: this.$slots,
features: Features.RenderStrategy | Features.Static,
name: 'ComboboxInput',
})
},
setup(props, { emit }) {
let api = useComboboxContext('ComboboxInput')
let id = `headlessui-combobox-input-${useId()}`
api.inputPropsRef = computed(() => props)
function handleKeyDown(event: KeyboardEvent) {
switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
case Keys.Enter:
event.preventDefault()
event.stopPropagation()
api.selectActiveOption()
api.closeCombobox()
break
case match(api.orientation.value, {
vertical: Keys.ArrowDown,
horizontal: Keys.ArrowRight,
}):
event.preventDefault()
event.stopPropagation()
return match(api.ComboboxState.value, {
[ComboboxStates.Open]: () => api.goToOption(Focus.Next),
[ComboboxStates.Closed]: () => {
api.openCombobox()
nextTick(() => {
if (!api.value.value) {
api.goToOption(Focus.First)
}
})
},
})
case match(api.orientation.value, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }):
event.preventDefault()
event.stopPropagation()
return match(api.ComboboxState.value, {
[ComboboxStates.Open]: () => api.goToOption(Focus.Previous),
[ComboboxStates.Closed]: () => {
api.openCombobox()
nextTick(() => {
if (!api.value.value) {
api.goToOption(Focus.Last)
}
})
},
})
case Keys.Home:
case Keys.PageUp:
event.preventDefault()
event.stopPropagation()
return api.goToOption(Focus.First)
case Keys.End:
case Keys.PageDown:
event.preventDefault()
event.stopPropagation()
return api.goToOption(Focus.Last)
case Keys.Escape:
event.preventDefault()
event.stopPropagation()
api.closeCombobox()
break
case Keys.Tab:
api.selectActiveOption()
api.closeCombobox()
break
}
}
function handleChange(event: Event & { target: HTMLInputElement }) {
api.openCombobox()
emit('change', event)
}
return { id, el: api.inputRef, handleKeyDown, handleChange }
},
})
// ---
export let ComboboxOptions = defineComponent({
name: 'ComboboxOptions',
props: {
as: { type: [Object, String], default: 'ul' },
static: { type: Boolean, default: false },
unmount: { type: Boolean, default: true },
},
render() {
let api = useComboboxContext('ComboboxOptions')
let slot = { open: api.ComboboxState.value === ComboboxStates.Open }
let propsWeControl = {
'aria-activedescendant':
api.activeOptionIndex.value === null
? undefined
: api.options.value[api.activeOptionIndex.value]?.id,
'aria-labelledby': dom(api.labelRef)?.id ?? dom(api.buttonRef)?.id,
'aria-orientation': api.orientation.value,
id: this.id,
ref: 'el',
role: 'listbox',
}
let passThroughProps = this.$props
return render({
props: { ...passThroughProps, ...propsWeControl },
slot,
attrs: this.$attrs,
slots: this.$slots,
features: Features.RenderStrategy | Features.Static,
visible: this.visible,
name: 'ComboboxOptions',
})
},
setup() {
let api = useComboboxContext('ComboboxOptions')
let id = `headlessui-combobox-options-${useId()}`
let usesOpenClosedState = useOpenClosed()
let visible = computed(() => {
if (usesOpenClosedState !== null) {
return usesOpenClosedState.value === State.Open
}
return api.ComboboxState.value === ComboboxStates.Open
})
return { id, el: api.optionsRef, visible }
},
})
export let ComboboxOption = defineComponent({
name: 'ComboboxOption',
props: {
as: { type: [Object, String], default: 'li' },
value: { type: [Object, String, Number, Boolean] },
disabled: { type: Boolean, default: false },
},
setup(props, { slots, attrs }) {
let api = useComboboxContext('ComboboxOption')
let id = `headlessui-combobox-option-${useId()}`
let active = computed(() => {
return api.activeOptionIndex.value !== null
? api.options.value[api.activeOptionIndex.value].id === id
: false
})
let selected = computed(() => toRaw(api.value.value) === toRaw(props.value))
let dataRef = computed<ComboboxOptionDataRef['value']>(() => ({
disabled: props.disabled,
value: props.value,
}))
onMounted(() => api.registerOption(id, dataRef))
onUnmounted(() => api.unregisterOption(id))
onMounted(() => {
watch(
[api.ComboboxState, selected],
() => {
if (api.ComboboxState.value !== ComboboxStates.Open) return
if (!selected.value) return
api.goToOption(Focus.Specific, id)
},
{ immediate: true }
)
})
watchEffect(() => {
if (api.ComboboxState.value !== ComboboxStates.Open) return
if (!active.value) return
nextTick(() => document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' }))
})
function handleClick(event: MouseEvent) {
if (props.disabled) return event.preventDefault()
api.selectOption(id)
api.closeCombobox()
nextTick(() => dom(api.inputRef)?.focus({ preventScroll: true }))
}
function handleFocus() {
if (props.disabled) return api.goToOption(Focus.Nothing)
api.goToOption(Focus.Specific, id)
}
function handleMove() {
if (props.disabled) return
if (active.value) return
api.goToOption(Focus.Specific, id)
}
function handleLeave() {
if (props.disabled) return
if (!active.value) return
api.goToOption(Focus.Nothing)
}
return () => {
let { disabled } = props
let slot = { active: active.value, selected: selected.value, disabled }
let propsWeControl = {
id,
role: 'option',
tabIndex: disabled === true ? undefined : -1,
'aria-disabled': disabled === true ? true : undefined,
'aria-selected': selected.value === true ? selected.value : undefined,
disabled: undefined, // Never forward the `disabled` prop
onClick: handleClick,
onFocus: handleFocus,
onPointermove: handleMove,
onMousemove: handleMove,
onPointerleave: handleLeave,
onMouseleave: handleLeave,
}
return render({
props: { ...props, ...propsWeControl },
slot,
attrs,
slots,
name: 'ComboboxOption',
})
}
},
})
@@ -6,6 +6,14 @@ import * as HeadlessUI from './index'
*/
it('should expose the correct components', () => {
expect(Object.keys(HeadlessUI)).toEqual([
// Combobox
'Combobox',
'ComboboxLabel',
'ComboboxButton',
'ComboboxInput',
'ComboboxOptions',
'ComboboxOption',
// Dialog
'Dialog',
'DialogOverlay',
+1
View File
@@ -1,3 +1,4 @@
export * from './components/combobox/combobox'
export * from './components/dialog/dialog'
export * from './components/disclosure/disclosure'
export * from './components/focus-trap/focus-trap'
@@ -91,7 +91,7 @@ export function assertMenuButton(
expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
Error.captureStackTrace(err, assertMenuButton)
if (err instanceof Error) Error.captureStackTrace(err, assertMenuButton)
throw err
}
}
@@ -105,7 +105,7 @@ export function assertMenuButtonLinkedWithMenu(button = getMenuButton(), menu =
expect(button).toHaveAttribute('aria-controls', menu.getAttribute('id'))
expect(menu).toHaveAttribute('aria-labelledby', button.getAttribute('id'))
} catch (err) {
Error.captureStackTrace(err, assertMenuButtonLinkedWithMenu)
if (err instanceof Error) Error.captureStackTrace(err, assertMenuButtonLinkedWithMenu)
throw err
}
}
@@ -118,7 +118,7 @@ export function assertMenuLinkedWithMenuItem(item: HTMLElement | null, menu = ge
// Ensure link between menu & menu item is correct
expect(menu).toHaveAttribute('aria-activedescendant', item.getAttribute('id'))
} catch (err) {
Error.captureStackTrace(err, assertMenuLinkedWithMenuItem)
if (err instanceof Error) Error.captureStackTrace(err, assertMenuLinkedWithMenuItem)
throw err
}
}
@@ -130,7 +130,7 @@ export function assertNoActiveMenuItem(menu = getMenu()) {
// Ensure we don't have an active menu
expect(menu).not.toHaveAttribute('aria-activedescendant')
} catch (err) {
Error.captureStackTrace(err, assertNoActiveMenuItem)
if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveMenuItem)
throw err
}
}
@@ -183,7 +183,7 @@ export function assertMenu(
assertNever(options.state)
}
} catch (err) {
Error.captureStackTrace(err, assertMenu)
if (err instanceof Error) Error.captureStackTrace(err, assertMenu)
throw err
}
}
@@ -214,7 +214,393 @@ export function assertMenuItem(
}
}
} catch (err) {
Error.captureStackTrace(err, assertMenuItem)
if (err instanceof Error) Error.captureStackTrace(err, assertMenuItem)
throw err
}
}
// ---
export function getComboboxLabel(): HTMLElement | null {
return document.querySelector('label,[id^="headlessui-combobox-label"]')
}
export function getComboboxButton(): HTMLElement | null {
return document.querySelector('button,[role="button"],[id^="headlessui-combobox-button-"]')
}
export function getComboboxButtons(): HTMLElement[] {
return Array.from(document.querySelectorAll('button,[role="button"]'))
}
export function getComboboxInput(): HTMLInputElement | null {
return document.querySelector('[role="combobox"]')
}
export function getCombobox(): HTMLElement | null {
return document.querySelector('[role="listbox"]')
}
export function getComboboxInputs(): HTMLElement[] {
return Array.from(document.querySelectorAll('[role="combobox"]'))
}
export function getComboboxes(): HTMLElement[] {
return Array.from(document.querySelectorAll('[role="listbox"]'))
}
export function getComboboxOptions(): HTMLElement[] {
return Array.from(document.querySelectorAll('[role="option"]'))
}
// ---
export enum ComboboxState {
/** The combobox is visible to the user. */
Visible,
/** The combobox is **not** visible to the user. It's still in the DOM, but it is hidden. */
InvisibleHidden,
/** The combobox is **not** visible to the user. It's not in the DOM, it is unmounted. */
InvisibleUnmounted,
}
export function assertCombobox(
options: {
attributes?: Record<string, string | null>
textContent?: string
state: ComboboxState
orientation?: 'horizontal' | 'vertical'
},
combobox = getComboboxInput()
) {
let { orientation = 'vertical' } = options
try {
switch (options.state) {
case ComboboxState.InvisibleHidden:
if (combobox === null) return expect(combobox).not.toBe(null)
assertHidden(combobox)
expect(combobox).toHaveAttribute('aria-labelledby')
expect(combobox).toHaveAttribute('aria-orientation', orientation)
expect(combobox).toHaveAttribute('role', 'combobox')
if (options.textContent) expect(combobox).toHaveTextContent(options.textContent)
for (let attributeName in options.attributes) {
expect(combobox).toHaveAttribute(attributeName, options.attributes[attributeName])
}
break
case ComboboxState.Visible:
if (combobox === null) return expect(combobox).not.toBe(null)
assertVisible(combobox)
expect(combobox).toHaveAttribute('aria-labelledby')
expect(combobox).toHaveAttribute('aria-orientation', orientation)
expect(combobox).toHaveAttribute('role', 'combobox')
if (options.textContent) expect(combobox).toHaveTextContent(options.textContent)
for (let attributeName in options.attributes) {
expect(combobox).toHaveAttribute(attributeName, options.attributes[attributeName])
}
break
case ComboboxState.InvisibleUnmounted:
expect(combobox).toBe(null)
break
default:
assertNever(options.state)
}
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, assertCombobox)
throw err
}
}
export function assertComboboxList(
options: {
attributes?: Record<string, string | null>
textContent?: string
state: ComboboxState
orientation?: 'horizontal' | 'vertical'
},
listbox = getCombobox()
) {
let { orientation = 'vertical' } = options
try {
switch (options.state) {
case ComboboxState.InvisibleHidden:
if (listbox === null) return expect(listbox).not.toBe(null)
assertHidden(listbox)
expect(listbox).toHaveAttribute('aria-labelledby')
expect(listbox).toHaveAttribute('aria-orientation', orientation)
expect(listbox).toHaveAttribute('role', 'listbox')
if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
for (let attributeName in options.attributes) {
expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName])
}
break
case ComboboxState.Visible:
if (listbox === null) return expect(listbox).not.toBe(null)
assertVisible(listbox)
expect(listbox).toHaveAttribute('aria-labelledby')
expect(listbox).toHaveAttribute('aria-orientation', orientation)
expect(listbox).toHaveAttribute('role', 'listbox')
if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
for (let attributeName in options.attributes) {
expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName])
}
break
case ComboboxState.InvisibleUnmounted:
expect(listbox).toBe(null)
break
default:
assertNever(options.state)
}
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, assertCombobox)
throw err
}
}
export function assertComboboxButton(
options: {
attributes?: Record<string, string | null>
textContent?: string
state: ComboboxState
},
button = getComboboxButton()
) {
try {
if (button === null) return expect(button).not.toBe(null)
// Ensure menu button have these properties
expect(button).toHaveAttribute('id')
expect(button).toHaveAttribute('aria-haspopup')
switch (options.state) {
case ComboboxState.Visible:
expect(button).toHaveAttribute('aria-controls')
expect(button).toHaveAttribute('aria-expanded', 'true')
break
case ComboboxState.InvisibleHidden:
expect(button).toHaveAttribute('aria-controls')
if (button.hasAttribute('disabled')) {
expect(button).not.toHaveAttribute('aria-expanded')
} else {
expect(button).toHaveAttribute('aria-expanded', 'false')
}
break
case ComboboxState.InvisibleUnmounted:
expect(button).not.toHaveAttribute('aria-controls')
if (button.hasAttribute('disabled')) {
expect(button).not.toHaveAttribute('aria-expanded')
} else {
expect(button).toHaveAttribute('aria-expanded', 'false')
}
break
default:
assertNever(options.state)
}
if (options.textContent) {
expect(button).toHaveTextContent(options.textContent)
}
// Ensure menu button has the following attributes
for (let attributeName in options.attributes) {
expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, assertComboboxButton)
throw err
}
}
export function assertComboboxLabel(
options: {
attributes?: Record<string, string | null>
tag?: string
textContent?: string
},
label = getComboboxLabel()
) {
try {
if (label === null) return expect(label).not.toBe(null)
// Ensure menu button have these properties
expect(label).toHaveAttribute('id')
if (options.textContent) {
expect(label).toHaveTextContent(options.textContent)
}
if (options.tag) {
expect(label.tagName.toLowerCase()).toBe(options.tag)
}
// Ensure menu button has the following attributes
for (let attributeName in options.attributes) {
expect(label).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, assertComboboxLabel)
throw err
}
}
export function assertComboboxButtonLinkedWithCombobox(
button = getComboboxButton(),
combobox = getCombobox()
) {
try {
if (button === null) return expect(button).not.toBe(null)
if (combobox === null) return expect(combobox).not.toBe(null)
// Ensure link between button & combobox is correct
expect(button).toHaveAttribute('aria-controls', combobox.getAttribute('id'))
expect(combobox).toHaveAttribute('aria-labelledby', button.getAttribute('id'))
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, assertComboboxButtonLinkedWithCombobox)
throw err
}
}
export function assertComboboxLabelLinkedWithCombobox(
label = getComboboxLabel(),
combobox = getComboboxInput()
) {
try {
if (label === null) return expect(label).not.toBe(null)
if (combobox === null) return expect(combobox).not.toBe(null)
expect(combobox).toHaveAttribute('aria-labelledby', label.getAttribute('id'))
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, assertComboboxLabelLinkedWithCombobox)
throw err
}
}
export function assertComboboxButtonLinkedWithComboboxLabel(
button = getComboboxButton(),
label = getComboboxLabel()
) {
try {
if (button === null) return expect(button).not.toBe(null)
if (label === null) return expect(label).not.toBe(null)
// Ensure link between button & label is correct
expect(button).toHaveAttribute('aria-labelledby', `${label.id} ${button.id}`)
} catch (err) {
if (err instanceof Error)
Error.captureStackTrace(err, assertComboboxButtonLinkedWithComboboxLabel)
throw err
}
}
export function assertActiveComboboxOption(
item: HTMLElement | null,
combobox = getComboboxInput()
) {
try {
if (combobox === null) return expect(combobox).not.toBe(null)
if (item === null) return expect(item).not.toBe(null)
// Ensure link between combobox & combobox item is correct
expect(combobox).toHaveAttribute('aria-activedescendant', item.getAttribute('id'))
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, assertActiveComboboxOption)
throw err
}
}
export function assertNoActiveComboboxOption(combobox = getComboboxInput()) {
try {
if (combobox === null) return expect(combobox).not.toBe(null)
// Ensure we don't have an active combobox
expect(combobox).not.toHaveAttribute('aria-activedescendant')
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveComboboxOption)
throw err
}
}
export function assertNoSelectedComboboxOption(items = getComboboxOptions()) {
try {
for (let item of items) expect(item).not.toHaveAttribute('aria-selected')
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, assertNoSelectedComboboxOption)
throw err
}
}
export function assertComboboxOption(
item: HTMLElement | null,
options?: {
tag?: string
attributes?: Record<string, string | null>
selected?: boolean
}
) {
try {
if (item === null) return expect(item).not.toBe(null)
// Check that some attributes exists, doesn't really matter what the values are at this point in
// time, we just require them.
expect(item).toHaveAttribute('id')
// Check that we have the correct values for certain attributes
expect(item).toHaveAttribute('role', 'option')
if (!item.getAttribute('aria-disabled')) expect(item).toHaveAttribute('tabindex', '-1')
// Ensure combobox button has the following attributes
if (!options) return
for (let attributeName in options.attributes) {
expect(item).toHaveAttribute(attributeName, options.attributes[attributeName])
}
if (options.tag) {
expect(item.tagName.toLowerCase()).toBe(options.tag)
}
if (options.selected != null) {
switch (options.selected) {
case true:
return expect(item).toHaveAttribute('aria-selected', 'true')
case false:
return expect(item).not.toHaveAttribute('aria-selected')
default:
assertNever(options.selected)
}
}
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, assertComboboxOption)
throw err
}
}
@@ -311,7 +697,7 @@ export function assertListbox(
assertNever(options.state)
}
} catch (err) {
Error.captureStackTrace(err, assertListbox)
if (err instanceof Error) Error.captureStackTrace(err, assertListbox)
throw err
}
}
@@ -368,7 +754,7 @@ export function assertListboxButton(
expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
Error.captureStackTrace(err, assertListboxButton)
if (err instanceof Error) Error.captureStackTrace(err, assertListboxButton)
throw err
}
}
@@ -400,7 +786,7 @@ export function assertListboxLabel(
expect(label).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
Error.captureStackTrace(err, assertListboxLabel)
if (err instanceof Error) Error.captureStackTrace(err, assertListboxLabel)
throw err
}
}
@@ -417,7 +803,7 @@ export function assertListboxButtonLinkedWithListbox(
expect(button).toHaveAttribute('aria-controls', listbox.getAttribute('id'))
expect(listbox).toHaveAttribute('aria-labelledby', button.getAttribute('id'))
} catch (err) {
Error.captureStackTrace(err, assertListboxButtonLinkedWithListbox)
if (err instanceof Error) Error.captureStackTrace(err, assertListboxButtonLinkedWithListbox)
throw err
}
}
@@ -432,7 +818,7 @@ export function assertListboxLabelLinkedWithListbox(
expect(listbox).toHaveAttribute('aria-labelledby', label.getAttribute('id'))
} catch (err) {
Error.captureStackTrace(err, assertListboxLabelLinkedWithListbox)
if (err instanceof Error) Error.captureStackTrace(err, assertListboxLabelLinkedWithListbox)
throw err
}
}
@@ -448,7 +834,8 @@ export function assertListboxButtonLinkedWithListboxLabel(
// Ensure link between button & label is correct
expect(button).toHaveAttribute('aria-labelledby', `${label.id} ${button.id}`)
} catch (err) {
Error.captureStackTrace(err, assertListboxButtonLinkedWithListboxLabel)
if (err instanceof Error)
Error.captureStackTrace(err, assertListboxButtonLinkedWithListboxLabel)
throw err
}
}
@@ -461,7 +848,7 @@ export function assertActiveListboxOption(item: HTMLElement | null, listbox = ge
// Ensure link between listbox & listbox item is correct
expect(listbox).toHaveAttribute('aria-activedescendant', item.getAttribute('id'))
} catch (err) {
Error.captureStackTrace(err, assertActiveListboxOption)
if (err instanceof Error) Error.captureStackTrace(err, assertActiveListboxOption)
throw err
}
}
@@ -473,7 +860,7 @@ export function assertNoActiveListboxOption(listbox = getListbox()) {
// Ensure we don't have an active listbox
expect(listbox).not.toHaveAttribute('aria-activedescendant')
} catch (err) {
Error.captureStackTrace(err, assertNoActiveListboxOption)
if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveListboxOption)
throw err
}
}
@@ -482,7 +869,7 @@ export function assertNoSelectedListboxOption(items = getListboxOptions()) {
try {
for (let item of items) expect(item).not.toHaveAttribute('aria-selected')
} catch (err) {
Error.captureStackTrace(err, assertNoSelectedListboxOption)
if (err instanceof Error) Error.captureStackTrace(err, assertNoSelectedListboxOption)
throw err
}
}
@@ -530,7 +917,7 @@ export function assertListboxOption(
}
}
} catch (err) {
Error.captureStackTrace(err, assertListboxOption)
if (err instanceof Error) Error.captureStackTrace(err, assertListboxOption)
throw err
}
}
@@ -597,7 +984,7 @@ export function assertSwitch(
assertNever(options.state)
}
} catch (err) {
Error.captureStackTrace(err, assertSwitch)
if (err instanceof Error) Error.captureStackTrace(err, assertSwitch)
throw err
}
}
@@ -678,7 +1065,7 @@ export function assertDisclosureButton(
expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
Error.captureStackTrace(err, assertDisclosureButton)
if (err instanceof Error) Error.captureStackTrace(err, assertDisclosureButton)
throw err
}
}
@@ -725,7 +1112,7 @@ export function assertDisclosurePanel(
assertNever(options.state)
}
} catch (err) {
Error.captureStackTrace(err, assertDisclosurePanel)
if (err instanceof Error) Error.captureStackTrace(err, assertDisclosurePanel)
throw err
}
}
@@ -810,7 +1197,7 @@ export function assertPopoverButton(
expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
Error.captureStackTrace(err, assertPopoverButton)
if (err instanceof Error) Error.captureStackTrace(err, assertPopoverButton)
throw err
}
}
@@ -857,7 +1244,7 @@ export function assertPopoverPanel(
assertNever(options.state)
}
} catch (err) {
Error.captureStackTrace(err, assertPopoverPanel)
if (err instanceof Error) Error.captureStackTrace(err, assertPopoverPanel)
throw err
}
}
@@ -984,7 +1371,7 @@ export function assertDialog(
assertNever(options.state)
}
} catch (err) {
Error.captureStackTrace(err, assertDialog)
if (err instanceof Error) Error.captureStackTrace(err, assertDialog)
throw err
}
}
@@ -1040,7 +1427,7 @@ export function assertDialogTitle(
assertNever(options.state)
}
} catch (err) {
Error.captureStackTrace(err, assertDialogTitle)
if (err instanceof Error) Error.captureStackTrace(err, assertDialogTitle)
throw err
}
}
@@ -1096,7 +1483,7 @@ export function assertDialogDescription(
assertNever(options.state)
}
} catch (err) {
Error.captureStackTrace(err, assertDialogDescription)
if (err instanceof Error) Error.captureStackTrace(err, assertDialogDescription)
throw err
}
}
@@ -1143,7 +1530,7 @@ export function assertDialogOverlay(
assertNever(options.state)
}
} catch (err) {
Error.captureStackTrace(err, assertDialogOverlay)
if (err instanceof Error) Error.captureStackTrace(err, assertDialogOverlay)
throw err
}
}
@@ -1185,7 +1572,7 @@ export function assertRadioGroupLabel(
expect(label).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
Error.captureStackTrace(err, assertRadioGroupLabel)
if (err instanceof Error) Error.captureStackTrace(err, assertRadioGroupLabel)
throw err
}
}
@@ -1267,7 +1654,7 @@ export function assertTabs(
}
}
} catch (err) {
Error.captureStackTrace(err, assertTabs)
if (err instanceof Error) Error.captureStackTrace(err, assertTabs)
throw err
}
}
@@ -1287,7 +1674,7 @@ export function assertActiveElement(element: HTMLElement | null) {
expect(document.activeElement?.outerHTML).toBe(element.outerHTML)
}
} catch (err) {
Error.captureStackTrace(err, assertActiveElement)
if (err instanceof Error) Error.captureStackTrace(err, assertActiveElement)
throw err
}
}
@@ -1297,7 +1684,7 @@ export function assertContainsActiveElement(element: HTMLElement | null) {
if (element === null) return expect(element).not.toBe(null)
expect(element.contains(document.activeElement)).toBe(true)
} catch (err) {
Error.captureStackTrace(err, assertContainsActiveElement)
if (err instanceof Error) Error.captureStackTrace(err, assertContainsActiveElement)
throw err
}
}
@@ -1311,7 +1698,7 @@ export function assertHidden(element: HTMLElement | null) {
expect(element).toHaveAttribute('hidden')
expect(element).toHaveStyle({ display: 'none' })
} catch (err) {
Error.captureStackTrace(err, assertHidden)
if (err instanceof Error) Error.captureStackTrace(err, assertHidden)
throw err
}
}
@@ -1323,7 +1710,7 @@ export function assertVisible(element: HTMLElement | null) {
expect(element).not.toHaveAttribute('hidden')
expect(element).not.toHaveStyle({ display: 'none' })
} catch (err) {
Error.captureStackTrace(err, assertVisible)
if (err instanceof Error) Error.captureStackTrace(err, assertVisible)
throw err
}
}
@@ -1336,7 +1723,7 @@ export function assertFocusable(element: HTMLElement | null) {
expect(isFocusableElement(element, FocusableMode.Strict)).toBe(true)
} catch (err) {
Error.captureStackTrace(err, assertFocusable)
if (err instanceof Error) Error.captureStackTrace(err, assertFocusable)
throw err
}
}
@@ -1347,7 +1734,7 @@ export function assertNotFocusable(element: HTMLElement | null) {
expect(isFocusableElement(element, FocusableMode.Strict)).toBe(false)
} catch (err) {
Error.captureStackTrace(err, assertNotFocusable)
if (err instanceof Error) Error.captureStackTrace(err, assertNotFocusable)
throw err
}
}
@@ -1,4 +1,7 @@
import { fireEvent } from '@testing-library/dom'
import { disposables } from '../utils/disposables'
let d = disposables()
function nextFrame(cb: Function): void {
setImmediate(() =>
@@ -33,7 +36,19 @@ export function shift(event: Partial<KeyboardEvent>) {
}
export function word(input: string): Partial<KeyboardEvent>[] {
return input.split('').map(key => ({ key }))
let result = input.split('').map(key => ({ key }))
d.enqueue(() => {
let element = document.activeElement
if (element instanceof HTMLInputElement) {
fireEvent.change(element, {
target: Object.assign({}, element, { value: input }),
})
}
})
return result
}
let Default = Symbol()
@@ -76,6 +91,9 @@ let order: Record<
function keypress(element, event) {
return fireEvent.keyPress(element, event)
},
function input(element, event) {
return fireEvent.input(element, event)
},
function keyup(element, event) {
return fireEvent.keyUp(element, event)
},
@@ -159,9 +177,11 @@ export async function type(events: Partial<KeyboardEvent>[], element = document.
// We don't want to actually wait in our tests, so let's advance
jest.runAllTimers()
await d.workQueue()
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, type)
if (err instanceof Error) Error.captureStackTrace(err, type)
throw err
} finally {
jest.useRealTimers()
@@ -178,7 +198,7 @@ export enum MouseButton {
}
export async function click(
element: Document | Element | Window | null,
element: Document | Element | Window | Node | null,
button = MouseButton.Left
) {
try {
@@ -224,12 +244,12 @@ export async function click(
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, click)
if (err instanceof Error) Error.captureStackTrace(err, click)
throw err
}
}
export async function focus(element: Document | Element | Window | null) {
export async function focus(element: Document | Element | Window | Node | null) {
try {
if (element === null) return expect(element).not.toBe(null)
@@ -237,11 +257,10 @@ export async function focus(element: Document | Element | Window | null) {
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, focus)
if (err instanceof Error) Error.captureStackTrace(err, focus)
throw err
}
}
export async function mouseEnter(element: Document | Element | Window | null) {
try {
if (element === null) return expect(element).not.toBe(null)
@@ -252,7 +271,7 @@ export async function mouseEnter(element: Document | Element | Window | null) {
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, mouseEnter)
if (err instanceof Error) Error.captureStackTrace(err, mouseEnter)
throw err
}
}
@@ -266,7 +285,7 @@ export async function mouseMove(element: Document | Element | Window | null) {
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, mouseMove)
if (err instanceof Error) Error.captureStackTrace(err, mouseMove)
throw err
}
}
@@ -282,7 +301,7 @@ export async function mouseLeave(element: Document | Element | Window | null) {
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, mouseLeave)
if (err instanceof Error) Error.captureStackTrace(err, mouseLeave)
throw err
}
}
@@ -1,7 +1,12 @@
export function disposables() {
let disposables: Function[] = []
let queue: Function[] = []
let api = {
enqueue(fn: Function) {
queue.push(fn)
},
requestAnimationFrame(...args: Parameters<typeof requestAnimationFrame>) {
let raf = requestAnimationFrame(...args)
api.add(() => cancelAnimationFrame(raf))
@@ -27,6 +32,12 @@ export function disposables() {
dispose()
}
},
async workQueue() {
for (let handle of queue.splice(0)) {
await handle()
}
},
}
return api
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
devIndicators: {
autoPrerender: false,
},
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "playground-react",
"version": "1.0.0",
"main": "next.config.js",
"scripts": {
"prebuild": "yarn workspace @headlessui/react build",
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"keywords": [],
"author": "Robin Malfait",
"license": "ISC",
"description": "",
"dependencies": {
"@headlessui/react": "*",
"@popperjs/core": "^2.6.0",
"framer-motion": "^6.0.0",
"next": "^12.0.8",
"react": "16.14.0",
"react-dom": "16.14.0"
}
}
+239
View File
@@ -0,0 +1,239 @@
import React, { useState, useEffect } from 'react'
import Link from 'next/link'
import Head from 'next/head'
function disposables() {
let disposables: Function[] = []
let api = {
requestAnimationFrame(...args: Parameters<typeof requestAnimationFrame>) {
let raf = requestAnimationFrame(...args)
api.add(() => cancelAnimationFrame(raf))
},
nextFrame(...args: Parameters<typeof requestAnimationFrame>) {
api.requestAnimationFrame(() => {
api.requestAnimationFrame(...args)
})
},
setTimeout(...args: Parameters<typeof setTimeout>) {
let timer = setTimeout(...args)
api.add(() => clearTimeout(timer))
},
add(cb: () => void) {
disposables.push(cb)
},
dispose() {
for (let dispose of disposables.splice(0)) {
dispose()
}
},
}
return api
}
export function useDisposables() {
// Using useState instead of useRef so that we can use the initializer function.
let [d] = useState(disposables)
useEffect(() => () => d.dispose(), [d])
return d
}
function NextLink(props: React.ComponentProps<'a'>) {
let { href, children, ...rest } = props
return (
<Link href={href}>
<a {...rest}>{children}</a>
</Link>
)
}
enum KeyDisplayMac {
ArrowUp = '↑',
ArrowDown = '↓',
ArrowLeft = '←',
ArrowRight = '→',
Home = '↖',
End = '↘',
Alt = '⌥',
CapsLock = '⇪',
Meta = '⌘',
Shift = '⇧',
Control = '⌃',
Backspace = '⌫',
Delete = '⌦',
Enter = '↵',
Escape = '⎋',
Tab = '↹',
PageUp = '⇞',
PageDown = '⇟',
' ' = '␣',
}
enum KeyDisplayWindows {
ArrowUp = '↑',
ArrowDown = '↓',
ArrowLeft = '←',
ArrowRight = '→',
Meta = 'Win',
Control = 'Ctrl',
Backspace = '⌫',
Delete = 'Del',
Escape = 'Esc',
PageUp = 'PgUp',
PageDown = 'PgDn',
' ' = '␣',
}
function tap<T>(value: T, cb: (value: T) => void) {
cb(value)
return value
}
function useKeyDisplay() {
let [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) return {}
let isMac = navigator.userAgent.indexOf('Mac OS X') !== -1
return isMac ? KeyDisplayMac : KeyDisplayWindows
}
function KeyCaster() {
let [keys, setKeys] = useState<string[]>([])
let d = useDisposables()
let KeyDisplay = useKeyDisplay()
useEffect(() => {
function handler(event: KeyboardEvent) {
setKeys(current => [
event.shiftKey && event.key !== 'Shift'
? KeyDisplay[`Shift${event.key}`] ?? event.key
: KeyDisplay[event.key] ?? event.key,
...current,
])
d.setTimeout(() => setKeys(current => tap(current.slice(), clone => clone.pop())), 2000)
}
window.addEventListener('keydown', handler, true)
return () => window.removeEventListener('keydown', handler, true)
}, [d, KeyDisplay])
if (keys.length <= 0) return null
return (
<div className="fixed z-50 px-4 py-2 overflow-hidden text-2xl tracking-wide text-blue-100 bg-blue-800 rounded-md shadow cursor-default pointer-events-none select-none right-4 bottom-4">
{keys
.slice()
.reverse()
.join(' ')}
</div>
)
}
function MyApp({ Component, pageProps }) {
return (
<>
<Head>
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
<script src="https://cdn.tailwindcss.com?plugins=forms,typography"></script>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="https://headlessui.dev/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="https://headlessui.dev/favicon-16x16.png"
/>
</Head>
<div className="flex flex-col h-screen overflow-hidden font-sans antialiased text-gray-900 bg-gray-700">
<header className="relative z-10 flex items-center justify-between flex-shrink-0 px-4 py-4 bg-gray-700 border-b border-gray-200 sm:px-6 lg:px-8">
<NextLink href="/">
<Logo className="h-6" />
</NextLink>
</header>
<KeyCaster />
<main className="flex-1 overflow-auto bg-gray-50">
<Component {...pageProps} />
</main>
</div>
</>
)
}
function Logo({ className }) {
return (
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 243 42">
<path
fill="#fff"
d="M65.74 13.663c-2.62 0-4.702.958-5.974 2.95V6.499h-4.163V33.32h4.163V23.051c0-3.908 2.159-5.518 4.896-5.518 2.62 0 4.317 1.533 4.317 4.445V33.32h4.162V21.557c0-4.982-3.083-7.894-7.4-7.894zM79.936 25.503h15.341c.077-.536.154-1.15.154-1.724 0-5.518-3.931-10.116-9.674-10.116-6.052 0-10.176 4.407-10.176 10.078 0 5.748 4.124 10.078 10.484 10.078 3.778 0 6.668-1.572 8.441-4.177l-3.43-1.993c-.925 1.341-2.66 2.376-4.972 2.376-3.084 0-5.512-1.533-6.168-4.521zm-.038-3.372c.578-2.873 2.698-4.713 5.82-4.713 2.506 0 4.934 1.418 5.512 4.713H79.898zM113.282 14.161v2.72c-1.465-1.992-3.739-3.218-6.746-3.218-5.242 0-9.597 4.368-9.597 10.078 0 5.67 4.355 10.078 9.597 10.078 3.007 0 5.281-1.227 6.746-3.258v2.76h4.162V14.16h-4.162zm-6.09 15.71c-3.469 0-6.091-2.567-6.091-6.13 0-3.564 2.622-6.131 6.091-6.131 3.469 0 6.09 2.567 6.09 6.13 0 3.564-2.621 6.132-6.09 6.132zM136.597 6.498v10.384c-1.465-1.993-3.739-3.219-6.746-3.219-5.242 0-9.597 4.368-9.597 10.078 0 5.67 4.355 10.078 9.597 10.078 3.007 0 5.281-1.227 6.746-3.258v2.76h4.163V6.497h-4.163zm-6.09 23.374c-3.469 0-6.09-2.568-6.09-6.131 0-3.564 2.621-6.131 6.09-6.131s6.09 2.567 6.09 6.13c0 3.564-2.621 6.132-6.09 6.132zM144.648 33.32h4.163V5.348h-4.163V33.32zM155.957 25.503h15.341c.077-.536.154-1.15.154-1.724 0-5.518-3.931-10.116-9.675-10.116-6.051 0-10.176 4.407-10.176 10.078 0 5.748 4.125 10.078 10.485 10.078 3.777 0 6.668-1.572 8.441-4.177l-3.43-1.993c-.926 1.341-2.66 2.376-4.973 2.376-3.083 0-5.512-1.533-6.167-4.521zm-.038-3.372c.578-2.873 2.698-4.713 5.82-4.713 2.505 0 4.934 1.418 5.512 4.713h-11.332zM177.137 19.45c0-1.38 1.311-2.032 2.814-2.032 1.581 0 2.93.69 3.623 2.184l3.508-1.954c-1.349-2.529-3.97-3.985-7.131-3.985-3.931 0-7.053 2.26-7.053 5.863 0 6.859 10.368 4.943 10.368 8.353 0 1.533-1.426 2.146-3.276 2.146-2.12 0-3.662-1.035-4.279-2.759l-3.584 2.07c1.233 2.758 4.008 4.483 7.863 4.483 4.163 0 7.516-2.07 7.516-5.902 0-7.088-10.369-4.98-10.369-8.468zM192.774 19.45c0-1.38 1.31-2.032 2.813-2.032 1.581 0 2.93.69 3.624 2.184l3.507-1.954c-1.349-2.529-3.97-3.985-7.131-3.985-3.931 0-7.053 2.26-7.053 5.863 0 6.859 10.368 4.943 10.368 8.353 0 1.533-1.426 2.146-3.276 2.146-2.12 0-3.662-1.035-4.278-2.759l-3.585 2.07c1.233 2.758 4.009 4.483 7.863 4.483 4.163 0 7.516-2.07 7.516-5.902 0-7.088-10.368-4.98-10.368-8.468zM224.523 28.9c2.889 0 5.027-1.715 5.027-4.53v-8.782h-2.588v8.577c0 1.268-.676 2.219-2.439 2.219s-2.438-.951-2.438-2.22v-8.576h-2.569v8.782c0 2.815 2.138 4.53 5.007 4.53zM232.257 15.588V28.64h2.588V15.588h-2.588z"
/>
<path
fill="#fff"
fillRule="evenodd"
d="M233.817 9.328H220.42c-2.96 0-5.359 2.385-5.359 5.327v13.318c0 2.942 2.399 5.327 5.359 5.327h13.397c2.959 0 5.358-2.385 5.358-5.327V14.655c0-2.942-2.399-5.327-5.358-5.327zM220.42 6.664c-4.439 0-8.038 3.578-8.038 7.99v13.319c0 4.413 3.599 7.99 8.038 7.99h13.397c4.439 0 8.038-3.577 8.038-7.99V14.655c0-4.413-3.599-7.99-8.038-7.99H220.42z"
clipRule="evenodd"
/>
<path
fill="#fff"
fillRule="evenodd"
d="M220.42 9.328h13.397c2.959 0 5.358 2.385 5.358 5.327v13.318c0 2.942-2.399 5.327-5.358 5.327H220.42c-2.96 0-5.359-2.385-5.359-5.327V14.655c0-2.942 2.399-5.327 5.359-5.327zm-8.038 5.327c0-4.413 3.599-7.99 8.038-7.99h13.397c4.439 0 8.038 3.577 8.038 7.99v13.318c0 4.413-3.599 7.99-8.038 7.99H220.42c-4.439 0-8.038-3.577-8.038-7.99V14.655z"
clipRule="evenodd"
/>
<path
fill="url(#prefix__paint0_linear)"
d="M8.577 26.097l25.779-8.556c-.514-3.201-.88-5.342-1.307-6.974-.457-1.756-.821-2.226-.965-2.39a5.026 5.026 0 00-1.81-1.306c-.2-.086-.762-.284-2.583-.175-1.924.116-4.453.507-8.455 1.137-4.003.63-6.529 1.035-8.395 1.516-1.766.456-2.239.817-2.403.96a4.999 4.999 0 00-1.315 1.8c-.085.198-.285.757-.175 2.568.116 1.913.51 4.426 1.143 8.405.178 1.114.337 2.113.486 3.015z"
/>
<path
fill="url(#prefix__paint1_linear)"
fillRule="evenodd"
d="M1.47 24.124C.244 16.427-.37 12.58.96 9.49A11.665 11.665 0 014.027 5.29c2.545-2.21 6.416-2.82 14.16-4.039C25.93.031 29.8-.578 32.907.743a11.729 11.729 0 014.225 3.05c2.223 2.53 2.836 6.38 4.063 14.076 1.226 7.698 1.84 11.546.511 14.636a11.666 11.666 0 01-3.069 4.199c-2.545 2.21-6.416 2.82-14.159 4.039-7.743 1.219-11.614 1.828-14.722.508a11.728 11.728 0 01-4.224-3.05C3.31 35.67 2.697 31.82 1.47 24.123zm13.657 13.668c2.074-.125 4.743-.54 8.697-1.163 3.953-.622 6.62-1.047 8.632-1.566 1.949-.502 2.846-.992 3.426-1.496a7.5 7.5 0 001.973-2.7c.302-.703.494-1.703.372-3.7-.125-2.063-.543-4.716-1.17-8.646-.625-3.93-1.053-6.582-1.574-8.582-.506-1.937-.999-2.83-1.505-3.405a7.54 7.54 0 00-2.716-1.961c-.707-.301-1.713-.492-3.723-.371-2.074.125-4.743.54-8.697 1.163-3.953.622-6.62 1.047-8.632 1.565-1.949.503-2.846.993-3.426 1.497a7.5 7.5 0 00-1.972 2.699c-.303.704-.495 1.704-.373 3.701.125 2.062.543 4.716 1.17 8.646.625 3.93 1.053 6.582 1.574 8.581.506 1.938 1 2.83 1.505 3.406a7.54 7.54 0 002.716 1.961c.707.3 1.713.492 3.723.37z"
clipRule="evenodd"
/>
<defs>
<linearGradient
id="prefix__paint0_linear"
x1="16.759"
x2="23.386"
y1="0"
y2="41.662"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#66E3FF" />
<stop offset="1" stopColor="#7064F9" />
</linearGradient>
<linearGradient
id="prefix__paint1_linear"
x1="16.759"
x2="23.386"
y1="0"
y2="41.662"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#66E3FF" />
<stop offset="1" stopColor="#7064F9" />
</linearGradient>
</defs>
</svg>
)
}
export default MyApp
@@ -0,0 +1,63 @@
import React from 'react'
import ErrorPage from 'next/error'
import Head from 'next/head'
import Link from 'next/link'
import { ExamplesType, resolveAllExamples } from '../utils/resolve-all-examples'
function NextLink(props: React.ComponentProps<'a'>) {
let { href, children, ...rest } = props
return (
<Link href={href}>
<a {...rest}>{children}</a>
</Link>
)
}
export async function getStaticProps() {
return {
props: {
examples: await resolveAllExamples('pages'),
},
}
}
export default function Page(props: { examples: false | ExamplesType[] }) {
if (props.examples === false) {
return <ErrorPage statusCode={404} />
}
return (
<>
<Head>
<title>Examples</title>
</Head>
<div className="container mx-auto my-24">
<div className="prose">
<h2>Examples</h2>
<Examples examples={props.examples} />
</div>
</div>
</>
)
}
export function Examples(props: { examples: ExamplesType[] }) {
return (
<ul>
{props.examples.map(example => (
<li key={example.path}>
{example.children ? (
<h3 className="text-xl capitalize">{example.name}</h3>
) : (
<NextLink href={example.path} className="capitalize">
{example.name}
</NextLink>
)}
{example.children && <Examples examples={example.children} />}
</li>
))}
</ul>
)
}
@@ -0,0 +1,136 @@
import React, { useState, useEffect } from 'react'
import { Combobox } from '@headlessui/react'
import { classNames } from '../../utils/class-names'
let everybody = [
'Wade Cooper',
'Arlene Mccoy',
'Devon Webb',
'Tom Cook',
'Tanya Fox',
'Hellen Schmidt',
'Caroline Schultz',
'Mason Heaney',
'Claudie Smitham',
'Emil Schaefer',
]
function useDebounce<T>(value: T, delay: number) {
let [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
let timer = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}
export default function Home() {
let [query, setQuery] = useState('')
let [activePerson, setActivePerson] = useState(everybody[2])
// Mimic delayed response from an API
let actualQuery = useDebounce(query, 0 /* Change to higher value like 100 for testing purposes */)
// Choose a random person on mount
useEffect(() => {
setActivePerson(everybody[Math.floor(Math.random() * everybody.length)])
}, [])
let people =
actualQuery === ''
? everybody
: everybody.filter(person => person.toLowerCase().includes(actualQuery.toLowerCase()))
return (
<div className="flex justify-center w-screen h-full p-12 bg-gray-50">
<div className="w-full max-w-xs mx-auto">
<div className="text-xs py-8 font-mono">Selected person: {activePerson}</div>
<div className="space-y-1">
<Combobox
value={activePerson}
onChange={value => {
setActivePerson(value)
}}
as="div"
>
<Combobox.Label className="block text-sm font-medium leading-5 text-gray-700">
Assigned to
</Combobox.Label>
<div className="relative">
<span className="relative inline-flex flex-row rounded-md overflow-hidden shadow-sm border">
<Combobox.Input
onChange={e => setQuery(e.target.value)}
className="border-none outline-none px-3 py-1"
/>
<Combobox.Button className="focus:outline-none px-1 bg-gray-100 cursor-default border-l text-indigo-600">
<span className="flex items-center px-2 pointer-events-none">
<svg
className="w-5 h-5 text-gray-400"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
>
<path
d="M7 7l3-3 3 3m0 6l-3 3-3-3"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</Combobox.Button>
</span>
<div className="absolute w-full mt-1 bg-white rounded-md shadow-lg">
<Combobox.Options className="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5">
{people.map(name => (
<Combobox.Option
key={name}
value={name}
className={({ active }) => {
return classNames(
'relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none',
active ? 'text-white bg-indigo-600' : 'text-gray-900'
)
}}
>
{({ active, selected }) => (
<>
<span
className={classNames(
'block truncate',
selected ? 'font-semibold' : 'font-normal'
)}
>
{name}
</span>
{selected && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-indigo-600'
)}
>
<svg className="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
)}
</>
)}
</Combobox.Option>
))}
</Combobox.Options>
</div>
</div>
</Combobox>
</div>
</div>
</div>
)
}
@@ -0,0 +1,150 @@
import React, { useState, useEffect, Fragment } from 'react'
import { Combobox } from '@headlessui/react'
import { classNames } from '../../utils/class-names'
let everybody = [
{ id: 1, img: 'https://github.com/adamwathan.png', name: 'Adam Wathan' },
{ id: 2, img: 'https://github.com/sschoger.png', name: 'Steve Schoger' },
{ id: 3, img: 'https://github.com/bradlc.png', name: 'Brad Cornes' },
{ id: 4, img: 'https://github.com/simonswiss.png', name: 'Simon Vrachliotis' },
{ id: 5, img: 'https://github.com/robinmalfait.png', name: 'Robin Malfait' },
{
id: 6,
img: 'https://pbs.twimg.com/profile_images/1478879681491394569/eV2PyCnm_400x400.jpg',
name: 'James McDonald',
},
{ id: 7, img: 'https://github.com/reinink.png', name: 'Jonathan Reinink' },
{ id: 8, img: 'https://github.com/thecrypticace.png', name: 'Jordan Pittman' },
]
export default function Home() {
let [query, setQuery] = useState('')
let [activePerson, setActivePerson] = useState(everybody[2])
function setPerson(person) {
setActivePerson(person)
setQuery(person.name ?? '')
}
// Choose a random person on mount
useEffect(() => {
setPerson(everybody[Math.floor(Math.random() * everybody.length)])
}, [])
let people =
query === ''
? everybody
: everybody.filter(person => person.name.toLowerCase().includes(query.toLowerCase()))
let groups = people.reduce((groups, person) => {
let lastNameLetter = person.name.split(' ')[1][0]
groups.set(lastNameLetter, [...(groups.get(lastNameLetter) || []), person])
return groups
}, new Map())
return (
<div className="flex justify-center w-screen h-full p-12 bg-gray-50">
<div className="w-full max-w-lg mx-auto">
<div className="space-y-1">
<Combobox
as="div"
value={activePerson}
onChange={person => setPerson(person)}
className="bg-white w-full shadow-sm border border-black/5 bg-clip-padding rounded overflow-hidden"
>
{({ activeOption }) => {
return (
<div className="flex flex-col w-full">
<Combobox.Input
onChange={e => setQuery(e.target.value)}
className="border-none outline-none px-3 py-1 bg-none rounded-none w-full"
placeholder="Search users…"
displayValue={item => item?.name}
/>
<div className="flex">
<Combobox.Options className="flex-1 overflow-auto text-base leading-6 shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5">
{Array.from(groups.entries())
.sort(([letterA], [letterZ]) => letterA.localeCompare(letterZ))
.map(([letter, people]) => (
<Fragment key={letter}>
<div className="bg-gray-100 px-4 py-2">{letter}</div>
{people.map(person => (
<Combobox.Option
key={person.id}
value={person}
className={({ active }) => {
return classNames(
'flex relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none space-x-4',
active ? 'text-white bg-indigo-600' : 'text-gray-900'
)
}}
>
{({ active, selected }) => (
<>
<img
src={person.img}
className="w-6 h-6 overflow-hidden rounded-full"
/>
<span
className={classNames(
'block truncate',
selected ? 'font-semibold' : 'font-normal'
)}
>
{person.name}
</span>
{active && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-indigo-600'
)}
>
<svg className="w-5 h-5" viewBox="0 0 25 24" fill="none">
<path
d="M11.25 8.75L14.75 12L11.25 15.25"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
)}
</>
)}
</Combobox.Option>
))}
</Fragment>
))}
</Combobox.Options>
{people.length === 0 ? (
<div className="text-center w-full py-4">No person selected</div>
) : activeOption === null ? null : (
<div className="border-l">
<div className="flex flex-col">
<div className="p-8 text-center">
<img
src={activeOption.img}
className="w-16 h-16 rounded-full overflow-hidden inline-block mb-4"
/>
<div className="text-gray-900 font-bold">{activeOption.name}</div>
<div className="text-gray-700">Obviously cool person</div>
</div>
</div>
</div>
)}
</div>
</div>
)
}}
</Combobox>
</div>
</div>
</div>
)
}
@@ -0,0 +1,135 @@
import React, { useState, useEffect } from 'react'
import { Combobox } from '@headlessui/react'
import { classNames } from '../../utils/class-names'
let everybody = [
{ id: 1, img: 'https://github.com/adamwathan.png', name: 'Adam Wathan' },
{ id: 2, img: 'https://github.com/sschoger.png', name: 'Steve Schoger' },
{ id: 3, img: 'https://github.com/bradlc.png', name: 'Brad Cornes' },
{ id: 4, img: 'https://github.com/simonswiss.png', name: 'Simon Vrachliotis' },
{ id: 5, img: 'https://github.com/robinmalfait.png', name: 'Robin Malfait' },
{
id: 6,
img: 'https://pbs.twimg.com/profile_images/1478879681491394569/eV2PyCnm_400x400.jpg',
name: 'James McDonald',
},
{ id: 7, img: 'https://github.com/reinink.png', name: 'Jonathan Reinink' },
{ id: 8, img: 'https://github.com/thecrypticace.png', name: 'Jordan Pittman' },
]
export default function Home() {
let [query, setQuery] = useState('')
let [activePerson, setActivePerson] = useState(everybody[2])
// Choose a random person on mount
useEffect(() => {
setActivePerson(everybody[Math.floor(Math.random() * everybody.length)])
}, [])
let people =
query === ''
? everybody
: everybody.filter(person => person.name.toLowerCase().includes(query.toLowerCase()))
return (
<div className="flex justify-center w-screen h-full p-12 bg-gray-50">
<div className="w-full max-w-lg mx-auto">
<div className="space-y-1">
<Combobox
as="div"
value={activePerson}
onChange={person => setActivePerson(person)}
className="bg-white w-full shadow-sm border border-black/5 bg-clip-padding rounded overflow-hidden"
>
{({ activeOption, open }) => {
return (
<div className="flex flex-col w-full">
<Combobox.Input
onChange={e => setQuery(e.target.value)}
className="border-none outline-none px-3 py-1 rounded-none w-full"
placeholder="Search users…"
displayValue={item => item?.name}
/>
<div
className={classNames(
'flex border-t',
activePerson && !open ? 'border-transparent' : 'border-gray-200'
)}
>
<Combobox.Options className="flex-1 py-1 overflow-auto text-base leading-6 shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5">
{people.map(person => (
<Combobox.Option
key={person.id}
value={person}
className={({ active }) => {
return classNames(
'flex relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none space-x-4',
active ? 'text-white bg-indigo-600' : 'text-gray-900'
)
}}
>
{({ active, selected }) => (
<>
<img
src={person.img}
className="w-6 h-6 overflow-hidden rounded-full"
/>
<span
className={classNames(
'block truncate',
selected ? 'font-semibold' : 'font-normal'
)}
>
{person.name}
</span>
{active && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-indigo-600'
)}
>
<svg className="w-5 h-5" viewBox="0 0 25 24" fill="none">
<path
d="M11.25 8.75L14.75 12L11.25 15.25"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
)}
</>
)}
</Combobox.Option>
))}
</Combobox.Options>
{people.length === 0 ? (
<div className="text-center w-full py-4">No person selected</div>
) : activeOption === null ? null : (
<div className="border-l">
<div className="flex flex-col">
<div className="p-8 text-center">
<img
src={activeOption.img}
className="w-16 h-16 rounded-full overflow-hidden inline-block mb-4"
/>
<div className="text-gray-900 font-bold">{activeOption.name}</div>
<div className="text-gray-700">Obviously cool person</div>
</div>
</div>
</div>
)}
</div>
</div>
)
}}
</Combobox>
</div>
</div>
</div>
)
}
@@ -0,0 +1,238 @@
import React, { useState, Fragment } from 'react'
import { Dialog, Menu, Portal, Transition } from '@headlessui/react'
import { usePopper } from '../../utils/hooks/use-popper'
import { classNames } from '../../utils/class-names'
function resolveClass({ active, disabled }) {
return classNames(
'flex justify-between w-full px-4 py-2 text-sm leading-5 text-left',
active ? 'bg-gray-100 text-gray-900' : 'text-gray-700',
disabled && 'cursor-not-allowed opacity-50'
)
}
function Nested({ onClose, level = 0 }) {
let [showChild, setShowChild] = useState(false)
return (
<>
<Dialog open={true} onClose={onClose} className="fixed z-10 inset-0">
<Dialog.Overlay className="fixed inset-0 bg-gray-500 opacity-25" />
<div
className="z-10 fixed left-12 top-24 bg-white w-96 p-4"
style={{
transform: `translate(calc(50px * ${level}), calc(50px * ${level}))`,
}}
>
<p>Level: {level}</p>
<div className="space-x-4">
<button className="bg-gray-200 px-2 py-1 rounded" onClick={() => setShowChild(true)}>
Open (1)
</button>
<button className="bg-gray-200 px-2 py-1 rounded" onClick={() => setShowChild(true)}>
Open (2)
</button>
<button className="bg-gray-200 px-2 py-1 rounded" onClick={() => setShowChild(true)}>
Open (3)
</button>
</div>
</div>
{showChild && <Nested onClose={() => setShowChild(false)} level={level + 1} />}
</Dialog>
</>
)
}
export default function Home() {
let [isOpen, setIsOpen] = useState(false)
let [nested, setNested] = useState(false)
let [trigger, container] = usePopper({
placement: 'bottom-end',
strategy: 'fixed',
modifiers: [{ name: 'offset', options: { offset: [0, 10] } }],
})
return (
<>
<button
type="button"
onClick={() => setIsOpen(v => !v)}
className="m-12 px-4 py-2 text-base font-medium leading-6 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md shadow-sm hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue sm:text-sm sm:leading-5"
>
Toggle!
</button>
<button onClick={() => setNested(true)}>Show nested</button>
{nested && <Nested onClose={() => setNested(false)} />}
<Transition show={isOpen} as={Fragment} afterLeave={() => console.log('done')}>
<Dialog onClose={setIsOpen}>
<div className="fixed z-10 inset-0 overflow-y-auto">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-75"
leave="ease-in duration-200"
leaveFrom="opacity-75"
leaveTo="opacity-0"
entered="opacity-75"
>
<Dialog.Overlay className="fixed inset-0 bg-gray-500 transition-opacity" />
</Transition.Child>
<Transition.Child
enter="ease-out transform duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in transform duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
{/* This element is to trick the browser into centering the modal contents. */}
<span
className="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true"
>
&#8203;
</span>
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
{/* Heroicon name: exclamation */}
<svg
className="h-6 w-6 text-red-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title
as="h3"
className="text-lg leading-6 font-medium text-gray-900"
>
Deactivate account
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to deactivate your account? All of your data will
be permanently removed. This action cannot be undone.
</p>
<div className="relative inline-block text-left mt-10">
<Menu>
<span className="rounded-md shadow-sm">
<Menu.Button
ref={trigger}
className="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800"
>
<span>Choose a reason</span>
<svg
className="w-5 h-5 ml-2 -mr-1"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</Menu.Button>
</span>
<Transition
enter="transition duration-300 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Portal>
<Menu.Items
ref={container}
className="z-20 w-56 mt-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none"
>
<div className="px-4 py-3">
<p className="text-sm leading-5">Signed in as</p>
<p className="text-sm font-medium leading-5 text-gray-900 truncate">
tom@example.com
</p>
</div>
<div className="py-1">
<Menu.Item
as="a"
href="#account-settings"
className={resolveClass}
>
Account settings
</Menu.Item>
<Menu.Item as="a" href="#support" className={resolveClass}>
Support
</Menu.Item>
<Menu.Item
as="a"
disabled
href="#new-feature"
className={resolveClass}
>
New feature (soon)
</Menu.Item>
<Menu.Item as="a" href="#license" className={resolveClass}>
License
</Menu.Item>
</div>
<div className="py-1">
<Menu.Item as="a" href="#sign-out" className={resolveClass}>
Sign out
</Menu.Item>
</div>
</Menu.Items>
</Portal>
</Transition>
</Menu>
</div>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
onClick={() => setIsOpen(false)}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:shadow-outline-red sm:ml-3 sm:w-auto sm:text-sm"
>
Deactivate
</button>
<button
type="button"
onClick={() => setIsOpen(false)}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:shadow-outline-indigo sm:mt-0 sm:w-auto sm:text-sm"
>
Cancel
</button>
</div>
</div>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
</>
)
}
@@ -0,0 +1,25 @@
import React from 'react'
import { Disclosure, Transition } from '@headlessui/react'
export default function Home() {
return (
<div className="flex justify-center w-screen h-full p-12 bg-gray-50">
<div className="w-full max-w-xs mx-auto">
<Disclosure>
<Disclosure.Button>Trigger</Disclosure.Button>
<Transition
enter="transition duration-1000 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-1000 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Disclosure.Panel className="p-4 bg-white mt-4">Content</Disclosure.Panel>
</Transition>
</Disclosure>
</div>
</div>
)
}
@@ -0,0 +1,115 @@
import React, { useState, useEffect } from 'react'
import { Listbox } from '@headlessui/react'
import { classNames } from '../../utils/class-names'
let people = [
'Wade Cooper',
'Arlene Mccoy',
'Devon Webb',
'Tom Cook',
'Tanya Fox',
'Hellen Schmidt',
'Caroline Schultz',
'Mason Heaney',
'Claudie Smitham',
'Emil Schaefer',
]
export default function Home() {
let [active, setActivePerson] = useState(people[2])
// Choose a random person on mount
useEffect(() => {
setActivePerson(people[Math.floor(Math.random() * people.length)])
}, [])
return (
<div className="flex justify-center w-screen h-full p-12 bg-gray-50">
<div className="w-full max-w-xs mx-auto">
<div className="space-y-1">
<Listbox
value={active}
onChange={value => {
console.log('value:', value)
setActivePerson(value)
}}
>
<Listbox.Label className="block text-sm font-medium leading-5 text-gray-700">
Assigned to
</Listbox.Label>
<div className="relative">
<span className="inline-block w-full rounded-md shadow-sm">
<Listbox.Button className="relative w-full py-2 pl-3 pr-10 text-left transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
<span className="block truncate">{active}</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg
className="w-5 h-5 text-gray-400"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
>
<path
d="M7 7l3-3 3 3m0 6l-3 3-3-3"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</Listbox.Button>
</span>
<div className="absolute w-full mt-1 bg-white rounded-md shadow-lg">
<Listbox.Options className="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5">
{people.map(name => (
<Listbox.Option
key={name}
value={name}
className={({ active }) => {
return classNames(
'relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none',
active ? 'text-white bg-indigo-600' : 'text-gray-900'
)
}}
>
{({ active, selected }) => (
<>
<span
className={classNames(
'block truncate',
selected ? 'font-semibold' : 'font-normal'
)}
>
{name}
</span>
{selected && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-indigo-600'
)}
>
<svg className="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</div>
</div>
</Listbox>
</div>
</div>
</div>
)
}
@@ -0,0 +1,134 @@
import React, { useState, useEffect } from 'react'
import { Listbox } from '@headlessui/react'
import { classNames } from '../../utils/class-names'
let people = [
'Wade Cooper',
'Arlene Mccoy',
'Devon Webb',
'Tom Cook',
'Tanya Fox',
'Hellen Schmidt',
'Caroline Schultz',
'Mason Heaney',
'Claudie Smitham',
'Emil Schaefer',
]
export default function Home() {
return (
<div className="flex justify-center w-screen h-full p-12 space-x-4 bg-gray-50">
<PeopleList />
<div>
<label htmlFor="email" className="block text-sm font-medium leading-5 text-gray-700">
Email
</label>
<div className="relative mt-1 rounded-md shadow-sm">
<input
className="block w-full form-input sm:text-sm sm:leading-5"
placeholder="you@example.com"
/>
</div>
</div>
<PeopleList />
</div>
)
}
function PeopleList() {
let [active, setActivePerson] = useState(people[2])
// Choose a random person on mount
useEffect(() => {
setActivePerson(people[Math.floor(Math.random() * people.length)])
}, [])
return (
<div className="w-64">
<div className="space-y-1">
<Listbox
value={active}
onChange={value => {
console.log('value:', value)
setActivePerson(value)
}}
>
<Listbox.Label className="block text-sm font-medium leading-5 text-gray-700">
Assigned to
</Listbox.Label>
<div className="relative">
<span className="inline-block w-full rounded-md shadow-sm">
<Listbox.Button className="relative w-full py-2 pl-3 pr-10 text-left transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
<span className="block truncate">{active}</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg
className="w-5 h-5 text-gray-400"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
>
<path
d="M7 7l3-3 3 3m0 6l-3 3-3-3"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</Listbox.Button>
</span>
<div className="absolute w-full mt-1 bg-white rounded-md shadow-lg">
<Listbox.Options className="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5">
{people.map(name => (
<Listbox.Option
key={name}
value={name}
className={({ active }) => {
return classNames(
'relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none',
active ? 'text-white bg-indigo-600' : 'text-gray-900'
)
}}
>
{({ active, selected }) => (
<>
<span
className={classNames(
'block truncate',
selected ? 'font-semibold' : 'font-normal'
)}
>
{name}
</span>
{selected && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-indigo-600'
)}
>
<svg className="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</div>
</div>
</Listbox>
</div>
</div>
)
}
@@ -0,0 +1,111 @@
import React from 'react'
import Link from 'next/link'
import { Menu } from '@headlessui/react'
import { AnimatePresence, motion } from 'framer-motion'
import { classNames } from '../../utils/class-names'
export default function Home() {
return (
<div className="flex justify-center w-screen h-full p-12 bg-gray-50">
<div className="relative inline-block text-left">
<Menu>
{({ open }) => (
<>
<span className="rounded-md shadow-sm">
<Menu.Button className="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800">
<span>Options</span>
<svg className="w-5 h-5 ml-2 -mr-1" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</Menu.Button>
</span>
<AnimatePresence>
{open && (
<Menu.Items
static
as={motion.div}
initial={{ opacity: 0, y: 0 }}
animate={{ opacity: 1, y: '0.5rem' }}
exit={{ opacity: 0, y: 0 }}
className="absolute right-0 w-56 bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none opacity-0"
>
<div className="px-4 py-3">
<p className="text-sm leading-5">Signed in as</p>
<p className="text-sm font-medium leading-5 text-gray-900 truncate">
tom@example.com
</p>
</div>
<div className="py-1">
<Item href="#account-settings">Account settings</Item>
<Item as={NextLink} href="#support">
Support
</Item>
<Item href="#new-feature" disabled>
New feature (soon)
</Item>
<Item href="#license">License</Item>
</div>
<div className="py-1">
<Item as={SignOutButton} />
</div>
</Menu.Items>
)}
</AnimatePresence>
</>
)}
</Menu>
</div>
</div>
)
}
function NextLink(props: React.ComponentProps<'a'>) {
let { href, children, ...rest } = props
return (
<Link href={href}>
<a {...rest}>{children}</a>
</Link>
)
}
function SignOutButton(props) {
return (
<form
method="POST"
action="#"
onSubmit={e => {
e.preventDefault()
alert('SIGNED OUT')
}}
className="w-full"
>
<button type="submit" {...props}>
Sign out
</button>
</form>
)
}
function Item(props: React.ComponentProps<typeof Menu.Item>) {
return (
<Menu.Item
as="a"
className={({ active, disabled }) =>
classNames(
'block w-full text-left px-4 py-2 text-sm leading-5 text-gray-700',
active && 'bg-gray-100 text-gray-900',
disabled && 'cursor-not-allowed opacity-50'
)
}
{...props}
/>
)
}
@@ -0,0 +1,95 @@
import React, { ReactNode, useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { Menu } from '@headlessui/react'
import { usePopper } from '../../utils/hooks/use-popper'
import { classNames } from '../../utils/class-names'
export default function Home() {
let [trigger, container] = usePopper({
placement: 'bottom-end',
strategy: 'fixed',
modifiers: [{ name: 'offset', options: { offset: [0, 10] } }],
})
function resolveClass({ active, disabled }) {
return classNames(
'block w-full text-left px-4 py-2 text-sm leading-5 text-gray-700',
active && 'bg-gray-100 text-gray-900',
disabled && 'cursor-not-allowed opacity-50'
)
}
return (
<div className="flex justify-center w-screen h-full p-12 bg-gray-50">
<div className="inline-block mt-64 text-left">
<Menu>
<span className="inline-flex rounded-md shadow-sm">
<Menu.Button
ref={trigger}
className="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800"
>
<span>Options</span>
<svg className="w-5 h-5 ml-2 -mr-1" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</Menu.Button>
</span>
<Portal>
<Menu.Items
className="w-56 bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none"
ref={container}
>
<div className="px-4 py-3">
<p className="text-sm leading-5">Signed in as</p>
<p className="text-sm font-medium leading-5 text-gray-900 truncate">
tom@example.com
</p>
</div>
<div className="py-1">
<Menu.Item as="a" href="#account-settings" className={resolveClass}>
Account settings
</Menu.Item>
<Menu.Item>
{data => (
<a href="#support" className={resolveClass(data)}>
Support
</a>
)}
</Menu.Item>
<Menu.Item as="a" disabled href="#new-feature" className={resolveClass}>
New feature (soon)
</Menu.Item>
<Menu.Item as="a" href="#license" className={resolveClass}>
License
</Menu.Item>
</div>
<div className="py-1">
<Menu.Item as="a" href="#sign-out" className={resolveClass}>
Sign out
</Menu.Item>
</div>
</Menu.Items>
</Portal>
</Menu>
</div>
</div>
)
}
function Portal(props: { children: ReactNode }) {
let { children } = props
let [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return null
return createPortal(children, document.body)
}
@@ -0,0 +1,89 @@
import React from 'react'
import { Menu, Transition } from '@headlessui/react'
import { usePopper } from '../../utils/hooks/use-popper'
import { classNames } from '../../utils/class-names'
export default function Home() {
let [trigger, container] = usePopper({
placement: 'bottom-end',
strategy: 'fixed',
modifiers: [{ name: 'offset', options: { offset: [0, 10] } }],
})
function resolveClass({ active, disabled }) {
return classNames(
'flex justify-between w-full px-4 py-2 text-sm leading-5 text-left',
active ? 'bg-gray-100 text-gray-900' : 'text-gray-700',
disabled && 'cursor-not-allowed opacity-50'
)
}
return (
<div className="flex justify-center w-screen h-full p-12 bg-gray-50">
<div className="inline-block mt-64 text-left">
<Menu>
<span className="rounded-md shadow-sm">
<Menu.Button
ref={trigger}
className="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800"
>
<span>Options</span>
<svg className="w-5 h-5 ml-2 -mr-1" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</Menu.Button>
</span>
<div ref={container} className="w-56">
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Menu.Items className="bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none">
<div className="px-4 py-3">
<p className="text-sm leading-5">Signed in as</p>
<p className="text-sm font-medium leading-5 text-gray-900 truncate">
tom@example.com
</p>
</div>
<div className="py-1">
<Menu.Item as="a" href="#account-settings" className={resolveClass}>
Account settings
</Menu.Item>
<Menu.Item>
{data => (
<a href="#support" className={resolveClass(data)}>
Support
</a>
)}
</Menu.Item>
<Menu.Item as="a" disabled href="#new-feature" className={resolveClass}>
New feature (soon)
</Menu.Item>
<Menu.Item as="a" href="#license" className={resolveClass}>
License
</Menu.Item>
</div>
<div className="py-1">
<Menu.Item as="a" href="#sign-out" className={resolveClass}>
Sign out
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</div>
</Menu>
</div>
</div>
)
}
@@ -0,0 +1,73 @@
import React from 'react'
import { Menu, Transition } from '@headlessui/react'
import { classNames } from '../../utils/class-names'
export default function Home() {
function resolveClass({ active, disabled }) {
return classNames(
'flex justify-between w-full px-4 py-2 text-sm leading-5 text-left',
active ? 'bg-gray-100 text-gray-900' : 'text-gray-700',
disabled && 'cursor-not-allowed opacity-50'
)
}
return (
<div className="flex justify-center w-screen h-full p-12 bg-gray-50">
<div className="relative inline-block text-left">
<Menu>
<span className="rounded-md shadow-sm">
<Menu.Button className="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800">
<span>Options</span>
<svg className="w-5 h-5 ml-2 -mr-1" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</Menu.Button>
</span>
<Transition
enter="transition duration-1000 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-1000 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Menu.Items className="absolute right-0 w-56 mt-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none">
<div className="px-4 py-3">
<p className="text-sm leading-5">Signed in as</p>
<p className="text-sm font-medium leading-5 text-gray-900 truncate">
tom@example.com
</p>
</div>
<div className="py-1">
<Menu.Item as="a" href="#account-settings" className={resolveClass}>
Account settings
</Menu.Item>
<Menu.Item as="a" href="#support" className={resolveClass}>
Support
</Menu.Item>
<Menu.Item as="a" disabled href="#new-feature" className={resolveClass}>
New feature (soon)
</Menu.Item>
<Menu.Item as="a" href="#license" className={resolveClass}>
License
</Menu.Item>
</div>
<div className="py-1">
<Menu.Item as="a" href="#sign-out" className={resolveClass}>
Sign out
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
</div>
</div>
)
}
@@ -0,0 +1,72 @@
import React from 'react'
import { Menu } from '@headlessui/react'
import { classNames } from '../../utils/class-names'
export default function Home() {
return (
<div className="flex justify-center w-screen h-full p-12 bg-gray-50">
<div className="relative inline-block text-left">
<Menu>
<span className="rounded-md shadow-sm">
<Menu.Button className="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800">
<span>Options</span>
<svg
className="w-5 h-5 ml-2 -mr-1 transition-transform duration-150"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</Menu.Button>
</span>
<Menu.Items className="absolute right-0 w-56 mt-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none">
<div className="px-4 py-3">
<p className="text-sm leading-5">Signed in as</p>
<p className="text-sm font-medium leading-5 text-gray-900 truncate">
tom@example.com
</p>
</div>
<div className="py-1">
<CustomMenuItem href="#account-settings">Account settings</CustomMenuItem>
<CustomMenuItem href="#support">Support</CustomMenuItem>
<CustomMenuItem disabled href="#new-feature">
New feature (soon)
</CustomMenuItem>
<CustomMenuItem href="#license">License</CustomMenuItem>
</div>
<div className="py-1">
<CustomMenuItem href="#sign-out">Sign out</CustomMenuItem>
</div>
</Menu.Items>
</Menu>
</div>
</div>
)
}
function CustomMenuItem(props: React.ComponentProps<typeof Menu.Item>) {
return (
<Menu.Item {...props}>
{({ active, disabled }) => (
<a
href={props.href}
className={classNames(
'flex justify-between w-full text-left px-4 py-2 text-sm leading-5',
active ? 'bg-indigo-500 text-white' : 'text-gray-700',
disabled && 'cursor-not-allowed opacity-50'
)}
>
<span className={classNames(active && 'font-bold')}>{props.children}</span>
<kbd className={classNames('font-sans', active && 'text-indigo-50')}>K</kbd>
</a>
)}
</Menu.Item>
)
}
@@ -0,0 +1,83 @@
import React from 'react'
import { Menu } from '@headlessui/react'
import { classNames } from '../../utils/class-names'
export default function Home() {
return (
<div className="flex justify-center w-screen h-full p-12 space-x-4 bg-gray-50">
<Dropdown />
<div>
<div className="relative rounded-md shadow-sm">
<input
className="block w-full form-input sm:text-sm sm:leading-5"
placeholder="you@example.com"
/>
</div>
</div>
<Dropdown />
</div>
)
}
function Dropdown() {
function resolveClass({ active, disabled }) {
return classNames(
'block w-full text-left px-4 py-2 text-sm leading-5 text-gray-700',
active && 'bg-gray-100 text-gray-900',
disabled && 'cursor-not-allowed opacity-50'
)
}
return (
<div className="relative inline-block text-left">
<Menu>
<span className="inline-flex rounded-md shadow-sm">
<Menu.Button className="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800">
<span>Options</span>
<svg className="w-5 h-5 ml-2 -mr-1" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</Menu.Button>
</span>
<Menu.Items className="absolute right-0 w-56 mt-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none">
<div className="px-4 py-3">
<p className="text-sm leading-5">Signed in as</p>
<p className="text-sm font-medium leading-5 text-gray-900 truncate">tom@example.com</p>
</div>
<div className="py-1">
<Menu.Item as="a" href="#account-settings" className={resolveClass}>
Account settings
</Menu.Item>
<Menu.Item>
{data => (
<a href="#support" className={resolveClass(data)}>
Support
</a>
)}
</Menu.Item>
<Menu.Item as="a" disabled href="#new-feature" className={resolveClass}>
New feature (soon)
</Menu.Item>
<Menu.Item as="a" href="#license" className={resolveClass}>
License
</Menu.Item>
</div>
<div className="py-1">
<Menu.Item as="a" href="#sign-out" className={resolveClass}>
Sign out
</Menu.Item>
</div>
</Menu.Items>
</Menu>
</div>
)
}
@@ -0,0 +1,116 @@
import React, { forwardRef, Fragment } from 'react'
import { Popover, Portal, Transition } from '@headlessui/react'
import { usePopper } from '../../utils/hooks/use-popper'
let Button = forwardRef(
(props: React.ComponentProps<'button'>, ref: React.MutableRefObject<HTMLButtonElement>) => {
return (
<Popover.Button
ref={ref}
className="px-3 py-2 bg-gray-300 border-2 border-transparent focus:outline-none focus:border-blue-900"
{...props}
/>
)
}
)
function Link(props: React.ComponentProps<'a'>) {
return (
<a
href="/"
className="px-3 py-2 border-2 border-transparent hover:bg-gray-200 focus:bg-gray-200 focus:outline-none focus:border-blue-900"
{...props}
>
{props.children}
</a>
)
}
export default function Home() {
let options = {
placement: 'bottom-start' as const,
strategy: 'fixed' as const,
modifiers: [],
}
let [reference1, popper1] = usePopper(options)
let [reference2, popper2] = usePopper(options)
let links = ['First', 'Second', 'Third', 'Fourth']
return (
<div className="flex justify-center items-center space-x-12 p-12">
<button>Previous</button>
<Popover.Group as="nav" aria-label="Mythical University" className="flex space-x-3">
<Popover as="div" className="relative">
<Transition
as={Fragment}
enter="transition ease-out duration-300 transform"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-in duration-300 transform"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Popover.Overlay className="bg-opacity-75 bg-gray-500 fixed inset-0 z-20"></Popover.Overlay>
</Transition>
<Popover.Button className="px-3 py-2 bg-gray-300 border-2 border-transparent focus:outline-none focus:border-blue-900 relative z-30">
Normal
</Popover.Button>
<Popover.Panel className="absolute flex flex-col w-64 bg-gray-100 border-2 border-blue-900 z-30">
{links.map((link, i) => (
<Link key={link} hidden={i === 2}>
Normal - {link}
</Link>
))}
</Popover.Panel>
</Popover>
<Popover as="div" className="relative">
<Button>Focus</Button>
<Popover.Panel
focus
className="absolute flex flex-col w-64 bg-gray-100 border-2 border-blue-900"
>
{links.map((link, i) => (
<Link key={link}>Focus - {link}</Link>
))}
</Popover.Panel>
</Popover>
<Popover as="div" className="relative">
<Button ref={reference1}>Portal</Button>
<Portal>
<Popover.Panel
ref={popper1}
className="flex flex-col w-64 bg-gray-100 border-2 border-blue-900"
>
{links.map(link => (
<Link key={link}>Portal - {link}</Link>
))}
</Popover.Panel>
</Portal>
</Popover>
<Popover as="div" className="relative">
<Button ref={reference2}>Focus in Portal</Button>
<Portal>
<Popover.Panel
ref={popper2}
focus
className="flex flex-col w-64 bg-gray-100 border-2 border-blue-900"
>
{links.map(link => (
<Link key={link}>Focus in Portal - {link}</Link>
))}
</Popover.Panel>
</Portal>
</Popover>
</Popover.Group>
<button>Next</button>
</div>
)
}
@@ -0,0 +1,101 @@
import React, { useState } from 'react'
import { RadioGroup } from '@headlessui/react'
import { classNames } from '../../utils/class-names'
export default function Home() {
let access = [
{
id: 'access-1',
name: 'Public access',
description: 'This project would be available to anyone who has the link',
},
{
id: 'access-2',
name: 'Private to Project Members',
description: 'Only members of this project would be able to access',
},
{
id: 'access-3',
name: 'Private to you',
description: 'You are the only one able to access this project',
},
]
let [active, setActive] = useState()
return (
<div className="p-12 max-w-xl">
<a href="/">Link before</a>
<RadioGroup value={active} onChange={setActive}>
<fieldset className="space-y-4">
<legend>
<h2 className="text-xl">Privacy setting</h2>
</legend>
<div className="bg-white rounded-md -space-y-px">
{access.map(({ id, name, description }, i) => {
return (
<RadioGroup.Option
key={id}
value={id}
className={({ active }) =>
classNames(
// Rounded corners
i === 0 && 'rounded-tl-md rounded-tr-md',
access.length - 1 === i && 'rounded-bl-md rounded-br-md',
// Shared
'relative border p-4 flex focus:outline-none',
active ? 'bg-indigo-50 border-indigo-200 z-10' : 'border-gray-200'
)
}
>
{({ active, checked }) => (
<div className="flex justify-between items-center w-full">
<div className="ml-3 flex flex-col cursor-pointer">
<span
className={classNames(
'block text-sm leading-5 font-medium',
active ? 'text-indigo-900' : 'text-gray-900'
)}
>
{name}
</span>
<span
className={classNames(
'block text-sm leading-5',
active ? 'text-indigo-700' : 'text-gray-500'
)}
>
{description}
</span>
</div>
<div>
{checked && (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
className="h-5 w-5 text-indigo-500"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
)}
</div>
</div>
)}
</RadioGroup.Option>
)
})}
</div>
</fieldset>
</RadioGroup>
<a href="/">Link after</a>
</div>
)
}
@@ -0,0 +1,39 @@
import React, { useState } from 'react'
import { Switch } from '@headlessui/react'
import { classNames } from '../../utils/class-names'
export default function Home() {
let [state, setState] = useState(false)
return (
<div className="flex items-start justify-center w-screen h-full p-12 bg-gray-50">
<Switch.Group as="div" className="flex items-center space-x-4">
<Switch.Label>Enable notifications</Switch.Label>
<Switch
as="button"
checked={state}
onChange={setState}
className={({ checked }) =>
classNames(
'relative inline-flex flex-shrink-0 h-6 border-2 border-transparent rounded-full cursor-pointer w-11 focus:outline-none focus:shadow-outline transition-colors ease-in-out duration-200',
checked ? 'bg-indigo-600' : 'bg-gray-200'
)
}
>
{({ checked }) => (
<>
<span
className={classNames(
'inline-block w-5 h-5 bg-white rounded-full transform transition ease-in-out duration-200',
checked ? 'translate-x-5' : 'translate-x-0'
)}
/>
</>
)}
</Switch>
</Switch.Group>
</div>
)
}
@@ -0,0 +1,86 @@
import React, { useState } from 'react'
import { Tab, Switch } from '@headlessui/react'
import { classNames } from '../../utils/class-names'
export default function Home() {
let tabs = [
{ name: 'My Account', content: 'Tab content for my account' },
{ name: 'Company', content: 'Tab content for company', disabled: true },
{ name: 'Team Members', content: 'Tab content for team members' },
{ name: 'Billing', content: 'Tab content for billing' },
]
let [manual, setManual] = useState(false)
return (
<div className="flex flex-col items-start w-screen h-full p-12 bg-gray-50 space-y-12">
<Switch.Group as="div" className="flex items-center space-x-4">
<Switch.Label>Manual keyboard activation</Switch.Label>
<Switch
as="button"
checked={manual}
onChange={setManual}
className={({ checked }) =>
classNames(
'relative inline-flex flex-shrink-0 h-6 border-2 border-transparent rounded-full cursor-pointer w-11 focus:outline-none focus:shadow-outline transition-colors ease-in-out duration-200',
checked ? 'bg-indigo-600' : 'bg-gray-200'
)
}
>
{({ checked }) => (
<span
className={classNames(
'inline-block w-5 h-5 bg-white rounded-full transform transition ease-in-out duration-200',
checked ? 'translate-x-5' : 'translate-x-0'
)}
/>
)}
</Switch>
</Switch.Group>
<Tab.Group className="flex flex-col max-w-3xl w-full" as="div" manual={manual}>
<Tab.List className="relative z-0 rounded-lg shadow flex divide-x divide-gray-200">
{tabs.map((tab, tabIdx) => (
<Tab
key={tab.name}
disabled={tab.disabled}
className={({ selected }) =>
classNames(
selected ? 'text-gray-900' : 'text-gray-500 hover:text-gray-700',
tabIdx === 0 ? 'rounded-l-lg' : '',
tabIdx === tabs.length - 1 ? 'rounded-r-lg' : '',
tab.disabled && 'opacity-50',
'group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-sm font-medium text-center hover:bg-gray-50 focus:z-10'
)
}
>
{({ selected }) => (
<>
<span>{tab.name}</span>
{tab.disabled && <small className="inline-block px-4 text-xs">(disabled)</small>}
<span
aria-hidden="true"
className={classNames(
selected ? 'bg-indigo-500' : 'bg-transparent',
'absolute inset-x-0 bottom-0 h-0.5'
)}
/>
</>
)}
</Tab>
))}
</Tab.List>
<Tab.Panels className="mt-4">
{tabs.map(tab => (
<Tab.Panel className="bg-white rounded-lg p-4 shadow" key={tab.name}>
{tab.content}
</Tab.Panel>
))}
</Tab.Panels>
</Tab.Group>
</div>
)
}
@@ -0,0 +1,98 @@
import React, { useState } from 'react'
import Head from 'next/head'
import { Transition } from '@headlessui/react'
export default function Home() {
return (
<>
<Head>
<title>Transition Component - Playground</title>
</Head>
<div className="flex justify-center w-screen h-full p-12 bg-gray-50">
<Dropdown />
</div>
</>
)
}
function Dropdown() {
let [isOpen, setIsOpen] = useState(false)
return (
<div className="relative inline-block text-left">
<div>
<span className="rounded-md shadow-sm">
<button
type="button"
className="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800"
id="options-menu"
aria-haspopup="true"
aria-expanded={isOpen}
onClick={() => setIsOpen(v => !v)}
>
Options
<svg className="w-5 h-5 ml-2 -mr-1" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</span>
</div>
<Transition
show={isOpen}
enter="transition ease-out duration-75"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-150"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
className="absolute right-0 w-56 mt-2 origin-top-right rounded-md shadow-lg"
>
<div className="bg-white rounded-md shadow-xs">
<div
className="py-1"
role="menu"
aria-orientation="vertical"
aria-labelledby="options-menu"
>
<a
href="/"
className="block px-4 py-2 text-sm leading-5 text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:bg-gray-100 focus:text-gray-900"
role="menuitem"
>
Account settings
</a>
<a
href="/"
className="block px-4 py-2 text-sm leading-5 text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:bg-gray-100 focus:text-gray-900"
role="menuitem"
>
Support
</a>
<a
href="/"
className="block px-4 py-2 text-sm leading-5 text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:bg-gray-100 focus:text-gray-900"
role="menuitem"
>
License
</a>
<form method="POST" action="#">
<button
type="submit"
className="block w-full px-4 py-2 text-sm leading-5 text-left text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:bg-gray-100 focus:text-gray-900"
role="menuitem"
>
Sign out
</button>
</form>
</div>
</div>
</Transition>
</div>
)
}
@@ -0,0 +1,168 @@
import React, { useRef, useState } from 'react'
import { Transition } from '@headlessui/react'
export default function Home() {
let [isOpen, setIsOpen] = useState(false)
function toggle() {
setIsOpen(v => !v)
}
let [email, setEmail] = useState('')
let [events, setEvents] = useState([])
let inputRef = useRef(null)
function addEvent(name) {
setEvents(existing => [...existing, `${new Date().toJSON()} - ${name}`])
}
return (
<div>
<div className="flex p-12 space-x-4">
<div className="inline-block p-12">
<span className="flex w-full mt-3 rounded-md shadow-sm sm:mt-0 sm:w-auto">
<button
onClick={toggle}
type="button"
className="inline-flex justify-center w-full px-4 py-2 text-base font-medium leading-6 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md shadow-sm hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue sm:text-sm sm:leading-5"
>
Show modal
</button>
</span>
</div>
<ul className="p-4 text-gray-900 bg-gray-200">
<h3 className="font-bold">Events:</h3>
{events.map((event, i) => (
<li key={i} className="font-mono text-sm">
{event}
</li>
))}
</ul>
</div>
<Transition
show={isOpen}
className="fixed inset-0 z-10 overflow-y-auto"
beforeEnter={() => {
addEvent('Before enter')
}}
afterEnter={() => {
inputRef.current.focus()
addEvent('After enter')
}}
beforeLeave={() => {
addEvent('Before leave (before confirm)')
window.confirm('Are you sure?')
addEvent('Before leave (after confirm)')
}}
afterLeave={() => {
addEvent('After leave (before alert)')
window.alert('Consider it done!')
addEvent('After leave (after alert)')
setEmail('')
}}
>
<div className="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<Transition.Child
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 transition-opacity">
<div className="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
</Transition.Child>
{/* This element is to trick the browser into centering the modal contents. */}
<span className="hidden sm:inline-block sm:align-middle sm:h-screen"></span>&#8203;
<Transition.Child
className="inline-block overflow-hidden text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div className="px-4 pt-5 pb-4 bg-white sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto bg-red-100 rounded-full sm:mx-0 sm:h-10 sm:w-10">
{/* Heroicon name: exclamation */}
<svg
className="w-6 h-6 text-red-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-headline">
Deactivate account
</h3>
<div className="mt-2">
<p className="text-sm leading-5 text-gray-500">
Are you sure you want to deactivate your account? All of your data will be
permanently removed. This action cannot be undone.
</p>
</div>
<div className="mt-2">
<div>
<label
htmlFor="email"
className="block text-sm font-medium leading-5 text-gray-700"
>
Email address
</label>
<div className="relative mt-1 rounded-md shadow-sm">
<input
ref={inputRef}
value={email}
onChange={event => setEmail(event.target.value)}
id="email"
className="block w-full px-3 form-input sm:text-sm sm:leading-5"
placeholder="name@example.com"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="px-4 py-3 bg-gray-50 sm:px-6 sm:flex sm:flex-row-reverse">
<span className="flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto">
<button
type="button"
className="inline-flex justify-center w-full px-4 py-2 text-base font-medium leading-6 text-white transition duration-150 ease-in-out bg-red-600 border border-transparent rounded-md shadow-sm hover:bg-red-500 focus:outline-none focus:border-red-700 focus:shadow-outline-red sm:text-sm sm:leading-5"
>
Deactivate
</button>
</span>
<span className="flex w-full mt-3 rounded-md shadow-sm sm:mt-0 sm:w-auto">
<button
onClick={toggle}
type="button"
className="inline-flex justify-center w-full px-4 py-2 text-base font-medium leading-6 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md shadow-sm hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue sm:text-sm sm:leading-5"
>
Cancel
</button>
</span>
</div>
</Transition.Child>
</div>
</Transition>
</div>
)
}
@@ -0,0 +1,60 @@
import React, { useState, ReactNode } from 'react'
import { Transition } from '@headlessui/react'
export default function Home() {
let [isOpen, setIsOpen] = useState(true)
return (
<>
<div className="flex justify-center w-screen h-full p-12 bg-gray-50">
<div className="space-y-2 w-96">
<span className="inline-flex rounded-md shadow-sm">
<button
type="button"
onClick={() => setIsOpen(v => !v)}
className="inline-flex items-center px-3 py-2 text-sm font-medium leading-4 text-gray-700 transition bg-white border border-gray-300 rounded-md duration-150-out hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:text-gray-800 active:bg-gray-50"
>
{isOpen ? 'Hide' : 'Show'}
</button>
</span>
<Transition show={isOpen} unmount={false}>
<Box>
<Box>
<Box>
<Box />
</Box>
<Box>
<Box>
<Box>
<Box />
</Box>
</Box>
</Box>
</Box>
</Box>
</Transition>
</div>
</div>
</>
)
}
function Box({ children }: { children?: ReactNode }) {
return (
<Transition.Child
unmount={false}
enter="transition translate duration-300"
enterFrom="transform -translate-x-full"
enterTo="transform translate-x-0"
leave="transition translate duration-300"
leaveFrom="transform translate-x-0"
leaveTo="transform translate-x-full"
>
<div className="p-4 space-y-2 text-sm font-semibold tracking-wide text-gray-700 uppercase bg-white rounded-md shadow">
<span>This is a box</span>
{children}
</div>
</Transition.Child>
)
}
@@ -0,0 +1,60 @@
import React, { useState, ReactNode } from 'react'
import { Transition } from '@headlessui/react'
export default function Home() {
let [isOpen, setIsOpen] = useState(true)
return (
<>
<div className="flex justify-center w-screen h-full p-12 bg-gray-50">
<div className="space-y-2 w-96">
<span className="inline-flex rounded-md shadow-sm">
<button
type="button"
onClick={() => setIsOpen(v => !v)}
className="inline-flex items-center px-3 py-2 text-sm font-medium leading-4 text-gray-700 transition bg-white border border-gray-300 rounded-md duration-150-out hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:text-gray-800 active:bg-gray-50"
>
{isOpen ? 'Hide' : 'Show'}
</button>
</span>
<Transition show={isOpen} unmount={true}>
<Box>
<Box>
<Box>
<Box />
</Box>
<Box>
<Box>
<Box>
<Box />
</Box>
</Box>
</Box>
</Box>
</Box>
</Transition>
</div>
</div>
</>
)
}
function Box({ children }: { children?: ReactNode }) {
return (
<Transition.Child
unmount={true}
enter="transition translate duration-300"
enterFrom="transform -translate-x-full"
enterTo="transform translate-x-0"
leave="transition translate duration-300"
leaveFrom="transform translate-x-0"
leaveTo="transform translate-x-full"
>
<div className="p-4 space-y-2 text-sm font-semibold tracking-wide text-gray-700 uppercase bg-white rounded-md shadow">
<span>This is a box</span>
{children}
</div>
</Transition.Child>
)
}
@@ -0,0 +1,38 @@
import React, { useState } from 'react'
import { Transition } from '@headlessui/react'
export default function Home() {
let [isOpen, setIsOpen] = useState(true)
return (
<>
<div className="flex justify-center w-screen h-full p-12 bg-gray-50">
<div className="space-y-2 w-96">
<span className="inline-flex rounded-md shadow-sm">
<button
type="button"
onClick={() => setIsOpen(v => !v)}
className="inline-flex items-center px-3 py-2 text-sm font-medium leading-4 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:text-gray-800 active:bg-gray-50"
>
{isOpen ? 'Hide' : 'Show'}
</button>
</span>
<Transition
show={isOpen}
unmount={false}
enter="transition ease-out duration-300"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition ease-in duration-300"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
className="p-4 bg-white rounded-md shadow"
>
Contents to show and hide
</Transition>
</div>
</div>
</>
)
}
@@ -0,0 +1,181 @@
import React, { useEffect, useRef, useState } from 'react'
import Head from 'next/head'
import { Transition } from '@headlessui/react'
import { classNames } from '../../../utils/class-names'
import { match } from '../../../utils/match'
export default function Shell() {
return (
<>
<Head>
<title>Transition Component - Full Page Transition</title>
</Head>
<div className="h-full p-12 bg-gray-50">
<div className="flex flex-col flex-1 h-full overflow-hidden rounded-lg shadow-lg">
<FullPageTransition />
</div>
</div>
</>
)
}
function usePrevious<T>(value: T) {
let ref = useRef(value)
useEffect(() => {
ref.current = value
}, [value])
return ref.current
}
enum Direction {
Forwards = ' -> ',
Backwards = ' <- ',
}
let pages = ['Dashboard', 'Team', 'Projects', 'Calendar', 'Reports']
let colors = [
'bg-gradient-to-r from-teal-400 to-blue-400',
'bg-gradient-to-r from-blue-400 to-orange-400',
'bg-gradient-to-r from-orange-400 to-purple-400',
'bg-gradient-to-r from-purple-400 to-green-400',
'bg-gradient-to-r from-green-400 to-teal-400',
]
function FullPageTransition() {
let [activePage, setActivePage] = useState(0)
let previousPage = usePrevious(activePage)
let direction = activePage > previousPage ? Direction.Forwards : Direction.Backwards
let transitions = match(direction, {
[Direction.Forwards]: {
enter: 'transition transform ease-in-out duration-500',
enterFrom: 'translate-x-full',
enterTo: 'translate-x-0',
leave: 'transition transform ease-in-out duration-500',
leaveFrom: 'translate-x-0',
leaveTo: '-translate-x-full',
},
[Direction.Backwards]: {
enter: 'transition transform ease-in-out duration-500',
enterFrom: '-translate-x-full',
enterTo: 'translate-x-0',
leave: 'transition transform ease-in-out duration-500',
leaveFrom: 'translate-x-0',
leaveTo: 'translate-x-full',
},
})
return (
<div>
<div className="pb-32 bg-gray-800">
<nav className="bg-gray-800">
<div className="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div className="border-b border-gray-700">
<div className="flex items-center justify-between h-16 px-4 sm:px-0">
<div className="flex items-center">
<div className="flex-shrink-0">
<img
className="w-8 h-8"
src="https://tailwindui.com/img/logos/workflow-mark-on-dark.svg"
alt="Workflow logo"
/>
</div>
<div className="hidden md:block">
<div className="flex items-baseline ml-10 space-x-4">
{pages.map((page, i) => (
<button
key={page}
onClick={() => setActivePage(i)}
className={classNames(
'px-3 py-2 text-sm font-medium rounded-md focus:outline-none focus:text-white focus:bg-gray-700',
i === activePage
? 'text-white bg-gray-900'
: 'text-gray-300 hover:text-white hover:bg-gray-700'
)}
>
{page}
</button>
))}
</div>
</div>
</div>
<div className="hidden md:block">
<div className="flex items-center ml-4 md:ml-6">
<button
className="p-1 text-gray-400 border-2 border-transparent rounded-full hover:text-white focus:outline-none focus:text-white focus:bg-gray-700"
aria-label="Notifications"
>
<svg
className="w-6 h-6"
stroke="currentColor"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
</button>
{/* Profile dropdown */}
<div className="relative ml-3">
<div>
<button
className="flex items-center max-w-xs text-sm text-white rounded-full focus:outline-none focus:shadow-solid"
id="user-menu"
aria-label="User menu"
aria-haspopup="true"
>
<img
className="w-8 h-8 rounded-full"
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
alt=""
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</nav>
<header className="py-10">
<div className="px-4 mx-auto max-w-7xl sm:px-6 lg:px-8">
<h1 className="relative inline-block text-3xl font-bold leading-9 text-white">
{pages[activePage]}
</h1>
</div>
</header>
</div>
<main className="-mt-32">
<div className="px-4 pb-12 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div className="px-5 py-6 bg-white rounded-lg shadow sm:px-6">
<div className="relative overflow-hidden rounded-lg h-96">
{pages.map((page, i) => (
<Transition
appear={false}
key={page}
show={activePage === i}
className={classNames(
'absolute inset-0 p-8 text-3xl rounded-lg text-white font-bold',
colors[i]
)}
{...transitions}
>
{page} page content
</Transition>
))}
</div>
</div>
</div>
</main>
</div>
)
}
@@ -0,0 +1,170 @@
import React, { useEffect, useState } from 'react'
import Head from 'next/head'
import { Transition } from '@headlessui/react'
export default function App() {
let [mobileOpen, setMobileOpen] = useState(false)
useEffect(() => {
function handleEscape(event) {
if (!mobileOpen) return
if (event.key === 'Escape') {
setMobileOpen(false)
}
}
document.addEventListener('keyup', handleEscape)
return () => document.removeEventListener('keyup', handleEscape)
}, [mobileOpen])
return (
<>
<Head>
<title>Transition Component - Layout with sidebar</title>
</Head>
<div className="flex h-screen overflow-hidden bg-cool-gray-100">
{/* Off-canvas menu for mobile */}
<Transition show={mobileOpen} unmount={false} className="fixed inset-0 z-40 flex">
{/* Off-canvas menu overlay, show/hide based on off-canvas menu state. */}
<Transition.Child
unmount={false}
enter="transition-opacity ease-linear duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-linear duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
{() => (
<div className="fixed inset-0">
<div
onClick={() => setMobileOpen(false)}
className="absolute inset-0 opacity-75 bg-cool-gray-600"
/>
</div>
)}
</Transition.Child>
{/* Off-canvas menu, show/hide based on off-canvas menu state. */}
<Transition.Child
unmount={false}
enter="transition ease-in-out duration-300 transform"
enterFrom="-translate-x-full"
enterTo="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leaveFrom="translate-x-0"
leaveTo="-translate-x-full"
className="relative flex flex-col flex-1 w-full max-w-xs pt-5 pb-4 bg-teal-600"
>
<div className="absolute top-0 right-0 p-1 -mr-14">
<Transition.Child
unmount={false}
className="flex items-center justify-center w-12 h-12 rounded-full focus:outline-none focus:bg-cool-gray-600"
aria-label="Close sidebar"
as="button"
onClick={() => setMobileOpen(false)}
>
<svg
className="w-6 h-6 text-white"
stroke="currentColor"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</Transition.Child>
</div>
<div className="flex items-center flex-shrink-0 px-4">
<img
className="w-auto h-8"
src="https://tailwindui.com/img/logos/easywire-logo-on-brand.svg"
alt="Easywire logo"
/>
</div>
</Transition.Child>
<div className="flex-shrink-0 w-14">
{/* Dummy element to force sidebar to shrink to fit close icon */}
</div>
</Transition>
{/* Static sidebar for desktop */}
<div className="hidden lg:flex lg:flex-shrink-0">
<div className="flex flex-col w-64">
{/* Sidebar component, swap this element with another sidebar if you like */}
<div className="flex flex-col flex-grow pt-5 pb-4 overflow-y-auto bg-teal-600">
<div className="flex items-center flex-shrink-0 px-4">
<img
className="w-auto h-8"
src="https://tailwindui.com/img/logos/easywire-logo-on-brand.svg"
alt="Easywire logo"
/>
</div>
</div>
</div>
</div>
<div className="flex-1 overflow-auto focus:outline-none" tabIndex={0}>
<div className="relative z-10 flex flex-shrink-0 h-16 bg-white border-b border-gray-200 lg:border-none">
<button
className="px-4 border-r border-cool-gray-200 text-cool-gray-400 focus:outline-none focus:bg-cool-gray-100 focus:text-cool-gray-600 lg:hidden"
aria-label="Open sidebar"
onClick={() => setMobileOpen(true)}
>
<svg
className="w-6 h-6 transition duration-150 ease-in-out"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 12h8m-8 6h16"
/>
</svg>
</button>
{/* Search bar */}
<div className="flex justify-between flex-1 px-4 sm:px-6 lg:max-w-6xl lg:mx-auto lg:px-8">
<div className="flex flex-1">
<form className="flex w-full md:ml-0" action="#" method="GET">
<label htmlFor="search_field" className="sr-only">
Search
</label>
<div className="relative w-full text-cool-gray-400 focus-within:text-cool-gray-600">
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
/>
</svg>
</div>
<input
id="search_field"
className="block w-full h-full py-2 pl-8 pr-3 rounded-md text-cool-gray-900 placeholder-cool-gray-500 focus:outline-none focus:placeholder-cool-gray-400 sm:text-sm"
placeholder="Search"
type="search"
/>
</div>
</form>
</div>
</div>
</div>
<main className="relative z-0 flex-1 p-8 overflow-y-auto">
{/* Replace with your content */}
<div className="border-4 border-gray-200 border-dashed rounded-lg h-96"></div>
{/* /End replace */}
</main>
</div>
</div>
</>
)
}
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}
@@ -0,0 +1,3 @@
export function classNames(...classes: (false | null | undefined | string)[]): string {
return classes.filter(Boolean).join(' ')
}
@@ -0,0 +1,37 @@
import { RefCallback, useRef, useCallback, useMemo } from 'react'
import { createPopper, Options } from '@popperjs/core'
/**
* Example implementation to use Popper: https://popper.js.org/
*/
export function usePopper(
options?: Partial<Options>
): [RefCallback<Element | null>, RefCallback<HTMLElement | null>] {
let reference = useRef<Element>(null)
let popper = useRef<HTMLElement>(null)
let cleanupCallback = useRef(() => {})
let instantiatePopper = useCallback(() => {
if (!reference.current) return
if (!popper.current) return
if (cleanupCallback.current) cleanupCallback.current()
cleanupCallback.current = createPopper(reference.current, popper.current, options).destroy
}, [reference, popper, cleanupCallback, options])
return useMemo(
() => [
referenceDomNode => {
reference.current = referenceDomNode
instantiatePopper()
},
popperDomNode => {
popper.current = popperDomNode
instantiatePopper()
},
],
[reference, popper, instantiatePopper]
)
}
+20
View File
@@ -0,0 +1,20 @@
export function match<TValue extends string | number = string, TReturnValue = unknown>(
value: TValue,
lookup: Record<TValue, TReturnValue | ((...args: any[]) => TReturnValue)>,
...args: any[]
): TReturnValue {
if (value in lookup) {
let returnValue = lookup[value]
return typeof returnValue === 'function' ? returnValue(...args) : returnValue
}
let error = new Error(
`Tried to handle "${value}" but there is no handler defined. Only defined handlers are: ${Object.keys(
lookup
)
.map(key => `"${key}"`)
.join(', ')}.`
)
if (Error.captureStackTrace) Error.captureStackTrace(error, match)
throw error
}
@@ -0,0 +1,50 @@
import fs from 'fs'
import path from 'path'
export type ExamplesType = {
name: string
path: string
children?: ExamplesType[]
}
export async function resolveAllExamples(...paths: string[]) {
let base = path.resolve(process.cwd(), ...paths)
if (!fs.existsSync(base)) {
return false
}
let files = await fs.promises.readdir(base, { withFileTypes: true })
let items: ExamplesType[] = []
for (let file of files) {
if (file.name === '.DS_Store') {
continue
}
// Skip reserved filenames from Next. E.g.: _app.tsx, _error.tsx
if (file.name.startsWith('_')) {
continue
}
let bucket: ExamplesType = {
name: file.name.replace(/-/g, ' ').replace(/\.tsx?/g, ''),
path: [...paths, file.name]
.join('/')
.replace(/^pages/, '')
.replace(/\.tsx?/g, '')
.replace(/\/+/g, '/'),
}
if (file.isDirectory()) {
let children = await resolveAllExamples(...paths, file.name)
if (children) {
bucket.children = children
}
}
items.push(bucket)
}
return items
}
+323 -15
View File
@@ -195,6 +195,11 @@
resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz#2f75a831269d4f677de49986dff59927533cf375"
integrity sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==
"@babel/helper-plugin-utils@^7.14.5":
version "7.16.7"
resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz#aa3a8ab4c3cceff8e65eb9e73d87dc4ff320b2f5"
integrity sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA==
"@babel/helper-regex@^7.10.4":
version "7.10.5"
resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.10.5.tgz#32dfbb79899073c415557053a19bd055aae50ae0"
@@ -244,7 +249,7 @@
dependencies:
"@babel/types" "^7.11.0"
"@babel/helper-validator-identifier@^7.10.4", "@babel/helper-validator-identifier@^7.16.7":
"@babel/helper-validator-identifier@^7.10.4", "@babel/helper-validator-identifier@^7.14.9", "@babel/helper-validator-identifier@^7.16.7":
version "7.16.7"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz#e8c602438c4a8195751243da9031d1607d247cad"
integrity sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==
@@ -443,6 +448,13 @@
dependencies:
"@babel/helper-plugin-utils" "^7.8.0"
"@babel/plugin-syntax-jsx@7.14.5":
version "7.14.5"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.14.5.tgz#000e2e25d8673cce49300517a3eda44c263e4201"
integrity sha512-ohuFIsOMXJnbOMRfX7/w7LocdR6R7whhuRD4ax8IipLcLPlZGJKkBxgHp++U4N/vKyU16/YDQr2f5seajD3jIw==
dependencies:
"@babel/helper-plugin-utils" "^7.14.5"
"@babel/plugin-syntax-logical-assignment-operators@^7.10.4", "@babel/plugin-syntax-logical-assignment-operators@^7.8.3":
version "7.10.4"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699"
@@ -874,6 +886,14 @@
globals "^11.1.0"
lodash "^4.17.19"
"@babel/types@7.15.0":
version "7.15.0"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.15.0.tgz#61af11f2286c4e9c69ca8deb5f4375a73c72dcbd"
integrity sha512-OBvfqnllOIdX4ojTHpwZbpvz4j3EWyjkZEdmjH0/cgsd6QOdSgU8rLSk6ard/pcW7rlmjdVSX/AWOaORR1uNOQ==
dependencies:
"@babel/helper-validator-identifier" "^7.14.9"
to-fast-properties "^2.0.0"
"@babel/types@^7.0.0", "@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.0", "@babel/types@^7.11.5", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0":
version "7.11.5"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.11.5.tgz#d9de577d01252d77c6800cee039ee64faf75662d"
@@ -904,6 +924,18 @@
exec-sh "^0.3.2"
minimist "^1.2.0"
"@emotion/is-prop-valid@^0.8.2":
version "0.8.8"
resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a"
integrity sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==
dependencies:
"@emotion/memoize" "0.7.4"
"@emotion/memoize@0.7.4":
version "0.7.4"
resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb"
integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==
"@istanbuljs/load-nyc-config@^1.0.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced"
@@ -1099,6 +1131,76 @@
"@types/yargs" "^15.0.0"
chalk "^4.0.0"
"@next/env@12.0.8":
version "12.0.8"
resolved "https://registry.yarnpkg.com/@next/env/-/env-12.0.8.tgz#a32ca0a97d464307f2e6ff106ce09b19aac108cf"
integrity sha512-Wa0gOeioB9PHap9wtZDZEhgOSE3/+qE/UALWjJHuNvH4J3oE+13EjVOiEsr1JcPCXUN8ESQE+phDKlo6qJ8P9g==
"@next/react-refresh-utils@12.0.8":
version "12.0.8"
resolved "https://registry.yarnpkg.com/@next/react-refresh-utils/-/react-refresh-utils-12.0.8.tgz#481760a95ef442abd091663db6582d4dc1b31f06"
integrity sha512-Bq4T/aOOFQUkCF9b8k9x+HpjOevu65ZPxsYJOpgEtBuJyvb+sZREtDDLKb/RtjUeLMrWrsGD0aLteyFFtiS8Og==
"@next/swc-android-arm64@12.0.8":
version "12.0.8"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.0.8.tgz#f8dc9663da367a75982730cac058339fb310d79a"
integrity sha512-BiXMcOZNnXSIXv+FQvbRgbMb+iYayLX/Sb2MwR0wja+eMs46BY1x/ssXDwUBADP1M8YtrGTlSPHZqUiCU94+Mg==
"@next/swc-darwin-arm64@12.0.8":
version "12.0.8"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.0.8.tgz#d6aced7d0a04815dd1324e7982accb3de6a643e8"
integrity sha512-6EGMmvcIwPpwt0/iqLbXDGx6oKHAXzbowyyVXK8cqmIvhoghRFjqfiNGBs+ar6wEBGt68zhwn/77vE3iQWoFJw==
"@next/swc-darwin-x64@12.0.8":
version "12.0.8"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.0.8.tgz#f4fe58d2ed852538410b15a0c80d78908050c716"
integrity sha512-todxgQOGP/ucz5UH2kKR3XGDdkWmWr0VZAAbzgTbiFm45Ol4ih602k2nNR3xSbza9IqNhxNuUVsMpBgeo19CFQ==
"@next/swc-linux-arm-gnueabihf@12.0.8":
version "12.0.8"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.0.8.tgz#2c02d824fb46e8c6094d7e758c5d7e965070f574"
integrity sha512-KULmdrfI+DJxBuhEyV47MQllB/WpC3P2xbwhHezxL/LkC2nkz5SbV4k432qpx2ebjIRf9SjdQ5Oz1FjD8Urayw==
"@next/swc-linux-arm64-gnu@12.0.8":
version "12.0.8"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.0.8.tgz#fc32caf3373b299558ede1d889e8555b9ba10ffb"
integrity sha512-1XO87wgIVPvt5fx5i8CqdhksRdcpqyzCOLW4KrE0f9pUCIT04EbsFiKdmsH9c73aqjNZMnCMXpbV+cn4hN8x1w==
"@next/swc-linux-arm64-musl@12.0.8":
version "12.0.8"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.0.8.tgz#c2d3d7bc2c34da81412b74bdd6e11d0615ae1886"
integrity sha512-NStRZEy/rkk2G18Yhc/Jzi1Q2Dv+zH176oO8479zlDQ5syRfc6AvRHVV4iNRc8Pai58If83r/nOJkwFgGwkKLw==
"@next/swc-linux-x64-gnu@12.0.8":
version "12.0.8"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.0.8.tgz#029d84f856801b818e5525ab1406f2446821d48c"
integrity sha512-rHxTGtTEDFsdT9/VjewzxE19S7W1NE+aZpm4TwbT1pSNGK9KQxQGcXjqoHMeB+VZCFknzNEoIU/vydbjZMlAuw==
"@next/swc-linux-x64-musl@12.0.8":
version "12.0.8"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.0.8.tgz#db572da90ab3bce0bc58595c6b8c2d32ec64a2d3"
integrity sha512-1F4kuFRQE10GSx7LMSvRmjMXFGpxT30g8rZzq9r/p/WKdErA4WB4uxaKEX0P8AINfuN63i4luKdR+LoacgBhYw==
"@next/swc-win32-arm64-msvc@12.0.8":
version "12.0.8"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.0.8.tgz#f33e2e56a96489935f87c6dd28f79a7b7ed3778f"
integrity sha512-QuRe49jqCV61TysGopC1P0HPqFAMZMWe1nbIQLyOkDLkULmZR8N2eYZq7fwqvZE5YwhMmJA/grwWFVBqSEh5Kg==
"@next/swc-win32-ia32-msvc@12.0.8":
version "12.0.8"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.0.8.tgz#0f6c7f3e50fc1a4752aed5c862f53c86ce77e3b8"
integrity sha512-0RV3/julybJr1IlPCowIWrJJZyAl+sOakJEM15y1NOOsbwTQ5eKZZXSi+7e23TN4wmy5HwNvn2dKzgOEVJ+jbA==
"@next/swc-win32-x64-msvc@12.0.8":
version "12.0.8"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.0.8.tgz#eae6d4c94dc8aae8ba177e2de02080339d0d4563"
integrity sha512-tTga6OFfO2JS+Yt5hdryng259c/tzNgSWkdiU2E+RBHiysAIOta57n4PJ8iPahOSqEqjaToPI76wM+o441GaNQ==
"@popperjs/core@^2.6.0":
version "2.11.2"
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.2.tgz#830beaec4b4091a9e9398ac50f865ddea52186b9"
integrity sha512-92FRmppjjqz29VMJ2dn+xdyXZBrMlE42AV6Kq6BwjWV7CNUW1hs2FtxSNLQE+gJhaZ6AAmYuO9y8dshhcBl7vA==
"@rollup/plugin-babel@^5.1.0":
version "5.2.1"
resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.2.1.tgz#20fc8f8864dc0eaa1c5578408459606808f72924"
@@ -1936,6 +2038,11 @@ bcrypt-pbkdf@^1.0.0:
dependencies:
tweetnacl "^0.14.3"
big.js@^5.2.2:
version "5.2.2"
resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==
brace-expansion@^1.1.7:
version "1.1.11"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@@ -2048,6 +2155,11 @@ caniuse-lite@^1.0.30001131:
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001191.tgz"
integrity sha512-xJJqzyd+7GCJXkcoBiQ1GuxEiOBCLQ0aVW9HMekifZsAVGdj5eJ4mFB9fEhSHipq9IOk/QXFJUiIr9lZT+EsGw==
caniuse-lite@^1.0.30001283:
version "1.0.30001300"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001300.tgz#11ab6c57d3eb6f964cba950401fd00a146786468"
integrity sha512-cVjiJHWGcNlJi8TZVKNMnvMid3Z3TTdDHmLDzlOdIiZq138Exvo0G+G0wTdVYolxKb4AYwC+38pxodiInVtJSA==
capture-exit@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/capture-exit/-/capture-exit-2.0.0.tgz#fb953bfaebeb781f62898239dabb426d08a509a4"
@@ -2211,6 +2323,11 @@ color-name@~1.1.4:
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
colorette@^1.2.2:
version "1.4.0"
resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.4.0.tgz#5190fbb87276259a86ad700bff2c6d6faa3fca40"
integrity sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==
colorette@^2.0.16:
version "2.0.16"
resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.16.tgz#713b9af84fdb000139f04546bd4a93f62a5085da"
@@ -2280,7 +2397,7 @@ contains-path@^0.1.0:
resolved "https://registry.yarnpkg.com/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a"
integrity sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo=
convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0:
convert-source-map@1.7.0, convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442"
integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==
@@ -2603,6 +2720,11 @@ emoji-regex@^9.2.2:
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72"
integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==
emojis-list@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389"
integrity sha1-TapNnbAPmBmIDHn6RXrlsJof04k=
end-of-stream@^1.1.0:
version "1.4.4"
resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
@@ -3221,6 +3343,26 @@ fragment-cache@^0.2.1:
dependencies:
map-cache "^0.2.2"
framer-motion@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-6.0.0.tgz#08e44c42b67c967774a197b3994f8475cd486c32"
integrity sha512-wVI+hVRkvQeWSvkxk8z5bTg+jBs9vfEZOist2s0e9tQzZvt+OBuAoAcvCvl+ADmFd4ncC2934vkwiJPZ8nSvMg==
dependencies:
framesync "6.0.1"
hey-listen "^1.0.8"
popmotion "11.0.3"
style-value-types "5.0.0"
tslib "^2.1.0"
optionalDependencies:
"@emotion/is-prop-valid" "^0.8.2"
framesync@6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/framesync/-/framesync-6.0.1.tgz#5e32fc01f1c42b39c654c35b16440e07a25d6f20"
integrity sha512-fUY88kXvGiIItgNC7wcTOl0SNRCVXMKSWW2Yzfmn7EKNc+MpCzcz9DhdHcdjbrtN3c6R4H5dTY2jiCpPdysEjA==
dependencies:
tslib "^2.1.0"
fs-extra@8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
@@ -3440,6 +3582,11 @@ has@^1.0.3:
dependencies:
function-bind "^1.1.1"
hey-listen@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68"
integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==
hosted-git-info@^2.1.4:
version "2.8.8"
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488"
@@ -3551,7 +3698,7 @@ inflight@^1.0.4:
once "^1.3.0"
wrappy "1"
inherits@2, inherits@^2.0.4:
inherits@2, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -4383,6 +4530,15 @@ jest-watcher@^25.2.4, jest-watcher@^25.5.0:
jest-util "^25.5.0"
string-length "^3.1.0"
jest-worker@27.0.0-next.5:
version "27.0.0-next.5"
resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.0.0-next.5.tgz#5985ee29b12a4e191f4aae4bb73b97971d86ec28"
integrity sha512-mk0umAQ5lT+CaOJ+Qp01N6kz48sJG2kr2n1rX0koqKf6FIygQV0qLOdN9SCYID4IVeSigDOcPeGLozdMLYfb5g==
dependencies:
"@types/node" "*"
merge-stream "^2.0.0"
supports-color "^8.0.0"
jest-worker@^24.9.0:
version "24.9.0"
resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-24.9.0.tgz#5dbfdb5b2d322e98567898238a9697bcce67b3e5"
@@ -4679,6 +4835,15 @@ load-json-file@^2.0.0:
pify "^2.0.0"
strip-bom "^3.0.0"
loader-utils@1.2.3:
version "1.2.3"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.2.3.tgz#1ff5dc6911c9f0a062531a4c04b609406108c2c7"
integrity sha512-fkpz8ejdnEMG3s37wGL07iSBDg99O9D5yflE9RGNH3hRdx9SOwYfnGYdZOUIZitN8E+E2vkq3MUMYMvPYl5ZZA==
dependencies:
big.js "^5.2.2"
emojis-list "^2.0.0"
json5 "^1.0.1"
locate-path@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"
@@ -4943,6 +5108,11 @@ mute-stream@0.0.8:
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
nanoid@^3.1.23:
version "3.2.0"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.2.0.tgz#62667522da6673971cca916a6d3eff3f415ff80c"
integrity sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA==
nanomatch@^1.2.9:
version "1.2.13"
resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"
@@ -4965,6 +5135,35 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
next@^12.0.8:
version "12.0.8"
resolved "https://registry.yarnpkg.com/next/-/next-12.0.8.tgz#29138f7cdd045e4bbba466af45bf430e769634b4"
integrity sha512-g5c1Kuh1F8tSXJn2rVvzYBzqe9EXaR6+rY3/KrQ7y0D9FueRLfHI35wM0DRadDcPSc3+vncspfhYH3jnYE/KjA==
dependencies:
"@next/env" "12.0.8"
"@next/react-refresh-utils" "12.0.8"
caniuse-lite "^1.0.30001283"
jest-worker "27.0.0-next.5"
node-fetch "2.6.1"
postcss "8.2.15"
react-is "17.0.2"
react-refresh "0.8.3"
stream-browserify "3.0.0"
styled-jsx "5.0.0-beta.6"
use-subscription "1.5.1"
optionalDependencies:
"@next/swc-android-arm64" "12.0.8"
"@next/swc-darwin-arm64" "12.0.8"
"@next/swc-darwin-x64" "12.0.8"
"@next/swc-linux-arm-gnueabihf" "12.0.8"
"@next/swc-linux-arm64-gnu" "12.0.8"
"@next/swc-linux-arm64-musl" "12.0.8"
"@next/swc-linux-x64-gnu" "12.0.8"
"@next/swc-linux-x64-musl" "12.0.8"
"@next/swc-win32-arm64-msvc" "12.0.8"
"@next/swc-win32-ia32-msvc" "12.0.8"
"@next/swc-win32-x64-msvc" "12.0.8"
nice-try@^1.0.4:
version "1.0.5"
resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
@@ -4978,6 +5177,11 @@ no-case@^3.0.3:
lower-case "^2.0.1"
tslib "^1.10.0"
node-fetch@2.6.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
node-int64@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"
@@ -5414,11 +5618,30 @@ pn@^1.1.0:
resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb"
integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==
popmotion@11.0.3:
version "11.0.3"
resolved "https://registry.yarnpkg.com/popmotion/-/popmotion-11.0.3.tgz#565c5f6590bbcddab7a33a074bb2ba97e24b0cc9"
integrity sha512-Y55FLdj3UxkR7Vl3s7Qr4e9m0onSnP8W7d/xQLsoJM40vs6UKHFdygs6SWryasTZYqugMjm3BepCF4CWXDiHgA==
dependencies:
framesync "6.0.1"
hey-listen "^1.0.8"
style-value-types "5.0.0"
tslib "^2.1.0"
posix-character-classes@^0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=
postcss@8.2.15:
version "8.2.15"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.2.15.tgz#9e66ccf07292817d226fc315cbbf9bc148fbca65"
integrity sha512-2zO3b26eJD/8rb106Qu2o7Qgg52ND5HPjcyQiK2B98O388h43A448LCslC0dI2P97wCAQRJsFvwTRcXxTKds+Q==
dependencies:
colorette "^1.2.2"
nanoid "^3.1.23"
source-map "^0.6.1"
prelude-ls@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
@@ -5556,7 +5779,7 @@ randombytes@^2.1.0:
dependencies:
safe-buffer "^5.1.0"
react-dom@^16.14.0:
react-dom@16.14.0, react-dom@^16.14.0:
version "16.14.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89"
integrity sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==
@@ -5566,17 +5789,22 @@ react-dom@^16.14.0:
prop-types "^15.6.2"
scheduler "^0.19.1"
react-is@17.0.2, react-is@^17.0.1:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
react-is@^16.12.0, react-is@^16.8.1:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-is@^17.0.1:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
react-refresh@0.8.3:
version "0.8.3"
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"
integrity sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg==
react@^16.14.0:
react@16.14.0, react@^16.14.0:
version "16.14.0"
resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d"
integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==
@@ -5621,6 +5849,15 @@ read-pkg@^5.2.0:
parse-json "^5.0.0"
type-fest "^0.6.0"
readable-stream@^3.5.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
dependencies:
inherits "^2.0.3"
string_decoder "^1.1.1"
util-deprecate "^1.0.1"
realpath-native@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-2.0.0.tgz#7377ac429b6e1fd599dc38d08ed942d0d7beb866"
@@ -5938,7 +6175,7 @@ sade@^1.4.2:
dependencies:
mri "^1.1.0"
safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2:
safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2, safe-buffer@~5.2.0:
version "5.2.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
@@ -6219,6 +6456,11 @@ source-map-url@^0.4.0:
resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3"
integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=
source-map@0.7.3, source-map@^0.7.3:
version "0.7.3"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383"
integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==
source-map@^0.5.0, source-map@^0.5.6:
version "0.5.7"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
@@ -6229,11 +6471,6 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1:
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
source-map@^0.7.3:
version "0.7.3"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383"
integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==
sourcemap-codec@^1.4.4:
version "1.4.8"
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
@@ -6317,11 +6554,24 @@ stealthy-require@^1.1.1:
resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b"
integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=
stream-browserify@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-3.0.0.tgz#22b0a2850cdf6503e73085da1fc7b7d0c2122f2f"
integrity sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==
dependencies:
inherits "~2.0.4"
readable-stream "^3.5.0"
string-argv@^0.3.1:
version "0.3.1"
resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da"
integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==
string-hash@1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/string-hash/-/string-hash-1.1.3.tgz#e8aafc0ac1855b4666929ed7dd1275df5d6c811b"
integrity sha1-6Kr8CsGFW0Zmkp7X3RJ1311sgRs=
string-length@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/string-length/-/string-length-3.1.0.tgz#107ef8c23456e187a8abd4a61162ff4ac6e25837"
@@ -6393,6 +6643,13 @@ string.prototype.trimstart@^1.0.1:
define-properties "^1.1.3"
es-abstract "^1.17.5"
string_decoder@^1.1.1:
version "1.3.0"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
dependencies:
safe-buffer "~5.2.0"
strip-ansi@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
@@ -6453,6 +6710,38 @@ strip-json-comments@^3.0.1:
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
style-value-types@5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/style-value-types/-/style-value-types-5.0.0.tgz#76c35f0e579843d523187989da866729411fc8ad"
integrity sha512-08yq36Ikn4kx4YU6RD7jWEv27v4V+PUsOGa4n/as8Et3CuODMJQ00ENeAVXAeydX4Z2j1XHZF1K2sX4mGl18fA==
dependencies:
hey-listen "^1.0.8"
tslib "^2.1.0"
styled-jsx@5.0.0-beta.6:
version "5.0.0-beta.6"
resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.0.0-beta.6.tgz#666552f8831a06f80c9084a47afc4b32b0c9f461"
integrity sha512-b1cM7Xyp2r1lsNpvoZ6wmTI8qxD0557vH2feHakNU8LMkzfJDgTQMul6O7sSYY0GxQ73pKEN69hCDp71w6Q0nA==
dependencies:
"@babel/plugin-syntax-jsx" "7.14.5"
"@babel/types" "7.15.0"
convert-source-map "1.7.0"
loader-utils "1.2.3"
source-map "0.7.3"
string-hash "1.1.3"
stylis "3.5.4"
stylis-rule-sheet "0.0.10"
stylis-rule-sheet@0.0.10:
version "0.0.10"
resolved "https://registry.yarnpkg.com/stylis-rule-sheet/-/stylis-rule-sheet-0.0.10.tgz#44e64a2b076643f4b52e5ff71efc04d8c3c4a430"
integrity sha512-nTbZoaqoBnmK+ptANthb10ZRZOGC+EmTLLUxeYIuHNkEKcmKgXX1XWKkUBT2Ac4es3NybooPe0SmvKdhKJZAuw==
stylis@3.5.4:
version "3.5.4"
resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.5.4.tgz#f665f25f5e299cf3d64654ab949a57c768b73fbe"
integrity sha512-8/3pSmthWM7lsPBKv7NXkzn2Uc9W7NotcwGNpJaa3k7WMM1XDCA4MgT5k/8BIexd5ydZdboXtU90XH9Ec4Bv/Q==
supports-color@^5.3.0:
version "5.5.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
@@ -6474,6 +6763,13 @@ supports-color@^7.0.0, supports-color@^7.1.0:
dependencies:
has-flag "^4.0.0"
supports-color@^8.0.0:
version "8.1.1"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c"
integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==
dependencies:
has-flag "^4.0.0"
supports-color@^9.2.1:
version "9.2.1"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-9.2.1.tgz#599dc9d45acf74c6176e0d880bab1d7d718fe891"
@@ -6863,11 +7159,23 @@ urix@^0.1.0:
resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"
integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=
use-subscription@1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/use-subscription/-/use-subscription-1.5.1.tgz#73501107f02fad84c6dd57965beb0b75c68c42d1"
integrity sha512-Xv2a1P/yReAjAbhylMfFplFKj9GssgTwN7RlcTxBujFQcloStWNDQdc4g4NRWH9xS4i/FDk04vQBptAXoF3VcA==
dependencies:
object-assign "^4.1.1"
use@^3.1.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==
util-deprecate@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
uuid@^3.3.2:
version "3.4.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"