@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.
- package/.turbo/turbo-test.log +19 -19
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/SECURITY.md +19 -0
- package/bunfig.toml +2 -0
- package/package.json +12 -4
- 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/tsconfig.json +1 -1
- package/src/_old/_table/index.ts +0 -14
- package/src/_old/_table/table-advanced.constants.tsx +0 -24
- package/src/_old/_table/table-advanced.tsx +0 -311
- package/src/_old/_table/table-advanced.types.ts +0 -272
- package/src/_old/_table/table.constants.ts +0 -2
- package/src/_old/_table/table.hook.tsx +0 -115
- package/src/_old/_table/table.lib.ts +0 -85
- package/src/_old/_table/table.tsx +0 -916
- package/src/_old/_table/table.types.ts +0 -118
- package/src/_old/_table/todo.md +0 -11
- package/src/_old/_upload/index.ts +0 -22
- package/src/_old/_upload/todo.md +0 -38
- package/src/_old/_upload/upload-advanced-chunks.tsx +0 -1624
- package/src/_old/_upload/upload-advanced.tsx +0 -507
- package/src/_old/_upload/upload-sonner.tsx +0 -58
- package/src/_old/_upload/upload.assets.tsx +0 -239
- package/src/_old/_upload/upload.constants.tsx +0 -75
- package/src/_old/_upload/upload.dto.ts +0 -19
- package/src/_old/_upload/upload.lib.tsx +0 -630
- package/src/_old/_upload/upload.tsx +0 -491
- package/src/_old/_upload/upload.types.ts +0 -436
package/.turbo/turbo-test.log
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
$ bun test
|
|
2
2
|
bun test v1.3.10 (30e609e0)
|
|
3
3
|
|
|
4
|
-
::group::src/
|
|
5
|
-
(pass) registry-ui
|
|
6
|
-
(pass) registry-ui
|
|
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 [
|
|
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/
|
|
22
|
-
(pass) registry-ui
|
|
23
|
-
(pass) registry-ui
|
|
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. [
|
|
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
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": "
|
|
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": "
|
|
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.
|
|
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'
|