@dumiflex/wp-kit 0.3.0 → 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 -51
  2. package/LICENSE +674 -674
  3. package/README.md +73 -76
  4. package/package.json +78 -77
  5. package/src/Avatar.vue +83 -83
  6. package/src/Badge.vue +97 -97
  7. package/src/Banner.vue +118 -118
  8. package/src/BookmarkButton.vue +27 -27
  9. package/src/BundleMeta.vue +51 -51
  10. package/src/Button.vue +103 -103
  11. package/src/Card.vue +68 -68
  12. package/src/Checkbox.vue +109 -107
  13. package/src/Chip.vue +163 -97
  14. package/src/ChoiceCards.vue +133 -133
  15. package/src/CloseButton.vue +69 -69
  16. package/src/CodeBlock.vue +119 -119
  17. package/src/ColorField.vue +188 -206
  18. package/src/ColorPicker.vue +201 -201
  19. package/src/Combobox.vue +331 -334
  20. package/src/ConfirmDialog.vue +165 -163
  21. package/src/ConfirmModal.vue +147 -147
  22. package/src/ConstraintMatrixPreview.vue +708 -708
  23. package/src/ContextMenu.vue +386 -379
  24. package/src/CopyButton.vue +156 -156
  25. package/src/DataTable.vue +292 -292
  26. package/src/DatePicker.vue +234 -234
  27. package/src/DescriptionList.vue +73 -73
  28. package/src/DiffView.vue +68 -68
  29. package/src/Disclosure.vue +166 -166
  30. package/src/DragHandle.vue +49 -49
  31. package/src/Drawer.vue +131 -131
  32. package/src/DropZone.vue +196 -196
  33. package/src/DropdownMenu.vue +72 -72
  34. package/src/EmptyState.vue +111 -111
  35. package/src/Field.vue +28 -28
  36. package/src/HsvPicker.vue +208 -208
  37. package/src/Icon.vue +39 -39
  38. package/src/IconPicker.vue +209 -209
  39. package/src/ImageGrid.vue +125 -125
  40. package/src/InlineEdit.vue +183 -183
  41. package/src/Input.vue +99 -99
  42. package/src/Kbd.vue +56 -56
  43. package/src/Lightbox.vue +173 -173
  44. package/src/ListFilter.vue +85 -85
  45. package/src/ListRow.vue +204 -204
  46. package/src/LoadingMark.vue +175 -161
  47. package/src/Logo.vue +60 -60
  48. package/src/Modal.vue +78 -76
  49. package/src/ModalShell.vue +109 -107
  50. package/src/Pagination.vue +106 -106
  51. package/src/Popover.vue +180 -171
  52. package/src/PostCard.vue +210 -211
  53. package/src/ProgressBar.vue +90 -90
  54. package/src/RadioGroup.vue +177 -177
  55. package/src/RangeSlider.vue +183 -183
  56. package/src/RelativeDate.vue +35 -35
  57. package/src/ResizeHandle.vue +143 -143
  58. package/src/SearchInput.vue +192 -199
  59. package/src/Select.vue +644 -641
  60. package/src/Separator.vue +54 -54
  61. package/src/Skeleton.vue +82 -82
  62. package/src/Slider.vue +198 -198
  63. package/src/SortableList.vue +260 -260
  64. package/src/SplitPane.vue +68 -68
  65. package/src/StarButton.vue +40 -40
  66. package/src/Stepper.vue +141 -141
  67. package/src/SupportLinks.vue +93 -93
  68. package/src/TagInput.vue +297 -300
  69. package/src/Textarea.vue +57 -57
  70. package/src/Timeline.vue +92 -92
  71. package/src/ToastCard.vue +119 -119
  72. package/src/ToastHost.vue +40 -40
  73. package/src/Toggle.vue +36 -36
  74. package/src/TokenizedValue.vue +310 -310
  75. package/src/Toolbar.vue +110 -110
  76. package/src/VirtualList.vue +87 -87
  77. package/src/VisuallyHidden.vue +29 -29
  78. package/src/WpButton.vue +75 -75
  79. package/src/WpCheck.vue +104 -102
  80. package/src/WpIconButton.vue +107 -107
  81. package/src/WpImageZoom.vue +544 -544
  82. package/src/WpNumber.vue +178 -183
  83. package/src/WpSegmented.vue +175 -175
  84. package/src/WpSelect.vue +93 -89
  85. package/src/WpSwitch.vue +163 -163
  86. package/src/WpTabs.vue +188 -188
  87. package/src/_modal-motion.css +33 -33
  88. package/src/_skeleton.css +50 -50
  89. package/src/axis-color.ts +42 -42
  90. package/src/button.css +169 -169
  91. package/src/calendar.ts +59 -59
  92. package/src/clipboard.ts +50 -50
  93. package/src/colour-presets.ts +12 -12
  94. package/src/diff.ts +51 -51
  95. package/src/drop-zone.ts +41 -41
  96. package/src/field.css +50 -0
  97. package/src/flip.ts +237 -237
  98. package/src/floating-layer.ts +48 -0
  99. package/src/floating.ts +99 -99
  100. package/src/hsv.ts +100 -100
  101. package/src/icon-catalog.ts +52 -52
  102. package/src/image-zoom.ts +93 -93
  103. package/src/kind-colour.ts +28 -0
  104. package/src/logo-paths.ts +21 -21
  105. package/src/matrix-axis.ts +91 -91
  106. package/src/pagination.ts +27 -27
  107. package/src/platform-keys.ts +29 -29
  108. package/src/rich-tokenize.ts +357 -357
  109. package/src/select-types.ts +32 -32
  110. package/src/slider-math.ts +45 -45
  111. package/src/sortable.ts +22 -22
  112. package/src/theme/fonts/OFL-Fraunces.txt +93 -93
  113. package/src/theme/fonts/OFL-JetBrainsMono.txt +93 -93
  114. package/src/theme/fonts/OFL-Manrope.txt +93 -93
  115. package/src/theme/looks.css +428 -428
  116. package/src/theme/palettes.css +254 -254
  117. package/src/theme/tokens.css +2896 -2894
  118. package/src/tokens.ts +56 -56
  119. package/src/tooltip.css +43 -43
  120. package/src/tooltip.ts +146 -146
  121. package/src/useDialogFocus.ts +73 -73
  122. package/src/useGrowableField.ts +357 -357
  123. package/src/useModalKeyShield.ts +116 -116
  124. package/src/useToast.ts +48 -48
  125. package/src/wp-logo.svg +14 -14
  126. package/src/ChipToggle.vue +0 -92
  127. package/src/KindChip.vue +0 -53
  128. package/src/RatingBadge.vue +0 -42
package/src/tokens.ts CHANGED
@@ -1,56 +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-select-line-h", "--wp-list-row-depth", "--wp-list-row-icon"] 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", "matrix-axis.ts", "BundleMeta.vue", "ConstraintMatrixPreview.vue", "KindChip.vue", "PostCard.vue", "RatingBadge.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;
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;
package/src/tooltip.css CHANGED
@@ -1,43 +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
- }
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 CHANGED
@@ -1,146 +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;
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;
@@ -1,73 +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
- }
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
+ }