feat(editor): F4.5b the device sheet for routers and home routers

Measured first: SheetRun now types into FILIUS' router and home router
panels, records five forwarding tables (holeTabelle) and steps in the
panel's table. Java turns a card address that passes the pattern but not
IP.ipCheck (".0.0.1") into null and the router panel throws; an empty
gateway is marked red yet taken; a refused table cell is stored as null;
a home router's DHCP client leaves its DHCP server running. None of the
nulls is adopted. The core's routeTableRows matches all five tables.

- doc: card addresses (the first card mirrored in the device's settings),
  router gateway, RIP, manual routes (a new row once all four cells pass,
  a cell, removal); the sheet reads cards with what their cable joins and
  the whole table
- editor: Java's tabs (general, each card titled by its address, the
  forwarding table greyed out under RIP; general, LAN, WAN on a home
  router); the open card's cable drawn active in Java's green

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 11:51:03 +00:00
co-authored by Claude Opus 5.5
parent 9b7ec2fc72
commit eff9433673
22 changed files with 2201 additions and 119 deletions
+13 -1
View File
@@ -78,6 +78,8 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
const [menu, setMenu] = useState<{ readonly target: Selection; readonly at: Point } | null>(null);
const [routerAt, setRouterAt] = useState<Point | null>(null);
const [message, setMessage] = useState<string | null>(null);
// The cable of the card whose tab is open in the sheet (Java's Kabel.setAktiv).
const [highlightedLink, setHighlightedLink] = useState<string | null>(null);
// A message goes after a while.
useEffect(() => {
@@ -203,6 +205,7 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
onMoveEnd={() => manager?.stopCapturing()}
onCableStart={setCableFrom}
onCable={onCable}
highlightedLink={highlightedLink}
onMenu={(target, at) => {
setSelection(target);
setMenu({ target, at });
@@ -247,7 +250,16 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
</div>
{/* Java shows the settings of the device that is chosen; closing them lets go of it, as a click beside it does. */}
{selected === undefined ? null : (
<DeviceSheet doc={doc} id={selected.id} label={selected.label} origin={origin} onChanged={() => manager?.stopCapturing()} onMessage={setMessage} onClose={() => setSelection(null)} />
<DeviceSheet
doc={doc}
id={selected.id}
label={selected.label}
origin={origin}
onChanged={() => manager?.stopCapturing()}
onMessage={setMessage}
onHighlight={setHighlightedLink}
onClose={() => setSelection(null)}
/>
)}
</div>
</div>
+7 -4
View File
@@ -54,6 +54,8 @@ export interface CanvasProps {
readonly onCableStart?: (from: { readonly id: string; readonly side: Side } | null) => void;
readonly onCable?: (from: { readonly id: string; readonly side: Side }, to: { readonly id: string; readonly side: Side }) => void;
readonly onMenu?: (target: Selection, at: Point) => void;
/** A cable drawn active, as Java draws the cable of the card whose tab is open (Kabel.setAktiv). */
readonly highlightedLink?: string | null;
}
/** The label's width in Java's font, measured once per text. */
@@ -74,8 +76,7 @@ function useTextWidth(): (text: string) => number {
);
}
const rgba = (color: DocumentationView['color']) =>
`rgba(${Math.round(color.red * 255)}, ${Math.round(color.green * 255)}, ${Math.round(color.blue * 255)}, ${color.alpha})`;
const rgba = (color: DocumentationView['color']) => `rgba(${Math.round(color.red * 255)}, ${Math.round(color.green * 255)}, ${Math.round(color.blue * 255)}, ${color.alpha})`;
function Documentation({ item }: { readonly item: DocumentationView }) {
if (item.kind === 'rectangle') {
@@ -140,7 +141,7 @@ type Gesture =
| { readonly kind: 'link'; readonly id: string; readonly start: Point; moved: boolean; longPressed: boolean };
export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
{ view, tool = 'select', selection = null, cableFrom = null, onSelect, onMoveDevice, onMoveEnd, onCableStart, onCable, onMenu },
{ view, tool = 'select', selection = null, cableFrom = null, onSelect, onMoveDevice, onMoveEnd, onCableStart, onCable, onMenu, highlightedLink = null },
handle,
) {
const t = useTranslate();
@@ -420,14 +421,16 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
if (source === undefined || target === undefined) return null;
const d = cablePath(source.center, target.center);
const selected = selection?.kind === 'link' && selection.id === link.id;
const active = highlightedLink === link.id;
return (
<g key={link.id}>
<path
data-link={link.id}
data-selected={selected ? 'true' : undefined}
data-active={active ? 'true' : undefined}
d={d}
strokeWidth={selected ? 4 : 2}
stroke={selected ? 'var(--accent)' : link.wireless ? 'var(--canvas-cable-wireless)' : 'var(--canvas-cable)'}
stroke={selected ? 'var(--accent)' : active ? 'var(--canvas-cable-active)' : link.wireless ? 'var(--canvas-cable-wireless)' : 'var(--canvas-cable)'}
strokeDasharray={link.wireless ? '10' : undefined}
/>
{/* A wide, invisible line to tap: 2 px is no target for a finger. */}
+15
View File
@@ -30,19 +30,28 @@
"menu.delete": "Löschen",
"menu.removeAllCables": "Alle Verbindungen entfernen",
"menu.removeCable": "Verbindung entfernen",
"route.destinationNetwork": "Ziel",
"route.gateway": "Nächstes Gateway",
"route.interfaceIPAddress": "Über Schnittstelle",
"route.subnetMask": "Netzmaske",
"router.cancel": "Abbrechen",
"router.prompt": "Wählen Sie die Anzahl der Schnittstellen des Vermittlungsrechners aus!",
"router.title": "Anzahl Schnittstellen",
"sheet.cable": "Kabelgebunden (LAN)",
"sheet.cablePlugged": "Das Kabel ist noch eingesteckt.",
"sheet.cards": "Netzwerkkarten",
"sheet.chooseNetwork": "Bitte auswählen",
"sheet.close": "Schließen",
"sheet.cloud": "als Wolke darstellen",
"sheet.connectedTo": "Verbunden mit {device}",
"sheet.connection": "Anschluss",
"sheet.defaultGateway": "Gateway",
"sheet.deleteEntry": "Eintrag löschen",
"sheet.dhcpClient": "DHCP zur Konfiguration verwenden",
"sheet.dnsServer": "Domain Name Server",
"sheet.empty": "(leer)",
"sheet.forwardingTable": "Weiterleitungstabelle",
"sheet.general": "Allgemein",
"sheet.invalidValue": "Dieser Wert ist ungültig.",
"sheet.ipAddress": "IP-Adresse",
"sheet.ipAsName": "IP-Adresse als Name verwenden",
@@ -51,9 +60,15 @@
"sheet.macAsName": "MAC-Adresse als Name verwenden",
"sheet.name": "Name",
"sheet.network": "Drahtloses Netzwerk (SSID)",
"sheet.newEntry": "Neuer Eintrag",
"sheet.newEntryHint": "Die Zeile wird übernommen, sobald alle vier Felder gültig sind.",
"sheet.nic": "Netzwerkkarte {number}",
"sheet.notConnected": "Keine Verbindung",
"sheet.notTaken": "Nicht übernommen – es gilt weiterhin {value}.",
"sheet.port": "Port",
"sheet.retention": "Gültigkeitsdauer SAT-Einträge (in Sek.)",
"sheet.rip": "Automatisches Routing",
"sheet.showAllEntries": "Alle Einträge anzeigen",
"sheet.subnetMask": "Netzmaske",
"sheet.switchSsid": "Name WLAN (SSID)",
"sheet.title": "Einstellungen",
+15
View File
@@ -30,19 +30,28 @@
"menu.delete": "Delete",
"menu.removeAllCables": "Remove all cables",
"menu.removeCable": "Remove cable",
"route.destinationNetwork": "Destination",
"route.gateway": "Next gateway",
"route.interfaceIPAddress": "NIC",
"route.subnetMask": "Netmask",
"router.cancel": "Cancel",
"router.prompt": "Choose number of NIC of the router!",
"router.title": "Number of NIC",
"sheet.cable": "Cable (LAN)",
"sheet.cablePlugged": "The cable is still plugged in.",
"sheet.cards": "Network cards",
"sheet.chooseNetwork": "Please select",
"sheet.close": "Close",
"sheet.cloud": "Display as Cloud",
"sheet.connectedTo": "Connected to {device}",
"sheet.connection": "Connection",
"sheet.defaultGateway": "Gateway",
"sheet.deleteEntry": "Delete entry",
"sheet.dhcpClient": "Use DHCP for configuration",
"sheet.dnsServer": "Domain Name Server",
"sheet.empty": "(empty)",
"sheet.forwardingTable": "Forwarding table",
"sheet.general": "General",
"sheet.invalidValue": "This value is invalid.",
"sheet.ipAddress": "IP Address",
"sheet.ipAsName": "Use IP address as name",
@@ -51,9 +60,15 @@
"sheet.macAsName": "Use MAC address as name",
"sheet.name": "Name",
"sheet.network": "Wireless Network (SSID)",
"sheet.newEntry": "New entry",
"sheet.newEntryHint": "The row is taken once all four fields are valid.",
"sheet.nic": "NIC {number}",
"sheet.notConnected": "No Connection",
"sheet.notTaken": "Not taken – still {value}.",
"sheet.port": "Port",
"sheet.retention": "Retention Time SAT Entries (Seconds)",
"sheet.rip": "Automatic Routing",
"sheet.showAllEntries": "Show all entries",
"sheet.subnetMask": "Netmask",
"sheet.switchSsid": "Wi-Fi Name (SSID)",
"sheet.title": "Settings",
+13 -29
View File
@@ -1,16 +1,14 @@
// The device sheet (plan §7.1): the settings of the selected device, the
// fields of FILIUS' settings panel of its kind (JHostKonfiguration,
// JSwitchKonfiguration, JModemKonfiguration) in the panels' order. Java
// shows the panel when a device is chosen; so does the sheet, beside the
// surface or below it on a phone. Each change is one step of the person's
// undo. A router's and a home router's sheet name the device only, until
// their own panels follow (F4.5b).
// JSwitchKonfiguration, JModemKonfiguration; a router's and a home router's
// in RouterSheets.tsx) in the panels' order. Java shows the panel when a
// device is chosen; so does the sheet, beside the surface or below it on a
// phone. Each change is one step of the person's undo.
import { useEffect, useState } from 'react';
import type * as Y from 'yjs';
import {
deviceSheet,
renameDevice,
setDhcpClient,
setHostAddress,
setHostLabels,
@@ -30,6 +28,8 @@ import {
import { iconOf } from '../canvas/layout.js';
import { ICON_URLS } from '../canvas/icons.js';
import { useTranslate } from '../i18n/i18n.js';
import { NameField, type Change } from './NameField.js';
import { HomeRouterFields, RouterFields } from './RouterSheets.js';
import { SheetCheck, SheetField } from './SheetField.js';
import './sheet.css';
@@ -55,17 +55,11 @@ export interface DeviceSheetProps {
readonly onChanged: () => void;
readonly onMessage: (message: string) => void;
readonly onClose: () => void;
/** The cable to draw active (a router's card whose tab is open), or none. */
readonly onHighlight: (link: string | null) => void;
}
interface Change {
readonly doc: Y.Doc;
readonly id: string;
readonly origin: object;
/** Runs a change and ends the undo step. */
readonly run: <T>(change: () => T) => T;
}
export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose }: DeviceSheetProps) {
export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose, onHighlight }: DeviceSheetProps) {
const t = useTranslate();
const sheet = useDeviceSheet(doc, id);
if (sheet === undefined) return null;
@@ -97,6 +91,10 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
<SwitchFields sheet={sheet} change={change} />
) : sheet.kind === 'remoteLink' ? (
<ModemFields sheet={sheet} change={change} />
) : sheet.kind === 'router' ? (
<RouterFields sheet={sheet} change={change} onHighlight={onHighlight} />
) : sheet.kind === 'gateway' ? (
<HomeRouterFields sheet={sheet} change={change} onHighlight={onHighlight} />
) : (
<NameField name={sheet.name} change={change} />
)}
@@ -105,20 +103,6 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
);
}
function NameField({ name, change, shown, disabled = false }: { readonly name: string; readonly change: Change; readonly shown?: string; readonly disabled?: boolean }) {
const t = useTranslate();
return (
<SheetField
name="name"
label={t('sheet.name')}
value={shown ?? name}
rule="name"
disabled={disabled}
onCommit={(text) => change.run(() => (renameDevice(change.doc, change.id, text, change.origin), { value: text }))}
/>
);
}
function HostFields({ sheet, label, change, onMessage }: { readonly sheet: HostSheet; readonly label: string; readonly change: Change; readonly onMessage: (message: string) => void }) {
const t = useTranslate();
const address =
+29
View File
@@ -0,0 +1,29 @@
// What every part of the device sheet shares: the change of one device by
// one person, and the device's name.
import type * as Y from 'yjs';
import { renameDevice } from '@mdg-filius/doc';
import { useTranslate } from '../i18n/i18n.js';
import { SheetField } from './SheetField.js';
export interface Change {
readonly doc: Y.Doc;
readonly id: string;
readonly origin: object;
/** Runs a change and ends the undo step. */
readonly run: <T>(change: () => T) => T;
}
export function NameField({ name, change, shown, disabled = false }: { readonly name: string; readonly change: Change; readonly shown?: string; readonly disabled?: boolean }) {
const t = useTranslate();
return (
<SheetField
name="name"
label={t('sheet.name')}
value={shown ?? name}
rule="name"
disabled={disabled}
onCommit={(text) => change.run(() => (renameDevice(change.doc, change.id, text, change.origin), { value: text }))}
/>
);
}
+266
View File
@@ -0,0 +1,266 @@
// The sheets of a router and a home router, after Java's panels
// (JVermittlungsrechnerKonfiguration, JGatewayConfiguration): tabs for the
// general settings, each card and — on a router — the forwarding table
// (JWeiterleitungsTabelle), greyed out while automatic routing (RIP) runs.
// The card whose tab is open has its cable drawn active, as Java draws it.
// The firewall, the port forwarding, the DHCP server and the change of the
// cards follow with their dialogs (F4.5c).
import { useEffect, useState } from 'react';
import {
addRoute,
checkField,
ROUTE_COLUMNS,
removeRoute,
setDhcpClient,
setInterfaceAddress,
setRip,
setRouteCell,
setRouterGateway,
type CheckedField,
type HomeRouterSheet,
type InterfaceSheet,
type RouteColumn,
type RouterSheet,
} from '@mdg-filius/doc';
import { useTranslate } from '../i18n/i18n.js';
import { NameField, type Change } from './NameField.js';
import { SheetCheck, SheetField } from './SheetField.js';
interface Tab {
readonly key: string;
readonly label: string;
readonly disabled?: boolean;
}
function SheetTabs({ tabs, selected, onSelect }: { readonly tabs: readonly Tab[]; readonly selected: string; readonly onSelect: (key: string) => void }) {
return (
<div role="tablist" className="sheet-tabs">
{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}
</button>
))}
</div>
);
}
/** Reports the cable of the card whose tab is open, and none once the sheet goes. */
function useHighlight(link: string | null, onHighlight: (link: string | null) => void): void {
useEffect(() => {
onHighlight(link);
}, [link, onHighlight]);
useEffect(() => () => onHighlight(null), [onHighlight]);
}
/** A card: what its cable joins, its address and mask, its MAC address. */
function InterfaceFields({ card, change, closed = false }: { readonly card: InterfaceSheet; readonly change: Change; readonly closed?: boolean }) {
const t = useTranslate();
const set = (field: 'ipAddress' | 'subnetMask') => (text: string) => change.run(() => setInterfaceAddress(change.doc, change.id, card.portId, field, text, change.origin));
return (
<>
<p className="sheet-connected" data-connected={card.connected === null ? 'false' : 'true'}>
{card.connected === null ? t('sheet.notConnected') : t('sheet.connectedTo', { device: card.connected.device })}
</p>
<SheetField name="ipAddress" label={t('sheet.ipAddress')} value={card.ipAddress} rule="ipAddress" inputMode="decimal" disabled={closed} onCommit={set('ipAddress')} />
<SheetField name="subnetMask" label={t('sheet.subnetMask')} value={card.subnetMask} rule="subnetMask" inputMode="decimal" disabled={closed} onCommit={set('subnetMask')} />
<SheetField name="macAddress" label={t('sheet.macAddress')} value={card.macAddress} rule="name" readOnly onCommit={() => ({ invalid: true })} />
</>
);
}
function GatewayField({ value, change }: { readonly value: string; readonly change: Change }) {
const t = useTranslate();
return (
<SheetField
name="gateway"
label={t('sheet.defaultGateway')}
value={value}
rule="ipAddressOrEmpty"
inputMode="decimal"
onCommit={(text) => change.run(() => setRouterGateway(change.doc, change.id, text, change.origin))}
/>
);
}
export function RouterFields({ sheet, change, onHighlight }: { readonly sheet: RouterSheet; readonly change: Change; readonly onHighlight: (link: string | null) => void }) {
const t = useTranslate();
const [tab, setTab] = useState('general');
// Java greys the table out while RIP runs; an open table goes back to the general tab.
const shown = tab === 'table' && sheet.rip ? 'general' : tab;
const card = shown.startsWith('card-') ? sheet.interfaces[Number(shown.slice(5))] : undefined;
useHighlight(card?.connected?.link ?? null, onHighlight);
const tabs: Tab[] = [
{ key: 'general', label: t('sheet.general') },
// Java names a card's tab by its address (updateAttribute), "Netzwerkkarte n" without one.
...sheet.interfaces.map((each, index) => ({ key: `card-${index}`, label: each.ipAddress === '' ? t('sheet.nic', { number: index + 1 }) : each.ipAddress })),
{ key: 'table', label: t('sheet.forwardingTable'), disabled: sheet.rip },
];
return (
<>
<SheetTabs tabs={tabs} selected={shown} onSelect={setTab} />
{/* A panel per tab: a text typed on one card is not offered to the next. */}
<div role="tabpanel" key={shown}>
{shown === 'general' ? (
<>
<NameField name={sheet.name} change={change} />
<GatewayField value={sheet.gateway} change={change} />
<SheetCheck name="rip" label={t('sheet.rip')} checked={sheet.rip} onChange={(on) => change.run(() => setRip(change.doc, change.id, on, change.origin))} />
<SheetCheck name="ipForwarding" label={t('sheet.ipForwarding')} checked={sheet.ipForwarding} disabled onChange={() => undefined} />
</>
) : card !== undefined ? (
<InterfaceFields card={card} change={change} />
) : (
<RouteTable sheet={sheet} change={change} />
)}
</div>
</>
);
}
export function HomeRouterFields({ sheet, change, onHighlight }: { readonly sheet: HomeRouterSheet; readonly change: Change; readonly onHighlight: (link: string | null) => void }) {
const t = useTranslate();
const [tab, setTab] = useState('general');
const card = tab === 'lan' ? sheet.lan : tab === 'wan' ? sheet.wan : undefined;
useHighlight(card?.connected?.link ?? null, onHighlight);
// Java's order: general, LAN, WAN; a card's tab says which and its address.
const tabs: Tab[] = [
{ key: 'general', label: t('sheet.general') },
{ key: 'lan', label: `LAN: ${sheet.lan.ipAddress}` },
{ key: 'wan', label: `WAN: ${sheet.wan.ipAddress}` },
];
return (
<>
<SheetTabs tabs={tabs} selected={tab} onSelect={setTab} />
<div role="tabpanel" key={tab}>
{tab === 'general' ? (
<>
<NameField name={sheet.name} change={change} />
<GatewayField value={sheet.gateway} change={change} />
<SheetCheck name="ipForwarding" label={t('sheet.ipForwarding')} checked={sheet.ipForwarding} disabled onChange={() => undefined} />
</>
) : tab === 'lan' ? (
<InterfaceFields card={sheet.lan} change={change} />
) : (
<>
{/* DHCP configures the WAN card; its address and mask are closed meanwhile, as in Java. */}
<InterfaceFields card={sheet.wan} change={change} closed={sheet.dhcpClient} />
<SheetCheck name="dhcpClient" label={t('sheet.dhcpClient')} checked={sheet.dhcpClient} onChange={(on) => change.run(() => setDhcpClient(change.doc, change.id, on, change.origin))} />
</>
)}
</div>
</>
);
}
const routeRule = (column: RouteColumn) => (column === 'subnetMask' ? 'subnetMask' : 'ipAddress');
const EMPTY_ROW: Readonly<Record<RouteColumn, string>> = { destinationNetwork: '', subnetMask: '', gateway: '', interfaceIPAddress: '' };
/**
* The forwarding table: all rows or the manual ones only (Java's "show all
* entries", on at first), a manual row chosen to edit its cells or remove
* it, one new row at a time, taken once all four cells pass (as Java keeps
* its new row until Weiterleitungstabelle.validate takes it).
*/
function RouteTable({ sheet, change }: { readonly sheet: RouterSheet; readonly change: Change }) {
const t = useTranslate();
const [showAll, setShowAll] = useState(true);
const [chosen, setChosen] = useState<number | 'new' | null>(null);
const [draft, setDraft] = useState<Record<RouteColumn, string>>(EMPTY_ROW);
let manual = -1;
const rows = sheet.table.map((row) => ({ row, manual: row.isEditable ? ++manual : null })).filter((each) => showAll || each.manual !== null);
const editing = typeof chosen === 'number' ? sheet.routes[chosen] : undefined;
const draftCell =
(column: RouteColumn) =>
(text: string): CheckedField => {
const checked = checkField(routeRule(column), text);
if ('invalid' in checked) return checked;
const next = { ...draft, [column]: checked.value };
setDraft(next);
if (ROUTE_COLUMNS.every((each) => 'value' in checkField(routeRule(each), next[each]))) {
const added = change.run(() => addRoute(change.doc, change.id, next, change.origin));
if ('route' in added) {
setDraft(EMPTY_ROW);
setChosen(sheet.routes.length);
}
}
return checked;
};
return (
<>
<SheetCheck name="showAll" label={t('sheet.showAllEntries')} checked={showAll} onChange={setShowAll} />
<div className="route-table-frame">
<table className="route-table">
<thead>
<tr>
{ROUTE_COLUMNS.map((column) => (
<th key={column} scope="col">
{t(`route.${column}`)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map(({ row, manual: index }, place) => (
<tr key={place} data-manual={index === null ? undefined : String(index)} aria-selected={index !== null && chosen === index} onClick={index === null ? undefined : () => setChosen(index)}>
{[row.destinationNetwork, row.subnetMask, row.nextHop, row.interfaceIPAddress].map((cell, column) => (
// A manual cell the rule refuses (from a file) is marked, not mended.
<td key={column} data-invalid={index !== null && 'invalid' in checkField(routeRule(ROUTE_COLUMNS[column]!), cell) ? 'true' : undefined}>
{cell}
</td>
))}
</tr>
))}
{chosen === 'new' ? (
<tr aria-selected data-new>
{ROUTE_COLUMNS.map((column) => (
<td key={column}>{draft[column]}</td>
))}
</tr>
) : null}
</tbody>
</table>
</div>
<div className="sheet-buttons">
<button type="button" onClick={() => setChosen('new')}>
{t('sheet.newEntry')}
</button>
<button
type="button"
disabled={chosen === null}
onClick={() => {
if (chosen === 'new') setDraft(EMPTY_ROW);
else if (chosen !== null) change.run(() => removeRoute(change.doc, change.id, chosen, change.origin));
setChosen(null);
}}
>
{t('sheet.deleteEntry')}
</button>
</div>
{chosen === 'new' ? (
<fieldset className="sheet-group" key="new">
<p className="sheet-note">{t('sheet.newEntryHint')}</p>
{ROUTE_COLUMNS.map((column) => (
<SheetField key={column} name={`route-${column}`} label={t(`route.${column}`)} value={draft[column]} rule={routeRule(column)} inputMode="decimal" unfilled onCommit={draftCell(column)} />
))}
</fieldset>
) : editing !== undefined ? (
<fieldset className="sheet-group" key={`row-${String(chosen)}`}>
{ROUTE_COLUMNS.map((column) => (
<SheetField
key={column}
name={`route-${column}`}
label={t(`route.${column}`)}
value={editing[column]}
rule={routeRule(column)}
inputMode="decimal"
onCommit={(text) => change.run(() => setRouteCell(change.doc, change.id, chosen as number, column, text, change.origin))}
/>
))}
</fieldset>
) : null}
</>
);
}
+4 -2
View File
@@ -22,16 +22,18 @@ export interface SheetFieldProps {
/** A name for the browser tests and the label's input mode. */
readonly name: string;
readonly inputMode?: 'text' | 'decimal' | 'numeric';
/** A field still to be filled (a new row's cell): empty and untouched, it is not marked. */
readonly unfilled?: boolean;
}
export function SheetField({ label, value, rule, onCommit, disabled = false, readOnly = false, name, inputMode = 'text' }: SheetFieldProps) {
export function SheetField({ label, value, rule, onCommit, disabled = false, readOnly = false, name, inputMode = 'text', unfilled = false }: SheetFieldProps) {
const t = useTranslate();
const id = useId();
// What is typed and not yet taken; null shows the device's value.
const [draft, setDraft] = useState<string | null>(null);
const [refused, setRefused] = useState(false);
const shown = draft ?? value;
const marked = !readOnly && 'invalid' in checkField(rule, shown);
const marked = !readOnly && !(unfilled && draft === null && shown === '') && 'invalid' in checkField(rule, shown);
const commit = () => {
if (draft === null || readOnly) return;
+98
View File
@@ -148,3 +148,101 @@
padding: 0;
border: none;
}
/* Java's tabs (JTabbedPane), as chips that wrap in a narrow sheet. */
.sheet-tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 4px 0 8px;
}
.sheet-tabs button,
.sheet-buttons button {
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--border-default);
border-radius: 8px;
background: var(--surface-raised);
color: var(--text-primary);
font: inherit;
font-size: 14px;
cursor: pointer;
}
.sheet-tabs button[aria-selected='true'] {
border-color: var(--accent);
background: var(--accent-soft);
color: var(--accent-on-soft);
}
.sheet-tabs button:disabled,
.sheet-buttons button:disabled {
color: var(--text-muted);
background: var(--surface-sunken);
cursor: default;
}
.sheet-buttons {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 8px 0;
}
.sheet-connected,
.sheet-note {
margin: 8px 0;
font-size: 14px;
color: var(--text-secondary);
}
.route-table-frame {
overflow-x: auto;
border: 1px solid var(--border-default);
border-radius: 8px;
}
.route-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.route-table th,
.route-table td {
padding: 6px;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid var(--border-subtle);
}
.route-table th {
font-weight: 600;
color: var(--text-secondary);
background: var(--surface-sunken);
}
/* Java greys the rows it makes and lets the manual ones be edited. */
.route-table tbody tr:not([data-manual]):not([data-new]) {
color: var(--text-muted);
}
.route-table tbody tr[data-manual] {
cursor: pointer;
}
.route-table tbody tr[aria-selected='true'] {
background: var(--surface-selected);
}
.route-table td[data-invalid='true'] {
color: var(--danger);
}
/* A manual row is a finger's target. */
.route-table tbody tr[data-manual] td {
padding-top: 13px;
padding-bottom: 13px;
}
+3
View File
@@ -30,6 +30,8 @@
--canvas-bg: #ffffff;
--canvas-cable: rgb(64 64 64);
--canvas-cable-wireless: rgb(192 192 192);
/* Java's active cable (JCablePanel.farbeBlinken): a frame on its way, or the card whose tab is open. */
--canvas-cable-active: rgb(0 255 64);
--canvas-label: #000000;
}
@@ -60,6 +62,7 @@
--canvas-bg: #e9ecf1;
--canvas-cable: rgb(64 64 64);
--canvas-cable-wireless: rgb(150 150 150);
--canvas-cable-active: rgb(0 200 50);
--canvas-label: #000000;
}