@goodmanlabs/react-swipe-row 0.1.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 +21 -0
- package/README.md +23 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +156 -0
- package/dist/index.js.map +1 -0
- package/dist/style.css +87 -0
- package/package.json +71 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Glenn Goodman
|
|
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,23 @@
|
|
|
1
|
+
# react-swipe-row
|
|
2
|
+
|
|
3
|
+
A native-feeling horizontal “card rail” for React — momentum scrolling + scroll-snap alignment, with optional desktop paging controls.
|
|
4
|
+
|
|
5
|
+
This was built to solve a common UI problem: you want a horizontally scrollable row of arbitrary content (cards, tiles, chips, etc.) where the number and size of items is unknown, and you don’t want a “slide-based carousel” that feels artificial or forces layout math.
|
|
6
|
+
|
|
7
|
+
`react-swipe-row` is intentionally **unopinionated**:
|
|
8
|
+
- You provide the content (any React nodes)
|
|
9
|
+
- The row scrolls naturally (trackpad, mousewheel, touch)
|
|
10
|
+
- Scroll-snap keeps items aligned
|
|
11
|
+
- Optional left/right controls appear automatically on “desktop-like” pointers
|
|
12
|
+
- Control styling (buttons) and item spacing are exposed via props (no need to target internal DOM or CSS)
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install react-swipe-row
|
|
20
|
+
# or
|
|
21
|
+
pnpm add react-swipe-row
|
|
22
|
+
# or
|
|
23
|
+
yarn add react-swipe-row
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
type ShowControlsMode = 'auto' | 'always' | 'never';
|
|
5
|
+
type SwipeRowClassNames = {
|
|
6
|
+
/** Outer wrapper around the scroller + controls */
|
|
7
|
+
root?: string;
|
|
8
|
+
/** The horizontal scroller element */
|
|
9
|
+
scroller?: string;
|
|
10
|
+
/** Wrapper around each item (the snap target) */
|
|
11
|
+
item?: string;
|
|
12
|
+
/** Shared class applied to both buttons */
|
|
13
|
+
controlButton?: string;
|
|
14
|
+
/** Prev button */
|
|
15
|
+
prevButton?: string;
|
|
16
|
+
/** Next button */
|
|
17
|
+
nextButton?: string;
|
|
18
|
+
};
|
|
19
|
+
type SwipeRowProps = {
|
|
20
|
+
/** Optional array of React nodes (alternative to children) */
|
|
21
|
+
items?: React.ReactNode[];
|
|
22
|
+
children?: React.ReactNode;
|
|
23
|
+
ariaLabel?: string;
|
|
24
|
+
/** Enable CSS scroll-snap */
|
|
25
|
+
snap?: boolean;
|
|
26
|
+
/** How far arrows / keyboard page (fraction of visible width) */
|
|
27
|
+
pageFactor?: number;
|
|
28
|
+
/** When to show left/right controls */
|
|
29
|
+
showControls?: ShowControlsMode;
|
|
30
|
+
/** Optional stable id for aria-controls */
|
|
31
|
+
id?: string;
|
|
32
|
+
/** Extra class on outer wrapper */
|
|
33
|
+
className?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Spacing between items.
|
|
36
|
+
* This is a class hook (NOT Tailwind-specific). For non-Tailwind consumers,
|
|
37
|
+
* they'll typically use the provided default CSS and ignore this.
|
|
38
|
+
*/
|
|
39
|
+
gapClassName?: string;
|
|
40
|
+
/** More granular class hooks */
|
|
41
|
+
classNames?: SwipeRowClassNames;
|
|
42
|
+
/**
|
|
43
|
+
* Optional inline style passthrough for the scroller.
|
|
44
|
+
* Useful for consumers that want scrollbarGutter, etc.
|
|
45
|
+
*/
|
|
46
|
+
scrollerStyle?: React.CSSProperties;
|
|
47
|
+
};
|
|
48
|
+
declare function SwipeRow({ items, children, ariaLabel, className, gapClassName, snap, pageFactor, showControls, id, classNames, scrollerStyle, }: SwipeRowProps): react_jsx_runtime.JSX.Element;
|
|
49
|
+
|
|
50
|
+
export { type ShowControlsMode, SwipeRow, type SwipeRowClassNames, type SwipeRowProps, SwipeRow as default };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// src/SwipeRow.tsx
|
|
2
|
+
import React, { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
function cx(...parts) {
|
|
5
|
+
return parts.filter(Boolean).join(" ");
|
|
6
|
+
}
|
|
7
|
+
function SwipeRow({
|
|
8
|
+
items,
|
|
9
|
+
children,
|
|
10
|
+
ariaLabel = "Scrollable content",
|
|
11
|
+
className,
|
|
12
|
+
gapClassName,
|
|
13
|
+
snap = true,
|
|
14
|
+
pageFactor = 0.9,
|
|
15
|
+
showControls = "auto",
|
|
16
|
+
id,
|
|
17
|
+
classNames,
|
|
18
|
+
scrollerStyle
|
|
19
|
+
}) {
|
|
20
|
+
const scrollerRef = useRef(null);
|
|
21
|
+
const [canLeft, setCanLeft] = useState(false);
|
|
22
|
+
const [canRight, setCanRight] = useState(false);
|
|
23
|
+
const [controlsOn, setControlsOn] = useState(showControls === "always");
|
|
24
|
+
const autoId = useId();
|
|
25
|
+
const regionId = id != null ? id : autoId;
|
|
26
|
+
const content = useMemo(() => {
|
|
27
|
+
if (items) return items;
|
|
28
|
+
return React.Children.toArray(children);
|
|
29
|
+
}, [items, children]);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const el = scrollerRef.current;
|
|
32
|
+
if (!el) return;
|
|
33
|
+
const update = () => {
|
|
34
|
+
const max = el.scrollWidth - el.clientWidth - 1;
|
|
35
|
+
setCanLeft(el.scrollLeft > 0);
|
|
36
|
+
setCanRight(el.scrollLeft < max);
|
|
37
|
+
};
|
|
38
|
+
update();
|
|
39
|
+
el.addEventListener("scroll", update, { passive: true });
|
|
40
|
+
window.addEventListener("resize", update);
|
|
41
|
+
return () => {
|
|
42
|
+
el.removeEventListener("scroll", update);
|
|
43
|
+
window.removeEventListener("resize", update);
|
|
44
|
+
};
|
|
45
|
+
}, []);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
var _a;
|
|
48
|
+
if (showControls !== "auto") {
|
|
49
|
+
setControlsOn(showControls === "always");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
|
|
53
|
+
const set = () => setControlsOn(mq.matches);
|
|
54
|
+
set();
|
|
55
|
+
if (mq.addEventListener) mq.addEventListener("change", set);
|
|
56
|
+
else (_a = mq.addListener) == null ? void 0 : _a.call(mq, set);
|
|
57
|
+
return () => {
|
|
58
|
+
var _a2;
|
|
59
|
+
if (mq.removeEventListener) mq.removeEventListener("change", set);
|
|
60
|
+
else (_a2 = mq.removeListener) == null ? void 0 : _a2.call(mq, set);
|
|
61
|
+
};
|
|
62
|
+
}, [showControls]);
|
|
63
|
+
const page = (dir) => {
|
|
64
|
+
const el = scrollerRef.current;
|
|
65
|
+
if (!el) return;
|
|
66
|
+
const step = Math.round(el.clientWidth * pageFactor);
|
|
67
|
+
el.scrollBy({ left: dir * step, behavior: "smooth" });
|
|
68
|
+
};
|
|
69
|
+
const onKeyDown = (e) => {
|
|
70
|
+
const el = scrollerRef.current;
|
|
71
|
+
if (!el) return;
|
|
72
|
+
const step = Math.round(el.clientWidth * pageFactor);
|
|
73
|
+
if (e.key === "ArrowRight") {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
el.scrollBy({ left: step, behavior: "smooth" });
|
|
76
|
+
}
|
|
77
|
+
if (e.key === "ArrowLeft") {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
el.scrollBy({ left: -step, behavior: "smooth" });
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
return /* @__PURE__ */ jsxs("div", { className: cx("rsr-root", className, classNames == null ? void 0 : classNames.root), children: [
|
|
83
|
+
/* @__PURE__ */ jsx(
|
|
84
|
+
"div",
|
|
85
|
+
{
|
|
86
|
+
ref: scrollerRef,
|
|
87
|
+
role: "region",
|
|
88
|
+
"aria-label": ariaLabel,
|
|
89
|
+
tabIndex: 0,
|
|
90
|
+
onKeyDown,
|
|
91
|
+
className: cx(
|
|
92
|
+
"rsr-scroller",
|
|
93
|
+
snap && "rsr-snap",
|
|
94
|
+
gapClassName,
|
|
95
|
+
classNames == null ? void 0 : classNames.scroller
|
|
96
|
+
),
|
|
97
|
+
style: {
|
|
98
|
+
WebkitOverflowScrolling: "touch",
|
|
99
|
+
// matches your original (optional, but harmless)
|
|
100
|
+
scrollbarGutter: "stable both-edges",
|
|
101
|
+
...scrollerStyle
|
|
102
|
+
},
|
|
103
|
+
id: regionId,
|
|
104
|
+
children: content.map((node, i) => /* @__PURE__ */ jsx(
|
|
105
|
+
"div",
|
|
106
|
+
{
|
|
107
|
+
className: cx("rsr-item", snap && "rsr-snap-item", classNames == null ? void 0 : classNames.item),
|
|
108
|
+
children: node
|
|
109
|
+
},
|
|
110
|
+
i
|
|
111
|
+
))
|
|
112
|
+
}
|
|
113
|
+
),
|
|
114
|
+
controlsOn && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
115
|
+
/* @__PURE__ */ jsx(
|
|
116
|
+
"button",
|
|
117
|
+
{
|
|
118
|
+
type: "button",
|
|
119
|
+
onClick: () => page(-1),
|
|
120
|
+
disabled: !canLeft,
|
|
121
|
+
"aria-controls": regionId,
|
|
122
|
+
"aria-label": "Scroll left",
|
|
123
|
+
className: cx(
|
|
124
|
+
"rsr-control",
|
|
125
|
+
"rsr-prev",
|
|
126
|
+
classNames == null ? void 0 : classNames.controlButton,
|
|
127
|
+
classNames == null ? void 0 : classNames.prevButton
|
|
128
|
+
),
|
|
129
|
+
children: "\u2039"
|
|
130
|
+
}
|
|
131
|
+
),
|
|
132
|
+
/* @__PURE__ */ jsx(
|
|
133
|
+
"button",
|
|
134
|
+
{
|
|
135
|
+
type: "button",
|
|
136
|
+
onClick: () => page(1),
|
|
137
|
+
disabled: !canRight,
|
|
138
|
+
"aria-controls": regionId,
|
|
139
|
+
"aria-label": "Scroll right",
|
|
140
|
+
className: cx(
|
|
141
|
+
"rsr-control",
|
|
142
|
+
"rsr-next",
|
|
143
|
+
classNames == null ? void 0 : classNames.controlButton,
|
|
144
|
+
classNames == null ? void 0 : classNames.nextButton
|
|
145
|
+
),
|
|
146
|
+
children: "\u203A"
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
] })
|
|
150
|
+
] });
|
|
151
|
+
}
|
|
152
|
+
export {
|
|
153
|
+
SwipeRow,
|
|
154
|
+
SwipeRow as default
|
|
155
|
+
};
|
|
156
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/SwipeRow.tsx"],"sourcesContent":["import React, { useEffect, useId, useMemo, useRef, useState } from 'react';\n\nexport type ShowControlsMode = 'auto' | 'always' | 'never';\n\nexport type SwipeRowClassNames = {\n /** Outer wrapper around the scroller + controls */\n root?: string;\n /** The horizontal scroller element */\n scroller?: string;\n /** Wrapper around each item (the snap target) */\n item?: string;\n /** Shared class applied to both buttons */\n controlButton?: string;\n /** Prev button */\n prevButton?: string;\n /** Next button */\n nextButton?: string;\n};\n\nexport type SwipeRowProps = {\n /** Optional array of React nodes (alternative to children) */\n items?: React.ReactNode[];\n children?: React.ReactNode;\n\n ariaLabel?: string;\n\n /** Enable CSS scroll-snap */\n snap?: boolean;\n\n /** How far arrows / keyboard page (fraction of visible width) */\n pageFactor?: number;\n\n /** When to show left/right controls */\n showControls?: ShowControlsMode;\n\n /** Optional stable id for aria-controls */\n id?: string;\n\n /** Extra class on outer wrapper */\n className?: string;\n\n /**\n * Spacing between items.\n * This is a class hook (NOT Tailwind-specific). For non-Tailwind consumers,\n * they'll typically use the provided default CSS and ignore this.\n */\n gapClassName?: string;\n\n /** More granular class hooks */\n classNames?: SwipeRowClassNames;\n\n /**\n * Optional inline style passthrough for the scroller.\n * Useful for consumers that want scrollbarGutter, etc.\n */\n scrollerStyle?: React.CSSProperties;\n};\n\nfunction cx(...parts: Array<string | undefined | false | null>) {\n return parts.filter(Boolean).join(' ');\n}\n\nexport default function SwipeRow({\n items,\n children,\n ariaLabel = 'Scrollable content',\n className,\n gapClassName,\n snap = true,\n pageFactor = 0.9,\n showControls = 'auto',\n id,\n classNames,\n scrollerStyle,\n }: SwipeRowProps) {\n const scrollerRef = useRef<HTMLDivElement | null>(null);\n\n const [canLeft, setCanLeft] = useState(false);\n const [canRight, setCanRight] = useState(false);\n const [controlsOn, setControlsOn] = useState(showControls === 'always');\n\n const autoId = useId();\n const regionId = id ?? autoId;\n\n const content = useMemo(() => {\n if (items) return items;\n return React.Children.toArray(children);\n }, [items, children]);\n\n // Enable/disable arrows based on scroll position\n useEffect(() => {\n const el = scrollerRef.current;\n if (!el) return;\n\n const update = () => {\n // tolerance: helps avoid off-by-1 due to subpixel rounding\n const max = el.scrollWidth - el.clientWidth - 1;\n setCanLeft(el.scrollLeft > 0);\n setCanRight(el.scrollLeft < max);\n };\n\n update();\n el.addEventListener('scroll', update, { passive: true });\n window.addEventListener('resize', update);\n\n return () => {\n el.removeEventListener('scroll', update);\n window.removeEventListener('resize', update);\n };\n }, []);\n\n // Decide when to show controls (desktop-ish)\n useEffect(() => {\n if (showControls !== 'auto') {\n setControlsOn(showControls === 'always');\n return;\n }\n\n const mq = window.matchMedia('(hover: hover) and (pointer: fine)');\n const set = () => setControlsOn(mq.matches);\n\n set();\n\n // Safari fallback\n if (mq.addEventListener) mq.addEventListener('change', set);\n // eslint-disable-next-line deprecation/deprecation\n else mq.addListener?.(set);\n\n return () => {\n if (mq.removeEventListener) mq.removeEventListener('change', set);\n // eslint-disable-next-line deprecation/deprecation\n else mq.removeListener?.(set);\n };\n }, [showControls]);\n\n const page = (dir: -1 | 1) => {\n const el = scrollerRef.current;\n if (!el) return;\n const step = Math.round(el.clientWidth * pageFactor);\n el.scrollBy({ left: dir * step, behavior: 'smooth' });\n };\n\n // Keyboard paging (a11y)\n const onKeyDown = (e: React.KeyboardEvent) => {\n const el = scrollerRef.current;\n if (!el) return;\n const step = Math.round(el.clientWidth * pageFactor);\n\n if (e.key === 'ArrowRight') {\n e.preventDefault();\n el.scrollBy({ left: step, behavior: 'smooth' });\n }\n if (e.key === 'ArrowLeft') {\n e.preventDefault();\n el.scrollBy({ left: -step, behavior: 'smooth' });\n }\n };\n\n return (\n <div className={cx('rsr-root', className, classNames?.root)}>\n <div\n ref={scrollerRef}\n role=\"region\"\n aria-label={ariaLabel}\n tabIndex={0}\n onKeyDown={onKeyDown}\n className={cx(\n 'rsr-scroller',\n snap && 'rsr-snap',\n gapClassName,\n classNames?.scroller\n )}\n style={{\n WebkitOverflowScrolling: 'touch',\n // matches your original (optional, but harmless)\n scrollbarGutter: 'stable both-edges',\n ...scrollerStyle,\n }}\n id={regionId}\n >\n {content.map((node, i) => (\n <div\n key={i}\n className={cx('rsr-item', snap && 'rsr-snap-item', classNames?.item)}\n >\n {node}\n </div>\n ))}\n </div>\n\n {controlsOn && (\n <>\n <button\n type=\"button\"\n onClick={() => page(-1)}\n disabled={!canLeft}\n aria-controls={regionId}\n aria-label=\"Scroll left\"\n className={cx(\n 'rsr-control',\n 'rsr-prev',\n classNames?.controlButton,\n classNames?.prevButton\n )}\n >\n ‹\n </button>\n\n <button\n type=\"button\"\n onClick={() => page(1)}\n disabled={!canRight}\n aria-controls={regionId}\n aria-label=\"Scroll right\"\n className={cx(\n 'rsr-control',\n 'rsr-next',\n classNames?.controlButton,\n classNames?.nextButton\n )}\n >\n ›\n </button>\n </>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":["_a"]}
|
package/dist/style.css
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/* react-swipe-row: minimal mechanics (unopinionated) */
|
|
2
|
+
|
|
3
|
+
.rsr-root {
|
|
4
|
+
position: relative;
|
|
5
|
+
width: 100%;
|
|
6
|
+
min-width: 0;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/* Horizontal rail */
|
|
10
|
+
.rsr-scroller {
|
|
11
|
+
width: 100%;
|
|
12
|
+
min-width: 0;
|
|
13
|
+
overflow-x: auto;
|
|
14
|
+
overflow-y: hidden;
|
|
15
|
+
|
|
16
|
+
/* layout */
|
|
17
|
+
display: grid;
|
|
18
|
+
grid-auto-flow: column;
|
|
19
|
+
grid-auto-columns: max-content;
|
|
20
|
+
align-items: stretch;
|
|
21
|
+
|
|
22
|
+
/* prevents parent page swipe-back stealing horizontal scroll */
|
|
23
|
+
overscroll-behavior-x: contain;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* Snap behavior */
|
|
27
|
+
.rsr-snap {
|
|
28
|
+
scroll-snap-type: x mandatory;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.rsr-item {
|
|
32
|
+
align-self: stretch;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.rsr-snap-item {
|
|
36
|
+
scroll-snap-align: start;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* Controls (neutral default styling) */
|
|
40
|
+
.rsr-control {
|
|
41
|
+
position: absolute;
|
|
42
|
+
top: 50%;
|
|
43
|
+
transform: translateY(-50%);
|
|
44
|
+
z-index: 10;
|
|
45
|
+
|
|
46
|
+
display: inline-flex;
|
|
47
|
+
align-items: center;
|
|
48
|
+
justify-content: center;
|
|
49
|
+
|
|
50
|
+
width: 36px;
|
|
51
|
+
height: 36px;
|
|
52
|
+
border-radius: 9999px;
|
|
53
|
+
|
|
54
|
+
border: 1px solid rgba(0, 0, 0, 0.12);
|
|
55
|
+
background: rgba(255, 255, 255, 0.9);
|
|
56
|
+
color: rgba(0, 0, 0, 0.85);
|
|
57
|
+
|
|
58
|
+
cursor: pointer;
|
|
59
|
+
user-select: none;
|
|
60
|
+
-webkit-tap-highlight-color: transparent;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.rsr-control:hover {
|
|
64
|
+
background: rgba(255, 255, 255, 1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.rsr-control:disabled {
|
|
68
|
+
opacity: 0.45;
|
|
69
|
+
cursor: default;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.rsr-prev {
|
|
73
|
+
left: 8px;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.rsr-next {
|
|
77
|
+
right: 8px;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* Optional: hide scrollbar in browsers that support it */
|
|
81
|
+
.rsr-scroller {
|
|
82
|
+
scrollbar-width: none; /* Firefox */
|
|
83
|
+
}
|
|
84
|
+
.rsr-scroller::-webkit-scrollbar {
|
|
85
|
+
width: 0;
|
|
86
|
+
height: 0;
|
|
87
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@goodmanlabs/react-swipe-row",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"author": "Glenn Goodman",
|
|
5
|
+
"description": "A native-feeling horizontal scroll-snap card rail for React",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"private": false,
|
|
8
|
+
"type": "module",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/gpgoodman/react-swipe-row.git"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/gpgoodman/react-swipe-row#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/gpgoodman/react-swipe-row/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"react",
|
|
19
|
+
"react-component",
|
|
20
|
+
"horizontal-scroll",
|
|
21
|
+
"scroll-snap",
|
|
22
|
+
"carousel",
|
|
23
|
+
"card-rail",
|
|
24
|
+
"swipe",
|
|
25
|
+
"gesture",
|
|
26
|
+
"touch",
|
|
27
|
+
"momentum-scroll",
|
|
28
|
+
"ui",
|
|
29
|
+
"frontend",
|
|
30
|
+
"scrollable",
|
|
31
|
+
"horizontal",
|
|
32
|
+
"card-carousel"
|
|
33
|
+
],
|
|
34
|
+
"sideEffects": [
|
|
35
|
+
"dist/style.css"
|
|
36
|
+
],
|
|
37
|
+
"main": "dist/index.js",
|
|
38
|
+
"module": "dist/index.js",
|
|
39
|
+
"types": "dist/index.d.ts",
|
|
40
|
+
"exports": {
|
|
41
|
+
".": {
|
|
42
|
+
"types": "./dist/index.d.ts",
|
|
43
|
+
"import": "./dist/index.js"
|
|
44
|
+
},
|
|
45
|
+
"./style.css": "./dist/style.css"
|
|
46
|
+
},
|
|
47
|
+
"files": [
|
|
48
|
+
"dist"
|
|
49
|
+
],
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"react": ">=18",
|
|
52
|
+
"react-dom": ">=18"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
56
|
+
"@testing-library/react": "^16.3.1",
|
|
57
|
+
"@types/react": "^19.2.8",
|
|
58
|
+
"happy-dom": "^20.1.0",
|
|
59
|
+
"tsup": "^8.5.1",
|
|
60
|
+
"typescript": "^5.9.3",
|
|
61
|
+
"vitest": "^4.0.17"
|
|
62
|
+
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"prepublishOnly": "npm run test && npm run build",
|
|
65
|
+
"build": "tsup && cp src/style.css dist/style.css",
|
|
66
|
+
"dev": "tsup --watch",
|
|
67
|
+
"clean": "rm -rf dist",
|
|
68
|
+
"test": "vitest run",
|
|
69
|
+
"test:watch": "vitest"
|
|
70
|
+
}
|
|
71
|
+
}
|