@ahrowe/ui 0.12.0 → 0.14.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 (116) hide show
  1. package/dist/esm/common/alert/alert.mjs +2 -0
  2. package/dist/esm/common/alert/alert.mjs.map +1 -0
  3. package/dist/esm/common/alert/alert.module.mjs +2 -0
  4. package/dist/esm/common/alert/alert.module.mjs.map +1 -0
  5. package/dist/esm/common/alert/alert.types.mjs +2 -0
  6. package/dist/esm/common/alert/alert.types.mjs.map +1 -0
  7. package/dist/esm/common/animatedIcon/animatedIcon.mjs +2 -0
  8. package/dist/esm/common/animatedIcon/animatedIcon.mjs.map +1 -0
  9. package/dist/esm/common/animatedIcon/animatedIcon.module.mjs +2 -0
  10. package/dist/esm/common/animatedIcon/animatedIcon.module.mjs.map +1 -0
  11. package/dist/esm/common/breadcrumb/breadcrumb.mjs +2 -0
  12. package/dist/esm/common/breadcrumb/breadcrumb.mjs.map +1 -0
  13. package/dist/esm/common/breadcrumb/breadcrumb.module.mjs +2 -0
  14. package/dist/esm/common/breadcrumb/breadcrumb.module.mjs.map +1 -0
  15. package/dist/esm/common/buttonGroup/buttonGroup.mjs +2 -0
  16. package/dist/esm/common/buttonGroup/buttonGroup.mjs.map +1 -0
  17. package/dist/esm/common/buttonGroup/buttonGroup.module.mjs +2 -0
  18. package/dist/esm/common/buttonGroup/buttonGroup.module.mjs.map +1 -0
  19. package/dist/esm/common/buttonGroup/buttonGroup.types.mjs +2 -0
  20. package/dist/esm/common/buttonGroup/buttonGroup.types.mjs.map +1 -0
  21. package/dist/esm/common/carousel/carousel.mjs +2 -0
  22. package/dist/esm/common/carousel/carousel.mjs.map +1 -0
  23. package/dist/esm/common/carousel/carousel.module.mjs +2 -0
  24. package/dist/esm/common/carousel/carousel.module.mjs.map +1 -0
  25. package/dist/esm/common/divider/divider.mjs +1 -1
  26. package/dist/esm/common/divider/divider.mjs.map +1 -1
  27. package/dist/esm/common/divider/divider.module.mjs +1 -1
  28. package/dist/esm/common/divider/divider.module.mjs.map +1 -1
  29. package/dist/esm/common/divider/divider.types.mjs +1 -1
  30. package/dist/esm/common/divider/divider.types.mjs.map +1 -1
  31. package/dist/esm/common/dropdown/dropdown.module.mjs +1 -1
  32. package/dist/esm/common/dropdown/dropdown.module.mjs.map +1 -1
  33. package/dist/esm/common/fab/fab.mjs +1 -1
  34. package/dist/esm/common/fab/fab.mjs.map +1 -1
  35. package/dist/esm/common/floatingMenu/floatingMenu.mjs +1 -1
  36. package/dist/esm/common/floatingMenu/floatingMenu.mjs.map +1 -1
  37. package/dist/esm/common/floatingMenu/floatingMenu.module.mjs.map +1 -1
  38. package/dist/esm/common/hooks/useFocusBoundary.mjs +2 -0
  39. package/dist/esm/common/hooks/useFocusBoundary.mjs.map +1 -0
  40. package/dist/esm/common/input/input.mjs +1 -1
  41. package/dist/esm/common/input/input.mjs.map +1 -1
  42. package/dist/esm/common/input/input.module.mjs +1 -1
  43. package/dist/esm/common/input/input.module.mjs.map +1 -1
  44. package/dist/esm/common/input/input.types.mjs +1 -1
  45. package/dist/esm/common/input/input.types.mjs.map +1 -1
  46. package/dist/esm/common/klipyPicker/klipyPicker.mjs +1 -1
  47. package/dist/esm/common/klipyPicker/klipyPicker.mjs.map +1 -1
  48. package/dist/esm/common/numberInput/numberInput.mjs +1 -1
  49. package/dist/esm/common/numberInput/numberInput.mjs.map +1 -1
  50. package/dist/esm/common/popover/popover.mjs +1 -1
  51. package/dist/esm/common/popover/popover.mjs.map +1 -1
  52. package/dist/esm/common/rating/rating.mjs +2 -0
  53. package/dist/esm/common/rating/rating.mjs.map +1 -0
  54. package/dist/esm/common/rating/rating.module.mjs +2 -0
  55. package/dist/esm/common/rating/rating.module.mjs.map +1 -0
  56. package/dist/esm/common/splitButton/splitButton.mjs +2 -0
  57. package/dist/esm/common/splitButton/splitButton.mjs.map +1 -0
  58. package/dist/esm/common/splitButton/splitButton.module.mjs +2 -0
  59. package/dist/esm/common/splitButton/splitButton.module.mjs.map +1 -0
  60. package/dist/esm/common/virtualList/useRowDrag.mjs +1 -1
  61. package/dist/esm/common/virtualList/useRowDrag.mjs.map +1 -1
  62. package/dist/esm/common/virtualList/useVirtualWindow.mjs +1 -1
  63. package/dist/esm/common/virtualList/useVirtualWindow.mjs.map +1 -1
  64. package/dist/esm/common/virtualList/virtualList.mjs +1 -1
  65. package/dist/esm/common/virtualList/virtualList.mjs.map +1 -1
  66. package/dist/esm/common/virtualList/virtualRow.mjs +1 -1
  67. package/dist/esm/common/virtualList/virtualRow.mjs.map +1 -1
  68. package/dist/esm/index.mjs +1 -1
  69. package/dist/index.cjs +4 -4
  70. package/dist/index.cjs.map +1 -1
  71. package/dist/style.css +1 -1
  72. package/dist/types/package/common/alert/alert.d.ts +4 -0
  73. package/dist/types/package/common/alert/alert.types.d.ts +23 -0
  74. package/dist/types/package/common/alert/index.d.ts +2 -0
  75. package/dist/types/package/common/animatedIcon/animatedIcon.d.ts +3 -0
  76. package/dist/types/package/common/animatedIcon/animatedIcon.types.d.ts +25 -0
  77. package/dist/types/package/common/animatedIcon/index.d.ts +2 -0
  78. package/dist/types/package/common/breadcrumb/breadcrumb.d.ts +4 -0
  79. package/dist/types/package/common/breadcrumb/breadcrumb.types.d.ts +26 -0
  80. package/dist/types/package/common/breadcrumb/index.d.ts +2 -0
  81. package/dist/types/package/common/buttonGroup/buttonGroup.d.ts +4 -0
  82. package/dist/types/package/common/buttonGroup/buttonGroup.types.d.ts +12 -0
  83. package/dist/types/package/common/buttonGroup/index.d.ts +2 -0
  84. package/dist/types/package/common/carousel/carousel.d.ts +3 -0
  85. package/dist/types/package/common/carousel/carousel.types.d.ts +20 -0
  86. package/dist/types/package/common/carousel/index.d.ts +2 -0
  87. package/dist/types/package/common/configProvider/configProvider.types.d.ts +10 -0
  88. package/dist/types/package/common/divider/divider.types.d.ts +5 -0
  89. package/dist/types/package/common/hooks/useFocusBoundary.d.ts +20 -0
  90. package/dist/types/package/common/input/input.types.d.ts +5 -0
  91. package/dist/types/package/common/rating/index.d.ts +2 -0
  92. package/dist/types/package/common/rating/rating.d.ts +4 -0
  93. package/dist/types/package/common/rating/rating.types.d.ts +19 -0
  94. package/dist/types/package/common/splitButton/index.d.ts +2 -0
  95. package/dist/types/package/common/splitButton/splitButton.d.ts +3 -0
  96. package/dist/types/package/common/splitButton/splitButton.types.d.ts +28 -0
  97. package/dist/types/package/common/themeProvider/theme.types.d.ts +1 -0
  98. package/dist/types/package/common/virtualList/useRowDrag.d.ts +4 -0
  99. package/dist/types/package/common/virtualList/useVirtualWindow.d.ts +3 -1
  100. package/dist/types/package/common/virtualList/virtualList.types.d.ts +5 -1
  101. package/dist/types/package/common/virtualList/virtualRow.d.ts +8 -1
  102. package/dist/types/package/index.d.ts +12 -0
  103. package/docs/Alert.md +64 -0
  104. package/docs/Breadcrumb.md +106 -0
  105. package/docs/ButtonGroup.md +90 -0
  106. package/docs/CLAUDE.md +6 -0
  107. package/docs/Carousel.md +67 -0
  108. package/docs/Divider.md +15 -3
  109. package/docs/FloatingMenu.md +7 -3
  110. package/docs/Input.md +30 -6
  111. package/docs/NumberInput.md +2 -0
  112. package/docs/Rating.md +72 -0
  113. package/docs/SearchInput.md +6 -0
  114. package/docs/SplitButton.md +79 -0
  115. package/docs/VirtualList.md +86 -3
  116. package/package.json +1 -1
