@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
@@ -1,379 +1,386 @@
1
- <script setup lang="ts">
2
- import { onBeforeUnmount, ref, watch, nextTick } from "vue";
3
- import { placeFloating, viewportSize, type Placement } from "./floating";
4
-
5
- export interface ContextMenuItem {
6
- label: string;
7
- /** PrimeIcons class (e.g. "pi-trash"). ComfyUI loads PrimeIcons globally. */
8
- icon?: string | undefined;
9
- /** Optional separator above the item. */
10
- divider?: boolean | undefined;
11
- /** Render the label in red (destructive action). */
12
- danger?: boolean | undefined;
13
- /** Render the label in accent color to draw attention. Used for
14
- * recommended next actions in a contextual state (e.g. "Push to
15
- * library" when the row's library entry has been deleted upstream).
16
- * Lower-key than `danger`; signals "this is what you probably want"
17
- * not "be careful with this". */
18
- accent?: boolean | undefined;
19
- disabled?: boolean | undefined;
20
- /** Optional dim secondary line under the label. Useful for
21
- * explaining non-obvious actions like "Pull current library
22
- * version" or "Restore frozen state from bundle". When provided,
23
- * the row becomes two-line and uses more vertical space. */
24
- subtitle?: string | undefined;
25
- /** A checkable item (menuitemcheckbox): a tick shows when true. Leave it
26
- * out for a plain action. */
27
- checked?: boolean | undefined;
28
- /** Keep the menu open after this item is picked, so several checkable
29
- * items can be ticked in a row (a tag picker). */
30
- keepOpen?: boolean | undefined;
31
- onSelect: () => void;
32
- }
33
-
34
- /** Section labels group related items inside a single menu. Renders
35
- * as an uppercase, dim, non-interactive separator above the items
36
- * that follow. Distinguished from `ContextMenuItem` at type level
37
- * by the absence of `onSelect`. */
38
- export interface ContextMenuSection {
39
- section: string;
40
- }
41
-
42
- /** Optional header rendered at the top of the menu — kind icon + a
43
- * scope label like "Bundle · subject_phrase". Tells the user WHICH
44
- * entity the menu is for. Color of the icon can be overridden per
45
- * invocation (e.g. bundle's user-picked color, or a kind token). */
46
- export interface ContextMenuHeader {
47
- icon?: string | undefined;
48
- label: string;
49
- iconColor?: string | undefined;
50
- }
51
-
52
- export type ContextMenuEntry = ContextMenuItem | ContextMenuSection;
53
-
54
- function isSection(e: ContextMenuEntry): e is ContextMenuSection {
55
- return "section" in e;
56
- }
57
-
58
- const props = defineProps<{
59
- visible: boolean;
60
- x: number;
61
- y: number;
62
- items: ContextMenuEntry[];
63
- header?: ContextMenuHeader | undefined;
64
- /** Open next to this element instead of at x/y (DropdownMenu's trigger).
65
- * A click on it doesn't count as "outside", so the trigger can toggle. */
66
- anchor?: HTMLElement | null | undefined;
67
- placement?: Placement | undefined;
68
- }>();
69
-
70
- const emit = defineEmits<{ (e: "close"): void }>();
71
-
72
- const activeIndex = ref(-1);
73
- const menuEl = ref<HTMLElement | null>(null);
74
- /** Where the menu sits: x/y (or the anchor), moved to stay on screen. */
75
- const pos = ref({ x: 0, y: 0 });
76
-
77
- function place(): void {
78
- const el = menuEl.value;
79
- const size = el ? { width: el.offsetWidth, height: el.offsetHeight } : { width: 0, height: 0 };
80
- const from = props.anchor?.getBoundingClientRect() ?? { left: props.x, top: props.y, width: 0, height: 0 };
81
- const at = placeFloating(from, size, viewportSize(), { placement: props.placement ?? "bottom-start", gap: props.anchor ? 4 : 0 });
82
- pos.value = { x: at.x, y: at.y };
83
- }
84
-
85
- function pickableIndices(): number[] {
86
- const out: number[] = [];
87
- for (let i = 0; i < props.items.length; i++) {
88
- const e = props.items[i];
89
- if (isSection(e)) continue;
90
- if (!e.disabled) out.push(i);
91
- }
92
- return out;
93
- }
94
-
95
- function moveActive(delta: 1 | -1) {
96
- const idxs = pickableIndices();
97
- if (!idxs.length) return;
98
- const cur = idxs.indexOf(activeIndex.value);
99
- const next = cur < 0 ? (delta > 0 ? 0 : idxs.length - 1) : (cur + delta + idxs.length) % idxs.length;
100
- activeIndex.value = idxs[next];
101
- }
102
-
103
- function gotoEdge(edge: "first" | "last") {
104
- const idxs = pickableIndices();
105
- if (!idxs.length) return;
106
- activeIndex.value = edge === "first" ? idxs[0] : idxs[idxs.length - 1];
107
- }
108
-
109
- function onWindowPointerDown(ev: PointerEvent) {
110
- // Close on any pointerdown outside the menu. Capture phase so
111
- // inner `@click.stop` handlers (trace value cells, seed buttons,
112
- // etc) can't swallow the close — pointerdown fires before click,
113
- // and the capture-phase listener runs before any descendant's
114
- // own listener could stop propagation. Click inside the menu
115
- // is gated by the `@click.stop` on the UL — pointerdown there
116
- // bubbles, but the menu element is detected by composedPath()
117
- // so we don't close.
118
- const path = ev.composedPath();
119
- for (const el of path) {
120
- if (el instanceof HTMLElement && el.classList?.contains("wp-ctxmenu")) {
121
- return;
122
- }
123
- if (props.anchor && el === props.anchor) return;
124
- }
125
- emit("close");
126
- }
127
-
128
- // A menu opened from a trigger follows it as the page scrolls; one opened
129
- // at the pointer closes instead, since the spot it was opened on has moved.
130
- function onScroll(ev: Event): void {
131
- if (ev.target instanceof Node && menuEl.value?.contains(ev.target)) return;
132
- if (props.anchor) place();
133
- else emit("close");
134
- }
135
-
136
- function onKey(ev: KeyboardEvent) {
137
- switch (ev.key) {
138
- case "Escape": ev.preventDefault(); emit("close"); break;
139
- case "ArrowDown": ev.preventDefault(); moveActive(1); break;
140
- case "ArrowUp": ev.preventDefault(); moveActive(-1); break;
141
- case "Home": ev.preventDefault(); gotoEdge("first"); break;
142
- case "End": ev.preventDefault(); gotoEdge("last"); break;
143
- case "Enter":
144
- case " ": {
145
- ev.preventDefault();
146
- const it = props.items[activeIndex.value];
147
- if (it && !isSection(it) && !it.disabled) pick(it);
148
- break;
149
- }
150
- }
151
- }
152
-
153
- watch(() => props.visible, async (v) => {
154
- if (v) {
155
- activeIndex.value = -1;
156
- pos.value = { x: props.x, y: props.y };
157
- // Capture phase so descendant `@click.stop` handlers (trace
158
- // value cells, seed buttons, etc) can't prevent the close.
159
- window.addEventListener("pointerdown", onWindowPointerDown, true);
160
- window.addEventListener("keydown", onKey);
161
- window.addEventListener("scroll", onScroll, true);
162
- window.addEventListener("resize", place);
163
- // Defer one tick so the right-click that opened the menu doesn't
164
- // immediately close it (the synthetic mousedown that preceded the
165
- // contextmenu happens BEFORE the listener is wired, so this is
166
- // mostly belt-and-braces).
167
- await nextTick();
168
- place();
169
- } else {
170
- window.removeEventListener("pointerdown", onWindowPointerDown, true);
171
- window.removeEventListener("keydown", onKey);
172
- window.removeEventListener("scroll", onScroll, true);
173
- window.removeEventListener("resize", place);
174
- }
175
- });
176
-
177
- onBeforeUnmount(() => {
178
- window.removeEventListener("pointerdown", onWindowPointerDown, true);
179
- window.removeEventListener("keydown", onKey);
180
- window.removeEventListener("scroll", onScroll, true);
181
- window.removeEventListener("resize", place);
182
- });
183
-
184
- function pick(item: ContextMenuItem) {
185
- if (item.disabled) return;
186
- item.onSelect();
187
- if (!item.keepOpen) emit("close");
188
- }
189
-
190
- function isSectionEntry(entry: ContextMenuEntry): entry is ContextMenuSection {
191
- return isSection(entry);
192
- }
193
- </script>
194
-
195
- <template>
196
- <Teleport to="body">
197
- <ul
198
- v-if="visible"
199
- ref="menuEl"
200
- class="wp-ctxmenu"
201
- :style="{ left: `${pos.x}px`, top: `${pos.y}px` }"
202
- role="menu"
203
- @click.stop
204
- @contextmenu.prevent
205
- @mouseleave="activeIndex = -1"
206
- >
207
- <!-- Header — kind icon + scope label, only renders when prop provided.
208
- Non-interactive — divider between header and items handled by
209
- the `.wp-ctxmenu__header` border-bottom. -->
210
- <li v-if="header" class="wp-ctxmenu__header" aria-hidden="true">
211
- <i
212
- v-if="header.icon"
213
- :class="['pi', header.icon, 'wp-ctxmenu__header-icon']"
214
- :style="header.iconColor ? { color: header.iconColor } : {}"
215
- ></i>
216
- <span class="wp-ctxmenu__header-label">{{ header.label }}</span>
217
- </li>
218
-
219
- <template v-for="(entry, i) in items" :key="i">
220
- <!-- Section label — uppercase dim title, groups items semantically. -->
221
- <li v-if="isSectionEntry(entry)" class="wp-ctxmenu__section" aria-hidden="true">
222
- {{ entry.section }}
223
- </li>
224
- <!-- Item action — same shape as before plus optional subtitle. -->
225
- <template v-else>
226
- <li v-if="entry.divider" class="wp-ctxmenu__sep" aria-hidden="true"></li>
227
- <!-- The role is menuitem or menuitemcheckbox (bound, so the rule
228
- can't see it); keys come from the menu's own keydown. -->
229
- <!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->
230
- <li
231
- class="wp-ctxmenu__item"
232
- :class="{
233
- 'wp-ctxmenu__item--danger': entry.danger,
234
- 'wp-ctxmenu__item--accent': entry.accent,
235
- 'wp-ctxmenu__item--disabled': entry.disabled,
236
- 'wp-ctxmenu__item--active': activeIndex === i,
237
- 'wp-ctxmenu__item--with-sub': !!entry.subtitle,
238
- }"
239
- :role="entry.checked === undefined ? 'menuitem' : 'menuitemcheckbox'"
240
- :aria-checked="entry.checked === undefined ? undefined : entry.checked"
241
- :aria-disabled="entry.disabled || undefined"
242
- @mouseenter="!entry.disabled && (activeIndex = i)"
243
- @click="pick(entry)"
244
- >
245
- <i
246
- v-if="entry.checked !== undefined"
247
- class="pi pi-check wp-ctxmenu__check"
248
- :data-on="entry.checked || undefined"
249
- aria-hidden="true"
250
- ></i>
251
- <i v-if="entry.icon" :class="['pi', entry.icon, 'wp-ctxmenu__icon']" aria-hidden="true"></i>
252
- <span v-else-if="entry.checked === undefined" class="wp-ctxmenu__icon-spacer" aria-hidden="true"></span>
253
- <span class="wp-ctxmenu__text">
254
- <span class="wp-ctxmenu__title">{{ entry.label }}</span>
255
- <span v-if="entry.subtitle" class="wp-ctxmenu__sub">{{ entry.subtitle }}</span>
256
- </span>
257
- </li>
258
- </template>
259
- </template>
260
- </ul>
261
- </Teleport>
262
- </template>
263
-
264
- <style scoped>
265
- .wp-ctxmenu {
266
- position: fixed;
267
- z-index: 10000;
268
- background: var(--wp-bg-2);
269
- border: 1px solid var(--wp-border-strong);
270
- border-radius: var(--wp-radius-sm);
271
- box-shadow: var(--wp-shadow-md);
272
- /* Tighter padding than the old 4px-0 to match the v2 mockup. Items
273
- * carry their own 6px padding + rounded corners so the menu reads
274
- * as a stack of pills, not a flat list. */
275
- padding: 4px;
276
- /* Bumped from 180px to 250px — subtitles wrap awkwardly at narrower
277
- * widths. Existing callers without subtitles still fit comfortably. */
278
- min-width: 250px;
279
- list-style: none;
280
- margin: 0;
281
- font-family: var(--wp-font-sans, sans-serif);
282
- font-size: 12px;
283
- color: var(--wp-text);
284
- user-select: none;
285
- }
286
- /* Header — scope label at the top of the menu. Border-bottom acts as
287
- * a divider between header and the first item. */
288
- .wp-ctxmenu__header {
289
- display: flex;
290
- align-items: center;
291
- gap: 6px;
292
- padding: 6px 8px 4px 8px;
293
- font: 600 10px/1 var(--wp-font-sans);
294
- color: var(--wp-text-dim);
295
- border-bottom: 1px solid var(--wp-border-soft, var(--wp-border));
296
- margin-bottom: 4px;
297
- }
298
- .wp-ctxmenu__header-icon {
299
- font-size: 12px;
300
- width: 14px;
301
- text-align: center;
302
- }
303
- /* Section label — non-interactive uppercase tag separating groups of
304
- * related items. Smaller + dimmer than the header label so the
305
- * header still reads as the primary scope and sections read as
306
- * subdivisions. */
307
- .wp-ctxmenu__section {
308
- font: 600 9px/1 var(--wp-font-sans);
309
- text-transform: uppercase;
310
- letter-spacing: 0.08em;
311
- color: var(--wp-text-dim);
312
- padding: 6px 8px 4px 8px;
313
- }
314
- .wp-ctxmenu__item {
315
- padding: 6px 8px;
316
- border-radius: min(4px, var(--wp-radius-cap, 999px));
317
- cursor: pointer;
318
- white-space: nowrap;
319
- display: flex;
320
- align-items: center;
321
- gap: 8px;
322
- }
323
- .wp-ctxmenu__item--active,
324
- .wp-ctxmenu__item:hover { background: var(--wp-accent-glow); color: var(--wp-accent); }
325
- /* A checkable item's tick: always takes its room, shows only when on. */
326
- .wp-ctxmenu__check { width: 14px; font-size: 11px; text-align: center; visibility: hidden; color: var(--wp-accent); }
327
- .wp-ctxmenu__check[data-on] { visibility: visible; }
328
- .wp-ctxmenu__item--danger { color: var(--wp-red); }
329
- .wp-ctxmenu__item--danger.wp-ctxmenu__item--active,
330
- .wp-ctxmenu__item--danger:hover { background: var(--wp-red-bg); color: var(--wp-red); }
331
- /* Accent variant — recommended action highlight. Tinted background +
332
- * accent text in idle state so the eye lands on it; hover uses the
333
- * stronger accent-glow + accent text already wired for other hovers. */
334
- .wp-ctxmenu__item--accent {
335
- color: var(--wp-accent);
336
- background: color-mix(in oklab, var(--wp-accent) 12%, transparent);
337
- }
338
- .wp-ctxmenu__item--accent.wp-ctxmenu__item--active,
339
- .wp-ctxmenu__item--accent:hover {
340
- background: var(--wp-accent-glow);
341
- color: var(--wp-accent);
342
- }
343
- .wp-ctxmenu__item--disabled {
344
- color: var(--wp-text-dim);
345
- cursor: not-allowed;
346
- }
347
- .wp-ctxmenu__item--disabled:hover { background: transparent; color: var(--wp-text-dim); }
348
- .wp-ctxmenu__icon,
349
- .wp-ctxmenu__icon-spacer {
350
- width: 14px;
351
- font-size: 11px;
352
- text-align: center;
353
- flex-shrink: 0;
354
- /* Align with title baseline when subtitle is present (two-line layout). */
355
- align-self: flex-start;
356
- margin-top: 1px;
357
- }
358
- .wp-ctxmenu__text {
359
- flex: 1;
360
- display: flex;
361
- flex-direction: column;
362
- gap: 1px;
363
- min-width: 0;
364
- }
365
- .wp-ctxmenu__title {
366
- font: 500 12px/1.2 var(--wp-font-sans);
367
- color: inherit;
368
- }
369
- .wp-ctxmenu__sub {
370
- color: var(--wp-text-dim);
371
- font: 400 10px/1.3 var(--wp-font-sans);
372
- white-space: normal;
373
- }
374
- .wp-ctxmenu__sep {
375
- height: 1px;
376
- background: var(--wp-border-soft, var(--wp-border));
377
- margin: 4px 6px;
378
- }
379
- </style>
1
+ <script setup lang="ts">
2
+ import { onBeforeUnmount, ref, watch, nextTick } from "vue";
3
+ import { placeFloating, viewportSize, type Placement } from "./floating";
4
+ import { useFloatingChild } from "./floating-layer";
5
+
6
+ export interface ContextMenuItem {
7
+ label: string;
8
+ /** PrimeIcons class (e.g. "pi-trash"). ComfyUI loads PrimeIcons globally. */
9
+ icon?: string | undefined;
10
+ /** Optional separator above the item. */
11
+ divider?: boolean | undefined;
12
+ /** Render the label in red (destructive action). */
13
+ danger?: boolean | undefined;
14
+ /** Render the label in accent color to draw attention. Used for
15
+ * recommended next actions in a contextual state (e.g. "Push to
16
+ * library" when the row's library entry has been deleted upstream).
17
+ * Lower-key than `danger`; signals "this is what you probably want"
18
+ * not "be careful with this". */
19
+ accent?: boolean | undefined;
20
+ disabled?: boolean | undefined;
21
+ /** Optional dim secondary line under the label. Useful for
22
+ * explaining non-obvious actions like "Pull current library
23
+ * version" or "Restore frozen state from bundle". When provided,
24
+ * the row becomes two-line and uses more vertical space. */
25
+ subtitle?: string | undefined;
26
+ /** A checkable item (menuitemcheckbox): a tick shows when true. Leave it
27
+ * out for a plain action. */
28
+ checked?: boolean | undefined;
29
+ /** Keep the menu open after this item is picked, so several checkable
30
+ * items can be ticked in a row (a tag picker). */
31
+ keepOpen?: boolean | undefined;
32
+ onSelect: () => void;
33
+ }
34
+
35
+ /** Section labels group related items inside a single menu. Renders
36
+ * as an uppercase, dim, non-interactive separator above the items
37
+ * that follow. Distinguished from `ContextMenuItem` at type level
38
+ * by the absence of `onSelect`. */
39
+ export interface ContextMenuSection {
40
+ section: string;
41
+ }
42
+
43
+ /** Optional header rendered at the top of the menu — kind icon + a
44
+ * scope label like "Bundle · subject_phrase". Tells the user WHICH
45
+ * entity the menu is for. Color of the icon can be overridden per
46
+ * invocation (e.g. bundle's user-picked color, or a kind token). */
47
+ export interface ContextMenuHeader {
48
+ icon?: string | undefined;
49
+ label: string;
50
+ iconColor?: string | undefined;
51
+ }
52
+
53
+ export type ContextMenuEntry = ContextMenuItem | ContextMenuSection;
54
+
55
+ function isSection(e: ContextMenuEntry): e is ContextMenuSection {
56
+ return "section" in e;
57
+ }
58
+
59
+ const props = defineProps<{
60
+ visible: boolean;
61
+ x: number;
62
+ y: number;
63
+ items: ContextMenuEntry[];
64
+ header?: ContextMenuHeader | undefined;
65
+ /** Open next to this element instead of at x/y (DropdownMenu's trigger).
66
+ * A click on it doesn't count as "outside", so the trigger can toggle. */
67
+ anchor?: HTMLElement | null | undefined;
68
+ placement?: Placement | undefined;
69
+ }>();
70
+
71
+ const emit = defineEmits<{ (e: "close"): void }>();
72
+
73
+ const activeIndex = ref(-1);
74
+ const menuEl = ref<HTMLElement | null>(null);
75
+ // Inside a Popover, clicks and Escape on the menu are the menu's own.
76
+ useFloatingChild(() => menuEl.value);
77
+ /** Where the menu sits: x/y (or the anchor), moved to stay on screen. */
78
+ const pos = ref({ x: 0, y: 0 });
79
+
80
+ function place(): void {
81
+ const el = menuEl.value;
82
+ const size = el ? { width: el.offsetWidth, height: el.offsetHeight } : { width: 0, height: 0 };
83
+ const from = props.anchor?.getBoundingClientRect() ?? { left: props.x, top: props.y, width: 0, height: 0 };
84
+ const at = placeFloating(from, size, viewportSize(), { placement: props.placement ?? "bottom-start", gap: props.anchor ? 4 : 0 });
85
+ pos.value = { x: at.x, y: at.y };
86
+ }
87
+
88
+ function pickableIndices(): number[] {
89
+ const out: number[] = [];
90
+ for (let i = 0; i < props.items.length; i++) {
91
+ const e = props.items[i];
92
+ if (isSection(e)) continue;
93
+ if (!e.disabled) out.push(i);
94
+ }
95
+ return out;
96
+ }
97
+
98
+ function moveActive(delta: 1 | -1) {
99
+ const idxs = pickableIndices();
100
+ if (!idxs.length) return;
101
+ const cur = idxs.indexOf(activeIndex.value);
102
+ const next = cur < 0 ? (delta > 0 ? 0 : idxs.length - 1) : (cur + delta + idxs.length) % idxs.length;
103
+ activeIndex.value = idxs[next];
104
+ }
105
+
106
+ function gotoEdge(edge: "first" | "last") {
107
+ const idxs = pickableIndices();
108
+ if (!idxs.length) return;
109
+ activeIndex.value = edge === "first" ? idxs[0] : idxs[idxs.length - 1];
110
+ }
111
+
112
+ function onWindowPointerDown(ev: PointerEvent) {
113
+ // Close on any pointerdown outside the menu. Capture phase so
114
+ // inner `@click.stop` handlers (trace value cells, seed buttons,
115
+ // etc) can't swallow the close — pointerdown fires before click,
116
+ // and the capture-phase listener runs before any descendant's
117
+ // own listener could stop propagation. Click inside the menu
118
+ // is gated by the `@click.stop` on the UL — pointerdown there
119
+ // bubbles, but the menu element is detected by composedPath()
120
+ // so we don't close.
121
+ const path = ev.composedPath();
122
+ for (const el of path) {
123
+ if (el instanceof HTMLElement && el.classList?.contains("wp-ctxmenu")) {
124
+ return;
125
+ }
126
+ if (props.anchor && el === props.anchor) return;
127
+ }
128
+ emit("close");
129
+ }
130
+
131
+ // A menu opened from a trigger follows it as the page scrolls; one opened
132
+ // at the pointer closes instead, since the spot it was opened on has moved.
133
+ function onScroll(ev: Event): void {
134
+ if (ev.target instanceof Node && menuEl.value?.contains(ev.target)) return;
135
+ if (props.anchor) place();
136
+ else emit("close");
137
+ }
138
+
139
+ function onKey(ev: KeyboardEvent) {
140
+ switch (ev.key) {
141
+ case "Escape": ev.preventDefault(); emit("close"); break;
142
+ case "ArrowDown": ev.preventDefault(); moveActive(1); break;
143
+ case "ArrowUp": ev.preventDefault(); moveActive(-1); break;
144
+ case "Home": ev.preventDefault(); gotoEdge("first"); break;
145
+ case "End": ev.preventDefault(); gotoEdge("last"); break;
146
+ case "Enter":
147
+ case " ": {
148
+ ev.preventDefault();
149
+ const it = props.items[activeIndex.value];
150
+ if (it && !isSection(it) && !it.disabled) pick(it);
151
+ break;
152
+ }
153
+ }
154
+ }
155
+
156
+ watch(() => props.visible, async (v) => {
157
+ if (v) {
158
+ activeIndex.value = -1;
159
+ pos.value = { x: props.x, y: props.y };
160
+ // Capture phase so descendant `@click.stop` handlers (trace
161
+ // value cells, seed buttons, etc) can't prevent the close.
162
+ window.addEventListener("pointerdown", onWindowPointerDown, true);
163
+ window.addEventListener("keydown", onKey);
164
+ window.addEventListener("scroll", onScroll, true);
165
+ window.addEventListener("resize", place);
166
+ // Defer one tick so the right-click that opened the menu doesn't
167
+ // immediately close it (the synthetic mousedown that preceded the
168
+ // contextmenu happens BEFORE the listener is wired, so this is
169
+ // mostly belt-and-braces).
170
+ await nextTick();
171
+ place();
172
+ } else {
173
+ window.removeEventListener("pointerdown", onWindowPointerDown, true);
174
+ window.removeEventListener("keydown", onKey);
175
+ window.removeEventListener("scroll", onScroll, true);
176
+ window.removeEventListener("resize", place);
177
+ }
178
+ });
179
+
180
+ onBeforeUnmount(() => {
181
+ window.removeEventListener("pointerdown", onWindowPointerDown, true);
182
+ window.removeEventListener("keydown", onKey);
183
+ window.removeEventListener("scroll", onScroll, true);
184
+ window.removeEventListener("resize", place);
185
+ });
186
+
187
+ function pick(item: ContextMenuItem) {
188
+ if (item.disabled) return;
189
+ item.onSelect();
190
+ if (!item.keepOpen) emit("close");
191
+ }
192
+
193
+ function isSectionEntry(entry: ContextMenuEntry): entry is ContextMenuSection {
194
+ return isSection(entry);
195
+ }
196
+ </script>
197
+
198
+ <template>
199
+ <Teleport to="body">
200
+ <!-- Keys reach the menu through its window keydown (arrows, Home/End,
201
+ Enter, Escape), so the list itself takes no focus; leaving with the
202
+ mouse only clears the hover highlight. -->
203
+ <!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus, vuejs-accessibility/mouse-events-have-key-events -->
204
+ <ul
205
+ v-if="visible"
206
+ ref="menuEl"
207
+ class="wp-ctxmenu"
208
+ :style="{ left: `${pos.x}px`, top: `${pos.y}px` }"
209
+ role="menu"
210
+ @click.stop
211
+ @contextmenu.prevent
212
+ @mouseleave="activeIndex = -1"
213
+ >
214
+ <!-- Header — kind icon + scope label, only renders when prop provided.
215
+ Non-interactive — divider between header and items handled by
216
+ the `.wp-ctxmenu__header` border-bottom. -->
217
+ <li v-if="header" class="wp-ctxmenu__header" aria-hidden="true">
218
+ <i
219
+ v-if="header.icon"
220
+ :class="['pi', header.icon, 'wp-ctxmenu__header-icon']"
221
+ :style="header.iconColor ? { color: header.iconColor } : {}"
222
+ ></i>
223
+ <span class="wp-ctxmenu__header-label">{{ header.label }}</span>
224
+ </li>
225
+
226
+ <template v-for="(entry, i) in items" :key="i">
227
+ <!-- Section label — uppercase dim title, groups items semantically. -->
228
+ <li v-if="isSectionEntry(entry)" class="wp-ctxmenu__section" aria-hidden="true">
229
+ {{ entry.section }}
230
+ </li>
231
+ <!-- Item action — same shape as before plus optional subtitle. -->
232
+ <template v-else>
233
+ <li v-if="entry.divider" class="wp-ctxmenu__sep" aria-hidden="true"></li>
234
+ <!-- The role is menuitem or menuitemcheckbox (bound, so the rule
235
+ can't see it); keys come from the menu's own keydown. -->
236
+ <!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions, vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/mouse-events-have-key-events -->
237
+ <li
238
+ class="wp-ctxmenu__item"
239
+ :class="{
240
+ 'wp-ctxmenu__item--danger': entry.danger,
241
+ 'wp-ctxmenu__item--accent': entry.accent,
242
+ 'wp-ctxmenu__item--disabled': entry.disabled,
243
+ 'wp-ctxmenu__item--active': activeIndex === i,
244
+ 'wp-ctxmenu__item--with-sub': !!entry.subtitle,
245
+ }"
246
+ :role="entry.checked === undefined ? 'menuitem' : 'menuitemcheckbox'"
247
+ :aria-checked="entry.checked === undefined ? undefined : entry.checked"
248
+ :aria-disabled="entry.disabled || undefined"
249
+ @mouseenter="!entry.disabled && (activeIndex = i)"
250
+ @click="pick(entry)"
251
+ >
252
+ <i
253
+ v-if="entry.checked !== undefined"
254
+ class="pi pi-check wp-ctxmenu__check"
255
+ :data-on="entry.checked || undefined"
256
+ aria-hidden="true"
257
+ ></i>
258
+ <i v-if="entry.icon" :class="['pi', entry.icon, 'wp-ctxmenu__icon']" aria-hidden="true"></i>
259
+ <span v-else-if="entry.checked === undefined" class="wp-ctxmenu__icon-spacer" aria-hidden="true"></span>
260
+ <span class="wp-ctxmenu__text">
261
+ <span class="wp-ctxmenu__title">{{ entry.label }}</span>
262
+ <span v-if="entry.subtitle" class="wp-ctxmenu__sub">{{ entry.subtitle }}</span>
263
+ </span>
264
+ </li>
265
+ </template>
266
+ </template>
267
+ </ul>
268
+ </Teleport>
269
+ </template>
270
+
271
+ <style scoped>
272
+ .wp-ctxmenu {
273
+ position: fixed;
274
+ z-index: 10000;
275
+ background: var(--wp-bg-2);
276
+ border: 1px solid var(--wp-border-strong);
277
+ border-radius: var(--wp-radius-sm);
278
+ box-shadow: var(--wp-shadow-md);
279
+ /* Tighter padding than the old 4px-0 to match the v2 mockup. Items
280
+ * carry their own 6px padding + rounded corners so the menu reads
281
+ * as a stack of pills, not a flat list. */
282
+ padding: 4px;
283
+ /* Bumped from 180px to 250px — subtitles wrap awkwardly at narrower
284
+ * widths. Existing callers without subtitles still fit comfortably. */
285
+ min-width: 250px;
286
+ list-style: none;
287
+ margin: 0;
288
+ font-family: var(--wp-font-sans, sans-serif);
289
+ font-size: 12px;
290
+ color: var(--wp-text);
291
+ user-select: none;
292
+ }
293
+ /* Header — scope label at the top of the menu. Border-bottom acts as
294
+ * a divider between header and the first item. */
295
+ .wp-ctxmenu__header {
296
+ display: flex;
297
+ align-items: center;
298
+ gap: 6px;
299
+ padding: 6px 8px 4px 8px;
300
+ font: 600 10px/1 var(--wp-font-sans);
301
+ color: var(--wp-text-dim);
302
+ border-bottom: 1px solid var(--wp-border-soft, var(--wp-border));
303
+ margin-bottom: 4px;
304
+ }
305
+ .wp-ctxmenu__header-icon {
306
+ font-size: 12px;
307
+ width: 14px;
308
+ text-align: center;
309
+ }
310
+ /* Section label — non-interactive uppercase tag separating groups of
311
+ * related items. Smaller + dimmer than the header label so the
312
+ * header still reads as the primary scope and sections read as
313
+ * subdivisions. */
314
+ .wp-ctxmenu__section {
315
+ font: 600 9px/1 var(--wp-font-sans);
316
+ text-transform: uppercase;
317
+ letter-spacing: 0.08em;
318
+ color: var(--wp-text-dim);
319
+ padding: 6px 8px 4px 8px;
320
+ }
321
+ .wp-ctxmenu__item {
322
+ padding: 6px 8px;
323
+ border-radius: min(4px, var(--wp-radius-cap, 999px));
324
+ cursor: pointer;
325
+ white-space: nowrap;
326
+ display: flex;
327
+ align-items: center;
328
+ gap: 8px;
329
+ }
330
+ .wp-ctxmenu__item--active,
331
+ .wp-ctxmenu__item:hover { background: var(--wp-accent-glow); color: var(--wp-accent); }
332
+ /* A checkable item's tick: always takes its room, shows only when on. */
333
+ .wp-ctxmenu__check { width: 14px; font-size: 11px; text-align: center; visibility: hidden; color: var(--wp-accent); }
334
+ .wp-ctxmenu__check[data-on] { visibility: visible; }
335
+ .wp-ctxmenu__item--danger { color: var(--wp-red); }
336
+ .wp-ctxmenu__item--danger.wp-ctxmenu__item--active,
337
+ .wp-ctxmenu__item--danger:hover { background: var(--wp-red-bg); color: var(--wp-red); }
338
+ /* Accent variant — recommended action highlight. Tinted background +
339
+ * accent text in idle state so the eye lands on it; hover uses the
340
+ * stronger accent-glow + accent text already wired for other hovers. */
341
+ .wp-ctxmenu__item--accent {
342
+ color: var(--wp-accent);
343
+ background: color-mix(in oklab, var(--wp-accent) 12%, transparent);
344
+ }
345
+ .wp-ctxmenu__item--accent.wp-ctxmenu__item--active,
346
+ .wp-ctxmenu__item--accent:hover {
347
+ background: var(--wp-accent-glow);
348
+ color: var(--wp-accent);
349
+ }
350
+ .wp-ctxmenu__item--disabled {
351
+ color: var(--wp-text-dim);
352
+ cursor: not-allowed;
353
+ }
354
+ .wp-ctxmenu__item--disabled:hover { background: transparent; color: var(--wp-text-dim); }
355
+ .wp-ctxmenu__icon,
356
+ .wp-ctxmenu__icon-spacer {
357
+ width: 14px;
358
+ font-size: 11px;
359
+ text-align: center;
360
+ flex-shrink: 0;
361
+ /* Align with title baseline when subtitle is present (two-line layout). */
362
+ align-self: flex-start;
363
+ margin-top: 1px;
364
+ }
365
+ .wp-ctxmenu__text {
366
+ flex: 1;
367
+ display: flex;
368
+ flex-direction: column;
369
+ gap: 1px;
370
+ min-width: 0;
371
+ }
372
+ .wp-ctxmenu__title {
373
+ font: 500 12px/1.2 var(--wp-font-sans);
374
+ color: inherit;
375
+ }
376
+ .wp-ctxmenu__sub {
377
+ color: var(--wp-text-dim);
378
+ font: 400 10px/1.3 var(--wp-font-sans);
379
+ white-space: normal;
380
+ }
381
+ .wp-ctxmenu__sep {
382
+ height: 1px;
383
+ background: var(--wp-border-soft, var(--wp-border));
384
+ margin: 4px 6px;
385
+ }
386
+ </style>