@eifi1/ui-kit 0.8.1 → 0.10.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 (204) hide show
  1. package/README.md +25 -11
  2. package/dist/components/alert-banner.d.ts +32 -5
  3. package/dist/components/alert-banner.js +57 -14
  4. package/dist/components/alert-banner.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +6 -0
  6. package/dist/components/breadcrumbs.d.ts +60 -0
  7. package/dist/components/breadcrumbs.js +88 -0
  8. package/dist/components/breadcrumbs.js.map +1 -0
  9. package/dist/components/bulk-action-bar.d.ts +66 -0
  10. package/dist/components/bulk-action-bar.js +149 -0
  11. package/dist/components/bulk-action-bar.js.map +1 -0
  12. package/dist/components/calculator.d.ts +6 -0
  13. package/dist/components/chip.d.ts +58 -10
  14. package/dist/components/chip.js +91 -7
  15. package/dist/components/chip.js.map +1 -1
  16. package/dist/components/copy-button.d.ts +13 -4
  17. package/dist/components/copy-button.js +17 -4
  18. package/dist/components/copy-button.js.map +1 -1
  19. package/dist/components/data-table-filter-popover.d.ts +1 -1
  20. package/dist/components/data-table-filters.d.ts +1 -1
  21. package/dist/components/data-table.d.ts +1 -1
  22. package/dist/components/data-table.js +122 -91
  23. package/dist/components/data-table.js.map +1 -1
  24. package/dist/components/date-picker.d.ts +89 -3
  25. package/dist/components/date-picker.js +163 -68
  26. package/dist/components/date-picker.js.map +1 -1
  27. package/dist/components/description-list.d.ts +43 -6
  28. package/dist/components/description-list.js +91 -11
  29. package/dist/components/description-list.js.map +1 -1
  30. package/dist/components/dialog-frame.d.ts +7 -0
  31. package/dist/components/dialog-frame.js.map +1 -1
  32. package/dist/components/disclosure.d.ts +68 -4
  33. package/dist/components/disclosure.js +33 -15
  34. package/dist/components/disclosure.js.map +1 -1
  35. package/dist/components/file-dropzone.d.ts +3 -4
  36. package/dist/components/file-dropzone.js +2 -1
  37. package/dist/components/file-dropzone.js.map +1 -1
  38. package/dist/components/floating-panel.d.ts +29 -2
  39. package/dist/components/floating-panel.js +16 -2
  40. package/dist/components/floating-panel.js.map +1 -1
  41. package/dist/components/full-bleed-dialog.d.ts +23 -4
  42. package/dist/components/full-bleed-dialog.js +11 -2
  43. package/dist/components/full-bleed-dialog.js.map +1 -1
  44. package/dist/components/list.d.ts +174 -0
  45. package/dist/components/list.js +215 -0
  46. package/dist/components/list.js.map +1 -0
  47. package/dist/components/menu-item.d.ts +99 -0
  48. package/dist/components/menu-item.js +83 -0
  49. package/dist/components/menu-item.js.map +1 -0
  50. package/dist/components/modal.d.ts +23 -1
  51. package/dist/components/modal.js +36 -10
  52. package/dist/components/modal.js.map +1 -1
  53. package/dist/components/number-field.d.ts +6 -0
  54. package/dist/components/number-input.d.ts +6 -0
  55. package/dist/components/numpad-sheet.d.ts +6 -0
  56. package/dist/components/page-contents.js +1 -2
  57. package/dist/components/page-contents.js.map +1 -1
  58. package/dist/components/page-header.d.ts +37 -0
  59. package/dist/components/page-header.js +36 -0
  60. package/dist/components/page-header.js.map +1 -0
  61. package/dist/components/progress-bar.d.ts +16 -2
  62. package/dist/components/progress-bar.js +4 -2
  63. package/dist/components/progress-bar.js.map +1 -1
  64. package/dist/components/series-chart-labels.d.ts +3 -0
  65. package/dist/components/series-chart-labels.js +2 -1
  66. package/dist/components/series-chart-labels.js.map +1 -1
  67. package/dist/components/series-chart.d.ts +16 -0
  68. package/dist/components/series-chart.js +137 -3
  69. package/dist/components/series-chart.js.map +1 -1
  70. package/dist/components/status-dot.d.ts +49 -0
  71. package/dist/components/status-dot.js +52 -0
  72. package/dist/components/status-dot.js.map +1 -0
  73. package/dist/components/table.d.ts +39 -6
  74. package/dist/components/table.js +43 -4
  75. package/dist/components/table.js.map +1 -1
  76. package/dist/components/text.d.ts +63 -0
  77. package/dist/components/text.js +23 -0
  78. package/dist/components/text.js.map +1 -0
  79. package/dist/components/toast.d.ts +167 -0
  80. package/dist/components/toast.js +226 -0
  81. package/dist/components/toast.js.map +1 -0
  82. package/dist/components/toggle-group.d.ts +29 -1
  83. package/dist/components/toggle-group.js +52 -3
  84. package/dist/components/toggle-group.js.map +1 -1
  85. package/dist/components/tooltip.d.ts +38 -15
  86. package/dist/components/tooltip.js +85 -47
  87. package/dist/components/tooltip.js.map +1 -1
  88. package/dist/components/ui.d.ts +139 -16
  89. package/dist/components/ui.js +136 -26
  90. package/dist/components/ui.js.map +1 -1
  91. package/dist/components/use-table-state.d.ts +1 -1
  92. package/dist/{data-table-filters-CF1PXqjQ.d.ts → data-table-filters-Dh9uF_S-.d.ts} +33 -2
  93. package/dist/data-table.d.ts +1 -1
  94. package/dist/hooks/use-close-transition.d.ts +7 -1
  95. package/dist/hooks/use-close-transition.js +5 -2
  96. package/dist/hooks/use-close-transition.js.map +1 -1
  97. package/dist/hooks/use-copy-to-clipboard.js +1 -1
  98. package/dist/hooks/use-copy-to-clipboard.js.map +1 -1
  99. package/dist/hooks/use-overlay-history.js +84 -4
  100. package/dist/hooks/use-overlay-history.js.map +1 -1
  101. package/dist/i18n/defaults.d.ts +6 -0
  102. package/dist/i18n/defaults.js +11 -1
  103. package/dist/i18n/defaults.js.map +1 -1
  104. package/dist/i18n/kit-labels.d.ts +11 -0
  105. package/dist/i18n/kit-labels.js.map +1 -1
  106. package/dist/i18n/locales/de-CH-informal.d.ts +6 -0
  107. package/dist/i18n/locales/de-CH.d.ts +6 -0
  108. package/dist/i18n/locales/de-informal.d.ts +6 -0
  109. package/dist/i18n/locales/de.d.ts +6 -0
  110. package/dist/i18n/locales/de.js +31 -1
  111. package/dist/i18n/locales/de.js.map +1 -1
  112. package/dist/i18n/locales/es.d.ts +6 -0
  113. package/dist/i18n/locales/es.js +31 -1
  114. package/dist/i18n/locales/es.js.map +1 -1
  115. package/dist/i18n/locales/fr.d.ts +6 -0
  116. package/dist/i18n/locales/fr.js +31 -1
  117. package/dist/i18n/locales/fr.js.map +1 -1
  118. package/dist/i18n/locales/hu.d.ts +6 -0
  119. package/dist/i18n/locales/hu.js +31 -1
  120. package/dist/i18n/locales/hu.js.map +1 -1
  121. package/dist/i18n/locales/it.d.ts +6 -0
  122. package/dist/i18n/locales/it.js +31 -1
  123. package/dist/i18n/locales/it.js.map +1 -1
  124. package/dist/i18n/locales/zh.d.ts +6 -0
  125. package/dist/i18n/locales/zh.js +31 -1
  126. package/dist/i18n/locales/zh.js.map +1 -1
  127. package/dist/index.d.ts +18 -8
  128. package/dist/index.js +14 -0
  129. package/dist/index.js.map +1 -1
  130. package/dist/lib/clipping.d.ts +9 -0
  131. package/dist/lib/clipping.js +14 -0
  132. package/dist/lib/clipping.js.map +1 -0
  133. package/dist/search/command-palette.d.ts +67 -2
  134. package/dist/search/command-palette.js +147 -33
  135. package/dist/search/command-palette.js.map +1 -1
  136. package/dist/search/global-search.d.ts +166 -0
  137. package/dist/search/global-search.js +293 -0
  138. package/dist/search/global-search.js.map +1 -0
  139. package/dist/search/search-index.d.ts +105 -0
  140. package/dist/search/search-index.js +142 -0
  141. package/dist/search/search-index.js.map +1 -0
  142. package/dist/search.d.ts +3 -1
  143. package/dist/search.js +2 -0
  144. package/dist/search.js.map +1 -1
  145. package/dist/wizard/stepper-nav.d.ts +10 -1
  146. package/dist/wizard/stepper-nav.js +3 -2
  147. package/dist/wizard/stepper-nav.js.map +1 -1
  148. package/dist/wizard/types.d.ts +2 -2
  149. package/dist/wizard/types.js.map +1 -1
  150. package/dist/wizard/use-wizard.js +1 -1
  151. package/dist/wizard/use-wizard.js.map +1 -1
  152. package/dist/wizard/wizard-summary.js +3 -3
  153. package/dist/wizard/wizard-summary.js.map +1 -1
  154. package/package.json +25 -12
  155. package/src/components/alert-banner.tsx +117 -18
  156. package/src/components/breadcrumbs.tsx +168 -0
  157. package/src/components/bulk-action-bar.tsx +256 -0
  158. package/src/components/chip.tsx +164 -13
  159. package/src/components/copy-button.tsx +29 -7
  160. package/src/components/data-table.tsx +81 -5
  161. package/src/components/date-picker.tsx +377 -118
  162. package/src/components/description-list.tsx +168 -15
  163. package/src/components/dialog-frame.tsx +7 -0
  164. package/src/components/disclosure.tsx +116 -28
  165. package/src/components/file-dropzone.tsx +9 -10
  166. package/src/components/floating-panel.tsx +46 -2
  167. package/src/components/full-bleed-dialog.tsx +42 -5
  168. package/src/components/list.tsx +417 -0
  169. package/src/components/menu-item.tsx +214 -0
  170. package/src/components/modal.tsx +80 -17
  171. package/src/components/page-contents.tsx +4 -4
  172. package/src/components/page-header.tsx +68 -0
  173. package/src/components/progress-bar.tsx +27 -3
  174. package/src/components/series-chart-labels.ts +4 -0
  175. package/src/components/series-chart.tsx +253 -3
  176. package/src/components/status-dot.tsx +99 -0
  177. package/src/components/table.tsx +91 -6
  178. package/src/components/text.tsx +89 -0
  179. package/src/components/toast.tsx +441 -0
  180. package/src/components/toggle-group.tsx +82 -3
  181. package/src/components/tooltip.tsx +186 -100
  182. package/src/components/ui.tsx +321 -41
  183. package/src/hooks/use-close-transition.ts +14 -5
  184. package/src/hooks/use-copy-to-clipboard.ts +1 -1
  185. package/src/hooks/use-overlay-history.ts +181 -4
  186. package/src/i18n/defaults.ts +10 -0
  187. package/src/i18n/kit-labels.tsx +10 -0
  188. package/src/i18n/locales/de.ts +30 -0
  189. package/src/i18n/locales/es.ts +30 -0
  190. package/src/i18n/locales/fr.ts +30 -0
  191. package/src/i18n/locales/hu.ts +30 -0
  192. package/src/i18n/locales/it.ts +30 -0
  193. package/src/i18n/locales/zh.ts +30 -0
  194. package/src/index.ts +30 -0
  195. package/src/lib/clipping.ts +16 -0
  196. package/src/search/command-palette.tsx +241 -31
  197. package/src/search/global-search.tsx +525 -0
  198. package/src/search/search-index.ts +295 -0
  199. package/src/search.ts +2 -0
  200. package/src/wizard/stepper-nav.tsx +13 -2
  201. package/src/wizard/types.ts +2 -2
  202. package/src/wizard/use-wizard.ts +3 -3
  203. package/src/wizard/wizard-summary.tsx +14 -10
  204. package/tokens.css +97 -0
@@ -65,6 +65,30 @@ export interface FloatingActionButtonProps extends Omit<ButtonHTMLAttributes<HTM
65
65
  * clears the transactions page's own action group this way (`calc(1rem + 4rem)`).
66
66
  */
67
67
  offset?: string;
68
+ /**
69
+ * Whether the button carries the browser's own tooltip (`title`, the `label` unless a
70
+ * `title` is passed). Default `true`, so an icon-only button keeps a hover hint for a
71
+ * mouse user with no other way to learn what it is. `false` renders no `title` at all:
72
+ * keksdose has ONE tooltip, the kit's `Tooltip` (dev#523), and its source scan fails a
73
+ * native `title=` anywhere — so a flag, not `title=""`, which the scan would still
74
+ * see. An empty `title` renders no attribute either, for the caller who has no scan.
75
+ *
76
+ * Not flipped to off by default: a FAB wrapped in nothing would lose the only hover
77
+ * hint it has, silently, in every app already on it. And the kit cannot wrap itself
78
+ * in its `Tooltip` — the bubble's anchor is a `relative inline-flex` span, and the
79
+ * button is `fixed` and portalled — so the one-tooltip caller wraps it.
80
+ */
81
+ nativeTitle?: boolean;
82
+ /**
83
+ * Make it a toggle. `true`/`false` set `aria-pressed` and swap the solid brand disc for
84
+ * a surface disc — the glyph in brand on the quiet brand fill when on, the secondary
85
+ * text colour when off — the "on" look of `IconButton`'s `pressed` and of a selected
86
+ * Chip. Left out, it is the ordinary action FAB. For keksdose's corner filter toggles
87
+ * (feedback-page's "awaiting only", the /transactions pending and upcoming toggles),
88
+ * which paint `text-[var(--brand)]` on a surface button by hand. Keep `label` the same
89
+ * in both states; `aria-pressed` already says which one it is in.
90
+ */
91
+ pressed?: boolean;
68
92
  ref?: Ref<HTMLButtonElement>;
