diff --git a/public/neu/ansicht-chat.js b/public/neu/ansicht-chat.js index 2969d13..041c9bc 100644 --- a/public/neu/ansicht-chat.js +++ b/public/neu/ansicht-chat.js @@ -58,20 +58,36 @@ export function verdrahteEingabe({ win = globalThis.window, doc = globalThis.doc } } -let aktiv = null // der laufende Chat: { timer, composer, eingabe, beobachter } +/** + * Druck auf einen Knopf der Eingabezeile (Senden, Anhang, Mikrofon) nimmt dem Textfeld den Fokus nicht. Sonst endet mit dem Fokus die Tastatur-Stellung der Hülle + * (leiste.js): die Leiste kommt zurück und schiebt die fest stehende Eingabe um ihre Höhe nach oben, bevor der Finger loslässt, und der Tipp auf „Senden“ + * geht ins Leere (Android). Das Textfeld behält den Fokus, die Tastatur bleibt wie in jedem Chat offen. Tab und Tastatur-Bedienung bleiben unberührt. + * Rückgabe { abbau } entfernt den Hörer. + */ +export function haltFokus(wurzel) { + const beiDruck = (ev) => { + const knopf = ev.target?.closest?.('button') + if (knopf && wurzel.contains(knopf)) ev.preventDefault() + } + wurzel.addEventListener('mousedown', beiDruck) + return { abbau() { wurzel.removeEventListener('mousedown', beiDruck) } } +} + +let aktiv = null // der laufende Chat: { timer, composer, eingabe, beobachter, fokus } function raeumeAuf() { if (!aktiv) return if (aktiv.timer) clearInterval(aktiv.timer) try { aktiv.composer?.destroy?.() } catch { /* schon abgebaut */ } try { aktiv.eingabe?.abbau() } catch { /* schon abgebaut */ } + try { aktiv.fokus?.abbau() } catch { /* schon abgebaut */ } try { aktiv.beobachter?.disconnect() } catch { /* schon abgebaut */ } aktiv = null } export async function zeigeChat(wurzel, { route, me, nav } = {}) { raeumeAuf() - const eigener = { timer: null, composer: null, eingabe: null, beobachter: null } + const eigener = { timer: null, composer: null, eingabe: null, beobachter: null, fokus: null } aktiv = eigener const gilt = () => aktiv === eigener leeren(wurzel).append(h('p', { class: K.leer }, 'Wird geladen …')) @@ -160,6 +176,7 @@ export async function zeigeChat(wurzel, { route, me, nav } = {}) { // Eingabe über der Tastatur halten; wird der Verlauf kleiner (Tastatur, Leiste), bleibt die neueste Nachricht sichtbar, sofern man unten war (E-Fix M5) eigener.eingabe = verdrahteEingabe({ win: globalThis.window, doc: globalThis.document, wurzel: composerWurzel }) + eigener.fokus = haltFokus(composerWurzel) if (typeof globalThis.ResizeObserver === 'function') { eigener.beobachter = new globalThis.ResizeObserver(() => { if (verlauf.dataset.stick !== 'false') verlauf.scrollTop = verlauf.scrollHeight }) eigener.beobachter.observe(verlauf) diff --git a/public/neu/ansichten.css b/public/neu/ansichten.css index 7467b6a..6f80df3 100644 --- a/public/neu/ansichten.css +++ b/public/neu/ansichten.css @@ -128,13 +128,16 @@ --chat-surface: var(--kn-bg); --chat-line: var(--kn-line-strong); --chat-muted: var(--kn-text-2); - --chat-bubble-in: var(--kn-bg-hover); + --chat-bubble-in: var(--kn-bg-subtle); --chat-bubble-out: var(--kn-grau-bg); --chat-track: var(--kn-spur); - display: flex; flex-direction: column; gap: var(--kn-sp-2); min-width: 0; + display: flex; flex: 1 1 auto; flex-direction: column; gap: var(--kn-sp-2); width: 100%; min-width: 0; min-height: 0; max-width: 760px; margin-inline: auto; } -.kn .kn-chat .chat-box { display: flex; flex-direction: column; gap: var(--kn-sp-2); min-width: 0; } -.kn .kn-chat .chat-messages { display: flex; flex-direction: column; gap: 8px; min-height: 280px; max-height: 60vh; overflow-y: auto; padding: 4px 0; } +.kn .kn-chat .chat-box { display: flex; flex: 1 1 auto; flex-direction: column; gap: var(--kn-sp-2); min-width: 0; min-height: 0; } +/* Der Verlauf füllt die Höhe zwischen Kopf und Eingabe und scrollt in sich; kurze Verläufe stehen unten, bei der Eingabe (Rahmen der Höhe: huelle.css) */ +.kn .kn-chat .chat-messages { display: flex; flex: 1 1 0; flex-direction: column; gap: 8px; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 8px 0; } +.kn .kn-chat .chat-messages > :first-child { margin-top: auto; } +.kn .kn-chat .chat-messages > .kn-leer { margin: auto 0; padding: 24px 0; } .kn .kn-chat .chat-bubble { align-self: flex-start; max-width: 78%; padding: 8px 12px; border: 0; border-radius: var(--kn-radius-lg); background: var(--chat-bubble-in); color: var(--kn-text); overflow-wrap: anywhere; @@ -158,6 +161,7 @@ .kn .kn-chat .chat-icon-btn:hover:not(:disabled) { background: var(--kn-bg-hover); } .kn .kn-chat .chat-icon-btn[aria-expanded="true"], .kn .kn-chat .chat-icon-btn[aria-expanded="true"]:hover:not(:disabled) { background: var(--kn-accent-soft); box-shadow: 0 0 0 1px var(--kn-accent); } .kn .kn-chat .chat-icon-btn:disabled, .kn .kn-chat .chat-send:disabled { opacity: .55; cursor: not-allowed; } +.kn .kn-chat .chat-composer:not(.is-disabled) .chat-send:disabled:not(.is-busy) { opacity: 1; cursor: default; } /* „Senden“ bleibt der schwarze Hauptknopf, auch bei leerem Feld; gesperrt (Ansichtsmodus) wirkt er weiter grau */ .kn .kn-chat .chat-send.is-busy:disabled { opacity: .8; cursor: progress; } .kn .kn-chat .chat-rec { border-color: var(--kn-danger-ring); background: var(--kn-rot-bg); } .kn .kn-chat .chat-rec .chat-icon-btn { background: transparent; box-shadow: none; } @@ -171,7 +175,7 @@ .kn .kn-chat .chat-tray-item { border: 1px solid transparent; background: var(--kn-bg-hover); } .kn .kn-chat .chat-tray-item.is-error { border-color: var(--kn-danger); background: var(--kn-rot-bg); } .kn .kn-chat.chat-drop-active::after, .kn .kn-chat .chat-box.chat-drop-active::after { background: var(--kn-ablage-hell); color: var(--kn-text); border-color: var(--kn-text-3); } -.kn .kn-chat .kn-chat-composer { margin-top: 0; } +.kn .kn-chat .kn-chat-composer { flex: none; margin-top: 0; } .kn .kn-chat .kn-chat-kasten { position: relative; } .kn .kn-chat .kn-chat-verlauf { scroll-behavior: auto; } .kn .kn-chat .chat-composer { margin-top: 8px; } @@ -377,7 +381,6 @@ .kn-sperrzeile { margin-bottom: 8px; } .kn-bestellen .kn-knopf { width: 100%; min-height: 44px; } .kn .kn-chat .chat-bubble { max-width: 88%; } - .kn .kn-chat .chat-messages { min-height: 240px; max-height: 55vh; } .kn .kn-chat .chat-input-row textarea { font-size: 16px; } .kn .ab-portal .ab-karte-aktionen a, .kn .ab-portal .ab-knopf, .kn .ab-portal .ab-details > summary, .kn .ab-portal .ab-links a { min-height: 44px; } .kn .ab-portal .ab-knopf { width: 100%; } diff --git a/public/neu/huelle.css b/public/neu/huelle.css index 074706d..c97c626 100644 --- a/public/neu/huelle.css +++ b/public/neu/huelle.css @@ -173,6 +173,25 @@ } .kn-tastatur .kn-leiste { transform: translateY(110%); visibility: hidden; transition: transform 400ms var(--kn-bremse), visibility 0s 400ms; } +/* ---------- Chat: Vollbild-Ansicht (E-Fix Chat) ---------- + Solange der Bereich Chat zu sehen ist, füllt er die Höhe: Kopf und Eingabe stehen fest, nur der Verlauf scrollt in sich (Aussehen: ansichten.css). + Der Chat hat keine Etage, die Hauptseite ist hier nie inert. Desktop: Spalte in Fensterhöhe, die Eingabe am unteren Rand der zentrierten Spalte + (höchstens 760 px). Handy: der Inhalt steht fest zwischen Kopf und Leiste (wie das Blatt), „Chat“ steht klein im Kopf statt als große Überschrift. */ +.kn-spalte:has(#bereich-chat:not([hidden])) { display: flex; flex-direction: column; height: calc(100vh - var(--kn-banner-h)); height: calc(100dvh - var(--kn-banner-h)); min-height: 0; } +.kn-spalte:has(#bereich-chat:not([hidden])) > :not(.kn-haupt) { flex: none; } +.kn-haupt:has(#bereich-chat:not([hidden])) { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; max-width: none; padding-bottom: 24px; } +.kn-haupt > #bereich-chat:not([hidden]) { display: flex; flex: 1 1 auto; flex-direction: column; width: 100%; min-height: 0; max-width: 760px; margin: 0 auto; } +#bereich-chat > .kn-titel { flex: none; margin: 0 0 16px; } +#chat-body { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; } + +@media (max-width: 880px), (pointer: coarse) and (max-height: 500px) { + .kn-spalte:has(#bereich-chat:not([hidden])) { display: block; height: auto; } + .kn-haupt:has(#bereich-chat:not([hidden])) { position: fixed; inset: calc(var(--kn-banner-h) + var(--kn-kopf-h) + env(safe-area-inset-top)) 0 var(--kn-unten, var(--kn-zeile-h)); padding: 0 max(16px, env(safe-area-inset-right)) 6px max(16px, env(safe-area-inset-left)); background: var(--kn-bg); } + #bereich-chat > .kn-titel { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } /* bleibt für Screenreader die Überschrift der Seite */ + .kn:has(#bereich-chat:not([hidden])) .kn-kopf { height: calc(var(--kn-kopf-h) + env(safe-area-inset-top)); border-bottom-color: var(--kn-line); } /* feste Höhe wie der Rand oben des Inhalts: im Querformat wäre der Kopf mit Haarlinie 1 px höher */ + .kn:has(#bereich-chat:not([hidden])) .kn-kopf-titel { opacity: 1; transform: none; } +} + /* ---------- Querformat (Handy gedreht): niedrigere Zeile, keine große Überschrift ---------- */ @media (max-height: 500px) and (max-width: 880px), (max-height: 500px) and (pointer: coarse) { diff --git a/test/knChatVollbild.test.js b/test/knChatVollbild.test.js new file mode 100644 index 0000000..4ae7960 --- /dev/null +++ b/test/knChatVollbild.test.js @@ -0,0 +1,81 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { haltFokus } from '../public/neu/ansicht-chat.js' +import { lies, handyTeil } from './support/knHuelle.js' + +// Chat als Vollbild-Ansicht (Pilot-Feedback 17.10.: „durchsichtig und verrutscht“). Die Lage prüfen die Ansichten kn-p2-chat-* des Werkzeugs im Browser; +// diese Pins lesen nur den Quelltext und halten die Regeln fest, an denen die Lage hängt. +const ANSICHTEN = lies('ansichten.css') +const HUELLE = lies('huelle.css') +const regel = (css, selektor) => { + const m = css.match(new RegExp(`(?:^|\\n)${selektor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{([^}]*)\\}`)) + return m ? m[1] : '' +} +const chatBlock = () => HUELLE.slice(HUELLE.indexOf('Chat: Vollbild-Ansicht')) + +test('Chat: der Verlauf füllt die Höhe und scrollt in sich, ohne feste Höhengrenze (kein 60vh/55vh mehr)', () => { + const verlauf = regel(ANSICHTEN, '.kn .kn-chat .chat-messages') + assert.match(verlauf, /flex:\s*1 1 0/) + assert.match(verlauf, /min-height:\s*0/) + assert.match(verlauf, /overflow-y:\s*auto/) + assert.doesNotMatch(verlauf, /max-height/) + assert.doesNotMatch(ANSICHTEN, /\.chat-messages\s*\{[^}]*(?:min-height:\s*(?:240|280)px|max-height:\s*\d+vh)/) + assert.match(ANSICHTEN, /\.kn \.kn-chat \.chat-messages > :first-child\s*\{\s*margin-top:\s*auto/) // kurze Verläufe stehen unten bei der Eingabe +}) + +test('Chat: Spalte höchstens 760 px, mittig; Eingabe fest unten', () => { + const spalte = ANSICHTEN.match(/\n\.kn-chat \{[^}]*\}/)[0] + assert.match(spalte, /max-width:\s*760px/) + assert.match(spalte, /margin-inline:\s*auto/) + assert.match(regel(ANSICHTEN, '.kn .kn-chat .kn-chat-composer'), /flex:\s*none/) + assert.match(regel(HUELLE, '.kn-haupt > #bereich-chat:not([hidden])'), /max-width:\s*760px[^}]*margin:\s*0 auto/) +}) + +test('Chat: Blasen ruhig (Fläche #f8f9fb für fremde, #ebeef1 für eigene), über Tokens', () => { + assert.match(ANSICHTEN, /--chat-bubble-in:\s*var\(--kn-bg-subtle\)/) + assert.match(ANSICHTEN, /--chat-bubble-out:\s*var\(--kn-grau-bg\)/) + const tokens = lies('tokens.css') + assert.match(tokens, /--kn-bg-subtle:\s*#f8f9fb/) + assert.match(tokens, /--kn-grau-bg:\s*#ebeef1/) +}) + +test('Chat: „Senden“ bleibt der schwarze Hauptknopf, auch bei leerem Feld; gesperrt (Ansichtsmodus) und beim Senden bleibt er gedämpft', () => { + assert.match(regel(ANSICHTEN, '.kn .kn-chat .chat-composer:not(.is-disabled) .chat-send:disabled:not(.is-busy)'), /opacity:\s*1\b/) + assert.match(regel(ANSICHTEN, '.kn .kn-chat .chat-icon-btn:disabled, .kn .kn-chat .chat-send:disabled'), /opacity:\s*\.55/) + assert.match(regel(ANSICHTEN, '.kn .kn-chat .chat-send.is-busy:disabled'), /opacity:\s*\.8/) +}) + +test('Chat in der Hülle: Fensterhöhe am Desktop, am Handy fest zwischen Kopf und Leiste, „Chat“ klein im Kopf statt große Überschrift', () => { + const block = chatBlock() + assert.match(regel(block, '.kn-spalte:has(#bereich-chat:not([hidden]))'), /display:\s*flex[^}]*height:\s*calc\(100dvh - var\(--kn-banner-h\)\)[^}]*min-height:\s*0/) + assert.match(regel(block, '#chat-body'), /flex:\s*1 1 auto[^}]*min-height:\s*0/) + const handy = handyTeil(block) + assert.match(handy, /\.kn-haupt:has\(#bereich-chat:not\(\[hidden\]\)\)\s*\{[^}]*position:\s*fixed[^}]*inset:\s*calc\(var\(--kn-banner-h\) \+ var\(--kn-kopf-h\) \+ env\(safe-area-inset-top\)\) 0 var\(--kn-unten, var\(--kn-zeile-h\)\)/) + assert.match(handy, /\.kn-haupt:has\(#bereich-chat:not\(\[hidden\]\)\)\s*\{[^}]*padding:\s*0 max\(16px, env\(safe-area-inset-right\)\) 6px max\(16px, env\(safe-area-inset-left\)\)/) // höchstens 8 px Luft über der Leiste + assert.match(handy, /#bereich-chat > \.kn-titel\s*\{[^}]*position:\s*absolute[^}]*width:\s*1px[^}]*height:\s*1px[^}]*clip:\s*rect\(0 0 0 0\)/) // nur noch für Screenreader + assert.match(handy, /\.kn:has\(#bereich-chat:not\(\[hidden\]\)\) \.kn-kopf\s*\{[^}]*height:[^}]*border-bottom-color:\s*var\(--kn-line\)/) // deckender Kopf mit Haarlinie + assert.match(handy, /\.kn:has\(#bereich-chat:not\(\[hidden\]\)\) \.kn-kopf-titel\s*\{[^}]*opacity:\s*1[^}]*transform:\s*none/) + assert.doesNotMatch(block, /backdrop-filter|gradient|rgba\(/) // deckend, kein Glas +}) + +test('haltFokus: Druck auf einen Knopf im Composer nimmt dem Eingabefeld den Fokus nicht (sonst springt die Eingabe, wenn die Leiste zurückkommt, und der Tipp auf „Senden“ geht verloren)', () => { + const hoerer = new Map() + const knopf = { matches: (s) => s === 'button' } + const wurzel = { + addEventListener: (n, f) => hoerer.set(n, f), + removeEventListener: (n) => hoerer.delete(n), + contains: (el) => el === knopf || el === symbol, + } + const symbol = { closest: (s) => (s === 'button' ? knopf : null) } // das Symbol im Knopf ist das Ziel des Drucks + const feld = { closest: () => null } + const draussen = { closest: () => ({}) } // ein Knopf, der nicht im Composer liegt + const h = haltFokus(wurzel) + assert.deepEqual([...hoerer.keys()], ['mousedown']) + const druck = (target) => { let verhindert = false; hoerer.get('mousedown')({ target, preventDefault: () => { verhindert = true } }); return verhindert } + assert.equal(druck(symbol), true, 'Knopf im Composer: Fokus bleibt') + assert.equal(druck(feld), false, 'Textfeld: normal') + assert.equal(druck(draussen), false, 'Knopf außerhalb: normal') + assert.equal(druck(undefined), false) + h.abbau() + assert.equal(hoerer.size, 0) +})