feat(editor): streamline ingredient entry with compact rows and expandable details

This commit is contained in:
OpenCode committed 2026-10-24 16:13:54 +02:00
1 parent 9ecd36db0f
commit 3abccf0999
14 files changed
+669 -212

No files matched your search

+3 -1
View File
@@ -46,7 +46,9 @@ Faktoren bleiben als hochpräzise Dezimalstrings erhalten. Neue Zielmengen werde
Änderungen werden lokal im Browser gesichert, ohne das veröffentlichte Rezept zu verändern. „Rezept speichern“ validiert und überträgt ausdrücklich auf den Server. Der Browser warnt beim Verlassen mit noch nicht auf den Server übertragenen Änderungen. Konflikte mit einer zwischenzeitlich geänderten Fassung werden als 409 zurückgegeben. Lokale Entwürfe bleiben dann erhalten.
Zutaten und Schritte lassen sich per Drag-Handle sowie per Auf/Ab-Buttons sortieren. So ist die Funktion auch ohne Maus bedienbar. Bilder werden sofort in die lokale Medienablage hochgeladen und können dort später entfernt werden, solange kein Rezept sie verwendet.
Zutaten werden als kompakte Liste mit Name, Menge und Einheit erfasst. Abschnitt, Hinweis und die optionale Kennzeichnung lassen sich pro Zeile aufklappen; ausgefüllte Details bleiben als kurze Zusammenfassung sichtbar. Enter im Zutatenname fügt die nächste Zeile hinzu und fokussiert sie direkt. Eine bereits leere Zeile wird wiederverwendet. Neue Zutaten übernehmen den letzten Abschnitt.
Zutaten und Schritte lassen sich per Drag-Handle sowie per Auf/Ab-Buttons sortieren; bei Zutaten befinden sich die Buttons in den aufgeklappten Details. So ist die Funktion auch ohne Maus bedienbar. Bilder werden sofort in die lokale Medienablage hochgeladen und können dort später entfernt werden, solange kein Rezept sie verwendet.
## Tests
+5 -1
View File
@@ -60,10 +60,14 @@ Ein erster Docker-Prüflauf deckte einen wiederverwendeten, durch den Containern
## Visuelle Inspektion
16 reale Browser-Screenshots liegen in [`docs/screenshots/`](screenshots/): Bibliothek, Rezeptseite, Kochmodus und Favoriten in Desktop/Mobile/Tablet sowie Editor in hellem und dunklem Design.
19 reale Browser-Screenshots liegen in [`docs/screenshots/`](screenshots/): Bibliothek, Rezeptseite, Kochmodus und Favoriten in Desktop/Mobile/Tablet sowie Editor und kompakte Zutatenliste in hellem und dunklem Design.
Tatsächlich angesehen: Desktop- und Mobile-Bibliothek, Tablet-Bibliothek, mobile Rezeptseite, mobiler Kochmodus, Desktop-Editor, mobiler Dark-Mode-Editor und dunkle Favoritenansicht. Dabei wurden eine falsche Suchfeld-Flexrichtung, ein zu schmaler mobiler Infobereich und eine Timer-Anzeigeabweichung nach Start korrigiert. Die aktualisierten Bilder wurden erneut kontrolliert. Browserprüfungen bestätigen fehlenden horizontalen Überlauf in den geprüften Bibliotheks-, Rezept- und Editoransichten.
### Nachprüfung: kompakter Zutateneditor
Nach der gestalterischen Überarbeitung wurden TypeScript-Prüfung und Produktionsbuild erneut erfolgreich ausgeführt. Die vier betroffenen Browserabläufe bestanden: vollständige Rezeptanlage/-bearbeitung auf Desktop sowie der Zutateneditor auf Desktop, Smartphone und Tablet. Geprüft wurden aufklappbare Details, Mengen, optionale Zutaten, Fokus nach Hinzufügen, Enter zum Weitererfassen, Reihenfolge per Buttons und Desktop-Drag-and-drop, Entwurfswiederherstellung sowie fehlender horizontaler Überlauf mit und ohne Live-Vorschau. Ein zunächst falsch auf `textbox` statt `combobox` gerichteter Testselektor für die Abschnittsauswahl wurde korrigiert; anschließend bestanden alle vier Tests. Die neuen Screenshots der hellen Desktop-/Smartphone-Ansicht und der dunklen Tablet-Ansicht wurden visuell kontrolliert.
## Präzise Grenzen
- Kein Test auf physischem Smartphone, Safari oder Firefox; keine tatsächliche iOS-/Android-Installation über das Betriebssystem-Installationsmenü.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

