@microbit/ui 0.0.0-gray.ramp.90
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/LICENSE.md +53 -0
- package/README.md +222 -0
- package/lang/ui.ca.json +22 -0
- package/lang/ui.cy.json +22 -0
- package/lang/ui.de.json +22 -0
- package/lang/ui.en-us.json +22 -0
- package/lang/ui.en.json +22 -0
- package/lang/ui.es-es.json +22 -0
- package/lang/ui.fr.json +22 -0
- package/lang/ui.ga-ie.json +22 -0
- package/lang/ui.it.json +22 -0
- package/lang/ui.ja.json +22 -0
- package/lang/ui.ko.json +22 -0
- package/lang/ui.lol.json +22 -0
- package/lang/ui.nl.json +22 -0
- package/lang/ui.pl.json +22 -0
- package/lang/ui.pt-br.json +22 -0
- package/lang/ui.zh-cn.json +22 -0
- package/lang/ui.zh-tw.json +22 -0
- package/package.json +66 -0
- package/postcss-legacy-safari.cjs +96 -0
- package/reset.css +35 -0
- package/src/Avatar.recipe.ts +168 -0
- package/src/Avatar.tsx +276 -0
- package/src/Button.recipe.ts +192 -0
- package/src/Button.tsx +69 -0
- package/src/ButtonGroup.tsx +70 -0
- package/src/Card.recipe.ts +49 -0
- package/src/Card.tsx +56 -0
- package/src/Checkbox.recipe.ts +111 -0
- package/src/Checkbox.tsx +106 -0
- package/src/CloseButton.tsx +87 -0
- package/src/CloseIcon.tsx +40 -0
- package/src/Code.tsx +20 -0
- package/src/Collapse.tsx +180 -0
- package/src/ComboBox.tsx +192 -0
- package/src/Divider.tsx +85 -0
- package/src/Drawer.recipe.ts +98 -0
- package/src/Drawer.tsx +138 -0
- package/src/Fade.tsx +48 -0
- package/src/GridList.recipe.ts +58 -0
- package/src/GridList.tsx +81 -0
- package/src/Heading.recipe.ts +52 -0
- package/src/Heading.tsx +14 -0
- package/src/Icon.tsx +75 -0
- package/src/IconButton.tsx +44 -0
- package/src/Image.tsx +11 -0
- package/src/Input.recipe.ts +75 -0
- package/src/Input.tsx +44 -0
- package/src/InputGroup.tsx +62 -0
- package/src/Kbd.tsx +26 -0
- package/src/Link.tsx +23 -0
- package/src/LinkBox.tsx +88 -0
- package/src/LinkButton.tsx +80 -0
- package/src/List.tsx +31 -0
- package/src/ListBox.recipe.ts +43 -0
- package/src/ListBox.tsx +88 -0
- package/src/Menu.recipe.ts +124 -0
- package/src/Menu.tsx +291 -0
- package/src/Modal.recipe.ts +163 -0
- package/src/Modal.tsx +377 -0
- package/src/NativeSelect.tsx +94 -0
- package/src/NumberField.recipe.ts +67 -0
- package/src/NumberField.tsx +86 -0
- package/src/PopoverArrow.tsx +67 -0
- package/src/ProgressBar.tsx +61 -0
- package/src/Radio.recipe.ts +112 -0
- package/src/Radio.tsx +89 -0
- package/src/Select.recipe.ts +210 -0
- package/src/Select.tsx +153 -0
- package/src/SharedUIProvider.tsx +55 -0
- package/src/Skeleton.tsx +146 -0
- package/src/Slide.tsx +54 -0
- package/src/Slider.recipe.ts +102 -0
- package/src/Slider.tsx +163 -0
- package/src/Spinner.tsx +74 -0
- package/src/Svg.tsx +23 -0
- package/src/Switch.recipe.ts +107 -0
- package/src/Switch.tsx +62 -0
- package/src/Text.recipe.ts +29 -0
- package/src/Text.tsx +16 -0
- package/src/TextField.recipe.ts +54 -0
- package/src/TextField.tsx +91 -0
- package/src/Toast.recipe.ts +98 -0
- package/src/Toast.tsx +173 -0
- package/src/Tooltip.recipe.ts +37 -0
- package/src/Tooltip.tsx +76 -0
- package/src/UnmountCallback.tsx +18 -0
- package/src/VisuallyHidden.tsx +14 -0
- package/src/base-preset.ts +371 -0
- package/src/base-tokens.ts +1017 -0
- package/src/button-icon.ts +23 -0
- package/src/data-attrs.ts +16 -0
- package/src/dense-preset.ts +108 -0
- package/src/hooks/useBreakpointValue.ts +63 -0
- package/src/hooks/useClipboard.ts +64 -0
- package/src/hooks/useDisclosure.ts +32 -0
- package/src/hooks/useMediaQuery.ts +28 -0
- package/src/hooks/usePrevious.ts +18 -0
- package/src/index.ts +63 -0
- package/src/messages.ts +17 -0
- package/src/system.ts +45 -0
package/src/Modal.tsx
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
createContext,
|
|
8
|
+
CSSProperties,
|
|
9
|
+
ReactNode,
|
|
10
|
+
RefObject,
|
|
11
|
+
useContext,
|
|
12
|
+
} from "react";
|
|
13
|
+
import {
|
|
14
|
+
Button as RACButton,
|
|
15
|
+
Dialog,
|
|
16
|
+
DialogTrigger as RACDialogTrigger,
|
|
17
|
+
Heading as RACHeading,
|
|
18
|
+
Modal as RACModal,
|
|
19
|
+
ModalOverlay,
|
|
20
|
+
OverlayTriggerStateContext,
|
|
21
|
+
} from "react-aria-components";
|
|
22
|
+
import { css, cx } from "styled-system/css";
|
|
23
|
+
import { dialog } from "styled-system/recipes";
|
|
24
|
+
import { ConditionalValue, SystemStyleObject } from "styled-system/types";
|
|
25
|
+
import { useIntl } from "react-intl";
|
|
26
|
+
import { CloseIcon } from "./CloseIcon";
|
|
27
|
+
import { dataAttrs } from "./data-attrs";
|
|
28
|
+
import { uiMessage } from "./messages";
|
|
29
|
+
import { UnmountCallback } from "./UnmountCallback";
|
|
30
|
+
|
|
31
|
+
type DialogSlots = ReturnType<typeof dialog>;
|
|
32
|
+
|
|
33
|
+
const SlotContext = createContext<{ slots: DialogSlots; onClose: () => void }>({
|
|
34
|
+
slots: dialog({}),
|
|
35
|
+
onClose: () => undefined,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const useDialog = () => useContext(SlotContext);
|
|
39
|
+
|
|
40
|
+
export type ModalSize = ConditionalValue<
|
|
41
|
+
| "xs"
|
|
42
|
+
| "sm"
|
|
43
|
+
| "md"
|
|
44
|
+
| "lg"
|
|
45
|
+
| "xl"
|
|
46
|
+
| "2xl"
|
|
47
|
+
| "3xl"
|
|
48
|
+
| "4xl"
|
|
49
|
+
| "5xl"
|
|
50
|
+
| "6xl"
|
|
51
|
+
| "full"
|
|
52
|
+
>;
|
|
53
|
+
|
|
54
|
+
export interface ModalOwnProps {
|
|
55
|
+
size?: ModalSize;
|
|
56
|
+
/** Allow closing by clicking the backdrop (default true; Escape always closes). */
|
|
57
|
+
isDismissable?: boolean;
|
|
58
|
+
/** Disable the enter/exit transitions (Chakra's motionPreset="none"). */
|
|
59
|
+
motionless?: boolean;
|
|
60
|
+
/** Prevent Escape closing the dialog (Chakra's closeOnEsc={false}). */
|
|
61
|
+
isKeyboardDismissDisabled?: boolean;
|
|
62
|
+
/** Style overrides for the dialog box (Chakra's ModalContent props). */
|
|
63
|
+
contentCss?: SystemStyleObject;
|
|
64
|
+
/**
|
|
65
|
+
* Inline styles for the dialog box, for runtime-computed positioning that
|
|
66
|
+
* Panda can't statically extract (e.g. a dialog aligned to a measured
|
|
67
|
+
* element). Prefer `contentCss` for static styles.
|
|
68
|
+
*/
|
|
69
|
+
contentStyle?: CSSProperties;
|
|
70
|
+
/**
|
|
71
|
+
* Style overrides for the backdrop (Chakra's ModalOverlay props), e.g. a
|
|
72
|
+
* transparent backdrop when something else provides the dimming.
|
|
73
|
+
*/
|
|
74
|
+
overlayCss?: SystemStyleObject;
|
|
75
|
+
/**
|
|
76
|
+
* Use "alertdialog" for confirmations that interrupt the user (Chakra's
|
|
77
|
+
* AlertDialog).
|
|
78
|
+
*/
|
|
79
|
+
role?: "dialog" | "alertdialog";
|
|
80
|
+
/** Vertically centre the dialog (Chakra's `isCentered`). */
|
|
81
|
+
isCentered?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Called after the dialog has fully closed (exit transition done and the
|
|
84
|
+
* dialog removed). Matches Chakra's `onCloseComplete`.
|
|
85
|
+
*/
|
|
86
|
+
onCloseComplete?: () => void;
|
|
87
|
+
/**
|
|
88
|
+
* Element to focus when the dialog closes, instead of the element that was
|
|
89
|
+
* focused when it opened. Matches Chakra's `finalFocusRef`.
|
|
90
|
+
*/
|
|
91
|
+
finalFocusRef?: RefObject<HTMLElement>;
|
|
92
|
+
/**
|
|
93
|
+
* Accessible name for dialogs without a ModalHeader (which otherwise
|
|
94
|
+
* provides the label). One of the two is required — react-aria warns in
|
|
95
|
+
* dev builds when a dialog has neither. An explicit `aria-label` wins
|
|
96
|
+
* over a ModalHeader.
|
|
97
|
+
*/
|
|
98
|
+
"aria-label"?: string;
|
|
99
|
+
children: ReactNode;
|
|
100
|
+
/**
|
|
101
|
+
* `data-*` attributes land on the dialog box (where Chakra's went, on
|
|
102
|
+
* ModalContent), so end-to-end tests can address a dialog. Shells that
|
|
103
|
+
* forward their caller's data attributes can spread them straight in.
|
|
104
|
+
*/
|
|
105
|
+
[key: `data-${string}`]: unknown;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A Modal you drive yourself. Also the type for a component that *forwards*
|
|
110
|
+
* modal props — `Omit<ControlledModalProps, "children">` — because a spread
|
|
111
|
+
* cannot be matched against the union `ModalProps` is: TypeScript has no way
|
|
112
|
+
* to know which half of it an object with `isOpen?: boolean` satisfies.
|
|
113
|
+
*/
|
|
114
|
+
export type ControlledModalProps = ModalOwnProps & {
|
|
115
|
+
/** Whether the dialog is showing. */
|
|
116
|
+
isOpen: boolean;
|
|
117
|
+
/** Called when the dialog asks to close. */
|
|
118
|
+
onClose: () => void;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The props of a `Modal`: its own, plus an open state that is either entirely
|
|
123
|
+
* yours or entirely a `DialogTrigger`'s. Never half of each — `isOpen`
|
|
124
|
+
* without `onClose` leaves the close button and Escape with nothing to call,
|
|
125
|
+
* so the pair is enforced rather than merely documented.
|
|
126
|
+
*/
|
|
127
|
+
export type ModalProps =
|
|
128
|
+
| ControlledModalProps
|
|
129
|
+
| (ModalOwnProps & { isOpen?: never; onClose?: never });
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Modal — a focus-trapping dialog. Collapses Chakra's
|
|
133
|
+
* Modal/ModalOverlay/ModalContent into a single shell; place ModalHeader,
|
|
134
|
+
* ModalBody and ModalFooter inside.
|
|
135
|
+
*
|
|
136
|
+
* Two ways to drive it:
|
|
137
|
+
*
|
|
138
|
+
* - **Controlled** (`isOpen` + `onClose`), which is what a Chakra app ports
|
|
139
|
+
* to, and what any dialog with more than one opener needs — a menu item and
|
|
140
|
+
* a toolbar button opening the same dialog, or one opened from a handler
|
|
141
|
+
* after an async result.
|
|
142
|
+
* - **Inside a `DialogTrigger`**, with neither prop: react-aria holds the
|
|
143
|
+
* open state, the trigger opens it, and `ModalCloseButton` and the footer's
|
|
144
|
+
* `useDialogClose()` still close it. Prefer this where a dialog has exactly
|
|
145
|
+
* one trigger sitting next to it — there is no state to hold, and none to
|
|
146
|
+
* get out of step.
|
|
147
|
+
*/
|
|
148
|
+
export const Modal = ({
|
|
149
|
+
isOpen,
|
|
150
|
+
onClose,
|
|
151
|
+
size,
|
|
152
|
+
isDismissable = true,
|
|
153
|
+
motionless,
|
|
154
|
+
isKeyboardDismissDisabled,
|
|
155
|
+
contentCss,
|
|
156
|
+
contentStyle,
|
|
157
|
+
overlayCss,
|
|
158
|
+
role,
|
|
159
|
+
isCentered,
|
|
160
|
+
onCloseComplete,
|
|
161
|
+
finalFocusRef,
|
|
162
|
+
"aria-label": ariaLabel,
|
|
163
|
+
children,
|
|
164
|
+
...rest
|
|
165
|
+
}: ModalProps) => {
|
|
166
|
+
// Set by a DialogTrigger (or any react-aria overlay trigger) above us. When
|
|
167
|
+
// `isOpen` is given it is ignored: RAC's ModalOverlay prefers an explicit
|
|
168
|
+
// prop over the context, and so do we for the close function.
|
|
169
|
+
const triggerState = useContext(OverlayTriggerStateContext);
|
|
170
|
+
const close = onClose ?? (() => triggerState?.close());
|
|
171
|
+
const dataProps = dataAttrs(rest);
|
|
172
|
+
const slots = dialog({ size, centered: isCentered });
|
|
173
|
+
const motionlessClass = motionless
|
|
174
|
+
? css({
|
|
175
|
+
transition: "none",
|
|
176
|
+
"&[data-entering]": { opacity: 1, transform: "none" },
|
|
177
|
+
"&[data-exiting]": { opacity: 1, transform: "none" },
|
|
178
|
+
})
|
|
179
|
+
: undefined;
|
|
180
|
+
const handleUnmount = () => {
|
|
181
|
+
onCloseComplete?.();
|
|
182
|
+
const el = finalFocusRef?.current;
|
|
183
|
+
if (el) {
|
|
184
|
+
// After RAC's own focus restoration, which also runs on unmount.
|
|
185
|
+
requestAnimationFrame(() => el.focus());
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
return (
|
|
189
|
+
<ModalOverlay
|
|
190
|
+
isOpen={isOpen}
|
|
191
|
+
onOpenChange={(open) => {
|
|
192
|
+
if (!open) {
|
|
193
|
+
close();
|
|
194
|
+
}
|
|
195
|
+
}}
|
|
196
|
+
isDismissable={isDismissable}
|
|
197
|
+
isKeyboardDismissDisabled={isKeyboardDismissDisabled}
|
|
198
|
+
className={cx(
|
|
199
|
+
slots.overlay,
|
|
200
|
+
motionlessClass,
|
|
201
|
+
overlayCss ? css(overlayCss) : undefined,
|
|
202
|
+
)}
|
|
203
|
+
>
|
|
204
|
+
<UnmountCallback callback={handleUnmount} />
|
|
205
|
+
<RACModal
|
|
206
|
+
{...dataProps}
|
|
207
|
+
style={contentStyle}
|
|
208
|
+
className={cx(
|
|
209
|
+
slots.content,
|
|
210
|
+
motionlessClass,
|
|
211
|
+
contentCss ? css(contentCss) : undefined,
|
|
212
|
+
)}
|
|
213
|
+
>
|
|
214
|
+
<Dialog role={role} aria-label={ariaLabel} className={slots.inner}>
|
|
215
|
+
<SlotContext.Provider value={{ slots, onClose: close }}>
|
|
216
|
+
{children}
|
|
217
|
+
</SlotContext.Provider>
|
|
218
|
+
</Dialog>
|
|
219
|
+
</RACModal>
|
|
220
|
+
</ModalOverlay>
|
|
221
|
+
);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* DialogTrigger — react-aria-components' <DialogTrigger>: wrap a trigger
|
|
226
|
+
* element and a `Modal`, and the open state is theirs rather than yours.
|
|
227
|
+
*
|
|
228
|
+
* ```tsx
|
|
229
|
+
* <DialogTrigger>
|
|
230
|
+
* <Button>Settings</Button>
|
|
231
|
+
* <Modal size="lg">
|
|
232
|
+
* <ModalHeader>Settings</ModalHeader>
|
|
233
|
+
* …
|
|
234
|
+
* </Modal>
|
|
235
|
+
* </DialogTrigger>
|
|
236
|
+
* ```
|
|
237
|
+
*
|
|
238
|
+
* Only for a dialog with a single trigger beside it. A dialog opened from
|
|
239
|
+
* more than one place, from a menu item (which cannot hold a dialog — a
|
|
240
|
+
* non-collection child truncates the menu), or from a handler, wants the
|
|
241
|
+
* controlled `Modal` instead.
|
|
242
|
+
*/
|
|
243
|
+
export const DialogTrigger = RACDialogTrigger;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The current dialog's close function — the same one `ModalCloseButton` uses,
|
|
247
|
+
* for a footer's own Cancel/Done buttons. Works in both modes, so a dialog's
|
|
248
|
+
* content need not know which is driving it.
|
|
249
|
+
*/
|
|
250
|
+
export const useDialogClose = () => useDialog().onClose;
|
|
251
|
+
|
|
252
|
+
interface SlotProps {
|
|
253
|
+
children?: ReactNode;
|
|
254
|
+
css?: SystemStyleObject;
|
|
255
|
+
className?: string;
|
|
256
|
+
/** `data-*` attributes land on the slot element, as they did on Chakra's. */
|
|
257
|
+
[key: `data-${string}`]: unknown;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Modal title. Rendered as RAC's labelling heading for the dialog. */
|
|
261
|
+
export const ModalHeader = ({
|
|
262
|
+
children,
|
|
263
|
+
css: cssProp,
|
|
264
|
+
className,
|
|
265
|
+
level,
|
|
266
|
+
...rest
|
|
267
|
+
}: SlotProps & {
|
|
268
|
+
/**
|
|
269
|
+
* Heading element level. Defaults to 2: RAC's Dialog supplies that through
|
|
270
|
+
* HeadingContext for the `title` slot, not the bare Heading default of 3.
|
|
271
|
+
*/
|
|
272
|
+
level?: number;
|
|
273
|
+
}) => {
|
|
274
|
+
const { slots } = useDialog();
|
|
275
|
+
return (
|
|
276
|
+
<RACHeading
|
|
277
|
+
{...dataAttrs(rest)}
|
|
278
|
+
slot="title"
|
|
279
|
+
level={level}
|
|
280
|
+
className={cx(
|
|
281
|
+
slots.header,
|
|
282
|
+
cssProp ? css(cssProp) : undefined,
|
|
283
|
+
className,
|
|
284
|
+
)}
|
|
285
|
+
>
|
|
286
|
+
{children}
|
|
287
|
+
</RACHeading>
|
|
288
|
+
);
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
export const ModalBody = ({
|
|
292
|
+
children,
|
|
293
|
+
css: cssProp,
|
|
294
|
+
className,
|
|
295
|
+
...rest
|
|
296
|
+
}: SlotProps) => {
|
|
297
|
+
const { slots } = useDialog();
|
|
298
|
+
return (
|
|
299
|
+
<div
|
|
300
|
+
{...dataAttrs(rest)}
|
|
301
|
+
className={cx(slots.body, cssProp ? css(cssProp) : undefined, className)}
|
|
302
|
+
>
|
|
303
|
+
{children}
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
export const ModalFooter = ({
|
|
309
|
+
children,
|
|
310
|
+
css: cssProp,
|
|
311
|
+
className,
|
|
312
|
+
...rest
|
|
313
|
+
}: SlotProps) => {
|
|
314
|
+
const { slots } = useDialog();
|
|
315
|
+
return (
|
|
316
|
+
<div
|
|
317
|
+
{...dataAttrs(rest)}
|
|
318
|
+
className={cx(
|
|
319
|
+
slots.footer,
|
|
320
|
+
cssProp ? css(cssProp) : undefined,
|
|
321
|
+
className,
|
|
322
|
+
)}
|
|
323
|
+
>
|
|
324
|
+
{children}
|
|
325
|
+
</div>
|
|
326
|
+
);
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
export interface ModalCloseButtonProps {
|
|
330
|
+
/** Accessible name; defaults to the localized close label. */
|
|
331
|
+
"aria-label"?: string;
|
|
332
|
+
/** `data-*` attributes land on the button. */
|
|
333
|
+
[key: `data-${string}`]: unknown;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* ModalCloseButton — the X in the dialog's top corner (Chakra's
|
|
338
|
+
* ModalCloseButton at its default md size). Closes via the Modal's onClose.
|
|
339
|
+
*/
|
|
340
|
+
export const ModalCloseButton = ({
|
|
341
|
+
"aria-label": ariaLabel,
|
|
342
|
+
...rest
|
|
343
|
+
}: ModalCloseButtonProps) => {
|
|
344
|
+
const intl = useIntl();
|
|
345
|
+
const { slots, onClose } = useDialog();
|
|
346
|
+
return (
|
|
347
|
+
<RACButton
|
|
348
|
+
{...dataAttrs(rest)}
|
|
349
|
+
aria-label={ariaLabel ?? intl.formatMessage(uiMessage("ui.close-action"))}
|
|
350
|
+
onPress={onClose}
|
|
351
|
+
className={cx(
|
|
352
|
+
slots.closeTrigger,
|
|
353
|
+
css({
|
|
354
|
+
display: "inline-flex",
|
|
355
|
+
alignItems: "center",
|
|
356
|
+
justifyContent: "center",
|
|
357
|
+
width: "8",
|
|
358
|
+
height: "8",
|
|
359
|
+
fontSize: "xs",
|
|
360
|
+
borderRadius: "md",
|
|
361
|
+
cursor: "pointer",
|
|
362
|
+
bg: "transparent",
|
|
363
|
+
border: "none",
|
|
364
|
+
color: "inherit",
|
|
365
|
+
outline: "none",
|
|
366
|
+
transitionProperty: "background-color, box-shadow",
|
|
367
|
+
transitionDuration: "normal",
|
|
368
|
+
_hover: { bg: "blackAlpha.100" },
|
|
369
|
+
_active: { bg: "blackAlpha.200" },
|
|
370
|
+
_focusVisible: { focusShadow: "outline" },
|
|
371
|
+
}),
|
|
372
|
+
)}
|
|
373
|
+
>
|
|
374
|
+
<CloseIcon />
|
|
375
|
+
</RACButton>
|
|
376
|
+
);
|
|
377
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import { forwardRef, SelectHTMLAttributes } from "react";
|
|
7
|
+
import { css, cx } from "styled-system/css";
|
|
8
|
+
import { input, InputVariantProps } from "styled-system/recipes";
|
|
9
|
+
import { SystemStyleObject } from "styled-system/types";
|
|
10
|
+
|
|
11
|
+
export interface NativeSelectProps
|
|
12
|
+
// `size` is the recipe's size scale, as in Chakra; the native visible-rows
|
|
13
|
+
// attribute it shadows was unused.
|
|
14
|
+
extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "className" | "size">,
|
|
15
|
+
InputVariantProps {
|
|
16
|
+
/**
|
|
17
|
+
* Suppress the dropdown chevron, e.g. when an adjacent control provides the
|
|
18
|
+
* affordance. Without the chevron a bare <select> element is rendered (no
|
|
19
|
+
* wrapper), so it can participate directly in e.g. an attached ButtonGroup.
|
|
20
|
+
*/
|
|
21
|
+
hideChevron?: boolean;
|
|
22
|
+
/** Per-instance style overrides for the select, merged after the recipe. */
|
|
23
|
+
css?: SystemStyleObject;
|
|
24
|
+
/**
|
|
25
|
+
* Style overrides for the chevron wrapper, which is where width constraints
|
|
26
|
+
* belong (the select fills it). Chakra's Select worked the same way: layout
|
|
27
|
+
* props were split onto `.chakra-select__wrapper`, `width: 100%` otherwise.
|
|
28
|
+
* No wrapper is rendered with `hideChevron`; constrain the select directly.
|
|
29
|
+
*/
|
|
30
|
+
wrapperCss?: SystemStyleObject;
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* NativeSelect — a native select styled like Chakra's Select field (outline).
|
|
36
|
+
* The recipe's `appearance: none` removes the platform chevron, so one is
|
|
37
|
+
* drawn back in by default (Chakra Select's glyph, `currentColor`).
|
|
38
|
+
*/
|
|
39
|
+
export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
|
|
40
|
+
function NativeSelect(
|
|
41
|
+
{ hideChevron = false, size, css: cssProp, wrapperCss, className, ...rest },
|
|
42
|
+
ref,
|
|
43
|
+
) {
|
|
44
|
+
const select = (
|
|
45
|
+
<select
|
|
46
|
+
ref={ref}
|
|
47
|
+
className={cx(
|
|
48
|
+
input({ size }),
|
|
49
|
+
css(
|
|
50
|
+
{ cursor: "pointer" },
|
|
51
|
+
// Room for the chevron overlay (Chakra Select's icon spacing,
|
|
52
|
+
// constant across sizes).
|
|
53
|
+
hideChevron ? undefined : { paddingRight: "8" },
|
|
54
|
+
cssProp,
|
|
55
|
+
),
|
|
56
|
+
className,
|
|
57
|
+
)}
|
|
58
|
+
{...rest}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
if (hideChevron) {
|
|
62
|
+
return select;
|
|
63
|
+
}
|
|
64
|
+
return (
|
|
65
|
+
<span
|
|
66
|
+
className={css(
|
|
67
|
+
// Full-width like every other field (and Chakra's select wrapper);
|
|
68
|
+
// the select's own recipe width fills it. Constrain via wrapperCss.
|
|
69
|
+
{ position: "relative", display: "inline-flex", width: "100%" },
|
|
70
|
+
wrapperCss,
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
{select}
|
|
74
|
+
{/* Chakra Select's chevron, fixed-size across field sizes. */}
|
|
75
|
+
<svg
|
|
76
|
+
viewBox="0 0 24 24"
|
|
77
|
+
aria-hidden
|
|
78
|
+
className={css({
|
|
79
|
+
position: "absolute",
|
|
80
|
+
right: "2",
|
|
81
|
+
top: "50%",
|
|
82
|
+
transform: "translateY(-50%)",
|
|
83
|
+
width: "5",
|
|
84
|
+
height: "5",
|
|
85
|
+
pointerEvents: "none",
|
|
86
|
+
fill: "currentColor",
|
|
87
|
+
})}
|
|
88
|
+
>
|
|
89
|
+
<path d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z" />
|
|
90
|
+
</svg>
|
|
91
|
+
</span>
|
|
92
|
+
);
|
|
93
|
+
},
|
|
94
|
+
);
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* NumberField slot recipe — Chakra's NumberInput look: an outline input (the
|
|
10
|
+
* `input` recipe styles the input itself) with a right-hand stepper column of
|
|
11
|
+
* two stacked buttons. Consumed by the shared-ui NumberField
|
|
12
|
+
* (react-aria-components NumberField).
|
|
13
|
+
*
|
|
14
|
+
* Registered in the base preset (base-preset.ts). No variants, so it needs
|
|
15
|
+
* no `staticCss` entry.
|
|
16
|
+
*/
|
|
17
|
+
export const numberField = defineSlotRecipe({
|
|
18
|
+
className: "numberField",
|
|
19
|
+
slots: ["root", "group", "stepper", "stepperButton"],
|
|
20
|
+
base: {
|
|
21
|
+
root: {
|
|
22
|
+
display: "flex",
|
|
23
|
+
flexDirection: "column",
|
|
24
|
+
alignItems: "stretch",
|
|
25
|
+
},
|
|
26
|
+
group: {
|
|
27
|
+
position: "relative",
|
|
28
|
+
zIndex: 0,
|
|
29
|
+
},
|
|
30
|
+
// Chakra's NumberInputStepper: a column overlaying the input's right
|
|
31
|
+
// edge, inset by the input border.
|
|
32
|
+
stepper: {
|
|
33
|
+
display: "flex",
|
|
34
|
+
flexDirection: "column",
|
|
35
|
+
position: "absolute",
|
|
36
|
+
insetEnd: "0",
|
|
37
|
+
top: "0",
|
|
38
|
+
height: "calc(100% - 2px)",
|
|
39
|
+
margin: "1px",
|
|
40
|
+
width: "6",
|
|
41
|
+
zIndex: 1,
|
|
42
|
+
},
|
|
43
|
+
stepperButton: {
|
|
44
|
+
display: "flex",
|
|
45
|
+
alignItems: "center",
|
|
46
|
+
justifyContent: "center",
|
|
47
|
+
flex: 1,
|
|
48
|
+
cursor: "pointer",
|
|
49
|
+
lineHeight: "normal",
|
|
50
|
+
fontSize: "xs",
|
|
51
|
+
color: "inherit",
|
|
52
|
+
bg: "transparent",
|
|
53
|
+
borderStart: "1px solid",
|
|
54
|
+
borderColor: "gray.200",
|
|
55
|
+
transitionProperty: "background",
|
|
56
|
+
transitionDuration: "ultra-fast",
|
|
57
|
+
"&:last-child": {
|
|
58
|
+
borderTop: "1px solid",
|
|
59
|
+
borderTopColor: "gray.200",
|
|
60
|
+
marginTop: "-1px",
|
|
61
|
+
},
|
|
62
|
+
"&[data-hovered]": { bg: "gray.100" },
|
|
63
|
+
"&[data-pressed]": { bg: "gray.200" },
|
|
64
|
+
"&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import { forwardRef, ReactNode } from "react";
|
|
7
|
+
import {
|
|
8
|
+
Button as RACButton,
|
|
9
|
+
Group as RACGroup,
|
|
10
|
+
Input as RACInput,
|
|
11
|
+
Label as RACLabel,
|
|
12
|
+
NumberField as RACNumberField,
|
|
13
|
+
NumberFieldProps as RACNumberFieldProps,
|
|
14
|
+
} from "react-aria-components";
|
|
15
|
+
import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
|
|
16
|
+
import { css, cx } from "styled-system/css";
|
|
17
|
+
import { field, input, numberField } from "styled-system/recipes";
|
|
18
|
+
import { SystemStyleObject } from "styled-system/types";
|
|
19
|
+
import { Icon } from "./Icon";
|
|
20
|
+
|
|
21
|
+
export interface NumberFieldProps
|
|
22
|
+
extends Omit<RACNumberFieldProps, "className" | "children" | "style"> {
|
|
23
|
+
/** Visible label (optional; otherwise pass `aria-label`). */
|
|
24
|
+
label?: ReactNode;
|
|
25
|
+
/** Root style overrides (e.g. row layout for label-beside-field forms). */
|
|
26
|
+
css?: SystemStyleObject;
|
|
27
|
+
/** Label style overrides. */
|
|
28
|
+
labelCss?: SystemStyleObject;
|
|
29
|
+
/** Group (input + steppers) style overrides — set `width` here. */
|
|
30
|
+
groupCss?: SystemStyleObject;
|
|
31
|
+
/** Input style overrides (e.g. a smaller size than the recipe's md). */
|
|
32
|
+
inputCss?: SystemStyleObject;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* NumberField — react-aria-components <NumberField> styled like Chakra's
|
|
37
|
+
* NumberInput (outline input + right-hand stepper column). The ref is
|
|
38
|
+
* forwarded to the input element. Value clamping to min/maxValue is
|
|
39
|
+
* handled by react-aria; onChange receives NaN when the field is emptied.
|
|
40
|
+
*/
|
|
41
|
+
export const NumberField = forwardRef<HTMLInputElement, NumberFieldProps>(
|
|
42
|
+
function NumberField(
|
|
43
|
+
{ label, css: cssProp, labelCss, groupCss, inputCss, ...rest },
|
|
44
|
+
ref,
|
|
45
|
+
) {
|
|
46
|
+
const slots = numberField();
|
|
47
|
+
const fieldSlots = field();
|
|
48
|
+
return (
|
|
49
|
+
<RACNumberField
|
|
50
|
+
{...rest}
|
|
51
|
+
className={cx(slots.root, cssProp ? css(cssProp) : undefined)}
|
|
52
|
+
>
|
|
53
|
+
{label != null && (
|
|
54
|
+
<RACLabel
|
|
55
|
+
className={cx(
|
|
56
|
+
fieldSlots.label,
|
|
57
|
+
labelCss ? css(labelCss) : undefined,
|
|
58
|
+
)}
|
|
59
|
+
>
|
|
60
|
+
{label}
|
|
61
|
+
</RACLabel>
|
|
62
|
+
)}
|
|
63
|
+
<RACGroup
|
|
64
|
+
className={cx(slots.group, groupCss ? css(groupCss) : undefined)}
|
|
65
|
+
>
|
|
66
|
+
<RACInput
|
|
67
|
+
ref={ref}
|
|
68
|
+
className={cx(
|
|
69
|
+
input(),
|
|
70
|
+
// Room for the stepper column.
|
|
71
|
+
css({ paddingEnd: "6" }, inputCss),
|
|
72
|
+
)}
|
|
73
|
+
/>
|
|
74
|
+
<div className={slots.stepper}>
|
|
75
|
+
<RACButton slot="increment" className={slots.stepperButton}>
|
|
76
|
+
<Icon as={RiArrowUpSFill} />
|
|
77
|
+
</RACButton>
|
|
78
|
+
<RACButton slot="decrement" className={slots.stepperButton}>
|
|
79
|
+
<Icon as={RiArrowDownSFill} />
|
|
80
|
+
</RACButton>
|
|
81
|
+
</div>
|
|
82
|
+
</RACGroup>
|
|
83
|
+
</RACNumberField>
|
|
84
|
+
);
|
|
85
|
+
},
|
|
86
|
+
);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import { OverlayArrow } from "react-aria-components";
|
|
7
|
+
import { css, cx } from "styled-system/css";
|
|
8
|
+
import { SystemStyleObject } from "styled-system/types";
|
|
9
|
+
|
|
10
|
+
// react-aria positions the arrow and stamps the resolved side on the wrapper
|
|
11
|
+
// as data-placement, but leaves orienting the glyph to us. The svg is square
|
|
12
|
+
// with the triangle in the half nearest the overlay (tip at centre), so
|
|
13
|
+
// rotating about the centre keeps it flush against the overlay on every side
|
|
14
|
+
// — no translate fix-ups, whose signs depend on rotation direction and are
|
|
15
|
+
// easy to get wrong (they detached the tooltip arrow from its box).
|
|
16
|
+
//
|
|
17
|
+
// "Flush" still antialiases to a hairline seam when the overlay's edge lands
|
|
18
|
+
// on a subpixel boundary, so each placement also pulls the svg 1px into the
|
|
19
|
+
// overlay with a negative margin on the overlay-facing side (margins move the
|
|
20
|
+
// box before the rotate, so the bleed direction is unaffected by it).
|
|
21
|
+
const arrowBase = css({
|
|
22
|
+
"& svg": { display: "block" },
|
|
23
|
+
"&[data-placement='top'] svg": { marginTop: "-1px" },
|
|
24
|
+
"&[data-placement='bottom'] svg": {
|
|
25
|
+
transform: "rotate(180deg)",
|
|
26
|
+
marginBottom: "-1px",
|
|
27
|
+
},
|
|
28
|
+
"&[data-placement='right'] svg": {
|
|
29
|
+
transform: "rotate(90deg)",
|
|
30
|
+
marginRight: "-1px",
|
|
31
|
+
},
|
|
32
|
+
"&[data-placement='left'] svg": {
|
|
33
|
+
transform: "rotate(-90deg)",
|
|
34
|
+
marginLeft: "-1px",
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export interface PopoverArrowProps {
|
|
39
|
+
/**
|
|
40
|
+
* Base width of the visible triangle in px; it protrudes half this into
|
|
41
|
+
* the gap. Defaults to Chakra's tooltip arrow proportions (an 8px square
|
|
42
|
+
* rotated 45°).
|
|
43
|
+
*/
|
|
44
|
+
size?: number;
|
|
45
|
+
/** Styles merged after the base, e.g. `{ "& svg": { fill: "white" } }`. */
|
|
46
|
+
css?: SystemStyleObject;
|
|
47
|
+
className?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* PopoverArrow — the notch pointing from a popover or tooltip at its
|
|
52
|
+
* trigger, oriented automatically for react-aria's resolved placement.
|
|
53
|
+
* Render inside a RAC Popover/Tooltip; set the fill via the css prop.
|
|
54
|
+
*/
|
|
55
|
+
export const PopoverArrow = ({
|
|
56
|
+
size = 11.314,
|
|
57
|
+
css: cssProp,
|
|
58
|
+
className,
|
|
59
|
+
}: PopoverArrowProps) => (
|
|
60
|
+
<OverlayArrow
|
|
61
|
+
className={cx(arrowBase, cssProp ? css(cssProp) : undefined, className)}
|
|
62
|
+
>
|
|
63
|
+
<svg width={size} height={size} viewBox="0 0 12 12">
|
|
64
|
+
<path d="M0 0 L6 6 L12 0" />
|
|
65
|
+
</svg>
|
|
66
|
+
</OverlayArrow>
|
|
67
|
+
);
|