@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,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
|
+
}
|