Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c926263
Remove button group resize observer
daun Dec 11, 2025
6537e75
Add auto orientation option to button groups
daun Dec 11, 2025
b61030d
Make button group fieldtype auto orientation
daun Dec 11, 2025
2fbce23
Add horizontal orientation styles
daun Dec 11, 2025
725e36e
Clean up button group styles
daun Dec 11, 2025
baf2ff3
Switch to component setup script
daun Dec 12, 2025
293d6a6
Add wrapper to allow observing resize
daun Dec 12, 2025
7589234
Build button group classes using cva
daun Dec 12, 2025
0ea4f81
Collapse vertically
daun Dec 12, 2025
3fea128
Remove unsupported variant
daun Dec 12, 2025
4a9e1d1
Fix horizontal styles
daun Dec 12, 2025
abcddc4
Make sure shadows hug contents
daun Dec 12, 2025
bde002c
Merge branch '6.x' into fix/button-group-orientation-cva
daun Feb 13, 2026
a59e986
Merge branch '6.x' into pr/13336
jaygeorge Feb 17, 2026
c278710
Change orientation="auto" / gap="auto" to a single prop of overflow =…
jaygeorge Feb 17, 2026
8f48167
Merge branch '6.x' into fix/button-group-orientation-cva
daun Mar 9, 2026
de6dfe1
Deduplicate default button group styles
daun Mar 9, 2026
9d990c7
default to stack
jasonvarga Mar 27, 2026
c82a92f
add stories
jasonvarga Mar 27, 2026
7c11f83
fix middle buttons not being rounded.
jasonvarga Mar 27, 2026
e801bd0
story to see all overflow variations
jasonvarga Mar 27, 2026
3863993
fix shadow being applied to gapped overflow and not to its buttons
jasonvarga Mar 27, 2026
50e4928
fix measurement hysteresis by resetting overflow state before measuring
jasonvarga Mar 27, 2026
41e3865
go back to no overflow by default
jasonvarga Mar 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 1 addition & 35 deletions resources/js/components/fieldtypes/ButtonGroupFieldtype.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<ButtonGroup ref="buttonGroup">
<ButtonGroup overflow="stack" ref="buttonGroup">
<Button
v-for="(option, $index) in options"
ref="button"
Expand All @@ -18,7 +18,6 @@
<script>
import Fieldtype from './Fieldtype.vue';
import HasInputOptions from './HasInputOptions.js';
import ResizeObserver from 'resize-observer-polyfill';
import { Button, ButtonGroup } from '@/components/ui';

