@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.
- package/.turbo/turbo-test.log +4 -4
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/SECURITY.md +19 -0
- package/package.json +10 -2
- package/src/calendar/calendar-day.tsx +131 -0
- package/src/calendar/calendar-header.tsx +291 -0
- package/src/calendar/calendar.tsx +195 -182
- package/src/calendar/calendar.types.ts +135 -0
- package/src/calendar/calendar.utils.ts +23 -0
- package/src/calendar/index.ts +2 -1
- package/src/sonner/sonner.chunks.tsx +0 -1
package/.turbo/turbo-test.log
CHANGED
|
@@ -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
|
|
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 [
|
|
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
|
|
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. [
|
|
41
|
+
Ran 14 tests across 5 files. [441.00ms]
|
package/CHANGELOG.md
CHANGED
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.
|
|
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 {
|
|
5
|
-
import {
|
|
5
|
+
import { useDirection } from '@gentleduck/primitives/direction'
|
|
6
|
+
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
|
|
6
7
|
import * as React from 'react'
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
showOutsideDays = true,
|
|
33
|
-
captionLayout = 'label',
|
|
19
|
+
adapter = defaultAdapter,
|
|
34
20
|
buttonVariant = 'ghost',
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
55
|
-
return new Intl.DateTimeFormat(localeTag, { month: 'long', year: 'numeric' })
|
|
56
|
-
}, [localeTag])
|
|
69
|
+
const { state, getDayProps, getGridProps, getNavProps, getHeaderProps, announcer } = calendar
|
|
57
70
|
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
+
onKeyDown={() => {
|
|
84
|
+
if (!keyboardActive) setKeyboardActive(true)
|
|
85
|
+
}}
|
|
86
|
+
onPointerDown={() => {
|
|
87
|
+
if (keyboardActive) setKeyboardActive(false)
|
|
88
|
+
}}
|
|
73
89
|
className={cn(
|
|
74
|
-
'group/calendar
|
|
75
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/calendar/index.ts
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export
|
|
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
|