From 88833b525f19cb2d8bb864a6ab4de5889d5a8689 Mon Sep 17 00:00:00 2001 From: Alex Lohr Date: Tue, 28 Apr 2026 15:44:34 +0200 Subject: [PATCH 1/3] update: permission adapted to Solid-2.0 --- .changeset/sweet-olives-talk.md | 5 ++ packages/permission/package.json | 4 +- packages/permission/src/index.ts | 12 ++-- packages/permission/test/index.test.ts | 18 ++++-- pnpm-lock.yaml | 80 ++++++++++++++++++++------ 5 files changed, 89 insertions(+), 30 deletions(-) create mode 100644 .changeset/sweet-olives-talk.md 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/permission/package.json b/packages/permission/package.json index f2cbe3381..9a12f568e 100644 --- a/packages/permission/package.json +++ b/packages/permission/package.json @@ -41,7 +41,7 @@ "test:ssr": "pnpm run vitest --mode ssr" }, "peerDependencies": { - "solid-js": "^1.6.12" + "solid-js": "^2.0.0" }, "keywords": [ "permission", @@ -51,6 +51,6 @@ ], "typesVersions": {}, "devDependencies": { - "solid-js": "^1.9.7" + "solid-js": "2.0.0-beta.8" } } diff --git a/packages/permission/src/index.ts b/packages/permission/src/index.ts index 09f50c6a4..e798df77f 100644 --- a/packages/permission/src/index.ts +++ b/packages/permission/src/index.ts @@ -1,5 +1,4 @@ -import { type Accessor, createEffect, createSignal, on, onCleanup } from "solid-js"; -import { isServer } from "solid-js/web"; +import { type Accessor, createEffect, createSignal } from "solid-js"; /** * Querying the permission API @@ -10,7 +9,7 @@ import { isServer } from "solid-js/web"; export const createPermission = ( name: PermissionDescriptor | PermissionName | "microphone" | "camera", ): Accessor => { - if (isServer) { + if (globalThis.window !== globalThis) { return () => "unknown"; } const [permission, setPermission] = createSignal("unknown"); @@ -43,14 +42,15 @@ export const createPermission = ( : getUserMedia(constraints); }); createEffect( - on(status, status => { + status, + (status?: PermissionStatus) => { if (status) { setPermission(status.state); const listener = () => setPermission(status.state); status.addEventListener("change", listener); - onCleanup(() => status.removeEventListener("change", listener)); + return () => status.removeEventListener("change", listener); } - }), + } ); } return permission; diff --git a/packages/permission/test/index.test.ts b/packages/permission/test/index.test.ts index ae3a20652..033afec7d 100644 --- a/packages/permission/test/index.test.ts +++ b/packages/permission/test/index.test.ts @@ -1,6 +1,6 @@ import { __permissions__ } from "./setup.js"; -import { createEffect, createRoot } from "solid-js"; +import { createEffect, createRoot, flush } from "solid-js"; import { it, describe, expect } from "vitest"; import { createPermission } from "../src/index.js"; @@ -11,16 +11,20 @@ describe("createPermission", () => { const dispose = createRoot(dispose => { const permission = createPermission("microphone" as PermissionName); - createEffect(() => { - captured = permission(); + createEffect(permission, (state) => { + captured = state; }); return dispose; }); + flush(); + expect(captured).toEqual("unknown"); await Promise.resolve(); + flush(); + expect(captured).toEqual("granted"); dispose(); @@ -32,19 +36,23 @@ describe("createPermission", () => { const dispose = createRoot(dispose => { const permission = createPermission("camera" as PermissionName); - createEffect(() => { - captured = permission(); + createEffect(permission, (state) => { + captured = state; }); return dispose; }); + flush(); + expect(captured).toEqual("unknown"); await Promise.resolve(); + flush(); expect(captured).toEqual("denied"); __permissions__.camera.__dispatchEvent("granted"); + flush(); expect(captured).toEqual("granted"); dispose(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecadfdb95..d038490bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -664,8 +664,8 @@ importers: packages/permission: devDependencies: solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.8 + version: 2.0.0-beta.8 packages/platform: devDependencies: @@ -1048,10 +1048,10 @@ importers: version: link:../packages/utils '@solidjs/meta': specifier: ^0.29.3 - version: 0.29.4(solid-js@1.9.7) + version: 0.29.4(solid-js@2.0.0-experimental.16) '@solidjs/router': specifier: ^0.13.1 - version: 0.13.6(solid-js@1.9.7) + version: 0.13.6(solid-js@2.0.0-experimental.16) clsx: specifier: ^2.0.0 version: 2.1.1 @@ -1078,13 +1078,13 @@ importers: version: 1.77.8 solid-dismiss: specifier: ^1.7.121 - version: 1.8.2(solid-js@1.9.7) + version: 1.8.2(solid-js@2.0.0-experimental.16) solid-icons: specifier: ^1.1.0 - version: 1.1.0(solid-js@1.9.7) + version: 1.1.0(solid-js@2.0.0-experimental.16) solid-tippy: specifier: ^0.2.1 - version: 0.2.1(solid-js@1.9.7)(tippy.js@6.3.7) + version: 0.2.1(solid-js@2.0.0-experimental.16)(tippy.js@6.3.7) tippy.js: specifier: ^6.3.7 version: 6.3.7 @@ -2587,6 +2587,12 @@ packages: peerDependencies: solid-js: ^1.5.3 + '@solidjs/signals@0.11.3': + resolution: {integrity: sha512-udMfutYPOlcxKUmc5+n1QtarsxOiAlC6LJY2TqFyaMwdXgo+reiYUcYGDlOiAPXfCLE0lavZHQ/6GT5pJbXKBA==} + + '@solidjs/signals@2.0.0-beta.8': + resolution: {integrity: sha512-4voN4js6a8miqWcOgo1wIWdGzHFqLraYaURJqHXGL4zHqTNDRRs1A3cYqhVanm3CoOCGXwyrR6Uw9zG6tzhobA==} + '@solidjs/start@1.1.4': resolution: {integrity: sha512-ma1TBYqoTju87tkqrHExMReM5Z/+DTXSmi30CCTavtwuR73Bsn4rVGqm528p4sL2koRMfAuBMkrhuttjzhL68g==} peerDependencies: @@ -5892,10 +5898,20 @@ packages: peerDependencies: seroval: ^1.0 + seroval-plugins@1.5.2: + resolution: {integrity: sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + seroval@1.3.2: resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} engines: {node: '>=10'} + seroval@1.5.2: + resolution: {integrity: sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==} + engines: {node: '>=10'} + serve-placeholder@2.0.2: resolution: {integrity: sha512-/TMG8SboeiQbZJWRlfTCqMs2DD3SZgWp0kDQePz9yUuCnDfDh/92gf7/PxGhzXTKBIPASIHxFcZndoNbp6QOLQ==} @@ -6001,6 +6017,12 @@ packages: solid-js@1.9.7: resolution: {integrity: sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw==} + solid-js@2.0.0-beta.8: + resolution: {integrity: sha512-xaoGUlv4+ob4OrWTada3eibEPKxtPFr4eNVwr5fiMJYNZmyW6Qm9KChYfhUFTDp+qe4QXIz9abLRCnIzjWR/2Q==} + + solid-js@2.0.0-experimental.16: + resolution: {integrity: sha512-zZ1dU7cR0EnvLnrYiRLQbCFiDw5blLdlqmofgLzKUYE1TCMWDcisBlSwz0Ez8l4yXB4adbdhtaYCuynH4xSq9A==} + solid-refresh@0.6.3: resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} peerDependencies: @@ -8576,18 +8598,22 @@ snapshots: dependencies: solid-js: 1.9.7 - '@solidjs/meta@0.29.4(solid-js@1.9.7)': + '@solidjs/meta@0.29.4(solid-js@2.0.0-experimental.16)': dependencies: - solid-js: 1.9.7 + solid-js: 2.0.0-experimental.16 - '@solidjs/router@0.13.6(solid-js@1.9.7)': + '@solidjs/router@0.13.6(solid-js@2.0.0-experimental.16)': dependencies: - solid-js: 1.9.7 + solid-js: 2.0.0-experimental.16 '@solidjs/router@0.8.4(solid-js@1.9.7)': dependencies: solid-js: 1.9.7 + '@solidjs/signals@0.11.3': {} + + '@solidjs/signals@2.0.0-beta.8': {} + '@solidjs/start@1.1.4(solid-js@1.9.7)(vinxi@0.5.7(@types/node@22.15.31)(db0@0.3.2)(ioredis@5.6.1)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0))(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0))': dependencies: '@tanstack/server-functions-plugin': 1.121.0(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0)) @@ -12441,8 +12467,14 @@ snapshots: dependencies: seroval: 1.3.2 + seroval-plugins@1.5.2(seroval@1.5.2): + dependencies: + seroval: 1.5.2 + seroval@1.3.2: {} + seroval@1.5.2: {} + serve-placeholder@2.0.2: dependencies: defu: 6.1.4 @@ -12557,13 +12589,13 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 - solid-dismiss@1.8.2(solid-js@1.9.7): + solid-dismiss@1.8.2(solid-js@2.0.0-experimental.16): dependencies: - solid-js: 1.9.7 + solid-js: 2.0.0-experimental.16 - solid-icons@1.1.0(solid-js@1.9.7): + solid-icons@1.1.0(solid-js@2.0.0-experimental.16): dependencies: - solid-js: 1.9.7 + solid-js: 2.0.0-experimental.16 solid-js@1.9.7: dependencies: @@ -12571,6 +12603,20 @@ snapshots: seroval: 1.3.2 seroval-plugins: 1.3.2(seroval@1.3.2) + solid-js@2.0.0-beta.8: + dependencies: + '@solidjs/signals': 2.0.0-beta.8 + csstype: 3.1.3 + seroval: 1.5.2 + seroval-plugins: 1.5.2(seroval@1.5.2) + + solid-js@2.0.0-experimental.16: + dependencies: + '@solidjs/signals': 0.11.3 + csstype: 3.1.3 + seroval: 1.5.2 + seroval-plugins: 1.5.2(seroval@1.5.2) + solid-refresh@0.6.3(solid-js@1.9.7): dependencies: '@babel/generator': 7.27.5 @@ -12580,9 +12626,9 @@ snapshots: transitivePeerDependencies: - supports-color - solid-tippy@0.2.1(solid-js@1.9.7)(tippy.js@6.3.7): + solid-tippy@0.2.1(solid-js@2.0.0-experimental.16)(tippy.js@6.3.7): dependencies: - solid-js: 1.9.7 + solid-js: 2.0.0-experimental.16 tippy.js: 6.3.7 solid-transition-group@0.2.3(solid-js@1.9.7): From cf498eea98d9365bc2af2984bf62d9f089a14310 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Mon, 11 May 2026 10:14:01 -0400 Subject: [PATCH 2/3] Initial commit --- .changeset/notification-initial.md | 14 + packages/notification/LICENSE | 21 ++ packages/notification/README.md | 159 +++++++++ packages/notification/dev/index.tsx | 62 ++++ packages/notification/package.json | 69 ++++ packages/notification/src/index.ts | 189 +++++++++++ packages/notification/test/index.test.ts | 374 ++++++++++++++++++++++ packages/notification/test/server.test.ts | 42 +++ packages/notification/tsconfig.json | 16 + pnpm-lock.yaml | 13 + 10 files changed, 959 insertions(+) create mode 100644 .changeset/notification-initial.md create mode 100644 packages/notification/LICENSE create mode 100644 packages/notification/README.md create mode 100644 packages/notification/dev/index.tsx create mode 100644 packages/notification/package.json create mode 100644 packages/notification/src/index.ts create mode 100644 packages/notification/test/index.test.ts create mode 100644 packages/notification/test/server.test.ts create mode 100644 packages/notification/tsconfig.json diff --git a/.changeset/notification-initial.md b/.changeset/notification-initial.md new file mode 100644 index 000000000..d3d6c1eda --- /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 `"denied"` on the server. + +Peer dependencies: `solid-js@^2.0.0-beta.10` and `@solidjs/web@^2.0.0-beta.10`. 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..cc7e207b9 --- /dev/null +++ b/packages/notification/README.md @@ -0,0 +1,159 @@ +

+ 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 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. + +```ts +import { makeNotification } from "@solid-primitives/notification"; + +const [show, close] = makeNotification("New message", { body: "Hello!" }); + +button.addEventListener("click", () => show()); + +// Close programmatically at any time +close(); +``` + +--- + +### `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. + +```ts +import { createNotification } from "@solid-primitives/notification"; + +const { show, close, notification, supported } = createNotification( + () => `You have ${unread()} messages`, + { icon: "/icon.png" }, +); + +// 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. The `permission` accessor updates after each `requestPermission()` call. + +On the server or when the API is unavailable, `permission` always returns `"denied"` and `requestPermission` resolves immediately to `"denied"`. + +```ts +import { createNotificationPermission } from "@solid-primitives/notification"; + +const { permission, requestPermission } = createNotificationPermission(); + +// Gate UI on permission state + + + + +// Await the result +const result = await requestPermission(); +// result: "granted" | "denied" | "default" +``` + +--- + +### Full example + +```tsx +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!", + }); + + return ( + Not supported

}> +

Permission: {permission()}

+

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

+ + + + + +
+ ); +}; +``` + +## Types + +```ts +// Standard DOM type re-exported for convenience +type NotificationPermission = "granted" | "denied" | "default"; +``` + +## 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. + +## 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..b21590c41 --- /dev/null +++ b/packages/notification/package.json @@ -0,0 +1,69 @@ +{ + "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.10", + "solid-js": "^2.0.0-beta.10" + }, + "dependencies": { + "@solid-primitives/utils": "workspace:^" + }, + "devDependencies": { + "@solidjs/web": "2.0.0-beta.10", + "solid-js": "2.0.0-beta.10" + } +} diff --git a/packages/notification/src/index.ts b/packages/notification/src/index.ts new file mode 100644 index 000000000..b5a6afcae --- /dev/null +++ b/packages/notification/src/index.ts @@ -0,0 +1,189 @@ +import { createSignal, onCleanup, type Accessor } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { INTERNAL_OPTIONS, noop, access, type MaybeAccessor } from "@solid-primitives/utils"; + +/** + * 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") 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]; +} + +/** + * 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. + * @returns `{ show, close, notification, supported }` + * + * @example + * ```ts + * const { show, close, notification } = createNotification( + * () => `You have ${unread()} messages`, + * { icon: "/icon.png" }, + * ); + * + * createEffect(() => { + * if (notification()) console.log("notification is visible"); + * }); + * ``` + */ +export function createNotification( + title: MaybeAccessor, + options?: MaybeAccessor, +): { + 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 closeHandler: VoidFunction | undefined; + + const close: VoidFunction = () => { + if (current && closeHandler) { + current.removeEventListener("close", closeHandler); + closeHandler = undefined; + } + current?.close(); + current = null; + setNotification(null); + }; + + const show = (): Notification | null => { + if (Notification.permission !== "granted") return null; + close(); + const n = new Notification(access(title), access(options)); + current = n; + closeHandler = () => { + if (current === n) { + current = null; + closeHandler = undefined; + setNotification(null); + } + }; + n.addEventListener("close", closeHandler); + setNotification(n); + return n; + }; + + onCleanup(close); + + return { show, close, notification, supported }; +} + +/** + * Reactive notification permission manager. + * + * The `permission` accessor reflects the current `Notification.permission` + * value and updates after each `requestPermission()` call. Use this to + * reactively gate UI controls or notification logic on permission state. + * + * On the server or when the Notifications API is unavailable, `permission` + * always returns `"denied"` and `requestPermission` resolves to `"denied"`. + * + * @returns `{ permission, requestPermission }` + * + * @example + * ```ts + * const { permission, requestPermission } = createNotificationPermission(); + * + * createEffect(() => { + * if (permission() === "granted") showWelcomeNotification(); + * }); + * + * + * ``` + */ +export function createNotificationPermission(): { + permission: Accessor; + requestPermission: () => Promise; +} { + if (!isNotificationSupported()) { + return { + permission: () => "denied" as NotificationPermission, + requestPermission: () => Promise.resolve("denied" as NotificationPermission), + }; + } + + const [permission, setPermission] = createSignal( + Notification.permission, + INTERNAL_OPTIONS, + ); + + const requestPermission = async (): Promise => { + const result = await Notification.requestPermission(); + setPermission(result); + return result; + }; + + return { permission, requestPermission }; +} diff --git a/packages/notification/test/index.test.ts b/packages/notification/test/index.test.ts new file mode 100644 index 000000000..509bba290 --- /dev/null +++ b/packages/notification/test/index.test.ts @@ -0,0 +1,374 @@ +import { describe, test, expect, vi, beforeAll, afterAll, beforeEach } from "vitest"; +import { createRoot, createSignal, flush } 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), + ); + }); + + /** Test helper: simulate the OS dismissing the notification externally. */ + simulateClose() { + this.listeners.get("close")?.forEach(fn => fn()); + } +} + +beforeAll(() => { + Object.defineProperty(window, "Notification", { + value: MockNotification, + configurable: true, + writable: true, + }); +}); + +afterAll(() => { + Object.defineProperty(window, "Notification", { + value: undefined, + configurable: true, + }); +}); + +beforeEach(() => { + MockNotification.instances = []; + MockNotification.permission = "granted"; + MockNotification.requestPermission.mockClear().mockResolvedValue("granted"); +}); + +// ── 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"); + const n = show(); + expect(n).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("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(); // OS dismissed it + instance.close.mockClear(); + close(); // should be a no-op — reference already cleared + expect(instance.close).not.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)); + }); +}); + +// ── 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 the Notification instance", () => { + const { show, dispose } = createRoot(dispose => { + const { show } = createNotification("Hello"); + return { show, dispose }; + }); + + const n = show(); + expect(n).toBeInstanceOf(MockNotification); + + dispose(); + }); + + test("show returns null and does not update signal when permission is denied", () => { + MockNotification.permission = "denied"; + + const { show, notification, dispose } = createRoot(dispose => { + const { show, notification } = createNotification("Hello"); + return { show, notification, dispose }; + }); + + const result = show(); + flush(); + expect(result).toBeNull(); + expect(notification()).toBeNull(); + + 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(); + expect(notification()).not.toBeNull(); + + close(); + flush(); + expect(notification()).toBeNull(); + expect(MockNotification.instances[0]!.close).toHaveBeenCalled(); + + 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(); + expect(notification()).not.toBeNull(); + + 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(); + const second = notification(); + + expect(first).not.toBe(second); + expect((first as MockNotification).close).toHaveBeenCalled(); + expect(MockNotification.instances).toHaveLength(2); + + 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("close removes the event listener before closing", () => { + const { show, close, dispose } = createRoot(dispose => { + const { show, close } = createNotification("Hello"); + return { show, close, dispose }; + }); + + show(); + flush(); + const instance = MockNotification.instances[0]!; + close(); + expect(instance.removeEventListener).toHaveBeenCalledWith("close", expect.any(Function)); + + dispose(); + }); + + 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(); + // title signal changed but notification is still showing "First" — re-show not automatic + expect((notification() as MockNotification).title).toBe("First"); + + // calling show() again reads the updated title + show(); + flush(); + expect((notification() as MockNotification).title).toBe("Second"); + + dispose(); + }); +}); + +// ── createNotificationPermission ────────────────────────────────────────────── + +describe("createNotificationPermission", () => { + test("permission reflects Notification.permission on creation", () => { + MockNotification.permission = "default"; + + createRoot(dispose => { + const { permission } = createNotificationPermission(); + expect(permission()).toBe("default"); + 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 updates after requestPermission resolves to granted", async () => { + MockNotification.permission = "default"; + MockNotification.requestPermission.mockResolvedValue("granted"); + + const { permission, requestPermission, dispose } = createRoot(dispose => { + const { permission, requestPermission } = createNotificationPermission(); + return { permission, requestPermission, dispose }; + }); + + expect(permission()).toBe("default"); + await requestPermission(); + flush(); + expect(permission()).toBe("granted"); + + dispose(); + }); + + test("permission updates after requestPermission resolves to denied", async () => { + MockNotification.permission = "default"; + MockNotification.requestPermission.mockResolvedValue("denied"); + + const { permission, requestPermission, dispose } = createRoot(dispose => { + const { permission, requestPermission } = createNotificationPermission(); + return { permission, requestPermission, dispose }; + }); + + await requestPermission(); + flush(); + expect(permission()).toBe("denied"); + + dispose(); + }); + + test("requestPermission returns the resolved permission value", async () => { + MockNotification.requestPermission.mockResolvedValue("granted"); + + const { requestPermission, dispose } = createRoot(dispose => { + const { requestPermission } = createNotificationPermission(); + return { requestPermission, dispose }; + }); + + const result = await requestPermission(); + expect(result).toBe("granted"); + + dispose(); + }); +}); diff --git a/packages/notification/test/server.test.ts b/packages/notification/test/server.test.ts new file mode 100644 index 000000000..a5b909991 --- /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 denied permission without throwing", async () => { + const { permission, requestPermission } = createNotificationPermission(); + expect(permission()).toBe("denied"); + const result = await requestPermission(); + expect(result).toBe("denied"); + }); +}); diff --git a/packages/notification/tsconfig.json b/packages/notification/tsconfig.json new file mode 100644 index 000000000..dc1970e16 --- /dev/null +++ b/packages/notification/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { + "path": "../utils" + } + ], + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70787571b..af00d92b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -644,6 +644,19 @@ importers: specifier: ^1.9.7 version: 1.9.7 + packages/notification: + dependencies: + '@solid-primitives/utils': + specifier: workspace:^ + version: link:../utils + devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10(solid-js@2.0.0-beta.10) + solid-js: + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10 + packages/orientation: dependencies: '@solid-primitives/utils': From ef5857a6d19b731fb9354068e76e05bdc64168fd Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Mon, 11 May 2026 10:42:12 -0400 Subject: [PATCH 3/3] new: notification primitive + permission beta.10 migration - Add @solid-primitives/notification package (stage 0) with isNotificationSupported, makeNotification, createNotification, createNotificationPermission - Migrate @solid-primitives/permission to Solid 2.0 beta.10: isServer from @solidjs/web, ownedWrite on signals, split createEffect with closure-based cleanup, onCleanup at function scope Co-Authored-By: Claude Sonnet 4.6 --- packages/notification/README.md | 49 +++- packages/notification/package.json | 1 + packages/notification/src/index.ts | 113 ++++++--- packages/notification/test/index.test.ts | 290 ++++++++++++++++------ packages/notification/test/server.test.ts | 4 +- packages/notification/tsconfig.json | 3 + packages/permission/README.md | 74 +++++- packages/permission/package.json | 6 +- packages/permission/src/index.ts | 35 ++- packages/permission/test/index.test.ts | 37 +-- pnpm-lock.yaml | 10 +- 11 files changed, 445 insertions(+), 177 deletions(-) diff --git a/packages/notification/README.md b/packages/notification/README.md index cc7e207b9..d8ef38d4d 100644 --- a/packages/notification/README.md +++ b/packages/notification/README.md @@ -13,7 +13,7 @@ Primitives for the browser [Notifications API](https://developer.mozilla.org/en- - **`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 signal and a `requestPermission` function. +- **`createNotificationPermission`** — Reactive permission manager that exposes a live permission signal and a `requestPermission` function. ## Installation @@ -47,17 +47,25 @@ Non-reactive helper with no Solid lifecycle dependency. Both returned functions `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()); -// Close programmatically at any time +// Or close programmatically at any time close(); ``` +Outside a reactive scope (e.g. in plain event handlers), call `close()` directly when done. + --- ### `createNotification` @@ -67,6 +75,7 @@ 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 { createNotification } from "@solid-primitives/notification"; @@ -74,6 +83,11 @@ 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) @@ -93,9 +107,13 @@ close(); ### `createNotificationPermission` -Reactive permission manager. The `permission` accessor updates after each `requestPermission()` call. +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. -On the server or when the API is unavailable, `permission` always returns `"denied"` and `requestPermission` resolves immediately to `"denied"`. +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 to `"denied"`. ```ts import { createNotificationPermission } from "@solid-primitives/notification"; @@ -107,7 +125,7 @@ const { permission, requestPermission } = createNotificationPermission(); -// Await the result +// Await the result (returns the raw NotificationPermission value) const result = await requestPermission(); // result: "granted" | "denied" | "default" ``` @@ -125,9 +143,11 @@ import { const NotificationDemo: Component = () => { const { permission, requestPermission } = createNotificationPermission(); - const { show, close, notification } = createNotification("Solid Primitives", { - body: "Hello from SolidJS!", - }); + const { show, close, notification } = createNotification( + "Solid Primitives", + { body: "Hello from SolidJS!" }, + { onClick: () => window.focus() }, + ); return ( Not supported

}> @@ -146,13 +166,20 @@ const NotificationDemo: Component = () => { ## Types ```ts -// Standard DOM type re-exported for convenience -type NotificationPermission = "granted" | "denied" | "default"; +/** 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. +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 diff --git a/packages/notification/package.json b/packages/notification/package.json index b21590c41..a8f18e92e 100644 --- a/packages/notification/package.json +++ b/packages/notification/package.json @@ -60,6 +60,7 @@ "solid-js": "^2.0.0-beta.10" }, "dependencies": { + "@solid-primitives/permission": "workspace:^", "@solid-primitives/utils": "workspace:^" }, "devDependencies": { diff --git a/packages/notification/src/index.ts b/packages/notification/src/index.ts index b5a6afcae..d6fa2df68 100644 --- a/packages/notification/src/index.ts +++ b/packages/notification/src/index.ts @@ -1,6 +1,7 @@ import { createSignal, onCleanup, type Accessor } from "solid-js"; import { isServer } from "@solidjs/web"; -import { INTERNAL_OPTIONS, noop, access, type MaybeAccessor } from "@solid-primitives/utils"; +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) @@ -45,7 +46,13 @@ export function makeNotification( }; const show = (): Notification | null => { - if (Notification.permission !== "granted") return 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; @@ -62,6 +69,16 @@ export function makeNotification( 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. * @@ -75,6 +92,7 @@ export function makeNotification( * * @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 @@ -82,16 +100,14 @@ export function makeNotification( * const { show, close, notification } = createNotification( * () => `You have ${unread()} messages`, * { icon: "/icon.png" }, + * { onClick: () => window.focus() }, * ); - * - * createEffect(() => { - * if (notification()) console.log("notification is visible"); - * }); * ``` */ export function createNotification( title: MaybeAccessor, options?: MaybeAccessor, + handlers?: NotificationEventHandlers, ): { show: () => Notification | null; close: VoidFunction; @@ -106,31 +122,56 @@ export function createNotification( const [notification, setNotification] = createSignal(null, INTERNAL_OPTIONS); let current: Notification | null = null; - let closeHandler: VoidFunction | undefined; + let currentCleanup: VoidFunction | undefined; const close: VoidFunction = () => { - if (current && closeHandler) { - current.removeEventListener("close", closeHandler); - closeHandler = undefined; - } - current?.close(); + 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") return 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; - closeHandler = () => { + + const onCloseEvent = () => { if (current === n) { + currentCleanup?.(); + currentCleanup = undefined; current = null; - closeHandler = undefined; setNotification(null); + handlers?.onClose?.(n); } }; - n.addEventListener("close", closeHandler); + + 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; }; @@ -141,14 +182,17 @@ export function createNotification( } /** - * Reactive notification permission manager. + * Reactive notification permission manager built on `createPermission`. * - * The `permission` accessor reflects the current `Notification.permission` - * value and updates after each `requestPermission()` call. Use this to - * reactively gate UI controls or notification logic on permission state. + * 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. * - * On the server or when the Notifications API is unavailable, `permission` - * always returns `"denied"` and `requestPermission` resolves to `"denied"`. + * 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 }` * @@ -156,34 +200,27 @@ export function createNotification( * ```ts * const { permission, requestPermission } = createNotificationPermission(); * - * createEffect(() => { - * if (permission() === "granted") showWelcomeNotification(); - * }); - * - * + * + * + * * ``` */ export function createNotificationPermission(): { - permission: Accessor; + permission: Accessor; requestPermission: () => Promise; } { if (!isNotificationSupported()) { return { - permission: () => "denied" as NotificationPermission, + permission: () => "unknown" as const, requestPermission: () => Promise.resolve("denied" as NotificationPermission), }; } - const [permission, setPermission] = createSignal( - Notification.permission, - INTERNAL_OPTIONS, - ); + const permission = createPermission("notifications"); - const requestPermission = async (): Promise => { - const result = await Notification.requestPermission(); - setPermission(result); - return result; - }; + // createPermission tracks state via the change event — no manual update needed + const requestPermission = async (): Promise => + Notification.requestPermission(); return { permission, requestPermission }; } diff --git a/packages/notification/test/index.test.ts b/packages/notification/test/index.test.ts index 509bba290..e53a88d9b 100644 --- a/packages/notification/test/index.test.ts +++ b/packages/notification/test/index.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, vi, beforeAll, afterAll, beforeEach } from "vitest"; -import { createRoot, createSignal, flush } from "solid-js"; +import { createRoot, createSignal, flush, onCleanup } from "solid-js"; import { isNotificationSupported, makeNotification, @@ -40,31 +40,63 @@ class MockNotification { ); }); - /** Test helper: simulate the OS dismissing the notification externally. */ 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, - }); + 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 ─────────────────────────────────────────────────── @@ -87,8 +119,7 @@ describe("makeNotification", () => { test("show returns the Notification instance", () => { const [show] = makeNotification("Hello"); - const n = show(); - expect(n).toBeInstanceOf(MockNotification); + expect(show()).toBeInstanceOf(MockNotification); }); test("show returns null when permission is not granted", () => { @@ -106,6 +137,14 @@ describe("makeNotification", () => { 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(); @@ -119,18 +158,25 @@ describe("makeNotification", () => { const [show, close] = makeNotification("Hello"); show(); const instance = MockNotification.instances[0]!; - instance.simulateClose(); // OS dismissed it + instance.simulateClose(); instance.close.mockClear(); - close(); // should be a no-op — reference already cleared + close(); expect(instance.close).not.toHaveBeenCalled(); }); - test("close removes the event listener before closing", () => { - const [show, close] = makeNotification("Hello"); - show(); + 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]!; - close(); - expect(instance.removeEventListener).toHaveBeenCalledWith("close", expect.any(Function)); + instance.close.mockClear(); + + dispose(); + expect(instance.close).toHaveBeenCalled(); }); }); @@ -160,31 +206,21 @@ describe("createNotification", () => { dispose(); }); - test("show returns the Notification instance", () => { - const { show, dispose } = createRoot(dispose => { - const { show } = createNotification("Hello"); - return { show, dispose }; - }); - - const n = show(); - expect(n).toBeInstanceOf(MockNotification); - - dispose(); - }); - - test("show returns null and does not update signal when permission is denied", () => { + 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 }; }); - const result = show(); + expect(show()).toBeNull(); flush(); - expect(result).toBeNull(); expect(notification()).toBeNull(); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); dispose(); }); @@ -196,7 +232,6 @@ describe("createNotification", () => { show(); flush(); - expect(notification()).not.toBeNull(); close(); flush(); @@ -206,6 +241,23 @@ describe("createNotification", () => { 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"); @@ -214,7 +266,6 @@ describe("createNotification", () => { show(); flush(); - expect(notification()).not.toBeNull(); MockNotification.instances[0]!.simulateClose(); flush(); @@ -235,11 +286,8 @@ describe("createNotification", () => { show(); flush(); - const second = notification(); - - expect(first).not.toBe(second); + expect(notification()).not.toBe(first); expect((first as MockNotification).close).toHaveBeenCalled(); - expect(MockNotification.instances).toHaveLength(2); dispose(); }); @@ -259,42 +307,123 @@ describe("createNotification", () => { expect(instance.close).toHaveBeenCalled(); }); - test("close removes the event listener before closing", () => { + 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"); + const { show, close } = createNotification("Hello", undefined, { onClose }); return { show, close, dispose }; }); show(); flush(); - const instance = MockNotification.instances[0]!; close(); - expect(instance.removeEventListener).toHaveBeenCalledWith("close", expect.any(Function)); + + expect(onClose).toHaveBeenCalledOnce(); dispose(); }); - test("reactive title: reads current accessor value at show() time", () => { - const [title, setTitle] = createSignal("First"); + test("onError fires when error event is dispatched", () => { + const onError = vi.fn(); - const { show, notification, dispose } = createRoot(dispose => { - const { show, notification } = createNotification(title); - return { show, notification, dispose }; + const { show, dispose } = createRoot(dispose => { + const { show } = createNotification("Hello", undefined, { onError }); + return { show, dispose }; }); show(); flush(); - expect((notification() as MockNotification).title).toBe("First"); + MockNotification.instances[0]!.simulateError(); - setTitle("Second"); - flush(); - // title signal changed but notification is still showing "First" — re-show not automatic - expect((notification() as MockNotification).title).toBe("First"); + 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 }; + }); - // calling show() again reads the updated title show(); flush(); - expect((notification() as MockNotification).title).toBe("Second"); + 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(); }); @@ -303,57 +432,57 @@ describe("createNotification", () => { // ── createNotificationPermission ────────────────────────────────────────────── describe("createNotificationPermission", () => { - test("permission reflects Notification.permission on creation", () => { - MockNotification.permission = "default"; - + test("permission starts as unknown before query resolves", () => { createRoot(dispose => { const { permission } = createNotificationPermission(); - expect(permission()).toBe("default"); + expect(permission()).toBe("unknown"); dispose(); }); }); - test("requestPermission calls Notification.requestPermission", async () => { - const { requestPermission, dispose } = createRoot(dispose => { - const { requestPermission } = createNotificationPermission(); - return { requestPermission, dispose }; + test("permission resolves to current state after query", async () => { + mockPermStatus.state = "granted"; + + const { permission, dispose } = createRoot(dispose => { + const { permission } = createNotificationPermission(); + return { permission, dispose }; }); - await requestPermission(); - expect(MockNotification.requestPermission).toHaveBeenCalledOnce(); + expect(permission()).toBe("unknown"); + await Promise.resolve(); + flush(); + expect(permission()).toBe("granted"); dispose(); }); - test("permission updates after requestPermission resolves to granted", async () => { - MockNotification.permission = "default"; - MockNotification.requestPermission.mockResolvedValue("granted"); + test("permission updates reactively when state changes externally", async () => { + mockPermStatus.state = "granted"; - const { permission, requestPermission, dispose } = createRoot(dispose => { - const { permission, requestPermission } = createNotificationPermission(); - return { permission, requestPermission, dispose }; + const { permission, dispose } = createRoot(dispose => { + const { permission } = createNotificationPermission(); + return { permission, dispose }; }); - expect(permission()).toBe("default"); - await requestPermission(); + await Promise.resolve(); flush(); expect(permission()).toBe("granted"); + mockPermStatus.dispatchChange("denied"); + flush(); + expect(permission()).toBe("denied"); + dispose(); }); - test("permission updates after requestPermission resolves to denied", async () => { - MockNotification.permission = "default"; - MockNotification.requestPermission.mockResolvedValue("denied"); - - const { permission, requestPermission, dispose } = createRoot(dispose => { - const { permission, requestPermission } = createNotificationPermission(); - return { permission, requestPermission, dispose }; + test("requestPermission calls Notification.requestPermission", async () => { + const { requestPermission, dispose } = createRoot(dispose => { + const { requestPermission } = createNotificationPermission(); + return { requestPermission, dispose }; }); await requestPermission(); - flush(); - expect(permission()).toBe("denied"); + expect(MockNotification.requestPermission).toHaveBeenCalledOnce(); dispose(); }); @@ -366,8 +495,7 @@ describe("createNotificationPermission", () => { return { requestPermission, dispose }; }); - const result = await requestPermission(); - expect(result).toBe("granted"); + expect(await requestPermission()).toBe("granted"); dispose(); }); diff --git a/packages/notification/test/server.test.ts b/packages/notification/test/server.test.ts index a5b909991..e19f755c0 100644 --- a/packages/notification/test/server.test.ts +++ b/packages/notification/test/server.test.ts @@ -33,9 +33,9 @@ describe("createNotification (SSR)", () => { }); describe("createNotificationPermission (SSR)", () => { - test("returns denied permission without throwing", async () => { + test("returns unknown permission and resolves denied without throwing", async () => { const { permission, requestPermission } = createNotificationPermission(); - expect(permission()).toBe("denied"); + expect(permission()).toBe("unknown"); const result = await requestPermission(); expect(result).toBe("denied"); }); diff --git a/packages/notification/tsconfig.json b/packages/notification/tsconfig.json index dc1970e16..cc6a7fe80 100644 --- a/packages/notification/tsconfig.json +++ b/packages/notification/tsconfig.json @@ -6,6 +6,9 @@ "rootDir": "src" }, "references": [ + { + "path": "../permission" + }, { "path": "../utils" } 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..96c119a30 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.10", + "solid-js": "^2.0.0-beta.10" }, "keywords": [ "permission", @@ -51,6 +52,7 @@ ], "typesVersions": {}, "devDependencies": { - "solid-js": "^1.9.7" + "@solidjs/web": "2.0.0-beta.10", + "solid-js": "2.0.0-beta.10" } } 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 af00d92b9..0a1208dca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -646,6 +646,9 @@ importers: packages/notification: dependencies: + '@solid-primitives/permission': + specifier: workspace:^ + version: link:../permission '@solid-primitives/utils': specifier: workspace:^ version: link:../utils @@ -698,9 +701,12 @@ importers: packages/permission: devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10(solid-js@2.0.0-beta.10) solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10 packages/platform: devDependencies: