@astryxdesign/core 0.7.0 → 0.7.1-canary.7e441be

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 (38) 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/ContextMenu/ContextMenu.d.ts +1 -1
  5. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  6. package/dist/ContextMenu/ContextMenu.js +22 -8
  7. package/dist/Dialog/Dialog.d.ts.map +1 -1
  8. package/dist/Dialog/Dialog.js +3 -26
  9. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  10. package/dist/DropdownMenu/DropdownMenu.js +10 -3
  11. package/dist/Typeahead/BaseTypeahead.d.ts +1 -1
  12. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  13. package/dist/Typeahead/BaseTypeahead.js +12 -3
  14. package/dist/hooks/useIsomorphicLayoutEffect.d.ts +2 -2
  15. package/dist/hooks/useIsomorphicLayoutEffect.d.ts.map +1 -1
  16. package/dist/utils/focusReturn.d.ts +12 -8
  17. package/dist/utils/focusReturn.d.ts.map +1 -1
  18. package/dist/utils/focusReturn.js +44 -3
  19. package/package.json +4 -4
  20. package/src/BottomSheet/BottomSheet.test.tsx +114 -0
  21. package/src/BottomSheet/BottomSheet.tsx +12 -2
  22. package/src/BottomSheet/__tests__/BottomSheetFocusReturn.a11y.browser.spec.ts +96 -0
  23. package/src/Chat/__tests__/ChatLayoutScrollButtonSurface.a11y.chromium.spec.ts +5 -6
  24. package/src/CodeBlock/CodeBlock.spec.md +54 -44
  25. package/src/ContextMenu/ContextMenu.spec.md +1 -1
  26. package/src/ContextMenu/ContextMenu.test.tsx +36 -3
  27. package/src/ContextMenu/ContextMenu.tsx +27 -11
  28. package/src/ContextMenu/__tests__/ContextMenuCursorAnchor.a11y.browser.spec.ts +153 -0
  29. package/src/Dialog/Dialog.tsx +2 -30
  30. package/src/DropdownMenu/DropdownMenu.test.tsx +97 -1
  31. package/src/DropdownMenu/DropdownMenu.tsx +10 -3
  32. package/src/DropdownMenu/__tests__/DropdownMenuFocusReturn.a11y.browser.spec.ts +73 -0
  33. package/src/Markdown/Markdown.spec.md +37 -23
  34. package/src/PowerSearch/PowerSearch.test.tsx +45 -0
  35. package/src/Typeahead/BaseTypeahead.test.tsx +99 -1
  36. package/src/Typeahead/BaseTypeahead.tsx +17 -3
  37. package/src/utils/focusReturn.test.ts +79 -2
  38. package/src/utils/focusReturn.ts +48 -3
@@ -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 zero-size
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
- // Zero-size anchor placed at the cursor point within the trigger. The menu
116
- // is anchored to this element, so it sits under the cursor yet is positioned
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: 0,
122
- height: 0,
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 zero-size
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 zero-size cursor anchor at a point in the trigger's local
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
- positionRef.current = {x: localX, y: localY};
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 = `${localX}px`;
534
- anchorEl.style.top = `${localY}px`;
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
+ });
@@ -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
- getInteractionModality,
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
@@ -9,7 +9,7 @@
9
9
  * SYNC: When DropdownMenu.tsx changes, update tests to match new behavior
10
10
  */
11
11
 
12
- import {describe, it, expect, vi, beforeEach} from 'vitest';
12
+ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
13
13
  import {readFileSync} from 'node:fs';
14
14
  import {render, screen, fireEvent, waitFor, act} from '@testing-library/react';
15
15
  import userEvent from '@testing-library/user-event';
@@ -3040,6 +3040,102 @@ describe('DropdownMenu focus return after a pointer pick', () => {
3040
3040
  ),
3041
3041
  );
3042
3042
  });
