@dumiflex/wp-kit 0.4.0 → 0.6.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 +47 -0
- package/README.md +5 -0
- package/package.json +4 -3
- package/src/Avatar.vue +1 -1
- package/src/Banner.vue +4 -4
- package/src/Button.vue +73 -44
- package/src/Checkbox.vue +201 -63
- package/src/Chip.vue +5 -2
- package/src/CodeBlock.vue +33 -6
- package/src/ColorField.vue +60 -6
- package/src/Combobox.vue +10 -14
- package/src/ConfirmModal.vue +12 -22
- package/src/ContextMenu.vue +11 -30
- package/src/CopyButton.vue +1 -1
- package/src/DataTable.vue +3 -4
- package/src/DatePicker.vue +1 -1
- package/src/Disclosure.vue +2 -1
- package/src/Drawer.vue +2 -2
- package/src/DropZone.vue +2 -2
- package/src/HsvPicker.vue +2 -2
- package/src/IconPicker.vue +1 -1
- package/src/ImageGrid.vue +17 -15
- package/src/Lightbox.vue +6 -3
- package/src/LoadingMark.vue +93 -51
- package/src/Modal.vue +109 -44
- package/src/ModalShell.vue +26 -5
- package/src/Pagination.vue +4 -4
- package/src/Popover.vue +68 -12
- package/src/RadioGroup.vue +5 -4
- package/src/SearchInput.vue +1 -1
- package/src/Select.vue +53 -158
- package/src/Skeleton.vue +21 -5
- package/src/Slider.vue +166 -55
- package/src/SortableList.vue +1 -1
- package/src/TagInput.vue +1 -1
- package/src/Timeline.vue +2 -2
- package/src/WorkingLine.vue +320 -0
- package/src/WpIconButton.vue +3 -2
- package/src/WpImageZoom.vue +7 -7
- package/src/WpSegmented.vue +3 -1
- package/src/WpSwitch.vue +1 -1
- package/src/WpTabs.vue +1 -1
- package/src/_modal-motion.css +1 -1
- package/src/button.css +8 -6
- package/src/floating.ts +5 -0
- package/src/highlight.ts +199 -0
- package/src/hsv.ts +1 -1
- package/src/loading-mark-motion.ts +249 -0
- package/src/menu.css +51 -0
- package/src/motion.css +73 -0
- package/src/slider-math.ts +45 -1
- package/src/theme/looks.css +6 -19
- package/src/theme/tokens.css +12 -1108
- package/src/tokens.ts +8 -3
- package/src/tooltip.css +8 -5
- package/src/useDialogFocus.ts +11 -2
- package/src/CloseButton.vue +0 -69
- package/src/ColorPicker.vue +0 -201
- package/src/ConfirmDialog.vue +0 -165
- package/src/RangeSlider.vue +0 -183
- package/src/Toggle.vue +0 -36
- package/src/WpButton.vue +0 -75
- package/src/WpCheck.vue +0 -104
- package/src/_skeleton.css +0 -50
package/src/Popover.vue
CHANGED
|
@@ -16,9 +16,15 @@
|
|
|
16
16
|
* element don't count as outside, so it can toggle the panel itself.
|
|
17
17
|
*
|
|
18
18
|
* <Popover :open="!!cell" :anchor="cell?.el" label="Cell rule" @update:open="cell = null">…</Popover>
|
|
19
|
+
*
|
|
20
|
+
* `variant="menu"` draws the panel as a menu (`menu.css`, the look Select's
|
|
21
|
+
* list, ContextMenu and Combobox share): tighter padding, 4px from the
|
|
22
|
+
* trigger, above modals, and it closes once its trigger scrolls out of view.
|
|
23
|
+
* Every panel is capped at `maxHeight` and at the room on its side, and
|
|
24
|
+
* scrolls past that instead of covering its trigger.
|
|
19
25
|
*/
|
|
20
26
|
import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from "vue";
|
|
21
|
-
import { placeFloating, viewportSize, type Placement, type Side } from "./floating";
|
|
27
|
+
import { placeFloating, roomOn, viewportSize, type Placement, type Side } from "./floating";
|
|
22
28
|
import { provideFloatingLayer, useFloatingChild } from "./floating-layer";
|
|
23
29
|
|
|
24
30
|
const props = withDefaults(defineProps<{
|
|
@@ -34,14 +40,21 @@ const props = withDefaults(defineProps<{
|
|
|
34
40
|
role?: "dialog" | "menu" | "listbox" | "group" | undefined;
|
|
35
41
|
/** Place the panel against this element instead of the trigger slot. */
|
|
36
42
|
anchor?: HTMLElement | null | undefined;
|
|
37
|
-
|
|
43
|
+
/** "menu" for a list of choices (Select, Combobox): the shared menu look. */
|
|
44
|
+
variant?: "panel" | "menu" | undefined;
|
|
45
|
+
/** Tallest the panel grows, in px, before it scrolls. */
|
|
46
|
+
maxHeight?: number | undefined;
|
|
47
|
+
}>(), { placement: "bottom-start", autofocus: true, role: "dialog", anchor: null, variant: "panel" });
|
|
38
48
|
|
|
39
49
|
const emit = defineEmits<{ (e: "update:open", v: boolean): void }>();
|
|
40
50
|
|
|
41
51
|
const id = `wp-popover-${useId()}`;
|
|
42
52
|
const anchorEl = ref<HTMLElement | null>(null);
|
|
43
53
|
const panelEl = ref<HTMLElement | null>(null);
|
|
44
|
-
const pos = ref<{ x: number; y: number; side: Side; minWidth?: number | undefined }>({ x: -9999, y: -9999, side: "bottom" });
|
|
54
|
+
const pos = ref<{ x: number; y: number; side: Side; minWidth?: number | undefined; maxHeight?: number | undefined }>({ x: -9999, y: -9999, side: "bottom" });
|
|
55
|
+
const isMenu = computed(() => props.variant === "menu");
|
|
56
|
+
/** A short panel still shows a few rows when the room on both sides is tight. */
|
|
57
|
+
const MIN_HEIGHT = 80;
|
|
45
58
|
|
|
46
59
|
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), '
|
|
47
60
|
+ 'textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
@@ -68,10 +81,39 @@ function place(): void {
|
|
|
68
81
|
const p = panelEl.value;
|
|
69
82
|
if (!t || !p) return;
|
|
70
83
|
const r = t.getBoundingClientRect();
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
84
|
+
const view = viewportSize();
|
|
85
|
+
const opts = { placement: props.placement, gap: isMenu.value ? 4 : 6 };
|
|
86
|
+
const width = Math.max(p.offsetWidth, props.matchWidth ? r.width : 0);
|
|
87
|
+
// The content's own height (scrollHeight ignores the cap set last time),
|
|
88
|
+
// so a panel that was squeezed can grow back when there is room again.
|
|
89
|
+
const borders = p.offsetHeight - p.clientHeight;
|
|
90
|
+
const natural = Math.min(p.scrollHeight + borders, props.maxHeight ?? Infinity);
|
|
91
|
+
const first = placeFloating(r, { width, height: natural }, view, opts);
|
|
92
|
+
const fits = Math.max(MIN_HEIGHT, roomOn(first.side, r, view, opts));
|
|
93
|
+
const height = Math.min(natural, fits);
|
|
94
|
+
const at = height < natural ? placeFloating(r, { width, height }, view, opts) : first;
|
|
95
|
+
pos.value = {
|
|
96
|
+
...at,
|
|
97
|
+
minWidth: props.matchWidth ? r.width : undefined,
|
|
98
|
+
maxHeight: Math.min(props.maxHeight ?? Infinity, fits),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** True once no part of the trigger is on screen. */
|
|
103
|
+
function triggerGone(): boolean {
|
|
104
|
+
const t = trigger();
|
|
105
|
+
if (!t) return false;
|
|
106
|
+
const r = t.getBoundingClientRect();
|
|
107
|
+
const view = viewportSize();
|
|
108
|
+
return r.bottom < 0 || r.top > view.height || r.right < 0 || r.left > view.width;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function onScroll(ev: Event): void {
|
|
112
|
+
// The panel's own scrolling changes nothing about where it sits.
|
|
113
|
+
if (ev.target instanceof Node && panelEl.value?.contains(ev.target)) return;
|
|
114
|
+
// A menu whose trigger has scrolled away has nothing left to follow.
|
|
115
|
+
if (isMenu.value && triggerGone()) { setOpen(false); return; }
|
|
116
|
+
place();
|
|
75
117
|
}
|
|
76
118
|
|
|
77
119
|
// A Select list, menu or Popover opened from inside this panel is part of it.
|
|
@@ -103,7 +145,7 @@ function listen(on: boolean): void {
|
|
|
103
145
|
window[m]("pointerdown", onPointerDown as EventListener, true);
|
|
104
146
|
window[m]("keydown", onKeydown as EventListener, true);
|
|
105
147
|
window[m]("resize", place);
|
|
106
|
-
window[m]("scroll",
|
|
148
|
+
window[m]("scroll", onScroll, true);
|
|
107
149
|
}
|
|
108
150
|
|
|
109
151
|
watch(() => props.open, async (v) => {
|
|
@@ -135,17 +177,26 @@ defineExpose({ place });
|
|
|
135
177
|
:id="id"
|
|
136
178
|
ref="panelEl"
|
|
137
179
|
class="wp-popover"
|
|
180
|
+
:class="{ 'wp-menu': isMenu }"
|
|
138
181
|
:data-side="pos.side"
|
|
182
|
+
:data-variant="variant"
|
|
139
183
|
:role="role"
|
|
140
184
|
:aria-label="label"
|
|
141
185
|
tabindex="-1"
|
|
142
|
-
:style="{
|
|
186
|
+
:style="{
|
|
187
|
+
left: `${pos.x}px`,
|
|
188
|
+
top: `${pos.y}px`,
|
|
189
|
+
minWidth: pos.minWidth ? `${pos.minWidth}px` : undefined,
|
|
190
|
+
maxHeight: pos.maxHeight !== undefined && Number.isFinite(pos.maxHeight) ? `${pos.maxHeight}px` : undefined,
|
|
191
|
+
}"
|
|
143
192
|
>
|
|
144
193
|
<slot :close="() => setOpen(false)" />
|
|
145
194
|
</div>
|
|
146
195
|
</Teleport>
|
|
147
196
|
</template>
|
|
148
197
|
|
|
198
|
+
<style scoped src="./menu.css"></style>
|
|
199
|
+
|
|
149
200
|
<style>
|
|
150
201
|
@layer wp-extension {
|
|
151
202
|
.wp-popover-anchor { display: inline-flex; }
|
|
@@ -155,12 +206,15 @@ defineExpose({ place });
|
|
|
155
206
|
max-width: min(420px, calc(100vw - 16px));
|
|
156
207
|
max-height: calc(100vh - 16px);
|
|
157
208
|
overflow: auto;
|
|
158
|
-
padding: var(--wp-space-3);
|
|
209
|
+
padding: calc(var(--wp-space-3) + 1px); /* plus the 1px the ring stands in for */
|
|
159
210
|
background: var(--wp-bg-2);
|
|
160
211
|
color: var(--wp-text);
|
|
161
|
-
border:
|
|
212
|
+
/* The outline is an inset ring, not a border: these rules are layered, and
|
|
213
|
+
a host's unlayered reset (Tailwind 3's preflight sets border-width: 0
|
|
214
|
+
on every element) would beat a border. Nothing resets box-shadow. */
|
|
215
|
+
border: 0;
|
|
162
216
|
border-radius: var(--wp-radius);
|
|
163
|
-
box-shadow: var(--wp-shadow-md);
|
|
217
|
+
box-shadow: inset 0 0 0 1px var(--wp-border-strong), var(--wp-shadow-md);
|
|
164
218
|
font-family: var(--wp-font-sans);
|
|
165
219
|
font-size: var(--wp-text-sm);
|
|
166
220
|
outline: none;
|
|
@@ -169,6 +223,8 @@ defineExpose({ place });
|
|
|
169
223
|
@keyframes wp-popover-in {
|
|
170
224
|
from { opacity: 0; transform: translateY(-2px); }
|
|
171
225
|
}
|
|
226
|
+
/* A menu is as wide as its longest row (up to 620px), not a 420px panel. */
|
|
227
|
+
.wp-popover[data-variant="menu"] { max-width: min(620px, calc(100vw - 16px)); }
|
|
172
228
|
.wp-popover[data-side="top"] { animation-name: wp-popover-in-up; }
|
|
173
229
|
@keyframes wp-popover-in-up {
|
|
174
230
|
from { opacity: 0; transform: translateY(2px); }
|
package/src/RadioGroup.vue
CHANGED
|
@@ -143,16 +143,17 @@ function onKeydown(ev: KeyboardEvent, at: T): void {
|
|
|
143
143
|
.wp-radio-group__opt[aria-disabled="true"] { opacity: 0.45; cursor: not-allowed; }
|
|
144
144
|
.wp-radio-group__dot {
|
|
145
145
|
position: relative; flex: none; box-sizing: border-box; width: 16px; height: 16px; margin-top: 1px;
|
|
146
|
-
|
|
146
|
+
/* Whole-pixel border and inset, so the dot sits dead centre (a square look shows any half pixel). */
|
|
147
|
+
border: 2px solid var(--wp-border-strong); border-radius: min(50%, var(--wp-radius-cap, 999px)); background: var(--wp-bg-1);
|
|
147
148
|
transition: border-color var(--wp-motion-hover);
|
|
148
149
|
}
|
|
149
|
-
.wp-radio-group[data-size="sm"] .wp-radio-group__dot { width:
|
|
150
|
+
.wp-radio-group[data-size="sm"] .wp-radio-group__dot { width: 12px; height: 12px; border-width: 1px; }
|
|
150
151
|
.wp-radio-group__opt:hover:not([aria-disabled="true"]) .wp-radio-group__dot { border-color: var(--wp-accent); }
|
|
151
152
|
.wp-radio-group__opt[data-checked] .wp-radio-group__dot { border-color: var(--wp-accent); }
|
|
152
153
|
.wp-radio-group__opt[data-checked] .wp-radio-group__dot::after {
|
|
153
|
-
content: ""; position: absolute; inset: 3px; border-radius: 50
|
|
154
|
+
content: ""; position: absolute; inset: 3px; border-radius: min(50%, var(--wp-radius-cap, 999px)); background: var(--wp-accent);
|
|
154
155
|
}
|
|
155
|
-
.wp-radio-group[data-size="sm"] .wp-radio-group__opt[data-checked] .wp-radio-group__dot::after { inset:
|
|
156
|
+
.wp-radio-group[data-size="sm"] .wp-radio-group__opt[data-checked] .wp-radio-group__dot::after { inset: 2px; }
|
|
156
157
|
/* A picked option that is disabled turns grey, so it can't pass for a live one. */
|
|
157
158
|
.wp-radio-group__opt[aria-disabled="true"][data-checked] .wp-radio-group__dot { border-color: var(--wp-text-dim); }
|
|
158
159
|
.wp-radio-group__opt[aria-disabled="true"][data-checked] .wp-radio-group__dot::after { background: var(--wp-text-dim); }
|
package/src/SearchInput.vue
CHANGED
|
@@ -186,7 +186,7 @@ defineExpose({
|
|
|
186
186
|
color: var(--wp-text-dim);
|
|
187
187
|
cursor: pointer;
|
|
188
188
|
}
|
|
189
|
-
.wp-search__clear .pi { font-size:
|
|
189
|
+
.wp-search__clear .pi { font-size: 10px; line-height: 1; }
|
|
190
190
|
.wp-search__clear:hover { color: var(--wp-text); background: var(--wp-bg-3); }
|
|
191
191
|
.wp-search__clear:focus-visible { outline: none; box-shadow: var(--wp-focus-ring); }
|
|
192
192
|
</style>
|
package/src/Select.vue
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, nextTick,
|
|
2
|
+
import { computed, nextTick, ref, useId, watch } from "vue";
|
|
3
3
|
import Icon, { ICON_SM } from "./Icon.vue";
|
|
4
|
+
import Popover from "./Popover.vue";
|
|
4
5
|
|
|
5
6
|
// Declared in `./select-types` so plain `.ts` helpers can build option lists
|
|
6
7
|
// (a `.ts` importer only sees the `*.vue` ambient shim). Re-exported here so
|
|
7
8
|
// existing `from "./Select.vue"` imports keep resolving.
|
|
8
9
|
import type { SelectOption } from "./select-types";
|
|
9
|
-
import { useFloatingChild } from "./floating-layer";
|
|
10
10
|
export type { SelectOption };
|
|
11
11
|
|
|
12
12
|
interface Props {
|
|
@@ -31,21 +31,16 @@ const emit = defineEmits<{
|
|
|
31
31
|
(e: "change", v: string | number | null): void;
|
|
32
32
|
}>();
|
|
33
33
|
|
|
34
|
-
const
|
|
34
|
+
const listId = `wp-select-list-${useId()}`;
|
|
35
35
|
const btnRef = ref<HTMLButtonElement | null>(null);
|
|
36
36
|
const open = ref(false);
|
|
37
|
-
const flip = ref(false);
|
|
38
37
|
const active = ref(0);
|
|
39
38
|
/** Type-to-filter query, built from printable keystrokes while the menu
|
|
40
39
|
* is open. Reset every time the menu opens or closes. */
|
|
41
40
|
const query = ref("");
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
useFloatingChild(() => menuRef.value);
|
|
46
|
-
|
|
47
|
-
/** Fixed-position coordinates for the teleported menu. */
|
|
48
|
-
const menuStyle = ref<Record<string, string>>({});
|
|
41
|
+
// The list floats in a kit Popover (variant "menu"): it places, flips, fits
|
|
42
|
+
// and follows the trigger, closes on a press outside or Escape, and counts as
|
|
43
|
+
// part of a Popover the Select sits in. The keys stay on the trigger.
|
|
49
44
|
|
|
50
45
|
const selected = computed(() => props.options.find((o) => o.value === props.modelValue) ?? null);
|
|
51
46
|
|
|
@@ -107,108 +102,18 @@ const composedAriaLabel = computed<string | undefined>(() => {
|
|
|
107
102
|
return props.ariaLabel;
|
|
108
103
|
});
|
|
109
104
|
|
|
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
105
|
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
106
|
query.value = "";
|
|
204
|
-
|
|
107
|
+
if (!v) return;
|
|
205
108
|
// Highlight currently-selected option (or first).
|
|
206
109
|
const idx = props.options.findIndex((o) => o.value === props.modelValue);
|
|
207
110
|
active.value = idx >= 0 ? idx : 0;
|
|
208
|
-
window.addEventListener("scroll", followOnScroll, true);
|
|
209
|
-
window.addEventListener("resize", reposition);
|
|
210
111
|
});
|
|
211
112
|
|
|
113
|
+
function onOpen(v: boolean) {
|
|
114
|
+
open.value = v;
|
|
115
|
+
}
|
|
116
|
+
|
|
212
117
|
function toggle() {
|
|
213
118
|
if (props.disabled) return;
|
|
214
119
|
open.value = !open.value;
|
|
@@ -246,6 +151,9 @@ function onKeydown(e: KeyboardEvent) {
|
|
|
246
151
|
} else if (e.key === "ArrowUp") {
|
|
247
152
|
e.preventDefault();
|
|
248
153
|
if (list.length) active.value = (active.value - 1 + list.length) % list.length;
|
|
154
|
+
} else if (e.key === "Home" || e.key === "End") {
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
if (list.length) active.value = e.key === "Home" ? 0 : list.length - 1;
|
|
249
157
|
} else if (e.key === "Enter") {
|
|
250
158
|
e.preventDefault();
|
|
251
159
|
const opt = list[active.value];
|
|
@@ -264,7 +172,7 @@ function onKeydown(e: KeyboardEvent) {
|
|
|
264
172
|
</script>
|
|
265
173
|
|
|
266
174
|
<template>
|
|
267
|
-
<div
|
|
175
|
+
<div class="wp-select-wrap">
|
|
268
176
|
<button
|
|
269
177
|
ref="btnRef"
|
|
270
178
|
type="button"
|
|
@@ -273,6 +181,7 @@ function onKeydown(e: KeyboardEvent) {
|
|
|
273
181
|
:aria-label="composedAriaLabel"
|
|
274
182
|
:aria-expanded="open"
|
|
275
183
|
:aria-haspopup="'listbox'"
|
|
184
|
+
:aria-controls="open ? listId : undefined"
|
|
276
185
|
:aria-invalid="error || undefined"
|
|
277
186
|
data-test="select-trigger"
|
|
278
187
|
@click="toggle"
|
|
@@ -332,15 +241,27 @@ function onKeydown(e: KeyboardEvent) {
|
|
|
332
241
|
<Icon class="wp-select__chevron" name="chevron-down" />
|
|
333
242
|
</button>
|
|
334
243
|
|
|
335
|
-
<!--
|
|
336
|
-
|
|
337
|
-
|
|
244
|
+
<!-- The list floats on <body> in a menu Popover, so it escapes any
|
|
245
|
+
overflow:hidden ancestor and clears every modal. -->
|
|
246
|
+
<!-- Popover's autofocus is turned OFF: focus stays on the trigger, which has the keys. -->
|
|
247
|
+
<!-- eslint-disable vuejs-accessibility/no-autofocus -->
|
|
248
|
+
<Popover
|
|
249
|
+
:open="open && !disabled"
|
|
250
|
+
:anchor="btnRef"
|
|
251
|
+
variant="menu"
|
|
252
|
+
role="group"
|
|
253
|
+
match-width
|
|
254
|
+
:max-height="240"
|
|
255
|
+
:autofocus="false"
|
|
256
|
+
:label="ariaLabel ?? placeholder"
|
|
257
|
+
@update:open="onOpen"
|
|
258
|
+
>
|
|
259
|
+
<!-- eslint-enable vuejs-accessibility/no-autofocus -->
|
|
338
260
|
<ul
|
|
339
|
-
|
|
340
|
-
ref="menuRef"
|
|
261
|
+
:id="listId"
|
|
341
262
|
class="wp-select__menu"
|
|
342
263
|
role="listbox"
|
|
343
|
-
:
|
|
264
|
+
:aria-label="ariaLabel ?? placeholder"
|
|
344
265
|
data-test="select-menu"
|
|
345
266
|
>
|
|
346
267
|
<li
|
|
@@ -364,7 +285,7 @@ function onKeydown(e: KeyboardEvent) {
|
|
|
364
285
|
<li
|
|
365
286
|
v-for="(opt, i) in filtered"
|
|
366
287
|
:key="String(opt.value)"
|
|
367
|
-
class="wp-select__option"
|
|
288
|
+
class="wp-select__option wp-menu__item"
|
|
368
289
|
role="option"
|
|
369
290
|
tabindex="-1"
|
|
370
291
|
:aria-selected="opt.value === modelValue"
|
|
@@ -407,7 +328,7 @@ function onKeydown(e: KeyboardEvent) {
|
|
|
407
328
|
style="padding:6px 12px;opacity:0.55;"
|
|
408
329
|
>No matches</li>
|
|
409
330
|
</ul>
|
|
410
|
-
</
|
|
331
|
+
</Popover>
|
|
411
332
|
</div>
|
|
412
333
|
</template>
|
|
413
334
|
|
|
@@ -415,14 +336,11 @@ function onKeydown(e: KeyboardEvent) {
|
|
|
415
336
|
Self-contained styles so <Select> works everywhere it's used — including the
|
|
416
337
|
ComfyUI CANVAS widget bundle, which does NOT load the manager-global
|
|
417
338
|
tokens.css (that's why an unstyled Select rendered behind the modal there).
|
|
418
|
-
Class names stay wp-select* (global-namespaced, no collision)
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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.
|
|
339
|
+
Class names stay wp-select* (global-namespaced, no collision). The list's
|
|
340
|
+
look is the shared menu sheet (menu.css, scoped, at the end), whose layer
|
|
341
|
+
(10020) clears every modal overlay (those run up to 10010). Vars fall back
|
|
342
|
+
to literals for the few the canvas theme doesn't define (--wp-input-h[-sm],
|
|
343
|
+
--wp-focus-ring).
|
|
426
344
|
-->
|
|
427
345
|
<style>
|
|
428
346
|
.wp-select-wrap { position: relative; display: inline-block; width: 100%; }
|
|
@@ -465,7 +383,7 @@ function onKeydown(e: KeyboardEvent) {
|
|
|
465
383
|
display: flex; align-items: center; gap: 6px;
|
|
466
384
|
flex: 1; min-width: 0; overflow: hidden;
|
|
467
385
|
}
|
|
468
|
-
.wp-select__dot { width: 8px; height: 8px; border-radius: 50
|
|
386
|
+
.wp-select__dot { width: 8px; height: 8px; border-radius: min(50%, var(--wp-radius-cap, 999px)); flex-shrink: 0; display: inline-block; }
|
|
469
387
|
/* Empty stand-in that holds the icon/dot column open for options without one.
|
|
470
388
|
* Width is set inline from `markerWidth`, since it depends on what the list
|
|
471
389
|
* actually contains. */
|
|
@@ -510,7 +428,7 @@ function onKeydown(e: KeyboardEvent) {
|
|
|
510
428
|
font-size: 10px;
|
|
511
429
|
color: var(--wp-text-dim);
|
|
512
430
|
}
|
|
513
|
-
.wp-select__chevron { color: var(--wp-text-dim); font-size:
|
|
431
|
+
.wp-select__chevron { color: var(--wp-text-dim); font-size: 12px; margin-left: 6px; flex-shrink: 0; }
|
|
514
432
|
/* Clear ✕: a plain glyph kept well clear of the chevron (a 1px rule and
|
|
515
433
|
* 8px either side) so it is not mis-clicked, and shown only while the
|
|
516
434
|
* field is hovered or focused. The ::after gives it a 20px click target. */
|
|
@@ -535,44 +453,20 @@ function onKeydown(e: KeyboardEvent) {
|
|
|
535
453
|
@media (hover: none) { .wp-select__clear { opacity: 1; } }
|
|
536
454
|
.wp-spacer { flex: 1 1 auto; }
|
|
537
455
|
|
|
538
|
-
/*
|
|
539
|
-
*
|
|
456
|
+
/* The list. Its panel (background, border, corners, padding, shadow, layer)
|
|
457
|
+
* and its rows' padding, corners, text and states are the shared menu look:
|
|
458
|
+
* Popover's `variant="menu"` and `menu.css` below. */
|
|
540
459
|
.wp-select__menu {
|
|
541
460
|
/* One line box for an option label, in px so a leading dot/icon can be
|
|
542
461
|
* centred against it without the em-resolves-against-its-own-font-size trap
|
|
543
|
-
* (the icon runs at 12px
|
|
544
|
-
*
|
|
545
|
-
|
|
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;
|
|
462
|
+
* (the icon runs at 12px and so does the label; `1.5em` on the icon would
|
|
463
|
+
* still measure against a different box). */
|
|
464
|
+
--wp-select-line-h: 18px;
|
|
555
465
|
list-style: none;
|
|
556
466
|
margin: 0;
|
|
467
|
+
padding: 0;
|
|
557
468
|
}
|
|
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
|
-
}
|
|
469
|
+
.wp-select__option { display: flex; gap: 6px; }
|
|
576
470
|
/* Option rows WRAP (to two lines) rather than ellipsis-clipping at the first.
|
|
577
471
|
* The menu is up to 620px wide and these values run long — a constraint
|
|
578
472
|
* exception source is a whole phrase plus a nested `@ref` — so clipping them
|
|
@@ -640,5 +534,6 @@ function onKeydown(e: KeyboardEvent) {
|
|
|
640
534
|
white-space: normal !important;
|
|
641
535
|
overflow-wrap: anywhere;
|
|
642
536
|
}
|
|
643
|
-
.wp-select__menu { max-width: min(620px, 92vw); }
|
|
644
537
|
</style>
|
|
538
|
+
|
|
539
|
+
<style scoped src="./menu.css"></style>
|
package/src/Skeleton.vue
CHANGED
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
import { computed } from "vue";
|
|
16
16
|
|
|
17
17
|
const props = withDefaults(defineProps<{
|
|
18
|
-
/** line: text lines (the last one shorter). block: a box. circle: an avatar or icon. row: an icon, a title and a hint line, like a list row. */
|
|
19
|
-
shape?: "line" | "block" | "circle" | "row" | undefined;
|
|
18
|
+
/** line: text lines (the last one shorter). block: a box. circle: an avatar or icon. row: an icon, a title and a hint line, like a list row. chip: a run of pills, like a tag or filter row. */
|
|
19
|
+
shape?: "line" | "block" | "circle" | "row" | "chip" | undefined;
|
|
20
|
+
/** The icon a row starts with: an avatar (circle, default) or an icon tile (square). */
|
|
21
|
+
icon?: "circle" | "square" | undefined;
|
|
20
22
|
/** How many lines or rows. */
|
|
21
23
|
lines?: number | undefined;
|
|
22
24
|
/** CSS width of a line, block or circle. */
|
|
@@ -25,9 +27,11 @@ const props = withDefaults(defineProps<{
|
|
|
25
27
|
height?: string | undefined;
|
|
26
28
|
/** Say "Loading…" to screen readers with this text. */
|
|
27
29
|
label?: string | undefined;
|
|
28
|
-
}>(), { shape: "line", lines: 1, width: undefined, height: undefined, label: undefined });
|
|
30
|
+
}>(), { shape: "line", lines: 1, icon: "circle", width: undefined, height: undefined, label: undefined });
|
|
29
31
|
|
|
30
32
|
const count = computed(() => Math.max(1, Math.floor(props.lines)));
|
|
33
|
+
/** Pill widths that read as different words. */
|
|
34
|
+
const CHIP_WIDTHS = ["64px", "48px", "80px", "56px", "72px", "40px"];
|
|
31
35
|
/** The last of several lines is shorter, like the end of a paragraph. */
|
|
32
36
|
const lineWidth = (i: number): string | undefined =>
|
|
33
37
|
props.width ?? (count.value > 1 && i === count.value - 1 ? "60%" : undefined);
|
|
@@ -41,9 +45,17 @@ const lineWidth = (i: number): string | undefined =>
|
|
|
41
45
|
</template>
|
|
42
46
|
<span v-else-if="shape === 'block'" class="wp-skeleton__bone wp-skeleton__block" :style="{ width, height }" aria-hidden="true" />
|
|
43
47
|
<span v-else-if="shape === 'circle'" class="wp-skeleton__bone wp-skeleton__circle" :style="{ width: width ?? height, height: height ?? width }" aria-hidden="true" />
|
|
48
|
+
<span v-else-if="shape === 'chip'" class="wp-skeleton__chips" aria-hidden="true">
|
|
49
|
+
<span
|
|
50
|
+
v-for="i in count"
|
|
51
|
+
:key="i"
|
|
52
|
+
class="wp-skeleton__bone wp-skeleton__chip"
|
|
53
|
+
:style="{ width: width ?? CHIP_WIDTHS[(i - 1) % CHIP_WIDTHS.length], height }"
|
|
54
|
+
/>
|
|
55
|
+
</span>
|
|
44
56
|
<template v-else>
|
|
45
57
|
<span v-for="i in count" :key="i" class="wp-skeleton__row" aria-hidden="true">
|
|
46
|
-
<span class="wp-skeleton__bone wp-skeleton__circle" />
|
|
58
|
+
<span class="wp-skeleton__bone" :class="icon === 'square' ? 'wp-skeleton__tile' : 'wp-skeleton__circle'" />
|
|
47
59
|
<span class="wp-skeleton__row-text">
|
|
48
60
|
<span class="wp-skeleton__bone wp-skeleton__line" :style="{ width: i % 2 ? '55%' : '42%' }" />
|
|
49
61
|
<span class="wp-skeleton__bone wp-skeleton__line wp-skeleton__line--hint" :style="{ width: i % 2 ? '80%' : '68%' }" />
|
|
@@ -68,7 +80,11 @@ const lineWidth = (i: number): string | undefined =>
|
|
|
68
80
|
.wp-skeleton__line { width: 100%; height: 0.75em; }
|
|
69
81
|
.wp-skeleton__line--hint { height: 0.6em; opacity: 0.7; }
|
|
70
82
|
.wp-skeleton__block { width: 100%; height: 64px; border-radius: var(--wp-radius); }
|
|
71
|
-
.wp-skeleton__circle { flex: none; width: 28px; height: 28px; border-radius: 50
|
|
83
|
+
.wp-skeleton__circle { flex: none; width: 28px; height: 28px; border-radius: min(50%, var(--wp-radius-cap, 999px)); }
|
|
84
|
+
.wp-skeleton__tile { flex: none; width: 28px; height: 28px; border-radius: var(--wp-radius-sm); }
|
|
85
|
+
.wp-skeleton__chips { display: flex; flex-wrap: wrap; gap: var(--wp-space-2); }
|
|
86
|
+
/* A Chip's height (xs, 19px); pass height for a sm (24px) filter row. */
|
|
87
|
+
.wp-skeleton__chip { height: 19px; border-radius: var(--wp-radius-cap, 999px); }
|
|
72
88
|
.wp-skeleton__row { display: flex; align-items: center; gap: var(--wp-space-4); padding: var(--wp-space-2) 0; }
|
|
73
89
|
.wp-skeleton__row-text { display: flex; flex-direction: column; gap: var(--wp-space-2); flex: 1; min-width: 0; }
|
|
74
90
|
.wp-skeleton__sr {
|