@stackonward/cli 1.2.1 → 1.3.1
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/dist/index.js +17 -10
- package/dist/index.js.map +1 -1
- package/dist/resources/components/nuxt/site-source/app/app.vue +9 -6
- package/dist/resources/components/nuxt/site-source/app/assets/css/site-shell.css +268 -33
- package/dist/resources/components/nuxt/site-source/app/components/site/ApplicationHeader.vue +7 -4
- package/dist/resources/components/nuxt/site-source/app/components/site/ApplicationShell.vue +1 -1
- package/dist/resources/components/nuxt/site-source/app/components/site/ApplicationSidebar.vue +4 -3
- package/dist/resources/components/nuxt/site-source/app/components/site/BackToTop.vue +4 -12
- package/dist/resources/components/nuxt/site-source/app/components/site/ConsentSettingsButton.vue +24 -0
- package/dist/resources/components/nuxt/site-source/app/components/site/ContentArticlePage.vue +18 -6
- package/dist/resources/components/nuxt/site-source/app/components/site/ContentListPage.vue +2 -2
- package/dist/resources/components/nuxt/site-source/app/components/site/CookieConsent.vue +44 -4
- package/dist/resources/components/nuxt/site-source/app/components/site/LocaleSwitcher.vue +57 -0
- package/dist/resources/components/nuxt/site-source/app/components/site/MarketingFooter.vue +11 -9
- package/dist/resources/components/nuxt/site-source/app/components/site/MarketingHeader.vue +31 -19
- package/dist/resources/components/nuxt/site-source/app/components/site/MarketingShell.vue +9 -9
- package/dist/resources/components/nuxt/site-source/app/components/site/SiteBrand.vue +3 -2
- package/dist/resources/components/nuxt/site-source/app/components/site/StaticPage.vue +2 -1
- package/dist/resources/components/nuxt/site-source/app/components/site/ThemeSwitcher.vue +63 -0
- package/dist/resources/components/nuxt/site-source/app/components/site/ToastViewport.vue +3 -2
- package/dist/resources/components/nuxt/site-source/app/composables/useConsent.ts +299 -51
- package/dist/resources/components/nuxt/site-source/app/composables/useNavigation.ts +49 -6
- package/dist/resources/components/nuxt/site-source/app/composables/useSeo.ts +4 -2
- package/dist/resources/components/nuxt/site-source/app/composables/useSiteShell.ts +29 -11
- package/dist/resources/components/nuxt/site-source/app/plugins/consent.ts +43 -0
- package/dist/resources/components/nuxt/site-source/app/types/site-shell.ts +146 -59
- package/dist/resources/components/registry.json +30 -0
- package/dist/resources/registry.json +3 -3
- package/dist/resources/templates/nuxt/app/app.config.ts +83 -38
- package/dist/resources/templates/nuxt/app/i18n/locales/en.json +124 -0
- package/dist/resources/templates/nuxt/app/pages/legal/cookies.vue +68 -0
- package/dist/resources/templates/nuxt/nuxt.config.ts +16 -0
- package/dist/resources/templates/nuxt/package.json +2 -2
- package/dist/resources/templates/nuxt/test/unit/consent.test.ts +289 -0
- package/dist/resources/templates/nuxt/vitest.config.ts +6 -0
- package/package.json +3 -3
|
@@ -1,17 +1,57 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
const site = useSiteShell();
|
|
3
3
|
const consent = useConsent();
|
|
4
|
+
const { t } = useI18n();
|
|
5
|
+
|
|
6
|
+
const persistenceMessage = computed(() => {
|
|
7
|
+
if (consent.state.value.error === "read-failed") {
|
|
8
|
+
return t("consent.storageUnavailable");
|
|
9
|
+
}
|
|
10
|
+
if (consent.state.value.error === "clear-failed") {
|
|
11
|
+
return t("consent.storageCleanupFailed");
|
|
12
|
+
}
|
|
13
|
+
if (consent.state.value.persistence === "cleared") {
|
|
14
|
+
return t("consent.storageCleared");
|
|
15
|
+
}
|
|
16
|
+
if (consent.state.value.persistence === "session-only") {
|
|
17
|
+
return t("consent.sessionOnly");
|
|
18
|
+
}
|
|
19
|
+
return "";
|
|
20
|
+
});
|
|
4
21
|
</script>
|
|
5
22
|
|
|
6
23
|
<template>
|
|
7
|
-
<section
|
|
8
|
-
|
|
24
|
+
<section
|
|
25
|
+
v-if="consent.visible.value"
|
|
26
|
+
class="site-consent"
|
|
27
|
+
role="region"
|
|
28
|
+
:aria-label="t('consent.region')"
|
|
29
|
+
>
|
|
30
|
+
<div class="site-consent__header">
|
|
31
|
+
<h2>{{ t("consent.title") }}</h2>
|
|
32
|
+
<button
|
|
33
|
+
v-if="consent.state.value.decision"
|
|
34
|
+
type="button"
|
|
35
|
+
class="site-consent__close"
|
|
36
|
+
:aria-label="t('consent.close')"
|
|
37
|
+
@click="consent.closeSettings"
|
|
38
|
+
>
|
|
39
|
+
<UIcon name="i-lucide-x" aria-hidden="true" />
|
|
40
|
+
</button>
|
|
41
|
+
</div>
|
|
42
|
+
<p class="site-consent__message">{{ t("consent.message") }}</p>
|
|
43
|
+
<NuxtLink class="site-consent__policy" :to="site.consent.policyPath">
|
|
44
|
+
{{ t("consent.policy") }}
|
|
45
|
+
</NuxtLink>
|
|
46
|
+
<p v-if="persistenceMessage" class="site-consent__status" role="status">
|
|
47
|
+
{{ persistenceMessage }}
|
|
48
|
+
</p>
|
|
9
49
|
<div class="site-consent__actions">
|
|
10
50
|
<button type="button" class="base-button base-button--secondary" @click="consent.reject">
|
|
11
|
-
{{
|
|
51
|
+
{{ t("consent.decline") }}
|
|
12
52
|
</button>
|
|
13
53
|
<button type="button" class="base-button base-button--primary" @click="consent.accept">
|
|
14
|
-
{{
|
|
54
|
+
{{ t("consent.allow") }}
|
|
15
55
|
</button>
|
|
16
56
|
</div>
|
|
17
57
|
</section>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const emit = defineEmits<{ changed: [] }>();
|
|
3
|
+
const { locale, locales, setLocale, t } = useI18n();
|
|
4
|
+
const dropdown = ref<HTMLDetailsElement | null>(null);
|
|
5
|
+
|
|
6
|
+
const options = computed(() =>
|
|
7
|
+
locales.value.map((item) => ({
|
|
8
|
+
code: item.code,
|
|
9
|
+
name: item.name || item.code.toUpperCase(),
|
|
10
|
+
})),
|
|
11
|
+
);
|
|
12
|
+
const current = computed(
|
|
13
|
+
() => options.value.find((option) => option.code === locale.value) || options.value[0],
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
async function selectLocale(code: typeof locale.value): Promise<void> {
|
|
17
|
+
dropdown.value?.removeAttribute("open");
|
|
18
|
+
if (code !== locale.value) await setLocale(code);
|
|
19
|
+
emit("changed");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function handleKeydown(event: KeyboardEvent): void {
|
|
23
|
+
if (event.key !== "Escape" || !dropdown.value?.open) return;
|
|
24
|
+
event.preventDefault();
|
|
25
|
+
dropdown.value.open = false;
|
|
26
|
+
dropdown.value.querySelector<HTMLElement>("summary")?.focus();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
onClickOutside(dropdown, () => dropdown.value?.removeAttribute("open"));
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<template>
|
|
33
|
+
<details
|
|
34
|
+
v-if="options.length > 1"
|
|
35
|
+
ref="dropdown"
|
|
36
|
+
class="site-preference"
|
|
37
|
+
@keydown="handleKeydown"
|
|
38
|
+
>
|
|
39
|
+
<summary :aria-label="t('language.current', { language: current?.name || locale })">
|
|
40
|
+
<UIcon name="i-lucide-languages" aria-hidden="true" />
|
|
41
|
+
<span>{{ current?.code.toUpperCase() || locale.toUpperCase() }}</span>
|
|
42
|
+
<UIcon class="site-preference__chevron" name="i-lucide-chevron-down" aria-hidden="true" />
|
|
43
|
+
</summary>
|
|
44
|
+
<div class="site-preference__menu" :aria-label="t('language.select')">
|
|
45
|
+
<button
|
|
46
|
+
v-for="option in options"
|
|
47
|
+
:key="option.code"
|
|
48
|
+
type="button"
|
|
49
|
+
:aria-pressed="option.code === locale"
|
|
50
|
+
@click="selectLocale(option.code)"
|
|
51
|
+
>
|
|
52
|
+
<span>{{ option.name }}</span>
|
|
53
|
+
<UIcon v-if="option.code === locale" name="i-lucide-check" aria-hidden="true" />
|
|
54
|
+
</button>
|
|
55
|
+
</div>
|
|
56
|
+
</details>
|
|
57
|
+
</template>
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
const site = useSiteShell();
|
|
3
3
|
const { footerGroups, legalLinks, socialLinks } = useNavigation();
|
|
4
|
+
const { t } = useI18n();
|
|
4
5
|
const year = new Date().getFullYear();
|
|
5
6
|
</script>
|
|
6
7
|
|
|
7
8
|
<template>
|
|
8
9
|
<footer class="marketing-footer">
|
|
9
10
|
<div class="marketing-footer__inner">
|
|
10
|
-
<section class="marketing-footer__brand" aria-label="
|
|
11
|
+
<section class="marketing-footer__brand" :aria-label="t('site.region')">
|
|
11
12
|
<SiteBrand />
|
|
12
|
-
<p>{{ site.
|
|
13
|
+
<p>{{ t(site.descriptionKey) }}</p>
|
|
13
14
|
<div v-if="socialLinks.length" class="marketing-footer__social">
|
|
14
15
|
<a
|
|
15
16
|
v-for="link in socialLinks"
|
|
@@ -23,7 +24,7 @@ const year = new Date().getFullYear();
|
|
|
23
24
|
</div>
|
|
24
25
|
</section>
|
|
25
26
|
|
|
26
|
-
<nav class="marketing-footer__nav" aria-label="
|
|
27
|
+
<nav class="marketing-footer__nav" :aria-label="t('footer.navigation')">
|
|
27
28
|
<section v-for="group in footerGroups" :key="group.title" class="marketing-footer__group">
|
|
28
29
|
<h2>{{ group.title }}</h2>
|
|
29
30
|
<ul>
|
|
@@ -31,9 +32,9 @@ const year = new Date().getFullYear();
|
|
|
31
32
|
<a v-if="link.external" :href="link.to" target="_blank" rel="noopener noreferrer">
|
|
32
33
|
{{ link.label }}
|
|
33
34
|
</a>
|
|
34
|
-
<
|
|
35
|
+
<NuxtLinkLocale v-else :to="link.to || site.homePath">
|
|
35
36
|
{{ link.label }}
|
|
36
|
-
</
|
|
37
|
+
</NuxtLinkLocale>
|
|
37
38
|
</li>
|
|
38
39
|
</ul>
|
|
39
40
|
</section>
|
|
@@ -41,11 +42,12 @@ const year = new Date().getFullYear();
|
|
|
41
42
|
</div>
|
|
42
43
|
|
|
43
44
|
<div class="marketing-footer__legal">
|
|
44
|
-
<span
|
|
45
|
-
<nav aria-label="
|
|
46
|
-
<
|
|
45
|
+
<span>{{ t("footer.copyright", { year, site: site.name }) }}</span>
|
|
46
|
+
<nav :aria-label="t('footer.legal')">
|
|
47
|
+
<NuxtLinkLocale v-for="link in legalLinks" :key="link.label" :to="link.to">
|
|
47
48
|
{{ link.label }}
|
|
48
|
-
</
|
|
49
|
+
</NuxtLinkLocale>
|
|
50
|
+
<ConsentSettingsButton />
|
|
49
51
|
</nav>
|
|
50
52
|
</div>
|
|
51
53
|
</footer>
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import type { MarketingNavigationItem } from "~/types/site-shell";
|
|
3
3
|
|
|
4
4
|
const site = useSiteShell();
|
|
5
|
-
const { marketingNav } = useNavigation();
|
|
5
|
+
const { marketingNav, primaryActionLabel, secondaryActionLabel } = useNavigation();
|
|
6
|
+
const { t } = useI18n();
|
|
6
7
|
const menuOpen = ref(false);
|
|
7
8
|
|
|
8
9
|
function closeMenu(): void {
|
|
@@ -19,12 +20,12 @@ function hasChildren(item: MarketingNavigationItem): boolean {
|
|
|
19
20
|
<div class="marketing-header__inner">
|
|
20
21
|
<SiteBrand />
|
|
21
22
|
|
|
22
|
-
<nav class="marketing-header__nav" aria-label="
|
|
23
|
+
<nav class="marketing-header__nav" :aria-label="t('navigation.primary')">
|
|
23
24
|
<template v-for="item in marketingNav" :key="item.label">
|
|
24
25
|
<details v-if="hasChildren(item)" class="marketing-nav-menu">
|
|
25
26
|
<summary>{{ item.label }}</summary>
|
|
26
27
|
<div class="marketing-nav-menu__panel">
|
|
27
|
-
<
|
|
28
|
+
<NuxtLinkLocale
|
|
28
29
|
v-for="child in item.children"
|
|
29
30
|
:key="child.label"
|
|
30
31
|
:to="child.to || site.homePath"
|
|
@@ -32,7 +33,7 @@ function hasChildren(item: MarketingNavigationItem): boolean {
|
|
|
32
33
|
>
|
|
33
34
|
<span>{{ child.label }}</span>
|
|
34
35
|
<small v-if="child.description">{{ child.description }}</small>
|
|
35
|
-
</
|
|
36
|
+
</NuxtLinkLocale>
|
|
36
37
|
</div>
|
|
37
38
|
</details>
|
|
38
39
|
<a
|
|
@@ -44,31 +45,38 @@ function hasChildren(item: MarketingNavigationItem): boolean {
|
|
|
44
45
|
>
|
|
45
46
|
{{ item.label }}
|
|
46
47
|
</a>
|
|
47
|
-
<
|
|
48
|
+
<NuxtLinkLocale v-else class="marketing-nav-link" :to="item.to || site.homePath">
|
|
48
49
|
{{ item.label }}
|
|
49
|
-
</
|
|
50
|
+
</NuxtLinkLocale>
|
|
50
51
|
</template>
|
|
51
52
|
</nav>
|
|
52
53
|
|
|
53
54
|
<div class="marketing-header__actions">
|
|
55
|
+
<div class="marketing-header__preferences">
|
|
56
|
+
<ThemeSwitcher />
|
|
57
|
+
<LocaleSwitcher />
|
|
58
|
+
</div>
|
|
54
59
|
<slot name="actions">
|
|
55
|
-
<
|
|
56
|
-
{{
|
|
57
|
-
</
|
|
58
|
-
<
|
|
59
|
-
{{
|
|
60
|
+
<NuxtLinkLocale :to="site.secondaryActionPath" class="base-button base-button--secondary">
|
|
61
|
+
{{ secondaryActionLabel }}
|
|
62
|
+
</NuxtLinkLocale>
|
|
63
|
+
<NuxtLinkLocale :to="site.appPath" class="base-button base-button--primary">
|
|
64
|
+
{{ primaryActionLabel }}
|
|
60
65
|
<UIcon name="i-lucide-arrow-right" />
|
|
61
|
-
</
|
|
66
|
+
</NuxtLinkLocale>
|
|
62
67
|
</slot>
|
|
63
68
|
<button
|
|
64
69
|
type="button"
|
|
65
70
|
class="icon-button marketing-header__menu-button"
|
|
66
71
|
:aria-expanded="menuOpen"
|
|
67
72
|
aria-controls="mobile-nav"
|
|
68
|
-
aria-label="
|
|
73
|
+
:aria-label="t('navigation.toggle')"
|
|
69
74
|
@click="menuOpen = !menuOpen"
|
|
70
75
|
>
|
|
71
|
-
<UIcon
|
|
76
|
+
<UIcon
|
|
77
|
+
:name="menuOpen ? 'i-lucide-x' : 'i-lucide-menu'"
|
|
78
|
+
class="marketing-header__menu-icon"
|
|
79
|
+
/>
|
|
72
80
|
</button>
|
|
73
81
|
</div>
|
|
74
82
|
</div>
|
|
@@ -77,20 +85,20 @@ function hasChildren(item: MarketingNavigationItem): boolean {
|
|
|
77
85
|
id="mobile-nav"
|
|
78
86
|
class="mobile-nav"
|
|
79
87
|
:class="{ 'mobile-nav--open': menuOpen }"
|
|
80
|
-
aria-label="
|
|
88
|
+
:aria-label="t('navigation.mobilePrimary')"
|
|
81
89
|
>
|
|
82
90
|
<template v-for="item in marketingNav" :key="item.label">
|
|
83
|
-
<
|
|
91
|
+
<NuxtLinkLocale
|
|
84
92
|
v-if="!hasChildren(item)"
|
|
85
93
|
:to="item.to || site.homePath"
|
|
86
94
|
class="mobile-nav__link"
|
|
87
95
|
@click="closeMenu"
|
|
88
96
|
>
|
|
89
97
|
{{ item.label }}
|
|
90
|
-
</
|
|
98
|
+
</NuxtLinkLocale>
|
|
91
99
|
<details v-else class="mobile-nav__group">
|
|
92
100
|
<summary>{{ item.label }}</summary>
|
|
93
|
-
<
|
|
101
|
+
<NuxtLinkLocale
|
|
94
102
|
v-for="child in item.children"
|
|
95
103
|
:key="child.label"
|
|
96
104
|
:to="child.to || site.homePath"
|
|
@@ -98,9 +106,13 @@ function hasChildren(item: MarketingNavigationItem): boolean {
|
|
|
98
106
|
@click="closeMenu"
|
|
99
107
|
>
|
|
100
108
|
{{ child.label }}
|
|
101
|
-
</
|
|
109
|
+
</NuxtLinkLocale>
|
|
102
110
|
</details>
|
|
103
111
|
</template>
|
|
112
|
+
<div class="mobile-nav__preferences">
|
|
113
|
+
<ThemeSwitcher @changed="closeMenu" />
|
|
114
|
+
<LocaleSwitcher @changed="closeMenu" />
|
|
115
|
+
</div>
|
|
104
116
|
</nav>
|
|
105
117
|
</header>
|
|
106
118
|
</template>
|
|
@@ -7,9 +7,6 @@ const LazyMarketingFooter = defineLazyHydrationComponent(
|
|
|
7
7
|
"visible",
|
|
8
8
|
() => import("./MarketingFooter.vue"),
|
|
9
9
|
);
|
|
10
|
-
|
|
11
|
-
const consent = useConsent();
|
|
12
|
-
const backToTopOffset = computed(() => (consent.visible.value ? 104 : 24));
|
|
13
10
|
</script>
|
|
14
11
|
|
|
15
12
|
<template>
|
|
@@ -18,7 +15,7 @@ const backToTopOffset = computed(() => (consent.visible.value ? 104 : 24));
|
|
|
18
15
|
<LazyMarketingHeader :hydrate-on-idle="800" />
|
|
19
16
|
</slot>
|
|
20
17
|
|
|
21
|
-
<main class="marketing-shell__main">
|
|
18
|
+
<main id="site-content" class="marketing-shell__main">
|
|
22
19
|
<slot />
|
|
23
20
|
</main>
|
|
24
21
|
|
|
@@ -26,10 +23,13 @@ const backToTopOffset = computed(() => (consent.visible.value ? 104 : 24));
|
|
|
26
23
|
<LazyMarketingFooter hydrate-on-visible />
|
|
27
24
|
</slot>
|
|
28
25
|
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
26
|
+
<div class="site-floating-stack">
|
|
27
|
+
<BackToTop />
|
|
28
|
+
<slot name="consent">
|
|
29
|
+
<ClientOnly>
|
|
30
|
+
<CookieConsent />
|
|
31
|
+
</ClientOnly>
|
|
32
|
+
</slot>
|
|
33
|
+
</div>
|
|
34
34
|
</div>
|
|
35
35
|
</template>
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
const site = useSiteShell();
|
|
3
|
+
const { t } = useI18n();
|
|
3
4
|
</script>
|
|
4
5
|
|
|
5
6
|
<template>
|
|
6
|
-
<
|
|
7
|
+
<NuxtLinkLocale :to="site.homePath" class="site-brand" :aria-label="t('site.home')">
|
|
7
8
|
<span class="site-brand__mark" aria-hidden="true">{{ site.logoText.slice(0, 1) }}</span>
|
|
8
9
|
<span class="site-brand__text">{{ site.logoText }}</span>
|
|
9
|
-
</
|
|
10
|
+
</NuxtLinkLocale>
|
|
10
11
|
</template>
|
|
@@ -4,6 +4,7 @@ defineProps<{
|
|
|
4
4
|
description?: string;
|
|
5
5
|
updatedAt?: string;
|
|
6
6
|
}>();
|
|
7
|
+
const { t } = useI18n();
|
|
7
8
|
</script>
|
|
8
9
|
|
|
9
10
|
<template>
|
|
@@ -11,7 +12,7 @@ defineProps<{
|
|
|
11
12
|
<header class="page-heading">
|
|
12
13
|
<h1>{{ title }}</h1>
|
|
13
14
|
<p v-if="description">{{ description }}</p>
|
|
14
|
-
<span v-if="updatedAt">
|
|
15
|
+
<span v-if="updatedAt">{{ t("content.updated", { date: updatedAt }) }}</span>
|
|
15
16
|
</header>
|
|
16
17
|
|
|
17
18
|
<div class="static-page__body">
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
type ThemePreference = "system" | "light" | "dark";
|
|
3
|
+
|
|
4
|
+
const emit = defineEmits<{ changed: [] }>();
|
|
5
|
+
const colorMode = useColorMode();
|
|
6
|
+
const { t } = useI18n();
|
|
7
|
+
const dropdown = ref<HTMLDetailsElement | null>(null);
|
|
8
|
+
const options = [
|
|
9
|
+
{ value: "system", labelKey: "theme.system", icon: "i-lucide-monitor" },
|
|
10
|
+
{ value: "light", labelKey: "theme.light", icon: "i-lucide-sun" },
|
|
11
|
+
{ value: "dark", labelKey: "theme.dark", icon: "i-lucide-moon" },
|
|
12
|
+
] as const satisfies readonly { value: ThemePreference; labelKey: string; icon: string }[];
|
|
13
|
+
const preference = computed<ThemePreference>(() => {
|
|
14
|
+
const value = colorMode.preference;
|
|
15
|
+
return value === "light" || value === "dark" ? value : "system";
|
|
16
|
+
});
|
|
17
|
+
const current = computed(
|
|
18
|
+
() => options.find((option) => option.value === preference.value) ?? options[0],
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
function selectTheme(value: ThemePreference): void {
|
|
22
|
+
colorMode.preference = value;
|
|
23
|
+
dropdown.value?.removeAttribute("open");
|
|
24
|
+
emit("changed");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function handleKeydown(event: KeyboardEvent): void {
|
|
28
|
+
if (event.key !== "Escape" || !dropdown.value?.open) return;
|
|
29
|
+
event.preventDefault();
|
|
30
|
+
dropdown.value.open = false;
|
|
31
|
+
dropdown.value.querySelector<HTMLElement>("summary")?.focus();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
onClickOutside(dropdown, () => dropdown.value?.removeAttribute("open"));
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<details ref="dropdown" class="site-preference" @keydown="handleKeydown">
|
|
39
|
+
<summary :aria-label="t('theme.current', { theme: t(current.labelKey) })">
|
|
40
|
+
<UIcon :name="current.icon" aria-hidden="true" />
|
|
41
|
+
<span class="site-preference__current">{{ t(current.labelKey) }}</span>
|
|
42
|
+
<UIcon class="site-preference__chevron" name="i-lucide-chevron-down" aria-hidden="true" />
|
|
43
|
+
</summary>
|
|
44
|
+
<div class="site-preference__menu" :aria-label="t('theme.select')">
|
|
45
|
+
<button
|
|
46
|
+
v-for="option in options"
|
|
47
|
+
:key="option.value"
|
|
48
|
+
type="button"
|
|
49
|
+
:aria-pressed="option.value === preference"
|
|
50
|
+
@click="selectTheme(option.value)"
|
|
51
|
+
>
|
|
52
|
+
<UIcon :name="option.icon" aria-hidden="true" />
|
|
53
|
+
<span>{{ t(option.labelKey) }}</span>
|
|
54
|
+
<UIcon
|
|
55
|
+
v-if="option.value === preference"
|
|
56
|
+
class="site-preference__check"
|
|
57
|
+
name="i-lucide-check"
|
|
58
|
+
aria-hidden="true"
|
|
59
|
+
/>
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
</details>
|
|
63
|
+
</template>
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
const { toasts, dismiss } = useToasts();
|
|
3
|
+
const { t } = useI18n();
|
|
3
4
|
</script>
|
|
4
5
|
|
|
5
6
|
<template>
|
|
6
|
-
<div class="toast-viewport" role="region" aria-label="
|
|
7
|
+
<div class="toast-viewport" role="region" :aria-label="t('notifications.region')">
|
|
7
8
|
<TransitionGroup name="site-toast" tag="div" class="toast-viewport__stack">
|
|
8
9
|
<section
|
|
9
10
|
v-for="toast in toasts"
|
|
@@ -16,7 +17,7 @@ const { toasts, dismiss } = useToasts();
|
|
|
16
17
|
<h2>{{ toast.title }}</h2>
|
|
17
18
|
<p v-if="toast.description">{{ toast.description }}</p>
|
|
18
19
|
</div>
|
|
19
|
-
<button type="button" aria-label="
|
|
20
|
+
<button type="button" :aria-label="t('notifications.dismiss')" @click="dismiss(toast.id)">
|
|
20
21
|
×
|
|
21
22
|
</button>
|
|
22
23
|
</section>
|