@kolkrabbi/kol-component 0.1.2 → 0.3.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 (110) hide show
  1. package/package.json +12 -4
  2. package/src/atoms/AnimatedTitle.jsx +108 -0
  3. package/src/atoms/AssetGrid.jsx +29 -0
  4. package/src/{primitives → atoms}/AssetPlaceholder.jsx +4 -2
  5. package/src/atoms/Avatar.jsx +4 -4
  6. package/src/atoms/Button.jsx +23 -9
  7. package/src/atoms/CopyButton.jsx +50 -0
  8. package/src/atoms/CurveOverlay.jsx +180 -0
  9. package/src/atoms/DocsToc.jsx +48 -0
  10. package/src/{molecules → atoms}/DropdownTagFilter.jsx +2 -1
  11. package/src/atoms/EmptyState.jsx +22 -0
  12. package/src/atoms/Figure.jsx +27 -0
  13. package/src/atoms/HlsVideo.jsx +56 -0
  14. package/src/atoms/Input.jsx +7 -2
  15. package/src/atoms/OverlayGlassPanel.jsx +40 -0
  16. package/src/atoms/PriceDisplay.jsx +34 -0
  17. package/src/atoms/ProsePreview.jsx +53 -0
  18. package/src/atoms/RotaryDial.jsx +150 -0
  19. package/src/atoms/SearchInput.jsx +108 -0
  20. package/src/{molecules → atoms}/SegmentedToggle.jsx +33 -14
  21. package/src/atoms/TextPressure.jsx +331 -0
  22. package/src/atoms/TiltCard.jsx +127 -0
  23. package/src/atoms/ToggleCheckbox.jsx +1 -1
  24. package/src/atoms/ToggleSwitch.jsx +1 -1
  25. package/src/atoms/TypeSample.jsx +50 -0
  26. package/src/atoms/TypeSpecCard.jsx +42 -0
  27. package/src/{molecules → atoms}/ViewToggle.jsx +6 -3
  28. package/src/graphics/Graphic.jsx +56 -18
  29. package/src/graphics/graphicData.js +19 -0
  30. package/src/hooks/cssVar.js +53 -0
  31. package/src/hooks/useAxisAnimation.js +91 -0
  32. package/src/hooks/usePrefersReducedMotion.js +21 -0
  33. package/src/hooks/useTilt.js +49 -0
  34. package/src/index.js +119 -39
  35. package/src/{primitives → molecules}/Accordion.jsx +4 -1
  36. package/src/molecules/AlignmentGrid.jsx +53 -0
  37. package/src/molecules/ArticleCard.jsx +178 -0
  38. package/src/molecules/CardFeatureItem.jsx +130 -0
  39. package/src/molecules/CodeBlock.jsx +11 -0
  40. package/src/molecules/ColorInputRow.jsx +179 -0
  41. package/src/molecules/ColorRamp.jsx +114 -0
  42. package/src/{atoms → molecules}/ColorSwatch.jsx +1 -1
  43. package/src/molecules/Dropdown.jsx +3 -2
  44. package/src/molecules/FramedMediaBand.jsx +56 -0
  45. package/src/{primitives → molecules}/Image.jsx +1 -1
  46. package/src/molecules/ImageBlock.jsx +34 -0
  47. package/src/molecules/MediaCard.jsx +97 -0
  48. package/src/molecules/MediaRow.jsx +49 -0
  49. package/src/molecules/MenuItem.jsx +3 -2
  50. package/src/molecules/MenuPopover.jsx +11 -96
  51. package/src/molecules/SelectionOverlay.jsx +108 -0
  52. package/src/molecules/ShapeDropdown.jsx +92 -0
  53. package/src/molecules/ShellDrawer.jsx +169 -0
  54. package/src/molecules/ShellSearchOverlay.jsx +177 -0
  55. package/src/{atoms → molecules}/Slider.jsx +1 -1
  56. package/src/molecules/SpecList.jsx +30 -0
  57. package/src/molecules/SpectrumControls.jsx +504 -0
  58. package/src/molecules/SwatchControls.jsx +217 -0
  59. package/src/molecules/TabsRow.jsx +87 -0
  60. package/src/molecules/VideoBlock.jsx +86 -0
  61. package/src/molecules/WorkListItem.jsx +83 -0
  62. package/src/molecules/foundry/SpecimenSectionHeader.jsx +89 -0
  63. package/src/organisms/ArticleHeader.jsx +95 -0
  64. package/src/organisms/AsciiCursor.jsx +526 -0
  65. package/src/organisms/BentoCard.jsx +187 -0
  66. package/src/organisms/Canvas.jsx +299 -0
  67. package/src/organisms/ColorLoader.jsx +155 -0
  68. package/src/{molecules → organisms}/ContentFilters.jsx +2 -2
  69. package/src/organisms/CtaGlobal.jsx +67 -0
  70. package/src/organisms/DiagonalMarqueeRiver.jsx +138 -0
  71. package/src/organisms/EditorShell.jsx +111 -0
  72. package/src/organisms/ErrorBoundary.jsx +70 -0
  73. package/src/organisms/FeatureSplit.jsx +82 -0
  74. package/src/organisms/FeaturedCarousel.jsx +258 -0
  75. package/src/organisms/FeaturesCardSection.jsx +90 -0
  76. package/src/organisms/FullBleedHero.jsx +111 -0
  77. package/src/organisms/GalleryCarousel.jsx +83 -0
  78. package/src/organisms/LoaderOverlay.jsx +30 -0
  79. package/src/organisms/MediaViewer.jsx +95 -0
  80. package/src/organisms/NewsletterBand.jsx +122 -0
  81. package/src/organisms/ParallaxShelf.jsx +141 -0
  82. package/src/organisms/PortableTextRenderer.jsx +115 -0
  83. package/src/organisms/ProductDetailLayout.jsx +189 -0
  84. package/src/organisms/ScrollDriftGallery.jsx +214 -0
  85. package/src/organisms/SpectrumGrid.jsx +90 -0
  86. package/src/organisms/StackHero.jsx +83 -0
  87. package/src/organisms/WorkCard.jsx +120 -0
  88. package/src/organisms/WorkViewToggle.jsx +170 -0
  89. package/src/organisms/foundry/FontPreviewSection.jsx +187 -0
  90. package/src/organisms/foundry/FoundryCharacterSets.jsx +113 -0
  91. package/src/organisms/foundry/GlyphMetricsGrid.jsx +335 -0
  92. package/src/organisms/foundry/TypefaceHero.jsx +107 -0
  93. package/src/organisms/foundry/TypefaceStyleSection.jsx +163 -0
  94. package/src/organisms/foundry/VariableFontSection.jsx +158 -0
  95. package/src/organisms/foundry/glyphData.js +30 -0
  96. package/src/organisms/foundry/index.js +21 -0
  97. package/src/primitives/CodeBlock.jsx +0 -41
  98. /package/src/{molecules → atoms}/Badge.jsx +0 -0
  99. /package/src/{primitives → atoms}/ExitPreview.jsx +0 -0
  100. /package/src/{primitives → atoms}/FullscreenOverlay.jsx +0 -0
  101. /package/src/{molecules → atoms}/LabeledControl.jsx +0 -0
  102. /package/src/{molecules → atoms}/Pill.jsx +0 -0
  103. /package/src/{molecules → atoms}/Popover.jsx +0 -0
  104. /package/src/{molecules → atoms}/QuantityInput.jsx +0 -0
  105. /package/src/{molecules → atoms}/QuantityStepper.jsx +0 -0
  106. /package/src/{molecules → atoms}/Section.jsx +0 -0
  107. /package/src/{molecules → atoms}/SectionLabel.jsx +0 -0
  108. /package/src/{molecules → atoms}/Tag.jsx +0 -0
  109. /package/src/{molecules → atoms}/ToggleBracket.jsx +0 -0
  110. /package/src/{primitives → organisms}/Carousel.jsx +0 -0
