{"_id":"@dmytromykhailiuk/preact-signal-modal","_rev":"2-54470df69912a11b567a2828ffa3c9a3","name":"@dmytromykhailiuk/preact-signal-modal","dist-tags":{"latest":"1.0.1"},"versions":{"1.0.0":{"name":"@dmytromykhailiuk/preact-signal-modal","version":"1.0.0","keywords":["preact","preact-signals","signals","modal","dialog","overlay","sheet","bottom-sheet","action-sheet","ionic","animation","web-animations","focus-trap","accessibility","typescript","type-safe","zero-rerender","zero-dependencies"],"author":{"name":"Dmytro Mykhailiuk","email":"dimamykhayluk@gmail.com"},"license":"MIT","_id":"@dmytromykhailiuk/preact-signal-modal@1.0.0","maintainers":[{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"}],"homepage":"https://github.com/dmytromykhailiuk/preact-signal-modal#readme","bugs":{"url":"https://github.com/dmytromykhailiuk/preact-signal-modal/issues"},"dist":{"shasum":"ddb11cd3dc9fad32db106142cce84a009e7b66f5","tarball":"https://registry.npmjs.org/@dmytromykhailiuk/preact-signal-modal/-/preact-signal-modal-1.0.0.tgz","fileCount":10,"integrity":"sha512-/7rq8AXKAwjlzoZySmJFvnBwaLmNoMViiRwO92tOxTfdpbtHli+6cl6/i+AI3xttXx7RgB54G6Vk15jMQQP8cA==","signatures":[{"sig":"MEUCIQCr0djGmXNUZr73PU49YtL1L+EdwGNTEKGR+gbDDCgR2AIgStOZFsUzPo6nu6UgH9nAKcEaHNxLcfc4qFcVw/HNEw0=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":418825},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=18"},"exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./styles.css":"./dist/styles.css","./package.json":"./package.json"},"gitHead":"3a736d6c0f8b56941c1c8c4a1e01ba9b5cc97f88","scripts":{"dev":"tsup --watch","lint":"biome check .","test":"vitest run","build":"tsup","format":"biome format --write .","lint:fix":"biome check --write .","typecheck":"tsc --noEmit","playground":"vite --config vite.playground.config.ts","test:watch":"vitest","prepublishOnly":"npm run build"},"_npmUser":{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"},"repository":{"url":"git+https://github.com/dmytromykhailiuk/preact-signal-modal.git","type":"git"},"_npmVersion":"11.6.2","description":"Signal-driven modals for Preact: imperative createModal() with an awaitable result, a declarative <Modal isOpen> component, Ionic-identical Web Animations API transitions, sheet modals with breakpoints, focus trap and zero re-renders.","directories":{},"sideEffects":false,"_nodeVersion":"24.12.0","_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.3.5","vite":"^5.4.11","jsdom":"^25.0.1","preact":"^10.25.4","vitest":"^2.1.8","typescript":"^5.7.3","@types/node":"^22.10.5","@biomejs/biome":"^1.9.4","@preact/signals":"^2.0.1","@preact/preset-vite":"^2.10.1","@testing-library/preact":"^3.2.4"},"peerDependencies":{"preact":">=10.25.0","@preact/signals":"^2.0.0"},"_npmOperationalInternal":{"tmp":"tmp/preact-signal-modal_1.0.0_1786365267928_0.887873730242785","host":"s3://npm-registry-packages-npm-production"}},"1.0.1":{"name":"@dmytromykhailiuk/preact-signal-modal","version":"1.0.1","description":"Signal-driven modals for Preact: imperative createModal() with an awaitable result, a declarative <Modal isOpen> component, Ionic-identical Web Animations API transitions, sheet modals with breakpoints, focus trap and zero re-renders.","type":"module","sideEffects":false,"author":{"name":"Dmytro Mykhailiuk","email":"dimamykhayluk@gmail.com"},"license":"MIT","keywords":["preact","preact-signals","signals","modal","dialog","overlay","sheet","bottom-sheet","action-sheet","ionic","animation","web-animations","focus-trap","accessibility","typescript","type-safe","zero-rerender","zero-dependencies"],"main":"./dist/index.cjs","module":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./styles.css":"./dist/styles.css","./package.json":"./package.json"},"scripts":{"build":"tsup","dev":"tsup --watch","playground":"vite --config vite.playground.config.ts","typecheck":"tsc --noEmit","test":"vitest run","test:watch":"vitest","lint":"biome check .","lint:fix":"biome check --write .","format":"biome format --write .","prepublishOnly":"npm run build"},"engines":{"node":">=18"},"peerDependencies":{"@preact/signals":"^2.0.0","preact":">=10.25.0"},"devDependencies":{"@biomejs/biome":"^1.9.4","@preact/preset-vite":"^2.10.1","@preact/signals":"^2.0.1","@testing-library/preact":"^3.2.4","@types/node":"^22.10.5","jsdom":"^25.0.1","preact":"^10.25.4","tsup":"^8.3.5","typescript":"^5.7.3","vite":"^5.4.11","vitest":"^2.1.8"},"repository":{"type":"git","url":"git+https://github.com/dmytromykhailiuk/preact-signal-modal.git"},"bugs":{"url":"https://github.com/dmytromykhailiuk/preact-signal-modal/issues"},"homepage":"https://dmytromykhailiuk.github.io/preact-signal-modal/","gitHead":"c698e18dd2a8d7055ee16a850c696d4685708071","_id":"@dmytromykhailiuk/preact-signal-modal@1.0.1","_nodeVersion":"24.12.0","_npmVersion":"11.6.2","dist":{"integrity":"sha512-n+VsE7ZVxQqd0TnMSbMV0G57tbeUj97X37zYGimsHz+azVb8//OU7dVRhzq/L6puUpgi+qR01l6TAKONo3Cg2g==","shasum":"fdb40b8a8a5cede70dc7b6e2675691c69159ab8a","tarball":"https://registry.npmjs.org/@dmytromykhailiuk/preact-signal-modal/-/preact-signal-modal-1.0.1.tgz","fileCount":10,"unpackedSize":418818,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIBaTLgDviHayAeUdzjPxUoMaEymaLx6qWFGOdFvJznPZAiEA6+2ljzpPbPwf/4A8b7UP7SqDqx4vOO4j/i6aNj9BJhI="}]},"_npmUser":{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"},"directories":{},"maintainers":[{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/preact-signal-modal_1.0.1_1786639067999_0.19113860684005446"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-10T12:34:27.745Z","modified":"2026-08-13T16:37:48.276Z","1.0.0":"2026-08-10T12:34:28.123Z","1.0.1":"2026-08-13T16:37:48.141Z"},"bugs":{"url":"https://github.com/dmytromykhailiuk/preact-signal-modal/issues"},"author":{"name":"Dmytro Mykhailiuk","email":"dimamykhayluk@gmail.com"},"license":"MIT","homepage":"https://dmytromykhailiuk.github.io/preact-signal-modal/","keywords":["preact","preact-signals","signals","modal","dialog","overlay","sheet","bottom-sheet","action-sheet","ionic","animation","web-animations","focus-trap","accessibility","typescript","type-safe","zero-rerender","zero-dependencies"],"repository":{"type":"git","url":"git+https://github.com/dmytromykhailiuk/preact-signal-modal.git"},"description":"Signal-driven modals for Preact: imperative createModal() with an awaitable result, a declarative <Modal isOpen> component, Ionic-identical Web Animations API transitions, sheet modals with breakpoints, focus trap and zero re-renders.","maintainers":[{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"}],"readme":"# @dmytromykhailiuk/preact-signal-modal\n\nSignal-driven modals for Preact: an awaitable `createModal()`, a declarative `<Modal isOpen>`, Ionic-identical transitions on the Web Animations API, sheet modals with breakpoints, a real focus trap — and not one re-render.\n\n> **Full documentation: [Docs](https://dmytromykhailiuk.github.io/preact-signal-modal/)** — every option, every animation hook, with examples.\n\n> **The one rule:** a modal is a value you are waiting for, not a piece of state you are babysitting. `const { data, role } = await modal.afterClose` is the whole API most of the time.\n\nModals are where component state goes to rot. You add an `isModalOpen` boolean, then a second one for the nested confirmation, then a `pendingDeleteId` to remember what the confirmation was about, then a `useEffect` to lock body scroll, then a `keydown` listener for Escape, then a `ref` to give focus back to the button that opened it. None of that is your feature. All of it re-renders the component that owns it, several levels above the thing that actually changed.\n\nThis package moves the whole lot into one signal-backed stack. Modals live outside your component tree, so opening one re-renders nothing; the answer comes back as a promise, so the state that only existed to remember the question can go. Scroll lock, Escape, the focus trap and `inert` on the rest of the page are handled once, for the stack, not once per modal you write.\n\nThe transitions are Ionic's, ported keyframe for keyframe: `ios` slides a full viewport up on `cubic-bezier(0.32,0.72,0,1)` over 500ms, `md` fades and rises 40px over 280ms. Swap either for your own with the same `createAnimation()` builder Ionic uses.\n\n## Install\n\n```bash\nnpm i @dmytromykhailiuk/preact-signal-modal\n```\n\n`preact >= 10.25` and `@preact/signals ^2` are peer dependencies. There are no others.\n\n## Quick start\n\nMount the container once, near the root of your app:\n\n```tsx\nimport { ModalContainer } from \"@dmytromykhailiuk/preact-signal-modal\";\n\nconst App = () => (\n  <>\n    <Routes />\n    <ModalContainer />\n  </>\n);\n```\n\nThen open a modal from anywhere and wait for the answer:\n\n```tsx\nimport { createModal, useModal } from \"@dmytromykhailiuk/preact-signal-modal\";\n\nconst Confirm = ({ question }: { question: string }) => {\n  const modal = useModal<boolean>();\n  return (\n    <>\n      <p>{question}</p>\n      <button type=\"button\" onClick={() => modal.close(true, \"confirm\")}>Yes</button>\n      <button type=\"button\" onClick={() => modal.close(false, \"cancel\")}>No</button>\n    </>\n  );\n};\n\nconst deleteFile = async (id: string) => {\n  const modal = createModal<boolean>(<Confirm question=\"Delete this file?\" />);\n  const { data, role } = await modal.afterClose;\n\n  if (role === \"backdrop\" || role === \"escape\") return; // dismissed, not answered\n  if (data) await api.delete(id);\n};\n```\n\n`afterClose` resolves once the leave animation has finished and the content has been unmounted — the modal is genuinely gone by then, not merely hidden. `role` tells you *how* it closed: `backdrop`, `escape`, `gesture`, `handler`, or any string you pass yourself.\n\n## Two ways to open a modal\n\nUse `createModal()` when the modal is the result of something that happened — a click, a failed request, a route guard. Use `<Modal>` when it belongs in the markup.\n\n`<Modal>` takes exactly one of `trigger` and `isOpen`, because they are two answers to the same question: who owns the open state. Let the component own it and there is nothing to wire up at all:\n\n```tsx\nimport { Modal } from \"@dmytromykhailiuk/preact-signal-modal\";\n\nconst Settings = () => (\n  <>\n    <button type=\"button\" id=\"settings-button\">Settings</button>\n\n    <Modal trigger=\"settings-button\" ariaLabel=\"Settings\">\n      <SettingsForm />\n    </Modal>\n  </>\n);\n```\n\nOr own it yourself, as a signal:\n\n```tsx\nconst isOpen = useSignal(false);\n\n<Modal isOpen={isOpen} onDidDismiss={() => (isOpen.value = false)} ariaLabel=\"Settings\">\n  <SettingsForm />\n</Modal>\n```\n\n`isOpen` is a signal — a plain boolean would re-render the owning component on every open and close — and a **read-only** one, because the modal does not write to state it does not own. So a `computed`, or a selector from your store, works as well as a `useSignal`. The other half of that bargain is yours: the modal still closes on the backdrop, on Escape and on a swipe, and `onDidDismiss` is where you set your signal back to `false`.\n\nBoth routes go through the same stack, so a declarative modal and an imperative one share one stacking order, one scroll lock and one focus trap.\n\n## Refusing to close\n\nA form with unsaved changes should not vanish because someone missed the modal by ten pixels:\n\n```tsx\nconst draft = signal(\"\");\n\ncreateModal(<EditNote draft={draft} />, {\n  canDismiss: ({ role }) => role === \"save\" || draft.peek().length === 0,\n});\n```\n\n`canDismiss` may be async — the modal stays open until it resolves, and stays open for good if it resolves `false`. `close()` returns `Promise<boolean>` so the caller can tell a refusal from a dismissal.\n\n## Animations\n\nEvery modal takes an `enterAnimation` and a `leaveAnimation`, each an `AnimationBuilder` handed the modal's root element:\n\n```tsx\nimport { createAnimation } from \"@dmytromykhailiuk/preact-signal-modal\";\nimport type { AnimationBuilder } from \"@dmytromykhailiuk/preact-signal-modal\";\n\nconst zoomEnter: AnimationBuilder = (baseEl) =>\n  createAnimation()\n    .addElement(baseEl)\n    .duration(300)\n    .easing(\"cubic-bezier(0.32,0.72,0,1)\")\n    .addAnimation([\n      createAnimation()\n        .addElement(baseEl.querySelector(\".psm-backdrop\"))\n        .fromTo(\"opacity\", 0, \"var(--psm-backdrop-opacity, 0.32)\"),\n      createAnimation()\n        .addElement(baseEl.querySelector(\".psm-wrapper\"))\n        .fromTo(\"transform\", \"scale(0.8)\", \"scale(1)\")\n        .fromTo(\"opacity\", 0, 1),\n    ]);\n```\n\nTiming set on the parent is inherited by children that do not define their own — that is how one `duration(300)` drives the backdrop and the modal together. Pass `configureModal({ enterAnimation, leaveAnimation })` to change it everywhere at once, `animated: false` to skip motion for one modal, and nothing at all to respect `prefers-reduced-motion` — that is already handled.\n\n## Styling\n\nThe stylesheet is injected on first use; customisation is CSS custom properties, no `!important` anywhere:\n\n```css\n:root {\n  --psm-background: #fff;\n  --psm-border-radius: 14px;\n  --psm-max-width: 32rem;\n  --psm-backdrop-opacity: 0.45;\n  --psm-z-index: 4000;\n}\n```\n\nUnder a strict CSP, or when server-rendering, turn injection off and import the identical stylesheet yourself:\n\n```ts\nconfigureModal({ injectStyles: false });\nimport \"@dmytromykhailiuk/preact-signal-modal/styles.css\";\n```\n\n## Sheet modals\n\nGive a modal `breakpoints` and it becomes a sheet you can drag, flick and swipe away:\n\n```tsx\ncreateModal(<Filters />, {\n  breakpoints: [0, 0.25, 0.5, 1],\n  initialBreakpoint: 0.25,\n  handleBehavior: \"cycle\",\n});\n```\n\nA slow drag snaps to the nearest breakpoint, a flick carries on to the next one in that direction, and landing on `0` dismisses with role `gesture`. From inside, `useModal().setBreakpoint(1)` moves it and `breakpoint$` reports where it is.\n\n## And the rest\n\n- **Focus** — the first focusable element is focused on open, Tab is trapped, and focus returns to whatever had it before. Everything outside the modal gets `inert` and `aria-hidden`, worked out by walking up from the modal rather than by assuming it was portalled to `<body>`.\n- **Escape and the backdrop** — on by default, `keyboardClose` and `backdropDismiss` turn them off. Escape only ever reaches the topmost modal.\n- **Body scroll** — locked while anything is open, reference counted, with scrollbar-width compensation so the page does not jump.\n- **`hasModals()`, `getTopModal()`, `hasModals$`** — for driving something else from the stack. The two functions are plain reads that subscribe to nothing, safe in a handler or a guard; `hasModals$` is the signal to derive from. The stack itself is not exported, because reading it in a render body would subscribe the component to every open and close.\n- **`closeAllModals()`** — for navigation, where a modal left behind is always a bug.\n\n## TypeScript\n\n`createModal<T>()` types both ends: `close(data)` only accepts a `T`, and `afterClose` resolves to `ModalDismissal<T>`. `useModal<T>()` inside the content agrees with it.\n\n```ts\nconst modal = createModal<{ id: string }>(<Picker />);\nconst { data } = await modal.afterClose; // { id: string } | undefined\n```\n\n`data` is optional because a modal can always be dismissed without answering — that is the type system telling you to check `role`.\n\n## License\n\nMIT\n","readmeFilename":"README.md"}