@gentleduck/registry-ui 0.3.0 → 0.3.1

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.
@@ -9,12 +9,12 @@ bun test v1.3.10 (30e609e0)
9
9
 
10
10
  ::group::src/sidebar/__test__/sidebar.test.tsx:
11
11
  (pass) registry-ui sidebar > SidebarTrigger prefers a provided icon over the default panel icon [1.00ms]
12
- (pass) registry-ui sidebar > SidebarTrigger still renders the default panel icon when no icon is provided [1.00ms]
12
+ (pass) registry-ui sidebar > SidebarTrigger still renders the default panel icon when no icon is provided
13
13
 
14
14
  ::endgroup::
15
15
 
16
16
  ::group::src/chart/__test__/chart.test.tsx:
17
- (pass) registry-ui chart > ChartContainer server render does not emit invalid size warnings [9.00ms]
17
+ (pass) registry-ui chart > ChartContainer server render does not emit invalid size warnings [11.00ms]
18
18
 
19
19
  ::endgroup::
20
20
 
@@ -29,7 +29,7 @@ bun test v1.3.10 (30e609e0)
29
29
  (pass) registry-ui button > buttonVariants applies explicit variant and size overrides
30
30
  (pass) registry-ui button > button exports keep stable display names
31
31
  (pass) registry-ui button > Button renders loading state as a busy disabled native button [1.00ms]
32
- (pass) registry-ui button > Button preserves explicit disabled state even when loading is false [1.00ms]
32
+ (pass) registry-ui button > Button preserves explicit disabled state even when loading is false
33
33
  (pass) registry-ui button > Button collapses into icon-only mode and hides secondary content
34
34
  (pass) registry-ui button > AnimationIcon renders left and right placements around children
35
35
 
@@ -38,4 +38,4 @@ bun test v1.3.10 (30e609e0)
38
38
  14 pass
39
39
  0 fail
40
40
  43 expect() calls
41
- Ran 14 tests across 5 files. [403.00ms]
41
+ Ran 14 tests across 5 files. [441.00ms]
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # @gentleduck/registry-ui
2
2
 
3
+ ## 0.3.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [58d1c61]
8
+ - @gentleduck/calendar@0.2.0
9
+
3
10
  ## 0.3.0
4
11
 
5
12
  ### Minor Changes
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 @gentleduck
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/SECURITY.md ADDED
@@ -0,0 +1,19 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+ We provide security updates for the latest major release of gentleduck/ui.
5
+ Older versions may not receive patches.
6
+
7
+ ## Reporting a Vulnerability
8
+ ⚠️ **Please do not disclose security issues publicly.**
9
+ If you discover a vulnerability in gentleduck/ui:
10
+
11
+ 1. Report it privately by emailing: **security@gentleduck.org**
12
+ 2. Include a detailed description of the vulnerability and how to reproduce it.
13
+ 3. We will confirm receipt within **48 hours** and provide a timeline for a fix.
14
+
15
+ ## Responsible Disclosure
16
+ We ask security researchers to give us **90 days** to address issues before public disclosure.
17
+ We will credit you in release notes unless you prefer to remain anonymous.
18
+
19
+ Thank you for helping keep gentleduck/ui secure.
package/package.json CHANGED
@@ -4,6 +4,7 @@
4
4
  "node": ">=22.0.0"
5
5
  },
6
6
  "dependencies": {
7
+ "@gentleduck/calendar": "workspace:*",
7
8
  "@gentleduck/hooks": "^0.1.12",
8
9
  "@gentleduck/libs": "^0.1.15",
9
10
  "@gentleduck/motion": "^0.1.17",
@@ -14,7 +15,6 @@
14
15
  "lucide-react": "0.577.0",
15
16
  "next-themes": "^0.4.6",
16
17
  "react": "^19.2.4",
17
- "react-day-picker": "^9.8.1",
18
18
  "react-dom": "^19.2.4",
19
19
  "react-hook-form": "^7.71.1",
20
20
  "react-resizable-panels": "^4.7.0",
@@ -56,5 +56,13 @@
56
56
  "test": "bun test"
57
57
  },
58
58
  "type": "module",
59
- "version": "0.3.0"
59
+ "version": "0.3.1",
60
+ "description": "Styled Tailwind components for Duck UI.",
61
+ "keywords": [
62
+ "gentleduck",
63
+ "react",
64
+ "components",
65
+ "tailwind",
66
+ "ui"
67
+ ]
60
68
  }
