@delacour/react-native-ui 0.1.0 → 0.2.0-alpha.20261010061731
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/package.json +2 -2
- package/src/components/badge/AGENTS.md +5 -0
- package/src/components/badge/badge-close-button.tsx +2 -2
- package/src/components/badge/badge.context.tsx +2 -1
- package/src/components/badge/badge.tsx +7 -3
- package/src/components/badge/badge.variants.test.ts +26 -0
- package/src/components/badge/badge.variants.ts +21 -0
- package/src/components/button/AGENTS.md +6 -0
- package/src/components/button/button.tsx +5 -0
- package/src/components/button/button.variants.test.ts +36 -0
- package/src/components/button/button.variants.ts +16 -0
- package/src/components/card/AGENTS.md +4 -0
- package/src/components/card/card.tsx +9 -3
- package/src/components/input/AGENTS.md +5 -0
- package/src/components/input/input.variants.test.ts +32 -0
- package/src/components/input/input.variants.ts +13 -1
- package/src/components/surface/AGENTS.md +10 -0
- package/src/components/surface/surface.context.tsx +3 -1
- package/src/components/surface/surface.tsx +21 -4
- package/src/components/surface/surface.variants.test.ts +47 -0
- package/src/components/surface/surface.variants.ts +32 -0
- package/src/components/text/AGENTS.md +8 -2
- package/src/components/text/text.tsx +7 -0
- package/src/components/text/text.variants.test.ts +24 -1
- package/src/components/text/text.variants.ts +5 -0
- package/src/components/textarea/AGENTS.md +3 -0
- package/src/lib/cn.parity.test.ts +62 -0
- package/src/lib/cn.ts +12 -7
- package/src/styles/tokens.css +33 -0
- package/src/styles/tokens.test.ts +34 -0
- package/src/styles/tokens.ts +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delacour/react-native-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0-alpha.20261010061731",
|
|
4
4
|
"description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
"gen-exports": "bun scripts/gen-exports.ts"
|
|
111
111
|
},
|
|
112
112
|
"dependencies": {
|
|
113
|
-
"
|
|
113
|
+
"cn": "^0.4.0",
|
|
114
114
|
"tailwind-merge": "^3.6.0",
|
|
115
115
|
"tailwind-variants": "^3.3.1"
|
|
116
116
|
},
|
|
@@ -86,3 +86,8 @@ A compact label for status, category or count. Compound root plus `Badge.Label`,
|
|
|
86
86
|
- **String children** are wrapped in a `Badge.Label` automatically, consecutive
|
|
87
87
|
strings collapsing into one — the same rule, and the same reason, as
|
|
88
88
|
[`Button`](../button/AGENTS.md).
|
|
89
|
+
|
|
90
|
+
- **`material="etched"` squares the capsule and tints.** The root and the close button take
|
|
91
|
+
`rounded-sm`; `soft` status fills become `bg-<status>/8` in light and `/16` in dark, written out
|
|
92
|
+
per colour because Tailwind's scanner cannot see a built class. The material rides the badge
|
|
93
|
+
context so the close button follows the root.
|
|
@@ -30,13 +30,13 @@ export function BadgeCloseButton({
|
|
|
30
30
|
feedback = "fade",
|
|
31
31
|
...props
|
|
32
32
|
}: BadgeCloseButtonProps): ReactElement {
|
|
33
|
-
const { size, isDisabled } = useBadgePart("Badge.CloseButton");
|
|
33
|
+
const { size, isDisabled, material } = useBadgePart("Badge.CloseButton");
|
|
34
34
|
|
|
35
35
|
return (
|
|
36
36
|
<Pressable
|
|
37
37
|
accessibilityLabel={accessibilityLabel}
|
|
38
38
|
accessibilityRole="button"
|
|
39
|
-
className={badgeVariants({ size }).closeButton({ className })}
|
|
39
|
+
className={badgeVariants({ material, size }).closeButton({ className })}
|
|
40
40
|
disabled={isDisabled}
|
|
41
41
|
feedback={feedback}
|
|
42
42
|
{...props}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createContext, type ReactElement, type ReactNode, use } from "react";
|
|
2
|
-
import type { BadgeColor, BadgeSize, BadgeVariant } from "./badge.variants";
|
|
2
|
+
import type { BadgeColor, BadgeMaterial, BadgeSize, BadgeVariant } from "./badge.variants";
|
|
3
3
|
|
|
4
4
|
export type BadgeContextValue = {
|
|
5
5
|
/** Size of the badge. */
|
|
@@ -10,6 +10,7 @@ export type BadgeContextValue = {
|
|
|
10
10
|
color: BadgeColor;
|
|
11
11
|
/** Whether the badge is disabled. */
|
|
12
12
|
isDisabled: boolean;
|
|
13
|
+
material: BadgeMaterial;
|
|
13
14
|
};
|
|
14
15
|
|
|
15
16
|
const BadgeContext = createContext<BadgeContextValue | null>(null);
|
|
@@ -7,6 +7,7 @@ import { type BadgeContextValue, BadgeProvider } from "./badge.context";
|
|
|
7
7
|
import {
|
|
8
8
|
BADGE_FOREGROUND_TOKEN,
|
|
9
9
|
type BadgeColor,
|
|
10
|
+
type BadgeMaterial,
|
|
10
11
|
type BadgeSize,
|
|
11
12
|
type BadgeVariant,
|
|
12
13
|
badgeVariants,
|
|
@@ -23,6 +24,8 @@ export type BadgeProps = Omit<PressableProps, "asChild" | "busy" | "children" |
|
|
|
23
24
|
/** What the surface means. */
|
|
24
25
|
color?: BadgeColor;
|
|
25
26
|
size?: BadgeSize;
|
|
27
|
+
/** `flat` by default, a capsule. `etched` squares the corner and tints soft status fills as an alpha. */
|
|
28
|
+
material?: BadgeMaterial;
|
|
26
29
|
isDisabled?: boolean;
|
|
27
30
|
/** Composes a trailing dismiss control in. Its press never reaches `onPress`. */
|
|
28
31
|
onClose?: () => void;
|
|
@@ -36,6 +39,7 @@ export type BadgeProps = Omit<PressableProps, "asChild" | "busy" | "children" |
|
|
|
36
39
|
function BadgeRoot({
|
|
37
40
|
variant = "solid",
|
|
38
41
|
color = "default",
|
|
42
|
+
material = "flat",
|
|
39
43
|
size = "md",
|
|
40
44
|
isDisabled = false,
|
|
41
45
|
onClose,
|
|
@@ -52,11 +56,11 @@ function BadgeRoot({
|
|
|
52
56
|
...props
|
|
53
57
|
}: BadgeProps): ReactElement {
|
|
54
58
|
const context = useMemo<BadgeContextValue>(
|
|
55
|
-
() => ({ variant, color, size, isDisabled }),
|
|
56
|
-
[variant, color, size, isDisabled]
|
|
59
|
+
() => ({ variant, color, size, isDisabled, material }),
|
|
60
|
+
[variant, color, size, isDisabled, material]
|
|
57
61
|
);
|
|
58
62
|
|
|
59
|
-
const slots = badgeVariants({ color, isDisabled, size, variant });
|
|
63
|
+
const slots = badgeVariants({ color, isDisabled, material, size, variant });
|
|
60
64
|
|
|
61
65
|
// Icons composed into the badge adopt these unless told otherwise.
|
|
62
66
|
const iconClassName = slots.icon();
|
|
@@ -267,3 +267,29 @@ describe("resolveBadgeInteractive", () => {
|
|
|
267
267
|
expect(resolveBadgeInteractive({ onPress: noop, onLongPress: noop })).toBe(true);
|
|
268
268
|
});
|
|
269
269
|
});
|
|
270
|
+
|
|
271
|
+
describe("the etched badge material", () => {
|
|
272
|
+
test("flat keeps the capsule", () => {
|
|
273
|
+
expect(badgeVariants({}).root()).toContain("rounded-full");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("etched squares the corner to the small step", () => {
|
|
277
|
+
const cls = badgeVariants({ material: "etched" }).root();
|
|
278
|
+
expect(cls).toContain("rounded-sm");
|
|
279
|
+
expect(cls).not.toContain("rounded-full");
|
|
280
|
+
expect(badgeVariants({ material: "etched" }).closeButton()).toContain("rounded-sm");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("etched soft status fills are alpha tints of the status colour", () => {
|
|
284
|
+
for (const [color, fill] of [
|
|
285
|
+
["success", "bg-success/8"],
|
|
286
|
+
["warning", "bg-warning/8"],
|
|
287
|
+
["destructive", "bg-destructive/8"],
|
|
288
|
+
["info", "bg-info/8"],
|
|
289
|
+
] as const) {
|
|
290
|
+
const cls = badgeVariants({ color, material: "etched", variant: "soft" }).root();
|
|
291
|
+
expect(cls).toContain(fill);
|
|
292
|
+
expect(cls).toContain(`dark:${fill.replace("/8", "/16")}`);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
});
|
|
@@ -7,10 +7,18 @@ export const BADGE_VARIANTS = ["solid", "soft", "outline", "ghost"] as const;
|
|
|
7
7
|
/** What the surface means. Orthogonal to {@link BADGE_VARIANTS}. */
|
|
8
8
|
export const BADGE_COLORS = ["default", "primary", "success", "warning", "destructive", "info"] as const;
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* `flat` is the library default, a capsule. `etched` squares it to the small
|
|
12
|
+
* corner and tints the status fills as a low alpha of the status colour, so a
|
|
13
|
+
* badge sits quietly on any surface it lands on.
|
|
14
|
+
*/
|
|
15
|
+
export const BADGE_MATERIALS = ["flat", "etched"] as const;
|
|
16
|
+
|
|
10
17
|
export const BADGE_SIZES = ["sm", "md", "lg"] as const;
|
|
11
18
|
|
|
12
19
|
export type BadgeVariant = (typeof BADGE_VARIANTS)[number];
|
|
13
20
|
export type BadgeColor = (typeof BADGE_COLORS)[number];
|
|
21
|
+
export type BadgeMaterial = (typeof BADGE_MATERIALS)[number];
|
|
14
22
|
export type BadgeSize = (typeof BADGE_SIZES)[number];
|
|
15
23
|
|
|
16
24
|
/**
|
|
@@ -148,6 +156,7 @@ export const badgeVariants = tv({
|
|
|
148
156
|
icon: "size-icon-md",
|
|
149
157
|
},
|
|
150
158
|
},
|
|
159
|
+
material: { flat: {}, etched: {} },
|
|
151
160
|
// The empty `false` branch is load-bearing typing, not a placeholder.
|
|
152
161
|
// See the note in button.variants.ts.
|
|
153
162
|
isDisabled: { true: { root: "opacity-50" }, false: {} },
|
|
@@ -204,6 +213,18 @@ export const badgeVariants = tv({
|
|
|
204
213
|
{ variant: "ghost", color: "warning", class: { label: "text-warning-soft-foreground" } },
|
|
205
214
|
{ variant: "ghost", color: "destructive", class: { label: "text-destructive-soft-foreground" } },
|
|
206
215
|
{ variant: "ghost", color: "info", class: { label: "text-info-soft-foreground" } },
|
|
216
|
+
|
|
217
|
+
// Written out per colour: Tailwind's scanner cannot see `bg-${color}/8`.
|
|
218
|
+
{ material: "etched", class: { root: "rounded-sm", closeButton: "rounded-sm" } },
|
|
219
|
+
{ material: "etched", variant: "soft", color: "success", class: { root: "bg-success/8 dark:bg-success/16" } },
|
|
220
|
+
{ material: "etched", variant: "soft", color: "warning", class: { root: "bg-warning/8 dark:bg-warning/16" } },
|
|
221
|
+
{
|
|
222
|
+
material: "etched",
|
|
223
|
+
variant: "soft",
|
|
224
|
+
color: "destructive",
|
|
225
|
+
class: { root: "bg-destructive/8 dark:bg-destructive/16" },
|
|
226
|
+
},
|
|
227
|
+
{ material: "etched", variant: "soft", color: "info", class: { root: "bg-info/8 dark:bg-info/16" } },
|
|
207
228
|
],
|
|
208
229
|
defaultVariants: {
|
|
209
230
|
variant: "solid",
|
|
@@ -249,3 +249,9 @@ for a chunk that says something rather than doing something. An
|
|
|
249
249
|
than `primary` when nothing names a variant: a chunk that cannot be pressed
|
|
250
250
|
should not wear the group's action paint. A group that *does* name a variant
|
|
251
251
|
is followed, so an outline run reads as one piece.
|
|
252
|
+
|
|
253
|
+
- **`material="etched"` is opt-in and per fill.** `primary` takes `shadow-etched-primary` (a white
|
|
254
|
+
inset top edge at 16%); `secondary`, `tertiary` and `outline` take the light/dark edge
|
|
255
|
+
(`shadow-etched dark:shadow-etched-dark`); `ghost`, `destructive` and `destructive-soft` stay flat
|
|
256
|
+
— a ghost has no surface to lift and a signal colour does not take a second highlight. The press
|
|
257
|
+
scale is already 0.97. The tokens live in `tokens.css`.
|
|
@@ -7,6 +7,7 @@ import { type ButtonContextValue, ButtonProvider, useButtonGroupItemContext } fr
|
|
|
7
7
|
import {
|
|
8
8
|
BUTTON_FOREGROUND_TOKEN,
|
|
9
9
|
type ButtonLayout,
|
|
10
|
+
type ButtonMaterial,
|
|
10
11
|
type ButtonSize,
|
|
11
12
|
type ButtonSpinnerPlacement,
|
|
12
13
|
type ButtonVariant,
|
|
@@ -23,6 +24,8 @@ import { ButtonStartContent } from "./button-start-content";
|
|
|
23
24
|
|
|
24
25
|
export type ButtonProps = Omit<PressableProps, "busy" | "children" | "disabled" | "pressedOpacity" | "pressedScale"> & {
|
|
25
26
|
variant?: ButtonVariant;
|
|
27
|
+
/** `flat` by default; `etched` sets a highlight into the fill's edge. */
|
|
28
|
+
material?: ButtonMaterial;
|
|
26
29
|
/** Height, label step, icon step, corner and padding on one axis. `icon-*` is a square. */
|
|
27
30
|
size?: ButtonSize;
|
|
28
31
|
isDisabled?: boolean;
|
|
@@ -37,6 +40,7 @@ export type ButtonProps = Omit<PressableProps, "busy" | "children" | "disabled"
|
|
|
37
40
|
|
|
38
41
|
function ButtonRoot({
|
|
39
42
|
variant,
|
|
43
|
+
material = "flat",
|
|
40
44
|
size,
|
|
41
45
|
isDisabled,
|
|
42
46
|
isLoading = false,
|
|
@@ -75,6 +79,7 @@ function ButtonRoot({
|
|
|
75
79
|
isLoading,
|
|
76
80
|
isSeamed: item?.isSeamed ?? false,
|
|
77
81
|
isStretched: item?.isStretched ?? false,
|
|
82
|
+
material,
|
|
78
83
|
orientation: item?.orientation ?? "horizontal",
|
|
79
84
|
size: resolvedSize,
|
|
80
85
|
variant: resolvedVariant,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
BUTTON_GROUP_SEPARATOR_ORIENTATION,
|
|
9
9
|
BUTTON_ICON_SIZES,
|
|
10
10
|
BUTTON_LABEL_SIZES,
|
|
11
|
+
BUTTON_MATERIALS,
|
|
11
12
|
BUTTON_SIZES,
|
|
12
13
|
BUTTON_SPINNER_PLACEMENTS,
|
|
13
14
|
BUTTON_VARIANTS,
|
|
@@ -812,6 +813,41 @@ describe("resolveGroupedButtonSize", () => {
|
|
|
812
813
|
});
|
|
813
814
|
});
|
|
814
815
|
|
|
816
|
+
describe("the etched button material", () => {
|
|
817
|
+
test("flat is the default and adds no shadow", () => {
|
|
818
|
+
for (const variant of BUTTON_VARIANTS) {
|
|
819
|
+
expect(buttonVariants({ variant }).root()).toBe(buttonVariants({ variant, material: "flat" }).root());
|
|
820
|
+
expect(buttonVariants({ variant }).root()).not.toMatch(/shadow/);
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
test("etched primary carries the white inset highlight", () => {
|
|
825
|
+
const cls = buttonVariants({ material: "etched", variant: "primary" }).root();
|
|
826
|
+
expect(cls).toContain("shadow-etched-primary");
|
|
827
|
+
expect(cls).not.toContain("shadow-etched ");
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
test("etched filled and outlined buttons carry the light and dark edge", () => {
|
|
831
|
+
for (const variant of ["secondary", "tertiary", "outline"] as const) {
|
|
832
|
+
const cls = buttonVariants({ material: "etched", variant }).root();
|
|
833
|
+
expect(cls).toContain("shadow-etched");
|
|
834
|
+
expect(cls).toContain("dark:shadow-etched-dark");
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
// A ghost has no surface to lift, and a destructive fill is a signal colour
|
|
839
|
+
// whose highlight would read as a second state.
|
|
840
|
+
test("ghost and destructive variants stay flat", () => {
|
|
841
|
+
for (const variant of ["ghost", "destructive", "destructive-soft"] as const) {
|
|
842
|
+
expect(buttonVariants({ material: "etched", variant }).root()).not.toMatch(/shadow/);
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
test("lists every material", () => {
|
|
847
|
+
expect(BUTTON_MATERIALS).toEqual(["flat", "etched"]);
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
|
|
815
851
|
describe("a detached group", () => {
|
|
816
852
|
test("places every member on its own", () => {
|
|
817
853
|
// `only` is the lone member's position and draws the lone button's corner,
|
|
@@ -13,6 +13,14 @@ export const BUTTON_VARIANTS = [
|
|
|
13
13
|
] as const;
|
|
14
14
|
|
|
15
15
|
/** Sizes that hold a label. Horizontal padding; the width comes from the content. */
|
|
16
|
+
/**
|
|
17
|
+
* `flat` is the library default. `etched` sets the highlight into the fill's
|
|
18
|
+
* edge — a white inset top edge on `primary`, the light/dark edge on the quiet
|
|
19
|
+
* fills. Ghost and destructive buttons stay flat: a ghost has no surface to
|
|
20
|
+
* lift, and a signal colour does not take a second highlight.
|
|
21
|
+
*/
|
|
22
|
+
export const BUTTON_MATERIALS = ["flat", "etched"] as const;
|
|
23
|
+
|
|
16
24
|
export const BUTTON_LABEL_SIZES = ["sm", "md", "lg"] as const;
|
|
17
25
|
|
|
18
26
|
/** Sizes with a square footprint, for a button whose only content is an icon. */
|
|
@@ -33,6 +41,7 @@ export const BUTTON_GROUP_ORIENTATIONS = ["horizontal", "vertical"] as const;
|
|
|
33
41
|
|
|
34
42
|
export const BUTTON_GROUP_POSITIONS = ["first", "middle", "last", "only"] as const;
|
|
35
43
|
|
|
44
|
+
export type ButtonMaterial = (typeof BUTTON_MATERIALS)[number];
|
|
36
45
|
export type ButtonVariant = (typeof BUTTON_VARIANTS)[number];
|
|
37
46
|
export type ButtonLabelSize = (typeof BUTTON_LABEL_SIZES)[number];
|
|
38
47
|
export type ButtonIconSize = (typeof BUTTON_ICON_SIZES)[number];
|
|
@@ -217,6 +226,7 @@ export const buttonVariants = tv({
|
|
|
217
226
|
// orientation, so it lands here rather than in six compound cells that
|
|
218
227
|
// would all say the same word.
|
|
219
228
|
groupPosition: { none: {}, first: {}, middle: { root: "rounded-none" }, last: {}, only: {} },
|
|
229
|
+
material: { flat: {}, etched: {} },
|
|
220
230
|
// The empty `false` branches are load-bearing typing, not placeholders.
|
|
221
231
|
// `tv` derives the prop type from the declared keys, so a map with only
|
|
222
232
|
// `true` types the prop as `true` rather than `boolean` and rejects
|
|
@@ -229,6 +239,12 @@ export const buttonVariants = tv({
|
|
|
229
239
|
isDimmedWhileLoading: { true: {}, false: {} },
|
|
230
240
|
},
|
|
231
241
|
compoundVariants: [
|
|
242
|
+
{ material: "etched", variant: "primary", class: { root: "shadow-etched-primary" } },
|
|
243
|
+
{
|
|
244
|
+
material: "etched",
|
|
245
|
+
variant: ["secondary", "tertiary", "outline"],
|
|
246
|
+
class: { root: "shadow-etched dark:shadow-etched-dark" },
|
|
247
|
+
},
|
|
232
248
|
// Loading is not a disabled state. The button keeps full contrast — the
|
|
233
249
|
// spinner already says the press landed — unless the caller opts in.
|
|
234
250
|
{ isLoading: true, isDimmedWhileLoading: true, class: { root: "opacity-50" } },
|
|
@@ -86,3 +86,7 @@ A content surface with a header, a body and a footer, built on
|
|
|
86
86
|
that knows what the press means. The playground's plan picker is the example.
|
|
87
87
|
- **No text treatment on the root, header, content or footer** (rule 1). The
|
|
88
88
|
tests assert it across every combination.
|
|
89
|
+
|
|
90
|
+
- **`material` passes straight to `Surface`.** `etched` lifts the card with the edge highlight;
|
|
91
|
+
`tray` frames panels. A card inside a tray takes the xl corner and the card fill. Pair `etched`
|
|
92
|
+
with `className="rounded-2xl"` for the 18-point corner; the radius is not part of the material.
|
|
@@ -15,19 +15,25 @@ export type CardProps = Omit<SurfaceProps, "padding"> & {
|
|
|
15
15
|
size?: CardSize;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
-
function CardRoot({ variant, size = "md", className, children, ...props }: CardProps): ReactElement {
|
|
18
|
+
function CardRoot({ variant, material = "flat", size = "md", className, children, ...props }: CardProps): ReactElement {
|
|
19
19
|
const parentPlane = useSurfaceContext()?.plane ?? null;
|
|
20
20
|
|
|
21
21
|
// Resolved here as well as inside `Surface`, because the parts need the plane
|
|
22
22
|
// the card landed on — the title's colour and a band footer's fill key off it.
|
|
23
23
|
const resolved = resolveSurfaceVariant({ parentPlane, variant });
|
|
24
|
-
const plane = resolveSurfacePlane({ parentPlane, variant: resolved });
|
|
24
|
+
const plane = resolveSurfacePlane({ material, parentPlane, variant: resolved });
|
|
25
25
|
|
|
26
26
|
const context = useMemo<CardContextValue>(() => ({ plane, size, variant: resolved }), [plane, size, resolved]);
|
|
27
27
|
|
|
28
28
|
return (
|
|
29
29
|
<CardProvider value={context}>
|
|
30
|
-
<Surface
|
|
30
|
+
<Surface
|
|
31
|
+
className={cardVariants({ size }).root({ className })}
|
|
32
|
+
material={material}
|
|
33
|
+
padding="none"
|
|
34
|
+
variant={resolved}
|
|
35
|
+
{...props}
|
|
36
|
+
>
|
|
31
37
|
{children}
|
|
32
38
|
</Surface>
|
|
33
39
|
</CardProvider>
|
|
@@ -124,3 +124,8 @@ reads as one control rather than two boxes touching.
|
|
|
124
124
|
wins. Disabled is the outermost rung of an already-three-deep ladder
|
|
125
125
|
(`Input.Group` → own prop → `Field` → `Button.Group`), so a single field in a
|
|
126
126
|
run can still be the only one disabled.
|
|
127
|
+
|
|
128
|
+
- **`variant="etched"` is the opt-in devl field.** `border-input bg-card` with the light/dark edge
|
|
129
|
+
highlight; focus swaps it for `border-ring` and a 3pt neutral halo (`shadow-focus`, `-dark`).
|
|
130
|
+
The halo is a plain alpha, not `color-mix` on the ring: `color-mix` inside a `box-shadow` does not
|
|
131
|
+
survive Uniwind's compile and draws nothing. Invalid keeps the highlight and the destructive border.
|
|
@@ -367,3 +367,35 @@ describe("a joined field's corner", () => {
|
|
|
367
367
|
}
|
|
368
368
|
});
|
|
369
369
|
});
|
|
370
|
+
|
|
371
|
+
describe("the etched input variant", () => {
|
|
372
|
+
test("is listed after the two flat variants", () => {
|
|
373
|
+
expect(INPUT_VARIANTS).toEqual(["primary", "secondary", "etched"]);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("is a card fill with the input border and the light and dark edge", () => {
|
|
377
|
+
const cls = inputVariants({ variant: "etched" }).root();
|
|
378
|
+
expect(cls).toContain("bg-card");
|
|
379
|
+
expect(cls).toContain("border-input");
|
|
380
|
+
expect(cls).toContain("shadow-etched");
|
|
381
|
+
expect(cls).toContain("dark:shadow-etched-dark");
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("focus swaps the border to the ring and adds the 3px halo", () => {
|
|
385
|
+
const cls = inputVariants({ isFocused: true, variant: "etched" }).root();
|
|
386
|
+
expect(cls).toContain("border-ring");
|
|
387
|
+
expect(cls).toContain("shadow-focus");
|
|
388
|
+
expect(cls).toContain("dark:shadow-focus-dark");
|
|
389
|
+
expect(cls).not.toContain("shadow-etched");
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("invalid outranks focus", () => {
|
|
393
|
+
const cls = inputVariants({ isFocused: true, isInvalid: true, variant: "etched" }).root();
|
|
394
|
+
expect(cls).toContain("border-destructive");
|
|
395
|
+
expect(cls).not.toContain("shadow-focus");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("the flat variants draw no shadow", () => {
|
|
399
|
+
expect(inputVariants({ isFocused: true, variant: "primary" }).root()).not.toMatch(/shadow/);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
@@ -3,7 +3,7 @@ import { cn } from "../../lib/cn";
|
|
|
3
3
|
import { tv } from "../../lib/tv";
|
|
4
4
|
import type { ButtonGroupOrientation, ButtonGroupSlotPosition } from "../button/button.variants";
|
|
5
5
|
|
|
6
|
-
export const INPUT_VARIANTS = ["primary", "secondary"] as const;
|
|
6
|
+
export const INPUT_VARIANTS = ["primary", "secondary", "etched"] as const;
|
|
7
7
|
|
|
8
8
|
export const INPUT_SIZES = ["sm", "md", "lg"] as const;
|
|
9
9
|
|
|
@@ -79,6 +79,9 @@ export const inputVariants = tv({
|
|
|
79
79
|
variant: {
|
|
80
80
|
primary: { root: "border-input bg-card" },
|
|
81
81
|
secondary: { root: "border-transparent bg-secondary" },
|
|
82
|
+
// The card fill with the highlight set into its edge. Focus swaps
|
|
83
|
+
// that for a ring border and a three-point halo — see the compound.
|
|
84
|
+
etched: { root: "border-input bg-card shadow-etched dark:shadow-etched-dark" },
|
|
82
85
|
},
|
|
83
86
|
size: {
|
|
84
87
|
sm: {
|
|
@@ -145,6 +148,15 @@ export const inputVariants = tv({
|
|
|
145
148
|
// field that went grey the moment it was tapped would drop the only
|
|
146
149
|
// signal it has that its value is wrong, exactly while it is being fixed.
|
|
147
150
|
{ isFocused: true, isInvalid: true, class: { root: "border-destructive" } },
|
|
151
|
+
// An etched field trades its edge highlight for the halo while it holds
|
|
152
|
+
// focus. Invalid keeps the highlight and the destructive border instead,
|
|
153
|
+
// so a wrong value never glows in the ring's colour.
|
|
154
|
+
{
|
|
155
|
+
isFocused: true,
|
|
156
|
+
isInvalid: false,
|
|
157
|
+
variant: "etched",
|
|
158
|
+
class: { root: "border-ring shadow-focus dark:shadow-focus-dark" },
|
|
159
|
+
},
|
|
148
160
|
// The corner a lone field draws: the generic ramp, deliberately not the
|
|
149
161
|
// button's, because a field and the button beside it are meant to be
|
|
150
162
|
// retunable apart. `sm` steps down a notch; the other two share `lg`.
|
|
@@ -70,3 +70,13 @@ components built on it (`Card`, `Alert`) can read the plane they sit on.
|
|
|
70
70
|
- **No role.** A surface is layout, not a landmark; it adds nothing a screen
|
|
71
71
|
reader could announce. `ViewProps` pass through, so a caller that does mean
|
|
72
72
|
one — `accessibilityRole="summary"` — sets it.
|
|
73
|
+
|
|
74
|
+
- **`material` is what the surface is made of, separate from which plane it fills.** `flat` is the
|
|
75
|
+
default and draws nothing extra. `etched` adds `shadow-etched dark:shadow-etched-dark`: a one-pixel
|
|
76
|
+
highlight set into the edge (a darkening on the bottom in light, a lightening on the top in dark)
|
|
77
|
+
and a very soft drop. `tray` is the muted frame that holds panels — `bg-muted/70`, `p-1`,
|
|
78
|
+
`rounded-2xl`, whatever `padding` says. A tray paints its own fill, so it resets the plane to
|
|
79
|
+
`null` (`resolveSurfacePlane`) and a surface inside it resolves to the card fill, not the next rung
|
|
80
|
+
down; `SurfaceContext.material` is how that panel knows to take `rounded-xl`, one step under the
|
|
81
|
+
tray's corner. A single `shadow-*` utility sets one `box-shadow`, which is why light and dark are
|
|
82
|
+
two tokens joined by `dark:` and not one composed class. Opt-in: nothing in the default look uses it.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createContext, type ReactElement, type ReactNode, use } from "react";
|
|
2
|
-
import type { SurfacePlane, SurfaceVariant } from "./surface.variants";
|
|
2
|
+
import type { SurfaceMaterial, SurfacePlane, SurfaceVariant } from "./surface.variants";
|
|
3
3
|
|
|
4
4
|
export type SurfaceContextValue = {
|
|
5
5
|
/** The fill the enclosing surface resolved to — explicit, or stepped from its own parent. */
|
|
@@ -10,6 +10,8 @@ export type SurfaceContextValue = {
|
|
|
10
10
|
* plane beneath it through — `null` when there is none.
|
|
11
11
|
*/
|
|
12
12
|
plane: SurfacePlane | null;
|
|
13
|
+
/** What the surface is made of. A `tray` tells the panels inside it to take the inner corner. */
|
|
14
|
+
material: SurfaceMaterial;
|
|
13
15
|
};
|
|
14
16
|
|
|
15
17
|
const SurfaceContext = createContext<SurfaceContextValue | null>(null);
|
|
@@ -4,6 +4,7 @@ import { type SurfaceContextValue, SurfaceProvider, useSurfaceContext } from "./
|
|
|
4
4
|
import {
|
|
5
5
|
resolveSurfacePlane,
|
|
6
6
|
resolveSurfaceVariant,
|
|
7
|
+
type SurfaceMaterial,
|
|
7
8
|
type SurfacePadding,
|
|
8
9
|
type SurfaceVariant,
|
|
9
10
|
surfaceVariants,
|
|
@@ -17,6 +18,8 @@ export type SurfaceProps = ViewProps & {
|
|
|
17
18
|
variant?: SurfaceVariant;
|
|
18
19
|
/** Inner spacing. `none` also clips, for content bled to the corners. */
|
|
19
20
|
padding?: SurfacePadding;
|
|
21
|
+
/** `flat` by default. `etched` sets a highlight into the edge; `tray` is the muted frame that holds panels. */
|
|
22
|
+
material?: SurfaceMaterial;
|
|
20
23
|
className?: string;
|
|
21
24
|
children?: ReactNode;
|
|
22
25
|
};
|
|
@@ -50,18 +53,32 @@ export type SurfaceProps = ViewProps & {
|
|
|
50
53
|
* <Image className="h-40 w-full" source={cover} />
|
|
51
54
|
* </Surface>
|
|
52
55
|
*/
|
|
53
|
-
export function Surface({
|
|
56
|
+
export function Surface({
|
|
57
|
+
variant,
|
|
58
|
+
padding = "md",
|
|
59
|
+
material = "flat",
|
|
60
|
+
className,
|
|
61
|
+
children,
|
|
62
|
+
...props
|
|
63
|
+
}: SurfaceProps): ReactElement {
|
|
54
64
|
const parent = useSurfaceContext();
|
|
55
65
|
const parentPlane = parent?.plane ?? null;
|
|
56
66
|
|
|
57
67
|
const resolved = resolveSurfaceVariant({ parentPlane, variant });
|
|
58
|
-
const plane = resolveSurfacePlane({ parentPlane, variant: resolved });
|
|
68
|
+
const plane = resolveSurfacePlane({ material, parentPlane, variant: resolved });
|
|
69
|
+
const inTray = parent?.material === "tray";
|
|
59
70
|
|
|
60
|
-
const context = useMemo<SurfaceContextValue>(
|
|
71
|
+
const context = useMemo<SurfaceContextValue>(
|
|
72
|
+
() => ({ material, plane, variant: resolved }),
|
|
73
|
+
[material, plane, resolved]
|
|
74
|
+
);
|
|
61
75
|
|
|
62
76
|
return (
|
|
63
77
|
<SurfaceProvider value={context}>
|
|
64
|
-
<View
|
|
78
|
+
<View
|
|
79
|
+
className={surfaceVariants({ inTray, material, padding, variant: resolved }).root({ className })}
|
|
80
|
+
{...props}
|
|
81
|
+
>
|
|
65
82
|
{children}
|
|
66
83
|
</View>
|
|
67
84
|
</SurfaceProvider>
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
resolveSurfaceVariant,
|
|
7
7
|
SURFACE_FILLED_VARIANTS,
|
|
8
8
|
SURFACE_FOREGROUND_TOKENS,
|
|
9
|
+
SURFACE_MATERIALS,
|
|
9
10
|
SURFACE_PADDINGS,
|
|
10
11
|
SURFACE_VARIANTS,
|
|
11
12
|
surfaceVariants,
|
|
@@ -178,3 +179,49 @@ describe("resolveSurfacePlane", () => {
|
|
|
178
179
|
expect(resolveSurfaceVariant({ parentPlane: through })).toBe("secondary");
|
|
179
180
|
});
|
|
180
181
|
});
|
|
182
|
+
|
|
183
|
+
describe("surface materials", () => {
|
|
184
|
+
test("flat is the default and adds nothing", () => {
|
|
185
|
+
expect(surfaceVariants().root()).toBe(surfaceVariants({ material: "flat" }).root());
|
|
186
|
+
expect(surfaceVariants().root()).not.toMatch(/shadow/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("etched lifts the edge with the light and dark highlight", () => {
|
|
190
|
+
const cls = surfaceVariants({ material: "etched" }).root();
|
|
191
|
+
expect(cls).toContain("shadow-etched");
|
|
192
|
+
expect(cls).toContain("dark:shadow-etched-dark");
|
|
193
|
+
expect(cls).toContain("bg-card");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("a tray is a muted, squarely padded 2xl frame", () => {
|
|
197
|
+
const cls = surfaceVariants({ material: "tray", padding: "md" }).root();
|
|
198
|
+
expect(cls).toContain("bg-muted/70");
|
|
199
|
+
expect(cls).toContain("rounded-2xl");
|
|
200
|
+
expect(cls).toContain("p-1");
|
|
201
|
+
expect(cls).not.toMatch(/\bp-4\b/);
|
|
202
|
+
expect(cls).not.toContain("bg-card");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("a panel in a tray takes the xl corner", () => {
|
|
206
|
+
expect(surfaceVariants({ inTray: true }).root()).toContain("rounded-xl");
|
|
207
|
+
expect(surfaceVariants({ inTray: true }).root()).not.toContain("rounded-lg");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("lists every material", () => {
|
|
211
|
+
expect(SURFACE_MATERIALS).toEqual(["flat", "etched", "tray"]);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("tray planes", () => {
|
|
216
|
+
// The tray paints its own muted fill, so what sits in it steps from nothing:
|
|
217
|
+
// a default card panel, not a secondary one on a muted ground.
|
|
218
|
+
test("a tray resets the plane so its panels resolve to the card fill", () => {
|
|
219
|
+
const through = resolveSurfacePlane({ parentPlane: "tertiary", variant: "default", material: "tray" });
|
|
220
|
+
expect(through).toBeNull();
|
|
221
|
+
expect(resolveSurfaceVariant({ parentPlane: through })).toBe("default");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("other materials leave the plane alone", () => {
|
|
225
|
+
expect(resolveSurfacePlane({ parentPlane: null, variant: "secondary", material: "etched" })).toBe("secondary");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -5,6 +5,18 @@ export const SURFACE_VARIANTS = ["default", "secondary", "tertiary", "transparen
|
|
|
5
5
|
|
|
6
6
|
export const SURFACE_PADDINGS = ["none", "sm", "md", "lg"] as const;
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* What the surface is made of, orthogonal to which plane it fills.
|
|
10
|
+
*
|
|
11
|
+
* `flat` is the library default and draws nothing extra. `etched` sets a
|
|
12
|
+
* one-pixel highlight into the edge with a very soft drop — see the
|
|
13
|
+
* `--shadow-etched` tokens. `tray` is the muted frame that holds panels: a
|
|
14
|
+
* 2xl corner, 4pt of padding and a translucent muted fill, so the panels in it
|
|
15
|
+
* read as inset cards rather than as loose boxes on a page.
|
|
16
|
+
*/
|
|
17
|
+
export const SURFACE_MATERIALS = ["flat", "etched", "tray"] as const;
|
|
18
|
+
|
|
19
|
+
export type SurfaceMaterial = (typeof SURFACE_MATERIALS)[number];
|
|
8
20
|
export type SurfaceVariant = (typeof SURFACE_VARIANTS)[number];
|
|
9
21
|
export type SurfacePadding = (typeof SURFACE_PADDINGS)[number];
|
|
10
22
|
|
|
@@ -88,10 +100,25 @@ export const surfaceVariants = tv({
|
|
|
88
100
|
md: { root: "p-4" },
|
|
89
101
|
lg: { root: "p-6" },
|
|
90
102
|
},
|
|
103
|
+
material: {
|
|
104
|
+
flat: {},
|
|
105
|
+
etched: { root: "shadow-etched dark:shadow-etched-dark" },
|
|
106
|
+
tray: {},
|
|
107
|
+
},
|
|
108
|
+
// A panel sitting in a tray: one corner step under the tray's own, so
|
|
109
|
+
// the gap between the two curves stays even round the edge.
|
|
110
|
+
inTray: { true: {}, false: {} },
|
|
91
111
|
},
|
|
112
|
+
compoundVariants: [
|
|
113
|
+
// Beats the padding axis, so a tray is `p-1` whatever `padding` says.
|
|
114
|
+
{ material: "tray", class: { root: "rounded-2xl border-transparent bg-muted/70 p-1" } },
|
|
115
|
+
{ inTray: true, class: { root: "rounded-xl" } },
|
|
116
|
+
],
|
|
92
117
|
defaultVariants: {
|
|
93
118
|
variant: "default",
|
|
94
119
|
padding: "md",
|
|
120
|
+
material: "flat",
|
|
121
|
+
inTray: false,
|
|
95
122
|
},
|
|
96
123
|
});
|
|
97
124
|
|
|
@@ -123,11 +150,16 @@ export function resolveSurfaceVariant({
|
|
|
123
150
|
* surface, which paints none — the plane it sits on itself.
|
|
124
151
|
*/
|
|
125
152
|
export function resolveSurfacePlane({
|
|
153
|
+
material = "flat",
|
|
126
154
|
parentPlane,
|
|
127
155
|
variant,
|
|
128
156
|
}: {
|
|
157
|
+
material?: SurfaceMaterial;
|
|
129
158
|
parentPlane: SurfacePlane | null;
|
|
130
159
|
variant: SurfaceVariant;
|
|
131
160
|
}): SurfacePlane | null {
|
|
161
|
+
// A tray paints its own muted fill, so what it holds steps from no plane at
|
|
162
|
+
// all and resolves to the card fill rather than the next rung down.
|
|
163
|
+
if (material === "tray") return null;
|
|
132
164
|
return variant === "transparent" ? parentPlane : variant;
|
|
133
165
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# Text
|
|
2
2
|
|
|
3
3
|
The library's type scale, and the one component that reproduces React Native's
|
|
4
|
-
own text inheritance through classNames. Compound root plus
|
|
4
|
+
own text inheritance through classNames. Compound root plus thirteen presets:
|
|
5
5
|
`Display`, `Title`, `Header`, `Subheader`, `Paragraph`, `Label`, `Caption`,
|
|
6
|
-
`Overline`, and the four inline ones — `Strong`, `Emphasis`, `Link`, `Code`.
|
|
6
|
+
`Overline`, `Kicker`, and the four inline ones — `Strong`, `Emphasis`, `Link`, `Code`.
|
|
7
7
|
|
|
8
8
|
`import { Text } from "@delacour/react-native-ui/text";`
|
|
9
9
|
|
|
@@ -61,6 +61,12 @@ own text inheritance through classNames. Compound root plus twelve presets:
|
|
|
61
61
|
so Tailwind v4's logical-property utilities resolve to a value RN rejects.
|
|
62
62
|
`transform: "none"` emits `normal-case` rather than an empty string, so it can
|
|
63
63
|
actually clear an `Overline`'s `uppercase`.
|
|
64
|
+
- **`Kicker` is the mono, 10-point, 0.2em-tracked label — an opt-in sibling of
|
|
65
|
+
`Overline`, not a replacement.** Its size and tracking are the `--text-kicker`
|
|
66
|
+
and `--tracking-kicker` tokens in `tokens.css`, registered with
|
|
67
|
+
`tailwind-merge` as a pair (`KICKER_TOKEN`) so `text-kicker` is read as a
|
|
68
|
+
size and never as a colour. `font-mono` is deliberate: it labels, it does not
|
|
69
|
+
read as a sentence. Nothing in the default look uses it.
|
|
64
70
|
- **A nested `Text.Code` cannot be padded.** A nested `<Text>` is laid out by the
|
|
65
71
|
platform's text engine — an `NSAttributedString` run on iOS, a `Span` on
|
|
66
72
|
Android — and both ignore padding, margin and border radius on an inner
|
|
@@ -142,6 +142,12 @@ function TextOverline(props: TextPresetProps): ReactElement {
|
|
|
142
142
|
}
|
|
143
143
|
TextOverline.displayName = "DelacourUI.Text.Overline";
|
|
144
144
|
|
|
145
|
+
/** The tiny tracked mono label over a group, a screen or a field. */
|
|
146
|
+
function TextKicker(props: TextPresetProps): ReactElement {
|
|
147
|
+
return <TextRoot variant="kicker" {...props} />;
|
|
148
|
+
}
|
|
149
|
+
TextKicker.displayName = "DelacourUI.Text.Kicker";
|
|
150
|
+
|
|
145
151
|
/**
|
|
146
152
|
* Inline emphasis by weight.
|
|
147
153
|
*
|
|
@@ -238,6 +244,7 @@ export const Text = Object.assign(TextRoot, {
|
|
|
238
244
|
/** Supporting copy — 14pt on the muted token. */
|
|
239
245
|
Caption: TextCaption,
|
|
240
246
|
/** An eyebrow above a section — 12pt semibold, uppercase, muted. */
|
|
247
|
+
Kicker: TextKicker,
|
|
241
248
|
Overline: TextOverline,
|
|
242
249
|
/** Inline emphasis by weight. Inherits size and colour from the text around it. */
|
|
243
250
|
Strong: TextStrong,
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from "./text.variants";
|
|
18
18
|
|
|
19
19
|
/** Tailwind's type scale, smallest first — position, so a test can say "a step down". */
|
|
20
|
-
const TYPE_SCALE = ["text-xs", "text-sm", "text-base", "text-lg", "text-xl", "text-2xl", "text-3xl"];
|
|
20
|
+
const TYPE_SCALE = ["text-kicker", "text-xs", "text-sm", "text-base", "text-lg", "text-xl", "text-2xl", "text-3xl"];
|
|
21
21
|
|
|
22
22
|
/** The three alignment utilities, which share the `text-` prefix with sizes and colours. */
|
|
23
23
|
const ALIGNMENTS = ["text-left", "text-center", "text-right"];
|
|
@@ -420,3 +420,26 @@ describe("TEXT_MAX_FONT_SIZE_MULTIPLIER", () => {
|
|
|
420
420
|
expect(TEXT_MAX_FONT_SIZE_MULTIPLIER).toBeGreaterThan(1);
|
|
421
421
|
});
|
|
422
422
|
});
|
|
423
|
+
|
|
424
|
+
describe("the kicker variant", () => {
|
|
425
|
+
const cls = textVariants({ variant: "kicker" });
|
|
426
|
+
|
|
427
|
+
test("is a ten-point uppercase mono label, tracked wide", () => {
|
|
428
|
+
expect(cls).toContain("font-mono");
|
|
429
|
+
expect(cls).toContain("text-kicker");
|
|
430
|
+
expect(cls).toContain("uppercase");
|
|
431
|
+
expect(cls).toContain("tracking-kicker");
|
|
432
|
+
expect(cls).toContain("text-muted-foreground");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("is listed, and is not an inline variant", () => {
|
|
436
|
+
expect(TEXT_VARIANTS).toContain("kicker");
|
|
437
|
+
expect(isInlineTextVariant("kicker")).toBe(false);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("survives resolveTextClass without losing its size to the base class", () => {
|
|
441
|
+
const resolved = resolveTextClass({ variant: "kicker" });
|
|
442
|
+
expect(resolved).toContain("text-kicker");
|
|
443
|
+
expect(resolved).not.toContain("text-base");
|
|
444
|
+
});
|
|
445
|
+
});
|
|
@@ -18,6 +18,7 @@ export const TEXT_VARIANTS = [
|
|
|
18
18
|
"label",
|
|
19
19
|
"caption",
|
|
20
20
|
"overline",
|
|
21
|
+
"kicker",
|
|
21
22
|
"strong",
|
|
22
23
|
"emphasis",
|
|
23
24
|
"link",
|
|
@@ -136,6 +137,10 @@ export const textVariants = tv({
|
|
|
136
137
|
label: "font-medium text-sm text-foreground",
|
|
137
138
|
caption: "text-sm text-muted-foreground",
|
|
138
139
|
overline: "font-semibold text-muted-foreground text-xs uppercase tracking-wide",
|
|
140
|
+
// The tiny tracked mono label over a group or a screen. `font-mono`
|
|
141
|
+
// rather than `font-sans`, which is the point of it: it reads as a
|
|
142
|
+
// measurement, not a sentence. Tokens, not literals — see tokens.css.
|
|
143
|
+
kicker: "font-mono text-kicker text-muted-foreground uppercase tracking-kicker",
|
|
139
144
|
// The four below emit a delta and nothing else. See TEXT_INLINE_VARIANTS.
|
|
140
145
|
strong: "font-semibold",
|
|
141
146
|
emphasis: "italic",
|
|
@@ -84,3 +84,6 @@ A multiline text field, sized in rows. One component, no parts: it renders an
|
|
|
84
84
|
either context and take its box or its corner, but a decorated or joined
|
|
85
85
|
paragraph is not a control this library draws, and the height math assumes
|
|
86
86
|
the box is the field's own.
|
|
87
|
+
|
|
88
|
+
- **Takes `Input`'s `etched` variant** — the box is `Input`'s, so the third `INPUT_VARIANTS` value
|
|
89
|
+
reaches the textarea unchanged.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { extendTailwindMerge } from "tailwind-merge";
|
|
5
|
+
import { type CustomClassGroupId, TW_MERGE_CONFIG } from "../styles/tokens";
|
|
6
|
+
import { cn } from "./cn";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `cn` against the tailwind-merge it replaced, on this library's own classes.
|
|
10
|
+
*
|
|
11
|
+
* `cn` promises tailwind-merge's output for every input; this holds it to that
|
|
12
|
+
* on the strings that matter here — every class literal in a `*.variants.ts`,
|
|
13
|
+
* merged pairwise with a caller-style override, through the same
|
|
14
|
+
* `TW_MERGE_CONFIG`. tailwind-merge stays installed as `tailwind-variants`'
|
|
15
|
+
* peer, which is what makes the reference available.
|
|
16
|
+
*/
|
|
17
|
+
const reference = extendTailwindMerge<CustomClassGroupId>(TW_MERGE_CONFIG);
|
|
18
|
+
|
|
19
|
+
function variantFiles(dir: string): string[] {
|
|
20
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
21
|
+
const path = join(dir, entry.name);
|
|
22
|
+
if (entry.isDirectory()) return variantFiles(path);
|
|
23
|
+
return entry.name.endsWith(".variants.ts") ? [path] : [];
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const literals = [
|
|
28
|
+
...new Set(
|
|
29
|
+
variantFiles(join(import.meta.dir, "../components")).flatMap((file) =>
|
|
30
|
+
[...readFileSync(file, "utf8").matchAll(/"([a-z0-9!:[\]/.\-_% ]+)"/g)]
|
|
31
|
+
.map((match) => match[1] ?? "")
|
|
32
|
+
.filter((value) => value.includes("-"))
|
|
33
|
+
)
|
|
34
|
+
),
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const OVERRIDES = [
|
|
38
|
+
"p-4",
|
|
39
|
+
"h-12",
|
|
40
|
+
"rounded-none",
|
|
41
|
+
"bg-destructive",
|
|
42
|
+
"text-sm",
|
|
43
|
+
"text-primary",
|
|
44
|
+
"opacity-50",
|
|
45
|
+
"h-button-sm",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
describe("cn matches tailwind-merge on the library's classes", () => {
|
|
49
|
+
test("found a real corpus", () => {
|
|
50
|
+
expect(literals.length).toBeGreaterThan(100);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("every literal alone", () => {
|
|
54
|
+
for (const value of literals) expect(cn(value)).toBe(reference(value));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("every literal under a caller override", () => {
|
|
58
|
+
for (const value of literals) {
|
|
59
|
+
for (const override of OVERRIDES) expect(cn(value, override)).toBe(reference(value, override));
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|
package/src/lib/cn.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import type { ClassValue } from "cn";
|
|
2
|
+
import { createCn } from "cn/config";
|
|
3
|
+
import { TW_MERGE_CONFIG } from "../styles/tokens";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
6
|
+
* Class joining and conflict resolution, taught the semantic size tokens from `tokens.css`.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* Built on `cn` (shadcn-ui/cn), which replaces `clsx` + `tailwind-merge` with one
|
|
9
|
+
* engine: the same join semantics, the same merge output, and much cheaper on the
|
|
10
|
+
* repeated calls a render loop makes. `createCn` takes tailwind-merge's own
|
|
11
|
+
* `{ extend }` shape, so `TW_MERGE_CONFIG` is passed unchanged.
|
|
12
|
+
*
|
|
13
|
+
* Registering the tokens is load-bearing rather than tidiness. The merger only
|
|
9
14
|
* treats two classes as conflicting when it recognises both as members of the
|
|
10
15
|
* same group, and `button-md` is not a value it knows. Left unregistered,
|
|
11
16
|
* `cn("h-button-md", "h-12")` returns *both* classes: they each resolve to a
|
|
@@ -15,7 +20,7 @@ import { type CustomClassGroupId, TW_MERGE_CONFIG } from "../styles/tokens";
|
|
|
15
20
|
*
|
|
16
21
|
* `tv()` needs the same treatment for its own merger — see `lib/tv.ts`.
|
|
17
22
|
*/
|
|
18
|
-
const
|
|
23
|
+
const merge = createCn(TW_MERGE_CONFIG);
|
|
19
24
|
|
|
20
25
|
/**
|
|
21
26
|
* Merges class names and resolves Tailwind conflicts so the last utility wins.
|
|
@@ -25,5 +30,5 @@ const twMerge = extendTailwindMerge<CustomClassGroupId>(TW_MERGE_CONFIG);
|
|
|
25
30
|
* `className` through this before handing it to a component.
|
|
26
31
|
*/
|
|
27
32
|
export function cn(...inputs: ClassValue[]): string {
|
|
28
|
-
return
|
|
33
|
+
return merge(...inputs);
|
|
29
34
|
}
|
package/src/styles/tokens.css
CHANGED
|
@@ -107,6 +107,39 @@
|
|
|
107
107
|
--spacing-chart-md: 220px;
|
|
108
108
|
--spacing-chart-lg: 280px;
|
|
109
109
|
|
|
110
|
+
/*
|
|
111
|
+
* The kicker: the tiny uppercase mono label that sits over a group, a
|
|
112
|
+
* screen or a field. Ten points at 0.2em tracking, named as a pair so
|
|
113
|
+
* `Text variant="kicker"` and a hand-written `text-kicker tracking-kicker`
|
|
114
|
+
* agree. Nothing reads it unless asked, so the default look is unchanged.
|
|
115
|
+
*/
|
|
116
|
+
--text-kicker: 10px;
|
|
117
|
+
--tracking-kicker: 0.2em;
|
|
118
|
+
|
|
119
|
+
/*
|
|
120
|
+
* The etched material: a one-pixel highlight set into a surface's edge plus
|
|
121
|
+
* a very soft drop. Light puts the highlight on the bottom edge as a
|
|
122
|
+
* darkening, dark puts it on the top edge as a lightening, so a card reads
|
|
123
|
+
* as lit from above in both. They are two tokens because one `shadow-*`
|
|
124
|
+
* utility sets one `box-shadow` and a scheme flip needs `dark:`.
|
|
125
|
+
*
|
|
126
|
+
* `etched-primary` is the primary button's own: a white inset top edge at
|
|
127
|
+
* 16% that is the same in both schemes, because the fill under it is the
|
|
128
|
+
* dark one in light and invisible against the pale one in dark.
|
|
129
|
+
*
|
|
130
|
+
* `focus` is the three-point halo an etched field draws round its ring
|
|
131
|
+
* border. A neutral alpha rather than the ring colour: `color-mix` inside a
|
|
132
|
+
* `box-shadow` does not survive Uniwind's compile and draws nothing, and
|
|
133
|
+
* the ring is a neutral in every base ramp anyway.
|
|
134
|
+
*
|
|
135
|
+
* Opt-in only: no default component reads any of these.
|
|
136
|
+
*/
|
|
137
|
+
--shadow-etched: inset 0 -1px 0 0 rgb(0 0 0 / 4%), 0 1px 2px 0 rgb(0 0 0 / 5%);
|
|
138
|
+
--shadow-etched-dark: inset 0 1px 0 0 rgb(255 255 255 / 6%), 0 1px 2px 0 rgb(0 0 0 / 30%);
|
|
139
|
+
--shadow-etched-primary: inset 0 1px 0 0 rgb(255 255 255 / 16%), 0 1px 2px 0 rgb(0 0 0 / 5%);
|
|
140
|
+
--shadow-focus: 0 0 0 3px rgb(0 0 0 / 10%);
|
|
141
|
+
--shadow-focus-dark: 0 0 0 3px rgb(255 255 255 / 14%);
|
|
142
|
+
|
|
110
143
|
/*
|
|
111
144
|
* A floating action button's footprint — the circle's edge, and the
|
|
112
145
|
* height of an extended one. A scale of its own rather than
|
|
@@ -6,11 +6,14 @@ import {
|
|
|
6
6
|
BUTTON_SIZE_TOKENS,
|
|
7
7
|
BUTTON_TEXT_TOKENS,
|
|
8
8
|
CHART_SIZE_TOKENS,
|
|
9
|
+
ETCHED_SHADOW_TOKENS,
|
|
9
10
|
FAB_SIZE_TOKENS,
|
|
10
11
|
ICON_SIZE_TOKENS,
|
|
11
12
|
INPUT_SIZE_TOKENS,
|
|
12
13
|
INPUT_TEXT_TOKENS,
|
|
14
|
+
KICKER_TOKEN,
|
|
13
15
|
SCREEN_SIZE_TOKENS,
|
|
16
|
+
TW_MERGE_CONFIG,
|
|
14
17
|
} from "./tokens";
|
|
15
18
|
|
|
16
19
|
const CSS = readFileSync(join(import.meta.dirname, "tokens.css"), "utf-8");
|
|
@@ -182,6 +185,37 @@ describe("the input scale", () => {
|
|
|
182
185
|
});
|
|
183
186
|
});
|
|
184
187
|
|
|
188
|
+
describe("the etched material", () => {
|
|
189
|
+
test("declares every shadow the registry names, and no other", () => {
|
|
190
|
+
const declared = [...CSS.matchAll(/--shadow-([\w-]+):/g)].map((match) => match[1]).sort();
|
|
191
|
+
expect(declared).toEqual([...ETCHED_SHADOW_TOKENS].sort());
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// An inset layer is what makes it a highlight rather than a drop shadow, and
|
|
195
|
+
// the soft outer layer is what lifts the edge off the page.
|
|
196
|
+
test("light and dark edges sit on opposite sides of the surface", () => {
|
|
197
|
+
expect(CSS).toMatch(/--shadow-etched:\s*inset 0 -1px/);
|
|
198
|
+
expect(CSS).toMatch(/--shadow-etched-dark:\s*inset 0 1px/);
|
|
199
|
+
expect(CSS).toMatch(/--shadow-etched-primary:\s*inset 0 1px 0 0 rgb\(255 255 255 \/ 16%\)/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("registers its shadows so a caller's shadow-* can replace them", () => {
|
|
203
|
+
expect(TW_MERGE_CONFIG.extend.theme.shadow).toEqual([...ETCHED_SHADOW_TOKENS]);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("the kicker type style", () => {
|
|
208
|
+
test("is ten points, tracked at 0.2em", () => {
|
|
209
|
+
expect(px("text-kicker")).toBe(10);
|
|
210
|
+
expect(CSS).toMatch(/--tracking-kicker:\s*0\.2em;/);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("registers both tokens so text-kicker is read as a size, not a colour", () => {
|
|
214
|
+
expect(TW_MERGE_CONFIG.extend.theme.text).toContain(KICKER_TOKEN);
|
|
215
|
+
expect(TW_MERGE_CONFIG.extend.theme.tracking).toEqual([KICKER_TOKEN]);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
185
219
|
describe("the fab scale", () => {
|
|
186
220
|
test("ascends in the order the registry lists it", () => {
|
|
187
221
|
const values = FAB_SIZE_TOKENS.map((token) => px(`spacing-${token}`));
|
package/src/styles/tokens.ts
CHANGED
|
@@ -82,6 +82,12 @@ export const FAB_SIZE_TOKENS = ["fab-sm", "fab-md", "fab-lg"] as const;
|
|
|
82
82
|
* `borderCurve`. Unregistered, tailwind-merge reads it as a border *colour*,
|
|
83
83
|
* and `border-continuous border-border` loses one of the two.
|
|
84
84
|
*/
|
|
85
|
+
/** Names of the shadows the etched material declares in `tokens.css`. */
|
|
86
|
+
export const ETCHED_SHADOW_TOKENS = ["etched", "etched-dark", "etched-primary", "focus", "focus-dark"] as const;
|
|
87
|
+
|
|
88
|
+
/** The size and tracking token behind `Text variant="kicker"`; one name for both. */
|
|
89
|
+
export const KICKER_TOKEN = "kicker";
|
|
90
|
+
|
|
85
91
|
export const TW_MERGE_CONFIG = {
|
|
86
92
|
extend: {
|
|
87
93
|
classGroups: {
|
|
@@ -97,7 +103,9 @@ export const TW_MERGE_CONFIG = {
|
|
|
97
103
|
...INPUT_SIZE_TOKENS,
|
|
98
104
|
...SCREEN_SIZE_TOKENS,
|
|
99
105
|
],
|
|
100
|
-
|
|
106
|
+
shadow: [...ETCHED_SHADOW_TOKENS],
|
|
107
|
+
text: [...BUTTON_TEXT_TOKENS, ...INPUT_TEXT_TOKENS, KICKER_TOKEN],
|
|
108
|
+
tracking: [KICKER_TOKEN],
|
|
101
109
|
},
|
|
102
110
|
},
|
|
103
111
|
} as const;
|