@microbit/ui 0.5.1 → 0.5.2

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
@@ -209,7 +209,8 @@ Panda emits every token as a CSS custom property with its default naming —
209
209
  - `fonts.display` → `var(--fonts-display)`
210
210
 
211
211
  These names are **API** for styling that lives outside React/Panda — e.g.
212
- CodeMirror highlight styles or xterm themes written as raw CSS. Two rules
212
+ CodeMirror highlight styles, xterm themes, or `@microbit/ui-code-view`'s
213
+ stylesheet, which reads the `code.*` syntax-highlighting colours. Two rules
213
214
  keep them stable:
214
215
 
215
216
  - Never set `hashing` or `prefix` in `panda.config.ts`.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "ui.breadcrumb": {
3
- "defaultMessage": "Sendero de navegación"
3
+ "defaultMessage": "Breadcrumb"
4
4
  },
5
5
  "ui.cancel-action": {
6
6
  "defaultMessage": "Cancelar"
@@ -18,13 +18,13 @@
18
18
  "defaultMessage": "Cargando"
19
19
  },
20
20
  "ui.numberfield-decrease": {
21
- "defaultMessage": "Reducir {fieldLabel}"
21
+ "defaultMessage": "Disminuir {fieldLabel}"
22
22
  },
23
23
  "ui.numberfield-increase": {
24
24
  "defaultMessage": "Aumentar {fieldLabel}"
25
25
  },
26
26
  "ui.select-placeholder": {
27
- "defaultMessage": "Seleccionar un artículo"
27
+ "defaultMessage": "Selecciona un elemento"
28
28
  },
