@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.
- package/CHANGELOG.md +77 -0
- package/LICENSE +674 -0
- package/README.md +72 -2
- package/package.json +76 -4
- package/src/Avatar.vue +83 -0
- package/src/Badge.vue +97 -0
- package/src/Banner.vue +118 -0
- package/src/BookmarkButton.vue +27 -0
- package/src/BundleMeta.vue +51 -0
- package/src/Button.vue +103 -0
- package/src/Card.vue +68 -0
- package/src/Checkbox.vue +109 -0
- package/src/Chip.vue +163 -0
- package/src/ChoiceCards.vue +133 -0
- package/src/CloseButton.vue +69 -0
- package/src/CodeBlock.vue +119 -0
- package/src/ColorField.vue +188 -0
- package/src/ColorPicker.vue +201 -0
- package/src/Combobox.vue +331 -0
- package/src/ConfirmDialog.vue +165 -0
- package/src/ConfirmModal.vue +147 -0
- package/src/ConstraintMatrixPreview.vue +708 -0
- package/src/ContextMenu.vue +386 -0
- package/src/CopyButton.vue +156 -0
- package/src/DataTable.vue +292 -0
- package/src/DatePicker.vue +234 -0
- package/src/DescriptionList.vue +73 -0
- package/src/DiffView.vue +68 -0
- package/src/Disclosure.vue +166 -0
- package/src/DragHandle.vue +49 -0
- package/src/Drawer.vue +131 -0
- package/src/DropZone.vue +196 -0
- package/src/DropdownMenu.vue +72 -0
- package/src/EmptyState.vue +111 -0
- package/src/Field.vue +28 -0
- package/src/HsvPicker.vue +208 -0
- package/src/Icon.vue +39 -0
- package/src/IconPicker.vue +209 -0
- package/src/ImageGrid.vue +125 -0
- package/src/InlineEdit.vue +183 -0
- package/src/Input.vue +99 -0
- package/src/Kbd.vue +56 -0
- package/src/Lightbox.vue +173 -0
- package/src/ListFilter.vue +85 -0
- package/src/ListRow.vue +204 -0
- package/src/LoadingMark.vue +175 -0
- package/src/Logo.vue +60 -0
- package/src/Modal.vue +78 -0
- package/src/ModalShell.vue +109 -0
- package/src/Pagination.vue +106 -0
- package/src/Popover.vue +180 -0
- package/src/PostCard.vue +210 -0
- package/src/ProgressBar.vue +90 -0
- package/src/RadioGroup.vue +177 -0
- package/src/RangeSlider.vue +183 -0
- package/src/RelativeDate.vue +35 -0
- package/src/ResizeHandle.vue +143 -0
- package/src/SearchInput.vue +192 -0
- package/src/Select.vue +644 -0
- package/src/Separator.vue +54 -0
- package/src/Skeleton.vue +82 -0
- package/src/Slider.vue +198 -0
- package/src/SortableList.vue +260 -0
- package/src/SplitPane.vue +68 -0
- package/src/StarButton.vue +40 -0
- package/src/Stepper.vue +141 -0
- package/src/SupportLinks.vue +93 -0
- package/src/TagInput.vue +297 -0
- package/src/Textarea.vue +57 -0
- package/src/Timeline.vue +92 -0
- package/src/ToastCard.vue +119 -0
- package/src/ToastHost.vue +40 -0
- package/src/Toggle.vue +36 -0
- package/src/TokenizedValue.vue +310 -0
- package/src/Toolbar.vue +110 -0
- package/src/VirtualList.vue +87 -0
- package/src/VisuallyHidden.vue +29 -0
- package/src/WpButton.vue +75 -0
- package/src/WpCheck.vue +104 -0
- package/src/WpIconButton.vue +107 -0
- package/src/WpImageZoom.vue +544 -0
- package/src/WpNumber.vue +178 -0
- package/src/WpSegmented.vue +175 -0
- package/src/WpSelect.vue +93 -0
- package/src/WpSwitch.vue +163 -0
- package/src/WpTabs.vue +188 -0
- package/src/_modal-motion.css +33 -0
- package/src/_skeleton.css +50 -0
- package/src/axis-color.ts +42 -0
- package/src/button.css +169 -0
- package/src/calendar.ts +59 -0
- package/src/clipboard.ts +50 -0
- package/src/colour-presets.ts +12 -0
- package/src/diff.ts +51 -0
- package/src/drop-zone.ts +41 -0
- package/src/field.css +50 -0
- package/src/flip.ts +237 -0
- package/src/floating-layer.ts +48 -0
- package/src/floating.ts +99 -0
- package/src/hsv.ts +100 -0
- package/src/icon-catalog.ts +52 -0
- package/src/image-zoom.ts +93 -0
- package/src/kind-colour.ts +28 -0
- package/src/logo-paths.ts +21 -0
- package/src/matrix-axis.ts +91 -0
- package/src/pagination.ts +27 -0
- package/src/platform-keys.ts +29 -0
- package/src/rich-tokenize.ts +357 -0
- package/src/select-types.ts +32 -0
- package/src/slider-math.ts +45 -0
- package/src/sortable.ts +22 -0
- package/src/theme/fonts/OFL-Fraunces.txt +93 -0
- package/src/theme/fonts/OFL-JetBrainsMono.txt +93 -0
- package/src/theme/fonts/OFL-Manrope.txt +93 -0
- package/src/theme/fonts/fraunces-latin-wght-normal.woff2 +0 -0
- package/src/theme/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/src/theme/fonts/manrope-latin-wght-normal.woff2 +0 -0
- package/src/theme/looks.css +428 -0
- package/src/theme/palettes.css +254 -0
- package/src/theme/tokens.css +2896 -0
- package/src/tokens.ts +56 -0
- package/src/tooltip.css +43 -0
- package/src/tooltip.ts +146 -0
- package/src/useDialogFocus.ts +73 -0
- package/src/useGrowableField.ts +357 -0
- package/src/useModalKeyShield.ts +116 -0
- package/src/useToast.ts +48 -0
- package/src/wp-logo.svg +14 -0
|
@@ -0,0 +1,386 @@
|
|
|
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>
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Copy button: copies text and says so in place. The icon turns into a
|
|
4
|
+
* check ("Copied") or a cross ("Couldn't copy") for a moment, the tooltip
|
|
5
|
+
* and a polite live region say the same, so the message is the same
|
|
6
|
+
* everywhere and nobody needs a toast for it. Works on the canvas and in the
|
|
7
|
+
* manager, and on plain-http pages (see `clipboard.ts`).
|
|
8
|
+
*
|
|
9
|
+
* <CopyButton :text="prompt" label="Copy prompt" />
|
|
10
|
+
* <CopyButton :text="() => buildExport()" label="Copy JSON" show-label />
|
|
11
|
+
*
|
|
12
|
+
* `text` may be a function (also async) so costly text is built on click.
|
|
13
|
+
*
|
|
14
|
+
* With slot content it is an inline copy target instead (an id in a row):
|
|
15
|
+
* the slot is the visible text, styled by the caller, and the icon shows on
|
|
16
|
+
* hover, on focus and after a copy.
|
|
17
|
+
*
|
|
18
|
+
* <CopyButton :text="row.id" :label="`Copy ID ${row.id}`" class="wp-id">{{ row.id }}</CopyButton>
|
|
19
|
+
*/
|
|
20
|
+
import { computed, onBeforeUnmount, ref, useSlots } from "vue";
|
|
21
|
+
import { copyText } from "./clipboard";
|
|
22
|
+
import { vTooltip } from "./tooltip";
|
|
23
|
+
|
|
24
|
+
const props = withDefaults(defineProps<{
|
|
25
|
+
text: string | (() => string | Promise<string>);
|
|
26
|
+
/** The button's name and tooltip, e.g. "Copy prompt". */
|
|
27
|
+
label?: string | undefined;
|
|
28
|
+
/** Show the label next to the icon. */
|
|
29
|
+
showLabel?: boolean | undefined;
|
|
30
|
+
size?: "sm" | "md" | "lg" | undefined;
|
|
31
|
+
/** `ghost` (default) for rows and toolbars, `secondary` for a filled button. */
|
|
32
|
+
variant?: "ghost" | "secondary" | undefined;
|
|
33
|
+
disabled?: boolean | undefined;
|
|
34
|
+
/** The PrimeIcons class shown before a copy, e.g. "pi-link" for a link. */
|
|
35
|
+
icon?: string | undefined;
|
|
36
|
+
}>(), { icon: "pi-copy", label: "Copy", size: "md", variant: "ghost" });
|
|
37
|
+
|
|
38
|
+
const emit = defineEmits<{ (e: "copied", text: string): void; (e: "failed"): void }>();
|
|
39
|
+
|
|
40
|
+
const slots = useSlots();
|
|
41
|
+
const state = ref<"idle" | "copied" | "failed">("idle");
|
|
42
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
43
|
+
const RESET_MS = 1600;
|
|
44
|
+
|
|
45
|
+
const shown = computed(() =>
|
|
46
|
+
state.value === "copied" ? "Copied" : state.value === "failed" ? "Couldn't copy" : props.label);
|
|
47
|
+
const icon = computed(() =>
|
|
48
|
+
state.value === "copied" ? "pi-check" : state.value === "failed" ? "pi-times" : props.icon);
|
|
49
|
+
|
|
50
|
+
async function onClick(): Promise<void> {
|
|
51
|
+
if (props.disabled) return;
|
|
52
|
+
let value = "";
|
|
53
|
+
try {
|
|
54
|
+
value = typeof props.text === "function" ? await props.text() : props.text;
|
|
55
|
+
} catch {
|
|
56
|
+
value = "";
|
|
57
|
+
}
|
|
58
|
+
const ok = value !== "" && await copyText(value);
|
|
59
|
+
state.value = ok ? "copied" : "failed";
|
|
60
|
+
if (ok) emit("copied", value);
|
|
61
|
+
else emit("failed");
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
timer = setTimeout(() => { state.value = "idle"; }, RESET_MS);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
onBeforeUnmount(() => clearTimeout(timer));
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<template>
|
|
70
|
+
<button
|
|
71
|
+
v-tooltip="showLabel ? null : shown"
|
|
72
|
+
:data-inline="slots.default ? 'true' : undefined"
|
|
73
|
+
type="button"
|
|
74
|
+
class="wp-copy-btn"
|
|
75
|
+
:data-size="size"
|
|
76
|
+
:data-variant="variant"
|
|
77
|
+
:data-state="state"
|
|
78
|
+
:data-labelled="showLabel || undefined"
|
|
79
|
+
:aria-label="showLabel ? undefined : label"
|
|
80
|
+
:disabled="disabled"
|
|
81
|
+
@click="onClick"
|
|
82
|
+
>
|
|
83
|
+
<template v-if="slots.default">
|
|
84
|
+
<slot />
|
|
85
|
+
<i class="pi wp-copy-btn__inline-icon" :class="icon" aria-hidden="true" />
|
|
86
|
+
</template>
|
|
87
|
+
<template v-else>
|
|
88
|
+
<i class="pi" :class="icon" aria-hidden="true" />
|
|
89
|
+
<span v-if="showLabel" class="wp-copy-btn__text">{{ shown }}</span>
|
|
90
|
+
</template>
|
|
91
|
+
<span class="wp-copy-btn__live" aria-live="polite">{{ state === "idle" ? "" : shown }}</span>
|
|
92
|
+
</button>
|
|
93
|
+
</template>
|
|
94
|
+
|
|
95
|
+
<style scoped>
|
|
96
|
+
.wp-copy-btn { position: relative; }
|
|
97
|
+
.wp-copy-btn:not([data-inline]) {
|
|
98
|
+
display: inline-flex;
|
|
99
|
+
align-items: center;
|
|
100
|
+
justify-content: center;
|
|
101
|
+
gap: 6px;
|
|
102
|
+
flex: none;
|
|
103
|
+
min-width: var(--wp-control-h-md);
|
|
104
|
+
height: var(--wp-control-h-md);
|
|
105
|
+
margin: 0;
|
|
106
|
+
padding: 0;
|
|
107
|
+
border: 1px solid transparent;
|
|
108
|
+
border-radius: var(--wp-radius);
|
|
109
|
+
background: transparent;
|
|
110
|
+
color: var(--wp-text-muted);
|
|
111
|
+
font: 500 var(--wp-text-sm)/1 var(--wp-font-sans);
|
|
112
|
+
white-space: nowrap;
|
|
113
|
+
cursor: pointer;
|
|
114
|
+
transition: background var(--wp-motion-hover), color var(--wp-motion-hover), border-color var(--wp-motion-hover);
|
|
115
|
+
}
|
|
116
|
+
.wp-copy-btn[data-labelled] { padding: 0 var(--wp-space-5); }
|
|
117
|
+
.wp-copy-btn[data-size="sm"] { min-width: var(--wp-control-h-sm); height: var(--wp-control-h-sm); border-radius: var(--wp-radius-sm); font-size: var(--wp-text-xs); gap: 4px; }
|
|
118
|
+
.wp-copy-btn[data-size="sm"][data-labelled] { padding: 0 var(--wp-space-4); }
|
|
119
|
+
.wp-copy-btn[data-size="lg"] { min-width: var(--wp-control-h-lg); height: var(--wp-control-h-lg); }
|
|
120
|
+
.wp-copy-btn[data-variant="secondary"] { background: var(--wp-bg-3); border-color: var(--wp-border); color: var(--wp-text); }
|
|
121
|
+
.wp-copy-btn:not([data-inline]) .pi { font-size: 12px; }
|
|
122
|
+
.wp-copy-btn[data-size="sm"]:not([data-inline]) .pi { font-size: 11px; }
|
|
123
|
+
.wp-copy-btn:not([data-inline]):hover:not(:disabled) { background: var(--wp-bg-3); border-color: var(--wp-border); color: var(--wp-text); }
|
|
124
|
+
.wp-copy-btn[data-variant="secondary"]:hover:not(:disabled) { background: var(--wp-bg-4); border-color: var(--wp-border-strong); }
|
|
125
|
+
.wp-copy-btn:not([data-inline]):focus-visible { outline: none; box-shadow: 0 0 0 2px var(--wp-border-focus); }
|
|
126
|
+
.wp-copy-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
127
|
+
.wp-copy-btn[data-state="copied"] .pi { color: var(--wp-success); }
|
|
128
|
+
.wp-copy-btn[data-state="failed"] .pi { color: var(--wp-danger); }
|
|
129
|
+
/* Inline: the caller's class draws the text; only the icon is ours. */
|
|
130
|
+
.wp-copy-btn[data-inline] {
|
|
131
|
+
display: inline-flex;
|
|
132
|
+
align-items: center;
|
|
133
|
+
gap: 4px;
|
|
134
|
+
margin: 0;
|
|
135
|
+
border: 0;
|
|
136
|
+
border-radius: var(--wp-radius-sm);
|
|
137
|
+
text-align: left;
|
|
138
|
+
cursor: copy;
|
|
139
|
+
}
|
|
140
|
+
.wp-copy-btn[data-inline]:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--wp-border-focus); }
|
|
141
|
+
.wp-copy-btn__inline-icon { font-size: 0.85em; opacity: 0; transition: opacity var(--wp-motion-hover); }
|
|
142
|
+
.wp-copy-btn[data-inline]:hover .wp-copy-btn__inline-icon,
|
|
143
|
+
.wp-copy-btn[data-inline]:focus-visible .wp-copy-btn__inline-icon,
|
|
144
|
+
.wp-copy-btn[data-inline]:not([data-state="idle"]) .wp-copy-btn__inline-icon { opacity: 1; }
|
|
145
|
+
.wp-copy-btn__live {
|
|
146
|
+
position: absolute;
|
|
147
|
+
width: 1px;
|
|
148
|
+
height: 1px;
|
|
149
|
+
overflow: hidden;
|
|
150
|
+
clip-path: inset(50%);
|
|
151
|
+
white-space: nowrap;
|
|
152
|
+
}
|
|
153
|
+
@media (prefers-reduced-motion: reduce) {
|
|
154
|
+
.wp-copy-btn { transition: none; }
|
|
155
|
+
}
|
|
156
|
+
</style>
|