defmodule PrimerLive.Theme do @moduledoc """ Primer CSS contains styles for light/dark color modes and themes, with support for color blindness. PrimerLive provides components and functions to work with themes: - [`theme/1`](`PrimerLive.Component.theme/1`) - wrapper to set the theme on child elements - [`theme_menu_options/1`](`PrimerLive.Component.theme_menu_options/1`) - contents for a theme menu - `html_attributes/2` - HTML attributes to set a theme on a component or element directly ## Persistency There is no easy way to save persistent session data in LiveView because LiveView's state is stored in a process that ends when the page is left. The solution is to use an Ajax request to our Phoenix app, which updates the session. This setup involves 5 steps, but the provided helper functions make it a bit easier. ### 1. Create a SessionController Create file `controllers/session_controller.ex`: ``` defmodule MyAppWeb.SessionController do use MyAppWeb, :controller use PrimerLive.ThemeSessionController end ``` `PrimerLive.ThemeSessionController` will add the received theme request data to the session. ### 2. Add SessionController to the router's api ``` scope "/api", MyAppWeb do pipe_through :api post PrimerLive.Theme.session_route(), SessionController, :set end ``` Optionally set the `max_age` in `endpoint.ex`: ``` @session_options [ ... # Over 300 years. max_age: 9_999_999_999 ] ``` ### 3. Add ThemeMenu hook In `app.js`, import `ThemeMenu` and add it to the `liveSocket` hooks: ``` import { Prompt, ThemeMenu } from 'primer-live'; const hooks = {}; hooks.Prompt = Prompt; hooks.ThemeMenu = ThemeMenu; let liveSocket = new LiveSocket('/live', Socket, { params: { _csrf_token: csrfToken }, hooks, }); ``` You may the hook anywhere, but only once for the entire application. If you have a single theme menu, add it there: ``` <.action_menu phx-hook="ThemeMenu" id="theme_menu"> ... ``` ### 4. Initialise the theme from session data In the LiveView's `mount`, call `add_to_socket`: ``` def mount(_params, session, socket) do socket = socket |> PrimerLive.Theme.add_to_socket(session) {:ok, socket} end ``` This reads the theme state and adds it to the socket assigns. Optionally set the default theme in the seconds argument: ``` PrimerLive.Theme.add_to_socket(session, %{ color_mode: "light", light_theme: "light", dark_theme: "dark" }) ``` ### 5. Handle update events In the LiveView where the action menu resides, add: ``` use PrimerLive.ThemeEvent ``` This implements function `handle_event` for "update_theme" (which is called by clicks on the menu's `action_list` items). The function updates the socket and sends the event that is picked by by JavaScript (via the `ThemeMenu` hook). """ use Phoenix.Component @session_key "pl-session" @session_theme_key "theme" @reset_key "reset" @update_theme_event_key "update_theme" @default_theme_state %{ color_mode: "auto", light_theme: "light", dark_theme: "dark" } @default_menu_options %{ color_mode: ~w(light dark auto), light_theme: ~w(light light_high_contrast light_colorblind light_tritanopia), dark_theme: ~w(dark dark_dimmed dark_high_contrast dark_colorblind dark_tritanopia) } @default_menu_labels %{ color_mode: %{ title: "Theme", light: "Light", dark: "Dark", auto: "System" }, light_theme: %{ title: "Light tone", light: "Light", light_high_contrast: "Light high contrast", light_colorblind: "Light colorblind", light_tritanopia: "Light Tritanopia" }, dark_theme: %{ title: "Dark tone", dark: "Dark", dark_dimmed: "Dark dimmed", dark_high_contrast: "Dark high contrast", dark_colorblind: "Dark colorblind", dark_tritanopia: "Dark Tritanopia" }, reset: "Reset to default" } @doc ~S""" Generic key used for: - session route - JS event name ("phx:" prefix is assigned automatically) """ def session_key(), do: @session_key @doc ~S""" Route for session api calls. For example: ``` scope "/api", MyAppWeb do pipe_through :api post PrimerLive.Theme.session_route(), SessionController, :set end ``` """ def session_route(), do: "/#{@session_key}" @doc ~S""" Theme data stored in the session. """ def session_theme_key(), do: @session_theme_key @doc ~S""" Internal use. Reset link identifier to distinguish in update. """ def reset_key(), do: @reset_key @doc ~S""" Internal use. Update link identifier. """ def update_theme_event_key(), do: @update_theme_event_key @doc ~S""" Initial theme state. """ def default_theme_state(), do: @default_theme_state @doc ~S""" Default options for a theme menu. """ def default_menu_options(), do: @default_menu_options @doc ~S""" Default label for a theme menu. """ def default_menu_labels(), do: @default_menu_labels @doc ~S""" Configures menu options from supplied params: - theme: the current theme state (used to define the selected menu items) - menu_options: which menu options will be displayed - menu_labels: overrides of default text labels Returns a list with 3 menu elements: - color_mode - dark_theme - light_theme Each list item is a map that contains display attributes: - group label - option labels - the selected item ## Tests iex> PrimerLive.Theme.create_menu_items( ...> %{ ...> color_mode: "light", ...> light_theme: "light_high_contrast", ...> dark_theme: "dark_high_contrast" ...> }, ...> PrimerLive.Theme.default_menu_options(), ...> PrimerLive.Theme.default_menu_labels() ...> ) [color_mode: %{labeled_options: [{"light", "Light"}, {"dark", "Dark"}, {"auto", "System"}], options: ["light", "dark", "auto"], selected: "light", title: "Theme"}, dark_theme: %{labeled_options: [{"dark", "Dark"}, {"dark_dimmed", "Dark dimmed"}, {"dark_high_contrast", "Dark high contrast"}, {"dark_colorblind", "Dark colorblind"}, {"dark_tritanopia", "Dark Tritanopia"}], options: ["dark", "dark_dimmed", "dark_high_contrast", "dark_colorblind", "dark_tritanopia"], selected: "dark_high_contrast", title: "Dark tone"}, light_theme: %{labeled_options: [{"light", "Light"}, {"light_high_contrast", "Light high contrast"}, {"light_colorblind", "Light colorblind"}, {"light_tritanopia", "Light Tritanopia"}], options: ["light", "light_high_contrast", "light_colorblind", "light_tritanopia"], selected: "light_high_contrast", title: "Light tone"}] iex> PrimerLive.Theme.create_menu_items( ...> %{ ...> color_mode: "light", ...> light_theme: "light_high_contrast", ...> dark_theme: "dark_high_contrast" ...> }, ...> %{ ...> color_mode: ~w(light dark) ...> }, ...> %{ ...> color_mode: %{ ...> light: "Light theme" ...> }, ...> reset: "Reset" ...> }) [color_mode: %{labeled_options: [{"light", "Light theme"}, {"dark", "Dark"}], options: ["light", "dark"], selected: "light", title: "Theme"}] """ def create_menu_items(theme, menu_options, menu_labels) do menu_options |> Enum.map(&move_options_to_field/1) |> Enum.map(&add_selected_state(&1, theme)) |> Enum.map(&add_labels(&1, menu_labels)) end defp move_options_to_field({key, options}) do {key, %{options: options}} end defp add_selected_state({key, item_group}, theme) do selected = get_in(theme, [Access.key!(key)]) {key, item_group |> Map.put(:selected, selected)} end defp add_labels({key, item_group}, menu_labels) do merged_labels = Map.merge(@default_menu_labels[key], menu_labels[key] || %{}) {key, item_group |> Map.put( :labeled_options, item_group.options |> Enum.map( &add_option_label( &1, merged_labels ) ) ) |> Map.put(:title, merged_labels[:title])} end defp add_option_label(option, group_labels) do {option, group_labels[String.to_existing_atom(option)]} end @doc ~S""" Compares the supplied state with the supplied default state. ## Tests iex> PrimerLive.Theme.is_default_theme( ...> %{ ...> color_mode: "auto", ...> light_theme: "light", ...> dark_theme: "dark" ...> }, ...> PrimerLive.Theme.default_theme_state() ...> ) true iex> PrimerLive.Theme.is_default_theme( ...> %{ ...> color_mode: "light", ...> light_theme: "light_high_contrast", ...> dark_theme: "dark_high_contrast" ...> }, ...> PrimerLive.Theme.default_theme_state() ...> ) false """ def is_default_theme(theme, default_theme_state) do Map.equal?(theme, default_theme_state) end @doc ~S""" Adds `theme_state` and `default_theme_state` to `socket.assigns`. """ def add_to_socket(socket, session, default_theme_state) do socket |> assign(:theme_state, theme_state_from_session(session, default_theme_state)) |> assign(:default_theme_state, default_theme_state) end @doc """ See `add_to_socket/3`. """ def add_to_socket(socket, session), do: add_to_socket(socket, session, @default_theme_state) defp theme_state_from_session(data, default_theme_state) when not is_map_key(data, @session_theme_key), do: default_theme_state defp theme_state_from_session(%{@session_theme_key => json}, default_theme_state) do case Jason.decode(json) do {:ok, theme} -> theme |> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end) _ -> default_theme_state end end @doc ~S""" Returns an updated theme state by putting the supplied data in the theme state. If key is the reset key, returns the default theme state. """ def update( _theme_state, %{"key" => @reset_key, "data" => _data}, default_theme_state ), do: default_theme_state def update(theme_state, %{"key" => key, "data" => data}, _default_theme_state) do Map.put(theme_state, String.to_existing_atom(key), data) end @doc ~S""" Creates HTML (data) attributes from the supplied theme state to set a theme on a component or element directly: ``` <.button {PrimerLive.Theme.html_attributes([color_mode: "dark", dark_theme: "dark_high_contrast"])} >Button <.octicon name="sun-24" {PrimerLive.Theme.html_attributes(%{color_mode: "dark", dark_theme: "dark_dimmed"})} /> ``` ## Tests iex> PrimerLive.Theme.html_attributes( ...> %{ ...> color_mode: "light", ...> light_theme: "light_high_contrast", ...> dark_theme: "dark_high_contrast" ...> } ...> ) [data_color_mode: "light", data_light_theme: "light_high_contrast", data_dark_theme: "dark_high_contrast"] iex> PrimerLive.Theme.html_attributes( ...> %{ ...> }, ...> %{ ...> color_mode: "auto", ...> light_theme: "light", ...> dark_theme: "dark" ...> } ...> ) [data_color_mode: "auto", data_light_theme: "light", data_dark_theme: "dark"] iex> PrimerLive.Theme.html_attributes( ...> %{ ...> light_theme: "light_high_contrast", ...> }, ...> %{ ...> color_mode: "auto", ...> light_theme: "light", ...> dark_theme: "dark" ...> } ...> ) [data_color_mode: "auto", data_light_theme: "light_high_contrast", data_dark_theme: "dark"] iex> PrimerLive.Theme.html_attributes( ...> %{ ...> }, ...> %{ ...> color_mode: "auto", ...> } ...> ) [data_color_mode: "auto"] """ def html_attributes(theme_state, default_theme_state) do data_color_mode = theme_state[:color_mode] || default_theme_state[:color_mode] data_light_theme = theme_state[:light_theme] || default_theme_state[:light_theme] data_dark_theme = theme_state[:dark_theme] || default_theme_state[:dark_theme] PrimerLive.Helpers.AttributeHelpers.append_attributes([ data_color_mode && [data_color_mode: data_color_mode], data_light_theme && [data_light_theme: data_light_theme], data_dark_theme && [data_dark_theme: data_dark_theme] ]) end @doc """ See `html_attributes/2`. """ def html_attributes(theme_state), do: html_attributes(theme_state, @default_theme_state) end