@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
package/src/format.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Number-formatting helpers — small set of pure, zero-dep utilities
|
|
3
|
+
* shared by the `<CalculatorShell>` consumers (ROAS / ROI / CTR / CPC
|
|
4
|
+
* / CPM / Conversion Rate and the future ad-performance + customer/
|
|
5
|
+
* business calculators).
|
|
6
|
+
*
|
|
7
|
+
* Locked by ADR-030 D6 (the helpers' existence + signatures) and
|
|
8
|
+
* sharpened by ADR-058 D8 (re-exported from `@lovett/ui` alongside the
|
|
9
|
+
* promoted `<CalculatorShell>`). Implementation rules:
|
|
10
|
+
*
|
|
11
|
+
* • Garbage in → `'—'` out. NaN, ±Infinity, null, undefined all
|
|
12
|
+
* produce the em-dash string. Calculator UI should never crash on
|
|
13
|
+
* partial input (empty inputs, zero divisors).
|
|
14
|
+
* • Uses `Intl.NumberFormat('en-US', ...)` under the hood — proper
|
|
15
|
+
* locale-aware separators, no manual `toFixed` rounding.
|
|
16
|
+
* • `formatPercent` expects PERCENT UNITS, not a 0–1 fraction. Pass
|
|
17
|
+
* `2.45` to get `"2.45%"`. Passing `0.0245` would yield `"0.02%"`.
|
|
18
|
+
*
|
|
19
|
+
* Zero runtime deps — `Intl.NumberFormat` is a built-in.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const EM_DASH = '—'
|
|
23
|
+
|
|
24
|
+
function invalid(n: unknown): boolean {
|
|
25
|
+
return (
|
|
26
|
+
typeof n !== 'number' ||
|
|
27
|
+
Number.isNaN(n) ||
|
|
28
|
+
!Number.isFinite(n)
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface FormatOptions {
|
|
33
|
+
/** Decimal places. Defaults vary per helper (see each helper's JSDoc). */
|
|
34
|
+
decimals?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Format a number as USD currency.
|
|
39
|
+
*
|
|
40
|
+
* formatCurrency(42.5) → "$42.50"
|
|
41
|
+
* formatCurrency(1234567.89) → "$1,234,567.89"
|
|
42
|
+
* formatCurrency(NaN) → "—"
|
|
43
|
+
*
|
|
44
|
+
* Default decimals: 2.
|
|
45
|
+
*/
|
|
46
|
+
export function formatCurrency(n: number, opts: FormatOptions = {}): string {
|
|
47
|
+
if (invalid(n)) return EM_DASH
|
|
48
|
+
const decimals = opts.decimals ?? 2
|
|
49
|
+
return new Intl.NumberFormat('en-US', {
|
|
50
|
+
style: 'currency',
|
|
51
|
+
currency: 'USD',
|
|
52
|
+
minimumFractionDigits: decimals,
|
|
53
|
+
maximumFractionDigits: decimals,
|
|
54
|
+
}).format(n)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Format a number as a percent. Input is in PERCENT UNITS (i.e. pass
|
|
59
|
+
* `2.45` to get `"2.45%"`, NOT `0.0245`). This matches the way the
|
|
60
|
+
* calculator routes compute their results (e.g. CTR = clicks ÷
|
|
61
|
+
* impressions × 100 — the × 100 already happened upstream).
|
|
62
|
+
*
|
|
63
|
+
* formatPercent(2.45) → "2.45%"
|
|
64
|
+
* formatPercent(3.2, { decimals: 1 }) → "3.2%"
|
|
65
|
+
* formatPercent(NaN) → "—"
|
|
66
|
+
*
|
|
67
|
+
* Default decimals: 2.
|
|
68
|
+
*/
|
|
69
|
+
export function formatPercent(n: number, opts: FormatOptions = {}): string {
|
|
70
|
+
if (invalid(n)) return EM_DASH
|
|
71
|
+
const decimals = opts.decimals ?? 2
|
|
72
|
+
// Use 'decimal' style with manual '%' suffix rather than 'percent' —
|
|
73
|
+
// 'percent' would re-multiply by 100, which is the wrong contract for
|
|
74
|
+
// this helper (see JSDoc above).
|
|
75
|
+
const body = new Intl.NumberFormat('en-US', {
|
|
76
|
+
minimumFractionDigits: decimals,
|
|
77
|
+
maximumFractionDigits: decimals,
|
|
78
|
+
}).format(n)
|
|
79
|
+
return `${body}%`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Format a number as a ratio with the `x` suffix. Used by ROAS.
|
|
84
|
+
*
|
|
85
|
+
* formatRatio(4.25) → "4.25x"
|
|
86
|
+
* formatRatio(10, { decimals: 0 }) → "10x"
|
|
87
|
+
* formatRatio(Infinity) → "—"
|
|
88
|
+
*
|
|
89
|
+
* Default decimals: 2.
|
|
90
|
+
*/
|
|
91
|
+
export function formatRatio(n: number, opts: FormatOptions = {}): string {
|
|
92
|
+
if (invalid(n)) return EM_DASH
|
|
93
|
+
const decimals = opts.decimals ?? 2
|
|
94
|
+
const body = new Intl.NumberFormat('en-US', {
|
|
95
|
+
minimumFractionDigits: decimals,
|
|
96
|
+
maximumFractionDigits: decimals,
|
|
97
|
+
}).format(n)
|
|
98
|
+
return `${body}x`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Format a number with thousand-separators and configurable decimals.
|
|
103
|
+
*
|
|
104
|
+
* formatNumber(1234567) → "1,234,567"
|
|
105
|
+
* formatNumber(1234.567, { decimals: 2 }) → "1,234.57"
|
|
106
|
+
* formatNumber(NaN) → "—"
|
|
107
|
+
*
|
|
108
|
+
* Default decimals: 0.
|
|
109
|
+
*/
|
|
110
|
+
export function formatNumber(n: number, opts: FormatOptions = {}): string {
|
|
111
|
+
if (invalid(n)) return EM_DASH
|
|
112
|
+
const decimals = opts.decimals ?? 0
|
|
113
|
+
return new Intl.NumberFormat('en-US', {
|
|
114
|
+
minimumFractionDigits: decimals,
|
|
115
|
+
maximumFractionDigits: decimals,
|
|
116
|
+
}).format(n)
|
|
117
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FrameStack — slate outer-frame wrapper for the shell/tray pattern.
|
|
3
|
+
*
|
|
4
|
+
* Single purpose: wrap 2+ inner cards as ONE visual group. Renders a
|
|
5
|
+
* slate-tinted outer frame (--card-frame-bg, --border-card) holding
|
|
6
|
+
* the children with 10 px outer padding and 10 px gap between them.
|
|
7
|
+
*
|
|
8
|
+
* ┌─────────────────────────────────┐ ← <FrameStack>
|
|
9
|
+
* │ ┌─────────────────────────────┐ │ slate bg (--card-frame-bg)
|
|
10
|
+
* │ │ Card / SectionCard 01 │ │ 10 px padding, 10 px gap
|
|
11
|
+
* │ └─────────────────────────────┘ │ 1 px --border-card outline
|
|
12
|
+
* │ ┌─────────────────────────────┐ │ --radius-xl (20 px) outer
|
|
13
|
+
* │ │ Card / SectionCard 02 │ │
|
|
14
|
+
* │ └─────────────────────────────┘ │ ← child Card (tray)
|
|
15
|
+
* │ ┌─────────────────────────────┐ │ white bg, 1 px border,
|
|
16
|
+
* │ │ Card / SectionCard 03 │ │ --radius-lg (16 px)
|
|
17
|
+
* │ └─────────────────────────────┘ │
|
|
18
|
+
* └─────────────────────────────────┘
|
|
19
|
+
*
|
|
20
|
+
* Why a dedicated primitive instead of <Shell>:
|
|
21
|
+
* <Shell> is overloaded in the workspace ("the route shell" / "the
|
|
22
|
+
* primitive" / "the data-framed div"). When a directive says "use a
|
|
23
|
+
* shell" or "no shell," <Shell> consumers can't reliably tell which
|
|
24
|
+
* one is meant. FrameStack does exactly one job, named clearly. If
|
|
25
|
+
* you want this pattern, wrap. If you don't, don't.
|
|
26
|
+
*
|
|
27
|
+
* Anti-pattern (DO NOT replicate): rendering N <Card> / <SectionCard>
|
|
28
|
+
* elements as direct page-background children with `gap-3`. That's
|
|
29
|
+
* "free-floating cards," not the shell/tray pattern. Wrap them.
|
|
30
|
+
*
|
|
31
|
+
* Lens-level lock: `apps/workspace/src/lenses/meta-creative/CLAUDE.md`
|
|
32
|
+
* §"The shell/tray pattern" — the Meta Creative Builder uses this
|
|
33
|
+
* primitive for the 3-section form.
|
|
34
|
+
*
|
|
35
|
+
* Token discipline: internal tokens (--card-frame-bg, --border-card)
|
|
36
|
+
* are touched HERE per ADR-006 D2 — primitives live under
|
|
37
|
+
* `packages/ui/` and may reference internal tokens.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { forwardRef, type HTMLAttributes } from 'react'
|
|
41
|
+
import { cn } from './lib/utils'
|
|
42
|
+
|
|
43
|
+
export interface FrameStackProps extends HTMLAttributes<HTMLDivElement> {
|
|
44
|
+
/** Visual gap between child cards. Defaults to 10 px to match the
|
|
45
|
+
* prototype's spec. Override only with a token value
|
|
46
|
+
* (e.g. `'var(--space-3)'` for 12 px) — never a literal. */
|
|
47
|
+
gap?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const FrameStack = forwardRef<HTMLDivElement, FrameStackProps>(
|
|
51
|
+
function FrameStack({ className, style, gap = '10px', children, ...rest }, ref) {
|
|
52
|
+
return (
|
|
53
|
+
<div
|
|
54
|
+
ref={ref}
|
|
55
|
+
data-framed="true"
|
|
56
|
+
className={cn('flex flex-col', className)}
|
|
57
|
+
style={{
|
|
58
|
+
background: 'rgb(var(--card-frame-bg))',
|
|
59
|
+
border: '1px solid rgb(var(--border-card))',
|
|
60
|
+
borderRadius: 'var(--radius-xl)',
|
|
61
|
+
padding: '10px',
|
|
62
|
+
gap,
|
|
63
|
+
...style,
|
|
64
|
+
}}
|
|
65
|
+
{...rest}
|
|
66
|
+
>
|
|
67
|
+
{children}
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
FrameStack.displayName = 'FrameStack'
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HeroFormCard — centered hero-layout form scaffold.
|
|
3
|
+
*
|
|
4
|
+
* Shape:
|
|
5
|
+
*
|
|
6
|
+
* ┌──────────────────────────────────────────────────┐
|
|
7
|
+
* │ EYEBROW (uppercase, accent) │ ← header (outside card)
|
|
8
|
+
* │ Title │
|
|
9
|
+
* │ Subtitle / lede (muted) │
|
|
10
|
+
* ├──────────────────────────────────────────────────┤
|
|
11
|
+
* │ ╭──────────────────────────────────────────────╮ │
|
|
12
|
+
* │ │ Section title (optional icon) │ │ ← card body
|
|
13
|
+
* │ │ ┌────────────────────────────────────────┐ │ │
|
|
14
|
+
* │ │ │ Field label *required │ │ │
|
|
15
|
+
* │ │ │ <Input /> │ │ │
|
|
16
|
+
* │ │ └────────────────────────────────────────┘ │ │
|
|
17
|
+
* │ │ ... │ │
|
|
18
|
+
* │ ╰──────────────────────────────────────────────╯ │
|
|
19
|
+
* │ helper text (optional, muted, centered) │
|
|
20
|
+
* ├──────────────────────────────────────────────────┤
|
|
21
|
+
* │ Cancel Primary │ ← footer (outside card)
|
|
22
|
+
* └──────────────────────────────────────────────────┘
|
|
23
|
+
*
|
|
24
|
+
* Compound sub-components:
|
|
25
|
+
* • HeroFormCard.Section — labeled section with optional icon slot
|
|
26
|
+
* • HeroFormCard.Field — labeled field row with required / optional
|
|
27
|
+
* chip + error text
|
|
28
|
+
* • HeroFormCard.Advanced — collapsible <details> styled to match
|
|
29
|
+
* the rest of the card
|
|
30
|
+
* • HeroFormCard.Footer — action cluster (right-aligned)
|
|
31
|
+
* • HeroFormCard.Banner — informational strip above the body (e.g.,
|
|
32
|
+
* "From Quick Research · 80 keywords")
|
|
33
|
+
*
|
|
34
|
+
* Icon-agnostic: the primitive does NOT import or hard-code any
|
|
35
|
+
* specific icons. Consumers pass any Lucide (or custom) ReactNode as
|
|
36
|
+
* `icon` props on Section / Field / Banner.
|
|
37
|
+
*
|
|
38
|
+
* Token discipline: every spacing, radius, color, font-weight derives
|
|
39
|
+
* from a CSS variable in tokens.css. No hex literals.
|
|
40
|
+
*
|
|
41
|
+
* First consumers: NewSetPage + NewGroupPage. Designed to also fit the
|
|
42
|
+
* SEM Spec input lens — same hero-card pattern with a different body.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import {
|
|
46
|
+
forwardRef,
|
|
47
|
+
type FormEvent,
|
|
48
|
+
type FormHTMLAttributes,
|
|
49
|
+
type HTMLAttributes,
|
|
50
|
+
type ReactNode,
|
|
51
|
+
} from 'react'
|
|
52
|
+
import { cn } from './lib/utils'
|
|
53
|
+
import { Shell } from './shell'
|
|
54
|
+
|
|
55
|
+
// ─── HeroFormCard root ──────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export interface HeroFormCardProps
|
|
58
|
+
extends Omit<FormHTMLAttributes<HTMLFormElement>, 'title' | 'onSubmit'> {
|
|
59
|
+
/** Small accent text above the title, e.g. "Brand · Research". */
|
|
60
|
+
eyebrow?: ReactNode
|
|
61
|
+
/** The page-level title. Required. */
|
|
62
|
+
title: ReactNode
|
|
63
|
+
/** Supporting copy under the title. */
|
|
64
|
+
subtitle?: ReactNode
|
|
65
|
+
/** Optional helper text rendered between the body and the footer
|
|
66
|
+
* (centered, muted). Good for "Name is required · everything else
|
|
67
|
+
* is optional" affordances. */
|
|
68
|
+
helperText?: ReactNode
|
|
69
|
+
/** Body — typically composed of Section + Field. */
|
|
70
|
+
children: ReactNode
|
|
71
|
+
/** Footer action cluster (right-aligned). */
|
|
72
|
+
footer?: ReactNode
|
|
73
|
+
/** Container max-width in px (default 720). */
|
|
74
|
+
maxWidth?: number
|
|
75
|
+
/** When true the wrapping element is a <form> with onSubmit attached
|
|
76
|
+
* (default). When false, renders a <div> — useful when an outer form
|
|
77
|
+
* owns submission. */
|
|
78
|
+
asForm?: boolean
|
|
79
|
+
/** Form submit handler. Ignored when `asForm` is false. */
|
|
80
|
+
onSubmit?: (event: FormEvent<HTMLFormElement>) => void
|
|
81
|
+
className?: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function HeroFormCard({
|
|
85
|
+
eyebrow,
|
|
86
|
+
title,
|
|
87
|
+
subtitle,
|
|
88
|
+
helperText,
|
|
89
|
+
children,
|
|
90
|
+
footer,
|
|
91
|
+
maxWidth = 720,
|
|
92
|
+
asForm = true,
|
|
93
|
+
onSubmit,
|
|
94
|
+
className,
|
|
95
|
+
...rest
|
|
96
|
+
}: HeroFormCardProps) {
|
|
97
|
+
const bodyContent = (
|
|
98
|
+
<>
|
|
99
|
+
<div className="flex flex-col gap-2 pt-4 pb-7">
|
|
100
|
+
{eyebrow && (
|
|
101
|
+
<div
|
|
102
|
+
className="text-[11px] font-bold uppercase tracking-[0.12em]"
|
|
103
|
+
style={{ color: 'rgb(var(--accent))' }}
|
|
104
|
+
>
|
|
105
|
+
{eyebrow}
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
<h1
|
|
109
|
+
className="text-[26px] font-bold tracking-[-0.02em] leading-tight"
|
|
110
|
+
style={{ color: 'rgb(var(--foreground))' }}
|
|
111
|
+
>
|
|
112
|
+
{title}
|
|
113
|
+
</h1>
|
|
114
|
+
{subtitle && (
|
|
115
|
+
<p
|
|
116
|
+
className="text-[14px] leading-[1.5] mt-1"
|
|
117
|
+
style={{
|
|
118
|
+
color: 'rgb(var(--text-tertiary))',
|
|
119
|
+
maxWidth: 'var(--prose-wide)',
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
{subtitle}
|
|
123
|
+
</p>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<Shell centered={false}>{children}</Shell>
|
|
128
|
+
|
|
129
|
+
{helperText && (
|
|
130
|
+
<p
|
|
131
|
+
className="text-[12px] mt-3"
|
|
132
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
133
|
+
>
|
|
134
|
+
{helperText}
|
|
135
|
+
</p>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
{footer && (
|
|
139
|
+
<div className="flex items-center justify-end gap-2 mt-6">
|
|
140
|
+
{footer}
|
|
141
|
+
</div>
|
|
142
|
+
)}
|
|
143
|
+
</>
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if (!asForm) {
|
|
147
|
+
return (
|
|
148
|
+
<div
|
|
149
|
+
className={cn('mx-auto w-full', className)}
|
|
150
|
+
style={{ maxWidth }}
|
|
151
|
+
>
|
|
152
|
+
{bodyContent}
|
|
153
|
+
</div>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<form
|
|
159
|
+
onSubmit={onSubmit}
|
|
160
|
+
className={cn('mx-auto w-full', className)}
|
|
161
|
+
style={{ maxWidth }}
|
|
162
|
+
{...rest}
|
|
163
|
+
>
|
|
164
|
+
{bodyContent}
|
|
165
|
+
</form>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─── HeroFormCard.Section ───────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
export interface HeroFormCardSectionProps
|
|
172
|
+
extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
|
|
173
|
+
/** Section heading. Optional — omit for an untitled body. */
|
|
174
|
+
title?: ReactNode
|
|
175
|
+
/** Optional icon node (Lucide or custom). Sized by the consumer. */
|
|
176
|
+
icon?: ReactNode
|
|
177
|
+
/** Optional supporting copy under the section title. */
|
|
178
|
+
description?: ReactNode
|
|
179
|
+
/** Optional content rendered to the RIGHT of the section title row.
|
|
180
|
+
* Good for inline helper text ("Name is required · everything else
|
|
181
|
+
* is optional") or action chips. */
|
|
182
|
+
trailing?: ReactNode
|
|
183
|
+
children: ReactNode
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function Section({
|
|
187
|
+
title,
|
|
188
|
+
icon,
|
|
189
|
+
description,
|
|
190
|
+
trailing,
|
|
191
|
+
children,
|
|
192
|
+
className,
|
|
193
|
+
...rest
|
|
194
|
+
}: HeroFormCardSectionProps) {
|
|
195
|
+
// `icon` is accepted by the API for back-compat but not used in the
|
|
196
|
+
// shell-tray rendering — the prototype's tray headers are
|
|
197
|
+
// icon-free. Surface lint-friendly by void-ing the binding.
|
|
198
|
+
void icon
|
|
199
|
+
|
|
200
|
+
const hasHeader = Boolean(title || description || trailing)
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<Shell.Tray className={className} {...rest}>
|
|
204
|
+
{hasHeader && (
|
|
205
|
+
<Shell.TrayHeader
|
|
206
|
+
title={title ?? ''}
|
|
207
|
+
{...(trailing ? { meta: trailing } : {})}
|
|
208
|
+
{...(description ? { description } : {})}
|
|
209
|
+
/>
|
|
210
|
+
)}
|
|
211
|
+
<Shell.TrayBody spacing="form">{children}</Shell.TrayBody>
|
|
212
|
+
</Shell.Tray>
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ─── HeroFormCard.Field ─────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
export interface HeroFormCardFieldProps {
|
|
219
|
+
/** Visible field label. */
|
|
220
|
+
label: ReactNode
|
|
221
|
+
/** HTML id of the underlying control — wires <label htmlFor>. */
|
|
222
|
+
htmlFor?: string
|
|
223
|
+
required?: boolean
|
|
224
|
+
optional?: boolean
|
|
225
|
+
/** Inline error string. Renders below the field. */
|
|
226
|
+
error?: string | null
|
|
227
|
+
/** Optional helper hint below the field (when no error). */
|
|
228
|
+
hint?: ReactNode
|
|
229
|
+
children: ReactNode
|
|
230
|
+
className?: string
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function Field({
|
|
234
|
+
label,
|
|
235
|
+
htmlFor,
|
|
236
|
+
required,
|
|
237
|
+
optional,
|
|
238
|
+
error,
|
|
239
|
+
hint,
|
|
240
|
+
children,
|
|
241
|
+
className,
|
|
242
|
+
}: HeroFormCardFieldProps) {
|
|
243
|
+
return (
|
|
244
|
+
<div className={cn('flex flex-col gap-1.5', className)}>
|
|
245
|
+
<label
|
|
246
|
+
htmlFor={htmlFor}
|
|
247
|
+
className="text-[12px] font-semibold tracking-[0.02em]"
|
|
248
|
+
style={{ color: 'rgb(var(--foreground))' }}
|
|
249
|
+
>
|
|
250
|
+
{label}
|
|
251
|
+
{required && (
|
|
252
|
+
<span
|
|
253
|
+
className="ml-1"
|
|
254
|
+
style={{ color: 'rgb(var(--destructive))' }}
|
|
255
|
+
aria-hidden="true"
|
|
256
|
+
>
|
|
257
|
+
*
|
|
258
|
+
</span>
|
|
259
|
+
)}
|
|
260
|
+
{optional && (
|
|
261
|
+
<span
|
|
262
|
+
className="ml-1 font-normal"
|
|
263
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
264
|
+
>
|
|
265
|
+
(optional)
|
|
266
|
+
</span>
|
|
267
|
+
)}
|
|
268
|
+
</label>
|
|
269
|
+
{children}
|
|
270
|
+
{error ? (
|
|
271
|
+
<p
|
|
272
|
+
className="text-[12px]"
|
|
273
|
+
role="alert"
|
|
274
|
+
style={{ color: 'rgb(var(--destructive))' }}
|
|
275
|
+
>
|
|
276
|
+
{error}
|
|
277
|
+
</p>
|
|
278
|
+
) : hint ? (
|
|
279
|
+
<p
|
|
280
|
+
className="text-[12px]"
|
|
281
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
282
|
+
>
|
|
283
|
+
{hint}
|
|
284
|
+
</p>
|
|
285
|
+
) : null}
|
|
286
|
+
</div>
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ─── HeroFormCard.FieldRow ──────────────────────────────────────────
|
|
291
|
+
// Two-up grid for side-by-side fields. Collapses to single column on
|
|
292
|
+
// narrow viewports.
|
|
293
|
+
|
|
294
|
+
function FieldRow({
|
|
295
|
+
children,
|
|
296
|
+
className,
|
|
297
|
+
...rest
|
|
298
|
+
}: HTMLAttributes<HTMLDivElement>) {
|
|
299
|
+
return (
|
|
300
|
+
<div
|
|
301
|
+
className={cn('grid grid-cols-1 sm:grid-cols-2 gap-4', className)}
|
|
302
|
+
{...rest}
|
|
303
|
+
>
|
|
304
|
+
{children}
|
|
305
|
+
</div>
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ─── HeroFormCard.Banner ────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
export interface HeroFormCardBannerProps {
|
|
312
|
+
/** Optional leading icon. */
|
|
313
|
+
icon?: ReactNode
|
|
314
|
+
/** Body content — text + optional inline marks. */
|
|
315
|
+
children: ReactNode
|
|
316
|
+
/** Visual tone. Default `info`. */
|
|
317
|
+
tone?: 'info' | 'accent' | 'success' | 'warning' | 'destructive'
|
|
318
|
+
className?: string
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const BANNER_TONE: Record<
|
|
322
|
+
NonNullable<HeroFormCardBannerProps['tone']>,
|
|
323
|
+
{ bg: string; border: string; icon: string }
|
|
324
|
+
> = {
|
|
325
|
+
info: {
|
|
326
|
+
bg: 'rgb(var(--surface-overlay-soft))',
|
|
327
|
+
border: 'rgb(var(--border))',
|
|
328
|
+
icon: 'rgb(var(--text-secondary))',
|
|
329
|
+
},
|
|
330
|
+
accent: {
|
|
331
|
+
bg: 'rgb(var(--accent-subtle))',
|
|
332
|
+
border: 'rgb(var(--accent-muted))',
|
|
333
|
+
icon: 'rgb(var(--accent))',
|
|
334
|
+
},
|
|
335
|
+
success: {
|
|
336
|
+
bg: 'rgb(var(--success-bg))',
|
|
337
|
+
border: 'rgb(var(--success))',
|
|
338
|
+
icon: 'rgb(var(--success))',
|
|
339
|
+
},
|
|
340
|
+
warning: {
|
|
341
|
+
bg: 'rgb(var(--warning-bg))',
|
|
342
|
+
border: 'rgb(var(--warning))',
|
|
343
|
+
icon: 'rgb(var(--warning))',
|
|
344
|
+
},
|
|
345
|
+
destructive: {
|
|
346
|
+
bg: 'rgb(var(--surface-overlay-soft))',
|
|
347
|
+
border: 'rgb(var(--ring-error))',
|
|
348
|
+
icon: 'rgb(var(--destructive))',
|
|
349
|
+
},
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function Banner({
|
|
353
|
+
icon,
|
|
354
|
+
children,
|
|
355
|
+
tone = 'info',
|
|
356
|
+
className,
|
|
357
|
+
}: HeroFormCardBannerProps) {
|
|
358
|
+
const colors = BANNER_TONE[tone]
|
|
359
|
+
return (
|
|
360
|
+
<div
|
|
361
|
+
className={cn('flex items-start gap-3 p-3', className)}
|
|
362
|
+
style={{
|
|
363
|
+
background: colors.bg,
|
|
364
|
+
border: `1px solid ${colors.border}`,
|
|
365
|
+
borderRadius: 'var(--radius-md)',
|
|
366
|
+
}}
|
|
367
|
+
>
|
|
368
|
+
{icon && (
|
|
369
|
+
<span
|
|
370
|
+
className="inline-flex shrink-0 mt-0.5"
|
|
371
|
+
style={{ color: colors.icon }}
|
|
372
|
+
aria-hidden="true"
|
|
373
|
+
>
|
|
374
|
+
{icon}
|
|
375
|
+
</span>
|
|
376
|
+
)}
|
|
377
|
+
<div
|
|
378
|
+
className="text-[13px] leading-[1.45]"
|
|
379
|
+
style={{ color: 'rgb(var(--text-secondary))' }}
|
|
380
|
+
>
|
|
381
|
+
{children}
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ─── HeroFormCard.Advanced ──────────────────────────────────────────
|
|
388
|
+
// Collapsible "Advanced options" disclosure — native <details> styled
|
|
389
|
+
// to match the card chrome.
|
|
390
|
+
|
|
391
|
+
export interface HeroFormCardAdvancedProps {
|
|
392
|
+
summary?: ReactNode
|
|
393
|
+
/** Optional icon node next to the summary. */
|
|
394
|
+
icon?: ReactNode
|
|
395
|
+
defaultOpen?: boolean
|
|
396
|
+
children: ReactNode
|
|
397
|
+
className?: string
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function Advanced({
|
|
401
|
+
summary = 'Advanced options',
|
|
402
|
+
icon,
|
|
403
|
+
defaultOpen,
|
|
404
|
+
children,
|
|
405
|
+
className,
|
|
406
|
+
}: HeroFormCardAdvancedProps) {
|
|
407
|
+
return (
|
|
408
|
+
<details
|
|
409
|
+
open={defaultOpen}
|
|
410
|
+
className={cn('group flex flex-col gap-3', className)}
|
|
411
|
+
>
|
|
412
|
+
<summary
|
|
413
|
+
className="flex items-center gap-1.5 cursor-pointer select-none text-[12px] font-semibold uppercase tracking-[0.08em] list-none [&::-webkit-details-marker]:hidden"
|
|
414
|
+
style={{ color: 'rgb(var(--text-muted))' }}
|
|
415
|
+
>
|
|
416
|
+
{icon && (
|
|
417
|
+
<span className="inline-flex shrink-0" aria-hidden="true">
|
|
418
|
+
{icon}
|
|
419
|
+
</span>
|
|
420
|
+
)}
|
|
421
|
+
<span>{summary}</span>
|
|
422
|
+
</summary>
|
|
423
|
+
<div className="flex flex-col gap-4 pt-1">{children}</div>
|
|
424
|
+
</details>
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ─── HeroFormCard.Footer ────────────────────────────────────────────
|
|
429
|
+
// Footer rendering is folded into the root layout via the `footer`
|
|
430
|
+
// prop, but consumers sometimes want to render the footer cluster
|
|
431
|
+
// inline (e.g., right below a banner for inline confirmation flows).
|
|
432
|
+
// Footer compound provides the same styling for that use case.
|
|
433
|
+
|
|
434
|
+
function Footer({
|
|
435
|
+
children,
|
|
436
|
+
className,
|
|
437
|
+
...rest
|
|
438
|
+
}: HTMLAttributes<HTMLDivElement>) {
|
|
439
|
+
return (
|
|
440
|
+
<div
|
|
441
|
+
className={cn('flex items-center justify-end gap-2', className)}
|
|
442
|
+
{...rest}
|
|
443
|
+
>
|
|
444
|
+
{children}
|
|
445
|
+
</div>
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ─── Compound binding ───────────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
const HeroFormCardCompound = Object.assign(
|
|
452
|
+
forwardRef<HTMLFormElement, HeroFormCardProps>(
|
|
453
|
+
function HeroFormCardForwardRef(props, _ref) {
|
|
454
|
+
// _ref is intentionally unused — no consumer needs focus-management
|
|
455
|
+
// on the <form> yet. forwardRef is kept on the public surface so a
|
|
456
|
+
// ref can be wired later without a breaking API change. React requires
|
|
457
|
+
// the two-parameter signature even when ref is not forwarded.
|
|
458
|
+
return <HeroFormCard {...props} />
|
|
459
|
+
},
|
|
460
|
+
),
|
|
461
|
+
{
|
|
462
|
+
Section,
|
|
463
|
+
Field,
|
|
464
|
+
FieldRow,
|
|
465
|
+
Banner,
|
|
466
|
+
Advanced,
|
|
467
|
+
Footer,
|
|
468
|
+
},
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
export { HeroFormCardCompound as HeroFormCard }
|