lib/theme/theme.ex

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.

  ### Session for theme state has been removed

  Using the session to store the theme state (for example [using a AJAX call roundtrip](https://thepugautomatic.com/2020/05/persistent-session-data-in-phoenix-liveview/)) does not work as expected: when navigating to another LiveView page, the updated session data is not refetched and only becomes available after a page refresh. This means that the previous offered solution with `PrimerLive.ThemeSessionController` is no longer recommended and in fact removed.

  ### Alternatives: database or cache

  If you already have a database set up for storing data by session ID, it's a small step to integrate the theme state with it.

  - [Database Session Store with Elixir and Plug (2021)](https://kimlindholm.medium.com/database-session-store-with-elixir-and-plug-4354740e2f58)

  On the other hand, for a lightweight solution you may find caching simpler to start with. For example [Cachex](https://github.com/whitfin/cachex) (which I've selected for [primer-live.org](https://primer-live.org)).

  - [Cachex with Phoenix (2020)](https://www.alenm.com/code/phoenix-cachex)
  - [Use caching to speed up data loading in Phoenix LiveView](https://fullstackphoenix.com/quick_tips/liveview-caching)

  ## Handling user selection

  Assuming you are providing a [theme menu](`PrimerLive.Component.theme_menu_options/1`) on the website, the option the user selects must be stored in persistent storage.

  Here we're setting event callback "store_theme" in the theme menu:

  ```
  # Some app component

  <.action_menu>
    <:toggle class="btn btn-invisible">
      <.octicon name="sun-16" />
    </:toggle>
    <.theme_menu_options
      theme_state={@theme_state}
      update_theme_event="store_theme"
    />
  </.action_menu>
  ```

  The callback is invoked whenever a theme menu option is clicked. See attr [`update_theme_event`](`PrimerLive.Component.theme_menu_options/1`) for documentation of the arguments.

  ```
  # App LiveView

  def handle_event(
    "store_theme",
    %{"data" => data, "key" => key, "value" => _},
    socket
  ) do
    # Persist new theme state ...

    {:noreply, socket}
  end
  ```


  """

  use Phoenix.Component

  @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"
  }

  @update_theme_event_key "update_theme"
  @reset_key "reset"

  @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"""
  Default event name for the `handle_event` update callback.

  This value can be overridden in `theme_menu_options` with `update_theme_event`:
  ```
  <.theme_menu_options
    theme_state={@theme_state}
    update_theme_event="store_theme"
  />
  ```
  """
  def update_theme_event_key(), do: @update_theme_event_key

  @doc ~S"""
  Default reset link identifier for the `handle_event` update callback.
  """
  def reset_key(), do: @reset_key

  @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"}},{:light_theme, %{options: ["light", "light_high_contrast", "light_colorblind", "light_tritanopia"], selected: "light_high_contrast", title: "Light tone", labeled_options: [{"light", "Light"}, {"light_high_contrast", "Light high contrast"}, {"light_colorblind", "Light colorblind"}, {"light_tritanopia", "Light Tritanopia"}]}},{ :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" }}]

      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))
    |> Enum.sort_by(fn {key, _item} ->
      order =
        case key do
          :color_mode -> 0
          :light_theme -> 1
          :dark_theme -> 2
        end

      order
    end)
  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"""
  Creates HTML (data) attributes from the supplied theme state to set a theme on a component or element directly. This is useful to "theme" specific page parts regardless of the user selected theme, for example a dark page header.

  ```
  <.button
    {PrimerLive.Theme.html_attributes([color_mode: "dark", dark_theme: "dark_high_contrast"])}
  >Button</.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-dark-theme", "dark_high_contrast"}, {:"data-light-theme", "light_high_contrast"}]

      iex> PrimerLive.Theme.html_attributes(
      ...> %{
      ...> },
      ...> %{
      ...>   color_mode: "auto",
      ...>   light_theme: "light",
      ...>   dark_theme: "dark"
      ...> }
      ...> )
      [{:"data-color-mode", "auto"}, {:"data-dark-theme", "dark"}, {:"data-light-theme", "light"}]

      iex> PrimerLive.Theme.html_attributes(
      ...> %{
      ...>   light_theme: "light_high_contrast",
      ...> },
      ...> %{
      ...>   color_mode: "auto",
      ...>   light_theme: "light",
      ...>   dark_theme: "dark"
      ...> }
      ...> )
      [{:"data-color-mode", "auto"}, {:"data-dark-theme", "dark"}, {:"data-light-theme", "light_high_contrast"}]

      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