diff --git a/.changeset/notification-initial.md b/.changeset/notification-initial.md new file mode 100644 index 000000000..d2679fbdb --- /dev/null +++ b/.changeset/notification-initial.md @@ -0,0 +1,14 @@ +--- +"@solid-primitives/notification": minor +--- + +Add `@solid-primitives/notification` package (Stage 0) + +New primitives for the browser [Notifications API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API). + +- **`isNotificationSupported()`** — SSR-safe runtime check for Notifications API availability. +- **`makeNotification(title, options?)`** — Non-reactive helper returning `[show, close]`. `show()` creates and returns a `Notification` instance (or `null` when permission is not `"granted"`); calling it again replaces the previous notification. No Solid lifecycle dependency. +- **`createNotification(title, options?)`** — Reactive primitive returning `{ show, close, notification, supported }`. Accepts reactive accessors for `title` and `options` — their current values are read at `show()` time. The `notification` accessor tracks the live instance, updating to `null` when it is dismissed by the OS or closed programmatically. Cleans up automatically on owner disposal. +- **`createNotificationPermission()`** — Reactive permission manager returning `{ permission, requestPermission }`. The `permission` accessor reflects `Notification.permission` and updates after each `requestPermission()` call. Degrades gracefully to `"unknown"` on the server or when the Notifications API is unsupported. + +Peer dependencies: `solid-js@^2.0.0-beta.14` and `@solidjs/web@^2.0.0-beta.14`. diff --git a/.changeset/sweet-olives-talk.md b/.changeset/sweet-olives-talk.md new file mode 100644 index 000000000..a2c30d8f5 --- /dev/null +++ b/.changeset/sweet-olives-talk.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/permission": major +--- + +updated to Solid-2.0 diff --git a/packages/notification/LICENSE b/packages/notification/LICENSE new file mode 100644 index 000000000..38b41d975 --- /dev/null +++ b/packages/notification/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Solid Primitives Working Group + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/notification/README.md b/packages/notification/README.md new file mode 100644 index 000000000..4b5a85bc3 --- /dev/null +++ b/packages/notification/README.md @@ -0,0 +1,188 @@ +

+ Solid Primitives notification +

+ +# @solid-primitives/notification + +[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/notification?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-primitives/notification) +[![version](https://img.shields.io/npm/v/@solid-primitives/notification?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/notification) +[![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) + +Primitives for the browser [Notifications API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API) with reactive permission management. + +- **`isNotificationSupported`** — SSR-safe check for Notifications API availability. +- **`makeNotification`** — Non-reactive helper returning `[show, close]`. No Solid lifecycle dependency. +- **`createNotification`** — Reactive primitive that tracks the live `Notification` instance and cleans up on owner disposal. +- **`createNotificationPermission`** — Reactive permission manager that exposes a live permission signal and a `requestPermission` function. + +## Installation + +```bash +npm install @solid-primitives/notification +# or +yarn add @solid-primitives/notification +# or +pnpm add @solid-primitives/notification +``` + +## How to use it + +### `isNotificationSupported` + +Returns `true` when the Notifications API is available. Always `false` on the server. + +```ts +import { isNotificationSupported } from "@solid-primitives/notification"; + +if (isNotificationSupported()) { + console.log("notifications available"); +} +``` + +--- + +### `makeNotification` + +Non-reactive helper with no Solid lifecycle dependency. Both returned functions are no-ops when the API is unavailable. + +`show()` returns `null` when `Notification.permission` is not `"granted"` — use `createNotificationPermission` to request permission first. + +Because `makeNotification` has no reactive owner, **cleanup is the caller's responsibility**. Inside a reactive scope, register `close` with `onCleanup`: + +```ts +import { onCleanup } from "solid-js"; +import { makeNotification } from "@solid-primitives/notification"; + +const [show, close] = makeNotification("New message", { body: "Hello!" }); + +// Register cleanup with the current reactive owner +onCleanup(close); + +button.addEventListener("click", () => show()); + +// Or close programmatically at any time +close(); +``` + +Outside a reactive scope (e.g. in plain event handlers), call `close()` directly when done. + +--- + +### `createNotification` + +Reactive primitive tied to the current reactive owner. + +- `title` and `options` can be plain values **or** reactive accessors — their current values are read each time `show()` is called. +- `notification` is a reactive `Accessor` that reflects the live instance, updating to `null` when the notification is dismissed (either programmatically or by the OS). +- The notification is automatically closed when the reactive owner is disposed. +- Pass an optional `handlers` object to respond to notification events. + +```ts +import { createEffect } from "solid-js"; +import { createNotification } from "@solid-primitives/notification"; + +const { show, close, notification, supported } = createNotification( + () => `You have ${unread()} messages`, + { icon: "/icon.png" }, + { + onClick: n => { window.focus(); }, + onClose: n => { console.log("dismissed"); }, + onError: n => { console.error("notification failed"); }, + }, +); + +// Show a notification (reads reactive title at call time) +show(); + +// React to visibility changes +createEffect(() => { + if (notification()) console.log("notification visible"); + else console.log("notification gone"); +}); + +// Close programmatically +close(); +``` + +--- + +### `createNotificationPermission` + +Reactive permission manager built on the browser [Permissions API](https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API). + +The `permission` accessor reflects the **live** permission state and updates automatically whenever it changes — including after `requestPermission()` resolves or the user edits their browser settings directly. + +Permission values follow Permissions API vocabulary: `"granted"`, `"denied"`, `"prompt"` (not yet asked), or `"unknown"` while the initial async query is still resolving. Note that the Notifications API uses `"default"` for the same concept that the Permissions API calls `"prompt"`. + +On the server or when the API is unavailable, `permission` always returns `"unknown"` and `requestPermission` resolves immediately without effect. + +```ts +import { Show } from "solid-js"; +import { createNotificationPermission } from "@solid-primitives/notification"; + +const { permission, requestPermission } = createNotificationPermission(); + +// Gate UI on permission state + + + + +// Call without expecting a return value — permission() updates reactively after it resolves +requestPermission(); +``` + +--- + +### Full example + +```tsx +import { Component, Show } from "solid-js"; +import { + createNotification, + createNotificationPermission, + isNotificationSupported, +} from "@solid-primitives/notification"; + +const NotificationDemo: Component = () => { + const { permission, requestPermission } = createNotificationPermission(); + const { show, close, notification } = createNotification( + "Solid Primitives", + { body: "Hello from SolidJS!" }, + { onClick: () => window.focus() }, + ); + + return ( + Not supported

}> +

