From 74b1394c624f010e5e9fc62e5ada7a3289fbd3c8 Mon Sep 17 00:00:00 2001 From: ludmila-fialova_swi Date: Tue, 11 Aug 2026 10:21:50 +0200 Subject: [PATCH 1/2] NUI-6294 Nova version upgrade --- docs/CHANGELOG.md | 9 +++++++++ package.json | 2 +- packages/bits/package.json | 2 +- packages/bits/schematics/package.json | 2 +- packages/charts/package.json | 4 ++-- packages/dashboards/package.json | 6 +++--- packages/dashboards/schematics/package.json | 2 +- 7 files changed, 18 insertions(+), 9 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 53543de7c..f348de3ce 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog + +## [20.0.11] 📅 2026-08-17 +### Fixes +- `@nova-ui/bits` | Select-v2: added nested actionable elements with keyboard navigation support + +## [20.0.10] 📅 2026-08-11 +### Fixes +- `@nova-ui/bits` | SkipSpace a11y regression fixed + ## [20.0.9] 📅 2026-08-05 ### Fixes - Essentional A11y fixies diff --git a/package.json b/package.json index 47531cbe2..ac941cbb7 100644 --- a/package.json +++ b/package.json @@ -129,7 +129,7 @@ "trigger-pipeline-build-ci": "bash scripts/trigger-pipeline-build", "verify-ci": "bash scripts/verify-published" }, - "version": "20.0.10", + "version": "20.0.11", "workspaces": [ "packages/*" ] diff --git a/packages/bits/package.json b/packages/bits/package.json index 5201c3e50..ebbfbfc66 100644 --- a/packages/bits/package.json +++ b/packages/bits/package.json @@ -133,6 +133,6 @@ "visual:watch": "npx watch \"yarn run visual:base\" src demo spec --watch=1" }, "typings": "public_api.d.ts", - "version": "20.0.10", + "version": "20.0.11", "packageManager": "yarn@1.22.18" } diff --git a/packages/bits/schematics/package.json b/packages/bits/schematics/package.json index 1e8b9dcc8..e6541a834 100644 --- a/packages/bits/schematics/package.json +++ b/packages/bits/schematics/package.json @@ -1,7 +1,7 @@ { "name": "nova-schematics", "license": "Apache-2.0", - "version": "20.0.10", + "version": "20.0.11", "scripts": { "assemble": "run-s build copy:json copy:data test copy:dist", "build": "tsc -p tsconfig.json", diff --git a/packages/charts/package.json b/packages/charts/package.json index 2880c87b9..411f606ed 100644 --- a/packages/charts/package.json +++ b/packages/charts/package.json @@ -45,7 +45,7 @@ "license": "Apache-2.0", "name": "@nova-ui/charts", "dependencies": { - "@nova-ui/bits": "~20.0.10" + "@nova-ui/bits": "~20.0.11" }, "peerDependencies": { "@types/d3": "^5.0.0", @@ -114,6 +114,6 @@ "visual:gui": "yarn run visual:base -c gui", "visual:serve": "yarn run visual:base -c serve" }, - "version": "20.0.10" + "version": "20.0.11" } diff --git a/packages/dashboards/package.json b/packages/dashboards/package.json index d841207a9..7b5a27ec2 100644 --- a/packages/dashboards/package.json +++ b/packages/dashboards/package.json @@ -41,8 +41,8 @@ "license": "Apache-2.0", "name": "@nova-ui/dashboards", "dependencies": { - "@nova-ui/bits": "~20.0.10", - "@nova-ui/charts": "~20.0.10" + "@nova-ui/bits": "~20.0.11", + "@nova-ui/charts": "~20.0.11" }, "devDependencies": { "@apollo/client": "3.13.9", @@ -116,6 +116,6 @@ "visual:gui": "yarn run visual:base -c gui", "visual:serve": "yarn run visual:base -c serve" }, - "version": "20.0.10" + "version": "20.0.11" } diff --git a/packages/dashboards/schematics/package.json b/packages/dashboards/schematics/package.json index c097f15e3..d1243eeb2 100644 --- a/packages/dashboards/schematics/package.json +++ b/packages/dashboards/schematics/package.json @@ -1,7 +1,7 @@ { "name": "dashboards-schematics", "license": "Apache-2.0", - "version": "20.0.10", + "version": "20.0.11", "scripts": { "assemble": "run-s build copy:json copy:data test copy:dist", "build": "tsc -p tsconfig.json", From bad8dd964a510653ef21a5f0ac75106e4d5545e0 Mon Sep 17 00:00:00 2001 From: harsha573252 Date: Mon, 10 Aug 2026 17:31:01 +0530 Subject: [PATCH 2/2] feat(select-v2): add nested actionable elements with keyboard navigation support - Introduced SelectV2OptionActionDirective and SelectV2GroupActionDirective for nested actions. - Enhanced keyboard navigation to focus on group actions and option actions using Arrow keys. - Updated BaseSelectV2 to manage focus between options and actions. - Added tests for new keyboard interactions and focus management. --- ...t-v2-custom-content.example.component.html | 3 +- .../select-v2-test.example.component.html | 56 ++++++ .../select-v2-test.example.component.ts | 15 ++ .../select-v2/select-v2.e2e.spec.ts | 171 ++++++++++++++++++ .../bits/src/lib/select-v2/base-select-v2.ts | 37 ++++ packages/bits/src/lib/select-v2/constants.ts | 19 ++ .../option-key-control.service.spec.ts | 104 +++++++++++ .../select-v2/option-key-control.service.ts | 66 ++++++- .../option/select-v2-option.component.ts | 7 + .../select-v2-actions.directive.spec.ts | 144 +++++++++++++++ .../select-v2/select-v2-actions.directive.ts | 100 ++++++++++ .../src/lib/select-v2/select-v2.module.ts | 8 + .../select-v2/select/select-v2.component.html | 1 + .../select-v2/select/select-v2.component.less | 5 + packages/bits/src/lib/select-v2/types.ts | 9 + packages/bits/src/public_api.ts | 4 + 16 files changed, 747 insertions(+), 2 deletions(-) create mode 100644 packages/bits/src/lib/select-v2/select-v2-actions.directive.spec.ts create mode 100644 packages/bits/src/lib/select-v2/select-v2-actions.directive.ts diff --git a/packages/bits/demo/src/components/demo/select-v2/select-v2-custom-content/select-v2-custom-content.example.component.html b/packages/bits/demo/src/components/demo/select-v2/select-v2-custom-content/select-v2-custom-content.example.component.html index 6c0dd69f9..8863e0a50 100644 --- a/packages/bits/demo/src/components/demo/select-v2/select-v2-custom-content/select-v2-custom-content.example.component.html +++ b/packages/bits/demo/src/components/demo/select-v2/select-v2-custom-content/select-v2-custom-content.example.component.html @@ -8,6 +8,7 @@ diff --git a/packages/bits/demo/src/components/demo/select-v2/select-v2-test/select-v2-test.example.component.html b/packages/bits/demo/src/components/demo/select-v2/select-v2-test/select-v2-test.example.component.html index 45ef2ea5e..6cadf265e 100644 --- a/packages/bits/demo/src/components/demo/select-v2/select-v2-test/select-v2-test.example.component.html +++ b/packages/bits/demo/src/components/demo/select-v2/select-v2-test/select-v2-test.example.component.html @@ -262,6 +262,62 @@

