diff --git a/demo/components/grid/index.html b/demo/components/grid/index.html
new file mode 100644
index 00000000..7d0332e6
--- /dev/null
+++ b/demo/components/grid/index.html
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+ Static Grid — Z Layout
+
+
+
+
+
+ A (0,0)
+
+
+ B (1,0) width=2
+
+
+ D (1,1)
+
+
+ C (0,1)
+
+
+ E (2,1)
+
+
+
+
+ Static Grid — N Layout
+
+
+ Static N-layout grid placeholder.
+
+
+ Editable Grid
+
+
+ Editable grid placeholder.
+
+
+ RTL Grid
+
+
+ RTL grid placeholder.
+
+
+ Collision Demo
+
+
+ Collision demo placeholder.
+
+
+
+
+
+
diff --git a/index.html b/index.html
index 5653a3ea..01d64c14 100644
--- a/index.html
+++ b/index.html
@@ -37,6 +37,7 @@ Components
Community
File Uploader
Grade Result
+ Grid
Media Player
Navigation
diff --git a/src/components/grid/README.md b/src/components/grid/README.md
new file mode 100644
index 00000000..1dce18b9
--- /dev/null
+++ b/src/components/grid/README.md
@@ -0,0 +1,88 @@
+# @brightspace-ui/accessible-grid
+
+[](https://www.npmjs.org/package/@brightspace-ui/accessible-grid)
+
+Scannable and Editable grids for Brightspace
+
+## Installation
+
+Install from NPM:
+
+```shell
+npm install @brightspace-ui/accessible-grid
+```
+
+## Usage
+
+```html
+
+My element
+```
+
+**Properties:**
+
+| Property | Type | Description |
+|--|--|--|
+| | | |
+
+**Accessibility:**
+
+To make your usage of `d2l-accessible-grid` accessible, use the following properties when applicable:
+
+| Attribute | Description |
+|--|--|
+| | |
+
+## Developing and Contributing
+
+After cloning the repo, run `npm install` to install dependencies.
+
+### Testing
+
+To run the full suite of tests:
+
+```shell
+npm test
+```
+
+Alternatively, tests can be selectively run:
+
+```shell
+# eslint
+npm run lint:eslint
+
+# stylelint
+npm run lint:style
+
+# accessibility tests
+npm run test:axe
+
+# unit tests
+npm run test:unit
+```
+
+This repo uses [@brightspace-ui/testing](https://github.com/BrightspaceUI/testing)'s vdiff command to perform visual regression testing:
+
+```shell
+# vdiff
+npm run test:vdiff
+
+# re-generate goldens
+npm run test:vdiff golden
+```
+
+### Running the demos
+
+To start a [@web/dev-server](https://modern-web.dev/docs/dev-server/overview/) that hosts the demo page and tests:
+
+```shell
+npm start
+```
+
+### Versioning and Releasing
+
+This repo is configured to use `semantic-release`. Commits prefixed with `fix:` and `feat:` will trigger patch and minor releases when merged to `main`.
+
+To learn how to create major releases and release from maintenance branches, refer to the [semantic-release GitHub Action](https://github.com/BrightspaceUI/actions/tree/main/semantic-release) documentation.
diff --git a/src/components/grid/components/accessible-grid-cell.js b/src/components/grid/components/accessible-grid-cell.js
new file mode 100644
index 00000000..d10bb0d3
--- /dev/null
+++ b/src/components/grid/components/accessible-grid-cell.js
@@ -0,0 +1,76 @@
+import { css, html, LitElement } from 'lit';
+import { LocalizeLabsElement } from '../../localize-labs-element.js';
+import { PropertyRequiredMixin } from '@brightspace-ui/core/mixins/property-required/property-required-mixin.js';
+/**
+ * Data-only model carrier for `d2l-accessible-grid`.
+ * This element exposes authored cell geometry/label and dispatches lifecycle
+ * events so the host can rebuild its rendered tree. It renders NO chrome,
+ * Aria and focus management live on the host-rendered role="gridcell" divs (see accessible-grid.js).
+ */
+class AccessibleGridCell extends LocalizeLabsElement(PropertyRequiredMixin(LitElement)) {
+
+ static get properties() {
+ return {
+ /** Optional stable identifier used for focus restoration and slot naming. */
+ cellKey: { type: String, attribute: 'cell-key', reflect: true },
+ /** Row span (default 1). */
+ height: { type: Number, reflect: true },
+ /** Accessible label for this cell (required -- announced at pickup). */
+ label: { type: String, required: true },
+ /** Column span (default 1). */
+ width: { type: Number, reflect: true },
+ /** Zero-based column anchor. */
+ x: { type: Number, reflect: true },
+ /** Zero-based row anchor. */
+ y: { type: Number, reflect: true },
+ };
+ }
+
+ static get styles() {
+ return css`:host { display: contents; }`;
+ }
+
+ constructor() {
+ super();
+ this.cellKey = undefined;
+ this.height = 1;
+ this.label = '';
+ this.width = 1;
+ this.x = 0;
+ this.y = 0;
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.dispatchEvent(new CustomEvent('d2l-accessible-grid-cell-connected', {
+ bubbles: true,
+ composed: true,
+ }));
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this.dispatchEvent(new CustomEvent('d2l-accessible-grid-cell-disconnected', {
+ bubbles: true,
+ composed: true,
+ }));
+ }
+
+ render() {
+ return html``;
+ }
+
+ updated(changedProperties) {
+ super.updated(changedProperties);
+ const watched = ['x', 'y', 'width', 'height', 'label', 'cellKey'];
+ if (watched.some(p => changedProperties.has(p))) {
+ this.dispatchEvent(new CustomEvent('d2l-accessible-grid-cell-changed', {
+ bubbles: true,
+ composed: true,
+ }));
+ }
+ }
+
+}
+
+customElements.define('d2l-accessible-grid-cell', AccessibleGridCell);
diff --git a/src/components/grid/components/accessible-grid.js b/src/components/grid/components/accessible-grid.js
new file mode 100644
index 00000000..eb6f1e69
--- /dev/null
+++ b/src/components/grid/components/accessible-grid.js
@@ -0,0 +1,257 @@
+import { css, html, LitElement, nothing } from 'lit';
+import { getFocusRingStyles } from '@brightspace-ui/core/helpers/focus.js';
+import { LocalizeLabsElement } from '../../localize-labs-element.js';
+import { PropertyRequiredMixin } from '@brightspace-ui/core/mixins/property-required/property-required-mixin.js';
+import { SkeletonMixin } from '@brightspace-ui/core/components/skeleton/skeleton-mixin.js';
+import { styleMap } from 'lit/directives/style-map.js';
+
+/**
+ * A 2-D accessible grid with author-defined spanning cells.
+ *
+ * Architecture: authored elements are pure data
+ * carriers. This host reads their geometry from the light DOM, computes
+ * reading order, and renders ALL chrome (role="row" wrappers, role="gridcell"
+ * boxes, ARIA, drag handles, move-menu buttons) in its own shadow DOM.
+ * Cell content is projected into its rendered position via named slots.
+ *
+ * @fires d2l-accessible-grid-move - Fired when a cell is moved (cancelable).
+ */
+class AccessibleGrid extends LocalizeLabsElement(SkeletonMixin(PropertyRequiredMixin(LitElement))) {
+
+ static get properties() {
+ return {
+ /** Number of columns in the grid. */
+ cols: { type: Number, reflect: true },
+ /** When present, enables drag/drop, keyboard reorder, and Move menu. */
+ editable: { type: Boolean, reflect: true },
+ /** Accessible label for the grid (required). */
+ label: { type: String, required: true },
+ /** Number of rows in the grid. */
+ rows: { type: Number, reflect: true },
+ /** Internal array of cell descriptors built from slotted children. */
+ _cells: { state: true },
+ };
+ }
+
+ static get styles() {
+ return [
+ css`
+ :host {
+ display: block;
+ }
+ :host([hidden]) {
+ display: none;
+ }
+ .grid {
+ box-sizing: border-box;
+ display: grid;
+ gap: var(--d2l-accessible-grid-gap, 0.6rem);
+ width: 100%;
+ }
+ [role="row"] {
+ display: contents;
+ }
+ .cell {
+ background: var(--d2l-accessible-grid-cell-background, var(--d2l-color-sylvite));
+ border-radius: var(--d2l-accessible-grid-cell-border-radius, 0.3rem);
+ box-sizing: border-box;
+ min-height: 0;
+ min-width: 0;
+ overflow: hidden;
+ padding: var(--d2l-accessible-grid-cell-padding, 0.75rem);
+ }
+ .slot-observer {
+ display: none;
+ }
+ `,
+ getFocusRingStyles('.cell:focus-visible'),
+ ];
+ }
+
+ constructor() {
+ super();
+ this.cols = 0;
+ this.editable = false;
+ this.rows = 0;
+ this._cells = [];
+ /** @type {string|null} Key of the cell that owns roving tabindex focus. */
+ this._activeCellKey = null;
+ /** @type {{key:string,originX:number,originY:number}|null} Active keyboard-grab state. */
+ this._grabbed = null;
+ /** @type {Object|null} Snapshot for single-level undo. */
+ this._lastMove = null;
+ this._mutationObservers = new Map();
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.addEventListener('d2l-accessible-grid-cell-connected', this.#handleCellLifecycle);
+ this.addEventListener('d2l-accessible-grid-cell-disconnected', this.#handleCellLifecycle);
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this.removeEventListener('d2l-accessible-grid-cell-connected', this.#handleCellLifecycle);
+ this.removeEventListener('d2l-accessible-grid-cell-disconnected', this.#handleCellLifecycle);
+ this.#disconnectMutationObservers();
+ }
+
+ render() {
+ // left to right, top to bottom sorting to start
+ // TODO implement N reading order
+ const rowMap = new Map();
+ for (const cell of this._cells) {
+ if (!rowMap.has(cell.y)) rowMap.set(cell.y, []);
+ rowMap.get(cell.y).push(cell);
+ }
+ const sortedYs = [...rowMap.keys()].sort((a, b) => a - b);
+ const gridStyles = {
+ gridTemplateColumns: `repeat(${this.cols},minmax(0,1fr))`,
+ gridTemplateRows: `repeat(${this.rows},minmax(var(--d2l-accessible-grid-min-row-height,4rem),auto))`
+ };
+
+ const ariaColmCount = this.cols > 0 ? this.cols : nothing;
+ const ariaRowCount = this.rows > 0 ? this.rows : nothing;
+
+ return html`
+
+ ${this.#renderRow(sortedYs, rowMap)}
+
+
+
+
+ `;
+ }
+
+ #describeCellElement(el) {
+ return {
+ key: this.#getCellKey(el),
+ x: Number(el.getAttribute('x') ?? el.x ?? 0),
+ y: Number(el.getAttribute('y') ?? el.y ?? 0),
+ width: Number(el.getAttribute('width') ?? el.width ?? 1),
+ height: Number(el.getAttribute('height') ?? el.height ?? 1),
+ label: el.getAttribute('label') ?? el.label ?? '',
+ element: el,
+ };
+ }
+
+ #disconnectMutationObservers() {
+ for (const obs of this._mutationObservers.values()) {
+ obs.disconnect();
+ }
+ this._mutationObservers.clear();
+ }
+
+ #getCellKey(el) {
+ return el.getAttribute('cell-key') || el.cellKey || el._autoKey || (el._autoKey = `cell-${Math.random().toString(36).slice(2)}`);
+ }
+
+ #handleCellLifecycle() {
+ this.#rebuildCells();
+ }
+
+ #handleSlotChange() {
+ this.#rebuildCells();
+ }
+
+ #rebuildCells() {
+ const cells = [...this.children].filter(el => el.tagName === 'D2L-ACCESSIBLE-GRID-CELL');
+
+ // Disconnect observers for removed cells
+ const newKeys = new Set(cells.map(c => this.#getCellKey(c)));
+ for (const [key, obs] of this._mutationObservers) {
+ if (!newKeys.has(key)) {
+ obs.disconnect();
+ this._mutationObservers.delete(key);
+ }
+ }
+
+ // Assign named slots and attach observers for new cells
+ for (const cell of cells) {
+ const key = this.#getCellKey(cell);
+ const slotName = `cell-${key}`;
+ if (cell.slot !== slotName) cell.slot = slotName;
+
+ if (!this._mutationObservers.has(key)) {
+ const obs = new MutationObserver(() => this.#rebuildCells());
+ obs.observe(cell, { attributeFilter: ['x', 'y', 'width', 'height', 'label', 'cell-key'] });
+ this._mutationObservers.set(key, obs);
+ }
+ }
+
+ this._cells = cells.map(el => this.#describeCellElement(el));
+ this.#validateLayout();
+ }
+
+ #renderRow(sortedYs, rowMap) {
+ return sortedYs.map(y => {
+ const rowCells = rowMap.get(y).sort((a, b) => a.x - b.x);
+ return html`
+
+ ${rowCells.map(cell => html`
+
+
+
+ `)}
+
+ `;
+ });
+ }
+
+ #validateLayout() {
+ if (!this._cells.length) return;
+
+ // Overflow check
+ if (this.cols > 0 || this.rows > 0) {
+ for (const cell of this._cells) {
+ if (this.cols > 0 && (cell.x + cell.width) > this.cols) {
+ console.warn(`d2l-accessible-grid: cell "${cell.label || cell.key}" overflows cols (x=${cell.x}, width=${cell.width}, cols=${this.cols})`);
+ }
+ if (this.rows > 0 && (cell.y + cell.height) > this.rows) {
+ console.warn(`d2l-accessible-grid: cell "${cell.label || cell.key}" overflows rows (y=${cell.y}, height=${cell.height}, rows=${this.rows})`);
+ }
+ }
+ }
+
+ // Collision check -- DOM source order wins
+ const occupancy = new Map();
+ for (const cell of this._cells) {
+ let hasCollision = false;
+ for (let gridRow = cell.y; gridRow < cell.y + cell.height; gridRow++) {
+ for (let gridColumn = cell.x; gridColumn < cell.x + cell.width; gridColumn++) {
+ const gridSlot = `${gridRow},${gridColumn}`;
+ if (occupancy.has(gridSlot)) {
+ const winner = occupancy.get(gridSlot);
+ if (!hasCollision) {
+ console.warn(`d2l-accessible-grid: cell "${cell.label || cell.key}" collides with "${winner}" at row ${gridRow}, col ${gridColumn}. The later cell will be hidden.`);
+ hasCollision = true;
+ }
+ cell.element.setAttribute('aria-hidden', 'true');
+ cell.element.style.visibility = 'hidden';
+ } else {
+ occupancy.set(gridSlot, cell.label || cell.key);
+ }
+ }
+ }
+ }
+ }
+}
+
+customElements.define('d2l-accessible-grid', AccessibleGrid);
diff --git a/src/components/grid/grid.js b/src/components/grid/grid.js
new file mode 100644
index 00000000..fb865853
--- /dev/null
+++ b/src/components/grid/grid.js
@@ -0,0 +1,3 @@
+// Re-export shim — keeps `import '@brightspace-ui/accessible-grid/accessible-grid.js'` working.
+export * from './components/accessible-grid.js';
+export * from './components/accessible-grid-cell.js';
diff --git a/test/components/grid/accessible-grid.axe.js b/test/components/grid/accessible-grid.axe.js
new file mode 100644
index 00000000..b72292eb
--- /dev/null
+++ b/test/components/grid/accessible-grid.axe.js
@@ -0,0 +1,13 @@
+import '../../../src/components/grid/grid.js';
+import { expect, fixture, html } from '@brightspace-ui/testing';
+
+describe('d2l-accessible-grid', () => {
+
+ describe('accessibility', () => {
+ it('should pass all aXe tests', async() => {
+ const el = await fixture(html``);
+ await expect(el).to.be.accessible();
+ });
+ });
+
+});
diff --git a/test/components/grid/accessible-grid.test.js b/test/components/grid/accessible-grid.test.js
new file mode 100644
index 00000000..c79ea510
--- /dev/null
+++ b/test/components/grid/accessible-grid.test.js
@@ -0,0 +1,12 @@
+import '../../../src/components/grid/grid.js';
+import { runConstructor } from '@brightspace-ui/testing';
+
+describe('d2l-accessible-grid', () => {
+
+ describe('constructor', () => {
+ it('should construct', () => {
+ runConstructor('d2l-accessible-grid');
+ });
+ });
+
+});