@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
package/src/Select.tsx ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { createContext, ReactNode, useContext } from "react";
7
+ import {
8
+ Button as RACButton,
9
+ ListBox as RACListBox,
10
+ ListBoxItem as RACListBoxItem,
11
+ ListBoxItemProps as RACListBoxItemProps,
12
+ Popover,
13
+ PopoverProps,
14
+ Select as RACSelect,
15
+ SelectProps as RACSelectProps,
16
+ SelectValue,
17
+ } from "react-aria-components";
18
+ import { useIntl } from "react-intl";
19
+ import { RiArrowDownSLine } from "react-icons/ri";
20
+ import { css, cx } from "styled-system/css";
21
+ import { field, select, SelectVariantProps } from "styled-system/recipes";
22
+ import { SystemStyleObject } from "styled-system/types";
23
+ import {
24
+ FieldLabel,
25
+ FieldLayoutProps,
26
+ FieldSupport,
27
+ FieldSupportProps,
28
+ } from "./Field";
29
+ import { Icon } from "./Icon";
30
+ import { uiMessage } from "./messages";
31
+
32
+ export type SelectSlots = ReturnType<typeof select>;
33
+
34
+ // Options are children, so they can't see the variant their Select was given.
35
+ // The parent hands its resolved slots down, as Modal does for its own slots.
36
+ const SlotContext = createContext<SelectSlots>(select({}));
37
+
38
+ export const useSelectSlots = () => useContext(SlotContext);
39
+
40
+ export const SelectSlotProvider = SlotContext.Provider;
41
+
42
+ export interface SelectProps<T extends object>
43
+ extends Omit<
44
+ RACSelectProps<T>,
45
+ "className" | "children" | "style" | "placeholder"
46
+ >,
47
+ SelectVariantProps,
48
+ FieldSupportProps,
49
+ FieldLayoutProps {
50
+ /** Visible label. Use `aria-label` instead where the design has none. */
51
+ label?: ReactNode;
52
+ /** Label style overrides. */
53
+ labelCss?: SystemStyleObject;
54
+ /**
55
+ * Shown in the trigger while nothing is chosen. Defaults to a translated
56
+ * "Select an item".
57
+ */
58
+ placeholder?: string;
59
+ /** `SelectOption`s. */
60
+ children: ReactNode;
61
+ /**
62
+ * Replaces the chevron; `null` removes it. Rarely right on a Select — the
63
+ * chevron is the only thing marking its trigger as a dropdown rather than
64
+ * a label, where a ComboBox's text input speaks for itself (which is why
65
+ * classroom's chevron-less autocomplete is a ComboBox).
66
+ */
67
+ indicator?: ReactNode | null;
68
+ /** Placement of the dropdown relative to the trigger. */
69
+ placement?: PopoverProps["placement"];
70
+ /**
71
+ * Cap the dropdown's height. A prop rather than a `contentCss` rule because
72
+ * RAC writes its own max-height inline while positioning, which beats any
73
+ * class.
74
+ */
75
+ maxHeight?: number;
76
+ /** Per-instance overrides for the trigger (the button the value sits in). */
77
+ triggerCss?: SystemStyleObject;
78
+ /** Per-instance overrides for the dropdown card. */
79
+ contentCss?: SystemStyleObject;
80
+ className?: string;
81
+ }
82
+
83
+ /**
84
+ * Select — a listbox behind a button, for choosing one of a known set. Use
85
+ * ComboBox where the user should be able to type to filter.
86
+ */
87
+ export const Select = <T extends object>({
88
+ label,
89
+ labelCss,
90
+ placeholder,
91
+ children,
92
+ indicator,
93
+ placement = "bottom start",
94
+ maxHeight,
95
+ helperText,
96
+ errorMessage,
97
+ helperTextCss,
98
+ labelPosition,
99
+ triggerCss,
100
+ contentCss,
101
+ className,
102
+ ...props
103
+ }: SelectProps<T>) => {
104
+ // splitVariantProps, not a hand-picked list: an app preset can add variant
105
+ // groups to the recipe and they have to reach it.
106
+ const [variantProps, rest] = select.splitVariantProps(props);
107
+ const intl = useIntl();
108
+ const slots = select(variantProps);
109
+ const fieldSlots = field({ size: variantProps.size, labelPosition });
110
+ return (
111
+ <SelectSlotProvider value={slots}>
112
+ <RACSelect
113
+ {...(rest as RACSelectProps<T>)}
114
+ className={cx(fieldSlots.root, slots.root, className)}
115
+ >
116
+ {label != null && (
117
+ <FieldLabel
118
+ size={variantProps.size}
119
+ labelPosition={labelPosition}
120
+ isRequired={props.isRequired}
121
+ css={labelCss}
122
+ >
123
+ {label}
124
+ </FieldLabel>
125
+ )}
126
+ <RACButton
127
+ className={cx(
128
+ slots.trigger,
129
+ triggerCss ? css(triggerCss) : undefined,
130
+ )}
131
+ >
132
+ <SelectValue className={slots.value}>
133
+ {({ isPlaceholder, defaultChildren }) =>
134
+ isPlaceholder
135
+ ? placeholder ??
136
+ intl.formatMessage(uiMessage("ui.select-placeholder"))
137
+ : defaultChildren
138
+ }
139
+ </SelectValue>
140
+ {indicator !== null && (
141
+ <span className={slots.indicator} aria-hidden>
142
+ {indicator ?? <Icon as={RiArrowDownSLine} />}
143
+ </span>
144
+ )}
145
+ </RACButton>
146
+ <Popover
147
+ placement={placement}
148
+ maxHeight={maxHeight}
149
+ className={cx(
150
+ slots.content,
151
+ contentCss ? css(contentCss) : undefined,
152
+ )}
153
+ >
154
+ <RACListBox className={slots.list}>{children}</RACListBox>
155
+ </Popover>
156
+ <FieldSupport
157
+ helperText={helperText}
158
+ errorMessage={errorMessage}
159
+ helperTextCss={helperTextCss}
160
+ labelPosition={labelPosition}
161
+ />
162
+ </RACSelect>
163
+ </SelectSlotProvider>
164
+ );
165
+ };
166
+
167
+ export interface SelectOptionProps
168
+ extends Omit<RACListBoxItemProps, "className" | "children" | "style"> {
169
+ children?: ReactNode;
170
+ css?: SystemStyleObject;
171
+ className?: string;
172
+ }
173
+
174
+ /** A row in a `Select` or `ComboBox` list. */
175
+ export const SelectOption = ({
176
+ children,
177
+ css: cssProp,
178
+ className,
179
+ ...rest
180
+ }: SelectOptionProps) => {
181
+ const slots = useSelectSlots();
182
+ return (
183
+ <RACListBoxItem
184
+ {...rest}
185
+ className={cx(
186
+ slots.option,
187
+ cssProp ? css(cssProp) : undefined,
188
+ className,
189
+ )}
190
+ >
191
+ {children}
192
+ </RACListBoxItem>
193
+ );
194
+ };
@@ -3,7 +3,17 @@
3
3
  *
