@insymetri/styleguide 0.1.54 → 0.1.55

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.
@@ -1,5 +1,4 @@
1
1
  <script lang="ts">
2
- import {tick} from 'svelte'
3
2
  import type {Snippet} from 'svelte'
4
3
  import {fly} from 'svelte/transition'
5
4
  import {IIIcon} from '../IIIcon'
@@ -25,6 +24,7 @@
25
24
  renderSelected?: Snippet<[item: MenuItem]>
26
25
  searchable?: boolean
27
26
  searchPlaceholder?: string
27
+ autofocus?: boolean
28
28
  class?: string
29
29
  }
30
30
 
@@ -43,6 +43,7 @@
43
43
  renderSelected,
44
44
  searchable = false,
45
45
  searchPlaceholder = 'Search...',
46
+ autofocus = false,
46
47
  class: className,
47
48
  }: Props = $props()
48
49
 
@@ -67,10 +68,36 @@
67
68
  let open = $state(false)
68
69
  let triggerEl = $state<HTMLElement | null>(null)
69
70
  let floatingEl = $state<HTMLElement | null>(null)
71
+ let highlightedIndex = $state(-1)
72
+
73
+ $effect(() => {
74
+ if (autofocus && triggerEl) triggerEl.focus()
75
+ })
70
76
 
71
77
  const selectedItem = $derived(items.find(i => i.value === value))
72
78
  const selectedLabel = $derived(selectedItem?.label ?? placeholder)
73
79
 
