@dumiflex/wp-kit 0.0.0-stage → 0.4.0

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