@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
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
* @see https://github.com/facebook/astryx/issues/1148
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
syntaxTokenDefaults,
|
|
16
|
+
DIFF_SYNTAX_TOKEN_NAMES,
|
|
17
|
+
type SyntaxTokenName,
|
|
18
|
+
} from './tokens';
|
|
15
19
|
import {devWarn} from '../../utils/devWarning';
|
|
16
20
|
|
|
17
21
|
// =============================================================================
|
|
@@ -33,7 +37,12 @@ export type SyntaxThemeTokenKey =
|
|
|
33
37
|
| 'attribute'
|
|
34
38
|
| 'property'
|
|
35
39
|
| 'punctuation'
|
|
36
|
-
| 'background'
|
|
40
|
+
| 'background'
|
|
41
|
+
| DiffSyntaxThemeTokenKey;
|
|
42
|
+
|
|
43
|
+
/** The diff row slots a syntax theme MAY set; unset ones keep their defaults. */
|
|
44
|
+
export type DiffSyntaxThemeTokenKey =
|
|
45
|
+
'added' | 'removed' | 'diff-header' | 'added-marker' | 'removed-marker';
|
|
37
46
|
|
|
38
47
|
/**
|
|
39
48
|
* Token value — either a single string or a [light, dark] tuple.
|
|
@@ -41,14 +50,28 @@ export type SyntaxThemeTokenKey =
|
|
|
41
50
|
*/
|
|
42
51
|
export type SyntaxTokenValue = string | [light: string, dark: string];
|
|
43
52
|
|
|
44
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Token map for defineSyntaxTheme input — values can be strings or tuples.
|
|
55
|
+
* The diff row slots are optional: a theme that sets none keeps the defaults
|
|
56
|
+
* (semantic success/error muted tints and the muted surface).
|
|
57
|
+
*/
|
|
45
58
|
export type SyntaxThemeTokenInput = Record<
|
|
46
|
-
SyntaxThemeTokenKey,
|
|
59
|
+
Exclude<SyntaxThemeTokenKey, DiffSyntaxThemeTokenKey>,
|
|
47
60
|
SyntaxTokenValue
|
|
48
|
-
|
|
61
|
+
> &
|
|
62
|
+
Partial<Record<DiffSyntaxThemeTokenKey, SyntaxTokenValue>>;
|
|
49
63
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Resolved token map — all values are CSS strings (tuples resolved to
|
|
66
|
+
* light-dark()). The diff row slots are optional here as in the input, so a
|
|
67
|
+
* map written before they existed still type-checks; an unset slot renders
|
|
68
|
+
* its default.
|
|
69
|
+
*/
|
|
70
|
+
export type SyntaxThemeTokenMap = Record<
|
|
71
|
+
Exclude<SyntaxThemeTokenKey, DiffSyntaxThemeTokenKey>,
|
|
72
|
+
string
|
|
73
|
+
> &
|
|
74
|
+
Partial<Record<DiffSyntaxThemeTokenKey, string>>;
|
|
52
75
|
|
|
53
76
|
/** Input to defineSyntaxTheme. */
|
|
54
77
|
export interface SyntaxThemeInput {
|
|
@@ -81,6 +104,22 @@ export const ALL_SYNTAX_KEYS: SyntaxThemeTokenKey[] = Object.keys(
|
|
|
81
104
|
syntaxTokenDefaults,
|
|
82
105
|
).map(k => k.replace(CSS_PREFIX, '') as SyntaxThemeTokenKey);
|
|
83
106
|
|
|
107
|
+
const DIFF_KEYS = new Set<SyntaxThemeTokenKey>(
|
|
108
|
+
DIFF_SYNTAX_TOKEN_NAMES.map(
|
|
109
|
+
name => name.replace(CSS_PREFIX, '') as SyntaxThemeTokenKey,
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
/** The marker slots: unset in a theme, they follow that theme's punctuation. */
|
|
114
|
+
const MARKER_KEYS = new Set<SyntaxThemeTokenKey>([
|
|
115
|
+
'added-marker',
|
|
116
|
+
'removed-marker',
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
/** The keys every syntax theme must set: everything but the diff rows. */
|
|
120
|
+
export const REQUIRED_SYNTAX_KEYS: SyntaxThemeTokenKey[] =
|
|
121
|
+
ALL_SYNTAX_KEYS.filter(key => !DIFF_KEYS.has(key));
|
|
122
|
+
|
|
84
123
|
// =============================================================================
|
|
85
124
|
// Helpers
|
|
86
125
|
// =============================================================================
|
|
@@ -138,19 +177,37 @@ export function resolveSyntaxTokenForMode(
|
|
|
138
177
|
export function defineSyntaxTheme(
|
|
139
178
|
input: SyntaxThemeInput,
|
|
140
179
|
): SyntaxThemeDefinition {
|
|
141
|
-
const missing =
|
|
180
|
+
const missing = REQUIRED_SYNTAX_KEYS.filter(key => !(key in input.tokens));
|
|
142
181
|
if (missing.length > 0) {
|
|
143
182
|
devWarn(
|
|
144
183
|
'defineSyntaxTheme',
|
|
145
184
|
`"${input.name}": missing tokens: ${missing.join(', ')}. ` +
|
|
146
|
-
`All
|
|
185
|
+
`All ${REQUIRED_SYNTAX_KEYS.length} syntax colour tokens are required; ` +
|
|
186
|
+
`the diff row slots (added, removed, diff-header, added-marker, removed-marker) are optional.`,
|
|
147
187
|
);
|
|
148
188
|
}
|
|
149
189
|
|
|
150
|
-
// Resolve tuples to light-dark() CSS strings
|
|
190
|
+
// Resolve tuples to light-dark() CSS strings. An unset diff row paint keeps
|
|
191
|
+
// its default so every existing theme renders a readable diff unchanged; an
|
|
192
|
+
// unset marker slot takes this theme's own line-number colour (its
|
|
193
|
+
// punctuation token) rather than the page theme's green and red, and an
|
|
194
|
+
// unset header paint is none at all (the header row sits on this theme's
|
|
195
|
+
// own background with punctuation text), so number, glyph, and header
|
|
196
|
+
// text stay legible on this theme's background in every page mode — the
|
|
197
|
+
// glyph's shape carries the meaning where the colour does not.
|
|
151
198
|
const resolved: Partial<SyntaxThemeTokenMap> = {};
|
|
152
199
|
for (const key of ALL_SYNTAX_KEYS) {
|
|
153
|
-
|
|
200
|
+
const value = (
|
|
201
|
+
input.tokens as Partial<Record<SyntaxThemeTokenKey, SyntaxTokenValue>>
|
|
202
|
+
)[key];
|
|
203
|
+
resolved[key] =
|
|
204
|
+
value !== undefined
|
|
205
|
+
? resolveTokenValue(value)
|
|
206
|
+
: MARKER_KEYS.has(key)
|
|
207
|
+
? 'var(--color-syntax-punctuation)'
|
|
208
|
+
: key === 'diff-header'
|
|
209
|
+
? 'transparent'
|
|
210
|
+
: syntaxTokenDefaults[toCSSProperty(key)];
|
|
154
211
|
}
|
|
155
212
|
|
|
156
213
|
return {
|
|
@@ -170,14 +227,17 @@ export function syntaxThemeStyle(
|
|
|
170
227
|
): Record<string, string> {
|
|
171
228
|
const vars: Record<string, string> = {};
|
|
172
229
|
for (const key of ALL_SYNTAX_KEYS) {
|
|
173
|
-
|
|
230
|
+
// An unset diff slot is left to its default rather than written empty.
|
|
231
|
+
const value = theme.tokens[key];
|
|
232
|
+
if (value !== undefined) vars[toCSSProperty(key)] = value;
|
|
174
233
|
}
|
|
175
234
|
return vars;
|
|
176
235
|
}
|
|
177
236
|
|
|
178
237
|
/** Convert a syntax theme to CSS declarations (no selector wrapper). */
|
|
179
238
|
export function syntaxThemeToCSS(theme: SyntaxThemeDefinition): string {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
239
|
+
// An unset diff slot is left to its default rather than written `undefined`.
|
|
240
|
+
return ALL_SYNTAX_KEYS.filter(key => theme.tokens[key] !== undefined)
|
|
241
|
+
.map(key => toCSSProperty(key) + ': ' + theme.tokens[key] + ';')
|
|
242
|
+
.join('\n ');
|
|
183
243
|
}
|
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
export {syntaxTokenDefaults} from './tokens';
|
|
15
15
|
export type {SyntaxTokenName} from './tokens';
|
|
16
16
|
|
|
17
|
-
export {
|
|
17
|
+
export {
|
|
18
|
+
defineSyntaxTheme,
|
|
19
|
+
syntaxThemeStyle,
|
|
20
|
+
syntaxThemeToCSS,
|
|
21
|
+
} from './defineSyntaxTheme';
|
|
18
22
|
export type {
|
|
19
23
|
SyntaxThemeDefinition,
|
|
20
24
|
SyntaxThemeInput,
|
|
@@ -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
|
*/
|
|
@@ -51,6 +54,33 @@ export const syntaxTokenDefaults = {
|
|
|
51
54
|
'--color-syntax-punctuation': 'var(--color-text-secondary)',
|
|
52
55
|
// background -> muted surface
|
|
53
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':
|
|
65
|
+
'color-mix(in srgb, var(--color-success-muted) 25%, transparent)',
|
|
66
|
+
'--color-syntax-removed':
|
|
67
|
+
'color-mix(in srgb, var(--color-error-muted) 25%, transparent)',
|
|
68
|
+
'--color-syntax-diff-header': 'var(--color-background-muted)',
|
|
69
|
+
// The margin of a changed row — its line number and +/- glyph — in the
|
|
70
|
+
// default theme's tinted-text tokens, made for tinted surfaces; a defined
|
|
71
|
+
// syntax theme that leaves them unset follows its own punctuation colour
|
|
72
|
+
// (see defineSyntaxTheme). Code text keeps the syntax theme's colours.
|
|
73
|
+
'--color-syntax-added-marker': 'var(--color-text-green)',
|
|
74
|
+
'--color-syntax-removed-marker': 'var(--color-text-red)',
|
|
54
75
|
} as const;
|
|
55
76
|
|
|
56
77
|
export type SyntaxTokenName = keyof typeof syntaxTokenDefaults;
|
|
78
|
+
|
|
79
|
+
/** The five diff row slots: optional in a syntax theme, defaulted when unset. */
|
|
80
|
+
export const DIFF_SYNTAX_TOKEN_NAMES = [
|
|
81
|
+
'--color-syntax-added',
|
|
82
|
+
'--color-syntax-removed',
|
|
83
|
+
'--color-syntax-diff-header',
|
|
84
|
+
'--color-syntax-added-marker',
|
|
85
|
+
'--color-syntax-removed-marker',
|
|
86
|
+
] as const satisfies ReadonlyArray<SyntaxTokenName>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file useSyntaxTheme.diff.test.tsx
|
|
5
|
+
* @input A SyntaxTheme provider with a preset that sets no diff slots
|
|
6
|
+
* @output The hook hands a canvas consumer paintable colours for every slot
|
|
7
|
+
* @position Diff slot resolution for the imperative (canvas) path
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {render} from '@testing-library/react';
|
|
11
|
+
import {describe, expect, it} from 'vitest';
|
|
12
|
+
import {SyntaxTheme, useSyntaxTheme} from './SyntaxTheme';
|
|
13
|
+
import {dracula} from './presets';
|
|
14
|
+
import {parseColor} from '../../utils/color';
|
|
15
|
+
|
|
16
|
+
function Probe({onTokens}: {onTokens: (t: Record<string, string>) => void}) {
|
|
17
|
+
const syntax = useSyntaxTheme();
|
|
18
|
+
if (syntax) onTokens(syntax.tokens);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('useSyntaxTheme diff slots', () => {
|
|
23
|
+
it("resolves unset marker slots to the theme's own punctuation colour", () => {
|
|
24
|
+
let tokens: Record<string, string> = {};
|
|
25
|
+
render(
|
|
26
|
+
<SyntaxTheme theme={dracula}>
|
|
27
|
+
<Probe onTokens={t => (tokens = t)} />
|
|
28
|
+
</SyntaxTheme>,
|
|
29
|
+
);
|
|
30
|
+
expect(tokens.punctuation).toMatch(/^#|^rgb/);
|
|
31
|
+
expect(tokens['added-marker']).toBe(tokens.punctuation);
|
|
32
|
+
expect(tokens['removed-marker']).toBe(tokens.punctuation);
|
|
33
|
+
// The row paints derive from the page theme's resolved muted tokens at
|
|
34
|
+
// the default strength, as rgba() a canvas can paint.
|
|
35
|
+
expect(tokens.added).toMatch(/^rgba\(\d+, \d+, \d+, 0\.\d+\)$/);
|
|
36
|
+
expect(tokens.removed).toMatch(/^rgba\(\d+, \d+, \d+, 0\.\d+\)$/);
|
|
37
|
+
expect(parseColor(tokens.added)?.a).toBeCloseTo(0.2 * 0.25, 3);
|
|
38
|
+
expect(tokens['diff-header']).toBe('transparent');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
2
|
|
|
3
|
-
import {describe, it, expect, afterEach} from 'vitest';
|
|
4
|
-
import {isFocusDetached} from './focusReturn';
|
|
3
|
+
import {describe, it, expect, afterEach, beforeEach, vi} from 'vitest';
|
|
4
|
+
import {focusForLastInput, isFocusDetached} from './focusReturn';
|
|
5
|
+
import {
|
|
6
|
+
__resetInteractionModalityForTest,
|
|
7
|
+
__startInteractionModalityTrackingForTest,
|
|
8
|
+
} from './interactionModality';
|
|
5
9
|
|
|
6
10
|
describe('isFocusDetached', () => {
|
|
7
11
|
afterEach(() => {
|
|
@@ -40,3 +44,76 @@ describe('isFocusDetached', () => {
|
|
|
40
44
|
expect(isFocusDetached(doc)).toBe(true);
|
|
41
45
|
});
|
|
42
46
|
});
|
|
47
|
+
|
|
48
|
+
describe('focusForLastInput', () => {
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
__startInteractionModalityTrackingForTest();
|
|
51
|
+
__resetInteractionModalityForTest();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
vi.restoreAllMocks();
|
|
56
|
+
document.body.innerHTML = '';
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
function button(): HTMLButtonElement {
|
|
60
|
+
const element = document.createElement('button');
|
|
61
|
+
document.body.appendChild(element);
|
|
62
|
+
return element;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
it('leaves the ring to the browser after keyboard input, passing the options through', () => {
|
|
66
|
+
const element = button();
|
|
67
|
+
const focus = vi.spyOn(element, 'focus');
|
|
68
|
+
focusForLastInput(element);
|
|
69
|
+
focusForLastInput(element, {preventScroll: true});
|
|
70
|
+
expect(focus.mock.calls).toEqual([[], [{preventScroll: true}]]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('asks the browser for no ring after pointer input, keeping the options', () => {
|
|
74
|
+
const element = button();
|
|
75
|
+
document.dispatchEvent(new Event('pointerdown'));
|
|
76
|
+
const focus = vi.spyOn(element, 'focus');
|
|
77
|
+
focusForLastInput(element);
|
|
78
|
+
focusForLastInput(element, {preventScroll: true});
|
|
79
|
+
expect(focus.mock.calls).toEqual([
|
|
80
|
+
[{focusVisible: false}],
|
|
81
|
+
[{preventScroll: true, focusVisible: false}],
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('keeps plain focus on a text field after pointer input', () => {
|
|
86
|
+
const input = document.createElement('input');
|
|
87
|
+
document.body.appendChild(input);
|
|
88
|
+
document.dispatchEvent(new Event('pointerdown'));
|
|
89
|
+
const focus = vi.spyOn(input, 'focus');
|
|
90
|
+
focusForLastInput(input, {preventScroll: true});
|
|
91
|
+
expect(focus.mock.calls).toEqual([[{preventScroll: true}]]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('clears a ring the browser already drew before refocusing after pointer input', () => {
|
|
95
|
+
const element = button();
|
|
96
|
+
element.focus();
|
|
97
|
+
document.dispatchEvent(new Event('pointerdown'));
|
|
98
|
+
const matches = HTMLElement.prototype.matches;
|
|
99
|
+
vi.spyOn(HTMLElement.prototype, 'matches').mockImplementation(function (
|
|
100
|
+
this: HTMLElement,
|
|
101
|
+
selector: string,
|
|
102
|
+
) {
|
|
103
|
+
return selector === ':focus-visible'
|
|
104
|
+
? this === element
|
|
105
|
+
: matches.call(this, selector);
|
|
106
|
+
});
|
|
107
|
+
const blur = vi.spyOn(element, 'blur');
|
|
108
|
+
const focus = vi.spyOn(element, 'focus');
|
|
109
|
+
focusForLastInput(element, {preventScroll: true});
|
|
110
|
+
expect(blur).toHaveBeenCalledTimes(1);
|
|
111
|
+
expect(focus.mock.calls).toEqual([
|
|
112
|
+
[{preventScroll: true, focusVisible: false}],
|
|
113
|
+
]);
|
|
114
|
+
expect(blur.mock.invocationCallOrder[0]).toBeLessThan(
|
|
115
|
+
focus.mock.invocationCallOrder[0] ?? 0,
|
|
116
|
+
);
|
|
117
|
+
expect(document.activeElement).toBe(element);
|
|
118
|
+
});
|
|
119
|
+
});
|
package/src/utils/focusReturn.ts
CHANGED
|
@@ -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';
|
|
16
|
+
|
|
12
17
|
/**
|
|
13
18
|
* True when focus rests on nothing in particular — no active element, or the
|
|
14
19
|
* document body / root element.
|
|
@@ -29,3 +34,43 @@ export function isFocusDetached(doc: Document = document): boolean {
|
|
|
29
34
|
active == null || active === doc.body || active === doc.documentElement
|
|
30
35
|
);
|
|
31
36
|
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Move focus programmatically so its ring follows the last input
|
|
40
|
+
* (`architecture:interaction-modality` INV1). After pointer input the browser's
|
|
41
|
+
* focus option keeps the element from matching `:focus-visible`, which is the
|
|
42
|
+
* one way to reach an element the overlay does not render: the consumer's
|
|
43
|
+
* autofocus target, the control that opened a Dialog, or a menu trigger the
|
|
44
|
+
* caller renders. Pass `options` (for example `preventScroll`) as to `focus()`.
|
|
45
|
+
*
|
|
46
|
+
* Make sure modality tracking is active (`useInteractionModalityTracking()`)
|
|
47
|
+
* in the component that calls this.
|
|
48
|
+
*/
|
|
49
|
+
export function focusForLastInput(
|
|
50
|
+
element: HTMLElement,
|
|
51
|
+
options?: FocusOptions,
|
|
52
|
+
): void {
|
|
53
|
+
// A text-entry field paints its own focus treatment to show where typing
|
|
54
|
+
// goes, and an extra blur there can run validation or mark it touched.
|
|
55
|
+
const isTextEntry =
|
|
56
|
+
element instanceof HTMLInputElement ||
|
|
57
|
+
element instanceof HTMLTextAreaElement ||
|
|
58
|
+
element instanceof HTMLSelectElement ||
|
|
59
|
+
element.isContentEditable;
|
|
60
|
+
if (isTextEntry || getInteractionModality() !== 'pointer') {
|
|
61
|
+
if (options === undefined) {
|
|
62
|
+
element.focus();
|
|
63
|
+
} else {
|
|
64
|
+
element.focus(options);
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// Focusing the element that already has focus does nothing, so a ring the
|
|
69
|
+
// browser already drew would stay. WebKit draws one when its own focus
|
|
70
|
+
// restoration (a dialog's close, a popover's hide) moves focus itself, so
|
|
71
|
+
// clear that focus first.
|
|
72
|
+
if (element.matches(':focus-visible')) {
|
|
73
|
+
element.blur();
|
|
74
|
+
}
|
|
75
|
+
element.focus({...options, focusVisible: false});
|
|
76
|
+
}
|