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:
@@ -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' ? (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user