@unofficialbox/box-open-elements 0.5.0 → 0.7.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 (227) hide show
  1. package/README.md +27 -73
  2. package/dist/components/collections/table.d.ts +113 -3
  3. package/dist/components/collections/table.js +540 -37
  4. package/dist/components/feedback/due-badge.d.ts +38 -0
  5. package/dist/components/feedback/due-badge.js +168 -0
  6. package/dist/components/feedback/due-types.d.ts +32 -0
  7. package/dist/components/feedback/due-types.js +91 -0
  8. package/dist/components/feedback/progress-steps.d.ts +51 -5
  9. package/dist/components/feedback/progress-steps.js +200 -22
  10. package/dist/components/feedback/stage-path.d.ts +44 -0
  11. package/dist/components/feedback/stage-path.js +235 -0
  12. package/dist/components/forms/select.d.ts +21 -0
  13. package/dist/components/forms/select.js +100 -3
  14. package/dist/components/forms/text-field.d.ts +21 -0
  15. package/dist/components/forms/text-field.js +100 -1
  16. package/dist/components/layout/nav-sidebar.d.ts +14 -0
  17. package/dist/components/layout/nav-sidebar.js +49 -0
  18. package/dist/components/layout/split-view.d.ts +14 -0
  19. package/dist/components/layout/split-view.js +80 -1
  20. package/dist/components/overlays/command-palette.d.ts +62 -0
  21. package/dist/components/overlays/command-palette.js +537 -0
  22. package/dist/components/overlays/command-types.d.ts +90 -0
  23. package/dist/components/overlays/command-types.js +259 -0
  24. package/dist/components/overlays/drawer.d.ts +18 -0
  25. package/dist/components/overlays/drawer.js +135 -9
  26. package/dist/components/overlays/shortcuts-overlay.d.ts +48 -0
  27. package/dist/components/overlays/shortcuts-overlay.js +385 -0
  28. package/dist/components/overlays/tooltip.js +1 -1
  29. package/dist/core/index.d.ts +4 -0
  30. package/dist/core/index.js +2 -0
  31. package/dist/core/offset-window.d.ts +70 -0
  32. package/dist/core/offset-window.js +134 -0
  33. package/dist/core/virtualize.d.ts +71 -0
  34. package/dist/core/virtualize.js +67 -0
  35. package/dist/element-maps.d.ts +292 -0
  36. package/dist/element-maps.js +1 -0
  37. package/dist/entries/activity-density.d.ts +1 -0
  38. package/dist/entries/activity-density.js +1 -0
  39. package/dist/entries/agent-chat.d.ts +1 -0
  40. package/dist/entries/agent-chat.js +1 -0
  41. package/dist/entries/audit-log.d.ts +1 -0
  42. package/dist/entries/audit-log.js +1 -0
  43. package/dist/entries/command-palette.d.ts +1 -0
  44. package/dist/entries/command-palette.js +1 -0
  45. package/dist/entries/compare-view.d.ts +1 -0
  46. package/dist/entries/compare-view.js +1 -0
  47. package/dist/entries/diff-viewer.d.ts +1 -0
  48. package/dist/entries/diff-viewer.js +1 -0
  49. package/dist/entries/due-badge.d.ts +1 -0
  50. package/dist/entries/due-badge.js +1 -0
  51. package/dist/entries/form-wizard.d.ts +1 -0
  52. package/dist/entries/form-wizard.js +1 -0
  53. package/dist/entries/lineage-graph.d.ts +1 -0
  54. package/dist/entries/lineage-graph.js +1 -0
  55. package/dist/entries/notification-bell.d.ts +1 -0
  56. package/dist/entries/notification-bell.js +1 -0
  57. package/dist/entries/notification-inbox.d.ts +1 -0
  58. package/dist/entries/notification-inbox.js +1 -0
  59. package/dist/entries/provenance-strip.d.ts +1 -0
  60. package/dist/entries/provenance-strip.js +1 -0
  61. package/dist/entries/run-trace.d.ts +1 -0
  62. package/dist/entries/run-trace.js +1 -0
  63. package/dist/entries/shortcuts-overlay.d.ts +1 -0
  64. package/dist/entries/shortcuts-overlay.js +1 -0
  65. package/dist/entries/signature-ceremony.d.ts +1 -0
  66. package/dist/entries/signature-ceremony.js +1 -0
  67. package/dist/entries/stage-path.d.ts +1 -0
  68. package/dist/entries/stage-path.js +1 -0
  69. package/dist/entries/timeline.d.ts +1 -0
  70. package/dist/entries/timeline.js +1 -0
  71. package/dist/entries/version-graph.d.ts +1 -0
  72. package/dist/entries/version-graph.js +1 -0
  73. package/dist/entries/version-list.d.ts +1 -0
  74. package/dist/entries/version-list.js +1 -0
  75. package/dist/entries/wizard-summary.d.ts +1 -0
  76. package/dist/entries/wizard-summary.js +1 -0
  77. package/dist/entries/work-queue.d.ts +1 -0
  78. package/dist/entries/work-queue.js +1 -0
  79. package/dist/entries/workload-board.d.ts +1 -0
  80. package/dist/entries/workload-board.js +1 -0
  81. package/dist/index.d.ts +21 -0
  82. package/dist/index.js +21 -0
  83. package/dist/patterns/agent-chat/agent-chat.d.ts +75 -0
  84. package/dist/patterns/agent-chat/agent-chat.js +820 -0
  85. package/dist/patterns/agent-chat/controller.d.ts +27 -0
  86. package/dist/patterns/agent-chat/controller.js +182 -0
  87. package/dist/patterns/agent-chat/index.d.ts +3 -0
  88. package/dist/patterns/agent-chat/index.js +6 -0
  89. package/dist/patterns/agent-chat/types.d.ts +109 -0
  90. package/dist/patterns/agent-chat/types.js +1 -0
  91. package/dist/patterns/audit/activity-density.d.ts +68 -0
  92. package/dist/patterns/audit/activity-density.js +428 -0
  93. package/dist/patterns/audit/audit-log.d.ts +182 -0
  94. package/dist/patterns/audit/audit-log.js +1082 -0
  95. package/dist/patterns/audit/index.d.ts +3 -0
  96. package/dist/patterns/audit/index.js +7 -0
  97. package/dist/patterns/audit/shared.d.ts +10 -0
  98. package/dist/patterns/audit/shared.js +50 -0
  99. package/dist/patterns/audit/types.d.ts +143 -0
  100. package/dist/patterns/audit/types.js +327 -0
  101. package/dist/patterns/audit/window.d.ts +116 -0
  102. package/dist/patterns/audit/window.js +132 -0
  103. package/dist/patterns/content-explorer/actions/controller.js +12 -2
  104. package/dist/patterns/content-explorer/box-transport.js +117 -25
  105. package/dist/patterns/content-explorer/content-explorer.d.ts +13 -1
  106. package/dist/patterns/content-explorer/content-explorer.js +36 -3
  107. package/dist/patterns/content-explorer/contracts.d.ts +24 -1
  108. package/dist/patterns/content-explorer/contracts.js +68 -11
  109. package/dist/patterns/content-explorer/controller.d.ts +16 -1
  110. package/dist/patterns/content-explorer/controller.js +130 -4
  111. package/dist/patterns/content-explorer/navigation/controller.js +11 -9
  112. package/dist/patterns/content-explorer/types.d.ts +58 -1
  113. package/dist/patterns/content-picker/content-picker.d.ts +71 -0
  114. package/dist/patterns/content-picker/content-picker.js +747 -0
  115. package/dist/patterns/content-picker/controller.d.ts +38 -0
  116. package/dist/patterns/content-picker/controller.js +141 -0
  117. package/dist/patterns/content-picker/index.d.ts +3 -0
  118. package/dist/patterns/content-picker/index.js +6 -0
  119. package/dist/patterns/content-picker/types.d.ts +52 -0
  120. package/dist/patterns/content-picker/types.js +26 -0
  121. package/dist/patterns/content-sidebar/content-sidebar.d.ts +43 -0
  122. package/dist/patterns/content-sidebar/content-sidebar.js +293 -0
  123. package/dist/patterns/content-sidebar/index.d.ts +2 -0
  124. package/dist/patterns/content-sidebar/index.js +4 -0
  125. package/dist/patterns/content-sidebar/types.d.ts +18 -0
  126. package/dist/patterns/content-sidebar/types.js +20 -0
  127. package/dist/patterns/content-uploader/box-transport.d.ts +14 -0
  128. package/dist/patterns/content-uploader/box-transport.js +54 -0
  129. package/dist/patterns/content-uploader/content-uploader.d.ts +62 -0
  130. package/dist/patterns/content-uploader/content-uploader.js +559 -0
  131. package/dist/patterns/content-uploader/controller.d.ts +39 -0
  132. package/dist/patterns/content-uploader/controller.js +227 -0
  133. package/dist/patterns/content-uploader/index.d.ts +4 -0
  134. package/dist/patterns/content-uploader/index.js +8 -0
  135. package/dist/patterns/content-uploader/types.d.ts +120 -0
  136. package/dist/patterns/content-uploader/types.js +38 -0
  137. package/dist/patterns/diff/compare-view.d.ts +47 -0
  138. package/dist/patterns/diff/compare-view.js +276 -0
  139. package/dist/patterns/diff/diff-viewer.d.ts +42 -0
  140. package/dist/patterns/diff/diff-viewer.js +434 -0
  141. package/dist/patterns/diff/engine.d.ts +22 -0
  142. package/dist/patterns/diff/engine.js +276 -0
  143. package/dist/patterns/diff/index.d.ts +4 -0
  144. package/dist/patterns/diff/index.js +8 -0
  145. package/dist/patterns/diff/types.d.ts +69 -0
  146. package/dist/patterns/diff/types.js +24 -0
  147. package/dist/patterns/form-wizard/controller.d.ts +39 -0
  148. package/dist/patterns/form-wizard/controller.js +157 -0
  149. package/dist/patterns/form-wizard/form-wizard.d.ts +63 -0
  150. package/dist/patterns/form-wizard/form-wizard.js +468 -0
  151. package/dist/patterns/form-wizard/index.d.ts +4 -0
  152. package/dist/patterns/form-wizard/index.js +8 -0
  153. package/dist/patterns/form-wizard/types.d.ts +109 -0
  154. package/dist/patterns/form-wizard/types.js +83 -0
  155. package/dist/patterns/form-wizard/wizard-summary.d.ts +62 -0
  156. package/dist/patterns/form-wizard/wizard-summary.js +324 -0
  157. package/dist/patterns/internal/safe-href.d.ts +17 -0
  158. package/dist/patterns/internal/safe-href.js +17 -0
  159. package/dist/patterns/lineage/index.d.ts +3 -0
  160. package/dist/patterns/lineage/index.js +6 -0
  161. package/dist/patterns/lineage/lineage-graph.d.ts +30 -0
  162. package/dist/patterns/lineage/lineage-graph.js +434 -0
  163. package/dist/patterns/lineage/provenance-strip.d.ts +22 -0
  164. package/dist/patterns/lineage/provenance-strip.js +194 -0
  165. package/dist/patterns/lineage/types.d.ts +53 -0
  166. package/dist/patterns/lineage/types.js +49 -0
  167. package/dist/patterns/notifications/index.d.ts +3 -0
  168. package/dist/patterns/notifications/index.js +6 -0
  169. package/dist/patterns/notifications/notification-bell.d.ts +38 -0
  170. package/dist/patterns/notifications/notification-bell.js +198 -0
  171. package/dist/patterns/notifications/notification-inbox.d.ts +46 -0
  172. package/dist/patterns/notifications/notification-inbox.js +537 -0
  173. package/dist/patterns/notifications/types.d.ts +61 -0
  174. package/dist/patterns/notifications/types.js +108 -0
  175. package/dist/patterns/preview/annotation-inspector.d.ts +25 -6
  176. package/dist/patterns/preview/annotation-inspector.js +239 -94
  177. package/dist/patterns/preview/annotation-thread.d.ts +25 -5
  178. package/dist/patterns/preview/annotation-thread.js +233 -82
  179. package/dist/patterns/preview/annotation-toolbar.d.ts +20 -6
  180. package/dist/patterns/preview/annotation-toolbar.js +162 -96
  181. package/dist/patterns/preview/content-preview-adapter.d.ts +10 -1
  182. package/dist/patterns/preview/content-preview-adapter.js +26 -1
  183. package/dist/patterns/preview/preview-element.d.ts +15 -2
  184. package/dist/patterns/preview/preview-element.js +223 -52
  185. package/dist/patterns/preview/provider-adapter.d.ts +47 -0
  186. package/dist/patterns/preview/provider-adapter.js +17 -0
  187. package/dist/patterns/run/index.d.ts +2 -0
  188. package/dist/patterns/run/index.js +4 -0
  189. package/dist/patterns/run/run-trace.d.ts +40 -0
  190. package/dist/patterns/run/run-trace.js +441 -0
  191. package/dist/patterns/run/types.d.ts +66 -0
  192. package/dist/patterns/run/types.js +152 -0
  193. package/dist/patterns/signature/index.d.ts +2 -0
  194. package/dist/patterns/signature/index.js +4 -0
  195. package/dist/patterns/signature/signature-ceremony.d.ts +33 -0
  196. package/dist/patterns/signature/signature-ceremony.js +317 -0
  197. package/dist/patterns/signature/types.d.ts +68 -0
  198. package/dist/patterns/signature/types.js +89 -0
  199. package/dist/patterns/timeline/index.d.ts +2 -0
  200. package/dist/patterns/timeline/index.js +4 -0
  201. package/dist/patterns/timeline/timeline.d.ts +27 -0
  202. package/dist/patterns/timeline/timeline.js +430 -0
  203. package/dist/patterns/timeline/types.d.ts +36 -0
  204. package/dist/patterns/timeline/types.js +27 -0
  205. package/dist/patterns/versions/graph-layout.d.ts +43 -0
  206. package/dist/patterns/versions/graph-layout.js +159 -0
  207. package/dist/patterns/versions/index.d.ts +4 -0
  208. package/dist/patterns/versions/index.js +8 -0
  209. package/dist/patterns/versions/shared.d.ts +4 -0
  210. package/dist/patterns/versions/shared.js +13 -0
  211. package/dist/patterns/versions/types.d.ts +37 -0
  212. package/dist/patterns/versions/types.js +39 -0
  213. package/dist/patterns/versions/version-graph.d.ts +36 -0
  214. package/dist/patterns/versions/version-graph.js +438 -0
  215. package/dist/patterns/versions/version-list.d.ts +39 -0
  216. package/dist/patterns/versions/version-list.js +407 -0
  217. package/dist/patterns/work-queue/controller.d.ts +26 -0
  218. package/dist/patterns/work-queue/controller.js +150 -0
  219. package/dist/patterns/work-queue/index.d.ts +4 -0
  220. package/dist/patterns/work-queue/index.js +7 -0
  221. package/dist/patterns/work-queue/types.d.ts +128 -0
  222. package/dist/patterns/work-queue/types.js +45 -0
  223. package/dist/patterns/work-queue/work-queue.d.ts +48 -0
  224. package/dist/patterns/work-queue/work-queue.js +534 -0
  225. package/dist/patterns/work-queue/workload-board.d.ts +53 -0
  226. package/dist/patterns/work-queue/workload-board.js +608 -0
  227. package/package.json +89 -5
