@microbit/ui 0.1.0-alpha.9 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/LICENSE.md +8 -0
  2. package/README.md +171 -20
  3. package/lang/ui.ar.json +62 -0
  4. package/lang/ui.ca.json +43 -3
  5. package/lang/ui.cy.json +62 -0
  6. package/lang/ui.de.json +44 -4
  7. package/lang/ui.en-us.json +40 -0
  8. package/lang/ui.en.json +40 -0
  9. package/lang/ui.es-es.json +43 -3
  10. package/lang/ui.fr.json +44 -4
  11. package/lang/ui.ga-ie.json +43 -3
  12. package/lang/ui.it.json +62 -0
  13. package/lang/ui.ja.json +44 -4
  14. package/lang/ui.ko.json +44 -4
  15. package/lang/ui.lo.json +62 -0
  16. package/lang/ui.lol.json +40 -0
  17. package/lang/ui.nl.json +44 -4
  18. package/lang/ui.pl.json +44 -4
  19. package/lang/ui.pt-br.json +44 -4
  20. package/lang/ui.vi.json +62 -0
  21. package/lang/ui.zh-cn.json +44 -4
  22. package/lang/ui.zh-tw.json +44 -4
  23. package/package.json +12 -10
  24. package/src/Avatar.recipe.ts +191 -0
  25. package/src/Avatar.tsx +278 -0
  26. package/src/Breadcrumb.recipe.ts +45 -0
  27. package/src/Breadcrumb.tsx +114 -0
  28. package/src/Button.recipe.ts +88 -50
  29. package/src/Button.tsx +67 -14
  30. package/src/ButtonGroup.tsx +37 -15
  31. package/src/Card.recipe.ts +1 -2
  32. package/src/Card.tsx +2 -1
  33. package/src/Checkbox.recipe.ts +13 -12
  34. package/src/Checkbox.tsx +103 -34
  35. package/src/CheckboxGroup.tsx +70 -0
  36. package/src/CloseButton.tsx +3 -3
  37. package/src/CloseIcon.tsx +6 -4
  38. package/src/Code.tsx +1 -1
  39. package/src/Collapse.tsx +13 -14
  40. package/src/ComboBox.tsx +246 -0
  41. package/src/Divider.tsx +40 -7
  42. package/src/Drawer.recipe.ts +21 -10
  43. package/src/Drawer.tsx +3 -4
  44. package/src/ExternalLink.tsx +43 -0
  45. package/src/Fade.tsx +18 -4
  46. package/src/Field.recipe.ts +114 -0
  47. package/src/Field.tsx +187 -0
  48. package/src/GridList.recipe.ts +57 -0
  49. package/src/GridList.tsx +81 -0
  50. package/src/Heading.recipe.ts +20 -1
  51. package/src/Heading.tsx +3 -3
  52. package/src/Icon.tsx +23 -7
  53. package/src/IconButton.tsx +8 -18
  54. package/src/Image.tsx +1 -1
  55. package/src/Input.recipe.ts +30 -26
  56. package/src/Input.tsx +16 -7
  57. package/src/InputGroup.tsx +9 -9
  58. package/src/Kbd.tsx +1 -1
  59. package/src/Link.tsx +3 -3
  60. package/src/LinkBox.tsx +2 -3
  61. package/src/LinkButton.tsx +14 -13
  62. package/src/List.tsx +5 -7
  63. package/src/ListBox.recipe.ts +43 -0
  64. package/src/ListBox.tsx +88 -0
  65. package/src/Menu.recipe.ts +22 -19
  66. package/src/Menu.tsx +54 -28
  67. package/src/Modal.recipe.ts +17 -9
  68. package/src/Modal.tsx +123 -24
  69. package/src/MoreMenuButton.tsx +63 -0
  70. package/src/NativeSelect.tsx +37 -14
  71. package/src/NativeSelectField.tsx +84 -0
  72. package/src/NumberField.recipe.ts +56 -15
  73. package/src/NumberField.tsx +74 -22
  74. package/src/PopoverArrow.tsx +1 -2
  75. package/src/ProgressBar.tsx +3 -5
  76. package/src/Radio.recipe.ts +13 -12
  77. package/src/Radio.tsx +2 -29
  78. package/src/RadioGroup.tsx +68 -0
  79. package/src/Select.recipe.ts +211 -0
  80. package/src/Select.tsx +194 -0
  81. package/src/SharedUIProvider.tsx +63 -7
  82. package/src/Skeleton.tsx +146 -0
  83. package/src/Slide.tsx +2 -2
  84. package/src/Slider.recipe.ts +27 -16
  85. package/src/Slider.tsx +20 -12
  86. package/src/Spinner.tsx +11 -9
  87. package/src/Svg.tsx +2 -3
  88. package/src/Switch.recipe.ts +40 -21
  89. package/src/Switch.tsx +35 -5
  90. package/src/Text.recipe.ts +26 -0
  91. package/src/Text.tsx +6 -2
  92. package/src/TextField.tsx +35 -38
  93. package/src/Toast.recipe.ts +52 -15
  94. package/src/Toast.tsx +147 -48
  95. package/src/Tooltip.recipe.ts +32 -0
  96. package/src/Tooltip.tsx +68 -28
  97. package/src/TooltipButton.tsx +229 -0
  98. package/src/UnmountCallback.tsx +2 -2
  99. package/src/VisuallyHidden.tsx +1 -1
  100. package/src/base-preset.ts +323 -84
  101. package/src/base-tokens.ts +0 -3
  102. package/src/button-icon.ts +4 -5
  103. package/src/data-attrs.ts +16 -0
  104. package/src/dense-preset.ts +107 -0
  105. package/src/hooks/useBreakpointValue.ts +4 -4
  106. package/src/hooks/useClipboard.ts +5 -6
  107. package/src/hooks/useDisclosure.ts +31 -0
  108. package/src/hooks/useMediaQuery.ts +3 -4
  109. package/src/hooks/usePrevious.ts +1 -4
  110. package/src/index.ts +19 -3
  111. package/src/rac-locale.ts +33 -0
  112. package/src/system.ts +22 -1
  113. package/src/TextField.recipe.ts +0 -54
