@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,79 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import type { CSSProperties } from 'react';
|
|
3
|
+
|
|
4
|
+
interface ProfileAvatarProps {
|
|
5
|
+
/** The pulled Meta page profile image. Empty/missing OR a load failure falls
|
|
6
|
+
* back to the brand-initial letter. */
|
|
7
|
+
src?: string | undefined;
|
|
8
|
+
/** Brand / page name — its first character is the letter fallback. */
|
|
9
|
+
name: string;
|
|
10
|
+
/** Letter-fallback background — a color OR a gradient (platform-specific).
|
|
11
|
+
* Defaults to Meta blue. */
|
|
12
|
+
background?: string;
|
|
13
|
+
/** Letter-fallback text color. Defaults to white. */
|
|
14
|
+
color?: string;
|
|
15
|
+
/** Letter-fallback font size in px. Defaults to 14. */
|
|
16
|
+
fontSize?: number;
|
|
17
|
+
/** Geometry applied to BOTH the image and the letter so they stay identical.
|
|
18
|
+
* Defaults to filling the parent circle (the caller owns the size / radius /
|
|
19
|
+
* ring); pass explicit width/height/margin/borderRadius for avatars that are
|
|
20
|
+
* not a plain parent-fill (e.g. Messenger). */
|
|
21
|
+
style?: CSSProperties;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const FILL: CSSProperties = { width: '100%', height: '100%' };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Unified advertiser avatar for every Meta / Instagram preview. Renders the
|
|
28
|
+
* pulled page profile image (object-cover), falling back to the brand's initial
|
|
29
|
+
* when the image is missing OR fails to load. Centralising the image↔letter
|
|
30
|
+
* logic here is what keeps every preview consistent — no preview can drift to a
|
|
31
|
+
* grey box (Search/Explore did) or an empty circle (a broken CDN URL did).
|
|
32
|
+
*/
|
|
33
|
+
export function ProfileAvatar({
|
|
34
|
+
src,
|
|
35
|
+
name,
|
|
36
|
+
background = 'rgb(24, 119, 242)',
|
|
37
|
+
color = 'white',
|
|
38
|
+
fontSize = 14,
|
|
39
|
+
style,
|
|
40
|
+
}: ProfileAvatarProps) {
|
|
41
|
+
const [failed, setFailed] = useState(false);
|
|
42
|
+
// A new src (e.g. brand switch) gets a fresh chance to load.
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
setFailed(false);
|
|
45
|
+
}, [src]);
|
|
46
|
+
|
|
47
|
+
const geom = style ?? FILL;
|
|
48
|
+
|
|
49
|
+
if (src && !failed) {
|
|
50
|
+
return (
|
|
51
|
+
<img
|
|
52
|
+
src={src}
|
|
53
|
+
alt={name}
|
|
54
|
+
style={{ objectFit: 'cover', display: 'block', ...geom }}
|
|
55
|
+
loading="lazy"
|
|
56
|
+
decoding="async"
|
|
57
|
+
onError={() => setFailed(true)}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div
|
|
64
|
+
aria-label={name}
|
|
65
|
+
style={{
|
|
66
|
+
display: 'flex',
|
|
67
|
+
alignItems: 'center',
|
|
68
|
+
justifyContent: 'center',
|
|
69
|
+
background,
|
|
70
|
+
color,
|
|
71
|
+
fontSize: `${fontSize}px`,
|
|
72
|
+
fontWeight: 600,
|
|
73
|
+
...geom,
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
{(name.trim().charAt(0) || '?').toUpperCase()}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PreviewAdData } from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Placeholder copy shown in a preview when the corresponding input field is
|
|
5
|
+
* empty — so a fresh ad reads as a complete ad instead of blank gaps. Only
|
|
6
|
+
* fills BLANK fields; any typed value passes through unchanged.
|
|
7
|
+
*/
|
|
8
|
+
export const PREVIEW_DEFAULTS = {
|
|
9
|
+
brandName: 'Your Brand',
|
|
10
|
+
primaryText: 'Your primary text will show up here.',
|
|
11
|
+
headline: 'Your headline',
|
|
12
|
+
displayLink: 'yoursite.com',
|
|
13
|
+
callToAction: 'Learn more',
|
|
14
|
+
} as const
|
|
15
|
+
|
|
16
|
+
/** Fill empty `PreviewAdData` copy fields with placeholders (image/media fields
|
|
17
|
+
* are left untouched — those have their own in-component placeholders). */
|
|
18
|
+
export function withPreviewDefaults(ad: PreviewAdData): PreviewAdData {
|
|
19
|
+
return {
|
|
20
|
+
...ad,
|
|
21
|
+
brandName: ad.brandName || PREVIEW_DEFAULTS.brandName,
|
|
22
|
+
primaryText: ad.primaryText || PREVIEW_DEFAULTS.primaryText,
|
|
23
|
+
headline: ad.headline || PREVIEW_DEFAULTS.headline,
|
|
24
|
+
displayLink: ad.displayLink || PREVIEW_DEFAULTS.displayLink,
|
|
25
|
+
callToAction: ad.callToAction || PREVIEW_DEFAULTS.callToAction,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// @lovett/ui/meta-previews — public surface.
|
|
2
|
+
//
|
|
3
|
+
// Promoted from docs/prototype/meta/preview-package/ per ADR-060 D9a
|
|
4
|
+
// (amended 2026-05-28). Pixel-accurate Meta + Instagram + Messenger
|
|
5
|
+
// preview renderers; zero coupling to stores, hooks, routers, or API
|
|
6
|
+
// clients. Designed for portable drop-in reuse across the workspace's
|
|
7
|
+
// Meta-related lenses.
|
|
8
|
+
//
|
|
9
|
+
// API contract: every renderer takes one prop, `adData: PreviewAdData`.
|
|
10
|
+
// Several also accept `device`, `platform`, `format`, or dominant-color
|
|
11
|
+
// tinting hints — see each component's prop signature for specifics.
|
|
12
|
+
|
|
13
|
+
export { CreativeMedia } from './CreativeMedia';
|
|
14
|
+
export { MetaFeedPreview } from './MetaFeedPreview';
|
|
15
|
+
export { MetaStoryPreview } from './MetaStoryPreview';
|
|
16
|
+
export { MetaReelPreview } from './MetaReelPreview';
|
|
17
|
+
export { MetaMessengerPreview } from './MetaMessengerPreview';
|
|
18
|
+
export { MetaRightColumnPreview } from './MetaRightColumnPreview';
|
|
19
|
+
export { MetaSearchPreview } from './MetaSearchPreview';
|
|
20
|
+
export { MetaInstreamPreview } from './MetaInstreamPreview';
|
|
21
|
+
export { InstagramFeedPreview } from './InstagramFeedPreview';
|
|
22
|
+
export { InstagramExplorePreview } from './InstagramExplorePreview';
|
|
23
|
+
export { MetaFeedCarousel } from './MetaFeedCarousel';
|
|
24
|
+
export { InstagramFeedCarousel } from './InstagramFeedCarousel';
|
|
25
|
+
|
|
26
|
+
export type {
|
|
27
|
+
PreviewAdData,
|
|
28
|
+
PreviewPlatform,
|
|
29
|
+
PreviewPlacement,
|
|
30
|
+
PreviewDevice,
|
|
31
|
+
PlacementPreview,
|
|
32
|
+
PreviewConfiguration,
|
|
33
|
+
CarouselCardView,
|
|
34
|
+
CarouselViewProps,
|
|
35
|
+
} from './types';
|
|
36
|
+
|
|
37
|
+
export { PLACEMENT_CONFIGS } from './types';
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// @lovett/ui — Meta / Instagram platform-preview types.
|
|
2
|
+
//
|
|
3
|
+
// Promoted from docs/prototype/meta/preview-package/types/previews.ts
|
|
4
|
+
// per ADR-060 D9a (amended 2026-05-28). Pixel-accurate FB+IG preview
|
|
5
|
+
// renderers consume `PreviewAdData`; the 10 renderers + this types
|
|
6
|
+
// module + the PLACEMENT_CONFIGS constant comprise the public surface
|
|
7
|
+
// of `@lovett/ui/meta-previews`.
|
|
8
|
+
|
|
9
|
+
import type { ReactNode } from 'react';
|
|
10
|
+
|
|
11
|
+
export type PreviewPlatform = 'facebook' | 'instagram' | 'messenger';
|
|
12
|
+
export type PreviewPlacement =
|
|
13
|
+
| 'feed'
|
|
14
|
+
| 'story'
|
|
15
|
+
| 'reel'
|
|
16
|
+
| 'inbox'
|
|
17
|
+
| 'instream'
|
|
18
|
+
| 'search'
|
|
19
|
+
| 'rightcolumn'
|
|
20
|
+
| 'explore';
|
|
21
|
+
export type PreviewDevice = 'mobile' | 'desktop';
|
|
22
|
+
|
|
23
|
+
export interface PreviewConfiguration {
|
|
24
|
+
platform: PreviewPlatform;
|
|
25
|
+
placement: PreviewPlacement;
|
|
26
|
+
device: PreviewDevice;
|
|
27
|
+
format: '1:1' | '4:5' | '9:16';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PreviewAdData {
|
|
31
|
+
adName: string;
|
|
32
|
+
primaryText: string;
|
|
33
|
+
headline: string;
|
|
34
|
+
description: string;
|
|
35
|
+
callToAction: string;
|
|
36
|
+
websiteUrl: string;
|
|
37
|
+
displayLink: string;
|
|
38
|
+
brandName: string;
|
|
39
|
+
profileImage: string;
|
|
40
|
+
creativeImage: string;
|
|
41
|
+
// `| undefined` is explicit so callers may assign an absent media type
|
|
42
|
+
// under the workspace's exactOptionalPropertyTypes tsconfig.
|
|
43
|
+
creativeMediaType?: 'image' | 'video' | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A single carousel card for the presentational carousel views (ADR-119 D1).
|
|
48
|
+
* Per-card copy/destination only — the CTA is ad-level/shared (ADR-119 D3).
|
|
49
|
+
*/
|
|
50
|
+
export interface CarouselCardView {
|
|
51
|
+
key: string;
|
|
52
|
+
headline?: string | undefined;
|
|
53
|
+
description?: string | undefined;
|
|
54
|
+
url?: string | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Props for the pure presentational carousels (`MetaFeedCarousel`,
|
|
59
|
+
* `InstagramFeedCarousel`). They own NO data or crop logic: the caller supplies
|
|
60
|
+
* the card list, the active index + setter, and an image-render slot
|
|
61
|
+
* (`CropFrame` on the controller-driven form side; a plain <img> on static
|
|
62
|
+
* surfaces). Because the view imports neither the controller nor the crop
|
|
63
|
+
* system, it lives in `packages/ui` and earns Meta-exact hex (ADR-119 D1/D5).
|
|
64
|
+
*/
|
|
65
|
+
export interface CarouselViewProps {
|
|
66
|
+
cards: CarouselCardView[];
|
|
67
|
+
active: number;
|
|
68
|
+
onActive: (index: number) => void;
|
|
69
|
+
renderImage: (index: number) => ReactNode;
|
|
70
|
+
brandName: string;
|
|
71
|
+
profileImage?: string | undefined;
|
|
72
|
+
primaryText?: string | undefined;
|
|
73
|
+
/** Ad-level CTA, shared across cards (ADR-119 D3). */
|
|
74
|
+
callToAction: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface PlacementPreview {
|
|
78
|
+
id: string;
|
|
79
|
+
platform: PreviewPlatform;
|
|
80
|
+
placement: PreviewPlacement;
|
|
81
|
+
label: string;
|
|
82
|
+
description: string;
|
|
83
|
+
aspectRatio: '1:1' | '4:5' | '9:16';
|
|
84
|
+
device: PreviewDevice;
|
|
85
|
+
enabled: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const PLACEMENT_CONFIGS: PlacementPreview[] = [
|
|
89
|
+
{
|
|
90
|
+
id: 'fb-feed',
|
|
91
|
+
platform: 'facebook',
|
|
92
|
+
placement: 'feed',
|
|
93
|
+
label: 'Facebook Feed',
|
|
94
|
+
description: 'Standard news feed placement',
|
|
95
|
+
aspectRatio: '1:1',
|
|
96
|
+
device: 'desktop',
|
|
97
|
+
enabled: true,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: 'ig-feed',
|
|
101
|
+
platform: 'instagram',
|
|
102
|
+
placement: 'feed',
|
|
103
|
+
label: 'Instagram Feed',
|
|
104
|
+
description: 'Standard Instagram feed',
|
|
105
|
+
aspectRatio: '1:1',
|
|
106
|
+
device: 'mobile',
|
|
107
|
+
enabled: true,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: 'fb-rightcolumn',
|
|
111
|
+
platform: 'facebook',
|
|
112
|
+
placement: 'rightcolumn',
|
|
113
|
+
label: 'Facebook Right column',
|
|
114
|
+
description: 'Desktop right column placement',
|
|
115
|
+
aspectRatio: '1:1',
|
|
116
|
+
device: 'desktop',
|
|
117
|
+
enabled: true,
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: 'fb-feed-mobile',
|
|
121
|
+
platform: 'facebook',
|
|
122
|
+
placement: 'feed',
|
|
123
|
+
label: 'Facebook Feed (Mobile)',
|
|
124
|
+
description: 'Mobile news feed',
|
|
125
|
+
aspectRatio: '4:5',
|
|
126
|
+
device: 'mobile',
|
|
127
|
+
enabled: false,
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: 'ig-story',
|
|
131
|
+
platform: 'instagram',
|
|
132
|
+
placement: 'story',
|
|
133
|
+
label: 'Instagram Stories',
|
|
134
|
+
description: 'Full-screen vertical stories',
|
|
135
|
+
aspectRatio: '9:16',
|
|
136
|
+
device: 'mobile',
|
|
137
|
+
enabled: true,
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
id: 'fb-story',
|
|
141
|
+
platform: 'facebook',
|
|
142
|
+
placement: 'story',
|
|
143
|
+
label: 'Facebook Stories',
|
|
144
|
+
description: 'Full-screen vertical stories',
|
|
145
|
+
aspectRatio: '9:16',
|
|
146
|
+
device: 'mobile',
|
|
147
|
+
enabled: true,
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: 'messenger-story',
|
|
151
|
+
platform: 'messenger',
|
|
152
|
+
placement: 'story',
|
|
153
|
+
label: 'Messenger Stories',
|
|
154
|
+
description: 'Full-screen vertical stories',
|
|
155
|
+
aspectRatio: '9:16',
|
|
156
|
+
device: 'mobile',
|
|
157
|
+
enabled: true,
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: 'ig-reel',
|
|
161
|
+
platform: 'instagram',
|
|
162
|
+
placement: 'reel',
|
|
163
|
+
label: 'Instagram Reels',
|
|
164
|
+
description: 'Short-form video placement',
|
|
165
|
+
aspectRatio: '9:16',
|
|
166
|
+
device: 'mobile',
|
|
167
|
+
enabled: true,
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: 'fb-reel',
|
|
171
|
+
platform: 'facebook',
|
|
172
|
+
placement: 'reel',
|
|
173
|
+
label: 'Facebook Reels',
|
|
174
|
+
description: 'Short-form video placement',
|
|
175
|
+
aspectRatio: '9:16',
|
|
176
|
+
device: 'mobile',
|
|
177
|
+
enabled: true,
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
id: 'ig-explore',
|
|
181
|
+
platform: 'instagram',
|
|
182
|
+
placement: 'explore',
|
|
183
|
+
label: 'Instagram Explore home',
|
|
184
|
+
description: 'Explore grid placement',
|
|
185
|
+
aspectRatio: '1:1',
|
|
186
|
+
device: 'mobile',
|
|
187
|
+
enabled: true,
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
id: 'messenger-inbox',
|
|
191
|
+
platform: 'messenger',
|
|
192
|
+
placement: 'inbox',
|
|
193
|
+
label: 'Messenger Inbox',
|
|
194
|
+
description: 'Sponsored message in inbox',
|
|
195
|
+
aspectRatio: '1:1',
|
|
196
|
+
device: 'mobile',
|
|
197
|
+
enabled: true,
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: 'fb-instream',
|
|
201
|
+
platform: 'facebook',
|
|
202
|
+
placement: 'instream',
|
|
203
|
+
label: 'Facebook In-stream videos',
|
|
204
|
+
description: 'Video ads in Facebook Watch and video feeds',
|
|
205
|
+
aspectRatio: '1:1',
|
|
206
|
+
device: 'mobile',
|
|
207
|
+
enabled: true,
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: 'fb-search',
|
|
211
|
+
platform: 'facebook',
|
|
212
|
+
placement: 'search',
|
|
213
|
+
label: 'Facebook Marketplace Search',
|
|
214
|
+
description: 'Ads in Facebook Marketplace search results',
|
|
215
|
+
aspectRatio: '1:1',
|
|
216
|
+
device: 'mobile',
|
|
217
|
+
enabled: true,
|
|
218
|
+
},
|
|
219
|
+
];
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MetricCard — KPI tile with large value + label + optional tone variant.
|
|
3
|
+
*
|
|
4
|
+
* Promoted in ADR-020 (Phase 3a Discovery Company Research, first
|
|
5
|
+
* committed consumer). Cross-batch consumers: Phase 3c (admin stats),
|
|
6
|
+
* Phase 8 (results summary), Phase 12 (review-overview summary).
|
|
7
|
+
*
|
|
8
|
+
* Designed for use inside `<StatsGrid>` (also new this batch — see
|
|
9
|
+
* `stats-grid.tsx`) but works standalone in any flex/grid container.
|
|
10
|
+
*
|
|
11
|
+
* Token discipline: all colors via public tokens. Tone variant gives
|
|
12
|
+
* the value text an accent/success/warning/destructive/info color;
|
|
13
|
+
* background is always neutral surface-overlay.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ReactNode } from 'react'
|
|
17
|
+
|
|
18
|
+
export type MetricCardTone = 'neutral' | 'accent' | 'success' | 'warning' | 'destructive' | 'info'
|
|
19
|
+
|
|
20
|
+
const TONE_COLOR: Record<MetricCardTone, string> = {
|
|
21
|
+
neutral: 'rgb(var(--foreground))',
|
|
22
|
+
accent: 'rgb(var(--accent))',
|
|
23
|
+
success: 'rgb(var(--success))',
|
|
24
|
+
warning: 'rgb(var(--warning))',
|
|
25
|
+
destructive: 'rgb(var(--destructive))',
|
|
26
|
+
info: 'rgb(var(--info))',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MetricCardProps {
|
|
30
|
+
/** Headline value (large, semibold, tone-colored). Numbers should
|
|
31
|
+
* use `font-mono` for tabular alignment when relevant. */
|
|
32
|
+
value: ReactNode
|
|
33
|
+
/** Small descriptive label below the value. */
|
|
34
|
+
label: string
|
|
35
|
+
/** Optional tone — colors the value text. Background stays neutral. */
|
|
36
|
+
tone?: MetricCardTone
|
|
37
|
+
/** Optional icon shown to the left of the value (h-5 w-5 expected). */
|
|
38
|
+
icon?: ReactNode
|
|
39
|
+
/**
|
|
40
|
+
* Where the label sits relative to the value. Default `'bottom'`
|
|
41
|
+
* (value-then-label — the original shape; existing consumers are
|
|
42
|
+
* unchanged). `'top'` renders a small uppercase label above the
|
|
43
|
+
* value — the KPI-chip shape used by Calculator Shell v2 (ADR-076
|
|
44
|
+
* D8). The value size is unchanged in both positions.
|
|
45
|
+
*/
|
|
46
|
+
labelPosition?: 'top' | 'bottom'
|
|
47
|
+
/** Optional className override for the outer container. */
|
|
48
|
+
className?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function MetricCard({
|
|
52
|
+
value,
|
|
53
|
+
label,
|
|
54
|
+
tone = 'neutral',
|
|
55
|
+
icon,
|
|
56
|
+
labelPosition = 'bottom',
|
|
57
|
+
className,
|
|
58
|
+
}: MetricCardProps) {
|
|
59
|
+
const labelEl =
|
|
60
|
+
labelPosition === 'top' ? (
|
|
61
|
+
<div
|
|
62
|
+
className="text-[10px] font-bold uppercase tracking-[0.05em]"
|
|
63
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
64
|
+
>
|
|
65
|
+
{label}
|
|
66
|
+
</div>
|
|
67
|
+
) : (
|
|
68
|
+
<div
|
|
69
|
+
className="text-[12px]"
|
|
70
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
71
|
+
>
|
|
72
|
+
{label}
|
|
73
|
+
</div>
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
const valueEl = (
|
|
77
|
+
<div className="flex items-center gap-2">
|
|
78
|
+
{icon && (
|
|
79
|
+
<span style={{ color: TONE_COLOR[tone] }} className="flex-shrink-0">
|
|
80
|
+
{icon}
|
|
81
|
+
</span>
|
|
82
|
+
)}
|
|
83
|
+
<div
|
|
84
|
+
className="text-[24px] font-semibold tabular-nums"
|
|
85
|
+
style={{ color: TONE_COLOR[tone] }}
|
|
86
|
+
>
|
|
87
|
+
{value}
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div
|
|
94
|
+
className={
|
|
95
|
+
className ??
|
|
96
|
+
'flex flex-col gap-1 px-4 py-3 border'
|
|
97
|
+
}
|
|
98
|
+
style={{
|
|
99
|
+
background: 'rgb(var(--surface-overlay-soft))',
|
|
100
|
+
borderColor: 'rgb(var(--border))',
|
|
101
|
+
borderRadius: 'var(--radius-lg)',
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
{labelPosition === 'top' ? (
|
|
105
|
+
<>
|
|
106
|
+
{labelEl}
|
|
107
|
+
{valueEl}
|
|
108
|
+
</>
|
|
109
|
+
) : (
|
|
110
|
+
<>
|
|
111
|
+
{valueEl}
|
|
112
|
+
{labelEl}
|
|
113
|
+
</>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
package/src/modal.tsx
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { useEffect, type HTMLAttributes, type ReactNode } from 'react'
|
|
2
|
+
import { X } from 'lucide-react'
|
|
3
|
+
import { cn } from './lib/utils'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Modal — a centered floating panel on top of a backdrop. Built on
|
|
7
|
+
* `.ds-card-surface` so it inherits the premium card chrome (border,
|
|
8
|
+
* radius, multi-layer shadow with inner highlight in light mode).
|
|
9
|
+
*
|
|
10
|
+
* Two layout modes:
|
|
11
|
+
*
|
|
12
|
+
* 1. Flat children (back-compat) — pass content directly:
|
|
13
|
+
*
|
|
14
|
+
* <Modal title="..." onClose={...}>
|
|
15
|
+
* <p>body</p>
|
|
16
|
+
* <Button>action</Button>
|
|
17
|
+
* </Modal>
|
|
18
|
+
*
|
|
19
|
+
* 2. Shell / Tray pattern (recommended) — body in a recessed tray,
|
|
20
|
+
* action buttons on the outer-card bottom strip below the tray:
|
|
21
|
+
*
|
|
22
|
+
* <Modal title="..." onClose={...}>
|
|
23
|
+
* <Modal.Tray>
|
|
24
|
+
* <p>body content sits in a recessed shelf</p>
|
|
25
|
+
* <textarea ... />
|
|
26
|
+
* </Modal.Tray>
|
|
27
|
+
* <Modal.Footer>
|
|
28
|
+
* <Button variant="ghost" onClick={onClose}>Cancel</Button>
|
|
29
|
+
* <Button>Confirm</Button>
|
|
30
|
+
* </Modal.Footer>
|
|
31
|
+
* </Modal>
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
interface ModalProps {
|
|
35
|
+
isOpen: boolean
|
|
36
|
+
onClose: () => void
|
|
37
|
+
title: string
|
|
38
|
+
children: ReactNode
|
|
39
|
+
className?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function Modal({ isOpen, onClose, title, children, className }: ModalProps) {
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!isOpen) return
|
|
45
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
46
|
+
if (e.key === 'Escape') onClose()
|
|
47
|
+
}
|
|
48
|
+
document.addEventListener('keydown', handleEscape)
|
|
49
|
+
return () => document.removeEventListener('keydown', handleEscape)
|
|
50
|
+
}, [isOpen, onClose])
|
|
51
|
+
|
|
52
|
+
if (!isOpen) return null
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
56
|
+
{/* Backdrop */}
|
|
57
|
+
<div
|
|
58
|
+
className="absolute inset-0"
|
|
59
|
+
style={{ background: 'rgb(var(--modal-overlay))' }}
|
|
60
|
+
onClick={onClose}
|
|
61
|
+
/>
|
|
62
|
+
{/* Outer card — uses .ds-card-surface so the modal gets the
|
|
63
|
+
same chrome as cards (border + multi-layer shadow + inner
|
|
64
|
+
highlight in light mode). overflow-hidden clips children to
|
|
65
|
+
the rounded corners; the tray inside handles its own scroll. */}
|
|
66
|
+
<div
|
|
67
|
+
data-framed="true"
|
|
68
|
+
className={cn(
|
|
69
|
+
'ds-card-surface relative w-full max-w-2xl flex flex-col max-h-[90vh] overflow-hidden',
|
|
70
|
+
className,
|
|
71
|
+
)}
|
|
72
|
+
style={{ borderRadius: 'var(--radius-xl)' }}
|
|
73
|
+
>
|
|
74
|
+
{/* Header — title + close. Sits on the outer card surface,
|
|
75
|
+
outside any tray. Shrink-0 so it never collapses when the
|
|
76
|
+
body grows. */}
|
|
77
|
+
<div className="flex items-center justify-between shrink-0 px-6 pt-6 pb-4">
|
|
78
|
+
<h2 className="text-xl font-bold text-[rgb(var(--foreground))]">
|
|
79
|
+
{title}
|
|
80
|
+
</h2>
|
|
81
|
+
<button
|
|
82
|
+
onClick={onClose}
|
|
83
|
+
className="rounded-lg p-1.5 text-[rgb(var(--muted-foreground))] hover:bg-[rgb(var(--muted))] hover:text-[rgb(var(--foreground))] transition-colors"
|
|
84
|
+
aria-label="Close"
|
|
85
|
+
>
|
|
86
|
+
<X className="h-5 w-5" />
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
{children}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Modal.Tray — the recessed body panel. Sits inside the modal's outer
|
|
97
|
+
* card with --tray-inset margin and its own bg. Scrolls if content
|
|
98
|
+
* exceeds the modal's max-height.
|
|
99
|
+
*/
|
|
100
|
+
function Tray({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
101
|
+
return (
|
|
102
|
+
<div
|
|
103
|
+
className={cn(
|
|
104
|
+
'ds-card-tray flex-1 min-h-0 overflow-y-auto p-5',
|
|
105
|
+
className,
|
|
106
|
+
)}
|
|
107
|
+
{...rest}
|
|
108
|
+
/>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Modal.Footer — bottom action strip on the outer card, below the
|
|
114
|
+
* tray inset. `shrink-0` keeps it pinned even when the tray scrolls.
|
|
115
|
+
*/
|
|
116
|
+
function Footer({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
117
|
+
return (
|
|
118
|
+
<div
|
|
119
|
+
className={cn(
|
|
120
|
+
'flex items-center justify-end gap-2 shrink-0 px-6 py-4',
|
|
121
|
+
className,
|
|
122
|
+
)}
|
|
123
|
+
{...rest}
|
|
124
|
+
/>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
Modal.Tray = Tray
|
|
129
|
+
Modal.Footer = Footer
|
|
130
|
+
|
|
131
|
+
export default Modal
|