@dumiflex/wp-kit 0.5.0 → 0.7.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 (89) 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/BundlePreview.vue +254 -0
  7. package/src/Button.vue +73 -44
  8. package/src/Checkbox.vue +201 -63
  9. package/src/Chip.vue +5 -2
  10. package/src/CodeBlock.vue +1 -1
  11. package/src/ColorField.vue +60 -6
  12. package/src/CombinePreview.vue +150 -0
  13. package/src/Combobox.vue +10 -14
  14. package/src/ConfirmModal.vue +12 -22
  15. package/src/ConstraintMatrixPreview.vue +6 -22
  16. package/src/ConstraintPreview.vue +259 -0
  17. package/src/ContextMenu.vue +11 -30
  18. package/src/CopyButton.vue +1 -1
  19. package/src/DataTable.vue +0 -2
  20. package/src/DatePicker.vue +1 -1
  21. package/src/DerivationCondition.vue +68 -0
  22. package/src/DerivationPreview.vue +345 -0
  23. package/src/Disclosure.vue +2 -1
  24. package/src/Drawer.vue +2 -2
  25. package/src/DropZone.vue +2 -2
  26. package/src/FixedValuesPreview.vue +184 -0
  27. package/src/HsvPicker.vue +2 -2
  28. package/src/IconPicker.vue +32 -103
  29. package/src/IconPickerPanel.vue +132 -0
  30. package/src/ImageGrid.vue +2 -2
  31. package/src/Lightbox.vue +2 -2
  32. package/src/LoadingMark.vue +6 -61
  33. package/src/Modal.vue +109 -44
  34. package/src/ModalShell.vue +26 -5
  35. package/src/Pagination.vue +4 -4
  36. package/src/PayloadPreview.vue +154 -0
  37. package/src/Popover.vue +68 -12
  38. package/src/PostDetailSkeleton.vue +65 -0
  39. package/src/PostGridSkeleton.vue +61 -0
  40. package/src/PreviewGroup.vue +75 -0
  41. package/src/ProfileSkeleton.vue +73 -0
  42. package/src/RadioGroup.vue +5 -4
  43. package/src/RichText.vue +61 -0
  44. package/src/SearchInput.vue +1 -1
  45. package/src/Select.vue +53 -158
  46. package/src/SelectionBar.vue +90 -0
  47. package/src/SettingGroup.vue +57 -0
  48. package/src/SettingRow.vue +127 -0
  49. package/src/Skeleton.vue +1 -1
  50. package/src/Slider.vue +166 -55
  51. package/src/SortableList.vue +1 -1
  52. package/src/TagInput.vue +1 -1
  53. package/src/Timeline.vue +2 -2
  54. package/src/WildcardPreview.vue +607 -0
  55. package/src/WorkingLine.vue +234 -0
  56. package/src/WpIconButton.vue +3 -2
  57. package/src/WpImageZoom.vue +7 -7
  58. package/src/WpSegmented.vue +3 -1
  59. package/src/WpSwitch.vue +1 -1
  60. package/src/WpTabs.vue +1 -1
  61. package/src/_modal-motion.css +1 -1
  62. package/src/button.css +8 -6
  63. package/src/canvas-motion.ts +251 -0
  64. package/src/floating.ts +5 -0
  65. package/src/hsv.ts +1 -1
  66. package/src/kind-colour.ts +24 -1
  67. package/src/linkify.ts +142 -0
  68. package/src/loading-mark-motion.ts +12 -155
  69. package/src/matrix-axis.ts +28 -0
  70. package/src/menu.css +51 -0
  71. package/src/motion.css +73 -0
  72. package/src/pi-icons.ts +51 -0
  73. package/src/preview-readers.ts +401 -0
  74. package/src/preview.css +194 -0
  75. package/src/slider-math.ts +45 -1
  76. package/src/theme/looks.css +6 -19
  77. package/src/theme/tokens.css +12 -1108
  78. package/src/tokens.ts +15 -4
  79. package/src/tooltip.css +8 -5
  80. package/src/useDialogFocus.ts +11 -2
  81. package/src/working-line-motion.ts +135 -0
  82. package/src/CloseButton.vue +0 -69
  83. package/src/ColorPicker.vue +0 -201
  84. package/src/ConfirmDialog.vue +0 -165
  85. package/src/RangeSlider.vue +0 -183
  86. package/src/Toggle.vue +0 -36
  87. package/src/WpButton.vue +0 -75
  88. package/src/WpCheck.vue +0 -104
  89. package/src/_skeleton.css +0 -50
