@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.
- package/README.md +25 -11
- package/dist/components/alert-banner.d.ts +32 -5
- package/dist/components/alert-banner.js +57 -14
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +6 -0
- package/dist/components/breadcrumbs.d.ts +60 -0
- package/dist/components/breadcrumbs.js +88 -0
- package/dist/components/breadcrumbs.js.map +1 -0
- package/dist/components/bulk-action-bar.d.ts +66 -0
- package/dist/components/bulk-action-bar.js +149 -0
- package/dist/components/bulk-action-bar.js.map +1 -0
- package/dist/components/calculator.d.ts +6 -0
- package/dist/components/chip.d.ts +58 -10
- package/dist/components/chip.js +91 -7
- package/dist/components/chip.js.map +1 -1
- package/dist/components/copy-button.d.ts +13 -4
- package/dist/components/copy-button.js +17 -4
- package/dist/components/copy-button.js.map +1 -1
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +122 -91
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +89 -3
- package/dist/components/date-picker.js +163 -68
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/description-list.d.ts +43 -6
- package/dist/components/description-list.js +91 -11
- package/dist/components/description-list.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +7 -0
- package/dist/components/dialog-frame.js.map +1 -1
- package/dist/components/disclosure.d.ts +68 -4
- package/dist/components/disclosure.js +33 -15
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +3 -4
- package/dist/components/file-dropzone.js +2 -1
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/floating-panel.d.ts +29 -2
- package/dist/components/floating-panel.js +16 -2
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/full-bleed-dialog.d.ts +23 -4
- package/dist/components/full-bleed-dialog.js +11 -2
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/list.d.ts +174 -0
- package/dist/components/list.js +215 -0
- package/dist/components/list.js.map +1 -0
- package/dist/components/menu-item.d.ts +99 -0
- package/dist/components/menu-item.js +83 -0
- package/dist/components/menu-item.js.map +1 -0
- package/dist/components/modal.d.ts +23 -1
- package/dist/components/modal.js +36 -10
- package/dist/components/modal.js.map +1 -1
- package/dist/components/number-field.d.ts +6 -0
- package/dist/components/number-input.d.ts +6 -0
- package/dist/components/numpad-sheet.d.ts +6 -0
- package/dist/components/page-contents.js +1 -2
- package/dist/components/page-contents.js.map +1 -1
- package/dist/components/page-header.d.ts +37 -0
- package/dist/components/page-header.js +36 -0
- package/dist/components/page-header.js.map +1 -0
- package/dist/components/progress-bar.d.ts +16 -2
- package/dist/components/progress-bar.js +4 -2
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/series-chart-labels.d.ts +3 -0
- package/dist/components/series-chart-labels.js +2 -1
- package/dist/components/series-chart-labels.js.map +1 -1
- package/dist/components/series-chart.d.ts +16 -0
- package/dist/components/series-chart.js +137 -3
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/status-dot.d.ts +49 -0
- package/dist/components/status-dot.js +52 -0
- package/dist/components/status-dot.js.map +1 -0
- package/dist/components/table.d.ts +39 -6
- package/dist/components/table.js +43 -4
- package/dist/components/table.js.map +1 -1
- package/dist/components/text.d.ts +63 -0
- package/dist/components/text.js +23 -0
- package/dist/components/text.js.map +1 -0
- package/dist/components/toast.d.ts +167 -0
- package/dist/components/toast.js +226 -0
- package/dist/components/toast.js.map +1 -0
- package/dist/components/toggle-group.d.ts +29 -1
- package/dist/components/toggle-group.js +52 -3
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/tooltip.d.ts +38 -15
- package/dist/components/tooltip.js +85 -47
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/ui.d.ts +139 -16
- package/dist/components/ui.js +136 -26
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-filters-CF1PXqjQ.d.ts → data-table-filters-Dh9uF_S-.d.ts} +33 -2
- package/dist/data-table.d.ts +1 -1
- package/dist/hooks/use-close-transition.d.ts +7 -1
- package/dist/hooks/use-close-transition.js +5 -2
- package/dist/hooks/use-close-transition.js.map +1 -1
- package/dist/hooks/use-copy-to-clipboard.js +1 -1
- package/dist/hooks/use-copy-to-clipboard.js.map +1 -1
- package/dist/hooks/use-overlay-history.js +84 -4
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/i18n/defaults.d.ts +6 -0
- package/dist/i18n/defaults.js +11 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +11 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +6 -0
- package/dist/i18n/locales/de-CH.d.ts +6 -0
- package/dist/i18n/locales/de-informal.d.ts +6 -0
- package/dist/i18n/locales/de.d.ts +6 -0
- package/dist/i18n/locales/de.js +31 -1
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +6 -0
- package/dist/i18n/locales/es.js +31 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +6 -0
- package/dist/i18n/locales/fr.js +31 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +6 -0
- package/dist/i18n/locales/hu.js +31 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +6 -0
- package/dist/i18n/locales/it.js +31 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +6 -0
- package/dist/i18n/locales/zh.js +31 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +18 -8
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/clipping.d.ts +9 -0
- package/dist/lib/clipping.js +14 -0
- package/dist/lib/clipping.js.map +1 -0
- package/dist/search/command-palette.d.ts +67 -2
- package/dist/search/command-palette.js +147 -33
- package/dist/search/command-palette.js.map +1 -1
- package/dist/search/global-search.d.ts +166 -0
- package/dist/search/global-search.js +293 -0
- package/dist/search/global-search.js.map +1 -0
- package/dist/search/search-index.d.ts +105 -0
- package/dist/search/search-index.js +142 -0
- package/dist/search/search-index.js.map +1 -0
- package/dist/search.d.ts +3 -1
- package/dist/search.js +2 -0
- package/dist/search.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +10 -1
- package/dist/wizard/stepper-nav.js +3 -2
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.d.ts +2 -2
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +1 -1
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/wizard-summary.js +3 -3
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/package.json +25 -12
- package/src/components/alert-banner.tsx +117 -18
- package/src/components/breadcrumbs.tsx +168 -0
- package/src/components/bulk-action-bar.tsx +256 -0
- package/src/components/chip.tsx +164 -13
- package/src/components/copy-button.tsx +29 -7
- package/src/components/data-table.tsx +81 -5
- package/src/components/date-picker.tsx +377 -118
- package/src/components/description-list.tsx +168 -15
- package/src/components/dialog-frame.tsx +7 -0
- package/src/components/disclosure.tsx +116 -28
- package/src/components/file-dropzone.tsx +9 -10
- package/src/components/floating-panel.tsx +46 -2
- package/src/components/full-bleed-dialog.tsx +42 -5
- package/src/components/list.tsx +417 -0
- package/src/components/menu-item.tsx +214 -0
- package/src/components/modal.tsx +80 -17
- package/src/components/page-contents.tsx +4 -4
- package/src/components/page-header.tsx +68 -0
- package/src/components/progress-bar.tsx +27 -3
- package/src/components/series-chart-labels.ts +4 -0
- package/src/components/series-chart.tsx +253 -3
- package/src/components/status-dot.tsx +99 -0
- package/src/components/table.tsx +91 -6
- package/src/components/text.tsx +89 -0
- package/src/components/toast.tsx +441 -0
- package/src/components/toggle-group.tsx +82 -3
- package/src/components/tooltip.tsx +186 -100
- package/src/components/ui.tsx +321 -41
- package/src/hooks/use-close-transition.ts +14 -5
- package/src/hooks/use-copy-to-clipboard.ts +1 -1
- package/src/hooks/use-overlay-history.ts +181 -4
- package/src/i18n/defaults.ts +10 -0
- package/src/i18n/kit-labels.tsx +10 -0
- package/src/i18n/locales/de.ts +30 -0
- package/src/i18n/locales/es.ts +30 -0
- package/src/i18n/locales/fr.ts +30 -0
- package/src/i18n/locales/hu.ts +30 -0
- package/src/i18n/locales/it.ts +30 -0
- package/src/i18n/locales/zh.ts +30 -0
- package/src/index.ts +30 -0
- package/src/lib/clipping.ts +16 -0
- package/src/search/command-palette.tsx +241 -31
- package/src/search/global-search.tsx +525 -0
- package/src/search/search-index.ts +295 -0
- package/src/search.ts +2 -0
- package/src/wizard/stepper-nav.tsx +13 -2
- package/src/wizard/types.ts +2 -2
- package/src/wizard/use-wizard.ts +3 -3
- package/src/wizard/wizard-summary.tsx +14 -10
- 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
|
-
|
|
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
|
-
"
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|