@conduction/nextcloud-vue 2.3.0-beta.1 → 2.3.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.3.0-beta.1",
3
+ "version": "2.3.0-beta.3",
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
  "license": "EUPL-1.2",
6
6
  "author": "Conduction B.V. <info@conduction.nl>",
@@ -70,7 +70,8 @@
70
70
  "styleguide:build": "cd styleguide && npm run build",
71
71
  "build:vue3": "rollup -c rollup.config.vue3.mjs",
72
72
  "check:vue3-compile": "node scripts/vue3-compile-sweep.cjs",
73
- "check:dist-sideeffects": "node scripts/check-dist-sideeffects.mjs"
73
+ "check:dist-sideeffects": "node scripts/check-dist-sideeffects.mjs",
74
+ "check:public-safe": "node scripts/check-public-safe.js"
74
75
  },
75
76
  "tsd": {
76
77
  "directory": "test-d",
@@ -234,5 +235,20 @@
234
235
  "vue": "^3.5.13",
235
236
  "vue-eslint-parser": "^9.4.3",
236
237
  "vue-material-design-icons": "^5.2.0"
238
+ },
239
+ "exports": {
240
+ ".": {
241
+ "import": "./dist/esm/index.js",
242
+ "require": "./dist/nextcloud-vue.cjs.js",
243
+ "default": "./dist/nextcloud-vue.cjs.js"
244
+ },
245
+ "./public": {
246
+ "import": "./src/public/index.js",
247
+ "default": "./src/public/index.js"
248
+ },
249
+ "./dist/*": "./dist/*",
250
+ "./src/*": "./src/*",
251
+ "./css/*": "./css/*",
252
+ "./package.json": "./package.json"
237
253
  }
