@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podoba/react",
3
- "version": "0.0.7",
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.7",
29
- "@podoba/tailwind": "^0.0.7",
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
- // `menuTrigger="focus"` opens the list on focus/click (not only on typing), so
71
- // the options are always one interaction away. Consumers can override via props.
72
- <RACComboBox menuTrigger="focus" {...props} className="group flex flex-col gap-2">
73
- <Label className="text-heading5 font-medium text-fg">{label}</Label>
74
- <div className="relative">
75
- <ComboBoxInput placeholder={placeholder} />
76
- {/* RAC uses this Button to toggle the listbox open/closed. */}
77
- <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">
78
- <Chevron />
79
- </RACButton>
80
- </div>
81
- {description ? (
82
- <Text slot="description" className="text-xs text-fg-muted">
83
- {description}
84
- </Text>
85
- ) : null}
86
- <FieldError className="text-xs text-danger">{errorMessage}</FieldError>
87
- <Popover className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg">
88
- <ListBox
89
- className="flex max-h-64 flex-col gap-0.5 overflow-auto overscroll-contain p-1 outline-none"
90
- renderEmptyState={() => <div className="px-3 py-2 text-sm text-fg-muted">No results</div>}
91
- >
92
- {children}
93
- </ListBox>
94
- </Popover>
95
- </RACComboBox>
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
- <RACDatePicker {...props} className="group flex flex-col gap-2">
49
- <Label className="text-heading5 font-medium text-fg">{label}</Label>
50
- <Group className={`${dateInputClass} pr-2`}>
51
- <DateInput className="flex flex-1 items-center gap-0.5">
52
- {(segment) => <DateSegment segment={segment} className={segmentClass} />}
53
- </DateInput>
54
- <RACButton
55
- aria-label="Open calendar"
56
- 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"
57
- >
58
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
59
- <rect x="3" y="4" width="18" height="18" rx="2" />
60
- <path d="M16 2v4M8 2v4M3 10h18" />
61
- </svg>
62
- </RACButton>
63
- </Group>
64
- {description ? (
65
- <Text slot="description" className="text-xs text-fg-muted">
66
- {description}
67
- </Text>
68
- ) : null}
69
- <FieldError className="text-xs text-danger">{errorMessage}</FieldError>
70
- <Popover className="rounded-lg border border-border bg-surface p-4 shadow-lg outline-none">
71
- <Dialog className="outline-none">
72
- <Calendar className="w-[17.5rem]">
73
- <header className="flex items-center justify-between pb-3">
74
- <RACButton slot="previous" className={navButton}>
75
- <Chevron dir="left" />
76
- </RACButton>
77
- <Heading className="text-sm font-medium text-fg" />
78
- <RACButton slot="next" className={navButton}>
79
- <Chevron dir="right" />
80
- </RACButton>
81
- </header>
82
- <CalendarGrid className="w-full border-collapse">
83
- <CalendarGridHeader>
84
- {(day) => (
85
- <CalendarHeaderCell className="pb-1 text-micro font-medium uppercase tracking-wide text-fg-subtle">
86
- {day}
87
- </CalendarHeaderCell>
88
- )}
89
- </CalendarGridHeader>
90
- <CalendarGridBody>
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
+ }