Skip to content

Commit 48a1de0

Browse files
daunjaygeorgejasonvarga
authored
[6.x] Bring back responsive button groups (#13336)
Co-authored-by: Jay George <contact@jaygeorge.co.uk> Co-authored-by: Jason Varga <jason@pixelfear.com>
1 parent 1652e74 commit 48a1de0

5 files changed

Lines changed: 251 additions & 69 deletions

File tree

resources/js/components/fieldtypes/ButtonGroupFieldtype.vue

Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<template>
2-
<ButtonGroup ref="buttonGroup">
2+
<ButtonGroup overflow="stack" ref="buttonGroup">
33
<Button
44
v-for="(option, $index) in options"
55
ref="button"
@@ -18,7 +18,6 @@
1818
<script>
1919
import Fieldtype from './Fieldtype.vue';
2020
import HasInputOptions from './HasInputOptions.js';
21-
import ResizeObserver from 'resize-observer-polyfill';
2221
import { Button, ButtonGroup } from '@/components/ui';
2322
2423
export default {
@@ -28,20 +27,6 @@ export default {
2827
ButtonGroup
2928
},
3029
31-
data() {
32-
return {
33-
resizeObserver: null,
34-
};
35-
},
36-
37-
mounted() {
38-
this.setupResizeObserver();
39-
},
40-
41-
beforeUnmount() {
42-
this.resizeObserver.disconnect();
43-
},
44-
4530
computed: {
4631
options() {
4732
return this.normalizeInputOptions(this.meta.options || this.config.options);
@@ -60,25 +45,6 @@ export default {
6045
this.update(this.value == newValue && this.config.clearable ? null : newValue);
6146
},
6247
63-
setupResizeObserver() {
64-
this.resizeObserver = new ResizeObserver(() => {
65-
this.handleWrappingOfNode(this.$refs.buttonGroup.$el);
66-
});
67-
this.resizeObserver.observe(this.$refs.buttonGroup.$el);
68-
},
69-
70-
handleWrappingOfNode(node) {
71-
const lastEl = node.lastChild;
72-
73-
if (!lastEl) return;
74-
75-
node.classList.remove('btn-vertical');
76-
77-
if (lastEl.offsetTop > node.clientTop) {
78-
node.classList.add('btn-vertical');
79-
}
80-
},
81-
8248
focus() {
8349
this.$refs.button[0].focus();
8450
},
Lines changed: 128 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,135 @@
11
<template>
2-
<div
3-
:class="[
4-
'group/button inline-flex flex-wrap [[data-floating-toolbar]_&]:justify-center [[data-floating-toolbar]_&]:gap-1 [[data-floating-toolbar]_&]:lg:gap-x-0',
5-
'[&>[data-ui-group-target]:not(:first-child):not(:last-child)]:rounded-none',
6-
'[&>[data-ui-group-target]:first-child:not(:last-child)]:rounded-e-none',
7-
'[&>[data-ui-group-target]:last-child:not(:first-child)]:rounded-s-none',
8-
'[&>*:not(:first-child):not(:last-child):not(:only-child)_[data-ui-group-target]]:rounded-none',
9-
'[&>*:first-child:not(:last-child)_[data-ui-group-target]]:rounded-e-none',
10-
'[&>*:last-child:not(:first-child)_[data-ui-group-target]]:rounded-s-none',
11-
'dark:[&_button]:ring-0',
12-
'max-lg:[[data-floating-toolbar]_&_button]:rounded-md!',
13-
'shadow-ui-sm rounded-lg'
14-
]"
15-
data-ui-button-group
16-
>
17-
<slot />
2+
<div ref="wrapper" :class="{ invisible: measuringOverflow }">
3+
<div ref="group" :class="groupClasses" :data-measuring="measuringOverflow || undefined" data-ui-button-group>
4+
<slot />
5+
</div>
186
</div>
197
</template>
208

9+
<script setup>
10+
import { ref, computed, nextTick, onMounted, onBeforeUnmount } from 'vue';
11+
import { cva } from 'cva';
12+
13+
import debounce from '@/util/debounce';
14+
15+
const props = defineProps({
16+
/* When 'stack', switch to vertical layout when overflowing. When 'gap', switch to normal buttons with gaps when overflowing. */
17+
overflow: {
18+
type: String,
19+
default: null,
20+
validator: (v) => [null, 'stack', 'gap'].includes(v),
21+
},
22+
orientation: {
23+
type: String,
24+
default: 'horizontal',
25+
},
26+
gap: {
27+
type: [String, Boolean],
28+
default: false,
29+
},
30+
justify: {
31+
type: String,
32+
default: 'start',
33+
},
34+
});
35+
36+
const hasOverflow = ref(false);
37+
const needsOverflowObserver = computed(() => props.overflow === 'stack' || props.overflow === 'gap');
38+
const measuringOverflow = ref(false);
39+
40+
const groupClasses = computed(() => {
41+
const groupShadow = 'rounded-lg shadow-ui-sm [&_[data-ui-group-target]]:shadow-none';
42+
43+
const collapseHorizontally = [
44+
'[&>[data-ui-group-target]:not(:first-child):not(:last-child)]:rounded-none',
45+
'[&>:not(:first-child):not(:last-child)_[data-ui-group-target]]:rounded-none',
46+
'[&>[data-ui-group-target]:first-child:not(:last-child)]:rounded-e-none',
47+
'[&>:first-child:not(:last-child)_[data-ui-group-target]]:rounded-e-none',
48+
'[&>[data-ui-group-target]:last-child:not(:first-child)]:rounded-s-none',
49+
'[&>:last-child:not(:first-child)_[data-ui-group-target]]:rounded-s-none',
50+
'[&>[data-ui-group-target]:not(:first-child)]:border-s-0',
51+
'[&>:not(:first-child)_[data-ui-group-target]]:border-s-0',
52+
];
53+
54+
const collapseVertically = [
55+
'flex-col',
56+
'[&>[data-ui-group-target]:not(:first-child):not(:last-child)]:rounded-none',
57+
'[&>:not(:first-child):not(:last-child)_[data-ui-group-target]]:rounded-none',
58+
'[&>[data-ui-group-target]:first-child:not(:last-child)]:rounded-b-none',
59+
'[&>:first-child:not(:last-child)_[data-ui-group-target]]:rounded-b-none',
60+
'[&>[data-ui-group-target]:last-child:not(:first-child)]:rounded-t-none',
61+
'[&>:last-child:not(:first-child)_[data-ui-group-target]]:rounded-t-none',
62+
'[&>[data-ui-group-target]:not(:last-child)]:border-b-0',
63+
'[&>:not(:last-child)_[data-ui-group-target]]:border-b-0',
64+
];
65+
66+
return cva({
67+
base: [
68+
'group/button inline-flex flex-wrap relative',
69+
'dark:[&_button]:ring-0',
70+
],
71+
variants: {
72+
orientation: {
73+
vertical: collapseVertically,
74+
},
75+
justify: {
76+
center: 'justify-center',
77+
},
78+
},
79+
compoundVariants: [
80+
{ overflow: 'stack', hasOverflow: false, class: [...collapseHorizontally, groupShadow] },
81+
{ overflow: 'stack', hasOverflow: true, class: [...collapseVertically, groupShadow] },
82+
{ overflow: 'gap', hasOverflow: true, class: 'gap-1' },
83+
{ overflow: 'gap', hasOverflow: false, class: [...collapseHorizontally, groupShadow] },
84+
{ overflow: null, orientation: 'horizontal', gap: false, class: [...collapseHorizontally, groupShadow] },
85+
],
86+
})({
87+
gap: props.gap,
88+
justify: props.justify,
89+
orientation: props.orientation,
90+
overflow: props.overflow,
91+
hasOverflow: hasOverflow.value,
92+
});
93+
});
94+
95+
const wrapper = ref(null);
96+
const group = ref(null);
97+
let resizeObserver = null;
98+
99+
async function checkOverflow() {
100+
if (!group.value?.children.length) return;
101+
102+
// Measure in collapsed state to avoid hysteresis from gap spacing
103+
hasOverflow.value = false;
104+
measuringOverflow.value = true;
105+
await nextTick();
106+
107+
// Check if any child has wrapped to a new line
108+
const children = Array.from(group.value.children);
109+
const firstTop = children[0].offsetTop;
110+
const lastTop = children[children.length - 1].offsetTop;
111+
hasOverflow.value = lastTop > firstTop;
112+
113+
// Exit measuring mode
114+
measuringOverflow.value = false;
115+
}
116+
117+
onMounted(() => {
118+
if (needsOverflowObserver.value) {
119+
checkOverflow();
120+
resizeObserver = new ResizeObserver(debounce(checkOverflow, 50));
121+
resizeObserver.observe(wrapper.value);
122+
}
123+
});
124+
125+
onBeforeUnmount(() => {
126+
resizeObserver?.disconnect();
127+
});
128+
</script>
129+
21130
<style>
22-
/* GROUP FLOATING TOOLBAR / BUTTON GROUP BORDERS
23-
=================================================== */
24-
[data-ui-button-group] [data-ui-group-target] {
25-
@apply shadow-none;
26-
27-
&:not(:first-child):not([data-floating-toolbar] &) {
28-
border-inline-start: 0;
29-
}
30-
31-
/* Account for button groups being split apart on small screens */
32-
[data-floating-toolbar] & {
33-
@media (width >= 1024px) {
34-
&:not(:first-child) {
35-
border-inline-start: 0;
36-
}
37-
}
38-
}
131+
/* Force horizontal wrap layout during measurement to detect overflow */
132+
[data-ui-button-group][data-measuring] {
133+
@apply flex! flex-row!;
39134
}
40135
</style>

resources/js/components/ui/Listing/BulkActionsFloatingToolbar.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ onUnmounted(() => document.removeEventListener('keydown', onKeydown, true));
118118
:transition="{ duration: 0.2, ease: 'easeInOut' }"
119119
>
120120
<div class="pointer-events-auto space-y-3 rounded-xl border border-gray-300/60 dark:border-gray-700 p-1 bg-gray-200/55 shadow-[0_1px_16px_-2px_rgba(63,63,71,0.2)] dark:bg-gray-800 dark:shadow-[0_10px_15px_rgba(0,0,0,.5)] dark:inset-shadow-2xs dark:inset-shadow-white/10">
121-
<ButtonGroup>
121+
<ButtonGroup overflow="gap" justify="center">
122122
<Button
123123
class="text-blue-500!"
124124
@click="clearSelections?.()"

resources/js/stories/Button.stories.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,4 +300,116 @@ export const ButtonGroups: Story = {
300300
</ButtonGroup>
301301
`,
302302
}),
303+
};
304+
305+
export const ButtonGroupOverflowStack: Story = {
306+
parameters: {
307+
docs: {
308+
source: {
309+
code: `
310+
<ButtonGroup overflow="stack">
311+
<Button text="Option A" />
312+
<Button text="Option B" />
313+
<Button text="Option C" />
314+
<Button text="Option D" />
315+
<Button text="Option E" />
316+
</ButtonGroup>
317+
`,
318+
},
319+
},
320+
},
321+
render: () => ({
322+
components: { ButtonGroup, Button },
323+
template: `
324+
<div class="w-72">
325+
<ButtonGroup overflow="stack">
326+
<Button text="Option A" />
327+
<Button text="Option B" />
328+
<Button text="Option C" />
329+
<Button text="Option D" />
330+
<Button text="Option E" />
331+
</ButtonGroup>
332+
</div>
333+
`,
334+
}),
335+
};
336+
337+
export const ButtonGroupOverflowGap: Story = {
338+
parameters: {
339+
docs: {
340+
source: {
341+
code: `
342+
<ButtonGroup overflow="gap">
343+
<Button text="Option A" />
344+
<Button text="Option B" />
345+
<Button text="Option C" />
346+
<Button text="Option D" />
347+
<Button text="Option E" />
348+
</ButtonGroup>
349+
`,
350+
},
351+
},
352+
},
353+
render: () => ({
354+
components: { ButtonGroup, Button },
355+
template: `
356+
<div class="w-72">
357+
<ButtonGroup overflow="gap">
358+
<Button text="Option A" />
359+
<Button text="Option B" />
360+
<Button text="Option C" />
361+
<Button text="Option D" />
362+
<Button text="Option E" />
363+
</ButtonGroup>
364+
</div>
365+
`,
366+
}),
367+
};
368+
369+
export const ButtonGroupOverflowVariations: Story = {
370+
render: () => ({
371+
components: { ButtonGroup, Button },
372+
template: `
373+
<div class="space-y-8">
374+
<div>
375+
<p class="text-xs font-mono text-gray-500 mb-2">overflow="stack" — fits</p>
376+
<ButtonGroup overflow="stack">
377+
<Button text="Option A" />
378+
<Button text="Option B" />
379+
</ButtonGroup>
380+
</div>
381+
<div>
382+
<p class="text-xs font-mono text-gray-500 mb-2">overflow="stack" — overflows</p>
383+
<div class="w-48">
384+
<ButtonGroup overflow="stack">
385+
<Button text="Option A" />
386+
<Button text="Option B" />
387+
<Button text="Option C" />
388+
</ButtonGroup>
389+
</div>
390+
</div>
391+
<div>
392+
<p class="text-xs font-mono text-gray-500 mb-2">overflow="gap" — fits</p>
393+
<ButtonGroup overflow="gap">
394+
<Button text="Option A" />
395+
<Button text="Option B" />
396+
</ButtonGroup>
397+
</div>
398+
<div>
399+
<p class="text-xs font-mono text-gray-500 mb-2">overflow="gap" — overflows</p>
400+
<div class="w-72">
401+
<ButtonGroup overflow="gap">
402+
<Button text="Option A" />
403+
<Button text="Option B" />
404+
<Button text="Option C" />
405+
<Button text="Option D" />
406+
<Button text="Option E" />
407+
<Button text="Option F" />
408+
<Button text="Option G" />
409+
</ButtonGroup>
410+
</div>
411+
</div>
412+
</div>
413+
`,
414+
}),
303415
};

resources/js/stories/docs/Button.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,14 @@ When using `ghost` or `subtle` button variants, you can use the `inset` prop to
4343
You can combine multiple buttons into a "button group".
4444
<Canvas of={ButtonStories.ButtonGroups} sourceState={'shown'} />
4545

46+
### Overflow
47+
By default, button groups don't handle overflow. Use the `overflow` prop to opt in to responsive behavior.
48+
49+
Use `overflow="stack"` to switch to a vertical layout when buttons don't fit on one line.
50+
<Canvas of={ButtonStories.ButtonGroupOverflowStack} sourceState={'shown'} />
51+
52+
Use `overflow="gap"` to switch to separated buttons with gaps.
53+
<Canvas of={ButtonStories.ButtonGroupOverflowGap} sourceState={'shown'} />
54+
4655
## Arguments
4756
<ArgTypes of={ButtonStories} />

0 commit comments

Comments
 (0)