diff --git a/libs/collapsible/src/lib/collapsible/collapsible.component.html b/libs/collapsible/src/lib/collapsible/collapsible.component.html index b050a2de5..e547633c6 100644 --- a/libs/collapsible/src/lib/collapsible/collapsible.component.html +++ b/libs/collapsible/src/lib/collapsible/collapsible.component.html @@ -9,8 +9,10 @@ tabindex="0" > <ng-container *ngIf="alwaysRender || active"> - <ng-content></ng-content> - <ng-container *ngTemplateOutlet="contentTemplate"></ng-container> + <div [attr.inert]="!active ? true : null" class="ant-collapse-content-inner"> + <ng-content></ng-content> + <ng-container *ngTemplateOutlet="contentTemplate"></ng-container> + </div> </ng-container> </nz-collapse-panel> </nz-collapse> diff --git a/libs/collapsible/src/lib/collapsible/collapsible.component.less b/libs/collapsible/src/lib/collapsible/collapsible.component.less index 84829b419..c32ec3d7f 100644 --- a/libs/collapsible/src/lib/collapsible/collapsible.component.less +++ b/libs/collapsible/src/lib/collapsible/collapsible.component.less @@ -111,4 +111,8 @@ background: @gray-lighter; color: @ink; } + + &-content-inner { + display: contents; + } } diff --git a/libs/collapsible/src/lib/collapsible/collapsible.component.stories.ts b/libs/collapsible/src/lib/collapsible/collapsible.component.stories.ts index b4de1437c..2b3003ea3 100644 --- a/libs/collapsible/src/lib/collapsible/collapsible.component.stories.ts +++ b/libs/collapsible/src/lib/collapsible/collapsible.component.stories.ts @@ -1,12 +1,12 @@ import { Component } from '@angular/core'; import { provideAnimations } from '@angular/platform-browser/animations'; import { applicationConfig, Meta, moduleMetadata } from '@storybook/angular'; -import { CollapsibleComponent } from './collapsible.component'; import { CollapsibleModule } from '../collapsible.module'; +import { CollapsibleComponent } from './collapsible.component'; @Component({ selector: 'spy-story', - template: ` Collapse Content `, + template: `<input /> Collapse Content `, }) class StoryComponent { constructor() { diff --git a/libs/dropdown/src/lib/dropdown/dropdown.component.html b/libs/dropdown/src/lib/dropdown/dropdown.component.html index 30a9bb243..18295fafb 100644 --- a/libs/dropdown/src/lib/dropdown/dropdown.component.html +++ b/libs/dropdown/src/lib/dropdown/dropdown.component.html @@ -6,28 +6,55 @@ [(nzVisible)]="visible" [nzTrigger]="trigger" (click)="$event.stopPropagation()" + (keydown)="onKeyDown($event)" (nzVisibleChange)="visibleChange.emit($event)" + tabindex="0" + [attr.aria-label]="ariaLabel" > <ng-content></ng-content> </span> + <nz-dropdown-menu #menu="nzDropdownMenu"> - <ul nz-menu> - <ng-container *ngTemplateOutlet="dropdownTpl; context: { $implicit: items }"></ng-container> + <ul nz-menu (keydown)="onMenuKeyDown($event)"> + <ng-container *ngTemplateOutlet="dropdownTpl; context: { $implicit: items, index: 0 }"></ng-container> - <ng-template #dropdownTpl let-items> - <ng-container *ngFor="let item of items"> - <li *ngIf="!item.subItems" (click)="actionTriggered.emit(item.action)" nz-menu-item> - <spy-icon class="spy-dropdown-item__icon" *ngIf="item.icon" [name]="item.icon"></spy-icon> - <span class="spy-dropdown-item__text">{{ item.title }}</span> - </li> - <li *ngIf="item.subItems" (click)="actionTriggered.emit(item.action)" nz-submenu> - <ul> - <ng-container - *ngTemplateOutlet="dropdownTpl; context: { $implicit: item.subItems }" - ></ng-container> - </ul> - </li> - </ng-container> + <ng-template #dropdownTpl let-items let-index="index"> + @for (item of items; track $index) { + @if (item.subItems) { + <li + [attr.aria-label]="item.title" + [attr.tabindex]="item.disabled ? null : '0'" + #dropdownItem + (click)="onItemClick(item.action)" + (keydown)="onItemKeyDown($event, index)" + nz-submenu + [nzIcon]="item.icon" + [nzTitle]="item.title" + [nzDisabled]="item.disabled" + class="spy-dropdown-item__item spy-dropdown-item__item--submenu" + > + <ul #submenu (keydown)="onMenuKeyDown($event, index)"> + <ng-container + *ngTemplateOutlet="dropdownTpl; context: { $implicit: item.subItems, index: index + 1 }" + ></ng-container> + </ul> + </li> + } @else { + <li + [attr.aria-label]="item.title" + [attr.tabindex]="item.disabled ? null : '0'" + [nzDisabled]="item.disabled" + #dropdownItem + (click)="onItemClick(item.action)" + (keydown)="onItemKeyDown($event)" + nz-menu-item + class="spy-dropdown-item__item" + > + <spy-icon class="spy-dropdown-item__icon" *ngIf="item.icon" [name]="item.icon"></spy-icon> + <span class="spy-dropdown-item__text">{{ item.title }}</span> + </li> + } + } </ng-template> </ul> </nz-dropdown-menu> diff --git a/libs/dropdown/src/lib/dropdown/dropdown.component.less b/libs/dropdown/src/lib/dropdown/dropdown.component.less index 38da4f6b7..d7b3368cc 100644 --- a/libs/dropdown/src/lib/dropdown/dropdown.component.less +++ b/libs/dropdown/src/lib/dropdown/dropdown.component.less @@ -9,6 +9,11 @@ &-trigger { display: flex; height: 100%; + + &:focus-visible { + outline: @outline; + outline-offset: @outline-offset; + } } &-menu { @@ -18,26 +23,32 @@ background-color: @spy-white; padding: @dropdown-menu-padding; overflow: hidden; + } + + &-menu-submenu-title, + &-menu-item { + height: @dropdown-menu-item-height; + padding: @dropdown-menu-item-padding; + font: @font-default; + color: @ink; + border: @dropdown-menu-item-border; + display: flex; + align-items: center; - &-item { - height: @dropdown-menu-item-height; - padding: @dropdown-menu-item-padding; - font: @font-default; - color: @ink; - border: @dropdown-menu-item-border; - display: flex; - align-items: center; - - &:hover { - background-color: @gray-lighter; - border-color: @gray-lighter; - } - - &:focus { - border-radius: @dropdown-menu-item-border-focus-radius; - border-color: @green; - } + &:hover { + background-color: @gray-lighter; + border-color: @gray-lighter; } + + &:focus { + border-radius: @dropdown-menu-item-border-focus-radius; + border-color: @green; + } + } + + &-menu-submenu:focus .@{dropdown-prefix-cls}-menu-submenu-title { + border-radius: @dropdown-menu-item-border-focus-radius; + border-color: @green; } .@{menu-prefix-cls}-title-content { diff --git a/libs/dropdown/src/lib/dropdown/dropdown.component.stories.ts b/libs/dropdown/src/lib/dropdown/dropdown.component.stories.ts index 9f045d95f..dc2919594 100644 --- a/libs/dropdown/src/lib/dropdown/dropdown.component.stories.ts +++ b/libs/dropdown/src/lib/dropdown/dropdown.component.stories.ts @@ -38,7 +38,28 @@ export default { }, args: { items: [ - { action: 'action1', title: 'item1' }, + { + action: 'action1', + title: 'item1', + subItems: [ + { + action: 'action1', + title: 'subItem1', + subItems: [ + { action: 'action1', title: 'subItem1' }, + { action: 'action1', title: 'subItem2' }, + { action: 'action1', title: 'subItem3' }, + { action: 'action1', title: 'subItem4' }, + ], + }, + { action: 'action1', title: 'subItem2' }, + { action: 'action1', title: 'subItem3' }, + { action: 'action1', title: 'subItem4' }, + ], + }, + { action: 'action2', title: 'item2' }, + { action: 'action2', title: 'item2' }, + { action: 'action2', title: 'item2' }, { action: 'action2', title: 'item2' }, ], placement: 'bottomRight', @@ -56,9 +77,12 @@ export const primary = (args) => ({ [items]="items" [placement]="placement" [visible]="visible" + [trigger]="trigger" [disabled]="disabled"> {{ trigger }} me </spy-dropdown> + + <input /> </div> `, }); diff --git a/libs/dropdown/src/lib/dropdown/dropdown.component.ts b/libs/dropdown/src/lib/dropdown/dropdown.component.ts index 6ecc8ff38..2c3d63b58 100644 --- a/libs/dropdown/src/lib/dropdown/dropdown.component.ts +++ b/libs/dropdown/src/lib/dropdown/dropdown.component.ts @@ -2,12 +2,17 @@ import { booleanAttribute, ChangeDetectionStrategy, Component, + ElementRef, EventEmitter, - HostBinding, Input, Output, + QueryList, + ViewChild, + ViewChildren, ViewEncapsulation, } from '@angular/core'; +import { NzDropDownDirective } from 'ng-zorro-antd/dropdown'; +import { NzMenuDirective, NzSubMenuComponent } from 'ng-zorro-antd/menu'; export interface DropdownItem { action: string; @@ -25,15 +30,111 @@ export type Trigger = 'click' | 'hover'; styleUrls: ['./dropdown.component.less'], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, + host: { + '[class.spy-dropdown--open]': 'visible', + }, }) export class DropdownComponent { @Input() items: DropdownItem[] = []; @Input() placement: Placement = 'bottomRight'; @Input() trigger: Trigger = 'hover'; - @HostBinding('class.spy-dropdown--open') - @Input({ transform: booleanAttribute }) - visible = false; + @Input() ariaLabel: string = null; + @Input({ transform: booleanAttribute }) visible = true; @Input({ transform: booleanAttribute }) disabled = false; @Output() visibleChange = new EventEmitter<boolean>(); @Output() actionTriggered = new EventEmitter<string>(); + + @ViewChild(NzDropDownDirective, { read: ElementRef }) menuTrigger: ElementRef<HTMLElement>; + @ViewChild(NzMenuDirective, { read: ElementRef }) menu: ElementRef<HTMLElement>; + + @ViewChildren(NzSubMenuComponent) submenuTriggers: QueryList<NzSubMenuComponent>; + @ViewChildren('submenu') submenuElements: QueryList<ElementRef<HTMLElement>>; + + onKeyDown(event: KeyboardEvent): void { + if (event.key === 'Tab' && document.activeElement === this.menuTrigger.nativeElement) { + this.visible = false; + + return; + } + + const events = ['Enter', ' ', 'ArrowDown']; + + if (!events.includes(event.key) || !this.items.length) { + return; + } + + event.preventDefault(); + + if (event.key === 'ArrowDown') { + this.getMenuItems(this.menu.nativeElement)[0].focus(); + + return; + } + + this.visible = !this.visible; + } + + onMenuKeyDown(event: KeyboardEvent, submenuIndex?: number): void { + const events = ['ArrowUp', 'Tab', 'ArrowDown']; + const items = this.getMenuItems(event.currentTarget as HTMLElement); + const { length } = items; + + if (!events.includes(event.key) || !length) { + return; + } + + event.preventDefault(); + + if (event.key === 'Tab' && event.shiftKey) { + const focusElement = + submenuIndex === undefined + ? this.menuTrigger.nativeElement + : this.getMenuItems( + submenuIndex === 0 + ? this.menu.nativeElement + : this.submenuElements.get(submenuIndex - 1).nativeElement, + )[0]; + + focusElement.focus(); + this.visible = submenuIndex !== undefined; + this.submenuTriggers.get(submenuIndex)?.setOpenStateWithoutDebounce(false); + + return; + } + + const currentIndex = items.findIndex((item) => item === document.activeElement); + const index = event.key === 'ArrowUp' ? (currentIndex - 1 + length) % length : (currentIndex + 1) % length; + + items[index].focus(); + } + + onItemKeyDown(event: KeyboardEvent, submenuIndex?: number): void { + if (event.key !== ' ' && event.key !== 'Enter') { + return; + } + + const target = event.target as HTMLElement; + + target.click(); + + if (submenuIndex !== undefined) { + this.submenuTriggers.get(submenuIndex).setOpenStateWithoutDebounce(true); + + setTimeout(() => { + this.getMenuItems(this.submenuElements.get(submenuIndex).nativeElement)[0].focus(); + }); + + return; + } + + this.submenuTriggers.forEach((trigger) => trigger.setOpenStateWithoutDebounce(false)); + } + + onItemClick(action: string): void { + this.actionTriggered.emit(action); + } + + private getMenuItems(menu: HTMLElement): HTMLElement[] { + return Array.from(menu.querySelectorAll('.spy-dropdown-item__item')); + } } diff --git a/libs/styles/src/lib/themes/default/variables/common.less b/libs/styles/src/lib/themes/default/variables/common.less index bde6a4b38..3d8efad81 100644 --- a/libs/styles/src/lib/themes/default/variables/common.less +++ b/libs/styles/src/lib/themes/default/variables/common.less @@ -17,6 +17,11 @@ @box-shadow-2: var(--spy-box-shadow-2, 1px 3px 18px @ink-effect); @box-shadow-3: var(--spy-box-shadow-3, -2px 2px 20px @ink-effect); +@outline-color: var(--spy-outline-color, @green); +@outline-width: var(--spy-outline-width, 1px); +@outline: var(--spy-outline, @outline-width solid @outline-color); +@outline-offset: var(--spy-outline-offset, 2px); + // Breakpoints /* stylelint-disable property-no-unknown */