@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,440 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useRef,
7
+ useState,
8
+ type ReactNode,
9
+ type RefObject,
10
+ } from 'react'
11
+ import { createPortal } from 'react-dom'
12
+ import { Link } from 'react-router-dom'
13
+ import { cn } from './lib/utils'
14
+
15
+ export type Crumb = {
16
+ label: string
17
+ to?: string
18
+ }
19
+
20
+ interface PageShellProps {
21
+ /** Breadcrumb chain — last item renders as the bold current label.
22
+ * For back-compat, `breadcrumb` + `title` are still accepted. */
23
+ crumbs?: Crumb[]
24
+ /** Legacy convenience: single parent crumb (label only). */
25
+ breadcrumb?: string
26
+ /** Legacy: current-page title. Used as the last crumb when `crumbs` isn't provided. */
27
+ title?: string
28
+ /** Right-side actions in the page header. */
29
+ actions?: ReactNode
30
+ /**
31
+ * Content for the horizontally-centered slot in `<PageHeaderHost />`
32
+ * (Phase 7a). Relayed via the slot portal alongside `crumbs` /
33
+ * `actions`. When mounted in slot-host mode, the content sits in a
34
+ * dedicated absolutely-centered slot between the breadcrumb cluster
35
+ * and the trailing chrome, so it stays centered regardless of left/
36
+ * right slot widths. Ignored when PageShell renders its own inline
37
+ * header (no `<PageHeaderHost />` mounted).
38
+ *
39
+ * Phase 7a's GeoSearch lens lifts its mode-tabs ChipNav into this
40
+ * slot so the AppHeader carries: sidebar-toggle | crumbs | tabs |
41
+ * theme-toggle. Other lenses leave it unset.
42
+ */
43
+ headerCenter?: ReactNode
44
+ children: ReactNode
45
+ /** Override content padding if needed. Default leaves room for the sticky-blend header. */
46
+ contentClassName?: string
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // PageHeaderSlot — optional "publish to host" mode (portal-based).
51
+ //
52
+ // By default, PageShell renders its own sticky header band with breadcrumbs +
53
+ // actions. When the app wraps the tree in <PageHeaderSlotProvider> AND mounts
54
+ // a <PageHeaderHost /> elsewhere, PageShell PORTALS its breadcrumbs/actions
55
+ // into the host's slot div, and skips rendering its own header band. That
56
+ // collapses what would be two stacked sticky bars into a single bar.
57
+ //
58
+ // Portal-based to avoid any state churn on the publish path — only the host
59
+ // DOM ref + the active scroll element are stored in context (each set once
60
+ // per mount, never per render).
61
+ // ---------------------------------------------------------------------------
62
+
63
+ interface PageHeaderSlotValue {
64
+ hostEl: HTMLElement | null
65
+ centerEl: HTMLElement | null
66
+ scrollEl: HTMLElement | null
67
+ registerHost: (el: HTMLElement | null) => void
68
+ registerCenter: (el: HTMLElement | null) => void
69
+ registerScroll: (el: HTMLElement | null) => void
70
+ }
71
+
72
+ const PageHeaderSlotContext = createContext<PageHeaderSlotValue | null>(null)
73
+
74
+ export function PageHeaderSlotProvider({ children }: { children: ReactNode }) {
75
+ const [hostEl, setHostEl] = useState<HTMLElement | null>(null)
76
+ const [centerEl, setCenterEl] = useState<HTMLElement | null>(null)
77
+ const [scrollEl, setScrollEl] = useState<HTMLElement | null>(null)
78
+ const registerHost = useCallback((el: HTMLElement | null) => setHostEl(el), [])
79
+ const registerCenter = useCallback(
80
+ (el: HTMLElement | null) => setCenterEl(el),
81
+ [],
82
+ )
83
+ const registerScroll = useCallback((el: HTMLElement | null) => setScrollEl(el), [])
84
+ return (
85
+ <PageHeaderSlotContext.Provider
86
+ value={{
87
+ hostEl,
88
+ centerEl,
89
+ scrollEl,
90
+ registerHost,
91
+ registerCenter,
92
+ registerScroll,
93
+ }}
94
+ >
95
+ {children}
96
+ </PageHeaderSlotContext.Provider>
97
+ )
98
+ }
99
+
100
+ /**
101
+ * Page shell — promoted from brand-ai-extension Phase 2a (ADR-009 Decision 7).
102
+ *
103
+ * Self-contained: provides a scrollable region whose sticky-blend header
104
+ * gains a translucent backdrop-blur background once you scroll past a few
105
+ * px. Breadcrumbs render at top-left, page actions at top-right.
106
+ *
107
+ * When mounted inside <PageHeaderSlotProvider> with a <PageHeaderHost />
108
+ * elsewhere, PageShell portals its header content into the host instead of
109
+ * rendering its own bar — the workspace uses this to merge the lens crumbs
110
+ * onto the same row as the workspace sidebar toggle.
111
+ */
112
+ export function PageShell({
113
+ crumbs,
114
+ breadcrumb,
115
+ title,
116
+ actions,
117
+ headerCenter,
118
+ children,
119
+ contentClassName,
120
+ }: PageShellProps) {
121
+ const resolvedCrumbs: Crumb[] =
122
+ crumbs && crumbs.length > 0
123
+ ? crumbs
124
+ : ([
125
+ breadcrumb ? { label: breadcrumb } : null,
126
+ title ? { label: title } : null,
127
+ ].filter(Boolean) as Crumb[])
128
+
129
+ const slot = useContext(PageHeaderSlotContext)
130
+
131
+ return (
132
+ <AppScroll>
133
+ {(scrollRef) => (
134
+ <PageShellBody
135
+ resolvedCrumbs={resolvedCrumbs}
136
+ actions={actions ?? null}
137
+ headerCenter={headerCenter ?? null}
138
+ scrollRef={scrollRef}
139
+ slot={slot}
140
+ contentClassName={contentClassName}
141
+ >
142
+ {children}
143
+ </PageShellBody>
144
+ )}
145
+ </AppScroll>
146
+ )
147
+ }
148
+
149
+ function PageShellBody({
150
+ resolvedCrumbs,
151
+ actions,
152
+ headerCenter,
153
+ scrollRef,
154
+ slot,
155
+ contentClassName,
156
+ children,
157
+ }: {
158
+ resolvedCrumbs: Crumb[]
159
+ actions: ReactNode | null
160
+ headerCenter: ReactNode | null
161
+ scrollRef: RefObject<HTMLDivElement | null>
162
+ slot: PageHeaderSlotValue | null
163
+ contentClassName?: string | undefined
164
+ children: ReactNode
165
+ }) {
166
+ // Register our scroll element with the host once when this PageShell
167
+ // mounts. Unregisters on unmount. Effect runs once because `slot` is a
168
+ // stable context object (provider memoizes its callbacks) and scrollRef
169
+ // identity is stable across renders of the same PageShell instance.
170
+ useEffect(() => {
171
+ if (!slot) return
172
+ const el = scrollRef.current
173
+ if (!el) return
174
+ slot.registerScroll(el)
175
+ return () => slot.registerScroll(null)
176
+ }, [slot, scrollRef])
177
+
178
+ const headerContent = (
179
+ <>
180
+ <div className="flex items-center gap-2 min-w-0 flex-1">
181
+ <Breadcrumbs
182
+ parents={resolvedCrumbs.slice(0, -1)}
183
+ current={resolvedCrumbs[resolvedCrumbs.length - 1]}
184
+ />
185
+ </div>
186
+ {actions && (
187
+ <div className="flex items-center gap-1.5 shrink-0">{actions}</div>
188
+ )}
189
+ </>
190
+ )
191
+
192
+ return (
193
+ <>
194
+ {slot?.hostEl ? (
195
+ createPortal(headerContent, slot.hostEl)
196
+ ) : (
197
+ <PageHeader
198
+ crumbs={resolvedCrumbs}
199
+ actions={actions ?? undefined}
200
+ scrollRef={scrollRef}
201
+ />
202
+ )}
203
+ {/* Header-center relay: portal into the host's center slot when
204
+ one is mounted. Falls through silently in slot-less mode — the
205
+ inline `<PageHeader />` doesn't render a centered band. */}
206
+ {slot?.centerEl && headerCenter
207
+ ? createPortal(headerCenter, slot.centerEl)
208
+ : null}
209
+ <div className={cn(contentClassName ?? 'px-7 pb-7')}>{children}</div>
210
+ </>
211
+ )
212
+ }
213
+
214
+ export default PageShell
215
+
216
+ interface PageHeaderProps {
217
+ crumbs: Crumb[]
218
+ actions?: ReactNode
219
+ scrollRef: RefObject<HTMLDivElement | null>
220
+ }
221
+
222
+ function PageHeader({ crumbs, actions, scrollRef }: PageHeaderProps) {
223
+ const [stuck, setStuck] = useState(false)
224
+
225
+ useEffect(() => {
226
+ const el = scrollRef.current
227
+ if (!el) return
228
+ const onScroll = () => setStuck(el.scrollTop > 4)
229
+ onScroll()
230
+ el.addEventListener('scroll', onScroll, { passive: true })
231
+ return () => el.removeEventListener('scroll', onScroll)
232
+ }, [scrollRef])
233
+
234
+ const parents = crumbs.slice(0, -1)
235
+ const current = crumbs[crumbs.length - 1]
236
+
237
+ return (
238
+ <header
239
+ className="sticky top-0 z-30 flex items-center gap-3 h-14 px-5"
240
+ style={{
241
+ background: stuck ? 'rgb(var(--main-content-bg) / 0.78)' : 'transparent',
242
+ backdropFilter: stuck ? 'saturate(180%) blur(14px)' : undefined,
243
+ WebkitBackdropFilter: stuck ? 'saturate(180%) blur(14px)' : undefined,
244
+ borderBottom: `1px solid ${stuck ? 'rgb(var(--border))' : 'transparent'}`,
245
+ transition:
246
+ 'background var(--dur-base) var(--ease-out), border-color var(--dur-base) var(--ease-out)',
247
+ }}
248
+ >
249
+ <div className="flex items-center gap-2 min-w-0 flex-1">
250
+ <Breadcrumbs parents={parents} current={current} />
251
+ </div>
252
+ {actions && (
253
+ <div className="flex items-center gap-1.5 shrink-0">{actions}</div>
254
+ )}
255
+ </header>
256
+ )
257
+ }
258
+
259
+ /**
260
+ * PageHeaderHost — single workspace-level header bar that the active
261
+ * <PageShell> portals its breadcrumbs/actions into. Renders the `leading`
262
+ * slot (typically a sidebar toggle button) on the same row, so the lens's
263
+ * chrome doesn't stack as a separate sticky band. The `trailing` slot
264
+ * sits past the portal area for persistent right-cluster chrome (e.g.
265
+ * a theme toggle) that should never collide with per-page actions.
266
+ *
267
+ * Sticky-blend: subscribes to the scroll container the active PageShell
268
+ * registered with the slot so the same scroll-past-4px fade-in still fires.
269
+ */
270
+ export function PageHeaderHost({
271
+ leading,
272
+ center,
273
+ trailing,
274
+ padX = 'px-3',
275
+ }: {
276
+ leading?: ReactNode
277
+ /**
278
+ * Horizontal padding utility for the header bar. Defaults to `px-3`
279
+ * (the toggle-anchored layout). Callers with no `leading` slot (e.g. a
280
+ * single-lens skin that hides the sidebar toggle) pass a larger inset
281
+ * like `px-7` so the breadcrumbs line up with the content column below
282
+ * instead of hugging the edge. ADR-061 D3.
283
+ */
284
+ padX?: string
285
+ /**
286
+ * Horizontally-centered slot — Phase 7a (ADR-012 Decision 12
287
+ * deviation). Renders absolutely positioned at the geometric center
288
+ * of the header so width changes in the breadcrumb (left) and
289
+ * trailing (right) clusters never shift its position. The active
290
+ * `<PageShell>` may also portal content into this slot via its
291
+ * `headerCenter` prop; both routes target the same DOM node.
292
+ *
293
+ * Wrapped in a `max-w` so on narrow viewports the center stays
294
+ * within the breadcrumb and trailing clusters' visual lanes
295
+ * (truncates internally before overlapping).
296
+ */
297
+ center?: ReactNode
298
+ trailing?: ReactNode
299
+ }) {
300
+ const slot = useContext(PageHeaderSlotContext)
301
+ const [stuck, setStuck] = useState(false)
302
+ const scrollEl = slot?.scrollEl ?? null
303
+
304
+ useEffect(() => {
305
+ if (!scrollEl) {
306
+ setStuck(false)
307
+ return
308
+ }
309
+ const onScroll = () => setStuck(scrollEl.scrollTop > 4)
310
+ onScroll()
311
+ scrollEl.addEventListener('scroll', onScroll, { passive: true })
312
+ return () => scrollEl.removeEventListener('scroll', onScroll)
313
+ }, [scrollEl])
314
+
315
+ const setHostRef = useCallback(
316
+ (el: HTMLDivElement | null) => {
317
+ slot?.registerHost(el)
318
+ },
319
+ [slot],
320
+ )
321
+
322
+ const setCenterRef = useCallback(
323
+ (el: HTMLDivElement | null) => {
324
+ slot?.registerCenter(el)
325
+ },
326
+ [slot],
327
+ )
328
+
329
+ return (
330
+ <header
331
+ className={cn(
332
+ 'sticky top-0 z-30 flex items-center gap-3 h-12 shrink-0 relative',
333
+ padX,
334
+ )}
335
+ style={{
336
+ background: stuck ? 'rgb(var(--main-content-bg) / 0.78)' : 'transparent',
337
+ backdropFilter: stuck ? 'saturate(180%) blur(14px)' : undefined,
338
+ WebkitBackdropFilter: stuck ? 'saturate(180%) blur(14px)' : undefined,
339
+ borderBottom: `1px solid ${stuck ? 'rgb(var(--border))' : 'transparent'}`,
340
+ transition:
341
+ 'background var(--dur-base) var(--ease-out), border-color var(--dur-base) var(--ease-out)',
342
+ }}
343
+ >
344
+ {leading && <div className="shrink-0 flex items-center">{leading}</div>}
345
+ <div
346
+ ref={setHostRef}
347
+ data-page-header-host="true"
348
+ className="flex items-center gap-3 flex-1 min-w-0"
349
+ />
350
+ {/* Absolutely-centered slot — sits above the row's flex flow so
351
+ it stays geometrically centered regardless of leading/host/
352
+ trailing widths. `pointer-events-none` on the wrapper so empty
353
+ space passes clicks through; inner content re-enables. */}
354
+ <div
355
+ className="absolute left-1/2 -translate-x-1/2 flex items-center justify-center pointer-events-none"
356
+ style={{
357
+ top: 0,
358
+ bottom: 0,
359
+ maxWidth: 'min(560px, 56%)',
360
+ }}
361
+ aria-hidden={center == null && !slot?.centerEl ? 'true' : undefined}
362
+ >
363
+ <div
364
+ ref={setCenterRef}
365
+ data-page-header-center="true"
366
+ className="pointer-events-auto flex items-center gap-2 min-w-0"
367
+ >
368
+ {center}
369
+ </div>
370
+ </div>
371
+ {trailing && (
372
+ <div className="shrink-0 flex items-center gap-1.5">{trailing}</div>
373
+ )}
374
+ </header>
375
+ )
376
+ }
377
+
378
+ function Breadcrumbs({
379
+ parents,
380
+ current,
381
+ }: {
382
+ parents: Crumb[]
383
+ current: Crumb | undefined
384
+ }) {
385
+ return (
386
+ <nav
387
+ className="flex items-center gap-1.5 text-[13px] min-w-0"
388
+ style={{ color: 'rgb(var(--text-tertiary))' }}
389
+ >
390
+ {parents.map((c, i) => (
391
+ <span key={i} className="flex items-center gap-1.5 min-w-0">
392
+ {c.to ? (
393
+ <Link
394
+ to={c.to}
395
+ className="truncate transition-colors hover:text-[rgb(var(--foreground))]"
396
+ style={{ color: 'inherit' }}
397
+ >
398
+ {c.label}
399
+ </Link>
400
+ ) : (
401
+ <span className="truncate">{c.label}</span>
402
+ )}
403
+ <span style={{ color: 'rgb(var(--text-muted))' }}>/</span>
404
+ </span>
405
+ ))}
406
+ {current && (
407
+ <span
408
+ className="truncate font-medium"
409
+ style={{ color: 'rgb(var(--foreground))' }}
410
+ >
411
+ {current.label}
412
+ </span>
413
+ )}
414
+ </nav>
415
+ )
416
+ }
417
+
418
+ /**
419
+ * Scrollable wrapper that exposes its scrollRef via a render prop so
420
+ * the consuming header (PageShell's internal PageHeader) can subscribe
421
+ * to scroll for the stuck-state effect.
422
+ */
423
+ export function AppScroll({
424
+ children,
425
+ className,
426
+ }: {
427
+ children: (scrollRef: RefObject<HTMLDivElement | null>) => ReactNode
428
+ className?: string
429
+ }) {
430
+ const scrollRef = useRef<HTMLDivElement>(null)
431
+ return (
432
+ <div
433
+ ref={scrollRef}
434
+ data-app-scroll="true"
435
+ className={cn('flex-1 overflow-y-auto', className)}
436
+ >
437
+ {children(scrollRef)}
438
+ </div>
439
+ )
440
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Pagination — "Showing X-Y of Z" + prev/next buttons.
3
+ *
4
+ * Promoted in ADR-022 (Phase 3c Discovery reports table, first
5
+ * committed consumer). Cross-batch consumers: Phase 3b contacts
6
+ * (when >50), Phase 8 keywords table, Phase 12 SEMSpec keywords tab.
7
+ *
8
+ * Stateless — parent owns currentPage state and provides onPageChange.
9
+ *
10
+ * Token discipline: all colors via public tokens.
11
+ */
12
+
13
+ import { ChevronLeft, ChevronRight } from 'lucide-react'
14
+
15
+ export interface PaginationProps {
16
+ /** 1-indexed current page. */
17
+ currentPage: number
18
+ /** Items per page. */
19
+ pageSize: number
20
+ /** Total item count across all pages. */
21
+ totalItems: number
22
+ onPageChange: (page: number) => void
23
+ }
24
+
25
+ export function Pagination({
26
+ currentPage,
27
+ pageSize,
28
+ totalItems,
29
+ onPageChange,
30
+ }: PaginationProps) {
31
+ const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
32
+ const firstItem = totalItems === 0 ? 0 : (currentPage - 1) * pageSize + 1
33
+ const lastItem = Math.min(currentPage * pageSize, totalItems)
34
+ const canPrev = currentPage > 1
35
+ const canNext = currentPage < totalPages
36
+
37
+ if (totalPages <= 1) return null
38
+
39
+ return (
40
+ <div className="flex items-center justify-between gap-3 py-2">
41
+ <div
42
+ className="text-[12px] tabular-nums"
43
+ style={{ color: 'rgb(var(--text-tertiary))' }}
44
+ >
45
+ Showing {firstItem.toLocaleString()}–{lastItem.toLocaleString()} of{' '}
46
+ {totalItems.toLocaleString()}
47
+ </div>
48
+ <div className="flex items-center gap-2">
49
+ <span
50
+ className="text-[12px] tabular-nums"
51
+ style={{ color: 'rgb(var(--text-tertiary))' }}
52
+ >
53
+ Page {currentPage} of {totalPages}
54
+ </span>
55
+ <button
56
+ type="button"
57
+ aria-label="Previous page"
58
+ disabled={!canPrev}
59
+ onClick={() => onPageChange(currentPage - 1)}
60
+ className="inline-flex h-7 w-7 items-center justify-center transition-colors disabled:cursor-not-allowed disabled:opacity-40"
61
+ style={{
62
+ background: 'rgb(var(--surface-overlay))',
63
+ color: 'rgb(var(--text-secondary))',
64
+ borderRadius: 'var(--radius-sm)',
65
+ }}
66
+ >
67
+ <ChevronLeft className="h-4 w-4" />
68
+ </button>
69
+ <button
70
+ type="button"
71
+ aria-label="Next page"
72
+ disabled={!canNext}
73
+ onClick={() => onPageChange(currentPage + 1)}
74
+ className="inline-flex h-7 w-7 items-center justify-center transition-colors disabled:cursor-not-allowed disabled:opacity-40"
75
+ style={{
76
+ background: 'rgb(var(--surface-overlay))',
77
+ color: 'rgb(var(--text-secondary))',
78
+ borderRadius: 'var(--radius-sm)',
79
+ }}
80
+ >
81
+ <ChevronRight className="h-4 w-4" />
82
+ </button>
83
+ </div>
84
+ </div>
85
+ )
86
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * PillButton — toggle-able filter pill with active / inactive states.
3
+ *
4
+ * Promoted in ADR-024 (Phase 12 SEMSpec keywords + extensions tab
5
+ * filters, first committed consumer). Cross-batch consumers: Phase 8
6
+ * Keywords (competition + brand-type filters).
7
+ *
8
+ * Distinct from <Badge> (read-only labeled chip) — PillButton is
9
+ * always clickable and reflects active-state via accent tone.
10
+ *
11
+ * Compose 1+ PillButton inside a lens-local <FilterGroup> wrapper
12
+ * when grouped with a label.
13
+ *
14
+ * Token discipline: all colors via public tokens.
15
+ */
16
+
17
+ import type { ReactNode } from 'react'
18
+ import type { BadgeTone } from './badge'
19
+
20
+ const ACTIVE_STYLE: Record<BadgeTone, { bg: string; color: string }> = {
21
+ neutral: {
22
+ bg: 'rgb(var(--accent))',
23
+ color: 'rgb(var(--accent-foreground, 255 255 255))',
24
+ },
25
+ accent: {
26
+ bg: 'rgb(var(--accent))',
27
+ color: 'rgb(var(--accent-foreground, 255 255 255))',
28
+ },
29
+ success: {
30
+ bg: 'rgb(var(--success))',
31
+ color: 'rgb(var(--success-bg))',
32
+ },
33
+ warning: {
34
+ bg: 'rgb(var(--warning))',
35
+ color: 'rgb(var(--warning-bg))',
36
+ },
37
+ destructive: {
38
+ bg: 'rgb(var(--destructive))',
39
+ color: 'rgb(var(--destructive-foreground))',
40
+ },
41
+ info: {
42
+ bg: 'rgb(var(--info))',
43
+ color: 'rgb(var(--info-bg))',
44
+ },
45
+ }
46
+
47
+ export interface PillButtonProps {
48
+ active: boolean
49
+ onClick: () => void
50
+ children: ReactNode
51
+ /** Size variant. `sm` = compact (12px), `md` = default (13px). */
52
+ size?: 'sm' | 'md'
53
+ /** Optional active-state tone. Default = accent. */
54
+ tone?: BadgeTone
55
+ /** Optional leading icon (12-14px). */
56
+ icon?: ReactNode
57
+ disabled?: boolean
58
+ }
59
+
60
+ export function PillButton({
61
+ active,
62
+ onClick,
63
+ children,
64
+ size = 'md',
65
+ tone = 'accent',
66
+ icon,
67
+ disabled,
68
+ }: PillButtonProps) {
69
+ const sizeClass = size === 'sm' ? 'text-[12px] px-2.5 h-7' : 'text-[13px] px-3 h-8'
70
+ const activeStyle = ACTIVE_STYLE[tone]
71
+
72
+ return (
73
+ <button
74
+ type="button"
75
+ aria-pressed={active}
76
+ disabled={disabled}
77
+ onClick={onClick}
78
+ className={`inline-flex items-center gap-1.5 font-medium border transition-colors ${sizeClass} ${disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'}`}
79
+ style={
80
+ active
81
+ ? {
82
+ background: activeStyle.bg,
83
+ color: activeStyle.color,
84
+ borderColor: activeStyle.bg,
85
+ borderRadius: 'var(--radius-full)',
86
+ }
87
+ : {
88
+ background: 'transparent',
89
+ color: 'rgb(var(--text-secondary))',
90
+ borderColor: 'rgb(var(--border))',
91
+ borderRadius: 'var(--radius-full)',
92
+ }
93
+ }
94
+ >
95
+ {icon && (
96
+ <span className="flex-shrink-0" aria-hidden>
97
+ {icon}
98
+ </span>
99
+ )}
100
+ {children}
101
+ </button>
102
+ )
103
+ }