-
-
Notifications
You must be signed in to change notification settings - Fork 7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
allow customizing modifiers #160
Comments
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) %>
</button>
"""
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 Some considerations/rationale:
Note in the example below the addition of a 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 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 Note the use of a map for the 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
@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 use Doggo, components: [
button: [
modifiers: [
size: ["small", "medium", "large"]
]
]
] That way, the mix task can be updated to take a module attribute ( |
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. |
Right, you would be able to define any modifiers, so if you wanted, you could have a
Default values are included in my examples. |
I started rewriting the components as macros in #291. I decided to implement a separate macro for each component instead of implementing a single |
Doggo.default_options/0
#266use Doggo
#267--module
switch tomix dog.modifiers
to derive modifier classes from Doggo backend #268The last item depends on phenixdigital/phoenix_storybook#402.The text was updated successfully, but these errors were encountered: