feat(editor): a DHCP server's two pages as a segmented control

"Grundeinstellungen" and "Statische Adresszuweisung" (Java's words)
become the dashboard's segmented control: one frame, equal shares, the
chosen one in the accent. Measured: "Grundeinstellungen" is 138 px at
14 px and a half of the 340 px sheet holds 139 (157 bold). So the words
wrap at their spaces inside a segment, the chosen one is not bold (its
width would jump), and where a segment's longest word with its padding no
longer fits its share — a phone, another system font — the segments stack,
still one control. Decided by measuring the words in the segments' font,
not by a breakpoint that would hold for one font only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 18:52:27 +00:00
co-authored by Claude Opus 5.5
parent 16d9cc2095
commit 9fae9e59b2
5 changed files with 210 additions and 3 deletions
+67
View File
@@ -504,9 +504,76 @@ test("opens a home router's firewall from its general tab", async ({ page }) =>
await expect(sheet(page).getByLabel('Firewall aktivieren')).not.toBeChecked();
});
/** The DHCP page's segmented control: its frame, its two segments' boxes, and how they stand. */
async function dhcpSegments(page: Page) {
await page.locator('[data-palette="pc"]').click();
await sheet(page).getByRole('button', { name: 'DHCP-Server einrichten' }).click();
const control = sheet(page).getByRole('tablist');
await expect(control).toHaveClass('sheet-segments');
const boxes = await Promise.all([0, 1].map(async (index) => (await control.getByRole('tab').nth(index).boundingBox())!));
return { control, boxes };
}
test("shows a DHCP server's two pages as a segmented control: side by side in the sheet, the long word wrapped, nothing cut off", async ({ page }) => {
await openEditor(page, server.base, 'new');
const { control, boxes } = await dhcpSegments(page);
await expect(control).not.toHaveAttribute('data-stacked', 'true');
const [basic, assignments] = boxes as [{ x: number; y: number; width: number; height: number }, { x: number; y: number; width: number; height: number }];
// One row, equal shares, equal heights — „Statische Adresszuweisung" on two lines inside its 44 px.
expect(Math.round(basic.y)).toBe(Math.round(assignments.y));
expect(Math.abs(basic.width - assignments.width)).toBeLessThanOrEqual(1);
expect(basic.height).toBe(assignments.height);
expect(basic.height).toBeGreaterThanOrEqual(44);
// No word runs over its segment's edge.
for (const tab of await control.getByRole('tab').all()) {
expect(await tab.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
}
// The chosen one in the accent, the other quiet; a choice moves it.
const tabs = control.getByRole('tab');
await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'true');
await tabs.nth(1).click();
await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true');
await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'false');
// Not bold when chosen: the width does not jump.
expect(Math.round((await tabs.nth(1).boundingBox())!.width)).toBe(Math.round(assignments.width));
});
test("stacks the DHCP server's segments exactly where a segment's longest word, with its padding, no longer fits its share of the row", async ({ page }) => {
await openEditor(page, server.base, 'new');
const { control } = await dhcpSegments(page);
// What one segment needs: its longest word in the segments' font, and its padding.
const need = await control.evaluate((list) => {
const segments = [...list.querySelectorAll<HTMLElement>('[role="tab"]')];
const style = getComputedStyle(segments[0]!);
const measure = document.createElement('canvas').getContext('2d')!;
measure.font = style.font;
const words = segments.flatMap((segment) => (segment.textContent ?? '').split(/\s+/));
return Math.max(...words.map((word) => measure.measureText(word).width)) + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
});
// Two shares and the line between them, inside the frame; a little under and a little over.
const inside = async (width: number) => control.evaluate((list, value) => { list.style.boxSizing = 'content-box'; list.style.width = `${value}px`; }, width);
await inside(2 * need + 1 - 0.6);
await expect(control).toHaveAttribute('data-stacked', 'true');
await inside(2 * need + 1 + 0.6);
await expect(control).not.toHaveAttribute('data-stacked', 'true');
});
test.describe('on a phone', () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
test("stacks the DHCP server's segments where a word no longer fits its share — still one control", async ({ page }) => {
await openEditor(page, server.base, 'new');
const { control, boxes } = await dhcpSegments(page);
await expect(control).toHaveAttribute('data-stacked', 'true');
await expect(control).toHaveAttribute('aria-orientation', 'vertical');
const [basic, assignments] = boxes as [{ x: number; y: number; width: number; height: number }, { x: number; y: number; width: number; height: number }];
expect(Math.round(basic.x)).toBe(Math.round(assignments.x));
expect(assignments.y).toBeGreaterThanOrEqual(basic.y + basic.height - 1);
for (const tab of await control.getByRole('tab').all()) {
expect(await tab.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
}
});
test('opens below the surface with a tap, the device still in sight', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const id = await deviceNamed(page, 'Notebook 1.10');
@@ -44,6 +44,7 @@ export function DhcpServerPanel({ server, change }: { readonly server: DhcpServe
]}
selected={tab}
onSelect={setTab}
variant="segmented"
/>
<SheetPanel key={tab}>
{tab === 'basic' ? (
+69 -3
View File
@@ -1,4 +1,19 @@
// Java's tabs (JTabbedPane) in the device sheet: chips that wrap in a narrow sheet.
// Java's tabs (JTabbedPane) in the device sheet: chips that wrap in a narrow
// sheet — or, where a page has two or three tabs of equal rank (a DHCP
// server's base settings and static assignments), the dashboard's segmented
// control: one frame, equal segments, the chosen one in the accent.
//
// The width is the hard part. Java's words stay (M11), and "Grundeinstellungen"
// is one word of 138 px at 14 px — measured on 10.10.2026, while a half of the
// 340 px sheet holds 139. So a segment's words may wrap at their spaces
// ("Statische / Adresszuweisung"), the chosen one is not set bold (157 px; the
// width would jump on every choice), and where even the longest word of a
// segment no longer fits its share — a phone, another system font, English —
// the segments stand under one another, still one control. That is decided by
// measuring the words in the segments' own font, not by a breakpoint: a
// breakpoint would hold for one font only.
import { useLayoutEffect, useRef, useState } from 'react';
export interface Tab {
readonly key: string;
@@ -6,9 +21,60 @@ export interface Tab {
readonly disabled?: boolean;
}
export function SheetTabs({ tabs, selected, onSelect }: { readonly tabs: readonly Tab[]; readonly selected: string; readonly onSelect: (key: string) => void }) {
/** Whether every segment's longest word fits its equal share of the row (a 1 px line between two). */
function wordsFit(list: HTMLElement, measure: CanvasRenderingContext2D): boolean {
const segments = [...list.querySelectorAll<HTMLElement>('[role="tab"]')];
if (segments.length === 0) return true;
const style = getComputedStyle(segments[0]!);
measure.font = style.font;
const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
// The width inside the frame, to the fraction (clientWidth would round it).
const frame = getComputedStyle(list);
const inner = list.getBoundingClientRect().width - parseFloat(frame.borderLeftWidth) - parseFloat(frame.borderRightWidth);
const share = (inner - (segments.length - 1)) / segments.length;
return segments.every((segment) => Math.max(...(segment.textContent ?? '').split(/\s+/).map((word) => measure.measureText(word).width)) + padding <= share);
}
/** Side by side while the words fit, else stacked; measured again when the sheet's width or the words change. */
function useStacked(labels: string) {
const list = useRef<HTMLDivElement>(null);
const [stacked, setStacked] = useState(false);
useLayoutEffect(() => {
const element = list.current;
const measure = typeof document === 'undefined' ? null : document.createElement('canvas').getContext('2d');
// Without a canvas (a test's document) the row stays as it is.
if (element === null || measure === null || typeof ResizeObserver === 'undefined') return undefined;
const decide = () => setStacked(!wordsFit(element, measure));
decide();
const observer = new ResizeObserver(decide);
observer.observe(element);
return () => observer.disconnect();
}, [labels]);
return { list, stacked };
}
export function SheetTabs({
tabs,
selected,
onSelect,
variant = 'chips',
}: {
readonly tabs: readonly Tab[];
readonly selected: string;
readonly onSelect: (key: string) => void;
/** `segmented`: the dashboard's segmented control, for a few tabs of equal rank. */
readonly variant?: 'chips' | 'segmented';
}) {
const { list, stacked } = useStacked(tabs.map((tab) => tab.label).join('\n'));
const segmented = variant === 'segmented';
return (
<div role="tablist" className="sheet-tabs">
<div
ref={segmented ? list : undefined}
role="tablist"
className={segmented ? 'sheet-segments' : 'sheet-tabs'}
data-stacked={segmented && stacked ? 'true' : undefined}
aria-orientation={segmented && stacked ? 'vertical' : undefined}
>
{tabs.map((tab) => (
<button key={tab.key} type="button" role="tab" data-tab={tab.key} aria-selected={tab.key === selected} disabled={tab.disabled === true} onClick={() => onSelect(tab.key)}>
{tab.label}
+72
View File
@@ -215,6 +215,78 @@
cursor: default;
}
/* The segmented control (SheetTabs `segmented`), after the dashboard's
* SegmentedControl: one frame, lines between the segments, the chosen one in
* the accent — not bold, so that its width does not jump (see SheetTabs).
* Equal shares; a segment's words wrap at their spaces, and where a word no
* longer fits its share the segments stand under one another. */
.sheet-segments {
display: flex;
margin: 4px 0 12px;
border: 1px solid var(--border-default);
border-radius: 8px;
overflow: hidden;
background: var(--surface-raised);
}
.sheet-segments[data-stacked='true'] {
flex-direction: column;
}
.sheet-segments button {
flex: 1 1 0;
min-width: 0;
min-height: 44px;
padding: 4px 6px;
border: none;
border-right: 1px solid var(--border-default);
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: 14px;
line-height: 1.25;
text-align: center;
white-space: normal;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.sheet-segments button:last-child {
border-right: none;
}
.sheet-segments[data-stacked='true'] button {
flex: none;
border-right: none;
border-bottom: 1px solid var(--border-default);
}
.sheet-segments[data-stacked='true'] button:last-child {
border-bottom: none;
}
.sheet-segments button:hover:not(:disabled):not([aria-selected='true']) {
background: var(--surface-hover, var(--surface-sunken));
color: var(--text-primary);
}
.sheet-segments button[aria-selected='true'] {
background: var(--accent);
color: var(--text-on-accent);
}
/* Inside: outside, the frame's overflow would cut the ring off. */
.sheet-segments button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -3px;
}
.sheet-segments button:disabled {
color: var(--text-muted);
cursor: default;
}
.sheet-buttons {
display: flex;
flex-wrap: wrap;