aplance-ui 1.1.1 → 1.2.1

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/package.json CHANGED
@@ -1,63 +1,63 @@
1
- {
2
- "name": "aplance-ui",
3
- "version": "1.1.1",
4
- "description": "The aplance-ui CLI - scaffold the design system and copy components into your project as source.",
5
- "keywords": [
6
- "react",
7
- "ui",
8
- "components",
9
- "design-system",
10
- "cli",
11
- "scaffold",
12
- "shadcn",
13
- "source-first",
14
- "css-modules",
15
- "tokens"
16
- ],
17
- "homepage": "https://ui.aplance.com",
18
- "author": "Aplance Tech <tech.aplance@gmail.com>",
19
- "contributors": [
20
- "Viraj Gamdha <viraj@aplance.com>"
21
- ],
22
- "license": "MIT",
23
- "private": false,
24
- "type": "module",
25
- "bin": {
26
- "aplance-ui": "./dist/index.js"
27
- },
28
- "main": "./dist/api.js",
29
- "types": "./dist/api.d.ts",
30
- "exports": {
31
- ".": {
32
- "types": "./dist/api.d.ts",
33
- "import": "./dist/api.js"
34
- },
35
- "./package.json": "./package.json"
36
- },
37
- "files": [
38
- "dist",
39
- "registry",
40
- "README.md"
41
- ],
42
- "publishConfig": {
43
- "access": "public"
44
- },
45
- "scripts": {
46
- "sync": "npm run registry --workspace @aplance-ui/core",
47
- "build": "npm run sync && tsup",
48
- "dev": "tsup --watch",
49
- "typecheck": "tsc --noEmit",
50
- "lint": "eslint src",
51
- "prepublishOnly": "npm run build && npm run typecheck && npm run lint"
52
- },
53
- "dependencies": {
54
- "@clack/prompts": "^0.8.2",
55
- "commander": "^12.1.0",
56
- "picocolors": "^1.1.1"
57
- },
58
- "devDependencies": {
59
- "@types/node": "^24.10.1",
60
- "tsup": "^8.3.5",
61
- "typescript": "~5.9.3"
62
- }
63
- }
1
+ {
2
+ "name": "aplance-ui",
3
+ "version": "1.2.1",
4
+ "description": "The aplance-ui CLI - scaffold the design system and copy components into your project as source.",
5
+ "keywords": [
6
+ "react",
7
+ "ui",
8
+ "components",
9
+ "design-system",
10
+ "cli",
11
+ "scaffold",
12
+ "shadcn",
13
+ "source-first",
14
+ "css-modules",
15
+ "tokens"
16
+ ],
17
+ "homepage": "https://ui.aplance.com",
18
+ "author": "Aplance Tech <tech.aplance@gmail.com>",
19
+ "contributors": [
20
+ "Viraj Gamdha <viraj@aplance.com>"
21
+ ],
22
+ "license": "MIT",
23
+ "private": false,
24
+ "type": "module",
25
+ "bin": {
26
+ "aplance-ui": "./dist/index.js"
27
+ },
28
+ "main": "./dist/api.js",
29
+ "types": "./dist/api.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/api.d.ts",
33
+ "import": "./dist/api.js"
34
+ },
35
+ "./package.json": "./package.json"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "registry",
40
+ "README.md"
41
+ ],
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "scripts": {
46
+ "sync": "npm run registry --workspace @aplance-ui/core",
47
+ "build": "npm run sync && tsup",
48
+ "dev": "tsup --watch",
49
+ "typecheck": "tsc --noEmit",
50
+ "lint": "eslint src",
51
+ "prepublishOnly": "npm run build && npm run typecheck && npm run lint"
52
+ },
53
+ "dependencies": {
54
+ "@clack/prompts": "^0.8.2",
55
+ "commander": "^12.1.0",
56
+ "picocolors": "^1.1.1"
57
+ },
58
+ "devDependencies": {
59
+ "@types/node": "^24.10.1",
60
+ "tsup": "^8.3.5",
61
+ "typescript": "~5.9.3"
62
+ }
63
+ }
@@ -0,0 +1,32 @@
1
+ import { createContext, useContext } from "react";
2
+ import type { InfiniteScrollDirection, InfiniteScrollOrientation } from "./infinite-scroll";
3
+
4
+ export type InfiniteScrollApi = {
5
+ orientation: InfiniteScrollOrientation;
6
+ direction: InfiniteScrollDirection;
7
+ itemCount: number;
8
+ copyCount: number;
9
+ setSize: number;
10
+ duration: number;
11
+ isMeasured: boolean;
12
+ isPaused: boolean;
13
+ isManuallyPaused: boolean;
14
+ isHovering: boolean;
15
+ isFocusWithin: boolean;
16
+ pause: () => void;
17
+ resume: () => void;
18
+ toggle: () => void;
19
+ measure: () => void;
20
+ };
21
+
22
+ export const InfiniteScrollContext = createContext<InfiniteScrollApi | undefined>(
23
+ undefined,
24
+ );
25
+
26
+ export const useInfiniteScroll = () => {
27
+ const ctx = useContext(InfiniteScrollContext);
28
+ if (!ctx) {
29
+ throw new Error("useInfiniteScroll must be used within an <InfiniteScroll>");
30
+ }
31
+ return ctx;
32
+ };
@@ -0,0 +1,118 @@
1
+ /* ─── Root + viewport ────────────────────────────────────────────────────────
2
+ Edge fade uses CSS mask so consumers don't need to wrap the children in an
3
+ extra layer. Mask shape switches with orientation via `fade_x` / `fade_y`.
4
+ ─────────────────────────────────────────────────────────────────────────── */
5
+
6
+ .root {
7
+ --infinite-scroll-fade-size: 10%;
8
+
9
+ position: relative;
10
+ width: 100%;
11
+ min-width: 0;
12
+ }
13
+
14
+ .viewport {
15
+ width: 100%;
16
+ min-width: 0;
17
+ overflow: hidden;
18
+ user-select: none;
19
+ -webkit-user-select: none;
20
+ }
21
+
22
+ .viewport_vertical {
23
+ height: 100%;
24
+ }
25
+
26
+ .fade_x {
27
+ -webkit-mask-image: linear-gradient(
28
+ to right,
29
+ transparent,
30
+ #000 var(--infinite-scroll-fade-size),
31
+ #000 calc(100% - var(--infinite-scroll-fade-size)),
32
+ transparent
33
+ );
34
+ mask-image: linear-gradient(
35
+ to right,
36
+ transparent,
37
+ #000 var(--infinite-scroll-fade-size),
38
+ #000 calc(100% - var(--infinite-scroll-fade-size)),
39
+ transparent
40
+ );
41
+ }
42
+
43
+ .fade_y {
44
+ -webkit-mask-image: linear-gradient(
45
+ to bottom,
46
+ transparent,
47
+ #000 var(--infinite-scroll-fade-size),
48
+ #000 calc(100% - var(--infinite-scroll-fade-size)),
49
+ transparent
50
+ );
51
+ mask-image: linear-gradient(
52
+ to bottom,
53
+ transparent,
54
+ #000 var(--infinite-scroll-fade-size),
55
+ #000 calc(100% - var(--infinite-scroll-fade-size)),
56
+ transparent
57
+ );
58
+ }
59
+
60
+ /* ─── Track + keyframes ──────────────────────────────────────────────────────
61
+ The keyframe reads from CSS custom properties set by TSX so a single
62
+ `infinite-scroll` animation drives both axes and both directions. The
63
+ distance is `setSize` measured at runtime, not a hardcoded `-50%`.
64
+ ─────────────────────────────────────────────────────────────────────────── */
65
+
66
+ .track {
67
+ display: flex;
68
+ width: max-content;
69
+ will-change: transform;
70
+ animation-name: infinite-scroll;
71
+ animation-duration: var(--infinite-scroll-duration, 30s);
72
+ animation-timing-function: linear;
73
+ animation-iteration-count: infinite;
74
+ animation-play-state: var(--infinite-scroll-play-state, running);
75
+ }
76
+
77
+ .track_vertical {
78
+ width: 100%;
79
+ height: max-content;
80
+ flex-direction: column;
81
+ }
82
+
83
+ .item {
84
+ min-width: 0;
85
+ flex: 0 0 auto;
86
+
87
+ > img,
88
+ > video,
89
+ > picture {
90
+ display: block;
91
+ user-select: none;
92
+ -webkit-user-drag: none;
93
+ }
94
+ }
95
+
96
+ @keyframes infinite-scroll {
97
+ from {
98
+ transform: translate3d(
99
+ var(--infinite-scroll-from-x, 0),
100
+ var(--infinite-scroll-from-y, 0),
101
+ 0
102
+ );
103
+ }
104
+
105
+ to {
106
+ transform: translate3d(
107
+ var(--infinite-scroll-to-x, 0),
108
+ var(--infinite-scroll-to-y, 0),
109
+ 0
110
+ );
111
+ }
112
+ }
113
+
114
+ @media (--motion-reduce) {
115
+ .track {
116
+ animation: none;
117
+ }
118
+ }
@@ -0,0 +1,386 @@
1
+ import {
2
+ Children,
3
+ useCallback,
4
+ useLayoutEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type CSSProperties,
9
+ type ComponentPropsWithoutRef,
10
+ type Ref,
11
+ type ReactNode,
12
+ } from "react";
13
+ import clsx from "clsx";
14
+ import { useResizeObserver } from "../../../hooks/useResizeObserver";
15
+ import {
16
+ InfiniteScrollContext,
17
+ useInfiniteScroll,
18
+ } from "./infinite-scroll-context";
19
+ import s from "./infinite-scroll.module.css";
20
+
21
+ // ─── Types ────────────────────────────────────────────────────────────────────
22
+
23
+ export const infiniteScrollOrientations = ["horizontal", "vertical"] as const;
24
+ export type InfiniteScrollOrientation = (typeof infiniteScrollOrientations)[number];
25
+
26
+ export const infiniteScrollDirections = ["left", "right", "up", "down"] as const;
27
+ export type InfiniteScrollDirection = (typeof infiniteScrollDirections)[number];
28
+
29
+ export type InfiniteScrollProps = {
30
+ children: ReactNode;
31
+ className?: string;
32
+ viewportClassName?: string;
33
+ trackClassName?: string;
34
+ itemClassName?: string;
35
+ style?: CSSProperties;
36
+ ref?: Ref<HTMLDivElement>;
37
+ ariaLabel?: string;
38
+
39
+ /** Axis of continuous motion. Direction implies this when omitted. */
40
+ orientation?: InfiniteScrollOrientation;
41
+ /** Motion direction. Horizontal: left/right. Vertical: up/down. */
42
+ direction?: InfiniteScrollDirection;
43
+ /** Gap between items in px. */
44
+ gap?: number;
45
+ /** Alias for gap for API symmetry with Slider. */
46
+ spaceBetween?: number;
47
+ /** Seconds for one full content-set loop. Ignored when speed is provided. */
48
+ duration?: number;
49
+ /** Pixels per second. Keeps perceived speed stable across content widths. */
50
+ speed?: number;
51
+ /** Number of duplicated content sets. "auto" fills short tracks. */
52
+ copies?: number | "auto";
53
+ /** Pause motion while hovering. */
54
+ pauseOnHover?: boolean;
55
+ /** Pause motion while focus is inside. */
56
+ pauseOnFocus?: boolean;
57
+ /** Soft edge fade. */
58
+ fade?: boolean;
59
+ /** CSS length/percentage for fade size. */
60
+ fadeSize?: string;
61
+ /** Start paused. */
62
+ paused?: boolean;
63
+ /** Render controls or status UI inside the provider without making them scroll items. */
64
+ renderControls?: () => ReactNode;
65
+ } & Omit<ComponentPropsWithoutRef<"div">, "children" | "style">;
66
+
67
+ const directionToOrientation = (
68
+ direction: InfiniteScrollDirection,
69
+ ): InfiniteScrollOrientation =>
70
+ direction === "up" || direction === "down" ? "vertical" : "horizontal";
71
+
72
+ // ─── InfiniteScroll ───────────────────────────────────────────────────────────
73
+
74
+ export const InfiniteScroll = ({
75
+ children,
76
+ className,
77
+ viewportClassName,
78
+ trackClassName,
79
+ itemClassName,
80
+ style,
81
+ ref,
82
+ ariaLabel = "Infinite scrolling content",
83
+ orientation: orientationProp,
84
+ direction = "left",
85
+ gap,
86
+ spaceBetween,
87
+ duration = 30,
88
+ speed,
89
+ copies = "auto",
90
+ pauseOnHover = true,
91
+ pauseOnFocus = true,
92
+ fade = false,
93
+ fadeSize = "10%",
94
+ paused = false,
95
+ renderControls,
96
+ onMouseEnter,
97
+ onMouseLeave,
98
+ onFocus,
99
+ onBlur,
100
+ ...rest
101
+ }: InfiniteScrollProps) => {
102
+ const items = useMemo(
103
+ () =>
104
+ Children.toArray(children).filter(
105
+ (child) => child !== null && child !== undefined,
106
+ ),
107
+ [children],
108
+ );
109
+ const count = items.length;
110
+ const orientation = orientationProp ?? directionToOrientation(direction);
111
+ const isVertical = orientation === "vertical";
112
+ const gapValue = spaceBetween ?? gap ?? 0;
113
+ const resolvedGap = Number.isFinite(gapValue) ? Math.max(0, gapValue) : 0;
114
+
115
+ const viewportRef = useRef<HTMLDivElement>(null);
116
+ const trackRef = useRef<HTMLDivElement>(null);
117
+ const itemRefs = useRef<Array<HTMLDivElement | null>>([]);
118
+ const [setSize, setSetSize] = useState(0);
119
+ const [autoCopies, setAutoCopies] = useState(2);
120
+ const [isHovering, setIsHovering] = useState(false);
121
+ const [isFocusWithin, setIsFocusWithin] = useState(false);
122
+ const [isManuallyPaused, setIsManuallyPaused] = useState(false);
123
+
124
+ const copyCount =
125
+ copies === "auto"
126
+ ? autoCopies
127
+ : Number.isFinite(copies)
128
+ ? Math.max(2, Math.floor(copies))
129
+ : 2;
130
+
131
+ // ─── Measurement ──────────────────────────────────────────────────────────
132
+ // We measure the position of the first cloned item (index `count`) instead
133
+ // of dividing track size by copyCount; that captures the trailing gap
134
+ // exactly and keeps the loop seam invisible at subpixel widths.
135
+
136
+ const measure = useCallback(() => {
137
+ const viewport = viewportRef.current;
138
+ const track = trackRef.current;
139
+ if (count === 0) {
140
+ setSetSize(0);
141
+ return;
142
+ }
143
+ if (!viewport || !track) return;
144
+
145
+ const viewportSize = isVertical ? viewport.clientHeight : viewport.clientWidth;
146
+ const firstClone = itemRefs.current[count];
147
+ const measuredSetSize = firstClone
148
+ ? isVertical
149
+ ? firstClone.offsetTop
150
+ : firstClone.offsetLeft
151
+ : (isVertical ? track.scrollHeight : track.scrollWidth) /
152
+ Math.max(1, copyCount);
153
+
154
+ setSetSize(measuredSetSize);
155
+
156
+ // `copies="auto"` keeps duplicating until the track covers the viewport
157
+ // plus a buffer, so we never see an empty gap at any width.
158
+ if (copies === "auto" && measuredSetSize > 0) {
159
+ setAutoCopies(Math.max(2, Math.ceil(viewportSize / measuredSetSize) + 2));
160
+ }
161
+ }, [copies, copyCount, count, isVertical]);
162
+
163
+ useLayoutEffect(() => {
164
+ measure();
165
+ }, [measure]);
166
+ useResizeObserver(viewportRef, measure);
167
+ useResizeObserver(trackRef, measure);
168
+
169
+ useLayoutEffect(() => {
170
+ itemRefs.current.length = count * copyCount;
171
+ }, [copyCount, count]);
172
+
173
+ // ─── Animation state ──────────────────────────────────────────────────────
174
+
175
+ const shouldPause =
176
+ paused ||
177
+ isManuallyPaused ||
178
+ (pauseOnHover && isHovering) ||
179
+ (pauseOnFocus && isFocusWithin) ||
180
+ count <= 1 ||
181
+ setSize <= 0;
182
+
183
+ const isReverse = direction === "right" || direction === "down";
184
+ const from = isReverse ? -setSize : 0;
185
+ const to = isReverse ? 0 : -setSize;
186
+ const durationValue = Number.isFinite(duration) ? duration : 30;
187
+ const speedValue =
188
+ typeof speed === "number" && Number.isFinite(speed) ? speed : undefined;
189
+ // `speed` (px/s) wins over `duration` because perceived speed should stay
190
+ // constant as content (and thus setSize) changes.
191
+ const resolvedDuration =
192
+ speedValue && speedValue > 0 && setSize > 0
193
+ ? setSize / speedValue
194
+ : Math.max(0.1, durationValue);
195
+ const trackStyle = {
196
+ gap: `${resolvedGap}px`,
197
+ ["--infinite-scroll-duration" as string]: `${resolvedDuration}s`,
198
+ ["--infinite-scroll-play-state" as string]: shouldPause
199
+ ? "paused"
200
+ : "running",
201
+ ["--infinite-scroll-from-x" as string]: isVertical ? "0px" : `${from}px`,
202
+ ["--infinite-scroll-from-y" as string]: isVertical ? `${from}px` : "0px",
203
+ ["--infinite-scroll-to-x" as string]: isVertical ? "0px" : `${to}px`,
204
+ ["--infinite-scroll-to-y" as string]: isVertical ? `${to}px` : "0px",
205
+ } satisfies CSSProperties;
206
+
207
+ const api = useMemo(
208
+ () => ({
209
+ orientation,
210
+ direction,
211
+ itemCount: count,
212
+ copyCount,
213
+ setSize,
214
+ duration: resolvedDuration,
215
+ isMeasured: count === 0 || setSize > 0,
216
+ isPaused: shouldPause,
217
+ isManuallyPaused,
218
+ isHovering,
219
+ isFocusWithin,
220
+ pause: () => setIsManuallyPaused(true),
221
+ resume: () => setIsManuallyPaused(false),
222
+ toggle: () => setIsManuallyPaused((value) => !value),
223
+ measure,
224
+ }),
225
+ [
226
+ copyCount,
227
+ count,
228
+ direction,
229
+ isFocusWithin,
230
+ isHovering,
231
+ isManuallyPaused,
232
+ measure,
233
+ orientation,
234
+ resolvedDuration,
235
+ setSize,
236
+ shouldPause,
237
+ ],
238
+ );
239
+
240
+ return (
241
+ <InfiniteScrollContext.Provider value={api}>
242
+ <div
243
+ ref={ref}
244
+ className={clsx(s.root, className)}
245
+ style={{
246
+ ...style,
247
+ ["--infinite-scroll-fade-size" as string]: fadeSize,
248
+ }}
249
+ role="region"
250
+ aria-label={ariaLabel}
251
+ onMouseEnter={(event) => {
252
+ onMouseEnter?.(event);
253
+ setIsHovering(true);
254
+ }}
255
+ onMouseLeave={(event) => {
256
+ onMouseLeave?.(event);
257
+ setIsHovering(false);
258
+ }}
259
+ onFocus={(event) => {
260
+ onFocus?.(event);
261
+ setIsFocusWithin(true);
262
+ }}
263
+ onBlur={(event) => {
264
+ onBlur?.(event);
265
+ if (!event.currentTarget.contains(event.relatedTarget)) {
266
+ setIsFocusWithin(false);
267
+ }
268
+ }}
269
+ {...rest}
270
+ >
271
+ <div
272
+ ref={viewportRef}
273
+ className={clsx(
274
+ s.viewport,
275
+ isVertical && s.viewport_vertical,
276
+ fade && (isVertical ? s.fade_y : s.fade_x),
277
+ viewportClassName,
278
+ )}
279
+ style={{ visibility: count === 0 || setSize > 0 ? undefined : "hidden" }}
280
+ onDragStart={(event) => event.preventDefault()}
281
+ >
282
+ <div
283
+ ref={trackRef}
284
+ className={clsx(s.track, isVertical && s.track_vertical, trackClassName)}
285
+ style={trackStyle}
286
+ >
287
+ {Array.from({ length: copyCount }).flatMap((_, copyIndex) =>
288
+ items.map((item, itemIndex) => {
289
+ const renderedIndex = copyIndex * count + itemIndex;
290
+ return (
291
+ <div
292
+ key={`${itemIndex}-${copyIndex}`}
293
+ ref={(node) => {
294
+ itemRefs.current[renderedIndex] = node;
295
+ }}
296
+ className={clsx(s.item, itemClassName)}
297
+ aria-hidden={copyIndex > 0 || undefined}
298
+ data-clone={copyIndex > 0 ? "true" : undefined}
299
+ >
300
+ {item}
301
+ </div>
302
+ );
303
+ }),
304
+ )}
305
+ </div>
306
+ </div>
307
+ {renderControls?.()}
308
+ </div>
309
+ </InfiniteScrollContext.Provider>
310
+ );
311
+ };
312
+
313
+ // ─── Control sub-components ───────────────────────────────────────────────────
314
+ // Use these to wire pause/resume/toggle controls anywhere inside the provider.
315
+ // For arbitrary status UI, read `useInfiniteScroll()` directly.
316
+
317
+ type InfiniteScrollControlProps = {
318
+ className?: string;
319
+ children?: ReactNode;
320
+ ariaLabel?: string;
321
+ } & Omit<ComponentPropsWithoutRef<"button">, "children" | "onClick">;
322
+
323
+ export const InfiniteScrollPause = ({
324
+ className,
325
+ children = "Pause",
326
+ ariaLabel = "Pause infinite scroll",
327
+ ...rest
328
+ }: InfiniteScrollControlProps) => {
329
+ const { pause, isPaused } = useInfiniteScroll();
330
+ return (
331
+ <button
332
+ type="button"
333
+ className={className}
334
+ {...rest}
335
+ onClick={pause}
336
+ disabled={isPaused}
337
+ aria-label={ariaLabel}
338
+ >
339
+ {children}
340
+ </button>
341
+ );
342
+ };
343
+
344
+ export const InfiniteScrollResume = ({
345
+ className,
346
+ children = "Resume",
347
+ ariaLabel = "Resume infinite scroll",
348
+ ...rest
349
+ }: InfiniteScrollControlProps) => {
350
+ const { resume, isPaused } = useInfiniteScroll();
351
+ return (
352
+ <button
353
+ type="button"
354
+ className={className}
355
+ {...rest}
356
+ onClick={resume}
357
+ disabled={!isPaused}
358
+ aria-label={ariaLabel}
359
+ >
360
+ {children}
361
+ </button>
362
+ );
363
+ };
364
+
365
+ export const InfiniteScrollToggle = ({
366
+ className,
367
+ children,
368
+ ariaLabel,
369
+ ...rest
370
+ }: InfiniteScrollControlProps) => {
371
+ const { toggle, isPaused } = useInfiniteScroll();
372
+ return (
373
+ <button
374
+ type="button"
375
+ className={className}
376
+ {...rest}
377
+ onClick={toggle}
378
+ aria-pressed={isPaused}
379
+ aria-label={ariaLabel ?? (isPaused ? "Resume infinite scroll" : "Pause infinite scroll")}
380
+ >
381
+ {children ?? (isPaused ? "Resume" : "Pause")}
382
+ </button>
383
+ );
384
+ };
385
+
386
+ export { useInfiniteScroll };