@astryxdesign/core 0.6.2-canary.6203871 → 0.6.2-canary.7cce14f
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/Kbd/Kbd.d.ts +5 -3
- package/dist/Kbd/Kbd.d.ts.map +1 -1
- package/dist/Kbd/Kbd.js +36 -42
- package/dist/SideNav/SideNav.d.ts +2 -1
- package/dist/SideNav/SideNav.d.ts.map +1 -1
- package/dist/SideNav/SideNav.js +9 -3
- package/dist/astryx.css +1 -0
- package/package.json +3 -3
- package/src/Button/__tests__/Button.a11y.chromium.spec.ts +17 -1
- package/src/Button/__tests__/Button.a11y.known-failures.ts +0 -29
- package/src/Button/__tests__/Button.a11y.renders.tsx +9 -2
- package/src/Button/__tests__/Button.a11y.states.ts +9 -0
- package/src/ClickableCard/ClickableCard.test.tsx +102 -5
- package/src/Kbd/Kbd.doc.mjs +3 -3
- package/src/Kbd/Kbd.test.tsx +57 -1
- package/src/Kbd/Kbd.tsx +57 -37
- package/src/SideNav/SideNav.doc.mjs +1 -1
- package/src/SideNav/SideNav.test.tsx +10 -0
- package/src/SideNav/SideNav.tsx +14 -2
package/dist/Kbd/Kbd.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file Kbd.tsx
|
|
3
|
-
* @input Uses React, StyleX, theme tokens
|
|
4
|
-
* @output Exports Kbd component and KbdProps
|
|
3
|
+
* @input Uses React, StyleX, theme tokens, shortcut key names and aliases
|
|
4
|
+
* @output Exports Kbd component and KbdProps with canonical shortcut labels
|
|
5
5
|
* @position Core implementation; renders styled keyboard shortcut indicators
|
|
6
6
|
*
|
|
7
7
|
* SYNC: When modified, update:
|
|
@@ -14,7 +14,9 @@ export interface KbdProps extends BaseProps<HTMLSpanElement> {
|
|
|
14
14
|
ref?: React.Ref<HTMLSpanElement>;
|
|
15
15
|
/**
|
|
16
16
|
* Keyboard shortcut string. Use "+" to separate keys.
|
|
17
|
-
* Special keys: mod (Cmd on Mac), ctrl, alt, shift, enter, backspace, escape
|
|
17
|
+
* Special keys: mod (Cmd on Mac), ctrl, alt, shift, enter, backspace, escape,
|
|
18
|
+
* tab, up, down, left, and right.
|
|
19
|
+
* Aliases: "esc" for "escape" and "return" for "enter".
|
|
18
20
|
* Use "plus" to render a literal "+" key (e.g. "shift+plus").
|
|
19
21
|
*
|
|
20
22
|
* @example
|
package/dist/Kbd/Kbd.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Kbd.d.ts","sourceRoot":"","sources":["../../src/Kbd/Kbd.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;GASG;AAEH,OAAO,KAA6B,MAAM,OAAO,CAAC;AAIlD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"Kbd.d.ts","sourceRoot":"","sources":["../../src/Kbd/Kbd.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;GASG;AAEH,OAAO,KAA6B,MAAM,OAAO,CAAC;AAIlD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AA+G5C,MAAM,WAAW,QAAS,SAAQ,SAAS,CAAC,eAAe,CAAC;IAC1D,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,eAAe,CAAC,CAAC;IACjC;;;;;;;;;;;;;;OAcG;IACH,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,GAAG,CAAC,EAAC,IAAI,EAAE,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,KAAK,EAAE,GAAG,IAAI,EAAC,EAAE,QAAQ,qBA6C3E;yBA7Ce,GAAG"}
|
package/dist/Kbd/Kbd.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file Kbd.tsx
|
|
7
|
-
* @input Uses React, StyleX, theme tokens
|
|
8
|
-
* @output Exports Kbd component and KbdProps
|
|
7
|
+
* @input Uses React, StyleX, theme tokens, shortcut key names and aliases
|
|
8
|
+
* @output Exports Kbd component and KbdProps with canonical shortcut labels
|
|
9
9
|
* @position Core implementation; renders styled keyboard shortcut indicators
|
|
10
10
|
*
|
|
11
11
|
* SYNC: When modified, update:
|
|
@@ -35,26 +35,20 @@ const styles = {
|
|
|
35
35
|
* Note: `mod` is not in this map — it resolves dynamically via platform
|
|
36
36
|
* detection inside the component.
|
|
37
37
|
*/
|
|
38
|
-
const KEY_DISPLAY =
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
up: '\u2191',
|
|
53
|
-
down: '\u2193',
|
|
54
|
-
left: '\u2190',
|
|
55
|
-
right: '\u2192',
|
|
56
|
-
plus: '+'
|
|
57
|
-
};
|
|
38
|
+
const KEY_DISPLAY = new Map([['ctrl', '\u2303'],
|
|
39
|
+
// ⌃
|
|
40
|
+
['alt', '\u2325'],
|
|
41
|
+
// ⌥
|
|
42
|
+
['shift', '\u21E7'],
|
|
43
|
+
// ⇧
|
|
44
|
+
['enter', '\u21B5'],
|
|
45
|
+
// ↵
|
|
46
|
+
['backspace', '\u232B'],
|
|
47
|
+
// ⌫
|
|
48
|
+
['escape', 'Esc'], ['tab', '\u21E5'],
|
|
49
|
+
// ⇥
|
|
50
|
+
['up', '\u2191'], ['down', '\u2193'], ['left', '\u2190'], ['right', '\u2192'], ['plus', '+']]);
|
|
51
|
+
const KBD_KEY_ALIASES = new Map([['esc', 'escape'], ['return', 'enter']]);
|
|
58
52
|
|
|
59
53
|
/**
|
|
60
54
|
* Resolves a key name to its display string. Handles the platform-aware
|
|
@@ -65,7 +59,7 @@ function getKeyDisplay(key, isMac) {
|
|
|
65
59
|
if (key === 'mod') {
|
|
66
60
|
return isMac ? '\u2318' : 'Ctrl';
|
|
67
61
|
}
|
|
68
|
-
return KEY_DISPLAY
|
|
62
|
+
return KEY_DISPLAY.get(key) ?? key.toUpperCase();
|
|
69
63
|
}
|
|
70
64
|
|
|
71
65
|
/**
|
|
@@ -73,25 +67,12 @@ function getKeyDisplay(key, isMac) {
|
|
|
73
67
|
* (⌘, ⇧, ↵, …) that assistive tech cannot announce meaningfully, so the
|
|
74
68
|
* accessible name for the shortcut is built from these words instead.
|
|
75
69
|
*/
|
|
76
|
-
const KEY_LABEL =
|
|
77
|
-
ctrl: 'Control',
|
|
78
|
-
alt: 'Alt',
|
|
79
|
-
shift: 'Shift',
|
|
80
|
-
enter: 'Enter',
|
|
81
|
-
backspace: 'Backspace',
|
|
82
|
-
escape: 'Escape',
|
|
83
|
-
tab: 'Tab',
|
|
84
|
-
up: 'Up arrow',
|
|
85
|
-
down: 'Down arrow',
|
|
86
|
-
left: 'Left arrow',
|
|
87
|
-
right: 'Right arrow',
|
|
88
|
-
plus: 'Plus'
|
|
89
|
-
};
|
|
70
|
+
const KEY_LABEL = new Map([['ctrl', 'Control'], ['alt', 'Alt'], ['shift', 'Shift'], ['enter', 'Enter'], ['backspace', 'Backspace'], ['escape', 'Escape'], ['tab', 'Tab'], ['up', 'Up arrow'], ['down', 'Down arrow'], ['left', 'Left arrow'], ['right', 'Right arrow'], ['plus', 'Plus']]);
|
|
90
71
|
function getKeyLabel(key, isMac) {
|
|
91
72
|
if (key === 'mod') {
|
|
92
73
|
return isMac ? 'Command' : 'Control';
|
|
93
74
|
}
|
|
94
|
-
return KEY_LABEL
|
|
75
|
+
return KEY_LABEL.get(key) ?? key.toUpperCase();
|
|
95
76
|
}
|
|
96
77
|
function subscribeToPlatformChanges() {
|
|
97
78
|
return () => {};
|
|
@@ -123,24 +104,37 @@ export function Kbd({
|
|
|
123
104
|
...rest
|
|
124
105
|
}) {
|
|
125
106
|
const isMac = useSyncExternalStore(subscribeToPlatformChanges, isApplePlatform, getServerPlatformSnapshot);
|
|
126
|
-
const
|
|
107
|
+
const keyOccurrences = new Map();
|
|
108
|
+
const parts = keys.split('+').map(key => key.trim().toLowerCase()).map(sourceKey => {
|
|
109
|
+
const occurrence = keyOccurrences.get(sourceKey) ?? 0;
|
|
110
|
+
keyOccurrences.set(sourceKey, occurrence + 1);
|
|
111
|
+
return {
|
|
112
|
+
key: KBD_KEY_ALIASES.get(sourceKey) ?? sourceKey,
|
|
113
|
+
reactKey: `${sourceKey}:${occurrence}`
|
|
114
|
+
};
|
|
115
|
+
});
|
|
127
116
|
|
|
128
117
|
// Screen-reader name: the joined spoken labels (e.g. "Command + K"), since
|
|
129
118
|
// the visual glyphs below are announced meaninglessly by assistive tech.
|
|
130
|
-
const accessibleName = parts.map(
|
|
119
|
+
const accessibleName = parts.map(({
|
|
120
|
+
key
|
|
121
|
+
}) => getKeyLabel(key, isMac)).join(' + ');
|
|
131
122
|
return /*#__PURE__*/_jsx("span", {
|
|
132
123
|
...rest,
|
|
133
124
|
ref: ref,
|
|
134
125
|
role: "img",
|
|
135
126
|
"aria-label": accessibleName,
|
|
136
127
|
...mergeProps(themeProps('kbd'), stylex.props(styles.wrapper, xstyle), className, style),
|
|
137
|
-
children: parts.map(
|
|
128
|
+
children: parts.map(({
|
|
129
|
+
key,
|
|
130
|
+
reactKey
|
|
131
|
+
}) => /*#__PURE__*/_jsx("kbd", {
|
|
138
132
|
"aria-hidden": "true",
|
|
139
133
|
...{
|
|
140
134
|
className: "x3nfvp2 x6s0dn4 xl56j7k x16asifk x1grt7ep x7a5moj xx3sua9 x17x4s8c xlxy82 x1q0q8m5 xib2hle xv1l7n4 x9ynric x141an7d x1e4wzip x1ltkj2j x87ps6o"
|
|
141
135
|
},
|
|
142
136
|
children: getKeyDisplay(key, isMac)
|
|
143
|
-
},
|
|
137
|
+
}, reactKey))
|
|
144
138
|
});
|
|
145
139
|
}
|
|
146
140
|
Kbd.displayName = 'Kbd';
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* Sidebar navigation container with five zones: header + topContent (sticky together),
|
|
8
8
|
* children (scrollable), footer, and footerIcons (sticky bottom).
|
|
9
9
|
*
|
|
10
|
-
* Supports optional resize via drag handle at the inline-end edge.
|
|
10
|
+
* Supports optional resize via a drag handle at the inline-end edge. The
|
|
11
|
+
* resizable wrapper clips handle overflow while keeping its focus ring inside.
|
|
11
12
|
*
|
|
12
13
|
* SYNC: When modified, update these files to stay in sync:
|
|
13
14
|
* - /packages/core/src/SideNav/SideNav.doc.mjs
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SideNav.d.ts","sourceRoot":"","sources":["../../src/SideNav/SideNav.tsx"],"names":[],"mappings":"AAIA
|
|
1
|
+
{"version":3,"file":"SideNav.d.ts","sourceRoot":"","sources":["../../src/SideNav/SideNav.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAA2C,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAC/E,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAE5C,OAAO,KAAK,EAAC,YAAY,EAAC,MAAM,kBAAkB,CAAC;AAGnD,OAAO,EAGL,KAAK,wBAAwB,EAC7B,KAAK,+BAA+B,EACrC,MAAM,0BAA0B,CAAC;AAKlC,OAAO,KAAK,EAAC,eAAe,EAAC,MAAM,2BAA2B,CAAC;AAsP/D,MAAM,WAAW,YAAa,SAAQ,SAAS,CAAC,WAAW,CAAC;IAC1D,wCAAwC;IACxC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IAE7B;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,+BAA+B,CAAC,CAAC;IAEvD;;OAEG;IACH,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB;;OAEG;IACH,UAAU,CAAC,EAAE,SAAS,CAAC;IACvB;;OAEG;IACH,QAAQ,EAAE,SAAS,CAAC;IACpB;;OAEG;IACH,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB;;OAEG;IACH,WAAW,CAAC,EAAE,SAAS,CAAC;IACxB;;;;;;;;;OASG;IACH,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;IAC5B;;OAEG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IAEvB;;;;;;;;;;;;;;;;;;;OAmBG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,eAAe,CAAC;IAEtC;;;;;;;;;;;;;OAaG;IACH,WAAW,CAAC,EAAE,OAAO,GAAG,wBAAwB,CAAC;CAClD;AAMD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,OAAO,CAAC,EACtB,MAAM,EACN,UAAU,EACV,QAAQ,EACR,MAAM,EACN,WAAW,EACX,WAAmB,EACnB,SAAiB,EACjB,MAAM,EACN,SAAS,EACT,KAAK,EACL,aAAa,EAAE,MAAM,EACrB,GAAG,EACH,SAAS,EACT,GAAG,KAAK,EACT,EAAE,YAAY,+BA2Qd;yBA1Re,OAAO"}
|
package/dist/SideNav/SideNav.js
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* Sidebar navigation container with five zones: header + topContent (sticky together),
|
|
12
12
|
* children (scrollable), footer, and footerIcons (sticky bottom).
|
|
13
13
|
*
|
|
14
|
-
* Supports optional resize via drag handle at the inline-end edge.
|
|
14
|
+
* Supports optional resize via a drag handle at the inline-end edge. The
|
|
15
|
+
* resizable wrapper clips handle overflow while keeping its focus ring inside.
|
|
15
16
|
*
|
|
16
17
|
* SYNC: When modified, update these files to stay in sync:
|
|
17
18
|
* - /packages/core/src/SideNav/SideNav.doc.mjs
|
|
@@ -23,7 +24,7 @@
|
|
|
23
24
|
import { useCallback, useImperativeHandle, useRef } from 'react';
|
|
24
25
|
import * as stylex from '@stylexjs/stylex';
|
|
25
26
|
import "../theme/tokens.stylex.js";
|
|
26
|
-
import { spacingVars } from "../theme/tokens.stylex.js";
|
|
27
|
+
import { focusVars, spacingVars } from "../theme/tokens.stylex.js";
|
|
27
28
|
import { mergeProps } from "../utils/index.js";
|
|
28
29
|
import { SideNavCollapseContext } from "./SideNavCollapseContext.js";
|
|
29
30
|
import { SideNavCollapseButton } from "./SideNavCollapseButton.js";
|
|
@@ -100,6 +101,10 @@ const styles = {
|
|
|
100
101
|
kzqmXN: "xhgvbfk",
|
|
101
102
|
$$css: true
|
|
102
103
|
},
|
|
104
|
+
resizableHandle: {
|
|
105
|
+
kInvED: "x1wfwxd8 x1l5jwag",
|
|
106
|
+
$$css: true
|
|
107
|
+
},
|
|
103
108
|
topbar: {
|
|
104
109
|
k1xSpc: "x78zum5",
|
|
105
110
|
kXwgrk: "x1q0g3np",
|
|
@@ -400,7 +405,7 @@ export function SideNav({
|
|
|
400
405
|
// the panel's overflow: clip bounds.
|
|
401
406
|
const content = showResizeHandle ? /*#__PURE__*/_jsxs("div", {
|
|
402
407
|
...{
|
|
403
|
-
className: "x1n2onr6 x78zum5 x2lah0s x5yr21d"
|
|
408
|
+
className: "x1n2onr6 x78zum5 x2lah0s x5yr21d x7giv3"
|
|
404
409
|
},
|
|
405
410
|
children: [navElement, /*#__PURE__*/_jsx(ResizeHandle, {
|
|
406
411
|
"data-testid": "astryx-sidenav-resize-handle",
|
|
@@ -408,6 +413,7 @@ export function SideNav({
|
|
|
408
413
|
position: "overlay",
|
|
409
414
|
pillPlacement: "end",
|
|
410
415
|
isAlwaysVisible: false,
|
|
416
|
+
xstyle: styles.resizableHandle,
|
|
411
417
|
resizable: resizableHook.props,
|
|
412
418
|
label: t('@astryx.sideNav.resizeSidebar')
|
|
413
419
|
})]
|
package/dist/astryx.css
CHANGED
|
@@ -1365,6 +1365,7 @@
|
|
|
1365
1365
|
.xx47ajj:focus-visible:not(#\#):not(#\#):not(#\#){outline-color:var(--focus-outline-color)}
|
|
1366
1366
|
.x7s97pk:focus-visible:not(#\#):not(#\#):not(#\#){outline-offset:2px}
|
|
1367
1367
|
.x1nw7mhf:focus-visible:not(#\#):not(#\#):not(#\#){outline-offset:calc(-1 * var(--focus-outline-width))}
|
|
1368
|
+
.x1l5jwag:focus-visible:not(#\#):not(#\#):not(#\#){outline-offset:calc(0px - var(--focus-outline-width))}
|
|
1368
1369
|
.x13aywxo:focus-visible:not(#\#):not(#\#):not(#\#){outline-offset:var(--button-focus-offset)}
|
|
1369
1370
|
.x1vwwbsn:focus-visible:not(#\#):not(#\#):not(#\#){outline-offset:var(--focus-outline-offset)}
|
|
1370
1371
|
.xwz5zln.xwz5zln:where(.x1iwu4tg:has(:focus-visible) *):not(#\#):not(#\#):not(#\#){outline-offset:var(--focus-outline-offset)}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/core",
|
|
3
|
-
"version": "0.6.2-canary.
|
|
3
|
+
"version": "0.6.2-canary.7cce14f",
|
|
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",
|
|
@@ -672,8 +672,8 @@
|
|
|
672
672
|
"react-dom": ">=19.0.0"
|
|
673
673
|
},
|
|
674
674
|
"devDependencies": {
|
|
675
|
-
"@astryxdesign/a11y-spec": "0.6.2-canary.
|
|
676
|
-
"@astryxdesign/cli": "0.6.2-canary.
|
|
675
|
+
"@astryxdesign/a11y-spec": "0.6.2-canary.7cce14f",
|
|
676
|
+
"@astryxdesign/cli": "0.6.2-canary.7cce14f",
|
|
677
677
|
"@babel/cli": "^7.29.7",
|
|
678
678
|
"@babel/core": "^7.29.7",
|
|
679
679
|
"@babel/preset-react": "^7.29.7",
|
|
@@ -21,7 +21,13 @@
|
|
|
21
21
|
* ./Button.a11y.known-failures.ts, both shared with the jsdom lane.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
expect,
|
|
26
|
+
test,
|
|
27
|
+
type CDPSession,
|
|
28
|
+
type Locator,
|
|
29
|
+
type Page,
|
|
30
|
+
} from '@playwright/test';
|
|
25
31
|
import {
|
|
26
32
|
BUTTON_PATTERN,
|
|
27
33
|
blockingResults,
|
|
@@ -93,6 +99,14 @@ async function activationsOn(page: Page): Promise<number> {
|
|
|
93
99
|
return Number(raw ?? '0');
|
|
94
100
|
}
|
|
95
101
|
|
|
102
|
+
function pointerTargetFor(
|
|
103
|
+
page: Page,
|
|
104
|
+
state: ButtonBindingRow,
|
|
105
|
+
): Locator | undefined {
|
|
106
|
+
const selector = (state as ButtonBindingState).pointerTargetSelector;
|
|
107
|
+
return selector == null ? undefined : page.locator(selector).first();
|
|
108
|
+
}
|
|
109
|
+
|
|
96
110
|
async function runState(
|
|
97
111
|
page: Page,
|
|
98
112
|
cdp: CDPSession,
|
|
@@ -111,6 +125,7 @@ async function runState(
|
|
|
111
125
|
return createChromiumHarness({
|
|
112
126
|
page,
|
|
113
127
|
subject: page.locator('#storybook-root').getByRole('button').first(),
|
|
128
|
+
pointerTarget: pointerTargetFor(page, state),
|
|
114
129
|
cdp,
|
|
115
130
|
});
|
|
116
131
|
},
|
|
@@ -306,6 +321,7 @@ test('every expectation is exercised by at least one bound state', async ({
|
|
|
306
321
|
.locator('#storybook-root')
|
|
307
322
|
.getByRole('button')
|
|
308
323
|
.first(),
|
|
324
|
+
pointerTarget: pointerTargetFor(page, state),
|
|
309
325
|
cdp,
|
|
310
326
|
});
|
|
311
327
|
},
|
|
@@ -83,33 +83,4 @@ export const BUTTON_KNOWN_FAILURES: ReadonlyArray<KnownFailure> = [
|
|
|
83
83
|
userImpact: 'The same, from an icon button.',
|
|
84
84
|
reason: 'Inherited from Button, as above.',
|
|
85
85
|
},
|
|
86
|
-
|
|
87
|
-
// ---- ClickableCard's role-bearing element cannot be clicked -------------
|
|
88
|
-
{
|
|
89
|
-
expectation: 'button.action.survives-an-aborted-press',
|
|
90
|
-
binding: 'ClickableCard',
|
|
91
|
-
state: 'clickable-card',
|
|
92
|
-
evidenceLayer: 'real-browser',
|
|
93
|
-
failureEquals:
|
|
94
|
-
'a pointer press cannot land on this control: something else is on top of it at its own centre, so there is no press here to abort',
|
|
95
|
-
standardsReference: 'WCAG 2.2 2.5.2 Pointer Cancellation (Level A)',
|
|
96
|
-
userImpact:
|
|
97
|
-
'The same defect seen from the other side: pointer cancellation cannot be demonstrated on a control no pointer press can land on. Recorded rather than reported green — a serene pass here would claim an outcome nobody observed.',
|
|
98
|
-
reason:
|
|
99
|
-
'Same cause as the record above. It is a second record rather than a wider one because a known failure names exactly one outcome, and these two would be fixed and verified separately.',
|
|
100
|
-
},
|
|
101
|
-
{
|
|
102
|
-
expectation: 'button.action.runs-on-pointer',
|
|
103
|
-
binding: 'ClickableCard',
|
|
104
|
-
state: 'clickable-card',
|
|
105
|
-
evidenceLayer: 'real-browser',
|
|
106
|
-
failureEquals:
|
|
107
|
-
'a pointer could not reach this control within 2000ms: the browser never found it visible, stable, and able to receive a pointer event. Something is covering it, or it is clipped to nothing.',
|
|
108
|
-
standardsReference:
|
|
109
|
-
'Current Astryx buttons family FR2 and WAI-ARIA APG Button operability requirement; supports WCAG 2.2 4.1.2 Name, Role, Value (Level A)',
|
|
110
|
-
userImpact:
|
|
111
|
-
"Anyone whose tooling acts on the accessible object rather than on pixels — a speech-input user saying the card's name, or assistive technology dispatching a click at the element it is told is the button — aims at a 1×1 control the card's own content paints over. A sighted mouse user is unaffected: they click the card surface, which handles it.",
|
|
112
|
-
reason:
|
|
113
|
-
'ClickableCard deliberately splits the surface that takes the click from the hidden button that carries the role and name. Reconciling the two is a design-system decision, not a change this contract should make.',
|
|
114
|
-
},
|
|
115
86
|
];
|
|
@@ -88,12 +88,19 @@ export const BUTTON_STATE_RENDERS: Record<ButtonStateId, StateRender> = {
|
|
|
88
88
|
),
|
|
89
89
|
|
|
90
90
|
'clickable-card': activate => (
|
|
91
|
-
<ClickableCard
|
|
91
|
+
<ClickableCard
|
|
92
|
+
data-a11y-pointer-target
|
|
93
|
+
label="Open billing settings"
|
|
94
|
+
onClick={activate}>
|
|
92
95
|
<div>Billing</div>
|
|
93
96
|
</ClickableCard>
|
|
94
97
|
),
|
|
95
98
|
'clickable-card-disabled': activate => (
|
|
96
|
-
<ClickableCard
|
|
99
|
+
<ClickableCard
|
|
100
|
+
data-a11y-pointer-target
|
|
101
|
+
label="Open billing settings"
|
|
102
|
+
isDisabled
|
|
103
|
+
onClick={activate}>
|
|
97
104
|
<div>Billing</div>
|
|
98
105
|
</ClickableCard>
|
|
99
106
|
),
|
|
@@ -92,6 +92,13 @@ export interface ButtonBindingState {
|
|
|
92
92
|
* typo here is reported as a stale inventory, not as a standards failure.
|
|
93
93
|
*/
|
|
94
94
|
readonly visibleLabel: string | null;
|
|
95
|
+
/**
|
|
96
|
+
* The surface that receives pointer input when it is not the role-bearing
|
|
97
|
+
* element. ClickableCard keeps its button/link hidden for role, name, and
|
|
98
|
+
* keyboard focus, and takes the pointer on the card surface; a binding that
|
|
99
|
+
* aimed the pointer at the hidden control would be testing the wrong surface.
|
|
100
|
+
*/
|
|
101
|
+
readonly pointerTargetSelector?: string;
|
|
95
102
|
/** The checked-in Storybook story the Chromium lane drives. */
|
|
96
103
|
readonly storyId: string;
|
|
97
104
|
/**
|
|
@@ -220,6 +227,7 @@ export const BUTTON_BINDING_STATES = [
|
|
|
220
227
|
"a card whose whole surface performs an action, through the hidden button that carries the card's role and name",
|
|
221
228
|
facts: facts(),
|
|
222
229
|
visibleLabel: null,
|
|
230
|
+
pointerTargetSelector: '[data-a11y-pointer-target]',
|
|
223
231
|
storyId: 'a11y-button-pattern--clickable-card-default',
|
|
224
232
|
},
|
|
225
233
|
{
|
|
@@ -228,6 +236,7 @@ export const BUTTON_BINDING_STATES = [
|
|
|
228
236
|
summary: 'a disabled action card',
|
|
229
237
|
facts: facts({operable: false, focusable: false, unavailable: true}),
|
|
230
238
|
visibleLabel: null,
|
|
239
|
+
pointerTargetSelector: '[data-a11y-pointer-target]',
|
|
231
240
|
storyId: 'a11y-button-pattern--clickable-card-disabled',
|
|
232
241
|
},
|
|
233
242
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import {describe, it, expect, vi} from 'vitest';
|
|
4
4
|
import {render, screen, fireEvent} from '@testing-library/react';
|
|
5
|
+
import userEvent from '@testing-library/user-event';
|
|
5
6
|
import {ClickableCard} from './ClickableCard';
|
|
6
7
|
|
|
7
8
|
describe('ClickableCard', () => {
|
|
@@ -47,6 +48,47 @@ describe('ClickableCard', () => {
|
|
|
47
48
|
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
48
49
|
});
|
|
49
50
|
|
|
51
|
+
it('calls onClick once when the accessible control itself is clicked', () => {
|
|
52
|
+
const handleClick = vi.fn();
|
|
53
|
+
render(
|
|
54
|
+
<ClickableCard label="Test card" onClick={handleClick}>
|
|
55
|
+
<span>Content</span>
|
|
56
|
+
</ClickableCard>,
|
|
57
|
+
);
|
|
58
|
+
// Pointer activation aimed at the element carrying the role — what
|
|
59
|
+
// speech input, assistive technology, and automation dispatch.
|
|
60
|
+
fireEvent.click(screen.getByRole('button', {name: 'Test card'}));
|
|
61
|
+
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('passes the card surface as currentTarget for surface clicks', () => {
|
|
65
|
+
const seen: EventTarget[] = [];
|
|
66
|
+
render(
|
|
67
|
+
<ClickableCard
|
|
68
|
+
label="Test card"
|
|
69
|
+
onClick={e => seen.push(e.currentTarget)}>
|
|
70
|
+
<span>Content</span>
|
|
71
|
+
</ClickableCard>,
|
|
72
|
+
);
|
|
73
|
+
fireEvent.click(screen.getByText('Content'));
|
|
74
|
+
expect(seen).toHaveLength(1);
|
|
75
|
+
expect(seen[0]).toBe(screen.getByText('Content').parentElement);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('calls onClick exactly once when the surface of an href card is clicked without preventDefault', () => {
|
|
79
|
+
const handleClick = vi.fn();
|
|
80
|
+
render(
|
|
81
|
+
<ClickableCard label="Nav card" href="/settings" onClick={handleClick}>
|
|
82
|
+
<span>Content</span>
|
|
83
|
+
</ClickableCard>,
|
|
84
|
+
);
|
|
85
|
+
// The container hook proxies the surface click to the link with
|
|
86
|
+
// `link.click()`; that synthetic click bubbles back through the card and
|
|
87
|
+
// must not run the consumer callback a second time.
|
|
88
|
+
fireEvent.click(screen.getByText('Content'));
|
|
89
|
+
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
50
92
|
it('does NOT call onClick when a nested button is clicked', () => {
|
|
51
93
|
const handleCardClick = vi.fn();
|
|
52
94
|
const handleButtonClick = vi.fn();
|
|
@@ -86,25 +128,80 @@ describe('ClickableCard', () => {
|
|
|
86
128
|
expect(link).toHaveAttribute('target', '_blank');
|
|
87
129
|
});
|
|
88
130
|
|
|
89
|
-
it('disabled button is disabled', () => {
|
|
131
|
+
it('disabled button is disabled and the surface runs nothing', () => {
|
|
90
132
|
const handleClick = vi.fn();
|
|
91
133
|
render(
|
|
92
134
|
<ClickableCard label="Disabled" onClick={handleClick} isDisabled>
|
|
93
|
-
Content
|
|
135
|
+
<span>Content</span>
|
|
94
136
|
</ClickableCard>,
|
|
95
137
|
);
|
|
96
138
|
const button = screen.getByRole('button', {name: 'Disabled'});
|
|
97
139
|
expect(button).toBeDisabled();
|
|
140
|
+
fireEvent.click(screen.getByText('Content'));
|
|
141
|
+
fireEvent.click(button);
|
|
142
|
+
expect(handleClick).not.toHaveBeenCalled();
|
|
98
143
|
});
|
|
99
144
|
|
|
100
|
-
it('disabled link has aria-disabled', () => {
|
|
145
|
+
it('disabled link has aria-disabled, leaves the tab order, and the surface runs nothing', () => {
|
|
146
|
+
const handleClick = vi.fn();
|
|
101
147
|
render(
|
|
102
|
-
<ClickableCard
|
|
103
|
-
|
|
148
|
+
<ClickableCard
|
|
149
|
+
label="Disabled link"
|
|
150
|
+
href="/settings"
|
|
151
|
+
onClick={handleClick}
|
|
152
|
+
isDisabled>
|
|
153
|
+
<span>Content</span>
|
|
104
154
|
</ClickableCard>,
|
|
105
155
|
);
|
|
106
156
|
const link = screen.getByRole('link', {name: 'Disabled link'});
|
|
107
157
|
expect(link).toHaveAttribute('aria-disabled', 'true');
|
|
158
|
+
expect(link).toHaveAttribute('tabindex', '-1');
|
|
159
|
+
fireEvent.click(screen.getByText('Content'));
|
|
160
|
+
expect(handleClick).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('runs onClick once from the keyboard via the hidden button', async () => {
|
|
164
|
+
const user = userEvent.setup();
|
|
165
|
+
const handleClick = vi.fn();
|
|
166
|
+
render(
|
|
167
|
+
<ClickableCard label="Test card" onClick={handleClick}>
|
|
168
|
+
<span>Content</span>
|
|
169
|
+
</ClickableCard>,
|
|
170
|
+
);
|
|
171
|
+
await user.tab();
|
|
172
|
+
expect(screen.getByRole('button', {name: 'Test card'})).toHaveFocus();
|
|
173
|
+
await user.keyboard('{Enter}');
|
|
174
|
+
await user.keyboard(' ');
|
|
175
|
+
expect(handleClick).toHaveBeenCalledTimes(2);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('does NOT call onClick when a nested input is clicked', () => {
|
|
179
|
+
const handleCardClick = vi.fn();
|
|
180
|
+
render(
|
|
181
|
+
<ClickableCard label="Test card" onClick={handleCardClick}>
|
|
182
|
+
<input aria-label="Quantity" readOnly value="1" />
|
|
183
|
+
</ClickableCard>,
|
|
184
|
+
);
|
|
185
|
+
fireEvent.click(screen.getByRole('textbox', {name: 'Quantity'}));
|
|
186
|
+
expect(handleCardClick).not.toHaveBeenCalled();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('does NOT call onClick when text inside the card is selected', () => {
|
|
190
|
+
const handleClick = vi.fn();
|
|
191
|
+
render(
|
|
192
|
+
<ClickableCard label="Test card" onClick={handleClick}>
|
|
193
|
+
<span>Selectable body text</span>
|
|
194
|
+
</ClickableCard>,
|
|
195
|
+
);
|
|
196
|
+
const text = screen.getByText('Selectable body text');
|
|
197
|
+
const range = document.createRange();
|
|
198
|
+
range.selectNodeContents(text);
|
|
199
|
+
const selection = document.getSelection()!;
|
|
200
|
+
selection.removeAllRanges();
|
|
201
|
+
selection.addRange(range);
|
|
202
|
+
fireEvent.click(text);
|
|
203
|
+
expect(handleClick).not.toHaveBeenCalled();
|
|
204
|
+
selection.removeAllRanges();
|
|
108
205
|
});
|
|
109
206
|
|
|
110
207
|
describe('elevation', () => {
|
package/src/Kbd/Kbd.doc.mjs
CHANGED
|
@@ -27,7 +27,7 @@ export const docs = {
|
|
|
27
27
|
name: 'keys',
|
|
28
28
|
type: 'string',
|
|
29
29
|
description:
|
|
30
|
-
'Keyboard shortcut string. Use "+" to separate keys. Special keys: mod (Cmd on Mac), ctrl, alt, shift, enter, backspace, escape, tab, up, down, left, right.',
|
|
30
|
+
'Keyboard shortcut string. Use "+" to separate keys. Special keys: mod (Cmd on Mac), ctrl, alt, shift, enter, backspace, escape, tab, up, down, left, right, plus. Aliases: esc for escape and return for enter.',
|
|
31
31
|
required: true,
|
|
32
32
|
},
|
|
33
33
|
{
|
|
@@ -72,7 +72,7 @@ export const docsZh = {
|
|
|
72
72
|
name: 'keys',
|
|
73
73
|
type: 'string',
|
|
74
74
|
description:
|
|
75
|
-
'键盘快捷键字符串。使用 "+" 分隔各按键。特殊按键:mod(Mac 上为 Cmd)、ctrl、alt、shift、enter、backspace、escape、tab、up、down、left、right。',
|
|
75
|
+
'键盘快捷键字符串。使用 "+" 分隔各按键。特殊按键:mod(Mac 上为 Cmd)、ctrl、alt、shift、enter、backspace、escape、tab、up、down、left、right、plus。别名:esc 等同于 escape,return 等同于 enter。',
|
|
76
76
|
required: true,
|
|
77
77
|
},
|
|
78
78
|
{
|
|
@@ -122,7 +122,7 @@ export const docsDense = {
|
|
|
122
122
|
],
|
|
123
123
|
},
|
|
124
124
|
propDescriptions: {
|
|
125
|
-
keys: 'Shortcut string. "+" separates keys. Special: mod (Cmd on Mac), ctrl, alt, shift, enter, backspace, escape, tab, up, down, left, right.',
|
|
125
|
+
keys: 'Shortcut string. "+" separates keys. Special: mod (Cmd on Mac), ctrl, alt, shift, enter, backspace, escape, tab, up, down, left, right, plus. Aliases: esc for escape, return for enter.',
|
|
126
126
|
xstyle: 'StyleX styles for layout customization. Must be stylex.create() value.',
|
|
127
127
|
className: 'CSS class for root element. Prefer xstyle; className for non-StyleX integration.',
|
|
128
128
|
style: 'Inline styles for root element. Prefer xstyle; inline styles bypass StyleX optimization.',
|
package/src/Kbd/Kbd.test.tsx
CHANGED
|
@@ -10,13 +10,14 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import {render, screen} from '@testing-library/react';
|
|
13
|
-
import {describe, it, expect, afterEach} from 'vitest';
|
|
13
|
+
import {describe, it, expect, afterEach, vi} from 'vitest';
|
|
14
14
|
import {Kbd} from './Kbd';
|
|
15
15
|
|
|
16
16
|
describe('Kbd', () => {
|
|
17
17
|
const originalPlatform = navigator.platform;
|
|
18
18
|
|
|
19
19
|
afterEach(() => {
|
|
20
|
+
vi.restoreAllMocks();
|
|
20
21
|
// Restore platform after any test that spoofs it \u2014 ensures no
|
|
21
22
|
// test pollution even if an assertion fails mid-test.
|
|
22
23
|
Object.defineProperty(navigator, 'platform', {
|
|
@@ -72,6 +73,61 @@ describe('Kbd', () => {
|
|
|
72
73
|
expect(screen.getByText('Esc')).toBeInTheDocument();
|
|
73
74
|
});
|
|
74
75
|
|
|
76
|
+
it.each([
|
|
77
|
+
{alias: 'esc', display: 'Esc', label: 'Escape'},
|
|
78
|
+
{alias: 'return', display: '\u21B5', label: 'Enter'},
|
|
79
|
+
])('normalizes the $alias key alias', ({alias, display, label}) => {
|
|
80
|
+
render(<Kbd keys={alias} />);
|
|
81
|
+
expect(screen.getByText(display)).toBeInTheDocument();
|
|
82
|
+
expect(screen.getByRole('img')).toHaveAttribute('aria-label', label);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it.each([
|
|
86
|
+
{alias: 'esc', canonical: 'escape'},
|
|
87
|
+
{alias: 'return', canonical: 'enter'},
|
|
88
|
+
])('$alias renders identically to $canonical', ({alias, canonical}) => {
|
|
89
|
+
const {container, rerender} = render(<Kbd keys={canonical} />);
|
|
90
|
+
const text = container.textContent;
|
|
91
|
+
const label = screen.getByRole('img').getAttribute('aria-label');
|
|
92
|
+
rerender(<Kbd keys={alias} />);
|
|
93
|
+
expect(container.textContent).toBe(text);
|
|
94
|
+
expect(screen.getByRole('img').getAttribute('aria-label')).toBe(label);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it.each([
|
|
98
|
+
{keys: 'ctrl + ESC', display: 'Esc', label: 'Control + Escape'},
|
|
99
|
+
{keys: 'shift + RETURN', display: '\u21B5', label: 'Shift + Enter'},
|
|
100
|
+
])('normalizes aliases inside the $keys combo', ({keys, display, label}) => {
|
|
101
|
+
render(<Kbd keys={keys} />);
|
|
102
|
+
expect(screen.getByText(display)).toBeInTheDocument();
|
|
103
|
+
expect(screen.getByRole('img')).toHaveAttribute('aria-label', label);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('keeps child keys unique after alias normalization', () => {
|
|
107
|
+
const consoleError = vi
|
|
108
|
+
.spyOn(console, 'error')
|
|
109
|
+
.mockImplementation(() => {});
|
|
110
|
+
const {container} = render(
|
|
111
|
+
<Kbd keys="escape+esc+ESC+enter+return+return" />,
|
|
112
|
+
);
|
|
113
|
+
expect(consoleError).not.toHaveBeenCalled();
|
|
114
|
+
expect(
|
|
115
|
+
Array.from(container.querySelectorAll('kbd'), key => key.textContent),
|
|
116
|
+
).toEqual(['Esc', 'Esc', 'Esc', '↵', '↵', '↵']);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it.each(['constructor', '__proto__'])(
|
|
120
|
+
'renders the unknown %s key without consulting object prototypes',
|
|
121
|
+
key => {
|
|
122
|
+
render(<Kbd keys={key} />);
|
|
123
|
+
expect(screen.getByText(key.toUpperCase())).toBeInTheDocument();
|
|
124
|
+
expect(screen.getByRole('img')).toHaveAttribute(
|
|
125
|
+
'aria-label',
|
|
126
|
+
key.toUpperCase(),
|
|
127
|
+
);
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
|
|
75
131
|
it('exposes a spoken accessible name and hides the glyphs (obs-1)', () => {
|
|
76
132
|
render(<Kbd keys="mod+shift+k" />);
|
|
77
133
|
// The wrapper carries a screen-reader name built from spoken key labels
|
package/src/Kbd/Kbd.tsx
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file Kbd.tsx
|
|
7
|
-
* @input Uses React, StyleX, theme tokens
|
|
8
|
-
* @output Exports Kbd component and KbdProps
|
|
7
|
+
* @input Uses React, StyleX, theme tokens, shortcut key names and aliases
|
|
8
|
+
* @output Exports Kbd component and KbdProps with canonical shortcut labels
|
|
9
9
|
* @position Core implementation; renders styled keyboard shortcut indicators
|
|
10
10
|
*
|
|
11
11
|
* SYNC: When modified, update:
|
|
@@ -61,20 +61,25 @@ const styles = stylex.create({
|
|
|
61
61
|
* Note: `mod` is not in this map — it resolves dynamically via platform
|
|
62
62
|
* detection inside the component.
|
|
63
63
|
*/
|
|
64
|
-
const KEY_DISPLAY
|
|
65
|
-
ctrl
|
|
66
|
-
alt
|
|
67
|
-
shift
|
|
68
|
-
enter
|
|
69
|
-
backspace
|
|
70
|
-
escape
|
|
71
|
-
tab
|
|
72
|
-
up
|
|
73
|
-
down
|
|
74
|
-
left
|
|
75
|
-
right
|
|
76
|
-
plus
|
|
77
|
-
|
|
64
|
+
const KEY_DISPLAY = new Map<string, string>([
|
|
65
|
+
['ctrl', '\u2303'], // ⌃
|
|
66
|
+
['alt', '\u2325'], // ⌥
|
|
67
|
+
['shift', '\u21E7'], // ⇧
|
|
68
|
+
['enter', '\u21B5'], // ↵
|
|
69
|
+
['backspace', '\u232B'], // ⌫
|
|
70
|
+
['escape', 'Esc'],
|
|
71
|
+
['tab', '\u21E5'], // ⇥
|
|
72
|
+
['up', '\u2191'],
|
|
73
|
+
['down', '\u2193'],
|
|
74
|
+
['left', '\u2190'],
|
|
75
|
+
['right', '\u2192'],
|
|
76
|
+
['plus', '+'],
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
const KBD_KEY_ALIASES = new Map<string, string>([
|
|
80
|
+
['esc', 'escape'],
|
|
81
|
+
['return', 'enter'],
|
|
82
|
+
]);
|
|
78
83
|
|
|
79
84
|
/**
|
|
80
85
|
* Resolves a key name to its display string. Handles the platform-aware
|
|
@@ -85,7 +90,7 @@ function getKeyDisplay(key: string, isMac: boolean): string {
|
|
|
85
90
|
if (key === 'mod') {
|
|
86
91
|
return isMac ? '\u2318' : 'Ctrl';
|
|
87
92
|
}
|
|
88
|
-
return KEY_DISPLAY
|
|
93
|
+
return KEY_DISPLAY.get(key) ?? key.toUpperCase();
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
/**
|
|
@@ -93,26 +98,26 @@ function getKeyDisplay(key: string, isMac: boolean): string {
|
|
|
93
98
|
* (⌘, ⇧, ↵, …) that assistive tech cannot announce meaningfully, so the
|
|
94
99
|
* accessible name for the shortcut is built from these words instead.
|
|
95
100
|
*/
|
|
96
|
-
const KEY_LABEL
|
|
97
|
-
ctrl
|
|
98
|
-
alt
|
|
99
|
-
shift
|
|
100
|
-
enter
|
|
101
|
-
backspace
|
|
102
|
-
escape
|
|
103
|
-
tab
|
|
104
|
-
up
|
|
105
|
-
down
|
|
106
|
-
left
|
|
107
|
-
right
|
|
108
|
-
plus
|
|
109
|
-
|
|
101
|
+
const KEY_LABEL = new Map<string, string>([
|
|
102
|
+
['ctrl', 'Control'],
|
|
103
|
+
['alt', 'Alt'],
|
|
104
|
+
['shift', 'Shift'],
|
|
105
|
+
['enter', 'Enter'],
|
|
106
|
+
['backspace', 'Backspace'],
|
|
107
|
+
['escape', 'Escape'],
|
|
108
|
+
['tab', 'Tab'],
|
|
109
|
+
['up', 'Up arrow'],
|
|
110
|
+
['down', 'Down arrow'],
|
|
111
|
+
['left', 'Left arrow'],
|
|
112
|
+
['right', 'Right arrow'],
|
|
113
|
+
['plus', 'Plus'],
|
|
114
|
+
]);
|
|
110
115
|
|
|
111
116
|
function getKeyLabel(key: string, isMac: boolean): string {
|
|
112
117
|
if (key === 'mod') {
|
|
113
118
|
return isMac ? 'Command' : 'Control';
|
|
114
119
|
}
|
|
115
|
-
return KEY_LABEL
|
|
120
|
+
return KEY_LABEL.get(key) ?? key.toUpperCase();
|
|
116
121
|
}
|
|
117
122
|
|
|
118
123
|
function subscribeToPlatformChanges(): () => void {
|
|
@@ -127,7 +132,9 @@ export interface KbdProps extends BaseProps<HTMLSpanElement> {
|
|
|
127
132
|
ref?: React.Ref<HTMLSpanElement>;
|
|
128
133
|
/**
|
|
129
134
|
* Keyboard shortcut string. Use "+" to separate keys.
|
|
130
|
-
* Special keys: mod (Cmd on Mac), ctrl, alt, shift, enter, backspace, escape
|
|
135
|
+
* Special keys: mod (Cmd on Mac), ctrl, alt, shift, enter, backspace, escape,
|
|
136
|
+
* tab, up, down, left, and right.
|
|
137
|
+
* Aliases: "esc" for "escape" and "return" for "enter".
|
|
131
138
|
* Use "plus" to render a literal "+" key (e.g. "shift+plus").
|
|
132
139
|
*
|
|
133
140
|
* @example
|
|
@@ -163,11 +170,24 @@ export function Kbd({keys, ref, xstyle, className, style, ...rest}: KbdProps) {
|
|
|
163
170
|
getServerPlatformSnapshot,
|
|
164
171
|
);
|
|
165
172
|
|
|
166
|
-
const
|
|
173
|
+
const keyOccurrences = new Map<string, number>();
|
|
174
|
+
const parts = keys
|
|
175
|
+
.split('+')
|
|
176
|
+
.map(key => key.trim().toLowerCase())
|
|
177
|
+
.map(sourceKey => {
|
|
178
|
+
const occurrence = keyOccurrences.get(sourceKey) ?? 0;
|
|
179
|
+
keyOccurrences.set(sourceKey, occurrence + 1);
|
|
180
|
+
return {
|
|
181
|
+
key: KBD_KEY_ALIASES.get(sourceKey) ?? sourceKey,
|
|
182
|
+
reactKey: `${sourceKey}:${occurrence}`,
|
|
183
|
+
};
|
|
184
|
+
});
|
|
167
185
|
|
|
168
186
|
// Screen-reader name: the joined spoken labels (e.g. "Command + K"), since
|
|
169
187
|
// the visual glyphs below are announced meaninglessly by assistive tech.
|
|
170
|
-
const accessibleName = parts
|
|
188
|
+
const accessibleName = parts
|
|
189
|
+
.map(({key}) => getKeyLabel(key, isMac))
|
|
190
|
+
.join(' + ');
|
|
171
191
|
|
|
172
192
|
return (
|
|
173
193
|
<span
|
|
@@ -181,8 +201,8 @@ export function Kbd({keys, ref, xstyle, className, style, ...rest}: KbdProps) {
|
|
|
181
201
|
className,
|
|
182
202
|
style,
|
|
183
203
|
)}>
|
|
184
|
-
{parts.map(key => (
|
|
185
|
-
<kbd key={
|
|
204
|
+
{parts.map(({key, reactKey}) => (
|
|
205
|
+
<kbd key={reactKey} aria-hidden="true" {...stylex.props(styles.kbd)}>
|
|
186
206
|
{getKeyDisplay(key, isMac)}
|
|
187
207
|
</kbd>
|
|
188
208
|
))}
|
|
@@ -25,7 +25,7 @@ export const docs = {
|
|
|
25
25
|
{className: 'astryx-side-nav-section'},
|
|
26
26
|
],
|
|
27
27
|
},
|
|
28
|
-
description: 'Container with five zones: header, topContent, children (scrollable), footer, and footerIcons. Supports collapsible
|
|
28
|
+
description: 'Container with five zones: header, topContent, children (scrollable), footer, and footerIcons. Supports collapsible and resizable modes.',
|
|
29
29
|
props: [
|
|
30
30
|
{
|
|
31
31
|
name: 'header',
|
|
@@ -989,6 +989,16 @@ describe('SideNav resizable', () => {
|
|
|
989
989
|
).toBeInTheDocument();
|
|
990
990
|
});
|
|
991
991
|
|
|
992
|
+
it('clips the overlay drag handle within the resizable nav bounds', () => {
|
|
993
|
+
render(<SideNav resizable>Content</SideNav>);
|
|
994
|
+
const nav = screen.getByRole('navigation');
|
|
995
|
+
const container = nav.parentElement;
|
|
996
|
+
const handle = screen.getByTestId('astryx-sidenav-resize-handle');
|
|
997
|
+
|
|
998
|
+
expect(container).toContainElement(handle);
|
|
999
|
+
expect(getComputedStyle(container!).overflow).toBe('clip');
|
|
1000
|
+
});
|
|
1001
|
+
|
|
992
1002
|
it('does not render drag handle without resizable', () => {
|
|
993
1003
|
render(<SideNav>Content</SideNav>);
|
|
994
1004
|
expect(
|
package/src/SideNav/SideNav.tsx
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* Sidebar navigation container with five zones: header + topContent (sticky together),
|
|
12
12
|
* children (scrollable), footer, and footerIcons (sticky bottom).
|
|
13
13
|
*
|
|
14
|
-
* Supports optional resize via drag handle at the inline-end edge.
|
|
14
|
+
* Supports optional resize via a drag handle at the inline-end edge. The
|
|
15
|
+
* resizable wrapper clips handle overflow while keeping its focus ring inside.
|
|
15
16
|
*
|
|
16
17
|
* SYNC: When modified, update these files to stay in sync:
|
|
17
18
|
* - /packages/core/src/SideNav/SideNav.doc.mjs
|
|
@@ -25,7 +26,7 @@ import {useCallback, useImperativeHandle, useRef, type ReactNode} from 'react';
|
|
|
25
26
|
import type {BaseProps} from '../BaseProps';
|
|
26
27
|
import * as stylex from '@stylexjs/stylex';
|
|
27
28
|
import type {StyleXStyles} from '@stylexjs/stylex';
|
|
28
|
-
import {spacingVars} from '../theme/tokens.stylex';
|
|
29
|
+
import {focusVars, spacingVars} from '../theme/tokens.stylex';
|
|
29
30
|
import {mergeProps} from '../utils';
|
|
30
31
|
import {
|
|
31
32
|
SideNavCollapseContext,
|
|
@@ -241,6 +242,16 @@ const styles = stylex.create({
|
|
|
241
242
|
display: 'flex',
|
|
242
243
|
flexShrink: 0,
|
|
243
244
|
height: '100%',
|
|
245
|
+
overflow: 'clip',
|
|
246
|
+
},
|
|
247
|
+
resizableHandle: {
|
|
248
|
+
// Keep the shared ring inside the clipping boundary. The wrapper cannot
|
|
249
|
+
// use overflow-clip-margin: that paint allowance also contributes the
|
|
250
|
+
// overlay's half-pixel spill to AppShell's scrollable overflow.
|
|
251
|
+
outlineOffset: {
|
|
252
|
+
default: '0',
|
|
253
|
+
':focus-visible': `calc(0px - ${focusVars['--focus-outline-width']})`,
|
|
254
|
+
},
|
|
244
255
|
},
|
|
245
256
|
// Topbar mode — horizontal layout for mobile top bar
|
|
246
257
|
topbar: {
|
|
@@ -658,6 +669,7 @@ export function SideNav({
|
|
|
658
669
|
position="overlay"
|
|
659
670
|
pillPlacement="end"
|
|
660
671
|
isAlwaysVisible={false}
|
|
672
|
+
xstyle={styles.resizableHandle}
|
|
661
673
|
resizable={resizableHook.props}
|
|
662
674
|
label={t('@astryx.sideNav.resizeSidebar')}
|
|
663
675
|
/>
|