nuxt-unified-ui 0.5.11 → 0.5.12

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.
@@ -17,14 +17,3 @@
17
17
  @utility ltr {
18
18
  direction: ltr;
19
19
  }
20
-
21
-
22
- /* fixes */
23
-
24
- body {
25
- @apply overflow-y-auto! px-0!;
26
- }
27
-
28
- table thead tr th {
29
- @apply text-start whitespace-nowrap;
30
- }
@@ -0,0 +1,215 @@
1
+ <script setup>
2
+
3
+ /* interface */
4
+
5
+ const props = defineProps({
6
+ columns: Array,
7
+ loading: Boolean,
8
+ data: Array,
9
+ hidePagination: Boolean,
10
+ totalItems: Number,
11
+ actions: Array,
12
+ extraActions: Array,
13
+ stickyActions: Boolean,
14
+ ui: Object,
15
+ meta: Object,
16
+ });
17
+
18
+
19
+ const currentPage = defineModel('currentPage', {
20
+ type: [
21
+ Number,
22
+ String,
23
+ ],
24
+ default: '1',
25
+ });
26
+
27
+ const itemsPerPage = defineModel('itemsPerPage', {
28
+ type: [
29
+ Number,
30
+ String,
31
+ ],
32
+ default: '25',
33
+ });
34
+
35
+
36
+ /* columns */
37
+
38
+ const hasActions = computed(() => {
39
+ return !!props.actions?.length || !!props.extraActions?.length;
40
+ });
41
+
42
+ const fullColumns = computed(() => {
43
+ return [
44
+ ...(props.columns || []),
45
+ ...(!hasActions.value ? [] : [
46
+ {
47
+ header: $t('un.table.actions'),
48
+ accessorKey: 'actions',
49
+ meta: {
50
+ class: {
51
+ th: 'text-end!',
52
+ },
53
+ },
54
+ },
55
+ ]),
56
+ ];
57
+ });
58
+
59
+ const columnPinning = computed(() => {
60
+ return {
61
+ right: props.stickyActions ? [
62
+ 'actions',
63
+ ] : undefined,
64
+ };
65
+ });
66
+
67
+ const tableUi = computed(() => {
68
+ return {
69
+ tr: 'data-[expanded=true]:bg-elevated!',
70
+ ...(props.ui || {}),
71
+ };
72
+ });
73
+
74
+
75
+ /* actions */
76
+
77
+ function resolveActionValue(value, row) {
78
+ if (typeof value === 'function') {
79
+ return value(row.original);
80
+ }
81
+ else {
82
+ return value;
83
+ }
84
+ }
85
+
86
+ function getExtraActionItems(row) {
87
+ return (props.extraActions || [])
88
+ .filter(it => !it.vIf || it.vIf(row.original))
89
+ .map(it => {
90
+ return {
91
+ ...radOmit(it, [
92
+ 'vIf',
93
+ 'actionType',
94
+ 'tooltip',
95
+ 'to',
96
+ 'href',
97
+ 'disabled',
98
+ 'onClick',
99
+ ]),
100
+ href: resolveActionValue(it.href, row),
101
+ to: resolveActionValue(it.to, row),
102
+ disabled: resolveActionValue(it.disabled, row),
103
+ onClick: () => it.onClick?.(row.original),
104
+ };
105
+ });
106
+ }
107
+
108
+
109
+ /* pagination */
110
+
111
+ const pageSizeItems = [
112
+ 5,
113
+ 10,
114
+ 25,
115
+ 50,
116
+ 100,
117
+ ];
118
+
119
+ </script>
120
+
121
+
122
+ <template>
123
+ <div>
124
+
125
+ <u-table
126
+ loading-color="neutral"
127
+ :columns="fullColumns"
128
+ :loading="props.loading"
129
+ :data="props.data || []"
130
+ :column-pinning="columnPinning"
131
+ :ui="tableUi"
132
+ :meta="props.meta">
133
+
134
+ <template v-for="(_, name) in $slots" #[name]="slotData">
135
+ <slot
136
+ :name="name"
137
+ v-bind="slotData"
138
+ />
139
+ </template>
140
+
141
+ <template v-if="hasActions" #actions-cell="{ row }">
142
+ <div class="flex items-center justify-end gap-1">
143
+
144
+ <template v-for="(action, index) in props.actions" :key="index">
145
+ <template v-if="!action.vIf || action.vIf(row.original)">
146
+
147
+ <template v-if="!action.actionType || action.actionType === 'button'">
148
+ <u-tooltip :text="action.tooltip">
149
+ <u-button
150
+ variant="subtle"
151
+ v-bind="radOmit(action, [ 'actionType', 'vIf', 'tooltip', 'to', 'href', 'disabled', 'onClick' ])"
152
+ :to="resolveActionValue(action.to, row)"
153
+ :href="resolveActionValue(action.href, row)"
154
+ :disabled="resolveActionValue(action.disabled, row)"
155
+ loading-auto
156
+ @click="action.onClick?.(row.original)"
157
+ />
158
+ </u-tooltip>
159
+ </template>
160
+
161
+ <template v-else-if="action.actionType === 'separator'">
162
+ <u-separator
163
+ orientation="vertical"
164
+ class="h-6 mx-1"
165
+ />
166
+ </template>
167
+
168
+ </template>
169
+ </template>
170
+
171
+ <template v-if="!!props.extraActions?.length">
172
+
173
+ <template v-if="!!props.actions?.length">
174
+ <u-separator
175
+ orientation="vertical"
176
+ class="h-6 mx-1"
177
+ />
178
+ </template>
179
+
180
+ <u-dropdown-menu :items="getExtraActionItems(row)">
181
+ <u-button
182
+ variant="subtle"
183
+ icon="lucide:ellipsis-vertical"
184
+ />
185
+ </u-dropdown-menu>
186
+
187
+ </template>
188
+
189
+ </div>
190
+ </template>
191
+
192
+ </u-table>
193
+
194
+ <template v-if="!props.hidePagination">
195
+ <div class="flex items-center gap-2 p-3 border-t border-default">
196
+ <u-pagination
197
+ active-color="neutral"
198
+ :total="props.totalItems"
199
+ :items-per-page="Number(itemsPerPage)"
200
+ :page="Number(currentPage)"
201
+ @update:page="currentPage = $event"
202
+ />
203
+ <div class="grow" />
204
+ <u-select
205
+ :items="pageSizeItems"
206
+ v-model="itemsPerPage"
207
+ />
208
+ <span class="text-sm">
209
+ {{ $t('un.table.itemsPerPage') }}
210
+ </span>
211
+ </div>
212
+ </template>
213
+
214
+ </div>
215
+ </template>
@@ -41,19 +41,6 @@ const inputTitle = computed(() => {
41
41
 
42
42
  });
