@cocoar/vue-ui 3.1.0 → 3.2.0-beta.11

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 (180) hide show
  1. package/bin/cli.mjs +53 -0
  2. package/dist/components/checkbox/CoarCheckbox.vue.d.ts +1 -1
  3. package/dist/components/checkbox/CoarCheckbox.vue.d.ts.map +1 -1
  4. package/dist/components/code-block/CoarCodeBlock.vue.d.ts +1 -1
  5. package/dist/components/code-block/CoarCodeBlock.vue.d.ts.map +1 -1
  6. package/dist/components/data-list/CoarDataList.vue.d.ts +158 -0
  7. package/dist/components/data-list/CoarDataList.vue.d.ts.map +1 -0
  8. package/dist/components/data-list/CoarDataListToolbar.vue.d.ts +30 -0
  9. package/dist/components/data-list/CoarDataListToolbar.vue.d.ts.map +1 -0
  10. package/dist/components/data-list/data-list-builder.d.ts +288 -0
  11. package/dist/components/data-list/data-list-builder.d.ts.map +1 -0
  12. package/dist/components/data-list/index.d.ts +12 -0
  13. package/dist/components/data-list/index.d.ts.map +1 -0
  14. package/dist/components/data-list/internal/compare.d.ts +14 -0
  15. package/dist/components/data-list/internal/compare.d.ts.map +1 -0
  16. package/dist/components/data-list/internal/reorder-core.d.ts +46 -0
  17. package/dist/components/data-list/internal/reorder-core.d.ts.map +1 -0
  18. package/dist/components/data-list/internal/search.d.ts +16 -0
  19. package/dist/components/data-list/internal/search.d.ts.map +1 -0
  20. package/dist/components/data-list/internal/useDataListLines.d.ts +92 -0
  21. package/dist/components/data-list/internal/useDataListLines.d.ts.map +1 -0
  22. package/dist/components/data-list/internal/useDataListReorder.d.ts +68 -0
  23. package/dist/components/data-list/internal/useDataListReorder.d.ts.map +1 -0
  24. package/dist/components/data-list/internal/useSearchHighlight.d.ts +19 -0
  25. package/dist/components/data-list/internal/useSearchHighlight.d.ts.map +1 -0
  26. package/dist/components/data-list/types.d.ts +185 -0
  27. package/dist/components/data-list/types.d.ts.map +1 -0
  28. package/dist/components/data-list/useDataListModel.d.ts +79 -0
  29. package/dist/components/data-list/useDataListModel.d.ts.map +1 -0
  30. package/dist/components/date-time/_shared/maskito-config.d.ts +7 -2
  31. package/dist/components/date-time/_shared/maskito-config.d.ts.map +1 -1
  32. package/dist/components/date-time/_shared/time-helpers.d.ts +11 -4
  33. package/dist/components/date-time/_shared/time-helpers.d.ts.map +1 -1
  34. package/dist/components/date-time/_shared/use-date-picker-base.d.ts +1 -1
  35. package/dist/components/date-time/plain-date-picker/CoarPlainDatePicker.vue.d.ts +1 -1
  36. package/dist/components/date-time/plain-date-picker/CoarPlainDatePicker.vue.d.ts.map +1 -1
  37. package/dist/components/date-time/plain-date-time-picker/CoarPlainDateTimePicker.vue.d.ts +1 -1
  38. package/dist/components/date-time/plain-date-time-picker/CoarPlainDateTimePicker.vue.d.ts.map +1 -1
  39. package/dist/components/date-time/zoned-date-time-picker/CoarZonedDateTimePicker.vue.d.ts +1 -1
  40. package/dist/components/date-time/zoned-date-time-picker/CoarZonedDateTimePicker.vue.d.ts.map +1 -1
  41. package/dist/components/dual-listbox/CoarDualListbox.vue.d.ts +3 -0
  42. package/dist/components/dual-listbox/CoarDualListbox.vue.d.ts.map +1 -1
  43. package/dist/components/form-field/CoarFormField.vue.d.ts +1 -1
  44. package/dist/components/form-field/CoarFormField.vue.d.ts.map +1 -1
  45. package/dist/components/icon/CoarIcon.vue.d.ts +2 -2
  46. package/dist/components/icon/CoarIcon.vue.d.ts.map +1 -1
  47. package/dist/components/listbox/CoarListbox.vue.d.ts +7 -0
  48. package/dist/components/listbox/CoarListbox.vue.d.ts.map +1 -1
  49. package/dist/components/number-input/CoarNumberInput.vue.d.ts +1 -1
  50. package/dist/components/number-input/CoarNumberInput.vue.d.ts.map +1 -1
  51. package/dist/components/otp-input/CoarOtpInput.vue.d.ts +1 -1
  52. package/dist/components/otp-input/CoarOtpInput.vue.d.ts.map +1 -1
  53. package/dist/components/password-input/CoarPasswordInput.vue.d.ts +1 -1
  54. package/dist/components/password-input/CoarPasswordInput.vue.d.ts.map +1 -1
  55. package/dist/components/switch/CoarSwitch.vue.d.ts +1 -1
  56. package/dist/components/switch/CoarSwitch.vue.d.ts.map +1 -1
  57. package/dist/components/text-input/CoarTextInput.vue.d.ts +1 -1
  58. package/dist/components/text-input/CoarTextInput.vue.d.ts.map +1 -1
  59. package/dist/composables/dragRegistry.d.ts +12 -0
  60. package/dist/composables/dragRegistry.d.ts.map +1 -1
  61. package/dist/composables/index.d.ts +2 -2
  62. package/dist/composables/index.d.ts.map +1 -1
  63. package/dist/composables/useDragDrop.d.ts +62 -6
  64. package/dist/composables/useDragDrop.d.ts.map +1 -1
  65. package/dist/composables/useVirtualList.d.ts +21 -1
  66. package/dist/composables/useVirtualList.d.ts.map +1 -1
  67. package/dist/index.css +1 -1
  68. package/dist/index.d.ts +4 -2
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +4508 -2039
  71. package/package.json +9 -3
  72. package/skills/cocoar-vue-ui/SKILL.md +244 -0
  73. package/skills/cocoar-vue-ui/references/components/avatar.md +178 -0
  74. package/skills/cocoar-vue-ui/references/components/badge.md +290 -0
  75. package/skills/cocoar-vue-ui/references/components/breadcrumb.md +238 -0
  76. package/skills/cocoar-vue-ui/references/components/button.md +252 -0
  77. package/skills/cocoar-vue-ui/references/components/calendar/agenda-view.md +372 -0
  78. package/skills/cocoar-vue-ui/references/components/calendar/coar-calendar.md +1647 -0
  79. package/skills/cocoar-vue-ui/references/components/calendar/day-view.md +361 -0
  80. package/skills/cocoar-vue-ui/references/components/calendar/index.md +312 -0
  81. package/skills/cocoar-vue-ui/references/components/calendar/month-view.md +486 -0
  82. package/skills/cocoar-vue-ui/references/components/calendar/performance.md +186 -0
  83. package/skills/cocoar-vue-ui/references/components/calendar/timeline-view.md +301 -0
  84. package/skills/cocoar-vue-ui/references/components/calendar/week-view.md +382 -0
  85. package/skills/cocoar-vue-ui/references/components/calendar/work-week-view.md +145 -0
  86. package/skills/cocoar-vue-ui/references/components/calendar/year-view.md +63 -0
  87. package/skills/cocoar-vue-ui/references/components/card.md +151 -0
  88. package/skills/cocoar-vue-ui/references/components/checkbox-group.md +103 -0
  89. package/skills/cocoar-vue-ui/references/components/checkbox.md +279 -0
  90. package/skills/cocoar-vue-ui/references/components/code-block.md +241 -0
  91. package/skills/cocoar-vue-ui/references/components/context-menu.md +355 -0
  92. package/skills/cocoar-vue-ui/references/components/data-grid/checkbox.md +213 -0
  93. package/skills/cocoar-vue-ui/references/components/data-grid/date-columns.md +272 -0
  94. package/skills/cocoar-vue-ui/references/components/data-grid/editing.md +236 -0
  95. package/skills/cocoar-vue-ui/references/components/data-grid/multi-select.md +193 -0
  96. package/skills/cocoar-vue-ui/references/components/data-grid/number.md +147 -0
  97. package/skills/cocoar-vue-ui/references/components/data-grid/select.md +202 -0
  98. package/skills/cocoar-vue-ui/references/components/data-grid/text.md +127 -0
  99. package/skills/cocoar-vue-ui/references/components/data-grid.md +1225 -0
  100. package/skills/cocoar-vue-ui/references/components/data-list.md +2121 -0
  101. package/skills/cocoar-vue-ui/references/components/date-or-time-picker.md +200 -0
  102. package/skills/cocoar-vue-ui/references/components/date-picker.md +203 -0
  103. package/skills/cocoar-vue-ui/references/components/date-time-picker.md +154 -0
  104. package/skills/cocoar-vue-ui/references/components/date-views.md +212 -0
  105. package/skills/cocoar-vue-ui/references/components/dialog.md +148 -0
  106. package/skills/cocoar-vue-ui/references/components/divider.md +157 -0
  107. package/skills/cocoar-vue-ui/references/components/document-viewer/annotations.md +400 -0
  108. package/skills/cocoar-vue-ui/references/components/document-viewer/coar-document-viewer.md +327 -0
  109. package/skills/cocoar-vue-ui/references/components/document-viewer/index.md +234 -0
  110. package/skills/cocoar-vue-ui/references/components/document-viewer/toolbar.md +221 -0
  111. package/skills/cocoar-vue-ui/references/components/drag-drop.md +267 -0
  112. package/skills/cocoar-vue-ui/references/components/dual-listbox.md +449 -0
  113. package/skills/cocoar-vue-ui/references/components/file-explorer/asset-store.md +327 -0
  114. package/skills/cocoar-vue-ui/references/components/file-explorer/in-memory-store.md +176 -0
  115. package/skills/cocoar-vue-ui/references/components/file-explorer/index.md +311 -0
  116. package/skills/cocoar-vue-ui/references/components/file-explorer/use-file-explorer.md +300 -0
  117. package/skills/cocoar-vue-ui/references/components/form-field.md +1025 -0
  118. package/skills/cocoar-vue-ui/references/components/fragment-parser.md +298 -0
  119. package/skills/cocoar-vue-ui/references/components/link.md +235 -0
  120. package/skills/cocoar-vue-ui/references/components/listbox.md +575 -0
  121. package/skills/cocoar-vue-ui/references/components/map/editor.md +448 -0
  122. package/skills/cocoar-vue-ui/references/components/map/index.md +351 -0
  123. package/skills/cocoar-vue-ui/references/components/markdown-diagrams.md +210 -0
  124. package/skills/cocoar-vue-ui/references/components/markdown-editor.md +1478 -0
  125. package/skills/cocoar-vue-ui/references/components/markdown-embeds.md +387 -0
  126. package/skills/cocoar-vue-ui/references/components/markdown-form.md +447 -0
  127. package/skills/cocoar-vue-ui/references/components/markdown.md +276 -0
  128. package/skills/cocoar-vue-ui/references/components/menu.md +380 -0
  129. package/skills/cocoar-vue-ui/references/components/mermaid.md +172 -0
  130. package/skills/cocoar-vue-ui/references/components/navbar.md +147 -0
  131. package/skills/cocoar-vue-ui/references/components/note.md +96 -0
  132. package/skills/cocoar-vue-ui/references/components/notice.md +131 -0
  133. package/skills/cocoar-vue-ui/references/components/number-input.md +215 -0
  134. package/skills/cocoar-vue-ui/references/components/otp-input.md +324 -0
  135. package/skills/cocoar-vue-ui/references/components/page-builder/authoring-contract.md +271 -0
  136. package/skills/cocoar-vue-ui/references/components/page-builder/coar-page-builder.md +476 -0
  137. package/skills/cocoar-vue-ui/references/components/page-builder/coar-page-renderer.md +677 -0
  138. package/skills/cocoar-vue-ui/references/components/page-builder/custom-elements.md +441 -0
  139. package/skills/cocoar-vue-ui/references/components/page-builder/idp-integration.md +122 -0
  140. package/skills/cocoar-vue-ui/references/components/page-builder/index.md +573 -0
  141. package/skills/cocoar-vue-ui/references/components/pagination.md +150 -0
  142. package/skills/cocoar-vue-ui/references/components/panel-layout.md +372 -0
  143. package/skills/cocoar-vue-ui/references/components/password-input.md +175 -0
  144. package/skills/cocoar-vue-ui/references/components/popconfirm.md +186 -0
  145. package/skills/cocoar-vue-ui/references/components/popover.md +173 -0
  146. package/skills/cocoar-vue-ui/references/components/progress-bar.md +178 -0
  147. package/skills/cocoar-vue-ui/references/components/radio-group.md +225 -0
  148. package/skills/cocoar-vue-ui/references/components/script-editor.md +1271 -0
  149. package/skills/cocoar-vue-ui/references/components/segmented-control.md +238 -0
  150. package/skills/cocoar-vue-ui/references/components/select.md +463 -0
  151. package/skills/cocoar-vue-ui/references/components/sidebar.md +421 -0
  152. package/skills/cocoar-vue-ui/references/components/spinner.md +132 -0
  153. package/skills/cocoar-vue-ui/references/components/switch.md +195 -0
  154. package/skills/cocoar-vue-ui/references/components/table.md +170 -0
  155. package/skills/cocoar-vue-ui/references/components/tabs.md +382 -0
  156. package/skills/cocoar-vue-ui/references/components/tag.md +178 -0
  157. package/skills/cocoar-vue-ui/references/components/text-input.md +256 -0
  158. package/skills/cocoar-vue-ui/references/components/toast.md +160 -0
  159. package/skills/cocoar-vue-ui/references/components/tooltip.md +121 -0
  160. package/skills/cocoar-vue-ui/references/components/transitions.md +193 -0
  161. package/skills/cocoar-vue-ui/references/components/tree.md +2388 -0
  162. package/skills/cocoar-vue-ui/references/components/virtual-list.md +212 -0
  163. package/skills/cocoar-vue-ui/references/components/wizard.md +251 -0
  164. package/skills/cocoar-vue-ui/references/components/zoned-date-time-picker.md +177 -0
  165. package/skills/cocoar-vue-ui/references/foundations/colors.md +708 -0
  166. package/skills/cocoar-vue-ui/references/foundations/design-principles.md +115 -0
  167. package/skills/cocoar-vue-ui/references/foundations/icons.md +381 -0
  168. package/skills/cocoar-vue-ui/references/foundations/localization/formatting.md +530 -0
  169. package/skills/cocoar-vue-ui/references/foundations/localization/setup.md +86 -0
  170. package/skills/cocoar-vue-ui/references/foundations/localization/timezones.md +182 -0
  171. package/skills/cocoar-vue-ui/references/foundations/localization/translations.md +305 -0
  172. package/skills/cocoar-vue-ui/references/foundations/motion.md +549 -0
  173. package/skills/cocoar-vue-ui/references/foundations/spacing.md +330 -0
  174. package/skills/cocoar-vue-ui/references/foundations/theming.md +140 -0
  175. package/skills/cocoar-vue-ui/references/foundations/typography.md +206 -0
  176. package/skills/cocoar-vue-ui/references/guide/error-handling.md +162 -0
  177. package/skills/cocoar-vue-ui/references/guide/getting-started.md +116 -0
  178. package/skills/cocoar-vue-ui/references/guide/migration-page-builder-3.md +207 -0
  179. package/skills/cocoar-vue-ui/references/guide/migration.md +140 -0
  180. package/skills/cocoar-vue-ui/references/guide/theming.md +98 -0
