@microbit/ui 0.1.0-alpha.3 → 0.1.0-alpha.31

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 (115) hide show
  1. package/LICENSE.md +40 -0
  2. package/README.md +279 -26
  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 +62 -0
  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 +62 -0
  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 +62 -0
  22. package/lang/ui.zh-tw.json +44 -4
  23. package/package.json +19 -10
  24. package/postcss-legacy-safari.cjs +96 -0
  25. package/reset.css +35 -0
  26. package/src/Avatar.recipe.ts +191 -0
  27. package/src/Avatar.tsx +278 -0
  28. package/src/Breadcrumb.recipe.ts +45 -0
  29. package/src/Breadcrumb.tsx +114 -0
  30. package/src/Button.recipe.ts +88 -50
  31. package/src/Button.tsx +68 -30
  32. package/src/ButtonGroup.tsx +37 -15
  33. package/src/Card.recipe.ts +1 -2
  34. package/src/Card.tsx +2 -1
  35. package/src/Checkbox.recipe.ts +49 -14
  36. package/src/Checkbox.tsx +111 -32
  37. package/src/CheckboxGroup.tsx +70 -0
  38. package/src/CloseButton.tsx +3 -3
  39. package/src/CloseIcon.tsx +6 -4
  40. package/src/Code.tsx +20 -0
  41. package/src/Collapse.tsx +179 -0
  42. package/src/ComboBox.tsx +246 -0
  43. package/src/Divider.tsx +72 -8
  44. package/src/Drawer.recipe.ts +21 -10
  45. package/src/Drawer.tsx +3 -4
  46. package/src/ExternalLink.tsx +43 -0
  47. package/src/Fade.tsx +62 -0
  48. package/src/Field.recipe.ts +114 -0
  49. package/src/Field.tsx +187 -0
  50. package/src/GridList.recipe.ts +57 -0
  51. package/src/GridList.tsx +81 -0
  52. package/src/Heading.recipe.ts +20 -1
  53. package/src/Heading.tsx +3 -3
  54. package/src/Icon.tsx +23 -7
  55. package/src/IconButton.tsx +8 -13
  56. package/src/Image.tsx +1 -1
  57. package/src/Input.recipe.ts +41 -28
  58. package/src/Input.tsx +23 -7
  59. package/src/InputGroup.tsx +26 -12
  60. package/src/Kbd.tsx +26 -0
  61. package/src/Link.tsx +3 -3
  62. package/src/LinkBox.tsx +2 -3
  63. package/src/LinkButton.tsx +81 -0
  64. package/src/List.tsx +8 -6
  65. package/src/ListBox.recipe.ts +43 -0
  66. package/src/ListBox.tsx +88 -0
  67. package/src/Menu.recipe.ts +51 -17
  68. package/src/Menu.tsx +117 -2
  69. package/src/Modal.recipe.ts +17 -9
  70. package/src/Modal.tsx +137 -24
  71. package/src/MoreMenuButton.tsx +63 -0
  72. package/src/NativeSelect.tsx +41 -14
  73. package/src/NativeSelectField.tsx +84 -0
  74. package/src/NumberField.recipe.ts +108 -0
  75. package/src/NumberField.tsx +138 -0
  76. package/src/PopoverArrow.tsx +19 -5
  77. package/src/ProgressBar.tsx +3 -5
  78. package/src/Radio.recipe.ts +108 -0
  79. package/src/Radio.tsx +62 -0
  80. package/src/RadioGroup.tsx +68 -0
  81. package/src/Select.recipe.ts +211 -0
  82. package/src/Select.tsx +194 -0
  83. package/src/SharedUIProvider.tsx +63 -7
  84. package/src/Skeleton.tsx +146 -0
  85. package/src/Slide.tsx +2 -2
  86. package/src/Slider.recipe.ts +27 -16
  87. package/src/Slider.tsx +78 -6
  88. package/src/Spinner.tsx +11 -9
  89. package/src/Svg.tsx +2 -3
  90. package/src/Switch.recipe.ts +57 -11
  91. package/src/Switch.tsx +39 -7
  92. package/src/Text.recipe.ts +26 -0
  93. package/src/Text.tsx +6 -2
  94. package/src/TextField.tsx +54 -40
  95. package/src/Toast.recipe.ts +52 -15
  96. package/src/Toast.tsx +151 -46
  97. package/src/Tooltip.recipe.ts +32 -0
  98. package/src/Tooltip.tsx +68 -28
  99. package/src/TooltipButton.tsx +229 -0
  100. package/src/UnmountCallback.tsx +2 -2
  101. package/src/VisuallyHidden.tsx +1 -1
  102. package/src/base-preset.ts +336 -84
  103. package/src/{chakra-tokens.ts → base-tokens.ts} +7 -7
  104. package/src/button-icon.ts +22 -0
  105. package/src/data-attrs.ts +16 -0
  106. package/src/dense-preset.ts +107 -0
  107. package/src/hooks/useBreakpointValue.ts +4 -4
  108. package/src/hooks/useClipboard.ts +63 -0
  109. package/src/hooks/useDisclosure.ts +31 -0
  110. package/src/hooks/useMediaQuery.ts +27 -0
  111. package/src/hooks/usePrevious.ts +15 -0
  112. package/src/index.ts +29 -3
  113. package/src/rac-locale.ts +33 -0
  114. package/src/system.ts +22 -1
  115. package/src/TextField.recipe.ts +0 -54
@@ -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
+ });
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
  */