@dumiflex/wp-kit 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -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/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/Combobox.vue +10 -14
- package/src/ConfirmModal.vue +12 -22
- 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/Disclosure.vue +2 -1
- package/src/Drawer.vue +2 -2
- package/src/DropZone.vue +2 -2
- package/src/HsvPicker.vue +2 -2
- package/src/IconPicker.vue +1 -1
- package/src/ImageGrid.vue +2 -2
- package/src/Lightbox.vue +2 -2
- package/src/Modal.vue +109 -44
- package/src/ModalShell.vue +26 -5
- package/src/Pagination.vue +4 -4
- package/src/Popover.vue +68 -12
- package/src/RadioGroup.vue +5 -4
- package/src/SearchInput.vue +1 -1
- package/src/Select.vue +53 -158
- 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/WorkingLine.vue +320 -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/floating.ts +5 -0
- package/src/hsv.ts +1 -1
- package/src/menu.css +51 -0
- package/src/motion.css +73 -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 +8 -3
- package/src/tooltip.css +8 -5
- package/src/useDialogFocus.ts +11 -2
- 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
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* WorkingLine: "the model is working", in one line like Claude Code's. A
|
|
4
|
+
* small mark drawn from the logo's sparkles, a word that crossfades when it
|
|
5
|
+
* changes, and the time so far. It only draws: the app decides the word,
|
|
6
|
+
* the time and the tempo (the extension's AiWorking cycles playful words
|
|
7
|
+
* and the real status, and reads the request's phase).
|
|
8
|
+
*
|
|
9
|
+
* <WorkingLine word="Thinking" time="12s" tempo="mid" />
|
|
10
|
+
* <WorkingLine :word="word" :word-key="phase" variant="orbit" inline />
|
|
11
|
+
*
|
|
12
|
+
* Marks (`variant`): typing (three stars hop in turn), shuffle (the small
|
|
13
|
+
* stars swap places round the big one), orbit (the logo's four nodes circle
|
|
14
|
+
* the stars) or burst (the small stars fly out of the big one and back).
|
|
15
|
+
* `tempo` sets their speed: slow while the model reads, mid while it thinks,
|
|
16
|
+
* fast while it writes.
|
|
17
|
+
*
|
|
18
|
+
* The word crossfades in place when `wordKey` changes (default: the word),
|
|
19
|
+
* so a token count ticking up can keep its key and not flicker. Its box is
|
|
20
|
+
* never narrower than the word: it widens at once and narrows smoothly once
|
|
21
|
+
* the old word has mostly faded.
|
|
22
|
+
*
|
|
23
|
+
* Every mark animates registered custom properties, never `transform` or
|
|
24
|
+
* `opacity` themselves, so the paths repaint sharp each frame instead of
|
|
25
|
+
* going to a compositor layer that blurs them and snaps them to whole pixels.
|
|
26
|
+
*/
|
|
27
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
28
|
+
import { BRAND_COLOURS, STAR_A, STAR_B, STAR_BIG, type LogoBrand } from "./logo-paths";
|
|
29
|
+
|
|
30
|
+
export type WorkingVariant = "typing" | "shuffle" | "orbit" | "burst";
|
|
31
|
+
|
|
32
|
+
const props = withDefaults(defineProps<{
|
|
33
|
+
/** What is shown, without the "…" (the line adds it). */
|
|
34
|
+
word: string;
|
|
35
|
+
/** What the crossfade keys on; defaults to the word. */
|
|
36
|
+
wordKey?: string | undefined;
|
|
37
|
+
/** Time so far, already formatted ("42s"). Left out, no time shows. */
|
|
38
|
+
time?: string | undefined;
|
|
39
|
+
variant?: WorkingVariant | undefined;
|
|
40
|
+
tempo?: "slow" | "mid" | "fast" | undefined;
|
|
41
|
+
/** The mark's size in px. */
|
|
42
|
+
size?: number | undefined;
|
|
43
|
+
/** Smaller text and a capped word width, for a top bar. */
|
|
44
|
+
inline?: boolean | undefined;
|
|
45
|
+
/** The whole status, for screen readers and the tooltip; defaults to the word. */
|
|
46
|
+
label?: string | undefined;
|
|
47
|
+
brand?: LogoBrand | undefined;
|
|
48
|
+
}>(), { variant: "typing", tempo: "slow", size: 22, inline: false, brand: "extension" });
|
|
49
|
+
|
|
50
|
+
const key = computed(() => props.wordKey ?? props.word);
|
|
51
|
+
const name = computed(() => props.label ?? props.word);
|
|
52
|
+
|
|
53
|
+
const colours = computed(() => BRAND_COLOURS[props.brand]);
|
|
54
|
+
const BIG = computed(() => colours.value[0]);
|
|
55
|
+
const A = computed(() => colours.value[1]);
|
|
56
|
+
const B = computed(() => colours.value[2]);
|
|
57
|
+
/** The logo's four nodes for the orbit, with steps between the colours. */
|
|
58
|
+
const nodes = computed(() => [
|
|
59
|
+
[911, 512, B.value],
|
|
60
|
+
[512, 911, `color-mix(in srgb, ${B.value} 70%, ${A.value})`],
|
|
61
|
+
[113, 512, `color-mix(in srgb, ${BIG.value} 60%, ${A.value})`],
|
|
62
|
+
[512, 113, A.value],
|
|
63
|
+
] as const);
|
|
64
|
+
/** Typing stars: the big sparkle drawn small three times in a row. */
|
|
65
|
+
const typing = computed(() => [[250, A.value], [512, BIG.value], [774, B.value]] as const);
|
|
66
|
+
|
|
67
|
+
const measure = ref<HTMLElement | null>(null);
|
|
68
|
+
const boxWidth = ref<number | null>(null);
|
|
69
|
+
const growing = ref(false);
|
|
70
|
+
async function fit(): Promise<void> {
|
|
71
|
+
await nextTick();
|
|
72
|
+
// Rounded up, plus a pixel to spare: one pixel short is enough for the
|
|
73
|
+
// browser to swap the word's end for its own "…", which Firefox draws
|
|
74
|
+
// invisibly on this gradient text.
|
|
75
|
+
const raw = measure.value?.getBoundingClientRect().width ?? 0;
|
|
76
|
+
const w = raw ? Math.ceil(raw) + 1 : 0;
|
|
77
|
+
if (!w || w === boxWidth.value) return;
|
|
78
|
+
growing.value = boxWidth.value === null || w > boxWidth.value;
|
|
79
|
+
boxWidth.value = w;
|
|
80
|
+
}
|
|
81
|
+
watch(() => props.word, () => { void fit(); });
|
|
82
|
+
|
|
83
|
+
// The word's width can also change on its own, when the font loads late.
|
|
84
|
+
let resized: ResizeObserver | null = null;
|
|
85
|
+
onMounted(() => {
|
|
86
|
+
void fit();
|
|
87
|
+
if (typeof ResizeObserver !== "undefined" && measure.value) {
|
|
88
|
+
resized = new ResizeObserver(() => { void fit(); });
|
|
89
|
+
resized.observe(measure.value);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
onBeforeUnmount(() => resized?.disconnect());
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
<template>
|
|
96
|
+
<div
|
|
97
|
+
class="wp-wl"
|
|
98
|
+
:data-tempo="tempo"
|
|
99
|
+
:data-variant="variant"
|
|
100
|
+
:data-inline="inline ? 'true' : undefined"
|
|
101
|
+
role="status"
|
|
102
|
+
:aria-label="name"
|
|
103
|
+
:title="name"
|
|
104
|
+
:style="{ '--wp-wl-big': BIG, '--wp-wl-a': A, '--wp-wl-b': B }"
|
|
105
|
+
>
|
|
106
|
+
<svg class="wp-wl__logo" viewBox="0 0 1024 1024" :width="size" :height="size" aria-hidden="true">
|
|
107
|
+
<template v-if="variant === 'typing'">
|
|
108
|
+
<g v-for="([x, c], i) in typing" :key="i" class="wp-wl__hop" :style="{ '--wp-wl-k': i }">
|
|
109
|
+
<path class="wp-wl__star" :style="{ fill: c }" :transform="`translate(${x - 256} 256) scale(.5)`" :d="STAR_BIG" />
|
|
110
|
+
</g>
|
|
111
|
+
</template>
|
|
112
|
+
<g v-else-if="variant === 'shuffle'" transform="translate(512 512) scale(1.45) translate(-512 -512)">
|
|
113
|
+
<path class="wp-wl__star wp-wl__star--big wp-wl__dip" :style="{ fill: BIG }" :d="STAR_BIG" />
|
|
114
|
+
<g class="wp-wl__vb wp-wl__swap">
|
|
115
|
+
<path class="wp-wl__star" :style="{ fill: A }" :d="STAR_A" />
|
|
116
|
+
<path class="wp-wl__star" :style="{ fill: B }" :d="STAR_B" />
|
|
117
|
+
</g>
|
|
118
|
+
</g>
|
|
119
|
+
<template v-else-if="variant === 'orbit'">
|
|
120
|
+
<g class="wp-wl__vb wp-wl__spin">
|
|
121
|
+
<circle
|
|
122
|
+
v-for="([x, y, c], i) in nodes" :key="i" class="wp-wl__dot"
|
|
123
|
+
:style="{ '--wp-wl-k': i, fill: c }" :cx="x" :cy="y" r="70"
|
|
124
|
+
/>
|
|
125
|
+
</g>
|
|
126
|
+
<path class="wp-wl__star wp-wl__star--big wp-wl__breathe" :style="{ fill: BIG }" :d="STAR_BIG" />
|
|
127
|
+
<path class="wp-wl__star" :style="{ fill: A }" :d="STAR_A" />
|
|
128
|
+
<path class="wp-wl__star" :style="{ fill: B }" :d="STAR_B" />
|
|
129
|
+
</template>
|
|
130
|
+
<g v-else transform="translate(512 512) scale(1.3) translate(-512 -512)">
|
|
131
|
+
<path class="wp-wl__star wp-wl__star--big wp-wl__pop" :style="{ fill: BIG }" :d="STAR_BIG" />
|
|
132
|
+
<path class="wp-wl__star wp-wl__burst" :style="{ fill: A, '--wp-wl-dx': '152px', '--wp-wl-dy': '-152px' }" :d="STAR_A" />
|
|
133
|
+
<path class="wp-wl__star wp-wl__burst" :style="{ fill: B, '--wp-wl-dx': '-152px', '--wp-wl-dy': '152px' }" :d="STAR_B" />
|
|
134
|
+
</g>
|
|
135
|
+
</svg>
|
|
136
|
+
<span
|
|
137
|
+
class="wp-wl__words"
|
|
138
|
+
:data-grow="growing ? 'true' : undefined"
|
|
139
|
+
:style="boxWidth ? { width: `${boxWidth}px` } : undefined"
|
|
140
|
+
>
|
|
141
|
+
<Transition name="wp-wl-word" type="transition">
|
|
142
|
+
<span :key="key" class="wp-wl__word" data-test="working-line-word">{{ word }}…</span>
|
|
143
|
+
</Transition>
|
|
144
|
+
<span ref="measure" class="wp-wl__word wp-wl__measure" aria-hidden="true">{{ word }}…</span>
|
|
145
|
+
</span>
|
|
146
|
+
<span v-if="time !== undefined" class="wp-wl__time" data-test="working-line-time">{{ time }}</span>
|
|
147
|
+
</div>
|
|
148
|
+
</template>
|
|
149
|
+
|
|
150
|
+
<style scoped>
|
|
151
|
+
.wp-wl {
|
|
152
|
+
--wp-wl-t: 2.8s;
|
|
153
|
+
display: flex;
|
|
154
|
+
align-items: center;
|
|
155
|
+
gap: 8px;
|
|
156
|
+
min-width: 0;
|
|
157
|
+
color: var(--wp-text-muted);
|
|
158
|
+
font-size: 13px;
|
|
159
|
+
white-space: nowrap;
|
|
160
|
+
}
|
|
161
|
+
.wp-wl[data-tempo="mid"] { --wp-wl-t: 2.2s; }
|
|
162
|
+
.wp-wl[data-tempo="fast"] { --wp-wl-t: 1.5s; }
|
|
163
|
+
.wp-wl__logo { flex-shrink: 0; overflow: visible; }
|
|
164
|
+
.wp-wl__words {
|
|
165
|
+
position: relative;
|
|
166
|
+
display: inline-flex;
|
|
167
|
+
align-items: center;
|
|
168
|
+
min-width: 0;
|
|
169
|
+
max-width: 100%;
|
|
170
|
+
overflow: hidden;
|
|
171
|
+
/* Narrows once the old word has mostly faded; widens at once. */
|
|
172
|
+
transition: width 0.35s cubic-bezier(0.25, 0.8, 0.25, 1) 0.3s;
|
|
173
|
+
}
|
|
174
|
+
.wp-wl__words[data-grow="true"] { transition: none; }
|
|
175
|
+
.wp-wl__word {
|
|
176
|
+
display: inline-block;
|
|
177
|
+
min-width: 0;
|
|
178
|
+
overflow: hidden;
|
|
179
|
+
text-overflow: ellipsis;
|
|
180
|
+
font-weight: 500;
|
|
181
|
+
/* A light band sweeps over the gradient text, like Claude's own line.
|
|
182
|
+
The band is a fixed white, like the logo's brand colours. */
|
|
183
|
+
background:
|
|
184
|
+
linear-gradient(100deg, transparent 35%, rgba(255, 255, 255, 0.75) 50%, transparent 65%) no-repeat,
|
|
185
|
+
linear-gradient(90deg, var(--wp-wl-big), var(--wp-wl-a) 45%, var(--wp-wl-b));
|
|
186
|
+
background-size: 250% 100%, 100% 100%;
|
|
187
|
+
background-position: 150% 0, 0 0;
|
|
188
|
+
-webkit-background-clip: text; background-clip: text; color: transparent;
|
|
189
|
+
animation: wp-wl-shimmer 2.6s linear infinite;
|
|
190
|
+
}
|
|
191
|
+
@keyframes wp-wl-shimmer {
|
|
192
|
+
0% { background-position: 150% 0, 0 0; }
|
|
193
|
+
70%, 100% { background-position: -50% 0, 0 0; }
|
|
194
|
+
}
|
|
195
|
+
/* Sized like the word, never seen: what the box grows or shrinks to. */
|
|
196
|
+
.wp-wl__measure {
|
|
197
|
+
position: absolute; left: 0; top: 0;
|
|
198
|
+
visibility: hidden; pointer-events: none; animation: none;
|
|
199
|
+
max-width: none; overflow: visible;
|
|
200
|
+
}
|
|
201
|
+
.wp-wl__time { flex-shrink: 0; font-variant-numeric: tabular-nums; color: var(--wp-text-dim); }
|
|
202
|
+
.wp-wl[data-inline="true"] { font-size: 12px; }
|
|
203
|
+
.wp-wl[data-inline="true"] .wp-wl__words { max-width: 240px; }
|
|
204
|
+
/* Crossfade in place: the old word fades out on top of the new one. The
|
|
205
|
+
* fade and blur run through registered custom properties (see the marks
|
|
206
|
+
* below for why): an `opacity` / `filter` transition lifts the word onto a
|
|
207
|
+
* compositor layer, which re-rasterises the text and snaps it to whole
|
|
208
|
+
* pixels as the fade begins. The leaving word keeps its exact spot (top 0,
|
|
209
|
+
* the box is one word tall), with no -50% translate to land between pixels. */
|
|
210
|
+
.wp-wl-word-enter-active, .wp-wl-word-leave-active {
|
|
211
|
+
opacity: var(--wp-wl-wo);
|
|
212
|
+
filter: blur(var(--wp-wl-wb));
|
|
213
|
+
transition: --wp-wl-wo 0.45s ease, --wp-wl-wb 0.45s ease;
|
|
214
|
+
}
|
|
215
|
+
.wp-wl-word-leave-active { position: absolute; left: 0; top: 0; }
|
|
216
|
+
.wp-wl-word-enter-from, .wp-wl-word-leave-to { --wp-wl-wo: 0; --wp-wl-wb: 2px; }
|
|
217
|
+
@media (prefers-reduced-motion: reduce) {
|
|
218
|
+
.wp-wl__word { animation: none; }
|
|
219
|
+
.wp-wl__words { transition: none; }
|
|
220
|
+
.wp-wl-word-enter-from, .wp-wl-word-leave-to { --wp-wl-wb: 0px; }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/* Every mark animates registered custom properties, never `transform` or
|
|
224
|
+
* `opacity` themselves. A `transform` / `opacity` animation goes to the
|
|
225
|
+
* compositor, which lifts each path onto its own layer as it starts:
|
|
226
|
+
* rasterised once at a fixed scale and snapped to whole pixels, so the sharp
|
|
227
|
+
* mark went soft and jumped half a pixel the moment it began to move. A
|
|
228
|
+
* custom property animates on the main thread and each frame repaints the
|
|
229
|
+
* vector paths in place, as sharp as at rest. */
|
|
230
|
+
@property --wp-wl-x { syntax: "<length>"; inherits: false; initial-value: 0px; }
|
|
231
|
+
@property --wp-wl-f { syntax: "<number>"; inherits: false; initial-value: 0; }
|
|
232
|
+
@property --wp-wl-r { syntax: "<angle>"; inherits: false; initial-value: 0deg; }
|
|
233
|
+
@property --wp-wl-s { syntax: "<number>"; inherits: false; initial-value: 1; }
|
|
234
|
+
@property --wp-wl-o { syntax: "<number>"; inherits: false; initial-value: 1; }
|
|
235
|
+
@property --wp-wl-wo { syntax: "<number>"; inherits: false; initial-value: 1; }
|
|
236
|
+
@property --wp-wl-wb { syntax: "<length>"; inherits: false; initial-value: 0px; }
|
|
237
|
+
|
|
238
|
+
.wp-wl__star, .wp-wl__dot, .wp-wl__hop { transform-box: fill-box; transform-origin: center; }
|
|
239
|
+
.wp-wl__vb { transform-box: view-box; transform-origin: 512px 512px; }
|
|
240
|
+
|
|
241
|
+
/* Typing stars: each hops in turn. */
|
|
242
|
+
.wp-wl__hop {
|
|
243
|
+
transform: translateY(var(--wp-wl-x)) scale(var(--wp-wl-s));
|
|
244
|
+
animation: wp-wl-hop var(--wp-wl-t) ease-in-out infinite;
|
|
245
|
+
animation-delay: calc(var(--wp-wl-t) * var(--wp-wl-k) * 0.15);
|
|
246
|
+
}
|
|
247
|
+
@keyframes wp-wl-hop {
|
|
248
|
+
0%, 40%, 100% { --wp-wl-x: 0px; --wp-wl-s: 1; }
|
|
249
|
+
20% { --wp-wl-x: -110px; --wp-wl-s: 1.12; }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/* Shuffle: the small stars swap places, the big one dips and turns. */
|
|
253
|
+
.wp-wl__swap {
|
|
254
|
+
transform: rotate(var(--wp-wl-r));
|
|
255
|
+
animation: wp-wl-swap calc(var(--wp-wl-t) * 2) cubic-bezier(0.65, 0, 0.35, 1) infinite;
|
|
256
|
+
}
|
|
257
|
+
.wp-wl__dip {
|
|
258
|
+
transform: rotate(var(--wp-wl-r)) scale(var(--wp-wl-s));
|
|
259
|
+
animation: wp-wl-dip var(--wp-wl-t) cubic-bezier(0.65, 0, 0.35, 1) infinite;
|
|
260
|
+
}
|
|
261
|
+
@keyframes wp-wl-swap {
|
|
262
|
+
0%, 15% { --wp-wl-r: 0deg; }
|
|
263
|
+
50%, 65% { --wp-wl-r: 180deg; }
|
|
264
|
+
100% { --wp-wl-r: 360deg; }
|
|
265
|
+
}
|
|
266
|
+
@keyframes wp-wl-dip {
|
|
267
|
+
0%, 15% { --wp-wl-s: 1; --wp-wl-r: 0deg; }
|
|
268
|
+
32% { --wp-wl-s: 0.7; --wp-wl-r: 45deg; }
|
|
269
|
+
50%, 100% { --wp-wl-s: 1; --wp-wl-r: 90deg; }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/* Orbit: the four nodes circle the stars and twinkle in turn. */
|
|
273
|
+
.wp-wl__spin {
|
|
274
|
+
transform: rotate(var(--wp-wl-r));
|
|
275
|
+
animation: wp-wl-spin calc(var(--wp-wl-t) * 2) cubic-bezier(0.5, 0.1, 0.5, 0.9) infinite;
|
|
276
|
+
}
|
|
277
|
+
.wp-wl__dot {
|
|
278
|
+
opacity: var(--wp-wl-o);
|
|
279
|
+
transform: scale(var(--wp-wl-s));
|
|
280
|
+
animation: wp-wl-twinkle var(--wp-wl-t) ease-in-out infinite;
|
|
281
|
+
animation-delay: calc(var(--wp-wl-t) * var(--wp-wl-k) * -0.25);
|
|
282
|
+
}
|
|
283
|
+
.wp-wl__breathe {
|
|
284
|
+
transform: scale(var(--wp-wl-s));
|
|
285
|
+
animation: wp-wl-breathe var(--wp-wl-t) ease-in-out infinite;
|
|
286
|
+
}
|
|
287
|
+
@keyframes wp-wl-spin { from { --wp-wl-r: 0deg; } to { --wp-wl-r: 360deg; } }
|
|
288
|
+
@keyframes wp-wl-twinkle {
|
|
289
|
+
0%, 100% { --wp-wl-o: 1; --wp-wl-s: 1; }
|
|
290
|
+
50% { --wp-wl-o: 0.35; --wp-wl-s: 0.6; }
|
|
291
|
+
}
|
|
292
|
+
@keyframes wp-wl-breathe { 0%, 100% { --wp-wl-s: 1; } 50% { --wp-wl-s: 0.86; } }
|
|
293
|
+
|
|
294
|
+
/* Burst: the small stars fly out of the big one and back. `--wp-wl-f` is
|
|
295
|
+
* how far out they are (1 = at --wp-wl-dx/dy, 0 = home). */
|
|
296
|
+
.wp-wl__burst {
|
|
297
|
+
opacity: var(--wp-wl-o);
|
|
298
|
+
transform:
|
|
299
|
+
translate(calc(var(--wp-wl-dx) * var(--wp-wl-f)), calc(var(--wp-wl-dy) * var(--wp-wl-f)))
|
|
300
|
+
scale(var(--wp-wl-s));
|
|
301
|
+
animation: wp-wl-burst var(--wp-wl-t) cubic-bezier(0.3, 0, 0.2, 1) infinite;
|
|
302
|
+
}
|
|
303
|
+
.wp-wl__pop {
|
|
304
|
+
transform: scale(var(--wp-wl-s));
|
|
305
|
+
animation: wp-wl-pop var(--wp-wl-t) ease-in-out infinite;
|
|
306
|
+
}
|
|
307
|
+
@keyframes wp-wl-burst {
|
|
308
|
+
0% { --wp-wl-f: 1; --wp-wl-s: 0; --wp-wl-o: 0; }
|
|
309
|
+
15% { --wp-wl-o: 1; }
|
|
310
|
+
45%, 75% { --wp-wl-f: 0; --wp-wl-s: 1.15; --wp-wl-o: 1; }
|
|
311
|
+
100% { --wp-wl-f: 1; --wp-wl-s: 0; --wp-wl-o: 0; }
|
|
312
|
+
}
|
|
313
|
+
@keyframes wp-wl-pop { 0%, 100% { --wp-wl-s: 1.1; } 40% { --wp-wl-s: 0.8; } }
|
|
314
|
+
|
|
315
|
+
@media (prefers-reduced-motion: reduce) {
|
|
316
|
+
.wp-wl__logo * { animation: none !important; }
|
|
317
|
+
.wp-wl__logo { opacity: var(--wp-wl-o); animation: wp-wl-fade 2.4s ease-in-out infinite; }
|
|
318
|
+
@keyframes wp-wl-fade { 0%, 100% { --wp-wl-o: 1; } 50% { --wp-wl-o: 0.55; } }
|
|
319
|
+
}
|
|
320
|
+
</style>
|
package/src/WpIconButton.vue
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* WpIconButton: the 20 px square icon button inside a canvas row (module,
|
|
4
4
|
* injector, bundle and model rows, the Context toolbar). Rows are too tight
|
|
5
|
-
* for the 24 px `
|
|
5
|
+
* for the 24 px `Button size="sm"`, so this is the one smaller control on
|
|
6
6
|
* the canvas, still at the 20 px hit-target floor.
|
|
7
7
|
*
|
|
8
8
|
* <WpIconButton icon="pi-trash" label="Remove module" tone="danger" @click="remove" />
|
|
@@ -75,7 +75,8 @@ const emit = defineEmits<{ click: [evt: MouseEvent] }>();
|
|
|
75
75
|
.wp-icon-btn[data-tone="accent"] { --tone: var(--wp-accent); }
|
|
76
76
|
.wp-icon-btn[data-tone="warn"] { --tone: var(--wp-warn); }
|
|
77
77
|
.wp-icon-btn[data-tone="danger"] { --tone: var(--wp-danger); }
|
|
78
|
-
|
|
78
|
+
/* Even icon in the 18px inside of the box: an odd size sits half a pixel off. */
|
|
79
|
+
.wp-icon-btn .pi { font-size: 12px; }
|
|
79
80
|
.wp-icon-btn__glyph { font: 600 11px/1 var(--wp-font-mono); }
|
|
80
81
|
.wp-icon-btn:hover:not(:disabled) { background: var(--wp-bg-4); color: var(--tone); }
|
|
81
82
|
.wp-icon-btn:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--wp-border-focus); }
|
package/src/WpImageZoom.vue
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* <WpImageZoom v-model:zoom="zoom" :src="url" alt="Frame 3" />
|
|
26
26
|
*/
|
|
27
27
|
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
|
28
|
-
import
|
|
28
|
+
import Button from "./Button.vue";
|
|
29
29
|
import {
|
|
30
30
|
centreOf, clampView, fitScale, percent, stepScale, viewAround, visibleRect, zoomAt, IDLE_MS, type Size, type View,
|
|
31
31
|
} from "./image-zoom";
|
|
@@ -298,13 +298,13 @@ const at = (s: number): boolean => !isFit.value && Math.abs(view.value.scale - s
|
|
|
298
298
|
@focusout="chromeLeave"
|
|
299
299
|
>
|
|
300
300
|
<slot name="bar-start" />
|
|
301
|
-
<
|
|
302
|
-
<
|
|
303
|
-
<
|
|
304
|
-
<
|
|
305
|
-
<
|
|
301
|
+
<Button variant="ghost" :size="size" icon="pi-minus" aria-label="Zoom out" kbd="−" data-test="imgzoom-out" @click="zoomOut" />
|
|
302
|
+
<Button :variant="isFit ? 'secondary' : 'ghost'" :size="size" kbd="0" hint="Show the whole picture" :aria-pressed="isFit" data-test="imgzoom-fit" @click="setZoom(null)">Fit</Button>
|
|
303
|
+
<Button :variant="at(1) ? 'secondary' : 'ghost'" :size="size" kbd="1" hint="Real size: one image pixel per screen pixel" :aria-pressed="at(1)" data-test="imgzoom-100" @click="setZoom(1)">100%</Button>
|
|
304
|
+
<Button :variant="at(2) ? 'secondary' : 'ghost'" :size="size" kbd="2" hint="Twice the real size" :aria-pressed="at(2)" data-test="imgzoom-200" @click="setZoom(2)">200%</Button>
|
|
305
|
+
<Button variant="ghost" :size="size" icon="pi-plus" aria-label="Zoom in" kbd="+" data-test="imgzoom-in" @click="zoomIn" />
|
|
306
306
|
<span class="wp-imgzoom__level" aria-live="polite" data-test="imgzoom-level">{{ level }}</span>
|
|
307
|
-
<
|
|
307
|
+
<Button
|
|
308
308
|
v-if="canFull"
|
|
309
309
|
variant="ghost"
|
|
310
310
|
:size="size"
|
package/src/WpSegmented.vue
CHANGED
|
@@ -168,7 +168,9 @@ function onKeydown(ev: KeyboardEvent): void {
|
|
|
168
168
|
.wp-segmented__opt[data-active="true"] .pi { color: var(--wp-accent-text); }
|
|
169
169
|
.wp-segmented__opt:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--wp-border-focus); }
|
|
170
170
|
.wp-segmented__opt:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
171
|
-
|
|
171
|
+
/* Even sizes: an icon-only option is a square, and an odd icon sits half a pixel off in it. */
|
|
172
|
+
.wp-segmented__opt .pi { font-size: 12px; }
|
|
173
|
+
.wp-segmented[data-size="lg"] .wp-segmented__opt .pi { font-size: 14px; }
|
|
172
174
|
@media (prefers-reduced-motion: reduce) {
|
|
173
175
|
.wp-segmented__opt { transition: none; }
|
|
174
176
|
}
|
package/src/WpSwitch.vue
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
* The on/off switch for canvas widgets and the manager (
|
|
3
|
+
* The one on/off switch, for canvas widgets and the manager (size="md" there).
|
|
4
4
|
*
|
|
5
5
|
* <WpSwitch v-model="held" label="Hold across run" /> track
|
|
6
6
|
* <WpSwitch v-model="held" variant="chip" icon="pi-link" label="Hold across run" />
|
package/src/WpTabs.vue
CHANGED
|
@@ -165,7 +165,7 @@ function onKeydown(ev: KeyboardEvent): void {
|
|
|
165
165
|
.wp-tabset__tab[data-active="true"] { color: var(--wp-text); border-bottom-color: var(--wp-accent-400); }
|
|
166
166
|
.wp-tabset__tab:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--wp-border-focus); border-radius: var(--wp-radius-sm); }
|
|
167
167
|
.wp-tabset__tab:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
168
|
-
.wp-tabset__tab .pi { font-size:
|
|
168
|
+
.wp-tabset__tab .pi { font-size: 12px; }
|
|
169
169
|
.wp-tabset__count {
|
|
170
170
|
min-width: 16px;
|
|
171
171
|
padding: 1px 5px;
|
package/src/_modal-motion.css
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* the dialog grows into place.
|
|
7
7
|
*
|
|
8
8
|
* Selector for the panel is `> *` (immediate child of the overlay
|
|
9
|
-
* element). Works for ModalShell's `.wp-modal-wrapper
|
|
9
|
+
* element). Works for ModalShell's `.wp-modal-wrapper` (Modal, ConfirmModal), the old
|
|
10
10
|
* `.wp-confirm`, InjectorBindingModal's `.ibm`, and any future modal
|
|
11
11
|
* that follows the overlay-with-one-child layout.
|
|
12
12
|
*
|
package/src/button.css
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
/* Button — the one .wp-btn stylesheet
|
|
2
|
-
*
|
|
3
|
-
* the
|
|
4
|
-
* standard rule 8), which pulls it in scoped. It reads only the
|
|
1
|
+
/* Button — the one .wp-btn stylesheet. Button.vue pulls it in scoped (so it
|
|
2
|
+
* works on the canvas), and the manager and the community site also load it
|
|
3
|
+
* globally for the few plain elements styled as buttons (a router-link). It reads only the
|
|
5
4
|
* SHARED_TOKENS names, so it wears the manager colours on a page and the
|
|
6
5
|
* canvas colours in a node.
|
|
7
6
|
*
|
|
8
7
|
* Unlayered on purpose: the manager loads it beside tokens.css, where an
|
|
9
8
|
* unlayered reset would otherwise beat it. The canvas never loads it
|
|
10
|
-
* globally:
|
|
9
|
+
* globally: Button pulls in a scoped copy, so its classes never meet
|
|
11
10
|
* another extension's `.wp-btn`.
|
|
12
11
|
*
|
|
13
12
|
* Heights follow the control scale: sm 24 / md 32 / lg 40.
|
|
@@ -21,6 +20,8 @@
|
|
|
21
20
|
*/
|
|
22
21
|
|
|
23
22
|
.wp-btn {
|
|
23
|
+
/* A link drawn as a button (<a class="wp-btn">, Button as="a") loses the browser's underline; the link variant adds its own on hover. */
|
|
24
|
+
text-decoration: none;
|
|
24
25
|
height: var(--wp-control-h-md);
|
|
25
26
|
padding: 0 12px;
|
|
26
27
|
border-radius: min(7px, var(--wp-radius-cap, 999px));
|
|
@@ -46,7 +47,8 @@
|
|
|
46
47
|
outline: none;
|
|
47
48
|
box-shadow: var(--wp-focus-ring);
|
|
48
49
|
}
|
|
49
|
-
|
|
50
|
+
/* Even icon sizes in the even control heights: an odd one sits half a pixel off. */
|
|
51
|
+
.wp-btn .pi, .wp-btn svg { font-size: 14px; }
|
|
50
52
|
/* The label box (not on a link, which has no fixed height) is trimmed to
|
|
51
53
|
* the cap height where the browser can, so the
|
|
52
54
|
* flex centring puts the capitals, not the ascender-to-descender box, on the
|
package/src/floating.ts
CHANGED
|
@@ -53,6 +53,11 @@ function room(side: Side, a: Rect, view: Size, gap: number, margin: number): num
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** Space on one side of the anchor for a panel, after the gap and margin. */
|
|
57
|
+
export function roomOn(side: Side, anchor: Rect, view: Size, opts: Pick<PlaceOptions, "gap" | "margin"> = {}): number {
|
|
58
|
+
return room(side, anchor, view, opts.gap ?? 6, opts.margin ?? 8);
|
|
59
|
+
}
|
|
60
|
+
|
|
56
61
|
function clamp(v: number, min: number, max: number): number {
|
|
57
62
|
return max < min ? min : Math.min(Math.max(v, min), max);
|
|
58
63
|
}
|
package/src/hsv.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* lowercase. Functions are tolerant of out-of-range input — they clamp
|
|
6
6
|
* rather than throw, which keeps the picker UI robust under fast drags.
|
|
7
7
|
*
|
|
8
|
-
* Lives in shared/ so both the
|
|
8
|
+
* Lives in shared/ so both the ColorField / TweaksPanel
|
|
9
9
|
* AND the canvas-side bundle modal can drive the same HsvPicker
|
|
10
10
|
* without a cross-package import.
|
|
11
11
|
*/
|
package/src/menu.css
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/* Menu panel: the one look of every floating list in the kit (Select's list,
|
|
2
|
+
* ContextMenu / DropdownMenu and Combobox's list). Pulled in scoped by
|
|
3
|
+
* Popover (whose `variant="menu"` panel is the menu), ContextMenu, Select and
|
|
4
|
+
* Combobox. It reads only the SHARED_TOKENS names, so a menu wears the
|
|
5
|
+
* manager colours on a page and the canvas colours in a node.
|
|
6
|
+
*
|
|
7
|
+
* <div class="wp-menu"> the panel
|
|
8
|
+
* <li class="wp-menu__item" data-active="true" aria-selected="true">
|
|
9
|
+
*
|
|
10
|
+
* Item states: hover and `[data-active="true"]` (the keyboard's row) share one
|
|
11
|
+
* tint; `[aria-selected="true"]` is a lighter one; `[aria-disabled="true"]` is
|
|
12
|
+
* dim and takes no hover. A piece keeps its own row layout (icons, meta,
|
|
13
|
+
* subtitles, ticks); the sheet sets everything you see around it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
.wp-menu {
|
|
17
|
+
z-index: 10020; /* above every modal overlay (they run up to 10010) */
|
|
18
|
+
box-sizing: border-box;
|
|
19
|
+
margin: 0;
|
|
20
|
+
padding: 4px;
|
|
21
|
+
list-style: none;
|
|
22
|
+
background: var(--wp-bg-2);
|
|
23
|
+
color: var(--wp-text);
|
|
24
|
+
border: 1px solid var(--wp-border-strong);
|
|
25
|
+
border-radius: min(8px, var(--wp-radius-cap, 999px));
|
|
26
|
+
box-shadow: var(--wp-shadow-md);
|
|
27
|
+
font-family: var(--wp-font-sans, sans-serif);
|
|
28
|
+
font-size: 12px;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.wp-menu__item {
|
|
32
|
+
padding: 6px 8px;
|
|
33
|
+
border-radius: min(6px, var(--wp-radius-cap, 999px));
|
|
34
|
+
color: var(--wp-text);
|
|
35
|
+
font-size: 12px;
|
|
36
|
+
cursor: pointer;
|
|
37
|
+
user-select: none;
|
|
38
|
+
}
|
|
39
|
+
.wp-menu__item[aria-selected="true"] {
|
|
40
|
+
background: color-mix(in oklab, var(--wp-accent) 12%, transparent);
|
|
41
|
+
}
|
|
42
|
+
.wp-menu__item:hover,
|
|
43
|
+
.wp-menu__item[data-active="true"] {
|
|
44
|
+
background: var(--wp-accent-glow);
|
|
45
|
+
color: var(--wp-text);
|
|
46
|
+
}
|
|
47
|
+
.wp-menu__item[aria-disabled="true"] {
|
|
48
|
+
color: var(--wp-text-dim);
|
|
49
|
+
background: transparent;
|
|
50
|
+
cursor: not-allowed;
|
|
51
|
+
}
|
package/src/motion.css
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Entrances and nudges for things that appear while you watch: a streamed
|
|
3
|
+
* reply's new words, a reaction, a chip by the selection, a question that
|
|
4
|
+
* needs an answer. Add the class to the element as it mounts.
|
|
5
|
+
*
|
|
6
|
+
* .wp-enter-fade fades in (150 ms): each new run of streamed text.
|
|
7
|
+
* .wp-enter-pop pops in with a small overshoot (320 ms): a reaction.
|
|
8
|
+
* .wp-enter-rise rises 4 px into place (140 ms): a chip above a
|
|
9
|
+
* selection; add data-from="below" when it sits under it.
|
|
10
|
+
* .wp-enter-drop drops 6 px into place (500 ms): a panel that asks for
|
|
11
|
+
* something.
|
|
12
|
+
* .wp-attention a soft accent ring that pulses three times (after
|
|
13
|
+
* .wp-enter-drop when both are set).
|
|
14
|
+
*
|
|
15
|
+
* The moving element keeps its own position: set --wp-enter-x for an extra
|
|
16
|
+
* horizontal shift (a chip centred with -50%), and it goes into the same
|
|
17
|
+
* transform.
|
|
18
|
+
*
|
|
19
|
+
* Every entrance animates registered custom properties, never `opacity` or
|
|
20
|
+
* `transform` themselves: those go to the compositor, which lifts the element
|
|
21
|
+
* onto its own layer while it moves and drops it after, so text went soft
|
|
22
|
+
* during the motion and snapped half a pixel at its end.
|
|
23
|
+
*/
|
|
24
|
+
@property --wp-enter-o { syntax: "<number>"; inherits: false; initial-value: 1; }
|
|
25
|
+
@property --wp-enter-s { syntax: "<number>"; inherits: false; initial-value: 1; }
|
|
26
|
+
@property --wp-enter-y { syntax: "<length>"; inherits: false; initial-value: 0px; }
|
|
27
|
+
|
|
28
|
+
@layer wp-extension {
|
|
29
|
+
.wp-enter-fade {
|
|
30
|
+
opacity: var(--wp-enter-o);
|
|
31
|
+
animation: wp-enter-fade 150ms ease-out;
|
|
32
|
+
}
|
|
33
|
+
.wp-enter-pop {
|
|
34
|
+
opacity: var(--wp-enter-o);
|
|
35
|
+
transform: translateX(var(--wp-enter-x, 0px)) scale(var(--wp-enter-s));
|
|
36
|
+
animation: wp-enter-pop 0.32s cubic-bezier(0.3, 1.6, 0.5, 1) both;
|
|
37
|
+
}
|
|
38
|
+
.wp-enter-rise {
|
|
39
|
+
opacity: var(--wp-enter-o);
|
|
40
|
+
transform: translate(var(--wp-enter-x, 0px), var(--wp-enter-y));
|
|
41
|
+
animation: wp-enter-rise 0.14s ease-out both;
|
|
42
|
+
}
|
|
43
|
+
.wp-enter-rise[data-from="below"] { animation-name: wp-enter-rise-below; }
|
|
44
|
+
.wp-enter-drop {
|
|
45
|
+
opacity: var(--wp-enter-o);
|
|
46
|
+
transform: translate(var(--wp-enter-x, 0px), var(--wp-enter-y));
|
|
47
|
+
animation: wp-enter-drop 0.5s ease-out;
|
|
48
|
+
}
|
|
49
|
+
.wp-attention { animation: wp-attention 2.4s ease-in-out 3; }
|
|
50
|
+
.wp-enter-drop.wp-attention {
|
|
51
|
+
animation: wp-enter-drop 0.5s ease-out, wp-attention 2.4s ease-in-out 0.5s 3;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@keyframes wp-enter-fade { from { --wp-enter-o: 0; } to { --wp-enter-o: 1; } }
|
|
55
|
+
@keyframes wp-enter-pop {
|
|
56
|
+
from { --wp-enter-s: 0.3; --wp-enter-o: 0; }
|
|
57
|
+
to { --wp-enter-s: 1; --wp-enter-o: 1; }
|
|
58
|
+
}
|
|
59
|
+
@keyframes wp-enter-rise {
|
|
60
|
+
from { --wp-enter-o: 0; --wp-enter-y: 4px; }
|
|
61
|
+
to { --wp-enter-o: 1; --wp-enter-y: 0px; }
|
|
62
|
+
}
|
|
63
|
+
@keyframes wp-enter-rise-below {
|
|
64
|
+
from { --wp-enter-o: 0; --wp-enter-y: -4px; }
|
|
65
|
+
to { --wp-enter-o: 1; --wp-enter-y: 0px; }
|
|
66
|
+
}
|
|
67
|
+
@keyframes wp-enter-drop { from { --wp-enter-o: 0; --wp-enter-y: -6px; } }
|
|
68
|
+
@keyframes wp-attention { 50% { box-shadow: 0 0 0 6px var(--wp-accent-glow); } }
|
|
69
|
+
|
|
70
|
+
@media (prefers-reduced-motion: reduce) {
|
|
71
|
+
.wp-enter-fade, .wp-enter-pop, .wp-enter-rise, .wp-enter-drop, .wp-attention { animation: none; }
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/slider-math.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Number helpers for
|
|
2
|
+
* Number helpers for Slider (one thumb or a pair): clamp
|
|
3
3
|
* to the ends, snap to the step counted from `min`, and turn a pointer
|
|
4
4
|
* position into a value. Steps like 0.1 snap without float fuzz.
|
|
5
5
|
*/
|
|
@@ -43,3 +43,47 @@ export function keyStep(key: string, v: number, min: number, max: number, step:
|
|
|
43
43
|
default: return null;
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Mark snapping. `snap: true` keeps the value on a mark (the nearest one);
|
|
49
|
+
* a number pulls it onto a mark only within that distance, so the thumb
|
|
50
|
+
* moves freely by its step elsewhere and "clicks" into a mark near one.
|
|
51
|
+
* With no marks, or snap off, the value comes back as it was.
|
|
52
|
+
*/
|
|
53
|
+
export function snapToMarks(v: number, marks: readonly number[], snapTo: boolean | number | undefined): number {
|
|
54
|
+
if (!snapTo || !marks.length) return v;
|
|
55
|
+
let best = marks[0] ?? v;
|
|
56
|
+
for (const m of marks) if (Math.abs(m - v) < Math.abs(best - v)) best = m;
|
|
57
|
+
if (snapTo === true) return best;
|
|
58
|
+
return Math.abs(best - v) <= snapTo ? best : v;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A key press on a slider that only takes marks (`snap: true`): arrows go to
|
|
63
|
+
* the next mark up or down, PageUp/PageDown jump `big` marks, Home/End the
|
|
64
|
+
* first and last. Null for a key the slider ignores.
|
|
65
|
+
*/
|
|
66
|
+
export function keyToMark(key: string, v: number, marks: readonly number[], big = 3): number | null {
|
|
67
|
+
const sorted = [...new Set(marks)].sort((a, b) => a - b);
|
|
68
|
+
if (!sorted.length) return null;
|
|
69
|
+
// Where v sits: the index of the mark at it, or between two marks.
|
|
70
|
+
const at = sorted.findIndex((m) => m >= v);
|
|
71
|
+
const exact = at >= 0 && sorted[at] === v;
|
|
72
|
+
const up = (n: number): number => {
|
|
73
|
+
const from = at < 0 ? sorted.length : exact ? at : at - 1;
|
|
74
|
+
return sorted[Math.min(sorted.length - 1, from + n)] ?? v;
|
|
75
|
+
};
|
|
76
|
+
const down = (n: number): number => {
|
|
77
|
+
const from = at < 0 ? sorted.length : at;
|
|
78
|
+
return sorted[Math.max(0, from - n)] ?? v;
|
|
79
|
+
};
|
|
80
|
+
switch (key) {
|
|
81
|
+
case "ArrowRight": case "ArrowUp": return up(1);
|
|
82
|
+
case "ArrowLeft": case "ArrowDown": return down(1);
|
|
83
|
+
case "PageUp": return up(big);
|
|
84
|
+
case "PageDown": return down(big);
|
|
85
|
+
case "Home": return sorted[0] ?? v;
|
|
86
|
+
case "End": return sorted[sorted.length - 1] ?? v;
|
|
87
|
+
default: return null;
|
|
88
|
+
}
|
|
89
|
+
}
|