43
43
 
44
- const availableYears = computed(() => {
45
- return Array.from({ length: 80 }, (_, i) => new Date().getFullYear() + 10 - i);
46
- });
47
-
48
-
49
- function setMonthTo(month) {
50
- internalModel.value = new CalendarDate(internalModel.value?.toDate()?.getFullYear(), month, internalModel.value?.toDate()?.getDate());
51
- }
52
-
53
- function setYearTo(year) {
54
- internalModel.value = new CalendarDate(year, internalModel.value?.toDate()?.getMonth() + 1, internalModel.value?.toDate()?.getDate());
55
- }
56
-
57
44
  </script>
58
45
 
59
46
 
@@ -70,93 +57,10 @@ function setYearTo(year) {
70
57
  </u-form-field>
71
58
 
72
59
  <template #content>
73
-
74
60
  <u-calendar
75
- class="ltr p-2 [&_th]:text-center!"
76
- v-model="internalModel">
77
- <template #heading="{ value }">
78
-
79
- <template v-if="value?.split(' ')?.[0]">
80
- <u-dropdown-menu
81
- :items="[
82
- {
83
- label: 'January',
84
- onClick: () => setMonthTo(1)
85
- },
86
- {
87
- label: 'February',
88
- onClick: () => setMonthTo(2)
89
- },
90
- {
91
- label: 'March',
92
- onClick: () => setMonthTo(3)
93
- },
94
- {
95
- label: 'April',
96
- onClick: () => setMonthTo(4),
97
- },
98
- {
99
- label: 'May',
100
- onClick: () => setMonthTo(5),
101
- },
102
- {
103
- label: 'June',
104
- onClick: () => setMonthTo(6),
105
- },
106
- {
107
- label: 'July',
108
- onClick: () => setMonthTo(7),
109
- },
110
- {
111
- label: 'August',
112
- onClick: () => setMonthTo(8),
113
- },
114
- {
115
- label: 'September',
116
- onClick: () => setMonthTo(9),
117
- },
118
- {
119
- label: 'October',
120
- onClick: () => setMonthTo(10),
121
- },
122
- {
123
- label: 'November',
124
- onClick: () => setMonthTo(11),
125
- },
126
- {
127
- label: 'December',
128
- onClick: () => setMonthTo(12),
129
- },
130
- ]">
131
- <u-button
132
- variant="subtle"
133
- size="sm"
134
- :label="value?.split(' ')?.[0]"
135
- />
136
- </u-dropdown-menu>
137
- </template>
138
-
139
- <template v-if="value?.split(' ')?.[1]">
140
- <u-dropdown-menu
141
- :items="availableYears.map(year => ({
142
- label: String(year),
143
- onClick: () => setYearTo(year),
144
- }))"
145
- :ui="{
146
- content: 'max-h-40'
147
- }">
148
- <u-button
149
- variant="subtle"
150
- size="sm"
151
- :label="value?.split(' ')?.[1]"
152
- class="ms-1"
153
- />
154
- </u-dropdown-menu>
155
- </template>
156
-
157
- </template>
158
- </u-calendar>
159
-
61
+ class="p-2 [&_th]:text-center!"
62
+ v-model="internalModel"
63
+ />
160
64
  </template>
