feat: seperated accordion list component to be seperate and reusable ui component

This commit is contained in:
Ida 2026-05-14 12:08:05 +02:00
parent aa61e00af6
commit 0fd05f638f
7 changed files with 237 additions and 153 deletions

View file

@ -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;

View file

@ -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;

View file

@ -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<SchedulePlannerProps> = ({
[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<SchedulePlannerProps> = ({
<button
key={cronDow}
type="button"
data-schedule-day=""
className={`${styles.dayBtn} ${v.weekdays.includes(cronDow) ? styles.dayBtnSel : ''}`}
onClick={() => {
const next = { ...v, mode: 'weeks' as const };
@ -508,43 +509,18 @@ export const SchedulePlanner: React.FC<SchedulePlannerProps> = ({
'Legen Sie fest, wann dieser Workflow automatisch laufen soll. Der Cron-Ausdruck wird für die API erzeugt.'
)}
</p>
<div role="list">
{PLANNER_MODE_IDS.map((mid) => {
const open = openId === mid;
const isSelected = activeMode === mid;
const meta = modeMeta[mid];
return (
<div
key={mid}
role="listitem"
className={`${styles.item} ${open ? styles.itemExpanded : ''} ${isSelected ? styles.itemSelected : ''}`}
>
<button
type="button"
className={styles.header}
aria-expanded={open}
onClick={() => toggle(mid)}
>
<span className={styles.dotRail} aria-hidden>
<span
className={`${styles.modeDot} ${isSelected ? styles.modeDotOn : ''}`}
/>
</span>
<span className={styles.title}>{meta.label}</span>
<span className={styles.chevron} aria-hidden />
</button>
<div
className={`${styles.bodyCollapse} ${open ? styles.bodyCollapseOpen : ''}`}
aria-hidden={!open}
>
<div className={styles.bodyInner}>
<div className={styles.bodyPad}>{renderBody(mid)}</div>
</div>
</div>
</div>
);
})}
</div>
<AccordionList<PlannerModeId>
items={PLANNER_MODE_IDS.map((mid) => ({
id: mid,
title: modeMeta[mid].label,
children: renderBody(mid),
}))}
showSelectionIndicator
selectedId={activeMode}
openId={openId}
onOpenChange={handleOpenChange}
disabled={disabled}
/>
</div>
);
};

View file

@ -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;
}

View file

@ -0,0 +1,98 @@
import React, { useState } from 'react';
import styles from './AccordionList.module.css';
export interface AccordionListItem<T extends string = string> {
id: T;
title: React.ReactNode;
/** Always mounted while the list is mounted — enables smooth open/close. */
children: React.ReactNode;
}
export interface AccordionListProps<T extends string = string> {
items: AccordionListItem<T>[];
/** 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<T extends string = string>({
items,
showSelectionIndicator = false,
selectedId = null,
openId: openIdProp,
defaultOpenId = null,
onOpenChange,
className = '',
disabled = false,
}: AccordionListProps<T>): React.ReactElement {
const isControlled = openIdProp !== undefined;
const [openIdInternal, setOpenIdInternal] = useState<T | null>(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 (
<div
role="list"
className={`${styles.list} ${className} ${disabled ? styles.nonInteractive : ''}`}
>
{items.map((item) => {
const expanded = openId === item.id;
const isSelected = selectedId != null && selectedId === item.id;
return (
<div
key={item.id}
role="listitem"
className={`${styles.item} ${expanded ? styles.itemExpanded : ''} ${isSelected ? styles.itemSelected : ''}`}
>
<button
type="button"
className={styles.header}
aria-expanded={expanded}
disabled={disabled}
data-accordion-header=""
onClick={() => onToggle(item.id)}
>
{showSelectionIndicator ? (
<span className={styles.dotRail} aria-hidden>
<span
className={`${styles.selectionDot} ${isSelected ? styles.selectionDotOn : ''}`}
/>
</span>
) : null}
<span className={styles.title}>{item.title}</span>
<span className={styles.chevron} aria-hidden />
</button>
<div
className={`${styles.bodyCollapse} ${expanded ? styles.bodyCollapseOpen : ''}`}
aria-hidden={!expanded}
>
<div className={styles.bodyInner}>
<div className={styles.bodyPad}>{item.children}</div>
</div>
</div>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,2 @@
export { AccordionList } from './AccordionList';
export type { AccordionListProps, AccordionListItem } from './AccordionList';

View file

@ -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';