@astryxdesign/core 0.7.0-canary.fe57f54 → 0.7.0

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 (125) hide show
  1. package/CHANGELOG.md +186 -0
  2. package/dist/BottomSheet/BottomSheet.d.ts +5 -4
  3. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheetPanel.d.ts +1 -1
  5. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  6. package/dist/BottomSheet/BottomSheetPanel.js +1 -1
  7. package/dist/Calendar/styles.d.ts +6 -6
  8. package/dist/Chat/ChatComposerInput.d.ts +0 -11
  9. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  10. package/dist/Chat/ChatComposerInput.js +0 -11
  11. package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
  12. package/dist/Chat/useTriggerMenu.js +1 -1
  13. package/dist/CommandPalette/CommandPaletteList.d.ts +3 -2
  14. package/dist/CommandPalette/CommandPaletteList.d.ts.map +1 -1
  15. package/dist/CommandPalette/CommandPaletteList.js +3 -2
  16. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  17. package/dist/DateInput/TouchDateField.js +4 -1
  18. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  19. package/dist/DateTimeInput/TouchDateTimeField.js +29 -13
  20. package/dist/DropdownMenu/DropdownMenu.d.ts +11 -0
  21. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  22. package/dist/DropdownMenu/DropdownMenu.js +173 -11
  23. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  24. package/dist/DropdownMenu/DropdownMenuSubMenu.js +13 -2
  25. package/dist/DropdownMenu/menuRegionsContext.d.ts +2 -0
  26. package/dist/DropdownMenu/menuRegionsContext.d.ts.map +1 -0
  27. package/dist/DropdownMenu/menuRegionsContext.js +17 -0
  28. package/dist/Field/inputStyles.stylex.d.ts +3 -3
  29. package/dist/Layout/container.stylex.d.ts +18 -20
  30. package/dist/Layout/container.stylex.d.ts.map +1 -1
  31. package/dist/Layout/container.stylex.js +15 -25
  32. package/dist/Layout/padding.stylex.d.ts +231 -231
  33. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  34. package/dist/Lightbox/Lightbox.js +15 -6
  35. package/dist/Markdown/plugins/protocol.d.ts.map +1 -1
  36. package/dist/Markdown/plugins/protocol.js +33 -8
  37. package/dist/NavItem/navItemStyles.stylex.d.ts +5 -5
  38. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  39. package/dist/PowerSearch/PowerSearch.js +21 -1
  40. package/dist/PowerSearch/PowerSearchEditPopover.d.ts.map +1 -1
  41. package/dist/PowerSearch/PowerSearchEditPopover.js +30 -15
  42. package/dist/Stack/stack.stylex.d.ts +22 -22
  43. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  44. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +89 -14
  45. package/dist/Table/table.stylex.d.ts +6 -6
  46. package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
  47. package/dist/TimeInput/TouchTimeField.js +4 -1
  48. package/dist/Tokenizer/Tokenizer.d.ts +1 -12
  49. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  50. package/dist/Tokenizer/Tokenizer.js +1 -13
  51. package/dist/Typeahead/BaseTypeahead.d.ts +0 -11
  52. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  53. package/dist/Typeahead/BaseTypeahead.js +1 -9
  54. package/dist/Typeahead/Typeahead.d.ts +1 -12
  55. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  56. package/dist/Typeahead/Typeahead.js +1 -13
  57. package/dist/astryx.css +17 -12
  58. package/dist/hooks/useFocusTrap.d.ts +8 -0
  59. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  60. package/dist/hooks/useFocusTrap.js +5 -1
  61. package/dist/hooks/useRenamedProp.d.ts +6 -6
  62. package/dist/hooks/useRenamedProp.js +6 -6
  63. package/dist/reset.a11y.chromium.spec.d.ts +2 -0
  64. package/dist/reset.a11y.chromium.spec.d.ts.map +1 -0
  65. package/dist/reset.a11y.chromium.spec.js +176 -0
  66. package/dist/theme/tokens.stylex.d.ts +30 -30
  67. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  68. package/dist/theme/tokens.stylex.js +18 -15
  69. package/dist/theme/useTheme.d.ts +1 -1
  70. package/package.json +4 -4
  71. package/src/BottomSheet/BottomSheet.doc.mjs +4 -4
  72. package/src/BottomSheet/BottomSheet.tsx +5 -4
  73. package/src/BottomSheet/BottomSheetPanel.test.tsx +6 -9
  74. package/src/BottomSheet/BottomSheetPanel.tsx +2 -3
  75. package/src/Chat/ChatComposerInput.doc.mjs +3 -3
  76. package/src/Chat/ChatComposerInput.test.tsx +7 -55
  77. package/src/Chat/ChatComposerInput.tsx +0 -36
  78. package/src/Chat/useTriggerMenu.tsx +1 -4
  79. package/src/CommandPalette/CommandPalette.spec.md +2 -0
  80. package/src/CommandPalette/CommandPaletteList.spec.md +189 -0
  81. package/src/CommandPalette/CommandPaletteList.test.tsx +47 -0
  82. package/src/CommandPalette/CommandPaletteList.tsx +3 -2
  83. package/src/DateInput/TouchDateField.tsx +3 -1
  84. package/src/DateTimeInput/DateTimeInputTouch.test.tsx +60 -1
  85. package/src/DateTimeInput/TouchDateTimeField.tsx +31 -14
  86. package/src/DropdownMenu/DropdownMenu.doc.mjs +12 -0
  87. package/src/DropdownMenu/DropdownMenu.spec.md +65 -40
  88. package/src/DropdownMenu/DropdownMenu.test.tsx +457 -0
  89. package/src/DropdownMenu/DropdownMenu.tsx +278 -49
  90. package/src/DropdownMenu/DropdownMenuCheckboxItem.doc.mjs +6 -3
  91. package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +2 -1
  92. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +11 -1
  93. package/src/DropdownMenu/__tests__/MenuHeaderFooter.a11y.chromium.spec.ts +195 -0
  94. package/src/DropdownMenu/menuRegionsContext.ts +17 -0
  95. package/src/Layout/container.stylex.ts +11 -21
  96. package/src/Lightbox/Lightbox.test.tsx +58 -0
  97. package/src/Lightbox/Lightbox.tsx +12 -6
  98. package/src/Markdown/plugins/protocol.ts +45 -12
  99. package/src/Markdown/plugins/sourceLines.test.tsx +121 -0
  100. package/src/PowerSearch/PowerSearch.tsx +28 -1
  101. package/src/PowerSearch/PowerSearchEditPopover.test.tsx +284 -0
  102. package/src/PowerSearch/PowerSearchEditPopover.tsx +41 -13
  103. package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +33 -9
  104. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +81 -18
  105. package/src/TimeInput/TouchTimeField.tsx +3 -1
  106. package/src/Tokenizer/Tokenizer.doc.mjs +0 -16
  107. package/src/Tokenizer/Tokenizer.test.tsx +12 -63
  108. package/src/Tokenizer/Tokenizer.tsx +1 -25
  109. package/src/Toolbar/Toolbar.test.tsx +29 -0
  110. package/src/Typeahead/BaseTypeahead.doc.mjs +0 -16
  111. package/src/Typeahead/BaseTypeahead.test.tsx +1 -1
  112. package/src/Typeahead/BaseTypeahead.tsx +1 -21
  113. package/src/Typeahead/Typeahead.doc.mjs +0 -7
  114. package/src/Typeahead/Typeahead.test.tsx +24 -42
  115. package/src/Typeahead/Typeahead.tsx +1 -25
  116. package/src/hooks/useFocusTrap.ts +5 -1
  117. package/src/hooks/useRenamedProp.ts +6 -6
  118. package/src/reset.a11y.chromium.spec.ts +178 -0
  119. package/src/reset.css +8 -8
  120. package/src/reset.test.ts +112 -0
  121. package/src/theme/__tests__/SpacingRootFontSize.a11y.chromium.spec.ts +198 -0
  122. package/src/theme/tokens.stylex.ts +18 -15
  123. package/src/theme/tokens.test.ts +1 -1
  124. package/src/theme/useTheme.test.tsx +4 -4
  125. package/src/theme/useTheme.ts +1 -1
