diff --git a/README.md b/README.md index db74c31..40963d8 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,8 @@ const twMerge = extendTailwindMerge(squircleMergeConfig, { }); ``` +The conflicts mirror Tailwind's own `rounded` hierarchy: a later all-corners utility cancels earlier side/corner utilities (from either family), a side cancels its two corners, and a narrower utility refines a broader one instead of canceling it — `squircle-md squircle-tl-sm` keeps both. `squircle-amt-*` is independent of radius classes. + ### Utilities | Utility | Equivalent | Description | @@ -106,13 +108,34 @@ const twMerge = extendTailwindMerge(squircleMergeConfig, { | `squircle-es-*` | `rounded-es-*` | End-start corner (logical) | | `squircle-ee-*` | `rounded-ee-*` | End-end corner (logical) | | `squircle-amt-*` | — | Superellipse exponent (default 2) | +| `squircle-none` | `rounded-none` | Remove rounding | +| `squircle-full` | `rounded-full` | Fully rounded (pill) | ### What values are accepted? Values are validated strictly so typos fail at build time instead of producing invalid CSS: -- **`squircle-*` and its variants** accept the same theme values as `rounded-*` (`sm`, `md`, `lg`, `xl`, `2xl`, `3xl`, `full`, plus anything you add to `@theme`) and arbitrary lengths like `squircle-[16px]`. Non-length arbitraries (`[50%]`, `[foo]`) and paren refs (`squircle-(--my-radius)`) are rejected — use a theme key instead. -- **`squircle-amt-*`** accepts bare numbers (`squircle-amt-2`), arbitrary numbers (`squircle-amt-[3.5]`), and theme values. Unit-bearing arbitraries (`[1em]`) and paren refs (`(--my-amt)`) are rejected. +- **`squircle-*` and its variants** accept the same theme values as `rounded-*` (`sm`, `md`, `lg`, `xl`, `2xl`, `3xl`, plus anything you add to `@theme`) and arbitrary lengths like `squircle-[16px]`. Non-length arbitraries (`[50%]`, `[foo]`) and paren refs (`squircle-(--my-radius)`) are rejected — use a theme key instead. +- **`squircle-none` and `squircle-full`** (and their side/corner variants) are static utilities defined the same way as `rounded-none` and `rounded-full`: `0` and `calc(infinity * 1px)`. Neither needs the visual radius correction — correcting zero or infinity is a no-op — so `squircle-full` is just `rounded-full` plus the superellipse corner shape, and `squircle-none` is identical to `rounded-none`. +- **`squircle-amt-*`** accepts bare numbers (`squircle-amt-2`), arbitrary numbers (`squircle-amt-[3.5]`), and theme values. Unit-bearing arbitraries (`[1em]`) and paren refs (`(--my-amt)`) are rejected. It only sets the amount — exactly what writing `--squircle-amt` yourself does — and applies no corner shape of its own, so it never reshapes a corner no `squircle-*` utility claimed. + +### Mixing squircled and rounded corners + +Corner shape is tracked per corner, so the two families compose in both directions on the same element: + +```html + +
…
+ + +
…
+``` + +Two things make that work. A `squircle-*` utility only shapes the corners it sets a radius on — the all-corners utility uses the `corner-shape` shorthand, while the side and corner variants use the matching longhands (`corner-top-left-shape` and friends). And because Tailwind's own `rounded-*` utilities only set a radius, they could never take a corner back from a squircle, so this package re-declares them to also reset their corners to `round`. That reset is the initial value, so it changes nothing unless a squircle class is on the same element. + +Note that a corner reclaimed by `rounded-*` uses the plain radius, not the corrected one — the correction exists only to make a squircle look the same size as a rounded corner, so a genuinely round corner doesn't want it. + +The reset accepts the same values the `squircle-*` utilities do — theme keys and arbitrary lengths like `rounded-tl-[3px]`. Tailwind's own `rounded-*` is looser, so a corner named with a paren ref (`rounded-tl-(--my-radius)`) or a non-length arbitrary still gets its radius from Tailwind but keeps its squircle shape. Use a theme key there, as everywhere else in this package. ### What does `squircle-amt-*` control? @@ -596,12 +619,24 @@ If you'd rather not add a dependency, copy the source directly. Click to expand /* ── Squircle utilities ─────────────────────────────────────── */ /* squircle-amt-[n] sets the superellipse amount (default 2) */ -/* squircle-* mirrors rounded-* variants: all, t, r, b, l, s, e, tl, tr, br, bl, ss, se, es, ee */ +/* squircle-* mirrors rounded-* variants: t, r, b, l, s, e, tl, tr, br, bl, ss, se, es, ee */ +/* squircle-*-none and squircle-*-full are static, matching rounded-none and rounded-full */ +/* squircle-amt-[n] only sets the amount — the same thing writing + --squircle-amt yourself does. It applies no corner-shape of its own, so it + never reshapes corners that no squircle-* utility claimed. */ @utility squircle-amt-* { --squircle-amt: --value(--squircle-amt-*, number, [number]); +} + +@utility squircle-none { + border-radius: 0; +} + +@utility squircle-full { + border-radius: calc(infinity * 1px); @supports (corner-shape: superellipse(2)) { - corner-shape: superellipse(var(--squircle-amt)); + corner-shape: superellipse(var(--squircle-amt, 2)); } } @@ -616,6 +651,20 @@ If you'd rather not add a dependency, copy the source directly. Click to expand /* --- Per-side physical variants --- */ +@utility squircle-t-none { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +@utility squircle-t-full { + border-top-left-radius: calc(infinity * 1px); + border-top-right-radius: calc(infinity * 1px); + @supports (corner-shape: superellipse(2)) { + corner-top-left-shape: superellipse(var(--squircle-amt, 2)); + corner-top-right-shape: superellipse(var(--squircle-amt, 2)); + } +} + @utility squircle-t-* { border-top-left-radius: --value(--radius-*, [length]); border-top-right-radius: --value(--radius-*, [length]); @@ -623,7 +672,22 @@ If you'd rather not add a dependency, copy the source directly. Click to expand --squircle-r: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-top-left-radius: var(--squircle-r); border-top-right-radius: var(--squircle-r); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-top-left-shape: superellipse(var(--squircle-amt, 2)); + corner-top-right-shape: superellipse(var(--squircle-amt, 2)); + } +} + +@utility squircle-r-none { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +@utility squircle-r-full { + border-top-right-radius: calc(infinity * 1px); + border-bottom-right-radius: calc(infinity * 1px); + @supports (corner-shape: superellipse(2)) { + corner-top-right-shape: superellipse(var(--squircle-amt, 2)); + corner-bottom-right-shape: superellipse(var(--squircle-amt, 2)); } } @@ -634,7 +698,22 @@ If you'd rather not add a dependency, copy the source directly. Click to expand --squircle-r: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-top-right-radius: var(--squircle-r); border-bottom-right-radius: var(--squircle-r); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-top-right-shape: superellipse(var(--squircle-amt, 2)); + corner-bottom-right-shape: superellipse(var(--squircle-amt, 2)); + } +} + +@utility squircle-b-none { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +@utility squircle-b-full { + border-bottom-left-radius: calc(infinity * 1px); + border-bottom-right-radius: calc(infinity * 1px); + @supports (corner-shape: superellipse(2)) { + corner-bottom-left-shape: superellipse(var(--squircle-amt, 2)); + corner-bottom-right-shape: superellipse(var(--squircle-amt, 2)); } } @@ -645,7 +724,22 @@ If you'd rather not add a dependency, copy the source directly. Click to expand --squircle-r: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-bottom-left-radius: var(--squircle-r); border-bottom-right-radius: var(--squircle-r); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-bottom-left-shape: superellipse(var(--squircle-amt, 2)); + corner-bottom-right-shape: superellipse(var(--squircle-amt, 2)); + } +} + +@utility squircle-l-none { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +@utility squircle-l-full { + border-top-left-radius: calc(infinity * 1px); + border-bottom-left-radius: calc(infinity * 1px); + @supports (corner-shape: superellipse(2)) { + corner-top-left-shape: superellipse(var(--squircle-amt, 2)); + corner-bottom-left-shape: superellipse(var(--squircle-amt, 2)); } } @@ -656,12 +750,27 @@ If you'd rather not add a dependency, copy the source directly. Click to expand --squircle-r: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-top-left-radius: var(--squircle-r); border-bottom-left-radius: var(--squircle-r); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-top-left-shape: superellipse(var(--squircle-amt, 2)); + corner-bottom-left-shape: superellipse(var(--squircle-amt, 2)); } } /* --- Per-side logical variants --- */ +@utility squircle-s-none { + border-start-start-radius: 0; + border-end-start-radius: 0; +} + +@utility squircle-s-full { + border-start-start-radius: calc(infinity * 1px); + border-end-start-radius: calc(infinity * 1px); + @supports (corner-shape: superellipse(2)) { + corner-start-start-shape: superellipse(var(--squircle-amt, 2)); + corner-end-start-shape: superellipse(var(--squircle-amt, 2)); + } +} + @utility squircle-s-* { border-start-start-radius: --value(--radius-*, [length]); border-end-start-radius: --value(--radius-*, [length]); @@ -669,7 +778,22 @@ If you'd rather not add a dependency, copy the source directly. Click to expand --squircle-r: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-start-start-radius: var(--squircle-r); border-end-start-radius: var(--squircle-r); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-start-start-shape: superellipse(var(--squircle-amt, 2)); + corner-end-start-shape: superellipse(var(--squircle-amt, 2)); + } +} + +@utility squircle-e-none { + border-start-end-radius: 0; + border-end-end-radius: 0; +} + +@utility squircle-e-full { + border-start-end-radius: calc(infinity * 1px); + border-end-end-radius: calc(infinity * 1px); + @supports (corner-shape: superellipse(2)) { + corner-start-end-shape: superellipse(var(--squircle-amt, 2)); + corner-end-end-shape: superellipse(var(--squircle-amt, 2)); } } @@ -680,17 +804,40 @@ If you'd rather not add a dependency, copy the source directly. Click to expand --squircle-r: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-start-end-radius: var(--squircle-r); border-end-end-radius: var(--squircle-r); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-start-end-shape: superellipse(var(--squircle-amt, 2)); + corner-end-end-shape: superellipse(var(--squircle-amt, 2)); } } /* --- Per-corner physical variants --- */ +@utility squircle-tl-none { + border-top-left-radius: 0; +} + +@utility squircle-tl-full { + border-top-left-radius: calc(infinity * 1px); + @supports (corner-shape: superellipse(2)) { + corner-top-left-shape: superellipse(var(--squircle-amt, 2)); + } +} + @utility squircle-tl-* { border-top-left-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { border-top-left-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-top-left-shape: superellipse(var(--squircle-amt, 2)); + } +} + +@utility squircle-tr-none { + border-top-right-radius: 0; +} + +@utility squircle-tr-full { + border-top-right-radius: calc(infinity * 1px); + @supports (corner-shape: superellipse(2)) { + corner-top-right-shape: superellipse(var(--squircle-amt, 2)); } } @@ -698,7 +845,18 @@ If you'd rather not add a dependency, copy the source directly. Click to expand border-top-right-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { border-top-right-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-top-right-shape: superellipse(var(--squircle-amt, 2)); + } +} + +@utility squircle-br-none { + border-bottom-right-radius: 0; +} + +@utility squircle-br-full { + border-bottom-right-radius: calc(infinity * 1px); + @supports (corner-shape: superellipse(2)) { + corner-bottom-right-shape: superellipse(var(--squircle-amt, 2)); } } @@ -706,7 +864,18 @@ If you'd rather not add a dependency, copy the source directly. Click to expand border-bottom-right-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { border-bottom-right-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-bottom-right-shape: superellipse(var(--squircle-amt, 2)); + } +} + +@utility squircle-bl-none { + border-bottom-left-radius: 0; +} + +@utility squircle-bl-full { + border-bottom-left-radius: calc(infinity * 1px); + @supports (corner-shape: superellipse(2)) { + corner-bottom-left-shape: superellipse(var(--squircle-amt, 2)); } } @@ -714,17 +883,39 @@ If you'd rather not add a dependency, copy the source directly. Click to expand border-bottom-left-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { border-bottom-left-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-bottom-left-shape: superellipse(var(--squircle-amt, 2)); } } /* --- Per-corner logical variants --- */ +@utility squircle-ss-none { + border-start-start-radius: 0; +} + +@utility squircle-ss-full { + border-start-start-radius: calc(infinity * 1px); + @supports (corner-shape: superellipse(2)) { + corner-start-start-shape: superellipse(var(--squircle-amt, 2)); + } +} + @utility squircle-ss-* { border-start-start-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { border-start-start-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-start-start-shape: superellipse(var(--squircle-amt, 2)); + } +} + +@utility squircle-se-none { + border-start-end-radius: 0; +} + +@utility squircle-se-full { + border-start-end-radius: calc(infinity * 1px); + @supports (corner-shape: superellipse(2)) { + corner-start-end-shape: superellipse(var(--squircle-amt, 2)); } } @@ -732,7 +923,18 @@ If you'd rather not add a dependency, copy the source directly. Click to expand border-start-end-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { border-start-end-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-start-end-shape: superellipse(var(--squircle-amt, 2)); + } +} + +@utility squircle-es-none { + border-end-start-radius: 0; +} + +@utility squircle-es-full { + border-end-start-radius: calc(infinity * 1px); + @supports (corner-shape: superellipse(2)) { + corner-end-start-shape: superellipse(var(--squircle-amt, 2)); } } @@ -740,7 +942,18 @@ If you'd rather not add a dependency, copy the source directly. Click to expand border-end-start-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { border-end-start-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-end-start-shape: superellipse(var(--squircle-amt, 2)); + } +} + +@utility squircle-ee-none { + border-end-end-radius: 0; +} + +@utility squircle-ee-full { + border-end-end-radius: calc(infinity * 1px); + @supports (corner-shape: superellipse(2)) { + corner-end-end-shape: superellipse(var(--squircle-amt, 2)); } } @@ -748,9 +961,170 @@ If you'd rather not add a dependency, copy the source directly. Click to expand border-end-end-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { border-end-end-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-end-end-shape: superellipse(var(--squircle-amt, 2)); } } + +/* ── rounded-* corner-shape resets ──────────────────────────── */ +/* Tailwind's rounded-* utilities only set a radius, so on their own they can't + take a corner back from a squircle. These re-declarations add the matching + corner-shape reset; Tailwind still emits its own rule for the radius. The + reset is the initial value, so it does nothing unless a squircle class set a + shape on the same element. rounded-*-none needs none: a zero radius has no + visible corner to shape. */ + +@utility rounded-full { + corner-shape: round; +} + +@utility rounded-* { + border-radius: --value(--radius-*, [length]); + corner-shape: round; +} + +@utility rounded-t-full { + corner-top-left-shape: round; + corner-top-right-shape: round; +} + +@utility rounded-t-* { + border-top-left-radius: --value(--radius-*, [length]); + border-top-right-radius: --value(--radius-*, [length]); + corner-top-left-shape: round; + corner-top-right-shape: round; +} + +@utility rounded-r-full { + corner-top-right-shape: round; + corner-bottom-right-shape: round; +} + +@utility rounded-r-* { + border-top-right-radius: --value(--radius-*, [length]); + border-bottom-right-radius: --value(--radius-*, [length]); + corner-top-right-shape: round; + corner-bottom-right-shape: round; +} + +@utility rounded-b-full { + corner-bottom-left-shape: round; + corner-bottom-right-shape: round; +} + +@utility rounded-b-* { + border-bottom-left-radius: --value(--radius-*, [length]); + border-bottom-right-radius: --value(--radius-*, [length]); + corner-bottom-left-shape: round; + corner-bottom-right-shape: round; +} + +@utility rounded-l-full { + corner-top-left-shape: round; + corner-bottom-left-shape: round; +} + +@utility rounded-l-* { + border-top-left-radius: --value(--radius-*, [length]); + border-bottom-left-radius: --value(--radius-*, [length]); + corner-top-left-shape: round; + corner-bottom-left-shape: round; +} + +@utility rounded-s-full { + corner-start-start-shape: round; + corner-end-start-shape: round; +} + +@utility rounded-s-* { + border-start-start-radius: --value(--radius-*, [length]); + border-end-start-radius: --value(--radius-*, [length]); + corner-start-start-shape: round; + corner-end-start-shape: round; +} + +@utility rounded-e-full { + corner-start-end-shape: round; + corner-end-end-shape: round; +} + +@utility rounded-e-* { + border-start-end-radius: --value(--radius-*, [length]); + border-end-end-radius: --value(--radius-*, [length]); + corner-start-end-shape: round; + corner-end-end-shape: round; +} + +@utility rounded-tl-full { + corner-top-left-shape: round; +} + +@utility rounded-tl-* { + border-top-left-radius: --value(--radius-*, [length]); + corner-top-left-shape: round; +} + +@utility rounded-tr-full { + corner-top-right-shape: round; +} + +@utility rounded-tr-* { + border-top-right-radius: --value(--radius-*, [length]); + corner-top-right-shape: round; +} + +@utility rounded-br-full { + corner-bottom-right-shape: round; +} + +@utility rounded-br-* { + border-bottom-right-radius: --value(--radius-*, [length]); + corner-bottom-right-shape: round; +} + +@utility rounded-bl-full { + corner-bottom-left-shape: round; +} + +@utility rounded-bl-* { + border-bottom-left-radius: --value(--radius-*, [length]); + corner-bottom-left-shape: round; +} + +@utility rounded-ss-full { + corner-start-start-shape: round; +} + +@utility rounded-ss-* { + border-start-start-radius: --value(--radius-*, [length]); + corner-start-start-shape: round; +} + +@utility rounded-se-full { + corner-start-end-shape: round; +} + +@utility rounded-se-* { + border-start-end-radius: --value(--radius-*, [length]); + corner-start-end-shape: round; +} + +@utility rounded-es-full { + corner-end-start-shape: round; +} + +@utility rounded-es-* { + border-end-start-radius: --value(--radius-*, [length]); + corner-end-start-shape: round; +} + +@utility rounded-ee-full { + corner-end-end-shape: round; +} + +@utility rounded-ee-* { + border-end-end-radius: --value(--radius-*, [length]); + corner-end-end-shape: round; +} ``` @@ -807,38 +1181,47 @@ If you'd rather not add a dependency, copy the source directly. Click to expand import { extendTailwindMerge } from "tailwind-merge"; -const allRoundedGroups = [ - "rounded", "rounded-s", "rounded-e", "rounded-t", "rounded-r", - "rounded-b", "rounded-l", "rounded-ss", "rounded-se", "rounded-es", - "rounded-ee", "rounded-tl", "rounded-tr", "rounded-br", "rounded-bl", -]; - -export const squircleMergeConfig = { extend: { - classGroups: { - squircle: [ - { squircle: [() => true] }, - { "squircle-t": [() => true] }, - { "squircle-r": [() => true] }, - { "squircle-b": [() => true] }, - { "squircle-l": [() => true] }, - { "squircle-s": [() => true] }, - { "squircle-e": [() => true] }, - { "squircle-tl": [() => true] }, - { "squircle-tr": [() => true] }, - { "squircle-br": [() => true] }, - { "squircle-bl": [() => true] }, - { "squircle-ss": [() => true] }, - { "squircle-se": [() => true] }, - { "squircle-es": [() => true] }, - { "squircle-ee": [() => true] }, - ], - "squircle-amt": [{ "squircle-amt": [() => true] }], - }, - conflictingClassGroups: { - squircle: [...allRoundedGroups, "squircle-amt"], - ...Object.fromEntries(allRoundedGroups.map((g) => [g, ["squircle", "squircle-amt"]])), +// Mirrors tailwind-merge's own `rounded` hierarchy: a later all-corners +// utility cancels earlier side/corner utilities, a side cancels its two +// corners, and a narrower utility never cancels a broader one. Each squircle +// group also conflicts with its `rounded` counterpart (and vice versa). +// `squircle-amt-*` is orthogonal: radius classes never cancel it. +const SIDE_CORNERS = { + t: ["tl", "tr"], r: ["tr", "br"], b: ["br", "bl"], + l: ["tl", "bl"], s: ["ss", "es"], e: ["se", "ee"], +}; +const CORNERS = ["tl", "tr", "br", "bl", "ss", "se", "es", "ee"]; +const SIDES = Object.keys(SIDE_CORNERS); +const ALL_SUFFIXES = ["", ...SIDES, ...CORNERS]; + +const sq = (suffix) => (suffix ? `squircle-${suffix}` : "squircle"); +const rd = (suffix) => (suffix ? `rounded-${suffix}` : "rounded"); + +const conflictingClassGroups = { + squircle: [...ALL_SUFFIXES.slice(1).map(sq), ...ALL_SUFFIXES.map(rd)], + rounded: ALL_SUFFIXES.map(sq), +}; +for (const side of SIDES) { + const corners = SIDE_CORNERS[side]; + conflictingClassGroups[sq(side)] = [...corners.map(sq), rd(side), ...corners.map(rd)]; + conflictingClassGroups[rd(side)] = [sq(side), ...corners.map(sq)]; +} +for (const corner of CORNERS) { + conflictingClassGroups[sq(corner)] = [rd(corner)]; + conflictingClassGroups[rd(corner)] = [sq(corner)]; +} + +export const squircleMergeConfig = { + extend: { + classGroups: { + ...Object.fromEntries( + ALL_SUFFIXES.map((suffix) => [sq(suffix), [{ [sq(suffix)]: [() => true] }]]), + ), + "squircle-amt": [{ "squircle-amt": [() => true] }], + }, + conflictingClassGroups, }, -} }; +}; export const twMerge = extendTailwindMerge(squircleMergeConfig); ``` @@ -918,7 +1301,11 @@ const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerTopLeftShape: { + default: null, + "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, + }, + cornerTopRightShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -932,7 +1319,11 @@ const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerTopRightShape: { + default: null, + "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, + }, + cornerBottomRightShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -946,7 +1337,11 @@ const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerBottomLeftShape: { + default: null, + "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, + }, + cornerBottomRightShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -960,7 +1355,11 @@ const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerTopLeftShape: { + default: null, + "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, + }, + cornerBottomLeftShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -974,7 +1373,11 @@ const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerStartStartShape: { + default: null, + "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, + }, + cornerEndStartShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -988,7 +1391,11 @@ const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerStartEndShape: { + default: null, + "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, + }, + cornerEndEndShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -998,7 +1405,7 @@ const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerTopLeftShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -1008,7 +1415,7 @@ const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerTopRightShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -1018,7 +1425,7 @@ const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerBottomRightShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -1028,7 +1435,7 @@ const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerBottomLeftShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -1038,7 +1445,7 @@ const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerStartStartShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -1048,7 +1455,7 @@ const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerStartEndShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -1058,7 +1465,7 @@ const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerEndStartShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -1068,7 +1475,7 @@ const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerEndEndShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, diff --git a/package/scripts/generate-squircle-css.ts b/package/scripts/generate-squircle-css.ts index 996e05a..f308009 100644 --- a/package/scripts/generate-squircle-css.ts +++ b/package/scripts/generate-squircle-css.ts @@ -1,16 +1,44 @@ import { copyFileSync, mkdirSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { DEFAULT_AMT, SUPPORTS_RULE, VARIANTS, isComment, squircleCssObj } from "../src/variants"; +import { + DEFAULT_AMT, + NONE_RADIUS, + cornerShapeProp, + squircleFullCssObj, + SUPPORTS_RULE, + VARIANTS, + isComment, + squircleCssObj, + type CssLikeObject, +} from "../src/variants"; const __dirname = dirname(fileURLToPath(import.meta.url)); // Support arbitrary, bare, and theme values in one --value() call. // https://tailwindcss.com/docs/adding-custom-styles#functional-utilities const value = "--value(--radius-*, [length])"; +// The reset re-declares the radius purely so `--value()` still decides what +// matches. Without it the utility would accept any suffix at all, turning a +// typo like `rounded-tl-garbage` into a real class. It accepts the same values +// the squircle-* utilities do — theme keys and arbitrary lengths, not paren +// refs — and the radius it emits is the one core emits for the same class. +const roundedValue = value; -function renderUtility(name: string, props: string[]): string { - const obj = squircleCssObj(props, value); +/** + * Re-declare a `rounded-*` utility so it also resets the corners it owns back + * to `round`. Tailwind keeps its own definition and emits it alongside this + * one, so this only has to contribute the reset — which is the initial value, + * hence inert unless a squircle class set a shape on the same element. + */ +function renderRoundedReset(name: string, props: string[], radius?: string): string { + const obj: CssLikeObject = {}; + if (radius) for (const p of props) obj[p] = radius; + for (const p of props) obj[cornerShapeProp(p)] = "round"; + return renderUtility(name, obj); +} + +function renderUtility(name: string, obj: CssLikeObject): string { const lines: string[] = [`@utility ${name} {`]; for (const [key, val] of Object.entries(obj)) { @@ -40,13 +68,14 @@ function generateCss(): string { blocks.push(`\ /* ── Squircle utilities ─────────────────────────────────────── */ /* squircle-amt-[n] sets the superellipse amount (default ${DEFAULT_AMT}) */ -/* squircle-* mirrors rounded-* variants: all, t, r, b, l, s, e, tl, tr, br, bl, ss, se, es, ee */ +/* squircle-* mirrors rounded-* variants: t, r, b, l, s, e, tl, tr, br, bl, ss, se, es, ee */ +/* squircle-*-none and squircle-*-full are static, matching rounded-none and rounded-full */ +/* squircle-amt-[n] only sets the amount — the same thing writing + --squircle-amt yourself does. It applies no corner-shape of its own, so it + never reshapes corners that no squircle-* utility claimed. */ @utility squircle-amt-* { --squircle-amt: --value(--squircle-amt-*, number, [number]); - ${SUPPORTS_RULE} { - corner-shape: superellipse(var(--squircle-amt)); - } }`); for (const [suffix, entry] of Object.entries(VARIANTS)) { @@ -55,8 +84,33 @@ function generateCss(): string { continue; } - const name = suffix ? `squircle-${suffix}-*` : "squircle-*"; - blocks.push(renderUtility(name, entry)); + const root = suffix ? `squircle-${suffix}` : "squircle"; + // Static -none/-full first, then the functional utility — the same shape + // Tailwind uses for rounded-none/rounded-full. -none needs no correction. + blocks.push( + renderUtility(`${root}-none`, Object.fromEntries(entry.map((p) => [p, NONE_RADIUS]))), + ); + blocks.push(renderUtility(`${root}-full`, squircleFullCssObj(entry))); + blocks.push(renderUtility(`${root}-*`, squircleCssObj(entry, value))); + } + + blocks.push(`\ +/* ── rounded-* corner-shape resets ──────────────────────────── */ +/* Tailwind's rounded-* utilities only set a radius, so on their own they can't + take a corner back from a squircle. These re-declarations add the matching + corner-shape reset; Tailwind still emits its own rule for the radius. The + reset is the initial value, so it does nothing unless a squircle class set a + shape on the same element. rounded-*-none needs none: a zero radius has no + visible corner to shape. */`); + + for (const [suffix, entry] of Object.entries(VARIANTS)) { + if (isComment(entry)) continue; + + const root = suffix ? `rounded-${suffix}` : "rounded"; + // The -full name is static, so it needs no value matching and can stay + // radius-free; the functional one has to keep --value() to stay bounded. + blocks.push(renderRoundedReset(`${root}-full`, entry)); + blocks.push(renderRoundedReset(`${root}-*`, entry, roundedValue)); } return blocks.join("\n\n") + "\n"; diff --git a/package/scripts/generate-stylex.ts b/package/scripts/generate-stylex.ts index fdbca5b..029be2c 100644 --- a/package/scripts/generate-stylex.ts +++ b/package/scripts/generate-stylex.ts @@ -1,7 +1,13 @@ import { readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { CAMEL_VARIANTS, VARIANTS, isComment, SUPPORTS_RULE } from "../src/variants"; +import { + CAMEL_VARIANTS, + VARIANTS, + cornerShapeProp, + isComment, + SUPPORTS_RULE, +} from "../src/variants"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -30,7 +36,7 @@ function toCamel(kebab: string): string { /** * Render one `key: (radius, amt) => ({ ... })` entry for the * `stylex.create({...})` literal. The body is one CSS-property block per - * radius prop plus a single `cornerShape` block, both gated by `@supports`. + * radius prop plus a matching shape block for each, both gated by `@supports`. * * Tweak the indentation, comment style, or fallback expression here — the * template file just stamps these strings into its create() call. @@ -49,13 +55,24 @@ function renderVariant(side: string, kebabProps: string[]): string { }) .join("\n"); + // One shape block per radius prop, so a variant only reshapes the corners it + // actually sets a radius on. `border-radius` maps to the `cornerShape` + // shorthand, the narrower props to their per-corner longhands. + const shapeBlocks = kebabProps + .map((prop) => + [ + `${indent}${indent}${toCamel(cornerShapeProp(prop))}: {`, + `${indent}${indent}${indent}default: null,`, + `${indent}${indent}${indent}"${SUPPORTS_RULE}": \`superellipse(\${amt ?? 2})\`,`, + `${indent}${indent}},`, + ].join("\n"), + ) + .join("\n"); + return [ `${indent}${side}: (radius: string | number, amt: string | number | undefined) => ({`, radiusBlocks, - `${indent}${indent}cornerShape: {`, - `${indent}${indent}${indent}default: null,`, - `${indent}${indent}${indent}"${SUPPORTS_RULE}": \`superellipse(\${amt ?? 2})\`,`, - `${indent}${indent}},`, + shapeBlocks, `${indent}}),`, ].join("\n"); } diff --git a/package/src/__snapshots__/squircle-css.test.ts.snap b/package/src/__snapshots__/squircle-css.test.ts.snap index 5809a77..2b45d26 100644 --- a/package/src/__snapshots__/squircle-css.test.ts.snap +++ b/package/src/__snapshots__/squircle-css.test.ts.snap @@ -8,7 +8,8 @@ exports[`squircle.css utilities > squircle-b-md snapshot 1`] = ` --squircle-r: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-bottom-left-radius: var(--squircle-r); border-bottom-right-radius: var(--squircle-r); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-bottom-left-shape: superellipse(var(--squircle-amt, 2)); + corner-bottom-right-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -18,7 +19,7 @@ exports[`squircle.css utilities > squircle-bl-md snapshot 1`] = ` border-bottom-left-radius: var(--radius-md); @supports (corner-shape: superellipse(2)) { border-bottom-left-radius: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-bottom-left-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -28,7 +29,7 @@ exports[`squircle.css utilities > squircle-br-md snapshot 1`] = ` border-bottom-right-radius: var(--radius-md); @supports (corner-shape: superellipse(2)) { border-bottom-right-radius: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-bottom-right-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -41,7 +42,8 @@ exports[`squircle.css utilities > squircle-e-md snapshot 1`] = ` --squircle-r: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-start-end-radius: var(--squircle-r); border-end-end-radius: var(--squircle-r); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-start-end-shape: superellipse(var(--squircle-amt, 2)); + corner-end-end-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -51,7 +53,7 @@ exports[`squircle.css utilities > squircle-ee-md snapshot 1`] = ` border-end-end-radius: var(--radius-md); @supports (corner-shape: superellipse(2)) { border-end-end-radius: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-end-end-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -61,7 +63,7 @@ exports[`squircle.css utilities > squircle-es-md snapshot 1`] = ` border-end-start-radius: var(--radius-md); @supports (corner-shape: superellipse(2)) { border-end-start-radius: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-end-start-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -74,7 +76,8 @@ exports[`squircle.css utilities > squircle-l-md snapshot 1`] = ` --squircle-r: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-top-left-radius: var(--squircle-r); border-bottom-left-radius: var(--squircle-r); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-top-left-shape: superellipse(var(--squircle-amt, 2)); + corner-bottom-left-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -98,7 +101,8 @@ exports[`squircle.css utilities > squircle-r-md snapshot 1`] = ` --squircle-r: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-top-right-radius: var(--squircle-r); border-bottom-right-radius: var(--squircle-r); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-top-right-shape: superellipse(var(--squircle-amt, 2)); + corner-bottom-right-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -111,7 +115,8 @@ exports[`squircle.css utilities > squircle-s-md snapshot 1`] = ` --squircle-r: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-start-start-radius: var(--squircle-r); border-end-start-radius: var(--squircle-r); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-start-start-shape: superellipse(var(--squircle-amt, 2)); + corner-end-start-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -121,7 +126,7 @@ exports[`squircle.css utilities > squircle-se-md snapshot 1`] = ` border-start-end-radius: var(--radius-md); @supports (corner-shape: superellipse(2)) { border-start-end-radius: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-start-end-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -131,7 +136,7 @@ exports[`squircle.css utilities > squircle-ss-md snapshot 1`] = ` border-start-start-radius: var(--radius-md); @supports (corner-shape: superellipse(2)) { border-start-start-radius: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-start-start-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -144,7 +149,8 @@ exports[`squircle.css utilities > squircle-t-md snapshot 1`] = ` --squircle-r: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-top-left-radius: var(--squircle-r); border-top-right-radius: var(--squircle-r); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-top-left-shape: superellipse(var(--squircle-amt, 2)); + corner-top-right-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -154,7 +160,7 @@ exports[`squircle.css utilities > squircle-tl-md snapshot 1`] = ` border-top-left-radius: var(--radius-md); @supports (corner-shape: superellipse(2)) { border-top-left-radius: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-top-left-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -164,7 +170,7 @@ exports[`squircle.css utilities > squircle-tr-md snapshot 1`] = ` border-top-right-radius: var(--radius-md); @supports (corner-shape: superellipse(2)) { border-top-right-radius: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-top-right-shape: superellipse(var(--squircle-amt, 2)); } }" `; diff --git a/package/src/__snapshots__/tailwind.test.ts.snap b/package/src/__snapshots__/tailwind.test.ts.snap index 9090b84..92994a5 100644 --- a/package/src/__snapshots__/tailwind.test.ts.snap +++ b/package/src/__snapshots__/tailwind.test.ts.snap @@ -8,7 +8,8 @@ exports[`plugin.ts utilities > squircle-b-md snapshot 1`] = ` --squircle-r: calc(0.375rem * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-bottom-left-radius: var(--squircle-r); border-bottom-right-radius: var(--squircle-r); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-bottom-left-shape: superellipse(var(--squircle-amt, 2)); + corner-bottom-right-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -18,7 +19,7 @@ exports[`plugin.ts utilities > squircle-bl-md snapshot 1`] = ` border-bottom-left-radius: 0.375rem; @supports (corner-shape: superellipse(2)) { border-bottom-left-radius: calc(0.375rem * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-bottom-left-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -28,7 +29,7 @@ exports[`plugin.ts utilities > squircle-br-md snapshot 1`] = ` border-bottom-right-radius: 0.375rem; @supports (corner-shape: superellipse(2)) { border-bottom-right-radius: calc(0.375rem * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-bottom-right-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -41,7 +42,8 @@ exports[`plugin.ts utilities > squircle-e-md snapshot 1`] = ` --squircle-r: calc(0.375rem * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-start-end-radius: var(--squircle-r); border-end-end-radius: var(--squircle-r); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-start-end-shape: superellipse(var(--squircle-amt, 2)); + corner-end-end-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -51,7 +53,7 @@ exports[`plugin.ts utilities > squircle-ee-md snapshot 1`] = ` border-end-end-radius: 0.375rem; @supports (corner-shape: superellipse(2)) { border-end-end-radius: calc(0.375rem * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-end-end-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -61,7 +63,7 @@ exports[`plugin.ts utilities > squircle-es-md snapshot 1`] = ` border-end-start-radius: 0.375rem; @supports (corner-shape: superellipse(2)) { border-end-start-radius: calc(0.375rem * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-end-start-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -74,7 +76,8 @@ exports[`plugin.ts utilities > squircle-l-md snapshot 1`] = ` --squircle-r: calc(0.375rem * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-top-left-radius: var(--squircle-r); border-bottom-left-radius: var(--squircle-r); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-top-left-shape: superellipse(var(--squircle-amt, 2)); + corner-bottom-left-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -98,7 +101,8 @@ exports[`plugin.ts utilities > squircle-r-md snapshot 1`] = ` --squircle-r: calc(0.375rem * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-top-right-radius: var(--squircle-r); border-bottom-right-radius: var(--squircle-r); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-top-right-shape: superellipse(var(--squircle-amt, 2)); + corner-bottom-right-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -111,7 +115,8 @@ exports[`plugin.ts utilities > squircle-s-md snapshot 1`] = ` --squircle-r: calc(0.375rem * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-start-start-radius: var(--squircle-r); border-end-start-radius: var(--squircle-r); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-start-start-shape: superellipse(var(--squircle-amt, 2)); + corner-end-start-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -121,7 +126,7 @@ exports[`plugin.ts utilities > squircle-se-md snapshot 1`] = ` border-start-end-radius: 0.375rem; @supports (corner-shape: superellipse(2)) { border-start-end-radius: calc(0.375rem * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-start-end-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -131,7 +136,7 @@ exports[`plugin.ts utilities > squircle-ss-md snapshot 1`] = ` border-start-start-radius: 0.375rem; @supports (corner-shape: superellipse(2)) { border-start-start-radius: calc(0.375rem * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-start-start-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -144,7 +149,8 @@ exports[`plugin.ts utilities > squircle-t-md snapshot 1`] = ` --squircle-r: calc(0.375rem * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-top-left-radius: var(--squircle-r); border-top-right-radius: var(--squircle-r); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-top-left-shape: superellipse(var(--squircle-amt, 2)); + corner-top-right-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -154,7 +160,7 @@ exports[`plugin.ts utilities > squircle-tl-md snapshot 1`] = ` border-top-left-radius: 0.375rem; @supports (corner-shape: superellipse(2)) { border-top-left-radius: calc(0.375rem * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-top-left-shape: superellipse(var(--squircle-amt, 2)); } }" `; @@ -164,7 +170,7 @@ exports[`plugin.ts utilities > squircle-tr-md snapshot 1`] = ` border-top-right-radius: 0.375rem; @supports (corner-shape: superellipse(2)) { border-top-right-radius: calc(0.375rem * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); - corner-shape: superellipse(var(--squircle-amt, 2)); + corner-top-right-shape: superellipse(var(--squircle-amt, 2)); } }" `; diff --git a/package/src/panda.test.ts b/package/src/panda.test.ts index 7a805fc..a9dd8e0 100644 --- a/package/src/panda.test.ts +++ b/package/src/panda.test.ts @@ -70,7 +70,7 @@ describe("panda preset transform output", () => { { "@supports (corner-shape: superellipse(2))": { "borderTopLeftRadius": "calc(var(--radii-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))))", - "cornerShape": "superellipse(var(--squircle-amt, 2))", + "cornerTopLeftShape": "superellipse(var(--squircle-amt, 2))", }, "borderTopLeftRadius": "var(--radii-md)", } @@ -89,7 +89,8 @@ describe("panda preset transform output", () => { "--squircle-r": "calc(var(--radii-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))))", "borderTopLeftRadius": "var(--squircle-r)", "borderTopRightRadius": "var(--squircle-r)", - "cornerShape": "superellipse(var(--squircle-amt, 2))", + "cornerTopLeftShape": "superellipse(var(--squircle-amt, 2))", + "cornerTopRightShape": "superellipse(var(--squircle-amt, 2))", }, "borderTopLeftRadius": "var(--radii-md)", "borderTopRightRadius": "var(--radii-md)", @@ -97,7 +98,7 @@ describe("panda preset transform output", () => { `); }); - it("squircleAmount sets the variable and gates corner-shape", () => { + it("squircleAmount sets only the variable, applying no shape of its own", () => { const out = preset.utilities!.extend!["squircleAmount"]!.transform!("3", { token: mockToken, raw: "3", @@ -106,9 +107,6 @@ describe("panda preset transform output", () => { expect(out).toMatchInlineSnapshot(` { "--squircle-amt": "3", - "@supports (corner-shape: superellipse(2))": { - "cornerShape": "superellipse(var(--squircle-amt))", - }, } `); }); @@ -141,11 +139,8 @@ describe("panda preset options", () => { mockArgs("3"), ) as Record; expect(amtOut["--my-amt"]).toBe("3"); - const amtSupports = amtOut["@supports (corner-shape: superellipse(2))"] as Record< - string, - string - >; - expect(amtSupports["cornerShape"]).toBe("superellipse(var(--my-amt))"); + // The amount utility contributes no shape, so the variable is all it emits. + expect(Object.keys(amtOut)).toEqual(["--my-amt"]); const allOutput = JSON.stringify({ radiusOut, amtOut }); expect(allOutput).not.toContain("--squircle-amt"); diff --git a/package/src/panda.ts b/package/src/panda.ts index 8c88928..2566f5e 100644 --- a/package/src/panda.ts +++ b/package/src/panda.ts @@ -55,15 +55,13 @@ export function squirclePandaPreset(options: SquirclePandaPresetOptions = {}) { }; } + // Only sets the amount — the same thing writing the custom property yourself + // does. Applying a corner-shape here would reshape all four corners, + // including ones no squircle utility claimed. utilities["squircleAmount"] = { shorthand: "squircleAmt", values: { type: "number" }, - transform: (value) => ({ - [amtVar]: value, - [SUPPORTS_RULE]: { - cornerShape: `superellipse(var(${amtVar}))`, - }, - }), + transform: (value) => ({ [amtVar]: value }), }; return definePreset({ diff --git a/package/src/squircle-css.test.ts b/package/src/squircle-css.test.ts index e165a51..3c25090 100644 --- a/package/src/squircle-css.test.ts +++ b/package/src/squircle-css.test.ts @@ -1,13 +1,14 @@ import { describe, expect, it } from "vitest"; import { createCompiler, VARIANTS } from "./test-utils"; +import { cornerShapeProp } from "./variants"; const { compileCss } = createCompiler(import.meta.dirname); describe("squircle.css utilities", () => { - it("squircle-amt-* sets --squircle-amt and corner-shape", async () => { + it("squircle-amt-* sets only --squircle-amt, applying no shape of its own", async () => { const css = await compileCss(["squircle-amt-2"]); expect(css).toContain("--squircle-amt: 2"); - expect(css).toContain("corner-shape: superellipse(var(--squircle-amt))"); + expect(css).not.toContain("corner-shape:"); }); for (const [suffix, props] of Object.entries(VARIANTS)) { @@ -23,7 +24,11 @@ describe("squircle.css utilities", () => { it(`${className} applies corrected radius in @supports block`, async () => { const css = await compileCss([className]); expect(css).toContain("@supports (corner-shape: superellipse(2))"); - expect(css).toContain("corner-shape: superellipse(var(--squircle-amt, 2))"); + // Only the corners this variant sets a radius on get reshaped, so the + // all-corners utility uses the shorthand and the rest use longhands. + for (const prop of props) { + expect(css).toContain(`${cornerShapeProp(prop)}: superellipse(var(--squircle-amt, 2))`); + } expect(css).toContain("pow(2, -0.5)"); }); @@ -50,6 +55,36 @@ describe("squircle.css utilities", () => { }); } + describe("static none/full utilities (matching rounded-none/rounded-full)", () => { + it("squircle-full uses calc(infinity * 1px) uncorrected, with corner-shape", async () => { + const css = await compileCss(["squircle-full"]); + expect(css).toContain("border-radius: calc(infinity * 1px)"); + expect(css).toContain("corner-shape: superellipse(var(--squircle-amt, 2))"); + // Correcting an infinite radius is a no-op, so no correction is emitted. + expect(css).not.toContain("pow("); + expect(css).not.toContain("--squircle-r"); + }); + + it("squircle-t-full applies the full radius to side variants", async () => { + const css = await compileCss(["squircle-t-full"]); + expect(css).toContain("border-top-left-radius: calc(infinity * 1px)"); + expect(css).toContain("border-top-right-radius: calc(infinity * 1px)"); + }); + + it("squircle-none is plain zero radius with no correction", async () => { + const css = await compileCss(["squircle-none"]); + expect(css).toContain("border-radius: 0"); + expect(css).not.toContain("@supports"); + expect(css).not.toContain("corner-shape"); + }); + + it("squircle-tl-none zeroes a single corner", async () => { + const css = await compileCss(["squircle-tl-none"]); + expect(css).toContain("border-top-left-radius: 0"); + expect(css).not.toContain("@supports"); + }); + }); + describe("arbitrary values", () => { it("squircle-[1rem] emits literal length in fallback and calc", async () => { const css = await compileCss(["squircle-[1rem]"]); @@ -87,7 +122,7 @@ describe("squircle.css utilities", () => { it("squircle-amt-[4.5] accepts arbitrary bare number", async () => { const css = await compileCss(["squircle-amt-[4.5]"]); expect(css).toContain("--squircle-amt: 4.5"); - expect(css).toContain("corner-shape: superellipse(var(--squircle-amt))"); + expect(css).not.toContain("corner-shape:"); }); it("squircle-amt-[1em] is rejected (unit-bearing values are not numbers)", async () => { diff --git a/package/src/stylex.ts b/package/src/stylex.ts index 82a8bf5..c226003 100644 --- a/package/src/stylex.ts +++ b/package/src/stylex.ts @@ -79,7 +79,11 @@ export const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerTopLeftShape: { + default: null, + "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, + }, + cornerTopRightShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -94,7 +98,11 @@ export const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerTopRightShape: { + default: null, + "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, + }, + cornerBottomRightShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -109,7 +117,11 @@ export const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerBottomLeftShape: { + default: null, + "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, + }, + cornerBottomRightShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -124,7 +136,11 @@ export const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerTopLeftShape: { + default: null, + "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, + }, + cornerBottomLeftShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -141,7 +157,11 @@ export const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerStartStartShape: { + default: null, + "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, + }, + cornerEndStartShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -156,7 +176,11 @@ export const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerStartEndShape: { + default: null, + "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, + }, + cornerEndEndShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -169,7 +193,7 @@ export const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerTopLeftShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -180,7 +204,7 @@ export const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerTopRightShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -191,7 +215,7 @@ export const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerBottomRightShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -202,7 +226,7 @@ export const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerBottomLeftShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -215,7 +239,7 @@ export const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerStartStartShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -226,7 +250,7 @@ export const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerStartEndShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -237,7 +261,7 @@ export const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerEndStartShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, @@ -248,7 +272,7 @@ export const squircle = stylex.create({ default: radius, "@supports (corner-shape: superellipse(2))": `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt ?? 2}))))`, }, - cornerShape: { + cornerEndEndShape: { default: null, "@supports (corner-shape: superellipse(2))": `superellipse(${amt ?? 2})`, }, diff --git a/package/src/tailwind-corner-shape.test.ts b/package/src/tailwind-corner-shape.test.ts new file mode 100644 index 0000000..d930d57 --- /dev/null +++ b/package/src/tailwind-corner-shape.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; +import { createCompiler, VARIANTS } from "./test-utils"; +import { cornerShapeProp } from "./variants"; + +const { compileCss, compilePlugin } = createCompiler(import.meta.dirname); +const paths = [ + ["css", compileCss], + ["plugin", compilePlugin], +] as const; + +// The declaration form of the shorthand. The `@supports` condition also +// mentions `corner-shape`, so matching the bare property name would always hit. +const SHORTHAND_DECL = "corner-shape: superellipse(var("; + +describe("squircle utilities only reshape the corners they own", () => { + for (const [pathName, compile] of paths) { + describe(pathName, () => { + for (const [suffix, props] of Object.entries(VARIANTS)) { + const className = suffix ? `squircle-${suffix}-md` : "squircle-md"; + const isAllCorners = props[0] === "border-radius"; + + it(`${className} uses ${isAllCorners ? "the shorthand" : "per-corner longhands"}`, async () => { + const css = await compile([className]); + for (const prop of props) { + expect(css).toContain(`${cornerShapeProp(prop)}: superellipse(var(--squircle-amt, 2))`); + } + if (isAllCorners) { + expect(css).toContain(SHORTHAND_DECL); + } else { + // The shorthand would reshape all four corners, including ones + // this utility never set a radius on. + expect(css).not.toContain(SHORTHAND_DECL); + } + }); + } + + it("squircle-t-md leaves the bottom corners' shape alone", async () => { + const css = await compile(["squircle-t-md"]); + expect(css).not.toContain("corner-bottom"); + expect(css).not.toContain(SHORTHAND_DECL); + }); + + it("squircle-tl-md shapes only the top-left corner", async () => { + const css = await compile(["squircle-tl-md"]); + expect(css).toContain("corner-top-left-shape:"); + expect(css).not.toContain("corner-top-right-shape:"); + expect(css).not.toContain("corner-bottom"); + }); + }); + } +}); + +describe("rounded-* utilities reset the corner shape they own", () => { + for (const [pathName, compile] of paths) { + describe(pathName, () => { + it("rounded-tl-lg resets only the top-left corner", async () => { + const css = await compile(["rounded-tl-lg"]); + expect(css).toContain("corner-top-left-shape: round"); + expect(css).not.toContain("corner-top-right-shape: round"); + }); + + it("rounded-t-lg resets both top corners", async () => { + const css = await compile(["rounded-t-lg"]); + expect(css).toContain("corner-top-left-shape: round"); + expect(css).toContain("corner-top-right-shape: round"); + expect(css).not.toContain("corner-bottom-left-shape: round"); + }); + + it("rounded-lg resets all four via the shorthand", async () => { + const css = await compile(["rounded-lg"]); + expect(css).toContain("corner-shape: round"); + }); + + it("rounded-tl-full resets the corner too", async () => { + const css = await compile(["rounded-tl-full"]); + expect(css).toContain("corner-top-left-shape: round"); + }); + + it("rounded-tl-none needs no reset (a zero radius has no visible corner)", async () => { + const css = await compile(["rounded-tl-none"]); + expect(css).toContain("border-top-left-radius: 0"); + expect(css).not.toContain("corner-top-left-shape: round"); + }); + + // Same specificity, so the reset only wins if it is emitted later. + it("the reset is emitted after the squircle rule it has to override", async () => { + const css = await compile(["squircle-lg", "rounded-tl-lg"]); + expect(css.indexOf("corner-top-left-shape: round")).toBeGreaterThan( + css.indexOf(".squircle-lg {"), + ); + }); + + // The reset accepts the same values the squircle-* utilities do: theme + // keys and arbitrary lengths, nothing else. Anything wider would reset + // corners for values this package tells you not to write. + for (const value of ["lg", "[3px]"]) { + it(`rounded-tl-${value} gets the reset`, async () => { + const css = await compile([`rounded-tl-${value}`]); + expect(css).toContain("corner-top-left-shape: round"); + }); + } + + // A corner named this way keeps its squircle shape — use a theme key. + for (const value of ["[var(--my-r)]", "(--my-r)", "[50%]", "[foo]"]) { + it(`rounded-tl-${value} gets no reset (not a length)`, async () => { + const css = await compile([`rounded-tl-${value}`]); + expect(css).not.toContain("corner-top-left-shape: round"); + }); + } + + it("a value Tailwind itself rejects stays unmatched", async () => { + const css = await compile(["rounded-tl-garbage"]); + expect(css).not.toContain("rounded-tl-garbage"); + }); + }); + } + + // The plugin bounds matching with its `values` map, so its reset carries no + // radius. The generated CSS has no such map: it keeps a `--value()` radius + // purely so the utility stays bounded, and emits the value core emits anyway. + it("plugin: the reset rule carries no radius of its own", async () => { + const css = await compilePlugin(["rounded-tl-lg"]); + const resetRule = (css.match(/\.rounded-tl-lg \{[^}]*\}/g) ?? []).find((r) => + r.includes("corner-top-left-shape"), + ); + expect(resetRule).toBeDefined(); + expect(resetRule).not.toContain("border-top-left-radius"); + }); +}); + +describe("squircle-amt only sets the amount", () => { + for (const [pathName, compile] of paths) { + it(`${pathName}: squircle-amt applies no corner-shape of its own`, async () => { + const css = await compile(["squircle-amt-[2]"]); + expect(css).toContain("--squircle-amt: 2"); + expect(css).not.toContain("corner-shape:"); + }); + + it(`${pathName}: it does not reshape corners a side variant left alone`, async () => { + const css = await compile(["squircle-t-md", "squircle-amt-[3]"]); + expect(css).toContain("--squircle-amt: 3"); + expect(css).not.toContain("corner-bottom"); + expect(css).not.toContain(SHORTHAND_DECL); + }); + + it(`${pathName}: the amount still reaches the variants through the variable`, async () => { + const css = await compile(["squircle-t-md"]); + expect(css).toContain("corner-top-left-shape: superellipse(var(--squircle-amt, 2))"); + }); + } +}); diff --git a/package/src/tailwind-merge.test.ts b/package/src/tailwind-merge.test.ts new file mode 100644 index 0000000..672e068 --- /dev/null +++ b/package/src/tailwind-merge.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { extendTailwindMerge } from "tailwind-merge"; +import { squircleMergeConfig } from "./tailwind"; +import { createCompiler } from "./test-utils"; + +const twMerge = extendTailwindMerge(squircleMergeConfig); +const { compileCss, compilePlugin } = createCompiler(import.meta.dirname); + +describe("squircleMergeConfig", () => { + describe("squircle vs squircle", () => { + it("later value wins within the same group", () => { + expect(twMerge("squircle-md squircle-lg")).toBe("squircle-lg"); + expect(twMerge("squircle-tl-sm squircle-tl-lg")).toBe("squircle-tl-lg"); + }); + + it("all-corners cancels earlier side and corner utilities", () => { + expect(twMerge("squircle-tl-sm squircle-md")).toBe("squircle-md"); + expect(twMerge("squircle-t-lg squircle-md")).toBe("squircle-md"); + expect(twMerge("squircle-tl-full squircle-none")).toBe("squircle-none"); + }); + + it("a single corner does not cancel an earlier all-corners utility", () => { + expect(twMerge("squircle-md squircle-tl-sm")).toBe("squircle-md squircle-tl-sm"); + expect(twMerge("squircle-md squircle-tl-full")).toBe("squircle-md squircle-tl-full"); + }); + + it("a side cancels its own two corners", () => { + expect(twMerge("squircle-tl-sm squircle-t-lg")).toBe("squircle-t-lg"); + expect(twMerge("squircle-tr-sm squircle-t-lg")).toBe("squircle-t-lg"); + expect(twMerge("squircle-ss-sm squircle-s-lg")).toBe("squircle-s-lg"); + }); + + it("a side does not cancel an unrelated corner or an all-corners utility", () => { + expect(twMerge("squircle-bl-sm squircle-t-lg")).toBe("squircle-bl-sm squircle-t-lg"); + expect(twMerge("squircle-md squircle-t-lg")).toBe("squircle-md squircle-t-lg"); + }); + + it("a corner does not cancel a side", () => { + expect(twMerge("squircle-t-lg squircle-tl-sm")).toBe("squircle-t-lg squircle-tl-sm"); + }); + }); + + describe("squircle vs rounded", () => { + it("all-corners utilities cancel each other across families", () => { + expect(twMerge("rounded-lg squircle-md")).toBe("squircle-md"); + expect(twMerge("squircle-md rounded-lg")).toBe("rounded-lg"); + expect(twMerge("rounded-full squircle-full")).toBe("squircle-full"); + }); + + it("all-corners cancels the other family's sides and corners", () => { + expect(twMerge("rounded-tl-sm squircle-md")).toBe("squircle-md"); + expect(twMerge("rounded-t-lg squircle-md")).toBe("squircle-md"); + expect(twMerge("squircle-tl-sm rounded-lg")).toBe("rounded-lg"); + }); + + it("a narrower utility refines the other family's broader one", () => { + expect(twMerge("squircle-md rounded-tl-sm")).toBe("squircle-md rounded-tl-sm"); + expect(twMerge("rounded-lg squircle-tl-full")).toBe("rounded-lg squircle-tl-full"); + }); + + it("a side cancels the other family's matching side and corners", () => { + expect(twMerge("squircle-tl-sm rounded-t-lg")).toBe("rounded-t-lg"); + expect(twMerge("rounded-tr-sm squircle-t-none")).toBe("squircle-t-none"); + }); + + it("corners cancel only the matching corner across families", () => { + expect(twMerge("rounded-tl-sm squircle-tl-lg")).toBe("squircle-tl-lg"); + expect(twMerge("rounded-t-lg squircle-tl-sm")).toBe("rounded-t-lg squircle-tl-sm"); + }); + }); + + describe("squircle-amt is orthogonal to radius classes", () => { + it("radius utilities never cancel squircle-amt", () => { + expect(twMerge("squircle-amt-3 squircle-md")).toBe("squircle-amt-3 squircle-md"); + expect(twMerge("squircle-amt-3 rounded-lg")).toBe("squircle-amt-3 rounded-lg"); + }); + + it("later squircle-amt wins over earlier squircle-amt", () => { + expect(twMerge("squircle-amt-2 squircle-amt-3")).toBe("squircle-amt-3"); + }); + }); +}); + +/** + * Keeping both classes only refines the corner if the narrower utility also + * wins the cascade — these all have single-class specificity, so it comes down + * to emission order. Tailwind sorts radius utilities by property breadth + * (`border-radius` shorthand, then two-corner sides, then single corners) and + * interleaves `squircle-*` with `rounded-*` in each tier, so the narrower + * utility lands later regardless of family. These cases pin that down. + */ +describe("cascade order agrees with merge semantics", () => { + // [broader utility, narrower utility that must win the overlapping corner] + const pairs: [string, string][] = [ + ["squircle-md", "squircle-t-lg"], + ["squircle-md", "squircle-tl-sm"], + ["squircle-md", "squircle-tl-full"], + ["squircle-md", "squircle-tl-none"], + ["squircle-t-lg", "squircle-tl-sm"], + ["squircle-full", "squircle-tl-sm"], + ["squircle-none", "squircle-tl-full"], + ["squircle-t-full", "squircle-tl-none"], + ["squircle-s-md", "squircle-ss-sm"], + ["squircle-e-full", "squircle-ee-none"], + ["squircle-md", "rounded-tl-sm"], + ["rounded-lg", "squircle-tl-full"], + ["rounded-lg", "squircle-tl-none"], + ["rounded-full", "squircle-tl-sm"], + ["rounded-t-lg", "squircle-tl-sm"], + ]; + + function selectorIndex(css: string, className: string): number { + const index = css.indexOf(`.${className} {`); + expect(index, `${className} was not emitted`).toBeGreaterThan(-1); + return index; + } + + for (const [broad, narrow] of pairs) { + it(`"${broad} ${narrow}" keeps both, and ${narrow} is emitted last`, async () => { + expect(twMerge(`${broad} ${narrow}`)).toBe(`${broad} ${narrow}`); + + for (const compile of [compileCss, compilePlugin]) { + const css = await compile([broad, narrow]); + expect(selectorIndex(css, narrow)).toBeGreaterThan(selectorIndex(css, broad)); + } + }); + } +}); diff --git a/package/src/tailwind.test.ts b/package/src/tailwind.test.ts index fa7e8b2..7c9d48d 100644 --- a/package/src/tailwind.test.ts +++ b/package/src/tailwind.test.ts @@ -4,10 +4,10 @@ import { createCompiler, VARIANTS } from "./test-utils"; const { compilePlugin } = createCompiler(import.meta.dirname); describe("plugin.ts utilities", () => { - it("squircle-amt-* sets --squircle-amt and corner-shape", async () => { + it("squircle-amt-* sets only --squircle-amt, applying no shape of its own", async () => { const css = await compilePlugin(["squircle-amt-[2]"]); expect(css).toContain("--squircle-amt: 2"); - expect(css).toContain("corner-shape: superellipse(var(--squircle-amt))"); + expect(css).not.toContain("corner-shape:"); }); for (const [suffix, props] of Object.entries(VARIANTS)) { @@ -48,6 +48,49 @@ describe("plugin.ts utilities", () => { }); } + describe("static none/full utilities (matching rounded-none/rounded-full)", () => { + it("squircle-full uses calc(infinity * 1px) uncorrected, with corner-shape", async () => { + const css = await compilePlugin(["squircle-full"]); + expect(css).toContain("border-radius: calc(infinity * 1px)"); + expect(css).toContain("corner-shape: superellipse(var(--squircle-amt, 2))"); + // Correcting an infinite radius is a no-op, so no correction is emitted. + expect(css).not.toContain("pow("); + expect(css).not.toContain("--squircle-r"); + }); + + it("squircle-full is emitted exactly once (no v3-compat theme duplicate)", async () => { + const css = await compilePlugin(["squircle-full"]); + expect(css.match(/\.squircle-full \{/g)).toHaveLength(1); + expect(css).not.toContain("9999px"); + }); + + it("squircle-tl-full applies the full radius to corner variants", async () => { + const css = await compilePlugin(["squircle-tl-full"]); + expect(css).toContain("border-top-left-radius: calc(infinity * 1px)"); + }); + + it("squircle-none is plain zero radius with no correction", async () => { + const css = await compilePlugin(["squircle-none"]); + expect(css).toContain("border-radius: 0"); + expect(css).not.toContain("@supports"); + expect(css).not.toContain("corner-shape"); + expect(css.match(/\.squircle-none \{/g)).toHaveLength(1); + }); + + it("squircle-t-none zeroes side variants", async () => { + const css = await compilePlugin(["squircle-t-none"]); + expect(css).toContain("border-top-left-radius: 0"); + expect(css).toContain("border-top-right-radius: 0"); + }); + + it("custom prefix applies to static none/full utilities", async () => { + const css = await compilePlugin(["se-none", "se-full"], "prefix: se;"); + expect(css).toContain(".se-none"); + expect(css).toContain(".se-full"); + expect(css).toContain("border-radius: calc(infinity * 1px)"); + }); + }); + describe("arbitrary and invalid values", () => { it("squircle-[1rem] emits literal length", async () => { const css = await compilePlugin(["squircle-[1rem]"]); @@ -105,7 +148,7 @@ describe("plugin.ts custom options", () => { it("custom prefix works for amt utility", async () => { const css = await compilePlugin(["se-amt-[3]"], "prefix: se;"); expect(css).toContain(".se-amt-\\[3\\]"); - expect(css).toContain("corner-shape: superellipse"); + expect(css).toContain("--squircle-amt: 3"); }); it("custom amt-var changes the CSS variable name", async () => { @@ -117,7 +160,7 @@ describe("plugin.ts custom options", () => { it("custom amt-var applies to amt utility", async () => { const css = await compilePlugin(["squircle-amt-[3]"], "amt-var: --se-amt;"); expect(css).toContain("--se-amt: 3"); - expect(css).toContain("superellipse(var(--se-amt))"); + expect(css).not.toContain("corner-shape:"); }); it("custom r-var changes the intermediate CSS variable name", async () => { diff --git a/package/src/tailwind.ts b/package/src/tailwind.ts index cd430d7..cf9bb86 100644 --- a/package/src/tailwind.ts +++ b/package/src/tailwind.ts @@ -7,7 +7,9 @@ import plugin from "tailwindcss/plugin"; import { DEFAULT_AMOUNT_VAR_NAME, DEFAULT_R_VAR_NAME, - SUPPORTS_RULE, + NONE_RADIUS, + cornerShapeProp, + squircleFullCssObj, squircleCssObj, variantEntries, } from "./variants"; @@ -30,26 +32,35 @@ export interface SquirclePluginOptions { const squircle: ReturnType> = plugin.withOptions((options = {}) => // eslint-disable-next-line @typescript-eslint/unbound-method - ({ matchUtilities, theme }) => { + ({ addUtilities, matchUtilities, theme }) => { const amtVar = options.amtVar ?? options["amt-var"] ?? DEFAULT_AMOUNT_VAR_NAME; const rVar = options.rVar ?? options["r-var"] ?? DEFAULT_R_VAR_NAME; const prefix = options.prefix ?? "squircle"; - const radiusValues = theme("borderRadius"); + // Drop none/full from the functional values (the v3-compat theme still + // carries them) — they're registered as static utilities below instead, + // with the same values Tailwind uses for rounded-none/rounded-full. + const { none: _none, full: _full, ...radiusValues } = theme("borderRadius") ?? {}; + // Only sets the amount — the same thing writing the custom property + // yourself does. Applying a corner-shape here would reshape all four + // corners, including ones no squircle-* utility claimed. matchUtilities( - { - [`${prefix}-amt`]: (value: string) => ({ - [amtVar]: value, - [SUPPORTS_RULE]: { - "corner-shape": `superellipse(var(${amtVar}))`, - }, - }), - }, + { [`${prefix}-amt`]: (value: string) => ({ [amtVar]: value }) }, { type: "number" }, ); for (const [suffix, props] of variantEntries()) { const name = suffix ? `${prefix}-${suffix}` : prefix; + // Static -none/-full utilities, registered the same way Tailwind + // defines rounded-none and rounded-full (0 and calc(infinity * 1px) + // rather than theme values). -none needs no superellipse correction. + addUtilities({ + [`.${name}-none`]: Object.fromEntries(props.map((p) => [p, NONE_RADIUS])), + [`.${name}-full`]: squircleFullCssObj(props, { amtVar }) as Record< + string, + string | Record + >, + }); matchUtilities( { [name]: (value: string) => @@ -60,6 +71,20 @@ const squircle: ReturnType> = }, { type: "length", values: radiusValues }, ); + + // Re-declare the matching rounded-* utility so it also resets the + // corners it owns back to `round`. Tailwind keeps its own definition and + // emits it alongside this one, so the radius still comes from core and + // this contributes only the reset — the initial value, so it is inert + // unless a squircle class set a shape on the same element. Without it a + // rounded-* utility cannot take a corner back from a squircle, since it + // only ever sets a radius. Restricted to lengths, the same values the + // squircle-* utilities accept: a paren ref keeps its squircle shape, so + // reach for a theme key there as everywhere else in this package. + const roundedName = suffix ? `rounded-${suffix}` : "rounded"; + const reset = Object.fromEntries(props.map((p) => [cornerShapeProp(p), "round"])); + addUtilities({ [`.${roundedName}-full`]: reset }); + matchUtilities({ [roundedName]: () => reset }, { type: "length", values: radiusValues }); } }); @@ -67,49 +92,50 @@ export default squircle; // --- tailwind-merge config --------------------------------------------------- -const allRoundedGroups: string[] = [ - "rounded", - "rounded-s", - "rounded-e", - "rounded-t", - "rounded-r", - "rounded-b", - "rounded-l", - "rounded-ss", - "rounded-se", - "rounded-es", - "rounded-ee", - "rounded-tl", - "rounded-tr", - "rounded-br", - "rounded-bl", -]; +// Mirrors tailwind-merge's own `rounded` hierarchy: a later all-corners +// utility cancels earlier side/corner utilities, a side cancels its two +// corners, and a narrower utility never cancels a broader one — so +// `squircle-md squircle-tl-sm` keeps both, refining one corner. Each squircle +// group also conflicts with its `rounded` counterpart (and vice versa), since +// both set the same border-radius properties. `squircle-amt-*` is orthogonal: +// it controls corner shape, not radius, so radius classes never cancel it. +const SIDE_CORNERS = { + t: ["tl", "tr"], + r: ["tr", "br"], + b: ["br", "bl"], + l: ["tl", "bl"], + s: ["ss", "es"], + e: ["se", "ee"], +} as const; +const CORNERS = ["tl", "tr", "br", "bl", "ss", "se", "es", "ee"] as const; +const SIDES = Object.keys(SIDE_CORNERS) as (keyof typeof SIDE_CORNERS)[]; +const ALL_SUFFIXES: readonly string[] = ["", ...SIDES, ...CORNERS]; + +const sq = (suffix: string) => (suffix ? `squircle-${suffix}` : "squircle"); +const rd = (suffix: string) => (suffix ? `rounded-${suffix}` : "rounded"); + +const conflictingClassGroups: Record = { + squircle: [...ALL_SUFFIXES.slice(1).map(sq), ...ALL_SUFFIXES.map(rd)], + rounded: ALL_SUFFIXES.map(sq), +}; +for (const side of SIDES) { + const corners: readonly string[] = SIDE_CORNERS[side]; + conflictingClassGroups[sq(side)] = [...corners.map(sq), rd(side), ...corners.map(rd)]; + conflictingClassGroups[rd(side)] = [sq(side), ...corners.map(sq)]; +} +for (const corner of CORNERS) { + conflictingClassGroups[sq(corner)] = [rd(corner)]; + conflictingClassGroups[rd(corner)] = [sq(corner)]; +} export const squircleMergeConfig = { extend: { classGroups: { - squircle: [ - { squircle: [() => true] }, - { "squircle-t": [() => true] }, - { "squircle-r": [() => true] }, - { "squircle-b": [() => true] }, - { "squircle-l": [() => true] }, - { "squircle-s": [() => true] }, - { "squircle-e": [() => true] }, - { "squircle-tl": [() => true] }, - { "squircle-tr": [() => true] }, - { "squircle-br": [() => true] }, - { "squircle-bl": [() => true] }, - { "squircle-ss": [() => true] }, - { "squircle-se": [() => true] }, - { "squircle-es": [() => true] }, - { "squircle-ee": [() => true] }, - ], + ...Object.fromEntries( + ALL_SUFFIXES.map((suffix) => [sq(suffix), [{ [sq(suffix)]: [() => true] }]]), + ), "squircle-amt": [{ "squircle-amt": [() => true] }], }, - conflictingClassGroups: { - squircle: [...allRoundedGroups, "squircle-amt"], - ...Object.fromEntries(allRoundedGroups.map((g) => [g, ["squircle", "squircle-amt"]])), - }, + conflictingClassGroups, }, -} as const; +}; diff --git a/package/src/variants.ts b/package/src/variants.ts index ad05bb2..8a01759 100644 --- a/package/src/variants.ts +++ b/package/src/variants.ts @@ -6,6 +6,10 @@ export const DEFAULT_AMT = 2 as const; export const DEFAULT_AMOUNT_VAR_NAME = "--squircle-amt" as const; export const DEFAULT_R_VAR_NAME = "--squircle-r" as const; +/** Static value for `squircle-full`; matches Tailwind's `rounded-full`. */ +export const FULL_RADIUS = "calc(infinity * 1px)" as const; +/** Static value for `squircle-none`; matches Tailwind's `rounded-none`. */ +export const NONE_RADIUS = "0" as const; export const DEFAULT_AMT_CSS = `var(${DEFAULT_AMOUNT_VAR_NAME}, ${DEFAULT_AMT})` as const; export const getCornerShape = (varName: string = DEFAULT_AMOUNT_VAR_NAME) => @@ -15,6 +19,36 @@ export function correctedRadius(radius: string, amt: string = DEFAULT_AMT_CSS): return `calc(${radius} * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * ${amt}))))` as const; } +/** + * The corner-shape property matching a radius property: `border-radius` maps to + * the four-corner `corner-shape` shorthand, `border-top-left-radius` to + * `corner-top-left-shape`, and so on. A utility that owns fewer than four + * corners has to use the longhands, or it reshapes corners whose radius it + * never set — `squircle-t-lg rounded-b-lg` would squircle the bottom corners. + */ +export function cornerShapeProp(radiusProp: string): string { + return radiusProp.replace(/^border-/, "corner-").replace(/radius$/, "shape"); +} + +/** + * CSS object for the static `-full` utilities. The radius stays uncorrected — + * an infinite radius times any positive factor is still infinite, and the + * browser clamps it either way — so both branches share the same value, like + * `rounded-full`; the `@supports` block only adds the superellipse shape. + */ +export function squircleFullCssObj( + props: string[], + options: Pick = {}, +): CssLikeObject { + const obj: CssLikeObject = {}; + for (const p of props) obj[p] = FULL_RADIUS; + const shape = getCornerShape(options.amtVar ?? DEFAULT_AMOUNT_VAR_NAME); + const supportsBlock: Record = {}; + for (const p of props) supportsBlock[cornerShapeProp(p)] = shape; + obj[SUPPORTS_RULE] = supportsBlock; + return obj; +} + type SectionComment = { comment: string }; type VariantEntry = string[] | SectionComment; @@ -208,7 +242,6 @@ export function squircleCssObj( const cornerShape = getCornerShape(amtVar); const corrected = correctedRadius(radius, amtCss); - const cornerShapeKey = keyCase === "camel" ? "cornerShape" : "corner-shape"; const propKey = (p: string) => (keyCase === "camel" ? toCamel(p) : p); const fallback: Record = {}; @@ -221,7 +254,7 @@ export function squircleCssObj( } else { for (const p of props) supportsBlock[propKey(p)] = corrected; } - supportsBlock[cornerShapeKey] = cornerShape; + for (const p of props) supportsBlock[propKey(cornerShapeProp(p))] = cornerShape; return { ...fallback, diff --git a/package/vite.config.ts b/package/vite.config.ts index c7a847f..ec5db17 100644 --- a/package/vite.config.ts +++ b/package/vite.config.ts @@ -18,7 +18,10 @@ export default defineConfig({ run: { tasks: { "test:tailwind": { + // Matches tailwind.test.ts and tailwind-merge.test.ts; the latter + // compiles the generated utils.css, so the build has to run first. command: "vp test run tailwind", + dependsOn: ["build"], }, "test:css": { command: "vp test run squircle-css",