Custom control

+
+
+

Nested actionable elements (NUI-6294)

+ + + + + Items + + {{ item }} + + + + +
+ Group clicks: + {{ + groupActionClicks + }} +
+
+ Option clicks: + {{ + optionActionClicks + }} +
+
+
+
(null); public fancyForm; + // Nested actionable elements (NUI-6294) + public nestedActionItems = Array.from({ length: 3 }).map( + (_, i) => $localize`Item ${i + 1}` + ); + public groupActionClicks = 0; + public optionActionClicks = 0; // Test public customStylesOverlayConfig: OverlayConfig = { panelClass: [OVERLAY_WITH_POPUP_STYLES_CLASS, "custom-select-styles"], @@ -145,6 +151,15 @@ export class SelectV2TestExampleComponent this.select.inputElement.nativeElement.focus(); } + public onGroupAction(): void { + this.groupActionClicks++; + } + + public onOptionAction(event?: Event): void { + event?.stopPropagation(); + this.optionActionClicks++; + } + public ngOnInit(): void { this.selectControl.valueChanges .pipe(takeUntil(this.destroy$)) diff --git a/packages/bits/e2e/components/select-v2/select-v2.e2e.spec.ts b/packages/bits/e2e/components/select-v2/select-v2.e2e.spec.ts index 641f2ef28..25d731f02 100644 --- a/packages/bits/e2e/components/select-v2/select-v2.e2e.spec.ts +++ b/packages/bits/e2e/components/select-v2/select-v2.e2e.spec.ts @@ -32,6 +32,7 @@ test.describe("USERCONTROL Select V2 >", () => { let selectErrorState: SelectV2Atom; let selectCustomControl: SelectV2Atom; let selectInsideDialog: SelectV2Atom; + let selectNestedActions: SelectV2Atom; let buttonShow: Locator; let buttonHide: Locator; @@ -57,6 +58,10 @@ test.describe("USERCONTROL Select V2 >", () => { SelectV2Atom, "inside-dialog" ); + selectNestedActions = Atom.find( + SelectV2Atom, + "nested-actions" + ); buttonShow = Helpers.page.locator("#show"); buttonHide = Helpers.page.locator("#hide"); @@ -210,5 +215,171 @@ test.describe("USERCONTROL Select V2 >", () => { await expect(overlay).toHaveCount(0); }); }); + + test.describe("focus management on selection", () => { + const dvTrigger = () => + Helpers.page.locator( + "#display-value .nui-select-v2__container" + ); + + test.afterEach(async () => { + await focusdrop.click({ force: true }); + }); + + test("should return focus to the trigger after selecting in a custom-template select", async () => { + const selectDisplayValue = Atom.find( + SelectV2Atom, + "display-value" + ); + await dvTrigger().focus(); + await selectDisplayValue.toBeOpened(); + await Helpers.page.keyboard.press("Enter"); + await selectDisplayValue.toBeClosed(); + await expect(dvTrigger()).toBeFocused(); + }); + }); + + test.describe("nested interactive elements (NUI-6294)", () => { + const trigger = () => + Helpers.page.locator( + "#nested-actions .nui-select-v2__container" + ); + const groupButton = () => + Helpers.page.locator("#nested-actions-group-btn"); + const optionButton = () => + Helpers.page + .locator(".nested-actions-option-btn") + .first(); + const groupClicks = () => + Helpers.page.locator("#nested-actions-group-clicks"); + const optionClicks = () => + Helpers.page.locator("#nested-actions-option-clicks"); + const firstOption = () => + Helpers.page + .locator(".nui-overlay .nui-select-v2-option") + .first(); + + test.afterEach(async () => { + await focusdrop.click({ force: true }); + }); + + test("should reach the group action with ArrowUp on the first option", async () => { + await trigger().focus(); + await selectNestedActions.toBeOpened(); + await Helpers.page.keyboard.press("ArrowUp"); + await expect(groupButton()).toBeFocused(); + // the option highlight must be gone once focus leaves the list + await expect(firstOption()).not.toHaveClass( + /(^|\s)active(\s|$)/ + ); + }); + + test("should return to the options list from the group action on ArrowDown", async () => { + await trigger().focus(); + await selectNestedActions.toBeOpened(); + await Helpers.page.keyboard.press("ArrowUp"); + await expect(groupButton()).toBeFocused(); + await Helpers.page.keyboard.press("ArrowDown"); + await expect(trigger()).toBeFocused(); + await selectNestedActions.toBeOpened(); + }); + + test("should activate the group action and keep the dropdown open", async () => { + await trigger().focus(); + await selectNestedActions.toBeOpened(); + await Helpers.page.keyboard.press("ArrowUp"); + await Helpers.page.keyboard.press("Enter"); + await expect(groupClicks()).not.toHaveText("0"); + await selectNestedActions.toBeOpened(); + }); + + test("should reach the option action with ArrowRight", async () => { + await trigger().focus(); + await selectNestedActions.toBeOpened(); + await Helpers.page.keyboard.press("ArrowRight"); + await expect(optionButton()).toBeFocused(); + }); + + test("should keep focus on the option action on ArrowRight (entry key)", async () => { + await trigger().focus(); + await selectNestedActions.toBeOpened(); + await Helpers.page.keyboard.press("ArrowRight"); + await expect(optionButton()).toBeFocused(); + await Helpers.page.keyboard.press("ArrowRight"); + await expect(optionButton()).toBeFocused(); + }); + + test("should activate the option action without selecting/closing", async () => { + await trigger().focus(); + await selectNestedActions.toBeOpened(); + await Helpers.page.keyboard.press("ArrowRight"); + await Helpers.page.keyboard.press("Enter"); + await expect(optionClicks()).not.toHaveText("0"); + await selectNestedActions.toBeOpened(); + }); + + test("should return focus to the trigger on Escape from an action", async () => { + await trigger().focus(); + await selectNestedActions.toBeOpened(); + await Helpers.page.keyboard.press("ArrowRight"); + await expect(optionButton()).toBeFocused(); + await Helpers.page.keyboard.press("Escape"); + await expect(trigger()).toBeFocused(); + }); + + test("should return focus to the trigger on ArrowLeft from an action", async () => { + await trigger().focus(); + await selectNestedActions.toBeOpened(); + await Helpers.page.keyboard.press("ArrowRight"); + await expect(optionButton()).toBeFocused(); + await Helpers.page.keyboard.press("ArrowLeft"); + await expect(trigger()).toBeFocused(); + }); + + test("should keep the same option active when returning to the list from its action", async () => { + const options = Helpers.page.locator( + ".nui-overlay .nui-select-v2-option" + ); + const secondButton = Helpers.page + .locator(".nested-actions-option-btn") + .nth(1); + + await trigger().focus(); + await selectNestedActions.toBeOpened(); + await Helpers.page.keyboard.press("ArrowDown"); // second option active + await Helpers.page.keyboard.press("ArrowRight"); // enter its action + await expect(secondButton).toBeFocused(); + + await Helpers.page.keyboard.press("ArrowLeft"); // back to the list + await expect(trigger()).toBeFocused(); + // the same (second) option stays active, focus must not jump to another option + await expect(options.nth(1)).toHaveClass(/(^|\s)active(\s|$)/); + await expect(options.nth(0)).not.toHaveClass( + /(^|\s)active(\s|$)/ + ); + }); + + test("should close the dropdown and exit the widget on Tab from an option action", async () => { + await trigger().focus(); + await selectNestedActions.toBeOpened(); + await Helpers.page.keyboard.press("ArrowRight"); + await expect(optionButton()).toBeFocused(); + + await Helpers.page.keyboard.press("Tab"); + await selectNestedActions.toBeClosed(); + await expect(trigger()).not.toBeFocused(); + }); + + test("should close the dropdown and exit the widget on Tab from the group action", async () => { + await trigger().focus(); + await selectNestedActions.toBeOpened(); + await Helpers.page.keyboard.press("ArrowUp"); + await expect(groupButton()).toBeFocused(); + + await Helpers.page.keyboard.press("Tab"); + await selectNestedActions.toBeClosed(); + await expect(trigger()).not.toBeFocused(); + }); + }); }); }); diff --git a/packages/bits/src/lib/select-v2/base-select-v2.ts b/packages/bits/src/lib/select-v2/base-select-v2.ts index e4a369449..1716dd60d 100644 --- a/packages/bits/src/lib/select-v2/base-select-v2.ts +++ b/packages/bits/src/lib/select-v2/base-select-v2.ts @@ -56,6 +56,7 @@ import { } from "./constants"; import { SelectV2OptionComponent } from "./option/select-v2-option.component"; import { OptionKeyControlService } from "./option-key-control.service"; +import { SelectV2GroupActionDirective } from "./select-v2-actions.directive"; import { InputValueTypes, IOptionedComponent } from "./types"; import { KEYBOARD_CODE } from "../../constants/keycode.constants"; import { @@ -76,6 +77,11 @@ const DEFAULT_SELECT_OVERLAY_CONFIG: OverlayConfig = { const V_SCROLL_HEIGHT_BUFFER = 10; +// focusVisible isn't in this project's TS lib.dom types yet. +interface FocusOptionsWithVisible extends FocusOptions { + focusVisible?: boolean; +} + // Will be renamed in scope of the NUI-5797 @Directive() export abstract class BaseSelectV2 @@ -169,6 +175,10 @@ export abstract class BaseSelectV2 @ContentChildren(forwardRef(() => OVERLAY_ITEM), { descendants: true }) public allPopupItems: QueryList; + /** Interactive group-level controls (e.g. header buttons) reachable via ArrowUp */ + @ContentChildren(SelectV2GroupActionDirective, { descendants: true }) + public groupActions: QueryList; + /** Gets options from the model */ public get selectedOptions(): SelectV2OptionComponent[] { return this._selectedOptions; @@ -191,6 +201,8 @@ export abstract class BaseSelectV2 new OverlayUtilitiesService(); protected destroy$: Subject = new Subject(); protected mouseDown: boolean; + // Prevents onFocusIn from reopening the dropdown during a programmatic refocus. + private suppressOpenOnFocus: boolean = false; private _selectedOptions: SelectV2OptionComponent[] = []; private _ariaLabel: string = ""; @@ -278,6 +290,9 @@ export abstract class BaseSelectV2 */ @HostListener("focusin") public onFocusIn(): void { + if (this.suppressOpenOnFocus) { + return; + } if (this.isOpenOnFocus()) { this.showDropdown(); this.announceDropdown(true); @@ -341,6 +356,13 @@ export abstract class BaseSelectV2 } } + /** Moves DOM focus back to the Select trigger (used by nested action directives) */ + public focusTrigger(): void { + this.optionKeyControlService.restoreActiveHighlight(); + // Focus silently so the current active option is preserved (no reopen/reset). + this.returnFocusToTrigger(); + } + /** Selects specific option and set its value to the model */ public selectOption(option: SelectV2OptionComponent): void { if ( @@ -348,6 +370,7 @@ export abstract class BaseSelectV2 !this.manualDropdownControl ) { this.hideDropdown(); + this.returnFocusToTrigger(); return; } @@ -358,7 +381,20 @@ export abstract class BaseSelectV2 if (!this.manualDropdownControl) { this.hideDropdown(); + this.returnFocusToTrigger(); + } + } + + /** Returns DOM focus to the trigger without reopening the dropdown or losing the active option. */ + protected returnFocusToTrigger(): void { + if (!this.inputElement) { + return; } + this.suppressOpenOnFocus = true; + // focusVisible keeps the ring visible for this programmatic focus. + const options: FocusOptionsWithVisible = { focusVisible: true }; + this.inputElement.nativeElement.focus(options); + setTimeout(() => (this.suppressOpenOnFocus = false)); } /** Removes selected options or passed option if multi-select mode enabled */ @@ -511,6 +547,7 @@ export abstract class BaseSelectV2 private initKeyboardManager() { this.optionKeyControlService.optionItems = this.allPopupItems; this.optionKeyControlService.popup = this.dropdown; + this.optionKeyControlService.groupActions = this.groupActions; this.optionKeyControlService.initKeyboardManager(); this.optionKeyControlService.setSkipPredicate( (option: IOption) => !!(option.outfiltered || option.isDisabled) diff --git a/packages/bits/src/lib/select-v2/constants.ts b/packages/bits/src/lib/select-v2/constants.ts index bf25df13a..f299692dc 100644 --- a/packages/bits/src/lib/select-v2/constants.ts +++ b/packages/bits/src/lib/select-v2/constants.ts @@ -21,6 +21,7 @@ import { InjectionToken } from "@angular/core"; import { IOptionedComponent } from "./types"; +import { KEYBOARD_CODE } from "../../constants/keycode.constants"; // Will be renamed in scope of the NUI-5797 export const NUI_SELECT_V2_OPTION_PARENT_COMPONENT = @@ -29,3 +30,21 @@ export const NUI_SELECT_V2_OPTION_PARENT_COMPONENT = ); export const ANNOUNCER_OPEN_MESSAGE_SUFFIX = "options available"; export const ANNOUNCER_CLOSE_MESSAGE = "Dropdown closed"; + +/** Whether the key is an arrow key. */ +export function isArrowKey(code: string): boolean { + return ( + code === KEYBOARD_CODE.ARROW_LEFT || + code === KEYBOARD_CODE.ARROW_RIGHT || + code === KEYBOARD_CODE.ARROW_UP || + code === KEYBOARD_CODE.ARROW_DOWN + ); +} + +/** Whether the key should return focus to the list; excludes the action's own entry key. */ +export function isReturnToListKey(code: string, entryKey?: string): boolean { + if (code === entryKey) { + return false; + } + return isArrowKey(code) || code === KEYBOARD_CODE.ESCAPE; +} diff --git a/packages/bits/src/lib/select-v2/option-key-control.service.spec.ts b/packages/bits/src/lib/select-v2/option-key-control.service.spec.ts index eeaa6ca28..16db77da1 100644 --- a/packages/bits/src/lib/select-v2/option-key-control.service.spec.ts +++ b/packages/bits/src/lib/select-v2/option-key-control.service.spec.ts @@ -245,5 +245,109 @@ describe("components > ", () => { expect(spy).toHaveBeenCalled(); }); }); + + describe("nested interactive actions (NUI-6294)", () => { + // popupMock is shared across tests and a previous test sets showing=false + beforeEach(() => { + service.popup.showing = true; + }); + + it("should focus the active option's first action on ArrowRight", () => { + const focusSpy = jasmine.createSpy("focus"); + spyOnProperty( + service["keyboardEventsManager"], + "activeItem" + ).and.returnValue({ + actions: { first: { focus: focusSpy } }, + } as any); + const arrowRightEvent = { + ...keyBoardEventMock, + ...{ code: KEYBOARD_CODE.ARROW_RIGHT }, + }; + const preventSpy = spyOn(arrowRightEvent, "preventDefault"); + + service.handleKeydown(arrowRightEvent as any); + + expect(focusSpy).toHaveBeenCalled(); + expect(preventSpy).toHaveBeenCalled(); + }); + + it("should not throw or focus on ArrowRight when active option has no actions", () => { + spyOnProperty( + service["keyboardEventsManager"], + "activeItem" + ).and.returnValue({} as any); + const arrowRightEvent = { + ...keyBoardEventMock, + ...{ code: KEYBOARD_CODE.ARROW_RIGHT }, + }; + const preventSpy = spyOn(arrowRightEvent, "preventDefault"); + + expect(() => + service.handleKeydown(arrowRightEvent as any) + ).not.toThrow(); + expect(preventSpy).not.toHaveBeenCalled(); + }); + + it("should focus the first group action on ArrowUp when the first option is active", () => { + const focusSpy = jasmine.createSpy("focus"); + service.groupActions = { first: { focus: focusSpy } } as any; + spyOn(service, "getActiveItemIndex").and.returnValue(0); + const managerSpy = spyOn( + service["keyboardEventsManager"], + "onKeydown" + ); + const arrowUpEvent = { + ...keyBoardEventMock, + ...{ code: KEYBOARD_CODE.ARROW_UP }, + }; + + service.handleKeydown(arrowUpEvent as any); + + expect(focusSpy).toHaveBeenCalled(); + expect(managerSpy).not.toHaveBeenCalled(); + }); + + it("should navigate options normally on ArrowUp when no group actions exist", () => { + service.groupActions = undefined; + const managerSpy = spyOn( + service["keyboardEventsManager"], + "onKeydown" + ); + const arrowUpEvent = { + ...keyBoardEventMock, + ...{ code: KEYBOARD_CODE.ARROW_UP }, + }; + + service.handleKeydown(arrowUpEvent as any); + + expect(managerSpy).toHaveBeenCalledWith(arrowUpEvent as any); + }); + + it("should suspend and restore the active highlight around a group action", () => { + spyOn(service, "getActiveItemIndex").and.returnValue(2); + const setActiveSpy = spyOn( + service["keyboardEventsManager"], + "setActiveItem" + ); + + service.suspendActiveHighlight(); + expect(setActiveSpy).toHaveBeenCalledWith(-1); + + service.restoreActiveHighlight(); + expect(setActiveSpy).toHaveBeenCalledWith(2); + }); + + it("should not restore the highlight when nothing was suspended", () => { + const setActiveSpy = spyOn( + service["keyboardEventsManager"], + "setActiveItem" + ); + + service.restoreActiveHighlight(); + + expect(setActiveSpy).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/packages/bits/src/lib/select-v2/option-key-control.service.ts b/packages/bits/src/lib/select-v2/option-key-control.service.ts index 8ba0fb4bb..6f02052e8 100644 --- a/packages/bits/src/lib/select-v2/option-key-control.service.ts +++ b/packages/bits/src/lib/select-v2/option-key-control.service.ts @@ -26,6 +26,7 @@ import { ANNOUNCER_CLOSE_MESSAGE, ANNOUNCER_OPEN_MESSAGE_SUFFIX, } from "./constants"; +import { IFocusableAction } from "./types"; import { KEYBOARD_CODE } from "../../constants/keycode.constants"; import { IOption, IOverlayComponent } from "../overlay/types"; @@ -35,8 +36,14 @@ export class OptionKeyControlService { public optionItems: QueryList; public skipSpace: boolean = false; + /** Group-level focusable actions (e.g. header buttons) reachable via ArrowUp */ + public groupActions?: QueryList; + private keyboardEventsManager: ActiveDescendantKeyManager; + // Active option index saved while its highlight is suspended. + private suspendedActiveIndex: number | null = null; + constructor(public liveAnnouncer: LiveAnnouncer) {} public initKeyboardManager(): void { @@ -59,6 +66,23 @@ export class OptionKeyControlService { this.keyboardEventsManager.setActiveItem(-1); } + /** Removes the active option highlight while focus is on a group action. */ + public suspendActiveHighlight(): void { + this.suspendedActiveIndex = this.getActiveItemIndex(); + this.resetActiveItem(); + } + + /** Restores the option highlight suspended by {@link suspendActiveHighlight}. */ + public restoreActiveHighlight(): void { + if (this.suspendedActiveIndex == null) { + return; + } + if (this.suspendedActiveIndex >= 0) { + this.keyboardEventsManager.setActiveItem(this.suspendedActiveIndex); + } + this.suspendedActiveIndex = null; + } + public setFirstItemActive(): void { this.keyboardEventsManager?.setFirstItemActive(); } @@ -90,13 +114,53 @@ export class OptionKeyControlService { ); } + /** Moves DOM focus to the active option's first nested action, if any. */ + private focusActiveOptionAction(): boolean { + const activeItem = this.keyboardEventsManager.activeItem as + | (T & { actions?: QueryList }) + | null; + const action = activeItem?.actions?.first; + if (action) { + action.focus(); + return true; + } + return false; + } + + /** Moves DOM focus to the first group action when the first option is active. */ + private focusGroupActionOnArrowUp(): boolean { + const action = this.groupActions?.first; + if (action && this.getActiveItemIndex() === 0) { + // Focus leaves the options list, so drop the option highlight. + this.suspendActiveHighlight(); + action.focus(); + return true; + } + return false; + } + private handleOpenKeyDown(event: KeyboardEvent): void { switch (event.code) { - case KEYBOARD_CODE.ARROW_DOWN: case KEYBOARD_CODE.ARROW_UP: + // ArrowUp on the first option reaches the group action. + if (this.focusGroupActionOnArrowUp()) { + event.preventDefault(); + return; + } this.keyboardEventsManager.onKeydown(event); this.announceNavigatedOption(); break; + case KEYBOARD_CODE.ARROW_DOWN: + this.keyboardEventsManager.onKeydown(event); + this.announceNavigatedOption(); + break; + case KEYBOARD_CODE.ARROW_RIGHT: + // ArrowRight enters the active option's nested action(s) + if (this.focusActiveOptionAction()) { + event.preventDefault(); + return; + } + break; case KEYBOARD_CODE.PAGE_UP: event.preventDefault(); this.keyboardEventsManager.onKeydown(event); diff --git a/packages/bits/src/lib/select-v2/option/select-v2-option.component.ts b/packages/bits/src/lib/select-v2/option/select-v2-option.component.ts index 020e62155..d0cf43603 100644 --- a/packages/bits/src/lib/select-v2/option/select-v2-option.component.ts +++ b/packages/bits/src/lib/select-v2/option/select-v2-option.component.ts @@ -22,6 +22,7 @@ import { Highlightable } from "@angular/cdk/a11y"; import { ChangeDetectionStrategy, Component, + ContentChildren, ElementRef, forwardRef, HostBinding, @@ -29,12 +30,14 @@ import { Inject, Input, Optional, + QueryList, } from "@angular/core"; import { OVERLAY_ITEM } from "../../overlay/constants"; import { OverlayItemComponent } from "../../overlay/overlay-item/overlay-item.component"; import { IOption, OptionValueType } from "../../overlay/types"; import { NUI_SELECT_V2_OPTION_PARENT_COMPONENT } from "../constants"; +import { SelectV2OptionActionDirective } from "../select-v2-actions.directive"; import { IOptionedComponent } from "../types"; /** @@ -71,6 +74,10 @@ export class SelectV2OptionComponent /** Used to pass context for the custom template */ @Input() public displayValueContext: any; + /** Interactive controls nested inside the option, reachable via ArrowRight */ + @ContentChildren(SelectV2OptionActionDirective, { descendants: true }) + public actions: QueryList; + /** Whether the Option outfiltered */ @HostBinding("class.hidden") @Input() diff --git a/packages/bits/src/lib/select-v2/select-v2-actions.directive.spec.ts b/packages/bits/src/lib/select-v2/select-v2-actions.directive.spec.ts new file mode 100644 index 000000000..998504a8d --- /dev/null +++ b/packages/bits/src/lib/select-v2/select-v2-actions.directive.spec.ts @@ -0,0 +1,144 @@ +// © 2022 SolarWinds Worldwide, LLC. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import { ElementRef } from "@angular/core"; + +import { + SelectV2ActionBaseDirective, + SelectV2GroupActionDirective, + SelectV2OptionActionDirective, +} from "./select-v2-actions.directive"; +import { KEYBOARD_CODE } from "../../constants/keycode.constants"; +import { IOptionedComponent } from "./types"; + +describe("directives >", () => { + const cases: { + name: string; + Ctor: new ( + elRef: ElementRef, + parent: IOptionedComponent + ) => SelectV2ActionBaseDirective; + entryKey: string; + otherArrows: string[]; + }[] = [ + { + name: "SelectV2OptionActionDirective", + Ctor: SelectV2OptionActionDirective, + entryKey: KEYBOARD_CODE.ARROW_RIGHT, + otherArrows: [ + KEYBOARD_CODE.ARROW_LEFT, + KEYBOARD_CODE.ARROW_UP, + KEYBOARD_CODE.ARROW_DOWN, + ], + }, + { + name: "SelectV2GroupActionDirective", + Ctor: SelectV2GroupActionDirective, + entryKey: KEYBOARD_CODE.ARROW_UP, + otherArrows: [ + KEYBOARD_CODE.ARROW_LEFT, + KEYBOARD_CODE.ARROW_RIGHT, + KEYBOARD_CODE.ARROW_DOWN, + ], + }, + ]; + + cases.forEach(({ name, Ctor, entryKey, otherArrows }) => { + describe(name, () => { + let directive: SelectV2ActionBaseDirective; + let nativeElement: jasmine.SpyObj; + let parent: jasmine.SpyObj; + + const buildEvent = (code?: string) => + jasmine.createSpyObj( + "event", + ["stopPropagation", "preventDefault"], + { code } + ); + + beforeEach(() => { + nativeElement = jasmine.createSpyObj("nativeElement", ["focus"]); + parent = jasmine.createSpyObj("parent", [ + "focusTrigger", + "hideDropdown", + ]); + directive = new Ctor( + { nativeElement } as ElementRef, + parent + ); + }); + + it("should focus the host element", () => { + directive.focus(); + expect(nativeElement.focus).toHaveBeenCalled(); + }); + + it("should stop keydown propagation to the option key manager", () => { + const event = buildEvent(KEYBOARD_CODE.ENTER); + directive.onKeydown(event); + expect(event.stopPropagation).toHaveBeenCalled(); + }); + + it("should return focus to the trigger on Escape", () => { + const event = buildEvent(KEYBOARD_CODE.ESCAPE); + directive.onKeydown(event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(parent.focusTrigger).toHaveBeenCalled(); + }); + + // The entry key itself must not return (it would immediately pop back out). + otherArrows.forEach((code) => { + it(`should return focus to the trigger and prevent page scroll on ${code}`, () => { + const event = buildEvent(code); + directive.onKeydown(event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(parent.focusTrigger).toHaveBeenCalled(); + }); + }); + + it(`should do nothing on ${entryKey} (entry key) but still prevent page scroll`, () => { + const event = buildEvent(entryKey); + directive.onKeydown(event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(parent.focusTrigger).not.toHaveBeenCalled(); + }); + + it("should not return focus to the trigger on other keys", () => { + const event = buildEvent(KEYBOARD_CODE.ENTER); + directive.onKeydown(event); + expect(parent.focusTrigger).not.toHaveBeenCalled(); + }); + + it("should stop click propagation so the parent is not selected/closed", () => { + const event = buildEvent(); + directive.onClick(event as unknown as MouseEvent); + expect(event.stopPropagation).toHaveBeenCalled(); + }); + + it("should close the dropdown and refocus the trigger on Tab, without preventing default", () => { + const event = buildEvent(KEYBOARD_CODE.TAB); + directive.onKeydown(event); + expect(parent.hideDropdown).toHaveBeenCalled(); + expect(parent.focusTrigger).toHaveBeenCalled(); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/bits/src/lib/select-v2/select-v2-actions.directive.ts b/packages/bits/src/lib/select-v2/select-v2-actions.directive.ts new file mode 100644 index 000000000..8f7fc2278 --- /dev/null +++ b/packages/bits/src/lib/select-v2/select-v2-actions.directive.ts @@ -0,0 +1,100 @@ +// © 2022 SolarWinds Worldwide, LLC. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import { Directive, ElementRef, HostListener, Inject, Optional } from "@angular/core"; + +import { isArrowKey, isReturnToListKey, NUI_SELECT_V2_OPTION_PARENT_COMPONENT } from "./constants"; +import { IFocusableAction, IOptionedComponent } from "./types"; +import { KEYBOARD_CODE } from "../../constants/keycode.constants"; + +/** Shared keyboard/focus behavior for nested controls (option and group actions). */ +@Directive() +export abstract class SelectV2ActionBaseDirective implements IFocusableAction { + /** The arrow key used to enter this action; pressing it again is a no-op. */ + protected abstract readonly entryKey: string; + + constructor( + private elRef: ElementRef, + @Optional() + @Inject(NUI_SELECT_V2_OPTION_PARENT_COMPONENT) + private parent: IOptionedComponent + ) {} + + public focus(): void { + this.elRef.nativeElement.focus(); + } + + /** Keeps the option key manager from reacting to keys pressed on the action. */ + @HostListener("keydown", ["$event"]) + public onKeydown(event: KeyboardEvent): void { + event.stopPropagation(); + + // Tab exits the widget: close the dropdown and refocus the trigger + // first, then let native Tab navigation continue from there (the + // action lives in a portaled overlay, so it isn't in the trigger's + // own tab order). + if (event.code === KEYBOARD_CODE.TAB) { + this.parent?.hideDropdown?.(); + this.parent?.focusTrigger?.(); + return; + } + + // Never let arrow keys scroll the page while the action holds focus. + if (isArrowKey(event.code)) { + event.preventDefault(); + } + + // Any other arrow or Escape returns focus to the list. + if (isReturnToListKey(event.code, this.entryKey)) { + event.preventDefault(); + this.parent?.focusTrigger?.(); + } + } + + /** Prevents the parent from selecting/closing when the action is activated. */ + @HostListener("click", ["$event"]) + public onClick(event: MouseEvent): void { + event.stopPropagation(); + } +} + +/** Marks a nested control inside a `nui-select-v2-option` as reachable via ArrowRight. */ +@Directive({ + selector: "[nuiSelectV2OptionAction]", + host: { + tabindex: "-1", + }, + standalone: false, +}) +export class SelectV2OptionActionDirective extends SelectV2ActionBaseDirective { + protected readonly entryKey = KEYBOARD_CODE.ARROW_RIGHT; +} + +/** Marks a group header control as reachable via ArrowUp on the first option. */ +@Directive({ + selector: "[nuiSelectV2GroupAction]", + host: { + tabindex: "-1", + }, + standalone: false, +}) +export class SelectV2GroupActionDirective extends SelectV2ActionBaseDirective { + protected readonly entryKey = KEYBOARD_CODE.ARROW_UP; +} diff --git a/packages/bits/src/lib/select-v2/select-v2.module.ts b/packages/bits/src/lib/select-v2/select-v2.module.ts index 3bf0cee52..1d258632c 100644 --- a/packages/bits/src/lib/select-v2/select-v2.module.ts +++ b/packages/bits/src/lib/select-v2/select-v2.module.ts @@ -30,6 +30,10 @@ import { MarkAsSelectedItemDirective } from "./mark-as-selected-item.directive"; import { SelectV2OptionComponent } from "./option/select-v2-option.component"; import { SelectV2OptionGroupComponent } from "./option-group/select-v2-option-group.component"; import { SelectV2Component } from "./select/select-v2.component"; +import { + SelectV2GroupActionDirective, + SelectV2OptionActionDirective, +} from "./select-v2-actions.directive"; import { NuiButtonModule } from "../button/button.module"; import { NuiIconModule } from "../icon/icon.module"; import { NuiOverlayModule } from "../overlay/overlay.module"; @@ -56,6 +60,8 @@ import { NuiTooltipModule } from "../tooltip/tooltip.module"; ComboboxV2Component, MarkAsSelectedItemDirective, ComboboxV2OptionHighlightDirective, + SelectV2OptionActionDirective, + SelectV2GroupActionDirective, ], exports: [ SelectV2Component, @@ -64,6 +70,8 @@ import { NuiTooltipModule } from "../tooltip/tooltip.module"; ComboboxV2Component, MarkAsSelectedItemDirective, ComboboxV2OptionHighlightDirective, + SelectV2OptionActionDirective, + SelectV2GroupActionDirective, ], providers: [], }) diff --git a/packages/bits/src/lib/select-v2/select/select-v2.component.html b/packages/bits/src/lib/select-v2/select/select-v2.component.html index 8cf892226..c0c622c45 100644 --- a/packages/bits/src/lib/select-v2/select/select-v2.component.html +++ b/packages/bits/src/lib/select-v2/select/select-v2.component.html @@ -2,6 +2,7 @@ #input [id]="triggerId" class="nui-select-v2__container" + [class.nui-select-v2__container--custom-trigger]="!!displayValueTemplate" [attr.role]="displayValueTemplate ? 'none' : 'combobox'" [attr.tabindex]="displayValueTemplate ? -1 : isDisabled ? -1 : 0" [attr.aria-haspopup]="displayValueTemplate ? null : 'listbox'" diff --git a/packages/bits/src/lib/select-v2/select/select-v2.component.less b/packages/bits/src/lib/select-v2/select/select-v2.component.less index 2e054981c..28729b6c6 100644 --- a/packages/bits/src/lib/select-v2/select/select-v2.component.less +++ b/packages/bits/src/lib/select-v2/select/select-v2.component.less @@ -44,6 +44,11 @@ transform: unset; outline: unset; } + + // Focus ring for the trigger when a custom displayValueTemplate refocuses it. + &--custom-trigger:focus-visible { + .focus-outline(); + } } &__value { diff --git a/packages/bits/src/lib/select-v2/types.ts b/packages/bits/src/lib/select-v2/types.ts index 60d89440c..293518fdd 100644 --- a/packages/bits/src/lib/select-v2/types.ts +++ b/packages/bits/src/lib/select-v2/types.ts @@ -29,6 +29,15 @@ export interface IOptionedComponent { isTypeaheadEnabled?: boolean; selectedOptions: SelectV2OptionComponent[]; selectOption(option: SelectV2OptionComponent): void; + /** Moves DOM focus back to the Select trigger (used by nested action directives) */ + focusTrigger?(): void; + /** Closes the dropdown (used by nested action directives on Tab) */ + hideDropdown?(): void; +} + +/** A control that can receive real DOM focus from the option key manager */ +export interface IFocusableAction { + focus(): void; } export type InputValueTypes = number | string; diff --git a/packages/bits/src/public_api.ts b/packages/bits/src/public_api.ts index 50bb94250..0fe12b51d 100644 --- a/packages/bits/src/public_api.ts +++ b/packages/bits/src/public_api.ts @@ -77,6 +77,10 @@ export { OverlayArrowComponent as ArrowComponent } from "./lib/overlay/arrow-com export { SelectV2Component } from "./lib/select-v2/select/select-v2.component"; export { ComboboxV2Component } from "./lib/select-v2/combobox-v2/combobox-v2.component"; export { MarkAsSelectedItemDirective } from "./lib/select-v2/mark-as-selected-item.directive"; +export { + SelectV2OptionActionDirective, + SelectV2GroupActionDirective, +} from "./lib/select-v2/select-v2-actions.directive"; export { ComboboxComponent } from "./lib/select/combobox/combobox.component"; export { BaseSelect } from "./lib/select/base-select"; export { DatePickerComponent } from "./lib/date-picker/date-picker.component";