Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Keyboard Accessible fixes #634

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
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
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,8 @@
background: @gray-lighter;
color: @ink;
}

&-content-inner {
display: contents;
}
}
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down
59 changes: 43 additions & 16 deletions libs/dropdown/src/lib/dropdown/dropdown.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
47 changes: 29 additions & 18 deletions libs/dropdown/src/lib/dropdown/dropdown.component.less
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
&-trigger {
display: flex;
height: 100%;

&:focus-visible {
outline: @outline;
outline-offset: @outline-offset;
}
}

&-menu {
Expand All @@ -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 {
Expand Down
26 changes: 25 additions & 1 deletion libs/dropdown/src/lib/dropdown/dropdown.component.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -56,9 +77,12 @@ export const primary = (args) => ({
[items]="items"
[placement]="placement"
[visible]="visible"
[trigger]="trigger"
[disabled]="disabled">
{{ trigger }} me
</spy-dropdown>

<input />
</div>
`,
});
Expand Down
109 changes: 105 additions & 4 deletions libs/dropdown/src/lib/dropdown/dropdown.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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'));
}
}
5 changes: 5 additions & 0 deletions libs/styles/src/lib/themes/default/variables/common.less
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down