@podoba/react 0.0.7 → 0.0.9
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/brand-page-header.tsx +189 -0
- package/src/components/collapsible-card.tsx +121 -0
- package/src/components/combobox.tsx +78 -28
- package/src/components/dashboard-grid.tsx +50 -0
- package/src/components/date-picker.tsx +79 -65
- package/src/components/delivery/delivery-status-module.tsx +113 -0
- package/src/components/delivery/download-modal.tsx +204 -0
- package/src/components/delivery/publish-modal.tsx +170 -0
- package/src/components/delivery/send-to-print-modal.tsx +216 -0
- package/src/components/file-upload.tsx +4 -1
- package/src/components/focus-context.ts +11 -0
- package/src/components/focus-field.tsx +213 -0
- package/src/components/multiselect.tsx +55 -37
- package/src/components/request-changes-modal.tsx +134 -0
- package/src/components/rich-text-editor.tsx +5 -1
- package/src/components/select.tsx +53 -38
- package/src/components/stats-card.tsx +146 -0
- package/src/components/task-approval-modal.tsx +123 -0
- package/src/index.ts +18 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podoba/react",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "podoba React components — React Aria Components + Tailwind primitives + layout, built with uic.",
|
|
6
6
|
"repository": {
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"typecheck": "tsc --build"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@podoba/tokens": "^0.0.
|
|
29
|
-
"@podoba/tailwind": "^0.0.
|
|
28
|
+
"@podoba/tokens": "^0.0.9",
|
|
29
|
+
"@podoba/tailwind": "^0.0.9",
|
|
30
30
|
"react-aria-components": "1.18.0",
|
|
31
31
|
"class-variance-authority": "0.7.1",
|
|
32
32
|
"clsx": "2.1.1",
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { type ReactNode, useState } from 'react'
|
|
2
|
+
import { Button } from './button'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* BrandPageHeader — the brand-workspace page header (port of gs-platform
|
|
6
|
+
* `GSPageHeader` + `ExpandableCTA`).
|
|
7
|
+
*
|
|
8
|
+
* gs-platform's header is a two-column grid: a left "welcome" section
|
|
9
|
+
* (optional breadcrumbs + a large greeting line) and a right section holding an
|
|
10
|
+
* `ExpandableCTA` — a collapsed teal pill that expands into an inline
|
|
11
|
+
* create-hub panel. We port that interaction to React Aria + Tailwind + the
|
|
12
|
+
* teal accent token (`brand-secondary`), dropping gs-platform's mobile
|
|
13
|
+
* fixed-sheet behaviour for a simpler inline disclosure.
|
|
14
|
+
*
|
|
15
|
+
* The expandable CTA is a controlled disclosure: the collapsed teal pill is a
|
|
16
|
+
* React Aria `Button` (keyboard + focus ring + press handling) wired to a
|
|
17
|
+
* `region` with `aria-expanded`/`aria-controls`, so it follows the WAI-ARIA
|
|
18
|
+
* disclosure pattern without pulling in extra markup.
|
|
19
|
+
*
|
|
20
|
+
* Presentational only (hard rule #1): every string (greeting, breadcrumb
|
|
21
|
+
* labels, CTA label) arrives via props — no API, no i18n.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type BrandPageHeaderCrumb = {
|
|
25
|
+
label: ReactNode
|
|
26
|
+
/** Optional click handler — when set the crumb renders as a button. */
|
|
27
|
+
onPress?: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type BrandPageHeaderProps = {
|
|
31
|
+
/** The large greeting / page-title slot (e.g. "Good morning Jonas 👋"). */
|
|
32
|
+
greeting: ReactNode
|
|
33
|
+
/**
|
|
34
|
+
* gs-style "title to go back": a muted, clickable PARENT link rendered as the
|
|
35
|
+
* line ABOVE the title (so the title reads "Parent ⏎ Current"). Pass a real
|
|
36
|
+
* router `<Link>` so it's an anchor (cmd/middle-click, deep-link-safe), exactly
|
|
37
|
+
* like gs's `parentLink`. The header styles it muted with a hover→fg affordance.
|
|
38
|
+
*/
|
|
39
|
+
parentLink?: ReactNode
|
|
40
|
+
/** Optional breadcrumb trail rendered above the greeting. */
|
|
41
|
+
breadcrumbs?: BrandPageHeaderCrumb[]
|
|
42
|
+
/**
|
|
43
|
+
* Arbitrary right-column CTA node (e.g. the gs hero `CtaPill` banner). When
|
|
44
|
+
* set it REPLACES the collapsed-pill ExpandableCTA — use this for the
|
|
45
|
+
* gs-faithful "Let's create something" hero. `ctaLabel`/`createHub` are ignored.
|
|
46
|
+
*/
|
|
47
|
+
cta?: ReactNode
|
|
48
|
+
/** Label on the collapsed teal "Create" pill. Required to render the CTA. */
|
|
49
|
+
ctaLabel?: ReactNode
|
|
50
|
+
/** Inline content revealed when the CTA expands (the create hub). */
|
|
51
|
+
createHub?: ReactNode
|
|
52
|
+
/** Controlled expansion (optional — uncontrolled by default). */
|
|
53
|
+
expanded?: boolean
|
|
54
|
+
onExpandedChange?: (expanded: boolean) => void
|
|
55
|
+
/** Accessible label for the close control when expanded. Defaults to "Close". */
|
|
56
|
+
closeLabel?: string
|
|
57
|
+
/** Sticky header on scroll. */
|
|
58
|
+
sticky?: boolean
|
|
59
|
+
className?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const PANEL_ID = 'brand-page-header-create-hub'
|
|
63
|
+
|
|
64
|
+
export function BrandPageHeader({
|
|
65
|
+
greeting,
|
|
66
|
+
parentLink,
|
|
67
|
+
breadcrumbs,
|
|
68
|
+
cta,
|
|
69
|
+
ctaLabel,
|
|
70
|
+
createHub,
|
|
71
|
+
expanded: expandedProp,
|
|
72
|
+
onExpandedChange,
|
|
73
|
+
closeLabel = 'Close',
|
|
74
|
+
sticky = false,
|
|
75
|
+
className,
|
|
76
|
+
}: BrandPageHeaderProps) {
|
|
77
|
+
const [internalExpanded, setInternalExpanded] = useState(false)
|
|
78
|
+
const isControlled = expandedProp !== undefined
|
|
79
|
+
const expanded = isControlled ? expandedProp : internalExpanded
|
|
80
|
+
const hasExpandable = Boolean(createHub)
|
|
81
|
+
|
|
82
|
+
const setExpanded = (next: boolean) => {
|
|
83
|
+
if (!isControlled) setInternalExpanded(next)
|
|
84
|
+
onExpandedChange?.(next)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div
|
|
89
|
+
className={[
|
|
90
|
+
'mb-6 w-full',
|
|
91
|
+
sticky ? 'sticky top-0 z-30 bg-surface' : '',
|
|
92
|
+
className,
|
|
93
|
+
]
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
.join(' ')}
|
|
96
|
+
>
|
|
97
|
+
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
|
98
|
+
<div className="flex min-w-0 flex-col gap-1">
|
|
99
|
+
{breadcrumbs && breadcrumbs.length > 0 ? (
|
|
100
|
+
<nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-1 text-compact text-fg-muted">
|
|
101
|
+
{breadcrumbs.map((crumb, i) => (
|
|
102
|
+
<span key={i} className="inline-flex items-center gap-1">
|
|
103
|
+
{i > 0 ? <span aria-hidden="true">/</span> : null}
|
|
104
|
+
{crumb.onPress ? (
|
|
105
|
+
<Button
|
|
106
|
+
variant="ghost"
|
|
107
|
+
onPress={crumb.onPress}
|
|
108
|
+
className="h-auto rounded-sm p-0 text-compact font-normal text-fg-muted data-[hovered]:bg-transparent data-[hovered]:text-fg data-[hovered]:underline"
|
|
109
|
+
>
|
|
110
|
+
{crumb.label}
|
|
111
|
+
</Button>
|
|
112
|
+
) : (
|
|
113
|
+
<span>{crumb.label}</span>
|
|
114
|
+
)}
|
|
115
|
+
</span>
|
|
116
|
+
))}
|
|
117
|
+
</nav>
|
|
118
|
+
) : null}
|
|
119
|
+
<h1 className="text-display font-medium leading-[1.12] tracking-[-0.56px] text-fg">
|
|
120
|
+
{parentLink ? (
|
|
121
|
+
// gs "title to go back": muted clickable parent line above the title.
|
|
122
|
+
// `[&_a]` styles the nested router <Link> (anchor) without @app/ui
|
|
123
|
+
// importing the router.
|
|
124
|
+
<>
|
|
125
|
+
<span className="text-fg-muted transition-colors [&_a:hover]:text-fg [&_a]:text-fg-muted [&_a]:no-underline [&_a]:outline-none [&_a:focus-visible]:underline">
|
|
126
|
+
{parentLink}
|
|
127
|
+
</span>
|
|
128
|
+
<br />
|
|
129
|
+
</>
|
|
130
|
+
) : null}
|
|
131
|
+
{greeting}
|
|
132
|
+
</h1>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{cta ? (
|
|
136
|
+
<div className="w-full shrink-0 sm:w-auto sm:min-w-[360px]">{cta}</div>
|
|
137
|
+
) : ctaLabel ? (
|
|
138
|
+
<div className="shrink-0">
|
|
139
|
+
{hasExpandable ? (
|
|
140
|
+
<Button
|
|
141
|
+
onPress={() => setExpanded(!expanded)}
|
|
142
|
+
aria-expanded={expanded}
|
|
143
|
+
aria-controls={PANEL_ID}
|
|
144
|
+
className="h-10 rounded-full bg-brand-secondary px-5 text-sm font-medium text-fg data-[hovered]:opacity-90 data-[pressed]:opacity-80"
|
|
145
|
+
>
|
|
146
|
+
{ctaLabel}
|
|
147
|
+
</Button>
|
|
148
|
+
) : (
|
|
149
|
+
<Button className="h-10 rounded-full bg-brand-secondary px-5 text-sm font-medium text-fg data-[hovered]:opacity-90 data-[pressed]:opacity-80">
|
|
150
|
+
{ctaLabel}
|
|
151
|
+
</Button>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
) : null}
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{hasExpandable ? (
|
|
158
|
+
<div
|
|
159
|
+
id={PANEL_ID}
|
|
160
|
+
role="region"
|
|
161
|
+
aria-label={typeof ctaLabel === 'string' ? ctaLabel : undefined}
|
|
162
|
+
hidden={!expanded}
|
|
163
|
+
className="mt-4"
|
|
164
|
+
>
|
|
165
|
+
{expanded ? (
|
|
166
|
+
<div className="relative rounded-lg border border-border bg-surface-card p-4 animate-expand-cta motion-reduce:animate-none">
|
|
167
|
+
<Button
|
|
168
|
+
variant="ghost"
|
|
169
|
+
aria-label={closeLabel}
|
|
170
|
+
onPress={() => setExpanded(false)}
|
|
171
|
+
className="absolute right-2 top-2 h-7 w-7 rounded-full p-0 text-fg-muted data-[hovered]:bg-surface-muted data-[hovered]:text-fg"
|
|
172
|
+
>
|
|
173
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
174
|
+
<path
|
|
175
|
+
d="M6 6l12 12M18 6L6 18"
|
|
176
|
+
stroke="currentColor"
|
|
177
|
+
strokeWidth="2"
|
|
178
|
+
strokeLinecap="round"
|
|
179
|
+
/>
|
|
180
|
+
</svg>
|
|
181
|
+
</Button>
|
|
182
|
+
{createHub}
|
|
183
|
+
</div>
|
|
184
|
+
) : null}
|
|
185
|
+
</div>
|
|
186
|
+
) : null}
|
|
187
|
+
</div>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button as RACButton,
|
|
3
|
+
Disclosure as RACDisclosure,
|
|
4
|
+
DisclosurePanel as RACDisclosurePanel,
|
|
5
|
+
type DisclosureProps as RACDisclosureProps,
|
|
6
|
+
} from 'react-aria-components'
|
|
7
|
+
import type { ReactNode } from 'react'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* CollapsibleCard — a titled, expandable card (title + description + chevron,
|
|
11
|
+
* expanding to reveal a body).
|
|
12
|
+
*
|
|
13
|
+
* Styled composition over React Aria Components' `Disclosure` (see
|
|
14
|
+
* `./disclosure.tsx` for the unstyled primitive): RAC supplies the WAI-ARIA
|
|
15
|
+
* disclosure wiring (`aria-expanded` / `aria-controls`, keyboard activation,
|
|
16
|
+
* hidden-until-expanded panel) and, on its root element, a `data-expanded`
|
|
17
|
+
* attribute. We make the root a Tailwind `group`, so the chevron rotates via
|
|
18
|
+
* `group-data-[expanded]:rotate-90` — no render-prop plumbing needed.
|
|
19
|
+
*
|
|
20
|
+
* Token mapping (Tailwind + `@podoba/tokens` CSS vars — no SCSS, hard rule #3):
|
|
21
|
+
* - surface → `bg-surface-card` (#f7f6f2), 1px `border-border`, `rounded-lg`
|
|
22
|
+
* (8px) — same skin as `Card` / `StatsCard`.
|
|
23
|
+
* - title → `text-heading4` (1.125rem) `font-semibold text-fg`.
|
|
24
|
+
* - description → `text-body text-fg-muted`.
|
|
25
|
+
*
|
|
26
|
+
* Presentational only (hard rule #1): all copy via props, no API/i18n.
|
|
27
|
+
*
|
|
28
|
+
* Controlled or uncontrolled — pass `isExpanded` + `onExpandedChange` to control,
|
|
29
|
+
* or `defaultExpanded` to seed the uncontrolled state.
|
|
30
|
+
*
|
|
31
|
+
* <CollapsibleCard title="Variables" description="Shared variables applied across this template.">
|
|
32
|
+
* <Select label="Color theme" …/>
|
|
33
|
+
* </CollapsibleCard>
|
|
34
|
+
*/
|
|
35
|
+
export type CollapsibleCardProps = {
|
|
36
|
+
/** Bold card title (the always-visible header line). */
|
|
37
|
+
title: ReactNode
|
|
38
|
+
/** Optional muted supporting line under the title. */
|
|
39
|
+
description?: ReactNode
|
|
40
|
+
/** The collapsible body, revealed when expanded. */
|
|
41
|
+
children?: ReactNode
|
|
42
|
+
/** Seed the uncontrolled expanded state. */
|
|
43
|
+
defaultExpanded?: boolean
|
|
44
|
+
/** Controlled expanded state (pair with `onExpandedChange`). */
|
|
45
|
+
isExpanded?: boolean
|
|
46
|
+
/** Fires when the user toggles the card. */
|
|
47
|
+
onExpandedChange?: (isExpanded: boolean) => void
|
|
48
|
+
/** Disable expand/collapse. */
|
|
49
|
+
isDisabled?: boolean
|
|
50
|
+
/** Extra classes merged onto the card root. */
|
|
51
|
+
className?: string
|
|
52
|
+
'data-testid'?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const Chevron = () => (
|
|
56
|
+
<svg
|
|
57
|
+
width="16"
|
|
58
|
+
height="16"
|
|
59
|
+
viewBox="0 0 16 16"
|
|
60
|
+
fill="none"
|
|
61
|
+
aria-hidden="true"
|
|
62
|
+
className="mt-0.5 shrink-0 text-fg transition-transform duration-150 group-data-[expanded]:rotate-90"
|
|
63
|
+
>
|
|
64
|
+
<path
|
|
65
|
+
d="M6 4l4 4-4 4"
|
|
66
|
+
stroke="currentColor"
|
|
67
|
+
strokeWidth="1.5"
|
|
68
|
+
strokeLinecap="round"
|
|
69
|
+
strokeLinejoin="round"
|
|
70
|
+
/>
|
|
71
|
+
</svg>
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
export function CollapsibleCard({
|
|
75
|
+
title,
|
|
76
|
+
description,
|
|
77
|
+
children,
|
|
78
|
+
defaultExpanded,
|
|
79
|
+
isExpanded,
|
|
80
|
+
onExpandedChange,
|
|
81
|
+
isDisabled,
|
|
82
|
+
className,
|
|
83
|
+
...rest
|
|
84
|
+
}: CollapsibleCardProps) {
|
|
85
|
+
const disclosureProps: RACDisclosureProps = {
|
|
86
|
+
defaultExpanded,
|
|
87
|
+
isExpanded,
|
|
88
|
+
onExpandedChange,
|
|
89
|
+
isDisabled,
|
|
90
|
+
}
|
|
91
|
+
return (
|
|
92
|
+
<RACDisclosure
|
|
93
|
+
{...disclosureProps}
|
|
94
|
+
data-testid={rest['data-testid']}
|
|
95
|
+
className={[
|
|
96
|
+
'group overflow-hidden rounded-lg border border-border bg-surface-card',
|
|
97
|
+
className,
|
|
98
|
+
]
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
.join(' ')}
|
|
101
|
+
>
|
|
102
|
+
<RACButton
|
|
103
|
+
slot="trigger"
|
|
104
|
+
className={
|
|
105
|
+
'flex w-full items-center justify-between gap-4 rounded-lg p-6 text-left outline-none ' +
|
|
106
|
+
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-60 ' +
|
|
107
|
+
'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[focus-visible]:ring-inset'
|
|
108
|
+
}
|
|
109
|
+
>
|
|
110
|
+
<span className="flex flex-col gap-1">
|
|
111
|
+
<span className="text-heading4 font-semibold leading-tight text-fg">{title}</span>
|
|
112
|
+
{description ? (
|
|
113
|
+
<span className="text-body leading-snug text-fg-muted">{description}</span>
|
|
114
|
+
) : null}
|
|
115
|
+
</span>
|
|
116
|
+
<Chevron />
|
|
117
|
+
</RACButton>
|
|
118
|
+
<RACDisclosurePanel className="px-6 pb-6">{children}</RACDisclosurePanel>
|
|
119
|
+
</RACDisclosure>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
2
|
import {
|
|
3
|
+
Autocomplete,
|
|
3
4
|
Button as RACButton,
|
|
4
5
|
ComboBox as RACComboBox,
|
|
5
6
|
type ComboBoxProps as RACComboBoxProps,
|
|
@@ -10,9 +11,12 @@ import {
|
|
|
10
11
|
ListBoxItem,
|
|
11
12
|
type ListBoxItemProps,
|
|
12
13
|
Popover,
|
|
14
|
+
SearchField,
|
|
13
15
|
Text,
|
|
16
|
+
useFilter,
|
|
14
17
|
} from 'react-aria-components'
|
|
15
18
|
import { uic } from '../utils/uic'
|
|
19
|
+
import { useInFocusOverlay } from './focus-context'
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
22
|
* ComboBox — a filterable single-select: a text input that narrows a listbox as
|
|
@@ -65,32 +69,78 @@ export const ComboBox = <T extends object>({
|
|
|
65
69
|
errorMessage,
|
|
66
70
|
placeholder,
|
|
67
71
|
children,
|
|
72
|
+
selectedKey,
|
|
73
|
+
onSelectionChange,
|
|
68
74
|
...props
|
|
69
|
-
}: ComboBoxProps<T>) =>
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
<
|
|
83
|
-
{
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
)
|
|
75
|
+
}: ComboBoxProps<T>) => {
|
|
76
|
+
const inFocus = useInFocusOverlay()
|
|
77
|
+
const { contains } = useFilter({ sensitivity: 'base' })
|
|
78
|
+
const desc = description ? (
|
|
79
|
+
<Text slot="description" className="text-xs text-fg-muted">
|
|
80
|
+
{description}
|
|
81
|
+
</Text>
|
|
82
|
+
) : null
|
|
83
|
+
|
|
84
|
+
// Focus overlay: a bare search input + an inline filtered list (RAC ComboBox
|
|
85
|
+
// only fills its listbox while open, so compose Autocomplete + ListBox here).
|
|
86
|
+
if (inFocus) {
|
|
87
|
+
return (
|
|
88
|
+
<div className="flex flex-col gap-2">
|
|
89
|
+
<span className="text-heading5 font-medium text-fg">{label}</span>
|
|
90
|
+
<Autocomplete filter={contains}>
|
|
91
|
+
<SearchField aria-label={typeof label === 'string' ? label : 'Search'}>
|
|
92
|
+
<RACInput
|
|
93
|
+
placeholder={placeholder}
|
|
94
|
+
className="w-full border-0 bg-transparent p-0 text-3xl font-medium text-fg outline-none placeholder:text-fg-subtle"
|
|
95
|
+
/>
|
|
96
|
+
</SearchField>
|
|
97
|
+
<ListBox
|
|
98
|
+
selectionMode="single"
|
|
99
|
+
selectedKeys={selectedKey != null ? new Set([selectedKey]) : new Set()}
|
|
100
|
+
onSelectionChange={(keys) => {
|
|
101
|
+
const k = keys === 'all' ? undefined : [...keys][0]
|
|
102
|
+
onSelectionChange?.(k ?? null)
|
|
103
|
+
}}
|
|
104
|
+
renderEmptyState={() => <div className="px-3 py-2 text-sm text-fg-muted">No results</div>}
|
|
105
|
+
className="mt-2 flex max-h-72 flex-col gap-0.5 overflow-auto overscroll-contain p-1 outline-none"
|
|
106
|
+
>
|
|
107
|
+
{children}
|
|
108
|
+
</ListBox>
|
|
109
|
+
</Autocomplete>
|
|
110
|
+
{desc}
|
|
111
|
+
{errorMessage ? <span className="text-xs text-danger">{errorMessage}</span> : null}
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
// `menuTrigger="focus"` opens the list on focus/click (not only on typing), so
|
|
118
|
+
// the options are always one interaction away. Consumers can override via props.
|
|
119
|
+
<RACComboBox
|
|
120
|
+
menuTrigger="focus"
|
|
121
|
+
selectedKey={selectedKey}
|
|
122
|
+
onSelectionChange={onSelectionChange}
|
|
123
|
+
{...props}
|
|
124
|
+
className="group flex flex-col gap-2"
|
|
125
|
+
>
|
|
126
|
+
<Label className="text-heading5 font-medium text-fg">{label}</Label>
|
|
127
|
+
<div className="relative">
|
|
128
|
+
<ComboBoxInput placeholder={placeholder} />
|
|
129
|
+
{/* RAC uses this Button to toggle the listbox open/closed. */}
|
|
130
|
+
<RACButton className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg outline-none data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring">
|
|
131
|
+
<Chevron />
|
|
132
|
+
</RACButton>
|
|
133
|
+
</div>
|
|
134
|
+
{desc}
|
|
135
|
+
<FieldError className="text-xs text-danger">{errorMessage}</FieldError>
|
|
136
|
+
<Popover className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg">
|
|
137
|
+
<ListBox
|
|
138
|
+
className="flex max-h-72 flex-col gap-0.5 overflow-auto overscroll-contain p-1 outline-none"
|
|
139
|
+
renderEmptyState={() => <div className="px-3 py-2 text-sm text-fg-muted">No results</div>}
|
|
140
|
+
>
|
|
141
|
+
{children}
|
|
142
|
+
</ListBox>
|
|
143
|
+
</Popover>
|
|
144
|
+
</RACComboBox>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DashboardGrid + DashboardTile — responsive 12-column dashboard grid
|
|
5
|
+
* (port of gs-platform `DashboardGrid`).
|
|
6
|
+
*
|
|
7
|
+
* gs-platform's grid is a 12-col CSS grid where each item declares a `width`
|
|
8
|
+
* (3/4/6/12) and tiles collapse to full width on mobile. We reproduce that with
|
|
9
|
+
* Tailwind responsive column spans — no SCSS, no external grid lib.
|
|
10
|
+
*
|
|
11
|
+
* **Drag-to-reorder is DEFERRED** for this issue: gs-platform implements it with
|
|
12
|
+
* `@dnd-kit/core` + `@dnd-kit/sortable`, which are not dependencies of this repo
|
|
13
|
+
* and adding them is out of scope (no dependency bumps). A static responsive grid
|
|
14
|
+
* is acceptable per the issue spec; the `DashboardTile` API is shaped so a future
|
|
15
|
+
* sortable wrapper can slot in without changing call sites.
|
|
16
|
+
*
|
|
17
|
+
* Presentational only (hard rule #1).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type DashboardTileSpan = 3 | 4 | 6 | 12
|
|
21
|
+
|
|
22
|
+
const SPAN_CLASS: Record<DashboardTileSpan, string> = {
|
|
23
|
+
// Mobile: always full width. ≥sm: take the requested fraction of 12 cols.
|
|
24
|
+
3: 'col-span-12 sm:col-span-6 lg:col-span-3',
|
|
25
|
+
4: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
|
26
|
+
6: 'col-span-12 sm:col-span-6',
|
|
27
|
+
12: 'col-span-12',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type DashboardGridProps = {
|
|
31
|
+
children: ReactNode
|
|
32
|
+
className?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function DashboardGrid({ children, className }: DashboardGridProps) {
|
|
36
|
+
return (
|
|
37
|
+
<div className={['grid grid-cols-12 gap-4', className].filter(Boolean).join(' ')}>{children}</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type DashboardTileProps = {
|
|
42
|
+
/** Column span out of 12 (collapses to full width below `lg`). Default 3. */
|
|
43
|
+
span?: DashboardTileSpan
|
|
44
|
+
children: ReactNode
|
|
45
|
+
className?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function DashboardTile({ span = 3, children, className }: DashboardTileProps) {
|
|
49
|
+
return <div className={[SPAN_CLASS[span], className].filter(Boolean).join(' ')}>{children}</div>
|
|
50
|
+
}
|
|
@@ -20,7 +20,9 @@ import {
|
|
|
20
20
|
Popover,
|
|
21
21
|
Text,
|
|
22
22
|
} from 'react-aria-components'
|
|
23
|
+
import { clsx } from 'clsx'
|
|
23
24
|
import { dateInputClass, segmentClass } from './date-field'
|
|
25
|
+
import { useInFocusOverlay } from './focus-context'
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* DatePicker — a `DateField` with a calendar popover. Built on React Aria
|
|
@@ -38,74 +40,86 @@ const Chevron = ({ dir }: { dir: 'left' | 'right' }) => (
|
|
|
38
40
|
</svg>
|
|
39
41
|
)
|
|
40
42
|
|
|
43
|
+
const calendarCellClass =
|
|
44
|
+
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-sm text-fg outline-none transition-colors ' +
|
|
45
|
+
'data-[outside-month]:text-fg-subtle data-[hovered]:bg-surface-muted ' +
|
|
46
|
+
'data-[selected]:bg-fg data-[selected]:text-fg-inverted data-[selected]:font-medium ' +
|
|
47
|
+
'data-[unavailable]:text-fg-subtle data-[unavailable]:line-through ' +
|
|
48
|
+
'data-[disabled]:opacity-40 data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring'
|
|
49
|
+
|
|
50
|
+
function CalendarBody() {
|
|
51
|
+
return (
|
|
52
|
+
<Calendar className="w-[17.5rem]">
|
|
53
|
+
<header className="flex items-center justify-between pb-3">
|
|
54
|
+
<RACButton slot="previous" className={navButton}>
|
|
55
|
+
<Chevron dir="left" />
|
|
56
|
+
</RACButton>
|
|
57
|
+
<Heading className="text-sm font-medium text-fg" />
|
|
58
|
+
<RACButton slot="next" className={navButton}>
|
|
59
|
+
<Chevron dir="right" />
|
|
60
|
+
</RACButton>
|
|
61
|
+
</header>
|
|
62
|
+
<CalendarGrid className="w-full border-collapse">
|
|
63
|
+
<CalendarGridHeader>
|
|
64
|
+
{(day) => (
|
|
65
|
+
<CalendarHeaderCell className="pb-1 text-micro font-medium uppercase tracking-wide text-fg-subtle">
|
|
66
|
+
{day}
|
|
67
|
+
</CalendarHeaderCell>
|
|
68
|
+
)}
|
|
69
|
+
</CalendarGridHeader>
|
|
70
|
+
<CalendarGridBody>{(date) => <CalendarCell date={date} className={calendarCellClass} />}</CalendarGridBody>
|
|
71
|
+
</CalendarGrid>
|
|
72
|
+
</Calendar>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
41
76
|
export type DatePickerProps<T extends DateValue> = RACDatePickerProps<T> & {
|
|
42
77
|
label: ReactNode
|
|
43
78
|
description?: ReactNode
|
|
44
79
|
errorMessage?: string
|
|
45
80
|
}
|
|
46
81
|
|
|
47
|
-
export const DatePicker = <T extends DateValue>({ label, description, errorMessage, ...props }: DatePickerProps<T>) =>
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
className=
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
<
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
{(date) => (
|
|
92
|
-
<CalendarCell
|
|
93
|
-
date={date}
|
|
94
|
-
className={
|
|
95
|
-
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-sm text-fg outline-none transition-colors ' +
|
|
96
|
-
'data-[outside-month]:text-fg-subtle ' +
|
|
97
|
-
'data-[hovered]:bg-surface-muted ' +
|
|
98
|
-
'data-[selected]:bg-fg data-[selected]:text-fg-inverted data-[selected]:font-medium ' +
|
|
99
|
-
'data-[unavailable]:text-fg-subtle data-[unavailable]:line-through ' +
|
|
100
|
-
'data-[disabled]:opacity-40 ' +
|
|
101
|
-
'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring'
|
|
102
|
-
}
|
|
103
|
-
/>
|
|
104
|
-
)}
|
|
105
|
-
</CalendarGridBody>
|
|
106
|
-
</CalendarGrid>
|
|
107
|
-
</Calendar>
|
|
108
|
-
</Dialog>
|
|
109
|
-
</Popover>
|
|
110
|
-
</RACDatePicker>
|
|
111
|
-
)
|
|
82
|
+
export const DatePicker = <T extends DateValue>({ label, description, errorMessage, ...props }: DatePickerProps<T>) => {
|
|
83
|
+
// In a focus overlay, show the calendar inline (open) instead of a popover.
|
|
84
|
+
const inFocus = useInFocusOverlay()
|
|
85
|
+
return (
|
|
86
|
+
<RACDatePicker {...props} className="group flex flex-col gap-2">
|
|
87
|
+
<Label className="text-heading5 font-medium text-fg">{label}</Label>
|
|
88
|
+
{/* In focus mode the field is bare + large and the calendar is borderless,
|
|
89
|
+
so field + calendar read as one seamless editor rather than boxes. */}
|
|
90
|
+
<Group className={inFocus ? 'flex w-full items-center' : `${dateInputClass} pr-2`}>
|
|
91
|
+
<DateInput className={clsx('flex flex-1 items-center gap-0.5', inFocus && 'text-3xl font-medium')}>
|
|
92
|
+
{(segment) => <DateSegment segment={segment} className={segmentClass} />}
|
|
93
|
+
</DateInput>
|
|
94
|
+
{inFocus ? null : (
|
|
95
|
+
<RACButton
|
|
96
|
+
aria-label="Open calendar"
|
|
97
|
+
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-fg-muted outline-none transition-colors hover:bg-surface-muted hover:text-fg data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring"
|
|
98
|
+
>
|
|
99
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
100
|
+
<rect x="3" y="4" width="18" height="18" rx="2" />
|
|
101
|
+
<path d="M16 2v4M8 2v4M3 10h18" />
|
|
102
|
+
</svg>
|
|
103
|
+
</RACButton>
|
|
104
|
+
)}
|
|
105
|
+
</Group>
|
|
106
|
+
{description ? (
|
|
107
|
+
<Text slot="description" className="text-xs text-fg-muted">
|
|
108
|
+
{description}
|
|
109
|
+
</Text>
|
|
110
|
+
) : null}
|
|
111
|
+
<FieldError className="text-xs text-danger">{errorMessage}</FieldError>
|
|
112
|
+
{inFocus ? (
|
|
113
|
+
<div className="mt-2 -ml-2">
|
|
114
|
+
<CalendarBody />
|
|
115
|
+
</div>
|
|
116
|
+
) : (
|
|
117
|
+
<Popover className="rounded-lg border border-border bg-surface p-4 shadow-lg outline-none">
|
|
118
|
+
<Dialog className="outline-none">
|
|
119
|
+
<CalendarBody />
|
|
120
|
+
</Dialog>
|
|
121
|
+
</Popover>
|
|
122
|
+
)}
|
|
123
|
+
</RACDatePicker>
|
|
124
|
+
)
|
|
125
|
+
}
|