Integrate compact editor actions and fix header units

This commit is contained in:
Julian Appel 2026-09-26 21:07:36 +02:00
commit 203757b92c
5 changed files with 467 additions and 217 deletions

View file

@ -167,6 +167,17 @@ Keyboard behavior:
Support Ctrl+Plus and Ctrl+Shift+Plus for insertion.
Editor actions use `IconButton` (`src/frontend/components/icon-button.tsx`,
`lucide-react` icons). The German action text is always the accessible name and
tooltip; the toolbar shows icon plus text, group headers and rows show icons
only, separated into logical blocks with `IconButtonSeparator`. Destructive
actions use `variant="danger"` (trash icon for circuits and groups, `ListX` for
removing a device row).
Column headers show the label (click to sort) with a funnel icon for the
value filter; an active filter is highlighted and shows its value count.
Header labels break only between words and put a trailing unit such as
`[kW]` on a second line.
## Bootstrap / Styling Rule
Bootstrap may be used for the general application UI, such as navigation, page layout, buttons, forms, cards, alerts and modals.

View file

@ -127,11 +127,30 @@ Cross-section device moves show confirmation-required feedback before drop. The
## Filtering and Sorting
- Clicking a column label changes sorting; the funnel icon next to it opens
the value filter. An active filter is highlighted and shows the number of
selected values. Both controls retain German accessible names and tooltips.
- Header labels wrap between words; trailing units such as `[kW]` appear on
a separate line.
- Per-column filtering works on normalized displayed values.
- Sorting is view-level first and treats circuits as blocks.
- Multi-device circuits are not split during sort.
- Sorting alone does not persist order.
## Action Buttons
The editor toolbar uses icons with visible German labels. Group headers and
circuit/device rows use compact icon buttons with German accessible names and
tooltips. Group protection buttons additionally show `VS` or `FI` to distinguish
their roles. Dividers separate related actions; destructive actions use the
danger style. Circuit deletion uses a trash icon, while device-row deletion
uses a crossed-out list icon.
The shared `src/frontend/components/icon-button.tsx` component preserves
disabled states and expanded states for toggles. Row and group action buttons
retain `tabIndex={-1}` so the spreadsheet's existing cell navigation remains
unchanged. The existing command handlers and deletion confirmations are reused.
## Apply Sorted Order
`Apply sorted order` persists current sorted block order to backend by section.

View file

