@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,125 @@
|
|
|
1
|
+
import { useBottomSheetInternal } from "@gorhom/bottom-sheet";
|
|
2
|
+
import { type RefCallback, useCallback, useRef } from "react";
|
|
3
|
+
import { type BlurEvent, type FocusEvent, findNodeHandle, TextInput } from "react-native";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `findNodeHandle` for a value React Native's own types do not admit.
|
|
7
|
+
*
|
|
8
|
+
* `TextInput.State.currentlyFocusedInput()` returns a Fabric host instance, and
|
|
9
|
+
* the shipped signature still describes the pre-Fabric parameter — the runtime
|
|
10
|
+
* accepts it, which is what gorhom's own `BottomSheetTextInput` relies on. One
|
|
11
|
+
* cast, in one place, rather than at each call.
|
|
12
|
+
*/
|
|
13
|
+
function nodeHandleOf(instance: unknown): number | null {
|
|
14
|
+
return findNodeHandle(instance as Parameters<typeof findNodeHandle>[0]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type BottomSheetInputHandlers = {
|
|
18
|
+
onFocus: (event: FocusEvent) => void;
|
|
19
|
+
onBlur: (event: BlurEvent) => void;
|
|
20
|
+
ref: RefCallback<TextInput | null>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Makes this package's `Input` behave like a text field the sheet owns.
|
|
25
|
+
*
|
|
26
|
+
* Spread the result onto any `TextInput` inside a `BottomSheet`:
|
|
27
|
+
*
|
|
28
|
+
* ```tsx
|
|
29
|
+
* <Input {...useBottomSheetInput()} placeholder="Name" />
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* A sheet only knows to grow for the keyboard when it knows the focused field is
|
|
33
|
+
* one of its own. gorhom ships `BottomSheetTextInput` to register that, but
|
|
34
|
+
* reaching for it here would mean this library's `Input` — its variants, its
|
|
35
|
+
* `Input.Group`, its invalid and disabled cascade — could not be used inside a
|
|
36
|
+
* sheet at all. So the registration is exposed as handlers instead, and `Input`
|
|
37
|
+
* keeps knowing nothing about `@gorhom/bottom-sheet`.
|
|
38
|
+
*
|
|
39
|
+
* Three props, and all three are load-bearing:
|
|
40
|
+
*
|
|
41
|
+
* - `ref` registers the field's native node with the sheet. Without it, moving
|
|
42
|
+
* focus from one field in the sheet to the next reads as the keyboard closing
|
|
43
|
+
* and reopening, and the sheet resizes twice on the way.
|
|
44
|
+
* - `onFocus` tells the sheet which field to keep clear of the keyboard.
|
|
45
|
+
* - `onBlur` gives that up again, unless focus went to another field in the same
|
|
46
|
+
* sheet.
|
|
47
|
+
*
|
|
48
|
+
* Called outside a sheet it returns inert handlers rather than throwing, so a
|
|
49
|
+
* form component shared between a screen and a sheet needs no branch.
|
|
50
|
+
*
|
|
51
|
+
* It covers the keyboard half only. A *drag* across a field can still be claimed
|
|
52
|
+
* by the sheet's content pan, because gorhom's input is built on Gesture
|
|
53
|
+
* Handler's `TextInput` and this one is not; if that ever bites, the fix is
|
|
54
|
+
* `enableContentPanningGesture={false}` on the `BottomSheet.Container`. The
|
|
55
|
+
* *tap* half — a scrim that used to swallow the press meant for the field — is
|
|
56
|
+
* solved in `BottomSheet.Overlay`, not here.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* <Field>
|
|
60
|
+
* <Field.Label>Name</Field.Label>
|
|
61
|
+
* <Input {...useBottomSheetInput()} onChangeText={setName} value={name} />
|
|
62
|
+
* </Field>
|
|
63
|
+
*/
|
|
64
|
+
export function useBottomSheetInput(): BottomSheetInputHandlers {
|
|
65
|
+
const internal = useBottomSheetInternal(true);
|
|
66
|
+
// The node this field last registered, so the ref can withdraw exactly it.
|
|
67
|
+
// A JS ref rather than a shared value: nothing renders differently for it.
|
|
68
|
+
const registeredNodeRef = useRef<number | null>(null);
|
|
69
|
+
|
|
70
|
+
const ref = useCallback<RefCallback<TextInput | null>>(
|
|
71
|
+
(instance) => {
|
|
72
|
+
const nodes = internal?.textInputNodesRef.current;
|
|
73
|
+
const previous = registeredNodeRef.current;
|
|
74
|
+
|
|
75
|
+
if (previous !== null) {
|
|
76
|
+
nodes?.delete(previous);
|
|
77
|
+
registeredNodeRef.current = null;
|
|
78
|
+
if (internal !== null && internal.animatedKeyboardState.get().target === previous) {
|
|
79
|
+
internal.animatedKeyboardState.set((state) => ({ ...state, target: undefined }));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (instance === null || nodes === undefined) return;
|
|
84
|
+
|
|
85
|
+
const node = nodeHandleOf(instance);
|
|
86
|
+
if (node === null) return;
|
|
87
|
+
|
|
88
|
+
nodes.add(node);
|
|
89
|
+
registeredNodeRef.current = node;
|
|
90
|
+
},
|
|
91
|
+
[internal]
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const onFocus = useCallback(
|
|
95
|
+
(event: FocusEvent) => {
|
|
96
|
+
if (internal === null) return;
|
|
97
|
+
const target = event.nativeEvent.target;
|
|
98
|
+
internal.animatedKeyboardState.set((state) => ({ ...state, target }));
|
|
99
|
+
},
|
|
100
|
+
[internal]
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const onBlur = useCallback(
|
|
104
|
+
(event: BlurEvent) => {
|
|
105
|
+
if (internal === null) return;
|
|
106
|
+
|
|
107
|
+
const target = event.nativeEvent.target;
|
|
108
|
+
const isThisFieldsTarget = internal.animatedKeyboardState.get().target === target;
|
|
109
|
+
if (!isThisFieldsTarget) return;
|
|
110
|
+
|
|
111
|
+
// React Native's own focus registry, not the sheet's: focus may already
|
|
112
|
+
// have moved to the next field in the same sheet, and giving the target
|
|
113
|
+
// up there would close and reopen the sheet's keyboard allowance between
|
|
114
|
+
// two taps that never dismissed the keyboard.
|
|
115
|
+
const focused = TextInput.State.currentlyFocusedInput();
|
|
116
|
+
const focusedNode = focused === null ? null : nodeHandleOf(focused);
|
|
117
|
+
if (focusedNode !== null && internal.textInputNodesRef.current.has(focusedNode)) return;
|
|
118
|
+
|
|
119
|
+
internal.animatedKeyboardState.set((state) => ({ ...state, target: undefined }));
|
|
120
|
+
},
|
|
121
|
+
[internal]
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
return { onBlur, onFocus, ref };
|
|
125
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# Button
|
|
2
|
+
|
|
3
|
+
A pressable action, composed from parts rather than configured by flags —
|
|
4
|
+
compound root plus `Button.Label`, `Button.StartContent` and `Button.EndContent`,
|
|
5
|
+
and `Button.Group` for joining several into one run.
|
|
6
|
+
The reference implementation for the patterns in the package
|
|
7
|
+
[AGENTS.md](../../../AGENTS.md).
|
|
8
|
+
|
|
9
|
+
`import { Button } from "@delacour/react-native-ui/button";`
|
|
10
|
+
|
|
11
|
+
## Files
|
|
12
|
+
|
|
13
|
+
| File | What it holds |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| `index.ts` | → `@delacour/react-native-ui/button` |
|
|
16
|
+
| `button.tsx` | Root + the `Object.assign` compound surface |
|
|
17
|
+
| `button-label.tsx` | `Button.Label` |
|
|
18
|
+
| `button-start-content.tsx` | `Button.StartContent` |
|
|
19
|
+
| `button-end-content.tsx` | `Button.EndContent` |
|
|
20
|
+
| `button-group.tsx` | `Button.Group` + its `Object.assign` compound surface |
|
|
21
|
+
| `button-group-separator.tsx` | `Button.Group.Separator` |
|
|
22
|
+
| `button-group-text.tsx` | `Button.Group.Text` |
|
|
23
|
+
| `button.context.tsx` | `ButtonContext` and the two group contexts, with their hooks |
|
|
24
|
+
| `button.types.ts` | Prop types shared by two or more parts |
|
|
25
|
+
| `button.variants.ts` | Pure `tv()` slots, no RN imports |
|
|
26
|
+
| `button.variants.test.ts` | |
|
|
27
|
+
|
|
28
|
+
## Design
|
|
29
|
+
|
|
30
|
+
- **Variants**: `primary`, `secondary`, `tertiary`, `outline`, `ghost`,
|
|
31
|
+
`destructive`, `destructive-soft`. **Sizes**: `sm`, `md`, `lg`, and the square
|
|
32
|
+
`icon-sm`, `icon-md`, `icon-lg`.
|
|
33
|
+
- **Icons are composed, never passed as props.** Put an `Icon` in the children,
|
|
34
|
+
before or after the label. The button wraps its subtree in an
|
|
35
|
+
`IconDefaultsProvider` carrying `buttonVariants({ size }).icon()` and
|
|
36
|
+
`BUTTON_FOREGROUND_TOKEN[variant]`, so a bare `<Icon icon={IconPlus} />`
|
|
37
|
+
comes out the right size and colour with nothing said at the call site. An
|
|
38
|
+
explicit `size` or `color` on the icon still wins. `Button.StartContent` /
|
|
39
|
+
`Button.EndContent` remain for wrapping non-icon content.
|
|
40
|
+
- **The corner is a token on the size axis crossed with the group axis, and
|
|
41
|
+
nothing else sets one.** A button standing alone names
|
|
42
|
+
`rounded-button-{step}` — half its own height, so it is a capsule, and a
|
|
43
|
+
circle at a square size. Keeping it out of the base and off every variant
|
|
44
|
+
means exactly one corner *statement* reaches the root, so a caller's
|
|
45
|
+
`className="rounded-lg"` has a single thing to beat and tailwind-merge is
|
|
46
|
+
never picking between two. The shape is a default rather than a law: three
|
|
47
|
+
numbers in `tokens.css` square the whole kit off without touching a
|
|
48
|
+
component. Values above half the height are clamped by the renderer, which is
|
|
49
|
+
why `tokens.test.ts` holds them under it.
|
|
50
|
+
- **A square footprint is a size, not a flag.** `icon-sm` / `icon-md` /
|
|
51
|
+
`icon-lg` are `sm` / `md` / `lg` with the horizontal padding traded for a width
|
|
52
|
+
off the same token. Padding and width are then mutually exclusive by
|
|
53
|
+
construction rather than by rule, which is what let an `isIconOnly` axis and
|
|
54
|
+
six compound variants reconciling it against `size` be deleted outright. It is
|
|
55
|
+
also shadcn's spelling (rule 11), so `size="icon-md"` pastes across. Always
|
|
56
|
+
pair one with an `accessibilityLabel`; there is no text for a screen reader to
|
|
57
|
+
fall back on.
|
|
58
|
+
- **String children** are wrapped in a `Button.Label` automatically. React
|
|
59
|
+
Native crashes on bare text outside a `<Text>`, so never render a raw string
|
|
60
|
+
in a component that accepts free-form children without doing the same. Note
|
|
61
|
+
that *consecutive* strings collapse into one label — `Row {i}` is a single
|
|
62
|
+
piece of text, and wrapping each part separately would space them apart by
|
|
63
|
+
the button's own `gap`.
|
|
64
|
+
- **A button is a [`Pressable`](../pressable/AGENTS.md).** `ButtonProps` extends
|
|
65
|
+
`PressableProps`, so `feedback`, `haptic` and the rest are inherited rather
|
|
66
|
+
than restated; only the default differs, `scale`. It carried a narrowed
|
|
67
|
+
`ButtonFeedback` union for a while — that is gone, because a second definition
|
|
68
|
+
of a prop the button does not change is a definition that can drift. **Do not
|
|
69
|
+
add ripple, ink, glow or highlight overlays** — no wash layers on pressables
|
|
70
|
+
in this library. That rule is about wash layers, not the opacity axis, which
|
|
71
|
+
`fade` and `scale-fade` are welcome to use.
|
|
72
|
+
- **`isLoading` replaces the icon on its own side, it does not join it.** The
|
|
73
|
+
spinner takes the place of the composed `Icon` at the edge `spinnerPlacement`
|
|
74
|
+
names — the first child at `start`, the last at `end` — so the label does not
|
|
75
|
+
shift when work begins and shift back when it ends. The swap costs no layout
|
|
76
|
+
because both glyphs are drawn at the button's own `size-icon-*` token: the
|
|
77
|
+
root publishes one class and the icon and the spinner both read it. **An icon
|
|
78
|
+
on the other side is not taken.** A button with one leading icon and
|
|
79
|
+
`spinnerPlacement="end"` keeps the icon and gains a spinner at the end; taking
|
|
80
|
+
the only icon whichever side it sat on used to draw the spinner opposite the
|
|
81
|
+
side that was asked for. With no icon at that edge the spinner is inserted, as
|
|
82
|
+
it always was. Only a bare `Icon` is swapped; a `Button.StartContent` wraps
|
|
83
|
+
content of unknown height, and replacing one could resize the button.
|
|
84
|
+
`resolveSpinnerSwapIndex` is the pure decision and is unit-tested.
|
|
85
|
+
- **`isLoading`** composes a `Spinner` in and blocks presses. Placement is
|
|
86
|
+
`spinnerPlacement`: `start` (default), `end`, or `only` — which drops the
|
|
87
|
+
children and centres the spinner in the footprint the button already has,
|
|
88
|
+
carrying the label onto `accessibilityLabel` so a screen reader still has a
|
|
89
|
+
name to read. `only` never changes the footprint — a square is a `size`, and
|
|
90
|
+
loading cannot reach one; use `size="icon-md"` when a square is what you want.
|
|
91
|
+
- **Loading is not disabled.** `isLoading` blocks the press and announces the
|
|
92
|
+
button as *busy*, but keeps full contrast: the spinner already says the press
|
|
93
|
+
landed, and dimming reads as "this control is unavailable". `isDimmedWhileLoading`
|
|
94
|
+
opts into the faded treatment where a caller wants it.
|
|
95
|
+
- **A stretched button does not change width while loading**, because `only`
|
|
96
|
+
keeps its footprint. In a *content-width* container — a `flex-row` — it still
|
|
97
|
+
shrinks to the spinner, and that snap is un-animated on purpose: Pressable's
|
|
98
|
+
`Animated.View` already runs a `useAnimatedStyle` on `opacity` and `transform`,
|
|
99
|
+
and a native layout transition on the same view fights it for prop ownership.
|
|
100
|
+
A caller who needs a stable width in a row pins it (`w-full`, `min-w-*`).
|
|
101
|
+
- **A definite width defeats `alignItems: stretch`.** A stretch-aligned child
|
|
102
|
+
with a definite cross size resolves to cross-*start*, not centre — so any
|
|
103
|
+
control that conditionally takes a fixed width inside a gap column jumps to
|
|
104
|
+
the left edge unless it also sets `self-center`. This is what made an earlier
|
|
105
|
+
`only` implementation collapse a full-width button into a small box flush
|
|
106
|
+
left. Prefer not taking the width at all.
|
|
107
|
+
|
|
108
|
+
## Button.Group
|
|
109
|
+
|
|
110
|
+
Several controls joined into one run: `<Button.Group>` around the buttons, with
|
|
111
|
+
`Button.Group.Separator` for a rule between two of them and `Button.Group.Text`
|
|
112
|
+
for a chunk that says something rather than doing something. An
|
|
113
|
+
[`Input`](../input/AGENTS.md) joins the same way.
|
|
114
|
+
|
|
115
|
+
- **Position is computed in JavaScript, because React Native has no sibling
|
|
116
|
+
selector.** shadcn/ui squares a web button group's inner corners with
|
|
117
|
+
`[&>[data-slot]~[data-slot]]:rounded-l-none` and drops the seam border with
|
|
118
|
+
`border-l-0`. Neither selector exists here, and no parent `View` can reach a
|
|
119
|
+
child's style. So the root walks its children, works out each member's place
|
|
120
|
+
with `resolveGroupPositions`, and publishes it through a per-child context the
|
|
121
|
+
member reads. A provider is not a host component, so nothing is added to the
|
|
122
|
+
layout — Yoga sees exactly the children the group's `View` already had.
|
|
123
|
+
- **A member wrapped in a context, never cloned with props.** `cloneElement`
|
|
124
|
+
reaches only a *direct* child, so a member a caller wrapped in a `View` or
|
|
125
|
+
produced from a helper would silently keep its round corners. Context reaches
|
|
126
|
+
any depth, which is also what lets a control this package has never heard of
|
|
127
|
+
join a run by reading `useButtonGroupItem()`.
|
|
128
|
+
- **A joined member replaces its corner; it never layers a squaring class over
|
|
129
|
+
it.** This is the subtle one. Layering *renders* correctly — Uniwind
|
|
130
|
+
arbitrates a className string per style property in token order, and React
|
|
131
|
+
Native's corner cascade puts a per-corner radius above the uniform one — but
|
|
132
|
+
tailwind-merge annihilates a side class the moment any all-corner class is
|
|
133
|
+
emitted after it, and `tv` emits variants in declaration order. A surviving
|
|
134
|
+
`rounded-button-md` on the size axis would therefore delete the squaring pair
|
|
135
|
+
as soon as someone reordered two keys in `buttonVariants`, with nothing to see
|
|
136
|
+
in the diff. `cn.test.ts` pins the asymmetry in both directions.
|
|
137
|
+
- **Horizontal squares on the logical axis; vertical squares on the physical
|
|
138
|
+
one. Never mix them.** `rounded-s-*` / `rounded-e-*` compile to
|
|
139
|
+
`border-start-start-radius` and friends, survive lightningcss, and flip under
|
|
140
|
+
RTL — which is what a horizontal run wants. Tailwind's `rounded-t-*` /
|
|
141
|
+
`rounded-b-*` are physical and need no flip. React Native resolves a physical
|
|
142
|
+
corner *above* a logical one, so a stray `rounded-t-*` on a horizontal member
|
|
143
|
+
would silently outrank the `rounded-s-*` beside it and round the wrong edge. A
|
|
144
|
+
test asserts neither orientation emits the other's form.
|
|
145
|
+
- **The seam is a negative margin, not a dropped border.** Two adjacent
|
|
146
|
+
`outline` members would otherwise draw a two-point seam. `-ms-px` / `-mt-px`
|
|
147
|
+
pulls each member back over its neighbour so the two borders land as one
|
|
148
|
+
hairline. `border-s-0` was the alternative and is wrong twice: the base
|
|
149
|
+
reserves `border border-transparent` precisely so switching variant never
|
|
150
|
+
resizes the box, and dropping a point would make one member narrower than the
|
|
151
|
+
rest and shift its centred content half a point — and it would only be right
|
|
152
|
+
for the variants that draw a visible border, which the group cannot know
|
|
153
|
+
because a member may name its own. A margin moves the box and changes nothing
|
|
154
|
+
about it, so it is emitted for every seamed member regardless of paint.
|
|
155
|
+
- **A separator takes no position and suppresses the seam after it.** It is a
|
|
156
|
+
rule, not a segment, so the buttons either side of one are still the run's
|
|
157
|
+
first and last and keep their rounded outer corners. And the member following
|
|
158
|
+
it does not overlap: a one-point rule under a one-point overlap is an
|
|
159
|
+
invisible rule. `resolveGroupPositions` and `resolveGroupSeams` are two walks
|
|
160
|
+
rather than one because they ask different questions — "how many of us are
|
|
161
|
+
there" and "what is immediately before me" — and both are pure, so the whole
|
|
162
|
+
matrix is reachable from `bun test`.
|
|
163
|
+
- **Membership is a deny-list.** Anything that is not a separator is a member.
|
|
164
|
+
Not `child.type === Button`, for two reasons: it lets a custom control join,
|
|
165
|
+
and it means `button-group.tsx` never imports its own root for an identity
|
|
166
|
+
check — which would close a cycle (package AGENTS.md rule 3).
|
|
167
|
+
- **A joined member fades rather than scaling.** `feedback` defaults to `scale`
|
|
168
|
+
on a lone button, which pulls a member's edges in by a point and a half while
|
|
169
|
+
its neighbours hold still — the seam the group exists to close tears open for
|
|
170
|
+
the length of the press. `resolveButtonFeedback` runs the usual nearest-wins
|
|
171
|
+
ladder (`own ?? group ?? default`) and only changes the last rung, so a caller
|
|
172
|
+
still gets `scale` by asking for it on one button or on the whole group.
|
|
173
|
+
`??` rather than `||`, so an explicit `none` is honoured instead of read as an
|
|
174
|
+
absence. `pressedScale` still beats both — that is `resolvePressedState`'s
|
|
175
|
+
documented contract and a second rule contradicting it would be worse than the
|
|
176
|
+
tear.
|
|
177
|
+
- **The group owns the axes because it owns the shape.** `size` all but
|
|
178
|
+
outright, and `variant`, `isDisabled` and `feedback` as defaults a member may
|
|
179
|
+
override. Those three are published **raw**, so `undefined` means "the group
|
|
180
|
+
said nothing" and one member can disable itself inside a group that did not.
|
|
181
|
+
`Button`'s `variant` and `size` therefore come out of its destructure
|
|
182
|
+
*undefaulted*; a default there would swallow the group before it was ever
|
|
183
|
+
consulted.
|
|
184
|
+
- **A group owns a member's step, but not its shape.** Those are one axis now
|
|
185
|
+
that a square footprint is a size rather than a flag, so resolving `size` to
|
|
186
|
+
the group's value outright would make a square member impossible inside a run:
|
|
187
|
+
the icon button ending a split button would silently grow a label's padding
|
|
188
|
+
and lose its width. `resolveGroupedButtonSize` therefore takes the step from
|
|
189
|
+
the group — controls of different heights do not join — and the shape from the
|
|
190
|
+
member, falling back to the group's shape when the member names no size. An
|
|
191
|
+
`icon-md` member of an `sm` group comes out `icon-sm`: square, and the same
|
|
192
|
+
height as everything beside it. A control with no square form of its own reads
|
|
193
|
+
`resolveButtonSizeStep` instead, which is exactly what a joined `Input` does.
|
|
194
|
+
- **The group paints nothing.** No background, no border, no disabled fade. A
|
|
195
|
+
disabled group publishes `isDisabled` and each member fades itself; a group
|
|
196
|
+
fading as well would compound the two down to a quarter opacity. It carries no
|
|
197
|
+
`gap` either — a gap is the seam this component exists to close — and no
|
|
198
|
+
`overflow-hidden`, which would square off the very corners the position
|
|
199
|
+
compounds just rounded.
|
|
200
|
+
- **No `accessibilityRole` on the group.** Announcing a control with no action
|
|
201
|
+
in front of every member helps nobody; the members are already buttons.
|
|
202
|
+
- **Horizontal groups are content-width** (`self-start`), because that is what a
|
|
203
|
+
segmented control is. For a run that fills its parent, put `className="w-full"`
|
|
204
|
+
on the group and `className="flex-1"` on each member — **and make sure the
|
|
205
|
+
parent has a definite width**. Yoga resolves a percentage against the parent's
|
|
206
|
+
content box, so `w-full` inside a container that is itself content-sized
|
|
207
|
+
resolves to nothing and falls back to the content width: the group collapses
|
|
208
|
+
onto its buttons and a `flex-1` field between them is squeezed to a few points.
|
|
209
|
+
It looks like the group ignored `w-full`. It did not; there was no width to
|
|
210
|
+
take a percentage of.
|
|
211
|
+
- **`Button.Group.Text` draws the button's own chrome**, not chrome of its own,
|
|
212
|
+
which is what keeps its height, padding and corner identical to the buttons
|
|
213
|
+
beside it — those come off one axis, and restating any of them here is how a
|
|
214
|
+
row stops lining up three tokens later. It falls back to `secondary` rather
|
|
215
|
+
than `primary` when nothing names a variant: a chunk that cannot be pressed
|
|
216
|
+
should not wear the group's action paint. A group that *does* name a variant
|
|
217
|
+
is followed, so an outline run reads as one piece.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import type { ButtonSlotProps } from "./button.types";
|
|
4
|
+
import { buttonVariants } from "./button.variants";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A centred wrapper for trailing content that is not an `Icon`.
|
|
8
|
+
*
|
|
9
|
+
* An `Icon` needs no wrapper — it inherits the button's icon size and its
|
|
10
|
+
* variant's colour from the root's `IconDefaultsProvider`.
|
|
11
|
+
*/
|
|
12
|
+
export function ButtonEndContent({ className, ...props }: ButtonSlotProps): ReactElement {
|
|
13
|
+
return <View className={buttonVariants().endContent({ className })} {...props} />;
|
|
14
|
+
}
|
|
15
|
+
ButtonEndContent.displayName = "DelacourUI.Button.EndContent";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { Separator } from "../separator";
|
|
3
|
+
import { useButtonGroupPart } from "./button.context";
|
|
4
|
+
import { BUTTON_GROUP_SEPARATOR_ORIENTATION, buttonVariants } from "./button.variants";
|
|
5
|
+
|
|
6
|
+
export type ButtonGroupSeparatorProps = {
|
|
7
|
+
className?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A rule between two members of a group.
|
|
12
|
+
*
|
|
13
|
+
* Runs across the group rather than along it — a line drawn along the run would
|
|
14
|
+
* sit under the members instead of between them — so it reads the group's
|
|
15
|
+
* orientation and inverts it. That inversion is a map in `button.variants.ts`
|
|
16
|
+
* rather than a `SeparatorOrientation` here, because that type lives beside a
|
|
17
|
+
* React Native import and the variants file has to stay parseable by `bun test`.
|
|
18
|
+
*
|
|
19
|
+
* A separator is deliberately **not** a member: it consumes no position, so the
|
|
20
|
+
* buttons either side of one are still the group's first and last and keep their
|
|
21
|
+
* rounded outer corners. It also suppresses the seam of the member after it — a
|
|
22
|
+
* one-point rule under a one-point overlap would be an invisible rule.
|
|
23
|
+
*
|
|
24
|
+
* The line takes its length from this wrapper, since a `Separator` is
|
|
25
|
+
* `self-stretch` on its long axis rather than a percentage of its parent.
|
|
26
|
+
*/
|
|
27
|
+
export function ButtonGroupSeparator({ className }: ButtonGroupSeparatorProps): ReactElement {
|
|
28
|
+
const { orientation } = useButtonGroupPart("Button.Group.Separator");
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<Separator
|
|
32
|
+
className={buttonVariants({ orientation }).groupSeparator({ className })}
|
|
33
|
+
orientation={BUTTON_GROUP_SEPARATOR_ORIENTATION[orientation]}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
ButtonGroupSeparator.displayName = "DelacourUI.Button.Group.Separator";
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Children, type ReactElement, type ReactNode } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
import { IconDefaultsProvider } from "../icon";
|
|
4
|
+
import { Text } from "../text";
|
|
5
|
+
import { TextClassProvider } from "../text/text.context";
|
|
6
|
+
import { useButtonGroupItemContext } from "./button.context";
|
|
7
|
+
import {
|
|
8
|
+
BUTTON_FOREGROUND_TOKEN,
|
|
9
|
+
type ButtonVariant,
|
|
10
|
+
buttonVariants,
|
|
11
|
+
resolveGroupedButtonSize,
|
|
12
|
+
} from "./button.variants";
|
|
13
|
+
|
|
14
|
+
export type ButtonGroupTextProps = ViewProps & {
|
|
15
|
+
/** Paint for the chunk. Falls back to the group's, then to `secondary`. */
|
|
16
|
+
variant?: ButtonVariant;
|
|
17
|
+
className?: string;
|
|
18
|
+
children?: ReactNode;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A chunk of a group that says something rather than doing something.
|
|
23
|
+
*
|
|
24
|
+
* A member like any other — it reads the same item context a `Button` does, so
|
|
25
|
+
* it squares the corners crossing its seams and overlaps its neighbour without
|
|
26
|
+
* knowing anything about where it sits.
|
|
27
|
+
*
|
|
28
|
+
* It draws the button's own chrome rather than chrome of its own, which is what
|
|
29
|
+
* keeps its height, padding and corner identical to the buttons beside it: those
|
|
30
|
+
* five values come off one axis in `buttonVariants`, and restating any of them
|
|
31
|
+
* here is how a row stops lining up three tokens later.
|
|
32
|
+
*
|
|
33
|
+
* `secondary` rather than `primary` when nothing names a variant, because a
|
|
34
|
+
* chunk that cannot be pressed should not wear the group's action paint. A group
|
|
35
|
+
* that *does* name one is followed, so an outline group reads as one piece.
|
|
36
|
+
*
|
|
37
|
+
* Bare text children are wrapped in a `Text`, since React Native cannot render a
|
|
38
|
+
* string outside one. Composed content inherits the same icon size and label
|
|
39
|
+
* treatment a button publishes, so an `Icon` beside the text needs nothing.
|
|
40
|
+
*/
|
|
41
|
+
export function ButtonGroupText({ variant, className, children, ...props }: ButtonGroupTextProps): ReactElement {
|
|
42
|
+
const item = useButtonGroupItemContext();
|
|
43
|
+
const resolvedVariant = variant ?? item?.variant ?? "secondary";
|
|
44
|
+
const slots = buttonVariants({
|
|
45
|
+
groupPosition: item?.position ?? "none",
|
|
46
|
+
isSeamed: item?.isSeamed ?? false,
|
|
47
|
+
orientation: item?.orientation ?? "horizontal",
|
|
48
|
+
size: resolveGroupedButtonSize(undefined, item?.size),
|
|
49
|
+
variant: resolvedVariant,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const label = slots.label();
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<View className={slots.root({ className })} {...props}>
|
|
56
|
+
<IconDefaultsProvider value={{ className: slots.icon(), color: BUTTON_FOREGROUND_TOKEN[resolvedVariant] }}>
|
|
57
|
+
<TextClassProvider value={label}>{wrapTextChildren(children, label)}</TextClassProvider>
|
|
58
|
+
</IconDefaultsProvider>
|
|
59
|
+
</View>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
ButtonGroupText.displayName = "DelacourUI.Button.Group.Text";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Wraps bare text children in a `Text`.
|
|
66
|
+
*
|
|
67
|
+
* React Native cannot render a string outside a `<Text>`, so
|
|
68
|
+
* `<Button.Group.Text>3 of 7</Button.Group.Text>` would otherwise crash.
|
|
69
|
+
* Consecutive strings and numbers are collected into a single one rather than
|
|
70
|
+
* one each — `Page {n}` is one piece of text, and wrapping the parts separately
|
|
71
|
+
* would space them apart by the root's gap.
|
|
72
|
+
*
|
|
73
|
+
* Lives beside its caller rather than in the group's root, which inserts
|
|
74
|
+
* nothing: a helper follows the component that uses it.
|
|
75
|
+
*/
|
|
76
|
+
function wrapTextChildren(children: ReactNode, className: string): ReactNode {
|
|
77
|
+
const output: ReactNode[] = [];
|
|
78
|
+
let run: (string | number)[] = [];
|
|
79
|
+
|
|
80
|
+
const flushRun = () => {
|
|
81
|
+
if (run.length === 0) return;
|
|
82
|
+
output.push(
|
|
83
|
+
<Text className={className} key={`label-${output.length}`}>
|
|
84
|
+
{run.join("")}
|
|
85
|
+
</Text>
|
|
86
|
+
);
|
|
87
|
+
run = [];
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
for (const child of Children.toArray(children)) {
|
|
91
|
+
if (typeof child === "string" || typeof child === "number") {
|
|
92
|
+
run.push(child);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
flushRun();
|
|
96
|
+
output.push(child);
|
|
97
|
+
}
|
|
98
|
+
flushRun();
|
|
99
|
+
|
|
100
|
+
return output;
|
|
101
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { Children, isValidElement, type ReactElement, type ReactNode, useMemo } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
import type { PressableFeedback } from "../pressable/pressable.variants";
|
|
4
|
+
import { Separator } from "../separator";
|
|
5
|
+
import {
|
|
6
|
+
type ButtonGroupContextValue,
|
|
7
|
+
type ButtonGroupItemContextValue,
|
|
8
|
+
ButtonGroupItemProvider,
|
|
9
|
+
ButtonGroupProvider,
|
|
10
|
+
} from "./button.context";
|
|
11
|
+
import {
|
|
12
|
+
type ButtonGroupOrientation,
|
|
13
|
+
type ButtonSize,
|
|
14
|
+
type ButtonVariant,
|
|
15
|
+
buttonVariants,
|
|
16
|
+
resolveGroupPositions,
|
|
17
|
+
resolveGroupSeams,
|
|
18
|
+
} from "./button.variants";
|
|
19
|
+
import { ButtonGroupSeparator } from "./button-group-separator";
|
|
20
|
+
import { ButtonGroupText } from "./button-group-text";
|
|
21
|
+
|
|
22
|
+
export type ButtonGroupProps = ViewProps & {
|
|
23
|
+
/** Which way the group runs. Members square the corners crossing the run. */
|
|
24
|
+
orientation?: ButtonGroupOrientation;
|
|
25
|
+
/** Shared by every member — controls of different heights do not join. */
|
|
26
|
+
size?: ButtonSize;
|
|
27
|
+
/** A default every member takes unless it names its own. */
|
|
28
|
+
variant?: ButtonVariant;
|
|
29
|
+
/** Disables every member that does not say otherwise. */
|
|
30
|
+
isDisabled?: boolean;
|
|
31
|
+
/** Press treatment for every member. Unset, a joined member fades rather than scaling. */
|
|
32
|
+
feedback?: PressableFeedback;
|
|
33
|
+
className?: string;
|
|
34
|
+
children?: ReactNode;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function ButtonGroupRoot({
|
|
38
|
+
orientation = "horizontal",
|
|
39
|
+
size = "md",
|
|
40
|
+
variant,
|
|
41
|
+
isDisabled,
|
|
42
|
+
feedback,
|
|
43
|
+
className,
|
|
44
|
+
children,
|
|
45
|
+
...props
|
|
46
|
+
}: ButtonGroupProps): ReactElement {
|
|
47
|
+
const group = useMemo<ButtonGroupContextValue>(
|
|
48
|
+
() => ({ feedback, isDisabled, orientation, size, variant }),
|
|
49
|
+
[feedback, isDisabled, orientation, size, variant]
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const items = useMemo(() => Children.toArray(children), [children]);
|
|
53
|
+
|
|
54
|
+
// One memo for the whole array rather than a hook per child, which a `.map()`
|
|
55
|
+
// cannot have. A member's value is a new object only when the group's axes or
|
|
56
|
+
// the members' order actually change.
|
|
57
|
+
const values = useMemo(() => {
|
|
58
|
+
const isMember = items.map(isGroupMember);
|
|
59
|
+
const positions = resolveGroupPositions(isMember);
|
|
60
|
+
const seams = resolveGroupSeams(isMember);
|
|
61
|
+
|
|
62
|
+
return positions.map<ButtonGroupItemContextValue | null>((position, index) =>
|
|
63
|
+
position === null ? null : { ...group, isSeamed: seams[index] === true, position }
|
|
64
|
+
);
|
|
65
|
+
}, [items, group]);
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<ButtonGroupProvider value={group}>
|
|
69
|
+
<View className={buttonVariants({ orientation }).group({ className })} {...props}>
|
|
70
|
+
{items.map((child, index) => {
|
|
71
|
+
const value = values[index];
|
|
72
|
+
if (!value) return child;
|
|
73
|
+
return (
|
|
74
|
+
<ButtonGroupItemProvider key={keyOf(child, index)} value={value}>
|
|
75
|
+
{child}
|
|
76
|
+
</ButtonGroupItemProvider>
|
|
77
|
+
);
|
|
78
|
+
})}
|
|
79
|
+
</View>
|
|
80
|
+
</ButtonGroupProvider>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether a child takes a position in the group.
|
|
86
|
+
*
|
|
87
|
+
* A deny-list rather than a check for `Button`, for two reasons. It lets any
|
|
88
|
+
* control join a group and style itself from `useButtonGroupItem()` — the whole
|
|
89
|
+
* point of the library's `useX()` hooks — and it means this file never imports
|
|
90
|
+
* its own root for an identity check, which would close a cycle (AGENTS.md rule
|
|
91
|
+
* 3).
|
|
92
|
+
*
|
|
93
|
+
* A hand-placed `Separator` is excluded alongside the group's own, the same
|
|
94
|
+
* tolerance `ListGroup` extends to one.
|
|
95
|
+
*/
|
|
96
|
+
function isGroupMember(child: ReactNode): boolean {
|
|
97
|
+
return isValidElement(child) && child.type !== ButtonGroupSeparator && child.type !== Separator;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A stable key for a wrapped child.
|
|
102
|
+
*
|
|
103
|
+
* `Children.toArray` has already assigned every child a key, so the wrapper
|
|
104
|
+
* reuses it rather than minting an index-based one — a group whose members are
|
|
105
|
+
* reordered or conditionally rendered would otherwise remount them.
|
|
106
|
+
*/
|
|
107
|
+
function keyOf(child: ReactNode, index: number): string {
|
|
108
|
+
return isValidElement(child) && child.key !== null ? child.key : `member-${index}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Several controls joined into one run, sharing a corner and a seam.
|
|
113
|
+
*
|
|
114
|
+
* The group owns the axes because it owns the shape: `size` outright, since
|
|
115
|
+
* controls of different heights do not join, and `variant`, `isDisabled` and
|
|
116
|
+
* `feedback` as defaults a member may still override. Those three are published
|
|
117
|
+
* raw, so `undefined` means "the group said nothing" and one member can disable
|
|
118
|
+
* itself inside a group that did not.
|
|
119
|
+
*
|
|
120
|
+
* Each member is wrapped in a context provider carrying its place in the run.
|
|
121
|
+
* That indirection is forced: React Native has no sibling selector, so the CSS
|
|
122
|
+
* shadcn/ui uses on the web — `[&>[data-slot]~[data-slot]]:rounded-l-none` —
|
|
123
|
+
* has no counterpart here and position has to be computed in JavaScript. A
|
|
124
|
+
* provider is not a host component, so nothing is added to the layout.
|
|
125
|
+
*
|
|
126
|
+
* A member squares the corners crossing each seam and overlaps its neighbour by
|
|
127
|
+
* a point, so two adjacent borders draw as one hairline. A group of one is
|
|
128
|
+
* indistinguishable from the control on its own.
|
|
129
|
+
*
|
|
130
|
+
* The group paints nothing itself — no background, no border, no disabled fade.
|
|
131
|
+
* A fade here would compound with the members' own down to a quarter opacity.
|
|
132
|
+
*
|
|
133
|
+
* Horizontal groups are content-width. For a run that fills its parent, put
|
|
134
|
+
* `className="w-full"` on the group and `className="flex-1"` on each member.
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* <Button.Group variant="outline">
|
|
138
|
+
* <Button onPress={archive}>Archive</Button>
|
|
139
|
+
* <Button onPress={report}>Report</Button>
|
|
140
|
+
* <Button onPress={snooze}>Snooze</Button>
|
|
141
|
+
* </Button.Group>
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* <Button.Group>
|
|
145
|
+
* <Button onPress={save}>Save</Button>
|
|
146
|
+
* <Button.Group.Separator />
|
|
147
|
+
* <Button accessibilityLabel="More" size="icon-md" onPress={openMenu}>
|
|
148
|
+
* <Icon icon={IconChevronDown} />
|
|
149
|
+
* </Button>
|
|
150
|
+
* </Button.Group>
|
|
151
|
+
*/
|
|
152
|
+
export const ButtonGroup = Object.assign(ButtonGroupRoot, {
|
|
153
|
+
/** A rule between two members. Takes no position, so the ends stay rounded. */
|
|
154
|
+
Separator: ButtonGroupSeparator,
|
|
155
|
+
/** A chunk that says something rather than doing something, joined like any member. */
|
|
156
|
+
Text: ButtonGroupText,
|
|
157
|
+
displayName: "DelacourUI.Button.Group",
|
|
158
|
+
});
|