Skip to content
Merged
4 changes: 4 additions & 0 deletions docs/content/docs/2.components/banner.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ name: 'banner-example'
When closed, `banner-${id}` will be stored in the local storage to prevent it from being displayed again. :br For the example above, `banner-example` will be stored in the local storage.
::

::caution
To persist the dismissed state across page reloads, you must specify an `id` prop. Without an explicit `id`, the banner will only be hidden for the current session and will reappear on page reload.
::

### Close Icon

Use the `close-icon` prop to customize the close button [Icon](/docs/components/icon). Defaults to `i-lucide-x`.
Expand Down
11 changes: 11 additions & 0 deletions playgrounds/nuxt/app/pages/components/banner.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,16 @@ const attrs = reactive({
}]"
v-bind="props"
/>
<UBanner
title="Close me - I'll reappear on page reload (no id prop)"
:close="true"
v-bind="props"
/>
<UBanner
id="banner3"
title="Close me - I'll stay closed permanently (id='banner3')"
:close="true"
v-bind="props"
/>
</Matrix>
</template>
81 changes: 52 additions & 29 deletions src/runtime/components/Banner.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ export interface BannerProps {
as?: any
/**
* A unique id saved to local storage to remember if the banner has been dismissed.
* Change this value to show the banner again.
* @defaultValue '1'
* Without an explicit id, the banner will not be persisted and will reappear on page reload.
*/
id?: string
/**
Expand Down Expand Up @@ -65,7 +64,7 @@ export interface BannerEmits {
</script>

<script setup lang="ts">
import { computed, watch } from 'vue'
import { computed, ref, onMounted, useId } from 'vue'
import { Primitive } from 'reka-ui'
import { useHead, useAppConfig } from '#imports'
import { useLocale } from '../composables/useLocale'
Expand All @@ -89,37 +88,67 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.banner || {}
to: !!props.to
}))

const id = computed(() => `banner-${props.id || '1'}`)

watch(id, (newId) => {
if (typeof document === 'undefined' || typeof localStorage === 'undefined') return

const isClosed = localStorage.getItem(newId) === 'true'
const htmlElement = document.querySelector('html')

htmlElement?.classList.toggle('hide-banner', isClosed)
const instanceId = useId()
const id = computed(() => {
const rawId = props.id || instanceId
// Sanitize to only allow safe characters for CSS custom properties and selectors
return `banner-${rawId.replace(/[^\w-]/g, '-')}`
})
const isVisible = ref(true)
const hasPersistence = computed(() => !!props.id)

onMounted(() => {
if (hasPersistence.value && typeof localStorage !== 'undefined') {
const isClosed = localStorage.getItem(id.value) === 'true'
isVisible.value = !isClosed
}
})

useHead({
script: [{
key: 'prehydrate-template-banner',
innerHTML: `
useHead(() => {
if (!hasPersistence.value) return {}

return {
script: [{
key: `prehydrate-banner-${id.value}`,
innerHTML: `
(function() {
try {
if (localStorage.getItem(${JSON.stringify(id.value)}) === 'true') {
document.querySelector('html').classList.add('hide-banner')
}`.replace(/\s+/g, ' '),
type: 'text/javascript'
}]
document.documentElement.style.setProperty('--${id.value}-display', 'none');
}
} catch (e) {}
})();
`.replace(/\s+/g, ' '),
type: 'text/javascript',
tagPosition: 'head'
}],
style: [{
key: `banner-style-${id.value}`,
innerHTML: `.banner[data-banner-id="${id.value}"] { display: var(--${id.value}-display, block); }`,
tagPosition: 'head'
}]
}
})

function onClose() {
localStorage.setItem(id.value, 'true')
document.querySelector('html')?.classList.add('hide-banner')
if (hasPersistence.value) {
localStorage.setItem(id.value, 'true')
document.documentElement.style.setProperty(`--${id.value}-display`, 'none')
}
isVisible.value = false
emits('close')
}
</script>

<template>
<Primitive :as="as" class="banner" data-slot="root" :class="ui.root({ class: [props.ui?.root, props.class] })">
<Primitive
v-show="isVisible"
:as="as"
class="banner"
:data-banner-id="id"
data-slot="root"
:class="ui.root({ class: [props.ui?.root, props.class] })"
>
<ULink
v-if="to"
:aria-label="title"
Expand Down Expand Up @@ -171,9 +200,3 @@ function onClose() {
</UContainer>
</Primitive>
</template>

<style scoped>
.hide-banner .banner {
display: none;
}
</style>
Loading
Loading