Skip to content

Commit 661b0fb

Browse files
committed
feature #3215 [Toolkit][Shadcn] add ButtonGroup component (bernard-ng)
This PR was merged into the 2.x branch. Discussion ---------- [Toolkit][Shadcn] add ButtonGroup component | Q | A | -------------- | --- | Bug fix? | no | New feature? | yes | Deprecations? | no | Documentation? | no | License | MIT Add ButtonGroup, A container that groups related buttons together with consistent styling. ref: https://ui.shadcn.com/docs/components/button-group --- <img width="1128" height="348" alt="Screenshot 2025-12-05 at 02 29 31" src="https://github.com/user-attachments/assets/3a1e002f-0254-48fd-a93e-efcea0f1dbea" /> <img width="1120" height="319" alt="Screenshot 2025-12-05 at 02 29 39" src="https://github.com/user-attachments/assets/07420003-5a08-40f3-a7e5-f64b942d6a44" /> <img width="1122" height="533" alt="Screenshot 2025-12-05 at 02 29 46" src="https://github.com/user-attachments/assets/67b19fda-1ef3-450c-9985-71b546fd919a" /> <img width="1129" height="358" alt="Screenshot 2025-12-05 at 02 30 04" src="https://github.com/user-attachments/assets/06bbccbe-e179-41d6-ac45-142fcdae4273" /> Commits ------- e0f0c6e [Toolkit][Shadcn] add ButtonGroup component
2 parents 785185a + e0f0c6e commit 661b0fb

12 files changed

