@dumiflex/wp-kit 0.0.0-stage → 0.3.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 +51 -0
- package/LICENSE +674 -0
- package/README.md +76 -3
- package/package.json +77 -6
- package/src/Avatar.vue +83 -0
- package/src/Badge.vue +97 -0
- package/src/Banner.vue +118 -0
- package/src/BookmarkButton.vue +27 -0
- package/src/BundleMeta.vue +51 -0
- package/src/Button.vue +103 -0
- package/src/Card.vue +68 -0
- package/src/Checkbox.vue +107 -0
- package/src/Chip.vue +97 -0
- package/src/ChipToggle.vue +92 -0
- package/src/ChoiceCards.vue +133 -0
- package/src/CloseButton.vue +69 -0
- package/src/CodeBlock.vue +119 -0
- package/src/ColorField.vue +206 -0
- package/src/ColorPicker.vue +201 -0
- package/src/Combobox.vue +334 -0
- package/src/ConfirmDialog.vue +163 -0
- package/src/ConfirmModal.vue +147 -0
- package/src/ConstraintMatrixPreview.vue +708 -0
- package/src/ContextMenu.vue +379 -0
- package/src/CopyButton.vue +156 -0
- package/src/DataTable.vue +292 -0
- package/src/DatePicker.vue +234 -0
- package/src/DescriptionList.vue +73 -0
- package/src/DiffView.vue +68 -0
- package/src/Disclosure.vue +166 -0
- package/src/DragHandle.vue +49 -0
- package/src/Drawer.vue +131 -0
- package/src/DropZone.vue +196 -0
- package/src/DropdownMenu.vue +72 -0
- package/src/EmptyState.vue +111 -0
- package/src/Field.vue +28 -0
- package/src/HsvPicker.vue +208 -0
- package/src/Icon.vue +39 -0
- package/src/IconPicker.vue +209 -0
- package/src/ImageGrid.vue +125 -0
- package/src/InlineEdit.vue +183 -0
- package/src/Input.vue +99 -0
- package/src/Kbd.vue +56 -0
- package/src/KindChip.vue +53 -0
- package/src/Lightbox.vue +173 -0
- package/src/ListFilter.vue +85 -0
- package/src/ListRow.vue +204 -0
- package/src/LoadingMark.vue +161 -0
- package/src/Logo.vue +60 -0
- package/src/Modal.vue +76 -0
- package/src/ModalShell.vue +107 -0
- package/src/Pagination.vue +106 -0
- package/src/Popover.vue +171 -0
- package/src/PostCard.vue +211 -0
- package/src/ProgressBar.vue +90 -0
- package/src/RadioGroup.vue +177 -0
- package/src/RangeSlider.vue +183 -0
- package/src/RatingBadge.vue +42 -0
- package/src/RelativeDate.vue +35 -0
- package/src/ResizeHandle.vue +143 -0
- package/src/SearchInput.vue +199 -0
- package/src/Select.vue +641 -0
- package/src/Separator.vue +54 -0
- package/src/Skeleton.vue +82 -0
- package/src/Slider.vue +198 -0
- package/src/SortableList.vue +260 -0
- package/src/SplitPane.vue +68 -0
- package/src/StarButton.vue +40 -0
- package/src/Stepper.vue +141 -0
- package/src/SupportLinks.vue +93 -0
- package/src/TagInput.vue +300 -0
- package/src/Textarea.vue +57 -0
- package/src/Timeline.vue +92 -0
- package/src/ToastCard.vue +119 -0
- package/src/ToastHost.vue +40 -0
- package/src/Toggle.vue +36 -0
- package/src/TokenizedValue.vue +310 -0
- package/src/Toolbar.vue +110 -0
- package/src/VirtualList.vue +87 -0
- package/src/VisuallyHidden.vue +29 -0
- package/src/WpButton.vue +75 -0
- package/src/WpCheck.vue +102 -0
- package/src/WpIconButton.vue +107 -0
- package/src/WpImageZoom.vue +544 -0
- package/src/WpNumber.vue +183 -0
- package/src/WpSegmented.vue +175 -0
- package/src/WpSelect.vue +89 -0
- package/src/WpSwitch.vue +163 -0
- package/src/WpTabs.vue +188 -0
- package/src/_modal-motion.css +33 -0
- package/src/_skeleton.css +50 -0
- package/src/axis-color.ts +42 -0
- package/src/button.css +169 -0
- package/src/calendar.ts +59 -0
- package/src/clipboard.ts +50 -0
- package/src/colour-presets.ts +12 -0
- package/src/diff.ts +51 -0
- package/src/drop-zone.ts +41 -0
- package/src/flip.ts +237 -0
- package/src/floating.ts +99 -0
- package/src/hsv.ts +100 -0
- package/src/icon-catalog.ts +52 -0
- package/src/image-zoom.ts +93 -0
- package/src/logo-paths.ts +21 -0
- package/src/matrix-axis.ts +91 -0
- package/src/pagination.ts +27 -0
- package/src/platform-keys.ts +29 -0
- package/src/rich-tokenize.ts +357 -0
- package/src/select-types.ts +32 -0
- package/src/slider-math.ts +45 -0
- package/src/sortable.ts +22 -0
- package/src/theme/fonts/OFL-Fraunces.txt +93 -0
- package/src/theme/fonts/OFL-JetBrainsMono.txt +93 -0
- package/src/theme/fonts/OFL-Manrope.txt +93 -0
- package/src/theme/fonts/fraunces-latin-wght-normal.woff2 +0 -0
- package/src/theme/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/src/theme/fonts/manrope-latin-wght-normal.woff2 +0 -0
- package/src/theme/looks.css +428 -0
- package/src/theme/palettes.css +254 -0
- package/src/theme/tokens.css +2894 -0
- package/src/tokens.ts +56 -0
- package/src/tooltip.css +43 -0
- package/src/tooltip.ts +146 -0
- package/src/useDialogFocus.ts +73 -0
- package/src/useGrowableField.ts +357 -0
- package/src/useModalKeyShield.ts +116 -0
- package/src/useToast.ts +48 -0
- package/src/wp-logo.svg +14 -0
package/src/Lightbox.vue
ADDED
|
@@ -0,0 +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: 0 0 0 3px 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>
|
|
@@ -0,0 +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>
|
package/src/ListRow.vue
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* One row of a list you click through: a facet ("portrait 12"), a child in a
|
|
4
|
+
* bundle's list, a category in a tree, a dependency that opens its page. The
|
|
5
|
+
* whole row is the target, full width, with an optional icon (or `#start`
|
|
6
|
+
* for anything else, e.g. a KindChip), a label, a second `description` line,
|
|
7
|
+
* and a `count` or `#end` at the right; `arrow` adds a "goes somewhere"
|
|
8
|
+
* arrow that moves on hover.
|
|
9
|
+
*
|
|
10
|
+
* <ListRow :selected="tag === t" toggle icon="tag" :count="12" @click="pick(t)">portrait</ListRow>
|
|
11
|
+
* <ListRow :selected="current === c.key" :icon="kindIcon(c.type)" :description="summary(c)" :muted="!c.enabled">{{ c.name }}</ListRow>
|
|
12
|
+
* <ListRow :as="RouterLink" :to="`/p/${dep.slug}`" arrow><template #start><KindChip … /></template>{{ dep.slug }}</ListRow>
|
|
13
|
+
*
|
|
14
|
+
* `selected` marks the row in the accent with a bar on its left. A row that
|
|
15
|
+
* turns a filter on and off says so with `aria-pressed` (pass `toggle`);
|
|
16
|
+
* otherwise a selected row is `aria-current`, the one being shown. `muted`
|
|
17
|
+
* dims a row that is still clickable (a child that is switched off);
|
|
18
|
+
* `disabled` stops it. `depth` indents a row for a simple tree.
|
|
19
|
+
*
|
|
20
|
+
* ArrowUp / ArrowDown / Home / End move between the rows of the same list:
|
|
21
|
+
* the nearest ancestor with `data-wp-rows` (put it on the outer element of a
|
|
22
|
+
* nested tree), else the row's parent. It reads only the shared tokens, so it
|
|
23
|
+
* works on a page and on the canvas.
|
|
24
|
+
*/
|
|
25
|
+
import { computed, useSlots, type Component } from "vue";
|
|
26
|
+
import Icon from "./Icon.vue";
|
|
27
|
+
|
|
28
|
+
const props = withDefaults(defineProps<{
|
|
29
|
+
/** What to render: a button (default), `"a"`, or a link component such as RouterLink. */
|
|
30
|
+
as?: string | Component | undefined;
|
|
31
|
+
selected?: boolean | undefined;
|
|
32
|
+
/** The row turns something on and off: `aria-pressed` instead of `aria-current`. */
|
|
33
|
+
toggle?: boolean | undefined;
|
|
34
|
+
/** PrimeIcons name (`tag` or `pi-tag`) before the label. */
|
|
35
|
+
icon?: string | undefined;
|
|
36
|
+
/** Colour of the icon: any CSS colour. */
|
|
37
|
+
iconColor?: string | undefined;
|
|
38
|
+
/** A second, smaller line under the label. */
|
|
39
|
+
description?: string | undefined;
|
|
40
|
+
count?: number | string | undefined;
|
|
41
|
+
/** An arrow at the right end, for a row that opens another page. */
|
|
42
|
+
arrow?: boolean | undefined;
|
|
43
|
+
/** Dim the row; it stays clickable. */
|
|
44
|
+
muted?: boolean | undefined;
|
|
45
|
+
disabled?: boolean | undefined;
|
|
46
|
+
/** Indent level for a simple tree (0 = top). */
|
|
47
|
+
depth?: number | undefined;
|
|
48
|
+
size?: "sm" | "md" | undefined;
|
|
49
|
+
}>(), { as: "button", size: "md", depth: 0 });
|
|
50
|
+
|
|
51
|
+
const slots = useSlots();
|
|
52
|
+
const isButton = computed(() => props.as === "button");
|
|
53
|
+
|
|
54
|
+
const aria = computed(() => {
|
|
55
|
+
const a: Record<string, string | boolean | undefined> = {};
|
|
56
|
+
if (props.toggle) a["aria-pressed"] = !!props.selected;
|
|
57
|
+
else if (props.selected) a["aria-current"] = "true";
|
|
58
|
+
if (props.disabled && !isButton.value) a["aria-disabled"] = "true";
|
|
59
|
+
return a;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const style = computed(() => {
|
|
63
|
+
const s: Record<string, string> = {};
|
|
64
|
+
if (props.depth) s["--wp-list-row-depth"] = String(props.depth);
|
|
65
|
+
if (props.iconColor) s["--wp-list-row-icon"] = props.iconColor;
|
|
66
|
+
return Object.keys(s).length ? s : undefined;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
function onKey(e: KeyboardEvent): void {
|
|
70
|
+
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return;
|
|
71
|
+
const el = e.currentTarget as HTMLElement;
|
|
72
|
+
const list = el.closest("[data-wp-rows]") ?? el.parentElement;
|
|
73
|
+
if (!list) return;
|
|
74
|
+
const rows = Array.from(list.querySelectorAll<HTMLElement>(".wp-list-row"))
|
|
75
|
+
.filter((r) => !r.matches(":disabled, [aria-disabled='true']"));
|
|
76
|
+
const at = rows.indexOf(el);
|
|
77
|
+
if (at < 0) return;
|
|
78
|
+
const next = e.key === "Home" ? 0
|
|
79
|
+
: e.key === "End" ? rows.length - 1
|
|
80
|
+
: e.key === "ArrowDown" ? Math.min(rows.length - 1, at + 1)
|
|
81
|
+
: Math.max(0, at - 1);
|
|
82
|
+
e.preventDefault();
|
|
83
|
+
rows[next]?.focus();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function onClick(e: MouseEvent): void {
|
|
87
|
+
// A link can't be disabled natively; stop it here.
|
|
88
|
+
if (props.disabled && !isButton.value) {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
e.stopImmediatePropagation();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
<template>
|
|
96
|
+
<component
|
|
97
|
+
:is="as"
|
|
98
|
+
class="wp-list-row"
|
|
99
|
+
:class="[`wp-list-row--${size}`, { 'wp-list-row--muted': muted, 'wp-list-row--two-line': !!description || !!slots.description }]"
|
|
100
|
+
:type="isButton ? 'button' : undefined"
|
|
101
|
+
:disabled="isButton ? disabled : undefined"
|
|
102
|
+
:data-selected="selected || undefined"
|
|
103
|
+
:style="style"
|
|
104
|
+
v-bind="aria"
|
|
105
|
+
@keydown="onKey"
|
|
106
|
+
@click.capture="onClick"
|
|
107
|
+
>
|
|
108
|
+
<span v-if="slots.start" class="wp-list-row__start"><slot name="start" /></span>
|
|
109
|
+
<Icon v-else-if="icon" class="wp-list-row__icon" :name="icon" />
|
|
110
|
+
<span class="wp-list-row__main">
|
|
111
|
+
<span class="wp-list-row__label"><slot /></span>
|
|
112
|
+
<span v-if="description || slots.description" class="wp-list-row__desc">
|
|
113
|
+
<slot name="description">{{ description }}</slot>
|
|
114
|
+
</span>
|
|
115
|
+
</span>
|
|
116
|
+
<span v-if="count !== undefined" class="wp-list-row__count">{{ count }}</span>
|
|
117
|
+
<span v-if="slots.end" class="wp-list-row__end"><slot name="end" /></span>
|
|
118
|
+
<Icon v-if="arrow" class="wp-list-row__arrow" name="arrow-right" />
|
|
119
|
+
</component>
|
|
120
|
+
</template>
|
|
121
|
+
|
|
122
|
+
<style scoped>
|
|
123
|
+
.wp-list-row {
|
|
124
|
+
--wp-list-row-icon: var(--wp-text-muted);
|
|
125
|
+
display: flex;
|
|
126
|
+
align-items: center;
|
|
127
|
+
gap: var(--wp-space-3);
|
|
128
|
+
box-sizing: border-box;
|
|
129
|
+
width: 100%;
|
|
130
|
+
min-height: var(--wp-control-h-md);
|
|
131
|
+
margin: 0;
|
|
132
|
+
padding: var(--wp-space-2) var(--wp-space-4);
|
|
133
|
+
padding-left: calc(var(--wp-space-4) + var(--wp-list-row-depth, 0) * var(--wp-space-6));
|
|
134
|
+
border: 0;
|
|
135
|
+
border-radius: min(6px, var(--wp-radius-cap, 999px));
|
|
136
|
+
background: transparent;
|
|
137
|
+
color: var(--wp-text);
|
|
138
|
+
font: inherit;
|
|
139
|
+
font-family: var(--wp-font-sans);
|
|
140
|
+
font-size: var(--wp-text-sm);
|
|
141
|
+
line-height: 1.35;
|
|
142
|
+
text-align: left;
|
|
143
|
+
text-decoration: none;
|
|
144
|
+
cursor: pointer;
|
|
145
|
+
transition: background var(--wp-motion-quick) ease, color var(--wp-motion-quick) ease;
|
|
146
|
+
}
|
|
147
|
+
.wp-list-row--sm {
|
|
148
|
+
min-height: var(--wp-control-h-sm);
|
|
149
|
+
padding-top: var(--wp-space-1);
|
|
150
|
+
padding-bottom: var(--wp-space-1);
|
|
151
|
+
gap: var(--wp-space-2);
|
|
152
|
+
color: var(--wp-text-muted);
|
|
153
|
+
font-size: var(--wp-text-xs);
|
|
154
|
+
}
|
|
155
|
+
.wp-list-row--two-line { align-items: flex-start; }
|
|
156
|
+
.wp-list-row:hover:not(:disabled) { background: var(--wp-row-hover); color: var(--wp-text); }
|
|
157
|
+
.wp-list-row:focus-visible { outline: none; box-shadow: var(--wp-focus-ring); }
|
|
158
|
+
.wp-list-row:disabled, .wp-list-row[aria-disabled="true"] { opacity: 0.45; cursor: not-allowed; }
|
|
159
|
+
.wp-list-row--muted { opacity: 0.55; }
|
|
160
|
+
.wp-list-row[data-selected] {
|
|
161
|
+
background: color-mix(in oklab, var(--wp-accent-500) 12%, transparent);
|
|
162
|
+
box-shadow: inset 2px 0 var(--wp-accent-500);
|
|
163
|
+
color: var(--wp-accent-text-strong);
|
|
164
|
+
}
|
|
165
|
+
.wp-list-row[data-selected]:focus-visible { box-shadow: inset 2px 0 var(--wp-accent-500), var(--wp-focus-ring); }
|
|
166
|
+
.wp-list-row__icon {
|
|
167
|
+
flex: none;
|
|
168
|
+
width: 1.1em;
|
|
169
|
+
font-size: 0.9em;
|
|
170
|
+
text-align: center;
|
|
171
|
+
color: var(--wp-list-row-icon);
|
|
172
|
+
}
|
|
173
|
+
.wp-list-row--two-line .wp-list-row__icon { padding-top: 0.25em; }
|
|
174
|
+
.wp-list-row[data-selected] .wp-list-row__icon { color: var(--wp-accent-text); }
|
|
175
|
+
.wp-list-row__start { display: inline-flex; align-items: center; flex: none; }
|
|
176
|
+
.wp-list-row__main { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
|
177
|
+
.wp-list-row__label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
178
|
+
.wp-list-row__desc {
|
|
179
|
+
color: var(--wp-text-dim);
|
|
180
|
+
font-size: var(--wp-text-xs);
|
|
181
|
+
overflow-wrap: anywhere;
|
|
182
|
+
}
|
|
183
|
+
.wp-list-row__count {
|
|
184
|
+
flex: none;
|
|
185
|
+
color: var(--wp-text-dim);
|
|
186
|
+
font-family: var(--wp-font-mono);
|
|
187
|
+
font-size: var(--wp-text-xs);
|
|
188
|
+
font-variant-numeric: tabular-nums;
|
|
189
|
+
}
|
|
190
|
+
.wp-list-row[data-selected] .wp-list-row__count { color: inherit; }
|
|
191
|
+
.wp-list-row__end { display: inline-flex; align-items: center; gap: var(--wp-space-2); flex: none; }
|
|
192
|
+
.wp-list-row__arrow {
|
|
193
|
+
flex: none;
|
|
194
|
+
align-self: center;
|
|
195
|
+
font-size: 0.85em;
|
|
196
|
+
color: var(--wp-text-dim);
|
|
197
|
+
transition: transform var(--wp-motion-quick) ease, color var(--wp-motion-quick) ease;
|
|
198
|
+
}
|
|
199
|
+
.wp-list-row:hover .wp-list-row__arrow { color: var(--wp-accent-text); transform: translateX(2px); }
|
|
200
|
+
@media (prefers-reduced-motion: reduce) {
|
|
201
|
+
.wp-list-row, .wp-list-row__arrow { transition: none; }
|
|
202
|
+
.wp-list-row:hover .wp-list-row__arrow { transform: none; }
|
|
203
|
+
}
|
|
204
|
+
</style>
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/** Gradient ids must be unique on the page. `useId` restarts per Vue app and
|
|
3
|
+
* the extension mounts many small apps, so count across all of them. */
|
|
4
|
+
let nextId = 0;
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<script setup lang="ts">
|
|
8
|
+
/**
|
|
9
|
+
* The loading mark. Two drawings, one per kind of place:
|
|
10
|
+
*
|
|
11
|
+
* - `logo` (pages and panels): the full logo. Its two rings slide apart and
|
|
12
|
+
* back while the big star dips and turns and the small stars twinkle. A
|
|
13
|
+
* `block` mark, or one 24 px or bigger, gets it by default.
|
|
14
|
+
* - `sparkle` (tight inline spots: a text row, a button, a list item): the
|
|
15
|
+
* logo's three stars without the rings, the big one dipping while the small
|
|
16
|
+
* ones swap places, like the AI working line. Small marks get it by
|
|
17
|
+
* default, so an inline "Loading bundle…" is not a big colourful logo.
|
|
18
|
+
*
|
|
19
|
+
* Alone it is an icon (`size`, 14 inline and 40 as a block). With slot text it
|
|
20
|
+
* becomes a row ("[mark] Loading bundle…"); with `block` it is centred in its
|
|
21
|
+
* box, for a panel or page that is loading. `mono` draws it in the text
|
|
22
|
+
* colour, for a host with its own colours (a busy button).
|
|
23
|
+
*/
|
|
24
|
+
import { computed, useSlots } from "vue";
|
|
25
|
+
import { BRAND_COLOURS, RING_A, RING_B, STAR_A, STAR_B, STAR_BIG, type LogoBrand } from "./logo-paths";
|
|
26
|
+
|
|
27
|
+
const props = withDefaults(defineProps<{
|
|
28
|
+
/** Width and height in px. Default 14, or 40 with `block`. */
|
|
29
|
+
size?: number | undefined;
|
|
30
|
+
/** Accessible name when there is no slot text. */
|
|
31
|
+
label?: string | undefined;
|
|
32
|
+
/** Centre it (and its text, below) in the space it is given. */
|
|
33
|
+
block?: boolean | undefined;
|
|
34
|
+
/** Which drawing; default `logo` for a block or a 24 px+ mark, else `sparkle`. */
|
|
35
|
+
variant?: "logo" | "sparkle" | undefined;
|
|
36
|
+
/** Draw in the current text colour instead of the logo colours. */
|
|
37
|
+
mono?: boolean | undefined;
|
|
38
|
+
/** Hidden from screen readers, for a host that says it is busy itself
|
|
39
|
+
* (a button with `aria-busy`). */
|
|
40
|
+
decorative?: boolean | undefined;
|
|
41
|
+
/** Whose colours: the extension's (default) or the community site's. */
|
|
42
|
+
brand?: LogoBrand | undefined;
|
|
43
|
+
}>(), { size: undefined, label: "Loading", block: false, variant: undefined, mono: false, decorative: false, brand: "extension" });
|
|
44
|
+
|
|
45
|
+
const slots = useSlots();
|
|
46
|
+
const id = `wp-lm-${nextId++}`;
|
|
47
|
+
const px = computed(() => props.size ?? (props.block ? 40 : 14));
|
|
48
|
+
const drawing = computed(() => props.variant ?? (props.block || px.value >= 24 ? "logo" : "sparkle"));
|
|
49
|
+
/** The logo's own colours (brand, not theme tokens: the mark looks the same
|
|
50
|
+
* in light and dark). */
|
|
51
|
+
const VIOLET = computed(() => BRAND_COLOURS[props.brand][0]);
|
|
52
|
+
const INDIGO = computed(() => BRAND_COLOURS[props.brand][1]);
|
|
53
|
+
const TEAL = computed(() => BRAND_COLOURS[props.brand][2]);
|
|
54
|
+
const fill = (colour: string): string => (props.mono ? "currentColor" : colour);
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<template>
|
|
58
|
+
<span
|
|
59
|
+
class="wp-lm"
|
|
60
|
+
:class="{ 'wp-lm--block': block }"
|
|
61
|
+
:role="decorative ? undefined : 'status'"
|
|
62
|
+
:aria-label="decorative || slots.default ? undefined : label"
|
|
63
|
+
:aria-hidden="decorative || undefined"
|
|
64
|
+
data-test="loading-mark"
|
|
65
|
+
>
|
|
66
|
+
<svg
|
|
67
|
+
class="wp-lm__svg"
|
|
68
|
+
:data-variant="drawing"
|
|
69
|
+
viewBox="0 0 1024 1024"
|
|
70
|
+
:width="px"
|
|
71
|
+
:height="px"
|
|
72
|
+
aria-hidden="true"
|
|
73
|
+
>
|
|
74
|
+
<template v-if="drawing === 'logo'">
|
|
75
|
+
<defs v-if="!mono">
|
|
76
|
+
<linearGradient :id="`${id}a`" x1="312.56" y1="897.19" x2="312.56" y2="2" gradientUnits="userSpaceOnUse">
|
|
77
|
+
<stop offset="0" :stop-color="VIOLET" />
|
|
78
|
+
<stop offset="1" :stop-color="INDIGO" />
|
|
79
|
+
</linearGradient>
|
|
80
|
+
<linearGradient :id="`${id}b`" x1="711.44" y1="126.81" x2="711.44" y2="1022" gradientUnits="userSpaceOnUse">
|
|
81
|
+
<stop offset="0" :stop-color="INDIGO" />
|
|
82
|
+
<stop offset="1" :stop-color="TEAL" />
|
|
83
|
+
</linearGradient>
|
|
84
|
+
</defs>
|
|
85
|
+
<path class="wp-lm__ring wp-lm__ring--a" :fill="fill(`url(#${id}a)`)" :d="RING_A" />
|
|
86
|
+
<path class="wp-lm__ring wp-lm__ring--b" :fill="fill(`url(#${id}b)`)" :d="RING_B" />
|
|
87
|
+
<path class="wp-lm__star wp-lm__star--big" :fill="fill(VIOLET)" :d="STAR_BIG" />
|
|
88
|
+
<path class="wp-lm__star wp-lm__star--tw" :fill="fill(INDIGO)" :d="STAR_A" />
|
|
89
|
+
<path class="wp-lm__star wp-lm__star--tw" :fill="fill(TEAL)" :d="STAR_B" />
|
|
90
|
+
</template>
|
|
91
|
+
<g v-else transform="translate(512 512) scale(1.45) translate(-512 -512)">
|
|
92
|
+
<path class="wp-lm__star wp-lm__star--big" :fill="fill(VIOLET)" :d="STAR_BIG" />
|
|
93
|
+
<g class="wp-lm__swap">
|
|
94
|
+
<path class="wp-lm__star" :fill="fill(INDIGO)" :d="STAR_A" />
|
|
95
|
+
<path class="wp-lm__star" :fill="fill(TEAL)" :d="STAR_B" />
|
|
96
|
+
</g>
|
|
97
|
+
</g>
|
|
98
|
+
</svg>
|
|
99
|
+
<span v-if="slots.default" class="wp-lm__text"><slot /></span>
|
|
100
|
+
</span>
|
|
101
|
+
</template>
|
|
102
|
+
|
|
103
|
+
<style scoped>
|
|
104
|
+
.wp-lm {
|
|
105
|
+
--wp-lm-t: 2.4s;
|
|
106
|
+
display: inline-flex;
|
|
107
|
+
align-items: center;
|
|
108
|
+
gap: 8px;
|
|
109
|
+
vertical-align: middle;
|
|
110
|
+
}
|
|
111
|
+
.wp-lm--block {
|
|
112
|
+
display: flex;
|
|
113
|
+
flex-direction: column;
|
|
114
|
+
justify-content: center;
|
|
115
|
+
gap: 12px;
|
|
116
|
+
width: 100%;
|
|
117
|
+
padding: 48px 16px;
|
|
118
|
+
color: var(--wp-text-muted);
|
|
119
|
+
text-align: center;
|
|
120
|
+
}
|
|
121
|
+
.wp-lm__svg { flex-shrink: 0; overflow: visible; }
|
|
122
|
+
.wp-lm__ring { transform-box: view-box; transform-origin: 512px 512px; }
|
|
123
|
+
.wp-lm__star { transform-box: fill-box; transform-origin: center; }
|
|
124
|
+
.wp-lm__ring--a { animation: wp-lm-split-a var(--wp-lm-t) cubic-bezier(0.65, 0, 0.35, 1) infinite; }
|
|
125
|
+
.wp-lm__ring--b { animation: wp-lm-split-b var(--wp-lm-t) cubic-bezier(0.65, 0, 0.35, 1) infinite; }
|
|
126
|
+
.wp-lm__star--big { animation: wp-lm-dip var(--wp-lm-t) cubic-bezier(0.65, 0, 0.35, 1) infinite; }
|
|
127
|
+
.wp-lm__star--tw { animation: wp-lm-twinkle var(--wp-lm-t) ease-in-out infinite; }
|
|
128
|
+
.wp-lm__swap {
|
|
129
|
+
transform-box: view-box; transform-origin: 512px 512px;
|
|
130
|
+
animation: wp-lm-swap calc(var(--wp-lm-t) * 2) cubic-bezier(0.65, 0, 0.35, 1) infinite;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@keyframes wp-lm-split-a {
|
|
134
|
+
0%, 15%, 85%, 100% { transform: translate(0, 0); }
|
|
135
|
+
40%, 60% { transform: translate(-70px, -70px); }
|
|
136
|
+
}
|
|
137
|
+
@keyframes wp-lm-split-b {
|
|
138
|
+
0%, 15%, 85%, 100% { transform: translate(0, 0); }
|
|
139
|
+
40%, 60% { transform: translate(70px, 70px); }
|
|
140
|
+
}
|
|
141
|
+
@keyframes wp-lm-dip {
|
|
142
|
+
0%, 15% { transform: scale(1) rotate(0deg); }
|
|
143
|
+
32% { transform: scale(0.7) rotate(45deg); }
|
|
144
|
+
50%, 100% { transform: scale(1) rotate(90deg); }
|
|
145
|
+
}
|
|
146
|
+
@keyframes wp-lm-swap {
|
|
147
|
+
0%, 15% { transform: rotate(0deg); }
|
|
148
|
+
50%, 65% { transform: rotate(180deg); }
|
|
149
|
+
100% { transform: rotate(360deg); }
|
|
150
|
+
}
|
|
151
|
+
@keyframes wp-lm-twinkle {
|
|
152
|
+
0%, 100% { opacity: 1; transform: scale(1); }
|
|
153
|
+
50% { opacity: 0.35; transform: scale(0.6); }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
@media (prefers-reduced-motion: reduce) {
|
|
157
|
+
.wp-lm__svg * { animation: none !important; }
|
|
158
|
+
.wp-lm__svg { animation: wp-lm-fade 2.4s ease-in-out infinite; }
|
|
159
|
+
@keyframes wp-lm-fade { 50% { opacity: 0.55; } }
|
|
160
|
+
}
|
|
161
|
+
</style>
|