@microbit/ui 0.1.0-alpha.9 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +8 -0
- package/README.md +171 -20
- package/lang/ui.ar.json +62 -0
- package/lang/ui.ca.json +43 -3
- package/lang/ui.cy.json +62 -0
- package/lang/ui.de.json +44 -4
- package/lang/ui.en-us.json +40 -0
- package/lang/ui.en.json +40 -0
- package/lang/ui.es-es.json +43 -3
- package/lang/ui.fr.json +44 -4
- package/lang/ui.ga-ie.json +43 -3
- package/lang/ui.it.json +62 -0
- package/lang/ui.ja.json +44 -4
- package/lang/ui.ko.json +44 -4
- package/lang/ui.lo.json +62 -0
- package/lang/ui.lol.json +40 -0
- package/lang/ui.nl.json +44 -4
- package/lang/ui.pl.json +44 -4
- package/lang/ui.pt-br.json +44 -4
- package/lang/ui.vi.json +62 -0
- package/lang/ui.zh-cn.json +44 -4
- package/lang/ui.zh-tw.json +44 -4
- package/package.json +12 -10
- package/src/Avatar.recipe.ts +191 -0
- package/src/Avatar.tsx +278 -0
- package/src/Breadcrumb.recipe.ts +45 -0
- package/src/Breadcrumb.tsx +114 -0
- package/src/Button.recipe.ts +88 -50
- package/src/Button.tsx +67 -14
- package/src/ButtonGroup.tsx +37 -15
- package/src/Card.recipe.ts +1 -2
- package/src/Card.tsx +2 -1
- package/src/Checkbox.recipe.ts +13 -12
- package/src/Checkbox.tsx +103 -34
- package/src/CheckboxGroup.tsx +70 -0
- package/src/CloseButton.tsx +3 -3
- package/src/CloseIcon.tsx +6 -4
- package/src/Code.tsx +1 -1
- package/src/Collapse.tsx +13 -14
- package/src/ComboBox.tsx +246 -0
- package/src/Divider.tsx +40 -7
- package/src/Drawer.recipe.ts +21 -10
- package/src/Drawer.tsx +3 -4
- package/src/ExternalLink.tsx +43 -0
- package/src/Fade.tsx +18 -4
- package/src/Field.recipe.ts +114 -0
- package/src/Field.tsx +187 -0
- package/src/GridList.recipe.ts +57 -0
- package/src/GridList.tsx +81 -0
- package/src/Heading.recipe.ts +20 -1
- package/src/Heading.tsx +3 -3
- package/src/Icon.tsx +23 -7
- package/src/IconButton.tsx +8 -18
- package/src/Image.tsx +1 -1
- package/src/Input.recipe.ts +30 -26
- package/src/Input.tsx +16 -7
- package/src/InputGroup.tsx +9 -9
- package/src/Kbd.tsx +1 -1
- package/src/Link.tsx +3 -3
- package/src/LinkBox.tsx +2 -3
- package/src/LinkButton.tsx +14 -13
- package/src/List.tsx +5 -7
- package/src/ListBox.recipe.ts +43 -0
- package/src/ListBox.tsx +88 -0
- package/src/Menu.recipe.ts +22 -19
- package/src/Menu.tsx +54 -28
- package/src/Modal.recipe.ts +17 -9
- package/src/Modal.tsx +123 -24
- package/src/MoreMenuButton.tsx +63 -0
- package/src/NativeSelect.tsx +37 -14
- package/src/NativeSelectField.tsx +84 -0
- package/src/NumberField.recipe.ts +56 -15
- package/src/NumberField.tsx +74 -22
- package/src/PopoverArrow.tsx +1 -2
- package/src/ProgressBar.tsx +3 -5
- package/src/Radio.recipe.ts +13 -12
- package/src/Radio.tsx +2 -29
- package/src/RadioGroup.tsx +68 -0
- package/src/Select.recipe.ts +211 -0
- package/src/Select.tsx +194 -0
- package/src/SharedUIProvider.tsx +63 -7
- package/src/Skeleton.tsx +146 -0
- package/src/Slide.tsx +2 -2
- package/src/Slider.recipe.ts +27 -16
- package/src/Slider.tsx +20 -12
- package/src/Spinner.tsx +11 -9
- package/src/Svg.tsx +2 -3
- package/src/Switch.recipe.ts +40 -21
- package/src/Switch.tsx +35 -5
- package/src/Text.recipe.ts +26 -0
- package/src/Text.tsx +6 -2
- package/src/TextField.tsx +35 -38
- package/src/Toast.recipe.ts +52 -15
- package/src/Toast.tsx +147 -48
- package/src/Tooltip.recipe.ts +32 -0
- package/src/Tooltip.tsx +68 -28
- package/src/TooltipButton.tsx +229 -0
- package/src/UnmountCallback.tsx +2 -2
- package/src/VisuallyHidden.tsx +1 -1
- package/src/base-preset.ts +323 -84
- package/src/base-tokens.ts +0 -3
- package/src/button-icon.ts +4 -5
- package/src/data-attrs.ts +16 -0
- package/src/dense-preset.ts +107 -0
- package/src/hooks/useBreakpointValue.ts +4 -4
- package/src/hooks/useClipboard.ts +5 -6
- package/src/hooks/useDisclosure.ts +31 -0
- package/src/hooks/useMediaQuery.ts +3 -4
- package/src/hooks/usePrevious.ts +1 -4
- package/src/index.ts +19 -3
- package/src/rac-locale.ts +33 -0
- package/src/system.ts +22 -1
- package/src/TextField.recipe.ts +0 -54
package/src/Modal.tsx
CHANGED
|
@@ -13,15 +13,18 @@ import {
|
|
|
13
13
|
import {
|
|
14
14
|
Button as RACButton,
|
|
15
15
|
Dialog,
|
|
16
|
+
DialogTrigger as RACDialogTrigger,
|
|
16
17
|
Heading as RACHeading,
|
|
17
18
|
Modal as RACModal,
|
|
18
19
|
ModalOverlay,
|
|
20
|
+
OverlayTriggerStateContext,
|
|
19
21
|
} from "react-aria-components";
|
|
20
22
|
import { css, cx } from "styled-system/css";
|
|
21
23
|
import { dialog } from "styled-system/recipes";
|
|
22
24
|
import { ConditionalValue, SystemStyleObject } from "styled-system/types";
|
|
23
25
|
import { useIntl } from "react-intl";
|
|
24
26
|
import { CloseIcon } from "./CloseIcon";
|
|
27
|
+
import { dataAttrs } from "./data-attrs";
|
|
25
28
|
import { uiMessage } from "./messages";
|
|
26
29
|
import { UnmountCallback } from "./UnmountCallback";
|
|
27
30
|
|
|
@@ -48,17 +51,15 @@ export type ModalSize = ConditionalValue<
|
|
|
48
51
|
| "full"
|
|
49
52
|
>;
|
|
50
53
|
|
|
51
|
-
export interface
|
|
52
|
-
isOpen: boolean;
|
|
53
|
-
onClose: () => void;
|
|
54
|
+
export interface ModalOwnProps {
|
|
54
55
|
size?: ModalSize;
|
|
55
56
|
/** Allow closing by clicking the backdrop (default true; Escape always closes). */
|
|
56
57
|
isDismissable?: boolean;
|
|
57
|
-
/** Disable the enter/exit transitions
|
|
58
|
+
/** Disable the enter/exit transitions. */
|
|
58
59
|
motionless?: boolean;
|
|
59
|
-
/** Prevent Escape closing the dialog
|
|
60
|
+
/** Prevent Escape closing the dialog. */
|
|
60
61
|
isKeyboardDismissDisabled?: boolean;
|
|
61
|
-
/** Style overrides for the dialog box
|
|
62
|
+
/** Style overrides for the dialog box. */
|
|
62
63
|
contentCss?: SystemStyleObject;
|
|
63
64
|
/**
|
|
64
65
|
* Inline styles for the dialog box, for runtime-computed positioning that
|
|
@@ -67,25 +68,22 @@ export interface ModalProps {
|
|
|
67
68
|
*/
|
|
68
69
|
contentStyle?: CSSProperties;
|
|
69
70
|
/**
|
|
70
|
-
* Style overrides for the backdrop
|
|
71
|
-
*
|
|
71
|
+
* Style overrides for the backdrop, e.g. a transparent backdrop when
|
|
72
|
+
* something else provides the dimming.
|
|
72
73
|
*/
|
|
73
74
|
overlayCss?: SystemStyleObject;
|
|
74
|
-
/**
|
|
75
|
-
* Use "alertdialog" for confirmations that interrupt the user (Chakra's
|
|
76
|
-
* AlertDialog).
|
|
77
|
-
*/
|
|
75
|
+
/** Use "alertdialog" for confirmations that interrupt the user. */
|
|
78
76
|
role?: "dialog" | "alertdialog";
|
|
79
|
-
/** Vertically centre the dialog
|
|
77
|
+
/** Vertically centre the dialog. */
|
|
80
78
|
isCentered?: boolean;
|
|
81
79
|
/**
|
|
82
80
|
* Called after the dialog has fully closed (exit transition done and the
|
|
83
|
-
* dialog removed).
|
|
81
|
+
* dialog removed).
|
|
84
82
|
*/
|
|
85
83
|
onCloseComplete?: () => void;
|
|
86
84
|
/**
|
|
87
85
|
* Element to focus when the dialog closes, instead of the element that was
|
|
88
|
-
* focused when it opened.
|
|
86
|
+
* focused when it opened.
|
|
89
87
|
*/
|
|
90
88
|
finalFocusRef?: RefObject<HTMLElement>;
|
|
91
89
|
/**
|
|
@@ -96,12 +94,51 @@ export interface ModalProps {
|
|
|
96
94
|
*/
|
|
97
95
|
"aria-label"?: string;
|
|
98
96
|
children: ReactNode;
|
|
97
|
+
/**
|
|
98
|
+
* `data-*` attributes land on the dialog box, so end-to-end tests can
|
|
99
|
+
* address a dialog. Shells that forward their caller's data attributes can
|
|
100
|
+
* spread them straight in.
|
|
101
|
+
*/
|
|
102
|
+
[key: `data-${string}`]: unknown;
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
/**
|
|
102
|
-
* Modal
|
|
103
|
-
*
|
|
106
|
+
* A Modal you drive yourself. Also the type for a component that *forwards*
|
|
107
|
+
* modal props — `Omit<ControlledModalProps, "children">` — because a spread
|
|
108
|
+
* cannot be matched against the union `ModalProps` is: TypeScript has no way
|
|
109
|
+
* to know which half of it an object with `isOpen?: boolean` satisfies.
|
|
110
|
+
*/
|
|
111
|
+
export type ControlledModalProps = ModalOwnProps & {
|
|
112
|
+
/** Whether the dialog is showing. */
|
|
113
|
+
isOpen: boolean;
|
|
114
|
+
/** Called when the dialog asks to close. */
|
|
115
|
+
onClose: () => void;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The props of a `Modal`: its own, plus an open state that is either entirely
|
|
120
|
+
* yours or entirely a `DialogTrigger`'s. Never half of each — `isOpen`
|
|
121
|
+
* without `onClose` leaves the close button and Escape with nothing to call,
|
|
122
|
+
* so the pair is enforced rather than merely documented.
|
|
123
|
+
*/
|
|
124
|
+
export type ModalProps =
|
|
125
|
+
| ControlledModalProps
|
|
126
|
+
| (ModalOwnProps & { isOpen?: never; onClose?: never });
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Modal — a focus-trapping dialog in a single shell; place ModalHeader,
|
|
104
130
|
* ModalBody and ModalFooter inside.
|
|
131
|
+
*
|
|
132
|
+
* Two ways to drive it:
|
|
133
|
+
*
|
|
134
|
+
* - **Controlled** (`isOpen` + `onClose`), which is what any dialog with more
|
|
135
|
+
* than one opener needs — a menu item and a toolbar button opening the same
|
|
136
|
+
* dialog, or one opened from a handler after an async result.
|
|
137
|
+
* - **Inside a `DialogTrigger`**, with neither prop: react-aria holds the
|
|
138
|
+
* open state, the trigger opens it, and `ModalCloseButton` and the footer's
|
|
139
|
+
* `useDialogClose()` still close it. Prefer this where a dialog has exactly
|
|
140
|
+
* one trigger sitting next to it — there is no state to hold, and none to
|
|
141
|
+
* get out of step.
|
|
105
142
|
*/
|
|
106
143
|
export const Modal = ({
|
|
107
144
|
isOpen,
|
|
@@ -119,7 +156,14 @@ export const Modal = ({
|
|
|
119
156
|
finalFocusRef,
|
|
120
157
|
"aria-label": ariaLabel,
|
|
121
158
|
children,
|
|
159
|
+
...rest
|
|
122
160
|
}: ModalProps) => {
|
|
161
|
+
// Set by a DialogTrigger (or any react-aria overlay trigger) above us. When
|
|
162
|
+
// `isOpen` is given it is ignored: RAC's ModalOverlay prefers an explicit
|
|
163
|
+
// prop over the context, and so do we for the close function.
|
|
164
|
+
const triggerState = useContext(OverlayTriggerStateContext);
|
|
165
|
+
const close = onClose ?? (() => triggerState?.close());
|
|
166
|
+
const dataProps = dataAttrs(rest);
|
|
123
167
|
const slots = dialog({ size, centered: isCentered });
|
|
124
168
|
const motionlessClass = motionless
|
|
125
169
|
? css({
|
|
@@ -141,11 +185,17 @@ export const Modal = ({
|
|
|
141
185
|
isOpen={isOpen}
|
|
142
186
|
onOpenChange={(open) => {
|
|
143
187
|
if (!open) {
|
|
144
|
-
|
|
188
|
+
close();
|
|
145
189
|
}
|
|
146
190
|
}}
|
|
147
191
|
isDismissable={isDismissable}
|
|
148
192
|
isKeyboardDismissDisabled={isKeyboardDismissDisabled}
|
|
193
|
+
// Marker for the html:has() rule in base-preset.ts that releases the
|
|
194
|
+
// scroll lock's reserved scrollbar gutter while a full-size dialog is
|
|
195
|
+
// open: the reserved strip is a hit-testing dead zone (clicks fall
|
|
196
|
+
// through to the root and read as outside-dismissal), and the page
|
|
197
|
+
// reflowing behind an opaque full-screen dialog is invisible.
|
|
198
|
+
data-fullsize={size === "full" || undefined}
|
|
149
199
|
className={cx(
|
|
150
200
|
slots.overlay,
|
|
151
201
|
motionlessClass,
|
|
@@ -154,6 +204,7 @@ export const Modal = ({
|
|
|
154
204
|
>
|
|
155
205
|
<UnmountCallback callback={handleUnmount} />
|
|
156
206
|
<RACModal
|
|
207
|
+
{...dataProps}
|
|
157
208
|
style={contentStyle}
|
|
158
209
|
className={cx(
|
|
159
210
|
slots.content,
|
|
@@ -162,7 +213,7 @@ export const Modal = ({
|
|
|
162
213
|
)}
|
|
163
214
|
>
|
|
164
215
|
<Dialog role={role} aria-label={ariaLabel} className={slots.inner}>
|
|
165
|
-
<SlotContext.Provider value={{ slots, onClose }}>
|
|
216
|
+
<SlotContext.Provider value={{ slots, onClose: close }}>
|
|
166
217
|
{children}
|
|
167
218
|
</SlotContext.Provider>
|
|
168
219
|
</Dialog>
|
|
@@ -171,10 +222,40 @@ export const Modal = ({
|
|
|
171
222
|
);
|
|
172
223
|
};
|
|
173
224
|
|
|
225
|
+
/**
|
|
226
|
+
* DialogTrigger — react-aria-components' <DialogTrigger>: wrap a trigger
|
|
227
|
+
* element and a `Modal`, and the open state is theirs rather than yours.
|
|
228
|
+
*
|
|
229
|
+
* ```tsx
|
|
230
|
+
* <DialogTrigger>
|
|
231
|
+
* <Button>Settings</Button>
|
|
232
|
+
* <Modal size="lg">
|
|
233
|
+
* <ModalHeader>Settings</ModalHeader>
|
|
234
|
+
* …
|
|
235
|
+
* </Modal>
|
|
236
|
+
* </DialogTrigger>
|
|
237
|
+
* ```
|
|
238
|
+
*
|
|
239
|
+
* Only for a dialog with a single trigger beside it. A dialog opened from
|
|
240
|
+
* more than one place, from a menu item (which cannot hold a dialog — a
|
|
241
|
+
* non-collection child truncates the menu), or from a handler, wants the
|
|
242
|
+
* controlled `Modal` instead.
|
|
243
|
+
*/
|
|
244
|
+
export const DialogTrigger = RACDialogTrigger;
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The current dialog's close function — the same one `ModalCloseButton` uses,
|
|
248
|
+
* for a footer's own Cancel/Done buttons. Works in both modes, so a dialog's
|
|
249
|
+
* content need not know which is driving it.
|
|
250
|
+
*/
|
|
251
|
+
export const useDialogClose = () => useDialog().onClose;
|
|
252
|
+
|
|
174
253
|
interface SlotProps {
|
|
175
254
|
children?: ReactNode;
|
|
176
255
|
css?: SystemStyleObject;
|
|
177
256
|
className?: string;
|
|
257
|
+
/** `data-*` attributes land on the slot element. */
|
|
258
|
+
[key: `data-${string}`]: unknown;
|
|
178
259
|
}
|
|
179
260
|
|
|
180
261
|
/** Modal title. Rendered as RAC's labelling heading for the dialog. */
|
|
@@ -183,12 +264,18 @@ export const ModalHeader = ({
|
|
|
183
264
|
css: cssProp,
|
|
184
265
|
className,
|
|
185
266
|
level,
|
|
267
|
+
...rest
|
|
186
268
|
}: SlotProps & {
|
|
187
|
-
/**
|
|
269
|
+
/**
|
|
270
|
+
* Heading element level. Defaults to 2: RAC's Dialog supplies that through
|
|
271
|
+
* HeadingContext for the `title` slot, not the bare Heading default of 3.
|
|
272
|
+
*/
|
|
273
|
+
level?: number;
|
|
188
274
|
}) => {
|
|
189
275
|
const { slots } = useDialog();
|
|
190
276
|
return (
|
|
191
277
|
<RACHeading
|
|
278
|
+
{...dataAttrs(rest)}
|
|
192
279
|
slot="title"
|
|
193
280
|
level={level}
|
|
194
281
|
className={cx(
|
|
@@ -202,10 +289,16 @@ export const ModalHeader = ({
|
|
|
202
289
|
);
|
|
203
290
|
};
|
|
204
291
|
|
|
205
|
-
export const ModalBody = ({
|
|
292
|
+
export const ModalBody = ({
|
|
293
|
+
children,
|
|
294
|
+
css: cssProp,
|
|
295
|
+
className,
|
|
296
|
+
...rest
|
|
297
|
+
}: SlotProps) => {
|
|
206
298
|
const { slots } = useDialog();
|
|
207
299
|
return (
|
|
208
300
|
<div
|
|
301
|
+
{...dataAttrs(rest)}
|
|
209
302
|
className={cx(slots.body, cssProp ? css(cssProp) : undefined, className)}
|
|
210
303
|
>
|
|
211
304
|
{children}
|
|
@@ -217,10 +310,12 @@ export const ModalFooter = ({
|
|
|
217
310
|
children,
|
|
218
311
|
css: cssProp,
|
|
219
312
|
className,
|
|
313
|
+
...rest
|
|
220
314
|
}: SlotProps) => {
|
|
221
315
|
const { slots } = useDialog();
|
|
222
316
|
return (
|
|
223
317
|
<div
|
|
318
|
+
{...dataAttrs(rest)}
|
|
224
319
|
className={cx(
|
|
225
320
|
slots.footer,
|
|
226
321
|
cssProp ? css(cssProp) : undefined,
|
|
@@ -235,19 +330,23 @@ export const ModalFooter = ({
|
|
|
235
330
|
export interface ModalCloseButtonProps {
|
|
236
331
|
/** Accessible name; defaults to the localized close label. */
|
|
237
332
|
"aria-label"?: string;
|
|
333
|
+
/** `data-*` attributes land on the button. */
|
|
334
|
+
[key: `data-${string}`]: unknown;
|
|
238
335
|
}
|
|
239
336
|
|
|
240
337
|
/**
|
|
241
|
-
* ModalCloseButton — the X in the dialog's top corner
|
|
242
|
-
*
|
|
338
|
+
* ModalCloseButton — the X in the dialog's top corner. Closes via the Modal's
|
|
339
|
+
* onClose.
|
|
243
340
|
*/
|
|
244
341
|
export const ModalCloseButton = ({
|
|
245
342
|
"aria-label": ariaLabel,
|
|
343
|
+
...rest
|
|
246
344
|
}: ModalCloseButtonProps) => {
|
|
247
345
|
const intl = useIntl();
|
|
248
346
|
const { slots, onClose } = useDialog();
|
|
249
347
|
return (
|
|
250
348
|
<RACButton
|
|
349
|
+
{...dataAttrs(rest)}
|
|
251
350
|
aria-label={ariaLabel ?? intl.formatMessage(uiMessage("ui.close-action"))}
|
|
252
351
|
onPress={onClose}
|
|
253
352
|
className={cx(
|
|
@@ -269,7 +368,7 @@ export const ModalCloseButton = ({
|
|
|
269
368
|
transitionDuration: "normal",
|
|
270
369
|
_hover: { bg: "blackAlpha.100" },
|
|
271
370
|
_active: { bg: "blackAlpha.200" },
|
|
272
|
-
_focusVisible: {
|
|
371
|
+
_focusVisible: { focusRing: "outline" },
|
|
273
372
|
}),
|
|
274
373
|
)}
|
|
275
374
|
>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import { forwardRef } from "react";
|
|
7
|
+
import { MdMoreVert } from "react-icons/md";
|
|
8
|
+
import { Icon } from "./Icon";
|
|
9
|
+
import { IconButton, IconButtonProps } from "./IconButton";
|
|
10
|
+
|
|
11
|
+
export interface MoreMenuButtonProps
|
|
12
|
+
extends Omit<IconButtonProps, "children"> {}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* MoreMenuButton — the "more options" half of a split button. Use as the
|
|
16
|
+
* trigger inside a MenuTrigger, alongside the main action, both in an
|
|
17
|
+
* attached ButtonGroup:
|
|
18
|
+
*
|
|
19
|
+
* ```tsx
|
|
20
|
+
* <ButtonGroup isAttached>
|
|
21
|
+
* <Button variant="primary" onPress={send}>Send</Button>
|
|
22
|
+
* <MenuTrigger>
|
|
23
|
+
* <MoreMenuButton variant="primary" aria-label="More send options" />
|
|
24
|
+
* <MenuList>…</MenuList>
|
|
25
|
+
* </MenuTrigger>
|
|
26
|
+
* </ButtonGroup>
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* Give it the main action's `variant`, `tone` and `size`: the two halves are
|
|
30
|
+
* one control. The seam between them is ButtonGroup's job.
|
|
31
|
+
*/
|
|
32
|
+
export const MoreMenuButton = forwardRef<
|
|
33
|
+
HTMLButtonElement,
|
|
34
|
+
MoreMenuButtonProps
|
|
35
|
+
>(function MoreMenuButton({ css: cssProp, ...props }, ref) {
|
|
36
|
+
return (
|
|
37
|
+
<IconButton ref={ref} css={cssProp} {...props}>
|
|
38
|
+
<Icon
|
|
39
|
+
as={MdMoreVert}
|
|
40
|
+
css={{
|
|
41
|
+
// Optical centring: an attached end button is a rectangle plus a
|
|
42
|
+
// semicircle, whose area sits 4.7% of the width towards the flat
|
|
43
|
+
// edge. A share of the width rather than a length because that is a
|
|
44
|
+
// property of the shape, not the size — the button stays square and
|
|
45
|
+
// the radius always clamps to half its height (see IconButton), so
|
|
46
|
+
// one percentage holds everywhere. Leading is the mirror image; a
|
|
47
|
+
// middle or lone button is symmetric and needs nothing.
|
|
48
|
+
//
|
|
49
|
+
// Position rather than margin so the nudge can't feed back into the
|
|
50
|
+
// width the percentage resolves against.
|
|
51
|
+
"[data-attached] > *:not(:first-child):last-child &": {
|
|
52
|
+
position: "relative",
|
|
53
|
+
insetInlineStart: "-4.7%",
|
|
54
|
+
},
|
|
55
|
+
"[data-attached] > *:first-child:not(:last-child) &": {
|
|
56
|
+
position: "relative",
|
|
57
|
+
insetInlineStart: "4.7%",
|
|
58
|
+
},
|
|
59
|
+
}}
|
|
60
|
+
/>
|
|
61
|
+
</IconButton>
|
|
62
|
+
);
|
|
63
|
+
});
|
package/src/NativeSelect.tsx
CHANGED
|
@@ -9,8 +9,8 @@ import { input, InputVariantProps } from "styled-system/recipes";
|
|
|
9
9
|
import { SystemStyleObject } from "styled-system/types";
|
|
10
10
|
|
|
11
11
|
export interface NativeSelectProps
|
|
12
|
-
// `size` is the recipe's size scale
|
|
13
|
-
//
|
|
12
|
+
// `size` is the recipe's size scale; the native visible-rows attribute it
|
|
13
|
+
// shadows was unused.
|
|
14
14
|
extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "className" | "size">,
|
|
15
15
|
InputVariantProps {
|
|
16
16
|
/**
|
|
@@ -19,19 +19,25 @@ export interface NativeSelectProps
|
|
|
19
19
|
* wrapper), so it can participate directly in e.g. an attached ButtonGroup.
|
|
20
20
|
*/
|
|
21
21
|
hideChevron?: boolean;
|
|
22
|
-
/** Per-instance style overrides, merged after the recipe. */
|
|
22
|
+
/** Per-instance style overrides for the select, merged after the recipe. */
|
|
23
23
|
css?: SystemStyleObject;
|
|
24
|
+
/**
|
|
25
|
+
* Style overrides for the chevron wrapper, which is where width constraints
|
|
26
|
+
* belong (the select fills it). No wrapper is rendered with `hideChevron`;
|
|
27
|
+
* constrain the select directly.
|
|
28
|
+
*/
|
|
29
|
+
wrapperCss?: SystemStyleObject;
|
|
24
30
|
className?: string;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
/**
|
|
28
|
-
* NativeSelect — a native select styled like
|
|
29
|
-
*
|
|
30
|
-
*
|
|
34
|
+
* NativeSelect — a native select styled like the outline Input. The recipe's
|
|
35
|
+
* `appearance: none` removes the platform chevron, so one is drawn back in by
|
|
36
|
+
* default (`currentColor`).
|
|
31
37
|
*/
|
|
32
38
|
export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
|
|
33
39
|
function NativeSelect(
|
|
34
|
-
{ hideChevron = false, size, css: cssProp, className, ...rest },
|
|
40
|
+
{ hideChevron = false, size, css: cssProp, wrapperCss, className, ...rest },
|
|
35
41
|
ref,
|
|
36
42
|
) {
|
|
37
43
|
const select = (
|
|
@@ -40,10 +46,15 @@ export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
|
|
|
40
46
|
className={cx(
|
|
41
47
|
input({ size }),
|
|
42
48
|
css(
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
// Option text sits a hair higher than input text; the 1px bottom
|
|
50
|
+
// padding compensates.
|
|
51
|
+
{
|
|
52
|
+
cursor: "pointer",
|
|
53
|
+
paddingBottom: "1px",
|
|
54
|
+
_disabled: { cursor: "not-allowed" },
|
|
55
|
+
},
|
|
56
|
+
// Room for the chevron overlay (constant across sizes).
|
|
57
|
+
hideChevron ? undefined : { paddingEnd: "8" },
|
|
47
58
|
cssProp,
|
|
48
59
|
),
|
|
49
60
|
className,
|
|
@@ -55,21 +66,33 @@ export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
|
|
|
55
66
|
return select;
|
|
56
67
|
}
|
|
57
68
|
return (
|
|
58
|
-
<span
|
|
69
|
+
<span
|
|
70
|
+
className={css(
|
|
71
|
+
// Full-width like every other field; the select's own recipe width
|
|
72
|
+
// fills it. Constrain via wrapperCss.
|
|
73
|
+
{ position: "relative", display: "inline-flex", width: "100%" },
|
|
74
|
+
wrapperCss,
|
|
75
|
+
)}
|
|
76
|
+
>
|
|
59
77
|
{select}
|
|
60
|
-
{/*
|
|
78
|
+
{/* The chevron, fixed-size across field sizes. The path is Chakra
|
|
79
|
+
UI's Select chevron, inlined for visual parity with the apps'
|
|
80
|
+
original look (see the notice in LICENSE.md). */}
|
|
61
81
|
<svg
|
|
62
82
|
viewBox="0 0 24 24"
|
|
63
83
|
aria-hidden
|
|
64
84
|
className={css({
|
|
65
85
|
position: "absolute",
|
|
66
|
-
|
|
86
|
+
insetEnd: "2",
|
|
67
87
|
top: "50%",
|
|
68
88
|
transform: "translateY(-50%)",
|
|
69
89
|
width: "5",
|
|
70
90
|
height: "5",
|
|
71
91
|
pointerEvents: "none",
|
|
72
92
|
fill: "currentColor",
|
|
93
|
+
// The chevron sits outside the select so it doesn't inherit its
|
|
94
|
+
// disabled dimming; dim it explicitly to match.
|
|
95
|
+
"select:disabled + &": { opacity: 0.5 },
|
|
73
96
|
})}
|
|
74
97
|
>
|
|
75
98
|
<path d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z" />
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import { forwardRef, ReactNode, useId } from "react";
|
|
7
|
+
import { css, cx } from "styled-system/css";
|
|
8
|
+
import { field } from "styled-system/recipes";
|
|
9
|
+
import { SystemStyleObject } from "styled-system/types";
|
|
10
|
+
import { FieldHelperText, FieldLabel, FieldLayoutProps } from "./Field";
|
|
11
|
+
import { NativeSelect, NativeSelectProps } from "./NativeSelect";
|
|
12
|
+
|
|
13
|
+
export interface NativeSelectFieldProps
|
|
14
|
+
extends NativeSelectProps,
|
|
15
|
+
FieldLayoutProps {
|
|
16
|
+
/** Visible label, associated with the select via `htmlFor`. */
|
|
17
|
+
label: ReactNode;
|
|
18
|
+
/** Label style overrides. */
|
|
19
|
+
labelCss?: SystemStyleObject;
|
|
20
|
+
/** Help text below the field, wired to the select's `aria-describedby`. */
|
|
21
|
+
helperText?: ReactNode;
|
|
22
|
+
/** Per-instance style overrides for the field root. */
|
|
23
|
+
rootCss?: SystemStyleObject;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* NativeSelectField — a labelled `NativeSelect`, pairing the bare control with
|
|
28
|
+
* the field chrome the RAC fields get from react-aria, as `TextField` pairs
|
|
29
|
+
* with `Input`. The label association and `aria-describedby` are wired here,
|
|
30
|
+
* since there is no RAC context to do it; the label dims with the control
|
|
31
|
+
* exactly as the RAC fields' do. No `errorMessage` yet — no consumer needs
|
|
32
|
+
* one; render `FieldErrorMessage` beside it if yours does.
|
|
33
|
+
*
|
|
34
|
+
* With `labelPosition="side"` this is the settings row both
|
|
35
|
+
* `SelectFormControl`s hand-rolled: label beside a fixed-width select
|
|
36
|
+
* (`wrapperCss={{ width: "28ch" }}`), label absorbing the free space.
|
|
37
|
+
*/
|
|
38
|
+
export const NativeSelectField = forwardRef<
|
|
39
|
+
HTMLSelectElement,
|
|
40
|
+
NativeSelectFieldProps
|
|
41
|
+
>(function NativeSelectField(
|
|
42
|
+
{ label, labelCss, helperText, labelPosition, rootCss, id, ...rest },
|
|
43
|
+
ref,
|
|
44
|
+
) {
|
|
45
|
+
const generatedId = useId();
|
|
46
|
+
const selectId = id ?? generatedId;
|
|
47
|
+
const helperId = useId();
|
|
48
|
+
const describedBy =
|
|
49
|
+
[rest["aria-describedby"], helperText != null ? helperId : undefined]
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
.join(" ") || undefined;
|
|
52
|
+
return (
|
|
53
|
+
<div
|
|
54
|
+
className={cx(
|
|
55
|
+
field({ size: rest.size, labelPosition }).root,
|
|
56
|
+
rootCss ? css(rootCss) : undefined,
|
|
57
|
+
)}
|
|
58
|
+
// The field recipe's label dims off the root's data-disabled, which RAC
|
|
59
|
+
// stamps for the other fields; here it is restated from the attribute.
|
|
60
|
+
data-disabled={rest.disabled || undefined}
|
|
61
|
+
>
|
|
62
|
+
<FieldLabel
|
|
63
|
+
htmlFor={selectId}
|
|
64
|
+
size={rest.size}
|
|
65
|
+
labelPosition={labelPosition}
|
|
66
|
+
isRequired={rest.required}
|
|
67
|
+
css={labelCss}
|
|
68
|
+
>
|
|
69
|
+
{label}
|
|
70
|
+
</FieldLabel>
|
|
71
|
+
<NativeSelect
|
|
72
|
+
ref={ref}
|
|
73
|
+
id={selectId}
|
|
74
|
+
{...rest}
|
|
75
|
+
aria-describedby={describedBy}
|
|
76
|
+
/>
|
|
77
|
+
{helperText != null && (
|
|
78
|
+
<FieldHelperText id={helperId} labelPosition={labelPosition}>
|
|
79
|
+
{helperText}
|
|
80
|
+
</FieldHelperText>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
});
|
|
@@ -6,37 +6,44 @@
|
|
|
6
6
|
import { defineSlotRecipe } from "@pandacss/dev";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* NumberField slot recipe —
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* (react-aria-components NumberField).
|
|
9
|
+
* NumberField slot recipe — an outline input (the `input` recipe styles the
|
|
10
|
+
* input itself) with a right-hand stepper column of two stacked buttons.
|
|
11
|
+
* Consumed by the shared-ui NumberField (react-aria-components NumberField).
|
|
13
12
|
*
|
|
14
|
-
* Registered in the base preset (base-preset.ts)
|
|
15
|
-
*
|
|
13
|
+
* Registered in the base preset (base-preset.ts), which also has the
|
|
14
|
+
* `staticCss` entry that keeps the runtime-prop size variants generated.
|
|
16
15
|
*/
|
|
17
16
|
export const numberField = defineSlotRecipe({
|
|
18
17
|
className: "numberField",
|
|
19
18
|
slots: ["root", "group", "stepper", "stepperButton"],
|
|
20
19
|
base: {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
},
|
|
20
|
+
// No layout here: the component wears the `field` recipe's root alongside
|
|
21
|
+
// this slot, and that recipe is the single owner of field-root layout
|
|
22
|
+
// (else `labelPosition` would fight this slot over `flexDirection`). The
|
|
23
|
+
// slot stays for apps and variants to target.
|
|
24
|
+
root: {},
|
|
26
25
|
group: {
|
|
27
26
|
position: "relative",
|
|
28
27
|
zIndex: 0,
|
|
28
|
+
// The stepper overlays the input as a sibling, so the input's own
|
|
29
|
+
// :hover misses it — the group carries the hover tint. The :not()
|
|
30
|
+
// list is required: slot recipes layer above plain recipes
|
|
31
|
+
// (recipes.slots), so this would otherwise override focused/invalid
|
|
32
|
+
// (see docs/hints.md). :user-invalid takes its own :not(): :not() is
|
|
33
|
+
// not forgiving, so one list would drop the whole selector on a
|
|
34
|
+
// browser that doesn't know the pseudo-class (Safari < 16.5).
|
|
35
|
+
"&:hover input:not(:focus, [data-focused], [data-invalid]):not(:user-invalid)":
|
|
36
|
+
{ borderColor: "gray.500" },
|
|
29
37
|
},
|
|
30
|
-
//
|
|
31
|
-
// edge, inset by the input border.
|
|
38
|
+
// A column overlaying the input's right edge, inset by the input border.
|
|
32
39
|
stepper: {
|
|
33
40
|
display: "flex",
|
|
34
41
|
flexDirection: "column",
|
|
35
42
|
position: "absolute",
|
|
36
43
|
insetEnd: "0",
|
|
37
44
|
top: "0",
|
|
38
|
-
height: "calc(100% -
|
|
39
|
-
margin: "
|
|
45
|
+
height: "calc(100% - 4px)",
|
|
46
|
+
margin: "2px",
|
|
40
47
|
width: "6",
|
|
41
48
|
zIndex: 1,
|
|
42
49
|
},
|
|
@@ -54,14 +61,48 @@ export const numberField = defineSlotRecipe({
|
|
|
54
61
|
borderColor: "gray.200",
|
|
55
62
|
transitionProperty: "background",
|
|
56
63
|
transitionDuration: "ultra-fast",
|
|
64
|
+
// Follow the input's corners, less the 2px border the stepper is inset
|
|
65
|
+
// by, so the hover and pressed fills curve away with the border instead
|
|
66
|
+
// of squaring off over its arc. Radii track the input recipe's size
|
|
67
|
+
// scale, so the `sm` variant restates them.
|
|
68
|
+
"&:first-child": {
|
|
69
|
+
borderStartEndRadius: "calc(token(radii.md) - 2px)",
|
|
70
|
+
},
|
|
57
71
|
"&:last-child": {
|
|
58
72
|
borderTop: "1px solid",
|
|
59
73
|
borderTopColor: "gray.200",
|
|
60
74
|
marginTop: "-1px",
|
|
75
|
+
borderEndEndRadius: "calc(token(radii.md) - 2px)",
|
|
61
76
|
},
|
|
62
77
|
"&[data-hovered]": { bg: "gray.100" },
|
|
63
78
|
"&[data-pressed]": { bg: "gray.200" },
|
|
64
79
|
"&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
|
|
65
80
|
},
|
|
66
81
|
},
|
|
82
|
+
variants: {
|
|
83
|
+
// The stepper column stays 24px wide at every size (as does the input
|
|
84
|
+
// padding paired with it in NumberField.tsx); only the arrow glyphs
|
|
85
|
+
// scale, at 0.75 × the field's font size. The base's `xs` is exactly
|
|
86
|
+
// md × 0.75, so md adds nothing.
|
|
87
|
+
size: {
|
|
88
|
+
lg: {
|
|
89
|
+
stepperButton: { fontSize: "calc(token(fontSizes.lg) * 0.75)" },
|
|
90
|
+
},
|
|
91
|
+
md: {},
|
|
92
|
+
sm: {
|
|
93
|
+
stepperButton: {
|
|
94
|
+
fontSize: "calc(token(fontSizes.sm) * 0.75)",
|
|
95
|
+
"&:first-child": {
|
|
96
|
+
borderStartEndRadius: "calc(token(radii.sm) - 2px)",
|
|
97
|
+
},
|
|
98
|
+
"&:last-child": {
|
|
99
|
+
borderEndEndRadius: "calc(token(radii.sm) - 2px)",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
defaultVariants: {
|
|
106
|
+
size: "md",
|
|
107
|
+
},
|
|
67
108
|
});
|