@conduction/nextcloud-vue 2.112.0 → 2.113.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 (47) hide show
  1. package/dist/esm/components/CnActionButtons/CnActionButtons.vue.js +1 -1
  2. package/dist/esm/components/CnActionButtons/CnActionButtons.vue2.js +4 -6
  3. package/dist/esm/components/CnActionButtons/CnActionButtons.vue2.js.map +1 -1
  4. package/dist/esm/components/CnActionButtons/CnActionButtons.vue3.js.map +1 -1
  5. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue.js +1 -1
  6. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js.map +1 -1
  7. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js.map +1 -1
  8. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  9. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +18 -3
  10. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  11. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +2 -2
  12. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  13. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +20 -5
  14. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  15. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  16. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +56 -13
  17. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  18. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +5 -3
  19. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  20. package/dist/esm/components/CnReportsPage/CnReportsPage.vue.js +1 -1
  21. package/dist/esm/components/CnReportsPage/CnReportsPage.vue2.js +29 -12
  22. package/dist/esm/components/CnReportsPage/CnReportsPage.vue2.js.map +1 -1
  23. package/dist/esm/components/CnReportsPage/CnReportsPage.vue3.js +3 -1
  24. package/dist/esm/components/CnReportsPage/CnReportsPage.vue3.js.map +1 -1
  25. package/dist/esm/library-style.js +1 -1
  26. package/dist/esm/nextcloud-vue.css +152 -181
  27. package/dist/esm/utils/actionLink.js +104 -1
  28. package/dist/esm/utils/actionLink.js.map +1 -1
  29. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  30. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  31. package/dist/nextcloud-vue.cjs.js +241 -50
  32. package/dist/nextcloud-vue.cjs.js.map +2 -2
  33. package/dist/nextcloud-vue.css +152 -181
  34. package/package.json +1 -1
  35. package/src/components/CnActionButtons/CnActionButtons.vue +4 -6
  36. package/src/components/CnChoiceCards/CnChoiceCards.vue +0 -3
  37. package/src/components/CnDashboardPage/CnDashboardPage.vue +20 -5
  38. package/src/components/CnDetailPage/CnDetailPage.vue +20 -5
  39. package/src/components/CnIndexPage/CnIndexPage.vue +59 -14
  40. package/src/components/CnReportsPage/CnReportsPage.vue +31 -12
  41. package/src/css/badge.css +15 -13
  42. package/src/css/board.css +0 -1
  43. package/src/css/look-board-index.css +3 -17
  44. package/src/css/look-board.css +2 -16
  45. package/src/schemas/app-manifest-v2.schema.json +51 -9
  46. package/src/utils/actionLink.js +103 -0
  47. package/src/utils/validateManifestV2.compiled.js +3 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.112.0",
3
+ "version": "2.113.0",
4
4
  "description": "Shared Vue component library for Conduction Nextcloud apps — complements @nextcloud/vue with higher-level components, OpenRegister integration, and NL Design System support",
5
5
  "homepage": "https://github.com/ConductionNL/nextcloud-vue#readme",
