@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,239 @@
1
+ <!--
2
+ - SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ - SPDX-License-Identifier: EUPL-1.2
4
+ -->
5
+
6
+ <template>
7
+ <CnSiteSection variant="hero" :background-image="backgroundImage">
8
+ <!--
9
+ THE HEADING IS REAL BUT NOT NECESSARILY PAINTED, and that is a
10
+ deliberate improvement on the reference rather than a copy of it.
11
+
12
+ Measured on the NL Design System reference home page: the hero
13
+ contains NO heading element at all (its prompt lives inside the
14
+ search form) and the page therefore has no `h1` anywhere. Copying
15
+ that would inherit an outline defect.
16
+
17
+ Painting a heading directly on the band is the other trap. The band
18
+ is dark — `rgb(0, 56, 101)` on the reference — while the hero's own
19
+ computed colour is black, because the design system never puts bare
20
+ text there and so has no rule for it. A heading dropped onto that
21
+ band renders dark-on-dark: present, selectable, and unreadable.
22
+ (Exactly the failure that made this portal's footer links
23
+ `rgb(0, 68, 136)` on `rgb(0, 69, 137)`.)
24
+
25
+ So when the search box is shown, the heading stays in the DOM for
26
+ the outline and the VISIBLE prompt is the search label, which the
27
+ design system does style for this band. A host that wants a painted
28
+ heading asks for it with `heading-visible`.
29
+ -->
30
+ <component
31
+ :is="headingTag"
32
+ v-if="title"
33
+ :class="headingClass">
34
+ {{ title }}
35
+ </component>
36
+
37
+ <p v-if="subtitle" :class="subtitleClass">
38
+ {{ subtitle }}
39
+ </p>
40
+
41
+ <!--
42
+ THE CARD IS WHAT SIZES THE SEARCH BOX, and it is not decoration.
43
+ Captured from the reference:
44
+
45
+ .ac-hero > .container > .ac-card.ac-card--blue.ac-card--padding-lg
46
+ > .ac-card__content > form.ac-search-box
47
+
48
+ `nlds-app.css` says `.ac-hero .ac-card { inline-size: min(100%, 744px) }`,
49
+ and the card's padding takes the form from 744 down to 648. Rendered
50
+ without it the form simply filled the content column — measured 1168
51
+ against the design's 648 — because nothing else in the cascade
52
+ constrains it.
53
+ -->
54
+ <div v-if="search" class="ac-card ac-card--blue ac-card--padding-lg">
55
+ <div class="ac-card__content">
56
+ <CnSiteSearch
57
+ :label="effectiveSearchLabel"
58
+ :label-visible="true"
59
+ :placeholder="searchPlaceholder"
60
+ :submit-label="searchSubmitLabel"
61
+ :value="searchValue"
62
+ :input-id="searchInputId"
63
+ @search="$emit('search', $event)" />
64
+ </div>
65
+ </div>
66
+
67
+ <slot />
68
+ </CnSiteSection>
69
+ </template>
70
+
71
+ <script>
72
+ import CnSiteSearch from './CnSiteSearch.vue'
73
+ import CnSiteSection from './CnSiteSection.vue'
74
+
75
+ /**
76
+ * The leading band of a site page.
77
+ *
78
+ * Composes the section band and, optionally, the search box — the shape the NL
79
+ * Design System reference uses on its home page, where the hero IS the search
80
+ * entry point rather than decoration above one.
81
+ *
82
+ * The search is opt-in (`search`), because a hero that always renders a search
83
+ * field would force every portal to either wire up a query back end or ship an
84
+ * inert control. An inert search box is worse than none: it invites the one
85
+ * interaction it cannot honour.
86
+ *
87
+ * PUBLIC-SAFE (see ../index.js): no `@nextcloud/*` import.
88
+ */
89
+ export default {
90
+ name: 'CnSiteHero',
91
+
92
+ components: { CnSiteSearch, CnSiteSection },
93
+
94
+ props: {
95
+ /** The hero heading. */
96
+ title: {
97
+ type: String,
98
+ default: '',
99
+ },
100
+
101
+ /** Supporting line under the heading. */
102
+ subtitle: {
103
+ type: String,
104
+ default: '',
105
+ },
106
+
107
+ /** Heading level, so the page outline stays intact. */
108
+ headingLevel: {
109
+ type: Number,
110
+ default: 1,
111
+ validator: (v) => v >= 1 && v <= 6,
112
+ },
113
+
114
+ /** Whether to render the search box. */
115
+ search: {
116
+ type: Boolean,
117
+ default: false,
118
+ },
119
+
120
+ /**
121
+ * Accessible name for the search landmark.
122
+ *
123
+ * EMPTY BY DEFAULT so `title` can serve as the prompt. This defaulted
124
+ * to 'Zoeken', which made `searchLabel || title` dead code — the
125
+ * fallback could never fire, and a host that set only `title` got the
126
+ * generic word instead of its own question. Caught by the test that
127
+ * asserts the visible label carries the hero's words.
128
+ */
129
+ searchLabel: {
130
+ type: String,
131
+ default: '',
132
+ },
133
+
134
+ /** Placeholder inside the search field. */
135
+ searchPlaceholder: {
136
+ type: String,
137
+ default: '',
138
+ },
139
+
140
+ /** Visible text on the search button. */
141
+ searchSubmitLabel: {
142
+ type: String,
143
+ default: 'Zoeken',
144
+ },
145
+
146
+ /** Pre-filled search term. */
147
+ searchValue: {
148
+ type: String,
149
+ default: '',
150
+ },
151
+
152
+ /** DOM id for the search input. */
153
+ searchInputId: {
154
+ type: String,
155
+ default: 'cn-site-search',
156
+ },
157
+
158
+ /**
159
+ * Optional background image for the band.
160
+ *
161
+ * The reference's hero carries one (`cover`, centred) over its blue.
162
+ * A URL rather than a token because it is CONTENT — each portal's own
163
+ * photograph — not a design decision the token set can make.
164
+ */
165
+ backgroundImage: {
166
+ type: String,
167
+ default: '',
168
+ },
169
+
170
+ /**
171
+ * Paint the heading on the band.
172
+ *
173
+ * Defaults to the safe answer: visible only when there is no search box
174
+ * to carry the prompt. Forcing it on a dark band without a colour rule
175
+ * for it is how a heading becomes invisible.
176
+ */
177
+ headingVisible: {
178
+ type: Boolean,
179
+ default: null,
180
+ },
181
+ },
182
+
183
+ emits: ['search'],
184
+
185
+ computed: {
186
+ /**
187
+ * @return {string} The heading element to render.
188
+ */
189
+ headingTag() {
190
+ return `h${this.headingLevel}`
191
+ },
192
+
193
+ /**
194
+ * The search box's accessible name.
195
+ *
196
+ * Prefers an explicit `searchLabel`, then the hero's own `title` — the
197
+ * reference implementation's hero puts its question inside the form, so
198
+ * the title IS the prompt there. Falls back to a generic word only when
199
+ * a host supplies neither, because an unnamed search field is worse
200
+ * than a generically named one.
201
+ *
202
+ * @return {string} A non-empty label.
203
+ */
204
+ effectiveSearchLabel() {
205
+ return this.searchLabel || this.title || 'Zoeken'
206
+ },
207
+
208
+ /**
209
+ * Whether the heading is painted.
210
+ *
211
+ * @return {boolean} True when it should be visible.
212
+ */
213
+ showHeading() {
214
+ if (this.headingVisible !== null) {
215
+ return this.headingVisible
216
+ }
217
+
218
+ // With a search box the label carries the prompt, so a painted
219
+ // heading would duplicate it — and duplicate it in the one place
220
+ // the design system has no text colour for.
221
+ return this.search === false
222
+ },
223
+
224
+ /**
225
+ * @return {Array} Classes for the heading.
226
+ */
227
+ headingClass() {
228
+ return ['ac-hero__title', this.showHeading ? null : 'sr-only'].filter(Boolean)
229
+ },
230
+
231
+ /**
232
+ * @return {Array} Classes for the subtitle.
233
+ */
234
+ subtitleClass() {
235
+ return ['ac-hero__subtitle', this.showHeading ? null : 'sr-only'].filter(Boolean)
236
+ },
237
+ },
238
+ }
239
+ </script>
@@ -0,0 +1,90 @@
1
+ <!--
2
+ - SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ - SPDX-License-Identifier: EUPL-1.2
4
+ -->
5
+
6
+ <template>
7
+ <svg
8
+ v-if="path"
9
+ class="ac-icon"
10
+ :class="`ac-icon--${name}`"
11
+ :width="size"
12
+ :height="size"
13
+ viewBox="0 0 24 24"
14
+ fill="none"
15
+ aria-hidden="true"
16
+ focusable="false">
17
+ <path :d="path" fill="currentColor" />
18
+ </svg>
19
+ </template>
20
+
21
+ <script>
22
+ /**
23
+ * A small inline icon from a CLOSED vocabulary.
24
+ *
25
+ * WHY INLINE SVG AND NOT AN ICON FONT
26
+ *
27
+ * An icon font is a webfont, and this codebase has already been bitten by
28
+ * exactly that: the reference application's stylesheets declare `ac-icons` with
29
+ * a root-relative url, which from a portal's own path resolves against the
30
+ * ORIGIN and 404s. A missing icon font does not fail loudly — it renders a
31
+ * blank box or a stray letter, and the page looks finished.
32
+ *
33
+ * WHY A CLOSED SET AND NOT AN ARBITRARY `path` PROP
34
+ *
35
+ * Page content is authored data. Letting an author supply raw SVG path data
36
+ * would put attacker-controlled markup inside an `<svg>` on a public government
37
+ * page. A name resolved against a fixed map cannot express anything the map
38
+ * does not already contain, and an unknown name renders NOTHING rather than
39
+ * guessing.
40
+ *
41
+ * ALWAYS DECORATIVE. Every icon here sits beside its own visible label, so it
42
+ * is `aria-hidden` with `focusable="false"`. An icon that repeats the adjacent
43
+ * text is one more thing announced for no gain, and `focusable="false"` keeps
44
+ * IE-era SVG out of the tab order.
45
+ *
46
+ * PUBLIC-SAFE (see ../index.js): no `@nextcloud/*` import.
47
+ */
48
+
49
+ /**
50
+ * The icon vocabulary. Paths are 24x24, single-path, currentColor.
51
+ *
52
+ * @type {Record<string, string>}
53
+ */
54
+ const ICON_PATHS = {
55
+ search: 'M10 4a6 6 0 1 0 3.7 10.72l4.29 4.29a1 1 0 0 0 1.42-1.42l-4.29-4.29A6 6 0 0 0 10 4Zm-4 6a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z',
56
+ document: 'M6 2h7l5 5v15H6V2Zm7 1.5V8h4.5L13 3.5ZM8 12h8v1.5H8V12Zm0 4h8v1.5H8V16Z',
57
+ globe: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm6.9 9h-3a15.6 15.6 0 0 0-1.2-5.4A8 8 0 0 1 18.9 11ZM12 4.2c.8 1.2 1.5 3.4 1.7 6.8h-3.4c.2-3.4.9-5.6 1.7-6.8ZM5.1 11a8 8 0 0 1 4.2-5.4A15.6 15.6 0 0 0 8.1 11h-3Zm0 2h3a15.6 15.6 0 0 0 1.2 5.4A8 8 0 0 1 5.1 13Zm6.9 6.8c-.8-1.2-1.5-3.4-1.7-6.8h3.4c-.2 3.4-.9 5.6-1.7 6.8Zm2.7-1.4a15.6 15.6 0 0 0 1.2-5.4h3a8 8 0 0 1-4.2 5.4Z',
58
+ 'external-link': 'M14 3h7v7h-2V6.41l-9.29 9.3-1.42-1.42 9.3-9.29H14V3ZM5 5h5v2H7v10h10v-3h2v5H5V5Z',
59
+ 'arrow-right': 'M13.17 5.17 12 6.34 16.66 11H4v2h12.66L12 17.66l1.17 1.17L20 12l-6.83-6.83Z',
60
+ information: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm1 15h-2v-6h2v6Zm0-8h-2V7h2v2Z',
61
+ email: 'M4 4h16v16H4V4Zm2 2v.5l6 4 6-4V6H6Zm12 3-6 4-6-4v9h12V9Z',
62
+ }
63
+
64
+ export default {
65
+ name: 'CnSiteIcon',
66
+
67
+ props: {
68
+ /** Icon name from the closed vocabulary. Unknown names render nothing. */
69
+ name: {
70
+ type: String,
71
+ required: true,
72
+ },
73
+
74
+ /** Square size in pixels. */
75
+ size: {
76
+ type: [Number, String],
77
+ default: 20,
78
+ },
79
+ },
80
+
81
+ computed: {
82
+ /**
83
+ * @return {string} The path data, or '' when the name is unknown.
84
+ */
85
+ path() {
86
+ return ICON_PATHS[this.name] || ''
87
+ },
88
+ },
89
+ }
90
+ </script>
@@ -0,0 +1,173 @@
1
+ <!--
2
+ - SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ - SPDX-License-Identifier: EUPL-1.2
4
+ -->
5
+
6
+ <template>
7
+ <form
8
+ class="ac-search-box ac-search-box--home"
9
+ role="search"
10
+ :aria-label="label"
11
+ @submit.prevent="submit">
12
+ <!--
13
+ A LABEL, not just a placeholder. A placeholder disappears the moment
14
+ the field has content and is not reliably announced, so a
15
+ placeholder-only search box leaves a screen-reader user with an
16
+ unnamed text input.
17
+
18
+ VISIBLE BY DEFAULT when the host asks for it, because on the NL
19
+ Design System reference the prompt IS this label — measured on its
20
+ home page, the hero contains no heading element at all and the
21
+ question sits inside the form. Hiding it there would delete the only
22
+ visible prompt on the band.
23
+ -->
24
+ <!--
25
+ Class names captured from the running reference rather than guessed —
26
+ `utrecht-form-label`, `utrecht-textbox--html-input` and
27
+ `utrecht-button--primary-action` are what the design system's CSS
28
+ actually selects on. Without the primary-action modifier the submit
29
+ button renders as the SUBTLE variant: measured, transparent
30
+ background and rgb(10, 39, 80) text where the design wants a filled
31
+ rgb(0, 68, 136) button with white text.
32
+ -->
33
+ <div class="ac-flex ac-flex--column ac-flex--spacing-sm">
34
+ <label :class="labelClass" :for="inputId">
35
+ {{ label }}
36
+ </label>
37
+
38
+ <div class="ac-search-box__search">
39
+ <input
40
+ :id="inputId"
41
+ v-model="term"
42
+ type="text"
43
+ name="q"
44
+ class="ac-search-box__input utrecht-textbox utrecht-textbox--html-input"
45
+ :placeholder="placeholder"
46
+ autocomplete="off">
47
+ <button
48
+ type="submit"
49
+ class="ac-search-box__button utrecht-button utrecht-button--submit utrecht-button--primary-action">
50
+ <!--
51
+ DECORATIVE, so `aria-hidden` and no title: the button
52
+ already has a text label beside it, and an icon that
53
+ repeats it is one more thing for a screen reader to read
54
+ out for no gain.
55
+ -->
56
+ <svg
57
+ class="ac-search-box__search-icon"
58
+ width="20"
59
+ height="20"
60
+ viewBox="0 0 20 20"
61
+ fill="none"
62
+ aria-hidden="true"
63
+ focusable="false">
64
+ <path
65
+ d="M8.5 3a5.5 5.5 0 1 0 3.383 9.83l3.643 3.644a.75.75 0 1 0 1.061-1.06l-3.644-3.644A5.5 5.5 0 0 0 8.5 3Zm-4 5.5a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z"
66
+ fill="currentColor" />
67
+ </svg>
68
+ {{ submitLabel }}
69
+ </button>
70
+ </div>
71
+ </div>
72
+ </form>
73
+ </template>
74
+
75
+ <script>
76
+ /**
77
+ * The site search box.
78
+ *
79
+ * Emits `search` with the term and does NOT fetch anything itself. A block
80
+ * that owned its own transport could only ever work against one back end,
81
+ * which is the opposite of what a shared library is for — the host decides
82
+ * where a query goes.
83
+ *
84
+ * It is a real `<form>` with a submit button so that Enter works and the
85
+ * control is reachable and operable by keyboard without any script.
86
+ *
87
+ * PUBLIC-SAFE (see ../index.js): no `@nextcloud/*` import. Labels arrive as
88
+ * props rather than through `t()`, because the translation layer is exactly
89
+ * the dependency that makes a component unusable outside Nextcloud.
90
+ */
91
+ export default {
92
+ name: 'CnSiteSearch',
93
+
94
+ props: {
95
+ /** Accessible name for the search landmark and its input. */
96
+ label: {
97
+ type: String,
98
+ default: 'Zoeken',
99
+ },
100
+
101
+ /** Placeholder shown inside the field. */
102
+ placeholder: {
103
+ type: String,
104
+ default: '',
105
+ },
106
+
107
+ /** Visible text on the submit button. */
108
+ submitLabel: {
109
+ type: String,
110
+ default: 'Zoeken',
111
+ },
112
+
113
+ /** Pre-filled term, so a results page can round-trip the query. */
114
+ value: {
115
+ type: String,
116
+ default: '',
117
+ },
118
+
119
+ /** DOM id for the input, so the label can reference it. */
120
+ inputId: {
121
+ type: String,
122
+ default: 'cn-site-search',
123
+ },
124
+
125
+ /**
126
+ * Whether the label is shown or only exposed to assistive tech.
127
+ *
128
+ * It is ALWAYS in the DOM either way — this only decides whether it is
129
+ * painted. A search box whose label is hidden with `display: none`
130
+ * would be hidden from screen readers too, which is why the hidden
131
+ * state is a clip-based `sr-only`, not a display toggle.
132
+ */
133
+ labelVisible: {
134
+ type: Boolean,
135
+ default: false,
136
+ },
137
+ },
138
+
139
+ emits: ['search'],
140
+
141
+ data() {
142
+ return {
143
+ term: this.value,
144
+ }
145
+ },
146
+
147
+ computed: {
148
+ /**
149
+ * @return {Array} Classes for the label.
150
+ */
151
+ labelClass() {
152
+ return ['ac-search-box__label', 'utrecht-form-label', this.labelVisible ? null : 'sr-only'].filter(Boolean)
153
+ },
154
+ },
155
+
156
+ watch: {
157
+ value(next) {
158
+ this.term = next
159
+ },
160
+ },
161
+
162
+ methods: {
163
+ /**
164
+ * Hand the term to the host.
165
+ *
166
+ * @return {void}
167
+ */
168
+ submit() {
169
+ this.$emit('search', this.term)
170
+ },
171
+ },
172
+ }
173
+ </script>
@@ -0,0 +1,120 @@
1
+ <!--
2
+ - SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ - SPDX-License-Identifier: EUPL-1.2
4
+ -->
5
+
6
+ <template>
7
+ <section :class="sectionClass" :style="sectionStyle">
8
+ <div class="container">
9
+ <slot />
10
+ </div>
11
+ </section>
12
+ </template>
13
+
14
+ <script>
15
+ /**
16
+ * A full-bleed band with a constrained reading column inside it.
17
+ *
18
+ * The band paints edge to edge and the `.container` holds the content at the
19
+ * design system's column width. That split is not cosmetic: every band in the
20
+ * NL Design System reference works this way, and a region that emits its
21
+ * content WITHOUT a container renders body copy hard against the viewport edge
22
+ * while the navigation above and the footer below start inset.
23
+ *
24
+ * PUBLIC-SAFE (see ../index.js): no `@nextcloud/*` import, directly or
25
+ * transitively. This component must render at a public origin where there is
26
+ * no Nextcloud, no `OC` global and no translation bundle.
27
+ */
28
+ export default {
29
+ name: 'CnSiteSection',
30
+
31
+ props: {
32
+ /**
33
+ * Vertical rhythm. `spacing` adds the design system's block padding;
34
+ * `hero` is the taller leading band.
35
+ */
36
+ variant: {
37
+ type: String,
38
+ default: 'spacing',
39
+ validator: (v) => ['spacing', 'hero', 'flush'].includes(v),
40
+ },
41
+
42
+ /** Optional background image URL, layered over the band's colour. */
43
+ backgroundImage: {
44
+ type: String,
45
+ default: '',
46
+ },
47
+ },
48
+
49
+ computed: {
50
+ /**
51
+ * Inline background, when the host supplies an image.
52
+ *
53
+ * Inline rather than a class because the URL is per-portal CONTENT; a
54
+ * stylesheet cannot enumerate them. `background-image` is layered OVER
55
+ * the band's own colour rather than replacing it, so a slow or blocked
56
+ * image leaves the blue behind it and the text stays readable — an
57
+ * image that fails is not allowed to become white-on-white.
58
+ *
59
+ * @return {object|null} Style bindings, or null.
60
+ */
61
+ sectionStyle() {
62
+ if (this.backgroundImage === '') {
63
+ return null
64
+ }
65
+
66
+ return {
67
+ backgroundImage: `url(${JSON.stringify(this.backgroundImage)})`,
68
+ backgroundSize: 'cover',
69
+ backgroundPosition: '50% 50%',
70
+ backgroundRepeat: 'no-repeat',
71
+ }
72
+ },
73
+
74
+ /**
75
+ * @return {Array} The band's classes.
76
+ */
77
+ sectionClass() {
78
+ return [
79
+ 'ac-section',
80
+ this.variant === 'hero' ? 'ac-hero' : null,
81
+ this.variant === 'spacing' ? 'ac-section--spacing' : null,
82
+ ].filter(Boolean)
83
+ },
84
+ },
85
+ }
86
+ </script>
87
+
88
+ <style scoped>
89
+ /*
90
+ * ONLY THE TEXT THAT ACTUALLY SITS ON THE BAND IS RECOLOURED.
91
+ *
92
+ * `.ac-hero` paints itself `--tilburg-color-blue-500` — dark — and defines no
93
+ * colour for text placed directly on it, because the reference implementation
94
+ * never puts any there: its hero holds a white input and a blue button, both
95
+ * of which bring their own surface. So a heading or paragraph dropped onto the
96
+ * band inherits the document's dark body colour and lands dark-on-dark.
97
+ *
98
+ * THIS RULE WAS FIRST WRITTEN ON `.ac-hero` ITSELF, AND THAT WAS WRONG — it is
99
+ * the very mistake the rest of this codebase keeps recording. Colour set on an
100
+ * ancestor cannot know that a descendant paints its own background:
101
+ * `.ac-search-box` has one, `rgb(230, 246, 255)`, and inheriting white into it
102
+ * put the label and button at contrast 1.11 and the input's text at 1.0.
103
+ *
104
+ * Worse, the "bug" that prompted it was a MEASUREMENT ERROR. The label
105
+ * computed `rgb(51, 51, 51)`, which was compared against the BAND and scored
106
+ * 1.06 — but the label does not sit on the band, it sits on the search box's
107
+ * own light surface, where that colour is entirely correct. The contrast probe
108
+ * has to walk up to the first ancestor that actually paints a background;
109
+ * against the nearest NAMED band it reports a failure that is not there, and
110
+ * then invites a fix that breaks something real.
111
+ *
112
+ * So: the hero's own title and subtitle, and nothing else. The system's
113
+ * inverse token rather than a literal, so a theme with a light hero corrects
114
+ * both together.
115
+ */
116
+ .ac-hero__title,
117
+ .ac-hero__subtitle {
118
+ color: var(--utrecht-document-inverse-color, #ffffff);
119
+ }
120
+ </style>