@tangle-network/ui 11.14.0 → 11.15.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/CHANGELOG.md +10 -0
- package/dist/lib/control-presentation.js +11 -0
- package/dist/primitives/button.d.ts +2 -2
- package/dist/primitives/button.js +11 -9
- package/dist/primitives/input.d.ts +3 -1
- package/dist/primitives/input.js +7 -5
- package/dist/primitives/metric-strip.d.ts +6 -28
- package/dist/primitives/metric-strip.js +15 -18
- package/dist/primitives/select.d.ts +4 -1
- package/dist/primitives/select.js +7 -7
- package/package.json +1 -1
- package/src/lib/control-presentation.ts +26 -0
- package/src/primitives/button.tsx +11 -9
- package/src/primitives/control-presentation.stories.tsx +79 -0
- package/src/primitives/control-presentation.test.tsx +67 -0
- package/src/primitives/input.tsx +12 -6
- package/src/primitives/metric-strip.stories.tsx +39 -0
- package/src/primitives/metric-strip.test.tsx +82 -144
- package/src/primitives/metric-strip.tsx +27 -61
- package/src/primitives/select.tsx +16 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @tangle-network/ui
|
|
2
2
|
|
|
3
|
+
## 11.15.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 1327903: Use scoped recessed field fills, readable link-action ink and time-valued control motion. Add opt-in compact/touch control sizing and three-to-six-column MetricStrip presets that own their separators. Preserve existing sizes, imports and interaction contracts; wrap metric content and retain numeric-zero qualifiers as descriptions.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- cb72f8d: Give the shared Select its designed states by default: accent border and surface while open, a rotating chevron, the dropdown shadow on the menu, and accent focus and checked rows with an accent checkmark. Consumers no longer need per-app overrides to avoid a flat menu.
|
|
12
|
+
|
|
3
13
|
## 11.14.0
|
|
4
14
|
|
|
5
15
|
### Minor Changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { focusField } from "./focus.js";
|
|
2
|
+
import { cn } from "./utils.js";
|
|
3
|
+
//#region src/lib/control-presentation.ts
|
|
4
|
+
const controlMotion = "duration-[var(--duration-fast)] ease-[var(--ease-standard)] motion-reduce:transition-none motion-reduce:duration-0";
|
|
5
|
+
const controlSizes = {
|
|
6
|
+
compact: "h-[var(--control-height)] px-3 py-1 text-sm",
|
|
7
|
+
touch: "h-11 min-h-11 min-w-11 px-4 py-2 text-base"
|
|
8
|
+
};
|
|
9
|
+
const fieldPresentation = cn(focusField, controlMotion, "min-w-0 bg-[var(--bg-input)] text-foreground", "aria-invalid:border-[var(--surface-danger-border)] aria-invalid:hover:border-[var(--surface-danger-border)] aria-invalid:focus:border-[var(--focus-border-danger)] aria-invalid:focus:ring-[var(--focus-halo-danger)]", "autofill:shadow-[inset_0_0_0_1000px_var(--bg-input)] autofill:[-webkit-text-fill-color:var(--text-primary)] autofill:caret-[var(--text-primary)]");
|
|
10
|
+
//#endregion
|
|
11
|
+
export { controlMotion, controlSizes, fieldPresentation };
|
|
@@ -2,11 +2,11 @@ import * as React from "react";
|
|
|
2
2
|
//#region src/primitives/button.d.ts
|
|
3
3
|
declare const buttonVariants: (props?: ({
|
|
4
4
|
variant?: "default" | "destructive" | "ghost" | "link" | "outline" | "sandbox" | "secondary" | null | undefined;
|
|
5
|
-
size?: "default" | "icon" | "lg" | "sm" | "xl" | null | undefined;
|
|
5
|
+
size?: "compact" | "default" | "icon" | "lg" | "sm" | "touch" | "xl" | null | undefined;
|
|
6
6
|
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
7
7
|
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
8
8
|
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "sandbox" | null;
|
|
9
|
-
size?: "default" | "sm" | "lg" | "xl" | "icon" | null;
|
|
9
|
+
size?: "default" | "sm" | "lg" | "xl" | "icon" | "compact" | "touch" | null;
|
|
10
10
|
asChild?: boolean;
|
|
11
11
|
loading?: boolean;
|
|
12
12
|
children?: React.ReactNode;
|
|
@@ -1,27 +1,29 @@
|
|
|
1
1
|
import { focusRing } from "../lib/focus.js";
|
|
2
2
|
import { cn } from "../lib/utils.js";
|
|
3
|
+
import { controlMotion, controlSizes } from "../lib/control-presentation.js";
|
|
3
4
|
import { Slot } from "@radix-ui/react-slot";
|
|
4
5
|
import { cva } from "class-variance-authority";
|
|
5
6
|
import * as React from "react";
|
|
6
7
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
8
|
//#region src/primitives/button.tsx
|
|
8
|
-
const buttonVariants = cva(`inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium text-sm transition-
|
|
9
|
+
const buttonVariants = cva(`inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium text-sm transition-[color,background-color,border-color,box-shadow,transform,scale] ${controlMotion} ${focusRing} disabled:pointer-events-none disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 motion-reduce:active:scale-100 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0`, {
|
|
9
10
|
variants: {
|
|
10
11
|
variant: {
|
|
11
|
-
default: "bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.97]
|
|
12
|
-
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90 active:scale-[0.97]
|
|
13
|
-
outline: "border border-border bg-card hover:bg-muted active:scale-[0.97]
|
|
14
|
-
secondary: "bg-muted border border-border text-foreground hover:bg-muted/80 active:scale-[0.97]
|
|
15
|
-
ghost: "hover:bg-muted hover:text-foreground
|
|
16
|
-
link: "text-
|
|
17
|
-
sandbox: "bg-[var(--btn-primary-bg)] text-[var(--btn-primary-text)] border border-[var(--border-accent)] hover:bg-[var(--btn-primary-hover)] active:scale-[0.97]
|
|
12
|
+
default: "bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.97]",
|
|
13
|
+
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90 active:scale-[0.97]",
|
|
14
|
+
outline: "border border-border bg-card hover:bg-muted active:scale-[0.97] text-foreground",
|
|
15
|
+
secondary: "bg-muted border border-border text-foreground hover:bg-muted/80 active:scale-[0.97]",
|
|
16
|
+
ghost: "hover:bg-muted hover:text-foreground text-muted-foreground border border-transparent",
|
|
17
|
+
link: "text-[var(--accent-text)] underline-offset-4 hover:underline",
|
|
18
|
+
sandbox: "bg-[var(--btn-primary-bg)] text-[var(--btn-primary-text)] border border-[var(--border-accent)] hover:bg-[var(--btn-primary-hover)] active:scale-[0.97]"
|
|
18
19
|
},
|
|
19
20
|
size: {
|
|
20
21
|
default: "h-[var(--control-height)] px-4 py-2",
|
|
21
22
|
sm: "h-8 rounded-md px-3 text-xs",
|
|
22
23
|
lg: "h-11 rounded-lg px-7 text-sm",
|
|
23
24
|
xl: "h-13 rounded-xl px-9 text-base",
|
|
24
|
-
icon: "h-[var(--control-height)] w-[var(--control-height)]"
|
|
25
|
+
icon: "h-[var(--control-height)] w-[var(--control-height)]",
|
|
26
|
+
...controlSizes
|
|
25
27
|
}
|
|
26
28
|
},
|
|
27
29
|
defaultVariants: {
|
|
@@ -3,7 +3,7 @@ import * as React from "react";
|
|
|
3
3
|
//#region src/primitives/input.d.ts
|
|
4
4
|
declare const inputVariants: (props?: ({
|
|
5
5
|
variant?: "default" | "error" | "sandbox" | null | undefined;
|
|
6
|
-
size?: "default" | "lg" | "sm" | null | undefined;
|
|
6
|
+
size?: "compact" | "default" | "lg" | "sm" | "touch" | null | undefined;
|
|
7
7
|
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
8
8
|
export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">, VariantProps<typeof inputVariants> {
|
|
9
9
|
label?: string;
|
|
@@ -14,6 +14,8 @@ declare const Input: React.ForwardRefExoticComponent<InputProps & React.RefAttri
|
|
|
14
14
|
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
15
15
|
/** Both values render the same field. */
|
|
16
16
|
variant?: "default" | "sandbox";
|
|
17
|
+
/** Opt in to density/typing presentation without changing the default 120px well. */
|
|
18
|
+
size?: "default" | "compact" | "touch";
|
|
17
19
|
label?: string;
|
|
18
20
|
error?: string;
|
|
19
21
|
hint?: string;
|
package/dist/primitives/input.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import {
|
|
2
|
+
import { focusFieldInvalid } from "../lib/focus.js";
|
|
3
3
|
import { cn } from "../lib/utils.js";
|
|
4
|
+
import { controlSizes, fieldPresentation } from "../lib/control-presentation.js";
|
|
4
5
|
import { Label } from "./label.js";
|
|
5
6
|
import { cva } from "class-variance-authority";
|
|
6
7
|
import * as React from "react";
|
|
7
8
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
9
|
//#region src/primitives/input.tsx
|
|
9
|
-
const inputVariants = cva(cn("flex w-full rounded-lg border
|
|
10
|
+
const inputVariants = cva(cn("flex w-full rounded-lg border px-4 py-2 text-sm placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 file:border-0 file:bg-transparent file:font-medium file:text-sm", fieldPresentation), {
|
|
10
11
|
variants: {
|
|
11
12
|
variant: {
|
|
12
13
|
default: "",
|
|
@@ -16,7 +17,8 @@ const inputVariants = cva(cn("flex w-full rounded-lg border bg-card px-4 py-2 te
|
|
|
16
17
|
size: {
|
|
17
18
|
default: "h-11",
|
|
18
19
|
sm: "h-9 px-3",
|
|
19
|
-
lg: "h-12 px-5"
|
|
20
|
+
lg: "h-12 px-5",
|
|
21
|
+
...controlSizes
|
|
20
22
|
}
|
|
21
23
|
},
|
|
22
24
|
defaultVariants: {
|
|
@@ -69,14 +71,14 @@ const Input = React.forwardRef(({ className, type, variant, size, label, error,
|
|
|
69
71
|
});
|
|
70
72
|
});
|
|
71
73
|
Input.displayName = "Input";
|
|
72
|
-
const Textarea = React.forwardRef(({ className, variant: _variant, label, error, hint, id, "aria-describedby": describedBy, "aria-invalid": invalid, ...props }, ref) => {
|
|
74
|
+
const Textarea = React.forwardRef(({ className, variant: _variant, size = "default", label, error, hint, id, "aria-describedby": describedBy, "aria-invalid": invalid, ...props }, ref) => {
|
|
73
75
|
const generatedId = React.useId();
|
|
74
76
|
const textareaId = id ?? generatedId;
|
|
75
77
|
const hintId = hint ? `${textareaId}-hint` : void 0;
|
|
76
78
|
const errorId = error ? `${textareaId}-error` : void 0;
|
|
77
79
|
const textarea = /* @__PURE__ */ jsx("textarea", {
|
|
78
80
|
id: textareaId,
|
|
79
|
-
className: cn("flex min-h-[120px] w-full resize-y rounded-lg border
|
|
81
|
+
className: cn("flex min-h-[120px] w-full resize-y rounded-lg border px-4 py-3 text-sm", "placeholder:text-muted-foreground", "disabled:cursor-not-allowed disabled:opacity-50", fieldPresentation, error && "border-[var(--surface-danger-border)] hover:border-[var(--surface-danger-border)] focus:border-[var(--focus-border-danger)] focus:ring-[var(--focus-halo-danger)]", size === "compact" && "min-h-24 px-3 py-2", size === "touch" && "text-base", className),
|
|
80
82
|
ref,
|
|
81
83
|
...props,
|
|
82
84
|
"aria-describedby": descriptionIds(describedBy, hintId, errorId),
|
|
@@ -1,23 +1,12 @@
|
|
|
1
1
|
import { StatusTone } from "./status-pill.js";
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
//#region src/primitives/metric-strip.d.ts
|
|
4
|
-
/**
|
|
5
|
-
* The headline figures for a console page, as ONE statement rather than four.
|
|
6
|
-
*
|
|
7
|
-
* A row of separate stat cards is the default and it is usually wrong here.
|
|
8
|
-
* Each card is a box that must be tall enough for its longest member, so a row
|
|
9
|
-
* mixing "$248.55 / Personal wallet" against a bare "0" leaves the short ones
|
|
10
|
-
* mostly empty; and four bordered boxes read as four unrelated facts when they
|
|
11
|
-
* are four readings of one account. `MetricStrip` puts them on one plane
|
|
12
|
-
* divided by hairlines: same information, one object, no holes.
|
|
13
|
-
*
|
|
14
|
-
* The divider is a border on the item rather than a `divide-*` utility on the
|
|
15
|
-
* parent, because these wrap. `divide-x` draws from DOM order and leaves a
|
|
16
|
-
* stray rule at the start of every wrapped line; a per-item leading border
|
|
17
|
-
* suppressed at each row start does not.
|
|
18
|
-
*/
|
|
4
|
+
/** Headline readings on one plane, with native definition-list semantics. */
|
|
19
5
|
export interface MetricStripProps extends React.HTMLAttributes<HTMLDListElement> {
|
|
20
6
|
children: React.ReactNode;
|
|
7
|
+
/** Desktop columns. Default retains the existing two / sm:four layout.
|
|
8
|
+
* Five and six use three columns between sm and lg. Never inferred from data. */
|
|
9
|
+
columns?: 3 | 4 | 5 | 6;
|
|
21
10
|
}
|
|
22
11
|
declare const MetricStrip: React.ForwardRefExoticComponent<MetricStripProps & React.RefAttributes<HTMLDListElement>>;
|
|
23
12
|
export interface MetricProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
@@ -25,24 +14,13 @@ export interface MetricProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
25
14
|
value: React.ReactNode;
|
|
26
15
|
/** The qualifying line: which wallet, which window, what the limit is. */
|
|
27
16
|
hint?: React.ReactNode;
|
|
28
|
-
/**
|
|
29
|
-
* `attention` only when a person has to DO something — not merely when a
|
|
30
|
-
* number is zero. */
|
|
17
|
+
/** Attention is a caller-owned action requirement, never inferred from zero. */
|
|
31
18
|
attention?: {
|
|
32
19
|
tone: StatusTone;
|
|
33
20
|
label: string;
|
|
34
21
|
};
|
|
35
22
|
}
|
|
36
|
-
/**
|
|
37
|
-
* One reading inside a `MetricStrip`.
|
|
38
|
-
*
|
|
39
|
-
* `<dt>` carries the label and `<dd>` the value, so the pair is announced as a
|
|
40
|
-
* described term rather than as two loose strings — the semantic that makes a
|
|
41
|
-
* figure legible without the visual grouping.
|
|
42
|
-
*
|
|
43
|
-
* The value is `tabular-nums`: these sit in a row and change on a timer, and
|
|
44
|
-
* proportional digits make the column jitter every time a 1 becomes an 8.
|
|
45
|
-
*/
|
|
23
|
+
/** A described term; the strip, not this item, decides where separators belong. */
|
|
46
24
|
declare const Metric: React.ForwardRefExoticComponent<MetricProps & React.RefAttributes<HTMLDivElement>>;
|
|
47
25
|
//#endregion
|
|
48
26
|
export { Metric, MetricStrip };
|
|
@@ -3,32 +3,29 @@ import { StatusPill } from "./status-pill.js";
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
5
|
//#region src/primitives/metric-strip.tsx
|
|
6
|
-
const
|
|
6
|
+
const layouts = {
|
|
7
|
+
3: "grid-cols-2 sm:grid-cols-3 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:[&>div:not(:nth-child(3n+1))]:border-l",
|
|
8
|
+
4: "grid-cols-2 sm:grid-cols-4 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:[&>div:not(:nth-child(4n+1))]:border-l",
|
|
9
|
+
5: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:max-lg:[&>div:not(:nth-child(3n+1))]:border-l lg:[&>div:not(:nth-child(5n+1))]:border-l",
|
|
10
|
+
6: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:max-lg:[&>div:not(:nth-child(3n+1))]:border-l lg:[&>div:not(:nth-child(6n+1))]:border-l"
|
|
11
|
+
};
|
|
12
|
+
const MetricStrip = React.forwardRef(({ className, children, columns = 4, ...props }, ref) => /* @__PURE__ */ jsx("dl", {
|
|
7
13
|
ref,
|
|
8
|
-
className: cn("grid
|
|
14
|
+
className: cn("grid min-w-0 rounded-[var(--radius-lg)] border border-border bg-card shadow-[var(--shadow-card)]", layouts[columns], className),
|
|
9
15
|
...props,
|
|
10
16
|
children
|
|
11
17
|
}));
|
|
12
18
|
MetricStrip.displayName = "MetricStrip";
|
|
13
|
-
/**
|
|
14
|
-
* One reading inside a `MetricStrip`.
|
|
15
|
-
*
|
|
16
|
-
* `<dt>` carries the label and `<dd>` the value, so the pair is announced as a
|
|
17
|
-
* described term rather than as two loose strings — the semantic that makes a
|
|
18
|
-
* figure legible without the visual grouping.
|
|
19
|
-
*
|
|
20
|
-
* The value is `tabular-nums`: these sit in a row and change on a timer, and
|
|
21
|
-
* proportional digits make the column jitter every time a 1 becomes an 8.
|
|
22
|
-
*/
|
|
19
|
+
/** A described term; the strip, not this item, decides where separators belong. */
|
|
23
20
|
const Metric = React.forwardRef(({ className, label, value, hint, attention, ...props }, ref) => /* @__PURE__ */ jsxs("div", {
|
|
24
21
|
ref,
|
|
25
|
-
className: cn("border-border p-4 sm:p-5",
|
|
22
|
+
className: cn("min-w-0 border-border p-4 sm:p-5", className),
|
|
26
23
|
...props,
|
|
27
24
|
children: [
|
|
28
25
|
/* @__PURE__ */ jsxs("dt", {
|
|
29
|
-
className: "flex items-center gap-2 text-muted-foreground text-sm",
|
|
26
|
+
className: "flex min-w-0 flex-wrap items-center gap-2 text-muted-foreground text-sm",
|
|
30
27
|
children: [/* @__PURE__ */ jsx("span", {
|
|
31
|
-
className: "
|
|
28
|
+
className: "min-w-0 whitespace-normal [overflow-wrap:anywhere]",
|
|
32
29
|
children: label
|
|
33
30
|
}), attention && /* @__PURE__ */ jsx(StatusPill, {
|
|
34
31
|
tone: attention.tone,
|
|
@@ -36,12 +33,12 @@ const Metric = React.forwardRef(({ className, label, value, hint, attention, ...
|
|
|
36
33
|
})]
|
|
37
34
|
}),
|
|
38
35
|
/* @__PURE__ */ jsx("dd", {
|
|
39
|
-
className: cn("mt-1
|
|
36
|
+
className: cn("mt-1 whitespace-normal [overflow-wrap:anywhere] font-semibold text-xl tabular-nums tracking-tight lg:text-2xl", attention?.tone === "danger" && "text-[var(--surface-danger-text)]"),
|
|
40
37
|
title: typeof value === "string" ? value : void 0,
|
|
41
38
|
children: value
|
|
42
39
|
}),
|
|
43
|
-
hint && /* @__PURE__ */ jsx("dd", {
|
|
44
|
-
className: "mt-0.5
|
|
40
|
+
hint !== void 0 && hint !== null && hint !== false && hint !== "" && /* @__PURE__ */ jsx("dd", {
|
|
41
|
+
className: "mt-0.5 whitespace-normal [overflow-wrap:anywhere] text-[var(--text-dim)] text-xs",
|
|
45
42
|
title: typeof hint === "string" ? hint : void 0,
|
|
46
43
|
children: hint
|
|
47
44
|
})
|
|
@@ -4,7 +4,10 @@ import * as SelectPrimitive from "@radix-ui/react-select";
|
|
|
4
4
|
declare const Select: React.FC<SelectPrimitive.SelectProps>;
|
|
5
5
|
declare const SelectGroup: React.ForwardRefExoticComponent<SelectPrimitive.SelectGroupProps & React.RefAttributes<HTMLDivElement>>;
|
|
6
6
|
declare const SelectValue: React.ForwardRefExoticComponent<SelectPrimitive.SelectValueProps & React.RefAttributes<HTMLSpanElement>>;
|
|
7
|
-
declare const SelectTrigger: React.ForwardRefExoticComponent<Omit<SelectPrimitive.SelectTriggerProps & React.RefAttributes<HTMLButtonElement>, "ref"> &
|
|
7
|
+
declare const SelectTrigger: React.ForwardRefExoticComponent<Omit<SelectPrimitive.SelectTriggerProps & React.RefAttributes<HTMLButtonElement>, "ref"> & {
|
|
8
|
+
/** Default remains 36px; compact follows density, touch is at least 44px. */
|
|
9
|
+
size?: "default" | "compact" | "touch";
|
|
10
|
+
} & React.RefAttributes<HTMLButtonElement>>;
|
|
8
11
|
declare const SelectScrollUpButton: React.ForwardRefExoticComponent<Omit<SelectPrimitive.SelectScrollUpButtonProps & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
|
|
9
12
|
declare const SelectScrollDownButton: React.ForwardRefExoticComponent<Omit<SelectPrimitive.SelectScrollDownButtonProps & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
|
|
10
13
|
declare const SelectContent: React.ForwardRefExoticComponent<Omit<SelectPrimitive.SelectContentProps & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { focusField } from "../lib/focus.js";
|
|
3
2
|
import { cn } from "../lib/utils.js";
|
|
3
|
+
import { controlSizes, fieldPresentation } from "../lib/control-presentation.js";
|
|
4
4
|
import * as React from "react";
|
|
5
5
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
6
|
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
|
@@ -9,13 +9,13 @@ import * as SelectPrimitive from "@radix-ui/react-select";
|
|
|
9
9
|
const Select = SelectPrimitive.Root;
|
|
10
10
|
const SelectGroup = SelectPrimitive.Group;
|
|
11
11
|
const SelectValue = SelectPrimitive.Value;
|
|
12
|
-
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => /* @__PURE__ */ jsxs(SelectPrimitive.Trigger, {
|
|
12
|
+
const SelectTrigger = React.forwardRef(({ className, children, size = "default", ...props }, ref) => /* @__PURE__ */ jsxs(SelectPrimitive.Trigger, {
|
|
13
13
|
ref,
|
|
14
|
-
className: cn("flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border
|
|
14
|
+
className: cn("flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", fieldPresentation, "data-[placeholder]:text-muted-foreground [&>span]:min-w-0 [&>span]:text-left", "data-[state=open]:border-primary data-[state=open]:bg-[var(--accent-surface-strong)] [&[data-state=open]>svg]:rotate-180 [&>svg]:transition-transform", size !== "default" && controlSizes[size], className),
|
|
15
15
|
...props,
|
|
16
16
|
children: [children, /* @__PURE__ */ jsx(SelectPrimitive.Icon, {
|
|
17
17
|
asChild: true,
|
|
18
|
-
children: /* @__PURE__ */ jsx(ChevronDown, { className: "h-4 w-4 opacity-50" })
|
|
18
|
+
children: /* @__PURE__ */ jsx(ChevronDown, { className: "h-4 w-4 shrink-0 opacity-50" })
|
|
19
19
|
})]
|
|
20
20
|
}));
|
|
21
21
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
|
@@ -35,7 +35,7 @@ const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) =
|
|
|
35
35
|
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
|
36
36
|
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => /* @__PURE__ */ jsx(SelectPrimitive.Portal, { children: /* @__PURE__ */ jsxs(SelectPrimitive.Content, {
|
|
37
37
|
ref,
|
|
38
|
-
className: cn("data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-
|
|
38
|
+
className: cn("data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-card text-foreground shadow-[var(--shadow-dropdown)] data-[state=closed]:animate-out data-[state=open]:animate-in", position === "popper" && "data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1", className),
|
|
39
39
|
position,
|
|
40
40
|
...props,
|
|
41
41
|
children: [
|
|
@@ -56,11 +56,11 @@ const SelectLabel = React.forwardRef(({ className, ...props }, ref) => /* @__PUR
|
|
|
56
56
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
|
57
57
|
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => /* @__PURE__ */ jsxs(SelectPrimitive.Item, {
|
|
58
58
|
ref,
|
|
59
|
-
className: cn("relative flex w-full cursor-default select-none items-center rounded-
|
|
59
|
+
className: cn("relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pr-8 pl-2 text-sm outline-none transition-colors focus:bg-[var(--accent-surface-strong)] focus:text-foreground data-[state=checked]:bg-[var(--accent-surface-strong)] data-[state=checked]:font-medium data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className),
|
|
60
60
|
...props,
|
|
61
61
|
children: [/* @__PURE__ */ jsx("span", {
|
|
62
62
|
className: "absolute right-2 flex h-3.5 w-3.5 items-center justify-center",
|
|
63
|
-
children: /* @__PURE__ */ jsx(SelectPrimitive.ItemIndicator, { children: /* @__PURE__ */ jsx(Check, { className: "h-4 w-4" }) })
|
|
63
|
+
children: /* @__PURE__ */ jsx(SelectPrimitive.ItemIndicator, { children: /* @__PURE__ */ jsx(Check, { className: "h-4 w-4 text-[var(--accent-text)]" }) })
|
|
64
64
|
}), /* @__PURE__ */ jsx(SelectPrimitive.ItemText, { children })]
|
|
65
65
|
}));
|
|
66
66
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
package/package.json
CHANGED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { focusField } from "./focus";
|
|
2
|
+
import { cn } from "./utils";
|
|
3
|
+
|
|
4
|
+
// Time-valued tokens only: --transition-fast is a legacy duration + easing
|
|
5
|
+
// shorthand, not a valid transition-duration. Reuse Brand's scoped vocabulary.
|
|
6
|
+
export const controlMotion =
|
|
7
|
+
"duration-[var(--duration-fast)] ease-[var(--ease-standard)] motion-reduce:transition-none motion-reduce:duration-0";
|
|
8
|
+
|
|
9
|
+
// Explicit opt-in sizes. Legacy defaults and sm/lg/icon sizes stay unchanged.
|
|
10
|
+
// Compact follows the consumer's density token; touch does not shrink with it.
|
|
11
|
+
export const controlSizes = {
|
|
12
|
+
compact: "h-[var(--control-height)] px-3 py-1 text-sm",
|
|
13
|
+
touch: "h-11 min-h-11 min-w-11 px-4 py-2 text-base",
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
// --input is the shadcn border/track role, NOT a field background. --bg-input
|
|
17
|
+
// resolves the recessed well inside every canonical/named light/dark scope.
|
|
18
|
+
// An autofill inset shadow coexists with the Tailwind focus ring; never hide
|
|
19
|
+
// autofill with a seconds-long background transition or clear the focus halo.
|
|
20
|
+
export const fieldPresentation = cn(
|
|
21
|
+
focusField,
|
|
22
|
+
controlMotion,
|
|
23
|
+
"min-w-0 bg-[var(--bg-input)] text-foreground",
|
|
24
|
+
"aria-invalid:border-[var(--surface-danger-border)] aria-invalid:hover:border-[var(--surface-danger-border)] aria-invalid:focus:border-[var(--focus-border-danger)] aria-invalid:focus:ring-[var(--focus-halo-danger)]",
|
|
25
|
+
"autofill:shadow-[inset_0_0_0_1000px_var(--bg-input)] autofill:[-webkit-text-fill-color:var(--text-primary)] autofill:caret-[var(--text-primary)]",
|
|
26
|
+
);
|
|
@@ -1,27 +1,28 @@
|
|
|
1
1
|
import { Slot } from "@radix-ui/react-slot";
|
|
2
2
|
import { cva } from "class-variance-authority";
|
|
3
3
|
import * as React from "react";
|
|
4
|
+
import { controlMotion, controlSizes } from "../lib/control-presentation";
|
|
4
5
|
import { focusRing } from "../lib/focus";
|
|
5
6
|
import { cn } from "../lib/utils";
|
|
6
7
|
|
|
7
8
|
const buttonVariants = cva(
|
|
8
|
-
`inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium text-sm transition-
|
|
9
|
+
`inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium text-sm transition-[color,background-color,border-color,box-shadow,transform,scale] ${controlMotion} ${focusRing} disabled:pointer-events-none disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 motion-reduce:active:scale-100 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0`,
|
|
9
10
|
{
|
|
10
11
|
variants: {
|
|
11
12
|
variant: {
|
|
12
13
|
default:
|
|
13
|
-
"bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.97]
|
|
14
|
+
"bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.97]",
|
|
14
15
|
destructive:
|
|
15
|
-
"bg-destructive text-destructive-foreground hover:bg-destructive/90 active:scale-[0.97]
|
|
16
|
+
"bg-destructive text-destructive-foreground hover:bg-destructive/90 active:scale-[0.97]",
|
|
16
17
|
outline:
|
|
17
|
-
"border border-border bg-card hover:bg-muted active:scale-[0.97]
|
|
18
|
+
"border border-border bg-card hover:bg-muted active:scale-[0.97] text-foreground",
|
|
18
19
|
secondary:
|
|
19
|
-
"bg-muted border border-border text-foreground hover:bg-muted/80 active:scale-[0.97]
|
|
20
|
+
"bg-muted border border-border text-foreground hover:bg-muted/80 active:scale-[0.97]",
|
|
20
21
|
ghost:
|
|
21
|
-
"hover:bg-muted hover:text-foreground
|
|
22
|
-
link: "text-
|
|
22
|
+
"hover:bg-muted hover:text-foreground text-muted-foreground border border-transparent",
|
|
23
|
+
link: "text-[var(--accent-text)] underline-offset-4 hover:underline",
|
|
23
24
|
sandbox:
|
|
24
|
-
"bg-[var(--btn-primary-bg)] text-[var(--btn-primary-text)] border border-[var(--border-accent)] hover:bg-[var(--btn-primary-hover)] active:scale-[0.97]
|
|
25
|
+
"bg-[var(--btn-primary-bg)] text-[var(--btn-primary-text)] border border-[var(--border-accent)] hover:bg-[var(--btn-primary-hover)] active:scale-[0.97]",
|
|
25
26
|
},
|
|
26
27
|
size: {
|
|
27
28
|
default: "h-[var(--control-height)] px-4 py-2",
|
|
@@ -29,6 +30,7 @@ const buttonVariants = cva(
|
|
|
29
30
|
lg: "h-11 rounded-lg px-7 text-sm",
|
|
30
31
|
xl: "h-13 rounded-xl px-9 text-base",
|
|
31
32
|
icon: "h-[var(--control-height)] w-[var(--control-height)]",
|
|
33
|
+
...controlSizes,
|
|
32
34
|
},
|
|
33
35
|
},
|
|
34
36
|
defaultVariants: {
|
|
@@ -41,7 +43,7 @@ const buttonVariants = cva(
|
|
|
41
43
|
export interface ButtonProps
|
|
42
44
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
43
45
|
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "sandbox" | null;
|
|
44
|
-
size?: "default" | "sm" | "lg" | "xl" | "icon" | null;
|
|
46
|
+
size?: "default" | "sm" | "lg" | "xl" | "icon" | "compact" | "touch" | null;
|
|
45
47
|
asChild?: boolean;
|
|
46
48
|
loading?: boolean;
|
|
47
49
|
children?: React.ReactNode;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import { Button } from "./button";
|
|
4
|
+
import { Input, Textarea } from "./input";
|
|
5
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
|
|
6
|
+
import { Metric, MetricStrip } from "./metric-strip";
|
|
7
|
+
|
|
8
|
+
function Contract() {
|
|
9
|
+
const [actions, setActions] = React.useState(0);
|
|
10
|
+
const [choice, setChoice] = React.useState("one");
|
|
11
|
+
return (
|
|
12
|
+
<main className="mx-auto max-w-5xl space-y-6 bg-background p-4 text-foreground" data-presentation-contract>
|
|
13
|
+
<h1 className="text-xl font-semibold">Field and metric presentation</h1>
|
|
14
|
+
{(["default", "compact", "touch"] as const).map((size) => (
|
|
15
|
+
<section key={size} data-size-row={size} className="space-y-2">
|
|
16
|
+
<h2 className="font-semibold">{size}</h2>
|
|
17
|
+
<div className="flex flex-wrap items-end gap-3">
|
|
18
|
+
<Input size={size} aria-label={`${size} field`} placeholder="Project name" className="w-48 max-w-full" />
|
|
19
|
+
<Select value={choice} onValueChange={setChoice}>
|
|
20
|
+
<SelectTrigger size={size} aria-label={`${size} choice`} className="w-48 max-w-full"><SelectValue /></SelectTrigger>
|
|
21
|
+
<SelectContent>
|
|
22
|
+
<SelectItem value="one">First account</SelectItem>
|
|
23
|
+
<SelectItem value="two">Second account with a descriptive long name</SelectItem>
|
|
24
|
+
</SelectContent>
|
|
25
|
+
</Select>
|
|
26
|
+
<Button size={size} onClick={() => setActions((value) => value + 1)}>Save</Button>
|
|
27
|
+
</div>
|
|
28
|
+
<Textarea size={size} aria-label={`${size} notes`} placeholder="Details" />
|
|
29
|
+
</section>
|
|
30
|
+
))}
|
|
31
|
+
<section className="space-y-2" data-state-controls>
|
|
32
|
+
<h2 className="font-semibold">States and actions</h2>
|
|
33
|
+
<Input label="Email" autoComplete="email" name="email" defaultValue="reader@example.test" />
|
|
34
|
+
<Input label="Invalid field" hint="The hint remains available" error="Please correct this value" defaultValue="invalid" />
|
|
35
|
+
<Input label="Disabled field" disabled defaultValue="Unavailable" />
|
|
36
|
+
<div className="flex flex-wrap gap-3">
|
|
37
|
+
<Button loading data-pending>Publish</Button>
|
|
38
|
+
<Button disabled>Disabled action</Button>
|
|
39
|
+
<Button variant="link" data-link-action onClick={() => setActions((value) => value + 1)}>Review details</Button>
|
|
40
|
+
<Button asChild loading><a href="#unavailable" data-pending-link>Pending link</a></Button>
|
|
41
|
+
<Button size="touch" className="h-auto max-w-full whitespace-normal" data-long-action onClick={() => setActions((value) => value + 1)}>Save changes and return to the organization workspace</Button>
|
|
42
|
+
</div>
|
|
43
|
+
<output data-action-count>{actions}</output>
|
|
44
|
+
<output data-choice>{choice}</output>
|
|
45
|
+
</section>
|
|
46
|
+
<section data-density style={{ "--control-height": "32px" } as React.CSSProperties} className="flex flex-wrap gap-3">
|
|
47
|
+
<Input size="compact" aria-label="Dense field" className="w-48" />
|
|
48
|
+
<Select><SelectTrigger size="compact" aria-label="Dense choice" className="w-48"><SelectValue placeholder="Choose" /></SelectTrigger></Select>
|
|
49
|
+
<Button size="compact">Dense action</Button>
|
|
50
|
+
<Button size="touch">Touch action</Button>
|
|
51
|
+
</section>
|
|
52
|
+
<section data-theme="light" className="space-y-3 rounded-lg bg-card p-4 text-foreground" data-scope="light">
|
|
53
|
+
<Input aria-label="Light island" defaultValue="Light ink and well" />
|
|
54
|
+
<div data-theme="dark" className="space-y-3 rounded-lg bg-card p-4 text-foreground" data-scope="dark">
|
|
55
|
+
<Input aria-label="Dark island" defaultValue="Nested dark ink and well" />
|
|
56
|
+
<div data-theme="aubergine-light" className="rounded-lg bg-card p-4 text-foreground" data-scope="named-light">
|
|
57
|
+
<Input aria-label="Named island" defaultValue="Nested named light well" />
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</section>
|
|
61
|
+
{([3, 4, 5, 6] as const).map((columns) => (
|
|
62
|
+
<MetricStrip key={columns} columns={columns} data-columns={columns}>
|
|
63
|
+
{Array.from({ length: columns }, (_, i) => <Metric key={i}
|
|
64
|
+
label={i === 0 ? "Available balance · Organization operations wallet" : `Metric ${i}`}
|
|
65
|
+
value={i === 0 ? "$1,284,003.10" : i === 1 ? 0 : i === 2 ? "—" : "12345678901234567890"}
|
|
66
|
+
hint={i === 0 ? "LongUnbrokenAccountIdentity12345678901234567890" : i === 1 ? 0 : "Current window"} />)}
|
|
67
|
+
</MetricStrip>
|
|
68
|
+
))}
|
|
69
|
+
<MetricStrip data-legacy-summary className="sm:grid-cols-3">
|
|
70
|
+
<Metric className="max-sm:col-span-2 max-sm:border-b" label="Total spent" value="$123.45" />
|
|
71
|
+
<Metric className="max-sm:border-l-0!" label="Calls" value={0} />
|
|
72
|
+
<Metric className="max-sm:border-l!" label="Average" value="$0.000000" />
|
|
73
|
+
</MetricStrip>
|
|
74
|
+
</main>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const meta = { title: "Primitives/Control Presentation", component: Contract, parameters: { layout: "fullscreen" } } satisfies Meta<typeof Contract>;
|
|
78
|
+
export default meta;
|
|
79
|
+
export const Presentation: StoryObj<typeof meta> = {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { controlMotion, fieldPresentation } from "../lib/control-presentation";
|
|
5
|
+
import { Button, buttonVariants } from "./button";
|
|
6
|
+
import { Input, Textarea } from "./input";
|
|
7
|
+
import { Select, SelectTrigger, SelectValue } from "./select";
|
|
8
|
+
|
|
9
|
+
afterEach(cleanup);
|
|
10
|
+
|
|
11
|
+
describe("shared control presentation", () => {
|
|
12
|
+
it("uses the field well, not the border/track alias, with scoped foreground and time-only motion", () => {
|
|
13
|
+
expect(fieldPresentation).toContain("bg-[var(--bg-input)]");
|
|
14
|
+
expect(fieldPresentation.split(" ")).not.toContain("bg-input");
|
|
15
|
+
expect(fieldPresentation.split(" ")).not.toContain("bg-card");
|
|
16
|
+
expect(fieldPresentation).toContain("border-border");
|
|
17
|
+
expect(fieldPresentation).toContain("text-foreground");
|
|
18
|
+
expect(fieldPresentation).not.toContain("duration-150");
|
|
19
|
+
expect(controlMotion).toContain("duration-[var(--duration-fast)]");
|
|
20
|
+
expect(controlMotion).not.toContain("--transition-fast");
|
|
21
|
+
expect(controlMotion).toContain("motion-reduce:duration-0");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("keeps original defaults and aligns only explicit compact/touch sizes", () => {
|
|
25
|
+
const { rerender } = render(<><Input aria-label="Input" /><Textarea aria-label="Textarea" /><Button>Action</Button><Select><SelectTrigger aria-label="Select"><SelectValue /></SelectTrigger></Select></>);
|
|
26
|
+
expect(screen.getByLabelText("Input").className.split(" ")).toContain("h-11");
|
|
27
|
+
expect(screen.getByLabelText("Textarea").className).toContain("min-h-[120px]");
|
|
28
|
+
expect(screen.getByLabelText("Select").className.split(" ")).toContain("h-9");
|
|
29
|
+
expect(screen.getByText("Action").className).toContain("h-[var(--control-height)]");
|
|
30
|
+
for (const size of ["compact", "touch"] as const) {
|
|
31
|
+
rerender(<><Input size={size} aria-label="Input" /><Textarea size={size} aria-label="Textarea" /><Button size={size}>Action</Button><Select><SelectTrigger size={size} aria-label="Select"><SelectValue /></SelectTrigger></Select></>);
|
|
32
|
+
for (const name of ["Input", "Select"]) {
|
|
33
|
+
expect(screen.getByLabelText(name).className).toContain(size === "compact" ? "h-[var(--control-height)]" : "min-h-11");
|
|
34
|
+
expect(screen.getByLabelText(name)).not.toHaveAttribute("size");
|
|
35
|
+
}
|
|
36
|
+
expect(screen.getByText("Action").className).toContain(size === "compact" ? "h-[var(--control-height)]" : "min-h-11");
|
|
37
|
+
expect(screen.getByLabelText("Textarea")).not.toHaveAttribute("size");
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("preserves field and trigger refs, caller overrides, invalid/disabled and autofill attributes", () => {
|
|
42
|
+
const input = React.createRef<HTMLInputElement>();
|
|
43
|
+
const textarea = React.createRef<HTMLTextAreaElement>();
|
|
44
|
+
const trigger = React.createRef<HTMLButtonElement>();
|
|
45
|
+
render(<><Input ref={input} aria-label="Email" autoComplete="email" aria-invalid className="h-8 bg-card" /><Textarea ref={textarea} aria-label="Notes" disabled /><Select disabled><SelectTrigger ref={trigger} aria-label="Choice" size="touch" className="h-12"><SelectValue placeholder="Choose" /></SelectTrigger></Select></>);
|
|
46
|
+
expect(input.current).toBe(screen.getByLabelText("Email"));
|
|
47
|
+
expect(input.current).toHaveAttribute("autocomplete", "email");
|
|
48
|
+
expect(input.current).toHaveAttribute("aria-invalid", "true");
|
|
49
|
+
expect(input.current!.className).toContain("autofill:shadow-");
|
|
50
|
+
expect(input.current!.className.split(" ")).toContain("bg-card");
|
|
51
|
+
expect(input.current!.className).not.toContain("bg-[var(--bg-input)]");
|
|
52
|
+
expect(textarea.current).toBeDisabled();
|
|
53
|
+
expect(trigger.current).toBe(screen.getByLabelText("Choice"));
|
|
54
|
+
expect(trigger.current).toBeDisabled();
|
|
55
|
+
expect(trigger.current!.className.split(" ")).toContain("h-12");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("uses accent ink for link actions while retaining filled action foregrounds and scale motion", () => {
|
|
59
|
+
expect(buttonVariants({ variant: "link" })).toContain("text-[var(--accent-text)]");
|
|
60
|
+
expect(buttonVariants({ variant: "default" })).toContain("text-primary-foreground");
|
|
61
|
+
expect(buttonVariants({ variant: "destructive" })).toContain("text-destructive-foreground");
|
|
62
|
+
expect(buttonVariants({ variant: "sandbox" })).toContain("text-[var(--btn-primary-text)]");
|
|
63
|
+
expect(buttonVariants()).not.toContain("transition-all");
|
|
64
|
+
expect(buttonVariants()).toContain("box-shadow,transform,scale");
|
|
65
|
+
expect(buttonVariants()).toContain("motion-reduce:active:scale-100");
|
|
66
|
+
});
|
|
67
|
+
});
|
package/src/primitives/input.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
|
-
import {
|
|
4
|
+
import { controlSizes, fieldPresentation } from "../lib/control-presentation";
|
|
5
|
+
import { focusFieldInvalid } from "../lib/focus";
|
|
5
6
|
import { cn } from "../lib/utils";
|
|
6
7
|
import { Label } from "./label";
|
|
7
8
|
|
|
@@ -9,8 +10,8 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|
|
9
10
|
|
|
10
11
|
const inputVariants = cva(
|
|
11
12
|
cn(
|
|
12
|
-
"flex w-full rounded-lg border
|
|
13
|
-
|
|
13
|
+
"flex w-full rounded-lg border px-4 py-2 text-sm placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 file:border-0 file:bg-transparent file:font-medium file:text-sm",
|
|
14
|
+
fieldPresentation,
|
|
14
15
|
),
|
|
15
16
|
{
|
|
16
17
|
variants: {
|
|
@@ -24,6 +25,7 @@ const inputVariants = cva(
|
|
|
24
25
|
default: "h-11",
|
|
25
26
|
sm: "h-9 px-3",
|
|
26
27
|
lg: "h-12 px-5",
|
|
28
|
+
...controlSizes,
|
|
27
29
|
}
|
|
28
30
|
},
|
|
29
31
|
defaultVariants: {
|
|
@@ -89,6 +91,8 @@ export interface TextareaProps
|
|
|
89
91
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
90
92
|
/** Both values render the same field. */
|
|
91
93
|
variant?: "default" | "sandbox";
|
|
94
|
+
/** Opt in to density/typing presentation without changing the default 120px well. */
|
|
95
|
+
size?: "default" | "compact" | "touch";
|
|
92
96
|
label?: string;
|
|
93
97
|
error?: string;
|
|
94
98
|
hint?: string;
|
|
@@ -98,7 +102,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
|
98
102
|
(
|
|
99
103
|
// `variant` is taken out so it never reaches the DOM element.
|
|
100
104
|
{
|
|
101
|
-
className, variant: _variant, label, error, hint, id,
|
|
105
|
+
className, variant: _variant, size = "default", label, error, hint, id,
|
|
102
106
|
"aria-describedby": describedBy, "aria-invalid": invalid, ...props
|
|
103
107
|
},
|
|
104
108
|
ref,
|
|
@@ -112,11 +116,13 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
|
112
116
|
<textarea
|
|
113
117
|
id={textareaId}
|
|
114
118
|
className={cn(
|
|
115
|
-
"flex min-h-[120px] w-full resize-y rounded-lg border
|
|
119
|
+
"flex min-h-[120px] w-full resize-y rounded-lg border px-4 py-3 text-sm",
|
|
116
120
|
"placeholder:text-muted-foreground",
|
|
117
121
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
118
|
-
|
|
122
|
+
fieldPresentation,
|
|
119
123
|
error && focusFieldInvalid,
|
|
124
|
+
size === "compact" && "min-h-24 px-3 py-2",
|
|
125
|
+
size === "touch" && "text-base",
|
|
120
126
|
className,
|
|
121
127
|
)}
|
|
122
128
|
ref={ref}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Metric, MetricStrip } from "./metric-strip";
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: "Primitives/MetricStrip",
|
|
6
|
+
component: MetricStrip,
|
|
7
|
+
parameters: { layout: "padded" },
|
|
8
|
+
} satisfies Meta<typeof MetricStrip>;
|
|
9
|
+
export default meta;
|
|
10
|
+
type Story = StoryObj<typeof meta>;
|
|
11
|
+
|
|
12
|
+
const readings = [
|
|
13
|
+
{ label: "Available balance · project wallet", value: "$1,284,003.10", hint: "This account only; not the organization total" },
|
|
14
|
+
{ label: "Calls", value: 0, hint: "Known zero" },
|
|
15
|
+
{ label: "Pending amount", value: "—", hint: "Not yet available" },
|
|
16
|
+
{ label: "Average cost / call", value: "$0.000123", hint: "Current window" },
|
|
17
|
+
{ label: "LongUnbrokenAccountIdentity1234567890", value: "12345678901234567890", hint: 0 },
|
|
18
|
+
{ label: "Errors requiring review", value: 0, hint: "No inferred warning" },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const Default: Story = {
|
|
22
|
+
args: { children: readings.slice(0, 4).map((reading) => <Metric key={reading.label} {...reading} />) },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const ThreeToSix: Story = {
|
|
26
|
+
args: { children: null },
|
|
27
|
+
render: () => (
|
|
28
|
+
<div className="space-y-6">
|
|
29
|
+
{([3, 4, 5, 6] as const).map((columns) => (
|
|
30
|
+
<section key={columns} className="space-y-2">
|
|
31
|
+
<h2 className="text-lg font-semibold">{columns} metrics</h2>
|
|
32
|
+
<MetricStrip columns={columns} aria-label={`${columns} metrics`}>
|
|
33
|
+
{readings.slice(0, columns).map((reading) => <Metric key={reading.label} {...reading} />)}
|
|
34
|
+
</MetricStrip>
|
|
35
|
+
</section>
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
),
|
|
39
|
+
};
|
|
@@ -1,181 +1,119 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
4
|
import { Metric, MetricStrip } from "./metric-strip";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
it("pairs each label with its value as a described term", () => {
|
|
7
|
-
render(
|
|
8
|
-
<MetricStrip>
|
|
9
|
-
<Metric label="Balance" value="$248.55" />
|
|
10
|
-
</MetricStrip>,
|
|
11
|
-
);
|
|
12
|
-
// The label is wrapped for truncation, so assert the term it belongs to.
|
|
13
|
-
expect(screen.getByText("Balance").closest("dt")).not.toBeNull();
|
|
14
|
-
expect(screen.getByText("$248.55").tagName).toBe("DD");
|
|
15
|
-
});
|
|
6
|
+
afterEach(cleanup);
|
|
16
7
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
// between the terms is a violation, and it was one.
|
|
20
|
-
it("puts only permitted elements directly inside the dl", () => {
|
|
8
|
+
describe("MetricStrip", () => {
|
|
9
|
+
it("pairs labels, values and qualifiers as native described terms", () => {
|
|
21
10
|
const { container } = render(
|
|
22
11
|
<MetricStrip>
|
|
23
|
-
<Metric label="
|
|
12
|
+
<Metric label="Balance" value="$248.55" hint="Personal wallet" />
|
|
24
13
|
<Metric label="Runs" value="41" />
|
|
25
14
|
</MetricStrip>,
|
|
26
15
|
);
|
|
27
|
-
|
|
16
|
+
expect(screen.getByText("Balance").closest("dt")).not.toBeNull();
|
|
17
|
+
expect(screen.getByText("$248.55").tagName).toBe("DD");
|
|
18
|
+
expect(screen.getByText("Personal wallet").tagName).toBe("DD");
|
|
19
|
+
const dl = container.querySelector("dl")!;
|
|
28
20
|
const permitted = new Set(["DT", "DD", "SCRIPT", "TEMPLATE", "DIV"]);
|
|
29
|
-
for (const child of Array.from(dl.children))
|
|
30
|
-
|
|
31
|
-
|
|
21
|
+
for (const child of Array.from(dl.children)) expect(permitted).toContain(child.tagName);
|
|
22
|
+
expect(dl.querySelectorAll("dt")).toHaveLength(2);
|
|
23
|
+
expect(dl.querySelectorAll("dd")).toHaveLength(3);
|
|
32
24
|
});
|
|
33
25
|
|
|
34
|
-
it("
|
|
35
|
-
render(
|
|
36
|
-
<MetricStrip>
|
|
37
|
-
<Metric label="Spend" value="$12.00" hint="Personal wallet" />
|
|
38
|
-
</MetricStrip>,
|
|
39
|
-
);
|
|
40
|
-
const hint = screen.getByText("Personal wallet");
|
|
41
|
-
expect(hint.tagName).toBe("DD");
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("omits the hint entirely when not given", () => {
|
|
26
|
+
it("omits absent qualifiers but preserves a numeric zero qualifier inside dd", () => {
|
|
45
27
|
const { container } = render(
|
|
46
28
|
<MetricStrip>
|
|
47
|
-
<Metric label="
|
|
29
|
+
<Metric label="Unknown" value="—" />
|
|
30
|
+
<Metric label="Known zero" value={0} hint={0} />
|
|
31
|
+
<Metric label="Unavailable" value={null} hint={false} />
|
|
32
|
+
<Metric label="Empty hint" value="" hint="" />
|
|
48
33
|
</MetricStrip>,
|
|
49
34
|
);
|
|
50
|
-
expect(container.querySelectorAll("dd")).toHaveLength(
|
|
35
|
+
expect(container.querySelectorAll("dd")).toHaveLength(5);
|
|
36
|
+
expect(screen.getByText("Unknown").closest("div")!.querySelector("dd")!.textContent).toBe("—");
|
|
37
|
+
const zero = screen.getByText("Known zero").closest("div")!;
|
|
38
|
+
expect(Array.from(zero.querySelectorAll("dd")).map((node) => node.textContent)).toEqual(["0", "0"]);
|
|
39
|
+
expect(Array.from(zero.childNodes).every((node) => node.nodeType === Node.ELEMENT_NODE)).toBe(true);
|
|
40
|
+
expect(screen.getByText("Unavailable").closest("div")!.querySelector("dd")!.textContent).toBe("");
|
|
51
41
|
});
|
|
52
42
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
it("shows a pill beside the label only when attention is set", () => {
|
|
56
|
-
const { rerender, container } = render(
|
|
57
|
-
<MetricStrip>
|
|
58
|
-
<Metric label="Balance" value="$0.00" />
|
|
59
|
-
</MetricStrip>,
|
|
60
|
-
);
|
|
43
|
+
it("does not infer attention from zero; explicit danger retains its pill and value tone", () => {
|
|
44
|
+
const { rerender, container } = render(<MetricStrip><Metric label="Balance" value="$0.00" /></MetricStrip>);
|
|
61
45
|
expect(container.querySelector("svg")).toBeNull();
|
|
62
|
-
|
|
63
|
-
rerender(
|
|
64
|
-
<MetricStrip>
|
|
65
|
-
<Metric
|
|
66
|
-
attention={{ tone: "danger", label: "Empty" }}
|
|
67
|
-
label="Balance"
|
|
68
|
-
value="$0.00"
|
|
69
|
-
/>
|
|
70
|
-
</MetricStrip>,
|
|
71
|
-
);
|
|
46
|
+
rerender(<MetricStrip><Metric label="Balance" value="$0.00" attention={{ tone: "danger", label: "Empty" }} /></MetricStrip>);
|
|
72
47
|
expect(screen.getByText("Empty")).toBeInTheDocument();
|
|
73
48
|
expect(container.querySelector("svg")).not.toBeNull();
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
it("raises the value's tone only for a danger attention", () => {
|
|
77
|
-
const { container } = render(
|
|
78
|
-
<MetricStrip>
|
|
79
|
-
<Metric
|
|
80
|
-
attention={{ tone: "danger", label: "Empty" }}
|
|
81
|
-
label="Balance"
|
|
82
|
-
value="$0.00"
|
|
83
|
-
/>
|
|
84
|
-
</MetricStrip>,
|
|
85
|
-
);
|
|
86
|
-
const value = screen.getByText("$0.00");
|
|
87
|
-
expect(value.className).toContain("--surface-danger-text");
|
|
49
|
+
expect(screen.getByText("$0.00").className).toContain("--surface-danger-text");
|
|
88
50
|
expect(container.querySelectorAll("dd")).toHaveLength(1);
|
|
51
|
+
rerender(<MetricStrip><Metric label="Balance" value="$0.00" attention={{ tone: "warning", label: "Review" }} /></MetricStrip>);
|
|
52
|
+
expect(screen.getByText("$0.00").className).not.toContain("--surface-danger-text");
|
|
89
53
|
});
|
|
90
54
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
it("draws the divider without a suppressing counterpart", () => {
|
|
97
|
-
const { container } = render(
|
|
98
|
-
<MetricStrip>
|
|
99
|
-
<Metric label="Balance" value="$0.00" />
|
|
100
|
-
</MetricStrip>,
|
|
101
|
-
);
|
|
102
|
-
const item = (container.querySelector("dl") as HTMLElement)
|
|
103
|
-
.children[0] as HTMLElement;
|
|
104
|
-
expect(item.className).not.toContain("border-l-0");
|
|
55
|
+
it("retains the default two / sm:four grid", () => {
|
|
56
|
+
const { container } = render(<MetricStrip><Metric label="Runs" value={0} /></MetricStrip>);
|
|
57
|
+
const dl = container.querySelector("dl")!;
|
|
58
|
+
expect(dl.className.split(" ")).toEqual(expect.arrayContaining(["grid-cols-2", "sm:grid-cols-4"]));
|
|
59
|
+
expect(dl.className).not.toContain("lg:grid-cols");
|
|
105
60
|
});
|
|
106
61
|
|
|
107
|
-
|
|
108
|
-
// breakpoint. Nothing in the type system ties them together, so changing the
|
|
109
|
-
// grid to three columns without moving the divider puts a rule mid-row. Read
|
|
110
|
-
// both out of the rendered classes and require them to agree, so the coupling
|
|
111
|
-
// is enforced rather than only described.
|
|
112
|
-
it("keeps the divider's row-start test matching the grid's column count", () => {
|
|
62
|
+
it.each([3, 4, 5, 6] as const)("owns the grid and all nonoverlapping row-start guards for %i columns", (columns) => {
|
|
113
63
|
const { container } = render(
|
|
114
|
-
<MetricStrip>
|
|
115
|
-
<Metric label=
|
|
64
|
+
<MetricStrip columns={columns}>
|
|
65
|
+
{Array.from({ length: columns }, (_, i) => <Metric key={i} label={`Metric ${i}`} value={i} />)}
|
|
116
66
|
</MetricStrip>,
|
|
117
67
|
);
|
|
118
|
-
const dl = container.querySelector("dl")
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
};
|
|
130
|
-
const modulusAt = (prefix: string) => {
|
|
131
|
-
const m = item.className.match(
|
|
132
|
-
new RegExp(`(?:^| )${prefix}:\\[&:not\\(:nth-child\\((\\d+)n\\+1\\)\\)\\]:border-l(?: |$)`),
|
|
133
|
-
);
|
|
134
|
-
return m ? Number(m[1]) : null;
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
// base grid (below sm) is guarded by the `max-sm:` divider rule
|
|
138
|
-
expect(modulusAt("max-sm")).toBe(columnsAt(""));
|
|
139
|
-
// the `sm:` grid is guarded by the `sm:` divider rule
|
|
140
|
-
expect(modulusAt("sm")).toBe(columnsAt("sm"));
|
|
141
|
-
|
|
142
|
-
// Every grid-cols breakpoint the strip declares must have a divider rule.
|
|
143
|
-
const declared = [...dl.className.matchAll(/(?:^| )(?:([a-z]+):)?grid-cols-\d+(?= |$)/g)]
|
|
144
|
-
.map((m) => m[1] ?? "");
|
|
145
|
-
for (const bp of declared) {
|
|
146
|
-
const guard = bp === "" ? "max-sm" : bp;
|
|
147
|
-
expect(
|
|
148
|
-
modulusAt(guard),
|
|
149
|
-
`grid-cols at "${bp || "base"}" has no matching ${guard}: divider rule`,
|
|
150
|
-
).not.toBeNull();
|
|
68
|
+
const dl = container.querySelector("dl")!;
|
|
69
|
+
expect(dl).not.toHaveAttribute("columns");
|
|
70
|
+
expect(dl.className).toContain("max-sm:[&>div:not(:nth-child(2n+1))]:border-l");
|
|
71
|
+
if (columns <= 4) {
|
|
72
|
+
expect(dl.className).toContain(`sm:grid-cols-${columns}`);
|
|
73
|
+
expect(dl.className).toContain(`sm:[&>div:not(:nth-child(${columns}n+1))]:border-l`);
|
|
74
|
+
} else {
|
|
75
|
+
expect(dl.className).toContain("sm:grid-cols-3");
|
|
76
|
+
expect(dl.className).toContain("sm:max-lg:[&>div:not(:nth-child(3n+1))]:border-l");
|
|
77
|
+
expect(dl.className).toContain(`lg:grid-cols-${columns}`);
|
|
78
|
+
expect(dl.className).toContain(`lg:[&>div:not(:nth-child(${columns}n+1))]:border-l`);
|
|
151
79
|
}
|
|
80
|
+
expect(dl.className).not.toContain("border-l-0");
|
|
81
|
+
for (const item of Array.from(dl.children)) expect(item.className).not.toContain("nth-child");
|
|
152
82
|
});
|
|
153
83
|
|
|
154
|
-
it("
|
|
155
|
-
render(
|
|
156
|
-
|
|
157
|
-
<Metric
|
|
158
|
-
hint="Personal wallet (0x1234abcd5678efgh)"
|
|
159
|
-
label="Balance"
|
|
160
|
-
value="$248.55"
|
|
161
|
-
/>
|
|
162
|
-
</MetricStrip>,
|
|
163
|
-
);
|
|
164
|
-
expect(screen.getByText("Personal wallet (0x1234abcd5678efgh)")).toHaveAttribute(
|
|
165
|
-
"title",
|
|
166
|
-
"Personal wallet (0x1234abcd5678efgh)",
|
|
167
|
-
);
|
|
84
|
+
it("does not give a standalone Metric a strip divider", () => {
|
|
85
|
+
const { container } = render(<Metric label="Runs" value={0} />);
|
|
86
|
+
expect(container.firstElementChild!.className).not.toContain("border-l");
|
|
168
87
|
});
|
|
169
88
|
|
|
170
|
-
it("
|
|
171
|
-
render(
|
|
172
|
-
|
|
173
|
-
|
|
89
|
+
it("wraps full labels, values and qualifiers without relying on a mouse title", () => {
|
|
90
|
+
render(<MetricStrip><Metric label="Very long account label without loss" value="$1,284,003.10" hint="Personal wallet (0x1234abcd5678efgh)" /></MetricStrip>);
|
|
91
|
+
for (const text of ["Very long account label without loss", "$1,284,003.10", "Personal wallet (0x1234abcd5678efgh)"]) {
|
|
92
|
+
const node = screen.getByText(text);
|
|
93
|
+
expect(node.className).toContain("[overflow-wrap:anywhere]");
|
|
94
|
+
expect(node.className).not.toContain("truncate");
|
|
95
|
+
}
|
|
96
|
+
expect(screen.getByText("$1,284,003.10")).toHaveAttribute("title", "$1,284,003.10");
|
|
97
|
+
expect(screen.getByText("Personal wallet (0x1234abcd5678efgh)")).toHaveAttribute("title", "Personal wallet (0x1234abcd5678efgh)");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("preserves refs, attributes, caller classes and fragment/conditional DOM order", () => {
|
|
101
|
+
const strip = React.createRef<HTMLDListElement>();
|
|
102
|
+
const item = React.createRef<HTMLDivElement>();
|
|
103
|
+
const { container } = render(
|
|
104
|
+
<MetricStrip ref={strip} aria-label="Usage" className="mb-6 sm:grid-cols-3">
|
|
105
|
+
<><Metric ref={item} data-testid="first" className="max-sm:col-span-2 max-sm:border-b" label="Spend" value={0} /></>
|
|
106
|
+
{false && <Metric label="Hidden" value={0} />}
|
|
107
|
+
<Metric className="max-sm:border-l-0!" label="Calls" value={0} />
|
|
108
|
+
<Metric className="max-sm:border-l!" label="Average" value="—" />
|
|
174
109
|
</MetricStrip>,
|
|
175
110
|
);
|
|
176
|
-
expect(
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
);
|
|
111
|
+
expect(strip.current).toBe(container.querySelector("dl"));
|
|
112
|
+
expect(strip.current).toHaveAttribute("aria-label", "Usage");
|
|
113
|
+
expect(item.current).toBe(screen.getByTestId("first"));
|
|
114
|
+
expect(item.current!.className).toContain("max-sm:col-span-2");
|
|
115
|
+
expect(strip.current!.className).toContain("sm:grid-cols-3");
|
|
116
|
+
expect(strip.current!.className).not.toContain("sm:grid-cols-4");
|
|
117
|
+
expect(Array.from(strip.current!.querySelectorAll("dt")).map((node) => node.textContent)).toEqual(["Spend", "Calls", "Average"]);
|
|
180
118
|
});
|
|
181
119
|
});
|
|
@@ -2,32 +2,32 @@ import * as React from "react";
|
|
|
2
2
|
import { cn } from "../lib/utils";
|
|
3
3
|
import { StatusPill, type StatusTone } from "./status-pill";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
* parent, because these wrap. `divide-x` draws from DOM order and leaves a
|
|
17
|
-
* stray rule at the start of every wrapped line; a per-item leading border
|
|
18
|
-
* suppressed at each row start does not.
|
|
19
|
-
*/
|
|
5
|
+
// Each finite preset owns its grid AND its row-start rules. Keep the strings
|
|
6
|
+
// literal so host Tailwind consumers scanning the published source emit them.
|
|
7
|
+
// Ranges do not overlap: no drawn border is subsequently suppressed.
|
|
8
|
+
const layouts = {
|
|
9
|
+
3: "grid-cols-2 sm:grid-cols-3 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:[&>div:not(:nth-child(3n+1))]:border-l",
|
|
10
|
+
4: "grid-cols-2 sm:grid-cols-4 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:[&>div:not(:nth-child(4n+1))]:border-l",
|
|
11
|
+
5: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:max-lg:[&>div:not(:nth-child(3n+1))]:border-l lg:[&>div:not(:nth-child(5n+1))]:border-l",
|
|
12
|
+
6: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:max-lg:[&>div:not(:nth-child(3n+1))]:border-l lg:[&>div:not(:nth-child(6n+1))]:border-l",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
/** Headline readings on one plane, with native definition-list semantics. */
|
|
20
16
|
export interface MetricStripProps
|
|
21
17
|
extends React.HTMLAttributes<HTMLDListElement> {
|
|
22
18
|
children: React.ReactNode;
|
|
19
|
+
/** Desktop columns. Default retains the existing two / sm:four layout.
|
|
20
|
+
* Five and six use three columns between sm and lg. Never inferred from data. */
|
|
21
|
+
columns?: 3 | 4 | 5 | 6;
|
|
23
22
|
}
|
|
24
23
|
|
|
25
24
|
const MetricStrip = React.forwardRef<HTMLDListElement, MetricStripProps>(
|
|
26
|
-
({ className, children, ...props }, ref) => (
|
|
25
|
+
({ className, children, columns = 4, ...props }, ref) => (
|
|
27
26
|
<dl
|
|
28
27
|
ref={ref}
|
|
29
28
|
className={cn(
|
|
30
|
-
"grid
|
|
29
|
+
"grid min-w-0 rounded-[var(--radius-lg)] border border-border bg-card shadow-[var(--shadow-card)]",
|
|
30
|
+
layouts[columns],
|
|
31
31
|
className,
|
|
32
32
|
)}
|
|
33
33
|
{...props}
|
|
@@ -43,72 +43,38 @@ export interface MetricProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
43
43
|
value: React.ReactNode;
|
|
44
44
|
/** The qualifying line: which wallet, which window, what the limit is. */
|
|
45
45
|
hint?: React.ReactNode;
|
|
46
|
-
/**
|
|
47
|
-
* `attention` only when a person has to DO something — not merely when a
|
|
48
|
-
* number is zero. */
|
|
46
|
+
/** Attention is a caller-owned action requirement, never inferred from zero. */
|
|
49
47
|
attention?: { tone: StatusTone; label: string };
|
|
50
48
|
}
|
|
51
49
|
|
|
52
|
-
/**
|
|
53
|
-
* One reading inside a `MetricStrip`.
|
|
54
|
-
*
|
|
55
|
-
* `<dt>` carries the label and `<dd>` the value, so the pair is announced as a
|
|
56
|
-
* described term rather than as two loose strings — the semantic that makes a
|
|
57
|
-
* figure legible without the visual grouping.
|
|
58
|
-
*
|
|
59
|
-
* The value is `tabular-nums`: these sit in a row and change on a timer, and
|
|
60
|
-
* proportional digits make the column jitter every time a 1 becomes an 8.
|
|
61
|
-
*/
|
|
50
|
+
/** A described term; the strip, not this item, decides where separators belong. */
|
|
62
51
|
const Metric = React.forwardRef<HTMLDivElement, MetricProps>(
|
|
63
52
|
({ className, label, value, hint, attention, ...props }, ref) => (
|
|
64
53
|
<div
|
|
65
54
|
ref={ref}
|
|
66
|
-
className={cn(
|
|
67
|
-
// Leading hairline, DRAWN only where a divider belongs — never drawn
|
|
68
|
-
// and then suppressed. Stating it as `border-l` plus `border-l-0`
|
|
69
|
-
// variants puts both in one conflict group, where the winner is decided
|
|
70
|
-
// by emitted-rule order rather than by intent; that resolved the wrong
|
|
71
|
-
// way at every breakpoint and left a rule down the start of each row.
|
|
72
|
-
// `:not(:nth-child(Nn+1))` is the row-start test for the column count
|
|
73
|
-
// at that breakpoint, so no rule ever contradicts another.
|
|
74
|
-
//
|
|
75
|
-
// N is COUPLED to the grid template on `MetricStrip`: 2 columns below
|
|
76
|
-
// `sm`, 4 from `sm`. A new breakpoint there — `md:grid-cols-3`, say —
|
|
77
|
-
// needs its `md:[&:not(:nth-child(3n+1))]:border-l` here in the same
|
|
78
|
-
// change, or the dividers land mid-row at that width.
|
|
79
|
-
"border-border p-4 sm:p-5",
|
|
80
|
-
"max-sm:[&:not(:nth-child(2n+1))]:border-l",
|
|
81
|
-
"sm:[&:not(:nth-child(4n+1))]:border-l",
|
|
82
|
-
className,
|
|
83
|
-
)}
|
|
55
|
+
className={cn("min-w-0 border-border p-4 sm:p-5", className)}
|
|
84
56
|
{...props}
|
|
85
57
|
>
|
|
86
|
-
<dt className="flex items-center gap-2 text-muted-foreground text-sm">
|
|
87
|
-
<span className="
|
|
58
|
+
<dt className="flex min-w-0 flex-wrap items-center gap-2 text-muted-foreground text-sm">
|
|
59
|
+
<span className="min-w-0 whitespace-normal [overflow-wrap:anywhere]">{label}</span>
|
|
88
60
|
{attention && (
|
|
89
61
|
<StatusPill tone={attention.tone}>{attention.label}</StatusPill>
|
|
90
62
|
)}
|
|
91
63
|
</dt>
|
|
92
|
-
{/*
|
|
93
|
-
|
|
94
|
-
funded account and as "$1,284,003.10" on a busy one, and at four
|
|
95
|
-
columns the wide case has nowhere to go — it either overflows its cell
|
|
96
|
-
or pushes the whole strip past the page. */}
|
|
64
|
+
{/* Values and qualifiers must be readable on touch, not only in a hover
|
|
65
|
+
title. Wrap even unbroken values without making the grid wider. */}
|
|
97
66
|
<dd
|
|
98
67
|
className={cn(
|
|
99
|
-
"mt-1
|
|
68
|
+
"mt-1 whitespace-normal [overflow-wrap:anywhere] font-semibold text-xl tabular-nums tracking-tight lg:text-2xl",
|
|
100
69
|
attention?.tone === "danger" && "text-[var(--surface-danger-text)]",
|
|
101
70
|
)}
|
|
102
71
|
title={typeof value === "string" ? value : undefined}
|
|
103
72
|
>
|
|
104
73
|
{value}
|
|
105
74
|
</dd>
|
|
106
|
-
{hint && (
|
|
107
|
-
// Titled for the same reason the value is: the line truncates, and a
|
|
108
|
-
// hint is where the qualifying detail lives — which wallet, which
|
|
109
|
-
// window — so a clipped one is the case most worth recovering.
|
|
75
|
+
{hint !== undefined && hint !== null && hint !== false && hint !== "" && (
|
|
110
76
|
<dd
|
|
111
|
-
className="mt-0.5
|
|
77
|
+
className="mt-0.5 whitespace-normal [overflow-wrap:anywhere] text-[var(--text-dim)] text-xs"
|
|
112
78
|
title={typeof hint === "string" ? hint : undefined}
|
|
113
79
|
>
|
|
114
80
|
{hint}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
|
4
4
|
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
|
5
5
|
import * as React from "react";
|
|
6
|
-
import {
|
|
6
|
+
import { controlSizes, fieldPresentation } from "../lib/control-presentation";
|
|
7
7
|
import { cn } from "../lib/utils";
|
|
8
8
|
|
|
9
9
|
const Select = SelectPrimitive.Root;
|
|
@@ -14,20 +14,27 @@ const SelectValue = SelectPrimitive.Value;
|
|
|
14
14
|
|
|
15
15
|
const SelectTrigger = React.forwardRef<
|
|
16
16
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
|
17
|
-
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
|
18
|
-
|
|
17
|
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
|
|
18
|
+
/** Default remains 36px; compact follows density, touch is at least 44px. */
|
|
19
|
+
size?: "default" | "compact" | "touch";
|
|
20
|
+
}
|
|
21
|
+
>(({ className, children, size = "default", ...props }, ref) => (
|
|
19
22
|
<SelectPrimitive.Trigger
|
|
20
23
|
ref={ref}
|
|
21
24
|
className={cn(
|
|
22
|
-
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border
|
|
23
|
-
|
|
25
|
+
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
|
26
|
+
fieldPresentation,
|
|
27
|
+
"data-[placeholder]:text-muted-foreground [&>span]:min-w-0 [&>span]:text-left",
|
|
28
|
+
// The designed open state: accent border and surface while the menu is up.
|
|
29
|
+
"data-[state=open]:border-primary data-[state=open]:bg-[var(--accent-surface-strong)] [&[data-state=open]>svg]:rotate-180 [&>svg]:transition-transform",
|
|
30
|
+
size !== "default" && controlSizes[size],
|
|
24
31
|
className,
|
|
25
32
|
)}
|
|
26
33
|
{...props}
|
|
27
34
|
>
|
|
28
35
|
{children}
|
|
29
36
|
<SelectPrimitive.Icon asChild>
|
|
30
|
-
<ChevronDown className="h-4 w-4 opacity-50" />
|
|
37
|
+
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
|
31
38
|
</SelectPrimitive.Icon>
|
|
32
39
|
</SelectPrimitive.Trigger>
|
|
33
40
|
));
|
|
@@ -76,7 +83,7 @@ const SelectContent = React.forwardRef<
|
|
|
76
83
|
<SelectPrimitive.Content
|
|
77
84
|
ref={ref}
|
|
78
85
|
className={cn(
|
|
79
|
-
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-
|
|
86
|
+
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-card text-foreground shadow-[var(--shadow-dropdown)] data-[state=closed]:animate-out data-[state=open]:animate-in",
|
|
80
87
|
position === "popper" &&
|
|
81
88
|
"data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1",
|
|
82
89
|
className,
|
|
@@ -119,14 +126,14 @@ const SelectItem = React.forwardRef<
|
|
|
119
126
|
<SelectPrimitive.Item
|
|
120
127
|
ref={ref}
|
|
121
128
|
className={cn(
|
|
122
|
-
"relative flex w-full cursor-default select-none items-center rounded-
|
|
129
|
+
"relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pr-8 pl-2 text-sm outline-none transition-colors focus:bg-[var(--accent-surface-strong)] focus:text-foreground data-[state=checked]:bg-[var(--accent-surface-strong)] data-[state=checked]:font-medium data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
123
130
|
className,
|
|
124
131
|
)}
|
|
125
132
|
{...props}
|
|
126
133
|
>
|
|
127
134
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
128
135
|
<SelectPrimitive.ItemIndicator>
|
|
129
|
-
<Check className="h-4 w-4" />
|
|
136
|
+
<Check className="h-4 w-4 text-[var(--accent-text)]" />
|
|
130
137
|
</SelectPrimitive.ItemIndicator>
|
|
131
138
|
</span>
|
|
132
139
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|