@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.
Files changed (80) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts +1 -1
  2. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  3. package/dist/BottomSheet/BottomSheet.js +12 -2
  4. package/dist/CodeBlock/CodeBlock.d.ts +10 -1
  5. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  6. package/dist/CodeBlock/CodeBlock.js +158 -38
  7. package/dist/CodeBlock/tokenizer.d.ts +39 -0
  8. package/dist/CodeBlock/tokenizer.d.ts.map +1 -1
  9. package/dist/CodeBlock/tokenizer.js +108 -0
  10. package/dist/ContextMenu/ContextMenu.d.ts +1 -1
  11. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  12. package/dist/ContextMenu/ContextMenu.js +22 -8
  13. package/dist/Dialog/Dialog.d.ts.map +1 -1
  14. package/dist/Dialog/Dialog.js +3 -26
  15. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  16. package/dist/DropdownMenu/DropdownMenu.js +10 -3
  17. package/dist/Markdown/Markdown.d.ts.map +1 -1
  18. package/dist/Markdown/Markdown.js +19 -3
  19. package/dist/Typeahead/BaseTypeahead.d.ts +1 -1
  20. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  21. package/dist/Typeahead/BaseTypeahead.js +12 -3
  22. package/dist/astryx.css +13 -0
  23. package/dist/hooks/useIsomorphicLayoutEffect.d.ts +2 -2
  24. package/dist/hooks/useIsomorphicLayoutEffect.d.ts.map +1 -1
  25. package/dist/theme/syntax/SyntaxTheme.d.ts.map +1 -1
  26. package/dist/theme/syntax/SyntaxTheme.js +43 -2
  27. package/dist/theme/syntax/defineSyntaxTheme.d.ts +18 -5
  28. package/dist/theme/syntax/defineSyntaxTheme.d.ts.map +1 -1
  29. package/dist/theme/syntax/defineSyntaxTheme.js +38 -9
  30. package/dist/theme/syntax/index.d.ts +1 -1
  31. package/dist/theme/syntax/index.d.ts.map +1 -1
  32. package/dist/theme/syntax/tokens.d.ts +12 -2
  33. package/dist/theme/syntax/tokens.d.ts.map +1 -1
  34. package/dist/theme/syntax/tokens.js +25 -4
  35. package/dist/utils/focusReturn.d.ts +12 -8
  36. package/dist/utils/focusReturn.d.ts.map +1 -1
  37. package/dist/utils/focusReturn.js +44 -3
  38. package/package.json +4 -4
  39. package/src/BottomSheet/BottomSheet.test.tsx +114 -0
  40. package/src/BottomSheet/BottomSheet.tsx +12 -2
  41. package/src/BottomSheet/__tests__/BottomSheetFocusReturn.a11y.browser.spec.ts +96 -0
  42. package/src/Chat/__tests__/ChatLayoutScrollButtonSurface.a11y.chromium.spec.ts +5 -6
  43. package/src/CodeBlock/CodeBlock.doc.mjs +7 -0
  44. package/src/CodeBlock/CodeBlock.spec.md +55 -45
  45. package/src/CodeBlock/CodeBlock.test.tsx +89 -9
  46. package/src/CodeBlock/CodeBlock.tsx +205 -25
  47. package/src/CodeBlock/__tests__/CodeBlockOverflowStop.a11y.chromium.spec.ts +125 -0
  48. package/src/CodeBlock/__tests__/DiffRowPaint.a11y.chromium.spec.ts +93 -0
  49. package/src/CodeBlock/tokenizer.test.ts +112 -0
  50. package/src/CodeBlock/tokenizer.ts +106 -0
  51. package/src/ContextMenu/ContextMenu.spec.md +1 -1
  52. package/src/ContextMenu/ContextMenu.test.tsx +36 -3
  53. package/src/ContextMenu/ContextMenu.tsx +27 -11
  54. package/src/ContextMenu/__tests__/ContextMenuCursorAnchor.a11y.browser.spec.ts +153 -0
  55. package/src/Dialog/Dialog.tsx +2 -30
  56. package/src/Dialog/__tests__/Dialog.a11y.chromium.spec.ts +21 -14
  57. package/src/DropdownMenu/DropdownMenu.test.tsx +97 -1
  58. package/src/DropdownMenu/DropdownMenu.tsx +10 -3
  59. package/src/DropdownMenu/__tests__/DropdownMenuFocusReturn.a11y.browser.spec.ts +73 -0
  60. package/src/FieldStatus/__tests__/StatusMessage.a11y.chromium.spec.ts +21 -14
  61. package/src/Markdown/Markdown.spec.md +37 -23
  62. package/src/Markdown/Markdown.test.tsx +38 -0
  63. package/src/Markdown/Markdown.tsx +20 -2
  64. package/src/Markdown/plugins/semanticFence.test.tsx +3 -2
  65. package/src/NumberInput/__tests__/NumberInput.a11y.chromium.spec.ts +22 -15
  66. package/src/PowerSearch/PowerSearch.test.tsx +45 -0
  67. package/src/Switch/__tests__/Switch.a11y.chromium.spec.ts +25 -18
  68. package/src/TabList/__tests__/Tabs.a11y.chromium.spec.ts +19 -12
  69. package/src/TextInput/__tests__/TextInput.a11y.chromium.spec.ts +21 -14
  70. package/src/ToggleButton/__tests__/ToggleButton.a11y.chromium.spec.ts +18 -11
  71. package/src/Typeahead/BaseTypeahead.test.tsx +99 -1
  72. package/src/Typeahead/BaseTypeahead.tsx +17 -3
  73. package/src/theme/syntax/SyntaxTheme.tsx +46 -5
  74. package/src/theme/syntax/defineSyntaxTheme.diff.test.ts +91 -0
  75. package/src/theme/syntax/defineSyntaxTheme.ts +75 -15
  76. package/src/theme/syntax/index.ts +5 -1
  77. package/src/theme/syntax/tokens.ts +32 -2
  78. package/src/theme/syntax/useSyntaxTheme.diff.test.tsx +40 -0
  79. package/src/utils/focusReturn.test.ts +79 -2
  80. 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
