From 56905fd5a9784eb7e1b128fd736714fca9cf4da2 Mon Sep 17 00:00:00 2001 From: Diogo Esteves Date: Fri, 16 Oct 2026 10:40:58 +0100 Subject: [PATCH 1/2] fix(react): dialog accessibility on phones, for screen readers and in forced colors - A Close button (labels.close) in the input row on touch screens, in the esc hint's place, 44 by 44 px. A phone has no Escape key, and with an answer showing the dialog left an 8 px strip of page to tap. - Focus moves on when the pressed control goes away: a thumb to "Add a comment", a sent comment to "New question", and Stop or Retry pressed from the keyboard to the input. It fell to the dialog's container. - Each source's name starts with its number and whether the answer cites it (labels.source, sourceCited, sourceNotCited); the URL is aria-hidden, and cited sources have their number filled. - An error is announced once, by its alert, not again by the status. - The combobox is expanded only while its list shows, and named once, by the dialog's title; the key glyphs are aria-hidden; thread questions and Sources are h3 headings. - Numbered steps split by code or a blank line keep their numbers. - Touch screens get 44 px buttons and a 26 px tap area on citations; forced colors outline the selected suggestion, fill the cited sources' numbers and border the navbar button. --- .changeset/dialog-accessibility.md | 5 + examples/docusaurus/docs/ask-dialog.md | 8 +- examples/nextjs/content/docs/ask-dialog.md | 8 +- .../src/content/docs/reference/ask-dialog.md | 8 +- examples/vitepress/docs/ask-dialog.md | 8 +- src/docusaurus/launcher.css | 4 + src/embed/button.css | 4 + src/react/answer.tsx | 17 +- src/react/dialog-panel.tsx | 154 +++++++++++++++--- src/react/labels.ts | 12 ++ src/react/styles.css | 82 ++++++++++ test/keyboard-dialog.test.tsx | 85 ++++++++++ test/react.test.tsx | 94 ++++++++++- test/touch-and-forced-colors.test.ts | 95 +++++++++++ 14 files changed, 553 insertions(+), 31 deletions(-) create mode 100644 .changeset/dialog-accessibility.md create mode 100644 test/touch-and-forced-colors.test.ts diff --git a/.changeset/dialog-accessibility.md b/.changeset/dialog-accessibility.md new file mode 100644 index 00000000..9ebe0f48 --- /dev/null +++ b/.changeset/dialog-accessibility.md @@ -0,0 +1,5 @@ +--- +'ondocs': patch +--- + +The dialog works better on a phone and with assistive technology. On a touch screen, which has no Escape key, a Close button takes the place of the esc hint, buttons are 44 px tall, and citations have a larger area to tap. When a thumb, a sent comment, Stop or Retry goes away as it is pressed, focus moves on to "Add a comment", "New question" or the input, rather than falling to the dialog. Each source's name now starts with its number and whether the answer cites it, and the cited sources have their number filled. An error is announced once, the input is named once and is expanded only while its list shows, and the questions in a thread are headings. Numbered steps with code or a blank line between them keep their numbers: 1, 2, 3 rather than 1, 1, 1. In forced colors (Windows High Contrast), the selected suggestion is outlined, the cited sources keep their filled number, and the navbar button keeps a border. New labels: `close`, `source`, `sourceCited` and `sourceNotCited`. diff --git a/examples/docusaurus/docs/ask-dialog.md b/examples/docusaurus/docs/ask-dialog.md index f3ea5ba5..bf523306 100644 --- a/examples/docusaurus/docs/ask-dialog.md +++ b/examples/docusaurus/docs/ask-dialog.md @@ -186,6 +186,7 @@ The endpoint's own words, its "I don't know" answer, its rate limit and budget m | `list` | Suggestions | | `stop` | Stop | | `escapeKey` | esc | +| `close` | Close | | `footer` | Answers come from this site and can be wrong. Check the sources. | | `footerNoSources` | Answers come from this site and can be wrong. | | `footerAsk` | ask | @@ -201,6 +202,9 @@ The endpoint's own words, its "I don't know" answer, its rate limit and budget m | `retry` | Retry | | `sources` | Sources | | `citation` | Source {n}: {title} | +| `source` | Source {n}: | +| `sourceCited` | Source {n}, cited: | +| `sourceNotCited` | Source {n}, not cited: | | `newQuestion` | New question | | `helpfulQuestion` | Was this helpful? | | `helpful` | Yes, it helped | @@ -233,11 +237,11 @@ Import `ondocs/react/styles.css` for the default theme. With `theme="auto"`, the ## Accessibility -Focus moves into the dialog when it opens and returns to where it was when it closes. The answer region is `aria-live="polite"` and `aria-busy` while text streams, and a separate status region announces when the answer is ready. All animation is turned off under `prefers-reduced-motion`. The test suite runs axe on the dialog before and after it answers, and checks that every text color in the light and dark themes keeps a contrast of at least 4.5:1. +Focus moves into the dialog when it opens and returns to where it was when it closes. A touch screen has no Escape key, so there a Close button takes the place of the esc hint in the input row. When a control goes away as it is pressed, focus moves on rather than falling to the dialog: from a thumb to "Add a comment", from a sent comment to "New question", and from Stop or Retry pressed with the keyboard to the input. The answer region is `aria-live="polite"` and `aria-busy` while text streams, and a separate status region announces when the answer is ready; an error is announced once, by its alert. Each source's name starts with its number, which its citations name, and whether the answer cites it (the `source`, `sourceCited` and `sourceNotCited` labels), and questions in a thread are headings. On a touch screen, buttons are 44 px tall and citations have a larger area to tap. In forced colors (Windows High Contrast), the selected suggestion is outlined and the cited sources keep their filled number. All animation is turned off under `prefers-reduced-motion`. The test suite runs axe on the dialog before and after it answers, and checks that every text color in the light and dark themes keeps a contrast of at least 4.5:1. ## While an answer streams -Sources arrive before the first word, and the answer renders as it streams, batched to one update per animation frame. Closing the dialog, by Escape, a click outside or its parent, stops the answer in flight, so the model is not left generating for nobody. When an answer stops at the model's output limit, the dialog says it may be incomplete. Once an answer is complete, the sources it does not cite are dimmed, and an answer that cites none shows no sources. +Sources arrive before the first word, and the answer renders as it streams, batched to one update per animation frame. Closing the dialog, by Escape, its Close button, a click outside or its parent, stops the answer in flight, so the model is not left generating for nobody. When an answer stops at the model's output limit, the dialog says it may be incomplete. Once an answer is complete, the sources it cites have their number filled and the others are dimmed, and an answer that cites none shows no sources. ## The useAsk hook diff --git a/examples/nextjs/content/docs/ask-dialog.md b/examples/nextjs/content/docs/ask-dialog.md index 106ebe9b..c2391e51 100644 --- a/examples/nextjs/content/docs/ask-dialog.md +++ b/examples/nextjs/content/docs/ask-dialog.md @@ -188,6 +188,7 @@ The endpoint's own words, its "I don't know" answer, its rate limit and budget m | `list` | Suggestions | | `stop` | Stop | | `escapeKey` | esc | +| `close` | Close | | `footer` | Answers come from this site and can be wrong. Check the sources. | | `footerNoSources` | Answers come from this site and can be wrong. | | `footerAsk` | ask | @@ -203,6 +204,9 @@ The endpoint's own words, its "I don't know" answer, its rate limit and budget m | `retry` | Retry | | `sources` | Sources | | `citation` | Source {n}: {title} | +| `source` | Source {n}: | +| `sourceCited` | Source {n}, cited: | +| `sourceNotCited` | Source {n}, not cited: | | `newQuestion` | New question | | `helpfulQuestion` | Was this helpful? | | `helpful` | Yes, it helped | @@ -235,11 +239,11 @@ Import `ondocs/react/styles.css` for the default theme. With `theme="auto"`, the ## Accessibility -Focus moves into the dialog when it opens and returns to where it was when it closes. The answer region is `aria-live="polite"` and `aria-busy` while text streams, and a separate status region announces when the answer is ready. All animation is turned off under `prefers-reduced-motion`. The test suite runs axe on the dialog before and after it answers, and checks that every text color in the light and dark themes keeps a contrast of at least 4.5:1. +Focus moves into the dialog when it opens and returns to where it was when it closes. A touch screen has no Escape key, so there a Close button takes the place of the esc hint in the input row. When a control goes away as it is pressed, focus moves on rather than falling to the dialog: from a thumb to "Add a comment", from a sent comment to "New question", and from Stop or Retry pressed with the keyboard to the input. The answer region is `aria-live="polite"` and `aria-busy` while text streams, and a separate status region announces when the answer is ready; an error is announced once, by its alert. Each source's name starts with its number, which its citations name, and whether the answer cites it (the `source`, `sourceCited` and `sourceNotCited` labels), and questions in a thread are headings. On a touch screen, buttons are 44 px tall and citations have a larger area to tap. In forced colors (Windows High Contrast), the selected suggestion is outlined and the cited sources keep their filled number. All animation is turned off under `prefers-reduced-motion`. The test suite runs axe on the dialog before and after it answers, and checks that every text color in the light and dark themes keeps a contrast of at least 4.5:1. ## While an answer streams -Sources arrive before the first word, and the answer renders as it streams, batched to one update per animation frame. Closing the dialog, by Escape, a click outside or its parent, stops the answer in flight, so the model is not left generating for nobody. When an answer stops at the model's output limit, the dialog says it may be incomplete. Once an answer is complete, the sources it does not cite are dimmed, and an answer that cites none shows no sources. +Sources arrive before the first word, and the answer renders as it streams, batched to one update per animation frame. Closing the dialog, by Escape, its Close button, a click outside or its parent, stops the answer in flight, so the model is not left generating for nobody. When an answer stops at the model's output limit, the dialog says it may be incomplete. Once an answer is complete, the sources it cites have their number filled and the others are dimmed, and an answer that cites none shows no sources. ## The useAsk hook diff --git a/examples/starlight/src/content/docs/reference/ask-dialog.md b/examples/starlight/src/content/docs/reference/ask-dialog.md index 13c0dbfa..e379305f 100644 --- a/examples/starlight/src/content/docs/reference/ask-dialog.md +++ b/examples/starlight/src/content/docs/reference/ask-dialog.md @@ -188,6 +188,7 @@ The endpoint's own words, its "I don't know" answer, its rate limit and budget m | `list` | Suggestions | | `stop` | Stop | | `escapeKey` | esc | +| `close` | Close | | `footer` | Answers come from this site and can be wrong. Check the sources. | | `footerNoSources` | Answers come from this site and can be wrong. | | `footerAsk` | ask | @@ -203,6 +204,9 @@ The endpoint's own words, its "I don't know" answer, its rate limit and budget m | `retry` | Retry | | `sources` | Sources | | `citation` | Source {n}: {title} | +| `source` | Source {n}: | +| `sourceCited` | Source {n}, cited: | +| `sourceNotCited` | Source {n}, not cited: | | `newQuestion` | New question | | `helpfulQuestion` | Was this helpful? | | `helpful` | Yes, it helped | @@ -235,11 +239,11 @@ Import `ondocs/react/styles.css` for the default theme. With `theme="auto"`, the ## Accessibility -Focus moves into the dialog when it opens and returns to where it was when it closes. The answer region is `aria-live="polite"` and `aria-busy` while text streams, and a separate status region announces when the answer is ready. All animation is turned off under `prefers-reduced-motion`. The test suite runs axe on the dialog before and after it answers, and checks that every text color in the light and dark themes keeps a contrast of at least 4.5:1. +Focus moves into the dialog when it opens and returns to where it was when it closes. A touch screen has no Escape key, so there a Close button takes the place of the esc hint in the input row. When a control goes away as it is pressed, focus moves on rather than falling to the dialog: from a thumb to "Add a comment", from a sent comment to "New question", and from Stop or Retry pressed with the keyboard to the input. The answer region is `aria-live="polite"` and `aria-busy` while text streams, and a separate status region announces when the answer is ready; an error is announced once, by its alert. Each source's name starts with its number, which its citations name, and whether the answer cites it (the `source`, `sourceCited` and `sourceNotCited` labels), and questions in a thread are headings. On a touch screen, buttons are 44 px tall and citations have a larger area to tap. In forced colors (Windows High Contrast), the selected suggestion is outlined and the cited sources keep their filled number. All animation is turned off under `prefers-reduced-motion`. The test suite runs axe on the dialog before and after it answers, and checks that every text color in the light and dark themes keeps a contrast of at least 4.5:1. ## While an answer streams -Sources arrive before the first word, and the answer renders as it streams, batched to one update per animation frame. Closing the dialog, by Escape, a click outside or its parent, stops the answer in flight, so the model is not left generating for nobody. When an answer stops at the model's output limit, the dialog says it may be incomplete. Once an answer is complete, the sources it does not cite are dimmed, and an answer that cites none shows no sources. +Sources arrive before the first word, and the answer renders as it streams, batched to one update per animation frame. Closing the dialog, by Escape, its Close button, a click outside or its parent, stops the answer in flight, so the model is not left generating for nobody. When an answer stops at the model's output limit, the dialog says it may be incomplete. Once an answer is complete, the sources it cites have their number filled and the others are dimmed, and an answer that cites none shows no sources. ## The useAsk hook diff --git a/examples/vitepress/docs/ask-dialog.md b/examples/vitepress/docs/ask-dialog.md index 1dd446fe..644eb2d0 100644 --- a/examples/vitepress/docs/ask-dialog.md +++ b/examples/vitepress/docs/ask-dialog.md @@ -188,6 +188,7 @@ The endpoint's own words, its "I don't know" answer, its rate limit and budget m | `list` | Suggestions | | `stop` | Stop | | `escapeKey` | esc | +| `close` | Close | | `footer` | Answers come from this site and can be wrong. Check the sources. | | `footerNoSources` | Answers come from this site and can be wrong. | | `footerAsk` | ask | @@ -203,6 +204,9 @@ The endpoint's own words, its "I don't know" answer, its rate limit and budget m | `retry` | Retry | | `sources` | Sources | | `citation` | Source {n}: {title} | +| `source` | Source {n}: | +| `sourceCited` | Source {n}, cited: | +| `sourceNotCited` | Source {n}, not cited: | | `newQuestion` | New question | | `helpfulQuestion` | Was this helpful? | | `helpful` | Yes, it helped | @@ -235,11 +239,11 @@ Import `ondocs/react/styles.css` for the default theme. With `theme="auto"`, the ## Accessibility -Focus moves into the dialog when it opens and returns to where it was when it closes. The answer region is `aria-live="polite"` and `aria-busy` while text streams, and a separate status region announces when the answer is ready. All animation is turned off under `prefers-reduced-motion`. The test suite runs axe on the dialog before and after it answers, and checks that every text color in the light and dark themes keeps a contrast of at least 4.5:1. +Focus moves into the dialog when it opens and returns to where it was when it closes. A touch screen has no Escape key, so there a Close button takes the place of the esc hint in the input row. When a control goes away as it is pressed, focus moves on rather than falling to the dialog: from a thumb to "Add a comment", from a sent comment to "New question", and from Stop or Retry pressed with the keyboard to the input. The answer region is `aria-live="polite"` and `aria-busy` while text streams, and a separate status region announces when the answer is ready; an error is announced once, by its alert. Each source's name starts with its number, which its citations name, and whether the answer cites it (the `source`, `sourceCited` and `sourceNotCited` labels), and questions in a thread are headings. On a touch screen, buttons are 44 px tall and citations have a larger area to tap. In forced colors (Windows High Contrast), the selected suggestion is outlined and the cited sources keep their filled number. All animation is turned off under `prefers-reduced-motion`. The test suite runs axe on the dialog before and after it answers, and checks that every text color in the light and dark themes keeps a contrast of at least 4.5:1. ## While an answer streams -Sources arrive before the first word, and the answer renders as it streams, batched to one update per animation frame. Closing the dialog, by Escape, a click outside or its parent, stops the answer in flight, so the model is not left generating for nobody. When an answer stops at the model's output limit, the dialog says it may be incomplete. Once an answer is complete, the sources it does not cite are dimmed, and an answer that cites none shows no sources. +Sources arrive before the first word, and the answer renders as it streams, batched to one update per animation frame. Closing the dialog, by Escape, its Close button, a click outside or its parent, stops the answer in flight, so the model is not left generating for nobody. When an answer stops at the model's output limit, the dialog says it may be incomplete. Once an answer is complete, the sources it cites have their number filled and the others are dimmed, and an answer that cites none shows no sources. ## The useAsk hook diff --git a/src/docusaurus/launcher.css b/src/docusaurus/launcher.css index 0e6e1a18..e1f8e019 100644 --- a/src/docusaurus/launcher.css +++ b/src/docusaurus/launcher.css @@ -109,7 +109,11 @@ -webkit-mask: var(--ask-button-icon) center / contain no-repeat; mask: var(--ask-button-icon) center / contain no-repeat; } +/* Forced colors drop the fill: a border keeps it a button rather than bare text. */ @media (forced-colors: active) { + .ondocs-button { + border: 1px solid ButtonText; + } .ondocs-button::before { background: ButtonText; forced-color-adjust: none; diff --git a/src/embed/button.css b/src/embed/button.css index 41c2dc22..3a99fadf 100644 --- a/src/embed/button.css +++ b/src/embed/button.css @@ -70,7 +70,11 @@ -webkit-mask: var(--ask-button-icon) center / contain no-repeat; mask: var(--ask-button-icon) center / contain no-repeat; } +/* Forced colors drop the fill: a border keeps it a button rather than bare text. */ @media (forced-colors: active) { + .ondocs-button { + border: 1px solid ButtonText; + } .ondocs-button::before { background: ButtonText; forced-color-adjust: none; diff --git a/src/react/answer.tsx b/src/react/answer.tsx index ff10d2a7..ab73cd12 100644 --- a/src/react/answer.tsx +++ b/src/react/answer.tsx @@ -188,14 +188,16 @@ export function AskAnswer({ } const BULLET = /^[-*+]\s+/; -const NUMBERED = /^\d+[.)]\s+/; +const NUMBERED = /^(\d+)[.)]\s+/; type LineKind = 'ul' | 'ol' | 'p'; const kindOf = (line: string): LineKind => BULLET.test(line) ? 'ul' : NUMBERED.test(line) ? 'ol' : 'p'; /** * Paragraphs and lists. Within a paragraph block, consecutive lines of one kind are grouped, so - * "To install:" directly followed by "1. …" lines renders as a paragraph and then a list. + * "To install:" directly followed by "1. …" lines renders as a paragraph and then a list. A + * numbered list starts at its first item's number: steps with code or a blank line between them + * come as lists of their own, and read 1, 2, 3 rather than 1, 1, 1. */ function prose( text: string, @@ -228,7 +230,16 @@ function prose( } const marker = run.kind === 'ul' ? BULLET : NUMBERED; const items = run.lines.map((line, i) =>
  • {inline(line.replace(marker, ''))}
  • ); - out.push(run.kind === 'ul' ? :
      {items}
    ); + const start = Number(NUMBERED.exec(run.lines[0] ?? '')?.[1] ?? 1); + out.push( + run.kind === 'ul' ? ( + + ) : ( +
      + {items} +
    + ), + ); } } return out; diff --git a/src/react/dialog-panel.tsx b/src/react/dialog-panel.tsx index d557dbd9..406246f1 100644 --- a/src/react/dialog-panel.tsx +++ b/src/react/dialog-panel.tsx @@ -4,6 +4,7 @@ import * as Dialog from '@radix-ui/react-dialog'; import { Command } from 'cmdk'; import { + useCallback, useEffect, useId, useRef, @@ -51,6 +52,14 @@ const keepOwnKeys = (event: KeyboardEvent): void => { if (ownKeys.has(event.key)) event.stopPropagation(); }; +/** + * Whether a click came from the keyboard or a screen reader, which give it no click count, rather + * than a pointer. Stop and Retry go away when pressed, and focus on them would fall to the dialog: + * from the keyboard it moves on to the input, and a tap on a phone leaves it, so that the phone's + * keyboard stays down. + */ +const fromKeyboard = (event: MouseEvent): boolean => event.detail === 0; + function SearchIcon(): ReactNode { return (
    { + if (fromKeyboard(event)) focusInput(); + state.stop(); + }} onKeyDown={keepOwnKeys} > {labels.stop} ) : ( - {labels.escapeKey} + )} + {/* A touch screen has no Escape key, and with an answer on screen, little of the page + left to tap: it shows this in the esc hint's place. */} + + +
    {/* Always mounted, as the input's aria-controls points at it. While the answer shows, @@ -476,7 +560,9 @@ export function DialogPanel({ {followingUp ? labels.followUp : labels.ask} {trimmedQuery} - ↵ + ) : null} {matchingSuggestions.length > 0 ? ( @@ -504,6 +590,7 @@ export function DialogPanel({ labels={labels} onNavigate={navigate} onNewQuestion={newQuestion} + focusInput={focusInput} classNames={classNames} links={links} sourceUrls={sourceUrls} @@ -548,6 +635,7 @@ function AnswerPanel({ labels, onNavigate, onNewQuestion, + focusInput, classNames, links, sourceUrls, @@ -556,6 +644,7 @@ function AnswerPanel({ labels: ResolvedLabels; onNavigate: (url: string, event: MouseEvent) => void; onNewQuestion: () => void; + focusInput: () => void; classNames: Partial>; links: AnswerLinks; sourceUrls: SourceUrls | undefined; @@ -566,12 +655,13 @@ function AnswerPanel({ const cited = state.status === 'done' ? citedSourceIds(state.answer) : null; const showSources = showsSources(state); const thread = state.turns.length > 0; + const newRef = useRef(null); return (
    {/* The thread so far: each answer with its own citations, which link to its own sources. */} {state.turns.map((turn, i) => (
    -

    {turn.question}

    +

    {turn.question}

    {state.question}

    +

    {state.question}

    ) : null}
    void state.ask(state.question ?? '')} + onClick={(event) => { + // The error, its Retry with it, gives way to the answer. + if (fromKeyboard(event)) focusInput(); + void state.ask(state.question ?? ''); + }} > {labels.retry} @@ -639,7 +733,7 @@ function AnswerPanel({ {showSources ? (
    diff --git a/src/react/styles.css b/src/react/styles.css index f3a88814..3fd4a2ac 100644 --- a/src/react/styles.css +++ b/src/react/styles.css @@ -736,8 +736,9 @@ } } -/* Fingers: buttons and the comment field 44 px tall, and citations with a larger area to tap, - spaced so that two side by side do not share it. */ +/* Fingers: buttons and the comment field 44 px tall, and citations with a larger area to tap. A + citation right after another, as in [1][2], keeps 12 px from it, twice how far each area + reaches, so the two do not meet; next to words and punctuation it keeps its usual spacing. */ @media (pointer: coarse) { .ask-stop, .ask-retry, @@ -755,7 +756,10 @@ .ask-citation { position: relative; - margin: 0 0.5em; + } + + .ask-citation[data-after-citation] { + margin-left: 12px; } .ask-citation::after { diff --git a/test/keyboard-dialog.test.tsx b/test/keyboard-dialog.test.tsx index f0626116..2f7e9e1f 100644 --- a/test/keyboard-dialog.test.tsx +++ b/test/keyboard-dialog.test.tsx @@ -79,7 +79,7 @@ afterEach(() => { }); /** The dialog with an answer on screen: the panel, its live region and what was navigated to. */ -async function answered(onFeedback?: () => void) { +async function answered(onFeedback?: () => void | Promise) { const user = userEvent.setup(); const onNavigate = vi.fn((_url: string, event: { preventDefault: () => void }) => { event.preventDefault(); @@ -227,6 +227,21 @@ describe('focus after a control goes away', () => { expect(document.activeElement).toBe(more); }); + it('stays where the visitor moved it while the rating was being sent', async () => { + let release = (): void => undefined; + const sent = new Promise((resolve) => { + release = resolve; + }); + const { user, dialog } = await answered(() => sent); + within(dialog).getByRole('button', { name: 'Yes, it helped' }).focus(); + await user.keyboard('{Enter}'); + const next = within(dialog).getByRole('button', { name: 'New question' }); + next.focus(); + release(); + await within(dialog).findByRole('button', { name: 'Add a comment' }); + expect(document.activeElement).toBe(next); + }); + it('goes from a sent comment to "New question"', async () => { const { user, dialog } = await answered(vi.fn()); within(dialog).getByRole('button', { name: 'No, it did not help' }).focus(); @@ -294,6 +309,31 @@ describe('the Close button', () => { await closed(); }); + // The command palette around it takes Enter for its list: on Close, Enter asked the selected + // suggestion, or did nothing with an answer on screen. A phone or tablet with a keyboard uses it. + it('closes on Enter, rather than asking the selected suggestion', async () => { + const user = userEvent.setup(); + const fetch = handlerFetch(); + render(); + const dialog = await screen.findByRole('dialog'); + expect( + within(dialog) + .getByRole('option', { name: 'How do I install with pnpm?' }) + .getAttribute('aria-selected'), + ).toBe('true'); + within(dialog).getByRole('button', { name: 'Close' }).focus(); + await user.keyboard('{Enter}'); + await closed(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('closes on Enter, with an answer on screen', async () => { + const { user, dialog } = await answered(); + within(dialog).getByRole('button', { name: 'Close' }).focus(); + await user.keyboard('{Enter}'); + await closed(); + }); + it('takes its name from labels.close', async () => { render(); const dialog = await screen.findByRole('dialog'); diff --git a/test/react.test.tsx b/test/react.test.tsx index 946151cb..30f6ce58 100644 --- a/test/react.test.tsx +++ b/test/react.test.tsx @@ -279,6 +279,18 @@ describe('AskAnswer', () => { expect(screen.getByRole('link', { name: 'a login page' })).toBeTruthy(); }); + // On a touch screen, a citation right after another keeps its distance from it (styles.css). + it('marks a citation that comes right after another', () => { + const two = [...sources, { id: 2, url: '/docs/b', title: 'B', heading: '' }]; + render( + , + ); + const marked = screen + .getAllByRole('link') + .map((link) => `${link.textContent}${link.hasAttribute('data-after-citation') ? '+' : ''}`); + expect(marked).toEqual(['1', '2+', '1', '2+', '1', '2+', '1', '2']); + }); + it('extracts cited source numbers', () => { expect([...citedSourceIds('A [1]. B [2][3]. C [1, 4]. Not [x].')]).toEqual([1, 2, 3, 4]); }); diff --git a/test/touch-and-forced-colors.test.ts b/test/touch-and-forced-colors.test.ts index 11e02140..7f5270b6 100644 --- a/test/touch-and-forced-colors.test.ts +++ b/test/touch-and-forced-colors.test.ts @@ -63,9 +63,17 @@ describe('on a touch screen', () => { ]); } expect(rule(coarse, '.ask-feedback-button')).toMatch(/min-width:\s*44px;/); - // A citation keeps its look, with a larger area to tap and more room between neighbors. + // A citation keeps its look, with a larger area to tap, and one right after another keeps + // far enough from it that their areas do not meet. expect(rule(coarse, '.ask-citation')).toMatch(/position:\s*relative;/); - expect(rule(coarse, '.ask-citation::after')).toMatch(/inset:/); + // Next to words and punctuation it keeps its usual margins: "it [1]." with no gap before ".". + expect(rule(coarse, '.ask-citation')).not.toMatch(/margin/); + const reach = Number(/inset:\s*-(\d+)px;/.exec(rule(coarse, '.ask-citation::after'))?.[1]); + expect(reach).toBeGreaterThan(0); + const apart = /margin-left:\s*(\d+)px;/.exec( + rule(coarse, '.ask-citation[data-after-citation]'), + )?.[1]; + expect(Number(apart)).toBeGreaterThanOrEqual(2 * reach); }); });