@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,147 @@
|
|
|
1
|
+
import { useCallback, useEffect } from "react";
|
|
2
|
+
import { AppState, type AppStateStatus, TextInput } from "react-native";
|
|
3
|
+
import { KeyboardEvents, useKeyboardContext } from "react-native-keyboard-controller";
|
|
4
|
+
import type { SharedValue } from "react-native-reanimated";
|
|
5
|
+
import { scheduleOnUI } from "react-native-worklets";
|
|
6
|
+
import { shouldResetKeyboardAnimation } from "../lib/keyboard-animation";
|
|
7
|
+
|
|
8
|
+
// Re-exported so the pure decision is reachable beside the hook that acts on
|
|
9
|
+
// it; it lives in `lib` only because `bun test` cannot follow this module's
|
|
10
|
+
// React Native imports.
|
|
11
|
+
export { shouldResetKeyboardAnimation } from "../lib/keyboard-animation";
|
|
12
|
+
|
|
13
|
+
/** The one pair of animation values `KeyboardProvider` shares with the whole app. */
|
|
14
|
+
type KeyboardAnimationValues = {
|
|
15
|
+
progress: SharedValue<number>;
|
|
16
|
+
height: SharedValue<number>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Snaps the shared progress and height back to closed when they are stale.
|
|
21
|
+
*
|
|
22
|
+
* The focus check stays on the JS thread — that is where the registry lives —
|
|
23
|
+
* and the read-and-write happens inside one worklet, so the UI thread's own
|
|
24
|
+
* values are what the decision is made against.
|
|
25
|
+
*/
|
|
26
|
+
export function reconcileKeyboardAnimation({ progress, height }: KeyboardAnimationValues): void {
|
|
27
|
+
if (TextInput.State.currentlyFocusedInput() != null) return;
|
|
28
|
+
|
|
29
|
+
scheduleOnUI(() => {
|
|
30
|
+
"worklet";
|
|
31
|
+
if (!shouldResetKeyboardAnimation({ hasFocusedInput: false, height: height.value, progress: progress.value })) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// Snap, never animate: the keyboard is already gone, so there is nothing
|
|
35
|
+
// to animate towards and a timing curve would just be a visible glitch.
|
|
36
|
+
progress.value = 0;
|
|
37
|
+
height.value = 0;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Repairs the keyboard animation values `KeyboardProvider` shares with the
|
|
43
|
+
* whole app when the keyboard disappears without telling them.
|
|
44
|
+
*
|
|
45
|
+
* `KeyboardProvider` owns exactly ONE pair of Reanimated shared values
|
|
46
|
+
* (`progress` / `height`), and on iOS they are written only by
|
|
47
|
+
* `onKeyboardMoveStart` — emitted from `keyboardWillAppear` /
|
|
48
|
+
* `keyboardWillDisappear` — and by the interactive-move KVO.
|
|
49
|
+
* `onKeyboardMoveEnd`, the event `keyboardDidDisappear` emits, updates them on
|
|
50
|
+
* ANDROID ONLY, and the library has no app-lifecycle re-sync at all.
|
|
51
|
+
*
|
|
52
|
+
* So any teardown that produces no `keyboardWillHide` — an interactive dismiss
|
|
53
|
+
* interrupted by navigation, a native-stack pop, a call interruption, an app
|
|
54
|
+
* suspend — leaves both values pinned at their open state, app-wide, forever.
|
|
55
|
+
* Every consumer then renders "keyboard open" over a screen with no keyboard on
|
|
56
|
+
* it: `Screen.Footer` translates its content up by a keyboard height, and
|
|
57
|
+
* `useScreenScrollInsets` reserves a keyboard-sized spacer under content that
|
|
58
|
+
* needs none.
|
|
59
|
+
*
|
|
60
|
+
* Deliberately NOT used here, and why:
|
|
61
|
+
* - `KeyboardController.dismiss()` resolves on `keyboardDidHide` and natively
|
|
62
|
+
* just resigns the first responder. With the keyboard already gone there is
|
|
63
|
+
* no responder, nothing fires, and the values stay stuck — and it would blur
|
|
64
|
+
* an input the user legitimately has focused.
|
|
65
|
+
* - `setEnabled(false/true)` unmounts and remounts the native observers,
|
|
66
|
+
* dropping some of them, and never touches the shared values.
|
|
67
|
+
* - Gating each reader behind a screen-local flag adds a second driver to every
|
|
68
|
+
* consumer and still leaves the globals wrong for the next screen.
|
|
69
|
+
*
|
|
70
|
+
* Mount ONCE, directly inside `<KeyboardProvider>` — or let
|
|
71
|
+
* `DelacourProvider` do it.
|
|
72
|
+
*/
|
|
73
|
+
export function useKeyboardStateSync(): void {
|
|
74
|
+
const { reanimated } = useKeyboardContext();
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const appState = AppState.addEventListener("change", (status: AppStateStatus) => {
|
|
78
|
+
if (status === "active") reconcileKeyboardAnimation(reanimated);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// The `did` events carry the authoritative terminal state, and on iOS the
|
|
82
|
+
// provider throws them away. Mirroring them here is what makes a lost
|
|
83
|
+
// `will` event self-heal on the very next keyboard transition.
|
|
84
|
+
const didHide = KeyboardEvents.addListener("keyboardDidHide", () => {
|
|
85
|
+
scheduleOnUI(() => {
|
|
86
|
+
"worklet";
|
|
87
|
+
reanimated.progress.value = 0;
|
|
88
|
+
reanimated.height.value = 0;
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
const didShow = KeyboardEvents.addListener("keyboardDidShow", (event) => {
|
|
92
|
+
// The same height `onKeyboardMoveStart` delivers — both come from the
|
|
93
|
+
// observer's `keyboardHeight`.
|
|
94
|
+
const keyboardHeight = event.height;
|
|
95
|
+
scheduleOnUI(() => {
|
|
96
|
+
"worklet";
|
|
97
|
+
reanimated.progress.value = 1;
|
|
98
|
+
reanimated.height.value = -keyboardHeight;
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// The values may already be stale by the time this mounts.
|
|
103
|
+
reconcileKeyboardAnimation(reanimated);
|
|
104
|
+
|
|
105
|
+
return () => {
|
|
106
|
+
appState.remove();
|
|
107
|
+
didHide.remove();
|
|
108
|
+
didShow.remove();
|
|
109
|
+
};
|
|
110
|
+
}, [reanimated]);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Render-free host for {@link useKeyboardStateSync}, to drop into a layout tree.
|
|
115
|
+
*
|
|
116
|
+
* `DelacourProvider` already mounts this — write it out only when composing the
|
|
117
|
+
* root providers by hand.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* <KeyboardProvider>
|
|
121
|
+
* <KeyboardStateSync />
|
|
122
|
+
* <Stack />
|
|
123
|
+
* </KeyboardProvider>
|
|
124
|
+
*/
|
|
125
|
+
export function KeyboardStateSync(): null {
|
|
126
|
+
useKeyboardStateSync();
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
KeyboardStateSync.displayName = "DelacourUI.KeyboardStateSync";
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Screen-level repair, for a surface that mounts into an already-poisoned
|
|
133
|
+
* global state.
|
|
134
|
+
*
|
|
135
|
+
* Idempotent and cheap — a no-op whenever an input is genuinely focused, which
|
|
136
|
+
* is what keeps "navigate to a new screen with the keyboard still open"
|
|
137
|
+
* working. `Screen.Footer` runs this on mount so a screen repairs itself even
|
|
138
|
+
* in an app that never mounted {@link KeyboardStateSync}.
|
|
139
|
+
*/
|
|
140
|
+
export function useKeyboardAnimationGuard(): void {
|
|
141
|
+
const { reanimated } = useKeyboardContext();
|
|
142
|
+
const reconcile = useCallback(() => reconcileKeyboardAnimation(reanimated), [reanimated]);
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
reconcile();
|
|
146
|
+
}, [reconcile]);
|
|
147
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { useUniwind } from "uniwind";
|
|
3
|
+
import { NAVIGATION_THEME_TOKENS, type NavigationTheme, omitUnresolvedColors } from "../lib/navigation-theme";
|
|
4
|
+
import { useThemeColor } from "./use-theme-color";
|
|
5
|
+
|
|
6
|
+
// Re-exported so the mapping is reachable beside the hook that reads it; it
|
|
7
|
+
// lives in `lib` only because `bun test` cannot follow this module's React
|
|
8
|
+
// Native imports.
|
|
9
|
+
export {
|
|
10
|
+
NAVIGATION_THEME_TOKENS,
|
|
11
|
+
type NavigationTheme,
|
|
12
|
+
type NavigationThemeSlot,
|
|
13
|
+
} from "../lib/navigation-theme";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The active theme's colours, shaped for a React Navigation theme.
|
|
17
|
+
*
|
|
18
|
+
* Exists because a navigator paints its own chrome from its own theme, and
|
|
19
|
+
* nothing connects that to this library's tokens. `expo-router` mounts its
|
|
20
|
+
* `NavigationContainer` with no `theme`, so the light default stands however
|
|
21
|
+
* dark the app is: on iOS the native stack hands `colors.background` to the
|
|
22
|
+
* `UINavigationController`'s view, which is the slab visible between cards
|
|
23
|
+
* during a push. That layer is hardcoded to the theme, so a `contentStyle` in
|
|
24
|
+
* `screenOptions` cannot reach it — only a theme can.
|
|
25
|
+
*
|
|
26
|
+
* Returns plain values rather than a `Theme`, and imports nothing from a
|
|
27
|
+
* navigation library. That is deliberate: this package takes no navigation
|
|
28
|
+
* dependency — the same reason `Screen.Navbar.BackButton` takes an `onPress`
|
|
29
|
+
* instead of calling a router — so the app owns the wiring and this owns the
|
|
30
|
+
* colours. It also sidesteps restating React Navigation's `fonts`, whose shape
|
|
31
|
+
* is platform-specific and would be a second definition able to drift.
|
|
32
|
+
*
|
|
33
|
+
* `dark` comes from Uniwind's active theme, never React Native's
|
|
34
|
+
* `useColorScheme`: an app that lets the user force light or dark against the
|
|
35
|
+
* system setting would otherwise theme its navigator the wrong way round.
|
|
36
|
+
*
|
|
37
|
+
* Reactive. Every colour is read through {@link useThemeColor}, which
|
|
38
|
+
* subscribes to Uniwind's theme changes, so switching theme re-renders the
|
|
39
|
+
* caller with new values rather than needing a reload.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* // expo-router. `ThemeProvider` comes from `expo-router`, which vendors
|
|
43
|
+
* // React Navigation — `@react-navigation/native` does not resolve.
|
|
44
|
+
* import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from "expo-router";
|
|
45
|
+
*
|
|
46
|
+
* function NavigationTheme({ children }: { children: ReactNode }) {
|
|
47
|
+
* const { dark, colors } = useNavigationTheme();
|
|
48
|
+
* const base = dark ? DarkTheme : DefaultTheme;
|
|
49
|
+
* const value = useMemo(
|
|
50
|
+
* () => ({ ...base, dark, colors: { ...base.colors, ...colors } }),
|
|
51
|
+
* [base, dark, colors]
|
|
52
|
+
* );
|
|
53
|
+
*
|
|
54
|
+
* return <ThemeProvider value={value}>{children}</ThemeProvider>;
|
|
55
|
+
* }
|
|
56
|
+
*/
|
|
57
|
+
export function useNavigationTheme(): NavigationTheme {
|
|
58
|
+
const { theme } = useUniwind();
|
|
59
|
+
|
|
60
|
+
// Written out one call per slot rather than looped: a hook cannot be called
|
|
61
|
+
// from inside a map, however stable the list looks.
|
|
62
|
+
const background = useThemeColor(NAVIGATION_THEME_TOKENS.background);
|
|
63
|
+
const card = useThemeColor(NAVIGATION_THEME_TOKENS.card);
|
|
64
|
+
const text = useThemeColor(NAVIGATION_THEME_TOKENS.text);
|
|
65
|
+
const border = useThemeColor(NAVIGATION_THEME_TOKENS.border);
|
|
66
|
+
const primary = useThemeColor(NAVIGATION_THEME_TOKENS.primary);
|
|
67
|
+
const notification = useThemeColor(NAVIGATION_THEME_TOKENS.notification);
|
|
68
|
+
|
|
69
|
+
const dark = theme === "dark";
|
|
70
|
+
|
|
71
|
+
return useMemo(
|
|
72
|
+
() => ({
|
|
73
|
+
colors: omitUnresolvedColors({ background, border, card, notification, primary, text }),
|
|
74
|
+
dark,
|
|
75
|
+
}),
|
|
76
|
+
[dark, background, border, card, notification, primary, text]
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useCSSVariable } from "uniwind";
|
|
2
|
+
import { isLiteralColor, themeVariableName } from "../lib/color";
|
|
3
|
+
|
|
4
|
+
/** Defined by every theme, so looking it up never misses and never warns. */
|
|
5
|
+
const PROBE_VARIABLE = "--foreground";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reads a theme colour as a plain string for props that take a colour value
|
|
9
|
+
* rather than a style — icon `color`, gradient stops, chart series, and any
|
|
10
|
+
* third-party component that will not accept a className.
|
|
11
|
+
*
|
|
12
|
+
* Resolves against the nearest `ScopedTheme`, so it follows light/dark and any
|
|
13
|
+
* custom theme automatically. Prefer a `bg-*`/`text-*` className wherever the
|
|
14
|
+
* target actually accepts one.
|
|
15
|
+
*
|
|
16
|
+
* A literal colour — `#EC4899`, `rgb(...)` — passes straight through without
|
|
17
|
+
* being looked up, so a caller can hand one to any prop typed as a token
|
|
18
|
+
* without special-casing it.
|
|
19
|
+
*
|
|
20
|
+
* A token resolves only if the active theme actually emits its CSS variable.
|
|
21
|
+
* That is the semantic palette in `styles/theme.css` — Tailwind's own palette
|
|
22
|
+
* names (`emerald-500`) are not emitted unless some utility class references
|
|
23
|
+
* them. An unresolved token yields `undefined` rather than a bogus value.
|
|
24
|
+
*
|
|
25
|
+
* The variable looked up is the RAW name — `--destructive`, not
|
|
26
|
+
* `--color-destructive`. `theme.css` maps the raw names onto the `--color-*`
|
|
27
|
+
* namespace through `@theme inline`, and `inline` means Tailwind substitutes
|
|
28
|
+
* the value into each utility and emits no `--color-*` variable at all. Those
|
|
29
|
+
* names do not exist at runtime; the raw ones are the only thing there. They
|
|
30
|
+
* are also the names a shadcn theme is written in, so `useThemeColor("chart-1")`
|
|
31
|
+
* works on a pasted palette with no support from this package.
|
|
32
|
+
*/
|
|
33
|
+
export function useThemeColor(token: string): string | undefined {
|
|
34
|
+
const isLiteral = isLiteralColor(token);
|
|
35
|
+
const name = themeVariableName(token);
|
|
36
|
+
|
|
37
|
+
// A literal still has to go through the hook — hook order cannot be
|
|
38
|
+
// conditional — but it must not be the thing looked up. Uniwind logs a
|
|
39
|
+
// warning on every miss, and `--#EC4899` misses on every single render.
|
|
40
|
+
// Probe a variable that is always defined instead, and discard it.
|
|
41
|
+
const resolved = useCSSVariable(isLiteral ? PROBE_VARIABLE : name) as string | undefined;
|
|
42
|
+
|
|
43
|
+
return isLiteral ? token : resolved;
|
|
44
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "@central-icons-react-native/round-outlined-radius-1-stroke-1.5";
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
BUTTON_RADIUS_TOKENS,
|
|
4
|
+
BUTTON_SIZE_TOKENS,
|
|
5
|
+
BUTTON_TEXT_TOKENS,
|
|
6
|
+
ICON_SIZE_TOKENS,
|
|
7
|
+
INPUT_SIZE_TOKENS,
|
|
8
|
+
INPUT_TEXT_TOKENS,
|
|
9
|
+
SCREEN_SIZE_TOKENS,
|
|
10
|
+
} from "../styles/tokens";
|
|
11
|
+
import { cn } from "./cn";
|
|
12
|
+
|
|
13
|
+
describe("cn", () => {
|
|
14
|
+
test("joins plain class strings", () => {
|
|
15
|
+
expect(cn("px-2", "py-1")).toBe("px-2 py-1");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("drops falsy values", () => {
|
|
19
|
+
expect(cn("px-2", false, null, undefined, "", "py-1")).toBe("px-2 py-1");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("resolves conditional objects and arrays", () => {
|
|
23
|
+
expect(cn(["px-2", { "py-1": true, "py-4": false }])).toBe("px-2 py-1");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("last conflicting utility wins", () => {
|
|
27
|
+
expect(cn("p-2", "p-6")).toBe("p-6");
|
|
28
|
+
expect(cn("bg-primary", "bg-destructive")).toBe("bg-destructive");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("keeps non-conflicting utilities from the same group", () => {
|
|
32
|
+
expect(cn("px-2", "py-4")).toBe("px-2 py-4");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("an incoming className overrides the base", () => {
|
|
36
|
+
expect(cn("rounded-lg bg-primary", "bg-secondary")).toBe("rounded-lg bg-secondary");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("returns an empty string for no input", () => {
|
|
40
|
+
expect(cn()).toBe("");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Without these, tailwind-merge treats `h-button-md` as an unrecognised
|
|
45
|
+
// utility and keeps it alongside a caller's `h-12`. Both would apply and the
|
|
46
|
+
// winner would be whichever uniwind resolved last — the exact failure `cn`
|
|
47
|
+
// exists to prevent, and an invisible one, since nothing errors.
|
|
48
|
+
// A side corner survives an all-corner class emitted BEFORE it, and is
|
|
49
|
+
// annihilated by one emitted after. `Button.Group` rests on that asymmetry: it
|
|
50
|
+
// is why a joined member replaces its corner with a per-side pair rather than
|
|
51
|
+
// layering a squaring class over the `rounded-button-*` the size axis would
|
|
52
|
+
// otherwise leave behind. That class would delete the pair silently, with
|
|
53
|
+
// nothing to see in a diff.
|
|
54
|
+
describe("cn with a side corner", () => {
|
|
55
|
+
test("keeps a side class set after an all-corner one", () => {
|
|
56
|
+
for (const token of BUTTON_RADIUS_TOKENS) {
|
|
57
|
+
expect(cn(`rounded-${token}`, "rounded-e-none")).toBe(`rounded-${token} rounded-e-none`);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("drops a side class set before an all-corner one", () => {
|
|
62
|
+
for (const token of BUTTON_RADIUS_TOKENS) {
|
|
63
|
+
expect(cn("rounded-e-none", `rounded-${token}`)).toBe(`rounded-${token}`);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("lets a caller's all-corner class beat a whole per-side pair", () => {
|
|
68
|
+
for (const token of BUTTON_RADIUS_TOKENS) {
|
|
69
|
+
expect(cn(`rounded-s-${token} rounded-e-none`, "rounded-lg")).toBe("rounded-lg");
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("cn with the semantic size tokens", () => {
|
|
75
|
+
test("a caller's plain utility replaces a token one", () => {
|
|
76
|
+
for (const token of BUTTON_SIZE_TOKENS) {
|
|
77
|
+
expect(cn(`h-${token}`, "h-12")).toBe("h-12");
|
|
78
|
+
expect(cn(`w-${token}`, "w-12")).toBe("w-12");
|
|
79
|
+
}
|
|
80
|
+
for (const token of ICON_SIZE_TOKENS) {
|
|
81
|
+
expect(cn(`size-${token}`, "size-6")).toBe("size-6");
|
|
82
|
+
}
|
|
83
|
+
for (const token of BUTTON_TEXT_TOKENS) {
|
|
84
|
+
expect(cn(`text-${token}`, "text-lg")).toBe("text-lg");
|
|
85
|
+
}
|
|
86
|
+
// The button's corner is a token like its height, so a caller who wants
|
|
87
|
+
// a squarer button writes `rounded-lg` and gets it.
|
|
88
|
+
for (const token of BUTTON_RADIUS_TOKENS) {
|
|
89
|
+
expect(cn(`rounded-${token}`, "rounded-lg")).toBe("rounded-lg");
|
|
90
|
+
expect(cn(`rounded-${token}`, "rounded-full")).toBe("rounded-full");
|
|
91
|
+
}
|
|
92
|
+
// A field's height is read on two axes — fixed for a single line,
|
|
93
|
+
// a floor for a multiline one — so `min-h` has to be recognised too.
|
|
94
|
+
for (const token of INPUT_SIZE_TOKENS) {
|
|
95
|
+
expect(cn(`h-${token}`, "h-12")).toBe("h-12");
|
|
96
|
+
expect(cn(`min-h-${token}`, "min-h-12")).toBe("min-h-12");
|
|
97
|
+
}
|
|
98
|
+
for (const token of INPUT_TEXT_TOKENS) {
|
|
99
|
+
expect(cn(`text-${token}`, "text-lg")).toBe("text-lg");
|
|
100
|
+
}
|
|
101
|
+
// The screen tokens are read on two different axes — `h-navbar-row` sizes
|
|
102
|
+
// the navbar's row, `px-screen-gutter` its horizontal padding — so both
|
|
103
|
+
// have to be recognised, not just the one each token was named for.
|
|
104
|
+
for (const token of SCREEN_SIZE_TOKENS) {
|
|
105
|
+
expect(cn(`h-${token}`, "h-12")).toBe("h-12");
|
|
106
|
+
expect(cn(`px-${token}`, "px-8")).toBe("px-8");
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a token utility replaces a plain one", () => {
|
|
111
|
+
expect(cn("h-12", "h-button-sm")).toBe("h-button-sm");
|
|
112
|
+
expect(cn("size-6", "size-icon-xs")).toBe("size-icon-xs");
|
|
113
|
+
expect(cn("text-lg", "text-button-sm")).toBe("text-button-sm");
|
|
114
|
+
expect(cn("px-8", "px-screen-gutter")).toBe("px-screen-gutter");
|
|
115
|
+
expect(cn("min-h-12", "min-h-input-lg")).toBe("min-h-input-lg");
|
|
116
|
+
expect(cn("rounded-lg", "rounded-button-md")).toBe("rounded-button-md");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("one token replaces another on the same axis", () => {
|
|
120
|
+
expect(cn("size-icon-sm", "size-icon-2xl")).toBe("size-icon-2xl");
|
|
121
|
+
expect(cn("h-button-sm", "h-button-lg")).toBe("h-button-lg");
|
|
122
|
+
expect(cn("h-input-sm", "h-input-lg")).toBe("h-input-lg");
|
|
123
|
+
expect(cn("text-input-sm", "text-input-lg")).toBe("text-input-lg");
|
|
124
|
+
expect(cn("rounded-button-sm", "rounded-button-lg")).toBe("rounded-button-lg");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("tokens on different axes both survive", () => {
|
|
128
|
+
expect(cn("h-button-md", "w-button-md")).toBe("h-button-md w-button-md");
|
|
129
|
+
expect(cn("h-button-md", "rounded-button-md")).toBe("h-button-md rounded-button-md");
|
|
130
|
+
expect(cn("size-icon-md", "text-button-md")).toBe("size-icon-md text-button-md");
|
|
131
|
+
// `--input` and `--text-input-md` are different namespaces that
|
|
132
|
+
// happen to share a word. A field sets both at once, so a merger that
|
|
133
|
+
// judged them to conflict would silently strip one.
|
|
134
|
+
expect(cn("border-input", "text-input-md")).toBe("border-input text-input-md");
|
|
135
|
+
});
|
|
136
|
+
});
|
package/src/lib/cn.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ClassValue, clsx } from "clsx";
|
|
2
|
+
import { extendTailwindMerge } from "tailwind-merge";
|
|
3
|
+
import { TW_MERGE_CONFIG } from "../styles/tokens";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* tailwind-merge, taught the semantic size tokens from `tokens.css`.
|
|
7
|
+
*
|
|
8
|
+
* Registering them is load-bearing rather than tidiness. tailwind-merge only
|
|
9
|
+
* treats two classes as conflicting when it recognises both as members of the
|
|
10
|
+
* same group, and `button-md` is not a value it knows. Left unregistered,
|
|
11
|
+
* `cn("h-button-md", "h-12")` returns *both* classes: they each resolve to a
|
|
12
|
+
* height, uniwind applies whichever it saw last, and a caller's override works
|
|
13
|
+
* or does not depending on class order. Nothing throws, so the failure is
|
|
14
|
+
* silent — which is why `cn.test.ts` asserts every token here.
|
|
15
|
+
*
|
|
16
|
+
* `tv()` needs the same treatment for its own merger — see `lib/tv.ts`.
|
|
17
|
+
*/
|
|
18
|
+
const twMerge = extendTailwindMerge(TW_MERGE_CONFIG);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Merges class names and resolves Tailwind conflicts so the last utility wins.
|
|
22
|
+
*
|
|
23
|
+
* Uniwind does not deduplicate conflicting classes on its own — both would be
|
|
24
|
+
* applied and the winner would be undefined. Always route a caller-supplied
|
|
25
|
+
* `className` through this before handing it to a component.
|
|
26
|
+
*/
|
|
27
|
+
export function cn(...inputs: ClassValue[]): string {
|
|
28
|
+
return twMerge(clsx(inputs));
|
|
29
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { isLiteralColor, themeVariableName, transparentOf } from "./color";
|
|
3
|
+
|
|
4
|
+
describe("isLiteralColor", () => {
|
|
5
|
+
test("treats hex as a literal", () => {
|
|
6
|
+
expect(isLiteralColor("#EC4899")).toBe(true);
|
|
7
|
+
expect(isLiteralColor("#fff")).toBe(true);
|
|
8
|
+
expect(isLiteralColor("#00000080")).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("treats functional notation as a literal", () => {
|
|
12
|
+
expect(isLiteralColor("rgb(236, 72, 153)")).toBe(true);
|
|
13
|
+
expect(isLiteralColor("rgba(0, 0, 0, 0.5)")).toBe(true);
|
|
14
|
+
expect(isLiteralColor("hsl(330 81% 60%)")).toBe(true);
|
|
15
|
+
expect(isLiteralColor("oklch(0.7 0.2 20)")).toBe(true);
|
|
16
|
+
expect(isLiteralColor("color-mix(in oklch, red, blue)")).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("treats a theme token name as a token", () => {
|
|
20
|
+
expect(isLiteralColor("foreground")).toBe(false);
|
|
21
|
+
expect(isLiteralColor("primary-foreground")).toBe(false);
|
|
22
|
+
expect(isLiteralColor("destructive-soft-foreground")).toBe(false);
|
|
23
|
+
expect(isLiteralColor("emerald-500")).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("treats a raw CSS variable name as a token", () => {
|
|
27
|
+
expect(isLiteralColor("--color-foreground")).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("does not mistake an empty string for a literal", () => {
|
|
31
|
+
expect(isLiteralColor("")).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("themeVariableName", () => {
|
|
36
|
+
test("prefixes a bare token name", () => {
|
|
37
|
+
expect(themeVariableName("foreground")).toBe("--foreground");
|
|
38
|
+
expect(themeVariableName("destructive-soft-foreground")).toBe("--destructive-soft-foreground");
|
|
39
|
+
expect(themeVariableName("chart-1")).toBe("--chart-1");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("passes a raw variable name through", () => {
|
|
43
|
+
expect(themeVariableName("--foreground")).toBe("--foreground");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// `theme.css` maps the raw names onto `--color-*` through `@theme inline`,
|
|
47
|
+
// and `inline` emits no `--color-*` variable at all — so the prefixed form
|
|
48
|
+
// a caller may have written before would miss on every render.
|
|
49
|
+
test("rewrites the pre-shadcn --color- form onto the raw name", () => {
|
|
50
|
+
expect(themeVariableName("--color-foreground")).toBe("--foreground");
|
|
51
|
+
expect(themeVariableName("--color-destructive-soft")).toBe("--destructive-soft");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("transparentOf", () => {
|
|
56
|
+
test("keeps the hue and drops the alpha, so a fade never runs through black", () => {
|
|
57
|
+
expect(transparentOf("#0a0a0a")).toBe("#0a0a0a00");
|
|
58
|
+
expect(transparentOf("#FFFFFF")).toBe("#FFFFFF00");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("expands a short hex, which cannot simply take a suffix", () => {
|
|
62
|
+
expect(transparentOf("#abc")).toBe("#aabbcc00");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("replaces an alpha the colour already carries rather than appending a second", () => {
|
|
66
|
+
expect(transparentOf("#0a0a0aff")).toBe("#0a0a0a00");
|
|
67
|
+
expect(transparentOf("#abcd")).toBe("#aabbcc00");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("rewrites functional notation in place", () => {
|
|
71
|
+
expect(transparentOf("rgb(10, 20, 30)")).toBe("rgba(10, 20, 30, 0)");
|
|
72
|
+
expect(transparentOf("rgba(10, 20, 30, 0.5)")).toBe("rgba(10, 20, 30, 0)");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("gives up rather than guessing, so a caller can decline to draw", () => {
|
|
76
|
+
expect(transparentOf(undefined)).toBeUndefined();
|
|
77
|
+
expect(transparentOf("rebeccapurple")).toBeUndefined();
|
|
78
|
+
expect(transparentOf("oklch(0.5 0.1 200)")).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
});
|
package/src/lib/color.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const FUNCTIONAL_COLOR = /^[a-z][a-z-]*\(/i;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* True when a string is a literal CSS colour rather than a theme token name.
|
|
5
|
+
*
|
|
6
|
+
* Theme tokens (`foreground`, `destructive-soft-foreground`, `emerald-500`) are
|
|
7
|
+
* resolved through a CSS variable; literals are handed to the platform as-is.
|
|
8
|
+
* The two are told apart by shape: a literal is either hex or a functional
|
|
9
|
+
* notation, and no token name can look like either.
|
|
10
|
+
*/
|
|
11
|
+
export function isLiteralColor(value: string): boolean {
|
|
12
|
+
return value.startsWith("#") || FUNCTIONAL_COLOR.test(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The `--color-` prefix this package used before it adopted shadcn's names. */
|
|
16
|
+
const LEGACY_PREFIX = "--color-";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The CSS variable a token name resolves to.
|
|
20
|
+
*
|
|
21
|
+
* The raw name, not the `--color-*` one: `theme.css` maps raw onto `--color-*`
|
|
22
|
+
* through `@theme inline`, and `inline` means Tailwind substitutes the value
|
|
23
|
+
* into each utility and emits no `--color-*` variable at all. Only the raw
|
|
24
|
+
* names exist at runtime.
|
|
25
|
+
*
|
|
26
|
+
* A `--color-*` argument is rewritten rather than rejected — it is what a
|
|
27
|
+
* caller wrote before this package took shadcn's names, and left alone it would
|
|
28
|
+
* miss silently on every render.
|
|
29
|
+
*
|
|
30
|
+
* Pure, and separate from `useThemeColor` so it is reachable from `bun test` —
|
|
31
|
+
* the hook imports `uniwind`, which Bun's transpiler cannot parse. See AGENTS.md.
|
|
32
|
+
*/
|
|
33
|
+
export function themeVariableName(token: string): string {
|
|
34
|
+
if (token.startsWith(LEGACY_PREFIX)) return `--${token.slice(LEGACY_PREFIX.length)}`;
|
|
35
|
+
if (token.startsWith("--")) return token;
|
|
36
|
+
|
|
37
|
+
return `--${token}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const HEX = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
41
|
+
const RGB = /^rgba?\(\s*([^,]+),\s*([^,]+),\s*([^,)]+)/i;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The same colour at zero alpha, for the far end of a fade.
|
|
45
|
+
*
|
|
46
|
+
* A gradient must never fade to the keyword `transparent`: that is transparent
|
|
47
|
+
* BLACK, and interpolating toward it drags every stop between through grey — a
|
|
48
|
+
* dark bloom over a light ground, a milky one over a dark. The far stop has to
|
|
49
|
+
* be the near colour with its alpha taken off, which is what this returns.
|
|
50
|
+
*
|
|
51
|
+
* `undefined` for anything it cannot take apart, which a caller should read as
|
|
52
|
+
* "do not draw" rather than reaching for the keyword after all. A colour that
|
|
53
|
+
* arrives here in a notation this does not know is a colour whose channels are
|
|
54
|
+
* not knowable without a parser, and a fade is not worth one.
|
|
55
|
+
*/
|
|
56
|
+
export function transparentOf(color: string | undefined): string | undefined {
|
|
57
|
+
if (!color) return undefined;
|
|
58
|
+
|
|
59
|
+
if (HEX.test(color)) {
|
|
60
|
+
const digits = color.slice(1);
|
|
61
|
+
// A short hex cannot take a suffix: `#abc` + `00` is a four-digit hex,
|
|
62
|
+
// which is `#aabbccdd` shorthand and not the colour at all.
|
|
63
|
+
const rgb =
|
|
64
|
+
digits.length <= 4
|
|
65
|
+
? digits
|
|
66
|
+
.slice(0, 3)
|
|
67
|
+
.split("")
|
|
68
|
+
.map((digit) => digit + digit)
|
|
69
|
+
.join("")
|
|
70
|
+
: digits.slice(0, 6);
|
|
71
|
+
|
|
72
|
+
return `#${rgb}00`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const channels = RGB.exec(color);
|
|
76
|
+
if (channels) return `rgba(${channels[1]?.trim()}, ${channels[2]?.trim()}, ${channels[3]?.trim()}, 0)`;
|
|
77
|
+
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { composeRefs } from "./compose-refs";
|
|
3
|
+
|
|
4
|
+
describe("composeRefs", () => {
|
|
5
|
+
test("assigns to every object ref", () => {
|
|
6
|
+
const a: { current: string | null } = { current: null };
|
|
7
|
+
const b: { current: string | null } = { current: null };
|
|
8
|
+
composeRefs(a, b)("node");
|
|
9
|
+
expect(a.current).toBe("node");
|
|
10
|
+
expect(b.current).toBe("node");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("calls every callback ref", () => {
|
|
14
|
+
const seen: string[] = [];
|
|
15
|
+
composeRefs<string>(
|
|
16
|
+
(n) => {
|
|
17
|
+
seen.push(`a:${n}`);
|
|
18
|
+
},
|
|
19
|
+
(n) => {
|
|
20
|
+
seen.push(`b:${n}`);
|
|
21
|
+
}
|
|
22
|
+
)("node");
|
|
23
|
+
expect(seen).toEqual(["a:node", "b:node"]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("mixes callback and object refs", () => {
|
|
27
|
+
const obj: { current: string | null } = { current: null };
|
|
28
|
+
const called: string[] = [];
|
|
29
|
+
composeRefs<string>(obj, (n) => {
|
|
30
|
+
if (n) called.push(n);
|
|
31
|
+
})("node");
|
|
32
|
+
expect(obj.current).toBe("node");
|
|
33
|
+
expect(called).toEqual(["node"]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("skips null and undefined refs", () => {
|
|
37
|
+
const obj: { current: string | null } = { current: null };
|
|
38
|
+
expect(() => composeRefs<string>(null, undefined, obj)("node")).not.toThrow();
|
|
39
|
+
expect(obj.current).toBe("node");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("runs cleanups returned by callback refs on unmount", () => {
|
|
43
|
+
const order: string[] = [];
|
|
44
|
+
const cleanup = composeRefs<string>(
|
|
45
|
+
() => () => {
|
|
46
|
+
order.push("cleanup-a");
|
|
47
|
+
},
|
|
48
|
+
() => () => {
|
|
49
|
+
order.push("cleanup-b");
|
|
50
|
+
}
|
|
51
|
+
)("node");
|
|
52
|
+
expect(typeof cleanup).toBe("function");
|
|
53
|
+
cleanup?.();
|
|
54
|
+
expect(order).toEqual(["cleanup-a", "cleanup-b"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("clears object refs on unmount when a cleanup runs", () => {
|
|
58
|
+
const obj: { current: string | null } = { current: null };
|
|
59
|
+
const cleanup = composeRefs<string>(obj, () => () => {})("node");
|
|
60
|
+
expect(obj.current).toBe("node");
|
|
61
|
+
cleanup?.();
|
|
62
|
+
expect(obj.current).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
});
|