@epoch32/visage 0.0.0-stage → 0.0.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/LICENSE +373 -0
- package/README.md +110 -2
- package/dist/bundle.dev.js +4875 -0
- package/dist/index.js +9633 -0
- package/dist/src/api/index.js +4002 -0
- package/dist/src/charts/index.js +2717 -0
- package/dist/src/devtools/index.js +4677 -0
- package/dist/src/dom/index.js +1550 -0
- package/dist/src/form/index.js +1415 -0
- package/dist/src/index.js +11826 -0
- package/dist/src/jsx-runtime/index.js +1630 -0
- package/dist/src/query/index.js +2422 -0
- package/dist/src/router/index.js +1856 -0
- package/dist/src/rpc/index.js +3484 -0
- package/dist/src/server/index.js +1173 -0
- package/dist/src/signals/index.js +1077 -0
- package/dist/src/style/index.js +2340 -0
- package/dist/src/template/index.js +1149 -0
- package/dist/src/ui/index.js +4155 -0
- package/dist/src/virtual/index.js +1955 -0
- package/dist/src/vite/index.js +984 -0
- package/dist/vite/index.js +707 -0
- package/package.json +140 -4
- package/src/api/client.ts +371 -0
- package/src/api/file-router.ts +183 -0
- package/src/api/handler.ts +326 -0
- package/src/api/index.ts +71 -0
- package/src/api/middleware.ts +54 -0
- package/src/api/pragma.ts +155 -0
- package/src/api/procedure.ts +172 -0
- package/src/api/types.ts +249 -0
- package/src/charts/Area.tsx +111 -0
- package/src/charts/Axis.tsx +126 -0
- package/src/charts/Bar.tsx +86 -0
- package/src/charts/CartesianGrid.tsx +72 -0
- package/src/charts/Chart.tsx +269 -0
- package/src/charts/Cursor.tsx +42 -0
- package/src/charts/Legend.tsx +56 -0
- package/src/charts/Line.tsx +106 -0
- package/src/charts/Pie.tsx +88 -0
- package/src/charts/Tooltip.tsx +127 -0
- package/src/charts/context.ts +30 -0
- package/src/charts/index.ts +14 -0
- package/src/charts/path.ts +155 -0
- package/src/charts/scale.ts +133 -0
- package/src/charts/types.ts +50 -0
- package/src/devtools/DomTab.tsx +134 -0
- package/src/devtools/GraphInspector.tsx +196 -0
- package/src/devtools/GraphTab.tsx +315 -0
- package/src/devtools/QueryTab.tsx +200 -0
- package/src/devtools/RpcTab.tsx +190 -0
- package/src/devtools/SignalsTab.tsx +129 -0
- package/src/devtools/component.tsx +243 -0
- package/src/devtools/highlighter.ts +134 -0
- package/src/devtools/hub.ts +175 -0
- package/src/devtools/index.ts +20 -0
- package/src/devtools/styles.ts +313 -0
- package/src/devtools/types.ts +45 -0
- package/src/dom/async.ts +232 -0
- package/src/dom/control-flow.ts +353 -0
- package/src/dom/index.ts +32 -0
- package/src/dom/lazy.ts +82 -0
- package/src/dom/render.ts +286 -0
- package/src/dom/resource.ts +120 -0
- package/src/form/form.ts +218 -0
- package/src/form/index.ts +17 -0
- package/src/form/schema.ts +299 -0
- package/src/index.ts +413 -0
- package/src/jsx-dev-runtime/index.ts +1 -0
- package/src/jsx-runtime/index.ts +112 -0
- package/src/jsx-runtime/jsx.d.ts +278 -0
- package/src/query/create-mutation.ts +64 -0
- package/src/query/create-query.ts +426 -0
- package/src/query/focus-manager.ts +68 -0
- package/src/query/index.ts +63 -0
- package/src/query/mutation-cache.ts +138 -0
- package/src/query/online-manager.ts +67 -0
- package/src/query/query-cache.ts +369 -0
- package/src/query/query-client.ts +248 -0
- package/src/query/types.ts +221 -0
- package/src/query/utils.ts +132 -0
- package/src/router/index.ts +19 -0
- package/src/router/router.tsx +352 -0
- package/src/rpc/client.ts +317 -0
- package/src/rpc/error.ts +70 -0
- package/src/rpc/index.ts +52 -0
- package/src/rpc/procedure.ts +88 -0
- package/src/rpc/router.ts +105 -0
- package/src/rpc/server.ts +511 -0
- package/src/rpc/types.ts +328 -0
- package/src/server/hydrate.ts +21 -0
- package/src/server/index.ts +10 -0
- package/src/server/render-to-stream.ts +31 -0
- package/src/server/render-to-string.ts +125 -0
- package/src/signals/computed.ts +217 -0
- package/src/signals/context.ts +89 -0
- package/src/signals/effect.ts +161 -0
- package/src/signals/index.ts +57 -0
- package/src/signals/scope.ts +132 -0
- package/src/signals/signal.ts +203 -0
- package/src/signals/store.ts +163 -0
- package/src/signals/system.ts +124 -0
- package/src/signals/types.ts +64 -0
- package/src/style/component.tsx +122 -0
- package/src/style/create-style.ts +229 -0
- package/src/style/cx.ts +75 -0
- package/src/style/hash.ts +19 -0
- package/src/style/index.ts +8 -0
- package/src/style/layout.tsx +406 -0
- package/src/style/manager.ts +87 -0
- package/src/style/parser.ts +257 -0
- package/src/style/scale.ts +237 -0
- package/src/template/index.ts +4 -0
- package/src/template/template.ts +126 -0
- package/src/ui/accordion.tsx +286 -0
- package/src/ui/command-dialog.tsx +49 -0
- package/src/ui/command.tsx +400 -0
- package/src/ui/data-table.tsx +322 -0
- package/src/ui/dialog.tsx +362 -0
- package/src/ui/dropdown-menu.tsx +381 -0
- package/src/ui/icon.tsx +69 -0
- package/src/ui/index.ts +138 -0
- package/src/ui/layout.tsx +13 -0
- package/src/ui/popover.tsx +249 -0
- package/src/ui/switch.tsx +114 -0
- package/src/ui/tabs.tsx +297 -0
- package/src/ui/toast.tsx +280 -0
- package/src/ui/tooltip.tsx +262 -0
- package/src/ui/utils.ts +110 -0
- package/src/virtual/index.ts +12 -0
- package/src/virtual/virtual-list.tsx +185 -0
- package/src/virtual/virtualizer.ts +274 -0
- package/src/vite/hmr.ts +38 -0
- package/src/vite/index.ts +3 -0
- package/src/vite/lucide.ts +124 -0
- package/src/vite/plugin.ts +102 -0
- package/src/vite/rpc-middleware.ts +82 -0
- package/src/vite/types.ts +65 -0
package/src/ui/toast.tsx
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { For } from "../dom/control-flow";
|
|
2
|
+
import type { Child } from "../dom/render";
|
|
3
|
+
import { type ReadonlySignal, signal, type WritableSignal } from "../signals";
|
|
4
|
+
import { createContext, useContext } from "../signals/context";
|
|
5
|
+
import { createId } from "./utils";
|
|
6
|
+
|
|
7
|
+
export type ToastVariant = "default" | "success" | "error" | "warning" | "info";
|
|
8
|
+
|
|
9
|
+
export interface ToastData {
|
|
10
|
+
id: string;
|
|
11
|
+
title?: Child;
|
|
12
|
+
description?: Child;
|
|
13
|
+
variant?: ToastVariant;
|
|
14
|
+
duration?: number;
|
|
15
|
+
onDismiss?: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ToastOptions {
|
|
19
|
+
title?: Child;
|
|
20
|
+
description?: Child;
|
|
21
|
+
variant?: ToastVariant;
|
|
22
|
+
duration?: number;
|
|
23
|
+
onDismiss?: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ToastManager {
|
|
27
|
+
(options: ToastOptions | string): string;
|
|
28
|
+
toasts: ReadonlySignal<ToastData[]>;
|
|
29
|
+
show: (options: ToastOptions | string) => string;
|
|
30
|
+
success: (title: string, options?: Omit<ToastOptions, "title" | "variant">) => string;
|
|
31
|
+
error: (title: string, options?: Omit<ToastOptions, "title" | "variant">) => string;
|
|
32
|
+
warning: (title: string, options?: Omit<ToastOptions, "title" | "variant">) => string;
|
|
33
|
+
info: (title: string, options?: Omit<ToastOptions, "title" | "variant">) => string;
|
|
34
|
+
dismiss: (id: string) => void;
|
|
35
|
+
clear: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createToastManager(): ToastManager {
|
|
39
|
+
const toasts: WritableSignal<ToastData[]> = signal<ToastData[]>([]);
|
|
40
|
+
|
|
41
|
+
const dismiss = (id: string) => {
|
|
42
|
+
const item = toasts().find((t) => t.id === id);
|
|
43
|
+
if (item?.onDismiss) item.onDismiss();
|
|
44
|
+
toasts.update((list) => list.filter((t) => t.id !== id));
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const show = (options: ToastOptions | string): string => {
|
|
48
|
+
const id = createId("toast");
|
|
49
|
+
const normalized: ToastOptions = typeof options === "string" ? { title: options } : options;
|
|
50
|
+
|
|
51
|
+
const data: ToastData = {
|
|
52
|
+
id,
|
|
53
|
+
title: normalized.title,
|
|
54
|
+
description: normalized.description,
|
|
55
|
+
variant: normalized.variant ?? "default",
|
|
56
|
+
duration: normalized.duration ?? 5000,
|
|
57
|
+
onDismiss: normalized.onDismiss
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
toasts.update((list) => [...list, data]);
|
|
61
|
+
|
|
62
|
+
if (data.duration && data.duration > 0) {
|
|
63
|
+
setTimeout(() => {
|
|
64
|
+
dismiss(id);
|
|
65
|
+
}, data.duration);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return id;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const success = (title: string, options?: Omit<ToastOptions, "title" | "variant">) =>
|
|
72
|
+
show({ ...options, title, variant: "success" });
|
|
73
|
+
|
|
74
|
+
const error = (title: string, options?: Omit<ToastOptions, "title" | "variant">) =>
|
|
75
|
+
show({ ...options, title, variant: "error" });
|
|
76
|
+
|
|
77
|
+
const warning = (title: string, options?: Omit<ToastOptions, "title" | "variant">) =>
|
|
78
|
+
show({ ...options, title, variant: "warning" });
|
|
79
|
+
|
|
80
|
+
const info = (title: string, options?: Omit<ToastOptions, "title" | "variant">) =>
|
|
81
|
+
show({ ...options, title, variant: "info" });
|
|
82
|
+
|
|
83
|
+
const clear = () => {
|
|
84
|
+
toasts.set([]);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const fn = function toast(options: ToastOptions | string): string {
|
|
88
|
+
return show(options);
|
|
89
|
+
} as ToastManager;
|
|
90
|
+
|
|
91
|
+
fn.toasts = toasts;
|
|
92
|
+
fn.show = show;
|
|
93
|
+
fn.success = success;
|
|
94
|
+
fn.error = error;
|
|
95
|
+
fn.warning = warning;
|
|
96
|
+
fn.info = info;
|
|
97
|
+
fn.dismiss = dismiss;
|
|
98
|
+
fn.clear = clear;
|
|
99
|
+
|
|
100
|
+
return fn;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const defaultToastManager = createToastManager();
|
|
104
|
+
|
|
105
|
+
export const toast = defaultToastManager;
|
|
106
|
+
|
|
107
|
+
export const ToastContext = createContext<ToastManager>(defaultToastManager);
|
|
108
|
+
|
|
109
|
+
export function useToast(): ToastManager {
|
|
110
|
+
return useContext(ToastContext) ?? defaultToastManager;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface ToastProviderProps {
|
|
114
|
+
manager?: ToastManager;
|
|
115
|
+
children: Child | (() => Child);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function ToastProvider(props: ToastProviderProps): Child {
|
|
119
|
+
const manager = props.manager ?? defaultToastManager;
|
|
120
|
+
return ToastContext.Provider({
|
|
121
|
+
value: manager,
|
|
122
|
+
children: props.children
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface ToastViewportProps {
|
|
127
|
+
as?: string;
|
|
128
|
+
class?: any;
|
|
129
|
+
style?: any;
|
|
130
|
+
children?: (item: ToastData) => Child;
|
|
131
|
+
[key: string]: any;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function ToastViewport(props: ToastViewportProps): Child {
|
|
135
|
+
const manager = useToast();
|
|
136
|
+
const { as: Tag = "section", children, class: className, style, ...rest } = props;
|
|
137
|
+
|
|
138
|
+
const defaultRender = (t: ToastData) => (
|
|
139
|
+
<Toast
|
|
140
|
+
id={t.id}
|
|
141
|
+
variant={t.variant}
|
|
142
|
+
class={`visage-toast toast toast-${t.variant ?? "default"}`}
|
|
143
|
+
onDismiss={() => manager.dismiss(t.id)}
|
|
144
|
+
>
|
|
145
|
+
<div class="visage-toast-content">
|
|
146
|
+
{t.title && <ToastTitle class="visage-toast-title">{t.title}</ToastTitle>}
|
|
147
|
+
{t.description && (
|
|
148
|
+
<ToastDescription class="visage-toast-description">{t.description}</ToastDescription>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
<ToastClose class="visage-toast-close" onClick={() => manager.dismiss(t.id)} />
|
|
152
|
+
</Toast>
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const renderItem = children ?? defaultRender;
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<Tag
|
|
159
|
+
role="region"
|
|
160
|
+
aria-label="Notifications"
|
|
161
|
+
class={className}
|
|
162
|
+
style={style}
|
|
163
|
+
tabIndex={-1}
|
|
164
|
+
{...rest}
|
|
165
|
+
>
|
|
166
|
+
<For each={manager.toasts}>{(t) => renderItem(t)}</For>
|
|
167
|
+
</Tag>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface ToastProps {
|
|
172
|
+
id?: string;
|
|
173
|
+
variant?: ToastVariant;
|
|
174
|
+
onDismiss?: () => void;
|
|
175
|
+
as?: string;
|
|
176
|
+
class?: any;
|
|
177
|
+
style?: any;
|
|
178
|
+
children: Child | (() => Child);
|
|
179
|
+
[key: string]: any;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function ToastRoot(props: ToastProps): Child {
|
|
183
|
+
const {
|
|
184
|
+
id,
|
|
185
|
+
variant = "default",
|
|
186
|
+
onDismiss,
|
|
187
|
+
as: Tag = "div",
|
|
188
|
+
children,
|
|
189
|
+
class: className,
|
|
190
|
+
style,
|
|
191
|
+
...rest
|
|
192
|
+
} = props;
|
|
193
|
+
|
|
194
|
+
const role = variant === "error" ? "alert" : "status";
|
|
195
|
+
const ariaLive = variant === "error" ? "assertive" : "polite";
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<Tag
|
|
199
|
+
id={id}
|
|
200
|
+
role={role}
|
|
201
|
+
aria-live={ariaLive}
|
|
202
|
+
data-variant={variant}
|
|
203
|
+
data-state="open"
|
|
204
|
+
class={className}
|
|
205
|
+
style={style}
|
|
206
|
+
{...rest}
|
|
207
|
+
>
|
|
208
|
+
{children}
|
|
209
|
+
</Tag>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface ToastTitleProps {
|
|
214
|
+
as?: string;
|
|
215
|
+
class?: any;
|
|
216
|
+
style?: any;
|
|
217
|
+
children: Child | (() => Child);
|
|
218
|
+
[key: string]: any;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function ToastTitle(props: ToastTitleProps): Child {
|
|
222
|
+
const { as: Tag = "div", children, class: className, style, ...rest } = props;
|
|
223
|
+
return (
|
|
224
|
+
<Tag class={className} style={style} {...rest}>
|
|
225
|
+
{children}
|
|
226
|
+
</Tag>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface ToastDescriptionProps {
|
|
231
|
+
as?: string;
|
|
232
|
+
class?: any;
|
|
233
|
+
style?: any;
|
|
234
|
+
children: Child | (() => Child);
|
|
235
|
+
[key: string]: any;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function ToastDescription(props: ToastDescriptionProps): Child {
|
|
239
|
+
const { as: Tag = "div", children, class: className, style, ...rest } = props;
|
|
240
|
+
return (
|
|
241
|
+
<Tag class={className} style={style} {...rest}>
|
|
242
|
+
{children}
|
|
243
|
+
</Tag>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface ToastCloseProps {
|
|
248
|
+
as?: string;
|
|
249
|
+
class?: any;
|
|
250
|
+
style?: any;
|
|
251
|
+
onClick?: (e: MouseEvent) => void;
|
|
252
|
+
children?: Child | (() => Child);
|
|
253
|
+
[key: string]: any;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function ToastClose(props: ToastCloseProps): Child {
|
|
257
|
+
const { as: Tag = "button", children = "✕", onClick, class: className, style, ...rest } = props;
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<Tag
|
|
261
|
+
type={Tag === "button" ? "button" : undefined}
|
|
262
|
+
aria-label="Close notification"
|
|
263
|
+
class={className}
|
|
264
|
+
style={style}
|
|
265
|
+
onClick={onClick}
|
|
266
|
+
{...rest}
|
|
267
|
+
>
|
|
268
|
+
{children}
|
|
269
|
+
</Tag>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export const Toast = Object.assign(ToastRoot, {
|
|
274
|
+
Root: ToastRoot,
|
|
275
|
+
Provider: ToastProvider,
|
|
276
|
+
Viewport: ToastViewport,
|
|
277
|
+
Title: ToastTitle,
|
|
278
|
+
Description: ToastDescription,
|
|
279
|
+
Close: ToastClose
|
|
280
|
+
});
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { Portal, Show } from "../dom/control-flow";
|
|
2
|
+
import type { Child } from "../dom/render";
|
|
3
|
+
import { onCleanup, type ReadonlySignal } from "../signals";
|
|
4
|
+
import { createContext, useContext } from "../signals/context";
|
|
5
|
+
import { createControllableSignal, createId } from "./utils";
|
|
6
|
+
|
|
7
|
+
export interface TooltipContextValue {
|
|
8
|
+
open: ReadonlySignal<boolean>;
|
|
9
|
+
setOpen: (open: boolean) => void;
|
|
10
|
+
openDelay: number;
|
|
11
|
+
closeDelay: number;
|
|
12
|
+
tooltipId: string;
|
|
13
|
+
triggerRef: { current: HTMLElement | null };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const TooltipContext = createContext<TooltipContextValue | null>(null);
|
|
17
|
+
|
|
18
|
+
export function useTooltipContext(): TooltipContextValue | null {
|
|
19
|
+
return useContext(TooltipContext);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TooltipProps {
|
|
23
|
+
open?: () => boolean | undefined;
|
|
24
|
+
defaultOpen?: boolean;
|
|
25
|
+
onOpenChange?: (open: boolean) => void;
|
|
26
|
+
openDelay?: number;
|
|
27
|
+
closeDelay?: number;
|
|
28
|
+
content?: Child | (() => Child);
|
|
29
|
+
side?: "top" | "bottom" | "left" | "right";
|
|
30
|
+
triggerClass?: any;
|
|
31
|
+
triggerAs?: string;
|
|
32
|
+
contentClass?: any;
|
|
33
|
+
contentStyle?: any;
|
|
34
|
+
portalled?: boolean;
|
|
35
|
+
children?: Child | (() => Child);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function TooltipRoot(props: TooltipProps): Child {
|
|
39
|
+
const [open, setOpen] = createControllableSignal<boolean>({
|
|
40
|
+
value: props.open,
|
|
41
|
+
defaultValue: props.defaultOpen ?? false,
|
|
42
|
+
onChange: props.onOpenChange
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const tooltipId = createId("tooltip");
|
|
46
|
+
const triggerRef: { current: HTMLElement | null } = { current: null };
|
|
47
|
+
|
|
48
|
+
const ctx: TooltipContextValue = {
|
|
49
|
+
open,
|
|
50
|
+
setOpen,
|
|
51
|
+
openDelay: props.openDelay ?? 150,
|
|
52
|
+
closeDelay: props.closeDelay ?? 100,
|
|
53
|
+
tooltipId,
|
|
54
|
+
triggerRef
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
if (props.content !== undefined) {
|
|
58
|
+
return TooltipContext.Provider({
|
|
59
|
+
value: ctx,
|
|
60
|
+
children: () => (
|
|
61
|
+
<span
|
|
62
|
+
class="visage-tooltip-root"
|
|
63
|
+
style={{ position: "relative", display: "inline-flex", alignItems: "center" }}
|
|
64
|
+
>
|
|
65
|
+
<TooltipTrigger
|
|
66
|
+
as={props.triggerAs ?? "button"}
|
|
67
|
+
class={props.triggerClass}
|
|
68
|
+
style={{
|
|
69
|
+
background: "transparent",
|
|
70
|
+
border: "none",
|
|
71
|
+
padding: 0,
|
|
72
|
+
display: "inline-flex",
|
|
73
|
+
alignItems: "center",
|
|
74
|
+
cursor: "help"
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
{typeof props.children === "function" ? props.children() : props.children}
|
|
78
|
+
</TooltipTrigger>
|
|
79
|
+
<TooltipContent
|
|
80
|
+
side={props.side}
|
|
81
|
+
class={props.contentClass}
|
|
82
|
+
style={props.contentStyle}
|
|
83
|
+
portalled={props.portalled ?? false}
|
|
84
|
+
>
|
|
85
|
+
{typeof props.content === "function" ? props.content() : props.content}
|
|
86
|
+
</TooltipContent>
|
|
87
|
+
</span>
|
|
88
|
+
)
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return TooltipContext.Provider({
|
|
93
|
+
value: ctx,
|
|
94
|
+
children: props.children
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface TooltipTriggerProps {
|
|
99
|
+
as?: string;
|
|
100
|
+
class?: any;
|
|
101
|
+
style?: any;
|
|
102
|
+
onMouseEnter?: (e: MouseEvent) => void;
|
|
103
|
+
onMouseLeave?: (e: MouseEvent) => void;
|
|
104
|
+
onFocus?: (e: FocusEvent) => void;
|
|
105
|
+
onBlur?: (e: FocusEvent) => void;
|
|
106
|
+
children: Child | (() => Child);
|
|
107
|
+
[key: string]: any;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function TooltipTrigger(props: TooltipTriggerProps): Child {
|
|
111
|
+
const ctx = useContext(TooltipContext);
|
|
112
|
+
let openTimer: any = null;
|
|
113
|
+
let closeTimer: any = null;
|
|
114
|
+
|
|
115
|
+
const clearTimers = () => {
|
|
116
|
+
if (openTimer) clearTimeout(openTimer);
|
|
117
|
+
if (closeTimer) clearTimeout(closeTimer);
|
|
118
|
+
openTimer = null;
|
|
119
|
+
closeTimer = null;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const handleMouseEnter = (e: MouseEvent) => {
|
|
123
|
+
if (props.onMouseEnter) props.onMouseEnter(e);
|
|
124
|
+
clearTimers();
|
|
125
|
+
if (!ctx) return;
|
|
126
|
+
if (ctx.openDelay > 0) {
|
|
127
|
+
openTimer = setTimeout(() => ctx.setOpen(true), ctx.openDelay);
|
|
128
|
+
} else {
|
|
129
|
+
ctx.setOpen(true);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const handleMouseLeave = (e: MouseEvent) => {
|
|
134
|
+
if (props.onMouseLeave) props.onMouseLeave(e);
|
|
135
|
+
clearTimers();
|
|
136
|
+
if (!ctx) return;
|
|
137
|
+
if (ctx.closeDelay > 0) {
|
|
138
|
+
closeTimer = setTimeout(() => ctx.setOpen(false), ctx.closeDelay);
|
|
139
|
+
} else {
|
|
140
|
+
ctx.setOpen(false);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const handleFocus = (e: FocusEvent) => {
|
|
145
|
+
if (props.onFocus) props.onFocus(e);
|
|
146
|
+
clearTimers();
|
|
147
|
+
ctx?.setOpen(true);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const handleBlur = (e: FocusEvent) => {
|
|
151
|
+
if (props.onBlur) props.onBlur(e);
|
|
152
|
+
clearTimers();
|
|
153
|
+
ctx?.setOpen(false);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
onCleanup(clearTimers);
|
|
157
|
+
|
|
158
|
+
const {
|
|
159
|
+
as: Tag = "button",
|
|
160
|
+
children,
|
|
161
|
+
onMouseEnter,
|
|
162
|
+
onMouseLeave,
|
|
163
|
+
onFocus,
|
|
164
|
+
onBlur,
|
|
165
|
+
class: className,
|
|
166
|
+
style,
|
|
167
|
+
...rest
|
|
168
|
+
} = props;
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<Tag
|
|
172
|
+
ref={(el: HTMLElement | null) => {
|
|
173
|
+
if (ctx) ctx.triggerRef.current = el;
|
|
174
|
+
}}
|
|
175
|
+
type={Tag === "button" ? "button" : undefined}
|
|
176
|
+
tabIndex={Tag === "button" ? undefined : 0}
|
|
177
|
+
aria-describedby={ctx?.tooltipId}
|
|
178
|
+
data-state={() => (ctx?.open() ? "open" : "closed")}
|
|
179
|
+
class={className}
|
|
180
|
+
style={{ display: "inline-flex", alignItems: "center", ...style }}
|
|
181
|
+
onMouseEnter={handleMouseEnter}
|
|
182
|
+
onMouseLeave={handleMouseLeave}
|
|
183
|
+
onFocus={handleFocus}
|
|
184
|
+
onBlur={handleBlur}
|
|
185
|
+
{...rest}
|
|
186
|
+
>
|
|
187
|
+
{children}
|
|
188
|
+
</Tag>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface TooltipPortalProps {
|
|
193
|
+
mount?: HTMLElement | null;
|
|
194
|
+
children: Child | (() => Child);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function TooltipPortal(props: TooltipPortalProps): Child {
|
|
198
|
+
return <Portal mount={props.mount}>{props.children}</Portal>;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface TooltipContentProps {
|
|
202
|
+
forceMount?: boolean;
|
|
203
|
+
portalled?: boolean;
|
|
204
|
+
mount?: HTMLElement | null;
|
|
205
|
+
as?: string;
|
|
206
|
+
class?: any;
|
|
207
|
+
style?: any;
|
|
208
|
+
children: Child | (() => Child);
|
|
209
|
+
[key: string]: any;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function TooltipContent(props: TooltipContentProps): Child {
|
|
213
|
+
const ctx = useContext(TooltipContext);
|
|
214
|
+
const isOpen = () => (ctx ? ctx.open() : false);
|
|
215
|
+
|
|
216
|
+
const {
|
|
217
|
+
forceMount,
|
|
218
|
+
as: Tag = "div",
|
|
219
|
+
children,
|
|
220
|
+
class: className,
|
|
221
|
+
style,
|
|
222
|
+
portalled = true,
|
|
223
|
+
mount,
|
|
224
|
+
...rest
|
|
225
|
+
} = props;
|
|
226
|
+
|
|
227
|
+
const content = forceMount ? (
|
|
228
|
+
<Tag
|
|
229
|
+
id={ctx?.tooltipId}
|
|
230
|
+
role="tooltip"
|
|
231
|
+
data-state={() => (isOpen() ? "open" : "closed")}
|
|
232
|
+
hidden={() => !isOpen()}
|
|
233
|
+
class={className}
|
|
234
|
+
style={style}
|
|
235
|
+
{...rest}
|
|
236
|
+
>
|
|
237
|
+
{children}
|
|
238
|
+
</Tag>
|
|
239
|
+
) : (
|
|
240
|
+
<Show when={isOpen}>
|
|
241
|
+
<Tag
|
|
242
|
+
id={ctx?.tooltipId}
|
|
243
|
+
role="tooltip"
|
|
244
|
+
data-state="open"
|
|
245
|
+
class={className}
|
|
246
|
+
style={style}
|
|
247
|
+
{...rest}
|
|
248
|
+
>
|
|
249
|
+
{children}
|
|
250
|
+
</Tag>
|
|
251
|
+
</Show>
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
return portalled ? <Portal mount={mount}>{content}</Portal> : content;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export const Tooltip = Object.assign(TooltipRoot, {
|
|
258
|
+
Root: TooltipRoot,
|
|
259
|
+
Trigger: TooltipTrigger,
|
|
260
|
+
Portal: TooltipPortal,
|
|
261
|
+
Content: TooltipContent
|
|
262
|
+
});
|
package/src/ui/utils.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { computed, type ReadonlySignal, signal } from "../signals";
|
|
2
|
+
|
|
3
|
+
let idCounter = 0;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generate a unique, deterministic ID for ARIA attributes
|
|
7
|
+
*/
|
|
8
|
+
export function createId(prefix = "visage"): string {
|
|
9
|
+
return `${prefix}-${++idCounter}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Helper for components that support both controlled and uncontrolled signal/value patterns
|
|
14
|
+
*/
|
|
15
|
+
export function createControllableSignal<T>(options: {
|
|
16
|
+
value?: () => T | undefined;
|
|
17
|
+
defaultValue?: T;
|
|
18
|
+
onChange?: (value: T) => void;
|
|
19
|
+
}): [ReadonlySignal<T>, (next: T | ((prev: T) => T)) => void] {
|
|
20
|
+
const isControlled = typeof options.value === "function";
|
|
21
|
+
const internal = signal<T>(options.defaultValue as T, { internal: true });
|
|
22
|
+
|
|
23
|
+
const getter = computed<T>(
|
|
24
|
+
() => {
|
|
25
|
+
if (isControlled) {
|
|
26
|
+
const externalVal = options.value!();
|
|
27
|
+
if (externalVal !== undefined) return externalVal;
|
|
28
|
+
}
|
|
29
|
+
return internal();
|
|
30
|
+
},
|
|
31
|
+
{ internal: true }
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const setter = (next: T | ((prev: T) => T)) => {
|
|
35
|
+
const currentVal = getter();
|
|
36
|
+
const nextVal = typeof next === "function" ? (next as (prev: T) => T)(currentVal) : next;
|
|
37
|
+
|
|
38
|
+
if (!isControlled) {
|
|
39
|
+
internal.set(nextVal);
|
|
40
|
+
}
|
|
41
|
+
if (options.onChange && nextVal !== currentVal) {
|
|
42
|
+
options.onChange(nextVal);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return [getter, setter];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Setup a focus trap inside a container element
|
|
51
|
+
*/
|
|
52
|
+
export function trapFocus(container: HTMLElement, event: KeyboardEvent) {
|
|
53
|
+
if (event.key !== "Tab") return;
|
|
54
|
+
|
|
55
|
+
const focusables = container.querySelectorAll<HTMLElement>(
|
|
56
|
+
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (focusables.length === 0) {
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const first = focusables[0]!;
|
|
65
|
+
const last = focusables[focusables.length - 1]!;
|
|
66
|
+
|
|
67
|
+
if (event.shiftKey) {
|
|
68
|
+
if (document.activeElement === first) {
|
|
69
|
+
last.focus();
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
if (document.activeElement === last) {
|
|
74
|
+
first.focus();
|
|
75
|
+
event.preventDefault();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Listen for click outside a target element
|
|
82
|
+
*/
|
|
83
|
+
export function onClickOutside(
|
|
84
|
+
elements: Array<HTMLElement | null | undefined> | HTMLElement | null | undefined,
|
|
85
|
+
handler: (event: MouseEvent | TouchEvent) => void
|
|
86
|
+
): () => void {
|
|
87
|
+
const listener = (event: MouseEvent | TouchEvent) => {
|
|
88
|
+
const target = event.target as Node | null;
|
|
89
|
+
if (!target) return;
|
|
90
|
+
|
|
91
|
+
const list = Array.isArray(elements) ? elements : [elements];
|
|
92
|
+
const isInside = list.some((el) => el?.contains(target));
|
|
93
|
+
|
|
94
|
+
if (!isInside) {
|
|
95
|
+
handler(event);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
if (typeof document !== "undefined") {
|
|
100
|
+
document.addEventListener("mousedown", listener, true);
|
|
101
|
+
document.addEventListener("touchstart", listener, true);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return () => {
|
|
105
|
+
if (typeof document !== "undefined") {
|
|
106
|
+
document.removeEventListener("mousedown", listener, true);
|
|
107
|
+
document.removeEventListener("touchstart", listener, true);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|