package/docs/Divider.md CHANGED
@@ -1,13 +1,15 @@
1
1
  # Divider
2
2
 
3
- **When to use:** A horizontal rule that fills the width of its parent separating sections of content. Optionally label it with text (e.g. "OR", a date, a section name); the line splits around the text, leaving a gap for it.
3
+ **When to use:** A rule that separates sections of contenthorizontal by default, filling the width of its parent. Optionally label it with text (e.g. "OR", a date, a section name); the line splits around the text, leaving a gap for it. Set `orientation` to `Vertical` for a vertical line filling the height of its parent instead, e.g. as a separator between two buttons in a `ButtonGroup`.
4
4
 
5
- **Import:** `import { Divider, DividerTextPosition } from '@ahrowe/ui'`
5
+ **Import:** `import { Divider, DividerTextPosition, DividerOrientation } from '@ahrowe/ui'`
6
6
 
7
7
  **Text position:** `DividerTextPosition.Left` | `DividerTextPosition.Center` (default) | `DividerTextPosition.Right`
8
8
 
9
+ **Orientation:** `DividerOrientation.Horizontal` (default) | `DividerOrientation.Vertical`
10
+
9
11
  ```tsx
10
- import { Divider, DividerTextPosition } from '@ahrowe/ui';
12
+ import { Divider, DividerTextPosition, DividerOrientation } from '@ahrowe/ui';
11
13
 
12
14
  // Plain line
13
15
  <Divider />
@@ -20,6 +22,13 @@ import { Divider, DividerTextPosition } from '@ahrowe/ui';
20
22
 
21
23
  // Text near the right edge
22
24
  <Divider text="March 2026" textPosition={DividerTextPosition.Right} />
25
+
26
+ // Vertical — fills the height of its (flex/grid) parent, e.g. between two buttons
27
+ <ButtonGroup>
28
+ <Button>Save</Button>
29
+ <Divider orientation={DividerOrientation.Vertical} />
30
+ <Button>Save & new</Button>
31
+ </ButtonGroup>
23
32
  ```
