Skip to content

Commit f2711ce

Browse files
committed
feat: Enhance focus management across components
- Added `focus` methods to various components to improve accessibility and user experience. - Implemented `delegateFocus` utility to ensure elements are focused after they are fully initialized and styled. - Updated components including `KolLink`, `KolPopoverButton`, `KolSelect`, `KolSingleSelect`, `KolSkipNav`, `KolSplitButton`, `KolTabs`, `KolTextarea`, `KolToolbar`, `KolTree`, and `KolTreeItem` to utilize the new focus management. - Refactored tests and snapshots to validate the new focus behavior. - Introduced new visual test routes for components with focus capabilities.
1 parent 8bc7100 commit f2711ce

File tree

46 files changed

+534
-212
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+534
-212
lines changed

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,10 @@ export class KolAccordion implements AccordionAPI, FocusableElement {
5252
* Sets focus on the internal element.
5353
*/
5454
@Method()
55-
public async focus() {
56-
return Promise.resolve(this.buttonWcRef?.focus());
55+
public async focus(): Promise<void> {
56+
if (this.host) {
57+
await this.buttonWcRef?.focus(this.host);
58+
}
5759
}
5860

5961
private handleOnClick = (event: MouseEvent) => {

packages/components/src/components/alert/component.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export class KolAlertWc implements AlertAPI {
2929
private readonly close = () => {
3030
this._on?.onClose?.(new Event('Close'));
3131
if (this.host) {
32-
dispatchDomEvent(this.host as HTMLElement, KolEvent.close);
32+
dispatchDomEvent(this.host, KolEvent.close);
3333
}
3434
};
3535

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Component, h, Method, Prop, State, Watch } from '@stencil/core';
1+
import { Component, Element, h, Method, Prop, State, Watch } from '@stencil/core';
22
import { SpanFC } from '../../internal/functional-components/span/component';
33
import type { BadgeAPI, BadgeStates, FocusableElement, InternalButtonProps, KoliBriIconsProp, LabelPropType, PropColor, Stringified } from '../../schema';
44
import { featureHint, handleColorChange, objectObjectHandler, parseJson, setState, validateColor, validateIcons } from '../../schema';
@@ -22,6 +22,7 @@ featureHint(`[KolBadge] Optimierung des _color-Properties (rgba, rgb, hex usw.).
2222
shadow: true,
2323
})
2424
export class KolBadge implements BadgeAPI, FocusableElement {
25+
@Element() private readonly host?: HTMLKolBadgeElement;
2526
private bgColorStr = '#000';
2627
private colorStr = '#fff';
2728
private readonly id = nonce();
@@ -56,7 +57,9 @@ export class KolBadge implements BadgeAPI, FocusableElement {
5657
*/
5758
@Method()
5859
public async focus(): Promise<void> {
59-
return Promise.resolve(this.smartButtonRef?.focus());
60+
if (this.host) {
61+
return this.smartButtonRef?.focus(this.host);
62+
}
6063
}
6164

6265
public render(): JSX.Element {

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { JSX } from '@stencil/core';
2-
import { Component, h, Method, Prop } from '@stencil/core';
2+
import { Component, Element, h, Method, Prop } from '@stencil/core';
33
import { KolButtonWcTag } from '../../core/component-names';
44
import type {
55
AccessKeyPropType,
@@ -41,6 +41,7 @@ import type {
4141
shadow: true,
4242
})
4343
export class KolButtonLink implements ButtonLinkProps, FocusableElement {
44+
@Element() private readonly host?: HTMLKolButtonLinkElement;
4445
private buttonWcRef?: HTMLKolButtonWcElement;
4546

4647
private readonly catchRef = (ref?: HTMLKolButtonWcElement) => {
@@ -60,8 +61,10 @@ export class KolButtonLink implements ButtonLinkProps, FocusableElement {
6061
* Sets focus on the internal element.
6162
*/
6263
@Method()
63-
public async focus() {
64-
return Promise.resolve(this.buttonWcRef?.focus());
64+
public async focus(): Promise<void> {
65+
if (this.host) {
66+
await this.buttonWcRef?.focus(this.host);
67+
}
6568
}
6669

6770
public render(): JSX.Element {

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

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import type {
1212
ButtonTypePropType,
1313
CustomClassPropType,
1414
DisabledPropType,
15-
FocusableElement,
1615
HideLabelPropType,
1716
IconsPropType,
1817
IdPropType,
@@ -56,6 +55,7 @@ import { SpanFC } from '../../internal/functional-components/span/component';
5655
import type { AriaHasPopupPropType } from '../../schema/props/aria-has-popup';
5756
import { validateAccessAndShortKey } from '../../schema/validators/access-and-short-key';
5857
import clsx from '../../utils/clsx';
58+
import { delegateFocus } from '../../utils/element-focus';
5959
import { dispatchDomEvent, KolEvent } from '../../utils/events';
6060
import { propagateResetEventToForm, propagateSubmitEventToForm } from '../form/controller';
6161
import { AssociatedInputController } from '../input-adapter-leanup/associated.controller';
@@ -67,7 +67,7 @@ import { AssociatedInputController } from '../input-adapter-leanup/associated.co
6767
tag: 'kol-button-wc',
6868
shadow: false,
6969
})
70-
export class KolButtonWc implements ButtonAPI, FocusableElement {
70+
export class KolButtonWc implements ButtonAPI {
7171
@Element() private readonly host?: HTMLKolButtonWcElement;
7272
private buttonRef?: HTMLButtonElement;
7373
private tooltipRef?: HTMLKolTooltipWcElement;
@@ -76,13 +76,8 @@ export class KolButtonWc implements ButtonAPI, FocusableElement {
7676
* Sets focus on the internal element.
7777
*/
7878
@Method()
79-
public async focus() {
80-
return new Promise<void>((resolve) => {
81-
requestAnimationFrame(() => {
82-
this.buttonRef?.focus();
83-
resolve();
84-
});
85-
});
79+
public async focus(host: HTMLElement): Promise<void> {
80+
await delegateFocus(host, this.buttonRef);
8681
}
8782

8883
private readonly hideTooltip = () => {
@@ -118,14 +113,14 @@ export class KolButtonWc implements ButtonAPI, FocusableElement {
118113
}
119114

120115
if (this.host) {
121-
dispatchDomEvent(this.host, KolEvent.click, this.state._value);
116+
dispatchDomEvent(this.host as HTMLElement, KolEvent.click, this.state._value);
122117
}
123118
};
124119

125120
private readonly onMouseDown = (event: MouseEvent) => {
126121
this.state?._on?.onMouseDown?.(event);
127122
if (this.host) {
128-
dispatchDomEvent(this.host, KolEvent.mousedown);
123+
dispatchDomEvent(this.host as HTMLElement, KolEvent.mousedown);
129124
}
130125
};
131126

@@ -320,7 +315,7 @@ export class KolButtonWc implements ButtonAPI, FocusableElement {
320315
};
321316

322317
public constructor() {
323-
this.controller = new AssociatedInputController(this, 'button', this.host);
318+
this.controller = new AssociatedInputController(this, 'button', this.host as HTMLElement);
324319
}
325320

326321
@Watch('_accessKey')

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { JSX } from '@stencil/core';
2-
import { Component, h, Method, Prop } from '@stencil/core';
2+
import { Component, Element, h, Method, Prop } from '@stencil/core';
33
import { KolButtonWcTag } from '../../core/component-names';
44
import type {
55
AccessKeyPropType,
@@ -33,6 +33,7 @@ import type {
3333
shadow: true,
3434
})
3535
export class KolButton implements ButtonProps, FocusableElement {
36+
@Element() private readonly host?: HTMLKolButtonElement;
3637
private buttonWcRef?: HTMLKolButtonWcElement;
3738

3839
private readonly catchRef = (ref?: HTMLKolButtonWcElement) => {
@@ -52,8 +53,10 @@ export class KolButton implements ButtonProps, FocusableElement {
5253
* Sets focus on the internal element.
5354
*/
5455
@Method()
55-
public async focus() {
56-
return Promise.resolve(this.buttonWcRef?.focus());
56+
public async focus(): Promise<void> {
57+
if (this.host) {
58+
await this.buttonWcRef?.focus(this.host);
59+
}
5760
}
5861

5962
public render(): JSX.Element {

packages/components/src/components/combobox/shadow.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
ComboboxAPI,
1515
ComboboxStates,
1616
DisabledPropType,
17+
FocusableElement,
1718
HideLabelPropType,
1819
HideMsgPropType,
1920
HintPropType,
@@ -34,6 +35,7 @@ import type {
3435
import type { EventDetail } from '../../schema/interfaces/EventDetail';
3536
import clsx from '../../utils/clsx';
3637
import { nonce } from '../../utils/dev.utils';
38+
import { delegateFocus } from '../../utils/element-focus';
3739
import { ComboboxController } from './controller';
3840

3941
/**
@@ -46,8 +48,8 @@ import { ComboboxController } from './controller';
4648
},
4749
shadow: true,
4850
})
49-
export class KolCombobox implements ComboboxAPI {
50-
@Element() private readonly host?: HTMLElement;
51+
export class KolCombobox implements ComboboxAPI, FocusableElement {
52+
@Element() private readonly host?: HTMLKolComboboxElement;
5153
private refInput?: HTMLInputElement;
5254
private refSuggestions: HTMLLIElement[] = [];
5355
private _focusedOptionIndex: number = -1;
@@ -67,12 +69,7 @@ export class KolCombobox implements ComboboxAPI {
6769
*/
6870
@Method()
6971
public async focus() {
70-
return new Promise<void>((resolve) => {
71-
requestAnimationFrame(() => {
72-
this.refInput?.focus();
73-
resolve();
74-
});
75-
});
72+
await delegateFocus(this.host, this.refInput);
7673
}
7774

7875
private toggleListbox = () => {

packages/components/src/components/details/shadow.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,10 @@ export class KolDetails implements DetailsAPI, FocusableElement {
3838
* Sets focus on the internal element.
3939
*/
4040
@Method()
41-
public async focus() {
42-
return Promise.resolve(this.buttonWcRef?.focus());
41+
public async focus(): Promise<void> {
42+
if (this.host) {
43+
await this.buttonWcRef?.focus(this.host);
44+
}
4345
}
4446

4547
private toggleTimeout?: ReturnType<typeof setTimeout>;

packages/components/src/components/form/shadow.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { dispatchDomEvent, KolEvent } from '../../utils/events';
2222
shadow: true,
2323
})
2424
export class KolForm implements FormAPI {
25-
@Element() private readonly host?: HTMLKolTextareaElement;
25+
@Element() private readonly host?: HTMLKolFormElement;
2626
errorListBlock?: HTMLElement;
2727
errorListFirstLink?: HTMLElement;
2828
private readonly translateErrorListMessage = translate('kol-error-list-message');
@@ -61,7 +61,7 @@ export class KolForm implements FormAPI {
6161

6262
private readonly setBlockElement = (el?: HTMLElement) => (this.errorListBlock = el);
6363

64-
private readonly setFirstLinkElement = (el?: HTMLElement) => (this.errorListFirstLink = el);
64+
private readonly setFirstLinkElement = (el?: HTMLKolLinkWcElement) => (this.errorListFirstLink = el as HTMLElement);
6565

6666
private renderErrorList(errorList?: ErrorListPropType[]): JSX.Element {
6767
return (

packages/components/src/components/input-checkbox/input-checkbox.e2e.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,52 @@ test.describe(COMPONENT_NAME, () => {
2828
await fillAction(page);
2929
expect(await getCheckedProperty()).toBe(true);
3030
});
31+
32+
test(`should set focus on the internal input element via focus() method`, async ({ page }) => {
33+
await page.setContent(`<kol-input-checkbox _label="Checkbox"></kol-input-checkbox>`);
34+
35+
const component = page.locator(COMPONENT_NAME);
36+
const input = page.locator('input');
37+
38+
// Call the focus() method on the component
39+
await component.evaluate((element: HTMLKolInputCheckboxElement) => element.focus());
40+
41+
// Verify the input element has focus
42+
const isFocused = await input.evaluate((el) => el === document.activeElement || (el.getRootNode() as ShadowRoot | Document).activeElement === el);
43+
expect(isFocused).toBe(true);
44+
});
45+
46+
test(`should delegate focus to internal input when clicking on host element (delegatesFocus)`, async ({ page }) => {
47+
await page.setContent(`<button>Before</button><kol-input-checkbox _label="Checkbox"></kol-input-checkbox><button>After</button>`);
48+
49+
const component = page.locator(COMPONENT_NAME);
50+
const input = page.locator('input');
51+
52+
// Click on the component host element
53+
await component.click();
54+
await page.waitForChanges();
55+
56+
// Verify the internal input has focus
57+
const isFocused = await input.evaluate((el) => el === document.activeElement || (el.getRootNode() as ShadowRoot | Document).activeElement === el);
58+
expect(isFocused).toBe(true);
59+
});
60+
61+
test(`should allow Tab navigation to reach internal input (delegatesFocus)`, async ({ page }) => {
62+
await page.setContent(`<button>Before</button><kol-input-checkbox _label="Checkbox"></kol-input-checkbox><button>After</button>`);
63+
64+
const beforeButton = page.locator('button').first();
65+
const input = page.locator('input');
66+
67+
// Focus on the "Before" button
68+
await beforeButton.focus();
69+
await page.waitForChanges();
70+
71+
// Press Tab to move to checkbox
72+
await page.keyboard.press('Tab');
73+
await page.waitForChanges();
74+
75+
// Verify the internal input has focus (delegatesFocus should make host focusable)
76+
const isFocused = await input.evaluate((el) => el === document.activeElement || (el.getRootNode() as ShadowRoot | Document).activeElement === el);
77+
expect(isFocused).toBe(true);
78+
});
3179
});

0 commit comments

Comments
 (0)