@@ -0,0 +1,2121 @@
1
+ <!-- Generated from apps/docs/components/data-list.md by apps/docs/scripts/sync-skill.mjs. Do not edit; edit the docs page. -->
2
+
3
+ # Data List
4
+
5
+ A list of records where **you own the row layout**. Where [`CoarDataGrid`](./data-grid.md) squeezes every field into a column, `CoarDataList` gives each record a free-form, multi-line template — the right tool when a notebook screen would otherwise show a dozen tiny columns and a horizontal scrollbar. It brings the parts a hand-rolled list keeps reinventing: virtual scrolling with measured row heights, search, a sort menu, keyboard and pointer selection, grouping, and an empty state.
6
+
7
+ ```ts
8
+ import { CoarDataList, useDataList, useDataListModel } from '@cocoar/vue-ui';
9
+ import type { CoarDataListSortOption, CoarDataListSort, CoarDataListKey } from '@cocoar/vue-ui';
10
+ ```
11
+
12
+ Two ways to configure it: **props + `v-model`** for simple lists, or the **fluent builder** from `useDataList()` — the same pattern as `CoarGridBuilder` and `useTree()` — which also gives you declarative context menus and an imperative `api`.
13
+
14
+ ## Builder API
15
+
16
+ `useDataList<T>()` returns `{ builder, api }`. Configure data, behaviour, appearance, handlers and context menus in one chain; the template is just `<CoarDataList :builder>` plus your item slot. The list renders the `<CoarContextMenu>` itself.
17
+
18
+ **Demo — `data-list/demos/BuilderDataList.vue`**
19
+
20
+ ```vue
21
+ <template>
22
+ <div class="demo">
23
+ <CoarDataList :builder="builder">
24
+ <template #toolbar-right>
25
+ <CoarButton variant="secondary" size="s" :disabled="api.selectedItems.value.length === 0" @click="archiveSelected">
26
+ Archive {{ api.selectedItems.value.length || '' }}
27
+ </CoarButton>
28
+ </template>
29
+
30
+ <template #item="{ item }">
31
+ <div class="order">
32
+ <div class="order__primary">
33
+ <span class="order__number">#{{ item.number }}</span>
34
+ <span class="order__customer">{{ item.customer }}</span>
35
+ <span class="order__total">{{ item.total.toFixed(2) }} €</span>
36
+ </div>
37
+ <div class="order__secondary">
38
+ <CoarTag :variant="item.paid ? 'success' : 'warning'" size="s">{{ item.paid ? 'Paid' : 'Open' }}</CoarTag>
39
+ <span>{{ item.placed }}</span>
40
+ <span class="order__items">{{ item.items }} items</span>
41
+ </div>
42
+ </div>
43
+ </template>
44
+ </CoarDataList>
45
+ <p class="demo__hint">{{ hint }}</p>
46
+ </div>
47
+ </template>
48
+
49
+ <script setup lang="ts">
50
+ import { ref } from 'vue';
51
+ import { CoarButton, CoarDataList, CoarTag, useDataList } from '@cocoar/vue-ui';
52
+
53
+ interface Order {
54
+ number: number;
55
+ customer: string;
56
+ total: number;
57
+ paid: boolean;
58
+ placed: string;
59
+ items: number;
60
+ archived: boolean;
61
+ }
62
+
63
+ const customers = ['Vienna Gateway', 'Danube Relay', 'Pacific Node', 'Atlantic Edge', 'Nordic Mesh'];
64
+
65
+ const orders = ref<Order[]>(
66
+ Array.from({ length: 40 }, (_, index) => ({
67
+ number: 5000 + index,
68
+ customer: customers[(index * 7) % customers.length],
69
+ total: 40 + ((index * 137) % 900) + 0.5,
70
+ paid: index % 3 !== 0,
71
+ placed: `2026-08-${String(1 + (index % 28)).padStart(2, '0')}`,
72
+ items: 1 + (index % 6),
73
+ archived: false,
74
+ })),
75
+ );
76
+
77
+ const hint = ref('Right-click an order, or select several and use the toolbar button.');
78
+
79
+ const { builder, api } = useDataList<Order>();
80
+
81
+ builder
82
+ .items(orders)
83
+ .itemKey((order) => order.number)
84
+ .filter((order) => !order.archived)
85
+ .searchBy(['number', 'customer'])
86
+ .sortOption('placed', 'Date', { defaultDirection: 'desc' })
87
+ .sortOption('total', 'Total', { defaultDirection: 'desc' })
88
+ .sortOption('customer', 'Customer')
89
+ .sort({ key: 'placed', direction: 'desc' })
90
+ .groupBy((order) => (order.paid ? 'Paid' : 'Open'))
91
+ .selection('multiple')
92
+ .showSearch()
93
+ .showSort()
94
+ .searchHighlight()
95
+ .dividers()
96
+ .bordered()
97
+ .height('22rem')
98
+ .ariaLabel('Orders')
99
+ .onItemActivate((e) => { hint.value = `Opened order #${e.item.number}`; })
100
+ .itemMenu((order, selectedItems) => [
101
+ { label: `Open #${order.number}`, icon: 'external-link', onClick: () => { hint.value = `Opened order #${order.number}`; } },
102
+ { label: order.paid ? 'Mark as open' : 'Mark as paid', icon: 'check', onClick: () => { order.paid = !order.paid; } },
103
+ 'divider',
104
+ {
105
+ label: selectedItems.length > 1 ? `Archive ${selectedItems.length} orders` : 'Archive',
106
+ icon: 'x',
107
+ danger: true,
108
+ onClick: archiveSelected,
109
+ },
110
+ ])
111
+ .viewportMenu(() => [
112
+ { label: 'Select all', icon: 'check', onClick: () => api.selectAll() },
113
+ { label: 'Clear selection', onClick: () => api.clearSelection() },
114
+ ]);
115
+
116
+ function archiveSelected() {
117
+ const count = api.selectedItems.value.length;
118
+ for (const order of api.selectedItems.value) order.archived = true;
119
+ api.clearSelection();
120
+ hint.value = `Archived ${count} order(s).`;
121
+ }
122
+ </script>
123
+
124
+ <style scoped>
125
+ .demo {
126
+ display: flex;
127
+ flex-direction: column;
128
+ gap: var(--coar-spacing-s);
129
+ }
130
+
131
+ .demo__hint {
132
+ margin: 0;
133
+ color: var(--coar-text-neutral-secondary);
134
+ font-size: var(--coar-body-caption-size);
135
+ }
136
+
137
+ .order {
138
+ display: grid;
139
+ gap: var(--coar-spacing-xxs);
140
+ min-width: 0;
141
+ }
142
+
143
+ .order__primary,
144
+ .order__secondary {
145
+ display: flex;
146
+ align-items: center;
147
+ gap: var(--coar-spacing-s);
148
+ min-width: 0;
149
+ }
150
+
151
+ .order__number {
152
+ font-variant-numeric: tabular-nums;
153
+ color: var(--coar-text-neutral-secondary);
154
+ }
155
+
156
+ .order__customer {
157
+ flex: 1;
158
+ min-width: 0;
159
+ overflow: hidden;
160
+ text-overflow: ellipsis;
161
+ white-space: nowrap;
162
+ font-weight: var(--coar-font-weight-semibold);
163
+ }
164
+
165
+ .order__total {
166
+ font-variant-numeric: tabular-nums;
167
+ }
168
+
169
+ .order__secondary {
170
+ color: var(--coar-text-neutral-secondary);
171
+ font-size: var(--coar-body-caption-size);
172
+ }
173
+
174
+ .order__items {
175
+ margin-left: auto;
176
+ }
177
+ </style>
178
+ ```
179
+
180
+ ```ts
181
+ const { builder, api } = useDataList<Order>();
182
+
183
+ builder
184
+ .items(orders) // array, ref or getter
185
+ .itemKey((o) => o.number)
186
+ .filter((o) => !o.archived)
187
+ .searchBy(['number', 'customer'])
188
+ .sortOption('placed', 'Date', { defaultDirection: 'desc' })
189
+ .sortOption('total', 'Total', { by: (o) => o.total })
190
+ .sort({ key: 'placed', direction: 'desc' }) // initial value, or your own Ref
191
+ .groupBy((o) => (o.paid ? 'Paid' : 'Open'))
192
+ .selection('multiple')
193
+ .showSearch().showSort().searchHighlight()
194
+ .dividers().bordered().height('22rem')
195
+ .onItemActivate((e) => open(e.item))
196
+ .itemMenu((order, selectedItems) => [
197
+ { label: 'Open', icon: 'external-link', onClick: () => open(order) },
198
+ 'divider',
199
+ { label: `Archive ${selectedItems.length}`, danger: true, onClick: archive },
200
+ ])
201
+ .viewportMenu(() => [{ label: 'Select all', onClick: () => api.selectAll() }]);
202
+ ```
203
+
204
+ Every setter accepts a plain value, a `Ref`, or a getter, and setters called after mount re-render the list. Boolean setters default to `true` (`.showSearch()` ≡ `.showSearch(true)`). `.search()`, `.sort()` and `.selected()` take either an initial value or your own writable `Ref` — pass the ref when other UI (a view-mode toggle, a URL query) shares the state.
205
+
206
+ ### Context menus
207
+
208
+ `.itemMenu((item, selectedItems) => entries)` fires on right-click / long-press of an item. The item under the pointer is selected first unless it already was, so `selectedItems` is what a bulk action should operate on. `.viewportMenu(() => entries)` fires on the empty area. Entries are `{ label, icon?, danger?, disabled?, onClick }` or the literal `'divider'`. Setting `.onItemContextMenu(handler)` hands you the raw event instead and bypasses `itemMenu`.
209
+
210
+ ### `api`
211
+
212
+ | Member | Description |
213
+ |--------|-------------|
214
+ | `select(key, mode?)`, `selectAll()`, `clearSelection()`, `isSelected(key)` | Selection actions (`mode`: `'replace'` default, `'toggle'`, `'range'`) |
215
+ | `scrollToKey(key, align?)`, `scrollToIndex(index, align?)`, `focusKey(key)` | Scrolling and focus |
216
+ | `invalidateMeasurements(key?)` | Forget measured heights after a layout change the list cannot observe |
217
+ | `selected`, `search`, `sort` | The builder's writable refs |
218
+ | `selectedItems`, `items`, `count`, `total` | Readonly computed refs, live once mounted |
219
+
220
+ Actions called before `<CoarDataList>` mounts warn and do nothing; move them into `onMounted` or an event handler.
221
+
222
+ ## Basic (props)
223
+
224
+ Search, sort menu, multi-select, search-hit highlighting. The `item` slot renders a two-line ticket; its layout, fields and container-query breakpoints belong to the consumer. The control below the list switches the `gap` between rows — try it with a wide gap, the rows stay measured and aligned.
225
+
226
+ **Demo — `data-list/demos/BasicDataList.vue`**
227
+
228
+ ```vue
229
+ <template>
230
+ <div class="demo">
231
+ <CoarDataList
232
+ v-model:search="search"
233
+ v-model:sort="sort"
234
+ v-model:selected="selected"
235
+ :items="tickets"
236
+ :item-key="(ticket) => ticket.id"
237
+ :search-by="['title', 'customer', 'assignee', 'status']"
238
+ :sort-options="sortOptions"
239
+ selection="multiple"
240
+ show-search
241
+ show-sort
242
+ search-highlight
243
+ :gap="gap"
244
+ :dividers="gap === 0"
245
+ bordered
246
+ height="22rem"
247
+ aria-label="Tickets"
248
+ @item-activate="open"
249
+ >
250
+ <template #toolbar-right>
251
+ <CoarBadge variant="neutral">{{ selected.length }} selected</CoarBadge>
252
+ </template>
253
+
254
+ <template #item="{ item }">
255
+ <div class="ticket">
256
+ <div class="ticket__primary">
257
+ <span class="ticket__title">{{ item.title }}</span>
258
+ <CoarTag :variant="statusVariant[item.status]" size="s">{{ item.status }}</CoarTag>
259
+ <span class="ticket__due" :class="{ 'ticket__due--late': item.overdue }">{{ item.due }}</span>
260
+ </div>
261
+ <div class="ticket__secondary">
262
+ <span class="ticket__customer">{{ item.customer }}</span>
263
+ <span class="ticket__assignee">{{ item.assignee }}</span>
264
+ <span class="ticket__summary">{{ item.summary }}</span>
265
+ </div>
266
+ </div>
267
+ </template>
268
+ </CoarDataList>
269
+ <div class="demo__controls">
270
+ <span class="demo__label">Row gap</span>
271
+ <CoarSegmentedControl v-model="gap" :options="gapOptions" size="s" aria-label="Row gap" />
272
+ <p class="demo__hint">{{ hint }}</p>
273
+ </div>
274
+ </div>
275
+ </template>
276
+
277
+ <script setup lang="ts">
278
+ import { ref } from 'vue';
279
+ import { CoarBadge, CoarDataList, CoarSegmentedControl, CoarTag } from '@cocoar/vue-ui';
280
+ import type { CoarDataListItemEvent, CoarDataListKey, CoarDataListSort, CoarDataListSortOption } from '@cocoar/vue-ui';
281
+
282
+ type Status = 'Open' | 'In progress' | 'Blocked' | 'Done';
283
+
284
+ interface Ticket {
285
+ id: number;
286
+ title: string;
287
+ customer: string;
288
+ assignee: string;
289
+ status: Status;
290
+ due: string;
291
+ overdue: boolean;
292
+ summary: string;
293
+ priority: number;
294
+ }
295
+
296
+ const customers = ['Vienna Gateway', 'Danube Relay', 'Pacific Node', 'Atlantic Edge', 'Nordic Mesh'];
297
+ const people = ['Ada', 'Grace', 'Linus', 'Margaret', 'Tim'];
298
+ const statuses: Status[] = ['Open', 'In progress', 'Blocked', 'Done'];
299
+ const summaries = [
300
+ 'Customer reports intermittent timeouts after the last deployment; logs attached.',
301
+ 'Needs a decision on the retention policy before the migration can continue.',
302
+ 'Waiting for the vendor to confirm the certificate rotation window.',
303
+ 'Short follow-up call requested, no blocking issues.',
304
+ ];
305
+
306
+ const tickets: Ticket[] = Array.from({ length: 60 }, (_, index) => {
307
+ const day = 1 + ((index * 7) % 28);
308
+ return {
309
+ id: 1000 + index,
310
+ title: `Ticket #${1000 + index}: ${['Sync failure', 'Access request', 'Billing question', 'Feature wish'][index % 4]}`,
311
+ customer: customers[index % customers.length],
312
+ assignee: people[(index * 3) % people.length],
313
+ status: statuses[index % statuses.length],
314
+ due: `2026-09-${String(day).padStart(2, '0')}`,
315
+ overdue: index % 5 === 0,
316
+ summary: summaries[index % summaries.length],
317
+ priority: (index * 13) % 5,
318
+ };
319
+ });
320
+
321
+ const statusVariant: Record<Status, 'info' | 'warning' | 'error' | 'success'> = {
322
+ Open: 'info',
323
+ 'In progress': 'warning',
324
+ Blocked: 'error',
325
+ Done: 'success',
326
+ };
327
+
328
+ const sortOptions: CoarDataListSortOption<Ticket>[] = [
329
+ { key: 'title', label: 'Title' },
330
+ { key: 'customer', label: 'Customer' },
331
+ { key: 'due', label: 'Due date' },
332
+ { key: 'priority', label: 'Priority', defaultDirection: 'desc' },
333
+ ];
334
+
335
+ const search = ref('');
336
+ const sort = ref<CoarDataListSort | null>({ key: 'due', direction: 'asc' });
337
+ const selected = ref<CoarDataListKey[]>([]);
338
+ const hint = ref('Double-click or press Enter to open a ticket.');
339
+
340
+ // Gap is a list prop, not a template margin: the measured row height includes it.
341
+ // With a gap, dividers are switched off — the space separates the rows on its own.
342
+ const gap = ref(0);
343
+ const gapOptions = [
344
+ { value: 0, label: 'None' },
345
+ { value: 4, label: '4 px' },
346
+ { value: 8, label: '8 px' },
347
+ { value: 16, label: '16 px' },
348
+ ];
349
+
350
+ function open(event: CoarDataListItemEvent<Ticket>) {
351
+ hint.value = `Opened ${event.item.title}`;
352
+ }
353
+ </script>
354
+
355
+ <style scoped>
356
+ .demo {
357
+ display: flex;
358
+ flex-direction: column;
359
+ gap: var(--coar-spacing-s);
360
+ }
361
+
362
+ .demo__controls {
363
+ display: flex;
364
+ align-items: center;
365
+ gap: var(--coar-spacing-s);
366
+ flex-wrap: wrap;
367
+ }
368
+
369
+ .demo__label {
370
+ font-size: var(--coar-body-caption-size);
371
+ color: var(--coar-text-neutral-secondary);
372
+ }
373
+
374
+ .demo__hint {
375
+ margin: 0 0 0 auto;
376
+ color: var(--coar-text-neutral-secondary);
377
+ font-size: var(--coar-body-caption-size);
378
+ }
379
+
380
+ /* The item template owns its layout. Container queries adapt to the list's width,
381
+ not the viewport's, so the same template works inside a narrow side panel. */
382
+ .ticket {
383
+ container-type: inline-size;
384
+ display: grid;
385
+ gap: var(--coar-spacing-xxs);
386
+ min-width: 0;
387
+ }
388
+
389
+ .ticket__primary,
390
+ .ticket__secondary {
391
+ display: flex;
392
+ align-items: center;
393
+ gap: var(--coar-spacing-s);
394
+ min-width: 0;
395
+ }
396
+
397
+ .ticket__title {
398
+ flex: 1;
399
+ min-width: 0;
400
+ overflow: hidden;
401
+ text-overflow: ellipsis;
402
+ white-space: nowrap;
403
+ font-weight: var(--coar-font-weight-semibold);
404
+ }
405
+
406
+ .ticket__due {
407
+ flex-shrink: 0;
408
+ font-variant-numeric: tabular-nums;
409
+ color: var(--coar-text-neutral-secondary);
410
+ }
411
+
412
+ .ticket__due--late {
413
+ color: var(--coar-text-error-primary, #c0392b);
414
+ }
415
+
416
+ .ticket__secondary {
417
+ color: var(--coar-text-neutral-secondary);
418
+ font-size: var(--coar-body-caption-size);
419
+ }
420
+
421
+ .ticket__customer,
422
+ .ticket__assignee {
423
+ flex-shrink: 0;
424
+ }
425
+
426
+ .ticket__summary {
427
+ flex: 1;
428
+ min-width: 0;
429
+ overflow: hidden;
430
+ text-overflow: ellipsis;
431
+ white-space: nowrap;
432
+ }
433
+
434
+ @container (max-width: 480px) {
435
+ .ticket__summary {
436
+ display: none;
437
+ }
438
+ }
439
+ </style>
440
+ ```
441
+
442
+ ```vue
443
+ <CoarDataList
444
+ v-model:search="search"
445
+ v-model:sort="sort"
446
+ v-model:selected="selected"
447
+ :items="tickets"
448
+ :item-key="(t) => t.id"
449
+ :search-by="['title', 'customer', 'assignee']"
450
+ :sort-options="sortOptions"
451
+ selection="multiple"
452
+ show-search
453
+ show-sort
454
+ search-highlight
455
+ dividers
456
+ bordered
457
+ @item-activate="open"
458
+ >
459
+ <template #item="{ item, selected }">
460
+ <!-- any markup; the list only adds padding, hover, selection and focus -->
461
+ </template>
462
+ </CoarDataList>
463
+ ```
464
+
465
+ ### Writing the item template
466
+
467
+ The list owns the **box** around each record; the template owns everything **inside** it.
468
+
469
+ | The list decides | The template decides |
470
+ |---|---|
471
+ | Row/tile position, width and virtualization; tiles in a row are equal-width columns and stretch to the tallest | Everything inside the box: markup, layout, fields, images, typography, colours of the content, inline buttons or checkboxes |
472
+ | Box padding (`density`), `gap` between rows, dividers, card border and radius (`tileCards`) | Its own height — one line, three lines, varying per record; it is measured |
473
+ | Hover, selected, focus and drag styling of the box, drop indicators, cursor | How to *show* state: react to the slot props `selected`, `focused`, `dragging`, `expanded`, `depth`, `hasChildren` |
474
+ | Chevron gutter, indent, guide lines, band frame and elevation for nested lists | Own expand controls via `toggleExpanded()` (hide the built-in ones with `hideExpandToggle`), own selection controls via `select()` / `toggle()` |
475
+ | Roles, ARIA, keyboard, selection and drag behaviour | Whether a click inside should *not* select the row: stop it with `@click.stop` |
476
+
477
+ Rules that follow from the split:
478
+
479
+ - **No margins on the template root.** The list measures the box, margins are outside it and would make rows overlap. Use padding inside the template, or `gap` on the list.
480
+ - **Don't position the root.** Rows are positioned by the list; use `position: relative` on your root only to place children inside it.
481
+ - **Don't draw the box.** A border or background on the template root doubles with the list's card, hover and selection styling. Style content, not the container — or switch `tileCards` off and draw your own card, accepting that hover and selection then only tint it.
482
+ - **Width is not yours in the grid.** Tiles share the row's columns; per-item spans are a possible later addition, not a template concern.
483
+ - **Interactive children are safe.** Buttons, links and inputs inside the template never start a drag; clicks on them still bubble to the row unless stopped.
484
+ - Keep `min-width: 0` on flex/grid children that should truncate, and use **container queries** (`container-type: inline-size` on the root) rather than viewport media queries — the same template then adapts inside a narrow side panel.
485
+ - Render a checkbox bound to `toggle()` when touch users should multi-select without modifier keys; rows have no built-in actions column, so put a "…" button in the template or handle `item-contextmenu`.
486
+
487
+ CSS variables the list exposes: `--coar-data-list-gap`, `--coar-data-list-indent`, `--coar-data-list-padding` (inner padding of the scroll area), `--coar-data-list-item-pad-x` / `-pad-y` (box padding per density); `--coar-data-list-depth` is set on nested rows and can be read by the template.
488
+
489
+ ### Owning the box too: `unstyledItems`
490
+
491
+ When the template should be the *whole* item, set `unstyledItems` (builder: `.unstyledItems()`). The list then draws nothing around a record — no padding, hover, selection, focus, dividers or card — and keeps only what a template cannot do itself: position, measurement, the drop indicators and the row gap. State arrives through the slot props (`selected`, `focused`, `dragging`, `expanded`, `hasChildren`, `depth`), actions through `select()`, `toggle()`, `toggleExpanded()` and the `api`. Draw the focus state: the keyboard focus marker is now yours to show.
492
+
493
+ **Demo — `data-list/demos/UnstyledDataList.vue`**
494
+
495
+ ```vue
496
+ <template>
497
+ <CoarDataList :builder="builder">
498
+ <!-- The whole item is the template: box, states, controls. -->
499
+ <template #item="{ item, selected, focused, dragging, expanded, hasChildren, depth, toggle, toggleExpanded }">
500
+ <div
501
+ class="ticket"
502
+ :class="{
503
+ 'ticket--selected': selected,
504
+ 'ticket--focused': focused,
505
+ 'ticket--dragging': dragging,
506
+ 'ticket--child': depth > 0,
507
+ }"
508
+ >
509
+ <CoarCheckbox :model-value="selected" size="s" @update:model-value="toggle()" @click.stop />
510
+ <div class="ticket__body">
511
+ <div class="ticket__head">
512
+ <span class="ticket__title">{{ item.title }}</span>
513
+ <CoarTag :variant="item.done ? 'success' : 'warning'" size="s">{{ item.done ? 'Done' : 'Open' }}</CoarTag>
514
+ </div>
515
+ <span class="ticket__meta">{{ item.owner }} · {{ item.due }}</span>
516
+ </div>
517
+ <CoarButton
518
+ v-if="hasChildren"
519
+ variant="ghost"
520
+ size="xs"
521
+ :icon-start="expanded ? 'chevron-up' : 'chevron-down'"
522
+ :aria-label="expanded ? 'Hide sub-tasks' : `Show ${item.subTasks!.length} sub-tasks`"
523
+ @click.stop="toggleExpanded()"
524
+ />
525
+ </div>
526
+ </template>
527
+ </CoarDataList>
528
+ </template>
529
+
530
+ <script setup lang="ts">
531
+ import { CoarButton, CoarCheckbox, CoarDataList, CoarTag, useDataList } from '@cocoar/vue-ui';
532
+
533
+ interface Ticket {
534
+ id: string;
535
+ title: string;
536
+ owner: string;
537
+ due: string;
538
+ done: boolean;
539
+ subTasks?: Ticket[];
540
+ }
541
+
542
+ const tickets: Ticket[] = [
543
+ { id: 't1', title: 'Rework the onboarding mail', owner: 'Ada', due: '2026-09-12', done: false, subTasks: [
544
+ { id: 't1a', title: 'Draft copy', owner: 'Ada', due: '2026-09-08', done: true },
545
+ { id: 't1b', title: 'Review with legal', owner: 'Grace', due: '2026-09-10', done: false },
546
+ ] },
547
+ { id: 't2', title: 'Fix the login timeout', owner: 'Linus', due: '2026-09-09', done: true },
548
+ { id: 't3', title: 'Design the empty state', owner: 'Margaret', due: '2026-09-15', done: false },
549
+ { id: 't4', title: 'Update dependencies', owner: 'Tim', due: '2026-09-20', done: false },
550
+ ];
551
+
552
+ const { builder } = useDataList<Ticket>();
553
+ builder
554
+ .items(tickets)
555
+ .itemKey((ticket) => ticket.id)
556
+ .children((ticket) => ticket.subTasks)
557
+ .expanded(['t1'])
558
+ .selection('multiple')
559
+ .unstyledItems()
560
+ .hideExpandToggle()
561
+ .nestingStyle('none')
562
+ .gap(6)
563
+ .height('20rem')
564
+ .ariaLabel('Tickets');
565
+ </script>
566
+
567
+ <style scoped>
568
+ .ticket {
569
+ display: flex;
570
+ align-items: center;
571
+ gap: var(--coar-spacing-s);
572
+ padding: var(--coar-spacing-s);
573
+ border: 1px solid var(--coar-border-neutral);
574
+ border-left: 3px solid var(--coar-border-neutral);
575
+ border-radius: var(--coar-radius-s);
576
+ background: var(--coar-surface-neutral-primary, #fff);
577
+ min-width: 0;
578
+ transition: border-color var(--coar-duration-fast) var(--coar-ease-out), box-shadow var(--coar-duration-fast) var(--coar-ease-out);
579
+ }
580
+
581
+ .ticket--child {
582
+ padding-block: var(--coar-spacing-xs);
583
+ border-style: dashed;
584
+ }
585
+
586
+ .ticket--selected {
587
+ border-left-color: var(--coar-border-accent-primary);
588
+ background: var(--coar-background-accent-tertiary);
589
+ }
590
+
591
+ .ticket--focused {
592
+ box-shadow: 0 0 0 2px var(--coar-focus-color);
593
+ }
594
+
595
+ .ticket--dragging {
596
+ opacity: 0.5;
597
+ }
598
+
599
+ .ticket__body {
600
+ display: flex;
601
+ flex-direction: column;
602
+ min-width: 0;
603
+ flex: 1;
604
+ }
605
+
606
+ .ticket__head {
607
+ display: flex;
608
+ align-items: center;
609
+ gap: var(--coar-spacing-s);
610
+ min-width: 0;
611
+ }
612
+
613
+ .ticket__title {
614
+ flex: 1;
615
+ min-width: 0;
616
+ overflow: hidden;
617
+ text-overflow: ellipsis;
618
+ white-space: nowrap;
619
+ font-weight: var(--coar-font-weight-semibold);
620
+ }
621
+
622
+ .ticket__meta {
623
+ font-size: var(--coar-body-caption-size);
624
+ color: var(--coar-text-neutral-secondary);
625
+ }
626
+ </style>
627
+ ```
628
+
629
+ ## Selection and context menu
630
+
631
+ `selection` is `'none'` (default), `'single'` or `'multiple'`. Click replaces, `Ctrl`/`⌘`-click toggles, `Shift`-click selects a range; the keyboard mirrors this (see [Keyboard](#keyboard)). Right-clicking an unselected item selects it first, so a context menu always acts on the item under the pointer.
632
+
633
+ **Demo — `data-list/demos/SelectionDataList.vue`**
634
+
635
+ ```vue
636
+ <template>
637
+ <div class="demo">
638
+ <CoarDataList
639
+ v-model:selected="selected"
640
+ :items="files"
641
+ :item-key="(file) => file.path"
642
+ selection="multiple"
643
+ :gap="4"
644
+ bordered
645
+ height="18rem"
646
+ @item-contextmenu="onContextMenu"
647
+ >
648
+ <template #toolbar-left>
649
+ <span class="demo__title">Attachments</span>
650
+ </template>
651
+ <template #toolbar-right>
652
+ <CoarButton variant="secondary" size="s" :disabled="selected.length === 0" @click="removeSelected">
653
+ Remove {{ selected.length || '' }}
654
+ </CoarButton>
655
+ </template>
656
+
657
+ <template #item="{ item, selected: isSelected, toggle }">
658
+ <div class="file">
659
+ <CoarCheckbox :model-value="isSelected" size="s" @update:model-value="toggle()" @click.stop />
660
+ <CoarIcon :name="item.kind === 'image' ? 'image' : 'file-text'" size="m" />
661
+ <div class="file__text">
662
+ <span class="file__name">{{ item.name }}</span>
663
+ <span class="file__meta">{{ item.path }} · {{ item.size }}</span>
664
+ </div>
665
+ </div>
666
+ </template>
667
+ </CoarDataList>
668
+
669
+ <CoarContextMenu :menu="menu">
670
+ <CoarMenuItem label="Download" icon="download" @click="log('download')" />
671
+ <CoarMenuItem label="Rename" icon="pencil" :disabled="selected.length !== 1" @click="log('rename')" />
672
+ <CoarMenuDivider />
673
+ <CoarMenuItem label="Remove" icon="x" @click="removeSelected" />
674
+ </CoarContextMenu>
675
+
676
+ <p class="demo__hint">{{ hint }}</p>
677
+ </div>
678
+ </template>
679
+
680
+ <script setup lang="ts">
681
+ import { ref } from 'vue';
682
+ import {
683
+ CoarButton,
684
+ CoarCheckbox,
685
+ CoarContextMenu,
686
+ CoarDataList,
687
+ CoarIcon,
688
+ CoarMenuDivider,
689
+ CoarMenuItem,
690
+ useContextMenu,
691
+ } from '@cocoar/vue-ui';
692
+ import type { CoarDataListItemEvent, CoarDataListKey } from '@cocoar/vue-ui';
693
+
694
+ interface FileRow {
695
+ path: string;
696
+ name: string;
697
+ size: string;
698
+ kind: 'image' | 'document';
699
+ }
700
+
701
+ const files = ref<FileRow[]>([
702
+ { path: '/2026/offer.pdf', name: 'Offer.pdf', size: '184 KB', kind: 'document' },
703
+ { path: '/2026/floorplan.png', name: 'Floorplan.png', size: '2.1 MB', kind: 'image' },
704
+ { path: '/2026/notes.md', name: 'Notes.md', size: '3 KB', kind: 'document' },
705
+ { path: '/2026/site.jpg', name: 'Site.jpg', size: '4.7 MB', kind: 'image' },
706
+ { path: '/2026/contract.docx', name: 'Contract.docx', size: '96 KB', kind: 'document' },
707
+ { path: '/2026/invoice-0917.pdf', name: 'Invoice 0917.pdf', size: '71 KB', kind: 'document' },
708
+ ]);
709
+
710
+ const selected = ref<CoarDataListKey[]>([]);
711
+ const menu = useContextMenu();
712
+ const hint = ref('Tap the checkbox or use Ctrl/Shift+Click. Right-click for actions.');
713
+
714
+ function onContextMenu(event: CoarDataListItemEvent<FileRow>) {
715
+ // The list already selected the item under the pointer (unless it was part of the selection).
716
+ menu.open(event.event as MouseEvent);
717
+ }
718
+
719
+ function removeSelected() {
720
+ const keys = new Set(selected.value);
721
+ files.value = files.value.filter((file) => !keys.has(file.path));
722
+ hint.value = `Removed ${keys.size} file(s).`;
723
+ selected.value = [];
724
+ }
725
+
726
+ function log(action: string) {
727
+ hint.value = `${action}: ${selected.value.join(', ')}`;
728
+ }
729
+ </script>
730
+
731
+ <style scoped>
732
+ .demo {
733
+ display: flex;
734
+ flex-direction: column;
735
+ gap: var(--coar-spacing-s);
736
+ }
737
+
738
+ .demo__title {
739
+ font-weight: var(--coar-font-weight-semibold);
740
+ }
741
+
742
+ .demo__hint {
743
+ margin: 0;
744
+ color: var(--coar-text-neutral-secondary);
745
+ font-size: var(--coar-body-caption-size);
746
+ }
747
+
748
+ .file {
749
+ display: flex;
750
+ align-items: center;
751
+ gap: var(--coar-spacing-s);
752
+ min-width: 0;
753
+ }
754
+
755
+ .file__text {
756
+ display: flex;
757
+ flex-direction: column;
758
+ min-width: 0;
759
+ }
760
+
761
+ .file__meta {
762
+ font-size: var(--coar-body-caption-size);
763
+ color: var(--coar-text-neutral-secondary);
764
+ overflow: hidden;
765
+ text-overflow: ellipsis;
766
+ white-space: nowrap;
767
+ }
768
+ </style>
769
+ ```
770
+
771
+ ## Grid layout
772
+
773
+ `layout="grid"` flows the same records into tiles: as many per row as fit `tileMinWidth`, always in exact data order — a tile grid is a list that wraps. Search, sort, grouping, selection, context menus and the `api` work identically, so a list/grid toggle is one setter or prop. The item slot is unchanged; give the template a second shape for tiles as the demo does.
774
+
775
+ **Demo — `data-list/demos/GridDataList.vue`**
776
+
777
+ ```vue
778
+ <template>
779
+ <div class="demo">
780
+ <CoarDataList :builder="builder">
781
+ <template #toolbar-right>
782
+ <CoarSegmentedControl v-model="layout" :options="layoutOptions" size="s" aria-label="Layout" />
783
+ </template>
784
+
785
+ <template #item="{ item, selected }">
786
+ <div class="asset" :class="{ 'asset--tile': layout === 'grid' }">
787
+ <div class="asset__thumb" :style="{ background: item.color }">
788
+ <CoarIcon :name="item.icon" size="l" />
789
+ </div>
790
+ <div class="asset__text">
791
+ <span class="asset__name">{{ item.name }}</span>
792
+ <span class="asset__meta">{{ item.kind }} · {{ item.size }}</span>
793
+ </div>
794
+ <CoarIcon v-if="selected" name="check" size="s" class="asset__check" />
795
+ </div>
796
+ </template>
797
+ </CoarDataList>
798
+ <p class="demo__hint">Same data, same order, same selection — only the layout changes. Arrow keys move by tile and by row.</p>
799
+ </div>
800
+ </template>
801
+
802
+ <script setup lang="ts">
803
+ import { ref } from 'vue';
804
+ import { CoarDataList, CoarIcon, CoarSegmentedControl, useDataList } from '@cocoar/vue-ui';
805
+ import type { CoarDataListLayout } from '@cocoar/vue-ui';
806
+
807
+ interface Asset {
808
+ id: number;
809
+ name: string;
810
+ kind: 'Image' | 'Document' | 'Sheet' | 'Video';
811
+ size: string;
812
+ icon: string;
813
+ color: string;
814
+ }
815
+
816
+ const kinds: Array<[Asset['kind'], string, string]> = [
817
+ ['Image', 'image', 'var(--coar-background-accent-secondary)'],
818
+ ['Document', 'file-text', 'var(--coar-background-neutral-tertiary)'],
819
+ ['Sheet', 'table', 'var(--coar-background-success-secondary, #dcfce7)'],
820
+ ['Video', 'camera', 'var(--coar-background-warning-secondary, #fef3c7)'],
821
+ ];
822
+
823
+ const assets: Asset[] = Array.from({ length: 48 }, (_, index) => {
824
+ const [kind, icon, color] = kinds[index % kinds.length];
825
+ return {
826
+ id: index + 1,
827
+ name: `${kind} ${String(index + 1).padStart(2, '0')}`,
828
+ kind,
829
+ size: `${((index * 37) % 900) + 12} KB`,
830
+ icon,
831
+ color,
832
+ };
833
+ });
834
+
835
+ const layout = ref<CoarDataListLayout>('grid');
836
+ const layoutOptions = [
837
+ { value: 'list' as const, label: 'List', icon: 'list' },
838
+ { value: 'grid' as const, label: 'Grid', icon: 'layout-grid' },
839
+ ];
840
+
841
+ const { builder } = useDataList<Asset>();
842
+ builder
843
+ .items(assets)
844
+ .itemKey((asset) => asset.id)
845
+ .layout(layout)
846
+ .tileMinWidth('11rem')
847
+ .gap(8)
848
+ .searchBy(['name', 'kind'])
849
+ .sortOption('name', 'Name')
850
+ .sortOption('kind', 'Kind')
851
+ .selection('multiple')
852
+ .showSearch()
853
+ .showSort()
854
+ .bordered()
855
+ .height('24rem')
856
+ .ariaLabel('Assets');
857
+ </script>
858
+
859
+ <style scoped>
860
+ .demo {
861
+ display: flex;
862
+ flex-direction: column;
863
+ gap: var(--coar-spacing-s);
864
+ }
865
+
866
+ .demo__hint {
867
+ margin: 0;
868
+ color: var(--coar-text-neutral-secondary);
869
+ font-size: var(--coar-body-caption-size);
870
+ }
871
+
872
+ /* One template, two shapes: a row in list layout, a card in grid layout. */
873
+ .asset {
874
+ display: flex;
875
+ align-items: center;
876
+ gap: var(--coar-spacing-s);
877
+ min-width: 0;
878
+ height: 100%;
879
+ }
880
+
881
+ .asset--tile {
882
+ flex-direction: column;
883
+ align-items: stretch;
884
+ gap: var(--coar-spacing-xs);
885
+ }
886
+
887
+ .asset__thumb {
888
+ display: flex;
889
+ align-items: center;
890
+ justify-content: center;
891
+ flex-shrink: 0;
892
+ width: 2.5rem;
893
+ height: 2.5rem;
894
+ border-radius: var(--coar-radius-xs);
895
+ color: var(--coar-icon-neutral-secondary);
896
+ }
897
+
898
+ .asset--tile .asset__thumb {
899
+ width: auto;
900
+ height: 5rem;
901
+ }
902
+
903
+ .asset__text {
904
+ display: flex;
905
+ flex-direction: column;
906
+ min-width: 0;
907
+ flex: 1;
908
+ }
909
+
910
+ .asset__name {
911
+ font-weight: var(--coar-font-weight-medium);
912
+ overflow: hidden;
913
+ text-overflow: ellipsis;
914
+ white-space: nowrap;
915
+ }
916
+
917
+ .asset__meta {
918
+ font-size: var(--coar-body-caption-size);
919
+ color: var(--coar-text-neutral-secondary);
920
+ }
921
+
922
+ .asset__check {
923
+ color: var(--coar-icon-accent-primary);
924
+ flex-shrink: 0;
925
+ }
926
+
927
+ .asset--tile .asset__check {
928
+ position: absolute;
929
+ top: var(--coar-spacing-xs);
930
+ right: var(--coar-spacing-xs);
931
+ }
932
+
933
+ .asset--tile {
934
+ position: relative;
935
+ }
936
+ </style>
937
+ ```
938
+
939
+ ```ts
940
+ builder.layout(layoutRef).tileMinWidth('11rem').gap(8)
941
+ ```
942
+
943
+ Rows are still virtualized and measured — tiles in one row share the height of the tallest. Group headings take a full row. `dividers` only apply to list rows; use `gap` for spacing between tiles. Layouts that give up the data order for the visuals (masonry) are deliberately not part of this component. Nested children in the grid open in a band under the row, see [Nesting in the grid layout](#nesting-in-the-grid-layout).
944
+
945
+ ## Nested lists
946
+
947
+ `children` returns an item's sub-items and the list becomes a tree of lists: every child level is a list of its own — same template, own sorting, own layout — shown under its expanded parent. The exact order is kept on every level.
948
+
949
+ **Demo — `data-list/demos/NestedDataList.vue`**
950
+
951
+ ```vue
952
+ <template>
953
+ <div class="demo">
954
+ <CoarDataList :builder="builder">
955
+ <template #toolbar-right>
956
+ <CoarButton variant="ghost" size="s" @click="api.expandAll()">Expand all</CoarButton>
957
+ <CoarButton variant="ghost" size="s" @click="api.collapseAll()">Collapse all</CoarButton>
958
+ </template>
959
+
960
+ <template #item="{ item, depth, hasChildren, expanded }">
961
+ <div class="task" :class="{ 'task--child': depth > 0 }">
962
+ <div class="task__primary">
963
+ <span class="task__title">{{ item.title }}</span>
964
+ <CoarBadge v-if="hasChildren && !expanded" variant="neutral">{{ item.subTasks!.length }}</CoarBadge>
965
+ <span class="task__due">{{ item.due }}</span>
966
+ </div>
967
+ <div v-if="depth === 0" class="task__secondary">{{ item.summary }}</div>
968
+ </div>
969
+ </template>
970
+ </CoarDataList>
971
+ <p class="demo__hint">{{ hint }}</p>
972
+ </div>
973
+ </template>
974
+
975
+ <script setup lang="ts">
976
+ import { ref } from 'vue';
977
+ import { CoarBadge, CoarButton, CoarDataList, useDataList } from '@cocoar/vue-ui';
978
+ import type { CoarDataListDropEvent } from '@cocoar/vue-ui';
979
+
980
+ interface Task {
981
+ id: string;
982
+ title: string;
983
+ due: string;
984
+ summary?: string;
985
+ subTasks?: Task[];
986
+ }
987
+
988
+ const tasks = ref<Task[]>([
989
+ {
990
+ id: 'release',
991
+ title: 'Ship 3.2',
992
+ due: '2026-09-30',
993
+ summary: 'Everything that has to land before the release branch is cut.',
994
+ subTasks: [
995
+ { id: 'release-notes', title: 'Write release notes', due: '2026-09-28' },
996
+ { id: 'release-tag', title: 'Tag and publish', due: '2026-09-30' },
997
+ { id: 'release-qa', title: 'QA pass on the playground', due: '2026-09-25', subTasks: [
998
+ { id: 'qa-touch', title: 'Touch devices', due: '2026-09-24' },
999
+ { id: 'qa-desktop', title: 'Desktop browsers', due: '2026-09-23' },
1000
+ ] },
1001
+ ],
1002
+ },
1003
+ {
1004
+ id: 'migration',
1005
+ title: 'Migrate the todo list',
1006
+ due: '2026-10-15',
1007
+ summary: 'Replace the hand-rolled list in timetodo with CoarDataList.',
1008
+ subTasks: [
1009
+ { id: 'migration-nesting', title: 'Nesting parity', due: '2026-10-10' },
1010
+ { id: 'migration-dnd', title: 'Drag & drop parity', due: '2026-10-05' },
1011
+ ],
1012
+ },
1013
+ { id: 'docs', title: 'Docs sweep', due: '2026-10-01', summary: 'One pass over every page touched this quarter.' },
1014
+ ]);
1015
+
1016
+ const hint = ref('Parents sort by title; sub-tasks keep their manual order — drag them to reorder, drop onto a row to nest.');
1017
+
1018
+ const { builder, api } = useDataList<Task>();
1019
+
1020
+ function findParentList(list: Task[], parentKey: string | number | null): Task[] | null {
1021
+ if (parentKey === null) return list;
1022
+ for (const task of list) {
1023
+ if (task.id === parentKey) return (task.subTasks ??= []);
1024
+ const nested = task.subTasks ? findParentList(task.subTasks, parentKey) : null;
1025
+ if (nested) return nested;
1026
+ }
1027
+ return null;
1028
+ }
1029
+
1030
+ function remove(list: Task[], keys: Set<string | number>): Task[] {
1031
+ const kept: Task[] = [];
1032
+ for (const task of list) {
1033
+ if (keys.has(task.id)) continue;
1034
+ if (task.subTasks) task.subTasks = remove(task.subTasks, keys);
1035
+ kept.push(task);
1036
+ }
1037
+ return kept;
1038
+ }
1039
+
1040
+ function applyDrop(event: CoarDataListDropEvent<Task>) {
1041
+ const keys = new Set(event.keys);
1042
+ tasks.value = remove(tasks.value, keys);
1043
+ const target = findParentList(tasks.value, event.parentKey);
1044
+ if (!target) return;
1045
+ const anchor = event.afterKey === null ? -1 : target.findIndex((task) => task.id === event.afterKey);
1046
+ target.splice(anchor + 1, 0, ...event.items);
1047
+ hint.value = event.parentKey === null
1048
+ ? `Moved ${event.keys.join(', ')} to the top level.`
1049
+ : `Moved ${event.keys.join(', ')} under "${event.parentKey}".`;
1050
+ }
1051
+
1052
+ builder
1053
+ .items(tasks)
1054
+ .itemKey((task) => task.id)
1055
+ // Child levels are lists of their own: here they keep their manual order
1056
+ // (`sort(null)`), so sub-tasks can be dragged around while parents stay sorted.
1057
+ // `.sort({ key: 'due', direction: 'asc' })` would sort them by due date instead.
1058
+ .children((task) => task.subTasks, (level) => level.sortOption('due', 'Due date').sort(null))
1059
+ .expanded(['release'])
1060
+ .sortOption('title', 'Title')
1061
+ .sortOption('due', 'Due date')
1062
+ .sort({ key: 'title', direction: 'asc' })
1063
+ .searchBy(['title'])
1064
+ .selection('multiple')
1065
+ .reorderable()
1066
+ .showSearch()
1067
+ .showSort()
1068
+ .bordered()
1069
+ .height('22rem')
1070
+ .ariaLabel('Tasks')
1071
+ .onReorder(applyDrop);
1072
+ </script>
1073
+
1074
+ <style scoped>
1075
+ .demo {
1076
+ display: flex;
1077
+ flex-direction: column;
1078
+ gap: var(--coar-spacing-s);
1079
+ }
1080
+
1081
+ .demo__hint {
1082
+ margin: 0;
1083
+ color: var(--coar-text-neutral-secondary);
1084
+ font-size: var(--coar-body-caption-size);
1085
+ }
1086
+
1087
+ .task {
1088
+ display: grid;
1089
+ gap: var(--coar-spacing-xxs);
1090
+ min-width: 0;
1091
+ }
1092
+
1093
+ .task__primary {
1094
+ display: flex;
1095
+ align-items: center;
1096
+ gap: var(--coar-spacing-s);
1097
+ min-width: 0;
1098
+ }
1099
+
1100
+ .task__title {
1101
+ flex: 1;
1102
+ min-width: 0;
1103
+ overflow: hidden;
1104
+ text-overflow: ellipsis;
1105
+ white-space: nowrap;
1106
+ font-weight: var(--coar-font-weight-semibold);
1107
+ }
1108
+
1109
+ .task--child .task__title {
1110
+ font-weight: var(--coar-font-weight-regular, 400);
1111
+ }
1112
+
1113
+ .task__due {
1114
+ font-variant-numeric: tabular-nums;
1115
+ color: var(--coar-text-neutral-secondary);
1116
+ font-size: var(--coar-body-caption-size);
1117
+ }
1118
+
1119
+ .task__secondary {
1120
+ color: var(--coar-text-neutral-secondary);
1121
+ font-size: var(--coar-body-caption-size);
1122
+ overflow: hidden;
1123
+ text-overflow: ellipsis;
1124
+ white-space: nowrap;
1125
+ }
1126
+ </style>
1127
+ ```
1128
+
1129
+ ```ts
1130
+ builder
1131
+ .children((task) => task.subTasks, (level) =>
1132
+ level.sortOption('due', 'Due date').sort({ key: 'due', direction: 'asc' }), // child levels
1133
+ )
1134
+ .sortOption('title', 'Title').sort({ key: 'title', direction: 'asc' }) // top level
1135
+ .expanded(expandedRef) // v-model:expanded
1136
+ ```
1137
+
1138
+ - **Own configuration per level.** The `configure` callback of `.children()` receives a level builder: `sortOptions`, `sortOption`, `sort`, `layout`, `tileMinWidth`. Without it, child levels inherit the top level's sort and layout; `sort(null)` keeps their input order. A list may nest grid children and a grid may nest list rows.
1139
+ - **Drawing.** The list draws the structure so the template stays free: a chevron gutter (leaves get the same space so text aligns), an indent per level (`nestingIndent`) and, with `nestingStyle: 'lines'` (default), a guide line per ancestor. `hideExpandToggle` removes the chevrons when the template toggles itself via the `toggleExpanded()` slot prop.
1140
+ - **Search** keeps a parent whose descendants match and opens it while the query is active; a parent matching by itself does not force its children open.
1141
+ - **Selection** stays flat and key-based; selecting a parent does not select its children.
1142
+ - **Keyboard:** `→` expands a parent or steps into its first child, `←` collapses or jumps to the parent.
1143
+ - **Drag & drop** gains a third position: the middle of a row means **inside** — the row becomes the new parent. Drop events carry `parentKey`, and `toIndex` / `afterKey` / `beforeKey` refer to the destination's siblings. `canNest(item, parent)` and `maxDepth` veto nesting; an item can never be dropped into its own subtree. Keyboard moves stay among siblings.
1144
+
1145
+ The `api` adds `expand`, `collapse`, `toggleExpanded`, `expandAll`, `collapseAll` and the `expanded` ref.
1146
+
1147
+ ### Nesting in the grid layout
1148
+
1149
+ Tiles never move sideways when something expands. The children of an expanded tile open in a **band under that tile's row**, framed like a folder hanging from its tab: with `tileCards` the tile is drawn as a card whose bottom edge opens into the band. The band's rows use the child level's layout — tiles by default, or list rows with `level.layout('list')` — and can nest further bands.
1150
+
1151
+ **Demo — `data-list/demos/NestedGridDataList.vue`**
1152
+
1153
+ ```vue
1154
+ <template>
1155
+ <div class="demo">
1156
+ <div class="demo__bar">
1157
+ <span class="demo__label">Children as</span>
1158
+ <CoarSegmentedControl v-model="childLayout" :options="layoutOptions" size="s" aria-label="Child layout" />
1159
+ <span class="demo__hint">One open folder per row — open another in the same row and the first closes.</span>
1160
+ </div>
1161
+
1162
+ <!-- Inner padding gives the elevated band room inside the scroll area. -->
1163
+ <CoarDataList :builder="builder" class="files">
1164
+ <template #item="{ item, depth, hasChildren, expanded }">
1165
+ <div class="folder" :class="{ 'folder--child': depth > 0 }">
1166
+ <div class="folder__icon" :class="`folder__icon--${item.kind}`">
1167
+ <CoarIcon :name="item.kind === 'folder' ? 'folder' : item.kind === 'image' ? 'image' : 'file-text'" size="l" />
1168
+ </div>
1169
+ <div class="folder__text">
1170
+ <span class="folder__name">{{ item.name }}</span>
1171
+ <span class="folder__meta">
1172
+ <template v-if="hasChildren">{{ item.children!.length }} items{{ expanded ? '' : ' · open' }}</template>
1173
+ <template v-else>{{ item.size }}</template>
1174
+ </span>
1175
+ </div>
1176
+ </div>
1177
+ </template>
1178
+ </CoarDataList>
1179
+ </div>
1180
+ </template>
1181
+
1182
+ <script setup lang="ts">
1183
+ import { ref } from 'vue';
1184
+ import { CoarDataList, CoarIcon, CoarSegmentedControl, useDataList } from '@cocoar/vue-ui';
1185
+ import type { CoarDataListLayout } from '@cocoar/vue-ui';
1186
+
1187
+ interface Entry {
1188
+ id: string;
1189
+ name: string;
1190
+ kind: 'folder' | 'image' | 'document';
1191
+ size?: string;
1192
+ children?: Entry[];
1193
+ }
1194
+
1195
+ const file = (id: string, name: string, kind: 'image' | 'document', size: string): Entry => ({ id, name, kind, size });
1196
+
1197
+ const entries: Entry[] = [
1198
+ { id: 'brand', name: 'Brand', kind: 'folder', children: [
1199
+ file('brand-logo', 'Logo.svg', 'image', '12 KB'),
1200
+ file('brand-guide', 'Guidelines.pdf', 'document', '2.4 MB'),
1201
+ file('brand-palette', 'Palette.png', 'image', '310 KB'),
1202
+ ] },
1203
+ file('cover', 'Cover.jpg', 'image', '1.9 MB'),
1204
+ { id: 'release', name: 'Release 3.2', kind: 'folder', children: [
1205
+ file('rel-notes', 'Notes.md', 'document', '4 KB'),
1206
+ file('rel-shot-1', 'Screenshot 1.png', 'image', '640 KB'),
1207
+ file('rel-shot-2', 'Screenshot 2.png', 'image', '655 KB'),
1208
+ file('rel-shot-3', 'Screenshot 3.png', 'image', '612 KB'),
1209
+ file('rel-shot-4', 'Screenshot 4.png', 'image', '598 KB'),
1210
+ ] },
1211
+ file('contract', 'Contract.docx', 'document', '96 KB'),
1212
+ { id: 'archive', name: 'Archive', kind: 'folder', children: [
1213
+ file('arch-2024', '2024.zip', 'document', '48 MB'),
1214
+ { id: 'arch-old', name: 'Older', kind: 'folder', children: [file('arch-2023', '2023.zip', 'document', '41 MB')] },
1215
+ ] },
1216
+ file('invoice', 'Invoice 0917.pdf', 'document', '71 KB'),
1217
+ file('sketch', 'Sketch.png', 'image', '220 KB'),
1218
+ ];
1219
+
1220
+ const childLayout = ref<CoarDataListLayout>('grid');
1221
+ const layoutOptions = [
1222
+ { value: 'grid' as const, label: 'Tiles', icon: 'layout-grid' },
1223
+ { value: 'list' as const, label: 'Rows', icon: 'list' },
1224
+ ];
1225
+
1226
+ const { builder } = useDataList<Entry>();
1227
+ builder
1228
+ .items(entries)
1229
+ .itemKey((entry) => entry.id)
1230
+ .layout('grid')
1231
+ .tileMinWidth('10rem')
1232
+ .tileCards()
1233
+ .bandElevated()
1234
+ .gap(8)
1235
+ // Child levels are lists of their own — here their layout follows the toggle above.
1236
+ .children((entry) => entry.children, (level) => level.layout(childLayout.value).tileMinWidth('9rem'))
1237
+ .expanded(['brand'])
1238
+ .selection('single')
1239
+ .bordered()
1240
+ .height('26rem')
1241
+ .ariaLabel('Files');
1242
+
1243
+ // The level builder captured a value; re-apply when the toggle changes.
1244
+ import { watch } from 'vue';
1245
+ watch(childLayout, (layout) => {
1246
+ builder.children((entry) => entry.children, (level) => level.layout(layout).tileMinWidth('9rem'));
1247
+ });
1248
+ </script>
1249
+
1250
+ <style scoped>
1251
+ .demo {
1252
+ display: flex;
1253
+ flex-direction: column;
1254
+ gap: var(--coar-spacing-s);
1255
+ }
1256
+
1257
+ .demo__bar {
1258
+ display: flex;
1259
+ align-items: center;
1260
+ gap: var(--coar-spacing-s);
1261
+ flex-wrap: wrap;
1262
+ }
1263
+
1264
+ .demo__label,
1265
+ .demo__hint {
1266
+ font-size: var(--coar-body-caption-size);
1267
+ color: var(--coar-text-neutral-secondary);
1268
+ }
1269
+
1270
+ .demo__hint {
1271
+ margin-left: auto;
1272
+ }
1273
+
1274
+ .files {
1275
+ --coar-data-list-padding: var(--coar-spacing-m);
1276
+ }
1277
+
1278
+ .folder {
1279
+ display: flex;
1280
+ flex-direction: column;
1281
+ gap: var(--coar-spacing-xs);
1282
+ min-width: 0;
1283
+ }
1284
+
1285
+ .folder--child {
1286
+ flex-direction: row;
1287
+ align-items: center;
1288
+ }
1289
+
1290
+ .folder__icon {
1291
+ display: flex;
1292
+ align-items: center;
1293
+ justify-content: center;
1294
+ height: 3.5rem;
1295
+ border-radius: var(--coar-radius-xs);
1296
+ background: var(--coar-background-neutral-tertiary);
1297
+ color: var(--coar-icon-neutral-secondary);
1298
+ }
1299
+
1300
+ .folder--child .folder__icon {
1301
+ width: 2.25rem;
1302
+ height: 2.25rem;
1303
+ flex-shrink: 0;
1304
+ }
1305
+
1306
+ .folder__icon--folder {
1307
+ background: var(--coar-background-accent-secondary);
1308
+ color: var(--coar-icon-accent-primary);
1309
+ }
1310
+
1311
+ .folder__text {
1312
+ display: flex;
1313
+ flex-direction: column;
1314
+ min-width: 0;
1315
+ }
1316
+
1317
+ .folder__name {
1318
+ font-weight: var(--coar-font-weight-medium);
1319
+ overflow: hidden;
1320
+ text-overflow: ellipsis;
1321
+ white-space: nowrap;
1322
+ }
1323
+
1324
+ .folder__meta {
1325
+ font-size: var(--coar-body-caption-size);
1326
+ color: var(--coar-text-neutral-secondary);
1327
+ }
1328
+ </style>
1329
+ ```
1330
+
1331
+ ```ts
1332
+ builder
1333
+ .layout('grid').tileMinWidth('10rem').tileCards().bandElevated()
1334
+ .children((entry) => entry.children, (level) => level.layout('list')) // rows under the tile row
1335
+ ```
1336
+
1337
+ The other tiles of the row keep their `gap` to the band; only the expanded card reaches down to it, so card and band read as one frame around parent and children. `bandElevated` lifts that frame with a shadow — give the scroll area some inner room for it with the CSS variable `--coar-data-list-padding` on the list (the demo uses `var(--coar-spacing-m)`).
1338
+
1339
+ Because a band hangs from one tab, **one expanded parent per row** is the rule: expanding a second tile in the same row collapses the first (the most recently expanded wins). Parents in different rows may be open at the same time, and the rule re-applies when the column count changes. In the list layout any number of parents may be open.
1340
+
1341
+ Reading order: a band follows its parent's whole row, so the children come after the parent's row-mates rather than right after the parent — the one deliberate deviation from the data order, made for readability. Range selection (`Shift`) still follows the data order.
1342
+
1343
+ Keyboard in the grid: `↓` moves into the band (keeping the column), `↑` back out, `+` / `-` expand and collapse the focused tile.
1344
+
1345
+ ## Reordering with drag & drop
1346
+
1347
+ Opt in with `reorderable`. Users drag one item or the whole selection; the list shows an insertion line and reports the result — it **never mutates your data**. Lists sharing a `dragGroup` accept each other's items, which is all a board needs: three columns, three lists.
1348
+
1349
+ **Demo — `data-list/demos/ReorderDataList.vue`**
1350
+
1351
+ ```vue
1352
+ <template>
1353
+ <div class="demo">
1354
+ <div class="demo__bar">
1355
+ <span class="demo__label">Drag engine</span>
1356
+ <CoarSegmentedControl v-model="engine" :options="engineOptions" size="s" aria-label="Drag engine" />
1357
+ <span class="demo__hint">{{ hint }}</span>
1358
+ </div>
1359
+
1360
+ <div class="board">
1361
+ <CoarDataList v-for="column in columns" :key="column.id" :builder="column.builder" class="board__column">
1362
+ <template #toolbar-left>
1363
+ <span class="board__title">{{ column.title }}</span>
1364
+ <CoarBadge variant="neutral">{{ column.api.count.value }}</CoarBadge>
1365
+ </template>
1366
+
1367
+ <template #item="{ item, dragging }">
1368
+ <div class="task" :class="{ 'task--dragging': dragging }">
1369
+ <CoarIcon name="grip-vertical" size="s" class="task__grip" />
1370
+ <div class="task__text">
1371
+ <span class="task__title">{{ item.title }}</span>
1372
+ <span class="task__meta">{{ item.owner }} · {{ item.points }} pt</span>
1373
+ </div>
1374
+ </div>
1375
+ </template>
1376
+
1377
+ <template #empty>
1378
+ <span class="board__empty">Drop tasks here</span>
1379
+ </template>
1380
+ </CoarDataList>
1381
+ </div>
1382
+ </div>
1383
+ </template>
1384
+
1385
+ <script setup lang="ts">
1386
+ import { ref } from 'vue';
1387
+ import { CoarBadge, CoarDataList, CoarIcon, CoarSegmentedControl, useDataList } from '@cocoar/vue-ui';
1388
+ import type { CoarDataListDragEngine, CoarDataListDropEvent, CoarDataListItemsRemoveEvent } from '@cocoar/vue-ui';
1389
+
1390
+ interface Task {
1391
+ id: number;
1392
+ title: string;
1393
+ owner: string;
1394
+ points: number;
1395
+ }
1396
+
1397
+ const engine = ref<CoarDataListDragEngine>('native');
1398
+ const engineOptions = [
1399
+ { value: 'native' as const, label: 'Native' },
1400
+ { value: 'pointer' as const, label: 'Pointer' },
1401
+ { value: 'auto' as const, label: 'Auto' },
1402
+ ];
1403
+ const hint = ref('Drag within a column to reorder, across columns to move. Keyboard: Ctrl+X, arrows, Ctrl+V.');
1404
+
1405
+ const people = ['Ada', 'Grace', 'Linus', 'Margaret'];
1406
+ let nextId = 1;
1407
+ const makeTasks = (titles: string[]) =>
1408
+ titles.map((title, index) => ({ id: nextId++, title, owner: people[(nextId + index) % people.length], points: 1 + ((nextId * 3) % 5) }));
1409
+
1410
+ function column(id: string, title: string, initial: Task[]) {
1411
+ const items = ref<Task[]>(initial);
1412
+ const { builder, api } = useDataList<Task>();
1413
+
1414
+ // The list reports where the block should go; the data is ours to change.
1415
+ function insertAt(target: Task[], moved: Task[], event: CoarDataListDropEvent<Task>) {
1416
+ const remaining = target.filter((task) => !event.keys.includes(task.id));
1417
+ const anchor = event.afterKey === null ? -1 : remaining.findIndex((task) => task.id === event.afterKey);
1418
+ remaining.splice(anchor + 1, 0, ...moved);
1419
+ return remaining;
1420
+ }
1421
+
1422
+ builder
1423
+ .items(items)
1424
+ .itemKey((task) => task.id)
1425
+ .selection('multiple')
1426
+ .reorderable()
1427
+ .dragEngine(engine)
1428
+ .dragGroup('board')
1429
+ .dragId(id)
1430
+ .density('s')
1431
+ .gap(6)
1432
+ .bordered()
1433
+ .height('18rem')
1434
+ .ariaLabel(title)
1435
+ .onReorder((event) => {
1436
+ items.value = insertAt(items.value, event.items, event);
1437
+ hint.value = `Moved ${event.keys.length} task(s) in "${title}".`;
1438
+ })
1439
+ .onItemsAdd((event) => {
1440
+ items.value = insertAt(items.value, event.items, event);
1441
+ hint.value = `Moved ${event.keys.length} task(s) from "${event.sourceId}" to "${title}".`;
1442
+ })
1443
+ .onItemsRemove((event: CoarDataListItemsRemoveEvent<Task>) => {
1444
+ items.value = items.value.filter((task) => !event.keys.includes(task.id));
1445
+ });
1446
+
1447
+ return { id, title, builder, api };
1448
+ }
1449
+
1450
+ const columns = [
1451
+ column('backlog', 'Backlog', makeTasks(['Write release notes', 'Fix login timeout', 'Design empty state', 'Review PR #42', 'Update dependencies'])),
1452
+ column('doing', 'In progress', makeTasks(['Migrate todo list', 'Grid layout'])),
1453
+ column('done', 'Done', makeTasks(['Gap prop'])),
1454
+ ];
1455
+ </script>
1456
+
1457
+ <style scoped>
1458
+ .demo {
1459
+ display: flex;
1460
+ flex-direction: column;
1461
+ gap: var(--coar-spacing-s);
1462
+ }
1463
+
1464
+ .demo__bar {
1465
+ display: flex;
1466
+ align-items: center;
1467
+ gap: var(--coar-spacing-s);
1468
+ flex-wrap: wrap;
1469
+ }
1470
+
1471
+ .demo__label,
1472
+ .demo__hint {
1473
+ font-size: var(--coar-body-caption-size);
1474
+ color: var(--coar-text-neutral-secondary);
1475
+ }
1476
+
1477
+ .demo__hint {
1478
+ margin-left: auto;
1479
+ }
1480
+
1481
+ .board {
1482
+ display: grid;
1483
+ grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
1484
+ gap: var(--coar-spacing-s);
1485
+ }
1486
+
1487
+ .board__title {
1488
+ font-weight: var(--coar-font-weight-semibold);
1489
+ }
1490
+
1491
+ .board__empty {
1492
+ color: var(--coar-text-neutral-secondary);
1493
+ }
1494
+
1495
+ .task {
1496
+ display: flex;
1497
+ align-items: center;
1498
+ gap: var(--coar-spacing-xs);
1499
+ min-width: 0;
1500
+ }
1501
+
1502
+ .task__grip {
1503
+ color: var(--coar-icon-neutral-secondary);
1504
+ flex-shrink: 0;
1505
+ cursor: grab;
1506
+ }
1507
+
1508
+ .task__text {
1509
+ display: flex;
1510
+ flex-direction: column;
1511
+ min-width: 0;
1512
+ }
1513
+
1514
+ .task__title {
1515
+ overflow: hidden;
1516
+ text-overflow: ellipsis;
1517
+ white-space: nowrap;
1518
+ }
1519
+
1520
+ .task__meta {
1521
+ font-size: var(--coar-body-caption-size);
1522
+ color: var(--coar-text-neutral-secondary);
1523
+ }
1524
+ </style>
1525
+ ```
1526
+
1527
+ ```ts
1528
+ builder
1529
+ .reorderable()
1530
+ .dragEngine('auto') // 'native' | 'pointer' | 'auto'
1531
+ .dragGroup('board').dragId('backlog')
1532
+ .onReorder((e) => { /* same list: move e.items after e.afterKey */ })
1533
+ .onItemsAdd((e) => { /* from another list: insert e.items after e.afterKey */ })
1534
+ .onItemsRemove((e) => { /* another list accepted e.items: remove them */ });
1535
+ ```
1536
+
1537
+ The drop payload names the **visible neighbours** of the insertion point (`afterKey`, `beforeKey`) plus `toIndex` among the visible items. Neighbours stay correct while a search hides rows, so apply the move relative to `afterKey` rather than by index. With `groupBy`, `group` carries the heading the items were dropped into.
1538
+
1539
+ **Rules**
1540
+
1541
+ - Reordering is off on a sorted level — the dragged order would be gone after the next re-sort. This is judged **per level**: sorted parents with manually ordered children (`childLevel: { sort: null }`) let you drag the children but not the parents. Drops from other lists are still accepted and append, and dropping **inside** a row still works on a sorted level: re-parenting changes structure, not order.
1542
+ - `canDrag(item)` vetoes single items; `canDrop(payload)` and `dragAccept` veto incoming drops.
1543
+ - Keyboard: `Ctrl`+`X` grabs the focused item (or selection), arrows / `Home` / `End` move the insertion line, `Ctrl`+`V` or `Enter` drops, `Escape` cancels.
1544
+
1545
+ ### Drag engines
1546
+
1547
+ | Engine | Input | Interoperable with | Use when |
1548
+ |---|---|---|---|
1549
+ | `native` (default) | mouse via HTML5 drag events | `CoarTree`, `CoarListbox`, other HTML5 targets | desktop apps |
1550
+ | `pointer` | mouse, pen, touch (long-press) | other data lists only | tablets, touch-first views |
1551
+ | `auto` | picks `pointer` on coarse-pointer devices | — | one setting for both |
1552
+
1553
+ The engine is a prop, so an app can decide at runtime (a device check, a user setting). It is the shared `useDragDrop` engine — `CoarListbox` offers the same `dragEngine` prop, so lists and listboxes on the pointer engine can exchange items. **Accepting OS files** (`acceptsFiles` → `files-drop`) is a plain native drop listener and works with either engine.
1554
+
1555
+ ## Grouping
1556
+
1557
+ `groupBy` returns a group name per item; groups render with a heading (customisable via the `group-header` slot) and are ordered by `sortGroups` (`'asc'` by default). Sorting applies inside each group.
1558
+
1559
+ **Demo — `data-list/demos/GroupedDataList.vue`**
1560
+
1561
+ ```vue
1562
+ <template>
1563
+ <CoarDataList
1564
+ v-model:search="search"
1565
+ :items="contacts"
1566
+ :item-key="(contact) => contact.email"
1567
+ :group-by="(contact) => contact.team"
1568
+ :sort-options="[{ key: 'name', label: 'Name' }]"
1569
+ :sort="{ key: 'name', direction: 'asc' }"
1570
+ selection="single"
1571
+ show-search
1572
+ density="s"
1573
+ bordered
1574
+ height="20rem"
1575
+ >
1576
+ <template #group-header="{ group, count }">
1577
+ <CoarIcon name="users" size="s" />
1578
+ <span>{{ group }}</span>
1579
+ <CoarBadge variant="neutral">{{ count }}</CoarBadge>
1580
+ </template>
1581
+
1582
+ <template #item="{ item, selected }">
1583
+ <div class="contact">
1584
+ <CoarAvatar :name="item.name" size="s" />
1585
+ <div class="contact__text">
1586
+ <span class="contact__name">{{ item.name }}</span>
1587
+ <span class="contact__email">{{ item.email }}</span>
1588
+ </div>
1589
+ <CoarIcon v-if="selected" name="check" size="s" class="contact__check" />
1590
+ </div>
1591
+ </template>
1592
+ </CoarDataList>
1593
+ </template>
1594
+
1595
+ <script setup lang="ts">
1596
+ import { ref } from 'vue';
1597
+ import { CoarAvatar, CoarBadge, CoarDataList, CoarIcon } from '@cocoar/vue-ui';
1598
+
1599
+ interface Contact {
1600
+ name: string;
1601
+ email: string;
1602
+ team: string;
1603
+ }
1604
+
1605
+ const teams = ['Platform', 'Design', 'Support', 'Sales'];
1606
+ const first = ['Ada', 'Grace', 'Linus', 'Margaret', 'Tim', 'Barbara', 'Ken', 'Dennis', 'Radia', 'Vint'];
1607
+ const last = ['Lovelace', 'Hopper', 'Torvalds', 'Hamilton', 'Berners-Lee', 'Liskov', 'Thompson', 'Ritchie', 'Perlman', 'Cerf'];
1608
+
1609
+ const contacts: Contact[] = first.flatMap((givenName, i) =>
1610
+ last.slice(0, 3).map((familyName, j) => ({
1611
+ name: `${givenName} ${familyName}`,
1612
+ email: `${givenName}.${familyName}@example.com`.toLowerCase(),
1613
+ team: teams[(i + j) % teams.length],
1614
+ })),
1615
+ );
1616
+
1617
+ const search = ref('');
1618
+ </script>
1619
+
1620
+ <style scoped>
1621
+ .contact {
1622
+ display: flex;
1623
+ align-items: center;
1624
+ gap: var(--coar-spacing-s);
1625
+ min-width: 0;
1626
+ }
1627
+
1628
+ .contact__text {
1629
+ display: flex;
1630
+ flex-direction: column;
1631
+ min-width: 0;
1632
+ flex: 1;
1633
+ }
1634
+
1635
+ .contact__name {
1636
+ font-weight: var(--coar-font-weight-medium);
1637
+ }
1638
+
1639
+ .contact__email {
1640
+ font-size: var(--coar-body-caption-size);
1641
+ color: var(--coar-text-neutral-secondary);
1642
+ overflow: hidden;
1643
+ text-overflow: ellipsis;
1644
+ white-space: nowrap;
1645
+ }
1646
+
1647
+ .contact__check {
1648
+ color: var(--coar-icon-accent-primary);
1649
+ }
1650
+ </style>
1651
+ ```
1652
+
1653
+ ## Large lists
1654
+
1655
+ Every list is virtualized — only the rows in and around the viewport exist in the DOM. `item-size` is the **estimate** used before a row has been measured; pick something close to the typical row so the scrollbar does not jump. Twenty thousand rows with mixed heights:
1656
+
1657
+ **Demo — `data-list/demos/LargeDataList.vue`**
1658
+
1659
+ ```vue
1660
+ <template>
1661
+ <div class="demo">
1662
+ <CoarDataList
1663
+ ref="listRef"
1664
+ v-model:search="search"
1665
+ :items="entries"
1666
+ :item-key="(entry) => entry.id"
1667
+ :search-by="['message', 'level']"
1668
+ :sort-options="sortOptions"
1669
+ selection="single"
1670
+ show-search
1671
+ show-sort
1672
+ dividers
1673
+ bordered
1674
+ height="20rem"
1675
+ :item-size="44"
1676
+ >
1677
+ <template #toolbar-right>
1678
+ <CoarButton variant="secondary" size="s" @click="listRef?.scrollToIndex(entries.length - 1, 'end')">
1679
+ Jump to last
1680
+ </CoarButton>
1681
+ </template>
1682
+
1683
+ <template #item="{ item }">
1684
+ <div class="log" :class="`log--${item.level}`">
1685
+ <span class="log__time">{{ item.time }}</span>
1686
+ <span class="log__level">{{ item.level }}</span>
1687
+ <span class="log__message">{{ item.message }}</span>
1688
+ </div>
1689
+ </template>
1690
+ </CoarDataList>
1691
+ <p class="demo__hint">{{ entries.length.toLocaleString() }} rows, heights measured per row — only the visible window is in the DOM.</p>
1692
+ </div>
1693
+ </template>
1694
+
1695
+ <script setup lang="ts">
1696
+ import { ref } from 'vue';
1697
+ import { CoarButton, CoarDataList } from '@cocoar/vue-ui';
1698
+ import type { CoarDataListSortOption } from '@cocoar/vue-ui';
1699
+
1700
+ type Level = 'info' | 'warn' | 'error';
1701
+
1702
+ interface LogEntry {
1703
+ id: number;
1704
+ time: string;
1705
+ level: Level;
1706
+ message: string;
1707
+ }
1708
+
1709
+ const messages = [
1710
+ 'Connection established',
1711
+ 'Retrying request after transient failure; backoff window increased to 4 seconds while the upstream recovers',
1712
+ 'Cache miss',
1713
+ 'Certificate expires in 14 days — rotation scheduled by the platform team, no action required from tenants',
1714
+ 'Queue drained',
1715
+ ];
1716
+
1717
+ const entries: LogEntry[] = Array.from({ length: 20000 }, (_, index) => ({
1718
+ id: index,
1719
+ time: new Date(Date.UTC(2026, 8, 1, 0, 0, index)).toISOString().slice(11, 19),
1720
+ level: index % 47 === 0 ? 'error' : index % 11 === 0 ? 'warn' : 'info',
1721
+ message: messages[index % messages.length],
1722
+ }));
1723
+
1724
+ const sortOptions: CoarDataListSortOption<LogEntry>[] = [
1725
+ { key: 'time', label: 'Time' },
1726
+ { key: 'level', label: 'Level' },
1727
+ ];
1728
+
1729
+ const search = ref('');
1730
+ const listRef = ref<InstanceType<typeof CoarDataList> | null>(null);
1731
+ </script>
1732
+
1733
+ <style scoped>
1734
+ .demo {
1735
+ display: flex;
1736
+ flex-direction: column;
1737
+ gap: var(--coar-spacing-s);
1738
+ }
1739
+
1740
+ .demo__hint {
1741
+ margin: 0;
1742
+ color: var(--coar-text-neutral-secondary);
1743
+ font-size: var(--coar-body-caption-size);
1744
+ }
1745
+
1746
+ .log {
1747
+ display: grid;
1748
+ grid-template-columns: 5rem 3.5rem minmax(0, 1fr);
1749
+ gap: var(--coar-spacing-s);
1750
+ align-items: baseline;
1751
+ font-family: var(--coar-font-family-mono, monospace);
1752
+ font-size: var(--coar-body-caption-size);
1753
+ }
1754
+
1755
+ .log__time {
1756
+ color: var(--coar-text-neutral-secondary);
1757
+ font-variant-numeric: tabular-nums;
1758
+ }
1759
+
1760
+ .log__level {
1761
+ text-transform: uppercase;
1762
+ font-weight: var(--coar-font-weight-semibold);
1763
+ }
1764
+
1765
+ .log--warn .log__level {
1766
+ color: var(--coar-text-warning-primary, #b26a00);
1767
+ }
1768
+
1769
+ .log--error .log__level {
1770
+ color: var(--coar-text-error-primary, #c0392b);
1771
+ }
1772
+
1773
+ .log__message {
1774
+ min-width: 0;
1775
+ overflow-wrap: anywhere;
1776
+ }
1777
+ </style>
1778
+ ```
1779
+
1780
+ Group headings scroll with the content (they are not sticky), and browser find-in-page only sees rendered rows.
1781
+
1782
+ ## Headless: `useDataListModel`
1783
+
1784
+ The component is a thin renderer over `useDataListModel`, which owns the pipeline **filter → search → sort → group** and key-based selection. Use it directly when you render something other than a vertical list — cards, a kanban column, a map sidebar — and still want the same search, sort and selection semantics.
1785
+
1786
+ **Demo — `data-list/demos/HeadlessDataList.vue`**
1787
+
1788
+ ```vue
1789
+ <template>
1790
+ <div class="demo">
1791
+ <div class="demo__bar">
1792
+ <CoarTextInput v-model="search" placeholder="Filter products…" size="s" clearable style="flex: 1" />
1793
+ <CoarSegmentedControl v-model="sortKey" :options="sortChoices" size="s" />
1794
+ <span class="demo__count">{{ list.count.value }} / {{ list.total.value }}</span>
1795
+ </div>
1796
+
1797
+ <div class="cards">
1798
+ <button
1799
+ v-for="product in list.items.value"
1800
+ :key="product.sku"
1801
+ type="button"
1802
+ class="card"
1803
+ :class="{ 'card--selected': list.isSelected(product.sku) }"
1804
+ @click="list.select(product.sku, $event.ctrlKey || $event.metaKey ? 'toggle' : 'replace')"
1805
+ >
1806
+ <span class="card__name">{{ product.name }}</span>
1807
+ <span class="card__price">{{ product.price.toFixed(2) }} €</span>
1808
+ <span class="card__stock" :class="{ 'card__stock--low': product.stock < 5 }">{{ product.stock }} in stock</span>
1809
+ </button>
1810
+ </div>
1811
+ </div>
1812
+ </template>
1813
+
1814
+ <script setup lang="ts">
1815
+ import { computed, ref } from 'vue';
1816
+ import { CoarSegmentedControl, CoarTextInput, useDataListModel } from '@cocoar/vue-ui';
1817
+ import type { CoarDataListSortOption } from '@cocoar/vue-ui';
1818
+
1819
+ interface Product {
1820
+ sku: string;
1821
+ name: string;
1822
+ price: number;
1823
+ stock: number;
1824
+ }
1825
+
1826
+ const products: Product[] = [
1827
+ { sku: 'A-1', name: 'Anchor bolt M12', price: 1.2, stock: 240 },
1828
+ { sku: 'A-2', name: 'Ångström ruler', price: 18.5, stock: 3 },
1829
+ { sku: 'B-1', name: 'Brass hinge', price: 4.75, stock: 61 },
1830
+ { sku: 'C-1', name: 'Cable tie 200 mm', price: 0.08, stock: 5000 },
1831
+ { sku: 'C-2', name: 'Café table leg', price: 32, stock: 2 },
1832
+ { sku: 'D-1', name: 'Drill bit set', price: 24.9, stock: 17 },
1833
+ { sku: 'E-1', name: 'Étagère bracket', price: 6.3, stock: 0 },
1834
+ { sku: 'F-1', name: 'Felt pad 10 pack', price: 2.1, stock: 88 },
1835
+ ];
1836
+
1837
+ const sortOptions: CoarDataListSortOption<Product>[] = [
1838
+ { key: 'name', label: 'Name' },
1839
+ { key: 'price', label: 'Price' },
1840
+ { key: 'stock', label: 'Stock' },
1841
+ ];
1842
+ const sortChoices = sortOptions.map((option) => ({ value: option.key, label: option.label }));
1843
+
1844
+ const search = ref('');
1845
+ const sortKey = ref('name');
1846
+
1847
+ // The same pipeline the component uses — rendered here as a card grid.
1848
+ const list = useDataListModel<Product>({
1849
+ items: products,
1850
+ itemKey: (product) => product.sku,
1851
+ search,
1852
+ searchBy: ['name', 'sku'],
1853
+ sort: computed(() => ({ key: sortKey.value, direction: 'asc' as const })),
1854
+ sortOptions,
1855
+ selectionMode: 'multiple',
1856
+ });
1857
+ </script>
1858
+
1859
+ <style scoped>
1860
+ .demo {
1861
+ display: flex;
1862
+ flex-direction: column;
1863
+ gap: var(--coar-spacing-s);
1864
+ }
1865
+
1866
+ .demo__bar {
1867
+ display: flex;
1868
+ align-items: center;
1869
+ gap: var(--coar-spacing-s);
1870
+ }
1871
+
1872
+ .demo__count {
1873
+ color: var(--coar-text-neutral-secondary);
1874
+ font-size: var(--coar-body-caption-size);
1875
+ white-space: nowrap;
1876
+ }
1877
+
1878
+ .cards {
1879
+ display: grid;
1880
+ grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
1881
+ gap: var(--coar-spacing-s);
1882
+ }
1883
+
1884
+ .card {
1885
+ display: flex;
1886
+ flex-direction: column;
1887
+ gap: var(--coar-spacing-xxs);
1888
+ padding: var(--coar-spacing-s);
1889
+ border: 1px solid var(--coar-border-neutral);
1890
+ border-radius: var(--coar-radius-s);
1891
+ background: var(--coar-surface-neutral-primary, transparent);
1892
+ color: inherit;
1893
+ font: inherit;
1894
+ text-align: left;
1895
+ cursor: pointer;
1896
+ }
1897
+
1898
+ .card--selected {
1899
+ border-color: var(--coar-border-accent-primary);
1900
+ background: var(--coar-background-accent-tertiary);
1901
+ }
1902
+
1903
+ .card__name {
1904
+ font-weight: var(--coar-font-weight-semibold);
1905
+ }
1906
+
1907
+ .card__price {
1908
+ font-variant-numeric: tabular-nums;
1909
+ }
1910
+
1911
+ .card__stock {
1912
+ font-size: var(--coar-body-caption-size);
1913
+ color: var(--coar-text-neutral-secondary);
1914
+ }
1915
+
1916
+ .card__stock--low {
1917
+ color: var(--coar-text-error-primary, #c0392b);
1918
+ }
1919
+ </style>
1920
+ ```
1921
+
1922
+ ```ts
1923
+ const list = useDataListModel<Product>({
1924
+ items: products, // array, ref or getter
1925
+ itemKey: (p) => p.sku,
1926
+ search, // Ref<string>
1927
+ searchBy: ['name', 'sku'],
1928
+ sort: computed(() => ({ key: sortKey.value, direction: 'asc' })),
1929
+ sortOptions,
1930
+ selectionMode: 'multiple',
1931
+ });
1932
+
1933
+ list.items.value // visible, ordered
1934
+ list.select(sku, 'toggle')
1935
+ list.selectedItems.value
1936
+ ```
1937
+
1938
+ ## Sorting
1939
+
1940
+ `sortOptions` describes the sort menu. Each option compares `item[key]`, or the value returned by `by`, or uses a full `compare` function:
1941
+
1942
+ ```ts
1943
+ const sortOptions: CoarDataListSortOption<Ticket>[] = [
1944
+ { key: 'title', label: 'Title' }, // item.title
1945
+ { key: 'due', label: 'Due date', by: (t) => t.dueDate }, // extractor
1946
+ { key: 'priority', label: 'Priority', defaultDirection: 'desc' }, // menu picks desc first
1947
+ { key: 'smart', label: 'Smart', compare: (a, b) => rank(a) - rank(b) },
1948
+ ];
1949
+ ```
1950
+
1951
+ Values are compared with `Intl.Collator` for the active language (`useI18n().language`): numeric strings sort naturally (`item 9` before `item 10`), case and diacritics are ignored, numbers and dates compare as such, and `null`/`undefined` sort last in ascending order. Sorting is stable, so ties keep the input order. The same comparator is exported as `createValueComparator(locale)`.
1952
+
1953
+ ## Search
1954
+
1955
+ The query is split on whitespace into terms; **every term must occur** in the item's search text. Matching is case-insensitive and diacritic-insensitive (`cafe` finds `Café`). The search text comes from `searchBy`:
1956
+
1957
+ | `searchBy` | Search text |
1958
+ |---|---|
1959
+ | omitted | every own string, number and boolean property, joined |
1960
+ | `['title', 'customer']` | those properties, joined |
1961
+ | `(item) => string` | whatever you return |
1962
+
1963
+ `search-highlight` marks matches in the rendered rows through the CSS Custom Highlight API (`::highlight(coar-data-list-search)`); browsers without it show no highlight. The utilities `normalizeSearchText`, `searchTerms` and `matchesSearchTerms` are exported for custom filters.
1964
+
1965
+ ## Keyboard
1966
+
1967
+ The scroll area is the single tab stop; a focus marker moves between items.
1968
+
1969
+ | Key | Action |
1970
+ |---|---|
1971
+ | `↓` / `↑` | Move focus by one row (one tile row in grid layout); selects the focused item unless `Ctrl` is held; `Shift` extends the range |
1972
+ | `→` / `←` | Tile rows: move focus by one tile. List rows: expand / step into the first child, collapse / jump to the parent |
1973
+ | `+` / `-` | Expand / collapse the focused item (any layout) |
1974
+ | `Home` / `End` | First / last item |
1975
+ | `PageDown` / `PageUp` | Move by one viewport |
1976
+ | `Space` | Toggle the focused item |
1977
+ | `Enter` | `item-activate` |
1978
+ | `Ctrl`/`⌘` + `A` | Select all visible items (multiple) |
1979
+ | `Ctrl`+`X` → arrows → `Ctrl`+`V` / `Enter` | Grab, move, drop (`reorderable`); `Escape` cancels |
1980
+
1981
+ ARIA: `role="listbox"` with `option` children when selection is enabled, `role="list"` / `listitem` otherwise.
1982
+
1983
+ ## API
1984
+
1985
+ ### Props
1986
+
1987
+ | Prop | Type | Default | Description |
1988
+ |------|------|---------|-------------|
1989
+ | `builder` | `DataListBuilder<T>` | — | Fluent builder from `useDataList()`. When set, the other config props and the `v-model`s are ignored |
1990
+ | `items` | `T[]` | `[]` | Records to display |
1991
+ | `itemKey` | `(item: T) => string \| number` | — | Stable identity (required in props-mode). Must be unique across all levels — selection, focus, measured heights and drag & drop are stored under it; duplicates warn in DEV |
1992
+ | `searchBy` | `(keyof T)[] \| (item: T) => string` | all primitive props | Text the search matches against |
1993
+ | `filter` | `(item: T) => boolean` | — | Predicate applied before the search |
1994
+ | `sortOptions` | `CoarDataListSortOption<T>[]` | `[]` | Sort menu entries |
1995
+ | `groupBy` | `(item: T) => string` | — | Group items under headings |
1996
+ | `sortGroups` | `'asc' \| 'desc' \| 'none' \| (a, b) => number` | `'asc'` | Group order |
1997
+ | `selection` | `'none' \| 'single' \| 'multiple'` | `'none'` | Selection behaviour |
1998
+ | `showSearch` | `boolean` | `false` | Search input in the toolbar |
1999
+ | `showSort` | `boolean` | `false` | Sort control in the toolbar (needs `sortOptions`) |
2000
+ | `searchPlaceholder` | `string` | `'Search…'` | Placeholder of the search input |
2001
+ | `searchHighlight` | `boolean` | `false` | Highlight matches in rendered rows |
2002
+ | `layout` | `'list' \| 'grid'` | `'list'` | Rows, or tiles wrapping in data order |
2003
+ | `tileMinWidth` | `number \| string` | `'14rem'` | Minimum tile width in grid layout; decides the column count |
2004
+ | `density` | `'s' \| 'm' \| 'l'` | `'m'` | Row padding |
2005
+ | `dividers` | `boolean` | `false` | Line between rows (list layout) |
2006
+ | `gap` | `number \| string` | — | Space between rows (px number or CSS length); use this, not a template margin |
2007
+ | `bordered` | `boolean` | `false` | Border around the list |
2008
+ | `elevated` | `boolean` | `false` | Elevation shadow |
2009
+ | `height` | `string` | fills parent | Fixed height of the scroll area |
2010
+ | `itemSize` | `number` | `56` | Estimated row height in px (rows are measured) |
2011
+ | `overscan` | `number` | `5` | Rows rendered beyond the viewport |
2012
+ | `emptyText` | `string` | `'No items'` | Text when nothing is visible |
2013
+ | `ariaLabel` | `string` | — | Accessible name of the list |
2014
+ | `disabled` | `boolean` | `false` | Blocks interaction |
2015
+ | `reorderable` | `boolean` | `false` | Drag & drop reordering and accepting drops |
2016
+ | `dragEngine` | `'native' \| 'pointer' \| 'auto'` | `'native'` | How drags are tracked (see [Drag engines](#drag-engines)) |
2017
+ | `canDrag` | `(item: T) => boolean` | — | Per-item drag veto |
2018
+ | `dragGroup` | `string` | — | Lists sharing a group accept each other's items |
2019
+ | `dragId` | `string` | — | Reported to targets as `sourceId` |
2020
+ | `dragAccept` | `string[]` | — | Whitelist of source `dragId`s |
2021
+ | `canDrop` | `(payload) => boolean` | — | Runtime veto for incoming drops |
2022
+ | `acceptsFiles` | `boolean` | `false` | Accept OS file drops (`files-drop`) |
2023
+ | `children` | `(item: T) => T[] \| null \| undefined` | — | Nested lists |
2024
+ | `childLevel` | `{ sortOptions?, sort?, layout?, tileMinWidth? }` | inherits | Sorting and layout of the child levels |
2025
+ | `tileCards` | `boolean` | `false` | Grid: draw tiles as cards; an expanded card opens into its band |
2026
+ | `bandElevated` | `boolean` | `false` | Lift an expanded card and its band with a shadow |
2027
+ | `unstyledItems` | `boolean` | `false` | The template owns the whole box; the list draws no item chrome |
2028
+ | `maxDepth` | `number` | unlimited | Deepest level shown, 0 = top level only |
2029
+ | `nestingIndent` | `number \| string` | `'1.5rem'` | Indent per level |
2030
+ | `nestingStyle` | `'lines' \| 'none'` | `'lines'` | Guide lines per level and band frames, or indent only |
2031
+ | `hideExpandToggle` | `boolean` | `false` | No built-in chevrons |
2032
+ | `canNest` | `(item: T, parent: T) => boolean` | — | Veto for dropping inside a row |
2033
+
2034
+ ### Models
2035
+
2036
+ | Model | Type | Description |
2037
+ |-------|------|-------------|
2038
+ | `v-model:search` | `string` | Search query |
2039
+ | `v-model:sort` | `CoarDataListSort \| null` | `{ key, direction }` of the active sort option |
2040
+ | `v-model:selected` | `(string \| number)[]` | Selected item keys, in selection order |
2041
+ | `v-model:expanded` | `(string \| number)[]` | Keys whose children are shown |
2042
+
2043
+ ### Events
2044
+
2045
+ | Event | Payload | Description |
2046
+ |-------|---------|-------------|
2047
+ | `item-click` | `CoarDataListItemEvent<T>` | Pointer click on an item (after selection changed) |
2048
+ | `item-dblclick` | `CoarDataListItemEvent<T>` | Double-click |
2049
+ | `item-contextmenu` | `CoarDataListItemEvent<T>` | Right-click / long-press; item is selected first |
2050
+ | `item-activate` | `CoarDataListItemEvent<T>` | Double-click or `Enter` |
2051
+ | `reorder` | `CoarDataListDropEvent<T>` | Items dropped inside this list |
2052
+ | `items-add` | `CoarDataListDropEvent<T>` | Items from another list dropped here |
2053
+ | `items-remove` | `CoarDataListItemsRemoveEvent<T>` | Another list accepted items of this one |
2054
+ | `files-drop` | `CoarDataListFilesDropEvent<T>` | OS files dropped (`acceptsFiles`) |
2055
+ | `drag-start` / `drag-end` | `T[]` / `{ items, dropped }` | Drag lifecycle |
2056
+
2057
+ `CoarDataListItemEvent<T>` is `{ item, itemKey, index, event }`. `CoarDataListDropEvent<T>` is `{ items, keys, toIndex, afterKey, beforeKey, group, parentKey, fromSelf, sourceId, sourceDragGroup }` — `toIndex` and the neighbour keys refer to the siblings under `parentKey`.
2058
+
2059
+ ### Slots
2060
+
2061
+ | Slot | Props | Description |
2062
+ |------|-------|-------------|
2063
+ | `item` | `{ item, index, itemKey, selected, focused, dragging, depth, hasChildren, expanded, select, toggle, toggleExpanded }` | Row content |
2064
+ | `group-header` | `{ group, count, items }` | Group heading |
2065
+ | `empty` | — | Shown when no item is visible |
2066
+ | `toolbar-left` / `toolbar-right` | — | Extra toolbar content beside search and sort |
2067
+
2068
+ ### Exposed
2069
+
2070
+ | Member | Description |
2071
+ |--------|-------------|
2072
+ | `list` | The `useDataListModel` instance (visible items, selection, lookups) |
2073
+ | `scrollToKey(key, align?)` | Scroll an item into view |
2074
+ | `scrollToIndex(index, align?)` | Scroll the visible item at `index` into view |
2075
+ | `focusKey(key)` | Move the focus marker and scroll to it |
2076
+ | `invalidateMeasurements(key?)` | Forget measured heights (all or one) after a layout change the list cannot observe |
2077
+
2078
+ ### `useDataListModel(options)`
2079
+
2080
+ | Option | Type | Description |
2081
+ |--------|------|-------------|
2082
+ | `items` | `MaybeRefOrGetter<T[]>` | Source records |
2083
+ | `itemKey` | `(item: T) => key` | Identity |
2084
+ | `search` | `MaybeRefOrGetter<string>` | Query |
2085
+ | `searchBy` | `MaybeRef<...>` | Field list or extractor (plain value or ref) |
2086
+ | `filter` | `MaybeRef<(item: T) => boolean>` | Predicate |
2087
+ | `sort` | `MaybeRefOrGetter<CoarDataListSort \| null>` | Active sort |
2088
+ | `sortOptions` | `MaybeRefOrGetter<CoarDataListSortOption<T>[]>` | Sort definitions |
2089
+ | `groupBy` | `MaybeRef<(item: T) => string>` | Grouping |
2090
+ | `sortGroups` | `MaybeRef<CoarDataListSortGroups>` | Group order |
2091
+ | `locale` | `MaybeRefOrGetter<string>` | Collation locale (default: runtime) |
2092
+ | `selectionMode` | `MaybeRefOrGetter<'none' \| 'single' \| 'multiple'>` | Default `'multiple'` |
2093
+ | `selected` | `Ref<key[]>` | External selection model |
2094
+
2095
+ Returns `items`, `entries` (with group headings), `total`, `count`, `keyOf`, `itemByKey`, `indexOfKey`, `entryIndexOfKey`, `selected`, `selectedItems`, `anchor`, `isSelected`, `select(key, mode?)`, `selectAll()`, `clear()`.
2096
+
2097
+ ## i18n Keys
2098
+
2099
+ These keys can be translated via [`@cocoar/vue-localization`](../foundations/localization/translations.md).
2100
+
2101
+ | Key | Default (English) | Used as |
2102
+ |-----|-------------------|---------|
2103
+ | `coar.ui.dataList.search` | `'Search…'` | Placeholder of the toolbar search input |
2104
+ | `coar.ui.dataList.sortBy` | `'Sort by'` | Placeholder of the sort select |
2105
+ | `coar.ui.dataList.ascending` | `'Ascending'` | Label of the sort-direction button |
2106
+ | `coar.ui.dataList.descending` | `'Descending'` | Label of the sort-direction button |
2107
+ | `coar.ui.dataList.empty` | `'No items'` | Empty state when no item is visible |
2108
+ | `coar.ui.dataList.expand` | `'Expand'` | Chevron label of a collapsed parent |
2109
+ | `coar.ui.dataList.collapse` | `'Collapse'` | Chevron label of an expanded parent |
2110
+
2111
+ ## Grid or list?
2112
+
2113
+ | | `CoarDataGrid` | `CoarDataList` |
2114
+ |---|---|---|
2115
+ | Layout | columns, resizable, persisted | free template per record |
2116
+ | Best on | wide screens, many comparable fields | narrow screens, records with a natural "headline + details" shape |
2117
+ | Sorting | column headers | sort menu (`sortOptions`) |
2118
+ | Editing, tree data, column picker | yes | no |
2119
+ | Dependencies | AG Grid | none |
2120
+
2121
+ Both take the same data; apps that switch between them (a view-mode toggle) can share the search text and the sort definitions.