Tableau Theming
View SourceIntroduction
You add a multi-theme picker to a Tableau site that already follows Tableau. Visitors get data-theme on <html>, a before-paint script, a Corex <.select>, and theme.js for localStorage and corex:set-theme.
For Phoenix apps with Plugs.Theme and cookies, see Theming.
Before you start
| Requirement | Notes |
|---|---|
| Tableau | Design assets, ESM Esbuild, use Corex, LiveSocket |
{:jason, "~> 1.0"} | For Jason.encode!/1 in head_script/0 |
How it works
- Config lists allowed theme names; CSS must
@importeach theme file you expose. head_script/0runs in<head>and setsdata-themefromlocalStoragebefore paint.theme.jssyncs the picker after hydration and listens forcorex:set-theme.<.select id="theme-switcher">dispatchescorex:set-themeon change.
Config
In config/config.exs:
config :my_app,
site_name: "MyApp",
themes: ~w(neo uno duo leo),
default_theme: "neo"Only list themes you import in CSS. The first entry in themes is the fallback when nothing is stored.
Elixir
lib/my_app/config.ex (merge with locale fields if you use Tableau Localize):
defmodule MyApp.Config do
@app :my_app
def site_name, do: Application.get_env(@app, :site_name, "MyApp")
def themes, do: Application.get_env(@app, :themes, ["neo"])
def default_theme do
Application.get_env(@app, :default_theme) || List.first(themes()) || "neo"
end
endlib/my_app/theme.ex:
defmodule MyApp.Theme do
def themes, do: MyApp.Config.themes()
def default_theme, do: MyApp.Config.default_theme()
def head_script do
themes_json = Jason.encode!(themes())
default_theme_json = Jason.encode!(default_theme())
Phoenix.HTML.raw("""
<script>
try {
const themes = #{themes_json};
const dt = #{default_theme_json};
const t = localStorage.getItem("data-theme");
const theme = themes.includes(t) ? t : dt;
document.documentElement.setAttribute("data-theme", theme);
} catch (_) {}
</script>
""")
end
def current(assigns) do
list = themes()
d = default_theme()
case Map.get(assigns, :theme) do
t when is_binary(t) -> if(t in list, do: t, else: d)
_ -> d
end
end
def select_items do
themes()
|> Enum.map(fn t -> %{value: t, label: String.capitalize(t)} end)
|> Corex.List.new()
end
endCSS
Add select.css and each theme file to assets/css/site.css (after the Tableau baseline imports):
@import "../corex/components/select.css";
@import "../corex/theme/uno.css";
@import "../corex/theme/duo.css";
@import "../corex/theme/leo.css";Skip extra @imports if you only ship neo.
Layout
In RootLayout.template/1, before ~H:
assigns = Map.put(assigns, :theme, MyApp.Theme.current(assigns))On <html>:
<html
lang="en"
dir="ltr"
data-theme={@theme}
data-themes={Enum.join(MyApp.Config.themes(), ",")}
data-default-theme={MyApp.Config.default_theme()}
>In <head>, before stylesheets if you want the earliest paint (after charset/viewport is fine):
{MyApp.Theme.head_script()}When you add Tableau Localize, set lang and dir from your locale module instead of fixed en / ltr.
theme.js
Create assets/js/theme.js:
;(() => {
const html = () => document.documentElement
const parseList = (attr) =>
(html().getAttribute(attr) || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
const whenControlReady = (id, run) => {
const iv = window.setInterval(() => {
const root = document.getElementById(id)
if (root && !root.hasAttribute("data-loading")) {
window.clearInterval(iv)
run()
}
}, 10)
window.setTimeout(() => window.clearInterval(iv), 10_000)
}
const firstDetailValue = (e) => {
const value = e.detail?.value
return Array.isArray(value) && value[0] ? value[0] : null
}
const validThemes = () => parseList("data-themes")
const defaultTheme = () =>
html().getAttribute("data-default-theme") || validThemes()[0] || "neo"
const readStoredTheme = () => localStorage.getItem("data-theme")
const syncThemeSelect = (value) => {
const root = document.getElementById("theme-switcher")
if (!root || !value) return
root.dispatchEvent(
new CustomEvent("corex:select:set-value", { detail: { value: [value] } }),
)
}
const applyTheme = (theme) => {
const themes = validThemes()
const dt = defaultTheme()
const resolved = themes.includes(theme) ? theme : dt
localStorage.setItem("data-theme", resolved)
html().setAttribute("data-theme", resolved)
return resolved
}
const syncThemeFromDocument = () => {
const t = html().getAttribute("data-theme") || defaultTheme()
const themes = validThemes()
const dt = defaultTheme()
syncThemeSelect(themes.includes(t) ? t : dt)
}
applyTheme(
readStoredTheme() || html().getAttribute("data-theme") || defaultTheme(),
)
whenControlReady("theme-switcher", syncThemeFromDocument)
window.addEventListener("storage", (e) => {
if (e.key === "data-theme" && e.newValue) {
applyTheme(e.newValue)
whenControlReady("theme-switcher", syncThemeFromDocument)
}
})
window.addEventListener("corex:set-theme", (e) => {
const v = firstDetailValue(e)
applyTheme(v || defaultTheme())
whenControlReady("theme-switcher", syncThemeFromDocument)
})
})()site.js
At the top of assets/js/site.js:
import "./theme.js"Register Select in hooks:
import { Socket } from "phoenix"
import { LiveSocket } from "phoenix_live_view"
import { hooks } from "corex/hooks"
import "./theme.js"
const csrfToken = document
.querySelector("meta[name='csrf-token']")
?.getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: { _csrf_token: csrfToken },
hooks: {
...hooks({
Select: () => import("corex/select"),
Accordion: () => import("corex/accordion"),
}),
},
})
liveSocket.connect()Picker
Place in your header or toolbar. id="theme-switcher" must match theme.js.
<.select
id="theme-switcher"
class="select select--sm"
dir="ltr"
items={MyApp.Theme.select_items()}
value={[@theme]}
close_on_select={false}
update_trigger={false}
on_value_change_client="corex:set-theme"
translation={%Corex.Select.Translation{placeholder: "Theme"}}
>
<:label class="sr-only">Theme</:label>
<:item :let={item}>{item.label}</:item>
<:trigger>
<.heroicon name="hero-swatch" class="icon" />
</:trigger>
<:item_indicator>
<.heroicon name="hero-check" class="icon" />
</:item_indicator>
</.select>Related
- Tableau — baseline setup
- Tableau Mode —
data-mode; callTheme.head_script()thenMode.head_script()in<head>when both are used - Theming — Phoenix plug and cookie flow