Use icon buttons in the circuit list editor

- New IconButton/IconButtonSeparator component based on lucide-react.
- Toolbar: icon plus text, grouped into history, view and insert blocks.
- Group headers: compact icons for add circuit, upstream fuse (VS), group
  RCD (FI), edit, move up/down, renumber and delete; the header no longer
  overflows the grid.
- Rows: add device, delete circuit (trash) and delete device (ListX).
- The previous German labels remain accessible names and tooltips;
  destructive actions highlight in the danger color.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-09-26 10:12:03 +02:00
parent 2e3e85a8e5
commit 5150d4d166
4 changed files with 374 additions and 205 deletions

View file

@ -167,6 +167,13 @@ Keyboard behavior:
Support Ctrl+Plus and Ctrl+Shift+Plus for insertion. 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).
## Bootstrap / Styling Rule ## Bootstrap / Styling Rule
Bootstrap may be used for the general application UI, such as navigation, page layout, buttons, forms, cards, alerts and modals. Bootstrap may be used for the general application UI, such as navigation, page layout, buttons, forms, cards, alerts and modals.

View file

@ -487,6 +487,54 @@ a.kpi:hover {
opacity: 0.45; 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 { .editor-toolbar .project-device-drawer-toggle {
border-color: var(--accent-blue); border-color: var(--accent-blue);
background: var(--accent-blue); background: var(--accent-blue);
@ -1153,7 +1201,20 @@ a.kpi:hover {
.tree-grid .section-actions { .tree-grid .section-actions {
display: flex; 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 { .tree-grid .section-actions button {
@ -1265,7 +1326,7 @@ a.kpi:hover {
.tree-grid .action-cell { .tree-grid .action-cell {
display: flex; display: flex;
gap: 0.3rem; gap: 0.25rem;
} }
.tree-grid .action-cell button { .tree-grid .action-cell button {

View file

@ -1,6 +1,28 @@
"use client"; "use client";
import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"; import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
import {
ArrowDown,
ArrowDown01,
ArrowUp,
Check,
Columns3,
FilterX,
FolderPlus,
ListOrdered,
ListPlus,
ListX,
PackagePlus,
PanelRightClose,
PanelRightOpen,
Pencil,
Plus,
Redo2,
ShieldPlus,
Trash2,
Undo2,
} from "lucide-react";
import { IconButton, IconButtonSeparator } from "./icon-button";
import { import {
inferProjectDeviceSectionKey, inferProjectDeviceSectionKey,
isProjectDevicePlacementValid, isProjectDevicePlacementValid,
@ -2995,30 +3017,35 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return ( return (
<div className="tree-editor-shell"> <div className="tree-editor-shell">
<div className="editor-toolbar"> <div className="editor-toolbar">
<button <IconButton
type="button"
onClick={() => void handleUndo()}
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving} disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
> icon={Undo2}
Rückgängig label="Rückgängig"
</button> onClick={() => void handleUndo()}
<button showLabel
type="button" />
onClick={() => void handleRedo()} <IconButton
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving} disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
> icon={Redo2}
Wiederholen label="Wiederholen"
</button> onClick={() => void handleRedo()}
<button showLabel
type="button" />
onClick={clearSortAndFilters} <IconButtonSeparator />
<IconButton
ariaExpanded={isColumnMenuOpen}
icon={Columns3}
label="Spalten"
onClick={toggleColumnSettingsMenu}
showLabel
/>
<IconButton
disabled={!Boolean(sortState) && !hasActiveFilters} disabled={!Boolean(sortState) && !hasActiveFilters}
> icon={FilterX}
Sortierung/Filter zurücksetzen label="Sortierung/Filter zurücksetzen"
</button> onClick={clearSortAndFilters}
<button type="button" onClick={toggleColumnSettingsMenu} aria-expanded={isColumnMenuOpen}> showLabel
Spalten />
</button>
{renderColumnSettingsMenu("col-empty")} {renderColumnSettingsMenu("col-empty")}
</div> </div>
{renderActiveViewSummary()} {renderActiveViewSummary()}
@ -3140,82 +3167,90 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
/> />
) : null} ) : null}
<div className="editor-toolbar"> <div className="editor-toolbar">
<button <IconButton
type="button"
onClick={() => void handleUndo()}
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving} disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
icon={Undo2}
label="Rückgängig"
onClick={() => void handleUndo()}
showLabel
title={ title={
historyState?.undoDepth historyState?.undoDepth
? `${historyState.undoDepth} Änderung(en) können rückgängig gemacht werden.` ? `${historyState.undoDepth} Änderung(en) können rückgängig gemacht werden.`
: undefined : undefined
} }
> />
Rückgängig <IconButton
</button>
<button
type="button"
onClick={() => void handleRedo()}
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving} disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
icon={Redo2}
label="Wiederholen"
onClick={() => void handleRedo()}
showLabel
title={ title={
historyState?.redoDepth historyState?.redoDepth
? `${historyState.redoDepth} Änderung(en) können wiederholt werden.` ? `${historyState.redoDepth} Änderung(en) können wiederholt werden.`
: undefined : undefined
} }
> />
Wiederholen <IconButtonSeparator />
</button>
{isSortedView ? ( {isSortedView ? (
<button <IconButton
type="button"
onClick={() => void handleApplySortedOrder()}
disabled={hasActiveFilters || isSaving || historyBusy} 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} title={hasActiveFilters ? "Vor dem Übernehmen der Sortierung zuerst die Filter zurücksetzen." : undefined}
> />
Sortierung übernehmen
</button>
) : null} ) : null}
<button type="button" onClick={toggleColumnSettingsMenu} aria-expanded={isColumnMenuOpen}> <IconButton
Spalten ariaExpanded={isColumnMenuOpen}
</button> icon={Columns3}
<button label="Spalten"
aria-controls="project-device-drawer" onClick={toggleColumnSettingsMenu}
aria-expanded={isProjectDeviceDrawerOpen} showLabel
className="project-device-drawer-toggle" />
type="button" <IconButton
onClick={() => disabled={!isSortedView && !hasActiveFilters}
setIsProjectDeviceDrawerOpen((current) => !current) icon={FilterX}
} label="Sortierung/Filter zurücksetzen"
> onClick={clearSortAndFilters}
{isProjectDeviceDrawerOpen showLabel
? "Projektgeräte schließen" />
: `Projektgeräte öffnen (${projectDevices.length})`} <IconButtonSeparator />
</button> <IconButton
<button
type="button"
disabled={isSaving || historyBusy} 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={() => onClick={() =>
setComponentEditorIntent({ setComponentEditorIntent({
kind: "create", kind: "create",
role: "auxiliary", role: "auxiliary",
}) })
} }
> showLabel
Verteilergerät hinzufügen />
</button> <IconButton
<button ariaControls="project-device-drawer"
type="button" ariaExpanded={isProjectDeviceDrawerOpen}
disabled={isSaving || historyBusy} className="project-device-drawer-toggle"
onClick={() => setCircuitGroupEditorIntent({ kind: "create" })} icon={isProjectDeviceDrawerOpen ? PanelRightClose : PanelRightOpen}
> label={
Stromkreisgruppe hinzufügen isProjectDeviceDrawerOpen
</button> ? "Projektgeräte schließen"
<button : `Projektgeräte öffnen (${projectDevices.length})`
type="button" }
onClick={clearSortAndFilters} onClick={() =>
disabled={!isSortedView && !hasActiveFilters} setIsProjectDeviceDrawerOpen((current) => !current)
> }
Sortierung/Filter zurücksetzen showLabel
</button> />
{renderColumnSettingsMenu("col")} {renderColumnSettingsMenu("col")}
</div> </div>
{renderActiveViewSummary()} {renderActiveViewSummary()}
@ -3717,105 +3752,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</span> </span>
</div> </div>
<div className="section-actions"> <div className="section-actions">
<button <IconButton
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}
disabled={isSaving} disabled={isSaving}
title={ icon={Plus}
canDeleteCircuitGroup(section) label="Stromkreis hinzufügen"
? "Leere Stromkreisgruppe entfernen" onClick={() => void handleAddReserveCircuit(section.id)}
: "Befüllte Stromkreisgruppe mit vollständiger Warnung entfernen"
}
onClick={() =>
void handleDeleteCircuitGroup(section)
}
>
Gruppe entfernen
</button>
<button
type="button"
tabIndex={-1} tabIndex={-1}
/>
<IconButton
disabled={ disabled={
hasUpstreamProtection || hasUpstreamProtection ||
!section.category || !section.category ||
!section.groupNumber !section.groupNumber
} }
icon={ShieldPlus}
label="Vorsicherung hinzufügen"
onClick={() => onClick={() =>
setComponentEditorIntent({ setComponentEditorIntent({
kind: "create", kind: "create",
@ -3828,17 +3779,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
), ),
}) })
} }
>
Vorsicherung hinzufügen
</button>
<button
type="button"
tabIndex={-1} tabIndex={-1}
>
<span className="icon-button-text">VS</span>
</IconButton>
<IconButton
disabled={ disabled={
hasGroupRcd || hasGroupRcd ||
!section.category || !section.category ||
!section.groupNumber !section.groupNumber
} }
icon={ShieldPlus}
label="Gruppen-FI hinzufügen"
onClick={() => onClick={() =>
setComponentEditorIntent({ setComponentEditorIntent({
kind: "create", kind: "create",
@ -3851,26 +3803,112 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
), ),
}) })
} }
>
Gruppen-FI hinzufügen
</button>
<button
type="button"
tabIndex={-1} tabIndex={-1}
disabled={isSaving}
onClick={() => void handleAddReserveCircuit(section.id)}
> >
Stromkreis hinzufügen <span className="icon-button-text">FI</span>
</button> </IconButton>
<button <IconButtonSeparator />
type="button" <IconButton
disabled={!section.category || !section.groupNumber}
icon={Pencil}
label="Gruppe bearbeiten"
onClick={() =>
setCircuitGroupEditorIntent({
kind: "edit",
section,
})
}
tabIndex={-1} 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} disabled={hasActiveSortOrFilter || isSaving}
icon={ArrowDown01}
label="Bereich neu nummerieren"
onClick={() => void handleRenumberSection(section.id)} onClick={() => void handleRenumberSection(section.id)}
title={hasActiveSortOrFilter ? "Vor der Neunummerierung Sortierung und Filter zurücksetzen." : undefined} tabIndex={-1}
> title={
Bereich neu nummerieren hasActiveSortOrFilter
</button> ? "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>
</div> </div>
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? ( {dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? (
@ -4467,28 +4505,32 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
> >
{row.circuit && row.rowType !== "deviceRow" ? ( {row.circuit && row.rowType !== "deviceRow" ? (
<> <>
<button <IconButton
type="button"
tabIndex={-1}
disabled={isSaving} disabled={isSaving}
icon={ListPlus}
label="Manuelles Gerät hinzufügen"
onClick={() => void handleAddManualDevice(row.circuit!, row.sectionId)} onClick={() => void handleAddManualDevice(row.circuit!, row.sectionId)}
>
Manuelles Gerät hinzufügen
</button>
<button
type="button"
tabIndex={-1} tabIndex={-1}
/>
<IconButton
disabled={isSaving} disabled={isSaving}
icon={Trash2}
label="Stromkreis löschen"
onClick={() => void handleDeleteCircuit(row.circuit!.id)} onClick={() => void handleDeleteCircuit(row.circuit!.id)}
> tabIndex={-1}
Stromkreis löschen variant="danger"
</button> />
</> </>
) : null} ) : null}
{row.device ? ( {row.device ? (
<button type="button" tabIndex={-1} disabled={isSaving} onClick={() => void handleDeleteDevice(row.device!.id)}> <IconButton
Gerät löschen disabled={isSaving}
</button> icon={ListX}
label="Gerät löschen"
onClick={() => void handleDeleteDevice(row.device!.id)}
tabIndex={-1}
variant="danger"
/>
) : null} ) : null}
{dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? ( {dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? (
<span className="drop-hint"> <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" />;
}