Skip to content

Commit a9bc49b

Browse files
committed
refactor(aria/grid): rework selection and add calendar example (#32210)
(cherry picked from commit 1092256)
1 parent 049e8d5 commit a9bc49b

File tree

20 files changed

+1300
-399
lines changed

20 files changed

+1300
-399
lines changed

src/aria/grid/grid.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export class Grid {
4646
private readonly _elementRef = inject(ElementRef);
4747

4848
/** The rows that make up the grid. */
49-
private readonly _rows = contentChildren(GridRow);
49+
private readonly _rows = contentChildren(GridRow, {descendants: true});
5050

5151
/** The UI patterns for the rows in the grid. */
5252
private readonly _rowPatterns: Signal<GridRowPattern[]> = computed(() =>
@@ -77,6 +77,15 @@ export class Grid {
7777
/** The wrapping behavior for keyboard navigation along the column axis. */
7878
readonly colWrap = input<'continuous' | 'loop' | 'nowrap'>('loop');
7979

80+
/** Whether multiple cells in the grid can be selected. */
81+
readonly multi = input(false, {transform: booleanAttribute});
82+
83+
/** The selection strategy used by the grid. */
84+
readonly selectionMode = input<'follow' | 'explicit'>('follow');
85+
86+
/** Whether enable range selections (with modifier keys or dragging). */
87+
readonly enableRangeSelection = input(false, {transform: booleanAttribute});
88+
8089
/** The UI pattern for the grid. */
8190
readonly _pattern = new GridPattern({
8291
...this,
@@ -85,6 +94,7 @@ export class Grid {
8594
});
8695

8796
constructor() {
97+
afterRenderEffect(() => this._pattern.setDefaultStateEffect());
8898
afterRenderEffect(() => this._pattern.resetStateEffect());
8999
afterRenderEffect(() => this._pattern.focusEffect());
90100
}
@@ -123,7 +133,7 @@ export class GridRow {
123133
private readonly _elementRef = inject(ElementRef);
124134

125135
/** The cells that make up this row. */
126-
private readonly _cells = contentChildren(GridCell);
136+
private readonly _cells = contentChildren(GridCell, {descendants: true});
127137

128138
/** The UI patterns for the cells in this row. */
129139
private readonly _cellPatterns: Signal<GridCellPattern[]> = computed(() =>
@@ -163,6 +173,7 @@ export class GridRow {
163173
'[attr.rowspan]': '_pattern.rowSpan()',
164174
'[attr.colspan]': '_pattern.colSpan()',
165175
'[attr.data-active]': '_pattern.active()',
176+
'[attr.data-anchor]': '_pattern.anchor()',
166177
'[attr.aria-disabled]': '_pattern.disabled()',
167178
'[attr.aria-rowspan]': '_pattern.rowSpan()',
168179
'[attr.aria-colspan]': '_pattern.colSpan()',

src/aria/private/behaviors/grid/grid-navigation.spec.ts

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,19 @@ describe('GridNavigation', () => {
177177

178178
expect(nextCoords).toBeUndefined();
179179
});
180+
181+
it('should get disabled cells when allowDisabled is true and softDisabled is false', () => {
182+
const {gridNav, gridFocus} = setupGridNavigation(signal(cells), {
183+
softDisabled: signal(false),
184+
});
185+
gridNav.gotoCoords({row: 1, col: 0});
186+
cells[0][0].disabled.set(true);
187+
188+
const nextCoords = gridNav.peek(direction.Up, gridFocus.activeCoords(), 'nowrap', true);
189+
190+
expect(nextCoords).toEqual({row: 0, col: 0});
191+
expect(gridNav.peek(direction.Up, gridFocus.activeCoords(), 'nowrap')).toBeUndefined();
192+
});
180193
});
181194

182195
describe('down', () => {
@@ -216,6 +229,19 @@ describe('GridNavigation', () => {
216229

217230
expect(nextCoords).toBeUndefined();
218231
});
232+
233+
it('should get disabled cells when allowDisabled is true and softDisabled is false', () => {
234+
const {gridNav, gridFocus} = setupGridNavigation(signal(cells), {
235+
softDisabled: signal(false),
236+
});
237+
gridNav.gotoCoords({row: 1, col: 0});
238+
cells[2][0].disabled.set(true);
239+
240+
const nextCoords = gridNav.peek(direction.Down, gridFocus.activeCoords(), 'nowrap', true);
241+
242+
expect(nextCoords).toEqual({row: 2, col: 0});
243+
expect(gridNav.peek(direction.Down, gridFocus.activeCoords(), 'nowrap')).toBeUndefined();
244+
});
219245
});
220246

221247
describe('left', () => {
@@ -237,9 +263,7 @@ describe('GridNavigation', () => {
237263
});
238264

239265
it('should return the next coordinates even if all cells are disabled', () => {
240-
cells.flat().forEach(function (cell) {
241-
cell.disabled.set(true);
242-
});
266+
cells.flat().forEach(c => c.disabled.set(true));
243267
gridNav.gotoCoords({row: 1, col: 0});
244268

245269
const nextCoords = gridNav.peek(direction.Left, gridFocus.activeCoords());
@@ -257,6 +281,19 @@ describe('GridNavigation', () => {
257281

258282
expect(nextCoords).toBeUndefined();
259283
});
284+
285+
it('should get disabled cells when allowDisabled is true when softDisabled is false', () => {
286+
const {gridNav, gridFocus} = setupGridNavigation(signal(cells), {
287+
softDisabled: signal(false),
288+
});
289+
gridNav.gotoCoords({row: 0, col: 1});
290+
cells[0][0].disabled.set(true);
291+
292+
const nextCoords = gridNav.peek(direction.Left, gridFocus.activeCoords(), 'nowrap', true);
293+
294+
expect(nextCoords).toEqual({row: 0, col: 0});
295+
expect(gridNav.peek(direction.Left, gridFocus.activeCoords(), 'nowrap')).toBeUndefined();
296+
});
260297
});
261298

262299
describe('right', () => {
@@ -278,9 +315,7 @@ describe('GridNavigation', () => {
278315
});
279316

280317
it('should return the next coordinates even if all cells are disabled', () => {
281-
cells.flat().forEach(function (cell) {
282-
cell.disabled.set(true);
283-
});
318+
cells.flat().forEach(c => c.disabled.set(true));
284319
gridNav.gotoCoords({row: 1, col: 0});
285320

286321
const nextCoords = gridNav.peek(direction.Right, gridFocus.activeCoords());
@@ -298,6 +333,19 @@ describe('GridNavigation', () => {
298333

299334
expect(nextCoords).toBeUndefined();
300335
});
336+
337+
it('should get disabled cells when allowDisabled is true and softDisabled is false', () => {
338+
const {gridNav, gridFocus} = setupGridNavigation(signal(cells), {
339+
softDisabled: signal(false),
340+
});
341+
gridNav.gotoCoords({row: 0, col: 1});
342+
cells[0][2].disabled.set(true);
343+
344+
const nextCoords = gridNav.peek(direction.Right, gridFocus.activeCoords(), 'nowrap', true);
345+
346+
expect(nextCoords).toEqual({row: 0, col: 2});
347+
expect(gridNav.peek(direction.Right, gridFocus.activeCoords(), 'nowrap')).toBeUndefined();
348+
});
301349
});
302350
});
303351

@@ -1971,6 +2019,17 @@ describe('GridNavigation', () => {
19712019
expect(gridFocus.activeCell()).toBe(cells[1][0]);
19722020
expect(gridFocus.activeCoords()).toEqual({row: 1, col: 0});
19732021
});
2022+
2023+
it('should get disabled cells when allowDisabled is true and softDisabled is false', () => {
2024+
const cells = createTestGrid(createGridA);
2025+
const {gridNav} = setupGridNavigation(signal(cells), {softDisabled: signal(false)});
2026+
cells[0][0].disabled.set(true);
2027+
2028+
const firstCoords = gridNav.peekFirst(undefined, true);
2029+
2030+
expect(firstCoords).toEqual({row: 0, col: 0});
2031+
expect(gridNav.peekFirst()).toEqual({row: 0, col: 1});
2032+
});
19742033
});
19752034

19762035
describe('last/peekLast', () => {
@@ -2049,5 +2108,16 @@ describe('GridNavigation', () => {
20492108
expect(gridFocus.activeCell()!.id()).toBe('cell-1-3');
20502109
expect(gridFocus.activeCoords()).toEqual({row: 1, col: 3});
20512110
});
2111+
2112+
it('should get disabled cells when allowDisabled is true and softDisabled is false', () => {
2113+
const cells = createTestGrid(createGridA);
2114+
const {gridNav} = setupGridNavigation(signal(cells), {softDisabled: signal(false)});
2115+
cells[2][2].disabled.set(true);
2116+
2117+
const lastCoords = gridNav.peekLast(undefined, true);
2118+
2119+
expect(lastCoords).toEqual({row: 2, col: 2});
2120+
expect(gridNav.peekLast()).toEqual({row: 2, col: 1});
2121+
});
20522122
});
20532123
});

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

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,14 @@ export class GridNavigation<T extends GridNavigationCell> {
7373
/**
7474
* Gets the coordinates of the next focusable cell in a given direction, without changing focus.
7575
*/
76-
peek(direction: Delta, fromCoords: RowCol, wrap?: WrapStrategy): RowCol | undefined {
76+
peek(
77+
direction: Delta,
78+
fromCoords: RowCol,
79+
wrap?: WrapStrategy,
80+
allowDisabled?: boolean,
81+
): RowCol | undefined {
7782
wrap = wrap ?? (direction.row !== undefined ? this.inputs.rowWrap() : this.inputs.colWrap());
78-
return this._peekDirectional(direction, fromCoords, wrap);
83+
return this._peekDirectional(direction, fromCoords, wrap, allowDisabled);
7984
}
8085

8186
/**
@@ -90,14 +95,14 @@ export class GridNavigation<T extends GridNavigationCell> {
9095
* Gets the coordinates of the first focusable cell.
9196
* If a row is not provided, searches the entire grid.
9297
*/
93-
peekFirst(row?: number): RowCol | undefined {
98+
peekFirst(row?: number, allowDisabled?: boolean): RowCol | undefined {
9499
const fromCoords = {
95100
row: row ?? 0,
96101
col: -1,
97102
};
98103
return row === undefined
99-
? this._peekDirectional(direction.Right, fromCoords, 'continuous')
100-
: this._peekDirectional(direction.Right, fromCoords, 'nowrap');
104+
? this._peekDirectional(direction.Right, fromCoords, 'continuous', allowDisabled)
105+
: this._peekDirectional(direction.Right, fromCoords, 'nowrap', allowDisabled);
101106
}
102107

103108
/**
@@ -113,14 +118,14 @@ export class GridNavigation<T extends GridNavigationCell> {
113118
* Gets the coordinates of the last focusable cell.
114119
* If a row is not provided, searches the entire grid.
115120
*/
116-
peekLast(row?: number): RowCol | undefined {
121+
peekLast(row?: number, allowDisabled?: boolean): RowCol | undefined {
117122
const fromCoords = {
118123
row: row ?? this.inputs.grid.maxRowCount() - 1,
119124
col: this.inputs.grid.maxColCount(),
120125
};
121126
return row === undefined
122-
? this._peekDirectional(direction.Left, fromCoords, 'continuous')
123-
: this._peekDirectional(direction.Left, fromCoords, 'nowrap');
127+
? this._peekDirectional(direction.Left, fromCoords, 'continuous', allowDisabled)
128+
: this._peekDirectional(direction.Left, fromCoords, 'nowrap', allowDisabled);
124129
}
125130

126131
/**
@@ -139,6 +144,7 @@ export class GridNavigation<T extends GridNavigationCell> {
139144
delta: Delta,
140145
fromCoords: RowCol,
141146
wrap: 'continuous' | 'loop' | 'nowrap',
147+
allowDisabled: boolean = false,
142148
): RowCol | undefined {
143149
const fromCell = this.inputs.grid.getCell(fromCoords);
144150
const maxRowCount = this.inputs.grid.maxRowCount();
@@ -190,7 +196,7 @@ export class GridNavigation<T extends GridNavigationCell> {
190196
if (
191197
nextCell !== undefined &&
192198
nextCell !== fromCell &&
193-
this.inputs.gridFocus.isFocusable(nextCell)
199+
(allowDisabled || this.inputs.gridFocus.isFocusable(nextCell))
194200
) {
195201
return nextCoords;
196202
}

src/aria/private/behaviors/grid/grid-selection.spec.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,4 +207,97 @@ describe('GridSelection', () => {
207207
expect(validCellIds.length).toBe(allCellIds.length - 2);
208208
});
209209
});
210+
211+
describe('undo', () => {
212+
it('should undo a select operation', () => {
213+
const cells = createTestGrid(createGridA);
214+
const {gridSelection} = setupGridSelection(signal(cells));
215+
216+
gridSelection.select({row: 1, col: 1});
217+
expect(cells[1][1].selected()).toBe(true);
218+
219+
gridSelection.undo();
220+
expect(cells[1][1].selected()).toBe(false);
221+
});
222+
223+
it('should undo a deselect operation', () => {
224+
const cells = createTestGrid(createGridA);
225+
const {gridSelection} = setupGridSelection(signal(cells));
226+
cells[1][1].selected.set(true);
227+
228+
gridSelection.deselect({row: 1, col: 1});
229+
expect(cells[1][1].selected()).toBe(false);
230+
231+
gridSelection.undo();
232+
expect(cells[1][1].selected()).toBe(true);
233+
});
234+
235+
it('should undo a toggle operation', () => {
236+
const cells = createTestGrid(createGridA);
237+
const {gridSelection} = setupGridSelection(signal(cells));
238+
cells[0][0].selected.set(true);
239+
240+
gridSelection.toggle({row: 0, col: 0}, {row: 0, col: 1});
241+
expect(cells[0][0].selected()).toBe(false);
242+
expect(cells[0][1].selected()).toBe(true);
243+
244+
gridSelection.undo();
245+
expect(cells[0][0].selected()).toBe(true);
246+
expect(cells[0][1].selected()).toBe(false);
247+
});
248+
249+
it('should undo a selectAll operation', () => {
250+
const cells = createTestGrid(createGridA);
251+
const {gridSelection} = setupGridSelection(signal(cells));
252+
253+
gridSelection.selectAll();
254+
expect(cells.flat().every(c => c.selected())).toBe(true);
255+
256+
gridSelection.undo();
257+
expect(cells.flat().every(c => !c.selected())).toBe(true);
258+
});
259+
260+
it('should undo a deselectAll operation', () => {
261+
const cells = createTestGrid(createGridA);
262+
const {gridSelection} = setupGridSelection(signal(cells));
263+
cells.flat().forEach(c => c.selected.set(true));
264+
265+
gridSelection.deselectAll();
266+
expect(cells.flat().every(c => !c.selected())).toBe(true);
267+
268+
gridSelection.undo();
269+
expect(cells.flat().every(c => c.selected())).toBe(true);
270+
});
271+
272+
it('should do nothing if there is nothing to undo', () => {
273+
const cells = createTestGrid(createGridA);
274+
const {gridSelection} = setupGridSelection(signal(cells));
275+
cells[1][1].selected.set(true);
276+
277+
gridSelection.undo();
278+
expect(cells[1][1].selected()).toBe(true);
279+
});
280+
281+
it('should only undo the last operation', () => {
282+
const cells = createTestGrid(createGridA);
283+
const {gridSelection} = setupGridSelection(signal(cells));
284+
285+
gridSelection.select({row: 0, col: 0});
286+
gridSelection.select({row: 1, col: 1});
287+
expect(cells[1][1].selected()).toBe(true);
288+
289+
gridSelection.undo();
290+
expect(cells[0][0].selected()).toBe(true);
291+
expect(cells[1][1].selected()).toBe(false);
292+
});
293+
294+
it('should do nothing after undoing once', () => {
295+
const cells = createTestGrid(createGridA);
296+
const {gridSelection} = setupGridSelection(signal(cells));
297+
gridSelection.select({row: 1, col: 1});
298+
gridSelection.undo();
299+
gridSelection.undo();
300+
expect(cells[1][1].selected()).toBe(false);
301+
});
302+
});
210303
});

0 commit comments

Comments
 (0)