80
+ function scrollHighlightedIntoView() {
81
+ requestAnimationFrame(() => {
82
+ const el = floatingEl?.querySelector<HTMLElement>(`[role="option"][data-index="${highlightedIndex}"]`)
83
+ el?.scrollIntoView({block: 'nearest'})
84
+ })
85
+ }
86
+
87
+ function moveHighlight(direction: 1 | -1) {
88
+ const enabled = items.map((item, i) => (item.disabled ? -1 : i)).filter(i => i >= 0)
89
+ if (enabled.length === 0) return
90
+ const currentPos = enabled.indexOf(highlightedIndex)
91
+ const nextPos =
92
+ currentPos === -1
93
+ ? direction === 1 ? 0 : enabled.length - 1
94
+ : direction === 1
95
+ ? (currentPos >= enabled.length - 1 ? 0 : currentPos + 1)
96
+ : (currentPos <= 0 ? enabled.length - 1 : currentPos - 1)
97
+ highlightedIndex = enabled[nextPos]
98
+ scrollHighlightedIntoView()
99
+ }
100
+
74
101
  const search = createMenuSearch({
75
102
  getItems: () => items,
76
103
  onSelect: handleSelectValue,
@@ -106,21 +133,17 @@
106
133
  clearTimeout(typeaheadTimer)
107
134
  typeaheadTimer = setTimeout(() => { typeaheadBuffer = '' }, 500)
108
135
 
109
- const match = items.find(
136
+ const matchIndex = items.findIndex(
110
137
  i => !i.disabled && i.label.toLowerCase().startsWith(typeaheadBuffer)
111
138
  )
112
- if (match) {
113
- value = match.value
114
- onSelect?.(match.value)
139
+ if (matchIndex >= 0) {
140
+ const match = items[matchIndex]
115
141
  if (!wasOpen) {
116
- open = false
117
- triggerEl?.focus()
142
+ value = match.value
143
+ onSelect?.(match.value)
118
144
  } else {
119
- tick().then(() => {
120
- const el = floatingEl?.querySelector<HTMLElement>('[aria-selected="true"]')
121
- el?.focus()
122
- el?.scrollIntoView({block: 'nearest'})
123
- })
145
+ highlightedIndex = matchIndex
146
+ scrollHighlightedIntoView()
124
147
  }
125
148
  }
126
149
  }
@@ -128,15 +151,38 @@
128
151
  function handleTriggerKeydown(e: KeyboardEvent) {
129
152
  if (disabled) return
130
153
 
131
- if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
154
+ if (e.key === 'Escape' && open) {
132
155
  e.preventDefault()
133
- if (!open) open = true
156
+ close()
134
157
  return
135
158
  }
136
159
 
137
- if (e.key === ' ' || e.key === 'Enter') {
160
+ if (e.key === 'ArrowDown') {
138
161
  e.preventDefault()
139
162
  if (!open) open = true
163
+ else moveHighlight(1)
164
+ return
165
+ }
166
+ if (e.key === 'ArrowUp') {
167
+ e.preventDefault()
168
+ if (!open) open = true
169
+ else moveHighlight(-1)
170
+ return
171
+ }
172
+
173
+ if (e.key === 'Tab' && open) {
174
+ e.preventDefault()
175
+ moveHighlight(e.shiftKey ? -1 : 1)
176
+ return
177
+ }
178
+
179
+ if (e.key === 'Enter' || e.key === ' ') {
180
+ e.preventDefault()
181
+ if (!open) {
182
+ open = true
183
+ } else if (highlightedIndex >= 0 && !items[highlightedIndex]?.disabled) {
184
+ handleSelect(items[highlightedIndex])
185
+ }
140
186
  return
141
187
  }
142
188
 
@@ -163,66 +209,17 @@
163
209
  }
164
210
  })
165
211
 
166
- // Focus selected item (or first) when opened (non-searchable)
212
+ // Initialize highlight to selected (or first enabled) when opened (non-searchable)
167
213
  $effect(() => {
168
- if (open && floatingEl && !searchable) {
169
- requestAnimationFrame(() => {
170
- const selected = floatingEl?.querySelector<HTMLElement>('[role="option"][aria-selected="true"]:not([data-disabled])')
171
- const target = selected ?? floatingEl?.querySelector<HTMLElement>('[role="option"]:not([data-disabled])')
172
- target?.focus()
173
- target?.scrollIntoView({block: 'nearest'})
174
- })
214
+ if (open && !searchable) {
215
+ const selectedIdx = items.findIndex(i => i.value === value && !i.disabled)
216
+ const firstIdx = items.findIndex(i => !i.disabled)
217
+ highlightedIndex = selectedIdx >= 0 ? selectedIdx : firstIdx
218
+ scrollHighlightedIntoView()
219
+ } else if (!open) {
220
+ highlightedIndex = -1
175
221
  }
176
222
  })
177
-
178
- function getOptionItems(): HTMLElement[] {
179
- if (!floatingEl) return []
180
- return Array.from(floatingEl.querySelectorAll<HTMLElement>('[role="option"]:not([data-disabled])'))
181
- }
182
-
183
- function focusItem(optionItems: HTMLElement[], currentIndex: number, direction: 1 | -1) {
184
- if (optionItems.length === 0) return
185
- const nextIndex = direction === 1
186
- ? (currentIndex >= optionItems.length - 1 ? 0 : currentIndex + 1)
187
- : (currentIndex <= 0 ? optionItems.length - 1 : currentIndex - 1)
188
- optionItems[nextIndex]?.focus()
189
- }
190
-
191
- function handleListKeydown(e: KeyboardEvent) {
192
- if (searchable) return
193
-
194
- const optionItems = getOptionItems()
195
- const currentIndex = optionItems.indexOf(document.activeElement as HTMLElement)
196
-
197
- switch (e.key) {
198
- case 'Tab':
199
- e.preventDefault()
200
- focusItem(optionItems, currentIndex, e.shiftKey ? -1 : 1)
201
- break
202
- case 'ArrowDown':
203
- e.preventDefault()
204
- focusItem(optionItems, currentIndex, 1)
205
- break
206
- case 'ArrowUp':
207
- e.preventDefault()
208
- focusItem(optionItems, currentIndex, -1)
209
- break
210
- case 'Enter':
211
- e.preventDefault()
212
- ;(document.activeElement as HTMLElement)?.click()
213
- break
214
- case 'Escape':
215
- e.preventDefault()
216
- close()
217
- break
218
- default:
219
- if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
220
- e.preventDefault()
221
- typeahead(e.key)
222
- }
223
- break
224
- }
225
- }
226
223
  </script>
227
224
 
228
225
  <div class="flex flex-col">
@@ -262,8 +259,7 @@
262
259
  bind:this={floatingEl}
263
260
  role="listbox"
264
261
  data-menu-content
265
- class="min-w-100 bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-dropdown p-4 z-16 outline-none"
266
- onkeydown={handleListKeydown}
262
+ class="min-w-100 bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-dropdown p-4 z-16 pointer-events-auto outline-none"
267
263
  transition:fly={{y: -4, duration: 150}}
268
264
  >
269
265
  {#if searchable}
@@ -275,25 +271,29 @@
275
271
  />
276
272
  {/if}
277
273
  <div class="max-h-300 overflow-y-auto">
278
- {#each (searchable ? search.filteredItems as MenuItem[] : items) as item (item.value)}
279
- {@const index = searchable ? search.getItemIndex(item) : -1}
274
+ {#each (searchable ? search.filteredItems as MenuItem[] : items) as item, listIndex (item.value)}
275
+ {@const index = searchable ? search.getItemIndex(item) : listIndex}
276
+ {@const isHighlighted = searchable
277
+ ? index === search.highlightedIndex
278
+ : index === highlightedIndex}
280
279
  <div
281
280
  role="option"
282
281
  tabindex="-1"
283
282
  aria-selected={value === item.value}
284
283
  data-disabled={item.disabled ? '' : undefined}
284
+ data-index={index}
285
285
  class={cn(
286
286
  'flex items-center justify-between gap-12 px-12 rounded-6 text-dropdown-item cursor-default outline-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed',
287
287
  itemDensityClasses[density.value],
288
288
  value === item.value && 'text-dropdown-item-selected',
289
- !searchable && 'focus:bg-dropdown-item-hover',
290
- searchable && index === search.highlightedIndex && 'bg-dropdown-item-hover'
289
+ isHighlighted && 'bg-dropdown-item-hover'
291
290
  )}
292
291
  data-search-item={searchable ? '' : undefined}
293
292
  onclick={() => handleSelect(item)}
294
293
  onfocus={searchable ? search.refocusInput : undefined}
295
- onpointermove={searchable ? () => search.setHighlight(index) : undefined}
296
- onpointerenter={!searchable ? (e) => (e.currentTarget as HTMLElement).focus() : undefined}
294
+ onpointermove={searchable
295
+ ? () => search.setHighlight(index)
296
+ : () => { if (!item.disabled) highlightedIndex = index }}
297
297
  >
298
298
  {#if renderItem}
299
299
  {@render renderItem(item, value === item.value)}
@@ -15,6 +15,7 @@ type Props = {
15
15
  renderSelected?: Snippet<[item: MenuItem]>;
16
16
  searchable?: boolean;
17
17
  searchPlaceholder?: string;
18
+ autofocus?: boolean;
18
19
  class?: string;
19
20
  };
20
21
  declare const IIDropdownInput: import("svelte").Component<Props, {}, "value">;
@@ -0,0 +1,33 @@
1
+ <script lang="ts">
2
+ import IIDropdownInput from './IIDropdownInput.svelte'
3
+ import IIModal from '../IIModal/IIModal.svelte'
4
+ import IIButton from '../IIButton/IIButton.svelte'
5
+
6
+ let open = $state(true)
7
+ let value = $state('pto')
8
+
9
+ const items = [
10
+ {label: 'PTO', value: 'pto'},
11
+ {label: 'Sick', value: 'sick'},
12
+ {label: 'Personal', value: 'personal'},
13
+ ]
14
+ </script>
15
+
16
+ <IIButton onclick={() => (open = true)}>Open modal</IIButton>
17
+
18
+ {#if open}
19
+ <IIModal bind:open title="Request time off" size="sm" onOpenChange={v => (open = v)}>
20
+ <div class="flex flex-col gap-16">
21
+ <p class="text-small text-secondary m-0">
22
+ Repro: bits-ui Dialog sets <code>body &#123; pointer-events: none &#125;</code> when open. The dropdown's
23
+ portaled listbox needs <code>pointer-events: auto</code> or clicks fall through to the dialog content,
24
+ which triggers <code>clickOutside</code> and closes the dropdown without selecting.
25
+ </p>
26
+ <div class="flex flex-col gap-8">
27
+ <label for="type" class="text-small-emphasis text-secondary">Type</label>
28
+ <IIDropdownInput {items} bind:value matchTriggerWidth autofocus />
29
+ </div>
30
+ <p class="text-small text-secondary m-0">Selected value: <strong>{value}</strong></p>
31
+ </div>
32
+ </IIModal>
33
+ {/if}
@@ -0,0 +1,3 @@
1
+ declare const IIDropdownInputModalStories: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type IIDropdownInputModalStories = ReturnType<typeof IIDropdownInputModalStories>;
3
+ export default IIDropdownInputModalStories;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@insymetri/styleguide",
3
- "version": "0.1.54",
3
+ "version": "0.1.55",
4
4
  "description": "Insymetri shared UI component library built with Svelte 5",
5
5
  "type": "module",
6
6
  "scripts": {