@dumiflex/wp-kit 0.0.0-stage → 0.4.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.
Files changed (128) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/LICENSE +674 -0
  3. package/README.md +72 -2
  4. package/package.json +76 -4
  5. package/src/Avatar.vue +83 -0
  6. package/src/Badge.vue +97 -0
  7. package/src/Banner.vue +118 -0
  8. package/src/BookmarkButton.vue +27 -0
  9. package/src/BundleMeta.vue +51 -0
  10. package/src/Button.vue +103 -0
  11. package/src/Card.vue +68 -0
  12. package/src/Checkbox.vue +109 -0
  13. package/src/Chip.vue +163 -0
  14. package/src/ChoiceCards.vue +133 -0
  15. package/src/CloseButton.vue +69 -0
  16. package/src/CodeBlock.vue +119 -0
  17. package/src/ColorField.vue +188 -0
  18. package/src/ColorPicker.vue +201 -0
  19. package/src/Combobox.vue +331 -0
  20. package/src/ConfirmDialog.vue +165 -0
  21. package/src/ConfirmModal.vue +147 -0
  22. package/src/ConstraintMatrixPreview.vue +708 -0
  23. package/src/ContextMenu.vue +386 -0
  24. package/src/CopyButton.vue +156 -0
  25. package/src/DataTable.vue +292 -0
  26. package/src/DatePicker.vue +234 -0
  27. package/src/DescriptionList.vue +73 -0
  28. package/src/DiffView.vue +68 -0
  29. package/src/Disclosure.vue +166 -0
  30. package/src/DragHandle.vue +49 -0
  31. package/src/Drawer.vue +131 -0
  32. package/src/DropZone.vue +196 -0
  33. package/src/DropdownMenu.vue +72 -0
  34. package/src/EmptyState.vue +111 -0
  35. package/src/Field.vue +28 -0
  36. package/src/HsvPicker.vue +208 -0
  37. package/src/Icon.vue +39 -0
  38. package/src/IconPicker.vue +209 -0
  39. package/src/ImageGrid.vue +125 -0
  40. package/src/InlineEdit.vue +183 -0
  41. package/src/Input.vue +99 -0
  42. package/src/Kbd.vue +56 -0
  43. package/src/Lightbox.vue +173 -0
  44. package/src/ListFilter.vue +85 -0
  45. package/src/ListRow.vue +204 -0
  46. package/src/LoadingMark.vue +175 -0
  47. package/src/Logo.vue +60 -0
  48. package/src/Modal.vue +78 -0
  49. package/src/ModalShell.vue +109 -0
  50. package/src/Pagination.vue +106 -0
  51. package/src/Popover.vue +180 -0
  52. package/src/PostCard.vue +210 -0
  53. package/src/ProgressBar.vue +90 -0
  54. package/src/RadioGroup.vue +177 -0
  55. package/src/RangeSlider.vue +183 -0
  56. package/src/RelativeDate.vue +35 -0
  57. package/src/ResizeHandle.vue +143 -0
  58. package/src/SearchInput.vue +192 -0
  59. package/src/Select.vue +644 -0
  60. package/src/Separator.vue +54 -0
  61. package/src/Skeleton.vue +82 -0
  62. package/src/Slider.vue +198 -0
  63. package/src/SortableList.vue +260 -0
  64. package/src/SplitPane.vue +68 -0
  65. package/src/StarButton.vue +40 -0
  66. package/src/Stepper.vue +141 -0
  67. package/src/SupportLinks.vue +93 -0
  68. package/src/TagInput.vue +297 -0
  69. package/src/Textarea.vue +57 -0
  70. package/src/Timeline.vue +92 -0
  71. package/src/ToastCard.vue +119 -0
  72. package/src/ToastHost.vue +40 -0
  73. package/src/Toggle.vue +36 -0
  74. package/src/TokenizedValue.vue +310 -0
  75. package/src/Toolbar.vue +110 -0
  76. package/src/VirtualList.vue +87 -0
  77. package/src/VisuallyHidden.vue +29 -0
  78. package/src/WpButton.vue +75 -0
  79. package/src/WpCheck.vue +104 -0
  80. package/src/WpIconButton.vue +107 -0
  81. package/src/WpImageZoom.vue +544 -0
  82. package/src/WpNumber.vue +178 -0
  83. package/src/WpSegmented.vue +175 -0
  84. package/src/WpSelect.vue +93 -0
  85. package/src/WpSwitch.vue +163 -0
  86. package/src/WpTabs.vue +188 -0
  87. package/src/_modal-motion.css +33 -0
  88. package/src/_skeleton.css +50 -0
  89. package/src/axis-color.ts +42 -0
  90. package/src/button.css +169 -0
  91. package/src/calendar.ts +59 -0
  92. package/src/clipboard.ts +50 -0
  93. package/src/colour-presets.ts +12 -0
  94. package/src/diff.ts +51 -0
  95. package/src/drop-zone.ts +41 -0
  96. package/src/field.css +50 -0
  97. package/src/flip.ts +237 -0
  98. package/src/floating-layer.ts +48 -0
  99. package/src/floating.ts +99 -0
  100. package/src/hsv.ts +100 -0
  101. package/src/icon-catalog.ts +52 -0
  102. package/src/image-zoom.ts +93 -0
  103. package/src/kind-colour.ts +28 -0
  104. package/src/logo-paths.ts +21 -0
  105. package/src/matrix-axis.ts +91 -0
  106. package/src/pagination.ts +27 -0
  107. package/src/platform-keys.ts +29 -0
  108. package/src/rich-tokenize.ts +357 -0
  109. package/src/select-types.ts +32 -0
  110. package/src/slider-math.ts +45 -0
  111. package/src/sortable.ts +22 -0
  112. package/src/theme/fonts/OFL-Fraunces.txt +93 -0
  113. package/src/theme/fonts/OFL-JetBrainsMono.txt +93 -0
  114. package/src/theme/fonts/OFL-Manrope.txt +93 -0
  115. package/src/theme/fonts/fraunces-latin-wght-normal.woff2 +0 -0
  116. package/src/theme/fonts/jetbrains-mono-latin.woff2 +0 -0
  117. package/src/theme/fonts/manrope-latin-wght-normal.woff2 +0 -0
  118. package/src/theme/looks.css +428 -0
  119. package/src/theme/palettes.css +254 -0
  120. package/src/theme/tokens.css +2896 -0
  121. package/src/tokens.ts +56 -0
  122. package/src/tooltip.css +43 -0
  123. package/src/tooltip.ts +146 -0
  124. package/src/useDialogFocus.ts +73 -0
  125. package/src/useGrowableField.ts +357 -0
  126. package/src/useModalKeyShield.ts +116 -0
  127. package/src/useToast.ts +48 -0
  128. package/src/wp-logo.svg +14 -0
