Skip to content

Commit 3a53eb3

Browse files
fix: improve toolbar responsiveness (#2761)
* fix: improve toolbar responsiveness * test(toolbar): fill round-3 coverage gaps (SD-2328) Adds boundary and lifecycle coverage for the responsive toolbar fix: - defaultItems.test.js: XL overflow cutoff (1429 vs 1430) and LG compact-class application (1280 vs 1281). - Toolbar.test.js: positive ResizeObserver branch (constructor, observe, disconnect on unmount). Moves vi.unstubAllGlobals into afterEach so the stub doesn't leak on a thrown assertion. - super-toolbar.test.js: getAvailableWidth branches on responsiveToContainer and falls back to 0 when no container is set. - responsive-container-overflow.spec.ts: fixes a latent selector bug. The original descendant query never matched (class lives on the ButtonGroup root, not a descendant) so the minWidth assertion silently passed on null. Now uses a compound selector and asserts both side groups compact. --------- Co-authored-by: Caio Pizzol <caio@superdoc.dev>
1 parent 4e299a6 commit 3a53eb3

13 files changed

Lines changed: 448 additions & 50 deletions

File tree

packages/super-editor/src/editors/v1/components/toolbar/ButtonGroup.vue

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,30 +39,37 @@ const props = defineProps({
3939
type: Boolean,
4040
default: false,
4141
},
42+
compactSideGroups: {
43+
type: Boolean,
44+
default: false,
45+
},
4246
});
4347
4448
const currentItem = ref(null);
4549
const { isHighContrastMode } = useHighContrastMode();
4650
// Matches media query from SuperDoc.vue
4751
const isMobile = window.matchMedia('(max-width: 768px)').matches;
48-
const styleMap = {
49-
left: {
50-
minWidth: '120px',
51-
justifyContent: 'flex-start',
52-
},
53-
right: {
54-
minWidth: '120px',
55-
justifyContent: 'flex-end',
56-
},
57-
default: {
52+
53+
const getPositionStyle = computed(() => {
54+
if (props.position === 'left') {
55+
return {
56+
minWidth: props.compactSideGroups ? 'auto' : '120px',
57+
justifyContent: 'flex-start',
58+
};
59+
}
60+
61+
if (props.position === 'right') {
62+
return {
63+
minWidth: props.compactSideGroups ? 'auto' : '120px',
64+
justifyContent: 'flex-end',
65+
};
66+
}
67+
68+
return {
5869
// Only grow if not on a mobile device
5970
flexGrow: isMobile ? 0 : 1,
6071
justifyContent: 'center',
61-
},
62-
};
63-
64-
const getPositionStyle = computed(() => {
65-
return styleMap[props.position] || styleMap.default;
72+
};
6673
});
6774
6875
const isButton = (item) => item.type === 'button';

packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ function createMockToolbar() {
1717
config: {
1818
toolbarGroups: ['left', 'center', 'right'],
1919
toolbarButtonsExclude: [],
20+
responsiveToContainer: false,
2021
},
2122
getToolbarItemByGroup: () => [],
2223
getToolbarItemByName: () => null,
24+
getAvailableWidth: () => 1200,
2325
onToolbarResize: vi.fn(),
2426
emitCommand: vi.fn(),
2527
overflowItems: [],
@@ -30,6 +32,7 @@ function createMockToolbar() {
3032
describe('Toolbar', () => {
3133
afterEach(() => {
3234
vi.restoreAllMocks();
35+
vi.unstubAllGlobals();
3336
});
3437

3538
it('removes resize and keydown listeners on unmount (not only on KeepAlive deactivate)', () => {
@@ -111,4 +114,65 @@ describe('Toolbar', () => {
111114
addSpy.mockRestore();
112115
removeSpy.mockRestore();
113116
});
117+
118+
it('does not attach ResizeObserver when responsiveToContainer is disabled', () => {
119+
const observe = vi.fn();
120+
const disconnect = vi.fn();
121+
const ResizeObserverMock = vi.fn(() => ({ observe, disconnect }));
122+
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
123+
124+
const mockToolbar = {
125+
...createMockToolbar(),
126+
toolbarContainer: document.createElement('div'),
127+
};
128+
129+
const wrapper = mount(Toolbar, {
130+
global: {
131+
stubs: { ButtonGroup: true },
132+
plugins: [
133+
(app) => {
134+
app.config.globalProperties.$toolbar = mockToolbar;
135+
},
136+
],
137+
},
138+
});
139+
140+
expect(ResizeObserverMock).not.toHaveBeenCalled();
141+
expect(observe).not.toHaveBeenCalled();
142+
143+
wrapper.unmount();
144+
});
145+
146+
it('attaches ResizeObserver to the container when responsiveToContainer is enabled', () => {
147+
const observe = vi.fn();
148+
const disconnect = vi.fn();
149+
const ResizeObserverMock = vi.fn(() => ({ observe, disconnect }));
150+
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
151+
152+
const container = document.createElement('div');
153+
const mockToolbar = {
154+
...createMockToolbar(),
155+
config: { ...createMockToolbar().config, responsiveToContainer: true },
156+
toolbarContainer: container,
157+
};
158+
159+
const wrapper = mount(Toolbar, {
160+
global: {
161+
stubs: { ButtonGroup: true },
162+
plugins: [
163+
(app) => {
164+
app.config.globalProperties.$toolbar = mockToolbar;
165+
},
166+
],
167+
},
168+
});
169+
170+
expect(ResizeObserverMock).toHaveBeenCalledTimes(1);
171+
expect(observe).toHaveBeenCalledWith(container);
172+
expect(disconnect).not.toHaveBeenCalled();
173+
174+
wrapper.unmount();
175+
176+
expect(disconnect).toHaveBeenCalledTimes(1);
177+
});
114178
});

packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from 'vue';
1212
import { throttle } from './helpers.js';
1313
import ButtonGroup from './ButtonGroup.vue';
14+
import { RESPONSIVE_BREAKPOINTS } from './constants.js';
1415
1516
/**
1617
* The default font-family to use for toolbar UI surfaces when no custom font is configured.
@@ -23,6 +24,8 @@ const { proxy } = getCurrentInstance();
2324
const emit = defineEmits(['command', 'toggle', 'select']);
2425
2526
let toolbarKey = ref(1);
27+
const compactSideGroups = ref(false);
28+
let containerResizeObserver = null;
2629
2730
/**
2831
* Computed property that determines the font-family to use for toolbar UI surfaces.
@@ -49,6 +52,9 @@ const getFilteredItems = (position) => {
4952
return proxy.$toolbar.getToolbarItemByGroup(position).filter((item) => !excludeButtonsList.includes(item.name.value));
5053
};
5154
55+
const updateCompactSideGroups = () => {
56+
compactSideGroups.value = proxy.$toolbar.getAvailableWidth() <= RESPONSIVE_BREAKPOINTS.lg;
57+
};
5258
const onKeyDown = async (e) => {
5359
if (e.metaKey && e.key === 'f') {
5460
const searchItem = proxy.$toolbar.getToolbarItemByName('search');
@@ -65,18 +71,33 @@ const onKeyDown = async (e) => {
6571
6672
const onWindowResized = async () => {
6773
await proxy.$toolbar.onToolbarResize();
74+
updateCompactSideGroups();
6875
toolbarKey.value += 1;
6976
};
7077
const onResizeThrottled = throttle(onWindowResized, 300);
7178
7279
function teardownWindowListeners() {
7380
window.removeEventListener('resize', onResizeThrottled);
7481
window.removeEventListener('keydown', onKeyDown);
82+
containerResizeObserver?.disconnect();
83+
containerResizeObserver = null;
7584
}
7685
7786
function setupWindowListeners() {
87+
teardownWindowListeners();
7888
window.addEventListener('resize', onResizeThrottled);
7989
window.addEventListener('keydown', onKeyDown);
90+
if (
91+
typeof ResizeObserver !== 'undefined' &&
92+
proxy.$toolbar.config?.responsiveToContainer &&
93+
proxy.$toolbar.toolbarContainer
94+
) {
95+
containerResizeObserver = new ResizeObserver(() => {
96+
onResizeThrottled();
97+
});
98+
containerResizeObserver.observe(proxy.$toolbar.toolbarContainer);
99+
}
100+
updateCompactSideGroups();
80101
}
81102
82103
onMounted(setupWindowListeners);
@@ -121,6 +142,7 @@ const handleToolbarMousedown = (e) => {
121142
tabindex="0"
122143
v-if="showLeftSide"
123144
:toolbar-items="getFilteredItems('left')"
145+
:compact-side-groups="compactSideGroups"
124146
:ui-font-family="uiFontFamily"
125147
position="left"
126148
@command="handleCommand"
@@ -131,6 +153,7 @@ const handleToolbarMousedown = (e) => {
131153
tabindex="0"
132154
:toolbar-items="getFilteredItems('center')"
133155
:overflow-items="proxy.$toolbar.overflowItems"
156+
:compact-side-groups="compactSideGroups"
134157
:ui-font-family="uiFontFamily"
135158
position="center"
136159
@command="handleCommand"
@@ -140,6 +163,7 @@ const handleToolbarMousedown = (e) => {
140163
tabindex="0"
141164
v-if="showRightSide"
142165
:toolbar-items="getFilteredItems('right')"
166+
:compact-side-groups="compactSideGroups"
143167
:ui-font-family="uiFontFamily"
144168
position="right"
145169
@command="handleCommand"
@@ -162,12 +186,6 @@ const handleToolbarMousedown = (e) => {
162186
z-index: var(--sd-ui-toolbar-z-index, 10);
163187
}
164188
165-
@media (max-width: 1280px) {
166-
.superdoc-toolbar-group-side {
167-
min-width: auto !important;
168-
}
169-
}
170-
171189
@media (max-width: 768px) {
172190
.superdoc-toolbar {
173191
padding: 4px 10px;

packages/super-editor/src/editors/v1/components/toolbar/ToolbarButton.vue

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -291,21 +291,19 @@ const caretIcon = computed(() => {
291291
height: 10px;
292292
}
293293
294-
@media (max-width: 1280px) {
295-
.toolbar-item--doc-mode .button-label {
296-
display: none;
297-
}
294+
.toolbar-item--doc-mode-compact .button-label {
295+
display: none;
296+
}
298297
299-
.toolbar-item--doc-mode .toolbar-icon {
300-
margin-right: 5px;
301-
}
298+
.toolbar-item--doc-mode-compact .toolbar-icon {
299+
margin-right: 5px;
300+
}
302301
303-
.toolbar-item--linked-styles {
304-
width: auto !important;
305-
}
302+
.toolbar-item--linked-styles-compact {
303+
width: auto !important;
304+
}
306305
307-
.toolbar-item--linked-styles .button-label {
308-
display: none;
309-
}
306+
.toolbar-item--linked-styles-compact .button-label {
307+
display: none;
310308
}
311309
</style>

packages/super-editor/src/editors/v1/components/toolbar/constants.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ export const TOOLBAR_FONT_SIZES = [
5454
{ label: '96', key: '96pt', props: { 'data-item': 'btn-fontSize-option' } },
5555
];
5656

57+
export const RESPONSIVE_BREAKPOINTS = {
58+
sm: 768,
59+
md: 1024,
60+
lg: 1280,
61+
xl: 1410,
62+
};
63+
5764
export const HEADLESS_ITEM_MAP = {
5865
undo: 'undo',
5966
redo: 'redo',

packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { scrollToElement } from './scroll-helpers.js';
1515

1616
import checkIconSvg from '@superdoc/common/icons/check.svg?raw';
1717
import SearchInput from './SearchInput.vue';
18-
import { TOOLBAR_FONTS, TOOLBAR_FONT_SIZES } from './constants.js';
18+
import { RESPONSIVE_BREAKPOINTS, TOOLBAR_FONTS, TOOLBAR_FONT_SIZES } from './constants.js';
1919
import { getQuickFormatList } from '@extensions/linked-styles/index.js';
2020

2121
const closeDropdown = (dropdown) => {
@@ -996,18 +996,33 @@ export const makeDefaultItems = ({
996996
}),
997997
});
998998

999-
// Responsive toolbar calculations
1000-
const breakpoints = {
1001-
sm: 768,
1002-
md: 1024,
1003-
lg: 1280,
1004-
xl: 1410,
1005-
};
999+
// Responsive toolbar calculations.
1000+
// `availableWidth` comes from SuperToolbar and represents either:
1001+
// - container width when `responsiveToContainer: true`
1002+
// - viewport/document width when `responsiveToContainer: false`
1003+
1004+
// Extra headroom to prevent toolbar jitter at the XL edge.
1005+
const XL_OVERFLOW_SAFETY_BUFFER = 20;
10061006
const stickyItemsWidth = 120;
10071007
const toolbarPadding = 32;
10081008

10091009
const itemsToHideXL = ['linkedStyles', 'clearFormatting', 'copyFormat', 'ruler'];
10101010
const itemsToHideSM = ['zoom', 'fontFamily', 'fontSize', 'redo'];
1011+
const shouldUseLgCompactStyles = availableWidth <= RESPONSIVE_BREAKPOINTS.lg;
1012+
1013+
if (shouldUseLgCompactStyles) {
1014+
documentMode.attributes.value = {
1015+
...documentMode.attributes.value,
1016+
className: `${documentMode.attributes.value.className} toolbar-item--doc-mode-compact`,
1017+
};
1018+
}
1019+
1020+
if (shouldUseLgCompactStyles) {
1021+
linkedStyles.attributes.value = {
1022+
...linkedStyles.attributes.value,
1023+
className: `${linkedStyles.attributes.value.className} toolbar-item--linked-styles-compact`,
1024+
};
1025+
}
10111026

10121027
let toolbarItems = [
10131028
undo,
@@ -1054,7 +1069,7 @@ export const makeDefaultItems = ({
10541069
}
10551070

10561071
// Hide separators on small screens
1057-
if (availableWidth <= breakpoints.md && hideButtons) {
1072+
if (availableWidth <= RESPONSIVE_BREAKPOINTS.md && hideButtons) {
10581073
toolbarItems = toolbarItems.filter((item) => item.type !== 'separator');
10591074
}
10601075

@@ -1089,7 +1104,11 @@ export const makeDefaultItems = ({
10891104
toolbarItems.forEach((item) => {
10901105
const itemWidth = controlSizes.get(item.name.value) || controlSizes.get('default');
10911106

1092-
if (availableWidth < breakpoints.xl && itemsToHideXL.includes(item.name.value) && hideButtons) {
1107+
if (
1108+
availableWidth < RESPONSIVE_BREAKPOINTS.xl + XL_OVERFLOW_SAFETY_BUFFER &&
1109+
itemsToHideXL.includes(item.name.value) &&
1110+
hideButtons
1111+
) {
10931112
overflowItems.push(item);
10941113
if (item.name.value === 'linkedStyles') {
10951114
const linkedStylesIdx = toolbarItems.findIndex((item) => item.name.value === 'linkedStyles');
@@ -1098,7 +1117,7 @@ export const makeDefaultItems = ({
10981117
return;
10991118
}
11001119

1101-
if (availableWidth < breakpoints.sm && itemsToHideSM.includes(item.name.value) && hideButtons) {
1120+
if (availableWidth < RESPONSIVE_BREAKPOINTS.sm && itemsToHideSM.includes(item.name.value) && hideButtons) {
11021121
overflowItems.push(item);
11031122
return;
11041123
}

0 commit comments

Comments
 (0)