@minimalstuff/ui 5.1.0 → 5.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 CHANGED
@@ -59,6 +59,28 @@ import { THEME_INIT_SCRIPT } from '@minimalstuff/ui';
59
59
  </head>;
60
60
  ```
61
61
 
62
+ #### Status bar and `theme-color`
63
+
64
+ To make the browser or installed PWA status bar follow the theme, add a `theme-color` meta with a `data-light` and a `data-dark` color. `THEME_INIT_SCRIPT` and `useThemeStore` copy the matching one into `content` every time the theme is applied (init, `setTheme`, OS change while on `system`, another tab). A meta without both attributes is left alone, and without such a meta nothing changes.
65
+
66
+ ```html
67
+ <head>
68
+ <meta
69
+ name="theme-color"
70
+ content="#ffffff"
71
+ data-light="#ffffff"
72
+ data-dark="#0b0b0b"
73
+ />
74
+ <script>
75
+ /* THEME_INIT_SCRIPT */
76
+ </script>
77
+ </head>
78
+ ```
79
+
80
+ - Put the meta before the script in `<head>`.
81
+ - Remove any other `theme-color` meta, notably ones with a `media` attribute: the first matching one wins.
82
+ - Keep the meta in the static HTML. A meta recreated by a `<head>` manager (Inertia `<Head>`, react-helmet) is not resynced.
83
+
62
84
  With a strict Content-Security-Policy, either add the script's SHA-256 hash to `script-src` (`'sha256-<hash>'`), or write `THEME_INIT_SCRIPT` to a file at build time and load it with `<script src>`. Compute the hash with:
63
85
 
64
86
  ```bash
@@ -88,6 +110,8 @@ pnpm verify
88
110
 
89
111
  `pnpm test:stories` runs the stories in a real browser and needs Playwright's Chromium once: `pnpm exec playwright install chromium`.
90
112
 
113
+ To change the library from a project that uses it, see [docs/developing_from_a_consumer.md](docs/developing_from_a_consumer.md).
114
+
91
115
  Releases follow [docs/releasing.md](docs/releasing.md). Changes are listed in the [CHANGELOG](CHANGELOG.md).
92
116
 
93
117
  ## Report a bug
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ import { ReactElement } from 'react';
9
9
  import { ReactNode } from 'react';
10
10
  import { ReactPortal } from 'react';
11
11
  import { Ref } from 'react';
12
+ import { RefCallback } from 'react';
12
13
  import { RefObject } from 'react';
13
14
  import { StoreApi } from 'zustand';
14
15
  import { UseBoundStore } from 'zustand';
@@ -250,7 +251,7 @@ export declare interface ClientOnlyProps extends React.PropsWithChildren {
250
251
  fallback?: React.ReactNode;
251
252
  }
252
253
 
253
- export declare function Combobox({ options, filterOption, label, description, 'aria-label': ariaLabel, error, placeholder, noResultsText, clearLabel, radius, unstyled, disabled, required, className, wrapperClassName, value, defaultValue, onChange, id, }: Readonly<ComboboxProps>): JSX.Element;
254
+ export declare function Combobox({ options, filterOption, label, description, 'aria-label': ariaLabel, error, placeholder, noResultsText, clearLabel, radius, unstyled, disabled, required, className, wrapperClassName, dropdownClassName, optionLabelOverflow, value, defaultValue, onChange, id, }: Readonly<ComboboxProps>): JSX.Element;
254
255
 
