@ahrowe/ui 0.37.0 → 0.39.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.
Files changed (123) hide show
  1. package/README.md +5 -2
  2. package/dist/cjs-types/common/commandPalette/commandPalette.d.ts +4 -0
  3. package/dist/cjs-types/common/commandPalette/commandPalette.types.d.ts +42 -0
  4. package/dist/cjs-types/common/commandPalette/filterCommands.d.ts +15 -0
  5. package/dist/cjs-types/common/commandPalette/index.d.ts +2 -0
  6. package/dist/cjs-types/common/configProvider/configProvider.types.d.ts +6 -0
  7. package/dist/cjs-types/common/copyButton/copyButton.d.ts +4 -0
  8. package/dist/cjs-types/common/copyButton/copyButton.types.d.ts +36 -0
  9. package/dist/cjs-types/common/copyButton/index.d.ts +2 -0
  10. package/dist/cjs-types/common/datePicker/components/datePickerMonth/datePickerMonth.types.d.ts +6 -6
  11. package/dist/cjs-types/common/datePicker/dateFunctions.d.ts +10 -19
  12. package/dist/cjs-types/common/datePicker/datePicker.types.d.ts +20 -14
  13. package/dist/cjs-types/common/datePicker/index.d.ts +1 -0
  14. package/dist/cjs-types/common/errorBoundary/errorBoundary.d.ts +0 -3
  15. package/dist/cjs-types/common/errorBoundary/errorBoundary.types.d.ts +2 -3
  16. package/dist/cjs-types/common/hooks/index.d.ts +2 -0
  17. package/dist/cjs-types/common/hooks/useClipboard.d.ts +16 -0
  18. package/dist/cjs-types/common/inputDropdown/inputDropdown.types.d.ts +7 -1
  19. package/dist/cjs-types/common/menu/menu.types.d.ts +2 -0
  20. package/dist/cjs-types/common/popover/popover.types.d.ts +5 -0
  21. package/dist/cjs-types/common/themeProvider/themeProvider.d.ts +8 -7
  22. package/dist/cjs-types/common/utils/day.d.ts +16 -0
  23. package/dist/cjs-types/common/weatherIcon/index.d.ts +3 -0
  24. package/dist/cjs-types/common/weatherIcon/parts.d.ts +26 -0
  25. package/dist/cjs-types/common/weatherIcon/weatherIcon.d.ts +4 -0
  26. package/dist/cjs-types/common/weatherIcon/weatherIcon.types.d.ts +44 -0
  27. package/dist/cjs-types/index.d.ts +7 -1
  28. package/dist/esm/common/buttonGroup/buttonGroup.module.mjs.map +1 -1
  29. package/dist/esm/common/commandPalette/commandPalette.mjs +2 -0
  30. package/dist/esm/common/commandPalette/commandPalette.mjs.map +1 -0
  31. package/dist/esm/common/commandPalette/commandPalette.module.mjs +2 -0
  32. package/dist/esm/common/commandPalette/commandPalette.module.mjs.map +1 -0
  33. package/dist/esm/common/commandPalette/filterCommands.mjs +2 -0
  34. package/dist/esm/common/commandPalette/filterCommands.mjs.map +1 -0
  35. package/dist/esm/common/copyButton/copyButton.mjs +2 -0
  36. package/dist/esm/common/copyButton/copyButton.mjs.map +1 -0
  37. package/dist/esm/common/copyButton/copyButton.module.mjs +2 -0
  38. package/dist/esm/common/copyButton/copyButton.module.mjs.map +1 -0
  39. package/dist/esm/common/datePicker/components/datePickerMonth/datePickerMonth.mjs +1 -1
  40. package/dist/esm/common/datePicker/components/datePickerMonth/datePickerMonth.mjs.map +1 -1
  41. package/dist/esm/common/datePicker/dateFunctions.mjs +1 -1
  42. package/dist/esm/common/datePicker/dateFunctions.mjs.map +1 -1
  43. package/dist/esm/common/datePicker/datePicker.mjs +1 -1
  44. package/dist/esm/common/datePicker/datePicker.mjs.map +1 -1
  45. package/dist/esm/common/dropdown/dropdown.mjs +1 -1
  46. package/dist/esm/common/dropdown/dropdown.module.mjs.map +1 -1
  47. package/dist/esm/common/errorBoundary/errorBoundary.mjs +2 -2
  48. package/dist/esm/common/errorBoundary/errorBoundary.mjs.map +1 -1
  49. package/dist/esm/common/errorBoundary/errorBoundary.module.mjs +1 -1
  50. package/dist/esm/common/errorBoundary/errorBoundary.module.mjs.map +1 -1
  51. package/dist/esm/common/hooks/useClipboard.mjs +2 -0
  52. package/dist/esm/common/hooks/useClipboard.mjs.map +1 -0
  53. package/dist/esm/common/hooks/useFocusBoundary.mjs +1 -1
  54. package/dist/esm/common/hooks/useFocusBoundary.mjs.map +1 -1
  55. package/dist/esm/common/inputDropdown/inputDropdown.mjs +1 -1
  56. package/dist/esm/common/inputDropdown/inputDropdown.mjs.map +1 -1
  57. package/dist/esm/common/inputDropdown/inputDropdown.module.mjs +1 -1
  58. package/dist/esm/common/inputDropdown/inputDropdown.module.mjs.map +1 -1
  59. package/dist/esm/common/menu/menu.mjs +1 -1
  60. package/dist/esm/common/menu/menu.mjs.map +1 -1
  61. package/dist/esm/common/menu/menu.types.mjs.map +1 -1
  62. package/dist/esm/common/popover/popover.mjs +1 -1
  63. package/dist/esm/common/popover/popover.mjs.map +1 -1
  64. package/dist/esm/common/popover/popover.types.mjs.map +1 -1
  65. package/dist/esm/common/themeProvider/themeProvider.mjs +1 -1
  66. package/dist/esm/common/themeProvider/themeProvider.mjs.map +1 -1
  67. package/dist/esm/common/timer/timer.mjs +1 -1
  68. package/dist/esm/common/timer/timer.mjs.map +1 -1
  69. package/dist/esm/common/utils/day.mjs +2 -0
  70. package/dist/esm/common/utils/day.mjs.map +1 -0
  71. package/dist/esm/common/weatherIcon/parts.mjs +2 -0
  72. package/dist/esm/common/weatherIcon/parts.mjs.map +1 -0
  73. package/dist/esm/common/weatherIcon/weatherIcon.mjs +2 -0
  74. package/dist/esm/common/weatherIcon/weatherIcon.mjs.map +1 -0
  75. package/dist/esm/common/weatherIcon/weatherIcon.module.mjs +2 -0
  76. package/dist/esm/common/weatherIcon/weatherIcon.module.mjs.map +1 -0
  77. package/dist/esm/common/weatherIcon/weatherIcon.types.mjs +2 -0
  78. package/dist/esm/common/weatherIcon/weatherIcon.types.mjs.map +1 -0
  79. package/dist/esm/index.mjs +1 -1
  80. package/dist/index.cjs +3 -3
  81. package/dist/index.cjs.map +1 -1
  82. package/dist/style.css +1 -1
  83. package/dist/types/common/commandPalette/commandPalette.d.ts +4 -0
  84. package/dist/types/common/commandPalette/commandPalette.types.d.ts +42 -0
  85. package/dist/types/common/commandPalette/filterCommands.d.ts +15 -0
  86. package/dist/types/common/commandPalette/index.d.ts +2 -0
  87. package/dist/types/common/configProvider/configProvider.types.d.ts +6 -0
  88. package/dist/types/common/copyButton/copyButton.d.ts +4 -0
  89. package/dist/types/common/copyButton/copyButton.types.d.ts +36 -0
  90. package/dist/types/common/copyButton/index.d.ts +2 -0
  91. package/dist/types/common/datePicker/components/datePickerMonth/datePickerMonth.types.d.ts +6 -6
  92. package/dist/types/common/datePicker/dateFunctions.d.ts +10 -19
  93. package/dist/types/common/datePicker/datePicker.types.d.ts +20 -14
  94. package/dist/types/common/datePicker/index.d.ts +1 -0
  95. package/dist/types/common/errorBoundary/errorBoundary.d.ts +0 -3
  96. package/dist/types/common/errorBoundary/errorBoundary.types.d.ts +2 -3
  97. package/dist/types/common/hooks/index.d.ts +2 -0
  98. package/dist/types/common/hooks/useClipboard.d.ts +16 -0
  99. package/dist/types/common/inputDropdown/inputDropdown.types.d.ts +7 -1
  100. package/dist/types/common/menu/menu.types.d.ts +2 -0
  101. package/dist/types/common/popover/popover.types.d.ts +5 -0
  102. package/dist/types/common/themeProvider/themeProvider.d.ts +8 -7
  103. package/dist/types/common/utils/day.d.ts +16 -0
  104. package/dist/types/common/weatherIcon/index.d.ts +3 -0
  105. package/dist/types/common/weatherIcon/parts.d.ts +26 -0
  106. package/dist/types/common/weatherIcon/weatherIcon.d.ts +4 -0
  107. package/dist/types/common/weatherIcon/weatherIcon.types.d.ts +44 -0
  108. package/dist/types/index.d.ts +7 -1
  109. package/docs/ButtonGroup.md +2 -0
  110. package/docs/CLAUDE.md +9 -2
  111. package/docs/CommandPalette.md +84 -0
  112. package/docs/ConfigProvider.md +2 -2
  113. package/docs/CopyButton.md +45 -0
  114. package/docs/DatePicker.md +72 -45
  115. package/docs/ErrorBoundary.md +4 -4
  116. package/docs/Fab.md +1 -2
  117. package/docs/FormValidator.md +3 -3
  118. package/docs/Hooks.md +39 -3
  119. package/docs/InputDropdown.md +9 -4
  120. package/docs/Menu.md +5 -3
  121. package/docs/Popover.md +15 -0
  122. package/docs/WeatherIcon.md +49 -0
  123. package/package.json +2 -3
