|
| 1 | +# Focus Propagation Concept |
| 2 | + |
| 3 | +Beschreibt, wie der Focus durch die KoliBri-Komponentenschichten delegiert wird: Shadow DOM → (optional Light DOM) → HTML5-Element. |
| 4 | + |
| 5 | +## Überblick |
| 6 | + |
| 7 | +KoliBri Web Components verwenden Shadow DOM für Style-Isolation. Da der Browser fokussierbare Elemente innerhalb eines Shadow DOM nicht direkt ansteuern kann, muss der Focus programmatisch von der öffentlichen Shadow-Komponente an das tatsächlich fokussierbare HTML5-Element weitergeleitet werden. |
| 8 | + |
| 9 | +Die zentrale Herausforderung: Bevor der Focus gesetzt werden kann, müssen die Adopted Style Sheets geladen und angewendet sein. Ohne diese Absicherung kann es zu Race Conditions kommen — der Focus wird auf ein Element gesetzt, das noch nicht vollständig gerendert ist. |
| 10 | + |
| 11 | +## Architektur |
| 12 | + |
| 13 | +### Zwei Varianten der Focus-Delegation |
| 14 | + |
| 15 | +Es gibt zwei Varianten, je nach Komponentenaufbau: |
| 16 | + |
| 17 | +**Variante A: Shadow → Light DOM WC → HTML5-Element** (z. B. `kol-button`) |
| 18 | + |
| 19 | +``` |
| 20 | +┌─────────────────────────────────────────────────────────┐ |
| 21 | +│ Shadow Component: kol-button (shadow: true) │ |
| 22 | +│ focus() → delegateFocus(host, () => setFocus(wcRef)) │ |
| 23 | +└─────────────────────────┬───────────────────────────────┘ |
| 24 | + ↓ |
| 25 | +┌─────────────────────────────────────────────────────────┐ |
| 26 | +│ Light DOM Component: kol-button-wc (shadow: false) │ |
| 27 | +│ focus() → setFocus(buttonRef) │ |
| 28 | +└─────────────────────────┬───────────────────────────────┘ |
| 29 | + ↓ |
| 30 | +┌─────────────────────────────────────────────────────────┐ |
| 31 | +│ HTML5 Element: <button> │ |
| 32 | +│ Tatsächlich fokussierbar │ |
| 33 | +└─────────────────────────────────────────────────────────┘ |
| 34 | +``` |
| 35 | + |
| 36 | +**Variante B: Shadow → HTML5-Element direkt** (z. B. `kol-input-text`) |
| 37 | + |
| 38 | +``` |
| 39 | +┌─────────────────────────────────────────────────────────┐ |
| 40 | +│ Shadow Component: kol-input-text (shadow: true) │ |
| 41 | +│ focus() → delegateFocus(host, () => setFocus(inputRef)) │ |
| 42 | +└─────────────────────────┬───────────────────────────────┘ |
| 43 | + ↓ |
| 44 | +┌─────────────────────────────────────────────────────────┐ |
| 45 | +│ HTML5 Element: <input> │ |
| 46 | +│ Tatsächlich fokussierbar │ |
| 47 | +└─────────────────────────────────────────────────────────┘ |
| 48 | +``` |
| 49 | + |
| 50 | +### Das `data-themed`-Attribut |
| 51 | + |
| 52 | +Das Theme-System setzt das `data-themed`-Attribut auf Shadow-Komponenten, sobald die Adopted Style Sheets geladen sind. Der Ablauf: |
| 53 | + |
| 54 | +1. **Theme-Registrierung:** Beim Bootstrapping werden Themes über `register()` aus dem `adopted-style-sheets`-Paket registriert (`packages/components/src/core/bootstrap.ts`). |
| 55 | + |
| 56 | +2. **Style-Anwendung:** Stencils `setMode()`-Callback wird für jede Komponente aufgerufen (`packages/components/src/global/script.ts`). Dort wird `setThemeStyle(elm, getThemeDetails(elm))` aufgerufen, um die Adopted Style Sheets in den Shadow DOM zu injizieren. |
| 57 | + |
| 58 | +3. **Markierung:** `setThemeStyle()` setzt nach erfolgreicher Style-Anwendung das `data-themed`-Attribut auf dem Host-Element. |
| 59 | + |
| 60 | +## Utility Functions |
| 61 | + |
| 62 | +Datei: `packages/components/src/utils/element-focus.ts` |
| 63 | + |
| 64 | +### `delegateFocus(host, callback)` |
| 65 | + |
| 66 | +Zentrale Focus-Delegations-Funktion für **Shadow Components** (`shadow: true`). Wartet auf die Theme-Bereitschaft des Host-Elements und führt dann die Focus-Callback-Funktion aus. |
| 67 | + |
| 68 | +```typescript |
| 69 | +export async function delegateFocus(host: HTMLElement, callback: () => Promise<void>): Promise<void> { |
| 70 | + try { |
| 71 | + if (!host.hasAttribute('data-themed')) { |
| 72 | + await waitForThemed(host); |
| 73 | + } |
| 74 | + await callback(); |
| 75 | + } catch { |
| 76 | + throw new Error( |
| 77 | + `The interactive element inside the KoliBri web component could not be focused. Try calling the focus method on the web component after a short delay again.`, |
| 78 | + ); |
| 79 | + } |
| 80 | +} |
| 81 | +``` |
| 82 | + |
| 83 | +**Parameter:** |
| 84 | + |
| 85 | +- `host` — Das Shadow Component Host-Element (`this.host!`) |
| 86 | +- `callback` — Async Funktion, die `setFocus()` auf dem Ziel-Element aufruft |
| 87 | + |
| 88 | +**Verhalten:** |
| 89 | + |
| 90 | +1. Prüft, ob `data-themed` bereits gesetzt ist |
| 91 | +2. Falls nicht: wartet über `waitForThemed()` (MutationObserver) bis maximal 5 Sekunden |
| 92 | +3. Ruft dann den `callback` auf, der den eigentlichen Focus setzt |
| 93 | +4. Bei Fehler (Timeout oder Focus-Fehler): wirft einen benutzerfreundlichen Fehler |
| 94 | + |
| 95 | +### `setFocus(element)` |
| 96 | + |
| 97 | +Fokussiert ein HTML-Element durch wiederholte Versuche pro Animation Frame. Wird sowohl in Shadow- als auch in Light-DOM-Komponenten verwendet. |
| 98 | + |
| 99 | +```typescript |
| 100 | +export async function setFocus(element: HTMLElement): Promise<void> { |
| 101 | + let attempts = 0; |
| 102 | + do { |
| 103 | + if (element) { |
| 104 | + element.focus(); |
| 105 | + } |
| 106 | + await new Promise((r) => requestAnimationFrame(r)); |
| 107 | + attempts++; |
| 108 | + } while (!isActiveElement(element) && attempts < MAX_FOCUS_ATTEMPTS); |
| 109 | +} |
| 110 | +``` |
| 111 | + |
| 112 | +**Verhalten:** |
| 113 | + |
| 114 | +- Ruft `element.focus()` auf und prüft pro Animation Frame, ob das Element fokussiert ist |
| 115 | +- Maximal `MAX_FOCUS_ATTEMPTS` (10) Versuche |
| 116 | +- Nutzt `isActiveElement()` für korrekte Focus-Erkennung innerhalb von Shadow DOMs |
| 117 | + |
| 118 | +### `isActiveElement(element)` (intern) |
| 119 | + |
| 120 | +Prüft, ob ein Element aktuell fokussiert ist. Berücksichtigt dabei korrekt die Shadow-DOM-Grenze. |
| 121 | + |
| 122 | +```typescript |
| 123 | +function isActiveElement(element: HTMLElement): boolean { |
| 124 | + const root = element.getRootNode(); |
| 125 | + if (root instanceof ShadowRoot) { |
| 126 | + return root.activeElement === element; |
| 127 | + } |
| 128 | + return document.activeElement === element; |
| 129 | +} |
| 130 | +``` |
| 131 | + |
| 132 | +**Warum nötig:** `document.activeElement` zeigt bei fokussierten Elementen innerhalb eines Shadow DOM nur den Shadow Host, nicht das tatsächlich fokussierte Element. Daher wird `shadowRoot.activeElement` geprüft. |
| 133 | + |
| 134 | +### `waitForThemed(host)` (intern) |
| 135 | + |
| 136 | +Wartet per MutationObserver darauf, dass das `data-themed`-Attribut auf dem Host-Element gesetzt wird. |
| 137 | + |
| 138 | +```typescript |
| 139 | +function waitForThemed(host: HTMLElement): Promise<void> { |
| 140 | + return new Promise<void>((resolve, reject) => { |
| 141 | + const observer = new MutationObserver(() => { |
| 142 | + if (host.hasAttribute('data-themed')) { |
| 143 | + clearTimeout(timeoutId); |
| 144 | + observer.disconnect(); |
| 145 | + resolve(); |
| 146 | + } |
| 147 | + }); |
| 148 | + |
| 149 | + const timeoutId = setTimeout(() => { |
| 150 | + observer.disconnect(); |
| 151 | + reject(new Error('Timeout waiting for data-themed attribute')); |
| 152 | + }, MAX_TIMEOUT_DURATION); |
| 153 | + |
| 154 | + observer.observe(host, { |
| 155 | + attributes: true, |
| 156 | + attributeFilter: ['data-themed'], |
| 157 | + }); |
| 158 | + }); |
| 159 | +} |
| 160 | +``` |
| 161 | + |
| 162 | +**Verhalten:** |
| 163 | + |
| 164 | +- Beobachtet Attribut-Änderungen auf dem Host-Element |
| 165 | +- Resolved sofort, wenn `data-themed` gesetzt wird |
| 166 | +- Timeout nach `MAX_TIMEOUT_DURATION` (5000 ms) mit Error |
| 167 | + |
| 168 | +### Konstanten |
| 169 | + |
| 170 | +```typescript |
| 171 | +const MAX_FOCUS_ATTEMPTS = 10; |
| 172 | +const MAX_TIMEOUT_DURATION = 5000; |
| 173 | +``` |
| 174 | + |
| 175 | +## Umsetzung in Komponenten |
| 176 | + |
| 177 | +### Interface |
| 178 | + |
| 179 | +Alle fokussierbaren Komponenten implementieren das `FocusableElement`-Interface: |
| 180 | + |
| 181 | +```typescript |
| 182 | +export interface FocusableElement { |
| 183 | + focus(): Promise<void>; |
| 184 | +} |
| 185 | +``` |
| 186 | + |
| 187 | +### Regel 1: Shadow Component (`shadow: true`) |
| 188 | + |
| 189 | +**Immer** `delegateFocus()` mit `setFocus()` verwenden. |
| 190 | + |
| 191 | +#### Variante A: Mit innerem WC-Element |
| 192 | + |
| 193 | +Wenn die Shadow-Komponente eine Light-DOM-Komponente (`-wc`) rendert: |
| 194 | + |
| 195 | +```typescript |
| 196 | +@Component({ tag: 'kol-button', shadow: true }) |
| 197 | +export class KolButton implements ButtonProps, FocusableElement { |
| 198 | + @Element() private readonly host?: HTMLKolButtonElement; |
| 199 | + private buttonWcRef?: HTMLKolButtonWcElement; |
| 200 | + |
| 201 | + private readonly setButtonWcRef = (ref?: HTMLKolButtonWcElement) => { |
| 202 | + this.buttonWcRef = ref; |
| 203 | + }; |
| 204 | + |
| 205 | + @Method() |
| 206 | + public async focus(): Promise<void> { |
| 207 | + return delegateFocus(this.host!, () => setFocus(this.buttonWcRef!)); |
| 208 | + } |
| 209 | + |
| 210 | + public render(): JSX.Element { |
| 211 | + return ( |
| 212 | + <KolButtonWcTag ref={this.setButtonWcRef} /* ...props */> |
| 213 | + <slot name="expert" slot="expert"></slot> |
| 214 | + </KolButtonWcTag> |
| 215 | + ); |
| 216 | + } |
| 217 | +} |
| 218 | +``` |
| 219 | + |
| 220 | +Weitere Beispiele: `kol-link`, `kol-button-link`, `kol-link-button`, `kol-select`, `kol-accordion`, `kol-details` |
| 221 | + |
| 222 | +#### Variante B: Direkt auf HTML5-Element |
| 223 | + |
| 224 | +Wenn die Shadow-Komponente das fokussierbare HTML5-Element direkt rendert (ohne `-wc`-Zwischenschicht): |
| 225 | + |
| 226 | +```typescript |
| 227 | +@Component({ tag: 'kol-input-text', shadow: true }) |
| 228 | +export class KolInputText implements InputTextAPI, FocusableElement { |
| 229 | + @Element() private readonly host?: HTMLKolInputTextElement; |
| 230 | + private inputRef?: HTMLInputElement; |
| 231 | + |
| 232 | + private readonly setInputRef = (ref?: HTMLInputElement) => { |
| 233 | + this.inputRef = ref; |
| 234 | + }; |
| 235 | + |
| 236 | + @Method() |
| 237 | + public async focus() { |
| 238 | + return delegateFocus(this.host!, () => setFocus(this.inputRef!)); |
| 239 | + } |
| 240 | +} |
| 241 | +``` |
| 242 | + |
| 243 | +Weitere Beispiele: `kol-input-email`, `kol-input-number`, `kol-input-file`, `kol-textarea`, `kol-combobox`, `kol-single-select`, `kol-input-radio` |
| 244 | + |
| 245 | +### Regel 2: Light DOM Component (`shadow: false`) |
| 246 | + |
| 247 | +**Nur** `setFocus()` verwenden. Kein `delegateFocus()` nötig, da kein Shadow DOM vorhanden ist und das Theme-System nicht abgewartet werden muss. |
| 248 | + |
| 249 | +```typescript |
| 250 | +@Component({ tag: 'kol-button-wc', shadow: false }) |
| 251 | +export class KolButtonWc implements ButtonAPI, FocusableElement { |
| 252 | + private buttonRef?: HTMLButtonElement; |
| 253 | + |
| 254 | + private readonly setButtonRef = (ref?: HTMLButtonElement) => { |
| 255 | + this.buttonRef = ref; |
| 256 | + }; |
| 257 | + |
| 258 | + @Method() |
| 259 | + public async focus(): Promise<void> { |
| 260 | + return setFocus(this.buttonRef!); |
| 261 | + } |
| 262 | + |
| 263 | + public render(): JSX.Element { |
| 264 | + return ( |
| 265 | + <Host> |
| 266 | + <button ref={this.setButtonRef}> |
| 267 | + {/* content */} |
| 268 | + </button> |
| 269 | + </Host> |
| 270 | + ); |
| 271 | + } |
| 272 | +} |
| 273 | +``` |
| 274 | + |
| 275 | +### Zusammenfassung der Regeln |
| 276 | + |
| 277 | +| Komponente | `shadow` | Focus-Methode | |
| 278 | +| ------------------- | -------- | ------------------------------------------------ | |
| 279 | +| Shadow Component | `true` | `delegateFocus(this.host!, () => setFocus(ref))` | |
| 280 | +| Light DOM Component | `false` | `setFocus(ref)` | |
| 281 | + |
| 282 | +**Wichtig:** |
| 283 | + |
| 284 | +- `delegateFocus` darf **nur** in `shadow: true`-Komponenten verwendet werden |
| 285 | +- `setFocus` wird **in beiden Fällen** als eigentliche Focus-Funktion genutzt |
| 286 | +- Ref-Callbacks speichern die Referenz zum Ziel-Element (`ref={this.setXxxRef}`) |
| 287 | +- Alle `focus()`-Methoden sind `async` und geben `Promise<void>` zurück |
| 288 | +- Die `@Method()`-Dekorator macht die Methode auf dem Custom Element aufrufbar |
0 commit comments