@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,282 @@
|
|
|
1
|
+
import type { VariantProps } from "tailwind-variants";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
import { tv } from "../../lib/tv";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The named text roles, block presets first and largest first.
|
|
7
|
+
*
|
|
8
|
+
* Order carries meaning for the block half — the tests read it to assert the
|
|
9
|
+
* type scale descends — so a new block role goes in at its step rather than on
|
|
10
|
+
* the end.
|
|
11
|
+
*/
|
|
12
|
+
export const TEXT_VARIANTS = [
|
|
13
|
+
"display",
|
|
14
|
+
"title",
|
|
15
|
+
"header",
|
|
16
|
+
"subheader",
|
|
17
|
+
"paragraph",
|
|
18
|
+
"label",
|
|
19
|
+
"caption",
|
|
20
|
+
"overline",
|
|
21
|
+
"strong",
|
|
22
|
+
"emphasis",
|
|
23
|
+
"link",
|
|
24
|
+
"code",
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Presets that emit only a delta — a weight, a slant, a decoration, a family —
|
|
29
|
+
* and inherit size, colour and weight from the `Text` they sit inside.
|
|
30
|
+
*
|
|
31
|
+
* This is what makes `<Text.Paragraph>Hi <Text.Strong>there</Text.Strong></Text.Paragraph>`
|
|
32
|
+
* come out at the paragraph's size and colour instead of snapping back to a
|
|
33
|
+
* base of its own.
|
|
34
|
+
*/
|
|
35
|
+
export const TEXT_INLINE_VARIANTS = ["strong", "emphasis", "link", "code"] as const;
|
|
36
|
+
|
|
37
|
+
export const TEXT_SIZES = ["xs", "sm", "md", "lg", "xl", "2xl", "3xl"] as const;
|
|
38
|
+
export const TEXT_WEIGHTS = ["normal", "medium", "semibold", "bold"] as const;
|
|
39
|
+
export const TEXT_COLORS = ["default", "muted", "destructive", "success", "warning", "info"] as const;
|
|
40
|
+
export const TEXT_ALIGNS = ["left", "center", "right"] as const;
|
|
41
|
+
export const TEXT_TRANSFORMS = ["none", "uppercase", "lowercase", "capitalize"] as const;
|
|
42
|
+
|
|
43
|
+
export type TextVariant = (typeof TEXT_VARIANTS)[number];
|
|
44
|
+
export type TextInlineVariant = (typeof TEXT_INLINE_VARIANTS)[number];
|
|
45
|
+
export type TextSize = (typeof TEXT_SIZES)[number];
|
|
46
|
+
export type TextWeight = (typeof TEXT_WEIGHTS)[number];
|
|
47
|
+
export type TextColor = (typeof TEXT_COLORS)[number];
|
|
48
|
+
export type TextAlign = (typeof TEXT_ALIGNS)[number];
|
|
49
|
+
export type TextTransform = (typeof TEXT_TRANSFORMS)[number];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The treatment a `Text` falls back to on every axis nothing else names.
|
|
53
|
+
*
|
|
54
|
+
* First in {@link resolveTextClass}'s chain and therefore the weakest source, so
|
|
55
|
+
* it never fights an inherited class or a named axis. It is applied
|
|
56
|
+
* unconditionally — including inside a nested `Text` — and that is deliberate.
|
|
57
|
+
*
|
|
58
|
+
* For a nested `Text` it is a no-op: the parent's resolved class came out of
|
|
59
|
+
* this same function and already names all three axes, so tailwind-merge drops
|
|
60
|
+
* every class here. It only does work under a *partial* provider — a `Button`
|
|
61
|
+
* publishing nothing but `text-primary-foreground`, say. Guarding it behind "am
|
|
62
|
+
* I nested" would leave that `Text` with a colour and no size at all, silently
|
|
63
|
+
* collapsing to React Native's own 14pt default.
|
|
64
|
+
*
|
|
65
|
+
* It names the three axes React Native inherits natively and cannot default
|
|
66
|
+
* sensibly on its own — weight, size, colour — plus the family.
|
|
67
|
+
*
|
|
68
|
+
* `font-sans` is what makes the typeface themeable at all: it resolves
|
|
69
|
+
* `--font-sans`, which `theme.css` declares per platform and an app may retune
|
|
70
|
+
* at runtime. Without it a `Text` carries no `fontFamily` and the platform
|
|
71
|
+
* default stands however the variable is set. It is FIRST in the chain, so
|
|
72
|
+
* `Text.Code`'s `font-mono` and the headings' `font-heading` — emitted later by
|
|
73
|
+
* `textVariants`, in the same tailwind-merge group — both still win.
|
|
74
|
+
*/
|
|
75
|
+
export const TEXT_BASE_CLASS = "font-sans font-normal text-base text-foreground";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Cap on OS font scaling.
|
|
79
|
+
*
|
|
80
|
+
* Accessibility text sizes are respected — `allowFontScaling` is left at React
|
|
81
|
+
* Native's default — but the library's fixed-height chrome cannot grow with
|
|
82
|
+
* them: `h-button-sm|md|lg` are hard 36/44/52pt and `h-navbar-row` is 56pt, so
|
|
83
|
+
* an uncapped multiplier clips a label rather than enlarging it.
|
|
84
|
+
*/
|
|
85
|
+
export const TEXT_MAX_FONT_SIZE_MULTIPLIER = 1.4;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Styling for one piece of text, on six independent axes.
|
|
89
|
+
*
|
|
90
|
+
* ## Order is the design
|
|
91
|
+
*
|
|
92
|
+
* `tv` emits variants in the order their keys are declared here, then the
|
|
93
|
+
* compound variants, then the caller's `className`. `variant` is FIRST because
|
|
94
|
+
* a preset is the weakest named source: `<Text.Header size="sm">` has to come
|
|
95
|
+
* out at 14pt. The axes after it touch different tailwind-merge groups, so
|
|
96
|
+
* their order relative to each other means nothing.
|
|
97
|
+
*
|
|
98
|
+
* ## No `defaultVariants`, on purpose
|
|
99
|
+
*
|
|
100
|
+
* A `defaultVariants: { size: "md" }` would emit `text-base` from THIS call,
|
|
101
|
+
* after `variant` and therefore ahead of it in the merge — every preset's size
|
|
102
|
+
* would be overwritten by the default. More importantly an axis the caller did
|
|
103
|
+
* not name has to emit *nothing*, so it falls through to the class the
|
|
104
|
+
* enclosing `Text` published. That is what reproduces React Native's own
|
|
105
|
+
* cascade. `iconVariants` avoids the same trap for the same reason; the
|
|
106
|
+
* fallback lives in {@link TEXT_BASE_CLASS} instead.
|
|
107
|
+
*
|
|
108
|
+
* ## A variant names type, never a line height
|
|
109
|
+
*
|
|
110
|
+
* tailwind-merge lists `leading` among `font-size`'s conflicting groups, so a
|
|
111
|
+
* `text-lg` from the `size` axis silently strips a `leading-6` written beside a
|
|
112
|
+
* `text-base` here — no error, just a paragraph that loses its leading the
|
|
113
|
+
* moment someone resizes it. Tailwind v4's `--text-*--line-height` companions
|
|
114
|
+
* survive `tokens.css`'s size-only overrides, so every step already carries a
|
|
115
|
+
* paired leading. If prose ever needs its own, add a `--text-paragraph` /
|
|
116
|
+
* `--text-paragraph--line-height` *pair* in `tokens.css` and register the
|
|
117
|
+
* bespoke name in `tokens.ts` — one utility carrying both.
|
|
118
|
+
*
|
|
119
|
+
* Free of React Native imports so it stays unit-testable — `bun test` cannot
|
|
120
|
+
* parse React Native's Flow-typed source. See AGENTS.md.
|
|
121
|
+
*/
|
|
122
|
+
export const textVariants = tv({
|
|
123
|
+
variants: {
|
|
124
|
+
variant: {
|
|
125
|
+
// The three block headings, and the only presets that read
|
|
126
|
+
// `--font-heading`. It defaults to the same family as `--font-sans`,
|
|
127
|
+
// so pairing a display face with body text is opt-in for an app
|
|
128
|
+
// rather than something this package decides.
|
|
129
|
+
display: "font-heading font-bold text-3xl text-foreground",
|
|
130
|
+
title: "font-heading font-bold text-2xl text-foreground",
|
|
131
|
+
header: "font-heading font-semibold text-xl text-foreground",
|
|
132
|
+
// A step down from the title it sits under, not a fourth heading
|
|
133
|
+
// level — which is why it is muted and carries no weight of its own.
|
|
134
|
+
subheader: "text-lg text-muted-foreground",
|
|
135
|
+
paragraph: "text-base text-foreground",
|
|
136
|
+
label: "font-medium text-sm text-foreground",
|
|
137
|
+
caption: "text-sm text-muted-foreground",
|
|
138
|
+
overline: "font-semibold text-muted-foreground text-xs uppercase tracking-wide",
|
|
139
|
+
// The four below emit a delta and nothing else. See TEXT_INLINE_VARIANTS.
|
|
140
|
+
strong: "font-semibold",
|
|
141
|
+
emphasis: "italic",
|
|
142
|
+
// `text-info`, never `text-primary`. `--primary` is the primary
|
|
143
|
+
// SURFACE — #262626 light, #f5f5f5 dark — so a link painted with it
|
|
144
|
+
// would be indistinguishable from the foreground it sits in.
|
|
145
|
+
link: "text-info underline",
|
|
146
|
+
code: "bg-muted font-mono",
|
|
147
|
+
},
|
|
148
|
+
// The empty branches are load-bearing typing, not placeholders: a map with
|
|
149
|
+
// only `true` types the prop as `true` rather than `boolean`. The compound
|
|
150
|
+
// below matches without them — `tv` compares against `defaultVariants` plus
|
|
151
|
+
// props and never reads this map. See the note in button.variants.ts.
|
|
152
|
+
isNested: { true: "", false: "" },
|
|
153
|
+
size: {
|
|
154
|
+
xs: "text-xs",
|
|
155
|
+
sm: "text-sm",
|
|
156
|
+
// `md` rather than Tailwind's own `base`, so one step name means "the
|
|
157
|
+
// default step" across Icon, Spinner, Button, ListGroup and Text. The
|
|
158
|
+
// token underneath is still `--text-base`.
|
|
159
|
+
md: "text-base",
|
|
160
|
+
lg: "text-lg",
|
|
161
|
+
xl: "text-xl",
|
|
162
|
+
"2xl": "text-2xl",
|
|
163
|
+
"3xl": "text-3xl",
|
|
164
|
+
},
|
|
165
|
+
weight: {
|
|
166
|
+
normal: "font-normal",
|
|
167
|
+
medium: "font-medium",
|
|
168
|
+
semibold: "font-semibold",
|
|
169
|
+
bold: "font-bold",
|
|
170
|
+
},
|
|
171
|
+
// Page-level text colours only. Text drawn ON a coloured surface is that
|
|
172
|
+
// surface component's job — BUTTON_FOREGROUND_TOKEN already maps a variant
|
|
173
|
+
// to its foreground, and a second copy of that map here could drift from
|
|
174
|
+
// it. A caller on a coloured surface writes the utility directly.
|
|
175
|
+
color: {
|
|
176
|
+
default: "text-foreground",
|
|
177
|
+
muted: "text-muted-foreground",
|
|
178
|
+
destructive: "text-destructive",
|
|
179
|
+
success: "text-success",
|
|
180
|
+
warning: "text-warning",
|
|
181
|
+
info: "text-info",
|
|
182
|
+
},
|
|
183
|
+
// `left`/`right`, never `start`/`end`: React Native's `textAlign` accepts
|
|
184
|
+
// auto | left | right | center | justify and nothing else, so Tailwind v4's
|
|
185
|
+
// logical-property utilities would resolve to a value RN rejects.
|
|
186
|
+
align: { left: "text-left", center: "text-center", right: "text-right" },
|
|
187
|
+
transform: {
|
|
188
|
+
// `normal-case` rather than an empty string, so `transform="none"` can
|
|
189
|
+
// actually clear an inherited or preset `uppercase`.
|
|
190
|
+
none: "normal-case",
|
|
191
|
+
uppercase: "uppercase",
|
|
192
|
+
lowercase: "lowercase",
|
|
193
|
+
capitalize: "capitalize",
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
compoundVariants: [
|
|
197
|
+
// A standalone `Text.Code` is an ordinary Yoga box and takes padding. A
|
|
198
|
+
// NESTED one is a run inside the platform text engine — iOS lays it out as
|
|
199
|
+
// an NSAttributedString, Android as a Span — and both ignore padding,
|
|
200
|
+
// margin and border radius on an inner `<Text>` outright. Only the
|
|
201
|
+
// background survives, drawn tight to the glyphs with square corners.
|
|
202
|
+
{ variant: "code", isNested: false, class: "rounded-md px-1.5 py-0.5" },
|
|
203
|
+
],
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
/** Whether a preset emits only a delta and inherits the rest from the `Text` above it. */
|
|
207
|
+
export function isInlineTextVariant(variant: TextVariant): boolean {
|
|
208
|
+
return (TEXT_INLINE_VARIANTS as readonly string[]).includes(variant);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Whether a `Text` with these children can hold a nested `Text` at all.
|
|
213
|
+
*
|
|
214
|
+
* A string or number child has no descendant component, so nothing below could
|
|
215
|
+
* read the context — publishing it would render a provider element per label
|
|
216
|
+
* for the most-instantiated component in the library. `false` here is provable
|
|
217
|
+
* rather than a guess, which is what makes skipping the provider safe.
|
|
218
|
+
*
|
|
219
|
+
* Deliberately typed on `unknown` rather than `ReactNode`: keeping this module
|
|
220
|
+
* free of even a type import is what lets `bun test` reach it.
|
|
221
|
+
*/
|
|
222
|
+
export function canNestText(children: unknown): boolean {
|
|
223
|
+
return typeof children !== "string" && typeof children !== "number";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The class chain that styles one piece of text, weakest source first.
|
|
228
|
+
*
|
|
229
|
+
* | Source | How it wins |
|
|
230
|
+
* | --- | --- |
|
|
231
|
+
* | {@link TEXT_BASE_CLASS} | first in the chain |
|
|
232
|
+
* | the class inherited from the enclosing `Text` or `TextClassProvider` | second |
|
|
233
|
+
* | this `Text`'s own named axes | third |
|
|
234
|
+
* | the caller's `className` | last |
|
|
235
|
+
*
|
|
236
|
+
* This is what replaces React Native's native text inheritance. Natively a
|
|
237
|
+
* nested `<Text>` inherits the parent's *style object* and overrides only the
|
|
238
|
+
* keys it sets; here every `Text` compiles its own className, so the parent's
|
|
239
|
+
* resolved class has to be threaded through and beaten per-axis rather than
|
|
240
|
+
* wholesale. The chain does that: an axis the child names wins, an axis it
|
|
241
|
+
* leaves alone falls through.
|
|
242
|
+
*
|
|
243
|
+
* The property that makes nesting correct is a fixpoint — for every `x` this
|
|
244
|
+
* function can produce, `resolveTextClass({ inherited: x })` is exactly `x`, at
|
|
245
|
+
* any depth. The tests assert it across the matrix.
|
|
246
|
+
*
|
|
247
|
+
* Precedence is the ladder `Icon` and `Spinner` already follow, so a `Text`
|
|
248
|
+
* composed into a `Button` matches it without being told to. See
|
|
249
|
+
* `resolveIconSizeClass`.
|
|
250
|
+
*
|
|
251
|
+
* Pure, so the whole ladder is reachable from `bun test`. See AGENTS.md.
|
|
252
|
+
*/
|
|
253
|
+
export function resolveTextClass({
|
|
254
|
+
align,
|
|
255
|
+
className,
|
|
256
|
+
color,
|
|
257
|
+
inherited,
|
|
258
|
+
isNested = false,
|
|
259
|
+
size,
|
|
260
|
+
transform,
|
|
261
|
+
variant,
|
|
262
|
+
weight,
|
|
263
|
+
}: {
|
|
264
|
+
align?: TextAlign;
|
|
265
|
+
className?: string;
|
|
266
|
+
color?: TextColor;
|
|
267
|
+
inherited?: string;
|
|
268
|
+
isNested?: boolean;
|
|
269
|
+
size?: TextSize;
|
|
270
|
+
transform?: TextTransform;
|
|
271
|
+
variant?: TextVariant;
|
|
272
|
+
weight?: TextWeight;
|
|
273
|
+
}): string {
|
|
274
|
+
return cn(
|
|
275
|
+
TEXT_BASE_CLASS,
|
|
276
|
+
inherited,
|
|
277
|
+
textVariants({ align, color, isNested, size, transform, variant, weight }),
|
|
278
|
+
className
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export type TextVariantProps = VariantProps<typeof textVariants>;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const SRC = import.meta.dirname;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every component in the package must carry a `DelacourUI.`-prefixed displayName.
|
|
9
|
+
*
|
|
10
|
+
* React DevTools reads `displayName || name`, so without one a tree row, an error
|
|
11
|
+
* stack and a profiler entry all read the private symbol — `ScreenNavbarTitle`
|
|
12
|
+
* rather than `DelacourUI.Screen.Navbar.Title`. `bun test` cannot render React
|
|
13
|
+
* Native, so the convention is checked against the source text instead: this file
|
|
14
|
+
* reads the `.tsx` tree off disk and imports nothing from it.
|
|
15
|
+
*
|
|
16
|
+
* The corpus spans every file at once rather than checking each in isolation,
|
|
17
|
+
* because `ScreenRoot` is declared in `screen-root.tsx` and named by the
|
|
18
|
+
* `Object.assign` in `screen.tsx` — see components/screen/AGENTS.md for why the
|
|
19
|
+
* two cannot share a file.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Every `.tsx` under `src/`, as a path → source map. */
|
|
23
|
+
function sources(): Map<string, string> {
|
|
24
|
+
const found = new Map<string, string>();
|
|
25
|
+
const walk = (dir: string): void => {
|
|
26
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
27
|
+
const path = join(dir, entry.name);
|
|
28
|
+
if (entry.isDirectory()) walk(path);
|
|
29
|
+
else if (entry.name.endsWith(".tsx")) found.set(path, readFileSync(path, "utf-8"));
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
walk(SRC);
|
|
33
|
+
return found;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const SOURCES = sources();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A PascalCase `function` declaration, or a `memo(…)` / `forwardRef(…)` const.
|
|
40
|
+
*
|
|
41
|
+
* Those are the only three shapes a component takes here: the package has no
|
|
42
|
+
* arrow-function components, and every PascalCase function declaration in it
|
|
43
|
+
* returns `ReactElement`, `ReactElement | null` or `null`. A PascalCase helper
|
|
44
|
+
* would be a false positive — write helpers in camelCase, as the package already
|
|
45
|
+
* does (`wrapTextChildren`, `withDividers`, `resolveListComponent`).
|
|
46
|
+
*
|
|
47
|
+
* A `forwardRef` component's own render function is a function EXPRESSION on the
|
|
48
|
+
* same line as the call, so it never starts a line and is not counted twice —
|
|
49
|
+
* which is why the screen scrollables name themselves once, on the const.
|
|
50
|
+
*/
|
|
51
|
+
const DECLARATIONS = [
|
|
52
|
+
/^(?:export )?function ([A-Z][A-Za-z0-9]*)\s*[<(]/gm,
|
|
53
|
+
/^(?:export )?const ([A-Z][A-Za-z0-9]*) = memo\(/gm,
|
|
54
|
+
/^(?:export )?const ([A-Z][A-Za-z0-9]*) = forwardRef[<(]/gm,
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/** The trailing form on a part, and the `Object.assign` form on a root. */
|
|
58
|
+
const BINDINGS = [
|
|
59
|
+
/^([A-Z][A-Za-z0-9]*)\.displayName = "([^"]*)";/gm,
|
|
60
|
+
/Object\.assign\(\s*([A-Z][A-Za-z0-9]*)\s*,\s*\{[\s\S]*?displayName: "([^"]*)"/gm,
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/** Component name → the file it is declared in. */
|
|
64
|
+
function declared(): Map<string, string> {
|
|
65
|
+
const found = new Map<string, string>();
|
|
66
|
+
for (const [path, source] of SOURCES) {
|
|
67
|
+
for (const pattern of DECLARATIONS) {
|
|
68
|
+
for (const [, name] of source.matchAll(pattern)) found.set(name, path);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return found;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Component name → every displayName bound to it, across the whole tree. */
|
|
75
|
+
function bound(): Map<string, string[]> {
|
|
76
|
+
const found = new Map<string, string[]>();
|
|
77
|
+
for (const source of SOURCES.values()) {
|
|
78
|
+
for (const pattern of BINDINGS) {
|
|
79
|
+
for (const [, name, value] of source.matchAll(pattern)) {
|
|
80
|
+
found.set(name, [...(found.get(name) ?? []), value]);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const DECLARED = declared();
|
|
88
|
+
const BOUND = bound();
|
|
89
|
+
|
|
90
|
+
/** `DelacourUI` plus one segment per step down the public API. */
|
|
91
|
+
const SHAPE = /^DelacourUI(\.[A-Z][A-Za-z0-9]*)+$/;
|
|
92
|
+
|
|
93
|
+
describe("displayName", () => {
|
|
94
|
+
// A walker that matched nothing would let every assertion below pass on an
|
|
95
|
+
// empty set, so the suite would stay green while the convention rotted.
|
|
96
|
+
test("finds the component tree", () => {
|
|
97
|
+
expect(SOURCES.size).toBeGreaterThan(50);
|
|
98
|
+
expect(DECLARED.size).toBeGreaterThan(80);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("every component has one", () => {
|
|
102
|
+
const missing = [...DECLARED]
|
|
103
|
+
.filter(([name]) => !BOUND.has(name))
|
|
104
|
+
.map(([name, path]) => `${name} (${path.slice(SRC.length + 1)})`);
|
|
105
|
+
expect(missing).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Two bindings on one component is a rename that only landed in one place.
|
|
109
|
+
test("no component has two", () => {
|
|
110
|
+
const doubled = [...BOUND].filter(([, values]) => values.length > 1).map(([name]) => name);
|
|
111
|
+
expect(doubled).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("every name is a DelacourUI path", () => {
|
|
115
|
+
const wrong = [...BOUND]
|
|
116
|
+
.filter(([, values]) => values.some((value) => !SHAPE.test(value)))
|
|
117
|
+
.map(([name, values]) => `${name} = ${values[0]}`);
|
|
118
|
+
expect(wrong).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Two components answering to one name is a copy-pasted part file.
|
|
122
|
+
test("no two components share a name", () => {
|
|
123
|
+
const seen = new Map<string, string>();
|
|
124
|
+
const clashes: string[] = [];
|
|
125
|
+
for (const [name, values] of BOUND) {
|
|
126
|
+
for (const value of values) {
|
|
127
|
+
const owner = seen.get(value);
|
|
128
|
+
if (owner) clashes.push(`${value}: ${owner} and ${name}`);
|
|
129
|
+
else seen.set(value, name);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
expect(clashes).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// A part's name is its path in the public API, so its root's name is a prefix
|
|
136
|
+
// of it. This is what keeps `Badge.Label` from drifting to `Badge.Text`.
|
|
137
|
+
test("every name descends from a component that exists", () => {
|
|
138
|
+
const names = new Set([...BOUND.values()].flat());
|
|
139
|
+
const orphans = [...names].filter((name) => {
|
|
140
|
+
const parent = name.slice(0, name.lastIndexOf("."));
|
|
141
|
+
return parent !== "DelacourUI" && !names.has(parent);
|
|
142
|
+
});
|
|
143
|
+
expect(orphans).toEqual([]);
|
|
144
|
+
});
|
|
145
|
+
});
|
package/src/docs.test.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const SRC = import.meta.dirname;
|
|
6
|
+
const COMPONENTS = join(SRC, "components");
|
|
7
|
+
const PACKAGE_DOC = join(SRC, "..", "AGENTS.md");
|
|
8
|
+
const PACKAGE_JSON = join(SRC, "..", "package.json");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Every component folder must carry an `AGENTS.md`, and the package doc must
|
|
12
|
+
* index it.
|
|
13
|
+
*
|
|
14
|
+
* `Radio` shipped exported, tested and rendered in the playground, and stayed
|
|
15
|
+
* undocumented for fifteen commits — while nine bullets in other sections
|
|
16
|
+
* already pointed at rules nobody had written down. Nothing caught it, because
|
|
17
|
+
* nothing was looking. This looks.
|
|
18
|
+
*
|
|
19
|
+
* The index table is the second assertion for a reason: a folder can hold an
|
|
20
|
+
* `AGENTS.md` nobody can find. A component absent from the table is a component
|
|
21
|
+
* an agent reaches only by already knowing it exists, which is the same failure
|
|
22
|
+
* one step later.
|
|
23
|
+
*
|
|
24
|
+
* Like `display-name.test.ts` and `styles/tokens.test.ts`, this reads the tree as
|
|
25
|
+
* **text** and imports nothing from it. That is the move available whenever a
|
|
26
|
+
* convention is real but no renderer can check it — and `bun test` cannot render
|
|
27
|
+
* React Native at all.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Every directory under `src/components/`, by folder name. */
|
|
31
|
+
function componentFolders(): string[] {
|
|
32
|
+
return readdirSync(COMPONENTS, { withFileTypes: true })
|
|
33
|
+
.filter((entry) => entry.isDirectory())
|
|
34
|
+
.map((entry) => entry.name)
|
|
35
|
+
.sort();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The folder names the package doc's index table lists.
|
|
40
|
+
*
|
|
41
|
+
* Matched on the export subpath rather than the heading, because the two are not
|
|
42
|
+
* the same word: `provider` is `DelacourProvider` and `list-group` is
|
|
43
|
+
* `ListGroup`. The subpath is the unambiguous key, and it is the string a
|
|
44
|
+
* consumer actually types.
|
|
45
|
+
*
|
|
46
|
+
* The package name is read from `package.json` rather than written out here, so
|
|
47
|
+
* a rename cannot leave this matching a name nobody publishes any more. It did
|
|
48
|
+
* once: the literal was a regex, escaped slashes and all, so the rename that
|
|
49
|
+
* swept every other mention of the old name walked straight past it and every
|
|
50
|
+
* component read as undocumented.
|
|
51
|
+
*/
|
|
52
|
+
function indexedFolders(): string[] {
|
|
53
|
+
const doc = readFileSync(PACKAGE_DOC, "utf-8");
|
|
54
|
+
const { name } = JSON.parse(readFileSync(PACKAGE_JSON, "utf-8")) as { name: string };
|
|
55
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
56
|
+
const rows = doc.matchAll(new RegExp(`\\[\`?${escaped}/([a-z-]+)\`?\\]|\`${escaped}/([a-z-]+)\``, "g"));
|
|
57
|
+
return [...new Set([...rows].map(([, a, b]) => a ?? b))].sort();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const FOLDERS = componentFolders();
|
|
61
|
+
const INDEXED = indexedFolders();
|
|
62
|
+
|
|
63
|
+
describe("component docs", () => {
|
|
64
|
+
// A walker that found nothing would let every assertion below pass on an
|
|
65
|
+
// empty set, so the suite would stay green while the convention rotted.
|
|
66
|
+
test("finds the component tree", () => {
|
|
67
|
+
expect(FOLDERS.length).toBeGreaterThan(15);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("every component folder has an AGENTS.md", () => {
|
|
71
|
+
const missing = FOLDERS.filter((name) => !existsSync(join(COMPONENTS, name, "AGENTS.md")));
|
|
72
|
+
expect(missing).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// An empty file passes the check above and helps nobody.
|
|
76
|
+
test("no component doc is a stub", () => {
|
|
77
|
+
const stubs = FOLDERS.filter((name) => {
|
|
78
|
+
const path = join(COMPONENTS, name, "AGENTS.md");
|
|
79
|
+
if (!existsSync(path)) return false;
|
|
80
|
+
const doc = readFileSync(path, "utf-8");
|
|
81
|
+
return !doc.startsWith("# ") || doc.split("\n").length < 10;
|
|
82
|
+
});
|
|
83
|
+
expect(stubs).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// This is the assertion that would have caught Radio.
|
|
87
|
+
test("every component is named in the package doc", () => {
|
|
88
|
+
const unlisted = FOLDERS.filter((name) => !INDEXED.includes(name));
|
|
89
|
+
expect(unlisted).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// A row naming a folder that no longer exists is a rename that landed in one
|
|
93
|
+
// place, and it sends a reader to a path that will not resolve.
|
|
94
|
+
test("the package doc names no component that does not exist", () => {
|
|
95
|
+
const orphans = INDEXED.filter((name) => !FOLDERS.includes(name));
|
|
96
|
+
expect(orphans).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { DarkTheme, DefaultTheme, ThemeProvider } from "expo-router";
|
|
2
|
+
import { type ReactElement, type ReactNode, useMemo } from "react";
|
|
3
|
+
import { useNavigationTheme } from "../hooks/use-navigation-theme";
|
|
4
|
+
|
|
5
|
+
export type NavigationThemeProps = {
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Hands the navigator this library's theme colours.
|
|
11
|
+
*
|
|
12
|
+
* Mount it around a `Stack`, `Tabs` or `Drawer`, inside `DelacourProvider`.
|
|
13
|
+
*
|
|
14
|
+
* Not decoration. `expo-router` mounts its `NavigationContainer` with no
|
|
15
|
+
* `theme`, so React Navigation's light default stands however dark the app is —
|
|
16
|
+
* and on iOS the native stack hands `colors.background` to the
|
|
17
|
+
* `UINavigationController`'s view, which is the slab visible between the cards
|
|
18
|
+
* during a push and at their rounded corners. That layer is set from the theme
|
|
19
|
+
* with no escape hatch, so a `contentStyle` in `screenOptions` cannot reach it.
|
|
20
|
+
*
|
|
21
|
+
* `ThemeProvider` comes from `expo-router`, which vendors React Navigation:
|
|
22
|
+
* `@react-navigation/native` is not a package this monorepo can resolve.
|
|
23
|
+
*
|
|
24
|
+
* The framework's own base theme is spread underneath rather than replaced,
|
|
25
|
+
* because `ReactNavigation.Theme` also carries `fonts`, whose shape is
|
|
26
|
+
* platform-specific — restating it here would be a second definition able to
|
|
27
|
+
* drift from the one React Navigation actually ships. Only the colours this
|
|
28
|
+
* library owns are overridden, and `DarkTheme`'s `rgb(1, 1, 1)` background is
|
|
29
|
+
* exactly the kind of near-miss that makes overriding worth doing.
|
|
30
|
+
*
|
|
31
|
+
* Re-renders on a theme change and nothing else, so keep it below the
|
|
32
|
+
* providers — they have no reason to remount when the palette swaps.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* // app/_layout.tsx
|
|
36
|
+
* import { DelacourProvider } from "@delacour/react-native-ui/provider";
|
|
37
|
+
* import { NavigationTheme } from "@delacour/react-native-ui/expo/navigation-theme";
|
|
38
|
+
* import { Stack } from "expo-router";
|
|
39
|
+
*
|
|
40
|
+
* export default function RootLayout() {
|
|
41
|
+
* return (
|
|
42
|
+
* <DelacourProvider>
|
|
43
|
+
* <NavigationTheme>
|
|
44
|
+
* <Stack screenOptions={{ headerShown: false }} />
|
|
45
|
+
* </NavigationTheme>
|
|
46
|
+
* </DelacourProvider>
|
|
47
|
+
* );
|
|
48
|
+
* }
|
|
49
|
+
*/
|
|
50
|
+
export function NavigationTheme({ children }: NavigationThemeProps): ReactElement {
|
|
51
|
+
const { dark, colors } = useNavigationTheme();
|
|
52
|
+
const base = dark ? DarkTheme : DefaultTheme;
|
|
53
|
+
|
|
54
|
+
const value = useMemo(() => ({ ...base, colors: { ...base.colors, ...colors }, dark }), [base, colors, dark]);
|
|
55
|
+
|
|
56
|
+
return <ThemeProvider value={value}>{children}</ThemeProvider>;
|
|
57
|
+
}
|
|
58
|
+
NavigationTheme.displayName = "DelacourUI.NavigationTheme";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
type UseControllableStateOptions<T> = {
|
|
4
|
+
/** Controlled value. When not `undefined` the hook defers to the caller. */
|
|
5
|
+
value?: T;
|
|
6
|
+
/** Initial value used while uncontrolled. */
|
|
7
|
+
defaultValue: T;
|
|
8
|
+
onChange?: (value: T) => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Supports both controlled and uncontrolled usage from one hook.
|
|
13
|
+
*
|
|
14
|
+
* While `value` is undefined the state is held internally; once a value is
|
|
15
|
+
* passed the caller owns it and the setter only reports upward. Which mode is
|
|
16
|
+
* in play is locked in on first render — switching later is a caller bug and
|
|
17
|
+
* warns in development rather than silently changing behaviour.
|
|
18
|
+
*/
|
|
19
|
+
export function useControllableState<T>({
|
|
20
|
+
value,
|
|
21
|
+
defaultValue,
|
|
22
|
+
onChange,
|
|
23
|
+
}: UseControllableStateOptions<T>): [T, (next: T) => void] {
|
|
24
|
+
const isControlled = value !== undefined;
|
|
25
|
+
const wasControlled = useRef(isControlled);
|
|
26
|
+
const [uncontrolled, setUncontrolled] = useState<T>(defaultValue);
|
|
27
|
+
|
|
28
|
+
if (process.env.NODE_ENV !== "production" && wasControlled.current !== isControlled) {
|
|
29
|
+
console.warn(
|
|
30
|
+
`useControllableState: switched from ${wasControlled.current ? "controlled" : "uncontrolled"} to ` +
|
|
31
|
+
`${isControlled ? "controlled" : "uncontrolled"}. Pick one for the lifetime of the component.`
|
|
32
|
+
);
|
|
33
|
+
wasControlled.current = isControlled;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const setValue = useCallback(
|
|
37
|
+
(next: T) => {
|
|
38
|
+
if (!isControlled) setUncontrolled(next);
|
|
39
|
+
onChange?.(next);
|
|
40
|
+
},
|
|
41
|
+
[isControlled, onChange]
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
return [isControlled ? value : uncontrolled, setValue];
|
|
45
|
+
}
|