From 0a25911ba91965386b9d6f8e802815c51e723fc9 Mon Sep 17 00:00:00 2001 From: Mira Date: Tue, 28 Apr 2026 03:23:32 +0300 Subject: [PATCH] WIP: Get control back from AI Steps: - Write a README to understand what do I really want to have. - Go line by line and get learn how it work. - Rework a lot of shit into sustainable form. - Understanding how tw work make me mad, so BetterMatchUtility was created - it separated default values (such as xl or 3 which will be shown as a tip), bare values (19 - as default but out of range), and arbitrary (like *-[5vw]). P.S. AI is so bad in 2026, if it have choose between two evil it for sure take both, and that not good thing. And it's struggle to make any good design. And did not able see what it do clearly. But so great for exploration thought. --- src/README.md | 52 ++++++++ src/index.ts | 339 +++++++++++++++++++++++++++++++++----------------- 2 files changed, 278 insertions(+), 113 deletions(-) create mode 100644 src/README.md diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..2410485 --- /dev/null +++ b/src/README.md @@ -0,0 +1,52 @@ +# Tailwind Dashed Border SVG + +A plugin for adressing issue with custamable and animatable dashed border via tailwind interface (mostly). + +It uses real svg under the hood what provide the maximum flexibility. Non animated border coming soon. + +# Setup + +``` +pnpm i tailwind-dashed-border +``` + +prepare a component for svg + +```tsx +export function BorderSVG() { + return ( + + ); +} +``` + +and now you are ready to make your borders + +```tsx +
+ +
+``` + +# Properties + +## Setup +- dashed-border +- dashed-border-svg +- dashed-border-rect + +## Configuration +- dashed-border-color-[color] +- dashed-border-width-[spacing] +- dashed-border-length-[spacing] +- dashed-border-gap-[spacing] +- dashed-border-offset-[spacing] +- dashed-border-align-["inside"/"edge"/"outside"] +- dashed-border-align-[spacing] (0 - inside, toward the outside) + +## Vanilla extended +- duration-[time] +- [timing function] (easing-linear, ease-in-out, etc.) +- transition-all \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 7c213a2..cef43aa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,13 @@ import plugin from "tailwindcss/plugin"; +import type { + BareValueResolver, + NamedUtilityValue as BareValue, + MatchOptions, + PluginAPI, + MatchFn, +} from "tailwindcss/plugin"; import flattenColorPalette from "tailwindcss/lib/util/flattenColorPalette"; -const DEFAULT_GAP = "calc(var(--spacing, 0.25rem) * 2)"; -const DEFAULT_LENGTH = "calc(var(--spacing, 0.25rem) * 3)"; -const DEFAULT_OFFSET = "0px"; -const DEFAULT_WIDTH = "1px"; -const DEFAULT_COLOR = "currentColor"; -const DEFAULT_RADIUS = "0px"; - const DASH_ARRAY = "var(--tw-dashed-border-length) var(--tw-dashed-border-gap)"; const INSET = "calc(var(--tw-dashed-border-width) / 2)"; const INNER_SIZE = "calc(100% - var(--tw-dashed-border-width))"; @@ -22,48 +22,148 @@ const TRANSITION_DURATION = const TRANSITION_TIMING_FUNCTION = "var(--tw-ease, var(--default-transition-timing-function))"; -type ThemeLeafMap = Record; +type FlatThemeLeaf = Record; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +const POSITIVE_NUMBER_PATTERN = /^(?:\d+|\d*\.\d+)$/; //accept numbers like "12", ".5", "12.5" -function flattenThemeLeaves( - value: unknown, - path: string[] = [], -): ThemeLeafMap { - if (!isRecord(value)) { - return {}; - } - - const flattened: ThemeLeafMap = {}; - - for (const [key, entry] of Object.entries(value)) { - if (key === "__CSS_VALUES__") { - continue; - } - - const nextPath = key === "DEFAULT" ? path : [...path, key]; - - if (isRecord(entry)) { - Object.assign(flattened, flattenThemeLeaves(entry, nextPath)); - continue; - } - - if (typeof entry === "string" || typeof entry === "number") { - const flattenedKey = nextPath.join("-"); - flattened[flattenedKey === "" ? "DEFAULT" : flattenedKey] = String(entry); - } - } - - return flattened; -} - -function omitDefaultKey(values: ThemeLeafMap): ThemeLeafMap { +function omitDefault(values: FlatThemeLeaf): FlatThemeLeaf { const { DEFAULT: _default, ...rest } = values; return rest; } +function spacingBareValueResolver(candidate: BareValue): string | undefined { + if ( + candidate.fraction !== null || + !POSITIVE_NUMBER_PATTERN.test(candidate.value) + ) { + return undefined; + } + + return `calc(var(--spacing) * ${candidate.value})`; +} + +function borderWidthBareValueResolver( + candidate: BareValue, +): string | undefined { + if ( + candidate.fraction !== null || + !POSITIVE_NUMBER_PATTERN.test(candidate.value) + ) { + return undefined; + } + + return `${candidate.value}px`; +} + +// Please not use T as an object; that not how things used to be in tw anyway, just don't. +type ConfigValuesTree = { + [key: string]: ConfigValuesTree | T; +}; + +function isConfigValuesTree( + x: ConfigValuesTree | T, +): x is ConfigValuesTree { + const isObject = typeof x === "object" && x !== null && !Array.isArray(x); + return isObject; +} + +type BetterMatchUtility = { + utilityName: string; + arbitraryValueResolver: MatchFn; + bareValueResolver?: BareValueResolver; + type?: MatchOptions["type"]; + defaultValues?: ConfigValuesTree | undefined; + modifiers?: MatchOptions["modifiers"]; + supportsNegativeValues?: MatchOptions["supportsNegativeValues"]; +}; +function betterMatchUtility( + matchUtilities: PluginAPI["matchUtilities"], +): (utility: BetterMatchUtility | BetterMatchUtility[]) => void { + function handleUtility(utility: BetterMatchUtility): void { + // define utility base + const arbitraryValueResolver: Record = { + [utility.utilityName]: utility.arbitraryValueResolver, + }; + // define options + const defaultValues = utility.defaultValues + ? { ...utility.defaultValues } + : {}; + defaultValues["__CSS_VALUES__"] = undefined; + const flatDefaultValues = flat(defaultValues); + const stringValues = toStringOrSkip(flatDefaultValues); + const values = utility.bareValueResolver + ? withBareValueResolver(stringValues, utility.bareValueResolver) + : stringValues; + const options: MatchOptions = { + type: utility.type, + supportsNegativeValues: utility.supportsNegativeValues, + values, + modifiers: utility.modifiers, + }; + // Register this utility + matchUtilities(arbitraryValueResolver, options); + } + + return function (utilities) { + if (Array.isArray(utilities)) { + utilities.forEach(handleUtility); + } else { + handleUtility(utilities); + } + }; +} + +type MatchValues = Required["values"]; +function withBareValueResolver( + values: FlatThemeLeaf, + resolver: BareValueResolver, +): MatchValues { + const valuesWithResolver: MatchValues = {}; + Object.assign(valuesWithResolver, values); + valuesWithResolver.__BARE_VALUE__ = resolver; + return valuesWithResolver; +} + +function flat( + configPart: ConfigValuesTree, + path: string = "", +): Record { + function addKeyToPath(path: string, key: string): string { + if (key === "DEFAULT") return path; + if (path === "") return key; + return `${path}-${key}`; + } + + const flatConf: Record = {}; + for (const [key, value] of Object.entries(configPart)) { + const currentPath = addKeyToPath(path, key); + if (isConfigValuesTree(value)) { + Object.assign(flatConf, flat(value, currentPath)); + } else { + flatConf[currentPath] = value; + } + } + return flatConf; +} + +const _typeOf = typeof "any"; +type BaseTypes = typeof _typeOf; + +function toStringOrSkip( + flatConfigPart: Record, +): Record { + const allowedTypes: BaseTypes[] = ["bigint", "boolean", "number", "string"]; + const entries = Object.entries(flatConfigPart); + const stringifiedEntries: [string, string][] = []; + entries.forEach(([key, value]) => { + const type = typeof value; + if (allowedTypes.includes(type)) + stringifiedEntries.push([key, String(value)]); + }); + const result = Object.fromEntries(stringifiedEntries); + return result; +} + function createTransitionStyles(properties: string) { return { "transition-property": properties, @@ -72,19 +172,28 @@ function createTransitionStyles(properties: string) { }; } -const dashedBorderPlugin: ReturnType = plugin( - ({ addBase, addUtilities, matchUtilities, theme }) => { - const spacingValues = flattenThemeLeaves(theme("spacing")); - const borderWidthValues = omitDefaultKey( - flattenThemeLeaves(theme("borderWidth")), - ); - const borderRadiusValues = flattenThemeLeaves(theme("borderRadius")); - const colorValues = { - ...flattenColorPalette(theme("colors") ?? {}), - current: "currentColor", - inherit: "inherit", - transparent: "transparent", +function toCssVariable( + cssVariable: string, +): (value: string) => ReturnType { + return (value) => { + const cssInJs = { + [cssVariable]: value, }; + return cssInJs; + }; +} + +const dashedBorderPlugin: ReturnType = plugin( + ({ addBase, addUtilities, theme, ...pluginApi }) => { + // const matchUtilities = pluginApi.matchUtilities; + const matchUtilities = betterMatchUtility(pluginApi.matchUtilities); + + const DEFAULT_GAP = "0.5rem"; + const DEFAULT_LENGTH = "0.75rem"; + const DEFAULT_OFFSET = "0px"; + const DEFAULT_WIDTH = "1px"; + const DEFAULT_COLOR = "currentColor"; + const DEFAULT_RADIUS = "0px"; addBase({ "@property --tw-dashed-border-gap": { @@ -105,7 +214,7 @@ const dashedBorderPlugin: ReturnType = plugin( "@property --tw-dashed-border-width": { syntax: "", inherits: "true", - "initial-value": DEFAULT_WIDTH, + "initial-value": "1px", }, "@property --tw-dashed-border-radius": { syntax: "", @@ -114,19 +223,18 @@ const dashedBorderPlugin: ReturnType = plugin( }, ".dashed-border": { position: "relative", - "--tw-dashed-border-gap": DEFAULT_GAP, - "--tw-dashed-border-length": DEFAULT_LENGTH, - "--tw-dashed-border-offset": DEFAULT_OFFSET, - "--tw-dashed-border-width": DEFAULT_WIDTH, + // "--tw-dashed-border-gap": DEFAULT_GAP, + // "--tw-dashed-border-length": DEFAULT_LENGTH, + // "--tw-dashed-border-offset": DEFAULT_OFFSET, + // "--tw-dashed-border-width": DEFAULT_WIDTH, "--tw-dashed-border-color": DEFAULT_COLOR, - "--tw-dashed-border-radius": DEFAULT_RADIUS, + // "--tw-dashed-border-radius": DEFAULT_RADIUS, }, ".dashed-border-svg": { position: "absolute", inset: "0", width: "100%", height: "100%", - overflow: "hidden", "pointer-events": "none", }, ".dashed-border-rect": { @@ -144,62 +252,67 @@ const dashedBorderPlugin: ReturnType = plugin( }, }); - matchUtilities( - { - "dashed-border-gap": (value) => ({ - "--tw-dashed-border-gap": value, - }), - "dashed-border-length": (value) => ({ - "--tw-dashed-border-length": value, - }), - "dashed-border-offset": (value) => ({ - "--tw-dashed-border-offset": value, - }), - }, - { + const spacingRawValues = theme("spacing") as + | ConfigValuesTree + | undefined; + const spacingValues = flat(spacingRawValues ?? {}); + + const spacingUtil = (opts: { + utilityName: string; + supportsNegativeValues?: boolean; + }): BetterMatchUtility => { + return { + utilityName: opts.utilityName, + arbitraryValueResolver: toCssVariable(`--tw-${opts.utilityName}`), + bareValueResolver: spacingBareValueResolver, + defaultValues: spacingValues, + supportsNegativeValues: opts.supportsNegativeValues, type: "length", - values: spacingValues, - }, - ); + }; + }; - matchUtilities( - { - "dashed-border-width": (value) => ({ - "--tw-dashed-border-width": value, - }), - }, - { - type: "length", - values: borderWidthValues, - }, - ); - - matchUtilities( - { - "dashed-border-color": (value) => ({ - "--tw-dashed-border-color": value, - }), - }, - { - type: "color", - values: colorValues, - }, - ); - - const radiusUtilities = Object.fromEntries( - Object.entries(borderRadiusValues).map(([key, value]) => { - const roundedClass = key === "DEFAULT" ? "rounded" : `rounded-${key}`; - return [ - `.dashed-border.${roundedClass}`, - { - "--tw-dashed-border-radius": value, - }, - ]; + matchUtilities([ + spacingUtil({ utilityName: "dashed-border-gap" }), + spacingUtil({ utilityName: "dashed-border-length" }), + spacingUtil({ + utilityName: "dashed-border-offset", + supportsNegativeValues: true, }), - ); + { + utilityName: "dashed-border-width", + arbitraryValueResolver: toCssVariable("--tw-dashed-border-width"), + bareValueResolver: borderWidthBareValueResolver, + defaultValues: omitDefault(theme("borderWidth")), + type: "length", + }, + ]); + + const colorValues = { + DEFAULT: flattenColorPalette(theme("colors")), + current: "currentColor", + inherit: "inherit", + transparent: "transparent", + }; + + matchUtilities({ + utilityName: "dashed-border-color", + arbitraryValueResolver: toCssVariable("--tw-dashed-border-color"), + defaultValues: colorValues, + type: "color", + }); + + matchUtilities({ + utilityName: "rounded", + arbitraryValueResolver: toCssVariable("--tw-dashed-border-radius"), + defaultValues: omitDefault(theme("borderRadius")), + }); + addUtilities({ + ".rounded": { + "--tw-dashed-border-radius": theme("borderRadius.DEFAULT"), + }, + }); addUtilities({ - ...radiusUtilities, ".dashed-border-transition .dashed-border-rect": createTransitionStyles( ALL_TRANSITION_PROPERTIES, ),