@pienter/ui 0.20.0 → 0.21.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
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.21.0 - 2026-09-16
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `BlockEditor`'s drag handle is keyboard-operable and back in the tab
|
|
10
|
+
order. Space or Enter grabs a block (`aria-pressed`, `data-state="grabbed"`),
|
|
11
|
+
ArrowUp and ArrowDown move it one step through the same reorder the move
|
|
12
|
+
buttons use, Space or Enter drops it and Escape returns it to where it was
|
|
13
|
+
grabbed; blur while grabbed cancels too. Every step emits
|
|
14
|
+
`update:modelValue`, so the page sees the block move as it does with the
|
|
15
|
+
move buttons, and each grab, step, drop and cancel is announced through the
|
|
16
|
+
shared live region. The default `hint` now describes the keyboard pattern.
|
|
17
|
+
Pointer dragging is unchanged.
|
|
18
|
+
|
|
19
|
+
## 0.20.1 - 2026-09-16
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- `Select` shows its empty option again after the model clears from a value
|
|
24
|
+
no option carries. A single-value `FilterSelect` fed
|
|
25
|
+
`filter[status]=draft,published` selected nothing, and when Clear all set
|
|
26
|
+
the model to `''` the control stayed blank: a `<select>` with no selection
|
|
27
|
+
already reads `''`, so Vue saw nothing to write. The control now syncs the
|
|
28
|
+
native value itself after every model change. An unknown value still
|
|
29
|
+
selects nothing; only the way back is fixed.
|
|
30
|
+
|
|
5
31
|
## 0.20.0 - 2026-09-16
|
|
6
32
|
|
|
7
33
|
### Added
|
|
@@ -39,7 +39,7 @@ const props = withDefaults(
|
|
|
39
39
|
level: 3,
|
|
40
40
|
blockLabel: undefined,
|
|
41
41
|
addLabel: (typeLabel: string) => `Add ${typeLabel}`,
|
|
42
|
-
hint: 'Use the move buttons to reorder, or drag with a pointer.',
|
|
42
|
+
hint: 'Use the move buttons to reorder, or drag with a pointer. On a drag handle, Space grabs the block, the arrow keys move it, Space drops it and Escape cancels.',
|
|
43
43
|
emptyText: 'No blocks yet. Add a block to start writing.',
|
|
44
44
|
},
|
|
45
45
|
);
|
|
@@ -68,6 +68,9 @@ const collapsed = computed(
|
|
|
68
68
|
);
|
|
69
69
|
const dragging = ref<string>();
|
|
70
70
|
const dropIndex = ref(0);
|
|
71
|
+
const grabbed = ref<string>();
|
|
72
|
+
let grabbedFrom = 0;
|
|
73
|
+
let stepping = false;
|
|
71
74
|
const items = new Map<string, HTMLElement>();
|
|
72
75
|
let gesture:
|
|
73
76
|
| {
|
|
@@ -160,7 +163,7 @@ function remove(key: string) {
|
|
|
160
163
|
announce(`${title(block, index)} removed. ${remaining.length} blocks.`);
|
|
161
164
|
}
|
|
162
165
|
|
|
163
|
-
function
|
|
166
|
+
function reorder(key: string, target: number) {
|
|
164
167
|
if (locked.value) return;
|
|
165
168
|
const source = props.modelValue.findIndex((block) => block.id === key);
|
|
166
169
|
if (
|
|
@@ -174,12 +177,116 @@ function move(key: string, target: number) {
|
|
|
174
177
|
const block = blocks.splice(source, 1)[0]!;
|
|
175
178
|
blocks.splice(target, 0, block);
|
|
176
179
|
emit('update:modelValue', blocks);
|
|
180
|
+
return { block, source, blocks };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function move(key: string, target: number) {
|
|
184
|
+
const moved = reorder(key, target);
|
|
185
|
+
if (!moved) return;
|
|
177
186
|
focusBlock(key);
|
|
178
187
|
announce(
|
|
179
|
-
`${title(block, source)} moved to position ${target + 1} of ${blocks.length}.`,
|
|
188
|
+
`${title(moved.block, moved.source)} moved to position ${target + 1} of ${moved.blocks.length}.`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function focusHandle(key: string) {
|
|
193
|
+
items
|
|
194
|
+
.get(key)
|
|
195
|
+
?.querySelector<HTMLButtonElement>('.pui-block-editor__drag')
|
|
196
|
+
?.focus({ preventScroll: true });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function position(key: string) {
|
|
200
|
+
const index = props.modelValue.findIndex((block) => block.id === key);
|
|
201
|
+
const block = props.modelValue[index];
|
|
202
|
+
return block
|
|
203
|
+
? {
|
|
204
|
+
index,
|
|
205
|
+
name: title(block, index),
|
|
206
|
+
place: `position ${index + 1} of ${props.modelValue.length}`,
|
|
207
|
+
}
|
|
208
|
+
: undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function grab(key: string) {
|
|
212
|
+
const at = position(key);
|
|
213
|
+
if (!at || props.modelValue.length < 2) return;
|
|
214
|
+
grabbed.value = key;
|
|
215
|
+
grabbedFrom = at.index;
|
|
216
|
+
announce(
|
|
217
|
+
`Grabbed ${at.name}, ${at.place}. Use arrow keys to move, Space to drop, Escape to cancel.`,
|
|
180
218
|
);
|
|
181
219
|
}
|
|
182
220
|
|
|
221
|
+
function step(key: string, delta: -1 | 1) {
|
|
222
|
+
const at = position(key);
|
|
223
|
+
if (!at) return;
|
|
224
|
+
const target = at.index + delta;
|
|
225
|
+
if (target < 0 || target >= props.modelValue.length) {
|
|
226
|
+
announce(
|
|
227
|
+
`${at.name} cannot move further ${delta < 0 ? 'up' : 'down'}.`,
|
|
228
|
+
);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
// Vue moves the `<li>` in the DOM, which blurs the handle; that blur is not a cancel.
|
|
232
|
+
stepping = true;
|
|
233
|
+
reorder(key, target);
|
|
234
|
+
void nextTick(() => {
|
|
235
|
+
focusHandle(key);
|
|
236
|
+
stepping = false;
|
|
237
|
+
});
|
|
238
|
+
announce(
|
|
239
|
+
`${at.name} moved to position ${target + 1} of ${props.modelValue.length}.`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function drop(key: string) {
|
|
244
|
+
const at = position(key);
|
|
245
|
+
grabbed.value = undefined;
|
|
246
|
+
if (at) announce(`Dropped ${at.name}, ${at.place}.`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function cancelGrab() {
|
|
250
|
+
const key = grabbed.value;
|
|
251
|
+
if (!key) return;
|
|
252
|
+
grabbed.value = undefined;
|
|
253
|
+
stepping = true;
|
|
254
|
+
reorder(key, grabbedFrom);
|
|
255
|
+
void nextTick(() => {
|
|
256
|
+
stepping = false;
|
|
257
|
+
});
|
|
258
|
+
announce('Reordering cancelled.');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function handleKey(event: KeyboardEvent, key: string) {
|
|
262
|
+
if (locked.value) return;
|
|
263
|
+
const held = grabbed.value === key;
|
|
264
|
+
switch (event.key) {
|
|
265
|
+
case ' ':
|
|
266
|
+
case 'Enter':
|
|
267
|
+
event.preventDefault();
|
|
268
|
+
if (held) drop(key);
|
|
269
|
+
else grab(key);
|
|
270
|
+
return;
|
|
271
|
+
case 'ArrowUp':
|
|
272
|
+
case 'ArrowDown':
|
|
273
|
+
if (!held) return;
|
|
274
|
+
event.preventDefault();
|
|
275
|
+
step(key, event.key === 'ArrowUp' ? -1 : 1);
|
|
276
|
+
return;
|
|
277
|
+
case 'Escape':
|
|
278
|
+
if (!held) return;
|
|
279
|
+
event.preventDefault();
|
|
280
|
+
event.stopPropagation();
|
|
281
|
+
cancelGrab();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function handleBlur(key: string) {
|
|
287
|
+
if (!stepping && grabbed.value === key) cancelGrab();
|
|
288
|
+
}
|
|
289
|
+
|
|
183
290
|
function toggle(key: string) {
|
|
184
291
|
if (props.disabled) return;
|
|
185
292
|
const next = new Set(collapsed.value);
|
|
@@ -240,6 +347,7 @@ function startDrag(event: PointerEvent, key: string) {
|
|
|
240
347
|
if (locked.value || event.button !== 0 || props.modelValue.length < 2)
|
|
241
348
|
return;
|
|
242
349
|
stopDrag();
|
|
350
|
+
grabbed.value = undefined;
|
|
243
351
|
const handle = event.currentTarget as HTMLButtonElement;
|
|
244
352
|
handle.focus({ preventScroll: true });
|
|
245
353
|
gesture = {
|
|
@@ -313,9 +421,22 @@ function cancelWithEscape(event: KeyboardEvent) {
|
|
|
313
421
|
}
|
|
314
422
|
|
|
315
423
|
watch(locked, (value) => {
|
|
316
|
-
if (value)
|
|
424
|
+
if (value) {
|
|
425
|
+
stopDrag();
|
|
426
|
+
grabbed.value = undefined;
|
|
427
|
+
}
|
|
317
428
|
});
|
|
318
|
-
watch(
|
|
429
|
+
watch(
|
|
430
|
+
() => props.modelValue.map((block) => block.id).join('\u0000'),
|
|
431
|
+
() => {
|
|
432
|
+
stopDrag();
|
|
433
|
+
if (
|
|
434
|
+
grabbed.value &&
|
|
435
|
+
!props.modelValue.some((block) => block.id === grabbed.value)
|
|
436
|
+
)
|
|
437
|
+
grabbed.value = undefined;
|
|
438
|
+
},
|
|
439
|
+
);
|
|
319
440
|
onBeforeUnmount(stopDrag);
|
|
320
441
|
</script>
|
|
321
442
|
|
|
@@ -343,7 +464,11 @@ onBeforeUnmount(stopDrag);
|
|
|
343
464
|
"
|
|
344
465
|
class="pui-block-editor__item"
|
|
345
466
|
:data-block-id="block.id"
|
|
346
|
-
:data-dragging="
|
|
467
|
+
:data-dragging="
|
|
468
|
+
dragging === block.id || grabbed === block.id
|
|
469
|
+
? 'true'
|
|
470
|
+
: undefined
|
|
471
|
+
"
|
|
347
472
|
:data-drop="indicator(block.id)"
|
|
348
473
|
>
|
|
349
474
|
<div class="pui-block-editor__header">
|
|
@@ -351,11 +476,16 @@ onBeforeUnmount(stopDrag);
|
|
|
351
476
|
v-if="!readonly"
|
|
352
477
|
class="pui-block-editor__drag"
|
|
353
478
|
type="button"
|
|
354
|
-
tabindex="-1"
|
|
355
479
|
:disabled="disabled || modelValue.length < 2"
|
|
356
480
|
:aria-label="`Drag ${title(block, index)}`"
|
|
357
481
|
:aria-describedby="`${id}-hint`"
|
|
482
|
+
:aria-pressed="grabbed === block.id ? 'true' : 'false'"
|
|
483
|
+
:data-state="
|
|
484
|
+
grabbed === block.id ? 'grabbed' : undefined
|
|
485
|
+
"
|
|
358
486
|
@pointerdown="startDrag($event, block.id)"
|
|
487
|
+
@keydown="handleKey($event, block.id)"
|
|
488
|
+
@blur="handleBlur(block.id)"
|
|
359
489
|
>
|
|
360
490
|
<Icon name="arrow-up-down" size="sm" />
|
|
361
491
|
</button>
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
<select
|
|
19
19
|
v-bind="$attrs"
|
|
20
20
|
:id="inputId"
|
|
21
|
+
ref="controlRef"
|
|
21
22
|
class="pui-select__control"
|
|
22
23
|
:name="name"
|
|
23
24
|
:required="required"
|
|
@@ -58,7 +59,7 @@
|
|
|
58
59
|
</template>
|
|
59
60
|
|
|
60
61
|
<script setup lang="ts">
|
|
61
|
-
import { computed } from 'vue';
|
|
62
|
+
import { computed, ref, watch } from 'vue';
|
|
62
63
|
import { generateId } from '../../../utils/a11y/id.js';
|
|
63
64
|
import Icon from '../../display/icon/Icon.vue';
|
|
64
65
|
|
|
@@ -106,6 +107,20 @@ const inputId = props.id ?? generateId('select');
|
|
|
106
107
|
const hintId = `${inputId}-hint`;
|
|
107
108
|
const errorsId = `${inputId}-errors`;
|
|
108
109
|
|
|
110
|
+
const controlRef = ref<HTMLSelectElement | null>(null);
|
|
111
|
+
|
|
112
|
+
// Vue skips the write when `el.value` already reads the new value, which it does for '' while nothing is selected.
|
|
113
|
+
watch(
|
|
114
|
+
() => props.modelValue,
|
|
115
|
+
(value) => {
|
|
116
|
+
const el = controlRef.value;
|
|
117
|
+
if (!el) return;
|
|
118
|
+
const next = value ?? '';
|
|
119
|
+
if (el.selectedIndex === -1 || el.value !== next) el.value = next;
|
|
120
|
+
},
|
|
121
|
+
{ flush: 'post' },
|
|
122
|
+
);
|
|
123
|
+
|
|
109
124
|
const hasErrors = computed(() => props.errors.length > 0);
|
|
110
125
|
|
|
111
126
|
const computedStatus = computed(() => {
|