Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve control over Menu and Listbox options while searching #2471

Merged
merged 9 commits into from
May 4, 2023
6 changes: 5 additions & 1 deletion jest/create-jest-config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ module.exports = function createJestConfig(root, options) {
return Object.assign(
{
rootDir: root,
setupFilesAfterEnv: ['<rootDir>../../jest/custom-matchers.ts', ...setupFilesAfterEnv],
setupFilesAfterEnv: [
'<rootDir>../../jest/custom-matchers.ts',
'<rootDir>../../jest/polyfills.ts',
...setupFilesAfterEnv,
],
transform: {
'^.+\\.(t|j)sx?$': '@swc/jest',
...transform,
Expand Down
15 changes: 15 additions & 0 deletions jest/polyfills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// JSDOM Doesn't implement innerText yet: https://github.com/jsdom/jsdom/issues/1245
// So this is a hacky way of implementing it using `textContent`.
// Real implementation doesn't use textContent because:
// > textContent gets the content of all elements, including <script> and <style> elements. In
// > contrast, innerText only shows "human-readable" elements.
// >
// > β€” https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent#differences_from_innertext
Object.defineProperty(HTMLElement.prototype, 'innerText', {
get() {
return this.textContent
},
set(value) {
this.textContent = value
},
})
69 changes: 69 additions & 0 deletions packages/@headlessui-react/src/utils/get-text-value.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { getTextValue } from './get-text-value'

let html = String.raw

it('should be possible to get the text value from an element', () => {
let element = document.createElement('div')
element.innerText = 'Hello World'
expect(getTextValue(element)).toEqual('Hello World')
})

it('should strip out emojis when receiving the text from the element', () => {
let element = document.createElement('div')
element.innerText = 'πŸ‡¨πŸ‡¦ Canada'
expect(getTextValue(element)).toEqual('Canada')
})

it('should strip out hidden elements', () => {
let element = document.createElement('div')
element.innerHTML = html`<div><span hidden>Hello</span> world</div>`
expect(getTextValue(element)).toEqual('world')
})

it('should strip out aria-hidden elements', () => {
let element = document.createElement('div')
element.innerHTML = html`<div><span aria-hidden>Hello</span> world</div>`
expect(getTextValue(element)).toEqual('world')
})

it('should strip out role="img" elements', () => {
let element = document.createElement('div')
element.innerHTML = html`<div><span role="img">Β°</span> world</div>`
expect(getTextValue(element)).toEqual('world')
})

it('should be possible to get the text value from the aria-label', () => {
let element = document.createElement('div')
element.setAttribute('aria-label', 'Hello World')
expect(getTextValue(element)).toEqual('Hello World')
})

it('should be possible to get the text value from the aria-label (even if there is content)', () => {
let element = document.createElement('div')
element.setAttribute('aria-label', 'Hello World')
element.innerHTML = 'Hello Universe'
element.innerText = 'Hello Universe'
expect(getTextValue(element)).toEqual('Hello World')
})

it('should be possible to get the text value from the element referenced by aria-labelledby (using `aria-label`)', () => {
document.body.innerHTML = html`
<div>
<div id="foo" aria-labelledby="bar">Contents of foo</div>
<div id="bar" aria-label="Actual value of bar">Contents of bar</div>
</div>
`

expect(getTextValue(document.querySelector('#foo')!)).toEqual('Actual value of bar')
})

it('should be possible to get the text value from the element referenced by aria-labelledby (using its contents)', () => {
document.body.innerHTML = html`
<div>
<div id="foo" aria-labelledby="bar">Contents of foo</div>
<div id="bar">Contents of bar</div>
</div>
`

expect(getTextValue(document.querySelector('#foo')!)).toEqual('Contents of bar')
})
68 changes: 68 additions & 0 deletions packages/@headlessui-react/src/utils/get-text-value.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
function getTextContents(element: HTMLElement): string {
// Using innerText instad of textContent because:
//
// > textContent gets the content of all elements, including <script> and <style> elements. In
// > contrast, innerText only shows "human-readable" elements.
// >
// > β€” https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent#differences_from_innertext
let value = element.innerText ?? ''

// Check if it contains some emojis or not, if so, we need to remove them
// because ideally we work with simple text values.

// Ideally we can use the much simpler RegEx: /\p{Extended_Pictographic}/u
// but we can't rely on this yet, so we use the more complex one.
if (
/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/.test(
value
)
) {
value = value.replace(
/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g,
''
)
}
RobinMalfait marked this conversation as resolved.
Show resolved Hide resolved

// Remove all the elements that shouldn't be there.
//
// [hidden] β€” The user doesn't see it
// [aria-hidden] β€” The screen reader doesn't see it
// [role="img"] β€” Even if it is text, it is used as an image
//
// This is probably the slowest part, but if you want complete control over the text value,
// you better set an `aria-label` instead.
let children = element.querySelectorAll('[hidden],[aria-hidden],[role="img"]')
if (children.length > 0) {
for (let el of children) {
if (el instanceof HTMLElement) {
value = value.replace(el.innerText ?? '', '')
}
}
}
RobinMalfait marked this conversation as resolved.
Show resolved Hide resolved

return value
}

export function getTextValue(element: HTMLElement): string {
// Try to use the `aria-label` first
let label = element.getAttribute('aria-label')
if (typeof label === 'string') return label.trim()

// Try to use the `aria-labelledby` second
let labelledby = element.getAttribute('aria-labelledby')
if (labelledby) {
let labelEl = document.getElementById(labelledby)
if (labelEl) {
let label = labelEl.getAttribute('aria-label')
// Try to use the `aria-label` first (of the referenced element)
if (typeof label === 'string') return label.trim()

// This time, the `aria-labelledby` isn't used anymore (in Safari), so we just have to
// look at the contents itself.
return getTextContents(labelEl).trim()
}
}

// Try to use the text contents of the element itself
return getTextContents(element).trim()
}
69 changes: 69 additions & 0 deletions packages/@headlessui-vue/src/utils/get-text-value.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { getTextValue } from './get-text-value'

let html = String.raw

it('should be possible to get the text value from an element', () => {
let element = document.createElement('div')
element.innerText = 'Hello World'
expect(getTextValue(element)).toEqual('Hello World')
})

it('should strip out emojis when receiving the text from the element', () => {
let element = document.createElement('div')
element.innerText = 'πŸ‡¨πŸ‡¦ Canada'
expect(getTextValue(element)).toEqual('Canada')
})

it('should strip out hidden elements', () => {
let element = document.createElement('div')
element.innerHTML = html`<div><span hidden>Hello</span> world</div>`
expect(getTextValue(element)).toEqual('world')
})

it('should strip out aria-hidden elements', () => {
let element = document.createElement('div')
element.innerHTML = html`<div><span aria-hidden>Hello</span> world</div>`
expect(getTextValue(element)).toEqual('world')
})

it('should strip out role="img" elements', () => {
let element = document.createElement('div')
element.innerHTML = html`<div><span role="img">Β°</span> world</div>`
expect(getTextValue(element)).toEqual('world')
})

it('should be possible to get the text value from the aria-label', () => {
let element = document.createElement('div')
element.setAttribute('aria-label', 'Hello World')
expect(getTextValue(element)).toEqual('Hello World')
})

it('should be possible to get the text value from the aria-label (even if there is content)', () => {
let element = document.createElement('div')
element.setAttribute('aria-label', 'Hello World')
element.innerHTML = 'Hello Universe'
element.innerText = 'Hello Universe'
expect(getTextValue(element)).toEqual('Hello World')
})

it('should be possible to get the text value from the element referenced by aria-labelledby (using `aria-label`)', () => {
document.body.innerHTML = html`
<div>
<div id="foo" aria-labelledby="bar">Contents of foo</div>
<div id="bar" aria-label="Actual value of bar">Contents of bar</div>
</div>
`

expect(getTextValue(document.querySelector('#foo')!)).toEqual('Actual value of bar')
})

it('should be possible to get the text value from the element referenced by aria-labelledby (using its contents)', () => {
document.body.innerHTML = html`
<div>
<div id="foo" aria-labelledby="bar">Contents of foo</div>
<div id="bar">Contents of bar</div>
</div>
`

expect(getTextValue(document.querySelector('#foo')!)).toEqual('Contents of bar')
})
68 changes: 68 additions & 0 deletions packages/@headlessui-vue/src/utils/get-text-value.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
function getTextContents(element: HTMLElement): string {
// Using innerText instad of textContent because:
//
// > textContent gets the content of all elements, including <script> and <style> elements. In
// > contrast, innerText only shows "human-readable" elements.
// >
// > β€” https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent#differences_from_innertext
let value = element.innerText ?? ''

// Check if it contains some emojis or not, if so, we need to remove them
// because ideally we work with simple text values.

// Ideally we can use the much simpler RegEx: /\p{Extended_Pictographic}/u
// but we can't rely on this yet, so we use the more complex one.
if (
/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/.test(
value
)
) {
value = value.replace(
/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g,
''
)
}

// Remove all the elements that shouldn't be there.
//
// [hidden] β€” The user doesn't see it
// [aria-hidden] β€” The screen reader doesn't see it
// [role="img"] β€” Even if it is text, it is used as an image
//
// This is probably the slowest part, but if you want complete control over the text value,
// you better set an `aria-label` instead.
let children = element.querySelectorAll('[hidden],[aria-hidden],[role="img"]')
if (children.length > 0) {
for (let el of children) {
if (el instanceof HTMLElement) {
value = value.replace(el.innerText ?? '', '')
}
}
}

return value
}

export function getTextValue(element: HTMLElement): string {
// Try to use the `aria-label` first
let label = element.getAttribute('aria-label')
if (typeof label === 'string') return label.trim()

// Try to use the `aria-labelledby` second
let labelledby = element.getAttribute('aria-labelledby')
if (labelledby) {
let labelEl = document.getElementById(labelledby)
if (labelEl) {
let label = labelEl.getAttribute('aria-label')
// Try to use the `aria-label` first (of the referenced element)
if (typeof label === 'string') return label.trim()

// This time, the `aria-labelledby` isn't used anymore (in Safari), so we just have to
// look at the contents itself.
return getTextContents(labelEl).trim()
}
}

// Try to use the text contents of the element itself
return getTextContents(element).trim()
}