Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .ng-dev/commit-message.mts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const commitMessage: CommitMessageConfig = {
'multiple', // For when a commit applies to multiple components.
'aria/accordion',
'aria/combobox',
'aria/grid',
'aria/listbox',
'aria/menu',
'aria/radio-group',
Expand Down
37 changes: 37 additions & 0 deletions src/aria/grid/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite")

package(default_visibility = ["//visibility:public"])

ng_project(
name = "grid",
srcs = [
"grid.ts",
"index.ts",
],
deps = [
"//:node_modules/@angular/core",
"//src/aria/deferred-content",
"//src/aria/ui-patterns",
"//src/cdk/a11y",
"//src/cdk/bidi",
],
)

ng_project(
name = "unit_test_sources",
testonly = True,
srcs = [
"grid.spec.ts",
],
deps = [
":tabs",
"//:node_modules/@angular/core",
"//:node_modules/@angular/platform-browser",
"//src/cdk/testing/private",
],
)

ng_web_test_suite(
name = "unit_tests",
deps = [":unit_test_sources"],
)
272 changes: 272 additions & 0 deletions src/aria/grid/grid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import {_IdGenerator} from '@angular/cdk/a11y';
import {
afterRenderEffect,
booleanAttribute,
computed,
contentChild,
contentChildren,
Directive,
ElementRef,
inject,
input,
model,
signal,
Signal,
untracked,
} from '@angular/core';
import {GridPattern, GridRowPattern, GridCellPattern, GridCellWidgetPattern} from '../ui-patterns';

/** A directive that provides grid-based navigation and selection behavior. */
@Directive({
selector: '[ngGrid]',
exportAs: 'ngGrid',
host: {
'class': 'grid',
'role': 'grid',
'[tabindex]': 'pattern.tabIndex()',
'[attr.aria-disabled]': 'pattern.disabled()',
'[attr.aria-activedescendant]': 'pattern.activeDescendant()',
'(keydown)': 'pattern.onKeydown($event)',
'(pointerdown)': 'pattern.onPointerdown($event)',
'(pointermove)': 'pattern.onPointermove($event)',
'(pointerup)': 'pattern.onPointerup($event)',
'(focusin)': 'onFocusIn()',
'(focusout)': 'onFocusOut()',
},
})
export class Grid {
/** The rows that make up the grid. */
private readonly _rows = contentChildren(GridRow);

/** The UI patterns for the rows in the grid. */
private readonly _rowPatterns: Signal<GridRowPattern[]> = computed(() =>
this._rows().map(r => r.pattern),
);

/** Whether selection is enabled for the grid. */
readonly enableSelection = input(false, {transform: booleanAttribute});

/** Whether the grid is disabled. */
readonly disabled = input(false, {transform: booleanAttribute});

/** Whether to skip disabled items during navigation. */
readonly skipDisabled = input(true, {transform: booleanAttribute});

/** The focus strategy used by the tree. */
readonly focusMode = input<'roving' | 'activedescendant'>('roving');

/** The wrapping behavior for keyboard navigation along the row axis. */
readonly rowWrap = input<'continuous' | 'loop' | 'nowrap'>('loop');

/** The wrapping behavior for keyboard navigation along the column axis. */
readonly colWrap = input<'continuous' | 'loop' | 'nowrap'>('loop');

/** The UI pattern for the grid. */
readonly pattern = new GridPattern({
...this,
rows: this._rowPatterns,
getCell: e => this._getCell(e),
});

/** Whether the focus is in the grid. */
private readonly _hasFocus = signal(false);

constructor() {
afterRenderEffect(() => {
this.pattern.resetState();
});

afterRenderEffect(() => {
const activeCell = this.pattern.activeCell();
const hasFocus = untracked(() => this._hasFocus());
const isRoving = this.focusMode() === 'roving';
if (activeCell !== undefined && isRoving && hasFocus) {
activeCell.element().focus();
}
});
}

/** Handles focusin events on the grid. */
onFocusIn() {
this._hasFocus.set(true);
}

/** Handles focusout events on the grid. */
onFocusOut() {
this._hasFocus.set(false);
}

/** Gets the cell pattern for a given element. */
private _getCell(element: Element): GridCellPattern | undefined {
const cellElement = element.closest('[ngGridCell]');
if (cellElement === undefined) return;

const widgetElement = element.closest('[ngGridCellWidget]');
for (const row of this._rowPatterns()) {
for (const cell of row.inputs.cells()) {
if (
cell.element() === cellElement ||
(widgetElement !== undefined && cell.element() === widgetElement)
) {
return cell;
}
}
}
return;
}
}

/** A directive that represents a row in a grid. */
@Directive({
selector: '[ngGridRow]',
exportAs: 'ngGridRow',
host: {
'class': 'grid-row',
'[attr.role]': 'role()',
},
})
export class GridRow {
/** A reference to the host element. */
private readonly _elementRef = inject(ElementRef);

/** The cells that make up this row. */
private readonly _cells = contentChildren(GridCell);

/** The UI patterns for the cells in this row. */
private readonly _cellPatterns: Signal<GridCellPattern[]> = computed(() =>
this._cells().map(c => c.pattern),
);

/** The parent grid. */
private readonly _grid = inject(Grid);

/** The parent grid UI pattern. */
readonly grid = computed(() => this._grid.pattern);

/** The host native element. */
readonly element = computed(() => this._elementRef.nativeElement);

/** The ARIA role for the row. */
readonly role = input<'row' | 'rowheader'>('row');

/** The index of this row within the grid. */
readonly rowIndex = input<number>();

/** The UI pattern for the grid row. */
readonly pattern = new GridRowPattern({
...this,
cells: this._cellPatterns,
});
}

/** A directive that represents a cell in a grid. */
@Directive({
selector: '[ngGridCell]',
exportAs: 'ngGridCell',
host: {
'class': 'grid-cell',
'[attr.role]': 'role()',
'[attr.rowspan]': 'pattern.rowSpan()',
'[attr.colspan]': 'pattern.colSpan()',
'[attr.data-active]': 'pattern.active()',
'[attr.aria-disabled]': 'pattern.disabled()',
'[attr.aria-rowspan]': 'pattern.rowSpan()',
'[attr.aria-colspan]': 'pattern.colSpan()',
'[attr.aria-rowindex]': 'pattern.ariaRowIndex()',
'[attr.aria-colindex]': 'pattern.ariaColIndex()',
'[attr.aria-selected]': 'pattern.ariaSelected()',
'[tabindex]': 'pattern.tabIndex()',
},
})
export class GridCell {
/** A reference to the host element. */
private readonly _elementRef = inject(ElementRef);

/** The widget contained within this cell, if any. */
private readonly _widget = contentChild(GridCellWidget);

/** The UI pattern for the widget in this cell. */
private readonly _widgetPattern: Signal<GridCellWidgetPattern | undefined> = computed(
() => this._widget()?.pattern,
);

/** The parent row. */
private readonly _row = inject(GridRow);

/** A unique identifier for the cell. */
private readonly _id = inject(_IdGenerator).getId('ng-grid-cell-');

/** The host native element. */
readonly element = computed(() => this._elementRef.nativeElement);

/** The ARIA role for the cell. */
readonly role = input<'gridcell' | 'columnheader'>('gridcell');

/** The number of rows the cell should span. */
readonly rowSpan = input<number>(1);

/** The number of columns the cell should span. */
readonly colSpan = input<number>(1);

/** The index of this cell's row within the grid. */
readonly rowIndex = input<number>();

/** The index of this cell's column within the grid. */
readonly colIndex = input<number>();

/** Whether the cell is disabled. */
readonly disabled = input(false, {transform: booleanAttribute});

/** Whether the cell is selected. */
readonly selected = model<boolean>(false);

/** Whether the cell is selectable. */
readonly selectable = input<boolean>(true);

/** The UI pattern for the grid cell. */
readonly pattern = new GridCellPattern({
...this,
id: () => this._id,
grid: this._row.grid,
row: () => this._row.pattern,
widget: this._widgetPattern,
});
}

/** A directive that represents a widget inside a grid cell. */
@Directive({
selector: '[ngGridCellWidget]',
exportAs: 'ngGridCellWidget',
host: {
'class': 'grid-cell-widget',
'[attr.data-active]': 'pattern.active()',
'[tabindex]': 'pattern.tabIndex()',
},
})
export class GridCellWidget {
/** A reference to the host element. */
private readonly _elementRef = inject(ElementRef);

/** The parent cell. */
private readonly _cell = inject(GridCell);

/** The host native element. */
readonly element = computed(() => this._elementRef.nativeElement);

/** Whether grid navigation should be paused, usually because this widget has focus. */
readonly pauseGridNavigation = model<boolean>(false);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like something that shouldn't be exposed for developers to control


/** The UI pattern for the grid cell widget. */
readonly pattern = new GridCellWidgetPattern({
...this,
cell: () => this._cell.pattern,
});
}
9 changes: 9 additions & 0 deletions src/aria/grid/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

export * from './grid';
1 change: 1 addition & 0 deletions src/aria/ui-patterns/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ ts_project(
"//src/aria/ui-patterns/accordion",
"//src/aria/ui-patterns/behaviors/signal-like",
"//src/aria/ui-patterns/combobox",
"//src/aria/ui-patterns/grid",
"//src/aria/ui-patterns/listbox",
"//src/aria/ui-patterns/radio-group",
"//src/aria/ui-patterns/tabs",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export class PointerEventManager<T extends PointerEvent> extends EventManager<T>
};
}

if (typeof args[0] === 'number' && typeof args[1] === 'function') {
if (args.length === 2) {
return {
button: MouseButton.Main,
modifiers: args[0] as ModifierInputs,
Expand Down
32 changes: 32 additions & 0 deletions src/aria/ui-patterns/behaviors/grid/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project")

package(default_visibility = ["//visibility:public"])

ts_project(
name = "grid",
srcs = glob(
["**/*.ts"],
exclude = ["**/*.spec.ts"],
),
deps = [
"//:node_modules/@angular/core",
"//src/aria/ui-patterns/behaviors/signal-like",
],
)

ng_project(
name = "unit_test_sources",
testonly = True,
srcs = glob(["**/*.spec.ts"]),
deps = [
":grid",
"//:node_modules/@angular/core",
"//src/cdk/keycodes",
"//src/cdk/testing/private",
],
)

ng_web_test_suite(
name = "unit_tests",
deps = [":unit_test_sources"],
)
Loading
Loading