@@ -0,0 +1,84 @@
1
+ # CommandPalette
2
+
3
+ **When to use:** A search-first dialog for running any command or jumping anywhere in the app from the keyboard, usually opened with ⌘K / Ctrl+K. It takes the same `MenuEntry[]` as [Menu](Menu.md), so one list can feed the menus, the palette and the shortcuts. For picking a value in a form, use [InputDropdown](InputDropdown.md).
4
+
5
+ **Keywords:** cmd+k, ctrl+k, command menu, quick open, launcher, spotlight, go to anything, jump to, omnibox
6
+
7
+ **Import:** `import { CommandPalette } from '@ahrowe/ui'`
8
+ **Types:** `import type { CommandPaletteProps, CommandPaletteLabels } from '@ahrowe/ui'`
9
+
10
+ **Requires:** `<div id="bodyEnd"></div>` in your app: the palette is a [Modal](Modal.md) and portals there.
11
+
12
+ ```tsx
13
+ import { CommandPalette, menuHotkeys, useHotkeys } from '@ahrowe/ui';
14
+ import type { MenuEntry } from '@ahrowe/ui';
15
+
16
+ const commands: MenuEntry[] = [
17
+ { heading: 'Invoice' },
18
+ { id: 'new-invoice', label: 'New invoice', icon: faPlus, shortcut: 'mod+n', onClick: createInvoice },
19
+ { id: 'export', label: 'Export as PDF', icon: faFilePdf, keywords: ['download', 'print'], onClick: exportPdf },
20
+ { heading: 'Go to' },
21
+ { id: 'customers', label: 'Customers', onClick: () => navigate('/customers') },
22
+ ];
23
+
24
+ function App() {
25
+ const [isOpen, setOpen] = useState(false);
26
+ useHotkeys({ ...menuHotkeys(commands), 'mod+k': () => setOpen(true) });
27
+
28
+ return <CommandPalette items={commands} isOpen={isOpen} onClose={() => setOpen(false)} />;
29
+ }
30
+ ```
31
+
32
+ **Opening it is yours.** The palette binds no shortcut of its own, for the same reason `Menu` does not: if it listened for ⌘K and your app did too, it would open twice. `menuHotkeys` binds each command's `shortcut`, so the hint the palette draws and the key that fires stay one spelling.
33
+
34
+ **Filtering** matches every typed word, in any order, against a string `label` and the command's `keywords`, ignoring case and accents: `uber` finds `Übersicht`. Within each group, a label starting with the query comes first, then a label word or a keyword starting with it, then any other match, and the group holding the best match moves to the top. A heading moves with its group and stays above it while any of it matches; separators are dropped while a query is typed. Typing highlights the best match, so Enter runs it. A `label` that is not a string cannot be read, so give such a command `keywords`.
35
+
36
+ **Server results.** Set `filter={false}` and the palette shows `items` exactly as given, so they can be the results of your own search:
37
+
38
+ ```tsx
39
+ const [results, setResults] = useState<MenuEntry[]>(commands);
40
+ const [isLoading, setLoading] = useState(false);
41
+
42
+ <CommandPalette
43
+ items={results}
44
+ filter={false}
45
+ isLoading={isLoading}
46
+ onQueryChange={(query) => {
47
+ if (!query) return setResults(commands);
48
+ setLoading(true);
49
+ searchCustomers(query).then((hits) => {
50
+ setResults(hits.map((c) => ({ id: c.id, label: c.name, onClick: () => navigate(`/customers/${c.id}`) })));
51
+ setLoading(false);
52
+ });
53
+ }}
54
+ isOpen={isOpen}
55
+ onClose={() => setOpen(false)}
56
+ />;
57
+ ```
58
+
59
+ `onQueryChange` fires on every keystroke and with `''` each time the palette opens, so debounce the request and drop a response that arrives after a newer query. Between keystrokes the highlight follows the command's `id`, so it stays put while results arrive and are replaced around it.
60
+
61
+ **Keyboard:** the focus stays in the search field. ↑ and ↓ move the highlight, wrapping and skipping disabled commands, Enter runs it, and Escape closes. Running a command calls its own `onClick`, then `onAction`, then `onClose`, the same order `Menu` uses. The number of matches is announced to screen readers as it changes.
62
+
63
+ On a small touch screen it can present as a bottom sheet like every `Modal`: pass `presentation={Presentation.Auto}`, or set it app-wide through `ConfigProvider`.
64
+
65
+ **Key props:**
66
+
67
+ | Prop | Type | Description |
68
+ |------|------|-------------|
69
+ | `items` | `MenuEntry[]` | The commands. Headings group them; separators split groups (required) |
70
+ | `isOpen` | `boolean` | Whether the palette is open (required) |
71
+ | `onClose` | `() => void` | Called on Escape, a backdrop click, and after a command runs (required) |
72
+ | `onAction` | `(id: string) => void` | Called with the id of the command that ran, after its own `onClick` |
73
+ | `onQueryChange` | `(query: string) => void` | Called on every keystroke, and with `''` on open |
74
+ | `filter` | `boolean` | Filter `items` by the query (default `true`). `false` shows them as given |
75
+ | `isLoading` | `boolean` | Shows the `loading` label instead of `empty` while nothing matches |
76
+ | `presentation` | `Presentation` | Centred dialog (default), bottom sheet, or a sheet only on a small touch screen |
77
+
78
+ A command is a `MenuAction`: `id`, `label`, `icon`, `shortcut`, `keywords`, `disabled`, `danger` and `onClick`. See [Menu.md](Menu.md).
79
+
80
+ **Global defaults:** adopts `ConfigProvider`, e.g. `defaultProps={{ CommandPalette: { presentation: Presentation.Auto } }}`. See [ConfigProvider.md](ConfigProvider.md).
81
+
82
+ **Slots:** `root` `search` `input` `list` `item` `itemIcon` `itemLabel` `itemShortcut` `heading` `separator` `empty`
83
+
84
+ **Labels:** `label` `placeholder` `empty` `loading` `results(count)`: the text this component renders of its own. Pass `labels` to override any of them, on the component or app-wide through `ConfigProvider`; they merge per key. Type: `CommandPaletteLabels`.
@@ -92,8 +92,8 @@ A nested provider inherits it unless it sets its own, the same way `defaultProps
92
92
  Each entry is a `Partial<...Props>`, so any of that component's props can be defaulted:
93
93
 
94
94
  - **Form inputs:** `Input` · `Textarea` · `NumberInput` · `Dropdown` · `InputDropdown` · `Checkbox` · `Switch` · `RadioGroup` · `DatePicker` · `OptionPicker` · `OtpInput`
95
- - **Display:** `Button` · `ActionButtons` · `Badge` · `Chip` · `Card` · `SectionHeader` · `Skeleton` · `Accordion` · `Divider` · `Timer`
96
- - **Overlays:** `ConfirmModal` · `Modal` · `Popover` · `Popover`
95
+ - **Display:** `Button` · `CopyButton` · `ActionButtons` · `Badge` · `Chip` · `Card` · `SectionHeader` · `Skeleton` · `Accordion` · `Divider` · `Timer` · `WeatherIcon`
96
+ - **Overlays:** `ConfirmModal` · `Modal` · `CommandPalette` · `Popover`
97
97
  - **Editors:** `RoomDrawer`, `RoomViewer`
98
98
 
99
99
  ```tsx
