Skip to content

Commit f9608cf

Browse files
committed
feat(aria): add grid and datepicker simple-combobox examples
1 parent f130935 commit f9608cf

File tree

18 files changed

+668
-13
lines changed

18 files changed

+668
-13
lines changed

src/aria/grid/grid.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ export class Grid {
124124
/** Whether enable range selections (with modifier keys or dragging). */
125125
readonly enableRangeSelection = input(false, {transform: booleanAttribute});
126126

127+
/** Overrides the default tab index of the grid. */
128+
readonly tabIndex = input<number | undefined>(undefined);
129+
127130
/** The UI pattern for the grid. */
128131
readonly _pattern = new GridPattern({
129132
...this,
@@ -156,6 +159,11 @@ export class Grid {
156159
afterRenderEffect(() => this._pattern.focusEffect());
157160
}
158161

162+
/** Scrolls the active cell into view. */
163+
scrollActiveCellIntoView(options: ScrollIntoViewOptions = {block: 'nearest'}) {
164+
this._pattern.activeCell()?.element().scrollIntoView(options);
165+
}
166+
159167
/** Gets the cell pattern for a given element. */
160168
private _getCell(element: Element | null | undefined): GridCellPattern | undefined {
161169
let target = element;

src/aria/private/behaviors/grid/grid-focus.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ export interface GridFocusInputs {
3131

3232
/** Whether disabled cells in the grid should be focusable. */
3333
softDisabled: SignalLike<boolean>;
34+
35+
/** Overrides the default tab index of the grid. */
36+
tabIndex?: SignalLike<number | undefined>;
3437
}
3538

3639
/** Dependencies for the `GridFocus` class. */
@@ -96,6 +99,11 @@ export class GridFocus<T extends GridFocusCell> {
9699

97100
/** The tab index for the grid container. */
98101
readonly gridTabIndex = computed<-1 | 0>(() => {
102+
const tabIndexOverride = this.inputs.tabIndex?.();
103+
if (tabIndexOverride !== undefined && tabIndexOverride !== null) {
104+
return (tabIndexOverride === -1 ? -1 : 0) as -1 | 0;
105+
}
106+
99107
if (this.gridDisabled()) {
100108
return 0;
101109
}

src/aria/private/behaviors/grid/grid.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -318,20 +318,45 @@ export class Grid<T extends GridCell> {
318318
}
319319

320320
if (this.focusBehavior.stateStale()) {
321+
const activeCell = this.focusBehavior.activeCell();
322+
const activeCoords = this.focusBehavior.activeCoords();
323+
321324
// Try focus on the same active cell after if a reordering happened.
322-
if (this.focusBehavior.focusCell(this.focusBehavior.activeCell()!)) {
325+
if (activeCell && this.focusBehavior.focusCell(activeCell)) {
323326
return true;
324327
}
325328

326329
// If the active cell is no longer exist, focus on the coordinates instead.
327-
if (this.focusBehavior.focusCoordinates(this.focusBehavior.activeCoords())) {
330+
if (this.focusBehavior.focusCoordinates(activeCoords)) {
328331
return true;
329332
}
330333

334+
// If the coordinates are no longer valid (e.g. because the row was deleted at the end),
335+
// try to focus on the previous row focusing on the same column.
336+
const maxRow = this.data.maxRowCount() - 1;
337+
const targetRow = Math.min(activeCoords.row, maxRow);
338+
339+
if (targetRow >= 0) {
340+
// Try same column in the clamped row.
341+
if (this.focusBehavior.focusCoordinates({row: targetRow, col: activeCoords.col})) {
342+
return true;
343+
}
344+
345+
// If that fails, try to find ANY cell in that row.
346+
const firstInRow = this.navigationBehavior.peekFirst(targetRow);
347+
if (firstInRow !== undefined && this.focusBehavior.focusCoordinates(firstInRow)) {
348+
return true;
349+
}
350+
}
351+
331352
// If the coordinates no longer valid, go back to the first available cell.
332-
if (this.focusBehavior.focusCoordinates(this.navigationBehavior.peekFirst()!)) {
353+
const firstAvailable = this.navigationBehavior.peekFirst();
354+
if (firstAvailable !== undefined && this.focusBehavior.focusCoordinates(firstAvailable)) {
333355
return true;
334356
}
357+
358+
this.focusBehavior.activeCell.set(undefined);
359+
this.focusBehavior.activeCoords.set({row: -1, col: -1});
335360
}
336361

337362
return false;

src/aria/private/grid/grid.spec.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,16 @@ describe('Grid', () => {
298298
expect(widget.isActivated()).toBe(true);
299299
});
300300

301+
it('should trigger click on Enter for simple widget', () => {
302+
const {grid} = createGrid([{cells: [{widgets: [{widgetType: 'simple'}]}]}], gridInputs);
303+
const widget = grid.cells()[0][0].inputs.widgets()[0];
304+
const element = widget.element();
305+
spyOn(element, 'click');
306+
307+
widget.onKeydown(enter());
308+
expect(element.click).toHaveBeenCalled();
309+
});
310+
301311
it('should not activate if disabled', () => {
302312
const {grid} = createGrid(
303313
[{cells: [{widgets: [{widgetType: 'complex', disabled: true}]}]}],

src/aria/private/grid/grid.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ export class GridPattern {
249249
onKeydown(event: KeyboardEvent) {
250250
if (this.disabled()) return;
251251

252+
this.hasBeenFocused.set(true);
252253
this.activeCell()?.onKeydown(event);
253254
this.keydown().handle(event);
254255
}
@@ -325,7 +326,10 @@ export class GridPattern {
325326

326327
/** Sets the default active state of the grid before receiving focus the first time. */
327328
setDefaultStateEffect(): void {
328-
if (this.hasBeenFocused()) return;
329+
if (this.hasBeenFocused() || !this.gridBehavior.focusBehavior.stateEmpty()) {
330+
this.hasBeenFocused.set(true);
331+
return;
332+
}
329333

330334
this.gridBehavior.setDefaultState();
331335
}

src/aria/private/grid/widget.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,11 @@ export class GridCellWidgetPattern implements ListNavigationItem {
7676
const manager = new KeyboardEventManager();
7777

7878
// Simple widget does not need to pause default grid behaviors.
79+
// However, it does need to capture Enter key and trigger a click on the host element
80+
// since the browser won't do it for us in activedescendant mode.
7981
if (this.inputs.widgetType() === 'simple') {
82+
console.log('simple widget keydown');
83+
manager.on('Enter', () => this.element().click());
8084
return manager;
8185
}
8286

@@ -114,6 +118,7 @@ export class GridCellWidgetPattern implements ListNavigationItem {
114118
/** Handles keydown events for the widget. */
115119
onKeydown(event: KeyboardEvent): void {
116120
if (this.disabled()) return;
121+
console.log('keydown of widget.ts');
117122

118123
this.keydown().handle(event);
119124
}

src/aria/private/simple-combobox/simple-combobox.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,17 +107,17 @@ export class SimpleComboboxPattern {
107107
e => {
108108
this.keyboardEventRelay.set(e);
109109
},
110-
{preventDefault: this.popupType() !== 'listbox'},
110+
{preventDefault: this.popupType() !== 'listbox', ignoreRepeat: false},
111111
)
112112
.on(
113113
'ArrowRight',
114114
e => {
115115
this.keyboardEventRelay.set(e);
116116
},
117-
{preventDefault: this.popupType() !== 'listbox'},
117+
{preventDefault: this.popupType() !== 'listbox', ignoreRepeat: false},
118118
)
119-
.on('ArrowUp', e => this.keyboardEventRelay.set(e))
120-
.on('ArrowDown', e => this.keyboardEventRelay.set(e))
119+
.on('ArrowUp', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false})
120+
.on('ArrowDown', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false})
121121
.on('Home', e => this.keyboardEventRelay.set(e))
122122
.on('End', e => this.keyboardEventRelay.set(e))
123123
.on('Enter', e => this.keyboardEventRelay.set(e))

src/components-examples/aria/simple-combobox/BUILD.bazel

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,16 @@ ng_project(
1313
"//:node_modules/@angular/common",
1414
"//:node_modules/@angular/core",
1515
"//:node_modules/@angular/forms",
16+
"//src/aria/grid",
1617
"//src/aria/listbox",
1718
"//src/aria/simple-combobox",
1819
"//src/aria/tree",
20+
"//src/cdk/a11y",
1921
"//src/cdk/overlay",
22+
"//src/material/checkbox",
23+
"//src/material/core",
24+
"//src/material/icon",
25+
"//src/material/tooltip",
2026
],
2127
)
2228

src/components-examples/aria/simple-combobox/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ export {SimpleComboboxListboxExample} from './simple-combobox-listbox/simple-com
22
export {SimpleComboboxListboxInlineExample} from './simple-combobox-listbox-inline/simple-combobox-listbox-inline-example';
33
export {SimpleComboboxTreeExample} from './simple-combobox-tree/simple-combobox-tree-example';
44
export {SimpleComboboxSelectExample} from './simple-combobox-select/simple-combobox-select-example';
5+
export {SimpleComboboxGridExample} from './simple-combobox-grid/simple-combobox-grid-example';
6+
export {SimpleComboboxDatepickerExample} from './simple-combobox-datepicker/simple-combobox-datepicker-example';
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
.example-datepicker-popup {
2+
padding: 16px;
3+
width: 320px;
4+
max-height: none;
5+
overflow: visible;
6+
background-color: var(--mat-sys-surface);
7+
border: 1px solid var(--mat-sys-outline);
8+
border-radius: var(--mat-sys-corner-extra-small);
9+
box-shadow: var(--mat-sys-level2-shadow);
10+
}
11+
12+
.example-datepicker-header {
13+
display: flex;
14+
justify-content: space-between;
15+
align-items: center;
16+
padding-bottom: 12px;
17+
border-bottom: 1px solid var(--mat-sys-outline-variant);
18+
margin-bottom: 12px;
19+
}
20+
21+
.example-datepicker-title {
22+
font-weight: 600;
23+
font-size: 0.9rem;
24+
color: var(--mat-sys-on-surface);
25+
}
26+
27+
.example-datepicker-nav-button {
28+
background-color: transparent;
29+
border: none;
30+
border-radius: 50%;
31+
width: 32px;
32+
height: 32px;
33+
display: flex;
34+
align-items: center;
35+
justify-content: center;
36+
cursor: pointer;
37+
color: var(--mat-sys-on-surface);
38+
transition: background-color 0.2s ease;
39+
}
40+
41+
.example-datepicker-nav-button:hover {
42+
background-color: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent);
43+
}
44+
45+
.example-datepicker-grid {
46+
width: 100%;
47+
border-collapse: collapse;
48+
}
49+
50+
.example-datepicker-cell {
51+
width: 40px;
52+
height: 40px;
53+
text-align: center;
54+
vertical-align: middle;
55+
padding: 0;
56+
}
57+
58+
.example-datepicker-weekday {
59+
font-size: 0.75rem;
60+
font-weight: 500;
61+
color: var(--mat-sys-on-surface-variant);
62+
padding-bottom: 8px;
63+
}
64+
65+
.example-datepicker-empty {
66+
color: color-mix(in srgb, var(--mat-sys-on-surface) 30%, transparent);
67+
font-size: 0.8rem;
68+
}
69+
70+
.example-datepicker-day-button {
71+
width: 32px;
72+
height: 32px;
73+
border-radius: 50%;
74+
border: none;
75+
background-color: transparent;
76+
cursor: pointer;
77+
font-size: 0.85rem;
78+
color: var(--mat-sys-on-surface);
79+
transition:
80+
background-color 0.2s ease,
81+
color 0.2s ease;
82+
}
83+
84+
.example-datepicker-cell:hover .example-datepicker-day-button {
85+
background-color: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent);
86+
}
87+
88+
/* Show circular focus ring on the day button when active using box-shadow */
89+
/* Subdued grey by default when navigating from the input */
90+
.example-datepicker-cell[data-active='true'] .example-datepicker-day-button {
91+
box-shadow: 0 0 0 2px var(--mat-sys-outline);
92+
}
93+
94+
/* Highlight circle with primary color when the grid has actual focus */
95+
.example-datepicker-grid:focus .example-datepicker-cell[data-active='true'] .example-datepicker-day-button,
96+
.example-datepicker-grid:focus-within .example-datepicker-cell[data-active='true'] .example-datepicker-day-button {
97+
box-shadow: 0 0 0 2px var(--mat-sys-primary);
98+
}
99+
100+
/* Hide all grid focus indicators when focus is in the header navigation */
101+
.example-datepicker-header:focus-within~.example-datepicker-grid .example-datepicker-cell[data-active='true'] .example-datepicker-day-button {
102+
box-shadow: none;
103+
}
104+
105+
.example-datepicker-cell[aria-selected='true'] .example-datepicker-day-button {
106+
background-color: var(--mat-sys-primary);
107+
color: var(--mat-sys-on-primary);
108+
}

0 commit comments

Comments
 (0)