diff --git a/AGENTS.md b/AGENTS.md index 0e252dd..74a4528 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/circuit-list-editor-interactions.md b/docs/circuit-list-editor-interactions.md index 01ca537..533dfc7 100644 --- a/docs/circuit-list-editor-interactions.md +++ b/docs/circuit-list-editor-interactions.md @@ -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. diff --git a/src/app/globals.css b/src/app/globals.css index 22da5df..fe81d07 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 { diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 8dc993e..4023fe6 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -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} + {match[2]} + + ) : ( + 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 (
- - - - - {renderColumnSettingsMenu("col-empty")} -
+ icon={Redo2} + label="Wiederholen" + onClick={() => void handleRedo()} + showLabel + /> + + + + {renderColumnSettingsMenu("col-empty")} +
{renderActiveViewSummary()} {renderDistributionPowerSummary()} {error ? ( @@ -3141,82 +3187,90 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str /> ) : null}
- - + /> + {isSortedView ? ( - + /> ) : null} - - - - - + showLabel + /> + + setIsProjectDeviceDrawerOpen((current) => !current) + } + showLabel + /> {renderColumnSettingsMenu("col")}
{renderActiveViewSummary()} @@ -3406,7 +3460,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); }} > - {column.label} + + {renderColumnHeaderLabel(column.label)} + {sortState?.key === column.key ? ( {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})` : ""} +
- - - - - - - - - + tabIndex={-1} + title={ + hasActiveSortOrFilter + ? "Vor der Neunummerierung Sortierung und Filter zurücksetzen." + : "Stromkreise dieser Gruppe neu nummerieren" + } + /> + + 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" + } + /> + + + void handleDeleteCircuitGroup(section) + } + tabIndex={-1} + title={ + canDeleteCircuitGroup(section) + ? "Leere Stromkreisgruppe entfernen" + : "Befüllte Stromkreisgruppe entfernen (mit Warnung)" + } + variant="danger" + />
{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" ? ( <> - - + tabIndex={-1} + variant="danger" + /> ) : null} {row.device ? ( - + void handleDeleteDevice(row.device!.id)} + tabIndex={-1} + variant="danger" + /> ) : null} {dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? ( diff --git a/src/frontend/components/icon-button.tsx b/src/frontend/components/icon-button.tsx new file mode 100644 index 0000000..605036d --- /dev/null +++ b/src/frontend/components/icon-button.tsx @@ -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 ( + + ); +} + +/** Thin vertical divider between groups of icon buttons. */ +export function IconButtonSeparator() { + return