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:
+8
-1
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,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
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
devIndicators: {
|
||||
autoPrerender: false,
|
||||
},
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
​
|
||||
</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>​
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+181
@@ -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>
|
||||
)
|
||||
}
|
||||
+170
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user