Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/*"
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<nui-select-v2-option-group *ngFor="let item of items">
<button
nui-button
nuiSelectV2GroupAction
type="button"
displayStyle="action"
[icon]="'add'"
Expand All @@ -30,11 +31,11 @@
{{ option.name }}
<button
nui-button
nuiSelectV2OptionAction
type="button"
displayStyle="action"
[icon]="'menu'"
(click)="actionSimulation($event)"
(keydown)="$event.stopPropagation()"
></button>
</nui-select-v2-option>
</nui-select-v2-option-group>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,62 @@ <h4>Custom control</h4>
</div>
</div>

<div class="row mb-3">
<div class="col">
<h4>Nested actionable elements (NUI-6294)</h4>
<nui-select-v2
placeholder="Select Item"
i18n-placeholder
id="nested-actions"
>
<nui-select-v2-option-group>
<button
nui-button
nuiSelectV2GroupAction
id="nested-actions-group-btn"
type="button"
displayStyle="action"
[icon]="'add'"
(click)="onGroupAction()"
(keydown.enter)="onGroupAction()"
(keydown.space)="onGroupAction(); $event.preventDefault()"
>
Add new item
</button>
<nui-divider size="extra-small"></nui-divider>
<span class="nui-select-v2-option-group--header">Items</span>
<nui-select-v2-option
*ngFor="let item of nestedActionItems"
[value]="item"
>
{{ item }}
<button
nui-button
nuiSelectV2OptionAction
class="nested-actions-option-btn"
type="button"
displayStyle="action"
[icon]="'menu'"
(click)="onOptionAction($event)"
></button>
</nui-select-v2-option>
</nui-select-v2-option-group>
</nui-select-v2>
<div>
Group clicks:
<span id="nested-actions-group-clicks">{{
groupActionClicks
}}</span>
</div>
<div>
Option clicks:
<span id="nested-actions-option-clicks">{{
optionActionClicks
}}</span>
</div>
</div>
</div>

<ng-template #temp let-item let-open="open">
<div class="nui-select-v2__value">
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ export class SelectV2TestExampleComponent
// Form
public selectControl = new FormControl<IExampleItem | null>(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"],
Expand Down Expand Up @@ -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$))
Expand Down
171 changes: 171 additions & 0 deletions packages/bits/e2e/components/select-v2/select-v2.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -57,6 +58,10 @@ test.describe("USERCONTROL Select V2 >", () => {
SelectV2Atom,
"inside-dialog"
);
selectNestedActions = Atom.find<SelectV2Atom>(
SelectV2Atom,
"nested-actions"
);

buttonShow = Helpers.page.locator("#show");
buttonHide = Helpers.page.locator("#hide");
Expand Down Expand Up @@ -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>(
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();
});
});
});
});
2 changes: 1 addition & 1 deletion packages/bits/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
2 changes: 1 addition & 1 deletion packages/bits/schematics/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading