@delacour/react-native-ui 0.1.0-alpha.20260925053522
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -0
- package/package.json +125 -0
- package/src/components/accordion/AGENTS.md +193 -0
- package/src/components/accordion/accordion-content.tsx +125 -0
- package/src/components/accordion/accordion-description.tsx +17 -0
- package/src/components/accordion/accordion-indicator.tsx +104 -0
- package/src/components/accordion/accordion-item.tsx +78 -0
- package/src/components/accordion/accordion-title.tsx +18 -0
- package/src/components/accordion/accordion-trigger.tsx +148 -0
- package/src/components/accordion/accordion.context.tsx +153 -0
- package/src/components/accordion/accordion.tsx +253 -0
- package/src/components/accordion/accordion.types.ts +11 -0
- package/src/components/accordion/accordion.variants.test.ts +434 -0
- package/src/components/accordion/accordion.variants.ts +358 -0
- package/src/components/accordion/index.ts +45 -0
- package/src/components/badge/AGENTS.md +83 -0
- package/src/components/badge/badge-close-button.tsx +48 -0
- package/src/components/badge/badge-end-content.tsx +15 -0
- package/src/components/badge/badge-label.tsx +24 -0
- package/src/components/badge/badge-start-content.tsx +16 -0
- package/src/components/badge/badge.context.tsx +64 -0
- package/src/components/badge/badge.tsx +192 -0
- package/src/components/badge/badge.types.ts +10 -0
- package/src/components/badge/badge.variants.test.ts +269 -0
- package/src/components/badge/badge.variants.ts +238 -0
- package/src/components/badge/index.ts +17 -0
- package/src/components/bottom-sheet/AGENTS.md +242 -0
- package/src/components/bottom-sheet/bottom-sheet-background.tsx +35 -0
- package/src/components/bottom-sheet/bottom-sheet-close.tsx +60 -0
- package/src/components/bottom-sheet/bottom-sheet-container.tsx +246 -0
- package/src/components/bottom-sheet/bottom-sheet-content.tsx +66 -0
- package/src/components/bottom-sheet/bottom-sheet-description.tsx +29 -0
- package/src/components/bottom-sheet/bottom-sheet-footer.tsx +150 -0
- package/src/components/bottom-sheet/bottom-sheet-handle.tsx +47 -0
- package/src/components/bottom-sheet/bottom-sheet-overlay.tsx +180 -0
- package/src/components/bottom-sheet/bottom-sheet-portal.tsx +72 -0
- package/src/components/bottom-sheet/bottom-sheet-scroll-view.tsx +105 -0
- package/src/components/bottom-sheet/bottom-sheet-title.tsx +24 -0
- package/src/components/bottom-sheet/bottom-sheet-trigger.tsx +73 -0
- package/src/components/bottom-sheet/bottom-sheet.context.tsx +135 -0
- package/src/components/bottom-sheet/bottom-sheet.tsx +125 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.test.ts +293 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.ts +220 -0
- package/src/components/bottom-sheet/index.ts +40 -0
- package/src/components/bottom-sheet/use-bottom-sheet-input.ts +125 -0
- package/src/components/button/AGENTS.md +217 -0
- package/src/components/button/button-end-content.tsx +15 -0
- package/src/components/button/button-group-separator.tsx +37 -0
- package/src/components/button/button-group-text.tsx +101 -0
- package/src/components/button/button-group.tsx +158 -0
- package/src/components/button/button-label.tsx +24 -0
- package/src/components/button/button-start-content.tsx +15 -0
- package/src/components/button/button.context.tsx +187 -0
- package/src/components/button/button.tsx +273 -0
- package/src/components/button/button.types.ts +10 -0
- package/src/components/button/button.variants.test.ts +811 -0
- package/src/components/button/button.variants.ts +480 -0
- package/src/components/button/index.ts +50 -0
- package/src/components/chart/AGENTS.md +210 -0
- package/src/components/chart/chart-area.tsx +93 -0
- package/src/components/chart/chart-bar.tsx +103 -0
- package/src/components/chart/chart-bars.tsx +75 -0
- package/src/components/chart/chart-candlestick.tsx +60 -0
- package/src/components/chart/chart-grid.tsx +33 -0
- package/src/components/chart/chart-legend.tsx +41 -0
- package/src/components/chart/chart-line.tsx +50 -0
- package/src/components/chart/chart-scatter.tsx +35 -0
- package/src/components/chart/chart-tooltip-dot.tsx +70 -0
- package/src/components/chart/chart-tooltip-x.tsx +121 -0
- package/src/components/chart/chart-tooltip-y.tsx +55 -0
- package/src/components/chart/chart-tooltip.tsx +133 -0
- package/src/components/chart/chart-x-axis.tsx +27 -0
- package/src/components/chart/chart-y-axis.tsx +21 -0
- package/src/components/chart/chart.context.tsx +127 -0
- package/src/components/chart/chart.tsx +448 -0
- package/src/components/chart/chart.types.ts +108 -0
- package/src/components/chart/chart.variants.test.ts +893 -0
- package/src/components/chart/chart.variants.ts +726 -0
- package/src/components/chart/index.ts +103 -0
- package/src/components/chart/pie-chart-center.tsx +40 -0
- package/src/components/chart/pie-chart-label.tsx +56 -0
- package/src/components/chart/pie-chart-slice.tsx +45 -0
- package/src/components/chart/pie-chart-tooltip.tsx +85 -0
- package/src/components/chart/pie-chart.context.tsx +77 -0
- package/src/components/chart/pie-chart.tsx +247 -0
- package/src/components/chart/use-chart-font.ts +25 -0
- package/src/components/chart/use-chart-palette.ts +38 -0
- package/src/components/checkbox/AGENTS.md +185 -0
- package/src/components/checkbox/checkbox-box.tsx +152 -0
- package/src/components/checkbox/checkbox-group.tsx +107 -0
- package/src/components/checkbox/checkbox-label.tsx +34 -0
- package/src/components/checkbox/checkbox.context.tsx +136 -0
- package/src/components/checkbox/checkbox.tsx +258 -0
- package/src/components/checkbox/checkbox.types.ts +14 -0
- package/src/components/checkbox/checkbox.variants.test.ts +634 -0
- package/src/components/checkbox/checkbox.variants.ts +484 -0
- package/src/components/checkbox/index.ts +42 -0
- package/src/components/field/AGENTS.md +112 -0
- package/src/components/field/field-content.tsx +24 -0
- package/src/components/field/field-description.tsx +29 -0
- package/src/components/field/field-error.tsx +61 -0
- package/src/components/field/field-group.tsx +22 -0
- package/src/components/field/field-label.tsx +47 -0
- package/src/components/field/field-legend.tsx +34 -0
- package/src/components/field/field-separator.tsx +81 -0
- package/src/components/field/field-set.tsx +22 -0
- package/src/components/field/field.context.tsx +95 -0
- package/src/components/field/field.tsx +157 -0
- package/src/components/field/field.types.ts +15 -0
- package/src/components/field/field.variants.test.ts +240 -0
- package/src/components/field/field.variants.ts +156 -0
- package/src/components/field/index.ts +22 -0
- package/src/components/icon/AGENTS.md +104 -0
- package/src/components/icon/icon.context.tsx +29 -0
- package/src/components/icon/icon.tsx +92 -0
- package/src/components/icon/icon.variants.test.ts +111 -0
- package/src/components/icon/icon.variants.ts +79 -0
- package/src/components/icon/index.ts +11 -0
- package/src/components/input/AGENTS.md +126 -0
- package/src/components/input/index.ts +27 -0
- package/src/components/input/input-group-decorator.tsx +84 -0
- package/src/components/input/input-group-prefix.tsx +16 -0
- package/src/components/input/input-group-suffix.tsx +15 -0
- package/src/components/input/input-group.tsx +140 -0
- package/src/components/input/input.context.tsx +87 -0
- package/src/components/input/input.tsx +184 -0
- package/src/components/input/input.types.ts +10 -0
- package/src/components/input/input.variants.test.ts +369 -0
- package/src/components/input/input.variants.ts +329 -0
- package/src/components/list-group/AGENTS.md +67 -0
- package/src/components/list-group/index.ts +20 -0
- package/src/components/list-group/list-group-item-content.tsx +10 -0
- package/src/components/list-group/list-group-item-description.tsx +12 -0
- package/src/components/list-group/list-group-item-prefix.tsx +30 -0
- package/src/components/list-group/list-group-item-suffix.tsx +55 -0
- package/src/components/list-group/list-group-item-title.tsx +17 -0
- package/src/components/list-group/list-group-item.tsx +87 -0
- package/src/components/list-group/list-group.context.tsx +65 -0
- package/src/components/list-group/list-group.tsx +116 -0
- package/src/components/list-group/list-group.types.ts +14 -0
- package/src/components/list-group/list-group.variants.test.ts +223 -0
- package/src/components/list-group/list-group.variants.ts +107 -0
- package/src/components/pressable/AGENTS.md +74 -0
- package/src/components/pressable/index.ts +9 -0
- package/src/components/pressable/pressable.tsx +261 -0
- package/src/components/pressable/pressable.variants.test.ts +128 -0
- package/src/components/pressable/pressable.variants.ts +80 -0
- package/src/components/provider/AGENTS.md +90 -0
- package/src/components/provider/index.ts +1 -0
- package/src/components/provider/provider.tsx +105 -0
- package/src/components/radio/AGENTS.md +245 -0
- package/src/components/radio/index.ts +35 -0
- package/src/components/radio/radio-group.tsx +126 -0
- package/src/components/radio/radio-indicator.tsx +91 -0
- package/src/components/radio/radio-label.tsx +39 -0
- package/src/components/radio/radio.context.tsx +132 -0
- package/src/components/radio/radio.tsx +215 -0
- package/src/components/radio/radio.variants.test.ts +580 -0
- package/src/components/radio/radio.variants.ts +271 -0
- package/src/components/screen/AGENTS.md +287 -0
- package/src/components/screen/index.ts +85 -0
- package/src/components/screen/screen-chat-list.tsx +485 -0
- package/src/components/screen/screen-content.tsx +69 -0
- package/src/components/screen/screen-debug.ts +43 -0
- package/src/components/screen/screen-error.tsx +61 -0
- package/src/components/screen/screen-flat-list.tsx +88 -0
- package/src/components/screen/screen-footer-background.tsx +60 -0
- package/src/components/screen/screen-footer.tsx +170 -0
- package/src/components/screen/screen-header.tsx +36 -0
- package/src/components/screen/screen-legend-list.tsx +105 -0
- package/src/components/screen/screen-list-component.tsx +23 -0
- package/src/components/screen/screen-loading.tsx +50 -0
- package/src/components/screen/screen-navbar-back-button.tsx +79 -0
- package/src/components/screen/screen-navbar-background.tsx +45 -0
- package/src/components/screen/screen-navbar-subtitle.tsx +26 -0
- package/src/components/screen/screen-navbar-title.tsx +31 -0
- package/src/components/screen/screen-navbar.tsx +154 -0
- package/src/components/screen/screen-root.tsx +38 -0
- package/src/components/screen/screen-scroll-area.tsx +116 -0
- package/src/components/screen/screen-scroll-shadow.tsx +186 -0
- package/src/components/screen/screen-section-list.tsx +81 -0
- package/src/components/screen/screen-view.tsx +59 -0
- package/src/components/screen/screen.context.tsx +167 -0
- package/src/components/screen/screen.tsx +114 -0
- package/src/components/screen/screen.types.ts +66 -0
- package/src/components/screen/screen.variants.test.ts +602 -0
- package/src/components/screen/screen.variants.ts +475 -0
- package/src/components/screen/use-screen-scroll-insets.ts +218 -0
- package/src/components/separator/AGENTS.md +30 -0
- package/src/components/separator/index.ts +8 -0
- package/src/components/separator/separator.tsx +85 -0
- package/src/components/slider/AGENTS.md +274 -0
- package/src/components/slider/index.ts +50 -0
- package/src/components/slider/slider-fill.tsx +68 -0
- package/src/components/slider/slider-output.tsx +55 -0
- package/src/components/slider/slider-thumb.tsx +193 -0
- package/src/components/slider/slider-track.tsx +233 -0
- package/src/components/slider/slider.context.tsx +118 -0
- package/src/components/slider/slider.tsx +321 -0
- package/src/components/slider/slider.types.ts +26 -0
- package/src/components/slider/slider.variants.test.ts +856 -0
- package/src/components/slider/slider.variants.ts +661 -0
- package/src/components/spinner/AGENTS.md +73 -0
- package/src/components/spinner/index.ts +17 -0
- package/src/components/spinner/spinner-arc.tsx +90 -0
- package/src/components/spinner/spinner-content.tsx +63 -0
- package/src/components/spinner/spinner.context.tsx +52 -0
- package/src/components/spinner/spinner.tsx +128 -0
- package/src/components/spinner/spinner.variants.test.ts +273 -0
- package/src/components/spinner/spinner.variants.ts +187 -0
- package/src/components/switch/AGENTS.md +213 -0
- package/src/components/switch/index.ts +42 -0
- package/src/components/switch/switch-content.tsx +111 -0
- package/src/components/switch/switch-end-content.tsx +18 -0
- package/src/components/switch/switch-start-content.tsx +20 -0
- package/src/components/switch/switch-thumb.tsx +102 -0
- package/src/components/switch/switch.context.tsx +79 -0
- package/src/components/switch/switch.tsx +423 -0
- package/src/components/switch/switch.types.ts +14 -0
- package/src/components/switch/switch.variants.test.ts +570 -0
- package/src/components/switch/switch.variants.ts +511 -0
- package/src/components/tabs/AGENTS.md +287 -0
- package/src/components/tabs/index.ts +69 -0
- package/src/components/tabs/tabs-content.tsx +65 -0
- package/src/components/tabs/tabs-indicator.tsx +97 -0
- package/src/components/tabs/tabs-label.tsx +62 -0
- package/src/components/tabs/tabs-list.tsx +139 -0
- package/src/components/tabs/tabs-pager.tsx +59 -0
- package/src/components/tabs/tabs-scroll-view.tsx +171 -0
- package/src/components/tabs/tabs-separator.tsx +73 -0
- package/src/components/tabs/tabs-trigger.tsx +210 -0
- package/src/components/tabs/tabs.context.tsx +294 -0
- package/src/components/tabs/tabs.tsx +435 -0
- package/src/components/tabs/tabs.types.ts +13 -0
- package/src/components/tabs/tabs.variants.test.ts +1020 -0
- package/src/components/tabs/tabs.variants.ts +670 -0
- package/src/components/text/AGENTS.md +95 -0
- package/src/components/text/index.ts +25 -0
- package/src/components/text/text.context.tsx +60 -0
- package/src/components/text/text.tsx +251 -0
- package/src/components/text/text.variants.test.ts +422 -0
- package/src/components/text/text.variants.ts +282 -0
- package/src/display-name.test.ts +145 -0
- package/src/docs.test.ts +98 -0
- package/src/expo/navigation-theme.tsx +58 -0
- package/src/hooks/use-controllable-state.ts +45 -0
- package/src/hooks/use-keyboard-state-sync.tsx +147 -0
- package/src/hooks/use-navigation-theme.ts +78 -0
- package/src/hooks/use-theme-color.ts +44 -0
- package/src/icons/central.ts +1 -0
- package/src/lib/cn.test.ts +136 -0
- package/src/lib/cn.ts +29 -0
- package/src/lib/color.test.ts +80 -0
- package/src/lib/color.ts +79 -0
- package/src/lib/compose-refs.test.ts +64 -0
- package/src/lib/compose-refs.ts +37 -0
- package/src/lib/keyboard-animation.test.ts +33 -0
- package/src/lib/keyboard-animation.ts +28 -0
- package/src/lib/merge-props.test.ts +82 -0
- package/src/lib/merge-props.ts +47 -0
- package/src/lib/navigation-theme.test.ts +49 -0
- package/src/lib/navigation-theme.ts +53 -0
- package/src/lib/slot.tsx +45 -0
- package/src/lib/tv.ts +20 -0
- package/src/styles/base.css +2 -0
- package/src/styles/geometry.test.ts +117 -0
- package/src/styles/index.css +3 -0
- package/src/styles/theme-tokens.test.ts +252 -0
- package/src/styles/theme.css +447 -0
- package/src/styles/tokens.css +132 -0
- package/src/styles/tokens.test.ts +181 -0
- package/src/styles/tokens.ts +86 -0
- package/src/uniwind-env.d.ts +1 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { TEXT_COLORS } from "../text/text.variants";
|
|
3
|
+
import {
|
|
4
|
+
FIELD_LEGEND_VARIANTS,
|
|
5
|
+
FIELD_ORIENTATIONS,
|
|
6
|
+
FIELD_TEXT_PARTS,
|
|
7
|
+
type FieldTextPart,
|
|
8
|
+
fieldVariants,
|
|
9
|
+
resolveFieldInteractive,
|
|
10
|
+
resolveFieldLabelText,
|
|
11
|
+
resolveFieldTextColor,
|
|
12
|
+
} from "./field.variants";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A slot's class string, with `tv`'s empty-slot `undefined` flattened.
|
|
16
|
+
*
|
|
17
|
+
* A slot that emits nothing returns `undefined` rather than `""`, which is
|
|
18
|
+
* correct — these parts carry state only, so most are empty most of the time —
|
|
19
|
+
* but it makes a bare `not.toMatch()` throw rather than pass.
|
|
20
|
+
*/
|
|
21
|
+
function cls(value: string | undefined): string {
|
|
22
|
+
return value ?? "";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The gap step a class string sets — `gap-1.5` yields 1.5. */
|
|
26
|
+
function gapStep(cls: string): number | undefined {
|
|
27
|
+
const value = cls.match(/\bgap-(\d+(?:\.\d+)?)\b/)?.[1];
|
|
28
|
+
return value === undefined ? undefined : Number(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Slots that land on a `View`, which cannot cascade colour to a `Text`. */
|
|
32
|
+
const VIEW_SLOTS = ["root", "set", "group", "content", "separator"] as const;
|
|
33
|
+
|
|
34
|
+
/** Slots handed to a `Text`, whose type scale comes from the preset, not from here. */
|
|
35
|
+
const TEXT_SLOTS = ["label", "description", "error"] as const;
|
|
36
|
+
|
|
37
|
+
describe("the root slot", () => {
|
|
38
|
+
test("lays a field out on one axis or the other, never both", () => {
|
|
39
|
+
expect(fieldVariants({ orientation: "vertical" }).root()).toContain("flex-col");
|
|
40
|
+
expect(fieldVariants({ orientation: "vertical" }).root()).not.toContain("flex-row");
|
|
41
|
+
expect(fieldVariants({ orientation: "horizontal" }).root()).toContain("flex-row");
|
|
42
|
+
expect(fieldVariants({ orientation: "horizontal" }).root()).not.toContain("flex-col");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("centres a horizontal field on its control", () => {
|
|
46
|
+
expect(fieldVariants({ orientation: "horizontal" }).root()).toContain("items-center");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// The control belongs at the far edge, and the root pushes it there rather
|
|
50
|
+
// than the label growing into the space — a label that grew would stretch
|
|
51
|
+
// vertically the moment it moved inside a `Field.Content` column.
|
|
52
|
+
test("pushes a horizontal field's control to the far edge", () => {
|
|
53
|
+
expect(fieldVariants({ orientation: "horizontal" }).root()).toContain("justify-between");
|
|
54
|
+
expect(fieldVariants({ orientation: "vertical" }).root()).not.toContain("justify-between");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("stacks vertically by default", () => {
|
|
58
|
+
expect(fieldVariants().root()).toBe(fieldVariants({ orientation: "vertical" }).root());
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("merges an incoming className last", () => {
|
|
62
|
+
expect(fieldVariants().root({ className: "mb-6" })).toContain("mb-6");
|
|
63
|
+
expect(fieldVariants().root({ className: "gap-8" })).not.toContain("gap-1.5");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// A React Native `View` does not cascade colour to a `Text` descendant, so no
|
|
68
|
+
// slot worn by one may carry a colour. See AGENTS.md rule 1.
|
|
69
|
+
describe("the slots that land on a View", () => {
|
|
70
|
+
test("carry no text colour", () => {
|
|
71
|
+
for (const orientation of FIELD_ORIENTATIONS) {
|
|
72
|
+
for (const isInvalid of [false, true]) {
|
|
73
|
+
for (const isDisabled of [false, true]) {
|
|
74
|
+
const slots = fieldVariants({ isDisabled, isInvalid, orientation });
|
|
75
|
+
for (const slot of VIEW_SLOTS) {
|
|
76
|
+
expect(cls(slots[slot]())).not.toMatch(/\btext-/);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// The whole reason four different gaps exist: a field's own parts have to read
|
|
85
|
+
// as one thing, and two fields as two. Pinning the ordering rather than each
|
|
86
|
+
// number keeps the test meaningful when the spacing is retuned.
|
|
87
|
+
describe("the gap ladder", () => {
|
|
88
|
+
test("tightens inward, from field to field down to label and description", () => {
|
|
89
|
+
const slots = fieldVariants();
|
|
90
|
+
const content = gapStep(slots.content());
|
|
91
|
+
const root = gapStep(slots.root());
|
|
92
|
+
const set = gapStep(slots.set());
|
|
93
|
+
const group = gapStep(slots.group());
|
|
94
|
+
|
|
95
|
+
for (const gap of [content, root, set, group]) {
|
|
96
|
+
expect(gap).toBeDefined();
|
|
97
|
+
}
|
|
98
|
+
expect(content as number).toBeLessThan(root as number);
|
|
99
|
+
expect(root as number).toBeLessThan(set as number);
|
|
100
|
+
expect(set as number).toBeLessThanOrEqual(group as number);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("a disabled field", () => {
|
|
105
|
+
test("fades the label", () => {
|
|
106
|
+
expect(cls(fieldVariants({ isDisabled: true }).label())).toContain("opacity-50");
|
|
107
|
+
expect(cls(fieldVariants({ isDisabled: false }).label())).not.toContain("opacity-50");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// The control fades itself, and a description dimmed on top of an already
|
|
111
|
+
// dimmed control reads as two problems rather than one state.
|
|
112
|
+
test("leaves the description and the error alone", () => {
|
|
113
|
+
expect(cls(fieldVariants({ isDisabled: true }).description())).not.toContain("opacity-50");
|
|
114
|
+
expect(cls(fieldVariants({ isDisabled: true }).error())).not.toContain("opacity-50");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// The type scale belongs to the `Text` preset each part renders. A size or a
|
|
119
|
+
// weight here would be a second definition of `Text.Label` that could drift
|
|
120
|
+
// from it — the reason `Input` ships no label part at all. See AGENTS.md.
|
|
121
|
+
describe("the slots handed to a Text", () => {
|
|
122
|
+
test("restate no type scale", () => {
|
|
123
|
+
for (const isInvalid of [false, true]) {
|
|
124
|
+
for (const isDisabled of [false, true]) {
|
|
125
|
+
const slots = fieldVariants({ isDisabled, isInvalid });
|
|
126
|
+
for (const slot of TEXT_SLOTS) {
|
|
127
|
+
expect(cls(slots[slot]())).not.toMatch(/\btext-(xs|sm|base|lg|xl|2xl|3xl)\b/);
|
|
128
|
+
expect(cls(slots[slot]())).not.toMatch(/\bfont-(normal|medium|semibold|bold)\b/);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("carry no colour either, since that rides the preset's own axis", () => {
|
|
135
|
+
for (const isInvalid of [false, true]) {
|
|
136
|
+
const slots = fieldVariants({ isInvalid });
|
|
137
|
+
for (const slot of TEXT_SLOTS) {
|
|
138
|
+
expect(cls(slots[slot]())).not.toMatch(/\btext-\w+-?\w*\b/);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("the legend", () => {
|
|
145
|
+
test("gives its two variants different treatments", () => {
|
|
146
|
+
const seen = new Set(FIELD_LEGEND_VARIANTS.map((variant) => cls(fieldVariants({ variant }).legend())));
|
|
147
|
+
expect(seen.size).toBe(FIELD_LEGEND_VARIANTS.length);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("merges an incoming className last", () => {
|
|
151
|
+
expect(cls(fieldVariants().legend({ className: "mb-2" }))).toContain("mb-2");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("the separator", () => {
|
|
156
|
+
test("lays its rules out in a row", () => {
|
|
157
|
+
expect(fieldVariants().separator()).toContain("flex-row");
|
|
158
|
+
expect(fieldVariants().separator()).toContain("items-center");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Each rule takes half the leftover width so a centred label sits between
|
|
162
|
+
// them, rather than one rule being positioned behind an opaque label.
|
|
163
|
+
test("lets each rule take the space the label does not", () => {
|
|
164
|
+
expect(fieldVariants().separatorLine()).toContain("flex-1");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("resolveFieldTextColor", () => {
|
|
169
|
+
test("names a colour the Text component actually has, or nothing", () => {
|
|
170
|
+
for (const part of FIELD_TEXT_PARTS) {
|
|
171
|
+
for (const isInvalid of [false, true]) {
|
|
172
|
+
const color = resolveFieldTextColor(part, isInvalid);
|
|
173
|
+
if (color !== undefined) {
|
|
174
|
+
expect(TEXT_COLORS).toContain(color);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// The label turns with the control it names; the description stays muted so
|
|
181
|
+
// the error is the one line that changed.
|
|
182
|
+
test("reddens the label only while the field is invalid", () => {
|
|
183
|
+
expect(resolveFieldTextColor("label", true)).toBe("destructive");
|
|
184
|
+
expect(resolveFieldTextColor("label", false)).toBeUndefined();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("leaves the description on its preset's own colour in both states", () => {
|
|
188
|
+
for (const isInvalid of [false, true]) {
|
|
189
|
+
expect(resolveFieldTextColor("description", isInvalid)).toBeUndefined();
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("keeps the error destructive even outside an invalid field", () => {
|
|
194
|
+
for (const isInvalid of [false, true]) {
|
|
195
|
+
expect(resolveFieldTextColor("error", isInvalid)).toBe("destructive");
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("covers every part it declares", () => {
|
|
200
|
+
for (const part of FIELD_TEXT_PARTS) {
|
|
201
|
+
expect(() => resolveFieldTextColor(part as FieldTextPart, true)).not.toThrow();
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("resolveFieldInteractive", () => {
|
|
207
|
+
test("leaves a field of static text inert", () => {
|
|
208
|
+
// No control has offered a press, so the row stays a View. Mounting a
|
|
209
|
+
// gesture detector under every label and description in a form would
|
|
210
|
+
// announce static text as something you can activate.
|
|
211
|
+
expect(resolveFieldInteractive(null)).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("hands the row to a control that offered one", () => {
|
|
215
|
+
expect(resolveFieldInteractive(() => undefined)).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("resolveFieldLabelText", () => {
|
|
220
|
+
test("hands a string label through, trimmed", () => {
|
|
221
|
+
expect(resolveFieldLabelText("Volume")).toBe("Volume");
|
|
222
|
+
expect(resolveFieldLabelText(" Volume\n")).toBe("Volume");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("reads a number the way a screen reader would", () => {
|
|
226
|
+
expect(resolveFieldLabelText(42)).toBe("42");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// A label made of elements has no text this side of a render, and an empty
|
|
230
|
+
// string is not a name — both leave the control to name itself.
|
|
231
|
+
test("offers nothing for an element, an empty string, or no children at all", () => {
|
|
232
|
+
expect(resolveFieldLabelText("")).toBeNull();
|
|
233
|
+
expect(resolveFieldLabelText(" ")).toBeNull();
|
|
234
|
+
expect(resolveFieldLabelText(undefined)).toBeNull();
|
|
235
|
+
expect(resolveFieldLabelText(null)).toBeNull();
|
|
236
|
+
expect(resolveFieldLabelText(false)).toBeNull();
|
|
237
|
+
expect(resolveFieldLabelText({ type: "Text", props: {} })).toBeNull();
|
|
238
|
+
expect(resolveFieldLabelText(["a", "b"])).toBeNull();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { VariantProps } from "tailwind-variants";
|
|
2
|
+
import { tv } from "../../lib/tv";
|
|
3
|
+
import type { TextColor } from "../text/text.variants";
|
|
4
|
+
|
|
5
|
+
export const FIELD_ORIENTATIONS = ["vertical", "horizontal"] as const;
|
|
6
|
+
|
|
7
|
+
export const FIELD_LEGEND_VARIANTS = ["legend", "label"] as const;
|
|
8
|
+
|
|
9
|
+
/** The parts that render a `Text` and therefore need a colour rather than a class. */
|
|
10
|
+
export const FIELD_TEXT_PARTS = ["label", "description", "error"] as const;
|
|
11
|
+
|
|
12
|
+
export type FieldOrientation = (typeof FIELD_ORIENTATIONS)[number];
|
|
13
|
+
export type FieldLegendVariant = (typeof FIELD_LEGEND_VARIANTS)[number];
|
|
14
|
+
export type FieldTextPart = (typeof FIELD_TEXT_PARTS)[number];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Layout for a form field and the structures that hold one.
|
|
18
|
+
*
|
|
19
|
+
* One slotted `tv()` rather than a call per part, so the spacing ladder is
|
|
20
|
+
* declared in a single place. That ladder is the component: `content` groups a
|
|
21
|
+
* label with its description, `root` groups a field's parts, `set` groups a
|
|
22
|
+
* legend with its fields, and `group` separates one field from the next. Each
|
|
23
|
+
* step is looser than the one inside it, which is what makes a field read as one
|
|
24
|
+
* thing and two fields as two — `field.variants.test.ts` pins the ordering
|
|
25
|
+
* rather than the numbers, so the spacing can be retuned without the test
|
|
26
|
+
* becoming a transcript of it.
|
|
27
|
+
*
|
|
28
|
+
* **No slot here carries a type scale or a colour.** The text parts render the
|
|
29
|
+
* `Text` presets — `Text.Label`, `Text.Caption` — and pass a colour through
|
|
30
|
+
* {@link resolveFieldTextColor}, so the scale lives in exactly one place. A
|
|
31
|
+
* `text-sm font-medium` written here would be a second definition of
|
|
32
|
+
* `Text.Label` that could drift from it, which is the reason `Input` ships no
|
|
33
|
+
* label part of its own. The slots hold state and layout only.
|
|
34
|
+
*
|
|
35
|
+
* The `View` slots hold no `text-*` at all: a React Native `View` does not
|
|
36
|
+
* cascade colour to a `Text` descendant. See AGENTS.md rule 1.
|
|
37
|
+
*
|
|
38
|
+
* Free of React Native imports so it stays unit-testable — `bun test` cannot
|
|
39
|
+
* parse React Native's Flow-typed source. See AGENTS.md.
|
|
40
|
+
*/
|
|
41
|
+
export const fieldVariants = tv({
|
|
42
|
+
slots: {
|
|
43
|
+
root: "w-full gap-1.5",
|
|
44
|
+
/** A legend, its description and the fields under them. */
|
|
45
|
+
set: "w-full gap-4",
|
|
46
|
+
/** Field to field — the loosest step, so two fields never read as one. */
|
|
47
|
+
group: "w-full gap-5",
|
|
48
|
+
/** A label and its description, as one block beside a control. */
|
|
49
|
+
content: "min-w-0 flex-1 gap-0.5",
|
|
50
|
+
label: "",
|
|
51
|
+
description: "",
|
|
52
|
+
error: "",
|
|
53
|
+
legend: "",
|
|
54
|
+
/** The row a separator's rules and its optional label share. */
|
|
55
|
+
separator: "w-full flex-row items-center gap-3",
|
|
56
|
+
/** Each rule takes the width the label does not. */
|
|
57
|
+
separatorLine: "flex-1",
|
|
58
|
+
},
|
|
59
|
+
variants: {
|
|
60
|
+
orientation: {
|
|
61
|
+
vertical: { root: "flex-col" },
|
|
62
|
+
// `justify-between` is what pushes the control to the far edge, and it
|
|
63
|
+
// is the root's job rather than the label's. The web kit grows the label
|
|
64
|
+
// instead — `*:data-[slot=field-label]:flex-auto` — which needs a child
|
|
65
|
+
// selector uniwind does not have, and would stretch the label vertically
|
|
66
|
+
// once it sat inside a `Field.Content` column rather than beside it.
|
|
67
|
+
horizontal: { root: "flex-row items-center justify-between" },
|
|
68
|
+
},
|
|
69
|
+
variant: {
|
|
70
|
+
legend: { legend: "font-medium" },
|
|
71
|
+
label: { legend: "" },
|
|
72
|
+
},
|
|
73
|
+
// The empty `false` branches are load-bearing typing, not placeholders.
|
|
74
|
+
// `tv` derives the prop type from the declared keys, so a map with only
|
|
75
|
+
// `true` types the prop as `true` rather than `boolean`. See the note in
|
|
76
|
+
// button.variants.ts.
|
|
77
|
+
isInvalid: { true: {}, false: {} },
|
|
78
|
+
// Only the label fades. The control dims itself, and a description
|
|
79
|
+
// dimmed on top of an already dimmed control reads as two problems
|
|
80
|
+
// rather than one state.
|
|
81
|
+
isDisabled: { true: { label: "opacity-50" }, false: {} },
|
|
82
|
+
},
|
|
83
|
+
defaultVariants: {
|
|
84
|
+
orientation: "vertical",
|
|
85
|
+
variant: "legend",
|
|
86
|
+
isInvalid: false,
|
|
87
|
+
isDisabled: false,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The `Text` colour a text part takes, given the field's state.
|
|
93
|
+
*
|
|
94
|
+
* Returning `undefined` is meaningful rather than lazy: `Text`'s `color` axis
|
|
95
|
+
* emits nothing when it is not named, so the part falls through to its preset's
|
|
96
|
+
* own colour — `text-foreground` for a label, `text-muted-foreground` for a
|
|
97
|
+
* description. That is what lets a part say "leave it alone" without knowing
|
|
98
|
+
* which token its preset chose.
|
|
99
|
+
*
|
|
100
|
+
* The label turns with the control it names, so the pair reads as one state. The
|
|
101
|
+
* description stays muted in both, so the error is the one line that appeared.
|
|
102
|
+
* The error is destructive even outside an invalid field, because an error message is
|
|
103
|
+
* never the calm case.
|
|
104
|
+
*
|
|
105
|
+
* Pure, so the whole matrix is reachable from `bun test`. See AGENTS.md.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveFieldTextColor(part: FieldTextPart, isInvalid: boolean): TextColor | undefined {
|
|
108
|
+
switch (part) {
|
|
109
|
+
case "label":
|
|
110
|
+
return isInvalid ? "destructive" : undefined;
|
|
111
|
+
case "error":
|
|
112
|
+
return "destructive";
|
|
113
|
+
default:
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether the field's row should render as a `Pressable` rather than a `View`.
|
|
120
|
+
*
|
|
121
|
+
* Only once a control inside has offered a press. A field is layout until then,
|
|
122
|
+
* and mounting a `GestureDetector` regardless would put one under every label
|
|
123
|
+
* and description in a form — the same thing `Badge` refuses to do for a list of
|
|
124
|
+
* fifty tags, and for the same reason: assistive technology would announce a row
|
|
125
|
+
* of static text as something you can activate.
|
|
126
|
+
*
|
|
127
|
+
* A type predicate rather than a plain boolean, so the branch that renders the
|
|
128
|
+
* `Pressable` gets a non-null `onPress` out of the same check that decided it.
|
|
129
|
+
*
|
|
130
|
+
* Pure, so the decision is reachable from `bun test`. See AGENTS.md.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveFieldInteractive(press: (() => void) | null): press is () => void {
|
|
133
|
+
return press !== null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The text a `Field.Label` can lend to the control beside it as its accessible
|
|
138
|
+
* name, or `null` when it has none to lend.
|
|
139
|
+
*
|
|
140
|
+
* Only a bare string or number qualifies. A label built from elements has no
|
|
141
|
+
* text this side of a render, and guessing at one would name a control after
|
|
142
|
+
* the wrong thing — an icon's key, say. Whitespace is trimmed and an empty
|
|
143
|
+
* string is an absence, because a screen reader handed `""` reads nothing at
|
|
144
|
+
* all, which is worse than reading the value alone.
|
|
145
|
+
*
|
|
146
|
+
* React Native has no `<label for>`; this is the association, done by hand
|
|
147
|
+
* through the field's context. Pure, so `bun test` reaches it. See AGENTS.md.
|
|
148
|
+
*/
|
|
149
|
+
export function resolveFieldLabelText(children: unknown): string | null {
|
|
150
|
+
if (typeof children === "number") return String(children);
|
|
151
|
+
if (typeof children !== "string") return null;
|
|
152
|
+
const text = children.trim();
|
|
153
|
+
return text === "" ? null : text;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type FieldVariantProps = VariantProps<typeof fieldVariants>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { Field, type FieldProps } from "./field";
|
|
2
|
+
export { type FieldContextValue, FieldProvider, useField, useFieldContext } from "./field.context";
|
|
3
|
+
export type { FieldTextProps } from "./field.types";
|
|
4
|
+
export {
|
|
5
|
+
FIELD_LEGEND_VARIANTS,
|
|
6
|
+
FIELD_ORIENTATIONS,
|
|
7
|
+
FIELD_TEXT_PARTS,
|
|
8
|
+
type FieldLegendVariant,
|
|
9
|
+
type FieldOrientation,
|
|
10
|
+
type FieldTextPart,
|
|
11
|
+
type FieldVariantProps,
|
|
12
|
+
fieldVariants,
|
|
13
|
+
resolveFieldInteractive,
|
|
14
|
+
resolveFieldLabelText,
|
|
15
|
+
resolveFieldTextColor,
|
|
16
|
+
} from "./field.variants";
|
|
17
|
+
export type { FieldContentProps } from "./field-content";
|
|
18
|
+
export type { FieldErrorProps } from "./field-error";
|
|
19
|
+
export type { FieldGroupProps } from "./field-group";
|
|
20
|
+
export type { FieldLegendProps } from "./field-legend";
|
|
21
|
+
export type { FieldSeparatorProps } from "./field-separator";
|
|
22
|
+
export type { FieldSetProps } from "./field-set";
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Icon
|
|
2
|
+
|
|
3
|
+
Renders a Central Icon at a theme-aware size and colour. Pattern A in
|
|
4
|
+
[The three component patterns](../../../AGENTS.md#the-three-component-patterns) —
|
|
5
|
+
a styled wrapper with no state and no compound surface, which also reads
|
|
6
|
+
`IconDefaultsProvider` so a parent can set size and colour for its whole subtree
|
|
7
|
+
instead of every call site repeating them.
|
|
8
|
+
|
|
9
|
+
`import { Icon } from "@delacour/react-native-ui/icon";`
|
|
10
|
+
|
|
11
|
+
## Files
|
|
12
|
+
|
|
13
|
+
| File | What it holds |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| `index.ts` | → `@delacour/react-native-ui/icon` |
|
|
16
|
+
| `icon.tsx` | `Icon`, the `IconGlyph` proxy and the one `withUniwind` wrapper (see rule 7) — internal below `Icon` |
|
|
17
|
+
| `icon.context.tsx` | `IconDefaults`, `IconDefaultsProvider`, `useIconDefaults()` |
|
|
18
|
+
| `icon.variants.ts` | Pure `tv()` + the size-class ladder, no RN imports |
|
|
19
|
+
| `icon.variants.test.ts` | |
|
|
20
|
+
|
|
21
|
+
## Design
|
|
22
|
+
|
|
23
|
+
- **Props**: `icon` — required, a Central Icon component; `size` — a named step
|
|
24
|
+
or an edge length in points; `color` — a theme colour token, a CSS variable
|
|
25
|
+
name or a literal; `className` — a `size-*` utility. It extends `SvgProps`
|
|
26
|
+
with `color`, `width` and `height` removed, because those three are the axes
|
|
27
|
+
`Icon` owns: `color` is resolved through the theme, and width and height
|
|
28
|
+
arrive as the glyph's `size` prop rather than as props of their own.
|
|
29
|
+
- **Sizes**: `xs`, `sm`, `md`, `lg`, `xl`, `2xl` — 14/16/18/20/24/32pt, held as
|
|
30
|
+
`size-icon-*` token classes rather than numbers, plus a numeric escape hatch.
|
|
31
|
+
With no `size`, no `className` and nothing to inherit the fallback is
|
|
32
|
+
`size-icon-lg`, 20pt, on the `foreground` token.
|
|
33
|
+
- **Precedence is a five-source ladder** and it is shared with `Spinner`, so it
|
|
34
|
+
is documented once in the package doc — see
|
|
35
|
+
[Sizing](../../../AGENTS.md#sizing) for the table and for why a numeric size
|
|
36
|
+
can never become a class. `resolveIconSizeClass` builds the first four sources
|
|
37
|
+
as one `cn()` chain, so the last `size-*` wins; a numeric `size` bypasses the
|
|
38
|
+
chain entirely.
|
|
39
|
+
- **Overriding through `className` uses `size-*`, not `w-*` with `h-*`.**
|
|
40
|
+
tailwind-merge conflicts `size` into `w`/`h` but not the reverse, so a trailing
|
|
41
|
+
`w-6` will not clear a leading `size-5`.
|
|
42
|
+
- **Size and colour are inherited, not passed.** `IconDefaultsProvider` supplies
|
|
43
|
+
the class and the token that an unstyled `Icon` in its subtree adopts, and
|
|
44
|
+
`useIconDefaults()` reads the nearest one — `null` outside a provider. This is
|
|
45
|
+
what lets an icon be *composed into* a component rather than passed as a prop:
|
|
46
|
+
`<Button><Icon icon={IconPlus} /><Button.Label>Add</Button.Label></Button>`
|
|
47
|
+
sizes and tints the icon for the button's variant, with no `startIcon` prop and
|
|
48
|
+
no colour repeated at the call site. An explicit `size` or `color` on the icon
|
|
49
|
+
still wins.
|
|
50
|
+
- **The components that publish it** are
|
|
51
|
+
[Button](../button/AGENTS.md), `Badge`, `Switch.Content`, `Switch.Thumb`,
|
|
52
|
+
`Tabs.Trigger`, `Accordion.Trigger`, `Accordion.Indicator`,
|
|
53
|
+
`Input.Group`'s decorators, `ListGroup.ItemPrefix`, `Screen.Navbar.BackButton`
|
|
54
|
+
and [Spinner](../spinner/AGENTS.md) — each carrying its own step on the shared
|
|
55
|
+
scale and its own variant's foreground token.
|
|
56
|
+
- **An icon's size is a class; an icon's colour is a token.** The asymmetry is
|
|
57
|
+
deliberate — a colour class cannot express a literal like `#EC4899` or reach an
|
|
58
|
+
SVG paint prop, so `color` is resolved through the active theme by
|
|
59
|
+
`useThemeColor` instead. See [Theming](../../../AGENTS.md#theming).
|
|
60
|
+
- **`iconVariants` has no `defaultVariants`, on purpose.** The fall-through *is*
|
|
61
|
+
the feature: a default would emit from inside that same `tv()` call, ahead of
|
|
62
|
+
an inherited class in the merge, and the fallback would then beat the enclosing
|
|
63
|
+
component. An unnamed axis must reach the inherited provider rather than be
|
|
64
|
+
answered locally, so `resolveIconSizeClass` orders the sources instead. The
|
|
65
|
+
variant test pins it — without a size, `iconVariants()` emits no `size-*` at
|
|
66
|
+
all.
|
|
67
|
+
- **The `withUniwind` wrapper is forced, not stylistic.** A className cannot size
|
|
68
|
+
a Central Icon through a style: `CentralIconBase` spreads its props onto
|
|
69
|
+
`<Svg>` *before* setting its own `width`/`height`, and `react-native-svg`'s
|
|
70
|
+
`Svg.render()` then merges `{...style, ...props}` and pushes the
|
|
71
|
+
width/height-derived styles onto the root **last**, so a `size-4` that resolved
|
|
72
|
+
to `style.width` is overridden every single time. `icon.tsx` runs the class
|
|
73
|
+
through `withUniwind` in manual mode to recover the width and hands that number
|
|
74
|
+
to the glyph's `size` prop.
|
|
75
|
+
- **The wrapper goes on a proxy that takes the glyph as data.** `IconGlyph`
|
|
76
|
+
renders whatever component it is handed, so a *single* `withUniwind` covers the
|
|
77
|
+
whole two-thousand-icon set instead of one per glyph — still one component
|
|
78
|
+
wrapped once, in one file. It also drops the `className` that sized it, because
|
|
79
|
+
`withManualUniwind` spreads the original props straight through and
|
|
80
|
+
`CentralIconBase` would forward a stray `className` onto the native SVG view,
|
|
81
|
+
where nothing interprets it.
|
|
82
|
+
- **`withUniwind` is called at module scope.** In render it would mint a new
|
|
83
|
+
component type every frame and remount the icon.
|
|
84
|
+
- **This is the single sanctioned carve-out from rule 7, and it is already
|
|
85
|
+
spent.** Do not wrap a Central Icon directly, do not wrap the proxy anywhere
|
|
86
|
+
else, and do not add a second wrapper for `Svg`.
|
|
87
|
+
- **`Icon` and `Spinner` share one scale.** `SPINNER_SIZES` *is* `ICON_SIZES`, so
|
|
88
|
+
`size="md"` is the same edge length in both and one can stand in for the other
|
|
89
|
+
with nothing moving — which is what makes a button's loading swap free. See
|
|
90
|
+
[Spinner](../spinner/AGENTS.md).
|
|
91
|
+
- **Central Icons only**, via `@delacour/react-native-ui/icons/central` — never Lucide,
|
|
92
|
+
Hugeicons, or anything else. That is rule 5.
|
|
93
|
+
- **`icon.variants.ts` is free of React Native imports** so the whole ladder is
|
|
94
|
+
reachable from `bun test` — `resolveIconSizeClass`, `isIconSize` and
|
|
95
|
+
`iconVariants` are all pure. See [Testing](../../../AGENTS.md#testing).
|
|
96
|
+
|
|
97
|
+
## Known gaps
|
|
98
|
+
|
|
99
|
+
- **`Icon` is the only component with no playground route.** Step 5 of
|
|
100
|
+
[Adding a component](../../../AGENTS.md#adding-a-component) requires
|
|
101
|
+
`apps/playground/src/app/(components)/{name}.tsx` plus a row on the index, and
|
|
102
|
+
exempts only a component with nothing to render — `DelacourProvider`. `Icon`
|
|
103
|
+
renders, so it does not qualify for that exemption; it is verified today only
|
|
104
|
+
indirectly, through the glyphs on the other routes.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createContext, type ReactElement, type ReactNode, use } from "react";
|
|
2
|
+
|
|
3
|
+
export type IconDefaults = {
|
|
4
|
+
/** Classes an unstyled `Icon` in this subtree adopts, e.g. `size-4.5`. */
|
|
5
|
+
className: string;
|
|
6
|
+
/** Theme colour token, e.g. `primary-foreground`. */
|
|
7
|
+
color: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const IconDefaultsContext = createContext<IconDefaults | null>(null);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Supplies the size and colour that unstyled `Icon`s in this subtree adopt.
|
|
14
|
+
*
|
|
15
|
+
* This is what lets an icon be *composed into* a component rather than passed
|
|
16
|
+
* as a prop: `<Button><Icon icon={IconPlus} /><Button.Label>Add</Button.Label></Button>`
|
|
17
|
+
* sizes and tints the icon for the button's variant, with no `startIcon` prop
|
|
18
|
+
* and no colour repeated at the call site. An explicit `size` or `color` on the
|
|
19
|
+
* icon still wins.
|
|
20
|
+
*/
|
|
21
|
+
export function IconDefaultsProvider({ value, children }: { value: IconDefaults; children: ReactNode }): ReactElement {
|
|
22
|
+
return <IconDefaultsContext value={value}>{children}</IconDefaultsContext>;
|
|
23
|
+
}
|
|
24
|
+
IconDefaultsProvider.displayName = "DelacourUI.Icon.DefaultsProvider";
|
|
25
|
+
|
|
26
|
+
/** Icon defaults from the nearest provider, or null outside one. */
|
|
27
|
+
export function useIconDefaults(): IconDefaults | null {
|
|
28
|
+
return use(IconDefaultsContext);
|
|
29
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { ComponentType, ReactElement } from "react";
|
|
2
|
+
import type { SvgProps } from "react-native-svg";
|
|
3
|
+
import { withUniwind } from "uniwind";
|
|
4
|
+
import { useThemeColor } from "../../hooks/use-theme-color";
|
|
5
|
+
import { useIconDefaults } from "./icon.context";
|
|
6
|
+
import { ICON_FALLBACK_COLOR, type IconSize, isIconSize, resolveIconSizeClass } from "./icon.variants";
|
|
7
|
+
|
|
8
|
+
export type IconComponent = ComponentType<SvgProps & { size?: number | string }>;
|
|
9
|
+
|
|
10
|
+
export type IconProps = Omit<SvgProps, "color" | "width" | "height"> & {
|
|
11
|
+
/** An icon component from `@delacour/react-native-ui/icons/central`. */
|
|
12
|
+
icon: IconComponent;
|
|
13
|
+
/** A named size, or an edge length in points. A number beats `className`. */
|
|
14
|
+
size?: IconSize | number;
|
|
15
|
+
/**
|
|
16
|
+
* A theme colour token (`foreground`, `muted-foreground`, …) or a CSS
|
|
17
|
+
* variable name. Defaults to the enclosing component's icon colour.
|
|
18
|
+
*/
|
|
19
|
+
color?: string;
|
|
20
|
+
/** A `size-*` utility. Beats a named `size`, loses to a numeric one. */
|
|
21
|
+
className?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type IconGlyphProps = Omit<SvgProps, "color" | "width" | "height"> & {
|
|
25
|
+
glyph: IconComponent;
|
|
26
|
+
size?: number | string;
|
|
27
|
+
color?: string;
|
|
28
|
+
className?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Renders the glyph and drops the className that sized it.
|
|
33
|
+
*
|
|
34
|
+
* This one indirection is what keeps the whole Central Icons set to a *single*
|
|
35
|
+
* `withUniwind` wrapper: the wrapper goes on this component, which takes the
|
|
36
|
+
* glyph as data, rather than on each of the two thousand icon components.
|
|
37
|
+
* AGENTS.md rule 7 caps a component at one wrapper in one file — this is it.
|
|
38
|
+
*
|
|
39
|
+
* The className must not reach the glyph. `withManualUniwind` spreads the
|
|
40
|
+
* original props straight through, and `CentralIconBase` would forward a stray
|
|
41
|
+
* `className` onto the native SVG view, where nothing interprets it.
|
|
42
|
+
*/
|
|
43
|
+
function IconGlyph({ glyph: Glyph, className: _sizedBy, ...props }: IconGlyphProps): ReactElement {
|
|
44
|
+
return <Glyph {...props} />;
|
|
45
|
+
}
|
|
46
|
+
IconGlyph.displayName = "DelacourUI.Icon.Glyph";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Turns a `size-*` class into the glyph's `size` **prop**.
|
|
50
|
+
*
|
|
51
|
+
* A className cannot size an SVG through a style: `CentralIconBase` spreads its
|
|
52
|
+
* props onto `<Svg>` before its own `width`/`height`, and `Svg.render` pushes
|
|
53
|
+
* the width/height-derived styles on last, beating anything a className set.
|
|
54
|
+
*
|
|
55
|
+
* `withManualUniwind` reads `props.className`, compiles it, and writes
|
|
56
|
+
* `styles.width` to `size` — but skips the mapping entirely when `size` is
|
|
57
|
+
* already defined. That skip *is* the precedence rule: a numeric `size` from the
|
|
58
|
+
* caller wins and the class is ignored.
|
|
59
|
+
*
|
|
60
|
+
* Created once at module scope. Calling `withUniwind` inside a render would mint
|
|
61
|
+
* a new component type every frame and remount the icon.
|
|
62
|
+
*/
|
|
63
|
+
const StyledIconGlyph = withUniwind(IconGlyph, {
|
|
64
|
+
size: { fromClassName: "className", styleProperty: "width" },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Renders a Central Icon at a theme-aware size and colour.
|
|
69
|
+
*
|
|
70
|
+
* An icon's size is a class and an icon's colour is a token — a class cannot
|
|
71
|
+
* express a literal like `#EC4899` or reach an SVG paint prop, so colour is
|
|
72
|
+
* resolved through the active theme instead. See AGENTS.md.
|
|
73
|
+
*
|
|
74
|
+
* Both fall back to the nearest {@link IconDefaultsProvider}, so an icon
|
|
75
|
+
* composed into a Button matches that button's size and variant without being
|
|
76
|
+
* told to. An explicit `size` or `color` still wins.
|
|
77
|
+
*/
|
|
78
|
+
export function Icon({ icon, size, color, className, ...props }: IconProps): ReactElement {
|
|
79
|
+
const defaults = useIconDefaults();
|
|
80
|
+
const resolvedColor = useThemeColor(color ?? defaults?.color ?? ICON_FALLBACK_COLOR);
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<StyledIconGlyph
|
|
84
|
+
className={resolveIconSizeClass({ className, inherited: defaults?.className, size })}
|
|
85
|
+
color={resolvedColor}
|
|
86
|
+
glyph={icon}
|
|
87
|
+
size={isIconSize(size) ? undefined : size}
|
|
88
|
+
{...props}
|
|
89
|
+
/>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
Icon.displayName = "DelacourUI.Icon";
|