238
254
  }
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
4
+ * SPDX-License-Identifier: EUPL-1.2
5
+ *
6
+ * Fail if the public site-block entry point can reach the Nextcloud runtime.
7
+ *
8
+ * `src/public/index.js` promises components that render at a public origin
9
+ * with no Nextcloud behind them. That promise is only worth something if it is
10
+ * CHECKED: the failure mode is a `@nextcloud/router` call reaching for an `OC`
11
+ * global that does not exist, in a browser, on a live government portal —
12
+ * never at build time, and never in a Nextcloud-hosted test.
13
+ *
14
+ * TRANSITIVE, NOT DIRECT, AND THE DIFFERENCE IS THE WHOLE POINT. Checking only
15
+ * each file's own imports reported 12 of 13 candidate widgets clean; following
16
+ * relative imports through the tree showed 12 of 13 UNSAFE, almost all via
17
+ * `@nextcloud/l10n`. A direct-only check would have certified the exact set of
18
+ * components that cannot run.
19
+ *
20
+ * Exits 1 with the offending chain, or 0. It also fails when it inspects
21
+ * NOTHING — "no violations found" and "no files were read" are the two
22
+ * outcomes a checker must never conflate.
23
+ */
24
+
25
+ const fs = require('fs')
26
+ const path = require('path')
27
+
28
+ const ROOT = path.resolve(__dirname, '..')
29
+ const ENTRY = path.join(ROOT, 'src/public/index.js')
30
+
31
+ /** Any Nextcloud runtime package. The portal has none of these at a public origin. */
32
+ const FORBIDDEN = /^@nextcloud\//
33
+
34
+ const visited = new Set()
35
+ const violations = []
36
+ let filesInspected = 0
37
+
38
+ /**
39
+ * Resolve a relative import to a file on disk.
40
+ *
41
+ * @param {string} spec The import specifier.
42
+ * @param {string} from The importing file.
43
+ * @return {string|null} Resolved path, or null when not a local file.
44
+ */
45
+ function resolveLocal(spec, from) {
46
+ if (!spec.startsWith('.')) return null
47
+ const base = path.resolve(path.dirname(from), spec)
48
+ const candidates = [
49
+ base,
50
+ `${base}.vue`,
51
+ `${base}.js`,
52
+ `${base}.ts`,
53
+ path.join(base, 'index.js'),
54
+ path.join(base, 'index.vue'),
55
+ ]
56
+ return candidates.find((c) => fs.existsSync(c) && fs.statSync(c).isFile()) || null
57
+ }
58
+
59
+ /**
60
+ * Walk a file's imports, depth first.
61
+ *
62
+ * @param {string} file Absolute path.
63
+ * @param {Array} chain How we got here, for the error message.
64
+ * @return {void}
65
+ */
66
+ function walk(file, chain) {
67
+ if (visited.has(file)) return
68
+ visited.add(file)
69
+ filesInspected++
70
+
71
+ const source = fs.readFileSync(file, 'utf8')
72
+ const specs = [...source.matchAll(/(?:from|import)\s*['"]([^'"]+)['"]/g)].map((m) => m[1])
73
+ const here = chain.concat(path.relative(ROOT, file))
74
+
75
+ for (const spec of specs) {
76
+ if (FORBIDDEN.test(spec)) {
77
+ violations.push({ spec, chain: here })
78
+ continue
79
+ }
80
+ const local = resolveLocal(spec, file)
81
+ if (local) walk(local, here)
82
+ }
83
+ }
84
+
85
+ if (!fs.existsSync(ENTRY)) {
86
+ console.error(`::error::public entry not found at ${path.relative(ROOT, ENTRY)} — nothing was checked.`)
87
+ process.exit(1)
88
+ }
89
+
90
+ walk(ENTRY, [])
91
+
92
+ // A run that inspected one file read the entry and no components: that is a
93
+ // broken resolver, not a clean bill of health.
94
+ if (filesInspected < 2) {
95
+ console.error(
96
+ `::error::check-public-safe inspected only ${filesInspected} file(s). `
97
+ + 'The entry point resolves no local imports, so this run proves nothing.',
98
+ )
99
+ process.exit(1)
100
+ }
101
+
102
+ if (violations.length > 0) {
103
+ console.error(`::error::${violations.length} Nextcloud runtime import(s) reachable from the public site-block entry:`)
104
+ for (const v of violations) {
105
+ console.error(` ${v.spec}`)
106
+ console.error(` via ${v.chain.join(' -> ')}`)
107
+ }
108
+ console.error('')
109
+ console.error('These blocks must render at a public origin with no Nextcloud.')
110
+ console.error('Take strings as props instead of calling t(), and build URLs from')
111
+ console.error('props instead of generateUrl().')
112
+ process.exit(1)
113
+ }
114
+
115
+ console.log(`[public-safe] OK — ${filesInspected} file(s) inspected, no @nextcloud/* reachable.`)
@@ -0,0 +1,307 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * The public site-block entry point: its vocabulary and its ONE promise.
6
+ *
7
+ * The promise is that these blocks render where there is no Nextcloud. The
8
+ * transitive-import guard (`scripts/check-public-safe.js`) enforces that at
9
+ * build time; these tests cover the parts a static import walk cannot see —
10
+ * that the registry is populated, that every advertised key resolves to a real
11
+ * component, and that an unknown key is reported rather than silently dropped.
12
+ */
13
+
14
+ import { mount } from '@vue/test-utils'
15
+ import {
16
+ CnSiteCard,
17
+ CnSiteCardGrid,
18
+ CnSiteEmptyState,
19
+ CnSiteGlossary,
20
+ CnSiteHero,
21
+ CnSiteSearch,
22
+ CnSiteSection,
23
+ listSiteBlocks,
24
+ siteBlockFor,
25
+ siteBlockRegistry,
26
+ } from '../index.js'
27
+
28
+ describe('public site blocks — vocabulary', () => {
29
+ it('advertises a non-empty vocabulary', () => {
30
+ // A registry that emptied out would make every `siteBlockFor()` return
31
+ // null, and a renderer would show a page of nothing while every test
32
+ // below still passed on its own terms.
33
+ expect(listSiteBlocks().length).toBeGreaterThan(0)
34
+ })
35
+
36
+ it('resolves every advertised key to a real component', () => {
37
+ for (const key of listSiteBlocks()) {
38
+ const block = siteBlockFor(key)
39
+ expect(block).toBeTruthy()
40
+ expect(typeof block).toBe('object')
41
+ expect(block.name).toMatch(/^CnSite/)
42
+ }
43
+ })
44
+
45
+ it('reports an unknown key as null rather than guessing', () => {
46
+ // Null is the signal a renderer needs in order to show "unknown block".
47
+ // Falling back to some default block would render the WRONG content and
48
+ // look deliberate.
49
+ expect(siteBlockFor('no-such-block')).toBeNull()
50
+ expect(siteBlockFor('')).toBeNull()
51
+ })
52
+
53
+ it('exports each component directly as well as through the registry', () => {
54
+ expect(siteBlockRegistry.hero).toBe(CnSiteHero)
55
+ expect(siteBlockRegistry.search).toBe(CnSiteSearch)
56
+ expect(siteBlockRegistry.section).toBe(CnSiteSection)
57
+ expect(siteBlockRegistry.cardGrid).toBe(CnSiteCardGrid)
58
+ expect(siteBlockRegistry.card).toBe(CnSiteCard)
59
+ expect(siteBlockRegistry.emptyState).toBe(CnSiteEmptyState)
60
+ expect(siteBlockRegistry.glossary).toBe(CnSiteGlossary)
61
+ })
62
+ })
63
+
64
+ describe('public site blocks — markup contract', () => {
65
+ it('the section emits a full-bleed band with a constrained container', () => {
66
+ const wrapper = mount(CnSiteSection, { props: { variant: 'spacing' } })
67
+ expect(wrapper.find('section.ac-section.ac-section--spacing').exists()).toBe(true)
68
+ // The container is what holds the reading column. A band without one
69
+ // renders body copy against the viewport edge.
70
+ expect(wrapper.find('section > .container').exists()).toBe(true)
71
+ })
72
+
73
+ it('the hero band is a hero, not a spacing section', () => {
74
+ const wrapper = mount(CnSiteHero, { props: { title: 'Waar bent u naar op zoek?' } })
75
+ expect(wrapper.find('section.ac-section.ac-hero').exists()).toBe(true)
76
+ expect(wrapper.find('.ac-section--spacing').exists()).toBe(false)
77
+ expect(wrapper.text()).toContain('Waar bent u naar op zoek?')
78
+ })
79
+
80
+ it('the hero renders NO search box unless asked', () => {
81
+ // An inert search field invites the one interaction it cannot honour,
82
+ // so it is opt-in rather than default.
83
+ const off = mount(CnSiteHero, { props: { title: 'x' } })
84
+ expect(off.find('form').exists()).toBe(false)
85
+
86
+ const on = mount(CnSiteHero, { props: { title: 'x', search: true } })
87
+ expect(on.find('form.ac-search-box').exists()).toBe(true)
88
+ })
89
+
90
+ it('keeps the heading in the DOM but unpainted when the search carries the prompt', () => {
91
+ // The reference implementation's hero has NO heading element at all, so
92
+ // its page has no h1 — an outline defect not worth copying. The heading
93
+ // stays for structure; it is not PAINTED, because the band defines no
94
+ // colour for text on it and a visible duplicate of the search label
95
+ // would say the same thing twice.
96
+ const wrapper = mount(CnSiteHero, {
97
+ props: { title: 'Waar bent u naar op zoek?', headingLevel: 1, search: true },
98
+ })
99
+
100
+ const heading = wrapper.find('h1')
101
+ expect(heading.exists()).toBe(true)
102
+ expect(heading.classes()).toContain('sr-only')
103
+
104
+ // And the same words are the visible search label.
105
+ const label = wrapper.find('.ac-search-box__label')
106
+ expect(label.classes()).not.toContain('sr-only')
107
+ expect(label.text()).toBe('Waar bent u naar op zoek?')
108
+ })
109
+
110
+ it('paints the heading when there is no search box to carry the prompt', () => {
111
+ const wrapper = mount(CnSiteHero, { props: { title: 'Onderwerpen' } })
112
+ const heading = wrapper.find('h1')
113
+ expect(heading.exists()).toBe(true)
114
+ expect(heading.classes()).not.toContain('sr-only')
115
+ })
116
+
117
+ it('lets a host override whether the heading is painted', () => {
118
+ const forced = mount(CnSiteHero, {
119
+ props: { title: 'x', search: true, headingVisible: true },
120
+ })
121
+ expect(forced.find('h1').classes()).not.toContain('sr-only')
122
+ })
123
+
124
+ it('the search box is a real form with a named input', () => {
125
+ const wrapper = mount(CnSiteSearch, {
126
+ props: { label: 'Zoeken', inputId: 'q1' },
127
+ })
128
+ const form = wrapper.find('form')
129
+ expect(form.attributes('role')).toBe('search')
130
+ // A label, not a placeholder: a placeholder vanishes on input and is
131
+ // not reliably announced, leaving an unnamed field.
132
+ const label = wrapper.find('label')
133
+ expect(label.attributes('for')).toBe('q1')
134
+ expect(wrapper.find('input#q1').exists()).toBe(true)
135
+ expect(wrapper.find('button[type="submit"]').exists()).toBe(true)
136
+ })
137
+
138
+ it('hides the label by CLIPPING, never by removing it from the DOM', () => {
139
+ // `display: none` would hide it from assistive tech too, which is the
140
+ // entire reason the label exists.
141
+ const wrapper = mount(CnSiteSearch, { props: { labelVisible: false } })
142
+ const label = wrapper.find('label')
143
+ expect(label.exists()).toBe(true)
144
+ expect(label.classes()).toContain('sr-only')
145
+ })
146
+
147
+ it('the search box emits the term instead of fetching anything', async () => {
148
+ const wrapper = mount(CnSiteSearch)
149
+ await wrapper.find('input').setValue('zaaksysteem')
150
+ await wrapper.find('form').trigger('submit')
151
+ expect(wrapper.emitted('search')).toBeTruthy()
152
+ expect(wrapper.emitted('search')[0]).toEqual(['zaaksysteem'])
153
+ })
154
+
155
+ it('a card takes its heading level from the host', () => {
156
+ // Hard-coding <h3> produces a document outline that skips levels
157
+ // wherever the card is placed under a different heading.
158
+ const wrapper = mount(CnSiteCard, {
159
+ props: { title: 'Voor 342 gemeenten', headingLevel: 2 },
160
+ })
161
+ // The CLASS tracks the level too — the design system styles
162
+ // `.utrecht-heading-2`, not `h2`, so a host changing the level to keep
163
+ // a page outline intact must not silently lose the styling with it.
164
+ expect(wrapper.find('h2.utrecht-heading-2').exists()).toBe(true)
165
+ expect(wrapper.find('h3').exists()).toBe(false)
166
+ })
167
+
168
+ it('a card link carries the card text, never bare "lees meer"', () => {
169
+ const wrapper = mount(CnSiteCard, {
170
+ props: { title: 'Voor 336 leveranciers', link: '/leveranciers' },
171
+ })
172
+ // `utrecht-link` is what carries the colour; without it the anchor
173
+ // falls back to the browser default rgb(0, 0, 238).
174
+ const link = wrapper.find('a.utrecht-link')
175
+ expect(link.attributes('href')).toBe('/leveranciers')
176
+ // Defaults to the title so a link list read out of context still names
177
+ // its destination.
178
+ expect(link.text()).toBe('Voor 336 leveranciers')
179
+ })
180
+
181
+ it('the empty state announces each variant differently', () => {
182
+ // The three variants differ in what they ANNOUNCE, not in how they look,
183
+ // and that is the whole reason this is a component instead of a
184
+ // paragraph. Asserted per variant because picking the wrong
185
+ // announcement for the right visual is the mistake it prevents.
186
+
187
+ // loading: the region is working, and will say so again when it settles
188
+ const loading = mount(CnSiteEmptyState, {
189
+ props: { variant: 'loading', title: 'Bezig met laden…' },
190
+ })
191
+ expect(loading.attributes('aria-busy')).toBe('true')
192
+ expect(loading.attributes('aria-live')).toBe('polite')
193
+ expect(loading.attributes('role')).toBeUndefined()
194
+
195
+ // error: announced immediately — a visitor who cannot see the page must
196
+ // not wait for content that will never arrive
197
+ const error = mount(CnSiteEmptyState, {
198
+ props: { variant: 'error', title: 'Er ging iets mis' },
199
+ })
200
+ expect(error.attributes('role')).toBe('alert')
201
+ expect(error.attributes('aria-busy')).toBeUndefined()
202
+
203
+ // empty: ordinary content. Announcing "there is nothing here" as an
204
+ // alert cries wolf.
205
+ const empty = mount(CnSiteEmptyState, { props: { title: 'Niets gevonden' } })
206
+ expect(empty.attributes('role')).toBeUndefined()
207
+ expect(empty.attributes('aria-busy')).toBeUndefined()
208
+ expect(empty.attributes('aria-live')).toBeUndefined()
209
+ })
210
+
211
+ it('the empty state heading class tracks its level', () => {
212
+ // The design system styles `.utrecht-heading-3`, not `h3`; a bare tag
213
+ // renders unstyled, which is how a heading silently loses its type.
214
+ const wrapper = mount(CnSiteEmptyState, {
215
+ props: { title: 'Niets gevonden', headingLevel: 3 },
216
+ })
217
+ expect(wrapper.find('h3.utrecht-heading-3').exists()).toBe(true)
218
+ })
219
+
220
+ it('the glossary is a description list, not a stack of divs', () => {
221
+ // `<dl>`/`<dt>`/`<dd>` is what makes a screen reader announce "term,
222
+ // definition" pairs instead of an undifferentiated run of text. The
223
+ // two render identically, which is why this is easy to get wrong and
224
+ // impossible to notice by looking.
225
+ const wrapper = mount(CnSiteGlossary, {
226
+ props: {
227
+ terms: [
228
+ { term: 'Publicatie', definition: 'Een document dat de gemeente openbaar maakt.' },
229
+ { term: 'Woo-verzoek', definition: 'Een verzoek om openbaarmaking.' },
230
+ ],
231
+ },
232
+ })
233
+
234
+ expect(wrapper.find('dl').exists()).toBe(true)
235
+ expect(wrapper.findAll('dt')).toHaveLength(2)
236
+ expect(wrapper.findAll('dd')).toHaveLength(2)
237
+ expect(wrapper.text()).toContain('Publicatie')
238
+ expect(wrapper.text()).toContain('Een verzoek om openbaarmaking.')
239
+ })
240
+
241
+ it('renders synonyms, because the old name is often the only one a visitor has', () => {
242
+ // Someone searching for "Wob-verzoek" finds nothing if only the current
243
+ // term is rendered, and concludes the concept is gone rather than
244
+ // renamed.
245
+ const wrapper = mount(CnSiteGlossary, {
246
+ props: {
247
+ synonymsLabel: 'Ook bekend als:',
248
+ terms: [
249
+ {
250
+ term: 'Woo-verzoek',
251
+ definition: 'Een verzoek om openbaarmaking.',
252
+ synonyms: ['Wob-verzoek'],
253
+ },
254
+ ],
255
+ },
256
+ })
257
+
258
+ expect(wrapper.text()).toContain('Wob-verzoek')
259
+ expect(wrapper.text()).toContain('Ook bekend als:')
260
+ })
261
+
262
+ it('treats a bare string synonym as ONE synonym, not one per character', () => {
263
+ // `synonyms` arrives as a string or an array depending on the store
264
+ // that produced it. Spreading the string renders `W, o, b, …` — which
265
+ // is a real list, correctly styled, and complete nonsense.
266
+ const wrapper = mount(CnSiteGlossary, {
267
+ props: { terms: [{ term: 'Woo-verzoek', definition: 'x', synonyms: 'Wob-verzoek' }] },
268
+ })
269
+
270
+ expect(wrapper.text()).toContain('Wob-verzoek')
271
+ expect(wrapper.text()).not.toContain('W, o, b')
272
+ })
273
+
274
+ it('says something when there are no terms', () => {
275
+ // A bare heading over nothing reads as a page that failed to load.
276
+ const wrapper = mount(CnSiteGlossary, {
277
+ props: { title: 'Begrippenlijst', emptyLabel: 'Nog geen begrippen.' },
278
+ })
279
+
280
+ expect(wrapper.find('dl').exists()).toBe(false)
281
+ expect(wrapper.text()).toContain('Nog geen begrippen.')
282
+ })
283
+
284
+ it('the glossary heading class tracks its level', () => {
285
+ const wrapper = mount(CnSiteGlossary, {
286
+ props: { title: 'Begrippenlijst', headingLevel: 3, terms: [] },
287
+ })
288
+
289
+ expect(wrapper.find('h3.utrecht-heading-3').exists()).toBe(true)
290
+ })
291
+
292
+ it('the card grid renders one card per entry and reflows by width', () => {
293
+ const wrapper = mount(CnSiteCardGrid, {
294
+ props: {
295
+ cards: [
296
+ { title: 'Voor 342 gemeenten' },
297
+ { title: 'Voor 336 leveranciers' },
298
+ { title: "Voor 15 community's" },
299
+ ],
300
+ },
301
+ })
302
+ expect(wrapper.findAllComponents(CnSiteCard)).toHaveLength(3)
303
+ // auto-fit, not a fixed column count: a fixed three-column grid is the
304
+ // usual reason a card row forces a phone to scroll sideways.
305
+ expect(wrapper.find('.ac-grid').attributes('style')).toContain('auto-fit')
306
+ })
307
+ })
@@ -0,0 +1,178 @@
1
+ <!--
2
+ - SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ - SPDX-License-Identifier: EUPL-1.2
4
+ -->
5
+
6
+ <template>
7
+ <article :class="cardClass">
8
+ <div class="ac-card__content">
9
+ <!--
10
+ Structure captured from the running reference, not invented:
11
+
12
+ .ac-card__content
13
+ .ac-flex.ac-flex--spacing-sm.ac-flex--align-items-center
14
+ svg
15
+ h3.utrecht-heading-3
16
+ p.utrecht-paragraph
17
+ a.utrecht-link.utrecht-link--html-a
18
+ svg
19
+
20
+ THE ICON IS A SIBLING OF THE HEADING, not a child, and the flex
21
+ row is its own wrapper. Nesting the icon inside the heading
22
+ forces the heading to `display: flex`, and measured that way it
23
+ came out Roboto 18.72px rgb(51, 51, 51) against the design's
24
+ Avenir 24px rgb(0, 0, 0).
25
+
26
+ `.ac-card__content` also styles itself — adding `ac-flex--spacing-sm`
27
+ here overrode its own 12px gap with 16px.
28
+ -->
29
+ <div
30
+ v-if="title"
31
+ class="ac-flex ac-flex--spacing-sm ac-flex--align-items-center">
32
+ <CnSiteIcon v-if="icon" :name="icon" />
33
+ <component :is="headingTag" :class="headingClass">
34
+ {{ title }}
35
+ </component>
36
+ </div>
37
+
38
+ <p v-if="description" class="utrecht-paragraph">
39
+ {{ description }}
40
+ </p>
41
+
42
+ <slot />
43
+
44
+ <!--
45
+ `utrecht-link` is what carries the link's colour. Without it the
46
+ anchor falls back to the BROWSER DEFAULT — measured rgb(0, 0, 238)
47
+ against the design's rgb(0, 68, 136), the one colour on the page
48
+ nobody chose.
49
+
50
+ The text is the card's own, never "lees meer": a screen reader
51
+ listing a page's links reads them out of context, and four
52
+ identical "lees meer" entries name nothing.
53
+ -->
54
+ <a v-if="link" class="utrecht-link utrecht-link--html-a" :href="link">
55
+ {{ linkLabel || title }}
56
+ <CnSiteIcon v-if="linkIcon" :name="linkIcon" :size="16" />
57
+ </a>
58
+ </div>
59
+ </article>
60
+ </template>
61
+
62
+ <script>
63
+ import CnSiteIcon from './CnSiteIcon.vue'
64
+
65
+ /**
66
+ * One card in a card grid.
67
+ *
68
+ * The heading level is a PROP rather than a fixed `<h3>`. A card is placed by
69
+ * its host, and the correct level depends on what sits above it; hard-coding
70
+ * one produces a document whose outline skips levels, which is the most common
71
+ * way a visually tidy page fails an outline check.
72
+ *
73
+ * PUBLIC-SAFE (see ../index.js): no `@nextcloud/*` import.
74
+ */
75
+ export default {
76
+ name: 'CnSiteCard',
77
+
78
+ components: { CnSiteIcon },
79
+
80
+ props: {
81
+ /**
82
+ * Icon shown beside the title, from the closed vocabulary.
83
+ *
84
+ * A NAME, never path data: page content is authored input, and raw SVG
85
+ * from an author would be attacker-controlled markup inside an <svg> on
86
+ * a public government page. An unknown name renders nothing.
87
+ */
88
+ icon: {
89
+ type: String,
90
+ default: '',
91
+ },
92
+
93
+ /** Icon trailing the link. The reference uses a right arrow. */
94
+ linkIcon: {
95
+ type: String,
96
+ default: 'arrow-right',
97
+ },
98
+
99
+ /** Card heading. */
100
+ title: {
101
+ type: String,
102
+ default: '',
103
+ },
104
+
105
+ /** Supporting line under the heading. */
106
+ description: {
107
+ type: String,
108
+ default: '',
109
+ },
110
+
111
+ /** Where the card points, if anywhere. */
112
+ link: {
113
+ type: String,
114
+ default: '',
115
+ },
116
+
117
+ /** Visible link text. Defaults to the title, never "read more". */
118
+ linkLabel: {
119
+ type: String,
120
+ default: '',
121
+ },
122
+
123
+ /** Heading level, chosen by the host to keep the outline intact. */
124
+ headingLevel: {
125
+ type: Number,
126
+ default: 3,
127
+ validator: (v) => v >= 2 && v <= 6,
128
+ },
129
+
130
+ /** Design-system variant. */
131
+ variant: {
132
+ type: String,
133
+ default: 'category',
134
+ validator: (v) => ['category', 'blue', 'plain'].includes(v),
135
+ },
136
+
137
+ /** Internal padding step. */
138
+ padding: {
139
+ type: String,
140
+ default: 'md',
141
+ validator: (v) => ['md', 'lg'].includes(v),
142
+ },
143
+ },
144
+
145
+ computed: {
146
+ /**
147
+ * @return {string} The heading element to render.
148
+ */
149
+ headingTag() {
150
+ return `h${this.headingLevel}`
151
+ },
152
+
153
+ /**
154
+ * The heading's class, tracking its level.
155
+ *
156
+ * The design system styles the CLASS, not the tag — the same reason a
157
+ * bare `<h2>` out of markdown renders unstyled.
158
+ *
159
+ * @return {string} e.g. `utrecht-heading-3`.
160
+ */
161
+ headingClass() {
162
+ return `utrecht-heading-${this.headingLevel}`
163
+ },
164
+
165
+ /**
166
+ * @return {Array} The card's classes.
167
+ */
168
+ cardClass() {
169
+ return [
170
+ 'ac-card',
171
+ this.variant !== 'plain' ? `ac-card--${this.variant}` : null,
172
+ `ac-card--padding-${this.padding}`,
173
+ 'ac-card--space-between',
174
+ ].filter(Boolean)
175
+ },
176
+ },
177
+ }
178
+ </script>