@@ -1,21 +1,27 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
- * IconPicker — pick one PrimeIcons glyph, or none.
3
+ * IconPicker: pick one PrimeIcons glyph, or none.
4
4
  *
5
- * The `icon` column has existed on categories end-to-end (engine column,
6
- * `wp_api/categories.py` `_UPDATABLE_FIELDS`, `CategoryRow.icon`) with no UI
7
- * to set it, so every category rendered with the same generic bookmark.
5
+ * <IconPicker v-model="cat.icon" /> curated groups in a popover
6
+ * <IconPicker v-model="note.icon" all :color="tint" /> any glyph, with a search box
7
+ * <IconPicker :model-value="null" all inline :allow-none="false" @update:model-value="insert" />
8
8
  *
9
- * The offered glyphs live in `./icon-catalog`, which is verified against the
10
- * installed `primeicons.css` by a test — an unknown name renders as an empty
11
- * cell rather than failing, so nothing else would catch a typo.
9
+ * The closed picker shows the glyph and its name (`compact`: the glyph
10
+ * alone, for a table cell). The list opens in the kit Popover, so it follows
11
+ * the trigger on scroll, flips to stay on screen and closes on Escape or a
12
+ * click outside with focus back on the trigger. `inline` draws the list
13
+ * itself, with no trigger (a panel in an editor). Without `all` the list is
14
+ * the curated groups in `icon-catalog.ts`; with `all` it is every glyph
15
+ * (`pi-icons.ts`, both checked against the installed `primeicons.css` by a
16
+ * test), searchable by name. `color` tints the glyphs; `allow-none` (on by
17
+ * default) offers "No icon", which sets null.
12
18
  *
13
- * The glyph name is stored WITHOUT the `pi pi-` prefix (e.g. `"user"`), which
14
- * is what the engine column already holds and what `Icon.vue` expects.
19
+ * The name is stored WITHOUT the `pi pi-` prefix (`"user"`), which is what
20
+ * Icon.vue expects.
15
21
  */
16
22
  import { computed, ref } from "vue";
23
+ import IconPickerPanel from "./IconPickerPanel.vue";
17
24
  import Popover from "./Popover.vue";
18
- import { ICON_GROUPS } from "./icon-catalog";
19
25
 