4
4
  * SPDX-License-Identifier: MIT
5
5
  */
6
- import { createContext, ReactNode, useContext, useMemo } from "react";
6
+ import {
7
+ createContext,
8
+ ReactNode,
9
+ useContext,
10
+ useEffect,
11
+ useMemo,
12
+ } from "react";
13
+ import { isRTL } from "react-aria";
14
+ import { I18nProvider } from "react-aria-components";
15
+ import { IntlContext } from "react-intl";
16
+ import { racLocale } from "./rac-locale";
7
17
 
8
18
  /**
9
19
  * Registers the close function of the currently open dismissable overlay, or
@@ -21,27 +31,73 @@ const SharedUIContext = createContext<SharedUIContextValue | null>(null);
21
31
 
22
32
  export interface SharedUIProviderProps {
23
33
  overlayCloseRegistrar?: OverlayCloseRegistrar;
34
+ /**
35
+ * Locale for react-aria's own built-in strings. Defaults to the surrounding
36
+ * IntlProvider's locale, which is what apps want: pass this only where the
37
+ * two must differ.
38
+ */
39
+ locale?: string;
40
+ /**
41
+ * Keeps `<html lang>` and `<html dir>` in step with the locale, so assistive
42
+ * tech announces the page in the app's language and the layout runs the
43
+ * right way round. On by default; set false where the app doesn't own the
44
+ * document it's mounted in (an embedded widget), or when mounting more than
45
+ * one provider per page. The static `lang="en"` in index.html remains the
46
+ * pre-hydration default.
47
+ *
48
+ * Turning it off in an RTL locale means the app must set `dir` itself above
49
+ * the components, or the UI half-mirrors: react-aria mirrors from the locale
50
+ * in JS whatever the DOM says, while logical properties and `_rtl` rules
51
+ * read `dir` (see the README's RTL note).
52
+ *
53
+ * Correct only when the IntlProvider locale is the language the app
54
+ * actually renders in — an app falling back wholesale to its English
55
+ * catalog should claim `en` there, not the user's language setting (see
56
+ * the README's react-intl step).
57
+ */
58
+ setDocumentLang?: boolean;
24
59
  children: ReactNode;
