@podoba/react 0.0.30 → 0.0.34
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/package.json +45 -6
- package/src/components/brand-page-header.tsx +244 -44
- package/src/components/button.tsx +3 -3
- package/src/components/cta-pill.tsx +19 -4
- package/src/components/dialog.tsx +4 -0
- package/src/components/input.tsx +13 -7
- package/src/components/rich-text-editor.tsx +9 -9
- package/src/components/select.tsx +13 -11
- package/src/components/side-panel.examples.tsx +80 -0
- package/src/components/side-panel.tsx +140 -0
- package/src/components/text.tsx +6 -4
- package/src/editor/block-editor.tsx +522 -0
- package/src/editor/index.ts +3 -0
- package/src/index.ts +1 -0
- package/src/utils/safe-link-url.ts +13 -0
- package/src/utils/uic.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podoba/react",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.34",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "podoba React components — React Aria Components + Tailwind primitives + layout, built with uic.",
|
|
6
6
|
"repository": {
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"main": "./src/index.ts",
|
|
14
14
|
"types": "./src/index.ts",
|
|
15
15
|
"exports": {
|
|
16
|
-
".": "./src/index.ts"
|
|
16
|
+
".": "./src/index.ts",
|
|
17
|
+
"./editor": "./src/editor/index.ts"
|
|
17
18
|
},
|
|
18
19
|
"files": [
|
|
19
20
|
"src",
|
|
@@ -27,17 +28,48 @@
|
|
|
27
28
|
"typecheck": "tsc --build"
|
|
28
29
|
},
|
|
29
30
|
"dependencies": {
|
|
30
|
-
"@podoba/tokens": "^0.0.
|
|
31
|
-
"@podoba/tailwind": "^0.0.
|
|
31
|
+
"@podoba/tokens": "^0.0.34",
|
|
32
|
+
"@podoba/tailwind": "^0.0.34",
|
|
32
33
|
"react-aria-components": "1.18.0",
|
|
33
34
|
"class-variance-authority": "0.7.1",
|
|
34
35
|
"clsx": "2.1.1",
|
|
35
36
|
"tailwind-merge": "3.6.0",
|
|
36
37
|
"@radix-ui/react-slot": "1.2.4"
|
|
37
38
|
},
|
|
39
|
+
"//tiptap": "Only `@podoba/react/editor` (BlockEditor) needs Tiptap. Optional peers, not dependencies: the base package must not make every consumer install ProseMirror, and a peer keeps ONE @tiptap/pm in the tree — two copies of prosemirror-model break instanceof across the editor.",
|
|
38
40
|
"peerDependencies": {
|
|
39
41
|
"react": ">=19",
|
|
40
|
-
"react-dom": ">=19"
|
|
42
|
+
"react-dom": ">=19",
|
|
43
|
+
"@tiptap/react": "^3.27.4",
|
|
44
|
+
"@tiptap/pm": "^3.27.4",
|
|
45
|
+
"@tiptap/starter-kit": "^3.27.4",
|
|
46
|
+
"@tiptap/extension-placeholder": "^3.27.4",
|
|
47
|
+
"@tiptap/extension-highlight": "^3.27.4",
|
|
48
|
+
"@tiptap/extension-task-list": "^3.27.4",
|
|
49
|
+
"@tiptap/extension-task-item": "^3.27.4"
|
|
50
|
+
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"@tiptap/react": {
|
|
53
|
+
"optional": true
|
|
54
|
+
},
|
|
55
|
+
"@tiptap/pm": {
|
|
56
|
+
"optional": true
|
|
57
|
+
},
|
|
58
|
+
"@tiptap/starter-kit": {
|
|
59
|
+
"optional": true
|
|
60
|
+
},
|
|
61
|
+
"@tiptap/extension-placeholder": {
|
|
62
|
+
"optional": true
|
|
63
|
+
},
|
|
64
|
+
"@tiptap/extension-highlight": {
|
|
65
|
+
"optional": true
|
|
66
|
+
},
|
|
67
|
+
"@tiptap/extension-task-list": {
|
|
68
|
+
"optional": true
|
|
69
|
+
},
|
|
70
|
+
"@tiptap/extension-task-item": {
|
|
71
|
+
"optional": true
|
|
72
|
+
}
|
|
41
73
|
},
|
|
42
74
|
"devDependencies": {
|
|
43
75
|
"react": "^19.2.1",
|
|
@@ -45,6 +77,13 @@
|
|
|
45
77
|
"@types/react": "^19.2.7",
|
|
46
78
|
"@types/react-dom": "^19.2.3",
|
|
47
79
|
"typescript": "^5.9.3",
|
|
48
|
-
"@types/bun": "^1.3.3"
|
|
80
|
+
"@types/bun": "^1.3.3",
|
|
81
|
+
"@tiptap/react": "^3.27.4",
|
|
82
|
+
"@tiptap/pm": "^3.27.4",
|
|
83
|
+
"@tiptap/starter-kit": "^3.27.4",
|
|
84
|
+
"@tiptap/extension-placeholder": "^3.27.4",
|
|
85
|
+
"@tiptap/extension-highlight": "^3.27.4",
|
|
86
|
+
"@tiptap/extension-task-list": "^3.27.4",
|
|
87
|
+
"@tiptap/extension-task-item": "^3.27.4"
|
|
49
88
|
}
|
|
50
89
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ReactNode, useState } from 'react'
|
|
1
|
+
import { type ReactNode, useEffect, useId, useRef, useState } from 'react'
|
|
2
2
|
import { Button } from './button'
|
|
3
3
|
import { DisplayHeading } from './text'
|
|
4
4
|
|
|
@@ -8,10 +8,9 @@ import { DisplayHeading } from './text'
|
|
|
8
8
|
*
|
|
9
9
|
* gs-platform's header is a two-column grid: a left "welcome" section
|
|
10
10
|
* (optional breadcrumbs + a large greeting line) and a right section holding an
|
|
11
|
-
* `ExpandableCTA` — a collapsed teal pill that expands into
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* fixed-sheet behaviour for a simpler inline disclosure.
|
|
11
|
+
* `ExpandableCTA` — a collapsed teal pill that expands into a create-hub panel.
|
|
12
|
+
* The supplied hero `CtaPill` owns the source mobile fixed-bar treatment; this
|
|
13
|
+
* header switches to the desktop 2/3 + 1/3 grid at the matching 768px breakpoint.
|
|
15
14
|
*
|
|
16
15
|
* The expandable CTA is a controlled disclosure: the collapsed teal pill is a
|
|
17
16
|
* React Aria `Button` (keyboard + focus ring + press handling) wired to a
|
|
@@ -28,6 +27,12 @@ export type BrandPageHeaderCrumb = {
|
|
|
28
27
|
onPress?: () => void
|
|
29
28
|
}
|
|
30
29
|
|
|
30
|
+
export type BrandPageHeaderCtaRenderProps = {
|
|
31
|
+
expanded: boolean
|
|
32
|
+
controls: string
|
|
33
|
+
toggle: () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
31
36
|
export type BrandPageHeaderProps = {
|
|
32
37
|
/** The large greeting / page-title slot (e.g. "Good morning Jonas 👋"). */
|
|
33
38
|
greeting: ReactNode
|
|
@@ -37,17 +42,19 @@ export type BrandPageHeaderProps = {
|
|
|
37
42
|
* gs-style "title to go back": a muted, clickable PARENT link rendered as the
|
|
38
43
|
* line ABOVE the title (so the title reads "Parent ⏎ Current"). Pass a real
|
|
39
44
|
* router `<Link>` so it's an anchor (cmd/middle-click, deep-link-safe), exactly
|
|
40
|
-
* like gs's `parentLink`. The header
|
|
45
|
+
* like gs's `parentLink`. The header uses the source's decorative `fg-subtle`
|
|
46
|
+
* (#b3b3b3) with a hover→fg affordance.
|
|
41
47
|
*/
|
|
42
48
|
parentLink?: ReactNode
|
|
43
49
|
/** Optional breadcrumb trail rendered above the greeting. */
|
|
44
50
|
breadcrumbs?: BrandPageHeaderCrumb[]
|
|
45
51
|
/**
|
|
46
|
-
* Arbitrary right-column CTA node (e.g. the gs hero `CtaPill` banner)
|
|
47
|
-
*
|
|
48
|
-
*
|
|
52
|
+
* Arbitrary right-column CTA node (e.g. the gs hero `CtaPill` banner), or a
|
|
53
|
+
* render function receiving the disclosure state and generated controls id.
|
|
54
|
+
* Prefer the render function when the CTA opens `createHub`, so its trigger can
|
|
55
|
+
* expose `aria-expanded` and `aria-controls`.
|
|
49
56
|
*/
|
|
50
|
-
cta?: ReactNode
|
|
57
|
+
cta?: ReactNode | ((state: BrandPageHeaderCtaRenderProps) => ReactNode)
|
|
51
58
|
/** Label on the collapsed teal "Create" pill. Required to render the CTA. */
|
|
52
59
|
ctaLabel?: ReactNode
|
|
53
60
|
/** Inline content revealed when the CTA expands (the create hub). */
|
|
@@ -59,11 +66,14 @@ export type BrandPageHeaderProps = {
|
|
|
59
66
|
closeLabel?: string
|
|
60
67
|
/** Sticky header on scroll. */
|
|
61
68
|
sticky?: boolean
|
|
69
|
+
/**
|
|
70
|
+
* Dock the supplied CTA to the safe bottom edge below 768px, matching the
|
|
71
|
+
* source Manager's collapsed ExpandableCTA. Enabled by default.
|
|
72
|
+
*/
|
|
73
|
+
mobileCtaDocked?: boolean
|
|
62
74
|
className?: string
|
|
63
75
|
}
|
|
64
76
|
|
|
65
|
-
const PANEL_ID = 'brand-page-header-create-hub'
|
|
66
|
-
|
|
67
77
|
export function BrandPageHeader({
|
|
68
78
|
greeting,
|
|
69
79
|
headingLevel = 1,
|
|
@@ -76,18 +86,152 @@ export function BrandPageHeader({
|
|
|
76
86
|
onExpandedChange,
|
|
77
87
|
closeLabel = 'Close',
|
|
78
88
|
sticky = false,
|
|
89
|
+
mobileCtaDocked = true,
|
|
79
90
|
className,
|
|
80
91
|
}: BrandPageHeaderProps) {
|
|
81
92
|
const [internalExpanded, setInternalExpanded] = useState(false)
|
|
93
|
+
const [mobileSheet, setMobileSheet] = useState(false)
|
|
94
|
+
const [mobileFull, setMobileFull] = useState(false)
|
|
82
95
|
const isControlled = expandedProp !== undefined
|
|
83
96
|
const expanded = isControlled ? expandedProp : internalExpanded
|
|
84
97
|
const hasExpandable = Boolean(createHub)
|
|
85
98
|
const HeadingTag = `h${headingLevel}` as const
|
|
99
|
+
const panelId = useId()
|
|
100
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
101
|
+
const contentRef = useRef<HTMLDivElement>(null)
|
|
102
|
+
const returnFocusRef = useRef<HTMLElement | null>(null)
|
|
103
|
+
const touchStartYRef = useRef(0)
|
|
104
|
+
const touchStartScrollTopRef = useRef(0)
|
|
86
105
|
|
|
87
106
|
const setExpanded = (next: boolean) => {
|
|
88
107
|
if (!isControlled) setInternalExpanded(next)
|
|
89
108
|
onExpandedChange?.(next)
|
|
90
109
|
}
|
|
110
|
+
const renderedCta =
|
|
111
|
+
typeof cta === 'function'
|
|
112
|
+
? cta({
|
|
113
|
+
expanded,
|
|
114
|
+
controls: panelId,
|
|
115
|
+
toggle: () => setExpanded(!expanded),
|
|
116
|
+
})
|
|
117
|
+
: cta
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (!expanded || !hasExpandable || typeof window === 'undefined') return
|
|
121
|
+
|
|
122
|
+
returnFocusRef.current =
|
|
123
|
+
document.activeElement instanceof HTMLElement ? document.activeElement : null
|
|
124
|
+
|
|
125
|
+
let lockedScrollY = 0
|
|
126
|
+
let restoreBody: (() => void) | undefined
|
|
127
|
+
|
|
128
|
+
const updateComposition = () => {
|
|
129
|
+
const isMobile = window.innerWidth < 768
|
|
130
|
+
setMobileSheet(isMobile)
|
|
131
|
+
|
|
132
|
+
const content = contentRef.current
|
|
133
|
+
setMobileFull(Boolean(isMobile && content && content.scrollHeight >= window.innerHeight * 0.4))
|
|
134
|
+
|
|
135
|
+
if (isMobile && !restoreBody) {
|
|
136
|
+
lockedScrollY = window.scrollY
|
|
137
|
+
const previous = {
|
|
138
|
+
overflow: document.body.style.overflow,
|
|
139
|
+
position: document.body.style.position,
|
|
140
|
+
top: document.body.style.top,
|
|
141
|
+
width: document.body.style.width,
|
|
142
|
+
}
|
|
143
|
+
document.body.style.overflow = 'hidden'
|
|
144
|
+
document.body.style.position = 'fixed'
|
|
145
|
+
document.body.style.top = `-${lockedScrollY}px`
|
|
146
|
+
document.body.style.width = '100%'
|
|
147
|
+
restoreBody = () => {
|
|
148
|
+
document.body.style.overflow = previous.overflow
|
|
149
|
+
document.body.style.position = previous.position
|
|
150
|
+
document.body.style.top = previous.top
|
|
151
|
+
document.body.style.width = previous.width
|
|
152
|
+
window.scrollTo(0, lockedScrollY)
|
|
153
|
+
}
|
|
154
|
+
} else if (!isMobile && restoreBody) {
|
|
155
|
+
restoreBody()
|
|
156
|
+
restoreBody = undefined
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const keepFocusInsideMobileSheet = (event: KeyboardEvent) => {
|
|
161
|
+
if (event.key === 'Escape') {
|
|
162
|
+
event.preventDefault()
|
|
163
|
+
setExpanded(false)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
if (event.key !== 'Tab' || window.innerWidth >= 768 || !panelRef.current) return
|
|
167
|
+
|
|
168
|
+
const focusable = Array.from(
|
|
169
|
+
panelRef.current.querySelectorAll<HTMLElement>(
|
|
170
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
171
|
+
),
|
|
172
|
+
).filter((node) => {
|
|
173
|
+
const style = window.getComputedStyle(node)
|
|
174
|
+
return (
|
|
175
|
+
!node.hidden &&
|
|
176
|
+
!node.closest('[hidden]') &&
|
|
177
|
+
node.getAttribute('aria-hidden') !== 'true' &&
|
|
178
|
+
style.display !== 'none' &&
|
|
179
|
+
style.visibility !== 'hidden'
|
|
180
|
+
)
|
|
181
|
+
})
|
|
182
|
+
if (focusable.length === 0) {
|
|
183
|
+
event.preventDefault()
|
|
184
|
+
panelRef.current.focus()
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
const first = focusable[0]
|
|
188
|
+
const last = focusable[focusable.length - 1]
|
|
189
|
+
if (!panelRef.current.contains(document.activeElement)) {
|
|
190
|
+
event.preventDefault()
|
|
191
|
+
const boundary = event.shiftKey ? last : first
|
|
192
|
+
boundary?.focus()
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
196
|
+
event.preventDefault()
|
|
197
|
+
last?.focus()
|
|
198
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
199
|
+
event.preventDefault()
|
|
200
|
+
first?.focus()
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
updateComposition()
|
|
205
|
+
const observer =
|
|
206
|
+
typeof ResizeObserver === 'undefined' || !contentRef.current
|
|
207
|
+
? undefined
|
|
208
|
+
: new ResizeObserver(updateComposition)
|
|
209
|
+
if (contentRef.current) observer?.observe(contentRef.current)
|
|
210
|
+
window.addEventListener('resize', updateComposition)
|
|
211
|
+
document.addEventListener('keydown', keepFocusInsideMobileSheet)
|
|
212
|
+
|
|
213
|
+
return () => {
|
|
214
|
+
observer?.disconnect()
|
|
215
|
+
window.removeEventListener('resize', updateComposition)
|
|
216
|
+
document.removeEventListener('keydown', keepFocusInsideMobileSheet)
|
|
217
|
+
restoreBody?.()
|
|
218
|
+
returnFocusRef.current?.focus()
|
|
219
|
+
returnFocusRef.current = null
|
|
220
|
+
setMobileSheet(false)
|
|
221
|
+
setMobileFull(false)
|
|
222
|
+
}
|
|
223
|
+
}, [expanded, hasExpandable])
|
|
224
|
+
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
if (!expanded || !hasExpandable || typeof window === 'undefined') return
|
|
227
|
+
const frame = window.requestAnimationFrame(() => {
|
|
228
|
+
const selector = mobileSheet
|
|
229
|
+
? '[data-create-hub-focus="mobile"]'
|
|
230
|
+
: '[data-create-hub-focus="desktop"]'
|
|
231
|
+
panelRef.current?.querySelector<HTMLElement>(selector)?.focus()
|
|
232
|
+
})
|
|
233
|
+
return () => window.cancelAnimationFrame(frame)
|
|
234
|
+
}, [expanded, hasExpandable, mobileSheet])
|
|
91
235
|
|
|
92
236
|
return (
|
|
93
237
|
<div
|
|
@@ -99,8 +243,8 @@ export function BrandPageHeader({
|
|
|
99
243
|
.filter(Boolean)
|
|
100
244
|
.join(' ')}
|
|
101
245
|
>
|
|
102
|
-
<div className="flex flex-col gap-2
|
|
103
|
-
<div className="flex min-w-0 flex-1 flex-col gap-1
|
|
246
|
+
<div className="flex flex-col gap-2 md:grid md:grid-cols-3 md:items-stretch md:gap-4">
|
|
247
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1 md:col-span-2">
|
|
104
248
|
{breadcrumbs && breadcrumbs.length > 0 ? (
|
|
105
249
|
<nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-1 text-compact text-fg-muted">
|
|
106
250
|
{breadcrumbs.map((crumb, i) => (
|
|
@@ -128,7 +272,7 @@ export function BrandPageHeader({
|
|
|
128
272
|
// `[&_a]` styles the nested router <Link> (anchor) without @app/ui
|
|
129
273
|
// importing the router.
|
|
130
274
|
<>
|
|
131
|
-
<span className="text-fg-
|
|
275
|
+
<span className="text-fg-subtle transition-colors [&_a:hover]:text-fg [&_a]:text-fg-subtle [&_a]:no-underline [&_a]:outline-none [&_a:focus-visible]:underline">
|
|
132
276
|
{parentLink}
|
|
133
277
|
</span>
|
|
134
278
|
<br />
|
|
@@ -141,14 +285,25 @@ export function BrandPageHeader({
|
|
|
141
285
|
|
|
142
286
|
{cta ? (
|
|
143
287
|
// Hero CTA spans 4 of 12 columns (one third) — the greeting takes the rest.
|
|
144
|
-
<div
|
|
288
|
+
<div
|
|
289
|
+
className={
|
|
290
|
+
[
|
|
291
|
+
mobileCtaDocked
|
|
292
|
+
? 'fixed inset-x-0 bottom-0 z-40 min-w-0 px-3 pb-mobile-cta-bottom md:static md:inset-auto md:z-auto md:h-full md:p-0'
|
|
293
|
+
: 'h-full min-w-0',
|
|
294
|
+
expanded ? 'hidden' : '',
|
|
295
|
+
].join(' ')
|
|
296
|
+
}
|
|
297
|
+
>
|
|
298
|
+
{renderedCta}
|
|
299
|
+
</div>
|
|
145
300
|
) : ctaLabel ? (
|
|
146
301
|
<div className="shrink-0">
|
|
147
302
|
{hasExpandable ? (
|
|
148
303
|
<Button
|
|
149
304
|
onPress={() => setExpanded(!expanded)}
|
|
150
305
|
aria-expanded={expanded}
|
|
151
|
-
aria-controls={
|
|
306
|
+
aria-controls={panelId}
|
|
152
307
|
className="h-10 rounded-full bg-brand-secondary px-5 text-small font-medium text-fg data-[hovered]:opacity-90 data-[pressed]:opacity-80"
|
|
153
308
|
>
|
|
154
309
|
{ctaLabel}
|
|
@@ -162,35 +317,80 @@ export function BrandPageHeader({
|
|
|
162
317
|
) : null}
|
|
163
318
|
</div>
|
|
164
319
|
|
|
165
|
-
{hasExpandable ? (
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
320
|
+
{hasExpandable && expanded ? (
|
|
321
|
+
<>
|
|
322
|
+
<div
|
|
323
|
+
aria-hidden="true"
|
|
324
|
+
onClick={() => setExpanded(false)}
|
|
325
|
+
className="fixed inset-0 z-40 bg-black/50 animate-create-hub-backdrop md:hidden motion-reduce:animate-none"
|
|
326
|
+
/>
|
|
327
|
+
<div
|
|
328
|
+
ref={panelRef}
|
|
329
|
+
id={panelId}
|
|
330
|
+
role={mobileSheet ? 'dialog' : 'region'}
|
|
331
|
+
aria-modal={mobileSheet || undefined}
|
|
332
|
+
aria-label={typeof ctaLabel === 'string' ? ctaLabel : undefined}
|
|
333
|
+
tabIndex={-1}
|
|
334
|
+
onTouchStart={(event) => {
|
|
335
|
+
const touch = event.touches[0]
|
|
336
|
+
if (!touch || !contentRef.current) return
|
|
337
|
+
touchStartYRef.current = touch.clientY
|
|
338
|
+
touchStartScrollTopRef.current = contentRef.current.scrollTop
|
|
339
|
+
}}
|
|
340
|
+
onTouchMove={(event) => {
|
|
341
|
+
const touch = event.touches[0]
|
|
342
|
+
if (
|
|
343
|
+
!touch ||
|
|
344
|
+
touchStartScrollTopRef.current > 10 ||
|
|
345
|
+
touch.clientY - touchStartYRef.current < 80
|
|
346
|
+
) {
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
event.preventDefault()
|
|
350
|
+
setExpanded(false)
|
|
351
|
+
}}
|
|
352
|
+
className={[
|
|
353
|
+
'fixed inset-x-0 bottom-0 z-50 flex flex-col overflow-hidden bg-brand-green',
|
|
354
|
+
'outline-none motion-reduce:animate-none',
|
|
355
|
+
'md:relative md:inset-auto md:z-auto md:mt-4 md:max-h-none md:origin-top-right md:rounded-lg md:animate-create-hub-desktop',
|
|
356
|
+
mobileFull
|
|
357
|
+
? 'top-0 max-h-create-hub-full animate-create-hub-full'
|
|
358
|
+
: 'max-h-create-hub-partial animate-create-hub-sheet',
|
|
359
|
+
].join(' ')}
|
|
360
|
+
>
|
|
361
|
+
<Button
|
|
362
|
+
variant="ghost"
|
|
363
|
+
aria-label={closeLabel}
|
|
364
|
+
data-create-hub-focus="mobile"
|
|
365
|
+
onPress={() => setExpanded(false)}
|
|
366
|
+
className="h-10 w-full shrink-0 rounded-none p-0 md:hidden data-[hovered]:bg-transparent"
|
|
367
|
+
>
|
|
368
|
+
<span className="h-1 w-10 rounded-full bg-fg/25" aria-hidden="true" />
|
|
369
|
+
</Button>
|
|
370
|
+
<Button
|
|
371
|
+
variant="ghost"
|
|
372
|
+
aria-label={closeLabel}
|
|
373
|
+
data-create-hub-focus="desktop"
|
|
374
|
+
onPress={() => setExpanded(false)}
|
|
375
|
+
className="absolute right-4 top-14 z-10 hidden h-8 w-8 rounded-md p-0 text-fg md:flex data-[hovered]:bg-black/5"
|
|
376
|
+
>
|
|
377
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
378
|
+
<path
|
|
379
|
+
d="M6 6l12 12M18 6L6 18"
|
|
380
|
+
stroke="currentColor"
|
|
381
|
+
strokeWidth="2"
|
|
382
|
+
strokeLinecap="round"
|
|
383
|
+
/>
|
|
384
|
+
</svg>
|
|
385
|
+
</Button>
|
|
386
|
+
<div
|
|
387
|
+
ref={contentRef}
|
|
388
|
+
className="min-h-0 flex-1 overflow-y-auto overscroll-contain md:overflow-visible"
|
|
389
|
+
>
|
|
190
390
|
{createHub}
|
|
191
391
|
</div>
|
|
192
|
-
|
|
193
|
-
|
|
392
|
+
</div>
|
|
393
|
+
</>
|
|
194
394
|
) : null}
|
|
195
395
|
</div>
|
|
196
396
|
)
|
|
@@ -19,9 +19,9 @@ export const Button = uic(RACButton, {
|
|
|
19
19
|
'data-[pending]:opacity-70 data-[pending]:cursor-progress',
|
|
20
20
|
variants: {
|
|
21
21
|
variant: {
|
|
22
|
-
// primary (gs `_primary`): base
|
|
23
|
-
// hover neutral-600 (#333333) + `0 0 5px rgba(0,0,0,.5)` shadow →
|
|
24
|
-
// to
|
|
22
|
+
// primary (gs `_primary`): base brand-primary (#0d0d0d light / brand-green on
|
|
23
|
+
// dark) → hover neutral-600 (#333333) + `0 0 5px rgba(0,0,0,.5)` shadow →
|
|
24
|
+
// active back to brand-primary (no shadow).
|
|
25
25
|
primary:
|
|
26
26
|
'bg-brand-primary text-fg-inverted hover:bg-neutral-600 hover:shadow-[0_0_5px_0_rgba(0,0,0,0.5)] data-[pressed]:bg-brand-primary data-[pressed]:shadow-none',
|
|
27
27
|
// secondary: #f7f6f2 (surface-card) + 1px #eceae1 (border); hover bg #eceae1
|
|
@@ -5,8 +5,11 @@ import type { ReactNode } from 'react'
|
|
|
5
5
|
* something" (the middle word emphasised) with an action control on the right.
|
|
6
6
|
*
|
|
7
7
|
* The copy is three fragments (`lead` · `emphasis` · `tail`) so a consumer can
|
|
8
|
-
* translate each per-locale while keeping the middle-word highlight.
|
|
9
|
-
*
|
|
8
|
+
* translate each per-locale while keeping the middle-word highlight. With
|
|
9
|
+
* `mobileHeader`, it adopts the source ExpandableCTA's 99px mobile touch surface;
|
|
10
|
+
* `BrandPageHeader` owns the safe-bottom positioning and returns it to the one-third
|
|
11
|
+
* desktop grid at the `md` breakpoint. Presentational only (hard rule #1): every
|
|
12
|
+
* string arrives via props — no i18n, no domain data.
|
|
10
13
|
*
|
|
11
14
|
* a11y NOTE: the background is the FIXED light brand-secondary (#6eddb1) — it does
|
|
12
15
|
* not flip with the theme, so every sentence fragment sits on `fg-on-brand` (stable
|
|
@@ -23,11 +26,23 @@ export interface CtaPillProps {
|
|
|
23
26
|
tail: ReactNode
|
|
24
27
|
/** Right-side action control (e.g. a Create button). */
|
|
25
28
|
children: ReactNode
|
|
29
|
+
/**
|
|
30
|
+
* Use the source mobile header density (99px minimum height and doubled
|
|
31
|
+
* horizontal content padding). Positioning stays with `BrandPageHeader`.
|
|
32
|
+
*/
|
|
33
|
+
mobileHeader?: boolean
|
|
26
34
|
}
|
|
27
35
|
|
|
28
|
-
export function CtaPill({ lead, emphasis, tail, children }: CtaPillProps) {
|
|
36
|
+
export function CtaPill({ lead, emphasis, tail, children, mobileHeader = false }: CtaPillProps) {
|
|
29
37
|
return (
|
|
30
|
-
<div
|
|
38
|
+
<div
|
|
39
|
+
className={[
|
|
40
|
+
'flex h-full w-full items-center justify-between gap-nav-x rounded-lg bg-brand-green',
|
|
41
|
+
mobileHeader
|
|
42
|
+
? 'min-h-mobile-cta px-12 py-5 shadow-mobile-cta md:min-h-16 md:py-2.5 md:pr-3 md:pl-4.5 md:shadow-none'
|
|
43
|
+
: 'min-h-16 py-2.5 pr-3 pl-4.5',
|
|
44
|
+
].join(' ')}
|
|
45
|
+
>
|
|
31
46
|
<p className="min-w-0 text-heading4 font-medium leading-5 tracking-tight text-fg-on-brand">
|
|
32
47
|
{lead} <span className="font-semibold">{emphasis}</span> {tail}
|
|
33
48
|
</p>
|
|
@@ -133,6 +133,10 @@ export const Dialog = ({
|
|
|
133
133
|
// Either way the RACDialog render-prop `close` resolves against the active
|
|
134
134
|
// overlay state, so `close()` works in both modes.
|
|
135
135
|
<ModalOverlay
|
|
136
|
+
// Full-screen is an immersive takeover: swap the light scrim for a heavier
|
|
137
|
+
// backdrop blur so the page behind reads as clearly blurred around the
|
|
138
|
+
// near-fullscreen canvas. (tailwind-merge dedupes the base blur/scrim.)
|
|
139
|
+
className={isFlex ? 'bg-black/25 backdrop-blur-lg' : undefined}
|
|
136
140
|
isOpen={isOpen}
|
|
137
141
|
defaultOpen={defaultOpen}
|
|
138
142
|
onOpenChange={onOpenChange}
|
package/src/components/input.tsx
CHANGED
|
@@ -20,15 +20,21 @@ import { uic } from '../utils/uic'
|
|
|
20
20
|
*/
|
|
21
21
|
const StyledInput = uic(RACInput, {
|
|
22
22
|
displayName: 'InputControl',
|
|
23
|
-
//
|
|
24
|
-
//
|
|
23
|
+
// White fill + border so the field reads as editable. The gs original used a
|
|
24
|
+
// borderless cream (#f7f6f2 → surface-card) fill, but our Card surface is ALSO
|
|
25
|
+
// surface-card — and in dark theme surface-card and surface are the SAME colour
|
|
26
|
+
// (#242423) — so a borderless cream field vanishes on both. Inverted: active =
|
|
27
|
+
// surface, disabled = the muted cream. border #eceae1 → border · hover #aba89c →
|
|
28
|
+
// fg-subtle · focus #75e7b8 → brand-green · error → danger. This is the shared
|
|
29
|
+
// filled-field skin (textarea / combobox / date-field / number-field /
|
|
30
|
+
// search-field); `fieldSize` adds the single-line height on top.
|
|
25
31
|
baseClass:
|
|
26
|
-
'w-full rounded-lg border-
|
|
32
|
+
'w-full rounded-lg border border-border bg-surface px-4 text-small text-fg ' +
|
|
27
33
|
'outline-none transition-colors duration-200 placeholder:text-fg-muted ' +
|
|
28
|
-
'data-[hovered]:
|
|
29
|
-
'data-[focused]:ring-2 data-[focused]:ring-ring ' +
|
|
30
|
-
'data-[invalid]:ring-2 data-[invalid]:ring-danger ' +
|
|
31
|
-
'data-[disabled]:bg-surface-muted data-[disabled]:opacity-
|
|
34
|
+
'data-[hovered]:border-fg-subtle ' +
|
|
35
|
+
'data-[focused]:border-brand-green data-[focused]:ring-2 data-[focused]:ring-ring ' +
|
|
36
|
+
'data-[invalid]:border-danger data-[invalid]:ring-2 data-[invalid]:ring-danger ' +
|
|
37
|
+
'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60 data-[disabled]:pointer-events-none',
|
|
32
38
|
variants: {
|
|
33
39
|
// `fieldSize` (not `size`) to avoid colliding with the native <input size>
|
|
34
40
|
// attribute, which RAC's Input inherits (a numeric prop).
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { type ClipboardEvent as ReactClipboardEvent, type ReactNode, useEffect, useRef } from 'react'
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
|
+
import { SAFE_LINK_HINT, safeLinkUrl } from '../utils/safe-link-url'
|
|
3
4
|
import { useInFocusOverlay } from './focus-context'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* RichTextEditor — a dependency-free contentEditable WYSIWYG that emits an HTML
|
|
7
8
|
* string (round-trips with any `set:html` / `dangerouslySetInnerHTML` renderer,
|
|
8
9
|
* so no value migration and no bundled ProseMirror). Ported from pramen's
|
|
9
|
-
* cms-editor `RichText
|
|
10
|
+
* cms-editor `RichText`.
|
|
11
|
+
*
|
|
12
|
+
* Still the right pick for a short caption/bio field. For document-shaped content
|
|
13
|
+
* — blocks, a `/` palette, tables/embeds — reach for `BlockEditor` from
|
|
14
|
+
* `@podoba/react/editor` instead; it is the Tiptap-backed upgrade this component's
|
|
15
|
+
* `execCommand` core cannot grow into. Both emit the same HTML-string value, so
|
|
16
|
+
* swapping one for the other needs no data migration.
|
|
10
17
|
*
|
|
11
18
|
* The `prose prose-sm` body relies on the @tailwindcss/typography plugin, which
|
|
12
19
|
* @podoba/tailwind's preset already registers.
|
|
@@ -32,7 +39,7 @@ const RT_TOOLS: Array<{ label: string; title: string; run: (exec: (c: string, a?
|
|
|
32
39
|
if (!raw) return
|
|
33
40
|
const url = safeLinkUrl(raw)
|
|
34
41
|
if (!url) {
|
|
35
|
-
window.alert(
|
|
42
|
+
window.alert(SAFE_LINK_HINT)
|
|
36
43
|
return
|
|
37
44
|
}
|
|
38
45
|
x('createLink', url)
|
|
@@ -42,13 +49,6 @@ const RT_TOOLS: Array<{ label: string; title: string; run: (exec: (c: string, a?
|
|
|
42
49
|
{ label: 'Clear', title: 'Clear formatting', run: (x) => x('removeFormat') },
|
|
43
50
|
]
|
|
44
51
|
|
|
45
|
-
/** Allow-list for a link href: http(s), mailto, tel, or a relative/anchor path.
|
|
46
|
-
* The prefix allow-list inherently rejects `javascript:`/`data:`/`vbscript:`. */
|
|
47
|
-
function safeLinkUrl(raw: string): string | null {
|
|
48
|
-
const url = raw.trim()
|
|
49
|
-
return /^(https?:\/\/|mailto:|tel:|\/|#)/i.test(url) ? url : null
|
|
50
|
-
}
|
|
51
|
-
|
|
52
52
|
/** Cosmetic scrub (NOT a security boundary — sanitise on the server). */
|
|
53
53
|
function scrubHtml(html: string): string {
|
|
54
54
|
return html
|
|
@@ -21,24 +21,26 @@ import { useInFocusOverlay } from './focus-context'
|
|
|
21
21
|
* RAC handles the listbox ARIA pattern, keyboard navigation, typeahead and
|
|
22
22
|
* focus management. Styling via `uic`. Pass options as `SelectItem` children.
|
|
23
23
|
*
|
|
24
|
-
* Re-skinned
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* hover #eceae1 → surface-muted · text #0d0d0d → fg · placeholder → fg-muted ·
|
|
24
|
+
* Re-skinned to gs-platform's designer spec (Figma GraphicStandard 1.5, node
|
|
25
|
+
* 2115-4271 — `Select.module.scss`): a tall (58px) filled trigger matching our
|
|
26
|
+
* `Input` / `Textarea` so the form controls stay consistent. gs token map:
|
|
27
|
+
* hover border #aba89c → fg-subtle · text #0d0d0d → fg · placeholder → fg-muted ·
|
|
29
28
|
* 8px radius → rounded-lg · 6px item radius → rounded-md · error → danger.
|
|
30
29
|
*/
|
|
31
30
|
const SelectTrigger = uic(RACButton, {
|
|
32
31
|
displayName: 'SelectTrigger',
|
|
33
|
-
//
|
|
34
|
-
// fill
|
|
32
|
+
// gs source control geometry (58px tall, 20px inline padding, 8px radius) with
|
|
33
|
+
// the shared bordered fill: gs draws it borderless on cream, but our Card is
|
|
34
|
+
// also surface-card and dark theme collapses surface-card onto surface, so a
|
|
35
|
+
// borderless cream trigger disappears. White fill + border keeps it visible and
|
|
36
|
+
// consistent with the other form controls; hover darkens the border.
|
|
35
37
|
baseClass:
|
|
36
|
-
'flex h-control-tall w-full items-center justify-between gap-2.5 rounded-lg border-
|
|
38
|
+
'flex h-control-tall w-full items-center justify-between gap-2.5 rounded-lg border border-border bg-surface px-5 ' +
|
|
37
39
|
'text-small text-fg outline-none transition-colors ' +
|
|
38
|
-
'data-[hovered]:
|
|
40
|
+
'data-[hovered]:border-fg-subtle ' +
|
|
39
41
|
'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring ' +
|
|
40
|
-
'group-data-[invalid]:ring-2 group-data-[invalid]:ring-danger ' +
|
|
41
|
-
'data-[disabled]:bg-surface-muted data-[disabled]:opacity-
|
|
42
|
+
'group-data-[invalid]:border-danger group-data-[invalid]:ring-2 group-data-[invalid]:ring-danger ' +
|
|
43
|
+
'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60 data-[disabled]:pointer-events-none',
|
|
42
44
|
})
|
|
43
45
|
|
|
44
46
|
export const SelectItem = uic(ListBoxItem, {
|