serpentine-border 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # serpentine-border
2
+
3
+ A multi-stroke serpentine (wavy) border drawn with SVG. Use from vanilla JS or as a React component.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install serpentine-border
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ Pass a wrapper element; the border is computed from the measured heights of its children.
14
+
15
+ ```js
16
+ import { serpentineBorder } from 'serpentine-border'
17
+
18
+ const wrapper = document.getElementById('wrapper')
19
+ const result = serpentineBorder({ wrapperEl: wrapper })
20
+ if (result) {
21
+ Object.assign(wrapper.style, result.wrapperStyle)
22
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
23
+ if (result.svgAttributes.class) svg.setAttribute('class', result.svgAttributes.class)
24
+ svg.setAttribute('viewBox', result.svgAttributes.viewBox)
25
+ Object.assign(svg.style, result.svgAttributes.style)
26
+ result.paths.forEach((p) => {
27
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
28
+ path.setAttribute('d', p.d)
29
+ path.setAttribute('stroke', p.stroke)
30
+ path.setAttribute('stroke-width', String(p.strokeWidth))
31
+ path.setAttribute('fill', p.fill)
32
+ svg.appendChild(path)
33
+ })
34
+ wrapper.insertBefore(svg, wrapper.firstChild)
35
+ }
36
+ ```
37
+
38
+ ## API
39
+
40
+ ### serpentineBorder(options)
41
+
42
+ Returns `wrapperStyle`, `svgAttributes` (class, viewBox, style), and `paths`. Pass either `wrapperEl` (measures from the DOM; returns `null` when DOM is unavailable, e.g. SSR) or `width` + `sectionBottomYs` (pure; never returns null).
43
+
44
+ | Option | Type | Default | Description |
45
+ |--------|------|---------|-------------|
46
+ | `wrapperEl` | `HTMLElement` | — | Measure width and section heights from this element. Omit for pure/SSR. |
47
+ | `width` | `number` | — | Content width in px (use with `sectionBottomYs`). |
48
+ | `sectionBottomYs` | `number[]` | — | Cumulative section bottom Y coordinates. |
49
+ | `strokeCount` | `number` | `5` | Number of parallel strokes. |
50
+ | `strokeWidth` | `number` | `8` | Width of each stroke in px. |
51
+ | `radius` | `number` | `50` | Radius of the wavy turns in px. |
52
+ | `horizontalOverlap` | `number \| 'borderWidth' \| 'halfBorderWidth'` | `0` | Extra width per side (px or keyword). |
53
+ | `colors` | `string[]` | `['#ffffff', '#000000']` | Stroke colors (hex/CSS). |
54
+ | `layoutMode` | `'content' \| 'border'` | `'content'` | `'content'`: layout from content; `'border'`: outer border edge defines box. |
55
+ | `svgClassName` | `string` | `'serpentine-border-svg'` | Class applied to the SVG (and used to exclude it when measuring). |
56
+
57
+ ### measureSections(wrapperEl, options)
58
+
59
+ Measures a wrapper and its section children; returns `{ width, sectionBottomYs }` or `null`. Options: `layoutMode`, `horizontalOverlap`, `strokeCount`, `strokeWidth`, and optional `excludeClassName` (default: same as `serpentineBorder`’s `svgClassName`). Use when you want to measure once and pass dimensions into `serpentineBorder`, or in environments without a DOM.
60
+
61
+ ### React: SerpentineBorder
62
+
63
+ Wrap your content with the React component; it accepts the same options as `serpentineBorder` as props.
64
+
65
+ ```jsx
66
+ import { SerpentineBorder } from 'serpentine-border'
67
+
68
+ function App() {
69
+ return (
70
+ <SerpentineBorder
71
+ strokeCount={5}
72
+ strokeWidth={8}
73
+ radius={50}
74
+ colors={['#ffffff', '#000000']}
75
+ >
76
+ <section className="section">First section</section>
77
+ <section className="section">Second section</section>
78
+ <section className="section">Third section</section>
79
+ </SerpentineBorder>
80
+ )
81
+ }
82
+ ```
83
+
84
+ ## Explicit dimensions and SSR
85
+
86
+ When you need to measure once and reuse, or run without a DOM (SSR, workers), use `measureSections` then call `serpentineBorder` with `width` and `sectionBottomYs`:
87
+
88
+ ```js
89
+ import { measureSections, serpentineBorder } from 'serpentine-border'
90
+
91
+ const wrapper = document.getElementById('wrapper')
92
+ const measured = measureSections(wrapper, {
93
+ layoutMode: 'content',
94
+ horizontalOverlap: 20,
95
+ strokeCount: 5,
96
+ strokeWidth: 8,
97
+ })
98
+ if (measured) {
99
+ const result = serpentineBorder({
100
+ width: measured.width,
101
+ sectionBottomYs: measured.sectionBottomYs,
102
+ horizontalOverlap: 20,
103
+ })
104
+ if (result) {
105
+ // Apply result.wrapperStyle, result.svgAttributes, result.paths
106
+ }
107
+ }
108
+ ```
109
+
110
+ ## License
111
+
112
+ MIT
@@ -0,0 +1,2 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const W=require("react/jsx-runtime"),z=require("react"),J=["#ffffff","#000000"];function G(t,c,o){if(typeof t=="number")return t;const s=c*o;return t==="borderWidth"?s:t==="halfBorderWidth"?s/2:0}function Q(t,c,o,s,u,g,l,n,r,h){const e=u*(o-1),a=u/2,$=c.length-1,i=[];for(let f=0;f<o;f++){const d=f*u,m=(o-1-f)*u,v=s-d,B=s-m,C=e-m,k=r-h+a,x=d-r+k,L=h,b=t+L-m-a,y=x+(s-d),N=t+L-s-a,A=t+L-e-a,D=r+n,p=[`M ${b} ${D-e-u/2-l}`,`L ${b} ${D-e-l}`,`A ${C} ${C} 0 0 1 ${A} ${D-m-l}`,`L ${y} ${d+n-l}`,`A ${v} ${v} 0 0 0 ${x} ${s+n-l}`,`L ${x} ${s+n}`];for(let M=0;M<$-1;M++){const w=c[M+1],X=c[M+2],q=w+s-r+n;M%2===0?(p.push(`L ${x} ${w-s+n}`),p.push(`A ${v} ${v} 0 0 0 ${y} ${w-d+n}`),p.push(`L ${N} ${w-d+n}`),p.push(`A ${B} ${B} 0 0 1 ${b} ${q}`),p.push(`L ${b} ${X-s+n}`)):(p.push(`L ${b} ${w-s+n}`),p.push(`A ${B} ${B} 0 0 1 ${N} ${w-m+n}`),p.push(`L ${y} ${w-m+n}`),p.push(`A ${v} ${v} 0 0 0 ${x} ${q}`),p.push(`L ${x} ${X-s+n}`))}const U=c[$];($-2)%2===0?p.push(`L ${b} ${U}`):p.push(`L ${x} ${U}`),i.push({d:p.join(" "),stroke:g[f%g.length],strokeWidth:u,fill:"none"})}return i}const P="serpentine-border-svg",j={strokeCount:5,strokeWidth:8,radius:50,horizontalOverlap:0,layoutMode:"content"};function V(t){const c=t.strokeCount??j.strokeCount,o=t.strokeWidth??j.strokeWidth,s=t.radius??j.radius,u=t.horizontalOverlap??j.horizontalOverlap,g=t.colors??J,l=t.layoutMode??j.layoutMode,n=t.svgClassName??P;let r,h;if(t.wrapperEl!=null){const y=t.wrapperEl;if(!(typeof document<"u"&&typeof y.getBoundingClientRect=="function"))return null;const A=R(y,{layoutMode:l,horizontalOverlap:u,strokeCount:c,strokeWidth:o,excludeClassName:n});if(!A)return null;r=A.width,h=A.sectionBottomYs}else{if(t.width==null||t.sectionBottomYs==null)return null;r=t.width,h=t.sectionBottomYs}const e=G(u,c,o),a=(c-1)*o,$=c*o,i=2*o,f=a/2,d=(c-1)/2*o+f,S=l==="border"?{boxSizing:"border-box",position:"relative",marginTop:$/2,...e>0&&{paddingLeft:e,paddingRight:e}}:{position:"relative",boxSizing:"border-box"},m=l==="border"?{position:"absolute",overflow:"hidden",width:"100%",left:0,top:-(i+d),height:`calc(100% + ${i+d}px)`}:{position:"absolute",overflow:"hidden",width:`calc(100% + ${2*e}px)`,left:-e,top:-(i+d),height:`calc(100% + ${i+d}px)`},v=Q(r,h,c,s,o,g,d,f,a,e),B=h[h.length-1]??0,C=Math.max(1,r+2*e),k=B+i+d,x=e>0?-e:0,L=-o*2-d,b=`${x} ${L} ${C} ${k}`;return{wrapperStyle:S,svgAttributes:{class:n,viewBox:b,style:m},paths:v}}function R(t,c){const{layoutMode:o,horizontalOverlap:s=0,strokeCount:u,strokeWidth:g,excludeClassName:l=P}=c,n=G(s,u,g);if(!t)return null;const r=l?Array.from(t.children).filter(i=>!i.classList.contains(l)):Array.from(t.children);if(r.length===0)return null;const h=t.getBoundingClientRect(),e=h.width,a=o==="border"?Math.max(1,e-2*n):Math.max(1,e),$=[0];for(let i=0;i<r.length;i++){const f=r[i].getBoundingClientRect();$.push(f.top-h.top+f.height)}return{width:a,sectionBottomYs:$}}function Z({children:t,strokeCount:c,strokeWidth:o,radius:s,horizontalOverlap:u,colors:g,layoutMode:l}){const n=z.useRef(null),[r,h]=z.useState(null);return z.useEffect(()=>{const e=n.current;if(!e)return;const a=()=>{const i=R(e,{layoutMode:l,horizontalOverlap:u,strokeCount:c,strokeWidth:o});if(!i)return;const f=V({width:i.width,sectionBottomYs:i.sectionBottomYs,strokeCount:c,strokeWidth:o,radius:s,horizontalOverlap:u,colors:g,layoutMode:l});h(f)};a();const $=new ResizeObserver(a);return $.observe(e),()=>$.disconnect()},[c,o,s,u,g,l]),W.jsxs("div",{ref:n,className:"serpentine-wrapper",style:(r==null?void 0:r.wrapperStyle)??{position:"relative",boxSizing:"border-box"},"data-testid":"serpentine-wrapper",children:[r&&(()=>{const{class:e,...a}=r.svgAttributes;return W.jsx("svg",{"data-testid":"serpentine-svg",className:e,...a,children:r.paths.map(($,i)=>W.jsx("path",{...$},i))})})(),t]})}exports.SerpentineBorder=Z;exports.measureSections=R;exports.serpentineBorder=V;
2
+ //# sourceMappingURL=serpentine-border.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serpentine-border.cjs","sources":["../src/constants.js","../src/serpentineCore.js","../src/SerpentineBorder.jsx"],"sourcesContent":["export const DEFAULT_COLORS = ['#ffffff', '#000000']\n","/**\n * Vanilla JS core for serpentine border SVG generation.\n * Single export: call with measured dimensions and options to get everything needed to render.\n */\n\nimport { DEFAULT_COLORS } from './constants.js'\n\nfunction resolveOverlapToPixels(horizontalOverlap, N, STROKE_WIDTH) {\n if (typeof horizontalOverlap === 'number') return horizontalOverlap\n const totalBorderWidth = N * STROKE_WIDTH\n if (horizontalOverlap === 'borderWidth') return totalBorderWidth\n if (horizontalOverlap === 'halfBorderWidth') return totalBorderWidth / 2\n return 0\n}\n\nfunction buildPathD(W, Y, N, R, STROKE_WIDTH, COLORS, TOP_ARC_SHIFT, Y_OFFSET, O_TOTAL, BORDER_EXTRA) {\n const R1 = STROKE_WIDTH * (N - 1)\n const RIGHT_EXTEND = STROKE_WIDTH / 2\n const n = Y.length - 1\n const parts = []\n for (let i = 0; i < N; i++) {\n const o = i * STROKE_WIDTH\n const j = N - 1 - i\n const oj = j * STROKE_WIDTH\n const r = R - o\n const rj = R - oj\n const r1 = R1 - o\n const rj1 = R1 - oj\n\n const leftOffset = O_TOTAL - BORDER_EXTRA + RIGHT_EXTEND\n const xLeft = o - O_TOTAL + leftOffset\n const rightExt = BORDER_EXTRA\n const xRight = W + rightExt - oj - RIGHT_EXTEND\n const xLeftArc = xLeft + (R - o)\n const xRightArc = W + rightExt - R - RIGHT_EXTEND\n const xRightR1 = W + rightExt - R1 - RIGHT_EXTEND\n\n const yCurrTop = O_TOTAL + Y_OFFSET\n const segs = [\n `M ${xRight} ${yCurrTop - R1 - STROKE_WIDTH / 2 - TOP_ARC_SHIFT}`,\n `L ${xRight} ${yCurrTop - R1 - TOP_ARC_SHIFT}`,\n `A ${rj1} ${rj1} 0 0 1 ${xRightR1} ${yCurrTop - oj - TOP_ARC_SHIFT}`,\n `L ${xLeftArc} ${o + Y_OFFSET - TOP_ARC_SHIFT}`,\n `A ${r} ${r} 0 0 0 ${xLeft} ${R + Y_OFFSET - TOP_ARC_SHIFT}`,\n `L ${xLeft} ${R + Y_OFFSET}`,\n ]\n\n for (let t = 0; t < n - 1; t++) {\n const yCurr = Y[t + 1]\n const yNext = Y[t + 2]\n const yExit = yCurr + R - O_TOTAL + Y_OFFSET\n\n if (t % 2 === 0) {\n segs.push(`L ${xLeft} ${yCurr - R + Y_OFFSET}`)\n segs.push(`A ${r} ${r} 0 0 0 ${xLeftArc} ${yCurr - o + Y_OFFSET}`)\n segs.push(`L ${xRightArc} ${yCurr - o + Y_OFFSET}`)\n segs.push(`A ${rj} ${rj} 0 0 1 ${xRight} ${yExit}`)\n segs.push(`L ${xRight} ${yNext - R + Y_OFFSET}`)\n } else {\n segs.push(`L ${xRight} ${yCurr - R + Y_OFFSET}`)\n segs.push(`A ${rj} ${rj} 0 0 1 ${xRightArc} ${yCurr - oj + Y_OFFSET}`)\n segs.push(`L ${xLeftArc} ${yCurr - oj + Y_OFFSET}`)\n segs.push(`A ${r} ${r} 0 0 0 ${xLeft} ${yExit}`)\n segs.push(`L ${xLeft} ${yNext - R + Y_OFFSET}`)\n }\n }\n\n const lastY = Y[n]\n if ((n - 2) % 2 === 0) {\n segs.push(`L ${xRight} ${lastY}`)\n } else {\n segs.push(`L ${xLeft} ${lastY}`)\n }\n parts.push({\n d: segs.join(' '),\n stroke: COLORS[i % COLORS.length],\n strokeWidth: STROKE_WIDTH,\n fill: 'none',\n })\n }\n return parts\n}\n\nconst DEFAULT_SVG_CLASS = 'serpentine-border-svg'\n\nconst DEFAULTS = {\n strokeCount: 5,\n strokeWidth: 8,\n radius: 50,\n horizontalOverlap: 0,\n layoutMode: 'content',\n}\n\n/**\n * Compute everything needed to render the serpentine border.\n * Accepts either (width + sectionBottomYs) for pure/custom use, or wrapperEl to measure from the DOM.\n * When using wrapperEl, returns null in non-DOM environments (e.g. SSR) or when measurement fails.\n *\n * @param {{\n * width?: number\n * sectionBottomYs?: number[]\n * wrapperEl?: HTMLElement\n * strokeCount?: number\n * strokeWidth?: number\n * radius?: number\n * horizontalOverlap?: number | 'borderWidth' | 'halfBorderWidth'\n * colors?: string[]\n * layoutMode?: 'content' | 'border'\n * svgClassName?: string\n * }} options\n * @returns {{\n * wrapperStyle: Record<string, unknown>\n * svgAttributes: { class?: string, viewBox: string, style: Record<string, unknown> }\n * paths: Array<{ d: string, stroke: string, strokeWidth: number, fill: string }>\n * } | null}\n */\nexport function serpentineBorder(options) {\n const N = options.strokeCount ?? DEFAULTS.strokeCount\n const STROKE_WIDTH = options.strokeWidth ?? DEFAULTS.strokeWidth\n const R = options.radius ?? DEFAULTS.radius\n const horizontalOverlap = options.horizontalOverlap ?? DEFAULTS.horizontalOverlap\n const COLORS = options.colors ?? DEFAULT_COLORS\n const layoutMode = options.layoutMode ?? DEFAULTS.layoutMode\n const svgClassName = options.svgClassName ?? DEFAULT_SVG_CLASS\n\n let W, Y\n if (options.wrapperEl != null) {\n const wrapperEl = options.wrapperEl\n const hasDOM = typeof document !== 'undefined' && typeof wrapperEl.getBoundingClientRect === 'function'\n if (!hasDOM) return null\n const measured = measureSections(wrapperEl, {\n layoutMode,\n horizontalOverlap,\n strokeCount: N,\n strokeWidth: STROKE_WIDTH,\n excludeClassName: svgClassName,\n })\n if (!measured) return null\n W = measured.width\n Y = measured.sectionBottomYs\n } else {\n if (options.width == null || options.sectionBottomYs == null) return null\n W = options.width\n Y = options.sectionBottomYs\n }\n\n const BORDER_EXTRA = resolveOverlapToPixels(horizontalOverlap, N, STROKE_WIDTH)\n const O_TOTAL = (N - 1) * STROKE_WIDTH\n const TOTAL_BORDER_WIDTH = N * STROKE_WIDTH\n const TOP_OFFSET = 2 * STROKE_WIDTH\n const Y_OFFSET = O_TOTAL / 2\n const TOP_ARC_SHIFT = ((N - 1) / 2) * STROKE_WIDTH + Y_OFFSET\n\n const wrapperStyle =\n layoutMode === 'border'\n ? {\n boxSizing: 'border-box',\n position: 'relative',\n marginTop: TOTAL_BORDER_WIDTH / 2,\n ...(BORDER_EXTRA > 0 && {\n paddingLeft: BORDER_EXTRA,\n paddingRight: BORDER_EXTRA,\n }),\n }\n : {\n position: 'relative',\n boxSizing: 'border-box',\n }\n\n const svgStyle =\n layoutMode === 'border'\n ? {\n position: 'absolute',\n overflow: 'hidden',\n width: '100%',\n left: 0,\n top: -(TOP_OFFSET + TOP_ARC_SHIFT),\n height: `calc(100% + ${TOP_OFFSET + TOP_ARC_SHIFT}px)`,\n }\n : {\n position: 'absolute',\n overflow: 'hidden',\n width: `calc(100% + ${2 * BORDER_EXTRA}px)`,\n left: -BORDER_EXTRA,\n top: -(TOP_OFFSET + TOP_ARC_SHIFT),\n height: `calc(100% + ${TOP_OFFSET + TOP_ARC_SHIFT}px)`,\n }\n\n const paths = buildPathD(W, Y, N, R, STROKE_WIDTH, COLORS, TOP_ARC_SHIFT, Y_OFFSET, O_TOTAL, BORDER_EXTRA)\n\n const totalHeight = Y[Y.length - 1] ?? 0\n const totalWidth = Math.max(1, W + 2 * BORDER_EXTRA)\n const viewBoxHeight = totalHeight + TOP_OFFSET + TOP_ARC_SHIFT\n const viewBoxMinX = BORDER_EXTRA > 0 ? -BORDER_EXTRA : 0\n const viewBoxMinY = -STROKE_WIDTH * 2 - TOP_ARC_SHIFT\n const viewBoxStr = `${viewBoxMinX} ${viewBoxMinY} ${totalWidth} ${viewBoxHeight}`\n\n return {\n wrapperStyle,\n svgAttributes: {\n class: svgClassName,\n viewBox: viewBoxStr,\n style: svgStyle,\n },\n paths,\n }\n}\n\n/**\n * Measure wrapper and section elements to get width and section bottom Ys.\n * Children with the excludeClassName (default: same class used on the SVG by serpentineBorder) are excluded.\n * horizontalOverlap is resolved to pixels using strokeCount and strokeWidth.\n *\n * @param {HTMLElement} wrapperEl\n * @param {{\n * layoutMode: 'content' | 'border'\n * horizontalOverlap?: number | 'borderWidth' | 'halfBorderWidth'\n * strokeCount: number\n * strokeWidth: number\n * excludeClassName?: string\n * }} options\n * @returns {{ width: number, sectionBottomYs: number[] } | null}\n */\nexport function measureSections(wrapperEl, options) {\n const { layoutMode, horizontalOverlap = 0, strokeCount, strokeWidth, excludeClassName = DEFAULT_SVG_CLASS } = options\n const BORDER_EXTRA = resolveOverlapToPixels(horizontalOverlap, strokeCount, strokeWidth)\n if (!wrapperEl) return null\n\n const sectionEls = excludeClassName\n ? Array.from(wrapperEl.children).filter((el) => !el.classList.contains(excludeClassName))\n : Array.from(wrapperEl.children)\n\n if (sectionEls.length === 0) return null\n\n const rect = wrapperEl.getBoundingClientRect()\n const baseWidth = rect.width\n\n const W =\n layoutMode === 'border'\n ? Math.max(1, baseWidth - 2 * BORDER_EXTRA)\n : Math.max(1, baseWidth)\n\n const Y = [0]\n for (let i = 0; i < sectionEls.length; i++) {\n const r = sectionEls[i].getBoundingClientRect()\n Y.push(r.top - rect.top + r.height)\n }\n\n return { width: W, sectionBottomYs: Y }\n}\n","import { useEffect, useRef, useState } from 'react'\nimport { measureSections, serpentineBorder } from './serpentineCore.js'\n\nfunction SerpentineBorder({\n children,\n strokeCount,\n strokeWidth,\n radius,\n horizontalOverlap,\n colors,\n layoutMode,\n}) {\n const wrapperRef = useRef(null)\n const [borderData, setBorderData] = useState(null)\n\n useEffect(() => {\n const wrapper = wrapperRef.current\n if (!wrapper) return\n\n const measure = () => {\n const measured = measureSections(wrapper, {\n layoutMode,\n horizontalOverlap,\n strokeCount,\n strokeWidth,\n })\n if (!measured) return\n\n const data = serpentineBorder({\n width: measured.width,\n sectionBottomYs: measured.sectionBottomYs,\n strokeCount,\n strokeWidth,\n radius,\n horizontalOverlap,\n colors,\n layoutMode,\n })\n setBorderData(data)\n }\n\n measure()\n const ro = new ResizeObserver(measure)\n ro.observe(wrapper)\n return () => ro.disconnect()\n }, [strokeCount, strokeWidth, radius, horizontalOverlap, colors, layoutMode])\n\n return (\n <div\n ref={wrapperRef}\n className=\"serpentine-wrapper\"\n style={borderData?.wrapperStyle ?? { position: 'relative', boxSizing: 'border-box' }}\n data-testid=\"serpentine-wrapper\"\n >\n {borderData && (() => {\n const { class: className, ...restSvgAttrs } = borderData.svgAttributes\n return (\n <svg\n data-testid=\"serpentine-svg\"\n className={className}\n {...restSvgAttrs}\n >\n {borderData.paths.map((pathAttributes, i) => (\n <path key={i} {...pathAttributes} />\n ))}\n </svg>\n )\n })()}\n {children}\n </div>\n )\n}\n\nexport default SerpentineBorder\n"],"names":["DEFAULT_COLORS","resolveOverlapToPixels","horizontalOverlap","N","STROKE_WIDTH","totalBorderWidth","buildPathD","W","Y","R","COLORS","TOP_ARC_SHIFT","Y_OFFSET","O_TOTAL","BORDER_EXTRA","R1","RIGHT_EXTEND","n","parts","i","o","oj","r","rj","rj1","leftOffset","xLeft","rightExt","xRight","xLeftArc","xRightArc","xRightR1","yCurrTop","segs","t","yCurr","yNext","yExit","lastY","DEFAULT_SVG_CLASS","DEFAULTS","serpentineBorder","options","layoutMode","svgClassName","wrapperEl","measured","measureSections","TOTAL_BORDER_WIDTH","TOP_OFFSET","wrapperStyle","svgStyle","paths","totalHeight","totalWidth","viewBoxHeight","viewBoxMinX","viewBoxMinY","viewBoxStr","strokeCount","strokeWidth","excludeClassName","sectionEls","el","rect","baseWidth","SerpentineBorder","children","radius","colors","wrapperRef","useRef","borderData","setBorderData","useState","useEffect","wrapper","measure","data","ro","jsxs","className","restSvgAttrs","jsx","pathAttributes"],"mappings":"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"}
@@ -0,0 +1,174 @@
1
+ import { jsxs as J, jsx as X } from "react/jsx-runtime";
2
+ import { useRef as P, useState as Q, useEffect as Z } from "react";
3
+ const S = ["#ffffff", "#000000"];
4
+ function G(t, i, o) {
5
+ if (typeof t == "number") return t;
6
+ const s = i * o;
7
+ return t === "borderWidth" ? s : t === "halfBorderWidth" ? s / 2 : 0;
8
+ }
9
+ function E(t, i, o, s, u, g, l, n, r, h) {
10
+ const e = u * (o - 1), a = u / 2, $ = i.length - 1, c = [];
11
+ for (let f = 0; f < o; f++) {
12
+ const d = f * u, m = (o - 1 - f) * u, v = s - d, B = s - m, j = e - m, k = r - h + a, x = d - r + k, y = h, w = t + y - m - a, L = x + (s - d), N = t + y - s - a, A = t + y - e - a, D = r + n, p = [
13
+ `M ${w} ${D - e - u / 2 - l}`,
14
+ `L ${w} ${D - e - l}`,
15
+ `A ${j} ${j} 0 0 1 ${A} ${D - m - l}`,
16
+ `L ${L} ${d + n - l}`,
17
+ `A ${v} ${v} 0 0 0 ${x} ${s + n - l}`,
18
+ `L ${x} ${s + n}`
19
+ ];
20
+ for (let C = 0; C < $ - 1; C++) {
21
+ const b = i[C + 1], R = i[C + 2], U = b + s - r + n;
22
+ C % 2 === 0 ? (p.push(`L ${x} ${b - s + n}`), p.push(`A ${v} ${v} 0 0 0 ${L} ${b - d + n}`), p.push(`L ${N} ${b - d + n}`), p.push(`A ${B} ${B} 0 0 1 ${w} ${U}`), p.push(`L ${w} ${R - s + n}`)) : (p.push(`L ${w} ${b - s + n}`), p.push(`A ${B} ${B} 0 0 1 ${N} ${b - m + n}`), p.push(`L ${L} ${b - m + n}`), p.push(`A ${v} ${v} 0 0 0 ${x} ${U}`), p.push(`L ${x} ${R - s + n}`));
23
+ }
24
+ const z = i[$];
25
+ ($ - 2) % 2 === 0 ? p.push(`L ${w} ${z}`) : p.push(`L ${x} ${z}`), c.push({
26
+ d: p.join(" "),
27
+ stroke: g[f % g.length],
28
+ strokeWidth: u,
29
+ fill: "none"
30
+ });
31
+ }
32
+ return c;
33
+ }
34
+ const V = "serpentine-border-svg", M = {
35
+ strokeCount: 5,
36
+ strokeWidth: 8,
37
+ radius: 50,
38
+ horizontalOverlap: 0,
39
+ layoutMode: "content"
40
+ };
41
+ function H(t) {
42
+ const i = t.strokeCount ?? M.strokeCount, o = t.strokeWidth ?? M.strokeWidth, s = t.radius ?? M.radius, u = t.horizontalOverlap ?? M.horizontalOverlap, g = t.colors ?? S, l = t.layoutMode ?? M.layoutMode, n = t.svgClassName ?? V;
43
+ let r, h;
44
+ if (t.wrapperEl != null) {
45
+ const L = t.wrapperEl;
46
+ if (!(typeof document < "u" && typeof L.getBoundingClientRect == "function")) return null;
47
+ const A = q(L, {
48
+ layoutMode: l,
49
+ horizontalOverlap: u,
50
+ strokeCount: i,
51
+ strokeWidth: o,
52
+ excludeClassName: n
53
+ });
54
+ if (!A) return null;
55
+ r = A.width, h = A.sectionBottomYs;
56
+ } else {
57
+ if (t.width == null || t.sectionBottomYs == null) return null;
58
+ r = t.width, h = t.sectionBottomYs;
59
+ }
60
+ const e = G(u, i, o), a = (i - 1) * o, $ = i * o, c = 2 * o, f = a / 2, d = (i - 1) / 2 * o + f, W = l === "border" ? {
61
+ boxSizing: "border-box",
62
+ position: "relative",
63
+ marginTop: $ / 2,
64
+ ...e > 0 && {
65
+ paddingLeft: e,
66
+ paddingRight: e
67
+ }
68
+ } : {
69
+ position: "relative",
70
+ boxSizing: "border-box"
71
+ }, m = l === "border" ? {
72
+ position: "absolute",
73
+ overflow: "hidden",
74
+ width: "100%",
75
+ left: 0,
76
+ top: -(c + d),
77
+ height: `calc(100% + ${c + d}px)`
78
+ } : {
79
+ position: "absolute",
80
+ overflow: "hidden",
81
+ width: `calc(100% + ${2 * e}px)`,
82
+ left: -e,
83
+ top: -(c + d),
84
+ height: `calc(100% + ${c + d}px)`
85
+ }, v = E(r, h, i, s, o, g, d, f, a, e), B = h[h.length - 1] ?? 0, j = Math.max(1, r + 2 * e), k = B + c + d, x = e > 0 ? -e : 0, y = -o * 2 - d, w = `${x} ${y} ${j} ${k}`;
86
+ return {
87
+ wrapperStyle: W,
88
+ svgAttributes: {
89
+ class: n,
90
+ viewBox: w,
91
+ style: m
92
+ },
93
+ paths: v
94
+ };
95
+ }
96
+ function q(t, i) {
97
+ const { layoutMode: o, horizontalOverlap: s = 0, strokeCount: u, strokeWidth: g, excludeClassName: l = V } = i, n = G(s, u, g);
98
+ if (!t) return null;
99
+ const r = l ? Array.from(t.children).filter((c) => !c.classList.contains(l)) : Array.from(t.children);
100
+ if (r.length === 0) return null;
101
+ const h = t.getBoundingClientRect(), e = h.width, a = o === "border" ? Math.max(1, e - 2 * n) : Math.max(1, e), $ = [0];
102
+ for (let c = 0; c < r.length; c++) {
103
+ const f = r[c].getBoundingClientRect();
104
+ $.push(f.top - h.top + f.height);
105
+ }
106
+ return { width: a, sectionBottomYs: $ };
107
+ }
108
+ function I({
109
+ children: t,
110
+ strokeCount: i,
111
+ strokeWidth: o,
112
+ radius: s,
113
+ horizontalOverlap: u,
114
+ colors: g,
115
+ layoutMode: l
116
+ }) {
117
+ const n = P(null), [r, h] = Q(null);
118
+ return Z(() => {
119
+ const e = n.current;
120
+ if (!e) return;
121
+ const a = () => {
122
+ const c = q(e, {
123
+ layoutMode: l,
124
+ horizontalOverlap: u,
125
+ strokeCount: i,
126
+ strokeWidth: o
127
+ });
128
+ if (!c) return;
129
+ const f = H({
130
+ width: c.width,
131
+ sectionBottomYs: c.sectionBottomYs,
132
+ strokeCount: i,
133
+ strokeWidth: o,
134
+ radius: s,
135
+ horizontalOverlap: u,
136
+ colors: g,
137
+ layoutMode: l
138
+ });
139
+ h(f);
140
+ };
141
+ a();
142
+ const $ = new ResizeObserver(a);
143
+ return $.observe(e), () => $.disconnect();
144
+ }, [i, o, s, u, g, l]), /* @__PURE__ */ J(
145
+ "div",
146
+ {
147
+ ref: n,
148
+ className: "serpentine-wrapper",
149
+ style: (r == null ? void 0 : r.wrapperStyle) ?? { position: "relative", boxSizing: "border-box" },
150
+ "data-testid": "serpentine-wrapper",
151
+ children: [
152
+ r && (() => {
153
+ const { class: e, ...a } = r.svgAttributes;
154
+ return /* @__PURE__ */ X(
155
+ "svg",
156
+ {
157
+ "data-testid": "serpentine-svg",
158
+ className: e,
159
+ ...a,
160
+ children: r.paths.map(($, c) => /* @__PURE__ */ X("path", { ...$ }, c))
161
+ }
162
+ );
163
+ })(),
164
+ t
165
+ ]
166
+ }
167
+ );
168
+ }
169
+ export {
170
+ I as SerpentineBorder,
171
+ q as measureSections,
172
+ H as serpentineBorder
173
+ };
174
+ //# sourceMappingURL=serpentine-border.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serpentine-border.js","sources":["../src/constants.js","../src/serpentineCore.js","../src/SerpentineBorder.jsx"],"sourcesContent":["export const DEFAULT_COLORS = ['#ffffff', '#000000']\n","/**\n * Vanilla JS core for serpentine border SVG generation.\n * Single export: call with measured dimensions and options to get everything needed to render.\n */\n\nimport { DEFAULT_COLORS } from './constants.js'\n\nfunction resolveOverlapToPixels(horizontalOverlap, N, STROKE_WIDTH) {\n if (typeof horizontalOverlap === 'number') return horizontalOverlap\n const totalBorderWidth = N * STROKE_WIDTH\n if (horizontalOverlap === 'borderWidth') return totalBorderWidth\n if (horizontalOverlap === 'halfBorderWidth') return totalBorderWidth / 2\n return 0\n}\n\nfunction buildPathD(W, Y, N, R, STROKE_WIDTH, COLORS, TOP_ARC_SHIFT, Y_OFFSET, O_TOTAL, BORDER_EXTRA) {\n const R1 = STROKE_WIDTH * (N - 1)\n const RIGHT_EXTEND = STROKE_WIDTH / 2\n const n = Y.length - 1\n const parts = []\n for (let i = 0; i < N; i++) {\n const o = i * STROKE_WIDTH\n const j = N - 1 - i\n const oj = j * STROKE_WIDTH\n const r = R - o\n const rj = R - oj\n const r1 = R1 - o\n const rj1 = R1 - oj\n\n const leftOffset = O_TOTAL - BORDER_EXTRA + RIGHT_EXTEND\n const xLeft = o - O_TOTAL + leftOffset\n const rightExt = BORDER_EXTRA\n const xRight = W + rightExt - oj - RIGHT_EXTEND\n const xLeftArc = xLeft + (R - o)\n const xRightArc = W + rightExt - R - RIGHT_EXTEND\n const xRightR1 = W + rightExt - R1 - RIGHT_EXTEND\n\n const yCurrTop = O_TOTAL + Y_OFFSET\n const segs = [\n `M ${xRight} ${yCurrTop - R1 - STROKE_WIDTH / 2 - TOP_ARC_SHIFT}`,\n `L ${xRight} ${yCurrTop - R1 - TOP_ARC_SHIFT}`,\n `A ${rj1} ${rj1} 0 0 1 ${xRightR1} ${yCurrTop - oj - TOP_ARC_SHIFT}`,\n `L ${xLeftArc} ${o + Y_OFFSET - TOP_ARC_SHIFT}`,\n `A ${r} ${r} 0 0 0 ${xLeft} ${R + Y_OFFSET - TOP_ARC_SHIFT}`,\n `L ${xLeft} ${R + Y_OFFSET}`,\n ]\n\n for (let t = 0; t < n - 1; t++) {\n const yCurr = Y[t + 1]\n const yNext = Y[t + 2]\n const yExit = yCurr + R - O_TOTAL + Y_OFFSET\n\n if (t % 2 === 0) {\n segs.push(`L ${xLeft} ${yCurr - R + Y_OFFSET}`)\n segs.push(`A ${r} ${r} 0 0 0 ${xLeftArc} ${yCurr - o + Y_OFFSET}`)\n segs.push(`L ${xRightArc} ${yCurr - o + Y_OFFSET}`)\n segs.push(`A ${rj} ${rj} 0 0 1 ${xRight} ${yExit}`)\n segs.push(`L ${xRight} ${yNext - R + Y_OFFSET}`)\n } else {\n segs.push(`L ${xRight} ${yCurr - R + Y_OFFSET}`)\n segs.push(`A ${rj} ${rj} 0 0 1 ${xRightArc} ${yCurr - oj + Y_OFFSET}`)\n segs.push(`L ${xLeftArc} ${yCurr - oj + Y_OFFSET}`)\n segs.push(`A ${r} ${r} 0 0 0 ${xLeft} ${yExit}`)\n segs.push(`L ${xLeft} ${yNext - R + Y_OFFSET}`)\n }\n }\n\n const lastY = Y[n]\n if ((n - 2) % 2 === 0) {\n segs.push(`L ${xRight} ${lastY}`)\n } else {\n segs.push(`L ${xLeft} ${lastY}`)\n }\n parts.push({\n d: segs.join(' '),\n stroke: COLORS[i % COLORS.length],\n strokeWidth: STROKE_WIDTH,\n fill: 'none',\n })\n }\n return parts\n}\n\nconst DEFAULT_SVG_CLASS = 'serpentine-border-svg'\n\nconst DEFAULTS = {\n strokeCount: 5,\n strokeWidth: 8,\n radius: 50,\n horizontalOverlap: 0,\n layoutMode: 'content',\n}\n\n/**\n * Compute everything needed to render the serpentine border.\n * Accepts either (width + sectionBottomYs) for pure/custom use, or wrapperEl to measure from the DOM.\n * When using wrapperEl, returns null in non-DOM environments (e.g. SSR) or when measurement fails.\n *\n * @param {{\n * width?: number\n * sectionBottomYs?: number[]\n * wrapperEl?: HTMLElement\n * strokeCount?: number\n * strokeWidth?: number\n * radius?: number\n * horizontalOverlap?: number | 'borderWidth' | 'halfBorderWidth'\n * colors?: string[]\n * layoutMode?: 'content' | 'border'\n * svgClassName?: string\n * }} options\n * @returns {{\n * wrapperStyle: Record<string, unknown>\n * svgAttributes: { class?: string, viewBox: string, style: Record<string, unknown> }\n * paths: Array<{ d: string, stroke: string, strokeWidth: number, fill: string }>\n * } | null}\n */\nexport function serpentineBorder(options) {\n const N = options.strokeCount ?? DEFAULTS.strokeCount\n const STROKE_WIDTH = options.strokeWidth ?? DEFAULTS.strokeWidth\n const R = options.radius ?? DEFAULTS.radius\n const horizontalOverlap = options.horizontalOverlap ?? DEFAULTS.horizontalOverlap\n const COLORS = options.colors ?? DEFAULT_COLORS\n const layoutMode = options.layoutMode ?? DEFAULTS.layoutMode\n const svgClassName = options.svgClassName ?? DEFAULT_SVG_CLASS\n\n let W, Y\n if (options.wrapperEl != null) {\n const wrapperEl = options.wrapperEl\n const hasDOM = typeof document !== 'undefined' && typeof wrapperEl.getBoundingClientRect === 'function'\n if (!hasDOM) return null\n const measured = measureSections(wrapperEl, {\n layoutMode,\n horizontalOverlap,\n strokeCount: N,\n strokeWidth: STROKE_WIDTH,\n excludeClassName: svgClassName,\n })\n if (!measured) return null\n W = measured.width\n Y = measured.sectionBottomYs\n } else {\n if (options.width == null || options.sectionBottomYs == null) return null\n W = options.width\n Y = options.sectionBottomYs\n }\n\n const BORDER_EXTRA = resolveOverlapToPixels(horizontalOverlap, N, STROKE_WIDTH)\n const O_TOTAL = (N - 1) * STROKE_WIDTH\n const TOTAL_BORDER_WIDTH = N * STROKE_WIDTH\n const TOP_OFFSET = 2 * STROKE_WIDTH\n const Y_OFFSET = O_TOTAL / 2\n const TOP_ARC_SHIFT = ((N - 1) / 2) * STROKE_WIDTH + Y_OFFSET\n\n const wrapperStyle =\n layoutMode === 'border'\n ? {\n boxSizing: 'border-box',\n position: 'relative',\n marginTop: TOTAL_BORDER_WIDTH / 2,\n ...(BORDER_EXTRA > 0 && {\n paddingLeft: BORDER_EXTRA,\n paddingRight: BORDER_EXTRA,\n }),\n }\n : {\n position: 'relative',\n boxSizing: 'border-box',\n }\n\n const svgStyle =\n layoutMode === 'border'\n ? {\n position: 'absolute',\n overflow: 'hidden',\n width: '100%',\n left: 0,\n top: -(TOP_OFFSET + TOP_ARC_SHIFT),\n height: `calc(100% + ${TOP_OFFSET + TOP_ARC_SHIFT}px)`,\n }\n : {\n position: 'absolute',\n overflow: 'hidden',\n width: `calc(100% + ${2 * BORDER_EXTRA}px)`,\n left: -BORDER_EXTRA,\n top: -(TOP_OFFSET + TOP_ARC_SHIFT),\n height: `calc(100% + ${TOP_OFFSET + TOP_ARC_SHIFT}px)`,\n }\n\n const paths = buildPathD(W, Y, N, R, STROKE_WIDTH, COLORS, TOP_ARC_SHIFT, Y_OFFSET, O_TOTAL, BORDER_EXTRA)\n\n const totalHeight = Y[Y.length - 1] ?? 0\n const totalWidth = Math.max(1, W + 2 * BORDER_EXTRA)\n const viewBoxHeight = totalHeight + TOP_OFFSET + TOP_ARC_SHIFT\n const viewBoxMinX = BORDER_EXTRA > 0 ? -BORDER_EXTRA : 0\n const viewBoxMinY = -STROKE_WIDTH * 2 - TOP_ARC_SHIFT\n const viewBoxStr = `${viewBoxMinX} ${viewBoxMinY} ${totalWidth} ${viewBoxHeight}`\n\n return {\n wrapperStyle,\n svgAttributes: {\n class: svgClassName,\n viewBox: viewBoxStr,\n style: svgStyle,\n },\n paths,\n }\n}\n\n/**\n * Measure wrapper and section elements to get width and section bottom Ys.\n * Children with the excludeClassName (default: same class used on the SVG by serpentineBorder) are excluded.\n * horizontalOverlap is resolved to pixels using strokeCount and strokeWidth.\n *\n * @param {HTMLElement} wrapperEl\n * @param {{\n * layoutMode: 'content' | 'border'\n * horizontalOverlap?: number | 'borderWidth' | 'halfBorderWidth'\n * strokeCount: number\n * strokeWidth: number\n * excludeClassName?: string\n * }} options\n * @returns {{ width: number, sectionBottomYs: number[] } | null}\n */\nexport function measureSections(wrapperEl, options) {\n const { layoutMode, horizontalOverlap = 0, strokeCount, strokeWidth, excludeClassName = DEFAULT_SVG_CLASS } = options\n const BORDER_EXTRA = resolveOverlapToPixels(horizontalOverlap, strokeCount, strokeWidth)\n if (!wrapperEl) return null\n\n const sectionEls = excludeClassName\n ? Array.from(wrapperEl.children).filter((el) => !el.classList.contains(excludeClassName))\n : Array.from(wrapperEl.children)\n\n if (sectionEls.length === 0) return null\n\n const rect = wrapperEl.getBoundingClientRect()\n const baseWidth = rect.width\n\n const W =\n layoutMode === 'border'\n ? Math.max(1, baseWidth - 2 * BORDER_EXTRA)\n : Math.max(1, baseWidth)\n\n const Y = [0]\n for (let i = 0; i < sectionEls.length; i++) {\n const r = sectionEls[i].getBoundingClientRect()\n Y.push(r.top - rect.top + r.height)\n }\n\n return { width: W, sectionBottomYs: Y }\n}\n","import { useEffect, useRef, useState } from 'react'\nimport { measureSections, serpentineBorder } from './serpentineCore.js'\n\nfunction SerpentineBorder({\n children,\n strokeCount,\n strokeWidth,\n radius,\n horizontalOverlap,\n colors,\n layoutMode,\n}) {\n const wrapperRef = useRef(null)\n const [borderData, setBorderData] = useState(null)\n\n useEffect(() => {\n const wrapper = wrapperRef.current\n if (!wrapper) return\n\n const measure = () => {\n const measured = measureSections(wrapper, {\n layoutMode,\n horizontalOverlap,\n strokeCount,\n strokeWidth,\n })\n if (!measured) return\n\n const data = serpentineBorder({\n width: measured.width,\n sectionBottomYs: measured.sectionBottomYs,\n strokeCount,\n strokeWidth,\n radius,\n horizontalOverlap,\n colors,\n layoutMode,\n })\n setBorderData(data)\n }\n\n measure()\n const ro = new ResizeObserver(measure)\n ro.observe(wrapper)\n return () => ro.disconnect()\n }, [strokeCount, strokeWidth, radius, horizontalOverlap, colors, layoutMode])\n\n return (\n <div\n ref={wrapperRef}\n className=\"serpentine-wrapper\"\n style={borderData?.wrapperStyle ?? { position: 'relative', boxSizing: 'border-box' }}\n data-testid=\"serpentine-wrapper\"\n >\n {borderData && (() => {\n const { class: className, ...restSvgAttrs } = borderData.svgAttributes\n return (\n <svg\n data-testid=\"serpentine-svg\"\n className={className}\n {...restSvgAttrs}\n >\n {borderData.paths.map((pathAttributes, i) => (\n <path key={i} {...pathAttributes} />\n ))}\n </svg>\n )\n })()}\n {children}\n </div>\n )\n}\n\nexport default SerpentineBorder\n"],"names":["DEFAULT_COLORS","resolveOverlapToPixels","horizontalOverlap","N","STROKE_WIDTH","totalBorderWidth","buildPathD","W","Y","R","COLORS","TOP_ARC_SHIFT","Y_OFFSET","O_TOTAL","BORDER_EXTRA","R1","RIGHT_EXTEND","n","parts","i","o","oj","r","rj","rj1","leftOffset","xLeft","rightExt","xRight","xLeftArc","xRightArc","xRightR1","yCurrTop","segs","t","yCurr","yNext","yExit","lastY","DEFAULT_SVG_CLASS","DEFAULTS","serpentineBorder","options","layoutMode","svgClassName","wrapperEl","measured","measureSections","TOTAL_BORDER_WIDTH","TOP_OFFSET","wrapperStyle","svgStyle","paths","totalHeight","totalWidth","viewBoxHeight","viewBoxMinX","viewBoxMinY","viewBoxStr","strokeCount","strokeWidth","excludeClassName","sectionEls","el","rect","baseWidth","SerpentineBorder","children","radius","colors","wrapperRef","useRef","borderData","setBorderData","useState","useEffect","wrapper","measure","data","ro","jsxs","className","restSvgAttrs","jsx","pathAttributes"],"mappings":";;AAAO,MAAMA,IAAiB,CAAC,WAAW,SAAS;ACOnD,SAASC,EAAuBC,GAAmBC,GAAGC,GAAc;AAClE,MAAI,OAAOF,KAAsB,SAAU,QAAOA;AAClD,QAAMG,IAAmBF,IAAIC;AAC7B,SAAIF,MAAsB,gBAAsBG,IAC5CH,MAAsB,oBAA0BG,IAAmB,IAChE;AACT;AAEA,SAASC,EAAWC,GAAGC,GAAGL,GAAGM,GAAGL,GAAcM,GAAQC,GAAeC,GAAUC,GAASC,GAAc;AACpG,QAAMC,IAAKX,KAAgBD,IAAI,IACzBa,IAAeZ,IAAe,GAC9Ba,IAAIT,EAAE,SAAS,GACfU,IAAQ,CAAA;AACd,WAASC,IAAI,GAAGA,IAAIhB,GAAGgB,KAAK;AAC1B,UAAMC,IAAID,IAAIf,GAERiB,KADIlB,IAAI,IAAIgB,KACHf,GACTkB,IAAIb,IAAIW,GACRG,IAAKd,IAAIY,GAETG,IAAMT,IAAKM,GAEXI,IAAaZ,IAAUC,IAAeE,GACtCU,IAAQN,IAAIP,IAAUY,GACtBE,IAAWb,GACXc,IAASrB,IAAIoB,IAAWN,IAAKL,GAC7Ba,IAAWH,KAASjB,IAAIW,IACxBU,IAAYvB,IAAIoB,IAAWlB,IAAIO,GAC/Be,IAAWxB,IAAIoB,IAAWZ,IAAKC,GAE/BgB,IAAWnB,IAAUD,GACrBqB,IAAO;AAAA,MACX,KAAKL,CAAM,IAAII,IAAWjB,IAAKX,IAAe,IAAIO,CAAa;AAAA,MAC/D,KAAKiB,CAAM,IAAII,IAAWjB,IAAKJ,CAAa;AAAA,MAC5C,KAAKa,CAAG,IAAIA,CAAG,UAAUO,CAAQ,IAAIC,IAAWX,IAAKV,CAAa;AAAA,MAClE,KAAKkB,CAAQ,IAAIT,IAAIR,IAAWD,CAAa;AAAA,MAC7C,KAAKW,CAAC,IAAIA,CAAC,UAAUI,CAAK,IAAIjB,IAAIG,IAAWD,CAAa;AAAA,MAC1D,KAAKe,CAAK,IAAIjB,IAAIG,CAAQ;AAAA,IAChC;AAEI,aAASsB,IAAI,GAAGA,IAAIjB,IAAI,GAAGiB,KAAK;AAC9B,YAAMC,IAAQ3B,EAAE0B,IAAI,CAAC,GACfE,IAAQ5B,EAAE0B,IAAI,CAAC,GACfG,IAAQF,IAAQ1B,IAAII,IAAUD;AAEpC,MAAIsB,IAAI,MAAM,KACZD,EAAK,KAAK,KAAKP,CAAK,IAAIS,IAAQ1B,IAAIG,CAAQ,EAAE,GAC9CqB,EAAK,KAAK,KAAKX,CAAC,KAAKA,CAAC,WAAWO,CAAQ,SAASM,IAAQf,IAAIR,CAAQ,EAAE,GACxEqB,EAAK,KAAK,KAAKH,CAAS,IAAIK,IAAQf,IAAIR,CAAQ,EAAE,GAClDqB,EAAK,KAAK,KAAKV,CAAE,IAAIA,CAAE,UAAUK,CAAM,IAAIS,CAAK,EAAE,GAClDJ,EAAK,KAAK,KAAKL,CAAM,IAAIQ,IAAQ3B,IAAIG,CAAQ,EAAE,MAE/CqB,EAAK,KAAK,KAAKL,CAAM,IAAIO,IAAQ1B,IAAIG,CAAQ,EAAE,GAC/CqB,EAAK,KAAK,KAAKV,CAAE,IAAIA,CAAE,UAAUO,CAAS,IAAIK,IAAQd,IAAKT,CAAQ,EAAE,GACrEqB,EAAK,KAAK,KAAKJ,CAAQ,IAAIM,IAAQd,IAAKT,CAAQ,EAAE,GAClDqB,EAAK,KAAK,KAAKX,CAAC,KAAKA,CAAC,WAAWI,CAAK,SAASW,CAAK,EAAE,GACtDJ,EAAK,KAAK,KAAKP,CAAK,IAAIU,IAAQ3B,IAAIG,CAAQ,EAAE;AAAA,IAElD;AAEA,UAAM0B,IAAQ9B,EAAES,CAAC;AACjB,KAAKA,IAAI,KAAK,MAAM,IAClBgB,EAAK,KAAK,KAAKL,CAAM,IAAIU,CAAK,EAAE,IAEhCL,EAAK,KAAK,KAAKP,CAAK,IAAIY,CAAK,EAAE,GAEjCpB,EAAM,KAAK;AAAA,MACT,GAAGe,EAAK,KAAK,GAAG;AAAA,MAChB,QAAQvB,EAAOS,IAAIT,EAAO,MAAM;AAAA,MAChC,aAAaN;AAAA,MACb,MAAM;AAAA,IACZ,CAAK;AAAA,EACH;AACA,SAAOc;AACT;AAEA,MAAMqB,IAAoB,yBAEpBC,IAAW;AAAA,EACf,aAAa;AAAA,EACb,aAAa;AAAA,EACb,QAAQ;AAAA,EACR,mBAAmB;AAAA,EACnB,YAAY;AACd;AAyBO,SAASC,EAAiBC,GAAS;AACxC,QAAMvC,IAAIuC,EAAQ,eAAeF,EAAS,aACpCpC,IAAesC,EAAQ,eAAeF,EAAS,aAC/C/B,IAAIiC,EAAQ,UAAUF,EAAS,QAC/BtC,IAAoBwC,EAAQ,qBAAqBF,EAAS,mBAC1D9B,IAASgC,EAAQ,UAAU1C,GAC3B2C,IAAaD,EAAQ,cAAcF,EAAS,YAC5CI,IAAeF,EAAQ,gBAAgBH;AAE7C,MAAIhC,GAAGC;AACP,MAAIkC,EAAQ,aAAa,MAAM;AAC7B,UAAMG,IAAYH,EAAQ;AAE1B,QAAI,EADW,OAAO,WAAa,OAAe,OAAOG,EAAU,yBAA0B,YAChF,QAAO;AACpB,UAAMC,IAAWC,EAAgBF,GAAW;AAAA,MAC1C,YAAAF;AAAA,MACA,mBAAAzC;AAAA,MACA,aAAaC;AAAA,MACb,aAAaC;AAAA,MACb,kBAAkBwC;AAAA,IACxB,CAAK;AACD,QAAI,CAACE,EAAU,QAAO;AACtB,IAAAvC,IAAIuC,EAAS,OACbtC,IAAIsC,EAAS;AAAA,EACf,OAAO;AACL,QAAIJ,EAAQ,SAAS,QAAQA,EAAQ,mBAAmB,KAAM,QAAO;AACrE,IAAAnC,IAAImC,EAAQ,OACZlC,IAAIkC,EAAQ;AAAA,EACd;AAEA,QAAM5B,IAAeb,EAAuBC,GAAmBC,GAAGC,CAAY,GACxES,KAAWV,IAAI,KAAKC,GACpB4C,IAAqB7C,IAAIC,GACzB6C,IAAa,IAAI7C,GACjBQ,IAAWC,IAAU,GACrBF,KAAkBR,IAAI,KAAK,IAAKC,IAAeQ,GAE/CsC,IACJP,MAAe,WACX;AAAA,IACE,WAAW;AAAA,IACX,UAAU;AAAA,IACV,WAAWK,IAAqB;AAAA,IAChC,GAAIlC,IAAe,KAAK;AAAA,MACtB,aAAaA;AAAA,MACb,cAAcA;AAAA,IAC1B;AAAA,EACA,IACQ;AAAA,IACE,UAAU;AAAA,IACV,WAAW;AAAA,EACrB,GAEQqC,IACJR,MAAe,WACX;AAAA,IACE,UAAU;AAAA,IACV,UAAU;AAAA,IACV,OAAO;AAAA,IACP,MAAM;AAAA,IACN,KAAK,EAAEM,IAAatC;AAAA,IACpB,QAAQ,eAAesC,IAAatC,CAAa;AAAA,EAC3D,IACQ;AAAA,IACE,UAAU;AAAA,IACV,UAAU;AAAA,IACV,OAAO,eAAe,IAAIG,CAAY;AAAA,IACtC,MAAM,CAACA;AAAA,IACP,KAAK,EAAEmC,IAAatC;AAAA,IACpB,QAAQ,eAAesC,IAAatC,CAAa;AAAA,EAC3D,GAEQyC,IAAQ9C,EAAWC,GAAGC,GAAGL,GAAGM,GAAGL,GAAcM,GAAQC,GAAeC,GAAUC,GAASC,CAAY,GAEnGuC,IAAc7C,EAAEA,EAAE,SAAS,CAAC,KAAK,GACjC8C,IAAa,KAAK,IAAI,GAAG/C,IAAI,IAAIO,CAAY,GAC7CyC,IAAgBF,IAAcJ,IAAatC,GAC3C6C,IAAc1C,IAAe,IAAI,CAACA,IAAe,GACjD2C,IAAc,CAACrD,IAAe,IAAIO,GAClC+C,IAAa,GAAGF,CAAW,IAAIC,CAAW,IAAIH,CAAU,IAAIC,CAAa;AAE/E,SAAO;AAAA,IACL,cAAAL;AAAA,IACA,eAAe;AAAA,MACb,OAAON;AAAA,MACP,SAASc;AAAA,MACT,OAAOP;AAAA,IACb;AAAA,IACI,OAAAC;AAAA,EACJ;AACA;AAiBO,SAASL,EAAgBF,GAAWH,GAAS;AAClD,QAAM,EAAE,YAAAC,GAAY,mBAAAzC,IAAoB,GAAG,aAAAyD,GAAa,aAAAC,GAAa,kBAAAC,IAAmBtB,MAAsBG,GACxG5B,IAAeb,EAAuBC,GAAmByD,GAAaC,CAAW;AACvF,MAAI,CAACf,EAAW,QAAO;AAEvB,QAAMiB,IAAaD,IACf,MAAM,KAAKhB,EAAU,QAAQ,EAAE,OAAO,CAACkB,MAAO,CAACA,EAAG,UAAU,SAASF,CAAgB,CAAC,IACtF,MAAM,KAAKhB,EAAU,QAAQ;AAEjC,MAAIiB,EAAW,WAAW,EAAG,QAAO;AAEpC,QAAME,IAAOnB,EAAU,sBAAqB,GACtCoB,IAAYD,EAAK,OAEjBzD,IACJoC,MAAe,WACX,KAAK,IAAI,GAAGsB,IAAY,IAAInD,CAAY,IACxC,KAAK,IAAI,GAAGmD,CAAS,GAErBzD,IAAI,CAAC,CAAC;AACZ,WAASW,IAAI,GAAGA,IAAI2C,EAAW,QAAQ3C,KAAK;AAC1C,UAAMG,IAAIwC,EAAW3C,CAAC,EAAE,sBAAqB;AAC7C,IAAAX,EAAE,KAAKc,EAAE,MAAM0C,EAAK,MAAM1C,EAAE,MAAM;AAAA,EACpC;AAEA,SAAO,EAAE,OAAOf,GAAG,iBAAiBC,EAAC;AACvC;ACtPA,SAAS0D,EAAiB;AAAA,EACxB,UAAAC;AAAA,EACA,aAAAR;AAAA,EACA,aAAAC;AAAA,EACA,QAAAQ;AAAA,EACA,mBAAAlE;AAAA,EACA,QAAAmE;AAAA,EACA,YAAA1B;AACF,GAAG;AACD,QAAM2B,IAAaC,EAAO,IAAI,GACxB,CAACC,GAAYC,CAAa,IAAIC,EAAS,IAAI;AAEjD,SAAAC,EAAU,MAAM;AACd,UAAMC,IAAUN,EAAW;AAC3B,QAAI,CAACM,EAAS;AAEd,UAAMC,IAAU,MAAM;AACpB,YAAM/B,IAAWC,EAAgB6B,GAAS;AAAA,QACxC,YAAAjC;AAAA,QACA,mBAAAzC;AAAA,QACA,aAAAyD;AAAA,QACA,aAAAC;AAAA,MAAA,CACD;AACD,UAAI,CAACd,EAAU;AAEf,YAAMgC,IAAOrC,EAAiB;AAAA,QAC5B,OAAOK,EAAS;AAAA,QAChB,iBAAiBA,EAAS;AAAA,QAC1B,aAAAa;AAAA,QACA,aAAAC;AAAA,QACA,QAAAQ;AAAA,QACA,mBAAAlE;AAAA,QACA,QAAAmE;AAAA,QACA,YAAA1B;AAAA,MAAA,CACD;AACD,MAAA8B,EAAcK,CAAI;AAAA,IACpB;AAEA,IAAAD,EAAA;AACA,UAAME,IAAK,IAAI,eAAeF,CAAO;AACrC,WAAAE,EAAG,QAAQH,CAAO,GACX,MAAMG,EAAG,WAAA;AAAA,EAClB,GAAG,CAACpB,GAAaC,GAAaQ,GAAQlE,GAAmBmE,GAAQ1B,CAAU,CAAC,GAG1E,gBAAAqC;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,KAAKV;AAAA,MACL,WAAU;AAAA,MACV,QAAOE,KAAA,gBAAAA,EAAY,iBAAgB,EAAE,UAAU,YAAY,WAAW,aAAA;AAAA,MACtE,eAAY;AAAA,MAEX,UAAA;AAAA,QAAAA,MAAe,MAAM;AACpB,gBAAM,EAAE,OAAOS,GAAW,GAAGC,EAAA,IAAiBV,EAAW;AACzD,iBACE,gBAAAW;AAAA,YAAC;AAAA,YAAA;AAAA,cACC,eAAY;AAAA,cACZ,WAAAF;AAAA,cACC,GAAGC;AAAA,cAEH,UAAAV,EAAW,MAAM,IAAI,CAACY,GAAgBjE,MACrC,gBAAAgE,EAAC,QAAA,EAAc,GAAGC,EAAA,GAAPjE,CAAuB,CACnC;AAAA,YAAA;AAAA,UAAA;AAAA,QAGP,GAAA;AAAA,QACCgD;AAAA,MAAA;AAAA,IAAA;AAAA,EAAA;AAGP;"}
package/index.d.ts ADDED
@@ -0,0 +1,49 @@
1
+ import type { ReactNode, ReactElement } from 'react'
2
+
3
+ export interface SerpentineBorderProps {
4
+ children?: ReactNode
5
+ strokeCount?: number
6
+ strokeWidth?: number
7
+ radius?: number
8
+ horizontalOverlap?: number | 'borderWidth' | 'halfBorderWidth'
9
+ colors?: string[]
10
+ layoutMode?: 'content' | 'border'
11
+ }
12
+
13
+ export declare function SerpentineBorder(props: SerpentineBorderProps): ReactElement
14
+
15
+ /** Vanilla JS core (no React). Single function for custom rendering or non-React environments. */
16
+ export interface SerpentineBorderOptions {
17
+ /** Content width in px (use with sectionBottomYs, or omit when using wrapperEl). */
18
+ width?: number
19
+ /** Cumulative section bottom Y coordinates (use with width, or omit when using wrapperEl). */
20
+ sectionBottomYs?: number[]
21
+ /** When set, width and sectionBottomYs are measured from this element; returns null in SSR or when measurement fails. */
22
+ wrapperEl?: HTMLElement
23
+ strokeCount?: number
24
+ strokeWidth?: number
25
+ radius?: number
26
+ horizontalOverlap?: number | 'borderWidth' | 'halfBorderWidth'
27
+ colors?: string[]
28
+ layoutMode?: 'content' | 'border'
29
+ svgClassName?: string
30
+ }
31
+
32
+ export interface SerpentineBorderResult {
33
+ wrapperStyle: Record<string, unknown>
34
+ svgAttributes: { class?: string; viewBox: string; style: Record<string, unknown> }
35
+ paths: Array<{ d: string; stroke: string; strokeWidth: number; fill: string }>
36
+ }
37
+
38
+ export declare function serpentineBorder(options: SerpentineBorderOptions): SerpentineBorderResult | null
39
+
40
+ export declare function measureSections(
41
+ wrapperEl: HTMLElement,
42
+ options: {
43
+ layoutMode: 'content' | 'border'
44
+ horizontalOverlap?: number | 'borderWidth' | 'halfBorderWidth'
45
+ strokeCount: number
46
+ strokeWidth: number
47
+ excludeClassName?: string
48
+ }
49
+ ): { width: number; sectionBottomYs: number[] } | null
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "serpentine-border",
3
+ "version": "1.0.0",
4
+ "description": "Multi-stroke serpentine (wavy) border SVG — vanilla JS and React",
5
+ "type": "module",
6
+ "main": "./dist/serpentine-border.cjs",
7
+ "module": "./dist/serpentine-border.js",
8
+ "types": "./index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/serpentine-border.js",
12
+ "require": "./dist/serpentine-border.cjs",
13
+ "types": "./index.d.ts"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "index.d.ts",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "build": "vite build",
24
+ "test": "playwright test",
25
+ "test:debug": "DEBUG_E2E=1 playwright test",
26
+ "test:install-browsers": "playwright install firefox",
27
+ "dev:test-app": "vite --config vite.test-app.config.js",
28
+ "prepublishOnly": "npm run build"
29
+ },
30
+ "peerDependencies": {
31
+ "react": ">=17.0.0",
32
+ "react-dom": ">=17.0.0"
33
+ },
34
+ "devDependencies": {
35
+ "@playwright/test": "^1.49.0",
36
+ "@vitejs/plugin-react": "^4.2.1",
37
+ "react": "^18.2.0",
38
+ "react-dom": "^18.2.0",
39
+ "vite": "^5.0.0"
40
+ },
41
+ "keywords": [
42
+ "react",
43
+ "border",
44
+ "serpentine",
45
+ "wavy",
46
+ "svg",
47
+ "component"
48
+ ],
49
+ "author": "",
50
+ "license": "MIT",
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "https://github.com/your-username/serpentine-border.git"
54
+ }
55
+ }