@thangnqdev/ui 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -206
  3. package/dist/bento/index.cjs.map +1 -1
  4. package/dist/bento/index.js +2 -2
  5. package/dist/bento/styles.css +2 -46
  6. package/dist/buttons/index.cjs +111 -18
  7. package/dist/buttons/index.cjs.map +1 -1
  8. package/dist/buttons/index.d.cts +1 -1
  9. package/dist/buttons/index.d.ts +1 -1
  10. package/dist/buttons/index.js +3 -3
  11. package/dist/buttons/styles.css +2 -6
  12. package/dist/{chunk-4S6L5V7X.js → chunk-2YJBW4E2.js} +2 -2
  13. package/dist/chunk-2YJBW4E2.js.map +1 -0
  14. package/dist/{chunk-F5Q7BTMH.js → chunk-4CRFNALF.js} +1011 -556
  15. package/dist/chunk-4CRFNALF.js.map +1 -0
  16. package/dist/{chunk-LWFRUJRU.js → chunk-4MS6ZHFQ.js} +225 -41
  17. package/dist/chunk-4MS6ZHFQ.js.map +1 -0
  18. package/dist/{chunk-2OXZK6IP.js → chunk-77KTYOMQ.js} +3 -3
  19. package/dist/chunk-77KTYOMQ.js.map +1 -0
  20. package/dist/{chunk-IABPUCAQ.js → chunk-ADO3SVUA.js} +2 -2
  21. package/dist/chunk-ADO3SVUA.js.map +1 -0
  22. package/dist/{chunk-GXI2H5XA.js → chunk-D7FB3TCA.js} +3 -3
  23. package/dist/chunk-D7FB3TCA.js.map +1 -0
  24. package/dist/chunk-DSEL7ELZ.js +314 -0
  25. package/dist/chunk-DSEL7ELZ.js.map +1 -0
  26. package/dist/{chunk-KJRBRTQE.js → chunk-EXFR2BFJ.js} +37 -28
  27. package/dist/chunk-EXFR2BFJ.js.map +1 -0
  28. package/dist/{chunk-PF4ZHGVW.js → chunk-GXWB6BSY.js} +116 -23
  29. package/dist/chunk-GXWB6BSY.js.map +1 -0
  30. package/dist/{chunk-66VWUJEI.js → chunk-I2FQBMVW.js} +3 -3
  31. package/dist/chunk-I2FQBMVW.js.map +1 -0
  32. package/dist/{chunk-EU2LPUYP.js → chunk-I5O2WCGF.js} +3 -3
  33. package/dist/chunk-I5O2WCGF.js.map +1 -0
  34. package/dist/{chunk-ATYGDTMK.js → chunk-ME4XILG5.js} +5 -5
  35. package/dist/chunk-ME4XILG5.js.map +1 -0
  36. package/dist/{chunk-5FZ6ZFII.js → chunk-PC5SZFZC.js} +373 -150
  37. package/dist/chunk-PC5SZFZC.js.map +1 -0
  38. package/dist/{chunk-C4SUXMI3.js → chunk-S6XJMYME.js} +3 -3
  39. package/dist/chunk-S6XJMYME.js.map +1 -0
  40. package/dist/{chunk-A7P3O5Z5.js → chunk-V52JOSDF.js} +3 -3
  41. package/dist/chunk-V52JOSDF.js.map +1 -0
  42. package/dist/{chunk-NFVONJUK.js → chunk-W5WNWTQC.js} +455 -211
  43. package/dist/chunk-W5WNWTQC.js.map +1 -0
  44. package/dist/{chunk-WAFHC4DQ.js → chunk-YJ64RI7J.js} +3 -3
  45. package/dist/chunk-YJ64RI7J.js.map +1 -0
  46. package/dist/chunk-YOCGBORV.js +155 -0
  47. package/dist/chunk-YOCGBORV.js.map +1 -0
  48. package/dist/chunk-ZEDXARXW.js +96 -0
  49. package/dist/chunk-ZEDXARXW.js.map +1 -0
  50. package/dist/components.css +2 -0
  51. package/dist/core/index.cjs +33 -24
  52. package/dist/core/index.cjs.map +1 -1
  53. package/dist/core/index.js +2 -2
  54. package/dist/core/styles.css +2 -134
  55. package/dist/data-display/index.cjs +770 -247
  56. package/dist/data-display/index.cjs.map +1 -1
  57. package/dist/data-display/index.d.cts +1 -1
  58. package/dist/data-display/index.d.ts +1 -1
  59. package/dist/data-display/index.js +5 -4
  60. package/dist/data-display/styles.css +2 -4
  61. package/dist/feedback/index.cjs.map +1 -1
  62. package/dist/feedback/index.js +2 -2
  63. package/dist/feedback/styles.css +2 -4
  64. package/dist/forms/index.cjs +1211 -459
  65. package/dist/forms/index.cjs.map +1 -1
  66. package/dist/forms/index.d.cts +1 -1
  67. package/dist/forms/index.d.ts +1 -1
  68. package/dist/forms/index.js +8 -8
  69. package/dist/forms/styles.css +2 -6
  70. package/dist/glass/index.cjs.map +1 -1
  71. package/dist/glass/index.js +2 -2
  72. package/dist/glass/styles.css +2 -81
  73. package/dist/{index-D6fE7w-b.d.cts → index-BGt_hecO.d.cts} +1 -0
  74. package/dist/{index-D6fE7w-b.d.ts → index-BGt_hecO.d.ts} +1 -0
  75. package/dist/{index-DzcWh1Hs.d.cts → index-BtDvpZCf.d.cts} +7 -0
  76. package/dist/{index-DzcWh1Hs.d.ts → index-BtDvpZCf.d.ts} +7 -0
  77. package/dist/{index-Cle6QzQl.d.ts → index-COX1sBUN.d.ts} +27 -3
  78. package/dist/{index-BkRqGNG1.d.ts → index-Cg8MuP7u.d.cts} +40 -13
  79. package/dist/{index-BkRqGNG1.d.cts → index-Cg8MuP7u.d.ts} +40 -13
  80. package/dist/{index-CenuF_P2.d.ts → index-DHkPRTvE.d.cts} +39 -8
  81. package/dist/{index-CenuF_P2.d.cts → index-DHkPRTvE.d.ts} +39 -8
  82. package/dist/{index-TyK-Ldpy.d.cts → index-Du_oht9k.d.cts} +27 -3
  83. package/dist/index.cjs +2481 -917
  84. package/dist/index.cjs.map +1 -1
  85. package/dist/index.d.cts +5 -5
  86. package/dist/index.d.ts +5 -5
  87. package/dist/index.js +19 -19
  88. package/dist/layout/index.cjs.map +1 -1
  89. package/dist/layout/index.js +2 -2
  90. package/dist/layout/styles.css +2 -8
  91. package/dist/navigation/index.cjs +288 -48
  92. package/dist/navigation/index.cjs.map +1 -1
  93. package/dist/navigation/index.d.cts +1 -1
  94. package/dist/navigation/index.d.ts +1 -1
  95. package/dist/navigation/index.js +6 -6
  96. package/dist/navigation/styles.css +2 -4
  97. package/dist/overlays/index.cjs +566 -229
  98. package/dist/overlays/index.cjs.map +1 -1
  99. package/dist/overlays/index.d.cts +1 -1
  100. package/dist/overlays/index.d.ts +1 -1
  101. package/dist/overlays/index.js +7 -7
  102. package/dist/overlays/styles.css +2 -8
  103. package/dist/reset.css +45 -0
  104. package/dist/styles.css +1 -1
  105. package/dist/tokens.css +11 -0
  106. package/dist/typography/index.cjs.map +1 -1
  107. package/dist/typography/index.js +4 -4
  108. package/dist/typography/styles.css +2 -5
  109. package/llms.txt +211 -113
  110. package/package.json +160 -116
  111. package/src/bento/BentoCard.tsx +140 -140
  112. package/src/bento/BentoGrid.tsx +50 -50
  113. package/src/bento/index.ts +2 -2
  114. package/src/bento/tokens.css +46 -46
  115. package/src/buttons/Button.tsx +104 -104
  116. package/src/buttons/ButtonGroup.tsx +43 -43
  117. package/src/buttons/IconButton.tsx +79 -79
  118. package/src/buttons/SplitButton.tsx +186 -86
  119. package/src/buttons/ToggleButton.tsx +157 -157
  120. package/src/buttons/index.ts +5 -5
  121. package/src/buttons/tokens.css +6 -6
  122. package/src/core/ThemeProvider.tsx +135 -123
  123. package/src/core/index.ts +2 -2
  124. package/src/core/tokens.css +134 -134
  125. package/src/data-display/Accordion.tsx +147 -147
  126. package/src/data-display/Avatar.tsx +134 -134
  127. package/src/data-display/Card.tsx +112 -112
  128. package/src/data-display/Chip.tsx +74 -74
  129. package/src/data-display/DataTablePro.tsx +299 -121
  130. package/src/data-display/StatCard.tsx +60 -60
  131. package/src/data-display/Table.tsx +74 -74
  132. package/src/data-display/Timeline.tsx +62 -62
  133. package/src/data-display/TreeView.tsx +243 -104
  134. package/src/data-display/tokens.css +4 -4
  135. package/src/feedback/Alert.tsx +80 -80
  136. package/src/feedback/EmptyState.tsx +45 -45
  137. package/src/feedback/Skeleton.tsx +66 -66
  138. package/src/feedback/Spinner.tsx +65 -65
  139. package/src/feedback/index.ts +5 -5
  140. package/src/feedback/tokens.css +4 -4
  141. package/src/forms/Checkbox.tsx +149 -69
  142. package/src/forms/CheckboxGroup.tsx +76 -29
  143. package/src/forms/Combobox.tsx +172 -28
  144. package/src/forms/DatePicker.tsx +385 -81
  145. package/src/forms/FileUpload.tsx +51 -7
  146. package/src/forms/FormField.tsx +115 -115
  147. package/src/forms/Input.tsx +151 -151
  148. package/src/forms/NumberInput.tsx +34 -20
  149. package/src/forms/PasswordInput.tsx +35 -35
  150. package/src/forms/PinInput.tsx +139 -125
  151. package/src/forms/Radio.tsx +142 -142
  152. package/src/forms/RangeSlider.tsx +237 -111
  153. package/src/forms/SearchInput.tsx +34 -34
  154. package/src/forms/Select.tsx +103 -103
  155. package/src/forms/Slider.tsx +85 -85
  156. package/src/forms/Textarea.tsx +114 -114
  157. package/src/forms/tokens.css +6 -6
  158. package/src/glass/GlassButton.tsx +59 -59
  159. package/src/glass/GlassCard.tsx +71 -71
  160. package/src/glass/index.ts +3 -3
  161. package/src/glass/tokens.css +81 -81
  162. package/src/index.ts +26 -26
  163. package/src/layout/AspectRatio.tsx +39 -39
  164. package/src/layout/Container.tsx +53 -53
  165. package/src/layout/Divider.tsx +61 -61
  166. package/src/layout/Flex.tsx +79 -79
  167. package/src/layout/Grid.tsx +124 -124
  168. package/src/layout/PageLayout.tsx +59 -59
  169. package/src/layout/Section.tsx +47 -47
  170. package/src/layout/Spacer.tsx +30 -30
  171. package/src/layout/Stack.tsx +88 -88
  172. package/src/layout/tokens.css +8 -8
  173. package/src/navigation/Breadcrumb.tsx +73 -73
  174. package/src/navigation/CommandPalette.tsx +142 -142
  175. package/src/navigation/DropdownMenu.tsx +244 -160
  176. package/src/navigation/Pagination.tsx +114 -114
  177. package/src/navigation/Stepper.tsx +93 -93
  178. package/src/navigation/Tabs.tsx +265 -133
  179. package/src/navigation/index.ts +6 -6
  180. package/src/navigation/tokens.css +4 -4
  181. package/src/overlays/AlertDialog.tsx +63 -54
  182. package/src/overlays/BottomSheet.tsx +30 -10
  183. package/src/overlays/ContextMenu.tsx +51 -9
  184. package/src/overlays/Drawer.tsx +160 -145
  185. package/src/overlays/FocusTrap.tsx +122 -49
  186. package/src/overlays/Modal.tsx +296 -219
  187. package/src/overlays/Popover.tsx +55 -25
  188. package/src/overlays/Portal.tsx +23 -23
  189. package/src/overlays/Toast.tsx +175 -137
  190. package/src/overlays/Tooltip.tsx +125 -100
  191. package/src/overlays/tokens.css +8 -8
  192. package/src/registry/components.ts +1368 -0
  193. package/src/styles/components.css +39 -0
  194. package/src/styles/index.css +2 -93
  195. package/src/styles/reset.css +45 -0
  196. package/src/styles/tokens.css +11 -2
  197. package/src/typography/Blockquote.tsx +32 -32
  198. package/src/typography/Code.tsx +89 -89
  199. package/src/typography/Heading.tsx +56 -56
  200. package/src/typography/Kbd.tsx +25 -25
  201. package/src/typography/Label.tsx +47 -47
  202. package/src/typography/Link.tsx +56 -56
  203. package/src/typography/Text.tsx +62 -62
  204. package/src/typography/index.ts +7 -7
  205. package/src/typography/tokens.css +5 -5
  206. package/src/utils/cn.ts +6 -6
  207. package/src/utils/scroll-lock.ts +19 -0
  208. package/src/utils/useControllableState.ts +54 -0
  209. package/ui-contract.json +192 -35
  210. package/dist/chunk-2OXZK6IP.js.map +0 -1
  211. package/dist/chunk-4S6L5V7X.js.map +0 -1
  212. package/dist/chunk-5FZ6ZFII.js.map +0 -1
  213. package/dist/chunk-66VWUJEI.js.map +0 -1
  214. package/dist/chunk-A7P3O5Z5.js.map +0 -1
  215. package/dist/chunk-ATYGDTMK.js.map +0 -1
  216. package/dist/chunk-C4SUXMI3.js.map +0 -1
  217. package/dist/chunk-EU2LPUYP.js.map +0 -1
  218. package/dist/chunk-F5Q7BTMH.js.map +0 -1
  219. package/dist/chunk-GXI2H5XA.js.map +0 -1
  220. package/dist/chunk-H3LCNPMC.js +0 -101
  221. package/dist/chunk-H3LCNPMC.js.map +0 -1
  222. package/dist/chunk-IABPUCAQ.js.map +0 -1
  223. package/dist/chunk-KJRBRTQE.js.map +0 -1
  224. package/dist/chunk-LBUBXUYQ.js +0 -40
  225. package/dist/chunk-LBUBXUYQ.js.map +0 -1
  226. package/dist/chunk-LWFRUJRU.js.map +0 -1
  227. package/dist/chunk-NFVONJUK.js.map +0 -1
  228. package/dist/chunk-PF4ZHGVW.js.map +0 -1
  229. package/dist/chunk-WAFHC4DQ.js.map +0 -1
  230. package/dist/chunk-X76KBH4Y.js +0 -71
  231. package/dist/chunk-X76KBH4Y.js.map +0 -1
