@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.
- package/CHANGELOG.md +51 -0
- package/LICENSE +674 -0
- package/README.md +76 -3
- package/package.json +77 -6
- 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 +107 -0
- package/src/Chip.vue +97 -0
- package/src/ChipToggle.vue +92 -0
- package/src/ChoiceCards.vue +133 -0
- package/src/CloseButton.vue +69 -0
- package/src/CodeBlock.vue +119 -0
- package/src/ColorField.vue +206 -0
- package/src/ColorPicker.vue +201 -0
- package/src/Combobox.vue +334 -0
- package/src/ConfirmDialog.vue +163 -0
- package/src/ConfirmModal.vue +147 -0
- package/src/ConstraintMatrixPreview.vue +708 -0
- package/src/ContextMenu.vue +379 -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/KindChip.vue +53 -0
- package/src/Lightbox.vue +173 -0
- package/src/ListFilter.vue +85 -0
- package/src/ListRow.vue +204 -0
- package/src/LoadingMark.vue +161 -0
- package/src/Logo.vue +60 -0
- package/src/Modal.vue +76 -0
- package/src/ModalShell.vue +107 -0
- package/src/Pagination.vue +106 -0
- package/src/Popover.vue +171 -0
- package/src/PostCard.vue +211 -0
- package/src/ProgressBar.vue +90 -0
- package/src/RadioGroup.vue +177 -0
- package/src/RangeSlider.vue +183 -0
- package/src/RatingBadge.vue +42 -0
- package/src/RelativeDate.vue +35 -0
- package/src/ResizeHandle.vue +143 -0
- package/src/SearchInput.vue +199 -0
- package/src/Select.vue +641 -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 +300 -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 +102 -0
- package/src/WpIconButton.vue +107 -0
- package/src/WpImageZoom.vue +544 -0
- package/src/WpNumber.vue +183 -0
- package/src/WpSegmented.vue +175 -0
- package/src/WpSelect.vue +89 -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/flip.ts +237 -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/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 +2894 -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
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>
|