@conduction/nextcloud-vue 2.65.0 → 2.66.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 (61) hide show
  1. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +99 -2
  2. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  3. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +438 -383
  4. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  5. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js +9 -3
  6. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js.map +1 -1
  7. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js +1 -1
  8. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js.map +1 -1
  9. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +78 -21
  10. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  11. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +129 -96
  12. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  13. package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js +3 -1
  14. package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js.map +1 -1
  15. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +144 -4
  16. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  17. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  18. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +63 -7
  19. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  20. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +2 -1
  21. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  22. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js +11 -0
  23. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js.map +1 -1
  24. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js +2 -1
  25. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js.map +1 -1
  26. package/dist/esm/l10n/en.json.js +3 -0
  27. package/dist/esm/l10n/en.json.js.map +1 -1
  28. package/dist/esm/l10n/nl.json.js +3 -0
  29. package/dist/esm/l10n/nl.json.js.map +1 -1
  30. package/dist/esm/mixins/pageViews.js +400 -0
  31. package/dist/esm/mixins/pageViews.js.map +1 -0
  32. package/dist/esm/nextcloud-vue.css +18 -0
  33. package/dist/esm/utils/actionsDispatcher.js +11 -2
  34. package/dist/esm/utils/actionsDispatcher.js.map +1 -1
  35. package/dist/esm/utils/columnFilters.js +27 -3
  36. package/dist/esm/utils/columnFilters.js.map +1 -1
  37. package/dist/esm/utils/schema.js +40 -2
  38. package/dist/esm/utils/schema.js.map +1 -1
  39. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  40. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  41. package/dist/nextcloud-vue.cjs.js +1521 -596
  42. package/dist/nextcloud-vue.cjs.js.map +2 -2
  43. package/dist/nextcloud-vue.css +18 -0
  44. package/l10n/en.json +3 -0
  45. package/l10n/nl.json +3 -0
  46. package/package.json +1 -1
  47. package/src/components/CnDashboardPage/CnDashboardPage.vue +165 -8
  48. package/src/components/CnDataTable/CnColumnFilterPopover.vue +11 -5
  49. package/src/components/CnDetailPage/CnDetailPage.vue +94 -5
  50. package/src/components/CnFkResolveCell/CnFkResolveCell.vue +3 -1
  51. package/src/components/CnFormDialog/CnFormDialog.vue +144 -4
  52. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +67 -8
  53. package/src/components/CnSegmentedControl/CnSegmentedControl.md +1 -1
  54. package/src/components/CnSegmentedControl/CnSegmentedControl.vue +12 -0
  55. package/src/css/detail-page.css +5 -0
  56. package/src/mixins/pageViews.js +396 -0
  57. package/src/schemas/app-manifest-v2.schema.json +29 -1
  58. package/src/utils/actionsDispatcher.js +11 -2
  59. package/src/utils/columnFilters.js +29 -2
  60. package/src/utils/schema.js +40 -2
  61. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -1 +1 @@