+402
-0
lines changed
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Examples
2+
3+
## Default
4+
5+
```twig {"preview":true,"height":"220px"}
6+
<twig:ButtonGroup>
7+
<twig:ButtonGroup class="hidden sm:flex">
8+
<twig:Button variant="outline" size="icon" aria-label="Go back">
9+
<twig:ux:icon name="lucide:arrow-left" class="size-4" />
10+
</twig:Button>
11+
</twig:ButtonGroup>
12+
<twig:ButtonGroup>
13+
<twig:Button variant="outline">Archive</twig:Button>
14+
<twig:Button variant="outline">Report</twig:Button>
15+
</twig:ButtonGroup>
16+
<twig:ButtonGroup>
17+
<twig:Button variant="outline">
18+
<twig:ux:icon name="lucide:clock" class="size-4" />
19+
Snooze
20+
</twig:Button>
21+
<twig:Button variant="outline" size="icon" aria-label="More options">
22+
<twig:ux:icon name="lucide:more-horizontal" class="size-4" />
23+
</twig:Button>
24+
</twig:ButtonGroup>
25+
</twig:ButtonGroup>
26+
```
27+
28+
## Orientation
29+
30+
```twig {"preview":true,"height":"200px"}
31+
<twig:ButtonGroup orientation="vertical" aria-label="Media controls" class="h-fit">
32+
<twig:Button variant="outline" size="icon">
33+
<twig:ux:icon name="lucide:plus" class="size-4" />
34+
</twig:Button>
35+
<twig:Button variant="outline" size="icon">
36+
<twig:ux:icon name="lucide:minus" class="size-4" />
37+
</twig:Button>
38+
</twig:ButtonGroup>
39+
```
40+
41+
## Size
42+
43+
```twig {"preview":true,"height":"420px"}
44+
<div class="flex flex-col items-start gap-8">
45+
<twig:ButtonGroup>
46+
<twig:Button variant="outline" size="sm">Small</twig:Button>
47+
<twig:Button variant="outline" size="sm">Button</twig:Button>
48+
<twig:Button variant="outline" size="sm">Group</twig:Button>
49+
<twig:Button variant="outline" size="icon-sm">
50+
<twig:ux:icon name="lucide:plus" class="size-4" />
51+
</twig:Button>
52+
</twig:ButtonGroup>
53+
<twig:ButtonGroup>
54+
<twig:Button variant="outline">Default</twig:Button>
55+
<twig:Button variant="outline">Button</twig:Button>
56+
<twig:Button variant="outline">Group</twig:Button>
57+
<twig:Button variant="outline" size="icon">
58+
<twig:ux:icon name="lucide:plus" class="size-4" />
59+
</twig:Button>
60+
</twig:ButtonGroup>
61+
<twig:ButtonGroup>
62+
<twig:Button variant="outline" size="lg">Large</twig:Button>
63+
<twig:Button variant="outline" size="lg">Button</twig:Button>
64+
<twig:Button variant="outline" size="lg">Group</twig:Button>
65+
<twig:Button variant="outline" size="icon-lg">
66+
<twig:ux:icon name="lucide:plus" class="size-5" />
67+
</twig:Button>
68+
</twig:ButtonGroup>
69+
</div>
70+
```
71+
72+
## Input
73+
74+
```twig {"preview":true,"height":"200px"}
75+
<twig:ButtonGroup class="max-w-md">
76+
<twig:Input placeholder="Search..." />
77+
<twig:Button size="icon-lg" variant="outline" aria-label="Search">
78+
<twig:ux:icon name="lucide:search" class="size-4" />
79+
</twig:Button>
80+
</twig:ButtonGroup>
81+
```
82+
83+
## Nested
84+
85+
```twig {"preview":true,"height":"240px"}
86+
<twig:ButtonGroup>
87+
<twig:ButtonGroup>
88+
<twig:Button variant="outline" size="sm">1</twig:Button>
89+
<twig:Button variant="outline" size="sm">2</twig:Button>
90+
<twig:Button variant="outline" size="sm">3</twig:Button>
91+
<twig:Button variant="outline" size="sm">4</twig:Button>
92+
<twig:Button variant="outline" size="sm">5</twig:Button>
93+
</twig:ButtonGroup>
94+
<twig:ButtonGroup>
95+
<twig:Button variant="outline" size="icon-sm" aria-label="Previous">
96+
<twig:ux:icon name="lucide:arrow-left" class="size-4" />
97+
</twig:Button>
98+
<twig:Button variant="outline" size="icon-sm" aria-label="Next">
99+
<twig:ux:icon name="lucide:arrow-right" class="size-4" />
100+
</twig:Button>
101+
</twig:ButtonGroup>
102+
</twig:ButtonGroup>
103+
```
104+
105+
## Separator
106+
107+
```twig {"preview":true,"height":"200px"}
108+
<twig:ButtonGroup>
109+
<twig:Button variant="secondary" size="sm">Copy</twig:Button>
110+
<twig:ButtonGroup:Separator />
111+
<twig:Button variant="secondary" size="sm">Paste</twig:Button>
112+
</twig:ButtonGroup>
113+
```
114+
115+
## Split
116+
117+
```twig {"preview":true,"height":"200px"}
118+
<twig:ButtonGroup>
119+
<twig:Button variant="secondary">Button</twig:Button>
120+
<twig:ButtonGroup:Separator />
121+
<twig:Button size="icon" variant="secondary">
122+
<twig:ux:icon name="tabler:plus" class="size-4" />
123+
</twig:Button>
124+
</twig:ButtonGroup>
125+
```
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"$schema": "../../../schema-kit-recipe-v1.json",
3+
"type": "component",
4+
"name": "Button Group",
5+
"description": "A layout helper for grouping buttons and related controls with shared borders and separators.",
6+
"copy-files": {
7+
"templates/": "templates/"
8+
},
9+
"dependencies": {
10+
"composer": ["twig/extra-bundle", "twig/html-extra:^3.12.0", "tales-from-a-dev/twig-tailwind-extra:^1.0.0"],
11+
"recipe": ["separator"]
12+
}
13+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{# @prop orientation 'horizontal'|'vertical' The orientation, default to `horizontal` #}
2+
{# @block content The default block #}
3+
{%- props orientation = 'horizontal' -%}
4+
{%- set style = html_cva(
5+
base: 'flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*=\'w-\'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2',
6+
variants: {
7+
orientation: {
8+
horizontal: '[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
9+
vertical: 'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
10+
},
11+
},
12+
default_variant: {
13+
orientation: 'horizontal',
14+
},
15+
) -%}
16+
17+
<div
18+
role="group"
19+
data-slot="button-group"
20+
data-orientation="{{ orientation }}"
21+
class="{{ style.apply({orientation: orientation}, attributes.render('class'))|tailwind_merge }}"
22+
{{ attributes }}
23+
>
24+
{%- block content %}{% endblock -%}
25+
</div>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{# @prop orientation 'horizontal'|'vertical' The orientation of the separator, default to `vertical` #}
2+
{# @block content The default block #}
3+
{%- props orientation = 'vertical' -%}
4+
<twig:Separator
5+
orientation="{{ orientation }}"
6+
data-slot="button-group-separator"
7+
class="bg-input relative m-0! self-stretch {{ orientation == 'vertical' ? 'h-auto' }} {{ attributes.render('class')|tailwind_merge }}"
8+
{{ ...attributes }}
9+
/>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{# @prop as 'div' The HTML tag to use, default to `div` #}
2+
{# @block content The default block #}
3+
{%- props as = 'div' -%}
4+
<{{ as }}
5+
data-slot="button-group-text"
6+
class="{{ 'bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4 ' ~ attributes.render('class')|tailwind_merge }}"
7+
{{ attributes }}
8+
>
9+
{%- block content %}{% endblock -%}
10+
</{{ as }}>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<!--
2+
- Kit: Shadcn UI
3+
- Component: Button Group
4+
- Code:
5+
```twig
6+
<twig:ButtonGroup>
7+
<twig:ButtonGroup class="hidden sm:flex">
8+
<twig:Button variant="outline" size="icon" aria-label="Go back">
9+
<twig:ux:icon name="lucide:arrow-left" class="size-4" />
10+
</twig:Button>
11+
</twig:ButtonGroup>
12+
<twig:ButtonGroup>
13+
<twig:Button variant="outline">Archive</twig:Button>
14+
<twig:Button variant="outline">Report</twig:Button>
15+
</twig:ButtonGroup>
16+
<twig:ButtonGroup>
17+
<twig:Button variant="outline">
18+
<twig:ux:icon name="lucide:clock" class="size-4" />
19+
Snooze
20+
</twig:Button>
21+
<twig:Button variant="outline" size="icon" aria-label="More options">
22+
<twig:ux:icon name="lucide:more-horizontal" class="size-4" />
23+
</twig:Button>
24+
</twig:ButtonGroup>
25+
</twig:ButtonGroup>
26+
```
27+
- Rendered code (prettified for testing purposes, run "php vendor/bin/phpunit -d --update-snapshots" to update snapshots): -->
28+
<div role="group" data-slot="button-group" data-orientation="horizontal" class="flex w-fit items-stretch [&amp;&gt;*]:focus-visible:z-10 [&amp;&gt;*]:focus-visible:relative [&amp;&gt;[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&amp;&gt;input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&amp;&gt;[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[&gt;[data-slot=button-group]]:gap-2 [&amp;&gt;*:not(:first-child)]:rounded-l-none [&amp;&gt;*:not(:first-child)]:border-l-0 [&amp;&gt;*:not(:last-child)]:rounded-r-none">
29+
<div role="group" data-slot="button-group" data-orientation="horizontal" class="w-fit items-stretch [&amp;&gt;*]:focus-visible:z-10 [&amp;&gt;*]:focus-visible:relative [&amp;&gt;[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&amp;&gt;input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&amp;&gt;[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[&gt;[data-slot=button-group]]:gap-2 [&amp;&gt;*:not(:first-child)]:rounded-l-none [&amp;&gt;*:not(:first-child)]:border-l-0 [&amp;&gt;*:not(:last-child)]:rounded-r-none hidden sm:flex">
30+
<button data-slot="button" class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 shrink-0 [&amp;_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 size-9" aria-label="Go back"><svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24" fill="currentColor" class="size-4" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m12 19l-7-7l7-7m7 7H5"></path></svg>
31+
</button>
32+
</div>
33+
<div role="group" data-slot="button-group" data-orientation="horizontal" class="flex w-fit items-stretch [&amp;&gt;*]:focus-visible:z-10 [&amp;&gt;*]:focus-visible:relative [&amp;&gt;[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&amp;&gt;input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&amp;&gt;[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[&gt;[data-slot=button-group]]:gap-2 [&amp;&gt;*:not(:first-child)]:rounded-l-none [&amp;&gt;*:not(:first-child)]:border-l-0 [&amp;&gt;*:not(:last-child)]:rounded-r-none">
34+
<button data-slot="button" class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 shrink-0 [&amp;_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 h-9 px-4 py-2 has-[&gt;svg]:px-3">Archive</button>
35+
<button data-slot="button" class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 shrink-0 [&amp;_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 h-9 px-4 py-2 has-[&gt;svg]:px-3">Report</button>
36+
</div>
37+
<div role="group" data-slot="button-group" data-orientation="horizontal" class="flex w-fit items-stretch [&amp;&gt;*]:focus-visible:z-10 [&amp;&gt;*]:focus-visible:relative [&amp;&gt;[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&amp;&gt;input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&amp;&gt;[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[&gt;[data-slot=button-group]]:gap-2 [&amp;&gt;*:not(:first-child)]:rounded-l-none [&amp;&gt;*:not(:first-child)]:border-l-0 [&amp;&gt;*:not(:last-child)]:rounded-r-none">
38+
<button data-slot="button" class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 shrink-0 [&amp;_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 h-9 px-4 py-2 has-[&gt;svg]:px-3"><svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24" fill="currentColor" class="size-4" aria-hidden="true"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 6v6l4 2"></path><circle cx="12" cy="12" r="10"></circle></g></svg>
39+
Snooze
40+
</button>
41+
<button data-slot="button" class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 shrink-0 [&amp;_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 size-9" aria-label="More options"><svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24" fill="currentColor" class="size-4" aria-hidden="true"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle><circle cx="5" cy="12" r="1"></circle></g></svg>
42+
</button>
43+
</div>
44+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<!--
2+
- Kit: Shadcn UI
3+
- Component: Button Group
4+
- Code:
5+
```twig
6+
<twig:ButtonGroup orientation="vertical" aria-label="Media controls" class="h-fit">
7+
<twig:Button variant="outline" size="icon">
8+
<twig:ux:icon name="lucide:plus" class="size-4" />
9+
</twig:Button>
10+
<twig:Button variant="outline" size="icon">
11+
<twig:ux:icon name="lucide:minus" class="size-4" />
12+
</twig:Button>
13+
</twig:ButtonGroup>
14+
```
15+
- Rendered code (prettified for testing purposes, run "php vendor/bin/phpunit -d --update-snapshots" to update snapshots): -->
16+
<div role="group" data-slot="button-group" data-orientation="vertical" class="flex w-fit items-stretch [&amp;&gt;*]:focus-visible:z-10 [&amp;&gt;*]:focus-visible:relative [&amp;&gt;[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&amp;&gt;input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&amp;&gt;[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[&gt;[data-slot=button-group]]:gap-2 flex-col [&amp;&gt;*:not(:first-child)]:rounded-t-none [&amp;&gt;*:not(:first-child)]:border-t-0 [&amp;&gt;*:not(:last-child)]:rounded-b-none h-fit" aria-label="Media controls">
17+
<button data-slot="button" class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 shrink-0 [&amp;_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 size-9"><svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24" fill="currentColor" class="size-4" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-7-7v14"></path></svg>
18+
</button>
19+
<button data-slot="button" class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*='size-'])]:size-4 shrink-0 [&amp;_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 size-9"><svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24" fill="currentColor" class="size-4" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14"></path></svg>
20+
</button>
21+
</div>

0 commit comments

Comments
 (0)