24
33
 
25
34
  **Key props:**
@@ -28,6 +37,9 @@ import { Divider, DividerTextPosition } from '@ahrowe/ui';
28
37
  |------|------|-------------|
29
38
  | `text` | `ReactNode` | Text (or any node) rendered on the line. Omit for a plain full-width line |
30
39
  | `textPosition` | `DividerTextPosition` | Where `text` sits along the line (default `Center`) |
40
+ | `orientation` | `DividerOrientation` | `Horizontal` (default, fills width) or `Vertical` (fills height) |
31
41
  | `className` / `style` | | Root element |
32
42
 
43
+ **Vertical orientation:** stretches to fill the height of a flex or grid parent (`align-self: stretch`), the same way `Button`s in a `ButtonGroup` do. Outside a flex/grid context, give it an explicit height via `style`, since a vertical line has no natural height of its own the way a horizontal one has a natural width (`100%` of its block-level parent).
44
+
33
45
  **Slots:** `root` `line` (the line segment(s) either side of `text`) `content` (the `text` wrapper)
@@ -29,7 +29,9 @@ import { FloatingMenu, Align } from '@ahrowe/ui';
29
29
  <ActionIcon icon={faEllipsis} />
30
30
  </FloatingMenu>
31
31
 
32
- // Keep open when clicking inside content
32
+ // Keep open through several interactions with content — a column-visibility toggle where
33
+ // checking one box shouldn't close the whole menu — or when re-clicking the trigger itself
34
+ // (e.g. DatePicker re-clicking its own input) shouldn't close it either
33
35
  <FloatingMenu dontCloseOnChildClick content={<Form />}>
34
36
  <button>Open form</button>
35
37
  </FloatingMenu>
@@ -46,8 +48,10 @@ import { FloatingMenu, Align } from '@ahrowe/ui';
46
48
  | `align` | `Align` | Popover alignment relative to trigger |
47
49
  | `isOpen` | `boolean` | Controlled open state |
48
50
  | `onOpenChange` | `(isOpen: boolean) => void` | Open state change callback |
49
- | `dontCloseOnChildClick` | `boolean` | Prevent close when clicking inside content |
51
+ | `dontCloseOnChildClick` | `boolean` | Keep the menu open when its own content is clicked, and when the trigger itself is re-clicked while already open (default `false`) |
50
52
 
51
53
  **Slots:** `root` `trigger` `menu` `menuContainer`
52
54
 
