Skip to content

Commit a821a3e

Browse files
authored
feat(aria/grid): create the aria grid (#32092)
1 parent a0b5800 commit a821a3e

38 files changed

+2195
-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: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
load("//tools:defaults.bzl", "ng_project")
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+
)

src/aria/grid/grid.ts

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

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: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
load("//tools:defaults.bzl", "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+
)

0 commit comments

Comments
 (0)