@insymetri/styleguide 0.1.35 → 0.1.37

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 (39) hide show
  1. package/dist/IIContextMenu/IIContextMenu.svelte +223 -98
  2. package/dist/IIContextMenu/IIContextMenu.svelte.d.ts +13 -0
  3. package/dist/IIContextMenu/IIContextMenuStories.svelte +8 -0
  4. package/dist/IIContextMenu/IIContextMenuSub.svelte +131 -0
  5. package/dist/IIContextMenu/IIContextMenuSub.svelte.d.ts +13 -0
  6. package/dist/IIContextMenu/IIContextMenuSubSimple.svelte +137 -0
  7. package/dist/IIContextMenu/IIContextMenuSubSimple.svelte.d.ts +13 -0
  8. package/dist/IIDropdownInput/IIDropdownInput.svelte +161 -60
  9. package/dist/IIDropdownInput/IIDropdownInput.svelte.d.ts +6 -9
  10. package/dist/IIDropdownMenu/IIDropdownMenu.svelte +278 -89
  11. package/dist/IIDropdownMenu/IIDropdownMenu.svelte.d.ts +4 -17
  12. package/dist/IIDropdownMenu/IIDropdownMenuStories.svelte +20 -0
  13. package/dist/IIDropdownMenu/IIDropdownMenuSub.svelte +131 -0
  14. package/dist/IIDropdownMenu/IIDropdownMenuSub.svelte.d.ts +13 -0
  15. package/dist/IIDropdownMenu/IIDropdownMenuSubSimple.svelte +134 -0
  16. package/dist/IIDropdownMenu/IIDropdownMenuSubSimple.svelte.d.ts +13 -0
  17. package/dist/style/colors.css +1 -1
  18. package/dist/style/tailwind/animations.js +1 -1
  19. package/dist/style/tailwind/shadows.d.ts +1 -0
  20. package/dist/style/tailwind/shadows.js +3 -1
  21. package/dist/utils/menu/MenuItemContent.svelte +21 -0
  22. package/dist/utils/menu/MenuItemContent.svelte.d.ts +9 -0
  23. package/dist/utils/menu/MenuNoResults.svelte +4 -0
  24. package/dist/utils/menu/MenuNoResults.svelte.d.ts +18 -0
  25. package/dist/utils/menu/MenuSearchInput.svelte +25 -0
  26. package/dist/utils/menu/MenuSearchInput.svelte.d.ts +9 -0
  27. package/dist/utils/menu/index.d.ts +8 -0
  28. package/dist/utils/menu/index.js +8 -0
  29. package/dist/utils/menu/menu-search.svelte.d.ts +53 -0
  30. package/dist/utils/menu/menu-search.svelte.js +138 -0
  31. package/dist/utils/menu/menu-styles.d.ts +16 -0
  32. package/dist/utils/menu/menu-styles.js +19 -0
  33. package/dist/utils/menu/use-click-outside.d.ts +9 -0
  34. package/dist/utils/menu/use-click-outside.js +45 -0
  35. package/dist/utils/menu/use-floating.svelte.d.ts +13 -0
  36. package/dist/utils/menu/use-floating.svelte.js +55 -0
  37. package/dist/utils/menu/use-portal.d.ts +3 -0
  38. package/dist/utils/menu/use-portal.js +8 -0
  39. package/package.json +8 -1
