@birdycom/omniaui-vue 0.12.5 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/blocks/OcBlockContactForm.vue.d.ts +71 -0
  2. package/dist/blocks/OcBlockDataTable.vue.d.ts +1 -1
  3. package/dist/blocks/OcBlockProfileHeader.vue.d.ts +5 -3
  4. package/dist/components/OcAvatarPicker.vue.d.ts +21 -0
  5. package/dist/components/OcBarcode.vue.d.ts +12 -0
  6. package/dist/components/OcBrandMark.vue.d.ts +23 -0
  7. package/dist/components/OcBreadcrumb.vue.d.ts +7 -1
  8. package/dist/components/OcButton.vue.d.ts +6 -3
  9. package/dist/components/OcDataTable.vue.d.ts +68 -1
  10. package/dist/components/OcDataTableName.vue.d.ts +20 -0
  11. package/dist/components/OcDateField.vue.d.ts +19 -0
  12. package/dist/components/OcEmojiPicker.vue.d.ts +1 -1
  13. package/dist/components/OcFeatureCard.vue.d.ts +23 -0
  14. package/dist/components/OcFormSection.vue.d.ts +25 -0
  15. package/dist/components/OcGallery.vue.d.ts +17 -0
  16. package/dist/components/OcGlass.vue.d.ts +2 -1
  17. package/dist/components/OcGlassBar.vue.d.ts +32 -0
  18. package/dist/components/OcLazyImage.vue.d.ts +24 -0
  19. package/dist/components/OcLinkCard.vue.d.ts +22 -0
  20. package/dist/components/OcMeterRow.vue.d.ts +9 -0
  21. package/dist/components/{OcColumnMenu.vue.d.ts → OcNameCard.vue.d.ts} +15 -16
  22. package/dist/components/OcPanel.vue.d.ts +16 -0
  23. package/dist/components/OcPrice.vue.d.ts +20 -0
  24. package/dist/components/OcQuantity.vue.d.ts +24 -0
  25. package/dist/components/OcRating.vue.d.ts +11 -0
  26. package/dist/components/OcRatingInput.vue.d.ts +18 -0
  27. package/dist/components/OcResult.vue.d.ts +23 -0
  28. package/dist/components/OcRibbon.vue.d.ts +22 -0
  29. package/dist/components/OcSpecList.vue.d.ts +15 -0
  30. package/dist/components/OcStock.vue.d.ts +20 -0
  31. package/dist/index.d.ts +29 -3
  32. package/dist/omniaui-vue.js +4994 -3338
  33. package/dist/omniaui-vue.js.map +1 -1
  34. package/manifest.json +138 -6
  35. package/package.json +1 -1
  36. package/src/blocks/OcBlockContactForm.vue +146 -0
  37. package/src/blocks/OcBlockDataTable.vue +1 -1
  38. package/src/blocks/OcBlockProfileHeader.vue +15 -4
  39. package/src/components/OcAttachmentList.vue +1 -1
  40. package/src/components/OcAvatarPicker.vue +34 -0
  41. package/src/components/OcBarcode.vue +23 -0
  42. package/src/components/OcBrandMark.vue +15 -0
  43. package/src/components/OcBreadcrumb.vue +14 -5
  44. package/src/components/OcButton.vue +10 -2
  45. package/src/components/OcChatComposer.vue +1 -1
  46. package/src/components/OcDataTable.vue +100 -25
  47. package/src/components/OcDataTableName.vue +18 -0
  48. package/src/components/OcDateField.vue +23 -0
  49. package/src/components/OcEmojiPicker.vue +2 -2
  50. package/src/components/OcFeatureCard.vue +15 -0
  51. package/src/components/OcFormSection.vue +19 -0
  52. package/src/components/OcGallery.vue +27 -0
  53. package/src/components/OcGlass.vue +4 -4
  54. package/src/components/OcGlassBar.vue +17 -0
  55. package/src/components/OcLazyImage.vue +17 -0
  56. package/src/components/OcLinkCard.vue +16 -0
  57. package/src/components/OcMessageList.vue +6 -6
  58. package/src/components/OcMessageThread.vue +11 -11
  59. package/src/components/OcMeterRow.vue +18 -0
  60. package/src/components/OcNameCard.vue +28 -0
  61. package/src/components/OcPanel.vue +9 -0
  62. package/src/components/OcPrice.vue +30 -0
  63. package/src/components/OcProse.vue +3 -3
  64. package/src/components/OcQuantity.vue +21 -0
  65. package/src/components/OcRating.vue +17 -0
  66. package/src/components/OcRatingInput.vue +17 -0
  67. package/src/components/OcResult.vue +16 -0
  68. package/src/components/OcRibbon.vue +9 -0
  69. package/src/components/OcSpecList.vue +17 -0
  70. package/src/components/OcStock.vue +13 -0
  71. package/src/index.ts +74 -4
  72. package/src/components/OcColumnMenu.vue +0 -50