@@ -0,0 +1,169 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { Icon } from '@kolkrabbi/kol-loader'
4
+ import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
5
+
6
+ /* taxonomy-ok: nests kol-loader's Icon (a package import the relative-import
7
+ * check can't see). */
8
+
9
+ const FOCUSABLE =
10
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
11
+
12
+ /**
13
+ * ShellDrawer — THE edge drawer: a portalled panel that slides in from the
14
+ * left or right viewport edge over a dimming backdrop. Distinct from Modal
15
+ * (centered prompt/confirm) and FullscreenOverlay (fills the whole viewport,
16
+ * not an edge sheet). Escape, backdrop click and the built-in close button
17
+ * all call `onClose`; body scroll locks while open; focus moves into the
18
+ * panel on open, is trapped there, and returns to the opener on close.
19
+ *
20
+ * The slide transition is gated on prefers-reduced-motion: reduced users get
21
+ * an instant mount/unmount with no transform animation.
22
+ *
23
+ * Presentational shell — the parent owns the open state and supplies the
24
+ * header slot and body; no navigation or routing logic lives here.
25
+ *
26
+ * @param {boolean} open drawer visible (drives slide in/out)
27
+ * @param {Function} onClose close request (Esc / backdrop / close button)
28
+ * @param {string} side 'left' | 'right' — edge the panel slides from
29
+ * @param {number|string} width panel width (px number or CSS length); omit for full-width sheet
30
+ * @param {ReactNode} header header-row content beside the close button (replaces the source's baked-in wordmark)
31
+ * @param {ReactNode} children scrollable panel body
32
+ * @param {string} className extra classes on the panel
33
+ */
34
+ export default function ShellDrawer({
35
+ open,
36
+ onClose,
37
+ side = 'left',
38
+ width,
39
+ header,
40
+ children,
41
+ className = '',
42
+ }) {
43
+ const reduced = usePrefersReducedMotion()
44
+ const panelRef = useRef(null)
45
+
46
+ /* Mount/slide state machine: `present` keeps the portal mounted through
47
+ * the exit slide; `shown` drives the transform/opacity classes. Reduced
48
+ * motion collapses both onto `open` — instant show/hide, no transform. */
49
+ const [present, setPresent] = useState(open)
50
+ const [shown, setShown] = useState(open)
51
+
52
+ useEffect(() => {
53
+ if (reduced) {
54
+ setPresent(open)
55
+ setShown(open)
56
+ return undefined
57
+ }
58
+ if (open) {
59
+ setPresent(true)
60
+ // double rAF: let the off-screen position paint before sliding in
61
+ let raf2
62
+ const raf1 = requestAnimationFrame(() => {
63
+ raf2 = requestAnimationFrame(() => setShown(true))
64
+ })
65
+ return () => {
66
+ cancelAnimationFrame(raf1)
67
+ if (raf2) cancelAnimationFrame(raf2)
68
+ }
69
+ }
70
+ setShown(false)
71
+ const t = setTimeout(() => setPresent(false), 200) // matches duration-200
72
+ return () => clearTimeout(t)
73
+ }, [open, reduced])
74
+
75
+ // Escape closes; Tab is trapped inside the panel while open
76
+ useEffect(() => {
77
+ if (!open) return undefined
78
+ const onKey = (e) => {
79
+ if (e.key === 'Escape') {
80
+ onClose?.()
81
+ return
82
+ }
83
+ if (e.key !== 'Tab') return
84
+ const panel = panelRef.current
85
+ if (!panel) return
86
+ const nodes = panel.querySelectorAll(FOCUSABLE)
87
+ if (!nodes.length) {
88
+ e.preventDefault()
89
+ panel.focus()
90
+ return
91
+ }
92
+ const first = nodes[0]
93
+ const last = nodes[nodes.length - 1]
94
+ const active = document.activeElement
95
+ if (!panel.contains(active)) {
96
+ e.preventDefault()
97
+ first.focus()
98
+ } else if (e.shiftKey && (active === first || active === panel)) {
99
+ e.preventDefault()
100
+ last.focus()
101
+ } else if (!e.shiftKey && active === last) {
102
+ e.preventDefault()
103
+ first.focus()
104
+ }
105
+ }
106
+ document.addEventListener('keydown', onKey)
107
+ return () => document.removeEventListener('keydown', onKey)
108
+ }, [open, onClose])
109
+
110
+ /* Body scroll-lock + focus in/out. Gated on `present` too so the panel
111
+ * exists before we focus it (it mounts one commit after `open` flips). */
112
+ useEffect(() => {
113
+ if (!(open && present)) return undefined
114
+ const prevOverflow = document.body.style.overflow
115
+ document.body.style.overflow = 'hidden'
116
+ const prevFocus = document.activeElement
117
+ panelRef.current?.focus()
118
+ return () => {
119
+ document.body.style.overflow = prevOverflow
120
+ if (prevFocus instanceof HTMLElement) prevFocus.focus()
121
+ }
122
+ }, [open, present])
123
+
124
+ if (!present || typeof document === 'undefined') return null
125
+
126
+ const slideOut = side === 'right' ? 'translate-x-full' : '-translate-x-full'
127
+ const motionPanel = reduced
128
+ ? ''
129
+ : `transition-transform duration-200 ease-out ${shown ? 'translate-x-0' : slideOut}`
130
+ const motionBackdrop = reduced
131
+ ? ''
132
+ : `transition-opacity duration-200 ease-out ${shown ? 'opacity-100' : 'opacity-0'}`
133
+
134
+ return createPortal(
135
+ <>
136
+ <div
137
+ className={`fixed inset-0 z-[100] bg-black/50 ${motionBackdrop}`}
138
+ onClick={onClose}
139
+ aria-hidden="true"
140
+ />
141
+ <div
142
+ ref={panelRef}
143
+ role="dialog"
144
+ aria-modal="true"
145
+ tabIndex={-1}
146
+ className={`fixed inset-y-0 z-[200] flex max-w-full flex-col bg-surface-primary px-4 py-4 shadow-2xl outline-none md:px-5 lg:px-6 ${
147
+ side === 'right' ? 'right-0 border-l' : 'left-0 border-r'
148
+ } border-fg-08 ${width == null ? 'w-full' : ''} ${motionPanel} ${className}`}
149
+ style={width != null ? { width: typeof width === 'number' ? `${width}px` : width } : undefined}
150
+ >
151
+ <div className="mb-6 flex items-center gap-4">
152
+ {header != null && <div className="min-w-0 flex-1">{header}</div>}
153
+ <button
154
+ type="button"
155
+ onClick={onClose}
156
+ aria-label="Close"
157
+ className="ml-auto flex h-8 w-8 shrink-0 items-center justify-center rounded-md border-0 bg-transparent cursor-pointer text-fg-64 transition-colors hover:bg-fg-08 hover:text-emphasis"
158
+ >
159
+ <Icon name="close" size={14} />
160
+ </button>
161
+ </div>
162
+ <div className="flex-1 overflow-y-auto pr-1" style={{ overflowAnchor: 'none' }}>
163
+ {children}
164
+ </div>
165
+ </div>
166
+ </>,
167
+ document.body,
168
+ )
169
+ }
@@ -0,0 +1,177 @@
1
+ import { useEffect, useId, useRef, useState } from 'react'
2
+ import SearchInput from '../atoms/SearchInput.jsx'
3
+
4
+ /**
5
+ * HighlightMatch — default row renderer: underlines the first
6
+ * case-insensitive `query` slice inside `label` at full ink. Exported for
7
+ * consumers building their own rows; not in the package barrel.
8
+ *
9
+ * @param {string} label full row label
10
+ * @param {string} query current query (empty / no match → plain label)
11
+ */
12
+ export function HighlightMatch({ label, query }) {
13
+ const idx = query ? label.toLowerCase().indexOf(query.toLowerCase()) : -1
14
+ if (idx === -1) return <span>{label}</span>
15
+ return (
16
+ <>
17
+ <span>{label.slice(0, idx)}</span>
18
+ <span className="text-fg underline decoration-2 underline-offset-[3px]">
19
+ {label.slice(idx, idx + query.length)}
20
+ </span>
21
+ <span>{label.slice(idx + query.length)}</span>
22
+ </>
23
+ )
24
+ }
25
+
26
+ /**
27
+ * ShellSearchOverlay — the ⌘K command palette: fullscreen dim + centered
28
+ * panel, a bare SearchInput on top, result rows beneath (HighlightMatch
29
+ * label, dim hint line, right-aligned group label). Distinct from Modal
30
+ * (prompt/confirm only) — this is the search/command primitive.
31
+ *
32
+ * Content-agnostic: the consumer filters and passes `results`; selection
33
+ * emits `onSelect(item)` (no navigation here — ported off react-router).
34
+ * The ⌘K binding itself lives in the shell's key handler, not here.
35
+ *
36
+ * Keyboard: ArrowUp/ArrowDown rove the active row (mouse hover roves too),
37
+ * Enter selects it (index starts at 0 → Enter-selects-first preserved),
38
+ * Escape closes. Focus trap: focus moves into the input on open, returns to
39
+ * the opener on close, and Tab is pinned — rows are combobox options driven
40
+ * via aria-activedescendant, never tab stops.
41
+ *
42
+ * @param {boolean} open mount/unmount the overlay
43
+ * @param {Function} onClose () => void — backdrop click, Escape, post-select
44
+ * @param {Array} results pre-filtered rows: { id, label, group?, hint? }
45
+ * @param {string} query controlled query (drives the highlight slice)
46
+ * @param {Function} onQueryChange (string) => void — input change
47
+ * @param {Function} onSelect (item) => void — row click / Enter; consumer navigates
48
+ * @param {string} placeholder input placeholder
49
+ */
50
+ export default function ShellSearchOverlay({
51
+ open,
52
+ onClose,
53
+ results = [],
54
+ query = '',
55
+ onQueryChange,
56
+ onSelect,
57
+ placeholder = 'Search…',
58
+ }) {
59
+ const panelRef = useRef(null)
60
+ const listRef = useRef(null)
61
+ const listId = useId()
62
+ const [activeIndex, setActiveIndex] = useState(0)
63
+ const active = results.length > 0 ? Math.min(activeIndex, results.length - 1) : -1
64
+
65
+ /* Focus in on open, restore the opener on close. querySelector instead of
66
+ * a ref through SearchInput — ref-as-prop needs React 19 and the package
67
+ * peer range still allows 18. */
68
+ useEffect(() => {
69
+ if (!open) return undefined
70
+ const prev = document.activeElement
71
+ panelRef.current?.querySelector('input')?.focus()
72
+ return () => { if (prev instanceof HTMLElement) prev.focus() }
73
+ }, [open])
74
+
75
+ /* Roving row resets to the top on every query change / reopen. */
76
+ useEffect(() => { setActiveIndex(0) }, [query, open])
77
+
78
+ /* Keep the active row visible inside the scrolling list. */
79
+ useEffect(() => {
80
+ if (active < 0) return
81
+ listRef.current?.children[active]?.scrollIntoView({ block: 'nearest' })
82
+ }, [active])
83
+
84
+ if (!open) return null
85
+
86
+ const select = (item) => {
87
+ onSelect?.(item)
88
+ onClose?.()
89
+ }
90
+
91
+ const optionId = (item) => `${listId}-${item.id}`
92
+
93
+ const handleKeyDown = (e) => {
94
+ if (e.key === 'Escape') {
95
+ e.preventDefault()
96
+ onClose?.()
97
+ } else if (e.key === 'ArrowDown') {
98
+ e.preventDefault()
99
+ setActiveIndex((i) => Math.min(i + 1, results.length - 1))
100
+ } else if (e.key === 'ArrowUp') {
101
+ e.preventDefault()
102
+ setActiveIndex((i) => Math.max(i - 1, 0))
103
+ } else if (e.key === 'Enter' && active >= 0) {
104
+ e.preventDefault()
105
+ select(results[active])
106
+ } else if (e.key === 'Tab') {
107
+ /* Focus trap — the input is the palette's only tab stop. */
108
+ e.preventDefault()
109
+ }
110
+ }
111
+
112
+ return (
113
+ <div className="fixed inset-0 z-[300] flex items-start justify-center pt-[20vh]">
114
+ <div
115
+ className="absolute inset-0 bg-black/60 backdrop-blur-[1px]"
116
+ onClick={onClose}
117
+ aria-hidden="true"
118
+ />
119
+ <div
120
+ ref={panelRef}
121
+ role="dialog"
122
+ aria-modal="true"
123
+ aria-label="Search"
124
+ className="relative w-full max-w-lg mx-4 overflow-hidden bg-surface-primary border border-fg-08 rounded-[var(--kol-radius-2xl)] shadow-[0_20px_60px_rgba(0,0,0,0.4)]"
125
+ >
126
+ <SearchInput
127
+ bare
128
+ value={query}
129
+ onChange={(e) => onQueryChange?.(e.target.value)}
130
+ placeholder={placeholder}
131
+ onKeyDown={handleKeyDown}
132
+ role="combobox"
133
+ aria-expanded={results.length > 0}
134
+ aria-controls={listId}
135
+ aria-activedescendant={active >= 0 ? optionId(results[active]) : undefined}
136
+ />
137
+
138
+ {results.length > 0 && (
139
+ <ul
140
+ ref={listRef}
141
+ id={listId}
142
+ role="listbox"
143
+ className="border-t border-fg-08 max-h-80 overflow-y-auto py-1"
144
+ >
145
+ {results.map((item, i) => (
146
+ <li
147
+ key={item.id}
148
+ id={optionId(item)}
149
+ role="option"
150
+ aria-selected={i === active}
151
+ /* preventDefault keeps focus in the input through the click */
152
+ onMouseDown={(e) => e.preventDefault()}
153
+ onClick={() => select(item)}
154
+ onMouseEnter={() => setActiveIndex(i)}
155
+ className={`flex items-center gap-2 px-4 py-1.5 cursor-pointer kol-mono-14 transition-colors ${
156
+ i === active ? 'bg-fg-08 text-fg' : 'text-fg-64'
157
+ }`}
158
+ >
159
+ <span className="flex flex-col min-w-0">
160
+ <span className="truncate">
161
+ <HighlightMatch label={item.label} query={query} />
162
+ </span>
163
+ {item.hint && (
164
+ <span className="kol-mono-12 text-fg-48 truncate">{item.hint}</span>
165
+ )}
166
+ </span>
167
+ {item.group && (
168
+ <span className="ml-auto shrink-0 kol-helper-10 text-fg-48">{item.group}</span>
169
+ )}
170
+ </li>
171
+ ))}
172
+ </ul>
173
+ )}
174
+ </div>
175
+ </div>
176
+ )
177
+ }
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useMemo, useState } from 'react'
2
- import Input from './Input.jsx'
2
+ import Input from '../atoms/Input.jsx'
3
3
 
