From ea0a3c88cb2191f6326c019092c04edacad32220 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:18:15 +0200 Subject: [PATCH] Fix Schemer layout at narrow container widths --- .github/workflows/ci.yml | 31 +++ .gitignore | 2 + README.md | 14 ++ e2e/schemer-responsive.spec.js | 176 ++++++++++++++++++ package-lock.json | 46 +++++ package.json | 8 +- playwright.schemer.config.cjs | 21 +++ .../lib/schemer-standalone-menu.component.ts | 2 +- .../src/lib/schemer/schemer.component.scss | 23 +++ .../code-rules/code-rule-list.component.html | 12 +- .../code-rules/code-rule-list.component.scss | 32 ++++ .../code-rules/code-rules.component.html | 6 +- .../code-rules/code-rules.component.scss | 69 ++++++- .../lib/var-coding/codes-title.component.ts | 6 +- .../lib/var-coding/single-code.component.html | 12 +- .../lib/var-coding/single-code.component.ts | 41 ++++ .../lib/var-coding/var-coding.component.html | 14 +- .../lib/var-coding/var-coding.component.scss | 87 ++++++++- projects/schemer/src/app/app.component.ts | 6 + 19 files changed, 580 insertions(+), 28 deletions(-) create mode 100644 e2e/schemer-responsive.spec.js create mode 100644 playwright.schemer.config.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5f12ad..79cc67c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,37 @@ on: pull_request: jobs: + schemer-responsive: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Schemer viewport regression tests + env: + CI: true + run: npm run test:schemer:responsive + + - name: Upload browser failure artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: schemer-responsive-failures + path: test-results + if-no-files-found: warn + security-audit: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 32b05ca..ee00b03 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,8 @@ yarn-error.log testem.log /typings .eslintcache +/test-results +/playwright-report # System files diff --git a/README.md b/README.md index 5686e93..985b475 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,20 @@ Additionally, this library exports multiple dialogs and helpers (see “Public A # Installation (Angular) +## Schemer viewport support + +The Schemer supports container widths of **320 CSS pixels and above**, including +375, 768, 1024 and 1440 px. At narrow widths the variable navigation moves above +the editor, editor cards stack and toolbars wrap. The variable list and editor +scroll vertically independently so their actions remain reachable. The layout +uses the component's container width, including when embedded in a Verona iframe. +Below 320 px the component retains its minimum width; the host must provide +horizontal scrolling. + +Run the browser regression tests with `npm run test:schemer:responsive` after +`npx playwright install chromium`. For a visible local browser run, append +`-- --headed`. The tests start their own Schemer server on port 4210. + ## Prerequisites This library is built for modern Angular and requires: diff --git a/e2e/schemer-responsive.spec.js b/e2e/schemer-responsive.spec.js new file mode 100644 index 0000000..8244606 --- /dev/null +++ b/e2e/schemer-responsive.spec.js @@ -0,0 +1,176 @@ +/* Browser interactions in a scenario must run sequentially. */ +/* eslint-disable no-await-in-loop, no-restricted-syntax */ +const { test, expect } = require('@playwright/test'); +const fs = require('node:fs/promises'); + +const variables = Array.from({ length: 30 }, (_, i) => ({ + id: `v_${i + 1}`, + type: 'string', + format: '', + multiple: true, + nullable: false, + values: [], + valuePositionLabels: [] +})); + +const scheme = { + version: '3.4', + variableCodings: [{ + id: 'v_1', + alias: 'v_1', + label: 'Testvariable', + page: '', + sourceType: 'BASE', + processing: [], + codeModel: 'MANUAL_AND_RULES', + manualInstruction: '

Allgemeine Kodieranweisung

', + codes: [{ + id: 1, + type: 'FULL_CREDIT', + score: 1, + label: '', + manualInstruction: '

Richtige Antwort