@@ -0,0 +1,45 @@
1
+ # CopyButton
2
+
3
+ **When to use:** Icon-only button that copies a value to the clipboard: an invoice number, an ID in a table row, an API key or share link inside an `Input`. For a labelled "Copy link" button, use `Button` with the `useClipboard` hook instead (see [Hooks.md](Hooks.md)).
4
+
5
+ **Keywords:** token, secret, reference number, snippet
6
+
7
+ **Import:** `import { CopyButton } from '@ahrowe/ui'`
8
+ **Types:** `import type { CopyButtonProps, CopyButtonLabels } from '@ahrowe/ui'`
9
+
10
+ ```tsx
11
+ import { CopyButton, Input } from '@ahrowe/ui';
12
+
13
+ <span>
14
+ {invoice.number} <CopyButton value={invoice.number} />
15
+ </span>
16
+
17
+ // inside a field
18
+ <Input label='API key' value={apiKey} readOnly suffix={<CopyButton value={apiKey} />} />
19
+
20
+ // a toast on success, where the button is not where the user is looking
21
+ <CopyButton value={url} onCopy={() => showToast('Link copied', { type: 'success' })} />
22
+ ```
23
+
24
+ After a click the icon rotates into a check (through `AnimatedIcon`, held still under `prefers-reduced-motion`) for `timeout` ms, the accessible name and tooltip read "Copied", and a polite live region announces it, because a swapped icon or a changed `aria-label` alone is not read out. A refused write (an insecure context, a blocked iframe) announces "Could not copy" and calls `onError`; the icon stays as it was. `CopyButton` shows no toast of its own: pass one through `onCopy` / `onError` if you want it.
25
+
26
+ The live region is rendered as a sibling of the button, so `CopyButton` returns two elements. It is visually hidden and takes no space.
27
+
28
+ **Key props:**
29
+
30
+ | Prop | Type | Description |
31
+ |------|------|-------------|
32
+ | `value` | `string` | Text written to the clipboard on click (required) |
33
+ | `timeout` | `number` | How long the copied state shows, in ms (default `2000`) |
34
+ | `icon` | `IconDefinition \| ReactElement` | Icon before copying (default `faCopy`). The swap animates only when both icons are FontAwesome definitions |
35
+ | `copiedIcon` | `IconDefinition \| ReactElement` | Icon while copied (default `faCheck`) |
36
+ | `onCopy` | `(value: string) => void` | Called after a successful copy |
37
+ | `onError` | `() => void` | Called when the clipboard refuses the write. `useClipboard` returns the reason |
38
+ | `onClick` | `(event: MouseEvent) => void` | Called on every click, before the copy |
39
+ | `ref` | `Ref<HTMLDivElement>` | Ref to the button |
40
+
41
+ **Global defaults:** adopts `ConfigProvider`, e.g. `defaultProps={{ CopyButton: { timeout: 1500 } }}`. See [ConfigProvider.md](ConfigProvider.md).
42
+
43
+ **Slots:** `root` `icon`
44
+
45
+ **Labels:** `copy` `copied` `failed`: the text this component renders of its own. Pass `labels` to override any of them, on the component or app-wide through `ConfigProvider`; they merge per key. Type: `CopyButtonLabels`.
@@ -2,72 +2,58 @@
2
2
 
3
3
  **When to use:** Date selection input — booking forms, birth date fields, date range pickers, deadline selectors. Renders as a text input with a floating label by default; can also be inline or modal.
4
4
 
5
- **Keywords:** calendar picker
5
+ **Keywords:** calendar picker, timezone, ISO date, date string, day picker
6
6
 
