@vvedantb/ui 0.26.1 → 0.27.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.
@@ -23,12 +23,38 @@ interface TimePickerProps {
23
23
  // merged onto the trigger (width defaults to a compact 110px; height,
24
24
  // radius, padding and text size override cleanly)
25
25
  triggerClassName?: string;
26
+ // `12` shows "02:30 PM" with an AM/PM column; `value` stays "HH:mm" 24h.
27
+ // Default `24`.
28
+ hourCycle?: 12 | 24;
26
29
  }
27
30
 
28
31
  const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, "0"));
29
32
  const MINUTES = Array.from({ length: 60 }, (_, i) =>
30
33
  String(i).padStart(2, "0"),
31
34
  );
35
+ const HOURS_12 = Array.from({ length: 12 }, (_, i) =>
36
+ String(i + 1).padStart(2, "0"),
37
+ );
38
+ const PERIODS = ["AM", "PM"];
39
+
40
+ type Period = "AM" | "PM";
41
+
42
+ /** "14" → { hour: "02", period: "PM" }; "00" → { hour: "12", period: "AM" }. */
43
+ function toTwelveHour(hour: string): { hour: string; period: Period } {
44
+ const h = Number(hour);
45
+ return {
46
+ hour: String(((h + 11) % 12) + 1).padStart(2, "0"),
47
+ period: h < 12 ? "AM" : "PM",
48
+ };
49
+ }
50
+
51
+ /** "02" + "PM" → "14"; "12" + "AM" → "00". */
52
+ function toTwentyFourHour(hour: string, period: Period): string {
53
+ return String((Number(hour) % 12) + (period === "PM" ? 12 : 0)).padStart(
54
+ 2,
55
+ "0",
56
+ );
57
+ }
32
58
 