25
60
  }
26
61
 
27
62
  /**
28
- * SharedUIProvider — the app-side installation point for optional shared-ui
29
- * integrations. Currently that is only the overlay-close registrar, so apps
30
- * without one can omit the provider entirely. Localized strings come from
31
- * react-intl: an IntlProvider must be mounted above shared-ui components
32
- * (see the package README for merging this package's message catalogs).
63
+ * SharedUIProvider — the app-side installation point for shared-ui
64
+ * integrations: the optional overlay-close registrar, and the locale for
65
+ * react-aria's built-in strings.
66
+ *
67
+ * This package's own strings come from react-intl, so an IntlProvider must be
68
+ * mounted above shared-ui components (see the package README). react-aria
69
+ * translates its built-in strings itself, from its own bundled catalogs, and
70
+ * without this provider it picks the locale from the browser rather than from
71
+ * the app's language setting — mount it inside the IntlProvider so the two
72
+ * agree.
33
73
  */
34
74
  export const SharedUIProvider = ({
35
75
  overlayCloseRegistrar,
76
+ locale,
77
+ setDocumentLang = true,
36
78
  children,
37
79
  }: SharedUIProviderProps) => {
80
+ // Read the context rather than calling useIntl(), which throws when there is
81
+ // no IntlProvider: react-aria falls back to the browser locale, as before.
82
+ const intlLocale = useContext(IntlContext)?.locale;
83
+ const appLocale = locale ?? intlLocale;
84
+ // The tag react-aria gets, so the document's direction and the components'
85
+ // cannot disagree; also the only form isRTL can be handed safely, as it
86
+ // throws on a malformed tag.
87
+ const sanitizedLocale = racLocale(appLocale);
88
+ useEffect(() => {
89
+ if (setDocumentLang && appLocale && sanitizedLocale) {
90
+ document.documentElement.lang = appLocale;
91
+ document.documentElement.dir = isRTL(sanitizedLocale) ? "rtl" : "ltr";
92
+ }
93
+ }, [setDocumentLang, appLocale, sanitizedLocale]);
38
94
  const value = useMemo(
39
95
  () => ({ overlayCloseRegistrar }),
40
96
  [overlayCloseRegistrar],
41
97
  );
42
98
  return (
43
99
  <SharedUIContext.Provider value={value}>
44
- {children}
100
+ <I18nProvider locale={sanitizedLocale}>{children}</I18nProvider>
45
101
  </SharedUIContext.Provider>
46
102
  );
47
103
  };