7
7
  **Keyboard:** the day grid uses a roving tab stop (the selected day, or today, or the 1st — one Tab reaches it, not every day individually); arrow keys move by day (`←`/`→`) or by week (`↑`/`↓`), crossing into the adjacent month automatically when they run past the edge, and Enter/Space selects the focused day.
8
8
 
9
- **Import:** `import { DatePicker } from '@ahrowe/ui'`
9
+ **Import:** `import { DatePicker, dateToDay, dayToDate } from '@ahrowe/ui'`
10
10
  **Types:** `import type { DatePickerProps } from '@ahrowe/ui'`
11
11
 
12
12
  ```tsx
13
- import { DatePicker, FormValidator } from '@ahrowe/ui';
13
+ import { DatePicker, FormValidator, dateToDay } from '@ahrowe/ui';
14
14
 
15
- // Controlled input with floating label
16
- <DatePicker
17
- label="Date of birth"
18
- selected={date}
19
- onSelect={(date) => setDate(date)}
20
- />
15
+ // Controlled input with floating label. The value is a day: '2026-09-15'
16
+ const [day, setDay] = useState<string | null>(null);
17
+ <DatePicker label="Date of birth" selected={day} onSelect={setDay} />
21
18
 
22
19
  // With FormValidator
23
- const dateValidator = new FormValidator<Date | null>(null);
24
- <DatePicker label="Start date" formValidator={dateValidator} />
20
+ const dayValidator = new FormValidator<string | null>(null);
21
+ <DatePicker label="Start date" formValidator={dayValidator} />
25
22
 
26
23
  // Inline calendar (no input field)
27
- <DatePicker
28
- selected={date}
29
- onSelect={setDate}
30
- isInline
31
- noInput
32
- />
24
+ <DatePicker selected={day} onSelect={setDay} isInline noInput />
33
25
 
34
26
  // Inside a modal (calendar opens in a Modal overlay)
35
- <DatePicker
36
- label="Schedule date"
37
- selected={date}
38
- onSelect={setDate}
39
- isModal
40
- />
27
+ <DatePicker label="Schedule date" selected={day} onSelect={setDay} isModal />
41
28
 
42
- // With min/max constraints and a clear button
29
+ // From today to the end of the year, with a clear button
43
30
  <DatePicker
44
31
  label="Appointment"
45
- selected={date}
46
- onSelect={setDate}
47
- minDate={new Date()}
48
- maxDate={endOfYear}
32
+ selected={day}
33
+ onSelect={setDay}
34
+ minDate={dateToDay(new Date())}
35
+ maxDate="2026-12-31"
49
36
  showClearButton
50
37
  />
51
38
 
52
- // Date range (highlight start–end)
53
- <DatePicker
54
- label="From"
55
- selected={startDate}
56
- onSelect={setStartDate}
57
- startDate={startDate}
58
- endDate={endDate}
59
- />
39
+ // Range: two pickers sharing a highlighted start and end
40
+ <DatePicker label="From" selected={from} onSelect={setFrom} startDate={from} endDate={to} />
41
+ <DatePicker label="To" selected={to} onSelect={setTo} startDate={from} endDate={to} minDate={from} />
60
42
  ```
61
43
 
44
+ **A day, not a moment:** every day DatePicker takes or hands out is a `'YYYY-MM-DD'` string, the value an `<input type="date">` has. It has no time and no time zone, so it is the same day for everyone who sees it. The strings sort as the days do, so `from < to` compares two of them. `null`, `''` and anything that is not a real day, such as `'2026-02-30'`, mean no value; anything but the first two also logs a warning naming the prop, once.
45
+
46
+ `dateToDay(date)` and `dayToDate(day)` convert to and from a `Date` in the browser's time zone: `dateToDay(new Date())` is today, `dayToDate(day)` the first moment of that day. Do not use `date.toISOString().slice(0, 10)`: that is the day in UTC, which in Vienna shortly after midnight is still yesterday.
47
+
62
48
  **Key props:**
63
49
 
64
50
  | Prop | Type | Description |
65
51
  |------|------|-------------|
66
52
  | `label` | `string` | Floating label on the input |
67
- | `selected` | `Date` | Currently selected date |
68
- | `onSelect` | `(date: Date \| null) => void` | Called when a date is picked |
69
- | `onChange` | `(date: Date \| null) => void` | Alternative change callback |
70
- | `formValidator` | `FormValidator` | Connects to validation |
53
+ | `selected` | `string \| null` | Selected day, `'YYYY-MM-DD'` |
54
+ | `onSelect` | `(day: string \| null) => void` | Called when a day is picked in the calendar |
55
+ | `onChange` | `(day: string \| null) => void` | Called on every change, picked or typed |
56
+ | `formValidator` | `FormValidator<string \| null>` | Connects to validation, and then owns the value. `new FormValidator(null)` and `new FormValidator('')` fit too |
71
57
  | `isRequired` | `boolean` | |
72
58
  | `isValid` | `boolean` | Manual valid state |
73
59
  | `errorMessage` | `string` | Manual error message |
@@ -75,12 +61,53 @@ const dateValidator = new FormValidator<Date | null>(null);
75
61
  | `noHeader` | `boolean` | Hide the month/year navigation header |
76
62
  | `isModal` | `boolean` | Open calendar in a Modal overlay |
77
63
  | `isInline` | `boolean` | Render calendar always-visible inline |
78
- | `minDate` | `Date \| null` | Earliest selectable date |
79
- | `maxDate` | `Date \| null` | Latest selectable date |
64
+ | `minDate` | `string \| null` | Earliest selectable day |
65
+ | `maxDate` | `string \| null` | Latest selectable day |
80
66
  | `showClearButton` | `boolean` | Show a clear/reset button |
81
- | `startDate` | `Date` | Range highlight start |
82
- | `endDate` | `Date` | Range highlight end |
67
+ | `startDate` | `string \| null` | Range highlight start |
68
+ | `endDate` | `string \| null` | Range highlight end |
83
69
 
84
70
  **Slots:** `root` `input` `header` `monthDropdown` `yearDropdown` `calendar` `clearButton`
85
71
 
