@pienter/ui 0.5.0 → 0.7.1

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.
Files changed (31) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/CONVENTIONS.md +45 -0
  3. package/README.md +30 -0
  4. package/components/display/record-details/RecordDetails.vue +61 -0
  5. package/components/display/record-details/record-details.css +37 -0
  6. package/components/display/record-details/types.ts +8 -0
  7. package/components/form/block-editor/BlockEditor.vue +454 -0
  8. package/components/form/block-editor/block-editor.css +149 -0
  9. package/components/form/block-editor/types.ts +15 -0
  10. package/components/form/combobox/Combobox.vue +21 -34
  11. package/components/form/number-field/NumberField.vue +0 -1
  12. package/components/form/record-form/RecordFields.vue +128 -0
  13. package/components/form/record-form/RecordForm.vue +116 -0
  14. package/components/form/record-form/fields.ts +20 -0
  15. package/components/form/record-form/record-form.css +15 -0
  16. package/components/form/record-form/types.ts +28 -0
  17. package/components/form/text-input/text-input.css +2 -0
  18. package/components/layout/index/Index.vue +353 -0
  19. package/components/layout/index/index.css +114 -0
  20. package/components/layout/index/useIndex.ts +390 -0
  21. package/components/layout/table/table.css +2 -1
  22. package/components/navigation/breadcrumb/Breadcrumb.vue +24 -5
  23. package/components/navigation/breadcrumb/breadcrumb.css +15 -0
  24. package/components/navigation/sidebar/Sidebar.vue +11 -8
  25. package/components/navigation/tabs/Tabs.vue +6 -0
  26. package/composables/useMenu.ts +20 -27
  27. package/package.json +12 -2
  28. package/styles/0-settings/colors.css +10 -0
  29. package/utils/a11y/focus.ts +9 -3
  30. package/utils/cms/index.ts +283 -0
  31. package/utils/cms/schema.json +126 -0
