Files
tailwind-dashed-border/src/index.ts
T
2026-05-07 17:32:30 +03:00

358 lines
11 KiB
TypeScript

import plugin from "tailwindcss/plugin";
import type {
BareValueResolver,
NamedUtilityValue as BareValue,
MatchOptions,
PluginAPI,
MatchFn,
} from "tailwindcss/plugin";
import flattenColorPalette from "tailwindcss/lib/util/flattenColorPalette";
type FlatThemeLeaf = Record<string, string>;
const POSITIVE_NUMBER_PATTERN = /^(?:\d+|\d*\.\d+)$/; //accept numbers like "12", ".5", "12.5"
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<T = string> = {
[key: string]: ConfigValuesTree<T> | T;
};
function isConfigValuesTree<T>(
x: ConfigValuesTree<T> | T,
): x is ConfigValuesTree<T> {
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<unknown> | 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<string, MatchFn> = {
[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<MatchOptions>["values"];
function withBareValueResolver(
values: FlatThemeLeaf,
resolver: BareValueResolver,
): MatchValues {
const valuesWithResolver: MatchValues = {};
Object.assign(valuesWithResolver, values);
valuesWithResolver.__BARE_VALUE__ = resolver;
return valuesWithResolver;
}
function flat<T>(
configPart: ConfigValuesTree<T>,
path: string = "",
): Record<string, T> {
function addKeyToPath(path: string, key: string): string {
if (key === "DEFAULT") return path;
if (path === "") return key;
return `${path}-${key}`;
}
const flatConf: Record<string, T> = {};
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<string, unknown>,
): Record<string, string> {
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 toCssVariable(
cssVariable: string,
): (value: string) => ReturnType<MatchFn> {
return (value) => {
const cssInJs = {
[cssVariable]: value,
};
return cssInJs;
};
}
const dashedBorderPlugin: ReturnType<typeof plugin> = 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": {
syntax: '"<length>"',
inherits: "true",
"initial-value": "8px",
},
"@property --tw-dashed-border-length": {
syntax: '"<length>"',
inherits: "true",
"initial-value": "10px",
},
"@property --tw-dashed-border-offset": {
syntax: '"<length>"',
inherits: "true",
"initial-value": "0px",
},
"@property --tw-dashed-border-width": {
syntax: '"<length>"',
inherits: "true",
"initial-value": "1px",
},
"@property --tw-dashed-border-radius": {
syntax: '"<length>"',
inherits: "true",
"initial-value": "currentColor",
},
"@property --tw-dashed-border-alignment": {
syntax: '"<number>"',
inherits: "true",
"initial-value": "0",
},
"@property --tw-dashed-border-duration": {
syntax: '"<time>"',
inherits: "true",
"initial-value": "150ms",
},
"@property --tw-dashed-border-ease": {
syntax: '"*"',
inherits: "true",
"initial-value": "initial",
},
".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-color": DEFAULT_COLOR,
"--tw-dashed-border-ease": "var(--tw-ease)",
"--tw-dashed-border-duration": "var(--tw-duration)",
// "--tw-dashed-border-radius": DEFAULT_RADIUS,
},
".dashed-border-svg": {
position: "absolute",
inset: "0",
width: "100%",
height: "100%",
"pointer-events": "none",
},
});
const INSET =
"calc((0.5 - var(--tw-dashed-border-alignment)) * var(--tw-dashed-border-width))";
const INNER_SIZE =
"calc(100% + var(--tw-dashed-border-width) * (2 * var(--tw-dashed-border-alignment) - 1))";
const ADJUSTED_RADIUS =
"max(0px, calc(var(--tw-dashed-border-radius) - (0.5 - var(--tw-dashed-border-alignment)) * var(--tw-dashed-border-width)))";
addBase({
".dashed-border-rect": {
fill: "none",
stroke: "var(--tw-dashed-border-color)",
"stroke-width": "var(--tw-dashed-border-width)",
"stroke-dasharray":
"var(--tw-dashed-border-length) var(--tw-dashed-border-gap)",
"stroke-dashoffset": "var(--tw-dashed-border-offset)",
"vector-effect": "non-scaling-stroke",
x: INSET,
y: INSET,
width: INNER_SIZE,
height: INNER_SIZE,
rx: ADJUSTED_RADIUS,
},
});
const spacingRawValues = theme("spacing") as
| ConfigValuesTree<string>
| 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",
};
};
matchUtilities([
spacingUtil({ utilityName: "dashed-border-gap" }),
spacingUtil({ utilityName: "dashed-border-length" }),
spacingUtil({
utilityName: "dashed-border-offset",
supportsNegativeValues: true,
}),
{
utilityName: "dashed-border-align",
arbitraryValueResolver: toCssVariable("--tw-dashed-border-alignment"),
defaultValues: {
inside: "0",
edge: "0.5",
outside: "1",
},
type: "number",
},
{
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"),
},
});
function createTransitionStyles(properties: string) {
return {
"transition-property": properties,
"transition-duration":
"var(--tw-dashed-border-duration, var(--default-transition-duration))",
"transition-timing-function":
"var(--tw-dashed-border-ease, var(--default-transition-timing-function))",
};
}
const ALL_TRANSITION_PROPERTIES =
"stroke-dasharray, stroke-dashoffset, stroke-width, stroke, rx, x, y, width, height";
addUtilities({
".dashed-border.transition .dashed-border-rect": createTransitionStyles(
ALL_TRANSITION_PROPERTIES,
),
".dashed-border.transition-all .dashed-border-rect":
createTransitionStyles(ALL_TRANSITION_PROPERTIES),
".dashed-border.transition-colors .dashed-border-rect":
createTransitionStyles("stroke"),
".dashed-border.transition-dashed-border .dashed-border-rect":
createTransitionStyles(ALL_TRANSITION_PROPERTIES),
});
},
);
export default dashedBorderPlugin;