@microbit/ui 0.1.0-alpha.27 → 0.1.0-alpha.28

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/README.md CHANGED
@@ -197,6 +197,50 @@ Semantic tokens (`languageText`, `statusBarBg`, `danger.*`, `toast*Bg`,
197
197
  brand presets override; they resolve through var indirection, so overrides
198
198
  apply wherever the token is consumed.
199
199
 
200
+ `focusRing` and `focusBorder` need more care: their values are condition
201
+ objects and a merge replaces a value wholesale, so the flat form drops the
202
+ on-dark flip below. Keep the shape:
203
+
204
+ ```ts
205
+ focusBorder: { value: { base: "{colors.brand.700}", _onDark: "{colors.white}" } };
206
+ ```
207
+
208
+ ## Dark surfaces
209
+
210
+ Focus indicators are surface-aware through one tag. The default focus ring
211
+ is ink; on a dark surface it must be white, so tag the surface element by
212
+ spreading the exported constant:
213
+
214
+ ```tsx
215
+ import { darkSurface } from "@microbit/ui";
216
+
217
+ <header {...darkSurface}>…</header>; // a black toolbar, a coloured sidebar bar
218
+ ```
219
+
220
+ Custom properties inherit, so tagging the bar covers the bar itself and
221
+ every control inside it — including ones added later. If the tagged element
222
+ is focusable, tag one level in instead: its own ring is drawn _outside_ it,
223
+ on whatever is behind it. (The Toast does this — the card is dark and
224
+ focusable, so the tag sits on its close button.) Portalled overlays (a
225
+ modal opened from a dark toolbar) escape the tag with the DOM, which is
226
+ correct. Under the hood it is `data-surface="dark"`, which the preset's
227
+ `onDark` condition scopes the `focusRing`/`focusBorder` token flips to.
228
+
229
+ Two rules:
230
+
231
+ - **Dark surfaces must tag** — the ink ring is near-invisible on them, and
232
+ there is no automatic detection.
233
+ - **Tag surfaces, not themes**: "dark" describes the surface's own
234
+ luminance, so tag surfaces that are dark by design (a black toolbar, a
235
+ coloured sidebar bar) rather than anything relative to the app's overall
236
+ look. This is what will let the tag survive a dark mode if we ship one:
237
+ designed-dark surfaces stay dark and keep their tags; everything else
238
+ stays untagged, and a dark mode would flip the untagged defaults via
239
+ token conditions, never via markup.
240
+
241
+ Rule of thumb for coloured bars: tag when the surface lacks 3:1 contrast
242
+ against ink — roughly, darker than the grey ramp's 500.
243
+
200
244
  ## Runtime token lookups
201
245
 
202
246
  For values that feed _computation_ rather than stylesheets (canvas painting,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microbit/ui",
3
- "version": "0.1.0-alpha.27",
3
+ "version": "0.1.0-alpha.28",
4
4
  "description": "micro:bit design-system primitives: react-aria-components + Panda CSS with a design language ported from Chakra UI v2. Ships as source; see README for the consumption setup.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,6 +32,7 @@
32
32
  "peerDependencies": {
33
33
  "@pandacss/dev": "^1.11.4",
34
34
  "react": "^18.3.1",
35
+ "react-aria": "^3.50.0",
35
36
  "react-aria-components": "^1.19.0",
36
37
  "react-icons": "^4.12.0 || ^5.0.0",
37
38
  "react-intl": "^6.6.8 || ^7.0.0"
@@ -47,6 +48,7 @@
47
48
  "@vitejs/plugin-react": "^4.5.2",
48
49
  "jsdom": "^29.1.1",
49
50
  "react": "^18.3.1",
51
+ "react-aria": "^3.50.0",
50
52
  "react-aria-components": "^1.19.0",
51
53
  "react-dom": "^18.3.1",
52
54
  "react-icons": "^4.12.0",
@@ -6,9 +6,10 @@
6
6
  import { defineRecipe } from "@pandacss/dev";
7
7
 
8
8
  // The common transition-property list, inlined (Panda has no
9
- // transitionProperty token category).
9
+ // transitionProperty token category). No box-shadow: focus indication
10
+ // must never fade in.
10
11
  const transitionCommon =
11
- "background-color, border-color, color, fill, stroke, opacity, box-shadow, transform";
12
+ "background-color, border-color, color, fill, stroke, opacity, transform";
12
13
 
13
14
  /**
14
15
  * Button recipe — the family button base + sizes, with the house
@@ -46,7 +47,7 @@ export const button = defineRecipe({
46
47
  verticalAlign: "middle",
47
48
  outline: "none",
48
49
  _focusVisible: {
49
- focusShadow: "outline",
50
+ focusRing: "outline",
50
51
  },
51
52
  _disabled: {
52
53
  opacity: 0.4,
@@ -155,12 +156,13 @@ export const button = defineRecipe({
155
156
  _active: { bg: "danger.700" },
156
157
  },
157
158
  // Family-wide variant (every censused app has toolbar-class buttons).
159
+ // No ring override: the bar decides, and a dark one must spread
160
+ // `darkSurface` — an app adopting this variant needs the tag with it.
158
161
  toolbar: {
159
162
  color: "black",
160
163
  bg: "white",
161
164
  _hover: { bg: "whiteAlpha.900", _disabled: { bg: "white" } },
162
165
  _active: { bg: "whiteAlpha.800" },
163
- _focusVisible: { focusShadow: "outlineDark" },
164
166
  },
165
167
  },
166
168
  },
@@ -38,8 +38,6 @@ export const checkbox = defineSlotRecipe({
38
38
  alignItems: "center",
39
39
  justifyContent: "center",
40
40
  flexShrink: 0,
41
- transitionProperty: "box-shadow",
42
- transitionDuration: "normal",
43
41
  borderWidth: "2px",
44
42
  borderStyle: "solid",
45
43
  borderRadius: "sm",
@@ -68,7 +66,7 @@ export const checkbox = defineSlotRecipe({
68
66
  _hover: { bg: "gray.200", borderColor: "gray.200" },
69
67
  },
70
68
  "&[data-focus-visible]": {
71
- focusShadow: "outline",
69
+ focusRing: "outline",
72
70
  },
73
71
  },
74
72
  icon: {
@@ -57,7 +57,7 @@ export const CloseButton = forwardRef<HTMLButtonElement, CloseButtonProps>(
57
57
  transitionDuration: "normal",
58
58
  _hover: { bg: "blackAlpha.100" },
59
59
  _active: { bg: "blackAlpha.200" },
60
- _focusVisible: { focusShadow: "outline" },
60
+ _focusVisible: { focusRing: "outline" },
61
61
  },
62
62
  size === "sm"
63
63
  ? { width: "6", height: "6", fontSize: "2xs" }
package/src/ComboBox.tsx CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  Popover,
21
21
  PopoverProps,
22
22
  } from "react-aria-components";
23
+ import { useFocusVisible } from "react-aria";
23
24
  import { useIntl } from "react-intl";
24
25
  import { RiArrowDownSLine } from "react-icons/ri";
25
26
  import { css, cx } from "styled-system/css";
@@ -136,6 +137,10 @@ const ComboBoxInner = <T extends object>(
136
137
  const fieldSlots = field({ size: variantProps.size, labelPosition });
137
138
  // Anchor the card to the whole control, not to the bare input inside it —
138
139
  // otherwise it hangs off the text baseline and is as narrow as the input.
140
+ // Global modality (text-input key filter): unlike the input's own RAC
141
+ // attributes, it survives react-aria's synthetic blur during virtual
142
+ // focus — the select recipe's ring rule pairs it with :has(input:focus).
143
+ const { isFocusVisible } = useFocusVisible({ isTextInput: true });
139
144
  const triggerRef = useRef<HTMLDivElement>(null);
140
145
  // RAC's --trigger-width measures the input it anchors a ComboBox to, which
141
146
  // is the control's content box — so a card sized from it is narrower than
@@ -176,6 +181,7 @@ const ComboBoxInner = <T extends object>(
176
181
  )}
177
182
  <div
178
183
  ref={triggerRef}
184
+ data-keyboard-modality={isFocusVisible || undefined}
179
185
  className={cx(
180
186
  slots.trigger,
181
187
  triggerCss ? css(triggerCss) : undefined,
@@ -52,7 +52,8 @@ export const field = defineSlotRecipe({
52
52
  },
53
53
  requiredIndicator: {
54
54
  marginStart: "1",
55
- color: "danger.500",
55
+ // 600: red-as-text needs 4.5:1; danger.500 is border-grade (~4.1:1).
56
+ color: "danger.600",
56
57
  },
57
58
  helperText: {
58
59
  // RAC's Text renders a span, and RadioGroup/CheckboxGroup roots are not
@@ -70,7 +71,8 @@ export const field = defineSlotRecipe({
70
71
  mt: "2",
71
72
  fontSize: "sm",
72
73
  lineHeight: "normal",
73
- color: "danger.500",
74
+ // 600: red-as-text needs 4.5:1 (the invalid border stays 500).
75
+ color: "danger.600",
74
76
  },
75
77
  },
76
78
  variants: {
@@ -50,7 +50,7 @@ export const gridList = defineSlotRecipe({
50
50
  _hover: { bg: "gray.100" },
51
51
  "&:has([aria-expanded=true])": { bg: "gray.100" },
52
52
  },
53
- "&[data-focus-visible]": { focusShadow: "outline" },
53
+ "&[data-focus-visible]": { focusRing: "outline" },
54
54
  "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
55
55
  },
56
56
  },
@@ -8,25 +8,16 @@ import { defineRecipe } from "@pandacss/dev";
8
8
  // The common transition-property list, inlined (Panda has no
9
9
  // transitionProperty token category).
10
10
  const transitionCommon =
11
- "background-color, border-color, color, fill, stroke, opacity, box-shadow, transform";
11
+ "background-color, border-color, color, fill, stroke, opacity, transform";
12
12
 
13
13
  /**
14
14
  * Input recipe — the outline text field with an sm/md/lg size scale. Used by
15
15
  * the shared-ui Input and NativeSelect, and by TextField's input slot.
16
16
  *
17
- * Focus matches both native `:focus-visible` (plain inputs; browsers treat any
18
- * focus in a text field as focus-visible) and react-aria's `data-focused`
19
- * (inputs inside RAC TextField).
20
- *
21
- * Hover, invalid and focus all set `borderColor`, so their precedence has to be
22
- * hover < invalid < focus. Declaration order will not buy that: Panda sorts a
23
- * recipe's state rules itself, ranking selectors against a fixed
24
- * link/visited/focus/hover/active table, which puts `_hover` *after* focus and
25
- * after anything the table doesn't mention (`[data-invalid]`). Equal-specificity
26
- * rules then leave hover winning. So the ladder is spelled with repeated `&`
27
- * instead — `&&` and `&&&` emit `.input.input` and `.input.input.input`, making
28
- * precedence specificity rather than order, which nothing downstream can
29
- * resort. Variants still override freely; they land in a later cascade layer.
17
+ * hover < focused < invalid is a specificity ladder (`&&`, `&&&`): Panda
18
+ * sorts state rules against its own pseudo-class table, not declaration
19
+ * order, so equal-specificity borderColor ties would leave hover winning.
20
+ * Variants still override freely (later cascade layer).
30
21
  *
31
22
  * Registered in the base preset (base-preset.ts), which also has the
32
23
  * `staticCss` entry that keeps the runtime-prop size variants generated.
@@ -42,27 +33,30 @@ export const input = defineRecipe({
42
33
  font: "inherit",
43
34
  transitionProperty: transitionCommon,
44
35
  transitionDuration: "normal",
45
- border: "1px solid",
46
- // The accessible outline stops: gray.400 is the ramp's 3:1-on-white
47
- // boundary grey (WCAG 1.4.11), and hover steps darker, never lighter.
48
- borderColor: "gray.400",
36
+ border: "2px solid",
37
+ // Deliberately below 3:1 valid while something else visually
38
+ // identifies the field (label, or a ≥3:1 icon/placeholder).
39
+ // Checkbox/Radio keep gray.400: their box IS the identifier.
40
+ // Rationale: ui-private docs/a11y-positions.md.
41
+ borderColor: "gray.300",
49
42
  bg: "inherit",
50
43
  color: "inherit",
51
44
  _hover: { borderColor: "gray.500" },
52
- "&&:is([data-invalid], :user-invalid)": {
45
+ // Any focus, pointer included; the keyboard ring composes on top.
46
+ // zIndex so the border — and the ring with it — paint over
47
+ // attached-group neighbours.
48
+ "&&:is(:focus, [data-focused])": { zIndex: 1, borderColor: "focusBorder" },
49
+ "&&&:is([data-invalid], :user-invalid)": {
53
50
  borderColor: "danger.500",
54
- boxShadow: "0 0 0 1px token(colors.danger.500)",
55
- },
56
- "&&&:is(:focus-visible, [data-focused])": {
57
- zIndex: 1,
58
- borderColor: "focusBorder",
59
- boxShadow: "0 0 0 1px token(colors.focusBorder)",
60
- // Focus indicator for forced-colors modes, which strip the box-shadow
61
- // and force the border colour (the focusShadow utility's technique; the
62
- // ring here is the 1px border tint, not an outline* shadow token).
63
- outline: "2px solid transparent",
64
- outlineOffset: "2px",
65
51
  },
52
+ // Modality-tracked: RAC's attribute, or Input.tsx's. Native
53
+ // :focus-visible is the fallback for a bare element wearing the recipe,
54
+ // but not text-entry ones — browsers match it there on a pointer click,
55
+ // which is what the tracking exists to exclude.
56
+ "&&&:is([data-focus-visible], :focus-visible:not([data-rac], input, textarea))":
57
+ {
58
+ focusRing: "outline",
59
+ },
66
60
  "&:is(:disabled, [data-disabled])": {
67
61
  opacity: 0.4,
68
62
  cursor: "not-allowed",
package/src/Input.tsx CHANGED
@@ -4,6 +4,7 @@
4
4
  * SPDX-License-Identifier: MIT
5
5
  */
6
6
  import { forwardRef, InputHTMLAttributes } from "react";
7
+ import { mergeProps, useFocusRing } from "react-aria";
7
8
  import { css, cx } from "styled-system/css";
8
9
  import { input, InputVariantProps } from "styled-system/recipes";
9
10
  import { SystemStyleObject } from "styled-system/types";
@@ -30,15 +31,19 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
30
31
  // groups to the recipe (classroom adds `variant`), and cherry-picking would
31
32
  // silently drop them onto the DOM as unknown attributes instead.
32
33
  const [variantProps, rest] = input.splitVariantProps(props);
34
+ // A native input never gets RAC's data-focus-visible, which the recipe's
35
+ // ring keys off — track modality ourselves, as TextField's input does.
36
+ const { isFocusVisible, focusProps } = useFocusRing({ isTextInput: true });
33
37
  return (
34
38
  <input
35
39
  ref={ref}
40
+ data-focus-visible={isFocusVisible || undefined}
36
41
  className={cx(
37
42
  input(variantProps),
38
43
  cssProp ? css(cssProp) : undefined,
39
44
  className,
40
45
  )}
41
- {...rest}
46
+ {...mergeProps(rest, focusProps)}
42
47
  />
43
48
  );
44
49
  });
package/src/Link.tsx CHANGED
@@ -18,6 +18,6 @@ export const Link = styled("a", {
18
18
  "background-color, border-color, color, fill, stroke, opacity, box-shadow, transform",
19
19
  transitionDuration: "normal",
20
20
  _hover: { textDecoration: "underline" },
21
- _focusVisible: { focusShadow: "outline" },
21
+ _focusVisible: { focusRing: "outline" },
22
22
  },
23
23
  });
