diff --git a/.gitignore b/.gitignore index bbbced1..97c990e 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,8 @@ assets/node_modules/ # Vendored reference repos (not part of the package) vendor/ -# Storybook dev-harness assets: Tailwind-built CSS is a build artifact -# (source is assets/css/storybook.css). The static JS bundle is committed. +# Storybook dev-harness assets: both the Tailwind-built CSS and the esbuild-built +# JS bundle are build artifacts. Sources live in assets/css/storybook.css and +# assets/js/storybook.js (which imports the TS hooks in assets/ts/). /priv/static/assets/css/storybook.css +/priv/static/assets/js/storybook.js diff --git a/assets/js/storybook.js b/assets/js/storybook.js new file mode 100644 index 0000000..17f1218 --- /dev/null +++ b/assets/js/storybook.js @@ -0,0 +1,3 @@ +import { Hooks } from "../ts/index.ts"; + +window.storybook = { Hooks, Params: {}, Uploaders: {} }; diff --git a/assets/ts/dialog.ts b/assets/ts/dialog.ts new file mode 100644 index 0000000..aa12a5d --- /dev/null +++ b/assets/ts/dialog.ts @@ -0,0 +1,38 @@ +interface DialogHook { + el: HTMLElement; + mounted(): void; + destroyed(): void; +} + +const FOCUSABLE = + 'a[href],area[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),button:not([disabled]),[tabindex]:not([tabindex="-1"])'; + +export const ShadixDialog = { + mounted(this: DialogHook) { + const el = this.el; + let restoreTo: HTMLElement | null = null; + + const open = () => { + restoreTo = document.activeElement as HTMLElement | null; + document.body.style.overflow = "hidden"; + window.requestAnimationFrame(() => { + const content = el.querySelector('[role="dialog"]'); + const first = content?.querySelector(FOCUSABLE); + (first ?? content)?.focus(); + }); + }; + + const close = () => { + document.body.style.overflow = ""; + restoreTo?.focus(); + restoreTo = null; + }; + + el.addEventListener("shadix:dialog-open", open); + el.addEventListener("shadix:dialog-close", close); + }, + + destroyed(this: DialogHook) { + document.body.style.overflow = ""; + }, +}; diff --git a/assets/ts/index.ts b/assets/ts/index.ts new file mode 100644 index 0000000..69a1b46 --- /dev/null +++ b/assets/ts/index.ts @@ -0,0 +1,3 @@ +import { ShadixDialog } from "./dialog"; + +export const Hooks = { ShadixDialog }; diff --git a/assets/tsconfig.json b/assets/tsconfig.json new file mode 100644 index 0000000..eb8c6c2 --- /dev/null +++ b/assets/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["ts/**/*.ts"] +} diff --git a/config/config.exs b/config/config.exs index 49e4aa3..b06fda2 100644 --- a/config/config.exs +++ b/config/config.exs @@ -12,6 +12,18 @@ config :tailwind, cd: Path.expand("..", __DIR__) ] +# esbuild bundles the storybook JS entrypoint (assets/js/storybook.js), which +# imports the TypeScript LiveView hooks (assets/ts/*), into the static JS bundle +# served by the dev harness. The built file is a build artifact (gitignored). +config :esbuild, + version: "0.23.0", + storybook: [ + args: + ~w(js/storybook.js --bundle --format=iife --target=es2022 --outdir=../priv/static/assets/js), + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} + ] + if File.exists?(Path.join(__DIR__, "#{config_env()}.exs")) do import_config "#{config_env()}.exs" end diff --git a/mix.exs b/mix.exs index 71fdc44..0255cb2 100644 --- a/mix.exs +++ b/mix.exs @@ -52,7 +52,7 @@ defmodule Shadix.MixProject do defp aliases do [ - "assets.build": ["tailwind storybook"], + "assets.build": ["tailwind storybook", "esbuild storybook"], dev: ["run --no-halt dev.exs"] ] end diff --git a/priv/static/assets/js/storybook.js b/priv/static/assets/js/storybook.js deleted file mode 100644 index e8b6b3e..0000000 --- a/priv/static/assets/js/storybook.js +++ /dev/null @@ -1,3 +0,0 @@ -// Minimal storybook JS bundle. Shadix components have no LiveView hooks yet, so -// empty registries suffice. Loaded immediately before phoenix_storybook's own JS. -window.storybook = { Hooks: {}, Params: {}, Uploaders: {} };