@microbit/ui 0.2.1 → 0.3.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.
- package/README.md +32 -0
- package/package.json +2 -2
- package/src/Avatar.tsx +4 -0
- package/src/Collapse.tsx +2 -0
- package/src/ComboBox.tsx +1 -1
- package/src/Drawer.recipe.ts +21 -3
- package/src/Modal.recipe.ts +8 -6
- package/src/Modal.tsx +91 -0
- package/src/Select.tsx +1 -1
- package/src/SharedUIProvider.tsx +14 -2
- package/src/Slider.tsx +4 -4
- package/src/Toast.tsx +4 -4
- package/src/UnmountCallback.tsx +3 -0
- package/src/base-preset.ts +16 -1
- package/src/hooks/usePrevious.ts +3 -0
- package/src/hooks/useSafeAreaNav.ts +51 -0
- package/src/index.ts +4 -0
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.
|
|
3
|
+
"version": "0.3.0",
|
|
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
|
-
{...
|
|
169
|
+
{...rest}
|
|
170
170
|
className={cx(fieldSlots.root, slots.root, className)}
|
|
171
171
|
>
|
|
172
172
|
{label != null && (
|
package/src/Drawer.recipe.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
82
|
-
//
|
|
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
|
},
|
package/src/Modal.recipe.ts
CHANGED
|
@@ -149,20 +149,22 @@ export const dialog = defineSlotRecipe({
|
|
|
149
149
|
my: "0",
|
|
150
150
|
mx: "0",
|
|
151
151
|
borderRadius: "0",
|
|
152
|
-
paddingTop: "
|
|
153
|
-
paddingBottom: "
|
|
154
|
-
|
|
155
|
-
|
|
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)
|
|
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(
|
|
167
|
+
top: "calc(token(spacing.safeAreaTop) + token(spacing.2))",
|
|
166
168
|
},
|
|
167
169
|
},
|
|
168
170
|
},
|
package/src/Modal.tsx
CHANGED
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
ReactNode,
|
|
10
10
|
RefObject,
|
|
11
11
|
useContext,
|
|
12
|
+
useEffect,
|
|
13
|
+
useRef,
|
|
12
14
|
} from "react";
|
|
13
15
|
import {
|
|
14
16
|
Button as RACButton,
|
|
@@ -28,6 +30,92 @@ import { dataAttrs } from "./data-attrs";
|
|
|
28
30
|
import { uiMessage } from "./messages";
|
|
29
31
|
import { UnmountCallback } from "./UnmountCallback";
|
|
30
32
|
|
|
33
|
+
// react-aria's tabbable selector, which it doesn't export. It special-cases
|
|
34
|
+
// radio groups (only the checked radio is tabbable) where this doesn't — an
|
|
35
|
+
// acceptable divergence for the shim below, whose worst case on a miss is
|
|
36
|
+
// standing aside and leaving the keystroke to FocusScope, as without it.
|
|
37
|
+
const TABBABLE_SELECTOR = [
|
|
38
|
+
"input:not([disabled]):not([type=hidden])",
|
|
39
|
+
"select:not([disabled])",
|
|
40
|
+
"textarea:not([disabled])",
|
|
41
|
+
"button:not([disabled])",
|
|
42
|
+
"a[href]",
|
|
43
|
+
"area[href]",
|
|
44
|
+
"summary",
|
|
45
|
+
"iframe",
|
|
46
|
+
"object",
|
|
47
|
+
"embed",
|
|
48
|
+
"audio[controls]",
|
|
49
|
+
"video[controls]",
|
|
50
|
+
"[contenteditable]:not([contenteditable^='false'])",
|
|
51
|
+
"[tabindex]:not([disabled])",
|
|
52
|
+
]
|
|
53
|
+
.map((s) => `${s}:not([hidden]):not([tabindex='-1'])`)
|
|
54
|
+
.join(",");
|
|
55
|
+
|
|
56
|
+
const isTabbable = (element: Element) =>
|
|
57
|
+
!element.closest("[inert]") &&
|
|
58
|
+
(element.checkVisibility?.({ visibilityProperty: true }) ?? true);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Tab handling for iframes in the dialog, which react-aria's focus
|
|
62
|
+
* containment gets wrong in Firefox: FocusScope re-implements Tab as a
|
|
63
|
+
* synchronous programmatic focus of the next tabbable element, and Firefox
|
|
64
|
+
* silently drops a synchronous focus() of a cross-origin iframe made during
|
|
65
|
+
* key event dispatch — the keystroke appears to do nothing. The same call
|
|
66
|
+
* deferred to the next frame works, so this capture-phase listener (which
|
|
67
|
+
* runs before FocusScope's bubble-phase one) claims the keystroke whenever
|
|
68
|
+
* the element Tab would move to is an iframe, and performs the move
|
|
69
|
+
* deferred. Every other Tab is left to FocusScope. Remove if react-aria
|
|
70
|
+
* defers iframe focus itself.
|
|
71
|
+
*/
|
|
72
|
+
const IframeTabShim = ({
|
|
73
|
+
containerRef,
|
|
74
|
+
}: {
|
|
75
|
+
containerRef: RefObject<HTMLElement | null>;
|
|
76
|
+
}) => {
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
79
|
+
if (
|
|
80
|
+
e.key !== "Tab" ||
|
|
81
|
+
e.altKey ||
|
|
82
|
+
e.ctrlKey ||
|
|
83
|
+
e.metaKey ||
|
|
84
|
+
e.isComposing
|
|
85
|
+
) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const container = containerRef.current;
|
|
89
|
+
const active = container?.ownerDocument.activeElement;
|
|
90
|
+
if (!container || !active || !container.contains(active)) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const tabbables = Array.from(
|
|
94
|
+
container.querySelectorAll<HTMLElement>(TABBABLE_SELECTOR),
|
|
95
|
+
).filter(isTabbable);
|
|
96
|
+
const index = tabbables.indexOf(active as HTMLElement);
|
|
97
|
+
if (index === -1) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const target =
|
|
101
|
+
tabbables[
|
|
102
|
+
(index + (e.shiftKey ? -1 : 1) + tabbables.length) % tabbables.length
|
|
103
|
+
];
|
|
104
|
+
if (!(target instanceof HTMLIFrameElement)) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
// Keep FocusScope's own document-level handler from also acting.
|
|
109
|
+
e.stopImmediatePropagation();
|
|
110
|
+
requestAnimationFrame(() => target.focus());
|
|
111
|
+
};
|
|
112
|
+
const doc = containerRef.current?.ownerDocument ?? document;
|
|
113
|
+
doc.addEventListener("keydown", onKeyDown, true);
|
|
114
|
+
return () => doc.removeEventListener("keydown", onKeyDown, true);
|
|
115
|
+
}, [containerRef]);
|
|
116
|
+
return null;
|
|
117
|
+
};
|
|
118
|
+
|
|
31
119
|
type DialogSlots = ReturnType<typeof dialog>;
|
|
32
120
|
|
|
33
121
|
const SlotContext = createContext<{ slots: DialogSlots; onClose: () => void }>({
|
|
@@ -163,6 +251,7 @@ export const Modal = ({
|
|
|
163
251
|
// prop over the context, and so do we for the close function.
|
|
164
252
|
const triggerState = useContext(OverlayTriggerStateContext);
|
|
165
253
|
const close = onClose ?? (() => triggerState?.close());
|
|
254
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
166
255
|
const dataProps = dataAttrs(rest);
|
|
167
256
|
const slots = dialog({ size, centered: isCentered });
|
|
168
257
|
const motionlessClass = motionless
|
|
@@ -205,6 +294,7 @@ export const Modal = ({
|
|
|
205
294
|
<UnmountCallback callback={handleUnmount} />
|
|
206
295
|
<RACModal
|
|
207
296
|
{...dataProps}
|
|
297
|
+
ref={contentRef}
|
|
208
298
|
style={contentStyle}
|
|
209
299
|
className={cx(
|
|
210
300
|
slots.content,
|
|
@@ -213,6 +303,7 @@ export const Modal = ({
|
|
|
213
303
|
)}
|
|
214
304
|
>
|
|
215
305
|
<Dialog role={role} aria-label={ariaLabel} className={slots.inner}>
|
|
306
|
+
<IframeTabShim containerRef={contentRef} />
|
|
216
307
|
<SlotContext.Provider value={{ slots, onClose: close }}>
|
|
217
308
|
{children}
|
|
218
309
|
</SlotContext.Provider>
|
package/src/Select.tsx
CHANGED
package/src/SharedUIProvider.tsx
CHANGED
|
@@ -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,
|
|
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 && (
|
package/src/UnmountCallback.tsx
CHANGED
|
@@ -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;
|
package/src/base-preset.ts
CHANGED
|
@@ -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,
|
package/src/hooks/usePrevious.ts
CHANGED
|
@@ -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";
|