Skip to content

Commit

Permalink
Dark mode (#425)
Browse files Browse the repository at this point in the history
* manage color mode state
* theming storybook for dark mode
* testing
* color mode guide and README
  • Loading branch information
cblavier authored Nov 19, 2024
1 parent 31ae56f commit 9d46b43
Show file tree
Hide file tree
Showing 25 changed files with 768 additions and 178 deletions.
2 changes: 1 addition & 1 deletion .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
],
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
]
Expand All @@ -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).

<!-- MDOC !-->

Expand Down
8 changes: 8 additions & 0 deletions assets/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 13 additions & 1 deletion assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion assets/js/iframe.js
Original file line number Diff line number Diff line change
@@ -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.");
Expand All @@ -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 {
Expand Down
71 changes: 71 additions & 0 deletions assets/js/lib/color_mode_hook.js
Original file line number Diff line number Diff line change
@@ -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);
1 change: 1 addition & 0 deletions assets/tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,5 @@ module.exports = {
},
important: ".psb",
prefix: "psb-",
darkMode: "selector",
};
68 changes: 68 additions & 0 deletions guides/color_modes.md
Original file line number Diff line number Diff line change
@@ -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
<html class="storybook-demo-sandbox dark">
...
</html>
```
10 changes: 5 additions & 5 deletions guides/sandboxing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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
Expand All @@ -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` ...)
Expand Down Expand Up @@ -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.
Expand Down
29 changes: 20 additions & 9 deletions lib/phoenix_storybook/live/search.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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"
) %>
</.form>
<%= if Enum.empty?(@stories) do %>
<div class="psb psb-text-center psb-text-gray-600 psb-py-4">
<div class="psb psb-text-center psb-text-gray-600 dark:psb-text-slate-300 psb-py-4">
<p>No stories found</p>
</div>
<% end %>
<ul
id="search-list"
class="psb psb-max-h-72 psb-scroll-py-2 psb-divide-y psb-divide-gray-200 psb-overflow-y-auto psb-pb-2 psb-text-sm psb-text-gray-800"
class="psb psb-max-h-72 psb-scroll-py-2 psb-divide-y psb-divide-gray-200 dark:psb-divide-slate-600 psb-overflow-y-auto psb-pb-2 psb-text-sm psb-text-gray-800"
>
<%= for {story, i} <- Enum.with_index(@stories) do %>
<li
id={"story-#{i}"}
phx-highlight={JS.add_class("psb-bg-slate-50 psb-text-indigo-600")}
phx-baseline={JS.remove_class("psb-bg-slate-50 psb-text-indigo-600")}
class="psb psb-flex psb-justify-between psb-group psb-select-none psb-px-4 psb-py-4 psb-space-x-4 psb-cursor-pointer"
phx-highlight={
JS.add_class(
"psb-bg-slate-50 dark:psb-bg-slate-700 psb-text-indigo-600 dark:psb-text-sky-400"
)
}
phx-baseline={
JS.remove_class(
"psb-bg-slate-50 dark:psb-bg-slate-700 psb-text-indigo-600 dark:psb-text-sky-400"
)
}
class="psb psb-flex psb-justify-between psb-group psb-select-none psb-px-4 psb-py-4 psb-space-x-4 psb-cursor-pointer hover:psb-bg-slate-50 dark:hover:psb-bg-slate-700"
tabindex="-1"
phx-click={JS.navigate(Path.join(@root_path, story.path))}
>
<.link
patch={Path.join(@root_path, story.path)}
class="psb psb-font-semibold psb-whitespace-nowrap"
class="psb psb-font-semibold psb-whitespace-nowrap dark:psb-text-slate-300"
>
<%= story.name %>
</.link>
<div class="psb psb-truncate">
<%= LayoutView.render_breadcrumb(@socket, story.path, span_class: "psb-text-xs") %>
<%= LayoutView.render_breadcrumb(@socket, story.path,
span_class: "psb-text-xs dark:psb-text-slate-300"
) %>
</div>
</li>
<% end %>
Expand Down
Loading

0 comments on commit 9d46b43

Please sign in to comment.