@inglorious/ui 9.11.1 → 9.13.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.
Files changed (38) hide show
  1. package/package.json +5 -5
  2. package/src/controls/button/button.stories.js +12 -0
  3. package/src/controls/button/template.js +17 -0
  4. package/src/controls/button-group/template.js +5 -2
  5. package/src/controls/file-upload/file-upload.stories.js +11 -0
  6. package/src/controls/file-upload/template.js +2 -1
  7. package/src/controls/image-cropper/image-cropper.stories.js +9 -0
  8. package/src/controls/image-cropper/template.js +1 -1
  9. package/src/data-display/carousel/carousel.stories.js +14 -0
  10. package/src/data-display/carousel/template.js +20 -5
  11. package/src/data-display/data-grid/data-grid.stories.js +73 -0
  12. package/src/data-display/data-grid/template.js +129 -23
  13. package/src/data-display/data-grid/theme.css +10 -2
  14. package/src/data-display/list/list.stories.js +8 -0
  15. package/src/data-display/list/template.js +1 -1
  16. package/src/feedback/alert/alert.stories.js +11 -0
  17. package/src/feedback/alert/template.js +2 -1
  18. package/src/feedback/snackbar/snackbar.stories.js +11 -0
  19. package/src/feedback/snackbar/template.js +2 -1
  20. package/src/navigation/breadcrumbs/breadcrumbs.stories.js +12 -0
  21. package/src/navigation/breadcrumbs/template.js +2 -1
  22. package/src/navigation/menu/menu.stories.js +15 -0
  23. package/src/navigation/pagination/pagination.stories.js +9 -0
  24. package/src/navigation/pagination/template.js +7 -2
  25. package/src/navigation/speed-dial/speed-dial.stories.js +13 -0
  26. package/src/navigation/speed-dial/template.js +10 -1
  27. package/types/controls/button-group.d.ts +1 -0
  28. package/types/controls/button.d.ts +2 -0
  29. package/types/controls/file-upload.d.ts +2 -0
  30. package/types/controls/image-cropper.d.ts +2 -0
  31. package/types/data-display/carousel.d.ts +12 -0
  32. package/types/data-display/data-grid.d.ts +14 -0
  33. package/types/data-display/list.d.ts +2 -0
  34. package/types/feedback/alert.d.ts +2 -0
  35. package/types/feedback/snackbar.d.ts +2 -0
  36. package/types/navigation/breadcrumbs.d.ts +2 -0
  37. package/types/navigation/pagination.d.ts +4 -0
  38. package/types/navigation/speed-dial.d.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/ui",
3
- "version": "9.11.1",
3
+ "version": "9.13.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",
@@ -339,9 +339,9 @@
339
339
  "**/*.css"
340
340
  ],
341
341
  "peerDependencies": {
342
- "@inglorious/store": "^12.1.1",
342
+ "@inglorious/store": "^12.2.0",
343
343
  "@inglorious/utils": "^5.1.0",
344
- "@inglorious/web": "^6.14.1"
344
+ "@inglorious/web": "^6.16.0"
345
345
  },