@@ -0,0 +1,146 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { HTMLAttributes, ReactNode } from "react";
7
+ import { css, cx } from "styled-system/css";
8
+ import { SystemStyleObject } from "styled-system/types";
9
+
10
+ /**
11
+ * The placeholder block, as an object rather than a precomputed class so a
12
+ * caller's `css` is merged into one `css()` call and its overrides win.
13
+ *
14
+ * The colours flow through a pair of custom properties, so a call site can
15
+ * retint one skeleton without knowing how the animation works.
16
+ */
17
+ const skeletonBase: SystemStyleObject = {
18
+ "--skeleton-start-color": "token(colors.gray.100)",
19
+ // gray.350, the decorative-fill stop, as the Avatar default.
20
+ "--skeleton-end-color": "token(colors.gray.350)",
21
+ background: "var(--skeleton-start-color)",
22
+ borderColor: "var(--skeleton-end-color)",
23
+ opacity: 0.7,
24
+ borderRadius: "sm",
25
+ boxShadow: "none",
26
+ backgroundClip: "padding-box",
27
+ cursor: "default",
28
+ color: "transparent",
29
+ pointerEvents: "none",
30
+ userSelect: "none",
31
+ // The content is hidden rather than unmounted, so a skeleton sized from
32
+ // real children keeps their dimensions.
33
+ "&::before, &::after, *": { visibility: "hidden" },
34
+ animation:
35
+ "skeletonFade var(--skeleton-speed, 0.8s) linear infinite alternate",
36
+ _motionReduce: { animation: "none" },
37
+ };
38
+
39
+ export interface SkeletonProps
40
+ extends Omit<HTMLAttributes<HTMLDivElement>, "color"> {
41
+ /** Show the children instead of the placeholder. */
42
+ isLoaded?: boolean;
43
+ /** Seconds per pulse (default 0.8). */
44
+ speed?: number;
45
+ children?: ReactNode;
46
+ /** Per-instance style overrides, merged after the base. */
47
+ css?: SystemStyleObject;
48
+ className?: string;
49
+ }
50
+
51
+ /**
52
+ * Skeleton — a loading placeholder: a block pulsing between two greys until
53
+ * its content is ready.
54
+ *
55
+ * Content simply appears when `isLoaded` turns true; wrap in `Fade` where a
56
+ * transition matters.
57
+ */
58
+ export const Skeleton = ({
59
+ isLoaded,
60
+ speed,
61
+ children,
62
+ css: cssProp,
63
+ className,
64
+ style,
65
+ ...rest
66
+ }: SkeletonProps) => {
67
+ if (isLoaded) {
68
+ return (
69
+ <div
70
+ {...rest}
71
+ style={style}
72
+ className={cx(cssProp ? css(cssProp) : undefined, className)}
73
+ >
74
+ {children}
75
+ </div>
76
+ );
77
+ }
78
+ return (
79
+ <div
80
+ {...rest}
81
+ style={
82
+ speed === undefined
83
+ ? style
84
+ : ({ ...style, "--skeleton-speed": `${speed}s` } as typeof style)
85
+ }
86
+ className={cx(css({ ...skeletonBase, ...cssProp }), className)}
87
+ >
88
+ {children}
89
+ </div>
90
+ );
91
+ };
92
+
93
+ export interface SkeletonTextProps extends SkeletonProps {
94
+ /** How many lines to draw (default 3). */
95
+ noOfLines?: number;
96
+ /** Gap between the lines. Any CSS length. */
97
+ spacing?: string;
98
+ /** Height of each line. Any CSS length. */
99
+ skeletonHeight?: string;
100
+ }
101
+
102
+ /**
103
+ * SkeletonText — a paragraph-shaped `Skeleton`: evenly spaced lines, the last
104
+ * one short.
105
+ */
106
+ export const SkeletonText = ({
107
+ noOfLines = 3,
108
+ spacing = "0.5rem",
109
+ skeletonHeight = "0.5rem",
110
+ isLoaded,
111
+ speed,
112
+ children,
113
+ css: cssProp,
114
+ className,
115
+ ...rest
116
+ }: SkeletonTextProps) => {
117
+ if (isLoaded) {
118
+ return (
119
+ <div
120
+ {...rest}
121
+ className={cx(cssProp ? css(cssProp) : undefined, className)}
122
+ >
123
+ {children}
124
+ </div>
125
+ );
126
+ }
127
+ return (
128
+ <div
129
+ {...rest}
130
+ className={cx(cssProp ? css(cssProp) : undefined, className)}
131
+ >
132
+ {Array.from({ length: noOfLines }, (_, index) => (
133
+ <Skeleton
134
+ key={index}
135
+ speed={speed}
136
+ style={{
137
+ height: skeletonHeight,
138
+ // The last line is drawn at 80%, with no gap after it.
139
+ width: noOfLines > 1 && index === noOfLines - 1 ? "80%" : "100%",
140
+ marginBottom: index === noOfLines - 1 ? "0" : spacing,
141
+ }}
142
+ />
143
+ ))}
144
+ </div>
145
+ );
146
+ };
package/src/Slide.tsx CHANGED
@@ -17,8 +17,8 @@ export interface SlideProps {
17
17
  }
