@inglorious/ui 9.2.0 → 9.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/ui",
3
- "version": "9.2.0",
3
+ "version": "9.4.0",
4
4
  "description": "A design system for Inglorious Web with render-function components and customizable themes",
5
5
  "author": "Matteo Antony Mistretta <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
6
  "license": "MIT",
@@ -315,7 +315,7 @@
315
315
  "@iconify-icons/fa7-regular": "^2.0.0",
316
316
  "@iconify-icons/fa7-solid": "^2.0.0",
317
317
  "@iconify-icons/material-symbols": "^2.0.6",
318
- "@inglorious/charts": "12.1.0",
318
+ "@inglorious/charts": "12.2.1",
319
319
  "@inglorious/eslint-config": "1.2.0",
320
320
  "@inglorious/store": "12.0.0",
321
321
  "@inglorious/vite-plugin-jsx": "2.0.5",
@@ -93,6 +93,7 @@ export const Input = {
93
93
  type, // eslint-disable-line no-unused-vars
94
94
  name = "",
95
95
  label,
96
+ ariaLabel,
96
97
  hint, // eslint-disable-line no-unused-vars
97
98
  messageId,
98
99
  inputType = "text",
@@ -129,7 +130,7 @@ export const Input = {
129
130
  id=${controlId || nothing}
130
131
  name=${name}
131
132
  type=${inputType}
132
- aria-label=${fallbackLabel(controlId, label)}
133
+ aria-label=${ariaLabel || fallbackLabel(controlId, label)}
133
134
  aria-describedby=${messageId || nothing}
134
135
  aria-invalid=${error ? "true" : "false"}
135
136
  .value=${value}
@@ -35,9 +35,16 @@
35
35
  gap: var(--iw-space-2);
36
36
  }
37
37
 
38
- .iw-data-grid-filter-range-input {
39
- min-width: 0;
38
+ /* The two ends of a range share the column; native date and number
39
+ fields would otherwise keep a width of their own and spill out. */
40
+ .iw-data-grid-filter-range > .iw-input {
40
41
  flex: 1 1 0;
42
+ min-width: 0;
43
+
44
+ .iw-input-frame,
45
+ .iw-input-control {
46
+ min-width: 0;
47
+ }
41
48
  }
42
49
 
43
50
  .iw-data-grid-row {
@@ -48,6 +55,10 @@
48
55
  display: flex;
49
56
  }
50
57
 
58
+ .iw-data-grid-edits-actions {
59
+ display: flex;
60
+ }
61
+
51
62
  .iw-data-grid-column-resizer {
52
63
  position: absolute;
53
64
  inset: 0 0 0 auto;
@@ -74,6 +74,45 @@ export const Default = {
74
74
  },
75
75
  }
76
76
 
77
+ /** Editable columns: change cells, then reset or save the edits in the footer. */
78
+ export const Editable = {
79
+ args: {
80
+ ...Default.args,
81
+ id: "dataGridEditable",
82
+ search: null,
83
+ selection: [],
84
+ columns: [
85
+ { id: "name", title: "Name", isSortable: true, isEditable: true },
86
+ { id: "age", title: "Age", type: "number", isEditable: true },
87
+ {
88
+ id: "active",
89
+ title: "Active",
90
+ type: "boolean",
91
+ width: 100,
92
+ isEditable: true,
93
+ },
94
+ {
95
+ id: "role",
96
+ title: "Role",
97
+ width: 140,
98
+ isEditable: true,
99
+ options: [
100
+ { value: "Admin", label: "Admin" },
101
+ { value: "User", label: "User" },
102
+ ],
103
+ },
104
+ ],
105
+ },
106
+ parameters: {
107
+ docs: {
108
+ description: {
109
+ story:
110
+ "Columns with `isEditable` put an editor in each cell, by type, or a drop-down with `options`. Edits stay apart from the rows, counted in the footer, until saved.",
111
+ },
112
+ },
113
+ },
114
+ }
115
+
77
116
  export const Paginated = {
78
117
  args: {
79
118
  ...Default.args,
@@ -19,7 +19,6 @@ export const RangeFilter = {
19
19
  placeholder: column.filter.placeholder ?? "≥",
20
20
  size: "sm",
21
21
  isFullWidth: true,
22
- className: "iw-data-grid-filter-range-input",
23
22
  onChange: (value) => {
24
23
  const formattedValue = value ? format(value, column.type) : null
25
24
  entity.onFilterChange?.({
@@ -34,7 +33,6 @@ export const RangeFilter = {
34
33
  placeholder: column.filter.placeholder ?? "≤",
35
34
  size: "sm",
36
35
  isFullWidth: true,
37
- className: "iw-data-grid-filter-range-input",
38
36
  onChange: (value) => {
39
37
  const formattedValue = value ? format(value, column.type) : null
40
38
  entity.onFilterChange?.({
@@ -3,7 +3,12 @@
3
3
  * @typedef {import('../../../types/data-display/data-grid').Column} Column
4
4
  */
5
5
 
6
- import { getRowKeyValue, getRows, getTotalRows } from "./helpers.js"
6
+ import {
7
+ getEditedRows,
8
+ getRowKeyValue,
9
+ getRows,
10
+ getTotalRows,
11
+ } from "./helpers.js"
7
12
 
8
13
  const MIN_COLUMN_WIDTH = 72
9
14
  const VIRTUAL_START = 0
@@ -285,6 +290,43 @@ export function selectionClear(entity) {
285
290
  entity.selection.length = 0
286
291
  }
287
292
 
293
+ /**
294
+ * Records the user's edit of a cell, kept apart from the rows until saved. An
295
+ * edit back to the row's own value is no edit at all.
296
+ * @param {DataGridEntity} entity
297
+ * @param {{ rowId: string|number, columnId: string, value: any }} payload
298
+ */
299
+ export function cellChange(entity, { rowId, columnId, value }) {
300
+ const row = entity.rows.find((item) => getRowKeyValue(entity, item) === rowId)
301
+ if (!row) return
302
+
303
+ entity.edits ??= {}
304
+ const edits = (entity.edits[rowId] ??= {})
305
+ if (isSameValue(row[columnId], value)) {
306
+ delete edits[columnId]
307
+ } else {
308
+ edits[columnId] = value
309
+ }
310
+ if (!Object.keys(edits).length) delete entity.edits[rowId]
311
+ }
312
+
313
+ /**
314
+ * Drops every unsaved edit.
315
+ * @param {DataGridEntity} entity
316
+ */
317
+ export function editsReset(entity) {
318
+ entity.edits = {}
319
+ }
320
+
321
+ /**
322
+ * Writes the unsaved edits into the rows.
323
+ * @param {DataGridEntity} entity
324
+ */
325
+ export function editsSave(entity) {
326
+ entity.rows = getEditedRows(entity)
327
+ entity.edits = {}
328
+ }
329
+
288
330
  /**
289
331
  * Handles the scroll event to update the virtualized visible range.
290
332
  * @param {DataGridEntity} entity
@@ -394,6 +436,7 @@ function initDataGrid(entity) {
394
436
  }
395
437
  entity.selection ??= []
396
438
  entity.selectionAnchor ??= null
439
+ entity.edits ??= {}
397
440
  entity.isMultiSelect ??= true
398
441
  entity.isVirtualized ??= false
399
442
 
@@ -457,7 +500,7 @@ function getDefaultColumnFilter(type) {
457
500
  */
458
501
  function getDefaultColumnWidth(filterType) {
459
502
  if (filterType === "number") return 70
460
- if (filterType === "range") return 120
503
+ if (filterType === "range") return 160
461
504
  if (filterType === "select") return 70
462
505
  if (filterType === "date") return 120
463
506
  if (filterType === "time") return 120
@@ -521,3 +564,14 @@ function assertRowKeys(entity) {
521
564
  seen.add(rowKey)
522
565
  })
523
566
  }
567
+
568
+ /**
569
+ * Whether two cell values are the same, dates by their time.
570
+ * @param {any} a
571
+ * @param {any} b
572
+ * @returns {boolean}
573
+ */
574
+ function isSameValue(a, b) {
575
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime()
576
+ return Object.is(a, b)
577
+ }
@@ -8,6 +8,57 @@ export function getRowKeyValue(entity, row) {
8
8
  return row?.[entity.rowId]
9
9
  }
10
10
 
11
+ /**
12
+ * A cell's value as shown: the user's unsaved edit if there is one, else the
13
+ * row's own.
14
+ * @param {TableEntity} entity
15
+ * @param {any} row
16
+ * @param {string} columnId
17
+ * @returns {any}
18
+ */
19
+ export function getCellValue(entity, row, columnId) {
20
+ const edits = entity.edits?.[getRowKeyValue(entity, row)]
21
+ return edits && Object.hasOwn(edits, columnId)
22
+ ? edits[columnId]
23
+ : row?.[columnId]
24
+ }
25
+
26
+ /**
27
+ * Whether a cell has an unsaved edit.
28
+ * @param {TableEntity} entity
29
+ * @param {any} row
30
+ * @param {string} columnId
31
+ * @returns {boolean}
32
+ */
33
+ export function isCellEdited(entity, row, columnId) {
34
+ const edits = entity.edits?.[getRowKeyValue(entity, row)]
35
+ return Boolean(edits && Object.hasOwn(edits, columnId))
36
+ }
37
+
38
+ /**
39
+ * How many cells have unsaved edits.
40
+ * @param {TableEntity} entity
41
+ * @returns {number}
42
+ */
43
+ export function getEditCount(entity) {
44
+ return Object.values(entity.edits ?? {}).reduce(
45
+ (count, edits) => count + Object.keys(edits).length,
46
+ 0,
47
+ )
48
+ }
49
+
50
+ /**
51
+ * Every row with its unsaved edits applied, in the rows' own order.
52
+ * @param {TableEntity} entity
53
+ * @returns {any[]}
54
+ */
55
+ export function getEditedRows(entity) {
56
+ return entity.rows.map((row) => {
57
+ const edits = entity.edits?.[getRowKeyValue(entity, row)]
58
+ return edits ? { ...row, ...edits } : row
59
+ })
60
+ }
61
+
11
62
  /**
12
63
  * Gets the processed rows (filtered, searched, sorted, paginated).
13
64
  * @param {TableEntity} entity
@@ -69,7 +120,7 @@ export function getPaginationInfo(entity) {
69
120
  * @returns {"asc"|"desc"|null}
70
121
  */
71
122
  export function getSortDirection(entity, columnId) {
72
- const sort = entity.sorts.find((s) => s.column === columnId)
123
+ const sort = entity.sorts?.find((s) => s.column === columnId)
73
124
  return sort?.direction || null
74
125
  }
75
126
 
@@ -136,7 +187,7 @@ export function isSomeSelected(entity) {
136
187
  * @returns {any[]}
137
188
  */
138
189
  function applyFilters(entity, rows) {
139
- if (!Object.keys(entity.filters).length) {
190
+ if (!Object.keys(entity.filters ?? {}).length) {
140
191
  return rows
141
192
  }
142
193
 
@@ -201,7 +252,7 @@ function applySearch(entity, rows) {
201
252
  * @returns {any[]}
202
253
  */
203
254
  function applySorts(entity, rows) {
204
- if (!entity.sorts.length) {
255
+ if (!entity.sorts?.length) {
205
256
  return rows
206
257
  }
207
258
 
@@ -19,6 +19,9 @@ export const DataGrid = {
19
19
  onPagePrev: () => api.notify(`#${id}:pagePrev`),
20
20
  onPageSizeChange: (pageSize) =>
21
21
  api.notify(`#${id}:pageSizeChange`, pageSize),
22
+ onCellChange: (payload) => api.notify(`#${id}:cellChange`, payload),
23
+ onEditsReset: () => api.notify(`#${id}:editsReset`),
24
+ onEditsSave: () => api.notify(`#${id}:editsSave`),
22
25
  onVirtualMount: (containerEl) =>
23
26
  api.notify(`#${id}:virtualMount`, containerEl),
24
27
  onVirtualScroll: (containerEl) =>
@@ -30,6 +33,10 @@ export const DataGrid = {
30
33
  }
31
34
 
32
35
  export const {
36
+ getCellValue,
37
+ getEditCount,
38
+ getEditedRows,
39
+ isCellEdited,
33
40
  getPaginationInfo,
34
41
  getRows,
35
42
  isSomeSelected,
@@ -1,6 +1,7 @@
1
1
  @import "../../controls/input/style.css";
2
2
  @import "../../controls/select/style.css";
3
3
  @import "../../controls/button/style.css";
4
+ @import "../../controls/checkbox/style.css";
4
5
  @import "../../navigation/pagination/style.css";
5
6
 
6
7
  @import "./base";
@@ -10,16 +10,21 @@ import { classMap } from "@inglorious/web/directives/class-map"
10
10
  import { repeat } from "@inglorious/web/directives/repeat"
11
11
  import { when } from "@inglorious/web/directives/when"
12
12
 
13
+ import { Button } from "../../controls/button/index.js"
14
+ import { Checkbox } from "../../controls/checkbox/index.js"
13
15
  import { Input } from "../../controls/input/index.js"
14
16
  import { Select } from "../../controls/select/index.js"
15
17
  import { Pagination } from "../../navigation/pagination/index.js"
16
18
  import { VirtualList } from "../virtual-list/index.js"
17
19
  import { Filters } from "./filters/index.js"
18
20
  import {
21
+ getCellValue,
22
+ getEditCount,
19
23
  getPaginationInfo,
20
24
  getRowKeyValue,
21
25
  getRows,
22
26
  getSortDirection,
27
+ isCellEdited,
23
28
  } from "./helpers.js"
24
29
 
25
30
  const DIVISOR = 2
@@ -27,6 +32,14 @@ const DIVISOR = 2
27
32
  const PRETTY_PAGE = 1
28
33
  const FIRST_PAGE = 0
29
34
  const LAST_PAGE = 1
35
+ const ONE_EDIT = 1
36
+ const WEB_URL = /^https?:\/\//i
37
+ /** Fields edit dates as `YYYY-MM-DD`. */
38
+ const DATE_LENGTH = 10
39
+ const DATE_PART_LENGTH = 2
40
+ /** Date's months count from zero, people's from one. */
41
+ const MONTH_OFFSET = 1
42
+ const INPUT_TYPES = { number: "number", date: "date", url: "url" }
30
43
 
31
44
  export const DataGrid = {
32
45
  /**
@@ -67,7 +80,7 @@ export const DataGrid = {
67
80
  */
68
81
  renderHeader(props) {
69
82
  return html`<div class="iw-data-grid-header">
70
- ${props.search && this.renderToolbar?.(props)}
83
+ ${when(props.search, () => this.renderToolbar?.(props))}
71
84
  <div class="iw-data-grid-header-row">
72
85
  ${repeat(
73
86
  props.columns,
@@ -108,7 +121,10 @@ export const DataGrid = {
108
121
  @click=${() => column.isSortable && props.onSortChange?.(column.id)}
109
122
  class="iw-data-grid-header-title"
110
123
  >
111
- ${column.title} ${getSortIcon(getSortDirection(props, column.id))}
124
+ ${column.title}
125
+ ${when(column.isSortable, () =>
126
+ getSortIcon(getSortDirection(props, column.id)),
127
+ )}
112
128
  </div>
113
129
 
114
130
  ${when(column.isFilterable, () => Filters.render(props, column))}
@@ -225,7 +241,11 @@ export const DataGrid = {
225
241
  props.columns,
226
242
  (column) => column.id,
227
243
  (column, index) =>
228
- this.renderCell?.(props, { cell: row[column.id], index }),
244
+ this.renderCell?.(props, {
245
+ cell: getCellValue(props, row, column.id),
246
+ index,
247
+ row,
248
+ }),
229
249
  )}
230
250
  </div>`
231
251
  },
@@ -235,25 +255,86 @@ export const DataGrid = {
235
255
  * Override to customize per-column cell structure.
236
256
  * @param {DataGridProps} props The data grid props.
237
257
  * @param {object} payload The payload.
238
- * @param {any} payload.cell The cell data.
258
+ * @param {any} payload.cell The cell data, with any unsaved edit.
239
259
  * @param {number} payload.index The column index.
260
+ * @param {Row} payload.row The row the cell is in.
240
261
  * @returns {TemplateResult} The rendered cell.
241
262
  */
242
- renderCell(props, { cell, index }) {
263
+ renderCell(props, { cell, index, row }) {
243
264
  const column = props.columns[index]
265
+ const isEditable = Boolean(column.isEditable && row)
244
266
 
267
+ // Typing in a cell's editor is not picking its row.
245
268
  return html`<div
246
269
  class="iw-data-grid-cell ${classMap({
247
270
  "iw-data-grid-cell-number": column.type === "number",
248
271
  "iw-data-grid-cell-date": column.type === "date",
249
272
  "iw-data-grid-cell-boolean": column.type === "boolean",
273
+ "iw-data-grid-cell-editable": isEditable,
274
+ "iw-data-grid-cell-edited":
275
+ isEditable && isCellEdited(props, row, column.id),
250
276
  })}"
251
277
  style=${getColumnStyle(column)}
278
+ @click=${isEditable ? (event) => event.stopPropagation() : undefined}
252
279
  >
253
- ${this.renderValue?.(props, { value: cell, column, index })}
280
+ ${when(
281
+ isEditable,
282
+ () => this.renderEditor?.(props, { value: cell, column, row, index }),
283
+ () => this.renderValue?.(props, { value: cell, column, index }),
284
+ )}
254
285
  </div>`
255
286
  },
256
287
 
288
+ /**
289
+ * Renders the field that edits a cell of an `isEditable` column: a
290
+ * drop-down when the column has `options`, a checkbox for booleans, a date
291
+ * or number field for those types, and a text field otherwise.
292
+ * Override to edit some columns differently.
293
+ * @param {DataGridProps} props The data grid props.
294
+ * @param {object} payload The payload.
295
+ * @param {any} payload.value The cell's value, with any unsaved edit.
296
+ * @param {Column} payload.column The column definition.
297
+ * @param {Row} payload.row The row the cell is in.
298
+ * @returns {TemplateResult} The rendered editor.
299
+ */
300
+ renderEditor(props, { value, column, row }) {
301
+ const change = (next) =>
302
+ props.onCellChange?.({
303
+ rowId: getRowKeyValue(props, row),
304
+ columnId: column.id,
305
+ value: next,
306
+ })
307
+
308
+ if (column.options) {
309
+ return Select.render({
310
+ size: "sm",
311
+ ariaLabel: column.title,
312
+ value: value ?? "",
313
+ options: column.options,
314
+ isFullWidth: true,
315
+ onChange: change,
316
+ })
317
+ }
318
+
319
+ if (column.type === "boolean") {
320
+ return Checkbox.render({
321
+ "aria-label": column.title,
322
+ isChecked: Boolean(value),
323
+ onChange: change,
324
+ })
325
+ }
326
+
327
+ const saved = row[column.id]
328
+ return Input.render({
329
+ size: "sm",
330
+ ariaLabel: column.title,
331
+ inputType: INPUT_TYPES[column.type] ?? "text",
332
+ value: column.type === "date" ? toDateField(value) : (value ?? ""),
333
+ isFullWidth: true,
334
+ onChange: (text) => change(fromField(text, column.type, saved)),
335
+ })
336
+ },
337
+
257
338
  /**
258
339
  * Renders the raw cell value for a column.
259
340
  * Override to format values or inject custom content.
@@ -262,8 +343,16 @@ export const DataGrid = {
262
343
  * @param {any} payload.value The value to render.
263
344
  * @returns {any} The value to be rendered.
264
345
  */
265
- renderValue(_props, { value }) {
266
- return value
346
+ renderValue(_props, { value, column }) {
347
+ // A link only for a web address: a `javascript:` one must not run.
348
+ return when(
349
+ column.type === "url" && WEB_URL.test(String(value ?? "")),
350
+ () =>
351
+ html`<a href=${value} target="_blank" rel="noopener noreferrer"
352
+ >${value}</a
353
+ >`,
354
+ () => value,
355
+ )
267
356
  },
268
357
 
269
358
  /**
@@ -274,22 +363,57 @@ export const DataGrid = {
274
363
  */
275
364
  renderFooter(props) {
276
365
  const pagination = getPaginationInfo(props)
366
+ const editCount = getEditCount(props)
277
367
 
278
- if (!pagination) {
368
+ if (!pagination && !editCount) {
279
369
  return null
280
370
  }
281
371
 
282
372
  return html`<div class="iw-data-grid-footer">
283
- <div class="iw-data-grid-footer-row">
284
- <div>
285
- ${pagination.start + PRETTY_PAGE} to ${pagination.end} of
286
- ${pagination.totalRows} entries
287
- </div>
288
-
289
- ${this.renderPagination?.(props, pagination)}
290
- ${when(pagination.pageSizes, () =>
291
- this.renderPageSize?.(props, pagination),
292
- )}
373
+ ${when(editCount, () => this.renderEdits?.(props, editCount))}
374
+ ${when(
375
+ pagination,
376
+ () =>
377
+ html`<div class="iw-data-grid-footer-row">
378
+ <div>
379
+ ${pagination.start + PRETTY_PAGE} to ${pagination.end} of
380
+ ${pagination.totalRows} entries
381
+ </div>
382
+
383
+ ${this.renderPagination?.(props, pagination)}
384
+ ${when(pagination.pageSizes, () =>
385
+ this.renderPageSize?.(props, pagination),
386
+ )}
387
+ </div>`,
388
+ )}
389
+ </div>`
390
+ },
391
+
392
+ /**
393
+ * Renders the bar that counts the unsaved edits, with buttons to reset or
394
+ * save them. Shown only while there are edits.
395
+ * @param {DataGridProps} props The data grid props.
396
+ * @param {number} editCount How many cells have unsaved edits.
397
+ * @returns {TemplateResult} The rendered bar.
398
+ */
399
+ renderEdits(props, editCount) {
400
+ return html`<div class="iw-data-grid-footer-row iw-data-grid-edits">
401
+ <div role="status">
402
+ ${editCount} ${editCount === ONE_EDIT ? "change" : "changes"}
403
+ </div>
404
+ <div class="iw-data-grid-edits-actions">
405
+ ${Button.render({
406
+ children: "Reset",
407
+ variant: "ghost",
408
+ size: "sm",
409
+ onClick: () => props.onEditsReset?.(),
410
+ })}
411
+ ${Button.render({
412
+ children: props.saveLabel ?? "Save changes",
413
+ color: "primary",
414
+ size: "sm",
415
+ onClick: () => props.onEditsSave?.(),
416
+ })}
293
417
  </div>
294
418
  </div>`
295
419
  },
@@ -471,3 +595,38 @@ function getVirtualizationState(props, totalItems) {
471
595
  visibleRange: { start, end },
472
596
  }
473
597
  }
598
+
599
+ /**
600
+ * A date, in whatever form a row keeps it, as a date field shows it.
601
+ * @param {Date|number|string|null|undefined} value
602
+ * @returns {string}
603
+ */
604
+ function toDateField(value) {
605
+ if (value == null || value === "") return ""
606
+ if (typeof value === "string") return value.slice(0, DATE_LENGTH)
607
+ const date = new Date(value)
608
+ if (Number.isNaN(date.getTime())) return ""
609
+ const pad = (part) => String(part).padStart(DATE_PART_LENGTH, "0")
610
+ return `${date.getFullYear()}-${pad(date.getMonth() + MONTH_OFFSET)}-${pad(date.getDate())}`
611
+ }
612
+
613
+ /**
614
+ * What a field holds, back in the form the row kept the value in: a number,
615
+ * or a date as a `Date`, a timestamp or a string, as it was. A date that had
616
+ * a time of day keeps it.
617
+ * @param {string} text
618
+ * @param {string} type The column's type.
619
+ * @param {any} saved The row's own value.
620
+ * @returns {any}
621
+ */
622
+ function fromField(text, type, saved) {
623
+ if (type === "number") return text === "" ? null : Number(text)
624
+ if (type !== "date") return text
625
+ if (text === "") return null
626
+ if (!(saved instanceof Date) && typeof saved !== "number") return text
627
+
628
+ const [year, month, day] = text.split("-").map(Number)
629
+ const date = new Date(saved)
630
+ date.setFullYear(year, month - MONTH_OFFSET, day)
631
+ return saved instanceof Date ? date : date.getTime()
632
+ }
@@ -145,4 +145,21 @@
145
145
  );
146
146
  }
147
147
  }
148
+
149
+ /* An editor fills its cell, which keeps less padding around it. */
150
+ .iw-data-grid-cell-editable {
151
+ padding: var(--iw-space-2);
152
+ }
153
+
154
+ .iw-data-grid-cell-edited {
155
+ background: color-mix(in srgb, var(--iw-color-warning) 12%, transparent);
156
+ }
157
+
158
+ .iw-data-grid-edits {
159
+ gap: var(--iw-space-3);
160
+
161
+ & + .iw-data-grid-footer-row {
162
+ margin-block-start: var(--iw-space-3);
163
+ }
164
+ }
148
165
  }
@@ -41,20 +41,20 @@ export const PrimitiveSection = {
41
41
  variant: "h4",
42
42
  children: showEmpty ? "Choose a Primitive" : name,
43
43
  }),
44
- category
45
- ? Typography.render({
46
- variant: "body2",
47
- color: "secondary",
48
- children: `Category: ${category}`,
49
- })
50
- : "",
51
- summary
52
- ? Typography.render({
53
- variant: "body1",
54
- color: "secondary",
55
- children: summary,
56
- })
57
- : "",
44
+ when(category, () =>
45
+ Typography.render({
46
+ variant: "body2",
47
+ color: "secondary",
48
+ children: `Category: ${category}`,
49
+ }),
50
+ ),
51
+ when(summary, () =>
52
+ Typography.render({
53
+ variant: "body1",
54
+ color: "secondary",
55
+ children: summary,
56
+ }),
57
+ ),
58
58
  ],
59
59
  }),
60
60
  Card.render({
@@ -99,61 +99,61 @@ export const PrimitiveSection = {
99
99
  `,
100
100
  }),
101
101
  }),
102
- showEmpty
103
- ? ""
104
- : Card.render({
105
- padding: "xl",
106
- className: "iw-primitive-example",
107
- children: Flex.render({
108
- direction: "column",
109
- gap: "md",
110
- children: [
111
- Typography.render({
112
- variant: "h6",
113
- children: "Example",
114
- }),
115
- Grid.render({
116
- minColumnWidth: "18rem",
117
- gap: "lg",
118
- children: [
119
- Flex.render({
120
- direction: "column",
121
- gap: "sm",
122
- children: [
123
- Typography.render({
124
- variant: "body2",
125
- color: "secondary",
126
- children: "Code",
127
- }),
128
- html`<pre class="iw-primitive-code"><code>${
129
- exampleCode ?? "// Example coming soon."
130
- }</code></pre>`,
131
- ],
132
- }),
133
- Flex.render({
134
- direction: "column",
135
- gap: "sm",
136
- children: [
137
- Typography.render({
138
- variant: "body2",
139
- color: "secondary",
140
- children: "Result",
141
- }),
142
- html`<div class="iw-primitive-preview">
143
- ${
144
- examplePreview ??
145
- html`<div class="iw-primitive-preview-empty">
146
- Preview coming soon.
147
- </div>`
148
- }
149
- </div>`,
150
- ],
151
- }),
152
- ],
153
- }),
154
- ],
155
- }),
102
+ when(!showEmpty, () =>
103
+ Card.render({
104
+ padding: "xl",
105
+ className: "iw-primitive-example",
106
+ children: Flex.render({
107
+ direction: "column",
108
+ gap: "md",
109
+ children: [
110
+ Typography.render({
111
+ variant: "h6",
112
+ children: "Example",
113
+ }),
114
+ Grid.render({
115
+ minColumnWidth: "18rem",
116
+ gap: "lg",
117
+ children: [
118
+ Flex.render({
119
+ direction: "column",
120
+ gap: "sm",
121
+ children: [
122
+ Typography.render({
123
+ variant: "body2",
124
+ color: "secondary",
125
+ children: "Code",
126
+ }),
127
+ html`<pre class="iw-primitive-code"><code>${
128
+ exampleCode ?? "// Example coming soon."
129
+ }</code></pre>`,
130
+ ],
131
+ }),
132
+ Flex.render({
133
+ direction: "column",
134
+ gap: "sm",
135
+ children: [
136
+ Typography.render({
137
+ variant: "body2",
138
+ color: "secondary",
139
+ children: "Result",
140
+ }),
141
+ html`<div class="iw-primitive-preview">
142
+ ${
143
+ examplePreview ??
144
+ html`<div class="iw-primitive-preview-empty">
145
+ Preview coming soon.
146
+ </div>`
147
+ }
148
+ </div>`,
149
+ ],
150
+ }),
151
+ ],
152
+ }),
153
+ ],
156
154
  }),
155
+ }),
156
+ ),
157
157
  ],
158
158
  }),
159
159
  })
@@ -20,6 +20,8 @@ export interface InputProps {
20
20
  value?: string
21
21
  placeholder?: string
22
22
  label?: string
23
+ /** Names the field for assistive tech when it has no visible `label`. */
24
+ ariaLabel?: string
23
25
  hint?: string
24
26
  error?: string
25
27
  size?: "sm" | "md" | "lg"
@@ -26,7 +26,11 @@ export interface DataGridVirtualization {
26
26
  export interface Column {
27
27
  id: string
28
28
  title: string
29
- type?: "string" | "number" | "boolean" | "date"
29
+ /**
30
+ * `"url"` cells show web addresses as links. A date column's editor gives
31
+ * back dates in the form the row keeps them: `Date`, timestamp or string.
32
+ */
33
+ type?: "string" | "number" | "boolean" | "date" | "url"
30
34
  isSortable?: boolean
31
35
  isFilterable?: boolean
32
36
  filter?: {
@@ -42,6 +46,13 @@ export interface Column {
42
46
  * - number / CSS length string: fixed width
43
47
  */
44
48
  width?: number | string
49
+ /**
50
+ * Lets the user edit the column's cells: a text, number or date field by
51
+ * its `type`, a checkbox for booleans, or a drop-down with `options`.
52
+ */
53
+ isEditable?: boolean
54
+ /** The values an editable cell can take, picked from a drop-down. */
55
+ options?: SelectOption[]
45
56
  formatter?: string | ((value: any) => any)
46
57
  sortFn?: (a: Row, b: Row) => number
47
58
  filterFn?: (row: Row, filterValue: any) => boolean
@@ -52,7 +63,7 @@ export interface Column {
52
63
  export interface DataGridProps<T extends Row = Row> {
53
64
  id?: string
54
65
  type: string
55
- data: T[]
66
+ rows: T[]
56
67
  columns: Column[]
57
68
  sorts: { column: string; direction: "asc" | "desc" }[]
58
69
  filters: Record<string, any>
@@ -65,6 +76,20 @@ export interface DataGridProps<T extends Row = Row> {
65
76
  pagination?: { page: number; pageSize: number; pageSizes: number[] } | null
66
77
  rowId?: string
67
78
  isMultiSelect: boolean
79
+ /**
80
+ * The user's unsaved edits, by row key and column id, shown over the rows
81
+ * until saved. Sorting, filtering and search go by the saved values.
82
+ */
83
+ edits?: Record<string | number, Record<string, any>>
84
+ /** The label of the button that saves the edits. Defaults to "Save changes". */
85
+ saveLabel?: string
86
+ onCellChange?: (payload: {
87
+ rowId: string | number
88
+ columnId: string
89
+ value: any
90
+ }) => void
91
+ onEditsReset?: () => void
92
+ onEditsSave?: () => void
68
93
  onSortChange?: (columnId: string) => void
69
94
  onFilterChange?: (payload: { columnId: string; value: any }) => void
70
95
  onSearchChange?: (value: string) => void
@@ -122,6 +147,15 @@ export type DataGridType = {
122
147
  rowsToggleAll(entity: DataGridProps): void
123
148
  rowsSelectAll(entity: DataGridProps): void
124
149
  selectionClear(entity: DataGridProps): void
150
+ /** Records an edit of a cell; an edit back to the saved value drops it. */
151
+ cellChange(
152
+ entity: DataGridProps,
153
+ payload: { rowId: string | number; columnId: string; value: any },
154
+ ): void
155
+ /** Drops every unsaved edit. */
156
+ editsReset(entity: DataGridProps): void
157
+ /** Writes the unsaved edits into the rows. */
158
+ editsSave(entity: DataGridProps): void
125
159
  virtualMount(entity: DataGridProps, containerEl: HTMLElement): void
126
160
  virtualScroll(entity: DataGridProps, containerEl: HTMLElement): void
127
161
  render(entity: DataGridProps, api: Api): TemplateResult
@@ -141,9 +175,15 @@ export type DataGridType = {
141
175
  ): TemplateResult
142
176
  renderCell(
143
177
  entity: DataGridProps,
144
- payload: { cell: any; index: number },
178
+ payload: { cell: any; index: number; row?: Row },
145
179
  api: Api,
146
180
  ): TemplateResult
181
+ renderEditor(
182
+ entity: DataGridProps,
183
+ payload: { value: any; column: Column; row: Row; index: number },
184
+ api: Api,
185
+ ): TemplateResult
186
+ renderEdits(entity: DataGridProps, editCount: number): TemplateResult
147
187
  renderValue(
148
188
  entity: DataGridProps,
149
189
  payload: { value: any; column: Column; index: number },
@@ -181,3 +221,21 @@ export declare function getSortDirection(
181
221
  ): "asc" | "desc" | null
182
222
  export declare function isAllSelected(entity: DataGridProps): boolean
183
223
  export declare function isSomeSelected(entity: DataGridProps): boolean
224
+ /** A cell's value as shown: its unsaved edit, or the row's own. */
225
+ export declare function getCellValue(
226
+ entity: DataGridProps,
227
+ row: Row,
228
+ columnId: string,
229
+ ): any
230
+ /** Whether a cell has an unsaved edit. */
231
+ export declare function isCellEdited(
232
+ entity: DataGridProps,
233
+ row: Row,
234
+ columnId: string,
235
+ ): boolean
236
+ /** How many cells have unsaved edits. */
237
+ export declare function getEditCount(entity: DataGridProps): number
238
+ /** Every row with its unsaved edits applied. */
239
+ export declare function getEditedRows<T extends Row = Row>(
240
+ entity: DataGridProps<T>,
241
+ ): T[]