@microbit/ui 0.1.0-alpha.27 → 0.1.0-alpha.29
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 +44 -0
- package/package.json +3 -1
- package/src/Button.recipe.ts +58 -29
- package/src/Button.tsx +2 -1
- package/src/Checkbox.recipe.ts +1 -3
- package/src/CloseButton.tsx +1 -1
- package/src/ComboBox.tsx +6 -0
- package/src/GridList.recipe.ts +1 -1
- package/src/Input.recipe.ts +24 -30
- package/src/Input.tsx +6 -1
- package/src/Link.tsx +1 -1
- package/src/LinkButton.tsx +2 -1
- package/src/ListBox.recipe.ts +1 -1
- package/src/Menu.recipe.ts +4 -5
- package/src/Modal.tsx +1 -1
- package/src/NumberField.recipe.ts +16 -7
- package/src/Radio.recipe.ts +1 -3
- package/src/Select.recipe.ts +27 -36
- package/src/Slider.recipe.ts +1 -1
- package/src/Switch.recipe.ts +1 -1
- package/src/Toast.recipe.ts +6 -1
- package/src/Toast.tsx +6 -0
- package/src/TooltipButton.tsx +1 -1
- package/src/base-preset.ts +125 -35
- package/src/base-tokens.ts +0 -3
- package/src/system.ts +12 -0
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.
|
|
3
|
+
"version": "0.1.0-alpha.29",
|
|
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",
|
package/src/Button.recipe.ts
CHANGED
|
@@ -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,
|
|
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
|
|
@@ -19,11 +20,9 @@ const transitionCommon =
|
|
|
19
20
|
*
|
|
20
21
|
* A config recipe (not a component cva): styles land in the `recipes` layer so
|
|
21
22
|
* call sites can override with plain style props, and presets extend the
|
|
22
|
-
* variants.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* `led`/`record*`/`secondary-disabled`). Brand divergence within a variant is
|
|
26
|
-
* token-driven (see the `button.*` and `languageText` semantic tokens).
|
|
23
|
+
* variants. Colour reaches a variant either through the `button.*` semantic
|
|
24
|
+
* tokens (the app's button idiom) or through `tone`, never through a per-app
|
|
25
|
+
* fork of a shape.
|
|
27
26
|
*
|
|
28
27
|
* Registered in the base preset (base-preset.ts).
|
|
29
28
|
*/
|
|
@@ -46,7 +45,7 @@ export const button = defineRecipe({
|
|
|
46
45
|
verticalAlign: "middle",
|
|
47
46
|
outline: "none",
|
|
48
47
|
_focusVisible: {
|
|
49
|
-
|
|
48
|
+
focusRing: "outline",
|
|
50
49
|
},
|
|
51
50
|
_disabled: {
|
|
52
51
|
opacity: 0.4,
|
|
@@ -130,42 +129,72 @@ export const button = defineRecipe({
|
|
|
130
129
|
},
|
|
131
130
|
_active: { bg: "button.primaryActiveBg" },
|
|
132
131
|
},
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
132
|
+
// `solid`/`outline` are coloured by `tone`, and reference only the
|
|
133
|
+
// stops it guarantees (50/500/600/700). They are not `primary`/
|
|
134
|
+
// `secondary` in another colour: those follow the app's button idiom,
|
|
135
|
+
// which is black-on-white in half the family and so can't be a palette.
|
|
136
|
+
solid: {
|
|
137
|
+
color: "white",
|
|
138
|
+
bg: "colorPalette.500",
|
|
139
|
+
_hover: {
|
|
140
|
+
bg: "colorPalette.600",
|
|
141
|
+
_disabled: { bg: "colorPalette.500" },
|
|
142
|
+
},
|
|
143
|
+
_active: { bg: "colorPalette.700" },
|
|
144
|
+
},
|
|
145
|
+
// Border at 500 (a boundary needs only 3:1) so a `solid`/`outline`
|
|
146
|
+
// toggle pair shows the same edge either way; the label needs 600.
|
|
147
|
+
//
|
|
148
|
+
// Classroom restates two other outlines per call site — a neutral 1px
|
|
149
|
+
// grey one and a white-on-colour one. Promote either if a second
|
|
150
|
+
// consumer appears.
|
|
151
|
+
outline: {
|
|
140
152
|
borderWidth: "2px",
|
|
141
|
-
borderColor: "
|
|
142
|
-
color: "
|
|
153
|
+
borderColor: "colorPalette.500",
|
|
154
|
+
color: "colorPalette.600",
|
|
143
155
|
bg: "transparent",
|
|
144
|
-
_hover: { borderColor: "
|
|
145
|
-
_active: { bg: "
|
|
156
|
+
_hover: { borderColor: "colorPalette.600", color: "colorPalette.700" },
|
|
157
|
+
_active: { bg: "colorPalette.50" },
|
|
146
158
|
},
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
bg: "
|
|
154
|
-
_hover: { bg: "danger.600", _disabled: { bg: "danger.500" } },
|
|
155
|
-
_active: { bg: "danger.700" },
|
|
159
|
+
// Not a palette shape: a light fill under dark text is a different
|
|
160
|
+
// button from `solid`, and 350 exists in no other ramp.
|
|
161
|
+
neutral: {
|
|
162
|
+
color: "gray.800",
|
|
163
|
+
bg: "gray.100",
|
|
164
|
+
_hover: { bg: "gray.300", _disabled: { bg: "gray.100" } },
|
|
165
|
+
_active: { bg: "gray.350" },
|
|
156
166
|
},
|
|
157
167
|
// Family-wide variant (every censused app has toolbar-class buttons).
|
|
168
|
+
// No ring override: the bar decides, and a dark one must spread
|
|
169
|
+
// `darkSurface` — an app adopting this variant needs the tag with it.
|
|
158
170
|
toolbar: {
|
|
159
171
|
color: "black",
|
|
160
172
|
bg: "white",
|
|
161
173
|
_hover: { bg: "whiteAlpha.900", _disabled: { bg: "white" } },
|
|
162
174
|
_active: { bg: "whiteAlpha.800" },
|
|
163
|
-
_focusVisible: { focusShadow: "outlineDark" },
|
|
164
175
|
},
|
|
165
176
|
},
|
|
177
|
+
/**
|
|
178
|
+
* The palette behind `solid`/`outline`. An allowlist rather than Panda's
|
|
179
|
+
* open `colorPalette` prop, because a palette missing a stop a shape
|
|
180
|
+
* reads renders it as nothing at all, with no error anywhere
|
|
181
|
+
* (docs/hints.md): this is where a palette is vetted. A tone should
|
|
182
|
+
* alias a whole ramp, so it has nowhere to fall through. Apps may add
|
|
183
|
+
* their own.
|
|
184
|
+
*/
|
|
185
|
+
tone: {
|
|
186
|
+
brand: { colorPalette: "brand" },
|
|
187
|
+
// The destructive role, which a brand preset can re-point.
|
|
188
|
+
danger: { colorPalette: "danger" },
|
|
189
|
+
// Conventional red that isn't destructive (record buttons), so
|
|
190
|
+
// deliberately not following a brand's error colour.
|
|
191
|
+
red: { colorPalette: "red" },
|
|
192
|
+
},
|
|
166
193
|
},
|
|
167
194
|
defaultVariants: {
|
|
168
195
|
variant: "secondary",
|
|
169
196
|
size: "md",
|
|
197
|
+
// Never absent: a shape with no palette renders nothing.
|
|
198
|
+
tone: "brand",
|
|
170
199
|
},
|
|
171
200
|
});
|
package/src/Button.tsx
CHANGED
|
@@ -71,6 +71,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
71
71
|
{
|
|
72
72
|
variant,
|
|
73
73
|
size,
|
|
74
|
+
tone,
|
|
74
75
|
css: cssProp,
|
|
75
76
|
className,
|
|
76
77
|
leftIcon,
|
|
@@ -96,7 +97,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
96
97
|
<RACButton
|
|
97
98
|
ref={ref}
|
|
98
99
|
className={cx(
|
|
99
|
-
button({ variant, size }),
|
|
100
|
+
button({ variant, size, tone }),
|
|
100
101
|
cssProp ? css(cssProp) : undefined,
|
|
101
102
|
className,
|
|
102
103
|
)}
|
package/src/Checkbox.recipe.ts
CHANGED
|
@@ -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
|
-
|
|
69
|
+
focusRing: "outline",
|
|
72
70
|
},
|
|
73
71
|
},
|
|
74
72
|
icon: {
|
package/src/CloseButton.tsx
CHANGED
|
@@ -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: {
|
|
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,
|
package/src/GridList.recipe.ts
CHANGED
|
@@ -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]": {
|
|
53
|
+
"&[data-focus-visible]": { focusRing: "outline" },
|
|
54
54
|
"&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
|
|
55
55
|
},
|
|
56
56
|
},
|
package/src/Input.recipe.ts
CHANGED
|
@@ -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,
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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: "
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
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
|
-
|
|
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: {
|
|
21
|
+
_focusVisible: { focusRing: "outline" },
|
|
22
22
|
},
|
|
23
23
|
});
|
package/src/LinkButton.tsx
CHANGED
|
@@ -48,6 +48,7 @@ export const LinkButton = forwardRef<HTMLAnchorElement, LinkButtonProps>(
|
|
|
48
48
|
{
|
|
49
49
|
variant,
|
|
50
50
|
size,
|
|
51
|
+
tone,
|
|
51
52
|
css: cssProp,
|
|
52
53
|
className,
|
|
53
54
|
leftIcon,
|
|
@@ -61,7 +62,7 @@ export const LinkButton = forwardRef<HTMLAnchorElement, LinkButtonProps>(
|
|
|
61
62
|
<RACLink
|
|
62
63
|
ref={ref}
|
|
63
64
|
className={cx(
|
|
64
|
-
button({ variant, size }),
|
|
65
|
+
button({ variant, size, tone }),
|
|
65
66
|
css(linkReset, cssProp),
|
|
66
67
|
className,
|
|
67
68
|
)}
|
package/src/ListBox.recipe.ts
CHANGED
|
@@ -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]": {
|
|
39
|
+
"&[data-focus-visible]": { focusRing: "outline" },
|
|
40
40
|
"&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
|
|
41
41
|
},
|
|
42
42
|
},
|
package/src/Menu.recipe.ts
CHANGED
|
@@ -66,12 +66,11 @@ export const menu = defineSlotRecipe({
|
|
|
66
66
|
color: "inherit",
|
|
67
67
|
textDecoration: "none",
|
|
68
68
|
outline: "none",
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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: {
|
|
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% -
|
|
37
|
-
margin: "
|
|
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
|
|
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) -
|
|
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) -
|
|
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) -
|
|
96
|
+
borderStartEndRadius: "calc(token(radii.sm) - 2px)",
|
|
88
97
|
},
|
|
89
98
|
"&:last-child": {
|
|
90
|
-
borderEndEndRadius: "calc(token(radii.sm) -
|
|
99
|
+
borderEndEndRadius: "calc(token(radii.sm) - 2px)",
|
|
91
100
|
},
|
|
92
101
|
},
|
|
93
102
|
},
|
package/src/Radio.recipe.ts
CHANGED
|
@@ -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
|
-
|
|
79
|
+
focusRing: "outline",
|
|
82
80
|
},
|
|
83
81
|
},
|
|
84
82
|
label: {
|
package/src/Select.recipe.ts
CHANGED
|
@@ -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,
|
|
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: "
|
|
66
|
-
// As the input recipe
|
|
67
|
-
//
|
|
68
|
-
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
|
|
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
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
},
|
package/src/Slider.recipe.ts
CHANGED
package/src/Switch.recipe.ts
CHANGED
|
@@ -42,7 +42,7 @@ export const switchRecipe = defineSlotRecipe({
|
|
|
42
42
|
bg: "controlCheckedBg",
|
|
43
43
|
},
|
|
44
44
|
"&[data-focus-visible]": {
|
|
45
|
-
|
|
45
|
+
focusRing: "outline",
|
|
46
46
|
},
|
|
47
47
|
"&[data-disabled]": { opacity: 0.4 },
|
|
48
48
|
// Forced-colors modes strip author backgrounds, flattening track and
|
package/src/Toast.recipe.ts
CHANGED
|
@@ -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: {
|
|
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 />
|
package/src/TooltipButton.tsx
CHANGED
package/src/base-preset.ts
CHANGED
|
@@ -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
|
|
59
|
-
// (
|
|
60
|
-
//
|
|
61
|
-
// contrast figures are the contract, hue
|
|
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
|
|
@@ -82,10 +83,30 @@ const gray = {
|
|
|
82
83
|
900: { value: "#1a1a1a" },
|
|
83
84
|
};
|
|
84
85
|
|
|
86
|
+
// The family red, on the gray ramp's ladder — it is the conventional colour
|
|
87
|
+
// for errors and recording, not a brand colour, so it can be graded rather
|
|
88
|
+
// than negotiated. 400 was already exactly gray's 3:1 and stays verbatim,
|
|
89
|
+
// anchoring the hue and saturation the darker stops hold while their
|
|
90
|
+
// lightness solves for gray's ratio. The washes have no contract and keep
|
|
91
|
+
// their values.
|
|
92
|
+
const red = {
|
|
93
|
+
50: { value: "#FFF5F5" },
|
|
94
|
+
100: { value: "#FED7D7" },
|
|
95
|
+
200: { value: "#FEB2B2" },
|
|
96
|
+
300: { value: "#FC8181" },
|
|
97
|
+
400: { value: "#F56565" }, // 3.03:1
|
|
98
|
+
500: { value: "#e22b2b" }, // 4.55:1 — the white-text fill stop
|
|
99
|
+
600: { value: "#ac1818" },
|
|
100
|
+
700: { value: "#811212" },
|
|
101
|
+
800: { value: "#4f0b0b" },
|
|
102
|
+
900: { value: "#380808" },
|
|
103
|
+
};
|
|
104
|
+
|
|
85
105
|
/**
|
|
86
106
|
* The base preset: the complete, working micro:bit design system. The base
|
|
87
107
|
* token scales (base-tokens.ts), the micro:bit house style
|
|
88
|
-
* (pill `radii.button`, `
|
|
108
|
+
* (pill `radii.button`, the `focusRing` utility/token pair, Helvetica
|
|
109
|
+
* fonts, the
|
|
89
110
|
* `toolbar` button variant in Button.recipe.ts, the
|
|
90
111
|
* `languageText`/`toast*Bg`/`statusBarBg` semantic tokens), the shared-ui
|
|
91
112
|
* component recipes, the react-aria condition widening, the `globalCss`
|
|
@@ -139,6 +160,7 @@ export const basePreset = definePreset({
|
|
|
139
160
|
colors: {
|
|
140
161
|
...colors,
|
|
141
162
|
gray,
|
|
163
|
+
red,
|
|
142
164
|
// OSS default brand ramps (see the brand contract above). `brand`
|
|
143
165
|
// aliases the blue ramp; `brand2` is a frozen legacy alias of the
|
|
144
166
|
// slate gray in base-tokens, deliberately decoupled from the neutral
|
|
@@ -166,11 +188,6 @@ export const basePreset = definePreset({
|
|
|
166
188
|
},
|
|
167
189
|
shadows: {
|
|
168
190
|
...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
191
|
},
|
|
175
192
|
fonts: {
|
|
176
193
|
// Helvetica heading/body (4/4 apps); a brand preset leaves these and
|
|
@@ -189,23 +206,42 @@ export const basePreset = definePreset({
|
|
|
189
206
|
},
|
|
190
207
|
semanticTokens: {
|
|
191
208
|
colors: {
|
|
192
|
-
// Checked
|
|
193
|
-
//
|
|
194
|
-
// so a brand can diverge them from its ramp — e.g. a light
|
|
195
|
-
// brand.500 needing a darker 3:1 focus border.
|
|
209
|
+
// Checked states of form controls: Checkbox/Switch/Radio checked
|
|
210
|
+
// backgrounds. Semantic so a brand can diverge them from its ramp.
|
|
196
211
|
controlCheckedBg: { value: "{colors.brand.500}" },
|
|
197
212
|
controlCheckedHoverBg: { value: "{colors.brand.600}" },
|
|
198
|
-
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
213
|
+
// Focused form-control border, any modality: the dark brand stop
|
|
214
|
+
// (all-ink read flat next to the ink ring). Flips white under the
|
|
215
|
+
// dark-surface tag, like `focusRing`.
|
|
216
|
+
// Both flips are condition objects, and a merge replaces a token
|
|
217
|
+
// value wholesale: an override must keep the `{ base, _onDark }`
|
|
218
|
+
// shape or silently lose the flip.
|
|
219
|
+
focusBorder: {
|
|
220
|
+
value: { base: "{colors.brand.600}", _onDark: "{colors.white}" },
|
|
221
|
+
},
|
|
222
|
+
// The focus ring colour: ink, or white inside `data-surface="dark"`
|
|
223
|
+
// (the `onDark` condition). The var inherits — tag the bar, cover
|
|
224
|
+
// its controls; portalled overlays escape with the DOM. Dark
|
|
225
|
+
// surfaces MUST tag (ink is near-invisible there). Opaque
|
|
226
|
+
// deliberately: translucent rings washed out (classroom #780).
|
|
227
|
+
// Both tag states: the Button "Variants" story.
|
|
228
|
+
focusRing: {
|
|
229
|
+
value: { base: "{colors.gray.900}", _onDark: "{colors.white}" },
|
|
230
|
+
},
|
|
231
|
+
// Error/destructive ramp: field error states, the error toast, and
|
|
232
|
+
// the `danger` button tone. Aliased whole, not just at the stops in
|
|
233
|
+
// use, so a tone has nowhere to fall through (Button.recipe.ts).
|
|
203
234
|
danger: {
|
|
204
235
|
50: { value: "{colors.red.50}" },
|
|
205
236
|
100: { value: "{colors.red.100}" },
|
|
237
|
+
200: { value: "{colors.red.200}" },
|
|
238
|
+
300: { value: "{colors.red.300}" },
|
|
239
|
+
400: { value: "{colors.red.400}" },
|
|
206
240
|
500: { value: "{colors.red.500}" },
|
|
207
241
|
600: { value: "{colors.red.600}" },
|
|
208
242
|
700: { value: "{colors.red.700}" },
|
|
243
|
+
800: { value: "{colors.red.800}" },
|
|
244
|
+
900: { value: "{colors.red.900}" },
|
|
209
245
|
},
|
|
210
246
|
// The language-dialog cards' text colour (@microbit/ui-patterns'
|
|
211
247
|
// LanguageDialog) follows the primary interactive brand: every
|
|
@@ -247,7 +283,9 @@ export const basePreset = definePreset({
|
|
|
247
283
|
toastInfoBg: { value: "{colors.teal.800}" },
|
|
248
284
|
toastSuccessBg: { value: "{colors.teal.800}" },
|
|
249
285
|
toastWarningBg: { value: "{colors.teal.800}" },
|
|
250
|
-
|
|
286
|
+
// 500, the text-safe stop, rather than following the teal toasts'
|
|
287
|
+
// 800: white on it is 4.55:1 and an error toast should read as red.
|
|
288
|
+
toastErrorBg: { value: "{colors.danger.500}" },
|
|
251
289
|
// The native app's status-bar area colour, shared by the ActionBar
|
|
252
290
|
// and the full-size dialog's safe-area gradient.
|
|
253
291
|
statusBarBg: { value: "{colors.brand2.500}" },
|
|
@@ -287,6 +325,11 @@ export const basePreset = definePreset({
|
|
|
287
325
|
html: {
|
|
288
326
|
textRendering: "optimizeLegibility",
|
|
289
327
|
touchAction: "manipulation",
|
|
328
|
+
// Alias preset-base's ring plumbing to our colour, so a stray use
|
|
329
|
+
// of its focusVisibleRing/focusRing* utilities renders in our
|
|
330
|
+
// ink rather than #005FCC. Still don't use them: un-gated focus
|
|
331
|
+
// selector, and this alias resolves on <html> (no tag awareness).
|
|
332
|
+
"--global-color-focus-ring": "var(--colors-focus-ring)",
|
|
290
333
|
},
|
|
291
334
|
body: {
|
|
292
335
|
// No `position: relative` (Chakra had it): it breaks react-aria's
|
|
@@ -367,7 +410,13 @@ export const basePreset = definePreset({
|
|
|
367
410
|
// too — otherwise the class lands on the element with no rule behind it
|
|
368
411
|
// and the button silently falls back to the base size.
|
|
369
412
|
avatar: ["*"],
|
|
370
|
-
|
|
413
|
+
// `tone` generates as its own rule, not crossed with `variant`: it
|
|
414
|
+
// only assigns the palette custom properties a shape reads.
|
|
415
|
+
button: [
|
|
416
|
+
{ size: ["*"], responsive: true },
|
|
417
|
+
{ variant: ["*"] },
|
|
418
|
+
{ tone: ["*"] },
|
|
419
|
+
],
|
|
371
420
|
checkbox: ["*"],
|
|
372
421
|
heading: ["*"],
|
|
373
422
|
card: ["*"],
|
|
@@ -391,19 +440,51 @@ export const basePreset = definePreset({
|
|
|
391
440
|
},
|
|
392
441
|
utilities: {
|
|
393
442
|
extend: {
|
|
394
|
-
// The app-wide focus indicator, usually inside `_focusVisible
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
443
|
+
// The app-wide focus indicator, usually inside `_focusVisible`: a
|
|
444
|
+
// 2px `focusRing`-coloured outline at 2px offset — the surface shows
|
|
445
|
+
// through the gap, and call sites never pick a ring per background.
|
|
446
|
+
// A real outline, so forced-colors modes keep a ring; longhands
|
|
447
|
+
// because Panda resolves tokens per-property. Thickness/contrast
|
|
448
|
+
// rationale: ui-private docs/a11y-positions.md. Shadows
|
|
449
|
+
// preset-base's `focusRing`: our transform replaces theirs, but the
|
|
450
|
+
// values arrays union, so its outside/inside/mixed/none typecheck
|
|
451
|
+
// here. `none` is honoured — the alternative is a permanent un-gated
|
|
452
|
+
// ring; the other three fall through to the standard one. Don't use
|
|
453
|
+
// them.
|
|
454
|
+
focusRing: {
|
|
455
|
+
className: "focus-ring",
|
|
456
|
+
// `outlineInset` draws the ring just inside — for full-bleed rows
|
|
457
|
+
// whose outward ring would overhang their popover.
|
|
458
|
+
values: ["outline", "outlineInset"],
|
|
459
|
+
transform: (value: string, { token }) =>
|
|
460
|
+
value === "none"
|
|
461
|
+
? { outlineStyle: "none" }
|
|
462
|
+
: {
|
|
463
|
+
outlineStyle: "solid",
|
|
464
|
+
outlineWidth: "2px",
|
|
465
|
+
outlineColor: token("colors.focusRing"),
|
|
466
|
+
outlineOffset: value === "outlineInset" ? "-2px" : "2px",
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
// preset-base's remaining ring plumbing sets --focus-ring-* custom
|
|
470
|
+
// properties only its own utilities read. Repointed at the outline
|
|
471
|
+
// longhands ours draws with, rather than left as no-ops that read
|
|
472
|
+
// like working knobs.
|
|
473
|
+
focusRingWidth: {
|
|
474
|
+
className: "focus-ring-w",
|
|
475
|
+
values: "borderWidths",
|
|
476
|
+
transform: (value: string) => ({ outlineWidth: value }),
|
|
477
|
+
},
|
|
478
|
+
focusRingOffset: {
|
|
479
|
+
className: "focus-ring-o",
|
|
480
|
+
values: "spacing",
|
|
481
|
+
transform: (value: string) => ({ outlineOffset: value }),
|
|
482
|
+
},
|
|
483
|
+
focusRingStyle: {
|
|
484
|
+
className: "focus-ring-s",
|
|
485
|
+
values: "borderStyles",
|
|
486
|
+
// `outlineStyle` is a keyword union, hence the cast.
|
|
487
|
+
transform: (value: string) => ({ outlineStyle: value as "solid" }),
|
|
407
488
|
},
|
|
408
489
|
},
|
|
409
490
|
},
|
|
@@ -414,9 +495,18 @@ export const basePreset = definePreset({
|
|
|
414
495
|
extend: {
|
|
415
496
|
hover: "&:is(:hover, [data-hovered])",
|
|
416
497
|
active: "&:is(:active, [data-pressed])",
|
|
417
|
-
|
|
498
|
+
// Native :focus-visible counts only on elements RAC doesn't manage:
|
|
499
|
+
// react-aria's modality tracking is stricter than the browser's
|
|
500
|
+
// (e.g. focus restored from a menu after mouse-only use).
|
|
501
|
+
focusVisible:
|
|
502
|
+
"&:is(:focus-visible:not([data-rac]), [data-focus-visible])",
|
|
418
503
|
disabled:
|
|
419
504
|
"&:is(:disabled, [disabled], [data-disabled], [aria-disabled=true])",
|
|
505
|
+
// A dark-by-design surface (spread the exported `darkSurface` onto
|
|
506
|
+
// the bar element); scopes the focusRing/focusBorder flips. Never
|
|
507
|
+
// theme-relative: a future dark mode flips untagged defaults via
|
|
508
|
+
// token conditions, not markup.
|
|
509
|
+
onDark: '[data-surface="dark"] &',
|
|
420
510
|
// High-contrast/forced-palette modes (e.g. Windows High Contrast), which
|
|
421
511
|
// strip author backgrounds and box-shadows.
|
|
422
512
|
forcedColors: "@media (forced-colors: active)",
|
package/src/base-tokens.ts
CHANGED
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;
|