-77
View File
@@ -1878,16 +1878,6 @@ input[type='checkbox'] {
.image-upload label {
margin: 0;
}
.editor-ingredient {
display: flex;
gap: 12px;
border: 1px solid var(--line);
background: var(--bg);
padding: 17px;
border-radius: 12px;
margin-bottom: 14px;
align-items: flex-start;
}
.reorder {
display: flex;
flex-direction: column;
@@ -1904,44 +1894,6 @@ input[type='checkbox'] {
cursor: grab;
display: flex;
}
.ingredient-fields {
flex: 1;
min-width: 0;
}
.ingredient-main-fields {
display: grid;
grid-template-columns: 80px 90px minmax(100px, 1fr);
gap: 10px;
}
.ingredient-main-fields label {
font-size: 10px;
margin-bottom: 10px;
}
.ingredient-secondary-fields {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
.ingredient-secondary-fields > input {
font-size: 10px;
padding: 7px;
flex: 1;
min-width: 50px;
}
.ingredient-secondary-fields .checkbox-label {
font-size: 10px;
margin: 0;
}
.ingredient-fields > small {
font-size: 8px;
overflow-wrap: anywhere;
}
.editor-ingredient > .icon-button {
width: 25px;
height: 30px;
margin-top: 24px;
}
.editor-step {
border: 1px solid var(--line);
border-radius: 14px;
@@ -2779,38 +2731,9 @@ input[type='checkbox'] {
.image-upload > img {
height: 180px;
}
.editor-ingredient {
padding: 12px;
gap: 6px;
position: relative;
}
.ingredient-main-fields {
grid-template-columns: 1fr 1fr;
gap: 8px;
padding-right: 15px;
}
.ingredient-main-fields > label:nth-child(3) {
grid-column: 1/-1;
}
.ingredient-secondary-fields {
flex-wrap: wrap;
}
.ingredient-secondary-fields > input {
flex-basis: 100%;
}
.editor-ingredient > .icon-button {
position: absolute;
top: 6px;
right: 3px;
margin: 0;
width: 24px;
}
.reorder {
padding-top: 20px;
}
.ingredient-fields small {
font-size: 7px;
}
.editor-step {
padding: 15px;
}
+13 -133
View File
@@ -15,18 +15,11 @@ import {
Check,
X,
} from 'lucide-react';
import {
type Recipe,
type SavedRecipe,
type Ingredient,
type Step,
emptyRecipe,
units,
recipeSchema,
} from '@/lib/recipe';
import { type Recipe, type SavedRecipe, type Step, emptyRecipe, recipeSchema } from '@/lib/recipe';
import { api, readLocal, writeLocal, toast } from '@/lib/client';
import { uploadImage } from './admin';
import { renderStep, ingredientLabel } from '@/lib/calculator';
import { IngredientEditor } from './ingredient-editor';
export function RecipeEditor({ initial }: { initial?: SavedRecipe }) {
const router = useRouter(),
key = `hamham:draft:${initial?.id ?? 'new'}`;
@@ -78,11 +71,6 @@ export function RecipeEditor({ initial }: { initial?: SavedRecipe }) {
dirty.current = true;
setRecipe((r) => ({ ...r, [key]: value }));
};
const updateIngredient = (n: number, patch: Partial<Ingredient>) =>
change(
'ingredients',
recipe.ingredients.map((i, index) => (index === n ? { ...i, ...patch } : i)),
);
const updateStep = (n: number, patch: Partial<Step>) =>
change(
'steps',
@@ -384,98 +372,15 @@ export function RecipeEditor({ initial }: { initial?: SavedRecipe }) {
</section>
)}
{tab === 'ingredients' && (
<section className="panel editor-panel">
<h2>Alles, was du brauchst.</h2>
<p className="muted">
Menge leer lassen für „nach Geschmack“. Sortieren per Ziehen oder Pfeiltasten.
</p>
<div className="editor-ingredient-list">
{recipe.ingredients.map((i, n) => (
<div
className="editor-ingredient"
key={i.id}
onDragOver={(e) => e.preventDefault()}
onDrop={() => {
if (dragging.current !== null) move('ingredients', dragging.current, n);
}}
>
{reorder('ingredients', n)}
<div className="ingredient-fields">
<div className="ingredient-main-fields">
<label>
Menge
<input
aria-label={`Menge ${n + 1}`}
type="number"
step="any"
min="0.001"
value={i.amount ?? ''}
onChange={(e) =>
updateIngredient(n, {
amount: e.target.value === '' ? null : +e.target.value,
})
}
/>
</label>
<label>
Einheit
<select
aria-label={`Einheit ${n + 1}`}
value={i.unit}
onChange={(e) =>
updateIngredient(n, { unit: e.target.value as Ingredient['unit'] })
}
>
{units.map((u) => (
<option key={u} value={u}>
{u || 'ohne'}
</option>
))}
</select>
</label>
<label>
Zutat
<input
aria-label={`Zutat ${n + 1}`}
value={i.name}
onChange={(e) => updateIngredient(n, { name: e.target.value })}
placeholder="z. B. Kartoffeln"
/>
</label>
</div>
<div className="ingredient-secondary-fields">
<input
aria-label={`Abschnitt ${n + 1}`}
value={i.section}
onChange={(e) => updateIngredient(n, { section: e.target.value })}
placeholder="Abschnitt, z. B. Sauce"
/>
<input
aria-label={`Hinweis ${n + 1}`}
value={i.note}
onChange={(e) => updateIngredient(n, { note: e.target.value })}
placeholder="Hinweis, z. B. fein gehackt"
/>
<label className="checkbox-label">
<input
type="checkbox"
checked={i.optional}
onChange={(e) => updateIngredient(n, { optional: e.target.checked })}
/>
Optional
</label>
</div>
<small className="muted">Referenz: {`{{${i.id}}}`}</small>
</div>
<button
className="icon-button danger"
aria-label={`Zutat ${n + 1} entfernen`}
disabled={recipe.ingredients.length === 1}
onClick={() => {
const used = recipe.steps.some(
(s) => s.ingredients.includes(i.id) || s.text.includes(`{{${i.id}}}`),
);
if (used) {
<IngredientEditor
ingredients={recipe.ingredients}
onChange={(ingredients) => change('ingredients', ingredients)}
onRemove={(id) => {
if (
recipe.steps.some(
(s) => s.ingredients.includes(id) || s.text.includes(`{{${id}}}`),
)
) {
setError(
'Diese Zutat wird in einem Schritt verwendet. Entferne zuerst die Referenz.',
);
@@ -483,35 +388,10 @@ export function RecipeEditor({ initial }: { initial?: SavedRecipe }) {
}
change(
'ingredients',
recipe.ingredients.filter((x) => x.id !== i.id),
recipe.ingredients.filter((i) => i.id !== id),
);
}}
>
<Trash2 size={17} />
</button>
</div>
))}
</div>
<button
className="button secondary"
onClick={() =>
change('ingredients', [
...recipe.ingredients,
{
id: crypto.randomUUID(),
name: '',
amount: null,
unit: 'g',
section: '',
note: '',
optional: false,
},
])
}
>
<Plus size={18} /> Zutat hinzufügen
</button>
</section>
/>
)}
{tab === 'steps' && (
<section className="panel editor-panel">
+332
View File
@@ -0,0 +1,332 @@
.ingredient-composer {
container: ingredient-editor / inline-size;
}
.ingredient-composer-heading {
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
margin: 4px 0 32px;
}
.ingredient-composer-heading .eyebrow {
font-size: 8px;
letter-spacing: 1.5px;
color: var(--accent);
margin-bottom: 12px;
}
.ingredient-composer-heading h2 {
margin-bottom: 10px;
}
.ingredient-composer-heading p {
color: var(--muted);
font-size: 13px;
margin: 0;
}
.ingredient-count {
display: inline-flex;
gap: 7px;
align-items: center;
flex-shrink: 0;
padding: 8px 13px;
border-radius: 30px;
background: var(--soft-green);
color: var(--green);
font-size: 11px;
}
.ingredient-table-head,
.ingredient-entry-line {
display: grid;
grid-template-columns: 24px minmax(120px, 1fr) 88px 88px 38px 38px;
align-items: center;
gap: 10px;
padding: 0 8px;
}
.ingredient-table-head {
padding-bottom: 12px;
font-size: 9px;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--muted);
}
.ingredient-table-head span:nth-child(2) {
padding-left: 10px;
}
.ingredient-table-head span:nth-child(3),
.ingredient-table-head span:nth-child(4) {
text-align: center;
}
.ingredient-entries {
border-top: 1px solid var(--line);
}
.ingredient-entry {
padding: 12px 0;
border-bottom: 1px solid var(--line);
transition:
background 0.18s,
opacity 0.18s;
}
.ingredient-entry:focus-within,
.ingredient-entry.expanded {
background: var(--bg);
}
.ingredient-entry.dragging {
opacity: 0.45;
}
.ingredient-entry.drop-target {
box-shadow: inset 0 2px var(--green);
}
.ingredient-grip {
display: flex;
justify-content: center;
align-items: center;
min-height: 44px;
color: var(--muted);
cursor: grab;
}
.ingredient-grip:active {
cursor: grabbing;
}
.ingredient-entry-name {
min-width: 0;
}
.ingredient-entry-name > input {
font-size: 14px;
font-weight: 500;
background: transparent;
border-color: transparent;
padding: 10px;
min-height: 44px;
}
.ingredient-entry-name > input:hover {
border-color: var(--line);
}
.ingredient-entry-name > input:focus {
background: var(--surface);
}
.ingredient-entry-line > input,
.ingredient-entry-line > select {
background: var(--surface-2);
border-color: transparent;
font-size: 13px;
padding: 10px 8px;
min-height: 44px;
}
.ingredient-entry-amount {
text-align: center;
-moz-appearance: textfield;
}
.ingredient-entry-amount::-webkit-inner-spin-button {
-webkit-appearance: none;
}
.ingredient-entry-unit {
text-align: center;
}
.ingredient-detail-toggle,
.ingredient-remove {
display: grid;
place-items: center;
width: 38px;
height: 44px;
border-radius: 8px;
color: var(--muted);
}
.ingredient-detail-toggle:hover,
.ingredient-detail-toggle[aria-expanded='true'] {
background: var(--soft-green);
color: var(--green);
}
.ingredient-detail-toggle svg {
transition: transform 0.18s;
}
.ingredient-detail-toggle[aria-expanded='true'] svg {
transform: rotate(180deg);
}
.ingredient-remove:hover:not(:disabled) {
color: var(--accent);
background: var(--sand);
}
.ingredient-entry-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
font-size: 10px;
color: var(--muted);
padding: 0 10px 4px;
overflow-wrap: anywhere;
}
.ingredient-section-label,
.ingredient-optional-label {
font-size: 9px;
padding: 2px 7px;
border-radius: 5px;
background: var(--soft-green);
color: var(--green);
}
.ingredient-optional-label {
background: var(--sand);
color: var(--ink);
}
.ingredient-entry-details {
padding: 18px 18px 4px 52px;
}
.ingredient-detail-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.ingredient-detail-fields label {
margin-bottom: 16px;
font-size: 11px;
color: var(--muted);
}
.ingredient-detail-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.ingredient-detail-footer .checkbox-label {
margin: 0;
font-size: 11px;
}
.ingredient-order-actions {
display: flex;
align-items: center;
gap: 5px;
font-size: 10px;
color: var(--muted);
}
.ingredient-order-actions > span {
margin-right: 7px;
}
.ingredient-order-actions button {
display: grid;
place-items: center;
width: 36px;
height: 40px;
border-radius: 8px;
}
.ingredient-order-actions button:hover:not(:disabled) {
background: var(--surface-2);
color: var(--green);
}
.ingredient-add {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 14px 18px;
margin: 18px 0 14px;
border-radius: 10px;
background: var(--soft-green);
color: var(--green);
font-size: 12px;
font-weight: 500;
text-align: left;
transition: background 0.18s;
}
.ingredient-add > span {
display: grid;
place-items: center;
width: 28px;
height: 28px;
background: var(--surface);
border-radius: 50%;
}
.ingredient-add:hover {
background: var(--surface-2);
}
.ingredient-add kbd {
margin-left: auto;
font: inherit;
font-size: 16px;
opacity: 0.6;
}
.editor-panel > .ingredient-composer-hint {
margin: 0;
font-size: 11px;
color: var(--muted);
line-height: 1.7;
}
@container ingredient-editor (max-width: 560px) {
.ingredient-composer-heading {
align-items: flex-start;
flex-direction: column;
gap: 14px;
margin-bottom: 22px;
}
.ingredient-composer-heading h2 {
font-size: 27px;
}
.ingredient-composer-heading .eyebrow {
font-size: 7px;
}
.ingredient-count {
font-size: 10px;
padding: 5px 10px;
}
.ingredient-table-head {
display: none;
}
.ingredient-entry-line {
grid-template-columns: 20px minmax(0, 1fr) 72px 36px 36px;
gap: 6px;
padding: 0 4px;
}
.ingredient-grip {
grid-column: 1;
grid-row: 1;
}
.ingredient-entry-name {
grid-column: 2 / -1;
grid-row: 1;
}
.ingredient-entry-name > input {
font-size: 16px;
padding-left: 6px;
}
.ingredient-entry-line > input,
.ingredient-entry-line > select {
font-size: 16px;
}
.ingredient-entry-amount {
grid-column: 2;
grid-row: 2;
}
.ingredient-entry-unit {
grid-column: 3;
grid-row: 2;
}
.ingredient-detail-toggle {
grid-column: 4;
grid-row: 2;
width: 36px;
}
.ingredient-remove {
grid-column: 5;
grid-row: 2;
width: 36px;
}
.ingredient-entry-summary {
padding-left: 6px;
}
.ingredient-entry-details {
padding: 18px 12px 4px 30px;
}
.ingredient-detail-fields {
grid-template-columns: 1fr;
gap: 0;
}
.ingredient-detail-fields input {
font-size: 16px;
}
.ingredient-detail-footer {
align-items: flex-start;
flex-direction: column;
gap: 6px;
}
.ingredient-order-actions {
align-self: flex-end;
}
}
+275
View File
@@ -0,0 +1,275 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { ArrowDown, ArrowUp, ChevronDown, GripVertical, Leaf, Plus, Trash2 } from 'lucide-react';
import { type Ingredient, units } from '@/lib/recipe';
import './ingredient-editor.css';
export function IngredientEditor({
ingredients,
onChange,
onRemove,
}: {
ingredients: Ingredient[];
onChange: (ingredients: Ingredient[]) => void;
onRemove: (id: string) => void;
}) {
const [expanded, setExpanded] = useState<string[]>([]);
const [dragging, setDragging] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [announcement, setAnnouncement] = useState('');
const names = useRef(new Map<string, HTMLInputElement>());
const pendingFocus = useRef<string | null>(null);
const sections = Array.from(new Set(ingredients.map((i) => i.section).filter(Boolean)));
const count = ingredients.filter((i) => i.name.trim()).length;
useEffect(() => {
if (pendingFocus.current) {
names.current.get(pendingFocus.current)?.focus();
pendingFocus.current = null;
}
}, [ingredients]);
function update(id: string, patch: Partial<Ingredient>) {
onChange(ingredients.map((i) => (i.id === id ? { ...i, ...patch } : i)));
}
function add() {
const empty = ingredients.find((i) => !i.name.trim());
if (empty) {
names.current.get(empty.id)?.focus();
return;
}
const id = crypto.randomUUID();
pendingFocus.current = id;
onChange([
...ingredients,
{
id,
name: '',
amount: null,
unit: 'g',
section: ingredients.at(-1)?.section ?? '',
note: '',
optional: false,
},
]);
}
function move(id: string, destination: number) {
const from = ingredients.findIndex((i) => i.id === id);
if (from < 0 || destination < 0 || destination >= ingredients.length || from === destination)
return;
const next = [...ingredients];
next.splice(destination, 0, next.splice(from, 1)[0]);
onChange(next);
setAnnouncement(
`${ingredients[from].name || 'Zutat'} an Position ${destination + 1} verschoben.`,
);
}
return (
<section className="panel editor-panel ingredient-composer">
<div className="ingredient-composer-heading">
<div>
<span className="eyebrow">DIE GUTEN DINGE KOMMEN ZUSAMMEN</span>
<h2>Alles, was du brauchst.</h2>
<p>Zutat für Zutat zu deinem Lieblingsgericht.</p>
</div>
<span className="ingredient-count">
<Leaf size={16} />
{count} {count === 1 ? 'Zutat' : 'Zutaten'}
</span>
</div>
<div className="ingredient-table-head" aria-hidden="true">
<span /> <span>Zutat</span> <span>Menge</span> <span>Einheit</span>
</div>
<div className="ingredient-entries">
{ingredients.map((i, n) => {
const open = expanded.includes(i.id);
const detailId = `ingredient-details-${i.id}`;
return (
<div
key={i.id}
className={`ingredient-entry${open ? ' expanded' : ''}${dragging === i.id ? ' dragging' : ''}${dropTarget === i.id && dragging !== i.id ? ' drop-target' : ''}`}
onDragOver={(e) => {
if (!dragging) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDropTarget(i.id);
}}
onDrop={(e) => {
e.preventDefault();
if (dragging) move(dragging, n);
setDragging(null);
setDropTarget(null);
}}
>
<div className="ingredient-entry-line">
<span
className="ingredient-grip"
draggable
title="Zutat zum Sortieren ziehen"
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', i.id);
setDragging(i.id);
}}
onDragEnd={() => {
setDragging(null);
setDropTarget(null);
}}
>
<GripVertical size={16} />
</span>
<div className="ingredient-entry-name">
<input
ref={(element) => {
if (element) names.current.set(i.id, element);
else names.current.delete(i.id);
}}
aria-label={`Zutat ${n + 1}`}
value={i.name}
onChange={(e) => update(i.id, { name: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
e.preventDefault();
if (i.name.trim()) add();
}
}}
placeholder={n === 0 ? 'Was kommt in dein Rezept?' : 'Nächste Zutat …'}
/>
{!open && (i.section || i.note || i.optional) && (
<div className="ingredient-entry-summary">
{i.section && <span className="ingredient-section-label">{i.section}</span>}
{i.note && <span>{i.note}</span>}
{i.optional && <span className="ingredient-optional-label">optional</span>}
</div>
)}
</div>
<input
className="ingredient-entry-amount"
aria-label={`Menge ${n + 1}`}
title="Ohne Menge: nach Geschmack"
type="number"
step="any"
min="0.001"
inputMode="decimal"
placeholder="–"
value={i.amount ?? ''}
onChange={(e) =>
update(i.id, { amount: e.target.value === '' ? null : +e.target.value })
}
/>
<select
className="ingredient-entry-unit"
aria-label={`Einheit ${n + 1}`}
value={i.unit}
onChange={(e) => update(i.id, { unit: e.target.value as Ingredient['unit'] })}
>
{units.map((u) => (
<option key={u} value={u}>
{u || 'ohne'}
</option>
))}
</select>
<button
type="button"
className="ingredient-detail-toggle"
aria-label={`Details für Zutat ${n + 1}`}
aria-expanded={open}
aria-controls={detailId}
title="Abschnitt, Hinweis und weitere Optionen"
onClick={() =>
setExpanded(open ? expanded.filter((id) => id !== i.id) : [...expanded, i.id])
}
>
<ChevronDown size={18} />
</button>
<button
type="button"
className="ingredient-remove"
aria-label={`Zutat ${n + 1} entfernen`}
title="Zutat entfernen"
disabled={ingredients.length === 1}
onClick={() => onRemove(i.id)}
>
<Trash2 size={16} />
</button>
</div>
<div className="ingredient-entry-details" id={detailId} hidden={!open}>
<div className="ingredient-detail-fields">
<label>
Abschnitt
<input
aria-label={`Abschnitt ${n + 1}`}
list="ingredient-sections"
value={i.section}
onChange={(e) => update(i.id, { section: e.target.value })}
placeholder="z. B. Für die Sauce"
/>
</label>
<label>
Hinweis
<input
aria-label={`Hinweis ${n + 1}`}
value={i.note}
onChange={(e) => update(i.id, { note: e.target.value })}
placeholder="z. B. fein gehackt"
/>
</label>
</div>
<div className="ingredient-detail-footer">
<label className="checkbox-label">
<input
type="checkbox"
checked={i.optional}
onChange={(e) => update(i.id, { optional: e.target.checked })}
/>
Optionale Zutat
</label>
<div className="ingredient-order-actions">
<span>Reihenfolge</span>
<button
type="button"
aria-label={`Zutat ${n + 1} nach oben`}
disabled={n === 0}
onClick={() => move(i.id, n - 1)}
>
<ArrowUp size={16} />
</button>
<button
type="button"
aria-label={`Zutat ${n + 1} nach unten`}
disabled={n === ingredients.length - 1}
onClick={() => move(i.id, n + 1)}
>
<ArrowDown size={16} />
</button>
</div>
</div>
</div>
</div>
);
})}
</div>
<button type="button" className="ingredient-add" onClick={add}>
<span>
<Plus size={18} />
</span>
Zutat hinzufügen<kbd>↵</kbd>
</button>
<p className="ingredient-composer-hint">
Keine feste Menge? Lass das Mengenfeld einfach leer. Details findest du am kleinen Pfeil.
</p>
<datalist id="ingredient-sections">
{sections.map((s) => (
<option key={s} value={s} />
))}
</datalist>
<span className="visually-hidden" role="status">
{announcement}
</span>
</section>
);
}
+41
View File
@@ -10,16 +10,57 @@ test('editor remains usable on the current viewport in dark mode', async ({ page
await page.getByRole('button', { name: 'Theme wechseln', exact: true }).click();
await page.getByRole('button', { name: 'Zutaten', exact: true }).click();
await page.getByRole('textbox', { name: 'Zutat 1', exact: true }).fill('Kartoffeln');
await page.getByRole('spinbutton', { name: 'Menge 1', exact: true }).fill('400');
await page.getByRole('button', { name: 'Details für Zutat 1', exact: true }).click();
await page.getByRole('combobox', { name: 'Abschnitt 1', exact: true }).fill('Für das Gemüse');
await page
.getByRole('textbox', { name: 'Hinweis 1', exact: true })
.fill('geschält und gewürfelt');
await page.getByRole('button', { name: 'Details für Zutat 1', exact: true }).click();
await page.getByRole('button', { name: 'Zutat hinzufügen' }).click();
await expect(page.getByRole('textbox', { name: 'Zutat 2', exact: true })).toBeFocused();
await page.getByRole('textbox', { name: 'Zutat 2', exact: true }).fill('Karotten');
await page.getByRole('spinbutton', { name: 'Menge 2', exact: true }).fill('200');
await page.getByRole('button', { name: 'Details für Zutat 2', exact: true }).click();
await page.getByRole('checkbox', { name: 'Optionale Zutat' }).check();
await page.getByRole('button', { name: 'Zutat 2 nach oben' }).click();
await expect(page.getByRole('textbox', { name: 'Zutat 1', exact: true })).toHaveValue('Karotten');
await page.getByRole('button', { name: 'Details für Zutat 1', exact: true }).click();
await page.getByRole('textbox', { name: 'Zutat 2', exact: true }).press('Enter');
await expect(page.getByRole('textbox', { name: 'Zutat 3', exact: true })).toBeFocused();
await page.getByRole('textbox', { name: 'Zutat 3', exact: true }).fill('Zwiebel');
await page.getByRole('spinbutton', { name: 'Menge 3', exact: true }).fill('1');
await page.getByRole('combobox', { name: 'Einheit 3', exact: true }).selectOption('Stück');
await page.getByRole('textbox', { name: 'Zutat 3', exact: true }).press('Enter');
await page.getByRole('textbox', { name: 'Zutat 4', exact: true }).fill('Olivenöl');
await page.getByRole('spinbutton', { name: 'Menge 4', exact: true }).fill('2');
await page.getByRole('combobox', { name: 'Einheit 4', exact: true }).selectOption('EL');
page.on('dialog', (dialog) => dialog.accept());
await page.reload();
await page.getByRole('button', { name: 'Zutaten', exact: true }).click();
await expect(page.getByRole('textbox', { name: 'Zutat 1', exact: true })).toHaveValue('Karotten');
await expect(page.locator('.ingredient-entry').nth(0)).toContainText('optional');
await expect(page.locator('.ingredient-entry').nth(1)).toContainText('geschält und gewürfelt');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.screenshot({
path: `docs/screenshots/editor-dark-${info.project.name}.png`,
fullPage: true,
animations: 'disabled',
});
await page.getByRole('button', { name: 'Theme wechseln', exact: true }).click();
await page.screenshot({
path: `docs/screenshots/ingredients-light-${info.project.name}.png`,
fullPage: true,
animations: 'disabled',
});
if (info.project.name === 'desktop') {
await page.locator('.ingredient-grip').first().dragTo(page.locator('.ingredient-entry').nth(1));
await expect(page.getByRole('textbox', { name: 'Zutat 1', exact: true })).toHaveValue(
'Kartoffeln',
);
}
await page.getByRole('button', { name: 'Live-Vorschau', exact: true }).click();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
});
test('public library, shareable search, favorites, themes and responsive layouts', async ({
page,