mirror of
https://github.com/nocodb/nocodb.git
synced 2026-05-01 07:16:40 +00:00
491 lines
11 KiB
Vue
491 lines
11 KiB
Vue
<script lang="ts" setup>
|
||
import type { AlertProps } from 'ant-design-vue/es'
|
||
import { getI18n } from '~/plugins/a.i18n'
|
||
|
||
/**
|
||
* NcAlert Component
|
||
*
|
||
* A customizable alert component with optional icons, descriptions, actions, and notifications.
|
||
* Can be used as a standalone alert or inside the `message` notification system.
|
||
*
|
||
* @example
|
||
* ```vue
|
||
* <NcAlert
|
||
* type="error"
|
||
* message="Something went wrong"
|
||
* description="We couldn’t complete your request. Please try again."
|
||
* :closable="true"
|
||
* :copy-text="'ERR_CODE_404'"
|
||
* />
|
||
* ```
|
||
*/
|
||
export interface NcAlertProps extends Pick<AlertProps, 'showIcon' | 'message' | 'description' | 'closable'> {
|
||
/**
|
||
* type toast will be used only in message.toast('simple toast message')
|
||
*/
|
||
type: AlertProps['type'] | 'toast'
|
||
/**
|
||
* Controls the visibility of the alert.
|
||
* @default true
|
||
*/
|
||
visible?: boolean
|
||
|
||
/**
|
||
* Whether the alert has a border.
|
||
* @default true
|
||
*/
|
||
bordered?: boolean
|
||
|
||
/**
|
||
* Aligns the content vertically.
|
||
* - `top`: Align to the top
|
||
* - `center`: Align to the center
|
||
* @default 'top'
|
||
*/
|
||
align?: 'top' | 'center'
|
||
|
||
/**
|
||
* The text to be copied when clicking the copy button.
|
||
*/
|
||
copyText?: any
|
||
|
||
/**
|
||
* Show toast msg after copying the text
|
||
*/
|
||
copyTextToastMessage?: string
|
||
|
||
/**
|
||
* Tooltip text for the copy button.
|
||
* @default 'tooltip.copyErrorCode' (from i18n)
|
||
*/
|
||
copyBtnTooltip?: string
|
||
|
||
/**
|
||
* Custom class for the message text.
|
||
*/
|
||
messageClass?: string
|
||
|
||
/**
|
||
* Custom class for the description text.
|
||
*/
|
||
descriptionClass?: string
|
||
|
||
/**
|
||
* Whether this alert is used inside a notification message.
|
||
* @default false
|
||
*/
|
||
isNotification?: boolean
|
||
|
||
/**
|
||
* Duration before the alert disappears (in seconds).
|
||
* If not provided, uses default Ant Design message duration.
|
||
*/
|
||
duration?: number
|
||
|
||
/**
|
||
* Whether to show a visual progress bar for the remaining duration.
|
||
* @default true
|
||
*/
|
||
showDuration?: boolean
|
||
|
||
/**
|
||
* Show background color
|
||
* @default false
|
||
*/
|
||
background?: boolean
|
||
}
|
||
|
||
const props = withDefaults(defineProps<NcAlertProps>(), {
|
||
visible: true,
|
||
showIcon: true,
|
||
bordered: true,
|
||
align: 'top',
|
||
messageClass: '',
|
||
descriptionClass: '',
|
||
isNotification: false,
|
||
showDuration: true,
|
||
background: false,
|
||
})
|
||
|
||
/**
|
||
* Emits events when the alert is closed or visibility changes.
|
||
*/
|
||
const emits = defineEmits<{
|
||
/**
|
||
* Event triggered when visibility is updated.
|
||
* @param value - The new visibility state
|
||
*/
|
||
(e: 'update:visible', value: boolean): void
|
||
|
||
/**
|
||
* Event triggered when the alert is closed.
|
||
*/
|
||
(e: 'close'): void
|
||
}>()
|
||
|
||
const vVisible = useVModel(props, 'visible', emits, { defaultValue: true })
|
||
|
||
const { type } = toRefs(props)
|
||
|
||
const slots = useSlots()
|
||
|
||
const { t } = getI18n().global
|
||
|
||
const { copy } = useCopy()
|
||
|
||
const isMessageAvailable = computed(() => !!(slots.message || props.message))
|
||
|
||
const isDescriptionAvailable = computed(() => !!(slots.description || props.description))
|
||
|
||
const align = computed<NcAlertProps['align']>(() => {
|
||
return isMessageAvailable.value && isDescriptionAvailable.value ? props.align : 'center'
|
||
})
|
||
|
||
/**
|
||
* Tracks whether the text has been copied successfully.
|
||
*/
|
||
const isCopied = ref<boolean>(false)
|
||
|
||
const copyText = computed(() => props.copyText?.toString() ?? '')
|
||
|
||
const copyBtnTooltip = computed(() =>
|
||
ncIsUndefined(props.copyBtnTooltip) && props.type === 'error' ? t('tooltip.copyErrorCode') : props.copyBtnTooltip,
|
||
)
|
||
|
||
let copiedTimeoutId: any
|
||
|
||
/**
|
||
* Handles the copy button click event.
|
||
* Copies the `copyText` value to the clipboard and shows a success indicator.
|
||
*/
|
||
const onClickCopy = async () => {
|
||
if (copiedTimeoutId) {
|
||
clearTimeout(copiedTimeoutId)
|
||
}
|
||
|
||
if (!copyText.value) return
|
||
|
||
try {
|
||
await copy(copyText.value)
|
||
|
||
isCopied.value = true
|
||
|
||
if (props.copyTextToastMessage) {
|
||
message.toast(props.copyTextToastMessage)
|
||
}
|
||
|
||
copiedTimeoutId = setTimeout(() => {
|
||
isCopied.value = false
|
||
clearTimeout(copiedTimeoutId)
|
||
}, 3000)
|
||
} catch (e: any) {
|
||
message.error(e.message)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Computes the appropriate icon based on the alert type.
|
||
*/
|
||
const iconName = computed<IconMapKey>(() => {
|
||
if (type.value === 'error') {
|
||
return 'ncAlertCircleFilled'
|
||
}
|
||
|
||
if (type.value === 'warning') {
|
||
return 'alertTriangleSolid'
|
||
}
|
||
|
||
if (type.value === 'info') {
|
||
return 'ncInfoSolid'
|
||
}
|
||
|
||
return 'circleCheckSolid'
|
||
})
|
||
|
||
/**
|
||
* Handles alert close action.
|
||
*/
|
||
const handleClose = () => {
|
||
vVisible.value = false
|
||
emits('close')
|
||
}
|
||
|
||
/**
|
||
* Remaining duration of the alert in seconds.
|
||
*/
|
||
const remDuration = ref(props.duration ?? ANT_MESSAGE_DURATION)
|
||
|
||
/**
|
||
* Tracks the start time of the alert.
|
||
*/
|
||
const startTime = ref(performance.now())
|
||
|
||
/**
|
||
* Computes the progress percentage based on remaining duration.
|
||
*/
|
||
const remDurationPercent = computed(() => (remDuration.value / (props.duration ?? ANT_MESSAGE_DURATION)) * 100)
|
||
|
||
let frameId: number
|
||
|
||
/**
|
||
* Updates the progress bar smoothly using requestAnimationFrame.
|
||
*/
|
||
const updateProgress = () => {
|
||
const elapsedTime = (performance.now() - startTime.value) / 1000 // Convert ms to seconds
|
||
const totalDuration = props.duration ?? ANT_MESSAGE_DURATION
|
||
const remaining = Math.max(totalDuration - elapsedTime, 0)
|
||
|
||
// Lerp (smooth transition instead of abrupt frame jumps)
|
||
remDuration.value = remDuration.value * 0.9 + remaining * 0.1
|
||
|
||
if (remDuration.value > 0.01) {
|
||
// Stop when close to zero
|
||
frameId = requestAnimationFrame(updateProgress)
|
||
} else {
|
||
remDuration.value = 0 // Ensure it reaches zero exactly
|
||
}
|
||
}
|
||
/**
|
||
* Starts the progress bar animation when the component is mounted.
|
||
*/
|
||
onMounted(() => {
|
||
if (!props.showDuration) return
|
||
|
||
startTime.value = performance.now()
|
||
updateProgress()
|
||
})
|
||
|
||
/**
|
||
* Cancels the animation frame when the component is unmounted.
|
||
*/
|
||
onUnmounted(() => {
|
||
cancelAnimationFrame(frameId)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
v-if="vVisible"
|
||
class="nc-alert group"
|
||
:class="[
|
||
`nc-alert-type-${type}`,
|
||
{
|
||
'items-center': align === 'center',
|
||
'items-start': align === 'top',
|
||
'no-border': !bordered,
|
||
'nc-alert-notification': isNotification,
|
||
'nc-show-background': background,
|
||
},
|
||
]"
|
||
>
|
||
<div v-if="showIcon" class="nc-alert-icon-wrapper">
|
||
<slot name="icon">
|
||
<GeneralIcon :icon="iconName" class="nc-alert-icon" />
|
||
</slot>
|
||
</div>
|
||
|
||
<div class="nc-alert-content flex-1">
|
||
<div v-if="message || $slots.message" class="nc-alert-message" :class="messageClass">
|
||
<slot name="message">{{ message }}</slot>
|
||
</div>
|
||
|
||
<NcTooltip
|
||
v-if="description || $slots.description"
|
||
:title="description"
|
||
:line-clamp="isNotification ? 2 : 3"
|
||
show-on-truncate-only
|
||
:disabled="!description"
|
||
>
|
||
<div
|
||
class="nc-alert-description"
|
||
:class="[
|
||
descriptionClass,
|
||
{
|
||
'nc-only-description': isDescriptionAvailable && !isMessageAvailable,
|
||
},
|
||
]"
|
||
>
|
||
<slot name="description">{{ description }}</slot>
|
||
</div>
|
||
</NcTooltip>
|
||
</div>
|
||
|
||
<div v-if="$slots.action || copyText || closable" class="nc-alert-action">
|
||
<slot name="action"> </slot>
|
||
<NcTooltip
|
||
v-if="copyText"
|
||
:title="copyBtnTooltip"
|
||
:disabled="!copyBtnTooltip"
|
||
class="nc-alert-action-copy"
|
||
:class="{
|
||
'invisible group-hover:visible transition-all': isNotification,
|
||
}"
|
||
>
|
||
<NcButton size="xsmall" type="text" @click.stop="onClickCopy">
|
||
<div class="flex children:flex-none relative h-4 w-4">
|
||
<Transition name="icon-fade" :duration="200">
|
||
<GeneralIcon v-if="isCopied" icon="check" class="h-4 w-4 opacity-80" />
|
||
<GeneralIcon v-else icon="copy" class="h-4 w-4 opacity-80" />
|
||
</Transition>
|
||
</div>
|
||
</NcButton>
|
||
</NcTooltip>
|
||
<slot v-if="closable" name="closable" :handle-close="handleClose">
|
||
<NcButton size="xsmall" type="text" @click.stop="handleClose">
|
||
<GeneralIcon icon="close" class="text-nc-content-gray-subtle" />
|
||
</NcButton>
|
||
</slot>
|
||
</div>
|
||
|
||
<div
|
||
v-if="isNotification && showDuration"
|
||
class="nc-alert-progress-wrapper"
|
||
:class="{
|
||
'bg-nc-bg-brand': remDurationPercent > 0,
|
||
'bg-nc-bg-gray-medium': remDurationPercent <= 0,
|
||
}"
|
||
>
|
||
<div
|
||
class="nc-alert-progress"
|
||
:style="{
|
||
width: `${remDurationPercent}%`,
|
||
}"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style lang="scss" scoped>
|
||
.nc-alert {
|
||
@apply flex gap-4;
|
||
|
||
&:not(.nc-alert-notification) {
|
||
@apply rounded-lg p-4 w-full border-1 border-nc-border-gray-medium;
|
||
}
|
||
|
||
&.nc-alert-notification {
|
||
@apply min-w-[calc(100vw_-_64px)] md:min-w-[308px] max-w-[488px] w-[calc(30vw_-_32px)];
|
||
.nc-alert-content {
|
||
.nc-alert-description {
|
||
@apply line-clamp-2;
|
||
}
|
||
}
|
||
|
||
&.nc-alert-type-toast {
|
||
@apply min-w-[fit-content] md:min-w-[fit-content] max-w-[350px] w-[fit-content];
|
||
}
|
||
}
|
||
|
||
&.no-border {
|
||
@apply border-none;
|
||
}
|
||
|
||
.nc-alert-icon-wrapper {
|
||
@apply flex children:flex-none;
|
||
|
||
.nc-alert-icon {
|
||
@apply h-6 w-6;
|
||
}
|
||
}
|
||
|
||
.nc-alert-content {
|
||
@apply flex flex-col gap-1;
|
||
|
||
.nc-alert-message {
|
||
@apply text-base text-nc-content-gray font-weight-700;
|
||
}
|
||
|
||
.nc-alert-description {
|
||
@apply text-sm font-weight-500 line-clamp-3;
|
||
|
||
&:not(.nc-only-description) {
|
||
@apply text-nc-content-gray-muted;
|
||
}
|
||
|
||
&.nc-only-description {
|
||
@apply text-nc-content-gray;
|
||
}
|
||
}
|
||
}
|
||
|
||
.nc-alert-action {
|
||
@apply flex items-center gap-3 children:flex-none;
|
||
}
|
||
|
||
&.nc-alert-type-success,
|
||
&.nc-alert-type-undefined {
|
||
.nc-alert-icon-wrapper {
|
||
@apply text-green-700;
|
||
}
|
||
|
||
&.nc-show-background {
|
||
@apply bg-nc-bg-green-light dark:bg-nc-green-20;
|
||
}
|
||
}
|
||
|
||
&.nc-alert-type-error {
|
||
.nc-alert-icon-wrapper {
|
||
@apply text-red-700;
|
||
}
|
||
|
||
&.nc-show-background {
|
||
@apply bg-nc-bg-red-light dark:bg-nc-red-20;
|
||
}
|
||
}
|
||
|
||
&.nc-alert-type-warning {
|
||
.nc-alert-icon-wrapper {
|
||
@apply text-orange-700;
|
||
}
|
||
|
||
&.nc-show-background {
|
||
@apply bg-nc-bg-orange-light dark:bg-nc-orange-20;
|
||
}
|
||
}
|
||
|
||
&.nc-alert-type-info {
|
||
.nc-alert-icon-wrapper {
|
||
@apply text-nc-content-brand;
|
||
}
|
||
|
||
&.nc-show-background {
|
||
@apply bg-nc-bg-brand dark:bg-nc-brand-20;
|
||
}
|
||
}
|
||
|
||
.nc-alert-progress-wrapper {
|
||
@apply absolute bottom-0 left-0 right-0 h-1;
|
||
|
||
.nc-alert-progress {
|
||
@apply h-full bg-nc-brand-400;
|
||
}
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<style lang="scss">
|
||
.ant-message {
|
||
@apply z-1053;
|
||
|
||
.ant-message-notice {
|
||
&:has(.nc-alert-notification) {
|
||
.ant-message-notice-content {
|
||
@apply bg-nc-bg-default !rounded-lg p-4 gap-4 box-border border-1 border-nc-border-gray-medium text-left relative overflow-hidden;
|
||
|
||
.ant-message-custom-content > span {
|
||
@apply flex-none w-full block;
|
||
}
|
||
|
||
&:has(.nc-alert-type-toast) {
|
||
@apply py-2.5 px-3 bg-gray-700 border-gray-700;
|
||
|
||
.nc-alert-description {
|
||
@apply text-base-white;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</style>
|