@conduction/nextcloud-vue 2.3.0-beta.2 → 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.2",
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>",
@@ -16,6 +16,7 @@ import {
16
16
  CnSiteCard,
17
17
  CnSiteCardGrid,
18
18
  CnSiteEmptyState,
19
+ CnSiteGlossary,
19
20
  CnSiteHero,
20
21
  CnSiteSearch,
21
22
  CnSiteSection,
@@ -56,6 +57,7 @@ describe('public site blocks — vocabulary', () => {
56
57
  expect(siteBlockRegistry.cardGrid).toBe(CnSiteCardGrid)
57
58
  expect(siteBlockRegistry.card).toBe(CnSiteCard)
58
59
  expect(siteBlockRegistry.emptyState).toBe(CnSiteEmptyState)
60
+ expect(siteBlockRegistry.glossary).toBe(CnSiteGlossary)
59
61
  })
60
62
  })
61
63
 
@@ -215,6 +217,78 @@ describe('public site blocks — markup contract', () => {
215
217
  expect(wrapper.find('h3.utrecht-heading-3').exists()).toBe(true)
216
218
  })
217
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
+
218
292
  it('the card grid renders one card per entry and reflows by width', () => {
219
293
  const wrapper = mount(CnSiteCardGrid, {
220
294
  props: {
@@ -0,0 +1,240 @@
1
+ <!--
2
+ - SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ - SPDX-License-Identifier: EUPL-1.2
4
+ -->
5
+
6
+ <template>
7
+ <div class="ac-glossary">
8
+ <component :is="headingTag" v-if="title" :class="headingClass">
9
+ {{ title }}
10
+ </component>
11
+
12
+ <p v-if="description" class="utrecht-paragraph">
13
+ {{ description }}
14
+ </p>
15
+
16
+ <!--
17
+ A DESCRIPTION LIST, not a stack of divs.
18
+
19
+ `<dl>`/`<dt>`/`<dd>` is what a glossary IS, and it is the difference
20
+ between a screen reader announcing "term, definition" pairs and
21
+ announcing an undifferentiated run of text. The visual result is
22
+ identical either way, which is why this is easy to get wrong.
23
+ -->
24
+ <dl v-if="terms.length" class="ac-glossary__list">
25
+ <div
26
+ v-for="(entry, index) in terms"
27
+ :key="entry.term || index"
28
+ class="ac-glossary__entry">
29
+ <dt class="ac-glossary__term utrecht-data-list__item-key">
30
+ {{ entry.term }}
31
+ </dt>
32
+ <dd class="ac-glossary__definition">
33
+ <p class="utrecht-paragraph">
34
+ {{ entry.definition }}
35
+ </p>
36
+
37
+ <!--
38
+ SYNONYMS ARE PART OF THE DEFINITION, not a footnote.
39
+
40
+ A visitor searching for the word they know ("Wob-verzoek")
41
+ finds nothing if only the current term is rendered, and
42
+ concludes the concept is absent rather than renamed. The
43
+ old name is often the only one they have.
44
+ -->
45
+ <p
46
+ v-if="synonymsOf(entry).length"
47
+ class="utrecht-paragraph ac-glossary__synonyms">
48
+ {{ synonymsLabel }} {{ synonymsOf(entry).join(', ') }}
49
+ </p>
50
+
51
+ <p v-if="entry.source" class="utrecht-paragraph ac-glossary__source">
52
+ {{ sourceLabel }} {{ entry.source }}
53
+ </p>
54
+ </dd>
55
+ </div>
56
+ </dl>
57
+
58
+ <p v-else class="utrecht-paragraph ac-glossary__empty">
59
+ {{ emptyLabel }}
60
+ </p>
61
+ </div>
62
+ </template>
63
+
64
+ <script>
65
+ /**
66
+ * A glossary: the terms a portal uses, with what each one means.
67
+ *
68
+ * WHY THIS IS A BLOCK AND NOT A PAGE
69
+ *
70
+ * The consuming portal used to render its glossary from a hard-coded
71
+ * `<section>` carrying a literal `<h2>Begrippenlijst</h2>`, on every page that
72
+ * satisfied a route check. A municipality could not move it, rename it,
73
+ * translate it, reorder it or leave it out, and it could only ever live at one
74
+ * URL. As a block it is content an author places — which is what it always
75
+ * was.
76
+ *
77
+ * WHY THE TERMS ARE A PROP
78
+ *
79
+ * Every string and every row arrives from the host. This entry point exists for
80
+ * pages served from a municipality's own domain, where there is no `OC` global,
81
+ * no session and no translation bundle; a component that fetched its own data
82
+ * or called `t()` would fail in a browser, on a live page, rather than at build
83
+ * time. The host already holds these rows — it fetched them over its own public
84
+ * contract — so passing them down costs nothing and keeps this component
85
+ * portable.
86
+ *
87
+ * THE LABELS ARE PROPS FOR THE SAME REASON. "Synoniemen" is not a word this
88
+ * library is entitled to choose on behalf of a Dutch government portal, and
89
+ * hard-coding an English default would put the wrong language on the page for
90
+ * every consumer this entry point was built for.
91
+ *
92
+ * PUBLIC-SAFE (see ../index.js): no `@nextcloud/*` import.
93
+ */
94
+ export default {
95
+ name: 'CnSiteGlossary',
96
+
97
+ props: {
98
+ /**
99
+ * The glossary rows.
100
+ *
101
+ * Each entry is `{ term, definition, synonyms?, source? }`. `synonyms`
102
+ * tolerates a string or an array, because a single synonym arrives as
103
+ * either depending on the store that produced it, and a component that
104
+ * rendered `W,o,b` one character per row would be technically correct.
105
+ */
106
+ terms: {
107
+ type: Array,
108
+ default: () => [],
109
+ },
110
+
111
+ /** Heading above the list; '' renders none. */
112
+ title: {
113
+ type: String,
114
+ default: '',
115
+ },
116
+
117
+ /** Supporting line under the heading. */
118
+ description: {
119
+ type: String,
120
+ default: '',
121
+ },
122
+
123
+ /**
124
+ * Heading level, so the page outline stays intact.
125
+ *
126
+ * The design system styles `.utrecht-heading-2`, not `h2`, so the class
127
+ * tracks the level too — a host changing the level to keep an outline
128
+ * intact must not silently lose the styling with it.
129
+ */
130
+ headingLevel: {
131
+ type: Number,
132
+ default: 2,
133
+ validator: (v) => v >= 1 && v <= 6,
134
+ },
135
+
136
+ /** Prefix for the synonyms line, e.g. 'Ook bekend als:'. */
137
+ synonymsLabel: {
138
+ type: String,
139
+ default: '',
140
+ },
141
+
142
+ /** Prefix for the source line, e.g. 'Bron:'. */
143
+ sourceLabel: {
144
+ type: String,
145
+ default: '',
146
+ },
147
+
148
+ /**
149
+ * What to say when there are no terms.
150
+ *
151
+ * An empty glossary renders this sentence rather than a bare heading
152
+ * over nothing, which reads as a page that failed to load.
153
+ */
154
+ emptyLabel: {
155
+ type: String,
156
+ default: '',
157
+ },
158
+ },
159
+
160
+ computed: {
161
+ /**
162
+ * @return {string} The heading element to render.
163
+ */
164
+ headingTag() {
165
+ return `h${this.headingLevel}`
166
+ },
167
+
168
+ /**
169
+ * @return {string} The heading's class, tracking its level.
170
+ */
171
+ headingClass() {
172
+ return `utrecht-heading-${this.headingLevel}`
173
+ },
174
+ },
175
+
176
+ methods: {
177
+ /**
178
+ * A term's synonyms, as a list, whatever shape they arrived in.
179
+ *
180
+ * @param {object} entry One glossary row.
181
+ * @return {string[]} The synonyms, empty when there are none.
182
+ */
183
+ synonymsOf(entry) {
184
+ const raw = entry && entry.synonyms
185
+ if (!raw) {
186
+ return []
187
+ }
188
+
189
+ // A bare string is ONE synonym. Spreading it would render each
190
+ // character as its own entry.
191
+ if (typeof raw === 'string') {
192
+ return raw.trim() ? [raw.trim()] : []
193
+ }
194
+
195
+ return Array.isArray(raw) ? raw.filter(Boolean) : []
196
+ },
197
+ },
198
+ }
199
+ </script>
200
+
201
+ <style scoped>
202
+ /*
203
+ * Layout and rhythm only — no colours.
204
+ *
205
+ * A block lands on whatever surface a host puts it on, and this codebase has
206
+ * three separate defects on record from rules that coloured text without
207
+ * reference to its background. The design system colours
208
+ * `.utrecht-heading-*` and `.utrecht-paragraph` already.
209
+ */
210
+ .ac-glossary__list {
211
+ margin-block-start: var(--utrecht-space-block-lg, 1.5rem);
212
+ }
213
+
214
+ .ac-glossary__entry + .ac-glossary__entry {
215
+ margin-block-start: var(--utrecht-space-block-lg, 1.5rem);
216
+ padding-block-start: var(--utrecht-space-block-lg, 1.5rem);
217
+ /*
218
+ * `currentColor` at low alpha rather than a colour token: the rule has to
219
+ * be visible on a light band and on a dark one, and it cannot know which
220
+ * it is on.
221
+ */
222
+ border-block-start: 1px solid color-mix(in srgb, currentColor 15%, transparent);
223
+ }
224
+
225
+ .ac-glossary__term {
226
+ font-weight: 700;
227
+ }
228
+
229
+ .ac-glossary__definition {
230
+ /* `<dd>` carries a UA indent that misaligns the definition with its term. */
231
+ margin-inline-start: 0;
232
+ margin-block-start: var(--utrecht-space-block-sm, 0.5rem);
233
+ }
234
+
235
+ .ac-glossary__synonyms,
236
+ .ac-glossary__source {
237
+ margin-block-start: var(--utrecht-space-block-sm, 0.5rem);
238
+ font-size: 0.875em;
239
+ }
240
+ </style>
@@ -43,6 +43,7 @@
43
43
  import CnSiteCard from './components/CnSiteCard.vue'
44
44
  import CnSiteCardGrid from './components/CnSiteCardGrid.vue'
45
45
  import CnSiteEmptyState from './components/CnSiteEmptyState.vue'
46
+ import CnSiteGlossary from './components/CnSiteGlossary.vue'
46
47
  import CnSiteHero from './components/CnSiteHero.vue'
47
48
  import CnSiteIcon from './components/CnSiteIcon.vue'
48
49
  import CnSiteSearch from './components/CnSiteSearch.vue'
@@ -52,6 +53,7 @@ export {
52
53
  CnSiteCard,
53
54
  CnSiteCardGrid,
54
55
  CnSiteEmptyState,
56
+ CnSiteGlossary,
55
57
  CnSiteHero,
56
58
  CnSiteIcon,
57
59
  CnSiteSearch,
@@ -79,6 +81,7 @@ export const siteBlockRegistry = {
79
81
  cardGrid: CnSiteCardGrid,
80
82
  card: CnSiteCard,
81
83
  emptyState: CnSiteEmptyState,
84
+ glossary: CnSiteGlossary,
82
85
  }
83
86
 
84
87
  /**