@@ -0,0 +1,454 @@
1
+ <script lang="ts">
2
+ export type { BlockType, ContentBlock, BlockPatch } from './types.js';
3
+ </script>
4
+
5
+ <script setup lang="ts" generic="T extends ContentBlock">
6
+ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
7
+ import Button from '../../action/button/Button.vue';
8
+ import IconButton from '../../action/button/IconButton.vue';
9
+ import Icon from '../../display/icon/Icon.vue';
10
+ import { announce } from '../../../utils/a11y/live-region.js';
11
+ import { generateId } from '../../../utils/a11y/id.js';
12
+ import type { BlockPatch, BlockType, ContentBlock } from './types.js';
13
+
14
+ const props = withDefaults(
15
+ defineProps<{
16
+ modelValue: readonly T[];
17
+ blockTypes: readonly BlockType<T>[];
18
+ /** Optional controlled list of collapsed block ids. */
19
+ collapsed?: readonly string[];
20
+ disabled?: boolean;
21
+ label?: string;
22
+ blockLabel?: (block: T, index: number) => string;
23
+ }>(),
24
+ {
25
+ disabled: false,
26
+ collapsed: undefined,
27
+ label: 'Content blocks',
28
+ blockLabel: undefined,
29
+ },
30
+ );
31
+
32
+ const emit = defineEmits<{
33
+ 'update:modelValue': [blocks: T[]];
34
+ 'update:collapsed': [ids: string[]];
35
+ }>();
36
+ defineSlots<{
37
+ block: (scope: {
38
+ block: T;
39
+ index: number;
40
+ update: (patch: BlockPatch<T>) => void;
41
+ disabled: boolean;
42
+ }) => unknown;
43
+ summary?: (scope: { block: T; index: number }) => unknown;
44
+ empty?: () => unknown;
45
+ }>();
46
+
47
+ const id = generateId('blocks');
48
+ const localCollapsed = ref<string[]>([]);
49
+ const collapsed = computed(
50
+ () => new Set(props.collapsed ?? localCollapsed.value),
51
+ );
52
+ const dragging = ref<string>();
53
+ const dropIndex = ref(0);
54
+ const items = new Map<string, HTMLElement>();
55
+ let gesture:
56
+ | {
57
+ id: string;
58
+ pointerId: number;
59
+ startX: number;
60
+ startY: number;
61
+ y: number;
62
+ }
63
+ | undefined;
64
+ let pointerListeners: AbortController | undefined;
65
+ let scrollFrame = 0;
66
+ let scrollParent: HTMLElement | undefined;
67
+
68
+ function setItem(key: string, element: HTMLElement | null) {
69
+ if (element) items.set(key, element);
70
+ else items.delete(key);
71
+ }
72
+
73
+ function typeLabel(block: T) {
74
+ return (
75
+ props.blockTypes.find((type) => type.type === block.type)?.label ??
76
+ block.type
77
+ );
78
+ }
79
+
80
+ function title(block: T, index: number) {
81
+ return props.blockLabel?.(block, index) || typeLabel(block);
82
+ }
83
+
84
+ function bodyId(block: T) {
85
+ return `${id}-${encodeURIComponent(block.id)}`;
86
+ }
87
+
88
+ function focusBlock(key: string) {
89
+ void nextTick(() =>
90
+ items
91
+ .get(key)
92
+ ?.querySelector<HTMLButtonElement>('[data-block-toggle]')
93
+ ?.focus(),
94
+ );
95
+ }
96
+
97
+ function add(type: BlockType<T>) {
98
+ if (props.disabled) return;
99
+ const block = type.create();
100
+ if (
101
+ !block.id ||
102
+ block.type !== type.type ||
103
+ props.modelValue.some((item) => item.id === block.id)
104
+ ) {
105
+ throw new TypeError(
106
+ 'Block factories must return the requested type and a unique, non-empty id.',
107
+ );
108
+ }
109
+ emit('update:modelValue', [...props.modelValue, { ...block }]);
110
+ focusBlock(block.id);
111
+ announce(`${type.label} added. ${props.modelValue.length + 1} blocks.`);
112
+ }
113
+
114
+ function update(key: string, patch: BlockPatch<T>) {
115
+ if (props.disabled) return;
116
+ emit(
117
+ 'update:modelValue',
118
+ props.modelValue.map((block) =>
119
+ block.id === key
120
+ ? { ...block, ...patch, id: block.id, type: block.type }
121
+ : block,
122
+ ),
123
+ );
124
+ }
125
+
126
+ function remove(key: string) {
127
+ if (props.disabled) return;
128
+ const index = props.modelValue.findIndex((block) => block.id === key);
129
+ const block = props.modelValue[index];
130
+ if (!block) return;
131
+ const remaining = props.modelValue.filter((block) => block.id !== key);
132
+ emit('update:modelValue', remaining);
133
+ setCollapsed([...collapsed.value].filter((id) => id !== key));
134
+ const next = remaining[Math.min(index, remaining.length - 1)];
135
+ if (next) focusBlock(next.id);
136
+ else
137
+ void nextTick(() =>
138
+ document
139
+ .getElementById(`${id}-add`)
140
+ ?.querySelector<HTMLButtonElement>('button')
141
+ ?.focus(),
142
+ );
143
+ announce(`${title(block, index)} removed. ${remaining.length} blocks.`);
144
+ }
145
+
146
+ function move(key: string, target: number) {
147
+ if (props.disabled) return;
148
+ const source = props.modelValue.findIndex((block) => block.id === key);
149
+ if (
150
+ source < 0 ||
151
+ target < 0 ||
152
+ target >= props.modelValue.length ||
153
+ source === target
154
+ )
155
+ return;
156
+ const blocks = [...props.modelValue];
157
+ const block = blocks.splice(source, 1)[0]!;
158
+ blocks.splice(target, 0, block);
159
+ emit('update:modelValue', blocks);
160
+ focusBlock(key);
161
+ announce(
162
+ `${title(block, source)} moved to position ${target + 1} of ${blocks.length}.`,
163
+ );
164
+ }
165
+
166
+ function toggle(key: string) {
167
+ if (props.disabled) return;
168
+ const next = new Set(collapsed.value);
169
+ if (next.has(key)) next.delete(key);
170
+ else next.add(key);
171
+ setCollapsed([...next]);
172
+ }
173
+
174
+ function setCollapsed(ids: string[]) {
175
+ if (props.collapsed === undefined) localCollapsed.value = ids;
176
+ emit('update:collapsed', ids);
177
+ }
178
+
179
+ const otherBlocks = computed(() =>
180
+ props.modelValue.filter((block) => block.id !== dragging.value),
181
+ );
182
+ function indicator(key: string) {
183
+ if (!dragging.value) return undefined;
184
+ if (otherBlocks.value[dropIndex.value]?.id === key) return 'before';
185
+ if (
186
+ dropIndex.value === otherBlocks.value.length &&
187
+ otherBlocks.value.at(-1)?.id === key
188
+ )
189
+ return 'after';
190
+ return undefined;
191
+ }
192
+
193
+ function locateDrop(y: number) {
194
+ dropIndex.value = otherBlocks.value.filter((block) => {
195
+ const rect = items.get(block.id)?.getBoundingClientRect();
196
+ return rect && y > rect.top + rect.height / 2;
197
+ }).length;
198
+ }
199
+
200
+ function autoScroll() {
201
+ if (!gesture || !dragging.value) return;
202
+ const bounds = scrollParent?.getBoundingClientRect();
203
+ const top = bounds?.top ?? 0;
204
+ const bottom = bounds?.bottom ?? window.innerHeight;
205
+ const delta = gesture.y < top + 48 ? -14 : gesture.y > bottom - 48 ? 14 : 0;
206
+ if (delta) {
207
+ if (scrollParent) scrollParent.scrollTop += delta;
208
+ else window.scrollBy(0, delta);
209
+ locateDrop(gesture.y);
210
+ }
211
+ scrollFrame = requestAnimationFrame(autoScroll);
212
+ }
213
+
214
+ function stopDrag() {
215
+ pointerListeners?.abort();
216
+ pointerListeners = undefined;
217
+ cancelAnimationFrame(scrollFrame);
218
+ gesture = undefined;
219
+ dragging.value = undefined;
220
+ }
221
+
222
+ function startDrag(event: PointerEvent, key: string) {
223
+ if (props.disabled || event.button !== 0 || props.modelValue.length < 2)
224
+ return;
225
+ stopDrag();
226
+ const handle = event.currentTarget as HTMLButtonElement;
227
+ handle.focus({ preventScroll: true });
228
+ gesture = {
229
+ id: key,
230
+ pointerId: event.pointerId,
231
+ startX: event.clientX,
232
+ startY: event.clientY,
233
+ y: event.clientY,
234
+ };
235
+ scrollParent = undefined;
236
+ for (
237
+ let parent = handle.parentElement;
238
+ parent;
239
+ parent = parent.parentElement
240
+ ) {
241
+ if (
242
+ /(auto|scroll)/.test(getComputedStyle(parent).overflowY) &&
243
+ parent.scrollHeight > parent.clientHeight
244
+ ) {
245
+ scrollParent = parent;
246
+ break;
247
+ }
248
+ }
249
+ try {
250
+ handle.setPointerCapture(event.pointerId);
251
+ } catch {
252
+ /* Synthetic pointer events have no capturable pointer. */
253
+ }
254
+ pointerListeners = new AbortController();
255
+ const options = { signal: pointerListeners.signal };
256
+ window.addEventListener('pointermove', dragMove, options);
257
+ window.addEventListener('pointerup', finishDrag, options);
258
+ window.addEventListener('pointercancel', stopDrag, options);
259
+ window.addEventListener('blur', stopDrag, options);
260
+ window.addEventListener('keydown', cancelWithEscape, options);
261
+ event.preventDefault();
262
+ }
263
+
264
+ function dragMove(event: PointerEvent) {
265
+ if (!gesture || event.pointerId !== gesture.pointerId) return;
266
+ gesture.y = event.clientY;
267
+ if (!dragging.value) {
268
+ if (
269
+ Math.hypot(
270
+ event.clientX - gesture.startX,
271
+ event.clientY - gesture.startY,
272
+ ) < 5
273
+ )
274
+ return;
275
+ dragging.value = gesture.id;
276
+ scrollFrame = requestAnimationFrame(autoScroll);
277
+ }
278
+ locateDrop(event.clientY);
279
+ event.preventDefault();
280
+ }
281
+
282
+ function finishDrag(event: PointerEvent) {
283
+ if (!gesture || event.pointerId !== gesture.pointerId) return;
284
+ const key = dragging.value;
285
+ const target = dropIndex.value;
286
+ stopDrag();
287
+ if (key) move(key, target);
288
+ }
289
+
290
+ function cancelWithEscape(event: KeyboardEvent) {
291
+ if (event.key === 'Escape' && gesture) {
292
+ event.preventDefault();
293
+ stopDrag();
294
+ announce('Reordering cancelled.');
295
+ }
296
+ }
297
+
298
+ watch(
299
+ () => props.disabled,
300
+ (disabled) => {
301
+ if (disabled) stopDrag();
302
+ },
303
+ );
304
+ watch(() => props.modelValue.map((block) => block.id).join('\u0000'), stopDrag);
305
+ onBeforeUnmount(stopDrag);
306
+ </script>
307
+
308
+ <template>
309
+ <div
310
+ class="pui-block-editor"
311
+ :data-disabled="disabled ? 'true' : undefined"
312
+ >
313
+ <p :id="`${id}-hint`" class="pui-block-editor__hint">
314
+ Drag to reorder, or use the move buttons.
315
+ </p>
316
+ <ol
317
+ v-if="modelValue.length"
318
+ class="pui-block-editor__list"
319
+ :aria-label="label"
320
+ :aria-describedby="`${id}-hint`"
321
+ >
322
+ <li
323
+ v-for="(block, index) in modelValue"
324
+ :key="block.id"
325
+ :ref="
326
+ (element) =>
327
+ setItem(block.id, element as HTMLElement | null)
328
+ "
329
+ class="pui-block-editor__item"
330
+ :data-block-id="block.id"
331
+ :data-dragging="dragging === block.id ? 'true' : undefined"
332
+ :data-drop="indicator(block.id)"
333
+ >
334
+ <div class="pui-block-editor__header">
335
+ <button
336
+ class="pui-block-editor__drag"
337
+ type="button"
338
+ :disabled="disabled || modelValue.length < 2"
339
+ :aria-label="`Drag ${title(block, index)}`"
340
+ :aria-describedby="`${id}-hint`"
341
+ @pointerdown="startDrag($event, block.id)"
342
+ >
343
+ <Icon name="arrow-up-down" size="sm" />
344
+ </button>
345
+ <div class="pui-block-editor__heading">
346
+ <button
347
+ type="button"
348
+ class="pui-block-editor__toggle"
349
+ data-block-toggle
350
+ :disabled="disabled"
351
+ :aria-label="`${collapsed.has(block.id) ? 'Expand' : 'Collapse'} ${title(block, index)}`"
352
+ :aria-expanded="
353
+ collapsed.has(block.id) ? 'false' : 'true'
354
+ "
355
+ :aria-controls="bodyId(block)"
356
+ @click="toggle(block.id)"
357
+ >
358
+ <Icon
359
+ :name="
360
+ collapsed.has(block.id)
361
+ ? 'chevron-right'
362
+ : 'chevron-down'
363
+ "
364
+ size="sm"
365
+ />
366
+ <span class="pui-block-editor__title">{{
367
+ title(block, index)
368
+ }}</span>
369
+ <span
370
+ v-if="title(block, index) !== typeLabel(block)"
371
+ class="pui-block-editor__type"
372
+ >{{ typeLabel(block) }}</span
373
+ >
374
+ </button>
375
+ <div
376
+ v-if="$slots.summary"
377
+ class="pui-block-editor__summary"
378
+ >
379
+ <slot
380
+ name="summary"
381
+ :block="block"
382
+ :index="index"
383
+ />
384
+ </div>
385
+ </div>
386
+ <div class="pui-block-editor__actions">
387
+ <IconButton
388
+ name="arrow-up"
389
+ variant="ghost"
390
+ size="sm"
391
+ :label="`Move ${title(block, index)} up`"
392
+ :disabled="disabled || index === 0"
393
+ @click="move(block.id, index - 1)"
394
+ />
395
+ <IconButton
396
+ name="arrow-down"
397
+ variant="ghost"
398
+ size="sm"
399
+ :label="`Move ${title(block, index)} down`"
400
+ :disabled="
401
+ disabled || index === modelValue.length - 1
402
+ "
403
+ @click="move(block.id, index + 1)"
404
+ />
405
+ <IconButton
406
+ name="trash-2"
407
+ variant="ghost"
408
+ size="sm"
409
+ :label="`Remove ${title(block, index)}`"
410
+ :disabled="disabled"
411
+ @click="remove(block.id)"
412
+ />
413
+ </div>
414
+ </div>
415
+ <div
416
+ :id="bodyId(block)"
417
+ :hidden="collapsed.has(block.id)"
418
+ class="pui-block-editor__body"
419
+ :inert="disabled ? true : undefined"
420
+ >
421
+ <slot
422
+ name="block"
423
+ :block="block"
424
+ :index="index"
425
+ :update="
426
+ (patch: BlockPatch<T>) => update(block.id, patch)
427
+ "
428
+ :disabled="disabled"
429
+ />
430
+ </div>
431
+ </li>
432
+ </ol>
433
+ <div v-else class="pui-block-editor__empty">
434
+ <slot name="empty"
435
+ >No blocks yet. Add a block to start writing.</slot
436
+ >
437
+ </div>
438
+ <div :id="`${id}-add`" class="pui-block-editor__add">
439
+ <Button
440
+ v-for="type in blockTypes"
441
+ :key="type.type"
442
+ icon="plus"
443
+ size="sm"
444
+ :disabled="disabled"
445
+ @click="add(type)"
446
+ >Add {{ type.label }}</Button
447
+ >
448
+ </div>
449
+ </div>
450
+ </template>
451
+
452
+ <style>
453
+ @import './block-editor.css';
454
+ </style>
@@ -0,0 +1,149 @@
1
+ @layer components {
2
+ .pui-block-editor {
3
+ display: grid;
4
+ gap: var(--space-s);
5
+ min-inline-size: 0;
6
+ }
7
+
8
+ .pui-block-editor__hint,
9
+ .pui-block-editor__summary {
10
+ margin: 0;
11
+ color: var(--text-clr-muted);
12
+ font-size: var(--step--1);
13
+ }
14
+
15
+ .pui-block-editor__list {
16
+ display: grid;
17
+ gap: var(--space-s);
18
+ margin: 0;
19
+ padding: 0;
20
+ list-style: none;
21
+ }
22
+
23
+ .pui-block-editor__item {
24
+ position: relative;
25
+ min-inline-size: 0;
26
+ border: var(--stroke-sm) solid var(--border-clr-subtle);
27
+ border-radius: var(--radius-md);
28
+ background: var(--bg-clr-surface);
29
+
30
+ &[data-dragging='true'] {
31
+ opacity: var(--opacity-disabled);
32
+ }
33
+
34
+ &[data-drop]::before {
35
+ content: '';
36
+ position: absolute;
37
+ inset-inline: 0;
38
+ block-size: var(--stroke-lg);
39
+ border-radius: var(--radius-pill);
40
+ background: var(--outline-clr-base);
41
+ pointer-events: none;
42
+ }
43
+
44
+ &[data-drop='before']::before {
45
+ inset-block-start: calc(var(--space-2xs) * -1);
46
+ }
47
+
48
+ &[data-drop='after']::before {
49
+ inset-block-end: calc(var(--space-2xs) * -1);
50
+ }
51
+ }
52
+
53
+ .pui-block-editor__header {
54
+ display: flex;
55
+ align-items: start;
56
+ gap: var(--space-2xs);
57
+ padding: var(--space-xs);
58
+ }
59
+
60
+ .pui-block-editor__heading {
61
+ flex: 1;
62
+ min-inline-size: 0;
63
+ }
64
+
65
+ .pui-block-editor__drag,
66
+ .pui-block-editor__toggle {
67
+ display: inline-flex;
68
+ align-items: center;
69
+ gap: var(--space-2xs);
70
+ min-block-size: var(--control-height-sm);
71
+ padding: 0;
72
+ border: 0;
73
+ background: transparent;
74
+ color: var(--text-clr-base);
75
+ font: inherit;
76
+ text-align: start;
77
+ cursor: pointer;
78
+
79
+ &:focus-visible {
80
+ outline: var(--outline-width) solid var(--outline-clr-base);
81
+ outline-offset: var(--outline-offset);
82
+ border-radius: var(--radius-xs);
83
+ }
84
+
85
+ &:disabled {
86
+ opacity: var(--opacity-disabled);
87
+ cursor: not-allowed;
88
+ }
89
+ }
90
+
91
+ .pui-block-editor__drag {
92
+ justify-content: center;
93
+ flex: 0 0 var(--control-height-sm);
94
+ touch-action: none;
95
+ cursor: grab;
96
+
97
+ &:active:not(:disabled) {
98
+ cursor: grabbing;
99
+ }
100
+ }
101
+
102
+ .pui-block-editor__toggle {
103
+ flex-wrap: wrap;
104
+ inline-size: 100%;
105
+ }
106
+
107
+ .pui-block-editor__title {
108
+ font-size: var(--step--1);
109
+ font-weight: var(--fw-control);
110
+ overflow-wrap: anywhere;
111
+ }
112
+
113
+ .pui-block-editor__type {
114
+ color: var(--text-clr-muted);
115
+ font-size: var(--step--2);
116
+ }
117
+
118
+ .pui-block-editor__actions,
119
+ .pui-block-editor__add {
120
+ display: flex;
121
+ flex-wrap: wrap;
122
+ align-items: center;
123
+ gap: var(--space-3xs);
124
+ }
125
+
126
+ .pui-block-editor__body {
127
+ display: grid;
128
+ gap: var(--space-s);
129
+ padding: var(--space-s);
130
+ border-block-start: var(--stroke-sm) solid var(--border-clr-subtle);
131
+
132
+ &[hidden] {
133
+ display: none;
134
+ }
135
+ }
136
+
137
+ .pui-block-editor__empty {
138
+ padding: var(--space-m);
139
+ border: var(--stroke-sm) dashed var(--border-clr-subtle);
140
+ border-radius: var(--radius-md);
141
+ color: var(--text-clr-muted);
142
+ font-size: var(--step--1);
143
+ text-align: center;
144
+ }
145
+
146
+ .pui-block-editor__add {
147
+ gap: var(--space-2xs);
148
+ }
149
+ }
@@ -0,0 +1,15 @@
1
+ export interface ContentBlock {
2
+ id: string;
3
+ type: string;
4
+ }
5
+
6
+ export interface BlockType<T extends ContentBlock = ContentBlock> {
7
+ type: string;
8
+ label: string;
9
+ /** Return a new block with a unique, stable id. */
10
+ create: () => T;
11
+ }
12
+
13
+ export type BlockPatch<T extends ContentBlock> = Partial<
14
+ Omit<T, 'id' | 'type'>
15
+ >;
@@ -278,6 +278,18 @@ const filteredOptions = computed(() => {
278
278
  return props.options.filter((opt) => fn(opt, q));
279
279
  });
