@astryxdesign/core 0.7.0 → 0.7.1-canary.0365e23
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/BottomSheet/BottomSheet.d.ts +1 -1
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +12 -2
- package/dist/CodeBlock/CodeBlock.d.ts +10 -1
- package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
- package/dist/CodeBlock/CodeBlock.js +158 -38
- package/dist/CodeBlock/tokenizer.d.ts +39 -0
- package/dist/CodeBlock/tokenizer.d.ts.map +1 -1
- package/dist/CodeBlock/tokenizer.js +108 -0
- package/dist/ContextMenu/ContextMenu.d.ts +1 -1
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +22 -8
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +3 -26
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +10 -3
- package/dist/Markdown/Markdown.d.ts.map +1 -1
- package/dist/Markdown/Markdown.js +19 -3
- package/dist/Typeahead/BaseTypeahead.d.ts +1 -1
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +12 -3
- package/dist/astryx.css +13 -0
- package/dist/hooks/useIsomorphicLayoutEffect.d.ts +2 -2
- package/dist/hooks/useIsomorphicLayoutEffect.d.ts.map +1 -1
- package/dist/theme/syntax/SyntaxTheme.d.ts.map +1 -1
- package/dist/theme/syntax/SyntaxTheme.js +43 -2
- package/dist/theme/syntax/defineSyntaxTheme.d.ts +18 -5
- package/dist/theme/syntax/defineSyntaxTheme.d.ts.map +1 -1
- package/dist/theme/syntax/defineSyntaxTheme.js +38 -9
- package/dist/theme/syntax/index.d.ts +1 -1
- package/dist/theme/syntax/index.d.ts.map +1 -1
- package/dist/theme/syntax/tokens.d.ts +12 -2
- package/dist/theme/syntax/tokens.d.ts.map +1 -1
- package/dist/theme/syntax/tokens.js +25 -4
- package/dist/utils/focusReturn.d.ts +12 -8
- package/dist/utils/focusReturn.d.ts.map +1 -1
- package/dist/utils/focusReturn.js +44 -3
- package/package.json +4 -4
- package/src/BottomSheet/BottomSheet.test.tsx +114 -0
- package/src/BottomSheet/BottomSheet.tsx +12 -2
- package/src/BottomSheet/__tests__/BottomSheetFocusReturn.a11y.browser.spec.ts +96 -0
- package/src/Chat/__tests__/ChatLayoutScrollButtonSurface.a11y.chromium.spec.ts +5 -6
- package/src/CodeBlock/CodeBlock.doc.mjs +7 -0
- package/src/CodeBlock/CodeBlock.spec.md +55 -45
- package/src/CodeBlock/CodeBlock.test.tsx +89 -9
- package/src/CodeBlock/CodeBlock.tsx +205 -25
- package/src/CodeBlock/__tests__/CodeBlockOverflowStop.a11y.chromium.spec.ts +125 -0
- package/src/CodeBlock/__tests__/DiffRowPaint.a11y.chromium.spec.ts +93 -0
- package/src/CodeBlock/tokenizer.test.ts +112 -0
- package/src/CodeBlock/tokenizer.ts +106 -0
- package/src/ContextMenu/ContextMenu.spec.md +1 -1
- package/src/ContextMenu/ContextMenu.test.tsx +36 -3
- package/src/ContextMenu/ContextMenu.tsx +27 -11
- package/src/ContextMenu/__tests__/ContextMenuCursorAnchor.a11y.browser.spec.ts +153 -0
- package/src/Dialog/Dialog.tsx +2 -30
- package/src/Dialog/__tests__/Dialog.a11y.chromium.spec.ts +21 -14
- package/src/DropdownMenu/DropdownMenu.test.tsx +97 -1
- package/src/DropdownMenu/DropdownMenu.tsx +10 -3
- package/src/DropdownMenu/__tests__/DropdownMenuFocusReturn.a11y.browser.spec.ts +73 -0
- package/src/FieldStatus/__tests__/StatusMessage.a11y.chromium.spec.ts +21 -14
- package/src/Markdown/Markdown.spec.md +37 -23
- package/src/Markdown/Markdown.test.tsx +38 -0
- package/src/Markdown/Markdown.tsx +20 -2
- package/src/Markdown/plugins/semanticFence.test.tsx +3 -2
- package/src/NumberInput/__tests__/NumberInput.a11y.chromium.spec.ts +22 -15
- package/src/PowerSearch/PowerSearch.test.tsx +45 -0
- package/src/Switch/__tests__/Switch.a11y.chromium.spec.ts +25 -18
- package/src/TabList/__tests__/Tabs.a11y.chromium.spec.ts +19 -12
- package/src/TextInput/__tests__/TextInput.a11y.chromium.spec.ts +21 -14
- package/src/ToggleButton/__tests__/ToggleButton.a11y.chromium.spec.ts +18 -11
- package/src/Typeahead/BaseTypeahead.test.tsx +99 -1
- package/src/Typeahead/BaseTypeahead.tsx +17 -3
- package/src/theme/syntax/SyntaxTheme.tsx +46 -5
- package/src/theme/syntax/defineSyntaxTheme.diff.test.ts +91 -0
- package/src/theme/syntax/defineSyntaxTheme.ts +75 -15
- package/src/theme/syntax/index.ts +5 -1
- package/src/theme/syntax/tokens.ts +32 -2
- package/src/theme/syntax/useSyntaxTheme.diff.test.tsx +40 -0
- package/src/utils/focusReturn.test.ts +79 -2
- package/src/utils/focusReturn.ts +48 -3
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* @see https://github.com/facebook/astryx/issues/1148
|
|
12
12
|
*/
|
|
13
|
-
import { syntaxTokenDefaults } from "./tokens.js";
|
|
13
|
+
import { syntaxTokenDefaults, DIFF_SYNTAX_TOKEN_NAMES } from "./tokens.js";
|
|
14
14
|
import { devWarn } from "../../utils/devWarning.js";
|
|
15
15
|
|
|
16
16
|
// =============================================================================
|
|
@@ -19,14 +19,25 @@ import { devWarn } from "../../utils/devWarning.js";
|
|
|
19
19
|
|
|
20
20
|
/** Human-readable syntax token name (without CSS custom property prefix). */
|
|
21
21
|
|
|
22
|
+
/** The diff row slots a syntax theme MAY set; unset ones keep their defaults. */
|
|
23
|
+
|
|
22
24
|
/**
|
|
23
25
|
* Token value — either a single string or a [light, dark] tuple.
|
|
24
26
|
* Tuples are converted to CSS light-dark() at theme creation time.
|
|
25
27
|
*/
|
|
26
28
|
|
|
27
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Token map for defineSyntaxTheme input — values can be strings or tuples.
|
|
31
|
+
* The diff row slots are optional: a theme that sets none keeps the defaults
|
|
32
|
+
* (semantic success/error muted tints and the muted surface).
|
|
33
|
+
*/
|
|
28
34
|
|
|
29
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Resolved token map — all values are CSS strings (tuples resolved to
|
|
37
|
+
* light-dark()). The diff row slots are optional here as in the input, so a
|
|
38
|
+
* map written before they existed still type-checks; an unset slot renders
|
|
39
|
+
* its default.
|
|
40
|
+
*/
|
|
30
41
|
|
|
31
42
|
/** Input to defineSyntaxTheme. */
|
|
32
43
|
|
|
@@ -43,6 +54,13 @@ function toCSSProperty(key) {
|
|
|
43
54
|
|
|
44
55
|
/** All valid human-readable token keys, derived from the defaults. */
|
|
45
56
|
export const ALL_SYNTAX_KEYS = Object.keys(syntaxTokenDefaults).map(k => k.replace(CSS_PREFIX, ''));
|
|
57
|
+
const DIFF_KEYS = new Set(DIFF_SYNTAX_TOKEN_NAMES.map(name => name.replace(CSS_PREFIX, '')));
|
|
58
|
+
|
|
59
|
+
/** The marker slots: unset in a theme, they follow that theme's punctuation. */
|
|
60
|
+
const MARKER_KEYS = new Set(['added-marker', 'removed-marker']);
|
|
61
|
+
|
|
62
|
+
/** The keys every syntax theme must set: everything but the diff rows. */
|
|
63
|
+
export const REQUIRED_SYNTAX_KEYS = ALL_SYNTAX_KEYS.filter(key => !DIFF_KEYS.has(key));
|
|
46
64
|
|
|
47
65
|
// =============================================================================
|
|
48
66
|
// Helpers
|
|
@@ -96,15 +114,23 @@ export function resolveSyntaxTokenForMode(value, mode) {
|
|
|
96
114
|
* });
|
|
97
115
|
*/
|
|
98
116
|
export function defineSyntaxTheme(input) {
|
|
99
|
-
const missing =
|
|
117
|
+
const missing = REQUIRED_SYNTAX_KEYS.filter(key => !(key in input.tokens));
|
|
100
118
|
if (missing.length > 0) {
|
|
101
|
-
devWarn('defineSyntaxTheme', `"${input.name}": missing tokens: ${missing.join(', ')}. ` + `All
|
|
119
|
+
devWarn('defineSyntaxTheme', `"${input.name}": missing tokens: ${missing.join(', ')}. ` + `All ${REQUIRED_SYNTAX_KEYS.length} syntax colour tokens are required; ` + `the diff row slots (added, removed, diff-header, added-marker, removed-marker) are optional.`);
|
|
102
120
|
}
|
|
103
121
|
|
|
104
|
-
// Resolve tuples to light-dark() CSS strings
|
|
122
|
+
// Resolve tuples to light-dark() CSS strings. An unset diff row paint keeps
|
|
123
|
+
// its default so every existing theme renders a readable diff unchanged; an
|
|
124
|
+
// unset marker slot takes this theme's own line-number colour (its
|
|
125
|
+
// punctuation token) rather than the page theme's green and red, and an
|
|
126
|
+
// unset header paint is none at all (the header row sits on this theme's
|
|
127
|
+
// own background with punctuation text), so number, glyph, and header
|
|
128
|
+
// text stay legible on this theme's background in every page mode — the
|
|
129
|
+
// glyph's shape carries the meaning where the colour does not.
|
|
105
130
|
const resolved = {};
|
|
106
131
|
for (const key of ALL_SYNTAX_KEYS) {
|
|
107
|
-
|
|
132
|
+
const value = input.tokens[key];
|
|
133
|
+
resolved[key] = value !== undefined ? resolveTokenValue(value) : MARKER_KEYS.has(key) ? 'var(--color-syntax-punctuation)' : key === 'diff-header' ? 'transparent' : syntaxTokenDefaults[toCSSProperty(key)];
|
|
108
134
|
}
|
|
109
135
|
return {
|
|
110
136
|
name: input.name,
|
|
@@ -123,12 +149,15 @@ export function defineSyntaxTheme(input) {
|
|
|
123
149
|
export function syntaxThemeStyle(theme) {
|
|
124
150
|
const vars = {};
|
|
125
151
|
for (const key of ALL_SYNTAX_KEYS) {
|
|
126
|
-
|
|
152
|
+
// An unset diff slot is left to its default rather than written empty.
|
|
153
|
+
const value = theme.tokens[key];
|
|
154
|
+
if (value !== undefined) vars[toCSSProperty(key)] = value;
|
|
127
155
|
}
|
|
128
156
|
return vars;
|
|
129
157
|
}
|
|
130
158
|
|
|
131
159
|
/** Convert a syntax theme to CSS declarations (no selector wrapper). */
|
|
132
160
|
export function syntaxThemeToCSS(theme) {
|
|
133
|
-
|
|
161
|
+
// An unset diff slot is left to its default rather than written `undefined`.
|
|
162
|
+
return ALL_SYNTAX_KEYS.filter(key => theme.tokens[key] !== undefined).map(key => toCSSProperty(key) + ': ' + theme.tokens[key] + ';').join('\n ');
|
|
134
163
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export { syntaxTokenDefaults } from './tokens';
|
|
12
12
|
export type { SyntaxTokenName } from './tokens';
|
|
13
|
-
export { defineSyntaxTheme, syntaxThemeStyle, syntaxThemeToCSS } from './defineSyntaxTheme';
|
|
13
|
+
export { defineSyntaxTheme, syntaxThemeStyle, syntaxThemeToCSS, } from './defineSyntaxTheme';
|
|
14
14
|
export type { SyntaxThemeDefinition, SyntaxThemeInput, SyntaxThemeTokenKey, SyntaxThemeTokenMap, SyntaxThemeTokenInput, SyntaxTokenValue, } from './defineSyntaxTheme';
|
|
15
15
|
export { SyntaxTheme, useSyntaxTheme } from './SyntaxTheme';
|
|
16
16
|
export type { UseSyntaxThemeReturn } from './SyntaxTheme';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/theme/syntax/index.ts"],"names":[],"mappings":"AAEA;;;;;;;;;GASG;AAEH,OAAO,EAAC,mBAAmB,EAAC,MAAM,UAAU,CAAC;AAC7C,YAAY,EAAC,eAAe,EAAC,MAAM,UAAU,CAAC;AAE9C,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/theme/syntax/index.ts"],"names":[],"mappings":"AAEA;;;;;;;;;GASG;AAEH,OAAO,EAAC,mBAAmB,EAAC,MAAM,UAAU,CAAC;AAC7C,YAAY,EAAC,eAAe,EAAC,MAAM,UAAU,CAAC;AAE9C,OAAO,EACL,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,qBAAqB,CAAC;AAC7B,YAAY,EACV,qBAAqB,EACrB,gBAAgB,EAChB,mBAAmB,EACnB,mBAAmB,EACnB,qBAAqB,EACrB,gBAAgB,GACjB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAAC,WAAW,EAAE,cAAc,EAAC,MAAM,eAAe,CAAC;AAC1D,YAAY,EAAC,oBAAoB,EAAC,MAAM,eAAe,CAAC;AAGxD,OAAO,EACL,UAAU,EACV,OAAO,EACP,OAAO,EACP,IAAI,EACJ,UAAU,EACV,eAAe,EACf,UAAU,EACV,WAAW,EACX,cAAc,EACd,QAAQ,EACR,eAAe,EACf,eAAe,EACf,iBAAiB,EACjB,kBAAkB,EAClB,gBAAgB,GACjB,MAAM,WAAW,CAAC"}
|
|
@@ -11,8 +11,11 @@
|
|
|
11
11
|
* colors automatically adapt to any theme's color system. Themes can also
|
|
12
12
|
* set an explicit syntax theme via defineTheme({ syntax: dracula }).
|
|
13
13
|
*
|
|
14
|
-
* 14
|
|
15
|
-
*
|
|
14
|
+
* 14 token colours and the code background, validated against 11 community
|
|
15
|
+
* code themes — all of them map cleanly to those slots — plus five diff row
|
|
16
|
+
* slots (added, removed, header paints; added and removed markers) that default
|
|
17
|
+
* to semantic tokens, so a syntax theme that does not set them keeps a
|
|
18
|
+
* readable diff.
|
|
16
19
|
*
|
|
17
20
|
* @see https://github.com/facebook/astryx/issues/1148
|
|
18
21
|
*/
|
|
@@ -31,6 +34,13 @@ export declare const syntaxTokenDefaults: {
|
|
|
31
34
|
readonly '--color-syntax-property': "var(--color-text-cyan)";
|
|
32
35
|
readonly '--color-syntax-punctuation': "var(--color-text-secondary)";
|
|
33
36
|
readonly '--color-syntax-background': "var(--color-background-muted)";
|
|
37
|
+
readonly '--color-syntax-added': "color-mix(in srgb, var(--color-success-muted) 25%, transparent)";
|
|
38
|
+
readonly '--color-syntax-removed': "color-mix(in srgb, var(--color-error-muted) 25%, transparent)";
|
|
39
|
+
readonly '--color-syntax-diff-header': "var(--color-background-muted)";
|
|
40
|
+
readonly '--color-syntax-added-marker': "var(--color-text-green)";
|
|
41
|
+
readonly '--color-syntax-removed-marker': "var(--color-text-red)";
|
|
34
42
|
};
|
|
35
43
|
export type SyntaxTokenName = keyof typeof syntaxTokenDefaults;
|
|
44
|
+
/** The five diff row slots: optional in a syntax theme, defaulted when unset. */
|
|
45
|
+
export declare const DIFF_SYNTAX_TOKEN_NAMES: readonly ["--color-syntax-added", "--color-syntax-removed", "--color-syntax-diff-header", "--color-syntax-added-marker", "--color-syntax-removed-marker"];
|
|
36
46
|
//# sourceMappingURL=tokens.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../../src/theme/syntax/tokens.ts"],"names":[],"mappings":"AAEA
|
|
1
|
+
{"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../../src/theme/syntax/tokens.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;CAkDtB,CAAC;AAEX,MAAM,MAAM,eAAe,GAAG,MAAM,OAAO,mBAAmB,CAAC;AAE/D,iFAAiF;AACjF,eAAO,MAAM,uBAAuB,2JAMe,CAAC"}
|
|
@@ -13,8 +13,11 @@
|
|
|
13
13
|
* colors automatically adapt to any theme's color system. Themes can also
|
|
14
14
|
* set an explicit syntax theme via defineTheme({ syntax: dracula }).
|
|
15
15
|
*
|
|
16
|
-
* 14
|
|
17
|
-
*
|
|
16
|
+
* 14 token colours and the code background, validated against 11 community
|
|
17
|
+
* code themes — all of them map cleanly to those slots — plus five diff row
|
|
18
|
+
* slots (added, removed, header paints; added and removed markers) that default
|
|
19
|
+
* to semantic tokens, so a syntax theme that does not set them keeps a
|
|
20
|
+
* readable diff.
|
|
18
21
|
*
|
|
19
22
|
* @see https://github.com/facebook/astryx/issues/1148
|
|
20
23
|
*/
|
|
@@ -50,5 +53,23 @@ export const syntaxTokenDefaults = {
|
|
|
50
53
|
// text — see #5386.
|
|
51
54
|
'--color-syntax-punctuation': 'var(--color-text-secondary)',
|
|
52
55
|
// background -> muted surface
|
|
53
|
-
'--color-syntax-background': 'var(--color-background-muted)'
|
|
54
|
-
|
|
56
|
+
'--color-syntax-background': 'var(--color-background-muted)',
|
|
57
|
+
// Diff rows (CodeBlock isDiff): whole-row tints behind unchanged syntax
|
|
58
|
+
// colours. Added/removed derive from the semantic success/error muted
|
|
59
|
+
// tokens at 25% strength: at full strength the secondary-grey syntax
|
|
60
|
+
// tokens (operator, punctuation, comment) fall under 4.5:1 on the tints
|
|
61
|
+
// in light; at 25% every default token clears it on both schemes with
|
|
62
|
+
// margin while the tint still reads against the plain row. Header rows
|
|
63
|
+
// (@@, and ---/+++ before the first hunk) sit on the muted surface.
|
|
64
|
+
'--color-syntax-added': 'color-mix(in srgb, var(--color-success-muted) 25%, transparent)',
|
|
65
|
+
'--color-syntax-removed': 'color-mix(in srgb, var(--color-error-muted) 25%, transparent)',
|
|
66
|
+
'--color-syntax-diff-header': 'var(--color-background-muted)',
|
|
67
|
+
// The margin of a changed row — its line number and +/- glyph — in the
|
|
68
|
+
// default theme's tinted-text tokens, made for tinted surfaces; a defined
|
|
69
|
+
// syntax theme that leaves them unset follows its own punctuation colour
|
|
70
|
+
// (see defineSyntaxTheme). Code text keeps the syntax theme's colours.
|
|
71
|
+
'--color-syntax-added-marker': 'var(--color-text-green)',
|
|
72
|
+
'--color-syntax-removed-marker': 'var(--color-text-red)'
|
|
73
|
+
};
|
|
74
|
+
/** The five diff row slots: optional in a syntax theme, defaulted when unset. */
|
|
75
|
+
export const DIFF_SYNTAX_TOKEN_NAMES = ['--color-syntax-added', '--color-syntax-removed', '--color-syntax-diff-header', '--color-syntax-added-marker', '--color-syntax-removed-marker'];
|
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file focusReturn.ts
|
|
3
|
-
* @input The document's current active element.
|
|
4
|
-
* @output Whether focus is currently "detached" — on nothing in particular.
|
|
5
|
-
* @position Utility; used by overlay triggers (e.g. DateInput, DateTimeInput)
|
|
6
|
-
* that return focus to their control when the overlay closes, but only when
|
|
7
|
-
* the dismiss did not already move focus somewhere the user chose.
|
|
8
|
-
*/
|
|
9
1
|
/**
|
|
10
2
|
* True when focus rests on nothing in particular — no active element, or the
|
|
11
3
|
* document body / root element.
|
|
@@ -21,4 +13,16 @@
|
|
|
21
13
|
* when the trigger should reclaim it.
|
|
22
14
|
*/
|
|
23
15
|
export declare function isFocusDetached(doc?: Document): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Move focus programmatically so its ring follows the last input
|
|
18
|
+
* (`architecture:interaction-modality` INV1). After pointer input the browser's
|
|
19
|
+
* focus option keeps the element from matching `:focus-visible`, which is the
|
|
20
|
+
* one way to reach an element the overlay does not render: the consumer's
|
|
21
|
+
* autofocus target, the control that opened a Dialog, or a menu trigger the
|
|
22
|
+
* caller renders. Pass `options` (for example `preventScroll`) as to `focus()`.
|
|
23
|
+
*
|
|
24
|
+
* Make sure modality tracking is active (`useInteractionModalityTracking()`)
|
|
25
|
+
* in the component that calls this.
|
|
26
|
+
*/
|
|
27
|
+
export declare function focusForLastInput(element: HTMLElement, options?: FocusOptions): void;
|
|
24
28
|
//# sourceMappingURL=focusReturn.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"focusReturn.d.ts","sourceRoot":"","sources":["../../src/utils/focusReturn.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"focusReturn.d.ts","sourceRoot":"","sources":["../../src/utils/focusReturn.ts"],"names":[],"mappings":"AAgBA;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,GAAG,GAAE,QAAmB,GAAG,OAAO,CAKjE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,WAAW,EACpB,OAAO,CAAC,EAAE,YAAY,GACrB,IAAI,CAwBN"}
|
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @file focusReturn.ts
|
|
5
|
-
* @input The document's current active element
|
|
6
|
-
*
|
|
5
|
+
* @input The document's current active element and the shared interaction
|
|
6
|
+
* modality.
|
|
7
|
+
* @output Whether focus is currently "detached" — on nothing in particular —
|
|
8
|
+
* and a focus move whose ring follows the last input.
|
|
7
9
|
* @position Utility; used by overlay triggers (e.g. DateInput, DateTimeInput)
|
|
8
10
|
* that return focus to their control when the overlay closes, but only when
|
|
9
|
-
* the dismiss did not already move focus somewhere the user chose
|
|
11
|
+
* the dismiss did not already move focus somewhere the user chose, and by
|
|
12
|
+
* overlays (Dialog, DropdownMenu) that move focus programmatically.
|
|
10
13
|
*/
|
|
11
14
|
|
|
15
|
+
import { getInteractionModality } from "./interactionModality.js";
|
|
16
|
+
|
|
12
17
|
/**
|
|
13
18
|
* True when focus rests on nothing in particular — no active element, or the
|
|
14
19
|
* document body / root element.
|
|
@@ -26,4 +31,40 @@
|
|
|
26
31
|
export function isFocusDetached(doc = document) {
|
|
27
32
|
const active = doc.activeElement;
|
|
28
33
|
return active == null || active === doc.body || active === doc.documentElement;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Move focus programmatically so its ring follows the last input
|
|
38
|
+
* (`architecture:interaction-modality` INV1). After pointer input the browser's
|
|
39
|
+
* focus option keeps the element from matching `:focus-visible`, which is the
|
|
40
|
+
* one way to reach an element the overlay does not render: the consumer's
|
|
41
|
+
* autofocus target, the control that opened a Dialog, or a menu trigger the
|
|
42
|
+
* caller renders. Pass `options` (for example `preventScroll`) as to `focus()`.
|
|
43
|
+
*
|
|
44
|
+
* Make sure modality tracking is active (`useInteractionModalityTracking()`)
|
|
45
|
+
* in the component that calls this.
|
|
46
|
+
*/
|
|
47
|
+
export function focusForLastInput(element, options) {
|
|
48
|
+
// A text-entry field paints its own focus treatment to show where typing
|
|
49
|
+
// goes, and an extra blur there can run validation or mark it touched.
|
|
50
|
+
const isTextEntry = element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement || element.isContentEditable;
|
|
51
|
+
if (isTextEntry || getInteractionModality() !== 'pointer') {
|
|
52
|
+
if (options === undefined) {
|
|
53
|
+
element.focus();
|
|
54
|
+
} else {
|
|
55
|
+
element.focus(options);
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// Focusing the element that already has focus does nothing, so a ring the
|
|
60
|
+
// browser already drew would stay. WebKit draws one when its own focus
|
|
61
|
+
// restoration (a dialog's close, a popover's hide) moves focus itself, so
|
|
62
|
+
// clear that focus first.
|
|
63
|
+
if (element.matches(':focus-visible')) {
|
|
64
|
+
element.blur();
|
|
65
|
+
}
|
|
66
|
+
element.focus({
|
|
67
|
+
...options,
|
|
68
|
+
focusVisible: false
|
|
69
|
+
});
|
|
29
70
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/core",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1-canary.0365e23",
|
|
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",
|
|
@@ -787,6 +787,8 @@
|
|
|
787
787
|
"react-dom": ">=19.0.0"
|
|
788
788
|
},
|
|
789
789
|
"devDependencies": {
|
|
790
|
+
"@astryxdesign/a11y-spec": "0.7.1-canary.0365e23",
|
|
791
|
+
"@astryxdesign/cli": "0.7.1-canary.0365e23",
|
|
790
792
|
"@babel/cli": "^7.29.7",
|
|
791
793
|
"@babel/core": "^7.29.7",
|
|
792
794
|
"@babel/preset-react": "^7.29.7",
|
|
@@ -797,9 +799,7 @@
|
|
|
797
799
|
"@testing-library/jest-dom": "^6.6.0",
|
|
798
800
|
"@testing-library/react": "^16.3.2",
|
|
799
801
|
"@types/babel__core": "^7.20.5",
|
|
800
|
-
"tailwindcss": "^4.3.2"
|
|
801
|
-
"@astryxdesign/a11y-spec": "0.0.0",
|
|
802
|
-
"@astryxdesign/cli": "0.7.0"
|
|
802
|
+
"tailwindcss": "^4.3.2"
|
|
803
803
|
},
|
|
804
804
|
"dependencies": {
|
|
805
805
|
"intl-messageformat": "^11.2.9"
|
|
@@ -14,6 +14,7 @@ import {act, render, screen, fireEvent} from '@testing-library/react';
|
|
|
14
14
|
import {createRef, useState} from 'react';
|
|
15
15
|
import {BottomSheet} from './BottomSheet';
|
|
16
16
|
import {BottomSheetSwitcher} from './BottomSheetSwitcher';
|
|
17
|
+
import {__resetInteractionModalityForTest} from '../utils/interactionModality';
|
|
17
18
|
|
|
18
19
|
// A sheet has no stops unless its host asks for them, so the tests that
|
|
19
20
|
// exercise detents pass their own. These three — a 14% peek, a half-height
|
|
@@ -2351,6 +2352,119 @@ describe('BottomSheet', () => {
|
|
|
2351
2352
|
|
|
2352
2353
|
expect(document.activeElement).toBe(finalFocusRef.current);
|
|
2353
2354
|
});
|
|
2355
|
+
|
|
2356
|
+
// The returned focus shows the focus-visible ring only after keyboard
|
|
2357
|
+
// input (`architecture:interaction-modality` INV1). A plain focus() leaves
|
|
2358
|
+
// that to the browser, which rings it after a pointer close: WebKit after
|
|
2359
|
+
// a tap, and Chromium when the sheet was opened from the keyboard.
|
|
2360
|
+
describe('ring on the returned focus', () => {
|
|
2361
|
+
beforeEach(() => {
|
|
2362
|
+
__resetInteractionModalityForTest();
|
|
2363
|
+
});
|
|
2364
|
+
|
|
2365
|
+
afterEach(() => {
|
|
2366
|
+
vi.restoreAllMocks();
|
|
2367
|
+
});
|
|
2368
|
+
|
|
2369
|
+
function openThenClose(closeWith: 'pointer' | 'keyboard') {
|
|
2370
|
+
render(<Harness />);
|
|
2371
|
+
const opener = screen.getByRole('button', {name: 'Open sheet'});
|
|
2372
|
+
opener.focus();
|
|
2373
|
+
fireEvent.click(opener);
|
|
2374
|
+
const done = screen.getByRole('button', {name: 'Done'});
|
|
2375
|
+
if (closeWith === 'pointer') {
|
|
2376
|
+
fireEvent.pointerDown(done);
|
|
2377
|
+
} else {
|
|
2378
|
+
fireEvent.keyDown(done, {key: 'Enter'});
|
|
2379
|
+
}
|
|
2380
|
+
fireEvent.click(done);
|
|
2381
|
+
return opener;
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
it('asks the browser for no ring after a pointer close', () => {
|
|
2385
|
+
const opener = openThenClose('pointer');
|
|
2386
|
+
const focus = vi.spyOn(opener, 'focus');
|
|
2387
|
+
finishSheetExit();
|
|
2388
|
+
|
|
2389
|
+
expect(opener).toHaveFocus();
|
|
2390
|
+
expect(focus.mock.calls).toEqual([[{focusVisible: false}]]);
|
|
2391
|
+
});
|
|
2392
|
+
|
|
2393
|
+
it('leaves the ring to the browser after a keyboard close', () => {
|
|
2394
|
+
const opener = openThenClose('keyboard');
|
|
2395
|
+
const focus = vi.spyOn(opener, 'focus');
|
|
2396
|
+
finishSheetExit();
|
|
2397
|
+
|
|
2398
|
+
expect(opener).toHaveFocus();
|
|
2399
|
+
expect(focus.mock.calls).toEqual([[]]);
|
|
2400
|
+
});
|
|
2401
|
+
|
|
2402
|
+
it('clears a ring the native close already drew before refocusing', () => {
|
|
2403
|
+
const opener = openThenClose('pointer');
|
|
2404
|
+
// The browser's own dialog close restores focus to the opener, with a
|
|
2405
|
+
// ring, before the sheet moves focus, and focusing the focused element
|
|
2406
|
+
// changes nothing.
|
|
2407
|
+
HTMLDialogElement.prototype.close = vi.fn(function (
|
|
2408
|
+
this: HTMLDialogElement,
|
|
2409
|
+
) {
|
|
2410
|
+
this.removeAttribute('open');
|
|
2411
|
+
opener.focus();
|
|
2412
|
+
});
|
|
2413
|
+
const matches = HTMLElement.prototype.matches;
|
|
2414
|
+
vi.spyOn(HTMLElement.prototype, 'matches').mockImplementation(function (
|
|
2415
|
+
this: HTMLElement,
|
|
2416
|
+
selector: string,
|
|
2417
|
+
) {
|
|
2418
|
+
return selector === ':focus-visible'
|
|
2419
|
+
? this === opener
|
|
2420
|
+
: matches.call(this, selector);
|
|
2421
|
+
});
|
|
2422
|
+
const blur = vi.spyOn(opener, 'blur');
|
|
2423
|
+
const focus = vi.spyOn(opener, 'focus');
|
|
2424
|
+
finishSheetExit();
|
|
2425
|
+
|
|
2426
|
+
expect(blur).toHaveBeenCalledTimes(1);
|
|
2427
|
+
expect(focus.mock.calls.at(-1)).toEqual([{focusVisible: false}]);
|
|
2428
|
+
expect(blur.mock.invocationCallOrder[0]).toBeLessThan(
|
|
2429
|
+
focus.mock.invocationCallOrder.at(-1) ?? 0,
|
|
2430
|
+
);
|
|
2431
|
+
expect(opener).toHaveFocus();
|
|
2432
|
+
});
|
|
2433
|
+
|
|
2434
|
+
it('asks for no ring on an explicit final focus target too', () => {
|
|
2435
|
+
const finalFocusRef = createRef<HTMLButtonElement>();
|
|
2436
|
+
function ExplicitFocusHarness() {
|
|
2437
|
+
const [isOpen, setIsOpen] = useState(true);
|
|
2438
|
+
return (
|
|
2439
|
+
<>
|
|
2440
|
+
<button ref={finalFocusRef} type="button">
|
|
2441
|
+
Adaptive trigger
|
|
2442
|
+
</button>
|
|
2443
|
+
<BottomSheet
|
|
2444
|
+
isOpen={isOpen}
|
|
2445
|
+
onOpenChange={setIsOpen}
|
|
2446
|
+
finalFocusRef={finalFocusRef}
|
|
2447
|
+
label="Filters">
|
|
2448
|
+
<button type="button" onClick={() => setIsOpen(false)}>
|
|
2449
|
+
Done
|
|
2450
|
+
</button>
|
|
2451
|
+
</BottomSheet>
|
|
2452
|
+
</>
|
|
2453
|
+
);
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
render(<ExplicitFocusHarness />);
|
|
2457
|
+
const done = screen.getByRole('button', {name: 'Done'});
|
|
2458
|
+
fireEvent.pointerDown(done);
|
|
2459
|
+
fireEvent.click(done);
|
|
2460
|
+
const target = finalFocusRef.current!;
|
|
2461
|
+
const focus = vi.spyOn(target, 'focus');
|
|
2462
|
+
finishSheetExit();
|
|
2463
|
+
|
|
2464
|
+
expect(target).toHaveFocus();
|
|
2465
|
+
expect(focus.mock.calls).toEqual([[{focusVisible: false}]]);
|
|
2466
|
+
});
|
|
2467
|
+
});
|
|
2354
2468
|
});
|
|
2355
2469
|
|
|
2356
2470
|
describe('initial focus', () => {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file BottomSheet.tsx
|
|
7
|
-
* @input Uses React, StyleX, core hooks, shared layer dismissal, named BottomSheetPanel, BottomSheetSwitcherContext
|
|
7
|
+
* @input Uses React, StyleX, core hooks, shared layer dismissal, the shared interaction modality, named BottomSheetPanel, BottomSheetSwitcherContext
|
|
8
8
|
* @output Exports BottomSheet component and BottomSheetProps
|
|
9
9
|
* @position Public BottomSheet router plus private standalone/switcher hosts
|
|
10
10
|
*
|
|
@@ -46,6 +46,8 @@ import {colorVars, durationVars, easeVars} from '../theme/tokens.stylex';
|
|
|
46
46
|
import {useDevWarning, useScrollLock} from '../hooks';
|
|
47
47
|
import {LayerDepthProvider} from '../Layer/LayerDepthContext';
|
|
48
48
|
import {useLayerDismissal} from '../Layer/useLayerDismissal';
|
|
49
|
+
import {focusForLastInput} from '../utils/focusReturn';
|
|
50
|
+
import {useInteractionModalityTracking} from '../utils/interactionModality';
|
|
49
51
|
import {
|
|
50
52
|
BottomSheetPanel,
|
|
51
53
|
type BottomSheetPanelMotion,
|
|
@@ -320,12 +322,20 @@ function StandaloneBottomSheet({
|
|
|
320
322
|
dialog.close();
|
|
321
323
|
}
|
|
322
324
|
setIsPresented(false);
|
|
323
|
-
|
|
325
|
+
// The returned focus shows the focus-visible ring only after keyboard
|
|
326
|
+
// input (`architecture:interaction-modality` INV1). The native close
|
|
327
|
+
// may already have restored focus to the target with a ring; the helper
|
|
328
|
+
// clears it first.
|
|
329
|
+
const returnTarget = finalFocusRef?.current ?? triggerRef.current;
|
|
330
|
+
if (returnTarget != null) {
|
|
331
|
+
focusForLastInput(returnTarget);
|
|
332
|
+
}
|
|
324
333
|
triggerRef.current = null;
|
|
325
334
|
},
|
|
326
335
|
[finalFocusRef, isOpen],
|
|
327
336
|
);
|
|
328
337
|
|
|
338
|
+
useInteractionModalityTracking();
|
|
329
339
|
useScrollLock(shouldPresent && hasScrim);
|
|
330
340
|
useDevWarning(
|
|
331
341
|
'BottomSheet',
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file BottomSheetFocusReturn.a11y.browser.spec.ts
|
|
5
|
+
* @input The `Core/DropdownMenu` action-sheet story with a trigger the caller
|
|
6
|
+
* renders, in a built Storybook, and real Chromium/WebKit pointer and
|
|
7
|
+
* keyboard input.
|
|
8
|
+
* @output Browser evidence that the focus a BottomSheet returns when it closes
|
|
9
|
+
* follows the last input (docs/architecture/interaction-modality.md INV1):
|
|
10
|
+
* no ring after a pointer close, the ring after a keyboard close.
|
|
11
|
+
* @position Cross-browser proof; jsdom only proves which focus call the sheet
|
|
12
|
+
* makes. The menu's sheet returns focus through BottomSheet's
|
|
13
|
+
* `finalFocusRef`, and a control the caller renders carries no style of its
|
|
14
|
+
* own that would hide a ring, so the ring shown is the browser's answer.
|
|
15
|
+
* Chromium rings it when a keyboard-opened sheet is closed by a pointer;
|
|
16
|
+
* WebKit also rings it after a tap open and a tap close.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {expect, test, type Locator, type Page} from '@playwright/test';
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_STORYBOOK_DIR,
|
|
22
|
+
serveStorybook,
|
|
23
|
+
type StaticServer,
|
|
24
|
+
} from '@astryxdesign/a11y-spec/storybook';
|
|
25
|
+
|
|
26
|
+
let storybook: StaticServer;
|
|
27
|
+
test.beforeAll(async () => {
|
|
28
|
+
storybook = await serveStorybook(
|
|
29
|
+
process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
test.afterAll(async () => {
|
|
33
|
+
await storybook?.close();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
async function mount(page: Page): Promise<Locator> {
|
|
37
|
+
await page.goto(
|
|
38
|
+
`${storybook.origin}/iframe.html?id=core-dropdownmenu--action-sheet-custom-trigger&viewMode=story`,
|
|
39
|
+
);
|
|
40
|
+
const trigger = page.getByRole('button', {
|
|
41
|
+
name: 'Project actions',
|
|
42
|
+
exact: true,
|
|
43
|
+
});
|
|
44
|
+
await expect(trigger).toBeVisible();
|
|
45
|
+
return trigger;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function openFromKeyboard(page: Page, trigger: Locator): Promise<void> {
|
|
49
|
+
await trigger.focus();
|
|
50
|
+
await page.keyboard.press('Enter');
|
|
51
|
+
await expect(page.getByRole('dialog')).toBeVisible();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function row(page: Page): Locator {
|
|
55
|
+
return page.getByRole('dialog').getByRole('button', {name: /Edit project/});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function expectReturnedFocus(
|
|
59
|
+
page: Page,
|
|
60
|
+
trigger: Locator,
|
|
61
|
+
ring: boolean,
|
|
62
|
+
): Promise<void> {
|
|
63
|
+
await expect(page.getByRole('dialog')).toBeHidden();
|
|
64
|
+
await expect(trigger).toBeFocused();
|
|
65
|
+
expect(
|
|
66
|
+
await trigger.evaluate(element => element.matches(':focus-visible')),
|
|
67
|
+
).toBe(ring);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test('a keyboard-opened sheet closed by a pointer pick returns focus without a ring', async ({
|
|
71
|
+
page,
|
|
72
|
+
}) => {
|
|
73
|
+
const trigger = await mount(page);
|
|
74
|
+
await openFromKeyboard(page, trigger);
|
|
75
|
+
await row(page).click();
|
|
76
|
+
await expectReturnedFocus(page, trigger, false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a pointer open and pointer pick return focus without a ring', async ({
|
|
80
|
+
page,
|
|
81
|
+
}) => {
|
|
82
|
+
const trigger = await mount(page);
|
|
83
|
+
await trigger.click();
|
|
84
|
+
await expect(page.getByRole('dialog')).toBeVisible();
|
|
85
|
+
await row(page).click();
|
|
86
|
+
await expectReturnedFocus(page, trigger, false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a keyboard-opened sheet closed by Escape keeps the ring', async ({
|
|
90
|
+
page,
|
|
91
|
+
}) => {
|
|
92
|
+
const trigger = await mount(page);
|
|
93
|
+
await openFromKeyboard(page, trigger);
|
|
94
|
+
await page.keyboard.press('Escape');
|
|
95
|
+
await expectReturnedFocus(page, trigger, true);
|
|
96
|
+
});
|
|
@@ -15,12 +15,11 @@
|
|
|
15
15
|
* output—not an event wrapper, speculative internal structure, or a node
|
|
16
16
|
* created only for layout plumbing."
|
|
17
17
|
*
|
|
18
|
-
* This is not observable in the unit lane
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* the documented target change the pill?
|
|
18
|
+
* This is not observable in the unit lane: jsdom resolves no cascade, so it
|
|
19
|
+
* cannot say which element paints. An override can land on the element carrying
|
|
20
|
+
* the class while that element is not the one a reader sees. Only a real engine
|
|
21
|
+
* can be asked the question that matters: does styling the documented target
|
|
22
|
+
* change the pill?
|
|
24
23
|
*
|
|
25
24
|
* The pill is identified structurally, as the button's parent element, so every
|
|
26
25
|
* measurement below is independent of where the class currently sits.
|
|
@@ -56,6 +56,13 @@ export const docs = {
|
|
|
56
56
|
type: 'number[]',
|
|
57
57
|
description: '1-indexed line numbers to highlight.',
|
|
58
58
|
},
|
|
59
|
+
{
|
|
60
|
+
name: 'isDiff',
|
|
61
|
+
type: 'boolean',
|
|
62
|
+
default: 'false',
|
|
63
|
+
description:
|
|
64
|
+
'Render the code as a unified diff over `language`: lines starting with + are added, - removed, @@ is a hunk header (a `--- `/`+++ ` file-header pair directly before one is a header too), anything else is context. The language label reads `diff` or `<language> diff`. The glyph sits in its own column and the rest of each line keeps syntax highlighting; copy returns the exact source. Markdown ```diff and ```diff-<lang> fences set this.',
|
|
65
|
+
},
|
|
59
66
|
{
|
|
60
67
|
name: 'hasCopyButton',
|
|
61
68
|
type: 'boolean',
|