Skip to content

Commit 67f3c4d

Browse files
authored
Improve control over Menu and Listbox options while searching (#2471)
* add `get-text-value` helper * use `getTextValue` in `Listbox` component * use `getTextValue` in `Menu` component * update changelog * ensure we handle multiple values for `aria-labelledby` * hoist regex * drop child nodes instead of replacing its innerText This makes it a bit slower but also more correct. We can use a cache on another level to ensure that we are not creating useless work. * add `useTextValue` to improve performance of `getTextValue` This will add a cache and only if the `innerText` changes, only then will we calculate the new text value. * use better `useTextValue` hook
1 parent 0505e92 commit 67f3c4d

File tree

14 files changed

+447
-16
lines changed

14 files changed

+447
-16
lines changed

jest/create-jest-config.cjs

+5-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ module.exports = function createJestConfig(root, options) {
33
return Object.assign(
44
{
55
rootDir: root,
6-
setupFilesAfterEnv: ['<rootDir>../../jest/custom-matchers.ts', ...setupFilesAfterEnv],
6+
setupFilesAfterEnv: [
7+
'<rootDir>../../jest/custom-matchers.ts',
8+
'<rootDir>../../jest/polyfills.ts',
9+
...setupFilesAfterEnv,
10+
],
711
transform: {
812
'^.+\\.(t|j)sx?$': '@swc/jest',
913
...transform,

jest/polyfills.ts

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// JSDOM Doesn't implement innerText yet: https://github.com/jsdom/jsdom/issues/1245
2+
// So this is a hacky way of implementing it using `textContent`.
3+
// Real implementation doesn't use textContent because:
4+
// > textContent gets the content of all elements, including <script> and <style> elements. In
5+
// > contrast, innerText only shows "human-readable" elements.
6+
// >
7+
// > — https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent#differences_from_innertext
8+
Object.defineProperty(HTMLElement.prototype, 'innerText', {
9+
get() {
10+
return this.textContent
11+
},
12+
set(value) {
13+
this.textContent = value
14+
},
15+
})

packages/@headlessui-react/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515

1616
- Ensure `FocusTrap` is only active when the given `enabled` value is `true` ([#2456](https://github.com/tailwindlabs/headlessui/pull/2456))
1717
- Stop `<Transition appear>` from overwriting classes on re-render ([#2457](https://github.com/tailwindlabs/headlessui/pull/2457))
18+
- Improve control over `Menu` and `Listbox` options while searching ([#2471](https://github.com/tailwindlabs/headlessui/pull/2471))
1819

1920
### Changed
2021

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

+3-1
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { useEvent } from '../../hooks/use-event'
4848
import { useControllable } from '../../hooks/use-controllable'
4949
import { useLatestValue } from '../../hooks/use-latest-value'
5050
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
51+
import { useTextValue } from '../../hooks/use-text-value'
5152

5253
enum ListboxStates {
5354
Open,
@@ -934,12 +935,13 @@ function OptionFn<
934935

935936
let selected = data.isSelected(value)
936937
let internalOptionRef = useRef<HTMLLIElement | null>(null)
938+
let getTextValue = useTextValue(internalOptionRef)
937939
let bag = useLatestValue<ListboxOptionDataRef<TType>['current']>({
938940
disabled,
939941
value,
940942
domRef: internalOptionRef,
941943
get textValue() {
942-
return internalOptionRef.current?.textContent?.toLowerCase()
944+
return getTextValue()
943945
},
944946
})
945947
let optionRef = useSyncRefs(ref, internalOptionRef)

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

+10-4
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
5151
import { useOwnerDocument } from '../../hooks/use-owner'
5252
import { useEvent } from '../../hooks/use-event'
5353
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
54+
import { useTextValue } from '../../hooks/use-text-value'
5455

5556
enum MenuStates {
5657
Open,
@@ -636,14 +637,19 @@ function ItemFn<TTag extends ElementType = typeof DEFAULT_ITEM_TAG>(
636637
/* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ state.activeItemIndex,
637638
])
638639

639-
let bag = useRef<MenuItemDataRef['current']>({ disabled, domRef: internalItemRef })
640+
let getTextValue = useTextValue(internalItemRef)
641+
642+
let bag = useRef<MenuItemDataRef['current']>({
643+
disabled,
644+
domRef: internalItemRef,
645+
get textValue() {
646+
return getTextValue()
647+
},
648+
})
640649

641650
useIsoMorphicEffect(() => {
642651
bag.current.disabled = disabled
643652
}, [bag, disabled])
644-
useIsoMorphicEffect(() => {
645-
bag.current.textValue = internalItemRef.current?.textContent?.toLowerCase()
646-
}, [bag, internalItemRef])
647653

648654
useIsoMorphicEffect(() => {
649655
dispatch({ type: ActionTypes.RegisterItem, id, dataRef: bag })
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { useRef, MutableRefObject } from 'react'
2+
import { getTextValue } from '../utils/get-text-value'
3+
import { useEvent } from './use-event'
4+
5+
export function useTextValue(element: MutableRefObject<HTMLElement | null>) {
6+
let cacheKey = useRef<string>('')
7+
let cacheValue = useRef<string>('')
8+
9+
return useEvent(() => {
10+
let el = element.current
11+
if (!el) return ''
12+
13+
// Check for a cached version
14+
let currentKey = el.innerText
15+
if (cacheKey.current === currentKey) {
16+
return cacheValue.current
17+
}
18+
19+
// Calculate the value
20+
let value = getTextValue(el).trim().toLowerCase()
21+
cacheKey.current = currentKey
22+
cacheValue.current = value
23+
return value
24+
})
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { getTextValue } from './get-text-value'
2+
3+
let html = String.raw
4+
5+
it('should be possible to get the text value from an element', () => {
6+
let element = document.createElement('div')
7+
element.innerText = 'Hello World'
8+
expect(getTextValue(element)).toEqual('Hello World')
9+
})
10+
11+
it('should strip out emojis when receiving the text from the element', () => {
12+
let element = document.createElement('div')
13+
element.innerText = '🇨🇦 Canada'
14+
expect(getTextValue(element)).toEqual('Canada')
15+
})
16+
17+
it('should strip out hidden elements', () => {
18+
let element = document.createElement('div')
19+
element.innerHTML = html`<div><span hidden>Hello</span> world</div>`
20+
expect(getTextValue(element)).toEqual('world')
21+
})
22+
23+
it('should strip out aria-hidden elements', () => {
24+
let element = document.createElement('div')
25+
element.innerHTML = html`<div><span aria-hidden>Hello</span> world</div>`
26+
expect(getTextValue(element)).toEqual('world')
27+
})
28+
29+
it('should strip out role="img" elements', () => {
30+
let element = document.createElement('div')
31+
element.innerHTML = html`<div><span role="img">°</span> world</div>`
32+
expect(getTextValue(element)).toEqual('world')
33+
})
34+
35+
it('should be possible to get the text value from the aria-label', () => {
36+
let element = document.createElement('div')
37+
element.setAttribute('aria-label', 'Hello World')
38+
expect(getTextValue(element)).toEqual('Hello World')
39+
})
40+
41+
it('should be possible to get the text value from the aria-label (even if there is content)', () => {
42+
let element = document.createElement('div')
43+
element.setAttribute('aria-label', 'Hello World')
44+
element.innerHTML = 'Hello Universe'
45+
element.innerText = 'Hello Universe'
46+
expect(getTextValue(element)).toEqual('Hello World')
47+
})
48+
49+
it('should be possible to get the text value from the element referenced by aria-labelledby (using `aria-label`)', () => {
50+
document.body.innerHTML = html`
51+
<div>
52+
<div id="foo" aria-labelledby="bar">Contents of foo</div>
53+
<div id="bar" aria-label="Actual value of bar">Contents of bar</div>
54+
</div>
55+
`
56+
57+
expect(getTextValue(document.querySelector('#foo')!)).toEqual('Actual value of bar')
58+
})
59+
60+
it('should be possible to get the text value from the element referenced by aria-labelledby (using its contents)', () => {
61+
document.body.innerHTML = html`
62+
<div>
63+
<div id="foo" aria-labelledby="bar">Contents of foo</div>
64+
<div id="bar">Contents of bar</div>
65+
</div>
66+
`
67+
68+
expect(getTextValue(document.querySelector('#foo')!)).toEqual('Contents of bar')
69+
})
70+
71+
it('should be possible to get the text value from the element referenced by aria-labelledby (using `aria-label`, multiple)', () => {
72+
document.body.innerHTML = html`
73+
<div>
74+
<div id="foo" aria-labelledby="bar baz">Contents of foo</div>
75+
<div id="bar" aria-label="Actual value of bar">Contents of bar</div>
76+
<div id="baz" aria-label="Actual value of baz">Contents of baz</div>
77+
</div>
78+
`
79+
80+
expect(getTextValue(document.querySelector('#foo')!)).toEqual(
81+
'Actual value of bar, Actual value of baz'
82+
)
83+
})
84+
85+
it('should be possible to get the text value from the element referenced by aria-labelledby (using its contents, multiple)', () => {
86+
document.body.innerHTML = html`
87+
<div>
88+
<div id="foo" aria-labelledby="bar baz">Contents of foo</div>
89+
<div id="bar">Contents of bar</div>
90+
<div id="baz">Contents of baz</div>
91+
</div>
92+
`
93+
94+
expect(getTextValue(document.querySelector('#foo')!)).toEqual('Contents of bar, Contents of baz')
95+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
let emojiRegex =
2+
/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g
3+
4+
function getTextContents(element: HTMLElement): string {
5+
// Using innerText instead of textContent because:
6+
//
7+
// > textContent gets the content of all elements, including <script> and <style> elements. In
8+
// > contrast, innerText only shows "human-readable" elements.
9+
// >
10+
// > — https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent#differences_from_innertext
11+
let currentInnerText = element.innerText ?? ''
12+
13+
// Remove all the elements that shouldn't be there.
14+
//
15+
// [hidden] — The user doesn't see it
16+
// [aria-hidden] — The screen reader doesn't see it
17+
// [role="img"] — Even if it is text, it is used as an image
18+
//
19+
// This is probably the slowest part, but if you want complete control over the text value, then
20+
// it is better to set an `aria-label` instead.
21+
let copy = element.cloneNode(true)
22+
if (!(copy instanceof HTMLElement)) {
23+
return currentInnerText
24+
}
25+
26+
let dropped = false
27+
// Drop the elements that shouldn't be there.
28+
for (let child of copy.querySelectorAll('[hidden],[aria-hidden],[role="img"]')) {
29+
child.remove()
30+
dropped = true
31+
}
32+
33+
// Now that the elements are removed, we can get the innerText such that we can strip the emojis.
34+
let value = dropped ? copy.innerText ?? '' : currentInnerText
35+
36+
// Check if it contains some emojis or not, if so, we need to remove them
37+
// because ideally we work with simple text values.
38+
//
39+
// Ideally we can use the much simpler RegEx: /\p{Extended_Pictographic}/u
40+
// but we can't rely on this yet, so we use the more complex one.
41+
if (emojiRegex.test(value)) {
42+
value = value.replace(emojiRegex, '')
43+
}
44+
45+
return value
46+
}
47+
48+
export function getTextValue(element: HTMLElement): string {
49+
// Try to use the `aria-label` first
50+
let label = element.getAttribute('aria-label')
51+
if (typeof label === 'string') return label.trim()
52+
53+
// Try to use the `aria-labelledby` second
54+
let labelledby = element.getAttribute('aria-labelledby')
55+
if (labelledby) {
56+
// aria-labelledby can be a space-separated list of IDs, so we need to split them up and
57+
// combine them into a single string.
58+
let labels = labelledby
59+
.split(' ')
60+
.map((labelledby) => {
61+
let labelEl = document.getElementById(labelledby)
62+
if (labelEl) {
63+
let label = labelEl.getAttribute('aria-label')
64+
// Try to use the `aria-label` first (of the referenced element)
65+
if (typeof label === 'string') return label.trim()
66+
67+
// This time, the `aria-labelledby` isn't used anymore (in Safari), so we just have to
68+
// look at the contents itself.
69+
return getTextContents(labelEl).trim()
70+
}
71+
72+
return null
73+
})
74+
.filter(Boolean)
75+
76+
if (labels.length > 0) return labels.join(', ')
77+
}
78+
79+
// Try to use the text contents of the element itself
80+
return getTextContents(element).trim()
81+
}

packages/@headlessui-vue/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Fix memory leak in `Popover` component ([#2430](https://github.com/tailwindlabs/headlessui/pull/2430))
1313
- Ensure `FocusTrap` is only active when the given `enabled` value is `true` ([#2456](https://github.com/tailwindlabs/headlessui/pull/2456))
1414
- Ensure the exposed `activeIndex` is up to date for the `Combobox` component ([#2463](https://github.com/tailwindlabs/headlessui/pull/2463))
15+
- Improve control over `Menu` and `Listbox` options while searching ([#2471](https://github.com/tailwindlabs/headlessui/pull/2471))
1516

1617
### Changed
1718

packages/@headlessui-vue/src/components/listbox/listbox.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
3535
import { objectToFormEntries } from '../../utils/form'
3636
import { useControllable } from '../../hooks/use-controllable'
3737
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
38+
import { useTextValue } from '../../hooks/use-text-value'
3839

3940
function defaultComparator<T>(a: T, z: T): boolean {
4041
return a === z
@@ -731,16 +732,15 @@ export let ListboxOption = defineComponent({
731732
})
732733
})
733734

735+
let getTextValue = useTextValue(internalOptionRef)
734736
let dataRef = computed<ListboxOptionData>(() => ({
735737
disabled: props.disabled,
736738
value: props.value,
737-
textValue: '',
739+
get textValue() {
740+
return getTextValue()
741+
},
738742
domRef: internalOptionRef,
739743
}))
740-
onMounted(() => {
741-
let textValue = dom(internalOptionRef)?.textContent?.toLowerCase().trim()
742-
if (textValue !== undefined) dataRef.value.textValue = textValue
743-
})
744744

745745
onMounted(() => api.registerOption(props.id, dataRef))
746746
onUnmounted(() => api.unregisterOption(props.id))

packages/@headlessui-vue/src/components/menu/menu.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
} from '../../utils/focus-management'
3333
import { useOutsideClick } from '../../hooks/use-outside-click'
3434
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
35+
import { useTextValue } from '../../hooks/use-text-value'
3536

3637
enum MenuStates {
3738
Open,
@@ -511,15 +512,14 @@ export let MenuItem = defineComponent({
511512
: false
512513
})
513514

515+
let getTextValue = useTextValue(internalItemRef)
514516
let dataRef = computed<MenuItemData>(() => ({
515517
disabled: props.disabled,
516-
textValue: '',
518+
get textValue() {
519+
return getTextValue()
520+
},
517521
domRef: internalItemRef,
518522
}))
519-
onMounted(() => {
520-
let textValue = dom(internalItemRef)?.textContent?.toLowerCase().trim()
521-
if (textValue !== undefined) dataRef.value.textValue = textValue
522-
})
523523

524524
onMounted(() => api.registerItem(props.id, dataRef))
525525
onUnmounted(() => api.unregisterItem(props.id))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { ref, Ref } from 'vue'
2+
import { getTextValue } from '../utils/get-text-value'
3+
import { dom } from '../utils/dom'
4+
5+
export function useTextValue(element: Ref<HTMLElement | null>) {
6+
let cacheKey = ref<string>('')
7+
let cacheValue = ref<string>('')
8+
9+
return () => {
10+
let el = dom(element)
11+
if (!el) return ''
12+
13+
// Check for a cached version
14+
let currentKey = el.innerText
15+
if (cacheKey.value === currentKey) {
16+
return cacheValue.value
17+
}
18+
19+
// Calculate the value
20+
let value = getTextValue(el).trim().toLowerCase()
21+
cacheKey.value = currentKey
22+
cacheValue.value = value
23+
return value
24+
}
25+
}

0 commit comments

Comments
 (0)