@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.
- package/package.json +12 -4
- package/src/atoms/AnimatedTitle.jsx +108 -0
- package/src/atoms/AssetGrid.jsx +29 -0
- package/src/{primitives → atoms}/AssetPlaceholder.jsx +4 -2
- package/src/atoms/Avatar.jsx +4 -4
- package/src/atoms/Button.jsx +23 -9
- package/src/atoms/CopyButton.jsx +50 -0
- package/src/atoms/CurveOverlay.jsx +180 -0
- package/src/atoms/DocsToc.jsx +48 -0
- package/src/{molecules → atoms}/DropdownTagFilter.jsx +2 -1
- package/src/atoms/EmptyState.jsx +22 -0
- package/src/atoms/Figure.jsx +27 -0
- package/src/atoms/HlsVideo.jsx +56 -0
- package/src/atoms/Input.jsx +7 -2
- package/src/atoms/OverlayGlassPanel.jsx +40 -0
- package/src/atoms/PriceDisplay.jsx +34 -0
- package/src/atoms/ProsePreview.jsx +53 -0
- package/src/atoms/RotaryDial.jsx +150 -0
- package/src/atoms/SearchInput.jsx +108 -0
- package/src/{molecules → atoms}/SegmentedToggle.jsx +33 -14
- package/src/atoms/TextPressure.jsx +331 -0
- package/src/atoms/TiltCard.jsx +127 -0
- package/src/atoms/ToggleCheckbox.jsx +1 -1
- package/src/atoms/ToggleSwitch.jsx +1 -1
- package/src/atoms/TypeSample.jsx +50 -0
- package/src/atoms/TypeSpecCard.jsx +42 -0
- package/src/{molecules → atoms}/ViewToggle.jsx +6 -3
- package/src/graphics/Graphic.jsx +56 -18
- package/src/graphics/graphicData.js +19 -0
- package/src/hooks/cssVar.js +53 -0
- package/src/hooks/useAxisAnimation.js +91 -0
- package/src/hooks/usePrefersReducedMotion.js +21 -0
- package/src/hooks/useTilt.js +49 -0
- package/src/index.js +119 -39
- package/src/{primitives → molecules}/Accordion.jsx +4 -1
- package/src/molecules/AlignmentGrid.jsx +53 -0
- package/src/molecules/ArticleCard.jsx +178 -0
- package/src/molecules/CardFeatureItem.jsx +130 -0
- package/src/molecules/CodeBlock.jsx +11 -0
- package/src/molecules/ColorInputRow.jsx +179 -0
- package/src/molecules/ColorRamp.jsx +114 -0
- package/src/{atoms → molecules}/ColorSwatch.jsx +1 -1
- package/src/molecules/Dropdown.jsx +3 -2
- package/src/molecules/FramedMediaBand.jsx +56 -0
- package/src/{primitives → molecules}/Image.jsx +1 -1
- package/src/molecules/ImageBlock.jsx +34 -0
- package/src/molecules/MediaCard.jsx +97 -0
- package/src/molecules/MediaRow.jsx +49 -0
- package/src/molecules/MenuItem.jsx +3 -2
- package/src/molecules/MenuPopover.jsx +11 -96
- package/src/molecules/SelectionOverlay.jsx +108 -0
- package/src/molecules/ShapeDropdown.jsx +92 -0
- package/src/molecules/ShellDrawer.jsx +169 -0
- package/src/molecules/ShellSearchOverlay.jsx +177 -0
- package/src/{atoms → molecules}/Slider.jsx +1 -1
- package/src/molecules/SpecList.jsx +30 -0
- package/src/molecules/SpectrumControls.jsx +504 -0
- package/src/molecules/SwatchControls.jsx +217 -0
- package/src/molecules/TabsRow.jsx +87 -0
- package/src/molecules/VideoBlock.jsx +86 -0
- package/src/molecules/WorkListItem.jsx +83 -0
- package/src/molecules/foundry/SpecimenSectionHeader.jsx +89 -0
- package/src/organisms/ArticleHeader.jsx +95 -0
- package/src/organisms/AsciiCursor.jsx +526 -0
- package/src/organisms/BentoCard.jsx +187 -0
- package/src/organisms/Canvas.jsx +299 -0
- package/src/organisms/ColorLoader.jsx +155 -0
- package/src/{molecules → organisms}/ContentFilters.jsx +2 -2
- package/src/organisms/CtaGlobal.jsx +67 -0
- package/src/organisms/DiagonalMarqueeRiver.jsx +138 -0
- package/src/organisms/EditorShell.jsx +111 -0
- package/src/organisms/ErrorBoundary.jsx +70 -0
- package/src/organisms/FeatureSplit.jsx +82 -0
- package/src/organisms/FeaturedCarousel.jsx +258 -0
- package/src/organisms/FeaturesCardSection.jsx +90 -0
- package/src/organisms/FullBleedHero.jsx +111 -0
- package/src/organisms/GalleryCarousel.jsx +83 -0
- package/src/organisms/LoaderOverlay.jsx +30 -0
- package/src/organisms/MediaViewer.jsx +95 -0
- package/src/organisms/NewsletterBand.jsx +122 -0
- package/src/organisms/ParallaxShelf.jsx +141 -0
- package/src/organisms/PortableTextRenderer.jsx +115 -0
- package/src/organisms/ProductDetailLayout.jsx +189 -0
- package/src/organisms/ScrollDriftGallery.jsx +214 -0
- package/src/organisms/SpectrumGrid.jsx +90 -0
- package/src/organisms/StackHero.jsx +83 -0
- package/src/organisms/WorkCard.jsx +120 -0
- package/src/organisms/WorkViewToggle.jsx +170 -0
- package/src/organisms/foundry/FontPreviewSection.jsx +187 -0
- package/src/organisms/foundry/FoundryCharacterSets.jsx +113 -0
- package/src/organisms/foundry/GlyphMetricsGrid.jsx +335 -0
- package/src/organisms/foundry/TypefaceHero.jsx +107 -0
- package/src/organisms/foundry/TypefaceStyleSection.jsx +163 -0
- package/src/organisms/foundry/VariableFontSection.jsx +158 -0
- package/src/organisms/foundry/glyphData.js +30 -0
- package/src/organisms/foundry/index.js +21 -0
- package/src/primitives/CodeBlock.jsx +0 -41
- /package/src/{molecules → atoms}/Badge.jsx +0 -0
- /package/src/{primitives → atoms}/ExitPreview.jsx +0 -0
- /package/src/{primitives → atoms}/FullscreenOverlay.jsx +0 -0
- /package/src/{molecules → atoms}/LabeledControl.jsx +0 -0
- /package/src/{molecules → atoms}/Pill.jsx +0 -0
- /package/src/{molecules → atoms}/Popover.jsx +0 -0
- /package/src/{molecules → atoms}/QuantityInput.jsx +0 -0
- /package/src/{molecules → atoms}/QuantityStepper.jsx +0 -0
- /package/src/{molecules → atoms}/Section.jsx +0 -0
- /package/src/{molecules → atoms}/SectionLabel.jsx +0 -0
- /package/src/{molecules → atoms}/Tag.jsx +0 -0
- /package/src/{molecules → atoms}/ToggleBracket.jsx +0 -0
- /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
|
+
}
|
|
@@ -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
|
+
}
|