From 237242500648bdc9db79d0e828db506d543a1550 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Fri, 8 May 2026 18:03:46 -0400 Subject: [PATCH 1/2] Initial commit of new orientation primitive --- .changeset/orientation-initial.md | 12 ++ packages/orientation/LICENSE | 21 +++ packages/orientation/README.md | 84 ++++++++++ packages/orientation/dev/index.tsx | 23 +++ packages/orientation/package.json | 67 ++++++++ packages/orientation/src/index.ts | 118 ++++++++++++++ packages/orientation/test/index.test.ts | 191 +++++++++++++++++++++++ packages/orientation/test/server.test.ts | 28 ++++ packages/orientation/tsconfig.json | 16 ++ pnpm-lock.yaml | 16 +- 10 files changed, 573 insertions(+), 3 deletions(-) create mode 100644 .changeset/orientation-initial.md create mode 100644 packages/orientation/LICENSE create mode 100644 packages/orientation/README.md create mode 100644 packages/orientation/dev/index.tsx create mode 100644 packages/orientation/package.json create mode 100644 packages/orientation/src/index.ts create mode 100644 packages/orientation/test/index.test.ts create mode 100644 packages/orientation/test/server.test.ts create mode 100644 packages/orientation/tsconfig.json diff --git a/.changeset/orientation-initial.md b/.changeset/orientation-initial.md new file mode 100644 index 000000000..f0b543b81 --- /dev/null +++ b/.changeset/orientation-initial.md @@ -0,0 +1,12 @@ +--- +"@solid-primitives/orientation": minor +--- + +Add `@solid-primitives/orientation` package (Stage 0) + +New primitives for tracking screen orientation via the Screen Orientation API. + +- **`makeOrientation(onChange)`** — Non-reactive base primitive. Attaches a listener for `screen.orientation` `change` events (or the legacy `orientationchange` event as fallback) and returns a cleanup function. Does not fire on mount. +- **`createOrientation()`** — Reactive primitive returning `angle` and `type` signal accessors, initialized to the current orientation and updated on every change. SSR-safe: returns static defaults (`angle: 0`, `type: "portrait-primary"`) on the server. + +Peer dependencies: `solid-js@^2.0.0-beta.10` and `@solidjs/web@^2.0.0-beta.10`. diff --git a/packages/orientation/LICENSE b/packages/orientation/LICENSE new file mode 100644 index 000000000..38b41d975 --- /dev/null +++ b/packages/orientation/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/orientation/README.md b/packages/orientation/README.md new file mode 100644 index 000000000..5c17c36ef --- /dev/null +++ b/packages/orientation/README.md @@ -0,0 +1,84 @@ +

+ Solid Primitives orientation +

+ +# @solid-primitives/orientation + +[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/orientation?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-primitives/orientation) +[![version](https://img.shields.io/npm/v/@solid-primitives/orientation?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/orientation) +[![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 tracking screen orientation via the [Screen Orientation API](https://developer.mozilla.org/en-US/docs/Web/API/Screen_Orientation_API). + +- [`makeOrientation`](#makeorientation) — Non-reactive listener; attaches a callback for each orientation change and returns a cleanup function. +- [`createOrientation`](#createorientation) — Reactive primitive; returns `angle` and `type` as signal accessors that update on orientation change. + +## Installation + +```bash +npm install @solid-primitives/orientation +# or +yarn add @solid-primitives/orientation +# or +pnpm add @solid-primitives/orientation +``` + +## `makeOrientation` + +A non-reactive base primitive. Attaches a listener for screen orientation changes and returns a cleanup function. The callback fires on every subsequent change but **not** on mount — use `createOrientation` if you need the initial value reactively. + +Uses `screen.orientation` when available, falling back to the legacy `orientationchange` event on `window`. + +```ts +import { makeOrientation } from "@solid-primitives/orientation"; + +const cleanup = makeOrientation(({ angle, type }) => { + console.log(angle); // 0 | 90 | 180 | 270 + console.log(type); // "portrait-primary" | "landscape-primary" | ... +}); + +// remove listener when done +cleanup(); +``` + +## `createOrientation` + +A reactive primitive that tracks the screen orientation. Returns `angle` and `type` signal accessors, initialized to the current orientation and updated on every change. Automatically removes the event listener on cleanup. + +On the server, returns static defaults: `angle: 0`, `type: "portrait-primary"`. + +```ts +import { createOrientation } from "@solid-primitives/orientation"; +import { createEffect } from "solid-js"; + +const { angle, type } = createOrientation(); + +createEffect(() => { + console.log(angle()); // 0 | 90 | 180 | 270 + console.log(type()); // "portrait-primary" | "landscape-primary" | ... +}); +``` + +## Types + +```ts +export type OrientationType = + | "landscape-primary" + | "landscape-secondary" + | "portrait-primary" + | "portrait-secondary" + | "unknown"; + +export interface OrientationState { + readonly angle: number; + readonly type: OrientationType; +} +``` + +## Browser Support + +`screen.orientation` is supported in Chrome 38+, Firefox 43+, and Safari 16.4+. On older browsers the primitive falls back to the deprecated `window.orientationchange` event. + +## Changelog + +See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/orientation/dev/index.tsx b/packages/orientation/dev/index.tsx new file mode 100644 index 000000000..e109f42a7 --- /dev/null +++ b/packages/orientation/dev/index.tsx @@ -0,0 +1,23 @@ +import { type Component } from "solid-js"; +import { createOrientation } from "../src/index.js"; + +const App: Component = () => { + const { angle, type } = createOrientation(); + + return ( +
+
+

