Closed woylie closed 2 months ago
I have done some investigation on this to enable a CSS Blueprint layer to specify its own design tokens and it is working.
After many different attempts, the approach I managed to get working was to make the base unstyled components macros, which can be called by the styled/CSS blueprint layer to create the actual component.
Styled component example, where the CSS blueprint can dictate the values for the attributes and their defaults:
defmodule HelloWorldWeb.StyledComponents do
use Phoenix.Component
import HelloWorldWeb.BaseComponents
@variants ~w[primary secondary info success warning danger brand signup social]
@fills ~w[solid outline glass ghost]
@shapes ~w[rectangle rounded pill circle square]
@sizes ~w[xs sm md lg xl full-width]
styled_component(:button,
variant: [values: @variants, default: "primary"],
fill: [values: @fills, default: "solid"],
shape: [values: @shapes, default: "rounded"],
size: [values: @sizes, default: "md"]
)
end
Unstyled semantic button component:
defmodule HelloWorldWeb.BaseComponents do
use Phoenix.Component
defmacro styled_component(:button, styles) do
quote do
@doc """
Renders a button.
Use this component when you need to perform an action that doesn't involve
navigating to a different page, such as submitting a form, confirming an
action, or deleting an item.
If you need to navigate to a different page or a specific section on the
current page and want to style the link like a button, use `button_link/1`
instead.
See also `button_link/1`, `toggle_button/1`, and `disclosure_button/1`.
## Examples
```heex
<Doggo.button>Confirm</Doggo.button>
<Doggo.button type="submit" variant={:secondary} size={:medium} shape={:pill}>
Submit
</Doggo.button>
To indicate a loading state, for example when submitting a form, use the
`aria-busy` attribute:
```heex
<Doggo.button aria-label="Saving..." aria-busy>
click me
</Doggo.button>
```
"""
@doc type: :button
@doc since: "0.1.0"
attr :type, :string, values: ["button", "reset", "submit"], default: "button"
attr :disabled, :boolean, default: nil
attr :rest, :global, include: ~w(autofocus form name value)
style_attr(unquote(styles), :variant)
style_attr(unquote(styles), :fill)
style_attr(unquote(styles), :shape)
style_attr(unquote(styles), :size)
slot :inner_block, required: true
def button(assigns), do: render_button(assigns)
end
end
def render_button(assigns) do ~H""" <button type={@type} class={[make_class(@variant), make_class(@size), make_class(@shape), make_class(@fill)]} disabled={@disabled} {@rest}
<%= render_slot(@inner_block) %> """ end
defmacro style_attr(kl, attr) do quote do attr unquote(attr), :string, values: unquote(style_values(kl, attr)), default: unquote(style_default(kl, attr)) end end
defp style_values(kl, key), do: Keyword.get(Keyword.get(kl, key), :values) defp style_default(kl, key), do: Keyword.get(Keyword.get(kl, key), :default)
defp make_class(nil), do: nil defp make_class(modifier), do: "is-#{modifier}" end
Nice, thanks, that looks very close to what I had in mind. I added some more details to the original issue comment. Let me know if you have any more thoughts on this, your input is very useful.
I have also experimented with a more flexible approach.
This leaves ALL the design attributes up to the CSS blueprint layer so there is no fixed set of design attributes.
This approach uses a code block to allow the CSS blueprint to define whatever style attributes they need through a style_attr
macro which passes the keyword list directly through to attr
, so full control, even doc
option.
Some considerations/rationale:
Note in the example below the addition of a border
style and design tokens thin
, regular
and thick
:
defmodule HelloWorldWeb.StyledComponents do
use Phoenix.Component
import HelloWorldWeb.BaseComponents
@variants ~w[primary secondary info success warning danger]
@fills ~w[solid outline glass ghost]
@shapes ~w[rectangle rounded pill circle square]
@sizes ~w[xs sm md lg xl full-width]
@borders ~w[thin regular thick]
styled_component :button do
style_attr :variant, values: @variants, default: "primary"
style_attr :fill, values: @fills, default: "solid"
style_attr :shape, values: @shapes, default: "rounded"
style_attr :size, values: @sizes, default: "md"
style_attr :border, values: @borders, default: "regular", doc: "Border design"
end
end
The more flexible base component macros:
defmodule HelloWorldWeb.BaseComponents do
use Phoenix.Component
defmacro styled_component(:button, style_block) do
quote do
@doc """
Renders a button.
"""
@doc type: :button
@doc since: "0.1.0"
attr :type, :string, values: ["button", "reset", "submit"], default: "button"
attr :disabled, :boolean, default: nil
attr :rest, :global, include: ~w(autofocus form name value)
attr :class, :string, default: nil
@style_attrs []
unquote(style_block)
slot :inner_block, required: true
def button(assigns), do: render_button(@style_attrs, assigns)
end
end
def render_button(style_attrs, assigns) do
styles =
assigns
|> Map.take(style_attrs)
|> Map.values()
|> Enum.map(&make_class(&1))
assigns =
assigns
|> assign(:styles, styles)
~H"""
<button type={@type} class={[@styles, @class]} disabled={@disabled} {@rest}>
<%= render_slot(@inner_block) %>
</button>
"""
end
defmacro style_attr(style_attr, kl) do
quote do
@style_attrs [unquote(style_attr) | @style_attrs]
attr unquote(style_attr), :string, unquote(kl)
end
end
defp make_class(nil), do: nil
defp make_class(modifier), do: "is-#{modifier}"
end
Just to provide an update of some further experimentation....
I have found that with tailwind you can't use computed class names because of the way code scanning and tree shaking works. Whilst you can compute classes to use, you can't build the class names from strings. Tailwind needs to see the full string.
So my example above does not work (unless you are using explicit class=".... is-foo ..." elsewhere on other elements in the project (which I was when I tested the above). As soon as one removes the full static class name from the code, tailwind doesn't emit the CSS rules for any custom CSS selectors , such as those based on .is-foo
because it never sees "is-foo".
This is documented here: [https://tailwindcss.com/docs/content-configuration#dynamic-class-names].
Now whilst one can "safelist" classes to make sure they are always included, that creates an additional burden and also defeats tree shaking based on what is used. However having all the class strings in the code also implies that tailwind will include them even if not used, in particular if an app that was to use my CSS blueprint hex package, pointing tailwind at it will necessary include every variant in the output whether it is used by the app or not.
A way to make tree shaking work is to not point tailwind at the CSS blueprint elixir source and only consider the actual app source. This can only work by relying on tailwind string detection within the app source when using full class names, not maps.
So it seems to be a mandatory requirement that the CSS blueprint must have full control of the class name used.
A summary of the options:
I have reworked the prior example to support all the above, however option 2 is the best if you want CSS tree shaking, but slightly less clean size="size-xs"
vs using a map size="xs"
. If using the class attribute directly it works well (but forgoes attribute value checking) class="size-xs fill-solid secondary"
Note the use of a map for the size:
attribute below, and the default value also being the friendly name.
defmodule HelloWorldWeb.StyledComponents do
use Phoenix.Component
import HelloWorldWeb.BaseComponents
# full class name used directly
@variants ~w[primary secondary info success warning danger]
@fills ~w[fill-solid fill-outline fill-glass fill-ghost]
@shapes ~w[shape-pill shape-circle shape-square]
# size variants mapped to a CSS class
@sizes %{
"xs" => "size-xs",
"sm" => "size-sm",
"md" => "size-md",
"lg" => "size-lg",
"xl" => "size-xl"
}
# ~w[xs sm md lg xl full-width]
@borders ~w[thin regular thick]
styled_component :button do
style_attr(:variant, values: @variants, default: "primary")
style_attr(:fill, values: @fills, default: "fill-solid")
style_attr(:shape, values: @shapes, required: false)
style_attr(:size, values: @sizes, default: "md")
style_attr(:border, values: @borders, default: "regular", doc: "Border design")
end
end
And the base component macros to support both mapped values and direct values:
defmodule HelloWorldWeb.BaseComponents do
use Phoenix.Component
defmacro styled_component(:button, style_block) do
quote do
@doc """
Renders a button.
"""
@doc type: :button
@doc since: "0.1.0"
attr :type, :string, values: ["button", "reset", "submit"], default: "button"
attr :disabled, :boolean, default: nil
attr :rest, :global, include: ~w(autofocus form name value)
attr :class, :string, default: nil
@style_attrs []
unquote(style_block)
slot :inner_block, required: true
def button(assigns), do: render_button(@style_attrs, assigns)
end
end
def render_button(style_attrs, assigns) do
assigns = add_style_assigns(style_attrs, assigns)
~H"""
<button type={@type} class={[@styles, @class]} disabled={@disabled} {@rest}>
<%= render_slot(@inner_block) %>
</button>
"""
end
def add_style_assigns(style_attrs, assigns) do
styles =
Enum.map(style_attrs, fn {attr, values} ->
value = Map.get(assigns, attr)
case values do
values when is_map(values) -> Map.get(values, value)
_values -> value
end
end)
assign(assigns, :styles, styles)
end
defmacro style_attr(style_attr, kl) do
quote do
values = Keyword.get(unquote(kl), :values)
@style_attrs [{unquote(style_attr), values} | @style_attrs]
kl =
case values do
values when is_map(values) ->
Keyword.merge(
unquote(kl),
values: Map.keys(values),
default: Keyword.get(unquote(kl), :default)
)
values when is_list(values) ->
Keyword.put(unquote(kl), :values, values)
nil ->
unquote(kl)
end
attr unquote(style_attr), :string, kl
end
end
end
Then in CSS I use the following convention using style-value
as the selector:
@layer components {
button {
/*
* button size variants
*/
&.size-xs {
@apply h-[1.5rem] min-h-[1.5rem] rounded-[4px] px-2 text-xs;
}
&.size-sm {
@apply h-8 min-h-[2rem] rounded-md px-3 text-sm;
}
&.size-md {
@apply h-10 min-h-[2.5rem] px-5 text-sm;
}
&.size-lg {
@apply h-12 min-h-[3rem] px-6 text-lg;
}
&.size-xl {
@apply h-16 min-h-[4rem] rounded-2xl px-8 text-xl;
}
}
}
Currently, we have mix dog.modifiers to save all configured modifiers to a file, which can then be added to the PurgeCSS configuration. Since you're working off a design system and would only configure the officially sanctioned modifiers, you shouldn't have too many unused ones in there. The plan is to have one single configuration for all Doggo components and a __using__
macro to generate all components for you, very roughly:
use Doggo, components: [
button: [
modifiers: [
size: ["small", "medium", "large"]
]
]
]
That way, the mix task can be updated to take a module attribute (mix dog.modifiers --module MyApp.Doggo --output assets/modifiers.txt
), and we can also generate the storybooks with the configured modifiers.
To expand a bit here, this is what a configuration that includes all the component details could look like:
[
default_modifiers: [
shape: [
values: [nil, :circle, :pill],
default: nil
],
size: [
values: [:small, :normal, :medium, :large],
default: :normal
],
variant: [
values: [nil, :primary, :secondary, :info, :success, :warning, :danger],
default: nil
]
],
# Function that takes the property name (:size, :variant) and the value
# (:small, :medium) and returns the class name
# (e.g. "is-small" or "size-small")
modifier_class_fun: &Doggo.build_modifier_class/2,
components: [
badge: [
base_class: "badge",
# use values and defaults declared with `default_modifiers`
modifiers: [:size, :variant]
],
button: [
base_class: "button",
modifiers: [
:shape,
# override defaults
size: [
values: [:small, :normal],
default: :normal
],
variant: [
values: [:primary, :secondary, :danger],
default: :primary
]
]
],
# button component with different name and base class <.cta_button />
button: [
name: :cta_button,
base_class: "cta-button",
modifiers: []
]
]
]
To define just a single component:
defmodule MyApp.Components do
Doggo.Components.badge(
base_class: "badge",
modifiers: [
# override defaults
size: [
values: [:small, :normal],
default: :normal
],
variant: [
values: [:primary, :secondary, :danger],
default: :primary
]
]
)
end
To define all components with defaults:
defmodule MyApp.Components do
use Doggo
end
To define all or a subset of components with config overrides:
defmodule MyApp.Components do
use Doggo,
default_modifiers: [
size: [
values: [:small, :normal, :medium, :large],
default: :normal
],
variant: [
values: [
nil,
:primary,
:secondary,
:info,
:success,
:warning,
:danger
],
default: nil
]
],
# Functions that takes the property name (:size, :variant) and the value
# (:small, :medium) and returns the class name
# (e.g. "is-small" or "size-small")
modifier_class_fun: &Doggo.build_modifier_class/2,
components: [
badge: [
base_class: "badge",
modifiers: [:size, :variant]
],
button: [
base_class: "button",
modifiers: [
size: [
values: [:small, :normal],
default: :normal
],
variant: [
values: [:primary, :secondary, :danger],
default: :primary
]
]
]
]
end
The examples above use atom values as in the current release, but they might as well be strings.
The same can of course be expressed with a DSL. Based on your example above, we can probably go for a friendlier naming:
@variants ~w[primary secondary info success warning danger]
@fills ~w[solid outline glass ghost]
@shapes ~w[rectangle rounded pill circle square]
@sizes ~w[xs sm md lg xl full-width]
@borders ~w[thin regular thick]
component :button do
modifier :variant, values: @variants, default: "primary"
modifier :fill, values: @fills, default: "solid"
modifier :shape, values: @shapes, default: "rounded"
modifier :size, values: @sizes, default: "md"
modifier :border, values: @borders, default: "regular", doc: "Border design"
end
Maybe modifiers could be defined globally like this:
modifier :size, values: ~w(small, normal, medium, large), default: "normal"
modifier :variant, values: [nil, "primary", "secondary"], default: nil
# or maybe like this? or `optional: true`?
modifier :variant, values: ~w(primary, secondary), default: nil, null: true
# <.button />
component :button do
# use the global values
modifier :variant
# override the default sizes
modifier :size, values: ~w(small, normal)
end
# <.cta_button />
component :button, name: :cta_button do
modifier :size, values: ~w(normal, large)
end
This looks good and nicely integrated with the storybook and tree shaking.
Hoping I understand this correctly, that with this approach a CSS blueprint can define whatever modifiers it wishes, and no variants are defined in Doggo aside from the capability to support modifiers?
So for example the sizes could be any range of sizes, or perhaps there is a border roundness modifier, or a transparency modifier and none of these design concerns are baked into Doggo, merely the mechanism of supporting arbitrary modifiers by a CSS blueprint?
My question (and possibly a gap) is where would a modifiers default value, required/optionality and doc be defined? The approach I prototyped above still give full control to the CSS library to define all those keys on the style/modifier attrs but I don't see support for this in the modifiers approach.
Hoping I understand this correctly, that with this approach a CSS blueprint can define whatever modifiers it wishes, and no variants are defined in Doggo aside from the capability to support modifiers?
So for example the sizes could be any range of sizes, or perhaps there is a border roundness modifier, or a transparency modifier and none of these design concerns are baked into Doggo, merely the mechanism of supporting arbitrary modifiers by a CSS blueprint?
Right, you would be able to define any modifiers, so if you wanted, you could have a taste
modifier with the values good
and bad
. The base class would also be optional. I would probably still offer an optional default configuration in some form, so that you can get started quickly.
My question (and possibly a gap) is where would a modifiers default value, required/optionality and doc be defined? The approach I prototyped above still give full control to the CSS library to define all those keys on the style/modifier attrs but I don't see support for this in the modifiers approach.
Default values are included in my examples. required
and doc
would just be other options.
I started rewriting the components as macros in #291. I decided to implement a separate macro for each component instead of implementing a single component
, so that we get a clear library documentation. ~I'm not sure how to generate storybooks in this setup yet. Maybe~ we ~can~ accumulate module attributes to collect the modifiers and generate introspection functions in a before_compile
hook.
~The last item depends on https://github.com/phenixdigital/phoenix_storybook/issues/402.~