@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
|
@@ -6,6 +6,9 @@ import {
|
|
|
6
6
|
tokenizeAsync,
|
|
7
7
|
tokenizeStreaming,
|
|
8
8
|
flatTokensToLines,
|
|
9
|
+
diffLineKind,
|
|
10
|
+
parseFenceLanguage,
|
|
11
|
+
splitDiffLines,
|
|
9
12
|
} from './tokenizer';
|
|
10
13
|
import type {TokenLine} from './tokenizer';
|
|
11
14
|
|
|
@@ -166,3 +169,112 @@ describe('flatTokensToLines', () => {
|
|
|
166
169
|
expect(result[1]).toContainEqual({type: 'number', start: 8, end: 9});
|
|
167
170
|
});
|
|
168
171
|
});
|
|
172
|
+
|
|
173
|
+
describe('diffLineKind', () => {
|
|
174
|
+
it('classifies unified-diff lines by their first character', () => {
|
|
175
|
+
expect(diffLineKind('+added line')).toBe('added');
|
|
176
|
+
expect(diffLineKind('-removed line')).toBe('removed');
|
|
177
|
+
expect(diffLineKind(' context line')).toBe('context');
|
|
178
|
+
expect(diffLineKind('')).toBe('context');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('keeps hunk headers out of the change rows; a lone --- or +++ is a change', () => {
|
|
182
|
+
expect(diffLineKind('@@ -1,3 +1,4 @@')).toBe('header');
|
|
183
|
+
expect(diffLineKind('--- a/file.ts')).toBe('removed');
|
|
184
|
+
expect(diffLineKind('+++ b/file.ts')).toBe('added');
|
|
185
|
+
// Lines with no glyph are context, whatever tool wrote them.
|
|
186
|
+
expect(diffLineKind('diff --git a/x b/x')).toBe('context');
|
|
187
|
+
expect(diffLineKind('index 83db48f..bf269f4 100644')).toBe('context');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('splits the glyph column from the code the base language sees', () => {
|
|
191
|
+
const {kinds, glyphs, content} = splitDiffLines([
|
|
192
|
+
'@@ -1 +1 @@',
|
|
193
|
+
'- const a = 1;',
|
|
194
|
+
'+ const a = 2;',
|
|
195
|
+
' const b = 3;',
|
|
196
|
+
'',
|
|
197
|
+
]);
|
|
198
|
+
expect(kinds).toEqual(['header', 'removed', 'added', 'context', 'context']);
|
|
199
|
+
expect(glyphs).toEqual(['', '-', '+', ' ', '']);
|
|
200
|
+
expect(content).toEqual([
|
|
201
|
+
'',
|
|
202
|
+
' const a = 1;',
|
|
203
|
+
' const a = 2;',
|
|
204
|
+
' const b = 3;',
|
|
205
|
+
'',
|
|
206
|
+
]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('treats a ---/+++ pair directly before @@ as a file header anywhere, and keeps unindented context whole', () => {
|
|
210
|
+
const {kinds, glyphs, content} = splitDiffLines([
|
|
211
|
+
'--- a/x.ts',
|
|
212
|
+
'+++ b/x.ts',
|
|
213
|
+
'@@ -1 +1 @@',
|
|
214
|
+
'--x;',
|
|
215
|
+
'---',
|
|
216
|
+
'+++y;',
|
|
217
|
+
'plain',
|
|
218
|
+
'--- a/y.ts',
|
|
219
|
+
'+++ b/y.ts',
|
|
220
|
+
'@@ -1 +1 @@',
|
|
221
|
+
'-old',
|
|
222
|
+
]);
|
|
223
|
+
expect(kinds).toEqual([
|
|
224
|
+
'header',
|
|
225
|
+
'header',
|
|
226
|
+
'header',
|
|
227
|
+
'removed',
|
|
228
|
+
'removed',
|
|
229
|
+
'added',
|
|
230
|
+
'context',
|
|
231
|
+
'header',
|
|
232
|
+
'header',
|
|
233
|
+
'header',
|
|
234
|
+
'removed',
|
|
235
|
+
]);
|
|
236
|
+
expect(glyphs.slice(0, 7)).toEqual(['', '', '', '-', '-', '+', '']);
|
|
237
|
+
expect(content.slice(3, 7)).toEqual(['-x;', '--', '++y;', 'plain']);
|
|
238
|
+
// No hunk after the pair: both lines are changes, not headers.
|
|
239
|
+
expect(splitDiffLines(['--- gone', '+++ here', ' same']).kinds).toEqual([
|
|
240
|
+
'removed',
|
|
241
|
+
'added',
|
|
242
|
+
'context',
|
|
243
|
+
]);
|
|
244
|
+
// A hunk ending in `---i;` / `+++i;` right before the next `@@` is code,
|
|
245
|
+
// not a file header: the header form has a space after the signs.
|
|
246
|
+
expect(
|
|
247
|
+
splitDiffLines(['@@ -1 +1 @@', '---i;', '+++i;', '@@ -9 +9 @@', ' x'])
|
|
248
|
+
.kinds,
|
|
249
|
+
).toEqual(['header', 'removed', 'added', 'header', 'context']);
|
|
250
|
+
expect(diffLineKind('--- a/x.ts')).toBe('removed');
|
|
251
|
+
expect(diffLineKind('--- a/x.ts', {isFileHeader: true})).toBe('header');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('tokenizes the code past the glyph with the base language', () => {
|
|
255
|
+
const {content} = splitDiffLines(['-const a = 1;', '+const a = 2;']);
|
|
256
|
+
const lines = tokenize(content.join('\n'), 'typescript');
|
|
257
|
+
expect(lines[0][0]).toEqual({type: 'keyword', start: 0, end: 5});
|
|
258
|
+
expect(lines[1][0]).toEqual({type: 'keyword', start: 0, end: 5});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('reads fence info strings: diff, diff-<lang>, and a plain language (patch is just a language)', () => {
|
|
262
|
+
expect(parseFenceLanguage('diff')).toEqual({
|
|
263
|
+
language: 'plaintext',
|
|
264
|
+
isDiff: true,
|
|
265
|
+
});
|
|
266
|
+
expect(parseFenceLanguage('patch')).toEqual({
|
|
267
|
+
language: 'patch',
|
|
268
|
+
isDiff: false,
|
|
269
|
+
});
|
|
270
|
+
expect(parseFenceLanguage('diff-tsx')).toEqual({
|
|
271
|
+
language: 'tsx',
|
|
272
|
+
isDiff: true,
|
|
273
|
+
});
|
|
274
|
+
expect(parseFenceLanguage('tsx')).toEqual({language: 'tsx', isDiff: false});
|
|
275
|
+
expect(parseFenceLanguage(undefined)).toEqual({
|
|
276
|
+
language: 'plaintext',
|
|
277
|
+
isDiff: false,
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -313,6 +313,112 @@ function buildLanguagePatterns(lang: string): {
|
|
|
313
313
|
}
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// Diff lines
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
/** How a unified-diff line reads: what it adds, removes, frames, or keeps. */
|
|
321
|
+
export type DiffLineKind = 'added' | 'removed' | 'header' | 'context';
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Read a Markdown fence info string the way CodeBlock renders it: `diff` is
|
|
325
|
+
* the diff layer over plaintext, `diff-<lang>` (the common convention) is the
|
|
326
|
+
* diff layer over `<lang>`, anything else is a language.
|
|
327
|
+
*/
|
|
328
|
+
export function parseFenceLanguage(info: string | undefined): {
|
|
329
|
+
language: string;
|
|
330
|
+
isDiff: boolean;
|
|
331
|
+
} {
|
|
332
|
+
const value = (info ?? '').trim();
|
|
333
|
+
if (value === 'diff') {
|
|
334
|
+
return {language: 'plaintext', isDiff: true};
|
|
335
|
+
}
|
|
336
|
+
const layered = /^diff-(.+)$/.exec(value);
|
|
337
|
+
if (layered) {
|
|
338
|
+
return {language: layered[1], isDiff: true};
|
|
339
|
+
}
|
|
340
|
+
return {language: value || 'plaintext', isDiff: false};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Split a diff into the glyph column and the code the base language sees:
|
|
345
|
+
* every added, removed, or space-led context line loses its first character
|
|
346
|
+
* (the `+`, `-`, or space); a context line with no leading space keeps all
|
|
347
|
+
* of it; header lines contribute an empty line so offsets stay per line.
|
|
348
|
+
*/
|
|
349
|
+
export function splitDiffLines(lines: ReadonlyArray<string>): {
|
|
350
|
+
kinds: DiffLineKind[];
|
|
351
|
+
glyphs: string[];
|
|
352
|
+
content: string[];
|
|
353
|
+
} {
|
|
354
|
+
const kinds: DiffLineKind[] = [];
|
|
355
|
+
const glyphs: string[] = [];
|
|
356
|
+
const content: string[] = [];
|
|
357
|
+
const fileHeaders = fileHeaderLines(lines);
|
|
358
|
+
lines.forEach((line, index) => {
|
|
359
|
+
const kind = diffLineKind(line, {isFileHeader: fileHeaders.has(index)});
|
|
360
|
+
kinds.push(kind);
|
|
361
|
+
if (kind === 'header') {
|
|
362
|
+
glyphs.push('');
|
|
363
|
+
content.push('');
|
|
364
|
+
} else if (kind === 'context' && !line.startsWith(' ')) {
|
|
365
|
+
// A context line without its leading space (an unindented diff, or
|
|
366
|
+
// an empty line) has no glyph: the marker column is empty and the
|
|
367
|
+
// whole line is code.
|
|
368
|
+
glyphs.push('');
|
|
369
|
+
content.push(line);
|
|
370
|
+
} else {
|
|
371
|
+
glyphs.push(line.slice(0, 1));
|
|
372
|
+
content.push(line.slice(1));
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
return {kinds, glyphs, content};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Classify one line of a unified diff by its first character: `+` added,
|
|
380
|
+
* `-` removed, `@@` header, anything else context. A `--- ` line (with its
|
|
381
|
+
* space) directly followed by a `+++ ` line and then an `@@` line is a file
|
|
382
|
+
* header (both lines), wherever it occurs; every other `---`/`+++` line is
|
|
383
|
+
* removed or added, so a removed line that starts with `--` — `---i;`
|
|
384
|
+
* included — is never misread. The glyph is part of the text and is never
|
|
385
|
+
* removed.
|
|
386
|
+
*/
|
|
387
|
+
export function diffLineKind(
|
|
388
|
+
line: string,
|
|
389
|
+
{isFileHeader = false}: {isFileHeader?: boolean} = {},
|
|
390
|
+
): DiffLineKind {
|
|
391
|
+
if (line.startsWith('@@') || isFileHeader) {
|
|
392
|
+
return 'header';
|
|
393
|
+
}
|
|
394
|
+
if (line.startsWith('+')) {
|
|
395
|
+
return 'added';
|
|
396
|
+
}
|
|
397
|
+
if (line.startsWith('-')) {
|
|
398
|
+
return 'removed';
|
|
399
|
+
}
|
|
400
|
+
return 'context';
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The indexes of the `--- `/`+++ ` lines (unified-diff form, with the space)
|
|
405
|
+
* that form a file header directly before an `@@`; `---i;` is a change.
|
|
406
|
+
*/
|
|
407
|
+
export function fileHeaderLines(lines: ReadonlyArray<string>): Set<number> {
|
|
408
|
+
const headers = new Set<number>();
|
|
409
|
+
for (let i = 0; i + 2 < lines.length; i++) {
|
|
410
|
+
if (
|
|
411
|
+
lines[i].startsWith('--- ') &&
|
|
412
|
+
lines[i + 1].startsWith('+++ ') &&
|
|
413
|
+
lines[i + 2].startsWith('@@')
|
|
414
|
+
) {
|
|
415
|
+
headers.add(i);
|
|
416
|
+
headers.add(i + 1);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return headers;
|
|
420
|
+
}
|
|
421
|
+
|
|
316
422
|
// ---------------------------------------------------------------------------
|
|
317
423
|
// Core tokenizer
|
|
318
424
|
// ---------------------------------------------------------------------------
|
|
@@ -146,7 +146,7 @@ the highlight is DOM focus per `module:DropdownMenu/useMenuPress` AR1.
|
|
|
146
146
|
| Touch action row | Presents one data-driven touch action or drill-in entry using ListItem. | `component:List` | Prominent | FR1, FR3, FR4 |
|
|
147
147
|
|
|
148
148
|
The `context-menu` target intentionally appears on both alternative painted
|
|
149
|
-
menu surfaces. Trigger area content stays caller-owned and the
|
|
149
|
+
menu surfaces. Trigger area content stays caller-owned and the 1px cursor
|
|
150
150
|
anchor is positioning infrastructure rather than consumer anatomy.
|
|
151
151
|
|
|
152
152
|
### Theming anatomy
|
|
@@ -658,7 +658,7 @@ describe('ContextMenu compound mode', () => {
|
|
|
658
658
|
});
|
|
659
659
|
|
|
660
660
|
describe('cursor anchor positioning (#3465)', () => {
|
|
661
|
-
// The menu is anchored to a
|
|
661
|
+
// The menu is anchored to a 1px element placed at the cursor point
|
|
662
662
|
// *inside the trigger*, so it is positioned relative to the trigger's
|
|
663
663
|
// context (scroll-follow + auto-flip) rather than the viewport.
|
|
664
664
|
function getCursorAnchor(trigger: HTMLElement): HTMLElement {
|
|
@@ -669,7 +669,7 @@ describe('ContextMenu compound mode', () => {
|
|
|
669
669
|
return anchor;
|
|
670
670
|
}
|
|
671
671
|
|
|
672
|
-
it('renders a
|
|
672
|
+
it('renders a 1px cursor anchor inside the trigger', () => {
|
|
673
673
|
render(
|
|
674
674
|
<ContextMenu items={[{label: 'Item 1'}]} data-testid="ctx">
|
|
675
675
|
<div>Right-click me</div>
|
|
@@ -680,6 +680,9 @@ describe('ContextMenu compound mode', () => {
|
|
|
680
680
|
// Carries an anchor-name so the menu can be anchored to it via CSS
|
|
681
681
|
// anchor positioning (context mode), not fixed viewport coordinates.
|
|
682
682
|
expect(anchor.style.anchorName).toMatch(/^--astryx-layer-/);
|
|
683
|
+
// Owns a box: WebKit counts an empty anchor box inside a scroll
|
|
684
|
+
// container as clipped, and `position-visibility` hides the menu.
|
|
685
|
+
expect(anchor).toHaveStyle({width: '1px', height: '1px'});
|
|
683
686
|
});
|
|
684
687
|
|
|
685
688
|
it('places the cursor anchor at the pointer position relative to the trigger', () => {
|
|
@@ -725,7 +728,37 @@ describe('ContextMenu compound mode', () => {
|
|
|
725
728
|
fireEvent.contextMenu(trigger, {clientX: 0, clientY: 0, detail: 0});
|
|
726
729
|
const anchor = getCursorAnchor(trigger);
|
|
727
730
|
expect(anchor.style.left).toBe('0px');
|
|
728
|
-
|
|
731
|
+
// The 1px anchor steps back into the trigger's last pixel row, so its
|
|
732
|
+
// bottom edge (where the menu starts) is still the trigger's bottom.
|
|
733
|
+
expect(anchor.style.top).toBe('19px');
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it('keeps the anchor inside the trigger at its end edges, and nowhere else', () => {
|
|
737
|
+
render(
|
|
738
|
+
<ContextMenu items={[{label: 'Item 1'}]} data-testid="ctx">
|
|
739
|
+
<div>Right-click me</div>
|
|
740
|
+
</ContextMenu>,
|
|
741
|
+
);
|
|
742
|
+
const trigger = screen.getByTestId('ctx');
|
|
743
|
+
trigger.getBoundingClientRect = () =>
|
|
744
|
+
({
|
|
745
|
+
left: 100,
|
|
746
|
+
top: 50,
|
|
747
|
+
right: 300,
|
|
748
|
+
bottom: 150,
|
|
749
|
+
width: 200,
|
|
750
|
+
height: 100,
|
|
751
|
+
}) as DOMRect;
|
|
752
|
+
const anchor = getCursorAnchor(trigger);
|
|
753
|
+
// A point on the trigger's end edges steps back by the anchor's pixel.
|
|
754
|
+
fireEvent.contextMenu(trigger, {clientX: 300, clientY: 150, detail: 1});
|
|
755
|
+
expect(anchor.style.left).toBe('199px');
|
|
756
|
+
expect(anchor.style.top).toBe('99px');
|
|
757
|
+
// A point past the trigger (content overflowing it) stays at the
|
|
758
|
+
// pointer, as does a point on the trigger's start edges.
|
|
759
|
+
fireEvent.contextMenu(trigger, {clientX: 320, clientY: 50, detail: 1});
|
|
760
|
+
expect(anchor.style.left).toBe('220px');
|
|
761
|
+
expect(anchor.style.top).toBe('0px');
|
|
729
762
|
});
|
|
730
763
|
});
|
|
731
764
|
});
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* @position Core implementation; consumed by index.ts
|
|
10
10
|
*
|
|
11
11
|
* Right-click context menu positioned at the cursor. The cursor point is
|
|
12
|
-
* captured as an offset *inside the trigger* and materialized as a
|
|
12
|
+
* captured as an offset *inside the trigger* and materialized as a 1px
|
|
13
13
|
* anchor element, so the menu is positioned relative to the trigger's context
|
|
14
14
|
* (via CSS anchor positioning) rather than the viewport. It therefore follows
|
|
15
15
|
* the content on scroll and auto-flips at viewport edges, while still appearing
|
|
@@ -112,14 +112,16 @@ const styles = stylex.create({
|
|
|
112
112
|
WebkitUserSelect: 'none',
|
|
113
113
|
userSelect: 'none',
|
|
114
114
|
},
|
|
115
|
-
//
|
|
116
|
-
//
|
|
115
|
+
// 1px anchor placed at the cursor point within the trigger. The menu is
|
|
116
|
+
// anchored to this element, so it sits under the cursor yet is positioned
|
|
117
117
|
// relative to the trigger's context — it follows the content on scroll and
|
|
118
|
-
// the browser can auto-flip it against the viewport edges.
|
|
118
|
+
// the browser can auto-flip it against the viewport edges. It owns a box
|
|
119
|
+
// because WebKit counts an empty anchor box as clipped, and the default
|
|
120
|
+
// `position-visibility: anchors-visible` then hides the menu.
|
|
119
121
|
cursorAnchor: {
|
|
120
122
|
position: 'absolute',
|
|
121
|
-
width:
|
|
122
|
-
height:
|
|
123
|
+
width: '1px',
|
|
124
|
+
height: '1px',
|
|
123
125
|
pointerEvents: 'none',
|
|
124
126
|
},
|
|
125
127
|
menu: {
|
|
@@ -185,6 +187,17 @@ const styles = stylex.create({
|
|
|
185
187
|
},
|
|
186
188
|
});
|
|
187
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Keeps the 1px cursor anchor inside the trigger along one axis. A point in
|
|
192
|
+
* the trigger's last pixel (Shift+F10 opens on its bottom edge) steps back by
|
|
193
|
+
* that pixel: past the edge the anchor would add scrollable overflow, and a
|
|
194
|
+
* scroller whose edge meets the trigger's would clip it and hide the menu.
|
|
195
|
+
* Any other point, inside the trigger or not, stays where it is.
|
|
196
|
+
*/
|
|
197
|
+
function keepAnchorInside(offset: number, size: number): number {
|
|
198
|
+
return offset > size - 1 && offset <= size ? Math.max(size - 1, 0) : offset;
|
|
199
|
+
}
|
|
200
|
+
|
|
188
201
|
// =============================================================================
|
|
189
202
|
// Types
|
|
190
203
|
// =============================================================================
|
|
@@ -324,7 +337,7 @@ export function ContextMenu({
|
|
|
324
337
|
|
|
325
338
|
const menuId = useId();
|
|
326
339
|
// Cursor point in the trigger's local coordinate space (offset from the
|
|
327
|
-
// trigger's top-left, in-flow). Stored here and written to the
|
|
340
|
+
// trigger's top-left, in-flow). Stored here and written to the cursor
|
|
328
341
|
// anchor element so the menu is positioned relative to the trigger context
|
|
329
342
|
// — it scrolls with the content instead of sitting at a fixed viewport point.
|
|
330
343
|
const positionRef = useRef({x: 0, y: 0});
|
|
@@ -520,18 +533,21 @@ export function ContextMenu({
|
|
|
520
533
|
[listNavKeyDown, closeMenu, typeahead, ownsEvent],
|
|
521
534
|
);
|
|
522
535
|
|
|
523
|
-
// Place the
|
|
536
|
+
// Place the 1px cursor anchor at a point in the trigger's local
|
|
524
537
|
// coordinate space and open the menu. Positioning the anchor inside the
|
|
525
538
|
// trigger (rather than storing viewport coordinates on the menu itself) is
|
|
526
539
|
// what makes the menu context-relative: it scrolls with the content and the
|
|
527
540
|
// browser auto-flips it against the viewport edges via CSS anchor positioning.
|
|
528
541
|
const openAtLocalPoint = useCallback(
|
|
529
542
|
(localX: number, localY: number, focusEl: HTMLElement | null) => {
|
|
530
|
-
|
|
543
|
+
const rect = triggerRef.current?.getBoundingClientRect();
|
|
544
|
+
const x = rect ? keepAnchorInside(localX, rect.width) : localX;
|
|
545
|
+
const y = rect ? keepAnchorInside(localY, rect.height) : localY;
|
|
546
|
+
positionRef.current = {x, y};
|
|
531
547
|
const anchorEl = cursorAnchorRef.current;
|
|
532
548
|
if (anchorEl) {
|
|
533
|
-
anchorEl.style.left = `${
|
|
534
|
-
anchorEl.style.top = `${
|
|
549
|
+
anchorEl.style.left = `${x}px`;
|
|
550
|
+
anchorEl.style.top = `${y}px`;
|
|
535
551
|
}
|
|
536
552
|
// Remember the element focused before opening so we can restore it on
|
|
537
553
|
// close (Escape or outside-click), instead of dropping focus to <body>.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file ContextMenuCursorAnchor.a11y.browser.spec.ts
|
|
5
|
+
* @input The built "Inside a scroll container" ContextMenu story and real
|
|
6
|
+
* Chromium/WebKit layout and paint
|
|
7
|
+
* @output Browser evidence that the menu paints at its cursor anchor inside a
|
|
8
|
+
* scroll container, hides while the container clips that anchor, and that
|
|
9
|
+
* the anchor adds no scrollable overflow
|
|
10
|
+
* @position Cross-browser proof; jsdom has no layout, anchor positioning or
|
|
11
|
+
* paint.
|
|
12
|
+
*
|
|
13
|
+
* The menu is anchored to a box at the pointer. The initial
|
|
14
|
+
* `position-visibility: anchors-visible` hides an anchored box whose anchor
|
|
15
|
+
* is clipped, and WebKit counts an empty anchor box inside a scroll container
|
|
16
|
+
* as clipped. Whether the menu painted is read from pixels: WebKit still
|
|
17
|
+
* hit-tests, and reports as visible, a box that rule hides.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {expect, test, type Locator, type Page} from '@playwright/test';
|
|
21
|
+
import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_STORYBOOK_DIR,
|
|
24
|
+
serveStorybook,
|
|
25
|
+
type StaticServer,
|
|
26
|
+
} from '@astryxdesign/a11y-spec/storybook';
|
|
27
|
+
|
|
28
|
+
let storybook: StaticServer;
|
|
29
|
+
test.beforeAll(async () => {
|
|
30
|
+
storybook = await serveStorybook(
|
|
31
|
+
process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
test.afterAll(async () => {
|
|
35
|
+
await storybook?.close();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** Mounts the story with the trigger scrolled fully into the box. */
|
|
39
|
+
async function mount(
|
|
40
|
+
page: Page,
|
|
41
|
+
): Promise<{trigger: Locator; scroller: Locator}> {
|
|
42
|
+
await page.goto(
|
|
43
|
+
`${storybook.origin}/iframe.html?id=core-contextmenu--in-scroll-container&viewMode=story`,
|
|
44
|
+
);
|
|
45
|
+
await holdMotionStill(page);
|
|
46
|
+
const scroller = page.getByTestId('context-menu-scroller');
|
|
47
|
+
// The trigger is the box's last child: at the end of the scroll its bottom
|
|
48
|
+
// edge is the box's bottom edge.
|
|
49
|
+
await scroller.evaluate(el => {
|
|
50
|
+
el.scrollTop = el.scrollHeight;
|
|
51
|
+
});
|
|
52
|
+
return {trigger: page.getByText('Right-click this area'), scroller};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Right-clicks the trigger 40px in and 30px down; returns that point. */
|
|
56
|
+
async function rightClick(
|
|
57
|
+
page: Page,
|
|
58
|
+
trigger: Locator,
|
|
59
|
+
): Promise<{x: number; y: number}> {
|
|
60
|
+
const box = await trigger.boundingBox();
|
|
61
|
+
const point = {
|
|
62
|
+
x: Math.round(box?.x ?? 0) + 40,
|
|
63
|
+
y: Math.round(box?.y ?? 0) + 30,
|
|
64
|
+
};
|
|
65
|
+
await page.mouse.click(point.x, point.y, {button: 'right'});
|
|
66
|
+
return point;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Whether the open menu is on screen: hiding it must change the pixels. */
|
|
70
|
+
async function menuPainted(page: Page): Promise<boolean> {
|
|
71
|
+
const layer = page.locator('[popover]:has(> [role="menu"])');
|
|
72
|
+
const clip = await layer.boundingBox();
|
|
73
|
+
if (clip == null) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const shown = await page.screenshot({clip});
|
|
77
|
+
await layer.evaluate(el => {
|
|
78
|
+
el.style.visibility = 'hidden';
|
|
79
|
+
});
|
|
80
|
+
const hidden = await page.screenshot({clip});
|
|
81
|
+
await layer.evaluate(el => {
|
|
82
|
+
el.style.visibility = '';
|
|
83
|
+
});
|
|
84
|
+
return !shown.equals(hidden);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
test('a right-click inside a scroll container paints the menu at the pointer', async ({
|
|
88
|
+
page,
|
|
89
|
+
}) => {
|
|
90
|
+
const {trigger} = await mount(page);
|
|
91
|
+
const point = await rightClick(page, trigger);
|
|
92
|
+
await expect(page.getByRole('menu')).toBeVisible();
|
|
93
|
+
await expect.poll(async () => menuPainted(page)).toBe(true);
|
|
94
|
+
|
|
95
|
+
const anchor = await page
|
|
96
|
+
.locator('span[aria-hidden="true"][style*="anchor-name"]')
|
|
97
|
+
.boundingBox();
|
|
98
|
+
// The anchor owns a box, so WebKit does not count it as clipped.
|
|
99
|
+
expect(anchor).toMatchObject({width: 1, height: 1});
|
|
100
|
+
expect(anchor?.x).toBeCloseTo(point.x, 0);
|
|
101
|
+
expect(anchor?.y).toBeCloseTo(point.y, 0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('the menu hides while the box scrolls its anchor out of view, and returns with it', async ({
|
|
105
|
+
page,
|
|
106
|
+
}) => {
|
|
107
|
+
const {trigger, scroller} = await mount(page);
|
|
108
|
+
await rightClick(page, trigger);
|
|
109
|
+
await expect(page.getByRole('menu')).toBeVisible();
|
|
110
|
+
await expect.poll(async () => menuPainted(page)).toBe(true);
|
|
111
|
+
|
|
112
|
+
await scroller.evaluate(el => {
|
|
113
|
+
el.scrollTop -= el.clientHeight;
|
|
114
|
+
});
|
|
115
|
+
await expect(page.getByRole('menu')).toBeAttached();
|
|
116
|
+
await expect.poll(async () => menuPainted(page)).toBe(false);
|
|
117
|
+
|
|
118
|
+
await scroller.evaluate(el => {
|
|
119
|
+
el.scrollTop = el.scrollHeight;
|
|
120
|
+
});
|
|
121
|
+
await expect.poll(async () => menuPainted(page)).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('a keyboard open on the bottom edge paints and adds no scrollable overflow', async ({
|
|
125
|
+
page,
|
|
126
|
+
}) => {
|
|
127
|
+
const {trigger, scroller} = await mount(page);
|
|
128
|
+
const scrollHeight = await scroller.evaluate(el => el.scrollHeight);
|
|
129
|
+
// Several browsers report Shift+F10 as a contextmenu at (0, 0), which
|
|
130
|
+
// anchors the menu to the trigger's bottom edge, here the box's own edge.
|
|
131
|
+
await trigger.evaluate(el => {
|
|
132
|
+
el.dispatchEvent(
|
|
133
|
+
new MouseEvent('contextmenu', {
|
|
134
|
+
bubbles: true,
|
|
135
|
+
cancelable: true,
|
|
136
|
+
clientX: 0,
|
|
137
|
+
clientY: 0,
|
|
138
|
+
detail: 0,
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
await expect(page.getByRole('menu')).toBeVisible();
|
|
143
|
+
const anchor = await page
|
|
144
|
+
.locator('span[aria-hidden="true"][style*="anchor-name"]')
|
|
145
|
+
.boundingBox();
|
|
146
|
+
const area = await trigger.boundingBox();
|
|
147
|
+
expect((anchor?.y ?? 0) + (anchor?.height ?? 0)).toBeCloseTo(
|
|
148
|
+
(area?.y ?? 0) + (area?.height ?? 0),
|
|
149
|
+
0,
|
|
150
|
+
);
|
|
151
|
+
await expect.poll(async () => menuPainted(page)).toBe(true);
|
|
152
|
+
expect(await scroller.evaluate(el => el.scrollHeight)).toBe(scrollHeight);
|
|
153
|
+
});
|
package/src/Dialog/Dialog.tsx
CHANGED
|
@@ -70,38 +70,10 @@ import {DialogContext} from './DialogContext';
|
|
|
70
70
|
import {themeProps} from '../utils/themeProps';
|
|
71
71
|
import type {DialogVariantMap} from './index';
|
|
72
72
|
import {focusOutlineProps} from '../utils/focusOutline.stylex';
|
|
73
|
-
import {
|
|
74
|
-
|
|
75
|
-
useInteractionModalityTracking,
|
|
76
|
-
} from '../utils/interactionModality';
|
|
73
|
+
import {useInteractionModalityTracking} from '../utils/interactionModality';
|
|
74
|
+
import {focusForLastInput} from '../utils/focusReturn';
|
|
77
75
|
|
|
78
76
|
import {useMergedRefs} from '../hooks/useMergedRefs';
|
|
79
|
-
/**
|
|
80
|
-
* Move focus programmatically so its ring follows the last input. Dialog does
|
|
81
|
-
* not render the element that opened it or the consumer's autofocus target, so
|
|
82
|
-
* the browser's focus option is the one way to reach them.
|
|
83
|
-
*/
|
|
84
|
-
function focusForLastInput(element: HTMLElement): void {
|
|
85
|
-
// A text-entry field paints its own focus treatment to show where typing
|
|
86
|
-
// goes, and an extra blur there can run validation or mark it touched.
|
|
87
|
-
const isTextEntry =
|
|
88
|
-
element instanceof HTMLInputElement ||
|
|
89
|
-
element instanceof HTMLTextAreaElement ||
|
|
90
|
-
element instanceof HTMLSelectElement ||
|
|
91
|
-
element.isContentEditable;
|
|
92
|
-
if (isTextEntry || getInteractionModality() !== 'pointer') {
|
|
93
|
-
element.focus();
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
// Focusing the element that already has focus does nothing, so a ring the
|
|
97
|
-
// browser already drew would stay. WebKit draws one when showModal() or its
|
|
98
|
-
// native close moves focus itself, so clear that focus first.
|
|
99
|
-
if (element.matches(':focus-visible')) {
|
|
100
|
-
element.blur();
|
|
101
|
-
}
|
|
102
|
-
element.focus({focusVisible: false});
|
|
103
|
-
}
|
|
104
|
-
|
|
105
77
|
/**
|
|
106
78
|
* Calculate a directional translate offset for dialog entry animation.
|
|
107
79
|
* Returns a normalized vector from the trigger element toward the viewport
|
|
@@ -180,25 +180,17 @@ async function runState(
|
|
|
180
180
|
});
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const results: BindingResult[] = [];
|
|
189
|
-
for (const state of DIALOG_MODAL_BINDING_STATES) {
|
|
190
|
-
results.push(await runState(page, cdp, state));
|
|
191
|
-
}
|
|
192
|
-
expect(neverExercised(results)).toEqual([]);
|
|
193
|
-
expect(unmatchedKnownFailures(DIALOG_MODAL_KNOWN_FAILURES, results)).toEqual(
|
|
194
|
-
[],
|
|
195
|
-
);
|
|
196
|
-
});
|
|
183
|
+
// Each state's result, recorded by its own test below so the whole-suite check
|
|
184
|
+
// at the end reads it instead of measuring every state a second time. A state
|
|
185
|
+
// whose test did not run in this worker (a filtered run, or a worker restarted
|
|
186
|
+
// after a failure) is measured by the whole-suite check itself.
|
|
187
|
+
const recorded = new Map<string, BindingResult>();
|
|
197
188
|
|
|
198
189
|
for (const state of DIALOG_MODAL_BINDING_STATES) {
|
|
199
190
|
test(`Dialog [${state.id}] — ${state.summary}`, async ({page}) => {
|
|
200
191
|
const cdp = await page.context().newCDPSession(page);
|
|
201
192
|
const result = await runState(page, cdp, state);
|
|
193
|
+
recorded.set(state.id, result);
|
|
202
194
|
const report = summarize(MODAL_DIALOG_PATTERN, [result]);
|
|
203
195
|
// eslint-disable-next-line no-console -- the report is this run's artifact
|
|
204
196
|
console.log(formatReport(report));
|
|
@@ -208,3 +200,18 @@ for (const state of DIALOG_MODAL_BINDING_STATES) {
|
|
|
208
200
|
expect(report.counts.unexpectedPass).toBe(0);
|
|
209
201
|
});
|
|
210
202
|
}
|
|
203
|
+
|
|
204
|
+
test('every expectation is exercised and every known failure matches once', async ({
|
|
205
|
+
page,
|
|
206
|
+
}) => {
|
|
207
|
+
test.setTimeout(5 * 60 * 1000);
|
|
208
|
+
const cdp = await page.context().newCDPSession(page);
|
|
209
|
+
const results: BindingResult[] = [];
|
|
210
|
+
for (const state of DIALOG_MODAL_BINDING_STATES) {
|
|
211
|
+
results.push(recorded.get(state.id) ?? (await runState(page, cdp, state)));
|
|
212
|
+
}
|
|
213
|
+
expect(neverExercised(results)).toEqual([]);
|
|
214
|
+
expect(unmatchedKnownFailures(DIALOG_MODAL_KNOWN_FAILURES, results)).toEqual(
|
|
215
|
+
[],
|
|
216
|
+
);
|
|
217
|
+
});
|