18
18
 
19
19
  /**
20
- * Slide — Chakra's Slide transition, bottom edge only (add other directions
21
- * when a use appears): a full-width panel pinned to the viewport bottom,
20
+ * Slide — a slide-in transition, bottom edge only (add other directions when
21
+ * a use appears): a full-width panel pinned to the viewport bottom,
22
22
  * translated offscreen when closed. Content stays mounted throughout, so
23
23
  * closing doesn't drop in-flight state and the exit animates.
24
24
  */
@@ -6,14 +6,18 @@
6
6
  import { defineSlotRecipe } from "@pandacss/dev";
7
7
 
8
8
  /**
9
- * Slider slot recipe — Chakra's horizontal md slider (14px thumb, 4px
10
- * gray.200 track, blue filled track; light mode). Consumed by the shared-ui
11
- * Slider, which maps the slots onto react-aria-components'
12
- * Slider/SliderTrack/SliderThumb.
9
+ * Slider slot recipe — a horizontal slider (3.5-token thumb, 1-token gray.200
10
+ * track, blue filled track). Consumed by the shared-ui Slider, which maps the
11
+ * slots onto react-aria-components' Slider/SliderTrack/SliderThumb.
13
12
  *
14
- * The `mark` slot is hidden until the slider has focus (Chakra call sites did
15
- * this with `_focusWithin` + a class); the reveal lives here so call sites
16
- * only style the mark's look.
13
+ * Geometry: the root is thumb-height with NO horizontal padding, the track is
14
+ * absolutely centred inside it at full width, and the thumb overhangs the
15
+ * track ends at 0%/100%. Call sites position overlays (marks, value labels)
16
+ * against that root box. Sizes are tokens, not px, so apps with resized
17
+ * scales (e.g. python-editor's ×0.88) keep proportional geometry.
18
+ *
19
+ * The `mark` slot is hidden until the slider has focus; the reveal lives here
20
+ * so call sites only style the mark's look.
17
21
  *
18
22
  * Registered in the base preset (base-preset.ts). No variants, so it
19
23
  * needs no `staticCss` entry.
@@ -26,9 +30,9 @@ export const slider = defineSlotRecipe({
26
30
  position: "relative",
27
31
  display: "block",
28
32
  width: "100%",
33
+ // Thumb-height; the track centres inside.
34
+ height: "3.5",
29
35
  touchAction: "none",
30
- // Half the thumb so it doesn't overflow the ends (Chakra container px).
31
- px: "calc(14px / 2)",
32
36
  "&:focus-within [data-part='mark']": {
33
37
  display: "block",
34
38
  },
@@ -38,8 +42,12 @@ export const slider = defineSlotRecipe({
38
42
  },
39
43
  },
40
44
  track: {
41
- position: "relative",
42
- height: "4px",
45
+ position: "absolute",
46
+ top: "50%",
47
+ transform: "translateY(-50%)",
48
+ left: 0,
49
+ width: "100%",
50
+ height: "1",
43
51
  overflow: "hidden",
44
52
  borderRadius: "sm",
45
53
  bg: "gray.200",
@@ -52,15 +60,18 @@ export const slider = defineSlotRecipe({
52
60
  filledTrack: {
53
61
  position: "absolute",
54
62
  top: 0,
55
- left: 0,
63
+ // Logical, unlike the thumb: the fill grows from the track's start, so
64
+ // in RTL it fills from the right and the two still meet.
65
+ insetStart: 0,
56
66
  height: "100%",
57
- bg: "sliderFilledTrack",
67
+ // Fill colour is set per call site via `filledTrackCss`.
68
+ bg: "brand.500",
58
69
  },
59
70
  thumb: {
60
71
  position: "absolute",
61
72
  top: "50%",
62
- width: "14px",
63
- height: "14px",
73
+ width: "3.5",
74
+ height: "3.5",
64
75
  display: "flex",
65
76
  alignItems: "center",
66
77
  justifyContent: "center",
@@ -76,7 +87,7 @@ export const slider = defineSlotRecipe({
76
87
  },
77
88
  transform: "translate(-50%, -50%)",
78
89
  "&[data-focus-visible]": {
79
- focusShadow: "outline",
90
+ focusRing: "outline",
80
91
  },
81
92
  "&[data-disabled]": {
82
93
  bg: "gray.300",
package/src/Slider.tsx CHANGED
@@ -4,6 +4,7 @@
4
4
  * SPDX-License-Identifier: MIT
5
5
  */