86
- The root also takes the full native attribute set (`id`, `data-*`, `aria-*`, `title`, event handlers). `onChange` and `onSelect` report a `Date`, so the native handlers of those names are not available.
72
+ The root also takes the full native attribute set (`id`, `data-*`, `aria-*`, `title`, event handlers). `onChange` and `onSelect` report a day, so the native handlers of those names are not available.
73
+
74
+ ## When a day stands for a moment
75
+
76
+ A poll that closes on a day, a booking that starts on one, a deadline: here the day has to become a moment, and only your app knows in which time zone and at which point of the day. Store the moment itself (a UTC timestamp), and show it in the time zone of whoever reads it.
77
+
78
+ In the user's own time zone the platform is enough:
79
+
80
+ ```ts
81
+ const start = dayToDate(day); // the first moment of the day, where the user is
82
+ ```
83
+
84
+ In a given zone, a company's, a household's, a venue's, use a library that knows time zones, such as Luxon. Name the zone (`'Europe/Vienna'`), not its offset (`+02:00`): the offset changes with daylight saving.
85
+
86
+ ```ts
87
+ import { DateTime } from 'luxon';
88
+
89
+ // Closes at the end of the day in Vienna, wherever the reader is.
90
+ const closesAt = DateTime.fromISO(day, { zone: 'Europe/Vienna' }).endOf('day').toJSDate();
91
+
92
+ // And back, for the picker: the day that moment falls on in Vienna.
93
+ const shown = DateTime.fromJSDate(closesAt, { zone: 'Europe/Vienna' }).toISODate();
94
+ ```
95
+
96
+ With a [TimeInput](TimeInput.md) next to it, which holds `'HH:MM'`, the two make a moment the same way: ``DateTime.fromISO(`${day}T${time}`, { zone })``, or, in the user's own zone, `dayToDate(day)` followed by `setHours(hours, minutes)`.
97
+
98
+ ## Migration from Date values
99
+
100
+ Before 0.39 every day was a `Date` at midnight UTC. Now it is a `'YYYY-MM-DD'` string, and TypeScript points at each place to change, because a `Date` no longer fits any of the props. Two things still get through: a `Date` in plain JavaScript or through a validator typed as `FormValidator<any>`, and a stored value read back as its JSON, `'2026-09-15T00:00:00.000Z'`, which is a string to TypeScript. DatePicker shows either as an empty field, logs a warning naming the prop, and leaves the value alone until the user actually edits the field.
101
+
102
+ | Before | After |
103
+ |--------|-------|
104
+ | `useState<Date \| null>(null)` | `useState<string \| null>(null)` |
105
+ | `new FormValidator<Date \| null>(null)` | `new FormValidator<string \| null>(null)` |
106
+ | `selected={new Date()}`, `minDate={new Date()}` | `selected={dateToDay(new Date())}` |
107
+ | `a.getTime() < b.getTime()` | `a < b` |
108
+ | `date.toLocaleDateString(locale)` | `dayToDate(day).toLocaleDateString(locale)` |
109
+ | `JSON.stringify(date)`, `date.toISOString()` | the string itself |
110
+
111
+ Data you already stored from DatePicker was midnight UTC of the picked day. `stored.toISOString().slice(0, 10)` turns exactly that back into the day, since the value was built in UTC; do this only for values DatePicker produced.
112
+
113
+ Where the value was really a moment (the poll and deadline cases above), decide the time zone once, in your own code, as shown in the section before this one, rather than relying on what DatePicker happened to hand out.
@@ -65,7 +65,7 @@ import { ErrorBoundary } from '@ahrowe/ui';
65
65
  </ErrorBoundary>
66
66
  ```
67
67
 
68
- The default fallback is a centered card with a tinted icon badge, an `Accordion` for the collapsible stack trace (with a labeled copy `Button` that fires a `Toast` confirmation if `ToastProvider` is mounted), and a `Button` for "Try again".
68
+ The default fallback is a centered card with a tinted icon badge, an `Accordion` for the collapsible stack trace (with a `CopyButton` that also fires a `Toast` confirmation if `ToastProvider` is mounted), and a `Button` for "Try again".
69
69
 
70
70
  **Key props:**
71
71
 
@@ -74,7 +74,7 @@ The default fallback is a centered card with a tinted icon badge, an `Accordion`
74
74
  | `children` | `ReactNode` | The subtree to protect |
75
75
  | `title` | `ReactNode` | Heading in the default fallback (default `"Something went wrong"`) |
76
76
  | `description` | `ReactNode` | Overrides the message shown under the title. Defaults to `error.message` |
77
- | `labels` | `ErrorBoundaryLabels` | Overrides for every other built-in string (stack trace label, copy/reset button text, copy toasts) — pass a translated set for non-English apps. See below |
77
+ | `labels` | `ErrorBoundaryLabels` | Overrides for every other built-in string (stack trace label, copy button tooltip, reset button text, copy toasts) — pass a translated set for non-English apps. See below |
78
78
  | `fallback` | `ReactNode \| (props: ErrorBoundaryFallbackProps) => ReactNode` | Replaces the built-in fallback entirely. The render-function form receives `{ error, errorInfo, resetErrorBoundary }` |
79
79
  | `onError` | `(error: Error, errorInfo: ErrorInfo) => void` | Called once per catch — the place to report to Sentry/Datadog/etc. |
80
80
  | `onReset` | `() => void` | Called after the boundary is reset, whether via the built-in "Try again" button, a custom fallback calling `resetErrorBoundary`, or a `resetKeys` change. Use it to re-arm whatever state caused the crash |
@@ -86,8 +86,8 @@ The default fallback is a centered card with a tinted icon badge, an `Accordion`
86
86
  | Field | Default | Description |
87
87
  |-------|---------|--------------|
88
88
  | `stackTrace` | `"Stack trace"` | Accordion header for the stack trace section |
89
- | `copy` | `"Copy"` | Copy button label before copying |
90
- | `copied` | `"Copied"` | Copy button label right after a successful copy |
89
+ | `copy` | `"Copy"` | Copy button tooltip and accessible name before copying |
90
+ | `copied` | `"Copied"` | Copy button tooltip and accessible name right after a successful copy |
91
91
  | `reset` | `"Try again"` | Reset button label |
92
92
  | `copySuccessToast` | `"Stack trace copied to clipboard"` | Toast shown after a successful copy (only visible if `ToastProvider` is mounted) |
93
93
  | `copyErrorToast` | `"Could not copy stack trace"` | Toast shown if the copy fails |
package/docs/Fab.md CHANGED
@@ -22,7 +22,6 @@ import { faPlus, faPen, faCamera } from '@fortawesome/free-solid-svg-icons';
22
22
  { id: 'photo', icon: faCamera, label: 'Photo' },
23
23
  ]}
24
24
  onMenuEntryClicked={(id) => handleAction(id)}
25
- closeMenuOnEntryClicked
26
25
  />
27
26
 
28
27
  // Mini size
@@ -38,7 +37,7 @@ import { faPlus, faPen, faCamera } from '@fortawesome/free-solid-svg-icons';
38
37
  | `size` | `'default' \| 'mini'` | Button size |
39
38
  | `menuEntries` | `FabMenuEntry[]` | `{ id, icon, label? }` — enables speed dial |
40
39
  | `onMenuEntryClicked` | `(id: string) => void` | Sub-menu entry handler |
41
- | `closeMenuOnEntryClicked` | `boolean` | Auto-close menu after selection |
40
+ | `closeMenuOnEntryClicked` | `boolean` | Close the menu after an entry is picked (default `true`) |
42
41
  | `onClick` | `() => void` | Simple click handler (no speed dial) |
43
42
  | `children` | `ReactNode` | FAB icon content |
44
43
 
@@ -32,7 +32,7 @@ nameValidator.dirty // value has changed since creation
32
32
  > | Field | Default value |
33
33
  > |-------|---------------|
34
34
  > | string (text, email, …) | `''` |
35
- > | `Date` | `null` |
35
+ > | day (`DatePicker`, `'YYYY-MM-DD'`) | `null` |
36
36
  > | number | `undefined` (or `0`) |
37
37
  > | checkbox / boolean | `false` |
38
38
  > | single-select id | `null` |
@@ -69,7 +69,7 @@ function NameField() {
69
69
 
70
70
  ```tsx