@@ -1,6 +1,15 @@
1
- import { BaseElement } from "../../core/index.js";
1
+ import { BaseElement, resolveRowWindow, sameRowWindow } from "../../core/index.js";
2
2
  import { boePanel, boeRadius, boeSpace } from "../../foundations/geometry/index.js";
3
+ import { isSafeHref } from "../../patterns/internal/safe-href.js";
3
4
  const DEFAULT_TAG_NAME = "box-table";
5
+ const CELL_TONES = new Set(["neutral", "brand", "success", "warning", "error"]);
6
+ const CELL_ALIGNS = new Set(["start", "end", "center"]);
7
+ /** `align` is author input from a JSON attribute like everything else — an
8
+ * unvalidated value would close the attribute and inject markup. */
9
+ const alignAttr = (align) => align && CELL_ALIGNS.has(align) ? ` data-align="${align}"` : "";
10
+ const isCellDescriptor = (value) => typeof value === "object" && value !== null && typeof value.text === "string";
11
+ /** Escape a value for use inside a double-quoted attribute selector. */
12
+ const cssAttrValue = (value) => value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
4
13
  const escapeHtml = (value) => value
5
14
  .replaceAll("&", "&")
6
15
  .replaceAll("<", "&lt;")
@@ -47,8 +56,35 @@ const tableStyles = `
47
56
  th[part="sortable"] {
48
57
  cursor: pointer;
49
58
  user-select: none;
59
+ /* The button carries the padding so its hit area fills the cell. */
60
+ padding: 0;
50
61
  }
51
62
  th[part="sortable"]:hover { color: var(--boe-token-text-text, #222222); }
63
+
64
+ /* A real button, so sorting is reachable by keyboard: focus, Enter and
65
+ Space come from the platform instead of a keydown reimplementation. */
66
+ [part="sort-button"] {
67
+ appearance: none;
68
+ display: block;
69
+ width: 100%;
70
+ padding: ${boeSpace[2]} ${boeSpace[3]};
71
+ border: 0;
72
+ background: transparent;
73
+ font: inherit;
74
+ letter-spacing: inherit;
75
+ text-transform: inherit;
76
+ text-align: inherit;
77
+ color: inherit;
78
+ cursor: pointer;
79
+ }
80
+
81
+ th[data-align="end"] [part="sort-button"] { text-align: end; }
82
+ th[data-align="center"] [part="sort-button"] { text-align: center; }
83
+
84
+ [part="sort-button"]:focus-visible {
85
+ outline: none;
86
+ box-shadow: inset 0 0 0 2px var(--boe-token-surface-surface-brand, #0061d5);
87
+ }
52
88
  th[aria-sort] .boe-sort-arrow::after { content: " ↕"; opacity: 0.5; }
53
89
  th[aria-sort="ascending"] .boe-sort-arrow::after { content: " ↑"; opacity: 1; }
54
90
  th[aria-sort="descending"] .boe-sort-arrow::after { content: " ↓"; opacity: 1; }
@@ -76,7 +112,156 @@ const tableStyles = `
76
112
  tbody tr[part="row"]:focus-visible td {
77
113
  box-shadow: inset 0 0 0 2px var(--boe-token-surface-surface-brand, #0061d5);
78
114
  }
115
+
116
+ /* Windowing needs a bounded viewport to scroll within: --boe-table-height is
117
+ the host's knob, defaulting to a sensible page-sized band. Sticky headers
118
+ already work inside this scroller. */
119
+ :host([virtualize]) [part="shell"] {
120
+ max-block-size: var(--boe-table-height, 26rem);
121
+ overflow-y: auto;
122
+ }
123
+
124
+ tr[part="spacer"] td {
125
+ border: 0;
126
+ padding: 0;
127
+ }
128
+
129
+ [part="cell-badge"] {
130
+ display: inline-block;
131
+ padding: 0.08rem 0.5rem;
132
+ border-radius: 999px;
133
+ font-size: 0.74rem;
134
+ font-weight: 700;
135
+ color: color-mix(in srgb, var(--cell-tone, var(--boe-token-text-text-secondary, #6f6f6f)) 76%, black 24%);
136
+ background: color-mix(in srgb, var(--cell-tone, var(--boe-token-text-text-secondary, #6f6f6f)) 14%, transparent);
137
+ border: 1px solid color-mix(in srgb, var(--cell-tone, var(--boe-token-text-text-secondary, #6f6f6f)) 30%, transparent);
138
+ }
139
+
140
+ [part="cell-link"] {
141
+ color: var(--boe-token-surface-surface-brand, #0061d5);
142
+ text-decoration: none;
143
+ }
144
+
145
+ [part="cell-link"]:hover {
146
+ text-decoration: underline;
147
+ }
148
+
149
+ [part="expander"] {
150
+ appearance: none;
151
+ display: inline-grid;
152
+ place-items: center;
153
+ inline-size: 1.4rem;
154
+ block-size: 1.4rem;
155
+ border: 0;
156
+ border-radius: 4px;
157
+ background: transparent;
158
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
159
+ font: inherit;
160
+ font-size: 0.72rem;
161
+ cursor: pointer;
162
+ }
163
+
164
+ [part="expander"]:hover {
165
+ background: var(--boe-token-surface-surface-hover, #f4f4f4);
166
+ }
167
+
168
+ [part="expander"]:focus-visible {
169
+ outline: 2px solid color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 34%, transparent);
170
+ outline-offset: 1px;
171
+ }
172
+
173
+ [part="expander"]::before {
174
+ content: "▸";
175
+ transition: transform 120ms ease;
176
+ }
177
+
178
+ [part="expander"][aria-expanded="true"]::before {
179
+ transform: rotate(90deg);
180
+ }
181
+
182
+ tr[part="detail-row"] td {
183
+ background: color-mix(in srgb, var(--boe-token-surface-surface-secondary, #fbfbfb) 80%, transparent);
184
+ font-size: 0.84rem;
185
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
186
+ }
187
+
188
+ tr[part="state-row"] td {
189
+ padding: 1.1rem ${boeSpace[3]};
190
+ text-align: center;
191
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
192
+ }
193
+
194
+ tr[part="state-row"][data-state="error"] td {
195
+ color: color-mix(in srgb, var(--boe-token-surface-status-surface-error, #ed3757) 76%, black 24%);
196
+ }
197
+
198
+ [part="state-spinner"] {
199
+ display: inline-block;
200
+ vertical-align: -0.15rem;
201
+ margin-inline-end: 0.45rem;
202
+ inline-size: 0.95rem;
203
+ block-size: 0.95rem;
204
+ border-radius: 999px;
205
+ border: 2px solid color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 20%, transparent);
206
+ border-top-color: var(--boe-token-surface-surface-brand, #0061d5);
207
+ animation: boe-table-spin 0.9s linear infinite;
208
+ }
209
+
210
+ @keyframes boe-table-spin {
211
+ to { transform: rotate(360deg); }
212
+ }
213
+
214
+ @media (prefers-reduced-motion: reduce) {
215
+ [part="state-spinner"] { animation-duration: 1.6s; }
216
+ }
217
+
218
+ /* Stacked rows: each row becomes a card, each cell states its column via
219
+ data-label. Roles are explicit on rows and cells, so the grid semantics
220
+ survive the display change. stacked="always" forces it; stacked="auto"
221
+ stacks when the host container narrows. */
222
+ :host {
223
+ container-type: inline-size;
224
+ }
225
+ ` +
226
+ (() => {
227
+ const rules = (scope) => `
228
+ ${scope} thead { display: none; }
229
+ ${scope} tr[part="row"] {
230
+ display: block;
231
+ padding: ${boeSpace[2]} 0;
232
+ }
233
+ ${scope} tbody td {
234
+ display: grid;
235
+ grid-template-columns: minmax(7.5rem, 9rem) 1fr;
236
+ gap: 0.5rem;
237
+ border-bottom: none;
238
+ padding-block: 0.18rem;
239
+ text-align: start;
240
+ }
241
+ ${scope} tbody td[data-label]::before {
242
+ content: attr(data-label);
243
+ font-size: 0.74rem;
244
+ font-weight: 700;
245
+ letter-spacing: 0.05em;
246
+ text-transform: uppercase;
247
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
248
+ align-self: center;
249
+ }
250
+ ${scope} tr[part="row"]:not(:last-child) {
251
+ border-bottom: 1px solid var(--boe-token-stroke-stroke, #e8e8e8);
252
+ }
253
+ ${scope} tr[part="detail-row"] td,
254
+ ${scope} tr[part="state-row"] td {
255
+ display: block;
256
+ }
79
257
  `;
258
+ return `
259
+ ${rules(':host([stacked]:not([stacked="auto"]))')}
260
+ @container (max-width: 560px) {
261
+ ${rules(':host([stacked="auto"])')}
262
+ }
263
+ `;
264
+ })();
80
265
  /**
81
266
  * A semantic, selectable data table — box-ui-elements `Table` + `makeSelectable`.
82
267
  * Rows and columns come from JSON. Selection supports single / Ctrl-click toggle
@@ -87,12 +272,135 @@ const tableStyles = `
87
272
  export class Table extends BaseElement {
88
273
  static tagName = DEFAULT_TAG_NAME;
89
274
  static get observedAttributes() {
90
- return ["columns", "rows", "selection-mode", "label", "sort-key", "sort-direction"];
275
+ return [
276
+ "columns",
277
+ "empty-text",
278
+ "error-text",
279
+ "label",
280
+ "loading",
281
+ "rows",
282
+ "selection-mode",
283
+ "sort-direction",
284
+ "sort-key",
285
+ "stacked",
286
+ "virtualize",
287
+ "row-height",
288
+ ];
91
289
  }
92
290
  bodyEl;
93
291
  headEl;
292
+ shellEl;
94
293
  selected = new Set();
95
294
  anchorIndex = -1;
295
+ expandedIds = new Set();
296
+ lastWindow = null;
297
+ /**
298
+ * Whether the last render actually windowed. Not the same as `virtualize`:
299
+ * the flag is suppressed for a collection the fixed-height window cannot
300
+ * describe (see `update`). Cached at render time so the scroll handler does
301
+ * not re-parse `rows` on every frame to answer the question.
302
+ */
303
+ windowing = false;
304
+ scrollFrame = 0;
305
+ measuredRowHeight = 0;
306
+ measureFrame = 0;
307
+ /** True while rows are being fetched: the body states "Loading rows…". */
308
+ get loading() {
309
+ return this.hasAttribute("loading");
310
+ }
311
+ set loading(value) {
312
+ this.toggleAttribute("loading", Boolean(value));
313
+ }
314
+ /** What the body says when there are no rows and nothing is loading. */
315
+ get emptyText() {
316
+ return this.getAttribute("empty-text") ?? "No rows";
317
+ }
318
+ set emptyText(value) {
319
+ this.setAttribute("empty-text", value);
320
+ }
321
+ /** Non-empty puts the table in an error state stating this text. */
322
+ get errorText() {
323
+ return this.getAttribute("error-text") ?? "";
324
+ }
325
+ set errorText(value) {
326
+ if (value) {
327
+ this.setAttribute("error-text", value);
328
+ }
329
+ else {
330
+ this.removeAttribute("error-text");
331
+ }
332
+ }
333
+ /**
334
+ * Render only the rows near the viewport. Opt-in: a windowed table needs a
335
+ * bounded height to scroll within, and silently windowing an unbounded table
336
+ * would render nothing.
337
+ *
338
+ * A request, not a guarantee. The window arithmetic is fixed-height, so it is
339
+ * suppressed for a collection whose rows can change height — today that means
340
+ * any collection where a row declares `detail`. Read `renderedWindow` to see
341
+ * whether a render actually windowed.
342
+ */
343
+ get virtualize() {
344
+ return this.hasAttribute("virtualize");
345
+ }
346
+ set virtualize(value) {
347
+ this.toggleAttribute("virtualize", Boolean(value));
348
+ }
349
+ /**
350
+ * Row height in pixels for the window arithmetic — a starting estimate, not
351
+ * a contract. The real height depends on density, font, and zoom, and a
352
+ * declared value that is off by a fraction of a pixel makes the scroll range
353
+ * drift as you scroll (a 10k-row table measured ~55px of wobble at 37px
354
+ * declared). After the first paint the element measures a rendered row and
355
+ * uses that instead, so the estimate only has to be close enough to pick a
356
+ * sane first window.
357
+ */
358
+ get rowHeight() {
359
+ if (this.measuredRowHeight > 0) {
360
+ return this.measuredRowHeight;
361
+ }
362
+ return this.declaredRowHeight;
363
+ }
364
+ get declaredRowHeight() {
365
+ const raw = Number(this.getAttribute("row-height"));
366
+ return Number.isFinite(raw) && raw > 0 ? raw : 37;
367
+ }
368
+ set rowHeight(value) {
369
+ // A new declaration supersedes the old measurement; the next paint
370
+ // measures again.
371
+ this.measuredRowHeight = 0;
372
+ this.setAttribute("row-height", String(value));
373
+ }
374
+ /**
375
+ * Adopt the real rendered row height, once, when it differs from the value
376
+ * the last window was computed with. Re-rendering with the measured height
377
+ * settles immediately: the next measurement matches, so this cannot loop.
378
+ */
379
+ measureRowHeight() {
380
+ if (!this.windowing || this.measureFrame)
381
+ return;
382
+ const row = this.bodyEl.querySelector('[part="row"]');
383
+ const height = row?.getBoundingClientRect().height ?? 0;
384
+ if (height <= 0 || Math.abs(height - this.rowHeight) < 0.5)
385
+ return;
386
+ this.measuredRowHeight = height;
387
+ this.measureFrame = requestAnimationFrame(() => {
388
+ this.measureFrame = 0;
389
+ this.update();
390
+ });
391
+ }
392
+ /**
393
+ * The window the last render used, or null when it rendered every row — for
394
+ * tests and hosts. Null despite `virtualize` means the collection is one the
395
+ * fixed-height window cannot describe and windowing was suppressed.
396
+ */
397
+ get renderedWindow() {
398
+ return this.windowing ? this.lastWindow : null;
399
+ }
400
+ /** Ids of the rows whose detail is expanded (host-readable). */
401
+ get expandedRows() {
402
+ return [...this.expandedIds];
403
+ }
96
404
  get columns() {
97
405
  return this.parseJson("columns", []);
98
406
  }
@@ -139,12 +447,35 @@ export class Table extends BaseElement {
139
447
  return fallback;
140
448
  }
141
449
  }
142
- cellText(row, column, index) {
450
+ cellValue(row, column, index) {
143
451
  if (Array.isArray(row.cells)) {
144
452
  return row.cells[index] ?? "";
145
453
  }
146
454
  return row.cells[column.key] ?? "";
147
455
  }
456
+ /**
457
+ * Descriptor rendering. Everything is escaped; a link with a non-http(s)
458
+ * href renders as plain text; a badge's tone colours a word that already
459
+ * carries the meaning.
460
+ */
461
+ cellMarkup(value) {
462
+ if (!isCellDescriptor(value)) {
463
+ return escapeHtml(typeof value === "string" ? value : "");
464
+ }
465
+ if (value.kind === "badge") {
466
+ const tone = value.tone && CELL_TONES.has(value.tone) ? value.tone : "neutral";
467
+ const toneVar = tone === "neutral"
468
+ ? "var(--boe-token-text-text-secondary, #6f6f6f)"
469
+ : tone === "brand"
470
+ ? "var(--boe-token-surface-surface-brand, #0061d5)"
471
+ : `var(--boe-token-surface-status-surface-${tone}, #6f6f6f)`;
472
+ return `<span part="cell-badge" style="--cell-tone:${toneVar};">${escapeHtml(value.text)}</span>`;
473
+ }
474
+ if (value.kind === "link" && value.href && isSafeHref(value.href)) {
475
+ return `<a part="cell-link" href="${escapeHtml(value.href)}">${escapeHtml(value.text)}</a>`;
476
+ }
477
+ return escapeHtml(value.text);
478
+ }
148
479
  renderTemplate() {
149
480
  if (!this.shadowRoot)
150
481
  return;
@@ -159,8 +490,23 @@ export class Table extends BaseElement {
159
490
  `;
160
491
  this.headEl = this.shadowRoot.querySelector('[part="header-row"]');
161
492
  this.bodyEl = this.shadowRoot.querySelector('[part="body"]');
493
+ this.shellEl = this.shadowRoot.querySelector('[part="shell"]');
162
494
  }
163
495
  setupListeners() {
496
+ // Scroll fires continuously; coalesce to one re-render per frame, and skip
497
+ // entirely while the resolved slice is unchanged — most scroll events move
498
+ // the viewport within the rows already rendered.
499
+ this.shellEl.addEventListener("scroll", () => {
500
+ if (!this.windowing || this.scrollFrame)
501
+ return;
502
+ this.scrollFrame = requestAnimationFrame(() => {
503
+ this.scrollFrame = 0;
504
+ const next = this.currentWindow();
505
+ if (this.lastWindow && sameRowWindow(this.lastWindow, next))
506
+ return;
507
+ this.update();
508
+ });
509
+ }, { passive: true });
164
510
  this.headEl.addEventListener("click", event => {
165
511
  const th = event.target.closest('th[part="sortable"]');
166
512
  if (!th)
@@ -168,6 +514,11 @@ export class Table extends BaseElement {
168
514
  this.toggleSort(th.dataset.key ?? "");
169
515
  });
170
516
  this.bodyEl.addEventListener("click", event => {
517
+ const expander = event.target.closest('[part="expander"]');
518
+ if (expander) {
519
+ this.toggleRowDetail(expander.dataset.rowId ?? "");
520
+ return; // expanding is not selecting
521
+ }
171
522
  const row = event.target.closest('[part="row"]');
172
523
  if (!row)
173
524
  return;
@@ -179,9 +530,34 @@ export class Table extends BaseElement {
179
530
  rowElements() {
180
531
  return Array.from(this.bodyEl.querySelectorAll('[part="row"]'));
181
532
  }
533
+ toggleRowDetail(rowId) {
534
+ if (!rowId)
535
+ return;
536
+ const expanded = !this.expandedIds.has(rowId);
537
+ if (expanded) {
538
+ this.expandedIds.add(rowId);
539
+ }
540
+ else {
541
+ this.expandedIds.delete(rowId);
542
+ }
543
+ this.dispatchEvent(new CustomEvent("row-toggled", {
544
+ bubbles: true,
545
+ composed: true,
546
+ detail: { rowId, expanded },
547
+ }));
548
+ this.update();
549
+ // update() rewrote the body, destroying the button that was activated;
550
+ // put focus on its replacement so a keyboard user is not dropped.
551
+ this.bodyEl
552
+ .querySelector(`[part="expander"][data-row-id="${cssAttrValue(rowId)}"]`)
553
+ ?.focus();
554
+ }
182
555
  onBodyKeydown(event) {
183
- const rows = this.rowElements();
184
- if (rows.length === 0)
556
+ // Keys aimed at the expander button (Enter/Space activate it natively)
557
+ // must not double as row selection.
558
+ if (event.target.closest('[part="expander"]'))
559
+ return;
560
+ if (this.rowElements().length === 0)
185
561
  return;
186
562
  const currentRow = event.target.closest('[part="row"]');
187
563
  const index = currentRow ? Number(currentRow.dataset.index) : -1;
@@ -204,29 +580,55 @@ export class Table extends BaseElement {
204
580
  this.activateRow(index, { toggle: this.selectionMode === "multiple" });
205
581
  return;
206
582
  }
583
+ // Bounds come from the data, not the DOM: when windowing, the rendered
584
+ // rows are a slice and `rows.length` would stop End at the bottom of the
585
+ // window instead of the bottom of the collection.
586
+ const lastIndex = this.rows.length - 1;
207
587
  let nextIndex = index;
208
588
  if (event.key === "ArrowDown")
209
- nextIndex = Math.min(index + 1, rows.length - 1);
589
+ nextIndex = Math.min(index + 1, lastIndex);
210
590
  else if (event.key === "ArrowUp")
211
591
  nextIndex = Math.max(index - 1, 0);
212
592
  else if (event.key === "Home")
213
593
  nextIndex = 0;
214
594
  else if (event.key === "End")
215
- nextIndex = rows.length - 1;
595
+ nextIndex = lastIndex;
216
596
  else
217
597
  return;
218
598
  event.preventDefault();
219
- rows[nextIndex]?.focus();
599
+ this.focusRowByIndex(nextIndex);
220
600
  // Shift+Arrow extends the selection from the anchor.
221
601
  if (event.shiftKey && this.selectionMode === "multiple") {
222
602
  this.selectRange(this.anchorIndex < 0 ? index : this.anchorIndex, nextIndex);
223
603
  }
224
604
  }
605
+ /**
606
+ * Focus a row by its absolute index, bringing it into the window first when
607
+ * virtualizing — keyboard navigation must be able to reach a row that is not
608
+ * currently rendered, or Home/End stop at the edge of the window.
609
+ */
610
+ focusRowByIndex(index) {
611
+ const find = () => this.bodyEl.querySelector(`[part="row"][data-index="${String(index)}"]`);
612
+ let target = find();
613
+ if (!target && this.windowing && this.shellEl) {
614
+ // Scroll the row to the top of the viewport, then re-render the window
615
+ // synchronously: the scroll event's own re-render lands a frame later,
616
+ // and focus cannot wait for it.
617
+ this.shellEl.scrollTop = index * this.rowHeight;
618
+ this.update();
619
+ target = find();
620
+ }
621
+ target?.focus();
622
+ }
225
623
  toggleSort(key) {
226
624
  const currentKey = this.getAttribute("sort-key");
227
625
  const currentDir = this.getAttribute("sort-direction");
228
626
  const direction = currentKey === key && currentDir === "ascending" ? "descending" : "ascending";
229
- this.dispatchEvent(new CustomEvent("sort", { bubbles: true, composed: true, detail: { key, direction } }));
627
+ this.dispatchEvent(new CustomEvent("sort", {
628
+ bubbles: true,
629
+ composed: true,
630
+ detail: { key, direction },
631
+ }));
230
632
  }
231
633
  activateRow(index, options = {}) {
232
634
  const rows = this.rows;
@@ -276,6 +678,14 @@ export class Table extends BaseElement {
276
678
  row.setAttribute("aria-selected", String(this.selected.has(id)));
277
679
  }
278
680
  }
681
+ currentWindow() {
682
+ return resolveRowWindow({
683
+ totalRows: this.rows.length,
684
+ rowHeight: this.rowHeight,
685
+ viewportHeight: this.shellEl?.clientHeight ?? 0,
686
+ scrollTop: this.shellEl?.scrollTop ?? 0,
687
+ });
688
+ }
279
689
  update() {
280
690
  if (!this.bodyEl || !this.headEl)
281
691
  return;
@@ -284,34 +694,127 @@ export class Table extends BaseElement {
284
694
  const selectable = this.selectionMode !== "none";
285
695
  const sortKey = this.getAttribute("sort-key");
286
696
  const sortDir = this.getAttribute("sort-direction");
287
- this.shadowRoot?.querySelector('[part="table"]')?.setAttribute("aria-label", this.label);
288
- this.shadowRoot?.querySelector('[part="table"]')?.setAttribute("role", selectable ? "grid" : "table");
289
- this.headEl.innerHTML = columns
290
- .map(column => {
291
- const align = column.align ? ` data-align="${column.align}"` : "";
292
- if (column.sortable) {
293
- const sort = sortKey === column.key ? ` aria-sort="${sortDir === "descending" ? "descending" : "ascending"}"` : ' aria-sort="none"';
294
- return `<th part="sortable" scope="col" data-key="${escapeHtml(column.key)}"${align}${sort}>${escapeHtml(column.label)}<span class="boe-sort-arrow" aria-hidden="true"></span></th>`;
295
- }
296
- return `<th scope="col"${align}>${escapeHtml(column.label)}</th>`;
297
- })
298
- .join("");
299
- this.bodyEl.innerHTML = rows
300
- .map((row, index) => {
301
- const selected = this.selected.has(row.id);
302
- const rowAttrs = selectable
303
- ? ` part="row" role="row" tabindex="${index === 0 ? "0" : "-1"}" aria-selected="${selected}"`
304
- : ' part="row" role="row"';
305
- const cells = columns
306
- .map(column => {
307
- const align = column.align ? ` data-align="${column.align}"` : "";
308
- const role = selectable ? ' role="gridcell"' : ' role="cell"';
309
- return `<td${align}${role}>${escapeHtml(this.cellText(row, column, columns.indexOf(column)))}</td>`;
310
- })
311
- .join("");
312
- return `<tr${rowAttrs} data-index="${index}" data-id="${escapeHtml(row.id)}">${cells}</tr>`;
313
- })
314
- .join("");
697
+ // A non-string detail is malformed input, not a crash: it gets no expander.
698
+ const expandable = rows.some(row => typeof row.detail === "string");
699
+ const columnCount = columns.length + (expandable ? 1 : 0);
700
+ const tableEl = this.shadowRoot?.querySelector('[part="table"]');
701
+ tableEl?.setAttribute("aria-label", this.label);
702
+ tableEl?.setAttribute("role", selectable ? "grid" : "table");
703
+ if (this.loading) {
704
+ tableEl?.setAttribute("aria-busy", "true");
705
+ }
706
+ else {
707
+ tableEl?.removeAttribute("aria-busy");
708
+ }
709
+ this.headEl.innerHTML =
710
+ (expandable ? `<th scope="col" aria-label="Row details"></th>` : "") +
711
+ columns
712
+ .map(column => {
713
+ const align = alignAttr(column.align);
714
+ if (column.sortable) {
715
+ const sorted = sortKey === column.key;
716
+ const sort = sorted
717
+ ? ` aria-sort="${sortDir === "descending" ? "descending" : "ascending"}"`
718
+ : ' aria-sort="none"';
719
+ // The state, in words as well as the arrow glyph.
720
+ const stateLabel = sorted
721
+ ? `, sorted ${sortDir === "descending" ? "descending" : "ascending"}`
722
+ : ", not sorted";
723
+ return `<th part="sortable" scope="col" data-key="${escapeHtml(column.key)}"${align}${sort}><button type="button" part="sort-button" aria-label="Sort by ${escapeHtml(column.label)}${stateLabel}">${escapeHtml(column.label)}<span class="boe-sort-arrow" aria-hidden="true"></span></button></th>`;
724
+ }
725
+ return `<th scope="col"${align}>${escapeHtml(column.label)}</th>`;
726
+ })
727
+ .join("");
728
+ // A state row is never windowed. Cleared up front so a table that drops
729
+ // into loading/error/empty does not leave the previous window's flag
730
+ // standing — the scroll handler and `renderedWindow` both read it.
731
+ this.windowing = false;
732
+ this.lastWindow = null;
733
+ // Loading, error, and empty are stated in words in the body — a blank
734
+ // grid reads as broken. Loading wins; a stale error must not outlive a
735
+ // retry that is visibly in flight.
736
+ if (this.loading) {
737
+ this.bodyEl.innerHTML = `<tr part="state-row" data-state="loading" role="row"><td role="${selectable ? "gridcell" : "cell"}" colspan="${String(columnCount)}"><span part="state-spinner" aria-hidden="true"></span>Loading rows…</td></tr>`;
738
+ return;
739
+ }
740
+ if (this.errorText) {
741
+ this.bodyEl.innerHTML = `<tr part="state-row" data-state="error" role="row"><td role="${selectable ? "gridcell" : "cell"}" colspan="${String(columnCount)}">${escapeHtml(this.errorText)}</td></tr>`;
742
+ return;
743
+ }
744
+ if (rows.length === 0) {
745
+ this.bodyEl.innerHTML = `<tr part="state-row" data-state="empty" role="row"><td role="${selectable ? "gridcell" : "cell"}" colspan="${String(columnCount)}">${escapeHtml(this.emptyText)}</td></tr>`;
746
+ return;
747
+ }
748
+ // Windowing renders only the rows near the viewport, padded with spacer
749
+ // rows so the scrollbar still describes the whole collection. Indices stay
750
+ // ABSOLUTE: selection, shift-range, and activateRow all address `rows`, and
751
+ // renumbering the slice would select the wrong record.
752
+ //
753
+ // `virtualize` is a request, not a guarantee. `resolveRowWindow` derives
754
+ // the whole scroll range from `rows.length * rowHeight`, so it can only
755
+ // describe a collection of uniform, fixed-height rows. An expanded row
756
+ // renders a second `<tr part="detail-row">` the arithmetic knows nothing
757
+ // about: the real content grows taller than the spacers claim, the scroll
758
+ // range no longer matches the scrollbar, and scrollTop stops mapping to the
759
+ // right absolute index — so a windowed table with expandable rows would
760
+ // scroll to the wrong record. Variable heights are a different algorithm (a
761
+ // cumulative offset index), not a flag on this one, so windowing is
762
+ // suppressed rather than approximated.
763
+ //
764
+ // The test is `expandable`, not "is anything expanded right now": deciding
765
+ // from transient UI state would flip windowing on and off mid-scroll, which
766
+ // jumps the viewport under the pointer. A collection that can expand never
767
+ // windows; `renderedWindow` returns null so a host can see that it didn't.
768
+ const virtualizing = this.virtualize && !expandable;
769
+ const rowWindow = virtualizing ? this.currentWindow() : null;
770
+ this.windowing = virtualizing;
771
+ this.lastWindow = rowWindow;
772
+ const visibleRows = rowWindow
773
+ ? rows.slice(rowWindow.startIndex, rowWindow.endIndex)
774
+ : rows;
775
+ const indexOffset = rowWindow?.startIndex ?? 0;
776
+ // The tab stop belongs to the first row that is actually rendered; row 0
777
+ // may be scrolled far out of the window.
778
+ const tabbableIndex = indexOffset;
779
+ const spacer = (height, position) => height > 0
780
+ ? `<tr part="spacer" data-position="${position}" aria-hidden="true"><td colspan="${String(columnCount)}" style="height:${String(height)}px;padding:0;border:0;"></td></tr>`
781
+ : "";
782
+ this.bodyEl.innerHTML =
783
+ (rowWindow ? spacer(rowWindow.paddingTop, "top") : "") +
784
+ visibleRows
785
+ .map((row, offset) => {
786
+ const index = offset + indexOffset;
787
+ const selected = this.selected.has(row.id);
788
+ const cellRole = selectable ? ' role="gridcell"' : ' role="cell"';
789
+ const rowAttrs = selectable
790
+ ? ` part="row" role="row" tabindex="${index === tabbableIndex ? "0" : "-1"}" aria-selected="${selected}"`
791
+ : ' part="row" role="row"';
792
+ const expanded = this.expandedIds.has(row.id);
793
+ const expanderCell = expandable
794
+ ? `<td${cellRole} part="expander-cell">${typeof row.detail === "string"
795
+ ? `<button type="button" part="expander" data-row-id="${escapeHtml(row.id)}" aria-expanded="${String(expanded)}" aria-label="${expanded ? "Hide" : "Show"} details for row ${escapeHtml(row.id)}"></button>`
796
+ : ""}</td>`
797
+ : "";
798
+ const cells = columns
799
+ .map((column, columnIndex) => {
800
+ const align = alignAttr(column.align);
801
+ const value = this.cellValue(row, column, columnIndex);
802
+ return `<td${align}${cellRole} data-label="${escapeHtml(column.label)}">${this.cellMarkup(value)}</td>`;
803
+ })
804
+ .join("");
805
+ const rowMarkup = `<tr${rowAttrs} data-index="${index}" data-id="${escapeHtml(row.id)}">${expanderCell}${cells}</tr>`;
806
+ // The detail row renders only while expanded, keeping the roving
807
+ // tabindex and shift-range math on visible [part="row"] rows only.
808
+ const detailRow = typeof row.detail === "string" && expanded
809
+ ? `<tr part="detail-row" role="row" data-for="${escapeHtml(row.id)}"><td${cellRole} colspan="${String(columnCount)}">${escapeHtml(row.detail)}<slot name="detail-${escapeHtml(row.id)}"></slot></td></tr>`
810
+ : "";
811
+ return rowMarkup + detailRow;
812
+ })
813
+ .join("") +
814
+ (rowWindow ? spacer(rowWindow.paddingBottom, "bottom") : "");
815
+ if (virtualizing) {
816
+ this.measureRowHeight();
817
+ }
315
818
  }
316
819
  }
317
820
  Table.register();