346
346
  "devDependencies": {
347
347
  "@iconify-icons/fa7-brands": "^2.0.0",
@@ -350,10 +350,10 @@
350
350
  "@iconify-icons/material-symbols": "^2.0.6",
351
351
  "@inglorious/charts": "12.2.6",
352
352
  "@inglorious/eslint-config": "1.2.0",
353
- "@inglorious/store": "12.1.1",
353
+ "@inglorious/store": "12.2.0",
354
354
  "@inglorious/vite-plugin-jsx": "2.0.6",
355
355
  "@inglorious/vite-plugin-vue": "1.0.7",
356
- "@inglorious/web": "6.14.1",
356
+ "@inglorious/web": "6.16.0",
357
357
  "@lit-labs/ssr": "^4.1.0",
358
358
  "@storybook/addon-docs": "^10.6.5",
359
359
  "@storybook/web-components-vite": "^10.6.5",
@@ -57,6 +57,10 @@ export default {
57
57
  options: ["button", "submit", "reset", "menu"],
58
58
  description: "Native HTML button type attribute.",
59
59
  },
60
+ href: {
61
+ control: "text",
62
+ description: "Renders a link styled as a button.",
63
+ },
60
64
  accept: {
61
65
  control: "text",
62
66
  description: "File types the picker offers (with `onFileSelect`).",
@@ -178,6 +182,14 @@ FilePicker.args = {
178
182
  onFileSelect: () => {},
179
183
  }
180
184
 
185
+ export const Link = {}
186
+ Link.args = {
187
+ ...Default.args,
188
+ children: "Open events",
189
+ color: "primary",
190
+ href: "#events",
191
+ }
192
+
181
193
  export const CustomContent = {}
182
194
  CustomContent.args = {
183
195
  ...Default.args,
@@ -14,6 +14,7 @@ import { applyElementProps } from "../../shared/apply-element-props.js"
14
14
  * Renders a themed button for actions and form submission.
15
15
  * Supports variants, sizes, shapes, pressed state, and full-width layout.
16
16
  * With `onFileSelect`, the button also opens a file picker on click.
17
+ * With `href`, it is a link that looks like a button.
17
18
  * @example
18
19
  * // Stateless usage
19
20
  * button.render({ children: 'Click me', color: 'primary' })
@@ -21,6 +22,9 @@ import { applyElementProps } from "../../shared/apply-element-props.js"
21
22
  * // File picker
22
23
  * button.render({ children: 'Upload', accept: 'image/*', onFileSelect })
23
24
  *
25
+ * // Link
26
+ * button.render({ children: 'Open', href: '/events' })
27
+ *
24
28
  * @param {ButtonProps} props
25
29
  * @returns {TemplateResult}
26
30
  */
@@ -36,6 +40,7 @@ export function render(props) {
36
40
  isPressed = false,
37
41
  isFullWidth = false,
38
42
  buttonType = "button",
43
+ href,
39
44
  ariaLabel,
40
45
  isAriaPressed,
41
46
  className = "",
@@ -72,6 +77,18 @@ export function render(props) {
72
77
  }
73
78
  : onClick
74
79
 
80
+ if (href != null) {
81
+ return html`<a
82
+ href=${isDisabled ? nothing : href}
83
+ aria-label=${ariaLabel || nothing}
84
+ aria-disabled=${isDisabled ? "true" : nothing}
85
+ class=${classMap(classes)}
86
+ @click=${isDisabled ? nothing : onClick}
87
+ ${ref((element) => applyElementProps(element, rest))}
88
+ >${children}</a
89
+ >`
90
+ }
91
+
75
92
  return html`
76
93
  <button
77
94
  type=${buttonType}
@@ -33,9 +33,11 @@ export function render(props) {
33
33
  ...rest
34
34
  } = props
35
35
 
36
+ const isSelection = value != null || isMultiple
37
+
36
38
  const groupClasses = {
37
39
  "iw-button-group": true,
38
- "iw-button-group-selection": value != null || isMultiple,
40
+ "iw-button-group-selection": isSelection,
39
41
  [`iw-button-group-${direction}`]: true,
40
42
  "iw-button-group-attached": isAttached,
41
43
  }
@@ -86,7 +88,8 @@ export function render(props) {
86
88
  size: itemSize ?? size,
87
89
  isDisabled: isDisabled || !!itemDisabled,
88
90
  isPressed,
89
- isAriaPressed: isPressed ? true : undefined,
91
+ isAriaPressed: isSelection ? isPressed : undefined,
92
+ ariaLabel: children != null ? label : undefined,
90
93
  onClick: () => {
91
94
  if (!onChange) return
92
95
 
@@ -91,3 +91,14 @@ Error.args = {
91
91
  error: "Only PDF files under 5MB are allowed.",
92
92
  selectedFiles: ["invoice.png"],
93
93
  }
94
+
95
+ /** Every text the field shows is a prop: `title`, `description` and `emptyLabel`. */
96
+ export const Localized = {}
97
+ Localized.args = {
98
+ ...Default.args,
99
+ label: "Curriculum",
100
+ hint: "Carica il curriculum in PDF o DOC.",
101
+ title: "Scegli un file",
102
+ description: "o trascinalo qui",
103
+ emptyLabel: "Nessun file selezionato",
104
+ }
@@ -32,6 +32,7 @@ export function render(props) {
32
32
  selectedFiles = [],
33
33
  title = "Choose files",
34
34
  description = "or drag and drop here",
35
+ emptyLabel = "No file selected",
35
36
  onChange,
36
37
  onBlur,
37
38
  onFocus,
@@ -54,7 +55,7 @@ export function render(props) {
54
55
  }
55
56
 
56
57
  const summaryText =
57
- selectedFiles.length > 0 ? selectedFiles.join(", ") : "No file selected"
58
+ selectedFiles.length > 0 ? selectedFiles.join(", ") : emptyLabel
58
59
 
59
60
  return html`
60
61
  <div class=${classMap(wrapperClasses)}>
@@ -83,3 +83,12 @@ Disabled.args = {
83
83
  rect: { x: 0.1, y: 0.1, w: 0.6, h: 0.6 },
84
84
  isDisabled: true,
85
85
  }
86
+
87
+ /** Screen readers announce the crop area by `label` and `roleDescription`. */
88
+ export const Localized = {}
89
+ Localized.args = {
90
+ ...Default.args,
91
+ id: "image-cropper-localized",
92
+ label: "Ritaglia la foto",
93
+ roleDescription: "area di ritaglio",
94
+ }
@@ -72,7 +72,7 @@ export const ImageCropper = {
72
72
  class=${classMap(classes)}
73
73
  style=${style}
74
74
  role="group"
75
- aria-roledescription="crop area"
75
+ aria-roledescription=${props.roleDescription ?? "crop area"}
76
76
  aria-label=${label || "Crop area"}
77
77
  tabindex=${isDisabled ? -1 : 0}
78
78
  aria-disabled=${isDisabled}
@@ -163,3 +163,17 @@ function slide(index) {
163
163
  ${index + 1}
164
164
  </div>`
165
165
  }
166
+
167
+ /** The carousel, its slides, arrows and dots take their accessible names from props. */
168
+ export const Localized = {}
169
+ Localized.args = {
170
+ ...Default.args,
171
+ id: "carousel-localized",
172
+ label: "Carosello di esempio",
173
+ roleDescription: "carosello",
174
+ slideRoleDescription: "diapositiva",
175
+ formatSlideLabel: (index, total) => `${index} di ${total}`,
176
+ formatIndicatorLabel: (index) => `Vai alla diapositiva ${index}`,
177
+ previousLabel: "Diapositiva precedente",
178
+ nextLabel: "Diapositiva successiva",
179
+ }
@@ -92,7 +92,7 @@ export const Carousel = {
92
92
  return html`<div
93
93
  class="iw-carousel-viewport"
94
94
  role="group"
95
- aria-roledescription="carousel"
95
+ aria-roledescription=${props.roleDescription ?? "carousel"}
96
96
  aria-label=${label || "Carousel"}
97
97
  tabindex="0"
98
98
  @scroll=${handleScroll(props)}
@@ -126,8 +126,11 @@ export const Carousel = {
126
126
  return html`<div
127
127
  class="iw-carousel-item"
128
128
  role="group"
129
- aria-roledescription="slide"
130
- aria-label=${`${index + HUMAN_OFFSET} of ${total}`}
129
+ aria-roledescription=${props.slideRoleDescription ?? "slide"}
130
+ aria-label=${(props.formatSlideLabel ?? formatSlideLabel)(
131
+ index + HUMAN_OFFSET,
132
+ total,
133
+ )}
131
134
  >
132
135
  ${item}
133
136
  </div>`
@@ -147,6 +150,8 @@ export const Carousel = {
147
150
  isInfinite = false,
148
151
  arrowVariant = DEFAULT_ARROW_VARIANT,
149
152
  arrowColor,
153
+ previousLabel = "Previous slide",
154
+ nextLabel = "Next slide",
150
155
  } = props
151
156
  const isPrevious = direction === PREVIOUS
152
157
 
@@ -168,7 +173,7 @@ export const Carousel = {
168
173
  shape: "round",
169
174
  isDisabled,
170
175
  children: html`<span class="iw-carousel-chevron"></span>`,
171
- "aria-label": isPrevious ? "Previous slide" : "Next slide",
176
+ "aria-label": isPrevious ? previousLabel : nextLabel,
172
177
  })}
173
178
  </span>`
174
179
  },
@@ -217,7 +222,9 @@ export const Carousel = {
217
222
  "iw-carousel-indicator-current": isCurrent,
218
223
  })}
219
224
  type="button"
220
- aria-label=${`Go to slide ${index + HUMAN_OFFSET}`}
225
+ aria-label=${(props.formatIndicatorLabel ?? formatIndicatorLabel)(
226
+ index + HUMAN_OFFSET,
227
+ )}
221
228
  aria-current=${isCurrent ? "page" : "false"}
222
229
  @click=${(event) =>
223
230
  this.indicatorSelect?.(props, index, viewportFrom(event.currentTarget))}
@@ -638,3 +645,11 @@ function viewportFrom(element) {
638
645
  element.closest(".iw-carousel")?.querySelector(".iw-carousel-viewport")
639
646
  )
640
647
  }
648
+
649
+ function formatSlideLabel(index, total) {
650
+ return `${index} of ${total}`
651
+ }
652
+
653
+ function formatIndicatorLabel(index) {
654
+ return `Go to slide ${index}`
655
+ }
@@ -195,3 +195,76 @@ function createRows(count) {
195
195
  role: index % 4 === 0 ? "Admin" : "User",
196
196
  }))
197
197
  }
198
+
199
+ /** Rows are selected from the keyboard: Tab to the grid, then use the arrows, Home and End to move, Enter to select a row, Space to toggle it, and Shift to extend the selection. */
200
+ export const KeyboardSelection = {
201
+ args: {
202
+ ...Default.args,
203
+ id: "dataGridKeyboard",
204
+ ariaLabel: "Users",
205
+ search: null,
206
+ selection: [],
207
+ isMultiSelect: true,
208
+ },
209
+ parameters: {
210
+ docs: {
211
+ description: {
212
+ story:
213
+ "An ARIA grid named by `ariaLabel`. One row is in the tab order: the selection anchor, or else the first row. Keys pressed in a cell's own field stay with the field.",
214
+ },
215
+ },
216
+ },
217
+ }
218
+
219
+ /** With no rows to show, the footer counts no entries. */
220
+ export const Empty = {
221
+ args: {
222
+ ...Default.args,
223
+ id: "dataGridEmpty",
224
+ rows: [],
225
+ selection: [],
226
+ pagination: { page: 0, pageSize: 10, pageSizes: [10, 20, 50] },
227
+ },
228
+ }
229
+
230
+ const TITLES_IT = {
231
+ id: "Id",
232
+ name: "Nome",
233
+ age: "Età",
234
+ active: "Attivo",
235
+ role: "Ruolo",
236
+ }
237
+
238
+ /** Every text the grid shows or announces is a prop, with English as the default. */
239
+ export const Localized = {
240
+ args: {
241
+ ...Default.args,
242
+ id: "dataGridLocalized",
243
+ ariaLabel: "Utenti",
244
+ rows: createRows(24),
245
+ selection: [],
246
+ columns: baseColumns.map((column) => ({
247
+ ...column,
248
+ title: TITLES_IT[column.id],
249
+ isEditable: column.id === "name",
250
+ filter: {
251
+ ...column.filter,
252
+ ariaLabel: `Filtra ${TITLES_IT[column.id]}`,
253
+ minLabel: `Filtra ${TITLES_IT[column.id]}: minimo`,
254
+ maxLabel: `Filtra ${TITLES_IT[column.id]}: massimo`,
255
+ },
256
+ })),
257
+ search: { value: "", ariaLabel: "Cerca", placeholder: "Cerca…" },
258
+ edits: { 1: { name: "Carlo" } },
259
+ saveLabel: "Salva le modifiche",
260
+ resetLabel: "Annulla",
261
+ formatEditCount: (count) =>
262
+ count === 1 ? "1 modifica" : `${count} modifiche`,
263
+ formatRange: ({ start, end, total }) =>
264
+ total ? `${start}–${end} di ${total} righe` : "Nessuna riga",
265
+ pageLabel: "Pagina",
266
+ pageSizeLabel: "Righe per pagina",
267
+ formatPageLabel: (page) => `Vai a pagina ${page}`,
268
+ pagination: { page: 0, pageSize: 10, pageSizes: [10, 20, 50] },
269
+ },
270
+ }
@@ -5,7 +5,7 @@
5
5
  * @typedef {import('@inglorious/web').TemplateResult} TemplateResult
6
6
  */
7
7
 
8
- import { html } from "@inglorious/web"
8
+ import { html, nothing } from "@inglorious/web"
9
9
  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"
@@ -40,6 +40,10 @@ const DATE_PART_LENGTH = 2
40
40
  /** Date's months count from zero, people's from one. */
41
41
  const MONTH_OFFSET = 1
42
42
  const INPUT_TYPES = { number: "number", date: "date", url: "url" }
43
+ const FIRST_ROW = 0
44
+ const LAST_ROW = -1
45
+ const ROW_STEPS = { ArrowDown: 1, ArrowUp: -1 }
46
+ const ARIA_SORT = { asc: "ascending", desc: "descending" }
43
47
 
44
48
  export const DataGrid = {
45
49
  /**
@@ -67,21 +71,28 @@ export const DataGrid = {
67
71
  }
68
72
 
69
73
  return html`<div class=${classMap(classes)}>
70
- ${this.renderHeader?.(props)} ${this.renderBody?.(props)}
74
+ ${when(props.search, () => this.renderToolbar?.(props))}
75
+ <div
76
+ class="iw-data-grid-table"
77
+ role="grid"
78
+ aria-label=${props.ariaLabel ?? nothing}
79
+ aria-multiselectable=${props.isMultiSelect ? "true" : nothing}
80
+ >
81
+ ${this.renderHeader?.(props)} ${this.renderBody?.(props)}
82
+ </div>
71
83
  ${this.renderFooter?.(props)}
72
84
  </div> `
73
85
  },
74
86
 
75
87
  /**
76
- * Renders the grid header region, including optional toolbar and column headers.
88
+ * Renders the grid header region with the column headers.
77
89
  * Column rendering is delegated to `renderHeaderColumn`.
78
90
  * @param {DataGridProps} props The data grid props.
79
91
  * @returns {TemplateResult} The rendered header.
80
92
  */
81
93
  renderHeader(props) {
82
94
  return html`<div class="iw-data-grid-header">
83
- ${when(props.search, () => this.renderToolbar?.(props))}
84
- <div class="iw-data-grid-header-row">
95
+ <div class="iw-data-grid-header-row" role="row">
85
96
  ${repeat(
86
97
  props.columns,
87
98
  (column) => column.id,
@@ -113,8 +124,12 @@ export const DataGrid = {
113
124
  * @returns {TemplateResult} The rendered header column.
114
125
  */
115
126
  renderHeaderColumn(props, { column }) {
127
+ const direction = getSortDirection(props, column.id)
128
+
116
129
  return html`<div
117
130
  class="iw-data-grid-header-column"
131
+ role="columnheader"
132
+ aria-sort=${column.isSortable ? (ARIA_SORT[direction] ?? "none") : nothing}
118
133
  style=${getColumnStyle(column)}
119
134
  >
120
135
  ${when(
@@ -126,9 +141,7 @@ export const DataGrid = {
126
141
  @click=${() => props.onSortChange?.(column.id)}
127
142
  >
128
143
  ${column.title}
129
- <span aria-hidden="true"
130
- >${getSortIcon(getSortDirection(props, column.id))}</span
131
- >
144
+ <span aria-hidden="true">${getSortIcon(direction)}</span>
132
145
  </button>`,
133
146
  () =>
134
147
  html`<div class="iw-data-grid-header-title">${column.title}</div>`,
@@ -176,14 +189,20 @@ export const DataGrid = {
176
189
  */
177
190
  renderBody(props) {
178
191
  const rows = getRows(props)
192
+ const rowIds = rows.map((row) => getRowKeyValue(props, row))
193
+ const tabStopId = rowIds.includes(props.selectionAnchor)
194
+ ? props.selectionAnchor
195
+ : rowIds[FIRST_ROW]
196
+ const renderRow = (row, index) =>
197
+ this.renderRow?.(props, {
198
+ row,
199
+ index,
200
+ isTabStop: getRowKeyValue(props, row) === tabStopId,
201
+ })
179
202
 
180
203
  if (!props.isVirtualized) {
181
204
  return html`<div class="iw-data-grid-body">
182
- ${repeat(
183
- rows,
184
- (row) => getRowKeyValue(props, row),
185
- (row, index) => this.renderRow?.(props, { row, index }),
186
- )}
205
+ ${repeat(rows, (row) => getRowKeyValue(props, row), renderRow)}
187
206
  </div>`
188
207
  }
189
208
 
@@ -211,8 +230,7 @@ export const DataGrid = {
211
230
  }
212
231
  },
213
232
  getType: () => ({
214
- renderItem: (_entity, { item, index }) =>
215
- this.renderRow?.(props, { row: item, index }),
233
+ renderItem: (_entity, { item, index }) => renderRow(item, index),
216
234
  }),
217
235
  }
218
236
 
@@ -221,17 +239,25 @@ export const DataGrid = {
221
239
 
222
240
  /**
223
241
  * Renders a single data row and wires row click/selection handlers.
224
- * Cells are delegated to `renderCell` for customization.
242
+ * The row is focusable: arrows, Home and End move between rows, Space
243
+ * toggles the row as Ctrl+click does, Enter selects it alone, and Shift
244
+ * with either extends the selection. Only the tab stop row is in the tab
245
+ * order. Cells are delegated to `renderCell` for customization.
225
246
  * @param {DataGridProps} props The data grid props.
226
247
  * @param {object} payload The payload.
227
248
  * @param {Row} payload.row The row data.
228
249
  * @param {number} payload.index The row index.
250
+ * @param {boolean} [payload.isTabStop] Whether Tab reaches this row.
229
251
  * @returns {TemplateResult} The rendered row.
230
252
  */
231
- renderRow(props, { row, index }) {
253
+ renderRow(props, { row, index, isTabStop = false }) {
232
254
  const rowId = getRowKeyValue(props, row)
255
+ const isSelected = Boolean(props.selection?.includes(rowId))
233
256
 
234
257
  return html`<div
258
+ role="row"
259
+ aria-selected=${String(isSelected)}
260
+ tabindex=${isTabStop ? "0" : "-1"}
235
261
  @click=${(event) =>
236
262
  props.onRowClick?.({
237
263
  rowId,
@@ -239,9 +265,10 @@ export const DataGrid = {
239
265
  metaKey: event.metaKey,
240
266
  ctrlKey: event.ctrlKey,
241
267
  })}
268
+ @keydown=${(event) => handleRowKeydown(event, props, rowId)}
242
269
  class="iw-data-grid-row ${classMap({
243
270
  "iw-data-grid-row-even": index % DIVISOR,
244
- "iw-data-grid-row-selected": props.selection?.includes(rowId),
271
+ "iw-data-grid-row-selected": isSelected,
245
272
  })}"
246
273
  >
247
274
  ${repeat(
@@ -272,6 +299,7 @@ export const DataGrid = {
272
299
  const isEditable = Boolean(column.isEditable && row)
273
300
 
274
301
  return html`<div
302
+ role="gridcell"
275
303
  class="iw-data-grid-cell ${classMap({
276
304
  "iw-data-grid-cell-number": column.type === "number",
277
305
  "iw-data-grid-cell-date": column.type === "date",
@@ -382,8 +410,11 @@ export const DataGrid = {
382
410
  () =>
383
411
  html`<div class="iw-data-grid-footer-row">
384
412
  <div>
385
- ${pagination.start + PRETTY_PAGE} to ${pagination.end} of
386
- ${pagination.totalRows} entries
413
+ ${(props.formatRange ?? formatRange)({
414
+ start: pagination.start + PRETTY_PAGE,
415
+ end: pagination.end,
416
+ total: pagination.totalRows,
417
+ })}
387
418
  </div>
388
419
 
389
420
  ${this.renderPagination?.(props, pagination)}
@@ -405,11 +436,11 @@ export const DataGrid = {
405
436
  renderEdits(props, editCount) {
406
437
  return html`<div class="iw-data-grid-footer-row iw-data-grid-edits">
407
438
  <div role="status">
408
- ${editCount} ${editCount === ONE_EDIT ? "change" : "changes"}
439
+ ${(props.formatEditCount ?? formatEditCount)(editCount)}
409
440
  </div>
410
441
  <div class="iw-data-grid-edits-actions">
411
442
  ${Button.render({
412
- children: "Reset",
443
+ children: props.resetLabel ?? "Reset",
413
444
  variant: "ghost",
414
445
  size: "sm",
415
446
  onClick: () => props.onEditsReset?.(),
@@ -438,6 +469,7 @@ export const DataGrid = {
438
469
  buttonSize: "sm",
439
470
  buttonVariant: "outline",
440
471
  itemClassName: "iw-data-grid-pagination-button",
472
+ formatPageLabel: props.formatPageLabel,
441
473
  },
442
474
  {
443
475
  label: "«",
@@ -451,6 +483,7 @@ export const DataGrid = {
451
483
  buttonSize: "sm",
452
484
  buttonVariant: "outline",
453
485
  itemClassName: "iw-data-grid-pagination-button",
486
+ formatPageLabel: props.formatPageLabel,
454
487
  },
455
488
  {
456
489
  label: "‹",
@@ -461,6 +494,7 @@ export const DataGrid = {
461
494
  )}
462
495
  ${Input.render({
463
496
  name: "page",
497
+ ariaLabel: props.pageLabel ?? "Page",
464
498
  size: "sm",
465
499
  inputType: "number",
466
500
  min: 1,
@@ -475,6 +509,7 @@ export const DataGrid = {
475
509
  buttonSize: "sm",
476
510
  buttonVariant: "outline",
477
511
  itemClassName: "iw-data-grid-pagination-button",
512
+ formatPageLabel: props.formatPageLabel,
478
513
  },
479
514
  {
480
515
  label: "›",
@@ -488,6 +523,7 @@ export const DataGrid = {
488
523
  buttonSize: "sm",
489
524
  buttonVariant: "outline",
490
525
  itemClassName: "iw-data-grid-pagination-button",
526
+ formatPageLabel: props.formatPageLabel,
491
527
  },
492
528
  {
493
529
  label: "»",
@@ -502,8 +538,9 @@ export const DataGrid = {
502
538
 
503
539
  renderPageSize(props, pagination) {
504
540
  return html`<div class="iw-data-grid-footer-row">
505
- <div>Page size:</div>
541
+ <div>${props.pageSizeLabel ?? "Page size"}</div>
506
542
  ${Select.render({
543
+ ariaLabel: props.pageSizeLabel ?? "Page size",
507
544
  size: "sm",
508
545
  value: pagination.pageSize,
509
546
  options: pagination.pageSizes,
@@ -636,3 +673,72 @@ function fromField(text, type, saved) {
636
673
  date.setFullYear(year, month - MONTH_OFFSET, day)
637
674
  return saved instanceof Date ? date : date.getTime()
638
675
  }
676
+
677
+ /**
678
+ * Selects or moves between rows from the keyboard. Keys pressed in a cell's
679
+ * field stay the field's.
680
+ * @param {KeyboardEvent} event
681
+ * @param {DataGridProps} props
682
+ * @param {string|number} rowId
683
+ */
684
+ function handleRowKeydown(event, props, rowId) {
685
+ if (event.target !== event.currentTarget) return
686
+
687
+ if (event.key === " " || event.key === "Enter") {
688
+ event.preventDefault()
689
+ props.onRowClick?.({
690
+ rowId,
691
+ shiftKey: event.shiftKey,
692
+ metaKey: event.metaKey,
693
+ ctrlKey: event.ctrlKey || event.key === " ",
694
+ })
695
+ return
696
+ }
697
+
698
+ const rows = [
699
+ ...event.currentTarget
700
+ .closest(".iw-data-grid-body")
701
+ .querySelectorAll(".iw-data-grid-row"),
702
+ ]
703
+ const target = getRowToFocus(
704
+ rows,
705
+ rows.indexOf(event.currentTarget),
706
+ event.key,
707
+ )
708
+ if (!target) return
709
+
710
+ event.preventDefault()
711
+ target.focus()
712
+ }
713
+
714
+ /**
715
+ * The row a navigation key moves focus to, if any.
716
+ * @param {HTMLElement[]} rows
717
+ * @param {number} current
718
+ * @param {string} key
719
+ * @returns {HTMLElement|undefined}
720
+ */
721
+ function getRowToFocus(rows, current, key) {
722
+ if (key === "Home") return rows[FIRST_ROW]
723
+ if (key === "End") return rows.at(LAST_ROW)
724
+ if (key in ROW_STEPS) return rows[current + ROW_STEPS[key]]
725
+ }
726
+
727
+ /**
728
+ * The footer's default count of the rows shown.
729
+ * @param {{ start: number, end: number, total: number }} range
730
+ * @returns {string}
731
+ */
732
+ function formatRange({ start, end, total }) {
733
+ if (!total) return "0 entries"
734
+ return `${start} to ${end} of ${total} entries`
735
+ }
736
+
737
+ /**
738
+ * The edits bar's default count of the unsaved edits.
739
+ * @param {number} count
740
+ * @returns {string}
741
+ */
742
+ function formatEditCount(count) {
743
+ return `${count} ${count === ONE_EDIT ? "change" : "changes"}`
744
+ }
@@ -10,8 +10,11 @@
10
10
  }
11
11
 
12
12
  .iw-data-grid-toolbar {
13
- padding: 0 var(--iw-space-4);
14
- padding-bottom: var(--iw-space-3);
13
+ padding: var(--iw-space-4) var(--iw-space-4) var(--iw-space-3);
14
+ }
15
+
16
+ .iw-data-grid-toolbar + .iw-data-grid-table .iw-data-grid-header {
17
+ padding-top: var(--iw-space-3);
15
18
  }
16
19
 
17
20
  .iw-data-grid-header-row {
@@ -71,6 +74,11 @@
71
74
  .iw-data-grid-row {
72
75
  align-items: center;
73
76
  column-gap: var(--iw-space-4);
77
+
78
+ &:focus-visible {
79
+ outline: 2px solid var(--iw-color-primary);
80
+ outline-offset: -2px;
81
+ }
74
82
  }
75
83
 
76
84
  &.iw-data-grid-striped {
@@ -185,3 +185,11 @@ export const Expandable = {
185
185
  ],
186
186
  },
187
187
  }
188
+
189
+ /** The expand toggle's accessible name comes from `toggleLabel`. */
190
+ export const Localized = {
191
+ args: {
192
+ ...Nested.args,
193
+ toggleLabel: "Espandi o comprimi",
194
+ },
195
+ }
@@ -225,7 +225,7 @@ export const List = {
225
225
  shape: "square",
226
226
  className: "iw-list-item-toggle",
227
227
  isDisabled,
228
- ariaLabel: "Toggle section",
228
+ ariaLabel: props.toggleLabel ?? "Toggle section",
229
229
  "aria-expanded": isExpanded,
230
230
  onClick: (event) => {
231
231
  event.stopPropagation()
@@ -76,3 +76,14 @@ export const Actionable = {
76
76
  onClose: () => {},
77
77
  },
78
78
  }
79
+
80
+ /** The dismiss button's accessible name comes from `dismissLabel`. */
81
+ export const Localized = {
82
+ args: {
83
+ title: "Salvato",
84
+ description: "Le modifiche sono state salvate.",
85
+ severity: "success",
86
+ dismissLabel: "Chiudi",
87
+ onClose: () => {},
88
+ },
89
+ }
@@ -28,6 +28,7 @@ export function render(props) {
28
28
  icon,
29
29
  action,
30
30
  onClose,
31
+ dismissLabel = "Dismiss",
31
32
  className = "",
32
33
  ...rest
33
34
  } = props
@@ -73,7 +74,7 @@ export function render(props) {
73
74
  html`<button
74
75
  type="button"
75
76
  class="iw-alert-close"
76
- aria-label="Dismiss"
77
+ aria-label=${dismissLabel}
77
78
  @click=${onClose}
78
79
  >
79
80
  ×
@@ -46,3 +46,14 @@ export const AboveBottomNavigation = {
46
46
  },
47
47
  ],
48
48
  }
49
+
50
+ /** The dismiss button's accessible name comes from `dismissLabel`. */
51
+ export const Localized = {
52
+ args: {
53
+ isOpen: true,
54
+ message: "Salvato",
55
+ position: "bottom-left",
56
+ dismissLabel: "Chiudi",
57
+ onClose: () => {},
58
+ },
59
+ }
@@ -26,6 +26,7 @@ export function render(props) {
26
26
  className = "",
27
27
  position = "bottom-left",
28
28
  onClose,
29
+ dismissLabel = "Dismiss",
29
30
  } = props
30
31
 
31
32
  if (!isOpen) return null
@@ -57,7 +58,7 @@ export function render(props) {
57
58
  html`<button
58
59
  type="button"
59
60
  class="iw-snackbar-close"
60
- aria-label="Dismiss"
61
+ aria-label=${dismissLabel}
61
62
  @click=${onClose}
62
63
  >
63
64
  ×
@@ -31,3 +31,15 @@ export const Default = {
31
31
  ],
32
32
  },
33
33
  }
34
+
35
+ /** The navigation's accessible name follows the app's language through `ariaLabel`. */
36
+ export const Localized = {
37
+ args: {
38
+ ariaLabel: "Percorso",
39
+ items: [
40
+ { label: "Home", href: "#" },
41
+ { label: "Libreria", href: "#" },
42
+ { label: "Dati" },
43
+ ],
44
+ },
45
+ }
@@ -33,6 +33,7 @@ export const Breadcrumbs = {
33
33
  type, // eslint-disable-line no-unused-vars
34
34
  items = [],
35
35
  separator = "/",
36
+ ariaLabel = "Breadcrumb",
36
37
  className = "",
37
38
  ...rest
38
39
  } = props
@@ -45,7 +46,7 @@ export const Breadcrumbs = {
45
46
  )
46
47
 
47
48
  return html`<nav
48
- aria-label="Breadcrumb"
49
+ aria-label=${ariaLabel}
49
50
  class=${classMap({ "iw-breadcrumbs": true, ...extraClasses })}
50
51
  ${ref((el) => applyElementProps(el, rest))}
51
52
  >
@@ -1,3 +1,5 @@
1
+ import { Form } from "@inglorious/web/form"
2
+
1
3
  import { createRender } from "../../stories/notify-story.js"
2
4
  import { Menu } from "."
3
5
 
@@ -47,3 +49,16 @@ export const WithTrigger = {
47
49
  items: Default.args.items,
48
50
  },
49
51
  }
52
+
53
+ /** A menu with a trigger inside a form opens as anywhere else: the Form type cancels only buttons that would submit or reset the form. */
54
+ export const InsideAForm = {
55
+ args: WithTrigger.args,
56
+ decorators: [
57
+ (story) => {
58
+ Form.init()
59
+ const form = document.createElement("form")
60
+ form.append(story())
61
+ return form
62
+ },
63
+ ],
64
+ }
@@ -59,3 +59,12 @@ export const Default = {
59
59
  buttonSize: "sm",
60
60
  },
61
61
  }
62
+
63
+ /** `ariaLabel` names the navigation and `formatPageLabel` each control, in the app's language. */
64
+ export const Localized = {
65
+ args: {
66
+ ...Default.args,
67
+ ariaLabel: "Pagine",
68
+ formatPageLabel: (page) => `Vai a pagina ${page}`,
69
+ },
70
+ }
@@ -38,6 +38,7 @@ export const Pagination = {
38
38
  isFirstButtonVisible = false,
39
39
  isLastButtonVisible = false,
40
40
  isDisabled = false,
41
+ ariaLabel = "Pagination",
41
42
  className = "",
42
43
  } = props
43
44
 
@@ -51,7 +52,7 @@ export const Pagination = {
51
52
 
52
53
  return html`<nav
53
54
  class=${classMap({ "iw-pagination": true, ...extraClasses })}
54
- aria-label="Pagination"
55
+ aria-label=${ariaLabel}
55
56
  >
56
57
  ${when(isFirstButtonVisible, () =>
57
58
  this.renderControl(props, {
@@ -130,7 +131,7 @@ export const Pagination = {
130
131
  className: ["iw-pagination-item", props.itemClassName ?? ""]
131
132
  .filter(Boolean)
132
133
  .join(" "),
133
- ariaLabel: `Go to page ${target}`,
134
+ ariaLabel: (props.formatPageLabel ?? formatPageLabel)(target),
134
135
  isDisabled,
135
136
  onClick: () => onChange?.(target),
136
137
  children: label,
@@ -138,6 +139,10 @@ export const Pagination = {
138
139
  },
139
140
  }
140
141
 
142
+ function formatPageLabel(page) {
143
+ return `Go to page ${page}`
144
+ }
145
+
141
146
  function getPageItems(page, count, siblingCount) {
142
147
  if (count <= 7) {
143
148
  return Array.from({ length: count }, (_, index) => index + PRETTY_PAGE)
@@ -38,3 +38,16 @@ export const Default = {
38
38
  ],
39
39
  },
40
40
  }
41
+
42
+ /** Actions with only an icon are named by `formatActionLabel`, counted from 1. */
43
+ export const UnlabelledActions = {
44
+ args: {
45
+ isOpen: true,
46
+ ariaLabel: "Azioni rapide",
47
+ formatActionLabel: (index) => `Azione ${index}`,
48
+ actions: [
49
+ { value: "edit", icon: "✎" },
50
+ { value: "share", icon: "↗" },
51
+ ],
52
+ },
53
+ }
@@ -10,6 +10,8 @@ import { ref } from "@inglorious/web/directives/ref"
10
10
 
11
11
  import { syncPopover } from "../../shared/top-layer.js"
12
12
 
13
+ const HUMAN_OFFSET = 1
14
+
13
15
  export const SpeedDial = {
14
16
  /**
15
17
  * Main entrypoint for the speed dial. Delegates to the base renderer for overrides.
@@ -96,7 +98,10 @@ export const SpeedDial = {
96
98
  })
97
99
  }
98
100
  })}
99
- aria-label=${action.label ?? `Speed dial action ${index + 1}`}
101
+ aria-label=${
102
+ action.label ??
103
+ (props.formatActionLabel ?? formatActionLabel)(index + HUMAN_OFFSET)
104
+ }
100
105
  ?disabled=${action.isDisabled}
101
106
  @click=${() => {
102
107
  action.onClick?.(action.value ?? index)
@@ -107,3 +112,7 @@ export const SpeedDial = {
107
112
  </button>`
108
113
  },
109
114
  }
115
+
116
+ function formatActionLabel(index) {
117
+ return `Speed dial action ${index}`
118
+ }
@@ -5,6 +5,7 @@ import type { Renderable } from "../shared/renderable"
5
5
  export interface ButtonGroupItem {
6
6
  id?: string
7
7
  value?: string
8
+ /** The button's text, or its accessible name when `children` are given */
8
9
  label: string
9
10
  children?: Renderable
10
11
  isDisabled?: boolean
@@ -21,6 +21,8 @@ export interface ButtonProps {
21
21
  isPressed?: boolean
22
22
  isFullWidth?: boolean
23
23
  buttonType?: "button" | "submit" | "reset" | "menu"
24
+ /** Renders a link styled as a button instead of a `<button>` */
25
+ href?: string
24
26
  ariaLabel?: string
25
27
  isAriaPressed?: boolean
26
28
  className?: string
@@ -14,6 +14,8 @@ export interface FileUploadProps {
14
14
  selectedFiles?: string[]
15
15
  title?: string
16
16
  description?: string
17
+ /** Shown while no file is selected. Defaults to "No file selected". */
18
+ emptyLabel?: string
17
19
  onChange?: (files: FileList | null) => void
18
20
  onBlur?: () => void
19
21
  onFocus?: () => void
@@ -12,6 +12,8 @@ export interface ImageCropperProps {
12
12
  id?: string
13
13
  type?: string
14
14
  label?: string
15
+ /** What screen readers call the crop area. Defaults to "crop area". */
16
+ roleDescription?: string
15
17
 
16
18
  src?: string
17
19
  alt?: string
@@ -13,6 +13,18 @@ export interface CarouselProps {
13
13
  id?: string
14
14
  type?: string
15
15
  label?: string
16
+ /** What screen readers call the carousel. Defaults to "carousel". */
17
+ roleDescription?: string
18
+ /** What screen readers call a slide. Defaults to "slide". */
19
+ slideRoleDescription?: string
20
+ /** A slide's accessible name. Defaults to "{index} of {total}", counted from 1. */
21
+ formatSlideLabel?: (index: number, total: number) => string
22
+ /** A dot's accessible name. Defaults to "Go to slide {index}", counted from 1. */
23
+ formatIndicatorLabel?: (index: number) => string
24
+ /** The previous arrow's accessible name. Defaults to "Previous slide". */
25
+ previousLabel?: string
26
+ /** The next arrow's accessible name. Defaults to "Next slide". */
27
+ nextLabel?: string
16
28
 
17
29
  items?: unknown[]
18
30
  /** Index of the item the viewport has settled on. */
@@ -79,6 +79,8 @@ export interface DataGridProps<T extends Row = Row> {
79
79
  /** The search field's accessible name. Defaults to "Search". */
80
80
  ariaLabel?: string
81
81
  } | null
82
+ /** The grid's accessible name. */
83
+ ariaLabel?: string
82
84
  isStriped?: boolean
83
85
  isVirtualized?: boolean
84
86
  virtualization?: DataGridVirtualization
@@ -101,6 +103,18 @@ export interface DataGridProps<T extends Row = Row> {
101
103
  edits?: Record<string | number, Record<string, any>>
102
104
  /** The label of the button that saves the edits. Defaults to "Save changes". */
103
105
  saveLabel?: string
106
+ /** The label of the button that resets the edits. Defaults to "Reset". */
107
+ resetLabel?: string
108
+ /** The edits bar's count. Defaults to "{count} change(s)". */
109
+ formatEditCount?: (count: number) => string
110
+ /** The footer's count of the rows shown. Defaults to "{start} to {end} of {total} entries", or "0 entries". */
111
+ formatRange?: (range: { start: number; end: number; total: number }) => string
112
+ /** The page field's accessible name. Defaults to "Page". */
113
+ pageLabel?: string
114
+ /** The page size selector's label. Defaults to "Page size". */
115
+ pageSizeLabel?: string
116
+ /** A page control's accessible name. Defaults to "Go to page {page}". */
117
+ formatPageLabel?: (page: number) => string
104
118
  onCellChange?: (payload: {
105
119
  rowId: string | number
106
120
  columnId: string
@@ -32,6 +32,8 @@ export interface ListProps {
32
32
  padding?: "none" | "sm" | "md" | "lg" | "xl"
33
33
  path?: number[]
34
34
  className?: string
35
+ /** The expand toggle's accessible name. Defaults to "Toggle section". */
36
+ toggleLabel?: string
35
37
  onItemClick?: (item: ListItem, path: number[]) => void
36
38
  onItemToggle?: (item: ListItem, path: number[]) => void
37
39
  [key: string]: unknown
@@ -13,6 +13,8 @@ export interface AlertProps {
13
13
  icon?: TemplateResult | string | number
14
14
  action?: TemplateResult | string | number
15
15
  onClose?: () => void
16
+ /** The dismiss button's accessible name. Defaults to "Dismiss". */
17
+ dismissLabel?: string
16
18
  className?: string
17
19
  [key: string]: unknown
18
20
  }
@@ -15,6 +15,8 @@ export interface SnackbarProps {
15
15
  | "top-right"
16
16
  className?: string
17
17
  onClose?: () => void
18
+ /** The dismiss button's accessible name. Defaults to "Dismiss". */
19
+ dismissLabel?: string
18
20
  [key: string]: unknown
19
21
  }
20
22
 
@@ -13,6 +13,8 @@ export interface BreadcrumbsProps {
13
13
  type?: string
14
14
  items: BreadcrumbItem[]
15
15
  separator?: TemplateResult | string | number
16
+ /** The navigation's accessible name. Defaults to "Breadcrumb". */
17
+ ariaLabel?: string
16
18
  className?: string
17
19
  [key: string]: unknown
18
20
  }
@@ -15,6 +15,10 @@ export interface PaginationProps {
15
15
  buttonSize?: "sm" | "md" | "lg"
16
16
  itemClassName?: string
17
17
  className?: string
18
+ /** The navigation's accessible name. Defaults to "Pagination". */
19
+ ariaLabel?: string
20
+ /** A control's accessible name. Defaults to "Go to page {page}". */
21
+ formatPageLabel?: (page: number) => string
18
22
  onChange?: (page: number) => void
19
23
  [key: string]: unknown
20
24
  }
@@ -18,6 +18,8 @@ export interface SpeedDialProps {
18
18
  ariaLabel?: string
19
19
  direction?: "up" | "down" | "left" | "right"
20
20
  actions?: SpeedDialAction[]
21
+ /** The name of an action with no label. Defaults to "Speed dial action {index}", counted from 1. */
22
+ formatActionLabel?: (index: number) => string
21
23
  /**
22
24
  * Called with the new state when the browser opens or closes the actions.
23
25
  * Without it, the browser alone keeps the open state and `isOpen` is not