feat: seperated accordion list component to be seperate and reusable ui component
This commit is contained in:
parent
8c2f61670c
commit
bef2aa8b83
7 changed files with 237 additions and 153 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
98
src/components/UiComponents/AccordionList/AccordionList.tsx
Normal file
98
src/components/UiComponents/AccordionList/AccordionList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
src/components/UiComponents/AccordionList/index.ts
Normal file
2
src/components/UiComponents/AccordionList/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { AccordionList } from './AccordionList';
|
||||
export type { AccordionListProps, AccordionListItem } from './AccordionList';
|
||||
|
|
@ -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';
|
||||
Loading…
Reference in a new issue