@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/README.md
CHANGED
|
@@ -919,7 +919,7 @@ Setup. The package does not bundle the font. `--font-sans` comes from the packag
|
|
|
919
919
|
| Prop | Type | Default | Notes |
|
|
920
920
|
| --- | --- | --- | --- |
|
|
921
921
|
| `app` | `"vibot" \| "vmem" \| "verve" \| "velth" \| "vhub"` | required | `ProductAppName`. |
|
|
922
|
-
| `size` | `number` | `24` | Text font size in px. The icon is `Math.round(size *
|
|
922
|
+
| `size` | `number` | `24` | Text font size in px. The icon is `Math.round(size * 1.4)` px square (visible glyph about 1.3x the cap height). Gap is `0.2em`. |
|
|
923
923
|
| `icon` | `boolean` | `true` | `false` renders text only. |
|
|
924
924
|
| `href` | `string` | none | Root is an `<a>` with a focus ring. Otherwise a `<span>`. |
|
|
925
925
|
| `className` | `string` | none | On the root. |
|
|
@@ -1184,8 +1184,31 @@ import { DatePicker, DateTimePicker, MonthPicker } from "@vvedantb/ui";
|
|
|
1184
1184
|
- `DatePicker`: `value` "YYYY-MM-DD" or "". The trigger matches `TimePicker` and shows the date with `dateStyle: "medium"` (`locale`) or `placeholder` ("Pick a date"). `min` and `max` disable days; `clearable` adds a clear control (`onChange("")`); `name` adds a hidden input. `className` goes on the wrapper, `triggerClassName` merges onto the button (default `w-40`). A width on either sizes the field, and `w-full` on either fills the row; pill inputs can pass `triggerClassName="h-10 w-full rounded-full px-4 text-sm"`. `ariaLabel` names the trigger, followed by the shown text.
|
|
1185
1185
|
- `DateTimePicker`: `value` "YYYY-MM-DDTHH:mm" or "". A date with no time uses `defaultTime` ("00:00"); a time with no date uses today. Only the date part of `min` and `max` applies. `triggerClassName` goes to both triggers, a width only to the date.
|
|
1186
1186
|
- `MonthPicker`: `value` "YYYY-MM" or "", with year navigation and a month grid. `min` and `max` are "YYYY-MM".
|
|
1187
|
+
- `TimePicker`: `value` "HH:mm" (24h) or "". `hourCycle={12}` (0.27.0) shows "02:30 PM" and adds an AM/PM column; `value` stays 24h. Each column opens centred on the selected item.
|
|
1187
1188
|
- `Calendar`: the styled `DayPicker` for custom layouts (every `DayPicker` prop). `parseDateValue` and `formatDateValue` convert between "YYYY-MM-DD" and a local `Date`.
|
|
1188
1189
|
|
|
1190
|
+
## Hooks, media and list motion (0.27.0)
|
|
1191
|
+
|
|
1192
|
+
Generic pieces lifted from an app. Components that use `m` need the app's `LazyMotion`.
|
|
1193
|
+
|
|
1194
|
+
```tsx
|
|
1195
|
+
import {
|
|
1196
|
+
ErrorBoundary, ListEnter, useFirstPaintGate, CountPop, countLabel, UnreadDot,
|
|
1197
|
+
MarqueeOnHover, RelativeDateTime, ImageLightbox, ImageGalleryPreview, ScreenshotPreview, VideoPreview,
|
|
1198
|
+
useMediaQuery, useDocumentVisible, useIdleCallback, useSingleFlight, usePersistedScrollParent,
|
|
1199
|
+
convexErrorMessage, userFacingErrorMessage, useHeldResult,
|
|
1200
|
+
} from "@vvedantb/ui";
|
|
1201
|
+
```
|
|
1202
|
+
|
|
1203
|
+
- `ErrorBoundary` (`fallback` node or `(retry) => node`, `onError`): contains a failure to its subtree; `retry` clears the error.
|
|
1204
|
+
- `ListEnter` (`index`, `fast`, `firstPaint`, `staggerStep`, `staggerMax`, `slide`, `as` `div | tr | li`) and `useFirstPaintGate()`: first-paint fade and 8px slide, staggered with `staggerDelay`. Pass `firstPaint={gate.current}` so recycled rows do not restagger.
|
|
1205
|
+
- `CountPop` (`label`, `className`, `children`): pop-in and shrink-out for a live count, re-popping on each change. `countLabel(n)` gives `null`, the number or `"99+"`. `UnreadDot` (`show`): a `bg-primary` dot on `CountPop`.
|
|
1206
|
+
- `MarqueeOnHover`: a single-line label that scrolls its hidden tail into view on hover (or on hover of a `group` ancestor). Import `@vvedantb/ui/tokens/css/marquee.css`; `--mq-delay` sets the dwell.
|
|
1207
|
+
- `RelativeDateTime` (`at`, `emptyLabel` default "—", `exactFormat` default "DD/MM/YYYY HH:mm"): "15m" / "2d" with the exact time in a tooltip.
|
|
1208
|
+
- `ImageLightbox` (`images`, `index` or `null`, `onIndexChange`, `onClose`): fullscreen viewer with arrow keys, buttons and swipe. `ImageGalleryPreview` (`images`): up to a 2×2 grid with a "+N" tile. `ScreenshotPreview` (`url`, `alt`) and `VideoPreview` (`url`).
|
|
1209
|
+
- Hooks: `useMediaQuery(query)`, `useDocumentVisible()`, `useIdleCallback(ms, fn)` (trailing debounce; `createIdleCallback` is the React-free core), `useSingleFlight(fn)` (one call at a time, latest args run next), `usePersistedScrollParent(storageKey)` (restores a container's `scrollTop` from `sessionStorage`).
|
|
1210
|
+
- Convex: `convexErrorMessage(error, fallback)` reads `ConvexError` data first; `userFacingErrorMessage(error, fallback)` strips the client envelope and stack. `useHeldResult(result, key)` keeps the last result while a query is skipped (`key` `null`). None of these import `convex`.
|
|
1211
|
+
|
|
1189
1212
|
## Slider (0.16.0, dark contrast and className props 0.16.1)
|
|
1190
1213
|
|
|
1191
1214
|
shadcn's slider on `@radix-ui/react-slider` (a peer dependency), with package tokens.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The message to show the user for a failed Convex call.
|
|
3
|
+
*
|
|
4
|
+
* Production Convex redacts plain `Error` messages to "Server Error", so
|
|
5
|
+
* `error.message` alone leaves the user with a request id and no reason. Only
|
|
6
|
+
* `ConvexError` data crosses the wire intact — read that first. Matched by
|
|
7
|
+
* `name` rather than `instanceof`, so this module does not import `convex`.
|
|
8
|
+
*/
|
|
9
|
+
export function convexErrorMessage(error: unknown, fallback: string): string {
|
|
10
|
+
if (
|
|
11
|
+
error instanceof Error &&
|
|
12
|
+
error.name === "ConvexError" &&
|
|
13
|
+
"data" in error &&
|
|
14
|
+
typeof error.data === "string"
|
|
15
|
+
) {
|
|
16
|
+
return error.data;
|
|
17
|
+
}
|
|
18
|
+
return error instanceof Error ? error.message : fallback;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const UNCAUGHT = "Uncaught Error:";
|
|
22
|
+
const PLAIN = "Error:";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The same failure, stripped of everything the Convex client wraps around it.
|
|
26
|
+
*
|
|
27
|
+
* A client-side rejection reads
|
|
28
|
+
* `[CONVEX M(teams:create)] [Request ID: abc] Server Error\nUncaught Error: Team
|
|
29
|
+
* name is required\n at handler (../convex/teams.ts:107:12)`. Shown verbatim
|
|
30
|
+
* in a form's error slot that is a stack trace with the sentence buried in the
|
|
31
|
+
* middle, so take the text the handler actually threw: after the last
|
|
32
|
+
* `Uncaught Error:` / `Error:`, up to the newline or the stack frame.
|
|
33
|
+
*
|
|
34
|
+
* `error` is deliberately not `unknown` — callers catch `unknown` and narrow at
|
|
35
|
+
* the boundary with `err instanceof Error ? err : null`.
|
|
36
|
+
*/
|
|
37
|
+
export function userFacingErrorMessage(
|
|
38
|
+
error: Error | string | null | undefined,
|
|
39
|
+
fallback: string,
|
|
40
|
+
): string {
|
|
41
|
+
const raw = typeof error === "string" ? error : (error?.message ?? "");
|
|
42
|
+
|
|
43
|
+
let start = 0;
|
|
44
|
+
const uncaught = raw.lastIndexOf(UNCAUGHT);
|
|
45
|
+
if (uncaught !== -1) {
|
|
46
|
+
start = uncaught + UNCAUGHT.length;
|
|
47
|
+
} else {
|
|
48
|
+
const plain = raw.lastIndexOf(PLAIN);
|
|
49
|
+
if (plain !== -1) start = plain + PLAIN.length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let text = raw.slice(start);
|
|
53
|
+
const newline = text.indexOf("\n");
|
|
54
|
+
if (newline !== -1) text = text.slice(0, newline);
|
|
55
|
+
const frame = text.indexOf(" at ");
|
|
56
|
+
if (frame !== -1) text = text.slice(0, frame);
|
|
57
|
+
text = text.trim();
|
|
58
|
+
|
|
59
|
+
// Nothing survived the strip, or the whole thing was the client's envelope.
|
|
60
|
+
if (text.length === 0 || text.startsWith("[CONVEX")) return fallback;
|
|
61
|
+
return text;
|
|
62
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keeps the last loaded `result` while a query is skipped, instead of
|
|
5
|
+
* flipping to `undefined`. Pair it with any `useQuery`:
|
|
6
|
+
*
|
|
7
|
+
* const result = useQuery(api.x.get, args);
|
|
8
|
+
* const shown = useHeldResult(result, args === "skip" ? null : JSON.stringify(args));
|
|
9
|
+
*
|
|
10
|
+
* `key` names what the result is for, and `null` means skipped. While skipped
|
|
11
|
+
* the last result stays (so a hidden pane that pauses its subscription does
|
|
12
|
+
* not flash a spinner when shown again). A new non-null key loads as
|
|
13
|
+
* `undefined` rather than showing the previous key's data; pass a stable key
|
|
14
|
+
* on purpose to keep the last result across argument changes.
|
|
15
|
+
*/
|
|
16
|
+
export function useHeldResult<T>(result: T | undefined, key: string | null): T | undefined {
|
|
17
|
+
// State, not a ref: reading a ref during render makes the React Compiler
|
|
18
|
+
// skip the file. Adjusting state during render is the supported way to
|
|
19
|
+
// remember a previous value.
|
|
20
|
+
const [held, setHeld] = useState({ key, value: result });
|
|
21
|
+
if (result !== undefined && (result !== held.value || key !== held.key)) {
|
|
22
|
+
setHeld({ key, value: result });
|
|
23
|
+
}
|
|
24
|
+
if (result !== undefined) return result;
|
|
25
|
+
return key === null || key === held.key ? held.value : undefined;
|
|
26
|
+
}
|
package/app/src/index.ts
CHANGED
|
@@ -22,11 +22,17 @@ export {
|
|
|
22
22
|
type UpdateToastOptions,
|
|
23
23
|
} from "./update/updateWatcher.js";
|
|
24
24
|
|
|
25
|
-
export {
|
|
25
|
+
export {
|
|
26
|
+
SettingsSection,
|
|
27
|
+
type SettingsSectionConfig,
|
|
28
|
+
} from "./settings/SettingsSection.js";
|
|
26
29
|
export { SettingsField } from "./settings/SettingsField.js";
|
|
27
30
|
export { SettingsStack } from "./settings/SettingsStack.js";
|
|
28
31
|
export { SettingsToggleRow } from "./settings/SettingsToggleRow.js";
|
|
29
|
-
export {
|
|
32
|
+
export {
|
|
33
|
+
SettingsEmptyState,
|
|
34
|
+
type SettingsEmptyStateConfig,
|
|
35
|
+
} from "./settings/SettingsEmptyState.js";
|
|
30
36
|
export {
|
|
31
37
|
DefaultSettingsLayout,
|
|
32
38
|
SettingsPage,
|
|
@@ -89,3 +95,5 @@ export {
|
|
|
89
95
|
type ViteEnvName,
|
|
90
96
|
} from "./convex/env.js";
|
|
91
97
|
export { useEnsureUser, type EnsureUserMutation } from "./convex/useEnsureUser.js";
|
|
98
|
+
export { convexErrorMessage, userFacingErrorMessage } from "./convex/errorMessage.js";
|
|
99
|
+
export { useHeldResult } from "./convex/useHeldResult.js";
|
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import type { ComponentType, ReactNode } from "react";
|
|
2
|
+
import { useUiConfig } from "../../../src/ui/ui-config";
|
|
3
|
+
|
|
4
|
+
const ICON_CLASS = "mb-5 text-muted-foreground opacity-50";
|
|
5
|
+
|
|
6
|
+
/** `UiConfig.SettingsEmptyState`: replaces the built-in classes of a part. */
|
|
7
|
+
export interface SettingsEmptyStateConfig {
|
|
8
|
+
classes?: {
|
|
9
|
+
/** The icon above the title (colour, opacity, spacing). */
|
|
10
|
+
icon?: string;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
2
13
|
|
|
3
14
|
interface SettingsEmptyStateProps {
|
|
4
15
|
/** Tabler icon component, rendered muted above the copy. */
|
|
@@ -23,9 +34,11 @@ export function SettingsEmptyState({
|
|
|
23
34
|
description,
|
|
24
35
|
action,
|
|
25
36
|
}: SettingsEmptyStateProps) {
|
|
37
|
+
const iconClass =
|
|
38
|
+
useUiConfig("SettingsEmptyState")?.classes?.icon ?? ICON_CLASS;
|
|
26
39
|
return (
|
|
27
40
|
<div className="flex flex-col items-center justify-center px-4 py-16 text-center">
|
|
28
|
-
<Icon size={28} className=
|
|
41
|
+
<Icon size={28} className={iconClass} />
|
|
29
42
|
<h4 className="text-sm font-medium text-foreground">{title}</h4>
|
|
30
43
|
{description ? (
|
|
31
44
|
<p className="mt-2 max-w-sm text-xs leading-relaxed text-muted-foreground">
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useCn, useUiConfig } from "../../../src/ui/ui-config";
|
|
4
4
|
|
|
5
5
|
type SettingsSectionBodyVariant = "form" | "list" | "compact";
|
|
6
6
|
|
|
@@ -10,6 +10,23 @@ const BODY_VARIANT_CLASS: Record<SettingsSectionBodyVariant, string> = {
|
|
|
10
10
|
compact: "px-4 py-3",
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
+
const CARD_CLASS =
|
|
14
|
+
"rounded-lg bg-surface-card [&_input]:bg-field-background [&_textarea]:bg-field-background [&_[data-slot=select-trigger]]:bg-field-background [&_[role=combobox]]:border-border [&_[role=combobox]]:bg-field-background";
|
|
15
|
+
const FOOTER_CLASS =
|
|
16
|
+
"flex items-center justify-end gap-2 rounded-b-lg bg-surface-secondary px-4 py-3";
|
|
17
|
+
|
|
18
|
+
/** `UiConfig.SettingsSection`: replaces the built-in classes of a part. */
|
|
19
|
+
export interface SettingsSectionConfig {
|
|
20
|
+
classes?: {
|
|
21
|
+
/** The card behind the body and footer (tone, radius, nested field fills). */
|
|
22
|
+
card?: string;
|
|
23
|
+
/** The bottom bar that holds `footer`. */
|
|
24
|
+
footer?: string;
|
|
25
|
+
/** Body padding per `bodyVariant`. */
|
|
26
|
+
body?: Partial<Record<SettingsSectionBodyVariant, string>>;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
13
30
|
interface SettingsSectionProps {
|
|
14
31
|
/** Section heading. Kept short — the description carries the detail. */
|
|
15
32
|
title: ReactNode;
|
|
@@ -49,6 +66,9 @@ export function SettingsSection({
|
|
|
49
66
|
bodyClassName,
|
|
50
67
|
className,
|
|
51
68
|
}: SettingsSectionProps) {
|
|
69
|
+
const config = useUiConfig("SettingsSection");
|
|
70
|
+
const cn = useCn();
|
|
71
|
+
const classes = config?.classes;
|
|
52
72
|
const hasBody = children != null;
|
|
53
73
|
const hasCard = hasBody || footer != null;
|
|
54
74
|
|
|
@@ -71,17 +91,22 @@ export function SettingsSection({
|
|
|
71
91
|
{hasCard ? (
|
|
72
92
|
<div
|
|
73
93
|
className={cn(
|
|
74
|
-
|
|
94
|
+
classes?.card ?? CARD_CLASS,
|
|
75
95
|
bodyVariant === "list" && "overflow-hidden",
|
|
76
96
|
)}
|
|
77
97
|
>
|
|
78
98
|
{hasBody ? (
|
|
79
|
-
<div
|
|
99
|
+
<div
|
|
100
|
+
className={cn(
|
|
101
|
+
classes?.body?.[bodyVariant] ?? BODY_VARIANT_CLASS[bodyVariant],
|
|
102
|
+
bodyClassName,
|
|
103
|
+
)}
|
|
104
|
+
>
|
|
80
105
|
{children}
|
|
81
106
|
</div>
|
|
82
107
|
) : null}
|
|
83
108
|
{footer ? (
|
|
84
|
-
<div className=
|
|
109
|
+
<div className={classes?.footer ?? FOOTER_CLASS}>
|
|
85
110
|
{footer}
|
|
86
111
|
</div>
|
|
87
112
|
) : null}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Shared React 19 UI for web apps: one JavaScript entry (primitives, shell pieces, app helpers including the Convex and Clerk provider, VITE env parsing and ensure-user, artifact viewers over the @vvedantb/server artifacts core, native tokens), design-token CSS, shared Tailwind v4 CSS and a Tailwind preset.",
|
|
6
6
|
"type": "module",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"./tokens/css/compat-shadcn-rgb.css": "./tokens/src/css/compat-shadcn-rgb.css",
|
|
35
35
|
"./tokens/css/border-beam.css": "./tokens/src/css/border-beam.css",
|
|
36
36
|
"./tokens/css/markdown.css": "./tokens/src/css/markdown.css",
|
|
37
|
+
"./tokens/css/marquee.css": "./tokens/src/css/marquee.css",
|
|
37
38
|
"./tokens/compat/shadcn-rgb.css": "./tokens/src/css/compat-shadcn-rgb.css",
|
|
38
39
|
"./tokens/css/*.css": "./tokens/src/css/*.css",
|
|
39
40
|
"./tokens/tailwind-preset": {
|
|
@@ -9,7 +9,7 @@ export type ProductAppName = Exclude<AppName, "projectv">;
|
|
|
9
9
|
export type AppWordmarkProps = Pick<AnchorHTMLAttributes<HTMLAnchorElement>, "target" | "rel" | "aria-label" | "title" | "id"> & {
|
|
10
10
|
/** Which product app's name to show. The label is always the lowercase app id. */
|
|
11
11
|
app: ProductAppName;
|
|
12
|
-
/** Text font size in px (default 24). The icon is `Math.round(size *
|
|
12
|
+
/** Text font size in px (default 24). The icon is `Math.round(size * 1.4)` px square (larger than the text, so the glyph overhangs the cap height) and the gap is `0.2em`. */
|
|
13
13
|
size?: number;
|
|
14
14
|
/** `false` renders the name only (default `true`). */
|
|
15
15
|
icon?: boolean;
|
|
@@ -18,8 +18,12 @@ export type AppWordmarkProps = Pick<AnchorHTMLAttributes<HTMLAnchorElement>, "ta
|
|
|
18
18
|
className?: string;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Icon frame size relative to the text font size. The glyphs fill about 55-80% of their square frame, so 1.4 makes the
|
|
23
|
+
* visible glyph about 1.3x the Instrument Sans 500 cap height (0.72em): a slight overhang above and below the letters.
|
|
24
|
+
* Stroke glyphs may read light next to weight-500 text. That is an observation only: stroke widths are unchanged.
|
|
25
|
+
*/
|
|
26
|
+
const ICON_SCALE = 1.4;
|
|
23
27
|
|
|
24
28
|
const FOCUS_RING =
|
|
25
29
|
"rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background";
|
|
@@ -32,8 +36,8 @@ const FOCUS_RING =
|
|
|
32
36
|
export function AppWordmark({ app, size = 24, icon = true, href, className, ...rest }: AppWordmarkProps) {
|
|
33
37
|
const Icon = APP_ICONS[app];
|
|
34
38
|
const classes = cn("inline-flex flex-row items-center text-foreground", href && FOCUS_RING, className);
|
|
35
|
-
// Font size sits on the root so the `0.
|
|
36
|
-
const style = { fontSize: size, gap: "0.
|
|
39
|
+
// Font size sits on the root so the `0.2em` gap tracks the text size. The text node inherits it.
|
|
40
|
+
const style = { fontSize: size, gap: "0.2em" };
|
|
37
41
|
const content = (
|
|
38
42
|
<>
|
|
39
43
|
{icon ? <Icon size={Math.round(size * ICON_SCALE)} /> : null}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { useMediaQuery } from "./use-media-query";
|
|
2
|
+
export { useDocumentVisible } from "./use-document-visible";
|
|
3
|
+
export { createIdleCallback, useIdleCallback } from "./use-idle-callback";
|
|
4
|
+
export { useSingleFlight } from "./use-single-flight";
|
|
5
|
+
export { usePersistedScrollParent } from "./use-persisted-scroll-parent";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
function subscribe(onStoreChange: () => void) {
|
|
4
|
+
document.addEventListener("visibilitychange", onStoreChange);
|
|
5
|
+
return () => document.removeEventListener("visibilitychange", onStoreChange);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** True while the tab is visible. Server snapshot is `true`, so SSR renders as visible. */
|
|
9
|
+
export function useDocumentVisible(): boolean {
|
|
10
|
+
return useSyncExternalStore(
|
|
11
|
+
subscribe,
|
|
12
|
+
() => document.visibilityState === "visible",
|
|
13
|
+
() => true,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
|
|
3
|
+
/** Anything worth passing through a debounced callback. */
|
|
4
|
+
type CallbackArgs = ReadonlyArray<string | number | boolean | null | object>;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The debounce itself, free of React so it can be tested with fake timers.
|
|
8
|
+
* `getFn` is read at fire time rather than captured, so the caller always runs
|
|
9
|
+
* the latest render's closure.
|
|
10
|
+
*/
|
|
11
|
+
export function createIdleCallback<Args extends CallbackArgs>(
|
|
12
|
+
ms: number,
|
|
13
|
+
getFn: () => (...args: Args) => void,
|
|
14
|
+
): (...args: Args) => void {
|
|
15
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
16
|
+
return (...args: Args) => {
|
|
17
|
+
if (timer !== null) clearTimeout(timer);
|
|
18
|
+
timer = setTimeout(() => {
|
|
19
|
+
timer = null;
|
|
20
|
+
getFn()(...args);
|
|
21
|
+
}, ms);
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Returns a stable callback that runs `fn` once `ms` has passed since the last
|
|
27
|
+
* call; each call cancels the pending one. Trailing edge only — nothing fires
|
|
28
|
+
* until the caller goes quiet.
|
|
29
|
+
*/
|
|
30
|
+
export function useIdleCallback<Args extends CallbackArgs>(
|
|
31
|
+
ms: number,
|
|
32
|
+
fn: (...args: Args) => void,
|
|
33
|
+
): (...args: Args) => void {
|
|
34
|
+
// No refs and no direct writes to state: the React Compiler skips a file
|
|
35
|
+
// that does either during render. The holder keeps the latest `fn` in a
|
|
36
|
+
// closure; the caller stores it on each call (always an event handler
|
|
37
|
+
// holding the current render's closure), and the timer reads it on fire.
|
|
38
|
+
const [idle] = useState(() => {
|
|
39
|
+
let current = fn;
|
|
40
|
+
return {
|
|
41
|
+
setFn: (next: (...args: Args) => void) => {
|
|
42
|
+
current = next;
|
|
43
|
+
},
|
|
44
|
+
run: createIdleCallback<Args>(ms, () => current),
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
return (...args: Args) => {
|
|
48
|
+
idle.setFn(fn);
|
|
49
|
+
idle.run(...args);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Subscribe to a CSS media query without setState-in-effect.
|
|
5
|
+
* `useSyncExternalStore` is the React 18+ recommended pattern for browser APIs.
|
|
6
|
+
*/
|
|
7
|
+
export function useMediaQuery(query: string): boolean {
|
|
8
|
+
return useSyncExternalStore(
|
|
9
|
+
(onStoreChange) => {
|
|
10
|
+
const mql = window.matchMedia(query);
|
|
11
|
+
mql.addEventListener("change", onStoreChange);
|
|
12
|
+
return () => mql.removeEventListener("change", onStoreChange);
|
|
13
|
+
},
|
|
14
|
+
() => window.matchMedia(query).matches,
|
|
15
|
+
() => false,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useLayoutEffect, useState, type RefCallback } from "react";
|
|
2
|
+
|
|
3
|
+
/** Re-apply after a virtualised list measures; first paint often resets scrollTop. */
|
|
4
|
+
function applyScrollTop(el: HTMLElement, scrollTop: number): void {
|
|
5
|
+
el.scrollTop = scrollTop;
|
|
6
|
+
requestAnimationFrame(() => {
|
|
7
|
+
el.scrollTop = scrollTop;
|
|
8
|
+
requestAnimationFrame(() => {
|
|
9
|
+
el.scrollTop = scrollTop;
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readScrollTop(key: string): number {
|
|
15
|
+
try {
|
|
16
|
+
// Stored as JSON; a JSON number is also a plain numeric string.
|
|
17
|
+
const value = Number(sessionStorage.getItem(key) ?? 0);
|
|
18
|
+
return Number.isFinite(value) ? value : 0;
|
|
19
|
+
} catch {
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** JSON, so a value written by an older `useSessionStorage` reads back. */
|
|
25
|
+
function writeScrollTop(key: string, scrollTop: number): void {
|
|
26
|
+
try {
|
|
27
|
+
sessionStorage.setItem(key, JSON.stringify(Math.round(scrollTop)));
|
|
28
|
+
} catch {
|
|
29
|
+
// Ignore quota / private-mode failures.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Scroll-container ref that saves `scrollTop` to `sessionStorage` under
|
|
35
|
+
* `storageKey` and restores it when the container mounts again, so a list keeps
|
|
36
|
+
* its place when a route swaps it out for a detail page and back. Per tab:
|
|
37
|
+
* closing the tab clears it. Writes are throttled to one per frame and do not
|
|
38
|
+
* re-render.
|
|
39
|
+
*/
|
|
40
|
+
export function usePersistedScrollParent(storageKey: string): {
|
|
41
|
+
scrollParent: HTMLDivElement | null;
|
|
42
|
+
scrollRef: RefCallback<HTMLDivElement>;
|
|
43
|
+
} {
|
|
44
|
+
const [scrollParent, scrollRef] = useState<HTMLDivElement | null>(null);
|
|
45
|
+
|
|
46
|
+
useLayoutEffect(() => {
|
|
47
|
+
if (!scrollParent) return;
|
|
48
|
+
|
|
49
|
+
const initial = readScrollTop(storageKey);
|
|
50
|
+
if (initial > 0) applyScrollTop(scrollParent, initial);
|
|
51
|
+
|
|
52
|
+
let rafId = 0;
|
|
53
|
+
const onScroll = () => {
|
|
54
|
+
if (rafId !== 0) return;
|
|
55
|
+
rafId = requestAnimationFrame(() => {
|
|
56
|
+
rafId = 0;
|
|
57
|
+
writeScrollTop(storageKey, scrollParent.scrollTop);
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
scrollParent.addEventListener("scroll", onScroll, { passive: true });
|
|
62
|
+
return () => {
|
|
63
|
+
if (rafId !== 0) cancelAnimationFrame(rafId);
|
|
64
|
+
writeScrollTop(storageKey, scrollParent.scrollTop);
|
|
65
|
+
scrollParent.removeEventListener("scroll", onScroll);
|
|
66
|
+
};
|
|
67
|
+
}, [scrollParent, storageKey]);
|
|
68
|
+
|
|
69
|
+
return { scrollParent, scrollRef };
|
|
70
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
2
|
+
|
|
3
|
+
type NoPending = { has: false };
|
|
4
|
+
type HasPending<TArgs> = { has: true; args: TArgs };
|
|
5
|
+
type Pending<TArgs> = NoPending | HasPending<TArgs>;
|
|
6
|
+
|
|
7
|
+
const NO_PENDING: NoPending = { has: false };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Wraps an async function so at most one invocation runs at a time.
|
|
11
|
+
* While a call is in flight, subsequent calls are coalesced — only the
|
|
12
|
+
* latest args are kept, and they run once the current invocation settles.
|
|
13
|
+
*
|
|
14
|
+
* Designed for autosave-on-keystroke: prevents stampeding mutations when
|
|
15
|
+
* the user types quickly, while ensuring the final state is always saved.
|
|
16
|
+
*/
|
|
17
|
+
export function useSingleFlight<TArgs, TRet>(
|
|
18
|
+
fn: (args: TArgs) => Promise<TRet>,
|
|
19
|
+
): (args: TArgs) => Promise<TRet | undefined> {
|
|
20
|
+
const inFlight = useRef(false);
|
|
21
|
+
const pending = useRef<Pending<TArgs>>(NO_PENDING);
|
|
22
|
+
|
|
23
|
+
const invoke = async (args: TArgs): Promise<TRet | undefined> => {
|
|
24
|
+
const runPending = (): void => {
|
|
25
|
+
const next: Pending<TArgs> = pending.current;
|
|
26
|
+
pending.current = NO_PENDING;
|
|
27
|
+
if (next.has) {
|
|
28
|
+
// Recurse with the latest coalesced args — return value discarded
|
|
29
|
+
// intentionally since the original caller already received undefined.
|
|
30
|
+
void invoke(next.args);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
if (inFlight.current) {
|
|
35
|
+
pending.current = { has: true, args };
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
inFlight.current = true;
|
|
40
|
+
pending.current = NO_PENDING;
|
|
41
|
+
|
|
42
|
+
// Avoid `finally` here: TypeScript narrows discriminated unions to `never`
|
|
43
|
+
// inside `finally` blocks when the try body is async, making `next.args`
|
|
44
|
+
// inaccessible. Use explicit try/catch instead.
|
|
45
|
+
let result: TRet | undefined;
|
|
46
|
+
try {
|
|
47
|
+
result = await fn(args);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
inFlight.current = false;
|
|
50
|
+
runPending();
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
inFlight.current = false;
|
|
54
|
+
runPending();
|
|
55
|
+
return result;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return invoke;
|
|
59
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,8 @@ export { fieldFocusClass, fieldFocusWithinClass } from "./ui/_field-classes";
|
|
|
8
8
|
export * from "./ui/surface-classes";
|
|
9
9
|
export * from "./motion/presets";
|
|
10
10
|
export * from "./motion/panel-transition";
|
|
11
|
+
export { ListEnter, useFirstPaintGate } from "./motion/list-enter";
|
|
12
|
+
export type { ListEnterProps } from "./motion/list-enter";
|
|
11
13
|
// Gesture physics and animation budget helpers (shared clock, on-screen and
|
|
12
14
|
// page-visibility pausing for infinite animations).
|
|
13
15
|
export { DRAG_ACTIVATION_DISTANCE_PX, projectVelocity, rubberband } from "./utils/gesture";
|
|
@@ -22,6 +24,18 @@ export { CAT_SLOT_COUNT, catColorForId, catColorForSlot, catSlotForId } from "./
|
|
|
22
24
|
export type { CatColorClasses, CatSlot } from "./utils/cat-color";
|
|
23
25
|
export { CONTROL_RADIUS_CLASS, SURFACE_RADIUS_CLASS } from "./utils/surface-radius";
|
|
24
26
|
export { restoreModalScrollLock } from "./ui/overlay-a11y";
|
|
27
|
+
// Generic hooks: media query, page visibility, trailing debounce,
|
|
28
|
+
// single-flight async calls and a persisted scroll container.
|
|
29
|
+
export * from "./hooks";
|
|
30
|
+
export { ErrorBoundary } from "./ui/error-boundary";
|
|
31
|
+
export type { ErrorBoundaryProps } from "./ui/error-boundary";
|
|
32
|
+
export { CountPop, countLabel, UnreadDot } from "./ui/count-pop";
|
|
33
|
+
export { MarqueeOnHover } from "./ui/marquee-on-hover";
|
|
34
|
+
export { RelativeDateTime } from "./ui/relative-date-time";
|
|
35
|
+
export type { RelativeDateTimeProps } from "./ui/relative-date-time";
|
|
36
|
+
export { ImageLightbox } from "./ui/image-lightbox";
|
|
37
|
+
export type { GalleryImage } from "./ui/image-lightbox";
|
|
38
|
+
export { ImageGalleryPreview, ScreenshotPreview, VideoPreview } from "./ui/media-preview";
|
|
25
39
|
|
|
26
40
|
export * from "./ui/avatar";
|
|
27
41
|
export * from "./ui/badge";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useEffect, useRef, type ReactNode } from "react";
|
|
2
|
+
import { m } from "motion/react";
|
|
3
|
+
import { staggerDelay, tokenTransition } from "./presets";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `true` until the list has painted once. Pass `firstPaint.current` to
|
|
7
|
+
* `ListEnter` so a virtualised list's recycled rows (and later overscan) do
|
|
8
|
+
* not restagger. Stable keys already skip a remount on a data refresh.
|
|
9
|
+
*/
|
|
10
|
+
export function useFirstPaintGate() {
|
|
11
|
+
const firstPaint = useRef(true);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
firstPaint.current = false;
|
|
14
|
+
}, []);
|
|
15
|
+
return firstPaint;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ListEnterProps {
|
|
19
|
+
index: number;
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
className?: string;
|
|
22
|
+
/** `tokenTransition.fast` instead of `.base`. */
|
|
23
|
+
fast?: boolean;
|
|
24
|
+
/** When `false`, skip the enter (recycled rows, later overscan). */
|
|
25
|
+
firstPaint?: boolean;
|
|
26
|
+
staggerStep?: number;
|
|
27
|
+
staggerMax?: number;
|
|
28
|
+
/** Fade only, for rows that must not shift (e.g. gantt bars). Default `true`. */
|
|
29
|
+
slide?: boolean;
|
|
30
|
+
/** `tr` / `li` when the parent is a table or list; an `m.div` there is invalid HTML. */
|
|
31
|
+
as?: "div" | "tr" | "li";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* First-paint fade and 8px slide for a list item, staggered by `index`.
|
|
36
|
+
* Uses `m`, so the app must render it inside `LazyMotion`.
|
|
37
|
+
*/
|
|
38
|
+
export function ListEnter({
|
|
39
|
+
index,
|
|
40
|
+
children,
|
|
41
|
+
className,
|
|
42
|
+
fast = false,
|
|
43
|
+
firstPaint,
|
|
44
|
+
staggerStep,
|
|
45
|
+
staggerMax,
|
|
46
|
+
slide = true,
|
|
47
|
+
as = "div",
|
|
48
|
+
}: ListEnterProps) {
|
|
49
|
+
const skip = firstPaint === false;
|
|
50
|
+
const transition = fast ? tokenTransition.fast : tokenTransition.base;
|
|
51
|
+
const MotionTag = as === "tr" ? m.tr : as === "li" ? m.li : m.div;
|
|
52
|
+
return (
|
|
53
|
+
<MotionTag
|
|
54
|
+
className={className}
|
|
55
|
+
initial={skip ? false : { opacity: 0, y: slide ? 8 : 0 }}
|
|
56
|
+
animate={{ opacity: 1, y: 0 }}
|
|
57
|
+
transition={{
|
|
58
|
+
...transition,
|
|
59
|
+
delay: skip ? 0 : staggerDelay(index, staggerStep, staggerMax),
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
{children}
|
|
63
|
+
</MotionTag>
|
|
64
|
+
);
|
|
65
|
+
}
|