@microbit/ui 0.2.2 → 0.3.1

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 CHANGED
@@ -228,6 +228,38 @@ on-dark flip below. Keep the shape:
228
228
  focusBorder: { value: { base: "{colors.brand.700}", _onDark: "{colors.white}" } };
229
229
  ```
230
230
 
231
+ ### Runtime inset variables
232
+
233
+ Recipes also read a few custom properties that are set at runtime rather
234
+ than emitted by Panda. Their names are API in the same way:
235
+
236
+ - `--safe-area-nav-left` / `--safe-area-nav-right` — the horizontal
237
+ safe-area insets content must stay clear of, as distinct from a camera
238
+ cutout content may flow under. Set by `SharedUIProvider` from an
239
+ app-supplied `safeAreaNavSource`: a native shell with real inset
240
+ metadata (e.g. Android's WindowInsets API via a Capacitor plugin, as in
241
+ ml-trainer), which can tell the navigation bar from a display cutout.
242
+ Consumers read them with the raw `env(safe-area-inset-*)` value as the
243
+ fallback, so contexts without a source — the web included — degrade to
244
+ padding for every inset: conservative, never occluded. Exactly one
245
+ source owns the variables, and the provider is always the writer.
246
+
247
+ Don't spell those `var(…, env(…))` expressions out: the spacing tokens
248
+ `safeAreaTop`, `safeAreaBottom`, `safeAreaNavLeft` and `safeAreaNavRight`
249
+ define them once, fallbacks included — `paddingLeft: "safeAreaNavLeft"`,
250
+ or `token(spacing.safeAreaNavLeft)` inside `calc()`/`max()` expressions.
251
+ The raw variable names remain API for styling outside Panda.
252
+
253
+ - `--window-controls-left` — the width of a shell's window-controls region
254
+ overlapping the top-left of the page (today: iPadOS 26's corner
255
+ adaptation, injected by ml-trainer's native shell). Read with a `0px`
256
+ fallback.
257
+
258
+ All of these are deliberately physical: they describe screen positions, so
259
+ they must not flip with the reading direction. Rules that consume them per
260
+ reading-order side mirror them explicitly under `dir=rtl` (see the drawer
261
+ recipe's placement variants).
262
+
231
263
  ## Dark surfaces
232
264
 
233
265
  Focus indicators are surface-aware through one tag. The default focus ring
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microbit/ui",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
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",
@@ -57,4 +57,4 @@
57
57
  "vite": "^7.3.6",
58
58
  "vitest": "^4.1.10"
59
59
  }
60
- }
60
+ }
package/src/Avatar.tsx CHANGED
@@ -103,6 +103,9 @@ const useImageStatus = (src?: string, srcSet?: string): ImageStatus => {
103
103
  );
104
104
  useEffect(() => {
105
105
  if (!src) {
106
+ // Resets for a removed src; a render-time reset is a behaviour change
107
+ // to review.
108
+ // eslint-disable-next-line react-hooks/set-state-in-effect
106
109
  setStatus("pending");
107
110
  return;
108
111
  }
@@ -233,6 +236,7 @@ export const Avatar = ({
233
236
  // directly in the flex container it is a flex item and centres
234
237
  // exactly.
235
238
  isValidElement(icon) ? (
239
+ // eslint-disable-next-line @eslint-react/no-clone-element -- labelling in place is the point, as above.
236
240
  cloneElement(icon as ReactElement<Record<string, unknown>>, {
237
241
  role: "img",
238
242
  "aria-label": iconLabel,
package/src/Collapse.tsx CHANGED
@@ -73,6 +73,8 @@ export const Collapse = ({
73
73
  if (isOpen) {
74
74
  // Opening: mount (collapsed) first, then flip on the next frame so
75
75
  // the browser has a computed collapsed state to transition from.
76
+ // The mount-then-flip sequencing above depends on the effect timing.
77
+ // eslint-disable-next-line react-hooks/set-state-in-effect
76
78
  setPresent(true);
77
79
  const raf = requestAnimationFrame(() => {
78
80
  setAnimating(true);
package/src/ComboBox.tsx CHANGED
@@ -166,7 +166,7 @@ const ComboBoxInner = <T extends object>(
166
166
  <SelectSlotProvider value={slots}>
167
167
  <RACComboBox
168
168
  allowsEmptyCollection={emptyState != null}
169
- {...(rest as RACComboBoxProps<T>)}
169
+ {...rest}
170
170
  className={cx(fieldSlots.root, slots.root, className)}
171
171
  >
172
172
  {label != null && (
@@ -10,6 +10,13 @@ import { defineSlotRecipe } from "@pandacss/dev";
10
10
  * Drawer, which maps the slots onto react-aria-components' ModalOverlay /
11
11
  * Modal / Dialog.
12
12
  *
13
+ * The panel spans the full viewport height, so like the full-size dialog it
14
+ * pads by the safe-area insets and paints the status-bar strip with the
15
+ * brand `statusBarBg` gradient. The inline inset is applied per placement,
16
+ * since only the screen edge the drawer touches needs it, and uses the
17
+ * nav-side safe-area tokens (README: CSS-variable contract) so content can
18
+ * flow under a camera cutout.
19
+ *
13
20
  * The enter transition is a decelerating tween; the exit a 0.15s ease-in-out
14
21
  * tween.
15
22
  *
@@ -41,7 +48,12 @@ export const drawer = defineSlotRecipe({
41
48
  maxWidth: "xs",
42
49
  maxH: "100dvh",
43
50
  color: "inherit",
44
- bg: "white",
51
+ // brand colour in the status-bar area, white below (matches the
52
+ // full-size dialog)
53
+ background:
54
+ "linear-gradient(to bottom, token(colors.statusBarBg) token(spacing.safeAreaTop), white token(spacing.safeAreaTop))",
55
+ paddingTop: "safeAreaTop",
56
+ paddingBottom: "safeAreaBottom",
45
57
  boxShadow: "lg",
46
58
  display: "flex",
47
59
  flexDirection: "column",
@@ -78,15 +90,18 @@ export const drawer = defineSlotRecipe({
78
90
  variants: {
79
91
  // Reading-order sides, so a drawer keeps its relationship to the content
80
92
  // in an RTL locale rather than its screen position. The slide-out
81
- // transforms are physical, so each needs mirroring under `dir=rtl` to
82
- // travel towards its own edge.
93
+ // transforms and the safe-area inline padding are physical, so each
94
+ // needs mirroring under `dir=rtl` to track its own screen edge.
83
95
  placement: {
84
96
  start: {
85
97
  content: {
86
98
  insetStart: 0,
99
+ paddingLeft: "safeAreaNavLeft",
87
100
  "&[data-entering]": { transform: "translateX(-100%)" },
88
101
  "&[data-exiting]": { transform: "translateX(-100%)" },
89
102
  _rtl: {
103
+ paddingLeft: "0px",
104
+ paddingRight: "safeAreaNavRight",
90
105
  "&[data-entering]": { transform: "translateX(100%)" },
91
106
  "&[data-exiting]": { transform: "translateX(100%)" },
92
107
  },
@@ -95,9 +110,12 @@ export const drawer = defineSlotRecipe({
95
110
  end: {
96
111
  content: {
97
112
  insetEnd: 0,
113
+ paddingRight: "safeAreaNavRight",
98
114
  "&[data-entering]": { transform: "translateX(100%)" },
99
115
  "&[data-exiting]": { transform: "translateX(100%)" },
100
116
  _rtl: {
117
+ paddingRight: "0px",
118
+ paddingLeft: "safeAreaNavLeft",
101
119
  "&[data-entering]": { transform: "translateX(-100%)" },
102
120
  "&[data-exiting]": { transform: "translateX(-100%)" },
103
121
  },
@@ -49,11 +49,11 @@ export const input = defineRecipe({
49
49
  "&&&:is([data-invalid], :user-invalid)": {
50
50
  borderColor: "danger.500",
51
51
  },
52
- // Modality-tracked: RAC's attribute, or Input.tsx's. Native
52
+ // Modality-tracked: RAC's attribute, or Input/NativeSelect's. Native
53
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))":
54
+ // but not form controls — browsers match it there on a pointer click
55
+ // (Chrome includes select), which is what the tracking exists to exclude.
56
+ "&&&:is([data-focus-visible], :focus-visible:not([data-rac], input, textarea, select))":
57
57
  {
58
58
  focusRing: "outline",
59
59
  },
@@ -149,20 +149,22 @@ export const dialog = defineSlotRecipe({
149
149
  my: "0",
150
150
  mx: "0",
151
151
  borderRadius: "0",
152
- paddingTop: "env(safe-area-inset-top)",
153
- paddingBottom: "env(safe-area-inset-bottom)",
154
- paddingLeft: "env(safe-area-inset-left)",
155
- paddingRight: "env(safe-area-inset-right)",
152
+ paddingTop: "safeAreaTop",
153
+ paddingBottom: "safeAreaBottom",
154
+ // The nav-side tokens (README: CSS-variable contract), so content
155
+ // can flow under a camera cutout.
156
+ paddingLeft: "safeAreaNavLeft",
157
+ paddingRight: "safeAreaNavRight",
156
158
  // brand colour in the status-bar area, white below (matches ActionBar)
157
159
  background:
158
- "linear-gradient(to bottom, token(colors.statusBarBg) env(safe-area-inset-top), white env(safe-area-inset-top))",
160
+ "linear-gradient(to bottom, token(colors.statusBarBg) token(spacing.safeAreaTop), white token(spacing.safeAreaTop))",
159
161
  },
160
162
  header: {
161
163
  pl: "calc(var(--window-controls-left, 0px) + token(spacing.6))",
162
164
  },
163
165
  body: { flex: "1", overflowY: "auto" },
164
166
  closeTrigger: {
165
- top: "calc(env(safe-area-inset-top) + token(spacing.2))",
167
+ top: "calc(token(spacing.safeAreaTop) + token(spacing.2))",
166
168
  },
167
169
  },
168
170
  },
@@ -4,6 +4,7 @@
4
4
  * SPDX-License-Identifier: MIT
5
5
  */
6
6
  import { forwardRef, SelectHTMLAttributes } 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";
@@ -40,9 +41,14 @@ export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
40
41
  { hideChevron = false, size, css: cssProp, wrapperCss, className, ...rest },
41
42
  ref,
42
43
  ) {
44
+ // Modality tracked here rather than via the recipe's native
45
+ // :focus-visible fallback: Chrome matches that on a clicked select (as it
46
+ // does on text inputs), and this ring is keyboard-only.
47
+ const { isFocusVisible, focusProps } = useFocusRing();
43
48
  const select = (
44
49
  <select
45
50
  ref={ref}
51
+ data-focus-visible={isFocusVisible || undefined}
46
52
  className={cx(
47
53
  input({ size }),
48
54
  css(
@@ -59,7 +65,7 @@ export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
59
65
  ),
60
66
  className,
61
67
  )}
62
- {...rest}
68
+ {...mergeProps(rest, focusProps)}
63
69
  />
64
70
  );
65
71
  if (hideChevron) {
package/src/Select.tsx CHANGED
@@ -110,7 +110,7 @@ export const Select = <T extends object>({
110
110
  return (
111
111
  <SelectSlotProvider value={slots}>
112
112
  <RACSelect
113
- {...(rest as RACSelectProps<T>)}
113
+ {...rest}
114
114
  className={cx(fieldSlots.root, slots.root, className)}
115
115
  >
116
116
  {label != null && (
@@ -13,6 +13,7 @@ import {
13
13
  import { isRTL } from "react-aria";
14
14
  import { I18nProvider } from "react-aria-components";
15
15
  import { IntlContext } from "react-intl";
16
+ import { SafeAreaNavSource, useSafeAreaNav } from "./hooks/useSafeAreaNav";
16
17
  import { racLocale } from "./rac-locale";
17
18
 
18
19
  /**
@@ -56,13 +57,22 @@ export interface SharedUIProviderProps {
56
57
  * the README's react-intl step).
57
58
  */
58
59
  setDocumentLang?: boolean;
60
+ /**
61
+ * The source for the `--safe-area-nav-*` variables (see the README's
62
+ * CSS-variable contract): a native shell with real inset metadata (e.g.
63
+ * Android's WindowInsets API via a Capacitor plugin). Pass it only in the
64
+ * app shell that provides it; without a source the variables stay unset
65
+ * and consumers fall back to the raw `env()` insets. Keep its identity
66
+ * stable.
67
+ */
68
+ safeAreaNavSource?: SafeAreaNavSource;
59
69
  children: ReactNode;
60
70
  }
61
71
 
62
72
  /**
63
73
  * 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.
74
+ * integrations: the optional overlay-close registrar, the locale for
75
+ * react-aria's built-in strings, and the safe-area nav variables.
66
76
  *
67
77
  * This package's own strings come from react-intl, so an IntlProvider must be
68
78
  * mounted above shared-ui components (see the package README). react-aria
@@ -75,8 +85,10 @@ export const SharedUIProvider = ({
75
85
  overlayCloseRegistrar,
76
86
  locale,
77
87
  setDocumentLang = true,
88
+ safeAreaNavSource,
78
89
  children,
79
90
  }: SharedUIProviderProps) => {
91
+ useSafeAreaNav(safeAreaNavSource);
80
92
  // Read the context rather than calling useIntl(), which throws when there is
81
93
  // no IntlProvider: react-aria falls back to the browser locale, as before.
82
94
  const intlLocale = useContext(IntlContext)?.locale;
package/src/Slider.tsx CHANGED
@@ -100,7 +100,7 @@ export const Slider = ({
100
100
  formatOptions={formatOptions}
101
101
  className={cx(slots.root, cssProp ? css(cssProp) : undefined)}
102
102
  >
103
- {mark && (
103
+ {mark ? (
104
104
  <div
105
105
  data-part="mark"
106
106
  className={cx(slots.mark, markCss ? css(markCss) : undefined)}
@@ -108,7 +108,7 @@ export const Slider = ({
108
108
  >
109
109
  {mark}
110
110
  </div>
111
- )}
111
+ ) : null}
112
112
  <SliderTrack
113
113
  className={cx(slots.track, trackCss ? css(trackCss) : undefined)}
114
114
  >
@@ -120,7 +120,7 @@ export const Slider = ({
120
120
  style={{ width: `${percent}%` }}
121
121
  />
122
122
  </SliderTrack>
123
- {thumbTooltip && isThumbTooltipOpen && (
123
+ {thumbTooltip && isThumbTooltipOpen ? (
124
124
  // Matches the shared Tooltip's look (tooltipBase) with a bottom
125
125
  // arrow, anchored above the thumb.
126
126
  <div
@@ -160,7 +160,7 @@ export const Slider = ({
160
160
  >
161
161
  {thumbTooltip}
162
162
  </div>
163
- )}
163
+ ) : null}
164
164
  <SliderThumb
165
165
  onFocusChange={onThumbFocusChange}
166
166
  className={cx(slots.thumb, thumbCss ? css(thumbCss) : undefined)}
package/src/Toast.tsx CHANGED
@@ -149,14 +149,14 @@ export const ToastProvider = () => {
149
149
  )}
150
150
  />
151
151
  <div className={slots.body}>
152
- {toast.content.title && (
152
+ {toast.content.title ? (
153
153
  <p className={slots.title}>{toast.content.title}</p>
154
- )}
155
- {toast.content.description && (
154
+ ) : null}
155
+ {toast.content.description ? (
156
156
  <div className={slots.description}>
157
157
  {toast.content.description}
158
158
  </div>
159
- )}
159
+ ) : null}
160
160
  </div>
161
161
  </RACToastContent>
162
162
  {toast.content.isClosable && (
@@ -12,6 +12,9 @@ import { useEffect, useRef } from "react";
12
12
  */
13
13
  export const UnmountCallback = ({ callback }: { callback?: () => void }) => {
14
14
  const ref = useRef(callback);
15
+ // Latest-ref pattern: the unmount effect calls the current callback
16
+ // without re-subscribing.
17
+ // eslint-disable-next-line react-hooks/refs
15
18
  ref.current = callback;
16
19
  useEffect(() => () => ref.current?.(), []);
17
20
  return null;
@@ -171,7 +171,22 @@ export const basePreset = definePreset({
171
171
  brand: colors.blue,
172
172
  brand2: colors.gray,
173
173
  },
174
- spacing,
174
+ spacing: {
175
+ ...spacing,
176
+ // The safe-area inset expressions, defined once (README: CSS-variable
177
+ // contract). The nav sides read the runtime `--safe-area-nav-*`
178
+ // variables with the raw env() insets as the fallback, so contexts
179
+ // without a SharedUIProvider safeAreaNavSource degrade to padding
180
+ // for every inset.
181
+ safeAreaTop: { value: "env(safe-area-inset-top)" },
182
+ safeAreaBottom: { value: "env(safe-area-inset-bottom)" },
183
+ safeAreaNavLeft: {
184
+ value: "var(--safe-area-nav-left, env(safe-area-inset-left))",
185
+ },
186
+ safeAreaNavRight: {
187
+ value: "var(--safe-area-nav-right, env(safe-area-inset-right))",
188
+ },
189
+ },
175
190
  sizes,
176
191
  fontSizes,
177
192
  fontWeights,
@@ -11,5 +11,8 @@ export function usePrevious<T>(value: T): T | undefined {
11
11
  useEffect(() => {
12
12
  ref.current = value;
13
13
  });
14
+ // Reading the previous render's value during render is the point of this
15
+ // hook.
16
+ // eslint-disable-next-line react-hooks/refs
14
17
  return ref.current;
15
18
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { useLayoutEffect } from "react";
7
+
8
+ /**
9
+ * The horizontal insets content must stay clear of, in CSS pixels.
10
+ */
11
+ export interface SafeAreaNavInsets {
12
+ left: number;
13
+ right: number;
14
+ }
15
+
16
+ /**
17
+ * A source for the `--safe-area-nav-left`/`--safe-area-nav-right` variables
18
+ * (see the README's CSS-variable contract): a native shell with real inset
19
+ * metadata (e.g. Android's WindowInsets API via a Capacitor plugin), which
20
+ * can tell the navigation bar from a display cutout. Called with a callback
21
+ * to invoke with the current insets immediately and again on every change;
22
+ * returns an unsubscribe function. Keep the source's identity stable — a
23
+ * new function each render resubscribes.
24
+ */
25
+ export type SafeAreaNavSource = (
26
+ onChange: (insets: SafeAreaNavInsets) => void,
27
+ ) => () => void;
28
+
29
+ /**
30
+ * Keeps `--safe-area-nav-left`/`--safe-area-nav-right` on the document
31
+ * element in step with `source`; without one the variables stay unset and
32
+ * consumers fall back to the raw env() insets. Installed by
33
+ * SharedUIProvider; not exported from the package.
34
+ */
35
+ export const useSafeAreaNav = (source?: SafeAreaNavSource) => {
36
+ useLayoutEffect(() => {
37
+ if (!source) {
38
+ return;
39
+ }
40
+ const root = document.documentElement;
41
+ const unsubscribe = source(({ left, right }) => {
42
+ root.style.setProperty("--safe-area-nav-left", `${left}px`);
43
+ root.style.setProperty("--safe-area-nav-right", `${right}px`);
44
+ });
45
+ return () => {
46
+ unsubscribe();
47
+ root.style.removeProperty("--safe-area-nav-left");
48
+ root.style.removeProperty("--safe-area-nav-right");
49
+ };
50
+ }, [source]);
51
+ };
package/src/index.ts CHANGED
@@ -63,6 +63,10 @@ export * from "./Tooltip";
63
63
  export * from "./TooltipButton";
64
64
  export * from "./Toast";
65
65
  export * from "./VisuallyHidden";
66
+ export type {
67
+ SafeAreaNavInsets,
68
+ SafeAreaNavSource,
69
+ } from "./hooks/useSafeAreaNav";
66
70
  export { useBreakpointValue } from "./hooks/useBreakpointValue";
67
71
  export { useClipboard } from "./hooks/useClipboard";
68
72
  export { useDisclosure } from "./hooks/useDisclosure";