@@ -0,0 +1,131 @@
1
+ 'use client'
2
+
3
+ import type { CalendarDay as CalendarDayType, DayProps } from '@gentleduck/calendar'
4
+ import { cn } from '@gentleduck/libs/cn'
5
+ import * as React from 'react'
6
+ import { buttonVariants } from '../button'
7
+ import { getCachedNumberFormat } from './calendar.utils'
8
+
9
+ const HEBREW_ONES = [
10
+ '',
11
+ '\u05D0\u05F3',
12
+ '\u05D1\u05F3',
13
+ '\u05D2\u05F3',
14
+ '\u05D3\u05F3',
15
+ '\u05D4\u05F3',
16
+ '\u05D5\u05F3',
17
+ '\u05D6\u05F3',
18
+ '\u05D7\u05F3',
19
+ '\u05D8\u05F3',
20
+ ]
21
+ const HEBREW_TENS = ['', '\u05D9\u05F3', '\u05DB\u05F3', '\u05DC\u05F3']
22
+
23
+ function toHebrewNumeral(n: number): string {
24
+ if (n === 15) return '\u05D8\u05F4\u05D5'
25
+ if (n === 16) return '\u05D8\u05F4\u05D6'
26
+ const ten = Math.floor(n / 10)
27
+ const one = n % 10
28
+ if (one === 0) return HEBREW_TENS[ten] ?? String(n)
29
+ if (ten === 0) return HEBREW_ONES[one] ?? String(n)
30
+ const t = (HEBREW_TENS[ten] ?? '').replace('\u05F3', '')
31
+ const o = (HEBREW_ONES[one] ?? '').replace('\u05F3', '')
32
+ return `${t}\u05F4${o}`
33
+ }
34
+
35
+ interface CalendarDayCellProps {
36
+ day: CalendarDayType<Date>
37
+ dayProps: Omit<DayProps, 'role' | 'aria-selected' | 'onMouseEnter'>
38
+ isFocused: boolean
39
+ isSelectedSingle: boolean
40
+ isFirstInRow: boolean
41
+ isLastInRow: boolean
42
+ locale?: string
43
+ onFocusDate: (date: Date) => void
44
+ renderDay?: (day: CalendarDayType<Date>, children: React.ReactNode) => React.ReactNode
45
+ }
46
+
47
+ function formatDayNumber(d: number, locale?: string): React.ReactNode {
48
+ if (!locale) return d
49
+ if (locale.startsWith('ar')) return getCachedNumberFormat(`${locale}-u-nu-arab`).format(d)
50
+ if (locale.startsWith('he')) return toHebrewNumeral(d)
51
+ return getCachedNumberFormat(locale).format(d)
52
+ }
53
+
54
+ export const CalendarDayCell = React.memo(function CalendarDayCell({
55
+ day,
56
+ dayProps,
57
+ isFocused,
58
+ isSelectedSingle,
59
+ isFirstInRow,
60
+ isLastInRow,
61
+ locale,
62
+ onFocusDate,
63
+ renderDay,
64
+ }: CalendarDayCellProps) {
65
+ const isInRange = day.isRangeStart || day.isRangeEnd || day.isRangeMiddle
66
+ const dayNum = formatDayNumber(day.date.getDate(), locale)
67
+
68
+ return (
69
+ // biome-ignore lint/a11y/useSemanticElements: gridcell on div per WAI-ARIA grid pattern
70
+ // biome-ignore lint/a11y/useFocusableInteractive: gridcell focus is on the child button
71
+ <div
72
+ role="gridcell"
73
+ aria-selected={day.isSelected}
74
+ data-selected={day.isSelected ? 'true' : undefined}
75
+ data-focused={isFocused ? 'true' : undefined}
76
+ className={cn(
77
+ 'group/day relative aspect-square h-full w-full select-none p-0 text-center',
78
+ day.isHidden && 'invisible',
79
+ isInRange && 'overflow-hidden',
80
+ day.isRangeStart && 'rounded-s-md bg-accent',
81
+ day.isRangeEnd && 'rounded-e-md bg-accent',
82
+ day.isRangeMiddle && 'bg-accent',
83
+ isInRange && isFirstInRow && !day.isRangeStart && 'rounded-s-md',
84
+ isInRange && isLastInRow && !day.isRangeEnd && 'rounded-e-md',
85
+ day.isOutside && !day.isHidden && 'text-muted-foreground',
86
+ day.isOutside && !day.isHidden && day.isSelected && 'text-muted-foreground',
87
+ day.isDisabled && !day.isHidden && 'pointer-events-none text-muted-foreground opacity-50',
88
+ )}>
89
+ <button
90
+ type="button"
91
+ {...dayProps}
92
+ disabled={day.isDisabled}
93
+ tabIndex={day.isDisabled ? -1 : dayProps.tabIndex}
94
+ onClick={(e) => {
95
+ if (day.isDisabled) return
96
+ dayProps.onClick({ shiftKey: e.shiftKey })
97
+ onFocusDate(day.date)
98
+ // Remove browser focus so the cell returns to neutral visual state
99
+ ;(e.currentTarget as HTMLElement).blur()
100
+ }}
101
+ data-day={day.date.toLocaleDateString()}
102
+ data-range-end={day.isRangeEnd || undefined}
103
+ data-range-middle={day.isRangeMiddle || undefined}
104
+ data-range-start={day.isRangeStart || undefined}
105
+ data-selected-single={isSelectedSingle || undefined}
106
+ data-today={day.isToday || undefined}
107
+ data-focused={isFocused || undefined}
108
+ className={cn(
109
+ buttonVariants({ variant: 'ghost', size: 'icon' }),
110
+ 'flex aspect-square size-auto w-full min-w-(--gentleduck-calendar-cell) flex-col gap-1 font-normal leading-none focus-visible:ring-0 focus-visible:ring-offset-0',
111
+ // Single selection
112
+ 'data-[selected-single=true]:rounded-md data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground',
113
+ // Range selection
114
+ 'data-[range-start=true]:rounded-s-md data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground',
115
+ 'data-[range-end=true]:rounded-e-md data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground',
116
+ 'data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground',
117
+ // Outside month
118
+ day.isOutside && 'text-muted-foreground/50',
119
+ // Today
120
+ 'data-[today=true]:font-semibold',
121
+ // Focus ring
122
+ 'group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:rounded-md group-data-[focused=true]/day:border group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-1 group-data-[focused=true]/day:ring-ring/50',
123
+ '[&>span]:text-xs [&>span]:opacity-70',
124
+ )}>
125
+ {renderDay ? renderDay(day, dayNum) : dayNum}
126
+ </button>
127
+ </div>
128
+ )
129
+ })
130
+
131
+ CalendarDayCell.displayName = 'CalendarDayCell'
@@ -0,0 +1,291 @@
1
+ 'use client'
2
+
3
+ import { buildCalendarYear, type DateAdapter, goToMonth, goToYear, NativeAdapter } from '@gentleduck/calendar'
4
+ import { cn } from '@gentleduck/libs/cn'
5
+ import { CheckIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
6
+ import * as React from 'react'
7
+ import { buttonVariants } from '../button'
8
+ import { getCachedNumberFormat } from './calendar.utils'
9
+
10
+ const defaultAdapter = new NativeAdapter()
11
+
12
+ /** Height of each dropdown item in pixels. */
13
+ const ITEM_HEIGHT = 28
14
+ /** Number of items visible in the dropdown viewport. */
15
+ const VISIBLE_ITEMS = 9
16
+ const LIST_HEIGHT = ITEM_HEIGHT * VISIBLE_ITEMS
17
+ /** Extra items rendered above/below the visible area for smooth scrolling. */
18
+ const OVERSCAN = 3
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // VirtualizedDropdown - shared by month and year
22
+ // ---------------------------------------------------------------------------
23
+
24
+ function VirtualizedDropdown({
25
+ items,
26
+ activeValue,
27
+ onSelect,
28
+ open,
29
+ }: {
30
+ items: { value: string; label: string }[]
31
+ activeValue: string
32
+ onSelect: (value: string) => void
33
+ open: boolean
34
+ }) {
35
+ const scrollRef = React.useRef<HTMLDivElement>(null)
36
+ const [scrollTop, setScrollTop] = React.useState(() => {
37
+ const idx = items.findIndex((i) => i.value === activeValue)
38
+ return Math.max(0, idx * ITEM_HEIGHT - LIST_HEIGHT / 2 + ITEM_HEIGHT / 2)
39
+ })
40
+
41
+ const totalHeight = items.length * ITEM_HEIGHT
42
+
43
+ React.useLayoutEffect(() => {
44
+ if (!open || !scrollRef.current) return
45
+ const idx = items.findIndex((i) => i.value === activeValue)
46
+ const target = Math.max(0, idx * ITEM_HEIGHT - LIST_HEIGHT / 2 + ITEM_HEIGHT / 2)
47
+ scrollRef.current.scrollTop = target
48
+ setScrollTop(target)
49
+ }, [activeValue, items, open])
50
+
51
+ const startIndex = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - OVERSCAN)
52
+ const endIndex = Math.min(items.length, Math.ceil((scrollTop + LIST_HEIGHT) / ITEM_HEIGHT) + OVERSCAN)
53
+
54
+ // Find the longest label to size the container
55
+ const longestLabel = React.useMemo(
56
+ () => items.reduce((a, b) => (b.label.length > a.length ? b.label : a), ''),
57
+ [items],
58
+ )
59
+
60
+ return (
61
+ <div
62
+ ref={scrollRef}
63
+ data-state={open ? 'open' : 'closed'}
64
+ className={cn(
65
+ 'overflow-y-auto rounded-md border bg-popover p-1 shadow-md',
66
+ 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-top-2 data-[state=closed]:slide-out-to-top-2 origin-top data-[state=closed]:hidden data-[state=closed]:animate-out data-[state=open]:animate-in',
67
+ 'transition-all transition-discrete duration-150 ease-(--duck-motion-ease)',
68
+ )}
69
+ style={{ height: LIST_HEIGHT, scrollBehavior: 'auto' }}
70
+ onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}>
71
+ {/* Hidden sizer - establishes the intrinsic width from the longest label */}
72
+ <div
73
+ aria-hidden
74
+ className="pointer-events-none invisible flex h-0 items-center gap-2 overflow-hidden ps-6 pe-4 text-sm">
75
+ <CheckIcon className="size-3.5 shrink-0" />
76
+ {longestLabel}
77
+ </div>
78
+ <div style={{ height: totalHeight, position: 'relative' }}>
79
+ {items.slice(startIndex, endIndex).map((item, i) => {
80
+ const isActive = item.value === activeValue
81
+ return (
82
+ <button
83
+ key={item.value}
84
+ type="button"
85
+ className={cn(
86
+ 'flex w-full cursor-default select-none items-center gap-2 whitespace-nowrap rounded-sm ps-6 pe-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground',
87
+ isActive && 'bg-accent font-medium text-accent-foreground',
88
+ )}
89
+ style={{
90
+ position: 'absolute',
91
+ top: (startIndex + i) * ITEM_HEIGHT,
92
+ height: ITEM_HEIGHT,
93
+ left: 0,
94
+ right: 0,
95
+ }}
96
+ onClick={() => onSelect(item.value)}>
97
+ {isActive && <CheckIcon className="absolute start-1.5 size-3.5" />}
98
+ {item.label}
99
+ </button>
100
+ )
101
+ })}
102
+ </div>
103
+ </div>
104
+ )
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // DropdownTrigger - shared trigger button
109
+ // ---------------------------------------------------------------------------
110
+
111
+ function DropdownTrigger({ label, open, onClick }: { label: string; open: boolean; onClick: () => void }) {
112
+ return (
113
+ <button
114
+ type="button"
115
+ dir="ltr"
116
+ aria-expanded={open}
117
+ onClick={onClick}
118
+ className="flex h-7 w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md border px-2 font-medium text-sm shadow-xs">
119
+ <span dir="auto">{label}</span>
120
+ <ChevronDownIcon className="size-3 text-muted-foreground" />
121
+ </button>
122
+ )
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // CalendarHeader
127
+ // ---------------------------------------------------------------------------
128
+
129
+ interface CalendarHeaderProps {
130
+ adapter?: DateAdapter<Date>
131
+ month: Date
132
+ title: string
133
+ direction: 'ltr' | 'rtl'
134
+ locale?: string
135
+ buttonVariant: string
136
+ showDropdowns: boolean
137
+ yearRange: { from: number; to: number }
138
+ getNavProps: (dir: 'prev' | 'next') => { 'aria-label': string; disabled: boolean; onClick: () => void }
139
+ getHeaderProps: () => { id: string; 'aria-live': 'polite' }
140
+ onMonthSelect: (date: Date) => void
141
+ }
142
+
143
+ /** Prevents Select portal interactions from dismissing parent Popover. */
144
+ function stopPopoverDismiss(e: React.PointerEvent) {
145
+ e.stopPropagation()
146
+ }
147
+
148
+ export function CalendarHeader({
149
+ adapter: adapterProp,
150
+ month,
151
+ title,
152
+ direction,
153
+ locale,
154
+ buttonVariant,
155
+ showDropdowns,
156
+ yearRange,
157
+ getNavProps,
158
+ getHeaderProps,
159
+ onMonthSelect,
160
+ }: CalendarHeaderProps) {
161
+ const adapter = adapterProp ?? defaultAdapter
162
+ const headerProps = getHeaderProps()
163
+ const currentYear = adapter.getYear(month)
164
+ const currentMonth = adapter.getMonth(month)
165
+ const [monthOpen, setMonthOpen] = React.useState(false)
166
+ const [yearOpen, setYearOpen] = React.useState(false)
167
+ const dropdownRef = React.useRef<HTMLDivElement>(null)
168
+
169
+ // Close dropdowns on click outside (without a fixed backdrop that breaks Popover)
170
+ React.useEffect(() => {
171
+ if (!monthOpen && !yearOpen) return
172
+ function handlePointerDown(e: PointerEvent) {
173
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
174
+ setMonthOpen(false)
175
+ setYearOpen(false)
176
+ }
177
+ }
178
+ document.addEventListener('pointerdown', handlePointerDown, true)
179
+ return () => document.removeEventListener('pointerdown', handlePointerDown, true)
180
+ }, [monthOpen, yearOpen])
181
+
182
+ const isArabic = locale?.startsWith('ar')
183
+ const formatLocaleTag = isArabic ? `${locale}-u-nu-arab` : locale
184
+
185
+ const monthItems = React.useMemo(() => {
186
+ const entries = buildCalendarYear(adapter, month, locale)
187
+ return entries.map((e) => ({
188
+ value: String(e.month),
189
+ label: isArabic ? e.label : e.label.slice(0, 3),
190
+ }))
191
+ }, [adapter, month, locale, isArabic])
192
+
193
+ const yearItems = React.useMemo(() => {
194
+ const fmt = formatLocaleTag ? getCachedNumberFormat(formatLocaleTag, { useGrouping: false }) : null
195
+ const result: { value: string; label: string }[] = []
196
+ for (let y = yearRange.from; y <= yearRange.to; y++) {
197
+ result.push({ value: String(y), label: fmt ? fmt.format(y) : String(y) })
198
+ }
199
+ return result
200
+ }, [yearRange.from, yearRange.to, formatLocaleTag])
201
+
202
+ return (
203
+ <div className="flex h-(--gentleduck-calendar-cell) w-full items-center justify-center px-(--gentleduck-calendar-cell)">
204
+ <div className="absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1">
205
+ <button
206
+ type="button"
207
+ {...getNavProps('prev')}
208
+ className={cn(
209
+ buttonVariants({ variant: buttonVariant as 'ghost' }),
210
+ 'size-(--gentleduck-calendar-cell) select-none p-0 aria-disabled:opacity-50',
211
+ )}>
212
+ <ChevronLeftIcon className={cn('size-4', direction === 'rtl' && 'rotate-180')} />
213
+ </button>
214
+
215
+ {showDropdowns ? (
216
+ <div
217
+ ref={dropdownRef}
218
+ {...headerProps}
219
+ className="flex items-center gap-1.5"
220
+ onPointerDown={stopPopoverDismiss}>
221
+ {/* Month dropdown */}
222
+ <div className="relative">
223
+ <DropdownTrigger
224
+ label={adapter.format(month, { month: isArabic ? 'long' : 'short' }, formatLocaleTag)}
225
+ open={monthOpen}
226
+ onClick={() => {
227
+ setMonthOpen((o) => !o)
228
+ setYearOpen(false)
229
+ }}
230
+ />
231
+ <div className="absolute start-0 top-full z-50 mt-1">
232
+ <VirtualizedDropdown
233
+ items={monthItems}
234
+ activeValue={String(currentMonth)}
235
+ open={monthOpen}
236
+ onSelect={(v) => {
237
+ onMonthSelect(goToMonth(adapter, month, Number(v)))
238
+ setMonthOpen(false)
239
+ }}
240
+ />
241
+ </div>
242
+ </div>
243
+
244
+ {/* Year dropdown */}
245
+ <div className="relative">
246
+ <DropdownTrigger
247
+ label={
248
+ formatLocaleTag
249
+ ? getCachedNumberFormat(formatLocaleTag, { useGrouping: false }).format(currentYear)
250
+ : String(currentYear)
251
+ }
252
+ open={yearOpen}
253
+ onClick={() => {
254
+ setYearOpen((o) => !o)
255
+ setMonthOpen(false)
256
+ }}
257
+ />
258
+ <div className="absolute end-0 top-full z-50 mt-1">
259
+ <VirtualizedDropdown
260
+ items={yearItems}
261
+ activeValue={String(currentYear)}
262
+ open={yearOpen}
263
+ onSelect={(v) => {
264
+ onMonthSelect(goToYear(adapter, month, Number(v)))
265
+ setYearOpen(false)
266
+ }}
267
+ />
268
+ </div>
269
+ </div>
270
+ </div>
271
+ ) : (
272
+ <div {...headerProps} className="select-none font-medium text-sm">
273
+ {title}
274
+ </div>
275
+ )}
276
+
277
+ <button
278
+ type="button"
279
+ {...getNavProps('next')}
280
+ className={cn(
281
+ buttonVariants({ variant: buttonVariant as 'ghost' }),
282
+ 'size-(--gentleduck-calendar-cell) select-none p-0 aria-disabled:opacity-50',
283
+ )}>
284
+ <ChevronRightIcon className={cn('size-4', direction === 'rtl' && 'rotate-180')} />
285
+ </button>
286
+ </div>
287
+ </div>
288
+ )
289
+ }
290
+
291
+ CalendarHeader.displayName = 'CalendarHeader'
@@ -1,211 +1,224 @@
1
1
  'use client'