', + ruleSetOperatorAnd: false, + ruleSets: [0, 1].map(valueArrayPos => ({ + valueArrayPos, + ruleOperatorAnd: false, + rules: [ + { method: 'MATCH', parameters: ['richtig'] }, + { method: 'NUMERIC_FULL_RANGE', parameters: ['1', '5'] } + ] + })) + }] + }] +}; + +async function loadJson(page, menuLabel, name, data) { + await page.locator('schemer-standalone-menu button').click(); + const chooser = page.waitForEvent('filechooser'); + await page.getByRole('menuitem', { name: menuLabel, exact: true }).click(); + await (await chooser).setFiles({ + name, + mimeType: 'application/json', + buffer: Buffer.from(JSON.stringify(data)) + }); +} + +async function loadEditor(page) { + await page.goto('/'); + await loadJson(page, 'Variablenliste laden', 'variables.json', variables); + await expect(page.locator('.var-list-entry')).toHaveCount(30); + await loadJson(page, 'Antwortschema laden', 'scheme.json', scheme); + await expect(page.locator('single-code')).toHaveCount(1); +} + +async function expectContained(page) { + const overflow = await page.locator('iqb-schemer').evaluate(root => { + const bounds = root.getBoundingClientRect(); + return [...root.querySelectorAll('button, mat-form-field, mat-card, mat-button-toggle-group')] + .filter(element => element.getClientRects().length > 0) + .map(element => { + const rect = element.getBoundingClientRect(); + return { + tag: element.tagName, + text: element.textContent.trim(), + left: rect.left, + right: rect.right + }; + }) + .filter(rect => rect.left < bounds.left - 1 || rect.right > bounds.right + 1); + }); + expect(overflow).toEqual([]); + const clippedToggles = await page.locator('mat-button-toggle-group').evaluateAll(groups => groups + .filter(group => group.getClientRects().length > 0) + .map(group => ({ + text: group.textContent.trim(), + width: group.clientWidth, + contentWidth: group.scrollWidth + })) + .filter(group => group.contentWidth > group.width + 1)); + expect(clippedToggles).toEqual([]); + const editorOverflow = await page.locator('var-coding') + .evaluate(element => element.scrollWidth - element.clientWidth); + expect(editorOverflow).toBeLessThanOrEqual(1); + const documentOverflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); + expect(documentOverflow).toBeLessThanOrEqual(1); +} + +async function expectActionReachable(locator) { + await locator.scrollIntoViewIfNeeded(); + await expect(locator).toBeInViewport(); + await locator.click({ trial: true }); +} + +for (const width of [320, 375, 768, 1011, 1024, 1440]) { + test(`navigation, editor and actions remain usable at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await loadEditor(page); + await expectContained(page); + + const nav = await page.locator('.navi-bar').boundingBox(); + const editor = await page.locator('var-coding').boundingBox(); + if (width <= 650) { + expect(nav.y + nav.height).toBeLessThanOrEqual(editor.y + 1); + } else { + expect(nav.x + nav.width).toBeLessThanOrEqual(editor.x + 1); + } + + await page.locator('.var-list-entry').filter({ hasText: /^\s*v_9\s*$/ }).click(); + await expect(page.locator('single-code')).toHaveCount(0); + await page.locator('.var-list-entry').filter({ hasText: /^\s*v_1\s*$/ }).click(); + await expect(page.locator('single-code')).toHaveCount(1); + await expectActionReachable(page.locator('.var-list-buttons button').last()); + await expectActionReachable(page.locator('.coding-actions button').last()); + await expectActionReachable(page.locator('.add-code-actions button').last()); + await expectActionReachable(page.locator('single-code .delete-code')); + await expectActionReachable(page.locator('code-instruction button').first()); + const andOperator = page.locator('code-rule-list').first().getByRole('radio', { name: 'UND', exact: true }); + await andOperator.click(); + await expect(andOperator).toBeChecked(); + + // Each code model must fit, including returning to the two-card view. + for (const modelIndex of [1, 2, 0]) { + await page.locator('.coding-actions mat-button-toggle').nth(modelIndex).click(); + await expectContained(page); + } + + // Exercise actions that used to be clipped, and confirm the result can be saved. + await page.locator('single-code .copy-code').click(); + await page.locator('codes-title button').first().click(); + await page.getByRole('button', { name: 'Trotzdem einfügen', exact: true }).click(); + await expect(page.locator('single-code')).toHaveCount(2); + await page.locator('single-code .delete-code').last().click(); + await expect(page.locator('single-code')).toHaveCount(1); + + await page.locator('schemer-standalone-menu button').click(); + const download = page.waitForEvent('download'); + await page.getByRole('menuitem', { name: 'Antwortschema speichern', exact: true }).click(); + const savedFile = await download; + expect(savedFile.suggestedFilename()).toBe('coding-scheme.json'); + const savedScheme = JSON.parse(await fs.readFile(await savedFile.path(), 'utf8')); + const savedVariable = savedScheme.variableCodings.find(variable => variable.id === 'v_1'); + expect(savedVariable.codeModel).toBe('MANUAL_AND_RULES'); + expect(savedVariable.codes).toHaveLength(1); + expect(savedVariable.codes[0].ruleSets).toHaveLength(2); + expect(savedVariable.codes[0].ruleSets[0].ruleOperatorAnd).toBe(true); + }); +} + +test('a narrow container reflows inside a wide host and after resizing', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await loadEditor(page); + for (const width of [375, 768, 1011, 1024, 1440]) { + await page.locator('iqb-schemer').evaluate((element, containerWidth) => { + element.style.setProperty('width', `${containerWidth}px`); + }, width); + await expectContained(page); + const nav = await page.locator('.navi-bar').boundingBox(); + const editor = await page.locator('var-coding').boundingBox(); + if (width === 375) { + expect(nav.y + nav.height).toBeLessThanOrEqual(editor.y + 1); + } else { + expect(nav.x + nav.width).toBeLessThanOrEqual(editor.x + 1); + } + await expectActionReachable(page.locator('single-code .delete-code')); + } +}); diff --git a/package-lock.json b/package-lock.json index 614f33e..47d4fc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "@angular/compiler-cli": "^21.2.23", "@iqb/eslint-config": "^2.2.0", "@iqbspecs/validate-json": "0.4.0", + "@playwright/test": "^1.63.0", "@tiptap/core": "^2.27.2", "@tiptap/extension-blockquote": "^2.27.2", "@tiptap/extension-bold": "^2.27.2", @@ -6219,6 +6220,22 @@ "node": ">=20.0.0" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -16339,6 +16356,35 @@ "node": ">=16.0.0" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 19d8fe6..c41a855 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ "npm_publish": "npm run npm_publish_cc && npm run npm_publish_elements", "npm_publish_cc": "cd dist/ngx-coding-components && npm publish --access public", "npm_publish_elements": "cd dist/coding-components-elements && npm publish --access public", - "lint": "eslint -c .eslintrc.cjs src projects --ext .ts,.js --cache --ignore-pattern '**/karma.conf.js'", - "lint:fix": "eslint -c .eslintrc.cjs src projects --ext .ts,.js --fix --cache --ignore-pattern '**/karma.conf.js'", + "lint": "eslint -c .eslintrc.cjs src projects e2e --ext .ts,.js --cache --ignore-pattern '**/karma.conf.js'", + "lint:fix": "eslint -c .eslintrc.cjs src projects e2e --ext .ts,.js --fix --cache --ignore-pattern '**/karma.conf.js'", "audit:prod": "npm audit --omit=dev", "test:ajv-override": "node --test scripts/ajv-override.test.js", "validate:scheme": "node scripts/validate-coding-scheme.js", @@ -31,7 +31,8 @@ "test:schemer:watch": "ng test schemer", "test:schemer:coverage": "ng test schemer --watch=false --browsers=ChromeHeadless --code-coverage", "test:coverage": "npm run test:cc:coverage && npm run test:schemer:coverage", - "test:scheme": "node --test scripts/validate-coding-scheme.test.js" + "test:scheme": "node --test scripts/validate-coding-scheme.test.js", + "test:schemer:responsive": "playwright test --config playwright.schemer.config.cjs" }, "dependencies": { "@angular/animations": "^21.2.23", @@ -94,6 +95,7 @@ "@angular/compiler-cli": "^21.2.23", "@iqb/eslint-config": "^2.2.0", "@iqbspecs/validate-json": "0.4.0", + "@playwright/test": "^1.63.0", "@tiptap/core": "^2.27.2", "@tiptap/extension-blockquote": "^2.27.2", "@tiptap/extension-bold": "^2.27.2", diff --git a/playwright.schemer.config.cjs b/playwright.schemer.config.cjs new file mode 100644 index 0000000..b4ec5a3 --- /dev/null +++ b/playwright.schemer.config.cjs @@ -0,0 +1,21 @@ +const { defineConfig } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: './e2e', + testMatch: 'schemer-responsive.spec.js', + fullyParallel: true, + workers: 2, + use: { + browserName: 'chromium', + headless: !!process.env.CI, + baseURL: 'http://127.0.0.1:4210', + screenshot: 'only-on-failure', + trace: 'retain-on-failure' + }, + webServer: { + command: 'npx ng serve schemer --host 127.0.0.1 --port 4210', + url: 'http://127.0.0.1:4210', + reuseExistingServer: !process.env.CI, + timeout: 120000 + } +}); diff --git a/projects/ngx-coding-components/src/lib/schemer-standalone-menu.component.ts b/projects/ngx-coding-components/src/lib/schemer-standalone-menu.component.ts index 9fc2e8b..e7fce07 100644 --- a/projects/ngx-coding-components/src/lib/schemer-standalone-menu.component.ts +++ b/projects/ngx-coding-components/src/lib/schemer-standalone-menu.component.ts @@ -71,7 +71,7 @@ import { UserRoleType } from './services/schemer.service'; `, styles: [ - '.mat-mdc-fab {z-index: 999; position: absolute; top: -8px; right: -8px}' + '.mat-mdc-fab {z-index: 999; position: absolute; top: 4px; right: 8px}' ], standalone: true, imports: [ diff --git a/projects/ngx-coding-components/src/lib/schemer/schemer.component.scss b/projects/ngx-coding-components/src/lib/schemer/schemer.component.scss index 9680baf..5d20518 100644 --- a/projects/ngx-coding-components/src/lib/schemer/schemer.component.scss +++ b/projects/ngx-coding-components/src/lib/schemer/schemer.component.scss @@ -1,3 +1,9 @@ +:host { + display: block; + min-width: 320px; + container: schemer / inline-size; +} + .scheme-body { height: 100%; top: 0; @@ -12,10 +18,13 @@ background-color:whitesmoke; margin: 0; border-left: 5px solid #c4e6e8; + min-height: 0; + min-width: 0; } .var-scheme-list { overflow: auto; + min-height: 0; } .var-list-buttons { @@ -35,6 +44,20 @@ background-color: white; overflow-x: hidden; overflow-y: auto; + min-width: 0; + min-height: 0; +} + +@container schemer (max-width: 650px) { + .scheme-body { + flex-direction: column; + } + + .navi-bar { + flex: 0 0 auto; + height: 180px; + max-height: 30%; + } } .var-list-header { diff --git a/projects/ngx-coding-components/src/lib/var-coding/code-rules/code-rule-list.component.html b/projects/ngx-coding-components/src/lib/var-coding/code-rules/code-rule-list.component.html index 9ce6b66..46dc975 100644 --- a/projects/ngx-coding-components/src/lib/var-coding/code-rules/code-rule-list.component.html +++ b/projects/ngx-coding-components/src/lib/var-coding/code-rules/code-rule-list.component.html @@ -1,14 +1,14 @@ -
+
@if (ruleSet) { -
+
@for (rule of ruleSet.rules; track ruleIndex; let ruleIndex = $index) { -
-
+
-
} -
+
-
+
@if (schemerService.userRole !== 'RO') {
{{ "code.prompt.add" | translate }}
-
+
@for (codeType of schemerService.orderOfCodeTypes; track codeType) {