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.
Files changed (51) hide show
  1. package/eslint.config.js +75 -0
  2. package/package.json +17 -4
  3. package/router.js +74 -0
  4. package/styles/dark-theme.css +20 -11
  5. package/styles/picocrank-extensions.css +123 -30
  6. package/supplemental-themes/catppuccin-latte-frappe/theme.css +10 -24
  7. package/supplemental-themes/dracula-alucard/theme.css +10 -24
  8. package/supplemental-themes/gruvbox-dark-light/theme.css +10 -24
  9. package/supplemental-themes/waffles/theme.css +2 -16
  10. package/vite.config.js +5 -0
  11. package/vue/components/Breadcrumbs.vue +192 -45
  12. package/vue/components/Calendar.vue +162 -145
  13. package/vue/components/DangerZone.vue +186 -0
  14. package/vue/components/FormField.spec.js +350 -0
  15. package/vue/components/FormField.vue +236 -32
  16. package/vue/components/FormLayout.spec.js +56 -0
  17. package/vue/components/Header.vue +22 -6
  18. package/vue/components/Login.vue +152 -41
  19. package/vue/components/NavigationGrid.vue +26 -180
  20. package/vue/components/NotificationBlock.vue +120 -0
  21. package/vue/components/NotificationPopup.vue +44 -41
  22. package/vue/components/Pagination.vue +5 -4
  23. package/vue/components/ReadOnlyTextArea.vue +1 -1
  24. package/vue/components/SidebarNavLink.vue +3 -3
  25. package/vue/components/Table.vue +107 -79
  26. package/vue/components/TableColumnOptionsPopover.vue +9 -0
  27. package/vue/components/Tabs.spec.js +101 -0
  28. package/vue/components/Tabs.vue +455 -81
  29. package/vue/components/TopBar.vue +2 -2
  30. package/vue/components/form-validation.spec.js +126 -0
  31. package/vue/composables/tableColumnPriorities.js +33 -0
  32. package/vue/composables/tableFilters.js +1 -1
  33. package/vue/data/navSectionHubs.js +196 -0
  34. package/vue/data/routeBreadcrumbs.js +90 -0
  35. package/vue/examples/Admin.vue +12 -12
  36. package/vue/examples/App.vue +27 -36
  37. package/vue/examples/CalendarExample.vue +32 -22
  38. package/vue/examples/ExampleHeaderThemeSwitcher.vue +1 -1
  39. package/vue/examples/FormExample.vue +76 -48
  40. package/vue/examples/Login.vue +5 -3
  41. package/vue/examples/LoginExample.vue +99 -15
  42. package/vue/examples/NavSectionHub.vue +36 -0
  43. package/vue/examples/PageStructure.vue +15 -12
  44. package/vue/examples/StatusExample.vue +248 -129
  45. package/vue/examples/TableExample.vue +72 -71
  46. package/vue/examples/TabsExample.vue +236 -257
  47. package/vue/examples/ThemePreview.vue +21 -23
  48. package/vue/examples/ViewItem.vue +32 -5
  49. package/vue/examples/Welcome.vue +3 -40
  50. package/vue/test/formTestUtils.js +279 -0
  51. package/vue/test/setup.js +5 -0
@@ -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
- <form @submit.prevent="handleLocalLogin" class="local-login-form">
8
- <input
9
- id="username"
10
- type="text"
11
- v-model="localLogin.username"
12
- required
13
- autocomplete="username"
14
- placeholder="Username"
15
- />
16
- <input
17
- id="password"
18
- type="password"
19
- v-model="localLogin.password"
20
- required
21
- autocomplete="current-password"
22
- placeholder="Password"
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
- <div v-if="localLoginError" class="error-message">
26
- {{ localLoginError }}
27
- </div>
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
- // Add default tabs if enabled
123
- if (props.showDefaultTabs) {
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 localLoginError = ref('');
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
- localLoginError.value = '';
167
-
168
- // Emit event for parent component to handle
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
- localLoginError.value = '';
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
- // Clear errors when switching tabs
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
- // Method to set error messages
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
- localLoginError.value = message;
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 input {
243
- padding: 0.75rem;
244
- border-radius: 4px;
245
- font-size: 1em;
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-form input:focus-visible {
251
- outline: 2px solid var(--login-input-focus-outline);
252
- outline-offset: 2px;
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-icon" :style="getIconStyle(link)">
23
- <HugeiconsIcon
24
- :icon="link.icon"
25
- :width="iconSize"
26
- :height="iconSize"
27
- />
28
- </div>
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="no-links">
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, useRoute } from 'vue-router';
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: '2em'
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
- .navigation-grid-container {
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
- flex-direction: column;
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:hover {
198
- border-color: var(--nav-grid-accent-fg);
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
- .no-links {
320
- padding: 2rem;
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
- @media (max-width: 768px) {
326
- .navigation-grid {
327
- grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
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>