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

feat: tv component definition w/ config overrides #242

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).

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.

1 participant