@astryxdesign/core 0.7.4-canary.626f01e → 0.7.4-canary.768c2f0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/CommandPalette/CommandPaletteItem.d.ts +1 -0
- package/dist/CommandPalette/CommandPaletteItem.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteItem.js +5 -1
- package/package.json +3 -3
- package/src/CommandPalette/CommandPalette.doc.mjs +4 -1
- package/src/CommandPalette/CommandPaletteItem.doc.mjs +17 -5
- package/src/CommandPalette/CommandPaletteItem.test.tsx +65 -12
- package/src/CommandPalette/CommandPaletteItem.tsx +6 -1
- package/src/CommandPalette/__tests__/CommandPaletteItemAudit.a11y.chromium.spec.ts +250 -20
- package/src/Overlay/Overlay.spec.md +304 -0
|
@@ -28,6 +28,7 @@ export interface CommandPaletteItemProps extends Omit<BaseProps<HTMLDivElement>,
|
|
|
28
28
|
isHighlighted?: boolean;
|
|
29
29
|
/**
|
|
30
30
|
* Whether this item is currently selected (picker mode).
|
|
31
|
+
* When omitted inside CommandPalette, derived from the palette value.
|
|
31
32
|
* @default false
|
|
32
33
|
*/
|
|
33
34
|
isSelected?: boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CommandPaletteItem.d.ts","sourceRoot":"","sources":["../../src/CommandPalette/CommandPaletteItem.tsx"],"names":[],"mappings":"AAGA;;;;;;;;GAQG;AAEH,OAAO,EAA0C,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAE9E,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAqD5C,MAAM,WAAW,uBAAwB,SAAQ,IAAI,CACnD,SAAS,CAAC,cAAc,CAAC,EACzB,UAAU,GAAG,UAAU,CACxB;IACC,yCAAyC;IACzC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAChC,qDAAqD;IACrD,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB
|
|
1
|
+
{"version":3,"file":"CommandPaletteItem.d.ts","sourceRoot":"","sources":["../../src/CommandPalette/CommandPaletteItem.tsx"],"names":[],"mappings":"AAGA;;;;;;;;GAQG;AAEH,OAAO,EAA0C,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAE9E,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAqD5C,MAAM,WAAW,uBAAwB,SAAQ,IAAI,CACnD,SAAS,CAAC,cAAc,CAAC,EACzB,UAAU,GAAG,UAAU,CACxB;IACC,yCAAyC;IACzC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAChC,qDAAqD;IACrD,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,iFAAiF;IACjF,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,kBAAkB,CAAC,EACjC,KAAK,EACL,QAAQ,EACR,aAAa,EAAE,qBAAqB,EACpC,UAAU,EAAE,kBAAkB,EAC9B,UAAkB,EAClB,QAAQ,EACR,GAAG,EACH,MAAM,EACN,SAAS,EACT,KAAK,EACL,EAAE,EAAE,MAAM,EACV,OAAO,EAAE,WAAW,EACpB,YAAY,EAAE,gBAAgB,EAC9B,GAAG,KAAK,EACT,EAAE,uBAAuB,+BAwFzB;yBAvGe,kBAAkB"}
|
|
@@ -148,7 +148,11 @@ export function CommandPaletteItem({
|
|
|
148
148
|
...pressable,
|
|
149
149
|
onClick: isDisabled ? undefined : composeEventHandlers(onClickProp, handleClick),
|
|
150
150
|
onMouseEnter: onMouseEnterProp,
|
|
151
|
-
...mergeProps(themeProps('command-palette-item'
|
|
151
|
+
...mergeProps(themeProps('command-palette-item', {
|
|
152
|
+
highlighted: isHighlighted ? 'highlighted' : null,
|
|
153
|
+
selected: isSelected ? 'selected' : null,
|
|
154
|
+
disabled: isDisabled ? 'disabled' : null
|
|
155
|
+
}), stylex.props(styles.item, !isDisabled && interactionOverlayStyles.backgroundColor, isHighlighted && styles.itemHighlighted, isSelected && styles.itemSelected, isDisabled && styles.itemDisabled, xstyle), className, style),
|
|
152
156
|
children: children
|
|
153
157
|
});
|
|
154
158
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/core",
|
|
3
|
-
"version": "0.7.4-canary.
|
|
3
|
+
"version": "0.7.4-canary.768c2f0",
|
|
4
4
|
"displayName": "Astryx Core",
|
|
5
5
|
"description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
|
|
6
6
|
"author": "Meta Open Source",
|
|
@@ -792,8 +792,8 @@
|
|
|
792
792
|
"react-dom": ">=19.0.0"
|
|
793
793
|
},
|
|
794
794
|
"devDependencies": {
|
|
795
|
-
"@astryxdesign/a11y-spec": "0.7.4-canary.
|
|
796
|
-
"@astryxdesign/cli": "0.7.4-canary.
|
|
795
|
+
"@astryxdesign/a11y-spec": "0.7.4-canary.768c2f0",
|
|
796
|
+
"@astryxdesign/cli": "0.7.4-canary.768c2f0",
|
|
797
797
|
"@babel/cli": "^7.29.7",
|
|
798
798
|
"@babel/core": "^7.29.7",
|
|
799
799
|
"@babel/preset-react": "^7.29.7",
|
|
@@ -225,7 +225,10 @@ export const docs = {
|
|
|
225
225
|
{className: 'astryx-command-palette-group'},
|
|
226
226
|
{className: 'astryx-command-palette-group-heading'},
|
|
227
227
|
{className: 'astryx-command-palette-input'},
|
|
228
|
-
{
|
|
228
|
+
{
|
|
229
|
+
className: 'astryx-command-palette-item',
|
|
230
|
+
states: ['highlighted', 'selected', 'disabled'],
|
|
231
|
+
},
|
|
229
232
|
{className: 'astryx-command-palette-list'},
|
|
230
233
|
],
|
|
231
234
|
},
|
|
@@ -21,7 +21,12 @@ export const docs = {
|
|
|
21
21
|
{
|
|
22
22
|
guidance: true,
|
|
23
23
|
description:
|
|
24
|
-
'
|
|
24
|
+
'Inside CommandPalette, each item is an option in the listbox popup of the WAI-ARIA combobox pattern: keyboard focus stays in the query field, arrow keys move the highlight through aria-activedescendant, and Enter selects the highlighted item.',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
guidance: false,
|
|
28
|
+
description:
|
|
29
|
+
'Nest buttons, links, or other interactive controls inside an item. Option semantics make descendants presentational, so use non-interactive content such as icons, descriptions, and shortcut hints, and activate the option itself.',
|
|
25
30
|
},
|
|
26
31
|
{
|
|
27
32
|
guidance: false,
|
|
@@ -45,7 +50,12 @@ export const docs = {
|
|
|
45
50
|
],
|
|
46
51
|
},
|
|
47
52
|
theming: {
|
|
48
|
-
targets: [
|
|
53
|
+
targets: [
|
|
54
|
+
{
|
|
55
|
+
className: 'astryx-command-palette-item',
|
|
56
|
+
states: ['highlighted', 'selected', 'disabled'],
|
|
57
|
+
},
|
|
58
|
+
],
|
|
49
59
|
},
|
|
50
60
|
props: [
|
|
51
61
|
{
|
|
@@ -79,7 +89,7 @@ export const docs = {
|
|
|
79
89
|
name: 'isSelected',
|
|
80
90
|
type: 'boolean',
|
|
81
91
|
description:
|
|
82
|
-
'Whether this item represents the selected value in picker mode.',
|
|
92
|
+
'Whether this item represents the selected value in picker mode. Derived from the CommandPalette value when omitted inside CommandPalette.',
|
|
83
93
|
default: 'false',
|
|
84
94
|
},
|
|
85
95
|
{
|
|
@@ -117,7 +127,8 @@ export const docsZh = {
|
|
|
117
127
|
'通过指针激活此条目后调用。CommandPalette 中的键盘选择通过根组件的 onValueChange 报告。',
|
|
118
128
|
isHighlighted:
|
|
119
129
|
'此条目是否为当前键盘结果。在 CommandPalette 内省略时从上下文派生。',
|
|
120
|
-
isSelected:
|
|
130
|
+
isSelected:
|
|
131
|
+
'此条目是否代表选择器模式中的已选值。在 CommandPalette 内省略时从面板的值派生。',
|
|
121
132
|
isDisabled: '条目是否不可用并禁止指针激活。',
|
|
122
133
|
xstyle: 'StyleX 布局自定义样式。必须是 stylex.create() 的值。',
|
|
123
134
|
},
|
|
@@ -135,7 +146,8 @@ export const docsDense = {
|
|
|
135
146
|
onSelect:
|
|
136
147
|
'called after pointer activation; palette keyboard selection uses root onValueChange',
|
|
137
148
|
isHighlighted: 'active keyboard result; derived from context when omitted',
|
|
138
|
-
isSelected:
|
|
149
|
+
isSelected:
|
|
150
|
+
'selected value in picker mode; derived from palette value when omitted',
|
|
139
151
|
isDisabled: 'unavailable; suppresses pointer activation',
|
|
140
152
|
xstyle: 'StyleX layout styles; must be stylex.create() value',
|
|
141
153
|
},
|
|
@@ -195,17 +195,6 @@ describe('CommandPaletteItem', () => {
|
|
|
195
195
|
expect(item).toHaveAttribute('aria-selected', 'true');
|
|
196
196
|
});
|
|
197
197
|
|
|
198
|
-
it('paints context-derived highlight when the item is not selected', () => {
|
|
199
|
-
renderWithContext(
|
|
200
|
-
<CommandPaletteItem value="test">Item</CommandPaletteItem>,
|
|
201
|
-
{highlightedIndex: 0},
|
|
202
|
-
);
|
|
203
|
-
|
|
204
|
-
expect(getComputedStyle(screen.getByRole('option')).backgroundColor).toBe(
|
|
205
|
-
'var(--color-overlay-hover)',
|
|
206
|
-
);
|
|
207
|
-
});
|
|
208
|
-
|
|
209
198
|
it('lets explicit visual state override context-derived state', () => {
|
|
210
199
|
renderWithContext(
|
|
211
200
|
<CommandPaletteItem value="test" isHighlighted={false} isSelected={false}>
|
|
@@ -216,7 +205,8 @@ describe('CommandPaletteItem', () => {
|
|
|
216
205
|
|
|
217
206
|
const item = screen.getByRole('option');
|
|
218
207
|
expect(item).toHaveAttribute('aria-selected', 'false');
|
|
219
|
-
expect(
|
|
208
|
+
expect(item).not.toHaveAttribute('data-selected');
|
|
209
|
+
expect(item).not.toHaveAttribute('data-highlighted');
|
|
220
210
|
});
|
|
221
211
|
|
|
222
212
|
it('protects owned option semantics from native passthrough props', () => {
|
|
@@ -354,6 +344,69 @@ describe('CommandPaletteItem', () => {
|
|
|
354
344
|
expect(item).toHaveStyle({marginBlockStart: '8px'});
|
|
355
345
|
});
|
|
356
346
|
|
|
347
|
+
it('reflects selected and disabled state on its theme target', () => {
|
|
348
|
+
render(
|
|
349
|
+
<>
|
|
350
|
+
<CommandPaletteItem value="rest">Rest</CommandPaletteItem>
|
|
351
|
+
<CommandPaletteItem value="selected" isSelected>
|
|
352
|
+
Selected
|
|
353
|
+
</CommandPaletteItem>
|
|
354
|
+
<CommandPaletteItem value="disabled" isDisabled>
|
|
355
|
+
Disabled
|
|
356
|
+
</CommandPaletteItem>
|
|
357
|
+
</>,
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const rest = screen.getByRole('option', {name: 'Rest'});
|
|
361
|
+
expect(rest).not.toHaveAttribute('data-selected');
|
|
362
|
+
expect(rest).not.toHaveAttribute('data-disabled');
|
|
363
|
+
expect(screen.getByRole('option', {name: 'Selected'})).toHaveAttribute(
|
|
364
|
+
'data-selected',
|
|
365
|
+
'selected',
|
|
366
|
+
);
|
|
367
|
+
expect(screen.getByRole('option', {name: 'Disabled'})).toHaveAttribute(
|
|
368
|
+
'data-disabled',
|
|
369
|
+
'disabled',
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('reflects context-derived selection on its theme target', () => {
|
|
374
|
+
renderWithContext(
|
|
375
|
+
<CommandPaletteItem value="test">Item</CommandPaletteItem>,
|
|
376
|
+
{value: 'test'},
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
expect(screen.getByRole('option')).toHaveAttribute(
|
|
380
|
+
'data-selected',
|
|
381
|
+
'selected',
|
|
382
|
+
);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('reflects explicit and context-derived highlight on its theme target', () => {
|
|
386
|
+
renderWithContext(
|
|
387
|
+
<>
|
|
388
|
+
<CommandPaletteItem value="test">
|
|
389
|
+
Context highlighted
|
|
390
|
+
</CommandPaletteItem>
|
|
391
|
+
<CommandPaletteItem value="other" isHighlighted>
|
|
392
|
+
Explicitly highlighted
|
|
393
|
+
</CommandPaletteItem>
|
|
394
|
+
<CommandPaletteItem value="rest">Rest</CommandPaletteItem>
|
|
395
|
+
</>,
|
|
396
|
+
{highlightedIndex: 0},
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
expect(
|
|
400
|
+
screen.getByRole('option', {name: 'Context highlighted'}),
|
|
401
|
+
).toHaveAttribute('data-highlighted', 'highlighted');
|
|
402
|
+
expect(
|
|
403
|
+
screen.getByRole('option', {name: 'Explicitly highlighted'}),
|
|
404
|
+
).toHaveAttribute('data-highlighted', 'highlighted');
|
|
405
|
+
expect(screen.getByRole('option', {name: 'Rest'})).not.toHaveAttribute(
|
|
406
|
+
'data-highlighted',
|
|
407
|
+
);
|
|
408
|
+
});
|
|
409
|
+
|
|
357
410
|
it('sets data-value attribute', () => {
|
|
358
411
|
render(<CommandPaletteItem value="my-value">Item</CommandPaletteItem>);
|
|
359
412
|
expect(screen.getByRole('option')).toHaveAttribute(
|
|
@@ -88,6 +88,7 @@ export interface CommandPaletteItemProps extends Omit<
|
|
|
88
88
|
isHighlighted?: boolean;
|
|
89
89
|
/**
|
|
90
90
|
* Whether this item is currently selected (picker mode).
|
|
91
|
+
* When omitted inside CommandPalette, derived from the palette value.
|
|
91
92
|
* @default false
|
|
92
93
|
*/
|
|
93
94
|
isSelected?: boolean;
|
|
@@ -202,7 +203,11 @@ export function CommandPaletteItem({
|
|
|
202
203
|
}
|
|
203
204
|
onMouseEnter={onMouseEnterProp}
|
|
204
205
|
{...mergeProps(
|
|
205
|
-
themeProps('command-palette-item'
|
|
206
|
+
themeProps('command-palette-item', {
|
|
207
|
+
highlighted: isHighlighted ? 'highlighted' : null,
|
|
208
|
+
selected: isSelected ? 'selected' : null,
|
|
209
|
+
disabled: isDisabled ? 'disabled' : null,
|
|
210
|
+
}),
|
|
206
211
|
stylex.props(
|
|
207
212
|
styles.item,
|
|
208
213
|
!isDisabled && interactionOverlayStyles.backgroundColor,
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @file CommandPaletteItemAudit.a11y.chromium.spec.ts
|
|
5
|
-
* @input Exact-head CommandPaletteItem stories
|
|
5
|
+
* @input Exact-head CommandPaletteItem stories, plus the inline grouped palette in
|
|
6
|
+
* the CommandPalette audit matrix, across states, themes, direction, pointers,
|
|
7
|
+
* and viewport sizes
|
|
6
8
|
* @output Screenshots and fail-closed sensor receipts for the CommandPaletteItem whole-component audit
|
|
7
9
|
* @position Component-owned Night Watch browser evidence; CI runs it only for a CommandPaletteItem audit pull request
|
|
8
10
|
*/
|
|
@@ -11,7 +13,13 @@ import {createHash} from 'node:crypto';
|
|
|
11
13
|
import {execFileSync} from 'node:child_process';
|
|
12
14
|
import * as fs from 'node:fs';
|
|
13
15
|
import * as path from 'node:path';
|
|
14
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
expect,
|
|
18
|
+
test,
|
|
19
|
+
type CDPSession,
|
|
20
|
+
type Locator,
|
|
21
|
+
type Page,
|
|
22
|
+
} from '@playwright/test';
|
|
15
23
|
import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
|
|
16
24
|
import {
|
|
17
25
|
DEFAULT_STORYBOOK_DIR,
|
|
@@ -26,8 +34,17 @@ const NARROW = {width: 320, height: 720};
|
|
|
26
34
|
type Mode = 'light' | 'dark';
|
|
27
35
|
type Direction = 'ltr' | 'rtl';
|
|
28
36
|
type Theme = 'neutral' | 'butter';
|
|
29
|
-
type
|
|
30
|
-
type
|
|
37
|
+
type Pointer = 'fine' | 'coarse';
|
|
38
|
+
type Story = 'states' | 'combined-and-long' | 'palette';
|
|
39
|
+
type State =
|
|
40
|
+
| 'rest'
|
|
41
|
+
| 'hover'
|
|
42
|
+
| 'pressed'
|
|
43
|
+
| 'pressed-highlighted'
|
|
44
|
+
| 'disabled-hover'
|
|
45
|
+
| 'touch-press'
|
|
46
|
+
| 'keyboard-highlight'
|
|
47
|
+
| 'keyboard-selected-highlight';
|
|
31
48
|
|
|
32
49
|
type AuditCase = {
|
|
33
50
|
key: string;
|
|
@@ -36,6 +53,8 @@ type AuditCase = {
|
|
|
36
53
|
direction: Direction;
|
|
37
54
|
theme: Theme;
|
|
38
55
|
state: State;
|
|
56
|
+
pointer: Pointer;
|
|
57
|
+
forcedColors: boolean;
|
|
39
58
|
viewport: {width: number; height: number};
|
|
40
59
|
};
|
|
41
60
|
|
|
@@ -57,6 +76,8 @@ function addPair(
|
|
|
57
76
|
direction: options.direction ?? 'ltr',
|
|
58
77
|
theme: options.theme ?? 'neutral',
|
|
59
78
|
state: options.state ?? 'rest',
|
|
79
|
+
pointer: options.pointer ?? 'fine',
|
|
80
|
+
forcedColors: options.forcedColors ?? false,
|
|
60
81
|
viewport: options.viewport ?? WIDE,
|
|
61
82
|
});
|
|
62
83
|
}
|
|
@@ -64,9 +85,19 @@ function addPair(
|
|
|
64
85
|
|
|
65
86
|
addPair('states-rest');
|
|
66
87
|
addPair('states-hover', {state: 'hover'});
|
|
88
|
+
addPair('states-pressed', {state: 'pressed'});
|
|
89
|
+
addPair('states-pressed-highlighted', {state: 'pressed-highlighted'});
|
|
90
|
+
addPair('states-disabled-hover', {state: 'disabled-hover'});
|
|
67
91
|
addPair('states-custom-theme', {theme: 'butter'});
|
|
68
92
|
addPair('states-rtl', {direction: 'rtl'});
|
|
69
93
|
addPair('states-narrow', {viewport: NARROW});
|
|
94
|
+
addPair('states-coarse', {viewport: NARROW, pointer: 'coarse'});
|
|
95
|
+
addPair('states-touch-press', {
|
|
96
|
+
viewport: NARROW,
|
|
97
|
+
pointer: 'coarse',
|
|
98
|
+
state: 'touch-press',
|
|
99
|
+
});
|
|
100
|
+
addPair('states-forced-colors', {forcedColors: true});
|
|
70
101
|
addPair('combined-rest', {story: 'combined-and-long'});
|
|
71
102
|
addPair('combined-custom-theme', {
|
|
72
103
|
story: 'combined-and-long',
|
|
@@ -76,6 +107,45 @@ addPair('combined-narrow', {
|
|
|
76
107
|
story: 'combined-and-long',
|
|
77
108
|
viewport: NARROW,
|
|
78
109
|
});
|
|
110
|
+
addPair('palette-keyboard-highlight', {
|
|
111
|
+
story: 'palette',
|
|
112
|
+
state: 'keyboard-highlight',
|
|
113
|
+
});
|
|
114
|
+
addPair('palette-keyboard-selected-highlight', {
|
|
115
|
+
story: 'palette',
|
|
116
|
+
state: 'keyboard-selected-highlight',
|
|
117
|
+
});
|
|
118
|
+
addPair('palette-keyboard-selected-highlight-custom-theme', {
|
|
119
|
+
story: 'palette',
|
|
120
|
+
state: 'keyboard-selected-highlight',
|
|
121
|
+
theme: 'butter',
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
addPair('palette-keyboard-highlight-forced-colors', {
|
|
125
|
+
story: 'palette',
|
|
126
|
+
state: 'keyboard-highlight',
|
|
127
|
+
forcedColors: true,
|
|
128
|
+
});
|
|
129
|
+
addPair('palette-keyboard-selected-highlight-forced-colors', {
|
|
130
|
+
story: 'palette',
|
|
131
|
+
state: 'keyboard-selected-highlight',
|
|
132
|
+
forcedColors: true,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/** Option each pointer state acts on, by its accessible text. */
|
|
136
|
+
const POINTER_TARGET: Partial<Record<State, string>> = {
|
|
137
|
+
hover: 'Open file',
|
|
138
|
+
pressed: 'Open file',
|
|
139
|
+
'pressed-highlighted': 'Search files',
|
|
140
|
+
'disabled-hover': 'Archived command',
|
|
141
|
+
'touch-press': 'Open file',
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** Option the palette keyboard states move the active descendant to. */
|
|
145
|
+
const KEYBOARD_TARGET: Partial<Record<State, string>> = {
|
|
146
|
+
'keyboard-highlight': 'Home',
|
|
147
|
+
'keyboard-selected-highlight': 'Settings',
|
|
148
|
+
};
|
|
79
149
|
|
|
80
150
|
test.describe.configure({mode: 'serial', retries: 0});
|
|
81
151
|
let server: StaticServer;
|
|
@@ -83,7 +153,9 @@ let checkoutSha: string;
|
|
|
83
153
|
let storybookSha: string;
|
|
84
154
|
|
|
85
155
|
function storyId(item: AuditCase) {
|
|
86
|
-
return
|
|
156
|
+
return item.story === 'palette'
|
|
157
|
+
? 'core-commandpalette--audit-matrix'
|
|
158
|
+
: `core-commandpaletteitem--${item.story}`;
|
|
87
159
|
}
|
|
88
160
|
|
|
89
161
|
function storyUrl(item: AuditCase) {
|
|
@@ -96,6 +168,38 @@ async function settle(page: Page) {
|
|
|
96
168
|
await page.waitForTimeout(40);
|
|
97
169
|
}
|
|
98
170
|
|
|
171
|
+
async function centerOf(locator: Locator) {
|
|
172
|
+
const box = await locator.boundingBox();
|
|
173
|
+
if (box == null) {
|
|
174
|
+
throw new Error('target option has no rendered bounds');
|
|
175
|
+
}
|
|
176
|
+
return {x: box.x + box.width / 2, y: box.y + box.height / 2};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function highlightedName(input: Locator) {
|
|
180
|
+
return input.evaluate(element => {
|
|
181
|
+
const id = element.getAttribute('aria-activedescendant');
|
|
182
|
+
const option = id ? document.getElementById(id) : null;
|
|
183
|
+
return option?.textContent?.replace(/\s+/g, ' ').trim() ?? null;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** PageUp moves the palette to its first result; ArrowDown walks forward. */
|
|
188
|
+
async function highlightFromKeyboard(
|
|
189
|
+
page: Page,
|
|
190
|
+
input: Locator,
|
|
191
|
+
target: string,
|
|
192
|
+
) {
|
|
193
|
+
await input.focus();
|
|
194
|
+
await page.keyboard.press('PageUp');
|
|
195
|
+
for (let step = 0; step < 4; step++) {
|
|
196
|
+
if ((await highlightedName(input)) === target) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
await page.keyboard.press('ArrowDown');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
99
203
|
test.beforeAll(async () => {
|
|
100
204
|
fs.rmSync(OUTPUT, {recursive: true, force: true});
|
|
101
205
|
fs.mkdirSync(OUTPUT, {recursive: true});
|
|
@@ -133,6 +237,8 @@ for (const item of cases) {
|
|
|
133
237
|
viewport: item.viewport,
|
|
134
238
|
colorScheme: item.mode,
|
|
135
239
|
reducedMotion: 'reduce',
|
|
240
|
+
hasTouch: item.pointer === 'coarse',
|
|
241
|
+
forcedColors: item.forcedColors ? 'active' : 'none',
|
|
136
242
|
});
|
|
137
243
|
const page = await context.newPage();
|
|
138
244
|
const pageErrors: string[] = [];
|
|
@@ -140,13 +246,20 @@ for (const item of cases) {
|
|
|
140
246
|
await page.goto(storyUrl(item), {waitUntil: 'load'});
|
|
141
247
|
await page.locator('#storybook-root').waitFor();
|
|
142
248
|
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const list =
|
|
249
|
+
const isPalette = item.story === 'palette';
|
|
250
|
+
const subject = isPalette
|
|
251
|
+
? page.getByRole('region', {name: 'Grouped command palette'})
|
|
252
|
+
: page.locator('#storybook-root');
|
|
253
|
+
const list = isPalette
|
|
254
|
+
? subject.getByRole('listbox')
|
|
255
|
+
: subject.getByRole('listbox', {
|
|
256
|
+
name:
|
|
257
|
+
item.story === 'states'
|
|
258
|
+
? 'Command item states'
|
|
259
|
+
: 'Combined and long command item states',
|
|
260
|
+
});
|
|
148
261
|
const options = list.getByRole('option');
|
|
149
|
-
const expectedCount = item.story === '
|
|
262
|
+
const expectedCount = item.story === 'combined-and-long' ? 2 : 4;
|
|
150
263
|
const expectedDisabled = item.story === 'states' ? 1 : 0;
|
|
151
264
|
await expect(list).toBeVisible();
|
|
152
265
|
await expect(options).toHaveCount(expectedCount);
|
|
@@ -156,12 +269,51 @@ for (const item of cases) {
|
|
|
156
269
|
await expect(
|
|
157
270
|
list.locator('[role="option"][aria-disabled="true"]'),
|
|
158
271
|
).toHaveCount(expectedDisabled);
|
|
159
|
-
await expect(
|
|
272
|
+
await expect(
|
|
273
|
+
list.locator('[role="option"] :is(button, a, [tabindex])'),
|
|
274
|
+
).toHaveCount(0);
|
|
275
|
+
|
|
276
|
+
const pointerTarget = POINTER_TARGET[item.state];
|
|
277
|
+
const keyboardTarget = KEYBOARD_TARGET[item.state];
|
|
278
|
+
const target = pointerTarget ?? keyboardTarget ?? null;
|
|
279
|
+
const targetOption =
|
|
280
|
+
target == null ? null : options.filter({hasText: target}).first();
|
|
281
|
+
let cdp: CDPSession | null = null;
|
|
160
282
|
|
|
161
|
-
if (item.state === 'hover') {
|
|
162
|
-
await options.first().hover();
|
|
163
|
-
}
|
|
164
283
|
await settle(page);
|
|
284
|
+
if (item.state === 'hover' || item.state === 'disabled-hover') {
|
|
285
|
+
await targetOption?.hover();
|
|
286
|
+
} else if (
|
|
287
|
+
item.state === 'pressed' ||
|
|
288
|
+
item.state === 'pressed-highlighted'
|
|
289
|
+
) {
|
|
290
|
+
if (targetOption == null) {
|
|
291
|
+
throw new Error(`${item.key}: no press target`);
|
|
292
|
+
}
|
|
293
|
+
await targetOption.hover();
|
|
294
|
+
await page.mouse.down();
|
|
295
|
+
} else if (item.state === 'touch-press') {
|
|
296
|
+
if (targetOption == null) {
|
|
297
|
+
throw new Error(`${item.key}: no touch target`);
|
|
298
|
+
}
|
|
299
|
+
const point = await centerOf(targetOption);
|
|
300
|
+
cdp = await context.newCDPSession(page);
|
|
301
|
+
await cdp.send('Input.dispatchTouchEvent', {
|
|
302
|
+
type: 'touchStart',
|
|
303
|
+
touchPoints: [{x: point.x, y: point.y, id: 1}],
|
|
304
|
+
});
|
|
305
|
+
// The touch press controller believes a stationary press after its
|
|
306
|
+
// onset delay; hold past it before reading the painted state.
|
|
307
|
+
await page.waitForTimeout(320);
|
|
308
|
+
} else if (keyboardTarget != null) {
|
|
309
|
+
await highlightFromKeyboard(
|
|
310
|
+
page,
|
|
311
|
+
subject.getByRole('combobox'),
|
|
312
|
+
keyboardTarget,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
await page.evaluate(async () => document.fonts.ready);
|
|
316
|
+
await page.waitForTimeout(40);
|
|
165
317
|
|
|
166
318
|
const observed = await page.evaluate(() => ({
|
|
167
319
|
theme: document
|
|
@@ -173,20 +325,41 @@ for (const item of cases) {
|
|
|
173
325
|
).direction,
|
|
174
326
|
viewport: {width: innerWidth, height: innerHeight},
|
|
175
327
|
reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
|
|
328
|
+
coarsePointer: matchMedia('(pointer: coarse)').matches,
|
|
329
|
+
forcedColors: matchMedia('(forced-colors: active)').matches,
|
|
176
330
|
horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
|
|
177
331
|
fonts: document.fonts.status,
|
|
178
332
|
}));
|
|
333
|
+
const listBackdrop = await list.evaluate(element => {
|
|
334
|
+
let node: Element | null = element;
|
|
335
|
+
while (node) {
|
|
336
|
+
const color = getComputedStyle(node).backgroundColor;
|
|
337
|
+
if (color !== 'rgba(0, 0, 0, 0)' && color !== 'transparent') {
|
|
338
|
+
return color;
|
|
339
|
+
}
|
|
340
|
+
node = node.parentElement;
|
|
341
|
+
}
|
|
342
|
+
return getComputedStyle(document.body).backgroundColor;
|
|
343
|
+
});
|
|
179
344
|
const optionStates = await options.evaluateAll(nodes =>
|
|
180
345
|
nodes.map(node => {
|
|
181
346
|
const style = getComputedStyle(node);
|
|
182
347
|
const box = node.getBoundingClientRect();
|
|
183
348
|
return {
|
|
184
349
|
name: node.textContent?.replace(/\s+/g, ' ').trim(),
|
|
350
|
+
id: node.id || null,
|
|
185
351
|
selected: node.getAttribute('aria-selected'),
|
|
186
352
|
disabled: node.getAttribute('aria-disabled'),
|
|
353
|
+
themeSelected: node.getAttribute('data-selected'),
|
|
354
|
+
themeDisabled: node.getAttribute('data-disabled'),
|
|
355
|
+
active: node.matches(':active'),
|
|
356
|
+
hovered: node.matches(':hover'),
|
|
357
|
+
press: node.getAttribute('data-astryx-press'),
|
|
187
358
|
backgroundColor: style.backgroundColor,
|
|
359
|
+
backgroundImage: style.backgroundImage,
|
|
188
360
|
color: style.color,
|
|
189
361
|
opacity: style.opacity,
|
|
362
|
+
cursor: style.cursor,
|
|
190
363
|
geometry: {
|
|
191
364
|
x: box.x,
|
|
192
365
|
y: box.y,
|
|
@@ -196,6 +369,18 @@ for (const item of cases) {
|
|
|
196
369
|
};
|
|
197
370
|
}),
|
|
198
371
|
);
|
|
372
|
+
const combobox = isPalette
|
|
373
|
+
? await subject.getByRole('combobox').evaluate(element => ({
|
|
374
|
+
focused: element === document.activeElement,
|
|
375
|
+
activeDescendant: element.getAttribute('aria-activedescendant'),
|
|
376
|
+
}))
|
|
377
|
+
: null;
|
|
378
|
+
|
|
379
|
+
const targetState =
|
|
380
|
+
target == null
|
|
381
|
+
? null
|
|
382
|
+
: (optionStates.find(option => option.name?.startsWith(target)) ??
|
|
383
|
+
null);
|
|
199
384
|
const semanticsPass =
|
|
200
385
|
optionStates.length === expectedCount &&
|
|
201
386
|
optionStates.filter(option => option.selected === 'true').length === 1 &&
|
|
@@ -204,11 +389,48 @@ for (const item of cases) {
|
|
|
204
389
|
optionStates.every(
|
|
205
390
|
option => option.geometry.width > 0 && option.geometry.height > 0,
|
|
206
391
|
);
|
|
392
|
+
let statePass = true;
|
|
393
|
+
if (item.state === 'hover') {
|
|
394
|
+
statePass = targetState?.hovered === true;
|
|
395
|
+
} else if (item.state === 'disabled-hover') {
|
|
396
|
+
statePass =
|
|
397
|
+
targetState?.hovered === true &&
|
|
398
|
+
targetState.disabled === 'true' &&
|
|
399
|
+
targetState.backgroundColor === 'rgba(0, 0, 0, 0)' &&
|
|
400
|
+
targetState.cursor === 'default';
|
|
401
|
+
} else if (
|
|
402
|
+
item.state === 'pressed' ||
|
|
403
|
+
item.state === 'pressed-highlighted'
|
|
404
|
+
) {
|
|
405
|
+
statePass = targetState?.active === true;
|
|
406
|
+
} else if (item.state === 'touch-press') {
|
|
407
|
+
statePass = targetState?.press === 'on';
|
|
408
|
+
} else if (keyboardTarget != null) {
|
|
409
|
+
statePass =
|
|
410
|
+
combobox?.focused === true &&
|
|
411
|
+
targetState?.id != null &&
|
|
412
|
+
combobox.activeDescendant === targetState.id &&
|
|
413
|
+
(item.state === 'keyboard-selected-highlight'
|
|
414
|
+
? targetState.selected === 'true'
|
|
415
|
+
: targetState.selected === 'false');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const png = isPalette
|
|
419
|
+
? await subject.screenshot({animations: 'disabled'})
|
|
420
|
+
: await page.screenshot({animations: 'disabled', fullPage: true});
|
|
421
|
+
if (item.state === 'pressed' || item.state === 'pressed-highlighted') {
|
|
422
|
+
await page.mouse.up();
|
|
423
|
+
}
|
|
424
|
+
if (cdp != null) {
|
|
425
|
+
await cdp.send('Input.dispatchTouchEvent', {
|
|
426
|
+
type: 'touchEnd',
|
|
427
|
+
touchPoints: [],
|
|
428
|
+
});
|
|
429
|
+
}
|
|
207
430
|
|
|
208
|
-
const png = await page.screenshot({animations: 'disabled', fullPage: true});
|
|
209
431
|
const image = `${item.key}.png`;
|
|
210
432
|
const receipt = {
|
|
211
|
-
schemaVersion:
|
|
433
|
+
schemaVersion: 2,
|
|
212
434
|
expected: {
|
|
213
435
|
build: checkoutSha,
|
|
214
436
|
storyId: storyId(item),
|
|
@@ -216,6 +438,9 @@ for (const item of cases) {
|
|
|
216
438
|
direction: item.direction,
|
|
217
439
|
theme: item.theme,
|
|
218
440
|
state: item.state,
|
|
441
|
+
target,
|
|
442
|
+
pointer: item.pointer,
|
|
443
|
+
forcedColors: item.forcedColors,
|
|
219
444
|
viewport: item.viewport,
|
|
220
445
|
optionCount: expectedCount,
|
|
221
446
|
disabledCount: expectedDisabled,
|
|
@@ -226,6 +451,8 @@ for (const item of cases) {
|
|
|
226
451
|
...observed,
|
|
227
452
|
listRole: await list.getAttribute('role'),
|
|
228
453
|
listName: await list.getAttribute('aria-label'),
|
|
454
|
+
listBackdrop,
|
|
455
|
+
combobox,
|
|
229
456
|
optionStates,
|
|
230
457
|
pageErrors,
|
|
231
458
|
},
|
|
@@ -235,7 +462,7 @@ for (const item of cases) {
|
|
|
235
462
|
width: png.readUInt32BE(16),
|
|
236
463
|
height: png.readUInt32BE(20),
|
|
237
464
|
},
|
|
238
|
-
checks: {semanticsPass},
|
|
465
|
+
checks: {semanticsPass, statePass},
|
|
239
466
|
passed:
|
|
240
467
|
storybookSha === checkoutSha &&
|
|
241
468
|
pageErrors.length === 0 &&
|
|
@@ -245,9 +472,12 @@ for (const item of cases) {
|
|
|
245
472
|
observed.viewport.width === item.viewport.width &&
|
|
246
473
|
observed.viewport.height === item.viewport.height &&
|
|
247
474
|
observed.reducedMotion &&
|
|
475
|
+
observed.coarsePointer === (item.pointer === 'coarse') &&
|
|
476
|
+
observed.forcedColors === item.forcedColors &&
|
|
248
477
|
!observed.horizontalOverflow &&
|
|
249
478
|
observed.fonts === 'loaded' &&
|
|
250
|
-
semanticsPass
|
|
479
|
+
semanticsPass &&
|
|
480
|
+
statePass,
|
|
251
481
|
};
|
|
252
482
|
fs.writeFileSync(path.join(OUTPUT, image), png);
|
|
253
483
|
fs.writeFileSync(
|
|
@@ -270,7 +500,7 @@ test('manifest is complete', async () => {
|
|
|
270
500
|
) as AuditReceipt,
|
|
271
501
|
);
|
|
272
502
|
const manifest = {
|
|
273
|
-
schemaVersion:
|
|
503
|
+
schemaVersion: 2,
|
|
274
504
|
component: 'core/CommandPaletteItem',
|
|
275
505
|
build: checkoutSha,
|
|
276
506
|
frames: cases.map(item => ({
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
---
|
|
2
|
+
schema_version: 3
|
|
3
|
+
template_version: 7
|
|
4
|
+
kind: component
|
|
5
|
+
id: component:Overlay
|
|
6
|
+
authority: current
|
|
7
|
+
archive_reason: null
|
|
8
|
+
superseded_by: null
|
|
9
|
+
approved_by: cixzhang
|
|
10
|
+
approved_at: 2026-10-22
|
|
11
|
+
owners: [cixzhang]
|
|
12
|
+
review_triggers: [public-api, layout, accessibility]
|
|
13
|
+
verified_by:
|
|
14
|
+
[
|
|
15
|
+
packages/core/src/Overlay/Overlay.test.tsx,
|
|
16
|
+
apps/storybook/stories/Overlay.stories.tsx,
|
|
17
|
+
]
|
|
18
|
+
modules: []
|
|
19
|
+
families: []
|
|
20
|
+
design_specs: [design:bidirectionality]
|
|
21
|
+
architecture:
|
|
22
|
+
[
|
|
23
|
+
architecture:public-component-api,
|
|
24
|
+
architecture:component-theming-surface,
|
|
25
|
+
architecture:internationalization,
|
|
26
|
+
]
|
|
27
|
+
contributing: []
|
|
28
|
+
system_specs: [spec:AST-002/DEC-1, spec:AST-017/FR5, spec:AST-027/FR2]
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
# Overlay component contract
|
|
32
|
+
|
|
33
|
+
<!-- Describe the system, not the project: present tense, what it does. No proposals, history, pull requests, or research in the record; see docs/contributing/spec-writing.md and report its rubric results in the pull request. -->
|
|
34
|
+
|
|
35
|
+
This record governs how several pieces of content are placed over an Overlay's
|
|
36
|
+
surface, each at a preset spot or at fractional coordinates, including the
|
|
37
|
+
stacking of a container that holds such content. Overlay's scrim, reveal modes,
|
|
38
|
+
own `position` values, and alignment remain described by its source, tests, and
|
|
39
|
+
`Overlay.doc.mjs` until a record claims them.
|
|
40
|
+
|
|
41
|
+
## Contract at a glance
|
|
42
|
+
|
|
43
|
+
| Area | Contract |
|
|
44
|
+
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
45
|
+
| Public contract | New `OverlayRegion` component, exported beside Overlay: `position` (required) is a preset (`'fill' \| 'top' \| 'bottom' \| 'center' \| 'top-start' \| 'top-end' \| 'bottom-start' \| 'bottom-end'`) or open placement `{x, y, width?, height?}` in 0–1 fractions; plus `children`, `ref`, `xstyle`, `className`, `style`. |
|
|
46
|
+
| Behavior | Inside a fill Overlay, a preset region sits at that spot (corners follow the writing direction) and open placement sits at fractions of the media measured from its top-left corner, never mirrored. A sized open region stays inside the media. Regions scale with the media. |
|
|
47
|
+
| End-user impact | Several captions, tags, badges, or hotspots over one image or video each stay on their spot of the media at every size, in either text direction. |
|
|
48
|
+
| Builder impact | None by default. A builder places content with `OverlayRegion` instead of hand-written absolute positioning; moving a region stays the builder's code. |
|
|
49
|
+
| Compatibility/readiness | Additive: a new component and export; an Overlay without a region renders and stacks exactly as before. This record is current authority; `OverlayRegion`, its consumer docs, region tests, and region stories have not shipped yet. |
|
|
50
|
+
| Review checks | Reject open placement that mirrors in right-to-left, corner presets that do not, JavaScript measurement or observers, drag or resize behavior inside Overlay, a role or focus stop on the region, a theme target on it, or a region placed inside a strip. |
|
|
51
|
+
| Governing rules | FR1–FR11 below; `design:bidirectionality/DR3`, `DR5`, `DR6`; `architecture:component-theming-surface/INV4`; `spec:AST-017/FR5`; `spec:AST-027/FR2`. |
|
|
52
|
+
|
|
53
|
+
This table is a review projection; the body below is authoritative.
|
|
54
|
+
|
|
55
|
+
## Intent
|
|
56
|
+
|
|
57
|
+
Overlay layers content over a bounded surface, usually media, and places that
|
|
58
|
+
content as one block: the whole surface or a band at the top or bottom. Some
|
|
59
|
+
surfaces carry several pieces of content, each belonging to its own spot: two
|
|
60
|
+
captions drawn into an image, a badge in a corner beside a caption, a tag on a
|
|
61
|
+
face. A spot is either one of Overlay's familiar places or a point on the media
|
|
62
|
+
described as fractions of its width and height, because the media is shown at
|
|
63
|
+
many sizes and the spot must stay on the same pixels of it. Overlay owns that
|
|
64
|
+
placement because Overlay owns the surface it is measured against.
|
|
65
|
+
|
|
66
|
+
## Compatibility and migration
|
|
67
|
+
|
|
68
|
+
- Released default preserved: `yes`; an Overlay without a region renders and
|
|
69
|
+
stacks exactly as before. Only a container that holds a region isolates its
|
|
70
|
+
stacking (FR10).
|
|
71
|
+
- Compatibility class: additive component and export.
|
|
72
|
+
- Controlled/uncontrolled behavior: not applicable; a region holds no state.
|
|
73
|
+
- Migration decision: none required (`spec:AST-017/FR5`).
|
|
74
|
+
|
|
75
|
+
Consumer migration instructions belong in consumer docs and release notes.
|
|
76
|
+
|
|
77
|
+
## Ownership boundary
|
|
78
|
+
|
|
79
|
+
**Owns**
|
|
80
|
+
|
|
81
|
+
- `OverlayRegion`: its `position` values, their geometry, clamping, scaling, and
|
|
82
|
+
reference box.
|
|
83
|
+
- The rule that open placement is measured in the media's own physical space.
|
|
84
|
+
- The rule that regions are placed only in fill overlays, and the fallback
|
|
85
|
+
everywhere else.
|
|
86
|
+
- The stacking isolation of a container that holds a region.
|
|
87
|
+
|
|
88
|
+
**Does not own / non-goals**
|
|
89
|
+
|
|
90
|
+
- Moving, dragging, or resizing a region, including pointer capture, keyboard
|
|
91
|
+
steps, snapping, and when a new position is reported — owned by the product
|
|
92
|
+
callsite (`component:Overlay/DEC-3`).
|
|
93
|
+
- What a region paints: borders, fills, handles, and text — owned by the
|
|
94
|
+
region's content.
|
|
95
|
+
- Making the base content fill the surface — owned by the caller, as for every
|
|
96
|
+
Overlay; fractions are measured against the surface, so media that does not
|
|
97
|
+
fill it does not line up with them.
|
|
98
|
+
- Overlay's own `position` and `align` — unchanged; a region does not read or
|
|
99
|
+
alter them beyond FR2.
|
|
100
|
+
- Placement relative to a trigger or outside the surface — owned by the layer
|
|
101
|
+
runtime (Popover, Tooltip, HoverCard).
|
|
102
|
+
|
|
103
|
+
## Public concepts
|
|
104
|
+
|
|
105
|
+
| Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
|
|
106
|
+
| -------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------- | ------------------- | --------- | ------------------------------------------------------------------------------------------------------- |
|
|
107
|
+
| Preset | `fill`, `top`, `bottom`, `center`, `top-start`, `top-end`, `bottom-start`, `bottom-end` | `fill`, `top`, and `bottom` are the places Overlay's own `position` names: the whole surface, or a full-width band at that edge. `center` and the corners hold content-sized boxes at that spot. | Fill overlays (FR2) | None; required prop | `component:Overlay` | stable | Other strings are rejected by the public type |
|
|
108
|
+
| Open placement | `{x, y, width?, height?}`; each a number from 0 to 1 | `x`, `y`: the region's top-left corner as fractions of its reference box, measured from that box's top-left corner. `width`, `height`: the region's size as fractions, extending right and down; omitted means it fits its content. | Fill overlays (FR2) | None; required prop | `component:Overlay` | stable | Values clamped into 0–1; a non-finite coordinate is treated as 0 and a non-finite size as omitted (FR6) |
|
|
109
|
+
|
|
110
|
+
## Behavioral and layout contract
|
|
111
|
+
|
|
112
|
+
| ID | Invariant | Basis | Draft review state |
|
|
113
|
+
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ------------------ |
|
|
114
|
+
| FR1 | `OverlayRegion` rendered directly in the content of an Overlay or `useOverlay` host with `position="fill"`, the default, MUST position against that host's scrim box, which is the whole base surface: 0 and 1 are the media's edges, and the box matches the Overlay's `ref`. Any element between the region and the scrim that establishes a containing block for absolutely positioned descendants becomes its reference box instead. In this record, the surface means the region's reference box. | `component:Overlay/DEC-1` | settled |
|
|
115
|
+
| FR2 | Regions are placed only in fill hosts. A region rendered anywhere else MUST NOT be placed: in the content of an Overlay or `useOverlay` host whose `position` is `"top"` or `"bottom"`, in an Overlay's `children`, or with no Overlay or `useOverlay` host at all, it renders its content as ordinary in-flow content where it stands (in a strip, that content reveals and slides with the strip) and MUST emit one development warning per mount naming the fill-only rule. | `component:Overlay/DEC-4` | settled |
|
|
116
|
+
| FR3 | A preset region MUST sit at its spot inside Overlay's content inset: `fill` covers the inset box; `top` and `bottom` span its full width at that edge with content height; `center` is content-sized at its center; each corner is content-sized at that corner. Content larger than the inset box is clipped by the container, not shifted. | `component:Overlay/DEC-2` | settled |
|
|
117
|
+
| FR4 | Corner presets MUST resolve `start` and `end` against the writing direction of the region's own context: `top-start` is the top-left corner in left-to-right and the top-right corner in right-to-left. `fill`, `top`, `bottom`, and `center` are symmetric and do not change with direction. | `design:bidirectionality/DR3` | settled |
|
|
118
|
+
| FR5 | Open placement MUST measure `x` from the surface's left edge and `y` from its top edge in every writing direction, and MUST NOT mirror in right-to-left. The region's own content follows the ambient direction. | `design:bidirectionality/DR5` | settled |
|
|
119
|
+
| FR6 | Open coordinates and sizes MUST be clamped into 0–1 before placement. A non-finite coordinate MUST resolve to 0, and a non-finite size MUST resolve to omitted. | Invalid-value behavior | settled |
|
|
120
|
+
| FR7 | Open placement puts the region's top-left corner at (`x`, `y`) (`component:Overlay/DEC-6`). On an axis with a size, the region MUST be exactly that fraction of the surface, extending right or down, and MUST sit wholly inside it: its start is clamped between 0 and one minus the size. On an axis without a size, the region MUST fit its content starting at the coordinate, and any overhang past the container's edge is clipped, not shifted inward. | `component:Overlay/DEC-5` | settled |
|
|
121
|
+
| FR8 | When the surface changes size, every region MUST keep its spot: presets by their insets, open placement by its fractional position and size, without a re-render. | `component:Overlay/DEC-1` | settled |
|
|
122
|
+
| FR9 | A region MUST appear, hide, and become inert with the overlay content that contains it, following Overlay's `showOn` and `isOpen`, and MUST inherit the scrim's media theme. It adds no visibility rule of its own. | Overlay's existing reveal behavior | settled |
|
|
123
|
+
| FR10 | A placed region MUST paint above the fill surface's in-flow overlay content, including positioned in-flow content written after it, and a later placed region MUST paint above an earlier one. A placed region inside a wrapper that forms a stacking context is ordered only among that wrapper's content. An Overlay or `useOverlay` container with a placed region anywhere inside it MUST own an isolated stacking context; a container with no region inside it keeps its stacking unchanged. A placed region MUST NOT take part in the scrim's flow layout. | Absolute positioning within the surface; `spec:AST-027/FR2` | settled |
|
|
124
|
+
| FR11 | Overlay and `OverlayRegion` MUST NOT move, drag, or resize a region, and MUST NOT attach pointer, keyboard, or gesture handlers to it. A region moves only when its caller passes a new `position`. | `component:Overlay/DEC-3` | settled |
|
|
125
|
+
|
|
126
|
+
### Allowed variation
|
|
127
|
+
|
|
128
|
+
- **AV1 — Content.** Any content may sit in a region; on an open region each
|
|
129
|
+
axis is sized or content-sized independently (FR7).
|
|
130
|
+
- **AV2 — Surface size and shape.** The surface may be any size or aspect ratio,
|
|
131
|
+
and may change size at any time (FR8).
|
|
132
|
+
- **AV3 — Mixed content.** In-flow overlay content, preset regions, and open
|
|
133
|
+
regions may share one surface (FR10).
|
|
134
|
+
|
|
135
|
+
### Representative states
|
|
136
|
+
|
|
137
|
+
| State | Required invariant | Allowed variation |
|
|
138
|
+
| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------ |
|
|
139
|
+
| Two captions, `top` and `bottom` presets | Each spans the inset width at its edge, like Overlay's own strips. | Caption text. |
|
|
140
|
+
| `top-start` badge, left-to-right and right-to-left | Top-left corner in left-to-right; top-right corner in right-to-left. | Badge content. |
|
|
141
|
+
| Open sized caption near the right edge | Box is the given fraction of the image and stays wholly inside, flush with that edge. | Which edge. |
|
|
142
|
+
| Open content-sized pin | Pin starts at its point and extends right and down; overhang past the container is clipped. | Pin content. |
|
|
143
|
+
| Open region, right-to-left page | Same spot on the media as left-to-right; the region's text reads right-to-left. | Writing direction. |
|
|
144
|
+
| Surface resized from 640px to 320px wide | Every region keeps its spot, with no re-render. | Any surface size. |
|
|
145
|
+
| Region in an Overlay's `children` or outside any Overlay | Is not placed: its content flows where it stands, and it warns once in development. | Location. |
|
|
146
|
+
| Region in a `position="bottom"` Overlay | Is not placed: its content flows in the strip like other strip content, and it warns once in development. | Strip content. |
|
|
147
|
+
| `showOn="hover"` overlay at rest | Regions are hidden with the rest of the overlay content. | Reveal mode. |
|
|
148
|
+
|
|
149
|
+
### Transformation and precedence order
|
|
150
|
+
|
|
151
|
+
- **ORD1 — Placement.** Resolve the reference box (FR1, FR2) → for a preset,
|
|
152
|
+
place it in the content inset and resolve start/end against direction (FR3,
|
|
153
|
+
FR4); for open placement, clamp values (FR6) and resolve each axis as sized or
|
|
154
|
+
content-sized in physical space (FR5, FR7) → express the result as insets and
|
|
155
|
+
fractions of the box (FR8). Styling inputs compose after placement in the order
|
|
156
|
+
`architecture:public-component-api/INV6` gives; a caller who overrides inset or
|
|
157
|
+
size properties owns the result.
|
|
158
|
+
|
|
159
|
+
### Performance and resources
|
|
160
|
+
|
|
161
|
+
- **PR1 — CSS geometry only.** Placement, clamping, and scaling MUST be achieved
|
|
162
|
+
with arithmetic on the given values and CSS: no measurement, observer,
|
|
163
|
+
listener, state, Effect, or extra render pass. The fill-only check adds no
|
|
164
|
+
measurement, state, or extra render pass.
|
|
165
|
+
|
|
166
|
+
## Accessibility contract
|
|
167
|
+
|
|
168
|
+
- **AR1 — Presentation only.** `OverlayRegion` MUST add no role, accessible name,
|
|
169
|
+
description, focus stop, or text. Its content keeps its own semantics.
|
|
170
|
+
- **AR2 — Source order is reading order.** Assistive technology and keyboard
|
|
171
|
+
focus follow the regions' source order, not their position on the surface. A
|
|
172
|
+
caller renders regions in the order a reader should meet them.
|
|
173
|
+
- **AR3 — Movement offers non-drag paths.** Overlay's documented move pattern
|
|
174
|
+
for open regions pairs pointer dragging with a single-pointer path that needs
|
|
175
|
+
no drag, where activating a spot on the media moves the region there, and a
|
|
176
|
+
keyboard path that moves a region whose content has focus by a fixed step per
|
|
177
|
+
arrow key, with arrow directions matching the visual axes
|
|
178
|
+
(`design:bidirectionality/DR6`).
|
|
179
|
+
|
|
180
|
+
## Design relationships
|
|
181
|
+
|
|
182
|
+
| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
|
|
183
|
+
| -------------------- | ------------------------------------------------------------ | ----------------------------- | -------------- | ------------------ |
|
|
184
|
+
| Preset region | Reads as Overlay's own places, with Overlay's content inset. | Owner direction | Supporting | FR3 |
|
|
185
|
+
| Corner preset in RTL | Leading and trailing corners follow reading order. | `design:bidirectionality/DR3` | Supporting | FR4 |
|
|
186
|
+
| Open region | Stays on the same spot of the media at every size. | Owner direction | Supporting | FR7, FR8 |
|
|
187
|
+
| Open region in RTL | Media is physical content and does not mirror. | `design:bidirectionality/DR5` | Supporting | FR5 |
|
|
188
|
+
|
|
189
|
+
## Family and system relationships
|
|
190
|
+
|
|
191
|
+
- `design:bidirectionality` owns direction behavior: FR4 applies DR3 to corner
|
|
192
|
+
presets, FR5 applies DR5 to open placement, and AR3 applies DR6 to movement.
|
|
193
|
+
- `architecture:component-theming-surface/INV4` keeps a non-painting box out of
|
|
194
|
+
the theme surface: the region is a geometry-only box that paints nothing, so it
|
|
195
|
+
has no theme target and its content owns its paint.
|
|
196
|
+
- `architecture:public-component-api` owns prop admission and styling-input
|
|
197
|
+
order; ORD1 applies INV6.
|
|
198
|
+
- `spec:AST-027/FR2` puts the isolation boundary on the surface; FR10 makes the
|
|
199
|
+
Overlay or `useOverlay` container that holds a region that owner.
|
|
200
|
+
- `spec:AST-017/FR5` classifies this as nonbreaking: it adds a new component,
|
|
201
|
+
changes no default, and a caller who never renders a region sees no change.
|
|
202
|
+
|
|
203
|
+
## Verification map
|
|
204
|
+
|
|
205
|
+
| Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
|
|
206
|
+
| ----------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
|
|
207
|
+
| FR1, FR2, FR6, FR11 | `Overlay.test.tsx` region suite | Region in Overlay and `useOverlay` content; in a `bottom` Overlay; outside any Overlay; out-of-range and non-finite input; pointer and key events | Unclamped values, a region outside the scrim, a placed region in a strip or a missing warning, or a region that moves on its own fails. | `audit:Overlay/api` |
|
|
208
|
+
| FR3, FR4, FR5, FR7, FR8, FR10 | Real-Chromium assertions in the Overlay region story's play function | Every preset; open sized, edge-clamped, and content-sized; resized surface; RTL; a region before a positioned control | A region off its spot by more than 0.5px, a corner that does not flip or an open region that does, the in-flow control hit at a region's center, or wrong isolation, fails. | `audit:Overlay/layout` |
|
|
209
|
+
| FR9 | `Overlay.test.tsx` reveal case with a region | `isOpen={false}` | A region outside the scrim, or a closed scrim that is not inert, fails. | `audit:Overlay/api` |
|
|
210
|
+
| AR1, AR2 | `Overlay.test.tsx` DOM check of the region element | Text content | Any attribute other than `class` and `style` on the region element, or text it adds, fails. | `audit:Overlay/a11y` |
|
|
211
|
+
| AR3 | Region move story's play function | Arrow key and single activation on the media, LTR and RTL | ArrowRight moving a region left, or an activation that does not move the region to the activated spot, fails. | `audit:Overlay/a11y` |
|
|
212
|
+
| PR1 | Source review of `OverlayRegion` | All inputs | A ref read, observer, listener, or Effect added for placement fails review. | `audit:Overlay/layout` |
|
|
213
|
+
|
|
214
|
+
## Decision log
|
|
215
|
+
|
|
216
|
+
<!-- Record the boundary or requirement, not how it was reached. A rejected alternative is at most one line here, kept only when it is consequential and likely to recur. -->
|
|
217
|
+
|
|
218
|
+
### DEC-1 — Several placed items are Overlay regions with one `position` prop
|
|
219
|
+
|
|
220
|
+
**Reference:** `component:Overlay/DEC-1`
|
|
221
|
+
**Decider:** cixzhang, 2026-10-22
|
|
222
|
+
|
|
223
|
+
Overlay places one content block, so a surface with several spots needs a child
|
|
224
|
+
that places itself. `OverlayRegion` inside Overlay's content inherits Overlay's
|
|
225
|
+
surface, reveal, and media theme with no new Overlay prop, and one `position`
|
|
226
|
+
prop covers both kinds of spot: a named place or open placement. Sizes live
|
|
227
|
+
inside the open placement object, so a region never carries `width` or `height`
|
|
228
|
+
props whose numbers mean fractions while the same names mean pixels on Card,
|
|
229
|
+
Button, and Grid.
|
|
230
|
+
|
|
231
|
+
Rejected: an object form of Overlay's own `position`, because it still places
|
|
232
|
+
only one block.
|
|
233
|
+
|
|
234
|
+
### DEC-2 — Presets reuse Overlay's places and add the center and corners
|
|
235
|
+
|
|
236
|
+
**Reference:** `component:Overlay/DEC-2`
|
|
237
|
+
**Decider:** cixzhang, 2026-10-22
|
|
238
|
+
|
|
239
|
+
`fill`, `top`, and `bottom` mean on a region what they mean on Overlay, so a
|
|
240
|
+
builder reads one vocabulary. Static badges and labels need the center and the
|
|
241
|
+
corners, which Overlay's single block cannot hold beside other content. Corners
|
|
242
|
+
use `start` and `end` because a leading badge leads in either direction.
|
|
243
|
+
|
|
244
|
+
### DEC-3 — Moving a region stays at the callsite
|
|
245
|
+
|
|
246
|
+
**Reference:** `component:Overlay/DEC-3`
|
|
247
|
+
**Decider:** cixzhang, 2026-10-22
|
|
248
|
+
|
|
249
|
+
What moving a region means differs by product: whether the position commits on
|
|
250
|
+
release or live, the keyboard step, snapping, which regions move, and what
|
|
251
|
+
happens while a new rendering of the media loads. Overlay therefore never moves
|
|
252
|
+
a region; the caller converts pointer and keyboard input into new fractions
|
|
253
|
+
measured against the region's reference box and passes them back.
|
|
254
|
+
|
|
255
|
+
Rejected: a drag hook on Overlay, because a shared drag primitive needs its own
|
|
256
|
+
record and more than one caller's needs.
|
|
257
|
+
|
|
258
|
+
### DEC-4 — Regions belong to fill overlays
|
|
259
|
+
|
|
260
|
+
**Reference:** `component:Overlay/DEC-4`
|
|
261
|
+
**Decider:** cixzhang, 2026-10-22
|
|
262
|
+
|
|
263
|
+
A spot on the media is meaningless inside a strip whose height follows its own
|
|
264
|
+
content and which slides in and out. A strip is also the containing block of
|
|
265
|
+
everything inside it, so CSS cannot place a region against the media from
|
|
266
|
+
there. In a strip overlay a region therefore renders its content as ordinary
|
|
267
|
+
strip content and warns in development, so the content stays visible. The
|
|
268
|
+
public type cannot enforce the rule, because regions arrive through `content`.
|
|
269
|
+
The same fallback applies outside any fill host, where there is no surface to
|
|
270
|
+
measure against and nothing to isolate a region's paint order.
|
|
271
|
+
|
|
272
|
+
Rejected: placing a strip's regions against the container, because escaping the
|
|
273
|
+
strip needs a second rendering layer that duplicates Overlay's reveal.
|
|
274
|
+
|
|
275
|
+
### DEC-5 — Open sized axes are clamped inside; content-sized axes are not
|
|
276
|
+
|
|
277
|
+
**Reference:** `component:Overlay/DEC-5`
|
|
278
|
+
**Decider:** cixzhang, 2026-10-22
|
|
279
|
+
|
|
280
|
+
A sized region can be kept inside the surface with arithmetic on the given
|
|
281
|
+
fractions, so it never spills off the media however far it is moved. Keeping a
|
|
282
|
+
content-sized region inside would require measuring it, which PR1 forbids, so
|
|
283
|
+
the container clips any overhang.
|
|
284
|
+
|
|
285
|
+
### DEC-6 — Open placement names the region's top-left corner
|
|
286
|
+
|
|
287
|
+
**Reference:** `component:Overlay/DEC-6`
|
|
288
|
+
**Decider:** cixzhang, 2026-10-22
|
|
289
|
+
|
|
290
|
+
`x` and `y` are the region's top-left corner, and `width` and `height` extend
|
|
291
|
+
right and down. That matches how servers and editors report boxes (left, top,
|
|
292
|
+
width, height) and how CSS insets read, so a reported box passes through with no
|
|
293
|
+
conversion and is clamped with plain arithmetic.
|
|
294
|
+
|
|
295
|
+
Rejected: the region's center, because every reported box would need converting.
|
|
296
|
+
|
|
297
|
+
## Open questions
|
|
298
|
+
|
|
299
|
+
None.
|
|
300
|
+
|
|
301
|
+
## Content boundary
|
|
302
|
+
|
|
303
|
+
This file does not duplicate consumer prop tables/examples, current audit
|
|
304
|
+
results, implementation steps, or family/system rules. It links to their owners.
|