@varialkit/buttongroup 0.1.0

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/docs.md ADDED
@@ -0,0 +1,95 @@
1
+ # ButtonGroup
2
+
3
+ The ButtonGroup component arranges multiple `@solara/button` buttons into a unified control with shared borders and smart corner rounding. Use it for grouped actions, toolbars, and split-button patterns.
4
+
5
+ ## How to Use
6
+
7
+ Import the component from `@solara/buttongroup` and pass `Button` elements or the `buttons` array prop.
8
+
9
+ ```tsx
10
+ import { Button } from "@solara/button";
11
+ import { ButtonGroup } from "@solara/buttongroup";
12
+
13
+ export function Example() {
14
+ return (
15
+ <ButtonGroup>
16
+ <Button label="Save" variant="primary" />
17
+ <Button label="Duplicate" variant="default" />
18
+ <Button label="Delete" variant="ghost" destructive />
19
+ </ButtonGroup>
20
+ );
21
+ }
22
+ ```
23
+
24
+ ## Features
25
+
26
+ - **Flush layout** with overlap for seamless borders.
27
+ - **Smart corner rounding** that keeps the outer radius while flattening inner edges.
28
+ - **Orientation support** for horizontal and vertical stacks.
29
+ - **Shared props** to update every button consistently.
30
+ - **Optional spacing control** to add gaps between buttons.
31
+ - **Icon support** through the underlying Button props (`iconLeft`, `iconRight`, `iconOnly`).
32
+
33
+ ## Props
34
+
35
+ | Prop | Type | Default | Description |
36
+ | --- | --- | --- | --- |
37
+ | `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Direction of the button group layout. |
38
+ | `fullWidth` | `boolean` | `false` | Stretch the group to fill its container. |
39
+ | `gap` | `number \| string` | `0` | Spacing between buttons. Use `0` for flush borders. |
40
+ | `radius` | `"default" \| "none" \| "full"` | `undefined` | Radius applied to every button in the group. |
41
+ | `buttonProps` | `Partial<ButtonProps>` | `undefined` | Props applied to every button (child props win). |
42
+ | `buttons` | `ButtonProps[]` | `undefined` | Optional button definitions appended after children. |
43
+ | `children` | `React.ReactNode` | `undefined` | Button elements to render inside the group. |
44
+ | `className` | `string` | `undefined` | Additional class name for the group container. |
45
+
46
+ ## Examples
47
+
48
+ ### Vertical Stack with Full Width
49
+
50
+ ```tsx
51
+ <ButtonGroup orientation="vertical" fullWidth>
52
+ <Button label="Top" />
53
+ <Button label="Middle" />
54
+ <Button label="Bottom" />
55
+ </ButtonGroup>
56
+ ```
57
+
58
+ ### Shared Props
59
+
60
+ ```tsx
61
+ <ButtonGroup buttonProps={{ size: "small", variant: "tertiary", iconLeft: "arrow_swap_16" }}>
62
+ <Button label="Left" />
63
+ <Button label="Center" />
64
+ <Button label="Right" />
65
+ </ButtonGroup>
66
+ ```
67
+
68
+ ### Icon-Only Buttons
69
+
70
+ ```tsx
71
+ <ButtonGroup gap={8} buttons={[
72
+ { iconOnly: "arrow_line_up_16", "aria-label": "Upload" },
73
+ { iconOnly: "arrow_line_down_16", "aria-label": "Download" },
74
+ ]} />
75
+ ```
76
+
77
+ ### Using the `buttons` Prop
78
+
79
+ ```tsx
80
+ <ButtonGroup
81
+ gap={8}
82
+ radius="full"
83
+ buttons={[
84
+ { label: "Today", variant: "default" },
85
+ { label: "Week", variant: "default" },
86
+ { label: "Month", variant: "default" },
87
+ ]}
88
+ />
89
+ ```
90
+
91
+ ## Accessibility
92
+
93
+ - `role="group"` is applied to the container for screen readers.
94
+ - Each button keeps its own `aria-*` attributes and focus styles.
95
+ - Provide `aria-label` for icon-only buttons.
package/examples.tsx ADDED
@@ -0,0 +1,187 @@
1
+ import React from "react";
2
+ import { Button } from "@solara/button";
3
+ import type { ButtonRadius, ButtonSize, ButtonVariant } from "@solara/button";
4
+ import { iconNames } from "@solara/icons";
5
+ import type { SolaraIconName } from "@solara/icons";
6
+ import { ButtonGroup } from "./src/ButtonGroup";
7
+ import type { ButtonGroupOrientation } from "./src/ButtonGroup.types";
8
+
9
+ type PlaygroundProps = {
10
+ orientation: ButtonGroupOrientation;
11
+ fullWidth: boolean;
12
+ gap: number;
13
+ radius: ButtonRadius;
14
+ size: ButtonSize;
15
+ variant: ButtonVariant;
16
+ iconLeft: SolaraIconName | "";
17
+ };
18
+
19
+ const ButtonGroupPlayground = ({
20
+ orientation,
21
+ fullWidth,
22
+ gap,
23
+ radius,
24
+ size,
25
+ variant,
26
+ iconLeft,
27
+ }: PlaygroundProps) => {
28
+ const resolvedIconLeft = iconLeft || undefined;
29
+
30
+ return (
31
+ <ButtonGroup
32
+ orientation={orientation}
33
+ fullWidth={fullWidth}
34
+ gap={gap}
35
+ radius={radius}
36
+ buttonProps={{ size, variant, iconLeft: resolvedIconLeft }}
37
+ >
38
+ <Button label="Save" />
39
+ <Button label="Duplicate" />
40
+ <Button label="Delete" destructive />
41
+ </ButtonGroup>
42
+ );
43
+ };
44
+
45
+ export const stories = {
46
+ playground: {
47
+ title: "Playground",
48
+ description: "Adjust orientation, spacing, and shared button props.",
49
+ render: (props: PlaygroundProps) => <ButtonGroupPlayground {...props} />,
50
+ controls: [
51
+ {
52
+ name: "orientation",
53
+ type: "select",
54
+ options: ["horizontal", "vertical"],
55
+ },
56
+ {
57
+ name: "fullWidth",
58
+ type: "boolean",
59
+ label: "Full Width",
60
+ },
61
+ {
62
+ name: "gap",
63
+ type: "number",
64
+ label: "Gap (px)",
65
+ min: 0,
66
+ max: 24,
67
+ step: 1,
68
+ },
69
+ {
70
+ name: "radius",
71
+ type: "select",
72
+ options: ["default", "none", "full"],
73
+ },
74
+ {
75
+ name: "size",
76
+ type: "select",
77
+ options: ["small", "medium", "large"],
78
+ },
79
+ {
80
+ name: "variant",
81
+ type: "select",
82
+ options: ["default", "primary", "tertiary", "ghost", "accent"],
83
+ },
84
+ {
85
+ name: "iconLeft",
86
+ label: "Icon Left",
87
+ type: "select",
88
+ options: ["", ...iconNames],
89
+ },
90
+ ],
91
+ initialProps: {
92
+ orientation: "horizontal",
93
+ fullWidth: false,
94
+ gap: 0,
95
+ radius: "default",
96
+ size: "medium",
97
+ variant: "default",
98
+ iconLeft: "",
99
+ },
100
+ },
101
+ buttonsProp: {
102
+ title: "Buttons Prop",
103
+ description: "Provide button definitions without manual children.",
104
+ showProps: false,
105
+ render: () => (
106
+ <ButtonGroup
107
+ gap={8}
108
+ radius="full"
109
+ buttons={[
110
+ { label: "Today", variant: "default" },
111
+ { label: "Week", variant: "default" },
112
+ { label: "Month", variant: "default" },
113
+ ]}
114
+ />
115
+ ),
116
+ code: `import { ButtonGroup } from "@solara/buttongroup";
117
+
118
+ export function Example() {
119
+ return (
120
+ <ButtonGroup
121
+ gap={8}
122
+ radius="full"
123
+ buttons={[
124
+ { label: "Today", variant: "default" },
125
+ { label: "Week", variant: "default" },
126
+ { label: "Month", variant: "default" },
127
+ ]}
128
+ />
129
+ );
130
+ }
131
+ `,
132
+ },
133
+ vertical: {
134
+ title: "Vertical",
135
+ description: "Stack buttons vertically and stretch to full width.",
136
+ showProps: false,
137
+ render: () => (
138
+ <div style={{ width: 260 }}>
139
+ <ButtonGroup orientation="vertical" fullWidth>
140
+ <Button label="Move" />
141
+ <Button label="Rename" />
142
+ <Button label="Archive" />
143
+ </ButtonGroup>
144
+ </div>
145
+ ),
146
+ code: `import { Button } from "@solara/button";
147
+ import { ButtonGroup } from "@solara/buttongroup";
148
+
149
+ export function Example() {
150
+ return (
151
+ <div style={{ width: 260 }}>
152
+ <ButtonGroup orientation="vertical" fullWidth>
153
+ <Button label="Move" />
154
+ <Button label="Rename" />
155
+ <Button label="Archive" />
156
+ </ButtonGroup>
157
+ </div>
158
+ );
159
+ }
160
+ `,
161
+ },
162
+ icons: {
163
+ title: "Icons",
164
+ description: "Use icon props on buttons inside the group.",
165
+ showProps: false,
166
+ render: () => (
167
+ <ButtonGroup gap={8} radius="full" buttonProps={{ iconLeft: "arrow_swap_16" }}>
168
+ <Button label="Sync" />
169
+ <Button label="Replace" />
170
+ <Button label="Merge" />
171
+ </ButtonGroup>
172
+ ),
173
+ code: `import { Button } from "@solara/button";
174
+ import { ButtonGroup } from "@solara/buttongroup";
175
+
176
+ export function Example() {
177
+ return (
178
+ <ButtonGroup gap={8} radius="full" buttonProps={{ iconLeft: "arrow_swap_16" }}>
179
+ <Button label="Sync" />
180
+ <Button label="Replace" />
181
+ <Button label="Merge" />
182
+ </ButtonGroup>
183
+ );
184
+ }
185
+ `,
186
+ },
187
+ };
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@varialkit/buttongroup",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./examples": "./examples.tsx"
10
+ },
11
+ "dependencies": {
12
+ "@varialkit/button": "0.1.0",
13
+ "@varialkit/icons": "0.1.0"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "docs.md",
18
+ "examples.tsx"
19
+ ],
20
+ "peerDependencies": {
21
+ "react": "^19.0.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/react": "19.0.10",
25
+ "react": "19.0.0"
26
+ }
27
+ }
@@ -0,0 +1,92 @@
1
+ .solara-button-group {
2
+ --button-group-gap: 1px;
3
+ --button-group-overlap: 0px;
4
+
5
+ display: inline-flex;
6
+ align-items: center;
7
+ position: relative;
8
+ }
9
+
10
+ .solara-button-group--horizontal {
11
+ flex-direction: row;
12
+ }
13
+
14
+ .solara-button-group--vertical {
15
+ flex-direction: column;
16
+ align-items: stretch;
17
+ }
18
+
19
+ .solara-button-group--horizontal > .solara-button-group__button + .solara-button-group__button {
20
+ margin-left: calc(
21
+ (var(--button-group-gap) * var(--spacing-multiplier)) -
22
+ (var(--button-group-overlap) * var(--spacing-multiplier))
23
+ );
24
+ }
25
+
26
+ .solara-button-group--vertical > .solara-button-group__button + .solara-button-group__button {
27
+ margin-top: calc(
28
+ (var(--button-group-gap) * var(--spacing-multiplier)) -
29
+ (var(--button-group-overlap) * var(--spacing-multiplier))
30
+ );
31
+ }
32
+
33
+ .solara-button-group__button {
34
+ position: relative;
35
+ z-index: 1;
36
+ flex-shrink: 0;
37
+ }
38
+
39
+ .solara-button-group__button:hover,
40
+ .solara-button-group__button:focus {
41
+ z-index: 2;
42
+ }
43
+
44
+ .solara-button-group__button:active {
45
+ z-index: 3;
46
+ }
47
+
48
+ .solara-button-group__button:focus-visible {
49
+ z-index: 4;
50
+ }
51
+
52
+ .solara-button-group--horizontal .solara-button-group__button--first {
53
+ border-top-right-radius: 0;
54
+ border-bottom-right-radius: 0;
55
+ }
56
+
57
+ .solara-button-group--horizontal .solara-button-group__button--middle {
58
+ border-radius: 0;
59
+ }
60
+
61
+ .solara-button-group--horizontal .solara-button-group__button--last {
62
+ border-top-left-radius: 0;
63
+ border-bottom-left-radius: 0;
64
+ }
65
+
66
+ .solara-button-group--vertical .solara-button-group__button--first {
67
+ border-bottom-left-radius: 0;
68
+ border-bottom-right-radius: 0;
69
+ }
70
+
71
+ .solara-button-group--vertical .solara-button-group__button--middle {
72
+ border-radius: 0;
73
+ }
74
+
75
+ .solara-button-group--vertical .solara-button-group__button--last {
76
+ border-top-left-radius: 0;
77
+ border-top-right-radius: 0;
78
+ }
79
+
80
+ .solara-button-group--full-width {
81
+ width: 100%;
82
+ }
83
+
84
+ .solara-button-group--full-width.solara-button-group--horizontal
85
+ > .solara-button-group__button {
86
+ flex: 1 1 0%;
87
+ }
88
+
89
+ .solara-button-group--full-width.solara-button-group--vertical
90
+ > .solara-button-group__button {
91
+ width: 100%;
92
+ }
@@ -0,0 +1,133 @@
1
+ import React, { Children, cloneElement, isValidElement, useMemo } from "react";
2
+ import { Button } from "@solara/button";
3
+ import type { ButtonProps } from "@solara/button";
4
+ import type { ButtonGroupProps } from "./ButtonGroup.types";
5
+ import "./ButtonGroup.scss";
6
+
7
+ const classNames = (...classes: Array<string | false | null | undefined>) =>
8
+ classes.filter(Boolean).join(" ");
9
+
10
+ const isZeroGap = (gap: ButtonGroupProps["gap"]) => {
11
+ if (gap === undefined || gap === null) return true;
12
+ if (typeof gap === "number") return gap === 0;
13
+ const trimmed = gap.trim();
14
+ return trimmed === "0" || trimmed === "0px" || trimmed === "0rem" || trimmed === "0em";
15
+ };
16
+
17
+ const resolveGapValue = (gap: ButtonGroupProps["gap"]) => {
18
+ if (gap === undefined || gap === null) return "0px";
19
+ return typeof gap === "number" ? `${gap}px` : gap;
20
+ };
21
+
22
+ const isButtonElement = (
23
+ child: React.ReactNode
24
+ ): child is React.ReactElement<ButtonProps> => isValidElement(child) && child.type === Button;
25
+
26
+ const mergeButtonProps = (
27
+ sharedProps: Partial<ButtonProps>,
28
+ childProps: ButtonProps,
29
+ className: string
30
+ ) => {
31
+ return {
32
+ ...sharedProps,
33
+ ...childProps,
34
+ className: classNames(sharedProps.className, childProps.className, className),
35
+ } as ButtonProps;
36
+ };
37
+
38
+ export const ButtonGroup = React.forwardRef<HTMLDivElement, ButtonGroupProps>(
39
+ (
40
+ {
41
+ orientation = "horizontal",
42
+ fullWidth = false,
43
+ gap,
44
+ radius,
45
+ buttonProps,
46
+ buttons,
47
+ className,
48
+ children,
49
+ style,
50
+ ...props
51
+ },
52
+ ref
53
+ ) => {
54
+ const sharedButtonProps = useMemo(() => {
55
+ if (!buttonProps && !radius) return undefined;
56
+ const shared: Partial<ButtonProps> = { ...(buttonProps ?? {}) };
57
+ if (radius) shared.radius = radius;
58
+ return shared;
59
+ }, [buttonProps, radius]);
60
+
61
+ const renderedButtons = useMemo(
62
+ () =>
63
+ (buttons ?? []).map((button, index) => (
64
+ <Button key={button.key ?? index} {...button} />
65
+ )),
66
+ [buttons]
67
+ );
68
+
69
+ const combinedChildren = useMemo(() => {
70
+ const baseChildren = Children.toArray(children);
71
+ if (renderedButtons.length === 0) return baseChildren;
72
+ return baseChildren.concat(renderedButtons);
73
+ }, [children, renderedButtons]);
74
+
75
+ const totalButtons = useMemo(
76
+ () => combinedChildren.filter((child) => isButtonElement(child)).length,
77
+ [combinedChildren]
78
+ );
79
+
80
+ if (combinedChildren.length === 0) return null;
81
+
82
+ let buttonIndex = 0;
83
+ const processedChildren = combinedChildren.map((child) => {
84
+ if (!isButtonElement(child)) return child;
85
+
86
+ const isSingle = totalButtons === 1;
87
+ const isFirst = buttonIndex === 0;
88
+ const isLast = buttonIndex === totalButtons - 1;
89
+ const isMiddle = !isFirst && !isLast;
90
+ buttonIndex += 1;
91
+
92
+ const positionClass = classNames(
93
+ "solara-button-group__button",
94
+ !isSingle && isFirst && "solara-button-group__button--first",
95
+ !isSingle && isMiddle && "solara-button-group__button--middle",
96
+ !isSingle && isLast && "solara-button-group__button--last"
97
+ );
98
+
99
+ if (!sharedButtonProps) {
100
+ return cloneElement(child, {
101
+ ...child.props,
102
+ className: classNames(child.props.className, positionClass),
103
+ });
104
+ }
105
+
106
+ return cloneElement(child, mergeButtonProps(sharedButtonProps, child.props, positionClass));
107
+ });
108
+
109
+ const mergedStyle = {
110
+ ...style,
111
+ } as React.CSSProperties;
112
+
113
+ return (
114
+ <div
115
+ ref={ref}
116
+ className={classNames(
117
+ "solara-button-group",
118
+ `solara-button-group--${orientation}`,
119
+ fullWidth && "solara-button-group--full-width",
120
+ className
121
+ )}
122
+ role="group"
123
+ aria-orientation={orientation}
124
+ style={mergedStyle}
125
+ {...props}
126
+ >
127
+ {processedChildren}
128
+ </div>
129
+ );
130
+ }
131
+ );
132
+
133
+ ButtonGroup.displayName = "ButtonGroup";
@@ -0,0 +1,44 @@
1
+ import type React from "react";
2
+ import type { ButtonProps, ButtonRadius } from "@solara/button";
3
+
4
+ export type ButtonGroupOrientation = "horizontal" | "vertical";
5
+
6
+ export type ButtonGroupButton = ButtonProps & { key?: React.Key };
7
+
8
+ export interface ButtonGroupProps
9
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
10
+ /**
11
+ * The orientation of the button group.
12
+ * @default "horizontal"
13
+ */
14
+ orientation?: ButtonGroupOrientation;
15
+ /**
16
+ * Whether the button group should take up the full width of its container.
17
+ * @default false
18
+ */
19
+ fullWidth?: boolean;
20
+ /**
21
+ * Spacing between buttons. Use `0` for flush edges (default).
22
+ */
23
+ gap?: number | string;
24
+ /**
25
+ * Override the radius for the grouped buttons.
26
+ */
27
+ radius?: ButtonRadius;
28
+ /**
29
+ * Props applied to every button in the group. Individual button props win.
30
+ */
31
+ buttonProps?: Partial<ButtonProps>;
32
+ /**
33
+ * Optional button definitions appended after children.
34
+ */
35
+ buttons?: ButtonGroupButton[];
36
+ /**
37
+ * Button elements to render inside the group.
38
+ */
39
+ children?: React.ReactNode;
40
+ /**
41
+ * Optional class name to add to the button group.
42
+ */
43
+ className?: string;
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { ButtonGroup } from "./ButtonGroup";
2
+ export type {
3
+ ButtonGroupProps,
4
+ ButtonGroupButton,
5
+ ButtonGroupOrientation,
6
+ } from "./ButtonGroup.types";