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