@djangocfg/ui-core 2.1.591 → 2.1.593
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/package.json +7 -7
- package/src/components/data/relative-time-card/index.tsx +39 -19
- package/src/components/forms/setting-row/index.tsx +1 -1
- package/src/components/layout/resizable/index.tsx +1 -1
- package/src/components/navigation/dropdown-menu/index.tsx +2 -2
- package/src/components/navigation/menu/render-items.tsx +1 -1
- package/src/components/navigation/sidebar/sidebar.tsx +2 -6
- package/src/components/overlay/dialog/index.tsx +7 -1
- package/src/components/overlay/popover/index.tsx +7 -1
- package/src/components/select/index.ts +4 -0
- package/src/components/select/timezone-select.tsx +146 -0
- package/src/styles/css/base.css +2 -0
- package/src/styles/css/presets/dense.css +0 -19
- package/src/styles/css/presets/django-cfg.css +2 -2
- package/src/styles/css/presets/macos.css +17 -17
- package/src/styles/css/theme/dark.css +23 -24
- package/src/styles/css/utilities/glass.css +0 -5
- package/src/styles/css/utilities/overlay.css +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-core",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.593",
|
|
4
4
|
"description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-components",
|
|
@@ -130,8 +130,8 @@
|
|
|
130
130
|
"check:contrast": "node scripts/check-preset-contrast.mjs"
|
|
131
131
|
},
|
|
132
132
|
"peerDependencies": {
|
|
133
|
-
"@djangocfg/api": "^2.1.
|
|
134
|
-
"@djangocfg/i18n": "^2.1.
|
|
133
|
+
"@djangocfg/api": "^2.1.593",
|
|
134
|
+
"@djangocfg/i18n": "^2.1.593",
|
|
135
135
|
"consola": "^3.4.2",
|
|
136
136
|
"lucide-react": "^0.545.0",
|
|
137
137
|
"moment": "^2.30.1",
|
|
@@ -207,10 +207,10 @@
|
|
|
207
207
|
"vaul": "1.1.2"
|
|
208
208
|
},
|
|
209
209
|
"devDependencies": {
|
|
210
|
-
"@djangocfg/api": "^2.1.
|
|
211
|
-
"@djangocfg/eslint-config": "^2.1.
|
|
212
|
-
"@djangocfg/i18n": "^2.1.
|
|
213
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
210
|
+
"@djangocfg/api": "^2.1.593",
|
|
211
|
+
"@djangocfg/eslint-config": "^2.1.593",
|
|
212
|
+
"@djangocfg/i18n": "^2.1.593",
|
|
213
|
+
"@djangocfg/typescript-config": "^2.1.593",
|
|
214
214
|
"@storybook/react-vite": "^10.5.0",
|
|
215
215
|
"@types/node": "^24.13.3",
|
|
216
216
|
"@types/react": "19.2.15",
|
|
@@ -51,6 +51,8 @@ interface RelativeTimeCardProps
|
|
|
51
51
|
locale?: string;
|
|
52
52
|
/** Use UTC for all calculations. @default true */
|
|
53
53
|
utc?: boolean;
|
|
54
|
+
/** IANA zone the wall-clock text is shown in (`useTimezone().timezone`). @default the browser zone */
|
|
55
|
+
timeZone?: string;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
/* Built on `Intl`, not moment.
|
|
@@ -110,35 +112,40 @@ function getRelativeTime(date: Date, locale: string = "en"): string {
|
|
|
110
112
|
return formatter.format(0, "second");
|
|
111
113
|
}
|
|
112
114
|
|
|
113
|
-
function getAutoFormat(date: Date, locale: string = "en"): string {
|
|
115
|
+
function getAutoFormat(date: Date, locale: string = "en", timeZone?: string): string {
|
|
114
116
|
const diffHours = (Date.now() - date.getTime()) / 3_600_000;
|
|
115
117
|
const diffDays = diffHours / 24;
|
|
116
118
|
|
|
117
119
|
if (diffHours < 24) {
|
|
118
120
|
return new Intl.DateTimeFormat(locale, {
|
|
121
|
+
timeZone,
|
|
119
122
|
hour: "2-digit",
|
|
120
123
|
minute: "2-digit",
|
|
121
124
|
hour12: false,
|
|
122
125
|
}).format(date);
|
|
123
126
|
}
|
|
124
127
|
if (diffDays < 7) {
|
|
125
|
-
return new Intl.DateTimeFormat(locale, {
|
|
128
|
+
return new Intl.DateTimeFormat(locale, {
|
|
129
|
+
timeZone, weekday: "short" }).format(date);
|
|
126
130
|
}
|
|
127
131
|
if (diffDays < 365) {
|
|
128
132
|
return new Intl.DateTimeFormat(locale, {
|
|
133
|
+
timeZone,
|
|
129
134
|
month: "short",
|
|
130
135
|
day: "numeric",
|
|
131
136
|
}).format(date);
|
|
132
137
|
}
|
|
133
138
|
return new Intl.DateTimeFormat(locale, {
|
|
139
|
+
timeZone,
|
|
134
140
|
year: "numeric",
|
|
135
141
|
month: "short",
|
|
136
142
|
day: "numeric",
|
|
137
143
|
}).format(date);
|
|
138
144
|
}
|
|
139
145
|
|
|
140
|
-
function getDateTimeFormat(date: Date, locale: string = "en"): string {
|
|
146
|
+
function getDateTimeFormat(date: Date, locale: string = "en", timeZone?: string): string {
|
|
141
147
|
return new Intl.DateTimeFormat(locale, {
|
|
148
|
+
timeZone,
|
|
142
149
|
year: "numeric",
|
|
143
150
|
month: "short",
|
|
144
151
|
day: "numeric",
|
|
@@ -149,15 +156,25 @@ function getDateTimeFormat(date: Date, locale: string = "en"): string {
|
|
|
149
156
|
}
|
|
150
157
|
|
|
151
158
|
/** The moment-style tokens this component shipped with, and only those. */
|
|
152
|
-
function applyCustomFormat(date: Date, pattern: string): string {
|
|
153
|
-
const
|
|
159
|
+
function applyCustomFormat(date: Date, pattern: string, timeZone?: string): string {
|
|
160
|
+
const parts = new Intl.DateTimeFormat("en-GB", {
|
|
161
|
+
timeZone,
|
|
162
|
+
year: "numeric",
|
|
163
|
+
month: "2-digit",
|
|
164
|
+
day: "2-digit",
|
|
165
|
+
hour: "2-digit",
|
|
166
|
+
minute: "2-digit",
|
|
167
|
+
second: "2-digit",
|
|
168
|
+
hourCycle: "h23",
|
|
169
|
+
}).formatToParts(date);
|
|
170
|
+
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
|
|
154
171
|
const tokens: Record<string, string> = {
|
|
155
|
-
YYYY:
|
|
156
|
-
MM:
|
|
157
|
-
DD:
|
|
158
|
-
HH:
|
|
159
|
-
mm:
|
|
160
|
-
ss:
|
|
172
|
+
YYYY: get("year"),
|
|
173
|
+
MM: get("month"),
|
|
174
|
+
DD: get("day"),
|
|
175
|
+
HH: get("hour"),
|
|
176
|
+
mm: get("minute"),
|
|
177
|
+
ss: get("second"),
|
|
161
178
|
};
|
|
162
179
|
return pattern.replace(/YYYY|MM|DD|HH|mm|ss/g, (t) => tokens[t] ?? t);
|
|
163
180
|
}
|
|
@@ -166,17 +183,18 @@ function formatDate(
|
|
|
166
183
|
date: Date,
|
|
167
184
|
format: "relative" | "auto" | "datetime" | "custom",
|
|
168
185
|
locale: string,
|
|
169
|
-
outputFormat?: string
|
|
186
|
+
outputFormat?: string,
|
|
187
|
+
timeZone?: string
|
|
170
188
|
): string {
|
|
171
189
|
switch (format) {
|
|
172
190
|
case "relative":
|
|
173
191
|
return getRelativeTime(date, locale);
|
|
174
192
|
case "auto":
|
|
175
|
-
return getAutoFormat(date, locale);
|
|
193
|
+
return getAutoFormat(date, locale, timeZone);
|
|
176
194
|
case "datetime":
|
|
177
|
-
return getDateTimeFormat(date, locale);
|
|
195
|
+
return getDateTimeFormat(date, locale, timeZone);
|
|
178
196
|
case "custom":
|
|
179
|
-
return applyCustomFormat(date, outputFormat || "YYYY-MM-DD HH:mm");
|
|
197
|
+
return applyCustomFormat(date, outputFormat || "YYYY-MM-DD HH:mm", timeZone);
|
|
180
198
|
}
|
|
181
199
|
}
|
|
182
200
|
|
|
@@ -195,6 +213,7 @@ const RelativeTimeCard = React.forwardRef<
|
|
|
195
213
|
suffix,
|
|
196
214
|
locale = "en",
|
|
197
215
|
utc = true,
|
|
216
|
+
timeZone,
|
|
198
217
|
className,
|
|
199
218
|
variant,
|
|
200
219
|
size,
|
|
@@ -205,17 +224,17 @@ const RelativeTimeCard = React.forwardRef<
|
|
|
205
224
|
const dateObj = React.useMemo(() => toDate(date, utc), [date, utc]);
|
|
206
225
|
|
|
207
226
|
const [display, setDisplay] = React.useState(() =>
|
|
208
|
-
formatDate(dateObj, format, locale, outputFormat)
|
|
227
|
+
formatDate(dateObj, format, locale, outputFormat, timeZone)
|
|
209
228
|
);
|
|
210
229
|
|
|
211
230
|
React.useEffect(() => {
|
|
212
|
-
setDisplay(formatDate(dateObj, format, locale, outputFormat));
|
|
231
|
+
setDisplay(formatDate(dateObj, format, locale, outputFormat, timeZone));
|
|
213
232
|
if (format !== "relative") return;
|
|
214
233
|
const id = setInterval(() => {
|
|
215
|
-
setDisplay(formatDate(dateObj, format, locale, outputFormat));
|
|
234
|
+
setDisplay(formatDate(dateObj, format, locale, outputFormat, timeZone));
|
|
216
235
|
}, updateInterval);
|
|
217
236
|
return () => clearInterval(id);
|
|
218
|
-
}, [dateObj, format, locale, outputFormat, updateInterval]);
|
|
237
|
+
}, [dateObj, format, locale, outputFormat, timeZone, updateInterval]);
|
|
219
238
|
|
|
220
239
|
// An invalid date would throw from `toISOString`, where moment returned
|
|
221
240
|
// null and rendered "Invalid date". Both attributes are omitted instead.
|
|
@@ -223,6 +242,7 @@ const RelativeTimeCard = React.forwardRef<
|
|
|
223
242
|
const isoString = valid ? dateObj.toISOString() : undefined;
|
|
224
243
|
const title = valid
|
|
225
244
|
? new Intl.DateTimeFormat(locale, {
|
|
245
|
+
timeZone,
|
|
226
246
|
dateStyle: "full",
|
|
227
247
|
timeStyle: "short",
|
|
228
248
|
}).format(dateObj)
|
|
@@ -326,7 +326,7 @@ export interface SettingsBlockProps {
|
|
|
326
326
|
defaultOpen?: boolean;
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
-
const BLOCK_TITLE = 'text-
|
|
329
|
+
const BLOCK_TITLE = 'text-base font-medium text-foreground';
|
|
330
330
|
|
|
331
331
|
export const SettingsBlock: React.FC<SettingsBlockProps> = ({
|
|
332
332
|
title,
|
|
@@ -156,7 +156,7 @@ const ResizableHandle = ({
|
|
|
156
156
|
className={cn(
|
|
157
157
|
"relative flex items-center justify-center bg-divider",
|
|
158
158
|
"w-px h-full",
|
|
159
|
-
"hover:bg-primary/
|
|
159
|
+
"hover:bg-primary/60 active:bg-primary",
|
|
160
160
|
"transition-colors",
|
|
161
161
|
// Touch handling for better drag performance
|
|
162
162
|
"touch-none select-none",
|
|
@@ -127,7 +127,7 @@ const DropdownMenuItem = React.forwardRef<
|
|
|
127
127
|
}
|
|
128
128
|
>(({ className, inset, href, variant = "default", children, ...props }, ref) => {
|
|
129
129
|
const classes = cn(
|
|
130
|
-
"relative flex cursor-default select-none items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1
|
|
130
|
+
"relative flex cursor-default select-none items-center gap-2 rounded-[var(--radius-sm)] px-2.5 py-1 text-sm outline-none transition-colors focus:bg-foreground/[0.07] data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
|
131
131
|
"data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive",
|
|
132
132
|
inset && "pl-8",
|
|
133
133
|
className
|
|
@@ -228,7 +228,7 @@ const DropdownMenuSeparator = React.forwardRef<
|
|
|
228
228
|
ref={ref}
|
|
229
229
|
// Soft hairline via the --divider token (was bg-muted, which is darker than
|
|
230
230
|
// the popover surface and read as a hard dark groove).
|
|
231
|
-
className={cn("
|
|
231
|
+
className={cn("mx-1.5 my-1 h-px bg-divider", className)}
|
|
232
232
|
{...props}
|
|
233
233
|
/>
|
|
234
234
|
))
|
|
@@ -44,7 +44,7 @@ export function renderItems(
|
|
|
44
44
|
|
|
45
45
|
case 'label':
|
|
46
46
|
return (
|
|
47
|
-
<DropdownMenuLabel key={item.id ?? `label-${index}`}>
|
|
47
|
+
<DropdownMenuLabel key={item.id ?? `label-${index}`} className="font-normal text-muted-foreground">
|
|
48
48
|
{item.label}
|
|
49
49
|
</DropdownMenuLabel>
|
|
50
50
|
);
|
|
@@ -196,7 +196,7 @@ const Sidebar = React.forwardRef<
|
|
|
196
196
|
data-sidebar="sidebar"
|
|
197
197
|
data-mobile="true"
|
|
198
198
|
className={cn(
|
|
199
|
-
"flex h-[100dvh] max-h-[100dvh] min-h-0 !w-[min(
|
|
199
|
+
"flex h-[100dvh] max-h-[100dvh] min-h-0 !w-[min(88vw,22rem)] max-w-[min(88vw,22rem)] flex-col gap-0 border-sidebar-border/60 p-0 text-sidebar-foreground shadow-xl",
|
|
200
200
|
drawerSide === "left" ? "border-r" : "border-l",
|
|
201
201
|
)}
|
|
202
202
|
style={
|
|
@@ -287,11 +287,7 @@ const Sidebar = React.forwardRef<
|
|
|
287
287
|
{variant === "sidebar" && (
|
|
288
288
|
<div
|
|
289
289
|
aria-hidden
|
|
290
|
-
className=
|
|
291
|
-
"pointer-events-none absolute inset-y-0 right-0 z-[1] w-px",
|
|
292
|
-
"bg-[linear-gradient(180deg,color-mix(in_oklab,var(--sidebar-border)_2%,transparent)_0%,color-mix(in_oklab,var(--sidebar-border)_22%,transparent)_18%,color-mix(in_oklab,var(--sidebar-border)_40%,transparent)_50%,color-mix(in_oklab,var(--sidebar-border)_20%,transparent)_82%,color-mix(in_oklab,var(--sidebar-border)_3%,transparent)_100%)]",
|
|
293
|
-
"dark:bg-[linear-gradient(180deg,color-mix(in_oklab,var(--sidebar-border)_8%,transparent)_0%,color-mix(in_oklab,var(--sidebar-border)_34%,transparent)_22%,color-mix(in_oklab,var(--sidebar-border)_55%,transparent)_50%,color-mix(in_oklab,var(--sidebar-border)_28%,transparent)_78%,color-mix(in_oklab,var(--sidebar-border)_6%,transparent)_100%)]"
|
|
294
|
-
)}
|
|
290
|
+
className="pointer-events-none absolute inset-y-0 right-0 z-[1] w-px bg-sidebar-border"
|
|
295
291
|
/>
|
|
296
292
|
)}
|
|
297
293
|
<div className="relative z-0 flex min-h-0 flex-1 flex-col">{children}</div>
|
|
@@ -47,6 +47,12 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
|
|
47
47
|
* widens it and the viewport margin is kept for you. A Tailwind `max-w-*`
|
|
48
48
|
* instead does nothing — the width is declared, and a maximum above a declared
|
|
49
49
|
* width is inert. Ignored when `fullscreen`.
|
|
50
|
+
*
|
|
51
|
+
* Proportional cards: set `--aspect-dialog` (width / height) and the card keeps
|
|
52
|
+
* that ratio at every size — the width shrinks to `(100dvh - 2rem) * ratio` when
|
|
53
|
+
* the window is short, so the height never overflows. Pure CSS, no measuring:
|
|
54
|
+
*
|
|
55
|
+
* <DialogContent className="[--width-dialog:1024px] [--aspect-dialog:1.28]">
|
|
50
56
|
*/
|
|
51
57
|
interface DialogContentProps
|
|
52
58
|
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
|
@@ -117,7 +123,7 @@ const DialogContent = React.forwardRef<
|
|
|
117
123
|
// scrolls. The cap is only a ceiling — a card that must scroll adds
|
|
118
124
|
// `overflow-y-auto`, and one laying out its own regions overrides
|
|
119
125
|
// `max-h` as before.
|
|
120
|
-
: "left-1/2 top-1/2 grid max-h-[calc(100dvh-2rem)] grid-cols-[minmax(0,1fr)] w-[min(var(--width-dialog),calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-[var(--radius-dialog)] border p-6 shadow-lg data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
|
126
|
+
: "left-1/2 top-1/2 grid max-h-[calc(100dvh-2rem)] grid-cols-[minmax(0,1fr)] [aspect-ratio:var(--aspect-dialog,auto)] w-[min(var(--width-dialog),calc(100vw-2rem),calc((100dvh-2rem)*var(--aspect-dialog,1000)))] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-[var(--radius-dialog)] border p-6 shadow-lg data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
|
121
127
|
className
|
|
122
128
|
)}
|
|
123
129
|
{...props}
|
|
@@ -15,7 +15,7 @@ const PopoverAnchor = PopoverPrimitive.Anchor
|
|
|
15
15
|
const PopoverContent = React.forwardRef<
|
|
16
16
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
|
17
17
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
|
18
|
-
>(({ className, align = "center", sideOffset = 4, collisionPadding = 8, forceMount, ...props }, ref) => (
|
|
18
|
+
>(({ className, align = "center", sideOffset = 4, collisionPadding = 8, forceMount, onWheel, onTouchMove, ...props }, ref) => (
|
|
19
19
|
// `forceMount` must reach BOTH halves, and that is the whole reason it is
|
|
20
20
|
// destructured rather than swept up by `...props` (live 2026-08-15).
|
|
21
21
|
//
|
|
@@ -42,6 +42,12 @@ const PopoverContent = React.forwardRef<
|
|
|
42
42
|
// mismatched darker rectangle inside the popover.
|
|
43
43
|
data-slot="popover-content"
|
|
44
44
|
aria-describedby={undefined}
|
|
45
|
+
// A popover is portaled to <body>, outside an open Dialog's content, so the
|
|
46
|
+
// Dialog's scroll lock (react-remove-scroll) swallows wheel/touch scrolling
|
|
47
|
+
// inside it — a long list inside a settings dialog would not scroll at all.
|
|
48
|
+
// Stopping propagation here keeps those events from reaching the lock.
|
|
49
|
+
onWheel={(e) => { onWheel?.(e); e.stopPropagation(); }}
|
|
50
|
+
onTouchMove={(e) => { onTouchMove?.(e); e.stopPropagation(); }}
|
|
45
51
|
align={align}
|
|
46
52
|
sideOffset={sideOffset}
|
|
47
53
|
// Radix defaults this to 0, which lets a popover that does not fit below
|
|
@@ -63,6 +63,10 @@ export { CountrySelect } from './country-select';
|
|
|
63
63
|
export type { CountrySelectProps, CountryOption } from './country-select';
|
|
64
64
|
export type { TCountryCode } from 'countries-list';
|
|
65
65
|
|
|
66
|
+
// TimezoneSelect
|
|
67
|
+
export { TimezoneSelect, detectTimezone, timezoneOffsetLabel } from './timezone-select';
|
|
68
|
+
export type { TimezoneSelectProps } from './timezone-select';
|
|
69
|
+
|
|
66
70
|
// LanguageSelect
|
|
67
71
|
export { LanguageSelect } from './language-select';
|
|
68
72
|
export type {
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
|
|
5
|
+
import { Combobox, type ComboboxOption } from './combobox';
|
|
6
|
+
|
|
7
|
+
/** Sentinel option value for "follow the browser" — never an IANA name. */
|
|
8
|
+
const AUTO = '__auto__';
|
|
9
|
+
|
|
10
|
+
export interface TimezoneSelectProps {
|
|
11
|
+
/** IANA zone (e.g. `Europe/Madrid`); `null`/`undefined` = automatic (browser). */
|
|
12
|
+
value?: string | null;
|
|
13
|
+
onChange?: (timezone: string | null) => void;
|
|
14
|
+
/** Offer the "Automatic" option. Default: true. */
|
|
15
|
+
allowAuto?: boolean;
|
|
16
|
+
/** Label of the automatic option (the detected zone is its description). */
|
|
17
|
+
autoLabel?: string;
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
searchPlaceholder?: string;
|
|
20
|
+
emptyText?: string;
|
|
21
|
+
className?: string;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The browser's zone, or `UTC` where Intl cannot tell. */
|
|
26
|
+
export function detectTimezone(): string {
|
|
27
|
+
try {
|
|
28
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
29
|
+
} catch {
|
|
30
|
+
return 'UTC';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Current offset of a zone as `UTC+01:00` (`UTC` for zero). */
|
|
35
|
+
export function timezoneOffsetLabel(timeZone: string, at: Date = new Date()): string {
|
|
36
|
+
try {
|
|
37
|
+
const part = new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'longOffset' })
|
|
38
|
+
.formatToParts(at)
|
|
39
|
+
.find((p) => p.type === 'timeZoneName')?.value;
|
|
40
|
+
if (!part || part === 'GMT') return 'UTC';
|
|
41
|
+
return part.replace('GMT', 'UTC');
|
|
42
|
+
} catch {
|
|
43
|
+
return 'UTC';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Offset in minutes, for ordering. */
|
|
48
|
+
function offsetMinutes(label: string): number {
|
|
49
|
+
const m = /UTC([+-])(\d{2}):(\d{2})/.exec(label);
|
|
50
|
+
if (!m) return 0;
|
|
51
|
+
return (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3]));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `America/Argentina/Buenos_Aires` → `Buenos Aires`. */
|
|
55
|
+
function cityOf(timeZone: string): string {
|
|
56
|
+
return (timeZone.split('/').pop() ?? timeZone).replace(/_/g, ' ');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function listTimezones(): string[] {
|
|
60
|
+
const supported = (Intl as { supportedValuesOf?: (key: string) => string[] }).supportedValuesOf;
|
|
61
|
+
const zones = supported ? supported('timeZone') : [];
|
|
62
|
+
// `UTC` is not always part of supportedValuesOf — keep it reachable.
|
|
63
|
+
return zones.includes('UTC') ? zones : ['UTC', ...zones];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Time zone picker — a thin layer over `Combobox`: options come from
|
|
68
|
+
* `Intl.supportedValuesOf('timeZone')` (no data dependency), each shown as
|
|
69
|
+
* city + region with its current UTC offset, ordered by offset.
|
|
70
|
+
*
|
|
71
|
+
* `value` is an IANA name or `null` for "Automatic" (browser zone), so the
|
|
72
|
+
* caller can store `null` and keep following the device.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```tsx
|
|
76
|
+
* <TimezoneSelect value={tz} onChange={setTz} />
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export function TimezoneSelect({
|
|
80
|
+
value,
|
|
81
|
+
onChange,
|
|
82
|
+
allowAuto = true,
|
|
83
|
+
autoLabel = 'Automatic',
|
|
84
|
+
placeholder = 'Select time zone…',
|
|
85
|
+
searchPlaceholder = 'Search city or offset…',
|
|
86
|
+
emptyText = 'No time zones found',
|
|
87
|
+
className,
|
|
88
|
+
disabled,
|
|
89
|
+
}: TimezoneSelectProps) {
|
|
90
|
+
const detected = React.useMemo(detectTimezone, []);
|
|
91
|
+
|
|
92
|
+
const options = React.useMemo<ComboboxOption[]>(() => {
|
|
93
|
+
const now = new Date();
|
|
94
|
+
const zones = listTimezones()
|
|
95
|
+
.map((tz) => ({ tz, offset: timezoneOffsetLabel(tz, now) }))
|
|
96
|
+
.sort((a, b) => offsetMinutes(a.offset) - offsetMinutes(b.offset) || a.tz.localeCompare(b.tz))
|
|
97
|
+
.map<ComboboxOption>(({ tz, offset }) => ({
|
|
98
|
+
value: tz,
|
|
99
|
+
label: cityOf(tz),
|
|
100
|
+
description: tz.includes('/') ? tz.split('/')[0]! : undefined,
|
|
101
|
+
badge: offset,
|
|
102
|
+
}));
|
|
103
|
+
return allowAuto
|
|
104
|
+
? [{ value: AUTO, label: autoLabel, description: detected, badge: timezoneOffsetLabel(detected, now) }, ...zones]
|
|
105
|
+
: zones;
|
|
106
|
+
}, [allowAuto, autoLabel, detected]);
|
|
107
|
+
|
|
108
|
+
const filter = React.useCallback((o: ComboboxOption, search: string) => {
|
|
109
|
+
const q = search.trim().toLowerCase();
|
|
110
|
+
if (!q) return true;
|
|
111
|
+
// `+9` must find `UTC+09:00`: compare offsets without prefix, leading zero and `:00`.
|
|
112
|
+
const short = (v: string) => v.toLowerCase().replace(/utc/g, '').replace(/([+-])0/g, '$1').replace(/:00/g, '');
|
|
113
|
+
const offset = typeof o.badge === 'string' ? o.badge : '';
|
|
114
|
+
return (
|
|
115
|
+
o.value.toLowerCase().replace(/_/g, ' ').includes(q) ||
|
|
116
|
+
o.label.toLowerCase().includes(q) ||
|
|
117
|
+
offset.toLowerCase().includes(q) ||
|
|
118
|
+
short(offset).includes(short(q))
|
|
119
|
+
);
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<Combobox
|
|
124
|
+
options={options}
|
|
125
|
+
value={value ?? (allowAuto ? AUTO : '')}
|
|
126
|
+
onValueChange={(v) => onChange?.(v === AUTO || v === '' ? null : v)}
|
|
127
|
+
placeholder={placeholder}
|
|
128
|
+
searchPlaceholder={searchPlaceholder}
|
|
129
|
+
emptyText={emptyText}
|
|
130
|
+
className={className}
|
|
131
|
+
disabled={disabled}
|
|
132
|
+
filterFunction={filter}
|
|
133
|
+
contentClassName="w-72"
|
|
134
|
+
renderValue={(o) =>
|
|
135
|
+
o ? (
|
|
136
|
+
<span className="flex min-w-0 items-baseline gap-2">
|
|
137
|
+
<span className="truncate">{o.value === AUTO ? `${o.label} · ${cityOf(detected)}` : o.label}</span>
|
|
138
|
+
<span className="shrink-0 text-xs text-muted-foreground">{o.badge}</span>
|
|
139
|
+
</span>
|
|
140
|
+
) : (
|
|
141
|
+
<span className="text-muted-foreground">{placeholder}</span>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
/>
|
|
145
|
+
);
|
|
146
|
+
}
|
package/src/styles/css/base.css
CHANGED
|
@@ -31,25 +31,6 @@
|
|
|
31
31
|
|
|
32
32
|
.dark {
|
|
33
33
|
--radius: 0.25rem;
|
|
34
|
-
--border: hsl(0 0% 24%);
|
|
35
|
-
--input: hsl(0 0% 24%);
|
|
36
|
-
/* 0.10, not 0.18. A divider separates rows INSIDE a panel, so it must be
|
|
37
|
-
weaker than that panel's own --border — otherwise the five lines between
|
|
38
|
-
rows shout as loudly as the one frame around them, which is what was
|
|
39
|
-
left of "кондово" after the fills were calmed. Over a 12% card this
|
|
40
|
-
lands at 15.4% (step 3.4) against the border's 18% (step 6.0): a clear
|
|
41
|
-
hierarchy, frame first. */
|
|
42
|
-
--divider: hsl(0 0% 46% / 0.10);
|
|
43
|
-
--muted: hsl(0 0% 12%);
|
|
44
|
-
/* 12%, against an INHERITED --background of 14.5% from dark.css — this
|
|
45
|
-
preset declares no background of its own, so the card sits DARKER than the
|
|
46
|
-
page here rather than lighter. Direction is not the contract; the size of
|
|
47
|
-
the step is. Capped by MAXIMUM_RESTING_SURFACE_GAP in
|
|
48
|
-
check-preset-contrast.mjs. */
|
|
49
|
-
--card: hsl(0 0% 12%);
|
|
50
|
-
/* 20%, not 14%: --background is 14.5%, so this was HALF a point — the
|
|
51
|
-
hover was effectively invisible. */
|
|
52
|
-
--accent: hsl(0 0% 20%);
|
|
53
34
|
--radius-xs: calc(0.25rem - 6px);
|
|
54
35
|
--radius-sm: calc(0.25rem - 4px);
|
|
55
36
|
--radius-md: calc(0.25rem - 2px);
|
|
@@ -27,13 +27,13 @@
|
|
|
27
27
|
.dark {
|
|
28
28
|
--primary: hsl(189 100% 50%);
|
|
29
29
|
--primary-foreground: hsl(0 0% 9%);
|
|
30
|
-
/* 20
|
|
30
|
+
/* 20%: a clear step above the 10% page. */
|
|
31
31
|
--accent: hsl(189 40% 20%);
|
|
32
32
|
--accent-foreground: hsl(189 100% 78%);
|
|
33
33
|
--ring: hsl(189 100% 50%);
|
|
34
34
|
--sidebar-primary: hsl(189 100% 50%);
|
|
35
35
|
--sidebar-primary-foreground: hsl(0 0% 9%);
|
|
36
|
-
--sidebar-accent: hsl(189 35%
|
|
36
|
+
--sidebar-accent: hsl(189 35% 17.5%);
|
|
37
37
|
--sidebar-accent-foreground: hsl(189 100% 78%);
|
|
38
38
|
--sidebar-ring: hsl(189 100% 50%);
|
|
39
39
|
--info: hsl(189 100% 55%);
|
|
@@ -81,24 +81,24 @@
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
.dark {
|
|
84
|
-
--background: hsl(
|
|
85
|
-
--foreground: hsl(0 0%
|
|
84
|
+
--background: hsl(0 0% 10%);
|
|
85
|
+
--foreground: hsl(0 0% 97.6%);
|
|
86
86
|
/* Three points above --background, not seven. A resting surface is
|
|
87
87
|
separated by its BORDER; when the fill does that job the page reads as
|
|
88
88
|
stacked plates and a table looks like a sticker laid over it. Capped by
|
|
89
89
|
MAXIMUM_RESTING_SURFACE_GAP in check-preset-contrast.mjs. */
|
|
90
|
-
--card: hsl(
|
|
91
|
-
--card-foreground: hsl(0 0%
|
|
92
|
-
--popover: hsl(
|
|
93
|
-
--popover-foreground: hsl(0 0%
|
|
90
|
+
--card: hsl(0 0% 13%);
|
|
91
|
+
--card-foreground: hsl(0 0% 97.6%);
|
|
92
|
+
--popover: hsl(0 0% 18.8%);
|
|
93
|
+
--popover-foreground: hsl(0 0% 97.6%);
|
|
94
94
|
--primary: hsl(211 100% 58%);
|
|
95
95
|
/* Bright system blue needs dark ink for compact controls (5.59:1). */
|
|
96
96
|
--primary-foreground: hsl(0 0% 9%);
|
|
97
|
-
--secondary: hsl(
|
|
97
|
+
--secondary: hsl(0 0% 17%);
|
|
98
98
|
--secondary-foreground: hsl(0 0% 88%);
|
|
99
|
-
--muted: hsl(
|
|
99
|
+
--muted: hsl(0 0% 8%);
|
|
100
100
|
/* Clears AA on background, card, popover, muted, and accent surfaces. */
|
|
101
|
-
--muted-foreground: hsl(
|
|
101
|
+
--muted-foreground: hsl(0 0% 62%);
|
|
102
102
|
--accent: hsl(211 25% 19%);
|
|
103
103
|
--accent-foreground: hsl(211 100% 72%);
|
|
104
104
|
--destructive: hsl(3 100% 62%);
|
|
@@ -114,25 +114,25 @@
|
|
|
114
114
|
definite edge at one device pixel without becoming the loudest thing on
|
|
115
115
|
the page — which it was once the fills stopped separating surfaces.
|
|
116
116
|
Capped by MAXIMUM_BORDER_GAP in check-preset-contrast.mjs. */
|
|
117
|
-
--border: hsl(
|
|
118
|
-
--input: hsl(
|
|
117
|
+
--border: hsl(0 0% 18%);
|
|
118
|
+
--input: hsl(0 0% 20%);
|
|
119
119
|
/* 0.10, not 0.18. A divider separates rows INSIDE a panel, so it must be
|
|
120
120
|
weaker than that panel's own --border — otherwise the five lines between
|
|
121
121
|
rows shout as loudly as the one frame around them, which is what was
|
|
122
122
|
left of "кондово" after the fills were calmed. Over a 12% card this
|
|
123
123
|
lands at 15.4% (step 3.4) against the border's 18% (step 6.0): a clear
|
|
124
124
|
hierarchy, frame first. */
|
|
125
|
-
--divider: hsl(
|
|
125
|
+
--divider: hsl(0 0% 100% / 0.08);
|
|
126
126
|
--ring: hsl(211 100% 58%);
|
|
127
127
|
--radius: 0.625rem;
|
|
128
128
|
--overlay: hsl(0 0% 0% / 0.55);
|
|
129
|
-
--sidebar-background: hsl(
|
|
130
|
-
--sidebar-foreground: hsl(0 0%
|
|
129
|
+
--sidebar-background: hsl(0 0% 12.5%);
|
|
130
|
+
--sidebar-foreground: hsl(0 0% 97.6%);
|
|
131
131
|
--sidebar-primary: hsl(211 100% 58%);
|
|
132
132
|
--sidebar-primary-foreground: hsl(0 0% 9%);
|
|
133
|
-
--sidebar-accent: hsl(
|
|
134
|
-
--sidebar-accent-foreground: hsl(
|
|
135
|
-
--sidebar-border: hsl(
|
|
133
|
+
--sidebar-accent: hsl(0 0% 19%);
|
|
134
|
+
--sidebar-accent-foreground: hsl(0 0% 97.6%);
|
|
135
|
+
--sidebar-border: hsl(0 0% 18%);
|
|
136
136
|
--sidebar-ring: hsl(211 100% 58%);
|
|
137
137
|
--chart-1: hsl(211 100% 58%);
|
|
138
138
|
--chart-2: hsl(142 52% 50%);
|
|
@@ -14,27 +14,26 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
.dark {
|
|
17
|
-
/*
|
|
18
|
-
*
|
|
19
|
-
* Claude's bg-100 (~14.5%) — NOT near-black; card/popover lift above it so
|
|
17
|
+
/* Neutral surface ladder (hue 0) measured on the Claude app: page 10%,
|
|
18
|
+
* sidebar/card 12.5–13%. Hue 0 on purpose — the earlier warm tint read as brown; card/popover lift above it so
|
|
20
19
|
* panels and menus read as raised. muted recesses below the page.
|
|
21
|
-
* page ≈ bg-100 card ≈ bg-000(18.4%) popover
|
|
22
|
-
--background: hsl(
|
|
23
|
-
--foreground: hsl(
|
|
24
|
-
--card: hsl(
|
|
25
|
-
--card-foreground: hsl(
|
|
26
|
-
--popover: hsl(
|
|
27
|
-
--popover-foreground: hsl(
|
|
20
|
+
* page ≈ bg-100 card ≈ bg-000(18.4%) popover 18.8% (claude.ai menus) */
|
|
21
|
+
--background: hsl(0 0% 10%);
|
|
22
|
+
--foreground: hsl(0 0% 97.6%);
|
|
23
|
+
--card: hsl(0 0% 13%);
|
|
24
|
+
--card-foreground: hsl(0 0% 97.6%);
|
|
25
|
+
--popover: hsl(0 0% 18.8%);
|
|
26
|
+
--popover-foreground: hsl(0 0% 97.6%);
|
|
28
27
|
/* Brand clay #d97757 (Claude). Bright enough on dark chrome to carry the full swatch. */
|
|
29
28
|
--primary: hsl(15 63% 60%);
|
|
30
29
|
/* Dark label on clay — 5.8:1, stronger than white-on-clay (3.1:1) */
|
|
31
30
|
--primary-foreground: hsl(0 0% 9%);
|
|
32
31
|
/* Secondary: elevated warm-gray control surface + light label */
|
|
33
|
-
--secondary: hsl(
|
|
34
|
-
--secondary-foreground: hsl(
|
|
32
|
+
--secondary: hsl(0 0% 17%);
|
|
33
|
+
--secondary-foreground: hsl(0 0% 97.6%);
|
|
35
34
|
/* Recessed surface — below the page (Claude bg-200), for input rest / chips. */
|
|
36
|
-
--muted: hsl(
|
|
37
|
-
--muted-foreground: hsl(
|
|
35
|
+
--muted: hsl(0 0% 8%);
|
|
36
|
+
--muted-foreground: hsl(0 0% 62%);
|
|
38
37
|
/* Neutral interaction surface: a quiet lift above the current dark page. */
|
|
39
38
|
--surface-hover: color-mix(in oklab, var(--card) 96%, var(--foreground));
|
|
40
39
|
/* The counterpart to --surface-hover: a surface that reads as sunk BELOW the
|
|
@@ -50,8 +49,8 @@
|
|
|
50
49
|
* and composites over whatever surface the band happens to sit on. */
|
|
51
50
|
--surface-recessed: rgb(0 0 0 / 0.35);
|
|
52
51
|
/* Hover/active surface (rails, menus, tabs) — a quiet warm lift above the page. */
|
|
53
|
-
--accent: hsl(
|
|
54
|
-
--accent-foreground: hsl(
|
|
52
|
+
--accent: hsl(0 0% 15.5%);
|
|
53
|
+
--accent-foreground: hsl(0 0% 97.6%);
|
|
55
54
|
--destructive: hsl(0 67% 60%);
|
|
56
55
|
--destructive-foreground: hsl(0 0% 98%);
|
|
57
56
|
/* Soft warm border — Claude draws hairlines from a light border at low
|
|
@@ -60,9 +59,9 @@
|
|
|
60
59
|
definite edge at one device pixel without becoming the loudest thing on
|
|
61
60
|
the page — which it was once the fills stopped separating surfaces.
|
|
62
61
|
Capped by MAXIMUM_BORDER_GAP in check-preset-contrast.mjs. */
|
|
63
|
-
--border: hsl(
|
|
62
|
+
--border: hsl(0 0% 18%);
|
|
64
63
|
/* Input surface — a notch above card so fields read as raised controls. */
|
|
65
|
-
--input: hsl(
|
|
64
|
+
--input: hsl(0 0% 17%);
|
|
66
65
|
/* Divider — TRANSLUCENT hairline (Apple-style): a light warm-gray at low alpha
|
|
67
66
|
* that dissolves into whatever sits behind it, so structural separators
|
|
68
67
|
* (columns, headers, rows) stay quiet on the dark page instead of reading as a
|
|
@@ -73,7 +72,7 @@
|
|
|
73
72
|
left of "кондово" after the fills were calmed. Over a 12% card this
|
|
74
73
|
lands at 15.4% (step 3.4) against the border's 18% (step 6.0): a clear
|
|
75
74
|
hierarchy, frame first. */
|
|
76
|
-
--divider: hsl(
|
|
75
|
+
--divider: hsl(0 0% 100% / 0.08);
|
|
77
76
|
/* Overlay — modal scrim / backdrop behind dialogs, drawers, sheets. Black in
|
|
78
77
|
* both themes; slightly darker here so it still reads on the dark page. */
|
|
79
78
|
--overlay: hsl(0 0% 0% / 0.7);
|
|
@@ -94,13 +93,13 @@
|
|
|
94
93
|
/* Sidebar — same warm-gray family as the page (Claude has NO black sidebar
|
|
95
94
|
* block; rail and page share one tone). A hair darker than --background for
|
|
96
95
|
* subtle separation, not a hard black slab. */
|
|
97
|
-
--sidebar-background: hsl(
|
|
98
|
-
--sidebar-foreground: hsl(
|
|
96
|
+
--sidebar-background: hsl(0 0% 12.5%);
|
|
97
|
+
--sidebar-foreground: hsl(0 0% 97.6%);
|
|
99
98
|
--sidebar-primary: hsl(15 63% 60%);
|
|
100
99
|
--sidebar-primary-foreground: hsl(0 0% 9%);
|
|
101
|
-
--sidebar-accent: hsl(
|
|
102
|
-
--sidebar-accent-foreground: hsl(
|
|
103
|
-
--sidebar-border: hsl(
|
|
100
|
+
--sidebar-accent: hsl(0 0% 19%);
|
|
101
|
+
--sidebar-accent-foreground: hsl(0 0% 97.6%);
|
|
102
|
+
--sidebar-border: hsl(0 0% 18%);
|
|
104
103
|
--sidebar-ring: hsl(217 91% 60%);
|
|
105
104
|
/* ── Status surfaces (dark) ──────────────────────────────────────
|
|
106
105
|
* Dark variants: dim backgrounds (~8-12% lightness) so banners
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
*/
|
|
17
17
|
.glass-macos {
|
|
18
18
|
backdrop-filter: blur(20px) saturate(180%);
|
|
19
|
-
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
|
20
19
|
background-color: color-mix(in oklab, var(--background) 72%, transparent);
|
|
21
20
|
}
|
|
22
21
|
|
|
@@ -27,7 +26,6 @@
|
|
|
27
26
|
*/
|
|
28
27
|
.glass-win11 {
|
|
29
28
|
backdrop-filter: blur(60px) saturate(125%);
|
|
30
|
-
-webkit-backdrop-filter: blur(60px) saturate(125%);
|
|
31
29
|
background-color: color-mix(in oklab, var(--background) 85%, transparent);
|
|
32
30
|
}
|
|
33
31
|
|
|
@@ -38,7 +36,6 @@
|
|
|
38
36
|
*/
|
|
39
37
|
.glass-liquid {
|
|
40
38
|
backdrop-filter: blur(12px) saturate(180%);
|
|
41
|
-
-webkit-backdrop-filter: blur(12px) saturate(180%);
|
|
42
39
|
background-color: color-mix(in oklab, var(--card) 60%, transparent);
|
|
43
40
|
border: 1px solid color-mix(in oklab, var(--foreground) 8%, transparent);
|
|
44
41
|
box-shadow:
|
|
@@ -52,7 +49,6 @@
|
|
|
52
49
|
*/
|
|
53
50
|
.glass-header {
|
|
54
51
|
backdrop-filter: blur(8px) saturate(160%);
|
|
55
|
-
-webkit-backdrop-filter: blur(8px) saturate(160%);
|
|
56
52
|
background-color: color-mix(in oklab, var(--background) 80%, transparent);
|
|
57
53
|
}
|
|
58
54
|
|
|
@@ -64,7 +60,6 @@
|
|
|
64
60
|
*/
|
|
65
61
|
.nav-dropdown-glass {
|
|
66
62
|
backdrop-filter: blur(var(--overlay-blur, 5px)) saturate(150%);
|
|
67
|
-
-webkit-backdrop-filter: blur(var(--overlay-blur, 5px)) saturate(150%);
|
|
68
63
|
background-color: color-mix(in oklab, var(--background) 55%, transparent);
|
|
69
64
|
}
|
|
70
65
|
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* override it per theme/preset to make the backdrop crisper or softer, or set
|
|
13
13
|
* it to `0px` to disable frosting entirely without touching the scrim color.
|
|
14
14
|
*
|
|
15
|
+
* Only the unprefixed `backdrop-filter` is written: lightningcss (Tailwind v4, Next) adds `-webkit-` itself, and when both are
|
|
16
|
+
* written it DROPS the unprefixed one (verified), leaving Chromium with no blur at all.
|
|
17
|
+
*
|
|
15
18
|
* Plain CSS (NOT a JIT-scanned Tailwind utility) so it compiles regardless of
|
|
16
19
|
* which package's source the Tailwind content scan covers — same rationale as
|
|
17
20
|
* `divider.css`.
|
|
@@ -20,7 +23,6 @@
|
|
|
20
23
|
.bg-overlay {
|
|
21
24
|
/* `--overlay-blur` defaults in base.css; a 0px value cleanly no-ops the blur. */
|
|
22
25
|
backdrop-filter: blur(var(--overlay-blur, 0px));
|
|
23
|
-
-webkit-backdrop-filter: blur(var(--overlay-blur, 0px));
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
/**
|