diff --git a/.gitignore b/.gitignore index ffa46fd78..82c77b3bc 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,4 @@ _temp_* # Local Netlify folder .netlify +storybook-static diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 000000000..b1b22be3c --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,59 @@ +import type { StorybookConfig } from "storybook-solidjs-vite"; +import { mergeConfig } from "vite"; + +const config: StorybookConfig = { + stories: ["../packages/*/stories/*.stories.{ts,tsx}"], + staticDirs: [ + { from: "../packages/audio/stories/assets", to: "/audio" }, + { from: "../assets/img", to: "/img" }, + { from: "../node_modules/geist/dist/fonts", to: "/geist-fonts" }, + ], + addons: ["@storybook/addon-docs"], + framework: { + name: "storybook-solidjs-vite", + options: {}, + }, + docs: {}, + // Swap Storybook's Nunito Sans woff2 files for Geist equivalents so that + // every existing `font-family: 'Nunito Sans'` rule in the manager runtime + // renders Geist without needing CSS overrides. + managerHead: (head = "") => + head + .replace("./sb-common-assets/nunito-sans-regular.woff2", "/geist-fonts/geist-sans/Geist-Regular.woff2") + .replace("./sb-common-assets/nunito-sans-bold.woff2", "/geist-fonts/geist-sans/Geist-Bold.woff2") + .replace("./sb-common-assets/nunito-sans-italic.woff2", "/geist-fonts/geist-sans/Geist-Italic.woff2") + .replace( + "./sb-common-assets/nunito-sans-bold-italic.woff2", + "/geist-fonts/geist-sans/Geist-BoldItalic.woff2", + ), + async viteFinal(config) { + return mergeConfig(config, { + plugins: [ + // babel-preset-solid +@font-face { + font-family: "Geist"; + src: url("/geist-fonts/geist-sans/Geist-Variable.woff2") format("woff2"); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "Geist"; + src: url("/geist-fonts/geist-sans/Geist-Italic[wght].woff2") format("woff2"); + font-weight: 100 900; + font-style: italic; + font-display: swap; +} +@font-face { + font-family: "Geist Mono"; + src: url("/geist-fonts/geist-mono/GeistMono-Variable.woff2") format("woff2"); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + diff --git a/.storybook/manager.ts b/.storybook/manager.ts new file mode 100644 index 000000000..d32a78e0a --- /dev/null +++ b/.storybook/manager.ts @@ -0,0 +1,6 @@ +import { addons } from "storybook/manager-api"; +import { solidTheme } from "./theme"; + +addons.setConfig({ + theme: solidTheme, +}); diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 000000000..99b729b49 --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1,26 @@ + diff --git a/.storybook/preview.ts b/.storybook/preview.ts new file mode 100644 index 000000000..de7d3799d --- /dev/null +++ b/.storybook/preview.ts @@ -0,0 +1,12 @@ +import { definePreview } from "storybook-solidjs-vite/next"; +import * as docsAnnotations from "@storybook/addon-docs/preview"; + +export default definePreview({ + addons: [docsAnnotations], + parameters: { + layout: "centered", + docs: { + toc: true, + }, + }, +}); diff --git a/.storybook/theme.ts b/.storybook/theme.ts new file mode 100644 index 000000000..7ed1247a6 --- /dev/null +++ b/.storybook/theme.ts @@ -0,0 +1,40 @@ +import { create } from "storybook/theming/create"; + +export const solidTheme = create({ + base: "dark", + + brandTitle: "Solid Primitives", + brandImage: "/img/logo.png", + brandTarget: "_self", + + // Solid.js brand blues (sourced from logo gradient stops) + colorPrimary: "#518ac8", + colorSecondary: "#76b3e1", + + // App chrome + appBg: "#0d1b2a", + appContentBg: "#122033", + appPreviewBg: "#122033", + appBorderColor: "#1f3b77", + appBorderRadius: 6, + + // Text + textColor: "#dcf2fd", + textInverseColor: "#0d1b2a", + textMutedColor: "#76b3e1", + + // Toolbar / sidebar + barTextColor: "#76b3e1", + barHoverColor: "#dcf2fd", + barSelectedColor: "#76b3e1", + barBg: "#0d1b2a", + + // Inputs + inputBg: "#1a2e45", + inputBorder: "#315aa9", + inputTextColor: "#dcf2fd", + inputBorderRadius: 4, + + fontBase: '"Geist", "Inter", system-ui, sans-serif', + fontCode: '"Geist Mono", "Fira Code", monospace', +}); diff --git a/.storybook/tsconfig.json b/.storybook/tsconfig.json new file mode 100644 index 000000000..f80eda751 --- /dev/null +++ b/.storybook/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "verbatimModuleSyntax": false, + "noUncheckedIndexedAccess": false, + "types": ["vite/client"] + }, + "include": ["./**/*", "../packages/*/stories/**/*", "../template/stories/**/*"] +} diff --git a/.storybook/ui/controls.tsx b/.storybook/ui/controls.tsx new file mode 100644 index 000000000..73aaaf553 --- /dev/null +++ b/.storybook/ui/controls.tsx @@ -0,0 +1,210 @@ +import type { JSX } from "solid-js"; +import { colors, font, radii } from "./tokens.js"; + +export const Button = (props: { + onClick?: () => void; + children: JSX.Element; + variant?: "primary" | "secondary" | "outline" | "ghost"; + color?: string; + disabled?: boolean; + style?: JSX.CSSProperties; + type?: "button" | "submit" | "reset"; + ref?: HTMLButtonElement | ((el: HTMLButtonElement) => void); +}) => ( + +); + +export const btnStyle = { + padding: "0.4rem 0.85rem", + "border-radius": radii.md, + border: `1px solid ${colors.border}`, + background: colors.surface, + cursor: "pointer", + "font-family": font.system, + "font-size": font.sizeBase, +} as const; + +export const inputStyle = { + padding: "0.4rem 0.75rem", + "font-size": font.sizeBase, + width: "100%", + border: `1px solid ${colors.border}`, + "border-radius": radii.md, + "box-sizing": "border-box", + "font-family": font.system, +} as const; + +export const popoverStyle = { + background: "white", + border: `1px solid ${colors.border}`, + "border-radius": radii.lg, + padding: "1rem 1.25rem", + "box-shadow": "0 4px 16px rgba(0,0,0,0.10)", + "font-size": font.sizeBase, + color: "#475569", +} as const; + +export const logBox = { + background: colors.surface, + "border-radius": radii.md, + border: `1px solid ${colors.border}`, + padding: "0.6rem 0.75rem", + "min-height": "80px", + "font-size": "0.85rem", + "line-height": "1.6", +} as const; + +export const Label = (props: { children: string }) => ( + +); + +export const TextField = (props: { + label?: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; + type?: string; +}) => ( +
+ {props.label && } + props.onChange(e.currentTarget.value)} + placeholder={props.placeholder} + style={inputStyle} + /> +
+); + +export const Kbd = (props: { children: JSX.Element }) => ( + + {props.children} + +); + +export const Badge = (props: { + children: JSX.Element; + variant?: "default" | "info" | "success" | "warning" | "error"; +}) => ( + + {props.children} + +); + +export const Code = (props: { children: JSX.Element }) => ( + + {props.children} + +); + +export const Alert = (props: { + children: JSX.Element; + variant?: "info" | "warning" | "error"; +}) => ( +
+ {props.children} +
+); diff --git a/.storybook/ui/index.ts b/.storybook/ui/index.ts new file mode 100644 index 000000000..c258529f4 --- /dev/null +++ b/.storybook/ui/index.ts @@ -0,0 +1,3 @@ +export * from "./tokens.js"; +export * from "./primitives.js"; +export * from "./controls.js"; diff --git a/.storybook/ui/primitives.tsx b/.storybook/ui/primitives.tsx new file mode 100644 index 000000000..df65b7faf --- /dev/null +++ b/.storybook/ui/primitives.tsx @@ -0,0 +1,199 @@ +import type { JSX } from "solid-js"; +import { For } from "solid-js"; +import { colors, font, radii } from "./tokens.js"; + +export const Container = (props: { + width?: number; + minWidth?: number; + gap?: string; + children: JSX.Element; +}) => ( +
+ {props.children} +
+); + +export const Stat = (props: { label: string; labelWidth?: string; children: JSX.Element }) => ( +
+ + {props.label} + + + {props.children} + +
+); + +export const StatRow = (props: { label: string; value: string | number }) => ( +
+ {props.label} + + {String(props.value)} + +
+); + +export const BoolRow = (props: { label: string; value: boolean }) => ( +
+ {props.label} + + {String(props.value)} + +
+); + +export const ValueDisplay = (props: { label: string; value: string }) => ( +
+ {props.label}: + + {props.value || empty} + +
+); + +export const Card = (props: { children: JSX.Element }) => ( +
+ {props.children} +
+); + +export const Separator = () => ( +
+); + +export const Section = (props: { title: string; children: JSX.Element }) => ( +
+
+ {props.title} +
+
+ {props.children} +
+
+); + +export const ButtonRow = (props: { children: JSX.Element }) => ( +
{props.children}
+); + +export const EventLog = (props: { entries: { label: string; time: string }[] }) => ( +
+ {props.entries.length === 0 ? ( + + waiting… + + ) : ( + + {(e, i) => ( +
+ {e.label} {e.time} +
+ )} +
+ )} +
+); + +export const Progress = (props: { value: number; color?: string }) => ( +
+
+
+); diff --git a/.storybook/ui/tokens.ts b/.storybook/ui/tokens.ts new file mode 100644 index 000000000..f9e8c4c31 --- /dev/null +++ b/.storybook/ui/tokens.ts @@ -0,0 +1,30 @@ +export const colors = { + primary: "#6366f1", + primaryFg: "#ffffff", + secondary: "#f1f5f9", + secondaryFg: "#334155", + border: "#e2e8f0", + borderStrong: "#cbd5e1", + muted: "#64748b", + mutedFg: "#94a3b8", + surface: "#f8fafc", + dark: "#0f172a", + darkFg: "#a5f3fc", + success: "#16a34a", + warning: "#f59e0b", +} as const; + +export const radii = { + sm: "4px", + md: "6px", + lg: "8px", + full: "9999px", +} as const; + +export const font = { + system: '"Geist", system-ui, sans-serif', + mono: '"Geist Mono", monospace', + sizeSm: "0.82rem", + sizeBase: "0.9rem", + sizeMd: "1rem", +} as const; diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..184f01571 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +Thanks for helping make Solid safe for everyone. + +# Security + +Solid Community takes the security of our software seriously, including all of the open source code repositories managed through [this GitHub organization](https://github.com/solidjs-community). + +## Reporting a Vulnerability + +**If you think you've found a security issue, please DO NOT report, discuss, or describe it on Discord or GitHub.** + +**All security-related issues, concerns, and problems must be reported via email to: security@solidjs.com** + +Please include everything necessary to reproduce the problem when sending over information, including an example repository on StackBlitz or GitHub. Please don't add explicit details about the security issue you are reporting in any of the repository's contents. + +**_This is detrimental to the safety of all Solid users. No exceptions._** + +## Embargo Policy + +SolidJS Community Security Team members must share information only within the Solid Core, relevant internal team members and security focused teams on a need-to-know basis to fix the related issue in Solid. The information members and others receive through participation in this group must not be made public, shared, or even hinted otherwise, except with prior explicit approval (which shall be handled on a case-by-case basis). This holds true until the agreed-upon public disclosure date/time is satisfied. + +As a clarifying example, this policy forbids Solid Security members from sharing list information with their employers; unless prior arrangements have been made directly with an employer. + +In the unfortunate event that you share the information beyond what is allowed by this policy, you must urgently inform the Solid Security Team of exactly what information leaked and to whom, as well as the steps that will be taken to prevent future leaks. + +SolidJS Community is not directly related to SolidJS as an organization but closely works with SolidJS Core. + +**Repeated offenses may lead to the removal from the Security or Solid team.** diff --git a/assets/img/logo.png b/assets/img/logo.png new file mode 100644 index 000000000..185acb3ac Binary files /dev/null and b/assets/img/logo.png differ diff --git a/eslint.config.mjs b/eslint.config.mjs index ecb337067..06a8add90 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -21,8 +21,8 @@ export default { sourceType: "module", parserOptions: { - project: "./tsconfig.json", - tsconfigRootDir: ".", + project: true, + tsconfigRootDir: import.meta.dirname, }, }, diff --git a/package.json b/package.json index 2d937f654..9d0a15ece 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "test": "pnpm run test:client && pnpm run test:ssr", "test:client2": "vitest -c ./configs/vitest.config.solid2.ts", "test:ssr2": "pnpm run test:client2 --mode ssr", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build", "build": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ./scripts/build.ts", "new-package": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ./scripts/new-package.ts", "update-readme": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ./scripts/update-readme.ts", @@ -35,16 +37,20 @@ "@babel/core": "^7.27.0", "@changesets/cli": "^2.29.4", "@nothing-but/node-resolve-ts": "^1.0.1", + "@solidjs/signals": "2.0.0-beta.14", "@solidjs/web": "2.0.0-beta.14", + "@storybook/addon-docs": "^10.4.1", "@types/jsdom": "^21.1.7", "@types/node": "^22.15.31", "@typescript-eslint/eslint-plugin": "^8.34.0", "@typescript-eslint/parser": "^8.34.0", + "babel-preset-solid": "2.0.0-beta.14", "esbuild": "^0.25.5", "esbuild-plugin-solid": "^0.6.0", "eslint": "^9.28.0", "eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-no-only-tests": "^3.3.0", + "geist": "^1.7.1", "jsdom": "^25.0.1", "json-to-markdown-table": "^1.0.0", "prettier": "^3.5.3", @@ -54,10 +60,11 @@ "rehype-slug": "^6.0.0", "remark-gfm": "^4.0.1", "solid-js": "2.0.0-beta.14", + "storybook": "^10.4.1", + "storybook-solidjs-vite": "^10.1.0", "typescript": "^5.8.3", "vite": "^6.3.5", "vite-plugin-solid": "3.0.0-next.5", - "babel-preset-solid": "2.0.0-beta.14", "vitest": "^2.1.9" }, "pnpm": { diff --git a/packages/audio/README.md b/packages/audio/README.md index ef1faa952..9099471ea 100644 --- a/packages/audio/README.md +++ b/packages/audio/README.md @@ -142,10 +142,6 @@ const media = new MediaSource(); const audio = createAudio(URL.createObjectURL(media)); ``` -## Demo - -You may view a working example here: https://stackblitz.com/edit/vitejs-vite-zwfs6h?file=src%2Fmain.tsx - ## Changelog See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/audio/dev/index.tsx b/packages/audio/dev/index.tsx deleted file mode 100644 index e4d1d19ca..000000000 --- a/packages/audio/dev/index.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { type Component, For, type JSX, Suspense, createSignal, splitProps } from "solid-js"; -import { createAudio } from "../src/index.js"; - -type IconPath = { path: () => JSX.Element; outline?: boolean; mini?: boolean }; -type IconProps = JSX.SvgSVGAttributes & { path: IconPath }; - -const Icon = (props: IconProps) => { - const [internal, external] = splitProps(props, ["path"]); - return ( - - {internal.path.path()} - - ); -}; - -const play: IconPath = { - path: () => ( - - ), -}; -const pause: IconPath = { - path: () => ( - - ), -}; -const speakerWave: IconPath = { - path: () => ( - - ), - outline: true, -}; - -const formatTime = (time: number) => new Date(time * 1000).toISOString().substr(14, 8); - -const Player: Component<{ source: () => string }> = props => { - const audio = createAudio(props.source); - - return ( -
- -
- - {formatTime(audio.currentTime())} / {formatTime(audio.duration())} - -
- }> - audio.seek(+evt.currentTarget.value)} - type="range" - min="0" - step="0.1" - max={audio.duration()} - value={audio.currentTime()} - class="form-range w-40 cursor-pointer appearance-none rounded-3xl bg-gray-200 transition hover:bg-gray-300 focus:ring-0 focus:outline-none" - /> - -
- - audio.setVolume(+evt.currentTarget.value)} - type="range" - min="0" - step="0.1" - max={1} - value={audio.volume()} - class="cursor w-10" - /> -
-
- ); -}; - -const App: Component = () => { - const [source, setSource] = createSignal("sample1.mp3"); - - return ( -
-
- -
- - {([label, url]) => ( - - )} - -
-
-
- ); -}; - -export default App; diff --git a/packages/audio/package.json b/packages/audio/package.json index e53ecff1d..f5367003c 100644 --- a/packages/audio/package.json +++ b/packages/audio/package.json @@ -37,8 +37,6 @@ }, "typesVersions": {}, "scripts": { - "start": "vite serve dev", - "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", diff --git a/packages/audio/stories/_helpers.tsx b/packages/audio/stories/_helpers.tsx new file mode 100644 index 000000000..dcd1df1bc --- /dev/null +++ b/packages/audio/stories/_helpers.tsx @@ -0,0 +1,46 @@ +export const SAMPLES = [ + { label: "Sample 1", url: "/audio/sample1.mp3" }, + { label: "Sample 2", url: "/audio/sample2.mp3" }, + { label: "Sample 3", url: "/audio/sample3.mp3" }, +]; + +export const formatTime = (s: number) => { + const m = Math.floor(s / 60); + const sec = Math.floor(s % 60); + return `${m}:${sec.toString().padStart(2, "0")}`; +}; + +export const VolumeSlider = (props: { value: () => number; onChange: (v: number) => void }) => ( + +); + +export const SeekSlider = (props: { + current: () => number; + max: number; + onSeek: (t: number) => void; +}) => ( + +); diff --git a/packages/audio/dev/sample1.mp3 b/packages/audio/stories/assets/sample1.mp3 similarity index 100% rename from packages/audio/dev/sample1.mp3 rename to packages/audio/stories/assets/sample1.mp3 diff --git a/packages/audio/dev/sample2.mp3 b/packages/audio/stories/assets/sample2.mp3 similarity index 100% rename from packages/audio/dev/sample2.mp3 rename to packages/audio/stories/assets/sample2.mp3 diff --git a/packages/audio/dev/sample3.mp3 b/packages/audio/stories/assets/sample3.mp3 similarity index 100% rename from packages/audio/dev/sample3.mp3 rename to packages/audio/stories/assets/sample3.mp3 diff --git a/packages/audio/stories/createAudio.stories.tsx b/packages/audio/stories/createAudio.stories.tsx new file mode 100644 index 000000000..5be867f87 --- /dev/null +++ b/packages/audio/stories/createAudio.stories.tsx @@ -0,0 +1,123 @@ +import { createSignal, For } from "solid-js"; +import { Loading } from "@solidjs/web"; +import preview from "../../../.storybook/preview.js"; +import { createAudio } from "@solid-primitives/audio"; +import readme from "../README.md?raw"; +import { SAMPLES, formatTime, VolumeSlider, SeekSlider } from "./_helpers.js"; +import { Button } from "../../../.storybook/ui/index.js"; + +const meta = preview.meta({ + title: "Display & Media/Audio", + tags: ["autodocs"], + parameters: { + layout: "centered", + docs: { + description: { + component: readme, + }, + }, + }, +}); + +export default meta; + +export const ReactivePlayer = meta.story({ + name: "Reactive player", + parameters: { + docs: { + description: { + story: + "`createAudio` returns signals for `playing`, `volume`, `currentTime`, and `duration`. Switch tracks reactively — the source accessor reconnects the player automatically.", + }, + }, + }, + render: () => { + const [source, setSource] = createSignal(SAMPLES.at(0)!.url); + const audio = createAudio(source); + + return ( +
+

createAudio

+ +
+ + {s => ( + + )} + +
+ +
+ + + Loading…}> + + + {formatTime(audio.currentTime())} / {formatTime(audio.duration())} + + +
+ + +
+ ); + }, +}); + +export const ReactiveSource = meta.story({ + name: "Reactive source swap", + parameters: { + docs: { + description: { + story: + "When the source signal changes, `createAudio` seamlessly reconnects the player. `duration` resets to pending and re-suspends `` while the new track's metadata loads.", + }, + }, + }, + render: () => { + const [idx, setIdx] = createSignal(0); + const source = () => SAMPLES.at(idx())!.url; + const audio = createAudio(source); + + return ( +
+

Reactive source swap

+ +
+ + {SAMPLES.at(idx())?.label} + +
+ +
+ + + ⏳ Buffering…}> + + {formatTime(audio.currentTime())} / {formatTime(audio.duration())} + + +
+ + +
+ ); + }, +}); diff --git a/packages/audio/stories/makeAudio.stories.tsx b/packages/audio/stories/makeAudio.stories.tsx new file mode 100644 index 000000000..9cbe5f928 --- /dev/null +++ b/packages/audio/stories/makeAudio.stories.tsx @@ -0,0 +1,58 @@ +import { createSignal } from "solid-js"; +import preview from "../../../.storybook/preview.js"; +import { makeAudio } from "@solid-primitives/audio"; +import { VolumeSlider } from "./_helpers.js"; +import { Button } from "../../../.storybook/ui/index.js"; + +const meta = preview.meta({ + title: "Display & Media/Audio", + parameters: { + layout: "centered", + }, +}); + +export default meta; + +export const NonReactive = meta.story({ + name: "Non-reactive", + parameters: { + docs: { + description: { + story: + "`makeAudio` creates a raw `HTMLAudioElement` with event handlers attached. No Solid owner required — you manage lifecycle yourself via the returned `cleanup` function.", + }, + }, + }, + render: () => { + const [playing, setPlaying] = createSignal(false); + const [volume, setVol] = createSignal(1); + + const [player, cleanup] = makeAudio("/audio/sample1.mp3", { + playing: () => setPlaying(true), + pause: () => setPlaying(false), + ended: () => setPlaying(false), + volumechange: () => setVol(player.volume), + }); + + return ( +
+

makeAudio

+

+ Direct HTMLAudioElement — no reactive signals. +

+ +
+ + +
+ + { player.volume = v; }} /> +
+ ); + }, +}); diff --git a/packages/audio/stories/makeAudioPlayer.stories.tsx b/packages/audio/stories/makeAudioPlayer.stories.tsx new file mode 100644 index 000000000..56e8a1bb4 --- /dev/null +++ b/packages/audio/stories/makeAudioPlayer.stories.tsx @@ -0,0 +1,68 @@ +import { createSignal, Show } from "solid-js"; +import preview from "../../../.storybook/preview.js"; +import { makeAudioPlayer } from "@solid-primitives/audio"; +import { formatTime, VolumeSlider, SeekSlider } from "./_helpers.js"; +import { Button } from "../../../.storybook/ui/index.js"; + +const meta = preview.meta({ + title: "Display & Media/Audio", + parameters: { + layout: "centered", + }, +}); + +export default meta; + +export const Controls = meta.story({ + name: "Player controls", + parameters: { + docs: { + description: { + story: + "`makeAudioPlayer` wraps `makeAudio` with a `controls` object exposing `play`, `pause`, `seek`, and `setVolume` methods. Still non-reactive — useful when you want imperative control without signals.", + }, + }, + }, + render: () => { + const [playing, setPlaying] = createSignal(false); + const [currentTime, setCurrentTime] = createSignal(0); + const [duration, setDuration] = createSignal(0); + const [volume, setVolume] = createSignal(1); + + const [controls] = makeAudioPlayer("/audio/sample2.mp3", { + playing: () => setPlaying(true), + pause: () => setPlaying(false), + ended: () => setPlaying(false), + timeupdate: () => setCurrentTime(controls.player.currentTime), + loadeddata: () => setDuration(controls.player.duration), + volumechange: () => setVolume(controls.player.volume), + }); + + return ( +
+

makeAudioPlayer

+ +
+ + + 0} + fallback={Loading…} + > + + + {formatTime(currentTime())} / {formatTime(duration())} + + +
+ + +
+ ); + }, +}); diff --git a/packages/audio/stories/tsconfig.json b/packages/audio/stories/tsconfig.json new file mode 100644 index 000000000..0cd588e9e --- /dev/null +++ b/packages/audio/stories/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../.storybook/tsconfig.json", + "include": ["./**/*"] +} diff --git a/packages/autofocus/README.md b/packages/autofocus/README.md index d07bdfa56..06d019cef 100644 --- a/packages/autofocus/README.md +++ b/packages/autofocus/README.md @@ -70,12 +70,6 @@ createAutofocus(ref); ; ``` -## Demo - -You may see the working example here: https://primitives.solidjs.community/playground/autofocus/ - -Source code: https://github.com/solidjs-community/solid-primitives/blob/main/packages/autofocus/dev/index.tsx - ## Changelog See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/autofocus/stories/autofocus.stories.tsx b/packages/autofocus/stories/autofocus.stories.tsx new file mode 100644 index 000000000..56caff277 --- /dev/null +++ b/packages/autofocus/stories/autofocus.stories.tsx @@ -0,0 +1,190 @@ +import { createSignal, Show } from "solid-js"; +import preview from "../../../.storybook/preview.js"; +import { autofocus, createAutofocus } from "@solid-primitives/autofocus"; +import readme from "../README.md?raw"; +import { Button, Container } from "../../../.storybook/ui/index.js"; + +const meta = preview.meta({ + title: "DOM/Autofocus", + tags: ["autodocs"], + parameters: { + layout: "centered", + docs: { + description: { + component: readme, + }, + }, + }, +}); + +export default meta; + +export const AutofocusRefCallback = meta.story({ + name: "autofocus() ref callback", + parameters: { + docs: { + description: { + story: + "`autofocus()` is a ref callback factory. Attach it via `ref={autofocus()}` and include the native `autofocus` attribute — the primitive checks for that attribute before focusing, so removing it is all you need to opt out. Unmount and remount the input below to see it re-focus each time.", + }, + }, + }, + render: () => { + const [mounted, setMounted] = createSignal(true); + + return ( + +

