slot-variants 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,10 +12,11 @@ npm install slot-variants
12
12
 
13
13
  ## Overview
14
14
 
15
- `slot-variants` exports two functions:
15
+ `slot-variants` exports three functions:
16
16
 
17
17
  - **`sv()`** - creates variant-based class name generators with optional slots
18
18
  - **`cn()`** - a utility for conditionally merging class names
19
+ - **`createSV()`** - builds a pre-configured `sv()` with shared config defaults
19
20
 
20
21
  `sv()` is a drop-in replacement for [CVA](https://cva.style/) (just rename `cva` to `sv`) and covers the core feature set of [tailwind-variants](https://www.tailwind-variants.org/) (`tv`) with a simpler API. See [Migrating from CVA / tailwind-variants](#migrating-from-cva--tailwind-variants) for details.
21
22
 
@@ -68,7 +69,7 @@ cn(['foo', ['bar', 'baz']]); // 'foo bar baz'
68
69
  cn({ foo: true, bar: false, baz: true }); // 'foo baz'
69
70
 
70
71
  // Mixed
71
- cn('base', ['responsive'], { active: true }); // 'base active responsive'
72
+ cn('base', ['responsive'], { active: true }); // 'base responsive active'
72
73
 
73
74
  // Falsy values are filtered out
74
75
  cn('foo', null, undefined, false, 'bar'); // 'foo bar'
@@ -384,6 +385,21 @@ button({ intent: 'primary', size: 'lg' }); // OK
384
385
  button({ size: 'lg' }); // Throws: Missing required variant: "intent"
385
386
  ```
386
387
 
388
+ Pass `true` to make every variant required, or `false` to require none:
389
+
390
+ ```typescript
391
+ const button = sv('btn', {
392
+ variants: {
393
+ size: { sm: 'text-sm', lg: 'text-lg' },
394
+ intent: { primary: 'bg-blue-500', danger: 'bg-red-500' }
395
+ },
396
+ requiredVariants: true
397
+ });
398
+
399
+ button({ size: 'sm', intent: 'primary' }); // OK
400
+ button({ size: 'sm' }); // Throws: Missing required variant: "intent"
401
+ ```
402
+
387
403
  ### Presets
388
404
 
389
405
  Presets are predefined named combinations of variant values. Use them to create reusable variant shortcuts:
@@ -597,6 +613,58 @@ compoundSlots: [
597
613
  ]
598
614
  ```
599
615
 
616
+ ### Multi Slots
617
+
618
+ By default each slot in the result object is a plain class string. The
619
+ `multiSlots` option turns the listed slots into reconfigurable functions
620
+ instead. A slot function accepts variant prop overrides and a
621
+ `class`/`className` override, and returns that slot's class string.
622
+
623
+ This is designed for cases where a single slot is rendered multiple times
624
+ with different props — for example a list of items where each item needs
625
+ its own variant values — so the same slot can be re-evaluated per use
626
+ without recreating the whole variant function:
627
+
628
+ ```typescript
629
+ const card = sv('border', {
630
+ slots: {
631
+ header: 'font-bold',
632
+ body: 'py-4'
633
+ },
634
+ variants: {
635
+ size: {
636
+ sm: { base: 'p-2', header: 'text-sm' },
637
+ lg: { base: 'p-6', header: 'text-lg' }
638
+ }
639
+ },
640
+ multiSlots: ['header']
641
+ });
642
+
643
+ const result = card({ size: 'sm' });
644
+ // result.base -> 'border p-2' (plain string)
645
+ // result.body -> 'py-4' (plain string)
646
+ // result.header -> function
647
+
648
+ result.header(); // 'font-bold text-sm'
649
+ result.header({ size: 'lg' }); // 'font-bold text-lg'
650
+ result.header({ class: 'mt-2' }); // 'font-bold text-sm mt-2'
651
+ ```
652
+
653
+ Slots not listed in `multiSlots` stay plain strings. Pass `true` to make
654
+ every slot a function, or `false` (the default) to keep them all strings:
655
+
656
+ ```typescript
657
+ const card = sv('border', {
658
+ slots: { header: 'font-bold', body: 'py-4' },
659
+ multiSlots: true
660
+ });
661
+
662
+ const { base, header, body } = card();
663
+ base(); // 'border'
664
+ header(); // 'font-bold'
665
+ body(); // 'py-4'
666
+ ```
667
+
600
668
  ### Class Override at Runtime
601
669
 
602
670
  Append additional classes at call time using `class` or `className`:
@@ -666,10 +734,66 @@ const button = sv('px-4 py-2 bg-blue-500', {
666
734
 
667
735
  The `postProcess` function is applied to each slot's final class string independently.
668
736
 
737
+ ### Shared Defaults with `createSV()`
738
+
739
+ `createSV(defaults)` returns a pre-configured `sv()` that merges `defaults` into every config-based call. This avoids repeating the same options — most commonly `postProcess: twMerge` — across every component:
740
+
741
+ ```typescript
742
+ import { createSV } from 'slot-variants';
743
+ import { twMerge } from 'tailwind-merge';
744
+
745
+ export const customSV = createSV({
746
+ postProcess: twMerge,
747
+ cacheSize: 512
748
+ });
749
+
750
+ // twMerge is applied without restating it per component
751
+ const button = customSV('px-4 py-2 bg-blue-500', {
752
+ variants: {
753
+ size: {
754
+ sm: 'px-2 py-1 text-sm',
755
+ lg: 'px-6 py-3 text-lg'
756
+ }
757
+ }
758
+ });
759
+ ```
760
+
761
+ `defaults` accepts **any** config option (`base`, `variants`, `slots`, `compoundVariants`, `cacheSize`, `introspection`, `postProcess`, …). The merge is shallow and **a per-call value always wins** over the matching default — there is no deep merging of variants or compound rules:
762
+
763
+ ```typescript
764
+ const customSV = createSV({ postProcess: twMerge });
765
+
766
+ // This component opts out of twMerge entirely
767
+ const raw = customSV('btn', {
768
+ variants: { size: { sm: 'text-sm' } },
769
+ postProcess: (className) => className // overrides the default
770
+ });
771
+ ```
772
+
773
+ Calls with no config object are forwarded straight to `cn()`-style merging and never see the defaults:
774
+
775
+ ```typescript
776
+ const customSV = createSV({ postProcess: (c) => c.toUpperCase() });
777
+
778
+ customSV('flex', 'items-center'); // 'flex items-center' — defaults are skipped
779
+ ```
780
+
781
+ A factory-level `introspection: true` is reflected in each component's return type, so the [introspection](#introspection) API is available without setting the flag on every config. A per-call `introspection: false` opts an individual component back out.
782
+
669
783
  ### Caching
670
784
 
671
785
  Results are cached automatically for performance. The default cache size is **256** entries.
672
786
 
787
+ Each cache entry corresponds to one distinct combination of resolved variant values. The largest number of combinations a config can produce is the product, over every variant, of its value count plus one — the `+ 1` counts the variant being left unset:
788
+
789
+ ```
790
+ maxEntries = (values₁ + 1) × (values₂ + 1) × ... × (valuesₙ + 1)
791
+ ```
792
+
793
+ For example, four variants with three values each yield `(3 + 1) ** 4 = 256` combinations — exactly the default. A config whose `maxEntries` is at or below its `cacheSize` never evicts, so raising `cacheSize` past that point has no effect.
794
+
795
+ The `+ 1` is dropped for any variant that cannot actually be left unset — one listed in `requiredVariants` (or all of them when `requiredVariants` is `true`), or one with a static `defaultVariants` value that always fills it in. A function-based default keeps the `+ 1`, since it may return `undefined`. The `getMaxEntries()` introspection method computes this exact count for a given config — see [Introspection](#introspection).
796
+
673
797
  ```typescript
674
798
  const button = sv('btn', {
675
799
  variants: {
@@ -719,10 +843,12 @@ button.slotKeys; // ['base', 'icon']
719
843
  button.slots; // { icon: 'w-4 h-4' }
720
844
  button.defaultVariants; // { size: 'sm' }
721
845
  button.requiredVariants; // ['intent']
846
+ button.multiSlots; // [] (slot names exposed as functions)
722
847
  button.presetKeys; // ['cta']
723
848
  button.presets; // { cta: { size: 'lg', intent: 'primary' } }
724
849
  button.getVariantValues('size'); // ['sm', 'lg']
725
850
  button.getVariantValues('intent'); // ['primary', 'danger']
851
+ button.getMaxEntries(); // 4 — distinct variant combinations
726
852
  button.getCacheSize(); // current number of cached entries
727
853
  button.clearCache(); // clear all cached entries
728
854
  ```
@@ -866,6 +992,7 @@ When used on an `sv()` definition without slots, `SlotClassProps` resolves to `{
866
992
  | `VariantProps<T, E>` | Extracts variant props from an `sv()` return type, optionally excluding keys in `E` |
867
993
  | `VariantValue<T, K>` | Extracts the value union for a single variant key `K`, without `undefined` |
868
994
  | `SlotClassProps<T>` | Extracts the per-slot class injection shape from an `sv()` return type |
995
+ | `SV<DI>` | The shape of an `sv()` function, with the factory's introspection default `DI`; the return type of `createSV()` |
869
996
 
870
997
  ### Return Type
871
998
 
@@ -884,7 +1011,8 @@ Class values inside the config (`base`, `variants`, `slots`, and `compound*` `cl
884
1011
  | `compoundVariants` | `Array` | Additional classes applied when multiple variant conditions match |
885
1012
  | `compoundSlots` | `Array` | Classes applied to multiple slots based on variant conditions |
886
1013
  | `defaultVariants` | `Object` | Default values for variants (static values or functions) |
887
- | `requiredVariants` | `string[]` | Variant names that must be provided at call time |
1014
+ | `requiredVariants` | `string[] \| boolean` | Variant names that must be provided at call time; `true` makes every variant required, `false` none |
1015
+ | `multiSlots` | `string[] \| boolean` | Slot names exposed as reconfigurable functions instead of strings; `true` makes every slot a function, `false` none |
888
1016
  | `presets` | `Record<string, Partial<VariantProps>>` | Named combinations of variant values selectable via `preset` prop |
889
1017
  | `postProcess` | `(className: string) => string` | Custom transformation applied to final class strings |
890
1018
  | `cacheSize` | `number` | Maximum number of cached results (default: `256`) |
@@ -896,15 +1024,17 @@ Class values inside the config (`base`, `variants`, `slots`, and `compound*` `cl
896
1024
 
897
1025
  ### Rules
898
1026
 
899
- - **`slot-variants/no-conflicting-classes`** — flags class tokens that collide within the output of an `sv()` or `cn()` call: both exact-duplicate tokens that will appear more than once and distinct tokens that target the same Tailwind-style utility namespace (e.g. `w-100` and `w-200`). For `sv()`, detects collisions within `base`, across different variant keys, inside compound variants and compound slots, between `base` and a variant value, and within a single literal. For `cn()` (and the cn-style calling convention of `sv()` without a config), flags collisions across args, inside arrays, template literals, or within a single literal. Tokens with different variant prefixes (`w-100` vs `hover:w-200`) don't conflict, the trailing `!` important marker is ignored when computing the namespace, and tokens that only co-occur across mutually-exclusive variant values are skipped.
1027
+ - **`slot-variants/no-conflicting-classes`** — flags class tokens that collide within the output of an `sv()` or `cn()` call: both exact-duplicate tokens that will appear more than once and distinct tokens that target the same Tailwind-style utility namespace (e.g. `w-100` and `w-200`). For `sv()`, detects collisions within `base`, across different variant keys, inside compound variants and compound slots, between `base` and a variant value, and within a single literal. For `cn()` (and the cn-style calling convention of `sv()` without a config), flags collisions across args, inside arrays, template literals, or within a single literal. Tokens with different variant prefixes (`w-100` vs `hover:w-200`) don't conflict, a leading or trailing `!` important marker (`!w-100`, `w-100!`) is ignored when computing the namespace, and tokens that only co-occur across mutually-exclusive variant values are skipped.
900
1028
 
901
- - **`slot-variants/no-dynamic-classes`** — flags class-bearing positions in `sv()` and `cn()` calls that aren't statically inferrable. Only string literals, template literals without expressions, flat arrays of those in config, and explicit `undefined` config class values are accepted, and config objects must use static keys (no spreads, no computed keys). Identifiers, member access, calls, spreads, non-string literals, templates with expressions, nested config arrays, and runtime conditional records are reported. Non-class-bearing config keys (`defaultVariants`, `presets`, `requiredVariants`, `cacheSize`, `postProcess`, `introspection`) are not validated, and runtime variant matchers inside compound entries are left alone — only the `class`/`className` value (and the `slots` array of `compoundSlots`) is checked.
1029
+ - **`slot-variants/no-dynamic-classes`** — flags class-bearing positions in `sv()` and `cn()` calls that aren't statically inferrable. Only string literals, template literals without expressions, flat arrays of those in config, and explicit `undefined` config class values are accepted, and config objects must use static keys (no spreads, no computed keys). Identifiers, member access, calls, spreads, non-string literals, templates with expressions, nested config arrays, and runtime conditional records are reported. Non-class-bearing config keys (`defaultVariants`, `presets`, `requiredVariants`, `multiSlots`, `cacheSize`, `postProcess`, `introspection`) are not validated, and runtime variant matchers inside compound entries are left alone — only the `class`/`className` value (and the `slots` array of `compoundSlots`) is checked.
902
1030
 
903
- - **`slot-variants/no-empty-classes`** — flags empty class values — empty strings, empty arrays, and empty objects — at any class-bearing position reachable from an `sv()` or `cn()` call, plus zero-argument `sv()` / `cn()` calls themselves (which always produce an empty class string). Reports empties in positional arguments (and inside arrays nested in those), in `base`, in variants including slot-keyed variant branches, in the `class`/`className` of `compoundVariants` and `compoundSlots` entries, and in the top-level `slots`, `variants`, `compoundVariants`, and `compoundSlots` containers themselves. Inside an `sv()` config, an empty string is still allowed as a direct `slots[key]` value — declaring a slot with no default classes is a valid use case.
1031
+ - **`slot-variants/no-empty-classes`** — flags empty class values — empty strings, empty arrays, and empty objects — at any class-bearing position reachable from an `sv()` or `cn()` call, plus zero-argument `sv()` / `cn()` calls themselves (which always produce an empty class string). Reports empties in positional arguments (and inside arrays nested in those), in `base`, in variants including slot-keyed variant branches, in the `class`/`className` of `compoundVariants` and `compoundSlots` entries, and in the top-level `slots`, `variants`, `compoundVariants`, and `compoundSlots` containers themselves. Inside an `sv()` config, an empty string is still allowed as a direct `slots[key]` value — declaring a slot with no default classes is a valid use case. **Partially auto-fixable**: `eslint --fix` removes an empty positional argument, an empty class-array element, or an empty top-level config property (`base`, `slots`, `variants`, `compoundVariants`, `compoundSlots`), along with its comma, when other items remain in that list or config; empties at other positions are reported without a fix.
904
1032
 
905
1033
  - **`slot-variants/no-redundant-spaces`** — flags class strings whose whitespace isn't canonical. Inside a class string, whitespace is canonical only as a single ASCII space between two non-whitespace tokens, so leading or trailing whitespace, repeated spaces, and non-space whitespace (tabs, newlines, etc.) are reported. The rule walks every string and expressionless template literal reachable from a call's arguments — including values nested inside arrays and objects — and bails silently on dynamic expressions. **Auto-fixable**: `eslint --fix` rewrites each offending literal in place, preserving its original quote style.
906
1034
 
907
- - **`slot-variants/no-shared-tokens`** — flags class tokens that appear in every value of an exhaustively-covered variant, where “exhaustive” means the variant has a statically defined default value or is listed in `requiredVariants`. Those tokens are constant in the rendered output, so they belong in `base` or the corresponding `slots[slot]` entry rather than being repeated in every variant value. The rule only analyzes `sv()` calls with a config, compares tokens per-slot, skips non-exhaustive variants, single-value variants, boolean shorthand, undefined or dynamic defaults, and dynamic or partially-analyzable variant value records, and reports every repeated occurrence that should be lifted out.
1035
+ - **`slot-variants/no-shared-tokens`** — flags class tokens that appear in every value of an exhaustively-covered variant, where “exhaustive” means the variant has a statically defined default value, is listed in `requiredVariants`, or `requiredVariants` is `true` (every variant required). Those tokens are constant in the rendered output, so they belong in `base` or the corresponding `slots[slot]` entry rather than being repeated in every variant value. The rule only analyzes `sv()` calls with a config, compares tokens per-slot, skips non-exhaustive variants, single-value variants, boolean shorthand, undefined or dynamic defaults, and dynamic or partially-analyzable variant value records, and reports every repeated occurrence that should be lifted out.
1036
+
1037
+ - **`slot-variants/require-top-level-config`** — flags `sv()` calls made with a config object that aren't at the module top level. The config form compiles the variant function and seeds its cache once; nesting it inside a function (a component body, an arrow, an object method, etc.) rebuilds that work — and throws away the variant cache — on every call, so the config form belongs at module scope. The cn-style calling convention of `sv()` (and every `cn()` call) carries no config and is left alone, as are calls inside top-level blocks or conditionals, which still run only at module load.
908
1038
 
909
1039
  Only calls where `sv` or `cn` is a named import from `'slot-variants'` are analyzed. `no-conflicting-classes` skips dynamic inputs silently to avoid false positives; `no-dynamic-classes` is the opposite — it flags exactly those positions so the static analyzer can fully reason about every call. `no-shared-tokens` sits between them: it needs a fully statically analyzable, exhaustive variant before it can prove a token is constant across every value. `no-empty-classes` and `no-redundant-spaces` are independent and complement the structural rules: they cover empty and badly-shaped literals reachable from a call's arguments, regardless of whether the surrounding call is fully static.
910
1040
 
@@ -931,7 +1061,8 @@ export default [
931
1061
  'slot-variants/no-dynamic-classes': 'error',
932
1062
  'slot-variants/no-empty-classes': 'error',
933
1063
  'slot-variants/no-redundant-spaces': 'error',
934
- 'slot-variants/no-shared-tokens': 'error'
1064
+ 'slot-variants/no-shared-tokens': 'error',
1065
+ 'slot-variants/require-top-level-config': 'error'
935
1066
  }
936
1067
  }
937
1068
  ];
@@ -947,7 +1078,8 @@ export default [
947
1078
  "slot-variants/no-dynamic-classes": "error",
948
1079
  "slot-variants/no-empty-classes": "error",
949
1080
  "slot-variants/no-redundant-spaces": "error",
950
- "slot-variants/no-shared-tokens": "error"
1081
+ "slot-variants/no-shared-tokens": "error",
1082
+ "slot-variants/require-top-level-config": "error"
951
1083
  }
952
1084
  }
953
1085
  ```
@@ -1111,11 +1243,11 @@ Key differences to be aware of:
1111
1243
 
1112
1244
  | Feature | tailwind-variants | slot-variants |
1113
1245
  | --- | --- | --- |
1114
- | Slot return type | Functions: `slot({ class: '...' })` | Strings: `slot` is already a `string` |
1246
+ | Slot return type | Always functions: `slot({ class: '...' })` | Strings by default; functions for slots listed in `multiSlots` |
1115
1247
  | `extend` (composition) | Supported | Not supported |
1116
- | Built-in `twMerge` | Enabled by default | Use `postProcess: twMerge` |
1248
+ | Built-in `twMerge` | Enabled by default | Use `postProcess: twMerge`, globally via `createSV` |
1117
1249
 
1118
- **Slot return type** is the most significant difference. In `tv()`, each slot returns a function that can accept additional props. In `sv()`, slots resolve to strings directly — use the `class` prop with a slot object for per-slot overrides:
1250
+ **Slot return type** is the most significant difference. In `tv()`, each slot returns a function that can accept additional props. In `sv()`, slots resolve to strings directly — use the `class` prop with a slot object for per-slot overrides, or list a slot in [`multiSlots`](#multi-slots) to expose it as a `tv`-style reconfigurable function:
1119
1251
 
1120
1252
  ```typescript
1121
1253
  // tailwind-variants
@@ -1139,3 +1271,13 @@ const button = sv({
1139
1271
  postProcess: twMerge
1140
1272
  });
1141
1273
  ```
1274
+
1275
+ To enable it globally — the closest equivalent to `tv`'s default `twMerge` — wrap it once with [`createSV()`](#shared-defaults-with-createsv) and import the result everywhere:
1276
+
1277
+ ```typescript
1278
+ // custom-sv.ts
1279
+ import { createSV } from 'slot-variants';
1280
+ import { twMerge } from 'tailwind-merge';
1281
+
1282
+ export const customSV = createSV({ postProcess: twMerge });
1283
+ ```