@brumaombra/ui-vintage 0.8.0 → 0.8.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/COMPONENTS.md CHANGED
@@ -20,7 +20,7 @@ This file lists every public entry point of the package with its components, pro
20
20
  - **Data display:** [animated-number](#brumaombraui-vintageanimated-number), [avatar](#brumaombraui-vintageavatar), [badge](#brumaombraui-vintagebadge), [card](#brumaombraui-vintagecard), [card-grid](#brumaombraui-vintagecard-grid), [chip](#brumaombraui-vintagechip), [data-list](#brumaombraui-vintagedata-list), [data-table](#brumaombraui-vintagedata-table), [info-card](#brumaombraui-vintageinfo-card), [progress](#brumaombraui-vintageprogress), [progress-component](#brumaombraui-vintageprogress-component), [scroll-area](#brumaombraui-vintagescroll-area), [separator](#brumaombraui-vintageseparator), [single-value-card](#brumaombraui-vintagesingle-value-card), [skeleton](#brumaombraui-vintageskeleton), [table](#brumaombraui-vintagetable)
21
21
  - **Navigation:** [accordion](#brumaombraui-vintageaccordion), [breadcrumb](#brumaombraui-vintagebreadcrumb), [collapsible](#brumaombraui-vintagecollapsible), [pagination](#brumaombraui-vintagepagination), [sidebar](#brumaombraui-vintagesidebar), [stepper](#brumaombraui-vintagestepper), [tabs](#brumaombraui-vintagetabs)
22
22
  - **Overlays:** [alert-dialog](#brumaombraui-vintagealert-dialog), [command](#brumaombraui-vintagecommand), [dialog](#brumaombraui-vintagedialog), [hover-card](#brumaombraui-vintagehover-card), [popover](#brumaombraui-vintagepopover), [sheet](#brumaombraui-vintagesheet), [tooltip](#brumaombraui-vintagetooltip)
23
- - **Feedback:** [alert](#brumaombraui-vintagealert), [busy](#brumaombraui-vintagebusy), [busy-indicator](#brumaombraui-vintagebusy-indicator), [confirm-dialog](#brumaombraui-vintageconfirm-dialog), [empty-state-card](#brumaombraui-vintageempty-state-card), [loading-state-card](#brumaombraui-vintageloading-state-card), [message-dialog](#brumaombraui-vintagemessage-dialog), [message-toast](#brumaombraui-vintagemessage-toast), [spinner](#brumaombraui-vintagespinner)
23
+ - **Feedback:** [alert](#brumaombraui-vintagealert), [busy](#brumaombraui-vintagebusy), [busy-indicator](#brumaombraui-vintagebusy-indicator), [confirm-dialog](#brumaombraui-vintageconfirm-dialog), [cookie-consent](#brumaombraui-vintagecookie-consent), [empty-state-card](#brumaombraui-vintageempty-state-card), [loading-state-card](#brumaombraui-vintageloading-state-card), [message-dialog](#brumaombraui-vintagemessage-dialog), [message-toast](#brumaombraui-vintagemessage-toast), [spinner](#brumaombraui-vintagespinner)
24
24
  - **Layouts and app shell:** [background-grid](#brumaombraui-vintagebackground-grid), [dashboard-shell](#brumaombraui-vintagedashboard-shell), [error-page](#brumaombraui-vintageerror-page), [landing](#brumaombraui-vintagelanding), [landing-content](#brumaombraui-vintagelanding-content), [landing-footer](#brumaombraui-vintagelanding-footer), [landing-navbar](#brumaombraui-vintagelanding-navbar), [landing-shell](#brumaombraui-vintagelanding-shell), [language-flag](#brumaombraui-vintagelanguage-flag), [language-selector](#brumaombraui-vintagelanguage-selector), [page-header](#brumaombraui-vintagepage-header), [theme-selector](#brumaombraui-vintagetheme-selector)
25
25
  - **Content and blog:** [blog](#brumaombraui-vintageblog), [content](#brumaombraui-vintagecontent)
26
26
  - **Utilities and types:** [common-types](#brumaombraui-vintagecommon-types), [utils](#brumaombraui-vintageutils)
@@ -2814,6 +2814,47 @@ interface ShowConfirmDialogOptions {
2814
2814
 
2815
2815
  ---
2816
2816
 
2817
+ ### `@brumaombra/ui-vintage/cookie-consent`
2818
+
2819
+ Cookie consent banner fixed to the bottom of the page, shown in the browser until the visitor accepts or declines. The choice is saved in `localStorage` under `storage-key`; `useCookieConsent(storageKey)` shares it, so optional scripts (analytics, ads) can wait for `consent.value === "granted"` and a settings page can call `reset()` to ask again. Texts have translated defaults, `policy-to` adds a link to the cookie policy.
2820
+
2821
+ ```ts
2822
+ import { CookieConsent, useCookieConsent } from '@brumaombra/ui-vintage/cookie-consent';
2823
+ import type { CookieConsentValue } from '@brumaombra/ui-vintage/cookie-consent';
2824
+ ```
2825
+
2826
+ ```vue
2827
+ <CookieConsent storage-key="analyticsConsent" policy-to="/cookie-policy" @accept="loadAnalytics" />
2828
+
2829
+ <script setup>
2830
+ const { consent, reset } = useCookieConsent('analyticsConsent');
2831
+ watch(consent, value => value === 'granted' && loadAnalytics(), { immediate: true });
2832
+ </script>
2833
+ ```
2834
+
2835
+ #### `CookieConsent`
2836
+
2837
+ | Prop | Type | Default |
2838
+ | --- | --- | --- |
2839
+ | `storageKey` | `string` | `'cookieConsent'` |
2840
+ | `message` | `string` | `undefined` |
2841
+ | `policyTo` | `string` | `undefined` |
2842
+ | `policyLabel` | `string` | `undefined` |
2843
+ | `acceptLabel` | `string` | `undefined` |
2844
+ | `declineLabel` | `string` | `undefined` |
2845
+ | `class` | `HTMLAttributes['class']` | `undefined` |
2846
+
2847
+ - **Emits:** `accept`, `decline`.
2848
+
2849
+ #### Functions, constants, and types
2850
+
2851
+ ```ts
2852
+ function useCookieConsent(storageKey = 'cookieConsent')
2853
+ type CookieConsentValue = 'granted' | 'denied' | null
2854
+ ```
2855
+
2856
+ ---
2857
+
2817
2858
  ### `@brumaombra/ui-vintage/empty-state-card`
2818
2859
 
2819
2860
  Empty state with an icon, title, description, and an `action` slot. Falls back to localized default copy.
package/README.md CHANGED
@@ -358,7 +358,7 @@ There is no required library build step before publishing. The normal verificati
358
358
  npm run typecheck
359
359
  ```
360
360
 
361
- For release details, see [NPM.md](NPM.md). The package is published under the public npm scope `@brumaombra/ui-vintage`.
361
+ For the release process, see the project skill in [`.claude/skills/publish-npm/SKILL.md`](.claude/skills/publish-npm/SKILL.md). The package is published under the public npm scope `@brumaombra/ui-vintage`.
362
362
 
363
363
  <a id="requirements"></a>
364
364
  ## 🧰 Requirements
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brumaombra/ui-vintage",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Source-published Nuxt UI runtime with shared theme tokens and built-in Nuxt integrations.",
5
5
  "private": false,
6
6
  "author": "Mauro Brambilla",
@@ -59,6 +59,7 @@
59
59
  "./command": "./src/components/ui/command/index.ts",
60
60
  "./common-types": "./src/lib/common-types.ts",
61
61
  "./confirm-dialog": "./src/components/confirm-dialog/index.ts",
62
+ "./cookie-consent": "./src/components/cookie-consent/index.ts",
62
63
  "./content": "./src/components/content/index.ts",
63
64
  "./dashboard-shell": "./src/components/dashboard-shell/index.ts",
64
65
  "./data-list": "./src/components/data-list/index.ts",
@@ -0,0 +1,82 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref, type HTMLAttributes } from 'vue';
3
+ import { useI18n } from 'vue-i18n';
4
+ import { Button } from '../ui/button';
5
+ import { Card } from '../ui/card';
6
+ import { cn } from '../../lib/utils';
7
+ import { useCookieConsent } from './useCookieConsent';
8
+
9
+ // Extra attributes go on the banner (the root is the Teleport)
10
+ defineOptions({ inheritAttrs: false });
11
+
12
+ // Props
13
+ const props = withDefaults(defineProps<{
14
+ storageKey?: string;
15
+ message?: string;
16
+ policyTo?: string;
17
+ policyLabel?: string;
18
+ acceptLabel?: string;
19
+ declineLabel?: string;
20
+ class?: HTMLAttributes['class'];
21
+ }>(), {
22
+ storageKey: 'cookieConsent',
23
+ message: undefined,
24
+ policyTo: undefined,
25
+ policyLabel: undefined,
26
+ acceptLabel: undefined,
27
+ declineLabel: undefined,
28
+ class: undefined
29
+ });
30
+
31
+ // Emits
32
+ const emit = defineEmits<{
33
+ accept: [];
34
+ decline: [];
35
+ }>();
36
+
37
+ const { t } = useI18n();
38
+ const { consent, accept, decline } = useCookieConsent(props.storageKey);
39
+ const hasMounted = ref(false);
40
+
41
+ // Shown in the browser only, until the visitor chooses (the stored choice is not known on the server)
42
+ const isVisible = computed(() => hasMounted.value && consent.value === null);
43
+
44
+ // Texts, with translated defaults
45
+ const resolvedMessage = computed(() => props.message || t('uiVintage.cookieConsent.message'));
46
+ const resolvedPolicyLabel = computed(() => props.policyLabel || t('uiVintage.cookieConsent.policy'));
47
+ const resolvedAcceptLabel = computed(() => props.acceptLabel || t('uiVintage.cookieConsent.accept'));
48
+ const resolvedDeclineLabel = computed(() => props.declineLabel || t('uiVintage.cookieConsent.decline'));
49
+
50
+ // Save the choice and notify
51
+ const handleAccept = () => {
52
+ accept();
53
+ emit('accept');
54
+ };
55
+ const handleDecline = () => {
56
+ decline();
57
+ emit('decline');
58
+ };
59
+
60
+ // Wait for the browser before showing the banner
61
+ onMounted(() => {
62
+ hasMounted.value = true;
63
+ });
64
+ </script>
65
+
66
+ <template>
67
+ <!-- Rendered in the body: a transformed or filtered ancestor (page transitions, reveal animations) would otherwise pin the fixed banner to itself instead of the viewport -->
68
+ <Teleport to="body">
69
+ <div v-if="isVisible" v-bind="$attrs" data-slot="cookie-consent" role="region" :aria-label="t('uiVintage.cookieConsent.region')" :class="cn('fixed inset-x-4 bottom-4 z-50 sm:left-auto sm:right-4 sm:max-w-md', props.class)">
70
+ <Card class="gap-3! p-4 shadow-elevated-sm">
71
+ <p class="text-sm text-foreground">
72
+ {{ resolvedMessage }}
73
+ <NuxtLinkLocale v-if="props.policyTo" :to="props.policyTo" class="font-bold underline underline-offset-2">{{ resolvedPolicyLabel }}</NuxtLinkLocale>
74
+ </p>
75
+ <div class="flex flex-wrap justify-end gap-2">
76
+ <Button variant="secondary" size="sm" @click="handleDecline">{{ resolvedDeclineLabel }}</Button>
77
+ <Button variant="primary" size="sm" @click="handleAccept">{{ resolvedAcceptLabel }}</Button>
78
+ </div>
79
+ </Card>
80
+ </div>
81
+ </Teleport>
82
+ </template>
@@ -0,0 +1,3 @@
1
+ export { default } from './CookieConsent.vue';
2
+ export { default as CookieConsent } from './CookieConsent.vue';
3
+ export { useCookieConsent, type CookieConsentValue } from './useCookieConsent';
@@ -0,0 +1,55 @@
1
+ import { ref, type Ref } from 'vue';
2
+
3
+ // The visitor's choice: 'granted', 'denied' or null (not asked yet)
4
+ export type CookieConsentValue = 'granted' | 'denied' | null;
5
+
6
+ // One shared state per storage key, so the banner and the code loading the scripts stay in sync (browser only)
7
+ const consentStates = new Map<string, Ref<CookieConsentValue>>();
8
+
9
+ // Read the stored choice (storage can be unavailable, e.g. blocked in private windows)
10
+ const readStoredConsent = (storageKey: string): CookieConsentValue => {
11
+ try {
12
+ const value = localStorage.getItem(storageKey);
13
+ return value === 'granted' || value === 'denied' ? value : null;
14
+ } catch {
15
+ return null;
16
+ }
17
+ };
18
+
19
+ // Cookie consent state: gate optional scripts (analytics, ads) on `consent.value === 'granted'` and call `reset()` to ask again
20
+ export const useCookieConsent = (storageKey = 'cookieConsent') => {
21
+ // On the server there is no stored choice (and no state must be shared between requests)
22
+ if (typeof window === 'undefined') {
23
+ const consent = ref<CookieConsentValue>(null);
24
+ const noop = () => {};
25
+ return { consent, accept: noop, decline: noop, reset: noop };
26
+ }
27
+
28
+ // Shared state for this key
29
+ let consent = consentStates.get(storageKey);
30
+ if (!consent) {
31
+ consent = ref(readStoredConsent(storageKey));
32
+ consentStates.set(storageKey, consent);
33
+ }
34
+
35
+ // Save a choice (null removes it, so the banner asks again)
36
+ const state = consent;
37
+ const setConsent = (value: CookieConsentValue) => {
38
+ state.value = value;
39
+ try {
40
+ if (value) {
41
+ localStorage.setItem(storageKey, value);
42
+ } else {
43
+ localStorage.removeItem(storageKey);
44
+ }
45
+ } catch { }
46
+ };
47
+
48
+ // Return the state and the actions
49
+ return {
50
+ consent: state,
51
+ accept: () => setConsent('granted'),
52
+ decline: () => setConsent('denied'),
53
+ reset: () => setConsent(null)
54
+ };
55
+ };
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { HTMLAttributes } from 'vue';
3
- import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
3
+ import { computed, getCurrentInstance, onBeforeMount, onBeforeUnmount, onMounted, ref, watch } from 'vue';
4
4
  import { cn } from '../../../lib/utils';
5
5
 
6
6
  // Props
@@ -22,8 +22,12 @@ const props = withDefaults(defineProps<{
22
22
  animateOnMount: true
23
23
  });
24
24
 
25
- const displayValue = ref(props.animateOnMount ? 0 : props.value);
25
+ // The server always renders the final value (search engines, link previews and the first paint never see a 0), the count-up only runs for components created in the browser
26
+ const isServer = typeof window === 'undefined';
27
+ const displayValue = ref(props.animateOnMount && !isServer ? 0 : props.value);
26
28
  const direction = ref<'up' | 'down' | null>(null);
29
+ const instance = getCurrentInstance();
30
+ let isHydrated = false;
27
31
  let frame: number | null = null;
28
32
 
29
33
  // Format the displayed value
@@ -34,6 +38,9 @@ const formatter = computed(() => {
34
38
  });
35
39
  const formattedValue = computed(() => `${props.prefix}${formatter.value.format(displayValue.value)}${props.suffix}`);
36
40
 
41
+ // Final value announced to screen readers from visually hidden text (the animated digits are hidden from them, and aria-label is not allowed on a span without a role)
42
+ const accessibleValue = computed(() => `${props.prefix}${formatter.value.format(props.value)}${props.suffix}`);
43
+
37
44
  // Decelerating curve so the count settles gently on the final value
38
45
  const easeOutExpo = (progress: number) => progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress);
39
46
 
@@ -70,9 +77,15 @@ const animateTo = (target: number) => {
70
77
  // Re-animate whenever the value changes
71
78
  watch(() => props.value, value => animateTo(value));
72
79
 
73
- // Count up on mount
80
+ // When hydrating server HTML (the element already exists before the first render), keep the final value the server rendered, so there is no mismatch and no flash to 0
81
+ onBeforeMount(() => {
82
+ isHydrated = !!instance?.vnode.el;
83
+ if (isHydrated) displayValue.value = props.value;
84
+ });
85
+
86
+ // Count up on mount (only for components created in the browser, server-rendered ones already show the final value)
74
87
  onMounted(() => {
75
- if (props.animateOnMount) animateTo(props.value);
88
+ if (props.animateOnMount && !isHydrated) animateTo(props.value);
76
89
  });
77
90
 
78
91
  // Stop any pending frame
@@ -82,7 +95,8 @@ onBeforeUnmount(() => {
82
95
  </script>
83
96
 
84
97
  <template>
85
- <span data-slot="animated-number" :data-direction="direction ?? undefined" :aria-label="`${props.prefix}${formatter.format(props.value)}${props.suffix}`" :class="cn('inline-block tabular-nums', props.class)">
98
+ <span data-slot="animated-number" :data-direction="direction ?? undefined" :class="cn('inline-block tabular-nums', props.class)">
86
99
  <span aria-hidden="true">{{ formattedValue }}</span>
100
+ <span class="sr-only">{{ accessibleValue }}</span>
87
101
  </span>
88
102
  </template>
package/src/i18n/de.json CHANGED
@@ -118,6 +118,13 @@
118
118
  "toast": {
119
119
  "region": "Benachrichtigungen"
120
120
  },
121
+ "cookieConsent": {
122
+ "region": "Cookie-Einwilligung",
123
+ "message": "Wir verwenden optionale Cookies, um zu verstehen, wie die Website genutzt wird. Sie werden nur gesetzt, wenn du zustimmst.",
124
+ "policy": "Cookie-Richtlinie",
125
+ "accept": "Akzeptieren",
126
+ "decline": "Ablehnen"
127
+ },
121
128
  "dataTable": {
122
129
  "selectAll": "Alle Zeilen auswählen",
123
130
  "selectRow": "Zeile auswählen"
package/src/i18n/en.json CHANGED
@@ -118,6 +118,13 @@
118
118
  "toast": {
119
119
  "region": "Notifications"
120
120
  },
121
+ "cookieConsent": {
122
+ "region": "Cookie consent",
123
+ "message": "We use optional cookies to understand how the site is used. They are only set if you accept.",
124
+ "policy": "Cookie policy",
125
+ "accept": "Accept",
126
+ "decline": "Decline"
127
+ },
121
128
  "dataTable": {
122
129
  "selectAll": "Select all rows",
123
130
  "selectRow": "Select row"
package/src/i18n/es.json CHANGED
@@ -118,6 +118,13 @@
118
118
  "toast": {
119
119
  "region": "Notificaciones"
120
120
  },
121
+ "cookieConsent": {
122
+ "region": "Consentimiento de cookies",
123
+ "message": "Usamos cookies opcionales para entender cómo se usa el sitio. Solo se instalan si aceptas.",
124
+ "policy": "Política de cookies",
125
+ "accept": "Aceptar",
126
+ "decline": "Rechazar"
127
+ },
121
128
  "dataTable": {
122
129
  "selectAll": "Seleccionar todas las filas",
123
130
  "selectRow": "Seleccionar fila"
package/src/i18n/fr.json CHANGED
@@ -118,6 +118,13 @@
118
118
  "toast": {
119
119
  "region": "Notifications"
120
120
  },
121
+ "cookieConsent": {
122
+ "region": "Consentement aux cookies",
123
+ "message": "Nous utilisons des cookies facultatifs pour comprendre comment le site est utilisé. Ils ne sont déposés que si vous acceptez.",
124
+ "policy": "Politique relative aux cookies",
125
+ "accept": "Accepter",
126
+ "decline": "Refuser"
127
+ },
121
128
  "dataTable": {
122
129
  "selectAll": "Sélectionner toutes les lignes",
123
130
  "selectRow": "Sélectionner la ligne"
package/src/i18n/it.json CHANGED
@@ -118,6 +118,13 @@
118
118
  "toast": {
119
119
  "region": "Notifiche"
120
120
  },
121
+ "cookieConsent": {
122
+ "region": "Consenso ai cookie",
123
+ "message": "Usiamo cookie facoltativi per capire come viene usato il sito. Vengono impostati solo se accetti.",
124
+ "policy": "Politica sui cookie",
125
+ "accept": "Accetta",
126
+ "decline": "Rifiuta"
127
+ },
121
128
  "dataTable": {
122
129
  "selectAll": "Seleziona tutte le righe",
123
130
  "selectRow": "Seleziona riga"
package/src/i18n/ja.json CHANGED
@@ -118,6 +118,13 @@
118
118
  "toast": {
119
119
  "region": "通知"
120
120
  },
121
+ "cookieConsent": {
122
+ "region": "Cookieの同意",
123
+ "message": "サイトの利用状況を把握するため、任意のCookieを使用しています。同意した場合にのみ設定されます。",
124
+ "policy": "Cookieポリシー",
125
+ "accept": "同意する",
126
+ "decline": "拒否する"
127
+ },
121
128
  "dataTable": {
122
129
  "selectAll": "すべての行を選択",
123
130
  "selectRow": "行を選択"
package/src/i18n/pt.json CHANGED
@@ -118,6 +118,13 @@
118
118
  "toast": {
119
119
  "region": "Notificações"
120
120
  },
121
+ "cookieConsent": {
122
+ "region": "Consentimento de cookies",
123
+ "message": "Usamos cookies opcionais para entender como o site é usado. Eles só são definidos se você aceitar.",
124
+ "policy": "Política de cookies",
125
+ "accept": "Aceitar",
126
+ "decline": "Recusar"
127
+ },
121
128
  "dataTable": {
122
129
  "selectAll": "Selecionar todas as linhas",
123
130
  "selectRow": "Selecionar linha"
package/src/i18n/ru.json CHANGED
@@ -118,6 +118,13 @@
118
118
  "toast": {
119
119
  "region": "Уведомления"
120
120
  },
121
+ "cookieConsent": {
122
+ "region": "Согласие на cookie",
123
+ "message": "Мы используем необязательные файлы cookie, чтобы понимать, как используется сайт. Они устанавливаются только с вашего согласия.",
124
+ "policy": "Политика cookie",
125
+ "accept": "Принять",
126
+ "decline": "Отклонить"
127
+ },
121
128
  "dataTable": {
122
129
  "selectAll": "Выбрать все строки",
123
130
  "selectRow": "Выбрать строку"
package/src/i18n/zh.json CHANGED
@@ -118,6 +118,13 @@
118
118
  "toast": {
119
119
  "region": "通知"
120
120
  },
121
+ "cookieConsent": {
122
+ "region": "Cookie 同意",
123
+ "message": "我们使用可选的 Cookie 来了解网站的使用情况。只有在您同意后才会设置。",
124
+ "policy": "Cookie 政策",
125
+ "accept": "接受",
126
+ "decline": "拒绝"
127
+ },
121
128
  "dataTable": {
122
129
  "selectAll": "全选所有行",
123
130
  "selectRow": "选择行"