@kolkrabbi/kol-component 0.6.0 → 0.8.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.
Files changed (38) hide show
  1. package/README.md +8 -0
  2. package/package.json +2 -3
  3. package/src/atoms/RotaryDial.jsx +35 -21
  4. package/src/index.js +6 -18
  5. package/src/molecules/ButtonGroup.jsx +45 -0
  6. package/src/molecules/ColorInputRow.jsx +146 -126
  7. package/src/molecules/Slider.jsx +29 -14
  8. package/src/molecules/SplitToolButton.jsx +133 -0
  9. package/src/organisms/ContentFilters.jsx +2 -2
  10. package/src/organisms/GalleryCarousel.jsx +2 -1
  11. package/src/organisms/LoaderOverlay.jsx +11 -14
  12. package/src/organisms/MediaTileGallery.jsx +57 -0
  13. package/src/organisms/MediaViewer.jsx +89 -64
  14. package/src/atoms/PriceDisplay.jsx +0 -34
  15. package/src/atoms/TextPressure.jsx +0 -331
  16. package/src/atoms/TypeSample.jsx +0 -50
  17. package/src/atoms/TypeSpecCard.jsx +0 -42
  18. package/src/molecules/ArticleCard.jsx +0 -178
  19. package/src/molecules/WorkListItem.jsx +0 -83
  20. package/src/molecules/foundry/SpecimenSectionHeader.jsx +0 -89
  21. package/src/organisms/ArticleHeader.jsx +0 -95
  22. package/src/organisms/ColorLoader.jsx +0 -155
  23. package/src/organisms/DiagonalMarqueeRiver.jsx +0 -138
  24. package/src/organisms/ParallaxShelf.jsx +0 -141
  25. package/src/organisms/PortableTextRenderer.jsx +0 -115
  26. package/src/organisms/ProductDetailLayout.jsx +0 -189
  27. package/src/organisms/ScrollDriftGallery.jsx +0 -214
  28. package/src/organisms/StackHero.jsx +0 -83
  29. package/src/organisms/WorkCard.jsx +0 -120
  30. package/src/organisms/WorkViewToggle.jsx +0 -170
  31. package/src/organisms/foundry/FontPreviewSection.jsx +0 -187
  32. package/src/organisms/foundry/FoundryCharacterSets.jsx +0 -113
  33. package/src/organisms/foundry/GlyphMetricsGrid.jsx +0 -335
  34. package/src/organisms/foundry/TypefaceHero.jsx +0 -107
  35. package/src/organisms/foundry/TypefaceStyleSection.jsx +0 -163
  36. package/src/organisms/foundry/VariableFontSection.jsx +0 -158
  37. package/src/organisms/foundry/glyphData.js +0 -30
  38. package/src/organisms/foundry/index.js +0 -21