export default {
Expand All @@ -28,20 +27,6 @@ export default {
ButtonGroup
},

data() {
return {
resizeObserver: null,
};
},

mounted() {
this.setupResizeObserver();
},

beforeUnmount() {
this.resizeObserver.disconnect();
},

computed: {
options() {
return this.normalizeInputOptions(this.meta.options || this.config.options);
Expand All @@ -60,25 +45,6 @@ export default {
this.update(this.value == newValue && this.config.clearable ? null : newValue);
},

setupResizeObserver() {
this.resizeObserver = new ResizeObserver(() => {
this.handleWrappingOfNode(this.$refs.buttonGroup.$el);
});
this.resizeObserver.observe(this.$refs.buttonGroup.$el);
},

handleWrappingOfNode(node) {
const lastEl = node.lastChild;

if (!lastEl) return;

node.classList.remove('btn-vertical');

if (lastEl.offsetTop > node.clientTop) {
node.classList.add('btn-vertical');
}
},

focus() {
this.$refs.button[0].focus();
},
Expand Down
161 changes: 128 additions & 33 deletions resources/js/components/ui/Button/Group.vue
Original file line number Diff line number Diff line change
@@ -1,40 +1,135 @@
<template>
<div
:class="[
'group/button inline-flex flex-wrap [[data-floating-toolbar]_&]:justify-center [[data-floating-toolbar]_&]:gap-1 [[data-floating-toolbar]_&]:lg:gap-x-0',
'[&>[data-ui-group-target]:not(:first-child):not(:last-child)]:rounded-none',
'[&>[data-ui-group-target]:first-child:not(:last-child)]:rounded-e-none',
'[&>[data-ui-group-target]:last-child:not(:first-child)]:rounded-s-none',
'[&>*:not(:first-child):not(:last-child):not(:only-child)_[data-ui-group-target]]:rounded-none',
'[&>*:first-child:not(:last-child)_[data-ui-group-target]]:rounded-e-none',
'[&>*:last-child:not(:first-child)_[data-ui-group-target]]:rounded-s-none',
'dark:[&_button]:ring-0',
'max-lg:[[data-floating-toolbar]_&_button]:rounded-md!',
'shadow-ui-sm rounded-lg'
]"
data-ui-button-group
>
<slot />
<div ref="wrapper" :class="{ invisible: measuringOverflow }">
<div ref="group" :class="groupClasses" :data-measuring="measuringOverflow || undefined" data-ui-button-group>
<slot />
</div>
</div>
</template>

<script setup>
import { ref, computed, nextTick, onMounted, onBeforeUnmount } from 'vue';
import { cva } from 'cva';

import debounce from '@/util/debounce';

const props = defineProps({
/* When 'stack', switch to vertical layout when overflowing. When 'gap', switch to normal buttons with gaps when overflowing. */
overflow: {
type: String,
default: null,
validator: (v) => [null, 'stack', 'gap'].includes(v),
},
orientation: {
type: String,
default: 'horizontal',
},
gap: {
type: [String, Boolean],
default: false,
},
justify: {
type: String,
default: 'start',
},
});

const hasOverflow = ref(false);
const needsOverflowObserver = computed(() => props.overflow === 'stack' || props.overflow === 'gap');
const measuringOverflow = ref(false);

const groupClasses = computed(() => {
const groupShadow = 'rounded-lg shadow-ui-sm [&_[data-ui-group-target]]:shadow-none';

const collapseHorizontally = [
'[&>[data-ui-group-target]:not(:first-child):not(:last-child)]:rounded-none',
'[&>:not(:first-child):not(:last-child)_[data-ui-group-target]]:rounded-none',
'[&>[data-ui-group-target]:first-child:not(:last-child)]:rounded-e-none',
'[&>:first-child:not(:last-child)_[data-ui-group-target]]:rounded-e-none',
'[&>[data-ui-group-target]:last-child:not(:first-child)]:rounded-s-none',
'[&>:last-child:not(:first-child)_[data-ui-group-target]]:rounded-s-none',
'[&>[data-ui-group-target]:not(:first-child)]:border-s-0',
'[&>:not(:first-child)_[data-ui-group-target]]:border-s-0',
];

const collapseVertically = [
'flex-col',
'[&>[data-ui-group-target]:not(:first-child):not(:last-child)]:rounded-none',
'[&>:not(:first-child):not(:last-child)_[data-ui-group-target]]:rounded-none',
'[&>[data-ui-group-target]:first-child:not(:last-child)]:rounded-b-none',
'[&>:first-child:not(:last-child)_[data-ui-group-target]]:rounded-b-none',
'[&>[data-ui-group-target]:last-child:not(:first-child)]:rounded-t-none',
'[&>:last-child:not(:first-child)_[data-ui-group-target]]:rounded-t-none',
'[&>[data-ui-group-target]:not(:last-child)]:border-b-0',
'[&>:not(:last-child)_[data-ui-group-target]]:border-b-0',
];

return cva({
base: [
'group/button inline-flex flex-wrap relative',
'dark:[&_button]:ring-0',
],
variants: {
orientation: {
vertical: collapseVertically,
},
justify: {
center: 'justify-center',
},
},
compoundVariants: [
{ overflow: 'stack', hasOverflow: false, class: [...collapseHorizontally, groupShadow] },
{ overflow: 'stack', hasOverflow: true, class: [...collapseVertically, groupShadow] },
{ overflow: 'gap', hasOverflow: true, class: 'gap-1' },
{ overflow: 'gap', hasOverflow: false, class: [...collapseHorizontally, groupShadow] },
{ overflow: null, orientation: 'horizontal', gap: false, class: [...collapseHorizontally, groupShadow] },
],
})({
gap: props.gap,
justify: props.justify,
orientation: props.orientation,
overflow: props.overflow,
hasOverflow: hasOverflow.value,
});
});

const wrapper = ref(null);
const group = ref(null);
let resizeObserver = null;

async function checkOverflow() {
if (!group.value?.children.length) return;

// Measure in collapsed state to avoid hysteresis from gap spacing
hasOverflow.value = false;
measuringOverflow.value = true;
await nextTick();

// Check if any child has wrapped to a new line
const children = Array.from(group.value.children);
const firstTop = children[0].offsetTop;
const lastTop = children[children.length - 1].offsetTop;
hasOverflow.value = lastTop > firstTop;

// Exit measuring mode
measuringOverflow.value = false;
}

onMounted(() => {
if (needsOverflowObserver.value) {
checkOverflow();
resizeObserver = new ResizeObserver(debounce(checkOverflow, 50));
resizeObserver.observe(wrapper.value);
}
});

onBeforeUnmount(() => {
resizeObserver?.disconnect();
});
</script>

<style>
/* GROUP FLOATING TOOLBAR / BUTTON GROUP BORDERS
=================================================== */
[data-ui-button-group] [data-ui-group-target] {
@apply shadow-none;

&:not(:first-child):not([data-floating-toolbar] &) {
border-inline-start: 0;
}

/* Account for button groups being split apart on small screens */
[data-floating-toolbar] & {
@media (width >= 1024px) {
&:not(:first-child) {
border-inline-start: 0;
}
}
}
/* Force horizontal wrap layout during measurement to detect overflow */
[data-ui-button-group][data-measuring] {
@apply flex! flex-row!;
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ onUnmounted(() => document.removeEventListener('keydown', onKeydown, true));
:transition="{ duration: 0.2, ease: 'easeInOut' }"
>
<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">
<ButtonGroup>
<ButtonGroup overflow="gap" justify="center">
<Button
class="text-blue-500!"
@click="clearSelections?.()"
Expand Down
112 changes: 112 additions & 0 deletions resources/js/stories/Button.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,4 +300,116 @@ export const ButtonGroups: Story = {
</ButtonGroup>
`,
}),
};

export const ButtonGroupOverflowStack: Story = {
parameters: {
docs: {
source: {
code: `
<ButtonGroup overflow="stack">
<Button text="Option A" />
<Button text="Option B" />
<Button text="Option C" />
<Button text="Option D" />
<Button text="Option E" />
</ButtonGroup>
`,
},
},
},
render: () => ({
components: { ButtonGroup, Button },
template: `
<div class="w-72">
<ButtonGroup overflow="stack">
<Button text="Option A" />
<Button text="Option B" />
<Button text="Option C" />
<Button text="Option D" />
<Button text="Option E" />
</ButtonGroup>
</div>
`,
}),
};

export const ButtonGroupOverflowGap: Story = {
parameters: {
docs: {
source: {
code: `
<ButtonGroup overflow="gap">
<Button text="Option A" />
<Button text="Option B" />
<Button text="Option C" />
<Button text="Option D" />
<Button text="Option E" />
</ButtonGroup>
`,
},
},
},
render: () => ({
components: { ButtonGroup, Button },
template: `
<div class="w-72">
<ButtonGroup overflow="gap">
<Button text="Option A" />
<Button text="Option B" />
<Button text="Option C" />
<Button text="Option D" />
<Button text="Option E" />
</ButtonGroup>
</div>
`,
}),
};

export const ButtonGroupOverflowVariations: Story = {
render: () => ({
components: { ButtonGroup, Button },
template: `
<div class="space-y-8">
<div>
<p class="text-xs font-mono text-gray-500 mb-2">overflow="stack" — fits</p>
<ButtonGroup overflow="stack">
<Button text="Option A" />
<Button text="Option B" />
</ButtonGroup>
</div>
<div>
<p class="text-xs font-mono text-gray-500 mb-2">overflow="stack" — overflows</p>
<div class="w-48">
<ButtonGroup overflow="stack">
<Button text="Option A" />
<Button text="Option B" />
<Button text="Option C" />
</ButtonGroup>
</div>
</div>
<div>
<p class="text-xs font-mono text-gray-500 mb-2">overflow="gap" — fits</p>
<ButtonGroup overflow="gap">
<Button text="Option A" />
<Button text="Option B" />
</ButtonGroup>
</div>
<div>
<p class="text-xs font-mono text-gray-500 mb-2">overflow="gap" — overflows</p>
<div class="w-72">
<ButtonGroup overflow="gap">
<Button text="Option A" />
<Button text="Option B" />
<Button text="Option C" />
<Button text="Option D" />
<Button text="Option E" />
<Button text="Option F" />
<Button text="Option G" />
</ButtonGroup>
</div>
</div>
</div>
`,
}),
};
9 changes: 9 additions & 0 deletions resources/js/stories/docs/Button.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,14 @@ When using `ghost` or `subtle` button variants, you can use the `inset` prop to
You can combine multiple buttons into a "button group".
<Canvas of={ButtonStories.ButtonGroups} sourceState={'shown'} />

### Overflow
By default, button groups don't handle overflow. Use the `overflow` prop to opt in to responsive behavior.

Use `overflow="stack"` to switch to a vertical layout when buttons don't fit on one line.
<Canvas of={ButtonStories.ButtonGroupOverflowStack} sourceState={'shown'} />

Use `overflow="gap"` to switch to separated buttons with gaps.
<Canvas of={ButtonStories.ButtonGroupOverflowGap} sourceState={'shown'} />

## Arguments
<ArgTypes of={ButtonStories} />
Loading