@@ -36,7 +36,7 @@ export const listBox = defineSlotRecipe({
36
36
  transitionTimingFunction: "ease-in",
37
37
  _hover: { bg: "gray.50" },
38
38
  "&[data-selected]": { bg: "gray.100", _hover: { bg: "gray.100" } },
39
- "&[data-focus-visible]": { focusShadow: "outline" },
39
+ "&[data-focus-visible]": { focusRing: "outline" },
40
40
  "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
41
41
  },
42
42
  },
@@ -66,12 +66,11 @@ export const menu = defineSlotRecipe({
66
66
  color: "inherit",
67
67
  textDecoration: "none",
68
68
  outline: "none",
69
- transitionProperty: "background",
70
- transitionDuration: "ultra-fast",
71
- transitionTimingFunction: "ease-in",
72
- // RAC highlights the active item (keyboard or pointer) with data-focused;
73
- // data-pressed is the pressed state.
69
+ // data-focused is RAC's active item, either modality. The highlight
70
+ // is focus indication: no transition (it snaps with the ring, which
71
+ // keyboard nav adds; inset, the rows being full-bleed).
74
72
  "&[data-focused]": { bg: "gray.100" },
73
+ "&[data-focus-visible]": { focusRing: "outlineInset" },
75
74
  "&[data-pressed]": { bg: "gray.200" },
76
75
  "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
77
76
  },
package/src/Modal.tsx CHANGED
@@ -368,7 +368,7 @@ export const ModalCloseButton = ({
368
368
  transitionDuration: "normal",
369
369
  _hover: { bg: "blackAlpha.100" },
370
370
  _active: { bg: "blackAlpha.200" },
371
- _focusVisible: { focusShadow: "outline" },
371
+ _focusVisible: { focusRing: "outline" },
372
372
  }),
373
373
  )}