Permission: {permission()}

+

Active: {notification() ? "yes" : "no"}

+ + + + + +
+ ); +}; +``` + +## Types + +```ts +/** Event handler callbacks for `createNotification`. */ +type NotificationEventHandlers = { + /** Called when the user clicks the notification. */ + onClick?: (notification: Notification) => void; + /** Called when the notification is dismissed, whether by the user, the OS, or `close()`. */ + onClose?: (notification: Notification) => void; + /** Called when the notification fails to display. */ + onError?: (notification: Notification) => void; +}; +``` + +## Browser Support + +The [Notifications API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API#browser_compatibility) is supported in all modern browsers. It is not available in iOS Safari (as of 2025) or on the server. All primitives degrade gracefully — `show()` returns `null`, `close()` is a no-op, and `permission()` returns `"unknown"`. + +## Changelog + +See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/notification/dev/index.tsx b/packages/notification/dev/index.tsx new file mode 100644 index 000000000..1aeecbefa --- /dev/null +++ b/packages/notification/dev/index.tsx @@ -0,0 +1,62 @@ +import { type Component, createSignal, Show } from "solid-js"; +import { + isNotificationSupported, + createNotification, + createNotificationPermission, +} from "../src/index.js"; + +const App: Component = () => { + const supported = isNotificationSupported(); + const [body, setBody] = createSignal("Hello from Solid Primitives!"); + const { permission, requestPermission } = createNotificationPermission(); + const { show, close, notification } = createNotification( + () => "Solid Primitives Notification", + () => ({ body: body() }), + ); + + return ( +
+
+

Notification Primitive

+ + Notifications API is not supported in this browser.

} + > +

+ Permission: {permission()} +

+

+ Active notification: {notification() ? "visible" : "none"} +

+ + + +
+ + + + + +
+
+
+
+ ); +}; + +export default App; diff --git a/packages/notification/package.json b/packages/notification/package.json new file mode 100644 index 000000000..e5f5b1072 --- /dev/null +++ b/packages/notification/package.json @@ -0,0 +1,70 @@ +{ + "name": "@solid-primitives/notification", + "version": "0.0.100", + "description": "Primitives for the browser Notifications API with reactive permission management", + "author": "David Di Biase ", + "contributors": [], + "license": "MIT", + "homepage": "https://primitives.solidjs.community/package/notification", + "repository": { + "type": "git", + "url": "git+https://github.com/solidjs-community/solid-primitives.git" + }, + "bugs": { + "url": "https://github.com/solidjs-community/solid-primitives/issues" + }, + "primitive": { + "name": "notification", + "stage": 0, + "list": [ + "isNotificationSupported", + "makeNotification", + "createNotification", + "createNotificationPermission" + ], + "category": "Browser APIs" + }, + "keywords": [ + "solid", + "notification", + "browser", + "permission", + "primitives" + ], + "private": false, + "sideEffects": false, + "files": [ + "dist" + ], + "type": "module", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "browser": {}, + "exports": { + "import": { + "@solid-primitives/source": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "typesVersions": {}, + "scripts": { + "dev": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/dev.ts", + "build": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/build.ts", + "vitest": "vitest -c ../../configs/vitest.config.ts", + "test": "pnpm run vitest", + "test:ssr": "pnpm run vitest --mode ssr" + }, + "peerDependencies": { + "@solidjs/web": "^2.0.0-beta.14", + "solid-js": "^2.0.0-beta.14" + }, + "dependencies": { + "@solid-primitives/permission": "workspace:^", + "@solid-primitives/utils": "workspace:^" + }, + "devDependencies": { + "@solidjs/web": "2.0.0-beta.14", + "solid-js": "2.0.0-beta.14" + } +} diff --git a/packages/notification/src/index.ts b/packages/notification/src/index.ts new file mode 100644 index 000000000..cd0ad0a0c --- /dev/null +++ b/packages/notification/src/index.ts @@ -0,0 +1,236 @@ +import { action, createOptimistic, createSignal, onCleanup, type Accessor } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { INTERNAL_OPTIONS, isDev, noop, access, type MaybeAccessor } from "@solid-primitives/utils"; +import { createPermission } from "@solid-primitives/permission"; + +/** + * Returns `true` when the [Notifications API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API) + * is available in the current environment. + */ +export const isNotificationSupported = (): boolean => !isServer && "Notification" in window; + +/** + * Non-reactive notification helper. No Solid lifecycle dependency. + * Both returned functions are no-ops / return `null` when the API is unavailable. + * + * Permission must be `"granted"` before calling `show()` — use + * `createNotificationPermission` to request it reactively. + * + * @param title Notification title. + * @param options Standard `NotificationOptions` (body, icon, tag, etc.). + * @returns `[show, close]` — `show()` creates and returns the `Notification` + * (or `null` when permission is not `"granted"`); `close()` dismisses it. + * + * @example + * ```ts + * const [show, close] = makeNotification("New message", { body: "Hello!" }); + * button.addEventListener("click", () => show()); + * ``` + */ +export function makeNotification( + title: string, + options?: NotificationOptions, +): [show: () => Notification | null, close: VoidFunction] { + if (!isNotificationSupported()) return [() => null, noop]; + + let current: Notification | undefined; + let closeHandler: VoidFunction | undefined; + + const close: VoidFunction = () => { + if (current && closeHandler) { + current.removeEventListener("close", closeHandler); + closeHandler = undefined; + } + current?.close(); + current = undefined; + }; + + const show = (): Notification | null => { + if (Notification.permission !== "granted") { + // eslint-disable-next-line no-console + if (isDev) console.warn( + `[@solid-primitives/notification] show() called with Notification.permission "${Notification.permission}" — must be "granted".`, + ); + return null; + } + close(); + const n = new Notification(title, options); + current = n; + closeHandler = () => { + if (current === n) { + current = undefined; + closeHandler = undefined; + } + }; + n.addEventListener("close", closeHandler); + return n; + }; + + return [show, close]; +} + +/** Event handler callbacks for `createNotification`. */ +export type NotificationEventHandlers = { + /** Called when the user clicks the notification. */ + onClick?: (notification: Notification) => void; + /** Called when the notification is dismissed, whether by the user, the OS, or `close()`. */ + onClose?: (notification: Notification) => void; + /** Called when the notification fails to display. */ + onError?: (notification: Notification) => void; +}; + +/** + * Reactive notification primitive tied to the current reactive owner. + * + * Accepts reactive `title` and `options` — their current values are read each + * time `show()` is called. The `notification` accessor tracks the live + * `Notification` instance, updating to `null` when it is dismissed or closed. + * The notification is closed automatically on owner disposal. + * + * Permission must be `"granted"` before calling `show()` — use + * `createNotificationPermission` to request it reactively. + * + * @param title Notification title, or a reactive accessor returning one. + * @param options Standard `NotificationOptions`, or a reactive accessor. + * @param handlers Optional event callbacks (`onClick`, `onClose`, `onError`). + * @returns `{ show, close, notification, supported }` + * + * @example + * ```ts + * const { show, close, notification } = createNotification( + * () => `You have ${unread()} messages`, + * { icon: "/icon.png" }, + * { onClick: () => window.focus() }, + * ); + * ``` + */ +export function createNotification( + title: MaybeAccessor, + options?: MaybeAccessor, + handlers?: NotificationEventHandlers, +): { + show: () => Notification | null; + close: VoidFunction; + notification: Accessor; + supported: boolean; +} { + const supported = isNotificationSupported(); + + if (!supported) { + return { show: () => null, close: noop, notification: () => null, supported }; + } + + const [notification, setNotification] = createSignal(null, INTERNAL_OPTIONS); + let current: Notification | null = null; + let currentCleanup: VoidFunction | undefined; + + const close: VoidFunction = () => { + const n = current; + currentCleanup?.(); + currentCleanup = undefined; + n?.close(); + current = null; + setNotification(null); + if (n) handlers?.onClose?.(n); + }; + + const show = (): Notification | null => { + if (Notification.permission !== "granted") { + // eslint-disable-next-line no-console + if (isDev) console.warn( + `[@solid-primitives/notification] show() called with Notification.permission "${Notification.permission}" — must be "granted".`, + ); + return null; + } + close(); + const n = new Notification(access(title), access(options)); + current = n; + + const onCloseEvent = () => { + if (current === n) { + currentCleanup?.(); + currentCleanup = undefined; + current = null; + setNotification(null); + handlers?.onClose?.(n); + } + }; + + n.addEventListener("close", onCloseEvent); + const cleanups: VoidFunction[] = [() => n.removeEventListener("close", onCloseEvent)]; + + if (handlers?.onClick) { + const h = () => handlers.onClick!(n); + n.addEventListener("click", h); + cleanups.push(() => n.removeEventListener("click", h)); + } + + if (handlers?.onError) { + const h = () => handlers.onError!(n); + n.addEventListener("error", h); + cleanups.push(() => n.removeEventListener("error", h)); + } + + currentCleanup = () => cleanups.forEach(fn => fn()); + setNotification(n); + return n; + }; + + onCleanup(close); + + return { show, close, notification, supported }; +} + +/** + * Reactive notification permission manager built on `createPermission`. + * + * The `permission` accessor reflects the live state from the browser + * [Permissions API](https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API) + * and updates automatically whenever permission changes — including after + * `requestPermission()` resolves or the user edits browser settings. + * + * Permission values follow the Permissions API vocabulary: `"granted"`, + * `"denied"`, `"prompt"` (not yet asked), or `"unknown"` while the query + * is still resolving. Note that the Notifications API uses `"default"` for + * the same concept that the Permissions API calls `"prompt"`. + * + * @returns `{ permission, requestPermission, pending }` + * + * @example + * ```ts + * const { permission, requestPermission } = createNotificationPermission(); + * + * + * + * + * ``` + */ +export function createNotificationPermission(): { + permission: Accessor; + requestPermission: () => Promise; + pending: Accessor; +} { + if (!isNotificationSupported()) { + return { + permission: () => "unknown" as const, + requestPermission: () => Promise.resolve(), + pending: () => false, + }; + } + + const permission = createPermission("notifications"); + const [pending, setPending] = createOptimistic(false, INTERNAL_OPTIONS); + + // createPermission tracks state via the change event — no manual update needed + const requestPermission = action(function* () { + setPending(true); + try { + yield Notification.requestPermission(); + } catch { + // swallow — permission updates reactively via createPermission + } + setPending(false); + }); + + return { permission, requestPermission, pending }; +} diff --git a/packages/notification/test/index.test.ts b/packages/notification/test/index.test.ts new file mode 100644 index 000000000..5a4333241 --- /dev/null +++ b/packages/notification/test/index.test.ts @@ -0,0 +1,538 @@ +import { describe, test, expect, vi, beforeAll, afterAll, beforeEach } from "vitest"; +import { createRoot, createSignal, flush, onCleanup } from "solid-js"; +import { + isNotificationSupported, + makeNotification, + createNotification, + createNotificationPermission, +} from "../src/index.js"; + +// ── Mock Notification API ───────────────────────────────────────────────────── + +class MockNotification { + static permission: NotificationPermission = "granted"; + static requestPermission = vi.fn().mockResolvedValue("granted" as NotificationPermission); + static instances: MockNotification[] = []; + + title: string; + private listeners: Map void)[]> = new Map(); + + constructor(title: string, _options?: NotificationOptions) { + this.title = title; + MockNotification.instances.push(this); + } + + close = vi.fn().mockImplementation(() => { + this.listeners.get("close")?.forEach(fn => fn()); + }); + + addEventListener = vi.fn().mockImplementation((event: string, fn: () => void) => { + const list = this.listeners.get(event) ?? []; + list.push(fn); + this.listeners.set(event, list); + }); + + removeEventListener = vi.fn().mockImplementation((event: string, fn: () => void) => { + const list = this.listeners.get(event) ?? []; + this.listeners.set( + event, + list.filter(f => f !== fn), + ); + }); + + simulateClose() { + this.listeners.get("close")?.forEach(fn => fn()); + } + + simulateClick() { + this.listeners.get("click")?.forEach(fn => fn()); + } + + simulateError() { + this.listeners.get("error")?.forEach(fn => fn()); + } +} + +// ── Mock Permissions API ────────────────────────────────────────────────────── + +const mockPermStatus = { + state: "granted" as PermissionState, + _listeners: [] as (() => void)[], + addEventListener(_: string, fn: () => void) { + this._listeners.push(fn); + }, + removeEventListener(_: string, fn: () => void) { + const i = this._listeners.indexOf(fn); + if (i >= 0) this._listeners.splice(i, 1); + }, + dispatchChange(state: PermissionState) { + this.state = state; + this._listeners.forEach(fn => fn()); + }, +}; + +// ── Global setup ────────────────────────────────────────────────────────────── + +beforeAll(() => { + Object.defineProperty(window, "Notification", { + value: MockNotification, + configurable: true, + writable: true, + }); + + (navigator as any).permissions ??= {} as any; + navigator.permissions.query = vi.fn().mockImplementation(({ name }: PermissionDescriptor) => { + if (name === "notifications") return Promise.resolve(mockPermStatus); + return Promise.reject(new Error(`Unhandled permission: ${name}`)); + }); +}); + +afterAll(() => { + Object.defineProperty(window, "Notification", { value: undefined, configurable: true }); +}); + +beforeEach(() => { + MockNotification.instances = []; + MockNotification.permission = "granted"; + MockNotification.requestPermission.mockClear().mockResolvedValue("granted"); + mockPermStatus.state = "granted"; + mockPermStatus._listeners = []; +}); + +// ── isNotificationSupported ─────────────────────────────────────────────────── + +describe("isNotificationSupported", () => { + test("returns true when Notification is available", () => { + expect(isNotificationSupported()).toBe(true); + }); +}); + +// ── makeNotification ────────────────────────────────────────────────────────── + +describe("makeNotification", () => { + test("show creates a Notification with the given title", () => { + const [show] = makeNotification("Hello"); + show(); + expect(MockNotification.instances).toHaveLength(1); + expect(MockNotification.instances[0]!.title).toBe("Hello"); + }); + + test("show returns the Notification instance", () => { + const [show] = makeNotification("Hello"); + expect(show()).toBeInstanceOf(MockNotification); + }); + + test("show returns null when permission is not granted", () => { + MockNotification.permission = "denied"; + const [show] = makeNotification("Hello"); + expect(show()).toBeNull(); + expect(MockNotification.instances).toHaveLength(0); + }); + + test("close dismisses the current notification", () => { + const [show, close] = makeNotification("Hello"); + show(); + const instance = MockNotification.instances[0]!; + close(); + expect(instance.close).toHaveBeenCalled(); + }); + + test("close removes the event listener before closing", () => { + const [show, close] = makeNotification("Hello"); + show(); + const instance = MockNotification.instances[0]!; + close(); + expect(instance.removeEventListener).toHaveBeenCalledWith("close", expect.any(Function)); + }); + + test("show replaces an existing notification", () => { + const [show] = makeNotification("Hello"); + show(); + const first = MockNotification.instances[0]!; + show(); + expect(first.close).toHaveBeenCalled(); + expect(MockNotification.instances).toHaveLength(2); + }); + + test("external close clears internal reference so close() becomes a no-op", () => { + const [show, close] = makeNotification("Hello"); + show(); + const instance = MockNotification.instances[0]!; + instance.simulateClose(); + instance.close.mockClear(); + close(); + expect(instance.close).not.toHaveBeenCalled(); + }); + + test("close can be registered with onCleanup by the caller for reactive cleanup", () => { + const { dispose } = createRoot(dispose => { + const [show, close] = makeNotification("Hello"); + onCleanup(close); + show(); + return { dispose }; + }); + + const instance = MockNotification.instances[0]!; + instance.close.mockClear(); + + dispose(); + expect(instance.close).toHaveBeenCalled(); + }); +}); + +// ── createNotification ──────────────────────────────────────────────────────── + +describe("createNotification", () => { + test("initial state: notification is null, supported is true", () => { + createRoot(dispose => { + const { notification, supported } = createNotification("Hello"); + expect(notification()).toBeNull(); + expect(supported).toBe(true); + dispose(); + }); + }); + + test("show creates a Notification and updates the signal", () => { + const { show, notification, dispose } = createRoot(dispose => { + const { show, notification } = createNotification("Hello"); + return { show, notification, dispose }; + }); + + show(); + flush(); + expect(notification()).toBeInstanceOf(MockNotification); + expect((notification() as MockNotification).title).toBe("Hello"); + + dispose(); + }); + + test("show returns null and warns when permission is not granted", () => { + MockNotification.permission = "denied"; + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { show, notification, dispose } = createRoot(dispose => { + const { show, notification } = createNotification("Hello"); + return { show, notification, dispose }; + }); + + expect(show()).toBeNull(); + flush(); + expect(notification()).toBeNull(); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + dispose(); + }); + + test("close dismisses the notification and sets signal to null", () => { + const { show, close, notification, dispose } = createRoot(dispose => { + const { show, close, notification } = createNotification("Hello"); + return { show, close, notification, dispose }; + }); + + show(); + flush(); + + close(); + flush(); + expect(notification()).toBeNull(); + expect(MockNotification.instances[0]!.close).toHaveBeenCalled(); + + dispose(); + }); + + test("close removes the event listener before closing", () => { + const { show, close, dispose } = createRoot(dispose => { + const { show, close } = createNotification("Hello"); + return { show, close, dispose }; + }); + + show(); + flush(); + close(); + expect(MockNotification.instances[0]!.removeEventListener).toHaveBeenCalledWith( + "close", + expect.any(Function), + ); + + dispose(); + }); + + test("external close (OS dismiss) sets signal to null", () => { + const { show, notification, dispose } = createRoot(dispose => { + const { show, notification } = createNotification("Hello"); + return { show, notification, dispose }; + }); + + show(); + flush(); + + MockNotification.instances[0]!.simulateClose(); + flush(); + expect(notification()).toBeNull(); + + dispose(); + }); + + test("show replaces an existing notification", () => { + const { show, notification, dispose } = createRoot(dispose => { + const { show, notification } = createNotification("Hello"); + return { show, notification, dispose }; + }); + + show(); + flush(); + const first = notification(); + + show(); + flush(); + expect(notification()).not.toBe(first); + expect((first as MockNotification).close).toHaveBeenCalled(); + + dispose(); + }); + + test("dispose closes the notification", () => { + const { show, dispose } = createRoot(dispose => { + const { show } = createNotification("Hello"); + return { show, dispose }; + }); + + show(); + flush(); + const instance = MockNotification.instances[0]!; + instance.close.mockClear(); + + dispose(); + expect(instance.close).toHaveBeenCalled(); + }); + + test("reactive title: reads current accessor value at show() time", () => { + const [title, setTitle] = createSignal("First"); + + const { show, notification, dispose } = createRoot(dispose => { + const { show, notification } = createNotification(title); + return { show, notification, dispose }; + }); + + show(); + flush(); + expect((notification() as MockNotification).title).toBe("First"); + + setTitle("Second"); + flush(); + // not re-shown automatically — title only read on next show() call + expect((notification() as MockNotification).title).toBe("First"); + + show(); + flush(); + expect((notification() as MockNotification).title).toBe("Second"); + + dispose(); + }); + + // ── Event callbacks ───────────────────────────────────────────────────────── + + test("onClick fires when click event is dispatched", () => { + const onClick = vi.fn(); + + const { show, dispose } = createRoot(dispose => { + const { show } = createNotification("Hello", undefined, { onClick }); + return { show, dispose }; + }); + + show(); + flush(); + MockNotification.instances[0]!.simulateClick(); + + expect(onClick).toHaveBeenCalledOnce(); + expect(onClick).toHaveBeenCalledWith(MockNotification.instances[0]); + + dispose(); + }); + + test("onClose fires when OS dismisses the notification", () => { + const onClose = vi.fn(); + + const { show, dispose } = createRoot(dispose => { + const { show } = createNotification("Hello", undefined, { onClose }); + return { show, dispose }; + }); + + show(); + flush(); + MockNotification.instances[0]!.simulateClose(); + + expect(onClose).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledWith(MockNotification.instances[0]); + + dispose(); + }); + + test("onClose fires when close() is called programmatically", () => { + const onClose = vi.fn(); + + const { show, close, dispose } = createRoot(dispose => { + const { show, close } = createNotification("Hello", undefined, { onClose }); + return { show, close, dispose }; + }); + + show(); + flush(); + close(); + + expect(onClose).toHaveBeenCalledOnce(); + + dispose(); + }); + + test("onError fires when error event is dispatched", () => { + const onError = vi.fn(); + + const { show, dispose } = createRoot(dispose => { + const { show } = createNotification("Hello", undefined, { onError }); + return { show, dispose }; + }); + + show(); + flush(); + MockNotification.instances[0]!.simulateError(); + + expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith(MockNotification.instances[0]); + + dispose(); + }); + + test("event listeners are removed when close() is called", () => { + const onClick = vi.fn(); + const onClose = vi.fn(); + + const { show, close, dispose } = createRoot(dispose => { + const { show, close } = createNotification("Hello", undefined, { onClick, onClose }); + return { show, close, dispose }; + }); + + show(); + flush(); + close(); + onClose.mockClear(); + + // After close(), simulating OS events should not trigger callbacks + MockNotification.instances[0]!.simulateClick(); + MockNotification.instances[0]!.simulateClose(); + + expect(onClick).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + + dispose(); + }); +}); + +// ── createNotificationPermission ────────────────────────────────────────────── + +describe("createNotificationPermission", () => { + test("permission starts as unknown before query resolves", () => { + createRoot(dispose => { + const { permission } = createNotificationPermission(); + expect(permission()).toBe("unknown"); + dispose(); + }); + }); + + test("permission resolves to current state after query", async () => { + mockPermStatus.state = "granted"; + + const { permission, dispose } = createRoot(dispose => { + const { permission } = createNotificationPermission(); + return { permission, dispose }; + }); + + expect(permission()).toBe("unknown"); + await Promise.resolve(); + flush(); + expect(permission()).toBe("granted"); + + dispose(); + }); + + test("permission updates reactively when state changes externally", async () => { + mockPermStatus.state = "granted"; + + const { permission, dispose } = createRoot(dispose => { + const { permission } = createNotificationPermission(); + return { permission, dispose }; + }); + + await Promise.resolve(); + flush(); + expect(permission()).toBe("granted"); + + mockPermStatus.dispatchChange("denied"); + flush(); + expect(permission()).toBe("denied"); + + dispose(); + }); + + test("requestPermission calls Notification.requestPermission", async () => { + const { requestPermission, dispose } = createRoot(dispose => { + const { requestPermission } = createNotificationPermission(); + return { requestPermission, dispose }; + }); + + await requestPermission(); + expect(MockNotification.requestPermission).toHaveBeenCalledOnce(); + + dispose(); + }); + + test("permission reflects resolved value after requestPermission", async () => { + mockPermStatus.state = "granted"; + MockNotification.requestPermission.mockResolvedValue("granted"); + + const { permission, requestPermission, dispose } = createRoot(dispose => { + const { permission, requestPermission } = createNotificationPermission(); + return { permission, requestPermission, dispose }; + }); + + await Promise.resolve(); + flush(); + await requestPermission(); + flush(); + expect(permission()).toBe("granted"); + + dispose(); + }); + + test("pending is false initially", () => { + createRoot(dispose => { + const { pending } = createNotificationPermission(); + expect(pending()).toBe(false); + dispose(); + }); + }); + + test("pending is true while requestPermission is in flight", async () => { + let resolve!: (v: NotificationPermission) => void; + MockNotification.requestPermission.mockImplementation( + () => new Promise(r => (resolve = r)), + ); + + const { requestPermission, pending, dispose } = createRoot(dispose => { + const { requestPermission, pending } = createNotificationPermission(); + return { requestPermission, pending, dispose }; + }); + + const promise = requestPermission(); + flush(); + expect(pending()).toBe(true); + + resolve("granted"); + await promise; + flush(); + expect(pending()).toBe(false); + + dispose(); + }); +}); diff --git a/packages/notification/test/server.test.ts b/packages/notification/test/server.test.ts new file mode 100644 index 000000000..952fb9361 --- /dev/null +++ b/packages/notification/test/server.test.ts @@ -0,0 +1,42 @@ +import { describe, test, expect } from "vitest"; +import { + isNotificationSupported, + makeNotification, + createNotification, + createNotificationPermission, +} from "../src/index.js"; + +describe("isNotificationSupported (SSR)", () => { + test("returns false on the server", () => { + expect(isNotificationSupported()).toBe(false); + }); +}); + +describe("makeNotification (SSR)", () => { + test("returns no-op functions without throwing", () => { + const [show, close] = makeNotification("Hello", { body: "World" }); + expect(typeof show).toBe("function"); + expect(typeof close).toBe("function"); + expect(show()).toBeNull(); + expect(() => close()).not.toThrow(); + }); +}); + +describe("createNotification (SSR)", () => { + test("returns static defaults without throwing", () => { + const { show, close, notification, supported } = createNotification("Hello"); + expect(supported).toBe(false); + expect(notification()).toBeNull(); + expect(show()).toBeNull(); + expect(() => close()).not.toThrow(); + }); +}); + +describe("createNotificationPermission (SSR)", () => { + test("returns unknown permission, false pending, and resolves without throwing", async () => { + const { permission, requestPermission, pending } = createNotificationPermission(); + expect(permission()).toBe("unknown"); + expect(pending()).toBe(false); + await expect(requestPermission()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/notification/tsconfig.json b/packages/notification/tsconfig.json new file mode 100644 index 000000000..cc6a7fe80 --- /dev/null +++ b/packages/notification/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { + "path": "../permission" + }, + { + "path": "../utils" + } + ], + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/packages/permission/README.md b/packages/permission/README.md index fdc864749..2931b08c7 100644 --- a/packages/permission/README.md +++ b/packages/permission/README.md @@ -8,25 +8,89 @@ [![size](https://img.shields.io/npm/v/@solid-primitives/permission?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/permission) [![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) -Creates a primitive to query user permissions. +Reactive wrapper around the browser [Permissions API](https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API). Queries a named permission and returns a live signal that updates automatically whenever the permission state changes. ## Installation -``` +```bash npm install @solid-primitives/permission # or yarn add @solid-primitives/permission +# or +pnpm add @solid-primitives/permission ``` ## How to use it +### `createPermission` + +Queries a browser permission by name (or descriptor object) and returns a reactive accessor reflecting its current state. + +```ts +import { createPermission } from "@solid-primitives/permission"; + +const permission = createPermission("microphone"); +// permission(): "unknown" | "granted" | "denied" | "prompt" +``` + +The signal starts as `"unknown"` — the Permissions API query is async and the initial value is not available synchronously. After the first microtask, the signal resolves to the current state and begins tracking changes. + +The signal updates automatically when the permission changes — for example when the user grants or revokes access in browser settings, or after an API call prompts the user. + +**Accepted values** follow the [PermissionName](https://developer.mozilla.org/en-US/docs/Web/API/Permissions/query#name) vocabulary. Pass either a plain string or a full `PermissionDescriptor` object: + ```ts -const state: "unknown" | PermissionState = createPermission(descriptor: PermissionDescription | PermissionName); +// Plain name +const mic = createPermission("microphone"); + +// Descriptor object (required for some permissions) +const cam = createPermission({ name: "camera" }); + +// Used by @solid-primitives/notification +const notifs = createPermission("notifications"); ``` -## Demo +**Return values** map to [PermissionState](https://developer.mozilla.org/en-US/docs/Web/API/PermissionStatus/state): + +| Value | Meaning | +|-------|---------| +| `"unknown"` | Initial state — query has not resolved yet | +| `"granted"` | Permission has been granted | +| `"denied"` | Permission has been denied | +| `"prompt"` | Not yet asked; prompting the user is possible | + +### SSR -TODO +On the server, `createPermission` returns a static `() => "unknown"` accessor. No query is made and no listeners are registered. + +### Reactive usage example + +```tsx +import { createPermission } from "@solid-primitives/permission"; + +const CameraGate: Component = () => { + const permission = createPermission("camera"); + + return ( + + +