@ -487,6 +487,54 @@ a.kpi:hover {
opacity: 0.45;
}
/* Icon buttons (circuit list editor toolbar, group and row actions) */
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
line-height: 1.2;
cursor: pointer;
transition: color 0.12s ease, border-color 0.12s ease, background-color 0.12s ease;
}
.icon-button svg {
flex: none;
}
.icon-button:disabled {
cursor: not-allowed;
}
.icon-button:not(:disabled):hover {
border-color: var(--accent-blue);
color: var(--accent-blue);
}
.icon-button-danger:not(:disabled):hover {
border-color: var(--color-danger);
background: var(--color-danger-bg);
color: var(--color-danger);
}
.editor-toolbar .project-device-drawer-toggle:not(:disabled):hover {
color: #fff;
filter: brightness(1.08);
}
.icon-button-text {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.icon-button-separator {
align-self: stretch;
width: 1px;
margin: 0.1rem 0.15rem;
background: var(--panel-border-strong);
}
.editor-toolbar .project-device-drawer-toggle {
border-color: var(--accent-blue);
background: var(--accent-blue);
@ -844,24 +892,37 @@ a.kpi:hover {
white-space: nowrap;
}
/* One header row: label (sort) on the left, filter icon on the right. */
.tree-grid .header-cell {
display: flex;
flex-direction: column;
align-items: stretch;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 0.2rem;
min-width: 0;
}
.tree-grid .header-filter-btn {
display: block;
width: 100%;
border: 1px solid var(--panel-border-strong);
background: var(--panel-bg);
color: var(--text-strong);
display: inline-flex;
flex: none;
align-items: center;
gap: 0.15rem;
border: 1px solid transparent;
background: transparent;
color: var(--text-faint);
border-radius: 3px;
font-size: 0.75rem;
padding: 0.15rem 0.35rem;
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
padding: 0.15rem 0.2rem;
white-space: nowrap;
cursor: pointer;
}
.tree-grid .header-filter-btn:hover,
.tree-grid .header-filter-btn:focus-visible {
border-color: var(--panel-border-strong);
color: var(--text-strong);
}
.tree-grid .header-sort-btn {
@ -879,7 +940,10 @@ a.kpi:hover {
font-size: 0.78rem;
font-weight: 600;
line-height: 1.15;
overflow-wrap: anywhere;
/* Break only between words; never inside a word such as "Bemerkung". */
overflow-wrap: normal;
word-break: normal;
hyphens: manual;
white-space: normal;
text-align: left;
cursor: pointer;
@ -919,6 +983,18 @@ a.kpi:hover {
font-size: 0.78rem;
}
.tree-grid .header-cell .header-sort-btn {
flex: 1 1 auto;
width: auto;
align-items: flex-start;
}
.tree-grid .header-unit {
display: block;
font-weight: 500;
color: var(--text-faint);
}
.tree-grid .header-sort-btn:hover span:first-child,
.tree-grid .header-sort-btn:focus-visible span:first-child {
text-decoration: underline;
@ -935,6 +1011,11 @@ a.kpi:hover {
background: var(--accent-blue-soft-bg);
}
.tree-grid .header-filter-count {
min-width: 0.7rem;
text-align: center;
}
.tree-grid .header-filter-menu {
position: absolute;
z-index: 7;
@ -1153,7 +1234,20 @@ a.kpi:hover {
.tree-grid .section-actions {
display: flex;
gap: 0.35rem;
align-items: center;
gap: 0.25rem;
}
.tree-grid .section-actions .icon-button,
.tree-grid .action-cell .icon-button {
min-width: 1.75rem;
min-height: 1.6rem;
padding: 0.15rem 0.3rem;
}
.tree-grid .section-actions .icon-button:disabled,
.tree-grid .action-cell .icon-button:disabled {
opacity: 0.35;
}
.tree-grid .section-actions button {
@ -1265,7 +1359,7 @@ a.kpi:hover {
.tree-grid .action-cell {
display: flex;
gap: 0.3rem;
gap: 0.25rem;
}
.tree-grid .action-cell button {

View file

@ -1,6 +1,29 @@
"use client";
import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
import {
ArrowDown,
ArrowDown01,
ArrowUp,
Check,
Columns3,
FilterX,
FolderPlus,
Funnel,
ListOrdered,
ListPlus,
ListX,
PackagePlus,
PanelRightClose,
PanelRightOpen,
Pencil,
Plus,
Redo2,
ShieldPlus,
Trash2,
Undo2,
} from "lucide-react";
import { IconButton, IconButtonSeparator } from "./icon-button";
import {
inferProjectDeviceSectionKey,
isProjectDevicePlacementValid,
@ -247,6 +270,24 @@ function createValuesFromEditPatch(
);
}
/**
* Header labels break only between words: hyphens become non-breaking and a
* trailing unit such as "[kW]" is always shown on its own second line, so
* header heights stay uniform.
*/
function renderColumnHeaderLabel(label: string) {
const match = /^(.*?)\s*(\[[^\]]+\])$/.exec(label);
const name = (match ? match[1] : label).replace(/-/g, "‑");
return match ? (
<>
{name}
<span className="header-unit">{match[2]}</span>
</>
) : (
name
);
}
export function CircuitTreeEditor(props: { projectId: string; circuitListId: string }) {
/*
Circuit-tree editor invariant overview:
@ -2996,32 +3037,37 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return (
<div className="tree-editor-shell">
<div className="editor-toolbar">
<button
type="button"
onClick={() => void handleUndo()}
<IconButton
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
>
Rückgängig
</button>
<button
type="button"
onClick={() => void handleRedo()}
icon={Undo2}
label="Rückgängig"
onClick={() => void handleUndo()}
showLabel
/>
<IconButton
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
>
Wiederholen
</button>
<button
type="button"
onClick={clearSortAndFilters}
disabled={!Boolean(sortState) && !hasActiveFilters}
>
Sortierung/Filter zurücksetzen
</button>
<button type="button" onClick={toggleColumnSettingsMenu} aria-expanded={isColumnMenuOpen}>
Spalten
</button>
{renderColumnSettingsMenu("col-empty")}
</div>
icon={Redo2}
label="Wiederholen"
onClick={() => void handleRedo()}
showLabel
/>
<IconButtonSeparator />
<IconButton
ariaExpanded={isColumnMenuOpen}
icon={Columns3}
label="Spalten"
onClick={toggleColumnSettingsMenu}
showLabel
/>
<IconButton
disabled={!Boolean(sortState) && !hasActiveFilters}
icon={FilterX}
label="Sortierung/Filter zurücksetzen"
onClick={clearSortAndFilters}
showLabel
/>
{renderColumnSettingsMenu("col-empty")}
</div>
{renderActiveViewSummary()}
{renderDistributionPowerSummary()}
{error ? (
@ -3141,82 +3187,90 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
/>
) : null}
<div className="editor-toolbar">
<button
type="button"
onClick={() => void handleUndo()}
<IconButton
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
icon={Undo2}
label="Rückgängig"
onClick={() => void handleUndo()}
showLabel
title={
historyState?.undoDepth
? `${historyState.undoDepth} Änderung(en) können rückgängig gemacht werden.`
: undefined
}
>
Rückgängig
</button>
<button
type="button"
onClick={() => void handleRedo()}
/>
<IconButton
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
icon={Redo2}
label="Wiederholen"
onClick={() => void handleRedo()}
showLabel
title={
historyState?.redoDepth
? `${historyState.redoDepth} Änderung(en) können wiederholt werden.`
: undefined
}
>
Wiederholen
</button>
/>
<IconButtonSeparator />
{isSortedView ? (
<button
type="button"
onClick={() => void handleApplySortedOrder()}
<IconButton
disabled={hasActiveFilters || isSaving || historyBusy}
icon={Check}
label="Sortierung übernehmen"
onClick={() => void handleApplySortedOrder()}
showLabel
title={hasActiveFilters ? "Vor dem Übernehmen der Sortierung zuerst die Filter zurücksetzen." : undefined}
>
Sortierung übernehmen
</button>
/>
) : null}
<button type="button" onClick={toggleColumnSettingsMenu} aria-expanded={isColumnMenuOpen}>
Spalten
</button>
<button
aria-controls="project-device-drawer"
aria-expanded={isProjectDeviceDrawerOpen}
className="project-device-drawer-toggle"
type="button"
onClick={() =>
setIsProjectDeviceDrawerOpen((current) => !current)
}
>
{isProjectDeviceDrawerOpen
? "Projektgeräte schließen"
: `Projektgeräte öffnen (${projectDevices.length})`}
</button>
<button
type="button"
<IconButton
ariaExpanded={isColumnMenuOpen}
icon={Columns3}
label="Spalten"
onClick={toggleColumnSettingsMenu}
showLabel
/>
<IconButton
disabled={!isSortedView && !hasActiveFilters}
icon={FilterX}
label="Sortierung/Filter zurücksetzen"
onClick={clearSortAndFilters}
showLabel
/>
<IconButtonSeparator />
<IconButton
disabled={isSaving || historyBusy}
icon={FolderPlus}
label="Stromkreisgruppe hinzufügen"
onClick={() => setCircuitGroupEditorIntent({ kind: "create" })}
showLabel
/>
<IconButton
disabled={isSaving || historyBusy}
icon={PackagePlus}
label="Verteilergerät hinzufügen"
onClick={() =>
setComponentEditorIntent({
kind: "create",
role: "auxiliary",
})
}
>
Verteilergerät hinzufügen
</button>
<button
type="button"
disabled={isSaving || historyBusy}
onClick={() => setCircuitGroupEditorIntent({ kind: "create" })}
>
Stromkreisgruppe hinzufügen
</button>
<button
type="button"
onClick={clearSortAndFilters}
disabled={!isSortedView && !hasActiveFilters}
>
Sortierung/Filter zurücksetzen
</button>
showLabel
/>
<IconButton
ariaControls="project-device-drawer"
ariaExpanded={isProjectDeviceDrawerOpen}
className="project-device-drawer-toggle"
icon={isProjectDeviceDrawerOpen ? PanelRightClose : PanelRightOpen}
label={
isProjectDeviceDrawerOpen
? "Projektgeräte schließen"
: `Projektgeräte öffnen (${projectDevices.length})`
}
onClick={() =>
setIsProjectDeviceDrawerOpen((current) => !current)
}
showLabel
/>
{renderColumnSettingsMenu("col")}
</div>
{renderActiveViewSummary()}
@ -3406,7 +3460,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
});
}}
>
<span>{column.label}</span>
<span className="header-label">
{renderColumnHeaderLabel(column.label)}
</span>
{sortState?.key === column.key ? (
<span className="sort-indicator" aria-label={sortState.direction === "asc" ? "aufsteigend" : "absteigend"}>
{sortState.direction === "asc" ? "↑" : "↓"}
@ -3417,6 +3473,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
type="button"
className={`header-filter-btn ${(columnFilters[column.key]?.length ?? 0) > 0 ? "active" : ""}`}
onClick={() => toggleColumnFilterMenu(column.key)}
aria-label={`${getFullColumnLabel(column)} filtern`}
aria-pressed={(columnFilters[column.key]?.length ?? 0) > 0}
title={
(columnFilters[column.key]?.length ?? 0) > 0
@ -3424,7 +3481,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
: `${getFullColumnLabel(column)} filtern`
}
>
Filtern{(columnFilters[column.key]?.length ?? 0) > 0 ? ` (${columnFilters[column.key]?.length})` : ""}
<Funnel aria-hidden="true" size={13} strokeWidth={2.2} />
{(columnFilters[column.key]?.length ?? 0) > 0 ? (
<span className="header-filter-count">{columnFilters[column.key]?.length}</span>
) : null}
</button>
</div>
{openFilterColumn === column.key ? (
@ -3718,105 +3778,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</span>
</div>
<div className="section-actions">
<button
type="button"
tabIndex={-1}
disabled={!section.category || !section.groupNumber}
onClick={() =>
setCircuitGroupEditorIntent({
kind: "edit",
section,
})
}
>
Gruppe bearbeiten
</button>
<button
type="button"
tabIndex={-1}
disabled={
isSaving ||
!buildCircuitGroupReorderAssignments(
data.sections,
section.id,
-1
)
}
onClick={() =>
void handleMoveCircuitGroup(section.id, -1)
}
title="Innerhalb derselben Kategorie nach oben verschieben"
>
Gruppe nach oben
</button>
<button
type="button"
tabIndex={-1}
disabled={
isSaving ||
!buildCircuitGroupReorderAssignments(
data.sections,
section.id,
1
)
}
onClick={() =>
void handleMoveCircuitGroup(section.id, 1)
}
title="Innerhalb derselben Kategorie nach unten verschieben"
>
Gruppe nach unten
</button>
<button
type="button"
tabIndex={-1}
disabled={
isSaving ||
hasActiveSortOrFilter ||
!section.category ||
!canRenumberCircuitGroups(
data.sections,
section.category
)
}
onClick={() =>
section.category
? void handleRenumberCircuitGroups(
section.category
)
: undefined
}
title={
hasActiveSortOrFilter
? "Vor der Gruppenneunummerierung Sortierung und Filter zurücksetzen."
: "Alle Gruppen dieser Kategorie gemäß ihrer Reihenfolge neu nummerieren"
}
>
Gruppen-BMK neu nummerieren
</button>
<button
type="button"
tabIndex={-1}
<IconButton
disabled={isSaving}
title={
canDeleteCircuitGroup(section)
? "Leere Stromkreisgruppe entfernen"
: "Befüllte Stromkreisgruppe mit vollständiger Warnung entfernen"
}
onClick={() =>
void handleDeleteCircuitGroup(section)
}
>
Gruppe entfernen
</button>
<button
type="button"
icon={Plus}
label="Stromkreis hinzufügen"
onClick={() => void handleAddReserveCircuit(section.id)}
tabIndex={-1}
/>
<IconButton
disabled={
hasUpstreamProtection ||
!section.category ||
!section.groupNumber
}
icon={ShieldPlus}
label="Vorsicherung hinzufügen"
onClick={() =>
setComponentEditorIntent({
kind: "create",
@ -3829,17 +3805,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
),
})
}
>
Vorsicherung hinzufügen
</button>
<button
type="button"
tabIndex={-1}
>
<span className="icon-button-text">VS</span>
</IconButton>
<IconButton
disabled={
hasGroupRcd ||
!section.category ||
!section.groupNumber
}
icon={ShieldPlus}
label="Gruppen-FI hinzufügen"
onClick={() =>
setComponentEditorIntent({
kind: "create",
@ -3852,26 +3829,112 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
),
})
}
>
Gruppen-FI hinzufügen
</button>
<button
type="button"
tabIndex={-1}
disabled={isSaving}
onClick={() => void handleAddReserveCircuit(section.id)}
>
Stromkreis hinzufügen
</button>
<button
type="button"
<span className="icon-button-text">FI</span>
</IconButton>
<IconButtonSeparator />
<IconButton
disabled={!section.category || !section.groupNumber}
icon={Pencil}
label="Gruppe bearbeiten"
onClick={() =>
setCircuitGroupEditorIntent({
kind: "edit",
section,
})
}
tabIndex={-1}
/>
<IconButton
disabled={
isSaving ||
!buildCircuitGroupReorderAssignments(
data.sections,
section.id,
-1
)
}
icon={ArrowUp}
label="Gruppe nach oben"
onClick={() =>
void handleMoveCircuitGroup(section.id, -1)
}
tabIndex={-1}
title="Gruppe nach oben (innerhalb derselben Kategorie)"
/>
<IconButton
disabled={
isSaving ||
!buildCircuitGroupReorderAssignments(
data.sections,
section.id,
1
)
}
icon={ArrowDown}
label="Gruppe nach unten"
onClick={() =>
void handleMoveCircuitGroup(section.id, 1)
}
tabIndex={-1}
title="Gruppe nach unten (innerhalb derselben Kategorie)"
/>
<IconButtonSeparator />
<IconButton
disabled={hasActiveSortOrFilter || isSaving}
icon={ArrowDown01}
label="Bereich neu nummerieren"
onClick={() => void handleRenumberSection(section.id)}
title={hasActiveSortOrFilter ? "Vor der Neunummerierung Sortierung und Filter zurücksetzen." : undefined}
>
Bereich neu nummerieren
</button>
tabIndex={-1}
title={
hasActiveSortOrFilter
? "Vor der Neunummerierung Sortierung und Filter zurücksetzen."
: "Stromkreise dieser Gruppe neu nummerieren"
}
/>
<IconButton
disabled={
isSaving ||
hasActiveSortOrFilter ||
!section.category ||
!canRenumberCircuitGroups(
data.sections,
section.category
)
}
icon={ListOrdered}
label="Gruppen-BMK neu nummerieren"
onClick={() =>
section.category
? void handleRenumberCircuitGroups(
section.category
)
: undefined
}
tabIndex={-1}
title={
hasActiveSortOrFilter
? "Vor der Gruppenneunummerierung Sortierung und Filter zurücksetzen."
: "Gruppen-BMK: alle Gruppen dieser Kategorie gemäß ihrer Reihenfolge neu nummerieren"
}
/>
<IconButtonSeparator />
<IconButton
disabled={isSaving}
icon={Trash2}
label="Gruppe entfernen"
onClick={() =>
void handleDeleteCircuitGroup(section)
}
tabIndex={-1}
title={
canDeleteCircuitGroup(section)
? "Leere Stromkreisgruppe entfernen"
: "Befüllte Stromkreisgruppe entfernen (mit Warnung)"
}
variant="danger"
/>
</div>
</div>
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? (
@ -4468,28 +4531,32 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
>
{row.circuit && row.rowType !== "deviceRow" ? (
<>
<button
type="button"
tabIndex={-1}
<IconButton
disabled={isSaving}
icon={ListPlus}
label="Manuelles Gerät hinzufügen"
onClick={() => void handleAddManualDevice(row.circuit!, row.sectionId)}
>
Manuelles Gerät hinzufügen
</button>
<button
type="button"
tabIndex={-1}
/>
<IconButton
disabled={isSaving}
icon={Trash2}
label="Stromkreis löschen"
onClick={() => void handleDeleteCircuit(row.circuit!.id)}
>
Stromkreis löschen
</button>
tabIndex={-1}
variant="danger"
/>
</>
) : null}
{row.device ? (
<button type="button" tabIndex={-1} disabled={isSaving} onClick={() => void handleDeleteDevice(row.device!.id)}>
Gerät löschen
</button>
<IconButton
disabled={isSaving}
icon={ListX}
label="Gerät löschen"
onClick={() => void handleDeleteDevice(row.device!.id)}
tabIndex={-1}
variant="danger"
/>
) : null}
{dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? (
<span className="drop-hint">

View file

@ -0,0 +1,59 @@
"use client";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
/**
* Compact editor button with a lucide icon. `label` is always the accessible
* name; it is also the tooltip unless a more specific `title` is given.
* With `showLabel` the label (or `children`) is rendered next to the icon.
*/
export function IconButton({
icon: Icon,
label,
title,
onClick,
disabled,
variant = "default",
showLabel = false,
children,
className,
tabIndex,
ariaExpanded,
ariaControls,
}: {
icon: LucideIcon;
label: string;
title?: string;
onClick: () => void;
disabled?: boolean;
variant?: "default" | "danger" | "primary";
showLabel?: boolean;
children?: ReactNode;
className?: string;
tabIndex?: number;
ariaExpanded?: boolean;
ariaControls?: string;
}) {
return (
<button
aria-controls={ariaControls}
aria-expanded={ariaExpanded}
aria-label={showLabel ? undefined : label}
className={`icon-button icon-button-${variant}${className ? ` ${className}` : ""}`}
disabled={disabled}
onClick={onClick}
tabIndex={tabIndex}
title={title ?? label}
type="button"
>
<Icon aria-hidden="true" size={15} strokeWidth={2} />
{showLabel ? <span>{children ?? label}</span> : children}
</button>
);
}
/** Thin vertical divider between groups of icon buttons. */
export function IconButtonSeparator() {
return <span aria-hidden="true" className="icon-button-separator" />;
}