@@ -1,8 +1,8 @@
1
1
  import React, { forwardRef, useState, useRef, useEffect } from 'react';
2
2
  import { Calendar as CalendarIcon, Clock, ChevronLeft, ChevronRight, X } from 'lucide-react';
3
3
  import { cn } from '../utils/cn';
4
- import { Button } from '../buttons/Button';
5
4
  import { Popover } from '../overlays/Popover';
5
+ import { useControllableState } from '../utils/useControllableState';
6
6
 
7
7
  // Utility helper to format date as YYYY-MM-DD
8
8
  export function formatDate(d: Date | null | undefined): string {
@@ -35,7 +35,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
35
35
  (
36
36
  {
37
37
  value: controlledValue,
38
- defaultValue,
38
+ defaultValue = null,
39
39
  onChange,
40
40
  placeholder = 'Select date...',
41
41
  minDate,
@@ -47,30 +47,32 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
47
47
  },
48
48
  ref
49
49
  ) => {
50
- const [internalDate, setInternalDate] = useState<Date | null>(
51
- defaultValue !== undefined ? defaultValue : null
52
- );
53
- const selectedDate = controlledValue !== undefined ? controlledValue : internalDate;
50
+ const [selectedDate, setSelectedDate] = useControllableState<Date | null>({
51
+ value: controlledValue,
52
+ defaultValue,
53
+ onChange,
54
+ name: 'DatePicker',
55
+ });
54
56
 
55
57
  const [viewDate, setViewDate] = useState<Date>(selectedDate || new Date());
56
58
  const [isOpen, setIsOpen] = useState(false);
59
+ const [focusedDate, setFocusedDate] = useState<Date>(selectedDate || new Date());
57
60
 
58
61
  useEffect(() => {
59
62
  if (selectedDate) {
60
63
  setViewDate(selectedDate);
64
+ setFocusedDate(selectedDate);
61
65
  }
62
66
  }, [selectedDate]);
63
67
 
64
68
  const handleSelectDate = (d: Date) => {
65
- setInternalDate(d);
66
- onChange?.(d);
69
+ setSelectedDate(d);
67
70
  setIsOpen(false);
68
71
  };
69
72
 
70
73
  const handleClear = (e: React.MouseEvent) => {
71
74
  e.stopPropagation();
72
- setInternalDate(null);
73
- onChange?.(null);
75
+ setSelectedDate(null);
74
76
  };
75
77
 
76
78
  const prevMonth = () => {
@@ -115,22 +117,74 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
115
117
 
116
118
  const todayStr = formatDate(new Date());
117
119
  const selectedStr = selectedDate ? formatDate(selectedDate) : '';
120
+ const focusedStr = formatDate(focusedDate);
121
+
122
+ // Keyboard navigation within the calendar grid
123
+ const handleGridKeyDown = (e: React.KeyboardEvent) => {
124
+ let nextDate = new Date(focusedDate);
125
+ let handled = false;
126
+
127
+ if (e.key === 'ArrowLeft') {
128
+ nextDate.setDate(nextDate.getDate() - 1);
129
+ handled = true;
130
+ } else if (e.key === 'ArrowRight') {
131
+ nextDate.setDate(nextDate.getDate() + 1);
132
+ handled = true;
133
+ } else if (e.key === 'ArrowUp') {
134
+ nextDate.setDate(nextDate.getDate() - 7);
135
+ handled = true;
136
+ } else if (e.key === 'ArrowDown') {
137
+ nextDate.setDate(nextDate.getDate() + 7);
138
+ handled = true;
139
+ } else if (e.key === 'PageUp') {
140
+ nextDate.setMonth(nextDate.getMonth() - 1);
141
+ handled = true;
142
+ } else if (e.key === 'PageDown') {
143
+ nextDate.setMonth(nextDate.getMonth() + 1);
144
+ handled = true;
145
+ } else if (e.key === 'Home') {
146
+ nextDate.setDate(1);
147
+ handled = true;
148
+ } else if (e.key === 'End') {
149
+ const lastDay = new Date(nextDate.getFullYear(), nextDate.getMonth() + 1, 0).getDate();
150
+ nextDate.setDate(lastDay);
151
+ handled = true;
152
+ } else if (e.key === 'Enter' || e.key === ' ') {
153
+ e.preventDefault();
154
+ handleSelectDate(focusedDate);
155
+ return;
156
+ } else if (e.key === 'Escape') {
157
+ e.preventDefault();
158
+ setIsOpen(false);
159
+ return;
160
+ }
161
+
162
+ if (handled) {
163
+ e.preventDefault();
164
+ setFocusedDate(nextDate);
165
+ if (nextDate.getMonth() !== viewDate.getMonth() || nextDate.getFullYear() !== viewDate.getFullYear()) {
166
+ setViewDate(new Date(nextDate.getFullYear(), nextDate.getMonth(), 1));
167
+ }
168
+ }
169
+ };
118
170
 
119
171
  return (
120
172
  <div ref={ref} className={cn('relative inline-block w-full', className)}>
121
173
  <Popover
122
- isOpen={isOpen}
123
- onClose={() => setIsOpen(false)}
174
+ open={isOpen}
175
+ onOpenChange={setIsOpen}
124
176
  trigger={
125
177
  <button
126
178
  type="button"
127
179
  disabled={disabled}
128
- onClick={() => !disabled && setIsOpen(!isOpen)}
180
+ aria-haspopup="dialog"
181
+ aria-expanded={isOpen}
182
+ aria-label={selectedDate ? `Selected date: ${formatDisplayDate(selectedDate)}` : placeholder}
129
183
  className={cn(
130
- 'w-full h-10 px-3.5 rounded-[var(--ui-radius-md)] border bg-[var(--ui-bg-surface)] text-left flex items-center justify-between text-sm transition-all shadow-inner cursor-pointer',
184
+ 'w-full h-10 px-3.5 rounded-[var(--ui-radius-md)] border bg-[var(--ui-bg-surface)] text-left flex items-center justify-between text-sm transition-all shadow-inner cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)]',
131
185
  disabled
132
186
  ? 'opacity-50 cursor-not-allowed border-[var(--ui-border-subtle)]'
133
- : 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)] focus:ring-2 focus:ring-[var(--ui-primary)]/50'
187
+ : 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)]'
134
188
  )}
135
189
  >
136
190
  <div className="flex items-center gap-2.5 truncate">
@@ -143,7 +197,11 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
143
197
  <div className="flex items-center gap-1 text-[var(--ui-text-muted)]">
144
198
  {clearable && selectedDate && !disabled && (
145
199
  <span
200
+ role="button"
201
+ tabIndex={0}
202
+ aria-label="Clear date"
146
203
  onClick={handleClear}
204
+ onKeyDown={(e) => e.key === 'Enter' && handleClear(e as unknown as React.MouseEvent)}
147
205
  className="p-1 rounded-full hover:bg-[var(--ui-bg-tertiary)] hover:text-[var(--ui-text-primary)] transition-colors"
148
206
  >
149
207
  <X className="w-3.5 h-3.5" />
@@ -153,24 +211,35 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
153
211
  </button>
154
212
  }
155
213
  >
156
- <div className="p-4 w-72 rounded-2xl bg-[var(--ui-bg-surface)] backdrop-blur-2xl border border-[var(--ui-border-default)] shadow-2xl space-y-3">
214
+ <div
215
+ role="dialog"
216
+ aria-label="Choose date"
217
+ className="p-4 w-72 rounded-2xl bg-[var(--ui-bg-surface)] backdrop-blur-2xl border border-[var(--ui-border-default)] shadow-2xl space-y-3"
218
+ onKeyDown={handleGridKeyDown}
219
+ >
157
220
  {/* Header: Month Year + Prev/Next */}
158
221
  <div className="flex items-center justify-between">
159
- <span className="text-xs font-bold text-[var(--ui-text-primary)] font-mono uppercase tracking-wider">
222
+ <span
223
+ id="calendar-month-heading"
224
+ className="text-xs font-bold text-[var(--ui-text-primary)] font-mono uppercase tracking-wider"
225
+ aria-live="polite"
226
+ >
160
227
  {viewDate.toLocaleString('default', { month: 'long', year: 'numeric' })}
161
228
  </span>
162
229
  <div className="flex items-center gap-1">
163
230
  <button
164
231
  type="button"
165
232
  onClick={prevMonth}
166
- className="p-1 rounded-lg hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)] transition-colors"
233
+ aria-label="Previous month"
234
+ className="p-1 rounded-lg hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)] transition-colors cursor-pointer"
167
235
  >
168
236
  <ChevronLeft className="w-4 h-4" />
169
237
  </button>
170
238
  <button
171
239
  type="button"
172
240
  onClick={nextMonth}
173
- className="p-1 rounded-lg hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)] transition-colors"
241
+ aria-label="Next month"
242
+ className="p-1 rounded-lg hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-muted)] hover:text-[var(--ui-text-primary)] transition-colors cursor-pointer"
174
243
  >
175
244
  <ChevronRight className="w-4 h-4" />
176
245
  </button>
@@ -185,7 +254,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
185
254
  key={idx}
186
255
  type="button"
187
256
  onClick={() => handleSelectDate(preset.date)}
188
- className="px-2 py-0.5 rounded-md text-[10px] font-semibold bg-[var(--ui-bg-secondary)] hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-secondary)] transition-colors"
257
+ className="px-2 py-0.5 rounded-md text-[10px] font-semibold bg-[var(--ui-bg-secondary)] hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-secondary)] transition-colors cursor-pointer"
189
258
  >
190
259
  {preset.label}
191
260
  </button>
@@ -194,31 +263,60 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
194
263
  )}
