@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 +44 -0
- package/package.json +3 -1
- package/src/Button.recipe.ts +6 -4
- package/src/Checkbox.recipe.ts +1 -3
- package/src/CloseButton.tsx +1 -1
- package/src/ComboBox.tsx +6 -0
- package/src/Field.recipe.ts +4 -2
- 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/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 +87 -29
- 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.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",
|
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
|
|
@@ -46,7 +47,7 @@ export const button = defineRecipe({
|
|
|
46
47
|
verticalAlign: "middle",
|
|
47
48
|
outline: "none",
|
|
48
49
|
_focusVisible: {
|
|
49
|
-
|
|
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
|
},
|
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/Field.recipe.ts
CHANGED
|
@@ -52,7 +52,8 @@ export const field = defineSlotRecipe({
|
|
|
52
52
|
},
|
|
53
53
|
requiredIndicator: {
|
|
54
54
|
marginStart: "1",
|
|
55
|
-
|
|
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
|
-
|
|
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: {
|
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/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
|
|
@@ -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`, `
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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)",
|
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;
|