1
- {"version":3,"file":"CnSegmentedControl.vue2.js","sources":["../../../../src/components/CnSegmentedControl/CnSegmentedControl.vue"],"sourcesContent":["<!--\n SPDX-FileCopyrightText: 2026 Conduction B.V.\n SPDX-License-Identifier: EUPL-1.2\n-->\n<template>\n\t<div\n\t\tclass=\"cn-segmented-control\"\n\t\t:class=\"{ 'cn-segmented-control--stretch': stretch }\"\n\t\trole=\"radiogroup\"\n\t\t:aria-label=\"ariaLabel || null\"\n\t\t:aria-labelledby=\"ariaLabelledby || null\"\n\t\t@keydown=\"onKeydown\">\n\t\t<button\n\t\t\tv-for=\"(option, index) in normalizedOptions\"\n\t\t\t:key=\"option.key\"\n\t\t\ttype=\"button\"\n\t\t\trole=\"radio\"\n\t\t\tclass=\"cn-segmented-control__option\"\n\t\t\t:class=\"{ 'cn-segmented-control__option--checked': isChecked(option) }\"\n\t\t\t:aria-checked=\"isChecked(option) ? 'true' : 'false'\"\n\t\t\t:tabindex=\"index === focusableIndex ? 0 : -1\"\n\t\t\t:disabled=\"option.disabled || null\"\n\t\t\t:data-value=\"option.key\"\n\t\t\t@click=\"select(option)\">\n\t\t\t<!-- @slot option Replaces the content of one option. -->\n\t\t\t<!-- @binding {object} option The option: `{ value, label, disabled, count }`. -->\n\t\t\t<!-- @binding {boolean} checked Whether this option is the chosen one. -->\n\t\t\t<slot name=\"option\" :option=\"option\" :checked=\"isChecked(option)\">\n\t\t\t\t<span class=\"cn-segmented-control__label\">{{ option.label }}</span>\n\t\t\t\t<!-- The leading space is not drawn in this flex row; it keeps a\n\t\t\t\t screen reader from reading label and count as one word. -->\n\t\t\t\t<span v-if=\"option.count !== null\" class=\"cn-segmented-control__count\">{{ ' ' + option.count }}</span>\n\t\t\t</slot>\n\t\t</button>\n\t</div>\n</template>\n\n<script>\n/**\n * CnSegmentedControl is a switch between a few views of the same thing, such\n * as \"My work / My team\". One option is always the chosen one.\n *\n * It is a radio group: screen readers announce the group and which option is\n * checked, the arrow keys move the choice, and Tab enters and leaves the group\n * in one step. Give it a name with `aria-label` (or `aria-labelledby`).\n *\n * ```vue\n * <CnSegmentedControl\n * v-model=\"view\"\n * aria-label=\"View\"\n * :options=\"[\n * { value: 'mine', label: 'My work' },\n * { value: 'team', label: 'My team' },\n * ]\" />\n * ```\n *\n * To switch between panels of content, use `CnTabs` with `variant=\"segmented\"`\n * instead: that keeps the tab and panel semantics and looks the same.\n */\nexport default {\n\tname: 'CnSegmentedControl',\n\n\tprops: {\n\t\t/**\n\t\t * The options. Each is `{ value, label, disabled?, count? }`, or a\n\t\t * plain string that is both the value and the label. `count` shows a\n\t\t * number after the label.\n\t\t *\n\t\t * @type {Array<string|{value: (string|number|boolean), label: string, disabled?: boolean, count?: number}>}\n\t\t */\n\t\toptions: {\n\t\t\ttype: Array,\n\t\t\trequired: true,\n\t\t},\n\n\t\t/**\n\t\t * The value of the chosen option (v-model).\n\t\t *\n\t\t * @type {string|number|boolean|null}\n\t\t */\n\t\tmodelValue: {\n\t\t\ttype: [Boolean, String, Number],\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** Accessible name of the group, read out when focus enters it. */\n\t\tariaLabel: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** Id of an element that names the group, as an alternative to `ariaLabel`. */\n\t\tariaLabelledby: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** Stretch the control to the full width, with options of equal width. */\n\t\tstretch: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\t},\n\n\temits: [\n\t\t/**\n\t\t * Emitted with the value of the newly chosen option.\n\t\t *\n\t\t * @event update:modelValue\n\t\t * @type {string|number|boolean}\n\t\t */\n\t\t'update:modelValue',\n\t],\n\n\tcomputed: {\n\t\t/**\n\t\t * The options in one shape.\n\t\t *\n\t\t * @return {Array<{key: string, value: (string|number|boolean), label: string, disabled: boolean, count: (number|null)}>}\n\t\t * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-segmented-control\n\t\t */\n\t\tnormalizedOptions() {\n\t\t\treturn (Array.isArray(this.options) ? this.options : [])\n\t\t\t\t.filter((option) => option !== null && option !== undefined)\n\t\t\t\t.map((option) => {\n\t\t\t\t\tif (typeof option !== 'object') {\n\t\t\t\t\t\treturn { key: String(option), value: option, label: String(option), disabled: false, count: null }\n\t\t\t\t\t}\n\t\t\t\t\treturn {\n\t\t\t\t\t\tkey: String(option.value),\n\t\t\t\t\t\tvalue: option.value,\n\t\t\t\t\t\tlabel: String(option.label ?? option.value),\n\t\t\t\t\t\tdisabled: option.disabled === true,\n\t\t\t\t\t\tcount: Number.isFinite(option.count) ? option.count : null,\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * The option Tab lands on: the chosen one, else the first enabled one.\n\t\t *\n\t\t * @return {number} Its index, or -1 when no option can take focus.\n\t\t * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-segmented-control\n\t\t */\n\t\tfocusableIndex() {\n\t\t\tconst checked = this.normalizedOptions.findIndex((option) => this.isChecked(option) && !option.disabled)\n\t\t\tif (checked !== -1) {\n\t\t\t\treturn checked\n\t\t\t}\n\t\t\treturn this.normalizedOptions.findIndex((option) => !option.disabled)\n\t\t},\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Whether an option is the chosen one.\n\t\t *\n\t\t * @param {{value: (string|number|boolean)}} option The option.\n\t\t * @return {boolean}\n\t\t * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-segmented-control\n\t\t */\n\t\tisChecked(option) {\n\t\t\treturn this.modelValue !== null && String(option.value) === String(this.modelValue)\n\t\t},\n\n\t\t/**\n\t\t * Choose an option.\n\t\t *\n\t\t * @param {{value: (string|number|boolean), disabled: boolean}} option The option.\n\t\t * @return {void}\n\t\t * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-segmented-control\n\t\t */\n\t\tselect(option) {\n\t\t\tif (option.disabled || this.isChecked(option)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.$emit('update:modelValue', option.value)\n\t\t},\n\n\t\t/**\n\t\t * Arrow keys, Home and End move the choice, as in a native radio\n\t\t * group. Disabled options are passed over and the ends wrap around.\n\t\t *\n\t\t * @param {KeyboardEvent} event The key press.\n\t\t * @return {void}\n\t\t * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-segmented-control\n\t\t */\n\t\tonKeydown(event) {\n\t\t\tconst enabled = this.normalizedOptions\n\t\t\t\t.map((option, index) => ({ option, index }))\n\t\t\t\t.filter((entry) => !entry.option.disabled)\n\t\t\tif (enabled.length === 0) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst buttons = Array.from(this.$el.querySelectorAll('[role=\"radio\"]'))\n\t\t\tconst focused = buttons.indexOf(document.activeElement)\n\t\t\tconst from = focused !== -1 ? focused : this.focusableIndex\n\t\t\tlet position = enabled.findIndex((entry) => entry.index === from)\n\t\t\tif (position === -1) {\n\t\t\t\tposition = 0\n\t\t\t}\n\t\t\tif (event.key === 'ArrowRight' || event.key === 'ArrowDown') {\n\t\t\t\tposition = (position + 1) % enabled.length\n\t\t\t} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {\n\t\t\t\tposition = (position - 1 + enabled.length) % enabled.length\n\t\t\t} else if (event.key === 'Home') {\n\t\t\t\tposition = 0\n\t\t\t} else if (event.key === 'End') {\n\t\t\t\tposition = enabled.length - 1\n\t\t\t} else {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tevent.preventDefault()\n\t\t\tconst target = enabled[position]\n\t\t\tthis.select(target.option)\n\t\t\tif (buttons[target.index] && typeof buttons[target.index].focus === 'function') {\n\t\t\t\tbuttons[target.index].focus()\n\t\t\t}\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n.cn-segmented-control {\n\tdisplay: inline-flex;\n\tgap: 4px;\n\tmax-width: 100%;\n\tpadding: 4px;\n\tborder-radius: var(--border-radius-container, var(--border-radius-large, 10px));\n\tbackground-color: var(--color-background-dark);\n\toverflow-x: auto;\n}\n\n.cn-segmented-control--stretch {\n\tdisplay: flex;\n}\n\n.cn-segmented-control--stretch .cn-segmented-control__option {\n\tflex: 1 1 0;\n}\n\n/* The parent class outscores Nextcloud's server rule that gives every plain\n `button` a margin and a border. */\n.cn-segmented-control .cn-segmented-control__option {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tjustify-content: center;\n\tgap: 8px;\n\tbox-sizing: border-box;\n\tmin-height: 36px;\n\tmargin: 0;\n\tpadding: 0 16px;\n\tborder: 1px solid transparent;\n\tborder-radius: var(--border-radius-element, var(--border-radius-large, 8px));\n\tbackground-color: transparent;\n\t/* Full-contrast label on every option: the choice is carried by the\n\t raised surface, not by dimming the others. */\n\tcolor: var(--color-main-text);\n\tfont: inherit;\n\tfont-weight: 600;\n\twhite-space: nowrap;\n\tcursor: pointer;\n}\n\n.cn-segmented-control .cn-segmented-control__option:hover {\n\tbackground-color: var(--color-background-hover);\n}\n\n.cn-segmented-control .cn-segmented-control__option:focus-visible {\n\toutline: 2px solid var(--color-primary-element);\n\toutline-offset: 2px;\n}\n\n/* The border survives forced-colours mode, which drops background and shadow. */\n.cn-segmented-control .cn-segmented-control__option--checked,\n.cn-segmented-control .cn-segmented-control__option--checked:hover {\n\tborder-color: var(--color-border-dark);\n\tbackground-color: var(--color-main-background);\n\tbox-shadow: 0 1px 2px var(--color-box-shadow);\n}\n\n.cn-segmented-control .cn-segmented-control__option[disabled] {\n\tcursor: default;\n\topacity: 0.5;\n}\n\n.cn-segmented-control__count {\n\tfont-weight: 400;\n}\n</style>\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"CnSegmentedControl.vue2.js","sources":["../../../../src/components/CnSegmentedControl/CnSegmentedControl.vue"],"sourcesContent":["<!--\n SPDX-FileCopyrightText: 2026 Conduction B.V.\n SPDX-License-Identifier: EUPL-1.2\n-->\n<template>\n\t<div\n\t\tclass=\"cn-segmented-control\"\n\t\t:class=\"{ 'cn-segmented-control--stretch': stretch }\"\n\t\trole=\"radiogroup\"\n\t\t:aria-label=\"ariaLabel || null\"\n\t\t:aria-labelledby=\"ariaLabelledby || null\"\n\t\t@keydown=\"onKeydown\">\n\t\t<button\n\t\t\tv-for=\"(option, index) in normalizedOptions\"\n\t\t\t:key=\"option.key\"\n\t\t\ttype=\"button\"\n\t\t\trole=\"radio\"\n\t\t\tclass=\"cn-segmented-control__option\"\n\t\t\t:class=\"{ 'cn-segmented-control__option--checked': isChecked(option) }\"\n\t\t\t:aria-checked=\"isChecked(option) ? 'true' : 'false'\"\n\t\t\t:tabindex=\"index === focusableIndex ? 0 : -1\"\n\t\t\t:disabled=\"option.disabled || null\"\n\t\t\t:aria-controls=\"controls || null\"\n\t\t\t:data-value=\"option.key\"\n\t\t\t@click=\"select(option)\">\n\t\t\t<!-- @slot option Replaces the content of one option. -->\n\t\t\t<!-- @binding {object} option The option: `{ value, label, disabled, count }`. -->\n\t\t\t<!-- @binding {boolean} checked Whether this option is the chosen one. -->\n\t\t\t<slot name=\"option\" :option=\"option\" :checked=\"isChecked(option)\">\n\t\t\t\t<span class=\"cn-segmented-control__label\">{{ option.label }}</span>\n\t\t\t\t<!-- The leading space is not drawn in this flex row; it keeps a\n\t\t\t\t screen reader from reading label and count as one word. -->\n\t\t\t\t<span v-if=\"option.count !== null\" class=\"cn-segmented-control__count\">{{ ' ' + option.count }}</span>\n\t\t\t</slot>\n\t\t</button>\n\t</div>\n</template>\n\n<script>\n/**\n * CnSegmentedControl is a switch between a few views of the same thing, such\n * as \"My work / My team\". One option is always the chosen one.\n *\n * It is a radio group: screen readers announce the group and which option is\n * checked, the arrow keys move the choice, and Tab enters and leaves the group\n * in one step. Give it a name with `aria-label` (or `aria-labelledby`).\n *\n * ```vue\n * <CnSegmentedControl\n * v-model=\"view\"\n * aria-label=\"View\"\n * :options=\"[\n * { value: 'mine', label: 'My work' },\n * { value: 'team', label: 'My team' },\n * ]\" />\n * ```\n *\n * To switch between panels of content, use `CnTabs` with `variant=\"segmented\"`\n * instead: that keeps the tab and panel semantics and looks the same.\n */\nexport default {\n\tname: 'CnSegmentedControl',\n\n\tprops: {\n\t\t/**\n\t\t * The options. Each is `{ value, label, disabled?, count? }`, or a\n\t\t * plain string that is both the value and the label. `count` shows a\n\t\t * number after the label.\n\t\t *\n\t\t * @type {Array<string|{value: (string|number|boolean), label: string, disabled?: boolean, count?: number}>}\n\t\t */\n\t\toptions: {\n\t\t\ttype: Array,\n\t\t\trequired: true,\n\t\t},\n\n\t\t/**\n\t\t * The value of the chosen option (v-model).\n\t\t *\n\t\t * @type {string|number|boolean|null}\n\t\t */\n\t\tmodelValue: {\n\t\t\ttype: [Boolean, String, Number],\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** Accessible name of the group, read out when focus enters it. */\n\t\tariaLabel: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** Id of an element that names the group, as an alternative to `ariaLabel`. */\n\t\tariaLabelledby: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/**\n\t\t * Id of the element the options switch, such as the region holding a\n\t\t * page view's widgets. Set as `aria-controls` on every option.\n\t\t *\n\t\t * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-view-switch-is-accessible\n\t\t */\n\t\tcontrols: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** Stretch the control to the full width, with options of equal width. */\n\t\tstretch: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\t},\n\n\temits: [\n\t\t/**\n\t\t * Emitted with the value of the newly chosen option.\n\t\t *\n\t\t * @event update:modelValue\n\t\t * @type {string|number|boolean}\n\t\t */\n\t\t'update:modelValue',\n\t],\n\n\tcomputed: {\n\t\t/**\n\t\t * The options in one shape.\n\t\t *\n\t\t * @return {Array<{key: string, value: (string|number|boolean), label: string, disabled: boolean, count: (number|null)}>}\n\t\t * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-segmented-control\n\t\t */\n\t\tnormalizedOptions() {\n\t\t\treturn (Array.isArray(this.options) ? this.options : [])\n\t\t\t\t.filter((option) => option !== null && option !== undefined)\n\t\t\t\t.map((option) => {\n\t\t\t\t\tif (typeof option !== 'object') {\n\t\t\t\t\t\treturn { key: String(option), value: option, label: String(option), disabled: false, count: null }\n\t\t\t\t\t}\n\t\t\t\t\treturn {\n\t\t\t\t\t\tkey: String(option.value),\n\t\t\t\t\t\tvalue: option.value,\n\t\t\t\t\t\tlabel: String(option.label ?? option.value),\n\t\t\t\t\t\tdisabled: option.disabled === true,\n\t\t\t\t\t\tcount: Number.isFinite(option.count) ? option.count : null,\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * The option Tab lands on: the chosen one, else the first enabled one.\n\t\t *\n\t\t * @return {number} Its index, or -1 when no option can take focus.\n\t\t * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-segmented-control\n\t\t */\n\t\tfocusableIndex() {\n\t\t\tconst checked = this.normalizedOptions.findIndex((option) => this.isChecked(option) && !option.disabled)\n\t\t\tif (checked !== -1) {\n\t\t\t\treturn checked\n\t\t\t}\n\t\t\treturn this.normalizedOptions.findIndex((option) => !option.disabled)\n\t\t},\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Whether an option is the chosen one.\n\t\t *\n\t\t * @param {{value: (string|number|boolean)}} option The option.\n\t\t * @return {boolean}\n\t\t * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-segmented-control\n\t\t */\n\t\tisChecked(option) {\n\t\t\treturn this.modelValue !== null && String(option.value) === String(this.modelValue)\n\t\t},\n\n\t\t/**\n\t\t * Choose an option.\n\t\t *\n\t\t * @param {{value: (string|number|boolean), disabled: boolean}} option The option.\n\t\t * @return {void}\n\t\t * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-segmented-control\n\t\t */\n\t\tselect(option) {\n\t\t\tif (option.disabled || this.isChecked(option)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.$emit('update:modelValue', option.value)\n\t\t},\n\n\t\t/**\n\t\t * Arrow keys, Home and End move the choice, as in a native radio\n\t\t * group. Disabled options are passed over and the ends wrap around.\n\t\t *\n\t\t * @param {KeyboardEvent} event The key press.\n\t\t * @return {void}\n\t\t * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-segmented-control\n\t\t */\n\t\tonKeydown(event) {\n\t\t\tconst enabled = this.normalizedOptions\n\t\t\t\t.map((option, index) => ({ option, index }))\n\t\t\t\t.filter((entry) => !entry.option.disabled)\n\t\t\tif (enabled.length === 0) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst buttons = Array.from(this.$el.querySelectorAll('[role=\"radio\"]'))\n\t\t\tconst focused = buttons.indexOf(document.activeElement)\n\t\t\tconst from = focused !== -1 ? focused : this.focusableIndex\n\t\t\tlet position = enabled.findIndex((entry) => entry.index === from)\n\t\t\tif (position === -1) {\n\t\t\t\tposition = 0\n\t\t\t}\n\t\t\tif (event.key === 'ArrowRight' || event.key === 'ArrowDown') {\n\t\t\t\tposition = (position + 1) % enabled.length\n\t\t\t} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {\n\t\t\t\tposition = (position - 1 + enabled.length) % enabled.length\n\t\t\t} else if (event.key === 'Home') {\n\t\t\t\tposition = 0\n\t\t\t} else if (event.key === 'End') {\n\t\t\t\tposition = enabled.length - 1\n\t\t\t} else {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tevent.preventDefault()\n\t\t\tconst target = enabled[position]\n\t\t\tthis.select(target.option)\n\t\t\tif (buttons[target.index] && typeof buttons[target.index].focus === 'function') {\n\t\t\t\tbuttons[target.index].focus()\n\t\t\t}\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n.cn-segmented-control {\n\tdisplay: inline-flex;\n\tgap: 4px;\n\tmax-width: 100%;\n\tpadding: 4px;\n\tborder-radius: var(--border-radius-container, var(--border-radius-large, 10px));\n\tbackground-color: var(--color-background-dark);\n\toverflow-x: auto;\n}\n\n.cn-segmented-control--stretch {\n\tdisplay: flex;\n}\n\n.cn-segmented-control--stretch .cn-segmented-control__option {\n\tflex: 1 1 0;\n}\n\n/* The parent class outscores Nextcloud's server rule that gives every plain\n `button` a margin and a border. */\n.cn-segmented-control .cn-segmented-control__option {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tjustify-content: center;\n\tgap: 8px;\n\tbox-sizing: border-box;\n\tmin-height: 36px;\n\tmargin: 0;\n\tpadding: 0 16px;\n\tborder: 1px solid transparent;\n\tborder-radius: var(--border-radius-element, var(--border-radius-large, 8px));\n\tbackground-color: transparent;\n\t/* Full-contrast label on every option: the choice is carried by the\n\t raised surface, not by dimming the others. */\n\tcolor: var(--color-main-text);\n\tfont: inherit;\n\tfont-weight: 600;\n\twhite-space: nowrap;\n\tcursor: pointer;\n}\n\n.cn-segmented-control .cn-segmented-control__option:hover {\n\tbackground-color: var(--color-background-hover);\n}\n\n.cn-segmented-control .cn-segmented-control__option:focus-visible {\n\toutline: 2px solid var(--color-primary-element);\n\toutline-offset: 2px;\n}\n\n/* The border survives forced-colours mode, which drops background and shadow. */\n.cn-segmented-control .cn-segmented-control__option--checked,\n.cn-segmented-control .cn-segmented-control__option--checked:hover {\n\tborder-color: var(--color-border-dark);\n\tbackground-color: var(--color-main-background);\n\tbox-shadow: 0 1px 2px var(--color-box-shadow);\n}\n\n.cn-segmented-control .cn-segmented-control__option[disabled] {\n\tcursor: default;\n\topacity: 0.5;\n}\n\n.cn-segmented-control__count {\n\tfont-weight: 400;\n}\n</style>\n"],"names":[],"mappings":"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"}
@@ -1,7 +1,7 @@
1
1
  import { openBlock, createElementBlock, normalizeClass, Fragment, renderList, renderSlot, createElementVNode, toDisplayString, createCommentVNode } from 'vue';
