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
|
/* 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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 './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';
|
||||||
Loading…
Reference in a new issue