autofocus()

+ + + + + + + +

+ The native autofocus attribute alone only fires on page load. This + primitive re-applies it on every render. +

+
+ ); + }, +}); + +export const ConditionalAutofocus = meta.story({ + name: "Conditional autofocus", + parameters: { + docs: { + description: { + story: + "Toggle the `autofocus` attribute to enable or disable focusing — no extra API needed. `autofocus()` only calls `.focus()` when the attribute is present on the element at settle time.", + }, + }, + }, + render: () => { + const [enabled, setEnabled] = createSignal(true); + const [mounted, setMounted] = createSignal(true); + + return ( + +

Conditional autofocus

+ + + + + + + + +
+ ); + }, +}); + +export const CreateAutofocusLetRef = meta.story({ + name: "createAutofocus — let ref", + parameters: { + docs: { + description: { + story: + "`createAutofocus(() => ref)` integrates with the reactive lifecycle using a plain `let` ref variable. The element receives focus after the component settles — no `autofocus` attribute required.", + }, + }, + }, + render: () => { + const [mounted, setMounted] = createSignal(true); + + const FocusedInput = () => { + let ref!: HTMLInputElement; + createAutofocus(() => ref); + return ( + + ); + }; + + return ( + +

createAutofocus — let ref

+ + + + + + +
+ ); + }, +}); + +export const CreateAutofocusSignalRef = meta.story({ + name: "createAutofocus — signal ref", + parameters: { + docs: { + description: { + story: + "`createAutofocus(ref)` also accepts a signal accessor — pass `ref={setRef}` on the element. Focus re-fires whenever the signal changes to a new element, making it easy to shift focus as the DOM updates.", + }, + }, + }, + render: () => { + const [mounted, setMounted] = createSignal(true); + + const FocusedInput = () => { + const [ref, setRef] = createSignal(); + createAutofocus(ref); + return ( + + ); + }; + + return ( + +

createAutofocus — signal ref

+ + + + + + + +

+ The signal approach is useful when you need to store the ref elsewhere — e.g.{" "} + const [ref, setRef] = createSignal() at an outer scope. +

+
+ ); + }, +}); diff --git a/packages/bounds/README.md b/packages/bounds/README.md index 37113eef5..a5fa2367d 100644 --- a/packages/bounds/README.md +++ b/packages/bounds/README.md @@ -95,10 +95,6 @@ const bounds = createElementBounds(target, { }); ``` -## Demo - -https://codesandbox.io/s/solid-primitives-bounds-64rls0?file=/index.tsx - ## Changelog See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/bounds/stories/_helpers.tsx b/packages/bounds/stories/_helpers.tsx new file mode 100644 index 000000000..a57e66c75 --- /dev/null +++ b/packages/bounds/stories/_helpers.tsx @@ -0,0 +1,23 @@ +import type { NullableBounds } from "@solid-primitives/bounds"; +import { colors, font } from "../../../.storybook/ui/index.js"; + +export const BoundsGrid = (props: { bounds: NullableBounds }) => ( +
+ {(["top", "left", "bottom", "right", "width", "height"] as const).map(key => ( +
+ {key} + + {props.bounds[key] !== null ? `${Math.round(props.bounds[key]!)}px` : "—"} + +
+ ))} +
+); diff --git a/packages/bounds/stories/createElementBounds.stories.tsx b/packages/bounds/stories/createElementBounds.stories.tsx new file mode 100644 index 000000000..5328c6b42 --- /dev/null +++ b/packages/bounds/stories/createElementBounds.stories.tsx @@ -0,0 +1,292 @@ +import { createSignal } from "solid-js"; +import preview from "../../../.storybook/preview.js"; +import { createElementBounds, type UpdateGuard } from "@solid-primitives/bounds"; +import readme from "../README.md?raw"; +import { BoundsGrid } from "./_helpers.js"; +import { Button, Container } from "../../../.storybook/ui/index.js"; + +const meta = preview.meta({ + title: "DOM/Bounds", + tags: ["autodocs"], + parameters: { + layout: "centered", + docs: { + description: { + component: readme, + }, + }, + }, +}); + +export default meta; + +export const LiveBounds = meta.story({ + name: "Live element bounds", + parameters: { + docs: { + description: { + story: + "`createElementBounds` returns a reactive store-like object that tracks an element's `getBoundingClientRect()` — updating on resize, scroll, and DOM mutation. Drag the sliders to resize the box and watch all six values update in real time.", + }, + }, + }, + render: () => { + const [width, setWidth] = createSignal(160); + const [height, setHeight] = createSignal(100); + + let ref!: HTMLDivElement; + const bounds = createElementBounds(() => ref); + + return ( + +

createElementBounds

+ +
+ {width()} × {height()} +
+ +
+ + +
+ + +
+ ); + }, +}); + +export const ReactiveTarget = meta.story({ + name: "Reactive target", + parameters: { + docs: { + description: { + story: + "The `target` argument can be a signal accessor. Set it to a falsy value to pause tracking — bounds values return `null`. Toggle the target below to see the difference.", + }, + }, + }, + render: () => { + const [active, setActive] = createSignal(true); + const [ref, setRef] = createSignal(); + + const bounds = createElementBounds(() => (active() ? ref() : null)); + + return ( + +

Reactive target

+ +
+ {active() ? "Tracked" : "Not tracked"} +
+ + + + +
+ ); + }, +}); + +export const ScrollTracking = meta.story({ + name: "Scroll tracking", + parameters: { + docs: { + description: { + story: + "`trackScroll` (enabled by default) listens for scroll events on any ancestor and updates `top`, `left`, `right`, and `bottom` as the element moves relative to the viewport. Scroll the container below to see the position values change.", + }, + }, + }, + render: () => { + let ref!: HTMLDivElement; + const bounds = createElementBounds(() => ref); + + return ( + +

Scroll tracking

+ +
+
+
+ Scroll me +
+
+
+ + + +

+ Scroll the container — top, left, bottom, and{" "} + right reflect the element's position in the viewport. +

+
+ ); + }, +}); + +export const ThrottledTracking = meta.story({ + name: "Throttled updates (UpdateGuard)", + parameters: { + docs: { + description: { + story: + "Each tracking option accepts an `UpdateGuard` in place of `true` — a higher-order function `(update) => throttledUpdate` that wraps the internal trigger. Use it to rate-limit expensive recalculations. The counter below only increments at most once every 400ms of scrolling, no matter how many scroll events fire.", + }, + }, + }, + render: () => { + const [updateCount, setUpdateCount] = createSignal(0); + + const throttleGuard = + (ms: number): UpdateGuard => + fn => { + let last = 0; + return (...args) => { + const now = Date.now(); + if (now - last < ms) return; + last = now; + setUpdateCount(c => c + 1); + fn(...args); + }; + }; + + let ref!: HTMLDivElement; + const bounds = createElementBounds(() => ref, { + trackScroll: throttleGuard(400), + trackResize: throttleGuard(400), + }); + + return ( + +

UpdateGuard — throttled tracking

+ +
+
+
+ Scroll me +
+
+
+ +
+ Bounds updates fired + {updateCount()} +
+ + + +

+ Pass an UpdateGuard to wrap the internal trigger — throttle, debounce, or + batch updates any way you like. +

+
+ ); + }, +}); diff --git a/packages/broadcast-channel/README.md b/packages/broadcast-channel/README.md index 76436f593..1fed2b011 100644 --- a/packages/broadcast-channel/README.md +++ b/packages/broadcast-channel/README.md @@ -164,10 +164,6 @@ const consumeDataCorrect = (data: { id: number; message: string }) => { }; ``` -## Demo - -Here's a working example here: https://stackblitz.com/edit/vitejs-vite-5xren3?file=src%2Fmain.tsx - ## Changelog See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/broadcast-channel/stories/broadcastChannel.stories.tsx b/packages/broadcast-channel/stories/broadcastChannel.stories.tsx new file mode 100644 index 000000000..ba58855e2 --- /dev/null +++ b/packages/broadcast-channel/stories/broadcastChannel.stories.tsx @@ -0,0 +1,207 @@ +import { createSignal, For, Show } from "solid-js"; +import preview from "../../../.storybook/preview.js"; +import { makeBroadcastChannel, createBroadcastChannel } from "@solid-primitives/broadcast-channel"; +import readme from "../README.md?raw"; +import { inputStyle, logBox, Button, Container } from "../../../.storybook/ui/index.js"; + +const CHANNEL = "sp-broadcast-demo"; + +const channelBadge = { + background: "#e0f2fe", + color: "#0369a1", + "border-radius": "4px", + padding: "0.15rem 0.5rem", + "font-size": "0.8rem", + "font-family": "monospace", +} as const; + +const meta = preview.meta({ + title: "Browser APIs/Broadcast Channel", + tags: ["autodocs"], + parameters: { + layout: "centered", + docs: { + description: { + component: readme, + }, + }, + }, +}); + +export default meta; + +export const MakeBroadcastChannelStory = meta.story({ + name: "makeBroadcastChannel", + parameters: { + docs: { + description: { + story: + "`makeBroadcastChannel(name)` creates (or joins) a named BroadcastChannel and returns an imperative API: `onMessage(cb)` subscribes to incoming messages; `postMessage(data)` sends to all **other** browsing contexts on the same channel. Multiple calls with the same `name` share a single underlying channel — the channel closes only when the last owner unmounts. Click **Open new tab ↗**, navigate to any Broadcast Channel story there, post a message, and watch it arrive in the log below.", + }, + }, + }, + render: () => { + const [log, setLog] = createSignal([]); + const [draft, setDraft] = createSignal("Hello from Storybook!"); + + const { onMessage, postMessage, channelName } = makeBroadcastChannel(CHANNEL); + + onMessage(({ data }) => { + const ts = new Date().toLocaleTimeString(); + setLog(prev => [`[${ts}] ${data}`, ...prev].slice(0, 6)); + }); + + const send = () => { + const msg = draft().trim(); + if (msg) postMessage(msg); + }; + + return ( + +