Checking camera permission…

+
+ + + + +

Camera access denied. Enable it in browser settings.

+
+ + + +
+ ); +}; +``` ## Changelog diff --git a/packages/permission/package.json b/packages/permission/package.json index f2cbe3381..403075bb9 100644 --- a/packages/permission/package.json +++ b/packages/permission/package.json @@ -41,7 +41,8 @@ "test:ssr": "pnpm run vitest --mode ssr" }, "peerDependencies": { - "solid-js": "^1.6.12" + "@solidjs/web": "^2.0.0-beta.14", + "solid-js": "^2.0.0-beta.14" }, "keywords": [ "permission", @@ -51,6 +52,7 @@ ], "typesVersions": {}, "devDependencies": { - "solid-js": "^1.9.7" + "@solidjs/web": "2.0.0-beta.14", + "solid-js": "2.0.0-beta.14" } } diff --git a/packages/permission/src/index.ts b/packages/permission/src/index.ts index 09f50c6a4..989c3086f 100644 --- a/packages/permission/src/index.ts +++ b/packages/permission/src/index.ts @@ -1,5 +1,5 @@ -import { type Accessor, createEffect, createSignal, on, onCleanup } from "solid-js"; -import { isServer } from "solid-js/web"; +import { type Accessor, createEffect, createSignal, onCleanup } from "solid-js"; +import { isServer } from "@solidjs/web"; /** * Querying the permission API @@ -13,13 +13,17 @@ export const createPermission = ( if (isServer) { return () => "unknown"; } - const [permission, setPermission] = createSignal("unknown"); - const [status, setStatus] = createSignal(); + const [permission, setPermission] = createSignal("unknown", { + ownedWrite: true, + }); + const [status, setStatus] = createSignal(undefined, { + ownedWrite: true, + }); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (navigator) { navigator.permissions .query(typeof name === "string" ? { name } : name) - .then(setStatus) + .then(s => setStatus(() => s)) .catch(error => { if (error.name !== "TypeError" || (name !== "microphone" && name !== "camera")) { return; @@ -42,16 +46,23 @@ export const createPermission = ( }) : getUserMedia(constraints); }); + let removeChangeListener: VoidFunction | undefined; + createEffect( - on(status, status => { - if (status) { - setPermission(status.state); - const listener = () => setPermission(status.state); - status.addEventListener("change", listener); - onCleanup(() => status.removeEventListener("change", listener)); + () => status(), + currentStatus => { + removeChangeListener?.(); + removeChangeListener = undefined; + if (currentStatus) { + setPermission(currentStatus.state); + const listener = () => setPermission(currentStatus.state); + currentStatus.addEventListener("change", listener); + removeChangeListener = () => currentStatus.removeEventListener("change", listener); } - }), + }, ); + + onCleanup(() => removeChangeListener?.()); } return permission; }; diff --git a/packages/permission/test/index.test.ts b/packages/permission/test/index.test.ts index ae3a20652..46193510f 100644 --- a/packages/permission/test/index.test.ts +++ b/packages/permission/test/index.test.ts @@ -1,51 +1,40 @@ import { __permissions__ } from "./setup.js"; -import { createEffect, createRoot } from "solid-js"; +import { createRoot, flush } from "solid-js"; import { it, describe, expect } from "vitest"; import { createPermission } from "../src/index.js"; describe("createPermission", () => { it("reads permission", async () => { - let captured: unknown; - - const dispose = createRoot(dispose => { + const { permission, dispose } = createRoot(dispose => { const permission = createPermission("microphone" as PermissionName); - - createEffect(() => { - captured = permission(); - }); - - return dispose; + return { permission, dispose }; }); - expect(captured).toEqual("unknown"); + expect(permission()).toBe("unknown"); await Promise.resolve(); - expect(captured).toEqual("granted"); + flush(); + expect(permission()).toBe("granted"); dispose(); }); it("reads permission updates", async () => { - let captured: unknown; - - const dispose = createRoot(dispose => { + const { permission, dispose } = createRoot(dispose => { const permission = createPermission("camera" as PermissionName); - - createEffect(() => { - captured = permission(); - }); - - return dispose; + return { permission, dispose }; }); - expect(captured).toEqual("unknown"); + expect(permission()).toBe("unknown"); await Promise.resolve(); - expect(captured).toEqual("denied"); + flush(); + expect(permission()).toBe("denied"); __permissions__.camera.__dispatchEvent("granted"); - expect(captured).toEqual("granted"); + flush(); + expect(permission()).toBe("granted"); dispose(); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee1923c5f..70fdcd15d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -692,6 +692,22 @@ importers: specifier: 2.0.0-beta.14 version: 2.0.0-beta.14 + packages/notification: + dependencies: + '@solid-primitives/permission': + specifier: workspace:^ + version: link:../permission + '@solid-primitives/utils': + specifier: workspace:^ + version: link:../utils + devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.14 + version: 2.0.0-beta.14(solid-js@2.0.0-beta.14) + solid-js: + specifier: 2.0.0-beta.14 + version: 2.0.0-beta.14 + packages/orientation: dependencies: '@solid-primitives/utils': @@ -733,9 +749,12 @@ importers: packages/permission: devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.14 + version: 2.0.0-beta.14(solid-js@2.0.0-beta.14) solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.14 + version: 2.0.0-beta.14 packages/platform: devDependencies: