Skip to content

feat: tv component definition w/ config overrides #242

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

zoobzio
Copy link

@zoobzio zoobzio commented Feb 25, 2025

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
  • New Feature
  • Documentation update
  • Other

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

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

@AGS1130
Copy link

AGS1130 commented Mar 18, 2025

This is a welcoming feature for Tailwind Variants! To give the repository owners a quick perspective, this feature will be VERY beneficial for library authors.

At my company, we are facing a similar scenario: we need to create a design system to share our component library across multiple teams and multiple web properties. We decided to utilize a headless UI library, Ark UI, so designers and front end engineers working with components will have the ability to quickly develop and greater freedom to style, while maintaining the accessibility, functionality, and standard supported by the authors of Chakra UI. Naturally, we wanted to "buy into" the Chakra UI ecosystem and looked into Panda CSS. It's a very promising library, but in short there were a lot of drawbacks upon implementing. A huge drawback is it does not have a major release (1.x.x) which means the API can change, and that is a very hard buy-in for stakeholders in a big tech company.

With that said, we've also looked into using Tailwind Variants which has great simplicity for structuring styles for complex components, overriding styles, and extending styles in a local project. However, Tailwind Variants cannot be shipped as an external package with a component library because we cannot override a tv options/config DURING runtime. We know this because authoring style overrides for components in Hero UI, whether in the theme configuration or in the component itself, is vastly different than in Nuxt UI because the authors of Nuxt UI created a plugin to inject tv objects into their runtime. The Nuxt UI approach is very similar to what @zoobzio has developed, but instead of creating a third-party plugin or library as a workaround, his solution works with Tailwind Variants!

For the reasons above, and much more (DX), we really need this feature for extensibility and portability of our components. If there are any questions, comments, or concerns regarding this feature, I'm happy to contribute and stress test so it meets your standards. Thank you for your consideration!

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

Successfully merging this pull request may close these issues.

2 participants