29
29
  "ui.select-row-action": {
30
30
  "defaultMessage": "Seleccionar"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microbit/ui",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
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
  "repository": {
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@pandacss/dev": "^1.11.4",
39
- "react": "^18.3.1",
39
+ "react": "^18.3.1 || ^19.0.0",
40
40
  "react-aria": "^3.50.0",
41
41
  "react-aria-components": "^1.19.0",
42
42
  "react-icons": "^4.12.0 || ^5.0.0",
@@ -47,7 +47,7 @@
47
47
  "@storybook/react-vite": "^10.5.2",
48
48
  "@testing-library/react": "^16.3.2",
49
49
  "@testing-library/user-event": "^14.6.1",
50
- "@types/node": "^26.1.1",
50
+ "@types/node": "^24.13.3",
51
51
  "@types/react": "^18.3.3",
52
52
  "@types/react-dom": "^18.3.0",
53
53
  "@vitejs/plugin-react": "^5.2.0",
package/src/ComboBox.tsx CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  ForwardedRef,
8
8
  forwardRef,
9
9
  ReactNode,
10
+ useContext,
10
11
  useLayoutEffect,
11
12
  useRef,
12
13
  useState,
@@ -15,6 +16,7 @@ import {
15
16
  Button as RACButton,
16
17
  ComboBox as RACComboBox,
17
18
  ComboBoxProps as RACComboBoxProps,
19
+ ComboBoxStateContext,
18
20
  Input as RACInput,
19
21
  ListBox as RACListBox,
20
22
  Popover,
@@ -74,9 +76,14 @@ export interface ComboBoxProps<T extends object>
74
76
  */
75
77
  emptyState?: ReactNode;
76
78
  /**
77
- * Keep the dropdown shut until this prop is true. For gating on a minimum
79
+ * Keep the dropdown shut while this prop is true. For gating on a minimum
78
80
  * query length — react-aria has no `minLength`, and rendering an empty list
79
- * still opens an empty card.
81
+ * still opens an empty card. Derive it from a controlled `inputValue`, so
82
+ * the keystroke that clears the gate is the one that opens the list.
83
+ *
84
+ * While true, nothing can open the list: the chevron is disabled, and
85
+ * ArrowUp/ArrowDown are stopped before they reach the input (so an
86
+ * `onKeyDown` you pass won't see them either).
80
87
  */
81
88
  isPopoverHidden?: boolean;
82
89
  placement?: PopoverProps["placement"];
@@ -98,6 +105,24 @@ export interface ComboBoxProps<T extends object>
98
105
  className?: string;
99
106
  }
100
107
 
108
+ /**
109
+ * Closes a list left open when `isPopoverHidden` turns on (deleting back
110
+ * below the minimum query length). Opening is blocked at source — see the
111
+ * `menuTrigger` override and arrow-key guard below — and this also catches
112
+ * RAC's rarer open paths, such as the iPad VoiceOver tap on the input.
113
+ *
114
+ * Layout effect, not a passive one, so the stale list never reaches a paint.
115
+ */
116
+ const ClosePopoverWhenHidden = ({ isHidden }: { isHidden?: boolean }) => {
117
+ const state = useContext(ComboBoxStateContext);
118
+ useLayoutEffect(() => {
119
+ if (isHidden && state?.isOpen) {
120
+ state.setOpen(false);
121
+ }
122
+ });
123
+ return null;
124
+ };
125
+
101
126
  /**
102
127
  * ComboBox — a text input that filters a listbox, for choosing one of a known
103
128
  * set where typing to narrow it down is the point. Use Select where the list
@@ -117,6 +142,7 @@ const ComboBoxInner = <T extends object>(
117
142
  indicator,
118
143
  emptyState,
119
144
  isPopoverHidden,
145
+ menuTrigger,
120
146
  placement = "bottom start",
121
147
  maxHeight,
122
148
  helperText,
@@ -167,6 +193,8 @@ const ComboBoxInner = <T extends object>(
167
193
  <RACComboBox
168
194
  allowsEmptyCollection={emptyState != null}
169
195
  {...rest}
196
+ // "manual" stops typing and focus from opening the list at all.
197
+ menuTrigger={isPopoverHidden ? "manual" : menuTrigger}
170
198
  className={cx(fieldSlots.root, slots.root, className)}
171
199
  >
172
200
  {label != null && (
@@ -182,6 +210,17 @@ const ComboBoxInner = <T extends object>(
182
210
  <div
183
211
  ref={triggerRef}
184
212
  data-keyboard-modality={isFocusVisible || undefined}
213
+ // "manual" still lets the arrow keys open the list, and RAC's
214
+ // handler is on the input, so stop them on the way down.
215
+ onKeyDownCapture={
216
+ isPopoverHidden
217
+ ? (e) => {
218
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
219
+ e.stopPropagation();
220
+ }
221
+ }
222
+ : undefined
223
+ }
185
224
  className={cx(
186
225
  slots.trigger,
187
226
  triggerCss ? css(triggerCss) : undefined,
@@ -196,36 +235,45 @@ const ComboBoxInner = <T extends object>(
196
235
  {indicator !== null && (
197
236
  <RACButton
198
237
  aria-label={intl.formatMessage(uiMessage("ui.combobox-trigger"))}
238
+ isDisabled={isPopoverHidden}
199
239
  className={slots.indicator}
200
240
  >
201
241
  {indicator ?? <Icon as={RiArrowDownSLine} />}
202
242
  </RACButton>
203
243
  )}
204
244
  </div>
205
- {!isPopoverHidden && (
206
- <Popover
207
- triggerRef={triggerRef}
208
- placement={placement}
209
- maxHeight={maxHeight}
210
- style={triggerWidth ? { width: triggerWidth } : undefined}
211
- className={cx(
212
- slots.content,
213
- contentCss ? css(contentCss) : undefined,
214
- )}
245
+ <ClosePopoverWhenHidden isHidden={isPopoverHidden} />
246
+ {/*
247
+ Always mounted, even while isPopoverHidden: RAC builds the
248
+ collection from the ListBox children inside the popover, so
249
+ unmounting it also empties the collection — and an empty
250
+ collection can't open (without allowsEmptyCollection), so the next
251
+ keystroke's open lands before the remounted children have
252
+ repopulated it and the list never appears. React 18's effect
253
+ ordering happened to win that race; React 19's does not.
254
+ */}
255
+ <Popover
256
+ triggerRef={triggerRef}
257
+ placement={placement}
258
+ maxHeight={maxHeight}
259
+ style={triggerWidth ? { width: triggerWidth } : undefined}
260
+ className={cx(
261
+ slots.content,
262
+ contentCss ? css(contentCss) : undefined,
263
+ )}
264
+ >
265
+ <RACListBox
266
+ aria-label={intl.formatMessage(uiMessage("ui.combobox-listbox"))}
267
+ className={slots.list}
268
+ renderEmptyState={
269
+ emptyState
270
+ ? () => <div className={slots.empty}>{emptyState}</div>
271
+ : undefined
272
+ }
215
273
  >
216
- <RACListBox
217
- aria-label={intl.formatMessage(uiMessage("ui.combobox-listbox"))}
218
- className={slots.list}
219
- renderEmptyState={
220
- emptyState
221
- ? () => <div className={slots.empty}>{emptyState}</div>
222
- : undefined
223
- }
224
- >
225
- {children}
226
- </RACListBox>
227
- </Popover>
228
- )}
274
+ {children}
275
+ </RACListBox>
276
+ </Popover>
229
277
  <FieldSupport
