|
| 1 | +<template> |
| 2 | + <teleport v-bind="teleportAttrs"> |
| 3 | + <div |
| 4 | + :id="id" |
| 5 | + class="b-toast" |
| 6 | + :class="solidBoolean ? 'b-toast-solid' : ''" |
| 7 | + :role="computedRole" |
| 8 | + :aria-live="computedAriaLive" |
| 9 | + :aria-atomic="computedAriaAtomic" |
| 10 | + @mouseenter="pauseTimer" |
| 11 | + @mouseleave="unPauseTimer" |
| 12 | + > |
| 13 | + <b-transition |
| 14 | + :no-fade="noFadeBoolean" |
| 15 | + @before-enter="onBeforeEnter" |
| 16 | + @after-enter="onAfterEnter" |
| 17 | + @before-leave="onBeforeLeave" |
| 18 | + @after-leave="onAfterLeave" |
| 19 | + > |
| 20 | + <div v-if="modelValueBoolean" class="toast" :class="toastClasses" tabindex="0"> |
| 21 | + <component |
| 22 | + :is="headerTag" |
| 23 | + v-if="!!$slots.title || !!title" |
| 24 | + :class="headerClass" |
| 25 | + class="toast-header" |
| 26 | + > |
| 27 | + <slot name="title" :hide="hide"> |
| 28 | + <strong class="me-auto"> |
| 29 | + {{ title }} |
| 30 | + </strong> |
| 31 | + </slot> |
| 32 | + <b-close-button v-if="!noCloseButtonBoolean" @click="hide()" /> |
| 33 | + </component> |
| 34 | + <component |
| 35 | + :is="computedTag" |
| 36 | + v-bind="computedLinkProps" |
| 37 | + v-if="!!$slots.default || !!body" |
| 38 | + class="toast-body" |
| 39 | + :class="bodyClass" |
| 40 | + @click="onBodyClick" |
| 41 | + > |
| 42 | + <slot :hide="hide">{{ body }}</slot> |
| 43 | + </component> |
| 44 | + </div> |
| 45 | + </b-transition> |
| 46 | + </div> |
| 47 | + </teleport> |
| 48 | +</template> |
| 49 | + |
| 50 | +<script lang="ts"> |
| 51 | +import {computed, defineComponent, onMounted, PropType, ref, toRef} from 'vue' |
| 52 | +import {isLink, pluckProps, toInteger} from '../../utils' |
| 53 | +import {useBooleanish} from '../../composables' |
| 54 | +import type {Booleanish, ClassValue, ColorVariant} from '../../types' |
| 55 | +import BTransition from '../BTransition/BTransition.vue' |
| 56 | +import BCloseButton from '../BButton/BCloseButton.vue' |
| 57 | +import BLink, {BLINK_PROPS} from '../BLink/BLink.vue' |
| 58 | +
|
| 59 | +export default defineComponent({ |
| 60 | + components: {BLink, BTransition, BCloseButton}, |
| 61 | + props: { |
| 62 | + ...BLINK_PROPS, |
| 63 | + delay: {type: Number, default: 5000}, |
| 64 | + bodyClass: {type: Object as PropType<ClassValue>, required: false}, |
| 65 | + body: {type: String, required: false}, |
| 66 | + headerClass: {type: Object as PropType<ClassValue>, required: false}, |
| 67 | + headerTag: {type: String, default: 'div'}, |
| 68 | + id: {type: String, required: false}, |
| 69 | + isStatus: {type: [Boolean, String] as PropType<Booleanish>, default: false}, |
| 70 | + autoHide: {type: [Boolean, String] as PropType<Booleanish>, default: true}, |
| 71 | + noCloseButton: {type: [Boolean, String] as PropType<Booleanish>, default: false}, |
| 72 | + noFade: {type: [Boolean, String] as PropType<Booleanish>, default: false}, |
| 73 | + noHoverPause: {type: [Boolean, String] as PropType<Booleanish>, default: false}, |
| 74 | + solid: {type: [Boolean, String] as PropType<Booleanish>, default: false}, |
| 75 | + static: {type: [Boolean, String] as PropType<Booleanish>, default: false}, |
| 76 | + title: {type: String, required: false}, |
| 77 | + modelValue: {type: [Boolean, String] as PropType<Booleanish>, default: false}, |
| 78 | + toastClass: {type: Object as PropType<ClassValue>, required: false}, |
| 79 | + variant: {type: String as PropType<ColorVariant>, required: false}, |
| 80 | + // TODO get the toaster right for b-toaster |
| 81 | + toaster: {type: String, default: 'b-toaster-top-right'}, |
| 82 | + }, |
| 83 | + emits: ['update:modelValue', 'hide', 'hidden', 'show', 'shown', 'paused', 'unPaused'], |
| 84 | + setup(props, {emit}) { |
| 85 | + const MIN_DURATION = 1000 |
| 86 | +
|
| 87 | + const isStatusBoolean = useBooleanish(toRef(props, 'isStatus')) |
| 88 | + const autoHideBoolean = useBooleanish(toRef(props, 'autoHide')) |
| 89 | + const noCloseButtonBoolean = useBooleanish(toRef(props, 'noCloseButton')) |
| 90 | + const noFadeBoolean = useBooleanish(toRef(props, 'noFade')) |
| 91 | + const noHoverPauseBoolean = useBooleanish(toRef(props, 'noHoverPause')) |
| 92 | + // TODO even though solid correctly appears in the class list, |
| 93 | + // The basic toast does not have a translucent background |
| 94 | + const solidBoolean = useBooleanish(toRef(props, 'solid')) |
| 95 | + const staticBoolean = useBooleanish(toRef(props, 'static')) |
| 96 | + const modelValueBoolean = useBooleanish(toRef(props, 'modelValue')) |
| 97 | +
|
| 98 | + const isMounted = ref(false) |
| 99 | + onMounted(() => { |
| 100 | + isMounted.value = true |
| 101 | + }) |
| 102 | +
|
| 103 | + const toastClasses = computed(() => [ |
| 104 | + props.toastClass, |
| 105 | + { |
| 106 | + [`b-toast-${props.variant}`]: props.variant !== undefined, |
| 107 | + show: modelValueBoolean.value, |
| 108 | + }, |
| 109 | + ]) |
| 110 | +
|
| 111 | + const computedRole = computed(() => |
| 112 | + !modelValueBoolean.value ? undefined : isStatusBoolean.value ? 'status' : 'alert' |
| 113 | + ) |
| 114 | +
|
| 115 | + const computedAriaLive = computed(() => |
| 116 | + !modelValueBoolean.value ? undefined : isStatusBoolean.value ? 'polite' : 'assertive' |
| 117 | + ) |
| 118 | +
|
| 119 | + const computedAriaAtomic = computed(() => (!modelValueBoolean.value ? undefined : 'true')) |
| 120 | +
|
| 121 | + const computedTag = computed<'div' | typeof BLink>(() => (isLink(props) ? BLink : 'div')) |
| 122 | +
|
| 123 | + const hide = () => { |
| 124 | + emit('update:modelValue', false) |
| 125 | + } |
| 126 | +
|
| 127 | + const onBodyClick = () => { |
| 128 | + if (!isLink(props)) return |
| 129 | + hide() |
| 130 | + } |
| 131 | +
|
| 132 | + // This would be a lot better if the timer was separated from the logic and was an external function |
| 133 | + // create a timer |
| 134 | + let dismissTimer: ReturnType<typeof setTimeout> | undefined |
| 135 | + let dismissStarted: number |
| 136 | + let resumeDismiss: number |
| 137 | + const computedDuration = computed(() => Math.max(toInteger(props.delay, 0), MIN_DURATION)) |
| 138 | + // start the timer |
| 139 | + const startDismissTimer = () => { |
| 140 | + clearDismissTimer() |
| 141 | + dismissTimer = setTimeout(hide, resumeDismiss || computedDuration.value) |
| 142 | + dismissStarted = Date.now() |
| 143 | + resumeDismiss = 0 |
| 144 | + } |
| 145 | + // stop the timer |
| 146 | + const clearDismissTimer = () => { |
| 147 | + if (dismissTimer !== undefined) return |
| 148 | + clearTimeout(dismissTimer) |
| 149 | + dismissTimer = undefined |
| 150 | + } |
| 151 | + // reset timer |
| 152 | + const resetTimer = () => { |
| 153 | + clearDismissTimer() |
| 154 | + dismissStarted = resumeDismiss = 0 |
| 155 | + } |
| 156 | + // pause the timer |
| 157 | + const pauseTimer = () => { |
| 158 | + if (!autoHideBoolean.value || noHoverPauseBoolean.value || !dismissTimer || resumeDismiss) |
| 159 | + return |
| 160 | + const passed = Date.now() - dismissStarted |
| 161 | + if (passed > 0) { |
| 162 | + emit('paused', passed) |
| 163 | + clearDismissTimer() |
| 164 | + resumeDismiss = Math.max(computedDuration.value - passed, MIN_DURATION) |
| 165 | + } |
| 166 | + } |
| 167 | + const unPauseTimer = () => { |
| 168 | + if (!autoHideBoolean.value || noHoverPauseBoolean.value || !resumeDismiss) { |
| 169 | + resumeDismiss = dismissStarted = 0 |
| 170 | + return |
| 171 | + } |
| 172 | + emit('unPaused') |
| 173 | + startDismissTimer() |
| 174 | + } |
| 175 | +
|
| 176 | + // Emit before transition starts |
| 177 | + const onBeforeEnter = () => { |
| 178 | + emit('show') |
| 179 | + } |
| 180 | + // Emit after transition entering ends |
| 181 | + // Also responsible for starting the timer |
| 182 | + const onAfterEnter = () => { |
| 183 | + emit('shown') |
| 184 | + if (autoHideBoolean.value) { |
| 185 | + startDismissTimer() |
| 186 | + } |
| 187 | + } |
| 188 | + // Emit before transition leaves |
| 189 | + const onBeforeLeave = () => { |
| 190 | + emit('hide') |
| 191 | + } |
| 192 | + // Emit after transition leaving ends |
| 193 | + // Also responsible for resetting the timer |
| 194 | + const onAfterLeave = () => { |
| 195 | + emit('hidden') |
| 196 | + if (autoHideBoolean.value) { |
| 197 | + resetTimer() |
| 198 | + } |
| 199 | + } |
| 200 | +
|
| 201 | + const teleportAttrs = computed(() => ({ |
| 202 | + // to: `#${props.toaster}`, |
| 203 | + to: isMounted.value ? `#${props.toaster}` : 'body', |
| 204 | + disabled: staticBoolean.value, |
| 205 | + })) |
| 206 | +
|
| 207 | + const computedLinkProps = computed(() => (isLink(props) ? pluckProps(props, BLINK_PROPS) : {})) |
| 208 | +
|
| 209 | + return { |
| 210 | + computedLinkProps, |
| 211 | + computedRole, |
| 212 | + computedAriaLive, |
| 213 | + computedAriaAtomic, |
| 214 | + pauseTimer, |
| 215 | + unPauseTimer, |
| 216 | + noFadeBoolean, |
| 217 | + onBeforeEnter, |
| 218 | + onAfterEnter, |
| 219 | + onBeforeLeave, |
| 220 | + onAfterLeave, |
| 221 | + modelValueBoolean, |
| 222 | + toastClasses, |
| 223 | + hide, |
| 224 | + noCloseButtonBoolean, |
| 225 | + computedTag, |
| 226 | + onBodyClick, |
| 227 | + teleportAttrs, |
| 228 | + solidBoolean, |
| 229 | + } |
| 230 | + }, |
| 231 | +}) |
| 232 | +</script> |
0 commit comments