Skip to content
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

Open
6 tasks
woylie opened this issue Dec 27, 2023 · 8 comments
Open
6 tasks

allow customizing modifiers #160

woylie opened this issue Dec 27, 2023 · 8 comments
Assignees

Comments

@ahacking
Copy link

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

@woylie woylie self-assigned this Feb 17, 2024
@woylie
Copy link
Owner Author

woylie commented Feb 17, 2024

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.

@ahacking
Copy link

ahacking commented Feb 18, 2024

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:

  • More flexible design atrributes
  • fully checked attribute values
  • attribute docs
  • strings in preference to symbols as that should in theory promote more static compile time markup.
  • The code block approach should result in far less maintenance and need for adding any design attribute support to the unstyled Doggo components.
  • we could also consider removing make_class altogether and let the design layer define this concern, perhaps they want their own prefix or just a direct mapping of value to class.

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

@ahacking
Copy link

ahacking commented Mar 2, 2024

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:

  1. don't use style properties at all, just use class names (this sacrifices attribute checking)
  2. use style properties and directly pass values to class names without any translation or mapping (attribute checking works and tailwind tree shaking works)
  3. where the permitted values on a style property is a map, then the provided attribute value and default value is mapped to the class string. This has attribute value checking and is nicer to use but tailwind will need to also scan the CSS blueprint code and it will see all of the class name strings in the CSS blueprint which means its not possible to omit unused CSS.

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;
    }
  }
}

@woylie
Copy link
Owner Author

woylie commented Mar 3, 2024

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.

@woylie
Copy link
Owner Author

woylie commented Apr 15, 2024

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

@ahacking
Copy link

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.

@woylie
Copy link
Owner Author

woylie commented Apr 15, 2024

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants