Skip to content

Commit

Permalink
fix(slider): Focus handle on keyboard interaction (AmadeusITGroup#1071)
Browse files Browse the repository at this point in the history
  • Loading branch information
MarkoOleksiyenko authored Jan 7, 2025
1 parent f5f42f9 commit 13d2026
Show file tree
Hide file tree
Showing 4 changed files with 106 additions and 31 deletions.
42 changes: 40 additions & 2 deletions angular/bootstrap/src/components/slider/slider.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ import {
Directive,
TemplateRef,
ViewEncapsulation,
afterRenderEffect,
contentChild,
forwardRef,
inject,
input,
output,
signal,
viewChild,
contentChild,
} from '@angular/core';
import {NG_VALUE_ACCESSOR} from '@angular/forms';
import {callWidgetFactory} from '../../config';
Expand Down Expand Up @@ -56,12 +58,48 @@ export class SliderHandleDirective {
imports: [UseDirective, SliderHandleDirective],
template: `
<ng-template auSliderHandle #handle let-state="state" let-directives="directives" let-item="item">
<button [auUse]="[directives.handleDirective, {item}]">&nbsp;</button>
<button [auUse]="[directives.handleDirective, {item}]" (keydown)="onKeyDown($event)">&nbsp;</button>
</ng-template>
`,
})
class SliderDefaultHandleSlotComponent {
readonly handle = viewChild.required<TemplateRef<SliderSlotHandleContext>>('handle');

/**
* When the element moves up the DOM Angluar removes the focus
* To avoid this we need to manually focus the element by calling the HTMLElement.focus
* after the change, afterRenderEffect is executed after each render of the component
* and when the signal inside is changed. On each key stroke we set the `refocus` signal
* to the element that needs to be focused and we put the `equal` function to always return
* `false` in order to trigger the change even when the element is the same
*/
readonly refocus = signal<HTMLElement | undefined>(undefined, {equal: () => false});

constructor() {
afterRenderEffect(() => {
this.refocus()?.focus();
});
}

/**
* Key handler that sets the refocus element only on the key strokes that move
* the element up the DOM
* @param event object containting key stroke and the target element
*/
onKeyDown(event: KeyboardEvent) {
switch (event.key) {
case 'ArrowDown':
case 'ArrowLeft':
case 'Home':
case 'ArrowUp':
case 'ArrowRight':
case 'End':
this.refocus.set(event.target as HTMLElement);
break;
default:
break;
}
}
}

/**
Expand Down
1 change: 0 additions & 1 deletion core/src/components/slider/slider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -726,7 +726,6 @@ export function createSlider(config?: PropsConfig<SliderProps>): SliderWidget {
return;
}
event.preventDefault();
event.stopPropagation();
}
},
mousedown: (event: MouseEvent) => {
Expand Down
56 changes: 29 additions & 27 deletions e2e/slider/slider.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,43 +334,17 @@ test.describe(`Slider tests`, () => {
expect((await sliderPO.sliderProgressState())[0]).toEqual('left: 40%; width: 43%; height: 100%;');
});

test(`should move handle on key strokes`, async ({page}) => {
test(`should move handle on key strokes and keep the focus`, async ({page}) => {
const sliderPO = new SliderPO(page, 1);

await page.goto('#/slider/range');
await sliderPO.locatorRoot.waitFor();

const expectedState = {...defaultExpectedHandleState};
assign(expectedState, [
{
...expectedState[0],
value: '0',
ariaLabel: '0',
ariaValueText: '0',
style: 'left: 0%;',
},
{
...expectedState[1],
value: '40',
ariaLabel: '40',
ariaValueText: '40',
style: 'left: 40%;',
},
]);

const minLabelLocator = sliderPO.locatorMinLabelHorizontal;
const maxLabelLocator = sliderPO.locatorMaxLabelHorizontal;

await sliderPO.locatorHandle.nth(0).click();
await page.keyboard.press('Home');

await expect.poll(async () => (await sliderPO.sliderHandleState()).at(0)).toEqual(expectedState[0]);
expect((await sliderPO.sliderHandleState()).at(1)).toEqual(expectedState[1]);
expect((await sliderPO.sliderProgressState())[0]).toEqual('left: 0%; width: 40%; height: 100%;');

await expect(minLabelLocator).toBeHidden();
await expect(maxLabelLocator).toBeVisible();

await page.keyboard.press('End');

assign(expectedState, [
Expand All @@ -394,8 +368,36 @@ test.describe(`Slider tests`, () => {
expect((await sliderPO.sliderHandleState()).at(1)).toEqual(expectedState[1]);
expect((await sliderPO.sliderProgressState())[0]).toEqual('left: 40%; width: 60%; height: 100%;');

await expect(sliderPO.locatorHandle.nth(1)).toBeFocused();
await expect(minLabelLocator).toBeVisible();
await expect(maxLabelLocator).toBeHidden();

await page.keyboard.press('Home');

assign(expectedState, [
{
...expectedState[0],
value: '0',
ariaLabel: '0',
ariaValueText: '0',
style: 'left: 0%;',
},
{
...expectedState[1],
value: '40',
ariaLabel: '40',
ariaValueText: '40',
style: 'left: 40%;',
},
]);

await expect.poll(async () => (await sliderPO.sliderHandleState()).at(0)).toEqual(expectedState[0]);
expect((await sliderPO.sliderHandleState()).at(1)).toEqual(expectedState[1]);
expect((await sliderPO.sliderProgressState())[0]).toEqual('left: 0%; width: 40%; height: 100%;');

await expect(sliderPO.locatorHandle.nth(0)).toBeFocused();
await expect(minLabelLocator).toBeHidden();
await expect(maxLabelLocator).toBeVisible();
});

test(`should add / remove combined label from dom`, async ({page}) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,43 @@
import type {SliderSlotHandleContext} from './slider.gen';
let {item, directives}: SliderSlotHandleContext = $props();
let refocusElement: HTMLElement | undefined = $state(undefined);
let updateTimeout: NodeJS.Timeout;
// Manually keep focus as scheduling goal elements are re-ordered.
// Svelte currently does not retain focus as elements are moved, even when keyed.
// See discussion here: https://github.com/sveltejs/svelte/issues/3973
$effect(() => {
if (refocusElement) {
updateTimeout = setTimeout(() => {
refocusElement?.focus();
refocusElement = undefined;
});
}
});
/**
* Key handler that sets the refocus element only on the key strokes that move
* the element up the DOM
* @param event object containting key stroke and the target element
*/
function onkeydown(event: KeyboardEvent) {
switch (event.key) {
case 'ArrowUp':
case 'ArrowRight':
case 'End':
case 'ArrowDown':
case 'ArrowLeft':
case 'Home':
refocusElement = event.target as HTMLElement;
clearTimeout(updateTimeout);
break;
default:
break;
}
}
</script>

<!-- svelte-ignore a11y_consider_explicit_label -->
<button use:directives.handleDirective={{item}}> &nbsp; </button>
<button {onkeydown} use:directives.handleDirective={{item}}> &nbsp; </button>

0 comments on commit 13d2026

Please sign in to comment.