picocrank 1.25.2 → 1.27.0
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/eslint.config.js +75 -0
- package/package.json +17 -4
- package/router.js +74 -0
- package/styles/dark-theme.css +20 -11
- package/styles/picocrank-extensions.css +123 -30
- package/supplemental-themes/catppuccin-latte-frappe/theme.css +10 -24
- package/supplemental-themes/dracula-alucard/theme.css +10 -24
- package/supplemental-themes/gruvbox-dark-light/theme.css +10 -24
- package/supplemental-themes/waffles/theme.css +2 -16
- package/vite.config.js +5 -0
- package/vue/components/Breadcrumbs.vue +192 -45
- package/vue/components/Calendar.vue +162 -145
- package/vue/components/DangerZone.vue +186 -0
- package/vue/components/FormField.spec.js +350 -0
- package/vue/components/FormField.vue +236 -32
- package/vue/components/FormLayout.spec.js +56 -0
- package/vue/components/Header.vue +22 -6
- package/vue/components/Login.vue +152 -41
- package/vue/components/NavigationGrid.vue +26 -180
- package/vue/components/NotificationBlock.vue +120 -0
- package/vue/components/NotificationPopup.vue +44 -41
- package/vue/components/Pagination.vue +5 -4
- package/vue/components/ReadOnlyTextArea.vue +1 -1
- package/vue/components/SidebarNavLink.vue +3 -3
- package/vue/components/Table.vue +107 -79
- package/vue/components/TableColumnOptionsPopover.vue +9 -0
- package/vue/components/Tabs.spec.js +101 -0
- package/vue/components/Tabs.vue +455 -81
- package/vue/components/TopBar.vue +2 -2
- package/vue/components/form-validation.spec.js +126 -0
- package/vue/composables/tableColumnPriorities.js +33 -0
- package/vue/composables/tableFilters.js +1 -1
- package/vue/data/navSectionHubs.js +196 -0
- package/vue/data/routeBreadcrumbs.js +90 -0
- package/vue/examples/Admin.vue +12 -12
- package/vue/examples/App.vue +27 -36
- package/vue/examples/CalendarExample.vue +32 -22
- package/vue/examples/ExampleHeaderThemeSwitcher.vue +1 -1
- package/vue/examples/FormExample.vue +76 -48
- package/vue/examples/Login.vue +5 -3
- package/vue/examples/LoginExample.vue +99 -15
- package/vue/examples/NavSectionHub.vue +36 -0
- package/vue/examples/PageStructure.vue +15 -12
- package/vue/examples/StatusExample.vue +248 -129
- package/vue/examples/TableExample.vue +72 -71
- package/vue/examples/TabsExample.vue +236 -257
- package/vue/examples/ThemePreview.vue +21 -23
- package/vue/examples/ViewItem.vue +32 -5
- package/vue/examples/Welcome.vue +3 -40
- package/vue/test/formTestUtils.js +279 -0
- package/vue/test/setup.js +5 -0
package/vue/components/Login.vue
CHANGED
|
@@ -1,30 +1,50 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="login-container">
|
|
2
|
+
<div class="login-container" :class="{ 'local-only': localOnly }">
|
|
3
3
|
<Tabs :tabs="loginTabs" @tab-change="onTabChange">
|
|
4
4
|
<!-- Local Login Tab -->
|
|
5
5
|
<template #tab-local>
|
|
6
6
|
<div class="login-section">
|
|
7
|
-
<
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
7
|
+
<div
|
|
8
|
+
v-if="localLoginLoading"
|
|
9
|
+
class="local-login-loading"
|
|
10
|
+
role="status"
|
|
11
|
+
aria-live="polite"
|
|
12
|
+
>
|
|
13
|
+
<span class="local-login-spinner" aria-hidden="true" />
|
|
14
|
+
<p>{{ localLoginLoadingMessage }}</p>
|
|
15
|
+
</div>
|
|
16
|
+
<form v-else @submit.prevent="handleLocalLogin" class="local-login-form">
|
|
17
|
+
<FormField label="Username" for="username" label-skip v-model:error="usernameError">
|
|
18
|
+
<input
|
|
19
|
+
id="username"
|
|
20
|
+
type="text"
|
|
21
|
+
v-model="localLogin.username"
|
|
22
|
+
required
|
|
23
|
+
autocomplete="username"
|
|
24
|
+
placeholder="Username"
|
|
25
|
+
aria-label="Username"
|
|
26
|
+
/>
|
|
27
|
+
</FormField>
|
|
28
|
+
<FormField label="Password" for="password" label-skip v-model:error="passwordError">
|
|
29
|
+
<input
|
|
30
|
+
id="password"
|
|
31
|
+
type="password"
|
|
32
|
+
v-model="localLogin.password"
|
|
33
|
+
required
|
|
34
|
+
autocomplete="current-password"
|
|
35
|
+
placeholder="Password"
|
|
36
|
+
aria-label="Password"
|
|
37
|
+
/>
|
|
38
|
+
</FormField>
|
|
24
39
|
<button type="submit" class="good">Login</button>
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
40
|
+
<NotificationBlock
|
|
41
|
+
v-if="formError"
|
|
42
|
+
class="login-form-error"
|
|
43
|
+
type="bad"
|
|
44
|
+
label="Error"
|
|
45
|
+
:message="formError"
|
|
46
|
+
role="alert"
|
|
47
|
+
/>
|
|
28
48
|
</form>
|
|
29
49
|
</div>
|
|
30
50
|
</template>
|
|
@@ -70,6 +90,8 @@
|
|
|
70
90
|
<script setup>
|
|
71
91
|
import { ref, reactive, watch, computed, useSlots } from 'vue';
|
|
72
92
|
import Tabs from './Tabs.vue';
|
|
93
|
+
import FormField from './FormField.vue';
|
|
94
|
+
import NotificationBlock from './NotificationBlock.vue';
|
|
73
95
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
74
96
|
|
|
75
97
|
const props = defineProps({
|
|
@@ -95,6 +117,15 @@ const props = defineProps({
|
|
|
95
117
|
showDefaultTabs: {
|
|
96
118
|
type: Boolean,
|
|
97
119
|
default: true
|
|
120
|
+
},
|
|
121
|
+
// Username & password only — no OAuth or custom tabs; hides the tab bar
|
|
122
|
+
localOnly: {
|
|
123
|
+
type: Boolean,
|
|
124
|
+
default: false
|
|
125
|
+
},
|
|
126
|
+
loadingMessage: {
|
|
127
|
+
type: String,
|
|
128
|
+
default: 'Signing in…'
|
|
98
129
|
}
|
|
99
130
|
});
|
|
100
131
|
|
|
@@ -119,8 +150,9 @@ const loginTabs = ref([]);
|
|
|
119
150
|
function initializeTabs() {
|
|
120
151
|
const tabs = [];
|
|
121
152
|
|
|
122
|
-
|
|
123
|
-
|
|
153
|
+
if (props.localOnly) {
|
|
154
|
+
tabs.push({ id: 'local', label: props.tabLabels.local || 'Username & Password' });
|
|
155
|
+
} else if (props.showDefaultTabs) {
|
|
124
156
|
tabs.push(
|
|
125
157
|
{ id: 'local', label: props.tabLabels.local || 'Username & Password' },
|
|
126
158
|
{ id: 'oauth', label: props.tabLabels.oauth || 'OAuth2' }
|
|
@@ -128,7 +160,7 @@ function initializeTabs() {
|
|
|
128
160
|
}
|
|
129
161
|
|
|
130
162
|
// Add custom tabs
|
|
131
|
-
if (props.customTabs && props.customTabs.length > 0) {
|
|
163
|
+
if (!props.localOnly && props.customTabs && props.customTabs.length > 0) {
|
|
132
164
|
tabs.push(...props.customTabs);
|
|
133
165
|
}
|
|
134
166
|
|
|
@@ -139,7 +171,7 @@ function initializeTabs() {
|
|
|
139
171
|
initializeTabs();
|
|
140
172
|
|
|
141
173
|
// Watch for changes to customTabs or showDefaultTabs
|
|
142
|
-
watch(() => [props.customTabs, props.showDefaultTabs], () => {
|
|
174
|
+
watch(() => [props.customTabs, props.showDefaultTabs, props.localOnly], () => {
|
|
143
175
|
initializeTabs();
|
|
144
176
|
}, { deep: true });
|
|
145
177
|
|
|
@@ -149,7 +181,26 @@ const localLogin = reactive({
|
|
|
149
181
|
password: ''
|
|
150
182
|
});
|
|
151
183
|
|
|
152
|
-
const
|
|
184
|
+
const usernameError = ref('');
|
|
185
|
+
const passwordError = ref('');
|
|
186
|
+
const formError = ref('');
|
|
187
|
+
const localLoginLoading = ref(false);
|
|
188
|
+
const localLoginLoadingMessage = ref('');
|
|
189
|
+
|
|
190
|
+
function clearLocalLoginErrors() {
|
|
191
|
+
usernameError.value = '';
|
|
192
|
+
passwordError.value = '';
|
|
193
|
+
formError.value = '';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
watch(
|
|
197
|
+
() => [localLogin.username, localLogin.password],
|
|
198
|
+
() => {
|
|
199
|
+
if (formError.value) {
|
|
200
|
+
formError.value = '';
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
);
|
|
153
204
|
|
|
154
205
|
// OAuth providers - provided by parent component
|
|
155
206
|
const oauthProviders = ref(props.oauthProviders);
|
|
@@ -163,9 +214,10 @@ watch(() => props.oauthProviders, (newProviders) => {
|
|
|
163
214
|
|
|
164
215
|
// Handle local login
|
|
165
216
|
function handleLocalLogin() {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
217
|
+
clearLocalLoginErrors();
|
|
218
|
+
localLoginLoading.value = true;
|
|
219
|
+
localLoginLoadingMessage.value = props.loadingMessage;
|
|
220
|
+
|
|
169
221
|
emit('local-login', {
|
|
170
222
|
username: localLogin.username,
|
|
171
223
|
password: localLogin.password
|
|
@@ -176,7 +228,8 @@ function handleLocalLogin() {
|
|
|
176
228
|
function resetLocalForm() {
|
|
177
229
|
localLogin.username = '';
|
|
178
230
|
localLogin.password = '';
|
|
179
|
-
|
|
231
|
+
clearLocalLoginErrors();
|
|
232
|
+
localLoginLoading.value = false;
|
|
180
233
|
}
|
|
181
234
|
|
|
182
235
|
// Handle OAuth login
|
|
@@ -189,15 +242,28 @@ function handleOAuthLogin(provider) {
|
|
|
189
242
|
|
|
190
243
|
// Handle tab change
|
|
191
244
|
function onTabChange(tab, tabId) {
|
|
192
|
-
|
|
193
|
-
localLoginError.value = '';
|
|
245
|
+
clearLocalLoginErrors();
|
|
194
246
|
oauthError.value = '';
|
|
247
|
+
localLoginLoading.value = false;
|
|
195
248
|
emit('tab-change', tab, tabId);
|
|
196
249
|
}
|
|
197
250
|
|
|
198
|
-
|
|
251
|
+
function setLocalLoginErrors(errors = {}) {
|
|
252
|
+
localLoginLoading.value = false;
|
|
253
|
+
usernameError.value = errors.username || '';
|
|
254
|
+
passwordError.value = errors.password || '';
|
|
255
|
+
formError.value = errors.form || '';
|
|
256
|
+
}
|
|
257
|
+
|
|
199
258
|
function setLocalLoginError(message) {
|
|
200
|
-
|
|
259
|
+
setLocalLoginErrors({ form: message });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function setLocalLoginLoading(loading, message) {
|
|
263
|
+
localLoginLoading.value = loading;
|
|
264
|
+
if (loading) {
|
|
265
|
+
localLoginLoadingMessage.value = message ?? props.loadingMessage;
|
|
266
|
+
}
|
|
201
267
|
}
|
|
202
268
|
|
|
203
269
|
function setOAuthError(message) {
|
|
@@ -214,6 +280,8 @@ defineExpose({
|
|
|
214
280
|
addOAuthProvider,
|
|
215
281
|
resetLocalForm,
|
|
216
282
|
setLocalLoginError,
|
|
283
|
+
setLocalLoginErrors,
|
|
284
|
+
setLocalLoginLoading,
|
|
217
285
|
setOAuthError,
|
|
218
286
|
localLogin
|
|
219
287
|
});
|
|
@@ -225,6 +293,15 @@ defineExpose({
|
|
|
225
293
|
margin: 2rem auto;
|
|
226
294
|
}
|
|
227
295
|
|
|
296
|
+
.login-container.local-only {
|
|
297
|
+
margin-top: 0;
|
|
298
|
+
margin-bottom: 0;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.login-container.local-only :deep(.tabs-header) {
|
|
302
|
+
display: none;
|
|
303
|
+
}
|
|
304
|
+
|
|
228
305
|
.login-section {
|
|
229
306
|
display: flex;
|
|
230
307
|
flex-direction: column;
|
|
@@ -239,17 +316,46 @@ defineExpose({
|
|
|
239
316
|
width: 100%;
|
|
240
317
|
}
|
|
241
318
|
|
|
242
|
-
.local-login-form
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
319
|
+
.local-login-form :deep(.form-field-control) {
|
|
320
|
+
width: 100%;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.local-login-form :deep(input) {
|
|
246
324
|
width: 100%;
|
|
247
325
|
box-sizing: border-box;
|
|
248
326
|
}
|
|
249
327
|
|
|
250
|
-
.local-login-
|
|
251
|
-
|
|
252
|
-
|
|
328
|
+
.local-login-loading {
|
|
329
|
+
display: flex;
|
|
330
|
+
flex-direction: column;
|
|
331
|
+
align-items: center;
|
|
332
|
+
justify-content: center;
|
|
333
|
+
gap: 1rem;
|
|
334
|
+
width: 100%;
|
|
335
|
+
min-height: 9rem;
|
|
336
|
+
padding: 1rem 0;
|
|
337
|
+
color: var(--login-muted-fg);
|
|
338
|
+
text-align: center;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.local-login-loading p {
|
|
342
|
+
margin: 0;
|
|
343
|
+
font-size: 0.95em;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
.local-login-spinner {
|
|
347
|
+
width: 2rem;
|
|
348
|
+
height: 2rem;
|
|
349
|
+
border: 3px solid var(--login-spinner-track);
|
|
350
|
+
border-top-color: var(--login-spinner-fg);
|
|
351
|
+
border-radius: 50%;
|
|
352
|
+
animation: local-login-spin 0.75s linear infinite;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
@keyframes local-login-spin {
|
|
356
|
+
to {
|
|
357
|
+
transform: rotate(360deg);
|
|
358
|
+
}
|
|
253
359
|
}
|
|
254
360
|
|
|
255
361
|
.local-login-form button {
|
|
@@ -266,6 +372,11 @@ defineExpose({
|
|
|
266
372
|
opacity: 0.9;
|
|
267
373
|
}
|
|
268
374
|
|
|
375
|
+
.local-login-form :deep(.login-form-error) {
|
|
376
|
+
width: 100%;
|
|
377
|
+
font-size: 0.9em;
|
|
378
|
+
}
|
|
379
|
+
|
|
269
380
|
.oauth-providers {
|
|
270
381
|
display: flex;
|
|
271
382
|
flex-direction: column;
|
|
@@ -19,20 +19,22 @@
|
|
|
19
19
|
class="nav-button-indicator"
|
|
20
20
|
aria-label="Requires attention"
|
|
21
21
|
/>
|
|
22
|
-
<div class="nav-button-
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
<div class="nav-button-label">{{ link.title }}</div>
|
|
30
|
-
<div v-if="link.description" class="nav-button-description">
|
|
31
|
-
{{ link.description }}
|
|
22
|
+
<div class="nav-button-heading">
|
|
23
|
+
<div class="nav-button-icon" :style="getIconStyle(link)">
|
|
24
|
+
<HugeiconsIcon
|
|
25
|
+
:icon="link.icon"
|
|
26
|
+
:width="iconSize"
|
|
27
|
+
:height="iconSize"
|
|
28
|
+
/>
|
|
32
29
|
</div>
|
|
30
|
+
<div class="nav-button-label">{{ link.title }}</div>
|
|
31
|
+
</div>
|
|
32
|
+
<div v-if="link.description" class="nav-button-description">
|
|
33
|
+
{{ link.description }}
|
|
34
|
+
</div>
|
|
33
35
|
</button>
|
|
34
36
|
</div>
|
|
35
|
-
<div v-if="filteredLinks.length === 0" class="
|
|
37
|
+
<div v-if="filteredLinks.length === 0" class="navigation-grid-empty">
|
|
36
38
|
<p>No navigation links available</p>
|
|
37
39
|
</div>
|
|
38
40
|
</div>
|
|
@@ -40,7 +42,7 @@
|
|
|
40
42
|
|
|
41
43
|
<script setup>
|
|
42
44
|
import { computed, inject, ref } from 'vue';
|
|
43
|
-
import { useRouter
|
|
45
|
+
import { useRouter } from 'vue-router';
|
|
44
46
|
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
45
47
|
|
|
46
48
|
const props = defineProps({
|
|
@@ -63,7 +65,7 @@ const props = defineProps({
|
|
|
63
65
|
// Icon size
|
|
64
66
|
iconSize: {
|
|
65
67
|
type: String,
|
|
66
|
-
default: '
|
|
68
|
+
default: '1.5em'
|
|
67
69
|
},
|
|
68
70
|
// Exclude separator and html items
|
|
69
71
|
excludeNonButtons: {
|
|
@@ -73,7 +75,6 @@ const props = defineProps({
|
|
|
73
75
|
});
|
|
74
76
|
|
|
75
77
|
const router = useRouter();
|
|
76
|
-
const route = useRoute();
|
|
77
78
|
|
|
78
79
|
// Inject navigation from Navigation component
|
|
79
80
|
const navigation = inject('navigation', null);
|
|
@@ -157,179 +158,24 @@ function handleLinkClick(link) {
|
|
|
157
158
|
</script>
|
|
158
159
|
|
|
159
160
|
<style scoped>
|
|
160
|
-
.
|
|
161
|
-
width: 100%;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
.navigation-grid {
|
|
165
|
-
display: grid;
|
|
166
|
-
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
167
|
-
gap: 1rem;
|
|
168
|
-
padding: 1rem 0;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
.navigation-grid.compact {
|
|
172
|
-
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
173
|
-
gap: 0.75rem;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
.nav-button {
|
|
161
|
+
.nav-button-heading {
|
|
177
162
|
display: flex;
|
|
178
|
-
|
|
179
|
-
align-items: top;
|
|
180
|
-
justify-content: flex-start;
|
|
181
|
-
gap: 0.75rem;
|
|
182
|
-
padding: 1.25rem 0.75rem;
|
|
183
|
-
background: transparent;
|
|
184
|
-
border: 1px solid var(--nav-grid-border);
|
|
185
|
-
border-radius: 8px;
|
|
186
|
-
cursor: pointer;
|
|
187
|
-
transition: all 0.2s ease;
|
|
188
|
-
text-align: left;
|
|
189
|
-
position: relative;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
.navigation-grid.compact .nav-button {
|
|
193
|
-
padding: 0.75rem 0.5rem;
|
|
163
|
+
align-items: center;
|
|
194
164
|
gap: 0.5rem;
|
|
165
|
+
width: 100%;
|
|
166
|
+
min-width: 0;
|
|
195
167
|
}
|
|
196
168
|
|
|
197
|
-
.nav-button
|
|
198
|
-
|
|
199
|
-
background: var(--nav-grid-hover-bg);
|
|
200
|
-
transform: translateY(-2px);
|
|
201
|
-
box-shadow: 0 4px 8px var(--nav-grid-hover-shadow);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
.nav-button:active {
|
|
205
|
-
transform: translateY(0);
|
|
206
|
-
box-shadow: 0 2px 4px var(--nav-grid-shadow);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
.nav-button.active {
|
|
210
|
-
background: var(--nav-grid-accent-bg);
|
|
211
|
-
border-color: var(--nav-grid-accent-bg);
|
|
212
|
-
color: var(--nav-grid-active-fg);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
.nav-button.active .nav-button-icon {
|
|
216
|
-
color: var(--nav-grid-active-fg);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
.nav-button-icon {
|
|
220
|
-
display: flex;
|
|
221
|
-
align-items: start;
|
|
222
|
-
justify-content: start;
|
|
223
|
-
color: var(--nav-grid-accent-fg);
|
|
224
|
-
transition: color 0.2s ease;
|
|
225
|
-
flex-shrink: 0;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
.nav-button.active .nav-button-icon {
|
|
229
|
-
color: var(--nav-grid-active-fg);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
.nav-button:disabled,
|
|
233
|
-
.nav-button.disabled {
|
|
234
|
-
cursor: not-allowed;
|
|
235
|
-
opacity: 0.6;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
.nav-button:disabled:hover,
|
|
239
|
-
.nav-button.disabled:hover {
|
|
240
|
-
border-color: var(--nav-grid-border);
|
|
241
|
-
transform: none;
|
|
242
|
-
box-shadow: none;
|
|
243
|
-
background: transparent;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
.nav-button:disabled .nav-button-icon,
|
|
247
|
-
.nav-button.disabled .nav-button-icon {
|
|
248
|
-
color: var(--nav-grid-disabled-fg);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
.nav-button-indicator {
|
|
252
|
-
position: absolute;
|
|
253
|
-
top: 0.75rem;
|
|
254
|
-
right: 0.75rem;
|
|
255
|
-
width: 0.625rem;
|
|
256
|
-
height: 0.625rem;
|
|
257
|
-
border-radius: 50%;
|
|
258
|
-
background: var(--nav-grid-indicator-bg);
|
|
259
|
-
box-shadow: 0 0 0 2px var(--nav-grid-ring-bg);
|
|
260
|
-
flex-shrink: 0;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
.nav-button-count {
|
|
264
|
-
position: absolute;
|
|
265
|
-
top: 0.5rem;
|
|
266
|
-
right: 0.5rem;
|
|
267
|
-
min-width: 1.25rem;
|
|
268
|
-
padding: 0.1rem 0.4rem;
|
|
269
|
-
border-radius: 999px;
|
|
270
|
-
background: var(--nav-grid-indicator-bg);
|
|
271
|
-
color: var(--nav-grid-indicator-fg);
|
|
272
|
-
font-size: 0.75em;
|
|
273
|
-
font-weight: 600;
|
|
274
|
-
line-height: 1.2;
|
|
275
|
-
text-align: center;
|
|
276
|
-
box-shadow: 0 0 0 2px var(--nav-grid-ring-bg);
|
|
277
|
-
flex-shrink: 0;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
.navigation-grid.compact .nav-button-indicator {
|
|
281
|
-
top: 0.5rem;
|
|
282
|
-
right: 0.5rem;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
.navigation-grid.compact .nav-button-count {
|
|
286
|
-
top: 0.35rem;
|
|
287
|
-
right: 0.35rem;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
.nav-button.active .nav-button-indicator,
|
|
291
|
-
.nav-button.active .nav-button-count {
|
|
292
|
-
box-shadow: 0 0 0 2px var(--nav-grid-accent-bg);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
.nav-button-label {
|
|
296
|
-
font-weight: bold;
|
|
297
|
-
font-size: 1.125rem;
|
|
298
|
-
word-break: break-word;
|
|
299
|
-
text-align: left;
|
|
300
|
-
line-height: 1.2;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
.nav-button.active .nav-button-label {
|
|
304
|
-
color: var(--nav-grid-active-fg);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
.nav-button-description {
|
|
308
|
-
font-weight: normal;
|
|
309
|
-
color: var(--nav-grid-muted-fg);
|
|
310
|
-
word-break: break-word;
|
|
311
|
-
text-align: left;
|
|
312
|
-
line-height: 1.5;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
.nav-button.active .nav-button-description {
|
|
316
|
-
color: var(--nav-grid-active-fg);
|
|
169
|
+
.navigation-grid.compact .nav-button-heading {
|
|
170
|
+
gap: 0.4rem;
|
|
317
171
|
}
|
|
318
172
|
|
|
319
|
-
.
|
|
320
|
-
|
|
321
|
-
text-align: center;
|
|
322
|
-
color: var(--nav-grid-muted-fg);
|
|
173
|
+
.nav-button-heading .nav-button-icon {
|
|
174
|
+
align-items: center;
|
|
323
175
|
}
|
|
324
176
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
gap: 0.75rem;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
.nav-button {
|
|
332
|
-
padding: 1rem 0.5rem;
|
|
333
|
-
}
|
|
177
|
+
.nav-button-heading .nav-button-label {
|
|
178
|
+
flex: 1;
|
|
179
|
+
min-width: 0;
|
|
334
180
|
}
|
|
335
181
|
</style>
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
:class="rootClasses"
|
|
4
|
+
:role="role"
|
|
5
|
+
@click="handleClick"
|
|
6
|
+
>
|
|
7
|
+
<span class="notification-key">{{ displayLabel }}</span>
|
|
8
|
+
<span class="notification-body">
|
|
9
|
+
<slot>
|
|
10
|
+
<template v-if="hasLink">
|
|
11
|
+
<template v-if="messagePrefix">{{ messagePrefix }}</template><RouterLink
|
|
12
|
+
v-if="linkTo"
|
|
13
|
+
:to="linkTo"
|
|
14
|
+
@click.stop
|
|
15
|
+
>{{ linkLabel }}</RouterLink><a
|
|
16
|
+
v-else
|
|
17
|
+
:href="linkHref || '#'"
|
|
18
|
+
@click.stop
|
|
19
|
+
>{{ linkLabel }}</a><template v-if="messageSuffix">{{ messageSuffix }}</template>
|
|
20
|
+
</template>
|
|
21
|
+
<template v-else>{{ message }}</template>
|
|
22
|
+
</slot>
|
|
23
|
+
</span>
|
|
24
|
+
</div>
|
|
25
|
+
</template>
|
|
26
|
+
|
|
27
|
+
<script setup>
|
|
28
|
+
import { computed } from 'vue'
|
|
29
|
+
import { RouterLink } from 'vue-router'
|
|
30
|
+
|
|
31
|
+
const TYPE_LABELS = {
|
|
32
|
+
info: 'INFO:',
|
|
33
|
+
note: 'NOTE:',
|
|
34
|
+
success: 'SUCCESS:',
|
|
35
|
+
good: 'SUCCESS:',
|
|
36
|
+
warning: 'WARNING:',
|
|
37
|
+
severe: 'SEVERE:',
|
|
38
|
+
critical: 'CRITICAL:',
|
|
39
|
+
error: 'CRITICAL:',
|
|
40
|
+
bad: 'CRITICAL:',
|
|
41
|
+
old: 'OLD:',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const props = defineProps({
|
|
45
|
+
type: {
|
|
46
|
+
type: String,
|
|
47
|
+
default: 'info',
|
|
48
|
+
validator: (value) => (
|
|
49
|
+
['info', 'note', 'success', 'good', 'warning', 'severe', 'critical', 'error', 'bad', 'old'].includes(value)
|
|
50
|
+
),
|
|
51
|
+
},
|
|
52
|
+
label: {
|
|
53
|
+
type: String,
|
|
54
|
+
default: '',
|
|
55
|
+
},
|
|
56
|
+
message: {
|
|
57
|
+
type: String,
|
|
58
|
+
default: '',
|
|
59
|
+
},
|
|
60
|
+
messagePrefix: {
|
|
61
|
+
type: String,
|
|
62
|
+
default: '',
|
|
63
|
+
},
|
|
64
|
+
linkLabel: {
|
|
65
|
+
type: String,
|
|
66
|
+
default: '',
|
|
67
|
+
},
|
|
68
|
+
linkHref: {
|
|
69
|
+
type: String,
|
|
70
|
+
default: '',
|
|
71
|
+
},
|
|
72
|
+
linkTo: {
|
|
73
|
+
type: [String, Object],
|
|
74
|
+
default: null,
|
|
75
|
+
},
|
|
76
|
+
messageSuffix: {
|
|
77
|
+
type: String,
|
|
78
|
+
default: '',
|
|
79
|
+
},
|
|
80
|
+
clickable: {
|
|
81
|
+
type: Boolean,
|
|
82
|
+
default: false,
|
|
83
|
+
},
|
|
84
|
+
dismissible: {
|
|
85
|
+
type: Boolean,
|
|
86
|
+
default: false,
|
|
87
|
+
},
|
|
88
|
+
role: {
|
|
89
|
+
type: String,
|
|
90
|
+
default: 'status',
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const emit = defineEmits(['click', 'dismiss'])
|
|
95
|
+
|
|
96
|
+
const displayLabel = computed(() => {
|
|
97
|
+
const trimmed = props.label.trim()
|
|
98
|
+
if (trimmed) {
|
|
99
|
+
return trimmed.endsWith(':') ? trimmed : `${trimmed}:`
|
|
100
|
+
}
|
|
101
|
+
return TYPE_LABELS[props.type]
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const hasLink = computed(() => Boolean(
|
|
105
|
+
props.linkLabel && (props.linkHref || props.linkTo)
|
|
106
|
+
))
|
|
107
|
+
|
|
108
|
+
const rootClasses = computed(() => ({
|
|
109
|
+
notification: true,
|
|
110
|
+
[props.type]: true,
|
|
111
|
+
clickable: props.clickable || props.dismissible,
|
|
112
|
+
}))
|
|
113
|
+
|
|
114
|
+
function handleClick(event) {
|
|
115
|
+
emit('click', event)
|
|
116
|
+
if (props.dismissible) {
|
|
117
|
+
emit('dismiss', event)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
</script>
|