@delacour/react-native-ui 0.1.0-alpha.20261009012154 → 0.1.0-alpha.20261009012956
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/package.json +3 -3
- package/src/components/button/AGENTS.md +45 -11
- package/src/components/button/button-group.tsx +44 -8
- package/src/components/button/button.context.tsx +10 -0
- package/src/components/button/button.tsx +2 -1
- package/src/components/button/button.variants.test.ts +154 -0
- package/src/components/button/button.variants.ts +71 -6
- package/src/components/button/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delacour/react-native-ui",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.20261009012956",
|
|
4
4
|
"description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -116,8 +116,8 @@
|
|
|
116
116
|
},
|
|
117
117
|
"peerDependencies": {
|
|
118
118
|
"@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
|
|
119
|
-
"@delacour/react-native-bottom-sheet": "0.1.0-alpha.
|
|
120
|
-
"@delacour/react-native-charts": "0.1.0-alpha.
|
|
119
|
+
"@delacour/react-native-bottom-sheet": "0.1.0-alpha.20261009012956",
|
|
120
|
+
"@delacour/react-native-charts": "0.1.0-alpha.20261009012956",
|
|
121
121
|
"@legendapp/list": ">=3.3",
|
|
122
122
|
"expo-linear-gradient": ">=15",
|
|
123
123
|
"expo-router": ">=57",
|
|
@@ -194,20 +194,54 @@ for a chunk that says something rather than doing something. An
|
|
|
194
194
|
- **The group paints nothing.** No background, no border, no disabled fade. A
|
|
195
195
|
disabled group publishes `isDisabled` and each member fades itself; a group
|
|
196
196
|
fading as well would compound the two down to a quarter opacity. It carries no
|
|
197
|
-
`gap`
|
|
198
|
-
`overflow-hidden`, which would square off the very corners the position
|
|
197
|
+
`gap` while attached — a gap is the seam this component exists to close — and
|
|
198
|
+
no `overflow-hidden`, which would square off the very corners the position
|
|
199
199
|
compounds just rounded.
|
|
200
200
|
- **No `accessibilityRole` on the group.** Announcing a control with no action
|
|
201
201
|
in front of every member helps nobody; the members are already buttons.
|
|
202
|
-
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
202
|
+
- **`isAttached={false}` shares the props and drops the shape.** A toolbar of
|
|
203
|
+
`Find · Export · Share` wants one `variant` and one `size` without becoming a
|
|
204
|
+
segmented control. Detached, `resolveGroupPositions` places every member
|
|
205
|
+
`only` — the lone member's existing position, which draws the lone button's
|
|
206
|
+
`rounded-button-*` — rather than minting a fourth sentinel, and
|
|
207
|
+
`resolveGroupSeams` returns no seams, so nothing overlaps into the gap. The gap
|
|
208
|
+
is `gap-2` on either axis, a `tv` variant on the `group` slot and never inline.
|
|
209
|
+
`Button.Group.Separator` still renders, as a rule between spaced members.
|
|
210
|
+
- **A detached member presses with `scale` again.** The fade exists only because
|
|
211
|
+
a scaling member tears its seam, and a detached run has none.
|
|
212
|
+
`resolveButtonFeedback` takes `isAttached` as a fourth argument, defaulting to
|
|
213
|
+
`true`, rather than turning into an options object: its positional signature
|
|
214
|
+
is public, `Input` and the existing tests call it, and the new rung is the only
|
|
215
|
+
thing that changed.
|
|
216
|
+
- **`isFullWidth` splits the parent equally, not by content.** A
|
|
217
|
+
`Monthly · Annual · One-off` picker sized to its labels moves its dividers when
|
|
218
|
+
the copy changes, and a wider segment reads as more important. The group trades
|
|
219
|
+
`self-start` for `w-full self-stretch` — declared after `orientation` in
|
|
220
|
+
`buttonVariants`, so tailwind-merge drops `self-start` rather than keeping it —
|
|
221
|
+
and every member of a horizontal run gets `flex-1 basis-0`. **`basis-0` is the
|
|
222
|
+
part that matters**: with `flex-1` alone Yoga shares out only the space left
|
|
223
|
+
after each member's content width, and the widths still follow the labels.
|
|
224
|
+
- **A member learns it stretches from context, as `isStretched`.**
|
|
225
|
+
`resolveGroupMemberStretch` decides it once per group, it rides on the
|
|
226
|
+
per-member context beside `position` and `isSeamed`, and `Button` feeds it to
|
|
227
|
+
an `isStretched` variant on its own `tv`. No `cloneElement`, for the reason
|
|
228
|
+
above. A caller's `className="flex-none"` on one member still wins — the
|
|
229
|
+
variant is merged before the caller's class. `Button.Group.Text` and the
|
|
230
|
+
separator do **not** stretch: a caption and a rule keep their content width
|
|
231
|
+
and the buttons share what is left. `Input` does not read it yet; a caller
|
|
232
|
+
sizes a joined field with its own `className`.
|
|
233
|
+
- **`isFullWidth` on a vertical group widens the group and does nothing else.**
|
|
234
|
+
Its members already stretch on the cross axis, and `basis-0` on the main axis
|
|
235
|
+
of a column with no definite height would collapse every member to nothing —
|
|
236
|
+
so `isStretched` is `false` there by construction.
|
|
237
|
+
- **Horizontal groups are content-width by default** (`self-start`), because
|
|
238
|
+
that is what a segmented control is. `isFullWidth` fills the parent — **and the
|
|
239
|
+
parent must have a definite width**. Yoga resolves a percentage against the
|
|
240
|
+
parent's content box, so `w-full` inside a container that is itself
|
|
241
|
+
content-sized resolves to nothing and falls back to the content width: the
|
|
242
|
+
group collapses onto its buttons and a `flex-1` field between them is squeezed
|
|
243
|
+
to a few points. It looks like the group ignored `isFullWidth`. It did not;
|
|
244
|
+
there was no width to take a percentage of.
|
|
211
245
|
- **`Button.Group.Text` draws the button's own chrome**, not chrome of its own,
|
|
212
246
|
which is what keeps its height, padding and corner identical to the buttons
|
|
213
247
|
beside it — those come off one axis, and restating any of them here is how a
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type ButtonSize,
|
|
14
14
|
type ButtonVariant,
|
|
15
15
|
buttonVariants,
|
|
16
|
+
resolveGroupMemberStretch,
|
|
16
17
|
resolveGroupPositions,
|
|
17
18
|
resolveGroupSeams,
|
|
18
19
|
} from "./button.variants";
|
|
@@ -30,6 +31,18 @@ export type ButtonGroupProps = ViewProps & {
|
|
|
30
31
|
isDisabled?: boolean;
|
|
31
32
|
/** Press treatment for every member. Unset, a joined member fades rather than scaling. */
|
|
32
33
|
feedback?: PressableFeedback;
|
|
34
|
+
/**
|
|
35
|
+
* Join the members into one shape. On by default, because that is what a group is. Off keeps the
|
|
36
|
+
* inherited `variant` / `size` / `isDisabled` / `feedback` and drops the joined shape: every
|
|
37
|
+
* member keeps its own corner, no seam overlap, and the run takes a `gap`.
|
|
38
|
+
*/
|
|
39
|
+
isAttached?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Span the parent and split it equally between members. Horizontal only — a vertical group
|
|
42
|
+
* already stretches its members, so there it only widens the group. The parent needs a
|
|
43
|
+
* definite width.
|
|
44
|
+
*/
|
|
45
|
+
isFullWidth?: boolean;
|
|
33
46
|
className?: string;
|
|
34
47
|
children?: ReactNode;
|
|
35
48
|
};
|
|
@@ -40,13 +53,15 @@ function ButtonGroupRoot({
|
|
|
40
53
|
variant,
|
|
41
54
|
isDisabled,
|
|
42
55
|
feedback,
|
|
56
|
+
isAttached = true,
|
|
57
|
+
isFullWidth = false,
|
|
43
58
|
className,
|
|
44
59
|
children,
|
|
45
60
|
...props
|
|
46
61
|
}: ButtonGroupProps): ReactElement {
|
|
47
62
|
const group = useMemo<ButtonGroupContextValue>(
|
|
48
|
-
() => ({ feedback, isDisabled, orientation, size, variant }),
|
|
49
|
-
[feedback, isDisabled, orientation, size, variant]
|
|
63
|
+
() => ({ feedback, isAttached, isDisabled, isFullWidth, orientation, size, variant }),
|
|
64
|
+
[feedback, isAttached, isDisabled, isFullWidth, orientation, size, variant]
|
|
50
65
|
);
|
|
51
66
|
|
|
52
67
|
const items = useMemo(() => Children.toArray(children), [children]);
|
|
@@ -56,17 +71,18 @@ function ButtonGroupRoot({
|
|
|
56
71
|
// the members' order actually change.
|
|
57
72
|
const values = useMemo(() => {
|
|
58
73
|
const isMember = items.map(isGroupMember);
|
|
59
|
-
const positions = resolveGroupPositions(isMember);
|
|
60
|
-
const seams = resolveGroupSeams(isMember);
|
|
74
|
+
const positions = resolveGroupPositions(isMember, group);
|
|
75
|
+
const seams = resolveGroupSeams(isMember, group);
|
|
76
|
+
const isStretched = resolveGroupMemberStretch(group);
|
|
61
77
|
|
|
62
78
|
return positions.map<ButtonGroupItemContextValue | null>((position, index) =>
|
|
63
|
-
position === null ? null : { ...group, isSeamed: seams[index] === true, position }
|
|
79
|
+
position === null ? null : { ...group, isSeamed: seams[index] === true, isStretched, position }
|
|
64
80
|
);
|
|
65
81
|
}, [items, group]);
|
|
66
82
|
|
|
67
83
|
return (
|
|
68
84
|
<ButtonGroupProvider value={group}>
|
|
69
|
-
<View className={buttonVariants({ orientation }).group({ className })} {...props}>
|
|
85
|
+
<View className={buttonVariants({ isAttached, isFullWidth, orientation }).group({ className })} {...props}>
|
|
70
86
|
{items.map((child, index) => {
|
|
71
87
|
const value = values[index];
|
|
72
88
|
if (!value) return child;
|
|
@@ -130,8 +146,14 @@ function keyOf(child: ReactNode, index: number): string {
|
|
|
130
146
|
* The group paints nothing itself — no background, no border, no disabled fade.
|
|
131
147
|
* A fade here would compound with the members' own down to a quarter opacity.
|
|
132
148
|
*
|
|
133
|
-
* Horizontal groups are content-width.
|
|
134
|
-
*
|
|
149
|
+
* Horizontal groups are content-width. `isFullWidth` spans the parent and
|
|
150
|
+
* splits it equally between members — `flex-1 basis-0` on each, published as
|
|
151
|
+
* `isStretched` on the member's context — whatever their labels say. The parent
|
|
152
|
+
* needs a definite width for there to be anything to split.
|
|
153
|
+
*
|
|
154
|
+
* `isAttached={false}` keeps the shared axes and drops the shape: every member
|
|
155
|
+
* draws its own corner, nothing overlaps, the run takes a gap, and an unset
|
|
156
|
+
* `feedback` goes back to the lone button's `scale`.
|
|
135
157
|
*
|
|
136
158
|
* @example
|
|
137
159
|
* <Button.Group variant="outline">
|
|
@@ -141,6 +163,20 @@ function keyOf(child: ReactNode, index: number): string {
|
|
|
141
163
|
* </Button.Group>
|
|
142
164
|
*
|
|
143
165
|
* @example
|
|
166
|
+
* <Button.Group isAttached={false} size="sm" variant="outline">
|
|
167
|
+
* <Button onPress={find}>Find</Button>
|
|
168
|
+
* <Button onPress={exportFile}>Export</Button>
|
|
169
|
+
* <Button onPress={share}>Share</Button>
|
|
170
|
+
* </Button.Group>
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* <Button.Group isFullWidth variant="outline">
|
|
174
|
+
* <Button onPress={monthly}>Monthly</Button>
|
|
175
|
+
* <Button onPress={annual}>Annual</Button>
|
|
176
|
+
* <Button onPress={once}>One-off</Button>
|
|
177
|
+
* </Button.Group>
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
144
180
|
* <Button.Group>
|
|
145
181
|
* <Button onPress={save}>Save</Button>
|
|
146
182
|
* <Button.Group.Separator />
|
|
@@ -76,6 +76,10 @@ export type ButtonGroupContextValue = {
|
|
|
76
76
|
isDisabled?: boolean;
|
|
77
77
|
/** Press treatment for members. Unset falls back to `fade` — see `resolveButtonFeedback`. */
|
|
78
78
|
feedback?: PressableFeedback;
|
|
79
|
+
/** Members join into one shape. False, each keeps its own corner and the run takes a gap. */
|
|
80
|
+
isAttached: boolean;
|
|
81
|
+
/** The group spans its parent. Horizontal members then split it — see `isStretched`. */
|
|
82
|
+
isFullWidth: boolean;
|
|
79
83
|
};
|
|
80
84
|
|
|
81
85
|
export type ButtonGroupItemContextValue = ButtonGroupContextValue & {
|
|
@@ -83,6 +87,12 @@ export type ButtonGroupItemContextValue = ButtonGroupContextValue & {
|
|
|
83
87
|
position: ButtonGroupPosition;
|
|
84
88
|
/** Overlap the member before it, so the shared edge is drawn once. */
|
|
85
89
|
isSeamed: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Take an equal share of a full-width horizontal run — `flex-1 basis-0`.
|
|
92
|
+
* A custom member that should split the run reads this; one that should
|
|
93
|
+
* keep its content width ignores it, as `Button.Group.Text` does.
|
|
94
|
+
*/
|
|
95
|
+
isStretched: boolean;
|
|
86
96
|
};
|
|
87
97
|
|
|
88
98
|
const ButtonGroupContext = createContext<ButtonGroupContextValue | null>(null);
|
|
@@ -74,6 +74,7 @@ function ButtonRoot({
|
|
|
74
74
|
isDisabled: resolvedIsDisabled,
|
|
75
75
|
isLoading,
|
|
76
76
|
isSeamed: item?.isSeamed ?? false,
|
|
77
|
+
isStretched: item?.isStretched ?? false,
|
|
77
78
|
orientation: item?.orientation ?? "horizontal",
|
|
78
79
|
size: resolvedSize,
|
|
79
80
|
variant: resolvedVariant,
|
|
@@ -96,7 +97,7 @@ function ButtonRoot({
|
|
|
96
97
|
busy={isLoading}
|
|
97
98
|
className={slots.root({ className })}
|
|
98
99
|
disabled={resolvedIsDisabled}
|
|
99
|
-
feedback={resolveButtonFeedback(feedback, item?.feedback, item !== null)}
|
|
100
|
+
feedback={resolveButtonFeedback(feedback, item?.feedback, item !== null, item?.isAttached)}
|
|
100
101
|
{...props}
|
|
101
102
|
>
|
|
102
103
|
<IconDefaultsProvider value={iconDefaults}>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
2
3
|
import { BUTTON_RADIUS_TOKENS, ICON_SIZE_TOKENS } from "../../styles/tokens";
|
|
3
4
|
import {
|
|
4
5
|
BUTTON_FOREGROUND_TOKEN,
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
resolveButtonFeedback,
|
|
17
18
|
resolveButtonLayout,
|
|
18
19
|
resolveGroupedButtonSize,
|
|
20
|
+
resolveGroupMemberStretch,
|
|
19
21
|
resolveGroupPositions,
|
|
20
22
|
resolveGroupSeams,
|
|
21
23
|
resolveSpinnerSwapIndex,
|
|
@@ -809,3 +811,155 @@ describe("resolveGroupedButtonSize", () => {
|
|
|
809
811
|
}
|
|
810
812
|
});
|
|
811
813
|
});
|
|
814
|
+
|
|
815
|
+
describe("a detached group", () => {
|
|
816
|
+
test("places every member on its own", () => {
|
|
817
|
+
// `only` is the lone member's position and draws the lone button's corner,
|
|
818
|
+
// so a spaced run is a row of whole buttons rather than a joined shape with
|
|
819
|
+
// gaps punched in it.
|
|
820
|
+
expect(resolveGroupPositions([true, true, true], { isAttached: false })).toEqual(["only", "only", "only"]);
|
|
821
|
+
expect(resolveGroupPositions([true, false, true], { isAttached: false })).toEqual(["only", null, "only"]);
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
test("still skips a separator", () => {
|
|
825
|
+
expect(resolveGroupPositions([false, true, false], { isAttached: false })).toEqual([null, "only", null]);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
test("joins by default", () => {
|
|
829
|
+
expect(resolveGroupPositions([true, true], { isAttached: true })).toEqual(["first", "last"]);
|
|
830
|
+
expect(resolveGroupPositions([true, true], {})).toEqual(["first", "last"]);
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
test("overlaps nothing", () => {
|
|
834
|
+
// There is no shared edge to draw once, and a point of overlap would eat
|
|
835
|
+
// into the gap instead.
|
|
836
|
+
for (const shape of SHAPES) {
|
|
837
|
+
expect(resolveGroupSeams(shape, { isAttached: false }).every((seamed) => !seamed)).toBe(true);
|
|
838
|
+
}
|
|
839
|
+
expect(resolveGroupSeams([true, true, true], { isAttached: true })).toEqual([false, true, true]);
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
test("draws each member's own corner", () => {
|
|
843
|
+
for (const size of BUTTON_SIZES) {
|
|
844
|
+
const step = size.startsWith("icon-") ? size.slice("icon-".length) : size;
|
|
845
|
+
const cls = buttonVariants({ groupPosition: "only", size }).root();
|
|
846
|
+
expect(cls).toContain(`rounded-button-${step}`);
|
|
847
|
+
expect(cls).not.toMatch(/\brounded-[sebt]-/);
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
test("spaces its members with a gap on either axis", () => {
|
|
852
|
+
for (const orientation of BUTTON_GROUP_ORIENTATIONS) {
|
|
853
|
+
expect(buttonVariants({ isAttached: false, orientation }).group()).toMatch(/\bgap-2\b/);
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
test("an attached group still holds no gap", () => {
|
|
858
|
+
for (const orientation of BUTTON_GROUP_ORIENTATIONS) {
|
|
859
|
+
expect(buttonVariants({ isAttached: true, orientation }).group()).not.toMatch(/\bgap-/);
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
describe("resolveButtonFeedback in a detached group", () => {
|
|
865
|
+
test("falls back to the lone button's scale", () => {
|
|
866
|
+
// Tearing a seam is impossible with no seam, so the group rung is dropped.
|
|
867
|
+
expect(resolveButtonFeedback(undefined, undefined, true, false)).toBe("scale");
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
test("still fades when attached", () => {
|
|
871
|
+
expect(resolveButtonFeedback(undefined, undefined, true, true)).toBe("fade");
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
test("defaults to attached, which is what a group is", () => {
|
|
875
|
+
expect(resolveButtonFeedback(undefined, undefined, true)).toBe("fade");
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
test("honours an explicit `none` either way", () => {
|
|
879
|
+
for (const isAttached of [true, false]) {
|
|
880
|
+
expect(resolveButtonFeedback("none", undefined, true, isAttached)).toBe("none");
|
|
881
|
+
expect(resolveButtonFeedback(undefined, "none", true, isAttached)).toBe("none");
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
test("still lets the group or the member name a treatment", () => {
|
|
886
|
+
expect(resolveButtonFeedback(undefined, "fade", true, false)).toBe("fade");
|
|
887
|
+
expect(resolveButtonFeedback("scale-fade", "fade", true, false)).toBe("scale-fade");
|
|
888
|
+
});
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
describe("a full-width group", () => {
|
|
892
|
+
test("spans its parent instead of hugging its content", () => {
|
|
893
|
+
const cls = buttonVariants({ isFullWidth: true, orientation: "horizontal" }).group();
|
|
894
|
+
expect(cls).toMatch(/\bw-full\b/);
|
|
895
|
+
expect(cls).toMatch(/\bself-stretch\b/);
|
|
896
|
+
expect(cls).not.toMatch(/\bself-start\b/);
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
test("fills the width when vertical too", () => {
|
|
900
|
+
const cls = buttonVariants({ isFullWidth: true, orientation: "vertical" }).group();
|
|
901
|
+
expect(cls).toMatch(/\bw-full\b/);
|
|
902
|
+
expect(cls).toMatch(/\bself-stretch\b/);
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
test("hugs its content by default", () => {
|
|
906
|
+
const cls = buttonVariants({ orientation: "horizontal" }).group();
|
|
907
|
+
expect(cls).toMatch(/\bself-start\b/);
|
|
908
|
+
expect(cls).not.toMatch(/\bw-full\b/);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
test("keeps a gap when it is also detached", () => {
|
|
912
|
+
const cls = buttonVariants({ isAttached: false, isFullWidth: true }).group();
|
|
913
|
+
expect(cls).toMatch(/\bgap-2\b/);
|
|
914
|
+
expect(cls).toMatch(/\bw-full\b/);
|
|
915
|
+
});
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
describe("a stretched member", () => {
|
|
919
|
+
test("splits the run equally rather than by content", () => {
|
|
920
|
+
// `basis-0` is the half that matters: with `flex-1` alone Yoga shares out
|
|
921
|
+
// only the space left over, and the widths still follow the labels.
|
|
922
|
+
const cls = buttonVariants({ isStretched: true }).root();
|
|
923
|
+
expect(cls).toMatch(/\bflex-1\b/);
|
|
924
|
+
expect(cls).toMatch(/\bbasis-0\b/);
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
test("does not stretch by default", () => {
|
|
928
|
+
const cls = buttonVariants().root();
|
|
929
|
+
expect(cls).not.toMatch(/\bflex-1\b/);
|
|
930
|
+
expect(cls).not.toMatch(/\bbasis-0\b/);
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
test("yields to a caller's flex", () => {
|
|
934
|
+
const slot = buttonVariants({ isStretched: true }).root({ className: "flex-none" });
|
|
935
|
+
expect(slot).toMatch(/\bflex-none\b/);
|
|
936
|
+
expect(slot).not.toMatch(/\bflex-1\b/);
|
|
937
|
+
const merged = cn(buttonVariants({ isStretched: true }).root(), "flex-none");
|
|
938
|
+
expect(merged).toMatch(/\bflex-none\b/);
|
|
939
|
+
expect(merged).not.toMatch(/\bflex-1\b/);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
test("keeps its own corner and height", () => {
|
|
943
|
+
const cls = buttonVariants({ groupPosition: "first", isStretched: true, size: "md" }).root();
|
|
944
|
+
expect(cls).toContain("h-button-md");
|
|
945
|
+
expect(cls).toContain("rounded-s-button-md");
|
|
946
|
+
});
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
describe("resolveGroupMemberStretch", () => {
|
|
950
|
+
test("stretches members of a full-width horizontal run", () => {
|
|
951
|
+
expect(resolveGroupMemberStretch({ isFullWidth: true, orientation: "horizontal" })).toBe(true);
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
test("leaves a vertical run to the cross axis it already stretches on", () => {
|
|
955
|
+
// `basis-0` on the main axis of a column with no definite height would
|
|
956
|
+
// collapse every member to nothing.
|
|
957
|
+
expect(resolveGroupMemberStretch({ isFullWidth: true, orientation: "vertical" })).toBe(false);
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
test("stretches nothing by default", () => {
|
|
961
|
+
for (const orientation of BUTTON_GROUP_ORIENTATIONS) {
|
|
962
|
+
expect(resolveGroupMemberStretch({ isFullWidth: false, orientation })).toBe(false);
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
});
|
|
@@ -41,6 +41,11 @@ export type ButtonSpinnerPlacement = (typeof BUTTON_SPINNER_PLACEMENTS)[number];
|
|
|
41
41
|
export type ButtonGroupOrientation = (typeof BUTTON_GROUP_ORIENTATIONS)[number];
|
|
42
42
|
export type ButtonGroupPosition = (typeof BUTTON_GROUP_POSITIONS)[number];
|
|
43
43
|
|
|
44
|
+
/** How a group shapes its run. Omitted, a group joins — that is what a group is. */
|
|
45
|
+
export type ButtonGroupShapeOptions = {
|
|
46
|
+
isAttached?: boolean;
|
|
47
|
+
};
|
|
48
|
+
|
|
44
49
|
/** A member's place in its group, or `none` for a button standing on its own. */
|
|
45
50
|
export type ButtonGroupSlotPosition = ButtonGroupPosition | "none";
|
|
46
51
|
|
|
@@ -135,8 +140,9 @@ export const buttonVariants = tv({
|
|
|
135
140
|
/**
|
|
136
141
|
* A `Button.Group`'s box.
|
|
137
142
|
*
|
|
138
|
-
* Holds no `gap` and no `overflow-hidden
|
|
139
|
-
* load-bearing. A gap is the seam
|
|
143
|
+
* Holds no `gap` while attached and no `overflow-hidden` ever, and both
|
|
144
|
+
* absences are load-bearing. A gap is the seam a joined run exists to
|
|
145
|
+
* close — only `isAttached: false` adds one, where there is no seam. A clip
|
|
140
146
|
* would square off the very corners the position compounds just rounded,
|
|
141
147
|
* and would have to restate the group's own corner to avoid it — while the
|
|
142
148
|
* members already square themselves, which is the whole point.
|
|
@@ -192,6 +198,21 @@ export const buttonVariants = tv({
|
|
|
192
198
|
horizontal: { group: "flex-row self-start" },
|
|
193
199
|
vertical: { group: "flex-col" },
|
|
194
200
|
},
|
|
201
|
+
// Declared after `orientation` on purpose: `tv` merges in declaration
|
|
202
|
+
// order, so `self-stretch` here replaces the horizontal run's `self-start`
|
|
203
|
+
// rather than losing to it.
|
|
204
|
+
/** A `Button.Group` that spans its parent. The members split it — see `isStretched`. */
|
|
205
|
+
isFullWidth: { true: { group: "w-full self-stretch" }, false: {} },
|
|
206
|
+
/** A `Button.Group` whose members join into one shape. Detached, they stand apart by a gap. */
|
|
207
|
+
isAttached: { true: {}, false: { group: "gap-2" } },
|
|
208
|
+
/**
|
|
209
|
+
* A member of a full-width horizontal run, taking an equal share of it.
|
|
210
|
+
*
|
|
211
|
+
* `basis-0` is the half that matters. With `flex-1` alone Yoga shares out
|
|
212
|
+
* only the space left over after each member's content width, so the
|
|
213
|
+
* widths still follow the labels; from a zero basis every share is equal.
|
|
214
|
+
*/
|
|
215
|
+
isStretched: { true: { root: "flex-1 basis-0" }, false: {} },
|
|
195
216
|
// `middle` is the one position whose corner needs neither the size nor the
|
|
196
217
|
// orientation, so it lands here rather than in six compound cells that
|
|
197
218
|
// would all say the same word.
|
|
@@ -304,6 +325,9 @@ export const buttonVariants = tv({
|
|
|
304
325
|
orientation: "horizontal",
|
|
305
326
|
groupPosition: "none",
|
|
306
327
|
isSeamed: false,
|
|
328
|
+
isFullWidth: false,
|
|
329
|
+
isAttached: true,
|
|
330
|
+
isStretched: false,
|
|
307
331
|
isDisabled: false,
|
|
308
332
|
isLoading: false,
|
|
309
333
|
isDimmedWhileLoading: false,
|
|
@@ -413,9 +437,18 @@ export function resolveGroupedButtonSize(own: ButtonSize | undefined, group: But
|
|
|
413
437
|
* trade {@link resolveSpinnerSwapIndex} already makes. React Native has no
|
|
414
438
|
* sibling selector, so this is the only place a member's corner can be decided.
|
|
415
439
|
*
|
|
440
|
+
* A detached run (`isAttached: false`) places every member `only` — the lone
|
|
441
|
+
* position, which draws the lone button's corner — rather than minting a fourth
|
|
442
|
+
* sentinel: a spaced member *is* a button on its own that happens to share props.
|
|
443
|
+
*
|
|
416
444
|
* Pure, so the whole matrix is reachable from `bun test`. See AGENTS.md.
|
|
417
445
|
*/
|
|
418
|
-
export function resolveGroupPositions(
|
|
446
|
+
export function resolveGroupPositions(
|
|
447
|
+
isMember: readonly boolean[],
|
|
448
|
+
{ isAttached = true }: ButtonGroupShapeOptions = {}
|
|
449
|
+
): (ButtonGroupPosition | null)[] {
|
|
450
|
+
if (!isAttached) return isMember.map((member) => (member ? "only" : null));
|
|
451
|
+
|
|
419
452
|
const count = isMember.reduce((total, member) => (member ? total + 1 : total), 0);
|
|
420
453
|
let seen = 0;
|
|
421
454
|
|
|
@@ -445,9 +478,16 @@ export function resolveGroupPositions(isMember: readonly boolean[]): (ButtonGrou
|
|
|
445
478
|
* `isMember[-1]` is `undefined`, so the first child falls out as `false` without
|
|
446
479
|
* a case of its own.
|
|
447
480
|
*
|
|
481
|
+
* A detached run has no shared edge, so nothing overlaps: a point of overlap
|
|
482
|
+
* there would only eat into the gap.
|
|
483
|
+
*
|
|
448
484
|
* Pure, so the whole matrix is reachable from `bun test`. See AGENTS.md.
|
|
449
485
|
*/
|
|
450
|
-
export function resolveGroupSeams(
|
|
486
|
+
export function resolveGroupSeams(
|
|
487
|
+
isMember: readonly boolean[],
|
|
488
|
+
{ isAttached = true }: ButtonGroupShapeOptions = {}
|
|
489
|
+
): boolean[] {
|
|
490
|
+
if (!isAttached) return isMember.map(() => false);
|
|
451
491
|
return isMember.map((member, index) => member && isMember[index - 1] === true);
|
|
452
492
|
}
|
|
453
493
|
|
|
@@ -463,6 +503,10 @@ export function resolveGroupSeams(isMember: readonly boolean[]): boolean[] {
|
|
|
463
503
|
* while its neighbours hold still, so the seam the group exists to close tears
|
|
464
504
|
* open for the length of the press. `fade` moves no geometry.
|
|
465
505
|
*
|
|
506
|
+
* Only while the group is *attached*. A detached run has no seam to tear, so
|
|
507
|
+
* its members fall back to the lone button's `scale`. `isAttached` defaults to
|
|
508
|
+
* `true`, because joining is what a group does when it says nothing.
|
|
509
|
+
*
|
|
466
510
|
* A caller still gets `scale` by asking for it, on one button or on a whole
|
|
467
511
|
* group. `pressedScale` beats both, because `resolvePressedState` lets an
|
|
468
512
|
* explicit value win on the axis it names — see `pressable.variants.ts`.
|
|
@@ -472,9 +516,30 @@ export function resolveGroupSeams(isMember: readonly boolean[]): boolean[] {
|
|
|
472
516
|
export function resolveButtonFeedback(
|
|
473
517
|
own: PressableFeedback | undefined,
|
|
474
518
|
group: PressableFeedback | undefined,
|
|
475
|
-
isGrouped: boolean
|
|
519
|
+
isGrouped: boolean,
|
|
520
|
+
isAttached = true
|
|
476
521
|
): PressableFeedback {
|
|
477
|
-
return own ?? group ?? (isGrouped ? BUTTON_GROUP_FEEDBACK : BUTTON_FEEDBACK);
|
|
522
|
+
return own ?? group ?? (isGrouped && isAttached ? BUTTON_GROUP_FEEDBACK : BUTTON_FEEDBACK);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Whether a group's members take an equal share of its width.
|
|
527
|
+
*
|
|
528
|
+
* Horizontal only. A vertical run already stretches its members on the cross
|
|
529
|
+
* axis, and `basis-0` on the main axis of a column with no definite height
|
|
530
|
+
* would collapse every member to nothing — so `isFullWidth` on a vertical group
|
|
531
|
+
* widens the group and does nothing further.
|
|
532
|
+
*
|
|
533
|
+
* Pure, so the whole matrix is reachable from `bun test`. See AGENTS.md.
|
|
534
|
+
*/
|
|
535
|
+
export function resolveGroupMemberStretch({
|
|
536
|
+
isFullWidth,
|
|
537
|
+
orientation,
|
|
538
|
+
}: {
|
|
539
|
+
isFullWidth: boolean;
|
|
540
|
+
orientation: ButtonGroupOrientation;
|
|
541
|
+
}): boolean {
|
|
542
|
+
return isFullWidth && orientation === "horizontal";
|
|
478
543
|
}
|
|
479
544
|
|
|
480
545
|
export type ButtonVariantProps = VariantProps<typeof buttonVariants>;
|
|
@@ -28,6 +28,7 @@ export {
|
|
|
28
28
|
BUTTON_VARIANTS,
|
|
29
29
|
type ButtonGroupOrientation,
|
|
30
30
|
type ButtonGroupPosition,
|
|
31
|
+
type ButtonGroupShapeOptions,
|
|
31
32
|
type ButtonGroupSlotPosition,
|
|
32
33
|
type ButtonIconSize,
|
|
33
34
|
type ButtonLabelSize,
|
|
@@ -41,6 +42,7 @@ export {
|
|
|
41
42
|
resolveButtonLayout,
|
|
42
43
|
resolveButtonSizeStep,
|
|
43
44
|
resolveGroupedButtonSize,
|
|
45
|
+
resolveGroupMemberStretch,
|
|
44
46
|
resolveGroupPositions,
|
|
45
47
|
resolveGroupSeams,
|
|
46
48
|
} from "./button.variants";
|