@dumiflex/wp-kit 0.0.0-stage → 0.3.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 +51 -0
  2. package/LICENSE +674 -0
  3. package/README.md +76 -3
  4. package/package.json +77 -6
  5. package/src/Avatar.vue +83 -0
  6. package/src/Badge.vue +97 -0
  7. package/src/Banner.vue +118 -0
  8. package/src/BookmarkButton.vue +27 -0
  9. package/src/BundleMeta.vue +51 -0
  10. package/src/Button.vue +103 -0
  11. package/src/Card.vue +68 -0
  12. package/src/Checkbox.vue +107 -0
  13. package/src/Chip.vue +97 -0
  14. package/src/ChipToggle.vue +92 -0
  15. package/src/ChoiceCards.vue +133 -0
  16. package/src/CloseButton.vue +69 -0
  17. package/src/CodeBlock.vue +119 -0
  18. package/src/ColorField.vue +206 -0
  19. package/src/ColorPicker.vue +201 -0
  20. package/src/Combobox.vue +334 -0
  21. package/src/ConfirmDialog.vue +163 -0
  22. package/src/ConfirmModal.vue +147 -0
  23. package/src/ConstraintMatrixPreview.vue +708 -0
  24. package/src/ContextMenu.vue +379 -0
  25. package/src/CopyButton.vue +156 -0
  26. package/src/DataTable.vue +292 -0
  27. package/src/DatePicker.vue +234 -0
  28. package/src/DescriptionList.vue +73 -0
  29. package/src/DiffView.vue +68 -0
  30. package/src/Disclosure.vue +166 -0
  31. package/src/DragHandle.vue +49 -0
  32. package/src/Drawer.vue +131 -0
  33. package/src/DropZone.vue +196 -0
  34. package/src/DropdownMenu.vue +72 -0
  35. package/src/EmptyState.vue +111 -0
  36. package/src/Field.vue +28 -0
  37. package/src/HsvPicker.vue +208 -0
  38. package/src/Icon.vue +39 -0
  39. package/src/IconPicker.vue +209 -0
  40. package/src/ImageGrid.vue +125 -0
  41. package/src/InlineEdit.vue +183 -0
  42. package/src/Input.vue +99 -0
  43. package/src/Kbd.vue +56 -0
  44. package/src/KindChip.vue +53 -0
  45. package/src/Lightbox.vue +173 -0
  46. package/src/ListFilter.vue +85 -0
  47. package/src/ListRow.vue +204 -0
  48. package/src/LoadingMark.vue +161 -0
  49. package/src/Logo.vue +60 -0
  50. package/src/Modal.vue +76 -0
  51. package/src/ModalShell.vue +107 -0
  52. package/src/Pagination.vue +106 -0
  53. package/src/Popover.vue +171 -0
  54. package/src/PostCard.vue +211 -0
  55. package/src/ProgressBar.vue +90 -0
  56. package/src/RadioGroup.vue +177 -0
  57. package/src/RangeSlider.vue +183 -0
  58. package/src/RatingBadge.vue +42 -0
  59. package/src/RelativeDate.vue +35 -0
  60. package/src/ResizeHandle.vue +143 -0
  61. package/src/SearchInput.vue +199 -0
  62. package/src/Select.vue +641 -0
  63. package/src/Separator.vue +54 -0
  64. package/src/Skeleton.vue +82 -0
  65. package/src/Slider.vue +198 -0
  66. package/src/SortableList.vue +260 -0
  67. package/src/SplitPane.vue +68 -0
  68. package/src/StarButton.vue +40 -0
  69. package/src/Stepper.vue +141 -0
  70. package/src/SupportLinks.vue +93 -0
  71. package/src/TagInput.vue +300 -0
  72. package/src/Textarea.vue +57 -0
  73. package/src/Timeline.vue +92 -0
  74. package/src/ToastCard.vue +119 -0
  75. package/src/ToastHost.vue +40 -0
  76. package/src/Toggle.vue +36 -0
  77. package/src/TokenizedValue.vue +310 -0
  78. package/src/Toolbar.vue +110 -0
  79. package/src/VirtualList.vue +87 -0
  80. package/src/VisuallyHidden.vue +29 -0
  81. package/src/WpButton.vue +75 -0
  82. package/src/WpCheck.vue +102 -0
  83. package/src/WpIconButton.vue +107 -0
  84. package/src/WpImageZoom.vue +544 -0
  85. package/src/WpNumber.vue +183 -0
  86. package/src/WpSegmented.vue +175 -0
  87. package/src/WpSelect.vue +89 -0
  88. package/src/WpSwitch.vue +163 -0
  89. package/src/WpTabs.vue +188 -0
  90. package/src/_modal-motion.css +33 -0
  91. package/src/_skeleton.css +50 -0
  92. package/src/axis-color.ts +42 -0
  93. package/src/button.css +169 -0
  94. package/src/calendar.ts +59 -0
  95. package/src/clipboard.ts +50 -0
  96. package/src/colour-presets.ts +12 -0
  97. package/src/diff.ts +51 -0
  98. package/src/drop-zone.ts +41 -0
  99. package/src/flip.ts +237 -0
  100. package/src/floating.ts +99 -0
  101. package/src/hsv.ts +100 -0
  102. package/src/icon-catalog.ts +52 -0
  103. package/src/image-zoom.ts +93 -0
  104. package/src/logo-paths.ts +21 -0
  105. package/src/matrix-axis.ts +91 -0
  106. package/src/pagination.ts +27 -0
  107. package/src/platform-keys.ts +29 -0
  108. package/src/rich-tokenize.ts +357 -0
  109. package/src/select-types.ts +32 -0
  110. package/src/slider-math.ts +45 -0
  111. package/src/sortable.ts +22 -0
  112. package/src/theme/fonts/OFL-Fraunces.txt +93 -0
  113. package/src/theme/fonts/OFL-JetBrainsMono.txt +93 -0
  114. package/src/theme/fonts/OFL-Manrope.txt +93 -0
  115. package/src/theme/fonts/fraunces-latin-wght-normal.woff2 +0 -0
  116. package/src/theme/fonts/jetbrains-mono-latin.woff2 +0 -0
  117. package/src/theme/fonts/manrope-latin-wght-normal.woff2 +0 -0
  118. package/src/theme/looks.css +428 -0
  119. package/src/theme/palettes.css +254 -0
  120. package/src/theme/tokens.css +2894 -0
  121. package/src/tokens.ts +56 -0
  122. package/src/tooltip.css +43 -0
  123. package/src/tooltip.ts +146 -0
  124. package/src/useDialogFocus.ts +73 -0
  125. package/src/useGrowableField.ts +357 -0
  126. package/src/useModalKeyShield.ts +116 -0
  127. package/src/useToast.ts +48 -0
  128. package/src/wp-logo.svg +14 -0
