@getdom/studio 0.1.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 (213) hide show
  1. package/LICENSE.md +109 -0
  2. package/README.md +44 -0
  3. package/SKILL.md +83 -0
  4. package/package.json +73 -0
  5. package/public/llms.txt +57 -0
  6. package/public/skills.sh +11 -0
  7. package/src/pages/_layout/componentCatalog.generated.js +4514 -0
  8. package/src/pages/_layout/componentInspector.js +60 -0
  9. package/src/pages/_layout/componentManager.js +192 -0
  10. package/src/pages/_layout/componentRuntimeRecords.js +49 -0
  11. package/src/pages/_layout/inspector/componentRegistry.js +639 -0
  12. package/src/pages/_layout/inspector/serialize.js +134 -0
  13. package/src/pages/_layout/inspector/useInspector.js +293 -0
  14. package/src/pages/components/accordion/DomAccordion.vue +39 -0
  15. package/src/pages/components/breadcrumbs/DomBreadcrumbs.vue +347 -0
  16. package/src/pages/components/button/DomButton.vue +132 -0
  17. package/src/pages/components/command-palette/DomCommandPalette.vue +482 -0
  18. package/src/pages/components/context-menu/DomContextMenu.vue +178 -0
  19. package/src/pages/components/dialog/DomDialog.vue +271 -0
  20. package/src/pages/components/dialog/DomDialogSchemaForm.vue +72 -0
  21. package/src/pages/components/dialog/DomDialogStack.vue +289 -0
  22. package/src/pages/components/dialog/useDialogs.js +253 -0
  23. package/src/pages/components/drawer/DomDrawer.vue +159 -0
  24. package/src/pages/components/dropdown/DomDropdown.vue +261 -0
  25. package/src/pages/components/icon-button/DomIconButton.vue +155 -0
  26. package/src/pages/components/lightbox/DomLightbox.vue +1561 -0
  27. package/src/pages/components/menu/DomMenu.vue +359 -0
  28. package/src/pages/components/menu/DomMenuItem.vue +233 -0
  29. package/src/pages/components/menu/DomMenuLabel.vue +19 -0
  30. package/src/pages/components/menu/DomMenuSeparator.vue +14 -0
  31. package/src/pages/components/menu/menuContext.js +11 -0
  32. package/src/pages/components/menu/menuPlacement.js +26 -0
  33. package/src/pages/components/menu/submenu.js +58 -0
  34. package/src/pages/components/pagination/DomPagination.vue +265 -0
  35. package/src/pages/components/playground/DomRenderer.vue +48 -0
  36. package/src/pages/components/popover/DomPopover.vue +244 -0
  37. package/src/pages/components/profile-menu/DomProfileMenu.vue +408 -0
  38. package/src/pages/components/profile-menu/DomProfileMenuItemContent.vue +45 -0
  39. package/src/pages/components/scroll-menu/DomScrollMenu.vue +430 -0
  40. package/src/pages/components/scroll-menu/DomScrollMenuItem.vue +212 -0
  41. package/src/pages/components/scroll-menu/scrollMenuContext.js +22 -0
  42. package/src/pages/components/splitter-panel/DomSplitterPanel.vue +283 -0
  43. package/src/pages/components/stepper/DomStepper.vue +386 -0
  44. package/src/pages/components/tabs/DomTabs.vue +149 -0
  45. package/src/pages/components/theme-switcher/DomThemeSwitcher.vue +226 -0
  46. package/src/pages/components/toast/DomToastItem.vue +52 -0
  47. package/src/pages/components/toast/DomToastStack.vue +768 -0
  48. package/src/pages/components/toast/useToasts.js +237 -0
  49. package/src/pages/components/toolbar/DomToolbar.vue +158 -0
  50. package/src/pages/components/tooltip/DomTooltip.vue +69 -0
  51. package/src/pages/components/tooltip/vDomTooltip.js +106 -0
  52. package/src/pages/components/tree-view/DomTreeView.vue +961 -0
  53. package/src/pages/components/tree-view/treeSchemas.js +129 -0
  54. package/src/pages/components/tree-view/treeUtils.js +228 -0
  55. package/src/pages/data/_shared/calculateVirtualWindow.js +101 -0
  56. package/src/pages/data/_shared/resourceRecord.js +29 -0
  57. package/src/pages/data/activity-timeline/DomActivityTimeline.vue +417 -0
  58. package/src/pages/data/agent-chat/DomAgentChatShell.vue +565 -0
  59. package/src/pages/data/agent-chat/DomAgentConversation.vue +737 -0
  60. package/src/pages/data/agent-chat/DomAgentConversationList.vue +319 -0
  61. package/src/pages/data/agent-chat/examples/agentChatMockApi.js +620 -0
  62. package/src/pages/data/agent-chat/examples/useAgentChatDemoStore.js +730 -0
  63. package/src/pages/data/data-grid/DataGridVirtualRows.vue +220 -0
  64. package/src/pages/data/data-grid/DomDataGrid.vue +1643 -0
  65. package/src/pages/data/document-editor/DomDocumentEditor.vue +3030 -0
  66. package/src/pages/data/document-editor/documentEditorCodeBlock.js +282 -0
  67. package/src/pages/data/document-editor/examples/documentEditorMockApi.js +272 -0
  68. package/src/pages/data/document-editor/examples/useDocumentEditorDemoStore.js +284 -0
  69. package/src/pages/data/filter-bar/DomFilterBar.vue +533 -0
  70. package/src/pages/data/kanban/DomKanban.vue +1703 -0
  71. package/src/pages/data/kanban/kanbanApi.js +80 -0
  72. package/src/pages/data/media-browser/DomMediaBrowser.vue +2344 -0
  73. package/src/pages/data/media-browser/examples/mediaBrowserMockApi.js +653 -0
  74. package/src/pages/data/media-browser/examples/useMediaBrowserDemoStore.js +1157 -0
  75. package/src/pages/data/month-calendar/DomMonthCalendar.vue +1718 -0
  76. package/src/pages/data/week-planner/DomWeekPlanner.vue +2308 -0
  77. package/src/pages/dev/code-block/DomCodeBlock.vue +263 -0
  78. package/src/pages/dev/json-viewer/DomJsonViewer.vue +962 -0
  79. package/src/pages/forms/_shared/DomFieldLoadingSpinner.vue +12 -0
  80. package/src/pages/forms/_shared/options.js +61 -0
  81. package/src/pages/forms/_shared/useOptionMenu.js +59 -0
  82. package/src/pages/forms/autocomplete/DomAutocomplete.vue +180 -0
  83. package/src/pages/forms/calendar/DomCalendar.vue +442 -0
  84. package/src/pages/forms/checkbox/DomCheckbox.vue +69 -0
  85. package/src/pages/forms/class-toggle-input/DomClassToggleInput.vue +316 -0
  86. package/src/pages/forms/code-input/DomCodeInput.vue +202 -0
  87. package/src/pages/forms/color-input/DomColorInput.vue +118 -0
  88. package/src/pages/forms/combobox/DomCombobox.vue +237 -0
  89. package/src/pages/forms/confirmation-code-input/DomConfirmationCodeInput.vue +345 -0
  90. package/src/pages/forms/date-picker/DomDatePicker.vue +299 -0
  91. package/src/pages/forms/date-time-picker/DomDateTimePicker.vue +630 -0
  92. package/src/pages/forms/email-input/DomEmailInput.vue +55 -0
  93. package/src/pages/forms/field/DomField.vue +267 -0
  94. package/src/pages/forms/field/fieldLayout.js +1 -0
  95. package/src/pages/forms/field/fieldProps.js +114 -0
  96. package/src/pages/forms/field/layouts/DomGridFieldLayout.vue +38 -0
  97. package/src/pages/forms/field/useField.js +343 -0
  98. package/src/pages/forms/field/validators.js +364 -0
  99. package/src/pages/forms/file-upload/DomFileUpload.vue +361 -0
  100. package/src/pages/forms/form/DomForm.vue +985 -0
  101. package/src/pages/forms/form/adapters/fieldBuilder.js +45 -0
  102. package/src/pages/forms/form/adapters/jsonSchema.js +264 -0
  103. package/src/pages/forms/form/adapters/shared.js +53 -0
  104. package/src/pages/forms/form/adapters/zod.js +235 -0
  105. package/src/pages/forms/form/formApi.js +38 -0
  106. package/src/pages/forms/form/formDefinition.js +392 -0
  107. package/src/pages/forms/form/schemaAdapter.js +54 -0
  108. package/src/pages/forms/icon-selector/DomIconSelector.vue +460 -0
  109. package/src/pages/forms/icon-selector/iconSelectorItems.js +490 -0
  110. package/src/pages/forms/input-group/DomInputGroup.vue +232 -0
  111. package/src/pages/forms/json-input/DomJsonInput.vue +227 -0
  112. package/src/pages/forms/json-list-input/DomJsonListInput.vue +206 -0
  113. package/src/pages/forms/listbox/DomListbox.vue +105 -0
  114. package/src/pages/forms/money-input/DomMoneyInput.vue +147 -0
  115. package/src/pages/forms/native-select/DomNativeSelect.vue +67 -0
  116. package/src/pages/forms/number-input/DomNumberInput.vue +47 -0
  117. package/src/pages/forms/password-input/DomPasswordInput.vue +218 -0
  118. package/src/pages/forms/password-input/pwnedPasswords.js +47 -0
  119. package/src/pages/forms/password-strength/DomPasswordStrength.vue +128 -0
  120. package/src/pages/forms/password-strength/passwordStrength.js +92 -0
  121. package/src/pages/forms/position-input/DomPositionInput.vue +83 -0
  122. package/src/pages/forms/radio-group/DomRadioGroup.vue +102 -0
  123. package/src/pages/forms/range-input/DomRangeInput.vue +296 -0
  124. package/src/pages/forms/select/DomSelect.vue +479 -0
  125. package/src/pages/forms/select-input/DomSelectInput.vue +155 -0
  126. package/src/pages/forms/tag-combobox/DomTagCombobox.vue +358 -0
  127. package/src/pages/forms/text-input/DomTextInput.vue +48 -0
  128. package/src/pages/forms/textarea-composer/DomTextareaComposer.vue +234 -0
  129. package/src/pages/forms/textarea-input/DomTextareaInput.vue +86 -0
  130. package/src/pages/forms/textarea-input/useTextareaAutosize.js +147 -0
  131. package/src/pages/forms/time-selector/DomTimeSelector.vue +389 -0
  132. package/src/pages/forms/time-selector/timeUtils.js +194 -0
  133. package/src/pages/forms/toggle/DomToggle.vue +158 -0
  134. package/src/pages/forms/toggle-button/DomToggleButton.vue +88 -0
  135. package/src/pages/forms/toggle-button-group/DomToggleButtonGroup.vue +340 -0
  136. package/src/pages/forms/url-input/DomUrlInput.vue +55 -0
  137. package/src/pages/forms/wysiwyg/DomWysiwyg.vue +204 -0
  138. package/src/pages/lib/css/color.js +245 -0
  139. package/src/pages/lib/headless/accordion.d.ts +7 -0
  140. package/src/pages/lib/headless/accordion.js +246 -0
  141. package/src/pages/lib/headless/autocomplete.d.ts +4 -0
  142. package/src/pages/lib/headless/autocomplete.js +33 -0
  143. package/src/pages/lib/headless/base.d.ts +25 -0
  144. package/src/pages/lib/headless/base.js +127 -0
  145. package/src/pages/lib/headless/checkbox.d.ts +19 -0
  146. package/src/pages/lib/headless/checkbox.js +99 -0
  147. package/src/pages/lib/headless/combobox.d.ts +7 -0
  148. package/src/pages/lib/headless/combobox.js +488 -0
  149. package/src/pages/lib/headless/dialog.d.ts +23 -0
  150. package/src/pages/lib/headless/dialog.js +384 -0
  151. package/src/pages/lib/headless/drawer.d.ts +21 -0
  152. package/src/pages/lib/headless/drawer.js +682 -0
  153. package/src/pages/lib/headless/dropdown.d.ts +19 -0
  154. package/src/pages/lib/headless/dropdown.js +412 -0
  155. package/src/pages/lib/headless/floating-effect.js +118 -0
  156. package/src/pages/lib/headless/floating.d.ts +34 -0
  157. package/src/pages/lib/headless/floating.js +382 -0
  158. package/src/pages/lib/headless/index.d.ts +19 -0
  159. package/src/pages/lib/headless/index.js +19 -0
  160. package/src/pages/lib/headless/listbox.d.ts +19 -0
  161. package/src/pages/lib/headless/listbox.js +126 -0
  162. package/src/pages/lib/headless/menu.d.ts +18 -0
  163. package/src/pages/lib/headless/menu.js +117 -0
  164. package/src/pages/lib/headless/popover-panel.d.ts +18 -0
  165. package/src/pages/lib/headless/popover-panel.js +309 -0
  166. package/src/pages/lib/headless/popover.d.ts +19 -0
  167. package/src/pages/lib/headless/popover.js +391 -0
  168. package/src/pages/lib/headless/radio-group.d.ts +19 -0
  169. package/src/pages/lib/headless/radio-group.js +100 -0
  170. package/src/pages/lib/headless/tabs.d.ts +18 -0
  171. package/src/pages/lib/headless/tabs.js +119 -0
  172. package/src/pages/lib/headless/toast.d.ts +13 -0
  173. package/src/pages/lib/headless/toast.js +65 -0
  174. package/src/pages/lib/headless/toggle.d.ts +19 -0
  175. package/src/pages/lib/headless/toggle.js +162 -0
  176. package/src/pages/lib/headless/tooltip-controller.d.ts +22 -0
  177. package/src/pages/lib/headless/tooltip-controller.js +549 -0
  178. package/src/pages/lib/headless/tooltip.d.ts +19 -0
  179. package/src/pages/lib/headless/tooltip.js +153 -0
  180. package/src/pages/lib/headless/transitions.d.ts +5 -0
  181. package/src/pages/lib/headless/transitions.js +89 -0
  182. package/src/pages/lib/vue/_internal/DomLiquidGlassSurface.vue +310 -0
  183. package/src/pages/lib/vue/_internal/liquidGlass.js +112 -0
  184. package/src/pages/lib/vue/_internal/liquidGlassConfig.js +10 -0
  185. package/src/pages/lib/vue/codeHighlighter.js +132 -0
  186. package/src/pages/lib/vue/floatingTeleportTarget.js +23 -0
  187. package/src/pages/lib/vue/index.d.ts +6806 -0
  188. package/src/pages/lib/vue/index.js +218 -0
  189. package/src/pages/lib/vue/useTheme.js +116 -0
  190. package/src/pages/mobile/action-sheet/DomActionSheet.vue +186 -0
  191. package/src/pages/mobile/bottom-nav/DomAppBottomNav.vue +94 -0
  192. package/src/pages/mobile/list-item/DomAppListItem.vue +90 -0
  193. package/src/pages/mobile/safe-area/DomSafeArea.vue +39 -0
  194. package/src/pages/mobile/shell/DomAppShell.vue +51 -0
  195. package/src/pages/mobile/stack/AppStackScreen.vue +63 -0
  196. package/src/pages/mobile/stack/DomAppStack.vue +711 -0
  197. package/src/pages/mobile/top-bar/DomAppTopBar.vue +67 -0
  198. package/src/pages/visual/alert/DomAlert.vue +363 -0
  199. package/src/pages/visual/avatar/DomAvatar.vue +110 -0
  200. package/src/pages/visual/avatar-stack/DomAvatarStack.vue +89 -0
  201. package/src/pages/visual/badge/DomBadge.vue +105 -0
  202. package/src/pages/visual/card/DomCard.vue +65 -0
  203. package/src/pages/visual/card-rail/DomCardRail.vue +99 -0
  204. package/src/pages/visual/empty-state/DomEmptyState.vue +88 -0
  205. package/src/pages/visual/progress/DomProgress.vue +212 -0
  206. package/src/pages/visual/progress/DomRadialProgress.vue +186 -0
  207. package/src/pages/visual/progress/DomSegmentedProgress.vue +350 -0
  208. package/src/pages/visual/skeleton/DomSkeleton.vue +174 -0
  209. package/src/pages/visual/spinner/DomSpinner.vue +122 -0
  210. package/src/pages/visual/status-pill/DomStatusPill.vue +75 -0
  211. package/src/pages/visual/text-diff/DomTextDiff.vue +577 -0
  212. package/src/pages/visual/text-diff/textDiff.js +300 -0
  213. package/src/style.css +446 -0
