tailwind-variants icon indicating copy to clipboard operation
tailwind-variants copied to clipboard

feat: tv component definition w/ config overrides

Open zoobzio opened this issue 8 months ago • 1 comments

Description

I use tailwind-variants to help write a top-level component library that will be used to implement a number of apps across my company. One requirement is that a given component may have different styling depending on what app it is a part of, but the core functionality should not change. This means that I need to be able to override class definitions w/ a prop.

Currently, my only option to extend/override the style is to use a "class" prop to supply alternate styling via the class prop in tv. To help illustrate what I mean, imagine we have a simple button component:

<script setup lang="ts">
const { class } = defineProps<{
  class?: { [K in "base" | "icon" | "label"]?: string }
}>();

const ui = tv({
  base: "base-class",
  slots: {
    icon: "icon-class",
    label: "label-class"
  }
})
</script>

<template>
  <button :class="ui.base({ class: class?.base })">
    <i :class="ui.icon({ class: class?.icon })" /> 
    <span :class="ui.label({ class: class?.label })">
      <slot />
    </span>
  </button>
</template>

This is great because my button's tv config needs to support a base & icon/label slots, but as this method relies on tailwind-merge we will always face the possibility of inheriting the classes in our original tv component config. What I really need is the ability to define an override that has the same structure as the original config but replaces the underlying class definitions.

This PR introduces that capability through the new defineTV function. I extracted the options config type for the tv & createTV functions into it's own type & added the defineTV function which accepts the same initial arguments that would be passed if we were defining a tv object & returns another function that accepts an optional override argument along w/ the props argument to set variant types & so on.

What this does is let us define our tv template in our component, accept an optional override prop from the downstream application, & instantiate a tv object at runtime that runs a merged config:

<script lang="ts">
const useUI = defineTV({
  base: "base-class",
  slots: {
    icon: "icon-class",
    label: "label-class"
  }
})
</script>

<script setup lang="ts">
const { 
  override,
  class
} = defineProps<{
  override?: Parameters<typeof useUI>[0];
  variants?: Parameters<typeof useUI>[1];
  class?: { [K in "base" | "icon" | "label"]?: string }
}>();

const ui = useUI(override, variants)
</script>

<template>
  <button :class="ui.base({ class: class?.base })">
    <i :class="ui.icon({ class: class?.icon })" /> 
    <span :class="ui.label({ class: class?.label })">
      <slot />
    </span>
  </button>
</template>

Now when I use this button in an app, I can include a tv configuration that is tightly coupled to the structure of the underlying component that allows for total customization of the applied classes!

Solution

The first step was extracting the options type that is accepted by the tv function into a separate type. I then defined a TVOverride type that accepts all of the same generic arguments as the tv function but the value of the type is a structure that directly reflects the root configuration.

To facilitate simpler merges, I introduced the defu dependency which performs recursive assignment but w/ a flexible API. I used defu to refactor the mergeObjects function as well as define a merger for the tv options config.

The new defineTV function accepts the same args tv does, but instead of returning the TV return type we return a function that can override the config before passing the TV return type. The result is a fully customizable component structure for layered environments!

Notes

I have had this working to great effect in my project for a while, so I wanted to adapt it to be a part of this package so that it can be used by the wider community.

If you have any questions or need me to make changes, please let me know!

What is the purpose of this pull request?

  • [ ] Bug fix
  • [X] New Feature
  • [ ] Documentation update
  • [ ] Other

Before submitting the PR, please make sure you do the following

  • [X] Read the Contributing Guidelines.
  • [X] Follow the Style Guide.
  • [X] Check that there isn't already a PR that solves the problem the same way to avoid creating a duplicate.
  • [X] Provide a description in this PR that addresses what the PR is solving, or reference the issue that it solves (e.g. fixes #123).

zoobzio avatar Feb 25 '25 02:02 zoobzio