- /** Token map for defineSyntaxTheme input — values can be strings or tuples. */
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
- /** Resolved token map — all values are CSS strings (tuples resolved to light-dark()). */
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 = ALL_SYNTAX_KEYS.filter(key => !(key in input.tokens));
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 14 syntax tokens are required.`);
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
- resolved[key] = resolveTokenValue(input.tokens[key]);
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
- vars[toCSSProperty(key)] = theme.tokens[key];
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
- return ALL_SYNTAX_KEYS.map(key => toCSSProperty(key) + ': ' + theme.tokens[key] + ';').join('\n ');
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,EAAC,iBAAiB,EAAE,gBAAgB,EAAE,gBAAgB,EAAC,MAAM,qBAAqB,CAAC;AAC1F,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"}
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-token architecture validated against 11 community code themes.
15
- * All themes map cleanly to these 14 slots.
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;;;;;;;;;;;;;;;;;GAiBG;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;CAgCtB,CAAC;AAEX,MAAM,MAAM,eAAe,GAAG,MAAM,OAAO,mBAAmB,CAAC"}
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-token architecture validated against 11 community code themes.
17
- * All themes map cleanly to these 14 slots.
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":"AAEA;;;;;;;GAOG;AAEH;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,GAAG,GAAE,QAAmB,GAAG,OAAO,CAKjE"}
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
- * @output Whether focus is currently "detached" — on nothing in particular.
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.0",
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
- (finalFocusRef?.current ?? triggerRef.current)?.focus();
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 and not observable to the probe-reach
19
- * gate either. jsdom resolves no cascade, so it cannot say which element paints.
20
- * The gate's reach check asks whether a target's override lands on the element
21
- * carrying the class, and it does — that element simply is not the one a reader
22
- * sees. Only a real engine can be asked the question that matters: does styling
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',