@invopop/popui 0.1.43 → 0.1.45

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.
@@ -90,7 +90,7 @@
90
90
 
91
91
  <div class="flex {className}" class:w-full={fullWidth} role="menu">
92
92
  <button
93
- class="cursor-pointer text-left w-full min-w-0"
93
+ class="cursor-pointer text-left w-full min-w-0 outline-none"
94
94
  use:floatingRef
95
95
  {...rest}
96
96
  onclick={handleClick}
@@ -28,6 +28,11 @@
28
28
  type DropIndicatorState,
29
29
  type DndItem as DndItemType
30
30
  } from './drawer-dnd-helpers'
31
+ import {
32
+ getFocusableItems,
33
+ getNextFocusedIndex,
34
+ selectFocusedItem
35
+ } from './drawer-keyboard-helpers'
31
36
 
32
37
  const flipDurationMs = 150
33
38
 
@@ -70,6 +75,27 @@
70
75
  return { groupedItems: grouped, ungroupedItems: ungrouped }
71
76
  })
72
77
 
78
+ // Items in display order (matches visual rendering order)
79
+ let itemsInDisplayOrder = $derived.by(() => {
80
+ const displayOrder: DrawerOption[] = []
81
+
82
+ if (hasGroups && groups) {
83
+ // Add grouped items in group order, only if group is open (when collapsible)
84
+ groups.forEach((group) => {
85
+ const isOpen = collapsibleGroups ? openGroups[group.slug] : true
86
+ if (isOpen) {
87
+ const groupItems = groupedItems.get(group.slug) || []
88
+ displayOrder.push(...groupItems)
89
+ }
90
+ })
91
+ }
92
+
93
+ // Add ungrouped items
94
+ displayOrder.push(...ungroupedItems)
95
+
96
+ return displayOrder
97
+ })
98
+
73
99
  let openGroups = $state<Record<string, boolean>>({})
74
100
  let groupDndItems = $state<Record<string, DndItem[]>>({})
75
101
  let ungroupedDndItems = $state<DndItem[]>([])
@@ -81,6 +107,8 @@
81
107
  let draggedOverGroup = $state<string | null>(null)
82
108
  let dropIndicator = $state<DropIndicatorState>(null)
83
109
  let cleanupFunctions: (() => void)[] = []
110
+ let focusedIndex = $state<number>(-1)
111
+ let containerRef = $state<HTMLDivElement | null>(null)
84
112
 
85
113
  // Build internal DND items from external items
86
114
  function buildListIn() {
@@ -168,11 +196,14 @@
168
196
  element: document.documentElement
169
197
  })
170
198
  cleanupFunctions.push(autoScrollCleanup)
199
+
200
+ window.addEventListener('keydown', handleKeyDown)
171
201
  })
172
202
 
173
203
  onDestroy(() => {
174
204
  cleanupFunctions.forEach((cleanup) => cleanup())
175
205
  cleanupFunctions = []
206
+ window.removeEventListener('keydown', handleKeyDown)
176
207
  })
177
208
 