71
71
  const ageValidator = new FormValidator<number | undefined>(undefined, [Validators.required()]);
72
- const dateValidator = new FormValidator<Date | null>(null);
72
+ const dayValidator = new FormValidator<string | null>(null); // DatePicker: '2026-09-15'
73
73
  const agreedValidator = new FormValidator<boolean>(false, [Validators.required()]);
74
74
  ```
75
75
 
@@ -108,7 +108,7 @@ Validators.multiEmail(errorMsg?) // comma/semicolon-separated list of
108
108
  Validators.mustBeNumber(errorMsg?) // parses as a number
109
109
  Validators.oneOf(allowedValues, errorMsg?) // value is in the allowed array
110
110
  Validators.typeOf(allowedTypes, errorMsg?) // typeof value matches (string or string[])
111
- Validators.date(errorMsg?) // valid date string (DD.MM.YYYY or ISO)
111
+ Validators.date(errorMsg?) // a real date: a 'YYYY-MM-DD' day (DatePicker), DD.MM.YYYY, a toISOString() timestamp, or a valid Date
112
112
  Validators.dateObject(errorMsg?) // an object with an `isValid` flag (e.g. a date wrapper)
113
113
  Validators.website(errorMsg?) // valid URL
114
114
  Validators.bic(errorMsg?) // valid BIC/SWIFT code
package/docs/Hooks.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # Hooks
2
2
 
3
- **When to use:** Standalone React hooks the library uses internally and publishes for the same jobs in your own code: keyboard shortcuts, media queries, the mobile keyboard's viewport, scroll-edge detection, swipe-to-dismiss, and the Escape / focus-trap / scroll-lock behaviour behind a custom overlay.
3
+ **When to use:** Standalone React hooks the library uses internally and publishes for the same jobs in your own code: keyboard shortcuts, media queries, the mobile keyboard's viewport, scroll-edge detection, swipe-to-dismiss, copying to the clipboard, and the Escape / focus-trap / scroll-lock behaviour behind a custom overlay.
4
4
 
5
5
  **Keywords:** hotkey, keybinding, keyboard shortcut, cmd+k, command palette, breakpoint, responsive, touch device, on-screen keyboard, focus trap, scroll lock, swipe
6
6
 
7
- **Import:** `import { useHotkeys, formatHotkey, useMediaQuery, useCoarsePointer, useVisualViewportSize, useScrollEdges, useSwipeDismiss, useOverlay, tabbablesIn } from '@ahrowe/ui'`
8
- **Types:** `import type { HotkeyMap, HotkeyHandler, UseHotkeysOptions, VisualViewportSize, ScrollEdges, ScrollEdgesOptions, UseSwipeDismissOptions, UseSwipeDismissResult, UseOverlayArgs } from '@ahrowe/ui'`
7
+ **Import:** `import { useHotkeys, formatHotkey, useMediaQuery, useCoarsePointer, useVisualViewportSize, useScrollEdges, useSwipeDismiss, useOverlay, useClipboard, tabbablesIn } from '@ahrowe/ui'`
8
+ **Types:** `import type { HotkeyMap, HotkeyHandler, UseHotkeysOptions, VisualViewportSize, ScrollEdges, ScrollEdgesOptions, UseSwipeDismissOptions, UseSwipeDismissResult, UseOverlayArgs, UseClipboardOptions, UseClipboardResult } from '@ahrowe/ui'`
9
9
 
10
10
  ---
11
11
 
@@ -211,6 +211,42 @@ function CustomOverlay({ isOpen, onClose, children }) {
211
211
 
212
212
  Reach for [Modal.md](Modal.md) or [Drawer.md](Drawer.md) first; this is for an overlay they do not cover.
213
213
 
214
+ ## useClipboard
215
+
216
+ Writes text to the clipboard and reports how it went. For the usual icon-only copy control, use
217
+ [CopyButton.md](CopyButton.md), which is this hook plus the feedback and the screen-reader
218
+ announcement. Reach for the hook when the trigger is something else, such as a labelled `Button`:
219
+
220
+ ```tsx
221
+ const { copy, copied } = useClipboard();
222
+
223
+ <Button icon={copied ? faCheck : faCopy} onClick={() => void copy(shareUrl)}>
224
+ {copied ? 'Copied' : 'Copy link'}
225
+ </Button>;
226
+ ```
227
+
228
+ `copy(text)` resolves `true` on success and `false` on failure, so it never needs a `try`:
229
+
230
+ ```tsx
231
+ const ok = await copy(invoice.number);
232
+ showToast(ok ? 'Copied' : 'Could not copy', { type: ok ? 'success' : 'error' });
233
+ ```
234
+
235
+ | Option | Type | Default | Description |
236
+ | ------ | ---- | ------- | ----------- |
237
+ | `timeout` | `number` | `2000` | How long `copied` stays `true` after a copy, in ms |
238
+
239
+ | Returns | Type | Description |
240
+ | ------- | ---- | ----------- |
241
+ | `copy` | `(text: string) => Promise<boolean>` | Writes `text`; a second copy restarts the timeout |
242
+ | `copied` | `boolean` | `true` for `timeout` ms after a successful copy |
243
+ | `error` | `Error \| null` | Why the last copy failed, until the next attempt |
244
+ | `reset` | `() => void` | Clears `copied` and `error` early |
245
+
246
+ It uses `navigator.clipboard.writeText` only. That API needs a secure context (`https://` or
247
+ `localhost`) and may be blocked inside an iframe; there it fails through `error` rather than falling
248
+ back to the deprecated `document.execCommand('copy')`.
249
+
214
250
  ## tabbablesIn
215
251
 
216
252
  The tabbable descendants of an element, in DOM order. An element taken out of the tab order with
@@ -5,7 +5,7 @@
5
5
  **Keywords:** autocomplete, typeahead
6
6
 
7
7
  **Import:** `import { InputDropdown } from '@ahrowe/ui'`
8
- **Types:** `import type { InputDropdownItem, InputDropdownProps } from '@ahrowe/ui'`
8
+ **Types:** `import type { InputDropdownItem, InputDropdownLabels, InputDropdownProps } from '@ahrowe/ui'`
9
9
 
