Skip to content

Commit cf2bab8

Browse files
committed
feat(aria/grid): create the aria grid
1 parent f9d3cde commit cf2bab8

38 files changed

+2139
-2
lines changed

.ng-dev/commit-message.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const commitMessage: CommitMessageConfig = {
1111
'multiple', // For when a commit applies to multiple components.
1212
'aria/accordion',
1313
'aria/combobox',
14+
'aria/grid',
1415
'aria/listbox',
1516
'aria/menu',
1617
'aria/radio-group',

src/aria/config.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ ARIA_ENTRYPOINTS = [
33
"accordion",
44
"combobox",
55
"deferred-content",
6+
"grid",
67
"listbox",
78
"menu",
89
"radio-group",

src/aria/grid/BUILD.bazel

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ng_project(
6+
name = "grid",
7+
srcs = [
8+
"grid.ts",
9+
"index.ts",
10+
],
11+
deps = [
12+
"//:node_modules/@angular/core",
13+
"//src/aria/deferred-content",
14+
"//src/aria/ui-patterns",
15+
"//src/cdk/a11y",
16+
"//src/cdk/bidi",
17+
],
18+
)
19+
20+
ng_project(
21+
name = "unit_test_sources",
22+
testonly = True,
23+
srcs = [
24+
"grid.spec.ts",
25+
],
26+
deps = [
27+
"//:node_modules/@angular/core",
28+
"//:node_modules/@angular/platform-browser",
29+
"//src/cdk/testing/private",
30+
],
31+
)
32+
33+
ng_web_test_suite(
34+
name = "unit_tests",
35+
deps = [":unit_test_sources"],
36+
)

src/aria/grid/grid.ts

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {_IdGenerator} from '@angular/cdk/a11y';
10+
import {
11+
afterRenderEffect,
12+
booleanAttribute,
13+
computed,
14+
contentChild,
15+
contentChildren,
16+
Directive,
17+
ElementRef,
18+
inject,
19+
input,
20+
model,
21+
signal,
22+
Signal,
23+
untracked,
24+
} from '@angular/core';
25+
import {GridPattern, GridRowPattern, GridCellPattern, GridCellWidgetPattern} from '../ui-patterns';
26+
27+
/** A directive that provides grid-based navigation and selection behavior. */
28+
@Directive({
29+
selector: '[ngGrid]',
30+
exportAs: 'ngGrid',
31+
host: {
32+
'class': 'grid',
33+
'role': 'grid',
34+
'[tabindex]': 'pattern.tabIndex()',
35+
'[attr.aria-disabled]': 'pattern.disabled()',
36+
'[attr.aria-activedescendant]': 'pattern.activeDescendant()',
37+
'(keydown)': 'pattern.onKeydown($event)',
38+
'(pointerdown)': 'pattern.onPointerdown($event)',
39+
'(pointermove)': 'pattern.onPointermove($event)',
40+
'(pointerup)': 'pattern.onPointerup($event)',
41+
'(focusin)': 'onFocusIn()',
42+
'(focusout)': 'onFocusOut()',
43+
},
44+
})
45+
export class Grid {
46+
/** The rows that make up the grid. */
47+
private readonly _rows = contentChildren(GridRow);
48+
49+
/** The UI patterns for the rows in the grid. */
50+
private readonly _rowPatterns: Signal<GridRowPattern[]> = computed(() =>
51+
this._rows().map(r => r.pattern),
52+
);
53+
54+
/** Whether selection is enabled for the grid. */
55+
readonly enableSelection = input(false, {transform: booleanAttribute});
56+
57+
/** Whether the grid is disabled. */
58+
readonly disabled = input(false, {transform: booleanAttribute});
59+
60+
/** Whether to skip disabled items during navigation. */
61+
readonly skipDisabled = input(true, {transform: booleanAttribute});
62+
63+
/** The focus strategy used by the tree. */
64+
readonly focusMode = input<'roving' | 'activedescendant'>('roving');
65+
66+
/** The wrapping behavior for keyboard navigation along the row axis. */
67+
readonly rowWrap = input<'continuous' | 'loop' | 'nowrap'>('loop');
68+
69+
/** The wrapping behavior for keyboard navigation along the column axis. */
70+
readonly colWrap = input<'continuous' | 'loop' | 'nowrap'>('loop');
71+
72+
/** The UI pattern for the grid. */
73+
readonly pattern = new GridPattern({
74+
...this,
75+
rows: this._rowPatterns,
76+
getCell: e => this._getCell(e),
77+
});
78+
79+
/** Whether the focus is in the grid. */
80+
private readonly _isFocused = signal(false);
81+
82+
constructor() {
83+
afterRenderEffect(() => {
84+
this.pattern.resetState();
85+
});
86+
87+
afterRenderEffect(() => {
88+
const activeCell = this.pattern.activeCell();
89+
const hasFocus = untracked(() => this._isFocused());
90+
const isRoving = this.focusMode() === 'roving';
91+
if (activeCell !== undefined && isRoving && hasFocus) {
92+
activeCell.element().focus();
93+
}
94+
});
95+
}
96+
97+
/** Handles focusin events on the grid. */
98+
onFocusIn() {
99+
this._isFocused.set(true);
100+
}
101+
102+
/** Handles focusout events on the grid. */
103+
onFocusOut() {
104+
this._isFocused.set(false);
105+
}
106+
107+
/** Gets the cell pattern for a given element. */
108+
private _getCell(element: Element): GridCellPattern | undefined {
109+
const cellElement = element.closest('[ngGridCell]');
110+
if (cellElement === undefined) return;
111+
112+
const widgetElement = element.closest('[ngGridCellWidget]');
113+
for (const row of this._rowPatterns()) {
114+
for (const cell of row.inputs.cells()) {
115+
if (
116+
cell.element() === cellElement ||
117+
(widgetElement !== undefined && cell.element() === widgetElement)
118+
) {
119+
return cell;
120+
}
121+
}
122+
}
123+
return;
124+
}
125+
}
126+
127+
/** A directive that represents a row in a grid. */
128+
@Directive({
129+
selector: '[ngGridRow]',
130+
exportAs: 'ngGridRow',
131+
host: {
132+
'class': 'grid-row',
133+
'[attr.role]': 'role()',
134+
},
135+
})
136+
export class GridRow {
137+
/** A reference to the host element. */
138+
private readonly _elementRef = inject(ElementRef);
139+
140+
/** The cells that make up this row. */
141+
private readonly _cells = contentChildren(GridCell);
142+
143+
/** The UI patterns for the cells in this row. */
144+
private readonly _cellPatterns: Signal<GridCellPattern[]> = computed(() =>
145+
this._cells().map(c => c.pattern),
146+
);
147+
148+
/** The parent grid. */
149+
private readonly _grid = inject(Grid);
150+
151+
/** The parent grid UI pattern. */
152+
readonly grid = computed(() => this._grid.pattern);
153+
154+
/** The host native element. */
155+
readonly element = computed(() => this._elementRef.nativeElement);
156+
157+
/** The ARIA role for the row. */
158+
readonly role = input<'row' | 'rowheader'>('row');
159+
160+
/** The index of this row within the grid. */
161+
readonly rowIndex = input<number>();
162+
163+
/** The UI pattern for the grid row. */
164+
readonly pattern = new GridRowPattern({
165+
...this,
166+
cells: this._cellPatterns,
167+
});
168+
}
169+
170+
/** A directive that represents a cell in a grid. */
171+
@Directive({
172+
selector: '[ngGridCell]',
173+
exportAs: 'ngGridCell',
174+
host: {
175+
'class': 'grid-cell',
176+
'[attr.role]': 'role()',
177+
'[attr.rowspan]': 'pattern.rowSpan()',
178+
'[attr.colspan]': 'pattern.colSpan()',
179+
'[attr.data-active]': 'pattern.active()',
180+
'[attr.aria-disabled]': 'pattern.disabled()',
181+
'[attr.aria-rowspan]': 'pattern.rowSpan()',
182+
'[attr.aria-colspan]': 'pattern.colSpan()',
183+
'[attr.aria-rowindex]': 'pattern.ariaRowIndex()',
184+
'[attr.aria-colindex]': 'pattern.ariaColIndex()',
185+
'[attr.aria-selected]': 'pattern.ariaSelected()',
186+
'[tabindex]': 'pattern.tabIndex()',
187+
},
188+
})
189+
export class GridCell {
190+
/** A reference to the host element. */
191+
private readonly _elementRef = inject(ElementRef);
192+
193+
/** The widget contained within this cell, if any. */
194+
private readonly _widget = contentChild(GridCellWidget);
195+
196+
/** The UI pattern for the widget in this cell. */
197+
private readonly _widgetPattern: Signal<GridCellWidgetPattern | undefined> = computed(
198+
() => this._widget()?.pattern,
199+
);
200+
201+
/** The parent row. */
202+
private readonly _row = inject(GridRow);
203+
204+
/** A unique identifier for the cell. */
205+
private readonly _id = inject(_IdGenerator).getId('ng-grid-cell-');
206+
207+
/** The host native element. */
208+
readonly element = computed(() => this._elementRef.nativeElement);
209+
210+
/** The ARIA role for the cell. */
211+
readonly role = input<'gridcell' | 'columnheader'>('gridcell');
212+
213+
/** The number of rows the cell should span. */
214+
readonly rowSpan = input<number>(1);
215+
216+
/** The number of columns the cell should span. */
217+
readonly colSpan = input<number>(1);
218+
219+
/** The index of this cell's row within the grid. */
220+
readonly rowIndex = input<number>();
221+
222+
/** The index of this cell's column within the grid. */
223+
readonly colIndex = input<number>();
224+
225+
/** Whether the cell is disabled. */
226+
readonly disabled = input(false, {transform: booleanAttribute});
227+
228+
/** Whether the cell is selected. */
229+
readonly selected = model<boolean>(false);
230+
231+
/** Whether the cell is selectable. */
232+
readonly selectable = input<boolean>(true);
233+
234+
/** The UI pattern for the grid cell. */
235+
readonly pattern = new GridCellPattern({
236+
...this,
237+
id: () => this._id,
238+
grid: this._row.grid,
239+
row: () => this._row.pattern,
240+
widget: this._widgetPattern,
241+
});
242+
}
243+
244+
/** A directive that represents a widget inside a grid cell. */
245+
@Directive({
246+
selector: '[ngGridCellWidget]',
247+
exportAs: 'ngGridCellWidget',
248+
host: {
249+
'class': 'grid-cell-widget',
250+
'[attr.data-active]': 'pattern.active()',
251+
'[tabindex]': 'pattern.tabIndex()',
252+
},
253+
})
254+
export class GridCellWidget {
255+
/** A reference to the host element. */
256+
private readonly _elementRef = inject(ElementRef);
257+
258+
/** The parent cell. */
259+
private readonly _cell = inject(GridCell);
260+
261+
/** The host native element. */
262+
readonly element = computed(() => this._elementRef.nativeElement);
263+
264+
/** Whether grid navigation should be paused, usually because this widget has focus. */
265+
readonly pauseGridNavigation = model<boolean>(false);
266+
267+
/** The UI pattern for the grid cell widget. */
268+
readonly pattern = new GridCellWidgetPattern({
269+
...this,
270+
cell: () => this._cell.pattern,
271+
});
272+
}

src/aria/grid/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
export * from './grid';

src/aria/ui-patterns/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ ts_project(
1313
"//src/aria/ui-patterns/accordion",
1414
"//src/aria/ui-patterns/behaviors/signal-like",
1515
"//src/aria/ui-patterns/combobox",
16+
"//src/aria/ui-patterns/grid",
1617
"//src/aria/ui-patterns/listbox",
1718
"//src/aria/ui-patterns/menu",
1819
"//src/aria/ui-patterns/radio-group",

src/aria/ui-patterns/behaviors/event-manager/pointer-event-manager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export class PointerEventManager<T extends PointerEvent> extends EventManager<T>
7070
};
7171
}
7272

73-
if (typeof args[0] === 'number' && typeof args[1] === 'function') {
73+
if (args.length === 2) {
7474
return {
7575
button: MouseButton.Main,
7676
modifiers: args[0] as ModifierInputs,
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ts_project(
6+
name = "grid",
7+
srcs = glob(
8+
["**/*.ts"],
9+
exclude = ["**/*.spec.ts"],
10+
),
11+
deps = [
12+
"//:node_modules/@angular/core",
13+
"//src/aria/ui-patterns/behaviors/signal-like",
14+
],
15+
)
16+
17+
ng_project(
18+
name = "unit_test_sources",
19+
testonly = True,
20+
srcs = glob(["**/*.spec.ts"]),
21+
deps = [
22+
":grid",
23+
"//:node_modules/@angular/core",
24+
"//src/cdk/keycodes",
25+
"//src/cdk/testing/private",
26+
],
27+
)
28+
29+
ng_web_test_suite(
30+
name = "unit_tests",
31+
deps = [":unit_test_sources"],
32+
)

0 commit comments

Comments
 (0)