Skip to content
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 orientation="auto" 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
152 changes: 120 additions & 32 deletions resources/js/components/ui/Button/Group.vue
Original file line number Diff line number Diff line change
@@ -1,39 +1,127 @@
<template>
<div
:class="[
'group/button 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({
orientation: {
type: String,
default: 'horizontal',
},
gap: {
type: [String, Boolean],
default: false,
},
justify: {
type: String,
default: 'start',
},
});

const hasOverflow = ref(false);
const needsOverflowObserver = props.orientation === 'auto' || props.gap === 'auto';
const measuringOverflow = ref(needsOverflowObserver);

const groupClasses = computed(() => {
const collapseHorizontally = [
'rounded-lg shadow-ui-sm [&_[data-ui-group-target]]:shadow-none',
'[&>[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',
'rounded-lg shadow-ui-sm [&_[data-ui-group-target]]:shadow-none',
'[&>[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(:first-child)]:border-t-0',
'[&>:not(:first-child)_[data-ui-group-target]]:border-t-0',
];

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

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

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

// Enter measuring mode: force horizontal wrap
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) {
checkOverflow();
resizeObserver = new ResizeObserver(debounce(checkOverflow, 50));
resizeObserver.observe(wrapper.value);
}
});

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

<style>
[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>
2 changes: 1 addition & 1 deletion resources/js/components/ui/Listing/BulkActions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ function actionFailed(response) {
:transition="{ duration: 0.2, ease: 'easeInOut' }"
>
<div class="[.nav-open_&]:translate-x-23 transition-transform duration-300 relative space-y-3 rounded-xl border border-gray-300/60 dark:border-gray-700 p-1 bg-gray-200/55 backdrop-blur-[20px] 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 gap="auto" justify="center">
<Button
class="text-blue-500!"
:text="__n(`Deselect :count item|Deselect all :count items`, selections.length)"
Expand Down