@minimalstuff/ui 5.0.1 → 5.2.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
@@ -42,10 +42,31 @@ export function Root() {
42
42
  }
43
43
  ```
44
44
 
45
+ For French, pass the bundled preset: `<UIProvider labels={FRENCH_UI_LABELS}>` (import `FRENCH_UI_LABELS` from `@minimalstuff/ui`).
46
+
45
47
  ### Dark mode
46
48
 
47
49
  Dark styles apply when `<html>` has the `dark` class. `useThemeStore` (and the `ThemeToggle` built on it) sets that class from the `light`, `dark` or `system` preference and keeps it in `localStorage`.
48
50
 
51
+ To avoid a light flash before your bundle runs, inline `THEME_INIT_SCRIPT` in `<head>`, before the stylesheet. It reads the same `localStorage` key as the store and sets the `dark` class the same way.
52
+
53
+ ```tsx
54
+ import { THEME_INIT_SCRIPT } from '@minimalstuff/ui';
55
+
56
+ <head>
57
+ <script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
58
+ <link rel="stylesheet" href="/style.css" />
59
+ </head>;
60
+ ```
61
+
62
+ 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
+
64
+ ```bash
65
+ node --input-type=module -e "import { THEME_INIT_SCRIPT } from '@minimalstuff/ui'; import { createHash } from 'node:crypto'; console.log(createHash('sha256').update(THEME_INIT_SCRIPT).digest('base64'))"
66
+ ```
67
+
68
+ The hash changes if a release changes the script: recompute it when you upgrade.
69
+
49
70
  ### Modals and drawers lock page scroll
50
71
 
51
72
  While a `Modal` or `Drawer` is open, page scroll is locked on `<html>`. The scrollbar width this frees is compensated with right padding on `<html>`, so the page does not shift. That padding does not move `position: fixed` elements: anchor right-aligned ones with `right: var(--scrollbar-compensation, 0px)`. Paint the page background on `<html>` or `<body>` rather than only on a wrapper element, or the padding shows the root's background as a strip on the right.
package/dist/index.d.ts CHANGED
@@ -13,6 +13,27 @@ import { RefObject } from 'react';
13
13
  import { StoreApi } from 'zustand';
14
14
  import { UseBoundStore } from 'zustand';
15
15
 
16
+ /**
17
+ * A thin application bar with a `start` slot on the left, a centered `title`
18
+ * and an `end` slot on the right. It is a plain `<header>` (the `banner`
19
+ * landmark when it is not nested in another sectioning element), not a
20
+ * WAI-ARIA toolbar: put your own links and buttons in the slots, or use
21
+ * `Toolbar` for a group of controls with arrow-key navigation. Its minimum
22
+ * height grows with enlarged text and its horizontal padding honours the
23
+ * device safe area.
24
+ */
25
+ export declare function AppBar(appBarProps: Readonly<AppBarProps>): JSX.Element;
26
+
27
+ export declare type AppBarProps = Omit<ComponentPropsWithRef<'header'>, 'title'> & {
28
+ start?: ReactNode;
29
+ title?: ReactNode;
30
+ end?: ReactNode;
31
+ startClassName?: string;
32
+ titleClassName?: string;
33
+ endClassName?: string;
34
+ unstyled?: boolean;
35
+ };
36
+
16
37
  /**
17
38
  * The picture of a person, or the initial of `name` when there is none.
18
39
  * Labelled with the full `name` so screen readers announce the person rather
@@ -64,6 +85,31 @@ export declare const BASE_INPUT_STYLES = "w-full border border-gray-300 dark:bor
64
85
 
65
86
  export declare function Button(buttonProps: Readonly<ButtonProps>): JSX.Element;
66
87
 
88
+ /**
89
+ * The exact class string `Button` renders, for styling another element as a
90
+ * button, typically a router `<Link>` or an `<a>`. Keyboard focus ring included.
91
+ * For a selected link, add `pressed` and `aria-current`, not `aria-pressed`.
92
+ */
93
+ export declare function buttonClassName(options?: Readonly<ButtonClassNameOptions>): string;
94
+
95
+ export declare type ButtonClassNameOptions = {
96
+ variant?: ButtonVariant;
97
+ color?: ButtonColor;
98
+ size?: ControlSize;
99
+ radius?: Radius;
100
+ fullWidth?: boolean;
101
+ /**
102
+ * Applies the pressed look, for a real `<button>` toggle paired with
103
+ * `aria-pressed`. Never put `aria-pressed` on a link (not allowed on role
104
+ * link): on an `<a>`, pair the selected look with `aria-current`, for
105
+ * example `aria-current="page"`.
106
+ */
107
+ pressed?: boolean;
108
+ /** Keeps only the layout classes, like `Button`'s `unstyled` prop. */
109
+ unstyled?: boolean;
110
+ className?: string;
111
+ };
112
+
67
113
  declare type ButtonColor =
68
114
  | 'primary'
69
115
  | 'neutral'
@@ -85,6 +131,11 @@ export declare type ButtonProps = Omit<ComponentPropsWithRef<'button'>, 'childre
85
131
  * the tab order, exposed as `aria-disabled`.
86
132
  */
87
133
  loading?: boolean;
134
+ /**
135
+ * Turns the button into a toggle: sets `aria-pressed` and a pressed look (dark fill and inset ring).
136
+ * Leave it `undefined` for a regular button (no attribute).
137
+ */
138
+ pressed?: boolean;
88
139
  startIcon?: string;
89
140
  endIcon?: string;
90
141
  } & ButtonStyle;
@@ -153,7 +204,7 @@ export declare type CheckboxOption = ChoiceOption;
153
204
  export declare function CheckboxOptions({ options, orientation, values, defaultValues, onChange, name: nameProp, label, error, required, requiredLabel, disabled, radius, unstyled, className, wrapperClassName, id, 'aria-describedby': callerDescribedBy, ...props }: Readonly<CheckboxOptionsProps>): JSX.Element;
154
205
 
155
206
  export declare interface CheckboxOptionsProps extends Omit<ComponentPropsWithRef<'fieldset'>, 'onChange'> {
156
- options: string[] | CheckboxOption[];
207
+ options: readonly string[] | readonly CheckboxOption[];
157
208
  orientation?: 'vertical' | 'horizontal';
158
209
  values?: readonly string[];
159
210
  defaultValues?: readonly string[];
@@ -193,13 +244,13 @@ export declare interface ChoiceOption {
193
244
  disabled?: boolean;
194
245
  }
195
246
 
196
- export declare function ClientOnly({ children, fallback }: Readonly<ClientOnlyProps>): string | number | bigint | boolean | JSX.Element | Iterable< ReactNode> | Promise<string | number | bigint | boolean | ReactPortal | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable< ReactNode> | null | undefined> | null;
247
+ export declare function ClientOnly({ children, fallback }: Readonly<ClientOnlyProps>): string | number | bigint | boolean | Iterable< ReactNode> | Promise<string | number | bigint | boolean | ReactPortal | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable< ReactNode> | null | undefined> | JSX.Element | null;
197
248
 
198
249
  export declare interface ClientOnlyProps extends React.PropsWithChildren {
199
250
  fallback?: React.ReactNode;
200
251
  }
201
252
 
202
- export declare function Combobox({ options, label, description, 'aria-label': ariaLabel, error, placeholder, noResultsText, clearLabel, radius, unstyled, disabled, required, className, wrapperClassName, value, defaultValue, onChange, id, }: Readonly<ComboboxProps>): JSX.Element;
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;
203
254
 
204
255
  export declare interface ComboboxOption {
205
256
  value: string;
@@ -207,7 +258,13 @@ export declare interface ComboboxOption {
207
258
  }
208
259
 
209
260
  export declare interface ComboboxProps {
210
- options: ComboboxOption[];
261
+ options: readonly ComboboxOption[];
262
+ /**
263
+ * Decides whether an option matches the text typed in the field. Replaces
264
+ * the default case-insensitive "label contains the query" match, for
265
+ * example to ignore accents.
266
+ */
267
+ filterOption?: (option: ComboboxOption, query: string) => boolean;
211
268
  label?: string | ReactNode;
212
269
  description?: string | ReactNode;
213
270
  'aria-label'?: string;
@@ -289,14 +346,20 @@ export declare interface ContextMenuProps {
289
346
  /** The size scale every inline control shares (Button, IconButton, Badge, Kbd). */
290
347
  declare type ControlSize = 'xs' | 'sm' | 'md' | 'lg';
291
348
 
292
- export declare function CopyButton({ value, children, onError, }: Readonly<CopyButtonProps>): JSX.Element;
349
+ export declare function CopyButton({ value, children, label, onError, }: Readonly<CopyButtonProps>): JSX.Element;
293
350
 
294
351
  export declare interface CopyButtonProps {
295
352
  value: string;
296
- children: (props: {
353
+ /**
354
+ * Render prop for a custom control. Omit it to get the default icon button,
355
+ * which swaps its copy icon for a check while `copied`.
356
+ */
357
+ children?: (props: {
297
358
  copied: boolean;
298
359
  copy: () => void | Promise<void>;
299
360
  }) => ReactNode;
361
+ /** Accessible name of the default icon button. Defaults to `labels.copy`. */
362
+ label?: string;
300
363
  onError?: (error: unknown) => void;
301
364
  }
302
365
 
@@ -307,8 +370,24 @@ export declare interface CopyButtonProps {
307
370
  */
308
371
  export declare type DismissGuard = () => boolean | Promise<boolean>;
309
372
 
373
+ /**
374
+ * Asks the topmost open dialog (`Modal`, `ConfirmModal`, `Drawer`) to close,
375
+ * exactly as an Escape press would: dismiss guards are honoured, and a
376
+ * dialog with `dismissible={false}` stays open. Only the topmost dialog is
377
+ * affected, and a dialog already closing does not count.
378
+ *
379
+ * Call it first from a hardware back button handler (for example Capacitor's
380
+ * `backButton` listener on Android) and navigate back only when it returns
381
+ * `false`.
382
+ *
383
+ * @returns `true` when a dialog was open and the press was consumed (even if
384
+ * it stayed open because it is not dismissible or a guard refused), `false`
385
+ * when no dialog is open.
386
+ */
387
+ export declare function dismissTopmostModal(): boolean;
388
+
310
389
  /** A modal side panel with the same focus handling, stacking and footer slot as `Modal`. */
311
- export declare function Drawer({ isOpen, onClose, size, side, radius, dismissible, ...dialogProps }: Readonly<DrawerProps>): JSX.Element | null;
390
+ export declare function Drawer({ isOpen, onClose, size, side, radius, dismissible, onExited, ...dialogProps }: Readonly<DrawerProps>): JSX.Element | null;
312
391
 
313
392
  export declare interface DrawerProps {
314
393
  isOpen: boolean;
@@ -334,6 +413,8 @@ export declare interface DrawerProps {
334
413
  /** `false` hides the close button and blocks Escape and backdrop dismissal. */
335
414
  dismissible?: boolean;
336
415
  closeLabel?: string;
416
+ /** 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}`. */
417
+ onExited?: () => void;
337
418
  /** Wins over the first-tabbable rule for where focus lands on open; may point at an element in the body or in the footer. */
