Skip to content

Commit c237e6c

Browse files
committed
refactor: update focus handling in components to use delegateFocus and setFocus utilities
- Replaced direct focus handling with delegateFocus in multiple components (e.g., KolCombobox, KolDetails, KolInputCheckbox, etc.) to ensure proper focus management after theming. - Updated ref callback methods to use a consistent naming convention (setInputRef, setButtonWcRef, etc.) across components. - Improved focus handling logic to include error handling and timeout management in the delegateFocus utility. - Ensured all components properly handle focus delegation to enhance accessibility and user experience.
1 parent 42360a5 commit c237e6c

File tree

33 files changed

+412
-186
lines changed

33 files changed

+412
-186
lines changed

FOCUS_PROPAGATION_CONCEPT.md

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
# Focus Propagation Concept
2+
3+
Definiert den fokussierbaren Durchsatz durch die Schichten: Shadow → Light DOM → HTML5.
4+
5+
## Utility Functions (`element-focus.ts`)
6+
7+
### `waitForThemed(host: HTMLElement): Promise<void>`
8+
9+
Wartet darauf, dass das Theme-System das Host-Element als bereit markiert (`data-themed`-Attribut).
10+
11+
```typescript
12+
function waitForThemed(host: HTMLElement): Promise<void> {
13+
const observed = new Promise<void>((resolve) => {
14+
const observer = new MutationObserver(() => {
15+
observer.disconnect();
16+
resolve();
17+
});
18+
observer.observe(host, {
19+
attributes: true,
20+
attributeFilter: ['data-themed'],
21+
});
22+
});
23+
24+
const timeout = new Promise<void>((_, reject) => {
25+
setTimeout(() => reject(new Error('Timeout waiting for data-themed attribute')), 5000);
26+
});
27+
28+
return Promise.race([observed, timeout]);
29+
}
30+
```
31+
32+
**Zweck:**
33+
34+
- Verhindert Race Conditions zwischen Shadow DOM Rendering und Focus-Setting
35+
- Timeout nach 5s falls Styling nie komplett wird
36+
- Nur für Shadow Components notwendig
37+
38+
---
39+
40+
### `delegateFocus(host: HTMLElement, callback: () => Promise<void>): Promise<void>`
41+
42+
Zentrale Focus-Delegations-Methode für Shadow Components.
43+
44+
```typescript
45+
export async function delegateFocus(host: HTMLElement, callback: () => Promise<void>): Promise<void> {
46+
try {
47+
if (!host.hasAttribute('data-themed')) {
48+
await waitForThemed(host);
49+
}
50+
await callback();
51+
} catch {
52+
throw new Error(
53+
`The interactive element inside the KoliBri web compontent could not be focused. Try calling the focus method on the web component after a short delay again.`,
54+
);
55+
}
56+
}
57+
```
58+
59+
**Verwendung:**
60+
61+
```typescript
62+
// In Shadow Component @Method()
63+
public async focus(): Promise<void> {
64+
return delegateFocus(this.host!, async () => this.innerWcRef?.focus?.());
65+
}
66+
```
67+
68+
**Signatur:**
69+
70+
- `host: HTMLElement` — Das Shadow Component Host-Element (Required)
71+
- `callback: () => Promise<void>` — Async Funktion die den inneren Element fokussiert
72+
73+
---
74+
75+
### `setFocus(element: HTMLElement): Promise<void>`
76+
77+
Fallback-Methode die ein Element durch wiederholte RAF-Polls fokussiert.
78+
79+
```typescript
80+
export async function setFocus(element: HTMLElement): Promise<void> {
81+
let attempts = 0;
82+
do {
83+
if (element) {
84+
element.focus();
85+
}
86+
await new Promise((r) => requestAnimationFrame(r));
87+
attempts++;
88+
} while (document.activeElement !== element && attempts < 10);
89+
}
90+
```
91+
92+
---
93+
94+
## Architektur-Schichten
95+
96+
Das Kolibri-Component-System besteht aus drei Schichten für Focus-Delegation:
97+
98+
```
99+
┌──────────────────────────────────────────────┐
100+
│ Shadow Component (z.B. `kol-button`) │
101+
│ - shadow: true │
102+
│ - @Method() focus() → delegateFocus() nutzen │
103+
└──────────────────────────────────────────────┘
104+
105+
┌──────────────────────────────────────────────┐
106+
│ Light DOM Component (z.B. `kol-button-wc`) │
107+
│ - shadow: false │
108+
│ - @Method() focus() → direktes Fokussieren │
109+
│ - hält Ref zum HTML5-Element │
110+
└──────────────────────────────────────────────┘
111+
112+
┌──────────────────────────────────────────────┐
113+
│ HTML5 Element (z.B. `<button>`) │
114+
│ - tatsächlich fokussierbar │
115+
│ - ref über Stencil gespeichert │
116+
└──────────────────────────────────────────────┘
117+
```
118+
119+
## Focus-Ablauf
120+
121+
### 1. Shadow Component (`kol-button`)
122+
123+
```typescript
124+
@Component({ tag: 'kol-button', shadow: true })
125+
export class KolButton {
126+
@Element() private readonly host?: HTMLKolButtonElement;
127+
private buttonWcRef?: HTMLKolButtonWcElement;
128+
129+
@Method()
130+
public async focus(): Promise<void> {
131+
return delegateFocus(this.host!, async () => this.buttonWcRef?.focus?.());
132+
}
133+
134+
private readonly setButtonWcRef = (ref: HTMLKolButtonWcElement | null) => {
135+
this.buttonWcRef = ref || undefined;
136+
}
137+
138+
public render(): JSX.Element {
139+
return (
140+
<kol-button-wc ref={this.setButtonWcRef}>
141+
{/* props */}
142+
</kol-button-wc>
143+
);
144+
}
145+
}
146+
```
147+
148+
**Ablauf:**
149+
150+
1. Shadow Component `focus()` wird aufgerufen
151+
2. `delegateFocus()` wartet bis Host das `data-themed`-Attribut hat
152+
3. Dann ruft `callback()` die innere WC-Komponente auf: `this.buttonWcRef?.focus?.()`
153+
4. Fehler werden mit freundlichem Error-Text geworfen
154+
155+
### 2. Light DOM Component (`kol-button-wc`)
156+
157+
```typescript
158+
@Component({ tag: 'kol-button-wc', shadow: false })
159+
export class KolButtonWc {
160+
@Element() private readonly host?: HTMLKolButtonWcElement;
161+
private buttonRef?: HTMLButtonElement;
162+
163+
@Method()
164+
public async focus(): Promise<void> {
165+
return setFocus(this.buttonRef!);
166+
}
167+
168+
private readonly setButtonRef = (ref: HTMLButtonElement | null) => {
169+
this.buttonRef = ref || undefined;
170+
}
171+
172+
public render(): JSX.Element {
173+
return (
174+
<Host>
175+
<button ref={this.setButtonRef}>
176+
{/* content */}
177+
</button>
178+
</Host>
179+
);
180+
}
181+
}
182+
```
183+
184+
**Ablauf:**
185+
186+
1. Light DOM Component `focus()` wird aufgerufen
187+
2. Direktes Fokussieren des HTML5-Elements über Ref
188+
3. Keine `delegateFocus()` nötig (Light DOM ist sofort synchron verfügbar)
189+
4. `setFocus()` Promise
190+
191+
### 3. HTML5 Element
192+
193+
Das `<button>`-Element wird fokussiert:
194+
195+
```typescript
196+
this.buttonRef?.focus();
197+
```

