@lovett/ui 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +28 -0
  2. package/package.json +53 -0
  3. package/src/accordion.tsx +153 -0
  4. package/src/atoms.tsx +197 -0
  5. package/src/badge.tsx +91 -0
  6. package/src/brand-logo-tile.tsx +200 -0
  7. package/src/bulk-action-bar.tsx +104 -0
  8. package/src/button-group.tsx +46 -0
  9. package/src/button.tsx +93 -0
  10. package/src/calculator-shell-v2.tsx +380 -0
  11. package/src/calculator-shell.tsx +310 -0
  12. package/src/card.tsx +369 -0
  13. package/src/checkbox.tsx +114 -0
  14. package/src/chip-nav.tsx +347 -0
  15. package/src/choropleth.tsx +62 -0
  16. package/src/code-block.tsx +235 -0
  17. package/src/completion-ring.tsx +119 -0
  18. package/src/copy-field.tsx +169 -0
  19. package/src/data-grid/column-options-menu.tsx +127 -0
  20. package/src/data-grid/data-grid.tsx +391 -0
  21. package/src/data-grid/drawer-panel.tsx +146 -0
  22. package/src/data-grid/index.ts +29 -0
  23. package/src/data-grid/sortable-parts.tsx +215 -0
  24. package/src/data-grid/table-body.tsx +238 -0
  25. package/src/data-grid/table-elements.tsx +135 -0
  26. package/src/data-grid/table-header.tsx +95 -0
  27. package/src/data-grid/table-overlays.tsx +78 -0
  28. package/src/data-grid/table-parts.tsx +15 -0
  29. package/src/data-grid/table-summary-footer.tsx +101 -0
  30. package/src/data-grid/table-view.tsx +176 -0
  31. package/src/data-grid/types.ts +141 -0
  32. package/src/data-grid/use-grid-columns.ts +311 -0
  33. package/src/data-grid/use-grid-editing.ts +154 -0
  34. package/src/device-frame.tsx +274 -0
  35. package/src/dropdown-button.tsx +221 -0
  36. package/src/dropdown-menu.tsx +450 -0
  37. package/src/empty-state.tsx +85 -0
  38. package/src/file-upload/file-drop-zone.tsx +156 -0
  39. package/src/file-upload/file-preview-grid.tsx +39 -0
  40. package/src/file-upload/file-preview-item.tsx +175 -0
  41. package/src/file-upload/file-thumbnail.tsx +98 -0
  42. package/src/file-upload/file-upload-button.tsx +95 -0
  43. package/src/file-upload/index.ts +13 -0
  44. package/src/file-upload/use-file-upload.ts +371 -0
  45. package/src/filter-bar.tsx +125 -0
  46. package/src/filter-dropdown.tsx +289 -0
  47. package/src/floating-drawer.tsx +420 -0
  48. package/src/folder-card.tsx +188 -0
  49. package/src/folder-tree-picker.tsx +511 -0
  50. package/src/format.ts +117 -0
  51. package/src/frame-stack.tsx +73 -0
  52. package/src/hero-form-card.tsx +471 -0
  53. package/src/index.ts +372 -0
  54. package/src/input.tsx +130 -0
  55. package/src/kbd.tsx +41 -0
  56. package/src/lib/layout.ts +22 -0
  57. package/src/lib/utils.ts +6 -0
  58. package/src/markdown.tsx +214 -0
  59. package/src/menu-button.tsx +142 -0
  60. package/src/meta-previews/CreativeMedia.tsx +119 -0
  61. package/src/meta-previews/InstagramExplorePreview.tsx +340 -0
  62. package/src/meta-previews/InstagramFeedCarousel.tsx +245 -0
  63. package/src/meta-previews/InstagramFeedPreview.tsx +259 -0
  64. package/src/meta-previews/MetaFeedCarousel.tsx +228 -0
  65. package/src/meta-previews/MetaFeedPreview.tsx +294 -0
  66. package/src/meta-previews/MetaInstreamPreview.tsx +135 -0
  67. package/src/meta-previews/MetaMessengerPreview.tsx +795 -0
  68. package/src/meta-previews/MetaReelPreview.tsx +750 -0
  69. package/src/meta-previews/MetaRightColumnPreview.tsx +245 -0
  70. package/src/meta-previews/MetaSearchPreview.tsx +113 -0
  71. package/src/meta-previews/MetaStoryPreview.tsx +1132 -0
  72. package/src/meta-previews/ProfileAvatar.tsx +79 -0
  73. package/src/meta-previews/defaults.ts +27 -0
  74. package/src/meta-previews/index.ts +37 -0
  75. package/src/meta-previews/types.ts +219 -0
  76. package/src/metric-card.tsx +117 -0
  77. package/src/modal.tsx +131 -0
  78. package/src/option-tile.tsx +252 -0
  79. package/src/page-hero.tsx +78 -0
  80. package/src/page-shell.tsx +440 -0
  81. package/src/pagination.tsx +86 -0
  82. package/src/pill-button.tsx +103 -0
  83. package/src/profile-section.tsx +389 -0
  84. package/src/progress-bar.tsx +84 -0
  85. package/src/radio.tsx +172 -0
  86. package/src/range-slider.tsx +161 -0
  87. package/src/resizable.tsx +282 -0
  88. package/src/search-bar.tsx +81 -0
  89. package/src/segmented-pill.tsx +143 -0
  90. package/src/shell.tsx +464 -0
  91. package/src/slider.tsx +107 -0
  92. package/src/sortable-table.tsx +198 -0
  93. package/src/sortable.tsx +137 -0
  94. package/src/stat-row.tsx +132 -0
  95. package/src/stats-grid.tsx +38 -0
  96. package/src/step-loader.tsx +141 -0
  97. package/src/styles.css +562 -0
  98. package/src/tabs.tsx +143 -0
  99. package/src/tag-chip-input.tsx +394 -0
  100. package/src/textarea.tsx +39 -0
  101. package/src/toast.tsx +59 -0
  102. package/src/tokens.css +579 -0
@@ -0,0 +1,161 @@
1
+ /**
2
+ * RangeSlider — dual-thumb numeric range selector.
3
+ *
4
+ * Label 1,000 – 48,000
5
+ * ●━━━━━━━━━━━━━━━━━━●─────────────────────────────────
6
+ *
7
+ * Two native `<input type="range">` elements are stacked over a shared
8
+ * track + fill. Native inputs give keyboard support (arrows / Home /
9
+ * End) and accessibility for free; the `.ds-range-input` CSS in
10
+ * styles.css makes the input bodies transparent + click-through so the
11
+ * two thumbs don't block each other.
12
+ *
13
+ * Controlled — the parent owns `value` as a `[low, high]` tuple. The
14
+ * component clamps so the thumbs can meet but never cross.
15
+ *
16
+ * Token discipline: track / fill colors from CSS variables; thumb
17
+ * styling lives in `.ds-range-input` (pseudo-elements can't be styled
18
+ * inline). No hex.
19
+ *
20
+ * First consumer: Keywords lens filter bar (Volume + Avg CPC ranges).
21
+ * Generic — reusable for any numeric min/max filter.
22
+ */
23
+
24
+ import { useId, type ReactNode } from 'react'
25
+ import { cn } from './lib/utils'
26
+
27
+ export interface RangeSliderProps {
28
+ /** Lower bound of the track. */
29
+ min: number
30
+ /** Upper bound of the track. */
31
+ max: number
32
+ /** Current selection — `[low, high]`. Clamped within `[min, max]`. */
33
+ value: [number, number]
34
+ onChange: (next: [number, number]) => void
35
+ /** Step granularity. Default 1. */
36
+ step?: number
37
+ /** Optional label rendered above the track. */
38
+ label?: ReactNode
39
+ /** Formats the two end values shown beside the label. Default
40
+ * `toLocaleString`. */
41
+ formatValue?: (value: number) => string
42
+ /** id base — the two range inputs get `${id}-min` / `${id}-max`. */
43
+ id?: string
44
+ disabled?: boolean
45
+ className?: string
46
+ }
47
+
48
+ function clamp(n: number, lo: number, hi: number): number {
49
+ return Math.max(lo, Math.min(hi, n))
50
+ }
51
+
52
+ export function RangeSlider({
53
+ min,
54
+ max,
55
+ value,
56
+ onChange,
57
+ step = 1,
58
+ label,
59
+ formatValue = (n) => n.toLocaleString('en-US'),
60
+ id,
61
+ disabled,
62
+ className,
63
+ }: RangeSliderProps) {
64
+ const generatedId = useId()
65
+ const baseId = id ?? `range-${generatedId.replace(/:/g, '')}`
66
+
67
+ const span = max - min || 1
68
+ const [lowRaw, highRaw] = value
69
+ const low = clamp(lowRaw, min, max)
70
+ const high = clamp(highRaw, min, max)
71
+
72
+ const lowPct = ((low - min) / span) * 100
73
+ const highPct = ((high - min) / span) * 100
74
+
75
+ const handleLow = (next: number) => {
76
+ // Low thumb can meet the high thumb but not pass it.
77
+ onChange([clamp(next, min, high), high])
78
+ }
79
+ const handleHigh = (next: number) => {
80
+ onChange([low, clamp(next, low, max)])
81
+ }
82
+
83
+ return (
84
+ <div className={cn('flex flex-col gap-1.5', className)}>
85
+ {label && (
86
+ <div className="flex items-baseline justify-between gap-2">
87
+ <span
88
+ className="text-[12px] font-semibold tracking-[0.02em]"
89
+ style={{ color: 'rgb(var(--foreground))' }}
90
+ >
91
+ {label}
92
+ </span>
93
+ <span
94
+ className="text-[12px] tabular-nums"
95
+ style={{ color: 'rgb(var(--text-tertiary))' }}
96
+ >
97
+ {formatValue(low)} – {formatValue(high)}
98
+ </span>
99
+ </div>
100
+ )}
101
+
102
+ <div className="ds-range-slider relative h-4 flex items-center">
103
+ {/* Base track */}
104
+ <div
105
+ className="absolute left-0 right-0 h-1.5 rounded-full"
106
+ style={{ background: 'rgb(var(--surface-overlay-strong))' }}
107
+ />
108
+ {/* Selected fill */}
109
+ <div
110
+ className="absolute h-1.5 rounded-full"
111
+ style={{
112
+ left: `${lowPct}%`,
113
+ right: `${100 - highPct}%`,
114
+ background: disabled
115
+ ? 'rgb(var(--border-strong))'
116
+ : 'rgb(var(--accent))',
117
+ }}
118
+ />
119
+ {/* Low thumb. z-index lifts when both thumbs sit at the top
120
+ end so the low thumb stays grabbable. */}
121
+ <input
122
+ id={`${baseId}-min`}
123
+ type="range"
124
+ className="ds-range-input"
125
+ style={{ zIndex: low >= max ? 4 : 3 }}
126
+ min={min}
127
+ max={max}
128
+ step={step}
129
+ value={low}
130
+ disabled={disabled}
131
+ onChange={(e) => handleLow(Number(e.target.value))}
132
+ aria-label={
133
+ typeof label === 'string'
134
+ ? `${label} — minimum`
135
+ : 'Range minimum'
136
+ }
137
+ aria-valuetext={formatValue(low)}
138
+ />
139
+ {/* High thumb */}
140
+ <input
141
+ id={`${baseId}-max`}
142
+ type="range"
143
+ className="ds-range-input"
144
+ style={{ zIndex: 3 }}
145
+ min={min}
146
+ max={max}
147
+ step={step}
148
+ value={high}
149
+ disabled={disabled}
150
+ onChange={(e) => handleHigh(Number(e.target.value))}
151
+ aria-label={
152
+ typeof label === 'string'
153
+ ? `${label} — maximum`
154
+ : 'Range maximum'
155
+ }
156
+ aria-valuetext={formatValue(high)}
157
+ />
158
+ </div>
159
+ </div>
160
+ )
161
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * ResizableHandle — pointer-driven splitter handle with WAI-ARIA splitter
3
+ * a11y. Controlled `value` (px); `onChange` fires every animation frame
4
+ * during drag, `onCommit` once on release. Arrow keys nudge by `step`;
5
+ * `Home` and double-click reset to `defaultValue ?? (min + max) / 2`.
6
+ *
7
+ * Pointer events (with `setPointerCapture`) give touch + mouse parity in
8
+ * a single code path; the capture also keeps the drag live when the
9
+ * cursor leaves the 1-px handle. State during drag lives in refs so
10
+ * pointermove never triggers a React re-render — `onChange` is the
11
+ * caller's signal that the controlled value should update.
12
+ *
13
+ * Promoted per ADR-049 D8/D9: the Generate lens (`form-panel.tsx`) had
14
+ * a local mouse-event resize; ADR-049's pinned sidebar panel is the
15
+ * second consumer (ADR-008 D3 gate).
16
+ *
17
+ * ResizablePane is an optional thin `<aside>` wrapper that sizes itself
18
+ * to `value` on the relevant axis and mounts a `<ResizableHandle>` on
19
+ * the matching edge.
20
+ */
21
+
22
+ import {
23
+ useCallback,
24
+ useRef,
25
+ useState,
26
+ type CSSProperties,
27
+ type KeyboardEvent,
28
+ type PointerEvent as ReactPointerEvent,
29
+ type ReactNode,
30
+ } from 'react'
31
+ import { cn } from './lib/utils'
32
+
33
+ export interface ResizableHandleProps {
34
+ /** Which edge of the parent the handle decorates. */
35
+ side: 'left' | 'right' | 'top' | 'bottom'
36
+ /** Controlled width/height (px). */
37
+ value: number
38
+ /** Called on every animation-frame-throttled pointermove during drag. */
39
+ onChange: (next: number) => void
40
+ /** Called once on pointerup with the final value. Use for persistence. */
41
+ onCommit?: (final: number) => void
42
+ /** Lower bound (px). */
43
+ min: number
44
+ /** Upper bound (px). */
45
+ max: number
46
+ /** Double-click / Home target. Defaults to `(min + max) / 2`. */
47
+ defaultValue?: number
48
+ /** Keyboard nudge step (px). Default 16. */
49
+ step?: number
50
+ /** ARIA label for the splitter. Required. */
51
+ 'aria-label': string
52
+ /** Disable all interaction. Used by the sidebar's hover-out mode. */
53
+ disabled?: boolean
54
+ className?: string
55
+ }
56
+
57
+ function clamp(n: number, lo: number, hi: number): number {
58
+ return Math.max(lo, Math.min(hi, n))
59
+ }
60
+
61
+ function isHorizontal(side: ResizableHandleProps['side']): boolean {
62
+ return side === 'left' || side === 'right'
63
+ }
64
+
65
+ export function ResizableHandle({
66
+ side,
67
+ value,
68
+ onChange,
69
+ onCommit,
70
+ min,
71
+ max,
72
+ defaultValue,
73
+ step = 16,
74
+ 'aria-label': ariaLabel,
75
+ disabled = false,
76
+ className,
77
+ }: ResizableHandleProps) {
78
+ const horizontal = isHorizontal(side)
79
+ const resetValue = defaultValue ?? (min + max) / 2
80
+
81
+ // Drag state lives in refs — pointermove must not re-render.
82
+ const dragStartCoord = useRef(0)
83
+ const dragStartValue = useRef(0)
84
+ const rafIdRef = useRef<number | null>(null)
85
+ const pendingValueRef = useRef<number>(value)
86
+ const lastEmittedRef = useRef<number>(value)
87
+
88
+ const [isDragging, setIsDragging] = useState(false)
89
+
90
+ const flushPending = useCallback(() => {
91
+ rafIdRef.current = null
92
+ const next = pendingValueRef.current
93
+ if (next !== lastEmittedRef.current) {
94
+ lastEmittedRef.current = next
95
+ onChange(next)
96
+ }
97
+ }, [onChange])
98
+
99
+ const handlePointerDown = useCallback(
100
+ (e: ReactPointerEvent<HTMLDivElement>) => {
101
+ if (disabled) return
102
+ // Only react to primary pointer (left mouse, primary touch).
103
+ if (e.button !== 0 && e.pointerType === 'mouse') return
104
+ e.preventDefault()
105
+ e.currentTarget.setPointerCapture(e.pointerId)
106
+ dragStartCoord.current = horizontal ? e.clientX : e.clientY
107
+ dragStartValue.current = value
108
+ pendingValueRef.current = value
109
+ lastEmittedRef.current = value
110
+ setIsDragging(true)
111
+ },
112
+ [disabled, horizontal, value],
113
+ )
114
+
115
+ const handlePointerMove = useCallback(
116
+ (e: ReactPointerEvent<HTMLDivElement>) => {
117
+ if (disabled) return
118
+ if (!e.currentTarget.hasPointerCapture(e.pointerId)) return
119
+ const coord = horizontal ? e.clientX : e.clientY
120
+ const delta = coord - dragStartCoord.current
121
+ // For `left` and `top` sides, dragging "toward" the parent shrinks it,
122
+ // so the sign of the delta is inverted.
123
+ const signed = side === 'left' || side === 'top' ? -delta : delta
124
+ const next = clamp(dragStartValue.current + signed, min, max)
125
+ pendingValueRef.current = next
126
+ if (rafIdRef.current === null) {
127
+ rafIdRef.current = requestAnimationFrame(flushPending)
128
+ }
129
+ },
130
+ [disabled, flushPending, horizontal, max, min, side],
131
+ )
132
+
133
+ const endDrag = useCallback(
134
+ (e: ReactPointerEvent<HTMLDivElement>) => {
135
+ if (!e.currentTarget.hasPointerCapture(e.pointerId)) return
136
+ e.currentTarget.releasePointerCapture(e.pointerId)
137
+ if (rafIdRef.current !== null) {
138
+ cancelAnimationFrame(rafIdRef.current)
139
+ rafIdRef.current = null
140
+ }
141
+ const final = pendingValueRef.current
142
+ // Ensure the final value reached the caller before commit.
143
+ if (final !== lastEmittedRef.current) {
144
+ lastEmittedRef.current = final
145
+ onChange(final)
146
+ }
147
+ setIsDragging(false)
148
+ onCommit?.(final)
149
+ },
150
+ [onChange, onCommit],
151
+ )
152
+
153
+ const handleDoubleClick = useCallback(() => {
154
+ if (disabled) return
155
+ const next = clamp(resetValue, min, max)
156
+ onChange(next)
157
+ onCommit?.(next)
158
+ }, [disabled, max, min, onChange, onCommit, resetValue])
159
+
160
+ const handleKeyDown = useCallback(
161
+ (e: KeyboardEvent<HTMLDivElement>) => {
162
+ if (disabled) return
163
+ const decreaseKey = horizontal ? 'ArrowLeft' : 'ArrowUp'
164
+ const increaseKey = horizontal ? 'ArrowRight' : 'ArrowDown'
165
+ // For `left`/`top` sides, "decrease size" maps to the OPPOSITE arrow
166
+ // because the handle sits on the far side of the parent.
167
+ const inverted = side === 'left' || side === 'top'
168
+ // PageUp/PageDown follow the conventional splitter pattern of nudging
169
+ // by step * 4 — large-step navigation for keyboard-only users.
170
+ const largeStep = step * 4
171
+ let next: number | null = null
172
+ if (e.key === decreaseKey) {
173
+ next = clamp(value + (inverted ? step : -step), min, max)
174
+ } else if (e.key === increaseKey) {
175
+ next = clamp(value + (inverted ? -step : step), min, max)
176
+ } else if (e.key === 'PageDown') {
177
+ // PageDown shrinks (mirrors decrease arrow direction).
178
+ next = clamp(value + (inverted ? largeStep : -largeStep), min, max)
179
+ } else if (e.key === 'PageUp') {
180
+ // PageUp grows (mirrors increase arrow direction).
181
+ next = clamp(value + (inverted ? -largeStep : largeStep), min, max)
182
+ } else if (e.key === 'Home') {
183
+ next = clamp(resetValue, min, max)
184
+ }
185
+ if (next !== null) {
186
+ e.preventDefault()
187
+ onChange(next)
188
+ onCommit?.(next)
189
+ }
190
+ },
191
+ [disabled, horizontal, max, min, onChange, onCommit, resetValue, side, step, value],
192
+ )
193
+
194
+ const cursor = horizontal ? 'col-resize' : 'row-resize'
195
+ const orientation = horizontal ? 'vertical' : 'horizontal'
196
+
197
+ // MAJ-2 (Sidebar-A hot-fix): hit-target widened to 8 px while the visible
198
+ // line stays 4 px (`w-1`/`h-1`). Padding extends the interactive area on the
199
+ // cross-axis; `background-clip: content-box` keeps the rest-state background
200
+ // painting only on the inner 4 px. Negative margin on the cross-axis
201
+ // recenters the element over the parent's edge so the visual line still
202
+ // sits on the seam.
203
+ //
204
+ // Math: 4 px line + 2 × 2 px padding = 8 px effective hit zone.
205
+ // `box-sizing: content-box` makes the padding additive to width/height
206
+ // (Tailwind's preflight sets box-sizing: border-box globally, which would
207
+ // otherwise eat the padding into the existing 4 px).
208
+ const HIT_PAD = 2
209
+ const style: CSSProperties = {
210
+ cursor: disabled ? 'default' : cursor,
211
+ backgroundColor: isDragging
212
+ ? 'rgb(var(--accent-muted))'
213
+ : undefined,
214
+ touchAction: 'none',
215
+ padding: horizontal ? `0 ${HIT_PAD}px` : `${HIT_PAD}px 0`,
216
+ backgroundClip: 'content-box',
217
+ boxSizing: 'content-box',
218
+ // Recenter on the parent's edge so the inner 4 px line stays where it
219
+ // was before the padding was added.
220
+ marginLeft: horizontal ? `-${HIT_PAD}px` : undefined,
221
+ marginTop: horizontal ? undefined : `-${HIT_PAD}px`,
222
+ }
223
+
224
+ return (
225
+ <div
226
+ role="separator"
227
+ aria-orientation={orientation}
228
+ aria-valuenow={value}
229
+ aria-valuemin={min}
230
+ aria-valuemax={max}
231
+ aria-label={ariaLabel}
232
+ aria-disabled={disabled || undefined}
233
+ tabIndex={disabled ? -1 : 0}
234
+ data-resizable-handle
235
+ data-side={side}
236
+ data-orientation={orientation}
237
+ data-dragging={isDragging || undefined}
238
+ onPointerDown={disabled ? undefined : handlePointerDown}
239
+ onPointerMove={disabled ? undefined : handlePointerMove}
240
+ onPointerUp={disabled ? undefined : endDrag}
241
+ onPointerCancel={disabled ? undefined : endDrag}
242
+ onDoubleClick={disabled ? undefined : handleDoubleClick}
243
+ onKeyDown={disabled ? undefined : handleKeyDown}
244
+ className={cn(
245
+ 'resizable-handle',
246
+ horizontal
247
+ ? 'absolute top-0 w-1 h-full'
248
+ : 'absolute left-0 h-1 w-full',
249
+ side === 'right' && 'right-0',
250
+ side === 'left' && 'left-0',
251
+ side === 'bottom' && 'bottom-0',
252
+ side === 'top' && 'top-0',
253
+ !isDragging && !disabled && 'hover:bg-[rgb(var(--accent-subtle))]',
254
+ 'focus-visible:outline-none focus-visible:bg-[rgb(var(--accent-subtle))]',
255
+ className,
256
+ )}
257
+ style={style}
258
+ />
259
+ )
260
+ }
261
+
262
+ export interface ResizablePaneProps extends ResizableHandleProps {
263
+ children: ReactNode
264
+ paneClassName?: string
265
+ }
266
+
267
+ export function ResizablePane({
268
+ children,
269
+ paneClassName,
270
+ ...handleProps
271
+ }: ResizablePaneProps) {
272
+ const horizontal = isHorizontal(handleProps.side)
273
+ const paneStyle: CSSProperties = horizontal
274
+ ? { width: `${handleProps.value}px` }
275
+ : { height: `${handleProps.value}px` }
276
+ return (
277
+ <aside className={cn('relative', paneClassName)} style={paneStyle}>
278
+ {children}
279
+ <ResizableHandle {...handleProps} />
280
+ </aside>
281
+ )
282
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * SearchBar — input-shaped search field with a leading magnifier icon
3
+ * and an inline clear button when the value is non-empty. The width prop
4
+ * accepts a px number or any CSS length.
5
+ *
6
+ * Promoted from `brand-ai-extension/webapp/src/components/ui/search-bar.tsx`
7
+ * as part of Phase A of the v1.1 lens port batch — used by Collections,
8
+ * Swipes, Prompts.
9
+ *
10
+ * Internal tokens (--bg-input, --border, --accent-subtle) are fine to use
11
+ * inside packages/ui per the token-boundary rule.
12
+ */
13
+ import { Search, X } from 'lucide-react'
14
+
15
+ export interface SearchBarProps {
16
+ value: string
17
+ onChange: (v: string) => void
18
+ placeholder?: string
19
+ width?: number | string
20
+ className?: string
21
+ }
22
+
23
+ export function SearchBar({
24
+ value,
25
+ onChange,
26
+ placeholder = 'Search…',
27
+ width = 260,
28
+ className,
29
+ }: SearchBarProps) {
30
+ return (
31
+ <div
32
+ className={`flex items-center gap-2 px-3.5 py-2 transition-all ${className ?? ''}`.trim()}
33
+ style={{
34
+ background: 'rgb(var(--bg-input))',
35
+ border: '1px solid rgb(var(--border))',
36
+ borderRadius: 'var(--radius-sm)',
37
+ width,
38
+ }}
39
+ onFocus={(e) => {
40
+ e.currentTarget.style.borderColor = 'rgb(var(--accent))'
41
+ e.currentTarget.style.boxShadow = '0 0 0 3px rgb(var(--accent-subtle))'
42
+ }}
43
+ onBlur={(e) => {
44
+ e.currentTarget.style.borderColor = 'rgb(var(--border))'
45
+ e.currentTarget.style.boxShadow = 'none'
46
+ }}
47
+ >
48
+ <Search
49
+ className="h-4 w-4 shrink-0"
50
+ style={{ color: 'rgb(var(--text-muted))' }}
51
+ />
52
+ <input
53
+ type="text"
54
+ value={value}
55
+ onChange={(e) => onChange(e.target.value)}
56
+ placeholder={placeholder}
57
+ className="flex-1 bg-transparent outline-none text-[13px]"
58
+ style={{
59
+ color: 'rgb(var(--foreground))',
60
+ fontFamily: 'var(--font-sans)',
61
+ }}
62
+ />
63
+ {value && (
64
+ <button
65
+ type="button"
66
+ onClick={() => onChange('')}
67
+ aria-label="Clear"
68
+ style={{ color: 'rgb(var(--text-muted))' }}
69
+ onMouseEnter={(e) =>
70
+ (e.currentTarget.style.color = 'rgb(var(--foreground))')
71
+ }
72
+ onMouseLeave={(e) =>
73
+ (e.currentTarget.style.color = 'rgb(var(--text-muted))')
74
+ }
75
+ >
76
+ <X className="h-3.5 w-3.5" />
77
+ </button>
78
+ )}
79
+ </div>
80
+ )
81
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * SegmentedPill — segmented control with an outlined-accent active state.
3
+ *
4
+ * Visual:
5
+ *
6
+ * ╭──────────────────────────────────────────────────────────────╮
7
+ * │ ┌──────────────┐ │
8
+ * │ │ ⚙ Active │ ☰ Inactive ⚙ Inactive ☰ Inactive │
9
+ * │ └──────────────┘ │
10
+ * ╰──────────────────────────────────────────────────────────────╯
11
+ *
12
+ * • Whole strip sits in a subtle `--surface-overlay-soft` rounded
13
+ * container so the inactive segments read as text rather than
14
+ * borderless buttons.
15
+ * • The active segment is wrapped in a 1px accent border with an
16
+ * `--accent-subtle` fill — reads as an "outlined pill" sitting
17
+ * inside the strip.
18
+ * • Each segment is a button with an optional leading icon.
19
+ *
20
+ * Generic — accepts a typed `items` array. First consumer: Keywords
21
+ * lens SearchPanel (research-mode picker). Reusable anywhere a small
22
+ * (2–5 item) mutually-exclusive choice needs a low-emphasis tab-like
23
+ * control.
24
+ *
25
+ * Icon-agnostic. Token-only styling.
26
+ */
27
+
28
+ import { useId, type ReactNode } from 'react'
29
+ import { cn } from './lib/utils'
30
+
31
+ export interface SegmentedPillItem<Id extends string = string> {
32
+ id: Id
33
+ label: ReactNode
34
+ icon?: ReactNode
35
+ disabled?: boolean
36
+ /** Optional accessible label override for icon-heavy segments. */
37
+ ariaLabel?: string
38
+ }
39
+
40
+ export interface SegmentedPillProps<Id extends string = string> {
41
+ items: SegmentedPillItem<Id>[]
42
+ value: Id
43
+ onChange: (next: Id) => void
44
+ /** Accessible label for the whole group (rendered as aria-label on
45
+ * the `role="tablist"` container). */
46
+ ariaLabel?: string
47
+ /** Size variant — controls vertical padding + font. Default `md`.
48
+ * `xs` is for dense inline contexts (e.g. a sort toggle inside a tab bar). */
49
+ size?: 'xs' | 'sm' | 'md'
50
+ /** Fill the available width and stretch segments. Default `true`. */
51
+ stretch?: boolean
52
+ className?: string
53
+ }
54
+
55
+ export function SegmentedPill<Id extends string = string>({
56
+ items,
57
+ value,
58
+ onChange,
59
+ ariaLabel,
60
+ size = 'md',
61
+ stretch = true,
62
+ className,
63
+ }: SegmentedPillProps<Id>) {
64
+ const reactId = useId()
65
+
66
+ const padY = size === 'xs' ? 'py-1' : size === 'sm' ? 'py-1.5' : 'py-2'
67
+ const textSize = size === 'xs' ? 'text-[11px]' : size === 'sm' ? 'text-[12px]' : 'text-[13px]'
68
+ const padX = size === 'xs' ? 'px-2.5' : 'px-3'
69
+ const containerPad = size === 'xs' ? 'p-0.5' : 'p-1'
70
+
71
+ return (
72
+ <div
73
+ role="tablist"
74
+ aria-label={ariaLabel}
75
+ className={cn(
76
+ 'flex items-center gap-1',
77
+ containerPad,
78
+ stretch && 'w-full',
79
+ className,
80
+ )}
81
+ style={{
82
+ background: 'rgb(var(--surface-overlay-soft))',
83
+ border: '1px solid rgb(var(--border))',
84
+ borderRadius: 'var(--radius-full)',
85
+ }}
86
+ >
87
+ {items.map((item) => {
88
+ const isActive = item.id === value
89
+ return (
90
+ <button
91
+ key={item.id}
92
+ type="button"
93
+ role="tab"
94
+ aria-selected={isActive}
95
+ aria-controls={`${reactId}-panel-${item.id}`}
96
+ disabled={item.disabled}
97
+ onClick={() => onChange(item.id)}
98
+ aria-label={item.ariaLabel}
99
+ className={cn(
100
+ 'inline-flex items-center justify-center gap-1.5',
101
+ padX,
102
+ padY,
103
+ textSize,
104
+ 'font-medium transition-colors',
105
+ 'disabled:opacity-50 disabled:cursor-not-allowed',
106
+ stretch && 'flex-1',
107
+ isActive ? 'cursor-default' : 'cursor-pointer',
108
+ )}
109
+ style={{
110
+ borderRadius: 'var(--radius-full)',
111
+ ...(isActive
112
+ ? {
113
+ background: 'rgb(var(--accent-subtle))',
114
+ border: '1px solid rgb(var(--accent))',
115
+ color: 'rgb(var(--accent))',
116
+ }
117
+ : {
118
+ background: 'transparent',
119
+ border: '1px solid transparent',
120
+ color: 'rgb(var(--text-secondary))',
121
+ }),
122
+ }}
123
+ >
124
+ {item.icon && (
125
+ <span
126
+ className="inline-flex shrink-0"
127
+ style={{
128
+ color: isActive
129
+ ? 'rgb(var(--accent))'
130
+ : 'rgb(var(--text-muted))',
131
+ }}
132
+ aria-hidden="true"
133
+ >
134
+ {item.icon}
135
+ </span>
136
+ )}
137
+ <span className="truncate">{item.label}</span>
138
+ </button>
139
+ )
140
+ })}
141
+ </div>
142
+ )
143
+ }