2
2
 
3
+ import { NativeAdapter, useCalendar } from '@gentleduck/calendar'
3
4
  import { cn } from '@gentleduck/libs/cn'
4
- import { type Direction, useDirection } from '@gentleduck/primitives/direction'
5
- import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
5
+ import { useDirection } from '@gentleduck/primitives/direction'
6
+ import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
6
7
  import * as React from 'react'
7
- import { type DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'
8
- import { Button, buttonVariants } from '../button'
8
+ import { buttonVariants } from '../button'
9
+ import type { CalendarProps } from './calendar.types'
10
+ import { CalendarDayCell } from './calendar-day'
11
+ import { CalendarHeader } from './calendar-header'
9
12
 
10
- function mergeRefs<T>(...refs: (React.Ref<T> | undefined)[]): React.RefCallback<T> {
11
- return (node) => {
12
- for (const ref of refs) {
13
- if (typeof ref === 'function') {
14
- ref(node)
15
- } else if (ref != null) {
16
- ;(ref as React.MutableRefObject<T | null>).current = node
17
- }
18
- }
19
- }
20
- }
13
+ const defaultAdapter = new NativeAdapter()
21
14
 
22
- const Calendar = React.forwardRef<
23
- HTMLDivElement,
24
- React.ComponentProps<typeof DayPicker> & {
25
- buttonVariant?: React.ComponentProps<typeof Button>['variant']
26
- }
27
- >(
15
+ const Calendar = React.forwardRef<HTMLDivElement, CalendarProps>(
28
16
  (
29
17
  {
30
18
  className,
31
- classNames,
32
- showOutsideDays = true,
33
- captionLayout = 'label',
19
+ adapter = defaultAdapter,
34
20
  buttonVariant = 'ghost',
35
- formatters,
36
- components,
21
+ mode = 'single',
22
+ selected,
23
+ onSelect,
24
+ disabled,
25
+ defaultMonth,
26
+ month: controlledMonth,
27
+ onMonthChange,
28
+ showOutsideDays = true,
29
+ fixedWeeks = false,
30
+ numberOfMonths = 1,
31
+ locale,
37
32
  dir,
38
- ...props
33
+ fromDate,
34
+ toDate,
35
+ onDismiss,
36
+ showDropdowns = true,
37
+ yearRange,
38
+ renderDay,
39
+ renderHeader,
40
+ renderWeekday,
41
+ renderFooter,
39
42
  },
40
43
  ref,
41
44
  ) => {
42
- const direction = useDirection(dir as Direction)
43
- const defaultClassNames = getDefaultClassNames()
44
- const localeTag = React.useMemo(() => {
45
- const code = props.locale?.code
46
- if (!code) return undefined
47
- return code.startsWith('ar') ? `${code}-u-nu-arab` : code
48
- }, [props.locale])
45
+ const direction = useDirection(dir)
46
+ const currentYear = new Date().getFullYear()
47
+ const resolvedYearRange = yearRange ?? { from: currentYear - 100, to: currentYear + 10 }
48
+ // Build full locale tag with numbering system for Arabic
49
+ const formatLocale = locale?.startsWith('ar') ? `${locale}-u-nu-arab` : locale
49
50
 
50
- const monthFormatter = React.useMemo(() => {
51
- return new Intl.DateTimeFormat(localeTag, { month: 'short' })
52
- }, [localeTag])
51
+ const calendar = useCalendar({
52
+ adapter,
53
+ mode,
54
+ locale: locale ? { locale, weekStartDay: 0, direction } : { weekStartDay: 0, direction },
55
+ month: controlledMonth,
56
+ defaultMonth,
57
+ selected,
58
+ onSelect,
59
+ onMonthChange,
60
+ showOutsideDays,
61
+ fixedWeeks,
62
+ numberOfMonths,
63
+ disabled,
64
+ fromDate,
65
+ toDate,
66
+ onDismiss,
67
+ })
53
68
 
54
- const captionFormatter = React.useMemo(() => {
55
- return new Intl.DateTimeFormat(localeTag, { month: 'long', year: 'numeric' })
56
- }, [localeTag])
69
+ const { state, getDayProps, getGridProps, getNavProps, getHeaderProps, announcer } = calendar
57
70
 
58
- const numberFormatter = React.useMemo(() => {
59
- return new Intl.NumberFormat(localeTag)
60
- }, [localeTag])
71
+ // Only show focus ring during keyboard navigation, not on mouse clicks
72
+ const [keyboardActive, setKeyboardActive] = React.useState(false)
61
73
 
62
- const formatLocalizedNumber = React.useCallback(
63
- (value: number) => {
64
- return numberFormatter.format(value)
65
- },
66
- [numberFormatter],
67
- )
74
+ const prevNavProps = getNavProps('prev')
75
+ const nextNavProps = getNavProps('next')
68
76
 
69
77
  return (
70
- <DayPicker
78
+ // biome-ignore lint/a11y/noStaticElementInteractions: keyboard/pointer tracking for focus ring management
79
+ <div
80
+ ref={ref}
81
+ data-slot="calendar"
71
82
  dir={direction}
72
- captionLayout={captionLayout}
83
+ onKeyDown={() => {
84
+ if (!keyboardActive) setKeyboardActive(true)
85
+ }}
86
+ onPointerDown={() => {
87
+ if (keyboardActive) setKeyboardActive(false)
88
+ }}
73
89
  className={cn(
74
- 'group/calendar bg-background in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent p-3 [--cell-size:--spacing(8)]',
75
- String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
76
- String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
90
+ 'group/calendar w-fit bg-background p-3 [--gentleduck-calendar-cell:--spacing(8)]',
91
+ 'rounded-md in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
77
92
  className,
78
- )}
79
- classNames={{
80
- button_next: cn(
81
- buttonVariants({ variant: buttonVariant }),
82
- 'size-(--cell-size) select-none p-0 aria-disabled:opacity-50',
83
- defaultClassNames.button_next,
84
- ),
85
- button_previous: cn(
86
- buttonVariants({ variant: buttonVariant }),
87
- 'size-(--cell-size) select-none p-0 aria-disabled:opacity-50',
88
- defaultClassNames.button_previous,
89
- ),
90
- caption_label: cn(
91
- 'select-none font-medium',
92
- captionLayout === 'label'
93
- ? 'text-sm'
94
- : 'flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
95
- defaultClassNames.caption_label,
96
- ),
97
- day: cn(
98
- 'group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-s-md [&:last-child[data-selected=true]_button]:rounded-e-md',
99
- defaultClassNames.day,
100
- ),
101
- disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
102
- dropdown: cn('absolute inset-0 bg-popover opacity-0', defaultClassNames.dropdown),
103
- dropdown_root: cn(
104
- 'relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
105
- defaultClassNames.dropdown_root,
106
- ),
107
- dropdowns: cn(
108
- 'flex h-(--cell-size) w-full items-center justify-center gap-1.5 font-medium text-sm',
109
- defaultClassNames.dropdowns,
110
- ),
111
- hidden: cn('invisible', defaultClassNames.hidden),
112
- month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
113
- month_caption: cn(
114
- 'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
115
- defaultClassNames.month_caption,
116
- ),
117
- months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months),
118
- nav: cn('absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', defaultClassNames.nav),
119
- outside: cn('text-muted-foreground aria-selected:text-muted-foreground', defaultClassNames.outside),
120
- range_end: cn('rounded-e-md bg-accent', defaultClassNames.range_end),
121
- range_middle: cn('rounded-none', defaultClassNames.range_middle),
122
- range_start: cn('rounded-s-md bg-accent', defaultClassNames.range_start),
123
- root: cn('w-fit', defaultClassNames.root),
124
- table: 'w-full border-collapse',
125
- today: cn(
126
- 'rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none',
127
- defaultClassNames.today,
128
- ),
129
- week: cn('mt-2 flex w-full', defaultClassNames.week),
130
- week_number: cn('select-none text-[0.8rem] text-muted-foreground', defaultClassNames.week_number),
131
- week_number_header: cn('w-(--cell-size) select-none', defaultClassNames.week_number_header),
132
- weekday: cn(
133
- 'flex-1 select-none rounded-md font-normal text-[0.8rem] text-muted-foreground',
134
- defaultClassNames.weekday,
135
- ),
136
- weekdays: cn('flex', defaultClassNames.weekdays),
137
- ...classNames,
138
- }}
139
- components={{
140
- Chevron: ({ className, orientation, ...props }) => {
141
- if (orientation === 'left') {
142
- return <ChevronLeftIcon className={cn('size-4', className)} {...props} />
143
- }
93
+ )}>
94
+ <div className="relative flex flex-col gap-4">
95
+ {/* Nav header - spans full width above all months */}
96
+ {renderHeader ? (
97
+ renderHeader({
98
+ month: state.month,
99
+ title: adapter.format(state.month, { month: 'long', year: 'numeric' }, formatLocale),
100
+ direction,
101
+ goToPrevMonth: prevNavProps.onClick,
102
+ goToNextMonth: nextNavProps.onClick,
103
+ isPrevDisabled: prevNavProps.disabled,
104
+ isNextDisabled: nextNavProps.disabled,
105
+ })
106
+ ) : numberOfMonths <= 1 ? (
107
+ <CalendarHeader
108
+ adapter={adapter}
109
+ month={state.month}
110
+ title={adapter.format(state.month, { month: 'long', year: 'numeric' }, formatLocale)}
111
+ direction={direction}
112
+ locale={locale}
113
+ buttonVariant={buttonVariant}
114
+ showDropdowns={showDropdowns}
115
+ yearRange={resolvedYearRange}
116
+ getNavProps={getNavProps}
117
+ getHeaderProps={getHeaderProps}
118
+ onMonthSelect={calendar.actions.setMonth}
119
+ />
120
+ ) : (
121
+ <div className="relative flex w-full items-center">
122
+ <button
123
+ type="button"
124
+ {...prevNavProps}
125
+ className={cn(
126
+ buttonVariants({ variant: buttonVariant as 'ghost' }),
127
+ 'absolute start-0 z-10 size-(--gentleduck-calendar-cell) select-none p-0 aria-disabled:opacity-50',
128
+ )}>
129
+ <ChevronLeftIcon className={cn('size-4', direction === 'rtl' && 'rotate-180')} />
130
+ </button>
131
+ {state.months.map((m) => (
132
+ <span key={m.month.getTime()} className="flex-1 select-none text-center font-medium text-sm">
133
+ {adapter.format(m.month, { month: 'long', year: 'numeric' }, formatLocale)}
134
+ </span>
135
+ ))}
136
+ <button
137
+ type="button"
138
+ {...nextNavProps}
139
+ className={cn(
140
+ buttonVariants({ variant: buttonVariant as 'ghost' }),
141
+ 'absolute end-0 z-10 size-(--gentleduck-calendar-cell) select-none p-0 aria-disabled:opacity-50',
142
+ )}>
143
+ <ChevronRightIcon className={cn('size-4', direction === 'rtl' && 'rotate-180')} />
144
+ </button>
145
+ </div>
146
+ )}
144
147
 
145
- if (orientation === 'right') {
146
- return <ChevronRightIcon className={cn('size-4', className)} {...props} />
147
- }
148
-
149
- return <ChevronDownIcon className={cn('size-4', className)} {...props} />
150
- },
151
- DayButton: CalendarDayButton,
152
- Root: ({ className, rootRef, ...props }) => {
153
- return <div className={cn(className)} data-slot="calendar" ref={mergeRefs(ref, rootRef)} {...props} />
154
- },
155
- WeekNumber: ({ children, ...props }) => {
156
- return (
157
- <td {...props}>
158
- <div className="flex size-(--cell-size) items-center justify-center text-center">{children}</div>
159
- </td>
160
- )
161
- },
162
- ...components,
163
- }}
164
- formatters={{
165
- formatCaption: (date) => captionFormatter.format(date),
166
- formatDay: (date) => formatLocalizedNumber(date.getDate()),
167
- formatMonthDropdown: (date) => monthFormatter.format(date),
168
- formatWeekNumber: (weekNumber) => formatLocalizedNumber(weekNumber),
169
- formatYearDropdown: (date) => String(date.getFullYear()),
170
- ...formatters,
171
- }}
172
- showOutsideDays={showOutsideDays}
173
- {...props}
174
- />
148
+ <div className="flex flex-col gap-4 md:flex-row">
149
+ {state.months.map((monthGrid) => {
150
+ const gridProps = getGridProps()
151
+ return (
152
+ <div key={monthGrid.month.getTime()} className="flex w-full flex-col gap-4">
153
+ <div {...gridProps}>
154
+ {/* biome-ignore lint/a11y/useSemanticElements: role="row" on div per WAI-ARIA grid pattern */}
155
+ {/* biome-ignore lint/a11y/useFocusableInteractive: weekday header row is not interactive */}
156
+ <div role="row" className="flex">
157
+ {state.weekdays.map((day, index) => (
158
+ // biome-ignore lint/a11y/useSemanticElements: columnheader on div per WAI-ARIA grid pattern
159
+ // biome-ignore lint/a11y/useFocusableInteractive: weekday headers are not interactive
160
+ <div
161
+ key={day}
162
+ role="columnheader"
163
+ className="flex-1 select-none rounded-md text-center font-normal text-[0.8rem] text-muted-foreground">
164
+ {renderWeekday
165
+ ? renderWeekday(day, index)
166
+ : locale?.startsWith('ar')
167
+ ? day.replace(/^ال/, '')
168
+ : locale?.startsWith('fa')
169
+ ? day.slice(0, 2)
170
+ : locale?.startsWith('he')
171
+ ? day.replace(/^יום\s*/, '')
172
+ : day}
173
+ </div>
174
+ ))}
175
+ </div>
176
+ {monthGrid.weeks.map((week) => (
177
+ // biome-ignore lint/a11y/useSemanticElements: role="row" on div per WAI-ARIA grid pattern
178
+ // biome-ignore lint/a11y/useFocusableInteractive: grid rows are not interactive
179
+ <div key={week.weekNumber} role="row" className="mt-2 flex w-full">
180
+ {week.days.map((day, dayIdx) => {
181
+ const {
182
+ onMouseEnter: _,
183
+ role: _role,
184
+ 'aria-selected': _ariaSel,
185
+ ...dayProps
186
+ } = getDayProps(day)
187
+ const isSelectedSingle =
188
+ day.isSelected && !day.isRangeStart && !day.isRangeEnd && !day.isRangeMiddle
189
+ const isFocused = keyboardActive && day.date.getTime() === state.focusedDate.getTime()
190
+ return (
191
+ <CalendarDayCell
192
+ key={day.date.getTime()}
193
+ day={day}
194
+ dayProps={dayProps}
195
+ isFocused={isFocused}
196
+ isSelectedSingle={isSelectedSingle}
197
+ isFirstInRow={dayIdx === 0}
198
+ isLastInRow={dayIdx === 6}
199
+ locale={locale}
200
+ onFocusDate={(date) => {
201
+ setKeyboardActive(false)
202
+ calendar.actions.focusDate(date)
203
+ }}
204
+ renderDay={renderDay}
205
+ />
206
+ )
207
+ })}
208
+ </div>
209
+ ))}
210
+ </div>
211
+ </div>
212
+ )
213
+ })}
214
+ </div>
215
+ {renderFooter?.(state.months)}
216
+ </div>
217
+ <announcer.AnnouncerPortal />
218
+ </div>
175
219
  )
176
220
  },
