@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,111 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { ICON_SIZE_TOKENS } from "../../styles/tokens";
|
|
3
|
+
import {
|
|
4
|
+
ICON_FALLBACK_COLOR,
|
|
5
|
+
ICON_FALLBACK_SIZE_CLASS,
|
|
6
|
+
ICON_SIZES,
|
|
7
|
+
iconVariants,
|
|
8
|
+
isIconSize,
|
|
9
|
+
resolveIconSizeClass,
|
|
10
|
+
} from "./icon.variants";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Position of a class string's `size-icon-*` token on the shared icon scale.
|
|
14
|
+
*
|
|
15
|
+
* Compares by step rather than by points so a test says what it means — that
|
|
16
|
+
* one icon is a step larger than another — and does not have to be edited when
|
|
17
|
+
* a token's value is retuned in `tokens.css`. `tokens.test.ts` is what keeps
|
|
18
|
+
* this array ordered.
|
|
19
|
+
*/
|
|
20
|
+
function iconStep(cls: string): number {
|
|
21
|
+
const token = cls.match(/\bsize-(icon-[\w-]+)\b/)?.[1];
|
|
22
|
+
return ICON_SIZE_TOKENS.indexOf(token as (typeof ICON_SIZE_TOKENS)[number]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("iconVariants", () => {
|
|
26
|
+
test("gives every named size a distinct token, increasing with it", () => {
|
|
27
|
+
const steps = ICON_SIZES.map((size) => iconStep(iconVariants({ size })));
|
|
28
|
+
expect(steps).not.toContain(-1);
|
|
29
|
+
expect(new Set(steps).size).toBe(ICON_SIZES.length);
|
|
30
|
+
expect([...steps]).toEqual([...steps].sort((a, b) => a - b));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// The whole point of the shared scale: a named size has to name a token, not
|
|
34
|
+
// a raw utility, or Spinner cannot line up with it.
|
|
35
|
+
test("names a token from the shared icon scale at every size", () => {
|
|
36
|
+
for (const size of ICON_SIZES) {
|
|
37
|
+
expect(iconVariants({ size })).toBe(`size-icon-${size}`);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// An icon takes a colour value rather than a class, so the size axis must not
|
|
42
|
+
// smuggle one in. See ICON_FALLBACK_COLOR and useThemeColor.
|
|
43
|
+
test("carries size only, never colour", () => {
|
|
44
|
+
for (const size of ICON_SIZES) {
|
|
45
|
+
expect(iconVariants({ size })).toMatch(/\bsize-/);
|
|
46
|
+
expect(iconVariants({ size })).not.toMatch(/\b(text|bg|border)-/);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// A default would emit from inside this call, ahead of an inherited class in
|
|
51
|
+
// the merge, and the fallback would then beat the enclosing component.
|
|
52
|
+
test("emits nothing without a size, so the fallback can lose to context", () => {
|
|
53
|
+
expect(iconVariants() ?? "").not.toMatch(/\bsize-/);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("isIconSize", () => {
|
|
58
|
+
test("separates a named size from an edge length", () => {
|
|
59
|
+
for (const size of ICON_SIZES) {
|
|
60
|
+
expect(isIconSize(size)).toBe(true);
|
|
61
|
+
}
|
|
62
|
+
expect(isIconSize(18)).toBe(false);
|
|
63
|
+
expect(isIconSize(undefined)).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("resolveIconSizeClass", () => {
|
|
68
|
+
test("falls back when there is nothing to go on", () => {
|
|
69
|
+
expect(resolveIconSizeClass({})).toBe(ICON_FALLBACK_SIZE_CLASS);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("an inherited class beats the fallback", () => {
|
|
73
|
+
expect(resolveIconSizeClass({ inherited: "size-icon-xs" })).toBe("size-icon-xs");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a named size beats an inherited class", () => {
|
|
77
|
+
expect(resolveIconSizeClass({ inherited: "size-icon-xs", size: "xl" })).toBe("size-icon-xl");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a className beats a named size", () => {
|
|
81
|
+
expect(resolveIconSizeClass({ className: "size-7", size: "xs" })).toBe("size-7");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a className beats an inherited class", () => {
|
|
85
|
+
expect(resolveIconSizeClass({ className: "size-7", inherited: "size-icon-sm" })).toBe("size-7");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("resolves to exactly one size utility, whatever it was given", () => {
|
|
89
|
+
const cls = resolveIconSizeClass({ className: "size-7", inherited: "size-icon-xl", size: "xs" });
|
|
90
|
+
expect(cls.split(" ").filter((name) => name.startsWith("size-"))).toHaveLength(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// A numeric size is not a class — it goes straight to the glyph's prop, and
|
|
94
|
+
// uniwind then skips its mapping because that prop is already defined. The
|
|
95
|
+
// chain is still built and still passed; nothing reads it.
|
|
96
|
+
test("a numeric size leaves the chain untouched", () => {
|
|
97
|
+
expect(resolveIconSizeClass({ size: 18 })).toBe(ICON_FALLBACK_SIZE_CLASS);
|
|
98
|
+
expect(resolveIconSizeClass({ inherited: "size-icon-sm", size: 18 })).toBe("size-icon-sm");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("carries through a className that is not a size", () => {
|
|
102
|
+
expect(resolveIconSizeClass({ className: "opacity-50" })).toContain("opacity-50");
|
|
103
|
+
expect(resolveIconSizeClass({ className: "opacity-50" })).toContain(ICON_FALLBACK_SIZE_CLASS);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("ICON_FALLBACK_COLOR", () => {
|
|
108
|
+
test("names a token the theme always defines", () => {
|
|
109
|
+
expect(ICON_FALLBACK_COLOR).toBe("foreground");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { cn } from "../../lib/cn";
|
|
2
|
+
import { tv } from "../../lib/tv";
|
|
3
|
+
|
|
4
|
+
export const ICON_SIZES = ["xs", "sm", "md", "lg", "xl", "2xl"] as const;
|
|
5
|
+
|
|
6
|
+
export type IconSize = (typeof ICON_SIZES)[number];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Edge length for each named icon size, as a token class rather than a number.
|
|
10
|
+
*
|
|
11
|
+
* The steps come from the `--spacing-icon-*` scale in `tokens.css`, which
|
|
12
|
+
* `Spinner` names identically — so `size="md"` is the same edge length in both
|
|
13
|
+
* and one can replace the other without the layout moving. A control's own size
|
|
14
|
+
* axis indexes into the same scale rather than restating a number.
|
|
15
|
+
*
|
|
16
|
+
* A className can never size a Central Icon through a style. `CentralIconBase`
|
|
17
|
+
* spreads its props onto `<Svg>` *before* its own `width`/`height`, and
|
|
18
|
+
* `Svg.render` then pushes the width/height-derived styles onto the root last,
|
|
19
|
+
* where they beat anything a className contributed. So the size has to arrive as
|
|
20
|
+
* the `size` **prop** — `Icon` runs this class through `withUniwind` to recover
|
|
21
|
+
* the width and hands that to the glyph. See `icon.tsx`.
|
|
22
|
+
*
|
|
23
|
+
* No `defaultVariants` on purpose. The fallback has to *lose* to an inherited
|
|
24
|
+
* class, and a default here would emit from inside this same call, ahead of it
|
|
25
|
+
* in the merge. {@link resolveIconSizeClass} orders the sources instead.
|
|
26
|
+
*
|
|
27
|
+
* Free of React Native imports so it stays unit-testable — `bun test` cannot
|
|
28
|
+
* parse React Native's Flow-typed source. See AGENTS.md.
|
|
29
|
+
*/
|
|
30
|
+
export const iconVariants = tv({
|
|
31
|
+
variants: {
|
|
32
|
+
size: {
|
|
33
|
+
xs: "size-icon-xs",
|
|
34
|
+
sm: "size-icon-sm",
|
|
35
|
+
md: "size-icon-md",
|
|
36
|
+
lg: "size-icon-lg",
|
|
37
|
+
xl: "size-icon-xl",
|
|
38
|
+
"2xl": "size-icon-2xl",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** Edge length used with no `size`, no `className` and nothing to inherit — 20pt. */
|
|
44
|
+
export const ICON_FALLBACK_SIZE_CLASS = "size-icon-lg";
|
|
45
|
+
|
|
46
|
+
/** Theme token used with no `color` prop and nothing to inherit from. */
|
|
47
|
+
export const ICON_FALLBACK_COLOR = "foreground";
|
|
48
|
+
|
|
49
|
+
/** Whether a size is one of the named steps rather than an edge length in points. */
|
|
50
|
+
export function isIconSize(size: IconSize | number | undefined): size is IconSize {
|
|
51
|
+
return typeof size === "string";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The class chain that sizes an icon, weakest source first: the fallback, the
|
|
56
|
+
* enclosing component's class, this icon's named size, then the caller's own
|
|
57
|
+
* className. `cn` resolves the conflicts, so the last `size-*` wins.
|
|
58
|
+
*
|
|
59
|
+
* A numeric `size` is deliberately absent. It is not a class — it goes straight
|
|
60
|
+
* to the glyph's prop, and uniwind then skips its mapping because that prop is
|
|
61
|
+
* already defined. That skip *is* the precedence rule.
|
|
62
|
+
*
|
|
63
|
+
* Note for callers overriding through `className`: use `size-*`, not `w-*` with
|
|
64
|
+
* `h-*`. tailwind-merge conflicts `size` into `w`/`h` but not the reverse, so a
|
|
65
|
+
* trailing `w-6` will not clear a leading `size-5`.
|
|
66
|
+
*
|
|
67
|
+
* Pure, so the whole ladder is reachable from `bun test`. See AGENTS.md.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveIconSizeClass({
|
|
70
|
+
size,
|
|
71
|
+
inherited,
|
|
72
|
+
className,
|
|
73
|
+
}: {
|
|
74
|
+
size?: IconSize | number;
|
|
75
|
+
inherited?: string;
|
|
76
|
+
className?: string;
|
|
77
|
+
}): string {
|
|
78
|
+
return cn(ICON_FALLBACK_SIZE_CLASS, inherited, isIconSize(size) ? iconVariants({ size }) : undefined, className);
|
|
79
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { Icon, type IconComponent, type IconProps } from "./icon";
|
|
2
|
+
export { type IconDefaults, IconDefaultsProvider, useIconDefaults } from "./icon.context";
|
|
3
|
+
export {
|
|
4
|
+
ICON_FALLBACK_COLOR,
|
|
5
|
+
ICON_FALLBACK_SIZE_CLASS,
|
|
6
|
+
ICON_SIZES,
|
|
7
|
+
type IconSize,
|
|
8
|
+
iconVariants,
|
|
9
|
+
isIconSize,
|
|
10
|
+
resolveIconSizeClass,
|
|
11
|
+
} from "./icon.variants";
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Input
|
|
2
|
+
|
|
3
|
+
A text field, and the box that can hold content beside it. Root plus
|
|
4
|
+
`Input.Group` and its two decorators, `Prefix` and `Suffix`.
|
|
5
|
+
|
|
6
|
+
`import { Input } from "@delacour/react-native-ui/input";`
|
|
7
|
+
|
|
8
|
+
## Files
|
|
9
|
+
|
|
10
|
+
| File | What it holds |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| `index.ts` | → `@delacour/react-native-ui/input` |
|
|
13
|
+
| `input.tsx` | Root + the `Object.assign` compound surface |
|
|
14
|
+
| `input-group.tsx` | `Input.Group`, plus its own nested surface |
|
|
15
|
+
| `input-group-decorator.tsx` | The shared body behind both decorators |
|
|
16
|
+
| `input-group-prefix.tsx` | `Input.Group.Prefix` |
|
|
17
|
+
| `input-group-suffix.tsx` | `Input.Group.Suffix` |
|
|
18
|
+
| `input.context.tsx` | `InputGroupProvider`, `useInputGroup()`, `useInputGroupContext()`, `useInputGroupPart()` |
|
|
19
|
+
| `input.types.ts` | Prop types shared by two or more parts |
|
|
20
|
+
| `input.variants.ts` | Pure `tv()` slots + resolvers, no RN imports |
|
|
21
|
+
| `input.variants.test.ts` | |
|
|
22
|
+
|
|
23
|
+
## Design
|
|
24
|
+
|
|
25
|
+
- **Variants**: `primary`, `secondary`. **Sizes**: `sm`, `md`, `lg` — the box
|
|
26
|
+
height, the value's type scale and a decorator's icon step, on one axis.
|
|
27
|
+
- **The box is one slot with two homes, and that is the whole design.** The
|
|
28
|
+
`root` slot of `inputVariants` lands on the `TextInput` when a field stands
|
|
29
|
+
alone and on `Input.Group`'s row when it does not. `resolveInputFieldClass` is
|
|
30
|
+
the decision, and it is pure, so `input.variants.test.ts` sweeps the entire
|
|
31
|
+
matrix and asserts that every chrome utility a lone field wears is present on
|
|
32
|
+
the group's row. A grouped field is therefore the *same* box rather than a
|
|
33
|
+
similar one. **Do not give `Input.Group` a border, background or height of its
|
|
34
|
+
own** — a second class string is a second thing that can drift, and the drift
|
|
35
|
+
would be a one-pixel difference nobody notices until it is shipped.
|
|
36
|
+
- **The group owns the axes, because it owns the box.** `variant`, `size`,
|
|
37
|
+
`isInvalid` and `isDisabled` live on `Input.Group`, and an `Input` inside one
|
|
38
|
+
reads them from context — the same way a [`ListGroup.Item`](../list-group/AGENTS.md) takes no `variant`.
|
|
39
|
+
The field's own copies of those props are ignored while it is grouped. One box,
|
|
40
|
+
one set of axes; two would be two answers to the same question.
|
|
41
|
+
- **The two state axes have three sources, and the nearest wins:**
|
|
42
|
+
`Input.Group` → the `Input`'s own prop → the enclosing [`Field`](../field/AGENTS.md). An `Input`
|
|
43
|
+
inside `<Field isInvalid>` goes `destructive` with nothing said at the call site, and
|
|
44
|
+
`<Input isInvalid={false} />` opts that one control out. `Input.Group` reads
|
|
45
|
+
the `Field` too, or a decorated field inside an invalid one would stay calm
|
|
46
|
+
while its label went red. Both are `??` chains, so an explicit `false` is a
|
|
47
|
+
value rather than an absence — the rule `pressedScale` already follows.
|
|
48
|
+
- **Uniwind bridges the three colour *props*, so rule 7 is untouched.** A
|
|
49
|
+
`TextInput`'s placeholder, caret and selection take a colour value, not a
|
|
50
|
+
style, and uniwind's own `TextInput` — which is what a plain
|
|
51
|
+
`import { TextInput } from "react-native"` resolves to, via its Metro
|
|
52
|
+
resolver — accepts a className for each and compiles it to `styles.accentColor`.
|
|
53
|
+
So this component wraps nothing in `withUniwind`, and the `Icon` carve-out
|
|
54
|
+
stays spent exactly once.
|
|
55
|
+
- **Those classNames must be `accent-*` utilities.** `accent-muted-foreground`,
|
|
56
|
+
never `text-muted-foreground`. Uniwind reads only `accentColor` off the
|
|
57
|
+
compiled class, so anything else resolves to nothing: it warns once in
|
|
58
|
+
development and leaves the prop undefined, which renders as the platform
|
|
59
|
+
default rather than as an error. The defaults live in `input.variants.ts`.
|
|
60
|
+
- **`placeholderTextColorClassName` is `Omit`ed from `InputProps`.** Uniwind's
|
|
61
|
+
name and ours would otherwise both reach the same colour, and a caller setting
|
|
62
|
+
one while the component set the other is a bug with no error attached.
|
|
63
|
+
`selectionColorClassName` keeps uniwind's name because it already is the name
|
|
64
|
+
this package would have chosen; only its default is supplied here.
|
|
65
|
+
- **Focus is React state, not a `focus:` class.** Uniwind's `TextInput` does
|
|
66
|
+
track its own focus, and `focus:border-ring` would work on a lone field — and
|
|
67
|
+
do nothing at all for the box `Input.Group` draws around a grouped one, since a
|
|
68
|
+
`View` cannot see a sibling's focus. One state, read by an `isFocused` variant,
|
|
69
|
+
keeps the two identical and puts the decision somewhere `bun test` can reach.
|
|
70
|
+
- **Invalid outranks focus.** A field that went grey the moment it was tapped
|
|
71
|
+
would drop the only signal it has that its value is wrong, exactly while the
|
|
72
|
+
value is being corrected. The border, the caret and the decorators all stay
|
|
73
|
+
`destructive`.
|
|
74
|
+
- **A multiline field turns its height into a floor**, and the row aligns to the
|
|
75
|
+
top with it — centred decorators would drift down the side of a paragraph
|
|
76
|
+
instead of sitting on its first line. `py-0` on the single-line branch is
|
|
77
|
+
load-bearing on Android, where the platform's own vertical padding would
|
|
78
|
+
otherwise push the value off centre inside a fixed height.
|
|
79
|
+
- **Prefix and suffix share one `decorator` slot and one implementation.** They
|
|
80
|
+
are the same box in different places — the row's `gap` is what separates
|
|
81
|
+
them — so a second identical slot would only be a second thing to keep in step.
|
|
82
|
+
`input-group-decorator.tsx` is the shared leaf; the two part files name it.
|
|
83
|
+
- **A decorator wraps bare text in a `Text`.** `<Input.Group.Prefix>$</...>` is
|
|
84
|
+
the shortest thing anyone will write and React Native cannot render a string
|
|
85
|
+
outside a `<Text>`, so it would crash. Consecutive strings collapse into one —
|
|
86
|
+
the same rule, and the same reason, as [`Button`](../button/AGENTS.md).
|
|
87
|
+
- **Pressing the group focuses the field.** A lone field is its own tap target
|
|
88
|
+
edge to edge; a grouped one only covers the middle of the box, so the group is
|
|
89
|
+
a `Pressable` with `feedback="none"` whose press focuses the field through the
|
|
90
|
+
ref it shares on context. A `Button` inside a decorator still receives its own
|
|
91
|
+
press.
|
|
92
|
+
- **There is no `Input.Label`, `Input.Description` or `Input.ErrorMessage`.**
|
|
93
|
+
[`Text.Label`](../text/AGENTS.md) and `Text.Caption` already are those, and a label defined twice is
|
|
94
|
+
a type scale that can drift. `apps/playground`'s `/input/form` is what the
|
|
95
|
+
trade looks like at a call site.
|
|
96
|
+
- **`--spacing-input-*` is its own scale**, matching `--spacing-button-*` in
|
|
97
|
+
value and not in name — this is the case [Sizing](../../../AGENTS.md#sizing)
|
|
98
|
+
anticipated. A token test asserts the two stay level, so either can be retuned
|
|
99
|
+
without silently dragging the other along.
|
|
100
|
+
|
|
101
|
+
## Joining a Button.Group
|
|
102
|
+
|
|
103
|
+
A field can sit inside a [`Button.Group`](../button/AGENTS.md#buttongroup) and
|
|
104
|
+
join the run, so `<Button.Group><Input /><Button>Go</Button></Button.Group>`
|
|
105
|
+
reads as one control rather than two boxes touching.
|
|
106
|
+
|
|
107
|
+
- **A joined field draws the *group's* corner, not its own.** Alone it takes the
|
|
108
|
+
generic ramp — `rounded-lg`, stepping down to `rounded-md` at `sm` — because a
|
|
109
|
+
field and the button beside it are meant to be retunable apart, which is the
|
|
110
|
+
same reason `--spacing-input-*` exists rather than the field reading
|
|
111
|
+
`h-button-md`. In a group that independence is exactly wrong: a run capped by
|
|
112
|
+
a field at one end and a button at the other would draw two different arcs.
|
|
113
|
+
The group owns the shape of its run, so a joined field switches to
|
|
114
|
+
`--radius-button-*`. `input.variants.test.ts` asserts the two emit identical
|
|
115
|
+
corner classes cell for cell, so the copies cannot drift.
|
|
116
|
+
- **A field nested in an `Input.Group` never consults the member axes.** That
|
|
117
|
+
row owns the box, and `resolveInputFieldClass` already drops the `root` slot
|
|
118
|
+
entirely when the field is grouped — so the corner and the seam land on the
|
|
119
|
+
row, once, exactly as they land on a lone field. The two are threaded through
|
|
120
|
+
the same `InputBoxState` for that reason: one struct, one box, wherever it
|
|
121
|
+
ends up.
|
|
122
|
+
- **`size` comes from the enclosing group outright; `isDisabled` stays a
|
|
123
|
+
fallback.** Controls of different heights do not join, so the group's size
|
|
124
|
+
wins. Disabled is the outermost rung of an already-three-deep ladder
|
|
125
|
+
(`Input.Group` → own prop → `Field` → `Button.Group`), so a single field in a
|
|
126
|
+
run can still be the only one disabled.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export { Input, type InputProps } from "./input";
|
|
2
|
+
export {
|
|
3
|
+
type InputGroupContextValue,
|
|
4
|
+
InputGroupProvider,
|
|
5
|
+
useInputGroup,
|
|
6
|
+
useInputGroupContext,
|
|
7
|
+
} from "./input.context";
|
|
8
|
+
export type { InputSlotProps } from "./input.types";
|
|
9
|
+
export {
|
|
10
|
+
INPUT_DECORATOR_ICON_TOKEN,
|
|
11
|
+
INPUT_INVALID_DECORATOR_ICON_TOKEN,
|
|
12
|
+
INPUT_INVALID_SELECTION_ACCENT_CLASS,
|
|
13
|
+
INPUT_PLACEHOLDER_ACCENT_CLASS,
|
|
14
|
+
INPUT_SELECTION_ACCENT_CLASS,
|
|
15
|
+
INPUT_SIZES,
|
|
16
|
+
INPUT_VARIANTS,
|
|
17
|
+
type InputBoxState,
|
|
18
|
+
type InputSize,
|
|
19
|
+
type InputVariant,
|
|
20
|
+
type InputVariantProps,
|
|
21
|
+
inputVariants,
|
|
22
|
+
resolveInputFieldClass,
|
|
23
|
+
resolveInputGroupClass,
|
|
24
|
+
resolvePlaceholderAccentClass,
|
|
25
|
+
resolveSelectionAccentClass,
|
|
26
|
+
} from "./input.variants";
|
|
27
|
+
export type { InputGroupProps } from "./input-group";
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Children, type ReactElement, type ReactNode, useMemo } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { IconDefaultsProvider } from "../icon";
|
|
4
|
+
import { Text } from "../text";
|
|
5
|
+
import { TextClassProvider } from "../text/text.context";
|
|
6
|
+
import { useInputGroupPart } from "./input.context";
|
|
7
|
+
import type { InputSlotProps } from "./input.types";
|
|
8
|
+
import { INPUT_DECORATOR_ICON_TOKEN, INPUT_INVALID_DECORATOR_ICON_TOKEN, inputVariants } from "./input.variants";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The shared implementation behind `Input.Group.Prefix` and `.Suffix`.
|
|
12
|
+
*
|
|
13
|
+
* The two are the same box in different places — the row's `gap` is what puts
|
|
14
|
+
* them either side of the field, not a class of their own — so they read one
|
|
15
|
+
* `decorator` slot and share this file. Duplicating {@link wrapTextChildren}
|
|
16
|
+
* into two part files to keep them apart is exactly what this leaf avoids.
|
|
17
|
+
*
|
|
18
|
+
* Not a part itself: it takes the caller-facing name so the error thrown
|
|
19
|
+
* outside a group names `Input.Group.Prefix` rather than something private.
|
|
20
|
+
*/
|
|
21
|
+
export function InputGroupDecorator({
|
|
22
|
+
part,
|
|
23
|
+
className,
|
|
24
|
+
children,
|
|
25
|
+
...props
|
|
26
|
+
}: InputSlotProps & { part: string }): ReactElement {
|
|
27
|
+
const { isInvalid, size } = useInputGroupPart(part);
|
|
28
|
+
const slots = inputVariants({ isInvalid, size });
|
|
29
|
+
|
|
30
|
+
const iconClassName = slots.decoratorIcon();
|
|
31
|
+
const iconDefaults = useMemo(
|
|
32
|
+
() => ({
|
|
33
|
+
className: iconClassName,
|
|
34
|
+
color: isInvalid ? INPUT_INVALID_DECORATOR_ICON_TOKEN : INPUT_DECORATOR_ICON_TOKEN,
|
|
35
|
+
}),
|
|
36
|
+
[iconClassName, isInvalid]
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<View className={slots.decorator({ className })} {...props}>
|
|
41
|
+
<IconDefaultsProvider value={iconDefaults}>
|
|
42
|
+
<TextClassProvider value={slots.decoratorText()}>{wrapTextChildren(children)}</TextClassProvider>
|
|
43
|
+
</IconDefaultsProvider>
|
|
44
|
+
</View>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
InputGroupDecorator.displayName = "DelacourUI.Input.Group.Decorator";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Wraps bare text children in a `Text`.
|
|
51
|
+
*
|
|
52
|
+
* A decorator renders a `View`, and React Native cannot render a string outside
|
|
53
|
+
* a `<Text>` — so `<Input.Group.Prefix>$</Input.Group.Prefix>`, which is the
|
|
54
|
+
* shortest thing anyone will write, would otherwise crash. Consecutive strings
|
|
55
|
+
* and numbers are collected into one `Text` rather than one each, so a
|
|
56
|
+
* `{currency}{symbol}` pair is a single affix instead of two spaced apart by
|
|
57
|
+
* nothing.
|
|
58
|
+
*
|
|
59
|
+
* The wrapped text needs no className: the decorator has already published the
|
|
60
|
+
* affix treatment through `TextClassProvider`.
|
|
61
|
+
*/
|
|
62
|
+
function wrapTextChildren(children: ReactNode): ReactNode {
|
|
63
|
+
const items = Children.toArray(children);
|
|
64
|
+
const output: ReactNode[] = [];
|
|
65
|
+
let run: (string | number)[] = [];
|
|
66
|
+
|
|
67
|
+
const flushRun = () => {
|
|
68
|
+
if (run.length === 0) return;
|
|
69
|
+
output.push(<Text key={`affix-${output.length}`}>{run.join("")}</Text>);
|
|
70
|
+
run = [];
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
for (const child of items) {
|
|
74
|
+
if (typeof child === "string" || typeof child === "number") {
|
|
75
|
+
run.push(child);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
flushRun();
|
|
79
|
+
output.push(child);
|
|
80
|
+
}
|
|
81
|
+
flushRun();
|
|
82
|
+
|
|
83
|
+
return output;
|
|
84
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import type { InputSlotProps } from "./input.types";
|
|
3
|
+
import { InputGroupDecorator } from "./input-group-decorator";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Leading content inside the field's own box — an icon, a text affix, a control.
|
|
7
|
+
*
|
|
8
|
+
* An `Icon` needs nothing said at the call site: it inherits the field's icon
|
|
9
|
+
* step and a muted colour from the decorator's `IconDefaultsProvider`, and turns
|
|
10
|
+
* destructive with the group when the value is invalid. A bare string is wrapped in a
|
|
11
|
+
* `Text` that inherits the same affix treatment.
|
|
12
|
+
*/
|
|
13
|
+
export function InputGroupPrefix(props: InputSlotProps): ReactElement {
|
|
14
|
+
return <InputGroupDecorator part="Input.Group.Prefix" {...props} />;
|
|
15
|
+
}
|
|
16
|
+
InputGroupPrefix.displayName = "DelacourUI.Input.Group.Prefix";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import type { InputSlotProps } from "./input.types";
|
|
3
|
+
import { InputGroupDecorator } from "./input-group-decorator";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Trailing content inside the field's own box — an icon, a text affix, a control.
|
|
7
|
+
*
|
|
8
|
+
* Draws nothing of its own, unlike `ListGroup.ItemSuffix`: a row in a list has a
|
|
9
|
+
* default chevron because it always navigates, whereas a field's trailing slot
|
|
10
|
+
* has no one thing it usually holds.
|
|
11
|
+
*/
|
|
12
|
+
export function InputGroupSuffix(props: InputSlotProps): ReactElement {
|
|
13
|
+
return <InputGroupDecorator part="Input.Group.Suffix" {...props} />;
|
|
14
|
+
}
|
|
15
|
+
InputGroupSuffix.displayName = "DelacourUI.Input.Group.Suffix";
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { type ReactElement, type ReactNode, useCallback, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { TextInput } from "react-native";
|
|
3
|
+
import { useButtonGroupItemContext } from "../button/button.context";
|
|
4
|
+
import { resolveButtonSizeStep } from "../button/button.variants";
|
|
5
|
+
import { useFieldContext } from "../field/field.context";
|
|
6
|
+
import { Pressable } from "../pressable";
|
|
7
|
+
import { type InputGroupContextValue, InputGroupProvider } from "./input.context";
|
|
8
|
+
import { type InputSize, type InputVariant, resolveInputGroupClass } from "./input.variants";
|
|
9
|
+
import { InputGroupPrefix } from "./input-group-prefix";
|
|
10
|
+
import { InputGroupSuffix } from "./input-group-suffix";
|
|
11
|
+
|
|
12
|
+
export type InputGroupProps = {
|
|
13
|
+
/** Visual variant of the box. Read by the `Input` inside it. */
|
|
14
|
+
variant?: InputVariant;
|
|
15
|
+
/** Size of the box, the field's type scale and a decorator's icon. */
|
|
16
|
+
size?: InputSize;
|
|
17
|
+
/**
|
|
18
|
+
* Reports an invalid value: the border, the caret and the decorators all turn
|
|
19
|
+
* destructive. Inherited from an enclosing `Field` when it is not given.
|
|
20
|
+
*/
|
|
21
|
+
isInvalid?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Disables the field inside and fades the whole box. Inherited from an
|
|
24
|
+
* enclosing `Field` when it is not given.
|
|
25
|
+
*/
|
|
26
|
+
isDisabled?: boolean;
|
|
27
|
+
className?: string;
|
|
28
|
+
children?: ReactNode;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function InputGroupRoot({
|
|
32
|
+
variant = "primary",
|
|
33
|
+
size = "md",
|
|
34
|
+
isInvalid,
|
|
35
|
+
isDisabled,
|
|
36
|
+
className,
|
|
37
|
+
children,
|
|
38
|
+
}: InputGroupProps): ReactElement {
|
|
39
|
+
const fieldRef = useRef<TextInput | null>(null);
|
|
40
|
+
const field = useFieldContext();
|
|
41
|
+
const [isFocused, setFocused] = useState(false);
|
|
42
|
+
|
|
43
|
+
// An enclosing `Field` is the outermost source, so it is the last fallback:
|
|
44
|
+
// a group inside an invalid field turns destructive with it, and a group that
|
|
45
|
+
// names its own state overrides that. These are deliberately not defaulted
|
|
46
|
+
// in the destructure — `false` there would swallow the field before it was
|
|
47
|
+
// ever consulted.
|
|
48
|
+
const resolvedIsInvalid = isInvalid ?? field?.isInvalid ?? false;
|
|
49
|
+
|
|
50
|
+
// An enclosing `Button.Group` owns the run's shape, so a grouped field draws
|
|
51
|
+
// the group's corner and overlaps its neighbour instead of drawing its own.
|
|
52
|
+
// `size` comes from the group outright — controls of different heights do
|
|
53
|
+
// not join — while `isDisabled` stays a fallback, so one member of a group
|
|
54
|
+
// can still be the only one disabled.
|
|
55
|
+
const member = useButtonGroupItemContext();
|
|
56
|
+
const resolvedSize = member ? resolveButtonSizeStep(member.size) : size;
|
|
57
|
+
const resolvedIsMemberDisabled = isDisabled ?? field?.isDisabled ?? member?.isDisabled ?? false;
|
|
58
|
+
|
|
59
|
+
const context = useMemo<InputGroupContextValue>(
|
|
60
|
+
() => ({
|
|
61
|
+
fieldRef,
|
|
62
|
+
isDisabled: resolvedIsMemberDisabled,
|
|
63
|
+
isFocused,
|
|
64
|
+
isInvalid: resolvedIsInvalid,
|
|
65
|
+
setFocused,
|
|
66
|
+
size: resolvedSize,
|
|
67
|
+
variant,
|
|
68
|
+
}),
|
|
69
|
+
[resolvedIsMemberDisabled, isFocused, resolvedIsInvalid, resolvedSize, variant]
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// A lone field is its own tap target edge to edge. Inside a group the field
|
|
73
|
+
// only covers the middle of the box, so the gutter and the space around a
|
|
74
|
+
// decorator have to hand the press on rather than swallow it.
|
|
75
|
+
const focusField = useCallback(() => fieldRef.current?.focus(), []);
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<InputGroupProvider value={context}>
|
|
79
|
+
<Pressable
|
|
80
|
+
accessible={false}
|
|
81
|
+
className={resolveInputGroupClass({
|
|
82
|
+
className,
|
|
83
|
+
groupPosition: member?.position ?? "none",
|
|
84
|
+
isDisabled: resolvedIsMemberDisabled,
|
|
85
|
+
isFocused,
|
|
86
|
+
isInvalid: resolvedIsInvalid,
|
|
87
|
+
isSeamed: member?.isSeamed ?? false,
|
|
88
|
+
orientation: member?.orientation ?? "horizontal",
|
|
89
|
+
size: resolvedSize,
|
|
90
|
+
variant,
|
|
91
|
+
})}
|
|
92
|
+
disabled={resolvedIsMemberDisabled}
|
|
93
|
+
feedback="none"
|
|
94
|
+
onPress={focusField}
|
|
95
|
+
>
|
|
96
|
+
{children}
|
|
97
|
+
</Pressable>
|
|
98
|
+
</InputGroupProvider>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A field with content inside its box — a leading icon, a trailing control, a
|
|
104
|
+
* text affix, or all three.
|
|
105
|
+
*
|
|
106
|
+
* The box is the same one a lone `Input` draws. It is not a copy: both read the
|
|
107
|
+
* one `root` slot of `inputVariants`, which lands on the `TextInput` when a
|
|
108
|
+
* field stands alone and on this row when it does not. A grouped field is
|
|
109
|
+
* therefore indistinguishable from an ungrouped one by construction, and
|
|
110
|
+
* `input.variants.test.ts` pins that as a property rather than a comment.
|
|
111
|
+
*
|
|
112
|
+
* The group owns the box, so it owns the axes that draw one — `variant`, `size`,
|
|
113
|
+
* `isInvalid`, `isDisabled` all live here and the `Input` inside reads them from
|
|
114
|
+
* context. One box, one set of axes.
|
|
115
|
+
*
|
|
116
|
+
* Pressing the box focuses the field, so the gutter behaves the way a lone
|
|
117
|
+
* field's does. A control inside a decorator still receives its own press.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* <Input.Group>
|
|
121
|
+
* <Input.Group.Prefix>
|
|
122
|
+
* <Icon icon={IconMagnifyingGlass} />
|
|
123
|
+
* </Input.Group.Prefix>
|
|
124
|
+
* <Input placeholder="Search" />
|
|
125
|
+
* </Input.Group>
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* <Input.Group size="lg">
|
|
129
|
+
* <Input.Group.Prefix>$</Input.Group.Prefix>
|
|
130
|
+
* <Input inputMode="decimal" placeholder="0.00" />
|
|
131
|
+
* <Input.Group.Suffix>NZD</Input.Group.Suffix>
|
|
132
|
+
* </Input.Group>
|
|
133
|
+
*/
|
|
134
|
+
export const InputGroup = Object.assign(InputGroupRoot, {
|
|
135
|
+
/** Leading content inside the box. An `Icon` or a bare string needs nothing else. */
|
|
136
|
+
Prefix: InputGroupPrefix,
|
|
137
|
+
/** Trailing content inside the box — a clear button, a unit, a reveal toggle. */
|
|
138
|
+
Suffix: InputGroupSuffix,
|
|
139
|
+
displayName: "DelacourUI.Input.Group",
|
|
140
|
+
});
|