@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +5 -0
  3. package/package.json +4 -3
  4. package/src/Avatar.vue +1 -1
  5. package/src/Banner.vue +4 -4
  6. package/src/Button.vue +73 -44
  7. package/src/Checkbox.vue +201 -63
  8. package/src/Chip.vue +5 -2
  9. package/src/CodeBlock.vue +33 -6
  10. package/src/ColorField.vue +60 -6
  11. package/src/Combobox.vue +10 -14
  12. package/src/ConfirmModal.vue +12 -22
  13. package/src/ContextMenu.vue +11 -30
  14. package/src/CopyButton.vue +1 -1
  15. package/src/DataTable.vue +3 -4
  16. package/src/DatePicker.vue +1 -1
  17. package/src/Disclosure.vue +2 -1
  18. package/src/Drawer.vue +2 -2
  19. package/src/DropZone.vue +2 -2
  20. package/src/HsvPicker.vue +2 -2
  21. package/src/IconPicker.vue +1 -1
  22. package/src/ImageGrid.vue +17 -15
  23. package/src/Lightbox.vue +6 -3
  24. package/src/LoadingMark.vue +93 -51
  25. package/src/Modal.vue +109 -44
  26. package/src/ModalShell.vue +26 -5
  27. package/src/Pagination.vue +4 -4
  28. package/src/Popover.vue +68 -12
  29. package/src/RadioGroup.vue +5 -4
  30. package/src/SearchInput.vue +1 -1
  31. package/src/Select.vue +53 -158
  32. package/src/Skeleton.vue +21 -5
  33. package/src/Slider.vue +166 -55
  34. package/src/SortableList.vue +1 -1
  35. package/src/TagInput.vue +1 -1
  36. package/src/Timeline.vue +2 -2
  37. package/src/WorkingLine.vue +320 -0
  38. package/src/WpIconButton.vue +3 -2
  39. package/src/WpImageZoom.vue +7 -7
  40. package/src/WpSegmented.vue +3 -1
  41. package/src/WpSwitch.vue +1 -1
  42. package/src/WpTabs.vue +1 -1
  43. package/src/_modal-motion.css +1 -1
  44. package/src/button.css +8 -6
  45. package/src/floating.ts +5 -0
  46. package/src/highlight.ts +199 -0
  47. package/src/hsv.ts +1 -1
  48. package/src/loading-mark-motion.ts +249 -0
  49. package/src/menu.css +51 -0
  50. package/src/motion.css +73 -0
  51. package/src/slider-math.ts +45 -1
  52. package/src/theme/looks.css +6 -19
  53. package/src/theme/tokens.css +12 -1108
  54. package/src/tokens.ts +8 -3
  55. package/src/tooltip.css +8 -5
  56. package/src/useDialogFocus.ts +11 -2
  57. package/src/CloseButton.vue +0 -69
  58. package/src/ColorPicker.vue +0 -201
  59. package/src/ConfirmDialog.vue +0 -165
  60. package/src/RangeSlider.vue +0 -183
  61. package/src/Toggle.vue +0 -36
  62. package/src/WpButton.vue +0 -75
  63. package/src/WpCheck.vue +0 -104
  64. 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); a tile with no
12
- * `src` draws a Skeleton-like block while it loads.
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 WpCheck from "./WpCheck.vue";
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
- const shots = computed(() => props.images.map((i) => ({ src: i.src, alt: i.alt, caption: i.caption })));
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
- <span v-else class="wp-image-grid__empty" aria-hidden="true" />
83
+ <Skeleton v-else class="wp-image-grid__empty" shape="block" width="100%" height="100%" />
77
84
  </button>
78
- <WpCheck
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-grid__empty {
111
- display: block; width: 100%; height: 100%;
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 CloseButton from "./CloseButton.vue";
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
- <CloseButton class="wp-lightbox__close" kbd="Esc" @click="close" />
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
- <WpImageZoom v-if="zoom" v-model:zoom="level" class="wp-lightbox__zoom" :src="image.src" :alt="image.alt" size="md" minimap />
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>
@@ -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-lm__svg { flex-shrink: 0; overflow: visible; }
122
- /* Every move animates a registered custom property, never `transform` or
123
- * `opacity` itself. A browser hands a `transform` / `opacity` animation to
124
- * the compositor, which lifts the path onto its own layer the moment the
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
- @property --wp-lm-x { syntax: "<length>"; inherits: false; initial-value: 0px; }
143
- @property --wp-lm-r { syntax: "<angle>"; inherits: false; initial-value: 0deg; }
144
- @property --wp-lm-s { syntax: "<number>"; inherits: false; initial-value: 1; }
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
- import CloseButton from "./CloseButton.vue";
3
- import { computed, ref, useId, useSlots } from "vue";
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
- interface Props {
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 onBackdrop() {
29
- if (props.closeOnBackdrop) close();
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
- onEscape: close,
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
- <Teleport to="body">
45
- <!-- The backdrop is a click target only; keyboard users close with Escape. -->
46
- <!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->
47
- <div
48
- v-if="open"
49
- class="wp-modal__backdrop"
50
- data-test="modal-backdrop"
51
- @mousedown.self="onBackdrop"
52
- >
53
- <div
54
- ref="dialog"
55
- :class="modalClasses"
56
- role="dialog"
57
- aria-modal="true"
58
- :aria-labelledby="title || slots.header ? titleId : undefined"
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
- </Teleport>
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>
@@ -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 v-if="visible" ref="overlayEl" class="wp-modal-overlay" @click="$emit('close')">
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
- (e: "close"): void;
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, ConfirmDialog,
128
+ * ContextWidget) so every modal — ModalShell consumers (Modal, ConfirmModal),
108
129
  * InjectorBindingModal — animates with the same rules. */
109
130
  </style>
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { computed } from "vue";
15
15
  import { pageItems } from "./pagination";
16
- import WpButton from "./WpButton.vue";
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
- <WpButton variant="ghost" :size="btnSize" icon="pi-chevron-left" aria-label="Previous page" :disabled="disabled || props.page <= 1" @click="go(props.page - 1)" />
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
- <WpButton variant="ghost" :size="btnSize" icon="pi-chevron-right" aria-label="Next page" :disabled="disabled || props.page >= pages" @click="go(props.page + 1)" />
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
- <WpButton v-if="props.shown < total" variant="secondary" :size="btnSize" :loading="loading" :disabled="disabled" @click="emit('more')">Load more</WpButton>
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