@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,245 @@
|
|
|
1
|
+
# Radio
|
|
2
|
+
|
|
3
|
+
One option in a set where exactly one can be chosen — alone, or inside a group
|
|
4
|
+
that owns the selection. Compound root plus `Radio.Label`, `Radio.Indicator` and
|
|
5
|
+
`Radio.Group`.
|
|
6
|
+
|
|
7
|
+
`import { Radio } from "@delacour/react-native-ui/radio";`
|
|
8
|
+
|
|
9
|
+
## Files
|
|
10
|
+
|
|
11
|
+
| File | What it holds |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| `index.ts` | → `@delacour/react-native-ui/radio` |
|
|
14
|
+
| `radio.tsx` | Root + the `Object.assign` compound surface, and `withIndicator` |
|
|
15
|
+
| `radio-group.tsx` | `Radio.Group`, which owns the selection |
|
|
16
|
+
| `radio-indicator.tsx` | `Radio.Indicator`, the ring and its animated dot |
|
|
17
|
+
| `radio-label.tsx` | `Radio.Label`, the `Text.Label` inside the tap target |
|
|
18
|
+
| `radio.context.tsx` | `RadioContext` and `RadioGroupContext`, with their hooks |
|
|
19
|
+
| `radio.variants.ts` | Pure `tv()` slots + three resolvers, no RN imports |
|
|
20
|
+
| `radio.variants.test.ts` | |
|
|
21
|
+
|
|
22
|
+
There is no `radio.types.ts`, unlike most compound folders here.
|
|
23
|
+
`{name}.types.ts` holds only prop types shared by two or more modules and a type
|
|
24
|
+
with exactly one consumer stays in that consumer's file — `RadioProps`,
|
|
25
|
+
`RadioGroupProps`, `RadioIndicatorProps` and `RadioLabelProps` each have exactly
|
|
26
|
+
one, so each lives beside the component it types. The state types the ladder
|
|
27
|
+
needs — `RadioGroupState`, `RadioOwnState`, `RadioFieldState`, `RadioState` —
|
|
28
|
+
sit in `radio.variants.ts` beside the pure resolver that reads them. See
|
|
29
|
+
[Compound component layout](../../../AGENTS.md#compound-component-layout).
|
|
30
|
+
|
|
31
|
+
## Design
|
|
32
|
+
|
|
33
|
+
- **Variants**: `primary`, `secondary` — `secondary` fills the ring, `primary`
|
|
34
|
+
leaves it hollow. **Sizes**: `sm`, `md`, `lg`. **Orientations**, on the group
|
|
35
|
+
only: `vertical` stacks the radios, `horizontal` lays them out in a wrapping
|
|
36
|
+
row.
|
|
37
|
+
- **The whole row is the control.** The ring, the label beside it and the space
|
|
38
|
+
between them are one tap target, so a press anywhere on the row selects. A
|
|
39
|
+
`Radio.Indicator` is composed in automatically and plain string children are
|
|
40
|
+
wrapped in a `Radio.Label`, so the shortest thing that works is
|
|
41
|
+
`<Radio value="pro">Pro</Radio>`.
|
|
42
|
+
- **A radio works with or without a group.** Inside a `Radio.Group` its `value`
|
|
43
|
+
identifies it and the group owns the selection, the size and the variant.
|
|
44
|
+
Outside one it is driven by its own `isSelected` and `onSelected`.
|
|
45
|
+
- **`onSelected` is only ever called with `true`.** A radio does not deselect
|
|
46
|
+
itself, which is [Switch](../switch/AGENTS.md) and
|
|
47
|
+
[Checkbox](../checkbox/AGENTS.md) semantics rather than radio ones. The
|
|
48
|
+
group's own `onSelected` takes the newly selected value and is never called
|
|
49
|
+
for a re-press of the current one: re-picking the option already selected is
|
|
50
|
+
not a change and a radio group has no deselect gesture, so without
|
|
51
|
+
`shouldEmitSelection` a tap on the current selection would re-notify the caller
|
|
52
|
+
with a value it already holds. HTML's own radio does not fire `change` there
|
|
53
|
+
either. The decision is pure, so `bun test` reaches it.
|
|
54
|
+
- **The axis ladder is `group ?? own ?? field ?? default`, and it deliberately
|
|
55
|
+
puts the group *first*** — the inverse of `Checkbox`'s
|
|
56
|
+
`own ?? group ?? field ?? default`. A radio group puts itself first because
|
|
57
|
+
escaping a disabled form group is a bug. `size` and `variant` the group owns
|
|
58
|
+
outright and publishes resolved, so a grouped radio's own copies are ignored: a
|
|
59
|
+
group whose options were different sizes is not a design. The two *state* axes
|
|
60
|
+
are published raw, which is the one place this differs from `Input.Group`, and
|
|
61
|
+
deliberately: a group holds many radios, so disabling one option out of five
|
|
62
|
+
has to be possible. A group that names the axis still wins outright, so
|
|
63
|
+
`isDisabled={false}` opts a radio out of a disabled `Field` and not out of a
|
|
64
|
+
disabled group. `resolveRadioState` is pure, so the whole matrix is reachable
|
|
65
|
+
from `bun test` — [Input](../input/AGENTS.md) runs this same ladder inline in
|
|
66
|
+
its own render, where no unit test can see it.
|
|
67
|
+
- **`Radio` takes no `asChild`.** `Pressable`'s root `Animated.View` already owns
|
|
68
|
+
`opacity` and `transform` through a `useAnimatedStyle` of its own, and two
|
|
69
|
+
animated styles on one node fight for the same props — the same rule that keeps
|
|
70
|
+
the dot's animated style off the ring, and that
|
|
71
|
+
[Slider](../slider/AGENTS.md)'s root and thumb are built around. `asChild`,
|
|
72
|
+
`busy`, `children` and `disabled` are the four props `Omit`ed from the
|
|
73
|
+
`Pressable` surface the root otherwise passes through; `isDisabled` is the
|
|
74
|
+
public spelling of the last, because the settled value is what reaches
|
|
75
|
+
`Pressable`.
|
|
76
|
+
- **The animated style lives on the dot, never on the ring.** The dot is a
|
|
77
|
+
descendant two levels down from that `Animated.View`, so the two never contend.
|
|
78
|
+
A test asserts the `dot` slot claims neither `opacity-*` nor `scale-*` in any
|
|
79
|
+
cell: a class fighting a `useAnimatedStyle` for the same property is a dot that
|
|
80
|
+
never appears, with no error anywhere.
|
|
81
|
+
- **The disabled fade lands on the ring and the label, never on the root.** It
|
|
82
|
+
was placed there because [`Pressable`](../pressable/AGENTS.md)'s animated
|
|
83
|
+
style used to overwrite an `opacity-50` on the root; `Pressable` now
|
|
84
|
+
multiplies a className's opacity into its press, so the root *could* fade,
|
|
85
|
+
but the fade stays on the parts: an `opacity-50` on the root as well would
|
|
86
|
+
compound with them and land at a quarter. The group must not fade either, for
|
|
87
|
+
the same reason. Tests pin both halves. `Switch`, `Tabs` and `Accordion` all
|
|
88
|
+
cite back here.
|
|
89
|
+
- **The ring is drawn from `View`s rather than a Central Icon.** Rule 5 governs
|
|
90
|
+
icons and [Spinner](../spinner/AGENTS.md)'s arc is the precedent for
|
|
91
|
+
primitives; here the set has no ring-with-a-centred-dot glyph at all. Two
|
|
92
|
+
things follow from drawing it: the dot can scale from the ring's centre
|
|
93
|
+
entirely on the UI thread, and the ring's four themed colours stay classes in
|
|
94
|
+
`radio.variants.ts` where `bun test` reaches them. An `Icon` would take its
|
|
95
|
+
colour as a resolved value, splitting one decision across two mechanisms.
|
|
96
|
+
- **The dot springs on `RADIO_DOT_SPRING`, deliberately near `Pressable`'s
|
|
97
|
+
`PRESS_SPRING` but a touch looser**, so it settles just after the row it sits
|
|
98
|
+
in has rebounded. It is read on the JS thread inside an effect rather than
|
|
99
|
+
captured by a worklet, so it stays a plain object. `progress` is seeded from the
|
|
100
|
+
current state rather than from zero, so a group that mounts with a selection
|
|
101
|
+
does not animate every dot in on its first paint. Both branches animate: the
|
|
102
|
+
outgoing dot shrinks rather than vanishing, which is what makes a group read as
|
|
103
|
+
one selection moving between rows — and the `dot` slot's fill is unconditional
|
|
104
|
+
rather than gated on `isSelected`, or the dot would vanish on the frame the
|
|
105
|
+
state flips instead of shrinking. A test asserts the spring actually springs:
|
|
106
|
+
non-zero mass and stiffness, and damping below critical.
|
|
107
|
+
- **It is a spring rather than a timing, and that is the whole of the choice.**
|
|
108
|
+
A dot that eases in reads as a fade — a mark arriving on the surface. A dot
|
|
109
|
+
that springs reads as a selection landing, which is what a press on a radio row
|
|
110
|
+
actually is.
|
|
111
|
+
- **Reduce motion is left at Reanimated's default `System`, deliberately the
|
|
112
|
+
opposite call to `Spinner`'s `ReduceMotion.Never`** — and with `Checkbox`,
|
|
113
|
+
`Switch` and `Accordion`. The spinner needs `Never` because a zero-length
|
|
114
|
+
animation inside `withRepeat(-1)` would spin forever. Here the state is carried
|
|
115
|
+
by the dot's *presence*, not by its motion, so `System` snapping straight to the
|
|
116
|
+
target is exactly the right degradation.
|
|
117
|
+
- **`Radio.Indicator` takes children, and a render function is one of them.** A
|
|
118
|
+
function is handed the settled `isSelected`, `isInvalid`, `size` and `variant`;
|
|
119
|
+
either form replaces the dot outright — the animated `Animated.View` is not
|
|
120
|
+
rendered at all — while the ring, its size and its colours stay the radio's.
|
|
121
|
+
`dotProps` is the other door: props for the dot's own `Animated.View`, for a
|
|
122
|
+
caller restyling it in place rather than replacing it, with the animated style
|
|
123
|
+
applied before the caller's own so `style` composes rather than clobbers.
|
|
124
|
+
- **Where the indicator sits decides the row's layout, and the root works it
|
|
125
|
+
out.** `none` means the caller wrote no indicator, so one is composed in at the
|
|
126
|
+
front. `end` means one placed last with something before it — a settings row,
|
|
127
|
+
`[label and description] [ring]` — and the row spreads to push the ring to the
|
|
128
|
+
far edge. A lone indicator with nothing beside it is `start`: there is nothing
|
|
129
|
+
to spread it away from. That is what makes the trailing ring work without a
|
|
130
|
+
`flex-1` spacer wedged between the two, which is the shape a caller would
|
|
131
|
+
otherwise have to reach for. `isIndicatorTrailing` is a variant rather than a
|
|
132
|
+
caller's `justify-between` because the root already knows where the indicator
|
|
133
|
+
landed, and a test asserts a trailing row differs from a leading one by exactly
|
|
134
|
+
that one class. `resolveIndicatorPlacement` takes an array of "is this child an
|
|
135
|
+
indicator" rather than the children themselves, so it stays free of React and
|
|
136
|
+
reachable from `bun test` — the trade `resolveSpinnerSwapIndex` already makes.
|
|
137
|
+
- **Bare text is wrapped in a `Radio.Label`, consecutive strings collapsing into
|
|
138
|
+
one.** React Native cannot render a string outside a `<Text>`, so
|
|
139
|
+
`<Radio>Yes</Radio>` would otherwise crash. They collapse rather than wrapping
|
|
140
|
+
one each because `Plan {n}` is a single piece of text, and wrapping the parts
|
|
141
|
+
separately would space them apart by the row's own gap — the same rule, and the
|
|
142
|
+
same reason, as [Button](../button/AGENTS.md). `withIndicator` lives in
|
|
143
|
+
`radio.tsx` rather than with the parts: it is the root that wraps its own
|
|
144
|
+
children, and importing it from a part would close a cycle. Rule 3.
|
|
145
|
+
- **The indicator is detected by reference, and that is a known wart.**
|
|
146
|
+
`withIndicator` asks `child.type === RadioIndicator`, where `Switch` asks its
|
|
147
|
+
child's `displayName` through `isSwitchThumbElement` and keeps reference
|
|
148
|
+
equality only as a fast path. Reference is not a safe question here: this
|
|
149
|
+
package ships raw `.tsx` for the consuming app to compile, so React Compiler
|
|
150
|
+
rewrites the binding on the way through, and Metro can serve two instances of
|
|
151
|
+
one module through a workspace symlink — either leaves an element whose `type`
|
|
152
|
+
is a different object standing for the same component. Detection then returns
|
|
153
|
+
false and a second ring is composed in at the front of the row. Nobody has
|
|
154
|
+
reported it because the duplicate overlaps invisibly rather than hiding
|
|
155
|
+
content — a second ring exactly behind the first — where the same bug in
|
|
156
|
+
`Switch` swallowed anything inside the caller's knob. `displayName` survives
|
|
157
|
+
all of it, rule 12 requires one, and `display-name.test.ts` already enforces
|
|
158
|
+
that they are present and unique. Worth fixing the next time this file is
|
|
159
|
+
touched.
|
|
160
|
+
- **`Radio.Label` *is* `Text.Label`.** It renders the preset and names a size
|
|
161
|
+
step from `RADIO_LABEL_TEXT_SIZE`, never a scale, a weight or a colour of its
|
|
162
|
+
own — restating them would be a second definition of `Text.Label` that could
|
|
163
|
+
drift from it, the rule [Field](../field/AGENTS.md) is built on. Naming the
|
|
164
|
+
size is what lets the label still track the radio's own axis, since
|
|
165
|
+
[Text](../text/AGENTS.md)'s size axis is built to beat its preset. The `label`
|
|
166
|
+
slot carries layout and nothing else, and a test asserts it holds no `text-*`
|
|
167
|
+
or `font-*` in any cell, plus that every step it names is a size `Text`
|
|
168
|
+
actually has.
|
|
169
|
+
- **The label does not redden while invalid.** The ring already carries that, and
|
|
170
|
+
a `Field.Error` under the group says what is actually wrong; five labels turning
|
|
171
|
+
red would read as five wrong answers. Disabled needs nothing from the part
|
|
172
|
+
either — the `isDisabled` variant fades the ring and the label together, as one
|
|
173
|
+
control.
|
|
174
|
+
- **The label is `shrink`, never `flex-1`.** `flex-1` sets `flex-basis: 0%`, and
|
|
175
|
+
in a content-sized `horizontal` row Yoga resolves that to zero and collapses
|
|
176
|
+
the text to nothing. `shrink` lets a long label wrap without claiming a basis.
|
|
177
|
+
- **The root takes no `self-start` and no `w-full`, and both absences are
|
|
178
|
+
load-bearing.** A radio row *is* its own tap target, so unlike a `Badge` it
|
|
179
|
+
wants the stretch it gets inside the group's `flex-col` — `self-start` would
|
|
180
|
+
shrink the target to the width of the word "Yes". But unlike a `ListGroup.Item`
|
|
181
|
+
it cannot take `w-full` either, because a `horizontal` group would then give
|
|
182
|
+
every radio the full width of the group and blow the row apart. Vertical
|
|
183
|
+
stretch already supplies the width; horizontal wants content width.
|
|
184
|
+
- **The row's height is a floor, never fixed.** `Text` respects OS font scaling,
|
|
185
|
+
so `h-*` would clip a label at a large accessibility step; `min-h-*` exists for
|
|
186
|
+
the hit target, which scaling may exceed but must never undercut.
|
|
187
|
+
- **The ring indexes the shared `--spacing-icon-*` scale rather than minting one
|
|
188
|
+
of its own.** A radio's ring is a small round mark in a row beside a label, the
|
|
189
|
+
same kind of thing as a row's chevron, and it should stay level with an `Icon`
|
|
190
|
+
at the same step by construction. The dot takes plain spacing steps — numbers
|
|
191
|
+
read in one component — and its fit inside the ring is pinned by a test rather
|
|
192
|
+
than by a token, the way `Field` pins its gap ladder: the test reads
|
|
193
|
+
`tokens.css` and asserts the dot clears the ring's inner diameter, border
|
|
194
|
+
included, at every size. See [Sizing](../../../AGENTS.md#sizing).
|
|
195
|
+
- **`border-2` sits in the base rather than on a variant.** A border declared
|
|
196
|
+
only where it is coloured would make the ring four points smaller the moment a
|
|
197
|
+
caller switched variant. A test asserts every cell reserves it and every cell
|
|
198
|
+
names a border colour.
|
|
199
|
+
- **All four `variant × isSelected` cells live in `compoundVariants`**, because
|
|
200
|
+
neither axis paints the ring alone — the reason a badge's twenty-four do.
|
|
201
|
+
Invalid outranks selected on both the ring and the dot, and it is a compound
|
|
202
|
+
rather than a plain variant purely for emission order: `tv` emits the variants
|
|
203
|
+
first and the compounds after, so a plain `isInvalid` branch would lose to the
|
|
204
|
+
four cells above. `Input` leans on the same mechanism for focused-and-invalid.
|
|
205
|
+
- **The group renders no legend, description or error.** `Field` already owns all
|
|
206
|
+
three, and a second definition of a label is a type scale that can drift — the
|
|
207
|
+
trade `Input` made. That does leave the group without an accessible name, since
|
|
208
|
+
React Native has no `aria-labelledby` to tie it to the `Field.Label` above it,
|
|
209
|
+
so pass `accessibilityLabel`. The group is a `View` carrying
|
|
210
|
+
`accessibilityRole="radiogroup"`, where `Checkbox.Group` carries no role at
|
|
211
|
+
all.
|
|
212
|
+
- **`selected` takes `value ?? null`.** `null` means "controlled, nothing
|
|
213
|
+
selected". Omitting the prop entirely is what makes the group uncontrolled, so a
|
|
214
|
+
`useState<string>()` seeded with `undefined` would silently hand the group its
|
|
215
|
+
own state and then switch it to controlled on the first press. This is the
|
|
216
|
+
first exercise `useControllableState` has had in the package, and the change
|
|
217
|
+
handler is memoised: without that, the setter rebuilds every render, which
|
|
218
|
+
rebuilds the context value, which re-renders every radio in the group on every
|
|
219
|
+
render of whatever holds it.
|
|
220
|
+
- **`isInvalid` and `isDisabled` are not defaulted in the group's destructure.**
|
|
221
|
+
A `false` there would swallow the `Field` before it was ever consulted. See
|
|
222
|
+
`input-group.tsx`.
|
|
223
|
+
- **A grouped radio with no `value` warns in development rather than throwing.**
|
|
224
|
+
It can never be selected, and group membership is invisible in the child's
|
|
225
|
+
props at compile time, so it cannot be a type error either. The warning lives
|
|
226
|
+
in `radio.tsx`, where the component name is available to name in the message.
|
|
227
|
+
- **A radio whose row holds no text needs an `accessibilityLabel`**, the same
|
|
228
|
+
rule an icon-sized `Button` follows.
|
|
229
|
+
- **There is no `Radio.Description`.** A `Text.Caption` composed inside the row
|
|
230
|
+
stays within the one tap target and inside the accessible name, which is what a
|
|
231
|
+
description part would have to do anyway. The root publishes the label's
|
|
232
|
+
treatment through `TextClassProvider` because one treatment covers the whole
|
|
233
|
+
subtree — a radio row has a label and no description part — which is the
|
|
234
|
+
condition for publishing into the cascade.
|
|
235
|
+
- **Two `Pressable` defaults differ and only two**: `feedback="fade"` and
|
|
236
|
+
`hitSlop={8}`, both ordinary props a caller can override. `haptic` is left at
|
|
237
|
+
`Pressable`'s own `false`, unlike `Checkbox`'s `haptic="selection"`.
|
|
238
|
+
- **The parts read only the settled state.** `RadioProvider` publishes it, so
|
|
239
|
+
`Radio.Indicator` never has to know whether it is inside a group, never reads a
|
|
240
|
+
`Field`, and never imports the root. `useRadioGroupContext` is nullable because
|
|
241
|
+
a radio has to work perfectly well on its own, the same way `useFieldContext`
|
|
242
|
+
is for every control that can stand outside a `Field`; `useRadio` and
|
|
243
|
+
`useRadioGroup` throw, and `useRadioPart` is internal and deliberately not
|
|
244
|
+
re-exported from `index.ts` — a caller outside the library wants `useRadio`,
|
|
245
|
+
whose error message names the hook rather than a part.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export { Radio, type RadioProps } from "./radio";
|
|
2
|
+
export {
|
|
3
|
+
type RadioContextValue,
|
|
4
|
+
type RadioGroupContextValue,
|
|
5
|
+
RadioGroupProvider,
|
|
6
|
+
RadioProvider,
|
|
7
|
+
useRadio,
|
|
8
|
+
useRadioContext,
|
|
9
|
+
useRadioGroup,
|
|
10
|
+
useRadioGroupContext,
|
|
11
|
+
} from "./radio.context";
|
|
12
|
+
export {
|
|
13
|
+
RADIO_DEFAULT_SIZE,
|
|
14
|
+
RADIO_DEFAULT_VARIANT,
|
|
15
|
+
RADIO_DOT_SPRING,
|
|
16
|
+
RADIO_LABEL_TEXT_SIZE,
|
|
17
|
+
RADIO_ORIENTATIONS,
|
|
18
|
+
RADIO_SIZES,
|
|
19
|
+
RADIO_VARIANTS,
|
|
20
|
+
type RadioFieldState,
|
|
21
|
+
type RadioGroupState,
|
|
22
|
+
type RadioOrientation,
|
|
23
|
+
type RadioOwnState,
|
|
24
|
+
type RadioSize,
|
|
25
|
+
type RadioState,
|
|
26
|
+
type RadioVariant,
|
|
27
|
+
type RadioVariantProps,
|
|
28
|
+
radioVariants,
|
|
29
|
+
resolveIndicatorPlacement,
|
|
30
|
+
resolveRadioState,
|
|
31
|
+
shouldEmitSelection,
|
|
32
|
+
} from "./radio.variants";
|
|
33
|
+
export type { RadioGroupProps } from "./radio-group";
|
|
34
|
+
export type { RadioIndicatorProps } from "./radio-indicator";
|
|
35
|
+
export type { RadioLabelProps } from "./radio-label";
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { type ReactElement, useCallback, useMemo } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
import { useControllableState } from "../../hooks/use-controllable-state";
|
|
4
|
+
import { useFieldContext } from "../field/field.context";
|
|
5
|
+
import { type RadioGroupContextValue, RadioGroupProvider } from "./radio.context";
|
|
6
|
+
import {
|
|
7
|
+
type RadioOrientation,
|
|
8
|
+
type RadioSize,
|
|
9
|
+
type RadioVariant,
|
|
10
|
+
radioVariants,
|
|
11
|
+
shouldEmitSelection,
|
|
12
|
+
} from "./radio.variants";
|
|
13
|
+
|
|
14
|
+
export type RadioGroupProps = ViewProps & {
|
|
15
|
+
/**
|
|
16
|
+
* The selected radio's value.
|
|
17
|
+
*
|
|
18
|
+
* `null` means "controlled, nothing selected". Omitting the prop entirely is
|
|
19
|
+
* what makes the group uncontrolled, so a `useState<string>()` seeded with
|
|
20
|
+
* `undefined` would silently hand the group its own state and then switch it
|
|
21
|
+
* to controlled on the first press. Pass `value ?? null`.
|
|
22
|
+
*/
|
|
23
|
+
selected?: string | null;
|
|
24
|
+
/** Initial selection while uncontrolled. */
|
|
25
|
+
defaultSelected?: string | null;
|
|
26
|
+
/** Called with the newly selected value. Never called for a re-press of the current one. */
|
|
27
|
+
onSelected?: (value: string) => void;
|
|
28
|
+
/** Size of every radio in the group. */
|
|
29
|
+
size?: RadioSize;
|
|
30
|
+
/** How every ring in the group is painted. */
|
|
31
|
+
variant?: RadioVariant;
|
|
32
|
+
/** `vertical` stacks the radios; `horizontal` lays them out in a wrapping row. */
|
|
33
|
+
orientation?: RadioOrientation;
|
|
34
|
+
/** Marks every radio invalid. Inherited from an enclosing `Field` when not given. */
|
|
35
|
+
isInvalid?: boolean;
|
|
36
|
+
/** Disables every radio. Inherited from an enclosing `Field` when not given. */
|
|
37
|
+
isDisabled?: boolean;
|
|
38
|
+
className?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Groups radios and owns which one is selected.
|
|
43
|
+
*
|
|
44
|
+
* Selection is `selected` plus `onSelected`, or `defaultSelected` alone to let
|
|
45
|
+
* the group hold its own. `size` and `variant` belong to the group outright — a
|
|
46
|
+
* group whose options were different sizes is not a design — while `isDisabled`
|
|
47
|
+
* and `isInvalid` are published raw, so one option can still disable itself.
|
|
48
|
+
*
|
|
49
|
+
* It renders no legend, description or error. `Field` already owns all three, and
|
|
50
|
+
* a second definition of a label is a type scale that can drift — the trade
|
|
51
|
+
* `Input` made. That does leave the group without an accessible name, since React
|
|
52
|
+
* Native has no `aria-labelledby` to tie it to the `Field.Label` above it, so
|
|
53
|
+
* pass `accessibilityLabel`.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* <Radio.Group onSelected={setPlan} selected={plan ?? null} accessibilityLabel="Plan">
|
|
57
|
+
* <Radio value="free"><Radio.Label>Free</Radio.Label></Radio>
|
|
58
|
+
* <Radio value="pro"><Radio.Label>Pro</Radio.Label></Radio>
|
|
59
|
+
* </Radio.Group>
|
|
60
|
+
*/
|
|
61
|
+
export function RadioGroup({
|
|
62
|
+
selected: selectedProp,
|
|
63
|
+
defaultSelected,
|
|
64
|
+
onSelected,
|
|
65
|
+
size = "md",
|
|
66
|
+
variant = "primary",
|
|
67
|
+
orientation = "vertical",
|
|
68
|
+
isInvalid,
|
|
69
|
+
isDisabled,
|
|
70
|
+
className,
|
|
71
|
+
...props
|
|
72
|
+
}: RadioGroupProps): ReactElement {
|
|
73
|
+
const field = useFieldContext();
|
|
74
|
+
|
|
75
|
+
// Memoised, or `useControllableState`'s setter rebuilds every render, which
|
|
76
|
+
// rebuilds the context value, which re-renders every radio in the group on
|
|
77
|
+
// every render of whatever holds it.
|
|
78
|
+
const handleChange = useCallback(
|
|
79
|
+
(next: string | null) => {
|
|
80
|
+
if (next !== null) onSelected?.(next);
|
|
81
|
+
},
|
|
82
|
+
[onSelected]
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const [selected, setSelected] = useControllableState<string | null>({
|
|
86
|
+
value: selectedProp,
|
|
87
|
+
defaultValue: defaultSelected ?? null,
|
|
88
|
+
onChange: handleChange,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const select = useCallback(
|
|
92
|
+
(next: string) => {
|
|
93
|
+
if (shouldEmitSelection(selected, next)) setSelected(next);
|
|
94
|
+
},
|
|
95
|
+
[selected, setSelected]
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Deliberately not defaulted in the destructure — `false` there would swallow
|
|
99
|
+
// the field before it was ever consulted. See `input-group.tsx`.
|
|
100
|
+
const resolvedIsInvalid = isInvalid ?? field?.isInvalid;
|
|
101
|
+
const resolvedIsDisabled = isDisabled ?? field?.isDisabled;
|
|
102
|
+
|
|
103
|
+
const context = useMemo<RadioGroupContextValue>(
|
|
104
|
+
() => ({
|
|
105
|
+
isDisabled: resolvedIsDisabled,
|
|
106
|
+
isInvalid: resolvedIsInvalid,
|
|
107
|
+
orientation,
|
|
108
|
+
select,
|
|
109
|
+
selected,
|
|
110
|
+
size,
|
|
111
|
+
variant,
|
|
112
|
+
}),
|
|
113
|
+
[resolvedIsDisabled, resolvedIsInvalid, orientation, select, selected, size, variant]
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<RadioGroupProvider value={context}>
|
|
118
|
+
<View
|
|
119
|
+
accessibilityRole="radiogroup"
|
|
120
|
+
className={radioVariants({ orientation, size }).group({ className })}
|
|
121
|
+
{...props}
|
|
122
|
+
/>
|
|
123
|
+
</RadioGroupProvider>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
RadioGroup.displayName = "DelacourUI.Radio.Group";
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { type ComponentProps, type ReactElement, type ReactNode, useEffect } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated";
|
|
4
|
+
import { useRadioPart } from "./radio.context";
|
|
5
|
+
import { RADIO_DOT_SPRING, radioVariants } from "./radio.variants";
|
|
6
|
+
|
|
7
|
+
export type RadioIndicatorChildrenProps = {
|
|
8
|
+
isSelected: boolean;
|
|
9
|
+
isInvalid: boolean;
|
|
10
|
+
size: "lg" | "md" | "sm";
|
|
11
|
+
variant: "primary" | "secondary";
|
|
12
|
+
};
|
|
13
|
+
export type RadioIndicatorProps = Omit<ViewProps, "children"> & {
|
|
14
|
+
className?: string;
|
|
15
|
+
/** Replaces the dot. The ring, its size and its colours are still the radio's. */
|
|
16
|
+
children?: ReactNode | ((props: RadioIndicatorChildrenProps) => ReactNode);
|
|
17
|
+
/** Props for the dot's own `Animated.View`, for a caller restyling it in place. */
|
|
18
|
+
dotProps?: Omit<ComponentProps<typeof Animated.View>, "children" | "className"> & { className?: string };
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The circle: a ring, and the dot that scales into it when the radio is selected.
|
|
23
|
+
*
|
|
24
|
+
* Composed in automatically as the radio's first child, so write it out by hand
|
|
25
|
+
* only to move it — a trailing indicator is `<Radio.Indicator />` placed last —
|
|
26
|
+
* or to replace the dot with children of your own.
|
|
27
|
+
*
|
|
28
|
+
* **Drawn from `View`s rather than a Central Icon.** Rule 5 governs icons, and
|
|
29
|
+
* `Spinner`'s arc is the precedent for primitives; here the set has no
|
|
30
|
+
* ring-with-a-centred-dot glyph at all. Two things follow from drawing it: the
|
|
31
|
+
* dot can scale from the ring's centre entirely on the UI thread, and the ring's
|
|
32
|
+
* four themed colours stay classes in `radio.variants.ts` where `bun test`
|
|
33
|
+
* reaches them. An `Icon` would take its colour as a resolved value, splitting
|
|
34
|
+
* one decision across two mechanisms.
|
|
35
|
+
*
|
|
36
|
+
* **The animated style lives on the dot, never on the ring.** `Pressable`'s root
|
|
37
|
+
* `Animated.View` already owns `opacity` and `transform` through a
|
|
38
|
+
* `useAnimatedStyle` of its own, and two animated styles on one node fight for
|
|
39
|
+
* the same props. The dot is a descendant two levels down, so the two never
|
|
40
|
+
* contend — which is also why `Radio` takes no `asChild`.
|
|
41
|
+
*
|
|
42
|
+
* **Reduce motion is left at Reanimated's default `System`, deliberately the
|
|
43
|
+
* opposite call to `Spinner`'s `ReduceMotion.Never`.** The spinner needs `Never`
|
|
44
|
+
* because a zero-length animation inside `withRepeat(-1)` would spin forever.
|
|
45
|
+
* Here the state is carried by the dot's *presence*, not by its motion, so
|
|
46
|
+
* `System` snapping straight to the target is exactly the right degradation.
|
|
47
|
+
*/
|
|
48
|
+
export function RadioIndicator({ className, children, dotProps, ...props }: RadioIndicatorProps): ReactElement {
|
|
49
|
+
const { size, variant, isSelected, isInvalid } = useRadioPart("Radio.Indicator");
|
|
50
|
+
// Seeded from the current state rather than from zero, so a group that mounts
|
|
51
|
+
// with a selection does not animate every dot in on its first paint.
|
|
52
|
+
const progress = useSharedValue(isSelected ? 1 : 0);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
progress.value = withSpring(isSelected ? 1 : 0, RADIO_DOT_SPRING);
|
|
56
|
+
}, [isSelected, progress]);
|
|
57
|
+
|
|
58
|
+
// Both branches animate: the outgoing dot shrinks rather than vanishing, which
|
|
59
|
+
// is what makes a group read as one selection moving between rows.
|
|
60
|
+
const dotStyle = useAnimatedStyle(() => ({
|
|
61
|
+
opacity: progress.value,
|
|
62
|
+
transform: [{ scale: progress.value }],
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
const slots = radioVariants({ isInvalid, isSelected, size, variant });
|
|
66
|
+
|
|
67
|
+
const renderChildren = () => {
|
|
68
|
+
if (typeof children === "function") {
|
|
69
|
+
return children({ isSelected, isInvalid, size, variant });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (children) {
|
|
73
|
+
return children;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return null;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<View className={slots.indicator({ className })} {...props}>
|
|
81
|
+
{renderChildren() ?? (
|
|
82
|
+
<Animated.View
|
|
83
|
+
{...dotProps}
|
|
84
|
+
className={slots.dot({ className: dotProps?.className })}
|
|
85
|
+
style={[dotStyle, dotProps?.style]}
|
|
86
|
+
/>
|
|
87
|
+
)}
|
|
88
|
+
</View>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
RadioIndicator.displayName = "DelacourUI.Radio.Indicator";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { Text, type TextPresetProps } from "../text";
|
|
3
|
+
import { useRadioPart } from "./radio.context";
|
|
4
|
+
import { RADIO_LABEL_TEXT_SIZE, radioVariants } from "./radio.variants";
|
|
5
|
+
|
|
6
|
+
export type RadioLabelProps = TextPresetProps;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The radio's text, inside the radio's own tap target.
|
|
10
|
+
*
|
|
11
|
+
* Not a duplicate of `Field.Label`, and the distinction is the point: a
|
|
12
|
+
* `Field.Label` names the whole control from outside it, while this sits *within*
|
|
13
|
+
* one radio's press area, so tapping the word selects that option.
|
|
14
|
+
*
|
|
15
|
+
* Renders `Text.Label` and names a size, never a scale of its own. The weight and
|
|
16
|
+
* the colour belong to the preset — restating them here would be a second
|
|
17
|
+
* definition of `Text.Label` that could drift from it, the rule `Field` is built
|
|
18
|
+
* on. Naming the size is what lets the label still track the radio's own axis,
|
|
19
|
+
* since `Text`'s size axis is built to beat its preset.
|
|
20
|
+
*
|
|
21
|
+
* **It does not redden while invalid.** The ring already carries that, and a
|
|
22
|
+
* `Field.Error` under the group says what is actually wrong; five labels turning
|
|
23
|
+
* red would read as five wrong answers. Disabled needs nothing here either: the
|
|
24
|
+
* `label` slot carries its own `opacity-50`, and the `indicator` slot carries a
|
|
25
|
+
* matching one, so the ring and the label fade together as one control. Neither
|
|
26
|
+
* fade may move to the `root` — see {@link radioVariants}.
|
|
27
|
+
*/
|
|
28
|
+
export function RadioLabel({ className, size, ...props }: RadioLabelProps): ReactElement {
|
|
29
|
+
const { size: radioSize, variant, isSelected, isInvalid } = useRadioPart("Radio.Label");
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<Text.Label
|
|
33
|
+
className={radioVariants({ isInvalid, isSelected, size: radioSize, variant }).label({ className })}
|
|
34
|
+
size={size ?? RADIO_LABEL_TEXT_SIZE[radioSize]}
|
|
35
|
+
{...props}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
RadioLabel.displayName = "DelacourUI.Radio.Label";
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createContext, type ReactElement, type ReactNode, use } from "react";
|
|
2
|
+
import type { RadioOrientation, RadioSize, RadioVariant } from "./radio.variants";
|
|
3
|
+
|
|
4
|
+
export type RadioGroupContextValue = {
|
|
5
|
+
/** The selected radio's value. `null` is "nothing selected", never `undefined`. */
|
|
6
|
+
selected: string | null;
|
|
7
|
+
/** What a radio calls on press. Stays quiet when the value is already selected. */
|
|
8
|
+
select: (value: string) => void;
|
|
9
|
+
/** Size of every radio in the group. The group owns this axis outright. */
|
|
10
|
+
size: RadioSize;
|
|
11
|
+
/** How every ring in the group is painted. The group owns this axis outright. */
|
|
12
|
+
variant: RadioVariant;
|
|
13
|
+
/** Which way the group lays its radios out. */
|
|
14
|
+
orientation: RadioOrientation;
|
|
15
|
+
/**
|
|
16
|
+
* Raw rather than resolved, and deliberately so.
|
|
17
|
+
*
|
|
18
|
+
* `Input.Group` publishes these settled because it draws a box whose state it
|
|
19
|
+
* has to paint. A radio group paints nothing, and it holds many radios — so
|
|
20
|
+
* leaving `undefined` to mean "the group said nothing" is what lets a single
|
|
21
|
+
* option disable itself. A group that does name the axis still wins outright.
|
|
22
|
+
*/
|
|
23
|
+
isDisabled?: boolean;
|
|
24
|
+
isInvalid?: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type RadioContextValue = {
|
|
28
|
+
/** Size of the radio, already settled from group, own props and field. */
|
|
29
|
+
size: RadioSize;
|
|
30
|
+
/** How the ring is painted, already settled. */
|
|
31
|
+
variant: RadioVariant;
|
|
32
|
+
/** Whether this radio is the selection. */
|
|
33
|
+
isSelected: boolean;
|
|
34
|
+
/** Whether this radio is unavailable. */
|
|
35
|
+
isDisabled: boolean;
|
|
36
|
+
/** Whether this radio reports an invalid value. */
|
|
37
|
+
isInvalid: boolean;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const RadioGroupContext = createContext<RadioGroupContextValue | null>(null);
|
|
41
|
+
const RadioContext = createContext<RadioContextValue | null>(null);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Supplies the enclosing group's selection and axes to the radios inside it.
|
|
45
|
+
*
|
|
46
|
+
* Lives in its own module, importing nothing but React and types, so a part can
|
|
47
|
+
* read it without importing `./radio` or `./radio-group`. That import would close
|
|
48
|
+
* a cycle, and Metro serves a partially initialised module for a cycle — leaving
|
|
49
|
+
* the context `undefined` at import time and red-boxing the app on a cold start.
|
|
50
|
+
*/
|
|
51
|
+
export function RadioGroupProvider({
|
|
52
|
+
value,
|
|
53
|
+
children,
|
|
54
|
+
}: {
|
|
55
|
+
value: RadioGroupContextValue;
|
|
56
|
+
children: ReactNode;
|
|
57
|
+
}): ReactElement {
|
|
58
|
+
return <RadioGroupContext value={value}>{children}</RadioGroupContext>;
|
|
59
|
+
}
|
|
60
|
+
RadioGroupProvider.displayName = "DelacourUI.Radio.Group.Provider";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Supplies one radio's settled state to its own parts.
|
|
64
|
+
*
|
|
65
|
+
* What the parts read is always the *resolved* state, so `Radio.Indicator` never
|
|
66
|
+
* has to know whether it is inside a group, never reads a `Field`, and never
|
|
67
|
+
* imports the root.
|
|
68
|
+
*/
|
|
69
|
+
export function RadioProvider({ value, children }: { value: RadioContextValue; children: ReactNode }): ReactElement {
|
|
70
|
+
return <RadioContext value={value}>{children}</RadioContext>;
|
|
71
|
+
}
|
|
72
|
+
RadioProvider.displayName = "DelacourUI.Radio.Provider";
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The enclosing group's context, or null outside a `<Radio.Group>`.
|
|
76
|
+
*
|
|
77
|
+
* This is the export the radio root reads: it is nullable because a radio has to
|
|
78
|
+
* work perfectly well on its own, the same way `useFieldContext` is nullable for
|
|
79
|
+
* every control that can stand outside a `Field`.
|
|
80
|
+
*/
|
|
81
|
+
export function useRadioGroupContext(): RadioGroupContextValue | null {
|
|
82
|
+
return use(RadioGroupContext);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Reads the enclosing group's selection and axes.
|
|
87
|
+
*
|
|
88
|
+
* For a custom control that has to match the group it sits in. Throws outside
|
|
89
|
+
* one — use {@link useRadioGroupContext} where the group is optional.
|
|
90
|
+
*/
|
|
91
|
+
export function useRadioGroup(): RadioGroupContextValue {
|
|
92
|
+
const context = useRadioGroupContext();
|
|
93
|
+
if (!context) {
|
|
94
|
+
throw new Error("useRadioGroup must be called inside a <Radio.Group>.");
|
|
95
|
+
}
|
|
96
|
+
return context;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The enclosing radio's settled state, or null outside a `<Radio>`. */
|
|
100
|
+
export function useRadioContext(): RadioContextValue | null {
|
|
101
|
+
return use(RadioContext);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Reads the enclosing radio's settled state.
|
|
106
|
+
*
|
|
107
|
+
* Lets a custom child style itself to match without the radio having to pass
|
|
108
|
+
* props down through every slot. Throws outside a `<Radio>` — use
|
|
109
|
+
* {@link useRadioContext} where the enclosing radio is optional.
|
|
110
|
+
*/
|
|
111
|
+
export function useRadio(): RadioContextValue {
|
|
112
|
+
const context = useRadioContext();
|
|
113
|
+
if (!context) {
|
|
114
|
+
throw new Error("useRadio must be called inside a <Radio>.");
|
|
115
|
+
}
|
|
116
|
+
return context;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The enclosing radio's state, for a compound part that cannot work without one.
|
|
121
|
+
*
|
|
122
|
+
* Internal: deliberately not re-exported from `index.ts`. A caller outside the
|
|
123
|
+
* library wants {@link useRadio}, whose error message names the hook rather than
|
|
124
|
+
* a part.
|
|
125
|
+
*/
|
|
126
|
+
export function useRadioPart(component: string): RadioContextValue {
|
|
127
|
+
const context = useRadioContext();
|
|
128
|
+
if (!context) {
|
|
129
|
+
throw new Error(`${component} must be rendered inside a <Radio>.`);
|
|
130
|
+
}
|
|
131
|
+
return context;
|
|
132
|
+
}
|