From bef2aa8b83c931675d839210fefdeb6327b15a6f 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 85e0981..80de85b 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.module.css +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.module.css @@ -1145,7 +1145,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 = ({ -
-
-
{renderBody(mid)}
-
-
- - ); - })} - + + 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 ( +
+ +
+
+
{item.children}
+
+
+
+ ); + })} +
+ ); +} 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