@epoch32/visage 1.3.0 → 1.3.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.
Files changed (60) hide show
  1. package/dist/api/index.js +4 -4
  2. package/dist/charts/index.js +5 -5
  3. package/dist/devtools/index.js +7 -7
  4. package/dist/dom/index.js +3 -3
  5. package/dist/form/index.js +2 -2
  6. package/dist/{index-h6tjqk6e.js → index-1jcd8gjf.js} +28 -17
  7. package/dist/{index-6kfe1nr4.js → index-1yhbg34h.js} +41 -9
  8. package/dist/{index-9fvarkrx.js → index-3y73zgbk.js} +1 -1
  9. package/dist/{index-y9bq7h9g.js → index-5jhzwtdz.js} +2 -2
  10. package/dist/{index-a4xns6cc.js → index-65j7a9et.js} +1 -1
  11. package/dist/{index-dyvgtp43.js → index-9bez1tmf.js} +2 -2
  12. package/dist/{index-df5e614s.js → index-awcrz19g.js} +1 -1
  13. package/dist/{index-061jf06a.js → index-cgepvvd4.js} +1 -1
  14. package/dist/{index-1xtrtz1t.js → index-dmhmde2z.js} +2 -2
  15. package/dist/{index-zr6cvset.js → index-dz6n88aq.js} +5 -5
  16. package/dist/{index-0madxn60.js → index-enc8cmea.js} +1 -1
  17. package/dist/{index-js199er4.js → index-g1drdx7k.js} +12 -1
  18. package/dist/{index-grhdtcb5.js → index-k7zd3awa.js} +14 -5
  19. package/dist/{index-8x8xz80v.js → index-kykzcs7p.js} +5 -4
  20. package/dist/{index-dvq5f3s8.js → index-vbsc8z8h.js} +121 -52
  21. package/dist/{index-8czhpea8.js → index-x6whpv54.js} +30 -19
  22. package/dist/{index-5s255b3w.js → index-xby428n5.js} +23 -22
  23. package/dist/{index-x2rs3vab.js → index-y3nprgn7.js} +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.js +20 -18
  26. package/dist/jsx-runtime/index.js +4 -4
  27. package/dist/query/index.js +3 -3
  28. package/dist/router/index.js +5 -5
  29. package/dist/router/router.d.ts +1 -0
  30. package/dist/rpc/index.js +4 -4
  31. package/dist/server/index.js +2 -2
  32. package/dist/signals/index.js +1 -1
  33. package/dist/signals/system.d.ts +2 -0
  34. package/dist/style/index.js +6 -6
  35. package/dist/template/index.js +2 -2
  36. package/dist/ui/dropdown-menu.d.ts +1 -1
  37. package/dist/ui/index.d.ts +1 -0
  38. package/dist/ui/index.js +9 -7
  39. package/dist/ui/overlay.d.ts +53 -0
  40. package/dist/ui/popover.d.ts +1 -1
  41. package/dist/ui/utils.d.ts +1 -1
  42. package/dist/virtual/index.js +5 -5
  43. package/package.json +1 -1
  44. package/src/dom/control-flow.ts +31 -21
  45. package/src/form/form.ts +29 -17
  46. package/src/index.ts +2 -0
  47. package/src/router/router.tsx +12 -2
  48. package/src/signals/effect.ts +2 -2
  49. package/src/signals/scope.ts +10 -8
  50. package/src/signals/store.ts +32 -1
  51. package/src/signals/system.ts +8 -0
  52. package/src/template/template.ts +12 -1
  53. package/src/ui/command-dialog.tsx +18 -1
  54. package/src/ui/dialog.tsx +16 -14
  55. package/src/ui/dropdown-menu.tsx +17 -14
  56. package/src/ui/index.ts +4 -0
  57. package/src/ui/overlay.ts +130 -0
  58. package/src/ui/popover.tsx +20 -22
  59. package/src/ui/utils.ts +8 -2
  60. package/src/virtual/virtualizer.ts +31 -18
