@podoba/react 0.0.32 → 0.0.35
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 +3 -3
- package/src/components/asset-masonry-grid.examples.tsx +9 -0
- package/src/components/asset-masonry-grid.tsx +62 -0
- package/src/components/asset-selection-surface.examples.tsx +32 -0
- package/src/components/asset-selection-surface.tsx +184 -0
- package/src/components/brand-page-header.tsx +297 -67
- package/src/components/button.tsx +27 -4
- package/src/components/combobox.tsx +1 -1
- package/src/components/compact-action-button.examples.tsx +7 -0
- package/src/components/compact-action-button.tsx +21 -0
- package/src/components/compact-settings-dialog.examples.tsx +15 -0
- package/src/components/compact-settings-dialog.tsx +48 -0
- package/src/components/context-action-glyph.examples.tsx +9 -0
- package/src/components/context-action-glyph.tsx +61 -0
- package/src/components/context-menu.tsx +191 -107
- package/src/components/context-search-panel.examples.tsx +9 -0
- package/src/components/context-search-panel.tsx +50 -0
- package/src/components/csv-binding-presentation.examples.tsx +14 -0
- package/src/components/csv-binding-presentation.tsx +40 -0
- package/src/components/cta-pill.tsx +19 -4
- package/src/components/dashboard-grid.tsx +1 -1
- package/src/components/date-picker.tsx +5 -3
- package/src/components/date-selection-calendar.examples.tsx +8 -0
- package/src/components/date-selection-calendar.tsx +47 -0
- package/src/components/delivery/send-to-print-modal.tsx +351 -84
- package/src/components/dialog-action-button.examples.tsx +7 -0
- package/src/components/dialog-action-button.tsx +22 -0
- package/src/components/dialog.tsx +32 -12
- package/src/components/document-upload-panel.examples.tsx +12 -0
- package/src/components/document-upload-panel.tsx +48 -0
- package/src/components/dropdown-menu.tsx +5 -3
- package/src/components/empty-panel-action.examples.tsx +7 -0
- package/src/components/empty-panel-action.tsx +8 -0
- package/src/components/field-appearance.ts +14 -0
- package/src/components/focus-field.tsx +3 -3
- package/src/components/icons.tsx +30 -0
- package/src/components/input.examples.tsx +8 -0
- package/src/components/input.tsx +13 -5
- package/src/components/media-asset-panel.examples.tsx +11 -0
- package/src/components/media-asset-panel.tsx +58 -0
- package/src/components/media-gallery.examples.tsx +12 -0
- package/src/components/media-gallery.tsx +53 -0
- package/src/components/media-settings-dialog.examples.tsx +21 -0
- package/src/components/media-settings-dialog.tsx +83 -0
- package/src/components/preview-info-card.examples.tsx +18 -0
- package/src/components/preview-info-card.tsx +27 -0
- package/src/components/reload-icon.examples.tsx +7 -0
- package/src/components/reload-icon.tsx +6 -0
- package/src/components/request-changes-modal.examples.tsx +26 -0
- package/src/components/request-changes-modal.tsx +36 -19
- package/src/components/rich-text-editor.tsx +1 -1
- package/src/components/select.examples.tsx +17 -0
- package/src/components/select.tsx +78 -22
- package/src/components/settings-dialog-surface.examples.tsx +44 -0
- package/src/components/settings-dialog-surface.tsx +240 -0
- package/src/components/side-panel.examples.tsx +80 -0
- package/src/components/side-panel.tsx +140 -0
- package/src/components/subtle.tsx +64 -8
- package/src/components/table.examples.tsx +9 -0
- package/src/components/table.tsx +29 -12
- package/src/components/template-catalog-card.examples.tsx +25 -0
- package/src/components/template-catalog-card.tsx +178 -0
- package/src/components/text.tsx +14 -1
- package/src/components/textarea.examples.tsx +8 -0
- package/src/components/textarea.tsx +16 -6
- package/src/components/tile.tsx +16 -17
- package/src/editor/block-editor.tsx +56 -14
- package/src/index.ts +20 -0
- package/src/layout/app-shell.tsx +13 -9
- package/src/layout/topbar.tsx +9 -12
|
@@ -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,27 +27,39 @@ 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
|
+
|
|
36
|
+
export type BrandPageHeaderVariant = 'dashboard' | 'default' | 'navigation'
|
|
37
|
+
|
|
31
38
|
export type BrandPageHeaderProps = {
|
|
32
39
|
/** The large greeting / page-title slot (e.g. "Good morning Jonas 👋"). */
|
|
33
40
|
greeting: ReactNode
|
|
41
|
+
/** Header geometry preset. Dashboard uses the source 20px desktop gutter. */
|
|
42
|
+
variant?: BrandPageHeaderVariant
|
|
34
43
|
/** Semantic heading level. Use 2 or 3 when the header is nested below a page title. */
|
|
35
44
|
headingLevel?: 1 | 2 | 3
|
|
36
45
|
/**
|
|
37
46
|
* gs-style "title to go back": a muted, clickable PARENT link rendered as the
|
|
38
47
|
* line ABOVE the title (so the title reads "Parent ⏎ Current"). Pass a real
|
|
39
48
|
* router `<Link>` so it's an anchor (cmd/middle-click, deep-link-safe), exactly
|
|
40
|
-
* like gs's `parentLink`.
|
|
41
|
-
*
|
|
49
|
+
* like gs's `parentLink`. Rendered in `fg-muted` with a hover→fg affordance —
|
|
50
|
+
* the source's decorative `fg-subtle` is 2.10:1 on `surface`, and this row is a
|
|
51
|
+
* navigation link, not ornament (#25).
|
|
42
52
|
*/
|
|
43
53
|
parentLink?: ReactNode
|
|
44
54
|
/** Optional breadcrumb trail rendered above the greeting. */
|
|
45
55
|
breadcrumbs?: BrandPageHeaderCrumb[]
|
|
46
56
|
/**
|
|
47
|
-
* Arbitrary right-column CTA node (e.g. the gs hero `CtaPill` banner)
|
|
48
|
-
*
|
|
49
|
-
*
|
|
57
|
+
* Arbitrary right-column CTA node (e.g. the gs hero `CtaPill` banner), or a
|
|
58
|
+
* render function receiving the disclosure state and generated controls id.
|
|
59
|
+
* Prefer the render function when the CTA opens `createHub`, so its trigger can
|
|
60
|
+
* expose `aria-expanded` and `aria-controls`.
|
|
50
61
|
*/
|
|
51
|
-
cta?: ReactNode
|
|
62
|
+
cta?: ReactNode | ((state: BrandPageHeaderCtaRenderProps) => ReactNode)
|
|
52
63
|
/** Label on the collapsed teal "Create" pill. Required to render the CTA. */
|
|
53
64
|
ctaLabel?: ReactNode
|
|
54
65
|
/** Inline content revealed when the CTA expands (the create hub). */
|
|
@@ -60,13 +71,17 @@ export type BrandPageHeaderProps = {
|
|
|
60
71
|
closeLabel?: string
|
|
61
72
|
/** Sticky header on scroll. */
|
|
62
73
|
sticky?: boolean
|
|
74
|
+
/**
|
|
75
|
+
* Dock the supplied CTA to the safe bottom edge below 768px, matching the
|
|
76
|
+
* source Manager's collapsed ExpandableCTA. Enabled by default.
|
|
77
|
+
*/
|
|
78
|
+
mobileCtaDocked?: boolean
|
|
63
79
|
className?: string
|
|
64
80
|
}
|
|
65
81
|
|
|
66
|
-
const PANEL_ID = 'brand-page-header-create-hub'
|
|
67
|
-
|
|
68
82
|
export function BrandPageHeader({
|
|
69
83
|
greeting,
|
|
84
|
+
variant = 'default',
|
|
70
85
|
headingLevel = 1,
|
|
71
86
|
parentLink,
|
|
72
87
|
breadcrumbs,
|
|
@@ -77,41 +92,196 @@ export function BrandPageHeader({
|
|
|
77
92
|
onExpandedChange,
|
|
78
93
|
closeLabel = 'Close',
|
|
79
94
|
sticky = false,
|
|
95
|
+
mobileCtaDocked = true,
|
|
80
96
|
className,
|
|
81
97
|
}: BrandPageHeaderProps) {
|
|
82
98
|
const [internalExpanded, setInternalExpanded] = useState(false)
|
|
99
|
+
const [mobileSheet, setMobileSheet] = useState(false)
|
|
100
|
+
const [mobileFull, setMobileFull] = useState(false)
|
|
83
101
|
const isControlled = expandedProp !== undefined
|
|
84
102
|
const expanded = isControlled ? expandedProp : internalExpanded
|
|
85
103
|
const hasExpandable = Boolean(createHub)
|
|
86
104
|
const HeadingTag = `h${headingLevel}` as const
|
|
105
|
+
const panelId = useId()
|
|
106
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
107
|
+
const contentRef = useRef<HTMLDivElement>(null)
|
|
108
|
+
const returnFocusRef = useRef<HTMLElement | null>(null)
|
|
109
|
+
const touchStartYRef = useRef(0)
|
|
110
|
+
const touchStartScrollTopRef = useRef(0)
|
|
87
111
|
|
|
88
112
|
const setExpanded = (next: boolean) => {
|
|
89
113
|
if (!isControlled) setInternalExpanded(next)
|
|
90
114
|
onExpandedChange?.(next)
|
|
91
115
|
}
|
|
116
|
+
const renderedCta =
|
|
117
|
+
typeof cta === 'function'
|
|
118
|
+
? cta({
|
|
119
|
+
expanded,
|
|
120
|
+
controls: panelId,
|
|
121
|
+
toggle: () => setExpanded(!expanded),
|
|
122
|
+
})
|
|
123
|
+
: cta
|
|
124
|
+
// Render the WHOLE trail the caller passed. Collapsing it to `breadcrumbs[-2]`
|
|
125
|
+
// silently dropped every other crumb (a three-crumb trail rendered one) and
|
|
126
|
+
// dropped a single crumb entirely unless it happened to be pressable. A caller
|
|
127
|
+
// that wants only the parent passes only the parent.
|
|
128
|
+
const visibleBreadcrumbs = breadcrumbs?.filter(Boolean) ?? []
|
|
129
|
+
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (!expanded || !hasExpandable || typeof window === 'undefined') return
|
|
132
|
+
|
|
133
|
+
returnFocusRef.current =
|
|
134
|
+
document.activeElement instanceof HTMLElement ? document.activeElement : null
|
|
135
|
+
|
|
136
|
+
let lockedScrollY = 0
|
|
137
|
+
let restoreBody: (() => void) | undefined
|
|
138
|
+
|
|
139
|
+
const updateComposition = () => {
|
|
140
|
+
const isMobile = window.innerWidth < 768
|
|
141
|
+
setMobileSheet(isMobile)
|
|
142
|
+
|
|
143
|
+
const content = contentRef.current
|
|
144
|
+
setMobileFull(Boolean(isMobile && content && content.scrollHeight >= window.innerHeight * 0.4))
|
|
145
|
+
|
|
146
|
+
if (isMobile && !restoreBody) {
|
|
147
|
+
lockedScrollY = window.scrollY
|
|
148
|
+
const previous = {
|
|
149
|
+
overflow: document.body.style.overflow,
|
|
150
|
+
position: document.body.style.position,
|
|
151
|
+
top: document.body.style.top,
|
|
152
|
+
width: document.body.style.width,
|
|
153
|
+
}
|
|
154
|
+
document.body.style.overflow = 'hidden'
|
|
155
|
+
document.body.style.position = 'fixed'
|
|
156
|
+
document.body.style.top = `-${lockedScrollY}px`
|
|
157
|
+
document.body.style.width = '100%'
|
|
158
|
+
restoreBody = () => {
|
|
159
|
+
document.body.style.overflow = previous.overflow
|
|
160
|
+
document.body.style.position = previous.position
|
|
161
|
+
document.body.style.top = previous.top
|
|
162
|
+
document.body.style.width = previous.width
|
|
163
|
+
window.scrollTo(0, lockedScrollY)
|
|
164
|
+
}
|
|
165
|
+
} else if (!isMobile && restoreBody) {
|
|
166
|
+
restoreBody()
|
|
167
|
+
restoreBody = undefined
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const keepFocusInsideMobileSheet = (event: KeyboardEvent) => {
|
|
172
|
+
if (event.key === 'Escape') {
|
|
173
|
+
event.preventDefault()
|
|
174
|
+
setExpanded(false)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
if (event.key !== 'Tab' || window.innerWidth >= 768 || !panelRef.current) return
|
|
178
|
+
|
|
179
|
+
const focusable = Array.from(
|
|
180
|
+
panelRef.current.querySelectorAll<HTMLElement>(
|
|
181
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
182
|
+
),
|
|
183
|
+
).filter((node) => {
|
|
184
|
+
const style = window.getComputedStyle(node)
|
|
185
|
+
return (
|
|
186
|
+
!node.hidden &&
|
|
187
|
+
!node.closest('[hidden]') &&
|
|
188
|
+
node.getAttribute('aria-hidden') !== 'true' &&
|
|
189
|
+
style.display !== 'none' &&
|
|
190
|
+
style.visibility !== 'hidden'
|
|
191
|
+
)
|
|
192
|
+
})
|
|
193
|
+
if (focusable.length === 0) {
|
|
194
|
+
event.preventDefault()
|
|
195
|
+
panelRef.current.focus()
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
const first = focusable[0]
|
|
199
|
+
const last = focusable[focusable.length - 1]
|
|
200
|
+
if (!panelRef.current.contains(document.activeElement)) {
|
|
201
|
+
event.preventDefault()
|
|
202
|
+
const boundary = event.shiftKey ? last : first
|
|
203
|
+
boundary?.focus()
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
207
|
+
event.preventDefault()
|
|
208
|
+
last?.focus()
|
|
209
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
210
|
+
event.preventDefault()
|
|
211
|
+
first?.focus()
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
updateComposition()
|
|
216
|
+
const observer =
|
|
217
|
+
typeof ResizeObserver === 'undefined' || !contentRef.current
|
|
218
|
+
? undefined
|
|
219
|
+
: new ResizeObserver(updateComposition)
|
|
220
|
+
if (contentRef.current) observer?.observe(contentRef.current)
|
|
221
|
+
window.addEventListener('resize', updateComposition)
|
|
222
|
+
document.addEventListener('keydown', keepFocusInsideMobileSheet)
|
|
223
|
+
|
|
224
|
+
return () => {
|
|
225
|
+
observer?.disconnect()
|
|
226
|
+
window.removeEventListener('resize', updateComposition)
|
|
227
|
+
document.removeEventListener('keydown', keepFocusInsideMobileSheet)
|
|
228
|
+
restoreBody?.()
|
|
229
|
+
returnFocusRef.current?.focus()
|
|
230
|
+
returnFocusRef.current = null
|
|
231
|
+
setMobileSheet(false)
|
|
232
|
+
setMobileFull(false)
|
|
233
|
+
}
|
|
234
|
+
}, [expanded, hasExpandable])
|
|
235
|
+
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
if (!expanded || !hasExpandable || typeof window === 'undefined') return
|
|
238
|
+
const frame = window.requestAnimationFrame(() => {
|
|
239
|
+
const selector = mobileSheet
|
|
240
|
+
? '[data-create-hub-focus="mobile"]'
|
|
241
|
+
: '[data-create-hub-focus="desktop"]'
|
|
242
|
+
panelRef.current?.querySelector<HTMLElement>(selector)?.focus()
|
|
243
|
+
})
|
|
244
|
+
return () => window.cancelAnimationFrame(frame)
|
|
245
|
+
}, [expanded, hasExpandable, mobileSheet])
|
|
92
246
|
|
|
93
247
|
return (
|
|
94
248
|
<div
|
|
95
249
|
className={[
|
|
96
|
-
'mb-6 w-
|
|
97
|
-
sticky
|
|
250
|
+
'mb-6 box-border w-auto min-w-0',
|
|
251
|
+
sticky
|
|
252
|
+
? [
|
|
253
|
+
'relative -mx-3 px-3',
|
|
254
|
+
'md:sticky md:top-0 md:z-30 md:-mx-6 md:bg-surface md:px-6 md:pb-6 md:mb-0',
|
|
255
|
+
expanded ? 'md:pb-0' : '',
|
|
256
|
+
].join(' ')
|
|
257
|
+
: '',
|
|
98
258
|
className,
|
|
99
259
|
]
|
|
100
260
|
.filter(Boolean)
|
|
101
261
|
.join(' ')}
|
|
102
262
|
>
|
|
103
|
-
<div
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
263
|
+
<div
|
|
264
|
+
className={`grid grid-cols-1 grid-rows-[auto_auto] items-start gap-y-2 ${
|
|
265
|
+
variant === 'navigation' ? 'md:grid-cols-2' : 'md:grid-cols-[2fr_1fr]'
|
|
266
|
+
} ${variant === 'dashboard' ? 'md:gap-x-5' : 'md:gap-x-4'}`}
|
|
267
|
+
>
|
|
268
|
+
<div className="col-start-1 row-[1/-1] flex w-full min-w-0 flex-col gap-0">
|
|
269
|
+
{/* EXACTLY ONE Breadcrumb landmark. Rendering the trail and `parentLink`
|
|
270
|
+
as siblings produced two <nav aria-label="Breadcrumb"> in one header —
|
|
271
|
+
duplicate landmarks sharing an accessible name. `breadcrumbs` is the
|
|
272
|
+
richer API, so it wins when a caller passes both.
|
|
273
|
+
Ink is `fg-muted` (5.98:1), NOT `fg-subtle` (2.10:1 on surface): a
|
|
274
|
+
crumb is a navigation link, which #25 keeps on the readable token. */}
|
|
275
|
+
{visibleBreadcrumbs.length > 0 ? (
|
|
276
|
+
<nav aria-label="Breadcrumb" className="m-0 flex w-full min-w-0 flex-wrap items-center gap-1 text-display-large font-medium tracking-wide text-fg-muted">
|
|
277
|
+
{visibleBreadcrumbs.map((crumb, i) => (
|
|
108
278
|
<span key={i} className="inline-flex items-center gap-1">
|
|
109
279
|
{i > 0 ? <span aria-hidden="true">/</span> : null}
|
|
110
280
|
{crumb.onPress ? (
|
|
111
281
|
<Button
|
|
112
282
|
variant="ghost"
|
|
113
283
|
onPress={crumb.onPress}
|
|
114
|
-
className="h-auto rounded-sm p-0 text-
|
|
284
|
+
className="h-auto rounded-sm p-0 text-display-large font-medium tracking-wide text-fg-muted data-[hovered]:bg-transparent data-[hovered]:text-fg"
|
|
115
285
|
>
|
|
116
286
|
{crumb.label}
|
|
117
287
|
</Button>
|
|
@@ -121,35 +291,50 @@ export function BrandPageHeader({
|
|
|
121
291
|
</span>
|
|
122
292
|
))}
|
|
123
293
|
</nav>
|
|
294
|
+
) : parentLink ? (
|
|
295
|
+
// gs "title to go back". MERGE NOTE: the breadcrumb is a NAV LANDMARK,
|
|
296
|
+
// not part of the <h1> — a page title should not contain its own parent
|
|
297
|
+
// link. #25 still applies to the colour: the source's decorative grey is
|
|
298
|
+
// 2.10:1 on `surface`, and this row is a navigation LINK, so it keeps the
|
|
299
|
+
// readable `fg-muted` (5.98:1) rather than `fg-subtle`.
|
|
300
|
+
<nav
|
|
301
|
+
aria-label="Breadcrumb"
|
|
302
|
+
className="h-8 text-display-large font-medium leading-8 tracking-wide text-fg-muted transition-colors [&_a:hover]:text-fg [&_a]:block [&_a]:h-8 [&_a]:text-fg-muted [&_a]:no-underline [&_a]:outline-none [&_a:focus-visible]:underline"
|
|
303
|
+
>
|
|
304
|
+
{parentLink}
|
|
305
|
+
</nav>
|
|
124
306
|
) : null}
|
|
125
|
-
<
|
|
126
|
-
<
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
{parentLink}
|
|
134
|
-
</span>
|
|
135
|
-
<br />
|
|
136
|
-
</>
|
|
137
|
-
) : null}
|
|
138
|
-
{greeting}
|
|
139
|
-
</HeadingTag>
|
|
140
|
-
</DisplayHeading>
|
|
307
|
+
<div className="flex w-full min-w-0 items-start overflow-visible">
|
|
308
|
+
<DisplayHeading
|
|
309
|
+
asChild
|
|
310
|
+
className="m-0 min-w-0 flex-1 overflow-visible break-words pb-0.5 tracking-[0]"
|
|
311
|
+
>
|
|
312
|
+
<HeadingTag>{greeting}</HeadingTag>
|
|
313
|
+
</DisplayHeading>
|
|
314
|
+
</div>
|
|
141
315
|
</div>
|
|
142
316
|
|
|
143
317
|
{cta ? (
|
|
144
318
|
// Hero CTA spans 4 of 12 columns (one third) — the greeting takes the rest.
|
|
145
|
-
<div
|
|
319
|
+
<div
|
|
320
|
+
className={
|
|
321
|
+
[
|
|
322
|
+
mobileCtaDocked
|
|
323
|
+
? 'fixed inset-x-0 bottom-0 z-40 min-w-0 px-3 pb-mobile-cta-bottom md:static md:inset-auto md:col-start-2 md:row-[1/-1] md:z-auto md:flex md:h-full md:max-w-full md:items-stretch md:justify-end md:self-stretch md:p-0'
|
|
324
|
+
: 'min-w-0 md:col-start-2 md:row-[1/-1] md:flex md:h-full md:max-w-full md:items-stretch md:justify-end md:self-stretch',
|
|
325
|
+
expanded ? 'hidden' : '',
|
|
326
|
+
].join(' ')
|
|
327
|
+
}
|
|
328
|
+
>
|
|
329
|
+
{renderedCta}
|
|
330
|
+
</div>
|
|
146
331
|
) : ctaLabel ? (
|
|
147
|
-
<div className="shrink-0">
|
|
332
|
+
<div className="shrink-0 md:col-start-2 md:row-[1/-1] md:flex md:items-start md:justify-end">
|
|
148
333
|
{hasExpandable ? (
|
|
149
334
|
<Button
|
|
150
335
|
onPress={() => setExpanded(!expanded)}
|
|
151
336
|
aria-expanded={expanded}
|
|
152
|
-
aria-controls={
|
|
337
|
+
aria-controls={panelId}
|
|
153
338
|
className="h-10 rounded-full bg-brand-secondary px-5 text-small font-medium text-fg data-[hovered]:opacity-90 data-[pressed]:opacity-80"
|
|
154
339
|
>
|
|
155
340
|
{ctaLabel}
|
|
@@ -163,35 +348,80 @@ export function BrandPageHeader({
|
|
|
163
348
|
) : null}
|
|
164
349
|
</div>
|
|
165
350
|
|
|
166
|
-
{hasExpandable ? (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
351
|
+
{hasExpandable && expanded ? (
|
|
352
|
+
<>
|
|
353
|
+
<div
|
|
354
|
+
aria-hidden="true"
|
|
355
|
+
onClick={() => setExpanded(false)}
|
|
356
|
+
className="fixed inset-0 z-40 bg-black/50 animate-create-hub-backdrop md:hidden motion-reduce:animate-none"
|
|
357
|
+
/>
|
|
358
|
+
<div
|
|
359
|
+
ref={panelRef}
|
|
360
|
+
id={panelId}
|
|
361
|
+
role={mobileSheet ? 'dialog' : 'region'}
|
|
362
|
+
aria-modal={mobileSheet || undefined}
|
|
363
|
+
aria-label={typeof ctaLabel === 'string' ? ctaLabel : undefined}
|
|
364
|
+
tabIndex={-1}
|
|
365
|
+
onTouchStart={(event) => {
|
|
366
|
+
const touch = event.touches[0]
|
|
367
|
+
if (!touch || !contentRef.current) return
|
|
368
|
+
touchStartYRef.current = touch.clientY
|
|
369
|
+
touchStartScrollTopRef.current = contentRef.current.scrollTop
|
|
370
|
+
}}
|
|
371
|
+
onTouchMove={(event) => {
|
|
372
|
+
const touch = event.touches[0]
|
|
373
|
+
if (
|
|
374
|
+
!touch ||
|
|
375
|
+
touchStartScrollTopRef.current > 10 ||
|
|
376
|
+
touch.clientY - touchStartYRef.current < 80
|
|
377
|
+
) {
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
event.preventDefault()
|
|
381
|
+
setExpanded(false)
|
|
382
|
+
}}
|
|
383
|
+
className={[
|
|
384
|
+
'fixed inset-x-0 bottom-0 z-50 flex flex-col overflow-hidden bg-brand-green',
|
|
385
|
+
'outline-none motion-reduce:animate-none',
|
|
386
|
+
'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',
|
|
387
|
+
mobileFull
|
|
388
|
+
? 'top-0 max-h-create-hub-full animate-create-hub-full'
|
|
389
|
+
: 'max-h-create-hub-partial animate-create-hub-sheet',
|
|
390
|
+
].join(' ')}
|
|
391
|
+
>
|
|
392
|
+
<Button
|
|
393
|
+
variant="ghost"
|
|
394
|
+
aria-label={closeLabel}
|
|
395
|
+
data-create-hub-focus="mobile"
|
|
396
|
+
onPress={() => setExpanded(false)}
|
|
397
|
+
className="h-10 w-full shrink-0 rounded-none p-0 md:hidden data-[hovered]:bg-transparent"
|
|
398
|
+
>
|
|
399
|
+
<span className="h-1 w-10 rounded-full bg-fg/25" aria-hidden="true" />
|
|
400
|
+
</Button>
|
|
401
|
+
<Button
|
|
402
|
+
variant="ghost"
|
|
403
|
+
aria-label={closeLabel}
|
|
404
|
+
data-create-hub-focus="desktop"
|
|
405
|
+
onPress={() => setExpanded(false)}
|
|
406
|
+
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"
|
|
407
|
+
>
|
|
408
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
409
|
+
<path
|
|
410
|
+
d="M6 6l12 12M18 6L6 18"
|
|
411
|
+
stroke="currentColor"
|
|
412
|
+
strokeWidth="2"
|
|
413
|
+
strokeLinecap="round"
|
|
414
|
+
/>
|
|
415
|
+
</svg>
|
|
416
|
+
</Button>
|
|
417
|
+
<div
|
|
418
|
+
ref={contentRef}
|
|
419
|
+
className="min-h-0 flex-1 overflow-y-auto overscroll-contain md:overflow-visible"
|
|
420
|
+
>
|
|
191
421
|
{createHub}
|
|
192
422
|
</div>
|
|
193
|
-
|
|
194
|
-
|
|
423
|
+
</div>
|
|
424
|
+
</>
|
|
195
425
|
) : null}
|
|
196
426
|
</div>
|
|
197
427
|
)
|
|
@@ -15,15 +15,29 @@ export const Button = uic(RACButton, {
|
|
|
15
15
|
baseClass:
|
|
16
16
|
'inline-flex items-center justify-center gap-2 rounded-full text-compact leading-4 transition-all duration-200 ease-in-out ' +
|
|
17
17
|
'outline-none data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[focus-visible]:ring-offset-2 ' +
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
// Inactive states (disabled / pending) soften the LABEL ONLY and leave every
|
|
19
|
+
// variant on its own surface. Two reasons:
|
|
20
|
+
// - fading the whole control (`opacity-*`) blends the label into the parent
|
|
21
|
+
// surface and drops it below WCAG AA;
|
|
22
|
+
// - repainting every variant onto one muted surface makes a disabled button
|
|
23
|
+
// indistinguishable from a hovered one (`secondary`/`ghost` both hover to
|
|
24
|
+
// `surface-muted`) and strips the brand fill off a submitting primary CTA.
|
|
25
|
+
// `text-fg/60` is measured over surface / surface-card / surface-muted in both
|
|
26
|
+
// themes by packages/tokens `contrast.test.ts`; variants whose ink or surface
|
|
27
|
+
// differs override it below.
|
|
28
|
+
'data-[disabled]:text-fg/60 data-[disabled]:pointer-events-none ' +
|
|
29
|
+
'data-[pending]:text-fg/60 data-[pending]:cursor-progress',
|
|
20
30
|
variants: {
|
|
21
31
|
variant: {
|
|
22
32
|
// primary (gs `_primary`): base brand-primary (#0d0d0d light / brand-green on
|
|
23
33
|
// dark) → hover neutral-600 (#333333) + `0 0 5px rgba(0,0,0,.5)` shadow →
|
|
24
34
|
// active back to brand-primary (no shadow).
|
|
35
|
+
// Keeps the brand fill while inactive — a submitting CTA has to stay the
|
|
36
|
+
// primary action next to a disabled `secondary` Cancel — so it softens the
|
|
37
|
+
// INVERTED ink instead of the base one.
|
|
25
38
|
primary:
|
|
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'
|
|
39
|
+
'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 ' +
|
|
40
|
+
'data-[disabled]:text-fg-inverted/70 data-[pending]:text-fg-inverted/70',
|
|
27
41
|
// secondary: #f7f6f2 (surface-card) + 1px #eceae1 (border); hover bg #eceae1
|
|
28
42
|
// (surface-muted) + border #aba89c (fg-subtle); active bg #eceae1.
|
|
29
43
|
secondary:
|
|
@@ -31,7 +45,16 @@ export const Button = uic(RACButton, {
|
|
|
31
45
|
// ghost: transparent → hover/active #eceae1 (surface-muted).
|
|
32
46
|
ghost: 'bg-transparent text-fg hover:bg-surface-muted data-[pressed]:bg-surface-muted',
|
|
33
47
|
// destructive: #ef4444 → danger token; hover/active dim via opacity (gs parity).
|
|
34
|
-
|
|
48
|
+
// Enabled white-on-danger is only ~4.8:1, so softening that ink would fail AA
|
|
49
|
+
// — an inactive destructive drops the red (it no longer affords the action)
|
|
50
|
+
// and falls back to the base muted ink on `surface-muted`. That pill is
|
|
51
|
+
// invisible on a `surface-muted` panel, so it outlines itself with an INSET
|
|
52
|
+
// shadow — a real border would resize the enabled button, and a `ring-*`
|
|
53
|
+
// would fight the focus ring a pending (still focusable) button can show.
|
|
54
|
+
destructive:
|
|
55
|
+
'bg-danger text-danger-fg hover:opacity-90 data-[pressed]:opacity-80 ' +
|
|
56
|
+
'data-[disabled]:bg-surface-muted data-[disabled]:shadow-[inset_0_0_0_1px_var(--color-border-muted)] ' +
|
|
57
|
+
'data-[pending]:bg-surface-muted data-[pending]:shadow-[inset_0_0_0_1px_var(--color-border-muted)]',
|
|
35
58
|
},
|
|
36
59
|
size: {
|
|
37
60
|
// gs sizes: PADDING ONLY (spacing-2/4, 3/6, 4/8) — text is a fixed 13px/400
|
|
@@ -91,7 +91,7 @@ export const ComboBox = <T extends object>({
|
|
|
91
91
|
<SearchField aria-label={typeof label === 'string' ? label : 'Search'}>
|
|
92
92
|
<RACInput
|
|
93
93
|
placeholder={placeholder}
|
|
94
|
-
className="w-full border-0 bg-transparent p-0 text-display font-medium text-fg outline-none placeholder:text-fg-
|
|
94
|
+
className="w-full border-0 bg-transparent p-0 text-display font-medium text-fg outline-none placeholder:text-fg-muted"
|
|
95
95
|
/>
|
|
96
96
|
</SearchField>
|
|
97
97
|
<ListBox
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { CompactActionButton } from './compact-action-button'
|
|
2
|
+
export const examples = {
|
|
3
|
+
default: () => <CompactActionButton>Create Task</CompactActionButton>,
|
|
4
|
+
variants: () => <><CompactActionButton variant="linear">Cancel</CompactActionButton><CompactActionButton>Mark done</CompactActionButton></>,
|
|
5
|
+
states: () => <><CompactActionButton isDisabled>Create Task</CompactActionButton><CompactActionButton variant="linear" isDisabled>Cancel</CompactActionButton><CompactActionButton isPending>Marking...</CompactActionButton></>,
|
|
6
|
+
}
|
|
7
|
+
export const meta = { category: 'Primitives', description: 'Compact source Manager actions with primary/linear emphasis, keyboard focus and disabled states.' }
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Button as AriaButton } from 'react-aria-components'
|
|
2
|
+
import { uic } from '../utils/uic'
|
|
3
|
+
|
|
4
|
+
export const COMPACT_ACTION_VARIANTS = ['primary', 'linear'] as const
|
|
5
|
+
export type CompactActionVariant = (typeof COMPACT_ACTION_VARIANTS)[number]
|
|
6
|
+
|
|
7
|
+
/** Compact Manager action; separate from the larger primitive dialog actions.
|
|
8
|
+
* Same semantic palette as Button / DialogActionButton. The hover shadow is the
|
|
9
|
+
* literal Button already uses — no shadow token covers a button lift, and the two
|
|
10
|
+
* must match. */
|
|
11
|
+
export const CompactActionButton = uic(AriaButton, {
|
|
12
|
+
displayName: 'CompactActionButton',
|
|
13
|
+
baseClass: 'inline-flex items-center justify-center rounded-full px-3.5 py-1 text-compact font-normal leading-[normal] cursor-pointer transition-all duration-120 ease-[ease] motion-reduce:transition-none outline-none ' +
|
|
14
|
+
'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[focus-visible]:ring-offset-2 ' +
|
|
15
|
+
'data-[hovered]:shadow-[0_0_5px_0_rgba(0,0,0,0.5)] data-[pressed]:shadow-none data-[disabled]:shadow-none data-[disabled]:cursor-not-allowed',
|
|
16
|
+
variants: { variant: {
|
|
17
|
+
primary: 'border-0 bg-brand-primary text-fg-inverted data-[hovered]:bg-neutral-600 data-[pressed]:bg-brand-primary data-[disabled]:bg-fg-subtle',
|
|
18
|
+
linear: 'border border-brand-primary bg-transparent text-brand-primary data-[hovered]:bg-surface-muted data-[pressed]:bg-transparent data-[disabled]:border-fg-subtle data-[disabled]:text-fg-subtle',
|
|
19
|
+
} },
|
|
20
|
+
defaultVariants: { variant: 'primary' },
|
|
21
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Button } from './button'
|
|
3
|
+
import { CompactSettingsDialog } from './compact-settings-dialog'
|
|
4
|
+
|
|
5
|
+
function Example({ pending = false }: { pending?: boolean }) {
|
|
6
|
+
const [open, setOpen] = useState(false)
|
|
7
|
+
return <>
|
|
8
|
+
<Button onPress={() => setOpen(true)}>Manage members</Button>
|
|
9
|
+
<CompactSettingsDialog isOpen={open} onOpenChange={setOpen} isPending={pending} title="Manage project members" description="Add or remove collaborators for this project." closeLabel="Close">
|
|
10
|
+
<p>No members in this category.</p>
|
|
11
|
+
</CompactSettingsDialog>
|
|
12
|
+
</>
|
|
13
|
+
}
|
|
14
|
+
export const examples = { default: () => <Example />, pending: () => <Example pending /> }
|
|
15
|
+
export const meta = { category: 'Composite', description: 'Intrinsic-height form modal with stacked title and description, responsive width, and pending dismissal lock.' }
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useId, type ReactNode } from 'react'
|
|
2
|
+
import { Button } from './button'
|
|
3
|
+
import { ModalDialog, ModalOverlay, ModalSurface } from './dialog'
|
|
4
|
+
import { Heading } from './text'
|
|
5
|
+
import { Button as AriaButton } from 'react-aria-components'
|
|
6
|
+
import { uic } from '../utils/uic'
|
|
7
|
+
|
|
8
|
+
export const MemberSelectionGrid = uic('ul', { displayName: 'MemberSelectionGrid', baseClass: 'm-0 mt-8 grid list-none grid-cols-1 gap-3 p-0 max-[767px]:gap-2 min-[640px]:grid-cols-2 min-[768px]:grid-cols-3 min-[1024px]:grid-cols-4' })
|
|
9
|
+
export const TeamSelectionGrid = uic('ul', { displayName: 'TeamSelectionGrid', baseClass: 'm-0 mt-8 grid list-none grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-4 p-0' })
|
|
10
|
+
export const MemberSelectionCard = uic(AriaButton, {
|
|
11
|
+
displayName: 'MemberSelectionCard',
|
|
12
|
+
baseClass: 'flex w-full items-center gap-3 rounded-lg border border-border bg-surface-card p-4 text-start text-fg outline-none transition-all duration-200 data-[hovered]:-translate-y-0.5 data-[hovered]:border-brand-primary data-[hovered]:shadow-[0_4px_12px_rgba(0,0,0,0.1)] aria-pressed:border-brand-primary aria-pressed:bg-surface-muted data-[disabled]:cursor-default data-[disabled]:opacity-60 data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring',
|
|
13
|
+
})
|
|
14
|
+
export const TeamSelectionCard = uic(AriaButton, {
|
|
15
|
+
displayName: 'TeamSelectionCard',
|
|
16
|
+
baseClass: 'block w-full rounded-md border-2 border-border bg-transparent p-4 text-start text-fg outline-none transition-all duration-200 data-[hovered]:border-brand-primary aria-pressed:border-brand-primary aria-pressed:bg-surface-muted data-[disabled]:cursor-default data-[disabled]:opacity-60 data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring',
|
|
17
|
+
})
|
|
18
|
+
export const MemberSelectionInfo = uic('span', { displayName: 'MemberSelectionInfo', baseClass: 'min-w-0 flex-1' })
|
|
19
|
+
export const MemberSelectionName = uic('span', { displayName: 'MemberSelectionName', baseClass: 'mb-1 block text-heading5 font-medium text-fg' })
|
|
20
|
+
export const MemberSelectionEmail = uic('span', { displayName: 'MemberSelectionEmail', baseClass: 'block text-compact font-normal text-fg-subtle' })
|
|
21
|
+
export const TeamSelectionName = uic('span', { displayName: 'TeamSelectionName', baseClass: 'mb-1 block font-medium text-fg' })
|
|
22
|
+
export const TeamSelectionMeta = uic('span', { displayName: 'TeamSelectionMeta', baseClass: 'block text-small font-normal text-fg-subtle' })
|
|
23
|
+
|
|
24
|
+
/** Intrinsic-height form surface. The tall settings and catalog surfaces remain separate. */
|
|
25
|
+
export function CompactSettingsDialog({ isOpen, onOpenChange, isPending = false, title, description, closeLabel, children }: {
|
|
26
|
+
isOpen: boolean
|
|
27
|
+
onOpenChange: (open: boolean) => void
|
|
28
|
+
isPending?: boolean
|
|
29
|
+
title: string
|
|
30
|
+
description: string
|
|
31
|
+
closeLabel: string
|
|
32
|
+
children: ReactNode
|
|
33
|
+
}) {
|
|
34
|
+
const titleId = useId()
|
|
35
|
+
const descriptionId = useId()
|
|
36
|
+
return <ModalOverlay className="p-0" isOpen={isOpen} onOpenChange={open => { if (!isPending) onOpenChange(open) }} isDismissable={!isPending} isKeyboardDismissDisabled={isPending}>
|
|
37
|
+
<ModalSurface className="relative w-[90vw] max-w-180 max-h-[85vh] overflow-y-auto p-5 min-[768px]:max-[1023px]:w-[85vw] max-[767px]:w-[calc(100vw-6*var(--spacing))] max-[767px]:max-w-[calc(100vw-6*var(--spacing))] max-[767px]:max-h-[calc(100vh-6*var(--spacing))]">
|
|
38
|
+
<ModalDialog aria-labelledby={titleId} aria-describedby={descriptionId} className="outline-none">
|
|
39
|
+
<Heading id={titleId} level="1" className="m-0 mb-4 max-w-5/6 tracking-normal">{title}</Heading>
|
|
40
|
+
<p id={descriptionId} className="m-0 mb-6 max-w-5/6 text-small font-normal text-fg-workflow-muted">{description}</p>
|
|
41
|
+
<div className="mt-4">{children}</div>
|
|
42
|
+
<Button variant="ghost" aria-label={closeLabel} isDisabled={isPending} onPress={() => onOpenChange(false)} className="absolute end-4 top-3.5 h-8 w-8 rounded-md p-0 text-fg-subtle max-[767px]:end-3">
|
|
43
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18" /></svg>
|
|
44
|
+
</Button>
|
|
45
|
+
</ModalDialog>
|
|
46
|
+
</ModalSurface>
|
|
47
|
+
</ModalOverlay>
|
|
48
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ContextActionGlyph, CONTEXT_ACTION_GLYPHS } from './context-action-glyph'
|
|
2
|
+
import { Button } from './button'
|
|
3
|
+
export const examples = {
|
|
4
|
+
default: () => <ContextActionGlyph kind="edit" />,
|
|
5
|
+
variants: () => <>{CONTEXT_ACTION_GLYPHS.map(kind => <ContextActionGlyph key={kind} kind={kind} />)}</>,
|
|
6
|
+
sizes: () => <><ContextActionGlyph kind="settings" /><ContextActionGlyph kind="settings" className="size-5" /></>,
|
|
7
|
+
states: () => <><Button><ContextActionGlyph kind="open" />Open</Button><Button isDisabled><ContextActionGlyph kind="archive" />Archive</Button></>,
|
|
8
|
+
}
|
|
9
|
+
export const meta = { category: 'Primitives', description: 'Decorative compact action glyphs from the original Radix icon set; inherit the parent action color and accessible name.' }
|