10
10
  ```tsx
11
11
  import { InputDropdown } from '@ahrowe/ui';
@@ -63,6 +63,7 @@ interface InputDropdownItem {
63
63
  | `onChange` | `(value: unknown) => void` | Called on every text change |
64
64
  | `placeholder` | `string` | |
65
65
  | `readOnly` | `boolean` | Make the field read-only — typing is blocked, the list won't open, and keyboard navigation/selection is disabled. Default `false` |
66
+ | `labels` | `InputDropdownLabels` | Text the component renders of its own, see **Labels** below |
66
67
  | `formValidator` | `FormValidator` | Connects to validation — shows the error state/message once the field has been touched (blurred), and the required mark (`*`) when the validator has a `required` validator |
67
68
  | `error` | `string` | Manual error message (used when no `formValidator` is given) |
68
69
  | `isRequired` | `boolean` | Shows the required mark (`*`) next to the label. Also inferred automatically when `formValidator` has a `required` validator. Default `false` |
@@ -78,8 +79,12 @@ Inside a `<form>`, `Enter` on a highlighted suggestion picks it without submitti
78
79
  with nothing highlighted submits as any text field would. So cycling to your own text and pressing
79
80
  `Enter` twice is the way to submit exactly what you typed. `Home` and `End` are left to the text field, so they move the caret.
80
81
 
81
- **Slots:** `root` `dropdown` `item`
82
+ **Nothing to show:** the list only opens when it has something in it. With `allowCustomValue` a search that matches nothing closes it, since the typed text is kept anyway, and it reopens as soon as something matches again. Without `allowCustomValue` the typed text is thrown away on blur, so a `No matches` row says so while the user is still typing, and a screen reader announces it. Nothing typed yet opens nothing in either mode, including a preset value that `items` does not contain.
82
83
 
83
- Long lists are handled by capping how many matches are rendered (`maxItems`, default `100`) rather than by virtualizing: only about six rows fit in the scroll box, so anything past the cap is DOM the user never sees. Raise `maxItems` (or set it to `0`) if a list is genuinely meant to be scrolled end to end. For lists too large to pass as `items` at all, fetch on the server, keep the results in your own state and feed them in as `items`.
84
+ **Labels:** `empty` (shown when nothing matches and `allowCustomValue` is off, default `No matches`): the text this component renders of its own. Pass `labels` to override any of them, on the component or app-wide through `ConfigProvider`; they merge per key. Type: `InputDropdownLabels`.
84
85
 
85
- The list is rendered through a portal and tracks the trigger across scroll, so it always escapes clipping ancestors (cards, scroll containers, virtualized lists). The panel itself is a [Popover](Popover.md), so it flips, clamps and presents as a bottom sheet the same way every other anchored surface in the library does. It flips to open upward automatically when there's no room below, and shifts horizontally to stay within the viewport when the trigger sits near the left or right edge of the screen. It hides (without closing) if the trigger itself scrolls behind a clipping ancestor, reappearing once it's back in view. It also re-measures when its own content changes size while open, so a list that shrinks (e.g. filtered down to fewer entries) stays anchored to the trigger instead of hanging above it.
86
+ **Slots:** `root` `dropdown` `item` `empty`
87
+
88
+ Long lists are handled by capping how many matches are rendered (`maxItems`, default `100`) rather than by virtualizing: only about six rows fit in the scroll box, so anything past the cap is DOM the user never sees. Raise `maxItems` (or set it to `0`) if a list is genuinely meant to be scrolled end to end. For lists too large to pass as `items` at all, fetch on the server, keep the results in your own state and feed them in as `items`. Without `allowCustomValue`, say so while a fetch runs, or the field reports `No matches` for results that have not arrived yet: `labels={{ empty: isFetching ? 'Loading…' : 'No matches' }}`.
89
+
90
+ The list is rendered through a portal and tracks the trigger across scroll, so it always escapes clipping ancestors (cards, scroll containers, virtualized lists). The panel itself is a [Popover](Popover.md), so it flips and clamps the same way every other anchored surface in the library does. It is never a bottom sheet, whatever `presentation` a `ConfigProvider` sets: the field being typed into is the trigger, and a sheet would cover it with its backdrop and open the list away from it, below an on-screen keyboard. It flips to open upward automatically when there's no room below, and shifts horizontally to stay within the viewport when the trigger sits near the left or right edge of the screen. It hides (without closing) if the trigger itself scrolls behind a clipping ancestor, reappearing once it's back in view. It also re-measures when its own content changes size while open, so a list that shrinks (e.g. filtered down to fewer entries) stays anchored to the trigger instead of hanging above it.
package/docs/Menu.md CHANGED
@@ -29,11 +29,13 @@ const items: MenuEntry[] = [
29
29
  **Entries** are one of three shapes, and the list is read in the order you give it:
30
30
 
31
31
  ```ts
32
- { id: 'edit', label: 'Edit', icon, shortcut, disabled, danger, onClick } // an action
33
- { separator: true } // a line
34
- { heading: 'This document' } // a label over what follows
32
+ { id: 'edit', label: 'Edit', icon, shortcut, keywords, disabled, danger, onClick } // an action
33
+ { separator: true } // a line
34
+ { heading: 'This document' } // a label over what follows
35
35
  ```
36
36
 
37
+ `keywords` are extra search terms for [CommandPalette](CommandPalette.md), which takes the same entries. The menu ignores them.
38
+
37
39
  **Shortcuts are drawn, not bound.** A `shortcut` is a combo in [useHotkeys](Hooks.md) spelling, and the menu writes it on the trailing edge for the platform the reader is on: `mod+s` shows as `⌘S` on an Apple one and `Ctrl+S` everywhere else. So there is one spelling per shortcut and a hand-written `'⌘S'` can no longer be wrong on Windows.
38
40
 
39
41
  Listening for it is yours, because only your app knows what else is listening: if it already binds `⌘S` and the menu bound it too, both would fire and the action would run twice. `menuHotkeys` turns the same entries into a map for `useHotkeys`, so the one spelling covers the hint and the binding:
package/docs/Popover.md CHANGED
@@ -28,6 +28,18 @@ import { Popover, PopoverPlacement, PopoverAlign, PopoverTrigger } from '@ahrowe
28
28
  <Popover isOpen={open} onOpenChange={setOpen} content={<Form onDone={() => setOpen(false)} />}>
29
29
  <button>Edit</button>
30
30
  </Popover>
31
+
32
+ // One popover for many elements it does not wrap: a calendar's events, a table's cells
33
+ const [anchor, setAnchor] = useState<HTMLElement | null>(null);
34
+ {events.map((event) => (
35
+ <button key={event.id} onClick={(e) => setAnchor(e.currentTarget)}>{event.title}</button>
36
+ ))}
37
+ <Popover
38
+ anchor={anchor}
39
+ isOpen={anchor !== null}
40
+ onOpenChange={(open) => !open && setAnchor(null)}
41
+ content={<EventDetails />}
42
+ />
31
43
  ```
32
44
 
33
45
  **PopoverPlacement enum:** `PopoverPlacement.Top` | `Bottom` | `Left` | `Right` — the preferred side; auto-flips to the opposite side when there's no room.
@@ -44,6 +56,7 @@ import { Popover, PopoverPlacement, PopoverAlign, PopoverTrigger } from '@ahrowe
44
56
  |------|------|---------|-------------|
45
57
  | `content` | `ReactNode` | — | Panel body |
46
58
  | `children` | `ReactNode` | — | Trigger element |
