Notification prompt
Explains what notifications are for and asks permission only when the reader presses the button.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add notification-promptpnpm dlx ermineui add notification-promptyarn dlx ermineui add notification-promptbunx ermineui add notification-promptThat copies 2 files into src/components/ui/notification-prompt/, and the 1 component it is built from. Files you already have are kept.
Use
import { NotificationPrompt } from '@/components/ui/notification-prompt'<OfflineBanner />
<WakeLockToggle v-model="keepAwake" />
<FullscreenButton target="#chart" />
<NotificationPrompt title="Get told when a deployment fails?" description="One notification per failure." @granted="subscribe" />In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
NotificationPrompt.vue | src/components/ui/notification-prompt/NotificationPrompt.vue |
index.ts | src/components/ui/notification-prompt/index.ts |
It is built from: Button. These are added with it.
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
NotificationPrompt.vue 134 lines
<script setup lang="ts">
import { onMounted, ref, useId } from 'vue'
import { Button } from '../button'
withDefaults(
defineProps<{
title?: string
/** Why it is worth saying yes. Be specific: people decline prompts they do not understand. */
description?: string
allowLabel?: string
dismissLabel?: string
}>(),
{
title: 'Turn on notifications?',
description: 'We will only notify you about things you asked to hear about.',
allowLabel: 'Turn on',
dismissLabel: 'Not now',
},
)
const emit = defineEmits<{
/** Permission was given. Notifications can be shown from now on. */
granted: []
/** Permission was refused in the browser's own prompt. */
denied: []
/** "Not now" was pressed. The browser was never asked, so you can offer again later. */
dismiss: []
}>()
/** Set false to hide the prompt (after "Not now", say). It also hides itself once the question is settled. */
const open = defineModel<boolean>('open', { default: true })
// Unknown until mounted: the server cannot see the browser's permission.
const titleId = useId()
const permission = ref<'unsupported' | NotificationPermission | 'unknown'>('unknown')
onMounted(() => {
permission.value = 'Notification' in window ? Notification.permission : 'unsupported'
})
async function allow() {
// The browser's own prompt only ever appears from here: in response to a press, never on page load.
// Asked cold, most people refuse, and a refusal is permanent until they dig through browser settings.
const result = await Notification.requestPermission()
permission.value = result
if (result === 'granted') emit('granted')
else if (result === 'denied') emit('denied')
}
function dismiss() {
open.value = false
emit('dismiss')
}
</script>
<template>
<section v-if="open && permission === 'default'" class="er-notification-prompt" :aria-labelledby="titleId">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 9a6 6 0 1 1 12 0c0 6 2.500 7 2.500 7h-17S6 15 6 9ZM10 20a2 2 0 0 0 4 0" />
</svg>
<div class="er-notification-prompt-text">
<p :id="titleId" class="er-notification-prompt-title">{{ title }}</p>
<p class="er-notification-prompt-description">{{ description }}</p>
</div>
<div class="er-notification-prompt-actions">
<Button size="sm" variant="ghost" @click="dismiss">{{ dismissLabel }}</Button>
<Button size="sm" @click="allow">{{ allowLabel }}</Button>
</div>
</section>
<!-- Refused: the page can no longer ask, so the only useful thing is to say where the setting lives.
The paragraph is there from the start and filled in when the answer arrives, so it is announced. -->
<p v-if="open" class="er-notification-prompt-note" role="status">
<slot v-if="permission === 'denied'" name="denied">Notifications are blocked for this site. You can allow them in your browser's site settings.</slot>
</p>
</template>
<style>
@layer components {
.er-notification-prompt {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
width: 100%;
padding: 0.875rem 1rem;
border: 1px solid var(--er-border);
border-radius: var(--er-radius-lg);
background: var(--er-card);
color: var(--er-card-foreground);
font-size: 0.875rem;
}
.er-notification-prompt > svg {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
color: var(--er-muted-foreground);
}
.er-notification-prompt-text {
flex: 1;
min-width: 12rem;
}
.er-notification-prompt-title {
margin: 0;
font-weight: var(--er-weight-medium);
}
.er-notification-prompt-description,
.er-notification-prompt-note {
margin: 0;
color: var(--er-muted-foreground);
font-size: 0.8125rem;
line-height: 1.5;
}
.er-notification-prompt-note:empty {
/* Out of sight but still in the page for screen readers, which is what lets them announce it filling in. */
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.er-notification-prompt-actions {
display: flex;
gap: 0.5rem;
margin-left: auto;
}
}
</style>index.ts 3 lines
/** Notification prompt — explains what notifications are for and asks permission only when the reader presses the button. */
export { default as NotificationPrompt } from './NotificationPrompt.vue'