@vvedantb/ui 0.30.0 → 0.30.2
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 +10 -4
- package/package.json +1 -1
- package/src/client.ts +7 -1
- package/src/hooks/index.ts +7 -0
- package/src/hooks/use-count-up-display.ts +80 -0
- package/src/hooks/use-document-appearance.ts +46 -0
- package/src/index.ts +7 -1
- package/src/markdown/mermaid.ts +7 -12
- package/src/motion/motion-provider.tsx +29 -0
- package/src/motion/panel-transition.tsx +39 -0
- package/src/ui/bulk-action-bar.tsx +229 -0
- package/src/ui/clearInputDissolve.ts +3 -8
- package/src/ui/copy-link-menu-item.tsx +29 -0
- package/src/ui/count-pop.tsx +18 -0
- package/src/ui/toggle-search.tsx +138 -0
package/README.md
CHANGED
|
@@ -1196,9 +1196,10 @@ Generic pieces lifted from an app. Components that use `m` need the app's `LazyM
|
|
|
1196
1196
|
|
|
1197
1197
|
```tsx
|
|
1198
1198
|
import {
|
|
1199
|
-
ErrorBoundary, ListEnter, useFirstPaintGate, CountPop, countLabel, UnreadDot,
|
|
1199
|
+
ErrorBoundary, ListEnter, useFirstPaintGate, CountPop, countLabel, UnreadDot, RollingNumber,
|
|
1200
1200
|
MarqueeOnHover, RelativeDateTime, ImageLightbox, ImageGalleryPreview, ScreenshotPreview, VideoPreview,
|
|
1201
|
-
useMediaQuery, useDocumentVisible, useIdleCallback, useSingleFlight, usePersistedScrollParent,
|
|
1201
|
+
useMediaQuery, useDocumentVisible, useDocumentAppearance, useIdleCallback, useSingleFlight, usePersistedScrollParent,
|
|
1202
|
+
useCountUpDisplay, parseCountable,
|
|
1202
1203
|
convexErrorMessage, userFacingErrorMessage, useHeldResult,
|
|
1203
1204
|
} from "@vvedantb/ui";
|
|
1204
1205
|
```
|
|
@@ -1206,12 +1207,16 @@ import {
|
|
|
1206
1207
|
- `ErrorBoundary` (`fallback` node or `(retry) => node`, `onError`): contains a failure to its subtree; `retry` clears the error.
|
|
1207
1208
|
- `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.
|
|
1208
1209
|
- `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`.
|
|
1210
|
+
- `RollingNumber` (`value`, `className`): shows an already formatted string ("1m 05s", "42%", "$1.20") and rolls each digit to its new value; other characters swap at once. Columns are keyed from the right, so "9s" → "10s" mounts only the new column. The full value stays in an `sr-only` span. For numbers that tick while the user watches; `CountPop` is for a badge that changes while the user looks elsewhere.
|
|
1209
1211
|
- `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.
|
|
1210
1212
|
- `RelativeDateTime` (`at`, `emptyLabel` default "—", `exactFormat` default "DD/MM/YYYY HH:mm"): "15m" / "2d" with the exact time in a tooltip.
|
|
1211
1213
|
- `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`).
|
|
1212
|
-
- 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`).
|
|
1214
|
+
- Hooks: `useMediaQuery(query)`, `useDocumentVisible()`, `useDocumentAppearance()` (`"light" | "dark"` from `<html>`'s `dark` class or `data-theme`; `readDocumentAppearance` / `subscribeDocumentAppearance` are the React-free core), `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`).
|
|
1215
|
+
- `useCountUpDisplay(value)`: counts a number, or a label with one number ("45%", "12h", "$1.20"), from the last value to the new one over `tokenTransition.base`, keeping the prefix, suffix and decimal places. Where `RollingNumber` rolls each digit, this counts through the numbers between. The first value counts up from 0, and the same amount again does not replay. Labels it cannot count ("1h 12m", "—") show as they are, and so does every value while `PageMotionProvider` is `disabled`. `parseCountable(value)` is the parser: `{ amount, prefix, suffix, decimals }` or `null`.
|
|
1213
1216
|
- 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`.
|
|
1214
1217
|
|
|
1218
|
+
Fixture for `RollingNumber`, `SendQueueIcon`, `PromptInputSubmit` `queued`, the `ActivityTasks` clock and `useCountUpDisplay`: `examples/motion-primitives/`.
|
|
1219
|
+
|
|
1215
1220
|
## Chrome extensions (0.28.0)
|
|
1216
1221
|
|
|
1217
1222
|
Extension pages (popup, side panel, options, welcome) run under the MV3 extension-page CSP, which blocks remote font stylesheets, and should not load `@vvedantb/server`. Three pieces cover them. Web apps change nothing unless they opt in.
|
|
@@ -1497,6 +1502,7 @@ const config = {
|
|
|
1497
1502
|
Outside a `StickToBottom` (for example a page outline) use `JumpRail` with `items` (`{ id, title, preview?, level? }`), a `scrollRef` or `getScrollElement`, and `targetAttribute="data-toc-id"` or `resolveTarget`. `onReveal` runs when a target is not in the DOM; `onBeforeScroll` runs just before it. `onSelect(id)` runs after a click or Enter/Space pick, once the scroll has started or `onReveal` has run, so an app can move focus to the target for keyboard users; `ChatJumpRail` accepts it too. `observeKey` re-observes when the rendered window changes. The geometry helpers (`resolveJumpRailTopPercent`, `resolveJumpRailIndexFromPointer`, `resolveJumpRailHeightStyle`, `resolveJumpRailTickWidthClass`, `resolveJumpRailTickLevelStyle`) are exported.
|
|
1498
1503
|
- `Message`: `classes.root`, `classes.user`, `classes.assistant` (the role classes of `Message`), `classes.content` (all `MessageContent` classes), `classes.branchSelector`, `classes.branchIcon` (a class in place of the arrows' `size={14}`), and `lazyActionTooltips`: a `MessageAction` mounts its tooltip on first hover or focus (focus opens it), not with the button.
|
|
1499
1504
|
- `PromptInput`: `classes.group` (the input group, default `overflow-hidden`), `classes.groupIdle` / `classes.groupDragOver` (added while no drag / an accepted drag is over the form), `classes.groupDefault` (added when no `containerClassName` is given), `classes.textarea`, `classes.footer`, `classes.submit`, `classes.tabItem`; `submitIcons.<send|submitted|streaming|error>`, `submitCrossfade` (cross-fade the submit icon on status change) and `menuTriggerAsChild` (`PromptInputActionMenuTrigger` becomes the Radix trigger instead of a button inside it). New `PromptInput` props: `dataDrop` (`{ type, onDrop }`: a non-file drag of that MIME type counts as a drag over the form and `onDrop` gets its string payload) and `inputGroupClassName` (same as `containerClassName`). The attachments context adds `replace(id, file)`; `accept` now matches extension patterns such as `.html` by file name; `useOptionalPromptInputController` is exported.
|
|
1505
|
+
- `PromptInputSubmit`: new prop `queued` (0.30.0). Set it when a send would join a queue (the agent is still working). The send icon becomes `SendQueueIcon`, an up arrow that morphs into a stack of bars while `queued` is `true`. Leave it unset to keep `submitIcons.send`. `SendQueueIcon` (`queued`, `className` default `size-4`) is exported for other send buttons. It uses `m`, so `queued` needs the app's `LazyMotion`.
|
|
1500
1506
|
- `PromptInputSettings` (new): an overflow menu with a "Model" submenu (one radio list, or a submenu per provider). Entry: `providerLabel(provider)` (default the id), `providerIcon(provider)` (default none), `classes.trigger`.
|
|
1501
1507
|
- `TraitsPanel` (new, with `isUltrathinkPrompt`, `applyUltrathinkPrefix`): reasoning, speed, context and thinking segments for a model picker; inside a `PromptInputProvider` it offers "Ultrathink" by editing the prompt. Entry: `classes.label`, `classes.empty`, `classes.option`, `classes.optionSelected`, `classes.optionIdle`, `classes.note`.
|
|
1502
1508
|
- `PromptInputSpeech`, `useSpeechRecognition`, `getSpeechRecognition` (new): Web Speech API dictation; the button renders nothing without the API.
|
|
@@ -1518,7 +1524,7 @@ New dependency (installed with the package): `katex`. `streamdown` and the `@str
|
|
|
1518
1524
|
|
|
1519
1525
|
AI surfaces that came from an app, exported from the root. Fills use `surface-secondary` (`bg-surface-secondary`, `from-surface-secondary/40`), which is the app's `--muted` under `compat-shadcn-rgb.css` and the secondary surface under `tokens.css`. They also use the shadcn colour names `bg-secondary`, `bg-popover` and `bg-card` and a few app utilities (`motion-press`, `smooth-shadow-*`, `scrollbar`, `scrollbar-none`, `text-2xs`, `text-3xs`), so they look as intended with `compat-shadcn-rgb.css` and an app that defines those utilities. They merge a caller `className` with `UiConfig.cn` when set.
|
|
1520
1526
|
|
|
1521
|
-
- `ActivityTasks` (`steps`, `isStreaming`, `name`, `startedAt`, `duration`, `layout` `folded | inline`, `onOpenFile`, `onRequestFullDetail`): an agent turn as a timeline. Tool runs fold behind one summary line, reasoning and mid-turn text render as Markdown, subtasks nest their steps, long timelines cap behind "Show N more". `OpenSubagentContext` gives subtask rows a "View" link.
|
|
1527
|
+
- `ActivityTasks` (`steps`, `isStreaming`, `name`, `startedAt`, `duration`, `layout` `folded | inline`, `onOpenFile`, `onRequestFullDetail`): an agent turn as a timeline. Tool runs fold behind one summary line, reasoning and mid-turn text render as Markdown, subtasks nest their steps, long timelines cap behind "Show N more". While `isStreaming` and before the first row, a header shows the time since `startedAt` as a `RollingNumber` clock. `OpenSubagentContext` gives subtask rows a "View" link.
|
|
1522
1528
|
- The step model: `ActivityStep`, `ActivityTodoItem`, `ActivityQuestion`, `ActivityQuestionOption`, `ActivityStepOutput`, `ActivityStepEdit`, `activityStepHasRichDetail`, `activityStepConfig` (glyph and fallback label per step type), `ActivityThinkingIcon`, `useActivitySpinnerVerb`, `getRandomActivityVerb`, `useActivityElapsedSeconds`, `formatActivityElapsed`, `useStreamingDisclosure`.
|
|
1523
1529
|
- Tool-call details show files with `FileChip` and output in a `CodeBlock` (`plain`) with its copy button, so the `CodeBlock` entry (see "App config: AI chat") styles those frames too.
|
|
1524
1530
|
- `ActivityQuestionCard` (`questions`, `answers`): the read-only record of a multiple-choice question and its answer.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/ui",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Shared React 19 UI for web apps: one main 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), a client entry without @vvedantb/server, convex or Clerk for Chrome extensions, design-token CSS, shared Tailwind v4 CSS, a Tailwind preset and packaged Instrument Sans fonts.",
|
|
6
6
|
"type": "module",
|
package/src/client.ts
CHANGED
|
@@ -33,10 +33,16 @@ export { restoreModalScrollLock } from "./ui/overlay-a11y";
|
|
|
33
33
|
export * from "./hooks";
|
|
34
34
|
export { ErrorBoundary } from "./ui/error-boundary";
|
|
35
35
|
export type { ErrorBoundaryProps } from "./ui/error-boundary";
|
|
36
|
-
export { CountPop, countLabel, UnreadDot } from "./ui/count-pop";
|
|
36
|
+
export { CountPop, countLabel, UnreadDot, CORNER_BADGE_CLASS, UnreadCornerBadge } from "./ui/count-pop";
|
|
37
37
|
export { RollingNumber } from "./ui/rolling-number";
|
|
38
38
|
export { ConfettiBurst } from "./ui/confetti-burst";
|
|
39
39
|
export { SendQueueIcon } from "./ui/send-queue-icon";
|
|
40
|
+
export { BulkActionBar } from "./ui/bulk-action-bar";
|
|
41
|
+
export type { BulkActionBarProps, BulkBarAction } from "./ui/bulk-action-bar";
|
|
42
|
+
export { CopyLinkMenuItem } from "./ui/copy-link-menu-item";
|
|
43
|
+
export { ToggleSearch } from "./ui/toggle-search";
|
|
44
|
+
export type { ToggleSearchProps, ToggleSearchVariant } from "./ui/toggle-search";
|
|
45
|
+
export { MotionProvider } from "./motion/motion-provider";
|
|
40
46
|
export { MarqueeOnHover } from "./ui/marquee-on-hover";
|
|
41
47
|
export { RelativeDateTime } from "./ui/relative-date-time";
|
|
42
48
|
export type { RelativeDateTimeProps } from "./ui/relative-date-time";
|
package/src/hooks/index.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
export { useMediaQuery } from "./use-media-query";
|
|
2
2
|
export { useDocumentVisible } from "./use-document-visible";
|
|
3
|
+
export {
|
|
4
|
+
readDocumentAppearance,
|
|
5
|
+
subscribeDocumentAppearance,
|
|
6
|
+
useDocumentAppearance,
|
|
7
|
+
type DocumentAppearance,
|
|
8
|
+
} from "./use-document-appearance";
|
|
3
9
|
export { createIdleCallback, useIdleCallback } from "./use-idle-callback";
|
|
4
10
|
export { useSingleFlight } from "./use-single-flight";
|
|
5
11
|
export { usePersistedScrollParent } from "./use-persisted-scroll-parent";
|
|
12
|
+
export { parseCountable, useCountUpDisplay } from "./use-count-up-display";
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { animate } from "motion/react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useDisablePageMotion } from "../../app/src/page-motion/PageMotionProvider";
|
|
4
|
+
import { motionEase, tokenTransition } from "../motion/presets";
|
|
5
|
+
|
|
6
|
+
/** Suffixes we will count; compound durations like "1h 12m" stay static. */
|
|
7
|
+
const COUNTABLE_SUFFIX = /^(?:%|h|k|m|s|ms)?$/i;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Splits a label into the number to count and the text around it. A finite
|
|
11
|
+
* number passes through; a string needs one number with an optional prefix
|
|
12
|
+
* ("$", "~") and a suffix of `%`, `h`, `k`, `m`, `s` or `ms`. Anything else
|
|
13
|
+
* (`—`, "1h 12m", "3 of 5") is `null`.
|
|
14
|
+
*/
|
|
15
|
+
export function parseCountable(value: string | number): {
|
|
16
|
+
amount: number;
|
|
17
|
+
prefix: string;
|
|
18
|
+
suffix: string;
|
|
19
|
+
decimals: number;
|
|
20
|
+
} | null {
|
|
21
|
+
if (typeof value === "number") {
|
|
22
|
+
if (!Number.isFinite(value)) return null;
|
|
23
|
+
return { amount: value, prefix: "", suffix: "", decimals: 0 };
|
|
24
|
+
}
|
|
25
|
+
const match = value.trim().match(/^(\D*?)(-?\d+(?:\.\d+)?)(\D*)$/);
|
|
26
|
+
if (!match) return null;
|
|
27
|
+
const prefix = match[1] ?? "";
|
|
28
|
+
const raw = match[2] ?? "";
|
|
29
|
+
const suffix = (match[3] ?? "").trim();
|
|
30
|
+
if (!COUNTABLE_SUFFIX.test(suffix)) return null;
|
|
31
|
+
const amount = Number(raw);
|
|
32
|
+
if (!Number.isFinite(amount)) return null;
|
|
33
|
+
const decimals = raw.includes(".") ? (raw.split(".")[1]?.length ?? 0) : 0;
|
|
34
|
+
return { amount, prefix, suffix, decimals };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Counts from the last seen number to `value` on `--motion-base`
|
|
39
|
+
* (`tokenTransition.base`), keeping the prefix, suffix and decimal places. The
|
|
40
|
+
* first value counts up from 0. The same amount again (a refetch) is a no-op,
|
|
41
|
+
* so the number does not replay on every write. Labels `parseCountable` rejects
|
|
42
|
+
* (`—`, "1h 12m") render as they are, and so does every value while
|
|
43
|
+
* `PageMotionProvider` is `disabled`.
|
|
44
|
+
*/
|
|
45
|
+
export function useCountUpDisplay(value: string | number): string | number {
|
|
46
|
+
const parsed = parseCountable(value);
|
|
47
|
+
const [display, setDisplay] = useState(value);
|
|
48
|
+
const lastAmount = useRef<number | null>(null);
|
|
49
|
+
const skipMotion = useDisablePageMotion();
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (skipMotion) {
|
|
53
|
+
lastAmount.current = parsed?.amount ?? null;
|
|
54
|
+
setDisplay(value);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (!parsed) {
|
|
58
|
+
lastAmount.current = null;
|
|
59
|
+
setDisplay(value);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (lastAmount.current === parsed.amount) {
|
|
63
|
+
setDisplay(value);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const from = lastAmount.current ?? 0;
|
|
67
|
+
lastAmount.current = parsed.amount;
|
|
68
|
+
const controls = animate(from, parsed.amount, {
|
|
69
|
+
duration: tokenTransition.base.duration,
|
|
70
|
+
ease: motionEase,
|
|
71
|
+
onUpdate: (latest) => {
|
|
72
|
+
const rounded = parsed.decimals > 0 ? latest.toFixed(parsed.decimals) : String(Math.round(latest));
|
|
73
|
+
setDisplay(`${parsed.prefix}${rounded}${parsed.suffix}`);
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
return () => controls.stop();
|
|
77
|
+
}, [parsed?.amount, parsed?.prefix, parsed?.suffix, parsed?.decimals, skipMotion, value]);
|
|
78
|
+
|
|
79
|
+
return display;
|
|
80
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
/** Light or dark, as the page currently draws itself. Neutral is dark-family. */
|
|
4
|
+
export type DocumentAppearance = "light" | "dark";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reads the appearance from `<html>`: the `dark` class (Tailwind's convention,
|
|
8
|
+
* set before paint by the app's boot script) or `data-theme="dark"`.
|
|
9
|
+
*
|
|
10
|
+
* Use this rather than a theme context when code can run outside the app's
|
|
11
|
+
* theme provider (a signed-out marketing page, a canvas effect): the class is
|
|
12
|
+
* the one source every page shares.
|
|
13
|
+
*/
|
|
14
|
+
export function readDocumentAppearance(): DocumentAppearance {
|
|
15
|
+
const root = document.documentElement;
|
|
16
|
+
return root.classList.contains("dark") ||
|
|
17
|
+
root.getAttribute("data-theme") === "dark"
|
|
18
|
+
? "dark"
|
|
19
|
+
: "light";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Calls `onChange` whenever `<html>`'s class or `data-theme` changes. */
|
|
23
|
+
export function subscribeDocumentAppearance(onChange: () => void): () => void {
|
|
24
|
+
const observer = new MutationObserver(onChange);
|
|
25
|
+
observer.observe(document.documentElement, {
|
|
26
|
+
attributes: true,
|
|
27
|
+
attributeFilter: ["class", "data-theme"],
|
|
28
|
+
});
|
|
29
|
+
return () => observer.disconnect();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readServerAppearance(): DocumentAppearance {
|
|
33
|
+
return "light";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The page's appearance, re-rendering when it changes. Server snapshot is
|
|
38
|
+
* `"light"`, matching a page with no `dark` class.
|
|
39
|
+
*/
|
|
40
|
+
export function useDocumentAppearance(): DocumentAppearance {
|
|
41
|
+
return useSyncExternalStore(
|
|
42
|
+
subscribeDocumentAppearance,
|
|
43
|
+
readDocumentAppearance,
|
|
44
|
+
readServerAppearance,
|
|
45
|
+
);
|
|
46
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -29,10 +29,16 @@ export { restoreModalScrollLock } from "./ui/overlay-a11y";
|
|
|
29
29
|
export * from "./hooks";
|
|
30
30
|
export { ErrorBoundary } from "./ui/error-boundary";
|
|
31
31
|
export type { ErrorBoundaryProps } from "./ui/error-boundary";
|
|
32
|
-
export { CountPop, countLabel, UnreadDot } from "./ui/count-pop";
|
|
32
|
+
export { CountPop, countLabel, UnreadDot, CORNER_BADGE_CLASS, UnreadCornerBadge } from "./ui/count-pop";
|
|
33
33
|
export { RollingNumber } from "./ui/rolling-number";
|
|
34
34
|
export { ConfettiBurst } from "./ui/confetti-burst";
|
|
35
35
|
export { SendQueueIcon } from "./ui/send-queue-icon";
|
|
36
|
+
export { BulkActionBar } from "./ui/bulk-action-bar";
|
|
37
|
+
export type { BulkActionBarProps, BulkBarAction } from "./ui/bulk-action-bar";
|
|
38
|
+
export { CopyLinkMenuItem } from "./ui/copy-link-menu-item";
|
|
39
|
+
export { ToggleSearch } from "./ui/toggle-search";
|
|
40
|
+
export type { ToggleSearchProps, ToggleSearchVariant } from "./ui/toggle-search";
|
|
41
|
+
export { MotionProvider } from "./motion/motion-provider";
|
|
36
42
|
export { MarqueeOnHover } from "./ui/marquee-on-hover";
|
|
37
43
|
export { RelativeDateTime } from "./ui/relative-date-time";
|
|
38
44
|
export type { RelativeDateTimeProps } from "./ui/relative-date-time";
|
package/src/markdown/mermaid.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { MermaidConfig } from "@streamdown/mermaid";
|
|
2
2
|
import { useSyncExternalStore } from "react";
|
|
3
3
|
import type { DiagramPlugin } from "streamdown";
|
|
4
|
+
import {
|
|
5
|
+
readDocumentAppearance,
|
|
6
|
+
subscribeDocumentAppearance,
|
|
7
|
+
} from "../hooks/use-document-appearance";
|
|
4
8
|
|
|
5
9
|
/**
|
|
6
10
|
* Mermaid for markdown, loaded on first use. `@streamdown/mermaid` imports
|
|
@@ -102,18 +106,9 @@ function subscribe(key: string, notify: () => void): () => void {
|
|
|
102
106
|
};
|
|
103
107
|
}
|
|
104
108
|
|
|
105
|
-
/**
|
|
109
|
+
/** Mermaid's theme for the page's current appearance (neutral is dark-family). */
|
|
106
110
|
function readTheme(): MermaidTheme {
|
|
107
|
-
return
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function subscribeTheme(notify: () => void): () => void {
|
|
111
|
-
const observer = new MutationObserver(notify);
|
|
112
|
-
observer.observe(document.documentElement, {
|
|
113
|
-
attributes: true,
|
|
114
|
-
attributeFilter: ["class"],
|
|
115
|
-
});
|
|
116
|
-
return () => observer.disconnect();
|
|
111
|
+
return readDocumentAppearance() === "dark" ? "dark" : "default";
|
|
117
112
|
}
|
|
118
113
|
|
|
119
114
|
/**
|
|
@@ -145,7 +140,7 @@ export function useMermaidRender(
|
|
|
145
140
|
code: string,
|
|
146
141
|
enabled: boolean,
|
|
147
142
|
): { theme: MermaidTheme; status: Entry["status"] } {
|
|
148
|
-
const theme = useSyncExternalStore(
|
|
143
|
+
const theme = useSyncExternalStore(subscribeDocumentAppearance, readTheme);
|
|
149
144
|
const key = enabled ? cacheKey(theme, code) : "";
|
|
150
145
|
const entry = useSyncExternalStore(
|
|
151
146
|
(notify) => (key === "" ? () => {} : subscribe(key, notify)),
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { LazyMotion, MotionConfig, domMax, type FeatureBundle } from "motion/react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The app-wide Motion setup that `m.*` components (in this package and the
|
|
6
|
+
* app) need: one lazy feature bundle under `strict`, so a stray `motion.*`
|
|
7
|
+
* import throws instead of pulling the full bundle into the entry chunk.
|
|
8
|
+
*
|
|
9
|
+
* `features` defaults to `domMax`, which includes layout animations
|
|
10
|
+
* (`layoutId` shared highlights); `domAnimation` is smaller if the app has
|
|
11
|
+
* none. Under `strict` a missing feature silently skips the animation.
|
|
12
|
+
* `reducedMotion` defaults to `"user"` (follow the OS setting); pass `"never"`
|
|
13
|
+
* to always animate.
|
|
14
|
+
*/
|
|
15
|
+
export function MotionProvider({
|
|
16
|
+
children,
|
|
17
|
+
features = domMax,
|
|
18
|
+
reducedMotion = "user",
|
|
19
|
+
}: {
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
features?: FeatureBundle;
|
|
22
|
+
reducedMotion?: "user" | "always" | "never";
|
|
23
|
+
}) {
|
|
24
|
+
return (
|
|
25
|
+
<LazyMotion features={features} strict>
|
|
26
|
+
<MotionConfig reducedMotion={reducedMotion}>{children}</MotionConfig>
|
|
27
|
+
</LazyMotion>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -36,3 +36,42 @@ export function PanelTransition({
|
|
|
36
36
|
</ViewTransition>
|
|
37
37
|
);
|
|
38
38
|
}
|
|
39
|
+
|
|
40
|
+
export interface ViewTransitionSuspenseProps {
|
|
41
|
+
fallback: ReactNode;
|
|
42
|
+
children: ReactNode;
|
|
43
|
+
/** View transition class on the content as it arrives. Default `vt-enter`. */
|
|
44
|
+
enter?: string;
|
|
45
|
+
/** View transition class on the fallback as it leaves. Default `vt-exit`. */
|
|
46
|
+
exit?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `<Suspense>` whose fallback fades out as the content comes in, through React
|
|
51
|
+
* `<ViewTransition>`. Suspense reveals commit on retry lanes, which React
|
|
52
|
+
* animates even outside `startTransition`. Needs a React build that exports
|
|
53
|
+
* `ViewTransition`; the app supplies the `::view-transition-old(.vt-exit)` and
|
|
54
|
+
* `::view-transition-new(.vt-enter)` CSS. Routers that commit route changes
|
|
55
|
+
* synchronously (through `useSyncExternalStore`) do not animate here; use the
|
|
56
|
+
* router's own view transition for pages.
|
|
57
|
+
*/
|
|
58
|
+
export function ViewTransitionSuspense({
|
|
59
|
+
fallback,
|
|
60
|
+
children,
|
|
61
|
+
enter = "vt-enter",
|
|
62
|
+
exit = "vt-exit",
|
|
63
|
+
}: ViewTransitionSuspenseProps) {
|
|
64
|
+
return (
|
|
65
|
+
<React.Suspense
|
|
66
|
+
fallback={
|
|
67
|
+
<ViewTransition default="none" exit={exit}>
|
|
68
|
+
{fallback}
|
|
69
|
+
</ViewTransition>
|
|
70
|
+
}
|
|
71
|
+
>
|
|
72
|
+
<ViewTransition default="none" enter={enter}>
|
|
73
|
+
{children}
|
|
74
|
+
</ViewTransition>
|
|
75
|
+
</React.Suspense>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { m, AnimatePresence } from "motion/react";
|
|
2
|
+
import { ConfirmSkipHint, skipConfirmTitle } from "../../app/src/confirm/ConfirmSkipHint";
|
|
3
|
+
import { tokenTransition } from "../motion/presets";
|
|
4
|
+
import { CountPop } from "./count-pop";
|
|
5
|
+
import {
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
DropdownMenuContent,
|
|
8
|
+
DropdownMenuItem,
|
|
9
|
+
DropdownMenuTrigger,
|
|
10
|
+
} from "./dropdown-menu";
|
|
11
|
+
import { Separator } from "./separator";
|
|
12
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
|
|
13
|
+
import { IconDots, IconX } from "@tabler/icons-react";
|
|
14
|
+
import type { ComponentType } from "react";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One control in the bar. The bar is deliberately dumb: each action carries the
|
|
18
|
+
* handler it should run, so confirm dialogs, Alt-to-skip and any per-surface
|
|
19
|
+
* bookkeeping stay with the caller that owns that state.
|
|
20
|
+
*/
|
|
21
|
+
export interface BulkBarAction {
|
|
22
|
+
key: string;
|
|
23
|
+
/** Full label used in the More menu and for accessibility. */
|
|
24
|
+
label: string;
|
|
25
|
+
/** Shorter label shown inline in the bar (defaults to `label`). */
|
|
26
|
+
shortLabel?: string;
|
|
27
|
+
icon: ComponentType<{ size: number; className?: string }>;
|
|
28
|
+
destructive?: boolean;
|
|
29
|
+
/** Shows the Alt-to-skip hint; the caller still owns the skip itself. */
|
|
30
|
+
showSkipHint?: boolean;
|
|
31
|
+
onClick: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface BulkActionBarProps {
|
|
35
|
+
isSelecting: boolean;
|
|
36
|
+
selectedCount: number;
|
|
37
|
+
totalCount: number;
|
|
38
|
+
onExitSelect: () => void;
|
|
39
|
+
onSelectAll: () => void;
|
|
40
|
+
onClearSelection: () => void;
|
|
41
|
+
primaryActions: ReadonlyArray<BulkBarAction>;
|
|
42
|
+
moreActions?: ReadonlyArray<BulkBarAction>;
|
|
43
|
+
destructiveAction?: BulkBarAction;
|
|
44
|
+
/** Motion key for the presence animation, e.g. "quick-tasks-bulk-bar". */
|
|
45
|
+
barKey: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A single labelled button inside the action bar. The label collapses to
|
|
50
|
+
* icon-only below the `sm` breakpoint (HeroUI "responsive labels" pattern).
|
|
51
|
+
*/
|
|
52
|
+
function BarButton({
|
|
53
|
+
action,
|
|
54
|
+
disabled,
|
|
55
|
+
}: {
|
|
56
|
+
action: BulkBarAction;
|
|
57
|
+
disabled: boolean;
|
|
58
|
+
}) {
|
|
59
|
+
const Icon = action.icon;
|
|
60
|
+
return (
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
aria-label={action.label}
|
|
64
|
+
title={
|
|
65
|
+
action.showSkipHint ? skipConfirmTitle(action.label) : action.label
|
|
66
|
+
}
|
|
67
|
+
onClick={action.onClick}
|
|
68
|
+
disabled={disabled}
|
|
69
|
+
// `motion-press`, not `transition-colors`: the destructive Delete takes
|
|
70
|
+
// this same branch, so the highest-consequence control in the bar was the
|
|
71
|
+
// one with no acknowledgement at all before its confirm dialog appeared.
|
|
72
|
+
className={`motion-press inline-flex h-9 shrink-0 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium active:scale-[0.96] disabled:pointer-events-none disabled:opacity-30 ${
|
|
73
|
+
action.destructive
|
|
74
|
+
? "text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
75
|
+
: "text-muted-foreground hover:bg-surface-secondary hover:text-foreground"
|
|
76
|
+
}`}
|
|
77
|
+
>
|
|
78
|
+
<Icon size={17} />
|
|
79
|
+
<span className="hidden sm:inline">
|
|
80
|
+
{action.shortLabel ?? action.label}
|
|
81
|
+
</span>
|
|
82
|
+
{action.showSkipHint ? <ConfirmSkipHint /> : null}
|
|
83
|
+
</button>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The floating selection bar for any list with bulk actions. Render it inside
|
|
89
|
+
* a `relative` list container: it sits absolute at the bottom. It owns the shell only: presence
|
|
90
|
+
* motion, the count, select-all/clear, the primary buttons, the More menu and
|
|
91
|
+
* the destructive slot on the right. Behaviour arrives as `onClick` per action.
|
|
92
|
+
*/
|
|
93
|
+
export function BulkActionBar({
|
|
94
|
+
isSelecting,
|
|
95
|
+
selectedCount,
|
|
96
|
+
totalCount,
|
|
97
|
+
onExitSelect,
|
|
98
|
+
onSelectAll,
|
|
99
|
+
onClearSelection,
|
|
100
|
+
primaryActions,
|
|
101
|
+
moreActions,
|
|
102
|
+
destructiveAction,
|
|
103
|
+
barKey,
|
|
104
|
+
}: BulkActionBarProps) {
|
|
105
|
+
const hasSelection = selectedCount > 0;
|
|
106
|
+
const allSelected = totalCount > 0 && selectedCount >= totalCount;
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<AnimatePresence initial={false}>
|
|
110
|
+
{isSelecting && (
|
|
111
|
+
<m.div
|
|
112
|
+
key={barKey}
|
|
113
|
+
className="absolute inset-x-0 bottom-3 z-20 flex justify-center px-4 pb-[env(safe-area-inset-bottom)]"
|
|
114
|
+
initial={{ opacity: 0, y: 20 }}
|
|
115
|
+
animate={{ opacity: 1, y: 0 }}
|
|
116
|
+
exit={{ opacity: 0, y: 20 }}
|
|
117
|
+
transition={tokenTransition.fast}
|
|
118
|
+
>
|
|
119
|
+
<TooltipProvider>
|
|
120
|
+
<div className="flex max-w-[calc(100vw-2rem)] items-center gap-1 overflow-x-auto rounded-surface bg-popover/95 px-2.5 py-2 backdrop-blur-md smooth-shadow-ring-lg scrollbar-none">
|
|
121
|
+
{/* Prefix: selection count */}
|
|
122
|
+
<div className="flex shrink-0 items-center gap-2 pl-1 pr-0.5">
|
|
123
|
+
<CountPop
|
|
124
|
+
label={String(selectedCount)}
|
|
125
|
+
className="rounded-md bg-surface-secondary px-2 py-0.5 text-xs font-semibold text-foreground tabular-nums"
|
|
126
|
+
/>
|
|
127
|
+
<span className="hidden text-sm font-medium text-muted-foreground sm:inline">
|
|
128
|
+
selected
|
|
129
|
+
</span>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
{/* Reaching the whole list is the one action a selection bar can
|
|
133
|
+
offer that the rows cannot: shift-clicking to the end of a
|
|
134
|
+
long list is the alternative. */}
|
|
135
|
+
{totalCount > 0 && (
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
onClick={allSelected ? onClearSelection : onSelectAll}
|
|
139
|
+
className="motion-press inline-flex h-9 shrink-0 items-center rounded-lg px-2 text-sm font-medium text-muted-foreground hover:bg-surface-secondary hover:text-foreground active:scale-[0.96]"
|
|
140
|
+
>
|
|
141
|
+
{allSelected ? "Clear" : `Select all (${totalCount})`}
|
|
142
|
+
</button>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
<Separator
|
|
146
|
+
orientation="vertical"
|
|
147
|
+
className="mx-1.5 h-5 shrink-0 bg-border"
|
|
148
|
+
/>
|
|
149
|
+
|
|
150
|
+
{/* Content: primary actions + More dropdown */}
|
|
151
|
+
{primaryActions.map((action) => (
|
|
152
|
+
<BarButton
|
|
153
|
+
key={action.key}
|
|
154
|
+
action={action}
|
|
155
|
+
disabled={!hasSelection}
|
|
156
|
+
/>
|
|
157
|
+
))}
|
|
158
|
+
|
|
159
|
+
{moreActions && moreActions.length > 0 && (
|
|
160
|
+
<DropdownMenu>
|
|
161
|
+
<DropdownMenuTrigger asChild>
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
aria-label="More actions"
|
|
165
|
+
disabled={!hasSelection}
|
|
166
|
+
className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-surface-secondary hover:text-foreground disabled:pointer-events-none disabled:opacity-30 data-[state=open]:bg-surface-secondary data-[state=open]:text-foreground"
|
|
167
|
+
>
|
|
168
|
+
<IconDots size={17} />
|
|
169
|
+
</button>
|
|
170
|
+
</DropdownMenuTrigger>
|
|
171
|
+
<DropdownMenuContent align="center" side="top" sideOffset={8}>
|
|
172
|
+
{moreActions.map((action) => {
|
|
173
|
+
const Icon = action.icon;
|
|
174
|
+
return (
|
|
175
|
+
<DropdownMenuItem
|
|
176
|
+
key={action.key}
|
|
177
|
+
disabled={!hasSelection}
|
|
178
|
+
onClick={action.onClick}
|
|
179
|
+
>
|
|
180
|
+
<Icon size={16} />
|
|
181
|
+
{action.label}
|
|
182
|
+
</DropdownMenuItem>
|
|
183
|
+
);
|
|
184
|
+
})}
|
|
185
|
+
</DropdownMenuContent>
|
|
186
|
+
</DropdownMenu>
|
|
187
|
+
)}
|
|
188
|
+
|
|
189
|
+
{destructiveAction && (
|
|
190
|
+
<>
|
|
191
|
+
<Separator
|
|
192
|
+
orientation="vertical"
|
|
193
|
+
className="mx-1.5 h-5 shrink-0 bg-border"
|
|
194
|
+
/>
|
|
195
|
+
<BarButton
|
|
196
|
+
action={destructiveAction}
|
|
197
|
+
disabled={!hasSelection}
|
|
198
|
+
/>
|
|
199
|
+
</>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
<Separator
|
|
203
|
+
orientation="vertical"
|
|
204
|
+
className="mx-1.5 h-5 shrink-0 bg-border"
|
|
205
|
+
/>
|
|
206
|
+
|
|
207
|
+
{/* Suffix: dismiss selection */}
|
|
208
|
+
<Tooltip>
|
|
209
|
+
<TooltipTrigger asChild>
|
|
210
|
+
<button
|
|
211
|
+
type="button"
|
|
212
|
+
aria-label="Cancel selection"
|
|
213
|
+
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-surface-secondary hover:text-foreground"
|
|
214
|
+
onClick={onExitSelect}
|
|
215
|
+
>
|
|
216
|
+
<IconX size={17} />
|
|
217
|
+
</button>
|
|
218
|
+
</TooltipTrigger>
|
|
219
|
+
<TooltipContent side="top" className="text-xs" sideOffset={8}>
|
|
220
|
+
Cancel selection
|
|
221
|
+
</TooltipContent>
|
|
222
|
+
</Tooltip>
|
|
223
|
+
</div>
|
|
224
|
+
</TooltipProvider>
|
|
225
|
+
</m.div>
|
|
226
|
+
)}
|
|
227
|
+
</AnimatePresence>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** transitions.dev input clear with dissolve — see 13-input-clear-dissolve.md */
|
|
2
2
|
|
|
3
|
+
import { readDocumentAppearance } from "../hooks/use-document-appearance";
|
|
4
|
+
|
|
3
5
|
// Times come back in ms. CSS minifiers rewrite `1000ms` as `1s`, so a value
|
|
4
6
|
// in seconds is converted rather than read as 1.
|
|
5
7
|
export function readCssNumber(
|
|
@@ -53,13 +55,6 @@ export function readCssEase(root: HTMLElement, name: string): (t: number) => num
|
|
|
53
55
|
};
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
function isDarkTheme(root: HTMLElement): boolean {
|
|
57
|
-
return (
|
|
58
|
-
root.classList.contains("dark") ||
|
|
59
|
-
root.getAttribute("data-theme") === "dark"
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
58
|
function measureCanvas(): CanvasRenderingContext2D | null {
|
|
64
59
|
const canvas = document.createElement("canvas");
|
|
65
60
|
return canvas.getContext("2d");
|
|
@@ -93,7 +88,7 @@ function buildGlowBackground(
|
|
|
93
88
|
return "";
|
|
94
89
|
}
|
|
95
90
|
canvas.font = getComputedStyle(input).font;
|
|
96
|
-
const dark =
|
|
91
|
+
const dark = readDocumentAppearance() === "dark";
|
|
97
92
|
const rgb = dark ? "255,255,255" : "0,0,0";
|
|
98
93
|
const width = wrap.clientWidth || 280;
|
|
99
94
|
const padLeft = parseFloat(getComputedStyle(input).paddingLeft) || 12;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { IconLink } from "@tabler/icons-react";
|
|
2
|
+
import { DropdownMenuItem } from "./dropdown-menu";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A "Copy link" item for a More menu: copies the current page URL. Pass `path`
|
|
6
|
+
* when the current URL is not the link to share (a per-user route that shows
|
|
7
|
+
* different content to each person, say); it resolves against the origin.
|
|
8
|
+
*/
|
|
9
|
+
export function CopyLinkMenuItem({
|
|
10
|
+
iconSize = 14,
|
|
11
|
+
path,
|
|
12
|
+
label = "Copy link",
|
|
13
|
+
}: {
|
|
14
|
+
iconSize?: number;
|
|
15
|
+
path?: string;
|
|
16
|
+
label?: string;
|
|
17
|
+
}) {
|
|
18
|
+
return (
|
|
19
|
+
<DropdownMenuItem
|
|
20
|
+
onClick={() => {
|
|
21
|
+
const href = path ? new URL(path, window.location.origin).href : window.location.href;
|
|
22
|
+
void navigator.clipboard.writeText(href);
|
|
23
|
+
}}
|
|
24
|
+
>
|
|
25
|
+
<IconLink size={iconSize} />
|
|
26
|
+
{label}
|
|
27
|
+
</DropdownMenuItem>
|
|
28
|
+
);
|
|
29
|
+
}
|
package/src/ui/count-pop.tsx
CHANGED
|
@@ -65,3 +65,21 @@ export function UnreadDot({ show, className }: { show: boolean; className?: stri
|
|
|
65
65
|
</CountPop>
|
|
66
66
|
);
|
|
67
67
|
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Shape and placement of a count badge hung off a tile's bottom-right corner.
|
|
71
|
+
* Put the tile in a `relative` box; add one tone class (e.g. a live colour).
|
|
72
|
+
*/
|
|
73
|
+
export const CORNER_BADGE_CLASS =
|
|
74
|
+
"absolute -bottom-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-semibold leading-none";
|
|
75
|
+
|
|
76
|
+
/** An unread count on a tile's corner: `CountPop` in the primary tone, "99+" above 99. */
|
|
77
|
+
export function UnreadCornerBadge({ count, className }: { count: number | undefined; className?: string }) {
|
|
78
|
+
const cn = useCn();
|
|
79
|
+
return (
|
|
80
|
+
<CountPop
|
|
81
|
+
label={countLabel(count)}
|
|
82
|
+
className={cn(CORNER_BADGE_CLASS, "bg-primary text-primary-foreground", className)}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { useState, type KeyboardEventHandler } from "react";
|
|
2
|
+
import { IconSearch } from "@tabler/icons-react";
|
|
3
|
+
import { Button } from "./button";
|
|
4
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
|
5
|
+
import { SearchInput } from "./search-input";
|
|
6
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";
|
|
7
|
+
|
|
8
|
+
// "compact" is the always-visible logs/toolbar field. "large" is the icon
|
|
9
|
+
// button + popover, which keeps a crowded toolbar short.
|
|
10
|
+
export type ToggleSearchVariant = "compact" | "large";
|
|
11
|
+
|
|
12
|
+
export interface ToggleSearchProps {
|
|
13
|
+
value: string;
|
|
14
|
+
onChange: (value: string | null) => void;
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
visible?: boolean;
|
|
17
|
+
variant?: ToggleSearchVariant;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function SearchField({
|
|
21
|
+
value,
|
|
22
|
+
onChange,
|
|
23
|
+
placeholder,
|
|
24
|
+
className,
|
|
25
|
+
onKeyDown,
|
|
26
|
+
autoFocus,
|
|
27
|
+
}: {
|
|
28
|
+
value: string;
|
|
29
|
+
onChange: (value: string | null) => void;
|
|
30
|
+
placeholder: string;
|
|
31
|
+
className?: string;
|
|
32
|
+
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
|
|
33
|
+
autoFocus?: boolean;
|
|
34
|
+
}) {
|
|
35
|
+
return (
|
|
36
|
+
<SearchInput
|
|
37
|
+
value={value}
|
|
38
|
+
onChange={(next) => onChange(next.length > 0 ? next : null)}
|
|
39
|
+
onClear={() => onChange(null)}
|
|
40
|
+
placeholder={placeholder}
|
|
41
|
+
className={className}
|
|
42
|
+
inputClassName="h-8"
|
|
43
|
+
autoFocus={autoFocus}
|
|
44
|
+
onKeyDown={onKeyDown}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Toolbar search. Compact stays an inline field; large collapses to an icon
|
|
51
|
+
* that opens a popover — a primary dot on the button means a query is applied.
|
|
52
|
+
* `large` uses a `Tooltip`, so render it under the app's `TooltipProvider`.
|
|
53
|
+
*/
|
|
54
|
+
export function ToggleSearch({
|
|
55
|
+
value,
|
|
56
|
+
onChange,
|
|
57
|
+
placeholder = "Search...",
|
|
58
|
+
visible = true,
|
|
59
|
+
variant = "compact",
|
|
60
|
+
}: ToggleSearchProps) {
|
|
61
|
+
const [open, setOpen] = useState(false);
|
|
62
|
+
const hasQuery = value.trim().length > 0;
|
|
63
|
+
|
|
64
|
+
if (!visible) return null;
|
|
65
|
+
|
|
66
|
+
if (variant === "compact") {
|
|
67
|
+
return (
|
|
68
|
+
<SearchField
|
|
69
|
+
value={value}
|
|
70
|
+
onChange={onChange}
|
|
71
|
+
placeholder={placeholder}
|
|
72
|
+
// `max-sm:min-w-0` + `sm:shrink-0`: on a phone the field yields to its
|
|
73
|
+
// toolbar siblings instead of forcing the row (and the page) to overflow.
|
|
74
|
+
className="w-36 max-w-none max-sm:min-w-0 sm:w-44 sm:shrink-0"
|
|
75
|
+
onKeyDown={(event) => {
|
|
76
|
+
if (event.key === "Escape") {
|
|
77
|
+
onChange(null);
|
|
78
|
+
}
|
|
79
|
+
}}
|
|
80
|
+
/>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
86
|
+
<Tooltip>
|
|
87
|
+
<TooltipTrigger asChild>
|
|
88
|
+
<PopoverTrigger asChild>
|
|
89
|
+
<Button
|
|
90
|
+
size="icon-sm"
|
|
91
|
+
variant="secondary"
|
|
92
|
+
aria-label={hasQuery ? "Search, filter applied" : "Search"}
|
|
93
|
+
aria-pressed={open}
|
|
94
|
+
className="relative motion-press hover:scale-[1.01] active:scale-[0.96]"
|
|
95
|
+
>
|
|
96
|
+
<IconSearch size={16} />
|
|
97
|
+
{hasQuery ? (
|
|
98
|
+
<span
|
|
99
|
+
aria-hidden
|
|
100
|
+
className="pointer-events-none absolute -top-0.5 -right-0.5 z-10 size-2 rounded-full bg-primary ring-2 ring-background"
|
|
101
|
+
/>
|
|
102
|
+
) : null}
|
|
103
|
+
</Button>
|
|
104
|
+
</PopoverTrigger>
|
|
105
|
+
</TooltipTrigger>
|
|
106
|
+
<TooltipContent>Search</TooltipContent>
|
|
107
|
+
</Tooltip>
|
|
108
|
+
<PopoverContent
|
|
109
|
+
align="end"
|
|
110
|
+
className="w-72 p-2"
|
|
111
|
+
onOpenAutoFocus={(event) => {
|
|
112
|
+
// Radix types this as a plain `Event`, so the content element has to
|
|
113
|
+
// be narrowed before it can be searched for the field.
|
|
114
|
+
const content = event.currentTarget;
|
|
115
|
+
if (!(content instanceof HTMLElement)) return;
|
|
116
|
+
const input = content.querySelector("input");
|
|
117
|
+
if (input instanceof HTMLInputElement) {
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
input.focus();
|
|
120
|
+
}
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
<SearchField
|
|
124
|
+
value={value}
|
|
125
|
+
onChange={onChange}
|
|
126
|
+
placeholder={placeholder}
|
|
127
|
+
className="w-full max-w-none"
|
|
128
|
+
autoFocus
|
|
129
|
+
onKeyDown={(event) => {
|
|
130
|
+
if (event.key === "Enter") {
|
|
131
|
+
setOpen(false);
|
|
132
|
+
}
|
|
133
|
+
}}
|
|
134
|
+
/>
|
|
135
|
+
</PopoverContent>
|
|
136
|
+
</Popover>
|
|
137
|
+
);
|
|
138
|
+
}
|