280
280
 
281
+ const firstEnabledIndex = computed(() => {
282
+ const index = filteredOptions.value.findIndex((option) => !option.disabled);
283
+ return index >= 0 ? index : null;
284
+ });
285
+
286
+ const lastEnabledIndex = computed(() => {
287
+ for (let index = filteredOptions.value.length - 1; index >= 0; index -= 1) {
288
+ if (!filteredOptions.value[index]?.disabled) return index;
289
+ }
290
+ return null;
291
+ });
292
+
281
293
  const activeOptionDomId = computed(() => {
282
294
  if (activeIndex.value == null) return null;
283
295
  return `${listboxId}-opt-${activeIndex.value}`;
@@ -343,8 +355,7 @@ function onInput(event: Event): void {
343
355
  isTyping.value = true;
344
356
  // First filtered match becomes the active option so Enter has
345
357
  // an obvious target. Skip disabled options.
346
- const firstEnabled = filteredOptions.value.findIndex((o) => !o.disabled);
347
- activeIndex.value = firstEnabled >= 0 ? firstEnabled : null;
358
+ activeIndex.value = firstEnabledIndex.value;
348
359
  if (!isOpen.value) open();
349
360
  }
350
361
 
@@ -386,12 +397,7 @@ function onKeydown(event: KeyboardEvent): void {
386
397
  event.preventDefault();
387
398
  if (!isOpen.value) {
388
399
  open();
389
- // Defer active-index move until after the popover opens so
390
- // the listbox content is visible to scroll into view.
391
- const firstEnabled = filteredOptions.value.findIndex(
392
- (o) => !o.disabled,
393
- );
394
- activeIndex.value = firstEnabled >= 0 ? firstEnabled : null;
400
+ activeIndex.value = firstEnabledIndex.value;
395
401
  } else {
396
402
  moveActive(1);
397
403
  }
@@ -400,41 +406,22 @@ function onKeydown(event: KeyboardEvent): void {
400
406
  event.preventDefault();
401
407
  if (!isOpen.value) {
402
408
  open();
403
- const lastEnabled = [...filteredOptions.value]
404
- .map((o, i) => ({ o, i }))
405
- .reverse()
406
- .find(({ o }) => !o.disabled);
407
- activeIndex.value = lastEnabled ? lastEnabled.i : null;
409
+ activeIndex.value = lastEnabledIndex.value;
408
410
  } else {
409
411
  moveActive(-1);
410
412
  }
411
413
  break;
412
414
  case 'Home':
413
- if (!isOpen.value) return;
414
- event.preventDefault();
415
- {
416
- const firstEnabled = filteredOptions.value.findIndex(
417
- (o) => !o.disabled,
418
- );
419
- if (firstEnabled >= 0) {
420
- activeIndex.value = firstEnabled;
421
- scrollActiveIntoView();
422
- }
423
- }
424
- break;
425
415
  case 'End':
426
416
  if (!isOpen.value) return;
427
417
  event.preventDefault();
428
418
  {
429
- let lastEnabled = -1;
430
- for (let i = filteredOptions.value.length - 1; i >= 0; i -= 1) {
431
- if (!filteredOptions.value[i]?.disabled) {
432
- lastEnabled = i;
433
- break;
434
- }
435
- }
436
- if (lastEnabled >= 0) {
437
- activeIndex.value = lastEnabled;
419
+ const index =
420
+ event.key === 'Home'
421
+ ? firstEnabledIndex.value
422
+ : lastEnabledIndex.value;
423
+ if (index !== null) {
424
+ activeIndex.value = index;
438
425
  scrollActiveIntoView();
439
426
  }
440
427
  }