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

+ + + +

Static Grid — N Layout

+ + + +

Editable Grid

+ + + +

RTL Grid

+ + + +

Collision Demo

+ + + + +
+ + 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 + +[![NPM version](https://img.shields.io/npm/v/@brightspace-ui/accessible-grid.svg)](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'); + }); + }); + +});