Data Grid
ソート、行選択、範囲選択、セル編集機能を備えた高度なインタラクティブデータグリッド。
デモ
Name
Email
Department
Alice Johnson
alice@example.com
Engineering
Bob Smith
bob@example.com
Marketing
Carol Williams
carol@example.com
Sales
Data Grid vs Grid
Data Grid は基本的な Grid パターンを拡張し、データ操作のための追加機能を提供します。
| 機能 | Grid | Data Grid |
|---|---|---|
| 2Dナビゲーション | あり | あり |
| セル選択 | あり | あり |
| 列ソート | なし | あり(aria-sort) |
| 行選択 | なし | あり(チェックボックス) |
| 範囲選択 | なし | あり(Shift+矢印) |
| セル編集 | なし | あり(Enter/F2) |
| ヘッダーナビゲーション | なし | あり(ソート可能ヘッダー) |
アクセシビリティ
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
grid | コンテナ | グリッドとして要素を識別します。グリッドはセルの行を含みます。 |
row | 各行 | セルの行を識別します |
gridcell | 各セル | グリッド内のインタラクティブなセルを識別します |
rowheader | 行ヘッダーセル | 行のヘッダーとしてセルを識別します |
columnheader | 列ヘッダーセル | 列のヘッダーとしてセルを識別します |
WAI-ARIA プロパティ
aria-rowcount
行が仮想化されている場合に必須
- 値
- 総行数
- 必須
- いいえ
aria-colcount
列が非表示または仮想化されている場合に必須
- 値
- 総列数
- 必須
- いいえ
aria-rowindex
行が仮想化されている場合に必須
- 値
- グリッド内の行の位置
- 必須
- いいえ
aria-colindex
列が非表示または仮想化されている場合に必須
- 値
- グリッド内の列の位置
- 必須
- いいえ
aria-sort
列のソート状態を示します
- 値
ascending|descending|none|other- 必須
- いいえ
aria-describedby
グリッドに関する追加のコンテキストを提供します
- 値
- 説明要素へのID参照
- 必須
- いいえ
WAI-ARIA ステート
aria-selected
- 対象要素
- gridcell または row
- 値
- true | false
- 必須
- いいえ
- 変更トリガー
- クリック、Space、Ctrl/Cmd+クリック
aria-readonly
- 対象要素
- grid または gridcell
- 値
- true | false
- 必須
- いいえ
- 変更トリガー
- グリッド/セルの設定
aria-disabled
- 対象要素
- grid、row、または gridcell
- 値
- true | false
- 必須
- いいえ
- 変更トリガー
- グリッド/行/セルの状態変更
キーボードサポート
| キー | アクション |
|---|---|
| ArrowRight | フォーカスを右に1セル移動します。末尾の場合は次の行に折り返します。 |
| ArrowLeft | フォーカスを左に1セル移動します。先頭の場合は前の行に折り返します。 |
| ArrowDown | フォーカスを下に1セル移動します。 |
| ArrowUp | フォーカスを上に1セル移動します。 |
| Home | 行の最初のセルにフォーカスを移動します。 |
| End | 行の最後のセルにフォーカスを移動します。 |
| Ctrl + Home | グリッドの最初のセルにフォーカスを移動します。 |
| Ctrl + End | グリッドの最後のセルにフォーカスを移動します。 |
| Page Down | フォーカスを1ページ下に移動します(実装依存)。 |
| Page Up | フォーカスを1ページ上に移動します(実装依存)。 |
| Space / Enter | セルをアクティブ化します(例:編集、選択)。 |
| Escape | 編集モードをキャンセルまたは選択解除します。 |
- テーブルがインタラクティブな場合にのみ role=“grid” を使用してください。静的なデータにはネイティブの
<table>要素を使用してください。 - 効率的なキーボードナビゲーションのためにローヴィングタブインデックスが推奨されます。
- フォーカスされているセルに視覚的なフォーカスインジケーターを提供することを検討してください。
フォーカス管理
| イベント | 振る舞い |
|---|---|
| グリッド | コンテナまたは最初のフォーカス可能なセルに tabindex="0" |
| フォーカス中のセル | tabindex="0" |
| 他のセル | tabindex="-1" |
| セル内のインタラクティブなコンテンツ | Enterでセル内のコンテンツにフォーカス移動、Escapeで移動終了 |
参考資料
ソースコード
DataGrid.astro
---
// =============================================================================
// Types
// =============================================================================
export type SortDirection = 'ascending' | 'descending' | 'none' | 'other';
export type EditType = 'text' | 'select' | 'combobox';
// =============================================================================
// Helper Functions
// =============================================================================
/** Get sort indicator character based on sort direction */
function getSortIndicator(direction?: SortDirection): string {
if (direction === 'ascending') return '▲';
if (direction === 'descending') return '▼';
return '⇅';
}
/** Get aria-readonly value for a cell in editable grid */
function getAriaReadonly(
gridEditable: boolean,
cellReadonly: boolean | undefined,
cellEditable: boolean | undefined
): 'true' | 'false' | undefined {
if (!gridEditable) return undefined;
if (cellReadonly === true) return 'true';
if (cellEditable) return 'false';
return 'true'; // Non-editable cell in editable grid
}
/** Get aria-selected value for a cell */
function getAriaSelected(
isSelectable: boolean,
isRowSelectable: boolean,
isSelected: boolean
): 'true' | 'false' | undefined {
if (!isSelectable) return undefined;
if (isRowSelectable) return undefined; // Row selection takes precedence
return isSelected ? 'true' : 'false';
}
export interface DataGridCellData {
id: string;
value: string | number;
disabled?: boolean;
colspan?: number;
rowspan?: number;
editable?: boolean;
readonly?: boolean;
}
export interface DataGridColumnDef {
id: string;
header: string;
sortable?: boolean;
sortDirection?: SortDirection;
colspan?: number;
isRowLabel?: boolean; // This column provides accessible labels for row checkboxes
editable?: boolean; // Column-level editable flag
editType?: EditType; // Type of editor: text, select, or combobox
options?: string[]; // Options for select/combobox
}
export interface DataGridRowData {
id: string;
cells: DataGridCellData[];
hasRowHeader?: boolean;
disabled?: boolean;
}
interface Props {
columns: DataGridColumnDef[];
rows: DataGridRowData[];
ariaLabel?: string;
ariaLabelledby?: string;
// Row Selection
rowSelectable?: boolean;
rowMultiselectable?: boolean;
defaultSelectedRowIds?: string[];
// Cell Editing
editable?: boolean;
readonly?: boolean;
// Range Selection
enableRangeSelection?: boolean;
// Cell selection
selectable?: boolean;
multiselectable?: boolean;
defaultSelectedIds?: string[];
defaultFocusedId?: string;
// Virtualization
totalColumns?: number;
totalRows?: number;
startRowIndex?: number;
startColIndex?: number;
// Behavior
wrapNavigation?: boolean;
enablePageNavigation?: boolean;
pageSize?: number;
class?: string;
renderCell?: (cell: DataGridCellData, rowId: string, colId: string) => string;
}
// =============================================================================
// Props
// =============================================================================
const {
columns,
rows,
ariaLabel,
ariaLabelledby,
// Row Selection
rowSelectable = false,
rowMultiselectable = false,
defaultSelectedRowIds = [],
// Cell Editing
editable = false,
readonly = false,
// Range Selection
enableRangeSelection = false,
// Cell selection
selectable = false,
multiselectable = false,
defaultSelectedIds = [],
defaultFocusedId,
// Virtualization
totalColumns,
totalRows,
startRowIndex = 1,
startColIndex = 1,
// Behavior
wrapNavigation = false,
enablePageNavigation = false,
pageSize = 5,
class: className,
renderCell,
} = Astro.props;
// Determine aria-multiselectable
const ariaMultiselectable =
(rowSelectable && rowMultiselectable) || (selectable && multiselectable);
// Determine if we need checkbox column
const hasCheckboxColumn = rowSelectable;
// Find the column that provides row labels (for aria-labelledby on row checkboxes)
// Priority: 1. Column with isRowLabel: true, 2. First column (fallback)
const rowLabelColumn = columns.find((col) => col.isRowLabel) ?? columns[0];
// Calculate effective column count
const effectiveColCount = hasCheckboxColumn ? columns.length + 1 : columns.length;
// Determine initial focused cell based on row selection mode
// rowMultiselectable: header checkbox cell is first (Select all rows)
// rowSelectable only: first row's checkbox cell
// Otherwise: first data cell
// Note: Sortable headers are focusable via arrow navigation but not the initial Tab stop
const getInitialFocusedId = () => {
if (defaultFocusedId) return defaultFocusedId;
if (rowSelectable && rowMultiselectable) return 'header-checkbox';
if (rowSelectable) return rows[0] ? `checkbox-${rows[0].id}` : null;
return rows[0]?.cells[0]?.id ?? null;
};
const initialFocusedId = getInitialFocusedId();
---
<apg-data-grid
class={`apg-data-grid ${className ?? ''}`}
style={`--apg-data-grid-columns: ${columns.length}`}
data-wrap-navigation={wrapNavigation}
data-enable-page-navigation={enablePageNavigation}
data-page-size={pageSize}
data-selectable={selectable}
data-multiselectable={multiselectable}
data-row-selectable={rowSelectable}
data-row-multiselectable={rowMultiselectable}
data-editable={editable}
data-readonly={readonly}
data-enable-range-selection={enableRangeSelection}
>
<div
role="grid"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-multiselectable={ariaMultiselectable ? 'true' : undefined}
aria-readonly={readonly ? 'true' : undefined}
aria-rowcount={totalRows}
aria-colcount={totalColumns ?? effectiveColCount}
>
{/* Header Row */}
<div role="row" aria-rowindex={totalRows ? 1 : undefined}>
{
hasCheckboxColumn &&
(() => {
const isHeaderCheckboxFocused = initialFocusedId === 'header-checkbox';
return (
<div
role="columnheader"
class={`apg-data-grid-header apg-data-grid-checkbox-cell ${isHeaderCheckboxFocused ? 'focused' : ''}`}
tabindex={rowMultiselectable ? (isHeaderCheckboxFocused ? 0 : -1) : undefined}
aria-colindex={totalColumns ? startColIndex : undefined}
data-cell-id="header-checkbox"
data-header-checkbox="true"
>
{rowMultiselectable && (
<input
type="checkbox"
tabindex={-1}
aria-label="Select all rows"
data-select-all="true"
/>
)}
</div>
);
})()
}
{
columns.map((col, colIndex) => {
const headerId = `header-${col.id}`;
const isFocused = initialFocusedId === headerId;
const ariaColIndex = totalColumns
? startColIndex + colIndex + (hasCheckboxColumn ? 1 : 0)
: undefined;
return (
<div
role="columnheader"
class={`apg-data-grid-header ${col.sortable ? 'sortable' : ''} ${isFocused ? 'focused' : ''}`}
tabindex={col.sortable ? (isFocused ? 0 : -1) : undefined}
aria-sort={col.sortable ? (col.sortDirection ?? 'none') : undefined}
aria-colindex={ariaColIndex}
aria-colspan={col.colspan}
data-header-id={headerId}
data-col-id={col.id}
data-col-index={colIndex}
data-sortable={col.sortable ? 'true' : undefined}
data-sort-direction={col.sortDirection}
>
{col.header}
{col.sortable && (
<span
class={`sort-indicator ${!col.sortDirection || col.sortDirection === 'none' ? 'unsorted' : ''}`}
aria-hidden="true"
>
{getSortIndicator(col.sortDirection)}
</span>
)}
</div>
);
})
}
</div>
{/* Data Rows */}
{
rows.map((row, rowIndex) => {
const isRowSelected = defaultSelectedRowIds.includes(row.id);
return (
<div
role="row"
aria-rowindex={totalRows ? startRowIndex + rowIndex + 1 : undefined}
aria-selected={rowSelectable ? (isRowSelected ? 'true' : 'false') : undefined}
aria-disabled={row.disabled ? 'true' : undefined}
data-row-id={row.id}
data-row-index={rowIndex}
>
{hasCheckboxColumn &&
(() => {
const checkboxCellId = `checkbox-${row.id}`;
const isCheckboxFocused = initialFocusedId === checkboxCellId;
return (
<div
role="gridcell"
class={`apg-data-grid-cell apg-data-grid-checkbox-cell ${isCheckboxFocused ? 'focused' : ''}`}
tabindex={isCheckboxFocused ? 0 : -1}
aria-colindex={totalColumns ? startColIndex : undefined}
data-checkbox-cell="true"
data-checkbox-id={checkboxCellId}
data-row-id={row.id}
data-row-index={rowIndex}
>
<input
type="checkbox"
tabindex={-1}
checked={isRowSelected}
disabled={row.disabled}
aria-labelledby={
rowLabelColumn ? `cell-${row.id}-${rowLabelColumn.id}` : undefined
}
data-row-checkbox="true"
data-row-id={row.id}
/>
</div>
);
})()}
{row.cells.map((cell, colIndex) => {
const isRowHeader = row.hasRowHeader && colIndex === 0;
const isFocused = cell.id === initialFocusedId;
const isSelected = defaultSelectedIds.includes(cell.id);
const colId = columns[colIndex]?.id ?? '';
const isDisabled = cell.disabled || row.disabled;
const cellEditable =
editable && cell.editable && !cell.readonly && !readonly && !isDisabled;
const ariaColIndex = totalColumns
? startColIndex + colIndex + (hasCheckboxColumn ? 1 : 0)
: undefined;
const isLabelColumn = rowLabelColumn && colId === rowLabelColumn.id;
return (
<div
id={isLabelColumn ? `cell-${row.id}-${colId}` : undefined}
role={isRowHeader ? 'rowheader' : 'gridcell'}
tabindex={isFocused ? 0 : -1}
aria-selected={getAriaSelected(selectable, rowSelectable, isSelected)}
aria-disabled={isDisabled ? 'true' : undefined}
aria-readonly={getAriaReadonly(editable, cell.readonly, cellEditable)}
aria-colindex={ariaColIndex}
aria-colspan={cell.colspan}
aria-rowspan={cell.rowspan}
data-cell-id={cell.id}
data-row-id={row.id}
data-col-id={colId}
data-row-index={rowIndex}
data-col-index={colIndex}
data-disabled={isDisabled ? 'true' : undefined}
data-editable={cellEditable ? 'true' : undefined}
class={`apg-data-grid-cell ${isFocused ? 'focused' : ''} ${isSelected ? 'selected' : ''} ${isDisabled ? 'disabled' : ''}`}
>
{renderCell ? (
<Fragment set:html={renderCell(cell, row.id, colId)} />
) : (
cell.value
)}
</div>
);
})}
</div>
);
})
}
</div>
</apg-data-grid>
<script>
class ApgDataGrid extends HTMLElement {
private focusedId: string | null = null;
private selectedIds: Set<string> = new Set();
private selectedRowIds: Set<string> = new Set();
private anchorCellId: string | null = null;
private isEditing = false;
private editingCellId: string | null = null;
private editValue = '';
private originalEditValue = '';
private isEndingEdit = false;
// Options
private wrapNavigation = false;
private enablePageNavigation = false;
private pageSize = 5;
private selectable = false;
private multiselectable = false;
private rowSelectable = false;
private rowMultiselectable = false;
private editable = false;
private readonly = false;
private enableRangeSelection = false;
connectedCallback() {
// Load options from data attributes
this.wrapNavigation = this.dataset.wrapNavigation === 'true';
this.enablePageNavigation = this.dataset.enablePageNavigation === 'true';
this.pageSize = parseInt(this.dataset.pageSize || '5', 10);
this.selectable = this.dataset.selectable === 'true';
this.multiselectable = this.dataset.multiselectable === 'true';
this.rowSelectable = this.dataset.rowSelectable === 'true';
this.rowMultiselectable = this.dataset.rowMultiselectable === 'true';
this.editable = this.dataset.editable === 'true';
this.readonly = this.dataset.readonly === 'true';
this.enableRangeSelection = this.dataset.enableRangeSelection === 'true';
// Find initial focused element (header or cell)
const focusedEl = this.querySelector<HTMLElement>('[tabindex="0"]');
this.focusedId = focusedEl?.dataset.headerId ?? focusedEl?.dataset.cellId ?? null;
// Load initial selected ids
this.querySelectorAll<HTMLElement>('[aria-selected="true"]').forEach((el) => {
const cellId = el.dataset.cellId;
if (cellId) this.selectedIds.add(cellId);
});
// Load initial selected row ids
this.querySelectorAll<HTMLElement>('[role="row"][aria-selected="true"]').forEach((el) => {
const rowId = el.dataset.rowId;
if (rowId) this.selectedRowIds.add(rowId);
});
// Set tabindex="-1" on all focusable elements inside grid cells
this.querySelectorAll<HTMLElement>(
'[role="gridcell"] a[href], [role="gridcell"] button:not([data-row-checkbox]), [role="rowheader"] a[href], [role="rowheader"] button'
).forEach((el) => {
el.setAttribute('tabindex', '-1');
});
// Add event listeners to sortable headers
this.querySelectorAll<HTMLElement>('[role="columnheader"][data-sortable="true"]').forEach(
(header) => {
header.addEventListener('keydown', this.handleHeaderKeyDown.bind(this) as EventListener);
header.addEventListener('click', this.handleHeaderClick.bind(this) as EventListener);
header.addEventListener('focusin', this.handleHeaderFocus.bind(this) as EventListener);
}
);
// Add event listeners to all cells
this.querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]').forEach(
(cell) => {
if (cell.dataset.checkboxCell !== 'true') {
cell.addEventListener('keydown', this.handleKeyDown.bind(this) as EventListener);
cell.addEventListener('focusin', this.handleFocus.bind(this) as EventListener);
cell.addEventListener(
'dblclick',
this.handleCellDoubleClick.bind(this) as EventListener
);
}
}
);
// Add event listeners to checkbox cells (for keyboard navigation)
this.querySelectorAll<HTMLElement>('[data-checkbox-cell="true"]').forEach((cell) => {
cell.addEventListener(
'keydown',
this.handleCheckboxCellKeyDown.bind(this) as EventListener
);
cell.addEventListener('focusin', this.handleCheckboxCellFocus.bind(this) as EventListener);
cell.addEventListener('click', this.handleCheckboxCellClick.bind(this) as EventListener);
});
// Add event listeners to row checkboxes
this.querySelectorAll<HTMLInputElement>('[data-row-checkbox="true"]').forEach((checkbox) => {
checkbox.addEventListener(
'change',
this.handleRowCheckboxChange.bind(this) as EventListener
);
});
// Add event listener to header checkbox cell (Select all)
const headerCheckboxCell = this.querySelector<HTMLElement>('[data-header-checkbox="true"]');
if (headerCheckboxCell) {
headerCheckboxCell.addEventListener(
'keydown',
this.handleHeaderCheckboxKeyDown.bind(this) as EventListener
);
headerCheckboxCell.addEventListener(
'focusin',
this.handleHeaderCheckboxFocus.bind(this) as EventListener
);
}
// Add event listener to select all checkbox
const selectAllCheckbox = this.querySelector<HTMLInputElement>('[data-select-all="true"]');
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener(
'change',
this.handleSelectAllChange.bind(this) as EventListener
);
this.updateSelectAllState();
}
}
disconnectedCallback() {
this.querySelectorAll<HTMLElement>('[role="columnheader"][data-sortable="true"]').forEach(
(header) => {
header.removeEventListener(
'keydown',
this.handleHeaderKeyDown.bind(this) as EventListener
);
header.removeEventListener('click', this.handleHeaderClick.bind(this) as EventListener);
header.removeEventListener('focusin', this.handleHeaderFocus.bind(this) as EventListener);
}
);
this.querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]').forEach(
(cell) => {
cell.removeEventListener('keydown', this.handleKeyDown.bind(this) as EventListener);
cell.removeEventListener('focusin', this.handleFocus.bind(this) as EventListener);
}
);
this.querySelectorAll<HTMLElement>('[data-checkbox-cell="true"]').forEach((cell) => {
cell.removeEventListener(
'keydown',
this.handleCheckboxCellKeyDown.bind(this) as EventListener
);
cell.removeEventListener(
'focusin',
this.handleCheckboxCellFocus.bind(this) as EventListener
);
cell.removeEventListener('click', this.handleCheckboxCellClick.bind(this) as EventListener);
});
this.querySelectorAll<HTMLInputElement>('[data-row-checkbox="true"]').forEach((checkbox) => {
checkbox.removeEventListener(
'change',
this.handleRowCheckboxChange.bind(this) as EventListener
);
});
// Remove header checkbox cell listeners
const headerCheckboxCell = this.querySelector<HTMLElement>('[data-header-checkbox="true"]');
if (headerCheckboxCell) {
headerCheckboxCell.removeEventListener(
'keydown',
this.handleHeaderCheckboxKeyDown.bind(this) as EventListener
);
headerCheckboxCell.removeEventListener(
'focusin',
this.handleHeaderCheckboxFocus.bind(this) as EventListener
);
}
const selectAllCheckbox = this.querySelector<HTMLInputElement>('[data-select-all="true"]');
if (selectAllCheckbox) {
selectAllCheckbox.removeEventListener(
'change',
this.handleSelectAllChange.bind(this) as EventListener
);
}
}
// =============================================================================
// Helper methods
// =============================================================================
private getDataRows(): HTMLElement[] {
return Array.from(this.querySelectorAll<HTMLElement>('[role="row"]')).slice(1);
}
private getColumnCount(): number {
const headers = this.querySelectorAll('[role="columnheader"]');
// Count data columns (exclude checkbox column header)
const dataColumnCount = Array.from(headers).filter(
(h) => !(h as HTMLElement).classList.contains('apg-data-grid-checkbox-cell')
).length;
// Include checkbox column if rowSelectable
return this.rowSelectable ? dataColumnCount + 1 : dataColumnCount;
}
private getDataColumnCount(): number {
const headers = this.querySelectorAll('[role="columnheader"]');
return Array.from(headers).filter(
(h) => !(h as HTMLElement).classList.contains('apg-data-grid-checkbox-cell')
).length;
}
private getSortableHeaders(): HTMLElement[] {
return Array.from(
this.querySelectorAll<HTMLElement>('[role="columnheader"][data-sortable="true"]')
);
}
private getCellAt(rowIndex: number, colIndex: number): HTMLElement | null {
const rows = this.getDataRows();
const row = rows[rowIndex];
if (!row) return null;
// Get only data cells (exclude checkbox cells)
const cells = Array.from(
row.querySelectorAll('[role="gridcell"], [role="rowheader"]')
).filter((c) => (c as HTMLElement).dataset.checkboxCell !== 'true');
return cells[colIndex] as HTMLElement | null;
}
private focusElement(el: HTMLElement, id: string) {
const currentFocused = this.querySelector('[tabindex="0"]');
if (currentFocused && currentFocused !== el) {
currentFocused.setAttribute('tabindex', '-1');
currentFocused.classList.remove('focused');
}
el.setAttribute('tabindex', '0');
el.classList.add('focused');
el.focus();
this.focusedId = id;
}
private focusCell(cell: HTMLElement) {
const cellId = cell.dataset.cellId ?? null;
if (!cellId) return;
const focusableChild = cell.querySelector<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
if (focusableChild) {
focusableChild.setAttribute('tabindex', '-1');
const currentFocused = this.querySelector('[tabindex="0"]');
if (currentFocused && currentFocused !== cell) {
currentFocused.setAttribute('tabindex', '-1');
currentFocused.classList.remove('focused');
}
cell.setAttribute('tabindex', '0');
cell.classList.add('focused');
focusableChild.focus();
this.focusedId = cellId;
} else {
this.focusElement(cell, cellId);
}
}
private focusHeader(header: HTMLElement) {
const headerId = header.dataset.headerId ?? null;
if (!headerId) return;
this.focusElement(header, headerId);
}
private focusCheckboxCell(cell: HTMLElement) {
const checkboxId = cell.dataset.checkboxId ?? null;
if (!checkboxId) return;
this.focusElement(cell, checkboxId);
}
private getCheckboxCellAt(rowIndex: number): HTMLElement | null {
const rows = this.getDataRows();
const row = rows[rowIndex];
if (!row) return null;
return row.querySelector<HTMLElement>('[data-checkbox-cell="true"]');
}
private handleCheckboxCellFocus(event: Event) {
const cell = event.currentTarget as HTMLElement;
const checkboxId = cell.dataset.checkboxId;
if (!checkboxId) return;
const currentFocused = this.querySelector('[tabindex="0"]');
if (currentFocused && currentFocused !== cell) {
currentFocused.setAttribute('tabindex', '-1');
currentFocused.classList.remove('focused');
}
cell.setAttribute('tabindex', '0');
cell.classList.add('focused');
this.focusedId = checkboxId;
}
private handleHeaderCheckboxFocus(event: Event) {
const cell = event.currentTarget as HTMLElement;
const currentFocused = this.querySelector('[tabindex="0"]');
if (currentFocused && currentFocused !== cell) {
currentFocused.setAttribute('tabindex', '-1');
currentFocused.classList.remove('focused');
}
cell.setAttribute('tabindex', '0');
cell.classList.add('focused');
this.focusedId = 'header-checkbox';
}
private handleHeaderCheckboxKeyDown(event: KeyboardEvent) {
const cell = event.currentTarget as HTMLElement;
const { key, ctrlKey } = event;
let handled = true;
switch (key) {
case 'ArrowRight': {
// Move to first sortable header or first data cell
const sortableHeaders = this.getSortableHeaders();
if (sortableHeaders.length > 0) {
this.focusHeader(sortableHeaders[0]);
} else {
const firstCell = this.getCellAt(0, 0);
if (firstCell) {
this.focusCell(firstCell);
}
}
break;
}
case 'ArrowLeft':
// Already at leftmost position, do nothing
handled = false;
break;
case 'ArrowDown': {
// Move to first row checkbox cell
const firstCheckboxCell = this.getCheckboxCellAt(0);
if (firstCheckboxCell) {
this.focusCheckboxCell(firstCheckboxCell);
}
break;
}
case 'ArrowUp':
// Already at topmost position, do nothing
handled = false;
break;
case 'Home':
if (ctrlKey) {
// Already at first position
handled = false;
}
break;
case 'End': {
if (ctrlKey) {
// Move to last cell in grid
const rowCount = this.getDataRows().length;
const dataColCount = this.getDataColumnCount();
const lastCell = this.getCellAt(rowCount - 1, dataColCount - 1);
if (lastCell) {
this.focusCell(lastCell);
}
} else {
// Move to last header in row
const sortableHeaders = this.getSortableHeaders();
if (sortableHeaders.length > 0) {
this.focusHeader(sortableHeaders[sortableHeaders.length - 1]);
}
}
break;
}
case ' ':
case 'Enter': {
// Toggle select all checkbox
const selectAllCheckbox = cell.querySelector<HTMLInputElement>(
'[data-select-all="true"]'
);
if (selectAllCheckbox) {
selectAllCheckbox.checked = !selectAllCheckbox.checked;
selectAllCheckbox.dispatchEvent(new Event('change', { bubbles: true }));
}
break;
}
default:
handled = false;
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
}
private handleCheckboxCellClick(event: MouseEvent) {
const cell = event.currentTarget as HTMLElement;
// Focus the cell after the checkbox change is processed
requestAnimationFrame(() => {
cell.focus();
const checkboxId = cell.dataset.checkboxId;
if (checkboxId) {
this.focusedId = checkboxId;
}
});
}
private handleCheckboxCellKeyDown(event: KeyboardEvent) {
const cell = event.currentTarget as HTMLElement;
const { key, ctrlKey } = event;
const rowIndex = parseInt(cell.dataset.rowIndex || '0', 10);
const rowId = cell.dataset.rowId ?? '';
let handled = true;
switch (key) {
case 'ArrowRight': {
// Move to first data cell in the same row
const firstDataCell = this.getCellAt(rowIndex, 0);
if (firstDataCell) {
this.focusCell(firstDataCell);
}
break;
}
case 'ArrowLeft': {
// Already at leftmost position, do nothing
handled = false;
break;
}
case 'ArrowDown': {
// Move to checkbox cell in next row
const nextCheckboxCell = this.getCheckboxCellAt(rowIndex + 1);
if (nextCheckboxCell) {
this.focusCheckboxCell(nextCheckboxCell);
}
break;
}
case 'ArrowUp': {
// Move to checkbox cell in previous row
if (rowIndex > 0) {
const prevCheckboxCell = this.getCheckboxCellAt(rowIndex - 1);
if (prevCheckboxCell) {
this.focusCheckboxCell(prevCheckboxCell);
}
}
break;
}
case 'Home': {
if (ctrlKey) {
// Move to first checkbox cell
const firstCheckboxCell = this.getCheckboxCellAt(0);
if (firstCheckboxCell) {
this.focusCheckboxCell(firstCheckboxCell);
}
}
// Home without ctrl - already at start of row
break;
}
case 'End': {
if (ctrlKey) {
// Move to last cell in grid
const rowCount = this.getDataRows().length;
const dataColCount = this.getDataColumnCount();
const lastCell = this.getCellAt(rowCount - 1, dataColCount - 1);
if (lastCell) {
this.focusCell(lastCell);
}
} else {
// Move to last cell in current row
const dataColCount = this.getDataColumnCount();
const lastCell = this.getCellAt(rowIndex, dataColCount - 1);
if (lastCell) {
this.focusCell(lastCell);
}
}
break;
}
case ' ':
case 'Enter': {
// Toggle row selection
const row = cell.closest('[role="row"]') as HTMLElement;
if (row && rowId) {
this.toggleRowSelection(rowId, row);
}
break;
}
default:
handled = false;
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
}
private handleFocus(event: Event) {
const cell = event.currentTarget as HTMLElement;
const cellId = cell.dataset.cellId;
if (!cellId) return;
const currentFocused = this.querySelector('[tabindex="0"]');
if (currentFocused && currentFocused !== cell) {
currentFocused.setAttribute('tabindex', '-1');
currentFocused.classList.remove('focused');
}
cell.setAttribute('tabindex', '0');
cell.classList.add('focused');
this.focusedId = cellId;
}
private handleHeaderFocus(event: Event) {
const header = event.currentTarget as HTMLElement;
const headerId = header.dataset.headerId;
if (!headerId) return;
const currentFocused = this.querySelector('[tabindex="0"]');
if (currentFocused && currentFocused !== header) {
currentFocused.setAttribute('tabindex', '-1');
currentFocused.classList.remove('focused');
}
header.setAttribute('tabindex', '0');
header.classList.add('focused');
this.focusedId = headerId;
}
private findNextCell(
rowIndex: number,
colIndex: number,
direction: 'right' | 'left' | 'up' | 'down'
): HTMLElement | null {
const colCount = this.getColumnCount();
const rowCount = this.getDataRows().length;
let newRow = rowIndex;
let newCol = colIndex;
switch (direction) {
case 'right':
newCol++;
if (newCol >= colCount) {
if (this.wrapNavigation) {
newCol = 0;
newRow++;
} else {
return null;
}
}
break;
case 'left':
newCol--;
if (newCol < 0) {
if (this.wrapNavigation) {
newCol = colCount - 1;
newRow--;
} else {
return null;
}
}
break;
case 'down':
newRow++;
break;
case 'up':
newRow--;
break;
}
if (newRow < 0 || newRow >= rowCount) return null;
const cell = this.getCellAt(newRow, newCol);
if (!cell) return null;
if (cell.dataset.disabled === 'true') {
return this.findNextCell(newRow, newCol, direction);
}
return cell;
}
// =============================================================================
// Sorting
// =============================================================================
private cycleSort(header: HTMLElement) {
const colId = header.dataset.colId;
const currentDirection = header.dataset.sortDirection || 'none';
let nextDirection: SortDirection;
switch (currentDirection) {
case 'none':
nextDirection = 'ascending';
break;
case 'ascending':
nextDirection = 'descending';
break;
case 'descending':
nextDirection = 'ascending';
break;
default:
nextDirection = 'ascending';
}
// Update all headers to none, then set this one
this.getSortableHeaders().forEach((h) => {
h.dataset.sortDirection = 'none';
h.setAttribute('aria-sort', 'none');
const indicator = h.querySelector('.sort-indicator');
if (indicator) indicator.remove();
});
header.dataset.sortDirection = nextDirection;
header.setAttribute('aria-sort', nextDirection);
// Add sort indicator (always add since nextDirection is 'ascending' or 'descending')
const indicator = document.createElement('span');
indicator.className = 'sort-indicator';
indicator.setAttribute('aria-hidden', 'true');
indicator.textContent = nextDirection === 'ascending' ? '▲' : '▼';
header.appendChild(indicator);
this.dispatchEvent(
new CustomEvent('sort', {
detail: { columnId: colId, direction: nextDirection },
})
);
}
private handleHeaderClick(event: Event) {
const header = event.currentTarget as HTMLElement;
this.cycleSort(header);
}
private handleHeaderKeyDown(event: KeyboardEvent) {
const header = event.currentTarget as HTMLElement;
const { key } = event;
const colIndex = parseInt(header.dataset.colIndex || '0', 10);
let handled = true;
switch (key) {
case 'Enter':
case ' ':
this.cycleSort(header);
break;
case 'ArrowDown': {
const firstCell = this.getCellAt(0, colIndex);
if (firstCell) this.focusCell(firstCell);
break;
}
case 'ArrowRight': {
const headers = this.getSortableHeaders();
const currentIndex = headers.indexOf(header);
if (currentIndex < headers.length - 1) {
this.focusHeader(headers[currentIndex + 1]);
}
break;
}
case 'ArrowLeft': {
const headers = this.getSortableHeaders();
const currentIndex = headers.indexOf(header);
if (currentIndex > 0) {
this.focusHeader(headers[currentIndex - 1]);
}
break;
}
case 'Home': {
const headers = this.getSortableHeaders();
if (headers.length > 0) {
this.focusHeader(headers[0]);
}
break;
}
case 'End': {
const headers = this.getSortableHeaders();
if (headers.length > 0) {
this.focusHeader(headers[headers.length - 1]);
}
break;
}
default:
handled = false;
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
}
// =============================================================================
// Row Selection
// =============================================================================
private toggleRowSelection(rowId: string, row: HTMLElement) {
if (row.getAttribute('aria-disabled') === 'true') return;
const checkbox = row.querySelector<HTMLInputElement>(
`[data-row-checkbox="true"][data-row-id="${rowId}"]`
);
if (this.rowMultiselectable) {
if (this.selectedRowIds.has(rowId)) {
this.selectedRowIds.delete(rowId);
row.setAttribute('aria-selected', 'false');
if (checkbox) checkbox.checked = false;
} else {
this.selectedRowIds.add(rowId);
row.setAttribute('aria-selected', 'true');
if (checkbox) checkbox.checked = true;
}
} else {
// Clear previous selection
this.getDataRows().forEach((r) => {
r.setAttribute('aria-selected', 'false');
const cb = r.querySelector<HTMLInputElement>('[data-row-checkbox="true"]');
if (cb) cb.checked = false;
});
this.selectedRowIds.clear();
this.selectedRowIds.add(rowId);
row.setAttribute('aria-selected', 'true');
if (checkbox) checkbox.checked = true;
}
this.updateSelectAllState();
this.dispatchEvent(
new CustomEvent('row-selection-change', {
detail: { selectedRowIds: Array.from(this.selectedRowIds) },
})
);
}
private handleRowCheckboxChange(event: Event) {
const checkbox = event.target as HTMLInputElement;
const rowId = checkbox.dataset.rowId;
if (!rowId) return;
const row = checkbox.closest('[role="row"]') as HTMLElement;
if (!row) return;
// Prevent default toggle since toggleRowSelection handles it
checkbox.checked = this.selectedRowIds.has(rowId);
this.toggleRowSelection(rowId, row);
}
private handleSelectAllChange(event: Event) {
const checkbox = event.target as HTMLInputElement;
const rows = this.getDataRows();
if (checkbox.checked) {
// Select all
rows.forEach((row) => {
if (row.getAttribute('aria-disabled') !== 'true') {
const rowId = row.dataset.rowId;
if (rowId) {
this.selectedRowIds.add(rowId);
row.setAttribute('aria-selected', 'true');
const cb = row.querySelector<HTMLInputElement>('[data-row-checkbox="true"]');
if (cb) cb.checked = true;
}
}
});
} else {
// Deselect all
rows.forEach((row) => {
row.setAttribute('aria-selected', 'false');
const cb = row.querySelector<HTMLInputElement>('[data-row-checkbox="true"]');
if (cb) cb.checked = false;
});
this.selectedRowIds.clear();
}
this.dispatchEvent(
new CustomEvent('row-selection-change', {
detail: { selectedRowIds: Array.from(this.selectedRowIds) },
})
);
}
private updateSelectAllState() {
const selectAllCheckbox = this.querySelector<HTMLInputElement>('[data-select-all="true"]');
if (!selectAllCheckbox) return;
const rows = this.getDataRows().filter((r) => r.getAttribute('aria-disabled') !== 'true');
const selectedCount = rows.filter((r) =>
this.selectedRowIds.has(r.dataset.rowId ?? '')
).length;
if (selectedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (selectedCount === rows.length) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
}
}
// =============================================================================
// Cell Selection
// =============================================================================
private toggleCellSelection(cell: HTMLElement) {
if (!this.selectable) return;
if (cell.dataset.disabled === 'true') return;
const cellId = cell.dataset.cellId;
if (!cellId) return;
if (this.multiselectable) {
if (this.selectedIds.has(cellId)) {
this.selectedIds.delete(cellId);
cell.setAttribute('aria-selected', 'false');
cell.classList.remove('selected');
} else {
this.selectedIds.add(cellId);
cell.setAttribute('aria-selected', 'true');
cell.classList.add('selected');
}
} else {
this.querySelectorAll('[aria-selected="true"]').forEach((el) => {
el.setAttribute('aria-selected', 'false');
el.classList.remove('selected');
});
this.selectedIds.clear();
this.selectedIds.add(cellId);
cell.setAttribute('aria-selected', 'true');
cell.classList.add('selected');
}
this.dispatchEvent(
new CustomEvent('selection-change', {
detail: { selectedIds: Array.from(this.selectedIds) },
})
);
}
private selectAllCells() {
if (!this.selectable || !this.multiselectable) return;
this.querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]').forEach(
(cell) => {
if (cell.dataset.disabled !== 'true' && cell.dataset.checkboxCell !== 'true') {
const cellId = cell.dataset.cellId;
if (cellId) {
this.selectedIds.add(cellId);
cell.setAttribute('aria-selected', 'true');
cell.classList.add('selected');
}
}
}
);
this.dispatchEvent(
new CustomEvent('selection-change', {
detail: { selectedIds: Array.from(this.selectedIds) },
})
);
}
// =============================================================================
// Range Selection
// =============================================================================
private getCellPosition(cellId: string): { rowIndex: number; colIndex: number } | null {
const cell = this.querySelector<HTMLElement>(`[data-cell-id="${cellId}"]`);
if (!cell) return null;
return {
rowIndex: parseInt(cell.dataset.rowIndex || '0', 10),
colIndex: parseInt(cell.dataset.colIndex || '0', 10),
};
}
private getCellsInRange(startCellId: string, endCellId: string): string[] {
const startPos = this.getCellPosition(startCellId);
const endPos = this.getCellPosition(endCellId);
if (!startPos || !endPos) return [];
const minRow = Math.min(startPos.rowIndex, endPos.rowIndex);
const maxRow = Math.max(startPos.rowIndex, endPos.rowIndex);
const minCol = Math.min(startPos.colIndex, endPos.colIndex);
const maxCol = Math.max(startPos.colIndex, endPos.colIndex);
const cellIds: string[] = [];
for (let r = minRow; r <= maxRow; r++) {
for (let c = minCol; c <= maxCol; c++) {
const cell = this.getCellAt(r, c);
if (cell && cell.dataset.cellId) {
cellIds.push(cell.dataset.cellId);
}
}
}
return cellIds;
}
private extendRangeSelection(currentCellId: string, newFocusId: string) {
if (!this.enableRangeSelection) return;
const anchor = this.anchorCellId ?? currentCellId;
if (!this.anchorCellId) {
this.anchorCellId = currentCellId;
}
const cellIds = this.getCellsInRange(anchor, newFocusId);
this.dispatchEvent(
new CustomEvent('range-select', {
detail: { cellIds },
})
);
}
private clearRangeSelection() {
this.anchorCellId = null;
this.dispatchEvent(
new CustomEvent('range-select', {
detail: { cellIds: [] },
})
);
}
// =============================================================================
// Cell Editing
// =============================================================================
private handleCellDoubleClick(event: Event) {
const cell = event.currentTarget as HTMLElement;
if (cell.dataset.editable === 'true') {
this.startEdit(cell);
}
}
private startEdit(cell: HTMLElement) {
if (!this.editable || this.readonly) return;
if (cell.dataset.editable !== 'true') return;
const cellId = cell.dataset.cellId;
const rowId = cell.dataset.rowId;
const colId = cell.dataset.colId;
if (!cellId) return;
const currentValue = cell.textContent?.trim() ?? '';
this.editValue = currentValue;
this.originalEditValue = currentValue;
this.editingCellId = cellId;
this.isEditing = true;
// Create input
const input = document.createElement('input');
input.type = 'text';
input.className = 'apg-data-grid-input';
input.value = currentValue;
input.addEventListener('keydown', (e) => this.handleEditKeyDown(e, cellId));
input.addEventListener('blur', () => this.handleEditBlur(cellId));
input.addEventListener('input', (e) => {
this.editValue = (e.target as HTMLInputElement).value;
this.dispatchEvent(
new CustomEvent('cell-value-change', {
detail: { cellId, value: this.editValue },
})
);
});
// Store original content and replace with input
cell.dataset.originalContent = cell.innerHTML;
cell.innerHTML = '';
cell.appendChild(input);
cell.classList.add('editing');
input.focus();
input.select();
this.dispatchEvent(
new CustomEvent('edit-start', {
detail: { cellId, rowId, colId },
})
);
}
private endEdit(cellId: string, cancelled: boolean) {
if (this.isEndingEdit) return;
if (this.editingCellId !== cellId) return;
this.isEndingEdit = true;
const cell = this.querySelector<HTMLElement>(`[data-cell-id="${cellId}"]`);
if (!cell) {
this.isEndingEdit = false;
return;
}
const finalValue = cancelled ? this.originalEditValue : this.editValue;
// Restore cell content
cell.innerHTML = finalValue;
cell.classList.remove('editing');
this.dispatchEvent(
new CustomEvent('edit-end', {
detail: { cellId, value: finalValue, cancelled },
})
);
this.editingCellId = null;
this.editValue = '';
this.originalEditValue = '';
this.isEditing = false;
// Return focus to cell
setTimeout(() => {
this.focusCell(cell);
this.isEndingEdit = false;
}, 0);
}
private handleEditKeyDown(event: KeyboardEvent, cellId: string) {
const { key } = event;
if (key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.endEdit(cellId, true);
} else if (key === 'Enter') {
event.preventDefault();
event.stopPropagation();
this.endEdit(cellId, false);
} else if (key === 'Tab') {
event.preventDefault();
event.stopPropagation();
this.endEdit(cellId, false);
}
}
private handleEditBlur(cellId: string) {
if (this.isEndingEdit) return;
this.endEdit(cellId, false);
}
// =============================================================================
// Cell KeyDown
// =============================================================================
private handleKeyDown(event: KeyboardEvent) {
if (this.isEditing) return;
const cell = event.currentTarget as HTMLElement;
const { key, ctrlKey, shiftKey } = event;
const rowIndex = parseInt(cell.dataset.rowIndex || '0', 10);
const colIndex = parseInt(cell.dataset.colIndex || '0', 10);
const cellId = cell.dataset.cellId ?? '';
const rowId = cell.dataset.rowId ?? '';
const colId = cell.dataset.colId ?? '';
let handled = true;
switch (key) {
case 'ArrowRight': {
const next = this.findNextCell(rowIndex, colIndex, 'right');
if (next) {
if (shiftKey && this.enableRangeSelection) {
this.extendRangeSelection(cellId, next.dataset.cellId ?? '');
} else {
this.clearRangeSelection();
}
this.focusCell(next);
}
break;
}
case 'ArrowLeft': {
// Check if at first data cell and should go to checkbox
if (colIndex === 0 && this.rowSelectable) {
this.clearRangeSelection();
const checkboxCell = this.getCheckboxCellAt(rowIndex);
if (checkboxCell) {
this.focusCheckboxCell(checkboxCell);
}
} else {
const next = this.findNextCell(rowIndex, colIndex, 'left');
if (next) {
if (shiftKey && this.enableRangeSelection) {
this.extendRangeSelection(cellId, next.dataset.cellId ?? '');
} else {
this.clearRangeSelection();
}
this.focusCell(next);
}
}
break;
}
case 'ArrowDown': {
const next = this.findNextCell(rowIndex, colIndex, 'down');
if (next) {
if (shiftKey && this.enableRangeSelection) {
this.extendRangeSelection(cellId, next.dataset.cellId ?? '');
} else {
this.clearRangeSelection();
}
this.focusCell(next);
}
break;
}
case 'ArrowUp': {
if (rowIndex === 0) {
// Try to move to sortable header
const headers = this.getSortableHeaders();
const header = headers.find(
(h) => parseInt(h.dataset.colIndex || '0', 10) === colIndex
);
if (header) {
this.clearRangeSelection();
this.focusHeader(header);
break;
}
}
const next = this.findNextCell(rowIndex, colIndex, 'up');
if (next) {
if (shiftKey && this.enableRangeSelection) {
this.extendRangeSelection(cellId, next.dataset.cellId ?? '');
} else {
this.clearRangeSelection();
}
this.focusCell(next);
}
break;
}
case 'Home': {
if (ctrlKey && shiftKey && this.enableRangeSelection) {
const firstCell = this.getCellAt(0, 0);
if (firstCell) {
this.extendRangeSelection(cellId, firstCell.dataset.cellId ?? '');
this.focusCell(firstCell);
}
} else if (shiftKey && this.enableRangeSelection) {
const firstInRow = this.getCellAt(rowIndex, 0);
if (firstInRow) {
this.extendRangeSelection(cellId, firstInRow.dataset.cellId ?? '');
this.focusCell(firstInRow);
}
} else if (ctrlKey) {
this.clearRangeSelection();
// Go to first cell in grid (checkbox if rowSelectable)
if (this.rowSelectable) {
const firstCheckbox = this.getCheckboxCellAt(0);
if (firstCheckbox) this.focusCheckboxCell(firstCheckbox);
} else {
const firstCell = this.getCellAt(0, 0);
if (firstCell) this.focusCell(firstCell);
}
} else {
this.clearRangeSelection();
// Go to first cell in row (checkbox if rowSelectable)
if (this.rowSelectable) {
const checkboxCell = this.getCheckboxCellAt(rowIndex);
if (checkboxCell) this.focusCheckboxCell(checkboxCell);
} else {
const firstInRow = this.getCellAt(rowIndex, 0);
if (firstInRow) this.focusCell(firstInRow);
}
}
break;
}
case 'End': {
const dataColCount = this.getDataColumnCount();
const rowCount = this.getDataRows().length;
if (ctrlKey && shiftKey && this.enableRangeSelection) {
const lastCell = this.getCellAt(rowCount - 1, dataColCount - 1);
if (lastCell) {
this.extendRangeSelection(cellId, lastCell.dataset.cellId ?? '');
this.focusCell(lastCell);
}
} else if (shiftKey && this.enableRangeSelection) {
const lastInRow = this.getCellAt(rowIndex, dataColCount - 1);
if (lastInRow) {
this.extendRangeSelection(cellId, lastInRow.dataset.cellId ?? '');
this.focusCell(lastInRow);
}
} else if (ctrlKey) {
this.clearRangeSelection();
const lastCell = this.getCellAt(rowCount - 1, dataColCount - 1);
if (lastCell) this.focusCell(lastCell);
} else {
this.clearRangeSelection();
const lastInRow = this.getCellAt(rowIndex, dataColCount - 1);
if (lastInRow) this.focusCell(lastInRow);
}
break;
}
case 'PageDown': {
if (this.enablePageNavigation) {
this.clearRangeSelection();
const rowCount = this.getDataRows().length;
const targetRow = Math.min(rowIndex + this.pageSize, rowCount - 1);
const targetCell = this.getCellAt(targetRow, colIndex);
if (targetCell) this.focusCell(targetCell);
} else {
handled = false;
}
break;
}
case 'PageUp': {
if (this.enablePageNavigation) {
this.clearRangeSelection();
const targetRow = Math.max(rowIndex - this.pageSize, 0);
const targetCell = this.getCellAt(targetRow, colIndex);
if (targetCell) this.focusCell(targetCell);
} else {
handled = false;
}
break;
}
case ' ': {
this.toggleCellSelection(cell);
break;
}
case 'Enter': {
if (this.editable && cell.dataset.editable === 'true' && !this.readonly) {
this.startEdit(cell);
} else if (cell.dataset.disabled !== 'true') {
this.dispatchEvent(
new CustomEvent('cell-activate', {
detail: { cellId, rowId, colId },
})
);
}
break;
}
case 'F2': {
if (this.editable && cell.dataset.editable === 'true' && !this.readonly) {
this.startEdit(cell);
}
break;
}
case 'a': {
if (ctrlKey) {
this.selectAllCells();
} else {
handled = false;
}
break;
}
default:
handled = false;
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
}
}
type SortDirection = 'ascending' | 'descending' | 'none' | 'other';
customElements.define('apg-data-grid', ApgDataGrid);
</script> 使い方
Example
---
import DataGrid from './DataGrid.astro';
const columns = [
{ id: 'name', header: 'Name', sortable: true, sortDirection: 'ascending' },
{ id: 'email', header: 'Email', sortable: true },
{ id: 'role', header: 'Role', sortable: true },
];
const rows = [
{
id: 'user1',
cells: [
{ id: 'user1-name', value: 'Alice Johnson', editable: true },
{ id: 'user1-email', value: 'alice@example.com', editable: true },
{ id: 'user1-role', value: 'Admin' },
],
},
];
---
<!-- Basic Data Grid -->
<DataGrid
columns={columns}
rows={rows}
ariaLabel="User list"
/>
<!-- With row selection -->
<DataGrid
columns={columns}
rows={rows}
ariaLabel="User list"
rowSelectable
rowMultiselectable
/>
<!-- With range selection and editing -->
<DataGrid
columns={columns}
rows={rows}
ariaLabel="User list"
enableRangeSelection
editable
/>
<!-- Listen to events via custom events -->
<script>
document.querySelector('apg-data-grid').addEventListener('datagrid:sort', (e) => {
console.log('Sort:', e.detail);
});
document.querySelector('apg-data-grid').addEventListener('datagrid:rowselect', (e) => {
console.log('Row selection:', e.detail);
});
document.querySelector('apg-data-grid').addEventListener('datagrid:editend', (e) => {
console.log('Edit end:', e.detail);
});
</script> API
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
columns | DataGridColumnDef[] | required | 列定義 |
rows | DataGridRowData[] | required | 行データ |
rowSelectable | boolean | false | 行選択を有効化 |
enableRangeSelection | boolean | false | 範囲選択を有効化 |
editable | boolean | false | セル編集を有効化 |
Custom Events
| イベント | Detail | 説明 |
|---|---|---|
datagrid:sort | { columnId, direction } | 列がソートされた時に発火 |
datagrid:rowselect | { rowIds } | 行選択が変更された時に発火 |
datagrid:editend | { cellId, value, cancelled } | セル編集が終了した時に発火 |
テスト
テストは、キーボード操作、ARIA属性、アクセシビリティ要件全体のAPG準拠を検証します。データグリッドコンポーネントは、基本的なグリッドテスト戦略をソート、行選択、範囲選択、セル編集の追加テストで拡張しています。
テスト戦略
ユニットテスト(Testing Library)
フレームワーク固有のTesting Libraryユーティリティを使用して、コンポーネントのレンダリングとインタラクションを検証します。これらのテストは、分離された状態での正しいコンポーネントの動作を確認します。
- HTML構造と要素階層(grid、row、gridcell)
- 初期属性値(role、aria-label、tabindex、aria-sort)
- 選択状態の変更(行とセルのaria-selected)
- 編集モード状態(aria-readonly)
- ソート方向の更新(aria-sort)
- CSSクラスの適用
E2Eテスト(Playwright)
4つのフレームワークすべてにわたって実際のブラウザ環境でコンポーネントの動作を検証します。これらのテストは、完全なブラウザコンテキストを必要とするインタラクションをカバーします。
- 2Dキーボードナビゲーション(矢印キー)
- ヘッダーナビゲーションとソート
- Shift+矢印による範囲選択
- セル編集ワークフロー(Enter、F2、Escape)
- チェックボックスによる行選択
- ヘッダーとセル間のフォーカス管理
- フレームワーク間の一貫性
テストカテゴリ
高優先度: APG ARIA属性
| テスト | 説明 |
|---|---|
role="grid" | コンテナにgridロールがある |
role="row" | すべての行にrowロールがある |
role="gridcell" | データセルにgridcellロールがある |
role="columnheader" | ヘッダーセルにcolumnheaderロールがある |
aria-sort | ソート可能なヘッダーにaria-sortがある |
aria-sort updates | ソートアクションでaria-sortが更新される |
aria-selected on rows | rowSelectableの場合、行にaria-selectedがある |
aria-readonly on grid | readonlyプロップの場合、グリッドにaria-readonlyがある |
aria-readonly on cells | 編集可能性に基づいてセルにaria-readonlyがある |
aria-multiselectable | 行またはセルのマルチセレクトが有効な場合に存在 |
高優先度: ソート
| テスト | 説明 |
|---|---|
Enter on header | ソート可能なヘッダーでEnterがソートをトリガー |
Space on header | ソート可能なヘッダーでSpaceがソートをトリガー |
Sort cycle | ソート循環: none → ascending → descending → ascending |
Non-sortable headers | ソート不可のヘッダーはEnter/Spaceに応答しない |
高優先度: 範囲選択
| テスト | 説明 |
|---|---|
Shift+ArrowDown | 選択を下に拡張 |
Shift+ArrowUp | 選択を上に拡張 |
Shift+Home | 選択を行の先頭まで拡張 |
Shift+End | 選択を行の末尾まで拡張 |
Ctrl+Shift+Home | 選択をグリッドの先頭まで拡張 |
Ctrl+Shift+End | 選択をグリッドの末尾まで拡張 |
Selection anchor | 最初の選択時に選択アンカーが設定される |
高優先度: 行選択
| テスト | 説明 |
|---|---|
Checkbox toggle | チェックボックスのクリックで行選択を切り替え |
aria-selected | 行要素でaria-selectedが更新される |
Callback fires | onRowSelectionChangeコールバックが発火 |
Select all | 全選択チェックボックスがすべての行を選択/解除 |
Indeterminate | 一部選択時に全選択が不確定状態を表示 |
高優先度: セル編集
| テスト | 説明 |
|---|---|
Enter starts edit | 編集可能なセルでEnterが編集モードに入る |
F2 starts edit | 編集可能なセルでF2が編集モードに入る |
Escape cancels | Escapeが編集をキャンセルして元の値を復元 |
Navigation disabled | 編集モード中はグリッドナビゲーションが無効 |
Focus on input | 編集開始時にフォーカスが入力フィールドに移動 |
Focus returns | 編集終了時にフォーカスがセルに戻る |
onEditStart | 編集モードに入るときにonEditStartコールバックが発火 |
onEditEnd | 編集モードを終了するときにonEditEndコールバックが発火 |
Readonly cell | 読み取り専用セルは編集モードに入らない |
高優先度: フォーカス管理
| テスト | 説明 |
|---|---|
Sortable headers focusable | ソート可能なヘッダーにtabindexがある |
Non-sortable not focusable | ソート不可のヘッダーにtabindexがない |
First has tabindex=0 | 最初のフォーカス可能な要素にtabindex="0"がある |
Header to data | ヘッダーからArrowDownで最初のデータ行に入る |
Data to header | 最初の行からArrowUpでソート可能なヘッダーに入る |
Roving tabindex | ローヴィングタブインデックスが正しく更新される |
中優先度: 仮想化サポート
| テスト | 説明 |
|---|---|
aria-rowcount | totalRows提供時に存在(1ベース) |
aria-colcount | totalColumns提供時に存在(1ベース) |
aria-rowindex | 仮想化時に行に存在(1ベース) |
aria-colindex | 仮想化時にセルに存在(1ベース) |
中優先度: アクセシビリティ
| テスト | 説明 |
|---|---|
axe-core | アクセシビリティ違反なし |
Sort indicators | ソートインジケーターにアクセシブルな名前がある |
Checkbox labels | チェックボックスにアクセシブルなラベルがある |
テストツール
- Vitest (opens in new tab) - ユニットテスト用テストランナー
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ(React、Vue、Svelte)
- Playwright (opens in new tab) - E2Eテスト用ブラウザ自動化
- axe-core/playwright (opens in new tab) - E2Eでの自動アクセシビリティテスト
testing-strategy.md (opens in new tab) を参照して完全なドキュメントをご覧ください。
リソース
- WAI-ARIA APG: Grid パターン (opens in new tab)
- WAI-ARIA APG: Data Grid 例 (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist