@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.
@@ -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;AA6B5C,OAAO,EAAc,KAAK,qBAAqB,EAAC,MAAM,iBAAiB,CAAC;AA4WxE,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,qBAmPhB;yBA3Qe,SAAS"}
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
- const scrollContainerRef = useRef(null);
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
- ref: scrollContainerRef
574
- // The scroll container is keyboard-focusable so keyboard users can
575
- // scroll long or wide code that overflows the viewport. Uses
576
- // role="group" (not "region") so multiple code blocks on a page don't
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 (tabIndex=0). aria-controls pointing at an inert
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.bc0261a",
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.bc0261a",
791
- "@astryxdesign/cli": "0.7.1-canary.bc0261a",
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). The current implementation still gives every code viewport a keyboard stop even when it does not overflow, contrary to `spec:AST-025/FR12`; that retained implementation gap is not accepted here. |
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; implementation gap |
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. The retained `spec:AST-025/FR12` integration gap has a current owner and required outcome; it is implementation work, not an unresolved product decision.
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('makes the scroll container keyboard-focusable', () => {
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', '0');
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 keyboard-focusable scroll
375
- // container (tabIndex=0) inside it drops out of the tab order and the
376
- // accessibility tree instead of remaining an invisible tab stop.
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
- // keyboard-focusable group once more.
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('tabindex', '0');
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
- const scrollContainerRef = useRef<HTMLDivElement>(null);
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
- ref={scrollContainerRef}
899
- // The scroll container is keyboard-focusable so keyboard users can
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
- {...stylex.props(
911
- styles.codeWrapper,
912
- showHeader && !hasLineNumbers && styles.codeWrapperCompact,
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 (tabIndex=0). aria-controls pointing at an inert
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
- 'tabindex',
417
- '0',
417
+ 'data-scroll-axis',
418
+ 'both',
418
419
  );
419
420
 
420
421
  rerender(