@@ -2,6 +2,7 @@ import { Portal } from "../dom/control-flow";
2
2
  import type { Child } from "../dom/render";
3
3
  import type { CommandProps } from "./command";
4
4
  import { CommandRoot } from "./command";
5
+ import { useDismiss } from "./overlay";
5
6
  import { createControllableSignal } from "./utils";
6
7
 
7
8
  export interface CommandDialogProps extends CommandProps {
@@ -19,6 +20,15 @@ export function CommandDialog(props: CommandDialogProps): Child {
19
20
  onChange: props.onOpenChange
20
21
  });
21
22
 
23
+ const contentRef: { current: HTMLElement | null } = { current: null };
24
+
25
+ const dismiss = useDismiss(() => contentRef.current, {
26
+ enabled: () => open(),
27
+ onDismiss: () => setOpen(false),
28
+ escapeKey: true,
29
+ outsidePointer: false
30
+ });
31
+
22
32
  const { overlayClass, contentClass, style, ...rest } = props;
23
33
 
24
34
  return (
@@ -39,8 +49,15 @@ export function CommandDialog(props: CommandDialogProps): Child {
39
49
  onClick={(e: MouseEvent) => {
40
50
  if (e.target === e.currentTarget) setOpen(false);
41
51
  }}
52
+ onKeyDown={dismiss.handleKeyDown}
42
53
  >
43
- <div style={{ width: "100%", maxWidth: "560px" }} class={contentClass}>
54
+ <div
55
+ ref={(el: HTMLElement | null) => {
56
+ contentRef.current = el;
57
+ }}
58
+ style={{ width: "100%", maxWidth: "560px" }}
59
+ class={contentClass}
60
+ >
44
61
  <CommandRoot {...rest} />
45
62
  </div>
46
63
  </div>
package/src/ui/dialog.tsx CHANGED
@@ -2,7 +2,8 @@ import { Portal } from "../dom/control-flow";
2
2
  import type { Child } from "../dom/render";
3
3
  import type { ReadonlySignal } from "../signals";
4
4
  import { createContext, useContext } from "../signals/context";
5
- import { createControllableSignal, createId, trapFocus } from "./utils";
5
+ import { useDismiss } from "./overlay";
6
+ import { createControllableSignal, createId } from "./utils";
6
7
 
7
8
  export interface DialogContextValue {
8
9
  open: ReadonlySignal<boolean>;
@@ -207,25 +208,26 @@ export interface DialogContentProps {
207
208
  export function DialogContent(props: DialogContentProps): Child {
208
209
  const ctx = useContext(DialogContext);
209
210
 
211
+ const dismiss = useDismiss(() => ctx?.contentRef.current, {
212
+ enabled: () => ctx?.open() ?? false,
213
+ onDismiss: () => ctx?.setOpen(false),
214
+ escapeKey: true,
215
+ trapFocus: true,
216
+ outsidePointer: false, // backdrop clicks are handled by DialogOverlay
217
+ closeSelector: "[data-dialog-close]",
218
+ onEscapeKeyDown: (e) => {
219
+ if (props.onKeyDown) props.onKeyDown(e);
220
+ }
221
+ });
222
+
210
223
  const handleKeyDown = (e: KeyboardEvent) => {
211
224
  if (props.onKeyDown) props.onKeyDown(e);
212
- if (e.defaultPrevented) return;
213
-
214
- if (e.key === "Escape") {
215
- ctx?.setOpen(false);
216
- e.stopPropagation();
217
- } else if (e.key === "Tab" && ctx?.contentRef.current) {
218
- trapFocus(ctx.contentRef.current, e);
219
- }
225
+ dismiss.handleKeyDown(e);
220
226
  };
221
227
 
222
228
  const handleClick = (e: MouseEvent) => {
223
229
  if (props.onClick) props.onClick(e);
224
- if (e.defaultPrevented) return;
225
- const target = e.target as HTMLElement | null;
226
- if (target?.closest("[data-dialog-close]")) {
227
- ctx?.setOpen(false);
228
- }
230
+ dismiss.handleClick(e);
229
231
  };
230
232
 
231
233
  const {
@@ -1,8 +1,9 @@
1
1
  import { Portal } from "../dom/control-flow";
2
2
  import type { Child } from "../dom/render";
3
- import { onCleanup, type ReadonlySignal } from "../signals";
3
+ import type { ReadonlySignal } from "../signals";
4
4
  import { createContext, useContext } from "../signals/context";
5
- import { createControllableSignal, createId, onClickOutside } from "./utils";
5
+ import { useDismiss } from "./overlay";
6
+ import { createControllableSignal, createId } from "./utils";
6
7
 
7
8
  export interface DropdownMenuContextValue {
8
9
  open: ReadonlySignal<boolean>;
@@ -207,6 +208,19 @@ export interface DropdownMenuContentProps {
207
208
  export function DropdownMenuContent(props: DropdownMenuContentProps): Child {
208
209
  const ctx = useContext(DropdownMenuContext);
209
210
 
211
+ const dismiss = useDismiss(() => ctx?.contentRef.current, {
212
+ enabled: () => ctx?.open() ?? false,
213
+ onDismiss: () => {
214
+ if (ctx) {
215
+ ctx.setOpen(false);
216
+ ctx.triggerRef.current?.focus();
217
+ }
218
+ },
219
+ escapeKey: true,
220
+ outsidePointer: true,
221
+ outsideIgnore: () => [ctx?.triggerRef.current]
222
+ });
223
+
210
224
  const handleKeyDown = (e: KeyboardEvent) => {
211
225
  if (props.onKeyDown) props.onKeyDown(e);
212
226
  if (e.defaultPrevented) return;
@@ -233,10 +247,7 @@ export function DropdownMenuContent(props: DropdownMenuContentProps): Child {
233
247
  items[items.length - 1]?.focus();
234
248
  e.preventDefault();
235
249
  } else if (e.key === "Escape") {
236
- if (ctx) {
237
- ctx.setOpen(false);
238
- ctx.triggerRef.current?.focus();
239
- }
250
+ dismiss.handleKeyDown(e);
240
251
  e.preventDefault();
241
252
  } else if (e.key === "Tab") {
242
253
  if (ctx) ctx.setOpen(false);
@@ -259,14 +270,6 @@ export function DropdownMenuContent(props: DropdownMenuContentProps): Child {
259
270
  ref={(el: HTMLElement | null) => {
260
271
  if (ctx) {
261
272
  ctx.contentRef.current = el;
262
- if (el) {
263
- const cleanup = onClickOutside([ctx.triggerRef.current, el], () => {
264
- if (ctx.open()) {
265
- ctx.setOpen(false);
266
- }
267
- });
268
- onCleanup(cleanup);
269
- }
270
273
  }
271
274
  }}
272
275
  id={ctx?.menuId}
package/src/ui/index.ts CHANGED
@@ -91,6 +91,10 @@ export {
91
91
  Stack,
92
92
  type StackProps
93
93
  } from "./layout";
94
+ export {
95
+ type UseDismissOptions,
96
+ useDismiss
97
+ } from "./overlay";
94
98
  export {
95
99
  Popover,
96
100
  PopoverClose,
@@ -0,0 +1,130 @@
1
+ import { onCleanup } from "../signals";
2
+ import { onClickOutside, trapFocus } from "./utils";
3
+
4
+ export interface UseDismissOptions {
5
+ /**
6
+ * Whether dismiss listeners are active.
7
+ * Defaults to true.
8
+ */
9
+ enabled?: boolean | (() => boolean);
10
+ /**
11
+ * Callback fired when dismissal is requested (Escape, click outside, or close button).
12
+ */
13
+ onDismiss: () => void;
14
+ /**
15
+ * Close on 'Escape' key press.
16
+ * Defaults to true.
17
+ */
18
+ escapeKey?: boolean;
19
+ /**
20
+ * Close on pointer interaction outside the target element(s).
21
+ * Defaults to true.
22
+ */
23
+ outsidePointer?: boolean;
24
+ /**
25
+ * Additional element(s) to exclude from outside click detection (e.g. trigger button).
26
+ */
27
+ outsideIgnore?:
28
+ | Array<HTMLElement | null | undefined>
29
+ | HTMLElement
30
+ | null
31
+ | undefined
32
+ | (() => Array<HTMLElement | null | undefined>);
33
+ /**
34
+ * Trap keyboard focus inside the container when Tab / Shift+Tab is pressed.
35
+ * Defaults to false.
36
+ */
37
+ trapFocus?: boolean;
38
+ /**
39
+ * Close selector for click events bubbling inside the container (e.g. '[data-dialog-close]').
40
+ */
41
+ closeSelector?: string;
42
+ /**
43
+ * Callback fired when an outside pointer interaction occurs.
44
+ */
45
+ onOutsidePointer?: (event: MouseEvent | TouchEvent) => void;
46
+ /**
47
+ * Callback fired when an Escape keydown occurs.
48
+ */
49
+ onEscapeKeyDown?: (event: KeyboardEvent) => void;
50
+ }
51
+
52
+ /**
53
+ * Headless hook to manage overlay dismissal:
54
+ * - Outside pointer dismissal (click outside)
55
+ * - Escape key dismissal
56
+ * - Keyboard focus trapping (Tab cycle)
57
+ * - Close button selector dismissal delegation
58
+ */
59
+ export function useDismiss(
60
+ element: () => HTMLElement | null | undefined,
61
+ options: UseDismissOptions
62
+ ): {
63
+ handleKeyDown: (e: KeyboardEvent) => void;
64
+ handleClick: (e: MouseEvent) => void;
65
+ } {
66
+ const isEnabled = () => {
67
+ if (typeof options.enabled === "function") return options.enabled();
68
+ return options.enabled ?? true;
69
+ };
70
+
71
+ const handleKeyDown = (e: KeyboardEvent) => {
72
+ if (!isEnabled() || e.defaultPrevented) return;
73
+
74
+ if (e.key === "Escape" && (options.escapeKey ?? true)) {
75
+ if (options.onEscapeKeyDown) {
76
+ options.onEscapeKeyDown(e);
77
+ }
78
+ if (!e.defaultPrevented) {
79
+ options.onDismiss();
80
+ e.stopPropagation();
81
+ }
82
+ } else if (e.key === "Tab" && options.trapFocus) {
83
+ const el = element();
84
+ if (el) {
85
+ trapFocus(el, e);
86
+ }
87
+ }
88
+ };
89
+
90
+ const handleClick = (e: MouseEvent) => {
91
+ if (!isEnabled() || e.defaultPrevented) return;
92
+ if (options.closeSelector) {
93
+ const target = e.target as HTMLElement | null;
94
+ if (target?.closest(options.closeSelector)) {
95
+ options.onDismiss();
96
+ }
97
+ }
98
+ };
99
+
100
+ // Setup outside pointer listener
101
+ if (typeof document !== "undefined") {
102
+ const unbind = onClickOutside(
103
+ () => {
104
+ const el = element();
105
+ const rawIgnores =
106
+ typeof options.outsideIgnore === "function"
107
+ ? options.outsideIgnore()
108
+ : options.outsideIgnore;
109
+ const ignores = Array.isArray(rawIgnores) ? rawIgnores : rawIgnores ? [rawIgnores] : [];
110
+ return [el, ...ignores];
111
+ },
112
+ (event) => {
113
+ if (!isEnabled()) return;
114
+ if (options.outsidePointer === false) return;
115
+ if (options.onOutsidePointer) {
116
+ options.onOutsidePointer(event);
117
+ }
118
+ if (!event.defaultPrevented) {
119
+ options.onDismiss();
120
+ }
121
+ }
122
+ );
123
+ onCleanup(unbind);
124
+ }
125
+
126
+ return {
127
+ handleKeyDown,
128
+ handleClick
129
+ };
130
+ }
@@ -1,8 +1,9 @@
1
1
  import { Portal } from "../dom/control-flow";
2
2
  import type { Child } from "../dom/render";
3
- import { onCleanup, type ReadonlySignal } from "../signals";
3
+ import type { ReadonlySignal } from "../signals";
4
4
  import { createContext, useContext } from "../signals/context";
5
- import { createControllableSignal, createId, onClickOutside } from "./utils";
5
+ import { useDismiss } from "./overlay";
6
+ import { createControllableSignal, createId } from "./utils";
6
7
 
7
8
  export interface PopoverContextValue {
8
9
  open: ReadonlySignal<boolean>;
@@ -138,24 +139,29 @@ export interface PopoverContentProps {
138
139
  export function PopoverContent(props: PopoverContentProps): Child {
139
140
  const ctx = useContext(PopoverContext);
140
141
 
142
+ const dismiss = useDismiss(() => ctx?.contentRef.current, {
143
+ enabled: () => ctx?.open() ?? false,
144
+ onDismiss: () => ctx?.setOpen(false),
145
+ escapeKey: true,
146
+ outsidePointer: true,
147
+ outsideIgnore: () => [ctx?.triggerRef.current],
148
+ closeSelector: "[data-popover-close]",
149
+ onEscapeKeyDown: (e) => {
150
+ if (props.onKeyDown) props.onKeyDown(e);
151
+ if (!e.defaultPrevented) {
152
+ ctx?.triggerRef.current?.focus();
153
+ }
154
+ }
155
+ });
156
+
141
157
  const handleKeyDown = (e: KeyboardEvent) => {
142
158
  if (props.onKeyDown) props.onKeyDown(e);
143
- if (e.defaultPrevented) return;
144
-
145
- if (e.key === "Escape" && ctx) {
146
- ctx.setOpen(false);
147
- ctx.triggerRef.current?.focus();
148
- e.stopPropagation();
149
- }
159
+ dismiss.handleKeyDown(e);
150
160
  };
151
161
 
152
162
  const handleClick = (e: MouseEvent) => {
153
163
  if (props.onClick) props.onClick(e);
154
- if (e.defaultPrevented) return;
155
- const target = e.target as HTMLElement | null;
156
- if (target?.closest("[data-popover-close]")) {
157
- ctx?.setOpen(false);
158
- }
164
+ dismiss.handleClick(e);
159
165
  };
160
166
 
161
167
  const {
@@ -175,14 +181,6 @@ export function PopoverContent(props: PopoverContentProps): Child {
175
181
  ref={(el: HTMLElement | null) => {
176
182
  if (ctx) {
177
183
  ctx.contentRef.current = el;
178
- if (el) {
179
- const cleanup = onClickOutside([ctx.triggerRef.current, el], () => {
180
- if (ctx.open()) {
181
- ctx.setOpen(false);
182
- }
183
- });
184
- onCleanup(cleanup);
185
- }
186
184
  }
187
185
  }}
188
186
  id={ctx?.contentId}
package/src/ui/utils.ts CHANGED
@@ -79,14 +79,20 @@ export function trapFocus(container: HTMLElement, event: KeyboardEvent) {
79
79
  * Listen for click outside a target element
80
80
  */
81
81
  export function onClickOutside(
82
- elements: Array<HTMLElement | null | undefined> | HTMLElement | null | undefined,
82
+ elements:
83
+ | Array<HTMLElement | null | undefined>
84
+ | HTMLElement
85
+ | null
86
+ | undefined
87
+ | (() => Array<HTMLElement | null | undefined>),
83
88
  handler: (event: MouseEvent | TouchEvent) => void
84
89
  ): () => void {
85
90
  const listener = (event: MouseEvent | TouchEvent) => {
86
91
  const target = event.target as Node | null;
87
92
  if (!target) return;
88
93
 
89
- const list = Array.isArray(elements) ? elements : [elements];
94
+ const raw = typeof elements === "function" ? elements() : elements;
95
+ const list = Array.isArray(raw) ? raw : [raw];
90
96
  const isInside = list.some((el) => el?.contains(target));
91
97
 
92
98
  if (!isInside) {
@@ -56,30 +56,41 @@ export function createVirtualizer(options: VirtualizerOptions): Virtualizer {
56
56
  return getItemHeight(index);
57
57
  };
58
58
 
59
- // Compute prefix offsets for fast range calculation
60
- const totalSize = computed(() => {
61
- const count = getCount();
62
- if (typeof options.itemHeight === "number" && dynamicSizeCache.size === 0) {
63
- return count * options.itemHeight;
64
- }
65
- let total = 0;
66
- for (let i = 0; i < count; i++) {
67
- total += getItemSize(i);
68
- }
69
- return total;
70
- });
59
+ // Cached prefix offsets for O(1) offset lookups and true O(log N) binary search
60
+ const offsetsCache: number[] = [0];
61
+ let offsetsValidUpTo = 0;
62
+
63
+ const invalidateOffsetsFrom = (index: number) => {
64
+ offsetsValidUpTo = Math.min(offsetsValidUpTo, index);
65
+ };
71
66
 
72
67
  const getOffsetForIndex = (targetIndex: number): number => {
68
+ if (targetIndex <= 0) return 0;
73
69
  if (typeof options.itemHeight === "number" && dynamicSizeCache.size === 0) {
74
70
  return targetIndex * options.itemHeight;
75
71
  }
76
- let offset = 0;
77
- for (let i = 0; i < targetIndex; i++) {
78
- offset += getItemSize(i);
72
+ if (targetIndex <= offsetsValidUpTo) {
73
+ return offsetsCache[targetIndex]!;
79
74
  }
80
- return offset;
75
+ for (let i = offsetsValidUpTo; i < targetIndex; i++) {
76
+ offsetsCache[i + 1] = (offsetsCache[i] ?? 0) + getItemSize(i);
77
+ }
78
+ offsetsValidUpTo = targetIndex;
79
+ return offsetsCache[targetIndex]!;
81
80
  };
82
81
 
82
+ const measurementVersion = signal(0, { internal: true });
83
+
84
+ // Compute prefix offsets for fast range calculation
85
+ const totalSize = computed(() => {
86
+ measurementVersion();
87
+ const count = getCount();
88
+ if (typeof options.itemHeight === "number" && dynamicSizeCache.size === 0) {
89
+ return count * options.itemHeight;
90
+ }
91
+ return getOffsetForIndex(count);
92
+ });
93
+
83
94
  const findIndexAtOffset = (offset: number): number => {
84
95
  const count = getCount();
85
96
  if (count === 0) return 0;
@@ -110,12 +121,14 @@ export function createVirtualizer(options: VirtualizerOptions): Virtualizer {
110
121
  };
111
122
 
112
123
  const startIndex = computed(() => {
124
+ measurementVersion();
113
125
  const offset = scrollOffset();
114
126
  const rawStart = findIndexAtOffset(offset);
115
127
  return Math.max(0, rawStart - overscan);
116
128
  });
117
129
 
118
130
  const endIndex = computed(() => {
131
+ measurementVersion();
119
132
  const count = getCount();
120
133
  if (count === 0) return 0;
121
134
  const endOffset = scrollOffset() + (containerSize() || 500);
@@ -255,8 +268,8 @@ export function createVirtualizer(options: VirtualizerOptions): Virtualizer {
255
268
  : element.getBoundingClientRect().height;
256
269
  if (measuredSize > 0 && dynamicSizeCache.get(index) !== measuredSize) {
257
270
  dynamicSizeCache.set(index, measuredSize);
258
- // Force recalculation by touching scrollOffset
259
- scrollOffset.update((o) => o);
271
+ invalidateOffsetsFrom(index);
272
+ measurementVersion.update((v) => v + 1);
260
273
  }
261
274
  };
262
275