package/src/Select.vue ADDED
@@ -0,0 +1,641 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
3
+ import Icon, { ICON_SM } from "./Icon.vue";
4
+
5
+ // Declared in `./select-types` so plain `.ts` helpers can build option lists
6
+ // (a `.ts` importer only sees the `*.vue` ambient shim). Re-exported here so
7
+ // existing `from "./Select.vue"` imports keep resolving.
8
+ import type { SelectOption } from "./select-types";
9
+ export type { SelectOption };
10
+
11
+ interface Props {
12
+ modelValue: string | number | null;
13
+ options: SelectOption[];
14
+ placeholder?: string | undefined;
15
+ clearable?: boolean | undefined;
16
+ size?: "sm" | "md" | "lg" | undefined;
17
+ disabled?: boolean | undefined;
18
+ error?: boolean | undefined;
19
+ ariaLabel?: string | undefined;
20
+ /** Type-to-filter. Default on. Turn OFF for a small fixed list (e.g. a
21
+ * 4-item mode picker) that needs neither the "Type to filter" hint nor the
22
+ * height it adds — the hint row was what pushed the menu into a scrollbar
23
+ * that overlapped the selected row's tick. */
24
+ filterable?: boolean | undefined;
25
+ }
26
+ const props = withDefaults(defineProps<Props>(), { size: "md", placeholder: "Select…", filterable: true });
27
+
28
+ const emit = defineEmits<{
29
+ (e: "update:modelValue", v: string | number | null): void;
30
+ (e: "change", v: string | number | null): void;
31
+ }>();
32
+
33
+ const wrapRef = ref<HTMLDivElement | null>(null);
34
+ const btnRef = ref<HTMLButtonElement | null>(null);
35
+ const open = ref(false);
36
+ const flip = ref(false);
37
+ const active = ref(0);
38
+ /** Type-to-filter query, built from printable keystrokes while the menu
39
+ * is open. Reset every time the menu opens or closes. */
40
+ const query = ref("");
41
+ /** The teleported menu, so the outside-click guard can exclude it. */
42
+ const menuRef = ref<HTMLElement | null>(null);
43
+
44
+ /** Fixed-position coordinates for the teleported menu. */
45
+ const menuStyle = ref<Record<string, string>>({});
46
+
47
+ const selected = computed(() => props.options.find((o) => o.value === props.modelValue) ?? null);
48
+
49
+ /**
50
+ * Width to reserve for the icon/dot gutter, or null when no option has one.
51
+ *
52
+ * Markers are per-option, so a list where only SOME options carry one used to
53
+ * render a ragged left edge — a categorised row sat 20px right of an
54
+ * uncategorised one, and the eye reads that indent as hierarchy that isn't
55
+ * there. Reserving the gutter for every row in a list where any row has a
56
+ * marker keeps the labels in one column.
57
+ *
58
+ * Measured off the widest marker actually present: icons are a 14px box, dots
59
+ * 8px, so a dots-only list does not pay for icon width it never uses.
60
+ */
61
+ const markerWidth = computed<string | null>(() => {
62
+ let hasDot = false;
63
+ for (const o of props.options) {
64
+ if (o.icon) return "14px";
65
+ if (o.dot) hasDot = true;
66
+ }
67
+ return hasDot ? "8px" : null;
68
+ });
69
+
70
+ /** Whether to advertise type-to-filter. Only a single-option menu has nothing
71
+ * to filter; anything above that benefits, and the capability was invisible
72
+ * until you already knew about it. An earlier threshold of six hid the hint on
73
+ * exactly the menus users were checking. */
74
+ const FILTER_HINT_MIN_OPTIONS = 2;
75
+ const showFilterHint = computed(() => props.filterable && props.options.length >= FILTER_HINT_MIN_OPTIONS);
76
+
77
+ /** Options narrowed by `query` (case-insensitive substring on the label).
78
+ * Empty query → all options, so every dropdown is type-to-filter. */
79
+ const filtered = computed<SelectOption[]>(() => {
80
+ if (!props.filterable) return props.options;
81
+ const q = query.value.trim().toLowerCase();
82
+ if (!q) return props.options;
83
+ // Match `meta` too — when several rows share a label, the meta (option
84
+ // count + uuid) is the ONLY thing that tells them apart, so it has to be
85
+ // searchable or the duplicates stay unreachable by typing.
86
+ return props.options.filter((o) =>
87
+ o.label.toLowerCase().includes(q) || (o.meta?.toLowerCase().includes(q) ?? false),
88
+ );
89
+ });
90
+
91
+ const btnClasses = computed(() => [
92
+ "wp-select",
93
+ props.size === "sm" && "wp-select--sm",
94
+ props.size === "lg" && "wp-select--lg",
95
+ ]);
96
+
97
+ /** Surface an aria-label only when there's no visible selected text —
98
+ * otherwise the visible label IS the accessible name and an aria-label
99
+ * override would just disagree with the visible text (axe rule
100
+ * label-content-name-mismatch). When the trigger shows a placeholder
101
+ * with no selection, fall through to the ariaLabel prop. */
102
+ const composedAriaLabel = computed<string | undefined>(() => {
103
+ if (selected.value) return undefined;
104
+ return props.ariaLabel;
105
+ });
106
+
107
+ function onDocClick(e: MouseEvent) {
108
+ const t = e.target as Node | null;
109
+ if (!t) return;
110
+ // Close when the press is outside the trigger AND outside the menu. The menu
111
+ // is teleported to <body>, so it is NOT inside `wrapRef` — checking only the
112
+ // trigger meant a mousedown on the menu's own scrollbar counted as "outside"
113
+ // and dismissed it. The wheel worked, the scrollbar did not.
114
+ if (wrapRef.value?.contains(t)) return;
115
+ if (menuRef.value?.contains(t)) return;
116
+ open.value = false;
117
+ }
118
+
119
+ onMounted(() => {
120
+ document.addEventListener("mousedown", onDocClick, true);
121
+ });
122
+ onBeforeUnmount(() => {
123
+ document.removeEventListener("mousedown", onDocClick, true);
124
+ window.removeEventListener("scroll", followOnScroll, true);
125
+ window.removeEventListener("resize", reposition);
126
+ });
127
+
128
+ /** Any scroll in the page moves the menu with its trigger (like every kit
129
+ * overlay), instead of closing it or leaving it where the trigger used to
130
+ * be. Only once the trigger has scrolled right out of view does it close. */
131
+ function followOnScroll(e: Event) {
132
+ // The menu's own list scroll changes nothing about where it sits.
133
+ const t = e.target as Element | null;
134
+ if (t && typeof t.closest === "function" && t.closest(".wp-select__menu")) return;
135
+ if (!open.value || !btnRef.value) return;
136
+ const r = btnRef.value.getBoundingClientRect();
137
+ if (r.bottom < 0 || r.top > window.innerHeight || r.right < 0 || r.left > window.innerWidth) {
138
+ open.value = false;
139
+ return;
140
+ }
141
+ computeMenuStyle();
142
+ }
143
+ function reposition() {
144
+ if (!open.value || !btnRef.value) return;
145
+ computeMenuStyle();
146
+ }
147
+
148
+ function computeMenuStyle() {
149
+ if (!btnRef.value) return;
150
+ const rect = btnRef.value.getBoundingClientRect();
151
+ // Ideal menu height = enough room for every option (~36px each + 12px
152
+ // padding), capped at 240px so a huge library doesn't blanket the
153
+ // viewport. The actual rendered height also gets capped by available
154
+ // screen space below — see `effectiveMaxHeight` — so the menu never
155
+ // bleeds past the viewport edge.
156
+ const idealHeight = Math.min(240, props.options.length * 36 + 12);
157
+ const gap = 4; // spacing between trigger and menu
158
+ const margin = 8; // breathing room from the viewport edge
159
+ const spaceBelow = window.innerHeight - rect.bottom - gap - margin;
160
+ const spaceAbove = rect.top - gap - margin;
161
+
162
+ // Flip above only when there isn't enough room below AND there's
163
+ // more room above than below — otherwise the menu still grows under
164
+ // the trigger and `effectiveMaxHeight` clamps it to whatever fits.
165
+ flip.value = spaceBelow < idealHeight && spaceAbove > spaceBelow;
166
+
167
+ const available = flip.value ? spaceAbove : spaceBelow;
168
+ // Floor at 80px so a tightly-cramped row (e.g. inside a modal near the
169
+ // viewport edge) still surfaces a usable scrolling list rather than
170
+ // collapsing to a 1-row sliver.
171
+ const effectiveMaxHeight = Math.max(80, Math.min(idealHeight, available));
172
+
173
+ menuStyle.value = flip.value
174
+ ? {
175
+ position: "fixed",
176
+ bottom: (window.innerHeight - rect.top + gap) + "px",
177
+ top: "auto",
178
+ left: rect.left + "px",
179
+ minWidth: rect.width + "px",
180
+ maxHeight: effectiveMaxHeight + "px",
181
+ }
182
+ : {
183
+ position: "fixed",
184
+ top: (rect.bottom + gap) + "px",
185
+ bottom: "auto",
186
+ left: rect.left + "px",
187
+ minWidth: rect.width + "px",
188
+ maxHeight: effectiveMaxHeight + "px",
189
+ };
190
+ }
191
+
192
+ watch(open, (v) => {
193
+ if (!v) {
194
+ query.value = "";
195
+ window.removeEventListener("scroll", followOnScroll, true);
196
+ window.removeEventListener("resize", reposition);
197
+ return;
198
+ }
199
+ if (!btnRef.value) return;
200
+ query.value = "";
201
+ computeMenuStyle();
202
+ // Highlight currently-selected option (or first).
203
+ const idx = props.options.findIndex((o) => o.value === props.modelValue);
204
+ active.value = idx >= 0 ? idx : 0;
205
+ window.addEventListener("scroll", followOnScroll, true);
206
+ window.addEventListener("resize", reposition);
207
+ });
208
+
209
+ function toggle() {
210
+ if (props.disabled) return;
211
+ open.value = !open.value;
212
+ }
213
+
214
+ function pick(opt: SelectOption) {
215
+ emit("update:modelValue", opt.value);
216
+ emit("change", opt.value);
217
+ open.value = false;
218
+ nextTick(() => btnRef.value?.focus());
219
+ }
220
+
221
+ function clear(e: MouseEvent) {
222
+ e.stopPropagation();
223
+ emit("update:modelValue", null);
224
+ emit("change", null);
225
+ }
226
+
227
+ function onKeydown(e: KeyboardEvent) {
228
+ if (props.disabled) return;
229
+ if (!open.value) {
230
+ if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
231
+ e.preventDefault();
232
+ open.value = true;
233
+ }
234
+ return;
235
+ }
236
+ const list = filtered.value;
237
+ if (e.key === "Escape") {
238
+ e.preventDefault();
239
+ open.value = false;
240
+ } else if (e.key === "ArrowDown") {
241
+ e.preventDefault();
242
+ if (list.length) active.value = (active.value + 1) % list.length;
243
+ } else if (e.key === "ArrowUp") {
244
+ e.preventDefault();
245
+ if (list.length) active.value = (active.value - 1 + list.length) % list.length;
246
+ } else if (e.key === "Enter") {
247
+ e.preventDefault();
248
+ const opt = list[active.value];
249
+ if (opt) pick(opt);
250
+ } else if (e.key === "Backspace") {
251
+ e.preventDefault();
252
+ query.value = query.value.slice(0, -1);
253
+ active.value = 0;
254
+ } else if (props.filterable && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
255
+ // Printable key → type-to-filter; jump the highlight back to the top.
256
+ e.preventDefault();
257
+ query.value += e.key;
258
+ active.value = 0;
259
+ }
260
+ }
261
+ </script>
262
+
263
+ <template>
264
+ <div ref="wrapRef" class="wp-select-wrap">
265
+ <button
266
+ ref="btnRef"
267
+ type="button"
268
+ :class="btnClasses"
269
+ :disabled="disabled"
270
+ :aria-label="composedAriaLabel"
271
+ :aria-expanded="open"
272
+ :aria-haspopup="'listbox'"
273
+ :aria-invalid="error || undefined"
274
+ data-test="select-trigger"
275
+ @click="toggle"
276
+ @keydown="onKeydown"
277
+ >
278
+ <!-- Selected label — optional color dot prefix. Consumers can
279
+ override the label text rendering via the `#label` scoped
280
+ slot (e.g. to render `@{uuid}` ref chips instead of raw text). -->
281
+ <!-- `title` on the label so a value clipped by the trigger's fixed width
282
+ is still readable on hover. Values here run long — a constraint
283
+ exception source can be a phrase plus a nested `@ref` — and the
284
+ ellipsis alone left no way to see the rest. -->
285
+ <span
286
+ class="wp-select__label-wrap"
287
+ :title="selected ? (selected.meta ? `${selected.label} — ${selected.meta}` : selected.label) : undefined"
288
+ >
289
+ <i
290
+ v-if="selected?.icon"
291
+ :class="`pi pi-${selected.icon} wp-select__icon`"
292
+ :style="selected.dot ? { color: selected.dot } : undefined"
293
+ aria-hidden="true"
294
+ />
295
+ <span
296
+ v-else-if="selected?.dot"
297
+ class="wp-select__dot"
298
+ :style="{ background: selected.dot }"
299
+ />
300
+ <span
301
+ v-else-if="selected && markerWidth"
302
+ class="wp-select__marker-gap"
303
+ :style="{ width: markerWidth }"
304
+ aria-hidden="true"
305
+ />
306
+ <span v-if="selected" class="wp-select__label-text">
307
+ <slot name="label" :option="selected">{{ selected.label }}</slot>
308
+ </span>
309
+ <!-- The meta repeats in the CLOSED trigger, not just in the open menu.
310
+ Disambiguating rows only while the menu is open solves half the
311
+ problem: pick one of five identical "Outfit" rows and the trigger
312
+ collapses straight back to "Outfit", so the thing you just took
313
+ care to choose becomes unverifiable again. Shown only when the
314
+ option carries meta, so every dropdown that sets none is
315
+ untouched. -->
316
+ <span v-if="selected?.meta" class="wp-select__label-meta">{{ selected.meta }}</span>
317
+ <span v-if="!selected" class="wp-select__placeholder">{{ placeholder }}</span>
318
+ </span>
319
+ <button
320
+ v-if="clearable && selected"
321
+ type="button"
322
+ class="wp-select__clear"
323
+ aria-label="Clear"
324
+ data-test="select-clear"
325
+ @click="clear"
326
+ >
327
+ <Icon name="times" :size="ICON_SM" />
328
+ </button>
329
+ <Icon class="wp-select__chevron" name="chevron-down" />
330
+ </button>
331
+
332
+ <!-- Menu teleported to <body> so it escapes any overflow:hidden ancestor
333
+ and renders above the sticky footer regardless of z-index stacking contexts. -->
334
+ <Teleport to="body">
335
+ <ul
336
+ v-if="open"
337
+ ref="menuRef"
338
+ class="wp-select__menu"
339
+ role="listbox"
340
+ :style="menuStyle"
341
+ data-test="select-menu"
342
+ >
343
+ <li
344
+ v-if="query"
345
+ class="wp-select__filter"
346
+ aria-hidden="true"
347
+ data-test="select-filter"
348
+ style="display:flex;align-items:center;padding:4px 12px;font-size:11px;opacity:0.6;"
349
+ >Filtering: {{ query }}</li>
350
+ <!-- Every one of these menus is type-to-filter, but the only sign of it
351
+ was the "Filtering:" line, which appears AFTER you have already
352
+ guessed. Users asked for it to be telegraphed. Shown only once the
353
+ list is long enough for filtering to be worth reaching for. -->
354
+ <li
355
+ v-else-if="showFilterHint"
356
+ class="wp-select__hint"
357
+ aria-hidden="true"
358
+ data-test="select-filter-hint"
359
+ style="display:flex;align-items:center;padding:4px 12px;font-size:11px;opacity:0.45;"
360
+ >Type to filter</li>
361
+ <li
362
+ v-for="(opt, i) in filtered"
363
+ :key="String(opt.value)"
364
+ class="wp-select__option"
365
+ role="option"
366
+ tabindex="-1"
367
+ :aria-selected="opt.value === modelValue"
368
+ :data-active="i === active ? 'true' : 'false'"
369
+ :data-selected="opt.value === modelValue ? 'true' : 'false'"
370
+ :title="opt.title ?? (opt.meta ? `${opt.label} — ${opt.meta}` : opt.label)"
371
+ @mousedown.prevent="pick(opt)"
372
+ @mouseenter="active = i"
373
+ @focusin="active = i"
374
+ >
375
+ <i
376
+ v-if="opt.icon"
377
+ :class="`pi pi-${opt.icon} wp-select__icon`"
378
+ :style="opt.dot ? { color: opt.dot } : undefined"
379
+ aria-hidden="true"
380
+ />
381
+ <span
382
+ v-else-if="opt.dot"
383
+ class="wp-select__dot"
384
+ :style="{ background: opt.dot }"
385
+ />
386
+ <span
387
+ v-else-if="markerWidth"
388
+ class="wp-select__marker-gap"
389
+ :style="{ width: markerWidth }"
390
+ aria-hidden="true"
391
+ />
392
+ <span class="wp-select__option-label">
393
+ <slot name="option" :option="opt">{{ opt.label }}</slot>
394
+ </span>
395
+ <span class="wp-spacer" />
396
+ <span v-if="opt.meta" class="wp-select__option-meta">{{ opt.meta }}</span>
397
+ <Icon v-if="opt.value === modelValue" name="check" :size="ICON_SM" />
398
+ </li>
399
+ <li
400
+ v-if="!filtered.length"
401
+ class="wp-select__empty"
402
+ aria-disabled="true"
403
+ data-test="select-empty"
404
+ style="padding:6px 12px;opacity:0.55;"
405
+ >No matches</li>
406
+ </ul>
407
+ </Teleport>
408
+ </div>
409
+ </template>
410
+
411
+ <!--
412
+ Self-contained styles so <Select> works everywhere it's used — including the
413
+ ComfyUI CANVAS widget bundle, which does NOT load the manager-global
414
+ tokens.css (that's why an unstyled Select rendered behind the modal there).
415
+ Class names stay wp-select* (global-namespaced, no collision); the menu is
416
+ teleported to <body>, so these are UNSCOPED — same pattern as the
417
+ RichTextInput autocomplete (wp-rt-suggestions), sharing its POPOVER tier
418
+ (10020) so the menu clears every modal overlay — those run up to 10010, so
419
+ the previous 9999 rendered the menu behind any modal above that. Vars fall
420
+ back to literals for the few the
421
+ canvas theme doesn't define (--wp-input-h[-sm], --wp-focus-ring). In the SPA
422
+ these duplicate tokens.css with identical rules — harmless.
423
+ -->
424
+ <style>
425
+ .wp-select-wrap { position: relative; display: inline-block; width: 100%; }
426
+
427
+ /* Trigger button — the manager .wp-input base (height/bg/border/…) inlined
428
+ * here + flex layout for label · spacer · chevron. */
429
+ .wp-select {
430
+ display: flex;
431
+ align-items: center;
432
+ gap: 4px;
433
+ width: 100%;
434
+ height: var(--wp-input-h, 34px);
435
+ box-sizing: border-box;
436
+ padding: 0 10px;
437
+ text-align: left;
438
+ cursor: pointer;
439
+ appearance: none;
440
+ -webkit-appearance: none;
441
+ background: var(--wp-bg-2);
442
+ color: var(--wp-text);
443
+ border: 1px solid var(--wp-border, rgba(255, 255, 255, 0.08));
444
+ border-radius: var(--wp-radius-sm);
445
+ font-size: 12.5px;
446
+ font-family: inherit;
447
+ outline: none;
448
+ transition: border-color 0.12s, background 0.12s, box-shadow 0.12s;
449
+ }
450
+ .wp-select--sm { height: var(--wp-input-h-sm, 24px); font-size: 12px; padding: 0 8px; }
451
+ .wp-select--lg { height: var(--wp-control-h-lg, 40px); font-size: 13.5px; padding: 0 12px; }
452
+ .wp-select:focus-visible,
453
+ .wp-select:focus {
454
+ border-color: var(--wp-accent-500);
455
+ box-shadow: var(--wp-focus-ring, 0 0 0 3px color-mix(in oklab, var(--wp-accent-500) 25%, transparent));
456
+ background: var(--wp-bg-1);
457
+ }
458
+ .wp-select[aria-invalid="true"] { border-color: var(--wp-error-border, var(--wp-danger, #ef4444)); }
459
+ .wp-select:disabled { opacity: 0.45; cursor: not-allowed; }
460
+
461
+ .wp-select__label-wrap {
462
+ display: flex; align-items: center; gap: 6px;
463
+ flex: 1; min-width: 0; overflow: hidden;
464
+ }
465
+ .wp-select__dot { width: 8px; height: 8px; border-radius: 50%; /* round: colour dot */ flex-shrink: 0; display: inline-block; }
466
+ /* Empty stand-in that holds the icon/dot column open for options without one.
467
+ * Width is set inline from `markerWidth`, since it depends on what the list
468
+ * actually contains. */
469
+ .wp-select__marker-gap { flex-shrink: 0; display: inline-block; }
470
+ /* Fixed square, centred: an icon font's glyphs have varying advance widths and
471
+ * a line box taller than the mark itself, so a bare `<i>` sat off-centre next
472
+ * to the label and shifted horizontally between categories. Sizing the BOX and
473
+ * centring the glyph inside it fixes both, and keeps a constant offset to the
474
+ * label whether the option shows an icon or the dot it replaces.
475
+ * Falls back to the dim text colour when a category has an icon but no colour. */
476
+ .wp-select__icon {
477
+ display: inline-flex;
478
+ align-items: center;
479
+ justify-content: center;
480
+ width: 14px;
481
+ height: 14px;
482
+ font-size: 12px;
483
+ line-height: 1;
484
+ flex-shrink: 0;
485
+ color: var(--wp-text-dim);
486
+ }
487
+ .wp-select__label-text,
488
+ .wp-select__placeholder {
489
+ flex: 1; min-width: 0;
490
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
491
+ }
492
+ .wp-select__placeholder { color: var(--wp-text-dim); }
493
+ /* Trigger-side twin of `.wp-select__option-meta`, same mono/dim treatment so
494
+ * the detail you picked in the menu is recognisably the same string once the
495
+ * menu closes.
496
+ *
497
+ * `flex: 0 1 auto` against the label's `flex: 1`: spare width goes to the
498
+ * NAME, which is what the user reads first, while both stay shrinkable so a
499
+ * long meta clips itself rather than pushing the name out of the trigger. */
500
+ .wp-select__label-meta {
501
+ flex: 0 1 auto;
502
+ min-width: 0;
503
+ overflow: hidden;
504
+ text-overflow: ellipsis;
505
+ white-space: nowrap;
506
+ font-family: var(--wp-font-mono);
507
+ font-size: 10px;
508
+ color: var(--wp-text-dim);
509
+ }
510
+ .wp-select__chevron { color: var(--wp-text-dim); font-size: 11px; margin-left: 6px; flex-shrink: 0; }
511
+ /* Clear ✕: a plain glyph kept well clear of the chevron (a 1px rule and
512
+ * 8px either side) so it is not mis-clicked, and shown only while the
513
+ * field is hovered or focused. The ::after gives it a 20px click target. */
514
+ .wp-select__clear {
515
+ position: relative;
516
+ display: inline-grid; place-items: center; flex-shrink: 0;
517
+ width: 14px; height: 14px; margin: 0 2px 0 8px; padding: 0;
518
+ background: none; border: none; color: var(--wp-text-dim); cursor: pointer; line-height: 0;
519
+ opacity: 0; transition: opacity .12s, color .12s;
520
+ }
521
+ .wp-select__clear::after { content: ""; position: absolute; inset: -3px; }
522
+ .wp-select__clear + .wp-select__chevron {
523
+ align-self: stretch; display: inline-flex; align-items: center;
524
+ margin: 6px 0 6px 6px; padding-left: 8px; border-left: 1px solid var(--wp-border);
525
+ }
526
+ .wp-select:hover .wp-select__clear,
527
+ .wp-select:focus-visible .wp-select__clear,
528
+ .wp-select:focus .wp-select__clear,
529
+ .wp-select__clear:focus-visible { opacity: 1; }
530
+ .wp-select__clear:hover { color: var(--wp-text); }
531
+ .wp-select__clear:focus-visible { outline: 2px solid var(--wp-accent-500); outline-offset: 1px; border-radius: min(2px, var(--wp-radius-cap, 999px)); }
532
+ @media (hover: none) { .wp-select__clear { opacity: 1; } }
533
+ .wp-spacer { flex: 1 1 auto; }
534
+
535
+ /* Menu — teleported to <body>. z-index 9999 matches wp-rt-suggestions, which
536
+ * already renders above the canvas modals. */
537
+ .wp-select__menu {
538
+ /* One line box for an option label, in px so a leading dot/icon can be
539
+ * centred against it without the em-resolves-against-its-own-font-size trap
540
+ * (the icon runs at 12px, the label at 12.5px, so `1.5em` means two
541
+ * different heights depending on which element it is declared on).
542
+ * The menu teleports to <body>, so it carries its own copy. */
543
+ --wp-select-line-h: 18.75px;
544
+ background: var(--wp-bg-3);
545
+ border: 1px solid var(--wp-border-strong);
546
+ border-radius: min(7px, var(--wp-radius-cap, 999px));
547
+ box-shadow: var(--wp-shadow-md);
548
+ z-index: 10020;
549
+ padding: 4px;
550
+ max-height: 240px;
551
+ overflow: auto;
552
+ list-style: none;
553
+ margin: 0;
554
+ }
555
+ .wp-select__option {
556
+ display: flex; align-items: center; gap: 6px;
557
+ padding: 6px 8px;
558
+ border-radius: min(6px, var(--wp-radius-cap, 999px));
559
+ color: var(--wp-text-muted);
560
+ cursor: pointer;
561
+ font-size: 12.5px;
562
+ user-select: none;
563
+ }
564
+ .wp-select__option:hover { background: var(--wp-bg-4); color: var(--wp-text); }
565
+ .wp-select__option[data-active="true"] {
566
+ background: color-mix(in oklab, var(--wp-accent-500) 20%, transparent);
567
+ color: var(--wp-text);
568
+ }
569
+ .wp-select__option[data-selected="true"] {
570
+ background: color-mix(in oklab, var(--wp-accent-500) 18%, transparent);
571
+ color: var(--wp-text);
572
+ }
573
+ /* Option rows WRAP (to two lines) rather than ellipsis-clipping at the first.
574
+ * The menu is up to 620px wide and these values run long — a constraint
575
+ * exception source is a whole phrase plus a nested `@ref` — so clipping them
576
+ * to one line hid the part that distinguishes one option from another. The
577
+ * trigger still clips to a single line (it has a fixed height); the menu is
578
+ * where the user is actually reading. `min-width: 0` keeps the meta + tick on
579
+ * screen either way. */
580
+ .wp-select__option-label {
581
+ /* `flex: 0 1 auto` not `1`: at basis 0 it split the row 50/50 with the
582
+ always-present .wp-spacer, so a SHORT option (a nowrap mode chip) got
583
+ squeezed to half-width and clipped by the overflow:hidden below — even
584
+ with room to spare. Sizing to content lets the spacer do the filling; a
585
+ long option still shrinks (flex-shrink:1) into the 2-line clamp. */
586
+ flex: 0 1 auto;
587
+ min-width: 0;
588
+ /* Explicit rather than `normal` so the leading dot/icon can be centred
589
+ * against exactly this height — and so `line-clamp` measures a known line. */
590
+ line-height: var(--wp-select-line-h);
591
+ white-space: normal;
592
+ overflow-wrap: anywhere;
593
+ display: -webkit-box;
594
+ -webkit-line-clamp: 2;
595
+ line-clamp: 2;
596
+ -webkit-box-orient: vertical;
597
+ overflow: hidden;
598
+ }
599
+ .wp-select__option { align-items: flex-start; }
600
+ /* Because the row aligns to flex-start, a leading dot or icon lines up with the
601
+ * TOP of the label rather than its first line, and reads as sitting too high.
602
+ * Both are re-centred against one line box here — scoped to the option, since
603
+ * the trigger row is `align-items: center` and would be thrown off by it. */
604
+ .wp-select__option > .wp-select__icon { height: var(--wp-select-line-h); }
605
+ .wp-select__option > .wp-select__dot {
606
+ margin-top: calc((var(--wp-select-line-h) - 8px) / 2);
607
+ }
608
+ /* Disambiguating detail (option count + uuid). Never shrinks — it is the
609
+ * whole point of the row when several options share a label. */
610
+ .wp-select__option-meta {
611
+ flex-shrink: 0;
612
+ margin-left: var(--wp-space-3, 8px);
613
+ font-family: var(--wp-font-mono);
614
+ font-size: 10px;
615
+ color: var(--wp-text-dim);
616
+ }
617
+ .wp-select__filter { display: flex; align-items: center; padding: 4px 12px; font-size: 11px; opacity: 0.6; }
618
+ .wp-select__empty { padding: 6px 12px; opacity: 0.55; }
619
+
620
+ /* Rich content (RichTextPreview / chips) rendered via the #label / #option
621
+ * slots defaults to pre-wrap (RichTextPreview's own scoped rule).
622
+ *
623
+ * The TRIGGER stays forced to one line: it has a fixed height, so wrapping
624
+ * blows up the surrounding row. `!important` beats RichTextPreview's scoped
625
+ * `.wp-rtp` rule from outside the component.
626
+ *
627
+ * OPTION rows deliberately do NOT get that treatment any more. Forcing them
628
+ * to one line was what truncated constraint-exception values — a phrase plus a
629
+ * nested `@ref` — at the point where they stop being distinguishable. They now
630
+ * wrap within the two-line clamp above, inside the menu's 620px max-width. */
631
+ .wp-select__label-text .wp-rtp,
632
+ .wp-select__label-text .wp-rtp__text {
633
+ white-space: nowrap !important;
634
+ }
635
+ .wp-select__option-label .wp-rtp,
636
+ .wp-select__option-label .wp-rtp__text {
637
+ white-space: normal !important;
638
+ overflow-wrap: anywhere;
639
+ }
640
+ .wp-select__menu { max-width: min(620px, 92vw); }
641
+ </style>
@@ -0,0 +1,54 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Separator: a thin line between groups, across or upright, with optional
4
+ * text in the middle ("or"). Works on the canvas and in the manager.
5
+ *
6
+ * <Separator />
7
+ * <Separator label="or" />
8
+ * <Separator orientation="vertical" /> (inside a Toolbar or a row)
9
+ *
10
+ * It is decorative by default (screen readers skip it). Set
11
+ * `decorative=false` when it really divides two parts of the content.
12
+ */
13
+ withDefaults(defineProps<{
14
+ orientation?: "horizontal" | "vertical" | undefined;
15
+ /** Text in the middle of a horizontal line. */
16
+ label?: string | undefined;
17
+ decorative?: boolean | undefined;
18
+ /** Room around the line. */
19
+ spacing?: "none" | "sm" | "md" | "lg" | undefined;
20
+ }>(), { orientation: "horizontal", label: undefined, decorative: true, spacing: "sm" });
21
+ </script>
22
+
23
+ <template>
24
+ <div
25
+ class="wp-separator"
26
+ :data-orientation="orientation"
27
+ :data-spacing="spacing"
28
+ :data-labelled="label ? 'true' : undefined"
29
+ :role="decorative ? 'none' : 'separator'"
30
+ :aria-orientation="!decorative && orientation === 'vertical' ? 'vertical' : undefined"
31
+ >
32
+ <span v-if="label && orientation === 'horizontal'" class="wp-separator__label">{{ label }}</span>
33
+ </div>
34
+ </template>
35
+
36
+ <style scoped>
37
+ .wp-separator { flex: none; align-self: stretch; }
38
+ .wp-separator[data-orientation="horizontal"] { height: 1px; width: 100%; background: var(--wp-border); }
39
+ .wp-separator[data-orientation="vertical"] { width: 1px; min-height: 1em; background: var(--wp-border); }
40
+ .wp-separator[data-orientation="horizontal"][data-spacing="sm"] { margin: var(--wp-space-3) 0; }
41
+ .wp-separator[data-orientation="horizontal"][data-spacing="md"] { margin: var(--wp-space-5) 0; }
42
+ .wp-separator[data-orientation="horizontal"][data-spacing="lg"] { margin: var(--wp-space-7) 0; }
43
+ .wp-separator[data-orientation="vertical"][data-spacing="sm"] { margin: 0 var(--wp-space-2); }
44
+ .wp-separator[data-orientation="vertical"][data-spacing="md"] { margin: 0 var(--wp-space-4); }
45
+ .wp-separator[data-orientation="vertical"][data-spacing="lg"] { margin: 0 var(--wp-space-6); }
46
+ .wp-separator[data-labelled] {
47
+ display: flex; align-items: center; gap: var(--wp-space-4); height: auto; background: none;
48
+ color: var(--wp-text-dim); font: 600 var(--wp-text-xs)/1 var(--wp-font-sans);
49
+ text-transform: uppercase; letter-spacing: 0.06em;
50
+ }
51
+ .wp-separator[data-labelled]::before, .wp-separator[data-labelled]::after {
52
+ content: ""; flex: 1; height: 1px; background: var(--wp-border);
53
+ }
54
+ </style>