Skip to content

Commit 4747189

Browse files
musalegavinbarron
andauthored
fix: make tasks header navigable with the keyboard. (#2313)
update the mgt-arrow-options to use fluent-menu update mgt-arrow-options items to use fluent ui tokens change to use a chevron separator use a fluentui button for arrow default state enter key press on the header to show the menu and focus the first menu element close the menu when it has focus on element and tab key is pressed return focus to header and close menu when you press Escape set focus on header after selecting an element click outside open menu closes it Signed-off-by: Musale Martin <[email protected]> Co-authored-by: Gavin Barron <[email protected]>
1 parent af34d15 commit 4747189

File tree

6 files changed

+133
-90
lines changed

6 files changed

+133
-90
lines changed

gulpfile.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ scssFileHeader = `
1111
// ANY CHANGES WILL BE LOST DURING BUILD
1212
// MODIFY THE .SCSS FILE INSTEAD
1313
14-
import { css } from 'lit';
14+
import { css, CSSResult } from 'lit';
1515
/**
1616
* exports lit-element css
1717
* @export styles
1818
*/
19-
export const styles = [
19+
export const styles: CSSResult[] = [
2020
css\``;
2121

2222
scssFileFooter = '`];';

packages/mgt-components/src/components/mgt-tasks/mgt-tasks.scss

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,20 @@ $task-new-dropdown-border-radius: var(--task-new-dropdown-border-radius, calc(va
7171

7272
.title {
7373
justify-content: left;
74+
display: flex;
75+
align-items: center;
7476

7577
.shimmer {
7678
width: 80px;
7779
height: 20px;
7880
}
81+
82+
svg {
83+
margin-top: 3px;
84+
padding: 0 4px;
85+
width: 16px;
86+
height: 16px;
87+
}
7988
}
8089

8190
.new-task-button {

packages/mgt-components/src/components/mgt-tasks/mgt-tasks.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { classMap } from 'lit/directives/class-map.js';
2121
import { repeat } from 'lit/directives/repeat.js';
2222
import { ifDefined } from 'lit/directives/if-defined.js';
2323
import { getMe } from '../../graph/graph.user';
24-
import { debounce, getShortDateString } from '../../utils/Utils';
24+
import { getShortDateString } from '../../utils/Utils';
2525
import { MgtPeoplePicker } from '../mgt-people-picker/mgt-people-picker';
2626
import { MgtPeople } from '../mgt-people/mgt-people';
2727
import '../mgt-person/mgt-person';
@@ -901,11 +901,13 @@ export class MgtTasks extends MgtTemplatedComponent {
901901
this._currentFolder = null;
902902
};
903903
}
904-
const groupSelect = mgtHtml`
905-
<mgt-arrow-options class="arrow-options" .options="${groupOptions}" .value="${currentGroup.title}"></mgt-arrow-options>
906-
`;
904+
const groupSelect: TemplateResult = mgtHtml`
905+
<mgt-arrow-options
906+
class="arrow-options"
907+
.options="${groupOptions}"
908+
.value="${currentGroup.title}"></mgt-arrow-options>`;
907909

908-
const divider = !this._currentGroup ? null : html`<fluent-divider></fluent-divider>`;
910+
const separator = !this._currentGroup ? null : getSvg(SvgIcon.ChevronRight);
909911

910912
const currentFolder = this._folders.find(d => d.id === this._currentFolder) || {
911913
name: this.res.BUCKETS_SELF_ASSIGNED
@@ -932,8 +934,8 @@ export class MgtTasks extends MgtTemplatedComponent {
932934
`;
933935

934936
return html`
935-
<div class="title">
936-
${groupSelect} ${divider} ${!this._currentGroup ? null : folderSelect}
937+
<div class="Title">
938+
${groupSelect} ${separator} ${!this._currentGroup ? null : folderSelect}
937939
</div>
938940
${addButton}
939941
`;

packages/mgt-components/src/components/sub-components/mgt-arrow-options/mgt-arrow-options.scss

Lines changed: 23 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -9,70 +9,43 @@
99
@import '../../../styles/shared-sass-variables';
1010

1111
$arrow-options-left: var(--arrow-options-left, 0);
12+
$arrow-options-btn-bg-color: var(--arrow-options-button-background-color, var(--neutral-layer-2));
13+
$arrow-options-btn-font-size: var(--arrow-options-button-font-size, large);
14+
$arrow-options-btn-font-weight: var(--arrow-options-button-font-weight, 600);
15+
$arrow-options-btn-font-color: var(--arrow-options-button-font-color, var(--accent-base-color));
1216

1317
:host {
1418
position: relative;
1519
font-family: $font-family;
1620

17-
.arrow-icon {
18-
font-family: $font-icon;
19-
margin: 0 0 0 20px;
20-
user-select: none;
21-
22-
}
23-
2421
.header {
25-
cursor: pointer;
26-
27-
&:hover {
28-
color: var(--theme-primary-color);
22+
&::part(control){
23+
font-size: $arrow-options-btn-font-size;
24+
font-weight: $arrow-options-btn-font-weight;
25+
color: $arrow-options-btn-font-color;
26+
background: $arrow-options-btn-bg-color;
27+
28+
&:hover {
29+
background: var(--neutral-fill-stealth-hover);
30+
}
31+
32+
&:active,
33+
&:focus {
34+
background: var(--neutral-fill-stealth-active);
35+
}
2936
}
3037
}
3138

32-
.menu {
39+
.menu{
3340
position: absolute;
34-
left: var(--arrow-options-left, 0);
35-
box-shadow: set-var(box-shadow__color, $theme-default, $common) 0 0 40px 5px;
36-
background: set-var(background-color, $theme-default, $common);
41+
left: $arrow-options-left;
3742
z-index: 1;
3843
display: none;
39-
color: set-var(color, $theme-default, $common);
40-
white-space: nowrap;
41-
42-
&.open {
44+
45+
&.open{
4346
display: block;
47+
width: max-content;
4448
}
4549
}
4650

47-
.menu-option {
48-
padding: 20px;
49-
cursor: pointer;
50-
user-select: none;
51-
display: flex;
52-
align-items: center;
53-
justify-content: stretch;
54-
justify-items: stretch;
55-
56-
&:first {
57-
padding: 12px 20px 20px;
58-
}
59-
60-
&:hover {
61-
background-color: set-var(background-color--hover, $theme-default, $common);
62-
}
63-
64-
&:active {
65-
background-color: set-var(background-color--active, $theme-default, $common);
66-
}
67-
}
68-
69-
.menu-option-check {
70-
font-family: $font-icon;
71-
color: rgb(0 0 0 / 0%);
72-
margin-right: 10px;
73-
74-
&.current-value {
75-
color: $comm-blue-primary;
76-
}
77-
}
7851
}

packages/mgt-components/src/components/sub-components/mgt-arrow-options/mgt-arrow-options.ts

Lines changed: 88 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import { property } from 'lit/decorators.js';
1010
import { classMap } from 'lit/directives/class-map.js';
1111
import { MgtBaseComponent, customElement } from '@microsoft/mgt-element';
1212
import { styles } from './mgt-arrow-options-css';
13+
import { registerFluentComponents } from '../../../utils/FluentComponents';
14+
import { fluentMenu, fluentMenuItem, fluentButton } from '@fluentui/web-components';
15+
registerFluentComponents(fluentMenu, fluentMenuItem, fluentButton);
1316

1417
/*
1518
Ok, the name here deserves a bit of explanation,
@@ -22,6 +25,12 @@ import { styles } from './mgt-arrow-options-css';
2225
/**
2326
* Custom Component used to handle an arrow rendering for TaskGroups utilized in the task component.
2427
*
28+
* @cssprop --arrow-options-left {Length} The distance of the dropdown menu from the left in absolute position. Default is 0.
29+
* @cssprop --arrow-options-button-background-color {Color} The background color of the arrow options button.
30+
* @cssprop --arrow-options-button-font-size {Length} The font size of the button text. Default is large.
31+
* @cssprop --arrow-options-button-font-weight {Length} The font weight of the button text. Default is 600.
32+
* @cssprop --arrow-options-button-font-color {Color} The font color of the text in the button.
33+
*
2534
* @export MgtArrowOptions
2635
* @class MgtArrowOptions
2736
* @extends {MgtBaseComponent}
@@ -53,20 +62,21 @@ export class MgtArrowOptions extends MgtBaseComponent {
5362
@property({ type: String }) public value: string;
5463

5564
/**
56-
* Menu options to be rendered with an attached MouseEvent handler for expansion of details
65+
* Menu options to be rendered with an attached UIEvent handler for expansion of details
5766
*
5867
* @type {object}
5968
* @memberof MgtArrowOptions
6069
*/
61-
@property({ type: Object }) public options: { [name: string]: (e: MouseEvent) => any | void };
70+
@property({ type: Object }) public options: { [name: string]: (e: UIEvent) => any | void };
6271

63-
private _clickHandler: (e: MouseEvent) => void | any;
72+
private _clickHandler: (e: UIEvent) => void | any;
6473

6574
constructor() {
6675
super();
6776
this.value = '';
6877
this.options = {};
69-
this._clickHandler = (e: MouseEvent) => (this.open = false);
78+
this._clickHandler = () => (this.open = false);
79+
window.addEventListener('onblur', () => (this.open = false));
7080
}
7181

7282
// eslint-disable-next-line @typescript-eslint/tslint/config
@@ -96,41 +106,90 @@ export class MgtArrowOptions extends MgtBaseComponent {
96106
}
97107
};
98108

109+
/**
110+
* Handles key down presses done on the header element.
111+
*
112+
* @param {KeyboardEvent} e
113+
*/
114+
private onHeaderKeyDown = (e: KeyboardEvent) => {
115+
if (e.key === 'Enter') {
116+
e.preventDefault();
117+
e.stopPropagation();
118+
this.open = !this.open;
119+
120+
// Manually adding the 'open' class to display the menu because
121+
// by the time I set the first element's focus, the classes are not
122+
// updated and that has no effect. You can't set focus on elements
123+
// that have no display.
124+
const fluentMenuEl: HTMLElement = this.renderRoot.querySelector('fluent-menu');
125+
if (fluentMenuEl) {
126+
fluentMenuEl.classList.remove('closed');
127+
fluentMenuEl.classList.add('open');
128+
}
129+
130+
const header: HTMLButtonElement = e.target as HTMLButtonElement;
131+
if (header) {
132+
const firstMenuItem: HTMLElement = this.renderRoot.querySelector("fluent-menu-item[tabindex='0']");
133+
if (firstMenuItem) {
134+
header.blur();
135+
firstMenuItem.focus();
136+
}
137+
}
138+
}
139+
};
140+
99141
/**
100142
* Invoked on each update to perform rendering tasks. This method must return
101143
* a lit-html TemplateResult. Setting properties inside this method will *not*
102144
* trigger the element to update.
103145
*/
104146
public render() {
105147
return html`
106-
<span class="header" @click=${this.onHeaderClick}>
107-
<span class="current-value">${this.value}</span>
108-
</span>
109-
<div class=${classMap({ menu: true, open: this.open, closed: !this.open })}>
110-
${this.getMenuOptions()}
111-
</div>
112-
`;
148+
<fluent-button
149+
class="header"
150+
@click=${this.onHeaderClick}
151+
@keydown=${this.onHeaderKeyDown}
152+
appearance="lightweight">
153+
${this.value}
154+
</fluent-button>
155+
<fluent-menu
156+
class=${classMap({ menu: true, open: this.open, closed: !this.open })}>
157+
${this.getMenuOptions()}
158+
</fluent-menu>`;
113159
}
114160

115161
private getMenuOptions() {
116162
const keys = Object.keys(this.options);
117-
const funcs = this.options;
118-
119-
return keys.map(
120-
opt => html`
121-
<div
122-
class="menu-option"
123-
@click="${(e: MouseEvent) => {
124-
this.open = false;
125-
funcs[opt](e);
126-
}}"
127-
>
128-
<span class=${classMap({ 'menu-option-check': true, 'current-value': this.value === opt })}>
129-
\uE73E
130-
</span>
131-
<span class="menu-option-name">${opt}</span>
132-
</div>
133-
`
134-
);
163+
164+
return keys.map((opt: string) => {
165+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
166+
const clickFn = (e: MouseEvent) => {
167+
this.open = false;
168+
this.options[opt](e);
169+
};
170+
171+
const keyDownFn = (e: KeyboardEvent) => {
172+
const header: HTMLButtonElement = this.renderRoot.querySelector<HTMLButtonElement>('.header');
173+
if (e.key === 'Enter') {
174+
this.open = false;
175+
this.options[opt](e);
176+
header.focus();
177+
} else if (e.key === 'Tab') {
178+
this.open = false;
179+
} else if (e.key === 'Escape') {
180+
this.open = false;
181+
if (header) {
182+
header.focus();
183+
}
184+
}
185+
};
186+
187+
return html`
188+
<fluent-menu-item
189+
@click=${clickFn}
190+
@keydown=${keyDownFn}>
191+
${opt}
192+
</fluent-menu-item>`;
193+
});
135194
}
136195
}

packages/mgt-components/src/utils/SvgHelper.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -634,8 +634,8 @@ export const getSvg = (svgIcon: SvgIcon, color?: string) => {
634634

635635
case SvgIcon.ChevronRight:
636636
return html`
637-
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
638-
<path d="M8.46967 4.21967C8.17678 4.51256 8.17678 4.98744 8.46967 5.28033L15.1893 12L8.46967 18.7197C8.17678 19.0126 8.17678 19.4874 8.46967 19.7803C8.76256 20.0732 9.23744 20.0732 9.53033 19.7803L16.7803 12.5303C17.0732 12.2374 17.0732 11.7626 16.7803 11.4697L9.53033 4.21967C9.23744 3.92678 8.76256 3.92678 8.46967 4.21967Z" fill="none" />
637+
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
638+
<path d="M8.46967 4.21967C8.17678 4.51256 8.17678 4.98744 8.46967 5.28033L15.1893 12L8.46967 18.7197C8.17678 19.0126 8.17678 19.4874 8.46967 19.7803C8.76256 20.0732 9.23744 20.0732 9.53033 19.7803L16.7803 12.5303C17.0732 12.2374 17.0732 11.7626 16.7803 11.4697L9.53033 4.21967C9.23744 3.92678 8.76256 3.92678 8.46967 4.21967Z" fill="currentColor" />
639639
</svg>`;
640640

641641
case SvgIcon.Delete:

0 commit comments

Comments
 (0)