-
-
Notifications
You must be signed in to change notification settings - Fork 102
test(ui): die drei Breiten-Zusicherungen der Küchen-Listen absichern #643
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
ce65665
9d5a7ad
e00961a
4d266c9
64426f7
534d3d3
f10d307
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1904,6 +1904,170 @@ test('die Küchen-Listen teilen eine Zeilen-Grammatik', () => { | |
| '.items-list darf kein horizontales Polster setzen: #list-content trägt schon --page-inline-pad'); | ||
| assert.doesNotMatch(shared.match(/\.kitchen-list\s*\{([^}]*)\}/)?.[1] ?? '', /padding-inline:/, | ||
| '.kitchen-list darf kein padding-inline setzen: wo der Spalten-Träger sitzt, ist pro Tab verschieden'); | ||
|
|
||
| // Die Kappung aufs Lesemaß sitzt an den KINDERN des Scrollers, nicht am | ||
| // Scroller selbst (PR #614). Die Begründung dafür stand bisher nur als | ||
| // Kommentar im CSS. | ||
| // | ||
| // Gescannt wird JEDE Regel JEDER Stylesheet-Datei, nicht der erste Textblock | ||
| // je Selektor. Zwei Wege führen sonst am Guard vorbei: ein zweiter Block | ||
| // hinter einem Breakpoint, und das Modul-CSS, das später lädt und auf | ||
| // demselben Element sitzt (`class="kitchen-list items-list"`). | ||
| const styleDir = new URL('../public/styles/', import.meta.url); | ||
| const allRules = readdirSync(styleDir).filter((f) => f.endsWith('.css')) | ||
| .flatMap((file) => cssRules(read(`../public/styles/${file}`)).map((rule) => ({ file, ...rule }))); | ||
|
|
||
| // Der WIRKSAME Wert einer Eigenschaft, oder null. Drei Fallen stecken darin: | ||
| // | ||
| // - Eine Deklaration ist kein Textvorkommen: `--eigene-max-width: 40rem` | ||
| // setzt keine Breite, und `--x: var(--content-max-width-narrow)` erfüllt | ||
| // keine Zusage. | ||
| // - Die LETZTE Deklaration gewinnt, wie im Browser. Sonst gilt | ||
| // `max-width: var(--content-max-width-narrow); max-width: none` als | ||
| // erfüllt, obwohl das Element bildschirmbreit läuft. | ||
| // - Kurzschreibweisen setzen dieselbe Eigenschaft mit: `place-self: | ||
| // stretch` setzt `align-self` zurück. Deshalb nimmt die Funktion eine | ||
| // Liste und gibt bei `place-*` den ersten Teilwert (die Block-Achse). | ||
| // - `!important` schlägt die Quellreihenfolge. `max-width: none !important; | ||
| // max-width: var(…)` sieht sonst erfüllt aus, obwohl das `none` gewinnt. | ||
| const declaredValue = (body, props) => { | ||
| const alternatives = [].concat(props).map((p) => escapeForRegExp(p)).join('|'); | ||
| const hits = [...body.matchAll(new RegExp(`(?:^|;)\\s*(${alternatives})\\s*:\\s*([^;]+)`, 'gm'))] | ||
|
ulsklyc marked this conversation as resolved.
Outdated
|
||
| .map(([, prop, raw]) => ({ prop, raw: raw.trim() })); | ||
| if (!hits.length) return null; | ||
|
ulsklyc marked this conversation as resolved.
|
||
| const important = hits.filter(({ raw }) => /!\s*important$/.test(raw)); | ||
| const { prop, raw } = (important.length ? important : hits).at(-1); | ||
| const value = raw.replace(/!\s*important$/, '').trim(); | ||
|
ulsklyc marked this conversation as resolved.
Outdated
|
||
| return prop.startsWith('place-') ? value.split(/\s+/)[0] : value; | ||
| }; | ||
| const NARROW = 'var(--content-max-width-narrow)'; | ||
|
ulsklyc marked this conversation as resolved.
|
||
| const ALIGN_SELF = ['align-self', 'place-self']; | ||
| // Eine Kappung ist eine Kappung, egal wie buchstabiert: die logischen Formen | ||
| // wirken im Schreibmodus dieser App auf dieselbe Achse. Dasselbe Paar prüft | ||
| // der Modul-Root-Breiten-Guard weiter unten schon. | ||
| const WIDTH_CAP = ['width', 'max-width', 'inline-size', 'max-inline-size']; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The audited size and alignment properties are not the only way to stop this column-flex item from filling its cross axis. Adding Useful? React with 👍 / 👎. |
||
| const MAX_WIDTH = ['max-width', 'max-inline-size']; | ||
|
|
||
| // Zielt der Selektor auf das Element selbst, nicht auf einen Nachfahren? | ||
| // Geprüft wird der LETZTE Compound, damit auch `.kitchen-list#items-list`, | ||
| // `.kitchen-list:hover` und `:is(.kitchen-list)` als Treffer gelten - | ||
| // `.kitchen-list .row` dagegen nicht. | ||
| // | ||
| // `:not(…)` und `:has(…)` fallen vorher weg, und zwar VOR dem Zerlegen: | ||
| // beide nennen die Klasse, ohne dass die Regel sie stylt. `.page:has( | ||
| // .kitchen-list)` gestaltet den Vorfahren, nicht den Scroller - dort rot zu | ||
| // werden hieße, eine korrekte Layoutregel zu blockieren. | ||
| // Das Token ist `.klasse` oder `#id`: dasselbe Element lässt sich über beide | ||
| // ansprechen, und eine Regel auf der ID nennt keine seiner Klassen. | ||
| const targets = (selector, token) => { | ||
| const subject = selector.replace(/:(?:not|has)\([^)]*\)/g, ''); | ||
| const compound = subject.trim().split(/[\s>+~]+/).pop() ?? ''; | ||
| return new RegExp(`${escapeForRegExp(token)}(?![\\w-])`).test(compound); | ||
|
ulsklyc marked this conversation as resolved.
|
||
| }; | ||
| const rulesFor = (token) => allRules.filter(({ selectors }) => selectors.some((s) => targets(s, token))); | ||
|
|
||
| // 1. Der Scroller selbst darf nicht gekappt werden. Er ist das Element mit | ||
| // `overflow-y: auto`; kappt man es aufs Lesemaß, endet damit auch sein | ||
| // eigener Trefferbereich fürs Mausrad an der Lesespalten-Kante, und auf | ||
| // einem breiten Fenster greift das Rad rechts davon ins Leere. | ||
| // | ||
| // Welche Klassen den Scroller mitbenennen, sagt das Markup, nicht diese | ||
| // Liste: wer auf demselben Element sitzt, kann seine Breite kappen. | ||
| // JEDE geprüfte Seite muss ihre eigene Kombination liefern. Eine globale | ||
| // Mindestzahl genügt nicht: fiele nur eine Seite aus der Erkennung, würden | ||
| // die beiden anderen sie weiter erfüllen, und deren Modul-Klasse wäre | ||
| // ungeprüft. | ||
| const scrollerTokens = new Set(['.kitchen-list']); | ||
| for (const page of ['shopping', 'pantry', 'recipes']) { | ||
| const src = read(`../public/pages/${page}.js`); | ||
| const combos = [...src.matchAll(/class(?:Name)?\s*=\s*(['"`])([^'"`]*\bkitchen-list\b[^'"`]*)\1/g)]; | ||
| assert.ok(combos.length > 0, | ||
| `${page}.js hängt seine Klasse nicht mehr literal an .kitchen-list - dieser Scan findet sie dann nicht und prüft den Scroller des Tabs ungewollt gar nicht`); | ||
| combos.forEach(([, , combo]) => combo.trim().split(/\s+/).forEach((cls) => scrollerTokens.add(`.${cls}`))); | ||
|
|
||
| // Und über die ID, die alle drei Scroller tragen: `#recipes-list` trifft | ||
| // dasselbe Element, ohne eine seiner Klassen zu nennen. Keine ID im | ||
| // Markup heißt umgekehrt, dass kein ID-Selektor es treffen kann - deshalb | ||
| // ist hier nichts zu fordern, nur einzusammeln. | ||
| const inTag = (src.match(/<[^>]*\bkitchen-list\b[^>]*>/g) ?? []) | ||
| .map((tag) => tag.match(/\bid="([^"]+)"/)?.[1]); | ||
| const nextToClassName = [...src.matchAll( | ||
| /(\w+)\.className\s*=\s*['"`][^'"`]*\bkitchen-list\b[^'"`]*['"`];\s*\1\.id\s*=\s*['"`]([^'"`]+)/g)] | ||
| .map(([, , id]) => id); | ||
| [...inTag, ...nextToClassName].filter(Boolean).forEach((id) => scrollerTokens.add(`#${id}`)); | ||
| } | ||
| assert.ok(rulesFor('.kitchen-list').length > 0, | ||
| '.kitchen-list ist nirgends definiert: ein leerer Treffer darf hier nicht still grün bleiben'); | ||
| for (const cls of scrollerTokens) { | ||
| for (const { file, selectors, body } of rulesFor(cls)) { | ||
| assert.equal(declaredValue(body, WIDTH_CAP), null, | ||
| `${file} ${selectors.join(', ')}: der Scroller darf nicht gekappt werden, sonst endet sein Mausrad-Trefferbereich an der Lesespalten-Kante`); | ||
|
ulsklyc marked this conversation as resolved.
Comment on lines
+2041
to
+2042
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This assertion rejects every size declaration regardless of its value, so a harmless module rule such as Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
|
|
||
| // Und das Lesemaß behält EINE Quelle. Definierte ein Modul | ||
| // --content-max-width-narrow lokal um, trüge das Kind zwar weiter die | ||
| // geforderte Deklaration, löste sie aber auf ein anderes Maß auf - der | ||
| // Guard unten vergliche dann zwei Texte, die dasselbe sagen und | ||
| // Verschiedenes bedeuten. | ||
| for (const { file, selectors, body } of allRules) { | ||
| if (file === 'tokens.css') continue; | ||
| assert.equal(declaredValue(body, '--content-max-width-narrow'), null, | ||
|
ulsklyc marked this conversation as resolved.
|
||
| `${file} ${selectors.join(', ')}: --content-max-width-narrow wird hier lokal umdefiniert - das Lesemaß kommt aus tokens.css und nirgendwo sonst`); | ||
| } | ||
|
ulsklyc marked this conversation as resolved.
|
||
|
|
||
| // 2. Tragen muss die Kappung stattdessen jedes Kind, das ALLEIN Kind des | ||
| // Scrollers sein kann: .kitchen-group bei gruppierten Tabs (Einkauf, | ||
| // Vorrat), .kitchen-rows ungruppiert (Rezepte). Fehlt sie an einem der | ||
| // beiden, läuft der betroffene Tab bildschirmbreit - und ein zweiter | ||
| // Block darf sie auch nicht auf einen abweichenden Wert ziehen. | ||
| for (const cls of ['.kitchen-group', '.kitchen-rows']) { | ||
| const rules = rulesFor(cls); | ||
| assert.ok(rules.some(({ body }) => declaredValue(body, MAX_WIDTH) === NARROW), | ||
| `${cls} muss das Lesemaß selbst tragen: es kann alleiniges Kind von .kitchen-list sein, und .kitchen-list kappt nicht mehr`); | ||
|
ulsklyc marked this conversation as resolved.
Outdated
|
||
| for (const { file, body } of rules) { | ||
| const width = declaredValue(body, MAX_WIDTH); | ||
|
ulsklyc marked this conversation as resolved.
|
||
| if (width === null) continue; | ||
| assert.equal(width, NARROW, | ||
| `${file}: ${cls} bekommt hier eine zweite, abweichende Breite - das Lesemaß ist EIN Wert`); | ||
| } | ||
| } | ||
|
|
||
| // 3. Und wer aufs Lesemaß kappt UND clippt, muss auf seine Inhaltshöhe | ||
| // wachsen dürfen. | ||
| // | ||
| // Absichtlich eine Regel und keine Allowlist: `overflow: hidden` (hier für | ||
| // die Eckenradien) macht aus dem gekappten Kind einen Clipper. Ohne | ||
| // `align-self: start` streckt das voreingestellte `align-items: stretch` | ||
| // es auf die volle Spurhöhe, und es schneidet alles darüber still ab, | ||
| // bevor .kitchen-list den Überlauf je sieht. Gemessen an einer Rezeptliste | ||
| // mit 50 gespiegelten Einträgen: scrollHeight 3249px gegen clientHeight | ||
| // 657px, kein Scrollbalken, kein Weg an die übrigen Zeilen. Harmlos ist | ||
| // das nur, solange mehrere kurze Gruppen dieselbe Spur teilen. | ||
| // | ||
| // Der Scan bleibt auf kitchen-row.css, wo die geteilten Bausteine | ||
| // definiert werden. Andere Module kappen mit demselben Token Elemente, die | ||
| // nie Grid-Item dieses Scrollers werden (shopping.css die Eingabezeile, | ||
| // layout.css den Leerzustand) - für die wäre `align-self: start` falsch. | ||
| // Innerhalb dieser Datei gilt dieselbe Einschränkung für .kitchen-bulkbar: | ||
| // sie steht ÜBER dem Scroller (siehe dort) und trägt das Lesemaß, clippt | ||
| // aber nicht. Käme dort ein `overflow: hidden` dazu, meldet dieser Guard | ||
| // einen Fall, den ein Mensch entscheiden muss. | ||
| for (const { selectors, body } of cssRules(shared)) { | ||
| if (declaredValue(body, MAX_WIDTH) !== NARROW) continue; | ||
| if (declaredValue(body, 'overflow') !== 'hidden') continue; | ||
|
ulsklyc marked this conversation as resolved.
Outdated
|
||
| assert.equal(declaredValue(body, ALIGN_SELF), 'start', | ||
| `${selectors.join(', ')} kappt aufs Lesemaß und clippt zugleich, ist also ein gekapptes Kind des Scroller-Grids: ohne align-self: start schneidet es den Überlauf ab, bevor .kitchen-list ihn sieht`); | ||
| } | ||
|
|
||
| // Und kein später geladenes Modul-Stylesheet biegt den Wert wieder um - | ||
| // auch nicht über die Kurzschreibweise place-self. | ||
| for (const { file, body } of rulesFor('.kitchen-rows')) { | ||
| const align = declaredValue(body, ALIGN_SELF); | ||
| if (align === null) continue; | ||
| assert.equal(align, 'start', | ||
| `${file}: .kitchen-rows bekommt hier ein anderes align-self - genau der Rückfall, den die Regel darüber verhindert`); | ||
| } | ||
| }); | ||
|
|
||
| /** | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.