@delacour/react-native-ui 0.1.0-alpha.3
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 +192 -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 +435 -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 +232 -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 +245 -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 +148 -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 +95 -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 +276 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.ts +199 -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 +100 -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 +33 -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 +78 -0
- package/src/components/field/field.tsx +144 -0
- package/src/components/field/field.types.ts +15 -0
- package/src/components/field/field.variants.test.ts +216 -0
- package/src/components/field/field.variants.ts +136 -0
- package/src/components/field/index.ts +21 -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 +64 -0
- package/src/components/pressable/index.ts +9 -0
- package/src/components/pressable/pressable.tsx +256 -0
- package/src/components/pressable/pressable.variants.test.ts +99 -0
- package/src/components/pressable/pressable.variants.ts +63 -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 +247 -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 +581 -0
- package/src/components/radio/radio.variants.ts +272 -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 +48 -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 +161 -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 +804 -0
- package/src/components/slider/slider.variants.ts +607 -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 +286 -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 +672 -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,37 @@
|
|
|
1
|
+
import type { Ref } from "react";
|
|
2
|
+
|
|
3
|
+
type RefCleanup = () => void;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fans a single node out to several refs.
|
|
7
|
+
*
|
|
8
|
+
* React 19 lets a callback ref return a cleanup function. When any of the given
|
|
9
|
+
* refs does, the composed ref returns a cleanup of its own that runs each child
|
|
10
|
+
* cleanup and nulls out every object ref.
|
|
11
|
+
*/
|
|
12
|
+
export function composeRefs<T>(...refs: (Ref<T> | undefined)[]): (node: T | null) => RefCleanup | undefined {
|
|
13
|
+
return (node) => {
|
|
14
|
+
const cleanups: RefCleanup[] = [];
|
|
15
|
+
|
|
16
|
+
for (const ref of refs) {
|
|
17
|
+
if (!ref) continue;
|
|
18
|
+
|
|
19
|
+
if (typeof ref === "function") {
|
|
20
|
+
const result = ref(node);
|
|
21
|
+
if (typeof result === "function") cleanups.push(result);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
ref.current = node;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (cleanups.length === 0) return undefined;
|
|
29
|
+
|
|
30
|
+
return () => {
|
|
31
|
+
for (const cleanup of cleanups) cleanup();
|
|
32
|
+
for (const ref of refs) {
|
|
33
|
+
if (ref && typeof ref !== "function") ref.current = null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { shouldResetKeyboardAnimation } from "./keyboard-animation";
|
|
3
|
+
|
|
4
|
+
describe("shouldResetKeyboardAnimation", () => {
|
|
5
|
+
test("leaves a genuinely focused keyboard alone", () => {
|
|
6
|
+
// The guard must never fight a real keyboard. Every open-looking state is
|
|
7
|
+
// legitimate while an input holds focus, including a mid-animation one.
|
|
8
|
+
for (const [progress, height] of [
|
|
9
|
+
[0, 0],
|
|
10
|
+
[0.5, -160],
|
|
11
|
+
[1, -336],
|
|
12
|
+
] as const) {
|
|
13
|
+
expect(shouldResetKeyboardAnimation({ hasFocusedInput: true, height, progress })).toBe(false);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("repairs an open state with nothing focused", () => {
|
|
18
|
+
expect(shouldResetKeyboardAnimation({ hasFocusedInput: false, height: -336, progress: 1 })).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("repairs a half-open state, which is what an interrupted dismiss leaves behind", () => {
|
|
22
|
+
expect(shouldResetKeyboardAnimation({ hasFocusedInput: false, height: -160, progress: 0.5 })).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("repairs either value on its own — the two are written separately", () => {
|
|
26
|
+
expect(shouldResetKeyboardAnimation({ hasFocusedInput: false, height: 0, progress: 1 })).toBe(true);
|
|
27
|
+
expect(shouldResetKeyboardAnimation({ hasFocusedInput: false, height: -336, progress: 0 })).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("is a no-op on an already-closed state, so mounting never schedules a pointless write", () => {
|
|
31
|
+
expect(shouldResetKeyboardAnimation({ hasFocusedInput: false, height: 0, progress: 0 })).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether `KeyboardProvider`'s shared animation values are provably stale.
|
|
3
|
+
*
|
|
4
|
+
* Lives here, free of React Native imports, so the decision is reachable from
|
|
5
|
+
* `bun test` — `hooks/use-keyboard-state-sync` cannot be, since Bun's
|
|
6
|
+
* transpiler cannot parse React Native's Flow-typed source. See AGENTS.md.
|
|
7
|
+
*
|
|
8
|
+
* `hasFocusedInput` MUST come from React Native's own focus registry
|
|
9
|
+
* (`TextInput.State.currentlyFocusedInput()`), never from
|
|
10
|
+
* `KeyboardController.isVisible()`. The latter is fed by the very
|
|
11
|
+
* `keyboardWillShow` / `keyboardDidHide` notifications that go missing in the
|
|
12
|
+
* failure this guard exists for, so in the broken case it reads a stale `true`
|
|
13
|
+
* and would veto the repair. RN's registry is driven by the input's own native
|
|
14
|
+
* focus and blur — a genuinely independent signal.
|
|
15
|
+
*/
|
|
16
|
+
export function shouldResetKeyboardAnimation(state: {
|
|
17
|
+
/** `KeyboardProvider`'s shared progress, 0 closed through 1 open. */
|
|
18
|
+
progress: number;
|
|
19
|
+
/** `KeyboardProvider`'s shared height, negative while open. */
|
|
20
|
+
height: number;
|
|
21
|
+
/** Whether React Native's focus registry holds a focused `TextInput`. */
|
|
22
|
+
hasFocusedInput: boolean;
|
|
23
|
+
}): boolean {
|
|
24
|
+
"worklet";
|
|
25
|
+
// Never fight a real, focused keyboard.
|
|
26
|
+
if (state.hasFocusedInput) return false;
|
|
27
|
+
return state.progress !== 0 || state.height !== 0;
|
|
28
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mergeProps } from "./merge-props";
|
|
3
|
+
|
|
4
|
+
describe("mergeProps", () => {
|
|
5
|
+
test("child props override slot props", () => {
|
|
6
|
+
expect(mergeProps({ testID: "slot" }, { testID: "child" })).toMatchObject({ testID: "child" });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("slot props survive when the child omits them", () => {
|
|
10
|
+
expect(mergeProps({ accessibilityRole: "button" }, {})).toMatchObject({ accessibilityRole: "button" });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("chains event handlers slot-first", () => {
|
|
14
|
+
const order: string[] = [];
|
|
15
|
+
const merged = mergeProps<{ onPress?: (v: string) => void }>(
|
|
16
|
+
{
|
|
17
|
+
onPress: (v: string) => {
|
|
18
|
+
order.push(`slot:${v}`);
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
onPress: (v: string) => {
|
|
23
|
+
order.push(`child:${v}`);
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
);
|
|
27
|
+
merged.onPress?.("x");
|
|
28
|
+
expect(order).toEqual(["slot:x", "child:x"]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("keeps a lone handler from either side", () => {
|
|
32
|
+
let hit = false;
|
|
33
|
+
const fromSlot = mergeProps<{ onPress?: () => void }>(
|
|
34
|
+
{
|
|
35
|
+
onPress: () => {
|
|
36
|
+
hit = true;
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{}
|
|
40
|
+
);
|
|
41
|
+
fromSlot.onPress?.();
|
|
42
|
+
expect(hit).toBe(true);
|
|
43
|
+
|
|
44
|
+
hit = false;
|
|
45
|
+
const fromChild = mergeProps<{ onPress?: () => void }>(
|
|
46
|
+
{},
|
|
47
|
+
{
|
|
48
|
+
onPress: () => {
|
|
49
|
+
hit = true;
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
fromChild.onPress?.();
|
|
54
|
+
expect(hit).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("merges className through cn so the child wins conflicts", () => {
|
|
58
|
+
const merged = mergeProps({ className: "p-2 bg-primary" }, { className: "bg-secondary" });
|
|
59
|
+
expect(merged.className).toBe("p-2 bg-secondary");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("flattens style into an array with the child last", () => {
|
|
63
|
+
const merged = mergeProps({ style: { opacity: 1 } }, { style: { opacity: 0.5 } });
|
|
64
|
+
expect(merged.style).toEqual([{ opacity: 1 }, { opacity: 0.5 }]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("keeps a single style untouched", () => {
|
|
68
|
+
expect(mergeProps({ style: { flex: 1 } }, {}).style).toEqual({ flex: 1 });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("does not treat a non-function on* value as a handler", () => {
|
|
72
|
+
expect(mergeProps({ onPress: undefined }, { onPress: undefined }).onPress).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("never mutates its inputs", () => {
|
|
76
|
+
const slot = { className: "p-2", onPress: () => {} };
|
|
77
|
+
const child = { className: "p-4" };
|
|
78
|
+
mergeProps(slot, child);
|
|
79
|
+
expect(slot.className).toBe("p-2");
|
|
80
|
+
expect(child.className).toBe("p-4");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { cn } from "./cn";
|
|
2
|
+
|
|
3
|
+
type UnknownProps = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
function isEventHandlerKey(key: string): boolean {
|
|
6
|
+
return key.startsWith("on") && key.length > 2 && key[2] === key[2]?.toUpperCase();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Merges a slot's own props with those of the element it renders into.
|
|
11
|
+
*
|
|
12
|
+
* Child props win, with three exceptions: event handlers are chained
|
|
13
|
+
* slot-first-then-child so neither side is silently dropped, `className` is
|
|
14
|
+
* merged through {@link cn} so the child's utilities beat conflicting slot
|
|
15
|
+
* ones, and `style` is flattened into an array with the child last (React
|
|
16
|
+
* Native resolves style arrays left to right).
|
|
17
|
+
*/
|
|
18
|
+
export function mergeProps<T extends UnknownProps>(slotProps: UnknownProps, childProps: UnknownProps): T {
|
|
19
|
+
const merged: UnknownProps = { ...slotProps };
|
|
20
|
+
|
|
21
|
+
for (const key of Object.keys(childProps)) {
|
|
22
|
+
const slotValue = slotProps[key];
|
|
23
|
+
const childValue = childProps[key];
|
|
24
|
+
|
|
25
|
+
if (isEventHandlerKey(key) && typeof slotValue === "function" && typeof childValue === "function") {
|
|
26
|
+
merged[key] = (...args: unknown[]) => {
|
|
27
|
+
(slotValue as (...a: unknown[]) => unknown)(...args);
|
|
28
|
+
return (childValue as (...a: unknown[]) => unknown)(...args);
|
|
29
|
+
};
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (key === "className") {
|
|
34
|
+
merged[key] = cn(slotValue as string, childValue as string);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (key === "style" && slotValue != null && childValue != null) {
|
|
39
|
+
merged[key] = [slotValue, childValue].flat();
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
merged[key] = childValue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return merged as T;
|
|
47
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { declaredTokens } from "../styles/theme-tokens.test";
|
|
3
|
+
import { NAVIGATION_THEME_TOKENS } from "./navigation-theme";
|
|
4
|
+
|
|
5
|
+
const LIGHT = declaredTokens("light");
|
|
6
|
+
const DARK = declaredTokens("dark");
|
|
7
|
+
|
|
8
|
+
/** The slots React Navigation's theme actually has. Missing one falls back to its light default. */
|
|
9
|
+
const REACT_NAVIGATION_SLOTS = ["background", "card", "text", "border", "primary", "notification"] as const;
|
|
10
|
+
|
|
11
|
+
describe("the theme.css reader", () => {
|
|
12
|
+
// The assertions below are only worth anything if the parse found something.
|
|
13
|
+
test("finds both variants", () => {
|
|
14
|
+
expect(LIGHT.size).toBeGreaterThan(0);
|
|
15
|
+
expect(DARK.size).toBeGreaterThan(0);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("NAVIGATION_THEME_TOKENS", () => {
|
|
20
|
+
test("covers every slot React Navigation's theme has", () => {
|
|
21
|
+
expect(Object.keys(NAVIGATION_THEME_TOKENS).sort()).toEqual([...REACT_NAVIGATION_SLOTS].sort());
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// A slot pointing at a token no theme emits resolves to undefined, gets
|
|
25
|
+
// dropped, and silently leaves React Navigation's light default in place —
|
|
26
|
+
// which is the exact failure this whole hook exists to fix.
|
|
27
|
+
test("names only tokens the light theme declares", () => {
|
|
28
|
+
for (const token of Object.values(NAVIGATION_THEME_TOKENS)) {
|
|
29
|
+
expect(LIGHT.has(token)).toBe(true);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("names only tokens the dark theme declares", () => {
|
|
34
|
+
for (const token of Object.values(NAVIGATION_THEME_TOKENS)) {
|
|
35
|
+
expect(DARK.has(token)).toBe(true);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("maps the background slot to the token a screen paints itself with", () => {
|
|
40
|
+
// Screen's root is `bg-background`. If these two ever disagree, the
|
|
41
|
+
// container behind a transition stops matching the cards in front of it.
|
|
42
|
+
expect(NAVIGATION_THEME_TOKENS.background).toBe("background");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("gives every slot its own token, so no two slots collapse together", () => {
|
|
46
|
+
const tokens = Object.values(NAVIGATION_THEME_TOKENS);
|
|
47
|
+
expect(new Set(tokens).size).toBe(tokens.length);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Navigation's colour slots, mapped to this library's semantic tokens.
|
|
3
|
+
*
|
|
4
|
+
* Lives here, free of React Native imports, so the mapping is reachable from
|
|
5
|
+
* `bun test` — `hooks/use-navigation-theme` cannot be, since Bun's transpiler
|
|
6
|
+
* cannot parse React Native's Flow-typed source. See AGENTS.md.
|
|
7
|
+
*
|
|
8
|
+
* A slot pointing at a token no theme emits would resolve to `undefined`, be
|
|
9
|
+
* dropped, and silently leave React Navigation's own light default in place —
|
|
10
|
+
* which is the exact failure the hook exists to fix. The test asserts every
|
|
11
|
+
* token here is declared in both variants of `styles/theme.css`.
|
|
12
|
+
*/
|
|
13
|
+
export const NAVIGATION_THEME_TOKENS = {
|
|
14
|
+
background: "background",
|
|
15
|
+
card: "card",
|
|
16
|
+
text: "foreground",
|
|
17
|
+
border: "border",
|
|
18
|
+
primary: "primary",
|
|
19
|
+
notification: "destructive",
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
export type NavigationThemeSlot = keyof typeof NAVIGATION_THEME_TOKENS;
|
|
23
|
+
|
|
24
|
+
/** The resolved colours, and whether the active theme is a dark one. */
|
|
25
|
+
export type NavigationTheme = {
|
|
26
|
+
dark: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Resolved colours by slot. A slot the active theme does not emit is
|
|
29
|
+
* ABSENT rather than `undefined`, so spreading this over a base theme
|
|
30
|
+
* leaves that slot's default in place instead of punching a hole in it.
|
|
31
|
+
*/
|
|
32
|
+
colors: Partial<Record<NavigationThemeSlot, string>>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Drops the slots that resolved to nothing.
|
|
37
|
+
*
|
|
38
|
+
* The result is spread over a base theme, and `{ ...base, background: undefined }`
|
|
39
|
+
* overwrites the default with `undefined` rather than leaving it — a navigator
|
|
40
|
+
* with no background at all, which reads as a transparent hole rather than as
|
|
41
|
+
* a missing token.
|
|
42
|
+
*/
|
|
43
|
+
export function omitUnresolvedColors(
|
|
44
|
+
colors: Record<NavigationThemeSlot, string | undefined>
|
|
45
|
+
): Partial<Record<NavigationThemeSlot, string>> {
|
|
46
|
+
const resolved: Partial<Record<NavigationThemeSlot, string>> = {};
|
|
47
|
+
|
|
48
|
+
for (const [slot, value] of Object.entries(colors) as [NavigationThemeSlot, string | undefined][]) {
|
|
49
|
+
if (value !== undefined) resolved[slot] = value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return resolved;
|
|
53
|
+
}
|
package/src/lib/slot.tsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Children, cloneElement, isValidElement, type ReactElement, type ReactNode, type Ref } from "react";
|
|
2
|
+
import { composeRefs } from "./compose-refs";
|
|
3
|
+
import { mergeProps } from "./merge-props";
|
|
4
|
+
|
|
5
|
+
type SlotProps = {
|
|
6
|
+
children?: ReactNode;
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type ChildProps = Record<string, unknown> & { ref?: Ref<unknown> };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Renders its props into its single child instead of emitting an element.
|
|
14
|
+
*
|
|
15
|
+
* This is what backs the `asChild` prop: `<Pressable asChild><Button /></Pressable>`
|
|
16
|
+
* gives the Button the Pressable's behaviour with no extra View in the tree.
|
|
17
|
+
* Props are combined by {@link mergeProps} and refs by {@link composeRefs}.
|
|
18
|
+
*
|
|
19
|
+
* React 19 exposes `ref` as an ordinary prop, so no `forwardRef` is involved.
|
|
20
|
+
*
|
|
21
|
+
* Do not pass a Reanimated style through this. React Native deep freezes style
|
|
22
|
+
* props on a non-animated component in development, and Reanimated's effect
|
|
23
|
+
* then throws trying to write to the frozen object. `Pressable` handles that
|
|
24
|
+
* case by rendering the child through an animated counterpart of its own type.
|
|
25
|
+
*/
|
|
26
|
+
export function Slot({ children, ...slotProps }: SlotProps): ReactElement {
|
|
27
|
+
const count = Children.count(children);
|
|
28
|
+
|
|
29
|
+
if (count !== 1) {
|
|
30
|
+
throw new Error(`Slot expects exactly one child element, received ${count}. Did you mean to omit \`asChild\`?`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const child = Children.only(children);
|
|
34
|
+
|
|
35
|
+
if (!isValidElement(child)) {
|
|
36
|
+
throw new Error("Slot expects a single React element child; text and fragments cannot receive props.");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const childProps = child.props as ChildProps;
|
|
40
|
+
const merged = mergeProps<ChildProps>(slotProps, childProps);
|
|
41
|
+
merged.ref = composeRefs(slotProps.ref as Ref<unknown> | undefined, childProps.ref);
|
|
42
|
+
|
|
43
|
+
return cloneElement(child, merged);
|
|
44
|
+
}
|
|
45
|
+
Slot.displayName = "DelacourUI.Slot";
|
package/src/lib/tv.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createTV } from "tailwind-variants";
|
|
2
|
+
import { TW_MERGE_CONFIG } from "../styles/tokens";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `tailwind-variants`, taught the semantic size tokens from `tokens.css`.
|
|
6
|
+
*
|
|
7
|
+
* Import `tv` from here in every `*.variants.ts` — never from
|
|
8
|
+
* `tailwind-variants` directly. `tv()` merges its own slots and variants
|
|
9
|
+
* through a tailwind-merge instance it builds itself, entirely separate from
|
|
10
|
+
* the one behind `cn()`, and a bare `tv` does not know what `button-md` is.
|
|
11
|
+
*
|
|
12
|
+
* That is not a cosmetic gap. An unrecognised `text-button-md` lands in
|
|
13
|
+
* tailwind-merge's text *colour* group alongside `text-primary-foreground`, the
|
|
14
|
+
* two are judged to conflict, and the label loses its colour — no error, just a
|
|
15
|
+
* button whose text quietly renders in the wrong shade.
|
|
16
|
+
*
|
|
17
|
+
* Free of React Native imports so variants files stay unit-testable. See
|
|
18
|
+
* AGENTS.md.
|
|
19
|
+
*/
|
|
20
|
+
export const tv = createTV({ twMergeConfig: TW_MERGE_CONFIG });
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const TOKENS_CSS = readFileSync(join(import.meta.dirname, "tokens.css"), "utf-8");
|
|
6
|
+
const THEME_CSS = readFileSync(join(import.meta.dirname, "theme.css"), "utf-8");
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Every `--name: value` declaration inside one `@variant` block of `theme.css`.
|
|
10
|
+
*
|
|
11
|
+
* Source text, not an import: these are CSS custom properties and no renderer
|
|
12
|
+
* is involved, so reading the file is the only way to assert against them.
|
|
13
|
+
* `styles/tokens.test.ts` and `styles/theme-tokens.test.ts` do the same.
|
|
14
|
+
*/
|
|
15
|
+
function variantDeclarations(variant: string): Map<string, string> {
|
|
16
|
+
const block = THEME_CSS.split(`@variant ${variant} {`)[1]?.split("}")[0] ?? "";
|
|
17
|
+
const found = new Map<string, string>();
|
|
18
|
+
|
|
19
|
+
for (const [, name, value] of block.matchAll(/(--[\w-]+):\s*([^;]+);/g)) {
|
|
20
|
+
found.set(name as string, (value as string).trim());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return found;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The same, from `tokens.css`'s first `@theme` block — the copy `apps/web` reads. */
|
|
27
|
+
function themeDeclarations(): Map<string, string> {
|
|
28
|
+
const block = TOKENS_CSS.split("@theme {")[1]?.split("\n}")[0] ?? "";
|
|
29
|
+
const found = new Map<string, string>();
|
|
30
|
+
|
|
31
|
+
for (const [, name, value] of block.matchAll(/(--[\w-]+):\s*([^;]+);/g)) {
|
|
32
|
+
found.set(name as string, (value as string).trim());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return found;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const NATIVE = variantDeclarations("native");
|
|
39
|
+
const TOKENS = themeDeclarations();
|
|
40
|
+
|
|
41
|
+
describe("the CSS readers", () => {
|
|
42
|
+
test("find both blocks", () => {
|
|
43
|
+
expect(NATIVE.size).toBeGreaterThan(0);
|
|
44
|
+
expect(TOKENS.size).toBeGreaterThan(0);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The geometry is declared twice, and the two copies must agree.
|
|
50
|
+
*
|
|
51
|
+
* `tokens.css` is the copy `apps/web` reads — the docs site imports only that
|
|
52
|
+
* file. `theme.css`'s `@variant native` block is the copy React Native reads,
|
|
53
|
+
* and it exists because Uniwind inlines a plain `@theme` declaration at build
|
|
54
|
+
* time: without it every one of these numbers would be frozen into the bundle
|
|
55
|
+
* and the playground's Style and Radius axes would silently do nothing.
|
|
56
|
+
*
|
|
57
|
+
* Two hand-maintained copies of one number always drift. This is what stops it.
|
|
58
|
+
*/
|
|
59
|
+
describe("the geometry block", () => {
|
|
60
|
+
test("restates every number tokens.css declares, with the same value", () => {
|
|
61
|
+
for (const [name, value] of NATIVE) {
|
|
62
|
+
expect(TOKENS.get(name), `tokens.css declares no ${name}`).toBeDefined();
|
|
63
|
+
expect(TOKENS.get(name), `${name} disagrees between tokens.css and theme.css`).toBe(value);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A token left out of the native block is not a build error — it is a
|
|
69
|
+
* control in the playground's customizer that moves nothing, which is the
|
|
70
|
+
* kind of gap only a person notices, late.
|
|
71
|
+
*/
|
|
72
|
+
test("covers every scale a customizer can retune", () => {
|
|
73
|
+
const required = [
|
|
74
|
+
"--radius",
|
|
75
|
+
...(["sm", "md", "lg"] as const).flatMap((step) => [
|
|
76
|
+
`--spacing-button-${step}`,
|
|
77
|
+
`--text-button-${step}`,
|
|
78
|
+
`--radius-button-${step}`,
|
|
79
|
+
`--spacing-input-${step}`,
|
|
80
|
+
`--text-input-${step}`,
|
|
81
|
+
]),
|
|
82
|
+
...(["xs", "sm", "md", "lg", "xl", "2xl"] as const).map((step) => `--spacing-icon-${step}`),
|
|
83
|
+
"--spacing-navbar-row",
|
|
84
|
+
"--spacing-screen-gutter",
|
|
85
|
+
...(["sm", "md", "lg"] as const).map((step) => `--spacing-chart-${step}`),
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
for (const name of required) {
|
|
89
|
+
expect(NATIVE.has(name), `theme.css's @variant native declares no ${name}`).toBe(true);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// The generic corner scale is derived from `--radius` in `tokens.css`'s
|
|
94
|
+
// `@theme inline` block. Restating a derived step here would pin it and
|
|
95
|
+
// break the one thing the Radius axis relies on.
|
|
96
|
+
test("does not restate the derived corner scale", () => {
|
|
97
|
+
for (const step of ["xs", "sm", "md", "lg", "xl", "2xl", "3xl", "4xl", "full"]) {
|
|
98
|
+
expect(NATIVE.has(`--radius-${step}`)).toBe(false);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/** The heading face is a token like the other three, or `Text.Display` cannot follow a theme. */
|
|
104
|
+
describe("the font families", () => {
|
|
105
|
+
test("declare all four for both platforms", () => {
|
|
106
|
+
for (const platform of ["ios", "android"] as const) {
|
|
107
|
+
const declared = variantDeclarations(platform);
|
|
108
|
+
|
|
109
|
+
for (const token of ["--font-sans", "--font-serif", "--font-mono", "--font-heading"] as const) {
|
|
110
|
+
expect(declared.get(token), `@variant ${platform} declares no ${token}`).toBeDefined();
|
|
111
|
+
// React Native's `fontFamily` takes one name and no fallback
|
|
112
|
+
// list, so a comma-separated web stack resolves to nothing.
|
|
113
|
+
expect(declared.get(token)).not.toContain(",");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|