178
209
  function emitGroupDistribution() {
@@ -400,6 +431,40 @@
400
431
  function toggleGroup(groupSlug: string) {
401
432
  openGroups = openGroups[groupSlug] ? {} : { [groupSlug]: true }
402
433
  }
434
+
435
+ let focusedItemValue = $derived.by(() => {
436
+ const focusableItems = getFocusableItems(itemsInDisplayOrder)
437
+ if (focusedIndex >= 0 && focusedIndex < focusableItems.length) {
438
+ return focusableItems[focusedIndex].value
439
+ }
440
+ return null
441
+ })
442
+
443
+ function handleKeyDown(event: KeyboardEvent) {
444
+ // Don't handle if container doesn't exist
445
+ if (!containerRef || !document.body.contains(containerRef)) return
446
+
447
+ const focusableItems = getFocusableItems(itemsInDisplayOrder)
448
+ if (focusableItems.length === 0) return
449
+
450
+ if (event.key === 'ArrowDown') {
451
+ event.preventDefault()
452
+ focusedIndex = getNextFocusedIndex(focusedIndex, 'down', focusableItems.length)
453
+ } else if (event.key === 'ArrowUp') {
454
+ event.preventDefault()
455
+ focusedIndex = getNextFocusedIndex(focusedIndex, 'up', focusableItems.length)
456
+ } else if (event.key === ' ' || event.key === 'Enter') {
457
+ event.preventDefault()
458
+ const result = selectFocusedItem(itemsInDisplayOrder, focusedIndex, multiple)
459
+ if (result) {
460
+ if (result.shouldUpdate) {
461
+ updateItem(result.item)
462
+ } else {
463
+ onclick?.(result.item.value)
464
+ }
465
+ }
466
+ }
467
+ }
403
468
  </script>
404
469
 
405
470
  {#snippet drawerItem(item: DrawerOption)}
@@ -407,12 +472,18 @@
407
472
  <DrawerContextSeparator />
408
473
  {:else}
409
474
  <div class:px-1={!item.groupBy} class:cursor-grab={draggable && !item.locked}>
410
- <DrawerContextItem {item} {multiple} {onclick} onchange={updateItem} />
475
+ <DrawerContextItem
476
+ item={{ ...item, focused: item.value === focusedItemValue }}
477
+ {multiple}
478
+ {onclick}
479
+ onchange={updateItem}
480
+ />
411
481
  </div>
412
482
  {/if}
413
483
  {/snippet}
414
484
 
415
485
  <div
486
+ bind:this={containerRef}
416
487
  class="{widthClass} border border-border rounded-2xl shadow-lg bg-background flex flex-col py-1 max-h-[568px] list-none"
417
488
  >
418
489
  {@render children?.()}
@@ -433,7 +504,7 @@
433
504
  >
434
505
  {#if collapsibleGroups}
435
506
  <button
436
- class="cursor-pointer flex items-center justify-between h-8 pl-2.5 pr-2.5 py-2.5 text-base font-medium text-foreground-default-secondary w-full hover:bg-background-default-secondary rounded-lg overflow-clip flex-shrink-0"
507
+ class="cursor-pointer flex items-center justify-between h-8 pl-2.5 pr-2.5 py-2.5 text-base font-medium text-foreground-default-secondary w-full hover:bg-background-default-secondary rounded-lg overflow-clip flex-shrink-0 outline-none"
437
508
  onclick={() => toggleGroup(group.slug)}
438
509
  >
439
510
  <div class="flex items-center gap-1.5">
@@ -15,19 +15,24 @@
15
15
  item = $bindable(),
16
16
  scrollIfSelected = false,
17
17
  onchange,
18
- onclick
18
+ onclick,
19
+ onfocus
19
20
  }: DrawerContextItemProps = $props()
20
21
 
21
22
  let el: HTMLElement | undefined = $state()
22
23
 
24
+ let shouldShowHoverStyle = $derived.by(() => {
25
+ if (multiple) return true
26
+ if (item?.selected || item?.disabled) return false
27
+ return true
28
+ })
29
+
23
30
  let styles = $derived(
24
31
  clsx(
25
32
  'px-2 py-1.5 space-x-1.5',
26
33
  { 'bg-background-selected': item?.selected && !multiple },
27
- {
28
- 'group-hover:bg-background-default-secondary':
29
- (!item?.selected && !item?.disabled) || multiple
30
- }
34
+ { 'bg-background-default-secondary': item?.focused && shouldShowHoverStyle },
35
+ { 'group-hover:bg-background-default-secondary': shouldShowHoverStyle }
31
36
  )
32
37
  )
33
38
 
@@ -67,50 +72,53 @@
67
72
  >
68
73
  <div class="bg-background rounded-md">
69
74
  <div class="{styles} rounded-md pr-2 flex items-center justify-start w-full">
70
- {#if item?.useAvatar}
71
- <ProfileAvatar name={item?.label || ''} picture={item?.picture || ''} variant="sm" />
72
- {:else if item?.picture}
73
- <ProfileAvatar name={item?.label || ''} picture={item?.picture} variant="sm" />
74
- {:else if item?.icon}
75
- <Icon
76
- src={item.icon}
77
- class="w-4 h-4 {item?.destructive
78
- ? 'text-icon-critical'
79
- : item?.iconClass || 'text-icon'} {item?.locked ? 'opacity-30' : ''}"
80
- />
81
- {/if}
82
- <div class="whitespace-nowrap flex-1 text-left flex items-center space-x-1.5 truncate" {title}>
83
- {#if item?.color}
84
- <TagStatus status={item.color} dot />
75
+ {#if item?.useAvatar}
76
+ <ProfileAvatar name={item?.label || ''} picture={item?.picture || ''} variant="sm" />
77
+ {:else if item?.picture}
78
+ <ProfileAvatar name={item?.label || ''} picture={item?.picture} variant="sm" />
79
+ {:else if item?.icon}
80
+ <Icon
81
+ src={item.icon}
82
+ class="w-4 h-4 {item?.destructive
83
+ ? 'text-icon-critical'
84
+ : item?.iconClass || 'text-icon'} {item?.locked ? 'opacity-30' : ''}"
85
+ />
85
86
  {/if}
86
- <span class="{labelStyles} text-base font-medium truncate">{item?.label || ''}</span>
87
+ <div
88
+ class="whitespace-nowrap flex-1 text-left flex items-center space-x-1.5 truncate"
89
+ {title}
90
+ >
91
+ {#if item?.color}
92
+ <TagStatus status={item.color} dot />
93
+ {/if}
94
+ <span class="{labelStyles} text-base font-medium truncate">{item?.label || ''}</span>
87
95
 
88
- {#if item?.country}
89
- <BaseFlag country={item.country} />
90
- <span class="text-xs font-medium text-foreground-default-secondary uppercase">
91
- {item.country}
92
- </span>
93
- {/if}
94
- </div>
95
- {#if item?.action}
96
- <div class="no-drag !cursor-default">
97
- {@render item.action(item)}
96
+ {#if item?.country}
97
+ <BaseFlag country={item.country} />
98
+ <span class="text-xs font-medium text-foreground-default-secondary uppercase">
99
+ {item.country}
100
+ </span>
101
+ {/if}
98
102
  </div>
99
- {:else if multiple}
100
- <InputCheckbox
101
- checked={item?.selected ?? false}
102
- onchange={(value) => {
103
- if (item) {
104
- item.selected = value
105
- onchange?.(item)
106
- }
107
- }}
108
- />
109
- {:else if item?.selected}
110
- <Icon src={Success} class="size-4 text-icon-selected" />
111
- {:else if item?.rightIcon}
112
- <Icon src={item.rightIcon} class="size-4 text-icon-default-secondary" />
113
- {/if}
103
+ {#if item?.action}
104
+ <div class="no-drag !cursor-default">
105
+ {@render item.action(item)}
106
+ </div>
107
+ {:else if multiple}
108
+ <InputCheckbox
109
+ checked={item?.selected ?? false}
110
+ onchange={(value) => {
111
+ if (item) {
112
+ item.selected = value
113
+ onchange?.(item)
114
+ }
115
+ }}
116
+ />
117
+ {:else if item?.selected}
118
+ <Icon src={Success} class="size-4 text-icon-selected" />
119
+ {:else if item?.rightIcon}
120
+ <Icon src={item.rightIcon} class="size-4 text-icon-default-secondary" />
121
+ {/if}
114
122
  </div>
115
123
  </div>
116
124
  </button>
@@ -0,0 +1,16 @@
1
+ import type { DrawerOption } from './types';
2
+ /**
3
+ * Get all focusable items (non-separator, non-disabled, non-locked)
4
+ */
5
+ export declare function getFocusableItems(items: DrawerOption[]): DrawerOption[];
6
+ /**
7
+ * Calculate next focused index based on arrow key direction
8
+ */
9
+ export declare function getNextFocusedIndex(currentIndex: number, direction: 'up' | 'down', itemsCount: number): number;
10
+ /**
11
+ * Handle selection of focused item
12
+ */
13
+ export declare function selectFocusedItem(items: DrawerOption[], focusedIndex: number, multiple: boolean): {
14
+ item: DrawerOption;
15
+ shouldUpdate: boolean;
16
+ } | null;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Get all focusable items (non-separator, non-disabled, non-locked)
3
+ */
4
+ export function getFocusableItems(items) {
5
+ return items.filter((item) => !item.separator && !item.disabled && !item.locked);
6
+ }
7
+ /**
8
+ * Calculate next focused index based on arrow key direction
9
+ */
10
+ export function getNextFocusedIndex(currentIndex, direction, itemsCount) {
11
+ if (itemsCount === 0)
12
+ return -1;
13
+ if (direction === 'down') {
14
+ return currentIndex < itemsCount - 1 ? currentIndex + 1 : 0;
15
+ }
16
+ else {
17
+ return currentIndex > 0 ? currentIndex - 1 : itemsCount - 1;
18
+ }
19
+ }
20
+ /**
21
+ * Handle selection of focused item
22
+ */
23
+ export function selectFocusedItem(items, focusedIndex, multiple) {
24
+ const focusableItems = getFocusableItems(items);
25
+ if (focusedIndex >= 0 && focusedIndex < focusableItems.length) {
26
+ const focusedItem = focusableItems[focusedIndex];
27
+ if (multiple) {
28
+ return {
29
+ item: { ...focusedItem, selected: !focusedItem.selected },
30
+ shouldUpdate: true
31
+ };
32
+ }
33
+ else {
34
+ return {
35
+ item: focusedItem,
36
+ shouldUpdate: false
37
+ };
38
+ }
39
+ }
40
+ return null;
41
+ }
package/dist/types.d.ts CHANGED
@@ -29,6 +29,7 @@ export type DrawerOption = SelectOption & {
29
29
  separator?: boolean;
30
30
  destructive?: boolean;
31
31
  selected?: boolean;
32
+ focused?: boolean;
32
33
  icon?: IconSource | undefined;
33
34
  rightIcon?: IconSource | undefined;
34
35
  country?: string;
@@ -349,6 +350,7 @@ export interface DrawerContextItemProps {
349
350
  scrollIfSelected?: boolean;
350
351
  onclick?: (value: AnyProp) => void;
351
352
  onchange?: (item: DrawerOption) => void;
353
+ onfocus?: (item: DrawerOption) => void;
352
354
  }
353
355
  export interface DropdownSelectProps {
354
356
  value?: AnyProp;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@invopop/popui",
3
3
  "license": "MIT",
4
- "version": "0.1.43",
4
+ "version": "0.1.45",
5
5
  "repository": {
6
6
  "url": "https://github.com/invopop/popui"
7
7
  },