@@ -0,0 +1,1643 @@
1
+ <script>
2
+ /**
3
+ * @typedef {object} DataGridColumn
4
+ * @property {string} key Stable column id. It can match a row property, or identify a display-only column such as actions.
5
+ * @property {string} [field] Row property to read when it differs from key.
6
+ * @property {string} [label] Header label.
7
+ * @property {'text'|'number'|'date'|'boolean'|'select'|'currency'} [type] Column data type. Drives sorting, formatting, and filter controls.
8
+ * @property {string|number} [width] CSS width for the column.
9
+ * @property {'left'|'center'|'right'} [align] Cell alignment.
10
+ * @property {object|Function|string} [component] Vue component used to render cells in this column. Receives row, column, value, formatted, rowIndex, and rowKey props.
11
+ * @property {boolean} [resizable] Set false to disable drag resizing for this column.
12
+ * @property {boolean} [sortable] Set false to disable sorting.
13
+ * @property {boolean} [filterable] Set false to disable filtering for this column, hide the filter button, and omit the column from query filters.
14
+ * @property {boolean} [searchable] Set false to remove the column from global search.
15
+ * @property {Array<string|number|boolean|object>} [options] Select filter options. Objects may include label, value, and tone.
16
+ * @property {string} [currency] Currency code for currency columns.
17
+ * @property {Function} [accessor] Function that receives a row and returns the cell value.
18
+ * @property {Function} [sortAccessor] Function that receives a row and column and returns the value used for local sorting.
19
+ * @property {Function} [format] Function that receives value, row, and column and returns display text.
20
+ */
21
+
22
+ /**
23
+ * @typedef {object} DataGridFilter
24
+ * @property {string} [type]
25
+ * @property {string} [operator]
26
+ * @property {unknown} [value]
27
+ * @property {Array<unknown>} [values]
28
+ * @property {string|number} [min]
29
+ * @property {string|number} [max]
30
+ * @property {string} [start]
31
+ * @property {string} [end]
32
+ */
33
+
34
+ /**
35
+ * @typedef {object} DataGridSortRule
36
+ * @property {string} [key] Column key, row field, or stable custom sort id.
37
+ * @property {'asc'|'desc'} [direction]
38
+ * @property {string} [field] Row property to sort by when it differs from key.
39
+ * @property {Function} [accessor] Function that receives a row and returns the local sort value.
40
+ * @property {Function} [compare] Function that receives left row, right row, rule, and context and returns an ascending comparison result.
41
+ */
42
+
43
+ /**
44
+ * @typedef {object} DataGridSort
45
+ * @property {string} [key]
46
+ * @property {'asc'|'desc'|''} [direction]
47
+ * @property {Array<DataGridSortRule>} [rules] Ordered sort rules for multi-column or computed local sorting and server query payloads.
48
+ */
49
+
50
+ /**
51
+ * @typedef {object} DataGridQuery
52
+ * @property {string} search
53
+ * @property {Record<string, DataGridFilter>} filters
54
+ * @property {DataGridSort|null} sort
55
+ */
56
+
57
+ export const dataGridProps = {
58
+ rows: {
59
+ type: Array,
60
+ default: () => [],
61
+ _edit: { description: 'Rows to render. Each row should be a plain object from an API, store, or database result.' },
62
+ },
63
+ /**
64
+ * @type {Array<DataGridColumn>}
65
+ */
66
+ columns: {
67
+ type: Array,
68
+ default: () => [],
69
+ _edit: { description: 'Column definitions. Each type chooses its own sorting, formatting, and filter controls.' },
70
+ },
71
+ rowKey: {
72
+ type: [String, Function],
73
+ default: 'id',
74
+ _edit: { description: 'Field name or function used to identify each row.' },
75
+ },
76
+ filters: {
77
+ type: Object,
78
+ default: () => ({}),
79
+ _edit: { description: 'Controlled filter map keyed by column key.' },
80
+ },
81
+ sort: {
82
+ type: [Object, Array],
83
+ default: () => ({ key: '', direction: '' }),
84
+ _edit: { description: 'Controlled sort state as { key, direction }, or { rules } for multi-column and computed sorting.' },
85
+ },
86
+ multiSort: {
87
+ type: Boolean,
88
+ default: false,
89
+ _edit: { description: 'When true, header clicks toggle each sortable column inside an ordered sort.rules array.' },
90
+ },
91
+ search: {
92
+ type: String,
93
+ default: '',
94
+ _edit: { description: 'Global search text.' },
95
+ },
96
+ selectedKeys: {
97
+ type: Array,
98
+ default: () => [],
99
+ _edit: { description: 'Selected row keys.' },
100
+ },
101
+ remote: {
102
+ type: Boolean,
103
+ default: false,
104
+ _edit: { description: 'When true, rows render as supplied and query-change can drive a server/API request.' },
105
+ },
106
+ loading: {
107
+ type: Boolean,
108
+ default: false,
109
+ _edit: { description: 'Show a loading row while server data is being fetched.' },
110
+ },
111
+ virtualRows: {
112
+ type: Boolean,
113
+ default: false,
114
+ _edit: { description: 'Render large in-memory row sets through the isolated virtual row renderer.' },
115
+ },
116
+ virtualRowBuffer: {
117
+ type: Number,
118
+ default: 50,
119
+ _edit: { description: 'Rows kept mounted above and below the visible viewport when virtualRows is true.' },
120
+ },
121
+ virtualRowHeight: {
122
+ type: Number,
123
+ default: null,
124
+ _edit: { description: 'Fixed row height in pixels for virtual row calculations. Defaults to the grid density.' },
125
+ },
126
+ selectable: {
127
+ type: Boolean,
128
+ default: true,
129
+ _edit: { description: 'Show row selection checkboxes.' },
130
+ },
131
+ showRowNumbers: {
132
+ type: Boolean,
133
+ default: true,
134
+ _edit: { description: 'Show spreadsheet-style row numbers.' },
135
+ },
136
+ showColumnLetters: {
137
+ type: Boolean,
138
+ default: true,
139
+ _edit: { description: 'Show spreadsheet-style column letters above headers.' },
140
+ },
141
+ stickyHeader: {
142
+ type: Boolean,
143
+ default: true,
144
+ _edit: { description: 'Keep header rows visible while the grid scrolls.' },
145
+ },
146
+ resizableColumns: {
147
+ type: Boolean,
148
+ default: true,
149
+ _edit: { description: 'Allow users to drag header dividers to resize columns.' },
150
+ },
151
+ minColumnWidth: {
152
+ type: Number,
153
+ default: 40,
154
+ _edit: { description: 'Minimum column width in pixels while resizing. The default leaves room for the filter icon.' },
155
+ },
156
+ toolbar: {
157
+ type: Boolean,
158
+ default: true,
159
+ _edit: { description: 'Show the title, search box, filter chips, and row count toolbar.' },
160
+ },
161
+ title: {
162
+ type: String,
163
+ default: 'Data grid',
164
+ _edit: { description: 'Toolbar title.' },
165
+ },
166
+ resourceLabel: {
167
+ type: String,
168
+ default: 'rows',
169
+ _edit: { description: 'Human label for the resource being rendered.' },
170
+ },
171
+ totalRows: {
172
+ type: Number,
173
+ default: null,
174
+ _edit: { description: 'Server-side total row count when remote is true.' },
175
+ },
176
+ density: {
177
+ type: String,
178
+ default: 'compact',
179
+ validator: (value) => ['compact', 'comfortable'].includes(value),
180
+ _edit: { options: ['compact', 'comfortable'], description: 'Row height density.' },
181
+ },
182
+ height: {
183
+ type: [String, Number],
184
+ default: '34rem',
185
+ _edit: { description: 'Scrollable grid height as a CSS size or pixel number.' },
186
+ },
187
+ emptyText: {
188
+ type: String,
189
+ default: 'No rows match the current filters.',
190
+ _edit: { description: 'Message shown when no rows are visible.' },
191
+ },
192
+ };
193
+ </script>
194
+
195
+ <script setup>
196
+ import { computed, markRaw, nextTick, onBeforeUnmount, onMounted, reactive, ref, useId, watch } from 'vue';
197
+ import DomPopover from '../../components/popover/DomPopover.vue';
198
+ import { resolveResourceKey, resolveResourceValue } from '../_shared/resourceRecord.js';
199
+ import DataGridVirtualRows from './DataGridVirtualRows.vue';
200
+
201
+ defineOptions({
202
+ __doc: {
203
+ name: 'Data grid',
204
+ tag: '<DomDataGrid>',
205
+ description: `A dense spreadsheet-like grid for database records, with typed column filters, sorting, row selection, and a server-query friendly API.
206
+ We talk about databases, rendering pipelines, virtual DOMs, and scalable app architecture. But really, they just want Excel.`,
207
+ icon: 'M4 5h16v14H4V5Zm0 4h16M4 13h16M9 5v14M15 5v14',
208
+ events: [
209
+ { name: 'update:filters', payload: 'Record<string, DataGridFilter>', description: 'Fired when a column filter is applied or cleared.' },
210
+ { name: 'update:sort', payload: 'DataGridSort', description: 'Fired when column sorting changes. Complex sort values may include an ordered rules array.' },
211
+ { name: 'update:search', payload: 'string', description: 'Fired when the toolbar search changes.' },
212
+ { name: 'update:selectedKeys', payload: 'Array<string | number>', description: 'Fired when row selection changes.' },
213
+ { name: 'query-change', payload: 'DataGridQuery', description: 'Fired with the normalized search, filters, and sort state. Use this to call a resource API.' },
214
+ { name: 'viewport-scroll', payload: '{ scrollTop, scrollHeight, clientHeight, remaining, atEnd }', description: 'Fired when the scrollable row viewport scrolls.' },
215
+ { name: 'viewport-end', payload: '{ scrollTop, scrollHeight, clientHeight, remaining, atEnd }', description: 'Fired when the scrollable row viewport reaches the bottom threshold. Useful for cursor pagination and infinite loading.' },
216
+ { name: 'cell-update', payload: '({ row, column, rowIndex, rowKey, value, previousValue })', description: 'Re-emitted when a column component emits update:value.' },
217
+ { name: 'cell-action', payload: '({ row, column, rowIndex, rowKey, value, formatted, action, payload })', description: 'Re-emitted when a column component emits action.' },
218
+ { name: 'column-resize', payload: '({ column, key, width, previousWidth, startWidth })', description: 'Fired while a header divider is dragged and a column width changes.' },
219
+ { name: 'column-resize-end', payload: '({ column, key, width, previousWidth, startWidth })', description: 'Fired when the user finishes resizing a column.' },
220
+ { name: 'render-window-change', payload: '({ startIndex, endIndex, renderedRows, totalRows, firstVisibleIndex, lastVisibleIndex })', description: 'Fired by the virtual row renderer when the mounted row window changes.' },
221
+ { name: 'row-click', payload: '({ row, index })', description: 'Fired when a data row is clicked.' },
222
+ ],
223
+ slots: [
224
+ { name: 'cell', payload: '{ row, column, value, formatted, rowIndex, rowKey }', description: 'Custom cell renderer.' },
225
+ { name: 'toolbar-actions', payload: '{ query, clearFilters }', description: 'Extra controls rendered next to search and Clear filters.' },
226
+ { name: 'empty', description: 'Custom empty state.' },
227
+ ],
228
+ keyboard: [
229
+ { keys: 'Enter / Space on a header', action: 'Toggle sort when the column is sortable.' },
230
+ { keys: 'Enter / Space on a filter button', action: 'Open the typed filter popover.' },
231
+ { keys: 'Esc', action: 'Close an open filter popover.' },
232
+ ],
233
+ },
234
+ });
235
+
236
+ const props = defineProps(dataGridProps);
237
+ const emit = defineEmits([
238
+ 'update:filters',
239
+ 'filter-change',
240
+ 'update:sort',
241
+ 'sort-change',
242
+ 'update:search',
243
+ 'query-change',
244
+ 'viewport-scroll',
245
+ 'viewport-end',
246
+ 'cell-update',
247
+ 'cell-action',
248
+ 'column-resize',
249
+ 'column-resize-end',
250
+ 'render-window-change',
251
+ 'update:selectedKeys',
252
+ 'selection-change',
253
+ 'row-click',
254
+ ]);
255
+
256
+ const titleId = `dom-data-grid-${useId()}`;
257
+ const filterDrafts = reactive({});
258
+ const resizedColumnWidths = reactive({});
259
+ const viewportState = reactive({ scrollTop: 0, clientHeight: 0 });
260
+ const internalFilters = ref({});
261
+ const internalSort = ref(normalizeSort(props.sort));
262
+ const internalSearch = ref(props.search || '');
263
+ const internalSelectedKeys = ref([...props.selectedKeys]);
264
+ const columnResize = ref(null);
265
+ const scrollViewport = ref(null);
266
+ let resizeListenersAttached = false;
267
+ let previousBodyCursor = '';
268
+ let previousBodyUserSelect = '';
269
+
270
+ const sourceRows = computed(() => Array.isArray(props.rows) ? props.rows : []);
271
+ const normalizedColumns = computed(() => {
272
+ const supplied = Array.isArray(props.columns) && props.columns.length
273
+ ? props.columns
274
+ : inferColumns(sourceRows.value);
275
+
276
+ return supplied
277
+ .filter((column) => column && column.hidden !== true && column.key)
278
+ .map((column) => normalizeColumn(column));
279
+ });
280
+ const visibleColumns = computed(() => normalizedColumns.value);
281
+ const activeFilters = computed(() => normalizeFilterMap(internalFilters.value));
282
+ const activeSortRules = computed(() => sortRules(internalSort.value));
283
+ const query = computed(() => ({
284
+ search: internalSearch.value.trim(),
285
+ filters: cloneFilterMap(activeFilters.value),
286
+ sort: activeSortRules.value.length ? cloneSort(internalSort.value) : null,
287
+ }));
288
+ const filteredRows = computed(() => {
289
+ if (props.remote) return sourceRows.value;
290
+ return sourceRows.value.filter((row) => rowMatchesSearch(row) && rowMatchesFilters(row));
291
+ });
292
+ const displayRows = computed(() => {
293
+ if (props.remote) return sourceRows.value;
294
+ if (!activeSortRules.value.length) return filteredRows.value;
295
+
296
+ return [...filteredRows.value].sort((a, b) => compareRows(a, b, activeSortRules.value));
297
+ });
298
+ const remoteTotalRows = computed(() => Number.isFinite(props.totalRows) ? props.totalRows : sourceRows.value.length);
299
+ const shownRowCount = computed(() => props.remote ? sourceRows.value.length : displayRows.value.length);
300
+ const totalRowCount = computed(() => props.remote ? remoteTotalRows.value : sourceRows.value.length);
301
+ const activeFilterEntries = computed(() => visibleColumns.value
302
+ .map((column) => ({
303
+ column,
304
+ filter: activeFilters.value[column.key],
305
+ summary: filterSummary(column, activeFilters.value[column.key]),
306
+ }))
307
+ .filter((entry) => entry.summary));
308
+ const hasActiveFilters = computed(() => activeFilterEntries.value.length > 0 || internalSearch.value.trim().length > 0);
309
+ const selectedKeySet = computed(() => new Set(internalSelectedKeys.value));
310
+ const visibleSelectableKeys = computed(() => displayRows.value.map((row, index) => rowKey(row, index)));
311
+ const allVisibleSelected = computed(() => (
312
+ visibleSelectableKeys.value.length > 0
313
+ && visibleSelectableKeys.value.every((key) => selectedKeySet.value.has(key))
314
+ ));
315
+ const someVisibleSelected = computed(() => (
316
+ visibleSelectableKeys.value.some((key) => selectedKeySet.value.has(key))
317
+ && !allVisibleSelected.value
318
+ ));
319
+ const gridHeightStyle = computed(() => ({
320
+ maxHeight: typeof props.height === 'number' ? `${props.height}px` : props.height,
321
+ }));
322
+ const tableStyle = computed(() => ({
323
+ minWidth: `${tableMinWidth()}px`,
324
+ }));
325
+ const emptyColspan = computed(() => visibleColumns.value.length + (props.selectable ? 1 : 0) + (props.showRowNumbers ? 1 : 0));
326
+ const headerOffset = computed(() => props.showColumnLetters ? '1.75rem' : '0px');
327
+ const virtualRowHeightPx = computed(() => {
328
+ const value = Number(props.virtualRowHeight);
329
+ if (Number.isFinite(value) && value > 0) return value;
330
+ return props.density === 'comfortable' ? 48 : 36;
331
+ });
332
+
333
+ watch(() => props.filters, (value) => {
334
+ internalFilters.value = normalizeFilterMap(value);
335
+ syncFilterDrafts();
336
+ }, { deep: true, immediate: true });
337
+
338
+ watch(() => props.sort, (value) => {
339
+ internalSort.value = normalizeSort(value);
340
+ }, { deep: true });
341
+
342
+ watch(() => props.search, (value) => {
343
+ internalSearch.value = value || '';
344
+ });
345
+
346
+ watch(() => props.selectedKeys, (value) => {
347
+ internalSelectedKeys.value = Array.isArray(value) ? [...value] : [];
348
+ }, { deep: true });
349
+
350
+ watch(visibleColumns, () => {
351
+ internalFilters.value = normalizeFilterMap(internalFilters.value);
352
+ syncFilterDrafts();
353
+ }, { deep: true });
354
+ watch(query, (value) => {
355
+ emit('query-change', cloneQuery(value));
356
+ }, { deep: true });
357
+ watch(() => [props.height, props.virtualRows, props.density, displayRows.value.length], () => {
358
+ nextTick(refreshViewportMetrics);
359
+ });
360
+
361
+ onBeforeUnmount(() => {
362
+ removeColumnResizeListeners();
363
+ columnResize.value = null;
364
+ });
365
+
366
+ onMounted(() => {
367
+ nextTick(refreshViewportMetrics);
368
+ });
369
+
370
+ function inferColumns(rows) {
371
+ const first = rows.find((row) => row && typeof row === 'object');
372
+ if (!first) return [];
373
+ return Object.keys(first).map((key) => ({
374
+ key,
375
+ label: labelFromKey(key),
376
+ type: inferColumnType(rows.map((row) => row?.[key])),
377
+ }));
378
+ }
379
+
380
+ function normalizeColumn(column) {
381
+ const type = column.type || 'text';
382
+ return {
383
+ ...column,
384
+ type,
385
+ label: column.label || labelFromKey(column.key),
386
+ component: column.component ? markRaw(column.component) : null,
387
+ width: columnWidth(column.width, type),
388
+ align: column.align || defaultAlign(type),
389
+ resizable: column.resizable !== false,
390
+ sortable: column.sortable !== false,
391
+ filterable: column.filterable !== false,
392
+ searchable: column.searchable !== false,
393
+ };
394
+ }
395
+
396
+ function inferColumnType(values) {
397
+ const sample = values.find((value) => value !== null && value !== undefined && value !== '');
398
+ if (typeof sample === 'boolean') return 'boolean';
399
+ if (typeof sample === 'number') return 'number';
400
+ if (typeof sample === 'string' && /^\d{4}-\d{2}-\d{2}/.test(sample)) return 'date';
401
+ return 'text';
402
+ }
403
+
404
+ function labelFromKey(key) {
405
+ return String(key)
406
+ .replace(/[_-]+/g, ' ')
407
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
408
+ .replace(/\b\w/g, (char) => char.toUpperCase());
409
+ }
410
+
411
+ function columnWidth(width, type) {
412
+ if (typeof width === 'number') return `${width}px`;
413
+ if (typeof width === 'string' && width.trim()) return width;
414
+
415
+ return {
416
+ boolean: '7rem',
417
+ currency: '8.5rem',
418
+ date: '9.5rem',
419
+ number: '7rem',
420
+ select: '9rem',
421
+ }[type] || '13rem';
422
+ }
423
+
424
+ function defaultAlign(type) {
425
+ if (type === 'number' || type === 'currency') return 'right';
426
+ if (type === 'boolean') return 'center';
427
+ return 'left';
428
+ }
429
+
430
+ function tableMinWidth() {
431
+ const utilityWidth = (props.selectable ? 44 : 0) + (props.showRowNumbers ? 54 : 0);
432
+ const columnWidthTotal = visibleColumns.value.reduce((total, column) => total + columnPixelWidth(column), 0);
433
+ return Math.max(720, utilityWidth + columnWidthTotal);
434
+ }
435
+
436
+ function columnWidthStyle(column) {
437
+ return `${columnPixelWidth(column)}px`;
438
+ }
439
+
440
+ function columnPixelWidth(column) {
441
+ const resizedWidth = resizedColumnWidths[column.key];
442
+ return Number.isFinite(resizedWidth) ? resizedWidth : numericWidth(column.width);
443
+ }
444
+
445
+ function numericWidth(width) {
446
+ if (typeof width === 'number') return width;
447
+ const value = Number.parseFloat(width);
448
+ if (Number.isFinite(value)) {
449
+ if (String(width).includes('rem')) return value * 16;
450
+ return value;
451
+ }
452
+ return 160;
453
+ }
454
+
455
+ function minimumColumnWidth() {
456
+ const value = Number(props.minColumnWidth);
457
+ return Number.isFinite(value) && value > 0 ? value : 40;
458
+ }
459
+
460
+ function canResizeColumn(column) {
461
+ return props.resizableColumns && column.resizable !== false;
462
+ }
463
+
464
+ function startColumnResize(column, event) {
465
+ if (!canResizeColumn(column)) return;
466
+
467
+ event.preventDefault();
468
+ event.stopPropagation();
469
+
470
+ const startWidth = columnPixelWidth(column);
471
+ columnResize.value = {
472
+ key: column.key,
473
+ column,
474
+ startX: event.clientX,
475
+ startWidth,
476
+ width: startWidth,
477
+ };
478
+
479
+ addColumnResizeListeners();
480
+ }
481
+
482
+ function addColumnResizeListeners() {
483
+ if (typeof document === 'undefined') return;
484
+ if (resizeListenersAttached) removeColumnResizeListeners();
485
+
486
+ resizeListenersAttached = true;
487
+ previousBodyCursor = document.body.style.cursor;
488
+ previousBodyUserSelect = document.body.style.userSelect;
489
+ document.body.style.cursor = 'col-resize';
490
+ document.body.style.userSelect = 'none';
491
+ document.addEventListener('pointermove', updateColumnResize);
492
+ document.addEventListener('pointerup', finishColumnResize);
493
+ document.addEventListener('pointercancel', finishColumnResize);
494
+ }
495
+
496
+ function removeColumnResizeListeners() {
497
+ if (!resizeListenersAttached || typeof document === 'undefined') return;
498
+
499
+ resizeListenersAttached = false;
500
+ document.removeEventListener('pointermove', updateColumnResize);
501
+ document.removeEventListener('pointerup', finishColumnResize);
502
+ document.removeEventListener('pointercancel', finishColumnResize);
503
+ document.body.style.cursor = previousBodyCursor;
504
+ document.body.style.userSelect = previousBodyUserSelect;
505
+ }
506
+
507
+ function updateColumnResize(event) {
508
+ const resize = columnResize.value;
509
+ if (!resize) return;
510
+
511
+ event.preventDefault();
512
+
513
+ const width = Math.max(
514
+ minimumColumnWidth(),
515
+ Math.round(resize.startWidth + event.clientX - resize.startX),
516
+ );
517
+
518
+ if (width === resize.width) return;
519
+
520
+ const previousWidth = resize.width;
521
+ resize.width = width;
522
+ resizedColumnWidths[resize.key] = width;
523
+
524
+ emit('column-resize', {
525
+ column: resize.column,
526
+ key: resize.key,
527
+ width,
528
+ previousWidth,
529
+ startWidth: resize.startWidth,
530
+ });
531
+ }
532
+
533
+ function finishColumnResize(event) {
534
+ if (event) event.preventDefault();
535
+
536
+ const resize = columnResize.value;
537
+ removeColumnResizeListeners();
538
+ if (!resize) return;
539
+
540
+ columnResize.value = null;
541
+ emit('column-resize-end', {
542
+ column: resize.column,
543
+ key: resize.key,
544
+ width: resize.width,
545
+ previousWidth: resize.startWidth,
546
+ startWidth: resize.startWidth,
547
+ });
548
+ }
549
+
550
+ /**
551
+ * Resolves the stable key used by selection and row rendering.
552
+ *
553
+ * @param {Record<string, unknown>} row Resource row to identify.
554
+ * @param {number} index Source row index.
555
+ * @returns {string|number} Stable row key.
556
+ */
557
+ function rowKey(row, index) {
558
+ return resolveResourceKey(row, props.rowKey, index);
559
+ }
560
+
561
+ /**
562
+ * Reads a column value through its accessor or logical resource field.
563
+ *
564
+ * @param {Record<string, unknown>} row Resource row to inspect.
565
+ * @param {DataGridColumn} column Column definition.
566
+ * @returns {unknown} Raw cell value.
567
+ */
568
+ function cellValue(row, column) {
569
+ if (typeof column.accessor === 'function') return column.accessor(row, column);
570
+ return resolveResourceValue(row, column.field || column.key);
571
+ }
572
+
573
+ function formattedCell(row, column) {
574
+ const value = cellValue(row, column);
575
+ if (typeof column.format === 'function') return column.format(value, row, column);
576
+ if (value === null || value === undefined || value === '') return '';
577
+
578
+ if (column.type === 'currency') {
579
+ const amount = Number(value);
580
+ if (!Number.isFinite(amount)) return String(value);
581
+ return new Intl.NumberFormat(column.locale || 'en-GB', {
582
+ style: 'currency',
583
+ currency: column.currency || 'GBP',
584
+ maximumFractionDigits: amount >= 1000 ? 0 : 2,
585
+ }).format(amount);
586
+ }
587
+
588
+ if (column.type === 'number') {
589
+ const number = Number(value);
590
+ return Number.isFinite(number) ? new Intl.NumberFormat(column.locale || 'en-GB').format(number) : String(value);
591
+ }
592
+
593
+ if (column.type === 'date') return formatDateValue(value, column.locale);
594
+ if (column.type === 'boolean') return value ? 'Yes' : 'No';
595
+ if (column.type === 'select') return optionLabel(column, value);
596
+ return String(value);
597
+ }
598
+
599
+ function formatDateValue(value, locale = 'en-GB') {
600
+ const date = dateFromValue(value);
601
+ if (!date) return String(value || '');
602
+ return new Intl.DateTimeFormat(locale, {
603
+ day: '2-digit',
604
+ month: 'short',
605
+ year: 'numeric',
606
+ }).format(date);
607
+ }
608
+
609
+ function dateFromValue(value) {
610
+ if (value instanceof Date && !Number.isNaN(value.getTime())) return value;
611
+ if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value)) {
612
+ const [year, month, day] = value.slice(0, 10).split('-').map(Number);
613
+ const date = new Date(year, month - 1, day);
614
+ return Number.isNaN(date.getTime()) ? null : date;
615
+ }
616
+ const date = new Date(value);
617
+ return Number.isNaN(date.getTime()) ? null : date;
618
+ }
619
+
620
+ function dateKey(value) {
621
+ const date = dateFromValue(value);
622
+ if (!date) return '';
623
+ const month = String(date.getMonth() + 1).padStart(2, '0');
624
+ const day = String(date.getDate()).padStart(2, '0');
625
+ return `${date.getFullYear()}-${month}-${day}`;
626
+ }
627
+
628
+ function optionRecords(column) {
629
+ if (Array.isArray(column.options) && column.options.length) {
630
+ return column.options.map((option) => normalizeOption(option));
631
+ }
632
+
633
+ const seen = new Set();
634
+ return sourceRows.value
635
+ .map((row) => cellValue(row, column))
636
+ .filter((value) => value !== null && value !== undefined && value !== '')
637
+ .filter((value) => {
638
+ const key = String(value);
639
+ if (seen.has(key)) return false;
640
+ seen.add(key);
641
+ return true;
642
+ })
643
+ .map((value) => normalizeOption(value));
644
+ }
645
+
646
+ function normalizeOption(option) {
647
+ if (option && typeof option === 'object') {
648
+ return {
649
+ ...option,
650
+ value: option.value ?? option.label,
651
+ label: option.label ?? option.value,
652
+ };
653
+ }
654
+ return { value: option, label: String(option) };
655
+ }
656
+
657
+ function optionLabel(column, value) {
658
+ const option = optionRecords(column).find((item) => String(item.value) === String(value));
659
+ return option?.label ?? String(value ?? '');
660
+ }
661
+
662
+ function optionTone(column, value) {
663
+ const option = optionRecords(column).find((item) => String(item.value) === String(value));
664
+ return option?.tone || '';
665
+ }
666
+
667
+ function toneClass(tone) {
668
+ return {
669
+ blue: 'bg-sky-500/10 text-sky-700 ring-sky-500/20 dark:text-sky-300',
670
+ green: 'bg-emerald-500/10 text-emerald-700 ring-emerald-500/20 dark:text-emerald-300',
671
+ amber: 'bg-amber-500/15 text-amber-800 ring-amber-500/20 dark:text-amber-300',
672
+ red: 'bg-rose-500/10 text-rose-700 ring-rose-500/20 dark:text-rose-300',
673
+ violet: 'bg-violet-500/10 text-violet-700 ring-violet-500/20 dark:text-violet-300',
674
+ neutral: 'bg-secondary text-muted-fg ring-border',
675
+ }[tone] || 'bg-secondary text-muted-fg ring-border';
676
+ }
677
+
678
+ function normalizeSort(sort) {
679
+ const source = Array.isArray(sort) ? { rules: sort } : sort && typeof sort === 'object' ? sort : {};
680
+ const parentDirection = normalizeSortDirection(source.direction);
681
+ const rules = Array.isArray(source.rules)
682
+ ? source.rules
683
+ .map((rule) => normalizeSortRule(rule, parentDirection || 'asc'))
684
+ .filter(Boolean)
685
+ : [];
686
+
687
+ if (rules.length) {
688
+ return {
689
+ ...source,
690
+ key: source.key ? String(source.key) : rules[0].key,
691
+ direction: parentDirection || rules[0].direction,
692
+ rules,
693
+ };
694
+ }
695
+
696
+ const direction = normalizeSortDirection(source.direction);
697
+ const key = direction ? String(source.key || '') : '';
698
+ return {
699
+ ...source,
700
+ key,
701
+ direction: key ? direction : '',
702
+ };
703
+ }
704
+
705
+ function normalizeSortRule(rule, fallbackDirection = '') {
706
+ if (!rule || typeof rule !== 'object') return null;
707
+
708
+ const direction = normalizeSortDirection(rule.direction) || fallbackDirection;
709
+ const key = rule.key !== undefined && rule.key !== null && rule.key !== ''
710
+ ? String(rule.key)
711
+ : rule.field !== undefined && rule.field !== null && rule.field !== ''
712
+ ? String(rule.field)
713
+ : '';
714
+
715
+ if (!direction || (!key && typeof rule.accessor !== 'function' && typeof rule.compare !== 'function')) return null;
716
+
717
+ return {
718
+ ...rule,
719
+ key,
720
+ direction,
721
+ };
722
+ }
723
+
724
+ function normalizeSortDirection(direction) {
725
+ return direction === 'desc' ? 'desc' : direction === 'asc' ? 'asc' : '';
726
+ }
727
+
728
+ function sortRules(sort) {
729
+ if (!sort || typeof sort !== 'object') return [];
730
+ if (Array.isArray(sort.rules) && sort.rules.length) return sort.rules;
731
+ if (!sort.key || !sort.direction) return [];
732
+ return [normalizeSortRule(sort)].filter(Boolean);
733
+ }
734
+
735
+ function toggleSort(column) {
736
+ if (!column.sortable) return;
737
+ if (props.multiSort) {
738
+ toggleMultiSort(column);
739
+ return;
740
+ }
741
+
742
+ const current = internalSort.value;
743
+ let next = { key: column.key, direction: 'asc' };
744
+
745
+ if (current.key === column.key && current.direction === 'asc') {
746
+ next = { key: column.key, direction: 'desc' };
747
+ } else if (current.key === column.key && current.direction === 'desc') {
748
+ next = { key: '', direction: '' };
749
+ }
750
+
751
+ internalSort.value = normalizeSort(next);
752
+ emit('update:sort', cloneSort(internalSort.value));
753
+ emit('sort-change', cloneSort(internalSort.value));
754
+ }
755
+
756
+ function toggleMultiSort(column) {
757
+ const rules = activeSortRules.value.map((rule) => ({ ...rule }));
758
+ const ruleIndex = rules.findIndex((rule) => rule.key === column.key);
759
+
760
+ if (ruleIndex === -1) {
761
+ rules.push(sortRuleForColumn(column, 'asc'));
762
+ } else if (rules[ruleIndex].direction === 'asc') {
763
+ rules[ruleIndex] = { ...rules[ruleIndex], direction: 'desc' };
764
+ } else {
765
+ rules.splice(ruleIndex, 1);
766
+ }
767
+
768
+ const next = sortFromRules(rules);
769
+ internalSort.value = normalizeSort(next);
770
+ emit('update:sort', cloneSort(internalSort.value));
771
+ emit('sort-change', cloneSort(internalSort.value));
772
+ }
773
+
774
+ function sortRuleForColumn(column, direction) {
775
+ return {
776
+ key: column.key,
777
+ direction,
778
+ ...(column.field && column.field !== column.key ? { field: column.field } : {}),
779
+ };
780
+ }
781
+
782
+ function sortFromRules(rules) {
783
+ const firstRule = rules[0];
784
+ return {
785
+ key: firstRule?.key || '',
786
+ direction: firstRule?.direction || '',
787
+ rules,
788
+ };
789
+ }
790
+
791
+ function ariaSort(column) {
792
+ const primaryRule = activeSortRules.value[0];
793
+ if (!primaryRule || primaryRule.key !== column.key) {
794
+ return activeSortRules.value.some((rule) => rule.key === column.key) ? 'other' : 'none';
795
+ }
796
+
797
+ return primaryRule.direction === 'asc' ? 'ascending' : 'descending';
798
+ }
799
+
800
+ function sortDirectionForColumn(column) {
801
+ if (internalSort.value.key === column.key && internalSort.value.direction) return internalSort.value.direction;
802
+ const rule = activeSortRules.value.find((item) => item.key === column.key);
803
+ return rule?.direction || '';
804
+ }
805
+
806
+ function sortRankForColumn(column) {
807
+ const index = activeSortRules.value.findIndex((rule) => rule.key === column.key);
808
+ return index >= 0 ? index + 1 : 0;
809
+ }
810
+
811
+ function sortButtonLabel(column) {
812
+ const direction = sortDirectionForColumn(column);
813
+ if (direction === 'asc') return `Sort ${column.label} descending`;
814
+ if (direction === 'desc') return props.multiSort ? `Remove ${column.label} sort` : `Clear ${column.label} sort`;
815
+ return `Sort ${column.label} ascending`;
816
+ }
817
+
818
+ function compareRows(a, b, rules) {
819
+ for (const rule of rules) {
820
+ const result = compareRowsByRule(a, b, rule);
821
+ if (result !== 0) return result;
822
+ }
823
+ return 0;
824
+ }
825
+
826
+ function compareRowsByRule(a, b, rule) {
827
+ const column = visibleColumns.value.find((item) => item.key === rule.key);
828
+
829
+ if (typeof rule.compare === 'function') {
830
+ const result = Number(rule.compare(a, b, rule, { column, columns: visibleColumns.value }));
831
+ if (Number.isFinite(result) && result !== 0) return rule.direction === 'desc' ? result * -1 : result;
832
+ return 0;
833
+ }
834
+
835
+ const av = sortableValue(sortRuleValue(a, column, rule), column || rule);
836
+ const bv = sortableValue(sortRuleValue(b, column, rule), column || rule);
837
+ const result = av > bv ? 1 : av < bv ? -1 : 0;
838
+ return rule.direction === 'desc' ? result * -1 : result;
839
+ }
840
+
841
+ function sortRuleValue(row, column, rule) {
842
+ if (typeof rule.accessor === 'function') return rule.accessor(row, column || rule);
843
+ if (column && typeof column.sortAccessor === 'function') return column.sortAccessor(row, column);
844
+ if (column) return cellValue(row, column);
845
+ return row?.[rule.field || rule.key];
846
+ }
847
+
848
+ function sortableValue(value, column) {
849
+ if (value === null || value === undefined || value === '') return '';
850
+ if (column.type === 'number' || column.type === 'currency') {
851
+ const number = Number(value);
852
+ return Number.isFinite(number) ? number : 0;
853
+ }
854
+ if (column.type === 'date') return dateKey(value);
855
+ if (column.type === 'boolean') return value ? 1 : 0;
856
+ return String(value).toLowerCase();
857
+ }
858
+
859
+ function rowMatchesSearch(row) {
860
+ const needle = internalSearch.value.trim().toLowerCase();
861
+ if (!needle) return true;
862
+
863
+ return visibleColumns.value
864
+ .filter((column) => column.searchable)
865
+ .some((column) => String(formattedCell(row, column)).toLowerCase().includes(needle));
866
+ }
867
+
868
+ function rowMatchesFilters(row) {
869
+ return visibleColumns.value.every((column) => rowMatchesFilter(row, column));
870
+ }
871
+
872
+ function rowMatchesFilter(row, column) {
873
+ if (!column.filterable) return true;
874
+
875
+ const filter = activeFilters.value[column.key];
876
+ if (!isFilterActive(filter, column)) return true;
877
+
878
+ const value = cellValue(row, column);
879
+ const type = column.type;
880
+
881
+ if (type === 'number' || type === 'currency') {
882
+ const number = Number(value);
883
+ if (!Number.isFinite(number)) return false;
884
+ if (filter.min !== '' && filter.min !== null && filter.min !== undefined && number < Number(filter.min)) return false;
885
+ if (filter.max !== '' && filter.max !== null && filter.max !== undefined && number > Number(filter.max)) return false;
886
+ return true;
887
+ }
888
+
889
+ if (type === 'date') {
890
+ const key = dateKey(value);
891
+ if (!key) return false;
892
+ if (filter.start && key < filter.start) return false;
893
+ if (filter.end && key > filter.end) return false;
894
+ return true;
895
+ }
896
+
897
+ if (type === 'boolean') {
898
+ if (filter.value === '' || filter.value === null || filter.value === undefined) return true;
899
+ return Boolean(value) === parseBoolean(filter.value);
900
+ }
901
+
902
+ if (type === 'select') {
903
+ const values = Array.isArray(filter.values) ? filter.values : [];
904
+ if (!values.length) return true;
905
+ return values.some((item) => String(item) === String(value));
906
+ }
907
+
908
+ const haystack = String(value ?? '').toLowerCase();
909
+ const needle = String(filter.value ?? '').trim().toLowerCase();
910
+ if (!needle) return true;
911
+ if (filter.operator === 'equals') return haystack === needle;
912
+ if (filter.operator === 'starts') return haystack.startsWith(needle);
913
+ return haystack.includes(needle);
914
+ }
915
+
916
+ function parseBoolean(value) {
917
+ return value === true || value === 'true';
918
+ }
919
+
920
+ function defaultFilterForColumn(column) {
921
+ if (column.type === 'number' || column.type === 'currency') return { type: column.type, min: '', max: '' };
922
+ if (column.type === 'date') return { type: 'date', start: '', end: '' };
923
+ if (column.type === 'boolean') return { type: 'boolean', value: '' };
924
+ if (column.type === 'select') return { type: 'select', values: [] };
925
+ return { type: 'text', operator: 'contains', value: '' };
926
+ }
927
+
928
+ function normalizeFilter(column, filter) {
929
+ return {
930
+ ...defaultFilterForColumn(column),
931
+ ...(filter || {}),
932
+ type: column.type,
933
+ };
934
+ }
935
+
936
+ function normalizeFilterMap(filters) {
937
+ const next = {};
938
+ const source = filters && typeof filters === 'object' ? filters : {};
939
+ for (const column of visibleColumns.value) {
940
+ if (!column.filterable) continue;
941
+
942
+ const filter = normalizeFilter(column, source[column.key]);
943
+ if (isFilterActive(filter, column)) next[column.key] = filter;
944
+ }
945
+ return next;
946
+ }
947
+
948
+ function isFilterActive(filter, column) {
949
+ if (!filter) return false;
950
+ const type = column?.type || filter.type;
951
+ if (type === 'number' || type === 'currency') return filter.min !== '' && filter.min !== null && filter.min !== undefined || filter.max !== '' && filter.max !== null && filter.max !== undefined;
952
+ if (type === 'date') return Boolean(filter.start || filter.end);
953
+ if (type === 'boolean') return filter.value !== '' && filter.value !== null && filter.value !== undefined;
954
+ if (type === 'select') return Array.isArray(filter.values) && filter.values.length > 0;
955
+ return String(filter.value ?? '').trim().length > 0;
956
+ }
957
+
958
+ function syncFilterDrafts() {
959
+ const availableKeys = new Set(visibleColumns.value.filter((column) => column.filterable).map((column) => column.key));
960
+ for (const key of Object.keys(filterDrafts)) {
961
+ if (!availableKeys.has(key)) delete filterDrafts[key];
962
+ }
963
+
964
+ for (const column of visibleColumns.value) {
965
+ if (!column.filterable) continue;
966
+
967
+ filterDrafts[column.key] = {
968
+ ...defaultFilterForColumn(column),
969
+ ...(internalFilters.value[column.key] || {}),
970
+ };
971
+ }
972
+ }
973
+
974
+ function applyFilter(column) {
975
+ if (!column.filterable) return;
976
+
977
+ const draft = filterDrafts[column.key] || defaultFilterForColumn(column);
978
+ const next = {
979
+ ...internalFilters.value,
980
+ [column.key]: normalizeFilter(column, draft),
981
+ };
982
+
983
+ if (!isFilterActive(next[column.key], column)) delete next[column.key];
984
+ setFilters(next);
985
+ }
986
+
987
+ function clearColumnFilter(column) {
988
+ const next = { ...internalFilters.value };
989
+ delete next[column.key];
990
+ setFilters(next);
991
+ filterDrafts[column.key] = defaultFilterForColumn(column);
992
+ }
993
+
994
+ function clearFilters() {
995
+ setFilters({});
996
+ internalSearch.value = '';
997
+ emit('update:search', '');
998
+ syncFilterDrafts();
999
+ }
1000
+
1001
+ function setFilters(next) {
1002
+ internalFilters.value = normalizeFilterMap(next);
1003
+ const cloned = cloneFilterMap(internalFilters.value);
1004
+ emit('update:filters', cloned);
1005
+ emit('filter-change', cloned);
1006
+ }
1007
+
1008
+ function updateSearch(value) {
1009
+ internalSearch.value = value;
1010
+ emit('update:search', value);
1011
+ }
1012
+
1013
+ function filterSummary(column, filter) {
1014
+ if (!isFilterActive(filter, column)) return '';
1015
+ if (column.type === 'number' || column.type === 'currency') {
1016
+ const min = filter.min !== '' && filter.min !== null && filter.min !== undefined ? `>= ${filter.min}` : '';
1017
+ const max = filter.max !== '' && filter.max !== null && filter.max !== undefined ? `<= ${filter.max}` : '';
1018
+ return [min, max].filter(Boolean).join(' ');
1019
+ }
1020
+ if (column.type === 'date') {
1021
+ if (filter.start && filter.end) return `${filter.start} to ${filter.end}`;
1022
+ if (filter.start) return `From ${filter.start}`;
1023
+ return `Until ${filter.end}`;
1024
+ }
1025
+ if (column.type === 'boolean') return parseBoolean(filter.value) ? 'Yes' : 'No';
1026
+ if (column.type === 'select') return filter.values.map((value) => optionLabel(column, value)).join(', ');
1027
+ return `${filter.operator === 'equals' ? '=' : filter.operator === 'starts' ? 'Starts' : 'Contains'} ${filter.value}`;
1028
+ }
1029
+
1030
+ function cloneFilterMap(filters) {
1031
+ return Object.fromEntries(Object.entries(filters || {}).map(([key, value]) => [key, cloneFilter(value)]));
1032
+ }
1033
+
1034
+ function cloneFilter(filter) {
1035
+ return {
1036
+ ...filter,
1037
+ ...(Array.isArray(filter?.values) ? { values: [...filter.values] } : {}),
1038
+ };
1039
+ }
1040
+
1041
+ function cloneQuery(value) {
1042
+ return {
1043
+ search: value.search,
1044
+ filters: cloneFilterMap(value.filters),
1045
+ sort: cloneSort(value.sort),
1046
+ };
1047
+ }
1048
+
1049
+ function cloneSort(sort) {
1050
+ if (!sort) return null;
1051
+ return {
1052
+ ...sort,
1053
+ ...(Array.isArray(sort.rules) ? { rules: sort.rules.map((rule) => ({ ...rule })) } : {}),
1054
+ };
1055
+ }
1056
+
1057
+ function toggleDraftOption(column, value) {
1058
+ const draft = filterDrafts[column.key];
1059
+ if (!draft) return;
1060
+ const values = new Set(Array.isArray(draft.values) ? draft.values.map(String) : []);
1061
+ const key = String(value);
1062
+ if (values.has(key)) values.delete(key);
1063
+ else values.add(key);
1064
+ draft.values = [...values];
1065
+ }
1066
+
1067
+ function setDraftBoolean(column, value) {
1068
+ if (!filterDrafts[column.key]) filterDrafts[column.key] = defaultFilterForColumn(column);
1069
+ filterDrafts[column.key].value = value;
1070
+ }
1071
+
1072
+ function toggleRowSelection(row, index) {
1073
+ const key = rowKey(row, index);
1074
+ const next = new Set(internalSelectedKeys.value);
1075
+ if (next.has(key)) next.delete(key);
1076
+ else next.add(key);
1077
+ setSelectedKeys([...next]);
1078
+ }
1079
+
1080
+ function toggleVisibleSelection() {
1081
+ if (allVisibleSelected.value) {
1082
+ const visible = new Set(visibleSelectableKeys.value);
1083
+ setSelectedKeys(internalSelectedKeys.value.filter((key) => !visible.has(key)));
1084
+ return;
1085
+ }
1086
+
1087
+ setSelectedKeys([...new Set([...internalSelectedKeys.value, ...visibleSelectableKeys.value])]);
1088
+ }
1089
+
1090
+ function setSelectedKeys(keys) {
1091
+ internalSelectedKeys.value = keys;
1092
+ const cloned = [...keys];
1093
+ emit('update:selectedKeys', cloned);
1094
+ emit('selection-change', cloned);
1095
+ }
1096
+
1097
+ function rowSelected(row, index) {
1098
+ return selectedKeySet.value.has(rowKey(row, index));
1099
+ }
1100
+
1101
+ function columnLetter(index) {
1102
+ let value = '';
1103
+ let number = index + 1;
1104
+ while (number > 0) {
1105
+ const modulo = (number - 1) % 26;
1106
+ value = String.fromCharCode(65 + modulo) + value;
1107
+ number = Math.floor((number - modulo) / 26);
1108
+ }
1109
+ return value;
1110
+ }
1111
+
1112
+ function cellAlignClass(column) {
1113
+ return {
1114
+ left: 'text-left',
1115
+ center: 'text-center',
1116
+ right: 'text-right tabular-nums',
1117
+ }[column.align] || 'text-left';
1118
+ }
1119
+
1120
+ function rowDensityClass() {
1121
+ return props.density === 'comfortable' ? 'h-12' : 'h-9';
1122
+ }
1123
+
1124
+ function refreshViewportMetrics() {
1125
+ if (!scrollViewport.value) return;
1126
+ updateViewportMetrics(scrollViewport.value);
1127
+ }
1128
+
1129
+ function updateViewportMetrics(element) {
1130
+ viewportState.scrollTop = element.scrollTop;
1131
+ viewportState.clientHeight = element.clientHeight;
1132
+ }
1133
+
1134
+ function onViewportScroll(event) {
1135
+ const element = event.currentTarget;
1136
+ updateViewportMetrics(element);
1137
+
1138
+ const remaining = Math.max(0, element.scrollHeight - element.scrollTop - element.clientHeight);
1139
+ const payload = {
1140
+ scrollTop: element.scrollTop,
1141
+ scrollHeight: element.scrollHeight,
1142
+ clientHeight: element.clientHeight,
1143
+ remaining,
1144
+ atEnd: remaining <= 32,
1145
+ };
1146
+
1147
+ emit('viewport-scroll', payload);
1148
+ if (payload.atEnd) emit('viewport-end', payload);
1149
+ }
1150
+
1151
+ function cellContext(row, column, rowIndex) {
1152
+ return {
1153
+ row,
1154
+ column,
1155
+ value: cellValue(row, column),
1156
+ formatted: formattedCell(row, column),
1157
+ rowIndex,
1158
+ rowKey: rowKey(row, rowIndex),
1159
+ };
1160
+ }
1161
+
1162
+ function onCellComponentUpdate(row, column, rowIndex, value) {
1163
+ emit('cell-update', {
1164
+ ...cellContext(row, column, rowIndex),
1165
+ value,
1166
+ previousValue: cellValue(row, column),
1167
+ });
1168
+ }
1169
+
1170
+ function onCellComponentAction(row, column, rowIndex, payload) {
1171
+ emit('cell-action', {
1172
+ ...cellContext(row, column, rowIndex),
1173
+ action: payload?.action ?? payload?.type ?? payload,
1174
+ payload,
1175
+ });
1176
+ }
1177
+ </script>
1178
+
1179
+ <template>
1180
+ <section
1181
+ class="dom-data-grid overflow-hidden rounded-lg border border-border bg-canvas text-canvas-fg shadow-sm"
1182
+ :aria-labelledby="titleId"
1183
+ >
1184
+ <header v-if="toolbar" class="border-b border-border bg-secondary/35 px-3 py-3">
1185
+ <div class="flex flex-wrap items-center gap-3">
1186
+ <div class="min-w-0 flex-1">
1187
+ <h2 :id="titleId" class="truncate text-sm font-semibold tracking-tight text-canvas-fg">{{ title }}</h2>
1188
+ <p class="mt-0.5 text-xs text-muted-fg">
1189
+ {{ shownRowCount }} of {{ totalRowCount }} {{ resourceLabel }}
1190
+ <span v-if="remote">from resource API</span>
1191
+ </p>
1192
+ </div>
1193
+
1194
+ <label class="relative min-w-[14rem] flex-1 sm:max-w-xs">
1195
+ <span class="sr-only">Search rows</span>
1196
+ <svg class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-fg" viewBox="0 0 24 24" fill="none" aria-hidden="true">
1197
+ <path d="m21 21-4.3-4.3M10.8 18a7.2 7.2 0 1 1 0-14.4 7.2 7.2 0 0 1 0 14.4Z" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
1198
+ </svg>
1199
+ <input
1200
+ :value="internalSearch"
1201
+ type="search"
1202
+ class="skin-input h-9 rounded-md pl-9"
1203
+ placeholder="Search rows"
1204
+ @input="updateSearch($event.target.value)"
1205
+ />
1206
+ </label>
1207
+
1208
+ <slot name="toolbar-actions" :query="query" :clear-filters="clearFilters" />
1209
+
1210
+ <button
1211
+ v-if="hasActiveFilters"
1212
+ type="button"
1213
+ class="skin-control inline-flex h-9 items-center gap-2 rounded-md px-3 text-xs font-semibold transition hover:brightness-[0.98] focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
1214
+ @click="clearFilters"
1215
+ >
1216
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
1217
+ <path d="M6 6l12 12M18 6 6 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
1218
+ </svg>
1219
+ Clear filters
1220
+ </button>
1221
+ </div>
1222
+
1223
+ <div v-if="activeFilterEntries.length" class="mt-3 flex flex-wrap gap-2">
1224
+ <span
1225
+ v-for="entry in activeFilterEntries"
1226
+ :key="entry.column.key"
1227
+ class="inline-flex max-w-full items-center gap-1.5 rounded-md border border-sky-500/20 bg-sky-500/10 px-2 py-1 text-xs font-medium text-sky-800 dark:text-sky-200"
1228
+ >
1229
+ <span class="truncate">{{ entry.column.label }}: {{ entry.summary }}</span>
1230
+ <button
1231
+ type="button"
1232
+ class="grid size-4 shrink-0 place-items-center rounded text-current/70 transition hover:bg-sky-500/15 hover:text-current focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
1233
+ :aria-label="`Clear ${entry.column.label} filter`"
1234
+ @click="clearColumnFilter(entry.column)"
1235
+ >
1236
+ <svg class="size-3" viewBox="0 0 24 24" fill="none" aria-hidden="true">
1237
+ <path d="M6 6l12 12M18 6 6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
1238
+ </svg>
1239
+ </button>
1240
+ </span>
1241
+ </div>
1242
+ </header>
1243
+
1244
+ <div ref="scrollViewport" class="overflow-auto pb-3" :style="gridHeightStyle" @scroll="onViewportScroll">
1245
+ <table class="w-full table-fixed border-separate border-spacing-0 text-sm" :style="tableStyle">
1246
+ <colgroup>
1247
+ <col v-if="selectable" class="w-11" />
1248
+ <col v-if="showRowNumbers" class="w-[3.375rem]" />
1249
+ <col
1250
+ v-for="column in visibleColumns"
1251
+ :key="column.key"
1252
+ :style="{ width: columnWidthStyle(column), minWidth: columnWidthStyle(column) }"
1253
+ />
1254
+ </colgroup>
1255
+
1256
+ <thead class="text-xs">
1257
+ <tr v-if="showColumnLetters" class="bg-secondary/60 text-muted-fg">
1258
+ <th
1259
+ v-if="selectable"
1260
+ class="border-b border-r border-border px-2 py-1.5 font-semibold"
1261
+ :class="stickyHeader && 'sticky top-0 z-20 bg-secondary/95 backdrop-blur'"
1262
+ ></th>
1263
+ <th
1264
+ v-if="showRowNumbers"
1265
+ class="border-b border-r border-border px-2 py-1.5 text-center font-semibold"
1266
+ :class="stickyHeader && 'sticky top-0 z-20 bg-secondary/95 backdrop-blur'"
1267
+ ></th>
1268
+ <th
1269
+ v-for="(column, columnIndex) in visibleColumns"
1270
+ :key="`${column.key}-letter`"
1271
+ class="border-b border-r border-border px-2 py-1.5 text-center font-semibold last:border-r-0"
1272
+ :class="stickyHeader && 'sticky top-0 z-20 bg-secondary/95 backdrop-blur'"
1273
+ >
1274
+ {{ columnLetter(columnIndex) }}
1275
+ </th>
1276
+ </tr>
1277
+
1278
+ <tr class="bg-canvas text-muted-fg">
1279
+ <th
1280
+ v-if="selectable"
1281
+ class="border-b border-r border-border px-2 text-center"
1282
+ :class="stickyHeader && 'sticky z-20 bg-canvas/95 backdrop-blur'"
1283
+ :style="stickyHeader ? { top: headerOffset } : null"
1284
+ >
1285
+ <input
1286
+ type="checkbox"
1287
+ class="size-4 rounded border-border text-primary accent-primary"
1288
+ :checked="allVisibleSelected"
1289
+ :aria-checked="someVisibleSelected ? 'mixed' : allVisibleSelected"
1290
+ aria-label="Select visible rows"
1291
+ @change="toggleVisibleSelection"
1292
+ />
1293
+ </th>
1294
+ <th
1295
+ v-if="showRowNumbers"
1296
+ class="border-b border-r border-border px-2 text-center font-semibold"
1297
+ :class="stickyHeader && 'sticky z-20 bg-canvas/95 backdrop-blur'"
1298
+ :style="stickyHeader ? { top: headerOffset } : null"
1299
+ >
1300
+ #
1301
+ </th>
1302
+ <th
1303
+ v-for="column in visibleColumns"
1304
+ :key="column.key"
1305
+ scope="col"
1306
+ class="relative overflow-hidden border-b border-r border-border px-0 text-left align-middle last:border-r-0"
1307
+ :aria-sort="ariaSort(column)"
1308
+ :class="stickyHeader && 'sticky z-20 bg-canvas/95 backdrop-blur'"
1309
+ :style="stickyHeader ? { top: headerOffset } : null"
1310
+ >
1311
+ <div
1312
+ class="relative flex min-w-0 items-center overflow-hidden px-1 py-1.5"
1313
+ :class="column.filterable ? 'pr-9' : 'pr-1'"
1314
+ >
1315
+ <button
1316
+ type="button"
1317
+ class="flex min-w-0 w-full items-center gap-1.5 overflow-hidden rounded-md px-1 py-1 text-left font-semibold text-canvas-fg transition focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
1318
+ :class="column.sortable ? 'hover:bg-secondary' : 'cursor-default'"
1319
+ :disabled="!column.sortable"
1320
+ :aria-label="sortButtonLabel(column)"
1321
+ :title="sortButtonLabel(column)"
1322
+ @click="toggleSort(column)"
1323
+ >
1324
+ <span class="truncate">{{ column.label }}</span>
1325
+ <span
1326
+ v-if="multiSort && sortRankForColumn(column)"
1327
+ class="grid size-4 shrink-0 place-items-center rounded bg-primary text-[10px] font-semibold leading-none text-primary-fg"
1328
+ aria-hidden="true"
1329
+ >
1330
+ {{ sortRankForColumn(column) }}
1331
+ </span>
1332
+ <svg
1333
+ v-if="column.sortable"
1334
+ class="size-3.5 shrink text-muted-fg"
1335
+ viewBox="0 0 24 24"
1336
+ fill="none"
1337
+ aria-hidden="true"
1338
+ >
1339
+ <path
1340
+ v-if="sortDirectionForColumn(column) === 'asc'"
1341
+ d="m7 14 5-5 5 5"
1342
+ stroke="currentColor"
1343
+ stroke-width="2"
1344
+ stroke-linecap="round"
1345
+ stroke-linejoin="round"
1346
+ />
1347
+ <path
1348
+ v-else-if="sortDirectionForColumn(column) === 'desc'"
1349
+ d="m7 10 5 5 5-5"
1350
+ stroke="currentColor"
1351
+ stroke-width="2"
1352
+ stroke-linecap="round"
1353
+ stroke-linejoin="round"
1354
+ />
1355
+ <path
1356
+ v-else
1357
+ d="m8 9 4-4 4 4M16 15l-4 4-4-4"
1358
+ stroke="currentColor"
1359
+ stroke-width="1.8"
1360
+ stroke-linecap="round"
1361
+ stroke-linejoin="round"
1362
+ />
1363
+ </svg>
1364
+ </button>
1365
+
1366
+ <div v-if="column.filterable" class="absolute right-1 top-1/2 -translate-y-1/2">
1367
+ <DomPopover
1368
+ position="bottom-end"
1369
+ width="w-[20rem]"
1370
+ padding="p-0"
1371
+ :arrow="false"
1372
+ >
1373
+ <template #trigger>
1374
+ <button
1375
+ type="button"
1376
+ class="relative grid size-7 shrink-0 place-items-center rounded-md border transition focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
1377
+ :class="activeFilters[column.key]
1378
+ ? 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300'
1379
+ : 'border-transparent text-muted-fg hover:border-border hover:bg-secondary hover:text-canvas-fg'"
1380
+ :aria-label="`Filter ${column.label}`"
1381
+ >
1382
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
1383
+ <path d="M4 5h16l-6.2 7.1V18l-3.6 1.8v-7.7L4 5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" />
1384
+ </svg>
1385
+ <span
1386
+ v-if="activeFilters[column.key]"
1387
+ class="absolute right-1 top-1 size-1.5 rounded-full bg-sky-500"
1388
+ ></span>
1389
+ </button>
1390
+ </template>
1391
+
1392
+ <template #default="{ close }">
1393
+ <div v-if="filterDrafts[column.key]" class="grid gap-4 p-4 text-sm">
1394
+ <div>
1395
+ <p class="font-semibold text-canvas-fg">Filter {{ column.label }}</p>
1396
+ </div>
1397
+
1398
+ <div v-if="column.type === 'number' || column.type === 'currency'" class="grid grid-cols-2 gap-3">
1399
+ <label class="grid gap-1.5">
1400
+ <span class="text-xs font-medium text-muted-fg">Minimum</span>
1401
+ <input v-model="filterDrafts[column.key].min" type="number" class="skin-input h-9 rounded-md" placeholder="Any" />
1402
+ </label>
1403
+ <label class="grid gap-1.5">
1404
+ <span class="text-xs font-medium text-muted-fg">Maximum</span>
1405
+ <input v-model="filterDrafts[column.key].max" type="number" class="skin-input h-9 rounded-md" placeholder="Any" />
1406
+ </label>
1407
+ </div>
1408
+
1409
+ <div v-else-if="column.type === 'date'" class="grid grid-cols-2 gap-3">
1410
+ <label class="grid gap-1.5">
1411
+ <span class="text-xs font-medium text-muted-fg">From</span>
1412
+ <input v-model="filterDrafts[column.key].start" type="date" class="skin-input h-9 rounded-md" />
1413
+ </label>
1414
+ <label class="grid gap-1.5">
1415
+ <span class="text-xs font-medium text-muted-fg">To</span>
1416
+ <input v-model="filterDrafts[column.key].end" type="date" class="skin-input h-9 rounded-md" />
1417
+ </label>
1418
+ </div>
1419
+
1420
+ <div v-else-if="column.type === 'boolean'" class="grid grid-cols-3 gap-1 rounded-md border border-border bg-secondary/40 p-1">
1421
+ <button
1422
+ v-for="option in [{ label: 'Any', value: '' }, { label: 'Yes', value: true }, { label: 'No', value: false }]"
1423
+ :key="option.label"
1424
+ type="button"
1425
+ class="h-8 rounded px-2 text-xs font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
1426
+ :class="filterDrafts[column.key].value === option.value ? 'bg-canvas text-canvas-fg shadow-xs' : 'text-muted-fg hover:text-canvas-fg'"
1427
+ @click="setDraftBoolean(column, option.value)"
1428
+ >
1429
+ {{ option.label }}
1430
+ </button>
1431
+ </div>
1432
+
1433
+ <div v-else-if="column.type === 'select'" class="grid gap-2">
1434
+ <p class="text-xs font-medium text-muted-fg">Values</p>
1435
+ <div class="max-h-52 overflow-y-auto rounded-md border border-border bg-canvas p-1">
1436
+ <label
1437
+ v-for="option in optionRecords(column)"
1438
+ :key="String(option.value)"
1439
+ class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm transition hover:bg-secondary"
1440
+ >
1441
+ <input
1442
+ type="checkbox"
1443
+ class="size-4 rounded border-border accent-primary"
1444
+ :checked="filterDrafts[column.key].values?.map(String).includes(String(option.value))"
1445
+ @change="toggleDraftOption(column, option.value)"
1446
+ />
1447
+ <span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
1448
+ <span v-if="option.tone" class="size-2 rounded-full" :class="toneClass(option.tone)"></span>
1449
+ </label>
1450
+ </div>
1451
+ </div>
1452
+
1453
+ <div v-else class="grid gap-3">
1454
+ <label class="grid gap-1.5">
1455
+ <span class="text-xs font-medium text-muted-fg">Match</span>
1456
+ <select v-model="filterDrafts[column.key].operator" class="skin-input h-9 rounded-md">
1457
+ <option value="contains">Contains</option>
1458
+ <option value="equals">Equals</option>
1459
+ <option value="starts">Starts with</option>
1460
+ </select>
1461
+ </label>
1462
+ <label class="grid gap-1.5">
1463
+ <span class="text-xs font-medium text-muted-fg">Value</span>
1464
+ <input v-model="filterDrafts[column.key].value" type="text" class="skin-input h-9 rounded-md" placeholder="Search this column" />
1465
+ </label>
1466
+ </div>
1467
+
1468
+ <div class="flex items-center justify-between gap-2 border-t border-border pt-3">
1469
+ <button
1470
+ type="button"
1471
+ class="inline-flex h-9 items-center rounded-md px-3 text-xs font-semibold text-muted-fg transition hover:bg-secondary hover:text-canvas-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
1472
+ @click="clearColumnFilter(column); close();"
1473
+ >
1474
+ Clear
1475
+ </button>
1476
+ <button
1477
+ type="button"
1478
+ class="skin-action inline-flex h-9 items-center rounded-md px-3 text-xs font-semibold transition hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
1479
+ @click="applyFilter(column); close();"
1480
+ >
1481
+ Apply filter
1482
+ </button>
1483
+ </div>
1484
+ </div>
1485
+ </template>
1486
+ </DomPopover>
1487
+ </div>
1488
+ </div>
1489
+ <span
1490
+ v-if="canResizeColumn(column)"
1491
+ class="absolute inset-y-0 right-0 z-30 block w-2 cursor-col-resize touch-none transition-opacity"
1492
+ :class="columnResize?.key === column.key ? 'opacity-100' : 'opacity-0 hover:opacity-100'"
1493
+ @pointerdown="startColumnResize(column, $event)"
1494
+ @click.stop
1495
+ >
1496
+ <span class="mx-auto block h-full w-px bg-primary/60"></span>
1497
+ </span>
1498
+ </th>
1499
+ </tr>
1500
+ </thead>
1501
+
1502
+ <DataGridVirtualRows
1503
+ v-if="virtualRows && !loading && displayRows.length"
1504
+ :rows="displayRows"
1505
+ :columns="visibleColumns"
1506
+ :row-key="rowKey"
1507
+ :cell-value="cellValue"
1508
+ :formatted-cell="formattedCell"
1509
+ :cell-align-class="cellAlignClass"
1510
+ :row-density-class="rowDensityClass"
1511
+ :selected-keys="internalSelectedKeys"
1512
+ :selectable="selectable"
1513
+ :show-row-numbers="showRowNumbers"
1514
+ :scroll-top="viewportState.scrollTop"
1515
+ :viewport-height="viewportState.clientHeight"
1516
+ :row-height="virtualRowHeightPx"
1517
+ :buffer-rows="virtualRowBuffer"
1518
+ @toggle-row-selection="toggleRowSelection($event.row, $event.index)"
1519
+ @row-click="emit('row-click', { row: $event.row, index: $event.index })"
1520
+ @render-window-change="emit('render-window-change', $event)"
1521
+ >
1522
+ <template #cell="{ row, column, value, formatted, rowIndex, rowKey: currentRowKey }">
1523
+ <slot
1524
+ name="cell"
1525
+ :row="row"
1526
+ :column="column"
1527
+ :value="value"
1528
+ :formatted="formatted"
1529
+ :row-index="rowIndex"
1530
+ :row-key="currentRowKey"
1531
+ >
1532
+ <component
1533
+ :is="column.component"
1534
+ v-if="column.component"
1535
+ :row="row"
1536
+ :column="column"
1537
+ :value="value"
1538
+ :formatted="formatted"
1539
+ :row-index="rowIndex"
1540
+ :row-key="currentRowKey"
1541
+ @update:value="onCellComponentUpdate(row, column, rowIndex, $event)"
1542
+ @action="onCellComponentAction(row, column, rowIndex, $event)"
1543
+ />
1544
+ <span v-else-if="column.type === 'boolean'" class="inline-flex items-center justify-center rounded-md px-2 py-0.5 text-xs font-semibold ring-1" :class="value ? toneClass('green') : toneClass('neutral')">
1545
+ {{ formatted }}
1546
+ </span>
1547
+ <span v-else-if="column.type === 'select'" class="inline-flex max-w-full items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-semibold ring-1" :class="toneClass(optionTone(column, value))">
1548
+ <span class="size-1.5 rounded-full bg-current opacity-70"></span>
1549
+ <span class="truncate">{{ formatted }}</span>
1550
+ </span>
1551
+ <span v-else-if="formatted" class="block max-w-full truncate">{{ formatted }}</span>
1552
+ <span v-else class="text-muted-fg/60">-</span>
1553
+ </slot>
1554
+ </template>
1555
+ </DataGridVirtualRows>
1556
+
1557
+ <tbody v-else>
1558
+ <tr v-if="loading">
1559
+ <td :colspan="emptyColspan" class="border-b border-border px-4 py-10 text-center text-sm text-muted-fg">
1560
+ Loading {{ resourceLabel }}...
1561
+ </td>
1562
+ </tr>
1563
+ <tr v-else-if="!displayRows.length">
1564
+ <td :colspan="emptyColspan" class="px-4 py-12 text-center text-sm text-muted-fg">
1565
+ <slot name="empty">{{ emptyText }}</slot>
1566
+ </td>
1567
+ </tr>
1568
+ <template v-else>
1569
+ <tr
1570
+ v-for="(row, rowIndex) in displayRows"
1571
+ :key="rowKey(row, rowIndex)"
1572
+ class="group bg-canvas transition hover:bg-secondary/45"
1573
+ :class="rowSelected(row, rowIndex) && 'bg-sky-500/5'"
1574
+ :aria-selected="rowSelected(row, rowIndex) || undefined"
1575
+ @click="emit('row-click', { row, index: rowIndex })"
1576
+ >
1577
+ <td v-if="selectable" class="border-b border-r border-border px-2 text-center" :class="rowDensityClass()" @click.stop>
1578
+ <input
1579
+ type="checkbox"
1580
+ class="size-4 rounded border-border accent-primary"
1581
+ :checked="rowSelected(row, rowIndex)"
1582
+ :aria-label="`Select row ${rowIndex + 1}`"
1583
+ @change="toggleRowSelection(row, rowIndex)"
1584
+ />
1585
+ </td>
1586
+ <td
1587
+ v-if="showRowNumbers"
1588
+ class="border-b border-r border-border bg-secondary/25 px-2 text-center text-xs font-medium text-muted-fg"
1589
+ :class="rowDensityClass()"
1590
+ >
1591
+ {{ rowIndex + 1 }}
1592
+ </td>
1593
+ <td
1594
+ v-for="column in visibleColumns"
1595
+ :key="column.key"
1596
+ class="overflow-hidden border-b border-r border-border px-3 align-middle last:border-r-0"
1597
+ :class="[cellAlignClass(column), rowDensityClass()]"
1598
+ >
1599
+ <slot
1600
+ name="cell"
1601
+ :row="row"
1602
+ :column="column"
1603
+ :value="cellValue(row, column)"
1604
+ :formatted="formattedCell(row, column)"
1605
+ :row-index="rowIndex"
1606
+ :row-key="rowKey(row, rowIndex)"
1607
+ >
1608
+ <component
1609
+ :is="column.component"
1610
+ v-if="column.component"
1611
+ :row="row"
1612
+ :column="column"
1613
+ :value="cellValue(row, column)"
1614
+ :formatted="formattedCell(row, column)"
1615
+ :row-index="rowIndex"
1616
+ :row-key="rowKey(row, rowIndex)"
1617
+ @update:value="onCellComponentUpdate(row, column, rowIndex, $event)"
1618
+ @action="onCellComponentAction(row, column, rowIndex, $event)"
1619
+ />
1620
+ <span v-else-if="column.type === 'boolean'" class="inline-flex items-center justify-center rounded-md px-2 py-0.5 text-xs font-semibold ring-1" :class="cellValue(row, column) ? toneClass('green') : toneClass('neutral')">
1621
+ {{ formattedCell(row, column) }}
1622
+ </span>
1623
+ <span v-else-if="column.type === 'select'" class="inline-flex max-w-full items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-semibold ring-1" :class="toneClass(optionTone(column, cellValue(row, column)))">
1624
+ <span class="size-1.5 rounded-full bg-current opacity-70"></span>
1625
+ <span class="truncate">{{ formattedCell(row, column) }}</span>
1626
+ </span>
1627
+ <span v-else-if="formattedCell(row, column)" class="block max-w-full truncate">{{ formattedCell(row, column) }}</span>
1628
+ <span v-else class="text-muted-fg/60">-</span>
1629
+ </slot>
1630
+ </td>
1631
+ </tr>
1632
+ </template>
1633
+ </tbody>
1634
+ </table>
1635
+ </div>
1636
+
1637
+ <footer class="flex flex-wrap items-center justify-between gap-2 border-t border-border bg-secondary/25 px-3 py-2 text-xs text-muted-fg">
1638
+ <span>{{ visibleColumns.length }} columns</span>
1639
+ <span v-if="internalSelectedKeys.length">{{ internalSelectedKeys.length }} selected</span>
1640
+ <span v-else>Ready</span>
1641
+ </footer>
1642
+ </section>
1643
+ </template>