Merge kn/chat-fix (Pilot-Feedback 17.10.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
85808fc115
4 files changed
+128
-8
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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%; }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user