@vvedantb/ui 0.26.0 → 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.
- package/README.md +24 -1
- package/app/src/convex/errorMessage.ts +62 -0
- package/app/src/convex/useHeldResult.ts +26 -0
- package/app/src/index.ts +10 -2
- package/app/src/settings/SettingsEmptyState.tsx +14 -1
- package/app/src/settings/SettingsSection.tsx +29 -4
- package/package.json +2 -1
- package/src/brand/AppWordmark.tsx +9 -5
- package/src/hooks/index.ts +5 -0
- package/src/hooks/use-document-visible.ts +15 -0
- package/src/hooks/use-idle-callback.ts +51 -0
- package/src/hooks/use-media-query.ts +17 -0
- package/src/hooks/use-persisted-scroll-parent.ts +70 -0
- package/src/hooks/use-single-flight.ts +59 -0
- package/src/index.ts +14 -0
- package/src/motion/list-enter.tsx +65 -0
- package/src/ui/count-pop.tsx +67 -0
- package/src/ui/error-boundary.tsx +48 -0
- package/src/ui/image-lightbox.tsx +197 -0
- package/src/ui/marquee-on-hover.tsx +80 -0
- package/src/ui/media-preview.tsx +173 -0
- package/src/ui/relative-date-time.tsx +42 -0
- package/src/ui/time-picker.tsx +104 -70
- package/src/ui/ui-config.tsx +4 -0
- package/tokens/src/css/marquee.css +43 -0
package/src/ui/time-picker.tsx
CHANGED
|
@@ -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
|
-
{
|
|
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
|
|
72
|
-
const
|
|
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
|
-
|
|
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>
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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 };
|
package/src/ui/ui-config.tsx
CHANGED
|
@@ -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
|
+
}
|