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
+
+[](https://bundlephobia.com/package/@solid-primitives/notification)
+[](https://www.npmjs.com/package/@solid-primitives/notification)
+[](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 @@
[](https://www.npmjs.com/package/@solid-primitives/permission)
[](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.