@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.
- package/README.md +28 -0
- package/package.json +53 -0
- package/src/accordion.tsx +153 -0
- package/src/atoms.tsx +197 -0
- package/src/badge.tsx +91 -0
- package/src/brand-logo-tile.tsx +200 -0
- package/src/bulk-action-bar.tsx +104 -0
- package/src/button-group.tsx +46 -0
- package/src/button.tsx +93 -0
- package/src/calculator-shell-v2.tsx +380 -0
- package/src/calculator-shell.tsx +310 -0
- package/src/card.tsx +369 -0
- package/src/checkbox.tsx +114 -0
- package/src/chip-nav.tsx +347 -0
- package/src/choropleth.tsx +62 -0
- package/src/code-block.tsx +235 -0
- package/src/completion-ring.tsx +119 -0
- package/src/copy-field.tsx +169 -0
- package/src/data-grid/column-options-menu.tsx +127 -0
- package/src/data-grid/data-grid.tsx +391 -0
- package/src/data-grid/drawer-panel.tsx +146 -0
- package/src/data-grid/index.ts +29 -0
- package/src/data-grid/sortable-parts.tsx +215 -0
- package/src/data-grid/table-body.tsx +238 -0
- package/src/data-grid/table-elements.tsx +135 -0
- package/src/data-grid/table-header.tsx +95 -0
- package/src/data-grid/table-overlays.tsx +78 -0
- package/src/data-grid/table-parts.tsx +15 -0
- package/src/data-grid/table-summary-footer.tsx +101 -0
- package/src/data-grid/table-view.tsx +176 -0
- package/src/data-grid/types.ts +141 -0
- package/src/data-grid/use-grid-columns.ts +311 -0
- package/src/data-grid/use-grid-editing.ts +154 -0
- package/src/device-frame.tsx +274 -0
- package/src/dropdown-button.tsx +221 -0
- package/src/dropdown-menu.tsx +450 -0
- package/src/empty-state.tsx +85 -0
- package/src/file-upload/file-drop-zone.tsx +156 -0
- package/src/file-upload/file-preview-grid.tsx +39 -0
- package/src/file-upload/file-preview-item.tsx +175 -0
- package/src/file-upload/file-thumbnail.tsx +98 -0
- package/src/file-upload/file-upload-button.tsx +95 -0
- package/src/file-upload/index.ts +13 -0
- package/src/file-upload/use-file-upload.ts +371 -0
- package/src/filter-bar.tsx +125 -0
- package/src/filter-dropdown.tsx +289 -0
- package/src/floating-drawer.tsx +420 -0
- package/src/folder-card.tsx +188 -0
- package/src/folder-tree-picker.tsx +511 -0
- package/src/format.ts +117 -0
- package/src/frame-stack.tsx +73 -0
- package/src/hero-form-card.tsx +471 -0
- package/src/index.ts +372 -0
- package/src/input.tsx +130 -0
- package/src/kbd.tsx +41 -0
- package/src/lib/layout.ts +22 -0
- package/src/lib/utils.ts +6 -0
- package/src/markdown.tsx +214 -0
- package/src/menu-button.tsx +142 -0
- package/src/meta-previews/CreativeMedia.tsx +119 -0
- package/src/meta-previews/InstagramExplorePreview.tsx +340 -0
- package/src/meta-previews/InstagramFeedCarousel.tsx +245 -0
- package/src/meta-previews/InstagramFeedPreview.tsx +259 -0
- package/src/meta-previews/MetaFeedCarousel.tsx +228 -0
- package/src/meta-previews/MetaFeedPreview.tsx +294 -0
- package/src/meta-previews/MetaInstreamPreview.tsx +135 -0
- package/src/meta-previews/MetaMessengerPreview.tsx +795 -0
- package/src/meta-previews/MetaReelPreview.tsx +750 -0
- package/src/meta-previews/MetaRightColumnPreview.tsx +245 -0
- package/src/meta-previews/MetaSearchPreview.tsx +113 -0
- package/src/meta-previews/MetaStoryPreview.tsx +1132 -0
- package/src/meta-previews/ProfileAvatar.tsx +79 -0
- package/src/meta-previews/defaults.ts +27 -0
- package/src/meta-previews/index.ts +37 -0
- package/src/meta-previews/types.ts +219 -0
- package/src/metric-card.tsx +117 -0
- package/src/modal.tsx +131 -0
- package/src/option-tile.tsx +252 -0
- package/src/page-hero.tsx +78 -0
- package/src/page-shell.tsx +440 -0
- package/src/pagination.tsx +86 -0
- package/src/pill-button.tsx +103 -0
- package/src/profile-section.tsx +389 -0
- package/src/progress-bar.tsx +84 -0
- package/src/radio.tsx +172 -0
- package/src/range-slider.tsx +161 -0
- package/src/resizable.tsx +282 -0
- package/src/search-bar.tsx +81 -0
- package/src/segmented-pill.tsx +143 -0
- package/src/shell.tsx +464 -0
- package/src/slider.tsx +107 -0
- package/src/sortable-table.tsx +198 -0
- package/src/sortable.tsx +137 -0
- package/src/stat-row.tsx +132 -0
- package/src/stats-grid.tsx +38 -0
- package/src/step-loader.tsx +141 -0
- package/src/styles.css +562 -0
- package/src/tabs.tsx +143 -0
- package/src/tag-chip-input.tsx +394 -0
- package/src/textarea.tsx +39 -0
- package/src/toast.tsx +59 -0
- 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
|
+
}
|