@kinetixui/ui 0.10.0 → 0.12.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/dist/index.d.ts +403 -1
- package/dist/index.js +1832 -2
- package/package.json +5 -2
- package/src/components/color-picker.tsx +352 -0
- package/src/components/comparison-slider.tsx +98 -0
- package/src/components/data-grid.tsx +304 -0
- package/src/components/diff-viewer.tsx +155 -0
- package/src/components/json-viewer.tsx +168 -0
- package/src/components/kanban-board.tsx +246 -0
- package/src/components/markdown-editor.tsx +288 -0
- package/src/components/marquee.tsx +57 -0
- package/src/components/message-bubble.tsx +83 -0
- package/src/components/multi-select.tsx +134 -0
- package/src/components/notification-center.tsx +119 -0
- package/src/components/page-header.tsx +43 -0
- package/src/components/tour.tsx +156 -0
- package/src/components/tree-view.tsx +273 -0
- package/src/components/virtual-list.tsx +92 -0
- package/src/index.ts +23 -0
- package/src/stories/ColorPicker.stories.tsx +28 -0
- package/src/stories/ComparisonSlider.stories.tsx +27 -0
- package/src/stories/DataGrid.stories.tsx +54 -0
- package/src/stories/DiffViewer.stories.tsx +26 -0
- package/src/stories/JsonViewer.stories.tsx +31 -0
- package/src/stories/KanbanBoard.stories.tsx +44 -0
- package/src/stories/MarkdownEditor.stories.tsx +25 -0
- package/src/stories/Marquee.stories.tsx +28 -0
- package/src/stories/MessageBubble.stories.tsx +32 -0
- package/src/stories/MultiSelect.stories.tsx +35 -0
- package/src/stories/NotificationCenter.stories.tsx +41 -0
- package/src/stories/PageHeader.stories.tsx +47 -0
- package/src/stories/Tour.stories.tsx +70 -0
- package/src/stories/TreeView.stories.tsx +38 -0
- package/src/stories/VirtualList.stories.tsx +36 -0
- package/tailwind.config.ts +4 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Check } from "lucide-react";
|
|
5
|
+
import { Popover, PopoverAnchor, PopoverContent } from "./popover";
|
|
6
|
+
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "./command";
|
|
7
|
+
import { Tag } from "./tag";
|
|
8
|
+
import { cn } from "../lib/utils";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* MultiSelect — a `Combobox` that keeps multiple chips; a free-entry
|
|
12
|
+
* token field with `creatable`. `Combobox` (the existing Popover +
|
|
13
|
+
* Command recipe) is single-value and `Tag` is display-only, so this
|
|
14
|
+
* composes both into an actual shippable component rather than another
|
|
15
|
+
* "recipe" doc page. The trigger is `role="combobox"` on a plain `div`,
|
|
16
|
+
* not a `<button>` — `Tag`'s own remove control is a real `<button>`,
|
|
17
|
+
* and a `<button>` can't nest inside a `<button>` (invalid HTML); using
|
|
18
|
+
* `PopoverAnchor` + manual open-state instead of `PopoverTrigger` avoids
|
|
19
|
+
* that while keeping the whole thing keyboard-operable. Gap-fill
|
|
20
|
+
* addition (not in the original Figma source).
|
|
21
|
+
*/
|
|
22
|
+
export interface MultiSelectOption {
|
|
23
|
+
value: string;
|
|
24
|
+
label: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MultiSelectProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
|
|
28
|
+
options: MultiSelectOption[];
|
|
29
|
+
value?: string[];
|
|
30
|
+
defaultValue?: string[];
|
|
31
|
+
onValueChange?: (value: string[]) => void;
|
|
32
|
+
placeholder?: string;
|
|
33
|
+
/** allow adding free-entry tags that aren't in `options` */
|
|
34
|
+
creatable?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
|
|
38
|
+
(
|
|
39
|
+
{ className, options, value, defaultValue = [], onValueChange, placeholder = "Select…", creatable = false, ...props },
|
|
40
|
+
ref,
|
|
41
|
+
) => {
|
|
42
|
+
const [internal, setInternal] = React.useState(defaultValue);
|
|
43
|
+
const current = value ?? internal;
|
|
44
|
+
const [open, setOpen] = React.useState(false);
|
|
45
|
+
const [query, setQuery] = React.useState("");
|
|
46
|
+
|
|
47
|
+
const set = (next: string[]) => {
|
|
48
|
+
setInternal(next);
|
|
49
|
+
onValueChange?.(next);
|
|
50
|
+
};
|
|
51
|
+
const toggle = (v: string) => set(current.includes(v) ? current.filter((x) => x !== v) : [...current, v]);
|
|
52
|
+
const remove = (v: string) => set(current.filter((x) => x !== v));
|
|
53
|
+
const labelFor = (v: string) => options.find((o) => o.value === v)?.label ?? v;
|
|
54
|
+
|
|
55
|
+
const trimmed = query.trim();
|
|
56
|
+
const canCreate = creatable && trimmed.length > 0 && !options.some((o) => o.label.toLowerCase() === trimmed.toLowerCase());
|
|
57
|
+
const visibleOptions = creatable
|
|
58
|
+
? options.filter((o) => o.label.toLowerCase().includes(trimmed.toLowerCase()))
|
|
59
|
+
: options;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
63
|
+
<PopoverAnchor asChild>
|
|
64
|
+
<div
|
|
65
|
+
ref={ref}
|
|
66
|
+
role="combobox"
|
|
67
|
+
aria-expanded={open}
|
|
68
|
+
aria-haspopup="listbox"
|
|
69
|
+
tabIndex={0}
|
|
70
|
+
onClick={() => setOpen(true)}
|
|
71
|
+
onKeyDown={(e) => {
|
|
72
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
setOpen(true);
|
|
75
|
+
}
|
|
76
|
+
}}
|
|
77
|
+
className={cn(
|
|
78
|
+
"flex min-h-11 w-full flex-wrap items-center gap-1.5 border border-input bg-background px-3 py-2 font-sans",
|
|
79
|
+
"outline-none focus-visible:border-primary focus-visible:shadow-focus",
|
|
80
|
+
"aria-expanded:border-primary aria-expanded:shadow-focus",
|
|
81
|
+
className,
|
|
82
|
+
)}
|
|
83
|
+
{...props}
|
|
84
|
+
>
|
|
85
|
+
{current.length === 0 && <span className="text-body-sm text-muted-foreground">{placeholder}</span>}
|
|
86
|
+
{current.map((v) => (
|
|
87
|
+
<Tag key={v} variant="secondary" onRemove={() => remove(v)} onClick={(e) => e.stopPropagation()}>
|
|
88
|
+
{labelFor(v)}
|
|
89
|
+
</Tag>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
</PopoverAnchor>
|
|
93
|
+
<PopoverContent
|
|
94
|
+
align="start"
|
|
95
|
+
className="w-[var(--radix-popover-trigger-width)] p-0"
|
|
96
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
97
|
+
>
|
|
98
|
+
<Command shouldFilter={!creatable}>
|
|
99
|
+
<CommandInput placeholder="Search…" value={query} onValueChange={setQuery} />
|
|
100
|
+
<CommandList>
|
|
101
|
+
<CommandEmpty>
|
|
102
|
+
{canCreate ? (
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
className="flex w-full items-center px-2 py-1.5 text-left text-body-sm text-foreground hover:bg-accent"
|
|
106
|
+
onClick={() => {
|
|
107
|
+
toggle(trimmed);
|
|
108
|
+
setQuery("");
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
Create “{trimmed}”
|
|
112
|
+
</button>
|
|
113
|
+
) : (
|
|
114
|
+
"No results."
|
|
115
|
+
)}
|
|
116
|
+
</CommandEmpty>
|
|
117
|
+
<CommandGroup>
|
|
118
|
+
{visibleOptions.map((option) => (
|
|
119
|
+
<CommandItem key={option.value} value={option.label} onSelect={() => toggle(option.value)}>
|
|
120
|
+
<Check className={cn("size-4", current.includes(option.value) ? "opacity-100" : "opacity-0")} />
|
|
121
|
+
{option.label}
|
|
122
|
+
</CommandItem>
|
|
123
|
+
))}
|
|
124
|
+
</CommandGroup>
|
|
125
|
+
</CommandList>
|
|
126
|
+
</Command>
|
|
127
|
+
</PopoverContent>
|
|
128
|
+
</Popover>
|
|
129
|
+
);
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
MultiSelect.displayName = "MultiSelect";
|
|
133
|
+
|
|
134
|
+
export { MultiSelect };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Bell } from "lucide-react";
|
|
5
|
+
import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
6
|
+
import { Button } from "./button";
|
|
7
|
+
import { cn } from "../lib/utils";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* NotificationCenter — a bell trigger opening a popover list of
|
|
11
|
+
* read/unread items with a "mark all read" action. Composed directly
|
|
12
|
+
* from `Popover` (re-exported as the root — no new open-state logic)
|
|
13
|
+
* and `Button`; `NotificationItem` follows `ListItem`'s interactive-row
|
|
14
|
+
* convention. `unreadCount`/`unread`/`onMarkAllRead` are plain props —
|
|
15
|
+
* the read-state itself stays the caller's, same as every other
|
|
16
|
+
* controlled/stateless component in this library. Gap-fill addition
|
|
17
|
+
* (not in the original Figma source).
|
|
18
|
+
*/
|
|
19
|
+
const NotificationCenter = Popover;
|
|
20
|
+
|
|
21
|
+
export interface NotificationCenterTriggerProps extends React.ComponentPropsWithoutRef<typeof Button> {
|
|
22
|
+
unreadCount?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const NotificationCenterTrigger = React.forwardRef<HTMLButtonElement, NotificationCenterTriggerProps>(
|
|
26
|
+
({ className, unreadCount = 0, children, ...props }, ref) => (
|
|
27
|
+
<PopoverTrigger asChild>
|
|
28
|
+
<Button
|
|
29
|
+
ref={ref}
|
|
30
|
+
variant="Ghost"
|
|
31
|
+
size="icon"
|
|
32
|
+
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : "Notifications"}
|
|
33
|
+
className={cn("relative", className)}
|
|
34
|
+
{...props}
|
|
35
|
+
>
|
|
36
|
+
{children ?? <Bell className="size-5" />}
|
|
37
|
+
{unreadCount > 0 && (
|
|
38
|
+
<span className="absolute right-1.5 top-1.5 flex size-2 rounded-full bg-destructive ring-2 ring-background" />
|
|
39
|
+
)}
|
|
40
|
+
</Button>
|
|
41
|
+
</PopoverTrigger>
|
|
42
|
+
),
|
|
43
|
+
);
|
|
44
|
+
NotificationCenterTrigger.displayName = "NotificationCenterTrigger";
|
|
45
|
+
|
|
46
|
+
export interface NotificationCenterContentProps extends React.ComponentPropsWithoutRef<typeof PopoverContent> {
|
|
47
|
+
onMarkAllRead?: () => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const NotificationCenterContent = React.forwardRef<HTMLDivElement, NotificationCenterContentProps>(
|
|
51
|
+
({ className, onMarkAllRead, children, align = "end", ...props }, ref) => (
|
|
52
|
+
<PopoverContent ref={ref} align={align} className={cn("w-80 p-0", className)} {...props}>
|
|
53
|
+
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
|
54
|
+
<p className="text-title-sm font-medium text-foreground">Notifications</p>
|
|
55
|
+
{onMarkAllRead && (
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
onClick={onMarkAllRead}
|
|
59
|
+
className="text-label-md font-medium text-primary outline-none hover:underline focus-visible:underline"
|
|
60
|
+
>
|
|
61
|
+
Mark all read
|
|
62
|
+
</button>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
<div className="flex max-h-80 flex-col divide-y divide-border overflow-y-auto">{children}</div>
|
|
66
|
+
</PopoverContent>
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
NotificationCenterContent.displayName = "NotificationCenterContent";
|
|
70
|
+
|
|
71
|
+
export interface NotificationItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
|
|
72
|
+
title: React.ReactNode;
|
|
73
|
+
description?: React.ReactNode;
|
|
74
|
+
time?: React.ReactNode;
|
|
75
|
+
unread?: boolean;
|
|
76
|
+
onSelect?: () => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const NotificationItem = React.forwardRef<HTMLDivElement, NotificationItemProps>(
|
|
80
|
+
({ className, title, description, time, unread, onSelect, ...props }, ref) => {
|
|
81
|
+
const interactive = !!onSelect;
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
ref={ref}
|
|
85
|
+
role={interactive ? "button" : undefined}
|
|
86
|
+
tabIndex={interactive ? 0 : undefined}
|
|
87
|
+
onClick={onSelect}
|
|
88
|
+
onKeyDown={
|
|
89
|
+
interactive
|
|
90
|
+
? (e) => {
|
|
91
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
onSelect?.();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
: undefined
|
|
97
|
+
}
|
|
98
|
+
className={cn(
|
|
99
|
+
"flex items-start gap-3 px-4 py-3 font-sans outline-none",
|
|
100
|
+
interactive && "cursor-pointer hover:bg-accent focus-visible:bg-accent",
|
|
101
|
+
className,
|
|
102
|
+
)}
|
|
103
|
+
{...props}
|
|
104
|
+
>
|
|
105
|
+
<span className={cn("mt-1.5 size-2 shrink-0 rounded-full", unread ? "bg-primary" : "bg-transparent")} />
|
|
106
|
+
<div className="min-w-0 flex-1">
|
|
107
|
+
<p className={cn("truncate text-body-sm", unread ? "font-medium text-foreground" : "text-muted-foreground")}>
|
|
108
|
+
{title}
|
|
109
|
+
</p>
|
|
110
|
+
{description && <p className="truncate text-body-sm text-muted-foreground">{description}</p>}
|
|
111
|
+
{time && <p className="mt-0.5 text-label-sm text-muted-foreground">{time}</p>}
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
NotificationItem.displayName = "NotificationItem";
|
|
118
|
+
|
|
119
|
+
export { NotificationCenter, NotificationCenterTrigger, NotificationCenterContent, NotificationItem };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* PageHeader — title + optional breadcrumb + description + action
|
|
8
|
+
* cluster + optional tabs row. A recipe every docs/app screen re-lays
|
|
9
|
+
* out by hand; `breadcrumb`/`actions`/`tabs` are plain slots so callers
|
|
10
|
+
* compose their own `Breadcrumb`/`Button`/`Tabs` (or `SegmentedControl`)
|
|
11
|
+
* into them rather than PageHeader re-implementing any of those. Gap-fill
|
|
12
|
+
* addition (not in the original Figma source).
|
|
13
|
+
*/
|
|
14
|
+
export interface PageHeaderProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
|
|
15
|
+
title: React.ReactNode;
|
|
16
|
+
description?: React.ReactNode;
|
|
17
|
+
breadcrumb?: React.ReactNode;
|
|
18
|
+
actions?: React.ReactNode;
|
|
19
|
+
tabs?: React.ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const PageHeader = React.forwardRef<HTMLDivElement, PageHeaderProps>(
|
|
23
|
+
({ className, title, description, breadcrumb, actions, tabs, ...props }, ref) => (
|
|
24
|
+
<div
|
|
25
|
+
ref={ref}
|
|
26
|
+
className={cn("flex flex-col gap-4 border-b border-border pb-6 font-sans", className)}
|
|
27
|
+
{...props}
|
|
28
|
+
>
|
|
29
|
+
{breadcrumb && <div className="text-body-sm">{breadcrumb}</div>}
|
|
30
|
+
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
31
|
+
<div className="flex min-w-0 flex-col gap-1">
|
|
32
|
+
<h1 className="text-headline-sm font-medium text-foreground">{title}</h1>
|
|
33
|
+
{description && <p className="text-body-md text-muted-foreground">{description}</p>}
|
|
34
|
+
</div>
|
|
35
|
+
{actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
|
|
36
|
+
</div>
|
|
37
|
+
{tabs && <div className="-mb-6">{tabs}</div>}
|
|
38
|
+
</div>
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
PageHeader.displayName = "PageHeader";
|
|
42
|
+
|
|
43
|
+
export { PageHeader };
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import { Button } from "./button";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tour — sequenced spotlight popovers over real elements (onboarding
|
|
9
|
+
* walkthroughs), with dismiss/skip/next. `target` is a CSS selector
|
|
10
|
+
* resolved against the live DOM on every step change (and on resize/
|
|
11
|
+
* scroll, so the spotlight tracks a target that moves or resizes) rather
|
|
12
|
+
* than a ref, since steps are authored as plain data ahead of the
|
|
13
|
+
* elements existing. The spotlight itself is a single positioned `div`
|
|
14
|
+
* with a `box-shadow: 0 0 0 9999px` — a well-known CSS-only cutout
|
|
15
|
+
* technique, no SVG mask or second overlay layer needed. No focus trap:
|
|
16
|
+
* a tour narrates the page rather than blocking interaction with it, so
|
|
17
|
+
* the underlying page stays operable while a step is shown. Gap-fill
|
|
18
|
+
* addition (not in the original Figma source).
|
|
19
|
+
*/
|
|
20
|
+
export interface TourStep {
|
|
21
|
+
/** CSS selector for the element this step spotlights */
|
|
22
|
+
target: string;
|
|
23
|
+
title: React.ReactNode;
|
|
24
|
+
content: React.ReactNode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TourProps {
|
|
28
|
+
steps: TourStep[];
|
|
29
|
+
open: boolean;
|
|
30
|
+
stepIndex: number;
|
|
31
|
+
onStepIndexChange: (index: number) => void;
|
|
32
|
+
onOpenChange: (open: boolean) => void;
|
|
33
|
+
nextLabel?: React.ReactNode;
|
|
34
|
+
backLabel?: React.ReactNode;
|
|
35
|
+
skipLabel?: React.ReactNode;
|
|
36
|
+
doneLabel?: React.ReactNode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SPOTLIGHT_PADDING = 8;
|
|
40
|
+
|
|
41
|
+
const Tour: React.FC<TourProps> = ({
|
|
42
|
+
steps,
|
|
43
|
+
open,
|
|
44
|
+
stepIndex,
|
|
45
|
+
onStepIndexChange,
|
|
46
|
+
onOpenChange,
|
|
47
|
+
nextLabel = "Next",
|
|
48
|
+
backLabel = "Back",
|
|
49
|
+
skipLabel = "Skip",
|
|
50
|
+
doneLabel = "Done",
|
|
51
|
+
}) => {
|
|
52
|
+
const [mounted, setMounted] = React.useState(false);
|
|
53
|
+
const [rect, setRect] = React.useState<DOMRect | null>(null);
|
|
54
|
+
const step = steps[stepIndex];
|
|
55
|
+
|
|
56
|
+
React.useEffect(() => setMounted(true), []);
|
|
57
|
+
|
|
58
|
+
React.useEffect(() => {
|
|
59
|
+
if (!open || !step) return;
|
|
60
|
+
const update = () => {
|
|
61
|
+
const el = document.querySelector(step.target);
|
|
62
|
+
if (el) {
|
|
63
|
+
setRect(el.getBoundingClientRect());
|
|
64
|
+
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
65
|
+
} else {
|
|
66
|
+
setRect(null);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
update();
|
|
70
|
+
window.addEventListener("resize", update);
|
|
71
|
+
window.addEventListener("scroll", update, true);
|
|
72
|
+
return () => {
|
|
73
|
+
window.removeEventListener("resize", update);
|
|
74
|
+
window.removeEventListener("scroll", update, true);
|
|
75
|
+
};
|
|
76
|
+
}, [open, step]);
|
|
77
|
+
|
|
78
|
+
React.useEffect(() => {
|
|
79
|
+
if (!open) return;
|
|
80
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
81
|
+
if (e.key === "Escape") onOpenChange(false);
|
|
82
|
+
};
|
|
83
|
+
document.addEventListener("keydown", onKeyDown);
|
|
84
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
85
|
+
}, [open, onOpenChange]);
|
|
86
|
+
|
|
87
|
+
if (!mounted || !open || !step) return null;
|
|
88
|
+
|
|
89
|
+
const isFirst = stepIndex === 0;
|
|
90
|
+
const isLast = stepIndex === steps.length - 1;
|
|
91
|
+
|
|
92
|
+
const spotlightStyle: React.CSSProperties = rect
|
|
93
|
+
? {
|
|
94
|
+
top: rect.top - SPOTLIGHT_PADDING,
|
|
95
|
+
left: rect.left - SPOTLIGHT_PADDING,
|
|
96
|
+
width: rect.width + SPOTLIGHT_PADDING * 2,
|
|
97
|
+
height: rect.height + SPOTLIGHT_PADDING * 2,
|
|
98
|
+
boxShadow: "0 0 0 9999px rgb(0 0 0 / 0.6)",
|
|
99
|
+
}
|
|
100
|
+
: { inset: 0, background: "rgb(0 0 0 / 0.6)" };
|
|
101
|
+
|
|
102
|
+
const cardStyle: React.CSSProperties = rect
|
|
103
|
+
? {
|
|
104
|
+
top: Math.min(rect.bottom + SPOTLIGHT_PADDING + 8, window.innerHeight - 220),
|
|
105
|
+
left: Math.min(Math.max(rect.left, 16), window.innerWidth - 336),
|
|
106
|
+
}
|
|
107
|
+
: { top: "50%", left: "50%", transform: "translate(-50%, -50%)" };
|
|
108
|
+
|
|
109
|
+
return createPortal(
|
|
110
|
+
<>
|
|
111
|
+
<div
|
|
112
|
+
aria-hidden
|
|
113
|
+
className="pointer-events-none fixed z-overlay rounded-lg transition-all duration-fast"
|
|
114
|
+
style={spotlightStyle}
|
|
115
|
+
/>
|
|
116
|
+
<div
|
|
117
|
+
role="dialog"
|
|
118
|
+
aria-modal="true"
|
|
119
|
+
className="fixed z-overlay w-80 rounded-lg border border-border bg-popover p-4 font-sans text-popover-foreground shadow-lg transition-all duration-fast"
|
|
120
|
+
style={cardStyle}
|
|
121
|
+
>
|
|
122
|
+
<p className="text-title-sm font-medium text-foreground">{step.title}</p>
|
|
123
|
+
<div className="mt-1.5 text-body-sm text-muted-foreground">{step.content}</div>
|
|
124
|
+
<div className="mt-4 flex items-center justify-between">
|
|
125
|
+
<span className="text-label-sm text-muted-foreground">
|
|
126
|
+
{stepIndex + 1} / {steps.length}
|
|
127
|
+
</span>
|
|
128
|
+
<div className="flex items-center gap-2">
|
|
129
|
+
{!isFirst && (
|
|
130
|
+
<Button variant="Ghost" size="sm" onClick={() => onStepIndexChange(stepIndex - 1)}>
|
|
131
|
+
{backLabel}
|
|
132
|
+
</Button>
|
|
133
|
+
)}
|
|
134
|
+
{!isLast ? (
|
|
135
|
+
<>
|
|
136
|
+
<Button variant="Link" size="sm" onClick={() => onOpenChange(false)}>
|
|
137
|
+
{skipLabel}
|
|
138
|
+
</Button>
|
|
139
|
+
<Button size="sm" onClick={() => onStepIndexChange(stepIndex + 1)}>
|
|
140
|
+
{nextLabel}
|
|
141
|
+
</Button>
|
|
142
|
+
</>
|
|
143
|
+
) : (
|
|
144
|
+
<Button size="sm" onClick={() => onOpenChange(false)}>
|
|
145
|
+
{doneLabel}
|
|
146
|
+
</Button>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
</>,
|
|
152
|
+
document.body,
|
|
153
|
+
);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export { Tour };
|