177
221
  )
178
222
  Calendar.displayName = 'Calendar'
179
223
 
180
- function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps<typeof DayButton>) {
181
- const defaultClassNames = getDefaultClassNames()
182
-
183
- const ref = React.useRef<HTMLButtonElement>(null)
184
- React.useEffect(() => {
185
- if (modifiers.focused) ref.current?.focus()
186
- }, [modifiers.focused])
187
-
188
- return (
189
- <Button
190
- className={cn(
191
- 'flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-s-md data-[range-end=true]:rounded-e-md data-[range-end=true]:bg-primary data-[range-middle=true]:bg-accent data-[range-start=true]:bg-primary data-[selected-single=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:text-accent-foreground data-[range-start=true]:text-primary-foreground data-[selected-single=true]:text-primary-foreground group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 [&>span]:text-xs [&>span]:opacity-70',
192
- defaultClassNames.day,
193
- className,
194
- )}
195
- data-day={day.date.toLocaleDateString()}
196
- data-range-end={modifiers.range_end}
197
- data-range-middle={modifiers.range_middle}
198
- data-range-start={modifiers.range_start}
199
- data-selected-single={
200
- modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
201
- }
202
- ref={ref}
203
- size="icon"
204
- variant="ghost"
205
- {...props}
206
- />
207
- )
208
- }
209
- CalendarDayButton.displayName = 'CalendarDayButton'
210
-
211
- export { Calendar, CalendarDayButton }
224
+ export { Calendar }
@@ -0,0 +1,135 @@
1
+ import type { CalendarDay, CalendarMonth, DateAdapter, SelectionMode } from '@gentleduck/calendar'
2
+ import type { Direction } from '@gentleduck/primitives/direction'
3
+ import type { Button } from '../button'
4
+
5
+ export interface CalendarHeaderContext {
6
+ /** The current displayed month Date. */
7
+ month: Date
8
+ /** Formatted title string (e.g. "March 2026"). */
9
+ title: string
10
+ /** Resolved text direction. */
11
+ direction: 'ltr' | 'rtl'
12
+ /** Navigate to the previous month. */
13
+ goToPrevMonth: () => void
14
+ /** Navigate to the next month. */
15
+ goToNextMonth: () => void
16
+ /** Whether previous navigation is disabled. */
17
+ isPrevDisabled: boolean
18
+ /** Whether next navigation is disabled. */
19
+ isNextDisabled: boolean
20
+ }
21
+
22
+ export interface CalendarProps {
23
+ className?: string
24
+ /**
25
+ * Date adapter for alternative calendar systems (Islamic, Persian, etc.).
26
+ * Default uses `NativeAdapter` (Gregorian).
27
+ */
28
+ adapter?: DateAdapter<Date>
29
+ /** Variant style for navigation buttons. Default `'ghost'`. */
30
+ buttonVariant?: React.ComponentProps<typeof Button>['variant']
31
+ /** Selection mode. Default `'single'`. */
32
+ mode?: SelectionMode
33
+ /** Controlled selection value. Shape depends on `mode`. */
34
+ // biome-ignore lint/suspicious/noExplicitAny: CalendarValue union is narrowed by mode at runtime
35
+ selected?: any
36
+ /** Called when the selection changes. Value shape depends on `mode`. */
37
+ // biome-ignore lint/suspicious/noExplicitAny: CalendarValue union is narrowed by mode at runtime
38
+ onSelect?: (value: any) => void
39
+ /** Dates that cannot be selected. */
40
+ disabled?: Date[] | ((date: Date) => boolean)
41
+ /** Default month to display (uncontrolled). */
42
+ defaultMonth?: Date
43
+ /** Controlled month. */
44
+ month?: Date
45
+ /** Called when the displayed month changes. */
46
+ onMonthChange?: (month: Date) => void
47
+ /** Show days from adjacent months. Default `true`. */
48
+ showOutsideDays?: boolean
49
+ /** Always show 6 weeks. Default `false`. */
50
+ fixedWeeks?: boolean
51
+ /** How many months to show side by side. Default `1`. */
52
+ numberOfMonths?: number
53
+ /** BCP 47 locale tag (e.g. `'ar-SA'`). */
54
+ locale?: string
55
+ /** Text direction. */
56
+ dir?: Direction
57
+ /** Earliest selectable date. */
58
+ fromDate?: Date
59
+ /** Latest selectable date. */
60
+ toDate?: Date
61
+ /** Called when the user presses Escape. */
62
+ onDismiss?: () => void
63
+ /**
64
+ * Show month and year dropdowns in the header.
65
+ * Default `true`. Set to `false` for a minimal caption.
66
+ */
67
+ showDropdowns?: boolean
68
+ /**
69
+ * Range of years to show in the year dropdown.
70
+ * Default `{ from: currentYear - 100, to: currentYear + 10 }`.
71
+ */
72
+ yearRange?: { from: number; to: number }
73
+ /**
74
+ * Custom render function for day cells.
75
+ * Receives the day object and the default rendered children (the date number).
76
+ * Return a ReactNode to replace or wrap the default content.
77
+ *
78
+ * @example
79
+ * ```tsx
80
+ * renderDay={(day, children) => (
81
+ * <>
82
+ * {children}
83
+ * {hasEvents(day.date) && <span className="size-1 rounded-full bg-primary" />}
84
+ * </>
85
+ * )}
86
+ * ```
87
+ */
88
+ renderDay?: (day: CalendarDay<Date>, children: React.ReactNode) => React.ReactNode
89
+ /**
90
+ * Custom render function for the navigation header.
91
+ * Receives header context with month info and navigation controls.
92
+ * Return a ReactNode to replace the default header entirely.
93
+ *
94
+ * @example
95
+ * ```tsx
96
+ * renderHeader={({ title, goToPrevMonth, goToNextMonth }) => (
97
+ * <div className="flex items-center justify-between">
98
+ * <button onClick={goToPrevMonth}><-</button>
99
+ * <span>{title}</span>
100
+ * <button onClick={goToNextMonth}>-></button>
101
+ * </div>
102
+ * )}
103
+ * ```
104
+ */
105
+ renderHeader?: (context: CalendarHeaderContext) => React.ReactNode
106
+ /**
107
+ * Custom render function for weekday column headers.
108
+ * Receives the weekday abbreviation (e.g. "Sun") and its index (0-6).
109
+ * Return a ReactNode to replace the default weekday label.
110
+ *
111
+ * @example
112
+ * ```tsx
113
+ * renderWeekday={(day, index) => (
114
+ * <span className={index === 0 || index === 6 ? 'text-red-500' : ''}>
115
+ * {day}
116
+ * </span>
117
+ * )}
118
+ * ```
119
+ */
120
+ renderWeekday?: (day: string, index: number) => React.ReactNode
121
+ /**
122
+ * Render content below the calendar grid.
123
+ * Receives the current months array for context.
124
+ *
125
+ * @example
126
+ * ```tsx
127
+ * renderFooter={(months) => (
128
+ * <div className="mt-2 text-xs text-muted-foreground">
129
+ * Selected: {selected?.toLocaleDateString()}
130
+ * </div>
131
+ * )}
132
+ * ```
133
+ */
134
+ renderFooter?: (months: CalendarMonth<Date>[]) => React.ReactNode
135
+ }
@@ -0,0 +1,23 @@
1
+ const MAX_CACHE_SIZE = 20
2
+
3
+ /** Cache Intl.NumberFormat instances to avoid recreating formatters on every render. */
4
+ const NUMBER_FORMAT_CACHE = new Map<string, Intl.NumberFormat>()
5
+
6
+ export function getCachedNumberFormat(locale: string, options?: Intl.NumberFormatOptions): Intl.NumberFormat {
7
+ const key = options ? `${locale}|${JSON.stringify(options)}` : locale
8
+ let fmt = NUMBER_FORMAT_CACHE.get(key)
9
+ if (fmt) {
10
+ // LRU: move to end
11
+ NUMBER_FORMAT_CACHE.delete(key)
12
+ NUMBER_FORMAT_CACHE.set(key, fmt)
13
+ return fmt
14
+ }
15
+ // Evict oldest if at capacity
16
+ if (NUMBER_FORMAT_CACHE.size >= MAX_CACHE_SIZE) {
17
+ const oldest = NUMBER_FORMAT_CACHE.keys().next().value
18
+ if (oldest !== undefined) NUMBER_FORMAT_CACHE.delete(oldest)
19
+ }
20
+ fmt = new Intl.NumberFormat(locale, options)
21
+ NUMBER_FORMAT_CACHE.set(key, fmt)
22
+ return fmt
23
+ }
@@ -1 +1,2 @@
1
- export * from './calendar'
1
+ export { Calendar } from './calendar'
2
+ export type { CalendarHeaderContext, CalendarProps } from './calendar.types'
@@ -31,7 +31,6 @@ const SonnerUpload = ({
31
31
  )}
32
32
  />
33
33
  <div className="flex w-full flex-col gap-2">
34
- {/* biome-ignore lint/a11y/useSemanticElements: status role on div is intentional for live region announcements */}
35
34
  <div className="flex w-full justify-between" role="status">
36
35
  <p className="text-foreground text-sm">
37
36
  {progress >= 100