@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.
- package/CHANGELOG.md +186 -0
- package/dist/BottomSheet/BottomSheet.d.ts +5 -4
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetPanel.d.ts +1 -1
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetPanel.js +1 -1
- package/dist/Calendar/styles.d.ts +6 -6
- package/dist/Chat/ChatComposerInput.d.ts +0 -11
- package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
- package/dist/Chat/ChatComposerInput.js +0 -11
- package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
- package/dist/Chat/useTriggerMenu.js +1 -1
- package/dist/CommandPalette/CommandPaletteList.d.ts +3 -2
- package/dist/CommandPalette/CommandPaletteList.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteList.js +3 -2
- package/dist/DateInput/TouchDateField.d.ts.map +1 -1
- package/dist/DateInput/TouchDateField.js +4 -1
- package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
- package/dist/DateTimeInput/TouchDateTimeField.js +29 -13
- package/dist/DropdownMenu/DropdownMenu.d.ts +11 -0
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +173 -11
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +13 -2
- package/dist/DropdownMenu/menuRegionsContext.d.ts +2 -0
- package/dist/DropdownMenu/menuRegionsContext.d.ts.map +1 -0
- package/dist/DropdownMenu/menuRegionsContext.js +17 -0
- package/dist/Field/inputStyles.stylex.d.ts +3 -3
- package/dist/Layout/container.stylex.d.ts +18 -20
- package/dist/Layout/container.stylex.d.ts.map +1 -1
- package/dist/Layout/container.stylex.js +15 -25
- package/dist/Layout/padding.stylex.d.ts +231 -231
- package/dist/Lightbox/Lightbox.d.ts.map +1 -1
- package/dist/Lightbox/Lightbox.js +15 -6
- package/dist/Markdown/plugins/protocol.d.ts.map +1 -1
- package/dist/Markdown/plugins/protocol.js +33 -8
- package/dist/NavItem/navItemStyles.stylex.d.ts +5 -5
- package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearch.js +21 -1
- package/dist/PowerSearch/PowerSearchEditPopover.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearchEditPopover.js +30 -15
- package/dist/Stack/stack.stylex.d.ts +22 -22
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +89 -14
- package/dist/Table/table.stylex.d.ts +6 -6
- package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
- package/dist/TimeInput/TouchTimeField.js +4 -1
- package/dist/Tokenizer/Tokenizer.d.ts +1 -12
- package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
- package/dist/Tokenizer/Tokenizer.js +1 -13
- package/dist/Typeahead/BaseTypeahead.d.ts +0 -11
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +1 -9
- package/dist/Typeahead/Typeahead.d.ts +1 -12
- package/dist/Typeahead/Typeahead.d.ts.map +1 -1
- package/dist/Typeahead/Typeahead.js +1 -13
- package/dist/astryx.css +17 -12
- package/dist/hooks/useFocusTrap.d.ts +8 -0
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +5 -1
- package/dist/hooks/useRenamedProp.d.ts +6 -6
- package/dist/hooks/useRenamedProp.js +6 -6
- package/dist/reset.a11y.chromium.spec.d.ts +2 -0
- package/dist/reset.a11y.chromium.spec.d.ts.map +1 -0
- package/dist/reset.a11y.chromium.spec.js +176 -0
- package/dist/theme/tokens.stylex.d.ts +30 -30
- package/dist/theme/tokens.stylex.d.ts.map +1 -1
- package/dist/theme/tokens.stylex.js +18 -15
- package/dist/theme/useTheme.d.ts +1 -1
- package/package.json +4 -4
- package/src/BottomSheet/BottomSheet.doc.mjs +4 -4
- package/src/BottomSheet/BottomSheet.tsx +5 -4
- package/src/BottomSheet/BottomSheetPanel.test.tsx +6 -9
- package/src/BottomSheet/BottomSheetPanel.tsx +2 -3
- package/src/Chat/ChatComposerInput.doc.mjs +3 -3
- package/src/Chat/ChatComposerInput.test.tsx +7 -55
- package/src/Chat/ChatComposerInput.tsx +0 -36
- package/src/Chat/useTriggerMenu.tsx +1 -4
- package/src/CommandPalette/CommandPalette.spec.md +2 -0
- package/src/CommandPalette/CommandPaletteList.spec.md +189 -0
- package/src/CommandPalette/CommandPaletteList.test.tsx +47 -0
- package/src/CommandPalette/CommandPaletteList.tsx +3 -2
- package/src/DateInput/TouchDateField.tsx +3 -1
- package/src/DateTimeInput/DateTimeInputTouch.test.tsx +60 -1
- package/src/DateTimeInput/TouchDateTimeField.tsx +31 -14
- package/src/DropdownMenu/DropdownMenu.doc.mjs +12 -0
- package/src/DropdownMenu/DropdownMenu.spec.md +65 -40
- package/src/DropdownMenu/DropdownMenu.test.tsx +457 -0
- package/src/DropdownMenu/DropdownMenu.tsx +278 -49
- package/src/DropdownMenu/DropdownMenuCheckboxItem.doc.mjs +6 -3
- package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +2 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +11 -1
- package/src/DropdownMenu/__tests__/MenuHeaderFooter.a11y.chromium.spec.ts +195 -0
- package/src/DropdownMenu/menuRegionsContext.ts +17 -0
- package/src/Layout/container.stylex.ts +11 -21
- package/src/Lightbox/Lightbox.test.tsx +58 -0
- package/src/Lightbox/Lightbox.tsx +12 -6
- package/src/Markdown/plugins/protocol.ts +45 -12
- package/src/Markdown/plugins/sourceLines.test.tsx +121 -0
- package/src/PowerSearch/PowerSearch.tsx +28 -1
- package/src/PowerSearch/PowerSearchEditPopover.test.tsx +284 -0
- package/src/PowerSearch/PowerSearchEditPopover.tsx +41 -13
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +33 -9
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +81 -18
- package/src/TimeInput/TouchTimeField.tsx +3 -1
- package/src/Tokenizer/Tokenizer.doc.mjs +0 -16
- package/src/Tokenizer/Tokenizer.test.tsx +12 -63
- package/src/Tokenizer/Tokenizer.tsx +1 -25
- package/src/Toolbar/Toolbar.test.tsx +29 -0
- package/src/Typeahead/BaseTypeahead.doc.mjs +0 -16
- package/src/Typeahead/BaseTypeahead.test.tsx +1 -1
- package/src/Typeahead/BaseTypeahead.tsx +1 -21
- package/src/Typeahead/Typeahead.doc.mjs +0 -7
- package/src/Typeahead/Typeahead.test.tsx +24 -42
- package/src/Typeahead/Typeahead.tsx +1 -25
- package/src/hooks/useFocusTrap.ts +5 -1
- package/src/hooks/useRenamedProp.ts +6 -6
- package/src/reset.a11y.chromium.spec.ts +178 -0
- package/src/reset.css +8 -8
- package/src/reset.test.ts +112 -0
- package/src/theme/__tests__/SpacingRootFontSize.a11y.chromium.spec.ts +198 -0
- package/src/theme/tokens.stylex.ts +18 -15
- package/src/theme/tokens.test.ts +1 -1
- package/src/theme/useTheme.test.tsx +4 -4
- 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
|
|
23
|
-
*
|
|
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
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
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':
|
|
259
|
+
'--layout-padding-outer-x': bottomSheetInlineStart,
|
|
270
260
|
},
|
|
271
261
|
layoutPaddingOuterY: {
|
|
272
|
-
'--layout-padding-outer-y':
|
|
262
|
+
'--layout-padding-outer-y': bottomSheetBlockStart,
|
|
273
263
|
},
|
|
274
264
|
layoutPaddingInnerX: {
|
|
275
|
-
'--layout-padding-inner-x':
|
|
265
|
+
'--layout-padding-inner-x': bottomSheetInlineStart,
|
|
276
266
|
},
|
|
277
267
|
layoutPaddingInnerY: {
|
|
278
|
-
'--layout-padding-inner-y':
|
|
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
|
-
//
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
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
|