package/src/tokens.ts ADDED
@@ -0,0 +1,56 @@
1
+ // The CSS custom properties a kit piece may read.
2
+ //
3
+ // Most pieces also run on the ComfyUI canvas, whose colour sheet is the
4
+ // extension's own (components/shared/theme.css), not src/theme/. Those pieces
5
+ // read only SHARED_TOKENS, which every host defines, so a piece wears
6
+ // whichever side's colours it sits on. The pieces in PAGE_PIECES only run on
7
+ // pages that load src/theme/tokens.css (the manager, the community site, the
8
+ // kit site), so they may read any name that sheet defines.
9
+ // tokens.test.ts holds both rules.
10
+ //
11
+ // Add a shared name only once every host defines it (docs/tokens.md).
12
+
13
+ export const SHARED_TOKENS = [
14
+ // Surfaces and text
15
+ "--wp-bg", "--wp-bg-1", "--wp-bg-2", "--wp-bg-3", "--wp-bg-4",
16
+ "--wp-text", "--wp-text-muted", "--wp-text-dim",
17
+ "--wp-border", "--wp-border-strong", "--wp-border-soft", "--wp-border-focus",
18
+ "--wp-overlay-bg", "--wp-row-hover", "--wp-input-shade", "--wp-scrollbar-thumb",
19
+ // Accent and states
20
+ "--wp-accent", "--wp-accent-glow", "--wp-accent-text", "--wp-accent-text-strong",
21
+ "--wp-accent-300", "--wp-accent-400", "--wp-accent-500", "--wp-accent-600", "--wp-accent-700", "--wp-accent-800",
22
+ "--wp-focus-ring", "--wp-on-accent", "--wp-on-media", "--wp-scrim",
23
+ "--wp-success", "--wp-warn", "--wp-danger", "--wp-info", "--wp-danger-text",
24
+ "--wp-error-border", "--wp-error-text",
25
+ "--wp-red", "--wp-red-bg", "--wp-green", "--wp-green-bg", "--wp-amber", "--wp-amber-bg",
26
+ "--wp-teal", "--wp-teal-bg", "--wp-violet", "--wp-violet-bg", "--wp-rose", "--wp-rose-bg",
27
+ // Kinds and variables
28
+ "--wp-kind-wildcard", "--wp-kind-fixed", "--wp-kind-combine", "--wp-kind-derivation", "--wp-kind-constraint",
29
+ "--wp-var-1", "--wp-var-2", "--wp-var-3", "--wp-var-4", "--wp-var-5", "--wp-var-6", "--wp-var-7", "--wp-var-8",
30
+ // Shape, depth and type
31
+ "--wp-radius-sm", "--wp-radius", "--wp-radius-md", "--wp-radius-lg", "--wp-radius-xl", "--wp-radius-cap",
32
+ "--wp-shadow-sm", "--wp-shadow", "--wp-shadow-md", "--wp-shadow-lg", "--wp-shadow-xl",
33
+ "--wp-font-sans", "--wp-font-mono",
34
+ "--wp-text-xs", "--wp-text-sm", "--wp-text-base", "--wp-text-md", "--wp-text-lg", "--wp-text-xl",
35
+ "--wp-space-0", "--wp-space-1", "--wp-space-2", "--wp-space-3", "--wp-space-4",
36
+ "--wp-space-5", "--wp-space-6", "--wp-space-7", "--wp-space-8",
37
+ "--wp-control-h-sm", "--wp-control-h-md", "--wp-control-h-lg",
38
+ // Motion
39
+ "--wp-motion-quick", "--wp-motion-hover", "--wp-motion-shimmer", "--wp-motion-fade", "--wp-motion-swap", "--wp-motion-flip",
40
+ "--wp-motion-curve-collapse", "--wp-motion-curve-flip", "--wp-motion-curve-linear",
41
+ ] as const;
42
+
43
+ /** Read with a fallback (`var(--wp-bg-deep, var(--wp-bg))`) or kept for older
44
+ * hosts; a host may leave these out. */
45
+ export const OPTIONAL_TOKENS = ["--wp-bg-deep", "--wp-bg2"] as const;
46
+
47
+ /** Set by the piece itself, on its own root, not by the host. */
48
+ export const LOCAL_TOKENS = ["--wp-hsv-hue", "--wp-lm-t", "--wp-lm-x", "--wp-lm-r", "--wp-lm-s", "--wp-lm-o", "--wp-select-line-h", "--wp-list-row-depth", "--wp-list-row-icon", "--wp-chip-c", "--wp-field-h"] as const;
49
+
50
+ /** Pieces that need a page with src/theme/ loaded (not the canvas). */
51
+ export const PAGE_PIECES = [
52
+ "axis-color.ts", "kind-colour.ts", "matrix-axis.ts", "BundleMeta.vue", "ConstraintMatrixPreview.vue", "PostCard.vue", "TokenizedValue.vue",
53
+ "Button.vue", "Card.vue", "Checkbox.vue", "Chip.vue", "ChoiceCards.vue", "ColorPicker.vue", "ConfirmModal.vue",
54
+ "DataTable.vue", "Drawer.vue", "EmptyState.vue", "Field.vue", "Icon.vue", "IconPicker.vue", "Input.vue",
55
+ "ListFilter.vue", "Modal.vue", "RelativeDate.vue", "Select.vue", "Textarea.vue", "ToastHost.vue", "Toggle.vue",
56
+ ] as const;
@@ -0,0 +1,43 @@
1
+ /* The one shared tooltip element (tooltip.ts). Colours come from tokens both
2
+ * the canvas and the manager define, so it matches whichever side it is on. */
3
+ @layer wp-extension {
4
+ .wp-tooltip {
5
+ position: fixed;
6
+ z-index: 10001;
7
+ display: inline-flex;
8
+ align-items: center;
9
+ gap: 8px;
10
+ max-width: 280px;
11
+ padding: 6px 10px;
12
+ border: 1px solid var(--wp-border-strong);
13
+ border-radius: var(--wp-radius);
14
+ background: var(--wp-bg-3);
15
+ color: var(--wp-text);
16
+ box-shadow: var(--wp-shadow-md);
17
+ font: 500 12px/1.4 var(--wp-font-sans);
18
+ pointer-events: none;
19
+ white-space: normal;
20
+ overflow-wrap: break-word;
21
+ opacity: 0;
22
+ visibility: hidden;
23
+ transition: opacity 90ms ease-out, visibility 0s linear 90ms;
24
+ }
25
+ .wp-tooltip[data-open] {
26
+ opacity: 1;
27
+ visibility: visible;
28
+ transition: opacity 90ms ease-out;
29
+ }
30
+ .wp-tooltip__kbd {
31
+ flex-shrink: 0;
32
+ padding: 1px 6px;
33
+ border: 1px solid var(--wp-border-strong);
34
+ border-radius: var(--wp-radius-sm);
35
+ background: var(--wp-bg-1);
36
+ color: var(--wp-text-muted);
37
+ font: 500 11px/16px var(--wp-font-mono);
38
+ white-space: nowrap;
39
+ }
40
+ @media (prefers-reduced-motion: reduce) {
41
+ .wp-tooltip, .wp-tooltip[data-open] { transition: none; }
42
+ }
43
+ }
package/src/tooltip.ts ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * `v-tooltip`: a short hint shown on hover (after a short wait) and on
3
+ * keyboard focus, hidden on leave, blur or Escape. Use it instead of
4
+ * `title=`, which is slow, unstyled, never shows from the keyboard and is
5
+ * cut off on the canvas.
6
+ *
7
+ * <Button v-tooltip="'Copy the prompt'" icon="pi-copy" aria-label="Copy" />
8
+ * <span v-tooltip="{ text: 'Runs first', placement: 'right' }">…</span>
9
+ * <Button v-tooltip="{ text: 'Copy to clipboard', kbd: 'Ctrl C' }" … />
10
+ *
11
+ * One tooltip element is shared by the whole page, so a thousand hints
12
+ * cost one node. State per element lives in a WeakMap (no expando keys).
13
+ */
14
+ import type { Directive } from "vue";
15
+ import { placeFloating, viewportSize, type Placement } from "./floating";
16
+ import "./tooltip.css";
17
+
18
+ export type TooltipValue =
19
+ | string
20
+ | null
21
+ | undefined
22
+ | {
23
+ text: string | null | undefined;
24
+ placement?: Placement | undefined;
25
+ /** A shortcut shown after the text, e.g. "Ctrl C". */
26
+ kbd?: string | undefined;
27
+ };
28
+
29
+ interface State {
30
+ text: string;
31
+ kbd: string;
32
+ placement: Placement;
33
+ timer: number | undefined;
34
+ off: () => void;
35
+ }
36
+
37
+ const SHOW_DELAY = 450;
38
+ /** Moving from one hint to the next within this window shows it at once. */
39
+ const WARM_MS = 600;
40
+
41
+ const states = new WeakMap<HTMLElement, State>();
42
+ let tipEl: HTMLDivElement | null = null;
43
+ let owner: HTMLElement | null = null;
44
+ let lastHidden = 0;
45
+ let uid = 0;
46
+
47
+ function read(v: TooltipValue): { text: string; kbd: string; placement: Placement } {
48
+ if (v && typeof v === "object") return { text: v.text ?? "", kbd: v.kbd ?? "", placement: v.placement ?? "top" };
49
+ return { text: v ?? "", kbd: "", placement: "top" };
50
+ }
51
+
52
+ function tip(): HTMLDivElement {
53
+ if (tipEl && tipEl.isConnected) return tipEl;
54
+ tipEl = document.createElement("div");
55
+ tipEl.className = "wp-tooltip";
56
+ tipEl.id = `wp-tooltip-${++uid}`;
57
+ tipEl.setAttribute("role", "tooltip");
58
+ document.body.appendChild(tipEl);
59
+ return tipEl;
60
+ }
61
+
62
+ function show(el: HTMLElement): void {
63
+ const s = states.get(el);
64
+ if (!s || !s.text || !el.isConnected) return;
65
+ const t = tip();
66
+ t.textContent = s.text;
67
+ if (s.kbd) {
68
+ const k = document.createElement("kbd");
69
+ k.className = "wp-tooltip__kbd";
70
+ k.textContent = s.kbd;
71
+ t.append(" ", k);
72
+ }
73
+ t.dataset.open = "true";
74
+ owner = el;
75
+ const ids = (el.getAttribute("aria-describedby") ?? "").split(" ").filter(Boolean);
76
+ if (!ids.includes(t.id)) el.setAttribute("aria-describedby", [...ids, t.id].join(" "));
77
+ const at = placeFloating(el.getBoundingClientRect(), { width: t.offsetWidth, height: t.offsetHeight }, viewportSize(), {
78
+ placement: s.placement,
79
+ });
80
+ t.style.left = `${at.x}px`;
81
+ t.style.top = `${at.y}px`;
82
+ t.dataset.side = at.side;
83
+ }
84
+
85
+ function hide(el: HTMLElement): void {
86
+ const s = states.get(el);
87
+ if (s?.timer !== undefined) { window.clearTimeout(s.timer); s.timer = undefined; }
88
+ if (owner !== el) return;
89
+ owner = null;
90
+ lastHidden = Date.now();
91
+ if (tipEl) {
92
+ delete tipEl.dataset.open;
93
+ const ids = (el.getAttribute("aria-describedby") ?? "").split(" ").filter((i) => i && i !== tipEl?.id);
94
+ if (ids.length) el.setAttribute("aria-describedby", ids.join(" "));
95
+ else el.removeAttribute("aria-describedby");
96
+ }
97
+ }
98
+
99
+ function schedule(el: HTMLElement): void {
100
+ const s = states.get(el);
101
+ if (!s) return;
102
+ if (s.timer !== undefined) window.clearTimeout(s.timer);
103
+ const warm = owner !== null || Date.now() - lastHidden < WARM_MS;
104
+ if (warm) { show(el); return; }
105
+ s.timer = window.setTimeout(() => { s.timer = undefined; show(el); }, SHOW_DELAY);
106
+ }
107
+
108
+ export const vTooltip: Directive<HTMLElement, TooltipValue> = {
109
+ mounted(el, binding) {
110
+ const enter = (): void => schedule(el);
111
+ const leave = (): void => hide(el);
112
+ const focus = (): void => { if (el.matches(":focus-visible")) show(el); };
113
+ const key = (ev: KeyboardEvent): void => { if (ev.key === "Escape" && owner === el) hide(el); };
114
+ el.addEventListener("pointerenter", enter);
115
+ el.addEventListener("pointerleave", leave);
116
+ el.addEventListener("pointerdown", leave);
117
+ el.addEventListener("focusin", focus);
118
+ el.addEventListener("focusout", leave);
119
+ el.addEventListener("keydown", key);
120
+ states.set(el, {
121
+ ...read(binding.value),
122
+ timer: undefined,
123
+ off: () => {
124
+ el.removeEventListener("pointerenter", enter);
125
+ el.removeEventListener("pointerleave", leave);
126
+ el.removeEventListener("pointerdown", leave);
127
+ el.removeEventListener("focusin", focus);
128
+ el.removeEventListener("focusout", leave);
129
+ el.removeEventListener("keydown", key);
130
+ },
131
+ });
132
+ },
133
+ updated(el, binding) {
134
+ const s = states.get(el);
135
+ if (!s) return;
136
+ Object.assign(s, read(binding.value));
137
+ if (owner === el) { if (s.text) show(el); else hide(el); }
138
+ },
139
+ beforeUnmount(el) {
140
+ hide(el);
141
+ states.get(el)?.off();
142
+ states.delete(el);
143
+ },
144
+ };
145
+
146
+ export default vTooltip;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Focus handling every dialog-like panel needs (Modal, Drawer): while open,
3
+ * Escape calls `onEscape` and Tab stays inside the panel; on open, focus
4
+ * moves in (`initial`, else the first control, else the panel itself); on
5
+ * close, it goes back to where it was.
6
+ */
7
+ import { nextTick, onBeforeUnmount, watch, type Ref } from "vue";
8
+
9
+ export const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), '
10
+ + 'textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]';
11
+
12
+ export function focusablesIn(root: HTMLElement | null): HTMLElement[] {
13
+ return Array.from(root?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? [])
14
+ .filter((el) => !el.closest("[hidden], [inert]"));
15
+ }
16
+
17
+ export interface DialogFocusOptions {
18
+ onEscape: () => void;
19
+ /** Where focus goes on open; defaults to the first control. */
20
+ initial?: (root: HTMLElement, controls: HTMLElement[]) => HTMLElement | null | undefined;
21
+ /** Keep Tab inside the panel (default). Off for a panel beside the page. */
22
+ trap?: () => boolean;
23
+ }
24
+
25
+ export function useDialogFocus(panel: Ref<HTMLElement | null>, isOpen: () => boolean, opts: DialogFocusOptions): void {
26
+ let returnTo: HTMLElement | null = null;
27
+
28
+ function onKeydown(e: KeyboardEvent): void {
29
+ if (e.key === "Escape") { opts.onEscape(); return; }
30
+ const root = panel.value;
31
+ if (e.key !== "Tab" || !root || opts.trap?.() === false) return;
32
+ const els = focusablesIn(root);
33
+ if (!els.length) { e.preventDefault(); root.focus(); return; }
34
+ const first = els[0];
35
+ const last = els[els.length - 1];
36
+ const at = document.activeElement;
37
+ const inside = at instanceof Node && root.contains(at);
38
+ if (e.shiftKey && (at === first || !inside)) { e.preventDefault(); last?.focus(); }
39
+ else if (!e.shiftKey && (at === last || !inside)) { e.preventDefault(); first?.focus(); }
40
+ }
41
+
42
+ async function opened(): Promise<void> {
43
+ returnTo = document.activeElement instanceof HTMLElement ? document.activeElement : null;
44
+ await nextTick();
45
+ const root = panel.value;
46
+ if (!root || root.contains(document.activeElement)) return;
47
+ const els = focusablesIn(root);
48
+ ((opts.initial ? opts.initial(root, els) : els[0]) ?? root).focus();
49
+ }
50
+
51
+ function closed(): void {
52
+ const el = returnTo;
53
+ returnTo = null;
54
+ if (el?.isConnected) el.focus();
55
+ }
56
+
57
+ watch(isOpen, (v, was) => {
58
+ if (typeof window === "undefined") return;
59
+ if (v) {
60
+ window.addEventListener("keydown", onKeydown);
61
+ void opened();
62
+ } else {
63
+ window.removeEventListener("keydown", onKeydown);
64
+ if (was) closed();
65
+ }
66
+ }, { immediate: true });
67
+
68
+ onBeforeUnmount(() => {
69
+ if (typeof window === "undefined") return;
70
+ window.removeEventListener("keydown", onKeydown);
71
+ if (isOpen()) closed();
72
+ });
73
+ }
@@ -0,0 +1,357 @@
1
+ /**
2
+ * The behaviour every capped, user-resizable text field in this project needs.
3
+ *
4
+ * Three components had grown their own copy of it — `RichTextInput`'s two
5
+ * hosts, the fixed-values `ValueRow`, the combine template — and the copies had
6
+ * already diverged: only one clears the resize grip, only one follows the grip
7
+ * off-screen, and the auto-grow in `ValueRow` silently fought the drag handle.
8
+ * One implementation, three fixes, no third copy.
9
+ *
10
+ * What it provides:
11
+ *
12
+ * 1. **Auto-grow that yields to the user.** The field sizes itself to its
13
+ * content until the user drags the handle; from then on their height is
14
+ * authoritative. Previously `height = scrollHeight` ran on every input and
15
+ * external value change, so a manually collapsed box snapped straight back
16
+ * open — which reads as "the drag sticks, then starts working".
17
+ *
18
+ * 2. **A "more below" flag.** A capped box looks identical whether it holds
19
+ * its whole value or a third of it.
20
+ *
21
+ * 3. **Grip-follow.** Dragging a field taller than the space beneath it moves
22
+ * the box, not the pointer, so the handle being held slides off-screen and
23
+ * the drag goes blind. Scrolling by exactly the overshoot pins the bottom
24
+ * edge to the viewport, keeping the grip under the cursor.
25
+ *
26
+ * DOM-only and framework-light: takes an element getter, returns plain
27
+ * functions plus one ref. Callers own their own `onMounted` / `watch`.
28
+ */
29
+ import { onBeforeUnmount, ref, type Ref } from "vue";
30
+
31
+ export interface GrowableField {
32
+ /** True while content extends past the visible bottom edge. */
33
+ hasMoreBelow: Ref<boolean>;
34
+ /** Re-measure the overflow flag. Cheap; call on scroll and input. */
35
+ updateOverflowHint: () => void;
36
+ /** Same, deferred a frame — use at mount, before layout has settled. */
37
+ scheduleOverflowHint: () => void;
38
+ /** Resize to fit content, UNLESS the user has taken manual control. */
39
+ autosize: () => void;
40
+ /** Begin observing: wires the resize observer for the hint + grip-follow. */
41
+ attach: () => void;
42
+ /** True once the user has dragged the handle. */
43
+ userResized: Ref<boolean>;
44
+ /** Bind to a grip element's `pointerdown`. Replaces `resize: vertical`,
45
+ * which cannot be clamped from outside — see `startResize`. */
46
+ startResize: (ev: PointerEvent) => void;
47
+ }
48
+
49
+ /** Nearest ancestor that actually scrolls, or null when only the page does.
50
+ * Walks computed `overflow-y` rather than trusting a class name — these
51
+ * fields live in modals, pages and canvas widgets, each of which puts its
52
+ * scroller somewhere different. */
53
+ function scrollableAncestor(el: HTMLElement): HTMLElement | null {
54
+ let node = el.parentElement;
55
+ while (node && node !== document.body) {
56
+ const oy = getComputedStyle(node).overflowY;
57
+ if (oy === "auto" || oy === "scroll") return node;
58
+ node = node.parentElement;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ /** Backstop floor for a field whose CSS declares no `min-height`. Enough to
64
+ * keep one line and its padding visible, so a drag can always be undone. */
65
+ const ABSOLUTE_MIN = 28;
66
+
67
+ export function useGrowableField(
68
+ getEl: () => HTMLElement | null,
69
+ opts: { minHeight?: number } = {},
70
+ ): GrowableField {
71
+ const hasMoreBelow = ref(false);
72
+ const userResized = ref(false);
73
+
74
+ function updateOverflowHint(): void {
75
+ const el = getEl();
76
+ if (!el) return;
77
+ // 2px slack absorbs sub-pixel rounding at the exact bottom.
78
+ hasMoreBelow.value = el.scrollHeight - el.scrollTop - el.clientHeight > 2;
79
+ }
80
+
81
+ function scheduleOverflowHint(): void {
82
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(updateOverflowHint);
83
+ else updateOverflowHint();
84
+ }
85
+
86
+ function autosize(): void {
87
+ const el = getEl();
88
+ // The user's drag wins from the moment it happens. Re-running the fit
89
+ // after that is what made a collapsed box spring open again.
90
+ if (!el || userResized.value) return;
91
+ autoDriven = true;
92
+ el.style.height = "auto";
93
+ const next = Math.max(opts.minHeight ?? 0, el.scrollHeight);
94
+ el.style.height = `${next}px`;
95
+ lastHeight = next;
96
+ // Release on the next frame: the observer fires asynchronously, and
97
+ // clearing synchronously would let our own write look like a user drag.
98
+ if (typeof requestAnimationFrame === "function") {
99
+ requestAnimationFrame(() => { autoDriven = false; });
100
+ } else {
101
+ autoDriven = false;
102
+ }
103
+ }
104
+
105
+ /** Keep the grip under the cursor while the box grows past the fold.
106
+ * Only ever scrolls DOWN, and only while the bottom is genuinely
107
+ * off-screen — shrinking must not yank the view around.
108
+ *
109
+ * Deliberately not `scrollIntoView`, which this project bans for scrolling
110
+ * every ancestor rather than the one the user is working in. */
111
+ function followGrip(): void {
112
+ const el = getEl();
113
+ if (!el) return;
114
+ const margin = 8;
115
+ const rect = el.getBoundingClientRect();
116
+ const container = scrollableAncestor(el);
117
+ if (container) {
118
+ const cRect = container.getBoundingClientRect();
119
+ const overshoot = rect.bottom - (cRect.bottom - margin);
120
+ if (overshoot > 0) container.scrollTop += overshoot;
121
+ return;
122
+ }
123
+ const overshoot = rect.bottom - (window.innerHeight - margin);
124
+ if (overshoot > 0) window.scrollBy(0, overshoot);
125
+ }
126
+
127
+ let obs: ResizeObserver | null = null;
128
+ let lastHeight = 0;
129
+ /** Set while `autosize` is writing, so its own change is not mistaken for a
130
+ * drag. Without it the first auto-fit would lock the field immediately. */
131
+ let autoDriven = false;
132
+ /** True between pointerdown on the field and the matching pointerup —
133
+ * i.e. while the user may be holding the resize handle. */
134
+ let dragging = false;
135
+ let pending = false;
136
+ /** Set once the height cap has been dropped for a manual drag. */
137
+ let capLifted = false;
138
+ /** Pointer Y at the previous move — the drag is incremental, so only the
139
+ * step between moves matters, never a captured origin. */
140
+ let lastPointerY = 0;
141
+ /** Floor read from CSS at drag start; CSS stays the source of truth. */
142
+ let minPx = 0;
143
+
144
+ /**
145
+ * Everything the observer wants to do, moved OUT of the observer.
146
+ *
147
+ * A ResizeObserver callback that reads layout and writes reactive state runs
148
+ * before paint, so doing it synchronously on every frame of a resize drag
149
+ * dirties layout inside the very callback the browser is using to report
150
+ * layout. Chrome throttles that (the "ResizeObserver loop" case) and stops
151
+ * delivering for a frame, which the user feels as the drag seizing up until
152
+ * they release and re-grab. Deferring to rAF leaves the callback trivial.
153
+ */
154
+ function flush(): void {
155
+ pending = false;
156
+ const el = getEl();
157
+ if (!el) return;
158
+ const h = el.getBoundingClientRect().height;
159
+ updateOverflowHint();
160
+ // A height change we did not author is the user on the drag handle.
161
+ if (!autoDriven && Math.abs(h - lastHeight) > 1) userResized.value = true;
162
+ // Follows LIVE, including mid-drag. Scrolling by the overshoot moves the
163
+ // element up by exactly that much, landing its bottom edge at the viewport
164
+ // edge — which is where the pointer already is, so the grip stays under the
165
+ // cursor. Deferring this to pointerup (an earlier attempt at the stall,
166
+ // which turned out to be the height cap) just made the field vanish off
167
+ // the bottom for the whole drag.
168
+ //
169
+ // Gated on `dragging`, which is what the name always implied. It exists to
170
+ // keep the GRIP under the CURSOR, and there is no grip being held unless a
171
+ // drag is in progress. Ungated it also ran on content-driven growth — so
172
+ // typing a long enough template scrolled the page out from under you — and
173
+ // it made this flush mutate scroll position, which moves the element the
174
+ // observer is measuring. A resize handler that can change its own input is
175
+ // the shape a runaway needs.
176
+ if (dragging && h > lastHeight) followGrip();
177
+ lastHeight = h;
178
+ }
179
+
180
+ function schedule(): void {
181
+ if (pending) return;
182
+ pending = true;
183
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(flush);
184
+ else flush();
185
+ }
186
+
187
+ /**
188
+ * Our own resize drag, replacing the native `resize: vertical`.
189
+ *
190
+ * The native resizer computes `startHeight + (pointerY - startY)` from an
191
+ * origin it captured at pointerdown, and it does NOT clamp to the element's
192
+ * own min/max. Drag past a limit and that sum keeps running while the
193
+ * rendered box stays pinned, so dragging back moves nothing until the
194
+ * invisible total walks all the way home. Measured: collapsing past a 34px
195
+ * floor then expanding cost 13 of 23 frames of completely dead travel. That
196
+ * is the "gets stuck, then starts working" report, and it bites exactly when
197
+ * the box reaches one line — which is also when the overflow fade returns,
198
+ * which is why the fade kept looking guilty.
199
+ *
200
+ * Nothing outside can fix it: rewriting `style.height` mid-drag is ignored,
201
+ * because the browser recomputes from its own captured origin every move.
202
+ * Clamping the RESULT does not help either — the accumulated input is the
203
+ * problem, so a clamped absolute drag measured the same 13 / 23.
204
+ *
205
+ * Applying each move's DELTA to the current height instead keeps no history
206
+ * to accumulate: the first pixel back off a limit moves the box. Same
207
+ * harness, same over-drag: 0 / 23.
208
+ */
209
+ function startResize(ev: PointerEvent): void {
210
+ const el = getEl();
211
+ if (!el) return;
212
+ ev.preventDefault();
213
+ dragging = true;
214
+ userResized.value = true;
215
+ lastPointerY = ev.clientY;
216
+
217
+ const cs = getComputedStyle(el);
218
+ // CSS stays the source of truth for the floor; the ceiling is deliberately
219
+ // released, because a deliberate drag is the user overriding a cap that
220
+ // exists only to bound AUTO-grow.
221
+ // `min-height: auto` (the default) parses to NaN, so a field that never
222
+ // declared one would get a floor of 0 and could be dragged away to a
223
+ // sliver. CSS stays authoritative when it says anything; ABSOLUTE_MIN is
224
+ // only the backstop for when it does not.
225
+ const declared = Number.parseFloat(cs.minHeight);
226
+ minPx = Number.isFinite(declared) && declared > 0 ? declared : ABSOLUTE_MIN;
227
+ if (!capLifted) {
228
+ el.style.height = `${el.getBoundingClientRect().height}px`;
229
+ el.style.maxHeight = "none";
230
+ capLifted = true;
231
+ }
232
+ /* No `setPointerCapture`.
233
+ *
234
+ * It reads like the right call and it is what stranded the drag. Capture
235
+ * retargets every subsequent pointer event to the grip — including
236
+ * `pointerup`. The editor re-renders while it resizes, and a captured
237
+ * element that gets replaced is detached: the `pointerup` is delivered to a
238
+ * node no longer in the tree, so it never bubbles to `window`, `dragging`
239
+ * stays true, and the box follows the cursor with no button held and no way
240
+ * to stop it. That is the reported symptom exactly.
241
+ *
242
+ * Capture bought nothing here anyway. The listeners below are on `window`,
243
+ * which sees the events regardless of what is under the cursor.
244
+ *
245
+ * CAPTURE PHASE on all three, so a `stopPropagation()` between the target
246
+ * and the window — ComfyUI's node layer does this to stop the canvas
247
+ * panning under a widget — cannot hide the end of the drag from us.
248
+ */
249
+ window.addEventListener("pointermove", onResizeMove, true);
250
+ window.addEventListener("pointerup", onPointerUp, true);
251
+ window.addEventListener("pointercancel", onPointerUp, true);
252
+ // A fresh press means the previous drag is over however it ended.
253
+ window.addEventListener("pointerdown", onPointerUp, true);
254
+ window.addEventListener("blur", onPointerUp);
255
+ window.addEventListener("keydown", onDragEscape, true);
256
+ }
257
+
258
+ /** Escape ends a drag. Cheap, and it is the key a stuck user presses. */
259
+ function onDragEscape(ev: KeyboardEvent): void {
260
+ if (ev.key === "Escape") onPointerUp();
261
+ }
262
+
263
+ function onResizeMove(ev: PointerEvent): void {
264
+ const el = getEl();
265
+ if (!el || !dragging) return;
266
+ // Self-heal. Every pointermove reports which buttons are down, so a move
267
+ // with none held means the pointerup was lost — whatever swallowed it —
268
+ // and the drag should already be over. Without this the field follows the
269
+ // cursor forever and no key or click can stop it, because nothing else in
270
+ // here ever clears `dragging`. Belt and braces on top of the listener
271
+ // changes above: those address the causes we found, this ends the state
272
+ // regardless of a cause we did not.
273
+ if (ev.buttons === 0) {
274
+ onPointerUp();
275
+ return;
276
+ }
277
+ const dy = ev.clientY - lastPointerY;
278
+ lastPointerY = ev.clientY;
279
+ const cur = el.getBoundingClientRect().height;
280
+ el.style.height = `${Math.max(minPx, cur + dy)}px`;
281
+ }
282
+
283
+ function onPointerUp(): void {
284
+ if (!dragging) return;
285
+ dragging = false;
286
+ // Re-arm for the next drag. `capLifted` guards a one-time "pin the current
287
+ // height, release the max-height cap" step, and leaving it set meant the
288
+ // release survived for the lifetime of the field while the explicit height
289
+ // that bounded it did not — `applyAtoms` replaces the host element on a
290
+ // rebuild, which drops the inline height but not this flag. The next drag
291
+ // then found an uncapped, unpinned box. Each drag now re-establishes both
292
+ // against the element that actually exists.
293
+ capLifted = false;
294
+ window.removeEventListener("pointermove", onResizeMove);
295
+ window.removeEventListener("pointerup", onPointerUp);
296
+ window.removeEventListener("pointercancel", onPointerUp);
297
+ }
298
+
299
+ /** Idempotent: callers re-attach when the element they own is REPLACED
300
+ * rather than mutated (RichTextInput swaps its contenteditable host to
301
+ * repair a browser-mangled DOM). Without the disconnect the old observer
302
+ * would keep watching a detached node and leak, and the hint would go
303
+ * permanently stale on the live one. */
304
+ function attach(): void {
305
+ obs?.disconnect();
306
+ obs = null;
307
+ const el = getEl();
308
+ if (!el) return;
309
+ lastHeight = el.getBoundingClientRect().height;
310
+
311
+ /* Re-measure once the webfonts land.
312
+ *
313
+ * These fields are typed in `--wp-font-mono`, a bundled webfont declared
314
+ * `font-display: swap` — so the first paint uses the system fallback
315
+ * (Consolas / Monaco) and the real face swaps in later. The two stacks
316
+ * have different metrics, so the `scrollHeight` measured at mount is taken
317
+ * against type that is about to be replaced.
318
+ *
319
+ * That is the "sometimes the bottom fade is wrong" report: on a cold load
320
+ * the hint is computed against the fallback and never recomputed, and on a
321
+ * warm cache the font is already there so it looks fine. Any later scroll,
322
+ * input or resize also fixes it by accident, which is what made it look
323
+ * intermittent rather than deterministic.
324
+ *
325
+ * `document.fonts.ready` resolves once, after all pending faces settle.
326
+ * Cheap, and a no-op where the font was already cached.
327
+ */
328
+ if (typeof document !== "undefined" && document.fonts?.ready) {
329
+ void document.fonts.ready.then(() => {
330
+ // The field may have unmounted during the load.
331
+ if (!getEl()) return;
332
+ updateOverflowHint();
333
+ autosize();
334
+ });
335
+ }
336
+
337
+ if (typeof ResizeObserver === "undefined") return;
338
+ obs = new ResizeObserver(schedule);
339
+ obs.observe(el);
340
+ }
341
+
342
+ onBeforeUnmount(() => {
343
+ obs?.disconnect();
344
+ obs = null;
345
+ onPointerUp();
346
+ });
347
+
348
+ return {
349
+ hasMoreBelow,
350
+ updateOverflowHint,
351
+ scheduleOverflowHint,
352
+ autosize,
353
+ attach,
354
+ userResized,
355
+ startResize,
356
+ };
357
+ }