@astryxdesign/core 0.7.2-canary.fbc03f7 → 0.7.2-canary.fdd384f
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/AppShell/AppShell.d.ts.map +1 -1
- package/dist/AppShell/AppShell.js +40 -1
- package/dist/CommandPalette/CommandPaletteInput.d.ts +1 -1
- package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteInput.js +37 -5
- package/dist/Heading/Heading.d.ts.map +1 -1
- package/dist/Heading/Heading.js +4 -2
- package/dist/MobileNav/MobileNav.d.ts +9 -0
- package/dist/MobileNav/MobileNav.d.ts.map +1 -1
- package/dist/MobileNav/MobileNav.js +30 -3
- package/dist/SideNav/SideNavItem.d.ts +8 -1
- package/dist/SideNav/SideNavItem.d.ts.map +1 -1
- package/dist/SideNav/SideNavItem.js +71 -9
- package/dist/Text/Text.d.ts.map +1 -1
- package/dist/Text/Text.js +4 -2
- package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMegaMenu.js +3 -3
- package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMenu.js +3 -3
- package/dist/TreeList/TreeList.d.ts.map +1 -1
- package/dist/TreeList/TreeList.js +1 -0
- package/dist/TreeList/TreeListBranches.d.ts.map +1 -1
- package/dist/TreeList/TreeListBranches.js +2 -2
- package/dist/TreeList/TreeListItem.d.ts.map +1 -1
- package/dist/TreeList/TreeListItem.js +57 -9
- package/dist/astryx.css +11 -1
- package/package.json +3 -3
- package/src/AppShell/AppShell.tsx +45 -1
- package/src/Breadcrumbs/Breadcrumbs.test.tsx +30 -1
- package/src/Card/Card.doc.mjs +1 -1
- package/src/CommandPalette/CommandPaletteInput.test.tsx +53 -4
- package/src/CommandPalette/CommandPaletteInput.tsx +33 -3
- package/src/CommandPalette/__tests__/CommandPaletteInputAudit.a11y.chromium.spec.ts +335 -0
- package/src/CommandPalette/__tests__/CommandPaletteItemAudit.a11y.chromium.spec.ts +292 -0
- package/src/ContextMenu/ContextMenu.doc.mjs +2 -3
- package/src/ContextMenu/__tests__/ContextMenuAudit.a11y.chromium.spec.ts +438 -0
- package/src/DropdownMenu/DropdownMenu.test.tsx +41 -1
- package/src/DropdownMenu/DropdownMenuDivider.spec.md +192 -0
- package/src/DropdownMenu/__tests__/DropdownMenuDividerAudit.a11y.chromium.spec.ts +280 -0
- package/src/Heading/Heading.test.tsx +57 -0
- package/src/Heading/Heading.tsx +6 -2
- package/src/MobileNav/MobileNav.tsx +30 -3
- package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +173 -15
- package/src/SideNav/SideNav.test.tsx +179 -0
- package/src/SideNav/SideNavItem.doc.mjs +2 -2
- package/src/SideNav/SideNavItem.tsx +96 -17
- package/src/Text/Text.test.tsx +57 -0
- package/src/Text/Text.tsx +6 -2
- package/src/TopNav/TopNavMegaMenu.test.tsx +22 -0
- package/src/TopNav/TopNavMegaMenu.tsx +7 -3
- package/src/TopNav/TopNavMenu.test.tsx +19 -0
- package/src/TopNav/TopNavMenu.tsx +11 -3
- package/src/TreeList/TreeList.doc.mjs +1 -0
- package/src/TreeList/TreeList.test.tsx +327 -6
- package/src/TreeList/TreeList.tsx +5 -1
- package/src/TreeList/TreeListBranches.tsx +6 -0
- package/src/TreeList/TreeListItem.tsx +88 -13
- package/src/theme/derivedVarRegistry.test.ts +4 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
---
|
|
2
|
+
schema_version: 3
|
|
3
|
+
template_version: 7
|
|
4
|
+
kind: component
|
|
5
|
+
id: component:DropdownMenuDivider
|
|
6
|
+
authority: draft
|
|
7
|
+
archive_reason: null
|
|
8
|
+
superseded_by: null
|
|
9
|
+
approved_by: null
|
|
10
|
+
approved_at: null
|
|
11
|
+
owners: [cixzhang]
|
|
12
|
+
review_triggers: [public-api, behavior, theming, accessibility, testing]
|
|
13
|
+
verified_by:
|
|
14
|
+
[
|
|
15
|
+
packages/core/src/DropdownMenu/DropdownMenu.test.tsx,
|
|
16
|
+
packages/core/src/ContextMenu/ContextMenu.test.tsx,
|
|
17
|
+
packages/core/src/Breadcrumbs/Breadcrumbs.test.tsx,
|
|
18
|
+
packages/core/src/theme/themingTargets.test.ts,
|
|
19
|
+
apps/storybook/stories/DropdownMenuDivider.stories.tsx,
|
|
20
|
+
scripts/check-knowledge.mjs,
|
|
21
|
+
]
|
|
22
|
+
modules: []
|
|
23
|
+
families: []
|
|
24
|
+
design_specs: []
|
|
25
|
+
architecture:
|
|
26
|
+
[
|
|
27
|
+
architecture:public-component-api,
|
|
28
|
+
architecture:component-theming-surface,
|
|
29
|
+
architecture:component-test-sufficiency,
|
|
30
|
+
]
|
|
31
|
+
contributing: []
|
|
32
|
+
system_specs: [spec:AST-020, spec:AST-029]
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
# DropdownMenuDivider component contract
|
|
36
|
+
|
|
37
|
+
## Contract at a glance
|
|
38
|
+
|
|
39
|
+
| Area | Contract |
|
|
40
|
+
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
41
|
+
| Public contract | `DropdownMenuDivider` / `DropdownMenuDividerProps`, plus the `ContextMenuDivider` and `BreadcrumbMenuDivider` aliases and their props; `BaseProps<HTMLDivElement>` minus caller-owned `role` and `aria-orientation`; a root `ref`. |
|
|
42
|
+
| Behavior | One unlabelled horizontal separator divides pointer-menu rows and stays out of arrow-key order; data and compound pointer modes render the same component. |
|
|
43
|
+
| End-user impact | Pointer-menu sections remain visually separated without adding a focus stop or changing row operation. |
|
|
44
|
+
| Builder impact | No new caller choice. Host attributes, handlers, and styling inputs continue to compose on the separator. |
|
|
45
|
+
| Compatibility/readiness | Patch-compatible observational contract. Runtime behavior, exports, defaults, and target names remain unchanged; authority stays `draft` until exact-head owner approval. |
|
|
46
|
+
| Review checks | Reject a focusable separator, changed role/orientation, pointer data/compound divergence, dropped root inputs, or a target moved off the visible separator. |
|
|
47
|
+
| Governing rules | `architecture:public-component-api/INV1,INV5–INV9`; `architecture:component-theming-surface/INV3–INV6`; WCAG 2.2 SC 1.3.1 and 4.1.2 through `spec:AST-020/FR1`. |
|
|
48
|
+
|
|
49
|
+
This table is a review projection; the body below is authoritative.
|
|
50
|
+
|
|
51
|
+
## Intent
|
|
52
|
+
|
|
53
|
+
DropdownMenuDivider provides the visual and programmatic boundary between groups
|
|
54
|
+
of rows in pointer-menu presentations. It ships directly under the DropdownMenu
|
|
55
|
+
name and through ContextMenu and Breadcrumbs aliases. It owns the separator
|
|
56
|
+
semantics, menu spacing, root input composition, and the
|
|
57
|
+
`dropdown-menu-divider` theme target. The surrounding DropdownMenu, ContextMenu,
|
|
58
|
+
or BreadcrumbItem owns menu lifecycle and row navigation; Divider supplies the
|
|
59
|
+
base rule geometry and paint.
|
|
60
|
+
|
|
61
|
+
Consumer syntax and examples belong in `DropdownMenuDivider.doc.mjs`.
|
|
62
|
+
|
|
63
|
+
## Compatibility and migration
|
|
64
|
+
|
|
65
|
+
- Released default preserved: yes — one horizontal, unlabelled separator.
|
|
66
|
+
- Compatibility class: observational patch; no behavior or public surface changes.
|
|
67
|
+
- Controlled/uncontrolled behavior: not applicable.
|
|
68
|
+
- Migration decision: none.
|
|
69
|
+
|
|
70
|
+
Consumer migration instructions belong in consumer docs and release notes.
|
|
71
|
+
|
|
72
|
+
## Ownership boundary
|
|
73
|
+
|
|
74
|
+
**Owns**
|
|
75
|
+
|
|
76
|
+
- One horizontal separator root and its public ref, DOM/data/ARIA, class, style,
|
|
77
|
+
event, and `xstyle` composition.
|
|
78
|
+
- Menu-specific block-axis spacing.
|
|
79
|
+
- The `dropdown-menu-divider` target on the same visible root.
|
|
80
|
+
- Pointer-menu data/compound rendering parity for the divider row across
|
|
81
|
+
DropdownMenu, ContextMenu, and breadcrumb menus.
|
|
82
|
+
|
|
83
|
+
**Does not own / non-goals**
|
|
84
|
+
|
|
85
|
+
- Menu open state, focus entry, row activation, or arrow-key navigation — owned by
|
|
86
|
+
DropdownMenu, ContextMenu, BreadcrumbItem, and their shared hooks.
|
|
87
|
+
- Touch and bottom-sheet data dividers — those presentations render Divider
|
|
88
|
+
directly and are owned by their parent menu surface.
|
|
89
|
+
- Base separator rule geometry and token paint — delegated to Divider.
|
|
90
|
+
- Caller-authored grouping meaning — owned by the product callsite.
|
|
91
|
+
|
|
92
|
+
## Public concepts
|
|
93
|
+
|
|
94
|
+
| Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
|
|
95
|
+
| ------------- | --------------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------- | ---------------------- | -------------------- | --------- | ------------------------------------------------------------------------------- |
|
|
96
|
+
| Separator row | one horizontal unlabelled separator | Divides adjacent pointer-menu row groups without becoming a row or focus stop. | Data and compound pointer-menu modes | present where authored | component | stable | Role and orientation are excluded from caller props and remain component-owned. |
|
|
97
|
+
| Root surface | ref; DOM/data/ARIA; class/style/xstyle/events | Extends the separator root while preserving component semantics. | Every separator row | omitted | public component API | stable | Accepted inputs compose; role and orientation remain authoritative. |
|
|
98
|
+
|
|
99
|
+
## Behavioral and layout contract
|
|
100
|
+
|
|
101
|
+
Draft requirements identify their basis so observed code is not mistaken for an
|
|
102
|
+
intentional decision.
|
|
103
|
+
|
|
104
|
+
| ID | Candidate invariant | Basis | Draft review state |
|
|
105
|
+
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------ |
|
|
106
|
+
| FR1 | The component MUST render one unlabelled horizontal separator with pointer-menu-specific block spacing. | Released source/docs and focused tests | verify |
|
|
107
|
+
| FR2 | The root MUST expose `role="separator"` and `aria-orientation="horizontal"`, remain outside menu-item collection and arrow-key order, and never become focusable. | WCAG 2.2 SC 1.3.1 and 4.1.2; focused tests | verify |
|
|
108
|
+
| FR3 | In pointer-menu presentation, `{type: "divider"}` in the data API and the compound divider component or alias MUST render the same component DOM, spacing, semantics, and targets. | Released docs/source; data/compound parity test | verify |
|
|
109
|
+
| FR4 | The visible root MUST carry both the delegated `divider` target and the menu-specific `dropdown-menu-divider` target; menu spacing applies before caller `xstyle`. | `architecture:component-theming-surface/INV3–INV6`; source/tests | verify |
|
|
110
|
+
| FR5 | Accepted ref, DOM/data/ARIA, class, style, events, and `xstyle` MUST reach or compose on the separator root without replacing component-owned role or orientation. | `architecture:public-component-api/INV5–INV8` | verify |
|
|
111
|
+
|
|
112
|
+
### Allowed variation
|
|
113
|
+
|
|
114
|
+
- **AV1 — Theme paint.** The delegated Divider and menu-specific targets may restyle
|
|
115
|
+
their owned surface without changing separator semantics or focus behavior.
|
|
116
|
+
- **AV2 — Caller root inputs.** Supported host attributes, handlers, and styling
|
|
117
|
+
inputs may vary while the owned role and orientation remain fixed.
|
|
118
|
+
|
|
119
|
+
### Representative states
|
|
120
|
+
|
|
121
|
+
| State | Required invariant | Allowed variation |
|
|
122
|
+
| ------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------- |
|
|
123
|
+
| Pointer data-mode divider | One horizontal separator between data-driven rows. | Adjacent row content and active theme may vary. |
|
|
124
|
+
| Pointer compound divider | DOM and behavior match the pointer data-mode divider. | Host attributes and supported styling inputs may be supplied. |
|
|
125
|
+
| Public aliases | ContextMenuDivider and BreadcrumbMenuDivider render this component. | The containing menu owns lifecycle and navigation. |
|
|
126
|
+
| Touch data divider | Outside this component; the parent surface renders Divider directly. | Touch-menu layout follows the parent contract. |
|
|
127
|
+
| RTL | The horizontal, full-width rule remains direction-neutral. | Surrounding text and menu alignment may mirror. |
|
|
128
|
+
| Narrow viewport | The rule remains inside the menu without horizontal overflow. | Menu width follows its owner. |
|
|
129
|
+
|
|
130
|
+
### Transformation and precedence order
|
|
131
|
+
|
|
132
|
+
- **ORD1 — Root composition.** Apply menu spacing, then caller `xstyle`; combine the
|
|
133
|
+
menu-specific target class with caller `className`; preserve caller `style` and
|
|
134
|
+
host props; keep role and orientation component-owned.
|
|
135
|
+
|
|
136
|
+
### Performance and resources
|
|
137
|
+
|
|
138
|
+
- **PR1 — No external lifecycle.** DropdownMenuDivider installs no listener,
|
|
139
|
+
observer, timer, request, state machine, or Effect.
|
|
140
|
+
|
|
141
|
+
## Accessibility contract
|
|
142
|
+
|
|
143
|
+
- **AR1 — Separator semantics.** The root exposes a horizontal separator and no
|
|
144
|
+
interactive role or tab stop.
|
|
145
|
+
- **AR2 — Navigation exclusion.** Menu navigation collects menu-item roles only, so
|
|
146
|
+
ArrowDown and ArrowUp pass across the separator without focusing it.
|
|
147
|
+
|
|
148
|
+
## Design relationships
|
|
149
|
+
|
|
150
|
+
| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
|
|
151
|
+
| ---------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -------------- | ------------------ |
|
|
152
|
+
| Menu separator | Visually separates adjacent menu row groups without competing with row content. | Current source and delegated Divider tokens; no component-local visual decision added | supporting | FR1–FR4 |
|
|
153
|
+
|
|
154
|
+
No component-local visual decision is settled by this draft.
|
|
155
|
+
|
|
156
|
+
## Family and system relationships
|
|
157
|
+
|
|
158
|
+
- `architecture:public-component-api` owns exports and root input composition.
|
|
159
|
+
- `architecture:component-theming-surface` owns target placement and composed target
|
|
160
|
+
ownership.
|
|
161
|
+
- `architecture:component-test-sufficiency` owns proportionate, observable evidence.
|
|
162
|
+
- DropdownMenu exports the implementation directly. ContextMenu and Breadcrumbs
|
|
163
|
+
re-export it as `ContextMenuDivider` and `BreadcrumbMenuDivider`, with matching
|
|
164
|
+
props aliases; their pointer presentations retain the same component contract.
|
|
165
|
+
- Touch and bottom-sheet data dividers bypass this component and delegate directly
|
|
166
|
+
to Divider under the parent menu contract.
|
|
167
|
+
- Divider provides the base separator implementation; its draft record is
|
|
168
|
+
observational context, not authority for this contract.
|
|
169
|
+
- `spec:AST-029` keeps this observational draft separate from the audit receipt.
|
|
170
|
+
|
|
171
|
+
## Verification map
|
|
172
|
+
|
|
173
|
+
| Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
|
|
174
|
+
| ---------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
|
175
|
+
| FR1–FR3, AR1–AR2 | `DropdownMenu.test.tsx`; ContextMenu and Breadcrumbs alias tests; dedicated Storybook state; scoped axe | pointer data/compound, aliases, open menu, keyboard traversal | Pointer DOM modes drift, an alias disappears, the role/orientation changes, or the separator enters arrow-key order | `audit:DropdownMenuDivider/§1,§3,§4,§6` |
|
|
176
|
+
| FR4 | Focused root-composition test; exact-head Neutral/Butter browser frames | light/dark themes; menu and delegated targets | A target leaves the visible root or caller `xstyle` stops composing after menu spacing | `audit:DropdownMenuDivider/§2,§5` |
|
|
177
|
+
| FR5 | Focused root-composition test; lint and typecheck | ref, host attributes, class/style/xstyle/events | An accepted input is dropped or owned semantics are overwritten | `audit:DropdownMenuDivider/§3` |
|
|
178
|
+
| PR1 | Source review | every state | A lifecycle mechanism appears without a resource contract | `audit:DropdownMenuDivider/§7` |
|
|
179
|
+
|
|
180
|
+
## Decision log
|
|
181
|
+
|
|
182
|
+
None. This draft records current facts and introduces no product decision.
|
|
183
|
+
|
|
184
|
+
## Open questions
|
|
185
|
+
|
|
186
|
+
None.
|
|
187
|
+
|
|
188
|
+
## Content boundary
|
|
189
|
+
|
|
190
|
+
This file does not duplicate consumer prop tables/examples, current audit scores,
|
|
191
|
+
screenshot matrices, implementation steps, or system rules. The audit pull request
|
|
192
|
+
owns the closed run evidence required by `spec:AST-029/FR5`.
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file DropdownMenuDividerAudit.a11y.chromium.spec.ts
|
|
5
|
+
* @input Exact-head DropdownMenuDivider story across themes, color modes, direction, and viewport widths
|
|
6
|
+
* @output Screenshots and fail-closed sensor receipts for the DropdownMenuDivider component audit
|
|
7
|
+
* @position Component-owned Night Watch browser evidence; CI runs it only for a DropdownMenuDivider audit pull request
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {createHash} from 'node:crypto';
|
|
11
|
+
import {execFileSync} from 'node:child_process';
|
|
12
|
+
import * as fs from 'node:fs';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import {expect, test, type Page} from '@playwright/test';
|
|
15
|
+
import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_STORYBOOK_DIR,
|
|
18
|
+
serveStorybook,
|
|
19
|
+
type StaticServer,
|
|
20
|
+
} from '@astryxdesign/a11y-spec/storybook';
|
|
21
|
+
|
|
22
|
+
const OUTPUT = path.resolve('dropdown-menu-divider-audit-evidence');
|
|
23
|
+
const STORY_ID = 'core-dropdownmenudivider--menu-separator';
|
|
24
|
+
const WIDE = {width: 1024, height: 768};
|
|
25
|
+
const NARROW = {width: 320, height: 720};
|
|
26
|
+
|
|
27
|
+
type Mode = 'light' | 'dark';
|
|
28
|
+
type Direction = 'ltr' | 'rtl';
|
|
29
|
+
type Theme = 'neutral' | 'butter';
|
|
30
|
+
type AuditCase = {
|
|
31
|
+
key: string;
|
|
32
|
+
mode: Mode;
|
|
33
|
+
direction: Direction;
|
|
34
|
+
theme: Theme;
|
|
35
|
+
viewport: {width: number; height: number};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const cases: AuditCase[] = [
|
|
39
|
+
{
|
|
40
|
+
key: 'neutral-light',
|
|
41
|
+
mode: 'light',
|
|
42
|
+
direction: 'ltr',
|
|
43
|
+
theme: 'neutral',
|
|
44
|
+
viewport: WIDE,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
key: 'neutral-dark',
|
|
48
|
+
mode: 'dark',
|
|
49
|
+
direction: 'ltr',
|
|
50
|
+
theme: 'neutral',
|
|
51
|
+
viewport: WIDE,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
key: 'butter-light',
|
|
55
|
+
mode: 'light',
|
|
56
|
+
direction: 'ltr',
|
|
57
|
+
theme: 'butter',
|
|
58
|
+
viewport: WIDE,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
key: 'butter-dark',
|
|
62
|
+
mode: 'dark',
|
|
63
|
+
direction: 'ltr',
|
|
64
|
+
theme: 'butter',
|
|
65
|
+
viewport: WIDE,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
key: 'rtl-light',
|
|
69
|
+
mode: 'light',
|
|
70
|
+
direction: 'rtl',
|
|
71
|
+
theme: 'neutral',
|
|
72
|
+
viewport: WIDE,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
key: 'rtl-dark',
|
|
76
|
+
mode: 'dark',
|
|
77
|
+
direction: 'rtl',
|
|
78
|
+
theme: 'neutral',
|
|
79
|
+
viewport: WIDE,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
key: 'narrow-light',
|
|
83
|
+
mode: 'light',
|
|
84
|
+
direction: 'ltr',
|
|
85
|
+
theme: 'neutral',
|
|
86
|
+
viewport: NARROW,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
key: 'narrow-dark',
|
|
90
|
+
mode: 'dark',
|
|
91
|
+
direction: 'ltr',
|
|
92
|
+
theme: 'neutral',
|
|
93
|
+
viewport: NARROW,
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
test.describe.configure({mode: 'serial', retries: 0});
|
|
98
|
+
let server: StaticServer;
|
|
99
|
+
let checkoutSha: string;
|
|
100
|
+
let storybookSha: string;
|
|
101
|
+
|
|
102
|
+
function storyUrl(item: AuditCase) {
|
|
103
|
+
return `${server.origin}/iframe.html?id=${STORY_ID}&viewMode=story&globals=colorMode:${item.mode};astryxTheme:${item.theme};direction:${item.direction}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function settle(page: Page) {
|
|
107
|
+
await holdMotionStill(page);
|
|
108
|
+
await page.evaluate(async () => document.fonts.ready);
|
|
109
|
+
await page.waitForTimeout(40);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
test.beforeAll(async () => {
|
|
113
|
+
fs.rmSync(OUTPUT, {recursive: true, force: true});
|
|
114
|
+
fs.mkdirSync(OUTPUT, {recursive: true});
|
|
115
|
+
checkoutSha = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
116
|
+
encoding: 'utf8',
|
|
117
|
+
}).trim();
|
|
118
|
+
const expected = process.env.ASTRYX_HEAD_SHA;
|
|
119
|
+
if (expected && expected !== checkoutSha) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`checkout ${checkoutSha} does not match expected head ${expected}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
server = await serveStorybook(
|
|
125
|
+
process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
|
|
126
|
+
);
|
|
127
|
+
const response = await fetch(`${server.origin}/astryx-build-sha.txt`);
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
throw new Error(`Storybook build has no SHA stamp (${response.status})`);
|
|
130
|
+
}
|
|
131
|
+
storybookSha = (await response.text()).trim();
|
|
132
|
+
if (storybookSha !== checkoutSha) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`Storybook ${storybookSha} does not match checkout ${checkoutSha}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test.afterAll(async () => {
|
|
140
|
+
await server?.close();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
for (const item of cases) {
|
|
144
|
+
test(item.key, async ({browser}) => {
|
|
145
|
+
const context = await browser.newContext({
|
|
146
|
+
viewport: item.viewport,
|
|
147
|
+
colorScheme: item.mode,
|
|
148
|
+
reducedMotion: 'reduce',
|
|
149
|
+
});
|
|
150
|
+
const page = await context.newPage();
|
|
151
|
+
const pageErrors: string[] = [];
|
|
152
|
+
page.on('pageerror', error => pageErrors.push(String(error)));
|
|
153
|
+
await page.goto(storyUrl(item), {waitUntil: 'load'});
|
|
154
|
+
await page.locator('#storybook-root').waitFor();
|
|
155
|
+
const menu = page.getByRole('menu').last();
|
|
156
|
+
await expect(menu).toBeVisible();
|
|
157
|
+
const separator = page.getByRole('separator');
|
|
158
|
+
await expect(separator).toBeVisible();
|
|
159
|
+
await expect(separator).toHaveAttribute('aria-orientation', 'horizontal');
|
|
160
|
+
await settle(page);
|
|
161
|
+
|
|
162
|
+
const menuItems = page.getByRole('menuitem');
|
|
163
|
+
await expect(menuItems).toHaveCount(2);
|
|
164
|
+
await menu.focus();
|
|
165
|
+
await page.keyboard.press('ArrowDown');
|
|
166
|
+
await expect(menuItems.first()).toBeFocused();
|
|
167
|
+
await page.keyboard.press('ArrowDown');
|
|
168
|
+
await expect(menuItems.last()).toBeFocused();
|
|
169
|
+
await expect(separator).not.toBeFocused();
|
|
170
|
+
|
|
171
|
+
const observed = await separator.evaluate(element => {
|
|
172
|
+
const rootBox = element.getBoundingClientRect();
|
|
173
|
+
const rule = element.firstElementChild;
|
|
174
|
+
const ruleBox = rule?.getBoundingClientRect() ?? null;
|
|
175
|
+
const rootStyle = getComputedStyle(element);
|
|
176
|
+
const ruleStyle = rule ? getComputedStyle(rule) : null;
|
|
177
|
+
return {
|
|
178
|
+
theme: document
|
|
179
|
+
.querySelector('[data-astryx-theme]')
|
|
180
|
+
?.getAttribute('data-astryx-theme'),
|
|
181
|
+
mode: document.documentElement.getAttribute('data-theme'),
|
|
182
|
+
direction: rootStyle.direction,
|
|
183
|
+
viewport: {width: innerWidth, height: innerHeight},
|
|
184
|
+
reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
|
|
185
|
+
horizontalOverflow:
|
|
186
|
+
document.documentElement.scrollWidth > innerWidth + 1,
|
|
187
|
+
fonts: document.fonts.status,
|
|
188
|
+
role: element.getAttribute('role'),
|
|
189
|
+
orientation: element.getAttribute('aria-orientation'),
|
|
190
|
+
tabIndex: element.getAttribute('tabindex'),
|
|
191
|
+
targetClasses: {
|
|
192
|
+
menuDivider: element.classList.contains(
|
|
193
|
+
'astryx-dropdown-menu-divider',
|
|
194
|
+
),
|
|
195
|
+
divider: element.classList.contains('astryx-divider'),
|
|
196
|
+
},
|
|
197
|
+
geometry: {
|
|
198
|
+
root: {
|
|
199
|
+
x: rootBox.x,
|
|
200
|
+
y: rootBox.y,
|
|
201
|
+
width: rootBox.width,
|
|
202
|
+
height: rootBox.height,
|
|
203
|
+
},
|
|
204
|
+
rule:
|
|
205
|
+
ruleBox === null
|
|
206
|
+
? null
|
|
207
|
+
: {
|
|
208
|
+
x: ruleBox.x,
|
|
209
|
+
y: ruleBox.y,
|
|
210
|
+
width: ruleBox.width,
|
|
211
|
+
height: ruleBox.height,
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
ruleBackground: ruleStyle?.backgroundColor ?? null,
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const geometryPass =
|
|
219
|
+
observed.geometry.root.width > 0 &&
|
|
220
|
+
observed.geometry.root.height > 0 &&
|
|
221
|
+
observed.geometry.rule !== null &&
|
|
222
|
+
observed.geometry.rule.width > 0 &&
|
|
223
|
+
observed.geometry.rule.height > 0;
|
|
224
|
+
const paintPass =
|
|
225
|
+
observed.ruleBackground !== null &&
|
|
226
|
+
observed.ruleBackground !== 'rgba(0, 0, 0, 0)' &&
|
|
227
|
+
observed.ruleBackground !== 'transparent';
|
|
228
|
+
const png = await page.screenshot({animations: 'disabled', fullPage: true});
|
|
229
|
+
const image = `${item.key}.png`;
|
|
230
|
+
const receipt = {
|
|
231
|
+
schemaVersion: 1,
|
|
232
|
+
expected: {
|
|
233
|
+
build: checkoutSha,
|
|
234
|
+
storyId: STORY_ID,
|
|
235
|
+
mode: item.mode,
|
|
236
|
+
direction: item.direction,
|
|
237
|
+
theme: item.theme,
|
|
238
|
+
viewport: item.viewport,
|
|
239
|
+
},
|
|
240
|
+
observed: {
|
|
241
|
+
build: storybookSha,
|
|
242
|
+
...observed,
|
|
243
|
+
menuItemCount: await menuItems.count(),
|
|
244
|
+
pageErrors,
|
|
245
|
+
},
|
|
246
|
+
image: {
|
|
247
|
+
file: image,
|
|
248
|
+
sha256: createHash('sha256').update(png).digest('hex'),
|
|
249
|
+
width: png.readUInt32BE(16),
|
|
250
|
+
height: png.readUInt32BE(20),
|
|
251
|
+
},
|
|
252
|
+
checks: {geometryPass, paintPass},
|
|
253
|
+
passed:
|
|
254
|
+
storybookSha === checkoutSha &&
|
|
255
|
+
pageErrors.length === 0 &&
|
|
256
|
+
observed.theme === item.theme &&
|
|
257
|
+
observed.mode === item.mode &&
|
|
258
|
+
observed.direction === item.direction &&
|
|
259
|
+
observed.viewport.width === item.viewport.width &&
|
|
260
|
+
observed.viewport.height === item.viewport.height &&
|
|
261
|
+
observed.reducedMotion &&
|
|
262
|
+
!observed.horizontalOverflow &&
|
|
263
|
+
observed.fonts === 'loaded' &&
|
|
264
|
+
observed.role === 'separator' &&
|
|
265
|
+
observed.orientation === 'horizontal' &&
|
|
266
|
+
observed.tabIndex === null &&
|
|
267
|
+
observed.targetClasses.menuDivider &&
|
|
268
|
+
observed.targetClasses.divider &&
|
|
269
|
+
geometryPass &&
|
|
270
|
+
paintPass,
|
|
271
|
+
};
|
|
272
|
+
fs.writeFileSync(path.join(OUTPUT, image), png);
|
|
273
|
+
fs.writeFileSync(
|
|
274
|
+
path.join(OUTPUT, `${item.key}.sensors.json`),
|
|
275
|
+
`${JSON.stringify(receipt, null, 2)}\n`,
|
|
276
|
+
);
|
|
277
|
+
expect(receipt.passed).toBe(true);
|
|
278
|
+
await context.close();
|
|
279
|
+
});
|
|
280
|
+
}
|
|
@@ -269,3 +269,60 @@ describe('Heading', () => {
|
|
|
269
269
|
expect(element).not.toHaveAttribute('data-type', 'editorial');
|
|
270
270
|
});
|
|
271
271
|
});
|
|
272
|
+
|
|
273
|
+
// With hasTruncateTooltip={false} nothing reads the measured overflow, so a
|
|
274
|
+
// long table of truncated cells must not pay a layout read per cell.
|
|
275
|
+
function countScrollWidthReads(run: () => void): number {
|
|
276
|
+
const proto = window.HTMLElement.prototype;
|
|
277
|
+
const original = Object.getOwnPropertyDescriptor(proto, 'scrollWidth');
|
|
278
|
+
let reads = 0;
|
|
279
|
+
Object.defineProperty(proto, 'scrollWidth', {
|
|
280
|
+
configurable: true,
|
|
281
|
+
get() {
|
|
282
|
+
reads += 1;
|
|
283
|
+
return 0;
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
try {
|
|
287
|
+
run();
|
|
288
|
+
} finally {
|
|
289
|
+
if (original) {
|
|
290
|
+
Object.defineProperty(proto, 'scrollWidth', original);
|
|
291
|
+
} else {
|
|
292
|
+
delete (proto as {scrollWidth?: number}).scrollWidth;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return reads;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
describe('Heading maxLines without a truncation tooltip', () => {
|
|
299
|
+
it('takes no truncation measurement when hasTruncateTooltip is false', () => {
|
|
300
|
+
const reads = countScrollWidthReads(() => {
|
|
301
|
+
render(
|
|
302
|
+
<>
|
|
303
|
+
{Array.from({length: 20}, (_, index) => (
|
|
304
|
+
<Heading
|
|
305
|
+
level={3}
|
|
306
|
+
maxLines={1}
|
|
307
|
+
hasTruncateTooltip={false}
|
|
308
|
+
key={index}>
|
|
309
|
+
Cell {index} with text far wider than its column
|
|
310
|
+
</Heading>
|
|
311
|
+
))}
|
|
312
|
+
</>,
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
expect(reads).toBe(0);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('still measures when the tooltip is enabled', () => {
|
|
319
|
+
const reads = countScrollWidthReads(() => {
|
|
320
|
+
render(
|
|
321
|
+
<Heading level={3} maxLines={1}>
|
|
322
|
+
Text far wider than its column
|
|
323
|
+
</Heading>,
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
expect(reads).toBeGreaterThan(0);
|
|
327
|
+
});
|
|
328
|
+
});
|
package/src/Heading/Heading.tsx
CHANGED
|
@@ -253,8 +253,12 @@ export function Heading({
|
|
|
253
253
|
// the closed built-in StyleX maps with an unknown name.
|
|
254
254
|
const builtinType = type && isBuiltinHeadingType(type) ? type : undefined;
|
|
255
255
|
|
|
256
|
-
// Truncation detection
|
|
257
|
-
|
|
256
|
+
// Truncation detection. Only the tooltip reads the measured overflow, so
|
|
257
|
+
// with the tooltip off the text is clipped by CSS alone and nothing is
|
|
258
|
+
// measured or observed.
|
|
259
|
+
const truncation = useTruncation({
|
|
260
|
+
maxLines: hasTruncateTooltip === false ? 0 : maxLines,
|
|
261
|
+
});
|
|
258
262
|
|
|
259
263
|
// Tooltip for truncated text
|
|
260
264
|
const tooltipPlacement: LayerPlacement =
|
|
@@ -260,8 +260,15 @@ const dynamicStyles = stylex.create({
|
|
|
260
260
|
// Close timing
|
|
261
261
|
// =============================================================================
|
|
262
262
|
|
|
263
|
-
/**
|
|
264
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Longest the drawer will wait before closing, however long the hold is.
|
|
265
|
+
*
|
|
266
|
+
* Exported so `AppShell` can defer hiding this drawer's `Activity` boundary
|
|
267
|
+
* by at least this long too (`ACTIVITY_HIDE_BUFFER_MS` in AppShell.tsx) —
|
|
268
|
+
* otherwise Activity's `display: none !important` lands before this delay's
|
|
269
|
+
* own scheduled `dialog.close()` has a chance to run (#5701).
|
|
270
|
+
*/
|
|
271
|
+
export const MAX_CLOSE_DELAY_MS = 250;
|
|
265
272
|
/** Fraction of the hold to close at, so the close never lands on its boundary. */
|
|
266
273
|
const CLOSE_WITHIN_HOLD = 0.6;
|
|
267
274
|
|
|
@@ -488,6 +495,21 @@ export function MobileNav({
|
|
|
488
495
|
// Element that was focused when the drawer opened; focus goes back to it
|
|
489
496
|
// once the native dialog has closed (see closeAndRestoreFocus).
|
|
490
497
|
const triggerElementRef = useRef<HTMLElement | null>(null);
|
|
498
|
+
|
|
499
|
+
// Keeps the dialog rendered from the commit that closes it until the delayed
|
|
500
|
+
// `close()` below has run. The `display` transition on the dialog is meant to
|
|
501
|
+
// do this, but a browser that does not hold a discrete `display` transition
|
|
502
|
+
// (WebKit) drops the dialog to `display: none` at once while it is still an
|
|
503
|
+
// open modal: nothing is visible, yet the whole page stays inert until the
|
|
504
|
+
// timer fires. Set during render, so it is already true in the commit that
|
|
505
|
+
// closes. The drawer's transform and the backdrop stay keyed to `isOpen`, so
|
|
506
|
+
// the slide-out is unchanged.
|
|
507
|
+
const [isCloseHeld, setIsCloseHeld] = useState(false);
|
|
508
|
+
const [wasOpen, setWasOpen] = useState(isOpen);
|
|
509
|
+
if (wasOpen !== isOpen) {
|
|
510
|
+
setWasOpen(isOpen);
|
|
511
|
+
setIsCloseHeld(!isOpen);
|
|
512
|
+
}
|
|
491
513
|
const gutterRef = useRef<ScrollbarGutterHold | null>(null);
|
|
492
514
|
|
|
493
515
|
// Gives back the gutter held open in place of the hidden scrollbar.
|
|
@@ -563,7 +585,12 @@ export function MobileNav({
|
|
|
563
585
|
|
|
564
586
|
closeTimeoutRef.current = setTimeout(() => {
|
|
565
587
|
closeAndRestoreFocus(dialog, triggerElementRef);
|
|
588
|
+
setIsCloseHeld(false);
|
|
566
589
|
}, resolveCloseDelay(dialog));
|
|
590
|
+
} else {
|
|
591
|
+
// Nothing is open, so there is no close to hold the dialog for, and no
|
|
592
|
+
// close timer is left to release the hold.
|
|
593
|
+
setIsCloseHeld(false);
|
|
567
594
|
}
|
|
568
595
|
|
|
569
596
|
return () => {
|
|
@@ -647,7 +674,7 @@ export function MobileNav({
|
|
|
647
674
|
layerStructureReset.reset,
|
|
648
675
|
styles.dialog,
|
|
649
676
|
overlayPaddingReset.reset,
|
|
650
|
-
isOpen && styles.open,
|
|
677
|
+
(isOpen || isCloseHeld) && styles.open,
|
|
651
678
|
styles.backdrop,
|
|
652
679
|
isOpen && styles.backdropOpen,
|
|
653
680
|
xstyle,
|