@dumiflex/wp-kit 0.3.0 → 0.4.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 +77 -51
- package/LICENSE +674 -674
- package/README.md +73 -76
- package/package.json +78 -77
- package/src/Avatar.vue +83 -83
- package/src/Badge.vue +97 -97
- package/src/Banner.vue +118 -118
- package/src/BookmarkButton.vue +27 -27
- package/src/BundleMeta.vue +51 -51
- package/src/Button.vue +103 -103
- package/src/Card.vue +68 -68
- package/src/Checkbox.vue +109 -107
- package/src/Chip.vue +163 -97
- package/src/ChoiceCards.vue +133 -133
- package/src/CloseButton.vue +69 -69
- package/src/CodeBlock.vue +119 -119
- package/src/ColorField.vue +188 -206
- package/src/ColorPicker.vue +201 -201
- package/src/Combobox.vue +331 -334
- package/src/ConfirmDialog.vue +165 -163
- package/src/ConfirmModal.vue +147 -147
- package/src/ConstraintMatrixPreview.vue +708 -708
- package/src/ContextMenu.vue +386 -379
- package/src/CopyButton.vue +156 -156
- package/src/DataTable.vue +292 -292
- package/src/DatePicker.vue +234 -234
- package/src/DescriptionList.vue +73 -73
- package/src/DiffView.vue +68 -68
- package/src/Disclosure.vue +166 -166
- package/src/DragHandle.vue +49 -49
- package/src/Drawer.vue +131 -131
- package/src/DropZone.vue +196 -196
- package/src/DropdownMenu.vue +72 -72
- package/src/EmptyState.vue +111 -111
- package/src/Field.vue +28 -28
- package/src/HsvPicker.vue +208 -208
- package/src/Icon.vue +39 -39
- package/src/IconPicker.vue +209 -209
- package/src/ImageGrid.vue +125 -125
- package/src/InlineEdit.vue +183 -183
- package/src/Input.vue +99 -99
- package/src/Kbd.vue +56 -56
- package/src/Lightbox.vue +173 -173
- package/src/ListFilter.vue +85 -85
- package/src/ListRow.vue +204 -204
- package/src/LoadingMark.vue +175 -161
- package/src/Logo.vue +60 -60
- package/src/Modal.vue +78 -76
- package/src/ModalShell.vue +109 -107
- package/src/Pagination.vue +106 -106
- package/src/Popover.vue +180 -171
- package/src/PostCard.vue +210 -211
- package/src/ProgressBar.vue +90 -90
- package/src/RadioGroup.vue +177 -177
- package/src/RangeSlider.vue +183 -183
- package/src/RelativeDate.vue +35 -35
- package/src/ResizeHandle.vue +143 -143
- package/src/SearchInput.vue +192 -199
- package/src/Select.vue +644 -641
- package/src/Separator.vue +54 -54
- package/src/Skeleton.vue +82 -82
- package/src/Slider.vue +198 -198
- package/src/SortableList.vue +260 -260
- package/src/SplitPane.vue +68 -68
- package/src/StarButton.vue +40 -40
- package/src/Stepper.vue +141 -141
- package/src/SupportLinks.vue +93 -93
- package/src/TagInput.vue +297 -300
- package/src/Textarea.vue +57 -57
- package/src/Timeline.vue +92 -92
- package/src/ToastCard.vue +119 -119
- package/src/ToastHost.vue +40 -40
- package/src/Toggle.vue +36 -36
- package/src/TokenizedValue.vue +310 -310
- package/src/Toolbar.vue +110 -110
- package/src/VirtualList.vue +87 -87
- package/src/VisuallyHidden.vue +29 -29
- package/src/WpButton.vue +75 -75
- package/src/WpCheck.vue +104 -102
- package/src/WpIconButton.vue +107 -107
- package/src/WpImageZoom.vue +544 -544
- package/src/WpNumber.vue +178 -183
- package/src/WpSegmented.vue +175 -175
- package/src/WpSelect.vue +93 -89
- package/src/WpSwitch.vue +163 -163
- package/src/WpTabs.vue +188 -188
- package/src/_modal-motion.css +33 -33
- package/src/_skeleton.css +50 -50
- package/src/axis-color.ts +42 -42
- package/src/button.css +169 -169
- package/src/calendar.ts +59 -59
- package/src/clipboard.ts +50 -50
- package/src/colour-presets.ts +12 -12
- package/src/diff.ts +51 -51
- package/src/drop-zone.ts +41 -41
- package/src/field.css +50 -0
- package/src/flip.ts +237 -237
- package/src/floating-layer.ts +48 -0
- package/src/floating.ts +99 -99
- package/src/hsv.ts +100 -100
- package/src/icon-catalog.ts +52 -52
- package/src/image-zoom.ts +93 -93
- package/src/kind-colour.ts +28 -0
- package/src/logo-paths.ts +21 -21
- package/src/matrix-axis.ts +91 -91
- package/src/pagination.ts +27 -27
- package/src/platform-keys.ts +29 -29
- package/src/rich-tokenize.ts +357 -357
- package/src/select-types.ts +32 -32
- package/src/slider-math.ts +45 -45
- package/src/sortable.ts +22 -22
- package/src/theme/fonts/OFL-Fraunces.txt +93 -93
- package/src/theme/fonts/OFL-JetBrainsMono.txt +93 -93
- package/src/theme/fonts/OFL-Manrope.txt +93 -93
- package/src/theme/looks.css +428 -428
- package/src/theme/palettes.css +254 -254
- package/src/theme/tokens.css +2896 -2894
- package/src/tokens.ts +56 -56
- package/src/tooltip.css +43 -43
- package/src/tooltip.ts +146 -146
- package/src/useDialogFocus.ts +73 -73
- package/src/useGrowableField.ts +357 -357
- package/src/useModalKeyShield.ts +116 -116
- package/src/useToast.ts +48 -48
- package/src/wp-logo.svg +14 -14
- package/src/ChipToggle.vue +0 -92
- package/src/KindChip.vue +0 -53
- package/src/RatingBadge.vue +0 -42
package/src/Lightbox.vue
CHANGED
|
@@ -1,173 +1,173 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* One image at a time, full screen, over a dimmed page: ← / → (or the side
|
|
4
|
-
* buttons) step through the list, Escape or the ✕ closes, and focus goes
|
|
5
|
-
* back to what opened it. Built on ModalShell, so keys pressed inside never
|
|
6
|
-
* reach ComfyUI's shortcuts. ImageGrid opens it for you; use it alone for
|
|
7
|
-
* a single image (a docs screenshot, a preview).
|
|
8
|
-
*
|
|
9
|
-
* <Lightbox v-model:open="open" v-model:index="at" :images="shots" />
|
|
10
|
-
* <Lightbox v-model:open="open" :images="frames" zoom />
|
|
11
|
-
*
|
|
12
|
-
* `zoom` shows each picture in WpImageZoom (Fit / 100% / 200%, wheel, drag,
|
|
13
|
-
* + − 0 1 2 keys); the level and the spot carry over as you step, so you
|
|
14
|
-
* can compare the same part of every frame.
|
|
15
|
-
*/
|
|
16
|
-
import { computed, nextTick, ref, watch } from "vue";
|
|
17
|
-
import ModalShell from "./ModalShell.vue";
|
|
18
|
-
import WpImageZoom from "./WpImageZoom.vue";
|
|
19
|
-
import CloseButton from "./CloseButton.vue";
|
|
20
|
-
import { focusablesIn } from "./useDialogFocus";
|
|
21
|
-
|
|
22
|
-
export interface LightboxImage { src: string; alt: string; caption?: string | undefined }
|
|
23
|
-
|
|
24
|
-
const props = withDefaults(defineProps<{
|
|
25
|
-
open: boolean;
|
|
26
|
-
images: LightboxImage[];
|
|
27
|
-
index?: number | undefined;
|
|
28
|
-
/** Go round from the last image to the first. */
|
|
29
|
-
loop?: boolean | undefined;
|
|
30
|
-
/** Real zoom levels (WpImageZoom) instead of a picture fitted to the screen. */
|
|
31
|
-
zoom?: boolean | undefined;
|
|
32
|
-
}>(), { index: 0, loop: false, zoom: false });
|
|
33
|
-
|
|
34
|
-
/** The zoom level, kept across images while the viewer is open. */
|
|
35
|
-
const level = ref<number | null>(null);
|
|
36
|
-
|
|
37
|
-
const emit = defineEmits<{
|
|
38
|
-
(e: "update:open", v: boolean): void;
|
|
39
|
-
(e: "update:index", v: number): void;
|
|
40
|
-
}>();
|
|
41
|
-
|
|
42
|
-
const panelEl = ref<HTMLElement | null>(null);
|
|
43
|
-
let returnTo: HTMLElement | null = null;
|
|
44
|
-
|
|
45
|
-
const count = computed(() => props.images.length);
|
|
46
|
-
const at = computed(() => Math.min(Math.max(props.index, 0), Math.max(count.value - 1, 0)));
|
|
47
|
-
const image = computed(() => props.images[at.value]);
|
|
48
|
-
const canPrev = computed(() => count.value > 1 && (props.loop || at.value > 0));
|
|
49
|
-
const canNext = computed(() => count.value > 1 && (props.loop || at.value < count.value - 1));
|
|
50
|
-
|
|
51
|
-
function go(dir: 1 | -1): void {
|
|
52
|
-
if (dir < 0 ? !canPrev.value : !canNext.value) return;
|
|
53
|
-
emit("update:index", (at.value + dir + count.value) % count.value);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function close(): void {
|
|
57
|
-
emit("update:open", false);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function onKey(ev: KeyboardEvent): void {
|
|
61
|
-
if (ev.key === "ArrowLeft") { ev.preventDefault(); go(-1); }
|
|
62
|
-
else if (ev.key === "ArrowRight") { ev.preventDefault(); go(1); }
|
|
63
|
-
else if (ev.key === "Home" && count.value) { ev.preventDefault(); emit("update:index", 0); }
|
|
64
|
-
else if (ev.key === "End" && count.value) { ev.preventDefault(); emit("update:index", count.value - 1); }
|
|
65
|
-
else if (ev.key === "Tab") {
|
|
66
|
-
// Keep Tab inside: ModalShell stops the keys at its overlay, so this is the trap.
|
|
67
|
-
const els = focusablesIn(panelEl.value);
|
|
68
|
-
if (!els.length) return;
|
|
69
|
-
const first = els[0];
|
|
70
|
-
const last = els[els.length - 1];
|
|
71
|
-
if (ev.shiftKey && document.activeElement === first) { ev.preventDefault(); last.focus(); }
|
|
72
|
-
else if (!ev.shiftKey && document.activeElement === last) { ev.preventDefault(); first.focus(); }
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
watch(() => props.open, async (v, was) => {
|
|
77
|
-
if (typeof document === "undefined") return;
|
|
78
|
-
if (v) {
|
|
79
|
-
level.value = null;
|
|
80
|
-
returnTo = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
81
|
-
await nextTick();
|
|
82
|
-
panelEl.value?.focus();
|
|
83
|
-
} else if (was) {
|
|
84
|
-
const el = returnTo;
|
|
85
|
-
returnTo = null;
|
|
86
|
-
if (el?.isConnected) el.focus();
|
|
87
|
-
}
|
|
88
|
-
}, { immediate: true });
|
|
89
|
-
</script>
|
|
90
|
-
|
|
91
|
-
<template>
|
|
92
|
-
<ModalShell :visible="open" @close="close" @keydown="onKey">
|
|
93
|
-
<!-- A click on the dark area closes, like the modal's own backdrop; Escape is the keyboard way. -->
|
|
94
|
-
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/no-static-element-interactions -->
|
|
95
|
-
<div
|
|
96
|
-
ref="panelEl"
|
|
97
|
-
class="wp-lightbox"
|
|
98
|
-
tabindex="-1"
|
|
99
|
-
role="group"
|
|
100
|
-
aria-roledescription="image viewer"
|
|
101
|
-
:aria-label="image ? `${image.alt}, ${at + 1} of ${count}` : 'Image viewer'"
|
|
102
|
-
@click.self="close"
|
|
103
|
-
>
|
|
104
|
-
<div class="wp-lightbox__bar">
|
|
105
|
-
<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
|
-
</div>
|
|
108
|
-
<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
|
-
<img v-else class="wp-lightbox__img" :src="image.src" :alt="image.alt">
|
|
111
|
-
<figcaption v-if="image.caption" class="wp-lightbox__caption">{{ image.caption }}</figcaption>
|
|
112
|
-
</figure>
|
|
113
|
-
<button
|
|
114
|
-
v-if="count > 1"
|
|
115
|
-
type="button"
|
|
116
|
-
class="wp-lightbox__step"
|
|
117
|
-
data-dir="prev"
|
|
118
|
-
aria-label="Previous image"
|
|
119
|
-
:disabled="!canPrev"
|
|
120
|
-
@click="go(-1)"
|
|
121
|
-
><i class="pi pi-chevron-left" aria-hidden="true" /></button>
|
|
122
|
-
<button
|
|
123
|
-
v-if="count > 1"
|
|
124
|
-
type="button"
|
|
125
|
-
class="wp-lightbox__step"
|
|
126
|
-
data-dir="next"
|
|
127
|
-
aria-label="Next image"
|
|
128
|
-
:disabled="!canNext"
|
|
129
|
-
@click="go(1)"
|
|
130
|
-
><i class="pi pi-chevron-right" aria-hidden="true" /></button>
|
|
131
|
-
</div>
|
|
132
|
-
</ModalShell>
|
|
133
|
-
</template>
|
|
134
|
-
|
|
135
|
-
<style scoped>
|
|
136
|
-
.wp-lightbox {
|
|
137
|
-
position: relative; display: flex; flex-direction: column; align-items: center;
|
|
138
|
-
width: min(1200px, 100%); max-height: calc(100vh - 40px); outline: none;
|
|
139
|
-
color: var(--wp-text); font-family: var(--wp-font-sans);
|
|
140
|
-
}
|
|
141
|
-
/* Its own near-black scrim in both themes: the shared modal one is light enough to read a dialog
|
|
142
|
-
over (and pale grey in light mode), too light to look at a picture on. A click on it closes. */
|
|
143
|
-
.wp-lightbox::before { content: ""; position: fixed; inset: 0; z-index: -1; background: rgb(0 0 0 / 0.86); }
|
|
144
|
-
.wp-lightbox__bar { display: flex; align-items: center; justify-content: flex-end; gap: var(--wp-space-3); align-self: stretch; padding-bottom: var(--wp-space-2); }
|
|
145
|
-
.wp-lightbox__count { color: rgb(255 255 255 / 0.75); font-size: var(--wp-text-sm); font-variant-numeric: tabular-nums; }
|
|
146
|
-
.wp-lightbox .wp-lightbox__close { color: rgb(255 255 255 / 0.85); }
|
|
147
|
-
.wp-lightbox__figure { display: flex; flex-direction: column; align-items: center; gap: var(--wp-space-2); min-height: 0; margin: 0; }
|
|
148
|
-
.wp-lightbox__figure[data-zoom] { align-self: stretch; }
|
|
149
|
-
.wp-lightbox__zoom {
|
|
150
|
-
width: 100%; height: calc(100vh - 140px);
|
|
151
|
-
border-radius: var(--wp-radius); overflow: hidden; background: var(--wp-bg-1); box-shadow: var(--wp-shadow-lg);
|
|
152
|
-
}
|
|
153
|
-
.wp-lightbox__img {
|
|
154
|
-
display: block; max-width: 100%; max-height: calc(100vh - 140px); object-fit: contain;
|
|
155
|
-
border-radius: var(--wp-radius); box-shadow: var(--wp-shadow-lg); background: var(--wp-bg-1);
|
|
156
|
-
}
|
|
157
|
-
.wp-lightbox__caption { color: rgb(255 255 255 / 0.8); font-size: var(--wp-text-sm); text-align: center; }
|
|
158
|
-
.wp-lightbox__step {
|
|
159
|
-
position: absolute; top: 50%; transform: translateY(-50%);
|
|
160
|
-
display: grid; place-items: center; width: var(--wp-control-h-lg); height: var(--wp-control-h-lg);
|
|
161
|
-
border: 1px solid var(--wp-border-strong); border-radius: min(50%, var(--wp-radius-cap, 999px));
|
|
162
|
-
background: var(--wp-bg-2); color: var(--wp-text); cursor: pointer; box-shadow: var(--wp-shadow-md);
|
|
163
|
-
transition: background var(--wp-motion-hover);
|
|
164
|
-
}
|
|
165
|
-
.wp-lightbox__step[data-dir="prev"] { left: var(--wp-space-2); }
|
|
166
|
-
.wp-lightbox__step[data-dir="next"] { right: var(--wp-space-2); }
|
|
167
|
-
.wp-lightbox__step:hover:not(:disabled) { background: var(--wp-bg-3); }
|
|
168
|
-
.wp-lightbox__step:focus-visible { outline: none; box-shadow:
|
|
169
|
-
.wp-lightbox__step:disabled { opacity: 0.35; cursor: not-allowed; }
|
|
170
|
-
@media (prefers-reduced-motion: reduce) {
|
|
171
|
-
.wp-lightbox__step { transition: none; }
|
|
172
|
-
}
|
|
173
|
-
</style>
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* One image at a time, full screen, over a dimmed page: ← / → (or the side
|
|
4
|
+
* buttons) step through the list, Escape or the ✕ closes, and focus goes
|
|
5
|
+
* back to what opened it. Built on ModalShell, so keys pressed inside never
|
|
6
|
+
* reach ComfyUI's shortcuts. ImageGrid opens it for you; use it alone for
|
|
7
|
+
* a single image (a docs screenshot, a preview).
|
|
8
|
+
*
|
|
9
|
+
* <Lightbox v-model:open="open" v-model:index="at" :images="shots" />
|
|
10
|
+
* <Lightbox v-model:open="open" :images="frames" zoom />
|
|
11
|
+
*
|
|
12
|
+
* `zoom` shows each picture in WpImageZoom (Fit / 100% / 200%, wheel, drag,
|
|
13
|
+
* + − 0 1 2 keys); the level and the spot carry over as you step, so you
|
|
14
|
+
* can compare the same part of every frame.
|
|
15
|
+
*/
|
|
16
|
+
import { computed, nextTick, ref, watch } from "vue";
|
|
17
|
+
import ModalShell from "./ModalShell.vue";
|
|
18
|
+
import WpImageZoom from "./WpImageZoom.vue";
|
|
19
|
+
import CloseButton from "./CloseButton.vue";
|
|
20
|
+
import { focusablesIn } from "./useDialogFocus";
|
|
21
|
+
|
|
22
|
+
export interface LightboxImage { src: string; alt: string; caption?: string | undefined }
|
|
23
|
+
|
|
24
|
+
const props = withDefaults(defineProps<{
|
|
25
|
+
open: boolean;
|
|
26
|
+
images: LightboxImage[];
|
|
27
|
+
index?: number | undefined;
|
|
28
|
+
/** Go round from the last image to the first. */
|
|
29
|
+
loop?: boolean | undefined;
|
|
30
|
+
/** Real zoom levels (WpImageZoom) instead of a picture fitted to the screen. */
|
|
31
|
+
zoom?: boolean | undefined;
|
|
32
|
+
}>(), { index: 0, loop: false, zoom: false });
|
|
33
|
+
|
|
34
|
+
/** The zoom level, kept across images while the viewer is open. */
|
|
35
|
+
const level = ref<number | null>(null);
|
|
36
|
+
|
|
37
|
+
const emit = defineEmits<{
|
|
38
|
+
(e: "update:open", v: boolean): void;
|
|
39
|
+
(e: "update:index", v: number): void;
|
|
40
|
+
}>();
|
|
41
|
+
|
|
42
|
+
const panelEl = ref<HTMLElement | null>(null);
|
|
43
|
+
let returnTo: HTMLElement | null = null;
|
|
44
|
+
|
|
45
|
+
const count = computed(() => props.images.length);
|
|
46
|
+
const at = computed(() => Math.min(Math.max(props.index, 0), Math.max(count.value - 1, 0)));
|
|
47
|
+
const image = computed(() => props.images[at.value]);
|
|
48
|
+
const canPrev = computed(() => count.value > 1 && (props.loop || at.value > 0));
|
|
49
|
+
const canNext = computed(() => count.value > 1 && (props.loop || at.value < count.value - 1));
|
|
50
|
+
|
|
51
|
+
function go(dir: 1 | -1): void {
|
|
52
|
+
if (dir < 0 ? !canPrev.value : !canNext.value) return;
|
|
53
|
+
emit("update:index", (at.value + dir + count.value) % count.value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function close(): void {
|
|
57
|
+
emit("update:open", false);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function onKey(ev: KeyboardEvent): void {
|
|
61
|
+
if (ev.key === "ArrowLeft") { ev.preventDefault(); go(-1); }
|
|
62
|
+
else if (ev.key === "ArrowRight") { ev.preventDefault(); go(1); }
|
|
63
|
+
else if (ev.key === "Home" && count.value) { ev.preventDefault(); emit("update:index", 0); }
|
|
64
|
+
else if (ev.key === "End" && count.value) { ev.preventDefault(); emit("update:index", count.value - 1); }
|
|
65
|
+
else if (ev.key === "Tab") {
|
|
66
|
+
// Keep Tab inside: ModalShell stops the keys at its overlay, so this is the trap.
|
|
67
|
+
const els = focusablesIn(panelEl.value);
|
|
68
|
+
if (!els.length) return;
|
|
69
|
+
const first = els[0];
|
|
70
|
+
const last = els[els.length - 1];
|
|
71
|
+
if (ev.shiftKey && document.activeElement === first) { ev.preventDefault(); last.focus(); }
|
|
72
|
+
else if (!ev.shiftKey && document.activeElement === last) { ev.preventDefault(); first.focus(); }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
watch(() => props.open, async (v, was) => {
|
|
77
|
+
if (typeof document === "undefined") return;
|
|
78
|
+
if (v) {
|
|
79
|
+
level.value = null;
|
|
80
|
+
returnTo = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
81
|
+
await nextTick();
|
|
82
|
+
panelEl.value?.focus();
|
|
83
|
+
} else if (was) {
|
|
84
|
+
const el = returnTo;
|
|
85
|
+
returnTo = null;
|
|
86
|
+
if (el?.isConnected) el.focus();
|
|
87
|
+
}
|
|
88
|
+
}, { immediate: true });
|
|
89
|
+
</script>
|
|
90
|
+
|
|
91
|
+
<template>
|
|
92
|
+
<ModalShell :visible="open" @close="close" @keydown="onKey">
|
|
93
|
+
<!-- A click on the dark area closes, like the modal's own backdrop; Escape is the keyboard way. -->
|
|
94
|
+
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/no-static-element-interactions -->
|
|
95
|
+
<div
|
|
96
|
+
ref="panelEl"
|
|
97
|
+
class="wp-lightbox"
|
|
98
|
+
tabindex="-1"
|
|
99
|
+
role="group"
|
|
100
|
+
aria-roledescription="image viewer"
|
|
101
|
+
:aria-label="image ? `${image.alt}, ${at + 1} of ${count}` : 'Image viewer'"
|
|
102
|
+
@click.self="close"
|
|
103
|
+
>
|
|
104
|
+
<div class="wp-lightbox__bar">
|
|
105
|
+
<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
|
+
</div>
|
|
108
|
+
<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
|
+
<img v-else class="wp-lightbox__img" :src="image.src" :alt="image.alt">
|
|
111
|
+
<figcaption v-if="image.caption" class="wp-lightbox__caption">{{ image.caption }}</figcaption>
|
|
112
|
+
</figure>
|
|
113
|
+
<button
|
|
114
|
+
v-if="count > 1"
|
|
115
|
+
type="button"
|
|
116
|
+
class="wp-lightbox__step"
|
|
117
|
+
data-dir="prev"
|
|
118
|
+
aria-label="Previous image"
|
|
119
|
+
:disabled="!canPrev"
|
|
120
|
+
@click="go(-1)"
|
|
121
|
+
><i class="pi pi-chevron-left" aria-hidden="true" /></button>
|
|
122
|
+
<button
|
|
123
|
+
v-if="count > 1"
|
|
124
|
+
type="button"
|
|
125
|
+
class="wp-lightbox__step"
|
|
126
|
+
data-dir="next"
|
|
127
|
+
aria-label="Next image"
|
|
128
|
+
:disabled="!canNext"
|
|
129
|
+
@click="go(1)"
|
|
130
|
+
><i class="pi pi-chevron-right" aria-hidden="true" /></button>
|
|
131
|
+
</div>
|
|
132
|
+
</ModalShell>
|
|
133
|
+
</template>
|
|
134
|
+
|
|
135
|
+
<style scoped>
|
|
136
|
+
.wp-lightbox {
|
|
137
|
+
position: relative; display: flex; flex-direction: column; align-items: center;
|
|
138
|
+
width: min(1200px, 100%); max-height: calc(100vh - 40px); outline: none;
|
|
139
|
+
color: var(--wp-text); font-family: var(--wp-font-sans);
|
|
140
|
+
}
|
|
141
|
+
/* Its own near-black scrim in both themes: the shared modal one is light enough to read a dialog
|
|
142
|
+
over (and pale grey in light mode), too light to look at a picture on. A click on it closes. */
|
|
143
|
+
.wp-lightbox::before { content: ""; position: fixed; inset: 0; z-index: -1; background: rgb(0 0 0 / 0.86); }
|
|
144
|
+
.wp-lightbox__bar { display: flex; align-items: center; justify-content: flex-end; gap: var(--wp-space-3); align-self: stretch; padding-bottom: var(--wp-space-2); }
|
|
145
|
+
.wp-lightbox__count { color: rgb(255 255 255 / 0.75); font-size: var(--wp-text-sm); font-variant-numeric: tabular-nums; }
|
|
146
|
+
.wp-lightbox .wp-lightbox__close { color: rgb(255 255 255 / 0.85); }
|
|
147
|
+
.wp-lightbox__figure { display: flex; flex-direction: column; align-items: center; gap: var(--wp-space-2); min-height: 0; margin: 0; }
|
|
148
|
+
.wp-lightbox__figure[data-zoom] { align-self: stretch; }
|
|
149
|
+
.wp-lightbox__zoom {
|
|
150
|
+
width: 100%; height: calc(100vh - 140px);
|
|
151
|
+
border-radius: var(--wp-radius); overflow: hidden; background: var(--wp-bg-1); box-shadow: var(--wp-shadow-lg);
|
|
152
|
+
}
|
|
153
|
+
.wp-lightbox__img {
|
|
154
|
+
display: block; max-width: 100%; max-height: calc(100vh - 140px); object-fit: contain;
|
|
155
|
+
border-radius: var(--wp-radius); box-shadow: var(--wp-shadow-lg); background: var(--wp-bg-1);
|
|
156
|
+
}
|
|
157
|
+
.wp-lightbox__caption { color: rgb(255 255 255 / 0.8); font-size: var(--wp-text-sm); text-align: center; }
|
|
158
|
+
.wp-lightbox__step {
|
|
159
|
+
position: absolute; top: 50%; transform: translateY(-50%);
|
|
160
|
+
display: grid; place-items: center; width: var(--wp-control-h-lg); height: var(--wp-control-h-lg);
|
|
161
|
+
border: 1px solid var(--wp-border-strong); border-radius: min(50%, var(--wp-radius-cap, 999px));
|
|
162
|
+
background: var(--wp-bg-2); color: var(--wp-text); cursor: pointer; box-shadow: var(--wp-shadow-md);
|
|
163
|
+
transition: background var(--wp-motion-hover);
|
|
164
|
+
}
|
|
165
|
+
.wp-lightbox__step[data-dir="prev"] { left: var(--wp-space-2); }
|
|
166
|
+
.wp-lightbox__step[data-dir="next"] { right: var(--wp-space-2); }
|
|
167
|
+
.wp-lightbox__step:hover:not(:disabled) { background: var(--wp-bg-3); }
|
|
168
|
+
.wp-lightbox__step:focus-visible { outline: none; box-shadow: var(--wp-focus-ring); border-color: var(--wp-border-focus); }
|
|
169
|
+
.wp-lightbox__step:disabled { opacity: 0.35; cursor: not-allowed; }
|
|
170
|
+
@media (prefers-reduced-motion: reduce) {
|
|
171
|
+
.wp-lightbox__step { transition: none; }
|
|
172
|
+
}
|
|
173
|
+
</style>
|
package/src/ListFilter.vue
CHANGED
|
@@ -1,85 +1,85 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import SearchInput from "./SearchInput.vue";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Text filter for a long list inside an editor card.
|
|
6
|
-
*
|
|
7
|
-
* Extracted from the fixed-values editor, which grew this pattern first
|
|
8
|
-
* (`fv-filter` in FixedEditor.vue). The wildcard options list has its own,
|
|
9
|
-
* richer variant with a tag menu attached; this is the plain-text case, which
|
|
10
|
-
* is what the constraint exceptions and derivation rules need.
|
|
11
|
-
*
|
|
12
|
-
* Since the component merge it is SearchInput with a list's rules on top:
|
|
13
|
-
* the "3 of 12" count inside the box, and the hide-until-long gate below.
|
|
14
|
-
*
|
|
15
|
-
* Deliberately dumb: it owns the query string and the count readout, and
|
|
16
|
-
* nothing else. Each caller keeps its own filtering, because what counts as a
|
|
17
|
-
* match differs per surface — an exception matches on resolved source/target
|
|
18
|
-
* labels, a rule matches on every variable name and value across its branches.
|
|
19
|
-
*/
|
|
20
|
-
const props = withDefaults(
|
|
21
|
-
defineProps<{
|
|
22
|
-
/** Unfiltered row count. Drives the "n of N" readout and the show/hide gate. */
|
|
23
|
-
total: number;
|
|
24
|
-
/** Row count after filtering. */
|
|
25
|
-
visible: number;
|
|
26
|
-
/** Plural noun for the placeholder and idle readout, e.g. "exceptions". */
|
|
27
|
-
noun: string;
|
|
28
|
-
/** `data-test` prefix; yields `<prefix>-search`, `-count`, `-clear` (the ✕). */
|
|
29
|
-
testPrefix: string;
|
|
30
|
-
/**
|
|
31
|
-
* Hide the control until the list is longer than this.
|
|
32
|
-
*
|
|
33
|
-
* Callers set it from how tall their rows are, not from a house style: a
|
|
34
|
-
* derivation rule is a whole card and three of them already scroll, while
|
|
35
|
-
* a fixed-value is one table row and eight fit comfortably.
|
|
36
|
-
*
|
|
37
|
-
* The default of 8 came from the fixed-values filter and was applied
|
|
38
|
-
* unchanged to the constraint and derivation editors, where it hid the
|
|
39
|
-
* feature outright — a real library measured 2026-08-06 had at most 3
|
|
40
|
-
* rules and 2 exceptions per module, so nobody ever saw it.
|
|
41
|
-
*
|
|
42
|
-
* Gated on `total`, never on `visible`, so the box cannot disappear
|
|
43
|
-
* underneath a query that filtered the list down past the threshold.
|
|
44
|
-
*/
|
|
45
|
-
minRows?: number | undefined;
|
|
46
|
-
}>(),
|
|
47
|
-
{ minRows: 8 },
|
|
48
|
-
);
|
|
49
|
-
|
|
50
|
-
const query = defineModel<string>({ required: true });
|
|
51
|
-
</script>
|
|
52
|
-
|
|
53
|
-
<template>
|
|
54
|
-
<SearchInput
|
|
55
|
-
v-if="total > props.minRows"
|
|
56
|
-
v-model="query"
|
|
57
|
-
class="wp-lfilter"
|
|
58
|
-
size="sm"
|
|
59
|
-
:placeholder="`Filter ${total} ${noun}…`"
|
|
60
|
-
:aria-label="`Filter ${noun}`"
|
|
61
|
-
:total="total"
|
|
62
|
-
:visible="visible"
|
|
63
|
-
:noun="noun"
|
|
64
|
-
clear-label="Clear filter"
|
|
65
|
-
:test-prefix="testPrefix"
|
|
66
|
-
:data-test="`${testPrefix}-search`"
|
|
67
|
-
/>
|
|
68
|
-
</template>
|
|
69
|
-
|
|
70
|
-
<style scoped>
|
|
71
|
-
.wp-lfilter {
|
|
72
|
-
/* Card's header puts a `.wp-spacer` (flex: 1) between the title and this
|
|
73
|
-
slot. With a grow factor of 1 the two split the free space evenly and the
|
|
74
|
-
search box ends up half the width it should be. A far larger factor takes
|
|
75
|
-
effectively all of the slack while leaving the spacer in place, which is
|
|
76
|
-
what still separates the title from the controls. */
|
|
77
|
-
flex: 1000 1 auto;
|
|
78
|
-
/* A floor, not a width. Below this the input is too small to read what you
|
|
79
|
-
typed, so the card header (which wraps) drops the filter onto its own row
|
|
80
|
-
instead of shaving it to nothing. The derivation card, with four buttons
|
|
81
|
-
beside it, is the case that forces this. */
|
|
82
|
-
min-width: 220px;
|
|
83
|
-
margin-right: var(--wp-space-4);
|
|
84
|
-
}
|
|
85
|
-
</style>
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import SearchInput from "./SearchInput.vue";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Text filter for a long list inside an editor card.
|
|
6
|
+
*
|
|
7
|
+
* Extracted from the fixed-values editor, which grew this pattern first
|
|
8
|
+
* (`fv-filter` in FixedEditor.vue). The wildcard options list has its own,
|
|
9
|
+
* richer variant with a tag menu attached; this is the plain-text case, which
|
|
10
|
+
* is what the constraint exceptions and derivation rules need.
|
|
11
|
+
*
|
|
12
|
+
* Since the component merge it is SearchInput with a list's rules on top:
|
|
13
|
+
* the "3 of 12" count inside the box, and the hide-until-long gate below.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately dumb: it owns the query string and the count readout, and
|
|
16
|
+
* nothing else. Each caller keeps its own filtering, because what counts as a
|
|
17
|
+
* match differs per surface — an exception matches on resolved source/target
|
|
18
|
+
* labels, a rule matches on every variable name and value across its branches.
|
|
19
|
+
*/
|
|
20
|
+
const props = withDefaults(
|
|
21
|
+
defineProps<{
|
|
22
|
+
/** Unfiltered row count. Drives the "n of N" readout and the show/hide gate. */
|
|
23
|
+
total: number;
|
|
24
|
+
/** Row count after filtering. */
|
|
25
|
+
visible: number;
|
|
26
|
+
/** Plural noun for the placeholder and idle readout, e.g. "exceptions". */
|
|
27
|
+
noun: string;
|
|
28
|
+
/** `data-test` prefix; yields `<prefix>-search`, `-count`, `-clear` (the ✕). */
|
|
29
|
+
testPrefix: string;
|
|
30
|
+
/**
|
|
31
|
+
* Hide the control until the list is longer than this.
|
|
32
|
+
*
|
|
33
|
+
* Callers set it from how tall their rows are, not from a house style: a
|
|
34
|
+
* derivation rule is a whole card and three of them already scroll, while
|
|
35
|
+
* a fixed-value is one table row and eight fit comfortably.
|
|
36
|
+
*
|
|
37
|
+
* The default of 8 came from the fixed-values filter and was applied
|
|
38
|
+
* unchanged to the constraint and derivation editors, where it hid the
|
|
39
|
+
* feature outright — a real library measured 2026-08-06 had at most 3
|
|
40
|
+
* rules and 2 exceptions per module, so nobody ever saw it.
|
|
41
|
+
*
|
|
42
|
+
* Gated on `total`, never on `visible`, so the box cannot disappear
|
|
43
|
+
* underneath a query that filtered the list down past the threshold.
|
|
44
|
+
*/
|
|
45
|
+
minRows?: number | undefined;
|
|
46
|
+
}>(),
|
|
47
|
+
{ minRows: 8 },
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const query = defineModel<string>({ required: true });
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<SearchInput
|
|
55
|
+
v-if="total > props.minRows"
|
|
56
|
+
v-model="query"
|
|
57
|
+
class="wp-lfilter"
|
|
58
|
+
size="sm"
|
|
59
|
+
:placeholder="`Filter ${total} ${noun}…`"
|
|
60
|
+
:aria-label="`Filter ${noun}`"
|
|
61
|
+
:total="total"
|
|
62
|
+
:visible="visible"
|
|
63
|
+
:noun="noun"
|
|
64
|
+
clear-label="Clear filter"
|
|
65
|
+
:test-prefix="testPrefix"
|
|
66
|
+
:data-test="`${testPrefix}-search`"
|
|
67
|
+
/>
|
|
68
|
+
</template>
|
|
69
|
+
|
|
70
|
+
<style scoped>
|
|
71
|
+
.wp-lfilter {
|
|
72
|
+
/* Card's header puts a `.wp-spacer` (flex: 1) between the title and this
|
|
73
|
+
slot. With a grow factor of 1 the two split the free space evenly and the
|
|
74
|
+
search box ends up half the width it should be. A far larger factor takes
|
|
75
|
+
effectively all of the slack while leaving the spacer in place, which is
|
|
76
|
+
what still separates the title from the controls. */
|
|
77
|
+
flex: 1000 1 auto;
|
|
78
|
+
/* A floor, not a width. Below this the input is too small to read what you
|
|
79
|
+
typed, so the card header (which wraps) drops the filter onto its own row
|
|
80
|
+
instead of shaving it to nothing. The derivation card, with four buttons
|
|
81
|
+
beside it, is the case that forces this. */
|
|
82
|
+
min-width: 220px;
|
|
83
|
+
margin-right: var(--wp-space-4);
|
|
84
|
+
}
|
|
85
|
+
</style>
|