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. 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 / 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

@ -127,11 +127,30 @@ Cross-section device moves show confirmation-required feedback before drop. The
## Filtering and Sorting ## 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. - Per-column filtering works on normalized displayed values.
- Sorting is view-level first and treats circuits as blocks. - Sorting is view-level first and treats circuits as blocks.
- Multi-device circuits are not split during sort. - Multi-device circuits are not split during sort.
- Sorting alone does not persist order. - 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
`Apply sorted order` persists current sorted block order to backend by section. `Apply sorted order` persists current sorted block order to backend by section.

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);
@ -844,24 +892,37 @@ a.kpi:hover {
white-space: nowrap; white-space: nowrap;
} }
/* One header row: label (sort) on the left, filter icon on the right. */
.tree-grid .header-cell { .tree-grid .header-cell {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: stretch; align-items: flex-start;
justify-content: space-between;
gap: 0.2rem; gap: 0.2rem;
min-width: 0; min-width: 0;
} }
.tree-grid .header-filter-btn { .tree-grid .header-filter-btn {
display: block; display: inline-flex;
width: 100%; flex: none;
border: 1px solid var(--panel-border-strong); align-items: center;
background: var(--panel-bg); gap: 0.15rem;
color: var(--text-strong); border: 1px solid transparent;
background: transparent;
color: var(--text-faint);
border-radius: 3px; border-radius: 3px;
font-size: 0.75rem; font-size: 0.7rem;
padding: 0.15rem 0.35rem; font-weight: 700;
line-height: 1;
padding: 0.15rem 0.2rem;
white-space: nowrap; 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 { .tree-grid .header-sort-btn {
@ -879,7 +940,10 @@ a.kpi:hover {
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 600; font-weight: 600;
line-height: 1.15; 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; white-space: normal;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
@ -919,6 +983,18 @@ a.kpi:hover {
font-size: 0.78rem; 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:hover span:first-child,
.tree-grid .header-sort-btn:focus-visible span:first-child { .tree-grid .header-sort-btn:focus-visible span:first-child {
text-decoration: underline; text-decoration: underline;
@ -935,6 +1011,11 @@ a.kpi:hover {
background: var(--accent-blue-soft-bg); background: var(--accent-blue-soft-bg);
} }
.tree-grid .header-filter-count {
min-width: 0.7rem;
text-align: center;
}
.tree-grid .header-filter-menu { .tree-grid .header-filter-menu {
position: absolute; position: absolute;
z-index: 7; z-index: 7;
@ -1153,7 +1234,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 +1359,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,29 @@
"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,
Funnel,
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,
@ -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 }) { export function CircuitTreeEditor(props: { projectId: string; circuitListId: string }) {
/* /*
Circuit-tree editor invariant overview: Circuit-tree editor invariant overview:
@ -2996,30 +3037,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()}
@ -3141,82 +3187,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()}
@ -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 ? ( {sortState?.key === column.key ? (
<span className="sort-indicator" aria-label={sortState.direction === "asc" ? "aufsteigend" : "absteigend"}> <span className="sort-indicator" aria-label={sortState.direction === "asc" ? "aufsteigend" : "absteigend"}>
{sortState.direction === "asc" ? "↑" : "↓"} {sortState.direction === "asc" ? "↑" : "↓"}
@ -3417,6 +3473,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
type="button" type="button"
className={`header-filter-btn ${(columnFilters[column.key]?.length ?? 0) > 0 ? "active" : ""}`} className={`header-filter-btn ${(columnFilters[column.key]?.length ?? 0) > 0 ? "active" : ""}`}
onClick={() => toggleColumnFilterMenu(column.key)} onClick={() => toggleColumnFilterMenu(column.key)}
aria-label={`${getFullColumnLabel(column)} filtern`}
aria-pressed={(columnFilters[column.key]?.length ?? 0) > 0} aria-pressed={(columnFilters[column.key]?.length ?? 0) > 0}
title={ title={
(columnFilters[column.key]?.length ?? 0) > 0 (columnFilters[column.key]?.length ?? 0) > 0
@ -3424,7 +3481,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
: `${getFullColumnLabel(column)} filtern` : `${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> </button>
</div> </div>
{openFilterColumn === column.key ? ( {openFilterColumn === column.key ? (
@ -3718,105 +3778,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",
@ -3829,17 +3805,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",
@ -3852,26 +3829,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 ? (
@ -4468,28 +4531,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" />;
}