@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,187 @@
|
|
|
1
|
+
import { cn } from "../../lib/cn";
|
|
2
|
+
import { tv } from "../../lib/tv";
|
|
3
|
+
import { ICON_SIZES, type IconSize } from "../icon/icon.variants";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The spinner's named sizes *are* the icon's.
|
|
7
|
+
*
|
|
8
|
+
* A spinner is a glyph that stands in for an icon — inside a button it replaces
|
|
9
|
+
* one outright — so the two have to mean the same thing by `size="md"`. Sharing
|
|
10
|
+
* the scale makes that structural instead of two lists that have to be kept in
|
|
11
|
+
* step. A leaf import, so no cycle: see AGENTS.md rule 3.
|
|
12
|
+
*/
|
|
13
|
+
export const SPINNER_SIZES = ICON_SIZES;
|
|
14
|
+
export const SPINNER_COLORS = ["default", "success", "warning", "destructive"] as const;
|
|
15
|
+
|
|
16
|
+
export type SpinnerSize = IconSize;
|
|
17
|
+
export type SpinnerColor = (typeof SPINNER_COLORS)[number];
|
|
18
|
+
|
|
19
|
+
/** Theme token behind each named colour. */
|
|
20
|
+
export const SPINNER_COLOR_TOKEN: Record<SpinnerColor, string> = {
|
|
21
|
+
default: "foreground",
|
|
22
|
+
success: "success",
|
|
23
|
+
warning: "warning",
|
|
24
|
+
destructive: "destructive",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** One full turn, in milliseconds, at speed 1. */
|
|
28
|
+
export const SPINNER_DURATION_MS = 900;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Stroke width of the default arc, in viewBox units.
|
|
32
|
+
*
|
|
33
|
+
* The head cap is a circle of half this radius, so the two cannot be tuned
|
|
34
|
+
* apart — see `spinner-arc.tsx` for why the head is drawn separately at all.
|
|
35
|
+
*/
|
|
36
|
+
export const SPINNER_ARC_STROKE_WIDTH = 2.5;
|
|
37
|
+
|
|
38
|
+
/** Alpha at the comet's head, and at the far end of its tail. */
|
|
39
|
+
export const SPINNER_ARC_HEAD_OPACITY = 1;
|
|
40
|
+
export const SPINNER_ARC_TAIL_OPACITY = 0;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Alpha where the two half-rings meet, opposite the head.
|
|
44
|
+
*
|
|
45
|
+
* Derived, never picked. Each half is a straight ramp, so the joint has to be
|
|
46
|
+
* the midpoint of the two ends or the ramps meet at different slopes and crease
|
|
47
|
+
* the ring — a soft bright wedge on one side of the joint, visible even though
|
|
48
|
+
* the alpha itself is continuous there. Held at 0.55 for a while, which is what
|
|
49
|
+
* that crease was.
|
|
50
|
+
*/
|
|
51
|
+
export const SPINNER_ARC_JOINT_OPACITY = (SPINNER_ARC_HEAD_OPACITY + SPINNER_ARC_TAIL_OPACITY) / 2;
|
|
52
|
+
|
|
53
|
+
/** Gradient steps per half-ring. Eight holds the residual skew under 0.01 alpha. */
|
|
54
|
+
export const SPINNER_ARC_STOP_COUNT = 8;
|
|
55
|
+
|
|
56
|
+
/** One `<Stop>` of a half-ring's gradient. */
|
|
57
|
+
export type SpinnerArcStop = { offset: number; opacity: number };
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Gradient stops that dim a half-ring at a constant rate per degree of turn.
|
|
61
|
+
*
|
|
62
|
+
* A linear gradient interpolates along its axis, and the axis here is `y` — but
|
|
63
|
+
* a point at angle θ clockwise from the top sits at `y = 12 - 10·cos θ`, so two
|
|
64
|
+
* stops alone make the fade stall near 3 and 9 o'clock and race through 12 and
|
|
65
|
+
* 6. The ring then reads as a bright chunk beside a flat grey quadrant rather
|
|
66
|
+
* than as an even comet.
|
|
67
|
+
*
|
|
68
|
+
* Each stop is therefore placed at the offset the arc actually occupies at that
|
|
69
|
+
* angle while its opacity steps evenly, which inverts the skew. Both half-rings
|
|
70
|
+
* share the resulting offset ladder; only the endpoints differ.
|
|
71
|
+
*
|
|
72
|
+
* Pure, so the whole ladder is reachable from `bun test`. See AGENTS.md.
|
|
73
|
+
*/
|
|
74
|
+
export function spinnerArcStops(from: number, to: number): SpinnerArcStop[] {
|
|
75
|
+
return Array.from({ length: SPINNER_ARC_STOP_COUNT + 1 }, (_unused, index) => {
|
|
76
|
+
const turn = index / SPINNER_ARC_STOP_COUNT;
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
offset: (1 - Math.cos(Math.PI * turn)) / 2,
|
|
80
|
+
opacity: from + (to - from) * turn,
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Edge length used with no `size` prop and nothing to inherit from — 24pt. */
|
|
86
|
+
export const SPINNER_FALLBACK_SIZE_CLASS = "size-icon-xl";
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Sizing a glyph composed into a spinner inherits.
|
|
90
|
+
*
|
|
91
|
+
* It fills rather than pinning a step, so it still matches at a numeric `size`
|
|
92
|
+
* — which the class scale cannot express, since Tailwind's scanner is static and
|
|
93
|
+
* a runtime `size-[40px]` is never compiled.
|
|
94
|
+
*/
|
|
95
|
+
export const SPINNER_GLYPH_SIZE_CLASS = "size-full";
|
|
96
|
+
|
|
97
|
+
/** Named colour used with no `color` prop and nothing to inherit from. */
|
|
98
|
+
export const SPINNER_FALLBACK_COLOR: SpinnerColor = "default";
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Styling for every part of a spinner.
|
|
102
|
+
*
|
|
103
|
+
* Neither slot carries a colour: the glyph is an SVG stroke, which takes a
|
|
104
|
+
* resolved value rather than a class. See {@link SPINNER_COLOR_TOKEN}.
|
|
105
|
+
*
|
|
106
|
+
* Free of React Native imports so it stays unit-testable — `bun test` cannot
|
|
107
|
+
* parse React Native's Flow-typed source. See AGENTS.md.
|
|
108
|
+
*/
|
|
109
|
+
export const spinnerVariants = tv({
|
|
110
|
+
slots: {
|
|
111
|
+
// The root is the only sized box in a spinner. Everything under it fills.
|
|
112
|
+
root: "items-center justify-center",
|
|
113
|
+
// `size-full` is load-bearing rather than tidiness. The arc is an `<Svg>`
|
|
114
|
+
// with no width or height, which react-native-svg resolves to `'100%'`.
|
|
115
|
+
// If this layer were content-sized instead, that percentage would resolve
|
|
116
|
+
// against an indefinite parent and the glyph would collapse to zero.
|
|
117
|
+
content: "size-full items-center justify-center",
|
|
118
|
+
},
|
|
119
|
+
variants: {
|
|
120
|
+
size: {
|
|
121
|
+
xs: { root: "size-icon-xs" },
|
|
122
|
+
sm: { root: "size-icon-sm" },
|
|
123
|
+
md: { root: "size-icon-md" },
|
|
124
|
+
lg: { root: "size-icon-lg" },
|
|
125
|
+
xl: { root: "size-icon-xl" },
|
|
126
|
+
"2xl": { root: "size-icon-2xl" },
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/** Whether a size is one of the named steps rather than an edge length in points. */
|
|
132
|
+
export function isSpinnerSize(size: SpinnerSize | number | undefined): size is SpinnerSize {
|
|
133
|
+
return typeof size === "string";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The class chain that sizes a spinner's root, weakest source first: the
|
|
138
|
+
* fallback, the enclosing component's icon class, this spinner's named size,
|
|
139
|
+
* then the caller's own className.
|
|
140
|
+
*
|
|
141
|
+
* A numeric size drops the chain's sizing entirely rather than leaving a losing
|
|
142
|
+
* class behind: the root takes the number through `style`, and relying on an
|
|
143
|
+
* inline style to out-rank a className-derived one is a dependency worth not
|
|
144
|
+
* having. Tailwind cannot help either way — its scanner is static, so a runtime
|
|
145
|
+
* `size-[40px]` is never compiled and would draw nothing.
|
|
146
|
+
*
|
|
147
|
+
* Precedence matches {@link resolveSpinnerColor}, and the rule `Icon` follows,
|
|
148
|
+
* so a spinner composed into a button matches it without being told to.
|
|
149
|
+
*
|
|
150
|
+
* Pure, so the whole ladder is reachable from `bun test`. See AGENTS.md.
|
|
151
|
+
*/
|
|
152
|
+
export function resolveSpinnerRootClass({
|
|
153
|
+
size,
|
|
154
|
+
inherited,
|
|
155
|
+
className,
|
|
156
|
+
}: {
|
|
157
|
+
size?: SpinnerSize | number;
|
|
158
|
+
inherited?: string;
|
|
159
|
+
className?: string;
|
|
160
|
+
}): string {
|
|
161
|
+
const isNumeric = typeof size === "number";
|
|
162
|
+
|
|
163
|
+
return cn(
|
|
164
|
+
isNumeric ? undefined : SPINNER_FALLBACK_SIZE_CLASS,
|
|
165
|
+
isNumeric ? undefined : inherited,
|
|
166
|
+
spinnerVariants({ size: isSpinnerSize(size) ? size : undefined }).root({ className })
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isSpinnerColor(color: string): color is SpinnerColor {
|
|
171
|
+
return (SPINNER_COLORS as readonly string[]).includes(color);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Colour string to hand `useThemeColor`.
|
|
176
|
+
*
|
|
177
|
+
* A named colour maps through {@link SPINNER_COLOR_TOKEN}; anything else — a
|
|
178
|
+
* theme token such as `muted-foreground`, or a literal `#EC4899` — passes
|
|
179
|
+
* through untouched for the hook to resolve or pass on.
|
|
180
|
+
*
|
|
181
|
+
* Precedence matches {@link resolveSpinnerRootClass}: explicit prop, then the
|
|
182
|
+
* inherited token, then the fallback.
|
|
183
|
+
*/
|
|
184
|
+
export function resolveSpinnerColor(color: string | undefined, inherited?: string): string {
|
|
185
|
+
const value = color ?? inherited ?? SPINNER_FALLBACK_COLOR;
|
|
186
|
+
return isSpinnerColor(value) ? SPINNER_COLOR_TOKEN[value] : value;
|
|
187
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# Switch
|
|
2
|
+
|
|
3
|
+
A binary preference, flipped by a tap or by dragging its thumb. Compound root
|
|
4
|
+
plus `Switch.Thumb`, `Switch.StartContent` and `Switch.EndContent`. It inherits
|
|
5
|
+
almost every structural decision from [Slider](../slider/AGENTS.md), which is the
|
|
6
|
+
section to read first — what follows is only where a switch differs.
|
|
7
|
+
|
|
8
|
+
`import { Switch } from "@delacour/react-native-ui/switch";`
|
|
9
|
+
|
|
10
|
+
## Files
|
|
11
|
+
|
|
12
|
+
| File | What it holds |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| `index.ts` | → `@delacour/react-native-ui/switch` |
|
|
15
|
+
| `switch.tsx` | Root — owns the pan, the state and the track |
|
|
16
|
+
| `switch-thumb.tsx` | `Switch.Thumb`, the knob and its one animated style |
|
|
17
|
+
| `switch-content.tsx` | The shared body behind both content layers — internal |
|
|
18
|
+
| `switch-start-content.tsx` | `Switch.StartContent` |
|
|
19
|
+
| `switch-end-content.tsx` | `Switch.EndContent` |
|
|
20
|
+
| `switch.context.tsx` | `SwitchContext`, `useSwitch()`, `useSwitchContext()`, `useSwitchPart()` |
|
|
21
|
+
| `switch.types.ts` | Prop types shared by two or more parts |
|
|
22
|
+
| `switch.variants.ts` | Pure `tv()` slots + the release worklet, no RN imports |
|
|
23
|
+
| `switch.variants.test.ts` | |
|
|
24
|
+
|
|
25
|
+
## Design
|
|
26
|
+
|
|
27
|
+
- **Colours**: `default`, `primary`, `success`, `warning`, `destructive`, `info` —
|
|
28
|
+
Badge's, Checkbox's and Slider's set. **Sizes**: `sm`, `md`, `lg`. There is no
|
|
29
|
+
`variant` axis: a switch has one shape, and a second way to paint it would be
|
|
30
|
+
a second thing to keep in step with the checkbox beside it.
|
|
31
|
+
|
|
32
|
+
## Gesture
|
|
33
|
+
|
|
34
|
+
- **The root is not a `Pressable`**, for `Slider`'s three reasons: its
|
|
35
|
+
`Gesture.Tap()` would fire `onPress` on every toggle, its root `Animated.View`
|
|
36
|
+
already owns `transform` — which is exactly what a thumb's position is — and a
|
|
37
|
+
thumb inside its own pressable would nest a descendant recogniser in the
|
|
38
|
+
root's. `HapticFeedback` and `playHaptic` are imported from `pressable.tsx`,
|
|
39
|
+
which is what that export is for.
|
|
40
|
+
- **One `Gesture.Pan()` serves the tap and the drag, and there is no `Tap` to
|
|
41
|
+
race it.** A release whose finger barely moved *is* the tap, which
|
|
42
|
+
`resolveSwitchRelease` decides along with everything else: tap slop first, then
|
|
43
|
+
a flick's velocity, then the position. Two recognisers would have to negotiate
|
|
44
|
+
which one owned a press that turned into a drag, and the negotiation is the
|
|
45
|
+
bug — this way there is nothing to arbitrate.
|
|
46
|
+
- **`distance` is the larger of the two axes, not the along-track
|
|
47
|
+
translation.** A vertical swipe that began on the switch moves nothing
|
|
48
|
+
horizontally, so reading only that axis would call every attempt to scroll past
|
|
49
|
+
the control a tap and toggle it. Reading both means such a swipe is movement
|
|
50
|
+
with no travel, which settles back to the state it started in — the switch is
|
|
51
|
+
left alone.
|
|
52
|
+
- **The value is settled in `onFinalize`, and only there.** Unlike a slider there
|
|
53
|
+
is nothing to write on `onBegin`: a switch has no position to move a handle to,
|
|
54
|
+
only a state to end up in. `onFinalize` is still the callback because it is the
|
|
55
|
+
one that fires on every path, the never-activated one included — which is
|
|
56
|
+
exactly the path a stationary tap takes.
|
|
57
|
+
- **`minDistance(0)` and `shouldCancelWhenOutside(false)`**, for the reasons
|
|
58
|
+
[Slider](../slider/AGENTS.md) sets out. The cost is the same one: a drag that
|
|
59
|
+
starts on the switch is the switch's, so you cannot scroll a list by putting
|
|
60
|
+
your finger on one.
|
|
61
|
+
|
|
62
|
+
## Geometry
|
|
63
|
+
|
|
64
|
+
- **The knob is a rounded rectangle lying on its side, not a disc**, and it is
|
|
65
|
+
the reason the thumb stopped reading `--spacing-icon-*`. That scale is where a
|
|
66
|
+
*glyph* belongs; a knob is the body of the control rather than a mark drawn on
|
|
67
|
+
it — the move `Slider`'s handle already made, and why `Checkbox` still reads
|
|
68
|
+
that scale for a square that really is a glyph in a box. No
|
|
69
|
+
`--spacing-switch-*` was minted either way; the thumb is plain spacing steps.
|
|
70
|
+
- **Three relationships hold at every size, and the tests pin each rather than
|
|
71
|
+
the points.** The knob's width is the track's height; its height is that less
|
|
72
|
+
twice `SWITCH_THUMB_INSET`; and because both are `rounded-full`, the two
|
|
73
|
+
capsules come out **concentric** — each radius is half its own height, so the
|
|
74
|
+
difference is exactly that inset. It is the subtraction `Checkbox`'s fill makes
|
|
75
|
+
against its border, arrived at by construction rather than by a number.
|
|
76
|
+
- **One inset, all four sides, and only two of them are written down.**
|
|
77
|
+
Horizontally it is a class the travel maths subtracts twice; vertically it is
|
|
78
|
+
never written at all, because the track is `justify-center` and an absolutely
|
|
79
|
+
positioned child with no vertical inset is centred by its parent — `Slider`'s
|
|
80
|
+
rule, where the track centres the thumb and the thumb carries no offset of its
|
|
81
|
+
own. On that axis the constant is a relationship a test pins, not a value
|
|
82
|
+
anything reads.
|
|
83
|
+
- **A content layer is as wide as the travel, not as wide as the knob.** It
|
|
84
|
+
occupies exactly the space the knob vacates at its own end, which is what the
|
|
85
|
+
travel *is*. Size it like the knob and the far layer reaches under a knob drawn
|
|
86
|
+
on top of it, and its text is clipped — visible only at the size where the text
|
|
87
|
+
is longest, which is the last place anyone looks.
|
|
88
|
+
- **The knob draws no border and no shadow.** A border is a second line where
|
|
89
|
+
there is already a boundary, and against a saturated track it reads as a dark
|
|
90
|
+
ring rather than as definition; `Slider`'s handle dropped its own for the same
|
|
91
|
+
reason. Contrast at rest comes from the track instead, which is why
|
|
92
|
+
`SWITCH_TRACK_REST_TOKEN` is `input` — the chrome a field's own box wears, a
|
|
93
|
+
step darker than the page in light and a step lighter in dark, so a knob
|
|
94
|
+
painted the page's own colour reads against it at either end of the theme.
|
|
95
|
+
- **The track and its touch padding ride in the same size cell**, summing to 44pt
|
|
96
|
+
at every size, and the test asserts the sum rather than the parts — `Slider`'s
|
|
97
|
+
rule, and the trap it names: split them across two variants and a shorter track
|
|
98
|
+
silently shrinks the target.
|
|
99
|
+
|
|
100
|
+
## Colour
|
|
101
|
+
|
|
102
|
+
- **Every colour on the control is interpolated, so the `tv()` describes almost
|
|
103
|
+
none of it.** The track, the thumb and both content layers fade between two
|
|
104
|
+
token *values* off the one `progress`, and a colour being interpolated cannot
|
|
105
|
+
be a class — `Checkbox`'s border, four times over. The slot set keeps
|
|
106
|
+
`bg-secondary` and `bg-background` as the resting appearance those styles start
|
|
107
|
+
from and names no colour anywhere else; the maps are the single source. There
|
|
108
|
+
is deliberately **no `color` axis in the `tv()`** — a `bg-*` per colour there
|
|
109
|
+
would be a second source for one surface, which is how a class and a style end
|
|
110
|
+
up disagreeing for a frame on every toggle.
|
|
111
|
+
- **The thumb takes the `-foreground` of the track it is travelling on**, so a
|
|
112
|
+
pale knob is never left unreadable on `warning`. `default` is the exception the
|
|
113
|
+
theme forces: there is no `--foreground-foreground`, and `background` is
|
|
114
|
+
what content drawn on the page's ink actually is. A test pins the whole map
|
|
115
|
+
against the track's.
|
|
116
|
+
- **`Switch.StartContent` and `.EndContent` crossfade themselves.** Start sits at
|
|
117
|
+
the leading edge, which the knob vacates as the switch turns **on**, so it fades
|
|
118
|
+
in with `progress`; end is the mirror. That is the whole reason both can be
|
|
119
|
+
written once with no `isSelected &&` at the call site — the knob reads as
|
|
120
|
+
uncovering the other end rather than sliding over content that was always
|
|
121
|
+
there. Each layer is exactly the thumb's footprint at its own end, so a glyph is
|
|
122
|
+
centred on the space the knob will vacate rather than beside it, and each
|
|
123
|
+
publishes an `IconDefaultsProvider` and a `TextClassProvider` for the surface it
|
|
124
|
+
sits on: the coloured track for start, the resting one for end.
|
|
125
|
+
- **A glyph's colour is a token and a `Text`'s is a class**, so
|
|
126
|
+
`SWITCH_CONTENT_TEXT_CLASS` exists beside `SWITCH_THUMB_TOKEN` rather than being
|
|
127
|
+
derived from it — Tailwind's scanner is static, so a runtime `text-${token}` is
|
|
128
|
+
never compiled and would silently draw nothing. A test pins every entry against
|
|
129
|
+
the token it must agree with.
|
|
130
|
+
|
|
131
|
+
## Layering and motion
|
|
132
|
+
|
|
133
|
+
- **The thumb is drawn last however the children were written.** Every part is
|
|
134
|
+
absolutely positioned and React Native paints later siblings on top, so a
|
|
135
|
+
`Switch.Thumb` written first — which is the order the anatomy reads best in —
|
|
136
|
+
would slide *under* the content layers. The root reorders rather than leaving a
|
|
137
|
+
gotcha in the API, and composes one in when the children hold none, so
|
|
138
|
+
`<Switch />` is already a complete control.
|
|
139
|
+
- **The track scales on press, and the thumb does not** — the reverse of
|
|
140
|
+
`Slider`, which grows its handle. The track clips, so a scaled knob would be
|
|
141
|
+
cut off by its own capsule: a bite taken out of the knob rather than an
|
|
142
|
+
acknowledgement of the press. The outermost node is the one thing nothing can
|
|
143
|
+
crop, so that is where the feedback lives, on `Pressable`'s own `PRESS_SPRING`
|
|
144
|
+
so a switch and a button answer a touch identically.
|
|
145
|
+
- **The thumb's own spring is critically damped**, where every other spring in
|
|
146
|
+
this package overshoots a little. A wider knob travels a shorter distance
|
|
147
|
+
inside a track that clips it, so an overshoot has nowhere to go — it would
|
|
148
|
+
visibly squash against the end of its own capsule on every toggle. A test pins
|
|
149
|
+
the damping ratio at or above one, and below the point where it crawls.
|
|
150
|
+
- **The colours interpolate off `progress`, not off a timing of their own.** The
|
|
151
|
+
track therefore colours *with the finger* through a drag rather than snapping
|
|
152
|
+
when it is let go, and there is no second clock for the position to drift
|
|
153
|
+
from — the reason all four animated properties read one shared value.
|
|
154
|
+
- **The colour and the press scale are two entries in one `useAnimatedStyle`,
|
|
155
|
+
never two calls.** Two animated styles on one view fight for the same props and
|
|
156
|
+
the later one silently wins, so the press would land on a track that never
|
|
157
|
+
scaled — `Radio.Indicator`'s rule, arrived at from the other side.
|
|
158
|
+
- **The haptic fires at the commit, never at the grab.** A slider ticks on grab
|
|
159
|
+
because the grab already moves the value; a switch dragged half way and released
|
|
160
|
+
back has changed nothing, and one that buzzed for it would be reporting a state
|
|
161
|
+
change that did not happen. Default `haptic="selection"`, matching `Checkbox`.
|
|
162
|
+
- **`settledDrags` and the `isDragging` ref are `Slider`'s, verbatim in shape.**
|
|
163
|
+
A controlled parent that rejects a dragged value leaves the state unchanged, so
|
|
164
|
+
without a token that moves on every release the sync effect has nothing to
|
|
165
|
+
re-run on and the thumb stays where the finger let go. The playground's
|
|
166
|
+
`/switch` has a switch that rejects every change, so the spring-back is on
|
|
167
|
+
screen rather than merely asserted.
|
|
168
|
+
- **Reduce motion is left at Reanimated's default `System`**, with `Radio` and
|
|
169
|
+
`Checkbox` and against `Spinner`. The state is carried by the thumb's
|
|
170
|
+
*position*, so snapping straight to it is the right degradation.
|
|
171
|
+
|
|
172
|
+
## Accessibility and API
|
|
173
|
+
|
|
174
|
+
- **The accessibility surface is written out, because there is no `Pressable` to
|
|
175
|
+
inherit it from.** `accessibilityRole="switch"` and a checked state announce it;
|
|
176
|
+
`onAccessibilityTap` and an `activate` entry in `accessibilityActions` are what
|
|
177
|
+
actually flip it, on iOS and Android respectively. Without them the switch would
|
|
178
|
+
announce its state and offer no way to change it — the same gap
|
|
179
|
+
`Slider.Thumb`'s `adjustable` actions close.
|
|
180
|
+
- **The root is marked `accessible`, and that one word is load-bearing.** Without
|
|
181
|
+
it the view is not an accessibility element on iOS at all, so the role and the
|
|
182
|
+
checked state written beside it never reach VoiceOver. It also merges the track
|
|
183
|
+
and its layers into the single element a control should be.
|
|
184
|
+
- **There is no `Switch.Label` and no `Switch.Group`.** The track is a fixed pill
|
|
185
|
+
and a label cannot sit inside it, so the name is a `Field.Label` or a
|
|
186
|
+
`ListGroup.ItemTitle` a row away — and unlike `Slider`, the switch **does**
|
|
187
|
+
register `field.registerPress`, because a row-wide press means exactly what a
|
|
188
|
+
tap on the pill means. A switch is a binary preference, not one of a set, so
|
|
189
|
+
there is nothing for a group to own; the axis ladder is two rungs, the switch's
|
|
190
|
+
own props then an enclosing `Field`.
|
|
191
|
+
- **A part is recognised by its `displayName`, never by reference alone.** The
|
|
192
|
+
root composes a `Switch.Thumb` in when the children hold none, which means it
|
|
193
|
+
has to *ask* whether a child is one — and `child.type === SwitchThumb` is not a
|
|
194
|
+
safe way to ask. This package ships raw `.tsx` for the consuming app to
|
|
195
|
+
compile, so React Compiler rewrites the binding on the way through, and Metro
|
|
196
|
+
can serve two instances of one module through a workspace symlink; either
|
|
197
|
+
leaves an element whose `type` is a different object standing for the same
|
|
198
|
+
component. The failure is silent and nearly invisible: detection returns false,
|
|
199
|
+
a second knob is composed on top of the caller's, and because the two are the
|
|
200
|
+
same size and colour the only symptom is that **anything inside the caller's
|
|
201
|
+
knob disappears**. `displayName` survives all of it — rule 12 requires one and
|
|
202
|
+
`display-name.test.ts` enforces that they are present and unique — so that is
|
|
203
|
+
what `isSwitchThumbElement` asks for, with reference equality kept only as the
|
|
204
|
+
fast path.
|
|
205
|
+
|
|
206
|
+
**[`Radio`](../radio/AGENTS.md) and `ListGroup` still ask the unsafe question**
|
|
207
|
+
(`radio.tsx`'s indicator detection, and the divider and icon-swap walks
|
|
208
|
+
elsewhere). Nobody has reported it because their duplicates overlap invisibly
|
|
209
|
+
rather than hiding content — a second ring exactly behind the first. Worth
|
|
210
|
+
fixing the next time one of them is touched.
|
|
211
|
+
- **RTL is not handled.** The thumb travels on `translateX`, which does not flip,
|
|
212
|
+
and nothing else in this package handles it yet. Stated here rather than
|
|
213
|
+
half-solved.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export { Switch, type SwitchProps } from "./switch";
|
|
2
|
+
export { type SwitchContextValue, SwitchProvider, useSwitch, useSwitchContext } from "./switch.context";
|
|
3
|
+
export type { SwitchContentProps } from "./switch.types";
|
|
4
|
+
export {
|
|
5
|
+
hasThumbChild,
|
|
6
|
+
resolveSwitchAxes,
|
|
7
|
+
resolveSwitchContentTreatment,
|
|
8
|
+
resolveSwitchRelease,
|
|
9
|
+
resolveSwitchThumbTokens,
|
|
10
|
+
resolveSwitchTrackTokens,
|
|
11
|
+
SWITCH_COLORS,
|
|
12
|
+
SWITCH_CONTENT_ICON_STEP,
|
|
13
|
+
SWITCH_CONTENT_REST_TEXT_CLASS,
|
|
14
|
+
SWITCH_CONTENT_REST_TOKEN,
|
|
15
|
+
SWITCH_CONTENT_TEXT_CLASS,
|
|
16
|
+
SWITCH_DEFAULT_COLOR,
|
|
17
|
+
SWITCH_DEFAULT_SIZE,
|
|
18
|
+
SWITCH_FLING_VELOCITY,
|
|
19
|
+
SWITCH_INVALID_CONTENT_TEXT_CLASS,
|
|
20
|
+
SWITCH_INVALID_THUMB_TOKEN,
|
|
21
|
+
SWITCH_INVALID_TRACK_TOKEN,
|
|
22
|
+
SWITCH_PRESS_ANIMATION,
|
|
23
|
+
SWITCH_PRESS_SPRING,
|
|
24
|
+
SWITCH_SIZES,
|
|
25
|
+
SWITCH_TAP_SLOP,
|
|
26
|
+
SWITCH_THUMB_INSET,
|
|
27
|
+
SWITCH_THUMB_REST_TOKEN,
|
|
28
|
+
SWITCH_THUMB_SPRING,
|
|
29
|
+
SWITCH_THUMB_TOKEN,
|
|
30
|
+
SWITCH_TRACK_REST_TOKEN,
|
|
31
|
+
SWITCH_TRACK_TOKEN,
|
|
32
|
+
type SwitchAxes,
|
|
33
|
+
type SwitchColor,
|
|
34
|
+
type SwitchContentPlacement,
|
|
35
|
+
type SwitchFieldAxes,
|
|
36
|
+
type SwitchOwnAxes,
|
|
37
|
+
type SwitchSize,
|
|
38
|
+
type SwitchVariantProps,
|
|
39
|
+
switchTravel,
|
|
40
|
+
switchVariants,
|
|
41
|
+
} from "./switch.variants";
|
|
42
|
+
export type { SwitchThumbProps } from "./switch-thumb";
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Children, type ReactElement, type ReactNode, useMemo } from "react";
|
|
2
|
+
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
|
3
|
+
import { useThemeColor } from "../../hooks/use-theme-color";
|
|
4
|
+
import { IconDefaultsProvider } from "../icon";
|
|
5
|
+
import { Text } from "../text";
|
|
6
|
+
import { TextClassProvider } from "../text/text.context";
|
|
7
|
+
import { useSwitchPart } from "./switch.context";
|
|
8
|
+
import type { SwitchContentProps } from "./switch.types";
|
|
9
|
+
import { resolveSwitchContentTreatment, type SwitchContentPlacement, switchVariants } from "./switch.variants";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The shared implementation behind `Switch.StartContent` and `.EndContent`.
|
|
13
|
+
*
|
|
14
|
+
* The two are the same box at opposite ends of the track — which end, and which
|
|
15
|
+
* way their opacity runs, are the only differences — so they share this leaf
|
|
16
|
+
* rather than duplicating {@link wrapTextChildren} and an animated style into
|
|
17
|
+
* two part files. `Input.Group`'s two decorators make the same trade.
|
|
18
|
+
*
|
|
19
|
+
* **The crossfade is the component's, not the caller's.** `StartContent` sits at
|
|
20
|
+
* the leading edge, which the thumb vacates as the switch turns **on**, so it
|
|
21
|
+
* fades in with `progress`; `EndContent` sits at the trailing edge and fades out
|
|
22
|
+
* with it. That is what lets both be written once with no conditionals — the
|
|
23
|
+
* thumb reads as uncovering the other end rather than as sliding over content
|
|
24
|
+
* that was always there.
|
|
25
|
+
*
|
|
26
|
+
* The layer is exactly the thumb's footprint at its own end, so a glyph is
|
|
27
|
+
* centred on the space the knob will vacate rather than beside it.
|
|
28
|
+
*
|
|
29
|
+
* Not a part itself: it takes the caller-facing name so the error thrown outside
|
|
30
|
+
* a switch names `Switch.StartContent` rather than something private.
|
|
31
|
+
*/
|
|
32
|
+
export function SwitchContent({
|
|
33
|
+
part,
|
|
34
|
+
placement,
|
|
35
|
+
className,
|
|
36
|
+
children,
|
|
37
|
+
...props
|
|
38
|
+
}: SwitchContentProps & { part: string; placement: SwitchContentPlacement }): ReactElement {
|
|
39
|
+
const { color, size, isDisabled, isInvalid, progress } = useSwitchPart(part);
|
|
40
|
+
const slots = switchVariants({ isDisabled, size });
|
|
41
|
+
|
|
42
|
+
const treatment = resolveSwitchContentTreatment({ color, isInvalid, placement });
|
|
43
|
+
const glyphColor = useThemeColor(treatment.color);
|
|
44
|
+
const glyphClassName = slots.glyph();
|
|
45
|
+
const iconDefaults = useMemo(
|
|
46
|
+
() => ({ className: glyphClassName, color: glyphColor ?? "" }),
|
|
47
|
+
[glyphClassName, glyphColor]
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const isStart = placement === "start";
|
|
51
|
+
const contentStyle = useAnimatedStyle(() => ({
|
|
52
|
+
opacity: isStart ? progress.value : 1 - progress.value,
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
const textClassName = slots.contentText({ className: treatment.textClass });
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<Animated.View
|
|
59
|
+
className={isStart ? slots.startContent({ className }) : slots.endContent({ className })}
|
|
60
|
+
// Never the element a screen reader lands on. The switch announces itself
|
|
61
|
+
// as one control with a checked state, and a decorative glyph either side
|
|
62
|
+
// of the knob has nothing to add to that.
|
|
63
|
+
accessible={false}
|
|
64
|
+
importantForAccessibility="no-hide-descendants"
|
|
65
|
+
pointerEvents="none"
|
|
66
|
+
style={contentStyle}
|
|
67
|
+
{...props}
|
|
68
|
+
>
|
|
69
|
+
<IconDefaultsProvider value={iconDefaults}>
|
|
70
|
+
<TextClassProvider value={textClassName}>{wrapTextChildren(children)}</TextClassProvider>
|
|
71
|
+
</IconDefaultsProvider>
|
|
72
|
+
</Animated.View>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
SwitchContent.displayName = "DelacourUI.Switch.Content";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Wraps bare text children in a `Text`.
|
|
79
|
+
*
|
|
80
|
+
* A content layer renders a `View`, and React Native cannot render a string
|
|
81
|
+
* outside a `<Text>` — so `<Switch.EndContent>OFF</Switch.EndContent>`, which is
|
|
82
|
+
* the shortest thing anyone will write, would otherwise crash. Consecutive
|
|
83
|
+
* strings and numbers collapse into one `Text` rather than one each, the same
|
|
84
|
+
* rule and the same reason as `Input`'s decorators.
|
|
85
|
+
*
|
|
86
|
+
* The wrapped text needs no className: the layer has already published its
|
|
87
|
+
* treatment through `TextClassProvider`.
|
|
88
|
+
*/
|
|
89
|
+
function wrapTextChildren(children: ReactNode): ReactNode {
|
|
90
|
+
const items = Children.toArray(children);
|
|
91
|
+
const output: ReactNode[] = [];
|
|
92
|
+
let run: (string | number)[] = [];
|
|
93
|
+
|
|
94
|
+
const flushRun = () => {
|
|
95
|
+
if (run.length === 0) return;
|
|
96
|
+
output.push(<Text key={`content-${output.length}`}>{run.join("")}</Text>);
|
|
97
|
+
run = [];
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
for (const child of items) {
|
|
101
|
+
if (typeof child === "string" || typeof child === "number") {
|
|
102
|
+
run.push(child);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
flushRun();
|
|
106
|
+
output.push(child);
|
|
107
|
+
}
|
|
108
|
+
flushRun();
|
|
109
|
+
|
|
110
|
+
return output;
|
|
111
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import type { SwitchContentProps } from "./switch.types";
|
|
3
|
+
import { SwitchContent } from "./switch-content";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Content at the trailing edge of the track, behind the thumb.
|
|
7
|
+
*
|
|
8
|
+
* Revealed as the switch turns **off** — this is the end the knob vacates — so
|
|
9
|
+
* it is where the "off" mark goes: a cross, a moon, the word OFF. The fade is
|
|
10
|
+
* the component's, so write it once and unconditionally.
|
|
11
|
+
*
|
|
12
|
+
* It sits on the resting track rather than the coloured one, so its glyph takes
|
|
13
|
+
* a muted foreground rather than the switch's colour.
|
|
14
|
+
*/
|
|
15
|
+
export function SwitchEndContent(props: SwitchContentProps): ReactElement {
|
|
16
|
+
return <SwitchContent part="Switch.EndContent" placement="end" {...props} />;
|
|
17
|
+
}
|
|
18
|
+
SwitchEndContent.displayName = "DelacourUI.Switch.EndContent";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import type { SwitchContentProps } from "./switch.types";
|
|
3
|
+
import { SwitchContent } from "./switch-content";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Content at the leading edge of the track, behind the thumb.
|
|
7
|
+
*
|
|
8
|
+
* Revealed as the switch turns **on** — this is the end the knob vacates — so it
|
|
9
|
+
* is where the "on" mark goes: a tick, a sun, the word ON. The fade is the
|
|
10
|
+
* component's, so write it once and unconditionally; there is no `isSelected &&`
|
|
11
|
+
* to remember.
|
|
12
|
+
*
|
|
13
|
+
* An `Icon` needs nothing said at the call site: it inherits the switch's glyph
|
|
14
|
+
* step and the foreground of the colour the track fades to. A bare string is
|
|
15
|
+
* wrapped in a `Text` that inherits the same treatment.
|
|
16
|
+
*/
|
|
17
|
+
export function SwitchStartContent(props: SwitchContentProps): ReactElement {
|
|
18
|
+
return <SwitchContent part="Switch.StartContent" placement="start" {...props} />;
|
|
19
|
+
}
|
|
20
|
+
SwitchStartContent.displayName = "DelacourUI.Switch.StartContent";
|