Guarantee DOM sort order when performing actions (#1168)

* ensure proper sort order

We already fixed a bug in the past where the order of DOM nodes wasn't
stored in the correct order when performing operations (e.g.: using your
keyboard to go to the next option).

We fixed this by ensuring that when we register/unregister an
option/item, that we sorted the list properly. This worked fine, until
we introduced the Combobox components. This is because items in a
Combobox are continuously filtered and because of that moved around.

Moving a DOM node to a new position _doesn't_ require a full
unmount/remount. This means that the sort gets messed up and the order
is wrong when moving around again.

To fix this, we will always perform a sort when performing actions. This
could have performance drawbacks, but the alternative is to re-sort when
the component gets updated. The bad part is that you can update a
component via many ways (like changes on the parent), in those
scenario's you probably don't care to properly re-order the internal
list. Instead we do it while performing an action (`goToOption` / `goToItem`).

To make things a bit more efficient, instead of querying the DOM all the
time using `document.querySelectorAll`, we will keep track of the
underlying DOM node instead. This does increase memory usage a bit but I
think that this is a fine trade-off.

Performance wise this could also be a bottleneck to perform the sorting
if you have a lot of data. But this problem already exists today,
therefore I consider this a complete new problem instead to solve. Maybe
we don't solve it in Headless UI itself, but figure out a way to make it
composable with existing virtualization libraries.

* update changelog
This commit is contained in:
Robin Malfait
2022-02-28 14:58:17 +01:00
committed by GitHub
parent ca56a15152
commit a63ca93aae
12 changed files with 136 additions and 116 deletions
+2
View File
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure links are triggered inside `Popover Panel` components ([#1153](https://github.com/tailwindlabs/headlessui/pull/1153))
- Improve SSR for `Tab` component ([#1155](https://github.com/tailwindlabs/headlessui/pull/1155))
- Fix `hover` scroll ([#1161](https://github.com/tailwindlabs/headlessui/pull/1161))
- Guarantee DOM sort order when performing actions ([#1168](https://github.com/tailwindlabs/headlessui/pull/1168))
## [Unreleased - @headlessui/vue]
@@ -23,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix Dialog usage in Tabs ([#1149](https://github.com/tailwindlabs/headlessui/pull/1149))
- Ensure links are triggered inside `Popover Panel` components ([#1153](https://github.com/tailwindlabs/headlessui/pull/1153))
- Fix `hover` scroll ([#1161](https://github.com/tailwindlabs/headlessui/pull/1161))
- Guarantee DOM sort order when performing actions ([#1168](https://github.com/tailwindlabs/headlessui/pull/1168))
## [@headlessui/react@v1.5.0] - 2022-02-17
@@ -35,6 +35,7 @@ import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-cl
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useLatestValue } from '../../hooks/use-latest-value'
import { useTreeWalker } from '../../hooks/use-tree-walker'
import { sortByDomNode } from '../../utils/focus-management'
enum ComboboxStates {
Open,
@@ -50,6 +51,7 @@ type ComboboxOptionDataRef = MutableRefObject<{
textValue?: string
disabled: boolean
value: unknown
domRef: MutableRefObject<HTMLElement | null>
}>
interface StateDefinition {
@@ -129,11 +131,14 @@ let reducers: {
state.optionsRef.current &&
!state.optionsPropsRef.current.static &&
state.comboboxState === ComboboxStates.Closed
)
) {
return state
}
let options = sortByDomNode(state.options, (option) => option.dataRef.current.domRef.current)
let activeOptionIndex = calculateActiveIndex(action, {
resolveItems: () => state.options,
resolveItems: () => options,
resolveActiveIndex: () => state.activeOptionIndex,
resolveId: (item) => item.id,
resolveDisabled: (item) => item.dataRef.current.disabled,
@@ -142,6 +147,7 @@ let reducers: {
if (state.activeOptionIndex === activeOptionIndex) return state
return {
...state,
options, // Sorted options
activeOptionIndex,
activationTrigger: action.trigger ?? ActivationTrigger.Other,
}
@@ -150,17 +156,7 @@ let reducers: {
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]
)
let options = [...state.options, { id: action.id, dataRef: action.dataRef }]
let nextState = {
...state,
options,
@@ -859,8 +855,9 @@ let Option = forwardRefWithAs(function Option<
let active =
state.activeOptionIndex !== null ? state.options[state.activeOptionIndex].id === id : false
let selected = state.comboboxPropsRef.current.value === value
let bag = useRef<ComboboxOptionDataRef['current']>({ disabled, value })
let optionRef = useSyncRefs(ref)
let internalOptionRef = useRef<HTMLLIElement | null>(null)
let bag = useRef<ComboboxOptionDataRef['current']>({ disabled, value, domRef: internalOptionRef })
let optionRef = useSyncRefs(ref, internalOptionRef)
useIsoMorphicEffect(() => {
bag.current.disabled = disabled
@@ -29,7 +29,7 @@ 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 { isFocusableElement, FocusableMode, sortByDomNode } 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'
@@ -48,6 +48,7 @@ type ListboxOptionDataRef = MutableRefObject<{
textValue?: string
disabled: boolean
value: unknown
domRef: MutableRefObject<HTMLElement | null>
}>
interface StateDefinition {
@@ -126,8 +127,10 @@ let reducers: {
if (state.disabled) return state
if (state.listboxState === ListboxStates.Closed) return state
let options = sortByDomNode(state.options, (option) => option.dataRef.current.domRef.current)
let activeOptionIndex = calculateActiveIndex(action, {
resolveItems: () => state.options,
resolveItems: () => options,
resolveActiveIndex: () => state.activeOptionIndex,
resolveId: (item) => item.id,
resolveDisabled: (item) => item.dataRef.current.disabled,
@@ -136,6 +139,7 @@ let reducers: {
if (state.searchQuery === '' && state.activeOptionIndex === activeOptionIndex) return state
return {
...state,
options, // Sorted options
searchQuery: '',
activeOptionIndex,
activationTrigger: action.trigger ?? ActivationTrigger.Other,
@@ -180,17 +184,7 @@ let reducers: {
return { ...state, searchQuery: '' }
},
[ActionTypes.RegisterOption]: (state, action) => {
let orderMap = Array.from(
state.optionsRef.current?.querySelectorAll('[id^="headlessui-listbox-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]
)
let options = [...state.options, { id: action.id, dataRef: action.dataRef }]
return { ...state, options }
},
[ActionTypes.UnregisterOption]: (state, action) => {
@@ -665,9 +659,10 @@ let Option = forwardRefWithAs(function Option<
let active =
state.activeOptionIndex !== null ? state.options[state.activeOptionIndex].id === id : false
let selected = state.propsRef.current.value === value
let optionRef = useSyncRefs(ref)
let internalOptionRef = useRef<HTMLElement | null>(null)
let optionRef = useSyncRefs(ref, internalOptionRef)
let bag = useRef<ListboxOptionDataRef['current']>({ disabled, value })
let bag = useRef<ListboxOptionDataRef['current']>({ disabled, value, domRef: internalOptionRef })
useIsoMorphicEffect(() => {
bag.current.disabled = disabled
@@ -30,7 +30,7 @@ import { useId } from '../../hooks/use-id'
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 { isFocusableElement, FocusableMode, sortByDomNode } from '../../utils/focus-management'
import { useWindowEvent } from '../../hooks/use-window-event'
import { useTreeWalker } from '../../hooks/use-tree-walker'
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
@@ -46,7 +46,11 @@ enum ActivationTrigger {
Other,
}
type MenuItemDataRef = MutableRefObject<{ textValue?: string; disabled: boolean }>
type MenuItemDataRef = MutableRefObject<{
textValue?: string
disabled: boolean
domRef: MutableRefObject<HTMLElement | null>
}>
interface StateDefinition {
menuState: MenuStates
@@ -98,8 +102,10 @@ let reducers: {
return { ...state, menuState: MenuStates.Open }
},
[ActionTypes.GoToItem]: (state, action) => {
let items = sortByDomNode(state.items, (item) => item.dataRef.current.domRef.current)
let activeItemIndex = calculateActiveIndex(action, {
resolveItems: () => state.items,
resolveItems: () => items,
resolveActiveIndex: () => state.activeItemIndex,
resolveId: (item) => item.id,
resolveDisabled: (item) => item.dataRef.current.disabled,
@@ -108,6 +114,7 @@ let reducers: {
if (state.searchQuery === '' && state.activeItemIndex === activeItemIndex) return state
return {
...state,
items, // Sorted items
searchQuery: '',
activeItemIndex,
activationTrigger: action.trigger ?? ActivationTrigger.Other,
@@ -144,17 +151,7 @@ let reducers: {
return { ...state, searchQuery: '', searchActiveItemIndex: null }
},
[ActionTypes.RegisterItem]: (state, action) => {
let orderMap = Array.from(
state.itemsRef.current?.querySelectorAll('[id^="headlessui-menu-item-"]')!
).reduce(
(lookup, element, index) => Object.assign(lookup, { [element.id]: index }),
{}
) as Record<string, number>
let items = [...state.items, { id: action.id, dataRef: action.dataRef }].sort(
(a, z) => orderMap[a.id] - orderMap[z.id]
)
let items = [...state.items, { id: action.id, dataRef: action.dataRef }]
return { ...state, items }
},
[ActionTypes.UnregisterItem]: (state, action) => {
@@ -560,7 +557,8 @@ let Item = forwardRefWithAs(function Item<TTag extends ElementType = typeof DEFA
let [state, dispatch] = useMenuContext('Menu.Item')
let id = `headlessui-menu-item-${useId()}`
let active = state.activeItemIndex !== null ? state.items[state.activeItemIndex].id === id : false
let itemRef = useSyncRefs(ref)
let internalItemRef = useRef<HTMLElement | null>(null)
let itemRef = useSyncRefs(ref, internalItemRef)
useIsoMorphicEffect(() => {
if (state.menuState !== MenuStates.Open) return
@@ -573,7 +571,7 @@ let Item = forwardRefWithAs(function Item<TTag extends ElementType = typeof DEFA
return d.dispose
}, [id, active, state.menuState, state.activationTrigger, /* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ state.activeItemIndex])
let bag = useRef<MenuItemDataRef['current']>({ disabled })
let bag = useRef<MenuItemDataRef['current']>({ disabled, domRef: internalItemRef })
useIsoMorphicEffect(() => {
bag.current.disabled = disabled
@@ -20,7 +20,7 @@ import { useId } from '../../hooks/use-id'
import { match } from '../../utils/match'
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
import { Keys } from '../../components/keyboard'
import { focusIn, Focus, FocusResult } from '../../utils/focus-management'
import { focusIn, Focus, FocusResult, sortByDomNode } from '../../utils/focus-management'
import { useFlags } from '../../hooks/use-flags'
import { Label, useLabels } from '../../components/label/label'
import { Description, useDescriptions } from '../../components/description/description'
@@ -53,12 +53,14 @@ let reducers: {
) => StateDefinition
} = {
[ActionTypes.RegisterOption](state, action) {
let nextOptions = [
...state.options,
{ id: action.id, element: action.element, propsRef: action.propsRef },
]
return {
...state,
options: [
...state.options,
{ id: action.id, element: action.element, propsRef: action.propsRef },
],
options: sortByDomNode(nextOptions, (option) => option.element.current),
}
},
[ActionTypes.UnregisterOption](state, action) {
@@ -102,15 +102,27 @@ export function focusElement(element: HTMLElement | null) {
element?.focus({ preventScroll: true })
}
export function sortByDomNode<T>(
nodes: T[],
resolveKey: (item: T) => HTMLElement | null = (i) => i as unknown as HTMLElement | null
): T[] {
return nodes.slice().sort((aItem, zItem) => {
let a = resolveKey(aItem)
let z = resolveKey(zItem)
if (a === null || z === null) return 0
let position = a.compareDocumentPosition(z)
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1
if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
return 0
})
}
export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus) {
let elements = Array.isArray(container)
? container.slice().sort((a, z) => {
let position = a.compareDocumentPosition(z)
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1
if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
return 0
})
? sortByDomNode(container)
: getFocusableElements(container)
let active = document.activeElement as HTMLElement
@@ -26,6 +26,7 @@ import { useOpenClosed, State, useOpenClosedProvider } from '../../internal/open
import { match } from '../../utils/match'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useTreeWalker } from '../../hooks/use-tree-walker'
import { sortByDomNode } from '../../utils/focus-management'
enum ComboboxStates {
Open,
@@ -37,7 +38,11 @@ enum ActivationTrigger {
Other,
}
type ComboboxOptionDataRef = Ref<{ disabled: boolean; value: unknown }>
type ComboboxOptionDataRef = Ref<{
disabled: boolean
value: unknown
domRef: Ref<HTMLElement | null>
}>
type StateDefinition = {
// State
comboboxState: Ref<ComboboxStates>
@@ -140,24 +145,27 @@ export let Combobox = defineComponent({
optionsRef.value &&
!optionsPropsRef.value.static &&
comboboxState.value === ComboboxStates.Closed
)
) {
return
}
let orderedOptions = sortByDomNode(options.value, (option) => option.dataRef.domRef.value)
let nextActiveOptionIndex = calculateActiveIndex(
focus === Focus.Specific
? { focus: Focus.Specific, id: id! }
: { focus: focus as Exclude<Focus, Focus.Specific> },
{
resolveItems: () => options.value,
resolveItems: () => orderedOptions,
resolveActiveIndex: () => activeOptionIndex.value,
resolveId: (option) => option.id,
resolveDisabled: (option) => option.dataRef.disabled,
}
)
if (activeOptionIndex.value === nextActiveOptionIndex) return
activeOptionIndex.value = nextActiveOptionIndex
activationTrigger.value = trigger ?? ActivationTrigger.Other
options.value = orderedOptions
},
syncInputValue() {
let value = api.value.value
@@ -189,17 +197,9 @@ export let Combobox = defineComponent({
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]
)
options.value = [...options.value, { id, dataRef }]
// 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
@@ -630,6 +630,7 @@ export let ComboboxOption = defineComponent({
setup(props, { slots, attrs }) {
let api = useComboboxContext('ComboboxOption')
let id = `headlessui-combobox-option-${useId()}`
let internalOptionRef = ref<HTMLElement | null>(null)
let active = computed(() => {
return api.activeOptionIndex.value !== null
@@ -642,6 +643,7 @@ export let ComboboxOption = defineComponent({
let dataRef = computed<ComboboxOptionDataRef['value']>(() => ({
disabled: props.disabled,
value: props.value,
domRef: internalOptionRef,
}))
onMounted(() => api.registerOption(id, dataRef))
@@ -696,6 +698,7 @@ export let ComboboxOption = defineComponent({
let slot = { active: active.value, selected: selected.value, disabled }
let propsWeControl = {
id,
ref: internalOptionRef,
role: 'option',
tabIndex: disabled === true ? undefined : -1,
'aria-disabled': disabled === true ? true : undefined,
@@ -24,6 +24,7 @@ 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'
import { sortByDomNode } from '../../utils/focus-management'
enum ListboxStates {
Open,
@@ -39,7 +40,12 @@ function nextFrame(cb: () => void) {
requestAnimationFrame(() => requestAnimationFrame(cb))
}
type ListboxOptionDataRef = Ref<{ textValue: string; disabled: boolean; value: unknown }>
type ListboxOptionDataRef = Ref<{
textValue: string
disabled: boolean
value: unknown
domRef: Ref<HTMLElement | null>
}>
type StateDefinition = {
// State
listboxState: Ref<ListboxStates>
@@ -133,22 +139,24 @@ export let Listbox = defineComponent({
if (props.disabled) return
if (listboxState.value === ListboxStates.Closed) return
let orderedOptions = sortByDomNode(options.value, (option) => option.dataRef.domRef.value)
let nextActiveOptionIndex = calculateActiveIndex(
focus === Focus.Specific
? { focus: Focus.Specific, id: id! }
: { focus: focus as Exclude<Focus, Focus.Specific> },
{
resolveItems: () => options.value,
resolveItems: () => orderedOptions,
resolveActiveIndex: () => activeOptionIndex.value,
resolveId: (option) => option.id,
resolveDisabled: (option) => option.dataRef.disabled,
}
)
if (searchQuery.value === '' && activeOptionIndex.value === nextActiveOptionIndex) return
searchQuery.value = ''
activeOptionIndex.value = nextActiveOptionIndex
activationTrigger.value = trigger ?? ActivationTrigger.Other
options.value = orderedOptions
},
search(value: string) {
if (props.disabled) return
@@ -185,17 +193,8 @@ export let Listbox = defineComponent({
searchQuery.value = ''
},
registerOption(id: string, dataRef: ListboxOptionDataRef) {
let orderMap = Array.from(
optionsRef.value?.querySelectorAll('[id^="headlessui-listbox-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]
)
options.value = [...options.value, { id, dataRef }]
},
unregisterOption(id: string) {
let nextOptions = options.value.slice()
@@ -518,6 +517,7 @@ export let ListboxOption = defineComponent({
setup(props, { slots, attrs }) {
let api = useListboxContext('ListboxOption')
let id = `headlessui-listbox-option-${useId()}`
let internalOptionRef = ref<HTMLElement | null>(null)
let active = computed(() => {
return api.activeOptionIndex.value !== null
@@ -527,11 +527,12 @@ export let ListboxOption = defineComponent({
let selected = computed(() => toRaw(api.value.value) === toRaw(props.value))
let dataRef = ref<ListboxOptionDataRef['value']>({
let dataRef = computed<ListboxOptionDataRef['value']>(() => ({
disabled: props.disabled,
value: props.value,
textValue: '',
})
domRef: internalOptionRef,
}))
onMounted(() => {
let textValue = document.getElementById(id)?.textContent?.toLowerCase().trim()
if (textValue !== undefined) dataRef.value.textValue = textValue
@@ -589,6 +590,7 @@ export let ListboxOption = defineComponent({
let slot = { active: active.value, selected: selected.value, disabled }
let propsWeControl = {
id,
ref: internalOptionRef,
role: 'option',
tabIndex: disabled === true ? undefined : -1,
'aria-disabled': disabled === true ? true : undefined,
@@ -704,6 +704,7 @@ describe('Rendering', () => {
'However we need to passthrough the following props:',
' - disabled',
' - id',
' - ref',
' - role',
' - tabIndex',
' - aria-disabled',
@@ -21,6 +21,7 @@ import { useTreeWalker } from '../../hooks/use-tree-walker'
import { useOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import { match } from '../../utils/match'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { sortByDomNode } from '../../utils/focus-management'
enum MenuStates {
Open,
@@ -36,7 +37,11 @@ function nextFrame(cb: () => void) {
requestAnimationFrame(() => requestAnimationFrame(cb))
}
type MenuItemDataRef = Ref<{ textValue: string; disabled: boolean }>
type MenuItemDataRef = Ref<{
textValue: string
disabled: boolean
domRef: Ref<HTMLElement | null>
}>
type StateDefinition = {
// State
menuState: Ref<MenuStates>
@@ -99,22 +104,23 @@ export let Menu = defineComponent({
},
openMenu: () => (menuState.value = MenuStates.Open),
goToItem(focus: Focus, id?: string, trigger?: ActivationTrigger) {
let orderedItems = sortByDomNode(items.value, (item) => item.dataRef.domRef.value)
let nextActiveItemIndex = calculateActiveIndex(
focus === Focus.Specific
? { focus: Focus.Specific, id: id! }
: { focus: focus as Exclude<Focus, Focus.Specific> },
{
resolveItems: () => items.value,
resolveItems: () => orderedItems,
resolveActiveIndex: () => activeItemIndex.value,
resolveId: (item) => item.id,
resolveDisabled: (item) => item.dataRef.disabled,
}
)
if (searchQuery.value === '' && activeItemIndex.value === nextActiveItemIndex) return
searchQuery.value = ''
activeItemIndex.value = nextActiveItemIndex
activationTrigger.value = trigger ?? ActivationTrigger.Other
items.value = orderedItems
},
search(value: string) {
let wasAlreadySearching = searchQuery.value !== ''
@@ -142,17 +148,8 @@ export let Menu = defineComponent({
searchQuery.value = ''
},
registerItem(id: string, dataRef: MenuItemDataRef) {
let orderMap = Array.from(
itemsRef.value?.querySelectorAll('[id^="headlessui-menu-item-"]') ?? []
).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; }; }'
items.value = [...items.value, { id, dataRef }].sort(
(a, z) => orderMap[a.id] - orderMap[z.id]
)
items.value = [...items.value, { id, dataRef }]
},
unregisterItem(id: string) {
let nextItems = items.value.slice()
@@ -449,6 +446,7 @@ export let MenuItem = defineComponent({
setup(props, { slots, attrs }) {
let api = useMenuContext('MenuItem')
let id = `headlessui-menu-item-${useId()}`
let internalItemRef = ref<HTMLElement | null>(null)
let active = computed(() => {
return api.activeItemIndex.value !== null
@@ -456,7 +454,11 @@ export let MenuItem = defineComponent({
: false
})
let dataRef = ref<MenuItemDataRef['value']>({ disabled: props.disabled, textValue: '' })
let dataRef = computed<MenuItemDataRef['value']>(() => ({
disabled: props.disabled,
textValue: '',
domRef: internalItemRef,
}))
onMounted(() => {
let textValue = document.getElementById(id)?.textContent?.toLowerCase().trim()
if (textValue !== undefined) dataRef.value.textValue = textValue
@@ -500,6 +502,7 @@ export let MenuItem = defineComponent({
let slot = { active: active.value, disabled }
let propsWeControl = {
id,
ref: internalItemRef,
role: 'menuitem',
tabIndex: disabled === true ? undefined : -1,
'aria-disabled': disabled === true ? true : undefined,
@@ -15,7 +15,7 @@ import {
} from 'vue'
import { dom } from '../../utils/dom'
import { Keys } from '../../keyboard'
import { focusIn, Focus, FocusResult } from '../../utils/focus-management'
import { focusIn, Focus, FocusResult, sortByDomNode } from '../../utils/focus-management'
import { useId } from '../../hooks/use-id'
import { omit, render } from '../../utils/render'
import { Label, useLabels } from '../label/label'
@@ -98,15 +98,8 @@ export let RadioGroup = defineComponent({
return true
},
registerOption(action: UnwrapRef<Option>) {
let orderMap = Array.from(
radioGroupRef.value?.querySelectorAll('[id^="headlessui-radiogroup-option-"]')!
).reduce(
(lookup, element, index) => Object.assign(lookup, { [element.id]: index }),
{}
) as Record<string, number>
options.value.push(action)
options.value.sort((a, z) => orderMap[a.id] - orderMap[z.id])
options.value = sortByDomNode(options.value, (option) => option.element)
},
unregisterOption(id: Option['id']) {
let idx = options.value.findIndex((radio) => radio.id === id)
@@ -95,15 +95,27 @@ export function focusElement(element: HTMLElement | null) {
element?.focus({ preventScroll: true })
}
export function sortByDomNode<T>(
nodes: T[],
resolveKey: (item: T) => HTMLElement | null = (i) => i as unknown as HTMLElement | null
): T[] {
return nodes.slice().sort((aItem, zItem) => {
let a = resolveKey(aItem)
let z = resolveKey(zItem)
if (a === null || z === null) return 0
let position = a.compareDocumentPosition(z)
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1
if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
return 0
})
}
export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus) {
let elements = Array.isArray(container)
? container.slice().sort((a, z) => {
let position = a.compareDocumentPosition(z)
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1
if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
return 0
})
? sortByDomNode(container)
: getFocusableElements(container)
let active = document.activeElement as HTMLElement