59
+ | `anchor` | `HTMLElement \| null` | | Place the panel against this element instead of `children`. Open it with `isOpen`; `children` can be left out. A click on it is not an outside click, Escape returns the focus to it, and changing it while open moves the panel. `null` falls back to `children` |
47
60
  | `placement` | `PopoverPlacement` | `Bottom` | Preferred side |
48
61
  | `align` | `PopoverAlign` | `Center` | Cross-axis alignment |
49
62
  | `trigger` | `PopoverTrigger` | `Click` | What opens the popover |
@@ -80,3 +93,5 @@ Requires a `<div id="bodyEnd"></div>` at the app root — the panel is portaled
80
93
  **Scrolling:** where the browser supports CSS anchor positioning, including the trigger's transforms, the panel is anchored to the trigger in CSS, so it moves with a scrolling trigger in the same frame instead of catching up a frame later. Firefox resolves anchors without transforms, so it keeps the pixel positioning. While it is open the trigger carries an inline `anchor-name`, added to any inline one it already has; an `anchor-name` your stylesheet gives the trigger is overridden for that time.
81
94
 
82
95
  **Keyboard & focus (click trigger):** the panel is portaled to the end of the DOM, so Tab can't reach it naturally. When the panel has focusable content and the keyboard opened it (Enter or Space on the trigger, or ArrowDown/ArrowUp), focus moves to the first focusable element and the panel gets `role="dialog"`; a pointer click leaves the focus where it already is. `focusOnOpen` forces either. Tabbing past the last element closes the popover and moves focus to the element after the trigger; Shift+Tab past the first element, and Escape, close it and return focus to the trigger. This is non-modal: focus is not trapped, and hover/focus triggers don't steal focus. Escape closes only the innermost open layer, so a popover inside a `Modal` closes on the first press and leaves the modal open. Popovers with only static content don't manage focus or claim the dialog role.
96
+
97
+ **Anchored (`anchor`):** your code opens the panel, not a key event Popover sees, so the default `focusOnOpen` looks at the click that opened it: Enter or Space on the anchor or on something inside it (a click with `detail === 0`) moves the focus into the panel, a pointer click leaves it where it is, and an open with no click at all, such as suggestions appearing while the user types into an anchored field, never takes it. Escape, Tab and Shift+Tab out of the panel continue from the anchor as they would from a trigger: from the anchor itself when it takes focus, otherwise from the first focusable element inside it.
@@ -0,0 +1,49 @@
1
+ # WeatherIcon
2
+
3
+ **When to use:** An animated weather symbol for a forecast, a dashboard widget or a day selector, one per condition a forecast reports. For a static pictogram of anything else, use a FontAwesome icon.
4
+
5
+ **Keywords:** forecast, rain, snow, sun, cloud, storm, climate, meteo
6
+
7
+ **Import:** `import { WeatherIcon, WeatherType } from '@ahrowe/ui'`
8
+ **Types:** `import type { WeatherIconProps, WeatherIconLabels } from '@ahrowe/ui'`
9
+
10
+ ```tsx
11
+ import { WeatherIcon, WeatherType } from '@ahrowe/ui';
12
+
13
+ <WeatherIcon type={WeatherType.Rainy} />
14
+
15
+ // Any CSS size; a number is pixels
16
+ <WeatherIcon type={WeatherType.Snowy} size='3em' />
17
+
18
+ // A still frame, for small or repeated icons such as a row of days
19
+ <WeatherIcon type={WeatherType.PartlyCloudy} size={32} animated={false} />
20
+ ```
21
+
22
+ **Enums:** `WeatherType`: `Sunny` · `ClearNight` · `Cloudy` · `PartlyCloudy` · `PartlyCloudyNight` · `RainyLight` · `Rainy` · `RainyHeavy` · `Snowy` · `Hail` · `Foggy` · `Thunderstorm`
23
+
24
+ **Your API's own enum works as it is.** `type` takes the enum or its string value (`'rainy'`, `'partlyCloudy'`), so a backend enum with the same values passes without a mapping, even though TypeScript treats two enums as different types.
25
+
26
+ **Colour** is the text colour it sits in (`currentColor`), like any icon. Set `color` on it or on a parent:
27
+
28
+ ```tsx
29
+ <WeatherIcon type={WeatherType.Sunny} style={{ color: 'var(--warn-color)' }} />
30
+ ```
31
+
32
+ **Motion.** Clouds drift, rain and snow fall, hail bounces, lightning flashes twice every four seconds. Under `prefers-reduced-motion` it draws the same still frame as `animated={false}`: each drop, flake and stone frozen at a different point of its fall rather than all bunched under the cloud. `animated={false}` is also worth setting on a long row of small icons, where the motion is too small to see but costs the same. Set it app-wide through `ConfigProvider`.
33
+
34
+ **Accessibility.** The svg is `role="img"` and named after the weather ("Heavy rain"). Where the text next to it already says the same, pass `aria-hidden`.
35
+
36
+ **Key props:**
37
+
38
+ | Prop | Type | Description |
39
+ |------|------|-------------|
40
+ | `type` | `WeatherType \| string value` | The weather to draw (required) |
41
+ | `size` | `number \| string` | Width and height. A number is pixels (default `64`) |
42
+ | `animated` | `boolean` | `false` draws a still frame (default `true`). Always still under reduced motion |
43
+ | `labels` | `WeatherIconLabels` | The accessible name of each weather |
44
+
45
+ The native svg attributes (`className`, `style`, `aria-*`, `data-*`, handlers) reach the `<svg>`.
46
+
47
+ **Global defaults:** adopts `ConfigProvider`, e.g. `defaultProps={{ WeatherIcon: { animated: false } }}`. See [ConfigProvider.md](ConfigProvider.md).
48
+
49
+ **Labels:** one key per `WeatherType` value, `sunny` to `thunderstorm`: the accessible name of each weather. Pass `labels` to override any of them, on the component or app-wide through `ConfigProvider`; they merge per key. Type: `WeatherIconLabels`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahrowe/ui",
3
- "version": "0.37.0",
3
+ "version": "0.39.0",
4
4
  "description": "A React UI component library with theming, CSS Modules, and TypeScript support",
5
5
  "keywords": [
6
6
  "agent",
@@ -88,7 +88,7 @@
88
88
  "prepare": "husky"
89
89
  },
90
90
  "dependencies": {
91
- "@ahrowe/form-validation": "^0.2.0",
91
+ "@ahrowe/form-validation": "^0.3.0",
92
92
  "@dnd-kit/core": "^6.3.1",
93
93
  "@dnd-kit/sortable": "^10.0.0",
94
94
  "@dnd-kit/utilities": "^3.2.2",
@@ -128,7 +128,6 @@
128
128
  "publint": "^0.3.24",
129
129
  "react": "^19.3.0",
130
130
  "react-dom": "^19.3.0",
131
- "react-element-to-jsx-string": "^17.0.1",
132
131
  "react-markdown": "^10.1.0",
133
132
  "react-syntax-highlighter": "^16.1.1",
134
133
  "remark-gfm": "^4.0.1",