@@ -0,0 +1,195 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file MenuHeaderFooter.a11y.chromium.spec.ts
5
+ * @input The `Core/DropdownMenu` HeaderAndFooter story in a built Storybook
6
+ * and real keyboard and mouse input.
7
+ * @output Real-engine proof that a menu's header and footer sit beside the
8
+ * menu rather than inside it, keep their own keys and presses, and join one
9
+ * Tab sequence with the menu.
10
+ * @position Run with `pnpm test:a11y-contract`.
11
+ */
12
+
13
+ import {expect, test, type Page} from '@playwright/test';
14
+ import {
15
+ DEFAULT_STORYBOOK_DIR,
16
+ serveStorybook,
17
+ type StaticServer,
18
+ } from '@astryxdesign/a11y-spec/storybook';
19
+
20
+ let storybook: StaticServer;
21
+
22
+ test.beforeAll(async () => {
23
+ storybook = await serveStorybook(
24
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
25
+ );
26
+ });
27
+
28
+ test.afterAll(async () => {
29
+ await storybook?.close();
30
+ });
31
+
32
+ const STORY = 'core-dropdownmenu--header-and-footer';
33
+ // The trigger button only: the sub-menu row inside the menu also carries
34
+ // aria-haspopup="menu".
35
+ const TRIGGER = '#storybook-root button[aria-haspopup="menu"]';
36
+
37
+ async function openMenu(page: Page) {
38
+ await page.goto(`${storybook.origin}/iframe.html?id=${STORY}&viewMode=story`);
39
+ await page.locator(TRIGGER).click();
40
+ const menu = page.getByRole('menu', {name: 'Labels', exact: true});
41
+ await expect(menu).toBeVisible();
42
+ // A pointer open focuses the menu itself; let that land first.
43
+ await expect(menu).toBeFocused();
44
+ return menu;
45
+ }
46
+
47
+ test('the menu holds only its rows; the field and button sit beside it', async ({
48
+ page,
49
+ }) => {
50
+ const menu = await openMenu(page);
51
+ // The menu holds menu items, plus the sub-menu row's own nested menu.
52
+ const roles = await menu.evaluate(el =>
53
+ Array.from(el.querySelectorAll('[role]')).map(n => n.getAttribute('role')),
54
+ );
55
+ expect(roles.every(r => r === 'menuitem' || r === 'menu')).toBe(true);
56
+ expect(roles).toContain('menuitem');
57
+ await expect(menu.getByRole('textbox', {name: 'Filter labels'})).toHaveCount(
58
+ 0,
59
+ );
60
+ await expect(menu.getByRole('button', {name: 'Create label'})).toHaveCount(0);
61
+ await expect(
62
+ page.getByRole('textbox', {name: 'Filter labels'}),
63
+ ).toBeVisible();
64
+ await expect(page.getByRole('button', {name: 'Create label'})).toBeVisible();
65
+ });
66
+
67
+ test('typing into the header field filters without moving to a row', async ({
68
+ page,
69
+ }) => {
70
+ await openMenu(page);
71
+ const field = page.getByRole('textbox', {name: 'Filter labels'});
72
+ await field.click();
73
+ await page.keyboard.type('de');
74
+ await expect(field).toHaveValue('de');
75
+ await expect(field).toBeFocused();
76
+ await expect(page.getByRole('menuitem')).toHaveText(['Design', 'Move to']);
77
+ });
78
+
79
+ test('Tab walks header, menu, footer, then closes to the trigger', async ({
80
+ page,
81
+ }) => {
82
+ const menu = await openMenu(page);
83
+ const field = page.getByRole('textbox', {name: 'Filter labels'});
84
+ const create = page.getByRole('button', {name: 'Create label'});
85
+
86
+ await page.keyboard.press('Shift+Tab');
87
+ await expect(field).toBeFocused();
88
+ await page.keyboard.press('Tab');
89
+ await expect(menu).toBeFocused();
90
+ await page.keyboard.press('ArrowDown');
91
+ await expect(page.getByRole('menuitem', {name: 'Bug'})).toBeFocused();
92
+ await page.keyboard.press('Tab');
93
+ await expect(create).toBeFocused();
94
+ await expect(menu).toBeVisible();
95
+
96
+ await page.keyboard.press('Enter');
97
+ await expect(page.getByTestId('created')).toHaveText('1');
98
+ await expect(menu).toBeVisible();
99
+
100
+ await page.keyboard.press('Tab');
101
+ await expect(menu).toBeHidden();
102
+ await expect(page.locator(TRIGGER)).toHaveAttribute('aria-expanded', 'false');
103
+ });
104
+
105
+ test('Shift+Tab from a row goes straight to the header', async ({page}) => {
106
+ const menu = await openMenu(page);
107
+ await page.keyboard.press('ArrowDown');
108
+ await expect(page.getByRole('menuitem', {name: 'Bug'})).toBeFocused();
109
+ await page.keyboard.press('Shift+Tab');
110
+ await expect(
111
+ page.getByRole('textbox', {name: 'Filter labels'}),
112
+ ).toBeFocused();
113
+ await expect(menu).toBeVisible();
114
+ });
115
+
116
+ test('a mouse open draws no keyboard focus ring on the menu', async ({
117
+ page,
118
+ }) => {
119
+ const menu = await openMenu(page);
120
+ const outlineStyle = await menu.evaluate(
121
+ el => getComputedStyle(el).outlineStyle,
122
+ );
123
+ expect(outlineStyle).toBe('none');
124
+ });
125
+
126
+ test('keyboard focus on the menu between the regions is visible', async ({
127
+ page,
128
+ }) => {
129
+ const menu = await openMenu(page);
130
+ await page.keyboard.press('Shift+Tab');
131
+ await page.keyboard.press('Tab');
132
+ await expect(menu).toBeFocused();
133
+ const outline = await menu.evaluate(el => {
134
+ const style = getComputedStyle(el);
135
+ return {style: style.outlineStyle, width: parseFloat(style.outlineWidth)};
136
+ });
137
+ expect(outline.style).not.toBe('none');
138
+ expect(outline.width).toBeGreaterThan(0);
139
+ });
140
+
141
+ test('Tab and Shift+Tab from an open sub-menu close it and reach a region', async ({
142
+ page,
143
+ }) => {
144
+ const menu = await openMenu(page);
145
+ const subTrigger = page.getByRole('menuitem', {name: 'Move to'});
146
+ await page.keyboard.press('End');
147
+ await expect(subTrigger).toBeFocused();
148
+ await page.keyboard.press('ArrowRight');
149
+ await expect(page.getByRole('menuitem', {name: 'Archive'})).toBeFocused();
150
+ await page.keyboard.press('Tab');
151
+ await expect(page.getByRole('button', {name: 'Create label'})).toBeFocused();
152
+ await expect(subTrigger).toHaveAttribute('aria-expanded', 'false');
153
+ await expect(menu).toBeVisible();
154
+
155
+ await page.keyboard.press('Shift+Tab');
156
+ await expect(menu).toBeFocused();
157
+ await page.keyboard.press('End');
158
+ await page.keyboard.press('ArrowRight');
159
+ await expect(page.getByRole('menuitem', {name: 'Archive'})).toBeFocused();
160
+ await page.keyboard.press('Shift+Tab');
161
+ await expect(
162
+ page.getByRole('textbox', {name: 'Filter labels'}),
163
+ ).toBeFocused();
164
+ await expect(subTrigger).toHaveAttribute('aria-expanded', 'false');
165
+ });
166
+
167
+ test('Escape in the header closes the menu and returns focus to the trigger', async ({
168
+ page,
169
+ }) => {
170
+ const menu = await openMenu(page);
171
+ await page.getByRole('textbox', {name: 'Filter labels'}).click();
172
+ await page.keyboard.press('Escape');
173
+ await expect(menu).toBeHidden();
174
+ await expect(page.locator(TRIGGER)).toBeFocused();
175
+ });
176
+
177
+ test('a finger tap on the footer button acts and the menu stays open', async ({
178
+ browser,
179
+ }) => {
180
+ const context = await browser.newContext({hasTouch: true, isMobile: true});
181
+ const page = await context.newPage();
182
+ try {
183
+ await page.goto(
184
+ `${storybook.origin}/iframe.html?id=${STORY}&viewMode=story`,
185
+ );
186
+ await page.locator(TRIGGER).tap();
187
+ const menu = page.getByRole('menu', {name: 'Labels', exact: true});
188
+ await expect(menu).toBeVisible();
189
+ await page.getByRole('button', {name: 'Create label'}).tap();
190
+ await expect(page.getByTestId('created')).toHaveText('1');
191
+ await expect(menu).toBeVisible();
192
+ } finally {
193
+ await context.close();
194
+ }
195
+ });
@@ -0,0 +1,17 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ 'use client';
3
+ /**
4
+ * @file menuRegionsContext.ts
5
+ * @input DropdownMenu's `header` and `footer` props
6
+ * @output `MenuRegionsContext`: whether the root menu has a header or footer
7
+ * @position Internal; not exported from the package. A sub-menu flyout reads it
8
+ * to close itself on Tab, so the root menu can move focus to a region.
9
+ *
10
+ * A plain context rather than a layer-scoped one on purpose: each sub-menu
11
+ * flyout is its own layer, and it must still see the root's value. A nested
12
+ * DropdownMenu provides its own value, so it never inherits an outer one.
13
+ */
14
+ import {createContext} from 'react';
15
+
16
+ export const MenuRegionsContext = createContext(false);
17
+ MenuRegionsContext.displayName = 'MenuRegionsContext';
@@ -19,10 +19,8 @@
19
19
  * --astryx-card-padding-block-end
