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