Screen Orientation

+

Rotate your device or resize to a narrow viewport to see changes.

+

+ Angle: {angle()}° +

+

+ Type: {type()} +

+
+
+ ); +}; + +export default App; diff --git a/packages/orientation/package.json b/packages/orientation/package.json new file mode 100644 index 000000000..c21e69a13 --- /dev/null +++ b/packages/orientation/package.json @@ -0,0 +1,67 @@ +{ + "name": "@solid-primitives/orientation", + "version": "0.0.100", + "description": "Primitives to track screen orientation using the Screen Orientation API", + "author": "David Di Biase ", + "contributors": [], + "license": "MIT", + "homepage": "https://primitives.solidjs.community/package/orientation", + "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": "orientation", + "stage": 0, + "list": [ + "makeOrientation", + "createOrientation" + ], + "category": "Sensors" + }, + "keywords": [ + "solid", + "orientation", + "screen", + "device", + "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/orientation/src/index.ts b/packages/orientation/src/index.ts new file mode 100644 index 000000000..a57ced870 --- /dev/null +++ b/packages/orientation/src/index.ts @@ -0,0 +1,118 @@ +import { createSignal, onCleanup, type Accessor } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { noop } from "@solid-primitives/utils"; + +export type OrientationType = + | "landscape-primary" + | "landscape-secondary" + | "portrait-primary" + | "portrait-secondary" + | "unknown"; + +export interface OrientationState { + readonly angle: number; + readonly type: OrientationType; +} + +const DEFAULT_STATE: OrientationState = { angle: 0, type: "portrait-primary" }; + +function angleToType(angle: number): OrientationType { + switch (angle) { + case 0: + return "portrait-primary"; + case 180: + return "portrait-secondary"; + case 90: + return "landscape-primary"; + case -90: + case 270: + return "landscape-secondary"; + default: + return "unknown"; + } +} + +function readOrientation(): OrientationState { + const orient = screen.orientation as ScreenOrientation | undefined; + if (orient) { + return { angle: orient.angle, type: orient.type as OrientationType }; + } + const angle = ((window as any).orientation as number | undefined) ?? 0; + return { angle, type: angleToType(angle) }; +} + +/** + * Attaches a listener for screen orientation changes. + * + * Uses the [Screen Orientation API](https://developer.mozilla.org/en-US/docs/Web/API/Screen_Orientation_API) + * when available, falling back to the deprecated `orientationchange` event. + * + * @param onChange called with the new {@link OrientationState} on every orientation change + * @returns cleanup function to remove the listener + * + * @example + * ```ts + * const cleanup = makeOrientation(({ angle, type }) => { + * console.log(angle, type); + * }); + * // remove listener + * cleanup(); + * ``` + */ +export function makeOrientation(onChange: (state: OrientationState) => void): VoidFunction { + if (isServer) return noop; + + const handler = () => onChange(readOrientation()); + const orient = screen.orientation as ScreenOrientation | undefined; + + if (orient) { + orient.addEventListener("change", handler); + return () => orient.removeEventListener("change", handler); + } + + window.addEventListener("orientationchange", handler); + return () => window.removeEventListener("orientationchange", handler); +} + +/** + * Creates a reactive primitive tracking the screen orientation. + * + * Returns reactive signals for `angle` (degrees: 0, 90, 180, 270) and `type` + * (e.g. `"portrait-primary"`). On the server, returns static defaults + * (`angle: 0`, `type: "portrait-primary"`). + * + * @returns object with `angle` and `type` signal accessors + * + * @example + * ```ts + * const { angle, type } = createOrientation(); + * + * createEffect(() => { + * console.log(angle(), type()); + * }); + * ``` + */ +export function createOrientation(): { + angle: Accessor; + type: Accessor; +} { + if (isServer) { + return { + angle: () => DEFAULT_STATE.angle, + type: () => DEFAULT_STATE.type, + }; + } + + const initial = readOrientation(); + const [angle, setAngle] = createSignal(initial.angle); + const [type, setType] = createSignal(initial.type); + + const cleanup = makeOrientation(state => { + setAngle(state.angle); + setType(state.type); + }); + + onCleanup(cleanup); + + return { angle, type }; +} diff --git a/packages/orientation/test/index.test.ts b/packages/orientation/test/index.test.ts new file mode 100644 index 000000000..7f80b0631 --- /dev/null +++ b/packages/orientation/test/index.test.ts @@ -0,0 +1,191 @@ +import { describe, test, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { createEffect, createRoot, flush } from "solid-js"; +import { makeOrientation, createOrientation, type OrientationState } from "../src/index.js"; + +// Minimal mock for screen.orientation +class MockScreenOrientation extends EventTarget { + angle = 0; + type = "portrait-primary"; + + simulate(angle: number, type: string) { + this.angle = angle; + this.type = type; + this.dispatchEvent(new Event("change")); + } +} + +const mockOrientation = new MockScreenOrientation(); + +beforeAll(() => { + Object.defineProperty(screen, "orientation", { + get: () => mockOrientation, + configurable: true, + }); +}); + +afterAll(() => { + Object.defineProperty(screen, "orientation", { + get: () => undefined, + configurable: true, + }); +}); + +beforeEach(() => { + mockOrientation.angle = 0; + mockOrientation.type = "portrait-primary"; +}); + +describe("makeOrientation", () => { + test("calls onChange when orientation changes", () => { + const states: OrientationState[] = []; + const cleanup = makeOrientation(state => states.push({ ...state })); + + mockOrientation.simulate(90, "landscape-primary"); + expect(states).toHaveLength(1); + expect(states[0]).toEqual({ angle: 90, type: "landscape-primary" }); + + mockOrientation.simulate(0, "portrait-primary"); + expect(states).toHaveLength(2); + expect(states[1]).toEqual({ angle: 0, type: "portrait-primary" }); + + cleanup(); + }); + + test("does not fire before a change occurs", () => { + const states: OrientationState[] = []; + const cleanup = makeOrientation(state => states.push({ ...state })); + + expect(states).toHaveLength(0); + cleanup(); + }); + + test("cleanup removes event listener", () => { + const states: OrientationState[] = []; + const cleanup = makeOrientation(state => states.push({ ...state })); + + cleanup(); + mockOrientation.simulate(90, "landscape-primary"); + expect(states).toHaveLength(0); + }); + + test("multiple listeners are independent", () => { + const a: number[] = []; + const b: number[] = []; + + const cleanupA = makeOrientation(({ angle }) => a.push(angle)); + const cleanupB = makeOrientation(({ angle }) => b.push(angle)); + + mockOrientation.simulate(90, "landscape-primary"); + expect(a).toEqual([90]); + expect(b).toEqual([90]); + + cleanupA(); + mockOrientation.simulate(180, "portrait-secondary"); + expect(a).toEqual([90]); + expect(b).toEqual([90, 180]); + + cleanupB(); + }); +}); + +describe("createOrientation", () => { + test("returns current orientation as initial state", () => { + mockOrientation.angle = 270; + mockOrientation.type = "landscape-secondary"; + + createRoot(dispose => { + const { angle, type } = createOrientation(); + expect(angle()).toBe(270); + expect(type()).toBe("landscape-secondary"); + dispose(); + }); + }); + + test("updates signals when orientation changes", () => { + const { angle, type, dispose } = createRoot(dispose => { + const { angle, type } = createOrientation(); + return { angle, type, dispose }; + }); + + expect(angle()).toBe(0); + expect(type()).toBe("portrait-primary"); + + mockOrientation.simulate(90, "landscape-primary"); + flush(); + expect(angle()).toBe(90); + expect(type()).toBe("landscape-primary"); + + mockOrientation.simulate(180, "portrait-secondary"); + flush(); + expect(angle()).toBe(180); + expect(type()).toBe("portrait-secondary"); + + dispose(); + }); + + test("stops updating after dispose", () => { + const { angle, type, dispose } = createRoot(dispose => { + const { angle, type } = createOrientation(); + return { angle, type, dispose }; + }); + + dispose(); + mockOrientation.simulate(90, "landscape-primary"); + flush(); + expect(angle()).toBe(0); + expect(type()).toBe("portrait-primary"); + }); + + test("tracks angle reactively via createEffect", () => { + const angles: number[] = []; + + const dispose = createRoot(dispose => { + const { angle } = createOrientation(); + createEffect(angle, (a: number) => { + angles.push(a); + }); + return dispose; + }); + + flush(); // initial apply + mockOrientation.simulate(90, "landscape-primary"); + flush(); // apply after change + + expect(angles).toEqual([0, 90]); + dispose(); + }); + + test("tracks type reactively via createEffect", () => { + const types: string[] = []; + + const dispose = createRoot(dispose => { + const { type } = createOrientation(); + createEffect(type, (t: string) => { + types.push(t); + }); + return dispose; + }); + + flush(); // initial apply + mockOrientation.simulate(90, "landscape-primary"); + flush(); // apply after change + + expect(types).toEqual(["portrait-primary", "landscape-primary"]); + dispose(); + }); + + test("multiple independent instances each update", () => { + const { a, b, dispose } = createRoot(dispose => { + const a = createOrientation(); + const b = createOrientation(); + return { a, b, dispose }; + }); + + mockOrientation.simulate(270, "landscape-secondary"); + flush(); + expect(a.angle()).toBe(270); + expect(b.angle()).toBe(270); + + dispose(); + }); +}); diff --git a/packages/orientation/test/server.test.ts b/packages/orientation/test/server.test.ts new file mode 100644 index 000000000..0aeebeb34 --- /dev/null +++ b/packages/orientation/test/server.test.ts @@ -0,0 +1,28 @@ +import { describe, test, expect } from "vitest"; +import { makeOrientation, createOrientation } from "../src/index.js"; + +describe("makeOrientation (SSR)", () => { + test("returns a no-op cleanup without throwing", () => { + let called = false; + const cleanup = makeOrientation(() => { + called = true; + }); + expect(typeof cleanup).toBe("function"); + expect(called).toBe(false); + expect(() => cleanup()).not.toThrow(); + }); +}); + +describe("createOrientation (SSR)", () => { + test("returns static default state", () => { + const { angle, type } = createOrientation(); + expect(angle()).toBe(0); + expect(type()).toBe("portrait-primary"); + }); + + test("angle and type are callable functions", () => { + const { angle, type } = createOrientation(); + expect(typeof angle).toBe("function"); + expect(typeof type).toBe("function"); + }); +}); diff --git a/packages/orientation/tsconfig.json b/packages/orientation/tsconfig.json new file mode 100644 index 000000000..dc1970e16 --- /dev/null +++ b/packages/orientation/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 55b312627..f99928e4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,9 +137,6 @@ importers: '@solid-primitives/event-listener': specifier: workspace:^ version: link:../event-listener - '@solid-primitives/static-store': - specifier: workspace:^ - version: link:../static-store '@solid-primitives/utils': specifier: workspace:^ version: link:../utils @@ -647,6 +644,19 @@ importers: specifier: ^1.9.7 version: 1.9.7 + packages/orientation: + 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/page-visibility: dependencies: '@solid-primitives/event-listener': From 81880539868814b1a85c5ec2e897a8f6814edf78 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 10 May 2026 09:33:00 -0400 Subject: [PATCH 2/2] Adapted the examples to use 2.0 effects --- packages/orientation/README.md | 11 +++++++---- packages/orientation/src/index.ts | 9 ++++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/orientation/README.md b/packages/orientation/README.md index 5c17c36ef..76cbeef26 100644 --- a/packages/orientation/README.md +++ b/packages/orientation/README.md @@ -53,10 +53,13 @@ import { createEffect } from "solid-js"; const { angle, type } = createOrientation(); -createEffect(() => { - console.log(angle()); // 0 | 90 | 180 | 270 - console.log(type()); // "portrait-primary" | "landscape-primary" | ... -}); +createEffect( + () => ({ angle: angle(), type: type() }), + ({ angle, type }) => { + console.log(angle); // 0 | 90 | 180 | 270 + console.log(type); // "portrait-primary" | "landscape-primary" | ... + } +); ``` ## Types diff --git a/packages/orientation/src/index.ts b/packages/orientation/src/index.ts index a57ced870..d9bd3834f 100644 --- a/packages/orientation/src/index.ts +++ b/packages/orientation/src/index.ts @@ -87,9 +87,12 @@ export function makeOrientation(onChange: (state: OrientationState) => void): Vo * ```ts * const { angle, type } = createOrientation(); * - * createEffect(() => { - * console.log(angle(), type()); - * }); + * createEffect( + * () => ({ angle: angle(), type: type() }), + * ({ angle, type }) => { + * console.log(angle, type); + * } + * ); * ``` */ export function createOrientation(): {