@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,56 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import Hls from 'hls.js'
3
+
4
+ /**
5
+ * HlsVideo — background/decorative HLS-streaming video atom. A single native
6
+ * <video>: the effect attaches an .m3u8 stream via hls.js (Safari-native HLS
7
+ * when hls.js isn't supported) and destroys the instance on unmount/src
8
+ * change. Deliberately inert — pointer-events none plus the full hardening
9
+ * attribute set (no controls, PiP, context menu, download, fullscreen, or
10
+ * remote playback) keep it non-interactive on every device; do not strip
11
+ * these without explicit approval. All layout/sizing arrives via `className`
12
+ * (consumer decides `absolute inset-0 object-cover`, etc.).
13
+ *
14
+ * @param {string} src HLS manifest URL (.m3u8); attach effect re-runs on change
15
+ * @param {string} poster native poster frame shown before playback
16
+ * @param {string} className all layout/sizing/positioning (consumer-supplied)
17
+ * @param {Function} onEnded end-of-playback callback; its presence disables `loop`
18
+ */
19
+ export default function HlsVideo({ src, poster, className, onEnded, ...props }) {
20
+ const videoRef = useRef(null)
21
+
22
+ useEffect(() => {
23
+ const video = videoRef.current
24
+ if (!video || !src) return
25
+
26
+ if (Hls.isSupported()) {
27
+ const hls = new Hls()
28
+ hls.loadSource(src)
29
+ hls.attachMedia(video)
30
+ return () => hls.destroy()
31
+ } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
32
+ // Safari native HLS support
33
+ video.src = src
34
+ }
35
+ }, [src])
36
+
37
+ return (
38
+ <video
39
+ ref={videoRef}
40
+ poster={poster}
41
+ className={className}
42
+ style={{ pointerEvents: 'none' }}
43
+ autoPlay
44
+ loop={!onEnded}
45
+ muted
46
+ onEnded={onEnded}
47
+ playsInline
48
+ controls={false}
49
+ disablePictureInPicture
50
+ disableRemotePlayback
51
+ controlsList="nodownload nofullscreen noremoteplayback"
52
+ onContextMenu={(e) => e.preventDefault()}
53
+ {...props}
54
+ />
55
+ )
56
+ }
@@ -102,10 +102,15 @@ export default function Input({
102
102
  {prefix !== undefined && (
103
103
  <span aria-hidden="true" className="text-meta pr-1 shrink-0">{prefix}</span>
104
104
  )}
105
+ {/* Controlled only when a `value` prop is passed — otherwise stay
106
+ * uncontrolled so prop-less usages (search stubs, quick demos) type
107
+ * normally instead of freezing on a value-without-onChange input.
108
+ * Controlled + no onChange = deliberate display-only → readOnly. */}
105
109
  <input
106
110
  type={type}
107
- value={value ?? ''}
108
- onChange={onChange}
111
+ {...(value !== undefined
112
+ ? { value: value ?? '', onChange, readOnly: !onChange || undefined }
113
+ : { onChange })}
109
114
  placeholder={placeholder}
110
115
  disabled={disabled}
111
116
  spellCheck={false}
@@ -0,0 +1,40 @@
1
+ /**
2
+ * OverlayGlassPanel — the KOL frosted-glass content card that floats over
3
+ * hero/carousel media: translucent surface-primary (color-mix) + a 1px
4
+ * backdrop blur, children stacked vertically. Extracted from 4 identical
5
+ * inline copies in the monorepo (StudioHero, StudioAboutCard,
6
+ * FeaturedCarousel, TypefacePage). The 80% / 1px defaults ARE the design —
7
+ * exposed as props, not tokenized away.
8
+ *
9
+ * @param {number} surfaceOpacity % of surface-primary in the color-mix
10
+ * @param {string} blur backdrop-filter blur radius
11
+ * @param {string} align 'center' | 'start' | 'end' (cross-axis + text)
12
+ * @param {string} gap vertical rhythm class between children
13
+ * @param {string} maxWidth optional max-w-* class (adds mx-auto)
14
+ */
15
+ export default function OverlayGlassPanel({
16
+ children,
17
+ surfaceOpacity = 80,
18
+ blur = '1px',
19
+ align = 'center',
20
+ gap = 'gap-6',
21
+ maxWidth = '',
22
+ className = '',
23
+ }) {
24
+ const alignCls =
25
+ align === 'start' ? 'items-start text-left'
26
+ : align === 'end' ? 'items-end text-right'
27
+ : 'items-center text-center'
28
+
29
+ return (
30
+ <div
31
+ className={`flex flex-col ${alignCls} ${gap} rounded-[2px] px-6 py-8 ${maxWidth ? `${maxWidth} mx-auto` : ''} ${className}`.trim()}
32
+ style={{
33
+ backgroundColor: `color-mix(in srgb, var(--kol-surface-primary) ${surfaceOpacity}%, transparent)`,
34
+ backdropFilter: `blur(${blur})`,
35
+ }}
36
+ >
37
+ {children}
38
+ </div>
39
+ )
40
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * PriceDisplay — baseline-aligned price line: a large primary amount beside a
3
+ * small muted secondary note (a second-currency echo or a cost breakdown).
4
+ * Formats `amount` via Intl.NumberFormat in whole units (prints are priced in
5
+ * round numbers); `secondary` is authored at the call site, parens and all —
6
+ * omit it to render the primary alone.
7
+ *
8
+ * Presentational — pure scalars in, formatted currency out. Seed atom for the
9
+ * price/sale/edition family.
10
+ *
11
+ * @param {number} amount primary price, run through the currency formatter
12
+ * @param {string} currency ISO 4217 code for `amount` (e.g. 'EUR', 'ISK')
13
+ * @param {string} locale number-format locale
14
+ * @param {ReactNode} secondary muted trailing note (omit to hide)
15
+ */
16
+ export default function PriceDisplay({
17
+ amount,
18
+ currency = 'EUR',
19
+ locale = 'de-DE',
20
+ secondary,
21
+ }) {
22
+ const formatted = new Intl.NumberFormat(locale, {
23
+ style: 'currency',
24
+ currency,
25
+ maximumFractionDigits: 0,
26
+ }).format(amount)
27
+
28
+ return (
29
+ <div className="flex flex-wrap items-baseline gap-3">
30
+ <span className="kol-sans-heading-03">{formatted}</span>
31
+ {secondary != null && <span className="kol-mono-12 text-fg-48">{secondary}</span>}
32
+ </div>
33
+ )
34
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * ProsePreview — a full rich-text specimen: one `.kol-prose` block exercising
3
+ * every long-form element — H1–H4 ladder, section lede, body paragraph,
4
+ * blockquote, indented passage, code block, pullout, and unordered/ordered
5
+ * lists — so the KOL prose stylesheet can be reviewed end-to-end in one view.
6
+ * The "long-form" member of the type-specimen kit.
7
+ *
8
+ * Fixed structure; only the text slots vary. All element styling comes from
9
+ * the prose stylesheet (kol-typography.css) plus the kit's
10
+ * `.kol-prose-indented` / `.kol-prose-pullout` chrome — none lives here.
11
+ *
12
+ * @param {string} h1 trailing text of the H1 (rendered as `H1 — {h1}`)
13
+ * @param {string} paragraph body paragraph text
14
+ * @param {string} code contents of the <pre><code> block
15
+ * @param {string} pullout trailing text of the pullout (rendered as `Pullout — {pullout}`)
16
+ * @param {string} quote blockquote text
17
+ */
18
+ export default function ProsePreview({ h1, paragraph, code, pullout, quote = 'Above as below — justice is a starfield.' }) {
19
+ return (
20
+ <div className="kol-prose">
21
+ <h1>H1 — {h1}</h1>
22
+ <h2>H2 — Section title</h2>
23
+ <h3>H3 — Subsection</h3>
24
+ <h4>H4 — Block title</h4>
25
+ <p className="kol-sans-body-01 text-strong mb-6">
26
+ Section lede — a larger paragraph used to introduce a section and
27
+ establish tone before the body copy begins.
28
+ </p>
29
+ <p>{paragraph}</p>
30
+ <blockquote><p>{quote}</p></blockquote>
31
+ <div className="kol-prose-indented">
32
+ <p>
33
+ Indented passage. Used for tangential copy, contextual notes, or
34
+ nested argument that supports the main line.
35
+ </p>
36
+ </div>
37
+ <pre className="bg-fg-04 border border-fg-08"><code>{code}</code></pre>
38
+ <p className="kol-prose-pullout kol-helper-12 tracking-widest text-body">
39
+ Pullout — {pullout}
40
+ </p>
41
+ <ul>
42
+ <li>Unordered list — item one</li>
43
+ <li>Item two</li>
44
+ <li>Item three</li>
45
+ </ul>
46
+ <ol>
47
+ <li>Ordered list — item one</li>
48
+ <li>Item two</li>
49
+ <li>Item three</li>
50
+ </ol>
51
+ </div>
52
+ )
53
+ }
@@ -0,0 +1,150 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+
3
+ /** Clamp to min–max, snapped to the nearest step from min. */
4
+ const snapTo = (v, min, max, step) => {
5
+ const stepped = min + Math.round((v - min) / step) * step
6
+ return Math.max(min, Math.min(max, stepped))
7
+ }
8
+
9
+ /**
10
+ * RotaryDial — drag-to-set rotary knob numeric input. Vertical pointer drag
11
+ * rotates the whole dial over a 270° sweep (−135° to +135°) mapped onto
12
+ * min–max; a full sweep is ~200px of drag, movement measured absolute from
13
+ * drag start. Controlled (`value`/`onChange`) with a local visual buffer so
14
+ * rotation stays smooth while parent updates are RAF-throttled; the final
15
+ * value fires on release. Focus + arrow keys step the value
16
+ * (role="slider" with aria-valuemin/max/now). The label and `%` readout
17
+ * rows render only when `label` is set; label text renders as authored.
18
+ *
19
+ * @param {string} label text under the dial; also gates the label + readout rows
20
+ * @param {number} value controlled value within min–max
21
+ * @param {Function} onChange (next: number) => void — RAF-throttled while dragging, final on release
22
+ * @param {number} min minimum value (default 0)
23
+ * @param {number} max maximum value (default 100)
24
+ * @param {number} step drag snap / arrow-key increment (default 1)
25
+ * @param {number} size dial px size (default 80); derives ring + disc radii
26
+ */
27
+ export default function RotaryDial({
28
+ label,
29
+ value = 0,
30
+ onChange,
31
+ min = 0,
32
+ max = 100,
33
+ step = 1,
34
+ size = 80,
35
+ }) {
36
+ const [isDragging, setIsDragging] = useState(false)
37
+ const [localValue, setLocalValue] = useState(value) // visual buffer — updates every move
38
+ const dragRef = useRef({ startY: 0, startValue: 0 })
39
+ const rafRef = useRef(null)
40
+ const onChangeRef = useRef(onChange)
41
+ onChangeRef.current = onChange
42
+
43
+ // External updates land whenever a drag isn't in progress
44
+ useEffect(() => {
45
+ if (!isDragging) setLocalValue(value)
46
+ }, [value, isDragging])
47
+
48
+ useEffect(() => {
49
+ if (!isDragging) return
50
+ let current = dragRef.current.startValue
51
+ const handleMove = (e) => {
52
+ // absolute delta from drag start; full min→max sweep ≈ 200px
53
+ const deltaY = dragRef.current.startY - e.clientY
54
+ current = snapTo(dragRef.current.startValue + deltaY * ((max - min) / 200), min, max, step)
55
+ setLocalValue(current)
56
+ if (!rafRef.current) {
57
+ rafRef.current = requestAnimationFrame(() => {
58
+ onChangeRef.current?.(current)
59
+ rafRef.current = null
60
+ })
61
+ }
62
+ }
63
+ const handleUp = () => {
64
+ setIsDragging(false)
65
+ if (rafRef.current) {
66
+ cancelAnimationFrame(rafRef.current)
67
+ rafRef.current = null
68
+ }
69
+ onChangeRef.current?.(current)
70
+ }
71
+ window.addEventListener('pointermove', handleMove)
72
+ window.addEventListener('pointerup', handleUp)
73
+ window.addEventListener('pointercancel', handleUp)
74
+ return () => {
75
+ window.removeEventListener('pointermove', handleMove)
76
+ window.removeEventListener('pointerup', handleUp)
77
+ window.removeEventListener('pointercancel', handleUp)
78
+ }
79
+ }, [isDragging, min, max, step])
80
+
81
+ const handlePointerDown = (e) => {
82
+ setIsDragging(true)
83
+ dragRef.current = { startY: e.clientY, startValue: localValue }
84
+ }
85
+
86
+ const handleKeyDown = (e) => {
87
+ let next = null
88
+ if (e.key === 'ArrowUp' || e.key === 'ArrowRight') next = snapTo(localValue + step, min, max, step)
89
+ else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') next = snapTo(localValue - step, min, max, step)
90
+ if (next === null) return
91
+ e.preventDefault()
92
+ setLocalValue(next)
93
+ onChange?.(next)
94
+ }
95
+
96
+ const outerRadius = size / 2
97
+ const innerRadius = (size * 0.7) / 2
98
+ const strokeWidth = 2
99
+ const angle = -135 + ((localValue - min) / (max - min || 1)) * 270
100
+
101
+ return (
102
+ <div className="flex flex-col items-center gap-2">
103
+ <div
104
+ role="slider"
105
+ tabIndex={0}
106
+ aria-label={label}
107
+ aria-valuemin={min}
108
+ aria-valuemax={max}
109
+ aria-valuenow={localValue}
110
+ className="relative cursor-pointer select-none touch-none"
111
+ style={{
112
+ width: size,
113
+ height: size,
114
+ transform: `rotate(${angle}deg)`,
115
+ willChange: isDragging ? 'transform' : 'auto',
116
+ }}
117
+ onPointerDown={handlePointerDown}
118
+ onKeyDown={handleKeyDown}
119
+ >
120
+ <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ overflow: 'visible' }}>
121
+ {/* outer dashed tick ring */}
122
+ <circle
123
+ cx={outerRadius}
124
+ cy={outerRadius}
125
+ r={outerRadius - strokeWidth}
126
+ fill="none"
127
+ stroke="currentColor"
128
+ strokeWidth={strokeWidth}
129
+ strokeDasharray="4 4"
130
+ className="text-fg-24"
131
+ />
132
+ {/* inner solid disc */}
133
+ <circle cx={outerRadius} cy={outerRadius} r={innerRadius} fill="currentColor" className="text-fg-96" />
134
+ {/* pointer notch — reads as a cut into the disc */}
135
+ <line
136
+ x1={outerRadius}
137
+ y1={outerRadius}
138
+ x2={outerRadius}
139
+ y2={outerRadius - innerRadius + 4}
140
+ stroke="var(--kol-surface-primary)"
141
+ strokeWidth={2}
142
+ strokeLinecap="round"
143
+ />
144
+ </svg>
145
+ </div>
146
+ {label && <div className="kol-helper-12 text-fg-64">{label}</div>}
147
+ {label && <div className="kol-helper-12 text-fg-96">{value}%</div>}
148
+ </div>
149
+ )
150
+ }
@@ -0,0 +1,108 @@
1
+ import { Icon } from '@kolkrabbi/kol-loader'
2
+
3
+ /**
4
+ * SearchInput — controlled search field on the .kol-control shell. The
5
+ * search-flavored sibling of Input (same variant/size chrome + inner-input
6
+ * treatment), standalone because atoms can't nest atoms. Leading search
7
+ * icon, then two mutually exclusive trailing affordances: a caller-authored
8
+ * shortcut kbd chip while the value is empty, a clear × while it isn't.
9
+ *
10
+ * Always controlled (`value` defaults to ''). Controlled + no onChange =
11
+ * deliberate display-only → readOnly, same convention as Input. Extra props
12
+ * (onKeyDown, autoFocus, role, aria-*) spread onto the inner <input> —
13
+ * ShellSearchOverlay drives its combobox wiring through that seam.
14
+ *
15
+ * `type="search"` for semantics (searchbox role, mobile "search" enter key);
16
+ * the native WebKit cancel button is hidden so the × stays the only clear
17
+ * affordance.
18
+ *
19
+ * @param {string} value controlled value
20
+ * @param {Function} onChange (event) => void — input change
21
+ * @param {string} placeholder placeholder text
22
+ * @param {Function} onClear () => void — trailing × click; × renders only when set and value is non-empty
23
+ * @param {string} shortcutHint caller-authored kbd chip (e.g. "⌘K"); shown while the value is empty
24
+ * @param {Function} onFocus (event) => void — input focus
25
+ * @param {string} size 'sm' | 'md' — kol-control size + matched mono type class
26
+ * @param {string} variant 'filled' | 'ghost' | 'outline' — kol-control variant, same chrome as Input (ignored when bare)
27
+ * @param {boolean} bare borderless inline field for overlay panels (full width, no shell chrome; keeps icon/clear/chip slots)
28
+ * @param {string} className extra classes on the shell
29
+ */
30
+
31
+ const SIZE_TYPE = { sm: 'kol-mono-12', md: 'kol-mono-14' }
32
+ const ICON_SIZE = { sm: 14, md: 14 }
33
+
34
+ export default function SearchInput({
35
+ value = '',
36
+ onChange,
37
+ placeholder = 'Search…',
38
+ onClear,
39
+ shortcutHint,
40
+ onFocus,
41
+ size = 'md',
42
+ variant = 'filled',
43
+ bare = false,
44
+ className = '',
45
+ ...inputProps
46
+ }) {
47
+ const shellCls = [
48
+ bare
49
+ ? 'flex w-full gap-2.5 px-4 py-3'
50
+ : `kol-control kol-control--${variant} kol-control-${size} gap-2`,
51
+ 'items-center cursor-text',
52
+ SIZE_TYPE[size],
53
+ className,
54
+ ].filter(Boolean).join(' ')
55
+
56
+ /* Same height pin as Input: Chromium sizes an <input> from font metrics,
57
+ * not CSS line-height, so without this the shell lands ~0.5px tall.
58
+ * h-4 / h-[18px] match the kol-mono-12 / -14 line-heights. */
59
+ const heightCls = size === 'sm' ? 'h-4' : 'h-[18px]'
60
+
61
+ const inputCls = [
62
+ 'min-w-0 flex-1 bg-transparent border-none outline-none text-auto',
63
+ 'appearance-none [&::-webkit-search-cancel-button]:hidden',
64
+ heightCls,
65
+ ].join(' ')
66
+
67
+ return (
68
+ <label className={shellCls}>
69
+ <span
70
+ aria-hidden="true"
71
+ className={`flex items-center shrink-0 ${bare ? 'text-fg-48' : 'text-auto opacity-50'}`}
72
+ >
73
+ <Icon name="search" size={ICON_SIZE[size] ?? 14} />
74
+ </span>
75
+ <input
76
+ type="search"
77
+ value={value ?? ''}
78
+ onChange={onChange}
79
+ readOnly={!onChange || undefined}
80
+ onFocus={onFocus}
81
+ placeholder={placeholder}
82
+ spellCheck={false}
83
+ className={inputCls}
84
+ {...inputProps}
85
+ />
86
+ {onClear && value ? (
87
+ <button
88
+ type="button"
89
+ aria-label="Clear"
90
+ /* preventDefault on mousedown keeps focus in the input across the clear */
91
+ onMouseDown={(e) => e.preventDefault()}
92
+ onClick={onClear}
93
+ className="inline-flex items-center justify-center shrink-0 cursor-pointer text-fg-48 hover:text-fg-96 transition-colors"
94
+ >
95
+ <Icon name="x" size={12} />
96
+ </button>
97
+ ) : shortcutHint ? (
98
+ /* aria-hidden — affordance, not a label (same stance as Input's prefix/suffix) */
99
+ <kbd
100
+ aria-hidden="true"
101
+ className="inline-flex items-center justify-center shrink-0 h-4 min-w-4 px-1 rounded-[3px] bg-fg-08 kol-helper-10 text-fg-48"
102
+ >
103
+ {shortcutHint}
104
+ </kbd>
105
+ ) : null}
106
+ </label>
107
+ )
108
+ }
@@ -1,7 +1,9 @@
1
1
  /**
2
- * SegmentedToggle — N-way segmented control. Joined buttons sharing one
3
- * outer stroke; thin dividers between cells; no gap. Active cell uses
4
- * bg-fg-04 + text-emphasis, inactive is text-meta with hover lifting.
2
+ * SegmentedToggle — N-way segmented control. Joined cells sharing one
3
+ * outer stroke; thin dividers between cells; no gap. Active cell fills
4
+ * with surface-secondary + text-emphasis, inactive is text-meta with
5
+ * hover lifting. Chrome comes from .kol-seg* in kol-theme (NOT Tailwind
6
+ * utilities — those never generate from package sources).
5
7
  *
6
8
  * <SegmentedToggle
7
9
  * value={current}
@@ -17,6 +19,9 @@
17
19
  * Labels accept any node — pass strings or inline SVG previews. Optional
18
20
  * `ariaLabel` per option for non-text labels.
19
21
  *
22
+ * A11y: radiogroup/radio semantics with a roving tabindex — Tab enters
23
+ * the group on the active cell, ←/→ (or ↑/↓) move selection + focus.
24
+ *
20
25
  * Props:
21
26
  * value — current option value
22
27
  * onChange — handler (newValue) => void
@@ -25,27 +30,41 @@
25
30
  * only options (e.g. line-style previews) where text labels
26
31
  * aren't used; the cells still center their contents but the
27
32
  * outer height is tighter.
33
+ * ariaLabel — accessible name for the group
28
34
  * className — additional classes on the outer shell
29
35
  */
30
- export default function SegmentedToggle({ value, onChange, options = [], size = 'md', className = '' }) {
31
- const wrapHeight = size === 'sm' ? 'h-4' : 'h-[26px]' /* 16 vs 26 */
32
- const cellType = size === 'sm' ? '' : 'kol-mono-12'
36
+ export default function SegmentedToggle({ value, onChange, options = [], size = 'md', ariaLabel, className = '' }) {
37
+ const cellType = size === 'sm' ? '' : 'kol-mono-12'
38
+ const focusIdx = Math.max(0, options.findIndex((opt) => opt.value === value))
39
+
40
+ const handleKeyDown = (e) => {
41
+ const dir = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[e.key]
42
+ if (!dir || !options.length) return
43
+ e.preventDefault()
44
+ const next = (focusIdx + dir + options.length) % options.length
45
+ onChange?.(options[next].value)
46
+ e.currentTarget.children[next]?.focus()
47
+ }
48
+
33
49
  return (
34
- <div className={`flex ${wrapHeight} border border-fg-04 rounded overflow-hidden ${className}`}>
50
+ <div
51
+ role="radiogroup"
52
+ aria-label={ariaLabel}
53
+ onKeyDown={handleKeyDown}
54
+ className={['kol-seg', size === 'sm' && 'kol-seg--sm', className].filter(Boolean).join(' ')}
55
+ >
35
56
  {options.map((opt, i) => {
36
57
  const isActive = opt.value === value
37
58
  return (
38
59
  <button
39
60
  key={opt.value}
40
61
  type="button"
41
- onClick={() => onChange?.(opt.value)}
42
- aria-pressed={isActive}
62
+ role="radio"
63
+ aria-checked={isActive}
43
64
  aria-label={opt.ariaLabel}
44
- className={[
45
- `flex-1 ${cellType} inline-flex items-center justify-center cursor-pointer`,
46
- isActive ? 'bg-surface-secondary text-emphasis' : 'text-meta hover:text-emphasis',
47
- i > 0 ? 'border-l border-fg-04' : '',
48
- ].filter(Boolean).join(' ')}
65
+ tabIndex={i === focusIdx ? 0 : -1}
66
+ onClick={() => onChange?.(opt.value)}
67
+ className={['kol-seg-cell', cellType, isActive && 'is-active'].filter(Boolean).join(' ')}
49
68
  >
50
69
  {opt.label}
51
70
  </button>