@@ -1,89 +0,0 @@
1
- import { Icon } from '@kolkrabbi/kol-icons'
2
- import Dropdown from '../Dropdown.jsx'
3
- import Divider from '../../atoms/Divider.jsx'
4
-
5
- /* taxonomy-ok: nests DS Dropdown + Divider (relative imports) plus kol-icons's
6
- * Icon (a package import the relative-import check can't see). */
7
-
8
- /**
9
- * SpecimenSectionHeader — the shared header every type-specimen section mounts
10
- * under. A title (with an optional inline icon) on the left, up to two
11
- * controlled dropdowns (weight + style/axis) on the right, and a horizontal
12
- * divider beneath. It is the connective tissue that makes the foundry sections
13
- * read as one system, so it is built before the sections that consume it.
14
- *
15
- * Fully controlled and data-free: it holds no font data, only label/badge text,
16
- * an icon name, and the two dropdowns' value/onChange pairs supplied by the
17
- * parent section. Promoted from the monorepo's route-local `FoundrySection`;
18
- * the `Foundry*` name was a route artifact, this name describes the role.
19
- *
20
- * Title source forks: `label` wins, else `badgeText`. `size` swaps the title
21
- * type stop (`sm` → kol-mono-12, else kol-mono-16). Each dropdown is
22
- * independently gated; the weight dropdown also requires a non-empty
23
- * `weightOptions`.
24
- *
25
- * Text casing: title and option labels render verbatim as authored — no
26
- * text-transform. Author "Roman" / "Italic" / "Character Set" in intended case.
27
- *
28
- * @param {Object} props
29
- * @param {string} props.label - Title text (takes precedence over badgeText).
30
- * @param {string} props.badgeText - Fallback title when label is unset.
31
- * @param {string} props.icon - KOL Icon name shown after the title; omit to hide.
32
- * @param {'sm'|'lg'} props.size - Title type stop (default 'lg').
33
- * @param {string} props.selectedStyle - Controlled value of the style/axis dropdown.
34
- * @param {Function} props.onStyleChange - Style dropdown change handler.
35
- * @param {boolean} props.showDropdown - Render the style/axis dropdown (default true).
36
- * @param {Array<{label,value}>} props.styleOptions - Style dropdown options (default Roman/Italic).
37
- * @param {string} props.selectedWeight - Controlled value of the weight dropdown.
38
- * @param {Function} props.onWeightChange - Weight dropdown change handler.
39
- * @param {boolean} props.showWeightDropdown - Gate the weight dropdown (default true; also needs options).
40
- * @param {Array<{label,value}>} props.weightOptions - Weight dropdown options (default []).
41
- */
42
- const SpecimenSectionHeader = ({
43
- label,
44
- badgeText,
45
- icon,
46
- size = 'lg',
47
- selectedStyle,
48
- onStyleChange,
49
- showDropdown = true,
50
- styleOptions = [
51
- { label: 'Roman', value: 'roman' },
52
- { label: 'Italic', value: 'italic' },
53
- ],
54
- selectedWeight,
55
- onWeightChange,
56
- showWeightDropdown = true,
57
- weightOptions = [],
58
- }) => {
59
- const title = label || badgeText
60
- // kol-mono-text-lg / kol-mono-sm-regular don't exist in the DS theme; the
61
- // nearest mono stops are kol-mono-16 (lg) and kol-mono-12 (sm).
62
- const titleClass = size === 'sm' ? 'kol-mono-12' : 'kol-mono-16'
63
-
64
- return (
65
- <div className="flex flex-col gap-[13px]">
66
- <div className="w-full flex flex-row justify-between items-end gap-4">
67
- {/* Left: title + optional icon */}
68
- <div className="flex items-center gap-3 md:gap-4">
69
- <span className={titleClass}>{title}</span>
70
- {icon && <Icon name={icon} size={20} />}
71
- </div>
72
-
73
- {/* Right: controlled dropdowns */}
74
- <div className="flex items-center gap-4">
75
- {showWeightDropdown && weightOptions.length > 0 && (
76
- <Dropdown options={weightOptions} value={selectedWeight} onChange={onWeightChange} />
77
- )}
78
- {showDropdown && (
79
- <Dropdown options={styleOptions} value={selectedStyle} onChange={onStyleChange} />
80
- )}
81
- </div>
82
- </div>
83
-
84
- <Divider variant="horizontal" />
85
- </div>
86
- )
87
- }
88
-
89
- export default SpecimenSectionHeader
@@ -1,95 +0,0 @@
1
- import Pill from '../atoms/Pill.jsx'
2
- import Avatar from '../atoms/Avatar.jsx'
3
- import Image from '../molecules/Image.jsx'
4
-
5
- /**
6
- * ArticleHeader — long-form article masthead: a row of tag Pills, a display
7
- * title, an author cluster (DS Avatar + name + role), a `date • readingTime`
8
- * meta line, an optional excerpt, and an optional hero image. Pure
9
- * presentational composition of a flat prop bag.
10
- *
11
- * De-Sanitized: the four embedded Sanity image-URL builders and the
12
- * `resolveImageUrl` unpacker are gone — `heroImage` is a plain resolved src
13
- * string handed straight to DS Image (which owns its own missing-asset
14
- * fallback). The author avatar is the DS Avatar atom (initials), not a
15
- * hand-rolled `<img>` — so `authorInitial` replaces the Sanity `authorImage`.
16
- * The app `reveal` entrance utility is dropped.
17
- *
18
- * Name and meta render uppercase via a presentational class only — strings are
19
- * authored as-is at the call site, no JS casing.
20
- *
21
- * @param {string[]} tags Pill labels; row hidden when empty
22
- * @param {string} title display title
23
- * @param {string} authorName author name (rendered uppercase via class)
24
- * @param {string} authorTitle author role/title line
25
- * @param {string} authorInitial avatar glyph; defaults to authorName's first char
26
- * @param {string} date publication date text
27
- * @param {string} readingTime reading-time text (prefixed `• ` in the meta line)
28
- * @param {string} excerpt lede paragraph; block hidden when falsy
29
- * @param {string} heroImage resolved hero image src (omit → no hero block)
30
- * @param {string} className extra classes on the <header>
31
- */
32
- export default function ArticleHeader({
33
- tags = [],
34
- title,
35
- authorName,
36
- authorTitle,
37
- authorInitial,
38
- date,
39
- readingTime,
40
- excerpt,
41
- heroImage,
42
- className = '',
43
- }) {
44
- const initial = authorInitial ?? (authorName ? authorName.trim().charAt(0) : '')
45
-
46
- return (
47
- <header className={`pb-12 ${className}`.trim()}>
48
- <div className="max-w-[1400px] mx-auto flex flex-col">
49
- {tags.length > 0 && (
50
- <div className="flex flex-wrap items-center gap-2 pb-6">
51
- {tags.map((tag, i) => (
52
- <Pill key={i} variant="inverse" size="md">{tag}</Pill>
53
- ))}
54
- </div>
55
- )}
56
-
57
- <div className="flex flex-col gap-6">
58
- <h1 className="kol-prose-title text-balance">{title}</h1>
59
-
60
- <div className="flex flex-wrap items-center gap-6 text-fg-64 pb-3">
61
- <div className="flex items-center gap-4">
62
- <Avatar initial={initial} size="lg" />
63
- <div className="flex flex-col">
64
- <span className="kol-helper-12 uppercase text-fg-48">{authorName}</span>
65
- <span className="kol-mono-12 text-fg-64">{authorTitle}</span>
66
- </div>
67
- </div>
68
- <div className="flex items-center gap-3 kol-helper-12 uppercase text-fg-48">
69
- <span>{date}</span>
70
- {readingTime && <span>• {readingTime}</span>}
71
- </div>
72
- </div>
73
-
74
- {excerpt && (
75
- <div className="pb-4 w-full lg:w-[80%]">
76
- <p className="kol-mono-14 text-fg-64">{excerpt}</p>
77
- </div>
78
- )}
79
-
80
- {heroImage && (
81
- <div className="rounded overflow-hidden border border-fg-08">
82
- <Image
83
- src={heroImage}
84
- alt=""
85
- loading="eager"
86
- className="object-cover"
87
- style={{ width: '100%', aspectRatio: '4/2', objectFit: 'cover' }}
88
- />
89
- </div>
90
- )}
91
- </div>
92
- </div>
93
- </header>
94
- )
95
- }
@@ -1,155 +0,0 @@
1
- import { motion as Motion, useAnimationControls } from 'framer-motion'
2
- import { useEffect, useRef, useState } from 'react'
3
- import TextPressure from '../atoms/TextPressure.jsx'
4
- import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
5
-
6
- /**
7
- * ColorLoader — a full-height branded intro/loading curtain. Fills its parent
8
- * on a solid backdrop, times in a variable-font wordmark (a live TextPressure
9
- * effect) after `wordmarkDelay`, then reveals a bouncing down-chevron cue after
10
- * `scrollDelay`. With `dismissOnClick`, clicking the curtain slides the whole
11
- * thing up out of view and fires `onComplete`; otherwise the parent owns
12
- * dismissal (unmount when its work is done). Generalized from the app's
13
- * KOLKRABBI loader: the cursor trail, `cursor: none`, and window-scroll/keyboard
14
- * gesture hijack are all dropped — exit is a plain click or parent-driven.
15
- *
16
- * Reduced motion → no timers, no tweens: one static frame (wordmark + cue shown
17
- * at rest) and `onComplete` fires immediately on mount, so the curtain never
18
- * makes a reduced-motion user wait.
19
- *
20
- * The wordmark needs a variable font — see TextPressure's font contract. Pass
21
- * `fontFamily` / `fontUrl` to point at the KOL variable font (a consumer asset).
22
- *
23
- * @param {string} text wordmark string fed to TextPressure
24
- * @param {string} fontFamily wordmark font family (→ TextPressure)
25
- * @param {string} [fontUrl] variable-font URL → TextPressure @font-face
26
- * @param {() => void} onComplete fired after the exit slide-up (or at once, reduced)
27
- * @param {boolean} dismissOnClick click the curtain to slide up + complete
28
- * @param {string} bgColor backdrop color (KOL token / CSS color)
29
- * @param {string} markColor wordmark + chevron color (KOL token / CSS color)
30
- * @param {number} wordmarkDelay ms before the wordmark fades in
31
- * @param {number} scrollDelay ms before the chevron cue reveals
32
- * @param {number} exitDuration seconds for the slide-up exit tween
33
- */
34
- export default function ColorLoader({
35
- text = 'KOLKRABBI',
36
- fontFamily = 'var(--kol-font-family-sans)',
37
- fontUrl,
38
- onComplete,
39
- dismissOnClick = false,
40
- bgColor = 'var(--kol-surface-primary)',
41
- markColor = 'var(--kol-fg-emphasis)',
42
- wordmarkDelay = 1000,
43
- scrollDelay = 2000,
44
- exitDuration = 1.8,
45
- }) {
46
- const reducedMotion = usePrefersReducedMotion()
47
- const [showMark, setShowMark] = useState(reducedMotion)
48
- const [showCue, setShowCue] = useState(reducedMotion)
49
- const [exiting, setExiting] = useState(false)
50
- const controls = useAnimationControls()
51
- const firedRef = useRef(false)
52
-
53
- const fireComplete = () => {
54
- if (firedRef.current) return
55
- firedRef.current = true
56
- onComplete?.()
57
- }
58
-
59
- useEffect(() => {
60
- if (reducedMotion) {
61
- // Skip the whole sequence: static frame, signal done at once.
62
- setShowMark(true)
63
- setShowCue(true)
64
- fireComplete()
65
- return undefined
66
- }
67
- const markTimer = setTimeout(() => setShowMark(true), wordmarkDelay)
68
- const cueTimer = setTimeout(() => setShowCue(true), scrollDelay)
69
- return () => {
70
- clearTimeout(markTimer)
71
- clearTimeout(cueTimer)
72
- }
73
- // eslint-disable-next-line react-hooks/exhaustive-deps
74
- }, [reducedMotion, wordmarkDelay, scrollDelay])
75
-
76
- const dismiss = () => {
77
- if (exiting) return
78
- setExiting(true)
79
- if (reducedMotion) {
80
- fireComplete()
81
- return
82
- }
83
- controls
84
- .start({ y: '-100%', transition: { duration: exitDuration, ease: 'easeInOut' } })
85
- .then(fireComplete)
86
- }
87
-
88
- return (
89
- <Motion.div
90
- className={`flex h-full w-full flex-col ${dismissOnClick ? 'cursor-pointer' : ''}`}
91
- style={{ backgroundColor: bgColor }}
92
- animate={controls}
93
- initial={{ y: 0 }}
94
- onClick={dismissOnClick ? dismiss : undefined}
95
- >
96
- {/* Top third — spacer */}
97
- <div className="flex flex-1 items-end justify-center self-stretch" />
98
-
99
- {/* Middle third — wordmark + cue */}
100
- <div className="flex flex-1 flex-col items-center justify-center gap-12 self-stretch">
101
- <Motion.div
102
- initial={{ opacity: 0 }}
103
- animate={{ opacity: showMark ? 1 : 0 }}
104
- transition={{ duration: reducedMotion ? 0 : 0.5 }}
105
- className="w-full px-6"
106
- >
107
- <div className="mx-auto h-20 w-full max-w-[280px] sm:h-24 sm:max-w-[400px] md:h-32 md:max-w-[600px]">
108
- <TextPressure
109
- text={text}
110
- fontFamily={fontFamily}
111
- fontUrl={fontUrl}
112
- textColor={markColor}
113
- flex
114
- width
115
- weight
116
- italic={false}
117
- minFontSize={40}
118
- />
119
- </div>
120
- </Motion.div>
121
-
122
- <Motion.div
123
- initial={{ opacity: 0, y: 0 }}
124
- animate={{
125
- opacity: showCue ? 0.6 : 0,
126
- y: showCue && !reducedMotion ? [0, 12, 0] : 0,
127
- }}
128
- transition={{
129
- opacity: { duration: reducedMotion ? 0 : 0.5 },
130
- y: reducedMotion ? { duration: 0 } : { duration: 1.5, repeat: Infinity, ease: 'easeInOut' },
131
- }}
132
- className="mt-12"
133
- style={{ color: markColor }}
134
- >
135
- <svg
136
- width="32"
137
- height="32"
138
- viewBox="0 0 24 24"
139
- fill="none"
140
- stroke="currentColor"
141
- strokeWidth="2"
142
- strokeLinecap="round"
143
- strokeLinejoin="round"
144
- aria-hidden="true"
145
- >
146
- <polyline points="6 9 12 15 18 9" />
147
- </svg>
148
- </Motion.div>
149
- </div>
150
-
151
- {/* Bottom third — spacer */}
152
- <div className="flex flex-1 items-end justify-center self-stretch" />
153
- </Motion.div>
154
- )
155
- }
@@ -1,138 +0,0 @@
1
- import { useEffect, useMemo, useRef } from 'react'
2
- import { gsap } from 'gsap'
3
- import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
4
-
5
- /**
6
- * DiagonalMarqueeRiver — an auto-scrolling multi-column marquee "river":
7
- * `items` are dealt round-robin into `colCount` vertical columns, each column
8
- * runs an infinite constant-velocity GSAP y-tween at its own speed, and the
9
- * whole block is rotated + scaled so the columns stream diagonally across a
10
- * tall stage. An IntersectionObserver pauses every tween while the stage is
11
- * off-screen. Data-agnostic — the card is a `renderItem` slot; the engine only
12
- * measures scrollHeight and tweens y.
13
- *
14
- * Each column's list is rendered twice so the tween can travel exactly one set
15
- * then repeat with an invisible wrap. On `prefers-reduced-motion` no GSAP runs
16
- * and the columns render static. The tween set is rebuilt on container resize
17
- * (a ResizeObserver remeasures scrollHeight — the source keyed only on the item
18
- * list and drifted when the viewport changed).
19
- *
20
- * @param {any[]} items pool dealt round-robin into columns
21
- * @param {(item:any,i:number)=>ReactNode} renderItem card slot per item
22
- * @param {number} colCount number of columns (default 4)
23
- * @param {number[]} columnSpeeds px/s per column (default [28,38,22,34], fallback 30)
24
- * @param {number} gap vertical gap between cards and gap-between-columns, px (default 24)
25
- * @param {string} height stage height (default '300vh')
26
- * @param {number} rotate tilt of the block, deg (default -15)
27
- * @param {number} scale oversize of the block (default 1.3)
28
- * @param {boolean} shuffle one-time shuffle of `items` before dealing (default false)
29
- */
30
- export default function DiagonalMarqueeRiver({
31
- items = [],
32
- renderItem,
33
- colCount = 4,
34
- columnSpeeds = [28, 38, 22, 34],
35
- gap = 24,
36
- height = '300vh',
37
- rotate = -15,
38
- scale = 1.3,
39
- shuffle = false,
40
- }) {
41
- const containerRef = useRef(null)
42
- const columnRefs = useRef([])
43
- const tweensRef = useRef([])
44
- const reducedMotion = usePrefersReducedMotion()
45
-
46
- // Deal items round-robin into columns (optionally shuffled once).
47
- const columns = useMemo(() => {
48
- const pool = shuffle ? [...items].sort(() => Math.random() - 0.5) : items
49
- const cols = Array.from({ length: colCount }, () => [])
50
- pool.forEach((item, i) => cols[i % colCount].push(item))
51
- return cols
52
- }, [items, colCount, shuffle])
53
-
54
- useEffect(() => {
55
- if (reducedMotion) return undefined
56
-
57
- const build = () => {
58
- const colEls = columnRefs.current.filter(Boolean)
59
- tweensRef.current.forEach((tw) => tw.kill())
60
- tweensRef.current = []
61
- if (!colEls.length) return
62
-
63
- colEls.forEach((colEl, i) => {
64
- colEl.style.willChange = 'transform'
65
- const singleSetHeight = colEl.scrollHeight / 2 // one copy of the doubled list
66
- const speed = columnSpeeds[i] || 30
67
- const initialOffset = -(singleSetHeight * ((i * 0.15) % 1)) // staggered per-column start
68
- gsap.set(colEl, { y: initialOffset })
69
- const tween = gsap.to(colEl, {
70
- y: initialOffset - singleSetHeight, // travel exactly one set → seamless
71
- duration: singleSetHeight / speed, // constant px/s regardless of column length
72
- ease: 'none',
73
- repeat: -1,
74
- })
75
- tweensRef.current.push(tween)
76
- })
77
- }
78
-
79
- build()
80
-
81
- // Pause tweens while the stage is off-screen.
82
- const io = new IntersectionObserver(
83
- ([entry]) => {
84
- tweensRef.current.forEach((tw) => (entry.isIntersecting ? tw.resume() : tw.pause()))
85
- },
86
- { threshold: 0 },
87
- )
88
- if (containerRef.current) io.observe(containerRef.current)
89
-
90
- // [source-bug] resize-remeasure: source keyed only on the item list, so a
91
- // viewport change left the tweens measuring a stale scrollHeight. Rebuild
92
- // on container resize (debounced).
93
- let raf = 0
94
- const ro = new ResizeObserver(() => {
95
- cancelAnimationFrame(raf)
96
- raf = requestAnimationFrame(build)
97
- })
98
- if (containerRef.current) ro.observe(containerRef.current)
99
-
100
- return () => {
101
- io.disconnect()
102
- ro.disconnect()
103
- cancelAnimationFrame(raf)
104
- tweensRef.current.forEach((tw) => tw.kill())
105
- tweensRef.current = []
106
- }
107
- }, [columns, columnSpeeds, reducedMotion])
108
-
109
- return (
110
- <div ref={containerRef} className="w-full overflow-hidden" style={{ height }}>
111
- <div
112
- className="flex gap-6"
113
- style={{
114
- transform: `rotate(${rotate}deg) scale(${scale})`,
115
- transformOrigin: 'center center',
116
- height: '140%',
117
- marginTop: '-20%',
118
- }}
119
- >
120
- {columns.map((col, colIndex) => (
121
- <div key={colIndex} className="flex-1 overflow-hidden">
122
- <div
123
- ref={(el) => {
124
- columnRefs.current[colIndex] = el
125
- }}
126
- className="flex flex-col"
127
- style={{ gap: `${gap}px` }}
128
- >
129
- {[...col, ...col].map((item, i) => (
130
- <div key={i}>{renderItem?.(item, i)}</div>
131
- ))}
132
- </div>
133
- </div>
134
- ))}
135
- </div>
136
- </div>
137
- )
138
- }
@@ -1,141 +0,0 @@
1
- import { useCallback, useEffect, useRef, useState } from 'react'
2
- import useEmblaCarousel from 'embla-carousel-react'
3
- import WorkCard from './WorkCard.jsx'
4
- import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
5
-
6
- /* Scroll-driven parallax strength — fraction of scroll delta applied to the
7
- * shelf. Eases in quadratically with page-scroll progress (min → max). */
8
- const SCROLL_PARALLAX_MIN = 0.1
9
- const SCROLL_PARALLAX_MAX = 0.4
10
- const DRAG_THRESHOLD_SQ = 25
11
- /* Aligned edge gets the big gutter so the first/last card lands under a
12
- * 1400px content column. */
13
- const EDGE_GUTTER = 'max(4rem, calc((100vw - 1400px) / 2 + 16rem))'
14
-
15
- /**
16
- * ParallaxShelf — one horizontal shelf row: a drag-free Embla carousel of
17
- * project cards with scroll-driven parallax. As the page scrolls the track is
18
- * nudged horizontally by `delta × parallax × direction`, the strength easing in
19
- * the further down the page you are; alternating rows (`fromLeft`) scroll in
20
- * opposite directions for a woven wall of shelves. Includes a drag-vs-click
21
- * guard (a drag never fires a card link) and an edge-aligned category label.
22
- *
23
- * Calls `useEmblaCarousel` directly (FeaturedCarousel's precedent) because the
24
- * parallax nudge needs `internalEngine()`, which DS Carousel encapsulates and
25
- * must not be modified. The source's `WheelGesturesPlugin` is dropped (not a
26
- * DS dependency). Parallax is disabled under prefers-reduced-motion and on
27
- * small screens — both fall back to a plain wheel/drag carousel.
28
- *
29
- * Card-agnostic via `renderCard`; defaults to DS **WorkCard** with an
30
- * `index`-staggered ragged height. Pass a plain `items` array of flat project
31
- * objects (no Sanity keying).
32
- *
33
- * Text casing: the label renders verbatim (author `type.label` as e.g.
34
- * "Client Work") — no text-transform.
35
- *
36
- * @param {{label:string}} type category; `label` renders in the edge caption
37
- * @param {Array} items flat project objects passed to the card renderer
38
- * @param {boolean} fromLeft direction of alignment / start index / parallax sign / label side (default false)
39
- * @param {Function} renderCard (item, index) => ReactNode — card renderer (default WorkCard)
40
- * @param {Function} onNavigate (href, event) => void — forwarded to the default WorkCard
41
- * @param {string} className extra classes on the section
42
- */
43
- export default function ParallaxShelf({
44
- type,
45
- items = [],
46
- fromLeft = false,
47
- renderCard,
48
- onNavigate,
49
- className = '',
50
- }) {
51
- const reduced = usePrefersReducedMotion()
52
- const [isMobile] = useState(
53
- () => typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches,
54
- )
55
-
56
- const [emblaRef, emblaApi] = useEmblaCarousel({
57
- dragFree: true,
58
- align: fromLeft ? 'end' : 'start',
59
- containScroll: 'trimSnaps',
60
- ...(fromLeft && { startIndex: items.length - 1 }),
61
- })
62
-
63
- const sectionRef = useRef(null)
64
- const lastScrollY = useRef(0)
65
-
66
- // Scroll-driven parallax: page scroll nudges the carousel while in view.
67
- useEffect(() => {
68
- if (!emblaApi || reduced || isMobile) return
69
- lastScrollY.current = window.scrollY
70
-
71
- const onScroll = () => {
72
- const section = sectionRef.current
73
- if (!section) return
74
- const rect = section.getBoundingClientRect()
75
- const inView = rect.bottom > 0 && rect.top < window.innerHeight
76
- if (!inView) {
77
- lastScrollY.current = window.scrollY
78
- return
79
- }
80
- const delta = window.scrollY - lastScrollY.current
81
- lastScrollY.current = window.scrollY
82
- const scrollProgress = Math.min(window.scrollY / (document.body.scrollHeight - window.innerHeight), 1)
83
- const parallax = SCROLL_PARALLAX_MIN + (SCROLL_PARALLAX_MAX - SCROLL_PARALLAX_MIN) * (scrollProgress * scrollProgress)
84
- const engine = emblaApi.internalEngine()
85
- const offset = delta * parallax * (fromLeft ? 1 : -1)
86
- engine.scrollBody.useDuration(0)
87
- engine.scrollTo.distance(offset, false)
88
- }
89
-
90
- window.addEventListener('scroll', onScroll, { passive: true })
91
- return () => window.removeEventListener('scroll', onScroll)
92
- }, [emblaApi, fromLeft, reduced, isMobile])
93
-
94
- // Drag-vs-click guard (5px): a drag suppresses the ensuing card-link click.
95
- const dragged = useRef(false)
96
- const pointerStart = useRef({ x: 0, y: 0 })
97
- const onPointerDown = useCallback((e) => {
98
- dragged.current = false
99
- pointerStart.current = { x: e.clientX, y: e.clientY }
100
- }, [])
101
- const onPointerMove = useCallback((e) => {
102
- if (dragged.current) return
103
- const dx = e.clientX - pointerStart.current.x
104
- const dy = e.clientY - pointerStart.current.y
105
- if (dx * dx + dy * dy > DRAG_THRESHOLD_SQ) dragged.current = true
106
- }, [])
107
- const onClickCapture = useCallback((e) => {
108
- if (dragged.current) e.preventDefault()
109
- }, [])
110
-
111
- const card = (item, i) =>
112
- renderCard ? renderCard(item, i) : <WorkCard key={i} {...item} index={i} onNavigate={onNavigate} />
113
-
114
- return (
115
- <section ref={sectionRef} className={`py-6 md:py-16 ${className}`.trim()}>
116
- <div
117
- className="overflow-visible select-none"
118
- ref={emblaRef}
119
- style={{
120
- paddingLeft: fromLeft ? undefined : EDGE_GUTTER,
121
- paddingRight: fromLeft ? EDGE_GUTTER : undefined,
122
- }}
123
- >
124
- <div
125
- className="flex gap-8 items-end"
126
- onPointerDown={onPointerDown}
127
- onPointerMove={onPointerMove}
128
- onClickCapture={onClickCapture}
129
- >
130
- {items.map((item, i) => card(item, i))}
131
- </div>
132
- </div>
133
-
134
- {type?.label && (
135
- <div className={`max-w-[1400px] mx-auto mt-4 md:mt-6 ${fromLeft ? 'pr-4 md:pr-64 text-right' : 'pl-4 md:pl-64'}`}>
136
- <p className="kol-helper-12 text-auto">{type.label}</p>
137
- </div>
138
- )}
139
- </section>
140
- )
141
- }