@conduction/nextcloud-vue 2.3.0-beta.1 → 2.3.0-beta.2
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 +18 -2
- package/scripts/check-public-safe.js +115 -0
- package/src/public/__tests__/publicBlocks.spec.js +233 -0
- package/src/public/components/CnSiteCard.vue +178 -0
- package/src/public/components/CnSiteCardGrid.vue +98 -0
- package/src/public/components/CnSiteEmptyState.vue +172 -0
- package/src/public/components/CnSiteHero.vue +239 -0
- package/src/public/components/CnSiteIcon.vue +90 -0
- package/src/public/components/CnSiteSearch.vue +173 -0
- package/src/public/components/CnSiteSection.vue +120 -0
- package/src/public/index.js +130 -0
|
@@ -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,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>
|