@elabs-ai/components-ui 4.1.0 → 4.2.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/dist/{chunk-JKPPTL63.js → chunk-FCH4ZN6G.js} +12 -3
- package/dist/chunk-FCH4ZN6G.js.map +1 -0
- package/dist/form.js +3 -3
- package/dist/form.js.map +1 -1
- package/dist/index.d.ts +126 -35
- package/dist/index.js +3346 -2079
- package/dist/index.js.map +1 -1
- package/dist/lib/cn.js +10 -1
- package/dist/lib/cn.js.map +1 -1
- package/package.json +3 -3
- package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
- package/src/__contract__/badge.contract.test.tsx +49 -0
- package/src/__contract__/bounded-number.contract.test.tsx +49 -0
- package/src/__contract__/button.contract.test.tsx +49 -0
- package/src/__contract__/color-picker.contract.test.tsx +49 -0
- package/src/__contract__/combobox.contract.test.tsx +49 -0
- package/src/__contract__/command-trigger.contract.test.tsx +49 -0
- package/src/__contract__/copyable-value.contract.test.tsx +49 -0
- package/src/__contract__/date-picker.contract.test.tsx +49 -0
- package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
- package/src/__contract__/empty-state.contract.test.tsx +49 -0
- package/src/__contract__/error-state.contract.test.tsx +49 -0
- package/src/__contract__/icon-button.contract.test.tsx +49 -0
- package/src/__contract__/input.contract.test.tsx +49 -0
- package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
- package/src/__contract__/list-editor.contract.test.tsx +49 -0
- package/src/__contract__/loading-state.contract.test.tsx +49 -0
- package/src/__contract__/metric-card.contract.test.tsx +49 -0
- package/src/__contract__/model-picker.contract.test.tsx +49 -0
- package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
- package/src/__contract__/number-input.contract.test.tsx +49 -0
- package/src/__contract__/rating.contract.test.tsx +49 -0
- package/src/__contract__/schema-form.contract.test.tsx +49 -0
- package/src/__contract__/section-header.contract.test.tsx +49 -0
- package/src/__contract__/segmented-field.contract.test.tsx +49 -0
- package/src/__contract__/slider-number.contract.test.tsx +49 -0
- package/src/__contract__/status-badge.contract.test.tsx +49 -0
- package/src/__contract__/tag-input.contract.test.tsx +49 -0
- package/src/__contract__/text.contract.test.tsx +49 -0
- package/src/__contract__/textarea.contract.test.tsx +49 -0
- package/src/__contract__/timeline.contract.test.tsx +49 -0
- package/src/__contract__/toggle.contract.test.tsx +49 -0
- package/src/__contract__/transfer.contract.test.tsx +49 -0
- package/src/__contract__/tree-select.contract.test.tsx +49 -0
- package/src/__contract__/tree.contract.test.tsx +49 -0
- package/src/__contract__/virtual-select.contract.test.tsx +49 -0
- package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
- package/src/components/accordion/accordion.tsx +13 -4
- package/src/components/alert/alert.tsx +11 -3
- package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
- package/src/components/alert-dialog/alert-dialog.tsx +28 -6
- package/src/components/app-shell/app-shell.test.tsx +99 -0
- package/src/components/app-shell/app-shell.tsx +29 -10
- package/src/components/attribution-panel/attribution-panel.tsx +2 -0
- package/src/components/attribution-panel/attributions.generated.ts +2 -2
- package/src/components/avatar/avatar.tsx +4 -1
- package/src/components/badge/badge.test.tsx +8 -0
- package/src/components/badge/badge.tsx +14 -4
- package/src/components/bento-grid/bento-grid.test.tsx +23 -2
- package/src/components/bento-grid/bento-grid.tsx +10 -0
- package/src/components/bounded-number/bounded-number.test.tsx +1 -1
- package/src/components/bounded-number/bounded-number.tsx +78 -75
- package/src/components/breadcrumb/breadcrumb.tsx +3 -2
- package/src/components/button/button.tsx +18 -12
- package/src/components/button-group/button-group.tsx +1 -1
- package/src/components/calendar/calendar.tsx +3 -2
- package/src/components/card/card.test.tsx +24 -2
- package/src/components/card/card.tsx +47 -6
- package/src/components/carousel/carousel.test.tsx +169 -0
- package/src/components/carousel/carousel.tsx +93 -32
- package/src/components/change-review/change-review.tsx +38 -29
- package/src/components/checkbox/checkbox.tsx +1 -0
- package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
- package/src/components/color-picker/color-picker.tsx +17 -11
- package/src/components/combobox/combobox.stories.tsx +10 -0
- package/src/components/combobox/combobox.test.tsx +54 -0
- package/src/components/combobox/combobox.tsx +25 -11
- package/src/components/command/command.test.tsx +15 -0
- package/src/components/command/command.tsx +8 -6
- package/src/components/context-menu/context-menu.tsx +16 -7
- package/src/components/context-rail/context-rail.tsx +4 -4
- package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
- package/src/components/copyable-value/copyable-value.test.tsx +28 -1
- package/src/components/copyable-value/copyable-value.tsx +12 -1
- package/src/components/date-picker/date-picker.stories.tsx +9 -0
- package/src/components/date-picker/date-picker.test.tsx +52 -0
- package/src/components/date-picker/date-picker.tsx +13 -15
- package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
- package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
- package/src/components/date-range-picker/date-range-picker.tsx +39 -28
- package/src/components/descriptions/descriptions.tsx +1 -1
- package/src/components/dialog/dialog.test.tsx +8 -3
- package/src/components/dialog/dialog.tsx +12 -4
- package/src/components/drawer/drawer.tsx +23 -6
- package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
- package/src/components/field/field.tsx +2 -0
- package/src/components/field-row/field-row.tsx +2 -0
- package/src/components/file-upload/file-upload.test.tsx +83 -10
- package/src/components/file-upload/file-upload.tsx +150 -28
- package/src/components/form/form.tsx +4 -2
- package/src/components/hover-card/hover-card.tsx +1 -0
- package/src/components/icon-button/icon-button.tsx +1 -1
- package/src/components/input/input.tsx +7 -3
- package/src/components/input-group/input-group.tsx +6 -6
- package/src/components/input-otp/input-otp.tsx +3 -1
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +2 -2
- package/src/components/label/label.tsx +1 -1
- package/src/components/list-editor/list-editor.test.tsx +24 -0
- package/src/components/list-editor/list-editor.tsx +126 -52
- package/src/components/locale-provider/locale-keys.test.ts +134 -0
- package/src/components/locale-provider/messages.ts +321 -0
- package/src/components/mention-input/mention-input.stories.tsx +3 -3
- package/src/components/mention-input/mention-input.test.tsx +51 -1
- package/src/components/mention-input/mention-input.tsx +58 -8
- package/src/components/menubar/menubar.tsx +16 -6
- package/src/components/nav-main/nav-main.tsx +7 -3
- package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
- package/src/components/nav-notifications/nav-notifications.tsx +11 -5
- package/src/components/nav-user/nav-user.tsx +5 -5
- package/src/components/navigation-menu/navigation-menu.tsx +16 -4
- package/src/components/number-input/number-input.stories.tsx +7 -3
- package/src/components/number-input/number-input.test.tsx +47 -0
- package/src/components/number-input/number-input.tsx +65 -24
- package/src/components/pagination/pagination.test.tsx +20 -1
- package/src/components/pagination/pagination.tsx +18 -14
- package/src/components/popover/popover.tsx +1 -0
- package/src/components/progress/progress.tsx +2 -0
- package/src/components/rating/rating.tsx +2 -0
- package/src/components/resizable/resizable.test.tsx +76 -0
- package/src/components/resizable/resizable.tsx +31 -5
- package/src/components/revision-timeline/revision-timeline.tsx +7 -2
- package/src/components/schema-form/schema-form-store.ts +506 -0
- package/src/components/schema-form/schema-form.test.tsx +122 -1
- package/src/components/schema-form/schema-form.tsx +163 -327
- package/src/components/section-header/section-header.tsx +2 -2
- package/src/components/select/select.tsx +7 -5
- package/src/components/separator/separator.tsx +1 -0
- package/src/components/sheet/sheet.test.tsx +39 -0
- package/src/components/sheet/sheet.tsx +40 -9
- package/src/components/side-dock/side-dock.test.tsx +26 -0
- package/src/components/side-dock/side-dock.tsx +15 -2
- package/src/components/sidebar/sidebar.test.tsx +35 -1
- package/src/components/sidebar/sidebar.tsx +20 -6
- package/src/components/skeleton/skeleton.tsx +1 -0
- package/src/components/slider-number/slider-number.stories.tsx +4 -1
- package/src/components/slider-number/slider-number.test.tsx +1 -1
- package/src/components/sonner/sonner.tsx +2 -0
- package/src/components/spinner/spinner.test.tsx +18 -0
- package/src/components/spinner/spinner.tsx +14 -4
- package/src/components/state-panel/state-panel.test.tsx +3 -3
- package/src/components/state-panel/state-panel.tsx +15 -8
- package/src/components/switch/switch.tsx +2 -0
- package/src/components/table/table.stories.tsx +3 -1
- package/src/components/table/table.test.tsx +9 -2
- package/src/components/table/table.tsx +18 -8
- package/src/components/tabs/index.ts +10 -1
- package/src/components/tabs/tabs.stories.tsx +48 -0
- package/src/components/tabs/tabs.test.tsx +67 -0
- package/src/components/tabs/tabs.tsx +175 -77
- package/src/components/tag-input/tag-input.tsx +4 -2
- package/src/components/team-switcher/team-switcher.tsx +3 -3
- package/src/components/textarea/textarea.tsx +1 -1
- package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
- package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
- package/src/components/theme-switcher/theme-switcher.tsx +134 -10
- package/src/components/timeline/timeline.tsx +0 -3
- package/src/components/toggle/toggle.tsx +7 -2
- package/src/components/toggle-group/toggle-group.tsx +2 -0
- package/src/components/tooltip/tooltip.tsx +2 -1
- package/src/components/top-nav/top-nav.tsx +1 -1
- package/src/components/transfer/transfer.tsx +35 -30
- package/src/components/tree/tree.test.tsx +41 -0
- package/src/components/tree/tree.tsx +77 -22
- package/src/components/tree/use-tree-keyboard.ts +37 -14
- package/src/components/tree-select/tree-select.stories.tsx +9 -0
- package/src/components/tree-select/tree-select.test.tsx +5 -0
- package/src/components/tree-select/tree-select.tsx +11 -14
- package/src/components/typography/prose.test.tsx +3 -2
- package/src/components/typography/prose.tsx +3 -3
- package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
- package/src/components/virtual-select/virtual-select.test.tsx +28 -2
- package/src/components/virtual-select/virtual-select.tsx +56 -18
- package/src/components/wizard/wizard.test.tsx +9 -0
- package/src/components/wizard/wizard.tsx +12 -4
- package/src/index.ts +5 -0
- package/src/lib/cn.ts +9 -0
- package/src/lib/csv.ts +48 -0
- package/src/lib/diff-rows.ts +2 -0
- package/src/lib/merge-refs.ts +2 -0
- package/src/lib/use-controllable-state.ts +47 -0
- package/src/lib/use-mobile.test.tsx +47 -0
- package/src/lib/use-mobile.ts +26 -10
- package/dist/chunk-JKPPTL63.js.map +0 -1
|
@@ -28,83 +28,86 @@ export interface BoundedNumberProps extends NumberInputProps {
|
|
|
28
28
|
* `null` AND the field is not focused, so the underlying input stays fully
|
|
29
29
|
* clickable/typable and still round-trips to `null` on an empty blur.
|
|
30
30
|
*/
|
|
31
|
-
export const BoundedNumber = forwardRef<
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
ref,
|
|
43
|
-
) {
|
|
44
|
-
const { t } = useLocale();
|
|
45
|
-
const resolvedEmptyLabel = emptyLabel ?? t("ui.boundedNumber.emptyLabel");
|
|
46
|
-
const isControlled = valueProp !== undefined;
|
|
47
|
-
const [internalValue, setInternalValue] = useState<number | null>(
|
|
48
|
-
defaultValue !== undefined ? defaultValue : null,
|
|
49
|
-
);
|
|
50
|
-
const [focused, setFocused] = useState(false);
|
|
51
|
-
const currentValue = isControlled ? valueProp : internalValue;
|
|
52
|
-
|
|
53
|
-
const handleValueChange = useCallback(
|
|
54
|
-
(next: number | null) => {
|
|
55
|
-
if (!isControlled) setInternalValue(next);
|
|
56
|
-
onValueChange?.(next);
|
|
31
|
+
export const BoundedNumber = forwardRef<HTMLInputElement, BoundedNumberProps>(
|
|
32
|
+
function BoundedNumber(
|
|
33
|
+
{
|
|
34
|
+
value: valueProp,
|
|
35
|
+
defaultValue,
|
|
36
|
+
onValueChange,
|
|
37
|
+
emptyLabel,
|
|
38
|
+
onFocus,
|
|
39
|
+
onBlur,
|
|
40
|
+
className,
|
|
41
|
+
...props
|
|
57
42
|
},
|
|
58
|
-
|
|
59
|
-
)
|
|
43
|
+
ref,
|
|
44
|
+
) {
|
|
45
|
+
const { t } = useLocale();
|
|
46
|
+
const resolvedEmptyLabel = emptyLabel ?? t("ui.boundedNumber.emptyLabel");
|
|
47
|
+
const isControlled = valueProp !== undefined;
|
|
48
|
+
const [internalValue, setInternalValue] = useState<number | null>(
|
|
49
|
+
defaultValue !== undefined ? defaultValue : null,
|
|
50
|
+
);
|
|
51
|
+
const [focused, setFocused] = useState(false);
|
|
52
|
+
const currentValue = isControlled ? valueProp : internalValue;
|
|
60
53
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
54
|
+
const handleValueChange = useCallback(
|
|
55
|
+
(next: number | null) => {
|
|
56
|
+
if (!isControlled) setInternalValue(next);
|
|
57
|
+
onValueChange?.(next);
|
|
58
|
+
},
|
|
59
|
+
[isControlled, onValueChange],
|
|
60
|
+
);
|
|
68
61
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
62
|
+
const handleFocus = useCallback(
|
|
63
|
+
(event: FocusEvent<HTMLDivElement>) => {
|
|
64
|
+
setFocused(true);
|
|
65
|
+
onFocus?.(event);
|
|
66
|
+
},
|
|
67
|
+
[onFocus],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const handleBlur = useCallback(
|
|
71
|
+
(event: FocusEvent<HTMLDivElement>) => {
|
|
72
|
+
setFocused(false);
|
|
73
|
+
onBlur?.(event);
|
|
74
|
+
},
|
|
75
|
+
[onBlur],
|
|
76
|
+
);
|
|
76
77
|
|
|
77
|
-
|
|
78
|
+
const showEmptyLabel = currentValue == null && !focused;
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
80
|
+
return (
|
|
81
|
+
// `className` lands on the ROOT (the positioning context), not on the
|
|
82
|
+
// inner `NumberInput`: a width utility on the inner control would resolve
|
|
83
|
+
// against a shrink-to-fit wrapper and silently do nothing, so
|
|
84
|
+
// `className="w-full"` could never widen the field. `w-36` is this
|
|
85
|
+
// preset's own default width (`NumberInput` itself is unconstrained and
|
|
86
|
+
// fills its container) — `cn()` lets the caller override it, and the
|
|
87
|
+
// inner `NumberInput` simply fills the root via `className="w-full"`.
|
|
88
|
+
<div className={cn("relative inline-block w-36", className)} data-slot="bounded-number">
|
|
89
|
+
<NumberInput
|
|
90
|
+
ref={ref}
|
|
91
|
+
value={currentValue}
|
|
92
|
+
onValueChange={handleValueChange}
|
|
93
|
+
onFocus={handleFocus}
|
|
94
|
+
onBlur={handleBlur}
|
|
95
|
+
className="w-full"
|
|
96
|
+
{...props}
|
|
97
|
+
/>
|
|
98
|
+
{showEmptyLabel && (
|
|
99
|
+
<span
|
|
100
|
+
aria-hidden="true"
|
|
101
|
+
data-slot="bounded-number-empty-label"
|
|
102
|
+
className={cn(
|
|
103
|
+
"pointer-events-none absolute inset-0 flex items-center justify-center",
|
|
104
|
+
"text-body text-muted-foreground",
|
|
105
|
+
)}
|
|
106
|
+
>
|
|
107
|
+
{resolvedEmptyLabel}
|
|
108
|
+
</span>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
},
|
|
113
|
+
);
|
|
@@ -6,7 +6,8 @@ import { useLocale } from "../locale-provider";
|
|
|
6
6
|
|
|
7
7
|
export const Breadcrumb = forwardRef<HTMLElement, ComponentProps<"nav">>(
|
|
8
8
|
function Breadcrumb(props, ref) {
|
|
9
|
-
|
|
9
|
+
const { t } = useLocale();
|
|
10
|
+
return <nav ref={ref} aria-label={t("ui.breadcrumb.label")} {...props} />;
|
|
10
11
|
},
|
|
11
12
|
);
|
|
12
13
|
export const BreadcrumbList = forwardRef<HTMLOListElement, ComponentProps<"ol">>(
|
|
@@ -15,7 +16,7 @@ export const BreadcrumbList = forwardRef<HTMLOListElement, ComponentProps<"ol">>
|
|
|
15
16
|
<ol
|
|
16
17
|
ref={ref}
|
|
17
18
|
className={cn(
|
|
18
|
-
"flex flex-wrap items-center gap-1.5 break-words text-
|
|
19
|
+
"flex flex-wrap items-center gap-1.5 break-words text-body text-muted-foreground",
|
|
19
20
|
className,
|
|
20
21
|
)}
|
|
21
22
|
{...props}
|
|
@@ -4,15 +4,21 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|
|
4
4
|
import { cn } from "../../lib/cn";
|
|
5
5
|
|
|
6
6
|
export const buttonVariants = cva(
|
|
7
|
-
"inline-flex items-center justify-center gap-2 whitespace-nowrap touch-manipulation rounded-
|
|
7
|
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap touch-manipulation rounded-control text-body font-control transition-[color,background-color,border-color,box-shadow,scale] duration-fast ease-standard active:scale-[0.98] motion-reduce:active:scale-100 focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
|
8
8
|
{
|
|
9
9
|
variants: {
|
|
10
10
|
variant: {
|
|
11
|
+
// Disabled treatment is ONE rule for every variant, inherited from the
|
|
12
|
+
// base string above (`disabled:pointer-events-none disabled:opacity-50`)
|
|
13
|
+
// — no per-variant fill swap. A solid-fill variant fading via opacity
|
|
14
|
+
// still reads as "off" against `--muted`/`--background` in both themes;
|
|
15
|
+
// keeping one mechanism (vs. a bg-muted swap on some variants only) is
|
|
16
|
+
// what made Input's disabled state internally inconsistent (#286).
|
|
11
17
|
default:
|
|
12
|
-
"bg-primary text-primary-foreground hover:bg-primary
|
|
18
|
+
"bg-primary text-primary-foreground hover:bg-primary-hover active:bg-primary-active",
|
|
13
19
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
14
20
|
destructive:
|
|
15
|
-
"bg-destructive text-destructive-foreground hover:bg-destructive
|
|
21
|
+
"bg-destructive text-destructive-foreground hover:bg-destructive-hover active:bg-destructive-active",
|
|
16
22
|
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
|
17
23
|
// The calm "outlined but quiet" rung (#194, research 02 §3a). `outline` uses
|
|
18
24
|
// the form-field `border-input` token, `outline-subtle` uses `border-border`.
|
|
@@ -24,17 +30,17 @@ export const buttonVariants = cva(
|
|
|
24
30
|
"outline-subtle":
|
|
25
31
|
"border border-border bg-background hover:bg-accent hover:text-accent-foreground",
|
|
26
32
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
27
|
-
// #399 — a link button is TEXT on the page
|
|
28
|
-
//
|
|
29
|
-
link: "text-
|
|
33
|
+
// #399 — a link button is TEXT on the page: `--link` defaults to the
|
|
34
|
+
// on-surface `--primary-text` rung; `bg-primary` above keeps the fill rung.
|
|
35
|
+
link: "text-link underline-offset-4 hover:underline",
|
|
30
36
|
},
|
|
31
37
|
size: {
|
|
32
|
-
sm: "h-
|
|
33
|
-
default: "h-
|
|
34
|
-
lg: "h-
|
|
35
|
-
icon: "size-
|
|
36
|
-
"icon-sm": "size-
|
|
37
|
-
"icon-lg": "size-
|
|
38
|
+
sm: "h-control-sm px-3 text-meta",
|
|
39
|
+
default: "h-control px-4 py-2",
|
|
40
|
+
lg: "h-control-lg px-6",
|
|
41
|
+
icon: "size-control",
|
|
42
|
+
"icon-sm": "size-control-sm",
|
|
43
|
+
"icon-lg": "size-control-lg",
|
|
38
44
|
},
|
|
39
45
|
},
|
|
40
46
|
defaultVariants: { variant: "default", size: "default" },
|
|
@@ -44,7 +44,7 @@ export const ButtonGroupText = forwardRef<
|
|
|
44
44
|
<Comp
|
|
45
45
|
ref={ref}
|
|
46
46
|
className={cn(
|
|
47
|
-
"flex items-center gap-2 rounded-md border bg-muted px-4 text-
|
|
47
|
+
"flex items-center gap-2 rounded-md border bg-muted px-4 text-body font-medium shadow-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
|
48
48
|
className,
|
|
49
49
|
)}
|
|
50
50
|
{...props}
|
|
@@ -14,6 +14,7 @@ export function Calendar({
|
|
|
14
14
|
}: CalendarProps) {
|
|
15
15
|
return (
|
|
16
16
|
<DayPicker
|
|
17
|
+
data-slot="calendar"
|
|
17
18
|
showOutsideDays={showOutsideDays}
|
|
18
19
|
className={cn("p-3", className)}
|
|
19
20
|
classNames={{
|
|
@@ -26,7 +27,7 @@ export function Calendar({
|
|
|
26
27
|
// for a taller caption (e.g. `captionLayout="dropdown"`), and `px-8` keeps
|
|
27
28
|
// the label clear of the buttons overlaying either edge.
|
|
28
29
|
month_caption: "flex min-h-7 items-center justify-center px-8",
|
|
29
|
-
caption_label: "text-
|
|
30
|
+
caption_label: "text-body font-medium",
|
|
30
31
|
// Overlays the caption row and pushes the buttons to either edge; the
|
|
31
32
|
// buttons themselves stay in normal flow inside it.
|
|
32
33
|
nav: "absolute inset-x-0 top-0 flex h-7 items-center justify-between",
|
|
@@ -42,7 +43,7 @@ export function Calendar({
|
|
|
42
43
|
weekdays: "flex",
|
|
43
44
|
weekday: "w-8 rounded-md text-[0.8rem] font-normal text-muted-foreground",
|
|
44
45
|
week: "mt-2 flex w-full",
|
|
45
|
-
day: "relative size-8 p-0 text-center text-
|
|
46
|
+
day: "relative size-8 p-0 text-center text-body focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-e-md first:[&:has([aria-selected])]:rounded-s-md last:[&:has([aria-selected])]:rounded-e-md",
|
|
46
47
|
day_button: cn(
|
|
47
48
|
buttonVariants({ variant: "ghost" }),
|
|
48
49
|
"size-8 p-0 font-normal aria-selected:opacity-100",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { render, screen } from "@testing-library/react";
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
3
3
|
import { Card, CardDescription, CardTitle } from "./card";
|
|
4
4
|
|
|
5
5
|
describe("Card", () => {
|
|
@@ -103,6 +103,28 @@ describe("Card", () => {
|
|
|
103
103
|
expect(region).toHaveAttribute("aria-hidden", "true");
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
+
it("with `detail` + hover, cancels its pending conceal rAF on unmount", () => {
|
|
107
|
+
const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockReturnValue(42);
|
|
108
|
+
const cafSpy = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
|
|
109
|
+
const { unmount } = render(
|
|
110
|
+
<Card data-testid="card" detailReveal="hover" detail={<button>More</button>}>
|
|
111
|
+
main
|
|
112
|
+
</Card>,
|
|
113
|
+
);
|
|
114
|
+
const card = screen.getByTestId("card");
|
|
115
|
+
// focus-out schedules the conceal rAF this test is locking cleanup for.
|
|
116
|
+
fireEvent.focusOut(card);
|
|
117
|
+
expect(rafSpy).toHaveBeenCalled();
|
|
118
|
+
|
|
119
|
+
unmount();
|
|
120
|
+
// Without cancelling it, the scheduled frame is still pending after
|
|
121
|
+
// teardown and fires against a component that no longer exists.
|
|
122
|
+
expect(cafSpy).toHaveBeenCalledWith(42);
|
|
123
|
+
|
|
124
|
+
rafSpy.mockRestore();
|
|
125
|
+
cafSpy.mockRestore();
|
|
126
|
+
});
|
|
127
|
+
|
|
106
128
|
it("with `detail` + hover, detail region is labelled (landmark navigation, #141)", () => {
|
|
107
129
|
render(
|
|
108
130
|
<Card detailReveal="hover" detail={<span>info</span>} detailLabel="Card details">
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
forwardRef,
|
|
5
|
+
useEffect,
|
|
5
6
|
useRef,
|
|
6
7
|
useState,
|
|
7
8
|
useCallback,
|
|
@@ -110,12 +111,22 @@ function CardWithHoverDetail({
|
|
|
110
111
|
}: CardProps) {
|
|
111
112
|
const [isRevealed, setIsRevealed] = useState(false);
|
|
112
113
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
114
|
+
const concealRafRef = useRef<number | null>(null);
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
return () => {
|
|
118
|
+
if (concealRafRef.current !== null) cancelAnimationFrame(concealRafRef.current);
|
|
119
|
+
};
|
|
120
|
+
}, []);
|
|
113
121
|
|
|
114
122
|
const reveal = useCallback(() => setIsRevealed(true), []);
|
|
115
123
|
const conceal = useCallback(() => {
|
|
116
124
|
// Only conceal if focus has truly left the card (not moved to detail).
|
|
117
125
|
// Use requestAnimationFrame so the new focusIn event fires first.
|
|
118
|
-
|
|
126
|
+
// Tracked in a ref so it can be cancelled if the card unmounts before the
|
|
127
|
+
// frame fires (a focus-out right before navigation away, say).
|
|
128
|
+
concealRafRef.current = requestAnimationFrame(() => {
|
|
129
|
+
concealRafRef.current = null;
|
|
119
130
|
if (containerRef.current && !containerRef.current.contains(document.activeElement)) {
|
|
120
131
|
setIsRevealed(false);
|
|
121
132
|
}
|
|
@@ -142,6 +153,7 @@ function CardWithHoverDetail({
|
|
|
142
153
|
return (
|
|
143
154
|
<div
|
|
144
155
|
ref={containerRef}
|
|
156
|
+
data-slot="card"
|
|
145
157
|
className={cn(
|
|
146
158
|
cardVariants({ interactive }),
|
|
147
159
|
cardDetailVariants({ detailPlacement, detailReveal: "hover" }),
|
|
@@ -161,6 +173,7 @@ function CardWithHoverDetail({
|
|
|
161
173
|
role="region"
|
|
162
174
|
aria-label={detailLabel}
|
|
163
175
|
aria-hidden={!isRevealed}
|
|
176
|
+
data-slot="card-detail"
|
|
164
177
|
className={cn("overflow-hidden bg-background p-6", dividerEdge, dividerColor)}
|
|
165
178
|
>
|
|
166
179
|
{detail}
|
|
@@ -189,6 +202,7 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(function Card(
|
|
|
189
202
|
return (
|
|
190
203
|
<div
|
|
191
204
|
ref={ref}
|
|
205
|
+
data-slot="card"
|
|
192
206
|
className={cn(cardVariants({ interactive }), className)}
|
|
193
207
|
style={style}
|
|
194
208
|
{...props}
|
|
@@ -230,6 +244,7 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(function Card(
|
|
|
230
244
|
return (
|
|
231
245
|
<div
|
|
232
246
|
ref={ref}
|
|
247
|
+
data-slot="card"
|
|
233
248
|
className={cn(
|
|
234
249
|
cardVariants({ interactive }),
|
|
235
250
|
cardDetailVariants({ detailPlacement, detailReveal }),
|
|
@@ -243,6 +258,7 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(function Card(
|
|
|
243
258
|
<div
|
|
244
259
|
role="region"
|
|
245
260
|
aria-label={detailLabel}
|
|
261
|
+
data-slot="card-detail"
|
|
246
262
|
className={cn("overflow-hidden bg-background p-6", dividerEdge, dividerColor)}
|
|
247
263
|
>
|
|
248
264
|
{detail}
|
|
@@ -253,7 +269,14 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(function Card(
|
|
|
253
269
|
|
|
254
270
|
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
255
271
|
function CardHeader({ className, ...props }, ref) {
|
|
256
|
-
return
|
|
272
|
+
return (
|
|
273
|
+
<div
|
|
274
|
+
ref={ref}
|
|
275
|
+
data-slot="card-header"
|
|
276
|
+
className={cn("flex flex-col gap-1.5 p-6", className)}
|
|
277
|
+
{...props}
|
|
278
|
+
/>
|
|
279
|
+
);
|
|
257
280
|
},
|
|
258
281
|
);
|
|
259
282
|
|
|
@@ -276,7 +299,14 @@ export const CardTitle = forwardRef<HTMLDivElement, CardTitleProps>(function Car
|
|
|
276
299
|
{ className, as: Tag = "div", ...props },
|
|
277
300
|
ref,
|
|
278
301
|
) {
|
|
279
|
-
return
|
|
302
|
+
return (
|
|
303
|
+
<Tag
|
|
304
|
+
ref={ref}
|
|
305
|
+
data-slot="card-title"
|
|
306
|
+
className={cn("text-title leading-none", className)}
|
|
307
|
+
{...props}
|
|
308
|
+
/>
|
|
309
|
+
);
|
|
280
310
|
});
|
|
281
311
|
|
|
282
312
|
export interface CardDescriptionProps extends HTMLAttributes<HTMLParagraphElement> {
|
|
@@ -293,11 +323,12 @@ export const CardDescription = forwardRef<HTMLParagraphElement, CardDescriptionP
|
|
|
293
323
|
return (
|
|
294
324
|
<p
|
|
295
325
|
ref={ref}
|
|
326
|
+
data-slot="card-description"
|
|
296
327
|
// `text-balance` sets `text-wrap: balance`. #336's real defect was an
|
|
297
328
|
// invalid, misspelled utility name that emitted zero CSS (NOT a
|
|
298
329
|
// tailwind-merge conflict) — this is the real, registered class.
|
|
299
330
|
className={cn(
|
|
300
|
-
"text-
|
|
331
|
+
"text-body text-balance text-muted-foreground",
|
|
301
332
|
measure && "max-w-prose",
|
|
302
333
|
className,
|
|
303
334
|
)}
|
|
@@ -312,6 +343,7 @@ export const CardAction = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivEleme
|
|
|
312
343
|
return (
|
|
313
344
|
<div
|
|
314
345
|
ref={ref}
|
|
346
|
+
data-slot="card-action"
|
|
315
347
|
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
|
316
348
|
{...props}
|
|
317
349
|
/>
|
|
@@ -321,12 +353,21 @@ export const CardAction = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivEleme
|
|
|
321
353
|
|
|
322
354
|
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
323
355
|
function CardContent({ className, ...props }, ref) {
|
|
324
|
-
return
|
|
356
|
+
return (
|
|
357
|
+
<div ref={ref} data-slot="card-content" className={cn("p-6 pt-0", className)} {...props} />
|
|
358
|
+
);
|
|
325
359
|
},
|
|
326
360
|
);
|
|
327
361
|
|
|
328
362
|
export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
329
363
|
function CardFooter({ className, ...props }, ref) {
|
|
330
|
-
return
|
|
364
|
+
return (
|
|
365
|
+
<div
|
|
366
|
+
ref={ref}
|
|
367
|
+
data-slot="card-footer"
|
|
368
|
+
className={cn("flex items-center p-6 pt-0", className)}
|
|
369
|
+
{...props}
|
|
370
|
+
/>
|
|
371
|
+
);
|
|
331
372
|
},
|
|
332
373
|
);
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { createRef } from "react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
4
|
+
import { DirectionProvider } from "@radix-ui/react-direction";
|
|
5
|
+
import {
|
|
6
|
+
Carousel,
|
|
7
|
+
CarouselContent,
|
|
8
|
+
CarouselItem,
|
|
9
|
+
CarouselNext,
|
|
10
|
+
CarouselPrevious,
|
|
11
|
+
} from "./carousel";
|
|
12
|
+
|
|
13
|
+
function renderCarousel(props: Partial<React.ComponentProps<typeof Carousel>> = {}) {
|
|
14
|
+
return render(
|
|
15
|
+
<Carousel {...props}>
|
|
16
|
+
<CarouselContent>
|
|
17
|
+
<CarouselItem>
|
|
18
|
+
<input aria-label="slide input" defaultValue="abc" />
|
|
19
|
+
</CarouselItem>
|
|
20
|
+
<CarouselItem>Slide 2</CarouselItem>
|
|
21
|
+
</CarouselContent>
|
|
22
|
+
<CarouselPrevious />
|
|
23
|
+
<CarouselNext />
|
|
24
|
+
</Carousel>,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("Carousel keyboard handling", () => {
|
|
29
|
+
it("does not intercept ArrowLeft/ArrowRight when the target is a text input", () => {
|
|
30
|
+
renderCarousel();
|
|
31
|
+
const input = screen.getByLabelText("slide input") as HTMLInputElement;
|
|
32
|
+
input.focus();
|
|
33
|
+
const event = fireEvent.keyDown(input, { key: "ArrowRight", bubbles: true });
|
|
34
|
+
// `fireEvent` returns `false` from `dispatchEvent` only when
|
|
35
|
+
// `preventDefault()` was called — the carousel must leave the caret event
|
|
36
|
+
// alone so it reaches the input's own default behavior.
|
|
37
|
+
expect(event).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("still intercepts ArrowLeft/ArrowRight outside of an editable target", () => {
|
|
41
|
+
renderCarousel();
|
|
42
|
+
const region = screen.getByRole("region");
|
|
43
|
+
const event = fireEvent.keyDown(region, { key: "ArrowRight", bubbles: true });
|
|
44
|
+
expect(event).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("Carousel RTL keyboard mapping", () => {
|
|
49
|
+
it("flips ArrowLeft/ArrowRight to the visually-adjacent slide under dir=rtl", () => {
|
|
50
|
+
let scrollPrevSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|
51
|
+
let scrollNextSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|
52
|
+
render(
|
|
53
|
+
<DirectionProvider dir="rtl">
|
|
54
|
+
<Carousel
|
|
55
|
+
setApi={(api) => {
|
|
56
|
+
if (!api) return;
|
|
57
|
+
scrollPrevSpy = vi.spyOn(api, "scrollPrev");
|
|
58
|
+
scrollNextSpy = vi.spyOn(api, "scrollNext");
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
<CarouselContent>
|
|
62
|
+
<CarouselItem>Slide 1</CarouselItem>
|
|
63
|
+
<CarouselItem>Slide 2</CarouselItem>
|
|
64
|
+
</CarouselContent>
|
|
65
|
+
</Carousel>
|
|
66
|
+
</DirectionProvider>,
|
|
67
|
+
);
|
|
68
|
+
const region = screen.getByRole("region");
|
|
69
|
+
// In RTL, ArrowLeft is the visually-forward direction — it must trigger
|
|
70
|
+
// the SAME embla call the "Next slide" button does, not "Previous".
|
|
71
|
+
fireEvent.keyDown(region, { key: "ArrowLeft" });
|
|
72
|
+
expect(scrollNextSpy).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(scrollPrevSpy).not.toHaveBeenCalled();
|
|
74
|
+
|
|
75
|
+
fireEvent.keyDown(region, { key: "ArrowRight" });
|
|
76
|
+
expect(scrollPrevSpy).toHaveBeenCalledTimes(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("keeps the physical mapping under dir=ltr (default)", () => {
|
|
80
|
+
let scrollPrevSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|
81
|
+
let scrollNextSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|
82
|
+
render(
|
|
83
|
+
<Carousel
|
|
84
|
+
setApi={(api) => {
|
|
85
|
+
if (!api) return;
|
|
86
|
+
scrollPrevSpy = vi.spyOn(api, "scrollPrev");
|
|
87
|
+
scrollNextSpy = vi.spyOn(api, "scrollNext");
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<CarouselContent>
|
|
91
|
+
<CarouselItem>Slide 1</CarouselItem>
|
|
92
|
+
<CarouselItem>Slide 2</CarouselItem>
|
|
93
|
+
</CarouselContent>
|
|
94
|
+
</Carousel>,
|
|
95
|
+
);
|
|
96
|
+
const region = screen.getByRole("region");
|
|
97
|
+
fireEvent.keyDown(region, { key: "ArrowRight" });
|
|
98
|
+
expect(scrollNextSpy).toHaveBeenCalledTimes(1);
|
|
99
|
+
fireEvent.keyDown(region, { key: "ArrowLeft" });
|
|
100
|
+
expect(scrollPrevSpy).toHaveBeenCalledTimes(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("uses ArrowUp/ArrowDown instead of ArrowLeft/ArrowRight for a vertical carousel", () => {
|
|
104
|
+
let scrollPrevSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|
105
|
+
let scrollNextSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|
106
|
+
render(
|
|
107
|
+
<Carousel
|
|
108
|
+
orientation="vertical"
|
|
109
|
+
setApi={(api) => {
|
|
110
|
+
if (!api) return;
|
|
111
|
+
scrollPrevSpy = vi.spyOn(api, "scrollPrev");
|
|
112
|
+
scrollNextSpy = vi.spyOn(api, "scrollNext");
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
<CarouselContent>
|
|
116
|
+
<CarouselItem>Slide 1</CarouselItem>
|
|
117
|
+
<CarouselItem>Slide 2</CarouselItem>
|
|
118
|
+
</CarouselContent>
|
|
119
|
+
</Carousel>,
|
|
120
|
+
);
|
|
121
|
+
const region = screen.getByRole("region");
|
|
122
|
+
fireEvent.keyDown(region, { key: "ArrowRight" });
|
|
123
|
+
expect(scrollNextSpy).not.toHaveBeenCalled();
|
|
124
|
+
fireEvent.keyDown(region, { key: "ArrowDown" });
|
|
125
|
+
expect(scrollNextSpy).toHaveBeenCalledTimes(1);
|
|
126
|
+
fireEvent.keyDown(region, { key: "ArrowUp" });
|
|
127
|
+
expect(scrollPrevSpy).toHaveBeenCalledTimes(1);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("CarouselPrevious / CarouselNext", () => {
|
|
132
|
+
it("forward a ref to the underlying button", () => {
|
|
133
|
+
const prevRef = createRef<HTMLButtonElement>();
|
|
134
|
+
const nextRef = createRef<HTMLButtonElement>();
|
|
135
|
+
renderCarousel();
|
|
136
|
+
render(
|
|
137
|
+
<Carousel>
|
|
138
|
+
<CarouselContent>
|
|
139
|
+
<CarouselItem>1</CarouselItem>
|
|
140
|
+
</CarouselContent>
|
|
141
|
+
<CarouselPrevious ref={prevRef} />
|
|
142
|
+
<CarouselNext ref={nextRef} />
|
|
143
|
+
</Carousel>,
|
|
144
|
+
);
|
|
145
|
+
expect(prevRef.current).toBeInstanceOf(HTMLButtonElement);
|
|
146
|
+
expect(nextRef.current).toBeInstanceOf(HTMLButtonElement);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("keeps the buttons inset on narrow viewports (no unconditional negative offset)", () => {
|
|
150
|
+
render(
|
|
151
|
+
<Carousel>
|
|
152
|
+
<CarouselContent>
|
|
153
|
+
<CarouselItem>1</CarouselItem>
|
|
154
|
+
</CarouselContent>
|
|
155
|
+
<CarouselPrevious />
|
|
156
|
+
<CarouselNext />
|
|
157
|
+
</Carousel>,
|
|
158
|
+
);
|
|
159
|
+
const prev = screen.getByRole("button", { name: "Previous slide" });
|
|
160
|
+
const next = screen.getByRole("button", { name: "Next slide" });
|
|
161
|
+
// The negative offset that pushes the button outside the carousel box
|
|
162
|
+
// only applies from `sm:` up; unconditionally offsetting it caused
|
|
163
|
+
// horizontal overflow on narrow phone screens.
|
|
164
|
+
expect(prev.className).not.toMatch(/(?<!sm:)-start-12/);
|
|
165
|
+
expect(next.className).not.toMatch(/(?<!sm:)-end-12/);
|
|
166
|
+
expect(prev.className).toContain("sm:-start-12");
|
|
167
|
+
expect(next.className).toContain("sm:-end-12");
|
|
168
|
+
});
|
|
169
|
+
});
|