20
26
  const props = withDefaults(
21
27
  defineProps<{
@@ -25,8 +31,16 @@ const props = withDefaults(
25
31
  dataTest?: string | undefined;
26
32
  /** Icon only, no name beside it, for a tight table cell. */
27
33
  compact?: boolean | undefined;
34
+ /** Every PrimeIcons glyph, with a search box, instead of the curated groups. */
35
+ all?: boolean | undefined;
36
+ /** The list itself, no trigger or popover. */
37
+ inline?: boolean | undefined;
38
+ /** Tint the glyphs. */
39
+ color?: string | undefined;
40
+ /** Offer "No icon" (null). */
41
+ allowNone?: boolean | undefined;
28
42
  }>(),
29
- { ariaLabel: "Pick an icon", dataTest: undefined, compact: false },
43
+ { ariaLabel: "Pick an icon", dataTest: undefined, compact: false, all: false, inline: false, color: undefined, allowNone: true },
30
44
  );
31
45
 
32
46
  const emit = defineEmits<{ "update:modelValue": [v: string | null] }>();
@@ -49,10 +63,10 @@ function pick(name: string | null): void {
49
63
  </script>
50
64
 
51
65
  <template>
52
- <!-- On the kit Popover: it follows the trigger on scroll, flips to stay on
53
- screen, and closes on Escape or a click outside with focus back on
54
- the trigger. -->
55
- <Popover v-model:open="open" :label="ariaLabel" role="listbox">
66
+ <div v-if="inline" class="wp-iconpick wp-iconpick--inline" :data-test="dataTest">
67
+ <IconPickerPanel :current="current" :all="all" :color="color" :allow-none="allowNone" @pick="pick" />
68
+ </div>
69
+ <Popover v-else v-model:open="open" :label="ariaLabel" role="listbox">
56
70
  <template #trigger="{ props: triggerProps }">
57
71
  <button
58
72
  v-bind="triggerProps"
@@ -66,7 +80,7 @@ function pick(name: string | null): void {
66
80
  different intrinsic widths and baselines, so without it the trigger
67
81
  resized and the mark shifted the moment an icon was picked. -->
68
82
  <span class="wp-iconpick__slot" aria-hidden="true">
69
- <i v-if="current" :class="`pi pi-${current}`" />
83
+ <i v-if="current" :class="`pi pi-${current}`" :style="color ? { color } : undefined" />
70
84
  <span v-else class="wp-iconpick__none">—</span>
71
85
  </span>
72
86
  <span
@@ -78,32 +92,7 @@ function pick(name: string | null): void {
78
92
  <i class="pi pi-chevron-down wp-iconpick__caret" aria-hidden="true" />
79
93
  </button>
80
94
  </template>
81
- <div class="wp-iconpick__panel" :data-test="dataTest ? `${dataTest}-panel` : undefined">
82
- <button
83
- type="button"
84
- class="wp-iconpick__clear"
85
- data-test="icon-picker-none"
86
- @click="pick(null)"
87
- >No icon</button>
88
- <div v-for="group in ICON_GROUPS" :key="group.label" class="wp-iconpick__group">
89
- <div class="wp-iconpick__group-label">{{ group.label }}</div>
90
- <div class="wp-iconpick__grid">
91
- <button
92
- v-for="name in group.icons"
93
- :key="name"
94
- type="button"
95
- class="wp-iconpick__cell"
96
- :class="{ 'wp-iconpick__cell--on': name === current }"
97
- role="option"
98
- :aria-selected="name === current"
99
- :title="name"
100
- :aria-label="name"
101
- :data-test="`icon-picker-${name}`"
102
- @click="pick(name)"
103
- ><i :class="`pi pi-${name}`" aria-hidden="true" /></button>
104
- </div>
105
- </div>
106
- </div>
95
+ <IconPickerPanel :current="current" :all="all" :color="color" :allow-none="allowNone" :data-test="dataTest" @pick="pick" />
107
96
  </Popover>
108
97
  </template>
109
98
 
@@ -144,66 +133,6 @@ function pick(name: string | null): void {
144
133
  line-height: 1;
145
134
  }
146
135
  .wp-iconpick__none { color: var(--wp-text-dim); line-height: 1; }
147
- .wp-iconpick__caret { font-size: 9px; color: var(--wp-text-dim); line-height: 1; }
136
+ .wp-iconpick__caret { font-size: 10px; color: var(--wp-text-dim); line-height: 1; }
148
137
  </style>
149
138
 
150
- <style>
151
- /* Unscoped: the panel is teleported to <body> by Popover, so a scoped selector's
152
- `[data-v-…]` attribute would never match it. Class names are
153
- component-specific (`wp-iconpick*`), so global is safe. */
154
- /* Lives inside the kit Popover's panel, which gives the frame and shadow. */
155
- .wp-iconpick__panel {
156
- width: 212px;
157
- max-height: 276px;
158
- overflow-y: auto;
159
- }
160
- .wp-iconpick__clear {
161
- width: 100%;
162
- margin-bottom: var(--wp-space-3);
163
- padding: var(--wp-space-2);
164
- background: transparent;
165
- border: 1px dashed var(--wp-border);
166
- border-radius: var(--wp-radius-sm);
167
- color: var(--wp-text-muted);
168
- font-size: var(--wp-text-xs);
169
- cursor: pointer;
170
- }
171
- .wp-iconpick__clear:hover { color: var(--wp-text); border-color: var(--wp-accent); }
172
- .wp-iconpick__group + .wp-iconpick__group { margin-top: var(--wp-space-3); }
173
- .wp-iconpick__group-label {
174
- font-size: 9.5px;
175
- font-weight: 700;
176
- letter-spacing: 0.08em;
177
- text-transform: uppercase;
178
- color: var(--wp-text-dim);
179
- margin-bottom: var(--wp-space-2);
180
- }
181
- .wp-iconpick__grid {
182
- display: grid;
183
- grid-template-columns: repeat(8, 1fr);
184
- gap: 2px;
185
- }
186
- .wp-iconpick__cell {
187
- display: flex;
188
- align-items: center;
189
- justify-content: center;
190
- aspect-ratio: 1;
191
- background: transparent;
192
- border: 1px solid transparent;
193
- border-radius: var(--wp-radius-sm);
194
- color: var(--wp-text-muted);
195
- cursor: pointer;
196
- font-size: 13px;
197
- }
198
- /* Kill the icon font's own line box — it otherwise pushes the glyph off the
199
- * cell's vertical centre even though the cell is a centred flex container. */
200
- .wp-iconpick__cell i { line-height: 1; }
201
- .wp-iconpick__cell:hover {
202
- background: color-mix(in oklab, var(--wp-accent) 18%, transparent);
203
- color: var(--wp-text);
204
- }
205
- .wp-iconpick__cell--on {
206
- border-color: var(--wp-accent);
207
- color: var(--wp-accent);
208
- }
209
- </style>
@@ -0,0 +1,132 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The grid inside IconPicker (in its Popover, or on its own with `inline`).
4
+ * Internal: use IconPicker. Without `all` it offers the curated groups in
5
+ * `icon-catalog.ts`; with `all`, every PrimeIcons glyph (`pi-icons.ts`) and a
6
+ * search box. Emits the picked name without `pi-`, or null for No icon.
7
+ */
8
+ import { computed, ref } from "vue";
9
+ import SearchInput from "./SearchInput.vue";
10
+ import { vTooltip } from "./tooltip";
11
+ import { ICON_GROUPS } from "./icon-catalog";
12
+ import { PI_ICONS } from "./pi-icons";
13
+
14
+ const props = withDefaults(defineProps<{
15
+ current: string | null;
16
+ all?: boolean | undefined;
17
+ /** Tint the glyphs (a note's colour). */
18
+ color?: string | undefined;
19
+ allowNone?: boolean | undefined;
20
+ dataTest?: string | undefined;
21
+ }>(), { all: false, color: undefined, allowNone: true, dataTest: undefined });
22
+
23
+ const emit = defineEmits<{ pick: [name: string | null] }>();
24
+
25
+ const query = ref("");
26
+ const words = (name: string): string => name.replace(/-/g, " ");
27
+ /** "arrow right" and "pi-arrow-right" both find arrow-right. */
28
+ const found = computed(() => {
29
+ const q = query.value.trim().toLowerCase().replace(/^pi-/, "").replace(/\s+/g, "-");
30
+ return q ? PI_ICONS.filter((n) => n.includes(q)) : PI_ICONS;
31
+ });
32
+ const groups = computed(() => (props.all ? [{ label: "", icons: found.value }] : ICON_GROUPS));
33
+ const tint = computed(() => (props.color ? { color: props.color } : undefined));
34
+ </script>
35
+
36
+ <template>
37
+ <div class="wp-iconpick__panel" :data-all="all || undefined" :data-test="dataTest ? `${dataTest}-panel` : undefined">
38
+ <SearchInput
39
+ v-if="all"
40
+ v-model="query"
41
+ size="sm"
42
+ placeholder="Search icons…"
43
+ aria-label="Search icons"
44
+ test-prefix="icon-picker"
45
+ class="wp-iconpick__search"
46
+ />
47
+ <button
48
+ v-if="allowNone"
49
+ type="button"
50
+ class="wp-iconpick__clear"
51
+ :aria-pressed="!current"
52
+ data-test="icon-picker-none"
53
+ @click="emit('pick', null)"
54
+ >No icon</button>
55
+ <div v-for="group in groups" :key="group.label" class="wp-iconpick__group">
56
+ <div v-if="group.label" class="wp-iconpick__group-label">{{ group.label }}</div>
57
+ <div class="wp-iconpick__grid" role="listbox" :aria-label="group.label || 'Icons'">
58
+ <button
59
+ v-for="name in group.icons"
60
+ :key="name"
61
+ v-tooltip="words(name)"
62
+ type="button"
63
+ class="wp-iconpick__cell"
64
+ :class="{ 'wp-iconpick__cell--on': name === current }"
65
+ role="option"
66
+ :aria-selected="name === current"
67
+ :aria-label="words(name)"
68
+ :data-test="`icon-picker-${name}`"
69
+ @click="emit('pick', name)"
70
+ ><i :class="`pi pi-${name}`" :style="tint" aria-hidden="true" /></button>
71
+ <p v-if="!group.icons.length" class="wp-iconpick__empty">No icon matches "{{ query }}".</p>
72
+ </div>
73
+ </div>
74
+ </div>
75
+ </template>
76
+
77
+ <style scoped>
78
+ .wp-iconpick__panel {
79
+ width: 212px;
80
+ max-height: 276px;
81
+ overflow-y: auto;
82
+ }
83
+ .wp-iconpick__panel[data-all] { width: 264px; }
84
+ /* Stays at the top while the glyphs scroll under it. */
85
+ .wp-iconpick__search { position: sticky; top: 0; z-index: 1; margin-bottom: var(--wp-space-3); box-shadow: 0 0 0 var(--wp-space-2) var(--wp-bg-2); background: var(--wp-bg-2); }
86
+ .wp-iconpick__clear {
87
+ width: 100%;
88
+ margin-bottom: var(--wp-space-3);
89
+ padding: var(--wp-space-2);
90
+ background: transparent;
91
+ border: 1px dashed var(--wp-border);
92
+ border-radius: var(--wp-radius-sm);
93
+ color: var(--wp-text-muted);
94
+ font: inherit;
95
+ font-size: var(--wp-text-xs);
96
+ cursor: pointer;
97
+ }
98
+ .wp-iconpick__clear:hover { color: var(--wp-text); border-color: var(--wp-accent); }
99
+ .wp-iconpick__clear[aria-pressed="true"] { border-style: solid; border-color: var(--wp-accent); color: var(--wp-text); }
100
+ .wp-iconpick__clear:focus-visible, .wp-iconpick__cell:focus-visible { outline: none; box-shadow: var(--wp-focus-ring); }
101
+ .wp-iconpick__group + .wp-iconpick__group { margin-top: var(--wp-space-3); }
102
+ .wp-iconpick__group-label {
103
+ margin-bottom: var(--wp-space-2);
104
+ font-size: var(--wp-text-xs);
105
+ font-weight: 700;
106
+ letter-spacing: 0.08em;
107
+ text-transform: uppercase;
108
+ color: var(--wp-text-dim);
109
+ }
110
+ .wp-iconpick__grid {
111
+ display: grid;
112
+ grid-template-columns: repeat(8, 1fr);
113
+ gap: 2px;
114
+ }
115
+ .wp-iconpick__cell {
116
+ display: flex;
117
+ align-items: center;
118
+ justify-content: center;
119
+ aspect-ratio: 1;
120
+ padding: 0;
121
+ background: transparent;
122
+ border: 1px solid transparent;
123
+ border-radius: var(--wp-radius-sm);
124
+ color: var(--wp-text-muted);
125
+ cursor: pointer;
126
+ }
127
+ /* An even size, on whole pixels; the font's own line box would push it off centre. */
128
+ .wp-iconpick__cell .pi { font-size: 14px; line-height: 1; }
129
+ .wp-iconpick__cell:hover { background: color-mix(in oklab, var(--wp-accent) 18%, transparent); color: var(--wp-text); }
130
+ .wp-iconpick__cell--on { border-color: var(--wp-accent); color: var(--wp-accent); }
131
+ .wp-iconpick__empty { grid-column: 1 / -1; margin: var(--wp-space-2) 0; color: var(--wp-text-dim); font-size: var(--wp-text-sm); }
132
+ </style>
package/src/ImageGrid.vue CHANGED
@@ -15,7 +15,7 @@
15
15
  import { computed, ref } from "vue";
16
16
  import Lightbox from "./Lightbox.vue";
17
17
  import Skeleton from "./Skeleton.vue";
18
- import WpCheck from "./WpCheck.vue";
18
+ import Checkbox from "./Checkbox.vue";
19
19
 
20
20
  export interface GridImage { id: string; src: string; alt: string; thumb?: string; caption?: string }
21
21
 
@@ -82,7 +82,7 @@ function toggle(id: string, on: boolean): void {
82
82
  <img v-if="img.thumb || img.src" class="wp-image-grid__img" :src="img.thumb || img.src" alt="" loading="lazy">
83
83
  <Skeleton v-else class="wp-image-grid__empty" shape="block" width="100%" height="100%" />
84
84
  </button>
85
- <WpCheck
85
+ <Checkbox
86
86
  v-if="selectable"
87
87
  class="wp-image-grid__check"
88
88
  :model-value="props.selected.includes(img.id)"
package/src/Lightbox.vue CHANGED
@@ -16,7 +16,7 @@
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
20
  import Skeleton from "./Skeleton.vue";
21
21
  import { focusablesIn } from "./useDialogFocus";
22
22
 
@@ -104,7 +104,7 @@ watch(() => props.open, async (v, was) => {
104
104
  >
105
105
  <div class="wp-lightbox__bar">
106
106
  <span v-if="count > 1" class="wp-lightbox__count" aria-hidden="true">{{ at + 1 }} / {{ count }}</span>
107
- <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" />
108
108
  </div>
109
109
  <figure v-if="image" class="wp-lightbox__figure" :data-zoom="zoom || undefined">
110
110
  <!-- An image with no src yet (still rendering): its shape, not an empty frame. -->
@@ -21,9 +21,10 @@ 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, nextTick, onBeforeUnmount, onMounted, ref, useSlots } from "vue";
24
+ import { computed, 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
+ import { canvasBox, useCanvasMotion } from "./canvas-motion";
27
+ import { drawPose, poseAt, type Look } from "./loading-mark-motion";
27
28
 
28
29
  const props = withDefaults(defineProps<{
29
30
  /** Width and height in px. Default 14, or 40 with `block`. */
@@ -56,16 +57,10 @@ const fill = (colour: string): string => (props.mono ? "currentColor" : colour);
56
57
 
57
58
  /* The SVG is the still mark (first paint, reduced motion, a page with no
58
59
  * canvas). Once mounted, a canvas over it draws the motion: see
59
- * `loading-mark-motion.ts` for why it is not CSS. */
60
+ * `canvas-motion.ts` for why it is not CSS. */
60
61
  const root = ref<HTMLElement | null>(null);
61
62
  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
- });
63
+ const canvasStyle = computed(() => canvasBox(px.value));
69
64
 
70
65
  let textColour = "";
71
66
  let textColourAt = -Infinity;
@@ -81,57 +76,7 @@ function look(): Look {
81
76
  return { variant: drawing.value, colours: [VIOLET.value, INDIGO.value, TEAL.value], flat: false };
82
77
  }
83
78
 
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
- });
79
+ const { live, drawn } = useCanvasMotion(canvas, (ctx, width, ms) => drawPose(ctx, width, look(), poseAt(ms)));
135
80
  </script>
136
81
 
137
82
  <template>
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>