From fb15a9b65252ac5fa32304096fbdb49ecdd6009b Mon Sep 17 00:00:00 2001 From: HiDeoo Date: Tue, 11 Jul 2023 12:26:27 +0200 Subject: Improve `` component keyboard interactions (#297) Co-authored-by: Chris Swithinbank --- .changeset/curvy-points-juggle.md | 5 +++ packages/starlight/package.json | 1 + packages/starlight/user-components/Tabs.astro | 19 ++++----- packages/starlight/user-components/rehype-tabs.ts | 30 ++++++++++++++- pnpm-lock.yaml | 47 +++++++++++++++++++++-- 5 files changed, 87 insertions(+), 15 deletions(-) create mode 100644 .changeset/curvy-points-juggle.md diff --git a/.changeset/curvy-points-juggle.md b/.changeset/curvy-points-juggle.md new file mode 100644 index 00000000..9829d1ff --- /dev/null +++ b/.changeset/curvy-points-juggle.md @@ -0,0 +1,5 @@ +--- +"@astrojs/starlight": minor +--- + +Improve `` component keyboard interactions diff --git a/packages/starlight/package.json b/packages/starlight/package.json index 5027131d..e41d7d6c 100644 --- a/packages/starlight/package.json +++ b/packages/starlight/package.json @@ -41,6 +41,7 @@ "@types/mdast": "^3.0.11", "bcp-47": "^2.1.0", "execa": "^7.1.1", + "hast-util-select": "^5.0.5", "hastscript": "^7.2.0", "pagefind": "^1.0.0-alpha.5", "rehype": "^12.0.1", diff --git a/packages/starlight/user-components/Tabs.astro b/packages/starlight/user-components/Tabs.astro index b0a2c5a5..8013a35a 100644 --- a/packages/starlight/user-components/Tabs.astro +++ b/packages/starlight/user-components/Tabs.astro @@ -98,23 +98,20 @@ const { html, panels } = processPanels(panelHtml); const index = this.tabs.indexOf(e.currentTarget as any); // Work out which key the user is pressing and // Calculate the new tab's index where appropriate - const dir = + const nextIndex = e.key === 'ArrowLeft' ? index - 1 : e.key === 'ArrowRight' ? index + 1 - : e.key === 'ArrowDown' - ? 'down' + : e.key === 'Home' + ? 0 + : e.key === 'End' + ? this.tabs.length - 1 : null; - if (dir === null) return; - // If the down key is pressed, move focus to the open panel, - // otherwise switch to the adjacent tab - if (dir === 'down') { + if (nextIndex === null) return; + if (this.tabs[nextIndex]) { e.preventDefault(); - this.panels[i]?.focus(); - } else if (this.tabs[dir]) { - e.preventDefault(); - this.switchTab(this.tabs[dir], dir); + this.switchTab(this.tabs[nextIndex], nextIndex); } }); }); diff --git a/packages/starlight/user-components/rehype-tabs.ts b/packages/starlight/user-components/rehype-tabs.ts index e3421856..dc69b9e4 100644 --- a/packages/starlight/user-components/rehype-tabs.ts +++ b/packages/starlight/user-components/rehype-tabs.ts @@ -1,3 +1,4 @@ +import { select } from 'hast-util-select'; import { rehype } from 'rehype'; import { CONTINUE, SKIP, visit } from 'unist-util-visit'; @@ -15,6 +16,26 @@ declare module 'vfile' { export const TabItemTagname = 'starlight-tab-item'; +// https://github.com/adobe/react-spectrum/blob/99ca82e87ba2d7fdd54f5b49326fd242320b4b51/packages/%40react-aria/focus/src/FocusScope.tsx#L256-L275 +const focusableElementSelectors = [ + 'input:not([disabled]):not([type=hidden])', + 'select:not([disabled])', + 'textarea:not([disabled])', + 'button:not([disabled])', + 'a[href]', + 'area[href]', + 'summary', + 'iframe', + 'object', + 'embed', + 'audio[controls]', + 'video[controls]', + '[contenteditable]', + '[tabindex]:not([disabled])', +] + .map((selector) => `${selector}:not([hidden]):not([tabindex="-1"])`) + .join(','); + let count = 0; const getIDs = () => { const id = count++; @@ -51,7 +72,14 @@ const tabsProcessor = rehype() node.properties.id = ids.panelId; node.properties['aria-labelledby'] = ids.tabId; node.properties.role = 'tabpanel'; - node.properties.tabindex = -1; + + const focusableChild = select(focusableElementSelectors, node); + // If the panel does not contain any focusable elements, include it in + // the tab sequence of the page. + if (!focusableChild) { + node.properties.tabindex = 0; + } + // Hide all panels except the first // TODO: make initially visible tab configurable if (isFirst) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5cd1e4a..e4cb870e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,9 @@ importers: execa: specifier: ^7.1.1 version: 7.1.1 + hast-util-select: + specifier: ^5.0.5 + version: 5.0.5 hastscript: specifier: ^7.2.0 version: 7.2.0 @@ -1578,6 +1581,10 @@ packages: /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + /bcp-47-match@2.0.3: + resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} + dev: false + /bcp-47@2.1.0: resolution: {integrity: sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==} dependencies: @@ -1627,7 +1634,6 @@ packages: /boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - dev: true /boxen@6.2.1: resolution: {integrity: sha512-H4PEsJXfFI/Pt8sjDWbHlQPx4zL/bvSQjcilJmaulGt5mLDorHOHpmdXAJcBcmru7PhYSp/cDMWRko4ZUMFkSw==} @@ -1944,6 +1950,10 @@ packages: nth-check: 2.1.1 dev: true + /css-selector-parser@1.4.1: + resolution: {integrity: sha512-HYPSb7y/Z7BNDCOrakL4raGO2zltZkbeXyAd6Tg9obzix6QhzxCotdBl6VT0Dv4vZfJGVz3WL/xaEI9Ly3ul0g==} + dev: false + /css-what@6.1.0: resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} engines: {node: '>= 6'} @@ -2080,6 +2090,11 @@ packages: path-type: 4.0.0 dev: true + /direction@2.0.1: + resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} + hasBin: true + dev: false + /dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -2993,6 +3008,10 @@ packages: vfile-location: 4.1.0 web-namespaces: 2.0.1 + /hast-util-has-property@2.0.1: + resolution: {integrity: sha512-X2+RwZIMTMKpXUzlotatPzWj8bspCymtXH3cfG3iQKV+wPF53Vgaqxi/eLqGck0wKq1kS9nvoB1wchbCPEL8sg==} + dev: false + /hast-util-parse-selector@3.1.1: resolution: {integrity: sha512-jdlwBjEexy1oGz0aJ2f4GKMaVKkA9jwjr4MjAAI22E5fM/TXVZHuS5OpONtdeIkRKqAaryQ2E9xNQxijoThSZA==} dependencies: @@ -3013,6 +3032,26 @@ packages: web-namespaces: 2.0.1 zwitch: 2.0.4 + /hast-util-select@5.0.5: + resolution: {integrity: sha512-QQhWMhgTFRhCaQdgTKzZ5g31GLQ9qRb1hZtDPMqQaOhpLBziWcshUS0uCR5IJ0U1jrK/mxg35fmcq+Dp/Cy2Aw==} + dependencies: + '@types/hast': 2.3.4 + '@types/unist': 2.0.6 + bcp-47-match: 2.0.3 + comma-separated-tokens: 2.0.3 + css-selector-parser: 1.4.1 + direction: 2.0.1 + hast-util-has-property: 2.0.1 + hast-util-to-string: 2.0.0 + hast-util-whitespace: 2.0.1 + not: 0.1.0 + nth-check: 2.1.1 + property-information: 6.2.0 + space-separated-tokens: 2.0.2 + unist-util-visit: 4.1.2 + zwitch: 2.0.4 + dev: false + /hast-util-to-estree@2.3.2: resolution: {integrity: sha512-YYDwATNdnvZi3Qi84iatPIl1lWpXba1MeNrNbDfJfVzEBZL8uUmtR7mt7bxKBC8kuAuvb0bkojXYZzsNHyHCLg==} dependencies: @@ -3064,7 +3103,6 @@ packages: resolution: {integrity: sha512-02AQ3vLhuH3FisaMM+i/9sm4OXGSq1UhOOCpTLLQtHdL3tZt7qil69r8M8iDkZYyC0HCFylcYoP+8IO7ddta1A==} dependencies: '@types/hast': 2.3.4 - dev: true /hast-util-whitespace@2.0.1: resolution: {integrity: sha512-nAxA0v8+vXSBDt3AnRUNjyRIQ0rD+ntpbAp4LnPkumc5M9yUbSMa4XDU9Q6etY4f1Wp4bNgvc1yjiZtsTTrSng==} @@ -4291,6 +4329,10 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + /not@0.1.0: + resolution: {integrity: sha512-5PDmaAsVfnWUgTUbJ3ERwn7u79Z0dYxN9ErxCpVJJqe2RK0PJ3z+iFUxuqjwtlDDegXvtWoxD/3Fzxox7tFGWA==} + dev: false + /npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -4308,7 +4350,6 @@ packages: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} dependencies: boolbase: 1.0.0 - dev: true /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} -- cgit