Skip to content

Commit 5cc31c7

Browse files
fix(plugin-list): fold the Display popover's layout segments to icons when they would not fit (#26696)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbX9aieGr9vBEvWMpCbnjnsG)_ Fixes #26637 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:列表「显示」弹层的「布局」一行,在分段按钮放不下时自动折叠为仅图标,当前选中的那一段保留文字;弹层宽度不变。 - **为什么改**:四种布局时「表格 / 看板 / 日历 / 时间线」每个标签都被挤成两行,英文更长;裁决要求按实测宽度折叠,不按数量或断点,所有语言一致。 - **风险与回滚**:只动 `ViewSettingsPopover.tsx` 一个组件,不改任何元数据或存储;回滚即 revert 本 PR。 - **先看**:`ui/packages/plugin-list/src/components/ViewSettingsPopover.tsx` 的 `LayoutSlot`(测量时临时撤下折叠属性、读宽、再放回)。 - **两分钟验证**:中文环境打开有四种布局的对象列表,点「显示」:布局行一行高,只有当前布局带文字,其余是图标,悬停显示名称。 - **需要你做的**:无,席位落地。 ## What changes `ui/packages/plugin-list/src/components/ViewSettingsPopover.tsx` only. The Layout row's control now sits in a `LayoutSlot`: - **Measured fold.** After every render and whenever the slot resizes, the slot compares the width its segments need with every label shown (the children's `scrollWidth`) against its own `clientWidth`. When they would not fit it sets `data-layout-fold="icons"`, and one class hides the words of every unselected `role="tab"`. The selected segment keeps its label. No count and no breakpoint enters the decision, so it holds in every locale. - **Measurement lifts the fold.** A folded row's hidden labels add nothing to its width, so each measurement removes the attribute, reads, and puts it back in the same synchronous task, before the browser paints. Without that step the row would unfold, refold and loop (the second ablation below shows React's update-depth error). - **Names and tooltips stay.** A folded segment keeps the `aria-label` and `title` that `ViewSwitcherDropdown` already gives it, so it is still announced and still named on hover. No locale key was added. - **No wrapping.** Tabs in the slot are `whitespace-nowrap` and `shrink-0`, and the row's title no longer shrinks. - **The popover keeps its width** (`w-80`); the ruling forbids widening it. The fold reads the shape `ViewSwitcherDropdown` draws (a `role="tablist"` whose tabs carry their name in `aria-label` and `title` and their words in a direct child span). That contract is written on the `layout` prop's doc comment. `ListView.tsx` and `ViewSwitcher.tsx` are untouched. ## Tests `ui/packages/plugin-list/src/__tests__/ViewSettingsPopover.layoutFold.test.tsx`, 5 cases. jsdom has no layout, so the test models the console's widths (segment padding, icon, gap, 12 px per CJK character and about 7 px per Latin one at `text-xs`) and reads the fold the way the class does: - zh-CN, the walked row 「布局 | 表格 | 看板 | 日历 | 时间线」: folds, the folded row fits the slot, every tab keeps its accessible name and `title`, only 表格 shows its words, and no width was read while the fold was on. - en, the same four layouts: folds. - Five layouts at the popover width: the fold engages and the row fits. - Measured, not counted: the same five layouts in a 600 px slot do not fold; two layouts at the popover width do not fold. - A row that fits again unfolds, and every label shows. ## Acceptance notes - The card says the popover is 240 px. On `main` it is `w-80`, 320 px; measured 320 in Chromium. The row still did not fit at 320: the slot beside the title is 258 px in zh-CN and 244 px in en. - Screenshots (Chromium, 1149 × 507 viewport, the real components and the console's compiled Tailwind sheet) are attached to the card thread: zh-CN, en, ja and de with four layouts all fold to one 49 px row with the selected label kept; en with Timeline selected keeps "Timeline"; zh-CN with two layouts keeps both labels. - Folding relies on the switcher's DOM shape rather than a prop, because the order keeps the fold inside the popover and `ListView.tsx` is being edited by another card. Passing an explicit icons-only flag through the `layout` render prop would be the next step if another host ever draws the row differently. ## Verification All runs on head `ecb5f58f` unless noted, ui suites from `ui/` under `scripts/pm/os-verify-lock.sh`. <details><summary>plugin-list suite: 164 files, 1650 tests, green</summary> ``` > vitest run --root ../.. packages/plugin-list/ --maxWorkers=2 Test Files 164 passed (164) Tests 1650 passed (1650) os-verify-lock: VERDICT command-exit 0 · held the lock 530s ``` </details> <details><summary>plugin-list type-check: green (tsconfig.test.json lists the new test)</summary> ``` pnpm exec turbo run type-check --filter=@object-ui/plugin-list --concurrency=2 > tsc --noEmit && tsc -p tsconfig.test.json Tasks: 49 successful, 49 total os-verify-lock: VERDICT command-exit 0 ``` </details> <details><summary>Ablations: both go red, both restored to HEAD</summary> Through `scripts/ablation-replace.mjs` (anchor hit 1 → 0, blob restored to HEAD, `git diff HEAD` empty), on `dfbd4bf5`: 1. Render the layout without `LayoutSlot`: 4 failed, 1 passed (the "does not fold" case stays green, as it should). 2. Delete the line that lifts the fold before measuring: 4 failed with `Maximum update depth exceeded`. </details> <details><summary>Chromium check of the compiled sheet</summary> The console's `index.css` compiled through `@tailwindcss/postcss` emits the fold rule: ``` .\[\&\[data-layout-fold\]_\[role\=tab\]\[aria-selected\=false\]\>span\]\:hidden[data-layout-fold] [role=tab][aria-selected=false] > span { display: none; } ``` | locale | layouts | fold | slot px | row px | selected keeps its label | every tab named + titled | |---|---|---|---|---|---|---| | zh-CN | 4 | icons | 258 | 49 | 表格 | yes | | en | 4 | icons | 244 | 49 | Grid | yes | | en | 4, Timeline picked | icons | 244 | 49 | Timeline | yes | | ja | 4 | icons | 221 | 49 | グリッド | yes | | de | 4 | icons | 244 | 49 | Raster | yes | | zh-CN | 2 | none | 258 | 49 | both labels shown | yes | Popover width 320 px in every run. </details> <details><summary>Derived gates: 34 of 34 exit 0, reconciled with --ran</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `ecb5f58f` derived 34 commands; each ran with its exit code captured before any pipe (`GITHUB_EVENT_PATH` pointing at this body for the PR-body gates). `--ran` answers: "34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN". `check:partof-closing-keyword` with this body: green. `check:closing-target-claim` needs the PR number, so CI's `pr-body-guards` runs it. ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` The type-check lanes (`Type Check UI`, the workspace lanes) and the wide-population families are CI's. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jUfYgopbwA4FqJngRV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2eefc8a commit 5cc31c7

3 files changed

Lines changed: 309 additions & 2 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/plugin-list': patch
3+
---
4+
5+
fix(plugin-list): the Display popover's Layout row folds its segments to icons when they would not fit
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
On a list that offers four layouts, the Layout row of the Display popover read 「布局 | 表格 | 看板 | 日历 | 时间线」 and broke each label onto two lines: the popover is a fixed width and the row shares it with its title. In English the labels are longer still.
11+
12+
What changes:
13+
14+
- The row now measures the width its segments need with every label shown against the width it has, in whatever locale is rendering. When they would not fit, every segment but the selected one shows its icon only. The selected segment keeps its label.
15+
- A folded segment keeps its accessible name and its tooltip (its `aria-label` and `title`), so it is still announced and still named on hover.
16+
- The fold is measured, not a count or a breakpoint: two layouts keep their labels, and the same row unfolds wherever it fits again.
17+
- Labels in the row no longer wrap, and the popover keeps its width.
18+
19+
What to do: nothing. Nothing a view or object declares changes.
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* The Display popover's Layout row folds its segments to icons when they would
11+
* not fit (objectstack#26637).
12+
*
13+
* Walked on a zh-CN list offering four layouts, the row read
14+
* 「布局 | 表格 | 看板 | 日历 | 时间线」 and each label broke onto two lines: the
15+
* panel is a fixed width and the row shares it with its title. The ruling is a
16+
* MEASURED fold, never a count or a breakpoint, in every locale; the popover
17+
* does not widen. Folded, the unselected segments keep their icon, accessible
18+
* name and tooltip, and the selected one keeps its label.
19+
*
20+
* jsdom has no layout, so the widths are a model of the console's tokens: a
21+
* segment is `px-2.5` (20) + a 16 px icon, plus `gap-1.5` (6) and its words
22+
* when they show; the track adds `p-0.5` (4) and `gap-0.5` (2) between
23+
* segments; `text-xs` draws a CJK character 12 px wide and a Latin one about
24+
* 7 px. The model reads the fold the way the class does (an unselected tab's
25+
* words are gone while the slot carries `data-layout-fold`), so a measurement
26+
* that forgot to lift the fold would read the folded width, unfold, read the
27+
* full width and fold again until React gives up.
28+
*/
29+
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
30+
import * as React from 'react';
31+
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
32+
import { I18nProvider, useObjectTranslation } from '@object-ui/i18n';
33+
import type { ListViewVisualization } from '@object-ui/core';
34+
import { ViewSettingsPopover, type ViewSettingsPopoverProps } from '../components/ViewSettingsPopover';
35+
import { ViewSwitcherDropdown } from '../ViewSwitcher';
36+
37+
/** `w-80`: the popover's width, which this card must not change. */
38+
const POPOVER_WIDTH = 320;
39+
/** The row's `px-3` on both sides and the `gap-3` between title and slot. */
40+
const ROW_CHROME = 24 + 12;
41+
42+
function textWidth(text: string): number {
43+
let w = 0;
44+
// CJK punctuation through the unified ideographs: one em wide at `text-xs`.
45+
for (const ch of text) w += ch.codePointAt(0)! >= 0x3000 && ch.codePointAt(0)! <= 0x9fff ? 12 : 7;
46+
return w;
47+
}
48+
49+
/** Whether a tab's words are drawn: the fold hides an unselected tab's, nothing else does. */
50+
function labelShows(tab: Element): boolean {
51+
return !(tab.closest('[data-layout-fold]') && tab.getAttribute('aria-selected') === 'false');
52+
}
53+
54+
/** The model width of a segmented control as the DOM now stands. */
55+
function tablistWidth(list: Element): number {
56+
const tabs = Array.from(list.querySelectorAll('[role="tab"]'));
57+
let w = 4 + 2 * Math.max(0, tabs.length - 1);
58+
for (const tab of tabs) {
59+
w += 20 + 16;
60+
const words = tab.querySelector(':scope > span')?.textContent ?? '';
61+
if (words && labelShows(tab)) w += 6 + textWidth(words);
62+
}
63+
return w;
64+
}
65+
66+
let slotWidth = 0;
67+
let readsWhileFolded = 0;
68+
const saved: Record<'scrollWidth' | 'clientWidth', PropertyDescriptor | undefined> = {
69+
scrollWidth: undefined,
70+
clientWidth: undefined,
71+
};
72+
73+
beforeEach(() => {
74+
readsWhileFolded = 0;
75+
saved.scrollWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollWidth');
76+
saved.clientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth');
77+
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
78+
configurable: true,
79+
get(this: HTMLElement) {
80+
if (this.getAttribute('role') !== 'tablist') return 0;
81+
if (this.closest('[data-layout-fold]')) readsWhileFolded += 1;
82+
return tablistWidth(this);
83+
},
84+
});
85+
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
86+
configurable: true,
87+
get(this: HTMLElement) {
88+
return this.dataset.testid === 'display-layout-slot' ? slotWidth : 0;
89+
},
90+
});
91+
});
92+
93+
afterEach(() => {
94+
cleanup();
95+
for (const key of ['scrollWidth', 'clientWidth'] as const) {
96+
if (saved[key]) Object.defineProperty(HTMLElement.prototype, key, saved[key]!);
97+
else delete (HTMLElement.prototype as unknown as Record<string, unknown>)[key];
98+
}
99+
});
100+
101+
/** The slot's width inside the popover, beside the row's title in this locale. */
102+
function slotAtPopoverWidth(title: string): number {
103+
return POPOVER_WIDTH - ROW_CHROME - textWidth(title);
104+
}
105+
106+
/**
107+
* Five layouts as a host would draw them: the segmented shape the popover folds
108+
* (`role="tab"`, name in `aria-label` and `title`, words in a child span).
109+
* `ViewSwitcherDropdown` itself turns five into a dropdown, so the five-segment
110+
* row is drawn here.
111+
*/
112+
function FiveSegments({ labels, current }: { labels: string[]; current: number }) {
113+
return (
114+
<div role="tablist">
115+
{labels.map((label, i) => (
116+
<button key={label} type="button" role="tab" aria-selected={i === current} aria-label={label} title={label}>
117+
<svg aria-hidden="true" />
118+
<span>{label}</span>
119+
</button>
120+
))}
121+
</div>
122+
);
123+
}
124+
125+
function Harness({ layout }: { layout: React.ReactNode }) {
126+
const { t } = useObjectTranslation();
127+
return (
128+
<ViewSettingsPopover
129+
t={t as ViewSettingsPopoverProps['t']}
130+
allFields={[]}
131+
layout={() => layout}
132+
/>
133+
);
134+
}
135+
136+
function inLocale(language: string, layout: React.ReactNode) {
137+
const tree = (node: React.ReactNode) => (
138+
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}>
139+
<Harness layout={node} />
140+
</I18nProvider>
141+
);
142+
const view = render(tree(layout));
143+
fireEvent.click(screen.getByTestId('toolbar-display-trigger'));
144+
return { rerender: (node: React.ReactNode) => view.rerender(tree(node)) };
145+
}
146+
147+
function switcher(views: ListViewVisualization[]) {
148+
return <ViewSwitcherDropdown currentView="grid" availableViews={views} onViewChange={() => {}} animated={false} />;
149+
}
150+
151+
const FOUR: ListViewVisualization[] = ['grid', 'kanban', 'calendar', 'timeline'];
152+
153+
function slot() {
154+
return screen.getByTestId('display-layout-slot');
155+
}
156+
157+
/** Every segment fits on one line: the row's segments take no more than the slot. */
158+
function expectFits() {
159+
expect(tablistWidth(screen.getByRole('tablist'))).toBeLessThanOrEqual(slotWidth);
160+
}
161+
162+
describe('Display popover: the Layout row folds to icons when its segments would not fit (objectstack#26637)', () => {
163+
it('zh-CN, the walked row 「布局 | 表格 | 看板 | 日历 | 时间线」: folds, and every segment keeps its name and tooltip', () => {
164+
slotWidth = slotAtPopoverWidth('布局');
165+
inLocale('zh', switcher(FOUR));
166+
167+
expect(slot().getAttribute('data-layout-fold')).toBe('icons');
168+
expectFits();
169+
for (const name of ['表格', '看板', '日历', '时间线']) {
170+
const tab = screen.getByRole('tab', { name });
171+
expect(tab.getAttribute('title')).toBe(name);
172+
}
173+
// The selected segment keeps its words; the others show their icon only.
174+
const shown = screen.getAllByRole('tab').filter(labelShows).map((tab) => tab.getAttribute('aria-label'));
175+
expect(shown).toEqual(['表格']);
176+
expect(readsWhileFolded).toBe(0);
177+
});
178+
179+
it('en: the same four layouts fold too, where the labels are longer still', () => {
180+
slotWidth = slotAtPopoverWidth('Layout');
181+
inLocale('en', switcher(FOUR));
182+
183+
expect(slot().getAttribute('data-layout-fold')).toBe('icons');
184+
expectFits();
185+
expect(screen.getByRole('tab', { name: 'Timeline' }).getAttribute('title')).toBe('Timeline');
186+
expect(readsWhileFolded).toBe(0);
187+
});
188+
189+
it('five layouts at the popover width: the fold engages and the row fits', () => {
190+
slotWidth = slotAtPopoverWidth('布局');
191+
inLocale('zh', <FiveSegments labels={['表格', '看板', '日历', '时间线', '画廊']} current={3} />);
192+
193+
expect(slot().getAttribute('data-layout-fold')).toBe('icons');
194+
expectFits();
195+
const shown = screen.getAllByRole('tab').filter(labelShows).map((tab) => tab.getAttribute('aria-label'));
196+
expect(shown).toEqual(['时间线']);
197+
});
198+
199+
it('measured, not counted: the same five layouts do not fold where they fit, and two never fold at the popover width', () => {
200+
slotWidth = 600;
201+
const { rerender } = inLocale('zh', <FiveSegments labels={['表格', '看板', '日历', '时间线', '画廊']} current={0} />);
202+
expect(slot().hasAttribute('data-layout-fold')).toBe(false);
203+
204+
slotWidth = slotAtPopoverWidth('布局');
205+
rerender(switcher(['grid', 'kanban']));
206+
expect(slot().hasAttribute('data-layout-fold')).toBe(false);
207+
expectFits();
208+
});
209+
210+
it('re-measures with every label shown: a row that fits again unfolds', () => {
211+
slotWidth = slotAtPopoverWidth('布局');
212+
const { rerender } = inLocale('zh', switcher(FOUR));
213+
expect(slot().getAttribute('data-layout-fold')).toBe('icons');
214+
215+
rerender(switcher(['grid', 'kanban']));
216+
expect(slot().hasAttribute('data-layout-fold')).toBe(false);
217+
expect(screen.getAllByRole('tab').every(labelShows)).toBe(true);
218+
expect(readsWhileFolded).toBe(0);
219+
});
220+
});

