@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.
@@ -0,0 +1,98 @@
1
+ <!--
2
+ - SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ - SPDX-License-Identifier: EUPL-1.2
4
+ -->
5
+
6
+ <template>
7
+ <div class="ac-grid" :style="gridStyle">
8
+ <CnSiteCard
9
+ v-for="(card, index) in cards"
10
+ :key="card.id || card.title || index"
11
+ :title="card.title"
12
+ :description="card.description"
13
+ :link="card.link"
14
+ :link-label="card.linkLabel"
15
+ :icon="card.icon"
16
+ :heading-level="headingLevel"
17
+ :variant="card.variant || variant"
18
+ :padding="padding" />
19
+ <slot />
20
+ </div>
21
+ </template>
22
+
23
+ <script>
24
+ import CnSiteCard from './CnSiteCard.vue'
25
+
26
+ /**
27
+ * A responsive row of cards.
28
+ *
29
+ * Columns are expressed with `repeat(auto-fit, minmax(...))` rather than a
30
+ * fixed count, so the grid reflows on a narrow viewport without a media query
31
+ * per breakpoint. A fixed three-column grid is the usual cause of a card row
32
+ * that forces a phone to scroll horizontally.
33
+ *
34
+ * NOTE ON THE NAME: nc-vue already exports a `CnCardGrid`, and this is
35
+ * deliberately NOT that component. That one imports `@nextcloud/vue`,
36
+ * `@nextcloud/auth` and `@nextcloud/event-bus` (measured transitively), so it
37
+ * cannot run at a public origin. Re-exporting it under a "public" entry point
38
+ * would have made the entry point a lie.
39
+ *
40
+ * PUBLIC-SAFE (see ../index.js): no `@nextcloud/*` import.
41
+ */
42
+ export default {
43
+ name: 'CnSiteCardGrid',
44
+
45
+ components: { CnSiteCard },
46
+
47
+ props: {
48
+ /** The cards to render. */
49
+ cards: {
50
+ type: Array,
51
+ default: () => [],
52
+ },
53
+
54
+ /** Minimum column width before the grid reflows. */
55
+ minColumnWidth: {
56
+ type: String,
57
+ default: '18rem',
58
+ },
59
+
60
+ /** Gap between cards. */
61
+ gap: {
62
+ type: String,
63
+ default: '1.75rem',
64
+ },
65
+
66
+ /** Heading level for every card, so the outline stays intact. */
67
+ headingLevel: {
68
+ type: Number,
69
+ default: 3,
70
+ },
71
+
72
+ /** Default variant for cards that do not name one. */
73
+ variant: {
74
+ type: String,
75
+ default: 'category',
76
+ },
77
+
78
+ /** Internal padding step for every card. */
79
+ padding: {
80
+ type: String,
81
+ default: 'md',
82
+ },
83
+ },
84
+
85
+ computed: {
86
+ /**
87
+ * @return {object} Inline grid geometry.
88
+ */
89
+ gridStyle() {
90
+ return {
91
+ display: 'grid',
92
+ gridTemplateColumns: `repeat(auto-fit, minmax(${this.minColumnWidth}, 1fr))`,
93
+ gap: this.gap,
94
+ }
95
+ },
96
+ },
97
+ }
98
+ </script>
@@ -0,0 +1,172 @@
1
+ <!--
2
+ - SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ - SPDX-License-Identifier: EUPL-1.2
4
+ -->
5
+
6
+ <template>
7
+ <div
8
+ :class="['ac-empty-state', `ac-empty-state--${variant}`]"
9
+ :role="variant === 'error' ? 'alert' : undefined"
10
+ :aria-busy="variant === 'loading' ? 'true' : undefined"
11
+ :aria-live="variant === 'loading' ? 'polite' : undefined">
12
+ <CnSiteIcon v-if="icon" :name="icon" :size="32" />
13
+
14
+ <component :is="headingTag" v-if="title" :class="headingClass">
15
+ {{ title }}
16
+ </component>
17
+
18
+ <p v-if="description" class="utrecht-paragraph">
19
+ {{ description }}
20
+ </p>
21
+
22
+ <a v-if="link" class="utrecht-link utrecht-link--html-a" :href="link">
23
+ {{ linkLabel || link }}
24
+ </a>
25
+
26
+ <slot />
27
+ </div>
28
+ </template>
29
+
30
+ <script>
31
+ import CnSiteIcon from './CnSiteIcon.vue'
32
+
33
+ /**
34
+ * The state a page shows when there is nothing to show yet, nothing to show at
35
+ * all, or nothing showable because something failed.
36
+ *
37
+ * WHY THIS IS NOT `NcEmptyContent`
38
+ *
39
+ * Nextcloud's own empty-state component is the obvious reuse and it cannot be
40
+ * used here: it comes from `@nextcloud/vue`, which is exactly the runtime
41
+ * coupling this entry point exists to keep out. A portal served from a
42
+ * municipality's own domain has no `OC` global, no session and no translation
43
+ * bundle, and a component reaching for them fails in a browser rather than at
44
+ * build time. The transitive-import guard in this package's CI would reject the
45
+ * import outright.
46
+ *
47
+ * WHY THE THREE VARIANTS ARE ONE COMPONENT
48
+ *
49
+ * `loading`, `empty` and `error` differ in what they ANNOUNCE, not in what they
50
+ * look like, and getting the announcement right is the entire point of having a
51
+ * component rather than a paragraph:
52
+ *
53
+ * loading aria-busy + aria-live="polite" — a screen reader is told the
54
+ * region is working and will be told again when it settles, rather
55
+ * than being handed a silent blank area
56
+ * error role="alert" — announced immediately, because a visitor who
57
+ * cannot see the page must not sit waiting for content that will
58
+ * never arrive
59
+ * empty neither. "There is nothing here" is ordinary content, and
60
+ * announcing it as an alert cries wolf.
61
+ *
62
+ * Splitting them into three components would let a caller pick the wrong
63
+ * announcement for the right visual, which is precisely the mistake this
64
+ * prevents.
65
+ *
66
+ * IT REPLACES A BARE SENTENCE. The portal previously rendered `<p>Bezig met
67
+ * laden…</p>` — no landmark, no live region, no icon, unstyled, and identical
68
+ * in markup to an error. A sighted visitor saw a stray line under the header; a
69
+ * screen-reader user was told nothing at all.
70
+ *
71
+ * PUBLIC-SAFE (see ../index.js): no `@nextcloud/*` import. All copy arrives as
72
+ * props, because `t()` is the dependency that makes a component unusable
73
+ * outside Nextcloud.
74
+ */
75
+ export default {
76
+ name: 'CnSiteEmptyState',
77
+
78
+ components: { CnSiteIcon },
79
+
80
+ props: {
81
+ /**
82
+ * Which state this is. Decides the ARIA posture, not the styling.
83
+ */
84
+ variant: {
85
+ type: String,
86
+ default: 'empty',
87
+ validator: (v) => ['loading', 'empty', 'error'].includes(v),
88
+ },
89
+
90
+ /** Headline, e.g. "Bezig met laden…" or "Pagina niet gevonden". */
91
+ title: {
92
+ type: String,
93
+ default: '',
94
+ },
95
+
96
+ /** Supporting line under the headline. */
97
+ description: {
98
+ type: String,
99
+ default: '',
100
+ },
101
+
102
+ /** Icon name from the closed vocabulary; '' renders none. */
103
+ icon: {
104
+ type: String,
105
+ default: '',
106
+ },
107
+
108
+ /** Optional way out — a link back to somewhere that works. */
109
+ link: {
110
+ type: String,
111
+ default: '',
112
+ },
113
+
114
+ /** Visible text for that link. */
115
+ linkLabel: {
116
+ type: String,
117
+ default: '',
118
+ },
119
+
120
+ /**
121
+ * Heading level, so the page outline stays intact.
122
+ *
123
+ * An empty state usually replaces a page's main content, so it usually
124
+ * wants the level that content would have had — not a fixed one.
125
+ */
126
+ headingLevel: {
127
+ type: Number,
128
+ default: 2,
129
+ validator: (v) => v >= 1 && v <= 6,
130
+ },
131
+ },
132
+
133
+ computed: {
134
+ /**
135
+ * @return {string} The heading element to render.
136
+ */
137
+ headingTag() {
138
+ return `h${this.headingLevel}`
139
+ },
140
+
141
+ /**
142
+ * The heading's class, tracking its level — the design system styles
143
+ * the class, not the tag.
144
+ *
145
+ * @return {string} e.g. `utrecht-heading-2`.
146
+ */
147
+ headingClass() {
148
+ return `utrecht-heading-${this.headingLevel}`
149
+ },
150
+ },
151
+ }
152
+ </script>
153
+
154
+ <style scoped>
155
+ /*
156
+ * Layout only, and deliberately token-driven.
157
+ *
158
+ * No colours are set here: an empty state can land on any surface a host puts
159
+ * it on, and this codebase has three separate bugs on record from rules that
160
+ * coloured text without knowing its background. The design system colours the
161
+ * heading, paragraph and link classes already.
162
+ */
163
+ .ac-empty-state {
164
+ display: flex;
165
+ flex-direction: column;
166
+ align-items: center;
167
+ justify-content: center;
168
+ text-align: center;
169
+ gap: var(--utrecht-space-block-md, 0.75rem);
170
+ padding-block: var(--utrecht-space-block-2xl, 3rem);
171
+ }
172
+ </style>
@@ -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>