@astryxdesign/core 0.7.0 → 0.7.1-canary.0365e23

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.
Files changed (80) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts +1 -1
  2. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  3. package/dist/BottomSheet/BottomSheet.js +12 -2
  4. package/dist/CodeBlock/CodeBlock.d.ts +10 -1
  5. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  6. package/dist/CodeBlock/CodeBlock.js +158 -38
  7. package/dist/CodeBlock/tokenizer.d.ts +39 -0
  8. package/dist/CodeBlock/tokenizer.d.ts.map +1 -1
  9. package/dist/CodeBlock/tokenizer.js +108 -0
  10. package/dist/ContextMenu/ContextMenu.d.ts +1 -1
  11. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  12. package/dist/ContextMenu/ContextMenu.js +22 -8
  13. package/dist/Dialog/Dialog.d.ts.map +1 -1
  14. package/dist/Dialog/Dialog.js +3 -26
  15. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  16. package/dist/DropdownMenu/DropdownMenu.js +10 -3
  17. package/dist/Markdown/Markdown.d.ts.map +1 -1
  18. package/dist/Markdown/Markdown.js +19 -3
  19. package/dist/Typeahead/BaseTypeahead.d.ts +1 -1
  20. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  21. package/dist/Typeahead/BaseTypeahead.js +12 -3
  22. package/dist/astryx.css +13 -0
  23. package/dist/hooks/useIsomorphicLayoutEffect.d.ts +2 -2
  24. package/dist/hooks/useIsomorphicLayoutEffect.d.ts.map +1 -1
  25. package/dist/theme/syntax/SyntaxTheme.d.ts.map +1 -1
  26. package/dist/theme/syntax/SyntaxTheme.js +43 -2
  27. package/dist/theme/syntax/defineSyntaxTheme.d.ts +18 -5
  28. package/dist/theme/syntax/defineSyntaxTheme.d.ts.map +1 -1
  29. package/dist/theme/syntax/defineSyntaxTheme.js +38 -9
  30. package/dist/theme/syntax/index.d.ts +1 -1
  31. package/dist/theme/syntax/index.d.ts.map +1 -1
  32. package/dist/theme/syntax/tokens.d.ts +12 -2
  33. package/dist/theme/syntax/tokens.d.ts.map +1 -1
  34. package/dist/theme/syntax/tokens.js +25 -4
  35. package/dist/utils/focusReturn.d.ts +12 -8
  36. package/dist/utils/focusReturn.d.ts.map +1 -1
  37. package/dist/utils/focusReturn.js +44 -3
  38. package/package.json +4 -4
  39. package/src/BottomSheet/BottomSheet.test.tsx +114 -0
  40. package/src/BottomSheet/BottomSheet.tsx +12 -2
  41. package/src/BottomSheet/__tests__/BottomSheetFocusReturn.a11y.browser.spec.ts +96 -0
  42. package/src/Chat/__tests__/ChatLayoutScrollButtonSurface.a11y.chromium.spec.ts +5 -6
  43. package/src/CodeBlock/CodeBlock.doc.mjs +7 -0
  44. package/src/CodeBlock/CodeBlock.spec.md +55 -45
  45. package/src/CodeBlock/CodeBlock.test.tsx +89 -9
  46. package/src/CodeBlock/CodeBlock.tsx +205 -25
  47. package/src/CodeBlock/__tests__/CodeBlockOverflowStop.a11y.chromium.spec.ts +125 -0
  48. package/src/CodeBlock/__tests__/DiffRowPaint.a11y.chromium.spec.ts +93 -0
  49. package/src/CodeBlock/tokenizer.test.ts +112 -0
  50. package/src/CodeBlock/tokenizer.ts +106 -0
  51. package/src/ContextMenu/ContextMenu.spec.md +1 -1
  52. package/src/ContextMenu/ContextMenu.test.tsx +36 -3
  53. package/src/ContextMenu/ContextMenu.tsx +27 -11
  54. package/src/ContextMenu/__tests__/ContextMenuCursorAnchor.a11y.browser.spec.ts +153 -0
  55. package/src/Dialog/Dialog.tsx +2 -30
  56. package/src/Dialog/__tests__/Dialog.a11y.chromium.spec.ts +21 -14
  57. package/src/DropdownMenu/DropdownMenu.test.tsx +97 -1
  58. package/src/DropdownMenu/DropdownMenu.tsx +10 -3
  59. package/src/DropdownMenu/__tests__/DropdownMenuFocusReturn.a11y.browser.spec.ts +73 -0
  60. package/src/FieldStatus/__tests__/StatusMessage.a11y.chromium.spec.ts +21 -14
  61. package/src/Markdown/Markdown.spec.md +37 -23
  62. package/src/Markdown/Markdown.test.tsx +38 -0
  63. package/src/Markdown/Markdown.tsx +20 -2
  64. package/src/Markdown/plugins/semanticFence.test.tsx +3 -2
  65. package/src/NumberInput/__tests__/NumberInput.a11y.chromium.spec.ts +22 -15
  66. package/src/PowerSearch/PowerSearch.test.tsx +45 -0
  67. package/src/Switch/__tests__/Switch.a11y.chromium.spec.ts +25 -18
  68. package/src/TabList/__tests__/Tabs.a11y.chromium.spec.ts +19 -12
  69. package/src/TextInput/__tests__/TextInput.a11y.chromium.spec.ts +21 -14
  70. package/src/ToggleButton/__tests__/ToggleButton.a11y.chromium.spec.ts +18 -11
  71. package/src/Typeahead/BaseTypeahead.test.tsx +99 -1
  72. package/src/Typeahead/BaseTypeahead.tsx +17 -3
  73. package/src/theme/syntax/SyntaxTheme.tsx +46 -5
  74. package/src/theme/syntax/defineSyntaxTheme.diff.test.ts +91 -0
  75. package/src/theme/syntax/defineSyntaxTheme.ts +75 -15
  76. package/src/theme/syntax/index.ts +5 -1
  77. package/src/theme/syntax/tokens.ts +32 -2
  78. package/src/theme/syntax/useSyntaxTheme.diff.test.tsx +40 -0
  79. package/src/utils/focusReturn.test.ts +79 -2
  80. package/src/utils/focusReturn.ts +48 -3
