Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
160 changes: 127 additions & 33 deletions resources/js/components/ui/Button/Group.vue
Original file line number Diff line number Diff line change
@@ -1,40 +1,134 @@
<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 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(: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 },
{ overflow: 'stack', hasOverflow: true, class: collapseVertically },
{ overflow: 'gap', hasOverflow: true, class: 'gap-1' },
{ overflow: 'gap', hasOverflow: false, class: collapseHorizontally },
{ overflow: null, orientation: 'horizontal', gap: false, class: collapseHorizontally },
],
})({
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;

// 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.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>
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 @@ -63,7 +63,7 @@ function actionFailed(response) {
: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!"
:text="__n(`Deselect :count item|Deselect all :count items`, selections.length)"
Expand Down