From 0fd05f638f855172b17ee7e6405aaf50967a5eb4 Mon Sep 17 00:00:00 2001
From: Ida
Date: Thu, 14 May 2026 12:08:05 +0200
Subject: [PATCH] feat: seperated accordion list component to be seperate and
reusable ui component
---
.../editor/Automation2FlowEditor.module.css | 4 +-
.../SchedulePlanner.module.css | 111 +----------------
.../SchedulePlanner/SchedulePlanner.tsx | 60 +++------
.../AccordionList/AccordionList.module.css | 114 ++++++++++++++++++
.../AccordionList/AccordionList.tsx | 98 +++++++++++++++
.../UiComponents/AccordionList/index.ts | 2 +
src/components/UiComponents/index.ts | 1 +
7 files changed, 237 insertions(+), 153 deletions(-)
create mode 100644 src/components/UiComponents/AccordionList/AccordionList.module.css
create mode 100644 src/components/UiComponents/AccordionList/AccordionList.tsx
create mode 100644 src/components/UiComponents/AccordionList/index.ts
diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.module.css b/src/components/FlowEditor/editor/Automation2FlowEditor.module.css
index 368ddf2..81fa85c 100644
--- a/src/components/FlowEditor/editor/Automation2FlowEditor.module.css
+++ b/src/components/FlowEditor/editor/Automation2FlowEditor.module.css
@@ -1094,7 +1094,9 @@
/* Kein Primär-Button-Stil für Zeitplan-Karten / Wochentage / Monat-Jahr-Chips
(DataPicker-Dialog wird per createPortal an document.body gehangen — nicht hier). */
.nodeConfigPanel
- button:not(.scheduleModeCard):not(.scheduleDayOn):not(.scheduleDayOff):not(.scheduleSubModeBtn) {
+ button:not(.scheduleModeCard):not(.scheduleDayOn):not(.scheduleDayOff):not(.scheduleSubModeBtn):not(
+ [data-accordion-header]
+ ):not([data-schedule-day]) {
margin-top: 0.5rem;
padding: 0.4rem 0.75rem;
font-size: 0.8rem;
diff --git a/src/components/SchedulePlanner/SchedulePlanner.module.css b/src/components/SchedulePlanner/SchedulePlanner.module.css
index 375feae..4e48e3f 100644
--- a/src/components/SchedulePlanner/SchedulePlanner.module.css
+++ b/src/components/SchedulePlanner/SchedulePlanner.module.css
@@ -1,5 +1,5 @@
/**
- * Schedule planner accordion — tokens aligned with FlowEditor / app theme.
+ * Schedule planner — layout + Felder; Akkordeon: UiComponents/AccordionList.
*/
.wrap {
max-width: 560px;
@@ -14,115 +14,6 @@
color: var(--text-secondary, #666);
}
-.item {
- border: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
- border-radius: var(--radius-md, 8px);
- margin-bottom: 6px;
- overflow: hidden;
- background: var(--bg-primary, #fff);
-}
-
-/** Gespeicherte / gültige Option — nur Dezent, Indikator ist der Punkt links */
-.itemSelected {
- border-color: var(--border-color, rgba(0, 0, 0, 0.14));
-}
-
-.header {
- display: flex;
- align-items: center;
- gap: 8px;
- width: 100%;
- padding: 8px 12px;
- margin: 0;
- border: none;
- cursor: pointer;
- user-select: none;
- text-align: left;
- background: transparent;
- color: inherit;
- font: inherit;
- outline: none;
-}
-
-
-
-.dotRail {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 14px;
- flex-shrink: 0;
-}
-
-.modeDot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: transparent;
- box-shadow: inset 0 0 0 1px var(--border-color, rgba(0, 0, 0, 0.22));
- transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
-}
-
-.modeDotOn {
- background: var(--text-primary, #1a1a1a);
- box-shadow: none;
-}
-
-.title {
- flex: 1;
- min-width: 0;
- font-size: 14px;
- font-weight: 500;
- color: var(--text-primary, #1a1a1a);
-}
-
-.chevron {
- flex-shrink: 0;
- margin-left: auto;
- width: 0;
- height: 0;
- border-left: 5px solid transparent;
- border-right: 5px solid transparent;
- border-top: 6px solid var(--text-tertiary, #888);
- transition: transform 0.2s ease;
- transform-origin: 50% 40%;
-}
-
-.itemExpanded .chevron {
- transform: rotate(180deg);
-}
-
-/*
- * Höhe: 0fr → 1fr interpoliert gegen die echte Inhaltshöhe (kein max-height-Ruckeln).
- * @see https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template-rows#interpolation
- */
-.bodyCollapse {
- display: grid;
- grid-template-rows: 0fr;
- transition: grid-template-rows 0.36s cubic-bezier(0.33, 1, 0.68, 1);
- pointer-events: none;
-}
-
-.bodyCollapseOpen {
- grid-template-rows: 1fr;
- pointer-events: auto;
-}
-
-@media (prefers-reduced-motion: reduce) {
- .bodyCollapse {
- transition: none;
- }
-}
-
-.bodyInner {
- min-height: 0;
- overflow: hidden;
-}
-
-.bodyPad {
- padding: 0 12px 12px;
-}
-
.fields {
display: grid;
gap: 10px;
diff --git a/src/components/SchedulePlanner/SchedulePlanner.tsx b/src/components/SchedulePlanner/SchedulePlanner.tsx
index ac0ae13..1f475ac 100644
--- a/src/components/SchedulePlanner/SchedulePlanner.tsx
+++ b/src/components/SchedulePlanner/SchedulePlanner.tsx
@@ -9,6 +9,7 @@ import {
type ScheduleSpec,
} from '../../utils/scheduleCron';
import { useLanguage } from '../../providers/language/LanguageContext';
+import { AccordionList } from '../UiComponents';
import styles from './SchedulePlanner.module.css';
export type PlannerModeId = 'minutes' | 'hours' | 'days' | 'weeks' | 'months' | 'custom';
@@ -122,11 +123,10 @@ export const SchedulePlanner: React.FC = ({
[disabled, onChange]
);
- const toggle = (id: PlannerModeId) => {
- const nextOpen: PlannerModeId | null = openId === id ? null : id;
- setOpenId(nextOpen);
- if (nextOpen != null) {
- push(withPlannerMode(value, nextOpen));
+ const handleOpenChange = (next: PlannerModeId | null) => {
+ setOpenId(next);
+ if (next != null) {
+ push(withPlannerMode(value, next));
}
};
@@ -309,6 +309,7 @@ export const SchedulePlanner: React.FC = ({
{
const next = { ...v, mode: 'weeks' as const };
@@ -508,43 +509,18 @@ export const SchedulePlanner: React.FC = ({
'Legen Sie fest, wann dieser Workflow automatisch laufen soll. Der Cron-Ausdruck wird für die API erzeugt.'
)}
-
- {PLANNER_MODE_IDS.map((mid) => {
- const open = openId === mid;
- const isSelected = activeMode === mid;
- const meta = modeMeta[mid];
- return (
-
-
toggle(mid)}
- >
-
-
-
- {meta.label}
-
-
-
-
- );
- })}
-
+
+ items={PLANNER_MODE_IDS.map((mid) => ({
+ id: mid,
+ title: modeMeta[mid].label,
+ children: renderBody(mid),
+ }))}
+ showSelectionIndicator
+ selectedId={activeMode}
+ openId={openId}
+ onOpenChange={handleOpenChange}
+ disabled={disabled}
+ />
);
};
diff --git a/src/components/UiComponents/AccordionList/AccordionList.module.css b/src/components/UiComponents/AccordionList/AccordionList.module.css
new file mode 100644
index 0000000..8b076c3
--- /dev/null
+++ b/src/components/UiComponents/AccordionList/AccordionList.module.css
@@ -0,0 +1,114 @@
+/** Single-expand accordion — grid 0fr/1fr height animation. */
+
+.list {
+ margin: 0;
+ padding: 0;
+}
+
+.item {
+ border: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
+ border-radius: var(--radius-md, 8px);
+ margin-bottom: 6px;
+ overflow: hidden;
+ background: var(--bg-primary, #fff);
+}
+
+.itemSelected {
+ border-color: var(--border-color, rgba(0, 0, 0, 0.14));
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 8px 12px;
+ margin: 0;
+ border: none;
+ cursor: pointer;
+ user-select: none;
+ text-align: left;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ outline: none;
+ appearance: none;
+}
+
+
+.dotRail {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 14px;
+ flex-shrink: 0;
+}
+
+.selectionDot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: transparent;
+ box-shadow: inset 0 0 0 1px var(--border-color, rgba(0, 0, 0, 0.22));
+ transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
+}
+
+.selectionDotOn {
+ background: var(--text-primary, #1a1a1a);
+ box-shadow: none;
+}
+
+.title {
+ flex: 1;
+ min-width: 0;
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--text-primary, #1a1a1a);
+}
+
+.chevron {
+ flex-shrink: 0;
+ margin-left: auto;
+ width: 0;
+ height: 0;
+ border-left: 5px solid transparent;
+ border-right: 5px solid transparent;
+ border-top: 6px solid var(--text-tertiary, #888);
+ transition: transform 0.2s ease;
+ transform-origin: 50% 40%;
+}
+
+.itemExpanded .chevron {
+ transform: rotate(180deg);
+}
+
+.bodyCollapse {
+ display: grid;
+ grid-template-rows: 0fr;
+ transition: grid-template-rows 0.36s cubic-bezier(0.33, 1, 0.68, 1);
+ pointer-events: none;
+}
+
+.bodyCollapseOpen {
+ grid-template-rows: 1fr;
+ pointer-events: auto;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .bodyCollapse {
+ transition: none;
+ }
+}
+
+.bodyInner {
+ min-height: 0;
+ overflow: hidden;
+}
+
+.bodyPad {
+ padding: 0 12px 12px;
+}
+
+.nonInteractive {
+ pointer-events: none;
+}
\ No newline at end of file
diff --git a/src/components/UiComponents/AccordionList/AccordionList.tsx b/src/components/UiComponents/AccordionList/AccordionList.tsx
new file mode 100644
index 0000000..b822b63
--- /dev/null
+++ b/src/components/UiComponents/AccordionList/AccordionList.tsx
@@ -0,0 +1,98 @@
+import React, { useState } from 'react';
+import styles from './AccordionList.module.css';
+
+export interface AccordionListItem {
+ id: T;
+ title: React.ReactNode;
+ /** Always mounted while the list is mounted — enables smooth open/close. */
+ children: React.ReactNode;
+}
+
+export interface AccordionListProps {
+ items: AccordionListItem[];
+ /** Optional left rail: dot marks the “selected” row (e.g. persisted value). */
+ showSelectionIndicator?: boolean;
+ selectedId?: T | null;
+ /** Controlled: currently open panel id, or `null` if all closed. */
+ openId?: T | null;
+ /** Uncontrolled initial open panel. Ignored when `openId` is passed. */
+ defaultOpenId?: T | null;
+ onOpenChange?: (openId: T | null) => void;
+ className?: string;
+ disabled?: boolean;
+}
+
+/**
+ * Single-expand accordion: one section open at a time; grid-row animation on real content height.
+ */
+export function AccordionList({
+ items,
+ showSelectionIndicator = false,
+ selectedId = null,
+ openId: openIdProp,
+ defaultOpenId = null,
+ onOpenChange,
+ className = '',
+ disabled = false,
+}: AccordionListProps): React.ReactElement {
+ const isControlled = openIdProp !== undefined;
+ const [openIdInternal, setOpenIdInternal] = useState(defaultOpenId ?? null);
+ const openId = isControlled ? (openIdProp as T | null) : openIdInternal;
+
+ const setOpen = (next: T | null) => {
+ if (!isControlled) setOpenIdInternal(next);
+ onOpenChange?.(next);
+ };
+
+ const onToggle = (id: T) => {
+ if (disabled) return;
+ const next = openId === id ? null : id;
+ setOpen(next);
+ };
+
+ return (
+
+ {items.map((item) => {
+ const expanded = openId === item.id;
+ const isSelected = selectedId != null && selectedId === item.id;
+ return (
+
+
onToggle(item.id)}
+ >
+ {showSelectionIndicator ? (
+
+
+
+ ) : null}
+ {item.title}
+
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/UiComponents/AccordionList/index.ts b/src/components/UiComponents/AccordionList/index.ts
new file mode 100644
index 0000000..6b1675a
--- /dev/null
+++ b/src/components/UiComponents/AccordionList/index.ts
@@ -0,0 +1,2 @@
+export { AccordionList } from './AccordionList';
+export type { AccordionListProps, AccordionListItem } from './AccordionList';
diff --git a/src/components/UiComponents/index.ts b/src/components/UiComponents/index.ts
index 27623a4..79c1ac1 100644
--- a/src/components/UiComponents/index.ts
+++ b/src/components/UiComponents/index.ts
@@ -19,6 +19,7 @@ export type { WorkflowStatusProps } from './WorkflowStatus/WorkflowStatusTypes';
export * from './AutoScroll';
export * from './Tabs';
export type { TabsProps, Tab } from './Tabs';
+export * from './AccordionList';
export * from './Toast';
export * from './VoiceLanguageSelect';
export * from './Modal';
\ No newline at end of file