4
4
  /**
5
5
  * Slider — range slider with label and an editable value readout.
@@ -0,0 +1,30 @@
1
+ import Divider from '../atoms/Divider.jsx'
2
+
3
+ /**
4
+ * SpecList — compact definition list of [label | value] rows: label left and
5
+ * muted, value right-aligned one tone brighter, Divider-separated between
6
+ * rows (never after the last). Data-agnostic "key facts" strip — the caller
7
+ * formats values before passing (e.g. "Limited (30)", "A3, A2, A1").
8
+ *
9
+ * @param {Array<{label: string, value: ReactNode}>} items rows, keyed by label
10
+ * @param {boolean} framed wrap the list in leading + trailing Divider
11
+ */
12
+ export default function SpecList({ items = [], framed = false }) {
13
+ return (
14
+ <>
15
+ {framed && <Divider />}
16
+ <dl className="py-3">
17
+ {items.map((item, index) => (
18
+ <div key={item.label}>
19
+ <div className="flex items-center justify-between gap-6 py-3">
20
+ <dt className="kol-helper-12 text-fg-48 whitespace-nowrap">{item.label}</dt>
21
+ <dd className="kol-mono-12 text-right text-fg-64">{item.value}</dd>
22
+ </div>
23
+ {index < items.length - 1 && <Divider />}
24
+ </div>
25
+ ))}
26
+ </dl>
27
+ {framed && <Divider />}
28
+ </>
29
+ )
30
+ }