@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.1.0-alpha.20261009012154",
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.20261009012154",
120
- "@delacour/react-native-charts": "0.1.0-alpha.20261009012154",
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` 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
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
- - **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.
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. For a run that fills its parent, put
134
- * `className="w-full"` on the group and `className="flex-1"` on each member.
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`, and both absences are
139
- * load-bearing. A gap is the seam this component exists to close. A clip
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(isMember: readonly boolean[]): (ButtonGroupPosition | null)[] {
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(isMember: readonly boolean[]): boolean[] {
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";