makeBroadcastChannel

+ +
+ Channel: + {channelName} + +
+ +
+ setDraft(e.currentTarget.value)} + placeholder="Message to send…" + style={inputStyle} + onKeyDown={e => e.key === "Enter" && send()} + /> + +
+ +
+ 0} + fallback={ + + No messages yet — open a second tab and post from there. + + } + > + + {(entry, i) => ( +
{entry}
+ )} +
+
+
+ +

+ BroadcastChannel only delivers to other browsing contexts — messages posted here + will not appear in this same tab's log. +

+
+ ); + }, +}); + +export const CreateBroadcastChannelStory = meta.story({ + name: "createBroadcastChannel", + parameters: { + docs: { + description: { + story: + "`createBroadcastChannel(name)` wraps `makeBroadcastChannel` in a reactive layer: instead of an `onMessage` callback it exposes `message` — a signal accessor that updates automatically whenever another browsing context posts to the same channel. The `message()` value starts as `null` and becomes the last received payload. Open a second tab, switch to either Broadcast Channel story (both use channel `sp-broadcast-demo`), post a message there, and the signal below will update reactively.", + }, + }, + }, + render: () => { + const [draft, setDraft] = createSignal("Hi from tab 2!"); + const { message, postMessage, channelName } = createBroadcastChannel(CHANNEL); + + const send = () => { + const msg = draft().trim(); + if (msg) postMessage(msg); + }; + + return ( + +

createBroadcastChannel

+ +
+ Channel: + {channelName} + +
+ +
+ + message() + + + {message() !== null ? `"${message()}"` : "null"} + +
+ +
+ setDraft(e.currentTarget.value)} + placeholder="Message to send…" + style={inputStyle} + onKeyDown={e => e.key === "Enter" && send()} + /> + +
+ +

+ message() is null until a message arrives from another tab. + Both stories share channel sp-broadcast-demo, so posting from either tab + updates this signal. +

+
+ ); + }, +}); diff --git a/packages/broadcast-channel/stories/tsconfig.json b/packages/broadcast-channel/stories/tsconfig.json new file mode 100644 index 000000000..0cd588e9e --- /dev/null +++ b/packages/broadcast-channel/stories/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../.storybook/tsconfig.json", + "include": ["./**/*"] +} diff --git a/packages/clipboard/README.md b/packages/clipboard/README.md index 743ee28ac..6983ca6c7 100644 --- a/packages/clipboard/README.md +++ b/packages/clipboard/README.md @@ -134,10 +134,6 @@ import { newClipboardItem } from "@solid-primitives/clipboard"; writeClipboard([newClipboardItem("image/png", await image.blob())]); ``` -## Demo - -You may view a working example in [the /dev playground](./dev/index.tsx) deployed on [primitives.solidjs.community/playground/clipboard](https://primitives.solidjs.community/playground/clipboard/). - ## Changelog See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/clipboard/stories/clipboard.stories.tsx b/packages/clipboard/stories/clipboard.stories.tsx new file mode 100644 index 000000000..838375a25 --- /dev/null +++ b/packages/clipboard/stories/clipboard.stories.tsx @@ -0,0 +1,276 @@ +import { createSignal, For, Show } from "solid-js"; +import preview from "../../../.storybook/preview.js"; +import { + writeClipboard, + readClipboard, + createClipboard, + copyToClipboard, + input as inputHighlight, + type ClipboardSetter, +} from "@solid-primitives/clipboard"; +import readme from "../README.md?raw"; +import { inputStyle, Button, Container } from "../../../.storybook/ui/index.js"; + +const meta = preview.meta({ + title: "Browser APIs/Clipboard", + tags: ["autodocs"], + parameters: { + layout: "centered", + docs: { + description: { + component: readme, + }, + }, + }, +}); + +export default meta; + +export const CopyDirective = meta.story({ + name: "copyToClipboard ref directive", + parameters: { + docs: { + description: { + story: + "`copyToClipboard()` returns a ref callback that writes an element's value to the clipboard on click. On ``/`