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/scripts/js-weight.mjs b/scripts/js-weight.mjs index 431252eb..74a638d4 100644 --- a/scripts/js-weight.mjs +++ b/scripts/js-weight.mjs @@ -44,7 +44,7 @@ const SITES = { // site loads its shared code in one chunk, not two, and 173.8 KB, the benchmarks page's // figure. The budget leaves about 4 KB of room, as Starlight's does. budget: 182, - // The dialog, Radix and cmdk, on the site's React: 27.2 KB. + // The dialog, Radix and cmdk, on the site's React: 27.7 KB. open: 31, }, starlight: { @@ -54,7 +54,7 @@ const SITES = { // Starlight's own 33.7 KB, with the demo's banner and question box, and ondocs's 2.4 KB: // 36.1 KB. budget: 40, - // ondocs/embed/standalone's dialog, with its own React: 92.6 KB. + // ondocs/embed/standalone's dialog, with its own React: 93.1 KB. open: 97, }, vitepress: { @@ -64,7 +64,7 @@ const SITES = { // VitePress's own 58.7 KB, with the demo's notice and question box, and ondocs's 2.9 KB: // 61.6 KB. budget: 64, - // ondocs/embed/standalone's dialog, with its own React: 95.6 KB. + // ondocs/embed/standalone's dialog, with its own React: 96.1 KB. open: 100, }, next: { @@ -75,9 +75,11 @@ const SITES = { // before the shared header's theme switch and phone menu, 171.4 KB with them, 178 KB with the // docs shell (On this page, the docs bar, page actions, tabs), and 174.5 KB once ondocs's // package.json, which client code reached through the site's constants, left the bundle. + // 180.5 KB with the dialog's styles for touch screens, forced colors and cited sources: the + // site inlines ondocs/react/styles.css in its page payload, so every rule counts here. launcher: false, - budget: 180, - // The dialog, Radix and cmdk, on the site's React: 27.3 KB. + budget: 182, + // The dialog, Radix and cmdk, on the site's React: 27.8 KB. open: 31, }, embed: { @@ -86,7 +88,7 @@ const SITES = { key: 'i', // dist/embed.global.js alone: the button, the shortcut and their styles. budget: 4, - // dist/embed-dialog.global.js: the dialog, React and the dialog's styles, 97.8 KB. + // dist/embed-dialog.global.js: the dialog, React and the dialog's styles, 98.5 KB. open: 102, }, }; 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..1f5bb883 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; @@ -245,6 +256,9 @@ function renderInline( const out: ReactNode[] = []; let last = 0; let key = 0; + // Where the last citation link ended, when it was the last thing shown: a citation right after + // it, as in [1][2] or [1, 2], is marked, so that on a touch screen the two keep their distance. + let citationEnd = -1; for (const match of text.matchAll(INLINE)) { if (match.index > last) out.push(text.slice(last, match.index)); last = match.index + match[0].length; @@ -274,17 +288,20 @@ function renderInline( } else if (citations !== undefined) { const ids = citations.split(',').map((id) => Number(id.trim())); if (ids.every((id) => sources.has(id))) { + let afterCitation = citationEnd !== -1 && !text.slice(citationEnd, match.index).trim(); for (const id of ids) { const source = sources.get(id); const href = source ? mappedHref(source.url, sourceUrls) : null; if (!source || !href) { out.push(`[${String(id)}]`); + afterCitation = false; continue; } out.push( , ); + afterCitation = true; } + citationEnd = afterCitation ? last : -1; } else { out.push(raw); } diff --git a/src/react/dialog-panel.tsx b/src/react/dialog-panel.tsx index d557dbd9..dc3e56bb 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, @@ -44,13 +45,22 @@ export type DialogPanelProps = Omit< const cx = (...names: (string | undefined | false)[]): string => names.filter(Boolean).join(' '); // The command palette takes these keys for its list, and cancels them anywhere inside it. On the -// answer and the Stop button they stop short of it: Enter follows the focused link or presses the -// focused button, and the others scroll the answer. Escape, Tab and the shortcut still go on. +// answer and the Stop and Close buttons they stop short of it: Enter follows the focused link or +// presses the focused button, rather than asking the selected suggestion, and the others scroll the +// answer. Escape, Tab and the shortcut still go on. const ownKeys = new Set(['Enter', 'ArrowUp', 'ArrowDown', 'Home', 'End']); 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 +562,9 @@ export function DialogPanel({ {followingUp ? labels.followUp : labels.ask} {trimmedQuery} - ↵ + ) : null} {matchingSuggestions.length > 0 ? ( @@ -504,6 +592,7 @@ export function DialogPanel({ labels={labels} onNavigate={navigate} onNewQuestion={newQuestion} + focusInput={focusInput} classNames={classNames} links={links} sourceUrls={sourceUrls} @@ -548,6 +637,7 @@ function AnswerPanel({ labels, onNavigate, onNewQuestion, + focusInput, classNames, links, sourceUrls, @@ -556,6 +646,7 @@ function AnswerPanel({ labels: ResolvedLabels; onNavigate: (url: string, event: MouseEvent) => void; onNewQuestion: () => void; + focusInput: () => void; classNames: Partial>; links: AnswerLinks; sourceUrls: SourceUrls | undefined; @@ -566,12 +657,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 +735,7 @@ function AnswerPanel({ {showSources ? (