@vvedantb/ui 0.4.2 → 0.4.3
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 +20 -0
- package/app/src/settings/SettingsField.tsx +30 -2
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/ui/confirm-dialog.tsx +45 -0
- package/src/ui/slide-over-panel.tsx +65 -0
- package/src/ui/toolbar-search-input.tsx +80 -0
package/README.md
CHANGED
|
@@ -194,6 +194,26 @@ import { SessionsHoverCard } from "@vvedantb/ui";
|
|
|
194
194
|
|
|
195
195
|
`children` is the trigger. It is rendered with `asChild` by default, so it keeps its own handlers (such as a context menu). Extra props and the ref go to it. Use `asChild={false}` for a default trigger element. The content portals to `document.body` by default, so `overflow:hidden` ancestors do not clip it. Pass `portal={false}` to opt out. `surface` is `"overlay"` (default), `"surface"` (`rounded-surface`) or `"glass"` (`glass-panel-strong`, `rounded-surface`). `className` merges over the surface without `!`. Defaults: `openDelay` 400 ms, `closeDelay` 150 ms, `side="right"`, `align="start"`, `sideOffset` 8, width and padding `w-64 p-3` (override with `className`). `body` is a node or a function of `{ open }`. `loadContent` runs only while the card is open. Control the state with `open`, `defaultOpen` and `onOpenChange`. The card opens on pointer hover and on keyboard focus (`:focus-visible`) only. Focus that follows a click or a context menu close does not open it. If pointer-driven focus left it stuck open, pointer leave force-closes it; otherwise Radix handles close (including moving onto the content). Fixture: `shell/examples/sessions-hover-card/`.
|
|
196
196
|
|
|
197
|
+
## Panels, search and confirm
|
|
198
|
+
|
|
199
|
+
```tsx
|
|
200
|
+
import { ConfirmDialog, SlideOverPanel, ToolbarSearchInput } from "@vvedantb/ui";
|
|
201
|
+
|
|
202
|
+
<div className="relative">
|
|
203
|
+
<SlideOverPanel open={open} onClose={() => setOpen(false)} header={<h2>Details</h2>}>
|
|
204
|
+
<div className="p-4">Body</div>
|
|
205
|
+
</SlideOverPanel>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<ToolbarSearchInput value={query} onChange={setQuery} />
|
|
209
|
+
|
|
210
|
+
<ConfirmDialog size="sm" open={open} onOpenChange={setOpen} title="Delete item?" variant="destructive" onConfirm={remove} />
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
- `SlideOverPanel`: a right-side panel, absolutely positioned inside its nearest positioned parent. It is not a portal or dialog. It slides in with a spring and is hidden below `md`. Only the close button closes it (no Escape, no outside click). `width` is `"w-96"` (default) or `"w-80"`. `closeLabel` defaults to `"Close panel"`. `className` merges last.
|
|
214
|
+
- `ToolbarSearchInput`: a compact `h-8` search field for toolbars, with a search icon and a clear button. The clear button stays mounted and is hidden while the value is blank. Clearing calls `onChange("")` and does not move focus. Defaults: `placeholder="Search..."`, `label="Search"` (the `aria-label`), `clearLabel="Clear search"`. The ref goes to the `<input>`.
|
|
215
|
+
- `ConfirmDialog` `size`: `"default"` keeps the alert dialog. `"sm"` renders a compact, dismissible dialog (outside click, Escape and the close button close it) with small Cancel and confirm buttons. Cancel calls `onCancel` and then `onOpenChange(false)`. Confirm calls `onConfirm` only, so the caller closes it.
|
|
216
|
+
|
|
197
217
|
## Toaster
|
|
198
218
|
|
|
199
219
|
```tsx
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
interface SettingsFieldProps {
|
|
4
|
-
/** Field name, shown above the control. */
|
|
4
|
+
/** Field name, shown above the control (stacked) or left of it (horizontal). */
|
|
5
5
|
label: ReactNode;
|
|
6
|
-
/** Help text shown under the control. */
|
|
6
|
+
/** Help text, shown under the control (stacked) or under the label (horizontal). */
|
|
7
7
|
description?: ReactNode;
|
|
8
8
|
/** `id` of the control, so clicking the label focuses it. */
|
|
9
9
|
htmlFor?: string;
|
|
10
|
+
/** `"stacked"` (default) or `"horizontal"`. */
|
|
11
|
+
layout?: "stacked" | "horizontal";
|
|
10
12
|
children: ReactNode;
|
|
11
13
|
}
|
|
12
14
|
|
|
@@ -16,13 +18,39 @@ interface SettingsFieldProps {
|
|
|
16
18
|
* Every settings form repeats the same label / control / help-text stack, so it
|
|
17
19
|
* lives here rather than being re-spaced per field. Stack several inside a
|
|
18
20
|
* section body with `grid gap-5`. Shared across apps.
|
|
21
|
+
*
|
|
22
|
+
* - `layout="stacked"` (default): label above the control, help text below it.
|
|
23
|
+
* - `layout="horizontal"`: one row with the label and help text on the left and
|
|
24
|
+
* the control on the right, for compact controls such as a select. The label
|
|
25
|
+
* is a `<label>` when `htmlFor` is set, otherwise a `<p>`.
|
|
19
26
|
*/
|
|
20
27
|
export function SettingsField({
|
|
21
28
|
label,
|
|
22
29
|
description,
|
|
23
30
|
htmlFor,
|
|
31
|
+
layout = "stacked",
|
|
24
32
|
children,
|
|
25
33
|
}: SettingsFieldProps) {
|
|
34
|
+
if (layout === "horizontal") {
|
|
35
|
+
return (
|
|
36
|
+
<div className="flex items-center justify-between gap-3">
|
|
37
|
+
<div className="min-w-0">
|
|
38
|
+
{htmlFor ? (
|
|
39
|
+
<label htmlFor={htmlFor} className="block text-sm font-medium text-foreground">
|
|
40
|
+
{label}
|
|
41
|
+
</label>
|
|
42
|
+
) : (
|
|
43
|
+
<p className="text-sm font-medium text-foreground">{label}</p>
|
|
44
|
+
)}
|
|
45
|
+
{description ? (
|
|
46
|
+
<p className="mt-1 text-xs text-muted-foreground text-pretty">{description}</p>
|
|
47
|
+
) : null}
|
|
48
|
+
</div>
|
|
49
|
+
{children}
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
26
54
|
return (
|
|
27
55
|
<div>
|
|
28
56
|
<label
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Shared React 19 UI for web apps: one JavaScript entry (primitives, shell pieces, app helpers, native tokens), design-token CSS, shared Tailwind v4 CSS and a Tailwind preset.",
|
|
6
6
|
"type": "module",
|
package/src/index.ts
CHANGED
|
@@ -42,12 +42,14 @@ export * from "./ui/search-input";
|
|
|
42
42
|
export * from "./ui/select";
|
|
43
43
|
export * from "./ui/separator";
|
|
44
44
|
export * from "./ui/skeleton";
|
|
45
|
+
export * from "./ui/slide-over-panel";
|
|
45
46
|
export * from "./ui/sonner";
|
|
46
47
|
export * from "./ui/spinner";
|
|
47
48
|
export * from "./ui/switch";
|
|
48
49
|
export * from "./ui/table";
|
|
49
50
|
export * from "./ui/tabs";
|
|
50
51
|
export * from "./ui/textarea";
|
|
52
|
+
export * from "./ui/toolbar-search-input";
|
|
51
53
|
export * from "./ui/time-picker";
|
|
52
54
|
export * from "./ui/tooltip";
|
|
53
55
|
export * from "./ui/kbd";
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
AlertDialogHeader,
|
|
10
10
|
AlertDialogTitle,
|
|
11
11
|
} from "./alert-dialog";
|
|
12
|
+
import { Button } from "./button";
|
|
13
|
+
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./dialog";
|
|
12
14
|
|
|
13
15
|
export type ConfirmDialogProps = React.ComponentProps<typeof AlertDialog> & {
|
|
14
16
|
title: React.ReactNode;
|
|
@@ -19,6 +21,12 @@ export type ConfirmDialogProps = React.ComponentProps<typeof AlertDialog> & {
|
|
|
19
21
|
onConfirm?: () => void;
|
|
20
22
|
variant?: "default" | "destructive";
|
|
21
23
|
confirmDisabled?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* `"default"`: blocking alert dialog that closes on either button.
|
|
26
|
+
* `"sm"`: compact dismissible dialog (outside click, Escape and the close
|
|
27
|
+
* button close it). Confirm does not close it; the caller controls `open`.
|
|
28
|
+
*/
|
|
29
|
+
size?: "default" | "sm";
|
|
22
30
|
};
|
|
23
31
|
|
|
24
32
|
function ConfirmDialog({
|
|
@@ -30,9 +38,46 @@ function ConfirmDialog({
|
|
|
30
38
|
onConfirm,
|
|
31
39
|
variant = "default",
|
|
32
40
|
confirmDisabled,
|
|
41
|
+
size = "default",
|
|
33
42
|
children,
|
|
34
43
|
...props
|
|
35
44
|
}: ConfirmDialogProps) {
|
|
45
|
+
if (size === "sm") {
|
|
46
|
+
return (
|
|
47
|
+
<Dialog {...props}>
|
|
48
|
+
{children}
|
|
49
|
+
<DialogContent className="sm:max-w-96">
|
|
50
|
+
<DialogTitle className="mb-2 text-sm font-medium">{title}</DialogTitle>
|
|
51
|
+
{description != null ? (
|
|
52
|
+
<DialogDescription className="mb-4 text-sm text-muted-foreground">
|
|
53
|
+
{description}
|
|
54
|
+
</DialogDescription>
|
|
55
|
+
) : null}
|
|
56
|
+
<div className="flex justify-end gap-2">
|
|
57
|
+
<Button
|
|
58
|
+
variant="ghost"
|
|
59
|
+
size="sm"
|
|
60
|
+
onClick={() => {
|
|
61
|
+
onCancel?.();
|
|
62
|
+
props.onOpenChange?.(false);
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
{cancelLabel}
|
|
66
|
+
</Button>
|
|
67
|
+
<Button
|
|
68
|
+
variant={variant}
|
|
69
|
+
size="sm"
|
|
70
|
+
disabled={confirmDisabled}
|
|
71
|
+
onClick={onConfirm}
|
|
72
|
+
>
|
|
73
|
+
{confirmLabel}
|
|
74
|
+
</Button>
|
|
75
|
+
</div>
|
|
76
|
+
</DialogContent>
|
|
77
|
+
</Dialog>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
36
81
|
return (
|
|
37
82
|
<AlertDialog {...props}>
|
|
38
83
|
{children}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { AnimatePresence, motion } from "motion/react";
|
|
3
|
+
import { IconX } from "@tabler/icons-react";
|
|
4
|
+
import { cn } from "../utils/cn";
|
|
5
|
+
import { Button } from "./button";
|
|
6
|
+
|
|
7
|
+
export interface SlideOverPanelProps {
|
|
8
|
+
open: boolean;
|
|
9
|
+
/** Panel width. Default `"w-96"`. */
|
|
10
|
+
width?: "w-80" | "w-96";
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
header: ReactNode;
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
/** `aria-label` of the close button. Default `"Close panel"`. */
|
|
15
|
+
closeLabel?: string;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Right-side slide-over shell, absolutely positioned inside its nearest
|
|
21
|
+
* positioned parent (not a portal or dialog). Only the close button closes it.
|
|
22
|
+
* Hidden below `md`.
|
|
23
|
+
*/
|
|
24
|
+
export function SlideOverPanel({
|
|
25
|
+
open,
|
|
26
|
+
width = "w-96",
|
|
27
|
+
onClose,
|
|
28
|
+
header,
|
|
29
|
+
children,
|
|
30
|
+
closeLabel = "Close panel",
|
|
31
|
+
className,
|
|
32
|
+
}: SlideOverPanelProps) {
|
|
33
|
+
return (
|
|
34
|
+
<AnimatePresence initial={false}>
|
|
35
|
+
{open && (
|
|
36
|
+
<motion.div
|
|
37
|
+
key="slide-over-panel"
|
|
38
|
+
initial={{ x: "100%" }}
|
|
39
|
+
animate={{ x: 0 }}
|
|
40
|
+
exit={{ x: "100%" }}
|
|
41
|
+
transition={{ type: "spring", damping: 30, stiffness: 300 }}
|
|
42
|
+
className={cn(
|
|
43
|
+
`absolute top-0 right-0 bottom-0 ${width} z-20 glass-panel-strong smooth-shadow-ring-xl overflow-y-auto hidden md:flex flex-col`,
|
|
44
|
+
className,
|
|
45
|
+
)}
|
|
46
|
+
>
|
|
47
|
+
<div className="flex items-start justify-between p-4 pb-2">
|
|
48
|
+
{header}
|
|
49
|
+
<Button
|
|
50
|
+
type="button"
|
|
51
|
+
variant="ghost"
|
|
52
|
+
size="icon-sm"
|
|
53
|
+
onClick={onClose}
|
|
54
|
+
className="ml-2 shrink-0 text-muted-foreground hover:text-foreground"
|
|
55
|
+
aria-label={closeLabel}
|
|
56
|
+
>
|
|
57
|
+
<IconX size={16} />
|
|
58
|
+
</Button>
|
|
59
|
+
</div>
|
|
60
|
+
{children}
|
|
61
|
+
</motion.div>
|
|
62
|
+
)}
|
|
63
|
+
</AnimatePresence>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { IconSearch, IconX } from "@tabler/icons-react";
|
|
3
|
+
import { cn } from "../utils/cn";
|
|
4
|
+
import { Button } from "./button";
|
|
5
|
+
import { Input } from "./input";
|
|
6
|
+
|
|
7
|
+
export interface ToolbarSearchInputProps {
|
|
8
|
+
value: string;
|
|
9
|
+
onChange: (value: string) => void;
|
|
10
|
+
/** Default `"Search..."`. */
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
/** `aria-label` of the input. Default `"Search"`. */
|
|
13
|
+
label?: string;
|
|
14
|
+
/** `aria-label` of the clear button. Default `"Clear search"`. */
|
|
15
|
+
clearLabel?: string;
|
|
16
|
+
className?: string;
|
|
17
|
+
inputClassName?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Stable rounded search field for toolbars: no enter or clear animations, no
|
|
22
|
+
* layout jump. The clear button stays mounted and is hidden while the value is
|
|
23
|
+
* blank. Clearing does not move focus. The ref points at the `<input>`.
|
|
24
|
+
*/
|
|
25
|
+
export const ToolbarSearchInput = React.forwardRef<HTMLInputElement, ToolbarSearchInputProps>(
|
|
26
|
+
function ToolbarSearchInput(
|
|
27
|
+
{
|
|
28
|
+
value,
|
|
29
|
+
onChange,
|
|
30
|
+
placeholder = "Search...",
|
|
31
|
+
label = "Search",
|
|
32
|
+
clearLabel = "Clear search",
|
|
33
|
+
className,
|
|
34
|
+
inputClassName,
|
|
35
|
+
},
|
|
36
|
+
ref,
|
|
37
|
+
) {
|
|
38
|
+
const active = value.trim().length > 0;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className={cn("relative min-w-0 flex-1 sm:flex-none", className)}>
|
|
42
|
+
<IconSearch
|
|
43
|
+
size={14}
|
|
44
|
+
className="pointer-events-none absolute left-2.5 top-1/2 z-[1] -translate-y-1/2 text-muted-foreground"
|
|
45
|
+
/>
|
|
46
|
+
<Input
|
|
47
|
+
ref={ref}
|
|
48
|
+
type="text"
|
|
49
|
+
value={value}
|
|
50
|
+
onChange={(event) => onChange(event.target.value)}
|
|
51
|
+
placeholder={placeholder}
|
|
52
|
+
aria-label={label}
|
|
53
|
+
enterKeyHint="search"
|
|
54
|
+
className={cn(
|
|
55
|
+
// match outline toolbar buttons beside it
|
|
56
|
+
"h-8 w-full min-w-0 rounded-lg border border-border bg-transparent pl-8 pr-8 text-sm shadow-none sm:w-44 md:w-52",
|
|
57
|
+
"placeholder:text-muted-foreground/70 hover:bg-default",
|
|
58
|
+
"focus-visible:border-border focus-visible:bg-default focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
59
|
+
inputClassName,
|
|
60
|
+
)}
|
|
61
|
+
/>
|
|
62
|
+
<Button
|
|
63
|
+
type="button"
|
|
64
|
+
variant="ghost"
|
|
65
|
+
size="icon-sm"
|
|
66
|
+
aria-label={clearLabel}
|
|
67
|
+
tabIndex={active ? 0 : -1}
|
|
68
|
+
aria-hidden={!active}
|
|
69
|
+
className={cn(
|
|
70
|
+
"absolute right-0.5 top-1/2 z-[1] h-7 w-7 -translate-y-1/2 text-muted-foreground hover:text-foreground",
|
|
71
|
+
!active && "pointer-events-none opacity-0",
|
|
72
|
+
)}
|
|
73
|
+
onClick={() => onChange("")}
|
|
74
|
+
>
|
|
75
|
+
<IconX size={14} stroke={1.75} />
|
|
76
|
+
</Button>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
);
|