20
20
  *
21
21
  * Read order per level: `var(--astryx-…, <next level>)`, terminating at
22
- * `--spacing-4`. Same pattern for section, dialog, and the Lab drawer.
23
- * Bottom-sheet keeps its released unpadded default: its edges terminate at 0px
24
- * and its Layout insets at no value, so an unthemed sheet changes nothing for
25
- * descendants.
22
+ * `--spacing-4`. Same pattern for section, dialog, bottom-sheet, and the Lab
23
+ * drawer.
26
24
  *
27
25
  * ```ts
28
26
  * components: {
@@ -141,19 +139,11 @@ const dialogInlineEnd = `var(--astryx-dialog-padding-inline-end, ${dialogInline}
141
139
  const dialogBlockStart = `var(--astryx-dialog-padding-block-start, ${dialogShorthand})`;
142
140
  const dialogBlockEnd = `var(--astryx-dialog-padding-block-end, ${dialogShorthand})`;
143
141
 
144
- // Bottom sheet padding chains: --astryx-* then the next specificity level.
145
- // Unlike card/section/dialog, the sheet's released default is no padding, so
146
- // the edge chains terminate at 0px. The Layout inset chains have NO terminal
147
- // value: with no theme padding they are invalid at computed-value time, which
148
- // leaves the overlay reset in force and lets a Layout inside the sheet fall
149
- // back to its own default, exactly as before the sheet was a container. Built
150
- // as chained const strings (no function calls) so StyleX can statically
151
- // analyze them; see naming.ts for the prefix policy.
152
- const bottomSheetThemeShorthand = 'var(--astryx-bottom-sheet-padding)';
153
- const bottomSheetThemeInline = `var(--astryx-bottom-sheet-padding-inline, ${bottomSheetThemeShorthand})`;
154
- const bottomSheetThemeInlineStart = `var(--astryx-bottom-sheet-padding-inline-start, ${bottomSheetThemeInline})`;
155
- const bottomSheetThemeBlockStart = `var(--astryx-bottom-sheet-padding-block-start, ${bottomSheetThemeShorthand})`;
156
- const bottomSheetShorthand = 'var(--astryx-bottom-sheet-padding, 0px)';
142
+ // Bottom sheet padding chains: --astryx-* then the next specificity level,
143
+ // terminating at --spacing-4 like Dialog. Built as chained const strings (no
144
+ // function calls) so StyleX can statically analyze them; see naming.ts for the
145
+ // prefix policy.
146
+ const bottomSheetShorthand = `var(--astryx-bottom-sheet-padding, ${SP4})`;
157
147
  const bottomSheetInline = `var(--astryx-bottom-sheet-padding-inline, ${bottomSheetShorthand})`;
158
148
  const bottomSheetInlineStart = `var(--astryx-bottom-sheet-padding-inline-start, ${bottomSheetInline})`;
159
149
  const bottomSheetInlineEnd = `var(--astryx-bottom-sheet-padding-inline-end, ${bottomSheetInline})`;
@@ -266,16 +256,16 @@ const bottomSheetDefaultPaddingStyles = stylex.create({
266
256
  '--container-padding-block-end': bottomSheetBlockEnd,
267
257
  },
268
258
  layoutPaddingOuterX: {
269
- '--layout-padding-outer-x': bottomSheetThemeInlineStart,
259
+ '--layout-padding-outer-x': bottomSheetInlineStart,
270
260
  },
271
261
  layoutPaddingOuterY: {
272
- '--layout-padding-outer-y': bottomSheetThemeBlockStart,
262
+ '--layout-padding-outer-y': bottomSheetBlockStart,
273
263
  },
274
264
  layoutPaddingInnerX: {
275
- '--layout-padding-inner-x': bottomSheetThemeInlineStart,
265
+ '--layout-padding-inner-x': bottomSheetInlineStart,
276
266
  },
277
267
  layoutPaddingInnerY: {
278
- '--layout-padding-inner-y': bottomSheetThemeBlockStart,
268
+ '--layout-padding-inner-y': bottomSheetBlockStart,
279
269
  },
280
270
  });
