@seekalfred/primitives 0.5.0 → 0.6.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 +664 -9
- package/dist/accordion/accordion.d.ts +5 -5
- package/dist/accordion/accordion.js +22 -11
- package/dist/accordion/accordion.js.map +1 -1
- package/dist/action-button/action-button.d.ts +50 -0
- package/dist/action-button/action-button.js +159 -0
- package/dist/action-button/action-button.js.map +1 -0
- package/dist/action-button/index.d.ts +2 -0
- package/dist/action-button/index.js +2 -0
- package/dist/alert/alert.d.ts +16 -2
- package/dist/alert/alert.js +118 -20
- package/dist/alert/alert.js.map +1 -1
- package/dist/alert-dialog/alert-dialog.d.ts +1 -1
- package/dist/alert-dialog/alert-dialog.js +4 -2
- package/dist/alert-dialog/alert-dialog.js.map +1 -1
- package/dist/avatar/avatar.d.ts +7 -1
- package/dist/avatar/avatar.js +51 -18
- package/dist/avatar/avatar.js.map +1 -1
- package/dist/badge/badge.d.ts +10 -3
- package/dist/badge/badge.js +37 -5
- package/dist/badge/badge.js.map +1 -1
- package/dist/breadcrumb/breadcrumb.d.ts +3 -30
- package/dist/breadcrumb/breadcrumb.js +25 -8
- package/dist/breadcrumb/breadcrumb.js.map +1 -1
- package/dist/button/button.d.ts +36 -23
- package/dist/button/button.js +178 -33
- package/dist/button/button.js.map +1 -1
- package/dist/button-group/button-group.d.ts +76 -0
- package/dist/button-group/button-group.js +403 -0
- package/dist/button-group/button-group.js.map +1 -0
- package/dist/button-group/index.d.ts +2 -0
- package/dist/button-group/index.js +2 -0
- package/dist/calendar/calendar.d.ts +3 -3
- package/dist/calendar/calendar.js +263 -34
- package/dist/calendar/calendar.js.map +1 -1
- package/dist/card/card.d.ts +14 -7
- package/dist/card/card.js +32 -14
- package/dist/card/card.js.map +1 -1
- package/dist/checkbox/checkbox.d.ts +2 -9
- package/dist/checkbox/checkbox.js +28 -29
- package/dist/checkbox/checkbox.js.map +1 -1
- package/dist/confirm-morph/confirm-morph.d.ts +85 -0
- package/dist/confirm-morph/confirm-morph.js +652 -0
- package/dist/confirm-morph/confirm-morph.js.map +1 -0
- package/dist/confirm-morph/index.d.ts +2 -0
- package/dist/confirm-morph/index.js +2 -0
- package/dist/copy-button/copy-button.d.ts +32 -0
- package/dist/copy-button/copy-button.js +134 -0
- package/dist/copy-button/copy-button.js.map +1 -0
- package/dist/copy-button/index.d.ts +2 -0
- package/dist/copy-button/index.js +2 -0
- package/dist/date-picker/date-picker.d.ts +51 -0
- package/dist/date-picker/date-picker.js +241 -0
- package/dist/date-picker/date-picker.js.map +1 -0
- package/dist/date-picker/date-range-picker.d.ts +78 -0
- package/dist/date-picker/date-range-picker.js +1015 -0
- package/dist/date-picker/date-range-picker.js.map +1 -0
- package/dist/date-picker/index.d.ts +4 -0
- package/dist/date-picker/index.js +4 -0
- package/dist/date-picker/picker-parts.d.ts +46 -0
- package/dist/date-picker/picker-parts.js +135 -0
- package/dist/date-picker/picker-parts.js.map +1 -0
- package/dist/dialog/dialog.d.ts +12 -16
- package/dist/dialog/dialog.js +45 -14
- package/dist/dialog/dialog.js.map +1 -1
- package/dist/empty-state/empty-state.d.ts +44 -0
- package/dist/empty-state/empty-state.js +83 -0
- package/dist/empty-state/empty-state.js.map +1 -0
- package/dist/empty-state/index.d.ts +2 -0
- package/dist/empty-state/index.js +2 -0
- package/dist/expanding-button-group/expanding-button-group.d.ts +54 -0
- package/dist/expanding-button-group/expanding-button-group.js +350 -0
- package/dist/expanding-button-group/expanding-button-group.js.map +1 -0
- package/dist/expanding-button-group/index.d.ts +2 -0
- package/dist/expanding-button-group/index.js +2 -0
- package/dist/file-drop-zone/file-drop-zone.d.ts +71 -0
- package/dist/file-drop-zone/file-drop-zone.js +116 -0
- package/dist/file-drop-zone/file-drop-zone.js.map +1 -0
- package/dist/file-drop-zone/index.d.ts +2 -0
- package/dist/file-drop-zone/index.js +2 -0
- package/dist/filter-bar/filter-bar.d.ts +121 -0
- package/dist/filter-bar/filter-bar.js +300 -0
- package/dist/filter-bar/filter-bar.js.map +1 -0
- package/dist/filter-bar/index.d.ts +2 -0
- package/dist/filter-bar/index.js +2 -0
- package/dist/floating-button-group/floating-button-group.d.ts +61 -0
- package/dist/floating-button-group/floating-button-group.js +340 -0
- package/dist/floating-button-group/floating-button-group.js.map +1 -0
- package/dist/floating-button-group/index.d.ts +2 -0
- package/dist/floating-button-group/index.js +2 -0
- package/dist/icon-morph/icon-morph.d.ts +55 -0
- package/dist/icon-morph/icon-morph.js +331 -0
- package/dist/icon-morph/icon-morph.js.map +1 -0
- package/dist/icon-morph/index.d.ts +2 -0
- package/dist/icon-morph/index.js +2 -0
- package/dist/index.d.ts +24 -2
- package/dist/index.js +29 -7
- package/dist/input/input.d.ts +23 -1
- package/dist/input/input.js +37 -13
- package/dist/input/input.js.map +1 -1
- package/dist/label/label.d.ts +1 -1
- package/dist/label/label.js +4 -2
- package/dist/label/label.js.map +1 -1
- package/dist/lib/count-to.js +113 -0
- package/dist/lib/count-to.js.map +1 -0
- package/dist/lib/eye-morph.js +62 -0
- package/dist/lib/eye-morph.js.map +1 -0
- package/dist/lib/field-message.js +336 -0
- package/dist/lib/field-message.js.map +1 -0
- package/dist/lib/field-shell.js +81 -0
- package/dist/lib/field-shell.js.map +1 -0
- package/dist/lib/field-surface.js +4 -2
- package/dist/lib/field-surface.js.map +1 -1
- package/dist/lib/flip.js +80 -0
- package/dist/lib/flip.js.map +1 -0
- package/dist/lib/height-follow.js +59 -0
- package/dist/lib/height-follow.js.map +1 -0
- package/dist/lib/icon-swap.js +47 -0
- package/dist/lib/icon-swap.js.map +1 -0
- package/dist/lib/leaving-list.js +57 -0
- package/dist/lib/leaving-list.js.map +1 -0
- package/dist/lib/merge-refs.js +22 -0
- package/dist/lib/merge-refs.js.map +1 -0
- package/dist/lib/morph-text.js +128 -0
- package/dist/lib/morph-text.js.map +1 -0
- package/dist/lib/motion.js +162 -0
- package/dist/lib/motion.js.map +1 -0
- package/dist/lib/pausable-timeout.js +67 -0
- package/dist/lib/pausable-timeout.js.map +1 -0
- package/dist/lib/rolling-value.js +31 -0
- package/dist/lib/rolling-value.js.map +1 -0
- package/dist/lib/spring-progress.js +114 -0
- package/dist/lib/spring-progress.js.map +1 -0
- package/dist/lib/svg-id.js +10 -0
- package/dist/lib/svg-id.js.map +1 -0
- package/dist/lib/swap-text.js +77 -0
- package/dist/lib/swap-text.js.map +1 -0
- package/dist/lib/use-copy-feedback.js +36 -0
- package/dist/lib/use-copy-feedback.js.map +1 -0
- package/dist/menu/menu.d.ts +5 -5
- package/dist/menu/menu.js +10 -6
- package/dist/menu/menu.js.map +1 -1
- package/dist/page-header/index.d.ts +2 -0
- package/dist/page-header/index.js +2 -0
- package/dist/page-header/page-header.d.ts +75 -0
- package/dist/page-header/page-header.js +83 -0
- package/dist/page-header/page-header.js.map +1 -0
- package/dist/password-field/index.d.ts +2 -0
- package/dist/password-field/index.js +2 -0
- package/dist/password-field/password-field.d.ts +37 -0
- package/dist/password-field/password-field.js +101 -0
- package/dist/password-field/password-field.js.map +1 -0
- package/dist/password-strength/index.d.ts +2 -0
- package/dist/password-strength/index.js +2 -0
- package/dist/password-strength/password-strength.d.ts +127 -0
- package/dist/password-strength/password-strength.js +455 -0
- package/dist/password-strength/password-strength.js.map +1 -0
- package/dist/popover/popover.d.ts +10 -2
- package/dist/popover/popover.js +6 -4
- package/dist/popover/popover.js.map +1 -1
- package/dist/progress/progress.d.ts +2 -2
- package/dist/progress/progress.js +50 -7
- package/dist/progress/progress.js.map +1 -1
- package/dist/radio-group/radio-group.d.ts +3 -3
- package/dist/radio-group/radio-group.js +14 -10
- package/dist/radio-group/radio-group.js.map +1 -1
- package/dist/scroll-area/scroll-area.d.ts +17 -6
- package/dist/scroll-area/scroll-area.js +30 -6
- package/dist/scroll-area/scroll-area.js.map +1 -1
- package/dist/search-field/index.d.ts +2 -0
- package/dist/search-field/index.js +2 -0
- package/dist/search-field/search-field.d.ts +26 -0
- package/dist/search-field/search-field.js +155 -0
- package/dist/search-field/search-field.js.map +1 -0
- package/dist/select/select.d.ts +3 -3
- package/dist/select/select.js +85 -24
- package/dist/select/select.js.map +1 -1
- package/dist/separator/separator.d.ts +1 -1
- package/dist/separator/separator.js +4 -2
- package/dist/separator/separator.js.map +1 -1
- package/dist/sheet/sheet.d.ts +1 -1
- package/dist/sheet/sheet.js +6 -3
- package/dist/sheet/sheet.js.map +1 -1
- package/dist/side-nav/index.d.ts +2 -0
- package/dist/side-nav/index.js +2 -0
- package/dist/side-nav/side-nav.d.ts +72 -0
- package/dist/side-nav/side-nav.js +142 -0
- package/dist/side-nav/side-nav.js.map +1 -0
- package/dist/skeleton/skeleton.d.ts +8 -2
- package/dist/skeleton/skeleton.js +9 -3
- package/dist/skeleton/skeleton.js.map +1 -1
- package/dist/slider/slider.d.ts +2 -2
- package/dist/slider/slider.js +64 -8
- package/dist/slider/slider.js.map +1 -1
- package/dist/spinner/spinner.d.ts +1 -1
- package/dist/spinner/spinner.js +4 -2
- package/dist/spinner/spinner.js.map +1 -1
- package/dist/split-button/index.d.ts +2 -0
- package/dist/split-button/index.js +2 -0
- package/dist/split-button/split-button.d.ts +46 -0
- package/dist/split-button/split-button.js +122 -0
- package/dist/split-button/split-button.js.map +1 -0
- package/dist/stat-card/index.d.ts +2 -0
- package/dist/stat-card/index.js +2 -0
- package/dist/stat-card/stat-card.d.ts +81 -0
- package/dist/stat-card/stat-card.js +152 -0
- package/dist/stat-card/stat-card.js.map +1 -0
- package/dist/styles.css +1 -1
- package/dist/switch/switch.d.ts +2 -2
- package/dist/switch/switch.js +11 -8
- package/dist/switch/switch.js.map +1 -1
- package/dist/table/index.d.ts +2 -2
- package/dist/table/index.js +2 -2
- package/dist/table/table.d.ts +38 -7
- package/dist/table/table.js +114 -12
- package/dist/table/table.js.map +1 -1
- package/dist/tabs/tabs.d.ts +4 -4
- package/dist/tabs/tabs.js +153 -12
- package/dist/tabs/tabs.js.map +1 -1
- package/dist/textarea/textarea.d.ts +24 -1
- package/dist/textarea/textarea.js +27 -3
- package/dist/textarea/textarea.js.map +1 -1
- package/dist/theme-switch/index.d.ts +2 -0
- package/dist/theme-switch/index.js +2 -0
- package/dist/theme-switch/theme-switch.d.ts +37 -0
- package/dist/theme-switch/theme-switch.js +123 -0
- package/dist/theme-switch/theme-switch.js.map +1 -0
- package/dist/time-picker/index.d.ts +2 -0
- package/dist/time-picker/index.js +2 -0
- package/dist/time-picker/time-picker.d.ts +54 -0
- package/dist/time-picker/time-picker.js +201 -0
- package/dist/time-picker/time-picker.js.map +1 -0
- package/dist/toast/toast.d.ts +2 -2
- package/dist/toast/toast.js +61 -39
- package/dist/toast/toast.js.map +1 -1
- package/dist/tooltip/tooltip.d.ts +3 -3
- package/dist/tooltip/tooltip.js +66 -15
- package/dist/tooltip/tooltip.js.map +1 -1
- package/package.json +83 -3
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { useMorphWidth } from "../lib/motion.js";
|
|
4
|
+
import { MorphText } from "../lib/morph-text.js";
|
|
5
|
+
import { Menu as Menu$1, MenuItem as MenuItem$1, MenuTrigger } from "../menu/menu.js";
|
|
6
|
+
import { IconSwap } from "../lib/icon-swap.js";
|
|
7
|
+
import { isValidElement, useRef } from "react";
|
|
8
|
+
import { Button } from "react-aria-components";
|
|
9
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
10
|
+
//#region src/split-button/split-button.tsx
|
|
11
|
+
/** Names the icon element, so swapping Copy for Check morphs while a re-render of the same icon stays still. */
|
|
12
|
+
function iconKey(node) {
|
|
13
|
+
if (!isValidElement(node)) return typeof node === "string" || typeof node === "number" ? String(node) : "";
|
|
14
|
+
const type = node.type;
|
|
15
|
+
return typeof type === "string" ? type : type?.displayName ?? type?.name ?? "icon";
|
|
16
|
+
}
|
|
17
|
+
const half = [
|
|
18
|
+
"relative inline-flex items-center justify-center cursor-pointer select-none font-sans font-medium",
|
|
19
|
+
"[-webkit-tap-highlight-color:transparent]",
|
|
20
|
+
"outline-none",
|
|
21
|
+
"data-[focus-visible]:outline-solid data-[focus-visible]:outline-(length:--focus-ring-width)",
|
|
22
|
+
"data-[focus-visible]:outline-offset-(--focus-ring-offset) data-[focus-visible]:outline-(--color-ring)",
|
|
23
|
+
"data-[disabled]:cursor-not-allowed data-[disabled]:opacity-(--opacity-button-disabled)",
|
|
24
|
+
"motion-safe:transition-[background-color,color,opacity] motion-safe:duration-(--duration-quick) motion-safe:ease-settle"
|
|
25
|
+
].join(" ");
|
|
26
|
+
const looks = {
|
|
27
|
+
primary: {
|
|
28
|
+
pill: "bg-primary text-primary-foreground shadow-surface rounded-md",
|
|
29
|
+
main: "h-(--density-control-h-md) px-(--density-control-pad-md) text-base rounded-s-md data-[hovered]:opacity-91 data-[pressed]:opacity-84",
|
|
30
|
+
trigger: "h-(--density-control-h-md) w-9 rounded-e-md border-s-(length:--border-width) border-primary-foreground/20 data-[hovered]:opacity-91 data-[pressed]:opacity-84"
|
|
31
|
+
},
|
|
32
|
+
secondary: {
|
|
33
|
+
pill: "bg-background text-foreground border-(length:--border-width) border-border shadow-surface rounded-md",
|
|
34
|
+
main: "h-(--density-control-h-sm) px-(--density-control-pad-sm) text-sm rounded-s-md data-[hovered]:bg-muted data-[pressed]:bg-muted",
|
|
35
|
+
trigger: "h-(--density-control-h-sm) w-8 rounded-e-md border-s-(length:--border-width) border-border data-[hovered]:bg-muted data-[pressed]:bg-muted"
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* One pill, two actions: a main action and a menu of nearby alternatives.
|
|
40
|
+
*
|
|
41
|
+
* The main half morphs in place: change `children` (or `icon`) and the letters
|
|
42
|
+
* that differ rise in while the old ones lift away, the icon swaps by scaling,
|
|
43
|
+
* and the width springs to fit. The whole pill presses in for the main action;
|
|
44
|
+
* the menu half answers in colour only, so the menu opens from a still anchor,
|
|
45
|
+
* growing from its trigger, its items arriving one after another.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* <SplitButton onPress={save} actions={[{ label: "Save as draft" }, { label: "Delete", destructive: true }]}>
|
|
49
|
+
* Save
|
|
50
|
+
* </SplitButton>
|
|
51
|
+
*/
|
|
52
|
+
function SplitButton({ children: label, actions, onPress, isDisabled, icon, variant = "primary", className }) {
|
|
53
|
+
const contentRef = useRef(null);
|
|
54
|
+
const glyph = iconKey(icon);
|
|
55
|
+
const look = looks[variant];
|
|
56
|
+
useMorphWidth(contentRef, glyph + "|" + label);
|
|
57
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
58
|
+
role: "group",
|
|
59
|
+
className: cn("split-pill inline-flex items-stretch " + look.pill, className),
|
|
60
|
+
children: [/* @__PURE__ */ jsxs(Button, {
|
|
61
|
+
"data-slot": "split-main",
|
|
62
|
+
className: cn(half + " " + look.main),
|
|
63
|
+
isDisabled,
|
|
64
|
+
onPress,
|
|
65
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
66
|
+
className: "button-slot [--morph-clip:inset(-50%_calc(var(--spacing)*-2))]",
|
|
67
|
+
"aria-hidden": "true",
|
|
68
|
+
children: /* @__PURE__ */ jsxs("span", {
|
|
69
|
+
ref: contentRef,
|
|
70
|
+
className: "inline-flex flex-none items-center gap-2 whitespace-nowrap",
|
|
71
|
+
children: [icon ? /* @__PURE__ */ jsx(IconSwap, {
|
|
72
|
+
iconKey: glyph,
|
|
73
|
+
children: icon
|
|
74
|
+
}) : null, /* @__PURE__ */ jsx(MorphText, {
|
|
75
|
+
text: label,
|
|
76
|
+
morphWidth: false
|
|
77
|
+
})]
|
|
78
|
+
})
|
|
79
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
80
|
+
className: "sr-only",
|
|
81
|
+
"aria-live": "polite",
|
|
82
|
+
children: label
|
|
83
|
+
})]
|
|
84
|
+
}), /* @__PURE__ */ jsxs(MenuTrigger, { children: [/* @__PURE__ */ jsx(Button, {
|
|
85
|
+
"data-slot": "split-trigger",
|
|
86
|
+
"aria-label": label + " more actions",
|
|
87
|
+
isDisabled,
|
|
88
|
+
className: cn("group/trigger " + half + " " + look.trigger),
|
|
89
|
+
children: /* @__PURE__ */ jsx("svg", {
|
|
90
|
+
viewBox: "0 0 24 24",
|
|
91
|
+
fill: "none",
|
|
92
|
+
stroke: "currentColor",
|
|
93
|
+
strokeWidth: 1.75,
|
|
94
|
+
strokeLinecap: "round",
|
|
95
|
+
strokeLinejoin: "round",
|
|
96
|
+
"aria-hidden": "true",
|
|
97
|
+
className: "size-4 motion-safe:transition-[rotate] motion-safe:duration-(--duration-gentle) motion-safe:ease-gentle group-aria-expanded/trigger:rotate-180",
|
|
98
|
+
children: /* @__PURE__ */ jsx("path", { d: "m6 9 6 6 6-6" })
|
|
99
|
+
})
|
|
100
|
+
}), /* @__PURE__ */ jsx(Menu$1, {
|
|
101
|
+
placement: "bottom end",
|
|
102
|
+
shouldFocusWrap: true,
|
|
103
|
+
className: "menu-motion [--menu-ox:right]",
|
|
104
|
+
children: actions.map((action, index) => /* @__PURE__ */ jsxs(MenuItem$1, {
|
|
105
|
+
id: action.label,
|
|
106
|
+
textValue: action.label,
|
|
107
|
+
isDisabled: action.isDisabled,
|
|
108
|
+
onAction: action.onSelect,
|
|
109
|
+
"data-i": Math.min(index, 4),
|
|
110
|
+
className: cn("menu-item-motion gap-2.5" + (action.destructive ? " text-destructive-strong data-[focused]:bg-[color-mix(in_oklab,var(--color-destructive-strong)_8%,var(--color-card))]" : "")),
|
|
111
|
+
children: [action.icon ? /* @__PURE__ */ jsx("span", {
|
|
112
|
+
"aria-hidden": "true",
|
|
113
|
+
children: action.icon
|
|
114
|
+
}) : null, action.label]
|
|
115
|
+
}, action.label))
|
|
116
|
+
})] })]
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
//#endregion
|
|
120
|
+
export { SplitButton };
|
|
121
|
+
|
|
122
|
+
//# sourceMappingURL=split-button.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"split-button.js","names":["AriaButton","Menu","MenuItem"],"sources":["../../src/split-button/split-button.tsx"],"sourcesContent":["\"use client\";\n\nimport { isValidElement, useRef, type ReactNode } from \"react\";\nimport { Button as AriaButton } from \"react-aria-components\";\n\nimport { cn } from \"../lib/cn.js\";\nimport { IconSwap } from \"../lib/icon-swap.js\";\nimport { MorphText } from \"../lib/morph-text.js\";\nimport { useMorphWidth } from \"../lib/motion.js\";\nimport { Menu, MenuItem, MenuTrigger } from \"../menu/menu.js\";\n\n/** One entry in the menu half. */\nexport interface SplitButtonAction {\n /** The item's text, and its identity. */\n label: string;\n /** Runs when the item is chosen. */\n onSelect?: () => void;\n isDisabled?: boolean;\n /** Tints the item as destructive. */\n destructive?: boolean;\n /** An icon before the label. Mark it `aria-hidden`. */\n icon?: ReactNode;\n}\n\nexport interface SplitButtonProps {\n /** The main action's label. A plain string: it is split into letters to morph when it changes. */\n children: string;\n /** The menu half's items: the nearby alternatives to the main action. */\n actions: SplitButtonAction[];\n /** Runs when the main half is pressed. */\n onPress?: () => void;\n isDisabled?: boolean;\n /** An icon before the label. Swapping it for another icon morphs; re-rendering the same icon does not. Mark it `aria-hidden`. */\n icon?: ReactNode;\n /** `secondary` is the quiet, outlined pill. @default \"primary\" */\n variant?: \"primary\" | \"secondary\";\n /** Extra classes for the pill. Merged last, so they win conflicts with ours. */\n className?: string;\n}\n\n/** Names the icon element, so swapping Copy for Check morphs while a re-render of the same icon stays still. */\nfunction iconKey(node: ReactNode): string {\n if (!isValidElement(node))\n return typeof node === \"string\" || typeof node === \"number\" ? String(node) : \"\";\n const type = node.type as string | { displayName?: string; name?: string };\n return typeof type === \"string\" ? type : (type?.displayName ?? type?.name ?? \"icon\");\n}\n\n/*\n * Class strings are static literals: Tailwind scans source text.\n *\n * Both halves share the focus outline, the tap-highlight reset and the\n * disabled look. The pill, not either half, scales on press (split-pill), and\n * the menu half never scales, so the menu opens from a still anchor.\n */\nconst half = [\n \"relative inline-flex items-center justify-center cursor-pointer select-none font-sans font-medium\",\n \"[-webkit-tap-highlight-color:transparent]\",\n \"outline-none\",\n \"data-[focus-visible]:outline-solid data-[focus-visible]:outline-(length:--focus-ring-width)\",\n \"data-[focus-visible]:outline-offset-(--focus-ring-offset) data-[focus-visible]:outline-(--color-ring)\",\n \"data-[disabled]:cursor-not-allowed data-[disabled]:opacity-(--opacity-button-disabled)\",\n \"motion-safe:transition-[background-color,color,opacity] motion-safe:duration-(--duration-quick) motion-safe:ease-settle\",\n].join(\" \");\n\nconst looks = {\n primary: {\n pill: \"bg-primary text-primary-foreground shadow-surface rounded-md\",\n main: \"h-(--density-control-h-md) px-(--density-control-pad-md) text-base rounded-s-md data-[hovered]:opacity-91 data-[pressed]:opacity-84\",\n trigger:\n \"h-(--density-control-h-md) w-9 rounded-e-md border-s-(length:--border-width) border-primary-foreground/20 data-[hovered]:opacity-91 data-[pressed]:opacity-84\",\n },\n secondary: {\n pill: \"bg-background text-foreground border-(length:--border-width) border-border shadow-surface rounded-md\",\n main: \"h-(--density-control-h-sm) px-(--density-control-pad-sm) text-sm rounded-s-md data-[hovered]:bg-muted data-[pressed]:bg-muted\",\n trigger:\n \"h-(--density-control-h-sm) w-8 rounded-e-md border-s-(length:--border-width) border-border data-[hovered]:bg-muted data-[pressed]:bg-muted\",\n },\n} as const;\n\nconst itemBase = \"gap-2.5\";\nconst itemDestructive =\n \"text-destructive-strong data-[focused]:bg-[color-mix(in_oklab,var(--color-destructive-strong)_8%,var(--color-card))]\";\n\n/**\n * One pill, two actions: a main action and a menu of nearby alternatives.\n *\n * The main half morphs in place: change `children` (or `icon`) and the letters\n * that differ rise in while the old ones lift away, the icon swaps by scaling,\n * and the width springs to fit. The whole pill presses in for the main action;\n * the menu half answers in colour only, so the menu opens from a still anchor,\n * growing from its trigger, its items arriving one after another.\n *\n * @example\n * <SplitButton onPress={save} actions={[{ label: \"Save as draft\" }, { label: \"Delete\", destructive: true }]}>\n * Save\n * </SplitButton>\n */\nexport function SplitButton({\n children: label,\n actions,\n onPress,\n isDisabled,\n icon,\n variant = \"primary\",\n className,\n}: SplitButtonProps) {\n const contentRef = useRef<HTMLSpanElement>(null);\n const glyph = iconKey(icon);\n const look = looks[variant];\n useMorphWidth(contentRef, glyph + \"|\" + label);\n\n return (\n <div\n role=\"group\"\n className={cn(\"split-pill inline-flex items-stretch \" + look.pill, className)}\n >\n <AriaButton\n data-slot=\"split-main\"\n className={cn(half + \" \" + look.main)}\n isDisabled={isDisabled}\n onPress={onPress}\n >\n <span\n className=\"button-slot [--morph-clip:inset(-50%_calc(var(--spacing)*-2))]\"\n aria-hidden=\"true\"\n >\n <span\n ref={contentRef}\n className=\"inline-flex flex-none items-center gap-2 whitespace-nowrap\"\n >\n {icon ? <IconSwap iconKey={glyph}>{icon}</IconSwap> : null}\n <MorphText text={label} morphWidth={false} />\n </span>\n </span>\n <span className=\"sr-only\" aria-live=\"polite\">\n {label}\n </span>\n </AriaButton>\n <MenuTrigger>\n <AriaButton\n data-slot=\"split-trigger\"\n aria-label={label + \" more actions\"}\n isDisabled={isDisabled}\n className={cn(\"group/trigger \" + half + \" \" + look.trigger)}\n >\n <svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={1.75}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n aria-hidden=\"true\"\n className=\"size-4 motion-safe:transition-[rotate] motion-safe:duration-(--duration-gentle) motion-safe:ease-gentle group-aria-expanded/trigger:rotate-180\"\n >\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n </AriaButton>\n <Menu placement=\"bottom end\" shouldFocusWrap className=\"menu-motion [--menu-ox:right]\">\n {actions.map((action, index) => (\n <MenuItem\n key={action.label}\n id={action.label}\n textValue={action.label}\n isDisabled={action.isDisabled}\n onAction={action.onSelect}\n data-i={Math.min(index, 4)}\n className={cn(\n \"menu-item-motion \" + itemBase + (action.destructive ? \" \" + itemDestructive : \"\"),\n )}\n >\n {action.icon ? <span aria-hidden=\"true\">{action.icon}</span> : null}\n {action.label}\n </MenuItem>\n ))}\n </Menu>\n </MenuTrigger>\n </div>\n );\n}\n"],"mappings":";;;;;;;;;;;AAyCA,SAAS,QAAQ,MAAyB;CACxC,IAAI,CAAC,eAAe,IAAI,GACtB,OAAO,OAAO,SAAS,YAAY,OAAO,SAAS,WAAW,OAAO,IAAI,IAAI;CAC/E,MAAM,OAAO,KAAK;CAClB,OAAO,OAAO,SAAS,WAAW,OAAQ,MAAM,eAAe,MAAM,QAAQ;AAC/E;AASA,MAAM,OAAO;CACX;CACA;CACA;CACA;CACA;CACA;CACA;AACF,CAAC,CAAC,KAAK,GAAG;AAEV,MAAM,QAAQ;CACZ,SAAS;EACP,MAAM;EACN,MAAM;EACN,SACE;CACJ;CACA,WAAW;EACT,MAAM;EACN,MAAM;EACN,SACE;CACJ;AACF;;;;;;;;;;;;;;;AAoBA,SAAgB,YAAY,EAC1B,UAAU,OACV,SACA,SACA,YACA,MACA,UAAU,WACV,aACmB;CACnB,MAAM,aAAa,OAAwB,IAAI;CAC/C,MAAM,QAAQ,QAAQ,IAAI;CAC1B,MAAM,OAAO,MAAM;CACnB,cAAc,YAAY,QAAQ,MAAM,KAAK;CAE7C,OACE,qBAAC,OAAD;EACE,MAAK;EACL,WAAW,GAAG,0CAA0C,KAAK,MAAM,SAAS;EAF9E,UAAA,CAIE,qBAACA,QAAD;GACE,aAAU;GACV,WAAW,GAAG,OAAO,MAAM,KAAK,IAAI;GACxB;GACH;GAJX,UAAA,CAME,oBAAC,QAAD;IACE,WAAU;IACV,eAAY;IAEZ,UAAA,qBAAC,QAAD;KACE,KAAK;KACL,WAAU;KAFZ,UAAA,CAIG,OAAO,oBAAC,UAAD;MAAU,SAAS;MAAQ,UAAA;KAAe,CAAA,IAAI,MACtD,oBAAC,WAAD;MAAW,MAAM;MAAO,YAAY;KAAQ,CAAA,CACxC;;GACF,CAAA,GACN,oBAAC,QAAD;IAAM,WAAU;IAAU,aAAU;IACjC,UAAA;GACG,CAAA,CACI;EACZ,CAAA,GAAA,qBAAC,aAAD,EAAA,UAAA,CACE,oBAACA,QAAD;GACE,aAAU;GACV,cAAY,QAAQ;GACR;GACZ,WAAW,GAAG,mBAAmB,OAAO,MAAM,KAAK,OAAO;GAE1D,UAAA,oBAAC,OAAD;IACE,SAAQ;IACR,MAAK;IACL,QAAO;IACP,aAAa;IACb,eAAc;IACd,gBAAe;IACf,eAAY;IACZ,WAAU;IAEV,UAAA,oBAAC,QAAD,EAAM,GAAE,eAAgB,CAAA;GACrB,CAAA;EACK,CAAA,GACZ,oBAACC,QAAD;GAAM,WAAU;GAAa,iBAAA;GAAgB,WAAU;GACpD,UAAA,QAAQ,KAAK,QAAQ,UACpB,qBAACC,YAAD;IAEE,IAAI,OAAO;IACX,WAAW,OAAO;IAClB,YAAY,OAAO;IACnB,UAAU,OAAO;IACjB,UAAQ,KAAK,IAAI,OAAO,CAAC;IACzB,WAAW,GACT,8BAAkC,OAAO,cAAc,0HAAwB,GACjF;IATF,UAAA,CAWG,OAAO,OAAO,oBAAC,QAAD;KAAM,eAAY;KAAQ,UAAA,OAAO;IAAW,CAAA,IAAI,MAC9D,OAAO,KACA;GAZH,GAAA,OAAO,KAYJ,CACX;EACG,CAAA,CACK,EAAA,CAAA,CACV;;AAET"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { StatCard, StatCardProps, StatCardTone, statCardLabelVariants, statCardSubVariants, statCardValueVariants, statCardVariants } from "./stat-card.js";
|
|
2
|
+
export { StatCard, type StatCardProps, type StatCardTone, statCardLabelVariants, statCardSubVariants, statCardValueVariants, statCardVariants };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
//#region src/stat-card/stat-card.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* StatCard: one headline figure with its label and a line of context. A row
|
|
5
|
+
* of them is a dashboard's summary strip.
|
|
6
|
+
*
|
|
7
|
+
* It exists because a summary strip is on almost every overview screen, and
|
|
8
|
+
* without a shared card each screen picks its own label case, number size,
|
|
9
|
+
* colour and height, so the same figure looks different on two pages.
|
|
10
|
+
*
|
|
11
|
+
* Three rules it holds:
|
|
12
|
+
*
|
|
13
|
+
* - The label is ONE line. A long label is cut with an ellipsis and the full
|
|
14
|
+
* text is on hover, so a row of cards keeps its numbers on one baseline
|
|
15
|
+
* instead of one card's label wrapping and pushing its number down.
|
|
16
|
+
* - The number's colour comes from `tone`, a fixed set of meanings, never a
|
|
17
|
+
* colour. `neutral` is the default and is right for any figure without a
|
|
18
|
+
* verdict in it, including every zero.
|
|
19
|
+
* - Cards in a row are the same height (`h-full` in a grid or flex row).
|
|
20
|
+
*
|
|
21
|
+
* A card can be a link (`href`, React Aria Link, so a router provider routes
|
|
22
|
+
* it), a button (`onPress`), or a filter toggle (`isSelected` + `onPress`, a
|
|
23
|
+
* React Aria ToggleButton with `aria-pressed` and one shared selected look).
|
|
24
|
+
*
|
|
25
|
+
* Every string in the cva blocks below is a STATIC literal. Tailwind scans
|
|
26
|
+
* source text, so a class assembled at runtime is invisible to it and would
|
|
27
|
+
* simply never be emitted.
|
|
28
|
+
*/
|
|
29
|
+
export declare const statCardVariants: (props?: ({
|
|
30
|
+
selected?: boolean | null | undefined;
|
|
31
|
+
interactive?: boolean | null | undefined;
|
|
32
|
+
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
33
|
+
export declare const statCardLabelVariants: (props?: import("class-variance-authority/types").ClassProp | undefined) => string;
|
|
34
|
+
export declare const statCardValueVariants: (props?: ({
|
|
35
|
+
tone?: "neutral" | "warning" | "positive" | "negative" | "attention" | null | undefined;
|
|
36
|
+
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
37
|
+
export declare const statCardSubVariants: (props?: import("class-variance-authority/types").ClassProp | undefined) => string;
|
|
38
|
+
export type StatCardTone = "neutral" | "positive" | "negative" | "warning" | "attention";
|
|
39
|
+
export interface StatCardProps {
|
|
40
|
+
/** What the figure is. One line; cut with an ellipsis, full text on hover. */
|
|
41
|
+
label: string;
|
|
42
|
+
/** The figure itself, already formatted. */
|
|
43
|
+
value: ReactNode;
|
|
44
|
+
/** One line of context under the figure. */
|
|
45
|
+
sub?: ReactNode;
|
|
46
|
+
/**
|
|
47
|
+
* What the figure means: `neutral` (default, and right for every zero),
|
|
48
|
+
* `positive`, `negative`, `warning` (part-way or waiting) or `attention`
|
|
49
|
+
* (a person has to look).
|
|
50
|
+
*/
|
|
51
|
+
tone?: StatCardTone;
|
|
52
|
+
/**
|
|
53
|
+
* The card is a filter, and this says whether it is the one applied.
|
|
54
|
+
* Renders a toggle button with `aria-pressed`. Pair with `onPress`.
|
|
55
|
+
*/
|
|
56
|
+
isSelected?: boolean;
|
|
57
|
+
/** Renders the card as a link to this URL. */
|
|
58
|
+
href?: string;
|
|
59
|
+
/** Renders the card as a button (or, with `isSelected`, a toggle). */
|
|
60
|
+
onPress?: () => void;
|
|
61
|
+
/** Hover text for the whole card. */
|
|
62
|
+
description?: string;
|
|
63
|
+
/** Anything under the context line. */
|
|
64
|
+
children?: ReactNode;
|
|
65
|
+
/** Layout classes for the card (margins, grid placement). */
|
|
66
|
+
className?: string;
|
|
67
|
+
"aria-label"?: string;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* One headline figure.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* <div className="grid gap-4 sm:grid-cols-3">
|
|
74
|
+
* <StatCard label="Open" value="$12,400" sub="18 invoices" />
|
|
75
|
+
* <StatCard label="Overdue" value="$3,100" sub="4 invoices" tone="negative" />
|
|
76
|
+
* <StatCard label="Collected" value="$9,800" tone="positive" href="/payments" />
|
|
77
|
+
* </div>
|
|
78
|
+
*/
|
|
79
|
+
export declare const StatCard: import("react").ForwardRefExoticComponent<StatCardProps & import("react").RefAttributes<HTMLElement>>;
|
|
80
|
+
//#endregion
|
|
81
|
+
//# sourceMappingURL=stat-card.d.ts.map
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { cva } from "class-variance-authority";
|
|
4
|
+
import { forwardRef } from "react";
|
|
5
|
+
import { Button, Link, ToggleButton } from "react-aria-components";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
//#region src/stat-card/stat-card.tsx
|
|
8
|
+
/**
|
|
9
|
+
* StatCard: one headline figure with its label and a line of context. A row
|
|
10
|
+
* of them is a dashboard's summary strip.
|
|
11
|
+
*
|
|
12
|
+
* It exists because a summary strip is on almost every overview screen, and
|
|
13
|
+
* without a shared card each screen picks its own label case, number size,
|
|
14
|
+
* colour and height, so the same figure looks different on two pages.
|
|
15
|
+
*
|
|
16
|
+
* Three rules it holds:
|
|
17
|
+
*
|
|
18
|
+
* - The label is ONE line. A long label is cut with an ellipsis and the full
|
|
19
|
+
* text is on hover, so a row of cards keeps its numbers on one baseline
|
|
20
|
+
* instead of one card's label wrapping and pushing its number down.
|
|
21
|
+
* - The number's colour comes from `tone`, a fixed set of meanings, never a
|
|
22
|
+
* colour. `neutral` is the default and is right for any figure without a
|
|
23
|
+
* verdict in it, including every zero.
|
|
24
|
+
* - Cards in a row are the same height (`h-full` in a grid or flex row).
|
|
25
|
+
*
|
|
26
|
+
* A card can be a link (`href`, React Aria Link, so a router provider routes
|
|
27
|
+
* it), a button (`onPress`), or a filter toggle (`isSelected` + `onPress`, a
|
|
28
|
+
* React Aria ToggleButton with `aria-pressed` and one shared selected look).
|
|
29
|
+
*
|
|
30
|
+
* Every string in the cva blocks below is a STATIC literal. Tailwind scans
|
|
31
|
+
* source text, so a class assembled at runtime is invisible to it and would
|
|
32
|
+
* simply never be emitted.
|
|
33
|
+
*/
|
|
34
|
+
const statCardVariants = cva([
|
|
35
|
+
"flex h-full min-w-0 flex-col p-4 text-start",
|
|
36
|
+
"rounded-lg border-(length:--border-width) bg-card text-card-foreground",
|
|
37
|
+
"box-border font-sans"
|
|
38
|
+
], {
|
|
39
|
+
variants: {
|
|
40
|
+
/** A filter card that is the one applied. */
|
|
41
|
+
selected: {
|
|
42
|
+
true: "border-primary-strong",
|
|
43
|
+
false: "border-border"
|
|
44
|
+
},
|
|
45
|
+
/** Whether the card is a link or a button (hover and focus states). */
|
|
46
|
+
interactive: {
|
|
47
|
+
true: [
|
|
48
|
+
"cursor-pointer outline-none",
|
|
49
|
+
"data-[hovered]:not-data-[selected]:border-muted-foreground",
|
|
50
|
+
"data-[focus-visible]:outline-solid",
|
|
51
|
+
"data-[focus-visible]:outline-(length:--focus-ring-width)",
|
|
52
|
+
"data-[focus-visible]:outline-offset-(--focus-ring-offset)",
|
|
53
|
+
"data-[focus-visible]:outline-(--color-ring)"
|
|
54
|
+
],
|
|
55
|
+
false: ""
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
defaultVariants: {
|
|
59
|
+
selected: false,
|
|
60
|
+
interactive: false
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
const statCardLabelVariants = cva(["m-0 truncate text-2xs leading-(--leading-normal) font-medium tracking-wide uppercase", "text-muted-foreground"]);
|
|
64
|
+
const statCardValueVariants = cva("m-0 mt-1 text-h4 leading-(--leading-snug) font-semibold tabular-nums break-words", {
|
|
65
|
+
variants: {
|
|
66
|
+
/** What the number MEANS. Colour follows from it; it is never picked directly. */
|
|
67
|
+
tone: {
|
|
68
|
+
neutral: "text-foreground",
|
|
69
|
+
positive: "text-success-strong",
|
|
70
|
+
negative: "text-destructive-strong",
|
|
71
|
+
warning: "text-warning-strong",
|
|
72
|
+
attention: "text-primary-strong"
|
|
73
|
+
} },
|
|
74
|
+
defaultVariants: { tone: "neutral" }
|
|
75
|
+
});
|
|
76
|
+
const statCardSubVariants = cva("m-0 mt-1 text-xs leading-(--leading-normal) text-muted-foreground");
|
|
77
|
+
/**
|
|
78
|
+
* One headline figure.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* <div className="grid gap-4 sm:grid-cols-3">
|
|
82
|
+
* <StatCard label="Open" value="$12,400" sub="18 invoices" />
|
|
83
|
+
* <StatCard label="Overdue" value="$3,100" sub="4 invoices" tone="negative" />
|
|
84
|
+
* <StatCard label="Collected" value="$9,800" tone="positive" href="/payments" />
|
|
85
|
+
* </div>
|
|
86
|
+
*/
|
|
87
|
+
const StatCard = forwardRef(function StatCard({ label, value, sub, tone, isSelected, href, onPress, description, children, className, ...rest }, ref) {
|
|
88
|
+
const body = /* @__PURE__ */ jsxs("span", {
|
|
89
|
+
className: "flex min-w-0 flex-1 flex-col",
|
|
90
|
+
title: description,
|
|
91
|
+
children: [
|
|
92
|
+
/* @__PURE__ */ jsx("span", {
|
|
93
|
+
"data-slot": "stat-card-label",
|
|
94
|
+
className: cn(statCardLabelVariants(), "block"),
|
|
95
|
+
title: label,
|
|
96
|
+
children: label
|
|
97
|
+
}),
|
|
98
|
+
/* @__PURE__ */ jsx("span", {
|
|
99
|
+
"data-slot": "stat-card-value",
|
|
100
|
+
"data-tone": tone ?? "neutral",
|
|
101
|
+
className: cn(statCardValueVariants({ tone }), "block"),
|
|
102
|
+
children: value
|
|
103
|
+
}),
|
|
104
|
+
sub === void 0 ? null : /* @__PURE__ */ jsx("span", {
|
|
105
|
+
"data-slot": "stat-card-sub",
|
|
106
|
+
className: cn(statCardSubVariants(), "block"),
|
|
107
|
+
children: sub
|
|
108
|
+
}),
|
|
109
|
+
children
|
|
110
|
+
]
|
|
111
|
+
});
|
|
112
|
+
const classes = cn(statCardVariants({
|
|
113
|
+
selected: isSelected === true,
|
|
114
|
+
interactive: isSelected !== void 0 || onPress !== void 0 || href !== void 0
|
|
115
|
+
}), className);
|
|
116
|
+
const common = {
|
|
117
|
+
"data-slot": "stat-card",
|
|
118
|
+
"aria-label": rest["aria-label"]
|
|
119
|
+
};
|
|
120
|
+
if (isSelected !== void 0) return /* @__PURE__ */ jsx(ToggleButton, {
|
|
121
|
+
ref,
|
|
122
|
+
...common,
|
|
123
|
+
isSelected,
|
|
124
|
+
onChange: () => onPress?.(),
|
|
125
|
+
className: classes,
|
|
126
|
+
children: body
|
|
127
|
+
});
|
|
128
|
+
if (onPress) return /* @__PURE__ */ jsx(Button, {
|
|
129
|
+
ref,
|
|
130
|
+
...common,
|
|
131
|
+
onPress,
|
|
132
|
+
className: classes,
|
|
133
|
+
children: body
|
|
134
|
+
});
|
|
135
|
+
if (href !== void 0) return /* @__PURE__ */ jsx(Link, {
|
|
136
|
+
ref,
|
|
137
|
+
...common,
|
|
138
|
+
href,
|
|
139
|
+
className: classes,
|
|
140
|
+
children: body
|
|
141
|
+
});
|
|
142
|
+
return /* @__PURE__ */ jsx("div", {
|
|
143
|
+
ref,
|
|
144
|
+
...common,
|
|
145
|
+
className: classes,
|
|
146
|
+
children: body
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
//#endregion
|
|
150
|
+
export { StatCard, statCardLabelVariants, statCardSubVariants, statCardValueVariants, statCardVariants };
|
|
151
|
+
|
|
152
|
+
//# sourceMappingURL=stat-card.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stat-card.js","names":["AriaToggleButton","AriaButton","AriaLink"],"sources":["../../src/stat-card/stat-card.tsx"],"sourcesContent":["\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport { forwardRef, type ReactNode, type Ref } from \"react\";\nimport {\n Button as AriaButton,\n Link as AriaLink,\n ToggleButton as AriaToggleButton,\n} from \"react-aria-components\";\n\nimport { cn } from \"../lib/cn.js\";\n\n/**\n * StatCard: one headline figure with its label and a line of context. A row\n * of them is a dashboard's summary strip.\n *\n * It exists because a summary strip is on almost every overview screen, and\n * without a shared card each screen picks its own label case, number size,\n * colour and height, so the same figure looks different on two pages.\n *\n * Three rules it holds:\n *\n * - The label is ONE line. A long label is cut with an ellipsis and the full\n * text is on hover, so a row of cards keeps its numbers on one baseline\n * instead of one card's label wrapping and pushing its number down.\n * - The number's colour comes from `tone`, a fixed set of meanings, never a\n * colour. `neutral` is the default and is right for any figure without a\n * verdict in it, including every zero.\n * - Cards in a row are the same height (`h-full` in a grid or flex row).\n *\n * A card can be a link (`href`, React Aria Link, so a router provider routes\n * it), a button (`onPress`), or a filter toggle (`isSelected` + `onPress`, a\n * React Aria ToggleButton with `aria-pressed` and one shared selected look).\n *\n * Every string in the cva blocks below is a STATIC literal. Tailwind scans\n * source text, so a class assembled at runtime is invisible to it and would\n * simply never be emitted.\n */\nexport const statCardVariants = cva(\n [\n \"flex h-full min-w-0 flex-col p-4 text-start\",\n \"rounded-lg border-(length:--border-width) bg-card text-card-foreground\",\n // box-border and font-sans are not boilerplate: the reset in\n // @seekalfred/styles is opt-in, so a consumer may load none.\n \"box-border font-sans\",\n ],\n {\n variants: {\n /** A filter card that is the one applied. */\n selected: {\n true: \"border-primary-strong\",\n false: \"border-border\",\n },\n /** Whether the card is a link or a button (hover and focus states). */\n interactive: {\n true: [\n \"cursor-pointer outline-none\",\n // A border, not a fill: a muted fill under the muted label fails contrast.\n \"data-[hovered]:not-data-[selected]:border-muted-foreground\",\n // outline-solid on the focus variant is load-bearing: outline-none\n // sets the outline style the width utility resolves from.\n \"data-[focus-visible]:outline-solid\",\n \"data-[focus-visible]:outline-(length:--focus-ring-width)\",\n \"data-[focus-visible]:outline-offset-(--focus-ring-offset)\",\n \"data-[focus-visible]:outline-(--color-ring)\",\n ],\n false: \"\",\n },\n },\n defaultVariants: { selected: false, interactive: false },\n },\n);\n\nexport const statCardLabelVariants = cva([\n \"m-0 truncate text-2xs leading-(--leading-normal) font-medium tracking-wide uppercase\",\n \"text-muted-foreground\",\n]);\n\nexport const statCardValueVariants = cva(\n // leading-* AFTER text-*: tailwind-merge treats a later text-* as owning\n // line-height and silently strips a leading-* that precedes it.\n \"m-0 mt-1 text-h4 leading-(--leading-snug) font-semibold tabular-nums break-words\",\n {\n variants: {\n /** What the number MEANS. Colour follows from it; it is never picked directly. */\n tone: {\n neutral: \"text-foreground\",\n positive: \"text-success-strong\",\n negative: \"text-destructive-strong\",\n warning: \"text-warning-strong\",\n attention: \"text-primary-strong\",\n },\n },\n defaultVariants: { tone: \"neutral\" },\n },\n);\n\nexport const statCardSubVariants = cva(\n \"m-0 mt-1 text-xs leading-(--leading-normal) text-muted-foreground\",\n);\n\nexport type StatCardTone = \"neutral\" | \"positive\" | \"negative\" | \"warning\" | \"attention\";\n\nexport interface StatCardProps {\n /** What the figure is. One line; cut with an ellipsis, full text on hover. */\n label: string;\n /** The figure itself, already formatted. */\n value: ReactNode;\n /** One line of context under the figure. */\n sub?: ReactNode;\n /**\n * What the figure means: `neutral` (default, and right for every zero),\n * `positive`, `negative`, `warning` (part-way or waiting) or `attention`\n * (a person has to look).\n */\n tone?: StatCardTone;\n /**\n * The card is a filter, and this says whether it is the one applied.\n * Renders a toggle button with `aria-pressed`. Pair with `onPress`.\n */\n isSelected?: boolean;\n /** Renders the card as a link to this URL. */\n href?: string;\n /** Renders the card as a button (or, with `isSelected`, a toggle). */\n onPress?: () => void;\n /** Hover text for the whole card. */\n description?: string;\n /** Anything under the context line. */\n children?: ReactNode;\n /** Layout classes for the card (margins, grid placement). */\n className?: string;\n \"aria-label\"?: string;\n}\n\n/**\n * One headline figure.\n *\n * @example\n * <div className=\"grid gap-4 sm:grid-cols-3\">\n * <StatCard label=\"Open\" value=\"$12,400\" sub=\"18 invoices\" />\n * <StatCard label=\"Overdue\" value=\"$3,100\" sub=\"4 invoices\" tone=\"negative\" />\n * <StatCard label=\"Collected\" value=\"$9,800\" tone=\"positive\" href=\"/payments\" />\n * </div>\n */\nexport const StatCard = forwardRef<HTMLElement, StatCardProps>(function StatCard(\n { label, value, sub, tone, isSelected, href, onPress, description, children, className, ...rest },\n ref,\n) {\n const body = (\n // The hover text sits on a box that fills the card: React Aria's Button\n // does not pass a `title` through to the element it renders.\n <span className=\"flex min-w-0 flex-1 flex-col\" title={description}>\n <span\n data-slot=\"stat-card-label\"\n className={cn(statCardLabelVariants(), \"block\")}\n title={label}\n >\n {label}\n </span>\n <span\n data-slot=\"stat-card-value\"\n data-tone={tone ?? \"neutral\"}\n className={cn(statCardValueVariants({ tone }), \"block\")}\n >\n {value}\n </span>\n {sub === undefined ? null : (\n <span data-slot=\"stat-card-sub\" className={cn(statCardSubVariants(), \"block\")}>\n {sub}\n </span>\n )}\n {children}\n </span>\n );\n const interactive = isSelected !== undefined || onPress !== undefined || href !== undefined;\n const classes = cn(statCardVariants({ selected: isSelected === true, interactive }), className);\n const common = { \"data-slot\": \"stat-card\", \"aria-label\": rest[\"aria-label\"] };\n\n if (isSelected !== undefined) {\n return (\n <AriaToggleButton\n ref={ref as Ref<HTMLButtonElement>}\n {...common}\n isSelected={isSelected}\n onChange={() => onPress?.()}\n className={classes}\n >\n {body}\n </AriaToggleButton>\n );\n }\n if (onPress) {\n return (\n <AriaButton\n ref={ref as Ref<HTMLButtonElement>}\n {...common}\n onPress={onPress}\n className={classes}\n >\n {body}\n </AriaButton>\n );\n }\n if (href !== undefined) {\n return (\n <AriaLink ref={ref as Ref<HTMLAnchorElement>} {...common} href={href} className={classes}>\n {body}\n </AriaLink>\n );\n }\n return (\n <div ref={ref as Ref<HTMLDivElement>} {...common} className={classes}>\n {body}\n </div>\n );\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsCA,MAAa,mBAAmB,IAC9B;CACE;CACA;CAGA;AACF,GACA;CACE,UAAU;;EAER,UAAU;GACR,MAAM;GACN,OAAO;EACT;;EAEA,aAAa;GACX,MAAM;IACJ;IAEA;IAGA;IACA;IACA;IACA;GACF;GACA,OAAO;EACT;CACF;CACA,iBAAiB;EAAE,UAAU;EAAO,aAAa;CAAM;AACzD,CACF;AAEA,MAAa,wBAAwB,IAAI,CACvC,wFACA,uBACF,CAAC;AAED,MAAa,wBAAwB,IAGnC,oFACA;CACE,UAAU;;AAER,MAAM;EACJ,SAAS;EACT,UAAU;EACV,UAAU;EACV,SAAS;EACT,WAAW;CACb,EACF;CACA,iBAAiB,EAAE,MAAM,UAAU;AACrC,CACF;AAEA,MAAa,sBAAsB,IACjC,mEACF;;;;;;;;;;;AA6CA,MAAa,WAAW,WAAuC,SAAS,SACtE,EAAE,OAAO,OAAO,KAAK,MAAM,YAAY,MAAM,SAAS,aAAa,UAAU,WAAW,GAAG,QAC3F,KACA;CACA,MAAM,OAGJ,qBAAC,QAAD;EAAM,WAAU;EAA+B,OAAO;EAAtD,UAAA;GACE,oBAAC,QAAD;IACE,aAAU;IACV,WAAW,GAAG,sBAAsB,GAAG,OAAO;IAC9C,OAAO;IAEN,UAAA;GACG,CAAA;GACN,oBAAC,QAAD;IACE,aAAU;IACV,aAAW,QAAQ;IACnB,WAAW,GAAG,sBAAsB,EAAE,KAAK,CAAC,GAAG,OAAO;IAErD,UAAA;GACG,CAAA;GACL,QAAQ,KAAA,IAAY,OACnB,oBAAC,QAAD;IAAM,aAAU;IAAgB,WAAW,GAAG,oBAAoB,GAAG,OAAO;IACzE,UAAA;GACG,CAAA;GAEP;EACG;;CAGR,MAAM,UAAU,GAAG,iBAAiB;EAAE,UAAU,eAAe;EAAM,aADjD,eAAe,KAAA,KAAa,YAAY,KAAA,KAAa,SAAS,KAAA;CACD,CAAC,GAAG,SAAS;CAC9F,MAAM,SAAS;EAAE,aAAa;EAAa,cAAc,KAAK;CAAc;CAE5E,IAAI,eAAe,KAAA,GACjB,OACE,oBAACA,cAAD;EACO;EACL,GAAI;EACQ;EACZ,gBAAgB,UAAU;EAC1B,WAAW;EAEV,UAAA;CACe,CAAA;CAGtB,IAAI,SACF,OACE,oBAACC,QAAD;EACO;EACL,GAAI;EACK;EACT,WAAW;EAEV,UAAA;CACS,CAAA;CAGhB,IAAI,SAAS,KAAA,GACX,OACE,oBAACC,MAAD;EAAe;EAA+B,GAAI;EAAc;EAAM,WAAW;EAC9E,UAAA;CACO,CAAA;CAGd,OACE,oBAAC,OAAD;EAAU;EAA4B,GAAI;EAAQ,WAAW;EAC1D,UAAA;CACE,CAAA;AAET,CAAC"}
|