69
93
  }
70
94
 
@@ -90,12 +114,18 @@ export function FloatingActionButton({
90
114
  icon,
91
115
  corner = "bottom-end",
92
116
  offset = "1rem",
117
+ nativeTitle = true,
118
+ pressed,
93
119
  className,
94
120
  style,
95
121
  type = "button",
122
+ title,
96
123
  ...rest
97
124
  }: FloatingActionButtonProps) {
98
125
  const [marker, dir] = usePortalDir();
126
+ const toggle = pressed !== undefined;
127
+ // An empty title is "no tooltip", not an attribute with nothing in it.
128
+ const nativeTip = nativeTitle && title !== "" ? (title ?? label) : undefined;
99
129
  return (
100
130
  <>
101
131
  <span ref={marker} hidden />
@@ -106,11 +136,20 @@ export function FloatingActionButton({
106
136
  type={type}
107
137
  dir={dir}
108
138
  aria-label={label}
109
- title={rest.title ?? label}
139
+ aria-pressed={pressed ?? rest["aria-pressed"]}
140
+ title={nativeTip}
110
141
  style={{ ...cornerStyle(corner, offset), ...style }}
111
142
  className={cn(
112
143
  "fixed z-40 inline-flex size-12 items-center justify-center rounded-full",
113
- "bg-[var(--brand)] text-[var(--brand-contrast)] shadow-lg transition-colors hover:bg-[var(--brand-hover)]",
144
+ "shadow-lg transition-colors",
145
+ !toggle && "bg-[var(--brand)] text-[var(--brand-contrast)] hover:bg-[var(--brand-hover)]",
146
+ // A toggle sits on the page's surface — a solid brand disc would read as
147
+ // "on" in both states — and says "on" in the brand family.
148
+ toggle && "border border-[var(--border)]",
149
+ pressed === false &&
150
+ "bg-[var(--bg-surface)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]",
151
+ pressed === true &&
152
+ "border-[var(--brand)] bg-[var(--brand-bg)] text-[var(--brand)] hover:bg-[var(--brand-bg-hover)]",
114
153
  "outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2",
115
154
  "[&_svg]:size-6",
116
155
  className,
@@ -141,6 +180,9 @@ export interface FloatingPanelProps {
141
180
  /** The FAB's distance above the nav — see {@link FloatingActionButtonProps.offset}.
142
181
  * From `md` up the card sits above the FAB, so it moves with it. */
143
182
  offset?: string;
183
+ /** The FAB's native `title` — see {@link FloatingActionButtonProps.nativeTitle}.
184
+ * `false` for keksdose's assistant launcher, under its one-tooltip rule (dev#523). */
185
+ fabNativeTitle?: boolean;
144
186
  /** Default: `floatingPanel.close` from the {@link UiKitProvider}, else "Close". */
145
187
  closeLabel?: string;
146
188
  /**
@@ -196,6 +238,7 @@ export function FloatingPanel({
196
238
  onOpenChange,
197
239
  corner = "bottom-end",
198
240
  offset = "1rem",
241
+ fabNativeTitle,
199
242
  closeLabel,
200
243
  initialFocus,
201
244
  className,
@@ -264,6 +307,7 @@ export function FloatingPanel({
264
307
  icon={fabIcon}
265
308
  corner={corner}
266
309
  offset={offset}
310
+ nativeTitle={fabNativeTitle}
267
311
  aria-expanded={open}
268
312
  aria-controls={open ? panelId : undefined}
269
313
  onClick={() => {
@@ -1,4 +1,4 @@
1
- import { useRef } from "react";
1
+ import { useId, useRef } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode } from "react";
4
4
  import { X } from "lucide-react";
@@ -20,12 +20,29 @@ import { useFocusTrap } from "../hooks/use-focus-trap";
20
20
  * `aria-describedby` or a `data-tour` anchor belongs on. {@link className} keeps going
21
21
  * to the panel inside it, which is what it has always meant here.
22
22
  */
23
- export interface FullBleedDialogProps extends ComponentPropsWithoutRef<"div"> {
23
+ export interface FullBleedDialogProps extends Omit<ComponentPropsWithoutRef<"div">, "title"> {
24
24
  open: boolean;
25
25
  /** The X, the backdrop (where one shows) and — unless {@link backCloses} is off —
26
26
  * the platform Back gesture all call this. */
27
27
  onClose: () => void;
28
- /** What the header strip shows beside the close button. */
28
+ /**
29
+ * The dialog's HEADING: rendered as a real `h2` (see {@link headingAs}) at the start
30
+ * of the header strip, and wired to the dialog's `aria-labelledby`, so the dialog is
31
+ * announced by name rather than as just "dialog".
32
+ *
33
+ * Before this the strip was a styled `div`, and a caller that wanted its full-screen
34
+ * phone editor NAMED had to leave this component for `DialogFrame fullBleed` —
35
+ * keksdose's budget-table phone editor says so in a comment at the call site. A
36
+ * caller's own `aria-label` / `aria-labelledby` still wins.
37
+ */
38
+ title?: ReactNode;
39
+ /** The heading level of {@link title}. `h2` by default, as `DialogFrame`'s. */
40
+ headingAs?: "h1" | "h2" | "h3" | "h4";
41
+ /**
42
+ * Free content for the header strip, beside the close button — after the
43
+ * {@link title} when there is one, or on its own (the row editor shows the row's
44
+ * own first cell here). It does not name the dialog; `title` does.
45
+ */
29
46
  header?: ReactNode;
30
47
  closeLabel: string;
31
48
  children: ReactNode;
@@ -81,12 +98,16 @@ export interface FullBleedDialogProps extends ComponentPropsWithoutRef<"div"> {
81
98
  * ## What the caller still owns
82
99
  *
83
100
  * The HEADER's content, because the two callers name themselves differently: the row
84
- * editor shows the row's own first column, the create card a plain title. And `open`,
101
+ * editor shows the row's own first column, the create card a plain title. A plain
102
+ * title belongs in `title`, which is a real heading and the dialog's name; `header`
103
+ * stays the free slot beside it. And `open`,
85
104
  * because who decides is the caller's business — see {@link backCloses}.
86
105
  */
87
106
  export function FullBleedDialog({
88
107
  open,
89
108
  onClose,
109
+ title,
110
+ headingAs: Heading = "h2",
90
111
  header,
91
112
  closeLabel,
92
113
  children,
@@ -108,6 +129,9 @@ export function FullBleedDialog({
108
129
  useOverlayHistory(open && backCloses, requestClose);
109
130
 
110
131
  const dialogRef = useRef<HTMLDivElement>(null);
132
+ const titleId = useId();
133
+ const hasTitle = title !== undefined && title !== null && title !== false;
134
+ const hasHeader = header !== undefined && header !== null && header !== false;
111
135
  // The half of "modal" this dialog was only claiming. It has said
112
136
  // `aria-modal="true"` since it was extracted from `data-table.tsx`, which tells
113
137
  // assistive technology to hide everything outside it — while the user's focus stayed
@@ -166,6 +190,10 @@ export function FullBleedDialog({
166
190
  )}
167
191
  role="dialog"
168
192
  aria-modal="true"
193
+ // The heading names the dialog unless the caller named it some other way — an
194
+ // `aria-labelledby` of its own, or an `aria-label` (which a `labelledby` would
195
+ // silently outrank).
196
+ aria-labelledby={rest["aria-labelledby"] ?? (hasTitle && !rest["aria-label"] ? titleId : undefined)}
169
197
  // `tabIndex={-1}` is what makes the line above more than a claim: without it the
170
198
  // container cannot take focus and `useFocusTrap` silently does nothing.
171
199
  tabIndex={-1}
@@ -195,7 +223,16 @@ export function FullBleedDialog({
195
223
  )}
196
224
  >
197
225
  <div className="flex items-center justify-between gap-2 border-b border-[var(--border)] px-3 py-3">
198
- <div className="min-w-0 flex-1 font-medium">{header}</div>
226
+ {hasTitle ? (
227
+ <div className="flex min-w-0 flex-1 items-center gap-2">
228
+ <Heading id={titleId} className="min-w-0 break-words text-base font-semibold leading-snug text-[var(--text-primary)]">
229
+ {title}
230
+ </Heading>
231
+ {hasHeader && <div className="min-w-0 flex-1 font-medium">{header}</div>}
232
+ </div>
233
+ ) : (
234
+ <div className="min-w-0 flex-1 font-medium">{header}</div>
235
+ )}
199
236
  <button
200
237
  type="button"
201
238
  onClick={requestClose}
@@ -0,0 +1,417 @@
1
+ import { createContext, forwardRef, useContext } from "react";
2
+ import type { ComponentPropsWithoutRef, MouseEvent, ReactElement, ReactNode, Ref } from "react";
3
+ import { ExternalLink } from "lucide-react";
4
+ import type { LucideIcon } from "lucide-react";
5
+ import { cn } from "../lib/cn";
6
+ import { Spinner } from "./ui";
7
+ import { StatusDot } from "./status-dot";
8
+ import { useKitLabels } from "../i18n/kit-labels";
9
+
10
+ /* ── Labels ───────────────────────────────────────────────────────────────── */
11
+
12
+ export interface ListLabels {
13
+ /** Read after an unread row's title — the dot alone says nothing to a screen reader. */
14
+ unread: string;
15
+ /** Read after an external row's title: the row leaves the app in a new tab. */
16
+ opensInNewTab: string;
17
+ }
18
+
19
+ export const DEFAULT_LIST_LABELS: ListLabels = {
20
+ unread: "Unread",
21
+ opensInNewTab: "opens in a new tab",
22
+ };
23
+
24
+ /* ── List ─────────────────────────────────────────────────────────────────── */
25
+
26
+ /**
27
+ * `compact` 36px-ish rows for a dense panel (an inbox in a menu), `default` the ~44px
28
+ * row every site draws with `py-2.5`, `comfortable` for a list that is the page.
29
+ */
30
+ export type ListDensity = "compact" | "default" | "comfortable";
31
+ /** `divider`: a rule between rows (kastlan's `divide-y` lists); `gap`: a small space
32
+ * between rows (lenkbank's `space-y-2`, kastlan's `space-y-1`); `none`: flush. */
33
+ export type ListSeparator = "divider" | "gap" | "none";
34
+
35
+ const ListContext = createContext<{ density: ListDensity; separator: ListSeparator }>({
36
+ density: "default",
37
+ separator: "gap",
38
+ });
39
+
40
+ export interface ListProps extends Omit<ComponentPropsWithoutRef<"ul">, "role"> {
41
+ /** `ol` when the order means something (a ranked list, numbered segments). */
42
+ as?: "ul" | "ol";
43
+ /** Every row's density, unless a row says otherwise. Default `default`. */
44
+ density?: ListDensity;
45
+ /** Default `gap`. */
46
+ separator?: ListSeparator;
47
+ }
48
+
49
+ /**
50
+ * The list of {@link ListItem}s.
51
+ *
52
+ * A `<ul>` with an explicit `role="list"`: Safari drops a list's semantics once its
53
+ * bullets are styled away, and "list, 7 items" is exactly what a screen-reader user
54
+ * wants to hear before the first row.
55
+ */
56
+ export const List = forwardRef<HTMLUListElement, ListProps>(function List(
57
+ { as = "ul", density = "default", separator = "gap", className, children, ...rest },
58
+ ref,
59
+ ) {
60
+ const Tag = as;
61
+ return (
62
+ <ListContext.Provider value={{ density, separator }}>
63
+ <Tag
64
+ {...rest}
65
+ ref={ref as Ref<HTMLUListElement & HTMLOListElement>}
66
+ role="list"
67
+ className={cn(
68
+ "m-0 flex list-none flex-col p-0",
69
+ separator === "gap" && (density === "compact" ? "gap-0.5" : "gap-1"),
70
+ className,
71
+ )}
72
+ >
73
+ {children}
74
+ </Tag>
75
+ </ListContext.Provider>
76
+ );
77
+ });
78
+
79
+ /* ── ListItem ─────────────────────────────────────────────────────────────── */
80
+
81
+ /** What {@link ListItemProps.renderLink} is handed. Spread it onto your router's link,
82
+ * mapping `href` to what it calls it — `({ href, ...p }) => <Link to={href} {...p} />`. */
83
+ export interface ListItemLinkProps {
84
+ href: string;
85
+ /** The row's target look — keep it, or the row's padding and focus ring go. */
86
+ className: string;
87
+ children: ReactNode;
88
+ ref?: Ref<HTMLAnchorElement>;
89
+ onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
90
+ onAuxClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
91
+ "aria-current"?: "true";
92
+ "aria-busy"?: true;
93
+ id?: string;
94
+ [key: `aria-${string}`]: string | boolean | number | undefined;
95
+ [key: `data-${string}`]: unknown;
96
+ }
97
+
98
+ const PAD: Record<ListDensity, { target: string; actions: string; gap: string }> = {
99
+ compact: { target: "gap-2 px-2 py-1.5", actions: "pe-1.5", gap: "gap-0.5" },
100
+ default: { target: "gap-3 px-3 py-2.5", actions: "pe-2", gap: "gap-1" },
101
+ comfortable: { target: "gap-3 px-4 py-3", actions: "pe-3", gap: "gap-1" },
102
+ };
103
+
104
+ const TARGET_RING =
105
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)]";
106
+
107
+ interface ListItemBaseProps {
108
+ /** The row's name: one line, truncated. */
109
+ title: ReactNode;
110
+ /** The line under it: one line, truncated — or two, with `subtitleLines={2}`. */
111
+ subtitle?: ReactNode;
112
+ /** `2` for a line that is a body rather than a label (keksdose's notification text). */
113
+ subtitleLines?: 1 | 2;
114
+ /** A third line in the caption type: a timestamp, "Updated …" (lenkbank's projects). */
115
+ meta?: ReactNode;
116
+ /** A Lucide icon before the text, muted, at 16px. */
117
+ icon?: LucideIcon;
118
+ /** Anything else before the text — an avatar, a file-type badge, a drag handle. */
119
+ leading?: ReactNode;
120
+ /**
121
+ * Inside the main target, at its end: a status chip, a file size, a count. It is part
122
+ * of what the row's click does and part of its accessible name — so never a control.
123
+ * Controls go in {@link actions}.
124
+ */
125
+ trailing?: ReactNode;
126
+ /**
127
+ * Controls BESIDE the main target, never inside it: copy, delete, "Open".
128
+ *
129
+ * A button inside a button (or a link) is invalid HTML, and a screen reader folds the
130
+ * inner one into the outer one's name where it cannot be reached. lenkbank found this
131
+ * three times (profile-bar.tsx:110, projects-page.tsx:61, segment-list.tsx:165) and
132
+ * solved it the same way each time: the row's target and its actions are siblings in
133
+ * one bordered box. That is this slot — and being siblings is also what stops a click
134
+ * on "Copy" from selecting the row.
135
+ */
136
+ actions?: ReactNode;
137
+ /**
138
+ * The unread mark: a brand dot at the row's end, the title in semibold, and "Unread"
139
+ * read after the title (keksdose notification-inbox.tsx:215). For another status,
140
+ * pass your own mark in {@link status}.
141
+ */
142
+ unread?: boolean;
143
+ /** A mark in the unread dot's place — a `StatusDot` of another tone, a count. */
144
+ status?: ReactNode;
145
+ /**
146
+ * The row is the chosen one — a brand border on the raised surface (lenkbank's
147
+ * profile bar and segment list). `aria-current="true"` on the target: the row is the
148
+ * current one of a set, which is what those lists mean, not a pressed toggle.
149
+ */
150
+ selected?: boolean;
151
+ disabled?: boolean;
152
+ /**
153
+ * Work under way on the row — keksdose's attachment download (support-attachments
154
+ * .tsx:91). A spinner takes the trailing edge, the target is `aria-busy`, and a
155
+ * second click is ignored. The row keeps focus: `aria-disabled`, not `disabled`, so
156
+ * a keyboard user is not dropped back to the top of the page mid-download.
157
+ */
158
+ loading?: boolean;
159
+ /** Overrides the {@link List}'s density for this row. */
160
+ density?: ListDensity;
161
+ /** `start` for rows whose text runs to two or three lines, so the icon sits by the
162
+ * title rather than the middle of the paragraph. Default `center`. */
163
+ align?: "center" | "start";
164
+ /** Classes on the visible row (the bordered box). */
165
+ className?: string;
166
+ /** Classes on the main target (the button, link or static block). */
167
+ targetClassName?: string;
168
+ /** `div` for a single row outside a {@link List}; an `<li>` must sit in a list. */
169
+ as?: "li" | "div";
170
+ /** Reaches the main target — the element with the row's name and action. */
171
+ id?: string;
172
+ [key: `aria-${string}`]: string | boolean | number | undefined;
173
+ [key: `data-${string}`]: unknown;
174
+ }
175
+
176
+ /**
177
+ * The row is ONE thing to activate, so the types allow one: a button, an in-app link,
178
+ * an external link, or nothing (a static row, like kastlan's passkey and user lists).
179
+ */
180
+ export type ListItemProps = ListItemBaseProps &
181
+ (
182
+ | {
183
+ /** Makes the row a button — select, open, mark read. */
184
+ onClick: (event: MouseEvent<HTMLButtonElement>) => void;
185
+ href?: never;
186
+ renderLink?: never;
187
+ external?: never;
188
+ onAuxClick?: never;
189
+ }
190
+ | {
191
+ /**
192
+ * Makes the row a link. A real `<a href>`, so a middle click opens it in a
193
+ * background tab (keksdose feedback #451) — which a button calling `navigate`
194
+ * cannot do.
195
+ */
196
+ href: string;
197
+ /** Your router's link for an in-app `href`. Default `<a>`. The API `Chip`,
198
+ * `StatTile` and `MenuItem` share. Ignored for an `external` row. */
199
+ renderLink?: (props: ListItemLinkProps) => ReactElement;
200
+ /**
201
+ * The link leaves the app: a plain `<a target="_blank" rel="noopener
202
+ * noreferrer">` with an external-link mark, and "opens in a new tab" read
203
+ * after the title. keksdose's inbox rows whose URL is absolute.
204
+ */
205
+ external?: boolean;
206
+ /** Runs on a click, before the navigation (mark the row read). */
207
+ onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
208
+ /** Runs on a middle click, which fires no `click` — keksdose marks a
209
+ * notification read here too, so a row opened into a background tab does not
210
+ * stay unread. */
211
+ onAuxClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
212
+ }
213
+ | {
214
+ onClick?: never;
215
+ href?: never;
216
+ renderLink?: never;
217
+ external?: never;
218
+ onAuxClick?: never;
219
+ }
220
+ );
221
+
222
+ /**
223
+ * One row of a {@link List}: leading icon or avatar, title and subtitle (both
224
+ * truncating), a trailing slot, and — beside the row, not inside it — its actions.
225
+ *
226
+ * All three apps draw this row by hand, a dozen times: kastlan's units
227
+ * (building-detail-page.tsx:402), open tickets (group-overview-page.tsx:160), activity
228
+ * feed (activity-feed.tsx:53), company users (platform-company-detail-page.tsx:140)
229
+ * and passkeys (passkeys-card.tsx:75); keksdose's notification inbox and attachment
230
+ * downloads; lenkbank's profiles, projects and segments. The copies differ in the
231
+ * details that are easy to get wrong — which of them truncate, whether the actions
232
+ * are nested in the button, whether a link row is a link at all.
233
+ */
234
+ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem(
235
+ {
236
+ title,
237
+ subtitle,
238
+ subtitleLines = 1,
239
+ meta,
240
+ icon: Icon,
241
+ leading,
242
+ trailing,
243
+ actions,
244
+ unread = false,
245
+ status,
246
+ selected = false,
247
+ disabled = false,
248
+ loading = false,
249
+ density: densityProp,
250
+ align = "center",
251
+ className,
252
+ targetClassName,
253
+ as = "li",
254
+ href,
255
+ renderLink,
256
+ external = false,
257
+ onClick,
258
+ onAuxClick,
259
+ ...rest
260
+ },
261
+ ref,
262
+ ) {
263
+ const list = useContext(ListContext);
264
+ const labels = useKitLabels("list", DEFAULT_LIST_LABELS);
265
+ const density = densityProp ?? list.density;
266
+ const pad = PAD[density];
267
+ const isLink = href !== undefined && !disabled;
268
+ const isButton = !isLink && onClick !== undefined && href === undefined;
269
+ const interactive = isLink || isButton;
270
+ const inert = disabled || loading;
271
+
272
+ const mark =
273
+ status ??
274
+ (unread ? <StatusDot tone="brand" size="sm" className={align === "start" ? "mt-1.5" : undefined} /> : null);
275
+
276
+ const body = (
277
+ <>
278
+ {Icon && (
279
+ <Icon
280
+ className={cn("size-4 shrink-0 text-[var(--text-muted)]", align === "start" && "mt-0.5")}
281
+ aria-hidden
282
+ />
283
+ )}
284
+ {leading}
285
+ <span className="flex min-w-0 flex-1 flex-col">
286
+ <span
287
+ className={cn(
288
+ "block truncate text-sm text-[var(--text-primary)]",
289
+ unread ? "font-semibold" : "font-medium",
290
+ )}
291
+ >
292
+ {title}
293
+ {unread && <span className="sr-only"> ({labels.unread})</span>}
294
+ {external && href !== undefined && <span className="sr-only"> ({labels.opensInNewTab})</span>}
295
+ </span>
296
+ {subtitle != null && (
297
+ <span
298
+ className={cn(
299
+ "block text-xs text-[var(--text-muted)]",
300
+ subtitleLines === 2 ? "line-clamp-2" : "truncate",
301
+ )}
302
+ >
303
+ {subtitle}
304
+ </span>
305
+ )}
306
+ {meta != null && <span className="mt-0.5 block truncate text-[11px] leading-snug text-[var(--text-muted)]">{meta}</span>}
307
+ </span>
308
+ {trailing != null && <span className="flex shrink-0 items-center gap-2 text-xs text-[var(--text-muted)]">{trailing}</span>}
309
+ {loading ? (
310
+ <Spinner label={null} className="size-3.5 shrink-0" />
311
+ ) : (
312
+ external && href !== undefined && (
313
+ <ExternalLink className="size-3.5 shrink-0 text-[var(--text-muted)] rtl:-scale-x-100" aria-hidden />
314
+ )
315
+ )}
316
+ {mark}
317
+ </>
318
+ );
319
+
320
+ const target = cn(
321
+ "flex min-w-0 flex-1 text-start",
322
+ align === "start" ? "items-start" : "items-center",
323
+ pad.target,
324
+ "rounded-[inherit]",
325
+ interactive && TARGET_RING,
326
+ interactive && !inert && "cursor-pointer",
327
+ loading && "cursor-progress",
328
+ targetClassName,
329
+ );
330
+
331
+ let main: ReactNode;
332
+ if (isLink) {
333
+ const linkProps: ListItemLinkProps = {
334
+ ...rest,
335
+ ref: ref as Ref<HTMLAnchorElement>,
336
+ href,
337
+ className: target,
338
+ "aria-current": selected ? "true" : undefined,
339
+ "aria-busy": loading || undefined,
340
+ onClick: loading
341
+ ? (e) => e.preventDefault()
342
+ : (onClick as ListItemLinkProps["onClick"]),
343
+ onAuxClick,
344
+ children: body,
345
+ };
346
+ const { children: linkBody, ...anchorProps } = linkProps;
347
+ main = renderLink && !external ? (
348
+ <RenderedLink render={renderLink} {...linkProps} />
349
+ ) : (
350
+ <a {...anchorProps} {...(external ? { target: "_blank", rel: "noopener noreferrer" } : null)}>
351
+ {linkBody}
352
+ </a>
353
+ );
354
+ } else if (isButton) {
355
+ main = (
356
+ <button
357
+ {...rest}
358
+ ref={ref as Ref<HTMLButtonElement>}
359
+ type="button"
360
+ // A disabled row is `disabled`: nothing to do there, and nothing to reach. A
361
+ // loading row is only `aria-disabled` — see `loading`.
362
+ disabled={disabled}
363
+ aria-disabled={loading || undefined}
364
+ aria-busy={loading || undefined}
365
+ aria-current={selected ? "true" : undefined}
366
+ onClick={loading ? undefined : (onClick as (event: MouseEvent<HTMLButtonElement>) => void)}
367
+ className={target}
368
+ >
369
+ {body}
370
+ </button>
371
+ );
372
+ } else {
373
+ main = (
374
+ <div
375
+ {...rest}
376
+ ref={ref as Ref<HTMLDivElement>}
377
+ aria-disabled={disabled || undefined}
378
+ aria-busy={loading || undefined}
379
+ className={target}
380
+ >
381
+ {body}
382
+ </div>
383
+ );
384
+ }
385
+
386
+ const Tag = as;
387
+ return (
388
+ <Tag
389
+ className={cn(
390
+ list.separator === "divider" && "border-b border-[var(--border)] last:border-b-0",
391
+ list.separator === "divider" && as === "li" && "py-0.5",
392
+ )}
393
+ >
394
+ <div
395
+ className={cn(
396
+ "flex min-w-0 items-center rounded-md border transition-colors",
397
+ selected ? "border-[var(--brand)] bg-[var(--bg-surface-2)]" : "border-transparent",
398
+ interactive && !inert && !selected && "hover:bg-[var(--bg-hover)]",
399
+ disabled && "opacity-50",
400
+ className,
401
+ )}
402
+ >
403
+ {main}
404
+ {actions != null && (
405
+ <div className={cn("flex shrink-0 items-center", pad.gap, pad.actions)}>{actions}</div>
406
+ )}
407
+ </div>
408
+ </Tag>
409
+ );
410
+ });
411
+
412
+ function RenderedLink({
413
+ render,
414
+ ...props
415
+ }: ListItemLinkProps & { render: (props: ListItemLinkProps) => ReactElement }) {
416
+ return render(props);
417
+ }