@astryxdesign/core 0.7.1-canary.bc0261a → 0.7.1-canary.e897146
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
- package/dist/CodeBlock/CodeBlock.js +25 -16
- package/package.json +3 -3
- package/src/CodeBlock/CodeBlock.spec.md +11 -11
- package/src/CodeBlock/CodeBlock.test.tsx +11 -9
- package/src/CodeBlock/CodeBlock.tsx +29 -18
- package/src/CodeBlock/__tests__/CodeBlockOverflowStop.a11y.chromium.spec.ts +125 -0
- package/src/Markdown/plugins/semanticFence.test.tsx +3 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CodeBlock.d.ts","sourceRoot":"","sources":["../../src/CodeBlock/CodeBlock.tsx"],"names":[],"mappings":"AAoBA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"CodeBlock.d.ts","sourceRoot":"","sources":["../../src/CodeBlock/CodeBlock.tsx"],"names":[],"mappings":"AAoBA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AA8B5C,OAAO,EAAc,KAAK,qBAAqB,EAAC,MAAM,iBAAiB,CAAC;AA+WxE,MAAM,WAAW,cAAe,SAAQ,SAAS,CAAC,cAAc,CAAC;IAC/D,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAC5B,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACnB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;;;OAQG;IACH,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/B,SAAS,CAAC,EAAE,CACV,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,KACb;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAC,EAAE,CAAC;IAClD,aAAa,CAAC,EAAE,MAAM,GAAG,QAAQ,GAAG,OAAO,CAAC;IAC5C;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,qBAAqB,CAAC;CACrC;AAqPD;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,EACxB,IAAI,EACJ,QAAsB,EACtB,KAAK,EACL,gBAAuB,EACvB,cAAsB,EACtB,cAAc,EACd,aAAoB,EACpB,MAAM,EACN,SAAiB,EACjB,SAAS,EACT,aAAqB,EACrB,oBAAyB,EACzB,IAAW,EACX,KAAK,EAAE,SAAyB,EAChC,SAAkB,EAClB,SAAS,EAAE,eAAe,EAC1B,aAAsB,EACtB,WAAW,EACX,MAAM,EACN,SAAS,EACT,KAAK,EACL,GAAG,EACH,GAAG,KAAK,EACT,EAAE,cAAc,qBA0PhB;yBAlRe,SAAS"}
|
|
@@ -15,6 +15,7 @@ import "../theme/tokens.stylex.js";
|
|
|
15
15
|
import { colorVars, spacingVars, radiusVars, typographyVars, fontWeightVars, typeScaleVars, borderVars, durationVars, easeVars } from "../theme/tokens.stylex.js";
|
|
16
16
|
import { mergeProps } from "../utils/index.js";
|
|
17
17
|
import { useClipboard } from "../hooks/useClipboard.js";
|
|
18
|
+
import { useScrollableArea } from "../hooks/useScrollableArea.js";
|
|
18
19
|
import { Icon } from "../Icon/index.js";
|
|
19
20
|
import { IconButton } from "../IconButton/index.js";
|
|
20
21
|
import { tokenize, tokenizeAsync, flatTokensToLines, SYNC_TOKENIZE_THRESHOLD } from "./tokenizer.js";
|
|
@@ -494,7 +495,23 @@ export function CodeBlock({
|
|
|
494
495
|
// pattern). The region stays mounted when collapsed (CSS grid animation), so
|
|
495
496
|
// this is always a resolvable reference — aria-controls can be unconditional.
|
|
496
497
|
const regionId = useId();
|
|
497
|
-
|
|
498
|
+
|
|
499
|
+
// The code viewport enters the Tab order only while its content actually
|
|
500
|
+
// overflows (AST-025 FR12–FR13): the shared scroll capability measures
|
|
501
|
+
// both axes, keeps focus where it is when overflow goes away, and owns the
|
|
502
|
+
// overflow declarations. role="group" (not "region") so several code
|
|
503
|
+
// blocks on a page don't create duplicate same-named landmarks.
|
|
504
|
+
const {
|
|
505
|
+
getViewportProps,
|
|
506
|
+
getContentProps
|
|
507
|
+
} = useScrollableArea({
|
|
508
|
+
axis: 'both',
|
|
509
|
+
keyboardAccess: {
|
|
510
|
+
owner: 'viewport',
|
|
511
|
+
role: 'group',
|
|
512
|
+
label: languageLabel ?? t('@astryx.codeBlock.code')
|
|
513
|
+
}
|
|
514
|
+
});
|
|
498
515
|
const scrollStyle = maxHeight != null ? {
|
|
499
516
|
maxHeight: typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight
|
|
500
517
|
} : undefined;
|
|
@@ -570,29 +587,21 @@ export function CodeBlock({
|
|
|
570
587
|
}), copyButtonEl]
|
|
571
588
|
}) : null;
|
|
572
589
|
const codeBody = /*#__PURE__*/_jsx("div", {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
// create duplicate same-named landmarks (axe: landmark-unique).
|
|
578
|
-
,
|
|
579
|
-
tabIndex: 0,
|
|
580
|
-
role: "group",
|
|
581
|
-
"aria-label": languageLabel ?? t('@astryx.codeBlock.code'),
|
|
582
|
-
...mergeProps({
|
|
583
|
-
className: "xw2csxc x1odjw0f"
|
|
584
|
-
}, {
|
|
590
|
+
...getViewportProps({
|
|
591
|
+
...{
|
|
592
|
+
className: "x2lwn1j"
|
|
593
|
+
},
|
|
585
594
|
style: scrollStyle
|
|
586
595
|
}),
|
|
587
596
|
children: /*#__PURE__*/_jsx("div", {
|
|
588
|
-
...{
|
|
597
|
+
...getContentProps({
|
|
589
598
|
0: {
|
|
590
599
|
className: "x78zum5 x1pshirs"
|
|
591
600
|
},
|
|
592
601
|
1: {
|
|
593
602
|
className: "x78zum5 x1pshirs x1ajqlyv"
|
|
594
603
|
}
|
|
595
|
-
}[!!(showHeader && !hasLineNumbers) << 0],
|
|
604
|
+
}[!!(showHeader && !hasLineNumbers) << 0]),
|
|
596
605
|
children: useSpans ? /*#__PURE__*/_jsx(SpanCodeContent, {
|
|
597
606
|
lines: lines,
|
|
598
607
|
tokenLines: tokenLines,
|
|
@@ -625,7 +634,7 @@ export function CodeBlock({
|
|
|
625
634
|
// While collapsed, the region is only hidden visually (0fr grid
|
|
626
635
|
// row); inert also removes it from the tab order and accessibility
|
|
627
636
|
// tree so keyboard users cannot Tab into the invisible scroll
|
|
628
|
-
// container (
|
|
637
|
+
// container (a tab stop while it overflows). aria-controls pointing at an inert
|
|
629
638
|
// element remains a valid, resolvable reference.
|
|
630
639
|
,
|
|
631
640
|
inert: isCollapsed ? true : undefined,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/core",
|
|
3
|
-
"version": "0.7.1-canary.
|
|
3
|
+
"version": "0.7.1-canary.e897146",
|
|
4
4
|
"displayName": "Astryx Core",
|
|
5
5
|
"description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
|
|
6
6
|
"author": "Meta Open Source",
|
|
@@ -787,8 +787,8 @@
|
|
|
787
787
|
"react-dom": ">=19.0.0"
|
|
788
788
|
},
|
|
789
789
|
"devDependencies": {
|
|
790
|
-
"@astryxdesign/a11y-spec": "0.7.1-canary.
|
|
791
|
-
"@astryxdesign/cli": "0.7.1-canary.
|
|
790
|
+
"@astryxdesign/a11y-spec": "0.7.1-canary.e897146",
|
|
791
|
+
"@astryxdesign/cli": "0.7.1-canary.e897146",
|
|
792
792
|
"@babel/cli": "^7.29.7",
|
|
793
793
|
"@babel/core": "^7.29.7",
|
|
794
794
|
"@babel/preset-react": "^7.29.7",
|
|
@@ -39,15 +39,15 @@ system_specs: [spec:AST-025]
|
|
|
39
39
|
|
|
40
40
|
## Contract at a glance
|
|
41
41
|
|
|
42
|
-
| Area | Contract
|
|
43
|
-
| ----------------------- |
|
|
44
|
-
| Public contract | `CodeBlock` and `CodeBlockProps` are exported from `@astryxdesign/core/CodeBlock`; `code` is required, and the remaining released props configure language, header, line presentation, copy, wrapping, sizing, syntax theme, and collapse behavior; `isDiff` (FR9) adds the diff layer as a caller choice over any language.
|
|
45
|
-
| Behavior | CodeBlock renders a read-only multi-line code surface, applies syntax highlighting when available, preserves the source string for copy, and exposes optional line, header, copy, theme, and disclosure behavior without editing the content.
|
|
46
|
-
| End-user impact | Readers can inspect and copy code, understand optional line emphasis, and operate long or collapsible examples with keyboard and assistive technology.
|
|
47
|
-
| Builder impact | Builders choose the released display axes without an additional migration or opt-in; the diff layer is one more opt-in axis, and five defaulted syntax-theme slots mean no syntax theme has to change for it.
|
|
48
|
-
| Compatibility/readiness | The released `CodeBlock` API and defaults remain stable; FR9 (the diff layer) is current and an implementation gap until its implementation ships; Markdown's default path for `diff` fences changes with it (`component:Markdown` FR29).
|
|
49
|
-
| Review checks | Reject lost code text, copy of a transformed string, an unnamed copy or disclosure control, stale disclosure state/wiring, dropped root props/ref, a theme target off its painter, or viewport focusability that ignores effective overflow.
|
|
50
|
-
| Governing rules | `architecture:public-component-api/INV1,INV5–INV9`; `architecture:component-theming-surface/INV3–INV7,INV12`; `architecture:component-test-sufficiency/INV1–INV7,INV10–INV11`; `spec:AST-025/FR12–FR13`; `architecture:theme-tokens` (syntax vocabulary and the diff slot defaults).
|
|
42
|
+
| Area | Contract |
|
|
43
|
+
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
44
|
+
| Public contract | `CodeBlock` and `CodeBlockProps` are exported from `@astryxdesign/core/CodeBlock`; `code` is required, and the remaining released props configure language, header, line presentation, copy, wrapping, sizing, syntax theme, and collapse behavior; `isDiff` (FR9) adds the diff layer as a caller choice over any language. |
|
|
45
|
+
| Behavior | CodeBlock renders a read-only multi-line code surface, applies syntax highlighting when available, preserves the source string for copy, and exposes optional line, header, copy, theme, and disclosure behavior without editing the content. |
|
|
46
|
+
| End-user impact | Readers can inspect and copy code, understand optional line emphasis, and operate long or collapsible examples with keyboard and assistive technology. |
|
|
47
|
+
| Builder impact | Builders choose the released display axes without an additional migration or opt-in; the diff layer is one more opt-in axis, and five defaulted syntax-theme slots mean no syntax theme has to change for it. |
|
|
48
|
+
| Compatibility/readiness | The released `CodeBlock` API and defaults remain stable; FR9 (the diff layer) is current and an implementation gap until its implementation ships; Markdown's default path for `diff` fences changes with it (`component:Markdown` FR29). |
|
|
49
|
+
| Review checks | Reject lost code text, copy of a transformed string, an unnamed copy or disclosure control, stale disclosure state/wiring, dropped root props/ref, a theme target off its painter, or viewport focusability that ignores effective overflow. |
|
|
50
|
+
| Governing rules | `architecture:public-component-api/INV1,INV5–INV9`; `architecture:component-theming-surface/INV3–INV7,INV12`; `architecture:component-test-sufficiency/INV1–INV7,INV10–INV11`; `spec:AST-025/FR12–FR13`; `architecture:theme-tokens` (syntax vocabulary and the diff slot defaults). |
|
|
51
51
|
|
|
52
52
|
This table is a review projection; the body below is authoritative only after owner approval.
|
|
53
53
|
|
|
@@ -102,7 +102,7 @@ Consumer migration instructions belong in consumer docs and release notes.
|
|
|
102
102
|
| FR5 | Collapse MUST become available only when enabled, a visible header exists, and the line threshold is met. The control MUST expose expanded state and a resolvable controlled region; collapsed content remains mounted but inert, and losing the control expands the region before hiding the control. | shipped docs/source/tests; WAI-ARIA disclosure pattern and HTML `inert` semantics | verify |
|
|
103
103
|
| FR6 | The root and documented header/title/copy targets MUST remain on their visible painters, reflect the documented visual axes, and continue emitting the approved deprecated aliases until their authorized removal window. | `architecture:component-theming-surface/INV3–INV7,INV12`; source/docs/tests | verify |
|
|
104
104
|
| FR7 | Supported DOM, data, ARIA, class, style, and `xstyle` inputs MUST compose on the documented root, and the public ref MUST reach that root. | `architecture:public-component-api/INV5,INV6,INV8`; shipped source | verify |
|
|
105
|
-
| FR8 | The code viewport MUST enter sequential keyboard navigation only while at least one applicable axis effectively overflows; losing overflow MUST NOT move current focus. It retains an appropriate role and accessible name while focusable. | `spec:AST-025/FR12–FR13` | settled by current authority
|
|
105
|
+
| FR8 | The code viewport MUST enter sequential keyboard navigation only while at least one applicable axis effectively overflows; losing overflow MUST NOT move current focus. It retains an appropriate role and accessible name while focusable. | `spec:AST-025/FR12–FR13` | settled by current authority |
|
|
106
106
|
| FR9 | With the diff layer on (`isDiff`), CodeBlock MUST classify each line by its first character — `+` added, `-` removed, `@@` header, anything else context — except that a `--- ` line (three minus signs and a space, the unified-diff file header form) directly followed by a `+++ ` line and then an `@@` line is a file header (both lines), wherever it occurs; every other `---`/`+++` line, `---i;` included, is removed or added. CodeBlock MUST set the glyph column aside and tokenize the REST of every line (context, added, and removed alike) with the base `language`'s syntax highlighting. Added, removed, and header lines MUST paint as whole rows — the line-number gutter cell included — from the syntax-theme slots `--color-syntax-added`, `--color-syntax-removed`, and `--color-syntax-diff-header`, whose defaults `architecture:theme-tokens` owns; header rows take the syntax theme's `punctuation` colour as text (`--color-text-secondary` in the default theme) and never change paint; context rows keep ordinary paint. On an added or removed row, the line number and the glyph take the row's marker slot — `--color-syntax-added-marker` / `--color-syntax-removed-marker` — so the margin reads the change in colour; every run of code text, painted by a token or not, keeps its syntax-theme colour. In the Astryx default syntax theme the marker slots default to the theme's tinted-text tokens and the header paint to the muted background (`architecture:theme-tokens` owns the values); a syntax theme that does not set them inherits its own colours — the marker slots and the header text its line-number colour (its `punctuation` slot), the header paint none at all (the header row sits on the theme's own syntax background) — so number, glyph, and header text stay legible on that theme's background in every page mode, and the glyph's shape carries the meaning where its colour does not. The glyph (`+`, `-`, the leading space of a context line, or nothing for a context or header line without one — the marker column is then empty and the whole line is code) MUST render in its own fixed-width marker column — secondary colour on context rows and the row's marker slot on added and removed rows, separated from the code by a token gap (`--spacing-2`, a proportion themes may vary like the rest), excluded from selection for layout yet present in the copied text — so the code column starts at the same inline offset on every line and the content's own indentation is never shifted; the gutter order is line number, marker, code, mirrored in RTL with the code's direction unchanged. The header (language label, copy, disclosure) and the viewport's name stay as for any other code: with the layer over plaintext the visible language label and the viewport name are `diff`, and over a language they name both (`tsx diff`). Markdown's fence mapping onto the layer is `component:Markdown` FR29. Row paint MUST compose with line numbers without moving text; on a highlighted added, removed, or header line the highlight REPLACES the row paint rather than stacking on it, so text keeps its single-paint contrast; and row paint MUST span the full line width — the scrolled overflow of a long line included, in wrapped and unwrapped mode — not only the visible viewport. Contrast floor: in the Astryx default syntax theme, every default token colour and the row's marker slot (line number and glyph) on a painted row MUST measure ≥ 4.5:1 against the default tints composited over the code block's own `--color-syntax-background` on the default card surface. In a syntax theme that overrides a diff slot, a token that already measures under 4.5:1 on that theme's own syntax background MUST NOT drop further on the slot, and every other token MUST clear 4.5:1 on it. Two known shortfalls, tracked separately: the shipped syntax presets do not yet meet the floor on the default tints with the layer on, and shipped page themes whose own text tokens fall under it on the tints keep that shortfall, tracked for a fix in those themes, until each sets its tokens or the diff slots. | this record DEC-1 | current; owner-approved 2026-10-12; implementation gap |
|
|
107
107
|
|
|
108
108
|
### Allowed variation
|
|
@@ -201,7 +201,7 @@ A per-line typed wash (an added/removed flavour of `highlightLines`) is not deci
|
|
|
201
201
|
|
|
202
202
|
## Open questions
|
|
203
203
|
|
|
204
|
-
None.
|
|
204
|
+
None.
|
|
205
205
|
|
|
206
206
|
## Content boundary
|
|
207
207
|
|
|
@@ -81,17 +81,19 @@ describe('CodeBlock', () => {
|
|
|
81
81
|
expect(root).toHaveStyle({opacity: '0.75'});
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
-
it('
|
|
84
|
+
it('names the code viewport and gives it a tab stop only while it overflows', () => {
|
|
85
|
+
// jsdom has no layout, so the viewport fits: a fitting viewport is not a
|
|
86
|
+
// tab stop (AST-025 FR12); the overflowing states are browser evidence.
|
|
85
87
|
render(<CodeBlock code="const x = 1;" language="javascript" />);
|
|
86
88
|
const region = screen.getByRole('group');
|
|
87
|
-
expect(region).toHaveAttribute('tabindex'
|
|
89
|
+
expect(region).not.toHaveAttribute('tabindex');
|
|
88
90
|
expect(region).toHaveAttribute('aria-label', 'javascript');
|
|
91
|
+
expect(region).toHaveAttribute('data-scroll-axis', 'both');
|
|
89
92
|
});
|
|
90
93
|
|
|
91
94
|
it('labels the scroll region "Code" when no language label is shown', () => {
|
|
92
95
|
render(<CodeBlock code="hello" hasLanguageLabel={false} />);
|
|
93
96
|
const region = screen.getByRole('group');
|
|
94
|
-
expect(region).toHaveAttribute('tabindex', '0');
|
|
95
97
|
expect(region).toHaveAttribute('aria-label', 'Code');
|
|
96
98
|
});
|
|
97
99
|
|
|
@@ -371,9 +373,9 @@ describe('CodeBlock', () => {
|
|
|
371
373
|
|
|
372
374
|
fireEvent.click(header);
|
|
373
375
|
expect(header).toHaveAttribute('aria-expanded', 'false');
|
|
374
|
-
// Collapsed: the wrapper is inert, so the
|
|
375
|
-
//
|
|
376
|
-
//
|
|
376
|
+
// Collapsed: the wrapper is inert, so the scroll container inside it
|
|
377
|
+
// drops out of the tab order and the accessibility tree instead of
|
|
378
|
+
// remaining an invisible tab stop while it overflows.
|
|
377
379
|
expect(region).toHaveAttribute('inert');
|
|
378
380
|
const scrollContainer = screen.getByRole('group');
|
|
379
381
|
expect(scrollContainer.closest('[inert]')).toBe(region);
|
|
@@ -399,12 +401,12 @@ describe('CodeBlock', () => {
|
|
|
399
401
|
expect(region).toHaveAttribute('inert');
|
|
400
402
|
fireEvent.click(header);
|
|
401
403
|
expect(header).toHaveAttribute('aria-expanded', 'true');
|
|
402
|
-
// Expanded again: inert is removed and the scroll container is a
|
|
403
|
-
//
|
|
404
|
+
// Expanded again: inert is removed and the scroll container is a named
|
|
405
|
+
// group once more (its tab stop follows overflow, see the browser spec).
|
|
404
406
|
expect(region).not.toHaveAttribute('inert');
|
|
405
407
|
const scrollContainer = screen.getByRole('group');
|
|
406
408
|
expect(scrollContainer.closest('[inert]')).toBeNull();
|
|
407
|
-
expect(scrollContainer).toHaveAttribute('
|
|
409
|
+
expect(scrollContainer).toHaveAttribute('data-scroll-axis', 'both');
|
|
408
410
|
});
|
|
409
411
|
|
|
410
412
|
it('applies a per-instance syntax theme via the syntaxTheme prop', () => {
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
} from '../theme/tokens.stylex';
|
|
35
35
|
import {mergeProps} from '../utils';
|
|
36
36
|
import {useClipboard} from '../hooks/useClipboard';
|
|
37
|
+
import {useScrollableArea} from '../hooks/useScrollableArea';
|
|
37
38
|
import {Icon} from '../Icon';
|
|
38
39
|
import {IconButton} from '../IconButton';
|
|
39
40
|
import {
|
|
@@ -117,6 +118,13 @@ const styles = stylex.create({
|
|
|
117
118
|
backgroundColor: 'var(--color-syntax-background)',
|
|
118
119
|
overflow: 'hidden',
|
|
119
120
|
},
|
|
121
|
+
// The viewport is a flex item of the column root. Its implied min-height
|
|
122
|
+
// would be its content while it fits (the shared capability keeps it
|
|
123
|
+
// `overflow: clip` then), so a root sized from outside could never make it
|
|
124
|
+
// shrink and scroll; 0 lets the root's bound reach it.
|
|
125
|
+
viewport: {
|
|
126
|
+
minHeight: 0,
|
|
127
|
+
},
|
|
120
128
|
headerRow: {
|
|
121
129
|
display: 'flex',
|
|
122
130
|
alignItems: 'center',
|
|
@@ -160,10 +168,6 @@ const styles = stylex.create({
|
|
|
160
168
|
margin: 0,
|
|
161
169
|
lineHeight: typeScaleVars['--text-supporting-leading'],
|
|
162
170
|
},
|
|
163
|
-
scrollContainer: {
|
|
164
|
-
overflowX: 'auto',
|
|
165
|
-
overflowY: 'auto',
|
|
166
|
-
},
|
|
167
171
|
codeWrapper: {
|
|
168
172
|
display: 'flex',
|
|
169
173
|
minWidth: 'fit-content',
|
|
@@ -800,7 +804,19 @@ export function CodeBlock({
|
|
|
800
804
|
// this is always a resolvable reference — aria-controls can be unconditional.
|
|
801
805
|
const regionId = useId();
|
|
802
806
|
|
|
803
|
-
|
|
807
|
+
// The code viewport enters the Tab order only while its content actually
|
|
808
|
+
// overflows (AST-025 FR12–FR13): the shared scroll capability measures
|
|
809
|
+
// both axes, keeps focus where it is when overflow goes away, and owns the
|
|
810
|
+
// overflow declarations. role="group" (not "region") so several code
|
|
811
|
+
// blocks on a page don't create duplicate same-named landmarks.
|
|
812
|
+
const {getViewportProps, getContentProps} = useScrollableArea({
|
|
813
|
+
axis: 'both',
|
|
814
|
+
keyboardAccess: {
|
|
815
|
+
owner: 'viewport',
|
|
816
|
+
role: 'group',
|
|
817
|
+
label: languageLabel ?? t('@astryx.codeBlock.code'),
|
|
818
|
+
},
|
|
819
|
+
});
|
|
804
820
|
const scrollStyle: CSSProperties | undefined =
|
|
805
821
|
maxHeight != null
|
|
806
822
|
? {
|
|
@@ -895,21 +911,16 @@ export function CodeBlock({
|
|
|
895
911
|
|
|
896
912
|
const codeBody = (
|
|
897
913
|
<div
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
// scroll long or wide code that overflows the viewport. Uses
|
|
901
|
-
// role="group" (not "region") so multiple code blocks on a page don't
|
|
902
|
-
// create duplicate same-named landmarks (axe: landmark-unique).
|
|
903
|
-
tabIndex={0}
|
|
904
|
-
role="group"
|
|
905
|
-
aria-label={languageLabel ?? t('@astryx.codeBlock.code')}
|
|
906
|
-
{...mergeProps(stylex.props(styles.scrollContainer), {
|
|
914
|
+
{...getViewportProps<HTMLDivElement>({
|
|
915
|
+
...stylex.props(styles.viewport),
|
|
907
916
|
style: scrollStyle,
|
|
908
917
|
})}>
|
|
909
918
|
<div
|
|
910
|
-
{...
|
|
911
|
-
|
|
912
|
-
|
|
919
|
+
{...getContentProps<HTMLDivElement>(
|
|
920
|
+
stylex.props(
|
|
921
|
+
styles.codeWrapper,
|
|
922
|
+
showHeader && !hasLineNumbers && styles.codeWrapperCompact,
|
|
923
|
+
),
|
|
913
924
|
)}>
|
|
914
925
|
{useSpans ? (
|
|
915
926
|
<SpanCodeContent
|
|
@@ -958,7 +969,7 @@ export function CodeBlock({
|
|
|
958
969
|
// While collapsed, the region is only hidden visually (0fr grid
|
|
959
970
|
// row); inert also removes it from the tab order and accessibility
|
|
960
971
|
// tree so keyboard users cannot Tab into the invisible scroll
|
|
961
|
-
// container (
|
|
972
|
+
// container (a tab stop while it overflows). aria-controls pointing at an inert
|
|
962
973
|
// element remains a valid, resolvable reference.
|
|
963
974
|
inert={isCollapsed ? true : undefined}
|
|
964
975
|
{...stylex.props(
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file CodeBlockOverflowStop.a11y.chromium.spec.ts
|
|
5
|
+
* @input Built CodeBlock story and real Chromium layout
|
|
6
|
+
* @output Browser evidence that the code viewport is a Tab stop only while it overflows
|
|
7
|
+
* @position CodeBlock FR8 / AST-025 FR12–FR13 proof over the shared scroll capability
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {expect, test, type Page} from '@playwright/test';
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_STORYBOOK_DIR,
|
|
13
|
+
serveStorybook,
|
|
14
|
+
type StaticServer,
|
|
15
|
+
} from '@astryxdesign/a11y-spec/storybook';
|
|
16
|
+
|
|
17
|
+
let storybook: StaticServer;
|
|
18
|
+
test.beforeAll(async () => {
|
|
19
|
+
storybook = await serveStorybook(
|
|
20
|
+
process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
test.afterAll(async () => {
|
|
24
|
+
await storybook?.close();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const STORY = 'core-codeblock--overflow-stop';
|
|
28
|
+
const WIDE = 'const wide = "' + 'x'.repeat(400) + '";';
|
|
29
|
+
const TALL = Array.from({length: 40}, (_, i) => `const line${i} = ${i};`).join(
|
|
30
|
+
'\n',
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
async function mount(page: Page) {
|
|
34
|
+
await page.setViewportSize({width: 600, height: 500});
|
|
35
|
+
await page.goto(`${storybook.origin}/iframe.html?id=${STORY}&viewMode=story`);
|
|
36
|
+
await expect(page.getByRole('group', {name: 'tsx'})).toBeVisible();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function setArgs(page: Page, args: Record<string, unknown>) {
|
|
40
|
+
await page.evaluate(
|
|
41
|
+
async ({storyId, updatedArgs}) =>
|
|
42
|
+
new Promise<void>(resolve => {
|
|
43
|
+
const channel = (
|
|
44
|
+
window as unknown as {
|
|
45
|
+
__STORYBOOK_ADDONS_CHANNEL__: {
|
|
46
|
+
emit: (event: string, payload: unknown) => void;
|
|
47
|
+
once: (event: string, handler: () => void) => void;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
).__STORYBOOK_ADDONS_CHANNEL__;
|
|
51
|
+
channel.once('storyRendered', () => resolve());
|
|
52
|
+
channel.emit('updateStoryArgs', {storyId, updatedArgs});
|
|
53
|
+
setTimeout(resolve, 1500);
|
|
54
|
+
}),
|
|
55
|
+
{storyId: STORY, updatedArgs: args},
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function viewport(page: Page) {
|
|
60
|
+
return page.getByRole('group', {name: 'tsx'});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test('a fitting viewport is not a Tab stop', async ({page}) => {
|
|
64
|
+
await mount(page);
|
|
65
|
+
await expect(viewport(page)).not.toHaveAttribute('tabindex', /.*/);
|
|
66
|
+
// Tab from the document start lands on the copy button, never the viewport.
|
|
67
|
+
await page.keyboard.press('Tab');
|
|
68
|
+
const active = await page.evaluate(() =>
|
|
69
|
+
document.activeElement?.getAttribute('role'),
|
|
70
|
+
);
|
|
71
|
+
expect(active).not.toBe('group');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('inline overflow adds the stop; block overflow adds it too', async ({
|
|
75
|
+
page,
|
|
76
|
+
}) => {
|
|
77
|
+
await mount(page);
|
|
78
|
+
await setArgs(page, {code: WIDE});
|
|
79
|
+
await expect(viewport(page)).toHaveAttribute('tabindex', '0');
|
|
80
|
+
await expect(viewport(page)).toHaveAttribute(
|
|
81
|
+
'data-scrollable-inline',
|
|
82
|
+
'true',
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
await setArgs(page, {code: TALL, maxHeight: 160});
|
|
86
|
+
await expect(viewport(page)).toHaveAttribute('tabindex', '0');
|
|
87
|
+
await expect(viewport(page)).toHaveAttribute('data-scrollable-block', 'true');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('losing overflow removes the stop without moving focus', async ({
|
|
91
|
+
page,
|
|
92
|
+
}) => {
|
|
93
|
+
await mount(page);
|
|
94
|
+
await setArgs(page, {code: WIDE});
|
|
95
|
+
await expect(viewport(page)).toHaveAttribute('tabindex', '0');
|
|
96
|
+
await viewport(page).focus();
|
|
97
|
+
await expect(viewport(page)).toBeFocused();
|
|
98
|
+
|
|
99
|
+
await setArgs(page, {code: 'const fits = true;'});
|
|
100
|
+
// Focus stays on the element that had it; the stop is gone for the next Tab.
|
|
101
|
+
await expect(viewport(page)).toBeFocused();
|
|
102
|
+
await expect(viewport(page)).toHaveAttribute('tabindex', '-1');
|
|
103
|
+
// Leaving and coming back by keyboard skips it: it is out of the Tab order.
|
|
104
|
+
await page.keyboard.press('Shift+Tab');
|
|
105
|
+
await page.keyboard.press('Tab');
|
|
106
|
+
await expect(viewport(page)).not.toBeFocused();
|
|
107
|
+
await expect(viewport(page)).not.toHaveAttribute('tabindex', '0');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('a root height set from outside bounds the viewport, which then scrolls', async ({
|
|
111
|
+
page,
|
|
112
|
+
}) => {
|
|
113
|
+
await mount(page);
|
|
114
|
+
// The IDE template's pattern: the block fills a parent of fixed height.
|
|
115
|
+
await setArgs(page, {code: TALL, style: {height: 160}});
|
|
116
|
+
const vp = viewport(page);
|
|
117
|
+
await expect(vp).toHaveAttribute('tabindex', '0');
|
|
118
|
+
await expect(vp).toHaveAttribute('data-scrollable-block', 'true');
|
|
119
|
+
const box = await vp.boundingBox();
|
|
120
|
+
expect(box?.height ?? 0).toBeLessThan(160);
|
|
121
|
+
const scrollable = await vp.evaluate(
|
|
122
|
+
node => node.scrollHeight > node.clientHeight + 1,
|
|
123
|
+
);
|
|
124
|
+
expect(scrollable).toBe(true);
|
|
125
|
+
});
|
|
@@ -412,9 +412,10 @@ describe('createMarkdownFenceTransform', () => {
|
|
|
412
412
|
expect(createNode).not.toHaveBeenCalled();
|
|
413
413
|
expect(document.querySelector('pre')).toHaveTextContent('plain source');
|
|
414
414
|
expect(screen.getByRole('button', {name: 'Copy code'})).toBeInTheDocument();
|
|
415
|
+
// CodeBlock's named viewport; its tab stop follows overflow (none in jsdom).
|
|
415
416
|
expect(screen.getByRole('group', {name: 'text'})).toHaveAttribute(
|
|
416
|
-
'
|
|
417
|
-
'
|
|
417
|
+
'data-scroll-axis',
|
|
418
|
+
'both',
|
|
418
419
|
);
|
|
419
420
|
|
|
420
421
|
rerender(
|