diff --git a/.storybook/main.ts b/.storybook/main.ts index 075e805d69..8f0840367e 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -19,6 +19,25 @@ const config: StorybookConfig = { rsbuildConfig.tools = rsbuildConfig.tools || {}; rsbuildConfig.tools.bundlerChain = (chain, { rspack }) => { + chain.module + .rule('sass-inline') + .test(/\.s[ac]ss$/i) + .resourceQuery(/inline/) + .use('style-loader') + .loader('style-loader') + .end() + .use('css-loader') + .loader('css-loader') + .end() + .use('sass-loader') + .loader('sass-loader') + .options({ + implementation: 'sass', + sassOptions: { + silenceDeprecations: ['abs-percent', 'color-functions', 'global-builtin', 'import', 'legacy-js-api'], + }, + }); + chain.plugin('extra-define').use(rspack.DefinePlugin, [ { __TUTOR_TEXT_DOMAIN__: { diff --git a/assets/core/scss/components/_toast.scss b/assets/core/scss/components/_toast.scss index de49eeb232..91d9d0731b 100644 --- a/assets/core/scss/components/_toast.scss +++ b/assets/core/scss/components/_toast.scss @@ -1,148 +1,451 @@ -// Toast Component - -@use '../mixins' as *; @use '../tokens' as *; +@use '../mixins' as *; + +.tutor-toast-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} .tutor-toast-container { - position: fixed; - z-index: $tutor-z-highest; + position: fixed !important; + z-index: $tutor-toast-z; + width: min(100vw - ($tutor-toast-offset-x * 2), $tutor-toast-max-width); + min-width: min($tutor-toast-min-width, calc(100vw - ($tutor-toast-offset-x * 2))); pointer-events: none; + list-style: none; + margin: 0; + padding: 0; + outline: none; - // Position variants - &-top-right { - top: $tutor-spacing-6; - inset-inline-end: $tutor-spacing-6; + &[data-position-x='left'] { + left: $tutor-toast-offset-x; } - &-top-left { - top: $tutor-spacing-6; - inset-inline-start: $tutor-spacing-6; + &[data-position-x='right'] { + right: $tutor-toast-offset-x; } - &-top-center { - top: $tutor-spacing-6; + &[data-position-x='center'] { left: 50%; transform: translateX(-50%); } - &-bottom-right { - bottom: $tutor-spacing-6; - inset-inline-end: $tutor-spacing-6; + &[data-position-y='top'] { + top: $tutor-toast-offset-y; } - &-bottom-left { - bottom: $tutor-spacing-6; - inset-inline-start: $tutor-spacing-6; + &[data-position-y='bottom'] { + bottom: $tutor-toast-offset-y; } +} - &-bottom-center { - bottom: $tutor-spacing-6; - left: 50%; - transform: translateX(-50%); +[dir='rtl'] { + .tutor-toast-container[data-position-x='left'] { + left: auto; + right: $tutor-toast-offset-x; + } + + .tutor-toast-container[data-position-x='right'] { + right: auto; + left: $tutor-toast-offset-x; + } +} + +.tutor-toast-stack { + position: relative; + width: 100%; + height: var(--tutor-toast-front-height, 88px); + pointer-events: none; + transition: height 400ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.tutor-toast-item { + position: absolute; + inset-inline: 0; + bottom: 0; + pointer-events: none; + transition: + transform 400ms cubic-bezier(0.4, 0, 0.2, 1), + opacity 400ms ease, + height 400ms cubic-bezier(0.4, 0, 0.2, 1); + transform: translateY(var(--tutor-toast-y, 0px)) scale(var(--tutor-toast-scale, 1)); + opacity: var(--tutor-toast-opacity, 1); + + .tutor-toast-container[data-position-y='top'] & { + top: 0; + bottom: auto; + } + + &[data-front='true'] { + pointer-events: all; + z-index: 10; } +} - // Stacking - .tutor-toast { - margin-bottom: $tutor-spacing-4; +@keyframes tutor-toast-enter-from-bottom { + from { + transform: translateY(100%) scale(1); + opacity: 0; + } - &:last-child { - margin-bottom: 0; - } + to { + transform: translateY(0) scale(1); + opacity: 1; } } -.tutor-toast { - @include tutor-flex(row, center, flex-start); - gap: $tutor-spacing-4; - width: 320px; - padding: $tutor-spacing-5 $tutor-spacing-6; - border-radius: $tutor-radius-full; - pointer-events: auto; +@keyframes tutor-toast-enter-from-top { + from { + transform: translateY(-100%) scale(1); + opacity: 0; + } + + to { + transform: translateY(0) scale(1); + opacity: 1; + } +} + +.tutor-toast-item[data-entering][data-position-y='bottom'] { + animation: tutor-toast-enter-from-bottom $tutor-toast-animation-enter cubic-bezier(0.34, 1.4, 0.64, 1) forwards; +} + +.tutor-toast-item[data-entering][data-position-y='top'] { + animation: tutor-toast-enter-from-top $tutor-toast-animation-enter cubic-bezier(0.34, 1.4, 0.64, 1) forwards; +} + +@keyframes tutor-toast-exit-to-bottom { + from { + opacity: 1; + } + + to { + transform: translateY(calc(100% + $tutor-toast-offset-y)); + opacity: 0; + } +} + +@keyframes tutor-toast-exit-to-top { + from { + opacity: 1; + } + + to { + transform: translateY(calc(-100% - $tutor-toast-offset-y)); + opacity: 0; + } +} + +.tutor-toast-item[data-exiting][data-position-y='bottom'] { + animation: tutor-toast-exit-to-bottom $tutor-toast-animation-exit ease forwards; + pointer-events: none; +} + +.tutor-toast-item[data-exiting][data-position-y='top'] { + animation: tutor-toast-exit-to-top $tutor-toast-animation-exit ease forwards; + pointer-events: none; +} + +@keyframes tutor-toast-swipe-right { + to { + transform: translateX(calc(100% + 40px)); + opacity: 0; + } +} + +@keyframes tutor-toast-swipe-left { + to { + transform: translateX(calc(-100% - 40px)); + opacity: 0; + } +} + +.tutor-toast-item[data-swipe-out='right'] { + animation: tutor-toast-swipe-right 240ms ease forwards; + pointer-events: none; +} + +.tutor-toast-item[data-swipe-out='left'] { + animation: tutor-toast-swipe-left 240ms ease forwards; + pointer-events: none; +} + +.tutor-toast-item[data-swiping='true'] { + transition: none; +} + +.tutor-toast-item[data-front='false']:not([data-expanded]) > .tutor-toast-card > * { + opacity: 0; + transition: opacity 400ms; +} + +.tutor-toast-item[data-front='true'] > .tutor-toast-card > *, +.tutor-toast-item[data-expanded] > .tutor-toast-card > * { + opacity: 1; + transition: opacity 400ms; +} + +.tutor-toast-card { position: relative; + display: flex; + align-items: center; + gap: $tutor-toast-gap; + width: 100%; + padding: $tutor-toast-padding; + border: 1px solid $tutor-toast-border; + border-radius: $tutor-toast-radius; + background: $tutor-toast-background; + box-shadow: $tutor-toast-shadow; + color: $tutor-toast-text; + font-family: $tutor-toast-font; + overflow: hidden; + cursor: grab; + + &:active { + cursor: grabbing; + } + + &[data-type='default'] { + background: $tutor-toast-default-background; + border-color: $tutor-toast-default-border; + color: $tutor-toast-default-text; + } + + &[data-type='success'] { + color: $tutor-toast-success-text; + } + + &[data-type='error'] { + color: $tutor-toast-error-text; + } + + &[data-type='warning'] { + color: $tutor-toast-warning-text; + } + + &[data-type='info'] { + color: $tutor-toast-info-text; + } + + &[data-type='loading'] { + color: $tutor-toast-loading-text; + } + + &[data-rich-colors][data-type='success'] { + background: $tutor-toast-success-background; + border-color: $tutor-toast-success-border; + } + + &[data-rich-colors][data-type='error'] { + background: $tutor-toast-error-background; + border-color: $tutor-toast-error-border; + } + + &[data-rich-colors][data-type='warning'] { + background: $tutor-toast-warning-background; + border-color: $tutor-toast-warning-border; + } + + &[data-rich-colors][data-type='info'] { + background: $tutor-toast-info-background; + border-color: $tutor-toast-info-border; + } + + &[data-rich-colors][data-type='loading'] { + background: $tutor-toast-loading-background; + border-color: $tutor-toast-loading-border; + } +} + +.tutor-toast-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + inline-size: $tutor-toast-icon-size; + block-size: $tutor-toast-icon-size; + border: 1px solid transparent; + border-radius: $tutor-radius-full; + background: $tutor-toast-default-background; + color: $tutor-toast-info-icon; + + .tutor-toast-card[data-type='success'] & { + color: $tutor-toast-success-icon; + border-color: $tutor-toast-success-border; + background: $tutor-toast-success-background; + } - // RTL support - [dir='rtl'] & { - flex-direction: row-reverse; + .tutor-toast-card[data-type='error'] & { + color: $tutor-toast-error-icon; + border-color: $tutor-toast-error-border; + background: $tutor-toast-error-background; } - &-icon { - flex-shrink: 0; - width: 36px; - height: 36px; - @include tutor-flex(row, center, center); - border-radius: $tutor-radius-full; - color: $tutor-icon-idle-inverse; + .tutor-toast-card[data-type='warning'] & { + color: $tutor-toast-warning-icon; + border-color: $tutor-toast-warning-border; + background: $tutor-toast-warning-background; } - &-content { - flex: 1; - min-width: 0; + .tutor-toast-card[data-type='info'] & { + color: $tutor-toast-info-icon; + border-color: $tutor-toast-info-border; + background: $tutor-toast-info-background; } - &-title { - @include tutor-typography(medium, medium); + .tutor-toast-card[data-type='loading'] & { + color: $tutor-toast-loading-icon; + border-color: $tutor-toast-loading-border; + background: $tutor-toast-loading-background; } - &-message { - @include tutor-typography(tiny, regular, secondary); + .tutor-toast-card[data-type='default'] & { + color: $tutor-toast-default-icon; + border-color: $tutor-toast-default-border; + background: $tutor-toast-default-background; } +} + +.tutor-toast-content { + min-width: 0; + flex: 1; +} + +.tutor-toast-title, +.tutor-toast-description { + margin: 0; +} + +.tutor-toast-title { + @include tutor-typography('medium', 'medium'); + color: currentColor; +} + +.tutor-toast-description { + @include tutor-typography('tiny'); + margin-top: $tutor-spacing-2; + color: $tutor-toast-text-muted; - &-close { - @include tutor-button-reset(); - width: 32px; - height: 32px; - border-radius: $tutor-radius-full; - color: $tutor-icon-idle; - flex-shrink: 0; + .tutor-toast-card[data-type='default'] & { + color: $tutor-toast-default-text; } +} + +.tutor-toast-actions { + display: flex; + align-items: center; + gap: $tutor-spacing-3; +} + +.tutor-toast-action-button, +.tutor-toast-close { + border: 0; + background: transparent; + color: $tutor-icon-idle; + cursor: pointer; +} + +.tutor-toast-action-button { + padding: $tutor-spacing-2 $tutor-spacing-4; + border-radius: $tutor-radius-md; + background: color-mix(in srgb, currentColor 12%, transparent); + font-size: $tutor-font-size-tiny; + font-weight: $tutor-font-weight-medium; + line-height: $tutor-line-height-tiny; +} + +.tutor-toast-close { + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: 28px; + block-size: 28px; + border-radius: 999px; + flex-shrink: 0; +} + +.tutor-toast-progress { + position: absolute; + inset-inline: 0; + inset-block-end: 0; + block-size: 3px; + background: color-mix(in srgb, currentColor 8%, transparent); + overflow: hidden; +} - // Type variants - &-success { - background-color: $tutor-surface-success; +.tutor-toast-progress-bar { + block-size: 100%; + transform-origin: left; - .tutor-toast-icon { - background-color: $tutor-icon-success-primary; - } + .tutor-toast-card[data-type='success'] & { + background: $tutor-toast-success-icon; + } - .tutor-toast-title { - color: $tutor-text-success; - } + .tutor-toast-card[data-type='error'] & { + background: $tutor-toast-error-icon; } - &-warning { - background-color: $tutor-surface-warning; + .tutor-toast-card[data-type='warning'] & { + background: $tutor-toast-warning-icon; + } - .tutor-toast-icon { - background-color: $tutor-icon-caution; - } + .tutor-toast-card[data-type='info'] & { + background: $tutor-toast-info-icon; + } - .tutor-toast-title { - color: $tutor-text-caution; - } + .tutor-toast-card[data-type='loading'] & { + background: $tutor-toast-loading-icon; } - &-error { - background-color: $tutor-surface-critical; + .tutor-toast-card[data-type='default'] & { + background: color-mix(in srgb, $tutor-toast-default-text 30%, transparent); + } +} - .tutor-toast-icon { - background-color: $tutor-icon-critical; - } +.tutor-toast-card[data-type='loading'] .tutor-toast-progress { + display: none; +} - .tutor-toast-title { - color: $tutor-text-critical; - } +@keyframes tutor-toast-progress-shrink { + from { + transform: scaleX(1); } - &-info { - background-color: $tutor-surface-brand-quaternary; + to { + transform: scaleX(0); + } +} - .tutor-toast-icon { - background-color: $tutor-icon-brand; - } +.tutor-toast-spinner { + inline-size: 18px; + block-size: 18px; + border: 2px solid color-mix(in srgb, currentColor 20%, transparent); + border-top-color: currentColor; + border-radius: 999px; + animation: tutor-toast-spinner-rotate 800ms linear infinite; +} - .tutor-toast-title { - color: $tutor-text-brand; - } +@keyframes tutor-toast-spinner-rotate { + to { + transform: rotate(360deg); } } +[data-tutor-motion='reduce'] .tutor-toast-item { + animation: none !important; + transition: opacity 120ms ease !important; +} + +@media (prefers-reduced-motion: reduce) { + .tutor-toast-item { + animation: none !important; + transition: opacity 120ms ease !important; + } +} diff --git a/assets/core/scss/toast.scss b/assets/core/scss/toast.scss new file mode 100644 index 0000000000..eb1e91f206 --- /dev/null +++ b/assets/core/scss/toast.scss @@ -0,0 +1,2 @@ +@use 'themes'; +@use 'components/toast'; diff --git a/assets/core/scss/tokens/_index.scss b/assets/core/scss/tokens/_index.scss index 4f08dbe0ca..a075590d89 100644 --- a/assets/core/scss/tokens/_index.scss +++ b/assets/core/scss/tokens/_index.scss @@ -19,3 +19,4 @@ @forward 'progress'; @forward 'quiz'; @forward 'visual'; +@forward 'toast'; diff --git a/assets/core/scss/tokens/_toast.scss b/assets/core/scss/tokens/_toast.scss new file mode 100644 index 0000000000..ab42bba4f3 --- /dev/null +++ b/assets/core/scss/tokens/_toast.scss @@ -0,0 +1,57 @@ +@use './typography' as *; +@use './borders' as *; +@use './shadows' as *; +@use './spacing' as *; +@use './surfaces' as *; +@use './text-colors' as *; +@use './icons' as *; +@use './zIndex' as *; + +$tutor-toast-font: var(--tutor-toast-font, #{$tutor-font-family-body}); +$tutor-toast-z: var(--tutor-toast-z, #{$tutor-z-highest + 1}); +$tutor-toast-gap: var(--tutor-toast-gap, #{$tutor-spacing-4}); +$tutor-toast-padding: var(--tutor-toast-padding, #{$tutor-spacing-5} #{$tutor-spacing-6}); +$tutor-toast-radius: var(--tutor-toast-radius, #{$tutor-radius-full}); +$tutor-toast-icon-size: var(--tutor-toast-icon-size, 36px); +$tutor-toast-min-width: var(--tutor-toast-min-width, 300px); +$tutor-toast-max-width: var(--tutor-toast-max-width, 420px); +$tutor-toast-animation-enter: var(--tutor-toast-animation-enter, 400ms); +$tutor-toast-animation-exit: var(--tutor-toast-animation-exit, 300ms); +$tutor-toast-offset-x: var(--tutor-toast-offset-x, #{$tutor-spacing-6}); +$tutor-toast-offset-y: var(--tutor-toast-offset-y, #{$tutor-spacing-6}); + +$tutor-toast-background: var(--tutor-toast-background, #{$tutor-surface-base}); +$tutor-toast-border: var(--tutor-toast-border, #{$tutor-border-idle}); +$tutor-toast-shadow: var(--tutor-toast-shadow, #{$tutor-shadow-xl}); +$tutor-toast-text: var(--tutor-toast-text, #{$tutor-text-primary}); +$tutor-toast-text-muted: var(--tutor-toast-text-muted, #{$tutor-text-secondary}); + +$tutor-toast-success-background: var(--tutor-toast-success-background, #{$tutor-surface-success}); +$tutor-toast-success-border: var(--tutor-toast-success-border, #{$tutor-border-success}); +$tutor-toast-success-icon: var(--tutor-toast-success-icon, #{$tutor-icon-success-primary}); +$tutor-toast-success-text: var(--tutor-toast-success-text, #{$tutor-text-success}); + +$tutor-toast-error-background: var(--tutor-toast-error-background, #{$tutor-surface-critical}); +$tutor-toast-error-border: var(--tutor-toast-error-border, #{$tutor-border-error}); +$tutor-toast-error-icon: var(--tutor-toast-error-icon, #{$tutor-icon-critical}); +$tutor-toast-error-text: var(--tutor-toast-error-text, #{$tutor-text-critical}); + +$tutor-toast-warning-background: var(--tutor-toast-warning-background, #{$tutor-surface-warning-hover}); +$tutor-toast-warning-border: var(--tutor-toast-warning-border, #{$tutor-border-warning-tertiary}); +$tutor-toast-warning-icon: var(--tutor-toast-warning-icon, #{$tutor-icon-warning}); +$tutor-toast-warning-text: var(--tutor-toast-warning-text, #{$tutor-text-caution}); + +$tutor-toast-info-background: var(--tutor-toast-info-background, #{$tutor-surface-brand-secondary}); +$tutor-toast-info-border: var(--tutor-toast-info-border, #{$tutor-border-brand-tertiary}); +$tutor-toast-info-icon: var(--tutor-toast-info-icon, #{$tutor-icon-brand}); +$tutor-toast-info-text: var(--tutor-toast-info-text, #{$tutor-text-brand}); + +$tutor-toast-loading-background: var(--tutor-toast-loading-background, #{$tutor-surface-l2}); +$tutor-toast-loading-border: var(--tutor-toast-loading-border, #{$tutor-border-hover}); +$tutor-toast-loading-icon: var(--tutor-toast-loading-icon, #{$tutor-icon-idle}); +$tutor-toast-loading-text: var(--tutor-toast-loading-text, #{$tutor-text-primary}); + +$tutor-toast-default-background: var(--tutor-toast-default-background, #{$tutor-surface-base}); +$tutor-toast-default-border: var(--tutor-toast-default-border, #{$tutor-border-idle}); +$tutor-toast-default-icon: var(--tutor-toast-default-icon, #{$tutor-icon-idle}); +$tutor-toast-default-text: var(--tutor-toast-default-text, #{$tutor-text-primary}); diff --git a/assets/core/ts/components/toast.ts b/assets/core/ts/components/toast.ts index 05259a6f40..a6605c1c69 100644 --- a/assets/core/ts/components/toast.ts +++ b/assets/core/ts/components/toast.ts @@ -1,71 +1,41 @@ -import { TUTOR_CUSTOM_EVENTS } from '@Core/ts/constant'; import { type AlpineComponentMeta } from '@Core/ts/types'; -import { type AlpineToastData, type ToastConfig, type ToastItem, type ToastType } from '@Core/ts/types/toast'; -import { __ } from '@wordpress/i18n'; +import { type AlpineToastData, type ToastConfig } from '@Core/ts/types/toast'; +import { toastServiceMeta } from '@Core/ts/services/Toast'; export function createToast(): AlpineToastData { return { - toasts: [] as ToastItem[], - $el: undefined as HTMLElement | undefined, + init(): void {}, - init(): void { - document.addEventListener(TUTOR_CUSTOM_EVENTS.TOAST_SHOW, ((event: CustomEvent) => { - const { message, config } = event.detail; - this.show(message, config); - }) as EventListener); - - document.addEventListener(TUTOR_CUSTOM_EVENTS.TOAST_CLEAR, () => { - this.clear(); - }); + show(message: string, config: ToastConfig = {}): string { + return toastServiceMeta.instance.show(message, config); }, - show(message: string, config: ToastConfig = {}): void { - const type = config.type || 'info'; - const defaultTitles: Record = { - success: __('Success', 'tutor'), - error: __('Error', 'tutor'), - warning: __('Warning', 'tutor'), - info: __('Info', 'tutor'), - }; - - const toast: ToastItem = { - id: Date.now() + Math.random(), - message, - type, - duration: config.duration || 5000, - title: config.title || defaultTitles[type], - }; - - this.toasts.push(toast); - - if (toast.duration > 0) { - setTimeout(() => this.remove(toast.id), toast.duration); - } + remove(id: string): void { + toastServiceMeta.instance.dismiss(id); }, - remove(id: number): void { - this.toasts = this.toasts.filter((toast: ToastItem) => toast.id !== id); + clear(): void { + toastServiceMeta.instance.clear(); }, - clear(): void { - this.toasts = []; + dismiss(id?: string): void { + toastServiceMeta.instance.dismiss(id); }, - // Fallback methods for type compatibility if needed, but not used for global triggering anymore - success(message: string, duration?: number): void { - this.show(message, { type: 'success', ...(duration !== undefined && { duration }) }); + success(message: string, duration?: number): string { + return toastServiceMeta.instance.success(message, duration); }, - error(message: string, duration?: number): void { - this.show(message, { type: 'error', ...(duration !== undefined && { duration }) }); + error(message: string, duration?: number): string { + return toastServiceMeta.instance.error(message, duration); }, - warning(message: string, duration?: number): void { - this.show(message, { type: 'warning', ...(duration !== undefined && { duration }) }); + warning(message: string, duration?: number): string { + return toastServiceMeta.instance.warning(message, duration); }, - info(message: string, duration?: number): void { - this.show(message, { type: 'info', ...(duration !== undefined && { duration }) }); + info(message: string, duration?: number): string { + return toastServiceMeta.instance.info(message, duration); }, }; } diff --git a/assets/core/ts/services/Toast.ts b/assets/core/ts/services/Toast.ts index 3343530dd4..27e3eae352 100644 --- a/assets/core/ts/services/Toast.ts +++ b/assets/core/ts/services/Toast.ts @@ -1,130 +1,56 @@ -import { TUTOR_CUSTOM_EVENTS } from '@Core/ts/constant'; import { type ServiceMeta } from '@Core/ts/types'; -import { type ToastConfig } from '@Core/ts/types/toast'; +import { + type ToastConfig, + type TutorToastConfig, + type TutorToastOptions, + type TutorToastPromiseMessages, + type TutorToastUpdateOptions, +} from '@Core/ts/types/toast'; +import { tutorToastManager } from '@Core/ts/toast/runtime'; export class ToastService { - private hasContainer = false; - private readonly TUTOR_TOAST_CONTAINER = '.tutor-toast-container'; - - constructor() { - this.initFullscreenListener(); + show(message: string, config: ToastConfig = {}): string { + return tutorToastManager.show(message, config); } - private initFullscreenListener(): void { - const handleFullscreenChange = () => { - const container = document.querySelector(this.TUTOR_TOAST_CONTAINER); - if (container) { - const target = document.fullscreenElement || document.body; - if (container.parentElement !== target) { - target.appendChild(container); - } - } - }; - - document.addEventListener('fullscreenchange', handleFullscreenChange); + success(message: string, duration?: number): string { + return tutorToastManager.success(message, duration); } - private ensureContainer(): void { - if (this.hasContainer || document.querySelector(this.TUTOR_TOAST_CONTAINER)) { - this.hasContainer = true; - return; - } - - const containerHTML = ` -
- -
- `; - - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = containerHTML; - const container = tempDiv.firstElementChild as HTMLElement; - - const target = document.fullscreenElement || document.body; - target.appendChild(container); + error(message: string, duration?: number): string { + return tutorToastManager.error(message, duration); + } - // Initialize Alpine on the new element - if (window.Alpine) { - window.Alpine.initTree(container); - } + warning(message: string, duration?: number): string { + return tutorToastManager.warning(message, duration); + } - this.hasContainer = true; + info(message: string, duration?: number): string { + return tutorToastManager.info(message, duration); } - show(message: string, config: ToastConfig = {}): void { - this.ensureContainer(); - document.dispatchEvent( - new CustomEvent(TUTOR_CUSTOM_EVENTS.TOAST_SHOW, { - detail: { message, config }, - }), - ); + loading(message: string, options?: TutorToastOptions): string { + return tutorToastManager.loading(message, options); } - success(message: string, duration?: number): void { - this.show(message, { type: 'success', ...(duration !== undefined && { duration }) }); + update(id: string, options: TutorToastUpdateOptions): void { + tutorToastManager.update(id, options); } - error(message: string, duration?: number): void { - this.show(message, { type: 'error', ...(duration !== undefined && { duration }) }); + dismiss(id?: string): void { + tutorToastManager.dismiss(id); } - warning(message: string, duration?: number): void { - this.show(message, { type: 'warning', ...(duration !== undefined && { duration }) }); + clear(): void { + tutorToastManager.clear(); } - info(message: string, duration?: number): void { - this.show(message, { type: 'info', ...(duration !== undefined && { duration }) }); + promise(promise: Promise, messages: TutorToastPromiseMessages, options?: TutorToastOptions): string { + return tutorToastManager.promise(promise, messages, options); } - clear(): void { - document.dispatchEvent(new CustomEvent(TUTOR_CUSTOM_EVENTS.TOAST_CLEAR)); + configure(options: TutorToastConfig): void { + tutorToastManager.configure(options); } } diff --git a/assets/core/ts/toast/index.ts b/assets/core/ts/toast/index.ts new file mode 100644 index 0000000000..69973cd932 --- /dev/null +++ b/assets/core/ts/toast/index.ts @@ -0,0 +1,3 @@ +import './styles'; + +export { default, useToast, type ToastOption } from './react'; diff --git a/assets/core/ts/toast/react.tsx b/assets/core/ts/toast/react.tsx new file mode 100644 index 0000000000..282df005e6 --- /dev/null +++ b/assets/core/ts/toast/react.tsx @@ -0,0 +1,101 @@ +import React, { useContext, useMemo, type PropsWithChildren } from 'react'; + +import { + type TutorToastConfig, + type TutorToastOptions, + type TutorToastPosition, + type TutorToastTheme, +} from '../types/toast'; +import { toast, tutorToastDefaults, tutorToastManager, type TutorToastApi } from './runtime'; + +type ReactToastType = 'success' | 'dark' | 'danger' | 'warning' | 'info'; + +export interface ToastOption { + type: ReactToastType; + message: string; + id?: string; + autoCloseDelay?: boolean | number; + title?: string; + position?: TutorToastPosition; +} + +interface ToastContextProps { + toast: TutorToastApi; + showToast: (option: ToastOption) => string; +} + +const providerDefaults: TutorToastConfig = { + ...tutorToastDefaults, + position: 'bottom-right', +}; + +const ToastContext = React.createContext({ + toast, + showToast: (option) => toast(option.message), +}); + +export const useToast = () => useContext(ToastContext); + +const normalizeType = (type: ReactToastType): TutorToastOptions['type'] => { + if (type === 'danger') { + return 'error'; + } + + if (type === 'dark') { + return 'default'; + } + + return type; +}; + +const normalizeDuration = (autoCloseDelay?: boolean | number): number | undefined => { + if (autoCloseDelay === false) { + return 0; + } + + if (typeof autoCloseDelay === 'number') { + return autoCloseDelay; + } + + return undefined; +}; + +const ToastProvider = ({ + children, + position = 'bottom-right', + theme = 'light', +}: PropsWithChildren<{ position?: TutorToastPosition; theme?: TutorToastTheme }>) => { + const config = useMemo( + () => ({ + ...providerDefaults, + position, + theme, + }), + [position, theme], + ); + + const contextToast = useMemo(() => tutorToastManager.createContextBound(config), [config]); + + const value = useMemo( + () => ({ + toast: contextToast, + showToast: (option) => { + const type = normalizeType(option.type); + const duration = normalizeDuration(option.autoCloseDelay); + + return contextToast(option.message, { + type, + title: option.title, + description: option.title ? option.message : undefined, + ...(duration !== undefined ? { duration } : {}), + ...(option.position ? { position: option.position } : {}), + }); + }, + }), + [contextToast], + ); + + return {children}; +}; + +export default ToastProvider; diff --git a/assets/core/ts/toast/runtime.ts b/assets/core/ts/toast/runtime.ts new file mode 100644 index 0000000000..43612b3e0a --- /dev/null +++ b/assets/core/ts/toast/runtime.ts @@ -0,0 +1,1158 @@ +import { __ } from '@wordpress/i18n'; + +import { + type ToastType, + type TutorToastConfig, + type TutorToastOptions, + type TutorToastPromiseMessages, + type TutorToastType, + type TutorToastUpdateOptions, +} from '@Core/ts/types/toast'; + +interface TutorToastEntry { + id: string; + element: HTMLElement; + card: HTMLElement; + timerId: ReturnType | null; + type: TutorToastType; + endsAt: number; + remainingMs: number; + paused: boolean; + exiting: boolean; + swiping: boolean; + height: number; +} + +export interface TutorToastApi { + (message: string, options?: TutorToastOptions): string; + success: (message: string, options?: TutorToastOptions) => string; + error: (message: string, options?: TutorToastOptions) => string; + warning: (message: string, options?: TutorToastOptions) => string; + info: (message: string, options?: TutorToastOptions) => string; + loading: (message: string, options?: TutorToastOptions) => string; + promise: (promise: Promise, messages: TutorToastPromiseMessages, options?: TutorToastOptions) => string; + update: (id: string, options: TutorToastUpdateOptions) => void; + dismiss: (id?: string) => void; + configure: (options: TutorToastConfig) => void; +} + +interface NormalizedTutorToastOptions { + type: TutorToastType; + title: string; + description?: string; + icon: string | null; + action: TutorToastOptions['action'] | null; + duration: number; + progressBar: boolean; + closeButton: boolean; + dir: 'ltr' | 'rtl' | 'auto'; + richColors: boolean; + position: TutorToastOptions['position']; +} + +const DEFAULT_CONFIG: Required = { + position: 'bottom-right', + duration: 5000, + closeButton: true, + progressBar: false, + maxVisible: 5, + dir: 'auto', + offset: { + x: 16, + y: 16, + mobile: { y: 12 }, + lg: {}, + }, + expandMode: 'hover', + richColors: false, + theme: 'auto', +}; + +const DEFAULT_STACK_DEPTH = { + gap: 10, + peek: 10, + scaleStep: 0.034, + scaleFloor: 0.883, + opacity1: 0.78, + opacity2: 0.52, + opacity3: 0, +} as const; + +const TOAST_DOM_ID = { + liveRegion: 'tutor-toast-aria-live', +} as const; + +const TOAST_CLASS = { + srOnly: 'tutor-toast-sr-only', + container: 'tutor-toast-container', + stack: 'tutor-toast-stack', + spinner: 'tutor-toast-spinner', + card: 'tutor-toast-card', + icon: 'tutor-toast-icon', + content: 'tutor-toast-content', + title: 'tutor-toast-title', + description: 'tutor-toast-description', + actions: 'tutor-toast-actions', + actionButton: 'tutor-toast-action-button', + closeButton: 'tutor-toast-close', + progress: 'tutor-toast-progress', + progressBar: 'tutor-toast-progress-bar', + item: 'tutor-toast-item', +} as const; + +const TOAST_SELECTOR = { + card: `.${TOAST_CLASS.card}`, + icon: `.${TOAST_CLASS.icon}`, + content: `.${TOAST_CLASS.content}`, + title: `.${TOAST_CLASS.title}`, + description: `.${TOAST_CLASS.description}`, + progressBar: `.${TOAST_CLASS.progressBar}`, +} as const; + +const TOAST_ATTR = { + ariaLive: 'aria-live', + ariaAtomic: 'aria-atomic', + ariaLabel: 'aria-label', + ariaLabelledBy: 'aria-labelledby', + dataPositionX: 'data-position-x', + dataPositionY: 'data-position-y', + dataTutorTheme: 'data-tutor-theme', + dataRichColors: 'data-rich-colors', + dataType: 'data-type', + dataFront: 'data-front', + dataExpanded: 'data-expanded', + dataEntering: 'data-entering', + dataExiting: 'data-exiting', + dataSwiping: 'data-swiping', + dataSwipeOut: 'data-swipe-out', + dataUpdating: 'data-updating', + dir: 'dir', + role: 'role', + tabIndex: 'tabindex', +} as const; + +const TOAST_ATTR_VALUE = { + polite: 'polite', + assertive: 'assertive', + region: 'region', + list: 'list', + listItem: 'listitem', + alert: 'alert', + status: 'status', + true: 'true', + frontZIndex: '10', +} as const; + +const TOAST_POSITION = { + left: 'left', + right: 'right', + center: 'center', + top: 'top', + bottom: 'bottom', +} as const; + +const TOAST_CSS_VAR = { + offsetX: '--tutor-toast-offset-x', + offsetY: '--tutor-toast-offset-y', + y: '--tutor-toast-y', + scale: '--tutor-toast-scale', + opacity: '--tutor-toast-opacity', + frontHeight: '--tutor-toast-front-height', +} as const; + +const TOAST_ANIMATION = { + progressShrink: 'tutor-toast-progress-shrink', +} as const; + +const TOAST_TITLE_ID_PREFIX = 'tutor-toast-title-'; + +const DEFAULT_LABELS: Record = { + success: __('Success', 'tutor'), + error: __('Error', 'tutor'), + warning: __('Warning', 'tutor'), + info: __('Info', 'tutor'), +}; + +const TOAST_ICON_MARKUP: Record = { + success: + '', + error: + '', + warning: + '', + info: '', + loading: '', + default: + '', +}; + +const createDefaultConfig = (): Required => ({ + ...DEFAULT_CONFIG, + offset: { + ...DEFAULT_CONFIG.offset, + mobile: { + ...DEFAULT_CONFIG.offset.mobile, + }, + lg: { + ...DEFAULT_CONFIG.offset.lg, + }, + }, +}); + +export class TutorToastManager { + private config: Required = createDefaultConfig(); + + private readonly entries = new Map(); + + private idCounter = 0; + + private container: HTMLOListElement | null = null; + + private stack: HTMLLIElement | null = null; + + private expanded = false; + + private hovered = false; + + constructor() { + this.initFullscreenListener(); + } + + private initFullscreenListener(): void { + document.addEventListener('fullscreenchange', () => { + if (!this.container) { + return; + } + + const target = document.fullscreenElement || document.body; + if (this.container.parentElement !== target) { + target.appendChild(this.container); + } + }); + } + + private isBottom(position = this.config.position): boolean { + return position.startsWith('bottom'); + } + + private xPosition(position = this.config.position): 'left' | 'right' | 'center' { + if (position.endsWith('left')) { + return TOAST_POSITION.left; + } + + if (position.endsWith('right')) { + return TOAST_POSITION.right; + } + + return TOAST_POSITION.center; + } + + private yPosition(position = this.config.position): 'top' | 'bottom' { + return this.isBottom(position) ? TOAST_POSITION.bottom : TOAST_POSITION.top; + } + + private ensureLiveRegion(): void { + if (document.getElementById(TOAST_DOM_ID.liveRegion)) { + return; + } + + const liveRegion = document.createElement('div'); + liveRegion.id = TOAST_DOM_ID.liveRegion; + liveRegion.className = TOAST_CLASS.srOnly; + liveRegion.setAttribute(TOAST_ATTR.ariaLive, TOAST_ATTR_VALUE.polite); + liveRegion.setAttribute(TOAST_ATTR.ariaAtomic, 'false'); + document.body.appendChild(liveRegion); + } + + private announce(title: string, description: string | undefined, type: TutorToastType): void { + this.ensureLiveRegion(); + const liveRegion = document.getElementById(TOAST_DOM_ID.liveRegion); + + if (!liveRegion) { + return; + } + + liveRegion.setAttribute( + TOAST_ATTR.ariaLive, + type === 'error' ? TOAST_ATTR_VALUE.assertive : TOAST_ATTR_VALUE.polite, + ); + liveRegion.textContent = ''; + requestAnimationFrame(() => { + liveRegion.textContent = description ? `${title}. ${description}` : title; + }); + } + + private applyOffset(): void { + if (!this.container) { + return; + } + + const offset = this.config.offset; + const isMobile = window.matchMedia('(max-width: 639px)').matches; + const isLarge = window.matchMedia('(min-width: 1280px)').matches; + + let offsetX = offset.x ?? 16; + let offsetY = offset.y ?? 16; + + if (isMobile && offset.mobile) { + if (offset.mobile.x != null) { + offsetX = offset.mobile.x; + } + + if (offset.mobile.y != null) { + offsetY = offset.mobile.y; + } + } + + if (isLarge && offset.lg) { + if (offset.lg.x != null) { + offsetX = offset.lg.x; + } + + if (offset.lg.y != null) { + offsetY = offset.lg.y; + } + } + + this.container.style.setProperty(TOAST_CSS_VAR.offsetX, `${offsetX}px`); + this.container.style.setProperty(TOAST_CSS_VAR.offsetY, `${offsetY}px`); + } + + private syncContainerAttributes(position = this.config.position): void { + if (!this.container) { + return; + } + + this.container.setAttribute(TOAST_ATTR.dataPositionX, this.xPosition(position)); + this.container.setAttribute(TOAST_ATTR.dataPositionY, this.yPosition(position)); + + const theme = this.config.theme; + if (theme === 'auto') { + this.container.removeAttribute(TOAST_ATTR.dataTutorTheme); + } else { + this.container.setAttribute(TOAST_ATTR.dataTutorTheme, theme); + } + + if (this.config.dir === 'auto') { + this.container.removeAttribute(TOAST_ATTR.dir); + return; + } + + this.container.setAttribute(TOAST_ATTR.dir, this.config.dir); + } + + private boot(position = this.config.position): void { + if (this.container && this.stack) { + this.syncContainerAttributes(position); + this.applyOffset(); + return; + } + + this.container = document.createElement('ol'); + this.container.className = TOAST_CLASS.container; + if (this.config.theme !== 'auto') { + this.container.setAttribute(TOAST_ATTR.dataTutorTheme, this.config.theme); + } + this.container.setAttribute(TOAST_ATTR.role, TOAST_ATTR_VALUE.region); + this.container.setAttribute(TOAST_ATTR.ariaLabel, __('Notifications', 'tutor')); + this.container.setAttribute(TOAST_ATTR.tabIndex, '-1'); + + this.stack = document.createElement('li'); + this.stack.className = TOAST_CLASS.stack; + this.stack.setAttribute(TOAST_ATTR.role, TOAST_ATTR_VALUE.list); + + this.stack.addEventListener('mouseenter', () => { + this.hovered = true; + if (this.config.expandMode === 'hover') { + this.setExpanded(true); + } + this.pauseAll(); + }); + + this.stack.addEventListener('mouseleave', (event: MouseEvent) => { + if (!this.stack) { + return; + } + + const bounds = this.stack.getBoundingClientRect(); + const inside = + event.clientX >= bounds.left && + event.clientX <= bounds.right && + event.clientY >= bounds.top && + event.clientY <= bounds.bottom; + + if (inside) { + return; + } + + this.hovered = false; + if (this.config.expandMode === 'hover') { + this.setExpanded(false); + } + this.resumeAll(); + }); + + this.stack.addEventListener('focusin', () => { + this.hovered = true; + this.pauseAll(); + this.setExpanded(true); + }); + + this.stack.addEventListener('focusout', (event: FocusEvent) => { + if (this.stack?.contains(event.relatedTarget as Node)) { + return; + } + + this.hovered = false; + this.resumeAll(); + this.setExpanded(false); + }); + + this.container.appendChild(this.stack); + const target = document.fullscreenElement || document.body; + target.appendChild(this.container); + + this.syncContainerAttributes(position); + this.applyOffset(); + + if (this.config.expandMode === 'always') { + this.expanded = true; + } + + window.addEventListener('resize', () => this.applyOffset()); + } + + private setExpanded(isExpanded: boolean): void { + if (this.config.expandMode === 'always') { + this.expanded = true; + } else if (this.config.expandMode === 'never') { + this.expanded = false; + } else { + this.expanded = isExpanded; + } + + this.restack(); + } + + private renderIcon(wrapper: HTMLElement, type: TutorToastType, override?: string | null): void { + wrapper.innerHTML = ''; + + if (type === 'loading') { + const spinner = document.createElement('div'); + spinner.className = TOAST_CLASS.spinner; + spinner.setAttribute(TOAST_ATTR.ariaLabel, __('Loading', 'tutor')); + wrapper.appendChild(spinner); + return; + } + + const iconMarkup = override ?? TOAST_ICON_MARKUP[type] ?? TOAST_ICON_MARKUP.default; + + if (iconMarkup?.trimStart().startsWith('<')) { + wrapper.innerHTML = iconMarkup; + return; + } + + wrapper.textContent = String(iconMarkup); + } + + private buildCard(id: string, title: string, options: NormalizedTutorToastOptions): HTMLElement { + const card = document.createElement('div'); + card.className = TOAST_CLASS.card; + card.setAttribute(TOAST_ATTR.dataType, options.type); + card.setAttribute(TOAST_ATTR.role, options.type === 'error' ? TOAST_ATTR_VALUE.alert : TOAST_ATTR_VALUE.status); + card.setAttribute(TOAST_ATTR.ariaAtomic, 'false'); + + if (options.dir) { + card.setAttribute(TOAST_ATTR.dir, options.dir); + } + + if (options.richColors) { + card.setAttribute(TOAST_ATTR.dataRichColors, TOAST_ATTR_VALUE.true); + } + + const icon = document.createElement('div'); + icon.className = TOAST_CLASS.icon; + this.renderIcon(icon, options.type, options.icon); + card.appendChild(icon); + + const content = document.createElement('div'); + content.className = TOAST_CLASS.content; + + const titleElement = document.createElement('p'); + titleElement.className = TOAST_CLASS.title; + titleElement.id = `${TOAST_TITLE_ID_PREFIX}${id}`; + titleElement.textContent = title; + content.appendChild(titleElement); + + if (options.description) { + const description = document.createElement('p'); + description.className = TOAST_CLASS.description; + description.textContent = options.description; + content.appendChild(description); + } + + card.appendChild(content); + + if (options.action) { + const actions = document.createElement('div'); + actions.className = TOAST_CLASS.actions; + + const actionButton = document.createElement('button'); + actionButton.className = TOAST_CLASS.actionButton; + actionButton.type = 'button'; + actionButton.textContent = options.action.label; + actionButton.addEventListener('click', (event) => { + event.stopPropagation(); + options.action?.onClick(); + if (options.action?.dismissOnClick !== false) { + this.dismiss(id); + } + }); + + actions.appendChild(actionButton); + card.appendChild(actions); + } + + if (options.closeButton) { + const closeButton = document.createElement('button'); + closeButton.className = TOAST_CLASS.closeButton; + closeButton.type = 'button'; + closeButton.setAttribute(TOAST_ATTR.ariaLabel, __('Close notification', 'tutor')); + closeButton.innerHTML = + ''; + closeButton.addEventListener('click', (event) => { + event.stopPropagation(); + this.dismiss(id); + }); + card.appendChild(closeButton); + } + + if (options.progressBar && options.type !== 'loading' && options.duration > 0) { + const progress = document.createElement('div'); + progress.className = TOAST_CLASS.progress; + + const progressBar = document.createElement('div'); + progressBar.className = TOAST_CLASS.progressBar; + progressBar.style.animation = `${TOAST_ANIMATION.progressShrink} ${options.duration}ms linear forwards`; + + progress.appendChild(progressBar); + card.appendChild(progress); + } + + return card; + } + + private restack(): void { + if (!this.stack) { + return; + } + + const visibleEntries = Array.from(this.entries.values()) + .filter((entry) => !entry.exiting && !entry.swiping) + .reverse(); + + const gap = DEFAULT_STACK_DEPTH.gap; + const peek = DEFAULT_STACK_DEPTH.peek; + const scaleStep = DEFAULT_STACK_DEPTH.scaleStep; + const direction = this.isBottom() ? -1 : 1; + + visibleEntries.forEach((entry) => { + const height = entry.element.offsetHeight; + if (height > 0) { + entry.height = height; + } + }); + + visibleEntries.forEach((entry, index) => { + const isFront = index === 0; + + entry.element.setAttribute(TOAST_ATTR.dataFront, String(isFront)); + if (this.expanded) { + entry.element.setAttribute(TOAST_ATTR.dataExpanded, TOAST_ATTR_VALUE.true); + } else { + entry.element.removeAttribute(TOAST_ATTR.dataExpanded); + } + + entry.element.style.pointerEvents = isFront || this.expanded ? 'all' : 'none'; + + if (!this.expanded) { + if (isFront) { + entry.element.style.setProperty(TOAST_CSS_VAR.y, '0px'); + entry.element.style.setProperty(TOAST_CSS_VAR.scale, '1'); + entry.element.style.setProperty(TOAST_CSS_VAR.opacity, '1'); + } else { + const offset = index * peek; + const scale = Math.max(DEFAULT_STACK_DEPTH.scaleFloor, 1 - index * scaleStep); + const opacity = + index === 1 + ? DEFAULT_STACK_DEPTH.opacity1 + : index === 2 + ? DEFAULT_STACK_DEPTH.opacity2 + : DEFAULT_STACK_DEPTH.opacity3; + + entry.element.style.setProperty(TOAST_CSS_VAR.y, `${direction * offset}px`); + entry.element.style.setProperty(TOAST_CSS_VAR.scale, String(scale)); + entry.element.style.setProperty(TOAST_CSS_VAR.opacity, String(opacity)); + } + } else { + let offset = 0; + for (let cursor = 0; cursor < index; cursor += 1) { + offset += (visibleEntries[cursor].height || 72) + gap; + } + + entry.element.style.setProperty(TOAST_CSS_VAR.y, `${direction * offset}px`); + entry.element.style.setProperty(TOAST_CSS_VAR.scale, '1'); + entry.element.style.setProperty(TOAST_CSS_VAR.opacity, '1'); + } + + entry.element.style.zIndex = String(Number(TOAST_ATTR_VALUE.frontZIndex) - index); + if (!entry.element.hasAttribute(TOAST_ATTR.dataEntering)) { + entry.element.style.transform = `translateY(var(${TOAST_CSS_VAR.y}, 0px)) scale(var(${TOAST_CSS_VAR.scale}, 1))`; + entry.element.style.opacity = `var(${TOAST_CSS_VAR.opacity}, 1)`; + } + }); + + const frontHeight = visibleEntries[0]?.height || 0; + this.stack.style.setProperty(TOAST_CSS_VAR.frontHeight, `${frontHeight}px`); + + if (this.expanded && visibleEntries.length > 0) { + const totalHeight = + visibleEntries.reduce((sum, entry) => sum + (entry.height || 72), 0) + + Math.max(0, visibleEntries.length - 1) * gap; + this.stack.style.height = `${totalHeight}px`; + } else { + this.stack.style.height = `${frontHeight}px`; + } + } + + private clearTimer(id: string): void { + const entry = this.entries.get(id); + if (entry?.timerId) { + clearTimeout(entry.timerId); + entry.timerId = null; + } + } + + private pauseEntry(entry: TutorToastEntry): void { + if (entry.paused || entry.exiting || entry.type === 'loading') { + return; + } + + this.clearTimer(entry.id); + entry.remainingMs = Math.max(0, entry.endsAt - Date.now()); + entry.paused = true; + + const progressBar = entry.card.querySelector(TOAST_SELECTOR.progressBar); + if (progressBar) { + progressBar.style.animationPlayState = 'paused'; + } + } + + private resumeEntry(entry: TutorToastEntry): void { + if (!entry.paused || entry.exiting) { + return; + } + + entry.paused = false; + + if (entry.remainingMs > 0) { + entry.endsAt = Date.now() + entry.remainingMs; + entry.timerId = setTimeout(() => this.dismiss(entry.id), entry.remainingMs); + + const progressBar = entry.card.querySelector(TOAST_SELECTOR.progressBar); + if (progressBar) { + progressBar.style.animationPlayState = 'running'; + } + } else if (entry.type !== 'loading') { + this.dismiss(entry.id); + } + } + + private pauseAll(): void { + this.entries.forEach((entry) => this.pauseEntry(entry)); + } + + private resumeAll(): void { + this.entries.forEach((entry) => this.resumeEntry(entry)); + } + + private collapseAndRemove(element: HTMLElement, id: string): void { + this.entries.delete(id); + + const height = element.offsetHeight; + element.style.pointerEvents = 'none'; + element.style.overflow = 'hidden'; + element.style.height = `${height}px`; + void element.offsetHeight; + element.style.transition = 'height 200ms ease'; + element.style.height = '0px'; + + setTimeout(() => { + element.remove(); + if (this.hovered) { + this.setExpanded(true); + } else { + this.restack(); + } + }, 210); + } + + private evict(id: string): void { + const entry = this.entries.get(id); + if (!entry) { + return; + } + + this.clearTimer(id); + this.entries.delete(id); + entry.element.remove(); + } + + private enforceLimits(): void { + const ids = Array.from(this.entries.keys()); + if (ids.length <= this.config.maxVisible) { + return; + } + + ids.slice(0, ids.length - this.config.maxVisible).forEach((id) => this.evict(id)); + this.restack(); + } + + private attachSwipe(element: HTMLElement, id: string): void { + const card = element.querySelector(TOAST_SELECTOR.card); + if (!card) { + return; + } + + let startX = 0; + let distanceX = 0; + let active = false; + + element.addEventListener('mouseenter', () => { + const entry = this.entries.get(id); + if (entry) { + this.pauseEntry(entry); + } + }); + + element.addEventListener('mouseleave', () => { + if (this.expanded) { + return; + } + + const entry = this.entries.get(id); + if (entry) { + this.resumeEntry(entry); + } + }); + + const handleStart = (event: MouseEvent | TouchEvent) => { + startX = 'touches' in event ? event.touches[0].clientX : event.clientX; + active = true; + card.style.transition = 'none'; + + const entry = this.entries.get(id); + if (entry) { + entry.swiping = true; + element.setAttribute(TOAST_ATTR.dataSwiping, TOAST_ATTR_VALUE.true); + } + }; + + const handleMove = (event: MouseEvent | TouchEvent) => { + if (!active) { + return; + } + + distanceX = ('touches' in event ? event.touches[0].clientX : event.clientX) - startX; + card.style.transform = `translateX(${distanceX}px)`; + card.style.opacity = String(Math.max(0, 1 - Math.abs(distanceX) / 180)); + }; + + const handleEnd = () => { + if (!active) { + return; + } + + active = false; + card.style.transition = ''; + card.style.transform = ''; + card.style.opacity = ''; + + const entry = this.entries.get(id); + if (entry) { + entry.swiping = false; + element.removeAttribute(TOAST_ATTR.dataSwiping); + } + + if (Math.abs(distanceX) >= 60) { + const direction = distanceX > 0 ? 'right' : 'left'; + this.clearTimer(id); + + if (entry) { + entry.exiting = true; + } + + element.setAttribute(TOAST_ATTR.dataSwipeOut, direction); + setTimeout(() => this.collapseAndRemove(element, id), 230); + } + + distanceX = 0; + }; + + element.addEventListener('touchstart', handleStart as EventListener, { passive: true }); + element.addEventListener('touchmove', handleMove as EventListener, { passive: true }); + element.addEventListener('touchend', handleEnd); + element.addEventListener('mousedown', handleStart as EventListener); + window.addEventListener('mousemove', handleMove as EventListener); + window.addEventListener('mouseup', handleEnd); + } + + private dismissOne(id: string): void { + const entry = this.entries.get(id); + if (!entry || entry.exiting) { + return; + } + + entry.exiting = true; + this.clearTimer(id); + entry.element.removeAttribute(TOAST_ATTR.dataEntering); + entry.element.setAttribute(TOAST_ATTR.dataExiting, TOAST_ATTR_VALUE.true); + entry.element.setAttribute(TOAST_ATTR.dataPositionY, this.yPosition()); + + setTimeout(() => this.collapseAndRemove(entry.element, id), 280); + } + + public dismiss(id?: string): void { + if (id) { + this.dismissOne(id); + return; + } + + Array.from(this.entries.keys()).forEach((entryId) => this.dismissOne(entryId)); + } + + public update(id: string, options: TutorToastUpdateOptions): void { + const entry = this.entries.get(id); + if (!entry) { + return; + } + + const { card } = entry; + const nextType = options.type ?? entry.type; + + if (options.type) { + card.setAttribute(TOAST_ATTR.dataType, options.type); + card.setAttribute(TOAST_ATTR.role, options.type === 'error' ? TOAST_ATTR_VALUE.alert : TOAST_ATTR_VALUE.status); + entry.type = options.type; + const icon = card.querySelector(TOAST_SELECTOR.icon); + if (icon) { + this.renderIcon(icon, options.type, options.icon ?? null); + } + } + + if (options.title != null) { + const titleElement = card.querySelector(TOAST_SELECTOR.title); + if (titleElement) { + titleElement.textContent = options.title; + } + } + + if (options.description != null) { + let descriptionElement = card.querySelector(TOAST_SELECTOR.description); + if (!descriptionElement) { + descriptionElement = document.createElement('p'); + descriptionElement.className = TOAST_CLASS.description; + card.querySelector(TOAST_SELECTOR.content)?.appendChild(descriptionElement); + } + descriptionElement.textContent = options.description; + } + + card.setAttribute(TOAST_ATTR.dataUpdating, TOAST_ATTR_VALUE.true); + setTimeout(() => card.removeAttribute(TOAST_ATTR.dataUpdating), 250); + + this.clearTimer(id); + const nextDuration = options.duration ?? this.config.duration; + + if (nextType !== 'loading' && nextDuration > 0) { + entry.paused = false; + entry.endsAt = Date.now() + nextDuration; + entry.remainingMs = nextDuration; + entry.timerId = setTimeout(() => this.dismiss(id), nextDuration); + + const progressBar = card.querySelector(TOAST_SELECTOR.progressBar); + if (progressBar) { + progressBar.style.animation = 'none'; + requestAnimationFrame(() => { + progressBar.style.animation = `${TOAST_ANIMATION.progressShrink} ${nextDuration}ms linear forwards`; + }); + } else if (options.progressBar ?? this.config.progressBar) { + const progress = document.createElement('div'); + progress.className = TOAST_CLASS.progress; + + const bar = document.createElement('div'); + bar.className = TOAST_CLASS.progressBar; + bar.style.animation = `${TOAST_ANIMATION.progressShrink} ${nextDuration}ms linear forwards`; + + progress.appendChild(bar); + card.appendChild(progress); + } + } + + const titleElement = card.querySelector(TOAST_SELECTOR.title); + const descriptionElement = card.querySelector(TOAST_SELECTOR.description); + this.announce(titleElement?.textContent || '', descriptionElement?.textContent, nextType); + } + + public show(message: string, options: TutorToastOptions = {}): string { + const position = options.position ?? this.config.position; + const theme = options.theme ?? this.config.theme; + + if (theme !== this.config.theme) { + this.config = { ...this.config, theme }; + } + + this.boot(position); + + const id = String(++this.idCounter); + const type = options.type ?? 'info'; + const duration = options.duration ?? this.config.duration; + const title = + options.title ?? (type === 'default' || type === 'loading' ? message : DEFAULT_LABELS[type as ToastType]); + const description = options.description ?? (options.title ? message : type === 'default' ? undefined : message); + + const normalizedOptions: NormalizedTutorToastOptions = { + type, + title, + description, + icon: options.icon ?? null, + action: options.action ?? null, + duration, + progressBar: options.progressBar ?? this.config.progressBar, + closeButton: options.closeButton ?? this.config.closeButton, + dir: options.dir ?? (this.config.dir !== 'auto' ? this.config.dir : 'ltr'), + richColors: options.richColors ?? this.config.richColors, + position, + }; + + const item = document.createElement('li'); + item.className = TOAST_CLASS.item; + item.setAttribute(TOAST_ATTR.role, TOAST_ATTR_VALUE.listItem); + item.setAttribute(TOAST_ATTR.ariaLabelledBy, `${TOAST_TITLE_ID_PREFIX}${id}`); + item.setAttribute(TOAST_ATTR.tabIndex, '0'); + item.setAttribute(TOAST_ATTR.dataEntering, TOAST_ATTR_VALUE.true); + item.setAttribute(TOAST_ATTR.dataPositionY, this.yPosition(position)); + + const card = this.buildCard(id, title, normalizedOptions); + item.appendChild(card); + + this.attachSwipe(item, id); + item.addEventListener('keydown', (event: KeyboardEvent) => { + if (event.key === 'Escape') { + this.dismiss(id); + } + }); + + if (this.stack?.firstChild) { + this.stack.insertBefore(item, this.stack.firstChild); + } else { + this.stack?.appendChild(item); + } + + setTimeout(() => { + item.removeAttribute(TOAST_ATTR.dataEntering); + this.restack(); + }, 420); + + this.announce(title, description, type); + + let timerId: ReturnType | null = null; + let endsAt = 0; + if (type !== 'loading' && duration > 0 && !this.hovered) { + endsAt = Date.now() + duration; + timerId = setTimeout(() => this.dismiss(id), duration); + } else if (duration > 0) { + endsAt = Date.now() + duration; + } + + this.entries.set(id, { + id, + element: item, + card, + timerId, + type, + endsAt, + remainingMs: duration, + paused: this.hovered, + exiting: false, + swiping: false, + height: 0, + }); + + this.restack(); + this.enforceLimits(); + + return id; + } + + public promise(promise: Promise, messages: TutorToastPromiseMessages, options?: TutorToastOptions): string { + const id = this.show( + typeof messages.loading === 'function' ? messages.loading() : messages.loading || __('Loading', 'tutor'), + { + ...options, + type: 'loading', + duration: 0, + }, + ); + + Promise.resolve(promise) + .then((result) => { + const title = typeof messages.success === 'function' ? messages.success(result) : messages.success; + this.update(id, { + type: 'success', + title, + duration: options?.duration ?? this.config.duration, + }); + }) + .catch((error: unknown) => { + const title = typeof messages.error === 'function' ? messages.error(error) : messages.error; + this.update(id, { + type: 'error', + title, + duration: options?.duration ?? this.config.duration, + }); + }); + + return id; + } + + public configure(options: TutorToastConfig): void { + const { offset, ...rest } = options; + + if (offset) { + this.config.offset = { + ...this.config.offset, + ...offset, + mobile: { + ...this.config.offset.mobile, + ...offset.mobile, + }, + lg: { + ...this.config.offset.lg, + ...offset.lg, + }, + }; + } + + this.config = { + ...this.config, + ...rest, + offset: this.config.offset, + }; + + if (this.config.expandMode === 'always') { + this.expanded = true; + } else if (this.config.expandMode !== 'hover') { + this.expanded = false; + } + + if (this.container) { + this.syncContainerAttributes(); + this.applyOffset(); + this.restack(); + } + } + + public clear(): void { + this.dismiss(); + } + + public success(message: string, duration?: number): string { + return this.show(message, { type: 'success', ...(duration !== undefined ? { duration } : {}) }); + } + + public error(message: string, duration?: number): string { + return this.show(message, { type: 'error', ...(duration !== undefined ? { duration } : {}) }); + } + + public warning(message: string, duration?: number): string { + return this.show(message, { type: 'warning', ...(duration !== undefined ? { duration } : {}) }); + } + + public info(message: string, duration?: number): string { + return this.show(message, { type: 'info', ...(duration !== undefined ? { duration } : {}) }); + } + + public loading(message: string, options?: TutorToastOptions): string { + return this.show(message, { ...options, type: 'loading', duration: 0 }); + } + + public createContextBound(contextConfig: TutorToastConfig): TutorToastApi { + const contextOverrides: Pick = { + ...(contextConfig.dir != null && { dir: contextConfig.dir }), + ...(contextConfig.theme != null && { theme: contextConfig.theme }), + }; + + const show = (message: string, options?: TutorToastOptions) => + this.show(message, { ...contextOverrides, ...options }); + + return createTutorToastApi(show, this, contextOverrides); + } +} + +type ToastContextOptions = Pick; + +export function createTutorToastApi( + showHandler: (message: string, options?: TutorToastOptions) => string, + manager: TutorToastManager, + contextOptions?: ToastContextOptions, +): TutorToastApi { + const merge = (overrides: Partial): TutorToastOptions => ({ + ...contextOptions, + ...overrides, + }); + + const api = ((message: string, options?: TutorToastOptions) => showHandler(message, options)) as TutorToastApi; + + api.success = (message, options) => showHandler(message, merge({ ...options, type: 'success' })); + api.error = (message, options) => showHandler(message, merge({ ...options, type: 'error' })); + api.warning = (message, options) => showHandler(message, merge({ ...options, type: 'warning' })); + api.info = (message, options) => showHandler(message, merge({ ...options, type: 'info' })); + api.loading = (message, options) => showHandler(message, merge({ ...options, type: 'loading', duration: 0 })); + api.promise = (promise, messages, options) => manager.promise(promise, messages, options); + api.update = (id, options) => manager.update(id, options); + api.dismiss = (id) => manager.dismiss(id); + api.configure = (options) => manager.configure(options); + + return api; +} + +const sharedTarget = globalThis as typeof globalThis & { + __TUTOR_TOAST_SHARED__?: { + manager: TutorToastManager; + defaults: TutorToastConfig; + toast: TutorToastApi; + }; +}; + +const sharedRuntime = + sharedTarget.__TUTOR_TOAST_SHARED__ ?? + (() => { + const manager = new TutorToastManager(); + const defaults: TutorToastConfig = DEFAULT_CONFIG; + const toast = createTutorToastApi((message, options) => manager.show(message, options), manager); + + const runtime = { + manager, + defaults, + toast, + }; + + sharedTarget.__TUTOR_TOAST_SHARED__ = runtime; + return runtime; + })(); + +export const tutorToastManager = sharedRuntime.manager; + +export const tutorToastDefaults = sharedRuntime.defaults; + +export const toast = sharedRuntime.toast; diff --git a/assets/core/ts/toast/styles.ts b/assets/core/ts/toast/styles.ts new file mode 100644 index 0000000000..67fc0b57b8 --- /dev/null +++ b/assets/core/ts/toast/styles.ts @@ -0,0 +1,3 @@ +import '@Core/scss/toast.scss?inline'; + +export {}; diff --git a/assets/core/ts/types/toast.ts b/assets/core/ts/types/toast.ts index b2d828ba1e..568f5e0c7a 100644 --- a/assets/core/ts/types/toast.ts +++ b/assets/core/ts/types/toast.ts @@ -1,30 +1,100 @@ -// Toast Component Types +export type TutorToastType = 'success' | 'error' | 'warning' | 'info' | 'loading' | 'default'; -export type ToastType = 'info' | 'success' | 'warning' | 'error'; +export type TutorToastPosition = + | 'top-left' + | 'top-center' + | 'top-right' + | 'bottom-left' + | 'bottom-center' + | 'bottom-right'; -export interface ToastConfig { - type?: ToastType; +export type TutorToastExpandMode = 'hover' | 'always' | 'never'; + +export type TutorToastTheme = 'light' | 'dark' | 'auto'; + +export interface TutorToastOffset { + x?: number; + y?: number; + mobile?: { + x?: number; + y?: number; + }; + lg?: { + x?: number; + y?: number; + }; +} + +export interface TutorToastAction { + label: string; + onClick: () => void; + dismissOnClick?: boolean; +} + +export interface TutorToastOptions { + type?: TutorToastType; + title?: string; + description?: string; + icon?: string | null; + action?: TutorToastAction; + duration?: number; + progressBar?: boolean; + closeButton?: boolean; + dir?: 'ltr' | 'rtl' | 'auto'; + richColors?: boolean; + position?: TutorToastPosition; + theme?: TutorToastTheme; +} + +export interface TutorToastConfig { + position?: TutorToastPosition; duration?: number; + closeButton?: boolean; + progressBar?: boolean; + maxVisible?: number; + dir?: 'ltr' | 'rtl' | 'auto'; + offset?: TutorToastOffset; + expandMode?: TutorToastExpandMode; + richColors?: boolean; + theme?: TutorToastTheme; +} + +export interface TutorToastUpdateOptions { + type?: TutorToastType; title?: string; + description?: string; + icon?: string | null; + duration?: number; + progressBar?: boolean; } -export interface ToastItem { - id: number; - message: string; - type: ToastType; - duration: number; +export interface TutorToastPromiseMessages { + loading: string | (() => string); + success: string | ((result: T) => string); + error: string | ((error: unknown) => string); +} + +export interface TutorToastItem { + id: string; title: string; + description?: string; + type: TutorToastType; + duration: number; } export interface AlpineToastData { - toasts: ToastItem[]; - $el?: HTMLElement; init(): void; - show(message: string, config?: ToastConfig): void; - remove(id: number): void; + show(message: string, config?: ToastConfig): string; + remove(id: string): void; clear(): void; - success(message: string, duration?: number): void; - error(message: string, duration?: number): void; - warning(message: string, duration?: number): void; - info(message: string, duration?: number): void; + dismiss(id?: string): void; + success(message: string, duration?: number): string; + error(message: string, duration?: number): string; + warning(message: string, duration?: number): string; + info(message: string, duration?: number): string; } + +export type ToastType = Extract; +export type ToastConfig = Pick & + Pick; +export type ToastItem = TutorToastItem; diff --git a/assets/src/js/@types/styles.d.ts b/assets/src/js/@types/styles.d.ts new file mode 100644 index 0000000000..b069d59696 --- /dev/null +++ b/assets/src/js/@types/styles.d.ts @@ -0,0 +1,19 @@ +declare module '*.scss' { + const content: Record; + export default content; +} + +declare module '*.scss?inline' { + const content: Record; + export default content; +} + +declare module '*.css' { + const content: Record; + export default content; +} + +declare module '*.css?inline' { + const content: Record; + export default content; +} diff --git a/assets/src/js/v3/entries/addon-list/components/AddonCard.tsx b/assets/src/js/v3/entries/addon-list/components/AddonCard.tsx index 64e58a0d81..435470a8e2 100644 --- a/assets/src/js/v3/entries/addon-list/components/AddonCard.tsx +++ b/assets/src/js/v3/entries/addon-list/components/AddonCard.tsx @@ -4,7 +4,7 @@ import { useRef, useState } from 'react'; import SVGIcon from '@TutorShared/atoms/SVGIcon'; import Switch from '@TutorShared/atoms/Switch'; -import { useToast } from '@TutorShared/atoms/Toast'; +import { useToast } from '@Core/ts/toast'; import Tooltip from '@TutorShared/atoms/Tooltip'; import { tutorConfig } from '@TutorShared/config/config'; diff --git a/assets/src/js/v3/entries/addon-list/components/App.tsx b/assets/src/js/v3/entries/addon-list/components/App.tsx index ce5535fb5a..0bf196c11b 100644 --- a/assets/src/js/v3/entries/addon-list/components/App.tsx +++ b/assets/src/js/v3/entries/addon-list/components/App.tsx @@ -1,6 +1,6 @@ import { Global } from '@emotion/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import ToastProvider from '@TutorShared/atoms/Toast'; +import ToastProvider from '@Core/ts/toast'; import RTLProvider from '@TutorShared/components/RTLProvider'; import { createGlobalCss } from '@TutorShared/utils/style-utils'; import { useState } from 'react'; @@ -27,7 +27,7 @@ function App() { return ( - +
diff --git a/assets/src/js/v3/entries/addon-list/services/addons.ts b/assets/src/js/v3/entries/addon-list/services/addons.ts index 54f1dbf784..369d3208b0 100644 --- a/assets/src/js/v3/entries/addon-list/services/addons.ts +++ b/assets/src/js/v3/entries/addon-list/services/addons.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery } from '@tanstack/react-query'; -import { useToast } from '@TutorShared/atoms/Toast'; +import { useToast } from '@Core/ts/toast'; import { tutorConfig } from '@TutorShared/config/config'; import { wpAjaxInstance } from '@TutorShared/utils/api'; import endpoints from '@TutorShared/utils/endpoints'; diff --git a/assets/src/js/v3/entries/coupon-details/components/App.tsx b/assets/src/js/v3/entries/coupon-details/components/App.tsx index e84684d87a..8483ae71a5 100644 --- a/assets/src/js/v3/entries/coupon-details/components/App.tsx +++ b/assets/src/js/v3/entries/coupon-details/components/App.tsx @@ -1,6 +1,6 @@ import { Global } from '@emotion/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import ToastProvider from '@TutorShared/atoms/Toast'; +import ToastProvider from '@Core/ts/toast'; import { ModalProvider } from '@TutorShared/components/modals/Modal'; import RTLProvider from '@TutorShared/components/RTLProvider'; import { createGlobalCss } from '@TutorShared/utils/style-utils'; @@ -27,7 +27,7 @@ function App() { return ( - +
diff --git a/assets/src/js/v3/entries/coupon-details/services/coupon.ts b/assets/src/js/v3/entries/coupon-details/services/coupon.ts index 47c8b114fd..39f4bff9b2 100644 --- a/assets/src/js/v3/entries/coupon-details/services/coupon.ts +++ b/assets/src/js/v3/entries/coupon-details/services/coupon.ts @@ -1,4 +1,4 @@ -import { useToast } from '@TutorShared/atoms/Toast'; +import { useToast } from '@Core/ts/toast'; import config from '@TutorShared/config/config'; import { DateFormats } from '@TutorShared/config/constants'; import { wpAjaxInstance } from '@TutorShared/utils/api'; diff --git a/assets/src/js/v3/entries/course-builder/components/App.tsx b/assets/src/js/v3/entries/course-builder/components/App.tsx index 8e8469d6da..c580c1b665 100644 --- a/assets/src/js/v3/entries/course-builder/components/App.tsx +++ b/assets/src/js/v3/entries/course-builder/components/App.tsx @@ -1,6 +1,6 @@ import routes from '@CourseBuilderConfig/routes'; import { CourseBuilderSlotProvider } from '@CourseBuilderContexts/CourseBuilderSlotContext'; -import ToastProvider from '@TutorShared/atoms/Toast'; +import ToastProvider from '@Core/ts/toast'; import RTLProvider from '@TutorShared/components/RTLProvider'; import { ModalProvider } from '@TutorShared/components/modals/Modal'; import { createGlobalCss } from '@TutorShared/utils/style-utils'; @@ -32,7 +32,7 @@ const App = () => { return ( - + diff --git a/assets/src/js/v3/entries/course-builder/components/ai-course-modal/ContentGeneration.tsx b/assets/src/js/v3/entries/course-builder/components/ai-course-modal/ContentGeneration.tsx index a7d5329e21..91a35ddc28 100644 --- a/assets/src/js/v3/entries/course-builder/components/ai-course-modal/ContentGeneration.tsx +++ b/assets/src/js/v3/entries/course-builder/components/ai-course-modal/ContentGeneration.tsx @@ -8,7 +8,7 @@ import Button from '@TutorShared/atoms/Button'; import { GradientLoadingSpinner } from '@TutorShared/atoms/LoadingSpinner'; import MagicButton from '@TutorShared/atoms/MagicButton'; import SVGIcon from '@TutorShared/atoms/SVGIcon'; -import { useToast } from '@TutorShared/atoms/Toast'; +import { useToast } from '@Core/ts/toast'; import { getCourseId } from '@CourseBuilderUtils/utils'; import FormTextareaInput from '@TutorShared/components/fields/FormTextareaInput'; diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/TopicFooter.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/TopicFooter.tsx index 09bd29291b..30288f08ff 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/TopicFooter.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/TopicFooter.tsx @@ -7,7 +7,7 @@ import { useFormContext } from 'react-hook-form'; import Button from '@TutorShared/atoms/Button'; import ProBadge from '@TutorShared/atoms/ProBadge'; import SVGIcon from '@TutorShared/atoms/SVGIcon'; -import { useToast } from '@TutorShared/atoms/Toast'; +import { useToast } from '@Core/ts/toast'; import { useModal } from '@TutorShared/components/modals/Modal'; import Show from '@TutorShared/controls/Show'; diff --git a/assets/src/js/v3/entries/course-builder/components/modals/QuizModal.tsx b/assets/src/js/v3/entries/course-builder/components/modals/QuizModal.tsx index acd157419d..ff4038b747 100644 --- a/assets/src/js/v3/entries/course-builder/components/modals/QuizModal.tsx +++ b/assets/src/js/v3/entries/course-builder/components/modals/QuizModal.tsx @@ -6,7 +6,7 @@ import { Controller, FormProvider } from 'react-hook-form'; import Button from '@TutorShared/atoms/Button'; import { LoadingOverlay } from '@TutorShared/atoms/LoadingSpinner'; import SVGIcon from '@TutorShared/atoms/SVGIcon'; -import { useToast } from '@TutorShared/atoms/Toast'; +import { useToast } from '@Core/ts/toast'; import FormTextareaInput from '@TutorShared/components/fields/FormTextareaInput'; import type { ModalProps } from '@TutorShared/components/modals/Modal'; diff --git a/assets/src/js/v3/entries/course-builder/services/course.ts b/assets/src/js/v3/entries/course-builder/services/course.ts index 044629cfa9..6dc3ac92e3 100644 --- a/assets/src/js/v3/entries/course-builder/services/course.ts +++ b/assets/src/js/v3/entries/course-builder/services/course.ts @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { __ } from '@wordpress/i18n'; import { format, isBefore, isValid, parseISO } from 'date-fns'; -import { useToast } from '@TutorShared/atoms/Toast'; +import { useToast } from '@Core/ts/toast'; import type { UserOption } from '@TutorShared/components/fields/FormSelectUser'; import type { CourseVideo } from '@TutorShared/components/fields/FormVideoInput'; diff --git a/assets/src/js/v3/entries/course-builder/services/curriculum.ts b/assets/src/js/v3/entries/course-builder/services/curriculum.ts index 2fb6ef6583..5bdc4d47d7 100644 --- a/assets/src/js/v3/entries/course-builder/services/curriculum.ts +++ b/assets/src/js/v3/entries/course-builder/services/curriculum.ts @@ -3,7 +3,7 @@ import { __ } from '@wordpress/i18n'; import type { AssignmentForm } from '@CourseBuilderComponents/modals/AssignmentModal'; import type { LessonForm } from '@CourseBuilderComponents/modals/LessonModal'; -import { useToast } from '@TutorShared/atoms/Toast'; +import { useToast } from '@Core/ts/toast'; import type { CourseVideo } from '@TutorShared/components/fields/FormVideoInput'; import type { ContentDripType, GoogleMeet, ZoomMeeting } from '@CourseBuilderServices/course'; diff --git a/assets/src/js/v3/entries/course-builder/services/quiz.ts b/assets/src/js/v3/entries/course-builder/services/quiz.ts index f6c0509a44..cfdd91e5de 100644 --- a/assets/src/js/v3/entries/course-builder/services/quiz.ts +++ b/assets/src/js/v3/entries/course-builder/services/quiz.ts @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { __ } from '@wordpress/i18n'; import type { AxiosResponse } from 'axios'; -import { useToast } from '@TutorShared/atoms/Toast'; +import { useToast } from '@Core/ts/toast'; import type { ContentDripType } from '@CourseBuilderServices/course'; import { tutorConfig } from '@TutorShared/config/config'; diff --git a/assets/src/js/v3/entries/import-export/components/App.tsx b/assets/src/js/v3/entries/import-export/components/App.tsx index 84beede438..cc08e07e51 100644 --- a/assets/src/js/v3/entries/import-export/components/App.tsx +++ b/assets/src/js/v3/entries/import-export/components/App.tsx @@ -3,7 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useState } from 'react'; import Main from '@ImportExport/components/Main'; -import ToastProvider from '@TutorShared/atoms/Toast'; +import ToastProvider from '@Core/ts/toast'; import RTLProvider from '@TutorShared/components/RTLProvider'; import { ModalProvider } from '@TutorShared/components/modals/Modal'; import { createGlobalCss } from '@TutorShared/utils/style-utils'; diff --git a/assets/src/js/v3/entries/import-export/components/Import.tsx b/assets/src/js/v3/entries/import-export/components/Import.tsx index 55c8b76f1d..0bcdddb087 100644 --- a/assets/src/js/v3/entries/import-export/components/Import.tsx +++ b/assets/src/js/v3/entries/import-export/components/Import.tsx @@ -3,7 +3,7 @@ import { __, sprintf } from '@wordpress/i18n'; import { useEffect } from 'react'; import { type ErrorResponse } from 'react-router-dom'; -import { useToast } from '@TutorShared/atoms/Toast'; +import { useToast } from '@Core/ts/toast'; import { UploadButton } from '@TutorShared/molecules/FileUploader'; import ImportModal from '@ImportExport/components/modals/ImportModal'; diff --git a/assets/src/js/v3/entries/import-export/components/modals/import-export-states/ImportInitialState.tsx b/assets/src/js/v3/entries/import-export/components/modals/import-export-states/ImportInitialState.tsx index 060186c255..bd8b7b7b53 100644 --- a/assets/src/js/v3/entries/import-export/components/modals/import-export-states/ImportInitialState.tsx +++ b/assets/src/js/v3/entries/import-export/components/modals/import-export-states/ImportInitialState.tsx @@ -6,7 +6,7 @@ import { Controller } from 'react-hook-form'; import Button from '@TutorShared/atoms/Button'; import { LoadingSection } from '@TutorShared/atoms/LoadingSpinner'; import SVGIcon from '@TutorShared/atoms/SVGIcon'; -import { useToast } from '@TutorShared/atoms/Toast'; +import { useToast } from '@Core/ts/toast'; import { UploadButton } from '@TutorShared/molecules/FileUploader'; import { hasAnyCourseWithChildren } from '@ImportExport/utils/utils'; diff --git a/assets/src/js/v3/entries/import-export/services/import-export.ts b/assets/src/js/v3/entries/import-export/services/import-export.ts index 7029caa13d..bed30dd9c8 100644 --- a/assets/src/js/v3/entries/import-export/services/import-export.ts +++ b/assets/src/js/v3/entries/import-export/services/import-export.ts @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { type ErrorResponse } from 'react-router-dom'; -import { useToast } from '@TutorShared/atoms/Toast'; +import { useToast } from '@Core/ts/toast'; import { tutorConfig } from '@TutorShared/config/config'; import { type Course } from '@TutorShared/services/course'; diff --git a/assets/src/js/v3/entries/order-details/components/App.tsx b/assets/src/js/v3/entries/order-details/components/App.tsx index 4b7f6b4980..3f75a80666 100644 --- a/assets/src/js/v3/entries/order-details/components/App.tsx +++ b/assets/src/js/v3/entries/order-details/components/App.tsx @@ -2,7 +2,7 @@ import { Global } from '@emotion/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useState } from 'react'; -import ToastProvider from '@TutorShared/atoms/Toast'; +import ToastProvider from '@Core/ts/toast'; import RTLProvider from '@TutorShared/components/RTLProvider'; import { ModalProvider } from '@TutorShared/components/modals/Modal'; diff --git a/assets/src/js/v3/entries/order-details/services/order.ts b/assets/src/js/v3/entries/order-details/services/order.ts index 5fd52d299d..4baea83775 100644 --- a/assets/src/js/v3/entries/order-details/services/order.ts +++ b/assets/src/js/v3/entries/order-details/services/order.ts @@ -1,4 +1,4 @@ -import { useToast } from '@TutorShared/atoms/Toast'; +import { useToast } from '@Core/ts/toast'; import { wpAjaxInstance } from '@TutorShared/utils/api'; import endpoints from '@TutorShared/utils/endpoints'; import type { ErrorResponse } from '@TutorShared/utils/form'; diff --git a/assets/src/js/v3/entries/payment-settings/components/App.tsx b/assets/src/js/v3/entries/payment-settings/components/App.tsx index c47eb3fb7c..d810c7a619 100644 --- a/assets/src/js/v3/entries/payment-settings/components/App.tsx +++ b/assets/src/js/v3/entries/payment-settings/components/App.tsx @@ -2,7 +2,7 @@ import { Global } from '@emotion/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useState } from 'react'; -import ToastProvider from '@TutorShared/atoms/Toast'; +import ToastProvider from '@Core/ts/toast'; import RTLProvider from '@TutorShared/components/RTLProvider'; import { ModalProvider } from '@TutorShared/components/modals/Modal'; import { createGlobalCss } from '@TutorShared/utils/style-utils'; diff --git a/assets/src/js/v3/entries/payment-settings/fields/OptionWebhookUrl.tsx b/assets/src/js/v3/entries/payment-settings/fields/OptionWebhookUrl.tsx index 66270c443b..5357fb6ae7 100644 --- a/assets/src/js/v3/entries/payment-settings/fields/OptionWebhookUrl.tsx +++ b/assets/src/js/v3/entries/payment-settings/fields/OptionWebhookUrl.tsx @@ -2,7 +2,7 @@ import { CURRENT_VIEWPORT } from '@TutorShared/config/constants'; import { copyToClipboard } from '@TutorShared/utils/util'; import Button from '@TutorShared/atoms/Button'; import SVGIcon from '@TutorShared/atoms/SVGIcon'; -import { useToast } from '@TutorShared/atoms/Toast'; +import { useToast } from '@Core/ts/toast'; import FormFieldWrapper from '@TutorShared/components/fields/FormFieldWrapper'; import { colorTokens, spacing } from '@TutorShared/config/styles'; import { typography } from '@TutorShared/config/typography'; diff --git a/assets/src/js/v3/entries/payment-settings/services/payment.ts b/assets/src/js/v3/entries/payment-settings/services/payment.ts index b586cb9dca..c02dc38a68 100644 --- a/assets/src/js/v3/entries/payment-settings/services/payment.ts +++ b/assets/src/js/v3/entries/payment-settings/services/payment.ts @@ -1,4 +1,4 @@ -import { useToast } from '@TutorShared/atoms/Toast'; +import { useToast } from '@Core/ts/toast'; import { tutorConfig } from '@TutorShared/config/config'; import { wpAjaxInstance } from '@TutorShared/utils/api'; import endpoints from '@TutorShared/utils/endpoints'; diff --git a/assets/src/js/v3/entries/tax-settings/components/App.tsx b/assets/src/js/v3/entries/tax-settings/components/App.tsx index 235918190e..b02dd303cf 100644 --- a/assets/src/js/v3/entries/tax-settings/components/App.tsx +++ b/assets/src/js/v3/entries/tax-settings/components/App.tsx @@ -2,7 +2,7 @@ import { Global } from '@emotion/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useState } from 'react'; -import ToastProvider from '@TutorShared/atoms/Toast'; +import ToastProvider from '@Core/ts/toast'; import RTLProvider from '@TutorShared/components/RTLProvider'; import { ModalProvider } from '@TutorShared/components/modals/Modal'; import { createGlobalCss } from '@TutorShared/utils/style-utils'; diff --git a/assets/src/js/v3/shared/atoms/CheckBox.tsx b/assets/src/js/v3/shared/atoms/CheckBox.tsx index e3caa98163..43e4805164 100644 --- a/assets/src/js/v3/shared/atoms/CheckBox.tsx +++ b/assets/src/js/v3/shared/atoms/CheckBox.tsx @@ -126,6 +126,7 @@ const styles = { disabled: boolean; }) => css` position: absolute; + display: none !important; opacity: 0 !important; height: 0; width: 0; diff --git a/assets/src/js/v3/shared/atoms/Toast.tsx b/assets/src/js/v3/shared/atoms/Toast.tsx index 59dfc9057e..146b5b1c30 100644 --- a/assets/src/js/v3/shared/atoms/Toast.tsx +++ b/assets/src/js/v3/shared/atoms/Toast.tsx @@ -1,196 +1,2 @@ -import { css } from '@emotion/react'; -import { useTransition } from '@react-spring/web'; -import React, { type ReactNode, useCallback, useContext, useState } from 'react'; - -import { borderRadius, colorTokens, spacing, zIndex } from '@TutorShared/config/styles'; -import { typography } from '@TutorShared/config/typography'; -import { AnimatedDiv } from '@TutorShared/hooks/useAnimation'; -import { isBoolean } from '@TutorShared/utils/types'; -import { nanoid } from '@TutorShared/utils/util'; - -import Button from './Button'; -import SVGIcon from './SVGIcon'; - -type Position = 'top-left' | 'top-right' | 'top-center' | 'bottom-left' | 'bottom-right' | 'bottom-center'; - -interface ToastOption { - type: 'success' | 'dark' | 'danger' | 'warning'; - message: string; - id?: string; - autoCloseDelay?: boolean | number; - title?: string; - position?: Position; -} - -const defaultToastOption: ToastOption = { - type: 'dark', - message: '', - autoCloseDelay: 3000, - position: 'bottom-right', -}; - -interface ToastContextProps { - showToast: (option: ToastOption) => void; -} - -const ToastContext = React.createContext({ - showToast: () => {}, -}); - -export const useToast = () => useContext(ToastContext); - -const ToastProvider = ({ children, position = 'bottom-right' }: { children: ReactNode; position?: Position }) => { - const [toastList, setToastList] = useState([]); - - const transitions = useTransition(toastList, { - from: { - opacity: 0, - y: -40, - }, - enter: { - opacity: 1, - y: 0, - }, - leave: { - opacity: 0.5, - y: 100, - }, - config: { - duration: 300, - }, - }); - - const showToast = useCallback((option) => { - const toastOption = { ...defaultToastOption, ...option, id: nanoid() }; - setToastList((prev) => [toastOption, ...prev]); - let timeout: ReturnType; - - if (!isBoolean(toastOption.autoCloseDelay) && toastOption.autoCloseDelay) { - timeout = setTimeout(() => { - setToastList((prev) => prev.slice(0, -1)); - }, toastOption.autoCloseDelay); - } - - return () => { - clearTimeout(timeout); - }; - }, []); - - return ( - - {children} -
- {transitions((style, toast) => { - return ( - -
{toast.message}
- - -
- ); - })} -
-
- ); -}; - -export default ToastProvider; - -const styles = { - toastWrapper: (position: Position) => css` - display: flex; - flex-direction: column; - gap: ${spacing[16]}; - max-width: 400px; - position: fixed; - z-index: ${zIndex.highest}; - - ${position === 'top-left' && - css` - left: ${spacing[20]}; - top: calc(${spacing[20]} + 60px); - `} - ${position === 'top-right' && - css` - right: ${spacing[20]}; - top: calc(${spacing[20]} + 60px); - `} - ${position === 'top-center' && - css` - left: 50%; - top: calc(${spacing[20]} + 60px); - transform: translateX(-50%); - `} - ${position === 'bottom-left' && - css` - left: ${spacing[20]}; - bottom: ${spacing[20]}; - `} - ${position === 'bottom-right' && - css` - right: ${spacing[20]}; - bottom: ${spacing[20]}; - `} - ${position === 'bottom-center' && - css` - left: 50%; - bottom: ${spacing[20]}; - transform: translateX(-50%); - `} - `, - toastItem: (type: ToastOption['type']) => css` - width: 100%; - min-height: 60px; - display: flex; - align-items: center; - justify-content: space-between; - gap: ${spacing[16]}; - border-radius: ${borderRadius[6]}; - padding: ${spacing[16]}; - - svg > path { - color: ${colorTokens.icon.white}; - } - - ${type === 'dark' && - css` - background: ${colorTokens.color.black.main}; - `} - ${type === 'danger' && - css` - background: ${colorTokens.design.error}; - `} - ${type === 'success' && - css` - background: ${colorTokens.design.success}; - `} - ${type === 'warning' && - css` - background: ${colorTokens.color.warning[70]}; - - h5 { - color: ${colorTokens.text.primary}; - } - - svg > path { - color: ${colorTokens.text.primary}; - } - `} - `, - message: css` - ${typography.body()}; - color: ${colorTokens.text.white}; - `, - timesIcon: css` - path { - color: ${colorTokens.icon.white}; - } - `, -}; +export { default, useToast } from '@Core/ts/toast'; +export type { ToastOption } from '@Core/ts/toast'; diff --git a/assets/src/js/v3/shared/components/magic-ai-image/ImageGeneration.tsx b/assets/src/js/v3/shared/components/magic-ai-image/ImageGeneration.tsx index 4ad91e7d61..aa01a821f4 100644 --- a/assets/src/js/v3/shared/components/magic-ai-image/ImageGeneration.tsx +++ b/assets/src/js/v3/shared/components/magic-ai-image/ImageGeneration.tsx @@ -21,7 +21,7 @@ import photo from '@SharedImages/ai-types/photo.png'; import retro from '@SharedImages/ai-types/retro.png'; import sketch from '@SharedImages/ai-types/sketch.png'; -import { useToast } from '@TutorShared/atoms/Toast'; +import { useToast } from '@Core/ts/toast'; import type { ErrorResponse } from '@TutorShared/utils/form'; import { styleUtils } from '@TutorShared/utils/style-utils'; import { type OptionWithImage, isDefined } from '@TutorShared/utils/types'; diff --git a/assets/src/js/v3/shared/hooks/useWpMedia.ts b/assets/src/js/v3/shared/hooks/useWpMedia.ts index d3e1828256..934a6d644e 100644 --- a/assets/src/js/v3/shared/hooks/useWpMedia.ts +++ b/assets/src/js/v3/shared/hooks/useWpMedia.ts @@ -1,7 +1,7 @@ import { __, sprintf } from '@wordpress/i18n'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useToast } from '@TutorShared/atoms/Toast'; +import { useToast } from '@Core/ts/toast'; export interface WPMedia { id: number; diff --git a/assets/src/js/v3/shared/services/category.ts b/assets/src/js/v3/shared/services/category.ts index 94a53afd46..a87dbbf58d 100644 --- a/assets/src/js/v3/shared/services/category.ts +++ b/assets/src/js/v3/shared/services/category.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useToast } from '@TutorShared/atoms/Toast'; +import { useToast } from '@Core/ts/toast'; import { wpAuthApiInstance } from '@TutorShared/utils/api'; import endpoints from '@TutorShared/utils/endpoints'; import type { ErrorResponse } from '@TutorShared/utils/form'; diff --git a/assets/src/js/v3/shared/services/magic-ai.ts b/assets/src/js/v3/shared/services/magic-ai.ts index f8fca86d97..4c784a98f5 100644 --- a/assets/src/js/v3/shared/services/magic-ai.ts +++ b/assets/src/js/v3/shared/services/magic-ai.ts @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useToast } from '@TutorShared/atoms/Toast'; +import { useToast } from '@Core/ts/toast'; import type { StyleType } from '@TutorShared/components/magic-ai-image/ImageContext'; import type { TopicContent } from '@CourseBuilderComponents/ai-course-modal/ContentGenerationContext'; diff --git a/assets/src/js/v3/shared/services/subscription.ts b/assets/src/js/v3/shared/services/subscription.ts index 1cbefb8a12..79c5c31e6c 100644 --- a/assets/src/js/v3/shared/services/subscription.ts +++ b/assets/src/js/v3/shared/services/subscription.ts @@ -3,7 +3,7 @@ import { __ } from '@wordpress/i18n'; import type { AxiosResponse } from 'axios'; import { format } from 'date-fns'; -import { useToast } from '@TutorShared/atoms/Toast'; +import { useToast } from '@Core/ts/toast'; import { DateFormats } from '@TutorShared/config/constants'; import { wpAjaxInstance } from '@TutorShared/utils/api'; import endpoints from '@TutorShared/utils/endpoints'; diff --git a/assets/src/js/v3/shared/services/tags.ts b/assets/src/js/v3/shared/services/tags.ts index 770630e60b..9b5cabe248 100644 --- a/assets/src/js/v3/shared/services/tags.ts +++ b/assets/src/js/v3/shared/services/tags.ts @@ -1,4 +1,4 @@ -import { useToast } from '@TutorShared/atoms/Toast'; +import { useToast } from '@Core/ts/toast'; import { wpAuthApiInstance } from '@TutorShared/utils/api'; import endpoints from '@TutorShared/utils/endpoints'; import type { ErrorResponse } from '@TutorShared/utils/form'; diff --git a/classes/Course.php b/classes/Course.php index 55c3ea7add..2b10bd8d74 100644 --- a/classes/Course.php +++ b/classes/Course.php @@ -1806,7 +1806,7 @@ private function save_course_content_order( $sort_order = array() ) { $i = 0; foreach ( $sort_order as $topic ) { - $i++; + ++$i; $wpdb->update( $wpdb->posts, array( 'menu_order' => $i ), @@ -2913,10 +2913,10 @@ public function tutor_lms_hide_course_complete_btn( $html ) { } } if ( ! $has_passed ) { - $required_assignment_pass++; + ++$required_assignment_pass; } } else { - $required_assignment_pass++; + ++$required_assignment_pass; } } @@ -2932,11 +2932,11 @@ public function tutor_lms_hide_course_complete_btn( $html ) { $earned_percentage = QuizModel::calculate_attempt_earned_percentage( $attempt ); if ( $earned_percentage < $passing_grade ) { - $required_quiz_pass++; + ++$required_quiz_pass; $is_quiz_pass = false; } } else { - $required_quiz_pass++; + ++$required_quiz_pass; $is_quiz_pass = false; } } diff --git a/rspack.config.mjs b/rspack.config.mjs index 329a31597a..f6c8d175a8 100644 --- a/rspack.config.mjs +++ b/rspack.config.mjs @@ -88,9 +88,36 @@ const createConfig = (env, options) => { cache: false, module: { rules: [ + { + test: /\.s[ac]ss$/i, + resourceQuery: /inline/, + use: [ + 'style-loader', + cssLoaderConfig, + { + loader: 'postcss-loader', + options: { + postcssOptions: { + plugins: [ + !isDevelopment && + purgecss({ + content: purgecssContent, + defaultExtractor: (content) => content.match(/[\w-/:]+(? { { test: /\.s[ac]ss$/i, exclude: [path.resolve(__dirname, 'assets/core/scss')], + resourceQuery: { + not: [/inline/], + }, use: [rspack.CssExtractRspackPlugin.loader, cssLoaderConfig, sassLoaderConfig], }, { diff --git a/stories/atoms/Toast.stories.tsx b/stories/atoms/Toast.stories.tsx index fd22eddd73..bd3345dc89 100644 --- a/stories/atoms/Toast.stories.tsx +++ b/stories/atoms/Toast.stories.tsx @@ -3,6 +3,8 @@ import Button from '@TutorShared/atoms/Button'; import ToastProvider, { useToast } from '@TutorShared/atoms/Toast'; import type { Meta, StoryObj } from 'storybook-react-rsbuild'; +import '../../assets/css/tutor-core.min.css'; + const meta = { title: 'Atoms/Toast', component: ToastProvider, @@ -42,7 +44,7 @@ type Story = StoryObj; const ToastDemo = () => { const { showToast } = useToast(); - const handleShowToast = (type: 'success' | 'danger' | 'warning' | 'dark') => () => { + const handleShowToast = (type: 'success' | 'danger' | 'warning' | 'dark' | 'info') => () => { showToast({ type, message: `This is a ${type} toast!`, @@ -90,6 +92,10 @@ const ToastDemo = () => { Show Warning Toast + +