@birdycom/omniaui-vue 0.12.5 → 0.12.6
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/dist/blocks/OcBlockDataTable.vue.d.ts +1 -1
- package/dist/blocks/OcBlockProfileHeader.vue.d.ts +4 -2
- package/dist/components/OcAvatarPicker.vue.d.ts +21 -0
- package/dist/components/OcBarcode.vue.d.ts +12 -0
- package/dist/components/OcBreadcrumb.vue.d.ts +7 -1
- package/dist/components/OcButton.vue.d.ts +6 -3
- package/dist/components/OcDataTable.vue.d.ts +68 -1
- package/dist/components/OcDataTableName.vue.d.ts +20 -0
- package/dist/components/OcDateField.vue.d.ts +19 -0
- package/dist/components/OcEmojiPicker.vue.d.ts +1 -1
- package/dist/components/OcFormSection.vue.d.ts +25 -0
- package/dist/components/OcGallery.vue.d.ts +17 -0
- package/dist/components/OcGlass.vue.d.ts +2 -1
- package/dist/components/OcGlassBar.vue.d.ts +32 -0
- package/dist/components/OcLazyImage.vue.d.ts +24 -0
- package/dist/components/OcMeterRow.vue.d.ts +9 -0
- package/dist/components/{OcColumnMenu.vue.d.ts → OcNameCard.vue.d.ts} +15 -16
- package/dist/components/OcPrice.vue.d.ts +20 -0
- package/dist/components/OcQuantity.vue.d.ts +24 -0
- package/dist/components/OcRating.vue.d.ts +11 -0
- package/dist/components/OcRatingInput.vue.d.ts +18 -0
- package/dist/components/OcRibbon.vue.d.ts +22 -0
- package/dist/components/OcSpecList.vue.d.ts +15 -0
- package/dist/components/OcStock.vue.d.ts +20 -0
- package/dist/index.d.ts +22 -3
- package/dist/omniaui-vue.js +5246 -4024
- package/dist/omniaui-vue.js.map +1 -1
- package/manifest.json +102 -6
- package/package.json +1 -1
- package/src/blocks/OcBlockDataTable.vue +1 -1
- package/src/blocks/OcBlockProfileHeader.vue +15 -4
- package/src/components/OcAttachmentList.vue +1 -1
- package/src/components/OcAvatarPicker.vue +34 -0
- package/src/components/OcBarcode.vue +23 -0
- package/src/components/OcBreadcrumb.vue +14 -5
- package/src/components/OcButton.vue +10 -2
- package/src/components/OcChatComposer.vue +1 -1
- package/src/components/OcDataTable.vue +100 -25
- package/src/components/OcDataTableName.vue +18 -0
- package/src/components/OcDateField.vue +23 -0
- package/src/components/OcEmojiPicker.vue +2 -2
- package/src/components/OcFormSection.vue +19 -0
- package/src/components/OcGallery.vue +27 -0
- package/src/components/OcGlass.vue +4 -4
- package/src/components/OcGlassBar.vue +17 -0
- package/src/components/OcLazyImage.vue +17 -0
- package/src/components/OcMessageList.vue +6 -6
- package/src/components/OcMessageThread.vue +11 -11
- package/src/components/OcMeterRow.vue +18 -0
- package/src/components/OcNameCard.vue +28 -0
- package/src/components/OcPrice.vue +30 -0
- package/src/components/OcProse.vue +3 -3
- package/src/components/OcQuantity.vue +21 -0
- package/src/components/OcRating.vue +17 -0
- package/src/components/OcRatingInput.vue +17 -0
- package/src/components/OcRibbon.vue +9 -0
- package/src/components/OcSpecList.vue +17 -0
- package/src/components/OcStock.vue +13 -0
- package/src/index.ts +55 -4
- package/src/components/OcColumnMenu.vue +0 -50
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
<!-- File: packages/vue/src/components/OcDataTable.vue | Map: props -> markup (toolbar, table, footer) -> bind the list-table script on mount -->
|
|
1
|
+
<!-- File: packages/vue/src/components/OcDataTable.vue | Map: props -> markup (tabs, toolbar, filters, table, footer) -> bind the list-table script on mount -->
|
|
2
2
|
<script setup lang="ts">
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
* Search, sort, pages, columns, resize, drag and bulk actions come from data-table.js.
|
|
4
|
+
* List table for business apps. Same markup as packages/ui/snippets/data-table.html.
|
|
5
|
+
* Search, column filters, sort, pages, columns, resize, drag and bulk actions come from data-table.js.
|
|
6
6
|
* Listen on the root for oc-datatable-bulk, oc-datatable-reorder, oc-datatable-search, oc-datatable-import.
|
|
7
|
+
* Slots: cell-<key> ({ row }) for a rich cell (an OcDataTableName, a badge …), actions ({ row }) for the row buttons
|
|
8
|
+
* (default: one soft edit button), page-actions for the toolbar (New customer …).
|
|
9
|
+
* Icons of tabs and filters are webicon names (mdi:account-outline …), drawn with <iconify-icon>.
|
|
7
10
|
*/
|
|
8
11
|
import { onMounted, useTemplateRef } from 'vue'
|
|
9
12
|
import { bindDataTable } from '../../../ui/src/data-table.js'
|
|
@@ -15,14 +18,31 @@ export interface DataTableColumn {
|
|
|
15
18
|
sortable?: boolean
|
|
16
19
|
hideable?: boolean
|
|
17
20
|
defaultHidden?: boolean
|
|
21
|
+
/** Cut long text with an ellipsis at this width (e.g. "12rem"); the full text goes in the title. */
|
|
22
|
+
truncate?: string
|
|
23
|
+
/** Sort the table on this column when it starts. */
|
|
24
|
+
defaultSort?: 'ascending' | 'descending'
|
|
18
25
|
}
|
|
19
26
|
export interface DataTableRow {
|
|
20
27
|
key: string
|
|
21
28
|
status?: 'active' | 'deleted' | 'archived'
|
|
22
29
|
cells: Record<string, string>
|
|
30
|
+
/** Sort, filter, search and export value when it differs from the visible text. */
|
|
23
31
|
values?: Record<string, string>
|
|
24
32
|
href?: string
|
|
25
33
|
}
|
|
34
|
+
export interface DataTableTabs {
|
|
35
|
+
column: string
|
|
36
|
+
label?: string
|
|
37
|
+
/** tone colors the count like the type badge in the table. */
|
|
38
|
+
items: { value: string; label: string; icon?: string; count?: number; tone?: 'success' | 'info' | 'warning' }[]
|
|
39
|
+
}
|
|
40
|
+
export interface DataTableColumnFilter {
|
|
41
|
+
column: string
|
|
42
|
+
label: string
|
|
43
|
+
icon?: string
|
|
44
|
+
options: { value: string; label: string }[]
|
|
45
|
+
}
|
|
26
46
|
|
|
27
47
|
const props = withDefaults(
|
|
28
48
|
defineProps<{
|
|
@@ -33,17 +53,34 @@ const props = withDefaults(
|
|
|
33
53
|
pageSize?: number
|
|
34
54
|
searchPlaceholder?: string
|
|
35
55
|
selectable?: boolean
|
|
56
|
+
/** Grip column to drag rows; the drag is OmniaUI Pro (data-table-pro.js). */
|
|
36
57
|
draggable?: boolean
|
|
58
|
+
/** Handles to drag a column width; the resize is OmniaUI Pro (data-table-pro.js). */
|
|
59
|
+
resizable?: boolean
|
|
60
|
+
/** Column chooser in the actions header (show or hide columns). Needs the OmniaUI Pro CSS. */
|
|
61
|
+
columnMenu?: boolean
|
|
37
62
|
filters?: boolean
|
|
63
|
+
archive?: boolean
|
|
64
|
+
tabs?: DataTableTabs
|
|
65
|
+
columnFilters?: DataTableColumnFilter[]
|
|
66
|
+
/** Footer count, e.g. "Showing {from}–{to} of {total} customers". */
|
|
67
|
+
summary?: string
|
|
68
|
+
/** Offer "Import CSV" in the settings (fires oc-datatable-import with the file). */
|
|
69
|
+
importable?: boolean
|
|
70
|
+
/** 'bordered' = version 2: tinted header, lines between the columns, sort chevrons always visible. */
|
|
71
|
+
variant?: 'default' | 'bordered'
|
|
72
|
+
/** Striped rows (default true). */
|
|
73
|
+
striped?: boolean
|
|
38
74
|
live?: boolean
|
|
39
75
|
}>(),
|
|
40
|
-
{ title: 'List', pageSize: 15, searchPlaceholder: 'Search...', selectable: true, draggable:
|
|
76
|
+
{ title: 'List', pageSize: 15, searchPlaceholder: 'Search...', selectable: true, draggable: false, resizable: false, columnMenu: false, filters: true, archive: true, summary: '', importable: false, variant: 'default', striped: true, live: false },
|
|
41
77
|
)
|
|
42
78
|
|
|
43
79
|
const root = useTemplateRef<HTMLElement>('root')
|
|
44
80
|
const sizes = [...new Set([10, 15, 20, 50, 100, props.pageSize])].sort((a, b) => a - b)
|
|
45
81
|
const lead = (props.selectable ? 1 : 0) + (props.draggable ? 1 : 0)
|
|
46
82
|
const id = props.tableId || `oc-dt-${Math.random().toString(36).slice(2, 8)}`
|
|
83
|
+
const label = (row: DataTableRow) => row.values?.name ?? row.cells.name ?? row.key
|
|
47
84
|
|
|
48
85
|
onMounted(() => {
|
|
49
86
|
if (root.value) bindDataTable(root.value)
|
|
@@ -51,19 +88,34 @@ onMounted(() => {
|
|
|
51
88
|
</script>
|
|
52
89
|
|
|
53
90
|
<template>
|
|
54
|
-
<div ref="root" class="oc-data-table oc-column-menu-scope" :id="id" :data-page-size="pageSize" :data-oc-live="live ? 'true' : undefined">
|
|
91
|
+
<div ref="root" :class="['oc-data-table', variant === 'bordered' && 'oc-data-table--bordered', !striped && 'oc-data-table--plain', columnMenu && 'oc-column-menu-scope']" :id="id" :data-page-size="pageSize" :data-oc-live="live ? 'true' : undefined">
|
|
92
|
+
<div v-if="tabs" class="oc-tab-list oc-data-table-tabs" role="tablist" :aria-label="tabs.label ?? 'Filter'">
|
|
93
|
+
<button
|
|
94
|
+
v-for="(tab, i) in tabs.items"
|
|
95
|
+
:key="tab.value"
|
|
96
|
+
class="oc-tab-trigger"
|
|
97
|
+
role="tab"
|
|
98
|
+
type="button"
|
|
99
|
+
:data-oc-col-filter="tabs.column"
|
|
100
|
+
:data-oc-value="tab.value"
|
|
101
|
+
:aria-selected="i === 0 ? 'true' : 'false'"
|
|
102
|
+
><iconify-icon v-if="tab.icon" :icon="tab.icon" aria-hidden="true" />{{ tab.label }}<span v-if="tab.count != null" :class="['oc-data-table-tab-count', tab.tone && `oc-data-table-tab-count--${tab.tone}`]">{{ tab.count }}</span></button>
|
|
103
|
+
</div>
|
|
55
104
|
<div class="oc-data-table-toolbar">
|
|
56
105
|
<div class="oc-data-table-heading">
|
|
57
106
|
<span class="oc-data-table-title">{{ title }}</span>
|
|
58
|
-
<button class="oc-btn oc-btn--outline oc-btn--icon oc-btn--sm" type="button" :popovertarget="`${id}-menu`" aria-label="
|
|
107
|
+
<button class="oc-btn oc-btn--outline oc-btn--icon oc-btn--sm" type="button" :popovertarget="`${id}-menu`" aria-label="List actions" title="List actions"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 13c-1.86 0-3.41 1.28-3.86 3H2v2h2.14c.45 1.72 2 3 3.86 3s3.41-1.28 3.86-3H22v-2H11.86c-.45-1.72-2-3-3.86-3m0 6c-1.1 0-2-.9-2-2s.9-2 2-2s2 .9 2 2s-.9 2-2 2M19.86 6c-.45-1.72-2-3-3.86-3s-3.41 1.28-3.86 3H2v2h10.14c.45 1.72 2 3 3.86 3s3.41-1.28 3.86-3H22V6zM16 9c-1.1 0-2-.9-2-2s.9-2 2-2s2 .9 2 2s-.9 2-2 2" /></svg></button>
|
|
59
108
|
<div class="oc-dropdown-menu" :id="`${id}-menu`" popover>
|
|
60
109
|
<button class="oc-dropdown-item" type="button" data-oc-export>Export CSV</button>
|
|
61
|
-
<
|
|
62
|
-
|
|
110
|
+
<template v-if="importable">
|
|
111
|
+
<button class="oc-dropdown-item" type="button" data-oc-import>Import CSV</button>
|
|
112
|
+
<input class="oc-data-table-file" type="file" accept=".csv,text/csv" data-oc-file>
|
|
113
|
+
</template>
|
|
114
|
+
<button class="oc-dropdown-item" type="button" data-oc-bulk="duplicate">Duplicate selection</button>
|
|
115
|
+
<button class="oc-dropdown-item" type="button" data-oc-bulk="archive">Move selection to trash</button>
|
|
63
116
|
<div class="oc-dropdown-separator"></div>
|
|
64
117
|
<button class="oc-dropdown-item" type="button" data-oc-density="comfortable">Comfortable rows</button>
|
|
65
118
|
<button class="oc-dropdown-item" type="button" data-oc-density="compact">Compact rows</button>
|
|
66
|
-
<button class="oc-dropdown-item" type="button" data-oc-reset-widths>Reset column widths</button>
|
|
67
119
|
</div>
|
|
68
120
|
</div>
|
|
69
121
|
<input class="oc-input oc-data-table-search" type="search" :placeholder="searchPlaceholder" :aria-label="`Search ${title}`">
|
|
@@ -71,20 +123,30 @@ onMounted(() => {
|
|
|
71
123
|
<button class="oc-filter-pill" type="button" data-oc-filter="active" aria-current="true">Active only</button>
|
|
72
124
|
<button class="oc-filter-pill" type="button" data-oc-filter="all">Including trash</button>
|
|
73
125
|
<button class="oc-filter-pill" type="button" data-oc-filter="deleted">Trash only</button>
|
|
74
|
-
<button class="oc-filter-pill" type="button" data-oc-filter="archived">Archive only</button>
|
|
126
|
+
<button v-if="archive" class="oc-filter-pill" type="button" data-oc-filter="archived">Archive only</button>
|
|
75
127
|
</div>
|
|
76
128
|
<div class="oc-data-table-view" role="group" aria-label="View">
|
|
77
|
-
<button class="oc-btn oc-btn--outline oc-btn--sm" type="button" data-oc-view="table" aria-pressed="true"
|
|
78
|
-
<button class="oc-btn oc-btn--outline oc-btn--sm" type="button" data-oc-view="grid"
|
|
129
|
+
<button class="oc-btn oc-btn--outline oc-btn--icon oc-btn--sm" type="button" data-oc-view="table" aria-pressed="true" aria-label="Table"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 5h14v2H7zm0 8v-2h14v2zM4 4.5A1.5 1.5 0 0 1 5.5 6A1.5 1.5 0 0 1 4 7.5A1.5 1.5 0 0 1 2.5 6A1.5 1.5 0 0 1 4 4.5m0 6A1.5 1.5 0 0 1 5.5 12A1.5 1.5 0 0 1 4 13.5A1.5 1.5 0 0 1 2.5 12A1.5 1.5 0 0 1 4 10.5M7 19v-2h14v2zm-3-2.5A1.5 1.5 0 0 1 5.5 18A1.5 1.5 0 0 1 4 19.5A1.5 1.5 0 0 1 2.5 18A1.5 1.5 0 0 1 4 16.5" /></svg></button>
|
|
130
|
+
<button class="oc-btn oc-btn--outline oc-btn--icon oc-btn--sm" type="button" data-oc-view="grid" aria-label="Cards"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M3 11h8V3H3m2 2h4v4H5m8 12h8v-8h-8m2 2h4v4h-4M3 21h8v-8H3m2 2h4v4H5m8-16v8h8V3m-2 6h-4V5h4Z" /></svg></button>
|
|
79
131
|
</div>
|
|
132
|
+
<div v-if="$slots['page-actions']" class="oc-data-table-actions"><slot name="page-actions" /></div>
|
|
80
133
|
<p class="oc-data-table-sort-summary" data-oc-sort-summary role="status"></p>
|
|
81
134
|
</div>
|
|
82
|
-
<div class="oc-table-
|
|
83
|
-
<
|
|
84
|
-
|
|
135
|
+
<div v-if="columnFilters?.length" :id="`${id}-filters`" class="oc-data-table-filters">
|
|
136
|
+
<label v-for="f in columnFilters" :key="f.column" class="oc-data-table-filter">
|
|
137
|
+
<iconify-icon v-if="f.icon" :icon="f.icon" aria-hidden="true" />
|
|
138
|
+
<template v-else><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M15 19.88c.04.3-.06.62-.29.83a.996.996 0 0 1-1.41 0L9.29 16.7a.99.99 0 0 1-.29-.83v-5.12L4.21 4.62a1 1 0 0 1 .17-1.4c.19-.14.4-.22.62-.22h14c.22 0 .43.08.62.22a1 1 0 0 1 .17 1.4L15 10.75zM7.04 5L11 10.06v5.52l2 2v-7.53L16.96 5z" /></svg></template>
|
|
139
|
+
<select class="oc-select" :data-oc-col-filter="f.column" :aria-label="f.label">
|
|
140
|
+
<option v-for="o in f.options" :key="o.value" :value="o.value" :selected="o.value === ''">{{ o.label }}</option>
|
|
141
|
+
</select>
|
|
142
|
+
</label>
|
|
85
143
|
</div>
|
|
86
|
-
<div class="oc-column-menu" :id="`${id}-columns`" popover>
|
|
87
|
-
<p class="oc-column-menu-title">
|
|
144
|
+
<div v-if="columnMenu" class="oc-column-menu" :id="`${id}-columns`" popover>
|
|
145
|
+
<p class="oc-column-menu-title">Columns</p>
|
|
146
|
+
<template v-if="resizable">
|
|
147
|
+
<button class="oc-dropdown-item" type="button" data-oc-reset-widths>Reset column widths</button>
|
|
148
|
+
<div class="oc-dropdown-separator"></div>
|
|
149
|
+
</template>
|
|
88
150
|
<form class="oc-column-menu-list">
|
|
89
151
|
<template v-for="(col, index) in columns" :key="col.key">
|
|
90
152
|
<label v-if="col.hideable !== false" class="oc-column-menu-item">
|
|
@@ -109,22 +171,35 @@ onMounted(() => {
|
|
|
109
171
|
<tr>
|
|
110
172
|
<th v-if="selectable" data-oc-col="select"><input class="oc-checkbox" type="checkbox" data-oc-select-all aria-label="Select all"></th>
|
|
111
173
|
<th v-if="draggable" data-oc-col="order"><span class="oc-visually-hidden">Order</span></th>
|
|
112
|
-
<th v-for="col in columns" :key="col.key" :data-oc-col="col.key" :data-oc-sort="col.numeric ? 'numeric' : 'text'" :data-numeric="col.numeric ? '' : undefined">
|
|
174
|
+
<th v-for="col in columns" :key="col.key" :data-oc-col="col.key" :data-oc-sort="col.numeric ? 'numeric' : 'text'" :data-numeric="col.numeric ? '' : undefined" :aria-sort="col.defaultSort">
|
|
113
175
|
<span class="oc-data-table-th">
|
|
114
176
|
<button v-if="col.sortable !== false" class="oc-table-sort" type="button">{{ col.header }}</button>
|
|
115
177
|
<template v-else>{{ col.header }}</template>
|
|
116
|
-
<span class="oc-data-table-resize" role="separator" aria-orientation="vertical" :aria-label="`Resize ${col.header}`"></span>
|
|
178
|
+
<span v-if="resizable" class="oc-data-table-resize" role="separator" aria-orientation="vertical" :aria-label="`Resize ${col.header}`"></span>
|
|
117
179
|
</span>
|
|
118
180
|
</th>
|
|
119
|
-
<th data-oc-col="actions"
|
|
181
|
+
<th data-oc-col="actions">
|
|
182
|
+
<span class="oc-data-table-th">Actions<button v-if="columnMenu" class="oc-btn oc-btn--plain oc-data-table-columns-btn" type="button" :popovertarget="`${id}-columns`" aria-label="Columns" title="Columns"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4 5v13h17V5zm10 2v9h-3V7zM6 7h3v9H6zm13 9h-3V7h3z" /></svg></button></span>
|
|
183
|
+
</th>
|
|
120
184
|
</tr>
|
|
121
185
|
</thead>
|
|
122
186
|
<tbody>
|
|
123
187
|
<tr v-for="row in rows" :key="row.key" data-oc-row :data-oc-key="row.key" :data-oc-status="row.status || 'active'" :data-oc-href="row.href">
|
|
124
|
-
<td v-if="selectable" data-oc-col="select"><input class="oc-checkbox" type="checkbox" data-oc-select :aria-label="`Select ${row
|
|
125
|
-
<td v-if="draggable" data-oc-col="order" class="oc-sortable-row-grip-cell"><button class="oc-sortable-row-grip" type="button" :aria-label="`Move ${row
|
|
126
|
-
<td v-for="col in columns" :key="col.key" :data-oc-col="col.key" :data-numeric="col.numeric ? '' : undefined" :data-oc-value="row.values?.[col.key]">
|
|
127
|
-
|
|
188
|
+
<td v-if="selectable" data-oc-col="select"><input class="oc-checkbox" type="checkbox" data-oc-select :aria-label="`Select ${label(row)}`"></td>
|
|
189
|
+
<td v-if="draggable" data-oc-col="order" class="oc-sortable-row-grip-cell"><button class="oc-sortable-row-grip" type="button" :aria-label="`Move ${label(row)}`"></button></td>
|
|
190
|
+
<td v-for="col in columns" :key="col.key" :data-oc-col="col.key" :data-numeric="col.numeric ? '' : undefined" :data-oc-value="row.values?.[col.key]">
|
|
191
|
+
<slot :name="`cell-${col.key}`" :row="row">
|
|
192
|
+
<span v-if="col.truncate" class="oc-data-table-truncate" :style="{ '--oc-data-table-truncate': col.truncate }" :title="row.cells[col.key]">{{ row.cells[col.key] ?? '' }}</span>
|
|
193
|
+
<template v-else>{{ row.cells[col.key] ?? '' }}</template>
|
|
194
|
+
</slot>
|
|
195
|
+
</td>
|
|
196
|
+
<td data-oc-col="actions">
|
|
197
|
+
<div style="display: flex; gap: 0.375rem; justify-content: flex-end">
|
|
198
|
+
<slot name="actions" :row="row">
|
|
199
|
+
<button class="oc-btn oc-btn--soft-primary oc-btn--icon oc-btn--sm" type="button" :aria-label="`Edit ${label(row)}`" title="Edit"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="m14.06 9l.94.94L5.92 19H5v-.92zm3.6-6c-.25 0-.51.1-.7.29l-1.83 1.83l3.75 3.75l1.83-1.83c.39-.39.39-1.04 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29m-3.6 3.19L3 17.25V21h3.75L17.81 9.94z" /></svg></button>
|
|
200
|
+
</slot>
|
|
201
|
+
</div>
|
|
202
|
+
</td>
|
|
128
203
|
</tr>
|
|
129
204
|
</tbody>
|
|
130
205
|
</table>
|
|
@@ -132,7 +207,7 @@ onMounted(() => {
|
|
|
132
207
|
<p class="oc-data-table-empty" hidden>No results</p>
|
|
133
208
|
</div>
|
|
134
209
|
<div class="oc-data-table-footer">
|
|
135
|
-
<p class="oc-data-table-summary" data-oc-summary></p>
|
|
210
|
+
<p class="oc-data-table-summary" :data-oc-summary="summary"></p>
|
|
136
211
|
<label class="oc-data-table-per-page">Rows per page
|
|
137
212
|
<select class="oc-select" data-oc-page-size aria-label="Rows per page">
|
|
138
213
|
<option v-for="n in sizes" :key="n" :value="n" :selected="n === pageSize">{{ n }}</option>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!-- File: packages/vue/src/components/OcDataTableName.vue | Map: props -> avatar + name line + second line -->
|
|
2
|
+
<script setup lang="ts">
|
|
3
|
+
/** Name cell of a data table: avatar (initials or image), name with a badge (slot) and a muted second line. */
|
|
4
|
+
import { computed } from 'vue'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{ name: string; initials?: string; avatar?: string; sub?: string }>()
|
|
7
|
+
const letters = computed(() => props.initials ?? props.name.split(/[\s&-]+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase())
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<span class="oc-data-table-name">
|
|
12
|
+
<span class="oc-avatar" aria-hidden="true"><img v-if="avatar" :src="avatar" alt=""><template v-else>{{ letters }}</template></span>
|
|
13
|
+
<span class="oc-data-table-name-text">
|
|
14
|
+
<span class="oc-data-table-name-line">{{ name }} <slot name="badge" /></span>
|
|
15
|
+
<span v-if="sub || $slots.sub" class="oc-data-table-name-sub"><slot name="sub">{{ sub }}</slot></span>
|
|
16
|
+
</span>
|
|
17
|
+
</span>
|
|
18
|
+
</template>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<!-- File: packages/vue/src/components/OcDateField.vue | Map: v-model date -> ISO week label -> markup -->
|
|
2
|
+
<script setup lang="ts">
|
|
3
|
+
/** Native date input with a calendar icon zone and the ISO week. v-model is the YYYY-MM-DD string. */
|
|
4
|
+
import { computed } from 'vue'
|
|
5
|
+
import { isoWeek } from '../../../ui/src/form-extras.js'
|
|
6
|
+
|
|
7
|
+
const props = withDefaults(defineProps<{ week?: boolean; weekLabel?: string; size?: 'md' | 'sm'; id?: string }>(), { week: true, weekLabel: 'Week {week}', size: 'md' })
|
|
8
|
+
const model = defineModel<string>({ default: '' })
|
|
9
|
+
const label = computed(() => {
|
|
10
|
+
const [y, m, d] = (model.value || '').split('-').map(Number)
|
|
11
|
+
if (!props.week || !y) return ''
|
|
12
|
+
const w = isoWeek(new Date(y, m - 1, d))
|
|
13
|
+
return props.weekLabel.replace('{week}', String(w.week)).replace('{year}', String(w.year))
|
|
14
|
+
})
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<div :class="['oc-date-field', size === 'sm' && 'oc-date-field--sm']">
|
|
19
|
+
<span class="oc-date-field__icon"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M19 3h-1V1h-2v2H8V1H6v2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14c1.11 0 2-.89 2-2V5a2 2 0 0 0-2-2m0 16H5V9h14zm0-12H5V5h14z" /></svg></span>
|
|
20
|
+
<input :id="id" v-model="model" type="date" class="oc-date-field__input">
|
|
21
|
+
<output v-if="week" class="oc-date-field__week">{{ label }}</output>
|
|
22
|
+
</div>
|
|
23
|
+
</template>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Emoji picker on the popover attribute (same markup as snippets/emoji-picker.html).
|
|
5
5
|
* Props: groups ({ label?, emojis[] }[]), inline (always visible, no popover), triggerLabel, label.
|
|
6
|
-
* Emits: select(emoji) and then closes the popover. Slot: trigger (button content, default
|
|
6
|
+
* Emits: select(emoji) and then closes the popover. Slot: trigger (button content, default a smiley webicon).
|
|
7
7
|
* Classes: .oc-emoji-picker(--inline), __trigger, __label, __grid, __item
|
|
8
8
|
*/
|
|
9
9
|
import { useUid } from '../composables/useUid'
|
|
@@ -32,7 +32,7 @@ function pick(e: MouseEvent, emoji: string) {
|
|
|
32
32
|
|
|
33
33
|
<template>
|
|
34
34
|
<button v-if="!inline" type="button" class="oc-emoji-picker__trigger" :popovertarget="id" :aria-label="triggerLabel">
|
|
35
|
-
<slot name="trigger"
|
|
35
|
+
<slot name="trigger"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20 12a8 8 0 0 0-8-8a8 8 0 0 0-8 8a8 8 0 0 0 8 8a8 8 0 0 0 8-8m2 0a10 10 0 0 1-10 10A10 10 0 0 1 2 12A10 10 0 0 1 12 2a10 10 0 0 1 10 10m-12-3.5c0 .8-.7 1.5-1.5 1.5S7 9.3 7 8.5S7.7 7 8.5 7s1.5.7 1.5 1.5m7 0c0 .8-.7 1.5-1.5 1.5S14 9.3 14 8.5S14.7 7 15.5 7s1.5.7 1.5 1.5m-5 9.73c-1.75 0-3.29-.73-4.19-1.81L9.23 14c.45.72 1.52 1.23 2.77 1.23s2.32-.51 2.77-1.23l1.42 1.42c-.9 1.08-2.44 1.81-4.19 1.81"/></svg></slot>
|
|
36
36
|
</button>
|
|
37
37
|
<div :id="id" :class="['oc-emoji-picker', inline && 'oc-emoji-picker--inline']" :popover="inline ? undefined : ''" :role="inline ? 'group' : undefined" :aria-label="label">
|
|
38
38
|
<div v-for="(g, gi) in groups" :key="gi">
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!-- File: packages/vue/src/components/OcFormSection.vue | Map: props -> details (summary: icon, heading, status) -> fields slot -->
|
|
2
|
+
<script setup lang="ts">
|
|
3
|
+
/** Collapsible form section. Slots: icon, status, default (the fields). Same `name` on siblings = one open at a time. */
|
|
4
|
+
withDefaults(defineProps<{ title: string; hint?: string; name?: string; open?: boolean }>(), { open: false })
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<details class="oc-form-section" :name="name" :open="open">
|
|
9
|
+
<summary class="oc-form-section__summary">
|
|
10
|
+
<span class="oc-form-section__icon"><slot name="icon" /></span>
|
|
11
|
+
<span class="oc-form-section__heading">
|
|
12
|
+
<span class="oc-form-section__title">{{ title }}</span>
|
|
13
|
+
<span v-if="hint" class="oc-form-section__hint">{{ hint }}</span>
|
|
14
|
+
</span>
|
|
15
|
+
<span class="oc-form-section__status"><slot name="status" /></span>
|
|
16
|
+
</summary>
|
|
17
|
+
<div class="oc-form-section__body"><slot /></div>
|
|
18
|
+
</details>
|
|
19
|
+
</template>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<!-- File: packages/vue/src/components/OcGallery.vue | Map: images (max 8) -> stage with slides -> thumbnail radios -->
|
|
2
|
+
<script setup lang="ts">
|
|
3
|
+
/** Product photos with thumbnails (same markup as snippets/gallery.html), CSS only: radios and :has(). */
|
|
4
|
+
import { computed } from 'vue'
|
|
5
|
+
import { useUid } from '../composables/useUid'
|
|
6
|
+
|
|
7
|
+
export interface GalleryImage { src: string; alt: string; thumb?: string; thumbAlt?: string }
|
|
8
|
+
const props = withDefaults(defineProps<{ images: GalleryImage[]; label?: string; badge?: string; side?: boolean; name?: string }>(), { label: 'Photos' })
|
|
9
|
+
const group = props.name ?? useUid('oc-gallery')
|
|
10
|
+
const list = computed(() => props.images.slice(0, 8))
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<div :class="['oc-gallery', side && 'oc-gallery--side']">
|
|
15
|
+
<div class="oc-gallery__stage">
|
|
16
|
+
<span v-if="badge" class="oc-badge oc-badge--destructive oc-gallery__badge">{{ badge }}</span>
|
|
17
|
+
<figure v-for="(img, i) in list" :key="i" class="oc-gallery__slide"><img :src="img.src" :alt="img.alt" :loading="i ? 'lazy' : undefined"></figure>
|
|
18
|
+
</div>
|
|
19
|
+
<fieldset v-if="list.length > 1" class="oc-gallery__thumbs">
|
|
20
|
+
<legend>{{ label }}</legend>
|
|
21
|
+
<label v-for="(img, i) in list" :key="i" class="oc-gallery__thumb">
|
|
22
|
+
<input type="radio" :name="group" :checked="i === 0">
|
|
23
|
+
<img :src="img.thumb ?? img.src" :alt="img.thumbAlt ?? img.alt">
|
|
24
|
+
</label>
|
|
25
|
+
</fieldset>
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
<!-- File: packages/vue/src/components/OcGlass.vue | Map: props (strong, flat, as) -> .oc-glass sheet -> default slot -->
|
|
1
|
+
<!-- File: packages/vue/src/components/OcGlass.vue | Map: props (strong, flat, liquid, as) -> .oc-glass sheet -> default slot -->
|
|
2
2
|
<script setup lang="ts">
|
|
3
3
|
/** See-through glass sheet with a light top edge; .oc-card inside becomes a glass panel.
|
|
4
|
-
* Classes: .oc-glass + --strong, --flat (desktop window where the OS draws the blur) */
|
|
5
|
-
withDefaults(defineProps<{ strong?: boolean; flat?: boolean; as?: 'div' | 'section' | 'aside' | 'article' | 'main' }>(), { as: 'div' })
|
|
4
|
+
* Classes: .oc-glass + --strong, --flat (desktop window where the OS draws the blur), --liquid (liquid glass) */
|
|
5
|
+
withDefaults(defineProps<{ strong?: boolean; flat?: boolean; liquid?: boolean; as?: 'div' | 'section' | 'aside' | 'article' | 'main' }>(), { as: 'div' })
|
|
6
6
|
</script>
|
|
7
7
|
|
|
8
8
|
<template>
|
|
9
|
-
<component :is="as" :class="['oc-glass', strong && 'oc-glass--strong', flat && 'oc-glass--flat']"><slot /></component>
|
|
9
|
+
<component :is="as" :class="['oc-glass', strong && 'oc-glass--strong', flat && 'oc-glass--flat', liquid && 'oc-glass--liquid']"><slot /></component>
|
|
10
10
|
</template>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!-- File: packages/vue/src/components/OcGlassBar.vue | Map: props (items, icons, label) -> nav.oc-glass-bar -> links or buttons (icon slot per item) -->
|
|
2
|
+
<script setup lang="ts">
|
|
3
|
+
/** Liquid glass menu bar: items with an icon and a label; the current item sits in a raised bubble.
|
|
4
|
+
* Slots: icon-<index> (the icon of an item). Emits select(index) for items without href. */
|
|
5
|
+
export interface GlassBarItem { label: string; href?: string; current?: boolean }
|
|
6
|
+
withDefaults(defineProps<{ items: GlassBarItem[]; icons?: boolean; label?: string }>(), { label: 'Main' })
|
|
7
|
+
defineEmits<{ select: [index: number] }>()
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<nav :class="['oc-glass-bar', icons && 'oc-glass-bar--icons']" :aria-label="label">
|
|
12
|
+
<template v-for="(item, i) in items" :key="i">
|
|
13
|
+
<a v-if="item.href != null" class="oc-glass-bar__item" :href="item.href" :aria-current="item.current ? 'page' : undefined" :aria-label="icons ? item.label : undefined"><slot :name="`icon-${i}`" /><span v-if="!icons">{{ item.label }}</span></a>
|
|
14
|
+
<button v-else class="oc-glass-bar__item" type="button" :aria-pressed="item.current ?? false" :aria-label="icons ? item.label : undefined" @click="$emit('select', i)"><slot :name="`icon-${i}`" /><span v-if="!icons">{{ item.label }}</span></button>
|
|
15
|
+
</template>
|
|
16
|
+
</nav>
|
|
17
|
+
</template>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!-- File: packages/vue/src/components/OcLazyImage.vue | Map: props -> figure.oc-lazy (ratio, preview) -> img loading=lazy -> loaded / error state -->
|
|
2
|
+
<script setup lang="ts">
|
|
3
|
+
/** An image that keeps its place, shows a shimmer (or a blurred preview) and fades in once loaded (same markup as
|
|
4
|
+
* snippets/lazy.html). Props: src, alt, width, height, ratio, preview, caption, loading. Slot: caption. */
|
|
5
|
+
import { computed, ref } from 'vue'
|
|
6
|
+
|
|
7
|
+
const props = withDefaults(defineProps<{ src: string; alt: string; width?: number; height?: number; ratio?: string; preview?: string; caption?: string; loading?: 'lazy' | 'eager' }>(), { loading: 'lazy' })
|
|
8
|
+
const state = ref<'' | 'is-loaded' | 'is-error'>('')
|
|
9
|
+
const vars = computed(() => ({ ...(props.ratio ? { '--oc-lazy-ratio': props.ratio } : {}), ...(props.preview ? { '--oc-lazy-preview': `url(${props.preview})` } : {}) }))
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<figure :class="['oc-lazy', state]" :style="vars">
|
|
14
|
+
<img :src="src" :alt="alt" :width="width" :height="height" :loading="loading" decoding="async" @load="state = 'is-loaded'" @error="state = 'is-error'">
|
|
15
|
+
<figcaption v-if="caption || $slots.caption"><slot name="caption">{{ caption }}</slot></figcaption>
|
|
16
|
+
</figure>
|
|
17
|
+
</template>
|
|
@@ -10,21 +10,21 @@ import OcMessage, { type MessageData } from './OcMessage.vue'
|
|
|
10
10
|
export type MessageListItem = MessageData | { divider: string; system?: boolean }
|
|
11
11
|
withDefaults(defineProps<{ items?: MessageListItem[]; log?: boolean; label?: string }>(), {
|
|
12
12
|
log: true,
|
|
13
|
-
label: 'Conversation with
|
|
13
|
+
label: 'Conversation with Customer 1',
|
|
14
14
|
items: () => [
|
|
15
15
|
{ divider: 'Tuesday 14 October' },
|
|
16
|
-
{ sender: '
|
|
16
|
+
{ sender: 'Customer 1', avatar: 'C1', time: '09:12', datetime: '2026-10-14T09:12', label: 'Customer 1, 09:12', text: 'Morning Tom! The worktop supplier sent two samples for Project 1.' },
|
|
17
17
|
{
|
|
18
|
-
sender: '
|
|
18
|
+
sender: 'Customer 1', avatar: 'C1', time: '09:13', datetime: '2026-10-14T09:13', label: 'Customer 1, 09:13', continued: true, id: 'oc-message-demo-question',
|
|
19
19
|
html: '<p>Which finish should we order?</p><ul><li><strong>Calacatta</strong> matt, 20 mm</li><li><strong>Nero Marquina</strong> polished, 12 mm</li></ul><p>Details are on the <a href="#supplier-sheet">supplier sheet</a>.</p>',
|
|
20
20
|
},
|
|
21
21
|
{
|
|
22
|
-
direction: 'out', avatar: '
|
|
23
|
-
quote: { href: '#oc-message-demo-question', sender: '
|
|
22
|
+
direction: 'out', avatar: 'C6', time: '09:20', datetime: '2026-10-14T09:20', label: 'You, 09:20', status: 'read', statusIcon: true,
|
|
23
|
+
quote: { href: '#oc-message-demo-question', sender: 'Customer 1', text: 'Which finish should we order? Calacatta matt, 20 mm or Nero Marquina polished, 12 mm' },
|
|
24
24
|
text: 'Calacatta matt. The client wants the lighter look next to the oak fronts.',
|
|
25
25
|
actions: [{ name: 'reply', label: 'Reply to this message' }, { name: 'delete', label: 'Delete this message', destructive: true }],
|
|
26
26
|
},
|
|
27
|
-
{ divider: '
|
|
27
|
+
{ divider: 'Customer 1 marked this question as answered', system: true },
|
|
28
28
|
],
|
|
29
29
|
})
|
|
30
30
|
const emit = defineEmits<{ action: [name: string, message: MessageData, index: number] }>()
|
|
@@ -45,21 +45,21 @@ const props = withDefaults(
|
|
|
45
45
|
}>(),
|
|
46
46
|
{
|
|
47
47
|
title: 'Kitchen worktop: which finish?',
|
|
48
|
-
project: '
|
|
49
|
-
meta: '
|
|
50
|
-
participants: () => [{ initials: '
|
|
48
|
+
project: 'Project 1',
|
|
49
|
+
meta: 'Customer 1 → Customer 6 · 14 Oct 2026',
|
|
50
|
+
participants: () => [{ initials: 'C1', name: 'Customer 1' }, { initials: 'C6', name: 'Customer 6' }],
|
|
51
51
|
status: () => ({ label: 'Urgent', variant: 'destructive' }),
|
|
52
52
|
menu: () => [{ name: 'unread', label: 'Mark as unread' }, { name: 'archive', label: 'Move to archive' }, { separator: true }, { name: 'delete', label: 'Delete conversation', destructive: true }],
|
|
53
53
|
messages: () => [
|
|
54
54
|
{ divider: 'Tuesday 14 October' },
|
|
55
|
-
{ sender: '
|
|
55
|
+
{ sender: 'Customer 1', avatar: 'C1', time: '09:12', datetime: '2026-10-14T09:12', text: 'The supplier dropped off two worktop samples at the workshop. Photo attached.',
|
|
56
56
|
attachments: [{ kind: 'image', href: '#samples.jpg', alt: 'Two worktop samples on the workbench', art: 6, caption: 'samples.jpg · 2.1 MB' }] },
|
|
57
|
-
{ sender: '
|
|
57
|
+
{ sender: 'Customer 1', avatar: 'C1', time: '09:13', datetime: '2026-10-14T09:13', continued: true,
|
|
58
58
|
html: '<p>Which one should we order: <strong>Calacatta matt</strong> or <strong>Nero Marquina polished</strong>? The cutting slot is Friday.</p>' },
|
|
59
|
-
{ direction: 'out', avatar: '
|
|
60
|
-
attachments: [{ name: 'Quote kitchen
|
|
59
|
+
{ direction: 'out', avatar: 'C6', time: '09:20', datetime: '2026-10-14T09:20', status: 'read', text: 'Calacatta matt, 20 mm. I\'ll send the updated quote to the client today.',
|
|
60
|
+
attachments: [{ name: 'Quote kitchen Project 1 v2.pdf', size: '1.2 MB', href: '#quote.pdf' }], actions: [{ name: 'delete', label: 'Delete this message', destructive: true }] },
|
|
61
61
|
{ divider: 'Today' },
|
|
62
|
-
{ sender: '
|
|
62
|
+
{ sender: 'Customer 1', avatar: 'C1', time: '10:02', datetime: '2026-10-24T10:02', text: 'Client signed. Can you confirm the edge profile before 16:00?' },
|
|
63
63
|
],
|
|
64
64
|
scrollLabel: 'Messages',
|
|
65
65
|
live: true,
|
|
@@ -137,11 +137,11 @@ function ctrlEnter(e: KeyboardEvent) {
|
|
|
137
137
|
<textarea :id="`${base}-input`" class="oc-chat-composer__input" name="message" rows="1" :placeholder="placeholder" required></textarea>
|
|
138
138
|
<div class="oc-chat-composer__bar">
|
|
139
139
|
<div v-if="tools.length" class="oc-chat-composer__tools">
|
|
140
|
-
<button v-if="tools.includes('attach')" type="button" class="oc-chat-composer__tool" aria-label="Attach a file" @click="emit('tool', 'attach')"
|
|
141
|
-
<button v-if="tools.includes('emoji')" type="button" class="oc-chat-composer__tool" aria-label="Insert emoji" @click="emit('tool', 'emoji')"
|
|
140
|
+
<button v-if="tools.includes('attach')" type="button" class="oc-chat-composer__tool" aria-label="Attach a file" @click="emit('tool', 'attach')"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M16.5 6v11.5a4 4 0 0 1-4 4a4 4 0 0 1-4-4V5a2.5 2.5 0 0 1 2.5-2.5A2.5 2.5 0 0 1 13.5 5v10.5a1 1 0 0 1-1 1a1 1 0 0 1-1-1V6H10v9.5a2.5 2.5 0 0 0 2.5 2.5a2.5 2.5 0 0 0 2.5-2.5V5a4 4 0 0 0-4-4a4 4 0 0 0-4 4v12.5a5.5 5.5 0 0 0 5.5 5.5a5.5 5.5 0 0 0 5.5-5.5V6z"/></svg></button>
|
|
141
|
+
<button v-if="tools.includes('emoji')" type="button" class="oc-chat-composer__tool" aria-label="Insert emoji" @click="emit('tool', 'emoji')"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20 12a8 8 0 0 0-8-8a8 8 0 0 0-8 8a8 8 0 0 0 8 8a8 8 0 0 0 8-8m2 0a10 10 0 0 1-10 10A10 10 0 0 1 2 12A10 10 0 0 1 12 2a10 10 0 0 1 10 10m-12-3.5c0 .8-.7 1.5-1.5 1.5S7 9.3 7 8.5S7.7 7 8.5 7s1.5.7 1.5 1.5m7 0c0 .8-.7 1.5-1.5 1.5S14 9.3 14 8.5S14.7 7 15.5 7s1.5.7 1.5 1.5m-5 9.73c-1.75 0-3.29-.73-4.19-1.81L9.23 14c.45.72 1.52 1.23 2.77 1.23s2.32-.51 2.77-1.23l1.42 1.42c-.9 1.08-2.44 1.81-4.19 1.81"/></svg></button>
|
|
142
142
|
</div>
|
|
143
143
|
<span v-if="hint" class="oc-chat-composer__hint">{{ hint }}</span>
|
|
144
|
-
<button type="submit" class="oc-chat-composer__send" aria-label="Send"
|
|
144
|
+
<button type="submit" class="oc-chat-composer__send" aria-label="Send"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M2 21l21-9L2 3v7l15 2l-15 2z"/></svg></button>
|
|
145
145
|
</div>
|
|
146
146
|
</form>
|
|
147
147
|
</slot>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!-- File: packages/vue/src/components/OcMeterRow.vue | Map: props -> level from the percentage -> row markup -->
|
|
2
|
+
<script setup lang="ts">
|
|
3
|
+
/** Usage line: label, percentage and amount, a colored bar (warn from 75, critical from 90) and the reset line. */
|
|
4
|
+
import { computed } from 'vue'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{ label: string; percent: number; value?: string; reset?: string; level?: 'ok' | 'warn' | 'critical' }>()
|
|
7
|
+
const pct = computed(() => Math.max(0, Math.min(100, Math.round(props.percent))))
|
|
8
|
+
const tone = computed(() => props.level ?? (pct.value >= 90 ? 'critical' : pct.value >= 75 ? 'warn' : 'ok'))
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<div class="oc-meter-row" :data-level="tone === 'ok' ? undefined : tone" :style="{ '--oc-meter': `${pct}%` }">
|
|
13
|
+
<span class="oc-meter-row__label">{{ label }}</span>
|
|
14
|
+
<span class="oc-meter-row__value"><strong>{{ pct }}%</strong><template v-if="value"> · {{ value }}</template></span>
|
|
15
|
+
<span class="oc-meter-row__bar" role="meter" aria-valuemin="0" aria-valuemax="100" :aria-valuenow="pct" :aria-label="label"></span>
|
|
16
|
+
<span v-if="reset" class="oc-meter-row__reset">{{ reset }}</span>
|
|
17
|
+
</div>
|
|
18
|
+
</template>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!-- File: packages/vue/src/components/OcNameCard.vue | Map: props -> details (head, body slot) -> rename event on mount -->
|
|
2
|
+
<script setup lang="ts">
|
|
3
|
+
/** Name card: name, rename pencil (when renamable), meta, amount and an actions slot; default slot = content. Emits rename. */
|
|
4
|
+
import { onMounted, useTemplateRef } from 'vue'
|
|
5
|
+
import { bindNameCard } from '../../../ui/src/form-extras.js'
|
|
6
|
+
|
|
7
|
+
const props = withDefaults(defineProps<{ name: string; amount?: string; meta?: string; renamable?: boolean; total?: boolean; open?: boolean }>(), { renamable: false, total: false, open: false })
|
|
8
|
+
const emit = defineEmits<{ rename: [name: string] }>()
|
|
9
|
+
const root = useTemplateRef<HTMLDetailsElement>('root')
|
|
10
|
+
onMounted(() => {
|
|
11
|
+
if (!root.value || !props.renamable) return
|
|
12
|
+
bindNameCard(root.value)
|
|
13
|
+
root.value.addEventListener('oc-namecard-rename', (e) => emit('rename', (e as CustomEvent<{ name: string }>).detail.name))
|
|
14
|
+
})
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<details ref="root" :class="['oc-name-card', total && 'oc-name-card--total']" :open="open">
|
|
19
|
+
<summary class="oc-name-card__head">
|
|
20
|
+
<span class="oc-name-card__title">{{ name }}</span>
|
|
21
|
+
<button v-if="renamable" type="button" class="oc-btn oc-btn--plain" data-oc-rename :aria-label="`Rename ${name}`" title="Rename"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="m14.06 9l.94.94L5.92 19H5v-.92zm3.6-6c-.25 0-.51.1-.7.29l-1.83 1.83l3.75 3.75l1.83-1.83c.39-.39.39-1.04 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29m-3.6 3.19L3 17.25V21h3.75L17.81 9.94z" /></svg></button>
|
|
22
|
+
<span v-if="meta" class="oc-name-card__meta">{{ meta }}</span>
|
|
23
|
+
<span v-if="amount" class="oc-name-card__amount">{{ amount }}</span>
|
|
24
|
+
<span v-if="$slots.actions" class="oc-name-card__actions"><slot name="actions" /></span>
|
|
25
|
+
</summary>
|
|
26
|
+
<div class="oc-name-card__body"><slot /></div>
|
|
27
|
+
</details>
|
|
28
|
+
</template>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<!-- File: packages/vue/src/components/OcPrice.vue | Map: props -> formatted amounts -> main line, note, alternative -->
|
|
2
|
+
<script setup lang="ts">
|
|
3
|
+
/** A price with old price, discount, VAT note and the other amount (same markup as snippets/price.html). */
|
|
4
|
+
const props = withDefaults(defineProps<{
|
|
5
|
+
amount: number | string
|
|
6
|
+
old?: number | string
|
|
7
|
+
discount?: string
|
|
8
|
+
note?: string
|
|
9
|
+
alt?: string
|
|
10
|
+
currency?: string
|
|
11
|
+
locale?: string
|
|
12
|
+
size?: 'sm' | 'md' | 'lg'
|
|
13
|
+
sale?: boolean
|
|
14
|
+
inline?: boolean
|
|
15
|
+
end?: boolean
|
|
16
|
+
}>(), { currency: 'EUR', locale: 'nl-BE', size: 'md' })
|
|
17
|
+
const format = (v: number | string) => (typeof v === 'number' ? new Intl.NumberFormat(props.locale, { style: 'currency', currency: props.currency }).format(v) : v)
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<div :class="['oc-price', size !== 'md' && `oc-price--${size}`, sale && 'oc-price--sale', inline && 'oc-price--inline', end && 'oc-price--end']">
|
|
22
|
+
<div class="oc-price__main">
|
|
23
|
+
<span class="oc-price__amount">{{ format(amount) }}</span>
|
|
24
|
+
<s v-if="old != null" class="oc-price__old">{{ format(old) }}</s>
|
|
25
|
+
<span v-if="discount" class="oc-badge oc-badge--destructive">{{ discount }}</span>
|
|
26
|
+
</div>
|
|
27
|
+
<span v-if="note" class="oc-price__note">{{ note }}</span>
|
|
28
|
+
<span v-if="alt" class="oc-price__alt">{{ alt }}</span>
|
|
29
|
+
</div>
|
|
30
|
+
</template>
|
|
@@ -24,7 +24,7 @@ withDefaults(defineProps<{ size?: 'compact' | 'sm' | 'md' | 'lg'; full?: boolean
|
|
|
24
24
|
</ul>
|
|
25
25
|
<figure>
|
|
26
26
|
<div class="oc-media-art oc-media-art--6" role="img" aria-label="Kitchen with oak fronts and a light stone worktop"></div>
|
|
27
|
-
<figcaption>
|
|
27
|
+
<figcaption>Project 1: oak fronts with a Calacatta matt worktop, finished in October 2026.</figcaption>
|
|
28
28
|
</figure>
|
|
29
29
|
<h3>From design to installation</h3>
|
|
30
30
|
<ol>
|
|
@@ -35,7 +35,7 @@ withDefaults(defineProps<{ size?: 'compact' | 'sm' | 'md' | 'lg'; full?: boolean
|
|
|
35
35
|
</ol>
|
|
36
36
|
<blockquote>
|
|
37
37
|
<p>The planning was clear from day one. We always knew who was coming and when, and the kitchen was ready a week early.</p>
|
|
38
|
-
<footer>—
|
|
38
|
+
<footer>— Customer 8, client in Ghent</footer>
|
|
39
39
|
</blockquote>
|
|
40
40
|
<h2>Worktop materials compared</h2>
|
|
41
41
|
<table>
|
|
@@ -49,7 +49,7 @@ withDefaults(defineProps<{ size?: 'compact' | 'sm' | 'md' | 'lg'; full?: boolean
|
|
|
49
49
|
</table>
|
|
50
50
|
<h3>Sharing your floor plan</h3>
|
|
51
51
|
<p>Upload a PDF or a photo in the client portal, or send it by e-mail. Press <kbd>Ctrl</kbd> + <kbd>V</kbd> in the chat to paste a screenshot. Files are stored as <code>project-id/plans/</code> and only your project team can see them.</p>
|
|
52
|
-
<pre><code>{{ 'Project: 2026-114
|
|
52
|
+
<pre><code>{{ 'Project: 2026-114 Project 1\nRoom: Kitchen, ground floor\nWall A: 412 cm (window at 160 cm)\nWall B: 298 cm (door at the left)' }}</code></pre>
|
|
53
53
|
<hr />
|
|
54
54
|
<p><small>Lead times are an estimate. <mark>Natural stone</mark> can take two weeks longer in summer.</small></p>
|
|
55
55
|
</slot>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<!-- File: packages/vue/src/components/OcQuantity.vue | Map: v-model number -> minus / input / plus within min, max, step -->
|
|
2
|
+
<script setup lang="ts">
|
|
3
|
+
/** Minus, number and plus in one frame (same markup as snippets/quantity.html). v-model is the number. */
|
|
4
|
+
import { stepQuantity } from '../../../ui/src/form-extras.js'
|
|
5
|
+
|
|
6
|
+
const props = withDefaults(defineProps<{ min?: number; max?: number; step?: number; size?: 'sm' | 'md'; label?: string; name?: string; id?: string; disabled?: boolean }>(), { min: 0, step: 1, size: 'md', label: 'Quantity' })
|
|
7
|
+
const model = defineModel<number>({ default: 1 })
|
|
8
|
+
const move = (direction: -1 | 1) => { model.value = stepQuantity(model.value, direction, { min: props.min, max: props.max ?? Infinity, step: props.step }) }
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<div :class="['oc-quantity', size === 'sm' && 'oc-quantity--sm']">
|
|
13
|
+
<button class="oc-quantity__step" type="button" :aria-label="`Decrease ${label.toLowerCase()}`" :disabled="disabled || model <= min" @click="move(-1)">
|
|
14
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M19 13H5v-2h14z" /></svg>
|
|
15
|
+
</button>
|
|
16
|
+
<input :id="id" v-model.number="model" class="oc-quantity__input" type="number" inputmode="numeric" :name="name" :min="min" :max="max" :step="step" :aria-label="label" :disabled="disabled">
|
|
17
|
+
<button class="oc-quantity__step" type="button" :aria-label="`Increase ${label.toLowerCase()}`" :disabled="disabled || (max != null && model >= max)" @click="move(1)">
|
|
18
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z" /></svg>
|
|
19
|
+
</button>
|
|
20
|
+
</div>
|
|
21
|
+
</template>
|