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 8c2f61670c
commit bef2aa8b83
7 changed files with 237 additions and 153 deletions

View file

@ -1145,7 +1145,9 @@
/* Kein Primär-Button-Stil für Zeitplan-Karten / Wochentage / Monat-Jahr-Chips /* Kein Primär-Button-Stil für Zeitplan-Karten / Wochentage / Monat-Jahr-Chips
(DataPicker-Dialog wird per createPortal an document.body gehangen nicht hier). */ (DataPicker-Dialog wird per createPortal an document.body gehangen nicht hier). */
.nodeConfigPanel .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; margin-top: 0.5rem;
padding: 0.4rem 0.75rem; padding: 0.4rem 0.75rem;
font-size: 0.8rem; 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 { .wrap {
max-width: 560px; max-width: 560px;
@ -14,115 +14,6 @@
color: var(--text-secondary, #666); 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 { .fields {
display: grid; display: grid;
gap: 10px; gap: 10px;

View file

@ -9,6 +9,7 @@ import {
type ScheduleSpec, type ScheduleSpec,
} from '../../utils/scheduleCron'; } from '../../utils/scheduleCron';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { AccordionList } from '../UiComponents';
import styles from './SchedulePlanner.module.css'; import styles from './SchedulePlanner.module.css';
export type PlannerModeId = 'minutes' | 'hours' | 'days' | 'weeks' | 'months' | 'custom'; export type PlannerModeId = 'minutes' | 'hours' | 'days' | 'weeks' | 'months' | 'custom';
@ -122,11 +123,10 @@ export const SchedulePlanner: React.FC<SchedulePlannerProps> = ({
[disabled, onChange] [disabled, onChange]
); );
const toggle = (id: PlannerModeId) => { const handleOpenChange = (next: PlannerModeId | null) => {
const nextOpen: PlannerModeId | null = openId === id ? null : id; setOpenId(next);
setOpenId(nextOpen); if (next != null) {
if (nextOpen != null) { push(withPlannerMode(value, next));
push(withPlannerMode(value, nextOpen));
} }
}; };
@ -309,6 +309,7 @@ export const SchedulePlanner: React.FC<SchedulePlannerProps> = ({
<button <button
key={cronDow} key={cronDow}
type="button" type="button"
data-schedule-day=""
className={`${styles.dayBtn} ${v.weekdays.includes(cronDow) ? styles.dayBtnSel : ''}`} className={`${styles.dayBtn} ${v.weekdays.includes(cronDow) ? styles.dayBtnSel : ''}`}
onClick={() => { onClick={() => {
const next = { ...v, mode: 'weeks' as const }; 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.' 'Legen Sie fest, wann dieser Workflow automatisch laufen soll. Der Cron-Ausdruck wird für die API erzeugt.'
)} )}
</p> </p>
<div role="list"> <AccordionList<PlannerModeId>
{PLANNER_MODE_IDS.map((mid) => { items={PLANNER_MODE_IDS.map((mid) => ({
const open = openId === mid; id: mid,
const isSelected = activeMode === mid; title: modeMeta[mid].label,
const meta = modeMeta[mid]; children: renderBody(mid),
return ( }))}
<div showSelectionIndicator
key={mid} selectedId={activeMode}
role="listitem" openId={openId}
className={`${styles.item} ${open ? styles.itemExpanded : ''} ${isSelected ? styles.itemSelected : ''}`} onOpenChange={handleOpenChange}
> disabled={disabled}
<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>
</div> </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 './AutoScroll';
export * from './Tabs'; export * from './Tabs';
export type { TabsProps, Tab } from './Tabs'; export type { TabsProps, Tab } from './Tabs';
export * from './AccordionList';
export * from './Toast'; export * from './Toast';
export * from './VoiceLanguageSelect'; export * from './VoiceLanguageSelect';
export * from './Modal'; export * from './Modal';