6
6
  import { ReactNode } from "react";
7
+ import { useLocale } from "react-aria";
7
8
  import {
8
9
  Slider as RACSlider,
9
10
  SliderThumb,
@@ -33,20 +34,24 @@ export interface SliderProps {
33
34
  thumbCss?: SystemStyleObject;
34
35
  /**
35
36
  * Positioned at the current value along the track and shown while the
36
- * slider has focus (Chakra's SliderMark usage).
37
+ * slider has focus.
37
38
  */
38
39
  mark?: ReactNode;
39
40
  markCss?: SystemStyleObject;
40
41
  /**
41
42
  * Additional positioned overlays rendered inside the slider root
42
43
  * (always-visible value labels, threshold markers, ...). The root is
43
- * position: relative; position children absolutely, typically with a
44
- * percentage `left` for the track position.
44
+ * position: relative; position children absolutely.
45
+ *
46
+ * Anything sitting at a *value* along the track takes a percentage `left`,
47
+ * physical and mirrored by hand in RTL as the thumb is (see `offset`
48
+ * below), never `insetInlineStart`. Anything pinned to the track's *ends*
49
+ * — min/max labels and the like — is ordinary layout and should be
50
+ * logical, so `insetStart`/`insetEnd`.
45
51
  */
46
52
  children?: ReactNode;
47
53
  /**
48
- * Tooltip-styled bubble anchored above the thumb (Chakra's
49
- * Tooltip-around-SliderThumb pattern). Rendered only while
54
+ * Tooltip-styled bubble anchored above the thumb. Rendered only while
50
55
  * `isThumbTooltipOpen`; drive it from hover/focus, e.g. via
51
56
  * `onThumbFocusChange` and mouse handlers on an enclosing element.
52
57
  */
@@ -56,10 +61,7 @@ export interface SliderProps {
56
61
  onThumbFocusChange?: (isFocused: boolean) => void;
57
62
  }
58
63
 
59
- /**
60
- * Slider — react-aria-components <Slider> styled like Chakra's horizontal md
61
- * slider.
62
- */
64
+ /** Slider — a styled horizontal react-aria-components <Slider>. */
63
65
  export const Slider = ({
64
66
  value,
65
67
  onChange,
@@ -80,7 +82,13 @@ export const Slider = ({
80
82
  onThumbFocusChange,
81
83
  }: SliderProps) => {
82
84
  const slots = slider();
85
+ const { direction } = useLocale();
83
86
  const percent = ((value - minValue) / (maxValue - minValue)) * 100;
87
+ // react-aria positions the thumb with a physical `left` that it mirrors
88
+ // itself in RTL (useSliderThumb), so anything tracking the thumb has to
89
+ // mirror the same way rather than going logical — `insetInlineStart` would
90
+ // put these on the opposite side of the track from the thumb.
91
+ const offset = direction === "rtl" ? 100 - percent : percent;
84
92
  return (
85
93
  <RACSlider
86
94
  value={value}
@@ -96,7 +104,7 @@ export const Slider = ({
96
104
  <div
97
105
  data-part="mark"
98
106
  className={cx(slots.mark, markCss ? css(markCss) : undefined)}
99
- style={{ left: `${percent}%` }}
107
+ style={{ left: `${offset}%` }}
100
108
  >
101
109
  {mark}
102
110
  </div>
@@ -119,7 +127,7 @@ export const Slider = ({
119
127
  role="presentation"
120
128
  className={css({
121
129
  position: "absolute",
122
- bottom: "calc(50% + 14px)",
130
+ bottom: "calc(50% + token(sizes.3.5))",
123
131
  transform: "translateX(-50%)",
124
132
  bg: "gray.700",
125
133
  color: "white",
@@ -148,7 +156,7 @@ export const Slider = ({
148
156
  borderTopColor: "gray.700",
149
157
  },
150
158
  })}
151
- style={{ left: `${percent}%` }}
159
+ style={{ left: `${offset}%` }}
152
160
  >
153
161
  {thumbTooltip}
154
162
  </div>
package/src/Spinner.tsx CHANGED
@@ -5,29 +5,29 @@
5
5
  */
6
6
  import { CSSProperties } from "react";
7
7
  import { css, cx } from "styled-system/css";
8
+ import { dataAttrs } from "./data-attrs";
8
9
  import { SystemStyleObject } from "styled-system/types";
9
10
 
10
11
  export interface SpinnerProps {
11
- /** Chakra Spinner sizes: sm 1rem, md 1.5rem (default). */
12
+ /** sm is 1rem, md 1.5rem (default). */
12
13
  size?: "sm" | "md";
13
14
  /**
14
- * Revolution time (Chakra's `speed`, default 0.45s). Under
15
- * prefers-reduced-motion the spin is slowed to ~3x this value.
15
+ * Revolution time (default 0.45s). Under prefers-reduced-motion the spin is
16
+ * slowed to ~3x this value.
16
17
  */
17
18
  speed?: string;
18
19
  /** Per-instance style overrides, merged after the base. */
19
20
  css?: SystemStyleObject;
20
21
  className?: string;
21
- /**
22
- * Accessible name. Required: Chakra's Spinner announced a visually hidden
23
- * "Loading..." by default, so a nameless spinner would regress on it.
24
- */
22
+ /** Accessible name. Required: a spinner must announce what is loading. */
25
23
  "aria-label": string;
24
+ /** `data-*` attributes land on the spinner, for tests that wait on it. */
25
+ [key: `data-${string}`]: unknown;
26
26
  }
27
27
 
28
28
  /**
29
- * Spinner — Chakra's border-based spinner (currentColor with transparent
30
- * bottom/left quadrants).
29
+ * Spinner — a border-based spinner (currentColor with transparent bottom/left
30
+ * quadrants).
31
31
  */
32
32
  export const Spinner = ({
33
33
  size = "md",
@@ -35,8 +35,10 @@ export const Spinner = ({
35
35
  css: cssProp,
36
36
  className,
37
37
  "aria-label": ariaLabel,
38
+ ...rest
38
39
  }: SpinnerProps) => (
39
40
  <span
41
+ {...dataAttrs(rest)}
40
42
  role="status"
41
43
  aria-label={ariaLabel}
42
44
  style={speed ? ({ "--spinner-speed": speed } as CSSProperties) : undefined}
package/src/Svg.tsx CHANGED
@@ -6,10 +6,9 @@
6
6
  import { styled } from "styled-system/jsx";
7
7
 
8
8
  /**
9
- * Svg — a Panda-styled svg element with Chakra Icon's base sizing (1em,
9
+ * Svg — a Panda-styled svg element with Icon's base sizing (1em,
10
10
  * inline-block). For custom-path icons; react-icons glyphs use `Icon`.
11
- * As a styled-factory component its call-site style props are extracted
12
- * (unlike style props forwarded through a plain wrapper — gotcha #9).
11
+ * As a styled-factory component its call-site style props are extracted.
13
12
  */
14
13
  export const Svg = styled("svg", {
15
14
  base: {