Skip to content

Commit c499caf

Browse files
[6.x] Handle many tabs for both the blueprint and entry view (#14073)
* Add overflowing tabs * Correct icon * Long tab names (e.g. lorem ipsum) in the overflow dropdown will now truncate with “...” at that max width. * Long tab names (e.g. lorem ipsum) in the menu will now truncate with “...” at that max width. * Use overflow-clip instead, just because it's the modern equivalent of overflow-hidden * Solve tab overflow with clipping for small mobile-like viewports * Add a incy wincy tiny bit of padding to solve clip margin. This means you can tab to the overflow menu and the focus outline won't be clipped. overflow-clip-margin: 1px; won't work here for some reason but 1px of end padding is not visually noticeable. * Apply the same treatment to tabs on the entry form * Tidy * Fix some focus clipping e.g. if you tab to "Main" the focus outline would be clipped on the left * Add a tooltip to show the full text if the tab name is excessively long * Onl list overflowedTabs instead of all tabs. * Add a way to access tab controls when they overflow * Add logic to span all columns of the dropdown menu if there's no icon * refactor tab overflow logic into a composable to DRY it up * recompute overflow after tab switches (just in case) and remove unused import and var --------- Co-authored-by: Jack McDade <jack@jackmcdade.com>
1 parent 2db624f commit c499caf

File tree

5 files changed

+264
-28
lines changed

5 files changed

+264
-28
lines changed

resources/js/components/blueprints/Tab.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
class="h-4 w-4 me-1"
88
/>
99

10-
{{ __(tab.display) }}
10+
<span class="block max-w-48 overflow-clip text-ellipsis whitespace-nowrap" v-tooltip="__(tab.display).length > 24 ? __(tab.display) : null">{{ __(tab.display) }}</span>
1111

1212
<Dropdown v-if="isActive" placement="left-start" class="me-3">
1313
<template #trigger>

resources/js/components/blueprints/Tabs.vue

Lines changed: 105 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,57 @@
33
<div>
44
<Tabs v-model="currentTab" :unmount-on-hide="false">
55
<div v-if="!singleTab && tabs.length > 0" class="flex items-center justify-between gap-x-2 mb-6">
6-
<TabList class="flex-1">
7-
<div ref="tabs" class="flex-1 flex items-center gap-x-2.5">
8-
<BlueprintTab
9-
ref="tab"
10-
v-for="tab in tabs"
11-
:key="tab._id"
12-
:tab="tab"
13-
:current-tab="currentTab"
14-
:show-instructions="showTabInstructionsField"
15-
:edit-text="editTabText"
16-
@removed="removeTab(tab._id)"
17-
@updated="updateTab(tab._id, $event)"
18-
@mouseenter="mouseEnteredTab(tab._id)"
19-
/>
6+
<TabList class="flex-1 min-w-0 overflow-x-clip overflow-y-visible pe-0.25">
7+
<div ref="tabs" class="flex-1 flex items-center gap-x-2.5 min-w-0">
8+
<div ref="tabWrapper" class="min-w-0 flex-1 flex overflow-clip px-0.25">
9+
<div ref="tabInner" class="flex items-center gap-x-2.5 shrink-0">
10+
<BlueprintTab
11+
ref="tab"
12+
v-for="tab in tabs"
13+
:key="tab._id"
14+
:tab="tab"
15+
:current-tab="currentTab"
16+
:show-instructions="showTabInstructionsField"
17+
:edit-text="editTabText"
18+
@removed="removeTab(tab._id)"
19+
@updated="updateTab(tab._id, $event)"
20+
@mouseenter="mouseEnteredTab(tab._id)"
21+
/>
22+
</div>
23+
</div>
24+
<Dropdown
25+
v-if="overflowedTabs.length"
26+
align="end"
27+
side="bottom"
28+
class="shrink-0"
29+
>
30+
<template #trigger>
31+
<Button
32+
icon="dots"
33+
variant="ghost"
34+
size="sm"
35+
:aria-label="__('Open dropdown menu')"
36+
/>
37+
</template>
38+
<DropdownMenu>
39+
<DropdownItem
40+
v-for="tab in overflowedTabs"
41+
:key="tab._id"
42+
:icon="tab.icon"
43+
:class="{ 'bg-gray-100 dark:bg-gray-800': currentTab === tab._id }"
44+
@click="selectTab(tab._id)"
45+
>
46+
<span class="block max-w-48 overflow-hidden text-ellipsis whitespace-nowrap">
47+
{{ __(tab.display) }}
48+
</span>
49+
</DropdownItem>
50+
<template v-if="activeTabIsOverflowed">
51+
<DropdownSeparator />
52+
<DropdownItem :text="__('Edit')" icon="edit" @click="editActiveOverflowedTab" />
53+
<DropdownItem :text="__('Delete')" icon="trash" variant="destructive" @click="removeActiveOverflowedTab" />
54+
</template>
55+
</DropdownMenu>
56+
</Dropdown>
2057
</div>
2158
</TabList>
2259

@@ -54,10 +91,11 @@
5491
<script>
5592
import { Sortable, Plugins } from '@shopify/draggable';
5693
import { nanoid as uniqid } from 'nanoid';
94+
import { createTabsOverflowTracker } from '@/util/tabs-overflow.js';
5795
import BlueprintTab from './Tab.vue';
5896
import BlueprintTabContent from './TabContent.vue';
5997
import CanDefineLocalizable from '../fields/CanDefineLocalizable';
60-
import { Tabs, TabList, Button, Description } from '@/components/ui';
98+
import { Tabs, TabList, Button, Description, Dropdown, DropdownMenu, DropdownItem, DropdownSeparator } from '@/components/ui';
6199
62100
export default {
63101
mixins: [CanDefineLocalizable],
@@ -69,6 +107,10 @@ export default {
69107
TabList,
70108
Button,
71109
Description,
110+
Dropdown,
111+
DropdownMenu,
112+
DropdownItem,
113+
DropdownSeparator,
72114
},
73115
74116
props: {
@@ -138,31 +180,59 @@ export default {
138180
sortableTabs: null,
139181
sortableSections: null,
140182
sortableFields: null,
183+
overflowedTabs: [],
141184
};
142185
},
143186
187+
computed: {
188+
activeTabIsOverflowed() {
189+
return this.overflowedTabs.some((t) => t._id === this.currentTab);
190+
},
191+
},
192+
144193
watch: {
194+
currentTab() {
195+
this.$nextTick(this.checkOverflow);
196+
},
145197
tabs: {
146198
deep: true,
147199
handler(tabs) {
148200
this.$emit('updated', tabs);
149201
this.makeSortable();
202+
this.$nextTick(this.checkOverflow);
150203
},
151204
},
152205
},
153206
154207
mounted() {
155208
this.ensureTab();
156209
this.makeSortable();
210+
this.overflowTracker = createTabsOverflowTracker({
211+
getWrapper: () => this.$refs.tabWrapper,
212+
getInner: () => this.$refs.tabInner,
213+
getItems: () => this.tabs,
214+
onUpdate: ({ overflowedItems }) => {
215+
this.overflowedTabs = overflowedItems;
216+
},
217+
});
218+
this.$nextTick(() => {
219+
this.overflowTracker.observe();
220+
this.overflowTracker.checkOverflow();
221+
});
157222
},
158223
159224
unmounted() {
160225
if (this.sortableTabs) this.sortableTabs.destroy();
161226
if (this.sortableSections) this.sortableSections.destroy();
162227
if (this.sortableFields) this.sortableFields.destroy();
228+
this.overflowTracker?.disconnect();
163229
},
164230
165231
methods: {
232+
checkOverflow() {
233+
this.overflowTracker?.checkOverflow();
234+
},
235+
166236
ensureTab() {
167237
if (this.requireSection && this.tabs.length === 0) {
168238
this.addTab();
@@ -180,7 +250,10 @@ export default {
180250
makeTabsSortable() {
181251
if (this.sortableTabs) this.sortableTabs.destroy();
182252
183-
this.sortableTabs = new Sortable(this.$refs.tabs, {
253+
const container = this.$refs.tabInner || this.$refs.tabs;
254+
if (!container) return;
255+
256+
this.sortableTabs = new Sortable(container, {
184257
draggable: '.blueprint-tab',
185258
mirror: { constrainDimensions: true },
186259
swapAnimation: { horizontal: true },
@@ -294,6 +367,22 @@ export default {
294367
this.currentTab = tabId;
295368
},
296369
370+
editOverflowedTab(tab) {
371+
if (!tab) return;
372+
const refs = this.$refs.tab;
373+
const tabRef = Array.isArray(refs) ? refs.find((c) => c.tab?._id === tab._id) : refs;
374+
tabRef?.edit();
375+
},
376+
377+
editActiveOverflowedTab() {
378+
const tab = this.overflowedTabs.find((t) => t._id === this.currentTab);
379+
if (tab) this.editOverflowedTab(tab);
380+
},
381+
382+
removeActiveOverflowedTab() {
383+
this.removeTab(this.currentTab);
384+
},
385+
297386
mouseEnteredTab(tabId) {
298387
if (this.lastInteractedTab) this.selectTab(tabId);
299388
},

resources/js/components/ui/Dropdown/Item.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ const iconClasses = cva({
6161
<div v-if="icon" class="flex size-5 items-center justify-center p-1">
6262
<Icon :name="icon" :class="iconClasses" />
6363
</div>
64-
<div class="col-start-2 px-2">
64+
<div class="px-2" :class="icon ? 'col-start-2' : 'col-span-full'">
6565
<slot v-if="hasDefaultSlot" />
6666
<template v-else>{{ text }}</template>
6767
</div>

resources/js/components/ui/Publish/Tabs.vue

Lines changed: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,18 @@ import {
44
TabList,
55
TabTrigger,
66
TabProvider,
7+
Button,
8+
Dropdown,
9+
DropdownMenu,
10+
DropdownItem,
711
} from '@ui';
812
import TabContent from './TabContent.vue';
913
import { injectContainerContext } from './Container.vue';
1014
import Sections from './Sections.vue';
11-
import { ref, computed, useSlots, onMounted, watch } from 'vue';
15+
import { ref, computed, useSlots, onMounted, onUnmounted, nextTick, watch } from 'vue';
1216
import ElementContainer from '@/components/ElementContainer.vue';
1317
import ShowField from '@/components/field-conditions/ShowField.js';
18+
import { createTabsOverflowTracker } from '@/util/tabs-overflow.js';
1419
1520
const slots = useSlots();
1621
const { blueprint, visibleValues, extraValues, revealerValues, errors, hiddenFields, setHiddenField, container, rememberTab } = injectContainerContext();
@@ -103,21 +108,91 @@ const tabsWithErrors = computed(() => {
103108
function tabHasError(tab) {
104109
return tabsWithErrors.value.includes(tab.handle);
105110
}
111+
112+
const tabWrapper = ref(null);
113+
const tabInner = ref(null);
114+
const hasOverflow = ref(false);
115+
const overflowedTabs = ref([]);
116+
const overflowTracker = createTabsOverflowTracker({
117+
getWrapper: () => tabWrapper.value,
118+
getInner: () => tabInner.value,
119+
getItems: () => visibleMainTabs.value,
120+
onUpdate: ({ hasOverflow: nextHasOverflow, overflowedItems }) => {
121+
hasOverflow.value = nextHasOverflow;
122+
overflowedTabs.value = overflowedItems;
123+
},
124+
});
125+
126+
function checkOverflow() {
127+
overflowTracker.checkOverflow();
128+
}
129+
130+
watch(tabWrapper, (el) => {
131+
if (el) {
132+
overflowTracker.observe();
133+
nextTick(checkOverflow);
134+
}
135+
});
136+
137+
watch(visibleMainTabs, () => {
138+
nextTick(checkOverflow);
139+
}, { deep: true });
140+
141+
onUnmounted(() => {
142+
overflowTracker.disconnect();
143+
});
106144
</script>
107145

108146
<template>
109147
<ElementContainer @resized="width = $event.width">
110148
<div>
111149
<Tabs v-if="width" v-model:modelValue="activeTab">
112-
<TabList v-if="hasMultipleVisibleMainTabs" class="-mt-2 mb-6">
113-
<TabTrigger
114-
v-for="tab in visibleMainTabs"
115-
:key="tab.handle"
116-
:name="tab.handle"
117-
:text="__(tab.display)"
118-
:class="{ '!text-red-600': tabHasError(tab) }"
119-
/>
120-
</TabList>
150+
<div v-if="hasMultipleVisibleMainTabs" class="flex items-center gap-x-2 -mt-2 mb-6">
151+
<TabList class="flex-1 min-w-0 overflow-x-clip overflow-y-visible pe-0.25">
152+
<div class="flex-1 flex items-center gap-x-2.5 min-w-0">
153+
<div ref="tabWrapper" class="min-w-0 flex-1 flex overflow-clip px-0.25">
154+
<div ref="tabInner" class="flex items-center gap-x-2.5 shrink-0">
155+
<TabTrigger
156+
v-for="tab in visibleMainTabs"
157+
:key="tab.handle"
158+
:name="tab.handle"
159+
:class="{ '!text-red-600': tabHasError(tab) }"
160+
>
161+
<span class="block max-w-48 overflow-clip text-ellipsis whitespace-nowrap" v-tooltip="__(tab.display).length > 24 ? __(tab.display) : null">{{ __(tab.display) }}</span>
162+
</TabTrigger>
163+
</div>
164+
</div>
165+
<Dropdown
166+
v-if="overflowedTabs.length"
167+
align="end"
168+
side="bottom"
169+
class="shrink-0"
170+
>
171+
<template #trigger>
172+
<Button
173+
icon="dots"
174+
variant="ghost"
175+
size="sm"
176+
:aria-label="__('Open dropdown menu')"
177+
/>
178+
</template>
179+
<DropdownMenu>
180+
<DropdownItem
181+
v-for="tab in overflowedTabs"
182+
:key="tab.handle"
183+
:icon="tab.icon"
184+
:class="{ 'bg-gray-100 dark:bg-gray-800': activeTab === tab.handle }"
185+
@click="setActive(tab.handle)"
186+
>
187+
<span class="block max-w-48 overflow-hidden text-ellipsis whitespace-nowrap">
188+
{{ __(tab.display) }}
189+
</span>
190+
</DropdownItem>
191+
</DropdownMenu>
192+
</Dropdown>
193+
</div>
194+
</TabList>
195+
</div>
121196

122197
<div :class="{ 'grid grid-cols-[1fr_320px] gap-8': shouldShowSidebar }">
123198
<component

0 commit comments

Comments
 (0)