Refactor modal components and enhance FormActionBar functionality
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s

- Updated modal components to utilize a consistent overlay and panel structure, improving layout and responsiveness.
- Enhanced FormActionBar to support short labels for action buttons, improving usability in mobile views.
- Introduced new styling for action buttons and modal titles, ensuring better alignment and visual consistency across forms.
- Improved accessibility by adding aria-labels and titles to buttons for better screen reader support.
This commit is contained in:
Lars 2026-05-19 10:20:49 +02:00
parent f15aa7c415
commit c9175bd2fd
5 changed files with 206 additions and 93 deletions

View File

@ -1186,56 +1186,203 @@ a.analysis-split__nav-item {
.form-action-bar__inner { .form-action-bar__inner {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: stretch;
justify-content: space-between; justify-content: flex-start;
gap: 8px; gap: 8px;
max-width: min(1100px, 100%); max-width: min(1100px, 100%);
margin: 0 auto; margin: 0 auto;
}
.form-action-bar__spacer {
flex: 0 0 auto;
min-width: 0; min-width: 0;
} }
.form-action-bar__primary-group { .form-action-bar__primary-group {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: stretch;
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: 8px;
margin-left: auto; margin-left: auto;
min-width: 0;
flex: 1 1 auto;
} }
.form-action-bar__btn { .form-action-bar__btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 44px; min-height: 44px;
padding: 8px 14px; padding: 8px 14px;
font-size: 0.88rem; font-size: 0.88rem;
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
min-width: 0;
} }
.form-action-bar__btn--cancel { .form-action-bar__btn--cancel {
min-width: 5.5rem; min-width: 5.5rem;
} }
.form-action-bar__icon {
flex-shrink: 0;
display: none;
}
.form-action-bar__text--short {
display: none;
}
.form-action-bar__saving {
font-size: 0.82rem;
letter-spacing: 0.02em;
}
/* Form-Modale: Overlay + Panel (Desktop zentriert, Mobile Vollbild) */
.modal-overlay--form {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
overflow: hidden;
box-sizing: border-box;
}
.modal-overlay--form.modal-overlay--raised {
z-index: 1100;
}
.modal-panel--form { .modal-panel--form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-height: min(92vh, 100%); background: var(--surface);
border-radius: 12px;
padding: clamp(12px, 3vw, 2rem);
width: 100%;
max-width: min(1100px, 100%);
max-height: min(92vh, 100dvh);
overflow: hidden; overflow: hidden;
min-height: 0; min-height: 0;
min-width: 0;
box-sizing: border-box;
}
.modal-panel--form.modal-panel--narrow {
max-width: min(560px, 100%);
}
.modal-panel--form.card {
/* card-Klasse nur für Desktop-Rahmen; Mobile überschreibt unten */
}
.modal-panel__title {
margin: 0 0 1rem;
flex-shrink: 0;
font-size: clamp(1.05rem, 4vw, 1.25rem);
line-height: 1.25;
min-width: 0;
}
.modal-panel__intro {
font-size: 0.88rem;
color: var(--text2);
line-height: 1.45;
margin: 0 0 1rem;
flex-shrink: 0;
min-width: 0;
} }
.modal-form-shell { .modal-form-shell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
min-width: 0;
overflow: hidden; overflow: hidden;
} }
.modal-form-shell__body { .modal-form-shell__body {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
min-width: 0;
overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
overscroll-behavior-x: none;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
padding-bottom: 4px; padding-bottom: 4px;
} }
.modal-panel--form > *,
.modal-form-shell__body > * {
max-width: 100%;
min-width: 0;
}
@media (max-width: 639px) {
.modal-overlay--form {
padding: 0;
align-items: stretch;
}
.modal-panel--form {
max-width: 100%;
max-height: 100dvh;
height: 100dvh;
border-radius: 0;
padding: 12px;
padding-top: max(12px, env(safe-area-inset-top, 0px));
padding-bottom: max(0px, env(safe-area-inset-bottom, 0px));
}
.modal-panel--form.card {
border: none;
border-radius: 0;
padding: 12px;
padding-top: max(12px, env(safe-area-inset-top, 0px));
padding-bottom: max(0px, env(safe-area-inset-bottom, 0px));
}
.modal-panel__title {
margin-bottom: 0.75rem;
font-size: 1.05rem;
}
.modal-panel__intro {
margin-bottom: 0.75rem;
font-size: 0.85rem;
}
.form-action-bar {
padding: 5px 6px;
padding-bottom: max(5px, env(safe-area-inset-bottom, 0px));
}
.form-action-bar__inner {
flex-wrap: nowrap;
gap: 6px;
max-width: none;
}
.form-action-bar__primary-group {
flex: 1 1 0;
flex-wrap: nowrap;
gap: 6px;
margin-left: 0;
min-width: 0;
}
.form-action-bar__btn {
min-height: 38px;
padding: 6px 8px;
font-size: 0.75rem;
gap: 4px;
flex: 1 1 0;
}
.form-action-bar__btn--cancel {
flex: 0 0 38px;
width: 38px;
min-width: 38px;
max-width: 38px;
padding: 0;
}
.form-action-bar__btn--cancel .form-action-bar__text--long,
.form-action-bar__btn--cancel .form-action-bar__text--short {
display: none !important;
}
.form-action-bar__icon {
display: inline;
}
.form-action-bar__text--long {
display: none;
}
.form-action-bar__text--short {
display: inline;
}
.form-action-bar--single-primary .form-action-bar__primary-group .form-action-bar__btn {
flex: 1 1 auto;
}
}
.page-form-shell { .page-form-shell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -1,13 +1,32 @@
import { Check, Save, X } from 'lucide-react'
/** /**
* Feste Aktionsleiste für Formulare/Modale: Speichern, Speichern & schließen, Abbrechen. * Feste Aktionsleiste für Formulare/Modale: Speichern, Speichern & schließen, Abbrechen.
* Bleibt sichtbar (sticky), während der Formularinhalt scrollt. * Bleibt sichtbar (sticky), während der Formularinhalt scrollt.
*/ */
function ActionLabel({ Icon, long, short, saving, savingShort = '…' }) {
if (saving) {
return <span className="form-action-bar__saving">{savingShort}</span>
}
return (
<>
{Icon ? <Icon size={17} strokeWidth={2.25} className="form-action-bar__icon" aria-hidden /> : null}
<span className="form-action-bar__text form-action-bar__text--long">{long}</span>
{short != null && short !== '' ? (
<span className="form-action-bar__text form-action-bar__text--short">{short}</span>
) : null}
</>
)
}
export default function FormActionBar({ export default function FormActionBar({
placement = 'bottom', placement = 'bottom',
variant = 'default', variant = 'default',
saving = false, saving = false,
saveLabel, saveLabel,
saveShortLabel,
saveAndCloseLabel = 'Speichern & schließen', saveAndCloseLabel = 'Speichern & schließen',
saveAndCloseShortLabel,
cancelLabel = 'Abbrechen', cancelLabel = 'Abbrechen',
onSave, onSave,
onSaveAndClose, onSaveAndClose,
@ -20,6 +39,8 @@ export default function FormActionBar({
primaryIsSaveOnly = false, primaryIsSaveOnly = false,
}) { }) {
const labelSave = saveLabel ?? (isNew ? 'Anlegen' : 'Speichern') const labelSave = saveLabel ?? (isNew ? 'Anlegen' : 'Speichern')
const shortSave = saveShortLabel ?? (isNew ? 'Neu' : 'Sichern')
const shortClose = saveAndCloseShortLabel ?? 'Fertig'
const showSaveBtn = showSave && (Boolean(onSave) || Boolean(formId)) const showSaveBtn = showSave && (Boolean(onSave) || Boolean(formId))
const showCloseBtn = showSaveAndClose && (Boolean(onSaveAndClose) || Boolean(formId)) const showCloseBtn = showSaveAndClose && (Boolean(onSaveAndClose) || Boolean(formId))
const showCancelBtn = showCancel && Boolean(onCancel) const showCancelBtn = showCancel && Boolean(onCancel)
@ -33,9 +54,13 @@ export default function FormActionBar({
primaryIsSaveOnly ? ' btn-secondary' : ' btn-primary' primaryIsSaveOnly ? ' btn-secondary' : ' btn-primary'
}` }`
const primaryCount = (showSaveBtn ? 1 : 0) + (showCloseBtn ? 1 : 0)
return ( return (
<div <div
className={`form-action-bar form-action-bar--${placement} form-action-bar--${variant}`} className={`form-action-bar form-action-bar--${placement} form-action-bar--${variant}${
primaryCount === 1 ? ' form-action-bar--single-primary' : ''
}`}
role="group" role="group"
aria-label="Formularaktionen" aria-label="Formularaktionen"
> >
@ -46,12 +71,12 @@ export default function FormActionBar({
className="btn btn-secondary form-action-bar__btn form-action-bar__btn--cancel" className="btn btn-secondary form-action-bar__btn form-action-bar__btn--cancel"
onClick={onCancel} onClick={onCancel}
disabled={saving} disabled={saving}
aria-label={cancelLabel}
title={cancelLabel}
> >
{cancelLabel} <ActionLabel Icon={X} long={cancelLabel} short="" saving={saving} />
</button> </button>
) : ( ) : null}
<span className="form-action-bar__spacer" aria-hidden />
)}
<div className="form-action-bar__primary-group"> <div className="form-action-bar__primary-group">
{showSaveBtn ? ( {showSaveBtn ? (
<button <button
@ -60,8 +85,9 @@ export default function FormActionBar({
className={saveBtnClass} className={saveBtnClass}
disabled={saving} disabled={saving}
onClick={onSave || undefined} onClick={onSave || undefined}
title={labelSave}
> >
{saving ? 'Speichern…' : labelSave} <ActionLabel Icon={Save} long={labelSave} short={shortSave} saving={saving} />
</button> </button>
) : null} ) : null}
{showCloseBtn ? ( {showCloseBtn ? (
@ -71,8 +97,14 @@ export default function FormActionBar({
className={closeBtnClass} className={closeBtnClass}
disabled={saving} disabled={saving}
onClick={onSaveAndClose || undefined} onClick={onSaveAndClose || undefined}
title={saveAndCloseLabel}
> >
{saving ? 'Speichern…' : saveAndCloseLabel} <ActionLabel
Icon={Check}
long={saveAndCloseLabel}
short={shortClose}
saving={saving}
/>
</button> </button>
) : null} ) : null}
</div> </div>

View File

@ -147,30 +147,10 @@ export default function SaveExercisesAsModuleModal({
if (!open) return null if (!open) return null
return ( return (
<div <div className="modal-overlay modal-overlay--form modal-overlay--raised">
style={{ <div className="card modal-panel--form modal-panel--narrow">
position: 'fixed', <h2 className="modal-panel__title">Übungen als Trainingsmodul</h2>
inset: 0, <p className="modal-panel__intro">
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1100,
padding: '1rem',
overflowY: 'auto',
}}
>
<div
className="card modal-panel--form"
style={{
maxWidth: 'min(560px, 100%)',
width: '100%',
padding: '1.25rem',
maxHeight: '90vh',
}}
>
<h2 style={{ marginTop: 0, marginBottom: '0.65rem', flexShrink: 0 }}>Übungen als Trainingsmodul</h2>
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45, marginBottom: '1rem', flexShrink: 0 }}>
Es werden die <strong>gespeicherten</strong> Übungspositionen der Einheit vom{' '} Es werden die <strong>gespeicherten</strong> Übungspositionen der Einheit vom{' '}
<strong>{unitLabel || '…'}</strong> verwendet. Speichere die Planung vorher, wenn du den aktuellen Stand <strong>{unitLabel || '…'}</strong> verwendet. Speichere die Planung vorher, wenn du den aktuellen Stand
brauchst. brauchst.
@ -296,6 +276,7 @@ export default function SaveExercisesAsModuleModal({
saving={submitting} saving={submitting}
showSave={false} showSave={false}
saveAndCloseLabel="Modul anlegen" saveAndCloseLabel="Modul anlegen"
saveAndCloseShortLabel="Anlegen"
onCancel={onClose} onCancel={onClose}
/> />
</form> </form>

View File

@ -57,37 +57,9 @@ export default function TrainingPlanningUnitFormModal({
const formId = 'planning-unit-form' const formId = 'planning-unit-form'
return ( return (
<div <div data-testid="planning-unit-form-modal" className="modal-overlay modal-overlay--form">
data-testid="planning-unit-form-modal" <div className="modal-panel--form">
className="modal-overlay" <h2 className="modal-panel__title">
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '1rem',
}}
>
<div
className="modal-panel--form"
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: 'clamp(12px, 3vw, 2rem)',
maxWidth: 'min(1100px, 100%)',
width: '100%',
margin: 'max(0px, env(safe-area-inset-top, 0px)) auto',
boxSizing: 'border-box',
minWidth: 0,
}}
>
<h2 style={{ marginBottom: '1rem', flexShrink: 0 }}>
{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'} {editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}
</h2> </h2>

View File

@ -192,30 +192,10 @@ export default function TrainingPublishToFrameworkModal({
if (!open) return null if (!open) return null
return ( return (
<div <div className="modal-overlay modal-overlay--form modal-overlay--raised">
style={{ <div className="card modal-panel--form modal-panel--narrow">
position: 'fixed', <h2 className="modal-panel__title">Ablauf ins Rahmenprogramm übernehmen</h2>
inset: 0, <p className="modal-panel__intro">
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1100,
padding: '1rem',
overflowY: 'auto',
}}
>
<div
className="card modal-panel--form"
style={{
maxWidth: 'min(520px, 100%)',
width: '100%',
padding: '1.25rem',
maxHeight: '90vh',
}}
>
<h2 style={{ marginTop: 0, marginBottom: '0.65rem', flexShrink: 0 }}>Ablauf ins Rahmenprogramm übernehmen</h2>
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45, marginBottom: '1rem', flexShrink: 0 }}>
Es wird der <strong>zuletzt gespeicherte</strong> Ablauf dieser Einheit aus der Datenbank übernommen. Es wird der <strong>zuletzt gespeicherte</strong> Ablauf dieser Einheit aus der Datenbank übernommen.
Nicht gespeicherte Änderungen im Formular sind nicht enthalten bitte vorher die Einheit speichern. Nicht gespeicherte Änderungen im Formular sind nicht enthalten bitte vorher die Einheit speichern.
</p> </p>
@ -415,6 +395,7 @@ export default function TrainingPublishToFrameworkModal({
saving={submitting} saving={submitting}
showSave={false} showSave={false}
saveAndCloseLabel="In Rahmen übernehmen" saveAndCloseLabel="In Rahmen übernehmen"
saveAndCloseShortLabel="Übernehmen"
onCancel={resetAndClose} onCancel={resetAndClose}
/> />
</form> </form>