@invopop/popui 0.1.43 → 0.1.44

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,12 @@
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'
36
+ import { isInputFocused } from './helpers'
31
37
 
32
38
  const flipDurationMs = 150
33
39
 
@@ -81,6 +87,8 @@
81
87
  let draggedOverGroup = $state<string | null>(null)
82
88
  let dropIndicator = $state<DropIndicatorState>(null)
83
89
  let cleanupFunctions: (() => void)[] = []
90
+ let focusedIndex = $state<number>(-1)
91
+ let containerRef = $state<HTMLDivElement | null>(null)
84
92
 
85
93
  // Build internal DND items from external items
86
94
  function buildListIn() {
@@ -168,11 +176,14 @@
168
176
  element: document.documentElement
169
177
  })
170
178
  cleanupFunctions.push(autoScrollCleanup)
179
+
180
+ window.addEventListener('keydown', handleKeyDown)
171
181
  })
172
182
 
173
183
  onDestroy(() => {
174
184
  cleanupFunctions.forEach((cleanup) => cleanup())
175
185
  cleanupFunctions = []
186
+ window.removeEventListener('keydown', handleKeyDown)
176
187
  })
177
188
 
178
189
  function emitGroupDistribution() {
@@ -400,6 +411,40 @@
400
411
  function toggleGroup(groupSlug: string) {
401
412
  openGroups = openGroups[groupSlug] ? {} : { [groupSlug]: true }
402
413
  }
414
+
415
+ let focusedItemValue = $derived.by(() => {
416
+ const focusableItems = getFocusableItems(items)
417
+ if (focusedIndex >= 0 && focusedIndex < focusableItems.length) {
418
+ return focusableItems[focusedIndex].value
419
+ }
420
+ return null
421
+ })
422
+
423
+ function handleKeyDown(event: KeyboardEvent) {
424
+ // Don't handle if any input is focused or container doesn't exist
425
+ if (isInputFocused() || !containerRef || !document.body.contains(containerRef)) return
426
+
427
+ const focusableItems = getFocusableItems(items)
428
+ if (focusableItems.length === 0) return
429
+
430
+ if (event.key === 'ArrowDown') {
431
+ event.preventDefault()
432
+ focusedIndex = getNextFocusedIndex(focusedIndex, 'down', focusableItems.length)
433
+ } else if (event.key === 'ArrowUp') {
434
+ event.preventDefault()
435
+ focusedIndex = getNextFocusedIndex(focusedIndex, 'up', focusableItems.length)
436
+ } else if (event.key === ' ' || event.key === 'Enter') {
437
+ event.preventDefault()
438
+ const result = selectFocusedItem(items, focusedIndex, multiple)
439
+ if (result) {
440
+ if (result.shouldUpdate) {
441
+ updateItem(result.item)
442
+ } else {
443
+ onclick?.(result.item.value)
444
+ }
445
+ }
446
+ }
447
+ }
403
448
  </script>
404
449
 
405
450
  {#snippet drawerItem(item: DrawerOption)}
@@ -407,12 +452,18 @@
407
452
  <DrawerContextSeparator />
408
453
  {:else}
409
454
  <div class:px-1={!item.groupBy} class:cursor-grab={draggable && !item.locked}>
410
- <DrawerContextItem {item} {multiple} {onclick} onchange={updateItem} />
455
+ <DrawerContextItem
456
+ item={{ ...item, focused: item.value === focusedItemValue }}
457
+ {multiple}
458
+ {onclick}
459
+ onchange={updateItem}
460
+ />
411
461
  </div>
412
462
  {/if}
413
463
  {/snippet}
414
464
 
415
465
  <div
466
+ bind:this={containerRef}
416
467
  class="{widthClass} border border-border rounded-2xl shadow-lg bg-background flex flex-col py-1 max-h-[568px] list-none"
417
468
  >
418
469
  {@render children?.()}
@@ -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.44",
5
5
  "repository": {
6
6
  "url": "https://github.com/invopop/popui"
7
7
  },