‎ui/packages/plugin-list/src/components/ViewSettingsPopover.tsx‎

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ export interface ViewSettingsPopoverProps {
7171
* choosing a layout completes the choice, so the host's switcher calls
7272
* `closeMenu` when a layout is picked (objectui#12621). The panel's toggles
7373
* (row numbers, inline edit, density, hidden fields) never close it.
74+
*
75+
* The row folds a segmented control that would not fit to icons (see
76+
* `LayoutSlot`). It folds what it can read: a `role="tablist"` whose tabs
77+
* carry their name in `aria-label` and `title` and their words in a direct
78+
* child `<span>`, the shape `ViewSwitcherDropdown` draws.
7479
*/
7580
layout?: (closeMenu: () => void) => React.ReactNode;
7681

@@ -248,6 +253,69 @@ function SwitchRow({
248253
);
249254
}
250255

256+
/** Set on the layout slot while its segments show icons only; the class below reads it. */
257+
const LAYOUT_FOLD_ATTR = 'data-layout-fold';
258+
259+
/**
260+
* The Layout row's control, folded to icons when its segments would not fit
261+
* (objectstack#26637). The panel is a fixed width and the row shares it with
262+
* its title, so four or five icon + label segments ran out of room and broke
263+
* each label onto two lines, in zh-CN and longer still in en.
264+
*
265+
* The fold is MEASURED, never a count or a breakpoint: what decides it is the
266+
* width the segments need with every label shown against the width the slot
267+
* has, in whatever locale is rendering. Folded, every segment but the selected
268+
* one hides its words; its icon stays, and its `aria-label` and `title` stay
269+
* its accessible name and tooltip. The selected segment keeps its label.
270+
*
271+
* Labels never wrap here (`whitespace-nowrap`, `shrink-0`), so the width the
272+
* segments need is the slot's children's `scrollWidth`. While folded the
273+
* hidden labels add nothing to it, so each measurement lifts the fold for one
274+
* synchronous read and puts it back before the browser paints. It runs after
275+
* every render (labels, locale and the views offered all arrive as props) and
276+
* whenever the slot itself resizes.
277+
*/
278+
function LayoutSlot({ children }: { children: React.ReactNode }) {
279+
const ref = React.useRef<HTMLDivElement>(null);
280+
const [folded, setFolded] = React.useState(false);
281+
282+
const measure = React.useCallback(() => {
283+
const slot = ref.current;
284+
if (!slot) return;
285+
const fold = slot.getAttribute(LAYOUT_FOLD_ATTR);
286+
if (fold !== null) slot.removeAttribute(LAYOUT_FOLD_ATTR);
287+
let needed = 0;
288+
for (const child of Array.from(slot.children)) needed += (child as HTMLElement).scrollWidth;
289+
if (fold !== null) slot.setAttribute(LAYOUT_FOLD_ATTR, fold);
290+
setFolded(needed > slot.clientWidth);
291+
}, []);
292+
293+
React.useLayoutEffect(measure);
294+
295+
React.useEffect(() => {
296+
const slot = ref.current;
297+
if (!slot || typeof ResizeObserver === 'undefined') return;
298+
const observer = new ResizeObserver(() => measure());
299+
observer.observe(slot);
300+
return () => observer.disconnect();
301+
}, [measure]);
302+
303+
return (
304+
<div
305+
ref={ref}
306+
data-layout-fold={folded ? 'icons' : undefined}
307+
className={cn(
308+
'flex min-w-0 flex-1 justify-end',
309+
'[&_[role=tab]]:shrink-0 [&_[role=tab]]:whitespace-nowrap',
310+
'[&[data-layout-fold]_[role=tab][aria-selected=false]>span]:hidden',
311+
)}
312+
data-testid="display-layout-slot"
313+
>
314+
{children}
315+
</div>
316+
);
317+
}
318+
251319
export function ViewSettingsPopover(props: ViewSettingsPopoverProps) {
252320
const {
253321
t,
@@ -321,10 +389,10 @@ export function ViewSettingsPopover(props: ViewSettingsPopoverProps) {
321389
<div className="max-h-[min(70vh,560px)] overflow-y-auto">
322390
{layout && (
323391
<div className="flex items-center justify-between gap-3 border-b px-3 py-2" data-testid="display-layout-row">
324-
<span className="text-xs font-medium text-foreground/80">
392+
<span className="shrink-0 whitespace-nowrap text-xs font-medium text-foreground/80">
325393
{t('list.layout', { defaultValue: 'Layout' })}
326394
</span>
327-
{layout(closeMenu)}
395+
<LayoutSlot>{layout(closeMenu)}</LayoutSlot>
328396
</div>
329397
)}
330398

0 commit comments

Comments
 (0)