@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/ImageGrid.vue
CHANGED
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
* <ImageGrid :images="outputs" />
|
|
9
9
|
* <ImageGrid v-model:selected="keep" :images="frames" selectable :min-size="96" />
|
|
10
10
|
*
|
|
11
|
-
* Tiles are buttons in reading order (one Tab stop each)
|
|
12
|
-
* `src` draws a Skeleton
|
|
11
|
+
* Tiles are buttons in reading order (one Tab stop each). A tile with no
|
|
12
|
+
* `src` yet (a frame still rendering) draws a Skeleton and can't be opened;
|
|
13
|
+
* the Lightbox steps only through the images that are there.
|
|
13
14
|
*/
|
|
14
15
|
import { computed, ref } from "vue";
|
|
15
16
|
import Lightbox from "./Lightbox.vue";
|
|
16
|
-
import
|
|
17
|
+
import Skeleton from "./Skeleton.vue";
|
|
18
|
+
import Checkbox from "./Checkbox.vue";
|
|
17
19
|
|
|
18
20
|
export interface GridImage { id: string; src: string; alt: string; thumb?: string; caption?: string }
|
|
19
21
|
|
|
@@ -38,12 +40,15 @@ const emit = defineEmits<{
|
|
|
38
40
|
|
|
39
41
|
const open = ref(false);
|
|
40
42
|
const at = ref(0);
|
|
41
|
-
|
|
43
|
+
/** Only the images that are there: a tile still loading has nothing to show. */
|
|
44
|
+
const ready = computed(() => props.images.map((img, i) => ({ img, i })).filter(({ img }) => !!img.src));
|
|
45
|
+
const shots = computed(() => ready.value.map(({ img }) => ({ src: img.src, alt: img.alt, caption: img.caption })));
|
|
42
46
|
|
|
43
47
|
function show(i: number): void {
|
|
48
|
+
if (!props.images[i]?.src) return;
|
|
44
49
|
emit("open", i);
|
|
45
50
|
if (!props.lightbox) return;
|
|
46
|
-
at.value = i;
|
|
51
|
+
at.value = ready.value.findIndex((r) => r.i === i);
|
|
47
52
|
open.value = true;
|
|
48
53
|
}
|
|
49
54
|
|
|
@@ -69,13 +74,15 @@ function toggle(id: string, on: boolean): void {
|
|
|
69
74
|
<button
|
|
70
75
|
type="button"
|
|
71
76
|
class="wp-image-grid__open"
|
|
72
|
-
:aria-label="selectable ? `Open ${img.alt}` : img.alt"
|
|
77
|
+
:aria-label="!img.src ? `${img.alt}, still loading` : selectable ? `Open ${img.alt}` : img.alt"
|
|
78
|
+
:aria-disabled="!img.src || undefined"
|
|
79
|
+
:data-loading="!img.src || undefined"
|
|
73
80
|
@click="show(i)"
|
|
74
81
|
>
|
|
75
82
|
<img v-if="img.thumb || img.src" class="wp-image-grid__img" :src="img.thumb || img.src" alt="" loading="lazy">
|
|
76
|
-
<
|
|
83
|
+
<Skeleton v-else class="wp-image-grid__empty" shape="block" width="100%" height="100%" />
|
|
77
84
|
</button>
|
|
78
|
-
<
|
|
85
|
+
<Checkbox
|
|
79
86
|
v-if="selectable"
|
|
80
87
|
class="wp-image-grid__check"
|
|
81
88
|
:model-value="props.selected.includes(img.id)"
|
|
@@ -107,12 +114,8 @@ function toggle(id: string, on: boolean): void {
|
|
|
107
114
|
.wp-image-grid__open:focus-visible { outline: none; border-color: var(--wp-border-focus); box-shadow: var(--wp-focus-ring); }
|
|
108
115
|
.wp-image-grid__tile[data-selected] .wp-image-grid__open { border-color: var(--wp-accent); box-shadow: 0 0 0 2px var(--wp-accent-glow); }
|
|
109
116
|
.wp-image-grid__img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
|
110
|
-
.wp-image-
|
|
111
|
-
|
|
112
|
-
background: linear-gradient(90deg, var(--wp-bg-2) 0%, var(--wp-bg-3) 50%, var(--wp-bg-2) 100%);
|
|
113
|
-
background-size: 200% 100%; animation: wp-image-grid-shimmer var(--wp-motion-shimmer) infinite;
|
|
114
|
-
}
|
|
115
|
-
@keyframes wp-image-grid-shimmer { to { background-position: -200% 0; } }
|
|
117
|
+
.wp-image-grid__open[data-loading] { cursor: progress; }
|
|
118
|
+
.wp-image-grid__empty { width: 100%; height: 100%; }
|
|
116
119
|
.wp-image-grid__check { position: absolute; top: 6px; left: 6px; }
|
|
117
120
|
.wp-image-grid__caption {
|
|
118
121
|
overflow: hidden; color: var(--wp-text-muted); font: 400 var(--wp-text-xs)/1.3 var(--wp-font-sans);
|
|
@@ -120,6 +123,5 @@ function toggle(id: string, on: boolean): void {
|
|
|
120
123
|
}
|
|
121
124
|
@media (prefers-reduced-motion: reduce) {
|
|
122
125
|
.wp-image-grid__open { transition: none; }
|
|
123
|
-
.wp-image-grid__empty { animation: none; }
|
|
124
126
|
}
|
|
125
127
|
</style>
|
package/src/Lightbox.vue
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
import { computed, nextTick, ref, watch } from "vue";
|
|
17
17
|
import ModalShell from "./ModalShell.vue";
|
|
18
18
|
import WpImageZoom from "./WpImageZoom.vue";
|
|
19
|
-
import
|
|
19
|
+
import Button from "./Button.vue";
|
|
20
|
+
import Skeleton from "./Skeleton.vue";
|
|
20
21
|
import { focusablesIn } from "./useDialogFocus";
|
|
21
22
|
|
|
22
23
|
export interface LightboxImage { src: string; alt: string; caption?: string | undefined }
|
|
@@ -103,10 +104,12 @@ watch(() => props.open, async (v, was) => {
|
|
|
103
104
|
>
|
|
104
105
|
<div class="wp-lightbox__bar">
|
|
105
106
|
<span v-if="count > 1" class="wp-lightbox__count" aria-hidden="true">{{ at + 1 }} / {{ count }}</span>
|
|
106
|
-
<
|
|
107
|
+
<Button variant="ghost" icon="times" aria-label="Close" class="wp-lightbox__close" kbd="Esc" @click="close" />
|
|
107
108
|
</div>
|
|
108
109
|
<figure v-if="image" class="wp-lightbox__figure" :data-zoom="zoom || undefined">
|
|
109
|
-
|
|
110
|
+
<!-- An image with no src yet (still rendering): its shape, not an empty frame. -->
|
|
111
|
+
<Skeleton v-if="!image.src" class="wp-lightbox__loading" shape="block" width="min(70vw, 640px)" height="min(60vh, 480px)" label="Still loading" />
|
|
112
|
+
<WpImageZoom v-else-if="zoom" v-model:zoom="level" class="wp-lightbox__zoom" :src="image.src" :alt="image.alt" size="md" minimap />
|
|
110
113
|
<img v-else class="wp-lightbox__img" :src="image.src" :alt="image.alt">
|
|
111
114
|
<figcaption v-if="image.caption" class="wp-lightbox__caption">{{ image.caption }}</figcaption>
|
|
112
115
|
</figure>
|
package/src/LoadingMark.vue
CHANGED
|
@@ -21,8 +21,9 @@ let nextId = 0;
|
|
|
21
21
|
* box, for a panel or page that is loading. `mono` draws it in the text
|
|
22
22
|
* colour, for a host with its own colours (a busy button).
|
|
23
23
|
*/
|
|
24
|
-
import { computed, useSlots } from "vue";
|
|
24
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots } from "vue";
|
|
25
25
|
import { BRAND_COLOURS, RING_A, RING_B, STAR_A, STAR_B, STAR_BIG, type LogoBrand } from "./logo-paths";
|
|
26
|
+
import { BOX, PAD, animate, drawNow, type Animated, type Look } from "./loading-mark-motion";
|
|
26
27
|
|
|
27
28
|
const props = withDefaults(defineProps<{
|
|
28
29
|
/** Width and height in px. Default 14, or 40 with `block`. */
|
|
@@ -52,17 +53,98 @@ const VIOLET = computed(() => BRAND_COLOURS[props.brand][0]);
|
|
|
52
53
|
const INDIGO = computed(() => BRAND_COLOURS[props.brand][1]);
|
|
53
54
|
const TEAL = computed(() => BRAND_COLOURS[props.brand][2]);
|
|
54
55
|
const fill = (colour: string): string => (props.mono ? "currentColor" : colour);
|
|
56
|
+
|
|
57
|
+
/* The SVG is the still mark (first paint, reduced motion, a page with no
|
|
58
|
+
* canvas). Once mounted, a canvas over it draws the motion: see
|
|
59
|
+
* `loading-mark-motion.ts` for why it is not CSS. */
|
|
60
|
+
const root = ref<HTMLElement | null>(null);
|
|
61
|
+
const canvas = ref<HTMLCanvasElement | null>(null);
|
|
62
|
+
const live = ref(false);
|
|
63
|
+
const drawn = ref(false);
|
|
64
|
+
const canvasStyle = computed(() => {
|
|
65
|
+
const side = (px.value * BOX) / 1024;
|
|
66
|
+
const off = (px.value * PAD) / 1024;
|
|
67
|
+
return { width: `${side}px`, height: `${side}px`, top: `${-off}px`, left: `${-off}px` };
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
let textColour = "";
|
|
71
|
+
let textColourAt = -Infinity;
|
|
72
|
+
function look(): Look {
|
|
73
|
+
if (props.mono) {
|
|
74
|
+
const now = performance.now();
|
|
75
|
+
if (now - textColourAt > 500 && root.value) {
|
|
76
|
+
textColour = getComputedStyle(root.value).color || "currentColor";
|
|
77
|
+
textColourAt = now;
|
|
78
|
+
}
|
|
79
|
+
return { variant: drawing.value, colours: [textColour, textColour, textColour], flat: true };
|
|
80
|
+
}
|
|
81
|
+
return { variant: drawing.value, colours: [VIOLET.value, INDIGO.value, TEAL.value], flat: false };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let stop: (() => void) | null = null;
|
|
85
|
+
let observer: IntersectionObserver | null = null;
|
|
86
|
+
let motion: MediaQueryList | null = null;
|
|
87
|
+
|
|
88
|
+
async function start(): Promise<void> {
|
|
89
|
+
if (stop) return;
|
|
90
|
+
live.value = true;
|
|
91
|
+
await nextTick();
|
|
92
|
+
const el = canvas.value;
|
|
93
|
+
if (!el || !live.value) return;
|
|
94
|
+
const mark: Animated = { canvas: el, look, clock: 0, visible: true };
|
|
95
|
+
if (!drawNow(mark)) {
|
|
96
|
+
live.value = false;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
drawn.value = true;
|
|
100
|
+
stop = animate(mark);
|
|
101
|
+
if (typeof IntersectionObserver === "function") {
|
|
102
|
+
observer = new IntersectionObserver((entries) => {
|
|
103
|
+
for (const e of entries) mark.visible = e.isIntersecting;
|
|
104
|
+
});
|
|
105
|
+
observer.observe(el);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function halt(): void {
|
|
109
|
+
stop?.();
|
|
110
|
+
stop = null;
|
|
111
|
+
observer?.disconnect();
|
|
112
|
+
observer = null;
|
|
113
|
+
live.value = false;
|
|
114
|
+
drawn.value = false;
|
|
115
|
+
}
|
|
116
|
+
function onMotionChange(): void {
|
|
117
|
+
if (motion?.matches) halt();
|
|
118
|
+
else void start();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
onMounted(() => {
|
|
122
|
+
// No matchMedia means no way to honour reduced motion, and a test DOM
|
|
123
|
+
// (jsdom) has no canvas and logs an error for every attempt: keep the
|
|
124
|
+
// still SVG in both.
|
|
125
|
+
if (typeof window.matchMedia !== "function" || typeof requestAnimationFrame !== "function") return;
|
|
126
|
+
if (navigator.userAgent.includes("jsdom")) return;
|
|
127
|
+
motion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
128
|
+
motion.addEventListener?.("change", onMotionChange);
|
|
129
|
+
if (!motion.matches) void start();
|
|
130
|
+
});
|
|
131
|
+
onBeforeUnmount(() => {
|
|
132
|
+
motion?.removeEventListener?.("change", onMotionChange);
|
|
133
|
+
halt();
|
|
134
|
+
});
|
|
55
135
|
</script>
|
|
56
136
|
|
|
57
137
|
<template>
|
|
58
138
|
<span
|
|
139
|
+
ref="root"
|
|
59
140
|
class="wp-lm"
|
|
60
|
-
:class="{ 'wp-lm--block': block }"
|
|
141
|
+
:class="{ 'wp-lm--block': block, 'wp-lm--drawn': drawn }"
|
|
61
142
|
:role="decorative ? undefined : 'status'"
|
|
62
143
|
:aria-label="decorative || slots.default ? undefined : label"
|
|
63
144
|
:aria-hidden="decorative || undefined"
|
|
64
145
|
data-test="loading-mark"
|
|
65
146
|
>
|
|
147
|
+
<span class="wp-lm__art" :style="{ width: `${px}px`, height: `${px}px` }">
|
|
66
148
|
<svg
|
|
67
149
|
class="wp-lm__svg"
|
|
68
150
|
:data-variant="drawing"
|
|
@@ -96,13 +178,14 @@ const fill = (colour: string): string => (props.mono ? "currentColor" : colour);
|
|
|
96
178
|
</g>
|
|
97
179
|
</g>
|
|
98
180
|
</svg>
|
|
181
|
+
<canvas v-if="live" ref="canvas" class="wp-lm__canvas" :style="canvasStyle" aria-hidden="true" />
|
|
182
|
+
</span>
|
|
99
183
|
<span v-if="slots.default" class="wp-lm__text"><slot /></span>
|
|
100
184
|
</span>
|
|
101
185
|
</template>
|
|
102
186
|
|
|
103
187
|
<style scoped>
|
|
104
188
|
.wp-lm {
|
|
105
|
-
--wp-lm-t: 2.4s;
|
|
106
189
|
display: inline-flex;
|
|
107
190
|
align-items: center;
|
|
108
191
|
gap: 8px;
|
|
@@ -118,57 +201,16 @@ const fill = (colour: string): string => (props.mono ? "currentColor" : colour);
|
|
|
118
201
|
color: var(--wp-text-muted);
|
|
119
202
|
text-align: center;
|
|
120
203
|
}
|
|
121
|
-
.wp-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
* animation starts: the layer is rasterised once, at a fixed scale, and
|
|
126
|
-
* snapped to whole pixels, so the crisp mark turned soft and jumped by up to
|
|
127
|
-
* half a pixel as it began to move. A custom property animates on the main
|
|
128
|
-
* thread, so each frame repaints the vector paths in place, as sharp as the
|
|
129
|
-
* mark at rest. The paths are tiny; the repaint costs nothing. */
|
|
130
|
-
.wp-lm__ring { transform-box: view-box; transform-origin: 512px 512px; }
|
|
131
|
-
.wp-lm__star { transform-box: fill-box; transform-origin: center; }
|
|
132
|
-
.wp-lm__ring--a { transform: translate(var(--wp-lm-x), var(--wp-lm-x)); animation: wp-lm-split-a var(--wp-lm-t) cubic-bezier(0.65, 0, 0.35, 1) infinite; }
|
|
133
|
-
.wp-lm__ring--b { transform: translate(var(--wp-lm-x), var(--wp-lm-x)); animation: wp-lm-split-b var(--wp-lm-t) cubic-bezier(0.65, 0, 0.35, 1) infinite; }
|
|
134
|
-
.wp-lm__star--big { transform: rotate(var(--wp-lm-r)) scale(var(--wp-lm-s)); animation: wp-lm-dip var(--wp-lm-t) cubic-bezier(0.65, 0, 0.35, 1) infinite; }
|
|
135
|
-
.wp-lm__star--tw { opacity: var(--wp-lm-o); transform: scale(var(--wp-lm-s)); animation: wp-lm-twinkle var(--wp-lm-t) ease-in-out infinite; }
|
|
136
|
-
.wp-lm__swap {
|
|
137
|
-
transform-box: view-box; transform-origin: 512px 512px;
|
|
138
|
-
transform: rotate(var(--wp-lm-r));
|
|
139
|
-
animation: wp-lm-swap calc(var(--wp-lm-t) * 2) cubic-bezier(0.65, 0, 0.35, 1) infinite;
|
|
140
|
-
}
|
|
204
|
+
.wp-lm__art { position: relative; display: inline-block; flex-shrink: 0; line-height: 0; }
|
|
205
|
+
.wp-lm__svg { display: block; overflow: visible; }
|
|
206
|
+
.wp-lm__canvas { position: absolute; pointer-events: none; }
|
|
207
|
+
.wp-lm--drawn .wp-lm__svg { visibility: hidden; }
|
|
141
208
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
209
|
+
/* Reduced motion: no canvas, the still mark breathes. A registered custom
|
|
210
|
+
* property, not `opacity` itself, so the fade stays off the compositor (a
|
|
211
|
+
* composited fade turns the mark soft). */
|
|
145
212
|
@property --wp-lm-o { syntax: "<number>"; inherits: false; initial-value: 1; }
|
|
146
|
-
|
|
147
|
-
@keyframes wp-lm-split-a {
|
|
148
|
-
0%, 15%, 85%, 100% { --wp-lm-x: 0px; }
|
|
149
|
-
40%, 60% { --wp-lm-x: -70px; }
|
|
150
|
-
}
|
|
151
|
-
@keyframes wp-lm-split-b {
|
|
152
|
-
0%, 15%, 85%, 100% { --wp-lm-x: 0px; }
|
|
153
|
-
40%, 60% { --wp-lm-x: 70px; }
|
|
154
|
-
}
|
|
155
|
-
@keyframes wp-lm-dip {
|
|
156
|
-
0%, 15% { --wp-lm-s: 1; --wp-lm-r: 0deg; }
|
|
157
|
-
32% { --wp-lm-s: 0.7; --wp-lm-r: 45deg; }
|
|
158
|
-
50%, 100% { --wp-lm-s: 1; --wp-lm-r: 90deg; }
|
|
159
|
-
}
|
|
160
|
-
@keyframes wp-lm-swap {
|
|
161
|
-
0%, 15% { --wp-lm-r: 0deg; }
|
|
162
|
-
50%, 65% { --wp-lm-r: 180deg; }
|
|
163
|
-
100% { --wp-lm-r: 360deg; }
|
|
164
|
-
}
|
|
165
|
-
@keyframes wp-lm-twinkle {
|
|
166
|
-
0%, 100% { --wp-lm-o: 1; --wp-lm-s: 1; }
|
|
167
|
-
50% { --wp-lm-o: 0.35; --wp-lm-s: 0.6; }
|
|
168
|
-
}
|
|
169
|
-
|
|
170
213
|
@media (prefers-reduced-motion: reduce) {
|
|
171
|
-
.wp-lm__svg * { animation: none !important; }
|
|
172
214
|
.wp-lm__svg { opacity: var(--wp-lm-o); animation: wp-lm-fade 2.4s ease-in-out infinite; }
|
|
173
215
|
@keyframes wp-lm-fade { 0%, 100% { --wp-lm-o: 1; } 50% { --wp-lm-o: 0.55; } }
|
|
174
216
|
}
|
package/src/Modal.vue
CHANGED
|
@@ -1,36 +1,58 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Modal: the one dialog, in the manager, on the canvas and on the community
|
|
4
|
+
* site. A titled panel (header with ×, body, optional footer) on ModalShell,
|
|
5
|
+
* so it gets the shared overlay, open/close motion and the key shield that
|
|
6
|
+
* keeps typing away from ComfyUI's shortcuts; useDialogFocus adds the focus
|
|
7
|
+
* trap and focus return.
|
|
8
|
+
*
|
|
9
|
+
* <Modal v-model:open="open" title="Rename module">…<template #footer>…</template></Modal>
|
|
10
|
+
*
|
|
11
|
+
* Escape, the × and the backdrop close it (`close-on-backdrop="false"` keeps
|
|
12
|
+
* a backdrop click from closing a form that would lose work). A custom
|
|
13
|
+
* `#header` puts `:id="titleId"` on its own heading. `keydown` gives every key
|
|
14
|
+
* pressed inside (the shield stops them before `window`), so a dialog's own
|
|
15
|
+
* keys go there, not on a window listener. ConfirmModal is built on it.
|
|
16
|
+
*
|
|
17
|
+
* The panel styles are scoped and read only shared tokens, so it wears the
|
|
18
|
+
* colours of whichever side it is on.
|
|
19
|
+
*/
|
|
20
|
+
import { ref, useId, useSlots } from "vue";
|
|
21
|
+
import Button from "./Button.vue";
|
|
22
|
+
import ModalShell from "./ModalShell.vue";
|
|
4
23
|
import { useDialogFocus } from "./useDialogFocus";
|
|
5
24
|
|
|
6
|
-
|
|
25
|
+
const props = withDefaults(defineProps<{
|
|
7
26
|
open: boolean;
|
|
8
27
|
title?: string | undefined;
|
|
9
28
|
size?: "sm" | "md" | "lg" | undefined;
|
|
10
29
|
closeOnBackdrop?: boolean | undefined;
|
|
11
|
-
}
|
|
12
|
-
const props = withDefaults(defineProps<Props>(), { size: "md", closeOnBackdrop: true });
|
|
30
|
+
}>(), { size: "md", closeOnBackdrop: true, title: undefined });
|
|
13
31
|
|
|
14
32
|
const emit = defineEmits<{
|
|
15
33
|
(e: "update:open", v: boolean): void;
|
|
34
|
+
/** Every keydown inside the dialog. */
|
|
35
|
+
(e: "keydown", ev: KeyboardEvent): void;
|
|
16
36
|
}>();
|
|
17
37
|
|
|
18
38
|
const slots = useSlots();
|
|
19
|
-
|
|
20
|
-
const modalClasses = computed(() => ["wp-modal", `wp-modal--${props.size}`]);
|
|
21
39
|
const dialog = ref<HTMLElement | null>(null);
|
|
22
40
|
const titleId = `wp-modal-title-${useId()}`;
|
|
23
41
|
|
|
24
|
-
function close() {
|
|
42
|
+
function close(): void {
|
|
25
43
|
emit("update:open", false);
|
|
26
44
|
}
|
|
27
45
|
|
|
28
|
-
function
|
|
29
|
-
if (props.closeOnBackdrop)
|
|
46
|
+
function onShellClose(reason: "escape" | "backdrop"): void {
|
|
47
|
+
if (reason === "backdrop" && !props.closeOnBackdrop) return;
|
|
48
|
+
close();
|
|
30
49
|
}
|
|
31
50
|
|
|
32
|
-
useDialogFocus(dialog, () => props.open, {
|
|
33
|
-
|
|
51
|
+
const focus = useDialogFocus(dialog, () => props.open, {
|
|
52
|
+
// ModalShell closes on Escape; the shield stops keys before window, so the
|
|
53
|
+
// trap runs from the shell's keydown (below), not a window listener.
|
|
54
|
+
onEscape: () => undefined,
|
|
55
|
+
window: false,
|
|
34
56
|
// The first control in the body, else the dialog itself (the close button
|
|
35
57
|
// would make Enter close it).
|
|
36
58
|
initial: (root, els) => {
|
|
@@ -38,41 +60,84 @@ useDialogFocus(dialog, () => props.open, {
|
|
|
38
60
|
return els.find((el) => body?.contains(el)) ?? root;
|
|
39
61
|
},
|
|
40
62
|
});
|
|
63
|
+
|
|
64
|
+
function onKeydown(ev: KeyboardEvent): void {
|
|
65
|
+
focus.onKeydown(ev);
|
|
66
|
+
emit("keydown", ev);
|
|
67
|
+
}
|
|
41
68
|
</script>
|
|
42
69
|
|
|
43
70
|
<template>
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
tabindex="-1"
|
|
60
|
-
@mousedown.stop
|
|
61
|
-
>
|
|
62
|
-
<header v-if="title || slots.header" class="wp-modal__head">
|
|
63
|
-
<h3 v-if="title" :id="titleId" class="wp-modal__title">{{ title }}</h3>
|
|
64
|
-
<!-- A custom header puts :id="titleId" on its own heading. -->
|
|
65
|
-
<slot name="header" :title-id="titleId" />
|
|
66
|
-
<span class="wp-spacer" />
|
|
67
|
-
<CloseButton kbd="Esc" @click="close" />
|
|
68
|
-
</header>
|
|
69
|
-
<div class="wp-modal__body">
|
|
70
|
-
<slot />
|
|
71
|
-
</div>
|
|
72
|
-
<footer v-if="slots.footer" class="wp-modal__foot">
|
|
73
|
-
<slot name="footer" />
|
|
74
|
-
</footer>
|
|
71
|
+
<ModalShell
|
|
72
|
+
:visible="open"
|
|
73
|
+
:labelledby="title || slots.header ? titleId : undefined"
|
|
74
|
+
@close="onShellClose"
|
|
75
|
+
@keydown="onKeydown"
|
|
76
|
+
>
|
|
77
|
+
<div ref="dialog" class="wp-modal" :class="`wp-modal--${size}`" tabindex="-1" data-test="modal">
|
|
78
|
+
<header v-if="title || slots.header" class="wp-modal__head">
|
|
79
|
+
<h3 v-if="title" :id="titleId" class="wp-modal__title">{{ title }}</h3>
|
|
80
|
+
<slot name="header" :title-id="titleId" />
|
|
81
|
+
<span class="wp-modal__spacer" />
|
|
82
|
+
<Button variant="ghost" icon="times" aria-label="Close" kbd="Esc" data-test="modal-close" @click="close" />
|
|
83
|
+
</header>
|
|
84
|
+
<div class="wp-modal__body">
|
|
85
|
+
<slot />
|
|
75
86
|
</div>
|
|
87
|
+
<footer v-if="slots.footer" class="wp-modal__foot">
|
|
88
|
+
<slot name="footer" />
|
|
89
|
+
</footer>
|
|
76
90
|
</div>
|
|
77
|
-
</
|
|
91
|
+
</ModalShell>
|
|
78
92
|
</template>
|
|
93
|
+
|
|
94
|
+
<style scoped>
|
|
95
|
+
.wp-modal {
|
|
96
|
+
display: flex;
|
|
97
|
+
flex-direction: column;
|
|
98
|
+
width: 100%;
|
|
99
|
+
max-width: 560px;
|
|
100
|
+
max-height: calc(100vh - 48px);
|
|
101
|
+
overflow: hidden;
|
|
102
|
+
background: var(--wp-bg-2);
|
|
103
|
+
border: 1px solid var(--wp-border);
|
|
104
|
+
border-radius: var(--wp-radius-lg);
|
|
105
|
+
box-shadow: var(--wp-shadow-xl);
|
|
106
|
+
color: var(--wp-text);
|
|
107
|
+
font-family: var(--wp-font-sans);
|
|
108
|
+
font-size: var(--wp-text-base);
|
|
109
|
+
}
|
|
110
|
+
/* The dialog takes focus only when nothing in it can. */
|
|
111
|
+
.wp-modal:focus { outline: none; }
|
|
112
|
+
.wp-modal--sm { max-width: 380px; }
|
|
113
|
+
.wp-modal--md { max-width: 560px; }
|
|
114
|
+
.wp-modal--lg { max-width: 880px; }
|
|
115
|
+
.wp-modal__head {
|
|
116
|
+
display: flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
gap: var(--wp-space-4);
|
|
119
|
+
padding: var(--wp-space-4) var(--wp-space-5);
|
|
120
|
+
border-bottom: 1px solid var(--wp-border);
|
|
121
|
+
background: var(--wp-bg);
|
|
122
|
+
}
|
|
123
|
+
.wp-modal__title { flex: 1; margin: 0; font-size: var(--wp-text-md); font-weight: 600; color: var(--wp-text); }
|
|
124
|
+
.wp-modal__spacer { flex: 1; }
|
|
125
|
+
.wp-modal__title + .wp-modal__spacer { display: none; }
|
|
126
|
+
.wp-modal__body {
|
|
127
|
+
flex: 1;
|
|
128
|
+
min-height: 0;
|
|
129
|
+
overflow: auto;
|
|
130
|
+
padding: var(--wp-space-5);
|
|
131
|
+
}
|
|
132
|
+
.wp-modal__foot {
|
|
133
|
+
display: flex;
|
|
134
|
+
justify-content: flex-end;
|
|
135
|
+
gap: var(--wp-space-3);
|
|
136
|
+
padding: var(--wp-space-4) var(--wp-space-5);
|
|
137
|
+
border-top: 1px solid var(--wp-border);
|
|
138
|
+
background: var(--wp-bg);
|
|
139
|
+
}
|
|
140
|
+
@media (max-width: 640px) {
|
|
141
|
+
.wp-modal__head, .wp-modal__body, .wp-modal__foot { padding-left: var(--wp-space-5); padding-right: var(--wp-space-5); }
|
|
142
|
+
}
|
|
143
|
+
</style>
|
package/src/ModalShell.vue
CHANGED
|
@@ -3,14 +3,21 @@
|
|
|
3
3
|
<Transition name="wp-modal" appear>
|
|
4
4
|
<!-- The overlay is a click target only; keyboard users close with Escape. -->
|
|
5
5
|
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/no-static-element-interactions -->
|
|
6
|
-
<div
|
|
6
|
+
<div
|
|
7
|
+
v-if="visible"
|
|
8
|
+
ref="overlayEl"
|
|
9
|
+
class="wp-modal-overlay"
|
|
10
|
+
data-test="modal-backdrop"
|
|
11
|
+
@mousedown="downOnBackdrop = isBackdrop($event)"
|
|
12
|
+
@click="onBackdropClick"
|
|
13
|
+
>
|
|
7
14
|
<div
|
|
15
|
+
ref="wrapperEl"
|
|
8
16
|
class="wp-modal-wrapper"
|
|
9
17
|
role="dialog"
|
|
10
18
|
aria-modal="true"
|
|
11
19
|
:aria-label="label"
|
|
12
20
|
:aria-labelledby="labelledby"
|
|
13
|
-
@click.stop
|
|
14
21
|
>
|
|
15
22
|
<slot></slot>
|
|
16
23
|
</div>
|
|
@@ -31,7 +38,8 @@ const props = defineProps<{
|
|
|
31
38
|
labelledby?: string | undefined;
|
|
32
39
|
}>();
|
|
33
40
|
const emit = defineEmits<{
|
|
34
|
-
|
|
41
|
+
/** `escape` or `backdrop`, so a dialog can ignore backdrop clicks. */
|
|
42
|
+
(e: "close", reason: "escape" | "backdrop"): void;
|
|
35
43
|
/** Every keydown inside the modal. Consumers MUST use this instead of a
|
|
36
44
|
* window listener: the shield stops inside-modal keys at the overlay, so a
|
|
37
45
|
* window listener would never see them. */
|
|
@@ -39,11 +47,24 @@ const emit = defineEmits<{
|
|
|
39
47
|
}>();
|
|
40
48
|
|
|
41
49
|
const overlayEl = ref<HTMLElement | null>(null);
|
|
50
|
+
const wrapperEl = ref<HTMLElement | null>(null);
|
|
51
|
+
|
|
52
|
+
/** The overlay or the wrapper around the panel, never the panel's own content. */
|
|
53
|
+
function isBackdrop(ev: MouseEvent): boolean {
|
|
54
|
+
return ev.target === overlayEl.value || ev.target === wrapperEl.value;
|
|
55
|
+
}
|
|
56
|
+
/** Only a press that also STARTED on the backdrop closes: a text selection
|
|
57
|
+
* dragged out of the dialog ends with a click on the overlay. */
|
|
58
|
+
const downOnBackdrop = ref(false);
|
|
59
|
+
function onBackdropClick(ev: MouseEvent): void {
|
|
60
|
+
if (downOnBackdrop.value && isBackdrop(ev)) emit("close", "backdrop");
|
|
61
|
+
downOnBackdrop.value = false;
|
|
62
|
+
}
|
|
42
63
|
|
|
43
64
|
function closeOnEscape(event: KeyboardEvent): void {
|
|
44
65
|
if (!props.visible || event.key !== "Escape") return;
|
|
45
66
|
event.preventDefault();
|
|
46
|
-
emit("close");
|
|
67
|
+
emit("close", "escape");
|
|
47
68
|
}
|
|
48
69
|
|
|
49
70
|
// Keys pressed INSIDE the modal are handled here and stop at the overlay, so
|
|
@@ -104,6 +125,6 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
|
|
|
104
125
|
|
|
105
126
|
/* `wp-modal` open/close transition lives in
|
|
106
127
|
* src/components/shared/_modal-motion.css (imported globally via
|
|
107
|
-
* ContextWidget) so every modal — ModalShell consumers,
|
|
128
|
+
* ContextWidget) so every modal — ModalShell consumers (Modal, ConfirmModal),
|
|
108
129
|
* InjectorBindingModal — animates with the same rules. */
|
|
109
130
|
</style>
|
package/src/Pagination.vue
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { computed } from "vue";
|
|
15
15
|
import { pageItems } from "./pagination";
|
|
16
|
-
import
|
|
16
|
+
import Button from "./Button.vue";
|
|
17
17
|
|
|
18
18
|
const props = withDefaults(defineProps<{
|
|
19
19
|
/** pages: numbered pages. more: a Load more button. */
|
|
@@ -54,7 +54,7 @@ const btnSize = computed(() => (props.size === "sm" ? "sm" : "md"));
|
|
|
54
54
|
<span class="wp-paging__range" aria-live="polite">{{ from }}–{{ to }} of {{ total }}</span>
|
|
55
55
|
<ul class="wp-paging__list">
|
|
56
56
|
<li>
|
|
57
|
-
<
|
|
57
|
+
<Button variant="ghost" :size="btnSize" icon="pi-chevron-left" aria-label="Previous page" :disabled="disabled || props.page <= 1" @click="go(props.page - 1)" />
|
|
58
58
|
</li>
|
|
59
59
|
<li v-for="it in items" :key="it">
|
|
60
60
|
<span v-if="typeof it !== 'number'" class="wp-paging__gap" aria-hidden="true">…</span>
|
|
@@ -70,13 +70,13 @@ const btnSize = computed(() => (props.size === "sm" ? "sm" : "md"));
|
|
|
70
70
|
>{{ it }}</button>
|
|
71
71
|
</li>
|
|
72
72
|
<li>
|
|
73
|
-
<
|
|
73
|
+
<Button variant="ghost" :size="btnSize" icon="pi-chevron-right" aria-label="Next page" :disabled="disabled || props.page >= pages" @click="go(props.page + 1)" />
|
|
74
74
|
</li>
|
|
75
75
|
</ul>
|
|
76
76
|
</nav>
|
|
77
77
|
<div v-else-if="mode === 'more' && total > 0" class="wp-paging wp-paging--more" :data-size="size">
|
|
78
78
|
<span class="wp-paging__range" aria-live="polite">Showing {{ Math.min(props.shown, total) }} of {{ total }}{{ noun ? ` ${noun}` : "" }}</span>
|
|
79
|
-
<
|
|
79
|
+
<Button v-if="props.shown < total" variant="secondary" :size="btnSize" :loading="loading" :disabled="disabled" @click="emit('more')">Load more</Button>
|
|
80
80
|
</div>
|
|
81
81
|
</template>
|
|
82
82
|
|