@@ -34,11 +34,14 @@ 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';
40
+ import type {DiffLineKind} from './tokenizer';
39
41
  import {
40
42
  tokenize,
41
43
  tokenizeAsync,
44
+ splitDiffLines,
42
45
  flatTokensToLines,
43
46
  SYNC_TOKENIZE_THRESHOLD,
44
47
  } from './tokenizer';
@@ -117,6 +120,13 @@ const styles = stylex.create({
117
120
  backgroundColor: 'var(--color-syntax-background)',
118
121
  overflow: 'hidden',
119
122
  },
123
+ // The viewport is a flex item of the column root. Its implied min-height
124
+ // would be its content while it fits (the shared capability keeps it
125
+ // `overflow: clip` then), so a root sized from outside could never make it
126
+ // shrink and scroll; 0 lets the root's bound reach it.
127
+ viewport: {
128
+ minHeight: 0,
129
+ },
120
130
  headerRow: {
121
131
  display: 'flex',
122
132
  alignItems: 'center',
@@ -160,10 +170,6 @@ const styles = stylex.create({
160
170
  margin: 0,
161
171
  lineHeight: typeScaleVars['--text-supporting-leading'],
162
172
  },
163
- scrollContainer: {
164
- overflowX: 'auto',
165
- overflowY: 'auto',
166
- },
167
173
  codeWrapper: {
168
174
  display: 'flex',
169
175
  minWidth: 'fit-content',
@@ -228,6 +234,10 @@ const styles = stylex.create({
228
234
  code: {
229
235
  display: 'block',
230
236
  flex: 1,
237
+ // The block grows to its longest line, so a row's paint (diff tint,
238
+ // highlightLines) spans the full scroll width instead of stopping at the
239
+ // viewport edge when the reader scrolls a long line.
240
+ minWidth: 'max-content',
231
241
  paddingBlock: spacingVars['--spacing-3'],
232
242
  paddingInline: spacingVars['--spacing-4'],
233
243
  margin: 0,
@@ -239,6 +249,8 @@ const styles = stylex.create({
239
249
  overflowWrap: 'normal',
240
250
  },
241
251
  codeWrapped: {
252
+ // Wrapped lines never overflow, and max-content would stop them wrapping.
253
+ minWidth: 'auto',
242
254
  whiteSpace: 'pre-wrap',
243
255
  wordBreak: 'break-all',
244
256
  overflowWrap: 'break-word',
@@ -298,6 +310,75 @@ const styles = stylex.create({
298
310
  marginInline: `calc(-1 * ${spacingVars['--spacing-4']})`,
299
311
  paddingInline: spacingVars['--spacing-4'],
300
312
  },
313
+ // Diff change rows: the whole row is tinted so added and removed lines read
314
+ // at a glance; the text keeps its ordinary colour, which stays readable on
315
+ // the tint where the semantic text colours would not, and the leading +/-
316
+ // glyph stays in the text as the cue that needs no colour. A caller's
317
+ // highlightLines row is declared after these and keeps its background.
318
+ // A changed row's margin reads the change: the tint runs under the gutter
319
+ // cell too (the row spans the full width), and the line number and the
320
+ // glyph take the row's marker slot. The code — tokens and plain runs alike
321
+ // — keeps the syntax theme's colours, so an identifier looks the same on a
322
+ // changed row as on a context row.
323
+ lineAdded: {
324
+ backgroundColor: 'var(--color-syntax-added)',
325
+ marginInline: `calc(-1 * ${spacingVars['--spacing-4']})`,
326
+ paddingInline: spacingVars['--spacing-4'],
327
+ '::before': {color: 'var(--color-syntax-added-marker)'},
328
+ },
329
+ lineRemoved: {
330
+ backgroundColor: 'var(--color-syntax-removed)',
331
+ marginInline: `calc(-1 * ${spacingVars['--spacing-4']})`,
332
+ paddingInline: spacingVars['--spacing-4'],
333
+ '::before': {color: 'var(--color-syntax-removed-marker)'},
334
+ },
335
+ // Hunk and file headers stay visible but quiet: a muted row with secondary
336
+ // text, never change paint.
337
+ lineDiffHeader: {
338
+ backgroundColor: 'var(--color-syntax-diff-header)',
339
+ // The syntax theme's own secondary colour: text-secondary in the default
340
+ // theme, the theme's punctuation colour in a defined one.
341
+ color: 'var(--color-syntax-punctuation)',
342
+ marginInline: `calc(-1 * ${spacingVars['--spacing-4']})`,
343
+ paddingInline: spacingVars['--spacing-4'],
344
+ },
345
+ // The glyph column: the +, - or space of every line sits in its own track
346
+ // before the code, so the code column starts at the same inline offset on
347
+ // every line and the content's indentation reads exactly as written. The
348
+ // glyph is laid out as text but not selectable; the copy button still
349
+ // copies the original source with its glyphs.
350
+ lineDiff: {
351
+ display: 'grid',
352
+ gridTemplateColumns: '1ch 1fr',
353
+ columnGap: spacingVars['--spacing-2'],
354
+ },
355
+ // With line numbers the gutter's gap (which the full-height divider sits
356
+ // in) has to stay between the number and the marker, so the grid has no
357
+ // gap of its own and the marker carries both gaps as margins.
358
+ lineDiffNumbered: {
359
+ // `auto`, not `1ch`: the marker's gap margins have to widen its track.
360
+ gridTemplateColumns: 'var(--_codeblock-gutter-width) auto 1fr',
361
+ columnGap: 0,
362
+ },
363
+ // The glyph is the source's own text: read by assistive technology, kept
364
+ // out of a drag selection (copy returns the exact source anyway).
365
+ diffMarker: {
366
+ color: colorVars['--color-text-secondary'],
367
+ userSelect: 'none',
368
+ whiteSpace: 'pre',
369
+ },
370
+ diffMarkerNumbered: {
371
+ marginInlineStart: `calc(${spacingVars['--spacing-3']} + ${borderVars['--border-width']} + ${spacingVars['--spacing-4']})`,
372
+ marginInlineEnd: spacingVars['--spacing-2'],
373
+ },
374
+ // On a tinted row the glyph is the cue a reader without colour depends on,
375
+ // so it takes the row's marker slot like the line number does.
376
+ diffMarkerAdded: {
377
+ color: 'var(--color-syntax-added-marker)',
378
+ },
379
+ diffMarkerRemoved: {
380
+ color: 'var(--color-syntax-removed-marker)',
381
+ },
301
382
  sizeSm: {
302
383
  fontSize: typeScaleVars['--text-supporting-size'],
303
384
  },
@@ -332,12 +413,14 @@ const CodeChunk = React.memo(function CodeChunk({
332
413
  lines,
333
414
  startIndex,
334
415
  highlightSet,
416
+ diffKinds,
335
417
  renderLineContent,
336
418
  lineNumbers,
337
419
  }: {
338
420
  lines: string[];
339
421
  startIndex: number;
340
422
  highlightSet: Set<number> | null;
423
+ diffKinds: ReadonlyArray<DiffLineKind> | null;
341
424
  renderLineContent: (line: string, lineIndex: number) => React.ReactNode;
342
425
  lineNumbers: boolean;
343
426
  }) {
@@ -345,13 +428,22 @@ const CodeChunk = React.memo(function CodeChunk({
345
428
  <>
346
429
  {lines.map((line, j) => {
347
430
  const i = startIndex + j;
431
+ const diffKind = diffKinds?.[i];
348
432
  return (
349
433
  <div
350
434
  key={i}
351
435
  data-line={i + 1}
436
+ data-diff={diffKind === 'context' ? undefined : diffKind}
352
437
  {...stylex.props(
353
438
  styles.line,
354
439
  lineNumbers && styles.lineNumbered,
440
+ diffKinds != null && styles.lineDiff,
441
+ diffKinds != null && lineNumbers && styles.lineDiffNumbered,
442
+ diffKind === 'added' && styles.lineAdded,
443
+ diffKind === 'removed' && styles.lineRemoved,
444
+ diffKind === 'header' && styles.lineDiffHeader,
445
+ // Last on purpose: a highlight replaces a diff tint rather than
446
+ // stacking on it, so tokens keep their single-paint contrast.
355
447
  (highlightSet?.has(i + 1) ?? false) && styles.lineHighlighted,
356
448
  )}>
357
449
  {renderLineContent(line, i)}
@@ -365,6 +457,7 @@ const CodeChunk = React.memo(function CodeChunk({
365
457
  function renderLines(
366
458
  lines: string[],
367
459
  highlightSet: Set<number> | null,
460
+ diffKinds: ReadonlyArray<DiffLineKind> | null,
368
461
  renderLineContent: (line: string, lineIndex: number) => React.ReactNode,
369
462
  lineNumbers: boolean,
370
463
  chunkSize: number = LINE_CHUNK_SIZE,
@@ -377,6 +470,7 @@ function renderLines(
377
470
  lines={lines}
378
471
  startIndex={0}
379
472
  highlightSet={highlightSet}
473
+ diffKinds={diffKinds}
380
474
  renderLineContent={renderLineContent}
381
475
  lineNumbers={lineNumbers}
382
476
  />
@@ -399,6 +493,7 @@ function renderLines(
399
493
  lines={chunkLines}
400
494
  startIndex={start}
401
495
  highlightSet={highlightSet}
496
+ diffKinds={diffKinds}
402
497
  renderLineContent={renderLineContent}
403
498
  lineNumbers={lineNumbers}
404
499
  />
@@ -420,6 +515,15 @@ export interface CodeBlockProps extends BaseProps<HTMLPreElement> {
420
515
  hasLanguageLabel?: boolean;
421
516
  hasLineNumbers?: boolean;
422
517
  highlightLines?: number[];
518
+ /**
519
+ * Render the code as a unified diff over `language`: lines starting with
520
+ * `+` are added, `-` removed, `@@` is a hunk header (a `--- `/`+++ ` pair
521
+ * directly before one is a file header), anything else is context; the
522
+ * label reads `diff` or `<language> diff`. The glyph sits in its own column and the rest of every line
523
+ * keeps the language's syntax highlighting. Copy still returns the exact
524
+ * source, glyphs included.
525
+ */
526
+ isDiff?: boolean;
423
527
  hasCopyButton?: boolean;
424
528
  onCopy?: () => void;
425
529
  isWrapped?: boolean;
@@ -594,6 +698,8 @@ function SpanCodeContent({
594
698
  lines,
595
699
  tokenLines,
596
700
  highlightSet,
701
+ diffKinds,
702
+ diff,
597
703
  isWrapped,
598
704
  sizeStyle,
599
705
  hasLineNumbers,
@@ -602,6 +708,8 @@ function SpanCodeContent({
602
708
  lines: string[];
603
709
  tokenLines: TokenLine[];
604
710
  highlightSet: Set<number> | null;
711
+ diffKinds: ReadonlyArray<DiffLineKind> | null;
712
+ diff: ReturnType<typeof splitDiffLines> | null;
605
713
  isWrapped: boolean;
606
714
  sizeStyle: stylex.StyleXStyles;
607
715
  hasLineNumbers: boolean;
@@ -614,6 +722,31 @@ function SpanCodeContent({
614
722
  const renderLineContent = useCallback(
615
723
  (line: string, lineIndex: number): React.ReactNode => {
616
724
  const tokens = tokenLines[lineIndex] ?? [];
725
+ if (diff) {
726
+ // Header rows show their raw text; every other row shows its glyph in
727
+ // the marker column and the base-language tokens of the rest.
728
+ const kind = diff.kinds[lineIndex];
729
+ const content = kind === 'header' ? line : diff.content[lineIndex];
730
+ return (
731
+ <>
732
+ <span
733
+ data-diff-marker=""
734
+ {...stylex.props(
735
+ styles.diffMarker,
736
+ hasLineNumbers && styles.diffMarkerNumbered,
737
+ kind === 'added' && styles.diffMarkerAdded,
738
+ kind === 'removed' && styles.diffMarkerRemoved,
739
+ )}>
740
+ {diff.glyphs[lineIndex] || ' '}
741
+ </span>
742
+ <span {...stylex.props(styles.lineContent)}>
743
+ {kind === 'header'
744
+ ? content || '\u200b'
745
+ : buildSpanLine(content, tokens)}
746
+ </span>
747
+ </>
748
+ );
749
+ }
617
750
  // Wrap tokens in a single element so they occupy one grid cell when line
618
751
  // numbers are on (see `lineNumbered`); an inline span is a no-op when off.
619
752
  return (
@@ -622,7 +755,7 @@ function SpanCodeContent({
622
755
  </span>
623
756
  );
624
757
  },
625
- [tokenLines],
758
+ [tokenLines, diff, hasLineNumbers],
626
759
  );
627
760
 
628
761
  return (
@@ -634,7 +767,13 @@ function SpanCodeContent({
634
767
  hasLineNumbers && styles.codeNumbered,
635
768
  hasLineNumbers && dynamicStyles.gutterWidth(maxDigits),
636
769
  )}>
637
- {renderLines(lines, highlightSet, renderLineContent, hasLineNumbers)}
770
+ {renderLines(
771
+ lines,
772
+ highlightSet,
773
+ diffKinds,
774
+ renderLineContent,
775
+ hasLineNumbers,
776
+ )}
638
777
  </code>
639
778
  );
640
779
  }
@@ -647,6 +786,7 @@ function RangeCodeContent({
647
786
  lines,
648
787
  tokenLines,
649
788
  highlightSet,
789
+ diffKinds,
650
790
  isWrapped,
651
791
  sizeStyle,
652
792
  hasLineNumbers,
@@ -655,6 +795,7 @@ function RangeCodeContent({
655
795
  lines: string[];
656
796
  tokenLines: TokenLine[];
657
797
  highlightSet: Set<number> | null;
798
+ diffKinds: ReadonlyArray<DiffLineKind> | null;
658
799
  isWrapped: boolean;
659
800
  sizeStyle: stylex.StyleXStyles;
660
801
  hasLineNumbers: boolean;
@@ -694,7 +835,13 @@ function RangeCodeContent({
694
835
  hasLineNumbers && styles.codeNumbered,
695
836
  hasLineNumbers && dynamicStyles.gutterWidth(maxDigits),
696
837
  )}>
697
- {renderLines(lines, highlightSet, renderLineContent, hasLineNumbers)}
838
+ {renderLines(
839
+ lines,
840
+ highlightSet,
841
+ diffKinds,
842
+ renderLineContent,
843
+ hasLineNumbers,
844
+ )}
698
845
  </code>
699
846
  );
700
847
  }
@@ -719,6 +866,7 @@ export function CodeBlock({
719
866
  hasLanguageLabel = true,
720
867
  hasLineNumbers = false,
721
868
  highlightLines,
869
+ isDiff = false,
722
870
  hasCopyButton = true,
723
871
  onCopy,
724
872
  isWrapped = false,
@@ -746,6 +894,7 @@ export function CodeBlock({
746
894
  });
747
895
 
748
896
  const useSpans =
897
+ isDiff ||
749
898
  highlightMode === 'spans' ||
750
899
  (highlightMode === 'auto' && !hasHighlightAPI()) ||
751
900
  (highlightMode === 'auto' && isSafari());
@@ -758,8 +907,19 @@ export function CodeBlock({
758
907
  return l;
759
908
  }, [code]);
760
909
 
761
- const tokenLines = useTokenLines(code, language, customTokenizer);
762
-
910
+ // In diff mode the base language sees the code with the glyph column set
911
+ // aside, so a `tsx` diff still highlights as `tsx` on every line; headers
912
+ // contribute an empty line and are rendered as their raw text.
913
+ const diff = useMemo(
914
+ () => (isDiff ? splitDiffLines(lines) : null),
915
+ [isDiff, lines],
916
+ );
917
+ const tokenLines = useTokenLines(
918
+ diff ? diff.content.join('\n') : code,
919
+ language,
920
+ customTokenizer,
921
+ );
922
+ const diffKinds = diff?.kinds ?? null;
763
923
  const highlightSet = useMemo(
764
924
  () => (highlightLines ? new Set(highlightLines) : null),
765
925
  [highlightLines],
@@ -775,8 +935,18 @@ export function CodeBlock({
775
935
  const sizeStyle = size === 'sm' ? styles.sizeSm : styles.sizeMd;
776
936
  // Digits in the largest line number — sizes the gutter column width.
777
937
  const maxLineDigits = String(lines.length).length;
778
- const languageLabel =
779
- hasLanguageLabel && language !== 'plaintext' ? language : null;
938
+ // With the diff layer on, the label and viewport name say so: `diff` over
939
+ // plaintext, `tsx diff` over a language — so a diff keeps its header,
940
+ // collapse control, and a name that still matches a `diff` fence.
941
+ const languageLabel = !hasLanguageLabel
942
+ ? null
943
+ : isDiff
944
+ ? language === 'plaintext'
945
+ ? 'diff'
946
+ : `${language} diff`
947
+ : language !== 'plaintext'
948
+ ? language
949
+ : null;
780
950
  const showHeader = title != null || languageLabel != null;
781
951
 
782
952
  // A collapsed body must never outlive the visible header that controls it.
@@ -800,7 +970,19 @@ export function CodeBlock({
800
970
  // this is always a resolvable reference — aria-controls can be unconditional.
801
971
  const regionId = useId();
802
972
 
803
- const scrollContainerRef = useRef<HTMLDivElement>(null);
973
+ // The code viewport enters the Tab order only while its content actually
974
+ // overflows (AST-025 FR12–FR13): the shared scroll capability measures
975
+ // both axes, keeps focus where it is when overflow goes away, and owns the
976
+ // overflow declarations. role="group" (not "region") so several code
977
+ // blocks on a page don't create duplicate same-named landmarks.
978
+ const {getViewportProps, getContentProps} = useScrollableArea({
979
+ axis: 'both',
980
+ keyboardAccess: {
981
+ owner: 'viewport',
982
+ role: 'group',
983
+ label: languageLabel ?? t('@astryx.codeBlock.code'),
984
+ },
985
+ });
804
986
  const scrollStyle: CSSProperties | undefined =
805
987
  maxHeight != null
806
988
  ? {
@@ -895,27 +1077,24 @@ export function CodeBlock({
895
1077
 
896
1078
  const codeBody = (
897
1079
  <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), {
1080
+ {...getViewportProps<HTMLDivElement>({
1081
+ ...stylex.props(styles.viewport),
907
1082
  style: scrollStyle,
908
1083
  })}>
909
1084
  <div
910
- {...stylex.props(
911
- styles.codeWrapper,
912
- showHeader && !hasLineNumbers && styles.codeWrapperCompact,
1085
+ {...getContentProps<HTMLDivElement>(
1086
+ stylex.props(
1087
+ styles.codeWrapper,
1088
+ showHeader && !hasLineNumbers && styles.codeWrapperCompact,
1089
+ ),
913
1090
  )}>
914
1091
  {useSpans ? (
915
1092
  <SpanCodeContent
916
1093
  lines={lines}
917
1094
  tokenLines={tokenLines}
918
1095
  highlightSet={highlightSet}
1096
+ diffKinds={diffKinds}
1097
+ diff={diff}
919
1098
  isWrapped={isWrapped}
920
1099
  sizeStyle={sizeStyle}
921
1100
  hasLineNumbers={hasLineNumbers}
@@ -926,6 +1105,7 @@ export function CodeBlock({
926
1105
  lines={lines}
927
1106
  tokenLines={tokenLines}
928
1107
  highlightSet={highlightSet}
1108
+ diffKinds={diffKinds}
929
1109
  isWrapped={isWrapped}
930
1110
  sizeStyle={sizeStyle}
931
1111
  hasLineNumbers={hasLineNumbers}
@@ -958,7 +1138,7 @@ export function CodeBlock({
958
1138
  // While collapsed, the region is only hidden visually (0fr grid
959
1139
  // row); inert also removes it from the tab order and accessibility
960
1140
  // tree so keyboard users cannot Tab into the invisible scroll
961
- // container (tabIndex=0). aria-controls pointing at an inert
1141
+ // container (a tab stop while it overflows). aria-controls pointing at an inert
962
1142
  // element remains a valid, resolvable reference.
963
1143
  inert={isCollapsed ? true : undefined}
964
1144
  {...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
+ });
@@ -0,0 +1,93 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file DiffRowPaint.a11y.chromium.spec.ts
5
+ * @input Built CodeBlock diff story with lines longer than a phone viewport
6
+ * @output Browser evidence that a changed row's paint spans the full scroll width
7
+ * @position CodeBlock FR9 proof: row paint follows a scrolled line, and wraps
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
+ async function mount(page: Page, wrapped: boolean) {
28
+ await page.setViewportSize({width: 390, height: 700});
29
+ await page.goto(
30
+ `${storybook.origin}/iframe.html?id=core-codeblock--diff-long-lines&viewMode=story&args=isWrapped:${wrapped}`,
31
+ );
32
+ await expect(page.locator('[data-diff="added"]')).toHaveCount(1);
33
+ }
34
+
35
+ /** The painted rows, the scroll container, and the widths that matter. */
36
+ async function measure(page: Page) {
37
+ return page.evaluate(() => {
38
+ const scroller = document.querySelector('[role="group"]') as HTMLElement;
39
+ const rows = ['added', 'removed'].map(kind => {
40
+ const row = document.querySelector(
41
+ `[data-diff="${kind}"]`,
42
+ ) as HTMLElement;
43
+ const rect = row.getBoundingClientRect();
44
+ return {
45
+ kind,
46
+ width: Math.round(rect.width),
47
+ lines: Math.round(
48
+ rect.height / parseFloat(getComputedStyle(row).lineHeight),
49
+ ),
50
+ };
51
+ });
52
+ return {
53
+ clientWidth: scroller.clientWidth,
54
+ scrollWidth: scroller.scrollWidth,
55
+ rows,
56
+ };
57
+ });
58
+ }
59
+
60
+ test("a changed row's paint spans the full scroll width of a long line", async ({
61
+ page,
62
+ }) => {
63
+ await mount(page, false);
64
+ const before = await measure(page);
65
+ // The line really overflows a phone viewport.
66
+ expect(before.scrollWidth).toBeGreaterThan(before.clientWidth + 100);
67
+ // Each painted row is as wide as the scrollable content, not the viewport.
68
+ for (const row of before.rows) {
69
+ expect(row.width).toBeGreaterThanOrEqual(before.scrollWidth - 1);
70
+ }
71
+ // Scrolled to the end, the rows still cover what is on screen.
72
+ await page.evaluate(() => {
73
+ const scroller = document.querySelector('[role="group"]') as HTMLElement;
74
+ scroller.scrollLeft = scroller.scrollWidth;
75
+ });
76
+ const after = await measure(page);
77
+ for (const row of after.rows) {
78
+ expect(row.width).toBeGreaterThanOrEqual(after.scrollWidth - 1);
79
+ }
80
+ });
81
+
82
+ test('in wrapped mode the rows wrap and still paint their full width', async ({
83
+ page,
84
+ }) => {
85
+ await mount(page, true);
86
+ const m = await measure(page);
87
+ // Nothing scrolls: the long line wrapped.
88
+ expect(m.scrollWidth).toBeLessThanOrEqual(m.clientWidth + 1);
89
+ for (const row of m.rows) {
90
+ expect(row.lines).toBeGreaterThan(1);
91
+ expect(row.width).toBeGreaterThanOrEqual(m.clientWidth - 1);
92
+ }
93
+ });