Newer
Older
navi-1 / webclient / src / components / ui / NotificationPromptBanner.vue
<template>
  <Transition name="prompt-slide">
    <div
      v-if="open"
      class="notification-prompt"
      role="region"
      aria-live="polite"
      :aria-label="title"
    >
      <slot name="icon">
        <i :class="`ph ${icon}`" />
      </slot>

      <div class="notification-prompt-body">
        <div class="notification-prompt-title">{{ title }}</div>
        <div class="notification-prompt-text">
          <slot>{{ text }}</slot>
        </div>
      </div>

      <slot name="actions">
        <div class="notification-prompt-actions">
          <GnButton
            variant="secondary"
            size="sm"
            :disabled="busy"
            :loading="busy"
            @click="emit('accept')"
          >
            {{ acceptText }}
          </GnButton>
          <GnButton
            variant="ghost"
            size="sm"
            :disabled="busy"
            @click="emit('dismiss')"
          >
            {{ dismissText }}
          </GnButton>
        </div>
      </slot>
    </div>
  </Transition>
</template>

<script setup>
/**
 * A bar that asks for a browser permission, with the click that grants it.
 *
 * Presentation only: it renders what it is told to and reports the two
 * answers. It holds no policy — when to show it, what to remember about the
 * answer and which storage to keep that in all belong to the caller. That is
 * what makes it portable: see the record in
 * gnexus-ui-kit/docs/tasks/gn-permission-banner.md.
 *
 * The accept button reports synchronously, straight from the click: the
 * gesture must reach `Notification.requestPermission()` unharmed.
 */
import { GnButton } from 'gnexus-ui-kit/vue'

defineProps({
  open: { type: Boolean, default: false },
  title: { type: String, default: '' },
  text: { type: String, default: '' },
  acceptText: { type: String, default: '' },
  dismissText: { type: String, default: '' },
  busy: { type: Boolean, default: false },
  icon: { type: String, default: 'ph-bell' },
})

const emit = defineEmits(['accept', 'dismiss'])
</script>

<style lang="scss" scoped>
@use 'kit-deps' as *;

.notification-prompt {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: $space-sm $space-md;
  flex-shrink: 0;
  padding: $space-3 $space-md;
  background: rgba($color-secondary, 0.12);
  border-bottom: 1px solid rgba($color-secondary, 0.3);

  i {
    font-size: 18px;
    color: $color-secondary;
    flex-shrink: 0;
  }
}

.notification-prompt-body {
  flex: 1 1 240px;
  min-width: 0;
}

.notification-prompt-title {
  font-size: 12px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: $color-secondary;
}

.notification-prompt-text {
  font-size: 12px;
  color: $color-text-medium;
  margin-top: 2px;
}

.notification-prompt-actions {
  display: flex;
  align-items: center;
  gap: $space-sm;
  flex-shrink: 0;
}

.prompt-slide-enter-active,
.prompt-slide-leave-active {
  transition: max-height $motion-slow $motion-ease, opacity $motion-slow $motion-ease;
  overflow: hidden;
}
.prompt-slide-enter-from,
.prompt-slide-leave-to {
  max-height: 0;
  opacity: 0;
}
.prompt-slide-enter-to,
.prompt-slide-leave-from {
  // Generous: the bar wraps to three rows on a phone, and a snug cap would
  // clip it on the way in.
  max-height: 200px;
  opacity: 1;
}
</style>