@@ -8,57 +8,102 @@ import {
8
8
  Button as RACButton,
9
9
  Group as RACGroup,
10
10
  Input as RACInput,
11
- Label as RACLabel,
12
11
  NumberField as RACNumberField,
13
12
  NumberFieldProps as RACNumberFieldProps,
14
13
  } from "react-aria-components";
14
+ import { useIntl } from "react-intl";
15
15
  import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
16
16
  import { css, cx } from "styled-system/css";
17
- import { field, input, numberField } from "styled-system/recipes";
17
+ import {
18
+ field,
19
+ input,
20
+ InputVariantProps,
21
+ numberField,
22
+ } from "styled-system/recipes";
18
23
  import { SystemStyleObject } from "styled-system/types";
24
+ import {
25
+ FieldLabel,
26
+ FieldLayoutProps,
27
+ FieldSupport,
28
+ FieldSupportProps,
29
+ } from "./Field";
19
30
  import { Icon } from "./Icon";
31
+ import { uiMessage } from "./messages";
20
32
 
21
33
  export interface NumberFieldProps
22
- extends Omit<RACNumberFieldProps, "className" | "children" | "style"> {
34
+ extends Omit<RACNumberFieldProps, "className" | "children" | "style">,
35
+ InputVariantProps,
36
+ FieldSupportProps,
37
+ FieldLayoutProps {
23
38
  /** Visible label (optional; otherwise pass `aria-label`). */
24
39
  label?: ReactNode;
25
- /** Root style overrides (e.g. row layout for label-beside-field forms). */
26
- css?: SystemStyleObject;
40
+ /** Root style overrides (for a label-beside-field row, prefer
41
+ * `labelPosition="side"`). */
42
+ rootCss?: SystemStyleObject;
27
43
  /** Label style overrides. */
28
44
  labelCss?: SystemStyleObject;
29
45
  /** Group (input + steppers) style overrides — set `width` here. */
30
46
  groupCss?: SystemStyleObject;
31
- /** Input style overrides (e.g. a smaller size than the recipe's md). */
47
+ /** Input style overrides (for sizing, prefer the `size` prop). */
32
48
  inputCss?: SystemStyleObject;
33
49
  }
34
50
 
35
51
  /**
36
- * NumberField — react-aria-components <NumberField> styled like Chakra's
37
- * NumberInput (outline input + right-hand stepper column). The ref is
38
- * forwarded to the input element. Value clamping to min/maxValue is
39
- * handled by react-aria; onChange receives NaN when the field is emptied.
52
+ * NumberField — react-aria-components <NumberField>: an outline input with a
53
+ * right-hand stepper column. The ref is forwarded to the input element. Value
54
+ * clamping to min/maxValue is handled by react-aria; onChange receives NaN
55
+ * when the field is emptied.
40
56
  */
41
57
  export const NumberField = forwardRef<HTMLInputElement, NumberFieldProps>(
42
58
  function NumberField(
43
- { label, css: cssProp, labelCss, groupCss, inputCss, ...rest },
59
+ {
60
+ label,
61
+ helperText,
62
+ errorMessage,
63
+ helperTextCss,
64
+ rootCss,
65
+ labelCss,
66
+ groupCss,
67
+ inputCss,
68
+ labelPosition,
69
+ ...props
70
+ },
44
71
  ref,
45
72
  ) {
46
- const slots = numberField();
47
- const fieldSlots = field();
73
+ // As Input: forward every recipe variant group, not just `size`, so a
74
+ // preset that adds one keeps working.
75
+ const [variantProps, rest] = input.splitVariantProps(props);
76
+ const slots = numberField({ size: variantProps.size });
77
+ const fieldSlots = field({ size: variantProps.size, labelPosition });
78
+ const intl = useIntl();
79
+ // Fold the field's name into the stepper labels as react-aria does; the
80
+ // trim eats the leftover space when there is no usable name.
81
+ const fieldLabel =
82
+ rest["aria-label"] ?? (typeof label === "string" ? label : "");
48
83
  return (
49
84
  <RACNumberField
85
+ incrementAriaLabel={intl
86
+ .formatMessage(uiMessage("ui.numberfield-increase"), { fieldLabel })
87
+ .trim()}
88
+ decrementAriaLabel={intl
89
+ .formatMessage(uiMessage("ui.numberfield-decrease"), { fieldLabel })
90
+ .trim()}
50
91
  {...rest}
51
- className={cx(slots.root, cssProp ? css(cssProp) : undefined)}
92
+ className={cx(
93
+ fieldSlots.root,
94
+ slots.root,
95
+ rootCss ? css(rootCss) : undefined,
96
+ )}
52
97
  >
53
98
  {label != null && (
54
- <RACLabel
55
- className={cx(
56
- fieldSlots.label,
57
- labelCss ? css(labelCss) : undefined,
58
- )}
99
+ <FieldLabel
100
+ size={variantProps.size}
101
+ labelPosition={labelPosition}
102
+ isRequired={rest.isRequired}
103
+ css={labelCss}
59
104
  >
60
105
  {label}
61
- </RACLabel>
106
+ </FieldLabel>
62
107
  )}
63
108
  <RACGroup
64
109
  className={cx(slots.group, groupCss ? css(groupCss) : undefined)}
@@ -66,8 +111,9 @@ export const NumberField = forwardRef<HTMLInputElement, NumberFieldProps>(
66
111
  <RACInput
67
112
  ref={ref}
68
113
  className={cx(
69
- input(),
70
- // Room for the stepper column.
114
+ input(variantProps),
115
+ // Room for the stepper column (constant across sizes, as the
116
+ // stepper's width is).
71
117
  css({ paddingEnd: "6" }, inputCss),
72
118
  )}
73
119
  />
@@ -80,6 +126,12 @@ export const NumberField = forwardRef<HTMLInputElement, NumberFieldProps>(
80
126
  </RACButton>
81
127
  </div>
82
128
  </RACGroup>
129
+ <FieldSupport
130
+ helperText={helperText}
131
+ errorMessage={errorMessage}
132
+ helperTextCss={helperTextCss}
133
+ labelPosition={labelPosition}
134
+ />
83
135
  </RACNumberField>
84
136
  );
85
137
  },
@@ -38,8 +38,7 @@ const arrowBase = css({
38
38
  export interface PopoverArrowProps {
39
39
  /**
40
40
  * Base width of the visible triangle in px; it protrudes half this into
41
- * the gap. Defaults to Chakra's tooltip arrow proportions (an 8px square
42
- * rotated 45°).
41
+ * the gap. Defaults to the proportions of an 8px square rotated 45°.
43
42
  */
44
43
  size?: number;
45
44
  /** Styles merged after the base, e.g. `{ "& svg": { fill: "white" } }`. */
@@ -18,9 +18,8 @@ export interface ProgressBarProps {
18
18
  }
19
19
 
20
20
  /**
21
- * ProgressBar — react-aria-components <ProgressBar> styled like Chakra's md
22
- * Progress (12px gray.100 track). Set the fill colour per call site via
23
- * `barCss` (Chakra's colorScheme).
21
+ * ProgressBar — a styled react-aria-components <ProgressBar> (12px gray.100
22
+ * track). Set the fill colour per call site via `barCss`.
24
23
  */
25
24
  export const ProgressBar = ({
26
25
  value,
@@ -49,8 +48,7 @@ export const ProgressBar = ({
49
48
  className={css(
50
49
  {
51
50
  height: "100%",
52
- bg: "progressFilledTrack",
53
- transition: "width 0.2s",
51
+ bg: "brand.500",
54
52
  },
55
53
  barCss,
56
54
  )}
@@ -6,11 +6,10 @@
6
6
  import { defineSlotRecipe } from "@pandacss/dev";
7
7
 
8
8
  /**
9
- * Radio slot recipe — Chakra's radio with the default blue colorScheme
10
- * (light mode) and its sm/md/lg size scale: the checkbox control rounded
11
- * fully, with a 50% `currentColor` dot when selected instead of the check
12
- * glyph. As with the checkbox, `borderColor: inherit` picks up a
13
- * `borderColor` set on the root.
9
+ * Radio slot recipe — the checkbox control rounded fully, with a 50%
10
+ * `currentColor` dot when selected instead of the check glyph, across the
11
+ * same sm/md/lg size scale. As with the checkbox, `borderColor: inherit`
12
+ * picks up a `borderColor` set on the root.
14
13
  *
15
14
  * State styling keys off data attributes stamped by the shared-ui Radio
16
15
  * (react-aria provides the state via render props).
@@ -28,6 +27,10 @@ export const radio = defineSlotRecipe({
28
27
  verticalAlign: "top",
29
28
  cursor: "pointer",
30
29
  position: "relative",
30
+ // As the checkbox: the accessible outline stop (WCAG 1.4.11), on the
31
+ // root so the control's `borderColor: inherit` reads it and call
32
+ // sites can still tint at the root.
33
+ borderColor: "gray.400",
31
34
  "&[data-disabled]": { cursor: "not-allowed" },
32
35
  },
33
36
  control: {
@@ -35,8 +38,6 @@ export const radio = defineSlotRecipe({
35
38
  alignItems: "center",
36
39
  justifyContent: "center",
37
40
  flexShrink: 0,
38
- transitionProperty: "box-shadow",
39
- transitionDuration: "normal",
40
41
  borderWidth: "2px",
41
42
  borderStyle: "solid",
42
43
  borderRadius: "full",
@@ -51,7 +52,7 @@ export const radio = defineSlotRecipe({
51
52
  bg: "controlCheckedHoverBg",
52
53
  borderColor: "controlCheckedHoverBg",
53
54
  },
54
- // Chakra's radio dot.
55
+ // The radio dot.
55
56
  _before: {
56
57
  content: '""',
57
58
  display: "inline-block",
@@ -62,8 +63,8 @@ export const radio = defineSlotRecipe({
62
63
  bg: "currentColor",
63
64
  },
64
65
  },
65
- // Chakra's disabled greys; the selected block restates _hover so the
66
- // widened native-:hover condition can't re-tint a disabled control.
66
+ // Disabled greys; the selected block restates _hover so the widened
67
+ // native-:hover condition can't re-tint a disabled control.
67
68
  "&[data-disabled]": {
68
69
  bg: "gray.100",
69
70
  borderColor: "gray.100",
@@ -75,7 +76,7 @@ export const radio = defineSlotRecipe({
75
76
  _hover: { bg: "gray.200", borderColor: "gray.200" },
76
77
  },
77
78
  "&[data-focus-visible]": {
78
- focusShadow: "outline",
79
+ focusRing: "outline",
79
80
  },
80
81
  },
81
82
  label: {
@@ -85,7 +86,7 @@ export const radio = defineSlotRecipe({
85
86
  },
86
87
  },
87
88
  variants: {
88
- // Chakra's Radio size scale (the Checkbox control scale).
89
+ // The sm/md/lg size scale; control dimensions match the checkbox's.
89
90
  size: {
90
91
  sm: {
91
92
  control: { width: "3", height: "3" },
package/src/Radio.tsx CHANGED
@@ -7,38 +7,11 @@ import { ReactNode } from "react";
7
7
  import {
8
8
  Radio as RACRadio,
9
9
  RadioProps as RACRadioProps,
10
- RadioGroup as RACRadioGroup,
11
- RadioGroupProps as RACRadioGroupProps,
12
10
  } from "react-aria-components";
13
11
  import { css, cx } from "styled-system/css";
14
12
  import { radio, RadioVariantProps } from "styled-system/recipes";
15
13
  import { SystemStyleObject } from "styled-system/types";
16
14
 
17
- export interface RadioGroupProps
18
- extends Omit<RACRadioGroupProps, "className" | "style"> {
19
- /** Per-instance style overrides, merged after the recipe. */
20
- css?: SystemStyleObject;
21
- className?: string;
22
- }
23
-
24
- /**
25
- * RadioGroup — react-aria-components <RadioGroup> for a set of Radios. Carries
26
- * no styling of its own: compose with Stack for layout, as Chakra call sites
27
- * did.
28
- */
29
- export const RadioGroup = ({
30
- css: cssProp,
31
- className,
32
- ...rest
33
- }: RadioGroupProps) => {
34
- return (
35
- <RACRadioGroup
36
- className={cx(cssProp ? css(cssProp) : undefined, className)}
37
- {...rest}
38
- />
39
- );
40
- };
41
-
42
15
  export interface RadioProps
43
16
  extends Omit<RACRadioProps, "className" | "children" | "style">,
44
17
  RadioVariantProps {
@@ -49,8 +22,8 @@ export interface RadioProps
49
22
  }
50
23
 
51
24
  /**
52
- * Radio — react-aria-components <Radio> styled like Chakra's radio. Must be
53
- * rendered inside a RadioGroup. Children render as the label.
25
+ * Radio — a styled react-aria-components <Radio>. Must be rendered inside a
26
+ * RadioGroup. Children render as the label.
54
27
  */
55
28
  export const Radio = ({
56
29
  size,
@@ -0,0 +1,68 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { ReactNode } from "react";
7
+ import {
8
+ RadioGroup as RACRadioGroup,
9
+ RadioGroupProps as RACRadioGroupProps,
10
+ } from "react-aria-components";
11
+ import { css, cx } from "styled-system/css";
12
+ import { SystemStyleObject } from "styled-system/types";
13
+ import { FieldLabel, FieldSupport, FieldSupportProps } from "./Field";
14
+
15
+ export interface RadioGroupProps
16
+ extends Omit<RACRadioGroupProps, "className" | "style">,
17
+ FieldSupportProps {
18
+ /**
19
+ * Visible label for the group. Use `aria-label` instead where the design
20
+ * has none.
21
+ */
22
+ label?: ReactNode;
23
+ /** Label style overrides. */
24
+ labelCss?: SystemStyleObject;
25
+ /** Per-instance style overrides, merged after the recipe. */
26
+ css?: SystemStyleObject;
27
+ className?: string;
28
+ }
29
+
30
+ /**
31
+ * RadioGroup — react-aria-components <RadioGroup> for a set of Radios. Beyond
32
+ * the optional field chrome (label/helperText/errorMessage) it carries no
33
+ * styling of its own: compose with Stack for layout.
34
+ */
35
+ export const RadioGroup = ({
36
+ label,
37
+ labelCss,
38
+ helperText,
39
+ errorMessage,
40
+ helperTextCss,
41
+ css: cssProp,
42
+ className,
43
+ children,
44
+ ...rest
45
+ }: RadioGroupProps) => {
46
+ return (
47
+ <RACRadioGroup
48
+ className={cx(cssProp ? css(cssProp) : undefined, className)}
49
+ {...rest}
50
+ >
51
+ {(renderProps) => (
52
+ <>
53
+ {label != null && (
54
+ <FieldLabel isRequired={rest.isRequired} css={labelCss}>
55
+ {label}
56
+ </FieldLabel>
57
+ )}
58
+ {typeof children === "function" ? children(renderProps) : children}
59
+ <FieldSupport
60
+ helperText={helperText}
61
+ errorMessage={errorMessage}
62
+ helperTextCss={helperTextCss}
63
+ />
64
+ </>
65
+ )}
66
+ </RACRadioGroup>
67
+ );
68
+ };
@@ -0,0 +1,211 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { defineSlotRecipe } from "@pandacss/dev";
7
+
8
+ // The common transition-property list, inlined (Panda has no
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.
12
+ const transitionCommon =
13
+ "background-color, border-color, color, fill, stroke, opacity, transform";
14
+
15
+ /**
16
+ * Select slot recipe — the dropdown pair, shared by `Select` (a listbox behind
17
+ * a button) and `ComboBox` (a listbox behind a text input). One recipe because
18
+ * the two differ only in what the control is: keeping them together is what
19
+ * stops a searchable and a non-searchable picker drifting apart visually.
20
+ *
21
+ * `trigger` matches the `input` recipe's outline field so a select sits level
22
+ * with a TextField beside it; `content` matches the `menu` recipe's card so
23
+ * every dropdown surface in the family agrees.
24
+ *
25
+ * Apps restyle it through the `variant` group — classroom's `classroom`
26
+ * variant is the rounded pill its join form uses.
27
+ *
28
+ * The label is not a slot here: it comes from the `field` recipe, as every
29
+ * other labelled field's does. The only thing that costs is a per-`variant`
30
+ * label style — restyle the control and let the label match the family.
31
+ *
32
+ * Registered in the base preset (base-preset.ts).
33
+ */
34
+ export const select = defineSlotRecipe({
35
+ className: "select",
36
+ slots: [
37
+ "root",
38
+ "trigger",
39
+ "value",
40
+ "indicator",
41
+ "content",
42
+ "list",
43
+ "option",
44
+ "optionIndicator",
45
+ "empty",
46
+ ],
47
+ base: {
48
+ // No layout here: the component wears the `field` recipe's root alongside
49
+ // this slot, and that recipe is the single owner of field-root layout
50
+ // (else `labelPosition` would fight this slot over `flexDirection`). The
51
+ // slot stays for apps and variants to target.
52
+ root: {},
53
+ trigger: {
54
+ display: "flex",
55
+ alignItems: "center",
56
+ justifyContent: "space-between",
57
+ gap: "2",
58
+ width: "100%",
59
+ minWidth: 0,
60
+ outline: "none",
61
+ appearance: "none",
62
+ font: "inherit",
63
+ textAlign: "start",
64
+ cursor: "pointer",
65
+ transitionProperty: transitionCommon,
66
+ transitionDuration: "normal",
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",
71
+ bg: "white",
72
+ color: "inherit",
73
+ // As the input recipe, so mixed fields tint together on hover.
74
+ _hover: { borderColor: "gray.500" },
75
+ // `data-invalid` lands on the root — and, in a ComboBox, on the input —
76
+ // but never on the trigger: a RAC Button has no validity state, and our
77
+ // ComboBox control is a plain div. So it comes down from the parent.
78
+ // `> &` rather than a descendant selector, so an app's own invalid form
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] > &&&": {
86
+ borderColor: "danger.500",
87
+ },
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",
96
+ },
97
+ "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
98
+ },
99
+ // Whatever shows the current value: Select's SelectValue, ComboBox's
100
+ // input. One slot for both, so an app restyling the placeholder (say)
101
+ // does not have to know which kind of control it is looking at.
102
+ value: {
103
+ flex: "1",
104
+ minWidth: 0,
105
+ overflow: "hidden",
106
+ textOverflow: "ellipsis",
107
+ whiteSpace: "nowrap",
108
+ outline: "none",
109
+ bg: "transparent",
110
+ color: "inherit",
111
+ font: "inherit",
112
+ // RAC sets data-placeholder on SelectValue when nothing is chosen; the
113
+ // ComboBox input uses the real placeholder attribute.
114
+ "&[data-placeholder]": { color: "gray.500" },
115
+ _placeholder: { color: "gray.500" },
116
+ },
117
+ indicator: {
118
+ display: "inline-flex",
119
+ flexShrink: 0,
120
+ alignItems: "center",
121
+ justifyContent: "center",
122
+ fontSize: "1.25em",
123
+ color: "inherit",
124
+ // No pointer-events:none here: in a ComboBox this slot is the button
125
+ // that opens the list. Select's is an aria-hidden span inside the
126
+ // trigger, so it needs no help being inert.
127
+ background: "transparent",
128
+ border: "none",
129
+ cursor: "pointer",
130
+ // No focus styling, deliberately: react-aria keeps a ComboBox's toggle
131
+ // button out of the tab order (the input owns the keyboard), so a ring
132
+ // here would only ever be reachable programmatically, and would suggest
133
+ // the chevron is a tab stop. The whole control shows focus instead.
134
+ outline: "none",
135
+ },
136
+ content: {
137
+ // Line the card up with the control, as a select should. `Select` gets
138
+ // this from RAC, whose trigger is the button it measures; `ComboBox`
139
+ // measures its own control and sets the width inline, because RAC's var
140
+ // is the *input's* width there.
141
+ minWidth: "var(--trigger-width)",
142
+ display: "flex",
143
+ flexDirection: "column",
144
+ bg: "white",
145
+ color: "inherit",
146
+ py: "2",
147
+ zIndex: "popover",
148
+ borderRadius: "md",
149
+ borderWidth: "1px",
150
+ borderColor: "gray.200",
151
+ boxShadow: "sm",
152
+ // Matches the menu recipe's fade/scale.
153
+ transformOrigin: "top",
154
+ opacity: 1,
155
+ transform: "scale(1)",
156
+ transition: "opacity 0.1s ease-out, transform 0.1s ease-out",
157
+ "&[data-entering]": { opacity: 0, transform: "scale(0.95)" },
158
+ "&[data-exiting]": { opacity: 0, transform: "scale(0.95)" },
159
+ _motionReduce: { transition: "none" },
160
+ },
161
+ list: {
162
+ outline: "none",
163
+ overflowY: "auto",
164
+ },
165
+ option: {
166
+ display: "flex",
167
+ alignItems: "center",
168
+ gap: "2",
169
+ py: "1.5",
170
+ px: "3",
171
+ cursor: "pointer",
172
+ color: "inherit",
173
+ outline: "none",
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).
177
+ "&[data-focused]": { bg: "gray.100" },
178
+ "&[data-focus-visible]": { focusRing: "outlineInset" },
179
+ "&[data-pressed]": { bg: "gray.200" },
180
+ "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
181
+ },
182
+ optionIndicator: {
183
+ display: "inline-flex",
184
+ flexShrink: 0,
185
+ alignItems: "center",
186
+ justifyContent: "center",
187
+ marginStart: "auto",
188
+ fontSize: "0.8em",
189
+ opacity: 0,
190
+ "[data-selected] &": { opacity: 1 },
191
+ },
192
+ empty: {
193
+ px: "3",
194
+ py: "2",
195
+ color: "gray.600",
196
+ },
197
+ },
198
+ variants: {
199
+ // The `input` recipe's size ladder, step for step, so a Select sits level
200
+ // with a TextField beside it at every size. The indicator is em-sized and
201
+ // scales free; the dropdown card keeps one density across sizes.
202
+ size: {
203
+ lg: { trigger: { fontSize: "lg", px: "4", h: "12", borderRadius: "md" } },
204
+ md: { trigger: { fontSize: "md", px: "4", h: "10", borderRadius: "md" } },
205
+ sm: { trigger: { fontSize: "sm", px: "3", h: "8", borderRadius: "sm" } },
206
+ },
207
+ },
208
+ defaultVariants: {
209
+ size: "md",
210
+ },
211
+ });