338
419
  initialFocusRef?: RefObject<HTMLElement | null>;
339
420
  }
@@ -342,6 +423,9 @@ export declare type DrawerSide = 'right' | 'left';
342
423
 
343
424
  export declare type DrawerSize = 'sm' | 'md' | 'lg';
344
425
 
426
+ /** French translation of every `UILabels` entry, ready for `<UIProvider labels={FRENCH_UI_LABELS}>`. */
427
+ export declare const FRENCH_UI_LABELS: UILabels;
428
+
345
429
  declare interface GlobalHotkeysStore {
346
430
  globalHotkeysEnabled: boolean;
347
431
  setGlobalHotkeysEnabled: (value: boolean) => void;
@@ -383,6 +467,11 @@ export declare type IconButtonProps = ComponentPropsWithRef<'button'> & {
383
467
  size?: ControlSize;
384
468
  children?: ReactNode;
385
469
  tooltip?: IconButtonTooltip;
470
+ /**
471
+ * Turns the button into a toggle: sets `aria-pressed` and a pressed look (dark fill and inset ring).
472
+ * Leave it `undefined` for a regular button (no attribute).
473
+ */
474
+ pressed?: boolean;
386
475
  } & IconButtonStyle;
387
476
 
388
477
  declare type IconButtonStyle = {
@@ -670,7 +759,7 @@ export declare type ModalSize = 'sm' | 'md' | 'lg' | 'xl';
670
759
  * pills opens a searchable dropdown of checkbox-style options. Selecting an
671
760
  * option never closes the dropdown, unlike the single-select `Combobox`.
672
761
  */
673
- export declare function MultiCombobox({ options, 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;
762
+ 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;
674
763
 
675
764
  export declare interface MultiComboboxOption {
676
765
  value: string;
@@ -679,6 +768,12 @@ export declare interface MultiComboboxOption {
679
768
 
680
769
  export declare interface MultiComboboxProps {
681
770
  options: readonly MultiComboboxOption[];
771
+ /**
772
+ * Decides whether an option matches the text typed in the search field.
773
+ * Replaces the default case-insensitive "label contains the query" match,
774
+ * for example to ignore accents.
775
+ */
776
+ filterOption?: (option: MultiComboboxOption, query: string) => boolean;
682
777
  label?: string | ReactNode;
683
778
  description?: string | ReactNode;
684
779
  'aria-label'?: string;
@@ -718,7 +813,7 @@ export declare type RadioOption = ChoiceOption;
718
813
  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;
719
814
 
720
815
  export declare interface RadioOptionsProps extends Omit<ComponentPropsWithRef<'fieldset'>, 'onChange'> {
721
- options: string[] | RadioOption[];
816
+ options: readonly string[] | readonly RadioOption[];
722
817
  orientation?: 'vertical' | 'horizontal';
723
818
  value?: string;
724
819
  defaultValue?: string;
@@ -744,7 +839,7 @@ export declare interface SelectOption {
744
839
  }
745
840
 
746
841
  export declare interface SelectProps extends Omit<ComponentPropsWithRef<'select'>, 'children'> {
747
- options: SelectOption[];
842
+ options: readonly SelectOption[];
748
843
  label?: string | ReactNode;
749
844
  description?: string | ReactNode;
750
845
  error?: string;
@@ -755,6 +850,28 @@ export declare interface SelectProps extends Omit<ComponentPropsWithRef<'select'
755
850
  wrapperClassName?: string;
756
851
  }
757
852
 
853
+ /**
854
+ * A loading indicator exposed as a `role="status"` live region. It uses the
855
+ * icon `Button` shows while loading; under `prefers-reduced-motion` it swaps
856
+ * to a static icon.
857
+ *
858
+ * The region is empty on first render and filled right after mount, because
859
+ * many screen readers ignore a live region inserted already filled. The most
860
+ * robust setup is still a persistent `role="status"` wrapper in your own
861
+ * markup that stays mounted while a `Spinner` appears and disappears inside it.
862
+ */
863
+ export declare function Spinner(spinnerProps: Readonly<SpinnerProps>): JSX.Element;
864
+
865
+ export declare type SpinnerProps = Omit<ComponentPropsWithRef<'span'>, 'children'> & {
866
+ size?: ControlSize;
867
+ /** Visible text next to the icon. Without it, `labels.loading` is announced instead. */
868
+ label?: string;
869
+ /** Strips the built-in layout, text size and icon size; size the icon with `iconClassName`. */
870
+ unstyled?: boolean;
871
+ iconClassName?: string;
872
+ labelClassName?: string;
873
+ };
874
+
758
875
  /**
759
876
  * A primary action joined to a chevron `Menu` trigger that offers secondary
760
877
  * variants of that action. Both halves share variant, color, size and radius:
@@ -869,7 +986,7 @@ export declare function Tabs({ items, value, defaultIndex, variant, orientation,
869
986
  export declare type TabsOrientation = 'horizontal' | 'vertical';
870
987
 
871
988
  export declare interface TabsProps {
872
- items: TabItem[];
989
+ items: readonly TabItem[];
873
990
  value?: number;
874
991
  defaultIndex?: number;
875
992
  variant?: TabsVariant;
@@ -930,6 +1047,15 @@ export declare interface TextareaProps extends ComponentPropsWithRef<'textarea'>
930
1047
 
931
1048
  export declare type Theme = 'light' | 'dark' | 'system';
932
1049
 
1050
+ /**
1051
+ * A self-contained script that applies the saved theme to `<html>` before the
1052
+ * first paint, so a dark-mode visitor never sees a light flash. It mirrors
1053
+ * `useThemeStore`: the `dark` class is set for a stored `dark`, or for a
1054
+ * stored `system` (or a missing, invalid or unreadable value) while the OS
1055
+ * prefers dark. Inline it in `<head>` before the stylesheet.
1056
+ */
1057
+ export declare const THEME_INIT_SCRIPT: string;
1058
+
933
1059
  declare interface ThemeStore {
934
1060
  theme: Theme;
935
1061
  setTheme: (theme: Theme) => void;
@@ -1060,8 +1186,14 @@ export declare interface UILabels {
1060
1186
  required: string;
1061
1187
  /** Screen-reader announcement of `CopyButton` after a successful copy. */
1062
1188
  copied: string;
1063
- /** Screen-reader text added to a `Button` while it is loading. */
1189
+ /** Screen-reader text added to a `Button` while it is loading, and announced by `Spinner` without a visible label. */
1064
1190
  loading: string;
1191
+ /**
1192
+ * Accessible name of the default `CopyButton` icon button. Optional so that
1193
+ * existing complete label objects keep compiling: a translated label set
1194
+ * must add it, otherwise the English "Copy" is used.
1195
+ */
1196
+ copy?: string;
1065
1197
  /** Screen-reader announcement of `CharacterCount` near and at the limit. */
1066
1198
  charactersRemaining: (remainingCount: number) => string;
1067
1199
  /** Accessible name of `ThemeToggle` for the current theme. */