230
278
  helperText={helperText}
231
279
  errorMessage={errorMessage}
@@ -22,7 +22,7 @@ export interface ConfirmDialogProps {
22
22
  confirmText: ReactNode;
23
23
  /** Defaults to "Cancel". */
24
24
  cancelText?: ReactNode;
25
- finalFocusRef?: RefObject<HTMLElement>;
25
+ finalFocusRef?: RefObject<HTMLElement | null>;
26
26
  onCloseComplete?: () => void;
27
27
  }
28
28
 
package/src/Modal.tsx CHANGED
@@ -173,7 +173,7 @@ export interface ModalOwnProps {
173
173
  * Element to focus when the dialog closes, instead of the element that was
174
174
  * focused when it opened.
175
175
  */
176
- finalFocusRef?: RefObject<HTMLElement>;
176
+ finalFocusRef?: RefObject<HTMLElement | null>;
177
177
  /**
178
178
  * Accessible name for dialogs without a ModalHeader (which otherwise
179
179
  * provides the label). One of the two is required — react-aria warns in
@@ -127,6 +127,9 @@ export const select = defineSlotRecipe({
127
127
  background: "transparent",
128
128
  border: "none",
129
129
  cursor: "pointer",
130
+ // Disabled only while ComboBox's isPopoverHidden withholds the list:
131
+ // it should stop looking clickable, not look like a disabled field.
132
+ "&[data-disabled]": { cursor: "default" },
130
133
  // No focus styling, deliberately: react-aria keeps a ComboBox's toggle
131
134
  // button out of the tab order (the input owns the keyboard), so a ring
132
135
  // here would only ever be reachable programmatically, and would suggest
@@ -170,6 +170,20 @@ export const basePreset = definePreset({
170
170
  // Removing the slot is a follow-up needing an ml-trainer lockstep.
171
171
  brand: colors.blue,
172
172
  brand2: colors.gray,
173
+ // Syntax-highlighting palette, read as CSS variables by the Python
174
+ // Editor's CodeMirror theme and by @microbit/ui-code-view's stylesheet
175
+ // (whose var() fallbacks repeat these values for apps without Panda).
176
+ // Generic OSS defaults; brand presets set the micro:bit palette.
177
+ code: {
178
+ default: { value: "black" },
179
+ comment: { value: "gray" },
180
+ keyword: { value: "darkblue" },
181
+ literal: { value: "darkgreen" },
182
+ string: { value: "green" },
183
+ // Below the 4.5:1 text floor (gray.500 meets it); matches the
184
+ // editor's gutter pending a decision on line-number contrast.
185
+ lineNumber: { value: "{colors.gray.350}" },
186
+ },
173
187
  },
174
188
  spacing: {
175
189
  ...spacing,
@@ -14,7 +14,9 @@ export function useClipboard(
14
14
  timeoutMs = 1500,
15
15
  ): { onCopy: () => void; hasCopied: boolean } {
16
16
  const [hasCopied, setHasCopied] = useState(false);
17
- const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
17
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
18
+ undefined,
19
+ );
18
20
  const markCopied = useCallback(() => {
19
21
  setHasCopied(true);
20
22
  clearTimeout(timeoutRef.current);
@@ -7,7 +7,7 @@ import { useEffect, useRef } from "react";
7
7
 
8
8
  /** The value from the previous render (undefined on the first render). */
9
9
  export function usePrevious<T>(value: T): T | undefined {
10
- const ref = useRef<T>();
10
+ const ref = useRef<T | undefined>(undefined);
11
11
  useEffect(() => {
12
12
  ref.current = value;
13
13
  });