255
256
  export declare interface ComboboxOption {
256
257
  value: string;
@@ -258,6 +259,7 @@ export declare interface ComboboxOption {
258
259
  }
259
260
 
260
261
  export declare interface ComboboxProps {
262
+ /** Long lists render faster when the array keeps the same identity between renders (memoize it), and when `optionLabelOverflow` is not `'wrap'`. */
261
263
  options: readonly ComboboxOption[];
262
264
  /**
263
265
  * Decides whether an option matches the text typed in the field. Replaces
@@ -278,6 +280,19 @@ export declare interface ComboboxProps {
278
280
  required?: boolean;
279
281
  className?: string;
280
282
  wrapperClassName?: string;
283
+ /**
284
+ * Classes for the dropdown panel, applied last so they can override its
285
+ * defaults, for example a width wider than the field when option labels are
286
+ * long (`w-96`, `min-w-80`). The panel is kept inside the viewport.
287
+ */
288
+ dropdownClassName?: string;
289
+ /**
290
+ * How option labels that do not fit on one line are shown.
291
+ * - `'wrap'` (default): labels wrap; every option is rendered, so lists of thousands of options open slower.
292
+ * - `'wrap-active'`: labels are truncated with a native tooltip, except the active option, which wraps in full; long lists only render the visible options. Recommended for long lists.
293
+ * - `'truncate'`: every label is truncated with a native tooltip, the active one included; keyboard and zoom users can no longer read long labels (WCAG 1.4.10), use only for labels known to be short.
294
+ */
295
+ optionLabelOverflow?: OptionLabelOverflow;
281
296
  value?: string;
282
297
  defaultValue?: string;
283
298
  onChange?: (value: string) => void;
@@ -370,8 +385,24 @@ export declare interface CopyButtonProps {
370
385
  */
371
386
  export declare type DismissGuard = () => boolean | Promise<boolean>;
372
387
 
388
+ /**
389
+ * Asks the topmost open dialog (`Modal`, `ConfirmModal`, `Drawer`) to close,
390
+ * exactly as an Escape press would: dismiss guards are honoured, and a
391
+ * dialog with `dismissible={false}` stays open. Only the topmost dialog is
392
+ * affected, and a dialog already closing does not count.
393
+ *
394
+ * Call it first from a hardware back button handler (for example Capacitor's
395
+ * `backButton` listener on Android) and navigate back only when it returns
396
+ * `false`.
397
+ *
398
+ * @returns `true` when a dialog was open and the press was consumed (even if
399
+ * it stayed open because it is not dismissible or a guard refused), `false`
400
+ * when no dialog is open.
401
+ */
402
+ export declare function dismissTopmostModal(): boolean;
403
+
373
404
  /** A modal side panel with the same focus handling, stacking and footer slot as `Modal`. */
374
- export declare function Drawer({ isOpen, onClose, size, side, radius, dismissible, ...dialogProps }: Readonly<DrawerProps>): JSX.Element | null;
405
+ export declare function Drawer({ isOpen, onClose, size, side, radius, dismissible, onExited, ...dialogProps }: Readonly<DrawerProps>): JSX.Element | null;
375
406
 
376
407
  export declare interface DrawerProps {
377
408
  isOpen: boolean;
@@ -397,6 +428,8 @@ export declare interface DrawerProps {
397
428
  /** `false` hides the close button and blocks Escape and backdrop dismissal. */
398
429
  dismissible?: boolean;
399
430
  closeLabel?: string;
431
+ /** Called once per close, when the exit has finished and the panel is unmounted. Not called when the drawer reopens during the exit, nor on an initial render with `isOpen={false}`. */
432
+ onExited?: () => void;
400
433
  /** Wins over the first-tabbable rule for where focus lands on open; may point at an element in the body or in the footer. */
401
434
  initialFocusRef?: RefObject<HTMLElement | null>;
402
435
  }
@@ -509,6 +542,15 @@ export declare interface KbdProps {
509
542
 
510
543
  export declare type KbdSize = ControlSize;
511
544
 
545
+ export declare type KeyboardScrollableProps = {
546
+ ref: RefCallback<HTMLElement>;
547
+ tabIndex?: 0;
548
+ role?: 'region';
549
+ 'aria-labelledby'?: string;
550
+ 'aria-label'?: string;
551
+ 'data-keyboard-scroll-region'?: true;
552
+ };
553
+
512
554
  /**
513
555
  * A dropdown anchored to `trigger` (e.g. a kebab `IconButton`): click to
514
556
  * open, click again/click outside/Escape to close, or ArrowDown/ArrowUp on
@@ -707,11 +749,15 @@ export declare interface ModalFooterProps {
707
749
 
708
750
  export declare type ModalHeight = 'auto' | 'fixed';
709
751
 
752
+ export declare type ModalPhoneLayout = 'center' | 'sheet';
753
+
710
754
  export declare type ModalProps = {
711
755
  children: ModalRenderProp;
712
756
  size?: ModalSize;
713
757
  /** `fixed` gives the dialog a stable height so it does not jump when content changes; the content area stops scrolling and a child with `flex-1 min-h-0 overflow-y-auto` scrolls on its own. */
714
758
  height?: ModalHeight;
759
+ /** `sheet` turns the dialog into a bottom sheet below the `sm` breakpoint (full width, flush with the bottom edge, only the top corners rounded, at most 85svh tall, bottom padding clearing the safe area, sliding up on open and down on close; plain fade under reduced motion). From `sm` up it renders as `center`. With `height="fixed"` the sheet is 85svh tall on phones. `unstyled` keeps the sheet position and size but drops radius and the slide. */
760
+ phoneLayout?: ModalPhoneLayout;
715
761
  radius?: Radius;
716
762
  className?: string;
717
763
  /** Applied to the `role=dialog` element. A `transform`, `filter` or `backdrop-filter` on the open panel makes it the containing block of `position: fixed` descendants, so Menus opened inside it get clipped. Keep these off the open state. Carries `data-state="open"` or `"closed"`, so `data-[state=closed]:` variants can animate it. Put your own transitions behind `motion-reduce:` variants, since `unstyled` drops the library's reduced-motion handling along with its animations. */
@@ -741,7 +787,7 @@ export declare type ModalSize = 'sm' | 'md' | 'lg' | 'xl';
741
787
  * pills opens a searchable dropdown of checkbox-style options. Selecting an
742
788
  * option never closes the dropdown, unlike the single-select `Combobox`.
743
789
  */
744
- export declare function MultiCombobox({ options, filterOption, label, description, 'aria-label': ariaLabel, error, placeholder, searchPlaceholder, noResultsText, clearLabel, clearText, maxSelectedValues, isOptionDisabled, maxDisplayedValues, formatOverflowCount, formatSelectedCount, formatMaxSelectedHint, radius, unstyled, disabled, required, className, wrapperClassName, values, defaultValues, onChange, onDropdownClose, id, }: Readonly<MultiComboboxProps>): ReactElement;
790
+ export declare function MultiCombobox({ options, filterOption, label, description, 'aria-label': ariaLabel, error, placeholder, searchPlaceholder, noResultsText, clearLabel, clearText, maxSelectedValues, isOptionDisabled, maxDisplayedValues, formatOverflowCount, formatSelectedCount, formatMaxSelectedHint, radius, unstyled, disabled, required, className, wrapperClassName, dropdownClassName, optionLabelOverflow, values, defaultValues, onChange, onDropdownClose, id, }: Readonly<MultiComboboxProps>): ReactElement;
745
791
 
746
792
  export declare interface MultiComboboxOption {
747
793
  value: string;
@@ -749,6 +795,7 @@ export declare interface MultiComboboxOption {
749
795
  }
750
796
 
751
797
  export declare interface MultiComboboxProps {
798
+ /** Long lists render faster when the array keeps the same identity between renders (memoize it), and when `optionLabelOverflow` is not `'wrap'`. */
752
799
  options: readonly MultiComboboxOption[];
753
800
  /**
754
801
  * Decides whether an option matches the text typed in the search field.
@@ -777,6 +824,19 @@ export declare interface MultiComboboxProps {
777
824
  required?: boolean;
778
825
  className?: string;
779
826
  wrapperClassName?: string;
827
+ /**
828
+ * Classes for the dropdown panel, applied last so they can override its
829
+ * defaults, for example a width wider than the field when option labels are
830
+ * long (`w-96`, `min-w-80`). The panel is kept inside the viewport.
831
+ */
832
+ dropdownClassName?: string;
833
+ /**
834
+ * How option labels that do not fit on one line are shown.
835
+ * - `'wrap'` (default): labels wrap; every option is rendered, so lists of thousands of options open slower.
836
+ * - `'wrap-active'`: labels are truncated with a native tooltip, except the active option, which wraps in full; long lists only render the visible options. Recommended for long lists.
837
+ * - `'truncate'`: every label is truncated with a native tooltip, the active one included; keyboard and zoom users can no longer read long labels (WCAG 1.4.10), use only for labels known to be short.
838
+ */
839
+ optionLabelOverflow?: OptionLabelOverflow;
780
840
  values?: readonly string[];
781
841
  defaultValues?: readonly string[];
782
842
  onChange?: (values: readonly string[]) => void;
@@ -790,6 +850,8 @@ export declare interface MultiComboboxProps {
790
850
  id?: string;
791
851
  }
792
852
 
853
+ export declare type OptionLabelOverflow = 'wrap' | 'wrap-active' | 'truncate';
854
+
793
855
  export declare type RadioOption = ChoiceOption;
794
856
 
795
857
  export declare function RadioOptions({ options, orientation, value, defaultValue, onChange, name: nameProp, label, error, required, disabled, radius, unstyled, className, wrapperClassName, id, 'aria-describedby': callerDescribedBy, ...props }: Readonly<RadioOptionsProps>): JSX.Element;
@@ -937,7 +999,7 @@ export declare function splitIntoHighlightSegments(
937
999
  return segments;
938
1000
  }
939
1001
 
940
- export declare function Switch({ label, description, error, unstyled, fullWidth, className, wrapperClassName, checked, defaultChecked, onChange, id, 'aria-describedby': callerDescribedBy, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...props }: Readonly<SwitchProps>): JSX.Element;
1002
+ export declare function Switch({ label, description, error, unstyled, fullWidth, className, wrapperClassName, checked, defaultChecked, onChange, id, 'aria-describedby': callerDescribedBy, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ref, ...props }: Readonly<SwitchProps>): JSX.Element;
941
1003
 
942
1004
  export declare interface SwitchProps extends Omit<ComponentPropsWithRef<'input'>, 'type' | 'className'> {
943
1005
  label?: string | ReactNode;
@@ -1034,7 +1096,10 @@ export declare type Theme = 'light' | 'dark' | 'system';
1034
1096
  * first paint, so a dark-mode visitor never sees a light flash. It mirrors
1035
1097
  * `useThemeStore`: the `dark` class is set for a stored `dark`, or for a
1036
1098
  * stored `system` (or a missing, invalid or unreadable value) while the OS
1037
- * prefers dark. Inline it in `<head>` before the stylesheet.
1099
+ * prefers dark. Every `<meta name="theme-color" data-light="..." data-dark="...">`
1100
+ * also gets its `content` set from `data-dark` or `data-light`; it must come
1101
+ * before this script in `<head>`. Inline the script in `<head>` before the
1102
+ * stylesheet.
1038
1103
  */
1039
1104
  export declare const THEME_INIT_SCRIPT: string;
1040
1105
 
@@ -1237,6 +1302,43 @@ export declare const useGlobalHotkeysStore: UseBoundStore<StoreApi<GlobalHotkeys
1237
1302
 
1238
1303
  export declare const useIsClient: () => boolean;
1239
1304
 
1305
+ /**
1306
+ * Makes a scroll container reachable by keyboard. When the container overflows
1307
+ * and holds nothing tabbable, `scrollableProps` gives it a tab stop, a
1308
+ * `region` role and an accessible name, and `focusClassName` a visible focus
1309
+ * outline. Otherwise only the `ref` is returned. Re-evaluated on resize and on
1310
+ * content changes; a container mounted after the first render is observed too.
1311
+ *
1312
+ * To combine `scrollableProps.ref` with your own ref, use a ref callback that
1313
+ * calls both.
1314
+ *
1315
+ * @example
1316
+ * const { scrollableProps, focusClassName } = useKeyboardScrollable({
1317
+ * label: 'Release notes',
1318
+ * })
1319
+ * return (
1320
+ * <div {...scrollableProps} className={cn('h-64 overflow-y-auto', focusClassName)}>
1321
+ * {content}
1322
+ * </div>
1323
+ * )
1324
+ */
1325
+ export declare function useKeyboardScrollable(options: Readonly<UseKeyboardScrollableOptions>): UseKeyboardScrollableReturn;
1326
+
1327
+ /** A scroll region must be named, exactly once: by `label` or by `labelledBy`. */
1328
+ export declare type UseKeyboardScrollableOptions = {
1329
+ label: string;
1330
+ labelledBy?: never;
1331
+ } | {
1332
+ labelledBy: string;
1333
+ label?: never;
1334
+ };
1335
+
1336
+ export declare type UseKeyboardScrollableReturn = {
1337
+ scrollableProps: KeyboardScrollableProps;
1338
+ /** Focus outline classes to add to the container's `className`; `undefined` when the container is not a tab stop. */
1339
+ focusClassName: string | undefined;
1340
+ };
1341
+
1240
1342
  export declare const useThemeStore: UseBoundStore<StoreApi<ThemeStore>>;
1241
1343
 
1242
1344
  /** Wraps `Component` so it only renders once the client has mounted. */
@@ -1250,3 +1352,10 @@ declare module 'vitest' {
1250
1352
  interface Assertion<T = unknown> extends AxeMatchers {}
1251
1353
  interface AsymmetricMatchersContaining extends AxeMatchers {}
1252
1354
  }
1355
+
1356
+ declare module 'vitest/browser' {
1357
+ interface BrowserCommands {
1358
+ /** Moves the real mouse cursor to a point outside the story document. */
1359
+ movePointerOutOfStory: () => Promise<void>;
1360
+ }
1361
+ }