diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e65a6db..89c011c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -26,9 +26,7 @@ jobs: deno-version: v1.x - name: Install pnpm - uses: pnpm/action-setup@v3 - with: - version: 9 + uses: pnpm/action-setup@v4 - name: Build qwik-sonner run: "pnpm install --no-frozen-lockfile && pnpm run build" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9185141..523f4ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,20 +6,33 @@ permissions: on: push: tags: - - 'v*' + - "v*" jobs: - release: + gh_release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-node@v3 - with: - node-version: 16.x + - uses: actions/setup-node@v4 - run: npx changelogithub # or changelogithub@0.12 if ensure the stable result env: - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} \ No newline at end of file + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + + npm_publish: + runs-on: ubuntu-latest + steps: + - name: clone repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: installing pnpm + uses: pnpm/action-setup@v4 + + - run: pnpm publish + env: + NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} diff --git a/package.json b/package.json index 380da9f..1afe129 100644 --- a/package.json +++ b/package.json @@ -64,14 +64,14 @@ "type": "module", "scripts": { "build": "qwik build", - "build.lib": "vite build --mode lib", + "build.lib": "ENTRY=styled vite build --mode lib && ENTRY=headless vite build --mode lib", "build.types": "tsc --emitDeclarationOnly -p tsconfig.lib.json", "dev": "vite --mode ssr", "dev.debug": "node --inspect-brk ./node_modules/vite/bin/vite.js --mode ssr --force", "fmt": "prettier --write .", "fmt.check": "prettier --check .", "lint": "eslint \"src/**/*.ts*\"", - "release": "bumpp --commit --tag --push && pnpm publish", + "release": "bumpp --commit --tag --push", "prepublishOnly": "pnpm build && pnpm test", "start": "vite --open --mode ssr", "test": "pnpm --filter=test run test", @@ -79,11 +79,13 @@ }, "devDependencies": { "@builder.io/qwik": "1.5.3", + "@types/dompurify": "3.0.5", "@types/eslint": "^8.56.10", "@types/node": "^20.12.7", "@typescript-eslint/eslint-plugin": "^7.7.1", "@typescript-eslint/parser": "^7.7.1", "bumpp": "9.4.0", + "dompurify": "3.1.2", "eslint-plugin-qwik": "latest", "eslint": "^8.57.0", "prettier": "^3.2.5", @@ -92,4 +94,4 @@ "vite": "^5.2.10", "vite-tsconfig-paths": "^4.2.1" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46bf175..3aa00dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@builder.io/qwik': specifier: 1.5.3 version: 1.5.3(@types/node@20.12.8)(undici@6.10.2) + '@types/dompurify': + specifier: 3.0.5 + version: 3.0.5 '@types/eslint': specifier: ^8.56.10 version: 8.56.10 @@ -26,6 +29,9 @@ importers: bumpp: specifier: 9.4.0 version: 9.4.0 + dompurify: + specifier: 3.1.2 + version: 3.1.2 eslint: specifier: ^8.57.0 version: 8.57.0 @@ -619,6 +625,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/dompurify@3.0.5': + resolution: {integrity: sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==} + '@types/eslint@8.56.1': resolution: {integrity: sha512-18PLWRzhy9glDQp3+wOgfLYRWlhgX0azxgJ63rdpoUHyrC9z0f5CkFburjQx4uD7ZCruw85ZtMt6K+L+R8fLJQ==} @@ -661,6 +670,9 @@ packages: '@types/semver@7.5.8': resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.10': resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==} @@ -1085,6 +1097,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.1.2: + resolution: {integrity: sha512-hLGGBI1tw5N8qTELr3blKjAML/LY4ANxksbS612UiJyDfyf/2D092Pvm+S7pmeTGJRqvlJkFzBoHBQKgQlOQVg==} + domutils@3.1.0: resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} @@ -2875,6 +2890,10 @@ snapshots: dependencies: '@types/ms': 0.7.34 + '@types/dompurify@3.0.5': + dependencies: + '@types/trusted-types': 2.0.7 + '@types/eslint@8.56.1': dependencies: '@types/estree': 1.0.5 @@ -2919,6 +2938,8 @@ snapshots: '@types/semver@7.5.8': {} + '@types/trusted-types@2.0.7': {} + '@types/unist@2.0.10': {} '@types/unist@3.0.2': {} @@ -3406,6 +3427,8 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.1.2: {} + domutils@3.1.0: dependencies: dom-serializer: 2.0.0 diff --git a/src/index.ts b/src/index.ts index e50892e..613d874 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ export { Toaster } from "./lib/styled/index"; -export { toast } from "./lib/core/state"; +export { toast } from "./lib/headless/state"; export { type ToastT as Toast, type ExternalToast, type ToasterProps, -} from "./lib/core/types"; +} from "./lib/headless/types"; diff --git a/src/lib/core/const.ts b/src/lib/core/const.ts deleted file mode 100644 index 505af76..0000000 --- a/src/lib/core/const.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const VISIBLE_TOASTS_AMOUNT = 3; // Visible toasts amount -export const VIEWPORT_OFFSET = "32px"; // Viewport padding -export const TOAST_LIFETIME = 4000; // Default lifetime of a toasts (in ms) -export const TOAST_WIDTH = 356; // Default toast width -export const GAP = 14; // Default gap between toasts -export const SWIPE_THRESHOLD = 20; -export const TIME_BEFORE_UNMOUNT = 200; diff --git a/src/lib/core/index.ts b/src/lib/core/index.ts deleted file mode 100644 index 56834b2..0000000 --- a/src/lib/core/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./assets"; -export * from "./const"; -export * from "./types"; -export * from "./state"; diff --git a/src/lib/core/state.ts b/src/lib/core/state.ts deleted file mode 100644 index ad60cf6..0000000 --- a/src/lib/core/state.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { JSXOutput } from "@builder.io/qwik"; -import { - ExternalToast, - PromiseData, - PromiseT, - ToastT, - ToastToDismiss, - ToastTypes, -} from "./index"; - -let toastsCounter: number = 1; - -export function createToastState() { - let toasts: (ToastT | ToastToDismiss)[] = []; - const subscribers: ((toast: ToastT | ToastToDismiss) => void)[] = []; - - // this subscribe func is to handle the dismiss props - // eslint-disable-next-line @typescript-eslint/no-unused-vars - function subscribe(subscriber: (toast: ToastT | ToastToDismiss) => void) { - subscribers.push(subscriber); - } - - function addToast(data: ToastT) { - subscribers.forEach((subscriber) => subscriber(data)); - toasts = [...toasts, data]; - } - - function create( - data: ExternalToast & { - message?: string | JSXOutput; - type?: ToastTypes; - promise?: PromiseT; - jsx?: JSXOutput; - } - ) { - const { message, ...rest } = data; - const id = - typeof data?.id === "number" || (data.id && data.id?.length > 0) - ? data.id - : toastsCounter++; - const dismissible = - data.dismissible === undefined ? true : data.dismissible; - - const $toasts = toasts; - - const alreadyExists = $toasts.find((toast) => { - return toast.id === id; - }); - - if (alreadyExists) { - toasts = $toasts.map((toast) => { - if (toast.id === id) { - subscribers.forEach((subscriber) => - subscriber({ - ...toast, - ...data, - id, - title: message, - dismissible, - }) - ); - return { ...toast, ...data, id, title: message, dismissible }; - } - return toast; - }); - } else { - addToast({ ...rest, id, title: message, dismissible }); - } - - if (toasts.length === 1) { - document.dispatchEvent( - new CustomEvent("sonner", { - detail: { ...rest, id, title: message, dismissible }, - }) - ); - } - - return id; - } - - function dismiss(id?: number | string) { - if (!id) { - toasts.forEach((toast) => { - subscribers.forEach((subscriber) => - subscriber({ id: toast.id, dismiss: true }) - ); - }); - } else { - subscribers.forEach((subscriber) => subscriber({ id, dismiss: true })); - } - - return id; - } - - function message(message: string | JSXOutput, data?: ExternalToast) { - return create({ ...data, message }); - } - - function error(message: string | JSXOutput, data?: ExternalToast) { - return create({ ...data, type: "error", message }); - } - - function success(message: string | JSXOutput, data?: ExternalToast) { - return create({ ...data, type: "success", message }); - } - - function info(message: string | JSXOutput, data?: ExternalToast) { - return create({ ...data, type: "info", message }); - } - - function warning(message: string | JSXOutput, data?: ExternalToast) { - return create({ ...data, type: "warning", message }); - } - - function loading(message: string | JSXOutput, data?: ExternalToast) { - return create({ ...data, type: "loading", message }); - } - - function promise( - promise: PromiseT, - data?: PromiseData - ) { - if (!data) { - // Nothing to show - return; - } - - let id: string | number | undefined = undefined; - if (data.loading !== undefined) { - id = create({ - ...data, - promise, - type: "loading", - message: data.loading, - }); - } - - const p = promise(); - - let shouldDismiss = id !== undefined; - - p.then(async (response) => { - // TODO: Clean up TS here, response has incorrect type - if (data.success !== undefined) { - shouldDismiss = false; - const message = - typeof data.success === "function" - ? await data.success(response) - : data.success; - create({ id, type: "success", message }); - } - }) - .catch((error) => { - if (data.error !== undefined) { - shouldDismiss = false; - const message = - typeof data.error === "function" ? data.error(error) : data.error; - create({ id, type: "error", message }); - } - }) - .finally(() => { - if (shouldDismiss) { - // Toast is still in load state (and will be indefinitely — dismiss it) - dismiss(id); - id = undefined; - } - - data.finally?.(); - }); - - return id; - } - - function custom( - jsx: (id: number | string) => JSXOutput, - data?: ExternalToast - ) { - const id = data?.id || toastsCounter++; - create({ jsx: jsx(id), id, ...data }); - return { id }; - } - - return { - // methods - create, - addToast, - dismiss, - message, - error, - success, - info, - warning, - loading, - promise, - custom, - subscribe, - // stores - toasts, - }; -} - -export const toastState = createToastState(); - -// bind this to the toast function -function toastFunction(message: string | JSXOutput, data?: ExternalToast) { - return toastState.create({ - message, - ...data, - }); -} - -const basicToast = toastFunction; - -// We use `Object.assign` to maintain the correct types as we would lose them otherwise -export const toast = Object.assign(basicToast, { - success: toastState.success, - info: toastState.info, - warning: toastState.warning, - error: toastState.error, - custom: toastState.custom, - message: toastState.message, - promise: toastState.promise, - dismiss: toastState.dismiss, - loading: toastState.loading, -}); diff --git a/src/lib/core/types.ts b/src/lib/core/types.ts deleted file mode 100644 index 5905217..0000000 --- a/src/lib/core/types.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type { CSSProperties, JSXOutput, PropsOf, QRL } from "@builder.io/qwik"; - -export type ToastTypes = - | "normal" - | "action" - | "success" - | "info" - | "warning" - | "error" - | "loading" - | "default"; - -export type PromiseT = QRL<() => Promise>; - -export type PromiseData = ExternalToast & { - loading?: string | JSXOutput; - success?: string | JSXOutput | QRL<(data: ToastData) => JSXOutput | string>; - error?: string | JSXOutput | ((error: any) => JSXOutput | string); - finally?: QRL<() => void | Promise>; -}; - -export type Dir = "rtl" | "ltr" | "auto"; -export interface ToastClassnames { - toast?: string; - title?: string; - description?: string; - loader?: string; - closeButton?: string; - cancelButton?: string; - actionButton?: string; - success?: string; - error?: string; - info?: string; - warning?: string; - default?: string; -} - -export interface ToastT { - id: number | string; - title?: string | JSXOutput; - type?: ToastTypes; - icon?: JSXOutput; - jsx?: JSXOutput; - invert?: boolean; - dismissible?: boolean; - description?: string | JSXOutput; - duration?: number; - delete?: boolean; - important?: boolean; - action?: { - label: string; - onClick: QRL<(event: PointerEvent, element: HTMLButtonElement) => void>; - }; - cancel?: { - label: string; - onClick?: QRL<() => void>; - }; - onDismiss?: QRL<(toast: ToastT) => void>; - onAutoClose?: QRL<(toast: ToastT) => void>; - promise?: PromiseT; - cancelButtonStyle?: string | CSSProperties; - actionButtonStyle?: string | CSSProperties; - style?: CSSProperties; - unstyled?: boolean; - className?: string; - classNames?: ToastClassnames; - descriptionClassName?: string; - position?: Position; -} - -export type Position = - | "top-left" - | "top-right" - | "bottom-left" - | "bottom-right" - | "top-center" - | "bottom-center"; -export interface HeightT { - height: number; - toastId: number | string; -} - -interface ToastOptions { - className?: string; - descriptionClassName?: string; - style?: CSSProperties; - cancelButtonStyle?: string | CSSProperties; - actionButtonStyle?: string | CSSProperties; - duration?: number; - unstyled?: boolean; - classNames?: ToastClassnames; -} - -export interface ToasterProps extends PropsOf<"ol"> { - invert?: boolean; - theme?: Theme; - position?: Position; - hotkey?: string[]; - richColors?: boolean; - expand?: boolean; - duration?: number; - gap?: number; - visibleToasts?: number; - closeButton?: boolean; - toastOptions?: ToastOptions; - style?: CSSProperties; - offset?: string | number; - dir?: Dir; - loadingIcon?: JSXOutput; - containerAriaLabel?: string; -} - -export interface ToasterStore { - toasts: ToastT[]; - expanded: boolean; - heights: HeightT[]; - interacting: boolean; -} - -export interface ToastProps { - toast: ToastT; - index: number; - invert: boolean; - state: ToasterStore; - removeToast: QRL<(toast: ToastT) => ToastT[]>; - gap: number; - position: Position; - visibleToasts: number; - expandByDefault: boolean; - closeButton: boolean; - style?: CSSProperties; - cancelButtonStyle?: string | CSSProperties; - actionButtonStyle?: string | CSSProperties; - duration: number; - className?: string; - unstyled?: boolean; - descriptionClassName?: string; - loadingIcon?: JSXOutput; - classNames?: ToastClassnames; - closeButtonAriaLabel?: string; -} - -export enum SwipeStateTypes { - SwipedOut = "SwipedOut", - SwipedBack = "SwipedBack", - NotSwiped = "NotSwiped", -} - -export type Theme = "light" | "dark" | "system"; - -export interface ToastToDismiss { - id: number | string; - dismiss: boolean; -} - -export type ExternalToast = Omit< - ToastT, - "id" | "type" | "jsx" | "delete" | "promise" -> & { - id?: number | string; -}; diff --git a/src/lib/headless/const.ts b/src/lib/headless/const.ts new file mode 100644 index 0000000..124a7fe --- /dev/null +++ b/src/lib/headless/const.ts @@ -0,0 +1,19 @@ +export const VISIBLE_TOASTS_AMOUNT = 3; + +// Viewport padding +export const VIEWPORT_OFFSET = "32px"; + +// Default lifetime of a toasts (in ms) +export const TOAST_LIFETIME = 4000; + +// Default toast width +export const TOAST_WIDTH = 356; + +// Default gap between toasts +export const GAP = 14; + +// Threshold to dismiss a toast +export const SWIPE_THRESHOLD = 20; + +// Equal to exit animation duration +export const TIME_BEFORE_UNMOUNT = 200; diff --git a/src/lib/core/assets.tsx b/src/lib/headless/icons.tsx similarity index 87% rename from src/lib/core/assets.tsx rename to src/lib/headless/icons.tsx index 76e5778..05c6e03 100644 --- a/src/lib/core/assets.tsx +++ b/src/lib/headless/icons.tsx @@ -1,8 +1,8 @@ -import { JSX } from "@builder.io/qwik/jsx-runtime"; -import type { ToastTypes } from "./index"; +"use client"; import { component$ } from "@builder.io/qwik"; +import type { ToastTypes } from "./types"; -export const getAsset = (type?: ToastTypes): JSX.Element | null => { +export const getAsset = (type: ToastTypes) => { switch (type) { case "success": return ; @@ -25,10 +25,10 @@ const bars = Array(12).fill(0); export const Loader = component$(({ visible }: { visible: boolean }) => { return ( -
-
+
+
{bars.map((_, i) => ( -
+
))}
diff --git a/src/lib/headless/index.ts b/src/lib/headless/index.ts deleted file mode 100644 index 29b94cf..0000000 --- a/src/lib/headless/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { Toaster } from "./toaster"; -export { Toast } from "./toast-card"; -export { toast } from "../core"; -export * from "../core/types"; diff --git a/src/lib/headless/state.ts b/src/lib/headless/state.ts new file mode 100644 index 0000000..f1ffd4f --- /dev/null +++ b/src/lib/headless/state.ts @@ -0,0 +1,246 @@ +import { JSXOutput } from "@builder.io/qwik"; +import type { + ExternalToast, + ToastT, + PromiseData, + PromiseT, + ToastToDismiss, + ToastTypes, +} from "./types"; + +let toastsCounter = 1; + +const subscribers: Array<(toast: ToastT | ToastToDismiss) => void> = []; +let toasts: Array = []; + +// We use arrow functions to maintain the correct `this` reference +const subscribe = (subscriber: (toast: ToastT | ToastToDismiss) => void) => { + subscribers.push(subscriber); + + return () => { + const index = subscribers.indexOf(subscriber); + subscribers.splice(index, 1); + }; +}; + +const publish = (data: ToastT) => { + subscribers.forEach((subscriber) => subscriber(data)); +}; + +const addToast = (data: ToastT) => { + publish(data); + toasts = [...toasts, data]; +}; + +const create = ( + data: ExternalToast & { + message?: string | JSXOutput; + type?: ToastTypes; + promise?: PromiseT; + jsx?: JSXOutput; + } +) => { + const { message, ...rest } = data; + const id = + typeof data?.id === "number" || (data.id && data.id?.length > 0) + ? data.id + : toastsCounter++; + const alreadyExists = toasts.find((toast) => { + return toast.id === id; + }); + const dismissible = data.dismissible === undefined ? true : data.dismissible; + + if (alreadyExists) { + toasts = toasts.map((toast) => { + if (toast.id === id) { + publish({ ...toast, ...data, id, title: message }); + return { + ...toast, + ...data, + id, + dismissible, + title: message, + }; + } + + return toast; + }); + } else { + addToast({ title: message, ...rest, dismissible, id }); + } + + return id; +}; + +const dismiss = (id?: number | string) => { + if (!id) { + return toasts.forEach((toast) => { + subscribers.forEach((subscriber) => + subscriber({ id: toast.id, dismiss: true }) + ); + }); + } + + subscribers.forEach((subscriber) => subscriber({ id, dismiss: true })); + return id; +}; + +const message = (message: string | JSXOutput, data?: ExternalToast) => { + return create({ ...data, message }); +}; + +const error = (message: string | JSXOutput, data?: ExternalToast) => { + return create({ ...data, message, type: "error" }); +}; + +const success = (message: string | JSXOutput, data?: ExternalToast) => { + return create({ ...data, type: "success", message }); +}; + +const info = (message: string | JSXOutput, data?: ExternalToast) => { + return create({ ...data, type: "info", message }); +}; + +const warning = (message: string | JSXOutput, data?: ExternalToast) => { + return create({ ...data, type: "warning", message }); +}; + +const loading = (message: string | JSXOutput, data?: ExternalToast) => { + return create({ ...data, type: "loading", message }); +}; + +const promise = ( + promise: PromiseT, + data?: PromiseData +) => { + if (!data) { + // Nothing to show + return; + } + + let id: string | number | undefined = undefined; + if (data.loading !== undefined) { + id = create({ + ...data, + promise, + type: "loading", + message: data.loading, + description: + typeof data.description !== "function" ? data.description : undefined, + }); + } + + const p = promise instanceof Promise ? promise : promise(); + + let shouldDismiss = id !== undefined; + + p.then(async (response) => { + // TODO: Clean up TS here, response has incorrect type + // @ts-expect-error + if (response && typeof response.ok === "boolean" && !response.ok) { + shouldDismiss = false; + const message = + typeof data.error === "function" + ? await data.error({ + // @ts-expect-error + error: `HTTP error! status: ${response.status}`, + }) + : data.error; + const description = + typeof data.description === "function" + ? await data.description( + // @ts-expect-error + `HTTP error! status: ${response.status}` + ) + : data.description; + create({ id, type: "error", message, description }); + } else if (data.success !== undefined) { + shouldDismiss = false; + const message = + typeof data.success === "function" + ? await data.success(response) + : data.success; + const description = + typeof data.description === "function" + ? await data.description(response) + : data.description; + create({ id, type: "success", message, description }); + } + }) + .catch(async (error) => { + if (data.error !== undefined) { + shouldDismiss = false; + const message = + typeof data.error === "function" + ? await data.error(error) + : data.error; + const description = + typeof data.description === "function" + ? await data.description(error) + : data.description; + create({ id, type: "error", message, description }); + } + }) + .finally(() => { + if (shouldDismiss) { + // Toast is still in load state (and will be indefinitely — dismiss it) + dismiss(id); + id = undefined; + } + + data.finally?.(); + }); + + return id; +}; + +const custom = ( + jsx: (id: number | string) => JSXOutput, + data?: ExternalToast +) => { + const id = data?.id || toastsCounter++; + create({ jsx: jsx(id), id, ...data }); + return id; +}; + +export const ToastState = { + subscribe, + addToast, + create, + dismiss, + message, + error, + success, + info, + warning, + loading, + promise, + custom, +}; + +// bind this to the toast function +const toastFunction = (message: string | JSXOutput, data?: ExternalToast) => { + const id = data?.id || toastsCounter++; + + ToastState.addToast({ + title: message, + ...data, + id, + }); + return id; +}; + +const basicToast = toastFunction; + +// We use `Object.assign` to maintain the correct types as we would lose them otherwise +export const toast = Object.assign(basicToast, { + success: ToastState.success, + info: ToastState.info, + warning: ToastState.warning, + error: ToastState.error, + custom: ToastState.custom, + message: ToastState.message, + promise: ToastState.promise, + dismiss: ToastState.dismiss, + loading: ToastState.loading, +}); diff --git a/src/lib/headless/toast-card.tsx b/src/lib/headless/toast-card.tsx deleted file mode 100644 index 555d322..0000000 --- a/src/lib/headless/toast-card.tsx +++ /dev/null @@ -1,419 +0,0 @@ -/* eslint-disable qwik/valid-lexical-scope */ -import { - $, - Fragment, - component$, - useComputed$, - useSignal, - useStore, - useTask$, - useVisibleTask$, -} from "@builder.io/qwik"; -import { - type ToastProps, - SWIPE_THRESHOLD, - TIME_BEFORE_UNMOUNT, - Loader, - getAsset, -} from "../core"; - -type ToastCardState = { - mounted: boolean; - removed: boolean; - swiping: boolean; - swipeOut: boolean; - offsetBeforeRemove: number; - initialHeight: number; - dragStartTime: Date | null; -}; - -export const Toast = component$((props: ToastProps) => { - const state = useStore({ - mounted: false, - removed: false, - swiping: false, - swipeOut: false, - offsetBeforeRemove: 0, - initialHeight: 0, - dragStartTime: null, - }); - - const toastRef = useSignal(); - - const isFront = props.index === 0; - const isVisible = props.index + 1 <= props.visibleToasts; - const { type: toastType } = props.toast; - const dismissible = props.toast.dismissible !== false; - const toastClassname = props.toast.className; - const toastDescriptionClassname = props.toast.descriptionClassName; - const invert = props.toast.invert || props.invert; - const disabled = toastType === "loading"; - - // Height index is used to calculate the offset as it gets updated before the toast array, which means we can calculate the new layout faster. - const heightIndex = useComputed$(() => { - const partialIndex = props.state.heights.findIndex( - (h) => h.toastId === props.toast.id - ); - return partialIndex === -1 ? 0 : partialIndex; - }); - - const duration = useComputed$(() => props.toast.duration || props.duration); - - const closeTimerStartTimeRef = useSignal(0); - // const closeTimerRemainingTimeRef = useSignal(duration.value); - const lastCloseTimerStartTimeRef = useSignal(0); - const pointerStartRef = useSignal<{ x: number; y: number } | null>(null); - const [y, x] = props.position.split("-"); - const toastsHeightBefore = useComputed$(() => { - return props.state.heights.reduce((prev, curr, reducerIndex) => { - // Calculate offset up until current toast - if (reducerIndex >= heightIndex.value) { - return prev; - } - - return prev + curr.height; - }, 0); - }); - - const offset = useComputed$( - () => heightIndex.value * props.gap + toastsHeightBefore.value - ); - - // eslint-disable-next-line qwik/no-use-visible-task - useVisibleTask$(({ track }) => { - const isMounted = track(() => state.mounted); - - if (!isMounted) { - state.mounted = true; - return; - } - }); - - useTask$(({ track }) => { - track(() => toastRef.value); - - const mounted = track(() => state.mounted); - if (!mounted) return; - - const originalHeight = toastRef.value!.style.height; - toastRef.value!.style.height = "auto"; - const newHeight = toastRef.value!.getBoundingClientRect().height; - toastRef.value!.style.height = originalHeight; - - state.initialHeight = newHeight; - - const alreadyExists = props.state.heights.find( - (height) => height.toastId === props.toast.id - ); - - if (!alreadyExists) { - props.state.heights = [ - { toastId: props.toast.id, height: newHeight }, - ...props.state.heights, - ]; - } else { - props.state.heights = props.state.heights.map((height) => - height.toastId === props.toast.id - ? { ...height, height: newHeight } - : height - ); - } - }); - - const deleteToast = $(() => { - // Save the offset for the exit swipe animation - state.removed = true; - state.offsetBeforeRemove = offset.value; - - props.state.heights = props.state.heights.filter( - (h) => h.toastId !== props.toast.id - ); - - setTimeout(() => { - props.removeToast(props.toast); - }, TIME_BEFORE_UNMOUNT); - }); - - // handle user interaction with toast or Toaster (parent) pause/resume lifecycle - useTask$(({ track, cleanup }) => { - track(() => props.toast.promise); - track(() => props.toast.duration); - track(() => props.state.expanded); - track(() => props.state.interacting); - track(() => props.expandByDefault); - track(() => toastType); - let remainingTime = track(() => duration.value); - - if ( - (props.toast.promise && toastType === "loading") || - props.toast.duration === Infinity - ) { - return; - } - - let timeoutId: number; - // Pause the timer on each hover - const pauseTimer = () => { - if (lastCloseTimerStartTimeRef.value < closeTimerStartTimeRef.value) { - // Get the elapsed time since the timer started - const elapsedTime = new Date().getTime() - closeTimerStartTimeRef.value; - - remainingTime = remainingTime - elapsedTime; - } - - lastCloseTimerStartTimeRef.value = new Date().getTime(); - }; - - const startTimer = () => { - closeTimerStartTimeRef.value = new Date().getTime(); - - // Let the toast know it has started - timeoutId = setTimeout(() => { - props.toast.onAutoClose?.(props.toast); - deleteToast(); - }, remainingTime); - }; - - if (props.state.expanded || props.state.interacting) { - pauseTimer(); - } else { - startTimer(); - } - - cleanup(() => clearTimeout(timeoutId)); - }); - - useTask$(({ track }) => { - const deleteToastCond = track(() => props.toast.delete); - - if (deleteToastCond) { - deleteToast(); - } - }); - - const getLoadingIcon = () => { - if (props.loadingIcon) { - return ( -
- {props.loadingIcon} -
- ); - } - return ; - }; - - return ( -
  • { - if (disabled || !dismissible) return; - state.dragStartTime = new Date(); - state.offsetBeforeRemove = offset.value; - // Ensure we maintain correct pointer capture even when going outside of the toast (e.g. when swiping) - (event.target as HTMLElement).setPointerCapture(event.pointerId); - if ((event.target as HTMLElement).tagName === "BUTTON") return; - state.swiping = true; - pointerStartRef.value = { x: event.clientX, y: event.clientY }; - }} - onPointerUp$={() => { - if (state.swipeOut || !dismissible) return; - - pointerStartRef.value = null; - const swipeAmount = Number( - toastRef.value?.style - .getPropertyValue("--swipe-amount") - .replace("px", "") || 0 - ); - const timeTaken = new Date().getTime() - state.dragStartTime!.getTime(); - const velocity = Math.abs(swipeAmount) / timeTaken; - - // Remove only if threshold is met - if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) { - state.offsetBeforeRemove = offset.value; - props.toast.onDismiss?.(props.toast); - deleteToast(); - state.swipeOut = true; - return; - } - - toastRef.value?.style.setProperty("--swipe-amount", "0px"); - state.swiping = false; - }} - onPointerMove$={(event) => { - if (!pointerStartRef.value || !dismissible) return; - - const yPosition = event.clientY - pointerStartRef.value.y; - const xPosition = event.clientX - pointerStartRef.value.x; - - const clamp = y === "top" ? Math.min : Math.max; - const clampedY = clamp(0, yPosition); - const swipeStartThreshold = event.pointerType === "touch" ? 10 : 2; - const isAllowedToSwipe = Math.abs(clampedY) > swipeStartThreshold; - - if (isAllowedToSwipe) { - toastRef.value?.style.setProperty("--swipe-amount", `${yPosition}px`); - } else if (Math.abs(xPosition) > swipeStartThreshold) { - // User is swiping in wrong direction so we disable swipe gesture - // for the current pointer down interaction - pointerStartRef.value = null; - } - }} - > - {props.closeButton && !props.toast.jsx ? ( - - ) : null} - {props.toast.jsx || typeof props.toast.title === "function" ? ( - props.toast.jsx || props.toast.title - ) : ( - - {toastType || props.toast.icon || props.toast.promise ? ( -
    - {(props.toast.promise || props.toast.type === "loading") && - !props.toast.icon - ? getLoadingIcon() - : null} - {props.toast.icon || getAsset(toastType)} -
    - ) : null} - -
    -
    - {props.toast.title} -
    - {props.toast.description ? ( -
    - {props.toast.description} -
    - ) : null} -
    - {props.toast.cancel ? ( - - ) : null} - {props.toast.action && ( - - )} -
    - )} -
  • - ); -}); diff --git a/src/lib/headless/toast-wrapper.tsx b/src/lib/headless/toast-wrapper.tsx new file mode 100644 index 0000000..c2b8416 --- /dev/null +++ b/src/lib/headless/toast-wrapper.tsx @@ -0,0 +1,336 @@ +import { + $, + component$, + QwikVisibleEvent, + useComputed$, + useOnDocument, + useSignal, + useTask$, +} from "@builder.io/qwik"; +import { + HeightT, + Theme, + ToasterProps, + ToastT, + ToastToDismiss, + ToastProps, +} from "./types"; +import { + GAP, + TOAST_WIDTH, + VIEWPORT_OFFSET, + VISIBLE_TOASTS_AMOUNT, + SWIPE_THRESHOLD, + TIME_BEFORE_UNMOUNT, + TOAST_LIFETIME, +} from "./const"; +import { ToastState } from "./state"; +import { Toast } from "./toast"; +import { toast } from "./state"; + +const Toaster = component$((props) => { + const { + invert = false, + position = "bottom-right", + hotkey = ["altKey", "KeyT"], + expand, + closeButton, + class: localClass, + offset, + theme = "light", + richColors, + duration, + style, + visibleToasts = VISIBLE_TOASTS_AMOUNT, + toastOptions, + dir, + gap = GAP, + loadingIcon, + icons, + containerAriaLabel = "Notifications", + pauseWhenPageIsHidden, + } = props; + + const toasts = useSignal([]); + const heights = useSignal([]); + const expanded = useSignal(false); + const interacting = useSignal(false); + const listRef = useSignal(); + const lastFocusedElementRef = useSignal(null); + const isFocusWithinRef = useSignal(false); + + const possiblePositions = useComputed$(() => { + return Array.from( + new Set( + [position].concat( + toasts.value + .filter((toast) => toast.position) + .map((toast) => toast.position!) + ) + ) + ); + }); + + const hotkeyLabel = hotkey + .join("+") + .replace(/Key/g, "") + .replace(/Digit/g, ""); + + const removeToast = $( + (toast: ToastT) => + (toasts.value = toasts.value.filter(({ id }) => id !== toast.id)) + ); + + const onMountHandler = $((_: QwikVisibleEvent, _1: HTMLElement) => { + return ToastState.subscribe((toast) => { + if ((toast as ToastToDismiss).dismiss) { + toasts.value = toasts.value.map((t) => + t.id === toast.id ? { ...t, delete: true } : t + ); + return; + } + + const indexOfExistingToast = toasts.value.findIndex( + (t) => t.id === toast.id + ); + + if (indexOfExistingToast !== -1) { + return (toasts.value = [ + ...toasts.value.slice(0, indexOfExistingToast), + { ...toasts.value[indexOfExistingToast], ...toast }, + ...toasts.value.slice(indexOfExistingToast + 1), + ]); + } + + return (toasts.value = [toast, ...toasts.value]); + }); + }); + + const onMountThemeHandler = $((_: QwikVisibleEvent, _1: HTMLElement) => { + const selectedTheme = listRef.value?.getAttribute("data-theme") as Theme; + + if (selectedTheme !== "system") return; + + const themeFromLocalStorage = localStorage.getItem("theme") as Theme; + + if (themeFromLocalStorage) { + return listRef.value?.setAttribute("data-theme", themeFromLocalStorage); + } + + const themeFromMediaQuery = + window.matchMedia && + window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + + listRef.value?.setAttribute("data-theme", themeFromMediaQuery); + localStorage.setItem("theme", themeFromMediaQuery); + }); + + const onMountDirHandler = $((_: QwikVisibleEvent, _1: HTMLElement) => { + if (dir && dir !== "auto") return; + + const newDir = window.getComputedStyle(document.documentElement).direction; + listRef.value?.setAttribute("dir", newDir); + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if ( + mutation.type === "attributes" && + mutation.attributeName === "dir" + ) { + const value = (mutation.target as HTMLElement).getAttribute("dir"); + if (value === "auto" || !value) { + return listRef.value!.setAttribute("dir", newDir); + } + listRef.value!.setAttribute("dir", value); + } + }); + }); + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["dir"], + }); + }); + + useTask$(({ track }) => { + track(() => toasts.value); + // Ensure expanded is always false when no toasts are present / only one left + if (toasts.value.length <= 1) { + expanded.value = false; + } + }); + + useOnDocument( + "keydown", + $((ev) => { + const isHotkeyPressed = hotkey.every( + (key) => (ev as any)[key] || ev.code === key + ); + + if (isHotkeyPressed) { + expanded.value = true; + listRef.value?.focus(); + } + + if ( + ev.code === "Escape" && + (document.activeElement === listRef.value || + listRef.value?.contains(document.activeElement)) + ) { + expanded.value = false; + } + }) + ); + + useTask$(({ track, cleanup }) => { + track(() => listRef.value); + + if (!listRef.value) return; + + cleanup(() => { + if (lastFocusedElementRef.value) { + lastFocusedElementRef.value.focus({ preventScroll: true }); + lastFocusedElementRef.value = null; + isFocusWithinRef.value = false; + } + }); + }); + + return ( + // Remove item from normal navigation flow, only available via hotkey +
    + {possiblePositions.value.map((position, index) => { + const [y, x] = position.split("-"); + return ( +
      { + if ( + isFocusWithinRef.value && + !target.contains(event.relatedTarget as HTMLElement) + ) { + isFocusWithinRef.value = false; + if (lastFocusedElementRef.value) { + lastFocusedElementRef.value.focus({ + preventScroll: true, + }); + lastFocusedElementRef.value = null; + } + } + }} + onFocusIn$={(event, target) => { + const isNotDismissible = + target instanceof HTMLElement && + target.dataset.dismissible === "false"; + + if (isNotDismissible) return; + + if (!isFocusWithinRef.value) { + isFocusWithinRef.value = true; + lastFocusedElementRef.value = + event.relatedTarget as HTMLElement; + } + }} + onMouseEnter$={() => (expanded.value = true)} + onMouseMove$={() => (expanded.value = true)} + onMouseLeave$={() => { + // Avoid setting expanded to false when interacting with a toast, e.g. swiping + if (!interacting.value) { + expanded.value = false; + } + }} + onPointerDown$={(_, target) => { + const isNotDismissible = + target instanceof HTMLElement && + target.dataset.dismissible === "false"; + + if (isNotDismissible) return; + interacting.value = true; + }} + onPointerUp$={() => (interacting.value = false)} + > + {toasts.value + .filter( + (toast) => + (!toast.position && index === 0) || + toast.position === position + ) + .map((toast, index) => ( + t.position === toast.position + )} + heights={heights} + expandByDefault={expand ?? false} + gap={gap} + loadingIcon={loadingIcon} + expanded={expanded} + pauseWhenPageIsHidden={pauseWhenPageIsHidden ?? false} + /> + ))} +
    + ); + })} +
    + ); +}); + +export { + Toaster, + toast, + type ToasterProps, + type ToastProps, + Toast, + GAP, + TOAST_WIDTH, + VIEWPORT_OFFSET, + VISIBLE_TOASTS_AMOUNT, + SWIPE_THRESHOLD, + TIME_BEFORE_UNMOUNT, + TOAST_LIFETIME, +}; diff --git a/src/lib/headless/toast.tsx b/src/lib/headless/toast.tsx new file mode 100644 index 0000000..cbcbac4 --- /dev/null +++ b/src/lib/headless/toast.tsx @@ -0,0 +1,490 @@ +import { + $, + component$, + useComputed$, + useSignal, + useTask$, + isSignal, + useOnDocument, + useVisibleTask$, +} from "@builder.io/qwik"; +import { isAction, ToastClassnames, ToastIcons, ToastProps } from "./types"; +import { SWIPE_THRESHOLD, TIME_BEFORE_UNMOUNT, TOAST_LIFETIME } from "./const"; +import { getAsset, Loader } from "./icons"; +import DOMPurify from "dompurify"; + +export const Toast = component$((props) => { + const { + invert: ToasterInvert, + toast, + unstyled, + interacting, + visibleToasts, + heights, + index, + toasts, + expanded, + removeToast, + closeButton: closeButtonFromToaster, + style, + cancelButtonStyle, + actionButtonStyle, + class: localClass = "", + descriptionClass = "", + duration: durationFromToaster, + position, + gap, + loadingIcon: loadingIconProp, + expandByDefault, + classes, + icons, + closeButtonAriaLabel = "Close toast", + pauseWhenPageIsHidden, + } = props; + + // signals + const mounted = useSignal(false); + const removed = useSignal(false); + const swiping = useSignal(false); + const swipeOut = useSignal(false); + const offsetBeforeRemove = useSignal(0); + const initialHeight = useSignal(0); + const dragStartTime = useSignal(null); + const toastRef = useSignal(); + const closeTimerStartTimeRef = useSignal(0); + const lastCloseTimerStartTimeRef = useSignal(0); + const pointerStartRef = useSignal<{ x: number; y: number } | null>(null); + const isDocumentHidden = useSignal(false); + + // local constants + const isFront = index === 0; + const isVisible = index + 1 <= visibleToasts; + const toastType = toast.type; + const dismissible = toast.dismissible !== false; + const toastClass = toast.class ?? ""; + const toastDescriptionClass = toast.descriptionClass ?? ""; + const [y, x] = position.split("-"); + const invertChecked = isSignal(ToasterInvert) + ? ToasterInvert.value + : ToasterInvert; + const invert = useComputed$(() => toast.invert ?? invertChecked); + const disabled = toastType === "loading"; + + // computed values + // Height index is used to calculate the offset as it gets updated before the toast array, which means we can calculate the new layout faster. + const heightIndex = useComputed$(() => { + const hSet = Array.from(new Set(heights.value.map((h) => h.toastId))); + const idx = hSet.findIndex((id) => id === toast.id) || 0; + return idx === -1 ? 0 : idx; + }); + const closeButton = useComputed$( + () => toast.closeButton ?? closeButtonFromToaster + ); + const duration = useComputed$( + () => toast.duration ?? durationFromToaster ?? TOAST_LIFETIME + ); + const toastsHeightBefore = useComputed$(() => { + return heights.value.reduce((prev, curr, reducerIndex) => { + // Calculate offset up until current toast + if (reducerIndex >= heightIndex.value) { + return prev; + } + + return prev + curr.height; + }, 0); + }); + const offset = useComputed$(() => { + return heightIndex.value * gap + toastsHeightBefore.value; + }); + + // util functions + const deleteToast = $(() => { + // Save the offset for the exit swipe animation + removed.value = true; + offsetBeforeRemove.value = offset.value; + heights.value = heights.value.filter((h) => h.toastId !== toast.id); + + setTimeout(() => { + removeToast(toast); + }, TIME_BEFORE_UNMOUNT); + }); + function getLoadingIcon() { + if (icons?.loading) { + return ( +
    + {icons.loading} +
    + ); + } + + if (loadingIconProp) { + return ( +
    + {loadingIconProp} +
    + ); + } + return ; + } + function sanitizeHTML(html: string | Node): string { + return DOMPurify.sanitize(html); + } + + // tasks + useTask$(({ track }) => { + track(() => toastRef.value); + + if (!toastRef.value) return; + + const originalHeight = toastRef.value.style.height; + toastRef.value.style.height = "auto"; + const newHeight = toastRef.value.getBoundingClientRect().height; + toastRef.value.style.height = originalHeight; + + initialHeight.value = newHeight; + const exists = heights.value.find((h) => h.toastId === toast.id); + + heights.value = exists + ? heights.value.map((h) => + h.toastId === toast.id ? { ...h, height: newHeight } : h + ) + : [{ toastId: toast.id, height: newHeight, position }, ...heights.value]; + }); + useTask$(({ track, cleanup }) => { + track(() => expanded.value); + track(() => interacting); + track(() => expandByDefault); + track(() => toast); + let remainingTime = track(() => duration.value); + track(() => toast.promise); + track(() => toastType); + track(() => pauseWhenPageIsHidden); + track(() => isDocumentHidden.value); + + if ( + (toast.promise && toastType === "loading") || + toast.duration === Infinity || + toast.type === "loading" + ) + return; + let timeoutId: number; + + // Pause the timer on each hover + const pauseTimer = () => { + if (lastCloseTimerStartTimeRef.value < closeTimerStartTimeRef.value) { + // Get the elapsed time since the timer started + const elapsedTime = new Date().getTime() - closeTimerStartTimeRef.value; + + remainingTime = remainingTime - elapsedTime; + } + + lastCloseTimerStartTimeRef.value = new Date().getTime(); + }; + + const startTimer = () => { + // setTimeout(, Infinity) behaves as if the delay is 0. + // As a result, the toast would be closed immediately, giving the appearance that it was never rendered. + // See: https://github.com/denysdovhan/wtfjs?tab=readme-ov-file#an-infinite-timeout + if (remainingTime === Infinity) return; + + closeTimerStartTimeRef.value = new Date().getTime(); + + // Let the toast know it has started + timeoutId = setTimeout(() => { + toast.onAutoClose$?.(toast); + deleteToast(); + }, remainingTime); + }; + + if ( + expanded.value || + interacting || + (pauseWhenPageIsHidden && isDocumentHidden.value) + ) { + pauseTimer(); + } else { + startTimer(); + } + + cleanup(() => clearTimeout(timeoutId)); + }); + useTask$(({ track, cleanup }) => { + track(() => toastRef.value); + track(() => toast.id); + + if (!toastRef.value) return; + + const height = toastRef.value.getBoundingClientRect().height; + + // Add toast height tot heights array after the toast is mounted + initialHeight.value = height; + heights.value = [ + { + toastId: toast.id, + height, + position: toast.position ?? "bottom-right", + }, + ...heights.value, + ]; + + cleanup( + () => + (heights.value = heights.value.filter((h) => h.toastId !== toast.id)) + ); + }); + useTask$(({ track }) => { + track(() => toast.delete); + track(() => deleteToast); + + if (toast.delete) { + deleteToast(); + } + }); + + useOnDocument( + "visibilitychange", + $(() => { + isDocumentHidden.value = document.hidden; + }) + ); + + // I shoudl not use visible task but I don't found a better way to do it + useVisibleTask$(({ track }) => { + const localMount = track(() => mounted.value); + if (!localMount) { + mounted.value = true; + } + }); + + return ( +
  • { + if (disabled || !dismissible) return; + dragStartTime.value = new Date(); + offsetBeforeRemove.value = offset.value; + // Ensure we maintain correct pointer capture even when going outside of the toast (e.g. when swiping) + (event.target as HTMLElement).setPointerCapture(event.pointerId); + if (target.tagName === "BUTTON") return; + swiping.value = true; + pointerStartRef.value = { x: event.clientX, y: event.clientY }; + }} + onPointerUp$={() => { + if (swipeOut.value || !dismissible) return; + + pointerStartRef.value = null; + const swipeAmount = Number( + toastRef.value?.style + .getPropertyValue("--swipe-amount") + .replace("px", "") ?? 0 + ); + const timeTaken = + new Date().getTime() - (dragStartTime.value as Date)?.getTime(); + const velocity = Math.abs(swipeAmount) / timeTaken; + + // Remove only if threshold is met + if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) { + offsetBeforeRemove.value = offset.value; + toast.onDismiss$?.(toast); + deleteToast(); + swipeOut.value = true; + return; + } + + toastRef.value?.style.setProperty("--swipe-amount", "0px"); + swiping.value = false; + }} + onPointerMove$={(event) => { + if (!pointerStartRef.value || !dismissible) return; + + const yPosition = event.clientY - pointerStartRef.value.y; + const xPosition = event.clientX - pointerStartRef.value.x; + + const clamp = y === "top" ? Math.min : Math.max; + const clampedY = clamp(0, yPosition); + const swipeStartThreshold = event.pointerType === "touch" ? 10 : 2; + const isAllowedToSwipe = Math.abs(clampedY) > swipeStartThreshold; + + if (isAllowedToSwipe) { + toastRef.value?.style.setProperty("--swipe-amount", `${yPosition}px`); + } else if (Math.abs(xPosition) > swipeStartThreshold) { + // User is swiping in wrong direction so we disable swipe gesture + // for the current pointer down interaction + pointerStartRef.value = null; + } + }} + > + {closeButton.value && !toast.jsx ? ( + + ) : null} + {toast.jsx ?? typeof toast.title === "object" ? ( + toast.jsx ?? toast.title + ) : ( + <> + {toastType || toast.icon || toast.promise ? ( +
    + {toast.promise || (toast.type === "loading" && !toast.icon) + ? toast.icon ?? getLoadingIcon() + : null} + {toast.type !== "loading" + ? toast.icon ?? + icons?.[toastType as keyof ToastIcons] ?? + getAsset(toastType!) + : null} +
    + ) : null} + +
    +
    + {toast.description ? ( + typeof toast.description === "string" ? ( +
    + ) : ( +
    + {toast.description} +
    + ) + ) : null} +
    + {typeof toast.cancel === "object" ? ( + toast.cancel + ) : toast.cancel && isAction(toast.cancel) ? ( + + ) : null} + {typeof toast.action === "object" ? ( + toast.action + ) : toast.action && isAction(toast.action) ? ( + + ) : null} + + )} +
  • + ); +}); diff --git a/src/lib/headless/toaster.tsx b/src/lib/headless/toaster.tsx deleted file mode 100644 index 217c4c0..0000000 --- a/src/lib/headless/toaster.tsx +++ /dev/null @@ -1,342 +0,0 @@ -/* eslint-disable qwik/valid-lexical-scope */ -import { - $, - component$, - useComputed$, - useOnDocument, - useSignal, - useStore, - useTask$, - useOnWindow, -} from "@builder.io/qwik"; -import { - Dir, - Theme, - ToastT, - ToastToDismiss, - ToasterProps, - ToasterStore, - toastState, -} from "../core"; -import { Toast } from "./toast-card"; - -// function getDocumentDirection(): Dir { -// if (typeof window === "undefined") return "ltr"; -// if (typeof document === "undefined") return "ltr"; // For Fresh purpose - -// const dirAttribute = document.documentElement.getAttribute("dir"); - -// if (dirAttribute === "auto" || !dirAttribute) { -// return window.getComputedStyle(document.documentElement).direction as Dir; -// } - -// return dirAttribute as Dir; -// } - -export const Toaster = component$( - (props) => { - const isInitializedSig = useSignal(false); - - const { - position, - hotkey, - theme, - visibleToasts, - dir, - containerAriaLabel, - expand, - toastOptions, - closeButton, - invert, - gap, - loadingIcon, - offset, - richColors, - duration, - ...restProps - } = props; - - const listRef = useSignal(); - const state = useStore({ - toasts: [], - expanded: expand!, - heights: [], - interacting: false, - }); - - useOnDocument( - "sonner", - $((ev: CustomEvent) => { - if (isInitializedSig.value) return; - isInitializedSig.value = true; - - state.toasts = [...state.toasts, ev.detail]; - - toastState.subscribe((toast) => { - if ((toast as ToastToDismiss).dismiss) { - state.toasts = state.toasts.map((t) => - t.id === toast.id ? { ...t, delete: true } : t - ); - return; - } - - const indexOfExistingToast = state.toasts.findIndex( - (t) => t.id === toast.id - ); - - // Update the toast if it already exists - if (indexOfExistingToast !== -1) { - state.toasts = [ - ...state.toasts.slice(0, indexOfExistingToast), - { ...state.toasts[indexOfExistingToast], ...toast }, - ...state.toasts.slice(indexOfExistingToast + 1), - ]; - return; - } - - return (state.toasts = [toast, ...state.toasts]); - }); - }) - ); - - const possiblePositions = useComputed$(() => { - return Array.from( - new Set( - [props.position ?? position].concat( - state.toasts - .filter((toast) => toast.position!) - .map((toast) => toast.position!) - ) - ) - ); - }); - - const hotkeyLabel = hotkey! - .join("+") - .replace(/Key/g, "") - .replace(/Digit/g, ""); - - const lastFocusedElementRef = useSignal(); - const isFocusWithinRef = useSignal(false); - - const removeToast = $( - (toast: ToastT) => - (state.toasts = state.toasts.filter(({ id }) => id !== toast.id)) - ); - - useOnWindow( - "DOMContentLoaded", - $(() => { - const userTheme = listRef.value?.getAttribute("data-theme") as Theme; - - // if there is a default theme that is not "system", return - if (userTheme !== "system") return; - - // reading the qwik script to handle user theme preferences - const themeFromLocalStorage = localStorage.getItem("theme") as Theme; - - if (themeFromLocalStorage) { - listRef.value!.setAttribute("data-theme", themeFromLocalStorage); - return; - } - - const themeFromMedia: Theme = - window.matchMedia && - window.matchMedia("(prefers-color-scheme: dark)").matches - ? "dark" - : "light"; - - listRef.value!.setAttribute("data-theme", themeFromMedia); - localStorage.setItem("theme", themeFromMedia); - }) - ); - - useOnWindow( - "DOMContentLoaded", - $(() => { - if (dir && dir !== "auto") return; - - const newDir = window.getComputedStyle(document.documentElement) - .direction as Dir; - listRef.value!.setAttribute("dir", newDir); - - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if ( - mutation.type === "attributes" && - mutation.attributeName === "dir" - ) { - const value = (mutation.target as HTMLElement).getAttribute( - "dir" - ); - if (value === "auto" || !value) { - const dir = window.getComputedStyle(document.documentElement) - .direction as Dir; - return listRef.value!.setAttribute("dir", dir); - } - listRef.value!.setAttribute("dir", value as Dir); - } - }); - }); - - observer.observe(document.querySelector("html")!, { - attributes: true, - attributeFilter: ["dir"], - }); - }) - ); - - useOnDocument( - "keydown", - $((event) => { - // Check if all hotkeys are pressed - const isHotkeyPressed = hotkey!.every( - (key) => (event as any)[key] || event.code === key - ); - - // If the key shortcut is pressed, expand the toaster - if (isHotkeyPressed) { - state.expanded = true; - listRef.value?.focus(); - } - - // Then, if user presses escape, close the toaster - if ( - event.code === "Escape" && - (document.activeElement === listRef.value || - listRef.value?.contains(document.activeElement)) - ) { - state.expanded = false; - } - }) - ); - - useTask$(({ track }) => { - track(() => state.toasts); - if (state.toasts.length <= 1) { - state.expanded = false; - } - }); - - const onFocusOutHandler = $( - (event: FocusEvent, element: HTMLOListElement) => { - if ( - isFocusWithinRef.value && - !element.contains(event.relatedTarget as Node) - ) { - isFocusWithinRef.value = false; - if (lastFocusedElementRef.value) { - lastFocusedElementRef.value.focus({ preventScroll: true }); - lastFocusedElementRef.value = undefined; - } - } - } - ); - - const onFocusHandler = $((event: FocusEvent, element: HTMLOListElement) => { - const isNotDismissible = element.dataset.dismissible === "false"; - - if (isNotDismissible) return; - - if (!isFocusWithinRef.value) { - isFocusWithinRef.value = true; - lastFocusedElementRef.value = event.relatedTarget as HTMLElement; - } - }); - - const onMouseleaveHandler = $(() => { - // Avoid setting expanded to false when interacting with a toast, e.g. swiping - if (!state.interacting) { - state.expanded = false; - } - }); - - const onPointerDownHandler = $( - (_: PointerEvent, element: HTMLOListElement) => { - const isNotDismissible = element.dataset.dismissible === "false"; - - if (isNotDismissible) return; - state.interacting = true; - } - ); - - const makeToasterExpanded = $(() => (state.expanded = true)); - - return ( - // Remove item from normal navigation flow, only available via hotkey -
    - {possiblePositions.value.map((position, index) => { - const [y, x] = position!.split("-"); - return ( -
      (state.interacting = false)), - props.onPointerUp$, - ]} - > - {state.toasts - .filter( - (toast) => - (!toast.position && index === 0) || - toast.position === position - ) - .map((toast, index) => ( - - ))} -
    - ); - })} -
    - ); - } -); diff --git a/src/lib/headless/types.ts b/src/lib/headless/types.ts new file mode 100644 index 0000000..3decd16 --- /dev/null +++ b/src/lib/headless/types.ts @@ -0,0 +1,201 @@ +import { + ClassList, + Component, + CSSProperties, + JSXOutput, + QRL, + QRLEventHandlerMulti, + Signal, +} from "@builder.io/qwik"; + +export type ToastTypes = + | "normal" + | "action" + | "success" + | "info" + | "warning" + | "error" + | "loading" + | "default"; + +export type PromiseT = Promise | (() => Promise); + +export type PromiseExternalToast = Omit; + +export type PromiseData = PromiseExternalToast & { + loading?: string; + success?: string | JSXOutput | QRL<(data: ToastData) => JSXOutput | string>; + error?: string | QRL<(error: any) => JSXOutput | string> | JSXOutput; + description?: string | QRL<(data: any) => JSXOutput | string> | JSXOutput; + finally?: () => void | Promise; +}; + +export interface ToastClassnames { + toast?: ClassList; + title?: ClassList; + description?: ClassList; + loader?: ClassList; + closeButton?: ClassList; + cancelButton?: ClassList; + actionButton?: ClassList; + success?: ClassList; + error?: ClassList; + info?: ClassList; + warning?: ClassList; + loading?: ClassList; + default?: ClassList; + content?: ClassList; + icon?: ClassList; +} + +export interface ToastIcons { + success?: Component; + info?: Component; + warning?: Component; + error?: Component; + loading?: Component; +} + +interface Action { + label: string; + onClick$: QRL<(ev: PointerEvent, target: HTMLButtonElement) => any>; + actionButtonStyle?: CSSProperties; +} + +export interface ToastT { + id: number | string; + title?: string | JSXOutput; + type?: ToastTypes; + icon?: Component; + jsx?: JSXOutput; + invert?: boolean; + closeButton?: boolean; + dismissible?: boolean; + description?: JSXOutput | string; + duration?: number; + delete?: boolean; + important?: boolean; + action?: Action | JSXOutput; + cancel?: Action | JSXOutput; + onDismiss$?: QRL<(toast: ToastT) => unknown>; + onAutoClose$?: QRL<(toast: ToastT) => unknown>; + promise?: PromiseT; + cancelButtonStyle?: CSSProperties; + actionButtonStyle?: CSSProperties; + style?: CSSProperties; + unstyled?: boolean; + class?: string; + classes?: ToastClassnames; + descriptionClass?: string; + position?: Position; +} + +export function isAction(action: Action | JSXOutput): action is Action { + return ( + (action as Action).label !== undefined && + typeof (action as Action).onClick$ === "function" + ); +} + +export type Position = + | "top-left" + | "top-right" + | "bottom-left" + | "bottom-right" + | "top-center" + | "bottom-center"; +export interface HeightT { + height: number; + toastId: number | string; + position: Position; +} + +interface ToastOptions { + class?: string; + closeButton?: boolean; + descriptionClass?: string; + style?: CSSProperties; + cancelButtonStyle?: CSSProperties; + actionButtonStyle?: CSSProperties; + duration?: number; + unstyled?: boolean; + classes?: ToastClassnames; +} + +export interface ToasterProps { + invert?: Signal | boolean; + theme?: "light" | "dark" | "system"; + position?: Position; + hotkey?: string[]; + richColors?: boolean; + expand?: boolean; + duration?: number; + gap?: number; + visibleToasts?: number; + closeButton?: boolean; + toastOptions?: ToastOptions; + class?: string; + style?: CSSProperties; + offset?: string | number; + dir?: "rtl" | "ltr" | "auto"; + /** + * @deprecated Please use the `icons` prop instead: + * ```jsx + * }} + * /> + * ``` + */ + loadingIcon?: Component; + icons?: ToastIcons; + containerAriaLabel?: string; + pauseWhenPageIsHidden?: boolean; +} + +export interface ToastProps { + toast: ToastT; + toasts: ToastT[]; + index: number; + expanded: Signal; + invert: Signal | boolean; + heights: Signal; + removeToast: (toast: ToastT) => void; + gap: number; + position: Position; + visibleToasts: number; + expandByDefault: boolean; + closeButton: boolean; + interacting: boolean; + style?: CSSProperties; + cancelButtonStyle?: CSSProperties; + actionButtonStyle?: CSSProperties; + duration?: number; + class?: string; + unstyled?: boolean; + descriptionClass?: string; + loadingIcon?: Component; + classes?: ToastClassnames; + icons?: ToastIcons; + closeButtonAriaLabel?: string; + pauseWhenPageIsHidden: boolean; +} + +export enum SwipeStateTypes { + SwipedOut = "SwipedOut", + SwipedBack = "SwipedBack", + NotSwiped = "NotSwiped", +} + +export type Theme = "light" | "dark" | "system"; + +export interface ToastToDismiss { + id: number | string; + dismiss: boolean; +} + +export type ExternalToast = Omit< + ToastT, + "id" | "type" | "title" | "jsx" | "delete" | "promise" +> & { + id?: number | string; +}; diff --git a/src/lib/index.ts b/src/lib/index.ts index 48fbb85..be44c15 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,7 +1,2 @@ export { Toaster } from "./styled"; -export { toast } from "./core/state"; -export { - type ToastT as Toast, - type ExternalToast, - type ToasterProps, -} from "./core/types"; +export { toast, type ToasterProps } from "./headless/toast-wrapper"; diff --git a/src/lib/styled/index.tsx b/src/lib/styled/index.tsx index 97f5890..ee64c21 100644 --- a/src/lib/styled/index.tsx +++ b/src/lib/styled/index.tsx @@ -1,56 +1,10 @@ import { component$, useStyles$ } from "@builder.io/qwik"; +import { ToasterProps } from "../headless/types"; import styles from "./styles.css?inline"; -import { Toaster as RawToaster } from "../headless"; -import { - GAP, - TOAST_LIFETIME, - TOAST_WIDTH, - VIEWPORT_OFFSET, - VISIBLE_TOASTS_AMOUNT, - ToasterProps, -} from "../core"; +import { Toaster as RawToaster } from "../headless/toast-wrapper"; export const Toaster = component$((props) => { useStyles$(styles); - const { - position = "bottom-right", - hotkey = ["altKey", "KeyT"], - theme = "light", - visibleToasts = VISIBLE_TOASTS_AMOUNT, - dir, - containerAriaLabel = "Notifications", - expand = false, - toastOptions = {}, - closeButton = false, - invert = false, - gap = GAP, - loadingIcon, - offset = VIEWPORT_OFFSET, - richColors = false, - duration = TOAST_LIFETIME, - ...restProps - } = props; - - return ( - - ); + return ; }); diff --git a/src/lib/styled/styles.css b/src/lib/styled/styles.css index 36bdf00..31c2873 100644 --- a/src/lib/styled/styles.css +++ b/src/lib/styled/styles.css @@ -1,5 +1,5 @@ :where(html[dir='ltr']), -:where([data-qwik-toaster][dir='ltr']) { +:where([data-sonner-toaster][dir='ltr']) { --toast-icon-margin-start: -3px; --toast-icon-margin-end: 4px; --toast-svg-margin-start: -1px; @@ -12,7 +12,7 @@ } :where(html[dir='rtl']), -:where([data-qwik-toaster][dir='rtl']) { +:where([data-sonner-toaster][dir='rtl']) { --toast-icon-margin-start: 4px; --toast-icon-margin-end: -3px; --toast-svg-margin-start: 0px; @@ -24,7 +24,7 @@ --toast-close-button-transform: translate(35%, -35%); } -:where([data-qwik-toaster]) { +:where([data-sonner-toaster]) { position: fixed; width: var(--width); font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, @@ -50,28 +50,28 @@ z-index: 999999999; } -:where([data-qwik-toaster][data-x-position='right']) { +:where([data-sonner-toaster][data-x-position='right']) { right: max(var(--offset), env(safe-area-inset-right)); } -:where([data-qwik-toaster][data-x-position='left']) { +:where([data-sonner-toaster][data-x-position='left']) { left: max(var(--offset), env(safe-area-inset-left)); } -:where([data-qwik-toaster][data-x-position='center']) { +:where([data-sonner-toaster][data-x-position='center']) { left: 50%; transform: translateX(-50%); } -:where([data-qwik-toaster][data-y-position='top']) { +:where([data-sonner-toaster][data-y-position='top']) { top: max(var(--offset), env(safe-area-inset-top)); } -:where([data-qwik-toaster][data-y-position='bottom']) { +:where([data-sonner-toaster][data-y-position='bottom']) { bottom: max(var(--offset), env(safe-area-inset-bottom)); } -:where([data-qwik-toast]) { +:where([data-sonner-toast]) { --y: translateY(100%); --lift-amount: calc(var(--lift) * var(--gap)); z-index: var(--z-index); @@ -87,7 +87,7 @@ overflow-wrap: anywhere; } -:where([data-qwik-toast][data-styled='true']) { +:where([data-sonner-toast][data-styled='true']) { padding: 16px; background: var(--normal-bg); border: 1px solid var(--normal-border); @@ -101,37 +101,37 @@ gap: 6px; } -:where([data-qwik-toast]:focus-visible) { +:where([data-sonner-toast]:focus-visible) { box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1), 0 0 0 2px rgba(0, 0, 0, 0.2); } -:where([data-qwik-toast][data-y-position='top']) { +:where([data-sonner-toast][data-y-position='top']) { top: 0; --y: translateY(-100%); --lift: 1; --lift-amount: calc(1 * var(--gap)); } -:where([data-qwik-toast][data-y-position='bottom']) { +:where([data-sonner-toast][data-y-position='bottom']) { bottom: 0; --y: translateY(100%); --lift: -1; --lift-amount: calc(var(--lift) * var(--gap)); } -:where([data-qwik-toast]) :where([data-description]) { +:where([data-sonner-toast]) :where([data-description]) { font-weight: 400; line-height: 1.4; color: inherit; } -:where([data-qwik-toast]) :where([data-title]) { +:where([data-sonner-toast]) :where([data-title]) { font-weight: 500; line-height: 1.5; color: inherit; } -:where([data-qwik-toast]) :where([data-icon]) { +:where([data-sonner-toast]) :where([data-icon]) { display: flex; height: 16px; width: 16px; @@ -143,29 +143,29 @@ margin-right: var(--toast-icon-margin-end); } -:where([data-qwik-toast][data-promise='true']) :where([data-icon])>svg { +:where([data-sonner-toast][data-promise='true']) :where([data-icon])>svg { opacity: 0; transform: scale(0.8); transform-origin: center; - animation: qwik-fade-in 300ms ease forwards; + animation: sonner-fade-in 300ms ease forwards; } -:where([data-qwik-toast]) :where([data-icon])>* { +:where([data-sonner-toast]) :where([data-icon])>* { flex-shrink: 0; } -:where([data-qwik-toast]) :where([data-icon]) svg { +:where([data-sonner-toast]) :where([data-icon]) svg { margin-left: var(--toast-svg-margin-start); margin-right: var(--toast-svg-margin-end); } -:where([data-qwik-toast]) :where([data-content]) { +:where([data-sonner-toast]) :where([data-content]) { display: flex; flex-direction: column; gap: 2px; } -[data-qwik-toast][data-styled="true"] [data-button] { +[data-sonner-toast][data-styled="true"] [data-button] { border-radius: 4px; padding-left: 8px; padding-right: 8px; @@ -184,25 +184,25 @@ transition: opacity 400ms, box-shadow 200ms; } -:where([data-qwik-toast]) :where([data-button]):focus-visible { +:where([data-sonner-toast]) :where([data-button]):focus-visible { box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.4); } -:where([data-qwik-toast]) :where([data-button]):first-of-type { +:where([data-sonner-toast]) :where([data-button]):first-of-type { margin-left: var(--toast-button-margin-start); margin-right: var(--toast-button-margin-end); } -:where([data-qwik-toast]) :where([data-cancel]) { +:where([data-sonner-toast]) :where([data-cancel]) { color: var(--normal-text); background: rgba(0, 0, 0, 0.08); } -:where([data-qwik-toast][data-theme='dark']) :where([data-cancel]) { +:where([data-sonner-toast][data-theme='dark']) :where([data-cancel]) { background: rgba(255, 255, 255, 0.3); } -:where([data-qwik-toast]) :where([data-close-button]) { +:where([data-sonner-toast]) :where([data-close-button]) { position: absolute; left: var(--toast-close-button-start); right: var(--toast-close-button-end); @@ -223,21 +223,21 @@ transition: opacity 100ms, background 200ms, border-color 200ms; } -:where([data-qwik-toast]) :where([data-close-button]):focus-visible { +:where([data-sonner-toast]) :where([data-close-button]):focus-visible { box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1), 0 0 0 2px rgba(0, 0, 0, 0.2); } -:where([data-qwik-toast]) :where([data-disabled='true']) { +:where([data-sonner-toast]) :where([data-disabled='true']) { cursor: not-allowed; } -:where([data-qwik-toast]):hover :where([data-close-button]):hover { +:where([data-sonner-toast]):hover :where([data-close-button]):hover { background: var(--gray2); border-color: var(--gray5); } /* Leave a ghost div to avoid setting hover to false when swiping out */ -:where([data-qwik-toast][data-swiping='true'])::before { +:where([data-sonner-toast][data-swiping='true'])::before { content: ''; position: absolute; left: 0; @@ -246,20 +246,20 @@ z-index: -1; } -:where([data-qwik-toast][data-y-position='top'][data-swiping='true'])::before { +:where([data-sonner-toast][data-y-position='top'][data-swiping='true'])::before { /* y 50% needed to distribute height additional height evenly */ bottom: 50%; transform: scaleY(3) translateY(50%); } -:where([data-qwik-toast][data-y-position='bottom'][data-swiping='true'])::before { +:where([data-sonner-toast][data-y-position='bottom'][data-swiping='true'])::before { /* y -50% needed to distribute height additional height evenly */ top: 50%; transform: scaleY(3) translateY(-50%); } /* Leave a ghost div to avoid setting hover to false when transitioning out */ -:where([data-qwik-toast][data-swiping='false'][data-removed='true'])::before { +:where([data-sonner-toast][data-swiping='false'][data-removed='true'])::before { content: ''; position: absolute; inset: 0; @@ -267,7 +267,7 @@ } /* Needed to avoid setting hover to false when inbetween toasts */ -:where([data-qwik-toast])::after { +:where([data-sonner-toast])::after { content: ''; position: absolute; left: 0; @@ -276,63 +276,63 @@ width: 100%; } -:where([data-qwik-toast][data-mounted='true']) { +:where([data-sonner-toast][data-mounted='true']) { --y: translateY(0); opacity: 1; } -:where([data-qwik-toast][data-expanded='false'][data-front='false']) { +:where([data-sonner-toast][data-expanded='false'][data-front='false']) { --scale: var(--toasts-before) * 0.05 + 1; --y: translateY(calc(var(--lift-amount) * var(--toasts-before))) scale(calc(-1 * var(--scale))); height: var(--front-toast-height); } -:where([data-qwik-toast])>* { +:where([data-sonner-toast])>* { transition: opacity 400ms; } -:where([data-qwik-toast][data-expanded='false'][data-front='false'][data-styled='true'])>* { +:where([data-sonner-toast][data-expanded='false'][data-front='false'][data-styled='true'])>* { opacity: 0; } -:where([data-qwik-toast][data-visible='false']) { +:where([data-sonner-toast][data-visible='false']) { opacity: 0; pointer-events: none; } -:where([data-qwik-toast][data-mounted='true'][data-expanded='true']) { +:where([data-sonner-toast][data-mounted='true'][data-expanded='true']) { --y: translateY(calc(var(--lift) * var(--offset))); height: var(--initial-height); } -:where([data-qwik-toast][data-removed='true'][data-front='true'][data-swipe-out='false']) { +:where([data-sonner-toast][data-removed='true'][data-front='true'][data-swipe-out='false']) { --y: translateY(calc(var(--lift) * -100%)); opacity: 0; } -:where([data-qwik-toast][data-removed='true'][data-front='false'][data-swipe-out='false'][data-expanded='true']) { +:where([data-sonner-toast][data-removed='true'][data-front='false'][data-swipe-out='false'][data-expanded='true']) { --y: translateY(calc(var(--lift) * var(--offset) + var(--lift) * -100%)); opacity: 0; } -:where([data-qwik-toast][data-removed='true'][data-front='false'][data-swipe-out='false'][data-expanded='false']) { +:where([data-sonner-toast][data-removed='true'][data-front='false'][data-swipe-out='false'][data-expanded='false']) { --y: translateY(40%); opacity: 0; transition: transform 500ms, opacity 200ms; } /* Bump up the height to make sure hover state doesn't get set to false */ -:where([data-qwik-toast][data-removed='true'][data-front='false'])::before { +:where([data-sonner-toast][data-removed='true'][data-front='false'])::before { height: calc(var(--initial-height) + 20%); } -[data-qwik-toast][data-swiping='true'] { +[data-sonner-toast][data-swiping='true'] { transform: var(--y) translateY(var(--swipe-amount, 0px)); transition: none; } -[data-qwik-toast][data-swipe-out='true'][data-y-position='bottom'], -[data-qwik-toast][data-swipe-out='true'][data-y-position='top'] { +[data-sonner-toast][data-swipe-out='true'][data-y-position='bottom'], +[data-sonner-toast][data-swipe-out='true'][data-y-position='top'] { animation: swipe-out 200ms ease-out forwards; } @@ -349,7 +349,7 @@ } @media (max-width: 600px) { - [data-qwik-toaster] { + [data-sonner-toaster] { position: fixed; --mobile-offset: 16px; right: var(--mobile-offset); @@ -357,32 +357,32 @@ width: 100%; } - [data-qwik-toaster] [data-qwik-toast] { + [data-sonner-toaster] [data-sonner-toast] { left: 0; right: 0; width: calc(100% - var(--mobile-offset) * 2); } - [data-qwik-toaster][data-x-position='left'] { + [data-sonner-toaster][data-x-position='left'] { left: var(--mobile-offset); } - [data-qwik-toaster][data-y-position='bottom'] { + [data-sonner-toaster][data-y-position='bottom'] { bottom: 20px; } - [data-qwik-toaster][data-y-position='top'] { + [data-sonner-toaster][data-y-position='top'] { top: 20px; } - [data-qwik-toaster][data-x-position='center'] { + [data-sonner-toaster][data-x-position='center'] { left: var(--mobile-offset); right: var(--mobile-offset); transform: none; } } -[data-qwik-toaster][data-theme='light'] { +[data-sonner-toaster][data-theme='light'] { --normal-bg: #fff; --normal-border: var(--gray4); --normal-text: var(--gray12); @@ -404,19 +404,19 @@ --error-text: hsl(360, 100%, 45%); } -[data-qwik-toaster][data-theme='light'] [data-qwik-toast][data-invert='true'] { +[data-sonner-toaster][data-theme='light'] [data-sonner-toast][data-invert='true'] { --normal-bg: #000; --normal-border: hsl(0, 0%, 20%); --normal-text: var(--gray1); } -[data-qwik-toaster][data-theme='dark'] [data-qwik-toast][data-invert='true'] { +[data-sonner-toaster][data-theme='dark'] [data-sonner-toast][data-invert='true'] { --normal-bg: #fff; --normal-border: var(--gray3); --normal-text: var(--gray12); } -[data-qwik-toaster][data-theme='dark'] { +[data-sonner-toaster][data-theme='dark'] { --normal-bg: #000; --normal-border: hsl(0, 0%, 20%); --normal-text: var(--gray1); @@ -438,55 +438,55 @@ --error-text: hsl(358, 100%, 81%); } -[data-rich-colors='true'] [data-qwik-toast][data-type='success'] { +[data-rich-colors='true'] [data-sonner-toast][data-type='success'] { background: var(--success-bg); border-color: var(--success-border); color: var(--success-text); } -[data-rich-colors='true'] [data-qwik-toast][data-type='success'] [data-close-button] { +[data-rich-colors='true'] [data-sonner-toast][data-type='success'] [data-close-button] { background: var(--success-bg); border-color: var(--success-border); color: var(--success-text); } -[data-rich-colors='true'] [data-qwik-toast][data-type='info'] { +[data-rich-colors='true'] [data-sonner-toast][data-type='info'] { background: var(--info-bg); border-color: var(--info-border); color: var(--info-text); } -[data-rich-colors='true'] [data-qwik-toast][data-type='info'] [data-close-button] { +[data-rich-colors='true'] [data-sonner-toast][data-type='info'] [data-close-button] { background: var(--info-bg); border-color: var(--info-border); color: var(--info-text); } -[data-rich-colors='true'] [data-qwik-toast][data-type='warning'] { +[data-rich-colors='true'] [data-sonner-toast][data-type='warning'] { background: var(--warning-bg); border-color: var(--warning-border); color: var(--warning-text); } -[data-rich-colors='true'] [data-qwik-toast][data-type='warning'] [data-close-button] { +[data-rich-colors='true'] [data-sonner-toast][data-type='warning'] [data-close-button] { background: var(--warning-bg); border-color: var(--warning-border); color: var(--warning-text); } -[data-rich-colors='true'] [data-qwik-toast][data-type='error'] { +[data-rich-colors='true'] [data-sonner-toast][data-type='error'] { background: var(--error-bg); border-color: var(--error-border); color: var(--error-text); } -[data-rich-colors='true'] [data-qwik-toast][data-type='error'] [data-close-button] { +[data-rich-colors='true'] [data-sonner-toast][data-type='error'] [data-close-button] { background: var(--error-bg); border-color: var(--error-border); color: var(--error-text); } -.qwik-loading-wrapper { +.sonner-loading-wrapper { --size: 16px; height: var(--size); width: var(--size); @@ -495,12 +495,12 @@ z-index: 10; } -.qwik-loading-wrapper[data-visible='false'] { +.sonner-loading-wrapper[data-visible='false'] { transform-origin: center; - animation: qwik-fade-out 0.2s ease forwards; + animation: sonner-fade-out 0.2s ease forwards; } -.qwik-spinner { +.sonner-spinner { position: relative; top: 50%; left: 50%; @@ -508,8 +508,8 @@ width: var(--size); } -.qwik-loading-bar { - animation: qwik-spin 1.2s linear infinite; +.sonner-loading-bar { + animation: sonner-spin 1.2s linear infinite; background: var(--gray11); border-radius: 6px; height: 8%; @@ -519,68 +519,67 @@ width: 24%; } -.qwik-loading-bar:nth-child(1) { +.sonner-loading-bar:nth-child(1) { animation-delay: -1.2s; - /* Rotate trick to avoid adding an additional pixel in some sizes */ transform: rotate(0.0001deg) translate(146%); } -.qwik-loading-bar:nth-child(2) { +.sonner-loading-bar:nth-child(2) { animation-delay: -1.1s; transform: rotate(30deg) translate(146%); } -.qwik-loading-bar:nth-child(3) { +.sonner-loading-bar:nth-child(3) { animation-delay: -1s; transform: rotate(60deg) translate(146%); } -.qwik-loading-bar:nth-child(4) { +.sonner-loading-bar:nth-child(4) { animation-delay: -0.9s; transform: rotate(90deg) translate(146%); } -.qwik-loading-bar:nth-child(5) { +.sonner-loading-bar:nth-child(5) { animation-delay: -0.8s; transform: rotate(120deg) translate(146%); } -.qwik-loading-bar:nth-child(6) { +.sonner-loading-bar:nth-child(6) { animation-delay: -0.7s; transform: rotate(150deg) translate(146%); } -.qwik-loading-bar:nth-child(7) { +.sonner-loading-bar:nth-child(7) { animation-delay: -0.6s; transform: rotate(180deg) translate(146%); } -.qwik-loading-bar:nth-child(8) { +.sonner-loading-bar:nth-child(8) { animation-delay: -0.5s; transform: rotate(210deg) translate(146%); } -.qwik-loading-bar:nth-child(9) { +.sonner-loading-bar:nth-child(9) { animation-delay: -0.4s; transform: rotate(240deg) translate(146%); } -.qwik-loading-bar:nth-child(10) { +.sonner-loading-bar:nth-child(10) { animation-delay: -0.3s; transform: rotate(270deg) translate(146%); } -.qwik-loading-bar:nth-child(11) { +.sonner-loading-bar:nth-child(11) { animation-delay: -0.2s; transform: rotate(300deg) translate(146%); } -.qwik-loading-bar:nth-child(12) { +.sonner-loading-bar:nth-child(12) { animation-delay: -0.1s; transform: rotate(330deg) translate(146%); } -@keyframes qwik-fade-in { +@keyframes sonner-fade-in { 0% { opacity: 0; transform: scale(0.8); @@ -592,7 +591,7 @@ } } -@keyframes qwik-fade-out { +@keyframes sonner-fade-out { 0% { opacity: 1; transform: scale(1); @@ -604,7 +603,7 @@ } } -@keyframes qwik-spin { +@keyframes sonner-spin { 0% { opacity: 1; } @@ -616,15 +615,15 @@ @media (prefers-reduced-motion) { - [data-qwik-toast], - [data-qwik-toast]>*, - .qwik-loading-bar { + [data-sonner-toast], + [data-sonner-toast]>*, + .sonner-loading-bar { transition: none !important; animation: none !important; } } -.qwik-loader { +.sonner-loader { position: absolute; top: 50%; left: 50%; @@ -633,7 +632,7 @@ transition: opacity 200ms, transform 200ms; } -.qwik-loader[data-visible='false'] { +.sonner-loader[data-visible='false'] { opacity: 0; transform: scale(0.8) translate(-50%, -50%); } \ No newline at end of file diff --git a/src/root.tsx b/src/root.tsx index 03ac089..d91435b 100644 --- a/src/root.tsx +++ b/src/root.tsx @@ -1,8 +1,9 @@ import { $, component$, useSignal } from "@builder.io/qwik"; -import { Toaster, toast } from "./lib"; +import { Toaster } from "./lib/styled"; +import { toast } from "./lib"; export default component$(() => { - const tId = useSignal("myCustomId"); + const tId = useSignal(0); return ( <> @@ -18,16 +19,30 @@ export default component$(() => { > open toaster - - + ); diff --git a/vite.config.ts b/vite.config.ts index 6b864f8..6910737 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,24 +8,27 @@ const makeRegex = (dep) => new RegExp(`^${dep}(/.*)?$`); const excludeAll = (obj) => Object.keys(obj).map(makeRegex); export default defineConfig(() => { + const env = process.env.ENTRY as "styled" | "headless" | undefined; + + if (!env) throw new Error("ENTRY env var is required"); + + const entry = + env === "headless" ? "src/lib/headless/toast-wrapper.tsx" : "src/lib"; + return { build: { target: "es2020", outDir: "lib", lib: { - entry: { - index: "./src/lib/", - headless: "./src/lib/headless", - core: "./src/lib/core", - }, + entry, formats: ["es", "cjs"], fileName: (format, file) => { const ext = format === "es" ? "mjs" : "cjs"; - return `${file}.qwik.${ext}`; + const name = env === "styled" ? "index" : "headless"; + return `${name}.qwik.${ext}`; }, - name: "qwik-toast", }, - emptyOutDir: true, + emptyOutDir: env === "styled" ? true : false, rollupOptions: { // externalize deps that shouldn't be bundled into the library external: [ @@ -35,7 +38,7 @@ export default defineConfig(() => { ], // all the chunks created by vite shuold also have qwik in the name output: { - chunkFileNames: "[name]-[hash].qwik.js", + chunkFileNames: "[name]-[format].qwik.js", }, }, }, diff --git a/website/src/components/Footer.tsx b/website/src/components/Footer.tsx index 6e446ce..6a296d0 100644 --- a/website/src/components/Footer.tsx +++ b/website/src/components/Footer.tsx @@ -1,6 +1,7 @@ +import { component$ } from "@builder.io/qwik"; import styles from "./footer.module.css"; -export const Footer = () => { +export const Footer = component$(() => { return (
    @@ -52,4 +53,4 @@ export const Footer = () => {
    ); -}; +}); diff --git a/website/src/components/Position.tsx b/website/src/components/Position.tsx index 73f562c..7192cc4 100644 --- a/website/src/components/Position.tsx +++ b/website/src/components/Position.tsx @@ -1,22 +1,20 @@ -import { toast, type ToasterProps } from "qwik-sonner"; +import { toast } from "qwik-sonner"; import { CodeBlock } from "./CodeBlock"; import { type Signal, component$ } from "@builder.io/qwik"; -type PositionT = Required>["position"]; - -const positions: PositionT[] = [ +const positions = [ "top-left", "top-center", "top-right", "bottom-left", "bottom-center", "bottom-right", -]; +] as const; export type Position = (typeof positions)[number]; export const Position = component$( - ({ position }: { position: Signal }) => { + ({ position }: { position: Signal }) => { return (

    Position

    diff --git a/website/src/components/Types.tsx b/website/src/components/Types.tsx index 52bf9fa..70c7e11 100644 --- a/website/src/components/Types.tsx +++ b/website/src/components/Types.tsx @@ -87,7 +87,7 @@ const allTypes = [ toast.message("Event has been created", { action: { label: "Undo", - onClick: $(() => console.log("Undo")), + onClick$: $(() => console.log("Undo")), }, }), ), @@ -104,9 +104,9 @@ toast.promise(promise, { error: 'Error', });`, action: $(() => - toast.promise<{ name: string }>( + toast.promise( $( - () => + (): Promise<{ name: string }> => new Promise((resolve) => { setTimeout(() => { resolve({ name: "Sonner" }); diff --git a/website/tsconfig.json b/website/tsconfig.json index 937994b..3d59706 100644 --- a/website/tsconfig.json +++ b/website/tsconfig.json @@ -2,14 +2,14 @@ "compilerOptions": { "allowJs": true, "target": "ES2017", - "module": "ES2022", + "module": "ESNext", "lib": ["es2022", "DOM", "WebWorker", "DOM.Iterable"], "jsx": "react-jsx", "jsxImportSource": "@builder.io/qwik", "strict": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "moduleResolution": "node", + "moduleResolution": "Bundler", "esModuleInterop": true, "skipLibCheck": true, "incremental": true,