@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 move(key: string, target: number) {
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) stopDrag();
424
+ if (value) {
425
+ stopDrag();
426
+ grabbed.value = undefined;
427
+ }
317
428
  });
318
- watch(() => props.modelValue.map((block) => block.id).join('\u0000'), stopDrag);
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="dragging === block.id ? 'true' : undefined"
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(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pienter/ui",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Shared Pienter UI components, styles, icons, and browser utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",