@vvedantb/ui 0.30.0 → 0.30.1
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 +1 -1
- package/src/client.ts +7 -1
- package/src/index.ts +7 -1
- 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/copy-link-menu-item.tsx +29 -0
- package/src/ui/count-pop.tsx +18 -0
- package/src/ui/toggle-search.tsx +138 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/ui",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.1",
|
|
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/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";
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|