@stoker-platform/web-app 0.5.184 → 0.5.185

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/CHANGELOG.md CHANGED
@@ -1,10 +1,16 @@
1
1
  # @stoker-platform/web-app
2
2
 
3
+ ## 0.5.185
4
+
5
+ ### Patch Changes
6
+
7
+ - feat: add temporary year picker
8
+
3
9
  ## 0.5.184
4
10
 
5
11
  ### Patch Changes
6
12
 
7
- - fix: perform bulk write in batches
13
+ - fix: perform bulk writes in batches
8
14
 
9
15
  ## 0.5.183
10
16
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stoker-platform/web-app",
3
- "version": "0.5.184",
3
+ "version": "0.5.185",
4
4
  "type": "module",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "scripts": {
@@ -1,17 +1,67 @@
1
1
  import * as React from "react"
2
- import { DayPicker } from "react-day-picker"
2
+ import { format } from "date-fns"
3
+ import { DayPicker, type CaptionLabelProps } from "react-day-picker"
3
4
 
4
5
  import { cn } from "@/lib/utils"
5
6
  import { buttonVariants } from "@/components/ui/button"
6
7
  import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons"
8
+ import YearPicker from "@/components/ui/year-picker"
7
9
 
8
10
  /* eslint-disable react/prop-types */
9
11
 
10
12
  export type CalendarProps = React.ComponentProps<typeof DayPicker>
11
13
 
12
- function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
14
+ function CalendarCaptionLabel({ displayMonth, id, onYearClick }: CaptionLabelProps & { onYearClick: () => void }) {
15
+ return (
16
+ <div className="text-sm font-medium" aria-live="polite" role="presentation" id={id}>
17
+ <button
18
+ type="button"
19
+ onClick={onYearClick}
20
+ className="rounded-sm p-2 hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
21
+ >
22
+ {format(displayMonth, "MMMM ")}
23
+ {format(displayMonth, "yyyy")}
24
+ </button>
25
+ </div>
26
+ )
27
+ }
28
+
29
+ function Calendar({
30
+ className,
31
+ classNames,
32
+ showOutsideDays = true,
33
+ month: monthProp,
34
+ onMonthChange: onMonthChangeProp,
35
+ defaultMonth,
36
+ fromYear,
37
+ toYear,
38
+ components,
39
+ ...props
40
+ }: CalendarProps) {
41
+ const [showYearPicker, setShowYearPicker] = React.useState(false)
42
+ const [internalMonth, setInternalMonth] = React.useState<Date>(defaultMonth ?? new Date())
43
+
44
+ const month = monthProp ?? internalMonth
45
+ const onMonthChange = onMonthChangeProp ?? setInternalMonth
46
+
47
+ if (showYearPicker) {
48
+ return (
49
+ <YearPicker
50
+ currentMonth={month}
51
+ onYearChange={(newMonth) => {
52
+ onMonthChange(newMonth)
53
+ setShowYearPicker(false)
54
+ }}
55
+ fromYear={fromYear}
56
+ toYear={toYear}
57
+ />
58
+ )
59
+ }
60
+
13
61
  return (
14
62
  <DayPicker
63
+ month={month}
64
+ onMonthChange={onMonthChange}
15
65
  showOutsideDays={showOutsideDays}
16
66
  className={cn("p-3", className)}
17
67
  classNames={{
@@ -52,7 +102,13 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
52
102
  components={{
53
103
  IconLeft: () => <ChevronLeftIcon className="h-4 w-4" />,
54
104
  IconRight: () => <ChevronRightIcon className="h-4 w-4" />,
105
+ ...components,
106
+ CaptionLabel: (captionProps) => (
107
+ <CalendarCaptionLabel {...captionProps} onYearClick={() => setShowYearPicker(true)} />
108
+ ),
55
109
  }}
110
+ fromYear={fromYear}
111
+ toYear={toYear}
56
112
  {...props}
57
113
  />
58
114
  )
@@ -0,0 +1,248 @@
1
+ import { format, getYear, isSameYear, setYear, startOfToday } from "date-fns"
2
+ import { ChevronLeft, ChevronRight } from "lucide-react"
3
+ import * as React from "react"
4
+
5
+ import { cn } from "@/lib/utils"
6
+ import { buttonVariants } from "./button"
7
+
8
+ const YEARS_PER_PAGE = 12
9
+ const GRID_COLUMNS = 3
10
+
11
+ interface YearPickerProps {
12
+ currentMonth: Date
13
+ onYearChange: (newMonth: Date) => void
14
+ disabled?: boolean
15
+ fromYear?: number
16
+ toYear?: number
17
+ }
18
+
19
+ export default function YearPicker({
20
+ currentMonth,
21
+ onYearChange,
22
+ disabled,
23
+ fromYear = 1900,
24
+ toYear = 2100,
25
+ }: YearPickerProps) {
26
+ const currentYear = getYear(currentMonth)
27
+ const [startYear, setStartYear] = React.useState(() => Math.floor(currentYear / YEARS_PER_PAGE) * YEARS_PER_PAGE)
28
+ const yearButtonRefs = React.useRef<(HTMLButtonElement | null)[]>([])
29
+
30
+ const years = Array.from({ length: YEARS_PER_PAGE }, (_, index) => startYear + index)
31
+
32
+ const isYearDisabled = React.useCallback(
33
+ (year: number) => disabled || year < fromYear || year > toYear,
34
+ [disabled, fromYear, toYear],
35
+ )
36
+
37
+ const getFirstFocusableIndex = React.useCallback(() => {
38
+ const selectedIndex = years.indexOf(currentYear)
39
+ // eslint-disable-next-line security/detect-object-injection
40
+ if (selectedIndex >= 0 && !isYearDisabled(years[selectedIndex])) {
41
+ return selectedIndex
42
+ }
43
+ return years.findIndex((year) => !isYearDisabled(year))
44
+ }, [years, currentYear, isYearDisabled])
45
+
46
+ const [focusedIndex, setFocusedIndex] = React.useState(0)
47
+
48
+ React.useEffect(() => {
49
+ setStartYear(Math.floor(getYear(currentMonth) / YEARS_PER_PAGE) * YEARS_PER_PAGE)
50
+ }, [currentMonth])
51
+
52
+ React.useEffect(() => {
53
+ setFocusedIndex(getFirstFocusableIndex())
54
+ }, [startYear, getFirstFocusableIndex])
55
+
56
+ React.useEffect(() => {
57
+ // eslint-disable-next-line security/detect-object-injection
58
+ yearButtonRefs.current[focusedIndex]?.focus()
59
+ }, [focusedIndex])
60
+
61
+ const today = startOfToday()
62
+
63
+ function previousPage() {
64
+ setStartYear((year) => Math.max(fromYear, year - YEARS_PER_PAGE))
65
+ }
66
+
67
+ function nextPage() {
68
+ setStartYear((year) => Math.min(toYear - YEARS_PER_PAGE + 1, year + YEARS_PER_PAGE))
69
+ }
70
+
71
+ function getNextFocusableIndex(index: number, step: number) {
72
+ let nextIndex = index + step
73
+ while (nextIndex >= 0 && nextIndex < years.length) {
74
+ // eslint-disable-next-line security/detect-object-injection
75
+ if (!isYearDisabled(years[nextIndex])) {
76
+ return nextIndex
77
+ }
78
+ nextIndex += step
79
+ }
80
+ return index
81
+ }
82
+
83
+ function handleYearKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, index: number, year: number) {
84
+ const yearDate = setYear(currentMonth, year)
85
+
86
+ switch (event.key) {
87
+ case "ArrowRight":
88
+ event.preventDefault()
89
+ setFocusedIndex(getNextFocusableIndex(index, 1))
90
+ break
91
+ case "ArrowLeft":
92
+ event.preventDefault()
93
+ setFocusedIndex(getNextFocusableIndex(index, -1))
94
+ break
95
+ case "ArrowDown": {
96
+ event.preventDefault()
97
+ const column = index % GRID_COLUMNS
98
+ let nextRow = Math.floor(index / GRID_COLUMNS) + 1
99
+ const totalRows = Math.ceil(years.length / GRID_COLUMNS)
100
+ while (nextRow < totalRows) {
101
+ const nextIndex = nextRow * GRID_COLUMNS + column
102
+ // eslint-disable-next-line security/detect-object-injection
103
+ if (nextIndex < years.length && !isYearDisabled(years[nextIndex])) {
104
+ setFocusedIndex(nextIndex)
105
+ return
106
+ }
107
+ nextRow++
108
+ }
109
+ break
110
+ }
111
+ case "ArrowUp": {
112
+ event.preventDefault()
113
+ const column = index % GRID_COLUMNS
114
+ let nextIndex = index - GRID_COLUMNS
115
+ while (nextIndex >= 0) {
116
+ const alignedIndex = Math.floor(nextIndex / GRID_COLUMNS) * GRID_COLUMNS + column
117
+ // eslint-disable-next-line security/detect-object-injection
118
+ if (!isYearDisabled(years[alignedIndex])) {
119
+ setFocusedIndex(alignedIndex)
120
+ return
121
+ }
122
+ nextIndex -= GRID_COLUMNS
123
+ }
124
+ break
125
+ }
126
+ case "Home":
127
+ event.preventDefault()
128
+ setFocusedIndex(getFirstFocusableIndex())
129
+ break
130
+ case "End": {
131
+ event.preventDefault()
132
+ for (let endIndex = years.length - 1; endIndex >= 0; endIndex--) {
133
+ // eslint-disable-next-line security/detect-object-injection
134
+ if (!isYearDisabled(years[endIndex])) {
135
+ setFocusedIndex(endIndex)
136
+ break
137
+ }
138
+ }
139
+ break
140
+ }
141
+ case "PageUp":
142
+ event.preventDefault()
143
+ previousPage()
144
+ break
145
+ case "PageDown":
146
+ event.preventDefault()
147
+ nextPage()
148
+ break
149
+ case "Enter":
150
+ case " ":
151
+ event.preventDefault()
152
+ if (!isYearDisabled(year)) {
153
+ onYearChange(yearDate)
154
+ }
155
+ break
156
+ }
157
+ }
158
+
159
+ return (
160
+ <div className="w-fit p-3">
161
+ <div className="flex w-fit flex-col space-y-4 sm:flex-row sm:space-x-4 sm:space-y-0">
162
+ <div className="space-y-4">
163
+ <div className="relative flex w-56 items-center justify-center pt-1">
164
+ <div className="text-sm font-medium" aria-live="polite" role="presentation" id="year-picker">
165
+ {startYear} – {startYear + YEARS_PER_PAGE - 1}
166
+ </div>
167
+ <div className="flex items-center space-x-1">
168
+ <button
169
+ name="previous-years"
170
+ aria-label="Go to previous years"
171
+ className={cn(
172
+ buttonVariants({ variant: "outline" }),
173
+ "absolute left-1 h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
174
+ )}
175
+ type="button"
176
+ onClick={previousPage}
177
+ disabled={disabled || startYear <= fromYear}
178
+ >
179
+ <ChevronLeft className="h-4 w-4" />
180
+ </button>
181
+ <button
182
+ name="next-years"
183
+ aria-label="Go to next years"
184
+ className={cn(
185
+ buttonVariants({ variant: "outline" }),
186
+ "absolute right-1 h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
187
+ )}
188
+ type="button"
189
+ onClick={nextPage}
190
+ disabled={disabled || startYear + YEARS_PER_PAGE - 1 >= toYear}
191
+ >
192
+ <ChevronRight className="h-4 w-4" />
193
+ </button>
194
+ </div>
195
+ </div>
196
+ <div className="grid w-56 grid-cols-3 gap-2" role="grid" aria-labelledby="year-picker">
197
+ {years.map((year, index) => {
198
+ const yearDate = setYear(currentMonth, year)
199
+ const isSelected = isSameYear(yearDate, currentMonth)
200
+ const isCurrentYear = isSameYear(yearDate, today)
201
+ const isDisabled = isYearDisabled(year)
202
+
203
+ return (
204
+ <div
205
+ key={year}
206
+ className="relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent rounded-md"
207
+ role="presentation"
208
+ >
209
+ <button
210
+ ref={(element) => {
211
+ // eslint-disable-next-line security/detect-object-injection
212
+ yearButtonRefs.current[index] = element
213
+ }}
214
+ name="year"
215
+ className={cn(
216
+ buttonVariants({ variant: "ghost" }),
217
+ "inline-flex h-9 w-full items-center justify-center p-0 text-sm font-normal aria-selected:opacity-100",
218
+ isSelected &&
219
+ "bg-blue-500 text-white hover:bg-blue-600 hover:text-white focus:bg-blue-600 focus:text-white",
220
+ !isSelected && isCurrentYear && "bg-accent text-accent-foreground",
221
+ )}
222
+ disabled={isDisabled}
223
+ role="gridcell"
224
+ tabIndex={index === focusedIndex ? 0 : -1}
225
+ type="button"
226
+ aria-selected={isSelected}
227
+ aria-label={
228
+ isSelected
229
+ ? `${year}, selected`
230
+ : isCurrentYear
231
+ ? `${year}, current year`
232
+ : String(year)
233
+ }
234
+ onClick={() => onYearChange(yearDate)}
235
+ onFocus={() => setFocusedIndex(index)}
236
+ onKeyDown={(event) => handleYearKeyDown(event, index, year)}
237
+ >
238
+ <time dateTime={format(yearDate, "yyyy-MM-dd")}>{year}</time>
239
+ </button>
240
+ </div>
241
+ )
242
+ })}
243
+ </div>
244
+ </div>
245
+ </div>
246
+ </div>
247
+ )
248
+ }