@gentleduck/registry-ui 0.3.0 → 0.3.2

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.
Files changed (35) hide show
  1. package/.turbo/turbo-test.log +19 -19
  2. package/CHANGELOG.md +14 -0
  3. package/LICENSE +21 -0
  4. package/SECURITY.md +19 -0
  5. package/bunfig.toml +2 -0
  6. package/package.json +12 -4
  7. package/src/calendar/calendar-day.tsx +131 -0
  8. package/src/calendar/calendar-header.tsx +291 -0
  9. package/src/calendar/calendar.tsx +195 -182
  10. package/src/calendar/calendar.types.ts +135 -0
  11. package/src/calendar/calendar.utils.ts +23 -0
  12. package/src/calendar/index.ts +2 -1
  13. package/src/sonner/sonner.chunks.tsx +0 -1
  14. package/tsconfig.json +1 -1
  15. package/src/_old/_table/index.ts +0 -14
  16. package/src/_old/_table/table-advanced.constants.tsx +0 -24
  17. package/src/_old/_table/table-advanced.tsx +0 -311
  18. package/src/_old/_table/table-advanced.types.ts +0 -272
  19. package/src/_old/_table/table.constants.ts +0 -2
  20. package/src/_old/_table/table.hook.tsx +0 -115
  21. package/src/_old/_table/table.lib.ts +0 -85
  22. package/src/_old/_table/table.tsx +0 -916
  23. package/src/_old/_table/table.types.ts +0 -118
  24. package/src/_old/_table/todo.md +0 -11
  25. package/src/_old/_upload/index.ts +0 -22
  26. package/src/_old/_upload/todo.md +0 -38
  27. package/src/_old/_upload/upload-advanced-chunks.tsx +0 -1624
  28. package/src/_old/_upload/upload-advanced.tsx +0 -507
  29. package/src/_old/_upload/upload-sonner.tsx +0 -58
  30. package/src/_old/_upload/upload.assets.tsx +0 -239
  31. package/src/_old/_upload/upload.constants.tsx +0 -75
  32. package/src/_old/_upload/upload.dto.ts +0 -19
  33. package/src/_old/_upload/upload.lib.tsx +0 -630
  34. package/src/_old/_upload/upload.tsx +0 -491
  35. package/src/_old/_upload/upload.types.ts +0 -436
@@ -1,9 +1,20 @@
1
1
  $ bun test
2
2
  bun test v1.3.10 (30e609e0)
3
3
 
4
- ::group::src/pagination/__test__/pagination.test.tsx:
5
- (pass) registry-ui pagination > PaginationWrapper prefers provided button icons over the default chevrons [17.00ms]
6
- (pass) registry-ui pagination > PaginationWrapper still renders default chevrons when icons are not provided [3.00ms]
4
+ ::group::src/carousel/__test__/carousel.test.tsx:
5
+ (pass) registry-ui carousel > CarouselPrevious prefers a provided icon over the default arrow icon [17.00ms]
6
+ (pass) registry-ui carousel > CarouselNext still renders the default arrow icon when no icon is provided [2.00ms]
7
+
8
+ ::endgroup::
9
+
10
+ ::group::src/button/__test__/button.test.tsx:
11
+ (pass) registry-ui button > buttonVariants returns the shared base styles and defaults
12
+ (pass) registry-ui button > buttonVariants applies explicit variant and size overrides
13
+ (pass) registry-ui button > button exports keep stable display names
14
+ (pass) registry-ui button > Button renders loading state as a busy disabled native button [2.00ms]
15
+ (pass) registry-ui button > Button preserves explicit disabled state even when loading is false
16
+ (pass) registry-ui button > Button collapses into icon-only mode and hides secondary content
17
+ (pass) registry-ui button > AnimationIcon renders left and right placements around children [1.00ms]
7
18
 
8
19
  ::endgroup::
9
20
 
@@ -14,28 +25,17 @@ bun test v1.3.10 (30e609e0)
14
25
  ::endgroup::
15
26
 
16
27
  ::group::src/chart/__test__/chart.test.tsx:
17
- (pass) registry-ui chart > ChartContainer server render does not emit invalid size warnings [9.00ms]
28
+ (pass) registry-ui chart > ChartContainer server render does not emit invalid size warnings [12.00ms]
18
29
 
19
30
  ::endgroup::
20
31
 
21
- ::group::src/carousel/__test__/carousel.test.tsx:
22
- (pass) registry-ui carousel > CarouselPrevious prefers a provided icon over the default arrow icon [2.00ms]
23
- (pass) registry-ui carousel > CarouselNext still renders the default arrow icon when no icon is provided [1.00ms]
24
-
25
- ::endgroup::
26
-
27
- ::group::src/button/__test__/button.test.tsx:
28
- (pass) registry-ui button > buttonVariants returns the shared base styles and defaults
29
- (pass) registry-ui button > buttonVariants applies explicit variant and size overrides
30
- (pass) registry-ui button > button exports keep stable display names
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]
33
- (pass) registry-ui button > Button collapses into icon-only mode and hides secondary content
34
- (pass) registry-ui button > AnimationIcon renders left and right placements around children
32
+ ::group::src/pagination/__test__/pagination.test.tsx:
33
+ (pass) registry-ui pagination > PaginationWrapper prefers provided button icons over the default chevrons [2.00ms]
34
+ (pass) registry-ui pagination > PaginationWrapper still renders default chevrons when icons are not provided [2.00ms]
35
35
 
36
36
  ::endgroup::
37
37
 
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. [499.00ms]
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @gentleduck/registry-ui
2
2
 
3
+ ## 0.3.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [32d0136]
8
+ - @gentleduck/calendar@0.2.1
9
+
10
+ ## 0.3.1
11
+
12
+ ### Patch Changes
13
+
14
+ - Updated dependencies [58d1c61]
15
+ - @gentleduck/calendar@0.2.0
16
+
3
17
  ## 0.3.0
4
18
 
5
19
  ### 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/bunfig.toml ADDED
@@ -0,0 +1,2 @@
1
+ [test]
2
+ timeout = 15000
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",
@@ -11,10 +12,9 @@
11
12
  "@gentleduck/variants": "^0.1.20",
12
13
  "@gentleduck/vim": "^0.1.16",
13
14
  "embla-carousel-react": "8.6.0",
14
- "lucide-react": "0.577.0",
15
+ "lucide-react": "1.6.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",
@@ -29,7 +29,7 @@
29
29
  "@gentleduck/typescript-config": "workspace:*",
30
30
  "@types/react": "19.2.14",
31
31
  "@types/react-dom": "19.2.3",
32
- "typescript": "5.9.3"
32
+ "typescript": "6.0.2"
33
33
  },
34
34
  "exports": {
35
35
  "./*": "./src/*/index.ts"
@@ -56,5 +56,13 @@
56
56
  "test": "bun test"
57
57
  },
58
58
  "type": "module",
59
- "version": "0.3.0"
59
+ "version": "0.3.2",
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'