33
59
  function parseValue(value: string): { hour: string; minute: string } {
34
60
  const match = /^(\d{2}):(\d{2})$/.exec(value);
@@ -42,44 +68,31 @@ function parseValue(value: string): { hour: string; minute: string } {
42
68
  return { hour: "00", minute: "00" };
43
69
  }
44
70
 
45
- // auto, scroll a column to its selected item when the popover opens using
46
- // `scrollintoview({ block: "centre" })` is good enough, the popover only
47
- // has 2 columns and a fixed height, so we don't need a custom scroll calc
48
- function useScrollSelectedIntoView(
49
- ref: React.RefObject<HTMLDivElement | null>,
50
- selector: string,
51
- open: boolean,
52
- ): void {
53
- React.useEffect(() => {
54
- if (!open) return;
55
- const root = ref.current;
56
- if (!root) return;
57
- const target = root.querySelector(selector);
58
- if (target instanceof HTMLElement) {
59
- target.scrollIntoView({ block: "center", behavior: "auto" });
60
- }
61
- }, [open, ref, selector]);
62
- }
63
-
64
71
  const TimePicker = React.forwardRef<HTMLButtonElement, TimePickerProps>(
65
72
  (
66
- { value, onChange, className, disabled, ariaLabel, triggerClassName },
73
+ {
74
+ value,
75
+ onChange,
76
+ className,
77
+ disabled,
78
+ ariaLabel,
79
+ triggerClassName,
80
+ hourCycle = 24,
81
+ },
67
82
  ref,
68
83
  ) => {
69
84
  const [open, setOpen] = React.useState(false);
70
85
  const { hour, minute } = parseValue(value);
71
- const hoursRef = React.useRef<HTMLDivElement | null>(null);
72
- const minutesRef = React.useRef<HTMLDivElement | null>(null);
73
-
74
- useScrollSelectedIntoView(hoursRef, `[data-time-value="${hour}"]`, open);
75
- useScrollSelectedIntoView(
76
- minutesRef,
77
- `[data-time-value="${minute}"]`,
78
- open,
79
- );
86
+ const twelve = toTwelveHour(hour);
87
+ const isTwelveHour = hourCycle === 12;
80
88
 
81
89
  const handleHourSelect = (h: string) => {
82
- onChange(`${h}:${minute}`);
90
+ const next = isTwelveHour ? toTwentyFourHour(h, twelve.period) : h;
91
+ onChange(`${next}:${minute}`);
92
+ };
93
+ const handlePeriodSelect = (p: string) => {
94
+ const period: Period = p === "PM" ? "PM" : "AM";
95
+ onChange(`${toTwentyFourHour(twelve.hour, period)}:${minute}`);
83
96
  };
84
97
  const handleMinuteSelect = (m: string) => {
85
98
  onChange(`${hour}:${m}`);
@@ -100,7 +113,11 @@ const TimePicker = React.forwardRef<HTMLButtonElement, TimePickerProps>(
100
113
  className,
101
114
  )}
102
115
  >
103
- <span>{`${hour}:${minute}`}</span>
116
+ <span>
117
+ {isTwelveHour
118
+ ? `${twelve.hour}:${minute} ${twelve.period}`
119
+ : `${hour}:${minute}`}
120
+ </span>
104
121
  <IconClock className="h-3.5 w-3.5 text-muted-foreground" />
105
122
  </button>
106
123
  </PopoverTrigger>
@@ -110,19 +127,25 @@ const TimePicker = React.forwardRef<HTMLButtonElement, TimePickerProps>(
110
127
  className="flex w-auto gap-1 p-2"
111
128
  >
112
129
  <TimeColumn
113
- ref={hoursRef}
114
130
  label="Hours"
115
- values={HOURS}
116
- selected={hour}
131
+ values={isTwelveHour ? HOURS_12 : HOURS}
132
+ selected={isTwelveHour ? twelve.hour : hour}
117
133
  onSelect={handleHourSelect}
118
134
  />
119
135
  <TimeColumn
120
- ref={minutesRef}
121
136
  label="Minutes"
122
137
  values={MINUTES}
123
138
  selected={minute}
124
139
  onSelect={handleMinuteSelect}
125
140
  />
141
+ {isTwelveHour ? (
142
+ <TimeColumn
143
+ label="AM/PM"
144
+ values={PERIODS}
145
+ selected={twelve.period}
146
+ onSelect={handlePeriodSelect}
147
+ />
148
+ ) : null}
126
149
  </PopoverContent>
127
150
  </Popover>
128
151
  );
@@ -137,41 +160,52 @@ interface TimeColumnProps {
137
160
  onSelect: (value: string) => void;
138
161
  }
139
162
 
140
- const TimeColumn = React.forwardRef<HTMLDivElement, TimeColumnProps>(
141
- ({ label, values, selected, onSelect }, ref) => {
142
- return (
143
- <div className="flex w-14 flex-col">
144
- <div className="px-2 pb-1 text-[10px] uppercase tracking-wider text-muted-foreground">
145
- {label}
146
- </div>
147
- <div
148
- ref={ref}
149
- className="h-48 overflow-y-auto rounded-lg bg-segment py-1"
150
- >
151
- {values.map((v) => {
152
- const isSelected = v === selected;
153
- return (
154
- <button
155
- key={v}
156
- type="button"
157
- data-time-value={v}
158
- onClick={() => onSelect(v)}
159
- className={cn(
160
- "flex h-7 w-full items-center justify-center text-xs tabular-nums transition-colors",
161
- isSelected
162
- ? pickerSelectedClass
163
- : cn("text-foreground/80", pickerItemHoverClass),
164
- )}
165
- >
166
- {v}
167
- </button>
168
- );
169
- })}
170
- </div>
163
+ // Centres the selected item when the column mounts. The column lives inside
164
+ // the popover content, which mounts after the trigger's open state changes,
165
+ // so the scroll has to run here rather than in the picker. It sets the
166
+ // column's own `scrollTop`, so the page never scrolls.
167
+ function TimeColumn({ label, values, selected, onSelect }: TimeColumnProps) {
168
+ const listRef = React.useRef<HTMLDivElement | null>(null);
169
+ React.useLayoutEffect(() => {
170
+ const list = listRef.current;
171
+ const item = list?.querySelector(`[data-time-value="${selected}"]`);
172
+ if (!list || !(item instanceof HTMLElement)) return;
173
+ list.scrollTop =
174
+ item.offsetTop - list.clientHeight / 2 + item.offsetHeight / 2;
175
+ // Mount only: re-centring on every pick would jump the list under the pointer.
176
+ // eslint-disable-next-line react-hooks/exhaustive-deps
177
+ }, []);
178
+ return (
179
+ <div className="flex w-14 flex-col" data-time-column={label}>
180
+ <div className="px-2 pb-1 text-[10px] uppercase tracking-wider text-muted-foreground">
181
+ {label}
171
182
  </div>
172
- );
173
- },
174
- );
175
- TimeColumn.displayName = "TimeColumn";
183
+ <div
184
+ ref={listRef}
185
+ className="relative h-48 overflow-y-auto rounded-lg bg-segment py-1"
186
+ >
187
+ {values.map((v) => {
188
+ const isSelected = v === selected;
189
+ return (
190
+ <button
191
+ key={v}
192
+ type="button"
193
+ data-time-value={v}
194
+ onClick={() => onSelect(v)}
195
+ className={cn(
196
+ "flex h-7 w-full items-center justify-center text-xs tabular-nums transition-colors",
197
+ isSelected
198
+ ? pickerSelectedClass
199
+ : cn("text-foreground/80", pickerItemHoverClass),
200
+ )}
201
+ >
202
+ {v}
203
+ </button>
204
+ );
205
+ })}
206
+ </div>
207
+ </div>
208
+ );
209
+ }
176
210
 
177
211
  export { TimePicker };
@@ -40,6 +40,8 @@ import type { QueueConfig } from "../ai-elements/queue";
40
40
  import type { SearchInputConfig } from "./search-input";
41
41
  import type { SelectConfig } from "./select";
42
42
  import type { SeparatorConfig } from "./separator";
43
+ import type { SettingsEmptyStateConfig } from "../../app/src/settings/SettingsEmptyState";
44
+ import type { SettingsSectionConfig } from "../../app/src/settings/SettingsSection";
43
45
  import type { ShimmerConfig } from "../ai-elements/shimmer";
44
46
  import type { SliderConfig } from "./slider";
45
47
  import type { SonnerToasterConfig } from "./sonner";
@@ -110,6 +112,8 @@ export interface UiConfig {
110
112
  SearchInput?: SearchInputConfig;
111
113
  Select?: SelectConfig;
112
114
  Separator?: SeparatorConfig;
115
+ SettingsEmptyState?: SettingsEmptyStateConfig;
116
+ SettingsSection?: SettingsSectionConfig;
113
117
  Shimmer?: ShimmerConfig;
114
118
  Slider?: SliderConfig;
115
119
  SonnerToaster?: SonnerToasterConfig;
@@ -0,0 +1,43 @@
1
+ /*
2
+ * @vvedantb/ui/tokens/css/marquee.css: the `.mq-*` rules for
3
+ * <MarqueeOnHover/>. Opt-in: neither tokens.css nor index.css imports it.
4
+ *
5
+ * @import "@vvedantb/ui/tokens/css/marquee.css";
6
+ *
7
+ * Idle is a plain ellipsis. On hover (only when the text overflows) the inner
8
+ * span scrolls left to reveal its hidden tail and holds, then eases back on
9
+ * leave. The component sets --mq-shift and --mq-dur per element and toggles
10
+ * data-mq-overflow. Reads --motion-base and --motion-ease-out for the return.
11
+ * Set on :root, the host or any ancestor to change:
12
+ * --mq-delay: 400ms (dwell before the reveal starts)
13
+ *
14
+ * Hovering the label or any ancestor with Tailwind's `group` class reveals
15
+ * it, so hovering anywhere on a card or row scrolls its title.
16
+ *
17
+ * Unlayered on purpose, so it wins over utilities at equal specificity.
18
+ */
19
+
20
+ .mq-host {
21
+ display: block;
22
+ overflow: hidden;
23
+ padding: 0; /* keep clientWidth == content box so the overflow measurement is honest */
24
+ }
25
+
26
+ .mq-inner {
27
+ display: block;
28
+ width: max-content;
29
+ max-width: 100%;
30
+ overflow: hidden;
31
+ white-space: nowrap;
32
+ text-overflow: ellipsis;
33
+ transform: translateX(0);
34
+ transition: transform var(--motion-base) var(--motion-ease-out); /* return ease on leave */
35
+ will-change: transform;
36
+ }
37
+
38
+ .mq-host[data-mq-overflow="true"]:hover .mq-inner,
39
+ .group:hover .mq-host[data-mq-overflow="true"] .mq-inner {
40
+ max-width: none; /* let the text spill to its full width; the host clips it */
41
+ transform: translateX(calc(-1 * var(--mq-shift, 0px)));
42
+ transition: transform var(--mq-dur, 0s) linear var(--mq-delay, 400ms);
43
+ }