@ciderpress/ui 1.0.0-rc.1 → 1.0.0-rc.11
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/dist/head/css/loader-apple.css +1 -1
- package/dist/head/css/loader-dots.css +1 -1
- package/dist/head/css/themes/amber.css +1 -1
- package/dist/head/css/themes/arcade.css +1 -1
- package/dist/head/css/themes/grannysmith.css +1 -1
- package/dist/head/css/themes/honeycrisp.css +1 -1
- package/dist/head/css/themes/midnight.css +1 -1
- package/dist/head/css/themes/mulled.css +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.mjs +4 -3
- package/dist/node.d.ts +1 -1
- package/dist/node.mjs +732 -55
- package/dist/plugins/mermaid/MermaidRenderer.tsx +49 -6
- package/dist/theme/components/footer/site-footer.tsx +68 -9
- package/dist/theme/components/home/cta.css +15 -0
- package/dist/theme/components/home/cta.tsx +10 -2
- package/dist/theme/components/home/feature-card.css +1 -1
- package/dist/theme/components/home/feature-card.tsx +8 -10
- package/dist/theme/components/home/feature.tsx +54 -31
- package/dist/theme/components/home/hero-demo.css +38 -0
- package/dist/theme/components/home/home-visual.tsx +226 -0
- package/dist/theme/components/home/layout.tsx +410 -108
- package/dist/theme/components/home/split.css +46 -19
- package/dist/theme/components/home/split.tsx +28 -8
- package/dist/theme/components/home/tabs.css +299 -0
- package/dist/theme/components/home/tabs.tsx +351 -0
- package/dist/theme/components/home/trust-strip.css +41 -0
- package/dist/theme/components/home/trust-strip.tsx +168 -11
- package/dist/theme/components/home/workspaces.tsx +176 -38
- package/dist/theme/components/nav/branch-tag.tsx +7 -7
- package/dist/theme/components/nav/ciderpress-docs-bar.css +13 -1
- package/dist/theme/components/nav/ciderpress-docs-bar.tsx +33 -2
- package/dist/theme/components/nav/ciderpress-header.css +2 -1
- package/dist/theme/components/nav/ciderpress-header.tsx +26 -3
- package/dist/theme/components/nav/ciderpress-nav-hamburger.css +50 -0
- package/dist/theme/components/nav/ciderpress-nav-hamburger.tsx +95 -9
- package/dist/theme/components/nav/ciderpress-nav-menu.css +88 -0
- package/dist/theme/components/nav/ciderpress-nav-menu.tsx +397 -38
- package/dist/theme/components/nav/ciderpress-nav-social-links.tsx +27 -22
- package/dist/theme/components/nav/floating-branch-indicator.tsx +7 -7
- package/dist/theme/components/nav/header-icon.css +24 -0
- package/dist/theme/components/nav/header-icon.tsx +119 -0
- package/dist/theme/components/nav/header-logo.css +16 -0
- package/dist/theme/components/nav/header-logo.tsx +200 -0
- package/dist/theme/components/nav/layout.tsx +117 -16
- package/dist/theme/components/nav/nav-logo.tsx +16 -19
- package/dist/theme/components/openapi/index.ts +0 -2
- package/dist/theme/components/openapi/openapi.css +0 -14
- package/dist/theme/components/shared/card-icon.tsx +37 -0
- package/dist/theme/components/shared/ciderpress-logo.tsx +5 -1
- package/dist/theme/components/shared/icon.tsx +120 -35
- package/dist/theme/components/shared/resolve-card-icon.ts +53 -12
- package/dist/theme/components/shared/section-card.tsx +37 -6
- package/dist/theme/components/sidebar/sidebar-badge.css +50 -0
- package/dist/theme/components/sidebar/sidebar-badge.tsx +153 -0
- package/dist/theme/components/sidebar/sidebar-links.tsx +6 -2
- package/dist/theme/components/sidebar/sidebar-scope.tsx +24 -1
- package/dist/theme/components/theme-provider.tsx +27 -11
- package/dist/theme/components/workspaces/card.css +4 -0
- package/dist/theme/components/workspaces/card.tsx +10 -12
- package/dist/theme/components/workspaces/grid.tsx +18 -6
- package/dist/theme/hooks/use-ciderpress.ts +65 -5
- package/dist/theme/hooks/use-nav-items.ts +82 -11
- package/dist/theme/index.tsx +10 -3
- package/dist/theme/lib/read-social-links.ts +4 -3
- package/dist/theme/lib/rich-text-parse.ts +740 -0
- package/dist/theme/lib/rich-text.test.tsx +342 -0
- package/dist/theme/lib/rich-text.tsx +86 -0
- package/dist/theme/lib/theme-favicon.ts +1 -1
- package/dist/theme/lib/with-mount-base.ts +31 -0
- package/dist/theme/styles/overrides/fonts.css +7 -4
- package/dist/theme/styles/overrides/footnotes.css +54 -0
- package/dist/theme/styles/overrides/home.css +11 -2
- package/dist/theme/styles/overrides/lists.css +83 -0
- package/dist/theme/styles/overrides/rail.css +27 -10
- package/dist/theme/styles/overrides/rspress.css +10 -5
- package/dist/theme/styles/overrides/section-card.css +9 -0
- package/dist/theme/styles/overrides/sidebar.css +22 -0
- package/dist/theme/styles/overrides/tokens.css +41 -23
- package/dist/theme/styles/rich-text.css +106 -0
- package/dist/theme/styles/themes/amber.css +32 -2
- package/dist/theme/styles/themes/arcade.css +16 -1
- package/dist/theme/styles/themes/grannysmith.css +32 -2
- package/dist/theme/styles/themes/honeycrisp.css +37 -7
- package/dist/theme/styles/themes/midnight.css +16 -1
- package/dist/theme/styles/themes/mulled.css +54 -9
- package/package.json +18 -16
- package/src/theme/components/footer/site-footer.tsx +68 -9
- package/src/theme/components/home/cta.css +15 -0
- package/src/theme/components/home/cta.tsx +10 -2
- package/src/theme/components/home/feature-card.css +1 -1
- package/src/theme/components/home/feature-card.tsx +8 -10
- package/src/theme/components/home/feature.tsx +54 -31
- package/src/theme/components/home/hero-demo.css +38 -0
- package/src/theme/components/home/home-visual.tsx +226 -0
- package/src/theme/components/home/layout.tsx +410 -108
- package/src/theme/components/home/split.css +46 -19
- package/src/theme/components/home/split.tsx +28 -8
- package/src/theme/components/home/tabs.css +299 -0
- package/src/theme/components/home/tabs.tsx +351 -0
- package/src/theme/components/home/trust-strip.css +41 -0
- package/src/theme/components/home/trust-strip.tsx +168 -11
- package/src/theme/components/home/workspaces.tsx +176 -38
- package/src/theme/components/nav/branch-tag.tsx +7 -7
- package/src/theme/components/nav/ciderpress-docs-bar.css +13 -1
- package/src/theme/components/nav/ciderpress-docs-bar.tsx +33 -2
- package/src/theme/components/nav/ciderpress-header.css +2 -1
- package/src/theme/components/nav/ciderpress-header.tsx +26 -3
- package/src/theme/components/nav/ciderpress-nav-hamburger.css +50 -0
- package/src/theme/components/nav/ciderpress-nav-hamburger.tsx +95 -9
- package/src/theme/components/nav/ciderpress-nav-menu.css +88 -0
- package/src/theme/components/nav/ciderpress-nav-menu.tsx +397 -38
- package/src/theme/components/nav/ciderpress-nav-social-links.tsx +27 -22
- package/src/theme/components/nav/floating-branch-indicator.tsx +7 -7
- package/src/theme/components/nav/header-icon.css +24 -0
- package/src/theme/components/nav/header-icon.tsx +119 -0
- package/src/theme/components/nav/header-logo.css +16 -0
- package/src/theme/components/nav/header-logo.tsx +200 -0
- package/src/theme/components/nav/layout.tsx +117 -16
- package/src/theme/components/nav/nav-logo.tsx +16 -19
- package/src/theme/components/openapi/index.ts +0 -2
- package/src/theme/components/openapi/openapi.css +0 -14
- package/src/theme/components/shared/card-icon.tsx +37 -0
- package/src/theme/components/shared/ciderpress-logo.tsx +5 -1
- package/src/theme/components/shared/icon.tsx +120 -35
- package/src/theme/components/shared/resolve-card-icon.ts +53 -12
- package/src/theme/components/shared/section-card.tsx +37 -6
- package/src/theme/components/sidebar/sidebar-badge.css +50 -0
- package/src/theme/components/sidebar/sidebar-badge.tsx +153 -0
- package/src/theme/components/sidebar/sidebar-links.tsx +6 -2
- package/src/theme/components/sidebar/sidebar-scope.tsx +24 -1
- package/src/theme/components/theme-provider.tsx +27 -11
- package/src/theme/components/workspaces/card.css +4 -0
- package/src/theme/components/workspaces/card.tsx +10 -12
- package/src/theme/components/workspaces/grid.tsx +18 -6
- package/src/theme/hooks/use-ciderpress.ts +65 -5
- package/src/theme/hooks/use-nav-items.ts +82 -11
- package/src/theme/index.tsx +10 -3
- package/src/theme/lib/read-social-links.ts +4 -3
- package/src/theme/lib/rich-text-parse.ts +740 -0
- package/src/theme/lib/rich-text.test.tsx +342 -0
- package/src/theme/lib/rich-text.tsx +86 -0
- package/src/theme/lib/theme-favicon.ts +1 -1
- package/src/theme/lib/with-mount-base.ts +31 -0
- package/src/theme/styles/overrides/fonts.css +7 -4
- package/src/theme/styles/overrides/footnotes.css +54 -0
- package/src/theme/styles/overrides/home.css +11 -2
- package/src/theme/styles/overrides/lists.css +83 -0
- package/src/theme/styles/overrides/rail.css +27 -10
- package/src/theme/styles/overrides/rspress.css +10 -5
- package/src/theme/styles/overrides/section-card.css +9 -0
- package/src/theme/styles/overrides/sidebar.css +22 -0
- package/src/theme/styles/overrides/tokens.css +41 -23
- package/src/theme/styles/rich-text.css +106 -0
- package/src/theme/styles/themes/amber.css +32 -2
- package/src/theme/styles/themes/arcade.css +16 -1
- package/src/theme/styles/themes/grannysmith.css +32 -2
- package/src/theme/styles/themes/honeycrisp.css +37 -7
- package/src/theme/styles/themes/midnight.css +16 -1
- package/src/theme/styles/themes/mulled.css +54 -9
- package/dist/theme/components/openapi/copy-markdown-button.tsx +0 -41
- package/src/theme/components/openapi/copy-markdown-button.tsx +0 -41
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import type { HomeVisual } from '@ciderpress/config'
|
|
2
|
+
import { match, P } from 'massaman/match'
|
|
3
|
+
import type React from 'react'
|
|
4
|
+
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
|
|
5
|
+
|
|
6
|
+
import { renderRichText, toPlainText } from '../../lib/rich-text.tsx'
|
|
7
|
+
import { RouteLink } from '../../lib/route-link.tsx'
|
|
8
|
+
import { safeUrl } from '../../lib/safe-url.ts'
|
|
9
|
+
import { CardIcon } from '../shared/card-icon'
|
|
10
|
+
import type { CardIconInput } from '../shared/resolve-card-icon'
|
|
11
|
+
import { resolveCardIcon } from '../shared/resolve-card-icon'
|
|
12
|
+
import { HomeVisualView } from './home-visual'
|
|
13
|
+
|
|
14
|
+
import './tabs.css'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* CTA rendered at the bottom of a tab panel's copy. Mirrors
|
|
18
|
+
* `SplitAction` — the `ButtonConfig` from config is mapped into this
|
|
19
|
+
* shape by the home layout.
|
|
20
|
+
*/
|
|
21
|
+
export interface TabsAction {
|
|
22
|
+
/**
|
|
23
|
+
* Visible label.
|
|
24
|
+
*/
|
|
25
|
+
readonly text: string
|
|
26
|
+
/**
|
|
27
|
+
* Destination URL.
|
|
28
|
+
*/
|
|
29
|
+
readonly link: string
|
|
30
|
+
/**
|
|
31
|
+
* Visual style — `brand` is the filled primary, `alt` is the outline.
|
|
32
|
+
*/
|
|
33
|
+
readonly theme?: 'brand' | 'alt'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One tab in the strip plus the copy and visual its panel shows while
|
|
38
|
+
* selected. Frontmatter-shaped: icons arrive pre-serialized from the
|
|
39
|
+
* sync engine.
|
|
40
|
+
*/
|
|
41
|
+
export interface HomeTabEntry {
|
|
42
|
+
/**
|
|
43
|
+
* Tab label rendered in the strip.
|
|
44
|
+
*/
|
|
45
|
+
readonly label: string
|
|
46
|
+
/**
|
|
47
|
+
* Icon rendered before the label.
|
|
48
|
+
*/
|
|
49
|
+
readonly icon?: CardIconInput
|
|
50
|
+
/**
|
|
51
|
+
* Panel headline. Falls back to {@link HomeTabEntry.label}.
|
|
52
|
+
*/
|
|
53
|
+
readonly title?: string
|
|
54
|
+
/**
|
|
55
|
+
* Panel body copy.
|
|
56
|
+
*/
|
|
57
|
+
readonly body?: string
|
|
58
|
+
/**
|
|
59
|
+
* Checkmark bullets rendered under the body.
|
|
60
|
+
*/
|
|
61
|
+
readonly bullets?: readonly string[]
|
|
62
|
+
/**
|
|
63
|
+
* CTA rendered at the bottom of the panel copy.
|
|
64
|
+
*/
|
|
65
|
+
readonly cta?: TabsAction
|
|
66
|
+
/**
|
|
67
|
+
* Visual rendered in the panel while the tab is selected.
|
|
68
|
+
*/
|
|
69
|
+
readonly visual?: HomeVisual
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Props for {@link HomeTabs} — the section heading trio, the tab
|
|
74
|
+
* entries, and how the strip is laid out against the panel.
|
|
75
|
+
*/
|
|
76
|
+
export interface HomeTabsProps {
|
|
77
|
+
/**
|
|
78
|
+
* Eyebrow label rendered above the section title.
|
|
79
|
+
*/
|
|
80
|
+
readonly eyebrow?: string
|
|
81
|
+
/**
|
|
82
|
+
* Section title rendered above the strip.
|
|
83
|
+
*/
|
|
84
|
+
readonly title?: string
|
|
85
|
+
/**
|
|
86
|
+
* Supporting sentence rendered under the section title.
|
|
87
|
+
*/
|
|
88
|
+
readonly body?: string
|
|
89
|
+
/**
|
|
90
|
+
* Tabs rendered in the strip, in order.
|
|
91
|
+
*/
|
|
92
|
+
readonly items: readonly HomeTabEntry[]
|
|
93
|
+
/**
|
|
94
|
+
* Strip placement. `vertical` (default) stacks the tabs beside the
|
|
95
|
+
* panel; `horizontal` runs them in a row above it.
|
|
96
|
+
*/
|
|
97
|
+
readonly orientation?: 'vertical' | 'horizontal'
|
|
98
|
+
/**
|
|
99
|
+
* Flip a vertical band's columns — panel left, strip right. Ignored
|
|
100
|
+
* when horizontal.
|
|
101
|
+
*/
|
|
102
|
+
readonly reverse?: boolean
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* HomeTabs — a strip of selectable tabs driving one panel. Clicking a tab
|
|
107
|
+
* swaps the panel's copy and visual; the first tab is selected on load.
|
|
108
|
+
*
|
|
109
|
+
* Built on react-aria-components `Tabs`, so roving focus, arrow-key
|
|
110
|
+
* navigation, and the tab/tabpanel ARIA wiring come from the library
|
|
111
|
+
* rather than being hand-rolled.
|
|
112
|
+
*
|
|
113
|
+
* @param props - Heading trio, tab entries, orientation, and column order.
|
|
114
|
+
* @returns React element with the tab band, or null when there are no tabs.
|
|
115
|
+
*/
|
|
116
|
+
export function HomeTabs(props: HomeTabsProps): React.ReactElement | null {
|
|
117
|
+
const { eyebrow, title, body, items, orientation, reverse } = props
|
|
118
|
+
// Frontmatter is unvalidated user content on hand-authored home pages —
|
|
119
|
+
// treat anything that is not a populated array as "no band".
|
|
120
|
+
const list = match(items)
|
|
121
|
+
.with(
|
|
122
|
+
P.when((i): i is readonly HomeTabEntry[] => Array.isArray(i) && i.length > 0),
|
|
123
|
+
(i) => i
|
|
124
|
+
)
|
|
125
|
+
.otherwise(() => null)
|
|
126
|
+
if (list === null) {
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const axis = match(orientation)
|
|
131
|
+
.with('horizontal', () => 'horizontal' as const)
|
|
132
|
+
.otherwise(() => 'vertical' as const)
|
|
133
|
+
const sectionClass = match(reverse ?? false)
|
|
134
|
+
.with(true, () => `cp-tabs cp-tabs--${axis} cp-tabs--reverse`)
|
|
135
|
+
.otherwise(() => `cp-tabs cp-tabs--${axis}`)
|
|
136
|
+
// aria-label takes a bare string — strip any markup the title carries.
|
|
137
|
+
const listLabel = match(title)
|
|
138
|
+
.with(P.string, (t) => toPlainText(t))
|
|
139
|
+
.otherwise(() => 'Highlights')
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<section className={sectionClass}>
|
|
143
|
+
{renderHead({ eyebrow, title, body })}
|
|
144
|
+
<Tabs className="cp-tabs__inner" orientation={axis}>
|
|
145
|
+
<TabList className="cp-tabs__list" aria-label={listLabel}>
|
|
146
|
+
{list.map((item, index) => (
|
|
147
|
+
<Tab key={`${item.label}-${index}`} id={String(index)} className="cp-tabs__tab">
|
|
148
|
+
{renderTabIcon(item.icon)}
|
|
149
|
+
{/* Plain text, not rich: the tab is already an interactive
|
|
150
|
+
`role="tab"`, and a markdown link in a label would nest an
|
|
151
|
+
anchor inside it — invalid DOM that breaks keyboard
|
|
152
|
+
traversal and screen-reader announcement. */}
|
|
153
|
+
<span className="cp-tabs__tab-label">{toPlainText(item.label)}</span>
|
|
154
|
+
</Tab>
|
|
155
|
+
))}
|
|
156
|
+
</TabList>
|
|
157
|
+
{list.map((item, index) => (
|
|
158
|
+
<TabPanel
|
|
159
|
+
key={`${item.label}-${index}`}
|
|
160
|
+
id={String(index)}
|
|
161
|
+
className={panelClass(item.visual)}
|
|
162
|
+
>
|
|
163
|
+
{renderPanelCopy(item, axis)}
|
|
164
|
+
{renderPanelVisual(item.visual)}
|
|
165
|
+
{match(axis)
|
|
166
|
+
.with('vertical', () => renderPanelCta(item.cta))
|
|
167
|
+
.otherwise(() => null)}
|
|
168
|
+
</TabPanel>
|
|
169
|
+
))}
|
|
170
|
+
</Tabs>
|
|
171
|
+
</section>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Heading trio rendered above the strip.
|
|
177
|
+
*
|
|
178
|
+
* @private
|
|
179
|
+
*/
|
|
180
|
+
interface TabsHead {
|
|
181
|
+
readonly eyebrow?: string
|
|
182
|
+
readonly title?: string
|
|
183
|
+
readonly body?: string
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Render the section heading, or nothing when every field is absent.
|
|
188
|
+
* Reuses the shared feature-section head so the typography matches the
|
|
189
|
+
* other home blocks.
|
|
190
|
+
*
|
|
191
|
+
* @private
|
|
192
|
+
* @param head - Heading trio from the block
|
|
193
|
+
* @returns Heading element, or null
|
|
194
|
+
*/
|
|
195
|
+
function renderHead(head: TabsHead): React.ReactElement | null {
|
|
196
|
+
const { eyebrow, title, body } = head
|
|
197
|
+
const hasContent = [eyebrow, title, body].some((value) => typeof value === 'string')
|
|
198
|
+
if (!hasContent) {
|
|
199
|
+
return null
|
|
200
|
+
}
|
|
201
|
+
return (
|
|
202
|
+
<div className="cp-feature-section-head">
|
|
203
|
+
{match(eyebrow)
|
|
204
|
+
.with(P.string, (e) => (
|
|
205
|
+
<div className="cp-feature-section-head__eyebrow">{renderRichText(e)}</div>
|
|
206
|
+
))
|
|
207
|
+
.otherwise(() => null)}
|
|
208
|
+
{match(title)
|
|
209
|
+
.with(P.string, (t) => (
|
|
210
|
+
<h2 className="cp-feature-section-head__title">{renderRichText(t)}</h2>
|
|
211
|
+
))
|
|
212
|
+
.otherwise(() => null)}
|
|
213
|
+
{match(body)
|
|
214
|
+
.with(P.string, (s) => <p className="cp-feature-section-head__sub">{renderRichText(s)}</p>)
|
|
215
|
+
.otherwise(() => null)}
|
|
216
|
+
</div>
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Render a tab's leading icon. Keeps the shared `cp-card__icon` chip so
|
|
222
|
+
* the colour variants come from the card stylesheet; tabs.css only
|
|
223
|
+
* shrinks it to fit the strip.
|
|
224
|
+
*
|
|
225
|
+
* @private
|
|
226
|
+
* @param icon - Serialized icon from frontmatter
|
|
227
|
+
* @returns Icon element, or null when the tab has no icon
|
|
228
|
+
*/
|
|
229
|
+
function renderTabIcon(icon: CardIconInput | undefined): React.ReactElement | null {
|
|
230
|
+
const resolved = resolveCardIcon(icon)
|
|
231
|
+
return match(resolved)
|
|
232
|
+
.with(P.nonNullable, (r) => <CardIcon resolved={r} />)
|
|
233
|
+
.otherwise(() => null)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Class for a panel, collapsing it to a single column when the selected
|
|
238
|
+
* tab carries no visual — otherwise a horizontal band would leave half
|
|
239
|
+
* the row empty.
|
|
240
|
+
*
|
|
241
|
+
* @private
|
|
242
|
+
* @param visual - The selected tab's optional visual
|
|
243
|
+
* @returns Panel class name
|
|
244
|
+
*/
|
|
245
|
+
function panelClass(visual: HomeVisual | undefined): string {
|
|
246
|
+
return match(visual)
|
|
247
|
+
.with(P.nullish, () => 'cp-tabs__panel cp-tabs__panel--full')
|
|
248
|
+
.otherwise(() => 'cp-tabs__panel')
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Which way the tab strip runs relative to its panel.
|
|
253
|
+
*
|
|
254
|
+
* @private
|
|
255
|
+
*/
|
|
256
|
+
type TabsAxis = 'horizontal' | 'vertical'
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Render a panel's copy column: headline, body, bullets, and, on the
|
|
260
|
+
* horizontal axis, the CTA. The headline falls back to the tab label so
|
|
261
|
+
* a minimal entry still reads as a titled panel.
|
|
262
|
+
*
|
|
263
|
+
* The CTA's home depends on the axis. Horizontal lays copy and visual out
|
|
264
|
+
* as side-by-side columns, so the CTA closes the copy column. Vertical
|
|
265
|
+
* stacks them, where that same placement would wedge the button between
|
|
266
|
+
* the body text and the visual, so the panel renders it last instead.
|
|
267
|
+
*
|
|
268
|
+
* @private
|
|
269
|
+
* @param item - The selected tab entry
|
|
270
|
+
* @param axis - Orientation the band is rendering on
|
|
271
|
+
* @returns Copy column element
|
|
272
|
+
*/
|
|
273
|
+
function renderPanelCopy(item: HomeTabEntry, axis: TabsAxis): React.ReactElement {
|
|
274
|
+
const heading = match(item.title)
|
|
275
|
+
.with(P.string, (t) => t)
|
|
276
|
+
.otherwise(() => item.label)
|
|
277
|
+
const bullets = item.bullets ?? []
|
|
278
|
+
|
|
279
|
+
return (
|
|
280
|
+
<div className="cp-tabs__copy">
|
|
281
|
+
<h3 className="cp-tabs__title">{renderRichText(heading)}</h3>
|
|
282
|
+
{match(item.body)
|
|
283
|
+
.with(P.string, (b) => <p className="cp-tabs__body">{renderRichText(b)}</p>)
|
|
284
|
+
.otherwise(() => null)}
|
|
285
|
+
{match(bullets.length === 0)
|
|
286
|
+
.with(true, () => null)
|
|
287
|
+
.otherwise(() => (
|
|
288
|
+
<ul className="cp-tabs__bullets">
|
|
289
|
+
{bullets.map((bullet) => (
|
|
290
|
+
<li key={bullet}>
|
|
291
|
+
<span className="cp-tabs__check">✓</span>
|
|
292
|
+
<span>{renderRichText(bullet)}</span>
|
|
293
|
+
</li>
|
|
294
|
+
))}
|
|
295
|
+
</ul>
|
|
296
|
+
))}
|
|
297
|
+
{match(axis)
|
|
298
|
+
.with('horizontal', () => renderPanelCta(item.cta))
|
|
299
|
+
.otherwise(() => null)}
|
|
300
|
+
</div>
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Render a panel CTA, dropping links that fail URL validation.
|
|
306
|
+
*
|
|
307
|
+
* @private
|
|
308
|
+
* @param cta - Optional CTA action
|
|
309
|
+
* @returns Link element, or null
|
|
310
|
+
*/
|
|
311
|
+
function renderPanelCta(cta: TabsAction | undefined): React.ReactElement | null {
|
|
312
|
+
return match(cta)
|
|
313
|
+
.with(P.nonNullable, (a) => {
|
|
314
|
+
const href = safeUrl(a.link)
|
|
315
|
+
if (href === null) {
|
|
316
|
+
return null
|
|
317
|
+
}
|
|
318
|
+
const className = match(a.theme ?? 'brand')
|
|
319
|
+
.with('brand', () => 'cp-tabs__btn cp-tabs__btn--primary')
|
|
320
|
+
.otherwise(() => 'cp-tabs__btn')
|
|
321
|
+
// Wrapped rather than returned bare: the CTA is a direct child of
|
|
322
|
+
// the panel so it lands under the visual, and an unwrapped link
|
|
323
|
+
// would stretch as a flex item and break out of the grid columns.
|
|
324
|
+
return (
|
|
325
|
+
<div className="cp-tabs__actions">
|
|
326
|
+
<RouteLink className={className} href={href}>
|
|
327
|
+
{a.text}
|
|
328
|
+
</RouteLink>
|
|
329
|
+
</div>
|
|
330
|
+
)
|
|
331
|
+
})
|
|
332
|
+
.otherwise(() => null)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Render a panel's visual frame. Omitted entirely when the entry carries
|
|
337
|
+
* no visual, so a copy-only tab does not paint an empty frame.
|
|
338
|
+
*
|
|
339
|
+
* @private
|
|
340
|
+
* @param visual - Optional visual for the selected tab
|
|
341
|
+
* @returns Framed visual element, or null
|
|
342
|
+
*/
|
|
343
|
+
function renderPanelVisual(visual: HomeVisual | undefined): React.ReactElement | null {
|
|
344
|
+
return match(visual)
|
|
345
|
+
.with(P.nonNullable, (v) => (
|
|
346
|
+
<div className="cp-tabs__visual">
|
|
347
|
+
<HomeVisualView visual={v} context="tabs" />
|
|
348
|
+
</div>
|
|
349
|
+
))
|
|
350
|
+
.otherwise(() => null)
|
|
351
|
+
}
|
|
@@ -31,3 +31,44 @@
|
|
|
31
31
|
.cp-trust__sep {
|
|
32
32
|
color: var(--cp-c-text-3);
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
.cp-trust__logo {
|
|
36
|
+
display: inline-block;
|
|
37
|
+
width: auto;
|
|
38
|
+
vertical-align: middle;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/*
|
|
42
|
+
* Mono variant: paint a solid block of the current text color, clipped
|
|
43
|
+
* to the asset's alpha channel. One file then tracks every theme and
|
|
44
|
+
* both variants, which is the escape hatch for marks whose baked
|
|
45
|
+
* palette only reads against one background.
|
|
46
|
+
*/
|
|
47
|
+
.cp-trust__logo--mono {
|
|
48
|
+
background-color: currentcolor;
|
|
49
|
+
color: var(--cp-c-text-2);
|
|
50
|
+
mask-position: center;
|
|
51
|
+
mask-repeat: no-repeat;
|
|
52
|
+
mask-size: contain;
|
|
53
|
+
-webkit-mask-position: center;
|
|
54
|
+
-webkit-mask-repeat: no-repeat;
|
|
55
|
+
-webkit-mask-size: contain;
|
|
56
|
+
transition: color 0.15s ease;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* Lays out at the asset's intrinsic ratio to give the mask a width. */
|
|
60
|
+
.cp-trust__logo--mono img {
|
|
61
|
+
display: block;
|
|
62
|
+
height: 100%;
|
|
63
|
+
width: auto;
|
|
64
|
+
visibility: hidden;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.cp-trust__link {
|
|
68
|
+
display: inline-flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.cp-trust__link:hover .cp-trust__logo--mono {
|
|
73
|
+
color: var(--cp-c-text-1);
|
|
74
|
+
}
|
|
@@ -1,25 +1,68 @@
|
|
|
1
|
-
import { match } from 'massaman/match'
|
|
1
|
+
import { match, P } from 'massaman/match'
|
|
2
2
|
import type React from 'react'
|
|
3
3
|
|
|
4
|
+
import { renderRichText } from '../../lib/rich-text.tsx'
|
|
5
|
+
import { safeUrl } from '../../lib/safe-url.ts'
|
|
6
|
+
import { withMountBase } from '../../lib/with-mount-base.ts'
|
|
7
|
+
|
|
4
8
|
import './trust-strip.css'
|
|
5
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Default rendered height, in pixels, for a proof logo.
|
|
12
|
+
*/
|
|
13
|
+
const DEFAULT_LOGO_HEIGHT = 20
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A logo entry in the trust strip. Drawn as a mask filled with the
|
|
17
|
+
* current text color, so one asset covers every theme and both variants.
|
|
18
|
+
*/
|
|
19
|
+
export interface TrustLogo {
|
|
20
|
+
/**
|
|
21
|
+
* Public URL of the logo asset.
|
|
22
|
+
*/
|
|
23
|
+
readonly src: string
|
|
24
|
+
/**
|
|
25
|
+
* Accessible name for the logo.
|
|
26
|
+
*/
|
|
27
|
+
readonly alt: string
|
|
28
|
+
/**
|
|
29
|
+
* Optional link target. When set, the logo becomes a link.
|
|
30
|
+
*/
|
|
31
|
+
readonly href?: string
|
|
32
|
+
/**
|
|
33
|
+
* Rendered height in pixels. Defaults to {@link DEFAULT_LOGO_HEIGHT}.
|
|
34
|
+
*/
|
|
35
|
+
readonly height?: number
|
|
36
|
+
/**
|
|
37
|
+
* Draw the logo as a silhouette in the current text color instead of
|
|
38
|
+
* its own colors. Defaults to `false`.
|
|
39
|
+
*/
|
|
40
|
+
readonly mono?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One trust strip entry — a plain name or a {@link TrustLogo}.
|
|
45
|
+
*/
|
|
46
|
+
export type TrustItem = string | TrustLogo
|
|
47
|
+
|
|
6
48
|
export interface TrustStripProps {
|
|
7
49
|
/**
|
|
8
50
|
* Lead text shown before the names. Defaults to "used by".
|
|
9
51
|
*/
|
|
10
52
|
readonly lead?: string
|
|
11
53
|
/**
|
|
12
|
-
*
|
|
54
|
+
* Entries to render, separated by mono dots.
|
|
13
55
|
*/
|
|
14
|
-
readonly names: readonly
|
|
56
|
+
readonly names: readonly TrustItem[]
|
|
15
57
|
}
|
|
16
58
|
|
|
17
59
|
/**
|
|
18
|
-
* TrustStrip — a single-row "used by …" band with mono dots between
|
|
19
|
-
* Renders only when at least
|
|
60
|
+
* TrustStrip — a single-row "used by …" band with mono dots between
|
|
61
|
+
* entries. Entries are either text or logos. Renders only when at least
|
|
62
|
+
* one entry is supplied.
|
|
20
63
|
*
|
|
21
64
|
* @param props - Trust strip configuration.
|
|
22
|
-
* @returns React element, or null when no
|
|
65
|
+
* @returns React element, or null when no entries are provided.
|
|
23
66
|
*/
|
|
24
67
|
export function TrustStrip(props: TrustStripProps): React.ReactElement | null {
|
|
25
68
|
const { lead = 'used by', names } = props
|
|
@@ -28,12 +71,16 @@ export function TrustStrip(props: TrustStripProps): React.ReactElement | null {
|
|
|
28
71
|
.otherwise(() => (
|
|
29
72
|
<section className="cp-trust">
|
|
30
73
|
<div className="cp-trust__row">
|
|
31
|
-
<span className="cp-trust__lead">{lead}</span>
|
|
32
|
-
{names.map((
|
|
33
|
-
<span key={`${
|
|
34
|
-
|
|
74
|
+
<span className="cp-trust__lead">{renderRichText(lead)}</span>
|
|
75
|
+
{names.map((item, i) => (
|
|
76
|
+
<span key={`${itemKey(item)}:${i}`} className="cp-trust__item">
|
|
77
|
+
{renderItem(item)}
|
|
35
78
|
{match(i < names.length - 1)
|
|
36
|
-
.with(true, () =>
|
|
79
|
+
.with(true, () => (
|
|
80
|
+
<span aria-hidden="true" className="cp-trust__sep">
|
|
81
|
+
·
|
|
82
|
+
</span>
|
|
83
|
+
))
|
|
37
84
|
.otherwise(() => null)}
|
|
38
85
|
</span>
|
|
39
86
|
))}
|
|
@@ -41,3 +88,113 @@ export function TrustStrip(props: TrustStripProps): React.ReactElement | null {
|
|
|
41
88
|
</section>
|
|
42
89
|
))
|
|
43
90
|
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Stable key fragment for an entry.
|
|
94
|
+
*
|
|
95
|
+
* @private
|
|
96
|
+
* @param item - Entry to derive a key from
|
|
97
|
+
* @returns Key fragment
|
|
98
|
+
*/
|
|
99
|
+
function itemKey(item: TrustItem): string {
|
|
100
|
+
return match(item)
|
|
101
|
+
.with(P.string, (name) => name)
|
|
102
|
+
.otherwise((logo) => logo.src)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Render a single entry as either text or a themed logo.
|
|
107
|
+
*
|
|
108
|
+
* @private
|
|
109
|
+
* @param item - Entry to render
|
|
110
|
+
* @returns React node for the entry
|
|
111
|
+
*/
|
|
112
|
+
function renderItem(item: TrustItem): React.ReactNode {
|
|
113
|
+
return match(item)
|
|
114
|
+
.with(P.string, (name) => <span className="cp-trust__name">{renderRichText(name)}</span>)
|
|
115
|
+
.otherwise((logo) => renderLogo(logo))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Render a logo in its own colors, or as a silhouette when `mono` is set.
|
|
120
|
+
*
|
|
121
|
+
* A destination that fails {@link safeUrl} degrades to the bare mark
|
|
122
|
+
* rather than a dead anchor, matching how `<Card />` handles the same
|
|
123
|
+
* case — the logo stays visible without becoming a script-execution sink.
|
|
124
|
+
*
|
|
125
|
+
* @private
|
|
126
|
+
* @param logo - Logo entry to render
|
|
127
|
+
* @returns React node for the logo
|
|
128
|
+
*/
|
|
129
|
+
function renderLogo(logo: TrustLogo): React.ReactNode {
|
|
130
|
+
const mark = match(logo.mono === true)
|
|
131
|
+
.with(true, () => renderMonoMark(logo))
|
|
132
|
+
.otherwise(() => renderColorMark(logo))
|
|
133
|
+
|
|
134
|
+
return match(logo.href)
|
|
135
|
+
.with(P.string, (raw) =>
|
|
136
|
+
match(safeUrl(raw))
|
|
137
|
+
.with(P.string, (href) => (
|
|
138
|
+
<a className="cp-trust__link" href={href} rel="noreferrer" target="_blank">
|
|
139
|
+
{mark}
|
|
140
|
+
</a>
|
|
141
|
+
))
|
|
142
|
+
.otherwise(() => mark)
|
|
143
|
+
)
|
|
144
|
+
.otherwise(() => mark)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Render a logo as an image, preserving the asset's own colors.
|
|
149
|
+
*
|
|
150
|
+
* @private
|
|
151
|
+
* @param logo - Logo entry to render
|
|
152
|
+
* @returns React node for the logo
|
|
153
|
+
*/
|
|
154
|
+
function renderColorMark(logo: TrustLogo): React.ReactNode {
|
|
155
|
+
const { src, alt, height = DEFAULT_LOGO_HEIGHT } = logo
|
|
156
|
+
return (
|
|
157
|
+
<img
|
|
158
|
+
alt={alt}
|
|
159
|
+
className="cp-trust__logo"
|
|
160
|
+
src={withMountBase(src)}
|
|
161
|
+
style={{ height: `${height}px` }}
|
|
162
|
+
/>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Render a logo as a mask filled with the current text color.
|
|
168
|
+
*
|
|
169
|
+
* The mask reads only the asset's alpha channel, so one file works on
|
|
170
|
+
* every theme and in both light and dark without a second variant.
|
|
171
|
+
*
|
|
172
|
+
* @private
|
|
173
|
+
* @param logo - Logo entry to render
|
|
174
|
+
* @returns React node for the logo
|
|
175
|
+
*/
|
|
176
|
+
function renderMonoMark(logo: TrustLogo): React.ReactNode {
|
|
177
|
+
const { src, alt, height = DEFAULT_LOGO_HEIGHT } = logo
|
|
178
|
+
// `encodeURI` so a `"` or `)` in the filename cannot terminate the
|
|
179
|
+
// `url()` token — the CSSOM would reject the whole declaration, and a
|
|
180
|
+
// mask that fails to load leaves the fill unclipped, painting a solid
|
|
181
|
+
// text-coloured rectangle where the logo should be.
|
|
182
|
+
const mask = `url("${encodeURI(withMountBase(src))}")`
|
|
183
|
+
// The inner image is hidden but still laid out, so the masked wrapper
|
|
184
|
+
// inherits the asset's intrinsic aspect ratio. A mask alone has no
|
|
185
|
+
// intrinsic size, so without it every logo would collapse to zero width.
|
|
186
|
+
return (
|
|
187
|
+
<span
|
|
188
|
+
aria-label={alt}
|
|
189
|
+
className="cp-trust__logo cp-trust__logo--mono"
|
|
190
|
+
role="img"
|
|
191
|
+
style={{
|
|
192
|
+
height: `${height}px`,
|
|
193
|
+
maskImage: mask,
|
|
194
|
+
WebkitMaskImage: mask,
|
|
195
|
+
}}
|
|
196
|
+
>
|
|
197
|
+
<img alt="" aria-hidden="true" src={withMountBase(src)} />
|
|
198
|
+
</span>
|
|
199
|
+
)
|
|
200
|
+
}
|