@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.
- 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/BundlePreview.vue +254 -0
- package/src/Button.vue +73 -44
- package/src/Checkbox.vue +201 -63
- package/src/Chip.vue +5 -2
- package/src/CodeBlock.vue +1 -1
- package/src/ColorField.vue +60 -6
- package/src/CombinePreview.vue +150 -0
- package/src/Combobox.vue +10 -14
- package/src/ConfirmModal.vue +12 -22
- package/src/ConstraintMatrixPreview.vue +6 -22
- package/src/ConstraintPreview.vue +259 -0
- package/src/ContextMenu.vue +11 -30
- package/src/CopyButton.vue +1 -1
- package/src/DataTable.vue +0 -2
- package/src/DatePicker.vue +1 -1
- package/src/DerivationCondition.vue +68 -0
- package/src/DerivationPreview.vue +345 -0
- package/src/Disclosure.vue +2 -1
- package/src/Drawer.vue +2 -2
- package/src/DropZone.vue +2 -2
- package/src/FixedValuesPreview.vue +184 -0
- package/src/HsvPicker.vue +2 -2
- package/src/IconPicker.vue +32 -103
- package/src/IconPickerPanel.vue +132 -0
- package/src/ImageGrid.vue +2 -2
- package/src/Lightbox.vue +2 -2
- package/src/LoadingMark.vue +6 -61
- package/src/Modal.vue +109 -44
- package/src/ModalShell.vue +26 -5
- package/src/Pagination.vue +4 -4
- package/src/PayloadPreview.vue +154 -0
- package/src/Popover.vue +68 -12
- package/src/PostDetailSkeleton.vue +65 -0
- package/src/PostGridSkeleton.vue +61 -0
- package/src/PreviewGroup.vue +75 -0
- package/src/ProfileSkeleton.vue +73 -0
- package/src/RadioGroup.vue +5 -4
- package/src/RichText.vue +61 -0
- package/src/SearchInput.vue +1 -1
- package/src/Select.vue +53 -158
- package/src/SelectionBar.vue +90 -0
- package/src/SettingGroup.vue +57 -0
- package/src/SettingRow.vue +127 -0
- package/src/Skeleton.vue +1 -1
- 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/WildcardPreview.vue +607 -0
- package/src/WorkingLine.vue +234 -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/canvas-motion.ts +251 -0
- package/src/floating.ts +5 -0
- package/src/hsv.ts +1 -1
- package/src/kind-colour.ts +24 -1
- package/src/linkify.ts +142 -0
- package/src/loading-mark-motion.ts +12 -155
- package/src/matrix-axis.ts +28 -0
- package/src/menu.css +51 -0
- package/src/motion.css +73 -0
- package/src/pi-icons.ts +51 -0
- package/src/preview-readers.ts +401 -0
- package/src/preview.css +194 -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 +15 -4
- package/src/tooltip.css +8 -5
- package/src/useDialogFocus.ts +11 -2
- package/src/working-line-motion.ts +135 -0
- 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/IconPicker.vue
CHANGED
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
* IconPicker
|
|
3
|
+
* IconPicker: pick one PrimeIcons glyph, or none.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
14
|
-
*
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
<
|
|
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:
|
|
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
|
|
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
|
-
<
|
|
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
|
|
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
|
-
<
|
|
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. -->
|
package/src/LoadingMark.vue
CHANGED
|
@@ -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,
|
|
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 {
|
|
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
|
-
* `
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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>
|