@@ -0,0 +1,137 @@
1
+ <script lang="ts">
2
+ import type {Snippet} from 'svelte'
3
+ import {isItem, isGroup, isSub, isSeparator, menuStyles, menuItemClass, type MenuItem, type MenuEntry, type SubEntry} from '../utils/menu'
4
+ import {useFloating, portal} from '../utils/menu'
5
+ import {cn} from '../utils/cn'
6
+ import {IIIcon} from '../IIIcon'
7
+ import MenuItemContent from '../utils/menu/MenuItemContent.svelte'
8
+
9
+ type Props = {
10
+ items: MenuEntry[]
11
+ onSelect: (value: string) => void
12
+ onClose: () => void
13
+ triggerEl: HTMLElement | null
14
+ renderItemContent: Snippet<[MenuItem]>
15
+ renderSubMenu: Snippet<[SubEntry]>
16
+ }
17
+
18
+ let {
19
+ items,
20
+ onSelect,
21
+ onClose,
22
+ triggerEl,
23
+ renderItemContent,
24
+ renderSubMenu,
25
+ }: Props = $props()
26
+
27
+ let floatingEl = $state<HTMLElement | null>(null)
28
+
29
+ useFloating({
30
+ reference: () => triggerEl,
31
+ floating: () => floatingEl,
32
+ placement: 'right-start',
33
+ offset: 4,
34
+ })
35
+
36
+ function activate() {
37
+ requestAnimationFrame(() => {
38
+ const first = floatingEl?.querySelector<HTMLElement>('[role="menuitem"]:not([data-disabled])')
39
+ first?.focus()
40
+ })
41
+ }
42
+
43
+ function getMenuItems(): HTMLElement[] {
44
+ if (!floatingEl) return []
45
+ return Array.from(floatingEl.querySelectorAll<HTMLElement>(':scope > [role="menuitem"]:not([data-disabled]), :scope > [role="group"] > [role="menuitem"]:not([data-disabled])'))
46
+ }
47
+
48
+ function focusItem(items: HTMLElement[], currentIndex: number, direction: 1 | -1) {
49
+ if (items.length === 0) return
50
+ const nextIndex = direction === 1
51
+ ? (currentIndex >= items.length - 1 ? 0 : currentIndex + 1)
52
+ : (currentIndex <= 0 ? items.length - 1 : currentIndex - 1)
53
+ items[nextIndex]?.focus()
54
+ }
55
+
56
+ function handleKeydown(e: KeyboardEvent) {
57
+ const menuItems = getMenuItems()
58
+ const currentIndex = menuItems.indexOf(document.activeElement as HTMLElement)
59
+
60
+ switch (e.key) {
61
+ case 'ArrowDown':
62
+ e.preventDefault()
63
+ focusItem(menuItems, currentIndex, 1)
64
+ break
65
+ case 'ArrowUp':
66
+ e.preventDefault()
67
+ focusItem(menuItems, currentIndex, -1)
68
+ break
69
+ case 'ArrowLeft':
70
+ case 'Escape':
71
+ e.preventDefault()
72
+ onClose()
73
+ break
74
+ case 'Enter':
75
+ case ' ':
76
+ e.preventDefault()
77
+ ;(document.activeElement as HTMLElement)?.click()
78
+ break
79
+ }
80
+ }
81
+ </script>
82
+
83
+ <div use:portal>
84
+ <div
85
+ bind:this={floatingEl}
86
+ role="menu"
87
+ data-menu-content
88
+ class={menuStyles.subContent}
89
+ onkeydown={handleKeydown}
90
+ onpointerenter={() => activate()}
91
+ onpointerleave={(e) => e.stopImmediatePropagation()}
92
+ >
93
+ {#each items as entry, i (i)}
94
+ {#if isSeparator(entry)}
95
+ <div role="separator" class={menuStyles.separator}></div>
96
+ {:else if isGroup(entry)}
97
+ {@const headingId = `sub-group-${i}`}
98
+ <div role="group" aria-labelledby={entry.heading ? headingId : undefined}>
99
+ {#if entry.heading}
100
+ <div id={headingId} role="presentation" class={menuStyles.groupHeading}>
101
+ {entry.heading}
102
+ </div>
103
+ {/if}
104
+ {#each entry.items as groupItem (isItem(groupItem) ? groupItem.value : groupItem.label)}
105
+ {#if isSub(groupItem)}
106
+ {@render renderSubMenu(groupItem)}
107
+ {:else if isItem(groupItem)}
108
+ <div
109
+ role="menuitem"
110
+ tabindex="-1"
111
+ data-disabled={groupItem.disabled ? '' : undefined}
112
+ class={menuItemClass({variant: groupItem.variant})}
113
+ onclick={() => onSelect(groupItem.value)}
114
+ onpointerenter={(e) => (e.currentTarget as HTMLElement).focus()}
115
+ >
116
+ {@render renderItemContent(groupItem)}
117
+ </div>
118
+ {/if}
119
+ {/each}
120
+ </div>
121
+ {:else if isSub(entry)}
122
+ {@render renderSubMenu(entry)}
123
+ {:else if isItem(entry)}
124
+ <div
125
+ role="menuitem"
126
+ tabindex="-1"
127
+ data-disabled={entry.disabled ? '' : undefined}
128
+ class={menuItemClass({variant: entry.variant})}
129
+ onclick={() => onSelect(entry.value)}
130
+ onpointerenter={(e) => (e.currentTarget as HTMLElement).focus()}
131
+ >
132
+ {@render renderItemContent(entry)}
133
+ </div>
134
+ {/if}
135
+ {/each}
136
+ </div>
137
+ </div>
@@ -0,0 +1,13 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type MenuItem, type MenuEntry, type SubEntry } from '../utils/menu';
3
+ type Props = {
4
+ items: MenuEntry[];
5
+ onSelect: (value: string) => void;
6
+ onClose: () => void;
7
+ triggerEl: HTMLElement | null;
8
+ renderItemContent: Snippet<[MenuItem]>;
9
+ renderSubMenu: Snippet<[SubEntry]>;
10
+ };
11
+ declare const IIContextMenuSubSimple: import("svelte").Component<Props, {}, "">;
12
+ type IIContextMenuSubSimple = ReturnType<typeof IIContextMenuSubSimple>;
13
+ export default IIContextMenuSubSimple;
@@ -1,27 +1,25 @@
1
1
  <script lang="ts">
2
2
  import type {Snippet} from 'svelte'
3
- import {DropdownMenu} from 'bits-ui'
4
3
  import {IIIcon} from '../IIIcon'
5
4
  import {cn} from '../utils/cn'
6
5
  import {useDensity} from '../density'
7
-
8
- type Item = {
9
- label: string
10
- value: string
11
- icon?: Snippet
12
- disabled?: boolean
13
- }
6
+ import {createMenuSearch, type MenuItem} from '../utils/menu'
7
+ import {useFloating, portal, clickOutside} from '../utils/menu'
8
+ import MenuSearchInput from '../utils/menu/MenuSearchInput.svelte'
9
+ import MenuNoResults from '../utils/menu/MenuNoResults.svelte'
14
10
 
15
11
  type Props = {
16
- items: Item[]
12
+ items: MenuItem[]
17
13
  value?: string | undefined
18
14
  placeholder?: string
19
15
  label?: string
20
16
  disabled?: boolean
21
17
  onSelect?: (value: string) => void
22
18
  matchTriggerWidth?: boolean
23
- renderItem?: Snippet<[item: Item, selected: boolean]>
24
- renderSelected?: Snippet<[item: Item]>
19
+ renderItem?: Snippet<[item: MenuItem, selected: boolean]>
20
+ renderSelected?: Snippet<[item: MenuItem]>
21
+ searchable?: boolean
22
+ searchPlaceholder?: string
25
23
  class?: string
26
24
  }
27
25
 
@@ -35,6 +33,8 @@
35
33
  matchTriggerWidth = false,
36
34
  renderItem,
37
35
  renderSelected,
36
+ searchable = false,
37
+ searchPlaceholder = 'Search...',
38
38
  class: className,
39
39
  }: Props = $props()
40
40
 
@@ -56,31 +56,113 @@
56
56
 
57
57
  let open = $state(false)
58
58
  let triggerEl = $state<HTMLElement | null>(null)
59
- let triggerWidth = $state<number | undefined>(undefined)
59
+ let floatingEl = $state<HTMLElement | null>(null)
60
60
 
61
61
  const selectedItem = $derived(items.find(i => i.value === value))
62
62
  const selectedLabel = $derived(selectedItem?.label ?? placeholder)
63
63
 
64
- function handleSelect(item: Item) {
64
+ const search = createMenuSearch({
65
+ getItems: () => items,
66
+ onSelect: handleSelectValue,
67
+ })
68
+
69
+ useFloating({
70
+ reference: () => triggerEl,
71
+ floating: () => floatingEl,
72
+ placement: 'bottom-start',
73
+ offset: 2,
74
+ matchWidth: matchTriggerWidth,
75
+ })
76
+
77
+ function handleSelect(item: MenuItem) {
65
78
  value = item.value
66
79
  open = false
67
80
  onSelect?.(item.value)
81
+ triggerEl?.focus()
82
+ }
83
+
84
+ function handleSelectValue(val: string) {
85
+ const item = items.find(i => i.value === val)
86
+ if (item) handleSelect(item)
87
+ }
88
+
89
+ function toggle() {
90
+ if (disabled) return
91
+ open = !open
92
+ }
93
+
94
+ function close() {
95
+ open = false
96
+ triggerEl?.focus()
68
97
  }
69
98
 
70
99
  $effect(() => {
71
- if (matchTriggerWidth && open && triggerEl) {
72
- triggerWidth = triggerEl.offsetWidth
100
+ if (open && searchable) {
101
+ search.resetAndFocus()
73
102
  }
74
103
  })
104
+
105
+ // Focus first item when opened (non-searchable)
106
+ $effect(() => {
107
+ if (open && floatingEl && !searchable) {
108
+ requestAnimationFrame(() => {
109
+ const first = floatingEl?.querySelector<HTMLElement>('[role="option"]:not([data-disabled])')
110
+ first?.focus()
111
+ })
112
+ }
113
+ })
114
+
115
+ function getOptionItems(): HTMLElement[] {
116
+ if (!floatingEl) return []
117
+ return Array.from(floatingEl.querySelectorAll<HTMLElement>('[role="option"]:not([data-disabled])'))
118
+ }
119
+
120
+ function focusItem(optionItems: HTMLElement[], currentIndex: number, direction: 1 | -1) {
121
+ if (optionItems.length === 0) return
122
+ const nextIndex = direction === 1
123
+ ? (currentIndex >= optionItems.length - 1 ? 0 : currentIndex + 1)
124
+ : (currentIndex <= 0 ? optionItems.length - 1 : currentIndex - 1)
125
+ optionItems[nextIndex]?.focus()
126
+ }
127
+
128
+ function handleListKeydown(e: KeyboardEvent) {
129
+ if (searchable) return
130
+
131
+ const optionItems = getOptionItems()
132
+ const currentIndex = optionItems.indexOf(document.activeElement as HTMLElement)
133
+
134
+ switch (e.key) {
135
+ case 'ArrowDown':
136
+ e.preventDefault()
137
+ focusItem(optionItems, currentIndex, 1)
138
+ break
139
+ case 'ArrowUp':
140
+ e.preventDefault()
141
+ focusItem(optionItems, currentIndex, -1)
142
+ break
143
+ case 'Enter':
144
+ case ' ':
145
+ e.preventDefault()
146
+ ;(document.activeElement as HTMLElement)?.click()
147
+ break
148
+ case 'Escape':
149
+ e.preventDefault()
150
+ close()
151
+ break
152
+ }
153
+ }
75
154
  </script>
76
155
 
77
156
  <div class="flex flex-col">
78
157
  {#if label}
79
158
  <span class="text-small-emphasis text-secondary mb-4">{label}</span>
80
159
  {/if}
81
- <DropdownMenu.Root bind:open>
82
- <DropdownMenu.Trigger
83
- bind:ref={triggerEl}
160
+ <button
161
+ bind:this={triggerEl}
162
+ type="button"
163
+ role="combobox"
164
+ aria-haspopup="listbox"
165
+ aria-expanded={open}
84
166
  {disabled}
85
167
  class={cn(
86
168
  'flex items-center justify-between gap-4 py-5 pl-12 pr-8 border bg-input-bg cursor-default text-button-secondary box-border appearance-none font-inherit outline-none transition-colors duration-base ease-in-out hover:text-button-secondary-hover hover:border-button-secondary-hover focus:border-accent focus:ring-3 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed',
@@ -88,6 +170,7 @@
88
170
  open ? 'border-button-secondary-hover' : 'border-button-secondary',
89
171
  className
90
172
  )}
173
+ onclick={toggle}
91
174
  >
92
175
  {#if renderSelected && selectedItem}
93
176
  {@render renderSelected(selectedItem)}
@@ -95,50 +178,68 @@
95
178
  <span>{selectedLabel}</span>
96
179
  {/if}
97
180
  <IIIcon iconName="caret-down" class="w-14 h-14 shrink-0" />
98
- </DropdownMenu.Trigger>
99
- <DropdownMenu.Portal>
100
- <DropdownMenu.Content
101
- class="min-w-100 bg-dropdown-bg border border-dropdown-border rounded-10 shadow-dropdown p-4 z-16 outline-none"
102
- sideOffset={2}
103
- side="bottom"
104
- align="start"
105
- >
106
- {#snippet child({props, wrapperProps})}
107
- <div {...wrapperProps} style:min-width={matchTriggerWidth && triggerWidth ? `${triggerWidth}px` : undefined}>
108
- <div {...props}>
109
- <div class="max-h-300 overflow-y-auto">
110
- {#each items as item (item.value)}
111
- <DropdownMenu.Item
112
- disabled={item.disabled}
113
- class={cn(
114
- 'flex items-center justify-between gap-12 px-12 rounded-6 text-dropdown-item cursor-default outline-none data-[highlighted]:bg-dropdown-item-hover data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed',
115
- itemDensityClasses[density.value],
116
- value === item.value && 'text-dropdown-item-selected'
117
- )}
118
- onSelect={() => handleSelect(item)}
119
- >
120
- {#if renderItem}
121
- {@render renderItem(item, value === item.value)}
122
- {:else}
123
- <span class="flex items-center gap-8">
124
- {#if item.icon}
125
- <span class="w-16 h-16 flex items-center justify-center shrink-0 [&_svg]:w-16 [&_svg]:h-16">
126
- {@render item.icon()}
127
- </span>
128
- {/if}
129
- {item.label}
181
+ </button>
182
+
183
+ {#if open}
184
+ <div use:portal use:clickOutside={{onClose: close}}>
185
+ <div
186
+ bind:this={floatingEl}
187
+ role="listbox"
188
+ data-menu-content
189
+ class="min-w-100 bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-dropdown p-4 z-16 outline-none"
190
+ onkeydown={handleListKeydown}
191
+ >
192
+ {#if searchable}
193
+ <MenuSearchInput
194
+ bind:inputEl={search.inputEl}
195
+ bind:searchQuery={search.searchQuery}
196
+ placeholder={searchPlaceholder}
197
+ onkeydown={(e) => search.handleKeydown(e, '[data-menu-content]')}
198
+ />
199
+ {/if}
200
+ <div class="max-h-300 overflow-y-auto">
201
+ {#each (searchable ? search.filteredItems as MenuItem[] : items) as item (item.value)}
202
+ {@const index = searchable ? search.getItemIndex(item) : -1}
203
+ <div
204
+ role="option"
205
+ tabindex="-1"
206
+ aria-selected={value === item.value}
207
+ data-disabled={item.disabled ? '' : undefined}
208
+ class={cn(
209
+ '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',
210
+ itemDensityClasses[density.value],
211
+ value === item.value && 'text-dropdown-item-selected',
212
+ !searchable && 'focus:bg-dropdown-item-hover',
213
+ searchable && index === search.highlightedIndex && 'bg-dropdown-item-hover'
214
+ )}
215
+ data-search-item={searchable ? '' : undefined}
216
+ onclick={() => handleSelect(item)}
217
+ onfocus={searchable ? search.refocusInput : undefined}
218
+ onpointermove={searchable ? () => search.setHighlight(index) : undefined}
219
+ onpointerenter={!searchable ? (e) => (e.currentTarget as HTMLElement).focus() : undefined}
220
+ >
221
+ {#if renderItem}
222
+ {@render renderItem(item, value === item.value)}
223
+ {:else}
224
+ <span class="flex items-center gap-8">
225
+ {#if item.icon}
226
+ <span class="w-16 h-16 flex items-center justify-center shrink-0 [&_svg]:w-16 [&_svg]:h-16">
227
+ {@render item.icon()}
130
228
  </span>
131
- {#if value === item.value}
132
- <IIIcon iconName="check" class="w-14 h-14 text-accent shrink-0" />
133
- {/if}
134
229
  {/if}
135
- </DropdownMenu.Item>
136
- {/each}
230
+ {item.label}
231
+ </span>
232
+ {#if value === item.value}
233
+ <IIIcon iconName="check" class="w-14 h-14 text-accent shrink-0" />
234
+ {/if}
235
+ {/if}
137
236
  </div>
138
- </div>
237
+ {/each}
238
+ {#if searchable && search.filteredItems.length === 0}
239
+ <MenuNoResults />
240
+ {/if}
139
241
  </div>
140
- {/snippet}
141
- </DropdownMenu.Content>
142
- </DropdownMenu.Portal>
143
- </DropdownMenu.Root>
242
+ </div>
243
+ </div>
244
+ {/if}
144
245
  </div>
@@ -1,20 +1,17 @@
1
1
  import type { Snippet } from 'svelte';
2
- type Item = {
3
- label: string;
4
- value: string;
5
- icon?: Snippet;
6
- disabled?: boolean;
7
- };
2
+ import { type MenuItem } from '../utils/menu';
8
3
  type Props = {
9
- items: Item[];
4
+ items: MenuItem[];
10
5
  value?: string | undefined;
11
6
  placeholder?: string;
12
7
  label?: string;
13
8
  disabled?: boolean;
14
9
  onSelect?: (value: string) => void;
15
10
  matchTriggerWidth?: boolean;
16
- renderItem?: Snippet<[item: Item, selected: boolean]>;
17
- renderSelected?: Snippet<[item: Item]>;
11
+ renderItem?: Snippet<[item: MenuItem, selected: boolean]>;
12
+ renderSelected?: Snippet<[item: MenuItem]>;
13
+ searchable?: boolean;
14
+ searchPlaceholder?: string;
18
15
  class?: string;
19
16
  };
20
17
  declare const IIDropdownInput: import("svelte").Component<Props, {}, "value">;