@@ -36,7 +36,7 @@ function onKeydown(e: KeyboardEvent) {
36
36
  <div class="oc-chat-composer__bar">
37
37
  <div v-if="$slots.tools" class="oc-chat-composer__tools"><slot name="tools" /></div>
38
38
  <span v-if="hint" class="oc-chat-composer__hint">{{ hint }}</span>
39
- <button type="submit" class="oc-chat-composer__send" aria-label="Send" :disabled="disabled">&#10148;</button>
39
+ <button type="submit" class="oc-chat-composer__send" aria-label="Send" :disabled="disabled"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M2 21l21-9L2 3v7l15 2l-15 2z"/></svg></button>
40
40
  </div>
41
41
  </form>
42
42
  </template>
@@ -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
- * Korrekt / OmniaDesk list table. Same markup as packages/ui/snippets/data-table.html.
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: true, filters: true, live: false },
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="Table actions">⚙</button>
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
- <button class="oc-dropdown-item" type="button" data-oc-import>Import CSV</button>
62
- <input class="oc-data-table-file" type="file" accept=".csv,text/csv" data-oc-file>
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">Table</button>
78
- <button class="oc-btn oc-btn--outline oc-btn--sm" type="button" data-oc-view="grid">Cards</button>
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-toolbar">
83
- <span class="oc-table-toolbar-spacer"></span>
84
- <button class="oc-btn oc-btn--outline oc-btn--sm" type="button" :popovertarget="`${id}-columns`">Columns</button>
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">Visible columns</p>
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"><span class="oc-visually-hidden">Actions</span></th>
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.cells.name || row.key}`"></td>
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.cells.name || row.key}`"></button></td>
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]">{{ row.cells[col.key] ?? '' }}</td>
127
- <td data-oc-col="actions" class="oc-table-actions"><button class="oc-table-row-action" type="button">Edit</button></td>
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">🙂</slot>
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,15 @@
1
+ <!-- File: packages/vue/src/components/OcFeatureCard.vue | Map: tile (icon) -> title -> text (slot) -->
2
+ <script setup lang="ts">
3
+ /** Service or feature card (snippets/feature-card.html). icon = webicon name; slots: default (text), icon. */
4
+ withDefaults(defineProps<{ title: string; icon?: string; accent?: string; headingLevel?: 2 | 3 | 4 }>(), { headingLevel: 3 })
5
+ </script>
6
+
7
+ <template>
8
+ <article class="oc-feature-card">
9
+ <span v-if="icon || $slots.icon" :class="['oc-icon-tile oc-icon-tile--sm', accent && `oc-accent--${accent}`]" aria-hidden="true">
10
+ <slot name="icon"><iconify-icon :icon="icon" /></slot>
11
+ </span>
12
+ <component :is="`h${headingLevel}`" class="oc-feature-card__title">{{ title }}</component>
13
+ <p v-if="$slots.default" class="oc-feature-card__text"><slot /></p>
14
+ </article>
15
+ </template>
@@ -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>
@@ -0,0 +1,16 @@
1
+ <!-- File: packages/vue/src/components/OcLinkCard.vue | Map: link -> tile (icon) -> title -> text (slot) -> more -->
2
+ <script setup lang="ts">
3
+ /** A card that is a whole link (snippets/feature-card.html). icon = webicon name; slots: default (text), icon. */
4
+ defineProps<{ title: string; href: string; icon?: string; accent?: string; more?: string }>()
5
+ </script>
6
+
7
+ <template>
8
+ <a :class="['oc-link-card', accent && `oc-accent--${accent}`]" :href="href">
9
+ <span v-if="icon || $slots.icon" class="oc-icon-tile oc-icon-tile--sm" aria-hidden="true">
10
+ <slot name="icon"><iconify-icon :icon="icon" /></slot>
11
+ </span>
12
+ <span class="oc-link-card__title">{{ title }}</span>
13
+ <span v-if="$slots.default" class="oc-link-card__text"><slot /></span>
14
+ <span v-if="more" class="oc-link-card__more">{{ more }}</span>
15
+ </a>
16
+ </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 Lisa Verbeke',
13
+ label: 'Conversation with Customer 1',
14
14
  items: () => [
15
15
  { divider: 'Tuesday 14 October' },
16
- { sender: 'Lisa Verbeke', avatar: 'LV', time: '09:12', datetime: '2026-10-14T09:12', label: 'Lisa Verbeke, 09:12', text: 'Morning Tom! The worktop supplier sent two samples for Villa Dewitte.' },
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: 'Lisa Verbeke', avatar: 'LV', time: '09:13', datetime: '2026-10-14T09:13', label: 'Lisa Verbeke, 09:13', continued: true, id: 'oc-message-demo-question',
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: 'TP', time: '09:20', datetime: '2026-10-14T09:20', label: 'You, 09:20', status: 'read', statusIcon: true,
23
- quote: { href: '#oc-message-demo-question', sender: 'Lisa Verbeke', text: 'Which finish should we order? Calacatta matt, 20 mm or Nero Marquina polished, 12 mm' },
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: 'Lisa Verbeke marked this question as answered', system: true },
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: 'Villa Dewitte',
49
- meta: 'Lisa Verbeke → Tom Peeters · 14 Oct 2026',
50
- participants: () => [{ initials: 'LV', name: 'Lisa Verbeke' }, { initials: 'TP', name: 'Tom Peeters' }],
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: 'Lisa Verbeke', avatar: 'LV', time: '09:12', datetime: '2026-10-14T09:12', text: 'The supplier dropped off two worktop samples at the workshop. Photo attached.',
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: 'Lisa Verbeke', avatar: 'LV', time: '09:13', datetime: '2026-10-14T09:13', continued: true,
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: 'TP', 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 Dewitte v2.pdf', size: '1.2 MB', href: '#quote.pdf' }], actions: [{ name: 'delete', label: 'Delete this message', destructive: true }] },
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: 'Lisa Verbeke', avatar: 'LV', time: '10:02', datetime: '2026-10-24T10:02', text: 'Client signed. Can you confirm the edge profile before 16:00?' },
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')">&#128206;</button>
141
- <button v-if="tools.includes('emoji')" type="button" class="oc-chat-composer__tool" aria-label="Insert emoji" @click="emit('tool', 'emoji')">&#128578;</button>
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">&#10148;</button>
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,9 @@
1
+ <!-- File: packages/vue/src/components/OcPanel.vue | Map: glow -> classes -> panel with the slot -->
2
+ <script setup lang="ts">
3
+ /** Framed panel for a form, a call to action or a feature (same markup as snippets/panel.html). glow: start | end | both. */
4
+ defineProps<{ glow?: 'start' | 'end' | 'both' }>()
5
+ </script>
6
+
7
+ <template>
8
+ <div :class="['oc-panel', (glow === 'start' || glow === 'end') && 'oc-panel--glow', glow === 'end' && 'oc-panel--glow-end', glow === 'both' && 'oc-panel--glow-both']"><slot /></div>
9
+ </template>