6
6
  "bugs": {
@@ -329,12 +329,10 @@ export default {
329
329
  * browser keeps middle-click, "open in new tab" and the link semantics
330
330
  * assistive tech announces.
331
331
  *
332
- * `href` is JS-composition-only, like `onSelect` — manifest JSON cannot
333
- * carry it (the schema's `target` field already names the `navigate`
334
- * destination URL, and `additionalProperties: false` rejects an
335
- * unlisted `href`). A manifest author reaching for a link should use
336
- * `type: "navigate"` with an external `target`, which auto-derives the
337
- * anchor/`window.open` behaviour instead.
332
+ * `href` is used as given, so it must be a finished URL. A manifest
333
+ * `headerActions[]` entry may carry `href` too: the page (CnDetailPage,
334
+ * CnDashboardPage) resolves an instance path through `generateUrl()`
335
+ * and sets `target` before the entry reaches this component.
338
336
  *
339
337
  * @type {Array<object>}
340
338
  */
@@ -522,7 +522,6 @@ export default {
522
522
  still legible (DqVolgendeStap), not faded. */
523
523
  .cn-choice-cards__row--disabled,
524
524
  .cn-choice-cards__row--disabled:hover {
525
- --nldesign-color-on-surface: var(--color-text-maxcontrast);
526
525
  border-color: var(--color-border);
527
526
  background: var(--color-background-hover);
528
527
  color: var(--color-text-maxcontrast);
@@ -532,7 +531,6 @@ export default {
532
531
  /* The title takes the muted ink itself: a theme that colours every label
533
532
  with `!important` (thematiq) would otherwise hand it the label colour. */
534
533
  .cn-choice-cards__row--disabled .cn-choice-cards__row-title {
535
- --nldesign-color-on-surface: var(--color-text-maxcontrast);
536
534
  color: var(--color-text-maxcontrast);
537
535
  }
538
536
 
@@ -600,7 +598,6 @@ export default {
600
598
  with `!important` (thematiq) reaches a span that only inherits. */
601
599
  .cn-choice-cards__row-description,
602
600
  .cn-choice-cards__reason {
603
- --nldesign-color-on-surface: var(--color-text-maxcontrast);
604
601
  font-size: 13px;
605
602
  font-weight: 400;
606
603
  color: var(--color-text-maxcontrast);
@@ -96,7 +96,7 @@
96
96
  <template v-if="!isBoardLook">
97
97
  <CnActionButtons
98
98
  v-if="headerActions && headerActions.length"
99
- :actions="headerActions"
99
+ :actions="linkedHeaderActions"
100
100
  data-testid="cn-dashboard-page-header-actions" />
101
101
  <!-- @slot header-actions Inline buttons rendered in the dashboard header next to the edit toggle. Used by every existing consumer (decidesk, launchpad, opencatalogi, pipelinq, procest). -->
102
102
  <slot name="header-actions" />
@@ -169,7 +169,7 @@
169
169
  <CnBuildiqEditButton />
170
170
  <CnActionButtons
171
171
  v-if="headerActions && headerActions.length"
172
- :actions="headerActions"
172
+ :actions="linkedHeaderActions"
173
173
  data-testid="cn-dashboard-page-header-actions" />
174
174
  <slot name="header-actions" />
175
175
  <slot name="actions" />
@@ -1042,6 +1042,7 @@ import { useIntegrationRegistry } from '../../composables/useIntegrationRegistry
1042
1042
  import { normalizeLook } from '../../composables/useLook.js'
1043
1043
  import { pageViews } from '../../mixins/pageViews.js'
1044
1044
  import { mergeUserLayout, resolveUserLayoutApi } from '../../store/plugins/dashboardLayouts.js'
1045
+ import { headerActionAsLink } from '../../utils/actionLink.js'
1045
1046
  import { isAppInstalled } from '../../utils/appInstalled.js'
1046
1047
  import { contentHeaderLink } from '../../utils/contentHeaderLink.js'
1047
1048
  import { pickDeclaredProps } from '../../utils/declaredProps.js'
@@ -1693,9 +1694,12 @@ export default {
1693
1694
  * in the dashboard header via CnActionButtons — `open-form` (schema
1694
1695
  * create dialog), `api-call` (POST/PUT + toast + refresh), `toggle`
1695
1696
  * (two-way state button), `navigate` / `open-modal` / `refresh`, each
1696
- * with an optional `visibleWhen` predicate. Empty (the default) renders
1697
- * nothing; the `#header-actions` slot still works alongside it for
1698
- * bespoke buttons.
1697
+ * with an optional `visibleWhen` predicate. An entry with `href` is a
1698
+ * link button, never dispatched: an absolute `http(s)`, `mailto:`,
1699
+ * `tel:` or `//host/...` URL as written, an instance path (leading `/`) through
1700
+ * `generateUrl()`, in a new tab with `target: '_blank'`. Empty (the
1701
+ * default) renders nothing; the `#header-actions` slot still works
1702
+ * alongside it for bespoke buttons.
1699
1703
  *
1700
1704
  * @type {Array<object>}
1701
1705
  */
@@ -2102,6 +2106,17 @@ export default {
2102
2106
  return typeof this.cnTranslate === 'function' ? this.cnTranslate : (key) => key
2103
2107
  },
2104
2108
 
2109
+ /**
2110
+ * The header actions handed to CnActionButtons, an entry with `href`
2111
+ * in its link form (resolved URL, anchor target, no handler).
2112
+ *
2113
+ * @spec openspec/changes/header-action-href/specs/header-action-href/spec.md#requirement-a-header-action-with-href-is-a-link-that-runs-and-emits-nothing
2114
+ * @return {Array<object>}
2115
+ */
2116
+ linkedHeaderActions() {
2117
+ return (this.headerActions || []).map(headerActionAsLink)
2118
+ },
2119
+
2105
2120
  /**
2106
2121
  * The greeting for the time of day, with the reader's first (or
2107
2122
  * full) name when known, or '' when `greeting` is off.
@@ -1257,6 +1257,7 @@ import { createRefLabelResolver } from '../../composables/useRefLabels.js'
1257
1257
  import { gridLayout } from '../../mixins/gridLayout.js'
1258
1258
  import { pageViews } from '../../mixins/pageViews.js'
1259
1259
  import { useObjectStore } from '../../store/index.js'
1260
+ import { headerActionAsLink } from '../../utils/actionLink.js'
1260
1261
  import { isAppInstalled } from '../../utils/appInstalled.js'
1261
1262
  import { compactLayoutRows } from '../../utils/dashboardPlacement.js'
1262
1263
  import { defaultDetailGrid } from '../../utils/defaultDetailGrid.js'
@@ -2219,6 +2220,11 @@ export default {
2219
2220
  * `@objectId` / `@object.<field>` token + local-predicate resolution.
2220
2221
  * Empty (the default) renders nothing.
2221
2222
  *
2223
+ * An entry with `href` is a link, never dispatched: an absolute
2224
+ * `http(s)`, `mailto:`, `tel:` or `//host/...` URL as written, or an instance path
2225
+ * (leading `/`) through `generateUrl()`, opening in place unless
2226
+ * `target` is `'_blank'`.
2227
+ *
2222
2228
  * The menu, not a row of buttons: that is what the manifest schema has
2223
2229
  * always said this key is, and what CnIndexPage already does with it.
2224
2230
  * The two surfaces had drifted, and on a dossiq case — twelve actions —
@@ -3941,6 +3947,7 @@ export default {
3941
3947
  * in. Edit goes LAST so it reads after the app's own actions in the
3942
3948
  * Actions menu — the page's affordance yields to the app's.
3943
3949
  *
3950
+ * @spec openspec/changes/header-action-href/specs/header-action-href/spec.md#requirement-a-header-action-with-href-is-a-link-that-runs-and-emits-nothing
3944
3951
  * @return {Array<object>} The action descriptors.
3945
3952
  */
3946
3953
  effectiveHeaderActions() {
@@ -3949,12 +3956,16 @@ export default {
3949
3956
  // written out in place) join the list CnActionButtons dispatches,
3950
3957
  // so they get the same dialogs, toasts and `visibleWhen` gating as
3951
3958
  // any header action. An id that is already declared is not added
3952
- // twice. With nothing pinned inline this is `headerActions` itself.
3959
+ // twice. With nothing pinned inline and no `href` entry this is
3960
+ // `headerActions` itself.
3953
3961
  const known = new Set(authored.map((action) => action && action.id))
3954
3962
  const inline = [this.stagePrimary, ...this.pinnedQuickActions]
3955
3963
  .filter((pinned) => pinned && pinned.inline && !known.has(pinned.id))
3956
3964
  .map((pinned) => pinned.inline)
3957
- const declared = inline.length > 0 ? [...authored, ...inline] : authored
3965
+ const combined = inline.length > 0 ? [...authored, ...inline] : authored
3966
+ // An entry (or child) with `href` becomes a link CnActionButtons renders as an anchor.
3967
+ const linked = combined.map(headerActionAsLink)
3968
+ const declared = linked.some((action, index) => action !== combined[index]) ? linked : combined
3958
3969
  if (!(this.foldsEditIntoActions && this.canEditRecord)) {
3959
3970
  return declared
3960
3971
  }
@@ -5198,12 +5209,13 @@ export default {
5198
5209
  *
5199
5210
  * A stage's action is dispatched the way a header action is, through
5200
5211
  * the `run()` CnActionButtons bound for it, and announced to the host
5201
- * with its declaration. A link action is left to the browser. Without
5202
- * a stage action this is the page's `primaryAction`, announced as
5203
- * before.
5212
+ * with its declaration. A link action is left to the browser and
5213
+ * announces nothing. Without a stage action this is the page's
5214
+ * `primaryAction`, announced as before.
5204
5215
  *
5205
5216
  * @return {void}
5206
5217
  * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-primary-action-follows-the-stage
5218
+ * @spec openspec/changes/header-action-href/specs/header-action-href/spec.md#requirement-a-header-action-with-href-is-a-link-that-runs-and-emits-nothing
5207
5219
  */
5208
5220
  onPrimaryActionClick() {
5209
5221
  const entry = this.stagePrimaryEntry
@@ -5211,6 +5223,9 @@ export default {
5211
5223
  this.$emit('primary-action', this.primaryAction)
5212
5224
  return
5213
5225
  }
5226
+ if (entry.href) {
5227
+ return
5228
+ }
5214
5229
  this.$emit('primary-action', this.headerActionsById[entry.id] || { id: entry.id, label: entry.label })
5215
5230
  this.onPinnedActionClick(entry)
5216
5231
  },
@@ -63,7 +63,7 @@
63
63
  <NcActionLink
64
64
  v-else-if="entry.href"
65
65
  :href="entry.href"
66
- target="_blank"
66
+ :target="entry.linkTarget || '_blank'"
67
67
  rel="noopener noreferrer"
68
68
  :data-testid="`cn-index-header-menu-${entry.key}`">
69
69
  <template v-if="entry.icon" #icon>
@@ -103,6 +103,8 @@
103
103
  <NcButton
104
104
  v-else
105
105
  :variant="button.variant"
106
+ :href="button.link ? button.link.href : undefined"
107
+ :target="button.link ? button.link.target : undefined"
106
108
  :data-testid="`cn-index-header-button-${button.key}`"
107
109
  @click="onHeaderButton(button)">
108
110
  <template v-if="button.icon" #icon>
@@ -1347,8 +1349,8 @@ import { createRefLabelResolver } from '../../composables/useRefLabels.js'
1347
1349
  import { useSavedViewsApi } from '../../composables/useSavedViewsApi.js'
1348
1350
  import { METADATA_COLUMNS } from '../../constants/metadata.js'
1349
1351
  import { useObjectStore } from '../../store/useObjectStore.js'
1350
- import { followItemActionLink, resolveItemActionLink, routeHref } from '../../utils/actionLink.js'
1351
- import { buildOnSuccessRoute, resolveRegisteredHandler } from '../../utils/actionsDispatcher.js'
1352
+ import { followItemActionLink, headerActionAsLink, resolveItemActionLink, routeHref } from '../../utils/actionLink.js'
1353
+ import { buildOnSuccessRoute, isExternalActionTarget, resolveRegisteredHandler } from '../../utils/actionsDispatcher.js'
1352
1354
  import {
1353
1355
  buildReferenceStages,
1354
1356
  buildValueStages,
@@ -3915,11 +3917,16 @@ export default {
3915
3917
  * (unless the handler is the `'none'` keyword) emits
3916
3918
  * `@header-action({ action: id, id })`.
3917
3919
  *
3920
+ * An entry with `href` instead renders as a real link and is never
3921
+ * dispatched or emitted: an absolute `http(s)`, `mailto:`, `tel:` or
3922
+ * `//host/...` URL is used as written, an instance path (leading `/`) goes through
3923
+ * `generateUrl()`. `target: '_blank'` opens it in a new tab.
3924
+ *
3918
3925
  * Reserved ids (those used by built-ins on the bar — `refresh`,
3919
3926
  * `import`, `export`, `copy`, `delete`) are dropped from the
3920
3927
  * merged list with a `console.warn`.
3921
3928
  *
3922
- * @type {Array<{ id: string, label: string, icon?: string, handler?: string|(() => void), route?: string, disabled?: boolean }>}
3929
+ * @type {Array<{ id: string, label: string, icon?: string, handler?: string|(() => void), route?: string, href?: string, target?: ('_blank'|'_self'), disabled?: boolean }>}
3923
3930
  */
3924
3931
  headerActions: {
3925
3932
  type: Array,
@@ -4372,7 +4379,7 @@ export default {
4372
4379
  /**
4373
4380
  * The header actions that open a dialog: `type: "open-form"` (the
4374
4381
  * schema-driven create form, as the navigation's primary action opens
4375
- * it) or `type: "open-modal"`, without a `handler` of their own.
4382
+ * it) or `type: "open-modal"`, without a `handler` or `href` of their own.
4376
4383
  * Reserved ids are left out, as in `mergedHeaderActions`.
4377
4384
  *
4378
4385
  * @spec openspec/changes/index-header-open-form/specs/index-header/spec.md#requirement-an-index-header-action-can-open-the-create-form
@@ -4384,9 +4391,18 @@ export default {
4384
4391
  && typeof entry.id === 'string'
4385
4392
  && !reserved.has(entry.id)
4386
4393
  && (entry.handler === undefined || entry.handler === null || entry.handler === '')
4394
+ && !entry.href
4387
4395
  && (entry.type === 'open-form' || entry.type === 'open-modal'))
4388
4396
  },
4389
4397
 
4398
+ /**
4399
+ * The header actions without reserved ids, each through
4400
+ * `resolveHeaderHandler` (a manifest `href` entry becomes a link with
4401
+ * `_dispatchSuppress`, so the page neither runs nor emits it).
4402
+ *
4403
+ * @spec openspec/changes/header-action-href/specs/header-action-href/spec.md#requirement-a-header-action-with-href-is-a-link-that-runs-and-emits-nothing
4404
+ * @return {Array<object>}
4405
+ */
4390
4406
  mergedHeaderActions() {
4391
4407
  const reserved = new Set(['refresh', 'import', 'export', 'copy', 'delete'])
4392
4408
  const merged = []
@@ -7425,11 +7441,13 @@ export default {
7425
7441
  *
7426
7442
  * @spec openspec/changes/zuiddrecht-pixel-gaps-3/specs/zuiddrecht-pixel-gaps-3/spec.md#requirement-an-index-page-can-take-the-board-header
7427
7443
  * @spec openspec/changes/screens-index-header-buttons-parity/specs/index-list-board-look/spec.md#requirement-the-add-header-button-carries-a-plus
7428
- * @return {Array<{key: string, label: string, action: string, variant: string, icon: string, format: string}>}
7444
+ * @spec openspec/changes/header-action-href/specs/header-action-href/spec.md#requirement-a-header-action-with-href-is-a-link-that-runs-and-emits-nothing
7445
+ * @return {Array<{key: string, label: string, action: string, variant: string, icon: string, format: string, link: ({href: string, target: (string|undefined)}|null)}>}
7429
7446
  */
7430
7447
  resolvedHeaderButtons() {
7431
7448
  const headerButtons = this.presented('headerButtons')
7432
7449
  const declared = Array.isArray(headerButtons) ? headerButtons : []
7450
+ const builtInActions = new Set(['add', 'export', 'import', 'refresh', 'save-view', 'actions-menu'])
7433
7451
  return declared
7434
7452
  .filter((button) => button && typeof button === 'object' && typeof button.action === 'string' && button.action !== '')
7435
7453
  .map((button, index) => {
@@ -7456,6 +7474,13 @@ export default {
7456
7474
  if (label === '' && button.action === 'save-view') {
7457
7475
  label = t('nextcloud-vue', 'Save view')
7458
7476
  }
7477
+ const run = typeof button.run === 'string' ? button.run : ''
7478
+ // A button that runs a manifest `href` header action is that link;
7479
+ // a `type: "navigate"` or function-handler entry keeps dispatching.
7480
+ const actionId = button.action === 'export' ? run : (builtInActions.has(button.action) ? '' : button.action)
7481
+ const linked = actionId
7482
+ ? this.mergedHeaderActions.find((entry) => entry && entry.id === actionId && entry.href && entry._dispatchSuppress && (!entry.type || entry.type === 'handler'))
7483
+ : null
7459
7484
  return {
7460
7485
  key: String(button.id || button.action || index),
7461
7486
  label,
@@ -7463,7 +7488,8 @@ export default {
7463
7488
  variant: button.variant === 'primary' ? 'primary' : 'secondary',
7464
7489
  icon,
7465
7490
  format: button.format === 'excel' ? 'excel' : 'csv',
7466
- run: typeof button.run === 'string' ? button.run : '',
7491
+ run,
7492
+ link: linked ? { href: linked.href, target: linked.linkTarget || undefined } : null,
7467
7493
  items: Array.isArray(button.items)
7468
7494
  ? button.items.filter((item) => (typeof item === 'string' && item !== '')
7469
7495
  || (item && typeof item === 'object' && typeof item.heading === 'string' && item.heading !== ''))
@@ -9121,8 +9147,11 @@ export default {
9121
9147
  * manifest-actions-dispatch — `navigate`, `emit`, `none`, or a
9122
9148
  * registry name (resolved through `resolveRegisteredHandler`).
9123
9149
  *
9150
+ * @spec openspec/changes/header-action-href/specs/header-action-href/spec.md#requirement-a-header-action-with-href-is-a-link-that-runs-and-emits-nothing
9124
9151
  * @param {object} entry Raw headerActions entry.
9125
- * @return {object} Possibly-mutated copy: function-typed
9152
+ * @return {object} Possibly-mutated copy: an `href` becomes a
9153
+ * resolved link with `linkTarget`, no handler (a function one
9154
+ * included) and `_dispatchSuppress: true`; function-typed
9126
9155
  * handlers untouched; `'emit'` and unknown registry names
9127
9156
  * strip the `handler` (emit-only fall-through); `'navigate'`
9128
9157
  * becomes a `$router.push` thunk (with the entry's literal
@@ -9134,14 +9163,23 @@ export default {
9134
9163
  if (!entry) {
9135
9164
  return entry
9136
9165
  }
9166
+ // An `href` makes the entry a link, which only navigates: it
9167
+ // carries no handler, and the page emits nothing for it.
9168
+ if (typeof entry.href === 'string' && entry.href !== '') {
9169
+ const link = headerActionAsLink(entry)
9170
+ return link.href ? { ...link, linkTarget: link.target, _dispatchSuppress: true } : link
9171
+ }
9137
9172
  const handler = entry.handler
9138
9173
  if (typeof handler === 'function') {
9139
9174
  return { ...entry }
9140
9175
  }
9141
9176
  // `type: "navigate"` with a `target` URL is a link: the bar and the
9142
- // header's Actions menu render it as a real anchor in a new tab.
9177
+ // header's Actions menu render it as a real anchor, an external URL
9178
+ // in a new tab.
9143
9179
  if (typeof handler !== 'string' && entry.type === 'navigate' && typeof entry.target === 'string' && entry.target !== '' && !entry.href) {
9144
- return { ...entry, href: entry.target }
9180
+ return isExternalActionTarget(entry.target)
9181
+ ? { ...entry, href: entry.target, linkTarget: '_blank' }
9182
+ : { ...entry, href: entry.target }
9145
9183
  }
9146
9184
  if (typeof handler !== 'string') {
9147
9185
  // No handler declared — emit-only fall-through.
@@ -9312,8 +9350,9 @@ export default {
9312
9350
  *
9313
9351
  * @spec openspec/changes/screens-index-header-actions-menu/specs/index-header/spec.md#requirement-the-header-actions-menu-lists-what-its-items-name
9314
9352
  * @spec openspec/changes/screens-index-header-menu-headings/specs/index-header/spec.md#requirement-the-header-actions-menu-can-draw-group-headings-and-separators
9353
+ * @spec openspec/changes/header-action-href/specs/header-action-href/spec.md#requirement-a-header-action-with-href-is-a-link-that-runs-and-emits-nothing
9315
9354
  * @param {{items?: Array<(string|{heading: string})>|null, format?: string}} button The resolved header button.
9316
- * @return {Array<{key: string, label: string, kind?: ('heading'|'separator'), icon?: string, href?: string, disabled?: boolean, title?: string, iconComponent?: object, run?: Function}>}
9355
+ * @return {Array<{key: string, label: string, kind?: ('heading'|'separator'), icon?: string, href?: string, linkTarget?: string, disabled?: boolean, title?: string, iconComponent?: object, run?: () => void}>}
9317
9356
  */
9318
9357
  headerMenuEntries(button) {
9319
9358
  const fromAction = (entry) => ({
@@ -9321,6 +9360,7 @@ export default {
9321
9360
  label: entry.label ? this.cnTranslate(entry.label) : '',
9322
9361
  icon: entry.icon && typeof entry.icon === 'string' && !entry.icon.startsWith('icon-') ? entry.icon : '',
9323
9362
  href: typeof entry.href === 'string' && entry.href !== '' ? entry.href : '',
9363
+ linkTarget: entry.linkTarget || '',
9324
9364
  // A `handler: "navigate"` entry whose route resolves carries `to`
9325
9365
  // (resolveHeaderHandler drops its handler): the menu draws it as
9326
9366
  // an in-app link, as the actions bar does, so it navigates.
@@ -9354,7 +9394,7 @@ export default {
9354
9394
  }),
9355
9395
 
9356
9396
  documentation: () => (this.documentationUrl
9357
- ? { key: 'documentation', label: this.documentationLabel || t('nextcloud-vue', 'Documentation'), icon: 'BookOpenVariantOutline', href: this.documentationUrl }
9397
+ ? { key: 'documentation', label: this.documentationLabel || t('nextcloud-vue', 'Documentation'), icon: 'BookOpenVariantOutline', href: this.documentationUrl, linkTarget: '_blank' }
9358
9398
  : null),
9359
9399
  }
9360
9400
  const entries = []
@@ -9450,13 +9490,18 @@ export default {
9450
9490
 
9451
9491
  /**
9452
9492
  * A header button was clicked: run its built-in action, or dispatch
9453
- * it as the `headerActions` entry with that id.
9493
+ * it as the `headerActions` entry with that id. A button that is a
9494
+ * link is left to the browser.
9454
9495
  *
9455
9496
  * @spec openspec/changes/zuiddrecht-pixel-gaps-3/specs/zuiddrecht-pixel-gaps-3/spec.md#requirement-an-index-page-can-take-the-board-header
9456
- * @param {{action: string, format: string}} button The resolved button.
9497
+ * @spec openspec/changes/header-action-href/specs/header-action-href/spec.md#requirement-a-header-action-with-href-is-a-link-that-runs-and-emits-nothing
9498
+ * @param {{action: string, format: string, link?: object}} button The resolved button.
9457
9499
  * @return {void}
9458
9500
  */
9459
9501
  onHeaderButton(button) {
9502
+ if (button.link) {
9503
+ return
9504
+ }
9460
9505
  if (button.action === 'add') {
9461
9506
  this.onAddClick()
9462
9507
  } else if (button.action === 'export') {
@@ -48,6 +48,8 @@
48
48
  <NcButton
49
49
  v-else
50
50
  :variant="button.variant"
51
+ :href="button.link ? button.link.href : undefined"
52
+ :target="button.link ? button.link.target : undefined"
51
53
  :data-testid="`cn-reports-header-button-${button.key}`"
52
54
  @click="onHeaderButton(button)">
53
55
  <template v-if="button.icon" #icon>
@@ -163,6 +165,7 @@ import { defineAsyncComponent } from 'vue'
163
165
  import CnBuildiqEditButton from '../CnBuildiqEditButton/CnBuildiqEditButton.vue'
164
166
  import CnIcon from '../CnIcon/CnIcon.vue'
165
167
  import { useLook } from '../../composables/useLook.js'
168
+ import { headerActionAsLink } from '../../utils/actionLink.js'
166
169
  import { resolveRegisteredHandler } from '../../utils/actionsDispatcher.js'
167
170
  import { followLinkClick } from '../../utils/linkNavigation.js'
168
171
 
@@ -325,7 +328,11 @@ export default {
325
328
  * function, `navigate` (with `route`, and `params`), `emit`, `none`,
326
329
  * or a registry name; `type: "navigate"` with a `target` URL opens
327
330
  * that URL in a new tab. Every click also emits `header-action`
328
- * `{ action, id }`, except for `handler: "none"`.
331
+ * `{ action, id }`, except for `handler: "none"`. An entry with `href`
332
+ * makes its button a real link that runs and emits nothing: an
333
+ * absolute `http(s)`, `mailto:`, `tel:` or `//host/...` URL as written,
334
+ * an instance path (leading `/`) through `generateUrl()`, in a new tab with
335
+ * `target: '_blank'`.
329
336
  *
330
337
  * @spec openspec/changes/screens-reports-header-buttons/specs/reports-page/spec.md#requirement-a-reports-page-carries-header-buttons
331
338
  * @type {Array<object>|null}
@@ -451,24 +458,31 @@ export default {
451
458
 
452
459
  /**
453
460
  * The declared header buttons that have a label and an action,
454
- * translated, each with a stable key and a variant.
461
+ * translated, each with a stable key and a variant. A button naming a
462
+ * header action with `href` carries that `link`.
455
463
  *
456
464
  * @spec openspec/changes/screens-reports-header-buttons/specs/reports-page/spec.md#requirement-a-reports-page-carries-header-buttons
457
- * @return {Array<{key: string, label: string, action: string, variant: string, icon: string}>} The buttons.
465
+ * @spec openspec/changes/header-action-href/specs/header-action-href/spec.md#requirement-a-header-action-with-href-is-a-link-that-runs-and-emits-nothing
466
+ * @return {Array<{key: string, label: string, action: string, variant: string, icon: string, link: ({href: string, target: string}|null)}>} The buttons.
458
467
  */
459
468
  resolvedHeaderButtons() {
460
469
  const declared = Array.isArray(this.config.headerButtons) ? this.config.headerButtons : []
470
+ const actions = Array.isArray(this.config.headerActions) ? this.config.headerActions : []
461
471
  return declared
462
472
  .filter((button) => button && typeof button === 'object'
463
473
  && typeof button.action === 'string' && button.action !== ''
464
474
  && typeof button.label === 'string' && button.label !== '')
465
- .map((button) => ({
466
- key: String(button.id || button.action),
467
- label: this.tr(button.label),
468
- action: button.action,
469
- variant: button.variant === 'primary' ? 'primary' : 'secondary',
470
- icon: typeof button.icon === 'string' ? button.icon : '',
471
- }))
475
+ .map((button) => {
476
+ const entry = headerActionAsLink(actions.find((action) => action && action.id === button.action))
477
+ return {
478
+ key: String(button.id || button.action),
479
+ label: this.tr(button.label),
480
+ action: button.action,
481
+ variant: button.variant === 'primary' ? 'primary' : 'secondary',
482
+ icon: typeof button.icon === 'string' ? button.icon : '',
483
+ link: entry && typeof entry.href === 'string' && entry.href !== '' ? { href: entry.href, target: entry.target } : null,
484
+ }
485
+ })
472
486
  },
473
487
 
474
488
  /**
@@ -685,13 +699,18 @@ export default {
685
699
 
686
700
  /**
687
701
  * A header button was clicked: run the `headerActions` entry it names
688
- * and emit `header-action`, as an index page's header button does.
702
+ * and emit `header-action`, as an index page's header button does. A
703
+ * button that is a link is left to the browser.
689
704
  *
690
705
  * @spec openspec/changes/screens-reports-header-buttons/specs/reports-page/spec.md#requirement-a-reports-page-carries-header-buttons
691
- * @param {{action: string}} button The resolved button.
706
+ * @spec openspec/changes/header-action-href/specs/header-action-href/spec.md#requirement-a-header-action-with-href-is-a-link-that-runs-and-emits-nothing
707
+ * @param {{action: string, link?: object}} button The resolved button.
692
708
  * @return {void}
693
709
  */
694
710
  onHeaderButton(button) {
711
+ if (button.link) {
712
+ return
713
+ }
695
714
  const id = button.action
696
715
  const actions = Array.isArray(this.config.headerActions) ? this.config.headerActions : []
697
716
  const entry = actions.find((action) => action && action.id === id) || null
package/src/css/badge.css CHANGED
@@ -3,12 +3,14 @@
3
3
  ======================================== */
4
4
 
5
5
  /* The two board tones without a Nextcloud colour role (screens-cell-pill-parity).
6
- A theme may redefine them; the six role variants below use Nextcloud's own. */
6
+ A theme may redefine them, or name the NL Design System hue tint and pill
7
+ text (`--nl-color-<hue>-light`, `--nl-color-<hue>-text`), which the tones
8
+ and the four role variants below read first (status-badge-hue-tokens). */
7
9
  :root {
8
- --cn-status-purple-bg: #e8e6f6;
9
- --cn-status-purple-text: #4a3f8f;
10
- --cn-status-teal-bg: #e3f1f3;
11
- --cn-status-teal-text: #1d5e66;
10
+ --cn-status-purple-bg: var(--nl-color-purple-light, #e8e6f6);
11
+ --cn-status-purple-text: var(--nl-color-purple-text, #4a3f8f);
12
+ --cn-status-teal-bg: var(--nl-color-teal-light, #e3f1f3);
13
+ --cn-status-teal-text: var(--nl-color-teal-text, #1d5e66);
12
14
  }
13
15
 
14
16
  .cn-status-badge {
@@ -43,26 +45,26 @@
43
45
  }
44
46
 
45
47
  .cn-status-badge--success {
46
- --cn-status-badge-ink: var(--color-success-text, var(--color-success));
47
- background-color: var(--color-success-light, rgba(70, 186, 97, 0.15));
48
+ --cn-status-badge-ink: var(--nl-color-green-text, var(--color-success-text, var(--color-success)));
49
+ background-color: var(--nl-color-green-light, var(--color-success-light, rgba(70, 186, 97, 0.15)));
48
50
  color: var(--cn-status-badge-ink);
49
51
  }
50
52
 
51
53
  .cn-status-badge--warning {
52
- --cn-status-badge-ink: var(--color-warning-text, var(--color-warning));
53
- background-color: var(--color-warning-light, rgba(232, 163, 39, 0.15));
54
+ --cn-status-badge-ink: var(--nl-color-orange-text, var(--color-warning-text, var(--color-warning)));
55
+ background-color: var(--nl-color-orange-light, var(--color-warning-light, rgba(232, 163, 39, 0.15)));
54
56
  color: var(--cn-status-badge-ink);
55
57
  }
56
58
 
57
59
  .cn-status-badge--error {
58
- --cn-status-badge-ink: var(--color-error-text, var(--color-error));
59
- background-color: var(--color-error-light, rgba(224, 68, 68, 0.15));
60
+ --cn-status-badge-ink: var(--nl-color-red-text, var(--color-error-text, var(--color-error)));
61
+ background-color: var(--nl-color-red-light, var(--color-error-light, rgba(224, 68, 68, 0.15)));
60
62
  color: var(--cn-status-badge-ink);
61
63
  }
62
64
 
63
65
  .cn-status-badge--info {
64
- --cn-status-badge-ink: var(--color-info-text, var(--color-info, #0082c9));
65
- background-color: var(--color-info-light, rgba(0, 130, 201, 0.15));
66
+ --cn-status-badge-ink: var(--nl-color-blue-text, var(--color-info-text, var(--color-info, #0082c9)));
67
+ background-color: var(--nl-color-blue-light, var(--color-info-light, rgba(0, 130, 201, 0.15)));
66
68
  color: var(--cn-status-badge-ink);
67
69
  }
68
70
 
package/src/css/board.css CHANGED
@@ -55,7 +55,6 @@
55
55
  margin-inline-start: auto;
56
56
  padding: 1px 8px;
57
57
  border-radius: 10px;
58
- --nldesign-color-on-surface: var(--cn-board-text-soft, var(--color-main-text));
59
58
  background-color: var(--color-main-background);
60
59
  color: var(--cn-board-text-soft, var(--color-main-text));
61
60
  font-size: 12px;
@@ -205,26 +205,19 @@
205
205
  outline-offset: 2px;
206
206
  }
207
207
 
208
- /* The selected chip is light on dark, so it hands its ink to
209
- --nldesign-color-on-surface: thematiq forces the body ink onto every span
210
- with `!important` and reads it from that property, which left the label
211
- dark on dark. */
208
+ /* The selected chip is light on dark. */
212
209
  .cn-look-board .cn-saved-views__chips .cn-saved-views__chip--selected,
213
210
  .cn-look-board .cn-saved-views__chips .cn-saved-views__chip--selected:hover,
214
211
  .cn-look-board .cn-quick-filter-bar .cn-quick-filter-bar__tab--active,
215
212
  .cn-look-board .cn-quick-filter-bar .cn-quick-filter-bar__tab--active:hover {
216
- --nldesign-color-on-surface: var(--color-main-background);
217
213
  border-color: transparent;
218
214
  background: var(--color-main-text);
219
215
  color: var(--color-main-background);
220
216
  }
221
217
 
222
- /* The count badge: grey on an idle chip, soft text colour on the selected one.
223
- Its ink also goes to --nldesign-color-on-surface, which a theme that forces
224
- the body ink onto every span (thematiq) reads. */
218
+ /* The count badge: grey on an idle chip, soft text colour on the selected one. */
225
219
  .cn-look-board .cn-saved-views__chip .cn-saved-views__chip-count,
226
220
  .cn-look-board .cn-quick-filter-bar .cn-quick-filter-bar__count {
227
- --nldesign-color-on-surface: var(--cn-board-text-soft);
228
221
  min-width: 0;
229
222
  padding: 1px 7px;
230
223
  border: 0;
@@ -242,7 +235,6 @@
242
235
  /* On the selected chip the count is the soft ink as a lighter bubble on the
243
236
  dark chip (DqZaken: #3d4047 on #1b1c1d). */
244
237
  .cn-look-board .cn-quick-filter-bar .cn-quick-filter-bar__tab--active .cn-quick-filter-bar__count {
245
- --nldesign-color-on-surface: var(--color-primary-element-text);
246
238
  background: var(--cn-board-text-soft);
247
239
  color: var(--color-primary-element-text);
248
240
  }
@@ -322,8 +314,6 @@
322
314
  margin-inline-start: 6px;
323
315
  padding: 0 6px;
324
316
  border-radius: 10px;
325
- /* A theme that forces the body ink onto every span reads it from here. */
326
- --nldesign-color-on-surface: var(--color-primary-element-text);
327
317
  background: var(--color-primary-element);
328
318
  color: var(--color-primary-element-text);
329
319
  font-size: 12px;
@@ -553,15 +543,13 @@
553
543
  }
554
544
 
555
545
  /* The header row is near-white on the boards (#fafbfc), not the hover grey,
556
- its labels 14px muted. The ink also goes to --nldesign-color-on-surface,
557
- which a theme that forces the body ink onto every span (thematiq) reads.
546
+ its labels 14px muted.
558
547
  The nldesign theme paints every `th` with `!important` through its table
559
548
  header tokens, and the sort button in it through its button weight token
560
549
  (bold), so the cell hands the board's ground, ink and weight to those
561
550
  tokens too.
562
551
  @spec openspec/changes/board-table-head-theme/specs/board-table-head-theme/spec.md#requirement-a-board-table-header-keeps-the-boards-ground-ink-and-weight-under-a-theme */
563
552
  .cn-look-board .cn-table-container--board .cn-data-table th {
564
- --nldesign-color-on-surface: var(--color-text-maxcontrast);
565
553
  --nldesign-component-table-header-background-color: var(--cn-board-table-head-bg, color-mix(in srgb, var(--color-background-hover) 40%, var(--color-main-background)));
566
554
  --nldesign-component-table-header-color: var(--color-text-maxcontrast);
567
555
  --nldesign-component-table-header-font-weight: 600;
@@ -695,7 +683,6 @@
695
683
  }
696
684
 
697
685
  .cn-look-board .cn-table-container--board .cn-data-table td.cn-table-col--title .cn-table-cell__secondary {
698
- --nldesign-color-on-surface: var(--color-text-maxcontrast);
699
686
  color: var(--color-text-maxcontrast);
700
687
  font-size: 14px;
701
688
  font-weight: 400;
@@ -740,7 +727,6 @@
740
727
  /* The initials sit on the light primary tint in the primary ink, the board's
741
728
  person chip (DqZaken's Behandelaar, and most boards' people). */
742
729
  .cn-look-board .cn-table-container--board .cn-data-table .cn-cell-renderer__avatar-initials {
743
- --nldesign-color-on-surface: var(--color-primary-element-light-text);
744
730
  background: var(--color-primary-element-light);
745
731
  color: var(--color-primary-element-light-text);
746
732
  font-size: 12px;