161
65
 
162
66
  </u-popover>
@@ -17,6 +17,10 @@
17
17
  },
18
18
  "media": {
19
19
  "uploadSuccess": "Datei erfolgreich hochgeladen."
20
+ },
21
+ "table": {
22
+ "actions": "Aktionen",
23
+ "itemsPerPage": "Einträge pro Seite"
20
24
  }
21
25
  }
22
26
  }
@@ -17,6 +17,10 @@
17
17
  },
18
18
  "media": {
19
19
  "uploadSuccess": "File uploaded successfully."
20
+ },
21
+ "table": {
22
+ "actions": "Actions",
23
+ "itemsPerPage": "Items per page"
20
24
  }
21
25
  }
22
26
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-unified-ui",
3
3
  "type": "module",
4
- "version": "0.5.11",
4
+ "version": "0.5.12",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./index.d.ts",
7
7
  "exports": {
@@ -20,15 +20,15 @@
20
20
  ],
21
21
  "dependencies": {
22
22
  "@formkit/tempo": "1.1.0",
23
- "@iconify-json/lucide": "1.2.131",
23
+ "@iconify-json/lucide": "1.2.135",
24
24
  "@nuxt/kit": "4.5.2",
25
25
  "@nuxt/ui": "4.11.1",
26
26
  "@nuxtjs/i18n": "10.6.0",
27
27
  "@types/canvas-confetti": "1.9.0",
28
- "@vueuse/core": "14.4.0",
29
- "@vueuse/nuxt": "14.4.0",
28
+ "@vueuse/core": "15.0.0",
29
+ "@vueuse/nuxt": "15.0.0",
30
30
  "canvas-confetti": "1.9.4",
31
- "radashi": "12.9.2",
31
+ "radashi": "12.9.5",
32
32
  "unified-mongo-filter": "0.4.0"
33
33
  },
34
34
  "peerDependencies": {
@@ -4,7 +4,8 @@ description: >-
4
4
  Single skill for the nuxt-unified-ui Nuxt layer and mandatory Nuxt code style:
5
5
  install/extend the layer, required CSS, modules/config, radashi radXxx
6
6
  auto-imports, un-form / useForm, launchFormPickerDialog /
7
- launchChoicePickerDialog, toast helpers, un-card / un-typography, pages /
7
+ launchChoicePickerDialog, toast helpers, un-card / un-typography / un-table
8
+ (attribute order, row actions, pagination), pages /
8
9
  definePageMeta, page /* params */ and /* seo */ blocks, ufetch / useUFetch wrapping, unified resources
9
10
  (server plugins, REST handleResource*, resource-manager dashboard /
10
11
  customization), attribute order/defaults, and whitespace/formatting
@@ -24,7 +25,7 @@ This is the **only** installable skill in this repo. Deep topics live under `ref
24
25
  ## When to use
25
26
 
26
27
  - Installing / extending `nuxt-unified-ui` as a Nuxt layer
27
- - Using `un-form`, dialogs, toasts, `un-card`, radashi `radXxx`, etc.
28
+ - Using `un-form`, dialogs, toasts, `un-card`, `un-table`, radashi `radXxx`, etc.
28
29
  - Declaring or customizing **unified resources** (plugins, REST, dashboard)
29
30
  - **Whenever generating or editing Nuxt/Vue/server code** that must follow the unified look (whitespace, wrapping, template shape, sectioning)
30
31
 
@@ -44,6 +45,7 @@ This is the **only** installable skill in this repo. Deep topics live under `ref
44
45
  | Dialogs / toasts / UI | [references/dialogs.md](references/dialogs.md) |
45
46
  | Dialog implementation | [references/dialogs-impl.md](references/dialogs-impl.md) |
46
47
  | Toast + `un-*` details | [references/toast-and-ui.md](references/toast-and-ui.md) |
48
+ | Tables (`un-table`) | [references/tables.md](references/tables.md) |
47
49
  | Radashi `radXxx` catalog | [references/radashi.md](references/radashi.md) |
48
50
 
49
51
  ---
@@ -122,7 +124,7 @@ Published: `nuxt.config.ts`, `index.d.ts`, `app/`, `i18n/`, `modules/`.
122
124
 
123
125
  | Path | Role |
124
126
  |------|------|
125
- | `app/components/` | `un-form`, `un-card`, `un-typography`, `un-spinner` |
127
+ | `app/components/` | `un-form`, `un-card`, `un-typography`, `un-spinner`, `un-table` |
126
128
  | `app/composables/` | `useForm`, `useFormExtraElements` |
127
129
  | `app/elements/` | Built-in form field renderers |
128
130
  | `app/dialogs/` | Form / choice picker modal UIs |
@@ -144,6 +146,7 @@ From `nuxt.config.ts`: `@vueuse/nuxt`, `@nuxt/ui`, `@nuxtjs/i18n`; `ui.colorMode
144
146
  | Confirm / choice | `launchChoicePickerDialog` + button `onClick` (avoid `value`) |
145
147
  | Feedback | `toastSuccess` / `toastError` / `toastWarning` / `toastInfo` / `toast` |
146
148
  | Page chrome | `un-typography` + `un-card` |
149
+ | Data table | `un-table` + parent-owned fetch / sort / filter → [tables.md](references/tables.md) |
147
150
  | Custom field | `registerFormExtraElement` in a plugin |
148
151
  | Utilities | `radXxx` → [radashi.md](references/radashi.md) |
149
152
  | New page / route | [pages.md](references/pages.md) |
@@ -522,7 +522,8 @@ Practical shortcuts:
522
522
 
523
523
  - `u-button`: `variant` → `color` → `size` → `icon` → label/value → `block` → `disabled` → `loading-auto` → events
524
524
  - `u-input` / `u-select*`: user-facing props (`:placeholder`, `:label`) → transient state (`:loading`, `:disabled`) → data source (`:items`) → `class` → `v-model` → events
525
- - Action objects (`:actions`, `:append-actions`, table row actions): `vIf` → `color` → `icon` → `label` → `tooltip` → `disabled` → `to` → `onClick`
525
+ - Action objects (`:actions`, `:append-actions`, table row actions): `vIf` → `actionType` → `color` → `icon` → `label` → `tooltip` → `disabled` → `to` → `href` → `onClick`
526
+ - `un-table`: `:columns` → `class` / `:ui` → `:loading` → `:data` → `hide-pagination` → `:total-items` → `v-model:items-per-page` → `v-model:current-page` → `sticky-actions` → `:actions` → `:extra-actions` → `:meta` (omit unused). Full table rules → [tables.md](tables.md)
526
527
  - Tab / select item objects: `value` → `icon` → `label`
527
528
 
528
529
  ### Default attribute values (omit noise)
@@ -5,6 +5,7 @@ Overlay dialogs (Nuxt UI `useOverlay`), toast helpers, and layout primitives.
5
5
  Deep refs:
6
6
  - [dialogs-impl.md](dialogs-impl.md)
7
7
  - [toast-and-ui.md](toast-and-ui.md)
8
+ - [tables.md](tables.md)
8
9
 
9
10
  Follow [code-style.md](code-style.md) for all generated dialog/UI code.
10
11
 
@@ -12,6 +12,7 @@ Reference components in **kebab-case** in templates.
12
12
  | `un-card` | `un-card.vue` | Card + typography + action rows |
13
13
  | `un-typography` | `un-typography.vue` | Icon / title / subtitle / text / append |
14
14
  | `un-spinner` | `un-spinner.vue` | Spinning Lucide loader icon |
15
+ | `un-table` | `un-table.vue` | `u-table` + row actions + pagination footer → [tables.md](tables.md) |
15
16
 
16
17
  ## Composables
17
18
 
@@ -0,0 +1,303 @@
1
+ # `un-table`
2
+
3
+ `app/components/un-table.vue` wraps Nuxt UI `u-table` with an optional actions column and a pagination footer. It is a **presentational page of rows**. The parent owns fetching, sorting, filtering, and which slice of data is passed in.
4
+
5
+ Follow [code-style.md](code-style.md) for all generated table code. i18n: `$t` in both script and template (do not call `useI18n()`). Keys: `un.table.actions`, `un.table.itemsPerPage`.
6
+
7
+ ## Mental model
8
+
9
+ | Belongs on `un-table` | Belongs on the parent |
10
+ |-----------------------|-----------------------|
11
+ | Columns, current page of `data`, loading flag | `useUFetch` / `ufetch`, query params |
12
+ | Footer page / page-size models | `skip` / `limit` (or client slice) from those models |
13
+ | Row `actions` / `extraActions` | Sort cycle, filter chips, toolbar |
14
+ | Slot forwarding | `{accessorKey}-header` / `-cell` contents |
15
+
16
+ Do **not** add sort, filter, or selection props to `un-table`. Resource dashboards put that above the table (`resource-explorer-table` pattern): filter bar + header slots, then `<un-table>`.
17
+
18
+ ## Props
19
+
20
+ | Prop / model | Role |
21
+ |--------------|------|
22
+ | `columns` | TanStack / Nuxt UI column defs |
23
+ | `loading` | Passed to `u-table` (OR of list + count pending) |
24
+ | `data` | **Current page** of row objects, not the full collection |
25
+ | `hidePagination` | Drop the footer (dialogs, already-sliced lists) |
26
+ | `totalItems` | Count for `u-pagination` (server `/count` or `array.length`) |
27
+ | `actions` | Visible row buttons; adds the trailing `actions` column |
28
+ | `extraActions` | Overflow `u-dropdown-menu` (ellipsis); also creates the column |
29
+ | `stickyActions` | Pin the `actions` column to the right |
30
+ | `ui` | Merged into `u-table` `:ui` after the expanded-row `tr` class |
31
+ | `meta` | Passed through to `u-table` |
32
+ | `v-model:itemsPerPage` | Page size (default `'25'`; choices 5 / 10 / 25 / 50 / 100) |
33
+ | `v-model:currentPage` | Page number (default `'1'`) |
34
+
35
+ The actions column is added when **either** `actions` or `extraActions` has length. `#actions-cell` is then owned by the wrapper — do not override it.
36
+
37
+ `to` / `href` / `disabled` on an action may be a value or `(row) => …`. `vIf(row)` hides the item. `onClick(row)` receives the original row. Buttons use `loading-auto`.
38
+
39
+ ## Attribute order on `<un-table>`
40
+
41
+ Omit unused props. When present, write them in this order:
42
+
43
+ 1. `:columns`
44
+ 2. `class` / `:ui` (presentation only)
45
+ 3. `:loading`
46
+ 4. `:data`
47
+ 5. `hide-pagination`
48
+ 6. `:total-items`
49
+ 7. `v-model:items-per-page`
50
+ 8. `v-model:current-page`
51
+ 9. `sticky-actions`
52
+ 10. `:actions`
53
+ 11. `:extra-actions`
54
+ 12. `:meta`
55
+
56
+ Models are always **page size, then page**. Closing `>` sits on the same line as the last attribute.
57
+
58
+ ### Paged table
59
+
60
+ ```vue
61
+ <un-table
62
+ :columns="columns"
63
+ :loading="isItemsLoading || isItemsCountLoading"
64
+ :data="itemsData"
65
+ :total-items="itemsCountData"
66
+ v-model:items-per-page="itemsPerPage"
67
+ v-model:current-page="currentPage"
68
+ :actions="itemActions"
69
+ :extra-actions="itemExtraActions">
70
+
71
+ <template #status-cell="{ row }">
72
+ ...
73
+ </template>
74
+
75
+ </un-table>
76
+ ```
77
+
78
+ ### Compact / dialog table
79
+
80
+ No footer, no page models, no `total-items`:
81
+
82
+ ```vue
83
+ <un-card
84
+ :icon="icon"
85
+ :title="title"
86
+ fluid-body>
87
+ <un-table
88
+ :columns="columns"
89
+ :data="rows"
90
+ hide-pagination
91
+ :actions="tableActions"
92
+ />
93
+ </un-card>
94
+ ```
95
+
96
+ `class` / `:ui` stay immediately after `:columns` when a product theme overrides the table chrome. Do not invent extra layout props on `un-table` — put search / filter toolbars in the parent, above the table.
97
+
98
+ ## Column defs
99
+
100
+ One object per column, **`accessorKey` then `header`**. Use `id` instead of `accessorKey` only when the cell is computed and there is no row field (duration, size, …).
101
+
102
+ ```js
103
+ const columns = [
104
+ {
105
+ accessorKey: 'name',
106
+ header: $t('people.columnName'),
107
+ },
108
+ {
109
+ accessorKey: 'status',
110
+ header: $t('people.columnStatus'),
111
+ },
112
+ {
113
+ id: 'duration',
114
+ header: $t('people.columnDuration'),
115
+ },
116
+ ];
117
+ ```
118
+
119
+ Keep column lists in the template when they are short and static. Move them to a script const / computed when they are reused or built from schema (`useResourceMeta` maps `key` → `accessorKey`, `radTitle(key)` → `header`).
120
+
121
+ ## Slots
122
+
123
+ All `u-table` slots are forwarded.
124
+
125
+ | Slot | Use |
126
+ |------|-----|
127
+ | `{accessorKey}-cell` | Custom cell. Bind `{ row }` and read **`row.original`** |
128
+ | `{accessorKey}-header` | Custom header (sort button + filter popover) |
129
+ | `empty` | Empty state (`u-empty` or copy) |
130
+ | `actions-cell` | **Do not set** when `actions` / `extraActions` are passed |
131
+
132
+ ```vue
133
+ <template #status-cell="{ row }">
134
+ <u-badge
135
+ variant="subtle"
136
+ :color="row.original.status === 'active' ? 'success' : undefined"
137
+ :label="radTitle(row.original.status)"
138
+ />
139
+ </template>
140
+
141
+ <template v-for="column in columns" :key="column.accessorKey" #[column.accessorKey+'-cell']="{ row }">
142
+ <resource-explorer-cell
143
+ :column="column"
144
+ :row="row.original"
145
+ :data="row.original[column.accessorKey]"
146
+ />
147
+ </template>
148
+ ```
149
+
150
+ Status / role / type cells use `u-badge` (`variant="subtle"`, omit neutral `color`). Dates use `formatDate`. Several visual states → explicit `<template v-if>` / `v-else` badge variants, not nested ternaries.
151
+
152
+ ## Row actions
153
+
154
+ Two channels:
155
+
156
+ | Prop | UI | Typical fields |
157
+ |------|----|----------------|
158
+ | `actions` | Inline icon (or labeled) buttons | `icon` + `tooltip`, or `label` |
159
+ | `extraActions` | Overflow menu | `icon` + `label` (menu items need text) |
160
+
161
+ Use `actions` for the one or two primary row operations (view, edit, delete). Use `extraActions` for the rest (assign, revoke, copy, archive). A vertical separator is inserted between the two groups automatically.
162
+
163
+ Field order on **every** action object (omit unused):
164
+
165
+ `vIf` → `actionType` → `color` → `icon` → `label` → `tooltip` → `disabled` → `to` → `href` → `onClick`
166
+
167
+ ```js
168
+ const itemActions = computed(() => {
169
+ return [
170
+ {
171
+ icon: 'lucide:eye',
172
+ tooltip: 'View',
173
+ onClick: handleItemView,
174
+ },
175
+ {
176
+ icon: 'lucide:pencil',
177
+ tooltip: 'Edit',
178
+ onClick: handleItemUpdate,
179
+ },
180
+ {
181
+ actionType: 'separator',
182
+ },
183
+ {
184
+ vIf: it => it.status !== 'archived',
185
+ color: 'error',
186
+ icon: 'lucide:trash',
187
+ tooltip: 'Delete',
188
+ disabled: it => it.role === 'admin',
189
+ onClick: handleItemDelete,
190
+ },
191
+ ];
192
+ });
193
+
194
+ const itemExtraActions = computed(() => {
195
+ return [
196
+ {
197
+ icon: 'lucide:copy',
198
+ label: 'Copy email',
199
+ onClick: handleItemCopyEmail,
200
+ },
201
+ {
202
+ vIf: it => it.status !== 'archived',
203
+ icon: 'lucide:archive',
204
+ label: 'Archive',
205
+ onClick: handleItemArchive,
206
+ },
207
+ ];
208
+ });
209
+ ```
210
+
211
+ Rules:
212
+
213
+ - Pass **handler references** (`onClick: handleItemUpdate`) unless the row must be adapted.
214
+ - Icon-only buttons: `icon` + `tooltip`, no `label`.
215
+ - Menu / text buttons: `label` (and `icon` when it helps).
216
+ - Destructive: `color: 'error'` before `icon`.
217
+ - Emphasized extra action: `color: 'primary'` before `icon`.
218
+ - `{ actionType: 'separator' }` is a lone-key object between visual groups in `actions`.
219
+ - Omit `variant: 'subtle'` — that is the button default.
220
+ - `vIf` / `disabled` / `to` / `href` take `(row) => …` when they depend on the row.
221
+ - Do not put toolbar Create / Refresh on the row. Those belong on the parent `un-card` (`:actions` / `:append-actions`).
222
+
223
+ Resource managers prepend custom row actions, then default Edit / Delete:
224
+
225
+ ```js
226
+ const resourceActions = computed(() => {
227
+ return [
228
+ ...(props.resourceActions || []),
229
+ {
230
+ icon: 'lucide:pencil',
231
+ tooltip: 'Edit',
232
+ onClick: handleResourceUpdate,
233
+ },
234
+ {
235
+ color: 'error',
236
+ icon: 'lucide:trash',
237
+ tooltip: 'Delete',
238
+ onClick: handleResourceDelete,
239
+ },
240
+ ];
241
+ });
242
+ ```
243
+
244
+ ## Pagination
245
+
246
+ Parent state:
247
+
248
+ ```js
249
+ const itemsPerPage = ref(20);
250
+ const currentPage = ref(1);
251
+ ```
252
+
253
+ - Layer default page size is `25` if the parent does not bind the model. Host pages often use `10` or `20` — set the ref explicitly.
254
+ - Server lists: `skip = (currentPage - 1) * itemsPerPage`, `limit = itemsPerPage`, `total-items` from the `/count` endpoint.
255
+ - Client lists: pass `data` already sliced; `total-items` is the uncut length.
256
+ - Reset `currentPage` to `1` when page size, filters, or the resource path change.
257
+ - After deletes, clamp `currentPage` if it is past the last page.
258
+ - `loading` should cover **both** the page request and the count request.
259
+ - `hide-pagination` still accepts `data` as the full in-memory list. Do not bind the page models just to hide them.
260
+
261
+ ## Layout
262
+
263
+ Prefer an `un-card` with `fluid-body` so the table and footer are edge-to-edge:
264
+
265
+ ```vue
266
+ <un-card
267
+ :title="`Manage ${titlePlural}`"
268
+ fluid-body
269
+ :append-actions="toolbarActions">
270
+ <un-table
271
+ :columns="columns"
272
+ :loading="isItemsLoading"
273
+ :data="itemsData"
274
+ :total-items="itemsCountData"
275
+ v-model:items-per-page="itemsPerPage"
276
+ v-model:current-page="currentPage"
277
+ :actions="itemActions"
278
+ />
279
+ </un-card>
280
+ ```
281
+
282
+ Search / filter chrome sits **inside** the card, **above** `<un-table>`, not on the table. `resource-explorer-table` is the reusable version of that stack (filter chips + sort headers + `<un-table>`).
283
+
284
+ `sticky-actions` is for wide column sets that scroll horizontally. Most pages do not need it.
285
+
286
+ ## Do / don’t
287
+
288
+ **Do**
289
+
290
+ - Keep `<un-table>` dumb: current page of rows in, actions and slots out
291
+ - Follow the attribute order and action-field order above
292
+ - Use `$t` for user-facing strings; `$t` is legal in script computeds
293
+ - Forward cell work through `{key}-cell` and `row.original`
294
+ - Put Create / Refresh on the card, Edit / Delete on the row
295
+
296
+ **Don’t**
297
+
298
+ - Pass the full unpaged array while the footer is visible
299
+ - Override `#actions-cell`
300
+ - Implement sort / filter as `un-table` props
301
+ - Call `useI18n()` for `$t`
302
+ - Restate `variant: 'subtle'` on row actions
303
+ - Use `ghost` on row actions (Cancel-only rule still applies)
@@ -64,3 +64,7 @@ Action buttons: `loading-auto`, props via `radOmit` excluding `actionType` / `to
64
64
  ```
65
65
 
66
66
  No props.
67
+
68
+ ## `un-table` — `app/components/un-table.vue`
69
+
70
+ Full rules, attribute order, actions, slots, and pagination: [tables.md](tables.md).