374
374
  >
@@ -25,6 +25,15 @@ export const numberField = defineSlotRecipe({
25
25
  group: {
26
26
  position: "relative",
27
27
  zIndex: 0,
28
+ // The stepper overlays the input as a sibling, so the input's own
29
+ // :hover misses it — the group carries the hover tint. The :not()
30
+ // list is required: slot recipes layer above plain recipes
31
+ // (recipes.slots), so this would otherwise override focused/invalid
32
+ // (see docs/hints.md). :user-invalid takes its own :not(): :not() is
33
+ // not forgiving, so one list would drop the whole selector on a
34
+ // browser that doesn't know the pseudo-class (Safari < 16.5).
35
+ "&:hover input:not(:focus, [data-focused], [data-invalid]):not(:user-invalid)":
36
+ { borderColor: "gray.500" },
28
37
  },
29
38
  // A column overlaying the input's right edge, inset by the input border.
30
39
  stepper: {
@@ -33,8 +42,8 @@ export const numberField = defineSlotRecipe({
33
42
  position: "absolute",
34
43
  insetEnd: "0",
35
44
  top: "0",
36
- height: "calc(100% - 2px)",
37
- margin: "1px",
45
+ height: "calc(100% - 4px)",
46
+ margin: "2px",
38
47
  width: "6",
39
48
  zIndex: 1,
40
49
  },
@@ -52,18 +61,18 @@ export const numberField = defineSlotRecipe({
52
61
  borderColor: "gray.200",
53
62
  transitionProperty: "background",
54
63
  transitionDuration: "ultra-fast",
55
- // Follow the input's corners, less the 1px border the stepper is inset
64
+ // Follow the input's corners, less the 2px border the stepper is inset
56
65
  // by, so the hover and pressed fills curve away with the border instead
57
66
  // of squaring off over its arc. Radii track the input recipe's size
58
67
  // scale, so the `sm` variant restates them.
59
68
  "&:first-child": {
60
- borderStartEndRadius: "calc(token(radii.md) - 1px)",
69
+ borderStartEndRadius: "calc(token(radii.md) - 2px)",
61
70
  },
62
71
  "&:last-child": {
63
72
  borderTop: "1px solid",
64
73
  borderTopColor: "gray.200",
65
74
  marginTop: "-1px",
66
- borderEndEndRadius: "calc(token(radii.md) - 1px)",
75
+ borderEndEndRadius: "calc(token(radii.md) - 2px)",
67
76
  },
68
77
  "&[data-hovered]": { bg: "gray.100" },
69
78
  "&[data-pressed]": { bg: "gray.200" },
@@ -84,10 +93,10 @@ export const numberField = defineSlotRecipe({
84
93
  stepperButton: {
85
94
  fontSize: "calc(token(fontSizes.sm) * 0.75)",
86
95
  "&:first-child": {
87
- borderStartEndRadius: "calc(token(radii.sm) - 1px)",
96
+ borderStartEndRadius: "calc(token(radii.sm) - 2px)",
88
97
  },
89
98
  "&:last-child": {
90
- borderEndEndRadius: "calc(token(radii.sm) - 1px)",
99
+ borderEndEndRadius: "calc(token(radii.sm) - 2px)",
91
100
  },
92
101
  },
93
102
  },
@@ -38,8 +38,6 @@ export const radio = defineSlotRecipe({
38
38
  alignItems: "center",
39
39
  justifyContent: "center",
40
40
  flexShrink: 0,
41
- transitionProperty: "box-shadow",
42
- transitionDuration: "normal",
43
41
  borderWidth: "2px",
44
42
  borderStyle: "solid",
45
43
  borderRadius: "full",
@@ -78,7 +76,7 @@ export const radio = defineSlotRecipe({
78
76
  _hover: { bg: "gray.200", borderColor: "gray.200" },
79
77
  },
80
78
  "&[data-focus-visible]": {
81
- focusShadow: "outline",
79
+ focusRing: "outline",
82
80
  },
83
81
  },
84
82
  label: {
@@ -7,8 +7,10 @@ import { defineSlotRecipe } from "@pandacss/dev";
7
7
 
8
8
  // The common transition-property list, inlined (Panda has no
9
9
  // transitionProperty token category).
10
+ // No box-shadow: nothing in this recipe uses one, and the focus ring's
11
+ // layers must never fade in.
10
12
  const transitionCommon =
11
- "background-color, border-color, color, fill, stroke, opacity, box-shadow, transform";
13
+ "background-color, border-color, color, fill, stroke, opacity, transform";
12
14
 
13
15
  /**
14
16
  * Select slot recipe — the dropdown pair, shared by `Select` (a listbox behind
@@ -62,47 +64,35 @@ export const select = defineSlotRecipe({
62
64
  cursor: "pointer",
63
65
  transitionProperty: transitionCommon,
64
66
  transitionDuration: "normal",
65
- border: "1px solid",
66
- // As the input recipe: the ramp's accessible outline stop, with hover
67
- // stepping darker. The dropdown card below keeps the light gray.200 —
68
- // it's a surface edge, not a form-control boundary.
69
- borderColor: "gray.400",
67
+ border: "2px solid",
68
+ // As the input recipe (rationale there). The dropdown card below
69
+ // keeps gray.200 a surface edge, not a form-control boundary.
70
+ borderColor: "gray.300",
70
71
  bg: "white",
71
72
  color: "inherit",
72
- // As the input recipe, so a Select, a NativeSelect and a TextField in one
73
- // form all tint together on hover. (react-aria's TextField has no hover
74
- // effect, but matching the family beats matching their docs.)
73
+ // As the input recipe, so mixed fields tint together on hover.
75
74
  _hover: { borderColor: "gray.500" },
76
75
  // `data-invalid` lands on the root — and, in a ComboBox, on the input —
77
76
  // but never on the trigger: a RAC Button has no validity state, and our
78
77
  // ComboBox control is a plain div. So it comes down from the parent.
79
78
  // `> &` rather than a descendant selector, so an app's own invalid form
80
- // wrapper cannot paint every control inside it red.
81
- //
82
- // Doubled `&` for the same reason as the input recipe: hover, invalid and
83
- // focus all set `borderColor`, and Panda sorts state rules by its own
84
- // pseudo-class table rather than declaration order, so hover would win
85
- // these ties. The repeated `&` makes the hover < invalid < focus ladder a
86
- // matter of specificity instead.
87
- "[data-invalid] > &&": {
79
+ // wrapper cannot paint every control inside it red. Repeated `&`: the
80
+ // hover < focused < invalid specificity ladder, as the input recipe.
81
+ "&&:is([data-focused], :has(input:focus))": {
82
+ borderColor: "focusBorder",
83
+ },
84
+ // Border colour alone, as the input recipe.
85
+ "[data-invalid] > &&&": {
88
86
  borderColor: "danger.500",
89
- boxShadow: "0 0 0 1px token(colors.danger.500)",
90
87
  },
91
- // Two focus cases. `data-focus-visible` is Select's button on keyboard
92
- // focus only (RAC leaves it unset for mouse, matching the react-aria
93
- // docs' Select). The `:has()` arm is ComboBox: its control is a plain
94
- // div wrapping an input, so it gets no RAC attributes itself, and as a
95
- // text field it should show focus on any modality. That arm watches
96
- // native `:focus` rather than the input's `data-focused`, because
97
- // react-aria dispatches a synthetic blur at the input whenever virtual
98
- // focus moves to an option (aria-activedescendant) — which strips RAC's
99
- // attribute for as long as the list has an active option, real focus
100
- // never having left. Select's trigger holds no input, so it can't match.
101
- "&&&[data-focus-visible], &&&:has(input:focus)": {
102
- boxShadow: "0 0 0 1px token(colors.focusBorder)",
103
- borderColor: "focusBorder",
104
- outline: "2px solid transparent",
105
- outlineOffset: "2px",
88
+ // Keyboard-only ring, two cases. Select's button: RAC's attribute.
89
+ // ComboBox: the input's RAC attributes are stripped by react-aria's
90
+ // synthetic blur during virtual focus, so its case combines the two
91
+ // signals that survive native :focus plus the global-modality
92
+ // attribute the component renders (see ComboBox.tsx). No flicker
93
+ // during list navigation; can't match Select (no input).
94
+ "&&&[data-focus-visible], &&&[data-keyboard-modality]:has(input:focus)": {
95
+ focusRing: "outline",
106
96
  },
107
97
  "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
108
98
  },
@@ -181,10 +171,11 @@ export const select = defineSlotRecipe({
181
171
  cursor: "pointer",
182
172
  color: "inherit",
183
173
  outline: "none",
184
- transitionProperty: "background",
185
- transitionDuration: "ultra-fast",
186
- transitionTimingFunction: "ease-in",
174
+ // As the menu recipe: the highlight is focus indication (no
175
+ // transition — it snaps with the ring, which keyboard nav adds;
176
+ // inset, the rows being full-bleed).
187
177
  "&[data-focused]": { bg: "gray.100" },
178
+ "&[data-focus-visible]": { focusRing: "outlineInset" },
188
179
  "&[data-pressed]": { bg: "gray.200" },
189
180
  "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
190
181
  },
@@ -85,7 +85,7 @@ export const slider = defineSlotRecipe({
85
85
  },
86
86
  transform: "translate(-50%, -50%)",
87
87
  "&[data-focus-visible]": {
88
- focusShadow: "outline",
88
+ focusRing: "outline",
89
89
  },
90
90
  "&[data-disabled]": {
91
91
  bg: "gray.300",
@@ -42,7 +42,7 @@ export const switchRecipe = defineSlotRecipe({
42
42
  bg: "controlCheckedBg",
43
43
  },
44
44
  "&[data-focus-visible]": {
45
- focusShadow: "outline",
45
+ focusRing: "outline",
46
46
  },
47
47
  "&[data-disabled]": { opacity: 0.4 },
48
48
  // Forced-colors modes strip author backgrounds, flattening track and
@@ -67,6 +67,11 @@ export const toast = defineSlotRecipe({
67
67
  color: "white",
68
68
  maxW: "sm",
69
69
  pointerEvents: "auto",
70
+ // The card is focusable (RAC gives it tabindex), so it takes the
71
+ // house ring rather than the UA default. Ink, because the ring is
72
+ // drawn on the page, not the card — Toast.tsx tags the close button.
73
+ outline: "none",
74
+ _focusVisible: { focusRing: "outline" },
70
75
  },
71
76
  // The icon has to be inside the alert region to be announced with the
72
77
  // message, so this row — not the root — is what lays the two out.
@@ -115,7 +120,7 @@ export const toast = defineSlotRecipe({
115
120
  // Hover is a subtle dark overlay (blackAlpha), not a bright highlight.
116
121
  _hover: { bg: "blackAlpha.100" },
117
122
  _active: { bg: "blackAlpha.200" },
118
- _focusVisible: { focusShadow: "outline" },
123
+ _focusVisible: { focusRing: "outline" },
119
124
  },
120
125
  },
121
126
  variants: {
package/src/Toast.tsx CHANGED
@@ -23,6 +23,7 @@ import { toast as toastRecipe } from "styled-system/recipes";
23
23
  import { CloseIcon } from "./CloseIcon";
24
24
  import { Icon } from "./Icon";
25
25
  import { uiMessage } from "./messages";
26
+ import { darkSurface } from "./system";
26
27
 
27
28
  export type ToastStatus = "info" | "success" | "warning" | "error";
28
29
 
@@ -162,6 +163,11 @@ export const ToastProvider = () => {
162
163
  <RACButton
163
164
  slot="close"
164
165
  aria-label={intl.formatMessage(uiMessage("ui.close-action"))}
166
+ // The card is dark at every status, and no app can tag a
167
+ // surface the package ships. Tagged here, not on the card:
168
+ // the tag covers the element it sits on, whose own ring is
169
+ // drawn outside it, on the page.
170
+ {...darkSurface}
165
171
  className={slots.closeButton}
166
172
  >
167
173
  <CloseIcon />
@@ -56,7 +56,7 @@ const triggerStyle: SystemStyleObject = {
56
56
  lineHeight: "1",
57
57
  cursor: "pointer",
58
58
  borderRadius: "50%",
59
- _focusVisible: { focusShadow: "outline" },
59
+ _focusVisible: { focusRing: "outline" },
60
60
  };
61
61
 
62
62
  export interface TooltipButtonProps {
@@ -55,10 +55,11 @@ import { toast } from "./Toast.recipe";
55
55
  // - 350 is the decorative/state fill stop (~2.1:1): avatar discs, skeleton
56
56
  // pulse, pressed fills. Never text or boundaries.
57
57
  // - 400–900 are ink stops (outlines, placeholders, text) with a contrast
58
- // contract on white: 400 ≥ 3:1, the accessible form-outline stop
59
- // (WCAG 1.4.11); 500 4.5:1, text-safe secondary (placeholders, muted
60
- // icons). Presets may re-tint these only luminance-matched — the
61
- // contrast figures are the contract, hue is free.
58
+ // contract on white: 400 ≥ 3:1, the floor for boundaries that identify
59
+ // a control (checkbox-family boxes; fields rest lighter see the input
60
+ // recipe); 500 ≥ 4.5:1, text-safe secondary. Presets may re-tint these
61
+ // only luminance-matched — the contrast figures are the contract, hue
62
+ // is free.
62
63
  //
63
64
  // Override values, never names: raw var(--colors-gray-*) references and
64
65
  // paired private presets depend on the names, so a rename is a breaking
@@ -85,7 +86,8 @@ const gray = {
85
86
  /**
86
87
  * The base preset: the complete, working micro:bit design system. The base
87
88
  * token scales (base-tokens.ts), the micro:bit house style
88
- * (pill `radii.button`, `outline*` focus shadows, Helvetica fonts, the
89
+ * (pill `radii.button`, the `focusRing` utility/token pair, Helvetica
90
+ * fonts, the
89
91
  * `toolbar` button variant in Button.recipe.ts, the
90
92
  * `languageText`/`toast*Bg`/`statusBarBg` semantic tokens), the shared-ui
91
93
  * component recipes, the react-aria condition widening, the `globalCss`
@@ -166,11 +168,6 @@ export const basePreset = definePreset({
166
168
  },
167
169
  shadows: {
168
170
  ...shadows,
169
- // The 4px focus outline shadow, plus dark/light-surface
170
- // companions. Consumed via the `focusShadow` utility.
171
- outline: { value: "0 0 0 4px rgba(66, 153, 225, 0.6)" },
172
- outlineDark: { value: "0 0 0 4px rgba(0, 0, 0, 0.5)" },
173
- outlineLight: { value: "0 0 0 4px rgba(255, 255, 255, 0.8)" },
174
171
  },
175
172
  fonts: {
176
173
  // Helvetica heading/body (4/4 apps); a brand preset leaves these and
@@ -189,13 +186,28 @@ export const basePreset = definePreset({
189
186
  },
190
187
  semanticTokens: {
191
188
  colors: {
192
- // Checked/focus states of form controls: Checkbox/Switch/Radio
193
- // checked backgrounds and the Input/Select focus border. Semantic
194
- // so a brand can diverge them from its ramp — e.g. a light
195
- // brand.500 needing a darker 3:1 focus border.
189
+ // Checked states of form controls: Checkbox/Switch/Radio checked
190
+ // backgrounds. Semantic so a brand can diverge them from its ramp.
196
191
  controlCheckedBg: { value: "{colors.brand.500}" },
197
192
  controlCheckedHoverBg: { value: "{colors.brand.600}" },
198
- focusBorder: { value: "{colors.brand.500}" },
193
+ // Focused form-control border, any modality: the dark brand stop
194
+ // (all-ink read flat next to the ink ring). Flips white under the
195
+ // dark-surface tag, like `focusRing`.
196
+ // Both flips are condition objects, and a merge replaces a token
197
+ // value wholesale: an override must keep the `{ base, _onDark }`
198
+ // shape or silently lose the flip.
199
+ focusBorder: {
200
+ value: { base: "{colors.brand.600}", _onDark: "{colors.white}" },
201
+ },
202
+ // The focus ring colour: ink, or white inside `data-surface="dark"`
203
+ // (the `onDark` condition). The var inherits — tag the bar, cover
204
+ // its controls; portalled overlays escape with the DOM. Dark
205
+ // surfaces MUST tag (ink is near-invisible there). Opaque
206
+ // deliberately: translucent rings washed out (classroom #780).
207
+ // Both tag states: the Button "Variants" story.
208
+ focusRing: {
209
+ value: { base: "{colors.gray.900}", _onDark: "{colors.white}" },
210
+ },
199
211
  // Error/destructive ramp. Destructive button variants,
200
212
  // field error states and the error toast; the record* button
201
213
  // variants deliberately stay on red.* (recording vocabulary, not
@@ -287,6 +299,11 @@ export const basePreset = definePreset({
287
299
  html: {
288
300
  textRendering: "optimizeLegibility",
289
301
  touchAction: "manipulation",
302
+ // Alias preset-base's ring plumbing to our colour, so a stray use
303
+ // of its focusVisibleRing/focusRing* utilities renders in our
304
+ // ink rather than #005FCC. Still don't use them: un-gated focus
305
+ // selector, and this alias resolves on <html> (no tag awareness).
306
+ "--global-color-focus-ring": "var(--colors-focus-ring)",
290
307
  },
291
308
  body: {
292
309
  // No `position: relative` (Chakra had it): it breaks react-aria's
@@ -391,19 +408,51 @@ export const basePreset = definePreset({
391
408
  },
392
409
  utilities: {
393
410
  extend: {
394
- // The app-wide focus indicator, usually inside `_focusVisible`. Values
395
- // are the outline* shadow token names. The transparent outline is for
396
- // forced-colors modes, which strip box-shadows but recolour outlines to
397
- // a visible system colour. (Named to avoid preset-base's outline-based
398
- // `focusRing` utility, whose values would break this transform.)
399
- focusShadow: {
400
- className: "focus-shadow",
401
- values: ["outline", "outlineDark", "outlineLight"],
402
- transform: (value: string, { token }) => ({
403
- outline: "2px solid transparent",
404
- outlineOffset: "2px",
405
- boxShadow: token(`shadows.${value}`),
406
- }),
411
+ // The app-wide focus indicator, usually inside `_focusVisible`: a
412
+ // 2px `focusRing`-coloured outline at 2px offset the surface shows
413
+ // through the gap, and call sites never pick a ring per background.
414
+ // A real outline, so forced-colors modes keep a ring; longhands
415
+ // because Panda resolves tokens per-property. Thickness/contrast
416
+ // rationale: ui-private docs/a11y-positions.md. Shadows
417
+ // preset-base's `focusRing`: our transform replaces theirs, but the
418
+ // values arrays union, so its outside/inside/mixed/none typecheck
419
+ // here. `none` is honoured the alternative is a permanent un-gated
420
+ // ring; the other three fall through to the standard one. Don't use
421
+ // them.
422
+ focusRing: {
423
+ className: "focus-ring",
424
+ // `outlineInset` draws the ring just inside — for full-bleed rows
425
+ // whose outward ring would overhang their popover.
426
+ values: ["outline", "outlineInset"],
427
+ transform: (value: string, { token }) =>
428
+ value === "none"
429
+ ? { outlineStyle: "none" }
430
+ : {
431
+ outlineStyle: "solid",
432
+ outlineWidth: "2px",
433
+ outlineColor: token("colors.focusRing"),
434
+ outlineOffset: value === "outlineInset" ? "-2px" : "2px",
435
+ },
436
+ },
437
+ // preset-base's remaining ring plumbing sets --focus-ring-* custom
438
+ // properties only its own utilities read. Repointed at the outline
439
+ // longhands ours draws with, rather than left as no-ops that read
440
+ // like working knobs.
441
+ focusRingWidth: {
442
+ className: "focus-ring-w",
443
+ values: "borderWidths",
444
+ transform: (value: string) => ({ outlineWidth: value }),
445
+ },
446
+ focusRingOffset: {
447
+ className: "focus-ring-o",
448
+ values: "spacing",
449
+ transform: (value: string) => ({ outlineOffset: value }),
450
+ },
451
+ focusRingStyle: {
452
+ className: "focus-ring-s",
453
+ values: "borderStyles",
454
+ // `outlineStyle` is a keyword union, hence the cast.
455
+ transform: (value: string) => ({ outlineStyle: value as "solid" }),
407
456
  },
408
457
  },
409
458
  },
@@ -414,9 +463,18 @@ export const basePreset = definePreset({
414
463
  extend: {
415
464
  hover: "&:is(:hover, [data-hovered])",
416
465
  active: "&:is(:active, [data-pressed])",
417
- focusVisible: "&:is(:focus-visible, [data-focus-visible])",
466
+ // Native :focus-visible counts only on elements RAC doesn't manage:
467
+ // react-aria's modality tracking is stricter than the browser's
468
+ // (e.g. focus restored from a menu after mouse-only use).
469
+ focusVisible:
470
+ "&:is(:focus-visible:not([data-rac]), [data-focus-visible])",
418
471
  disabled:
419
472
  "&:is(:disabled, [disabled], [data-disabled], [aria-disabled=true])",
473
+ // A dark-by-design surface (spread the exported `darkSurface` onto
474
+ // the bar element); scopes the focusRing/focusBorder flips. Never
475
+ // theme-relative: a future dark mode flips untagged defaults via
476
+ // token conditions, not markup.
477
+ onDark: '[data-surface="dark"] &',
420
478
  // High-contrast/forced-palette modes (e.g. Windows High Contrast), which
421
479
  // strip author backgrounds and box-shadows.
422
480
  forcedColors: "@media (forced-colors: active)",
@@ -960,9 +960,6 @@ export const shadows = {
960
960
  "2xl": {
961
961
  value: "0 25px 50px -12px rgba(0, 0, 0, 0.25)",
962
962
  },
963
- outline: {
964
- value: "0 0 0 3px rgba(66, 153, 225, 0.6)",
965
- },
966
963
  inner: {
967
964
  value: "inset 0 2px 4px 0 rgba(0,0,0,0.06)",
968
965
  },
package/src/system.ts CHANGED
@@ -43,3 +43,15 @@ export {
43
43
  Wrap,
44
44
  styled,
45
45
  } from "styled-system/jsx";
46
+
47
+ /**
48
+ * Spread onto a surface element that is dark by design — a black toolbar,
49
+ * a coloured sidebar header — so focus indicators inside it flip to their
50
+ * on-dark colours: `<header {...darkSurface}>`.
51
+ *
52
+ * Tag a surface's own designed luminance, never anything theme-relative
53
+ * (a future dark mode flips untagged defaults via token conditions, not
54
+ * markup). One inherited data attribute (the `onDark` condition): tag the
55
+ * bar, cover its controls; portalled overlays escape with the DOM.
56
+ */
57
+ export const darkSurface = { "data-surface": "dark" } as const;