53
- **Nested overlays in `content`:** a click anywhere inside `content` never closes the menu on its own — including inside a nested overlay that renders through its own portal (e.g. a `Dropdown` or another `FloatingMenu` used inside `content`), even though that overlay's DOM lives outside `content`'s own subtree. Closing on a selection is opt-in: have the handler that reacts to the selection call `onOpenChange(false)` (or your own state setter) explicitly, the way a calendar's day-selection handler would, while a nested month/year `Dropdown` inside the same menu is left alone.
55
+ **Closing behaviour:** by default, clicking anywhere in `content` closes the menu — including inside a nested overlay that renders through its own portal (e.g. a `Dropdown` or another `FloatingMenu` used inside `content`), even though that overlay's DOM lives outside `content`'s own subtree. Re-clicking the trigger while open is a clean toggle: it closes the menu (unless `dontCloseOnChildClick` is set, in which case it's a no-op — the trigger owns its own open/close entirely, so it never fights with an outside-click check). Set `dontCloseOnChildClick` when `content` needs several interactions before the user is done (a multi-checkbox toggle, a color picker's slider, a calendar) the consumer is then responsible for closing explicitly, e.g. calling `onOpenChange(false)` from the handler that reacts to a final selection.
56
+
57
+ **Keyboard:** the menu is portaled to the end of the DOM, so Tab can't reach it in visual order on its own. When `content` has real focusable elements, Escape closes the menu and returns focus to the trigger; Tab past the last focusable element (or Shift+Tab past the first) closes the menu and continues focus as if it sat right after the trigger. This doesn't include auto-focusing the first element on open — content ranges from menus to live controls (e.g. `ColorPicker`'s hue slider), where grabbing focus on open would let a stray arrow-key press change a value the user never touched. A consumer that wants that (like `SplitButton` focusing its first enabled action) implements it itself.
package/docs/Input.md CHANGED
@@ -17,14 +17,13 @@ const emailValidator = new FormValidator('', [Validators.required(), Validators.
17
17
  // Password
18
18
  <Input label="Password" type={InputType.Password} value={pw} onChange={setPw} />
19
19
 
20
- // Search with clear button
20
+ // Search clear button appears automatically once there's a value, and clears itself;
21
+ // onClearClicked is optional, only needed to react to the clear beyond the value itself
21
22
  <Input
22
23
  type={InputType.Search}
23
24
  label="Search"
24
25
  value={query}
25
26
  onChange={setQuery}
26
- showCancel
27
- onClearClicked={() => setQuery('')}
28
27
  />
29
28
 
30
29
  // Multiline (textarea)
@@ -38,10 +37,34 @@ const emailValidator = new FormValidator('', [Validators.required(), Validators.
38
37
 
39
38
  // Row layout
40
39
  <Input label="Name" isRow value={name} onChange={setName} />
40
+
41
+ // Custom icon on the left instead of the default right — the field's padding, the material
42
+ // label (resting and floated), and the native placeholder all shift out of its way automatically
43
+ <Input
44
+ label="Search"
45
+ icon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
46
+ iconPosition={InputIconPosition.Left}
47
+ value={query}
48
+ onChange={setQuery}
49
+ />
50
+
51
+ // A decorative type icon (Email/Phone/Edit) on the left, no custom icon needed
52
+ <Input label="Email" type={InputType.Email} iconPosition={InputIconPosition.Left} />
53
+
54
+ // A custom icon replaces Email's built-in envelope outright — there's nothing functional to lose
55
+ <Input label="Email" type={InputType.Email} icon={<FontAwesomeIcon icon={faAt} />} />
41
56
  ```
42
57
 
43
58
  **InputType enum:** `Edit` | `Phone` | `Email` | `Iban` | `Date` | `HasNoIcon` | `Search` | `Password`
44
59
 
60
+ **InputIconPosition enum:** `Left` | `Right` — which side the input's icon renders on, whichever icon is currently showing (built-in type icon or custom `icon`). Defaults to `Left` for `Email`/`Phone`/`Search` once `useMatLabelStyle` is off (their historical placement), `Right` otherwise; pass it explicitly to move any type's icon to either side regardless of `useMatLabelStyle`.
61
+
62
+ **Custom icon vs. built-in type icon:** passing `icon` alongside a decorative type (`Edit`, `Phone`, `Email`) replaces that type's built-in icon outright, since there's nothing functional attached to it to lose. `Search` and `Password` ignore `icon` — their icon is functional (clear button, show/hide toggle), not decorative, so it always renders regardless of whether `icon` is set.
63
+
64
+ **Search clear icon:** `InputType.Search` shows a magnifying glass that animates into a clear (×) button once the field has a value, rather than the browser's own native search-cancel decoration, which is suppressed so it doesn't clash with the theme.
65
+
66
+ **Mobile keyboard layout:** `Email`, `Phone`, and `Search` each set the matching `inputMode` (`email` / `tel` / `search`), so mobile browsers show the right on-screen keyboard — an `@`-and-`.com` layout for email, a numeric dial pad for phone, a "Go"/search action key for search. `Email`'s native `type` is deliberately left as `text` rather than `email`, to avoid the browser's own "please match the requested format" validation UI fighting the `formValidator`-driven error tooltip this component already shows — `inputMode="email"` alone is enough to get the correct keyboard. Pass `inputMode` directly to override the computed default for any other case.
67
+
45
68
  **Key props:**
46
69
 
47
70
  | Prop | Type | Description |
@@ -58,12 +81,13 @@ const emailValidator = new FormValidator('', [Validators.required(), Validators.
58
81
  | `rows` | `number` | Textarea row count |
59
82
  | `autoResize` | `boolean` | Textarea grows with content |
60
83
  | `suffix` | `string \| ReactNode` | Right-side suffix |
61
- | `icon` | `ReactElement` | Custom icon element, positioned absolute on the right (the field is padded to match); clickable via `onIconClick` |
84
+ | `icon` | `ReactElement` | Custom icon, positioned absolute on the side set by `iconPosition`; clickable via `onIconClick`. Replaces the built-in icon for `Edit`/`Phone`/`Email`; ignored for `Search`/`Password` (see below) |
85
+ | `iconPosition` | `InputIconPosition` | Side the input's icon renders on — built-in or custom, whichever is showing (see enum above for the default). On the left, the material label (resting and floated) and the fieldset notch shift right to clear it too |
62
86
  | `onIconClick` | `() => void` | Click handler for the custom `icon` |
63
87
  | `upperRightLabel` | `string \| ReactNode` | Small label above right |
64
- | `showCancel` | `boolean` | Show clear icon when focused |
88
+ | `showCancel` | `boolean` | Show clear icon when focused (ignored for `InputType.Search`, which always shows it once there's a value — see below) |
65
89
  | `alwaysShowCancel` | `boolean` | Always show clear icon |
66
- | `onClearClicked` | `() => void` | Clear button handler |
90
+ | `onClearClicked` | `() => void` | Called after the clear button clears the value. Optional — clicking clear always empties the field itself, whether or not this is set |
67
91
  | `isRow` | `boolean` | Horizontal layout |
68
92
  | `labelInBorder` | `boolean` | Label inside fieldset border |
69
93
  | `alwaysFloatLabel` | `boolean` | Keep the material label floated in the border notch even when empty and unfocused, so an empty field reads as blank instead of showing the label as if it were a value. Only applies with `useMatLabelStyle` (default) |
@@ -55,3 +55,5 @@ const priceValidator = new FormValidator<number | undefined>(undefined, [Validat
55
55
  | `readOnly` | `boolean` | Make the field read-only — typing is blocked, and the arrow steppers (`showArrows`) and ↑/↓ keys are disabled. Default: `false` |
56
56
  | `placeholder` | `string` | |
57
57
  | `error` | `string` | Manual error message |
58
+
59
+ **Mobile keyboard layout:** shows the numeric on-screen keyboard automatically (`inputMode="numeric"` when `decimalScale={0}`, `"decimal"` otherwise, so a decimal separator key is available whenever fractional values are allowed). Pass `inputMode` directly to override.
package/docs/Rating.md ADDED
@@ -0,0 +1,72 @@
1
+ # Rating
2
+
3
+ **When to use:** A star rating input or display, for review scores, feedback prompts, and quality indicators. Use `readOnly` to just show an existing rating (e.g. a product's average score) rather than collect one.
4
+
5
+ **Import:** `import { Rating } from '@ahrowe/ui'`
6
+ **Types:** `import type { RatingProps } from '@ahrowe/ui'`
7
+
8
+ ```tsx
9
+ import { useState } from 'react';
10
+ import { Rating } from '@ahrowe/ui';
11
+
12
+ // Controlled
13
+ const [value, setValue] = useState(3);
14
+ <Rating value={value} onChange={setValue} />
15
+
16
+ // Uncontrolled
17
+ <Rating defaultValue={4} onChange={(value) => console.log(value)} />
18
+
19
+ // Half-star increments
20
+ <Rating defaultValue={3.5} allowHalf />
21
+
22
+ // A different number of stars
23
+ <Rating defaultValue={6} max={10} />
24
+
25
+ // Read-only, for displaying an existing rating (e.g. a product card)
26
+ <Rating value={4} readOnly />
27
+
28
+ // Disabled
29
+ <Rating value={2} disabled />
30
+
31
+ // Larger, via font-size (sizing is em-based, like Switch and Avatar)
32
+ <Rating defaultValue={4} style={{ fontSize: 32 }} />
33
+
34
+ // Custom icon
35
+ import { faHeart } from '@fortawesome/free-solid-svg-icons';
36
+ <Rating defaultValue={3} icon={faHeart} />
37
+
38
+ // Disable click-to-clear (clicking the currently-selected star normally resets to 0)
39
+ <Rating value={value} onChange={setValue} allowClear={false} />
40
+ ```
41
+
42
+ **Interaction:** click, tap, or drag/swipe across the stars to set the rating, on any pointer type (mouse, touch, pen) via the Pointer Events API. Dragging updates the preview live and commits on release, which is the natural gesture on mobile rather than requiring a precise tap on one star. A plain click/tap on the currently-selected star clears it to `0` (set `allowClear={false}` to keep it fixed instead); a drag that happens to end back near its starting value doesn't clear, since that's a different gesture from a deliberate re-tap. With keyboard focus: `←`/`↓` and `→`/`↑` move by one star (or half a star with `allowHalf`), `Home`/`End` jump to `0`/`max`.
43
+
44
+ **Sizing:** there's no dedicated size prop, `font-size` scales the whole control (like `Switch` and `Avatar`). The root sizes to its content (`width: fit-content`) rather than stretching to fill a flex/grid ancestor, since the click/drag position is measured against the root's own width.
45
+
46
+ **Key props:**
47
+
48
+ | Prop | Type | Description |
49
+ |------|------|-------------|
50
+ | `value` | `number` | Controlled value (omit for uncontrolled) |
51
+ | `defaultValue` | `number` | Initial value when uncontrolled (default `0`) |
52
+ | `onChange` | `(value: number) => void` | Fires when the rating changes, from a click or keyboard input |
53
+ | `max` | `number` | Number of stars (default `5`) |
54
+ | `allowHalf` | `boolean` | Allow half-star increments (default `false`) |
55
+ | `allowClear` | `boolean` | Clicking the currently-selected star resets the value to `0` (default `true`) |
56
+ | `readOnly` | `boolean` | Display only, not focusable or interactive |
57
+ | `disabled` | `boolean` | Dims the control and disables interaction |
58
+ | `icon` | `IconDefinition` | Overrides the default star icon |
59
+ | `aria-label` | `string` | Accessible label (default `'Rating'`) |
60
+
61
+ **Accessibility:** renders `role="slider"` with `aria-valuemin`/`aria-valuemax`/`aria-valuenow`/`aria-valuetext`, matching `Slider`'s pattern.
62
+
63
+ **Theming:** override these CSS variables theme-wide via `ThemeProvider` or per instance via `style`; each falls back to a built-in default:
64
+
65
+ | Variable | Falls back to |
66
+ |----------|---------------|
67
+ | `--rating-filled-color` | `var(--primary-color)` |
68
+ | `--rating-empty-color` | `var(--background-accent-light)` |
69
+
70
+ **Global defaults:** adopts `ConfigProvider`, e.g. `defaultProps={{ Rating: { allowHalf: true } }}`. See [ConfigProvider.md](ConfigProvider.md).
71
+
72
+ **Slots:** `root` `item` `iconEmpty` `iconFilled`
@@ -33,6 +33,12 @@ import { SearchInput } from '@ahrowe/ui';
33
33
  />
34
34
  ```
35
35
 
36
+ **Theming:** override theme-wide via `ThemeProvider`'s `variables`; no built-in fallback, so it's unset in the default theme until set:
37
+
38
+ | Variable | Falls back to |
39
+ |----------|---------------|
40
+ | `--search-input-color` | *(none — unset by default)* |
41
+
36
42
  **Key props:**
37
43
 
38
44
  | Prop | Type | Description |
@@ -0,0 +1,79 @@
1
+ # SplitButton
2
+
3
+ **When to use:** A primary action button with a small attached dropdown for closely related secondary actions, e.g. "Save ▾" opening "Save as draft" / "Save and close". Built from `Button`, `ButtonGroup`, and `FloatingMenu` internally, rather than a mechanism of its own.
4
+
5
+ **Import:** `import { SplitButton } from '@ahrowe/ui'`
6
+ **Types:** `import type { SplitButtonAction, SplitButtonProps } from '@ahrowe/ui'`
7
+
8
+ **Requires:** `<div id="bodyEnd"></div>` in your HTML (the actions menu renders via `FloatingMenu`, which portals).
9
+
10
+ ```tsx
11
+ import { SplitButton } from '@ahrowe/ui';
12
+ import type { SplitButtonAction } from '@ahrowe/ui';
13
+
14
+ const actions: SplitButtonAction[] = [
15
+ { id: 'draft', label: 'Save as draft', onClick: () => saveDraft() },
16
+ { id: 'close', label: 'Save and close', onClick: () => saveAndClose() },
17
+ ];
18
+
19
+ <SplitButton actions={actions} onClick={() => save()}>
20
+ Save
21
+ </SplitButton>
22
+
23
+ // Destructive style, matching Button's own style variants
24
+ import { ButtonStyleType } from '@ahrowe/ui';
25
+
26
+ <SplitButton
27
+ actions={[{ id: 'archive', label: 'Archive instead', onClick: () => archive() }]}
28
+ styleType={ButtonStyleType.Delete}
29
+ onClick={() => deleteItem()}
30
+ >
31
+ Delete
32
+ </SplitButton>
33
+
34
+ // A disabled action within the menu
35
+ <SplitButton
36
+ actions={[
37
+ { id: 'draft', label: 'Save as draft', onClick: () => saveDraft() },
38
+ { id: 'archive', label: 'Archive instead', onClick: () => archive(), disabled: true },
39
+ ]}
40
+ onClick={() => save()}
41
+ >
42
+ Save
43
+ </SplitButton>
44
+
45
+ // Loading / disabled — both the primary and trigger button reflect the state
46
+ <SplitButton actions={actions} onClick={() => save()} isLoading>
47
+ Save
48
+ </SplitButton>
49
+ <SplitButton actions={actions} onClick={() => save()} disabled>
50
+ Save
51
+ </SplitButton>
52
+ ```
53
+
54
+ **SplitButtonAction:**
55
+
56
+ | Field | Type | Description |
57
+ |-------|------|-------------|
58
+ | `id` | `string` | Stable key for the action |
59
+ | `label` | `ReactNode` | Text shown in the dropdown |
60
+ | `onClick` | `() => void` | Called when the action is picked; the dropdown closes automatically afterward |
61
+ | `disabled` | `boolean` | Dims the action and prevents it from firing |
62
+
63
+ **Key props:**
64
+
65
+ | Prop | Type | Description |
66
+ |------|------|-------------|
67
+ | `children` | `ReactNode` | The primary button's label |
68
+ | `onClick` | `(event) => void \| Promise<void>` | The primary button's click handler |
69
+ | `actions` | `SplitButtonAction[]` | Secondary actions shown in the dropdown |
70
+ | `styleType` | `ButtonStyleType` | Visual variant, shared by both the primary and trigger button (default `Primary`) |
71
+ | `size` | `ButtonSize` | Size variant, shared by both buttons |
72
+ | `disabled` | `boolean` | Disables both the primary and trigger button |
73
+ | `isLoading` | `boolean` | Shows a spinner on the primary button (same as `Button`'s own `isLoading`) |
74
+
75
+ **`isLoading` vs. `disabled`:** `isLoading` only affects the primary button — the trigger and its menu stay usable while the primary action is in flight, e.g. to cancel via a menu action. To lock the whole control during the async action instead, pass both: `isLoading={isSaving} disabled={isSaving}`. Setting `disabled` also closes the menu if it happened to be open already, and blocks any in-flight click on a menu action.
76
+
77
+ **Global defaults:** adopts `ConfigProvider`, e.g. `defaultProps={{ SplitButton: { styleType: ButtonStyleType.Default } }}`. See [ConfigProvider.md](ConfigProvider.md).
78
+
79
+ **Slots:** `root` `primaryButton` `triggerButton` `menu` `menuItem`
@@ -58,7 +58,7 @@ const columns: VirtualListColumn<User>[] = [
58
58
  <VirtualList
59
59
  items={items}
60
60
  columns={columns}
61
- onLoadMore={async () => { await fetchNextPage(); }}
61
+ onLoadMore={hasMore ? async () => { await fetchNextPage(); } : undefined}
62
62
  isLoading={isInitialLoading}
63
63
  height="100%"
64
64
  />
@@ -123,6 +123,7 @@ const columns: VirtualListColumn<User>[] = [
123
123
  | `rowGap` | `number \| string` | Gap between rows — number = px; string = any CSS length (`'1em'`, `'var(--spacing-s)'`). Default `0` |
124
124
  | `rowPadding` | `number \| string` | Padding on every row — number = px; string = any CSS padding value (`'8px 12px'`, `'var(--spacing-s)'`) |
125
125
  | `showDivider` | `boolean` | Row divider lines (default `true`) |
126
+ | `rowHover` | `boolean` | Highlight rows on hover (default `true`). Set `false` when the row content brings its own hover affordance (e.g. a `Card` per row) so the two don't fight |
126
127
  | `getItemKey` | `(item, index) => string \| number` | Stable key per item |
127
128
  | `selectedKey` | `string \| number \| null` | Controlled single-select key |
128
129
  | `onRowClick` | `(item, index) => void` | Row click handler |
@@ -132,10 +133,13 @@ const columns: VirtualListColumn<User>[] = [
132
133
  | `showColumnToggle` | `boolean` | Show gear button to show/hide columns (default `true` when columns present) |
133
134
  | `visibleColumnKeys` | `string[]` | Controlled visible column keys |
134
135
  | `onVisibleColumnsChange` | `(keys) => void` | Column visibility change callback |
135
- | `onLoadMore` | `() => Promise<void>` | Triggered near the bottom append items in the handler |
136
+ | `persistColumnsKey` | `string` | Save column visibility to `localStorage` under this key and restore it on the next mount of a list using the same key. Uncontrolled mode only (ignored alongside `visibleColumnKeys`); columns added since the settings were saved fall back to their `defaultHidden` |
137
+ | `onLoadMore` | `() => Promise<void>` | Triggered near the bottom, and repeatedly while the rows don't fill the viewport — append items in the handler |
136
138
  | `loadMoreThreshold` | `number` | Distance from bottom that triggers `onLoadMore` (default `100`) |
137
139
  | `isLoading` | `boolean` | Replaces list body with a full-height spinner |
138
140
 
141
+ Drag-and-drop reordering adds `reorderable`, `treeReorder`, `dragHandle`, and their callbacks — see **Row reordering** below.
142
+
139
143
  **Imperative handle (`ref`):**
140
144
 
141
145
  Pass a `ref` typed as `VirtualListHandle` to control the list imperatively:
@@ -160,6 +164,85 @@ listRef.current?.scrollToKey(user.id, { align: 'center' });
160
164
  | `getScrollElement` | `() => HTMLDivElement \| null` | The underlying scroll container, for advanced use |
161
165
  | `recalculateColumns` | `() => void` | Re-measure `fit` columns on the next layout. Only needed after an out-of-band content change that doesn't flow through a new `items` array or `columns` set — those re-measure automatically, as does content first appearing in a cell |
162
166
 
163
- **Slots:** `root` `header` `headerCell` `headerToggle` `togglePopover` `toggleItem` `body` `row` `cell` `selectCell` `loadingIndicator`
167
+ **Row reordering:** two modes, both drag-and-drop, both mouse and touch. Set exactly one:
168
+
169
+ - **`reorderable`** — flat reorder. Rows move **live** as you drag, so `onReorder` fires repeatedly and you apply each move immediately.
170
+ - **`treeReorder`** — drop `before`, `after`, or `inside` another row. Rows do **not** move during the drag; a drop indicator is shown and `onTreeDrop` fires **once** on release.
171
+
172
+ If both are set, `treeReorder` wins. In either mode, pass `getItemKey` so rows keep their measured heights across the reorder.
173
+
174
+ ```tsx
175
+ // Flat: onReorder fires live, on every position change
176
+ const [rows, setRows] = useState(initialRows);
177
+
178
+ <VirtualList
179
+ items={rows}
180
+ renderRow={(row) => <div>{row.label}</div>}
181
+ getItemKey={(row) => row.id}
182
+ reorderable
183
+ onReorder={(from, to) => setRows((prev) => move(prev, from, to))}
184
+ height={400}
185
+ />
186
+ ```
187
+
188
+ The list is always a flat array, including in tree mode. Keep the real tree in your own state, flatten the expanded nodes into `items` tagging each with its depth, and re-flatten after applying a drop:
189
+
190
+ ```tsx
191
+ // Tree: onTreeDrop fires once, on release
192
+ <VirtualList
193
+ items={flattenedRows} // each row carries its own depth
194
+ renderRow={(row) => (
195
+ <div style={{ paddingLeft: row.depth * 24 }}>
196
+ <span data-drag-handle>⠿</span>
197
+ {row.label}
198
+ </div>
199
+ )}
200
+ getItemKey={(row) => row.id}
201
+ getItemDepth={(row) => row.depth}
202
+ isGroup={(row) => row.hasChildren} // only groups offer an `inside` drop
203
+ treeReorder
204
+ dragHandle
205
+ treeIndentPx={24} // match the row content's per-level padding
206
+ onTreeDrop={(dragKey, target) => applyDrop(dragKey, target)}
207
+ height={400}
208
+ />
209
+ ```
210
+
211
+ | Prop | Type | Description |
212
+ |------|------|-------------|
213
+ | `reorderable` | `boolean` | Flat drag-to-reorder. The whole row is draggable unless `dragHandle` is set |
214
+ | `onReorder` | `(fromIndex, toIndex) => void` | Fires **live** during a flat drag, once per position change. Apply it straight away |
215
+ | `treeReorder` | `boolean` | Tree drag-and-drop: `before` / `after` / `inside` drops instead of a flat move. Wins over `reorderable` |
216
+ | `onTreeDrop` | `(dragKey, target: TreeDropTarget) => void` | Fires **once** on release. Never called when the target is the dragged node itself or one of its descendants |
217
+ | `getItemDepth` | `(item, index) => number` | Each row's depth in the tree, `0` = root (default `0` for every row). Aligns the drop indicator and blocks dropping a node into its own subtree |
218
+ | `isGroup` | `(item, index) => boolean` | Whether a row can take children, i.e. whether hovering its middle third offers an `inside` drop (default `false`, leaf) |
219
+ | `treeIndentPx` | `number` | Px per depth level used to indent the `before`/`after` drop line so it lines up with where the dropped content starts (default `24`) |
220
+ | `dragHandle` | `boolean` | Only elements marked `data-drag-handle` start a drag, instead of the whole row. Applies to both modes, mouse and touch |
221
+ | `longPressDelay` | `number` | Touch only: press-and-hold duration in ms before drag mode arms, so a normal swipe still scrolls (default `400`) |
222
+
223
+ **`TreeDropTarget`** (`import type { TreeDropTarget, TreeDropPosition } from '@ahrowe/ui'`):
224
+
225
+ ```ts
226
+ interface TreeDropTarget {
227
+ key: string | number; // getItemKey of the row dropped on
228
+ position: 'before' | 'inside' | 'after'; // TreeDropPosition
229
+ }
230
+ ```
231
+
232
+ `before`/`after` place the dragged node as a sibling at the target's depth; `inside` makes it a child (only offered when `isGroup` is true for that row).
233
+
234
+ **Use `dragHandle` for rows containing inputs or other interactive controls.** Without it the whole row is draggable, which swallows clicking, typing, and text selection inside those controls. Render a handle in the row yourself and mark it: `<span data-drag-handle>⠿</span>`. With `dragHandle` the grab cursor is yours to style on the handle; without it, the list puts it on the whole row.
235
+
236
+ **Touch:** a press-and-hold of `longPressDelay` lifts a floating copy of the row that follows your finger, with auto-scroll near the list's top and bottom edges. A swipe before the hold completes scrolls the list as normal instead of starting a drag. With `dragHandle`, only a press that starts on the handle arms it, so the rest of the row stays tappable.
237
+
238
+ **Lazy loading:** `onLoadMore` fires when the user scrolls within `loadMoreThreshold` px of the bottom, **and** repeatedly whenever the rows don't fill the list's viewport. That second trigger matters because a list shorter than its own height can't be scrolled at all, so a first batch smaller than the viewport would otherwise be the last batch ever loaded. The list keeps asking for pages until the content overflows; each row count is asked at most once, so the loop ends as soon as a call appends nothing. Gate the prop on your own "has more" flag to skip even that last probe:
239
+
240
+ ```tsx
241
+ <VirtualList items={items} onLoadMore={hasMore ? loadNextPage : undefined} height={600} />
242
+ ```
243
+
244
+ This needs the list to have a **bounded height** (the default `height="100%"` inside a sized parent, or an explicit `height`). With a height that grows with its content, there is no empty space to detect and only the scroll trigger applies. Nothing fires while `isLoading` is set, and only one load runs at a time.
245
+
246
+ **Slots:** `root` `header` `headerCell` `headerToggle` `togglePopover` `toggleItem` `body` `row` `cell` `selectCell` `loadingIndicator` `dropIndicator` `columnChip`
164
247
 
165
248
  **In-row popovers:** Rows support inline popover content that overflows the row's bottom edge — e.g. a `Dropdown`, `InputDropdown`, `Tooltip`, or `FloatingMenu` whose list opens below its trigger. All four render their popover through a `BodyEnd` portal and track their trigger's position directly, so they always escape row/cell clipping and paint above the rows below, regardless of focus — no special CSS is needed on the row or cell for this. Cells still clip their own content by default (ellipsis / neighbour-spill guard); that's unaffected. This works in both list mode (`renderRow`) and table mode (`columns`). Stop the row's click handler from firing when interacting with the control (`onClick={(e) => e.stopPropagation()}` on the cell) if the row is also selectable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahrowe/ui",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },