@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.
- package/bin/cli.mjs +53 -0
- package/dist/components/checkbox/CoarCheckbox.vue.d.ts +1 -1
- package/dist/components/checkbox/CoarCheckbox.vue.d.ts.map +1 -1
- package/dist/components/code-block/CoarCodeBlock.vue.d.ts +1 -1
- package/dist/components/code-block/CoarCodeBlock.vue.d.ts.map +1 -1
- package/dist/components/data-list/CoarDataList.vue.d.ts +158 -0
- package/dist/components/data-list/CoarDataList.vue.d.ts.map +1 -0
- package/dist/components/data-list/CoarDataListToolbar.vue.d.ts +30 -0
- package/dist/components/data-list/CoarDataListToolbar.vue.d.ts.map +1 -0
- package/dist/components/data-list/data-list-builder.d.ts +288 -0
- package/dist/components/data-list/data-list-builder.d.ts.map +1 -0
- package/dist/components/data-list/index.d.ts +12 -0
- package/dist/components/data-list/index.d.ts.map +1 -0
- package/dist/components/data-list/internal/compare.d.ts +14 -0
- package/dist/components/data-list/internal/compare.d.ts.map +1 -0
- package/dist/components/data-list/internal/reorder-core.d.ts +46 -0
- package/dist/components/data-list/internal/reorder-core.d.ts.map +1 -0
- package/dist/components/data-list/internal/search.d.ts +16 -0
- package/dist/components/data-list/internal/search.d.ts.map +1 -0
- package/dist/components/data-list/internal/useDataListLines.d.ts +92 -0
- package/dist/components/data-list/internal/useDataListLines.d.ts.map +1 -0
- package/dist/components/data-list/internal/useDataListReorder.d.ts +68 -0
- package/dist/components/data-list/internal/useDataListReorder.d.ts.map +1 -0
- package/dist/components/data-list/internal/useSearchHighlight.d.ts +19 -0
- package/dist/components/data-list/internal/useSearchHighlight.d.ts.map +1 -0
- package/dist/components/data-list/types.d.ts +185 -0
- package/dist/components/data-list/types.d.ts.map +1 -0
- package/dist/components/data-list/useDataListModel.d.ts +79 -0
- package/dist/components/data-list/useDataListModel.d.ts.map +1 -0
- package/dist/components/date-time/_shared/maskito-config.d.ts +7 -2
- package/dist/components/date-time/_shared/maskito-config.d.ts.map +1 -1
- package/dist/components/date-time/_shared/time-helpers.d.ts +11 -4
- package/dist/components/date-time/_shared/time-helpers.d.ts.map +1 -1
- package/dist/components/date-time/_shared/use-date-picker-base.d.ts +1 -1
- package/dist/components/date-time/plain-date-picker/CoarPlainDatePicker.vue.d.ts +1 -1
- package/dist/components/date-time/plain-date-picker/CoarPlainDatePicker.vue.d.ts.map +1 -1
- package/dist/components/date-time/plain-date-time-picker/CoarPlainDateTimePicker.vue.d.ts +1 -1
- package/dist/components/date-time/plain-date-time-picker/CoarPlainDateTimePicker.vue.d.ts.map +1 -1
- package/dist/components/date-time/zoned-date-time-picker/CoarZonedDateTimePicker.vue.d.ts +1 -1
- package/dist/components/date-time/zoned-date-time-picker/CoarZonedDateTimePicker.vue.d.ts.map +1 -1
- package/dist/components/dual-listbox/CoarDualListbox.vue.d.ts +3 -0
- package/dist/components/dual-listbox/CoarDualListbox.vue.d.ts.map +1 -1
- package/dist/components/form-field/CoarFormField.vue.d.ts +1 -1
- package/dist/components/form-field/CoarFormField.vue.d.ts.map +1 -1
- package/dist/components/icon/CoarIcon.vue.d.ts +2 -2
- package/dist/components/icon/CoarIcon.vue.d.ts.map +1 -1
- package/dist/components/listbox/CoarListbox.vue.d.ts +7 -0
- package/dist/components/listbox/CoarListbox.vue.d.ts.map +1 -1
- package/dist/components/number-input/CoarNumberInput.vue.d.ts +1 -1
- package/dist/components/number-input/CoarNumberInput.vue.d.ts.map +1 -1
- package/dist/components/otp-input/CoarOtpInput.vue.d.ts +1 -1
- package/dist/components/otp-input/CoarOtpInput.vue.d.ts.map +1 -1
- package/dist/components/password-input/CoarPasswordInput.vue.d.ts +1 -1
- package/dist/components/password-input/CoarPasswordInput.vue.d.ts.map +1 -1
- package/dist/components/switch/CoarSwitch.vue.d.ts +1 -1
- package/dist/components/switch/CoarSwitch.vue.d.ts.map +1 -1
- package/dist/components/text-input/CoarTextInput.vue.d.ts +1 -1
- package/dist/components/text-input/CoarTextInput.vue.d.ts.map +1 -1
- package/dist/composables/dragRegistry.d.ts +12 -0
- package/dist/composables/dragRegistry.d.ts.map +1 -1
- package/dist/composables/index.d.ts +2 -2
- package/dist/composables/index.d.ts.map +1 -1
- package/dist/composables/useDragDrop.d.ts +62 -6
- package/dist/composables/useDragDrop.d.ts.map +1 -1
- package/dist/composables/useVirtualList.d.ts +21 -1
- package/dist/composables/useVirtualList.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4508 -2039
- package/package.json +9 -3
- package/skills/cocoar-vue-ui/SKILL.md +244 -0
- package/skills/cocoar-vue-ui/references/components/avatar.md +178 -0
- package/skills/cocoar-vue-ui/references/components/badge.md +290 -0
- package/skills/cocoar-vue-ui/references/components/breadcrumb.md +238 -0
- package/skills/cocoar-vue-ui/references/components/button.md +252 -0
- package/skills/cocoar-vue-ui/references/components/calendar/agenda-view.md +372 -0
- package/skills/cocoar-vue-ui/references/components/calendar/coar-calendar.md +1647 -0
- package/skills/cocoar-vue-ui/references/components/calendar/day-view.md +361 -0
- package/skills/cocoar-vue-ui/references/components/calendar/index.md +312 -0
- package/skills/cocoar-vue-ui/references/components/calendar/month-view.md +486 -0
- package/skills/cocoar-vue-ui/references/components/calendar/performance.md +186 -0
- package/skills/cocoar-vue-ui/references/components/calendar/timeline-view.md +301 -0
- package/skills/cocoar-vue-ui/references/components/calendar/week-view.md +382 -0
- package/skills/cocoar-vue-ui/references/components/calendar/work-week-view.md +145 -0
- package/skills/cocoar-vue-ui/references/components/calendar/year-view.md +63 -0
- package/skills/cocoar-vue-ui/references/components/card.md +151 -0
- package/skills/cocoar-vue-ui/references/components/checkbox-group.md +103 -0
- package/skills/cocoar-vue-ui/references/components/checkbox.md +279 -0
- package/skills/cocoar-vue-ui/references/components/code-block.md +241 -0
- package/skills/cocoar-vue-ui/references/components/context-menu.md +355 -0
- package/skills/cocoar-vue-ui/references/components/data-grid/checkbox.md +213 -0
- package/skills/cocoar-vue-ui/references/components/data-grid/date-columns.md +272 -0
- package/skills/cocoar-vue-ui/references/components/data-grid/editing.md +236 -0
- package/skills/cocoar-vue-ui/references/components/data-grid/multi-select.md +193 -0
- package/skills/cocoar-vue-ui/references/components/data-grid/number.md +147 -0
- package/skills/cocoar-vue-ui/references/components/data-grid/select.md +202 -0
- package/skills/cocoar-vue-ui/references/components/data-grid/text.md +127 -0
- package/skills/cocoar-vue-ui/references/components/data-grid.md +1225 -0
- package/skills/cocoar-vue-ui/references/components/data-list.md +2121 -0
- package/skills/cocoar-vue-ui/references/components/date-or-time-picker.md +200 -0
- package/skills/cocoar-vue-ui/references/components/date-picker.md +203 -0
- package/skills/cocoar-vue-ui/references/components/date-time-picker.md +154 -0
- package/skills/cocoar-vue-ui/references/components/date-views.md +212 -0
- package/skills/cocoar-vue-ui/references/components/dialog.md +148 -0
- package/skills/cocoar-vue-ui/references/components/divider.md +157 -0
- package/skills/cocoar-vue-ui/references/components/document-viewer/annotations.md +400 -0
- package/skills/cocoar-vue-ui/references/components/document-viewer/coar-document-viewer.md +327 -0
- package/skills/cocoar-vue-ui/references/components/document-viewer/index.md +234 -0
- package/skills/cocoar-vue-ui/references/components/document-viewer/toolbar.md +221 -0
- package/skills/cocoar-vue-ui/references/components/drag-drop.md +267 -0
- package/skills/cocoar-vue-ui/references/components/dual-listbox.md +449 -0
- package/skills/cocoar-vue-ui/references/components/file-explorer/asset-store.md +327 -0
- package/skills/cocoar-vue-ui/references/components/file-explorer/in-memory-store.md +176 -0
- package/skills/cocoar-vue-ui/references/components/file-explorer/index.md +311 -0
- package/skills/cocoar-vue-ui/references/components/file-explorer/use-file-explorer.md +300 -0
- package/skills/cocoar-vue-ui/references/components/form-field.md +1025 -0
- package/skills/cocoar-vue-ui/references/components/fragment-parser.md +298 -0
- package/skills/cocoar-vue-ui/references/components/link.md +235 -0
- package/skills/cocoar-vue-ui/references/components/listbox.md +575 -0
- package/skills/cocoar-vue-ui/references/components/map/editor.md +448 -0
- package/skills/cocoar-vue-ui/references/components/map/index.md +351 -0
- package/skills/cocoar-vue-ui/references/components/markdown-diagrams.md +210 -0
- package/skills/cocoar-vue-ui/references/components/markdown-editor.md +1478 -0
- package/skills/cocoar-vue-ui/references/components/markdown-embeds.md +387 -0
- package/skills/cocoar-vue-ui/references/components/markdown-form.md +447 -0
- package/skills/cocoar-vue-ui/references/components/markdown.md +276 -0
- package/skills/cocoar-vue-ui/references/components/menu.md +380 -0
- package/skills/cocoar-vue-ui/references/components/mermaid.md +172 -0
- package/skills/cocoar-vue-ui/references/components/navbar.md +147 -0
- package/skills/cocoar-vue-ui/references/components/note.md +96 -0
- package/skills/cocoar-vue-ui/references/components/notice.md +131 -0
- package/skills/cocoar-vue-ui/references/components/number-input.md +215 -0
- package/skills/cocoar-vue-ui/references/components/otp-input.md +324 -0
- package/skills/cocoar-vue-ui/references/components/page-builder/authoring-contract.md +271 -0
- package/skills/cocoar-vue-ui/references/components/page-builder/coar-page-builder.md +476 -0
- package/skills/cocoar-vue-ui/references/components/page-builder/coar-page-renderer.md +677 -0
- package/skills/cocoar-vue-ui/references/components/page-builder/custom-elements.md +441 -0
- package/skills/cocoar-vue-ui/references/components/page-builder/idp-integration.md +122 -0
- package/skills/cocoar-vue-ui/references/components/page-builder/index.md +573 -0
- package/skills/cocoar-vue-ui/references/components/pagination.md +150 -0
- package/skills/cocoar-vue-ui/references/components/panel-layout.md +372 -0
- package/skills/cocoar-vue-ui/references/components/password-input.md +175 -0
- package/skills/cocoar-vue-ui/references/components/popconfirm.md +186 -0
- package/skills/cocoar-vue-ui/references/components/popover.md +173 -0
- package/skills/cocoar-vue-ui/references/components/progress-bar.md +178 -0
- package/skills/cocoar-vue-ui/references/components/radio-group.md +225 -0
- package/skills/cocoar-vue-ui/references/components/script-editor.md +1271 -0
- package/skills/cocoar-vue-ui/references/components/segmented-control.md +238 -0
- package/skills/cocoar-vue-ui/references/components/select.md +463 -0
- package/skills/cocoar-vue-ui/references/components/sidebar.md +421 -0
- package/skills/cocoar-vue-ui/references/components/spinner.md +132 -0
- package/skills/cocoar-vue-ui/references/components/switch.md +195 -0
- package/skills/cocoar-vue-ui/references/components/table.md +170 -0
- package/skills/cocoar-vue-ui/references/components/tabs.md +382 -0
- package/skills/cocoar-vue-ui/references/components/tag.md +178 -0
- package/skills/cocoar-vue-ui/references/components/text-input.md +256 -0
- package/skills/cocoar-vue-ui/references/components/toast.md +160 -0
- package/skills/cocoar-vue-ui/references/components/tooltip.md +121 -0
- package/skills/cocoar-vue-ui/references/components/transitions.md +193 -0
- package/skills/cocoar-vue-ui/references/components/tree.md +2388 -0
- package/skills/cocoar-vue-ui/references/components/virtual-list.md +212 -0
- package/skills/cocoar-vue-ui/references/components/wizard.md +251 -0
- package/skills/cocoar-vue-ui/references/components/zoned-date-time-picker.md +177 -0
- package/skills/cocoar-vue-ui/references/foundations/colors.md +708 -0
- package/skills/cocoar-vue-ui/references/foundations/design-principles.md +115 -0
- package/skills/cocoar-vue-ui/references/foundations/icons.md +381 -0
- package/skills/cocoar-vue-ui/references/foundations/localization/formatting.md +530 -0
- package/skills/cocoar-vue-ui/references/foundations/localization/setup.md +86 -0
- package/skills/cocoar-vue-ui/references/foundations/localization/timezones.md +182 -0
- package/skills/cocoar-vue-ui/references/foundations/localization/translations.md +305 -0
- package/skills/cocoar-vue-ui/references/foundations/motion.md +549 -0
- package/skills/cocoar-vue-ui/references/foundations/spacing.md +330 -0
- package/skills/cocoar-vue-ui/references/foundations/theming.md +140 -0
- package/skills/cocoar-vue-ui/references/foundations/typography.md +206 -0
- package/skills/cocoar-vue-ui/references/guide/error-handling.md +162 -0
- package/skills/cocoar-vue-ui/references/guide/getting-started.md +116 -0
- package/skills/cocoar-vue-ui/references/guide/migration-page-builder-3.md +207 -0
- package/skills/cocoar-vue-ui/references/guide/migration.md +140 -0
- 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.
|