281
271
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
4
4
  import {render, screen, fireEvent, waitFor} from '@testing-library/react';
5
+ import {Profiler} from 'react';
5
6
  import {Lightbox} from './Lightbox';
6
7
  import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
7
8
  import {InternationalizationProvider} from '../i18n';
@@ -676,3 +677,60 @@ describe('Lightbox', () => {
676
677
  });
677
678
  });
678
679
  });
680
+
681
+ describe('Lightbox zoom reset timing', () => {
682
+ it('shows the next image unzoomed in the same commit that changes it', () => {
683
+ const media = [
684
+ {src: '/a.jpg', alt: 'Image A'},
685
+ {src: '/b.jpg', alt: 'Image B'},
686
+ ];
687
+ const frames: string[] = [];
688
+ const onRender = () =>
689
+ frames.push(
690
+ screen
691
+ .queryByRole('button', {name: 'Zoom'})
692
+ ?.getAttribute('aria-pressed') ?? '',
693
+ );
694
+ render(
695
+ <Profiler id="lightbox" onRender={onRender}>
696
+ <Lightbox isOpen={true} onOpenChange={() => {}} media={media} hasZoom />
697
+ </Profiler>,
698
+ );
699
+ fireEvent.keyDown(screen.getByRole('button', {name: 'Zoom'}), {
700
+ key: 'Enter',
701
+ });
702
+ expect(screen.getByRole('button', {name: 'Zoom'})).toHaveAttribute(
703
+ 'aria-pressed',
704
+ 'true',
705
+ );
706
+
707
+ frames.length = 0;
708
+ fireEvent.click(screen.getByRole('button', {name: /next/i}));
709
+
710
+ expect(frames.length).toBeGreaterThan(0);
711
+ expect(frames).not.toContain('true');
712
+ expect(screen.getByRole('button', {name: 'Zoom'})).toHaveAttribute(
713
+ 'aria-pressed',
714
+ 'false',
715
+ );
716
+ });
717
+ });
718
+
719
+ describe('Lightbox with an unusable controlled index', () => {
720
+ it('renders without looping when index is NaN', () => {
721
+ expect(() =>
722
+ render(
723
+ <Lightbox
724
+ isOpen={false}
725
+ onOpenChange={() => {}}
726
+ media={[
727
+ {src: '/a.jpg', alt: 'Image A'},
728
+ {src: '/b.jpg', alt: 'Image B'},
729
+ ]}
730
+ index={Number.NaN}
731
+ onIndexChange={() => {}}
732
+ />,
733
+ ),
734
+ ).not.toThrow();
735
+ });
736
+ });
@@ -367,14 +367,20 @@ export function Lightbox({
367
367
  // Scroll lock
368
368
  useScrollLock(isOpen);
369
369
 
370
- // Reset zoom on image change
371
- useEffect(() => {
372
- // Reset image view state when the active media item changes.
373
- // eslint-disable-next-line @eslint-react/set-state-in-effect
370
+ // A new media item starts unzoomed. Adjusted during render, so the new item
371
+ // never paints at the previous item's zoom and pan.
372
+ const currentSrc = currentItem?.src;
373
+ const [viewedItem, setViewedItem] = useState({index, src: currentSrc});
374
+ // Object.is, matching the dependency comparison of the effect this replaces:
375
+ // a controlled index of NaN must not re-render forever.
376
+ if (
377
+ !Object.is(viewedItem.index, index) ||
378
+ !Object.is(viewedItem.src, currentSrc)
379
+ ) {
380
+ setViewedItem({index, src: currentSrc});
374
381
  setZoom(1);
375
- // eslint-disable-next-line @eslint-react/set-state-in-effect
376
382
  setPan({x: 0, y: 0});
377
- }, [index, currentItem?.src]);
383
+ }
378
384
 
379
385
  // Announce gallery navigation to screen readers. Moving between images only
380
386
  // updates the visual counter, which is silent to assistive tech, so mirror
@@ -708,10 +708,46 @@ export function freezeMarkdownPluginData<T extends MarkdownPluginData>(
708
708
  return value;
709
709
  }
710
710
 
711
+ /**
712
+ * The identity of a source-backed node's position: its offsets, or, for a
713
+ * block nested in a list item or quote, which carries lines and no offsets
714
+ * while source lines are on (`module:Markdown/sourceLines`), its lines.
715
+ */
711
716
  function positionKey(position: MarkdownAstPosition | undefined): string | null {
712
717
  const start = position?.start.offset;
713
718
  const end = position?.end.offset;
714
- return start == null || end == null ? null : `${start}:${end}`;
719
+ if (start != null && end != null) {
720
+ return `${start}:${end}`;
721
+ }
722
+ const startLine = position?.start.line;
723
+ const endLine = position?.end.line;
724
+ return startLine == null || endLine == null
725
+ ? null
726
+ : `line ${startLine}:${endLine}`;
727
+ }
728
+
729
+ /** A position's two points measure the same thing and never run backwards. */
730
+ function isValidPosition(position: MarkdownAstPosition): boolean {
731
+ const measures = [
732
+ [position.start?.offset, position.end?.offset, 0],
733
+ [position.start?.line, position.end?.line, 1],
734
+ ] as const;
735
+ let measured = false;
736
+ for (const [start, end, minimum] of measures) {
737
+ if (start === undefined && end === undefined) {
738
+ continue;
739
+ }
740
+ if (
741
+ !Number.isInteger(start) ||
742
+ !Number.isInteger(end) ||
743
+ (start as number) < minimum ||
744
+ (end as number) < (start as number)
745
+ ) {
746
+ return false;
747
+ }
748
+ measured = true;
749
+ }
750
+ return measured;
715
751
  }
716
752
 
717
753
  interface CodeInternalProperties {
@@ -825,6 +861,8 @@ function restoreCodeInternalProperties(
825
861
 
826
862
  interface SourceInvariant {
827
863
  readonly type: string;
864
+ readonly startLine?: number;
865
+ readonly endLine?: number;
828
866
  readonly depth?: unknown;
829
867
  readonly ordered?: unknown;
830
868
  readonly start?: unknown;
@@ -844,6 +882,8 @@ function collectSourceInvariants(
844
882
  const record = node as unknown as Record<string, unknown>;
845
883
  const invariant = {
846
884
  type: node.type,
885
+ startLine: node.position?.start.line,
886
+ endLine: node.position?.end.line,
847
887
  depth: record.depth,
848
888
  ordered: record.ordered,
849
889
  start: record.start,
@@ -1048,23 +1088,16 @@ function validateAst(
1048
1088
 
1049
1089
  const position = node.position as MarkdownAstPosition | undefined;
1050
1090
  const key = positionKey(position);
1051
- if (position != null) {
1052
- const start = position.start?.offset;
1053
- const end = position.end?.offset;
1054
- if (
1055
- !Number.isInteger(start) ||
1056
- !Number.isInteger(end) ||
1057
- (start as number) < 0 ||
1058
- (end as number) < (start as number)
1059
- ) {
1060
- return false;
1061
- }
1091
+ if (position != null && !isValidPosition(position)) {
1092
+ return false;
1062
1093
  }
1063
1094
  if (key != null) {
1064
1095
  const invariants = sourcePositions.get(key) ?? [];
1065
1096
  const invariantIndex = invariants.findIndex(
1066
1097
  invariant =>
1067
1098
  invariant.type === type &&
1099
+ invariant.startLine === position?.start?.line &&
1100
+ invariant.endLine === position?.end?.line &&
1068
1101
  invariant.depth === node.depth &&
1069
1102
  invariant.ordered === node.ordered &&
1070
1103
  invariant.start === node.start &&
@@ -136,6 +136,16 @@ function hasAnyLine(value: unknown): boolean {
136
136
  return JSON.stringify(value).includes('"line"');
137
137
  }
138
138
 
139
+ /** A parsed node, walked generically by the transforms below. */
140
+ interface TreeNode {
141
+ readonly type?: string;
142
+ readonly position?: {
143
+ readonly start: {readonly line?: number};
144
+ readonly end: {readonly line?: number};
145
+ };
146
+ readonly children?: ReadonlyArray<TreeNode>;
147
+ }
148
+
139
149
  describe('markdownSourceLinesPlugin', () => {
140
150
  beforeEach(() => {
141
151
  parseCalls.full = 0;
@@ -466,6 +476,117 @@ describe('markdownSourceLinesPlugin', () => {
466
476
  expect(hasAnyLine(parseMarkdownAst(NESTED, {plugins: []}))).toBe(false);
467
477
  });
468
478
 
479
+ it('composes with a transform that rebuilds nested blocks, which keep their lines', () => {
480
+ const rebuild = (nodes: ReadonlyArray<TreeNode>): TreeNode[] =>
481
+ nodes.map(node =>
482
+ node.children == null
483
+ ? {...node}
484
+ : {...node, children: rebuild(node.children)},
485
+ );
486
+ const copy = createMarkdownPlugin({
487
+ apiVersion: 1,
488
+ name: 'test-rebuild',
489
+ transform: document => ({
490
+ ...document,
491
+ children: rebuild(
492
+ document.children as unknown as ReadonlyArray<TreeNode>,
493
+ ) as unknown as typeof document.children,
494
+ }),
495
+ });
496
+ const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
497
+ const {container} = render(
498
+ <Markdown plugins={[copy, markdownSourceLinesPlugin]}>{NESTED}</Markdown>,
499
+ );
500
+ expect(warning).not.toHaveBeenCalled();
501
+ warning.mockRestore();
502
+ expect(stamps(container, '[data-source-line]')).toEqual(
503
+ stamps(renderDocument(NESTED, true), '[data-source-line]'),
504
+ );
505
+ });
506
+
507
+ it('refuses a transform that shifts a nested block to other lines', () => {
508
+ const shift = (nodes: ReadonlyArray<TreeNode>): TreeNode[] =>
509
+ nodes.map(node => {
510
+ if (node.type === 'paragraph' && node.position?.start.line === 12) {
511
+ return {...node, position: {start: {line: 13}, end: {line: 13}}};
512
+ }
513
+ return node.children == null
514
+ ? node
515
+ : {...node, children: shift(node.children)};
516
+ });
517
+ const forge = createMarkdownPlugin({
518
+ apiVersion: 1,
519
+ name: 'test-shift-lines',
520
+ transform: document => ({
521
+ ...document,
522
+ children: shift(
523
+ document.children as unknown as ReadonlyArray<TreeNode>,
524
+ ) as unknown as typeof document.children,
525
+ }),
526
+ });
527
+ const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
528
+ const {container} = render(
529
+ <Markdown plugins={[forge, markdownSourceLinesPlugin]}>
530
+ {NESTED}
531
+ </Markdown>,
532
+ );
533
+ expect(warning).toHaveBeenCalledWith(
534
+ expect.stringContaining('"test-shift-lines" failed in transform'),
535
+ expect.anything(),
536
+ );
537
+ warning.mockRestore();
538
+ expect(stamps(container, '[data-source-line]')).toEqual(
539
+ stamps(renderDocument(NESTED, true), '[data-source-line]'),
540
+ );
541
+ });
542
+
543
+ it('refuses a transform that moves a top-level block to other lines while keeping its offsets', () => {
544
+ const move = (nodes: ReadonlyArray<TreeNode>): TreeNode[] =>
545
+ nodes.map(node => {
546
+ const position = node.position;
547
+ if (
548
+ node.type !== 'paragraph' ||
549
+ position == null ||
550
+ position.start.line !== 20
551
+ ) {
552
+ return node;
553
+ }
554
+ return {
555
+ ...node,
556
+ position: {
557
+ start: {...position.start, line: 19},
558
+ end: {...position.end, line: 19},
559
+ },
560
+ };
561
+ });
562
+ const forge = createMarkdownPlugin({
563
+ apiVersion: 1,
564
+ name: 'test-move-lines-keep-offsets',
565
+ transform: document => ({
566
+ ...document,
567
+ children: move(
568
+ document.children as unknown as ReadonlyArray<TreeNode>,
569
+ ) as unknown as typeof document.children,
570
+ }),
571
+ });
572
+ const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
573
+ const {container} = render(
574
+ <Markdown plugins={[forge, markdownSourceLinesPlugin]}>
575
+ {NESTED}
576
+ </Markdown>,
577
+ );
578
+ expect(warning).toHaveBeenCalledWith(
579
+ expect.stringContaining(
580
+ '"test-move-lines-keep-offsets" failed in transform',
581
+ ),
582
+ expect.anything(),
583
+ );
584
+ warning.mockRestore();
585
+ expect(stamps(container, '[data-source-line]')).toEqual(
586
+ stamps(renderDocument(NESTED, true), '[data-source-line]'),
587
+ );
588
+ });
589
+
469
590
  it('keeps the released DOM and renderer props byte-identical without the plugin', () => {
470
591
  const plain = renderToStaticMarkup(<Markdown>{SOURCE}</Markdown>);
471
592
  expect(
@@ -755,6 +755,25 @@ export function PowerSearch({
755
755
  return;
756
756
  }
757
757
 
758
+ // An "empty" operator filter has no value for the built-in editor to
759
+ // edit: commit it as that editor's save would, the way adding one does,
760
+ // instead of opening it. A consumer's `empty` Editor override still opens.
761
+ const operator = config.getOperator(filter.field, filter.operator);
762
+ if (
763
+ operator?.value.type === 'empty' &&
764
+ componentOverrides?.empty?.Editor == null
765
+ ) {
766
+ const newFilters = [...filters];
767
+ newFilters[index] = {
768
+ field: filter.field,
769
+ operator: filter.operator,
770
+ value: {type: 'empty'},
771
+ };
772
+ onChange(newFilters, 'edit', index);
773
+ setPopoverState({type: 'idle'});
774
+ return;
775
+ }
776
+
758
777
  setPopoverState({
759
778
  type: 'editing',
760
779
  filterIndex: index,
@@ -765,7 +784,15 @@ export function PowerSearch({
765
784
  },
766
785
  });
767
786
  },
768
- [filters, isReadOnly, isDisabled, setPopoverState],
787
+ [
788
+ filters,
789
+ isReadOnly,
790
+ isDisabled,
791
+ config,
792
+ componentOverrides,
793
+ onChange,
794
+ setPopoverState,
795
+ ],
769
796
  );
770
797
 
771
798
  // Handle popover save