3043
+
3044
+ it("asks the browser for no ring on the design system's Button too", async () => {
3045
+ const user = userEvent.setup();
3046
+ const trigger = renderMenu();
3047
+ await user.click(trigger);
3048
+ const focus = vi.spyOn(trigger, 'focus');
3049
+ await user.click(item('Edit'));
3050
+ expect(trigger).toHaveFocus();
3051
+ expect(focus.mock.calls.at(-1)).toEqual([
3052
+ {preventScroll: true, focusVisible: false},
3053
+ ]);
3054
+ });
3055
+
3056
+ describe('a trigger the caller renders', () => {
3057
+ // The style that keeps the ring off the design system's Button cannot
3058
+ // reach a caller's control, so the focus move itself has to: after
3059
+ // pointer input the browser is asked not to match `:focus-visible`, which
3060
+ // it otherwise does on the returned focus (Chromium after a mouse pick,
3061
+ // WebKit after a touch).
3062
+ function renderCustomMenu() {
3063
+ render(
3064
+ <DropdownMenu
3065
+ renderTrigger={props => (
3066
+ <button type="button" {...props}>
3067
+ More
3068
+ </button>
3069
+ )}>
3070
+ <DropdownMenuItem label="Edit" onClick={() => {}} />
3071
+ <DropdownMenuItem label="Delete" onClick={() => {}} />
3072
+ </DropdownMenu>,
3073
+ );
3074
+ return screen.getByRole('button', {name: 'More'});
3075
+ }
3076
+
3077
+ afterEach(() => {
3078
+ vi.restoreAllMocks();
3079
+ });
3080
+
3081
+ it('returns focus after a pointer pick with the browser asked for no ring', async () => {
3082
+ const user = userEvent.setup();
3083
+ const trigger = renderCustomMenu();
3084
+ await user.click(trigger);
3085
+ const focus = vi.spyOn(trigger, 'focus');
3086
+ await user.click(item('Edit'));
3087
+ expect(trigger).toHaveFocus();
3088
+ expect(focus.mock.calls.at(-1)).toEqual([
3089
+ {preventScroll: true, focusVisible: false},
3090
+ ]);
3091
+ });
3092
+
3093
+ it('returns focus after a keyboard pick with the ring left to the browser', async () => {
3094
+ const user = userEvent.setup();
3095
+ const trigger = renderCustomMenu();
3096
+ trigger.focus();
3097
+ await user.keyboard('{ArrowDown}');
3098
+ await waitFor(() => expect(item('Edit')).toHaveFocus());
3099
+ const focus = vi.spyOn(trigger, 'focus');
3100
+ await user.keyboard('{Enter}');
3101
+ await waitFor(() => expect(trigger).toHaveFocus());
3102
+ expect(focus.mock.calls.at(-1)).toEqual([{preventScroll: true}]);
3103
+ });
3104
+
3105
+ it('clears a ring the browser already drew before refocusing after a pointer pick', async () => {
3106
+ const user = userEvent.setup();
3107
+ const trigger = renderCustomMenu();
3108
+ await user.click(trigger);
3109
+ // WebKit's own popover focus restoration can focus the trigger, with a
3110
+ // ring, before the menu moves focus, and focusing the focused element
3111
+ // changes nothing.
3112
+ const hidePopover = HTMLElement.prototype.hidePopover;
3113
+ HTMLElement.prototype.hidePopover = vi.fn(function (this: HTMLElement) {
3114
+ hidePopover.call(this);
3115
+ trigger.focus();
3116
+ });
3117
+ const matches = HTMLElement.prototype.matches;
3118
+ vi.spyOn(HTMLElement.prototype, 'matches').mockImplementation(function (
3119
+ this: HTMLElement,
3120
+ selector: string,
3121
+ ) {
3122
+ return selector === ':focus-visible'
3123
+ ? this === trigger
3124
+ : matches.call(this, selector);
3125
+ });
3126
+ const blur = vi.spyOn(trigger, 'blur');
3127
+ const focus = vi.spyOn(trigger, 'focus');
3128
+ await user.click(item('Edit'));
3129
+ expect(blur).toHaveBeenCalledTimes(1);
3130
+ expect(focus.mock.calls.at(-1)).toEqual([
3131
+ {preventScroll: true, focusVisible: false},
3132
+ ]);
3133
+ expect(blur.mock.invocationCallOrder[0]).toBeLessThan(
3134
+ focus.mock.invocationCallOrder.at(-1) ?? 0,
3135
+ );
3136
+ expect(trigger).toHaveFocus();
3137
+ });
3138
+ });
3043
3139
  });