195
264
 
196
265
  {/* Weekday headers */}
197
- <div className="grid grid-cols-7 text-center text-[10px] font-bold text-[var(--ui-text-muted)] font-mono">
198
- {['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d) => (
199
- <div key={d} className="py-1">
200
- {d}
266
+ <div
267
+ role="row"
268
+ className="grid grid-cols-7 text-center text-[10px] font-bold text-[var(--ui-text-muted)] font-mono"
269
+ >
270
+ {[
271
+ { short: 'Su', full: 'Sunday' },
272
+ { short: 'Mo', full: 'Monday' },
273
+ { short: 'Tu', full: 'Tuesday' },
274
+ { short: 'We', full: 'Wednesday' },
275
+ { short: 'Th', full: 'Thursday' },
276
+ { short: 'Fr', full: 'Friday' },
277
+ { short: 'Sa', full: 'Saturday' },
278
+ ].map((d) => (
279
+ <div key={d.short} role="columnheader" aria-label={d.full} className="py-1">
280
+ {d.short}
201
281
  </div>
202
282
  ))}
203
283
  </div>
204
284
 
205
285
  {/* Day grid */}
206
- <div className="grid grid-cols-7 gap-1">
286
+ <div
287
+ role="grid"
288
+ aria-labelledby="calendar-month-heading"
289
+ className="grid grid-cols-7 gap-1"
290
+ >
207
291
  {days.map((item, idx) => {
208
292
  const itemStr = formatDate(item.date);
209
293
  const isSelected = itemStr === selectedStr;
210
294
  const isToday = itemStr === todayStr;
295
+ const isFocused = itemStr === focusedStr;
296
+
297
+ const fullDateLabel = item.date.toLocaleDateString('en-US', {
298
+ weekday: 'long',
299
+ year: 'numeric',
300
+ month: 'long',
301
+ day: 'numeric',
302
+ });
211
303
 
212
304
  return (
213
305
  <button
214
306
  key={idx}
215
307
  type="button"
308
+ role="gridcell"
216
309
  disabled={item.disabled}
310
+ aria-selected={isSelected}
311
+ aria-current={isToday ? 'date' : undefined}
312
+ aria-label={fullDateLabel}
313
+ tabIndex={isFocused ? 0 : -1}
217
314
  onClick={() => handleSelectDate(item.date)}
218
315
  className={cn(
219
- 'h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center transition-all cursor-pointer',
316
+ 'h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center transition-all cursor-pointer outline-none',
220
317
  item.disabled && 'opacity-25 cursor-not-allowed pointer-events-none',
221
318
  !item.currentMonth && 'text-[var(--ui-text-muted)] opacity-50',
319
+ isFocused && 'ring-2 ring-[var(--ui-primary)]',
222
320
  isSelected
223
321
  ? 'bg-[var(--ui-primary)] text-white font-bold shadow-md shadow-blue-500/30'
224
322
  : isToday
@@ -257,11 +355,14 @@ export interface DateRangePickerProps {
257
355
  }
258
356
 
259
357
  export const DateRangePicker = forwardRef<HTMLDivElement, DateRangePickerProps>(
260
- ({ value: controlledVal, defaultValue, onChange, placeholder = 'Select date range...', className, disabled = false }, ref) => {
261
- const [internalRange, setInternalRange] = useState<DateRange>(
262
- defaultValue || { from: null, to: null }
263
- );
264
- const range = controlledVal || internalRange;
358
+ ({ value: controlledVal, defaultValue = { from: null, to: null }, onChange, placeholder = 'Select date range...', className, disabled = false }, ref) => {
359
+ const [range, setRange] = useControllableState<DateRange>({
360
+ value: controlledVal,
361
+ defaultValue,
362
+ onChange,
363
+ name: 'DateRangePicker',
364
+ });
365
+
265
366
  const [viewDate, setViewDate] = useState<Date>(range.from || new Date());
266
367
  const [isOpen, setIsOpen] = useState(false);
267
368
 
@@ -277,8 +378,7 @@ export const DateRangePicker = forwardRef<HTMLDivElement, DateRangePickerProps>(
277
378
  setIsOpen(false);
278
379
  }
279
380
  }
280
- setInternalRange(next);
281
- onChange?.(next);
381
+ setRange(next);
282
382
  };
283
383
 
284
384
  const displayText =
@@ -302,14 +402,16 @@ export const DateRangePicker = forwardRef<HTMLDivElement, DateRangePickerProps>(
302
402
  return (
303
403
  <div ref={ref} className={cn('relative inline-block w-full', className)}>
304
404
  <Popover
305
- isOpen={isOpen}
306
- onClose={() => setIsOpen(false)}
405
+ open={isOpen}
406
+ onOpenChange={setIsOpen}
307
407
  trigger={
308
408
  <button
309
409
  type="button"
310
410
  disabled={disabled}
311
- onClick={() => !disabled && setIsOpen(!isOpen)}
312
- className="w-full h-10 px-3.5 rounded-[var(--ui-radius-md)] border border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)] bg-[var(--ui-bg-surface)] text-left flex items-center justify-between text-sm shadow-inner cursor-pointer"
411
+ aria-haspopup="dialog"
412
+ aria-expanded={isOpen}
413
+ aria-label={displayText}
414
+ className="w-full h-10 px-3.5 rounded-[var(--ui-radius-md)] border border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)] bg-[var(--ui-bg-surface)] text-left flex items-center justify-between text-sm shadow-inner cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)]"
313
415
  >
314
416
  <div className="flex items-center gap-2.5 truncate">
315
417
  <CalendarIcon className="w-4 h-4 text-[var(--ui-text-muted)] shrink-0" />
@@ -320,30 +422,40 @@ export const DateRangePicker = forwardRef<HTMLDivElement, DateRangePickerProps>(
320
422
  </button>
321
423
  }
322
424
  >
323
- <div className="p-4 w-76 rounded-2xl bg-[var(--ui-bg-surface)] backdrop-blur-2xl border border-[var(--ui-border-default)] shadow-2xl space-y-3">
425
+ <div
426
+ role="dialog"
427
+ aria-label="Choose date range"
428
+ className="p-4 w-76 rounded-2xl bg-[var(--ui-bg-surface)] backdrop-blur-2xl border border-[var(--ui-border-default)] shadow-2xl space-y-3"
429
+ >
324
430
  <div className="flex items-center justify-between">
325
- <span className="text-xs font-bold text-[var(--ui-text-primary)] font-mono uppercase tracking-wider">
431
+ <span id="range-month-heading" className="text-xs font-bold text-[var(--ui-text-primary)] font-mono uppercase tracking-wider">
326
432
  {viewDate.toLocaleString('default', { month: 'long', year: 'numeric' })}
327
433
  </span>
328
434
  <div className="flex items-center gap-1">
329
435
  <button
330
436
  type="button"
331
437
  onClick={() => setViewDate(new Date(year, month - 1, 1))}
332
- className="p-1 rounded-lg hover:bg-[var(--ui-bg-tertiary)]"
438
+ aria-label="Previous month"
439
+ className="p-1 rounded-lg hover:bg-[var(--ui-bg-tertiary)] cursor-pointer"
333
440
  >
334
441
  <ChevronLeft className="w-4 h-4" />
335
442
  </button>
336
443
  <button
337
444
  type="button"
338
445
  onClick={() => setViewDate(new Date(year, month + 1, 1))}
339
- className="p-1 rounded-lg hover:bg-[var(--ui-bg-tertiary)]"
446
+ aria-label="Next month"
447
+ className="p-1 rounded-lg hover:bg-[var(--ui-bg-tertiary)] cursor-pointer"
340
448
  >
341
449
  <ChevronRight className="w-4 h-4" />
342
450
  </button>
343
451
  </div>
344
452
  </div>
345
453
 
346
- <div className="grid grid-cols-7 gap-1 text-center">
454
+ <div
455
+ role="grid"
456
+ aria-labelledby="range-month-heading"
457
+ className="grid grid-cols-7 gap-1 text-center"
458
+ >
347
459
  {Array.from({ length: firstDayIndex }).map((_, i) => (
348
460
  <div key={`empty-${i}`} />
349
461
  ))}
@@ -360,6 +472,9 @@ export const DateRangePicker = forwardRef<HTMLDivElement, DateRangePickerProps>(
360
472
  <button
361
473
  key={idx}
362
474
  type="button"
475
+ role="gridcell"
476
+ aria-selected={Boolean(isFrom || isTo || isInRange)}
477
+ aria-label={d.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
363
478
  onClick={() => handleDayClick(d)}
364
479
  className={cn(
365
480
  'h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center transition-all cursor-pointer',
@@ -391,59 +506,202 @@ export interface TimePickerProps {
391
506
  format?: '12h' | '24h';
392
507
  className?: string;
393
508
  disabled?: boolean;
509
+ placeholder?: string;
394
510
  }
395
511
 
396
512
  export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
397
- ({ value: controlledVal, defaultValue = '12:00', onChange, format = '24h', className, disabled = false }, ref) => {
398
- const [internalTime, setInternalTime] = useState(defaultValue);
399
- const time = controlledVal !== undefined ? controlledVal : internalTime;
513
+ (
514
+ {
515
+ value: controlledVal,
516
+ defaultValue,
517
+ onChange,
518
+ format = '24h',
519
+ className,
520
+ disabled = false,
521
+ placeholder = 'Select time...',
522
+ },
523
+ ref
524
+ ) => {
525
+ const is12h = format === '12h';
526
+ const fallbackDefault = defaultValue ?? (is12h ? '12:00 PM' : '12:00');
527
+
528
+ const [time, setTime] = useControllableState<string>({
529
+ value: controlledVal,
530
+ defaultValue: fallbackDefault,
531
+ onChange,
532
+ name: 'TimePicker',
533
+ });
400
534
 
401
- const [hours, setHours] = useState(time.split(':')[0] || '12');
402
- const [minutes, setMinutes] = useState(time.split(':')[1] || '00');
403
535
  const [isOpen, setIsOpen] = useState(false);
404
536
 
405
- const updateTime = (newH: string, newM: string) => {
406
- const full = `${newH.padStart(2, '0')}:${newM.padStart(2, '0')}`;
407
- setHours(newH);
408
- setMinutes(newM);
409
- setInternalTime(full);
410
- onChange?.(full);
537
+ // Parsing helper
538
+ const parseTime = (str: string, is12: boolean) => {
539
+ if (!str) return { hours: is12 ? '12' : '12', minutes: '00', period: 'AM' };
540
+ const trimmed = str.trim();
541
+ let period = 'AM';
542
+ let cleanTime = trimmed;
543
+ if (trimmed.toLowerCase().includes('pm')) {
544
+ period = 'PM';
545
+ cleanTime = trimmed.replace(/pm/i, '').trim();
546
+ } else if (trimmed.toLowerCase().includes('am')) {
547
+ period = 'AM';
548
+ cleanTime = trimmed.replace(/am/i, '').trim();
549
+ }
550
+ const parts = cleanTime.split(':');
551
+ let h = parts[0] ? parts[0].padStart(2, '0') : is12 ? '12' : '00';
552
+ const m = parts[1] ? parts[1].padStart(2, '0') : '00';
553
+ return { hours: h, minutes: m, period };
554
+ };
555
+
556
+ const { hours, minutes, period } = parseTime(time, is12h);
557
+
558
+ const updateTime = (newH: string, newM: string, newPeriod?: string) => {
559
+ let formatted: string;
560
+ if (is12h) {
561
+ const p = newPeriod ?? period;
562
+ formatted = `${newH.padStart(2, '0')}:${newM.padStart(2, '0')} ${p}`;
563
+ } else {
564
+ formatted = `${newH.padStart(2, '0')}:${newM.padStart(2, '0')}`;
565
+ }
566
+ setTime(formatted);
567
+ };
568
+
569
+ const hourOptions = is12h
570
+ ? Array.from({ length: 12 }, (_, i) => String(i + 1).padStart(2, '0'))
571
+ : Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
572
+
573
+ const minuteOptions = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'];
574
+
575
+ const hourListRef = useRef<HTMLDivElement>(null);
576
+ const minuteListRef = useRef<HTMLDivElement>(null);
577
+ const periodListRef = useRef<HTMLDivElement>(null);
578
+
579
+ // Auto-focus active option when opened
580
+ useEffect(() => {
581
+ if (isOpen) {
582
+ const timer = setTimeout(() => {
583
+ const activeHourEl = hourListRef.current?.querySelector<HTMLElement>('[aria-selected="true"]');
584
+ activeHourEl?.focus();
585
+ }, 10);
586
+ return () => clearTimeout(timer);
587
+ }
588
+ }, [isOpen]);
589
+
590
+ const handleHourKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
591
+ const idx = hourOptions.indexOf(hours);
592
+ if (e.key === 'ArrowDown') {
593
+ e.preventDefault();
594
+ const next = hourOptions[(idx + 1) % hourOptions.length];
595
+ updateTime(next, minutes, period);
596
+ } else if (e.key === 'ArrowUp') {
597
+ e.preventDefault();
598
+ const prev = hourOptions[(idx - 1 + hourOptions.length) % hourOptions.length];
599
+ updateTime(prev, minutes, period);
600
+ } else if (e.key === 'ArrowRight') {
601
+ e.preventDefault();
602
+ const activeMinEl = minuteListRef.current?.querySelector<HTMLElement>('[aria-selected="true"]') || minuteListRef.current?.querySelector<HTMLElement>('[role="option"]');
603
+ activeMinEl?.focus();
604
+ } else if (e.key === 'Escape') {
605
+ e.preventDefault();
606
+ setIsOpen(false);
607
+ }
608
+ };
609
+
610
+ const handleMinuteKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
611
+ const idx = minuteOptions.indexOf(minutes);
612
+ if (e.key === 'ArrowDown') {
613
+ e.preventDefault();
614
+ const next = minuteOptions[(idx + 1) % minuteOptions.length];
615
+ updateTime(hours, next, period);
616
+ } else if (e.key === 'ArrowUp') {
617
+ e.preventDefault();
618
+ const prev = minuteOptions[(idx - 1 + minuteOptions.length) % minuteOptions.length];
619
+ updateTime(hours, prev, period);
620
+ } else if (e.key === 'ArrowLeft') {
621
+ e.preventDefault();
622
+ const activeHourEl = hourListRef.current?.querySelector<HTMLElement>('[aria-selected="true"]');
623
+ activeHourEl?.focus();
624
+ } else if (e.key === 'ArrowRight' && is12h) {
625
+ e.preventDefault();
626
+ const activePeriodEl = periodListRef.current?.querySelector<HTMLElement>('[aria-selected="true"]');
627
+ activePeriodEl?.focus();
628
+ } else if (e.key === 'Escape') {
629
+ e.preventDefault();
630
+ setIsOpen(false);
631
+ }
632
+ };
633
+
634
+ const handlePeriodKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
635
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
636
+ e.preventDefault();
637
+ const next = period === 'AM' ? 'PM' : 'AM';
638
+ updateTime(hours, minutes, next);
639
+ } else if (e.key === 'ArrowLeft') {
640
+ e.preventDefault();
641
+ const activeMinEl = minuteListRef.current?.querySelector<HTMLElement>('[aria-selected="true"]');
642
+ activeMinEl?.focus();
643
+ } else if (e.key === 'Escape') {
644
+ e.preventDefault();
645
+ setIsOpen(false);
646
+ }
411
647
  };
412
648
 
413
649
  return (
414
650
  <div ref={ref} className={cn('relative inline-block w-full', className)}>
415
651
  <Popover
416
- isOpen={isOpen}
417
- onClose={() => setIsOpen(false)}
652
+ open={isOpen}
653
+ onOpenChange={setIsOpen}
418
654
  trigger={
419
655
  <button
420
656
  type="button"
421
657
  disabled={disabled}
422
- onClick={() => !disabled && setIsOpen(!isOpen)}
423
- className="w-full h-10 px-3.5 rounded-[var(--ui-radius-md)] border border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)] bg-[var(--ui-bg-surface)] text-left flex items-center justify-between text-sm shadow-inner cursor-pointer"
658
+ aria-haspopup="dialog"
659
+ aria-expanded={isOpen}
660
+ aria-label={`Selected time: ${time}`}
661
+ className={cn(
662
+ 'w-full h-10 px-3.5 rounded-[var(--ui-radius-md)] border border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)] bg-[var(--ui-bg-surface)] text-left flex items-center justify-between text-sm shadow-inner transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)]',
663
+ disabled && 'opacity-50 cursor-not-allowed'
664
+ )}
424
665
  >
425
666
  <div className="flex items-center gap-2.5">
426
- <Clock className="w-4 h-4 text-[var(--ui-text-muted)]" />
427
- <span className="font-mono text-sm font-semibold text-[var(--ui-text-primary)]">{time}</span>
667
+ <Clock className="w-4 h-4 text-[var(--ui-text-muted)] shrink-0" />
668
+ <span className="font-mono text-sm font-semibold text-[var(--ui-text-primary)]">
669
+ {time || placeholder}
670
+ </span>
428
671
  </div>
429
672
  </button>
430
673
  }
431
674
  >
432
- <div className="p-4 w-52 rounded-2xl bg-[var(--ui-bg-surface)] backdrop-blur-2xl border border-[var(--ui-border-default)] shadow-2xl space-y-3">
675
+ <div
676
+ role="dialog"
677
+ aria-label="Choose time"
678
+ className="p-4 rounded-2xl bg-[var(--ui-bg-surface)] backdrop-blur-2xl border border-[var(--ui-border-default)] shadow-2xl space-y-3"
679
+ >
433
680
  <span className="text-xs font-bold text-[var(--ui-text-muted)] uppercase tracking-wider font-mono">
434
681
  Select Time ({format})
435
682
  </span>
436
683
  <div className="flex items-center justify-center gap-2">
437
- <div className="h-36 overflow-y-auto border border-[var(--ui-border-subtle)] rounded-xl p-1 w-16 text-center space-y-1">
438
- {Array.from({ length: 24 }).map((_, i) => {
439
- const h = String(i).padStart(2, '0');
684
+ {/* Hours */}
685
+ <div
686
+ ref={hourListRef}
687
+ tabIndex={0}
688
+ onKeyDown={handleHourKeyDown}
689
+ className="h-40 overflow-y-auto border border-[var(--ui-border-subtle)] rounded-xl p-1 w-16 text-center space-y-1 focus:outline-none focus:ring-1 focus:ring-[var(--ui-primary)]"
690
+ role="listbox"
691
+ aria-label="Hour"
692
+ >
693
+ {hourOptions.map((h) => {
694
+ const isSelected = hours === h;
440
695
  return (
441
696
  <div
442
697
  key={h}
443
- onClick={() => updateTime(h, minutes)}
698
+ tabIndex={isSelected ? 0 : -1}
699
+ role="option"
700
+ aria-selected={isSelected}
701
+ onClick={() => updateTime(h, minutes, period)}
444
702
  className={cn(
445
- 'text-xs py-1 rounded-lg font-mono font-semibold cursor-pointer transition-colors',
446
- hours === h
703
+ 'text-xs py-1 rounded-lg font-mono font-semibold cursor-pointer transition-colors select-none outline-none focus-visible:ring-1 focus-visible:ring-[var(--ui-primary)]',
704
+ isSelected
447
705
  ? 'bg-[var(--ui-primary)] text-white'
448
706
  : 'hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-primary)]'
449
707
  )}
@@ -454,22 +712,68 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
454
712
  })}
455
713
  </div>
456
714
  <span className="text-sm font-bold">:</span>
457
- <div className="h-36 overflow-y-auto border border-[var(--ui-border-subtle)] rounded-xl p-1 w-16 text-center space-y-1">
458
- {['00', '05', '10', '15', '20', '25', '30', '35', '40', '41', '45', '50', '55'].map((m) => (
459
- <div
460
- key={m}
461
- onClick={() => updateTime(hours, m)}
462
- className={cn(
463
- 'text-xs py-1 rounded-lg font-mono font-semibold cursor-pointer transition-colors',
464
- minutes === m
465
- ? 'bg-[var(--ui-primary)] text-white'
466
- : 'hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-primary)]'
467
- )}
468
- >
469
- {m}
470
- </div>
471
- ))}
715
+ {/* Minutes */}
716
+ <div
717
+ ref={minuteListRef}
718
+ tabIndex={0}
719
+ onKeyDown={handleMinuteKeyDown}
720
+ className="h-40 overflow-y-auto border border-[var(--ui-border-subtle)] rounded-xl p-1 w-16 text-center space-y-1 focus:outline-none focus:ring-1 focus:ring-[var(--ui-primary)]"
721
+ role="listbox"
722
+ aria-label="Minute"
723
+ >
724
+ {minuteOptions.map((m) => {
725
+ const isSelected = minutes === m;
726
+ return (
727
+ <div
728
+ key={m}
729
+ tabIndex={isSelected ? 0 : -1}
730
+ role="option"
731
+ aria-selected={isSelected}
732
+ onClick={() => updateTime(hours, m, period)}
733
+ className={cn(
734
+ 'text-xs py-1 rounded-lg font-mono font-semibold cursor-pointer transition-colors select-none outline-none focus-visible:ring-1 focus-visible:ring-[var(--ui-primary)]',
735
+ isSelected
736
+ ? 'bg-[var(--ui-primary)] text-white'
737
+ : 'hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-primary)]'
738
+ )}
739
+ >
740
+ {m}
741
+ </div>
742
+ );
743
+ })}
472
744
  </div>
745
+ {/* Period (AM/PM) for 12h */}
746
+ {is12h && (
747
+ <div
748
+ ref={periodListRef}
749
+ tabIndex={0}
750
+ onKeyDown={handlePeriodKeyDown}
751
+ className="h-40 border border-[var(--ui-border-subtle)] rounded-xl p-1 w-14 text-center space-y-1 flex flex-col justify-center focus:outline-none focus:ring-1 focus:ring-[var(--ui-primary)]"
752
+ role="listbox"
753
+ aria-label="Period"
754
+ >
755
+ {['AM', 'PM'].map((p) => {
756
+ const isSelected = period === p;
757
+ return (
758
+ <div
759
+ key={p}
760
+ tabIndex={isSelected ? 0 : -1}
761
+ role="option"
762
+ aria-selected={isSelected}
763
+ onClick={() => updateTime(hours, minutes, p)}
764
+ className={cn(
765
+ 'text-xs py-2 rounded-lg font-mono font-semibold cursor-pointer transition-colors select-none outline-none focus-visible:ring-1 focus-visible:ring-[var(--ui-primary)]',
766
+ isSelected
767
+ ? 'bg-[var(--ui-primary)] text-white'
768
+ : 'hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-primary)]'
769
+ )}
770
+ >
771
+ {p}
772
+ </div>
773
+ );
774
+ })}
775
+ </div>
776
+ )}
473
777
  </div>
474
778
  </div>
475
779
  </Popover>