@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.
- package/dist/BottomSheet/BottomSheet.d.ts +1 -1
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +12 -2
- package/dist/CodeBlock/CodeBlock.d.ts +10 -1
- package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
- package/dist/CodeBlock/CodeBlock.js +158 -38
- package/dist/CodeBlock/tokenizer.d.ts +39 -0
- package/dist/CodeBlock/tokenizer.d.ts.map +1 -1
- package/dist/CodeBlock/tokenizer.js +108 -0
- package/dist/ContextMenu/ContextMenu.d.ts +1 -1
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +22 -8
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +3 -26
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +10 -3
- package/dist/Markdown/Markdown.d.ts.map +1 -1
- package/dist/Markdown/Markdown.js +19 -3
- package/dist/Typeahead/BaseTypeahead.d.ts +1 -1
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +12 -3
- package/dist/astryx.css +13 -0
- package/dist/hooks/useIsomorphicLayoutEffect.d.ts +2 -2
- package/dist/hooks/useIsomorphicLayoutEffect.d.ts.map +1 -1
- package/dist/theme/syntax/SyntaxTheme.d.ts.map +1 -1
- package/dist/theme/syntax/SyntaxTheme.js +43 -2
- package/dist/theme/syntax/defineSyntaxTheme.d.ts +18 -5
- package/dist/theme/syntax/defineSyntaxTheme.d.ts.map +1 -1
- package/dist/theme/syntax/defineSyntaxTheme.js +38 -9
- package/dist/theme/syntax/index.d.ts +1 -1
- package/dist/theme/syntax/index.d.ts.map +1 -1
- package/dist/theme/syntax/tokens.d.ts +12 -2
- package/dist/theme/syntax/tokens.d.ts.map +1 -1
- package/dist/theme/syntax/tokens.js +25 -4
- package/dist/utils/focusReturn.d.ts +12 -8
- package/dist/utils/focusReturn.d.ts.map +1 -1
- package/dist/utils/focusReturn.js +44 -3
- package/package.json +4 -4
- package/src/BottomSheet/BottomSheet.test.tsx +114 -0
- package/src/BottomSheet/BottomSheet.tsx +12 -2
- package/src/BottomSheet/__tests__/BottomSheetFocusReturn.a11y.browser.spec.ts +96 -0
- package/src/Chat/__tests__/ChatLayoutScrollButtonSurface.a11y.chromium.spec.ts +5 -6
- package/src/CodeBlock/CodeBlock.doc.mjs +7 -0
- package/src/CodeBlock/CodeBlock.spec.md +55 -45
- package/src/CodeBlock/CodeBlock.test.tsx +89 -9
- package/src/CodeBlock/CodeBlock.tsx +205 -25
- package/src/CodeBlock/__tests__/CodeBlockOverflowStop.a11y.chromium.spec.ts +125 -0
- package/src/CodeBlock/__tests__/DiffRowPaint.a11y.chromium.spec.ts +93 -0
- package/src/CodeBlock/tokenizer.test.ts +112 -0
- package/src/CodeBlock/tokenizer.ts +106 -0
- package/src/ContextMenu/ContextMenu.spec.md +1 -1
- package/src/ContextMenu/ContextMenu.test.tsx +36 -3
- package/src/ContextMenu/ContextMenu.tsx +27 -11
- package/src/ContextMenu/__tests__/ContextMenuCursorAnchor.a11y.browser.spec.ts +153 -0
- package/src/Dialog/Dialog.tsx +2 -30
- package/src/Dialog/__tests__/Dialog.a11y.chromium.spec.ts +21 -14
- package/src/DropdownMenu/DropdownMenu.test.tsx +97 -1
- package/src/DropdownMenu/DropdownMenu.tsx +10 -3
- package/src/DropdownMenu/__tests__/DropdownMenuFocusReturn.a11y.browser.spec.ts +73 -0
- package/src/FieldStatus/__tests__/StatusMessage.a11y.chromium.spec.ts +21 -14
- package/src/Markdown/Markdown.spec.md +37 -23
- package/src/Markdown/Markdown.test.tsx +38 -0
- package/src/Markdown/Markdown.tsx +20 -2
- package/src/Markdown/plugins/semanticFence.test.tsx +3 -2
- package/src/NumberInput/__tests__/NumberInput.a11y.chromium.spec.ts +22 -15
- package/src/PowerSearch/PowerSearch.test.tsx +45 -0
- package/src/Switch/__tests__/Switch.a11y.chromium.spec.ts +25 -18
- package/src/TabList/__tests__/Tabs.a11y.chromium.spec.ts +19 -12
- package/src/TextInput/__tests__/TextInput.a11y.chromium.spec.ts +21 -14
- package/src/ToggleButton/__tests__/ToggleButton.a11y.chromium.spec.ts +18 -11
- package/src/Typeahead/BaseTypeahead.test.tsx +99 -1
- package/src/Typeahead/BaseTypeahead.tsx +17 -3
- package/src/theme/syntax/SyntaxTheme.tsx +46 -5
- package/src/theme/syntax/defineSyntaxTheme.diff.test.ts +91 -0
- package/src/theme/syntax/defineSyntaxTheme.ts +75 -15
- package/src/theme/syntax/index.ts +5 -1
- package/src/theme/syntax/tokens.ts +32 -2
- package/src/theme/syntax/useSyntaxTheme.diff.test.tsx +40 -0
- package/src/utils/focusReturn.test.ts +79 -2
- 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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
779
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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), {
|
|
1080
|
+
{...getViewportProps<HTMLDivElement>({
|
|
1081
|
+
...stylex.props(styles.viewport),
|
|
907
1082
|
style: scrollStyle,
|
|
908
1083
|
})}>
|
|
909
1084
|
<div
|
|
910
|
-
{...
|
|
911
|
-
|
|
912
|
-
|
|
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 (
|
|
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
|
+
});
|