@gentleduck/registry-ui 0.2.12 → 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 +5 -5
- package/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/SECURITY.md +19 -0
- package/package.json +10 -2
- package/src/accordion/accordion.tsx +1 -1
- package/src/alert/alert.tsx +1 -1
- package/src/alert-dialog/alert-dialog.tsx +7 -7
- package/src/avatar/avatar.tsx +1 -1
- package/src/breadcrumb/breadcrumb.tsx +2 -2
- package/src/button/button.tsx +1 -1
- 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/card/card.tsx +1 -1
- package/src/carousel/carousel.tsx +1 -1
- package/src/chart/chart.tsx +1 -1
- package/src/collapsible/collapsible.tsx +1 -1
- package/src/combobox/combobox.tsx +1 -0
- package/src/command/command.tsx +13 -8
- package/src/context-menu/context-menu.tsx +6 -6
- package/src/dialog/dialog-responsive.tsx +5 -5
- package/src/dialog/dialog.tsx +13 -11
- package/src/direction/direction.tsx +2 -1
- package/src/drawer/drawer.tsx +5 -5
- package/src/dropdown-menu/dropdown-menu.tsx +6 -6
- package/src/empty/empty.tsx +1 -1
- package/src/field/field.tsx +2 -2
- package/src/hover-card/hover-card.tsx +1 -1
- package/src/input-group/input-group.tsx +1 -1
- package/src/input-otp/input-otp.tsx +1 -1
- package/src/item/item.tsx +5 -5
- package/src/menubar/menubar.tsx +8 -8
- package/src/navigation-menu/navigation-menu.tsx +4 -4
- package/src/popover/popover.tsx +1 -1
- package/src/resizable/resizable.tsx +1 -1
- package/src/select/select.tsx +6 -6
- package/src/sheet/sheet.tsx +5 -5
- package/src/sonner/sonner.chunks.tsx +0 -1
- package/src/table/table.tsx +1 -1
- package/src/tabs/tabs.tsx +1 -1
- package/src/tooltip/tooltip.tsx +1 -1
package/.turbo/turbo-test.log
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
$ bun test
|
|
2
|
-
bun test v1.3.
|
|
2
|
+
bun test v1.3.10 (30e609e0)
|
|
3
3
|
|
|
4
4
|
::group::src/pagination/__test__/pagination.test.tsx:
|
|
5
|
-
(pass) registry-ui pagination > PaginationWrapper prefers provided button icons over the default chevrons [
|
|
6
|
-
(pass) registry-ui pagination > PaginationWrapper still renders default chevrons when icons are not provided [
|
|
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]
|
|
7
7
|
|
|
8
8
|
::endgroup::
|
|
9
9
|
|
|
@@ -30,7 +30,7 @@ bun test v1.3.5 (1e86cebd)
|
|
|
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
32
|
(pass) registry-ui button > Button preserves explicit disabled state even when loading is false
|
|
33
|
-
(pass) registry-ui button > Button collapses into icon-only mode and hides secondary content
|
|
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
|
|
|
36
36
|
::endgroup::
|
|
@@ -38,4 +38,4 @@ bun test v1.3.5 (1e86cebd)
|
|
|
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
|
@@ -1,5 +1,31 @@
|
|
|
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
|
+
|
|
10
|
+
## 0.3.0
|
|
11
|
+
|
|
12
|
+
### Minor Changes
|
|
13
|
+
|
|
14
|
+
- 3ee8b3a: feat: add AI documentation chat and command menu enhancements
|
|
15
|
+
|
|
16
|
+
@gentleduck/docs:
|
|
17
|
+
|
|
18
|
+
- Add useAIChat hook for streaming AI chat with rAF-batched updates
|
|
19
|
+
- Add AIChatPanel component with markdown rendering, shiki syntax highlighting, and dynamic props
|
|
20
|
+
- Add AI toggle mode to CommandMenu with auto-switch on empty search results
|
|
21
|
+
- Add react-markdown and shiki as optional peer dependencies
|
|
22
|
+
|
|
23
|
+
@gentleduck/registry-ui:
|
|
24
|
+
|
|
25
|
+
- Add hideClose prop to DialogContent to conditionally hide the close button
|
|
26
|
+
- Add children prop to CommandInput for rendering extra elements in the input wrapper
|
|
27
|
+
- Add contentClassName prop to CommandDialog for dynamic dialog sizing
|
|
28
|
+
|
|
3
29
|
## 0.2.12
|
|
4
30
|
|
|
5
31
|
### Patch 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.
|
|
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
|
}
|
|
@@ -244,4 +244,4 @@ const AccordionContent = React.forwardRef<HTMLDivElement, React.HTMLProps<HTMLDi
|
|
|
244
244
|
)
|
|
245
245
|
AccordionContent.displayName = 'AccordionContent'
|
|
246
246
|
|
|
247
|
-
export { Accordion, AccordionItem, AccordionTrigger
|
|
247
|
+
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }
|
package/src/alert/alert.tsx
CHANGED
|
@@ -101,14 +101,14 @@ AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
|
|
101
101
|
|
|
102
102
|
export {
|
|
103
103
|
AlertDialog,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
AlertDialogTrigger,
|
|
104
|
+
AlertDialogAction,
|
|
105
|
+
AlertDialogCancel,
|
|
107
106
|
AlertDialogContent,
|
|
108
|
-
|
|
107
|
+
AlertDialogDescription,
|
|
109
108
|
AlertDialogFooter,
|
|
109
|
+
AlertDialogHeader,
|
|
110
|
+
AlertDialogOverlay,
|
|
111
|
+
AlertDialogPortal,
|
|
110
112
|
AlertDialogTitle,
|
|
111
|
-
|
|
112
|
-
AlertDialogAction,
|
|
113
|
-
AlertDialogCancel,
|
|
113
|
+
AlertDialogTrigger,
|
|
114
114
|
}
|
package/src/avatar/avatar.tsx
CHANGED
|
@@ -110,10 +110,10 @@ BreadcrumbEllipsis.displayName = 'BreadcrumbEllipsis'
|
|
|
110
110
|
|
|
111
111
|
export {
|
|
112
112
|
Breadcrumb,
|
|
113
|
-
|
|
113
|
+
BreadcrumbEllipsis,
|
|
114
114
|
BreadcrumbItem,
|
|
115
115
|
BreadcrumbLink,
|
|
116
|
+
BreadcrumbList,
|
|
116
117
|
BreadcrumbPage,
|
|
117
118
|
BreadcrumbSeparator,
|
|
118
|
-
BreadcrumbEllipsis,
|
|
119
119
|
}
|
package/src/button/button.tsx
CHANGED
|
@@ -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'
|