diff --git a/.formatter.exs b/.formatter.exs index 50b69bdd..1063b0e0 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -7,7 +7,7 @@ locals_without_parens = [ plugins: [Phoenix.LiveView.HTMLFormatter], inputs: [ "*.{ex,exs}", - "{config,lib,priv}/**/*.{ex,exs,eex}", + "{config,lib,priv}/**/*.{ex,exs,eex,heex}", "test/phoenix_storybook/**/*.{ex,exs}", "test/*.{ex,exs}" ], diff --git a/README.md b/README.md index ac532928..0e406f98 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,11 @@ defmodule MyAppWeb.Storybook do themes: [ default: [name: "Default"], colorful: [name: "Colorful", dropdown_class: "text-pink-400"] + ], + + # Color mode settings. Defaults to false and 'dark'. + color_mode: true, + color_mode_sandbox_dark_class: "dark", # If you want to use custom FontAwesome icons. font_awesome_plan: :pro, # default value is :free @@ -140,7 +145,7 @@ defmodule MyAppWeb.Storybook do # - When lazy: only .index.exs files are compiled upfront and .story.exs are compile when the # matching story is loaded in UI. compilation_mode: :eager, - + # If you want to see debugging logs for storybooks compilation, set this to `true`. Default: `false` compilation_debug: true ] @@ -154,8 +159,8 @@ config :my_app, MyAppWeb.Storybook, content_path: "overridden/content/path" ``` -ℹ️ Learn more on theming components in the [theming guide](guides/theming.md), on icons in the -[icons](guides/icons.md) guide. +ℹ️ Learn more on theming components in the [theming guide](guides/theming.md), icons in the +[icons](guides/icons.md) guide and color mode in the [color modes guide](guides/color_modes.md). diff --git a/assets/css/app.css b/assets/css/app.css index 18e60c9f..1a24b903 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -80,6 +80,14 @@ @apply psb-font-mono psb-text-sm; } +.psb-dark ::-webkit-scrollbar { + @apply psb-bg-slate-900 psb-w-[15px]; +} + +.psb-dark ::-webkit-scrollbar-thumb { + @apply psb-bg-slate-700 psb-border-solid psb-border-4 psb-border-transparent psb-rounded-lg psb-bg-clip-content; +} + @layer base { /* 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) diff --git a/assets/js/app.js b/assets/js/app.js index 5110a429..079bef56 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -3,6 +3,7 @@ import { Socket } from "phoenix"; import { StoryHook } from "./lib/story_hook"; import { SearchHook } from "./lib/search_hook"; import { SidebarHook } from "./lib/sidebar_hook"; +import { ColorModeHook } from "./lib/color_mode_hook"; if (window.storybook === undefined) { console.warn("No storybook configuration detected."); @@ -20,13 +21,24 @@ let csrfToken = document .querySelector("meta[name='csrf-token']") ?.getAttribute("content"); +const selectedColorMode = ColorModeHook.selectedColorMode(); +const actualColorMode = ColorModeHook.actualColorMode(selectedColorMode); + let liveSocket = new LiveSocket(socketPath, Socket, { - hooks: { ...window.storybook.Hooks, StoryHook, SearchHook, SidebarHook }, + hooks: { + ...window.storybook.Hooks, + StoryHook, + SearchHook, + SidebarHook, + ColorModeHook, + }, uploaders: window.storybook.Uploaders, params: (liveViewName) => { return { _csrf_token: csrfToken, extra: window.storybook.Params, + selected_color_mode: selectedColorMode, + color_mode: actualColorMode, }; }, ...window.storybook.LiveSocketOptions, diff --git a/assets/js/iframe.js b/assets/js/iframe.js index de9a7f72..e53b5e0b 100644 --- a/assets/js/iframe.js +++ b/assets/js/iframe.js @@ -1,5 +1,6 @@ import { LiveSocket } from "phoenix_live_view"; import { Socket } from "phoenix"; +import { ColorModeHook } from "./lib/color_mode_hook"; if (window.storybook === undefined) { console.warn("No storybook configuration detected."); @@ -18,7 +19,7 @@ let csrfToken = window.parent.document ?.getAttribute("content"); let liveSocket = new LiveSocket(socketPath, Socket, { - hooks: { ...window.storybook.Hooks }, + hooks: { ...window.storybook.Hooks, ColorModeHook }, uploaders: window.storybook.Uploaders, params: (liveViewName) => { return { diff --git a/assets/js/lib/color_mode_hook.js b/assets/js/lib/color_mode_hook.js new file mode 100644 index 00000000..3807cb96 --- /dev/null +++ b/assets/js/lib/color_mode_hook.js @@ -0,0 +1,71 @@ +/* +This hook is meant to: +- remember, with local storage, which color mode has been selected by the user +- toggle psb color_mode on the HTML root element (which will change colors of the storybook itself, +not the component's colors) +- push to the storybook the selected color mode, and the actual color mode (when selected value +is system, it will send dark or light depending on the browser current prefers-color-scheme). +*/ + +let self; + +export const ColorModeHook = { + mounted() { + self = this; + window.addEventListener("psb:set-color-mode", this.onSetColorMode); + + window + .matchMedia("(prefers-color-scheme: dark)") + .addEventListener("change", () => { + const selectedMode = this.selectedColorMode(); + const actualMode = this.actualColorMode(selectedMode); + this.pushEvent("psb-set-color-mode", { + selected_mode: selectedMode, + mode: actualMode, + }); + this.toggleColorModeClass(actualMode); + }); + }, + + destroyed() { + window.removeEventListener("psb:set-color-mode", onSetColorMode); + }, + + selectedColorMode() { + return localStorage.getItem("psb_selected_color_mode") || "system"; + }, + + actualColorMode(selectedMode) { + if ( + selectedMode == "system" && + window.matchMedia("(prefers-color-scheme: dark)").matches + ) { + return "dark"; + } else if (selectedMode == "dark") { + return "dark"; + } else { + return "light"; + } + }, + toggleColorModeClass: (mode) => { + if (mode == "dark") { + document.documentElement.classList.add("psb-dark"); + } else { + document.documentElement.classList.remove("psb-dark"); + } + }, + onSetColorMode: (e) => { + const selectedMode = e.detail.mode || "system"; + localStorage.setItem("psb_selected_color_mode", selectedMode); + const actualMode = self.actualColorMode(selectedMode); + self.pushEvent("psb-set-color-mode", { + selected_mode: selectedMode, + mode: actualMode, + }); + self.toggleColorModeClass(actualMode); + }, +}; + +const selectedMode = ColorModeHook.selectedColorMode(); +const actualMode = ColorModeHook.actualColorMode(selectedMode); +ColorModeHook.toggleColorModeClass(actualMode); diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js index 24963a15..64048c4f 100644 --- a/assets/tailwind.config.js +++ b/assets/tailwind.config.js @@ -30,4 +30,5 @@ module.exports = { }, important: ".psb", prefix: "psb-", + darkMode: "selector", }; diff --git a/guides/color_modes.md b/guides/color_modes.md new file mode 100644 index 00000000..5fe0b7ac --- /dev/null +++ b/guides/color_modes.md @@ -0,0 +1,68 @@ +# Color modes + +The storybook can support color modes: _dark_, _light_ and _system_. + +- The storybook style itself is styled based on the selected color mode +- Your components will be wrapped in a div with a custom dark class. + +The different modes are handled as such: + +- when `dark`, the `dark` class (or custom dark class) is added on your components sandbox +- when `light`, no class is set +- when `system`, it will add the `dark` class if your system prefers dark (cf. `prefers-color-scheme`) + +## Setup + +First you need enable `color_mode` support. + +```elixir +use PhoenixStorybook, + # ... + color_mode: true +``` + +This will add a new color theme picker in the storybook header. At this time you should be able +to render the storybook with the new mode. + +## Component rendering + +Whenever a component of yours is rendered in the storybook, it's wrapped under a sandbox element (read [sandboxing guide](sandboxing.md)) + +If the current color_mode is dark (or system with your system being dark), then the sandbox will carry a `dark` css class. When in light mode, no class is set. + +You can customize the default dark class: + +```elixir +use PhoenixStorybook, + # ... + color_mode_sandbox_dark_class: "my-dark", +``` + +## Tailwind setup + +If you use Tailwind for your own components, then update your `tailwind.config.js` accordingly. + +```js +module.exports = { + // ... + darkMode: "selector", +}; +``` + +A custom dark class can be used like this: + +```js +module.exports = { + // ... + darkMode: ["selector", ".my-dark"], +}; +``` + +In your own application, when setting the dark mode class to your DOM, make sure it is added on +(or under) your sandbox/important element. + +```html + + ... + +``` diff --git a/guides/sandboxing.md b/guides/sandboxing.md index 2644fcca..48f43681 100644 --- a/guides/sandboxing.md +++ b/guides/sandboxing.md @@ -13,7 +13,7 @@ This guide will explain: - how you should provide the style of your components with scoped styles. - how to, as a last resort, enable iframe rendering. -## 1. What JS context do your components share with the storybook? +## What JS context do your components share with the storybook? `PhoenixStorybook` runs with Phoenix LiveView and therefore requires its `LiveSocket`. This LiveSocket is the same used by your components: you just need to inject it with your own `Hooks`, @@ -44,7 +44,7 @@ external scripts. The `Params` will be available in page stories as `connect_params` assign. There is currently no way to access them in component or live component stories. -## 2. How is the storybook styled? +## How is the storybook styled? `PhoenixStorybook` is using [TailwindCSS](https://tailwindcss.com) with [preflight](https://tailwindcss.com/docs/preflight) (which means all default HTML styles from your @@ -56,14 +56,14 @@ Only elements with the `.psb` class are preflighted, in order to let your compon So unless your components use `psb` or `psb-` prefixed classes there should be no styling leak from the storybook to you components. -## 3. How should you provide the style of your components? +## How should you provide the style of your components? You need to inject your component's stylesheets into the storybook. Set the `css_path: "/assets/storybook.css"` option in `storybook.ex`. This is a remote path (not a local file-system path) which means this file should be served by your own application endpoint with the given path. -The previous part (2.) was about storybook styles not leaking into your components. This part is +The previous part was about storybook styles not leaking into your components. This part is about the opposite: don't accidentally mess up Storybook styling with your styles. All containers rendering your components in the storybook (`stories`, `playground`, `pages` ...) @@ -122,7 +122,7 @@ module.exports = { } ``` -## 4. Enabling iframe rendering +## Enabling iframe rendering As a last resort, if for whatever reason you cannot make your component live within the storybook, it is possible to enable iframe rendering, component per component. diff --git a/lib/phoenix_storybook/live/search.ex b/lib/phoenix_storybook/live/search.ex index 688102f6..fdcc18c6 100644 --- a/lib/phoenix_storybook/live/search.ex +++ b/lib/phoenix_storybook/live/search.ex @@ -53,7 +53,7 @@ defmodule PhoenixStorybook.Search do phx-show={show_modal()} phx-hide={hide_modal()} phx-click-away={JS.dispatch("psb:close-search")} - class="psb psb-opacity-0 psb-scale-90 psb-mx-auto psb-max-w-xl psb-mt-16 psb-transform psb-divide-y psb-divide-gray-100 psb-overflow-hidden psb-rounded-xl psb-bg-white psb-shadow-2xl psb-transition-all" + class="psb psb-opacity-0 psb-scale-90 psb-mx-auto psb-max-w-xl psb-mt-16 psb-transform psb-divide-y psb-divide-gray-100 dark:psb-divide-slate-600 psb-overflow-hidden psb-rounded-xl psb-bg-white dark:psb-bg-slate-800 psb-shadow-2xl psb-transition-all" > <.form :let={f} @@ -76,36 +76,47 @@ defmodule PhoenixStorybook.Search do placeholder: "Search...", autocomplete: "off", class: - "psb psb-h-12 psb-w-full psb-border-0 psb-bg-transparent psb-pl-11 psb-pr-4 psb-text-gray-800 psb-placeholder-gray-400 psb-outline-none focus:psb-ring-0 sm:psb-text-sm" + "psb psb-h-12 psb-w-full psb-border-0 psb-bg-transparent psb-pl-11 psb-pr-4 psb-text-gray-800 dark:psb-text-slate-300 psb-placeholder-gray-400 dark:psb-placeholder-slate-500 psb-outline-none focus:psb-ring-0 sm:psb-text-sm" ) %> <%= if Enum.empty?(@stories) do %> -
No stories found