packages/components/src/components/accordion/shadow.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
} from '../../schema';
1515
import { featureHint, validateAccordionCallbacks, validateDisabled, validateLabel, validateOpen } from '../../schema';
1616
import { nonce } from '../../utils/dev.utils';
17+
import { delegateFocus } from '../../utils/element-focus';
1718
import { dispatchDomEvent, KolEvent } from '../../utils/events';
1819
import { watchHeadingLevel } from '../heading/validation';
1920

@@ -44,18 +45,16 @@ export class KolAccordion implements AccordionAPI, FocusableElement {
4445
private readonly nonce = nonce();
4546
private buttonWcRef?: HTMLKolButtonWcElement;
4647

47-
private readonly catchRef = (ref?: HTMLKolButtonWcElement) => {
48-
this.buttonWcRef = ref;
48+
private readonly setButtonWcRef = (ref: HTMLKolButtonWcElement | null) => {
49+
this.buttonWcRef = ref || undefined;
4950
};
5051

5152
/**
5253
* Sets focus on the internal element.
5354
*/
5455
@Method()
5556
public async focus(): Promise<void> {
56-
if (this.host) {
57-
await this.buttonWcRef?.focus(this.host);
58-
}
57+
return delegateFocus(this.host!, async () => this.buttonWcRef?.focus?.());
5958
}
6059

6160
private handleOnClick = (event: MouseEvent) => {
@@ -89,7 +88,7 @@ export class KolAccordion implements AccordionAPI, FocusableElement {
8988
class: rootClass,
9089
HeadingProps: { class: `${rootClass}__heading` },
9190
HeadingButtonProps: {
92-
ref: this.catchRef,
91+
ref: this.setButtonWcRef,
9392
class: `${rootClass}__heading-button`,
9493
},
9594
ContentProps: {

packages/components/src/components/badge/shadow.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { nonce } from '../../utils/dev.utils';
88
import type { JSX } from '@stencil/core';
99
import { KolButtonWcTag } from '../../core/component-names';
1010
import clsx from '../../utils/clsx';
11+
import { delegateFocus } from '../../utils/element-focus';
1112
featureHint(`[KolBadge] Optimierung des _color-Properties (rgba, rgb, hex usw.).`);
1213

1314
/**
@@ -28,14 +29,14 @@ export class KolBadge implements BadgeAPI, FocusableElement {
2829
private readonly id = nonce();
2930
private smartButtonRef?: HTMLKolButtonWcElement;
3031

31-
private readonly catchSmartButtonRef = (ref?: HTMLKolButtonWcElement) => {
32-
this.smartButtonRef = ref;
32+
private readonly setSmartButtonRef = (ref: HTMLKolButtonWcElement | null) => {
33+
this.smartButtonRef = ref || undefined;
3334
};
3435

3536
private renderSmartButton(props: InternalButtonProps): JSX.Element {
3637
return (
3738
<KolButtonWcTag
38-
ref={this.catchSmartButtonRef}
39+
ref={this.setSmartButtonRef}
3940
class="kol-badge__smart-button"
4041
_ariaControls={this.id}
4142
_ariaDescription={props._ariaDescription}
@@ -57,9 +58,7 @@ export class KolBadge implements BadgeAPI, FocusableElement {
5758
*/
5859
@Method()
5960
public async focus(): Promise<void> {
60-
if (this.host) {
61-
return this.smartButtonRef?.focus(this.host);
62-
}
61+
return delegateFocus(this.host!, async () => this.smartButtonRef?.focus?.());
6362
}
6463

6564
public render(): JSX.Element {

packages/components/src/components/button-link/shadow.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
TooltipAlignPropType,
2020
VariantClassNamePropType,
2121
} from '../../schema';
22+
import { delegateFocus } from '../../utils/element-focus';
2223

2324
/**
2425
* The **ButtonLink** component is semantically a button but has the appearance of a link. All relevant properties of the Button component are adopted and extended with the design-defining properties of a link.
@@ -44,8 +45,8 @@ export class KolButtonLink implements ButtonLinkProps, FocusableElement {
4445
@Element() private readonly host?: HTMLKolButtonLinkElement;
4546
private buttonWcRef?: HTMLKolButtonWcElement;
4647

47-
private readonly catchRef = (ref?: HTMLKolButtonWcElement) => {
48-
this.buttonWcRef = ref;
48+
private readonly setButtonWcRef = (ref: HTMLKolButtonWcElement | null) => {
49+
this.buttonWcRef = ref || undefined;
4950
};
5051

5152
/**
@@ -62,15 +63,13 @@ export class KolButtonLink implements ButtonLinkProps, FocusableElement {
6263
*/
6364
@Method()
6465
public async focus(): Promise<void> {
65-
if (this.host) {
66-
await this.buttonWcRef?.focus(this.host);
67-
}
66+
return delegateFocus(this.host!, async () => this.buttonWcRef?.focus?.());
6867
}
6968

7069
public render(): JSX.Element {
7170
return (
7271
<KolButtonWcTag
73-
ref={this.catchRef}
72+
ref={this.setButtonWcRef}
7473
_accessKey={this._accessKey}
7574
_ariaControls={this._ariaControls}
7675
_ariaDescription={this._ariaDescription}

packages/components/src/components/button/component.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ import { SpanFC } from '../../internal/functional-components/span/component';
5555
import type { AriaHasPopupPropType } from '../../schema/props/aria-has-popup';
5656
import { validateAccessAndShortKey } from '../../schema/validators/access-and-short-key';
5757
import clsx from '../../utils/clsx';
58-
import { delegateFocus } from '../../utils/element-focus';
5958
import { dispatchDomEvent, KolEvent } from '../../utils/events';
59+
import { setFocus } from '../../utils/element-focus';
6060
import { propagateResetEventToForm, propagateSubmitEventToForm } from '../form/controller';
6161
import { AssociatedInputController } from '../input-adapter-leanup/associated.controller';
6262

@@ -76,10 +76,18 @@ export class KolButtonWc implements ButtonAPI {
7676
* Sets focus on the internal element.
7777
*/
7878
@Method()
79-
public async focus(host: HTMLElement): Promise<void> {
80-
await delegateFocus(host, this.buttonRef);
79+
public async focus(): Promise<void> {
80+
return setFocus(this.buttonRef!);
8181
}
8282

83+
private readonly setButtonRef = (ref: HTMLButtonElement | null) => {
84+
this.buttonRef = ref || undefined;
85+
};
86+
87+
private readonly setTooltipRef = (ref: HTMLKolTooltipWcElement | null) => {
88+
this.tooltipRef = ref || undefined;
89+
};
90+
8391
private readonly hideTooltip = () => {
8492
void this.tooltipRef?.hideTooltip();
8593
};
@@ -134,7 +142,7 @@ export class KolButtonWc implements ButtonAPI {
134142
return (
135143
<Host>
136144
<button
137-
ref={(ref) => (this.buttonRef = ref)}
145+
ref={this.setButtonRef}
138146
accessKey={this.state._accessKey}
139147
aria-controls={this.state._ariaControls}
140148
aria-description={ariaDescription || undefined}
@@ -166,7 +174,7 @@ export class KolButtonWc implements ButtonAPI {
166174
</button>
167175
{hideLabel && (
168176
<KolTooltipWcTag
169-
ref={(ref) => (this.tooltipRef = ref)}
177+
ref={this.setTooltipRef}
170178
/**
171179
* Dieses Aria-Hidden verhindert das doppelte Vorlesen des Labels,
172180
* verhindert aber nicht das Aria-Labelledby vorgelesen wird.

packages/components/src/components/button/shadow.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
SyncValueBySelectorPropType,
2020
TooltipAlignPropType,
2121
} from '../../schema';
22+
import { delegateFocus } from '../../utils/element-focus';
2223

2324
/**
2425
* The **Button** component is used to present users with action options and arrange them in a clear hierarchy. It helps users find the most important actions on a page or within a viewport and allows them to execute those actions. The button label clearly indicates which action will be triggered. Buttons allow users to confirm a change, complete steps in a task, or make decisions.
@@ -36,8 +37,8 @@ export class KolButton implements ButtonProps, FocusableElement {
3637
@Element() private readonly host?: HTMLKolButtonElement;
3738
private buttonWcRef?: HTMLKolButtonWcElement;
3839

39-
private readonly catchRef = (ref?: HTMLKolButtonWcElement) => {
40-
this.buttonWcRef = ref;
40+
private readonly setButtonWcRef = (ref: HTMLKolButtonWcElement | null) => {
41+
this.buttonWcRef = ref || undefined;
4142
};
4243

4344
/**
@@ -54,15 +55,13 @@ export class KolButton implements ButtonProps, FocusableElement {
5455
*/
5556
@Method()
5657
public async focus(): Promise<void> {
57-
if (this.host) {
58-
await this.buttonWcRef?.focus(this.host);
59-
}
58+
return delegateFocus(this.host!, async () => this.buttonWcRef?.focus?.());
6059
}
6160

6261
public render(): JSX.Element {
6362
return (
6463
<KolButtonWcTag
65-
ref={this.catchRef}
64+
ref={this.setButtonWcRef}
6665
_accessKey={this._accessKey}
6766
_ariaControls={this._ariaControls}
6867
_ariaDescription={this._ariaDescription}

0 commit comments

Comments
 (0)