3044
3140
 
3045
3141
  describe('DropdownMenuItem href', () => {
@@ -68,6 +68,7 @@ import {useMenuPress} from '../hooks/useMenuPress';
68
68
  import type {MenuPressPointerType} from '../hooks/menuPressGesture';
69
69
  import {useTypeahead} from '../hooks/useTypeahead';
70
70
  import {useFocusReturnVisibility} from '../hooks/useFocusReturnVisibility';
71
+ import {focusForLastInput} from '../utils/focusReturn';
71
72
  import {useMenuOverflow} from './useMenuOverflow';
72
73
  import {MenuRegionsContext} from './menuRegionsContext';
73
74
  import {getFocusableElements} from '../hooks/useFocusTrap';
@@ -932,7 +933,12 @@ function DropdownMenuPopover({
932
933
  return;
933
934
  }
934
935
  prepareFocusReturn();
935
- trigger.focus({preventScroll: true});
936
+ // After pointer input the trigger takes focus without the focus-visible
937
+ // ring (`component:DropdownMenu/DEC-1`). The style the helper above adds
938
+ // reaches only the design system's own Button; the browser's focus option
939
+ // also reaches a control the caller renders, which the browser would
940
+ // otherwise ring (Chromium after a mouse pick, WebKit after a touch).
941
+ focusForLastInput(trigger, {preventScroll: true});
936
942
  }, [isControlled, onOpenChange, prepareFocusReturn]);
937
943
 
938
944
  // Defer item focus until the layer has committed open, so focus restore
@@ -1432,8 +1438,9 @@ function DropdownMenuPopover({
1432
1438
  onPointerDown: menuPress.triggerProps.onPointerDown,
1433
1439
  onContextMenu: menuPress.triggerProps.onContextMenu,
1434
1440
  onKeyDown: handleButtonKeyDown,
1435
- // The popover's focus-return hook is a separate change; until it lands
1436
- // the control's focus needs no bookkeeping here.
1441
+ // The caller's control paints its own ring, and the menu's focus return
1442
+ // keeps the browser from drawing one after pointer input (see
1443
+ // handleLayerHide), so its focus needs no bookkeeping here.
1437
1444
  onFocus: () => {},
1438
1445
  'aria-haspopup': 'menu',
1439
1446
  'aria-expanded': isOpen,
@@ -0,0 +1,73 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file DropdownMenuFocusReturn.a11y.browser.spec.ts
5
+ * @input The `Core/DropdownMenu` CustomTrigger story (an icon button the
6
+ * caller renders) in a built Storybook, real Chromium/WebKit pointer and
7
+ * keyboard input.
8
+ * @output Browser evidence that the focus the menu returns to a trigger the
9
+ * caller renders follows the last input
10
+ * (docs/architecture/interaction-modality.md INV1,
11
+ * `component:DropdownMenu/DEC-1`): no ring after a pointer pick, the ring
12
+ * after a keyboard pick.
13
+ * @position Cross-browser proof; jsdom only proves which focus call the menu
14
+ * makes. Unless the menu asks for no ring, both engines ring the focus it
15
+ * returns after a pointer pick.
16
+ */
17
+
18
+ import {expect, test, type Locator, type Page} from '@playwright/test';
19
+ import {
20
+ DEFAULT_STORYBOOK_DIR,
21
+ serveStorybook,
22
+ type StaticServer,
23
+ } from '@astryxdesign/a11y-spec/storybook';
24
+
25
+ let storybook: StaticServer;
26
+ test.beforeAll(async () => {
27
+ storybook = await serveStorybook(
28
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
29
+ );
30
+ });
31
+ test.afterAll(async () => {
32
+ await storybook?.close();
33
+ });
34
+
35
+ async function mount(page: Page): Promise<Locator> {
36
+ await page.goto(
37
+ `${storybook.origin}/iframe.html?id=core-dropdownmenu--custom-trigger&viewMode=story`,
38
+ );
39
+ const trigger = page.getByRole('button', {name: 'More actions'});
40
+ await expect(trigger).toBeVisible();
41
+ return trigger;
42
+ }
43
+
44
+ async function matchesFocusVisible(locator: Locator): Promise<boolean> {
45
+ return locator.evaluate(element => element.matches(':focus-visible'));
46
+ }
47
+
48
+ test('a pointer pick returns focus to the trigger without a ring', async ({
49
+ page,
50
+ }) => {
51
+ const trigger = await mount(page);
52
+ await trigger.click();
53
+ const menu = page.getByRole('menu');
54
+ await expect(menu).toBeVisible();
55
+ await page.getByRole('menuitem', {name: 'Rename'}).click();
56
+ await expect(menu).toBeHidden();
57
+ await expect(trigger).toBeFocused();
58
+ expect(await matchesFocusVisible(trigger)).toBe(false);
59
+ });
60
+
61
+ test('a keyboard pick returns focus to the trigger with its ring', async ({
62
+ page,
63
+ }) => {
64
+ const trigger = await mount(page);
65
+ await trigger.focus();
66
+ await page.keyboard.press('Enter');
67
+ const menu = page.getByRole('menu');
68
+ await expect(page.getByRole('menuitem', {name: 'Rename'})).toBeFocused();
69
+ await page.keyboard.press('Enter');
70
+ await expect(menu).toBeHidden();
71
+ await expect(trigger).toBeFocused();
72
+ expect(await matchesFocusVisible(trigger)).toBe(true);
73
+ });
@@ -64,18 +64,30 @@ constrained renderer seams. Callers may opt into the canonical plugin protocol f
64
64
  bounded source syntax, immutable document transformation, typed extension
65
65
  rendering, and native typed document-start frontmatter. They may separately opt into dollar-delimited math by supplying one typed
66
66
  renderer for both inline and display expressions. The parser accepts matching
67
- explicit options. Existing parsing, rendering, styling, and streaming behavior
68
- remain unchanged when plugins and math are absent.
67
+ explicit options. Plugins and math change nothing when absent; the default Code
68
+ block path renders `diff` and `diff-<language>` fences as CodeBlock's diff layer
69
+ (FR29), and every other parsing, rendering, styling, and streaming behavior is
70
+ the released one.
69
71
 
70
72
  ## Compatibility and migration
71
73
 
72
- - Released default preserved: `yes`
73
- - Compatibility class: additive, opt-in public API; existing parser nodes, DOM,
74
- styling, targets, dollar-delimited text, `components`, and `inlinePlugins` remain
75
- unchanged unless the caller supplies `plugins`, supplies `components.math`, or
76
- passes the matching explicit parser option.
74
+ - Released default preserved: `yes`, except the default Code block path for `diff` fences (FR29, below)
75
+ - Compatibility class: additive, opt-in public API, with one breaking default-path
76
+ rendering change (FR29, below); existing parser nodes, DOM, styling, targets,
77
+ dollar-delimited text, `components`, and `inlinePlugins` remain unchanged unless
78
+ the caller supplies `plugins`, supplies `components.math`, or passes the matching
79
+ explicit parser option.
77
80
  - Controlled/uncontrolled behavior: not applicable
78
81
  - Migration decision: none
82
+ - Default-path rendering change for diff fences (FR29), breaking, shipped in a
83
+ minor: a `diff` fence that rendered as plain text renders as CodeBlock's diff
84
+ layer over plaintext with a `diff` label, and a `diff-<language>` fence —
85
+ which rendered as plain text with a `diff` label and viewport name, its
86
+ leading word being all Markdown read — as the layer over that language with
87
+ a `<language> diff` label; the header, copy, disclosure, and viewport name
88
+ stay, and a supplied `code` renderer's contract is unchanged. The way back to
89
+ plain text is a `text` fence or a custom `code` renderer. Every other fence
90
+ is unchanged.
79
91
  - Default-path layout change (FR26–FR28): a default table's columns may become
80
92
  wider than the released fixed buckets, never narrower; header labels wrap
81
93
  instead of truncating; a table that squashed its columns may now scroll.
@@ -228,6 +240,7 @@ unions. Enabled calls return the explicit `InlineNodeWithMath` and
228
240
  | FR26 | On the default table path each column has a content-derived width floor: the larger of its own longest unbreakable token and a readable minimum derived from that column's content, bounded so a table of short columns still fits its container. The floor applies to the cell's text, not to its padded box, so it means the same amount of content at every density. A header label contributes its own readable minimum to that comparison. A table whose floors exceed the container grows and scrolls rather than dividing the container equally. |
229
241
  | FR27 | Default table header cells never truncate: no ellipsis and no width clamp. A header reads in full, on one line up to a bounded width and wrapping past it. Inline code inside a default table cell keeps its token whole rather than breaking mid-token; a supplied `inlineCode` renderer owns its own wrapping. |
230
242
  | FR28 | Markdown's Table block owns spacing, sizing, and alignment. The nested Table's own Scroll region remains the sole owner of the table's horizontal overflow, its accessible name, and its conditional keyboard focusability; Markdown adds no second scroll container, name, or tab stop of its own. |
243
+ | FR29 | On the default Code block path the fence's full first word selects CodeBlock's diff layer (`component:CodeBlock` FR9): `diff` renders the layer over plaintext, `diff-<language>` the layer over `<language>` (the language read by the same leading-word rule as any other fence); every other fence is unchanged. A supplied `code` renderer's contract is unchanged: it keeps receiving the leading word characters as `language` — `diff` for both `diff` and `diff-<language>` fences — and nothing new. |
231
244
 
232
245
  FR23 includes CommonMark-compatible lazy paragraph continuation inside blockquotes
233
246
  and ordered, unordered, or task-list items. Omitting a repeated quote marker or
@@ -421,22 +434,23 @@ and this change preserves the existing spelling exactly.
421
434
 
422
435
  ## Verification map
423
436
 
424
- | Contract | Verification | Representative states | Failure signal |
425
- | ---------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
426
- | FR1–FR5 | `Markdown.test.tsx`, theme-target tests, and `scripts/check-knowledge.mjs` | Default block/inline output and all current targets | Existing DOM, target, spacing, or renderer behavior changes. |
427
- | FR6–FR10 | `parser.test.ts` and `Markdown.test.tsx` | Opt-out, inline/display math, escapes, currency, code, links, plugins | A delimiter is claimed without opt-in, TeX is formatted as Markdown, or opaque contexts leak. |
428
- | FR11 | `incremental.test.ts` and `Markdown.test.tsx` | Every-character top-level/list/task-list/blockquote splits, CRLF, source ranges | Streaming diverges from a full parse, shows partial syntax, mistakes a nested closer for an opener, or crosses a closed container. |
429
- | FR12–FR16 | plugin, transform, adapter, performance, and Outline parser tests | omitted/empty lists, immutable transforms, invalid outputs, live updates, one compatible Remark plugin, duplicate headings | Empty behavior forks, input mutates, invalid structure escapes, transforms reparse, adapter loses content, budgets fail, or heading targets diverge. |
430
- | FR17–FR18 | container parse/validation, ownership, and dependency tests | leaf/container declarations, nested containers, foreign read/remove/mint/edit, unmet/misordered dependencies | Plugin-built parsed children, invalid content, lost fallback children, foreign mint/edit, or generic ownership codes. |
431
- | FR19 | diagnostic-channel tests in development and production | every phase, advisory reports, rate suppression, no handler, malformed list, duplicate Core, version skew | A silent production failure, document content in a diagnostic, or one entrypoint throwing where another recovers. |
432
- | FR20–FR22 | canonical/server imports, inference, streaming, preparation, theming tests | server imports, no explicit type args, chunk boundaries, recreated lists, themed/unthemed extensions | Client references, explicit-type workarounds, oscillation, per-render re-preparation, or unreachable opted-in output. |
433
- | FR23 | parser, incremental, renderer, nesting, and public option tests | mixed lists; escaped prose/code table cells, nested inline code, full/streaming parity; autolink omitted/enabled | A mixed list splits or loses state, an escaped table pipe changes cells or exposes its backslash, or autolink changes other syntax. |
434
- | FR24 | `plugins/frontmatter.test.tsx`, Storybook, and server rendering | complete, malformed, non-leading, LF/CRLF, unfinished streaming, full plugin stack | Metadata syntax renders after completion, unfinished syntax leaks while streaming, typing is lost, or later plugins stop composing. |
435
- | FR25 | `parser.public.test.ts`, plugin SSR tests, and package export checks | server/RSC parsing with plugins, synchronous SSR, suspending renderer streaming boundary | A server import gains `use client`, plugin execution needs serialization, SSR loses fallback, or direct RSC rendering is misrepresented as supported. |
436
- | FR26–FR28 | `Markdown.test.tsx` and a real-browser layer over checked-in narrow-width stories | short, prose, and token-heavy tables; header-only rows and empty cells; long headers over short bodies; reading columns from phone to document width | A column collapses below its content floor, cell padding eats the floor, a header truncates, an inline-code token breaks mid-token, or a second scroll viewport, name, or tab stop appears around a table. |
437
- | Public syntax/types | `Markdown.public.test.ts`, core typecheck, and `Markdown.doc.mjs` | Legacy exhaustive switches, math opt-ins, inferred extension-node unions | A released union widens, an enabled union loses nodes, or docs drift from declarations. |
438
- | Navigation contract | `parser.test.ts`, `Markdown.test.tsx`, and `Markdown.renderBoundary.test.tsx` | Parsed and rendered links, including transformed built-in links; accepted ordinary schemes; rejected destinations; links versus images | A blocked destination reaches navigation or a custom link renderer, or resource policy narrows accepted navigation. |
439
- | Security/accessibility | `parser.test.ts`, `Markdown.test.tsx`, and renderer guidance | Inert expression strings and renderer-owned semantics | Astryx executes math as HTML or silently claims renderer-owned accessibility. |
437
+ | Contract | Verification | Representative states | Failure signal |
438
+ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
439
+ | FR1–FR5 | `Markdown.test.tsx`, theme-target tests, and `scripts/check-knowledge.mjs` | Default block/inline output and all current targets | Existing DOM, target, spacing, or renderer behavior changes. |
440
+ | FR6–FR10 | `parser.test.ts` and `Markdown.test.tsx` | Opt-out, inline/display math, escapes, currency, code, links, plugins | A delimiter is claimed without opt-in, TeX is formatted as Markdown, or opaque contexts leak. |
441
+ | FR11 | `incremental.test.ts` and `Markdown.test.tsx` | Every-character top-level/list/task-list/blockquote splits, CRLF, source ranges | Streaming diverges from a full parse, shows partial syntax, mistakes a nested closer for an opener, or crosses a closed container. |
442
+ | FR12–FR16 | plugin, transform, adapter, performance, and Outline parser tests | omitted/empty lists, immutable transforms, invalid outputs, live updates, one compatible Remark plugin, duplicate headings | Empty behavior forks, input mutates, invalid structure escapes, transforms reparse, adapter loses content, budgets fail, or heading targets diverge. |
443
+ | FR17–FR18 | container parse/validation, ownership, and dependency tests | leaf/container declarations, nested containers, foreign read/remove/mint/edit, unmet/misordered dependencies | Plugin-built parsed children, invalid content, lost fallback children, foreign mint/edit, or generic ownership codes. |
444
+ | FR19 | diagnostic-channel tests in development and production | every phase, advisory reports, rate suppression, no handler, malformed list, duplicate Core, version skew | A silent production failure, document content in a diagnostic, or one entrypoint throwing where another recovers. |
445
+ | FR20–FR22 | canonical/server imports, inference, streaming, preparation, theming tests | server imports, no explicit type args, chunk boundaries, recreated lists, themed/unthemed extensions | Client references, explicit-type workarounds, oscillation, per-render re-preparation, or unreachable opted-in output. |
446
+ | FR23 | parser, incremental, renderer, nesting, and public option tests | mixed lists; escaped prose/code table cells, nested inline code, full/streaming parity; autolink omitted/enabled | A mixed list splits or loses state, an escaped table pipe changes cells or exposes its backslash, or autolink changes other syntax. |
447
+ | FR24 | `plugins/frontmatter.test.tsx`, Storybook, and server rendering | complete, malformed, non-leading, LF/CRLF, unfinished streaming, full plugin stack | Metadata syntax renders after completion, unfinished syntax leaks while streaming, typing is lost, or later plugins stop composing. |
448
+ | FR25 | `parser.public.test.ts`, plugin SSR tests, and package export checks | server/RSC parsing with plugins, synchronous SSR, suspending renderer streaming boundary | A server import gains `use client`, plugin execution needs serialization, SSR loses fallback, or direct RSC rendering is misrepresented as supported. |
449
+ | FR26–FR28 | `Markdown.test.tsx` and a real-browser layer over checked-in narrow-width stories | short, prose, and token-heavy tables; header-only rows and empty cells; long headers over short bodies; reading columns from phone to document width | A column collapses below its content floor, cell padding eats the floor, a header truncates, an inline-code token breaks mid-token, or a second scroll viewport, name, or tab stop appears around a table. |
450
+ | FR29 | `Markdown.test.tsx` (a `diff` fence → plaintext + layer, a `diff-tsx` fence → tsx + layer, a plain fence unchanged, a custom `code` renderer receives `language="diff"` for both diff fences and nothing new) | default path and custom renderer, `diff` and `diff-<language>` fences | a fence word other than `diff`/`diff-<language>` reaching the layer, or a custom renderer receiving a new prop or a different `language` |
451
+ | Public syntax/types | `Markdown.public.test.ts`, core typecheck, and `Markdown.doc.mjs` | Legacy exhaustive switches, math opt-ins, inferred extension-node unions | A released union widens, an enabled union loses nodes, or docs drift from declarations. |
452
+ | Navigation contract | `parser.test.ts`, `Markdown.test.tsx`, and `Markdown.renderBoundary.test.tsx` | Parsed and rendered links, including transformed built-in links; accepted ordinary schemes; rejected destinations; links versus images | A blocked destination reaches navigation or a custom link renderer, or resource policy narrows accepted navigation. |
453
+ | Security/accessibility | `parser.test.ts`, `Markdown.test.tsx`, and renderer guidance | Inert expression strings and renderer-owned semantics | Astryx executes math as HTML or silently claims renderer-owned accessibility. |
440
454
 
441
455
  Focused tests continue to pin all nine current target names and default block
442
456
  placement. Math intentionally adds no target and no default anatomy.