diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65356e9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +pnpm-lock.yaml +.direnv \ No newline at end of file diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..1b78b13 --- /dev/null +++ b/flake.lock @@ -0,0 +1,60 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1776105745, + "narHash": "sha256-3TdZByz6NafPdfpFS+pevgO75kSS9qFOR2pI9cZp7XE=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "d17aeaf88dea0fe37c73d33629d71cb51deafdb0", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8bdcaa8 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "tailwind-dashed-border", + "version": "0.1.0", + "description": "Configurable dashed border utility for Tailwind CSS v4", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "check": "tsc -p tsconfig.json --noEmit" + }, + "keywords": [ + "tailwindcss", + "tailwind", + "plugin", + "dashed-border", + "border" + ], + "peerDependencies": { + "tailwindcss": "^4.0.0" + }, + "devDependencies": { + "tailwindcss": "^4.2.1", + "typescript": "^5.9.3" + }, + "publishConfig": { + "access": "public" + }, + "license": "MIT" +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..a1e6509 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,199 @@ +import plugin from "tailwindcss/plugin"; + +interface ThemeValueMap { + [key: string]: string | ThemeValueMap; +} + +const DEFAULT_SPACING_VALUE = "0.25rem"; +const DEFAULT_GAP = `calc(var(--spacing, ${DEFAULT_SPACING_VALUE}) * 2)`; +const DEFAULT_LENGTH = `calc(var(--spacing, ${DEFAULT_SPACING_VALUE}) * 3)`; +const DEFAULT_BORDER_WIDTH = "1px"; +const DEFAULT_OFFSET = "0px"; +const DEFAULT_COLOR = "currentColor"; +const DEFAULT_RADIUS = "0px"; +const svgRadius = "max(0px, calc(var(--tw-dashed-border-radius) - (var(--tw-dashed-border-width) / 2)))"; +const dashedBorderTransitionProperties = + "stroke-dasharray, stroke-dashoffset, stroke-width, stroke, rx"; + +const flattenThemeMap = ( + input: ThemeValueMap, + path: string[] = [], +): Record => { + const output: Record = {}; + + for (const [key, value] of Object.entries(input)) { + const nextPath = key === "DEFAULT" ? path : [...path, key]; + + if (typeof value === "string") { + output[nextPath.join("-")] = value; + continue; + } + + Object.assign(output, flattenThemeMap(value, nextPath)); + } + + return output; +}; + +const roundedSelector = (name: string): string => + name === "DEFAULT" ? ".rounded" : `.rounded-${name}`; + +const dashedBorderPlugin: ReturnType = plugin( + ({ addBase, addUtilities, matchUtilities, theme }) => { + addBase({ + "@property --tw-dashed-border-gap": { + syntax: "", + inherits: "true", + "initial-value": "0.5rem", + }, + "@property --tw-dashed-border-length": { + syntax: "", + inherits: "true", + "initial-value": "0.75rem", + }, + "@property --tw-dashed-border-offset": { + syntax: "", + inherits: "true", + "initial-value": DEFAULT_OFFSET, + }, + "@property --tw-dashed-border-width": { + syntax: "", + inherits: "true", + "initial-value": DEFAULT_BORDER_WIDTH, + }, + "@property --tw-dashed-border-radius": { + syntax: "", + inherits: "true", + "initial-value": DEFAULT_RADIUS, + }, + }); + + addUtilities({ + ".dashed-border": { + "--tw-dashed-border-gap": DEFAULT_GAP, + "--tw-dashed-border-length": DEFAULT_LENGTH, + "--tw-dashed-border-offset": DEFAULT_OFFSET, + "--tw-dashed-border-width": DEFAULT_BORDER_WIDTH, + "--tw-dashed-border-color": DEFAULT_COLOR, + "--tw-dashed-border-radius": DEFAULT_RADIUS, + position: "relative", + }, + ".dashed-border-svg": { + display: "block", + position: "absolute", + inset: "0", + width: "100%", + height: "100%", + overflow: "hidden", + pointerEvents: "none", + }, + ".dashed-border-rect": { + fill: "none", + stroke: "var(--tw-dashed-border-color)", + strokeWidth: "var(--tw-dashed-border-width)", + strokeDasharray: + "var(--tw-dashed-border-length) var(--tw-dashed-border-gap)", + strokeDashoffset: "var(--tw-dashed-border-offset)", + shapeRendering: "geometricPrecision", + vectorEffect: "non-scaling-stroke", + x: "calc(var(--tw-dashed-border-width) / 2)", + y: "calc(var(--tw-dashed-border-width) / 2)", + width: "calc(100% - var(--tw-dashed-border-width))", + height: "calc(100% - var(--tw-dashed-border-width))", + rx: svgRadius, + }, + ".dashed-border-transition, .dashed-border.transition, .dashed-border.transition-all": + { + transitionTimingFunction: + "var(--default-transition-timing-function, cubic-bezier(0.4, 0, 0.2, 1))", + transitionDuration: "var(--default-transition-duration, 150ms)", + }, + ".dashed-border.transition-colors": { + transitionTimingFunction: + "var(--default-transition-timing-function, cubic-bezier(0.4, 0, 0.2, 1))", + transitionDuration: "var(--default-transition-duration, 150ms)", + }, + ".dashed-border-transition .dashed-border-rect, .dashed-border.transition .dashed-border-rect, .dashed-border.transition-all .dashed-border-rect": + { + transitionProperty: dashedBorderTransitionProperties, + transitionTimingFunction: "inherit", + transitionDuration: "inherit", + }, + ".dashed-border.transition-colors .dashed-border-rect": { + transitionProperty: "stroke", + transitionTimingFunction: + "inherit", + transitionDuration: "inherit", + }, + }); + + const spacing = theme("spacing", {}) as Record; + const borderWidths = theme("borderWidth", { + DEFAULT: DEFAULT_BORDER_WIDTH, + }) as Record; + const colors = flattenThemeMap(theme("colors", {}) as ThemeValueMap); + const radii = theme("borderRadius", { + DEFAULT: DEFAULT_RADIUS, + }) as Record; + + matchUtilities( + { + "dashed-border-gap": (value: string) => ({ + "--tw-dashed-border-gap": value, + }), + "dashed-border-length": (value: string) => ({ + "--tw-dashed-border-length": value, + }), + "dashed-border-offset": (value: string) => ({ + "--tw-dashed-border-offset": value, + }), + "dashed-border-radius": (value: string) => ({ + "--tw-dashed-border-radius": value, + }), + }, + { + values: spacing, + }, + ); + + matchUtilities( + { + "dashed-border-width": (value: string) => ({ + "--tw-dashed-border-width": value, + }), + }, + { + values: borderWidths, + }, + ); + + matchUtilities( + { + "dashed-border-color": (value: string) => ({ + "--tw-dashed-border-color": value, + }), + }, + { + values: { + ...colors, + current: "currentColor", + inherit: "inherit", + transparent: "transparent", + }, + }, + ); + + addUtilities( + Object.fromEntries( + Object.entries(radii).map(([name, value]) => [ + `.dashed-border${roundedSelector(name)}`, + { + "--tw-dashed-border-radius": value, + }, + ]), + ), + ); + }, +); + +export default dashedBorderPlugin; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d92d30d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": [ + "src" + ] +}