diff --git a/angular/bootstrap/src/components/slider/slider.component.ts b/angular/bootstrap/src/components/slider/slider.component.ts index 628bdb6705..82242f53c1 100644 --- a/angular/bootstrap/src/components/slider/slider.component.ts +++ b/angular/bootstrap/src/components/slider/slider.component.ts @@ -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'; @@ -56,12 +58,48 @@ export class SliderHandleDirective { imports: [UseDirective, SliderHandleDirective], template: ` - + `, }) class SliderDefaultHandleSlotComponent { readonly handle = viewChild.required>('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(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; + } + } } /** diff --git a/core/src/components/slider/slider.ts b/core/src/components/slider/slider.ts index 6bd57ef5c1..31afe49818 100644 --- a/core/src/components/slider/slider.ts +++ b/core/src/components/slider/slider.ts @@ -726,7 +726,6 @@ export function createSlider(config?: PropsConfig): SliderWidget { return; } event.preventDefault(); - event.stopPropagation(); } }, mousedown: (event: MouseEvent) => { diff --git a/e2e/slider/slider.e2e-spec.ts b/e2e/slider/slider.e2e-spec.ts index 739d495b00..d27d128176 100644 --- a/e2e/slider/slider.e2e-spec.ts +++ b/e2e/slider/slider.e2e-spec.ts @@ -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, [ @@ -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}) => { diff --git a/svelte/bootstrap/src/components/slider/SliderDefaultHandle.svelte b/svelte/bootstrap/src/components/slider/SliderDefaultHandle.svelte index 3934c0dfba..12cd7ce25b 100644 --- a/svelte/bootstrap/src/components/slider/SliderDefaultHandle.svelte +++ b/svelte/bootstrap/src/components/slider/SliderDefaultHandle.svelte @@ -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; + } + } - +