2
2
 
3
3
  const _hoisted_1 = ["aria-label", "aria-labelledby"];
4
- const _hoisted_2 = ["aria-checked", "tabindex", "disabled", "data-value", "onClick"];
4
+ const _hoisted_2 = ["aria-checked", "tabindex", "disabled", "aria-controls", "data-value", "onClick"];
5
5
  const _hoisted_3 = { class: "cn-segmented-control__label" };
6
6
  const _hoisted_4 = {
7
7
  key: 0,
@@ -25,6 +25,7 @@ function render(_ctx, _cache, $props, $setup, $data, $options) {
25
25
  "aria-checked": $options.isChecked(option) ? 'true' : 'false',
26
26
  tabindex: index === $options.focusableIndex ? 0 : -1,
27
27
  disabled: option.disabled || null,
28
+ "aria-controls": $props.controls || null,
28
29
  "data-value": option.key,
29
30
  onClick: $event => ($options.select(option))
30
31
  }, [
@@ -1 +1 @@
1
- {"version":3,"file":"CnSegmentedControl.vue3.js","sources":["../../../../src/components/CnSegmentedControl/CnSegmentedControl.vue?vue&type=template&id=7b344b4f&lang.js"],"sourcesContent":["<!--\n SPDX-FileCopyrightText: 2026 Conduction B.V.\n SPDX-License-Identifier: EUPL-1.2\n-->\n<template>\n\t<div\n\t\tclass=\"cn-segmented-control\"\n\t\t:class=\"{ 'cn-segmented-control--stretch': stretch }\"\n\t\trole=\"radiogroup\"\n\t\t:aria-label=\"ariaLabel || null\"\n\t\t:aria-labelledby=\"ariaLabelledby || null\"\n\t\t@keydown=\"onKeydown\">\n\t\t<button\n\t\t\tv-for=\"(option, index) in normalizedOptions\"\n\t\t\t:key=\"option.key\"\n\t\t\ttype=\"button\"\n\t\t\trole=\"radio\"\n\t\t\tclass=\"cn-segmented-control__option\"\n\t\t\t:class=\"{ 'cn-segmented-control__option--checked': isChecked(option) }\"\n\t\t\t:aria-checked=\"isChecked(option) ? 'true' : 'false'\"\n\t\t\t:tabindex=\"index === focusableIndex ? 0 : -1\"\n\t\t\t:disabled=\"option.disabled || null\"\n\t\t\t:data-value=\"option.key\"\n\t\t\t@click=\"select(option)\">\n\t\t\t<!-- @slot option Replaces the content of one option. -->\n\t\t\t<!-- @binding {object} option The option: `{ value, label, disabled, count }`. -->\n\t\t\t<!-- @binding {boolean} checked Whether this option is the chosen one. -->\n\t\t\t<slot name=\"option\" :option=\"option\" :checked=\"isChecked(option)\">\n\t\t\t\t<span class=\"cn-segmented-control__label\">{{ option.label }}</span>\n\t\t\t\t<!-- The leading space is not drawn in this flex row; it keeps a\n\t\t\t\t screen reader from reading label and count as one word. -->\n\t\t\t\t<span v-if=\"option.count !== null\" class=\"cn-segmented-control__count\">{{ ' ' + option.count }}</span>\n\t\t\t</slot>\n\t\t</button>\n\t</div>\n</template>\n\n<script>\n/**\n * CnSegmentedControl is a switch between a few views of the same thing, such\n * as \"My work / My team\". One option is always the chosen one.\n *\n * It is a radio group: screen readers announce the group and which option is\n * checked, the arrow keys move the choice, and Tab enters and leaves the group\n * in one step. Give it a name with `aria-label` (or `aria-labelledby`).\n *\n * ```vue\n * <CnSegmentedControl\n * v-model=\"view\"\n * aria-label=\"View\"\n * :options=\"[\n * { value: 'mine', label: 'My work' },\n * { value: 'team', label: 'My team' },\n * ]\" />\n * ```\n *\n * To switch between panels of content, use `CnTabs` with `variant=\"segmented\"`\n * instead: that keeps the tab and panel semantics and looks the same.\n */\nexport default {\n\tname: 'CnSegmentedControl',\n\n\tprops: {\n\t\t/**\n\t\t * The options. Each is `{ value, label, disabled?, count? }`, or a\n\t\t * plain string that is both the value and the label. `count` shows a\n\t\t * number after the label.\n\t\t *\n\t\t * @type {Array<string|{value: (string|number|boolean), label: string, disabled?: boolean, count?: number}>}\n\t\t */\n\t\toptions: {\n\t\t\ttype: Array,\n\t\t\trequired: true,\n\t\t},\n\n\t\t/**\n\t\t * The value of the chosen option (v-model).\n\t\t *\n\t\t * @type {string|number|boolean|null}\n\t\t */\n\t\tmodelValue: {\n\t\t\ttype: [Boolean, String, Number],\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** Accessible name of the group, read out when focus enters it. */\n\t\tariaLabel: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** Id of an element that names the group, as an alternative to `ariaLabel`. */\n\t\tariaLabelledby: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** Stretch the control to the full width, with options of equal width. */\n\t\tstretch: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\t},\n\n\temits: [\n\t\t/**\n\t\t * Emitted with the value of the newly chosen option.\n\t\t *\n\t\t * @event update:modelValue\n\t\t * @type {string|number|boolean}\n\t\t */\n\t\t'update:modelValue',\n\t],\n\n\tcomputed: {\n\t\t/**\n\t\t * The options in one shape.\n\t\t *\n\t\t * @return {Array<{key: string, value: (string|number|boolean), label: string, disabled: boolean, count: (number|null)}>}\n\t\t * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-segmented-control\n\t\t */\n\t\tnormalizedOptions() {\n\t\t\treturn (Array.isArray(this.options) ? this.options : [])\n\t\t\t\t.filter((option) => option !== null && option !== undefined)\n\t\t\t\t.map((option) => {\n\t\t\t\t\tif (typeof option !== 'object') {\n\t\t\t\t\t\treturn { key: String(option), value: option, label: String(option), disabled: false, count: null }\n\t\t\t\t\t}\n\t\t\t\t\treturn {\n\t\t\t\t\t\tkey: String(option.value),\n\t\t\t\t\t\tvalue: option.value,\n\t\t\t\t\t\tlabel: String(option.label ?? option.value),\n\t\t\t\t\t\tdisabled: option.disabled === true,\n\t\t\t\t\t\tcount: Number.isFinite(option.count) ? option.count : null,\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * The option Tab lands on: the chosen one, else the first enabled one.\n\t\t *\n\t\t * @return {number} Its index, or -1 when no option can take focus.\n\t\t * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-segmented-control\n\t\t */\n\t\tfocusableIndex() {\n\t\t\tconst checked = this.normalizedOptions.findIndex((option) => this.isChecked(option) && !option.disabled)\n\t\t\tif (checked !== -1) {\n\t\t\t\treturn checked\n\t\t\t}\n\t\t\treturn this.normalizedOptions.findIndex((option) => !option.disabled)\n\t\t},\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Whether an option is the chosen one.\n\t\t *\n\t\t * @param {{value: (string|number|boolean)}} option The option.\n\t\t * @return {boolean}\n\t\t * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-segmented-control\n\t\t */\n\t\tisChecked(option) {\n\t\t\treturn this.modelValue !== null && String(option.value) === String(this.modelValue)\n\t\t},\n\n\t\t/**\n\t\t * Choose an option.\n\t\t *\n\t\t * @param {{value: (string|number|boolean), disabled: boolean}} option The option.\n\t\t * @return {void}\n\t\t * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-segmented-control\n\t\t */\n\t\tselect(option) {\n\t\t\tif (option.disabled || this.isChecked(option)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.$emit('update:modelValue', option.value)\n\t\t},\n\n\t\t/**\n\t\t * Arrow keys, Home and End move the choice, as in a native radio\n\t\t * group. Disabled options are passed over and the ends wrap around.\n\t\t *\n\t\t * @param {KeyboardEvent} event The key press.\n\t\t * @return {void}\n\t\t * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-segmented-control\n\t\t */\n\t\tonKeydown(event) {\n\t\t\tconst enabled = this.normalizedOptions\n\t\t\t\t.map((option, index) => ({ option, index }))\n\t\t\t\t.filter((entry) => !entry.option.disabled)\n\t\t\tif (enabled.length === 0) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst buttons = Array.from(this.$el.querySelectorAll('[role=\"radio\"]'))\n\t\t\tconst focused = buttons.indexOf(document.activeElement)\n\t\t\tconst from = focused !== -1 ? focused : this.focusableIndex\n\t\t\tlet position = enabled.findIndex((entry) => entry.index === from)\n\t\t\tif (position === -1) {\n\t\t\t\tposition = 0\n\t\t\t}\n\t\t\tif (event.key === 'ArrowRight' || event.key === 'ArrowDown') {\n\t\t\t\tposition = (position + 1) % enabled.length\n\t\t\t} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {\n\t\t\t\tposition = (position - 1 + enabled.length) % enabled.length\n\t\t\t} else if (event.key === 'Home') {\n\t\t\t\tposition = 0\n\t\t\t} else if (event.key === 'End') {\n\t\t\t\tposition = enabled.length - 1\n\t\t\t} else {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tevent.preventDefault()\n\t\t\tconst target = enabled[position]\n\t\t\tthis.select(target.option)\n\t\t\tif (buttons[target.index] && typeof buttons[target.index].focus === 'function') {\n\t\t\t\tbuttons[target.index].focus()\n\t\t\t}\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n.cn-segmented-control {\n\tdisplay: inline-flex;\n\tgap: 4px;\n\tmax-width: 100%;\n\tpadding: 4px;\n\tborder-radius: var(--border-radius-container, var(--border-radius-large, 10px));\n\tbackground-color: var(--color-background-dark);\n\toverflow-x: auto;\n}\n\n.cn-segmented-control--stretch {\n\tdisplay: flex;\n}\n\n.cn-segmented-control--stretch .cn-segmented-control__option {\n\tflex: 1 1 0;\n}\n\n/* The parent class outscores Nextcloud's server rule that gives every plain\n `button` a margin and a border. */\n.cn-segmented-control .cn-segmented-control__option {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tjustify-content: center;\n\tgap: 8px;\n\tbox-sizing: border-box;\n\tmin-height: 36px;\n\tmargin: 0;\n\tpadding: 0 16px;\n\tborder: 1px solid transparent;\n\tborder-radius: var(--border-radius-element, var(--border-radius-large, 8px));\n\tbackground-color: transparent;\n\t/* Full-contrast label on every option: the choice is carried by the\n\t raised surface, not by dimming the others. */\n\tcolor: var(--color-main-text);\n\tfont: inherit;\n\tfont-weight: 600;\n\twhite-space: nowrap;\n\tcursor: pointer;\n}\n\n.cn-segmented-control .cn-segmented-control__option:hover {\n\tbackground-color: var(--color-background-hover);\n}\n\n.cn-segmented-control .cn-segmented-control__option:focus-visible {\n\toutline: 2px solid var(--color-primary-element);\n\toutline-offset: 2px;\n}\n\n/* The border survives forced-colours mode, which drops background and shadow. */\n.cn-segmented-control .cn-segmented-control__option--checked,\n.cn-segmented-control .cn-segmented-control__option--checked:hover {\n\tborder-color: var(--color-border-dark);\n\tbackground-color: var(--color-main-background);\n\tbox-shadow: 0 1px 2px var(--color-box-shadow);\n}\n\n.cn-segmented-control .cn-segmented-control__option[disabled] {\n\tcursor: default;\n\topacity: 0.5;\n}\n\n.cn-segmented-control__count {\n\tfont-weight: 400;\n}\n</style>\n"],"names":["_normalizeClass","_renderList","_renderSlot","_createElementVNode","_toDisplayString"],"mappings":";;;;AAgCS,MAAA,UAAA,GAAA,EAAA,KAAA,EAAA,6BAAA,GAAA;;;;;;;;IAtBP,KAAK,EAAAA,cAAA,CAAA,CAAC,sBAAsB,EAAA,EAAA,+BAAA,EACR,MAAA,CAAA,OAAA,EAAA,CAAA,CAAA;AACpB,IAAA,IAAI,EAAC,YAAC;AACL,IAAA,YAAU,EAAE,MAAS,CAAA,SAAA,IAAA,IAAA;AACrB,IAAA,iBAAe,EAAC,MAAA,CAAA,cAAA,IAAA,IAAA;AAChB,IAAA,SAAO,0CAAE,QAAG,CAAA,SAAA,IAAA,QAAA,CAAA,SAAA,CAAA,GAAA,IAAA,CAAA,CAAA,CAAA;;iDAEc,EAAA,IAAA,EAAAC,UAAA,CAAA,QAAA,CAAA,iBAAS,EAA3B,CAAA,MAAM,EAAE,KAAK,KAAA;;QACpB,GAAG,EAAE,MAAM,CAAC,GAAG;AAChB,QAAA,IAAI,EAAC,QAAQ;AACb,QAAA,IAAI,EAAC,OAAO;AACZ,QAAA,KAAK,EAACD,cAAA,CAAA,CAAA,8BAA4B,EACX,EAAA,uCAAA,EAAA,QAAA,CAAA,SAAA,CAAA,MAAA,CAAA,EAAA,CAAA,CAAA;QACtB,cAAY,EAAE,QAAQ,CAAA,SAAA,CAAA,MAAA,CAAA,GAAA,MAAA,GAAA,OAAA;QACtB,QAAQ,EAAE,KAAK,KAAK,QAAc,CAAA,cAAA,GAAA,CAAA,GAAA,CAAA,CAAA;QAClC,QAAQ,EAAE,MAAM,CAAC,QAAQ,IAAA,IAAA;QACzB,YAAU,EAAG,MAAK,CAAC,GAAE;QACrB,OAAK,EAAA,MAAA,KAAE,QAAM,CAAA,MAAA,CAAC,MAAM,CAAA,CAAA;;QAIpBE;AAAoB,UAAA,MAAM,QAAO;UAAI,OAAO,EAAE,QAAS,CAAA,SAAA,CAAC,MAAM,CAAA;WAA9D;AACA,UAAAC,kBAAA,CAAK,MAAL,EAAA,UAAK,EAAAC,eAAA,CAAA,MAAA,CAAA,KAAA,CAAA,EAAA,CAAA,YAAA;;;;;;;;;;;;"}
1
+ {"version":3,"file":"CnSegmentedControl.vue3.js","sources":["../../../../src/components/CnSegmentedControl/CnSegmentedControl.vue?vue&type=template&id=7b344b4f&lang.js"],"sourcesContent":["<!--\n SPDX-FileCopyrightText: 2026 Conduction B.V.\n SPDX-License-Identifier: EUPL-1.2\n-->\n<template>\n\t<div\n\t\tclass=\"cn-segmented-control\"\n\t\t:class=\"{ 'cn-segmented-control--stretch': stretch }\"\n\t\trole=\"radiogroup\"\n\t\t:aria-label=\"ariaLabel || null\"\n\t\t:aria-labelledby=\"ariaLabelledby || null\"\n\t\t@keydown=\"onKeydown\">\n\t\t<button\n\t\t\tv-for=\"(option, index) in normalizedOptions\"\n\t\t\t:key=\"option.key\"\n\t\t\ttype=\"button\"\n\t\t\trole=\"radio\"\n\t\t\tclass=\"cn-segmented-control__option\"\n\t\t\t:class=\"{ 'cn-segmented-control__option--checked': isChecked(option) }\"\n\t\t\t:aria-checked=\"isChecked(option) ? 'true' : 'false'\"\n\t\t\t:tabindex=\"index === focusableIndex ? 0 : -1\"\n\t\t\t:disabled=\"option.disabled || null\"\n\t\t\t:aria-controls=\"controls || null\"\n\t\t\t:data-value=\"option.key\"\n\t\t\t@click=\"select(option)\">\n\t\t\t<!-- @slot option Replaces the content of one option. -->\n\t\t\t<!-- @binding {object} option The option: `{ value, label, disabled, count }`. -->\n\t\t\t<!-- @binding {boolean} checked Whether this option is the chosen one. -->\n\t\t\t<slot name=\"option\" :option=\"option\" :checked=\"isChecked(option)\">\n\t\t\t\t<span class=\"cn-segmented-control__label\">{{ option.label }}</span>\n\t\t\t\t<!-- The leading space is not drawn in this flex row; it keeps a\n\t\t\t\t screen reader from reading label and count as one word. -->\n\t\t\t\t<span v-if=\"option.count !== null\" class=\"cn-segmented-control__count\">{{ ' ' + option.count }}</span>\n\t\t\t</slot>\n\t\t</button>\n\t</div>\n</template>\n\n<script>\n/**\n * CnSegmentedControl is a switch between a few views of the same thing, such\n * as \"My work / My team\". One option is always the chosen one.\n *\n * It is a radio group: screen readers announce the group and which option is\n * checked, the arrow keys move the choice, and Tab enters and leaves the group\n * in one step. Give it a name with `aria-label` (or `aria-labelledby`).\n *\n * ```vue\n * <CnSegmentedControl\n * v-model=\"view\"\n * aria-label=\"View\"\n * :options=\"[\n * { value: 'mine', label: 'My work' },\n * { value: 'team', label: 'My team' },\n * ]\" />\n * ```\n *\n * To switch between panels of content, use `CnTabs` with `variant=\"segmented\"`\n * instead: that keeps the tab and panel semantics and looks the same.\n */\nexport default {\n\tname: 'CnSegmentedControl',\n\n\tprops: {\n\t\t/**\n\t\t * The options. Each is `{ value, label, disabled?, count? }`, or a\n\t\t * plain string that is both the value and the label. `count` shows a\n\t\t * number after the label.\n\t\t *\n\t\t * @type {Array<string|{value: (string|number|boolean), label: string, disabled?: boolean, count?: number}>}\n\t\t */\n\t\toptions: {\n\t\t\ttype: Array,\n\t\t\trequired: true,\n\t\t},\n\n\t\t/**\n\t\t * The value of the chosen option (v-model).\n\t\t *\n\t\t * @type {string|number|boolean|null}\n\t\t */\n\t\tmodelValue: {\n\t\t\ttype: [Boolean, String, Number],\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** Accessible name of the group, read out when focus enters it. */\n\t\tariaLabel: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** Id of an element that names the group, as an alternative to `ariaLabel`. */\n\t\tariaLabelledby: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/**\n\t\t * Id of the element the options switch, such as the region holding a\n\t\t * page view's widgets. Set as `aria-controls` on every option.\n\t\t *\n\t\t * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-view-switch-is-accessible\n\t\t */\n\t\tcontrols: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** Stretch the control to the full width, with options of equal width. */\n\t\tstretch: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\t},\n\n\temits: [\n\t\t/**\n\t\t * Emitted with the value of the newly chosen option.\n\t\t *\n\t\t * @event update:modelValue\n\t\t * @type {string|number|boolean}\n\t\t */\n\t\t'update:modelValue',\n\t],\n\n\tcomputed: {\n\t\t/**\n\t\t * The options in one shape.\n\t\t *\n\t\t * @return {Array<{key: string, value: (string|number|boolean), label: string, disabled: boolean, count: (number|null)}>}\n\t\t * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-segmented-control\n\t\t */\n\t\tnormalizedOptions() {\n\t\t\treturn (Array.isArray(this.options) ? this.options : [])\n\t\t\t\t.filter((option) => option !== null && option !== undefined)\n\t\t\t\t.map((option) => {\n\t\t\t\t\tif (typeof option !== 'object') {\n\t\t\t\t\t\treturn { key: String(option), value: option, label: String(option), disabled: false, count: null }\n\t\t\t\t\t}\n\t\t\t\t\treturn {\n\t\t\t\t\t\tkey: String(option.value),\n\t\t\t\t\t\tvalue: option.value,\n\t\t\t\t\t\tlabel: String(option.label ?? option.value),\n\t\t\t\t\t\tdisabled: option.disabled === true,\n\t\t\t\t\t\tcount: Number.isFinite(option.count) ? option.count : null,\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * The option Tab lands on: the chosen one, else the first enabled one.\n\t\t *\n\t\t * @return {number} Its index, or -1 when no option can take focus.\n\t\t * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-segmented-control\n\t\t */\n\t\tfocusableIndex() {\n\t\t\tconst checked = this.normalizedOptions.findIndex((option) => this.isChecked(option) && !option.disabled)\n\t\t\tif (checked !== -1) {\n\t\t\t\treturn checked\n\t\t\t}\n\t\t\treturn this.normalizedOptions.findIndex((option) => !option.disabled)\n\t\t},\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Whether an option is the chosen one.\n\t\t *\n\t\t * @param {{value: (string|number|boolean)}} option The option.\n\t\t * @return {boolean}\n\t\t * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-segmented-control\n\t\t */\n\t\tisChecked(option) {\n\t\t\treturn this.modelValue !== null && String(option.value) === String(this.modelValue)\n\t\t},\n\n\t\t/**\n\t\t * Choose an option.\n\t\t *\n\t\t * @param {{value: (string|number|boolean), disabled: boolean}} option The option.\n\t\t * @return {void}\n\t\t * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-segmented-control\n\t\t */\n\t\tselect(option) {\n\t\t\tif (option.disabled || this.isChecked(option)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.$emit('update:modelValue', option.value)\n\t\t},\n\n\t\t/**\n\t\t * Arrow keys, Home and End move the choice, as in a native radio\n\t\t * group. Disabled options are passed over and the ends wrap around.\n\t\t *\n\t\t * @param {KeyboardEvent} event The key press.\n\t\t * @return {void}\n\t\t * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-segmented-control\n\t\t */\n\t\tonKeydown(event) {\n\t\t\tconst enabled = this.normalizedOptions\n\t\t\t\t.map((option, index) => ({ option, index }))\n\t\t\t\t.filter((entry) => !entry.option.disabled)\n\t\t\tif (enabled.length === 0) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst buttons = Array.from(this.$el.querySelectorAll('[role=\"radio\"]'))\n\t\t\tconst focused = buttons.indexOf(document.activeElement)\n\t\t\tconst from = focused !== -1 ? focused : this.focusableIndex\n\t\t\tlet position = enabled.findIndex((entry) => entry.index === from)\n\t\t\tif (position === -1) {\n\t\t\t\tposition = 0\n\t\t\t}\n\t\t\tif (event.key === 'ArrowRight' || event.key === 'ArrowDown') {\n\t\t\t\tposition = (position + 1) % enabled.length\n\t\t\t} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {\n\t\t\t\tposition = (position - 1 + enabled.length) % enabled.length\n\t\t\t} else if (event.key === 'Home') {\n\t\t\t\tposition = 0\n\t\t\t} else if (event.key === 'End') {\n\t\t\t\tposition = enabled.length - 1\n\t\t\t} else {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tevent.preventDefault()\n\t\t\tconst target = enabled[position]\n\t\t\tthis.select(target.option)\n\t\t\tif (buttons[target.index] && typeof buttons[target.index].focus === 'function') {\n\t\t\t\tbuttons[target.index].focus()\n\t\t\t}\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n.cn-segmented-control {\n\tdisplay: inline-flex;\n\tgap: 4px;\n\tmax-width: 100%;\n\tpadding: 4px;\n\tborder-radius: var(--border-radius-container, var(--border-radius-large, 10px));\n\tbackground-color: var(--color-background-dark);\n\toverflow-x: auto;\n}\n\n.cn-segmented-control--stretch {\n\tdisplay: flex;\n}\n\n.cn-segmented-control--stretch .cn-segmented-control__option {\n\tflex: 1 1 0;\n}\n\n/* The parent class outscores Nextcloud's server rule that gives every plain\n `button` a margin and a border. */\n.cn-segmented-control .cn-segmented-control__option {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tjustify-content: center;\n\tgap: 8px;\n\tbox-sizing: border-box;\n\tmin-height: 36px;\n\tmargin: 0;\n\tpadding: 0 16px;\n\tborder: 1px solid transparent;\n\tborder-radius: var(--border-radius-element, var(--border-radius-large, 8px));\n\tbackground-color: transparent;\n\t/* Full-contrast label on every option: the choice is carried by the\n\t raised surface, not by dimming the others. */\n\tcolor: var(--color-main-text);\n\tfont: inherit;\n\tfont-weight: 600;\n\twhite-space: nowrap;\n\tcursor: pointer;\n}\n\n.cn-segmented-control .cn-segmented-control__option:hover {\n\tbackground-color: var(--color-background-hover);\n}\n\n.cn-segmented-control .cn-segmented-control__option:focus-visible {\n\toutline: 2px solid var(--color-primary-element);\n\toutline-offset: 2px;\n}\n\n/* The border survives forced-colours mode, which drops background and shadow. */\n.cn-segmented-control .cn-segmented-control__option--checked,\n.cn-segmented-control .cn-segmented-control__option--checked:hover {\n\tborder-color: var(--color-border-dark);\n\tbackground-color: var(--color-main-background);\n\tbox-shadow: 0 1px 2px var(--color-box-shadow);\n}\n\n.cn-segmented-control .cn-segmented-control__option[disabled] {\n\tcursor: default;\n\topacity: 0.5;\n}\n\n.cn-segmented-control__count {\n\tfont-weight: 400;\n}\n</style>\n"],"names":["_normalizeClass","_renderList","_renderSlot","_createElementVNode","_toDisplayString"],"mappings":";;;;AAiCS,MAAA,UAAA,GAAA,EAAA,KAAA,EAAA,6BAAA,GAAA;;;;;;;;IAvBP,KAAK,EAAAA,cAAA,CAAA,CAAC,sBAAsB,EAAA,EAAA,+BAAA,EACR,MAAA,CAAA,OAAA,EAAA,CAAA,CAAA;AACpB,IAAA,IAAI,EAAC,YAAC;AACL,IAAA,YAAU,EAAE,MAAS,CAAA,SAAA,IAAA,IAAA;AACrB,IAAA,iBAAe,EAAC,MAAA,CAAA,cAAA,IAAA,IAAA;AAChB,IAAA,SAAO,0CAAE,QAAG,CAAA,SAAA,IAAA,QAAA,CAAA,SAAA,CAAA,GAAA,IAAA,CAAA,CAAA,CAAA;;iDAEc,EAAA,IAAA,EAAAC,UAAA,CAAA,QAAA,CAAA,iBAAS,EAA3B,CAAA,MAAM,EAAE,KAAK,KAAA;;QACpB,GAAG,EAAE,MAAM,CAAC,GAAG;AAChB,QAAA,IAAI,EAAC,QAAQ;AACb,QAAA,IAAI,EAAC,OAAO;AACZ,QAAA,KAAK,EAACD,cAAA,CAAA,CAAA,8BAA4B,EACF,EAAA,uCAAA,EAAA,QAAA,CAAA,SAAA,CAAA,MAAA,CAAA,EAAA,CAAA,CAAA;QAC/B,cAAY,EAAE,QAAQ,CAAA,SAAA,CAAA,MAAA,CAAA,GAAA,MAAA,GAAA,OAAA;QACtB,QAAQ,EAAE,KAAK,KAAK,QAAE,CAAA,cAAA,GAAA,CAAA,GAAA,CAAA,CAAA;QACtB,QAAQ,EAAE,OAAO,QAAO,IAAA,IAAA;AACxB,QAAA,eAAa,EAAE,MAAQ,CAAA,QAAA,IAAA,IAAA;QACvB,YAAU,EAAG,MAAK,CAAC,GAAE;QACrB,OAAK,EAAA,MAAA,KAAE,QAAM,CAAA,MAAA,CAAC,MAAM,CAAA,CAAA;;QAIpBE;AAAoB,UAAA,MAAM,QAAO;UAAI,OAAO,EAAE,QAAS,CAAA,SAAA,CAAC,MAAM,CAAA;WAA9D;AACA,UAAAC,kBAAA,CAAK,MAAL,EAAA,UAAK,EAAAC,eAAA,CAAA,MAAA,CAAA,KAAA,CAAA,EAAA,CAAA,YAAA;;;;;;;;;;;;"}
@@ -48,6 +48,7 @@ var translations = {
48
48
  "This integration is not available.": "This integration is not available.",
49
49
  Integrations: "Integrations",
50
50
  View: "View",
51
+ Views: "Views",
51
52
  "current step": "current step",
52
53
  "{app} not available": "{app} not available",
53
54
  "{app} not configured": "{app} not configured",
@@ -404,6 +405,7 @@ var translations = {
404
405
  Any: "Any",
405
406
  To: "To",
406
407
  Equals: "Equals",
408
+ Contains: "Contains",
407
409
  Clear: "Clear",
408
410
  Apply: "Apply",
409
411
  "This column cannot list its objects.": "This column cannot list its objects.",
@@ -1072,6 +1074,7 @@ var translations = {
1072
1074
  "The actions of an attention card are declared in the manifest.": "The actions of an attention card are declared in the manifest.",
1073
1075
  "An attention card needs a title": "An attention card needs a title",
1074
1076
  "Good morning, {name}": "Good morning, {name}",
1077
+ "This view has no widgets yet.": "This view has no widgets yet.",
1075
1078
  "Good morning": "Good morning",
1076
1079
  "Good afternoon, {name}": "Good afternoon, {name}",
1077
1080
  "Good afternoon": "Good afternoon",
@@ -1 +1 @@
1
- {"version":3,"file":"en.json.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
1
+ {"version":3,"file":"en.json.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
@@ -48,6 +48,7 @@ var translations = {
48
48
  "This integration is not available.": "Deze integratie is niet beschikbaar.",
49
49
  Integrations: "Integraties",
50
50
  View: "Bekijken",
51
+ Views: "Weergaven",
51
52
  "current step": "huidige stap",
52
53
  "{app} not available": "{app} niet beschikbaar",
53
54
  "{app} not configured": "{app} niet geconfigureerd",
@@ -404,6 +405,7 @@ var translations = {
404
405
  Any: "Alle",
405
406
  To: "Tot",
406
407
  Equals: "Is gelijk aan",
408
+ Contains: "Bevat",
407
409
  Clear: "Wissen",
408
410
  Apply: "Toepassen",
409
411
  "This column cannot list its objects.": "Deze kolom kan zijn objecten niet tonen.",
@@ -1124,6 +1126,7 @@ var translations = {
1124
1126
  "The actions of an attention card are declared in the manifest.": "De acties van een aandachtskaart staan in het manifest.",
1125
1127
  "An attention card needs a title": "Een aandachtskaart heeft een titel nodig",
1126
1128
  "Good morning, {name}": "Goedemorgen, {name}",
1129
+ "This view has no widgets yet.": "Deze weergave heeft nog geen widgets.",
1127
1130
  "Good morning": "Goedemorgen",
1128
1131
  "Good afternoon, {name}": "Goedemiddag, {name}",
1129
1132
  "Good afternoon": "Goedemiddag",
@@ -1 +1 @@
1
- {"version":3,"file":"nl.json.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
1
+ {"version":3,"file":"nl.json.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
@@ -0,0 +1,400 @@
1
+ import { translate } from '@nextcloud/l10n';
2
+ import { readUserPreference, writeUserPreference, USER_PREFERENCE_STORAGE_PREFIX } from '../composables/useUserPreferences.js';
3
+
4
+ /**
5
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
6
+ * SPDX-License-Identifier: EUPL-1.2
7
+ *
8
+ * Page views: a few views of one page, each its own widget grid, behind one
9
+ * segmented control ("My work | My team").
10
+ *
11
+ * A page that mixes this in accepts `views` (each `{ id, label, icon?,
12
+ * widgets, layout, emptyText? }`), `defaultView` and `viewsLabel`. It keeps
13
+ * the chosen view, writes it to the address (`?view=<id>`) and to the
14
+ * user's preferences (the server, mirrored in the browser), and provides `cnPageViews` so a greeting header widget on
15
+ * the page can draw the switch instead of the page.
16
+ *
17
+ * Which view opens: the address, then the stored view, then `defaultView`,
18
+ * then the first view. An id that names no view is ignored at every step.
19
+ *
20
+ * Used by CnDashboardPage and CnDetailPage. Each renders the chosen view's
21
+ * `layout` with its own grid, so a view looks exactly like the page's own
22
+ * grid.
23
+ *
24
+ * @mixin pageViews
25
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-page-declares-views-that-each-hold-a-widget-grid
26
+ */
27
+
28
+ /** The query parameter that carries the chosen view. */
29
+ const PAGE_VIEW_QUERY_KEY = 'view';
30
+
31
+ /** Prefix of the user-preference key; the page id follows it. */
32
+ const PAGE_VIEW_PREFERENCE_PREFIX = 'cn_page_view:';
33
+
34
+ let regionSeq = 0;
35
+
36
+ /**
37
+ * Read the browser mirror of a user preference synchronously, so the stored
38
+ * view is on screen from the first frame. Errors read as nothing stored.
39
+ *
40
+ * @param {string} appId The app id.
41
+ * @param {string} key The preference key.
42
+ * @return {string|null} The stored id, or null.
43
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-chosen-view-is-linkable-and-remembered
44
+ */
45
+ function readStoredViewMirror(appId, key) {
46
+ try {
47
+ if (typeof localStorage === 'undefined') {
48
+ return null
49
+ }
50
+ const raw = localStorage.getItem(`${USER_PREFERENCE_STORAGE_PREFIX}${appId}:${key}`);
51
+ if (raw === null || raw === undefined) {
52
+ return null
53
+ }
54
+ const value = JSON.parse(raw);
55
+ return typeof value === 'string' && value !== '' ? value : null
56
+ } catch {
57
+ return null
58
+ }
59
+ }
60
+
61
+ const pageViews = {
62
+ inject: {
63
+ /** The host app id, provided by CnAppRoot; keys the stored view. */
64
+ cnPageViewsAppId: { from: 'cnAppId', default: '' },
65
+ },
66
+
67
+ props: {
68
+ /**
69
+ * Views of this page, each its own widget grid behind one segmented
70
+ * control. Each is `{ id, label, icon?, widgets, layout, emptyText? }`:
71
+ * `widgets` and `layout` have the same shape as the page's own. The
72
+ * chosen view renders in a region below the page's own grid. Empty
73
+ * (the default) draws no switch and no region.
74
+ *
75
+ * @type {Array<{id: string, label: string, icon?: string, widgets?: Array<object>, layout?: Array<object>, emptyText?: string}>}
76
+ */
77
+ views: {
78
+ type: Array,
79
+ default: () => [],
80
+ },
81
+
82
+ /**
83
+ * The id of the view chosen when neither the address nor the browser
84
+ * store names one. Empty chooses the first view.
85
+ *
86
+ * @type {string}
87
+ */
88
+ defaultView: {
89
+ type: String,
90
+ default: '',
91
+ },
92
+
93
+ /**
94
+ * Accessible name of the view switch (through the host translate
95
+ * function). Empty reads "View".
96
+ *
97
+ * @type {string}
98
+ */
99
+ viewsLabel: {
100
+ type: String,
101
+ default: '',
102
+ },
103
+ },
104
+
105
+ data() {
106
+ regionSeq += 1;
107
+ return {
108
+ /** The view the user chose in this session, or the stored one. */
109
+ selectedViewId: null,
110
+ /** Whether the user chose a view in this session (a late server read then leaves it alone). */
111
+ viewChosen: false,
112
+ /** Id of the view region, the target of the options' `aria-controls`. */
113
+ viewRegionId: `cn-page-view-region-${regionSeq}`,
114
+ }
115
+ },
116
+
117
+ /**
118
+ * Provide the views to descendants, so a greeting header widget in the
119
+ * page's grid can draw the switch. Getters keep it reactive.
120
+ *
121
+ * @return {object} The provided `cnPageViews`.
122
+ */
123
+ provide() {
124
+ const vm = this;
125
+ return {
126
+ cnPageViews: {
127
+ get views() { return vm.normalizedViews },
128
+ get activeId() { return vm.activeViewId },
129
+ get regionId() { return vm.viewRegionId },
130
+ select: (id) => vm.selectView(id),
131
+ },
132
+ }
133
+ },
134
+
135
+ computed: {
136
+ /**
137
+ * The declared views in one shape, labels translated. Entries without
138
+ * an id or a label are dropped, as are repeated ids.
139
+ *
140
+ * @return {Array<{id: string, label: string, icon: string, widgets: Array<object>, layout: Array<object>, emptyText: string}>}
141
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-page-declares-views-that-each-hold-a-widget-grid
142
+ */
143
+ normalizedViews() {
144
+ const translate = typeof this.cnTranslate === 'function' ? this.cnTranslate : (key) => key;
145
+ const seen = new Set();
146
+ return (Array.isArray(this.views) ? this.views : [])
147
+ .filter((view) => {
148
+ if (!view || typeof view !== 'object' || view.id === undefined || view.id === null || view.id === '') {
149
+ return false
150
+ }
151
+ if (typeof view.label !== 'string' || view.label === '' || seen.has(String(view.id))) {
152
+ return false
153
+ }
154
+ seen.add(String(view.id));
155
+ return true
156
+ })
157
+ .map((view) => ({
158
+ id: String(view.id),
159
+ label: translate(view.label),
160
+ icon: typeof view.icon === 'string' ? view.icon : '',
161
+ widgets: Array.isArray(view.widgets) ? view.widgets : [],
162
+ layout: Array.isArray(view.layout) ? view.layout : [],
163
+ emptyText: typeof view.emptyText === 'string' && view.emptyText !== '' ? translate(view.emptyText) : '',
164
+ }))
165
+ },
166
+
167
+ /**
168
+ * Whether this page has views.
169
+ *
170
+ * @return {boolean}
171
+ */
172
+ hasViews() {
173
+ return this.normalizedViews.length > 0
174
+ },
175
+
176
+ /**
177
+ * The chosen view's id: the address, then this session's or the
178
+ * stored choice, then `defaultView`, then the first view.
179
+ *
180
+ * @return {string|null} The id, or null without views.
181
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-chosen-view-is-linkable-and-remembered
182
+ */
183
+ activeViewId() {
184
+ const views = this.normalizedViews;
185
+ if (views.length === 0) {
186
+ return null
187
+ }
188
+ const known = (id) => id !== null && id !== undefined && views.some((view) => view.id === String(id));
189
+ const fromAddress = this.$route && this.$route.query ? this.$route.query[PAGE_VIEW_QUERY_KEY] : undefined;
190
+ if (known(fromAddress)) {
191
+ return String(fromAddress)
192
+ }
193
+ if (known(this.selectedViewId)) {
194
+ return String(this.selectedViewId)
195
+ }
196
+ if (known(this.defaultView)) {
197
+ return this.defaultView
198
+ }
199
+ return views[0].id
200
+ },
201
+
202
+ /**
203
+ * The chosen view, or null without views.
204
+ *
205
+ * @return {object|null}
206
+ */
207
+ activeView() {
208
+ return this.normalizedViews.find((view) => view.id === this.activeViewId) || null
209
+ },
210
+
211
+ /**
212
+ * The switch's options, for CnSegmentedControl.
213
+ *
214
+ * @return {Array<{value: string, label: string}>}
215
+ */
216
+ viewSwitchOptions() {
217
+ return this.normalizedViews.map((view) => ({ value: view.id, label: view.label }))
218
+ },
219
+
220
+ /**
221
+ * The switch's accessible name: `viewsLabel` translated, else "View".
222
+ *
223
+ * @return {string}
224
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-view-switch-is-accessible
225
+ */
226
+ viewSwitchLabel() {
227
+ const translate$1 = typeof this.cnTranslate === 'function' ? this.cnTranslate : (key) => key;
228
+ return this.viewsLabel ? translate$1(this.viewsLabel) : translate('nextcloud-vue', 'Views')
229
+ },
230
+
231
+ /**
232
+ * Whether a greeting header in the page's own widgets draws the
233
+ * switch (an option with a `view`), so the page draws none.
234
+ *
235
+ * @return {boolean}
236
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-greeting-header-can-switch-the-pages-views
237
+ */
238
+ viewSwitchInHeaderWidget() {
239
+ const widgets = Array.isArray(this.widgets) ? this.widgets : [];
240
+ return widgets.some((def) => {
241
+ const options = def && def.content && def.content.views && def.content.views.options;
242
+ return Array.isArray(options) && options.some((option) => option && typeof option.view === 'string' && option.view !== '')
243
+ })
244
+ },
245
+
246
+ /**
247
+ * Whether the page draws the switch itself.
248
+ *
249
+ * @return {boolean}
250
+ */
251
+ showsPageViewSwitch() {
252
+ return this.hasViews && !this.viewSwitchInHeaderWidget
253
+ },
254
+
255
+ /**
256
+ * The user-preference key for this page's chosen view.
257
+ *
258
+ * @return {string}
259
+ */
260
+ viewPreferenceKey() {
261
+ // The page id, else the route name: a detail page's fallback id is
262
+ // its record's title, which would remember a view per record.
263
+ const id = this.pageId || (this.$route && this.$route.name) || this.resolvedPageId || 'page';
264
+ return `${PAGE_VIEW_PREFERENCE_PREFIX}${id}`
265
+ },
266
+
267
+ /**
268
+ * The app id the stored view is kept under: the page's `appId` prop
269
+ * when it has one, else the app root's.
270
+ *
271
+ * @return {string}
272
+ */
273
+ viewPreferenceAppId() {
274
+ const own = typeof this.appId === 'string' ? this.appId : '';
275
+ return own || (typeof this.cnPageViewsAppId === 'string' ? this.cnPageViewsAppId : '')
276
+ },
277
+
278
+ /**
279
+ * The message of the chosen view's empty state.
280
+ *
281
+ * @return {string}
282
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-view-with-nothing-to-draw-says-so
283
+ */
284
+ viewEmptyText() {
285
+ if (this.activeView && this.activeView.emptyText) {
286
+ return this.activeView.emptyText
287
+ }
288
+ return translate('nextcloud-vue', 'This view has no widgets yet.')
289
+ },
290
+ },
291
+
292
+ created() {
293
+ this.loadStoredView();
294
+ },
295
+
296
+ methods: {
297
+ /**
298
+ * Read the stored view: the browser mirror at once, then the user
299
+ * preference on the server, which wins unless the user already chose.
300
+ *
301
+ * @return {Promise<void>}
302
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-chosen-view-is-linkable-and-remembered
303
+ */
304
+ async loadStoredView() {
305
+ if (!this.hasViews) {
306
+ return
307
+ }
308
+ const appId = this.viewPreferenceAppId;
309
+ const key = this.viewPreferenceKey;
310
+ this.selectedViewId = readStoredViewMirror(appId, key);
311
+ if (!appId) {
312
+ return
313
+ }
314
+ const stored = await readUserPreference(appId, key, null);
315
+ if (!this.viewChosen && typeof stored === 'string' && stored !== '') {
316
+ this.selectedViewId = stored;
317
+ }
318
+ },
319
+
320
+ /**
321
+ * Choose a view: keep it, store it, and write it to the address
322
+ * (replacing the history entry, keeping the other query keys).
323
+ *
324
+ * @param {string} id The view id.
325
+ * @return {void}
326
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-chosen-view-is-linkable-and-remembered
327
+ */
328
+ selectView(id) {
329
+ const view = this.normalizedViews.find((candidate) => candidate.id === String(id));
330
+ if (!view) {
331
+ return
332
+ }
333
+ this.selectedViewId = view.id;
334
+ this.viewChosen = true;
335
+ writeUserPreference(this.viewPreferenceAppId, this.viewPreferenceKey, view.id);
336
+ if (!this.$router || !this.$route) {
337
+ return
338
+ }
339
+ const query = this.$route.query || {};
340
+ if (query[PAGE_VIEW_QUERY_KEY] === view.id) {
341
+ return
342
+ }
343
+ const result = this.$router.replace({ query: { ...query, [PAGE_VIEW_QUERY_KEY]: view.id } });
344
+ if (result && typeof result.catch === 'function') {
345
+ result.catch(() => {});
346
+ }
347
+ },
348
+
349
+ /**
350
+ * Look a widget definition up in the views, for a layout item of the
351
+ * chosen view.
352
+ *
353
+ * @param {string} widgetId The widget id.
354
+ * @return {object|null} The definition, or null.
355
+ */
356
+ findViewWidget(widgetId) {
357
+ const view = this.activeView;
358
+ if (!view) {
359
+ return null
360
+ }
361
+ return view.widgets.find((def) => def && def.id === widgetId) || null
362
+ },
363
+
364
+ /**
365
+ * Write a drag or resize in the chosen view's grid back onto its
366
+ * layout items in place, as the page does for its own layout, so the
367
+ * in-app manifest editor sees it.
368
+ *
369
+ * @param {Array<object>} updated The layout from the grid.
370
+ * @return {void}
371
+ */
372
+ onViewLayoutChange(updated) {
373
+ const view = this.activeView;
374
+ if (!view || !Array.isArray(updated)) {
375
+ return
376
+ }
377
+ for (const u of updated) {
378
+ const item = view.layout.find((l) => String(l.id) === String(u.id));
379
+ if (!item) {
380
+ continue
381
+ }
382
+ for (const key of ['gridX', 'gridY', 'gridWidth', 'gridHeight']) {
383
+ if (u[key] !== undefined) {
384
+ item[key] = u[key];
385
+ }
386
+ }
387
+ }
388
+ /**
389
+ * Emitted when a widget in a view's grid is dragged or resized.
390
+ *
391
+ * @event view-layout-change
392
+ * @type {{view: string, layout: Array<object>}}
393
+ */
394
+ this.$emit('view-layout-change', { view: view.id, layout: view.layout });
395
+ },
396
+ },
397
+ };
398
+
399
+ export { PAGE_VIEW_PREFERENCE_PREFIX, PAGE_VIEW_QUERY_KEY, pageViews };
400
+ //# sourceMappingURL=pageViews.js.map