Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

120 changes: 109 additions & 11 deletions packages/DataGrid/DataGrid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,18 @@ describe('DataGrid', () => {
describe('auto-generated', () => {
const fixture = new ComponentTestFixture<TestDataGrid>(html`<test-data-grid></test-data-grid>`)

it('should keep the generated columns while new data has the same shape', async () => {
const columns = fixture.component.columns
const columnsChange = vi.fn()
fixture.component.addEventListener('columnsChange', columnsChange)

fixture.component.data = [...fixture.component.data]
await fixture.updateComplete

expect(columnsChange).not.toHaveBeenCalled()
expect(fixture.component.columns).toEqual(columns)
})

it('should auto-generate columns', () => {
const [firstColumn, secondColumn, thirdColumn] = fixture.component.columns

Expand Down Expand Up @@ -260,8 +272,14 @@ describe('DataGrid', () => {
await fixture.updateComplete

expect(fixture.component.columns.map(c => c.dataSelector)).toEqual(['name'])
expect(fixture.component.columnsController.columns.definitions.programmatic.length).toBe(1)
expect(fixture.component.columnsController.columns.definitions.generated.length).toBe(0)
expect(fixture.component.columnDefinitions.programmatic.length).toBe(1)
expect(fixture.component.columnDefinitions.generated.length).toBe(0)
})

it('should take effect at once, before the next update', () => {
fixture.component.columns = [new DataGridColumn({ heading: 'Name', dataSelector: 'name' })]

expect(fixture.component.columns.map(c => c.dataSelector)).toEqual(['name'])
})

it('should be providable through the deprecated setColumns as well', async () => {
Expand Down Expand Up @@ -303,15 +321,15 @@ describe('DataGrid', () => {
const col = fixture.component.querySelector('mo-data-grid-column-number')!
col.heading = 'Identifier'
await col.updateComplete
fixture.component.columnsController.extractColumns()
fixture.component.extractColumns()
await fixture.updateComplete
expect(fixture.component.columns.find(c => c.dataSelector === 'id')?.heading).toEqual('Identifier')
})

it('should update columns when columns connect or disconnect', async () => {
const column = fixture.component.querySelector('mo-data-grid-column-number')
column?.remove()
fixture.component.columnsController.extractColumns()
fixture.component.extractColumns()
await fixture.updateComplete
expect(fixture.component.columns.map(c => c.dataSelector)).toContain('name')

Expand All @@ -322,31 +340,31 @@ describe('DataGrid', () => {
fixture.component.appendChild(newCol)
await new Promise(r => setTimeout(r, 20))
await newCol.updateComplete
fixture.component.columnsController.extractColumns()
fixture.component.extractColumns()
await fixture.updateComplete
expect(fixture.component.columns.map(c => c.dataSelector)).toContain('id')
expect(fixture.component.columns.map(c => c.dataSelector)).toContain('name')
})

it('should expose the definition sources with extracted definitions winning', () => {
fixture.component.columnsController.extractColumns()
const definitions = fixture.component.columnsController.columns.definitions
fixture.component.extractColumns()
const definitions = fixture.component.columnDefinitions

expect(definitions.extracted.map(c => c.dataSelector)).toContain('id')
expect(definitions.extracted.map(c => c.dataSelector)).toContain('name')
expect(definitions.programmatic.length).toBe(0)
})

it('should be iterable and array-like over the effective definitions', () => {
const definitions = fixture.component.columnsController.columns.definitions
const definitions = fixture.component.columnDefinitions

expect(definitions.length).toBeGreaterThanOrEqual(2)
expect(definitions.find(c => c.dataSelector === 'name')?.heading).toBe('Name')
expect(definitions.filter(c => c.dataSelector === 'id').length).toBe(1)
})

it('should compose anew and update the data grid when a source is assigned', async () => {
const definitions = fixture.component.columnsController.columns.definitions
const definitions = fixture.component.columnDefinitions
const columnsChange = vi.fn()
fixture.component.addEventListener('columnsChange', columnsChange)

Expand Down Expand Up @@ -423,7 +441,7 @@ describe('DataGrid', () => {
colText.heading = 'Full Name'
await fixture.updateComplete
await colText.updateComplete
fixture.component.columnsController.extractColumns()
fixture.component.extractColumns()
await fixture.updateComplete

expect(fixture.component.columns.find(c => c.dataSelector === 'name')?.heading).toBe('Full Name')
Expand Down Expand Up @@ -1195,7 +1213,7 @@ describe('DataGrid', () => {
get updateCompleted() {
return (async () => {
await this.balanceColumnElement.updateComplete
this.component.columnsController.extractColumns()
this.component.extractColumns()
this.component.requestUpdate()
await this.component.updateComplete
for (const row of this.component.rows) {
Expand Down Expand Up @@ -1756,4 +1774,84 @@ describe('DataGrid', () => {
expect(fixture.component.virtualizationController).toBe(controller.virtualization)
})
})

describe('Column tracks', () => {
const fixture = new ComponentTestFixture<DataGrid<{ a: number, b: number }>>(html`
<mo-data-grid .data=${[{ a: 1, b: 2 }]}>
<mo-data-grid-column-number heading='A' dataSelector='a'></mo-data-grid-column-number>
<mo-data-grid-column-number heading='B' dataSelector='b' width='50px'></mo-data-grid-column-number>
</mo-data-grid>
`)

const tracks = async () => {
await fixture.updateComplete
await new Promise(r => setTimeout(r, 30))
await fixture.updateComplete
return fixture.component.renderRoot.querySelector('#content')?.getAttribute('columns') ?? ''
}

const dataTrackCount = (value: string) => value.match(/\[data\]/g)?.length ?? 0

it('should provide a named track per visible column and none for hidden ones, as a zero track would still render a gap', async () => {
const value = await tracks()

expect(dataTrackCount(value)).toBe(2)
expect(value).toContain('[data] max-content')
expect(value).toContain('[data] 50px')
expect(value).toContain('[padding] 1fr')
expect(value).toContain('[actions]')

fixture.component.columns.find(c => c.dataSelector === 'b')!.hide()
const hidden = await tracks()

expect(dataTrackCount(hidden)).toBe(1)
expect(hidden).not.toContain('50px')
})

it('should include the selection, details and reorder tracks only while the corresponding feature is active', async () => {
const initial = await tracks()
expect(initial).not.toContain('[selection]')
expect(initial).not.toContain('[details]')
expect(initial).not.toContain('[order]')

fixture.component.selectability = DataGridSelectability.Multiple
expect(await tracks()).toContain('[selection]')

fixture.component.getRowDetailsTemplate = () => html`<div>Details</div>`
expect(await tracks()).toContain('[details]')

fixture.component.getRowDetailsTemplate = undefined
fixture.component.reorderability = true
const reorderable = await tracks()

expect(reorderable).toContain('[order]')
expect(reorderable).not.toContain('[details]')
})

it('should lay out the tracks of a grid which first rendered outside the flat tree, such as slotted into a host yet to render its slot', async () => {
const host = document.createElement('div')
const root = host.attachShadow({ mode: 'open' })
const grid = document.createElement('mo-data-grid') as DataGrid<{ a: number, b: number }>
grid.selectability = DataGridSelectability.Multiple
grid.data = [{ a: 1, b: 2 }]
const column = document.createElement('mo-data-grid-column-number')
Object.assign(column, { heading: 'A', dataSelector: 'a', width: '50px' })
grid.append(column)
host.append(grid)
document.body.append(host)
try {
await grid.updateComplete
await new Promise(r => setTimeout(r, 30))
await grid.updateComplete

root.append(document.createElement('slot'))
await new Promise(requestAnimationFrame)

const tracks = getComputedStyle(grid.renderRoot.querySelector('#content')!).gridTemplateColumns
expect(tracks).toMatch(/^\[selection\] 40px \[data\] 50px \[padding\] [\d.]+px \[actions\] 28px/)
} finally {
host.remove()
}
})
})
})
47 changes: 30 additions & 17 deletions packages/DataGrid/DataGrid.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { property, component, Component, html, css, query, type PropertyValues, event, style, literal, staticHtml, type HTMLTemplateResult, repeat, eventListener } from '@a11d/lit'
import { property, component, Component, html, css, query, type PropertyValues, event, style, literal, staticHtml, type HTMLTemplateResult, repeat } from '@a11d/lit'
import { LocalStorage } from '@a11d/local-storage'
import { NotificationComponent } from '@a11d/lit-application'
import { Downloader } from '@3mo/downloader'
Expand All @@ -8,12 +8,10 @@ import { tooltip } from '@3mo/tooltip'
import { Localizer } from '@3mo/localization'
import { type Scroller } from '@3mo/scroller'
import { observeResize } from '@3mo/resize-observer'
import { DataGridSelectability, DataGridSelectionBehaviorOnDataChange } from './DataGridSelectionController.js'
import { type DataGridRankedSortDefinition, type DataGridSorting } from './DataGridSortingController.js'
import { DataGridController } from './DataGridController.js'
import { DataGridEditability } from './DataGridEditabilityController.js'
import { DataGridEditability, DataGridController, type DataGridRankedSortDefinition, type DataGridSorting, DataGridSelectability, DataGridSelectionBehaviorOnDataChange, type DataRecord } from './controller/index.js'
import { DataGridColumnComponentsController } from './DataGridColumnComponentsController.js'
import { DataGridColumnDefinitions } from './DataGridColumnDefinitions.js'
import { type DataGridColumn, type DataGridCell, type DataGridFooter, type DataGridHeader, type DataGridRow, type DataGridReorderChange } from './index.js'
import { type DataRecord } from './DataRecord.js'
import { DataGridToolbarElementStyles } from './DataGridToolbarElementStyles.js'
import { DataGridPagination, type DataGridPaginationLike, type DataGridPaginationSize, type DataGridPaginationStrategy } from './DataGridPagination.js'

Expand All @@ -31,7 +29,7 @@ Localizer.dictionaries.add('de', {
* @element mo-data-grid
*
* @attr data - The data to be displayed in the DataGrid. It is an array of objects, where each object represents a row.
* @attr columns - The read-only columns of the DataGrid, composed of their definitions and modifications. Provide columns programmatically via `columns.definitions.programmatic`.
* @attr columns - The columns of the DataGrid, composed of their definitions and modifications. Assigning it gives the definitions in code, which column elements override.
* @attr headerHidden - Whether the header should be hidden.
* @attr page - The current page.
* @attr pagination - How the rows are paged: a strategy, `pages` or `scroll`, and a size, a number or `auto` to fit the height, e.g. `pages`, `pages 50` or `50`.
Expand Down Expand Up @@ -136,7 +134,7 @@ export class DataGrid<TData, TDetailsElement extends Element | undefined = undef

@property({ type: Array })
get columns() { return [...this.controller.columns.columns] }
set columns(value) { this.controller.columns.columns.definitions.programmatic = value }
set columns(value) { this.columnDefinitions.programmatic = value }

@property({ type: Boolean, reflect: true }) headerHidden = false
@property({ type: Number }) page = 1
Expand Down Expand Up @@ -277,20 +275,27 @@ export class DataGrid<TData, TDetailsElement extends Element | undefined = undef
this.columns = columns
}

extractColumns(...parameters: Parameters<typeof this.controller.columns.extractColumns>) {
return this.controller.columns.extractColumns(...parameters)
}

@eventListener('DataGridColumnComponent:update')
protected handleColumnChange(e: CustomEvent) {
e.stopPropagation()
this.controller.columns.extractColumns()
extractColumns() {
this.columnComponents.extractColumns()
}

get visibleColumns() {
return this.controller.columns.columns.visible
}

// Named tracks for the parts present only: a zero-width track would still render its gap.
private get columnTracks() {
const { reorderability, details, selection } = this.controller
return [
['order', !reorderability.enabled ? undefined : 'var(--mo-data-grid-column-reorder-width)'],
['details', !details.hasDetails ? undefined : 'var(--mo-data-grid-column-details-width)'],
['selection', !selection.hasSelection ? undefined : 'var(--mo-data-grid-column-selection-width)'],
...this.visibleColumns.map(column => ['data', column.width]),
['padding', '1fr'],
['actions', 'var(--mo-data-grid-column-actions-width)'],
].filter(([, width]) => width !== undefined).map(([name, width]) => `[${name}] ${width}`).join(' ')
}

getRow(data: TData) {
return this.rows.find(r => r.data === data)
}
Expand Down Expand Up @@ -416,6 +421,7 @@ export class DataGrid<TData, TDetailsElement extends Element | undefined = undef

readonly controller = new DataGridController<TData, DataGrid<TData, TDetailsElement>>(this, grid => ({
get data() { return grid.data },
get columns() { return grid.columnDefinitions.toArray() },
get subDataGridDataSelector() { return grid.subDataGridDataSelector },
get sorting() { return grid.sorting },
handleSortingChange: sorting => {
Expand Down Expand Up @@ -449,6 +455,13 @@ export class DataGrid<TData, TDetailsElement extends Element | undefined = undef
handleCsvError: error => NotificationComponent.notifyAndThrowError(error.message),
}))

readonly columnComponents = new DataGridColumnComponentsController<TData>(this)

readonly columnDefinitions = new DataGridColumnDefinitions<TData>({
generate: () => this.columnComponents.autoGeneratedColumns,
updated: () => this.controller.columns.syncColumns(),
})

/** @deprecated Use `controller.columns`. */
get columnsController() { return this.controller.columns }

Expand Down Expand Up @@ -713,7 +726,7 @@ export class DataGrid<TData, TDetailsElement extends Element | undefined = undef
${observeResize(([e]) => this.style.setProperty('--_content-height', `${e?.contentRect.height ?? 0}px`))}
${this.controller.virtualization.root.ref()}
>
<mo-grid id='content' autoRows='min-content' columns='var(--mo-data-grid-columns)'>
<mo-grid id='content' autoRows='min-content' columns=${this.columnTracks}>
${this.headerTemplate}
${this.contentTemplate}
</mo-grid>
Expand Down
73 changes: 73 additions & 0 deletions packages/DataGrid/DataGridColumnComponentsController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Controller, eventListener } from '@a11d/lit'
import { DataGridColumnComponent } from './columns/DataGridColumnComponent.js'
import { type DataGridColumn } from './controller/DataGridColumn.js'
import { type DataGrid } from './DataGrid.js'

/** The column elements of a `mo-data-grid`: the ones it is declared with, and the ones it makes up for data it has no columns for. */
export class DataGridColumnComponentsController<TData> extends Controller {
constructor(private readonly grid: DataGrid<TData, any>) {
super(grid)
}

private initialized = false

override hostUpdated() {
if (!this.initialized && !this.grid.columnDefinitions.extracted.length) {
this.extractColumns()
}
this.initialized = true
}

@eventListener('DataGridColumnComponent:update')
protected handleColumnChange(e: CustomEvent) {
e.stopPropagation()
this.extractColumns()
}

extractColumns() {
this.grid.columnDefinitions.extracted = this.columnsElements.map(c => c.column)
}

private get columnsElements() {
const slot = this.grid.renderRoot?.querySelector<HTMLSlotElement>('slot[name=column]')
if (!slot) {
return []
}
const children = [...slot.children]
const assigned = [...slot.assignedElements()]
return [...assigned, ...children]
.filter((c): c is DataGridColumnComponent<TData, any> => c instanceof DataGridColumnComponent)
.map(c => {
c.dataGrid = this.grid
return c
})
}

get autoGeneratedColumns(): Array<DataGridColumn<TData>> {
if (!this.grid.data.length) {
return []
}

const getDefaultColumnElement = (value: unknown) => {
switch (typeof value) {
case 'number':
case 'bigint':
return 'mo-data-grid-column-number'
case 'boolean':
return 'mo-data-grid-column-boolean'
default:
return 'mo-data-grid-column-text'
}
}
const [sampleData] = this.grid.data
return Object.keys(sampleData || {})
.filter(key => !key.startsWith('_'))
.map(key => {
const columnElement = document.createElement(getDefaultColumnElement(KeyPath.get(sampleData, key as any)))
columnElement.heading = key.replace(/([A-Z])/g, ' $1').charAt(0).toUpperCase() + key.replace(/([A-Z])/g, ' $1').slice(1)
columnElement.dataSelector = key
columnElement.dataGrid = this.grid as unknown as DataGrid<unknown, any>
return columnElement.column
}) as unknown as Array<DataGridColumn<TData>>
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DataGridColumn } from '../index.js'
import { DataGridColumn } from './index.js'
import { DataGridColumnDefinitions } from './DataGridColumnDefinitions.js'

type Person = { id: number, name: string }
Expand Down
Loading
Loading