diff --git a/@codexteam/ui/dev/Playground.vue b/@codexteam/ui/dev/Playground.vue index 7ae71961..b0036e0d 100644 --- a/@codexteam/ui/dev/Playground.vue +++ b/@codexteam/ui/dev/Playground.vue @@ -39,6 +39,7 @@ import { Popover, Popup } from '../src/vue'; + import { useTheme } from '../src/vue/composables/useTheme'; import { Navbar } from '../src/vue/layout/navbar'; import { PageBlock } from '../src/vue/layout/page-block'; @@ -113,6 +114,11 @@ const pages = computed(() => [ { title: 'Components', items: [ + { + title: 'Alert', + onActivate: () => router.push('/alert'), + isActive: route.path === '/alert', + }, { title: 'Button', onActivate: () => router.push('/components/button'), diff --git a/@codexteam/ui/dev/pages/components/Alert.vue b/@codexteam/ui/dev/pages/components/Alert.vue new file mode 100644 index 00000000..3a4b5043 --- /dev/null +++ b/@codexteam/ui/dev/pages/components/Alert.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/@codexteam/ui/dev/routes.ts b/@codexteam/ui/dev/routes.ts index 2b9f8420..b48bc286 100644 --- a/@codexteam/ui/dev/routes.ts +++ b/@codexteam/ui/dev/routes.ts @@ -1,6 +1,7 @@ import type { RouteRecordRaw } from 'vue-router'; import type { Component } from 'vue'; import Index from './pages/Index.vue'; +import Alert from './pages/components/Alert.vue'; import TypeScale from './pages/base-concepts/TypeScale.vue'; import ControlsDimensions from './pages/base-concepts/ControlsDimensions.vue'; import Sizes from './pages/base-concepts/Sizes.vue'; @@ -41,6 +42,10 @@ const routes: RouteRecordRaw[] = [ path: '/', component: Index as Component, }, + { + path: '/alert', + component: Alert as Component, + }, { path: '/type-scale', component: TypeScale as Component, diff --git a/@codexteam/ui/src/vue/components/alert/Alert.types.ts b/@codexteam/ui/src/vue/components/alert/Alert.types.ts new file mode 100644 index 00000000..41254153 --- /dev/null +++ b/@codexteam/ui/src/vue/components/alert/Alert.types.ts @@ -0,0 +1,35 @@ +/** + * Various alert type + */ +export type AlertType = 'success' | 'error' | 'warning' | 'info' | 'default'; + +/** + * alert configuration + */ +export interface AlertOptions { + /** unique alert id */ + id?: string | number; + + /** + * Custom icon class to be used. + * + */ + icon?: string; + + /** + * content to be rendered + */ + message: string; + + /** + * Type of the alert. + * + * Can be any of `(success, error, default, info, warning)` + */ + type?: AlertType; + + /** + * How many milliseconds for the alert to be auto dismissed + */ + timeout: number; +} diff --git a/@codexteam/ui/src/vue/components/alert/Alert.vue b/@codexteam/ui/src/vue/components/alert/Alert.vue new file mode 100644 index 00000000..7e7d3822 --- /dev/null +++ b/@codexteam/ui/src/vue/components/alert/Alert.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/@codexteam/ui/src/vue/components/alert/AlertContainer.vue b/@codexteam/ui/src/vue/components/alert/AlertContainer.vue new file mode 100644 index 00000000..fe4d0bf2 --- /dev/null +++ b/@codexteam/ui/src/vue/components/alert/AlertContainer.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/@codexteam/ui/src/vue/components/alert/AlertTransition.vue b/@codexteam/ui/src/vue/components/alert/AlertTransition.vue new file mode 100644 index 00000000..08b6d13c --- /dev/null +++ b/@codexteam/ui/src/vue/components/alert/AlertTransition.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/@codexteam/ui/src/vue/components/alert/index.ts b/@codexteam/ui/src/vue/components/alert/index.ts new file mode 100644 index 00000000..afe72443 --- /dev/null +++ b/@codexteam/ui/src/vue/components/alert/index.ts @@ -0,0 +1,5 @@ +import { useAlert } from './useAlert.js'; +import AlertContainer from './AlertContainer.vue'; + +export * from './Alert.types.js'; +export { AlertContainer, useAlert }; diff --git a/@codexteam/ui/src/vue/components/alert/useAlert.ts b/@codexteam/ui/src/vue/components/alert/useAlert.ts new file mode 100644 index 00000000..e02304d4 --- /dev/null +++ b/@codexteam/ui/src/vue/components/alert/useAlert.ts @@ -0,0 +1,177 @@ +import type { Ref } from 'vue'; +import { onUnmounted, ref } from 'vue'; +import { createSharedComposable } from '@vueuse/core'; +import type { AlertOptions } from './Alert.types'; + +/** + * Return values of useAlert composable + */ +export interface UseAlertComposableState { + + /** + * Iterated store of alerts + */ + alerts: Ref; + + /** + * trigger success alert + * @param opt - alert options + */ + success: (opt: Pick) => void; + + /** + * trigger error alert + * @param opt - alert options + */ + error: (opt: Pick) => void; + + /** + * trigger warning alert + * @param opt - alert options + */ + warning: (opt: Pick) => void; + + /** + * trigger info alert + * @param opt - alert options + */ + info: (opt: Pick) => void; + + /** + * trigger default alert + * @param opt - alert options + */ + alert: (opt: Pick) => void; +} + +/** + * Alert service composable hook + */ +export const useAlert = createSharedComposable((): UseAlertComposableState => { + const counter = ref(0); + const maxAlerts = 10; // Default maximum number of alerts + const alerts = ref([]); + const animationFrameIds = new Map(); + const ANIMATION_DELAY = 50; // ms delay for smooth animations + + // Batch removal of expired alerts to prevent layout thrashing + function removeExpiredAlerts(): void { + const currentTime = new Date().getTime(); + // Check if any alerts have expired by comparing their timeout + // timestamps to the current time. If any have expired, we'll + // need to remove them from the list of visible alerts. + const hasExpiredAlerts = alerts.value.some( + alert => alert.timeout <= currentTime + ); + + if (hasExpiredAlerts) { + // Use requestAnimationFrame to batch DOM updates + requestAnimationFrame(() => { + alerts.value = alerts.value.filter(alert => alert.timeout > currentTime); + }); + } + } + + function scheduleRemoval(alertId: number, timeout: number): void { + const startTime = performance.now(); + let lastFrameTime = startTime; + const FRAME_TIME_MS = 16.67; // ~60fps + const removalThreshold = FRAME_TIME_MS * 2; // ~2 frames at 60fps + + const checkExpiry = (timestamp: number): void => { + // Skip frames if we're calling too frequently + if (timestamp - lastFrameTime < removalThreshold) { + const frameId = requestAnimationFrame(checkExpiry); + + animationFrameIds.set(alertId, frameId); + + return; + } + lastFrameTime = timestamp; + + const elapsed = timestamp - startTime; + + if (elapsed >= timeout) { + // Use a slight delay to ensure smooth animation + setTimeout(() => { + removeExpiredAlerts(); + animationFrameIds.delete(alertId); + }, ANIMATION_DELAY); + } else { + const frameId = requestAnimationFrame(checkExpiry); + + animationFrameIds.set(alertId, frameId); + } + }; + + const frameId = requestAnimationFrame(checkExpiry); + + animationFrameIds.set(alertId, frameId); + } + + /** + * Trigger alert component + * @param opt alert options + */ + function triggerAlert(opt: AlertOptions): void { + if (opt.timeout === Infinity) { + return; + } + + const currentTime = new Date().getTime(); + const currentTimeout = currentTime + opt.timeout; + + // Use requestAnimationFrame to batch DOM updates + requestAnimationFrame(() => { + if (alerts.value.length >= maxAlerts) { + // Find and remove the oldest alert (smallest ID) + const oldestAlert = alerts.value.reduce((prev, current) => { + if (prev?.id === undefined || current.id === undefined) { + return current; + } + + return (prev.id < current.id) ? prev : current; + }); + + // Remove the oldest alert + alerts.value = alerts.value.filter(alert => alert.id !== oldestAlert?.id); + } + + const newAlert = { + ...opt, + id: counter.value++, + timeout: currentTimeout, + }; + + // Add new alert at the beginning of the array + alerts.value = [newAlert, ...alerts.value]; + + // Schedule removal with a small delay to ensure smooth animation + setTimeout(() => { + scheduleRemoval(Number(newAlert.id), opt.timeout); + }, ANIMATION_DELAY); + }); + } + + onUnmounted(() => { + animationFrameIds.forEach((frameId) => { + cancelAnimationFrame(frameId); + }); + + animationFrameIds.clear(); + }); + + return { + alerts, + success: (opt: Omit) => triggerAlert({ ...opt, + type: 'success' }), + error: (opt: Omit) => triggerAlert({ ...opt, + type: 'error' }), + warning: (opt: Omit) => triggerAlert({ ...opt, + type: 'warning' }), + info: (opt: Omit) => triggerAlert({ ...opt, + type: 'info' }), + alert: (opt: Omit) => triggerAlert({ ...opt, + type: 'default' }), + }; +}); diff --git a/@codexteam/ui/src/vue/components/button/Button.vue b/@codexteam/ui/src/vue/components/button/Button.vue index 82e88d02..3c9a142b 100644 --- a/@codexteam/ui/src/vue/components/button/Button.vue +++ b/@codexteam/ui/src/vue/components/button/Button.vue @@ -34,7 +34,7 @@ const props = withDefaults( secondary?: boolean; /** - * Pass this attribue for negative actions + * Pass this attribute for negative actions */ destructive?: boolean; diff --git a/@codexteam/ui/src/vue/index.ts b/@codexteam/ui/src/vue/index.ts index 1a2de67d..594a40b3 100644 --- a/@codexteam/ui/src/vue/index.ts +++ b/@codexteam/ui/src/vue/index.ts @@ -1,3 +1,4 @@ +export * from './components/alert'; export * from './components/button'; export * from './components/form'; export * from './components/heading';