@eifi1/ui-kit 0.10.0 → 0.11.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 +20 -16
- package/dist/components/alert-banner.d.ts +26 -1
- package/dist/components/alert-banner.js +10 -7
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +2 -0
- package/dist/components/bulk-action-bar.d.ts +51 -5
- package/dist/components/bulk-action-bar.js +88 -41
- package/dist/components/bulk-action-bar.js.map +1 -1
- package/dist/components/button-group.d.ts +58 -3
- package/dist/components/button-group.js +56 -5
- package/dist/components/button-group.js.map +1 -1
- package/dist/components/calculator.d.ts +2 -0
- package/dist/components/calendar-heatmap.d.ts +124 -0
- package/dist/components/calendar-heatmap.js +295 -0
- package/dist/components/calendar-heatmap.js.map +1 -0
- package/dist/components/choice-card.d.ts +34 -2
- package/dist/components/choice-card.js +49 -0
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/copy-button.d.ts +1 -0
- package/dist/components/data-table.js +2 -0
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/field.d.ts +43 -0
- package/dist/components/field.js +52 -0
- package/dist/components/field.js.map +1 -0
- package/dist/components/file-button.d.ts +1 -0
- package/dist/components/file-dropzone.d.ts +1 -0
- package/dist/components/floating-panel.d.ts +147 -4
- package/dist/components/floating-panel.js +188 -28
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/list.d.ts +14 -1
- package/dist/components/list.js +7 -1
- package/dist/components/list.js.map +1 -1
- package/dist/components/menu-item.d.ts +11 -1
- package/dist/components/menu-item.js +6 -1
- package/dist/components/menu-item.js.map +1 -1
- package/dist/components/mini-calendar.d.ts +70 -5
- package/dist/components/mini-calendar.js +166 -62
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/nav-pills.d.ts +66 -0
- package/dist/components/nav-pills.js +65 -0
- package/dist/components/nav-pills.js.map +1 -0
- package/dist/components/number-field.d.ts +2 -0
- package/dist/components/number-input.d.ts +2 -0
- package/dist/components/numpad-sheet.d.ts +2 -0
- package/dist/components/progress-bar.d.ts +31 -2
- package/dist/components/progress-bar.js +73 -7
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/series-chart.d.ts +16 -0
- package/dist/components/series-chart.js +4 -1
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/settings-fields.d.ts +1 -0
- package/dist/components/status-dot.d.ts +6 -2
- package/dist/components/status-dot.js +6 -1
- package/dist/components/status-dot.js.map +1 -1
- package/dist/components/table.d.ts +40 -8
- package/dist/components/table.js +41 -7
- package/dist/components/table.js.map +1 -1
- package/dist/components/text.d.ts +8 -1
- package/dist/components/text.js +1 -0
- package/dist/components/text.js.map +1 -1
- package/dist/components/time-input.d.ts +1 -0
- package/dist/components/toggle-group.d.ts +15 -0
- package/dist/components/toggle-group.js +23 -3
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/tooltip.d.ts +11 -3
- package/dist/components/tooltip.js +2 -0
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/ui.d.ts +55 -4
- package/dist/components/ui.js +84 -7
- package/dist/components/ui.js.map +1 -1
- package/dist/components/user-avatar.d.ts +25 -3
- package/dist/components/user-avatar.js +32 -3
- package/dist/components/user-avatar.js.map +1 -1
- package/dist/hooks/use-file-drop.d.ts +1 -0
- package/dist/i18n/defaults.d.ts +2 -0
- package/dist/i18n/defaults.js +2 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +3 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +2 -0
- package/dist/i18n/locales/de-CH.d.ts +2 -0
- package/dist/i18n/locales/de-informal.d.ts +2 -0
- package/dist/i18n/locales/de.d.ts +2 -0
- package/dist/i18n/locales/de.js +9 -1
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +2 -0
- package/dist/i18n/locales/es.js +9 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +2 -0
- package/dist/i18n/locales/fr.js +9 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +2 -0
- package/dist/i18n/locales/hu.js +9 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +2 -0
- package/dist/i18n/locales/it.js +9 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +2 -0
- package/dist/i18n/locales/zh.js +9 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +14 -10
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/clipping.d.ts +21 -5
- package/dist/lib/clipping.js +3 -0
- package/dist/lib/clipping.js.map +1 -1
- package/dist/lib/dates.d.ts +17 -1
- package/dist/lib/dates.js +19 -0
- package/dist/lib/dates.js.map +1 -1
- package/dist/rhf/form.d.ts +1 -0
- package/dist/rhf/use-rhf-wizard-step.d.ts +22 -0
- package/dist/rhf/use-rhf-wizard-step.js +38 -0
- package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
- package/dist/rhf.d.ts +2 -0
- package/dist/rhf.js +1 -0
- package/dist/rhf.js.map +1 -1
- package/dist/shell/topbar-action-menu.d.ts +73 -3
- package/dist/shell/topbar-action-menu.js +100 -27
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +2 -1
- package/dist/wizard/stepper-nav.d.ts +1 -0
- package/dist/wizard/wizard-context.d.ts +7 -1
- package/dist/wizard/wizard-context.js +4 -0
- package/dist/wizard/wizard-context.js.map +1 -1
- package/dist/wizard.d.ts +2 -1
- package/package.json +1 -1
- package/src/components/alert-banner.tsx +36 -7
- package/src/components/bulk-action-bar.tsx +163 -57
- package/src/components/button-group.tsx +124 -4
- package/src/components/calendar-heatmap.tsx +504 -0
- package/src/components/choice-card.tsx +97 -1
- package/src/components/data-table.tsx +7 -0
- package/src/components/field.tsx +137 -0
- package/src/components/floating-panel.tsx +366 -15
- package/src/components/list.tsx +28 -2
- package/src/components/menu-item.tsx +22 -1
- package/src/components/mini-calendar.tsx +245 -54
- package/src/components/nav-pills.tsx +145 -0
- package/src/components/progress-bar.tsx +129 -11
- package/src/components/series-chart.tsx +21 -0
- package/src/components/status-dot.tsx +10 -2
- package/src/components/table.tsx +85 -11
- package/src/components/text.tsx +9 -1
- package/src/components/toggle-group.tsx +48 -3
- package/src/components/tooltip.tsx +17 -3
- package/src/components/ui.tsx +149 -11
- package/src/components/user-avatar.tsx +57 -3
- package/src/i18n/defaults.ts +2 -0
- package/src/i18n/kit-labels.tsx +2 -0
- package/src/i18n/locales/de.ts +9 -0
- package/src/i18n/locales/es.ts +9 -0
- package/src/i18n/locales/fr.ts +9 -0
- package/src/i18n/locales/hu.ts +9 -0
- package/src/i18n/locales/it.ts +9 -0
- package/src/i18n/locales/zh.ts +9 -0
- package/src/index.ts +5 -0
- package/src/lib/clipping.ts +22 -4
- package/src/lib/dates.ts +37 -0
- package/src/rhf/use-rhf-wizard-step.ts +113 -0
- package/src/rhf.ts +2 -0
- package/src/shell/topbar-action-menu.tsx +239 -49
- package/src/wizard/wizard-context.tsx +9 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A field group with its label ABOVE the control: label, control, hint, error.
|
|
3
|
+
*
|
|
4
|
+
* ```tsx
|
|
5
|
+
* <Field label="IBAN" required hint="22 characters" error={errors.iban}>
|
|
6
|
+
* {(ids) => <Input {...ids} value={iban} onChange={…} />}
|
|
7
|
+
* </Field>
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* The kit's `Input` and `Select` float their label inside the field, and the pickers
|
|
11
|
+
* draw theirs the same way. That is the wrong shape for a control the kit did not
|
|
12
|
+
* render, a radio group, a range slider, or a form that sets its labels above the
|
|
13
|
+
* fields throughout — which is what kastlan's wizards are, and why it kept its own
|
|
14
|
+
* `WizardField` after the kit dropped the one it had never rendered. This is that
|
|
15
|
+
* group, with the wiring the old one left to the caller: the label names the control
|
|
16
|
+
* through `htmlFor`, and the control is described by the hint and the error that are
|
|
17
|
+
* actually on screen.
|
|
18
|
+
*
|
|
19
|
+
* ## Wiring
|
|
20
|
+
*
|
|
21
|
+
* Pass a function as `children` and it receives {@link FieldControlProps} — `id`,
|
|
22
|
+
* `aria-describedby`, `aria-invalid` and `aria-required` — to spread on the control.
|
|
23
|
+
* Plain children render as they are, for a control that wires itself (a checkbox that
|
|
24
|
+
* carries its own label, a group with `aria-labelledby`); `htmlFor` then points the
|
|
25
|
+
* label at the control's own id, and the hint and error are on screen but describe
|
|
26
|
+
* nothing — the same as the `WizardField` this replaces.
|
|
27
|
+
*
|
|
28
|
+
* ## Decoupled
|
|
29
|
+
*
|
|
30
|
+
* Deliberately knows neither the wizard nor react-hook-form: `error` is a plain
|
|
31
|
+
* value. Pass `wizard.fieldErrors.iban`, a step-local error, or
|
|
32
|
+
* `fieldState.error?.message` from a `FormField` render — the markup is the same
|
|
33
|
+
* whichever of them gates the step, so a step can move between them without its
|
|
34
|
+
* fields changing. (Inside a react-hook-form form, `FormItem` + `FormLabel` from
|
|
35
|
+
* `@eifi1/ui-kit/rhf` do the same job with the ids taken from the form.)
|
|
36
|
+
*
|
|
37
|
+
* The error is NOT `role="alert"`, for the reason the kit's `error` prop gives (see
|
|
38
|
+
* `useFieldError` in components/ui.tsx): it is read when focus reaches the control.
|
|
39
|
+
*/
|
|
40
|
+
import { useId, type ReactNode } from "react";
|
|
41
|
+
import { cn } from "../lib/cn";
|
|
42
|
+
import { Label } from "./ui";
|
|
43
|
+
|
|
44
|
+
/** What a `Field` hands its render-prop children, to spread on the control. */
|
|
45
|
+
export interface FieldControlProps {
|
|
46
|
+
/** The id the label's `htmlFor` points at. */
|
|
47
|
+
id: string;
|
|
48
|
+
/** The ids of the hint and the error that are rendered, or `undefined` when
|
|
49
|
+
* neither is. */
|
|
50
|
+
"aria-describedby": string | undefined;
|
|
51
|
+
/** `true` while the field has an error, `undefined` otherwise. */
|
|
52
|
+
"aria-invalid": true | undefined;
|
|
53
|
+
/** `true` when the field is `required`. The label's star is `aria-hidden`; this
|
|
54
|
+
* is what a screen reader hears instead. */
|
|
55
|
+
"aria-required": true | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface FieldProps {
|
|
59
|
+
/** The label above the control. Omit it for a group whose control is labelled
|
|
60
|
+
* some other way; the hint and error still render. */
|
|
61
|
+
label?: ReactNode;
|
|
62
|
+
/** Draws the kit's required mark after the label, and sets `aria-required` on the
|
|
63
|
+
* render-prop control. */
|
|
64
|
+
required?: boolean;
|
|
65
|
+
/** Muted helper text below the control. */
|
|
66
|
+
hint?: ReactNode;
|
|
67
|
+
/** The error below the control. `null`, `false` and `""` are no error — what a
|
|
68
|
+
* `touched && errors.x` evaluates to on the happy path. */
|
|
69
|
+
error?: ReactNode;
|
|
70
|
+
/** The control's id. Defaults to a generated one; set it when the control carries
|
|
71
|
+
* its own id (plain children). */
|
|
72
|
+
htmlFor?: string;
|
|
73
|
+
/** Dims the label with its control. */
|
|
74
|
+
disabled?: boolean;
|
|
75
|
+
/** The label's size: `sm` for a dense row, `md` otherwise. */
|
|
76
|
+
labelSize?: "sm" | "md";
|
|
77
|
+
/** Wrapper className. */
|
|
78
|
+
className?: string;
|
|
79
|
+
/** The control. A function receives {@link FieldControlProps} to spread on it. */
|
|
80
|
+
children: ReactNode | ((control: FieldControlProps) => ReactNode);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const isShown = (node: ReactNode) =>
|
|
84
|
+
node !== undefined && node !== null && node !== false && node !== "";
|
|
85
|
+
|
|
86
|
+
export function Field({
|
|
87
|
+
label,
|
|
88
|
+
required,
|
|
89
|
+
hint,
|
|
90
|
+
error,
|
|
91
|
+
htmlFor,
|
|
92
|
+
disabled,
|
|
93
|
+
labelSize,
|
|
94
|
+
className,
|
|
95
|
+
children,
|
|
96
|
+
}: FieldProps) {
|
|
97
|
+
const generated = useId();
|
|
98
|
+
const id = htmlFor ?? `${generated}-control`;
|
|
99
|
+
const hintId = `${generated}-hint`;
|
|
100
|
+
const errorId = `${generated}-error`;
|
|
101
|
+
const hasHint = isShown(hint);
|
|
102
|
+
const hasError = isShown(error);
|
|
103
|
+
const describedBy = [hasHint && hintId, hasError && errorId].filter(Boolean).join(" ");
|
|
104
|
+
const control: FieldControlProps = {
|
|
105
|
+
id,
|
|
106
|
+
"aria-describedby": describedBy || undefined,
|
|
107
|
+
"aria-invalid": hasError || undefined,
|
|
108
|
+
"aria-required": required || undefined,
|
|
109
|
+
};
|
|
110
|
+
return (
|
|
111
|
+
<div data-slot="field" className={cn("grid gap-1.5", className)}>
|
|
112
|
+
{label !== undefined && (
|
|
113
|
+
<Label
|
|
114
|
+
htmlFor={id}
|
|
115
|
+
required={required}
|
|
116
|
+
disabled={disabled}
|
|
117
|
+
size={labelSize}
|
|
118
|
+
data-error={hasError || undefined}
|
|
119
|
+
className="data-[error=true]:text-[var(--danger)]"
|
|
120
|
+
>
|
|
121
|
+
{label}
|
|
122
|
+
</Label>
|
|
123
|
+
)}
|
|
124
|
+
{typeof children === "function" ? children(control) : children}
|
|
125
|
+
{hasHint && (
|
|
126
|
+
<p id={hintId} className="text-[11px] leading-tight text-[var(--text-muted)]">
|
|
127
|
+
{hint}
|
|
128
|
+
</p>
|
|
129
|
+
)}
|
|
130
|
+
{hasError && (
|
|
131
|
+
<p id={errorId} className="text-[11px] leading-tight text-[var(--danger)]">
|
|
132
|
+
{error}
|
|
133
|
+
</p>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -1,18 +1,38 @@
|
|
|
1
1
|
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
AnchorHTMLAttributes,
|
|
5
|
+
ButtonHTMLAttributes,
|
|
6
|
+
CSSProperties,
|
|
7
|
+
HTMLAttributes,
|
|
8
|
+
KeyboardEvent,
|
|
9
|
+
MouseEvent,
|
|
10
|
+
ReactElement,
|
|
11
|
+
ReactNode,
|
|
12
|
+
Ref,
|
|
13
|
+
} from "react";
|
|
4
14
|
import { X } from "lucide-react";
|
|
5
15
|
|
|
6
16
|
import { cn } from "../lib/cn";
|
|
7
17
|
import { dirOf, type Direction } from "../lib/direction";
|
|
8
18
|
import { useKitLabels } from "../i18n/kit-labels";
|
|
19
|
+
import { Tooltip, type TooltipSide } from "./tooltip";
|
|
9
20
|
|
|
10
21
|
export interface FloatingPanelLabels {
|
|
11
22
|
/** Accessible name of the panel header's X. */
|
|
12
23
|
close: string;
|
|
24
|
+
/**
|
|
25
|
+
* What a {@link FloatingAction}'s count badge adds to its accessible name ("3 new"),
|
|
26
|
+
* read after the label: "Invoices, 3 new". The dot itself is hidden from a reader —
|
|
27
|
+
* a bare "3" read out of context says nothing about what is counted.
|
|
28
|
+
*/
|
|
29
|
+
badge: (count: number) => string;
|
|
13
30
|
}
|
|
14
31
|
|
|
15
|
-
export const DEFAULT_FLOATING_PANEL_LABELS: FloatingPanelLabels = {
|
|
32
|
+
export const DEFAULT_FLOATING_PANEL_LABELS: FloatingPanelLabels = {
|
|
33
|
+
close: "Close",
|
|
34
|
+
badge: (count) => `${count} new`,
|
|
35
|
+
};
|
|
16
36
|
|
|
17
37
|
/** A LOGICAL corner: `bottom-end` is bottom-right in LTR and bottom-left in RTL. */
|
|
18
38
|
export type FloatingCorner = "bottom-end" | "bottom-start";
|
|
@@ -89,6 +109,49 @@ export interface FloatingActionButtonProps extends Omit<ButtonHTMLAttributes<HTM
|
|
|
89
109
|
* in both states; `aria-pressed` already says which one it is in.
|
|
90
110
|
*/
|
|
91
111
|
pressed?: boolean;
|
|
112
|
+
/**
|
|
113
|
+
* Show `label` beside the icon: the EXTENDED FAB, a pill rather than a disc. For a
|
|
114
|
+
* corner control whose words are the point — kastlan's offline indicator
|
|
115
|
+
* (shared/offline/offline-indicator.tsx:39) is a pill saying "Offline — 2 photo(s)
|
|
116
|
+
* queued" / "Syncing…", hand-placed at `fixed bottom-4 left-4`, i.e. over the
|
|
117
|
+
* bottom nav and on the wrong side in RTL. Same height as the disc (3rem), so a
|
|
118
|
+
* {@link FloatingPanel} card above it still clears it.
|
|
119
|
+
*/
|
|
120
|
+
extended?: boolean;
|
|
121
|
+
/**
|
|
122
|
+
* Announce `label` whenever it changes, through a polite `role="status"` region — for
|
|
123
|
+
* an extended FAB that REPORTS something (kastlan's offline/sync state), where a
|
|
124
|
+
* sighted user sees the words change and a screen reader user otherwise hears
|
|
125
|
+
* nothing until they happen to land on the button.
|
|
126
|
+
*
|
|
127
|
+
* ⚠️ A live region announces changes, not its own arrival: a FAB that mounts already
|
|
128
|
+
* saying "Offline" says it to nobody. Keep it mounted and pass `hidden` while there
|
|
129
|
+
* is nothing to report (kastlan returns `null` when online and idle); the region
|
|
130
|
+
* stays in the page, and the first real status is heard.
|
|
131
|
+
*/
|
|
132
|
+
live?: boolean;
|
|
133
|
+
/**
|
|
134
|
+
* `primary` (default): the solid brand disc of a page's main action. `surface`: the
|
|
135
|
+
* page's surface with a hairline border — for a FAB that is a status rather than a
|
|
136
|
+
* call to action, as kastlan's offline pill is (a brand pill would shout "press me"
|
|
137
|
+
* at a user who is merely offline). Ignored while `pressed` is set; a toggle has its
|
|
138
|
+
* own two looks.
|
|
139
|
+
*/
|
|
140
|
+
variant?: "primary" | "surface";
|
|
141
|
+
/**
|
|
142
|
+
* Hover and focus label in the kit {@link Tooltip}, instead of the native `title` —
|
|
143
|
+
* keksdose has ONE tooltip (dev#523) and wrapped its assistant launcher by hand,
|
|
144
|
+
* which cannot work: the tooltip's anchor is a `relative inline-flex` span, and this
|
|
145
|
+
* button is `fixed` and portalled. With `tooltip` the FAB positions a fixed wrapper
|
|
146
|
+
* instead and the button sits in the tooltip inside it. `true` shows the `label`;
|
|
147
|
+
* any other content shows that. Implies no native `title` (whatever `nativeTitle`
|
|
148
|
+
* says): one hover hint, not two. `style` stays on the button; the corner and
|
|
149
|
+
* `offset` move to the wrapper.
|
|
150
|
+
*/
|
|
151
|
+
tooltip?: ReactNode;
|
|
152
|
+
/** Where the {@link tooltip} opens. Default `top`; the bubble is portalled and kept on
|
|
153
|
+
* screen, so a corner button's label is not pushed off the edge. */
|
|
154
|
+
tooltipSide?: TooltipSide;
|
|
92
155
|
ref?: Ref<HTMLButtonElement>;
|
|
93
156
|
}
|
|
94
157
|
|
|
@@ -116,6 +179,11 @@ export function FloatingActionButton({
|
|
|
116
179
|
offset = "1rem",
|
|
117
180
|
nativeTitle = true,
|
|
118
181
|
pressed,
|
|
182
|
+
extended = false,
|
|
183
|
+
live = false,
|
|
184
|
+
variant = "primary",
|
|
185
|
+
tooltip,
|
|
186
|
+
tooltipSide = "top",
|
|
119
187
|
className,
|
|
120
188
|
style,
|
|
121
189
|
type = "button",
|
|
@@ -124,13 +192,12 @@ export function FloatingActionButton({
|
|
|
124
192
|
}: FloatingActionButtonProps) {
|
|
125
193
|
const [marker, dir] = usePortalDir();
|
|
126
194
|
const toggle = pressed !== undefined;
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
createPortal(
|
|
195
|
+
const tip = tooltip === true ? label : tooltip === false || tooltip === "" ? undefined : tooltip;
|
|
196
|
+
const wrapped = tip !== undefined && tip !== null;
|
|
197
|
+
// An empty title is "no tooltip", not an attribute with nothing in it. The kit
|
|
198
|
+
// tooltip replaces it outright.
|
|
199
|
+
const nativeTip = !wrapped && nativeTitle && title !== "" ? (title ?? label) : undefined;
|
|
200
|
+
const button = (
|
|
134
201
|
<button
|
|
135
202
|
{...rest}
|
|
136
203
|
type={type}
|
|
@@ -138,11 +205,19 @@ export function FloatingActionButton({
|
|
|
138
205
|
aria-label={label}
|
|
139
206
|
aria-pressed={pressed ?? rest["aria-pressed"]}
|
|
140
207
|
title={nativeTip}
|
|
141
|
-
style={{ ...cornerStyle(corner, offset), ...style }}
|
|
208
|
+
style={wrapped ? style : { ...cornerStyle(corner, offset), ...style }}
|
|
142
209
|
className={cn(
|
|
143
|
-
|
|
144
|
-
"
|
|
145
|
-
|
|
210
|
+
// Wrapped, the wrapper is the fixed thing and the button sits in it.
|
|
211
|
+
!wrapped && "fixed z-40",
|
|
212
|
+
"inline-flex items-center justify-center rounded-full",
|
|
213
|
+
extended ? "h-12 gap-2 px-4 text-sm font-medium [&_svg]:size-5" : "size-12 [&_svg]:size-6",
|
|
214
|
+
"shadow-lg transition-colors disabled:cursor-default",
|
|
215
|
+
!toggle &&
|
|
216
|
+
variant === "primary" &&
|
|
217
|
+
"bg-[var(--brand)] text-[var(--brand-contrast)] hover:bg-[var(--brand-hover)]",
|
|
218
|
+
!toggle &&
|
|
219
|
+
variant === "surface" &&
|
|
220
|
+
"border border-[var(--border)] bg-[var(--bg-surface)] text-[var(--text-primary)] hover:bg-[var(--bg-hover)] disabled:hover:bg-[var(--bg-surface)]",
|
|
146
221
|
// A toggle sits on the page's surface — a solid brand disc would read as
|
|
147
222
|
// "on" in both states — and says "on" in the brand family.
|
|
148
223
|
toggle && "border border-[var(--border)]",
|
|
@@ -151,12 +226,48 @@ export function FloatingActionButton({
|
|
|
151
226
|
pressed === true &&
|
|
152
227
|
"border-[var(--brand)] bg-[var(--brand-bg)] text-[var(--brand)] hover:bg-[var(--brand-bg-hover)]",
|
|
153
228
|
"outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2",
|
|
154
|
-
"[&_svg]:size-6",
|
|
155
229
|
className,
|
|
156
230
|
)}
|
|
157
231
|
>
|
|
158
232
|
{icon}
|
|
159
|
-
|
|
233
|
+
{/* Visible words, not the name: `aria-label` already is the name, and the
|
|
234
|
+
span would only repeat it. */}
|
|
235
|
+
{extended && (
|
|
236
|
+
<span aria-hidden className="whitespace-nowrap">
|
|
237
|
+
{label}
|
|
238
|
+
</span>
|
|
239
|
+
)}
|
|
240
|
+
</button>
|
|
241
|
+
);
|
|
242
|
+
return (
|
|
243
|
+
<>
|
|
244
|
+
<span ref={marker} hidden />
|
|
245
|
+
{typeof document !== "undefined" &&
|
|
246
|
+
createPortal(
|
|
247
|
+
<>
|
|
248
|
+
{wrapped ? (
|
|
249
|
+
<div
|
|
250
|
+
dir={dir}
|
|
251
|
+
hidden={rest.hidden}
|
|
252
|
+
style={cornerStyle(corner, offset)}
|
|
253
|
+
className="fixed z-40 inline-flex"
|
|
254
|
+
>
|
|
255
|
+
<Tooltip label={tip} side={tooltipSide} portal>
|
|
256
|
+
{/* A fragment when the bubble says the name again, so the tooltip
|
|
257
|
+
does not describe the button with its own label — a reader would
|
|
258
|
+
hear it twice. Other text is a real description. */}
|
|
259
|
+
{tip === label ? <>{button}</> : button}
|
|
260
|
+
</Tooltip>
|
|
261
|
+
</div>
|
|
262
|
+
) : (
|
|
263
|
+
button
|
|
264
|
+
)}
|
|
265
|
+
{live && (
|
|
266
|
+
<span role="status" className="sr-only">
|
|
267
|
+
{label}
|
|
268
|
+
</span>
|
|
269
|
+
)}
|
|
270
|
+
</>,
|
|
160
271
|
document.body,
|
|
161
272
|
)}
|
|
162
273
|
</>
|
|
@@ -183,6 +294,10 @@ export interface FloatingPanelProps {
|
|
|
183
294
|
/** The FAB's native `title` — see {@link FloatingActionButtonProps.nativeTitle}.
|
|
184
295
|
* `false` for keksdose's assistant launcher, under its one-tooltip rule (dev#523). */
|
|
185
296
|
fabNativeTitle?: boolean;
|
|
297
|
+
/** The FAB's kit tooltip — see {@link FloatingActionButtonProps.tooltip}. `true` for
|
|
298
|
+
* keksdose's assistant launcher, which wanted the kit bubble and had no way to
|
|
299
|
+
* put one on a fixed, portalled button (dev#523). */
|
|
300
|
+
fabTooltip?: ReactNode;
|
|
186
301
|
/** Default: `floatingPanel.close` from the {@link UiKitProvider}, else "Close". */
|
|
187
302
|
closeLabel?: string;
|
|
188
303
|
/**
|
|
@@ -239,6 +354,7 @@ export function FloatingPanel({
|
|
|
239
354
|
corner = "bottom-end",
|
|
240
355
|
offset = "1rem",
|
|
241
356
|
fabNativeTitle,
|
|
357
|
+
fabTooltip,
|
|
242
358
|
closeLabel,
|
|
243
359
|
initialFocus,
|
|
244
360
|
className,
|
|
@@ -308,6 +424,7 @@ export function FloatingPanel({
|
|
|
308
424
|
corner={corner}
|
|
309
425
|
offset={offset}
|
|
310
426
|
nativeTitle={fabNativeTitle}
|
|
427
|
+
tooltip={fabTooltip}
|
|
311
428
|
aria-expanded={open}
|
|
312
429
|
aria-controls={open ? panelId : undefined}
|
|
313
430
|
onClick={() => {
|
|
@@ -383,3 +500,237 @@ export function FloatingPanel({
|
|
|
383
500
|
</>
|
|
384
501
|
);
|
|
385
502
|
}
|
|
503
|
+
|
|
504
|
+
export interface FloatingActionGroupProps extends Omit<HTMLAttributes<HTMLDivElement>, "role"> {
|
|
505
|
+
/** Name the group ("Register actions"); a reader then introduces the members as one
|
|
506
|
+
* set. A group with no name is one more unlabelled wrapper. */
|
|
507
|
+
"aria-label"?: string;
|
|
508
|
+
/** Default `bottom-end`. */
|
|
509
|
+
corner?: FloatingCorner;
|
|
510
|
+
/** Distance above the nav / home indicator — see {@link FloatingActionButtonProps.offset}. */
|
|
511
|
+
offset?: string;
|
|
512
|
+
/** {@link FloatingAction}s, in reading order. */
|
|
513
|
+
children: ReactNode;
|
|
514
|
+
ref?: Ref<HTMLDivElement>;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* A floating pill of {@link FloatingAction}s — one to four `size-12` toggles, actions
|
|
519
|
+
* or links with hairline dividers — pinned to a corner exactly as the
|
|
520
|
+
* {@link FloatingActionButton} is: `--app-nav-h` and the home indicator cleared, a
|
|
521
|
+
* logical corner, portalled.
|
|
522
|
+
*
|
|
523
|
+
* keksdose built it twice by hand: the /transactions phone toolbar
|
|
524
|
+
* (transactions-page.tsx:644 — upcoming and pending toggles, an invoices link with a
|
|
525
|
+
* pending count, and the brand "+") and the feedback page's "awaiting only" toggle
|
|
526
|
+
* (feedback-page.tsx:854, one button in the group anyway, so the two pages look
|
|
527
|
+
* alike). Both at `right-4 bottom-20` — `right`, not `end`, so over the page's leading
|
|
528
|
+
* edge in RTL — with `divide-x`, slate hovers, and each member's `Tooltip` spelled out.
|
|
529
|
+
*
|
|
530
|
+
* `role="group"`, NOT a toolbar: every member keeps its own tab stop, as the
|
|
531
|
+
* hand-built ones did, and there is no arrow-key roving to learn. Hide it where it
|
|
532
|
+
* does not belong with a class (`md:hidden`, as both keksdose pages do).
|
|
533
|
+
*/
|
|
534
|
+
export function FloatingActionGroup({
|
|
535
|
+
corner = "bottom-end",
|
|
536
|
+
offset = "1rem",
|
|
537
|
+
className,
|
|
538
|
+
style,
|
|
539
|
+
children,
|
|
540
|
+
...rest
|
|
541
|
+
}: FloatingActionGroupProps) {
|
|
542
|
+
const [marker, dir] = usePortalDir();
|
|
543
|
+
return (
|
|
544
|
+
<>
|
|
545
|
+
<span ref={marker} hidden />
|
|
546
|
+
{typeof document !== "undefined" &&
|
|
547
|
+
createPortal(
|
|
548
|
+
<div
|
|
549
|
+
{...rest}
|
|
550
|
+
role="group"
|
|
551
|
+
dir={dir}
|
|
552
|
+
data-corner={corner}
|
|
553
|
+
style={{ ...cornerStyle(corner, offset), ...style }}
|
|
554
|
+
className={cn(
|
|
555
|
+
"fixed z-40 inline-flex flex-row overflow-hidden rounded-2xl",
|
|
556
|
+
"border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg",
|
|
557
|
+
// Dividers on the logical start edge of every member but the first, so
|
|
558
|
+
// they sit between the same two members in RTL. The member is the
|
|
559
|
+
// tooltip's wrapper span, which is the direct child.
|
|
560
|
+
"[&>*:not(:first-child)]:border-s [&>*]:border-[var(--border)]",
|
|
561
|
+
className,
|
|
562
|
+
)}
|
|
563
|
+
>
|
|
564
|
+
{children}
|
|
565
|
+
</div>,
|
|
566
|
+
document.body,
|
|
567
|
+
)}
|
|
568
|
+
</>
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** What {@link FloatingActionProps.renderLink} is handed. Spread it onto your router's
|
|
573
|
+
* link, mapping `href` — `renderLink={({ href, ...p }) => <Link to={href} {...p} />}`. */
|
|
574
|
+
export interface FloatingActionLinkProps {
|
|
575
|
+
href: string;
|
|
576
|
+
/** The member's whole look — keep it. */
|
|
577
|
+
className: string;
|
|
578
|
+
children: ReactNode;
|
|
579
|
+
"aria-label": string;
|
|
580
|
+
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
581
|
+
[key: `aria-${string}`]: string | boolean | number | undefined;
|
|
582
|
+
[key: `data-${string}`]: unknown;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export interface FloatingActionProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "onClick"> {
|
|
586
|
+
/** The accessible name, and the tooltip's text. Keep it the same in both states of a
|
|
587
|
+
* toggle; `aria-pressed` says which one it is in. */
|
|
588
|
+
label: string;
|
|
589
|
+
icon: ReactNode;
|
|
590
|
+
/** Make it a toggle: `aria-pressed` and the brand "on" look (the brand glyph on the
|
|
591
|
+
* quiet brand fill), as {@link FloatingActionButtonProps.pressed}. Ignored on a link. */
|
|
592
|
+
pressed?: boolean;
|
|
593
|
+
/**
|
|
594
|
+
* `primary`: the brand-filled member, for the group's main action (the /transactions
|
|
595
|
+
* "+"). Default: the quiet one.
|
|
596
|
+
*/
|
|
597
|
+
variant?: "default" | "primary";
|
|
598
|
+
/**
|
|
599
|
+
* A count in a dot at the member's top end corner — the /transactions invoices link's
|
|
600
|
+
* pending count. Nothing is drawn for `0` or less; above 99 it reads `99+`. The count
|
|
601
|
+
* joins the accessible name through `floatingPanel.badge` ("Invoices, 3 new"), or
|
|
602
|
+
* `badgeLabel` when passed.
|
|
603
|
+
*/
|
|
604
|
+
badge?: number;
|
|
605
|
+
/** The badge's words for a reader, replacing `floatingPanel.badge` — "3 to review". */
|
|
606
|
+
badgeLabel?: string;
|
|
607
|
+
/** The dot's colour. Default `warning`, the pending count's. */
|
|
608
|
+
badgeTone?: "warning" | "danger" | "brand";
|
|
609
|
+
/** Show `label` in the kit {@link Tooltip} on hover and focus. Default `true`: the
|
|
610
|
+
* members are icon-only and this is their only visible label. */
|
|
611
|
+
tooltip?: boolean;
|
|
612
|
+
/** Where the tooltip opens. Default `top` — the group sits at the bottom of the screen. */
|
|
613
|
+
tooltipSide?: TooltipSide;
|
|
614
|
+
/** Make it a link — the /transactions invoices member. A plain `<a>` unless
|
|
615
|
+
* `renderLink` hands it to your router. */
|
|
616
|
+
href?: string;
|
|
617
|
+
/** Render the link through your router's `Link` — see {@link FloatingActionLinkProps}. */
|
|
618
|
+
renderLink?: (props: FloatingActionLinkProps) => ReactElement;
|
|
619
|
+
onClick?: (event: MouseEvent<HTMLElement>) => void;
|
|
620
|
+
ref?: Ref<HTMLButtonElement>;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const BADGE_TONE: Record<NonNullable<FloatingActionProps["badgeTone"]>, string> = {
|
|
624
|
+
warning: "bg-[var(--warning)] text-[var(--text-inverse)]",
|
|
625
|
+
danger: "bg-[var(--danger)] text-[var(--danger-contrast)]",
|
|
626
|
+
brand: "bg-[var(--brand)] text-[var(--brand-contrast)]",
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* One member of a {@link FloatingActionGroup}: a `size-12` icon button, toggle or link
|
|
631
|
+
* with its label in a styled tooltip — what each keksdose member wrapped by hand in
|
|
632
|
+
* `<Tooltip portal>`. The tooltip is portalled because the group clips (`overflow-hidden`
|
|
633
|
+
* keeps the brand member's fill inside the pill's corners), and the focus ring is inset
|
|
634
|
+
* for the same reason.
|
|
635
|
+
*/
|
|
636
|
+
export function FloatingAction({
|
|
637
|
+
label,
|
|
638
|
+
icon,
|
|
639
|
+
pressed,
|
|
640
|
+
variant = "default",
|
|
641
|
+
badge,
|
|
642
|
+
badgeLabel,
|
|
643
|
+
badgeTone = "warning",
|
|
644
|
+
tooltip = true,
|
|
645
|
+
tooltipSide = "top",
|
|
646
|
+
href,
|
|
647
|
+
renderLink,
|
|
648
|
+
className,
|
|
649
|
+
type = "button",
|
|
650
|
+
onClick,
|
|
651
|
+
ref,
|
|
652
|
+
...rest
|
|
653
|
+
}: FloatingActionProps) {
|
|
654
|
+
const labels = useKitLabels("floatingPanel", DEFAULT_FLOATING_PANEL_LABELS);
|
|
655
|
+
const counted = badge !== undefined && badge > 0;
|
|
656
|
+
const name = counted ? `${label}, ${badgeLabel ?? labels.badge(badge)}` : label;
|
|
657
|
+
const isLink = href !== undefined;
|
|
658
|
+
const look = cn(
|
|
659
|
+
"relative flex size-12 items-center justify-center transition-colors",
|
|
660
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)]",
|
|
661
|
+
"disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-5",
|
|
662
|
+
variant === "primary"
|
|
663
|
+
? "bg-[var(--brand)] text-[var(--brand-contrast)] hover:bg-[var(--brand-hover)]"
|
|
664
|
+
: pressed && !isLink
|
|
665
|
+
? "bg-[var(--brand-bg)] text-[var(--brand)] hover:bg-[var(--brand-bg-hover)]"
|
|
666
|
+
: "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]",
|
|
667
|
+
className,
|
|
668
|
+
);
|
|
669
|
+
const body = (
|
|
670
|
+
<>
|
|
671
|
+
{icon}
|
|
672
|
+
{counted && (
|
|
673
|
+
<span
|
|
674
|
+
aria-hidden
|
|
675
|
+
data-badge=""
|
|
676
|
+
className={cn(
|
|
677
|
+
"pointer-events-none absolute end-1 top-1 h-4 min-w-4 rounded-full px-1 text-center text-[10px] leading-4 font-semibold",
|
|
678
|
+
BADGE_TONE[badgeTone],
|
|
679
|
+
)}
|
|
680
|
+
>
|
|
681
|
+
{badge > 99 ? "99+" : badge}
|
|
682
|
+
</span>
|
|
683
|
+
)}
|
|
684
|
+
</>
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
let member: ReactElement;
|
|
688
|
+
if (isLink) {
|
|
689
|
+
// The button-only attributes stay behind; `aria-*`, `data-*` and the like ride along.
|
|
690
|
+
const { disabled: _disabled, form: _form, value: _value, name: _name, ...anchorRest } = rest;
|
|
691
|
+
const linkProps: FloatingActionLinkProps = {
|
|
692
|
+
...(anchorRest as AnchorHTMLAttributes<HTMLAnchorElement>),
|
|
693
|
+
href,
|
|
694
|
+
"aria-label": name,
|
|
695
|
+
onClick,
|
|
696
|
+
className: look,
|
|
697
|
+
children: body,
|
|
698
|
+
};
|
|
699
|
+
const { children: content, ...anchor } = linkProps;
|
|
700
|
+
member = renderLink ? (
|
|
701
|
+
<RenderedFloatingLink render={renderLink} {...linkProps} />
|
|
702
|
+
) : (
|
|
703
|
+
<a {...anchor}>{content}</a>
|
|
704
|
+
);
|
|
705
|
+
} else {
|
|
706
|
+
member = (
|
|
707
|
+
<button
|
|
708
|
+
{...rest}
|
|
709
|
+
ref={ref}
|
|
710
|
+
type={type}
|
|
711
|
+
aria-label={name}
|
|
712
|
+
aria-pressed={pressed ?? rest["aria-pressed"]}
|
|
713
|
+
onClick={onClick}
|
|
714
|
+
className={look}
|
|
715
|
+
>
|
|
716
|
+
{body}
|
|
717
|
+
</button>
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
if (!tooltip) return member;
|
|
721
|
+
return (
|
|
722
|
+
// A fragment: the bubble says the member's name, and a description that repeats
|
|
723
|
+
// the name is read twice.
|
|
724
|
+
<Tooltip label={label} side={tooltipSide} portal>
|
|
725
|
+
<>{member}</>
|
|
726
|
+
</Tooltip>
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** Calls `renderLink` as a component, so its hooks belong to the link, not to us. */
|
|
731
|
+
function RenderedFloatingLink({
|
|
732
|
+
render,
|
|
733
|
+
...props
|
|
734
|
+
}: FloatingActionLinkProps & { render: (props: FloatingActionLinkProps) => ReactElement }) {
|
|
735
|
+
return render(props);
|
|
736
|
+
}
|
package/src/components/list.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createContext, forwardRef, useContext } from "react";
|
|
2
|
-
import type { ComponentPropsWithoutRef, MouseEvent, ReactElement, ReactNode, Ref } from "react";
|
|
2
|
+
import type { ComponentPropsWithoutRef, HTMLAttributes, MouseEvent, ReactElement, ReactNode, Ref } from "react";
|
|
3
3
|
import { ExternalLink } from "lucide-react";
|
|
4
4
|
import type { LucideIcon } from "lucide-react";
|
|
5
5
|
import { cn } from "../lib/cn";
|
|
@@ -165,6 +165,19 @@ interface ListItemBaseProps {
|
|
|
165
165
|
className?: string;
|
|
166
166
|
/** Classes on the main target (the button, link or static block). */
|
|
167
167
|
targetClassName?: string;
|
|
168
|
+
/**
|
|
169
|
+
* Extra attributes and handlers for the main target — e.g. HTML5 drag-and-drop
|
|
170
|
+
* (`draggable`, `onDragStart`, `onDragEnd`): lenkbank's segment list drags the WHOLE
|
|
171
|
+
* 44px row, and a grip inside the button cannot start a drag in Firefox. Spread first,
|
|
172
|
+
* so the row's own role, name, state and click handling always win.
|
|
173
|
+
*/
|
|
174
|
+
targetProps?: HTMLAttributes<HTMLElement>;
|
|
175
|
+
/**
|
|
176
|
+
* A visible border while unselected, instead of the transparent one. For rows laid out
|
|
177
|
+
* as a wrapping strip (lenkbank's profile bar), where borderless rows read as loose
|
|
178
|
+
* text. The selected row's brand border is unchanged.
|
|
179
|
+
*/
|
|
180
|
+
bordered?: boolean;
|
|
168
181
|
/** `div` for a single row outside a {@link List}; an `<li>` must sit in a list. */
|
|
169
182
|
as?: "li" | "div";
|
|
170
183
|
/** Reaches the main target — the element with the row's name and action. */
|
|
@@ -250,6 +263,8 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
|
|
|
250
263
|
align = "center",
|
|
251
264
|
className,
|
|
252
265
|
targetClassName,
|
|
266
|
+
targetProps,
|
|
267
|
+
bordered = false,
|
|
253
268
|
as = "li",
|
|
254
269
|
href,
|
|
255
270
|
renderLink,
|
|
@@ -328,9 +343,14 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
|
|
|
328
343
|
targetClassName,
|
|
329
344
|
);
|
|
330
345
|
|
|
346
|
+
// `role` is the row's own (a button, a link, or nothing on a static block): a
|
|
347
|
+
// targetProps role would silently turn the row into something else.
|
|
348
|
+
const { role: _ignoredRole, ...targetExtras } = targetProps ?? {};
|
|
349
|
+
|
|
331
350
|
let main: ReactNode;
|
|
332
351
|
if (isLink) {
|
|
333
352
|
const linkProps: ListItemLinkProps = {
|
|
353
|
+
...(targetExtras as Partial<ListItemLinkProps>),
|
|
334
354
|
...rest,
|
|
335
355
|
ref: ref as Ref<HTMLAnchorElement>,
|
|
336
356
|
href,
|
|
@@ -354,6 +374,7 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
|
|
|
354
374
|
} else if (isButton) {
|
|
355
375
|
main = (
|
|
356
376
|
<button
|
|
377
|
+
{...(targetExtras as HTMLAttributes<HTMLButtonElement>)}
|
|
357
378
|
{...rest}
|
|
358
379
|
ref={ref as Ref<HTMLButtonElement>}
|
|
359
380
|
type="button"
|
|
@@ -372,6 +393,7 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
|
|
|
372
393
|
} else {
|
|
373
394
|
main = (
|
|
374
395
|
<div
|
|
396
|
+
{...(targetExtras as HTMLAttributes<HTMLDivElement>)}
|
|
375
397
|
{...rest}
|
|
376
398
|
ref={ref as Ref<HTMLDivElement>}
|
|
377
399
|
aria-disabled={disabled || undefined}
|
|
@@ -394,7 +416,11 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
|
|
|
394
416
|
<div
|
|
395
417
|
className={cn(
|
|
396
418
|
"flex min-w-0 items-center rounded-md border transition-colors",
|
|
397
|
-
selected
|
|
419
|
+
selected
|
|
420
|
+
? "border-[var(--brand)] bg-[var(--bg-surface-2)]"
|
|
421
|
+
: bordered
|
|
422
|
+
? "border-[var(--border)]"
|
|
423
|
+
: "border-transparent",
|
|
398
424
|
interactive && !inert && !selected && "hover:bg-[var(--bg-hover)]",
|
|
399
425
|
disabled && "opacity-50",
|
|
400
426
|
className,
|