@invopop/popui 0.1.4-beta.22 → 0.1.4-beta.24

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.
@@ -10,12 +10,16 @@
10
10
  fullWidth = false,
11
11
  placement = 'bottom-start',
12
12
  matchParentWidth = false,
13
+ usePortal = true,
13
14
  class: className = '',
14
15
  trigger,
15
16
  children,
16
17
  ...rest
17
18
  }: BaseDropdownProps = $props()
18
19
 
20
+ // Conditional portal action - noop if disabled
21
+ const conditionalPortal = usePortal ? portal : () => {}
22
+
19
23
  const middleware = [offset(6), flip(), shift()]
20
24
 
21
25
  if (matchParentWidth) {
@@ -31,14 +35,16 @@
31
35
  )
32
36
  }
33
37
 
38
+ let closedFromClickOutside = $state(false)
39
+
40
+ // Create floating actions with strategy based on usePortal
41
+ const strategy = usePortal ? 'absolute' : 'fixed'
34
42
  const [floatingRef, floatingContent] = createFloatingActions({
35
- strategy: 'absolute',
43
+ strategy,
36
44
  placement,
37
45
  middleware
38
46
  })
39
47
 
40
- let closedFromClickOutside = $state(false)
41
-
42
48
  export const toggle = () => {
43
49
  isOpen = !isOpen
44
50
  }
@@ -62,7 +68,7 @@
62
68
  {#if isOpen}
63
69
  <div
64
70
  class="max-h-40 absolute z-1001"
65
- use:portal
71
+ use:conditionalPortal
66
72
  use:floatingContent
67
73
  use:clickOutside
68
74
  onclick_outside={() => {
@@ -50,7 +50,7 @@
50
50
  {/if}
51
51
  </span>
52
52
  {/snippet}
53
- <BaseDropdown bind:this={sortDropdown} {trigger} fullWidth>
53
+ <BaseDropdown bind:this={sortDropdown} {trigger} fullWidth usePortal={false}>
54
54
  <BaseTableHeaderOrderBy
55
55
  {sortDirection}
56
56
  isActive={sortBy === field.slug}
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import type { DrawerContextProps, DrawerOption } from './types.ts'
2
+ import type { DrawerContextProps, DrawerOption, AnyProp } from './types.ts'
3
3
  import DrawerContextItem from './DrawerContextItem.svelte'
4
4
  import DrawerContextSeparator from './DrawerContextSeparator.svelte'
5
5
  import EmptyState from './EmptyState.svelte'
@@ -7,21 +7,28 @@
7
7
  import { Icon } from '@steeze-ui/svelte-icon'
8
8
  import { ChevronRight } from '@steeze-ui/heroicons'
9
9
  import { slide } from 'svelte/transition'
10
- import Sortable from 'sortablejs'
10
+ import { flip } from 'svelte/animate'
11
+ import { dndzone } from 'svelte-dnd-action'
11
12
  import { onMount } from 'svelte'
12
13
 
14
+ const flipDurationMs = 150
15
+
13
16
  let {
14
17
  items = $bindable([]),
15
18
  multiple = false,
16
19
  draggable = false,
17
20
  widthClass = 'w-60',
21
+ collapsibleGroups = true,
18
22
  onclick,
19
23
  onselect,
20
24
  onreorder,
25
+ ondropitem,
21
26
  children,
22
27
  groups
23
28
  }: DrawerContextProps = $props()
24
29
 
30
+ type DndItem = DrawerOption & { id: string }
31
+
25
32
  let selectedItems = $derived(items.filter((i) => i.selected))
26
33
  let hasGroups = $derived(groups && groups.length > 0)
27
34
  let { groupedItems, ungroupedItems } = $derived.by(() => {
@@ -46,9 +53,75 @@
46
53
  })
47
54
 
48
55
  let openGroups = $state<Record<string, boolean>>({})
49
- let ungroupedContainer: HTMLElement | null = $state(null)
50
- let groupContainers: Record<string, HTMLElement | null> = {}
56
+ let groupDndItems = $state<Record<string, DndItem[]>>({})
57
+ let ungroupedDndItems = $state<DndItem[]>([])
58
+ let mounted = $state(false)
59
+ let itemsCache = $state<DrawerOption[]>([])
60
+ let isDragging = $state(false)
61
+ let emitTimeout: number | undefined
62
+
63
+ // Build internal DND items from external items
64
+ function buildListIn() {
65
+ if (hasGroups) {
66
+ // Build DND items for each group
67
+ groups!.forEach((group) => {
68
+ const groupItems = groupedItems.get(group.slug) || []
69
+ groupDndItems[group.slug] = groupItems.map((item: DrawerOption, i: number) => ({
70
+ ...item,
71
+ id: `${group.slug}-${item.value}-${i}`
72
+ }))
73
+ })
74
+ }
75
+
76
+ // Build DND items for ungrouped
77
+ ungroupedDndItems = ungroupedItems.map((item, i) => ({
78
+ ...item,
79
+ id: `ungrouped-${item.value}-${i}`
80
+ }))
81
+ }
82
+
83
+ // Build external items from internal DND items
84
+ function buildListOut() {
85
+ const newItems: DrawerOption[] = []
86
+ const used = new Set<AnyProp>()
87
+
88
+ // Add all grouped items
89
+ if (hasGroups) {
90
+ groups!.forEach((group) => {
91
+ const dndItems = groupDndItems[group.slug] || []
92
+ dndItems.forEach((dndItem) => {
93
+ if (!used.has(dndItem.value)) {
94
+ const { id, ...item } = dndItem
95
+ newItems.push({ ...item, groupBy: group.slug })
96
+ used.add(dndItem.value)
97
+ }
98
+ })
99
+ })
100
+ }
51
101
 
102
+ // Add ungrouped items
103
+ ungroupedDndItems.forEach((dndItem) => {
104
+ if (!used.has(dndItem.value)) {
105
+ const { id, ...item } = dndItem
106
+ newItems.push(item)
107
+ used.add(dndItem.value)
108
+ }
109
+ })
110
+
111
+ return newItems
112
+ }
113
+
114
+ // Sync items when they change from outside
115
+ $effect(() => {
116
+ if (items && mounted && !isDragging) {
117
+ if (JSON.stringify(items) !== JSON.stringify(itemsCache)) {
118
+ buildListIn()
119
+ itemsCache = JSON.parse(JSON.stringify(items))
120
+ }
121
+ }
122
+ })
123
+
124
+ // Open group with selected item on mount
52
125
  $effect(() => {
53
126
  if (hasGroups) {
54
127
  const selectedItem = items.find((i) => i.selected)
@@ -58,95 +131,78 @@
58
131
  }
59
132
  })
60
133
 
134
+ // Notify parent of selection changes
61
135
  $effect(() => {
62
136
  onselect?.(selectedItems)
63
137
  })
64
138
 
65
- function initializeSortable() {
66
- if (!draggable) return
67
-
68
- // Initialize sortable for ungrouped items
69
- if (ungroupedContainer && ungroupedItems.length > 0) {
70
- Sortable.create(ungroupedContainer, {
71
- animation: 150,
72
- handle: '.draggable-item',
73
- filter: '.no-drag',
74
- preventOnFilter: false,
75
- ghostClass: 'opacity-10',
76
- dragClass: 'cursor-grabbing',
77
- forceFallback: true,
78
- onMove: (event) => {
79
- // Prevent moving items above locked items
80
- return !event.related.classList.contains('no-drag')
81
- },
82
- onEnd: (event) => {
83
- if (event.oldIndex !== undefined && event.newIndex !== undefined) {
84
- const newItems = [...items]
85
- const ungroupedIndices = items
86
- .map((item, index) => (!item.groupBy ? index : -1))
87
- .filter((i) => i !== -1)
88
-
89
- const fromIndex = ungroupedIndices[event.oldIndex]
90
- const toIndex = ungroupedIndices[event.newIndex]
91
-
92
- const [removed] = newItems.splice(fromIndex, 1)
93
- newItems.splice(toIndex, 0, removed)
94
-
95
- items = newItems
96
- onreorder?.(newItems)
97
- }
98
- }
99
- })
139
+ onMount(() => {
140
+ itemsCache = JSON.parse(JSON.stringify(items))
141
+ buildListIn()
142
+ mounted = true
143
+ })
144
+
145
+ function transformDraggedElement(draggedEl: HTMLElement | undefined) {
146
+ if (draggedEl) {
147
+ draggedEl.style.border = 'none'
148
+ draggedEl.style.outline = 'none'
100
149
  }
150
+ }
101
151
 
102
- // Initialize sortable for grouped items
103
- if (hasGroups && groups) {
104
- groups.forEach((group) => {
105
- const container = groupContainers[group.slug]
106
- const groupItems = groupedItems.get(group.slug) || []
152
+ function emitGroupDistribution() {
153
+ if (ondropitem && hasGroups) {
154
+ // Clear any pending emit
155
+ if (emitTimeout) {
156
+ clearTimeout(emitTimeout)
157
+ }
107
158
 
108
- if (container && groupItems.length > 0) {
109
- Sortable.create(container, {
110
- animation: 150,
111
- handle: '.draggable-item',
112
- filter: '.no-drag',
113
- preventOnFilter: false,
114
- ghostClass: 'opacity-10',
115
- dragClass: 'cursor-grabbing',
116
- forceFallback: true,
117
- onMove: (event) => {
118
- // Prevent moving items above locked items
119
- return !event.related.classList.contains('no-drag')
120
- },
121
- onEnd: (event) => {
122
- if (event.oldIndex !== undefined && event.newIndex !== undefined) {
123
- const newItems = [...items]
124
- const groupedIndices = items
125
- .map((item, index) => (item.groupBy === group.slug ? index : -1))
126
- .filter((i) => i !== -1)
127
-
128
- const fromIndex = groupedIndices[event.oldIndex]
129
- const toIndex = groupedIndices[event.newIndex]
130
-
131
- const [removed] = newItems.splice(fromIndex, 1)
132
- newItems.splice(toIndex, 0, removed)
133
-
134
- items = newItems
135
- onreorder?.(newItems)
136
- }
137
- }
138
- })
139
- }
140
- })
159
+ // Debounce the emit to avoid duplicate calls when dragging between groups
160
+ emitTimeout = window.setTimeout(() => {
161
+ const groupsDistribution: Record<string, DrawerOption[]> = {}
162
+ groups!.forEach((group) => {
163
+ const dndItems = groupDndItems[group.slug] || []
164
+ groupsDistribution[group.slug] = dndItems.map(({ id, ...item }) => item)
165
+ })
166
+ ondropitem(groupsDistribution)
167
+ }, 0)
141
168
  }
142
169
  }
143
170
 
144
- onMount(() => {
145
- if (draggable) {
146
- // Small delay to ensure DOM is ready
147
- setTimeout(initializeSortable, 100)
171
+ function handleDndConsider(groupSlug: string, e: CustomEvent<any>) {
172
+ if (!isDragging) {
173
+ isDragging = true
148
174
  }
149
- })
175
+ groupDndItems[groupSlug] = e.detail.items
176
+ }
177
+
178
+ function handleDndFinalize(groupSlug: string, e: CustomEvent<any>) {
179
+ isDragging = false
180
+ groupDndItems[groupSlug] = e.detail.items
181
+
182
+ const newItems = buildListOut()
183
+ items = newItems
184
+ itemsCache = JSON.parse(JSON.stringify(items))
185
+ onreorder?.(newItems)
186
+ emitGroupDistribution()
187
+ }
188
+
189
+ function handleUngroupedDndConsider(e: CustomEvent<any>) {
190
+ if (!isDragging) {
191
+ isDragging = true
192
+ }
193
+ ungroupedDndItems = e.detail.items
194
+ }
195
+
196
+ function handleUngroupedDndFinalize(e: CustomEvent<any>) {
197
+ isDragging = false
198
+ ungroupedDndItems = e.detail.items
199
+
200
+ const newItems = buildListOut()
201
+ items = newItems
202
+ itemsCache = JSON.parse(JSON.stringify(items))
203
+ onreorder?.(newItems)
204
+ emitGroupDistribution()
205
+ }
150
206
 
151
207
  function updateItem(item: DrawerOption) {
152
208
  items = items.map((i) => {
@@ -157,11 +213,6 @@
157
213
 
158
214
  function toggleGroup(groupSlug: string) {
159
215
  openGroups = openGroups[groupSlug] ? {} : { [groupSlug]: true }
160
-
161
- // Reinitialize sortable when a group is toggled
162
- if (draggable) {
163
- setTimeout(initializeSortable, 100)
164
- }
165
216
  }
166
217
  </script>
167
218
 
@@ -169,7 +220,7 @@
169
220
  {#if item.separator}
170
221
  <DrawerContextSeparator />
171
222
  {:else}
172
- <div class:px-1={!item.groupBy} class:draggable-item={draggable && !item.locked} class:cursor-grab={draggable && !item.locked} class:no-drag={item.locked}>
223
+ <div class:px-1={!item.groupBy} class:cursor-grab={draggable && !item.locked}>
173
224
  <DrawerContextItem {item} {multiple} {onclick} onchange={updateItem} />
174
225
  </div>
175
226
  {/if}
@@ -184,50 +235,88 @@
184
235
  {#each groups as group, index}
185
236
  {@const groupItems = groupedItems.get(group.slug) || []}
186
237
  {@const isLastGroup = index === groups!.length - 1}
187
- {@const isOpen = openGroups[group.slug]}
188
- {@const hasOpenGroup = Object.values(openGroups).some((v) => v)}
238
+ {@const isOpen = collapsibleGroups ? openGroups[group.slug] : true}
239
+ {@const hasOpenGroup = collapsibleGroups ? Object.values(openGroups).some((v) => v) : true}
189
240
  <div
190
241
  class="px-1"
191
- class:flex-1={isOpen}
242
+ class:flex-1={isOpen && collapsibleGroups}
192
243
  class:flex={isOpen}
193
244
  class:flex-col={isOpen}
194
- class:min-h-0={isOpen}
195
- class:flex-shrink-0={!isOpen && hasOpenGroup}
245
+ class:min-h-0={isOpen && collapsibleGroups}
246
+ class:flex-shrink-0={!isOpen && hasOpenGroup && collapsibleGroups}
196
247
  >
197
- <button
198
- 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"
199
- onclick={() => toggleGroup(group.slug)}
200
- >
201
- <div class="flex items-center gap-1.5">
248
+ {#if collapsibleGroups}
249
+ <button
250
+ 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"
251
+ onclick={() => toggleGroup(group.slug)}
252
+ >
253
+ <div class="flex items-center gap-1.5">
254
+ <span>{group.label}</span>
255
+ <Icon
256
+ src={ChevronRight}
257
+ class="size-3 text-icon-default-secondary transition-all transform {isOpen
258
+ ? 'rotate-90'
259
+ : ''}"
260
+ />
261
+ </div>
262
+ {#if groupItems.length && !group.hideCounter}
263
+ <BaseCounter value={groupItems.length} />
264
+ {/if}
265
+ </button>
266
+ {:else}
267
+ <div
268
+ class="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 overflow-clip flex-shrink-0"
269
+ >
202
270
  <span>{group.label}</span>
203
- <Icon
204
- src={ChevronRight}
205
- class="size-3 text-icon-default-secondary transition-all transform {isOpen
206
- ? 'rotate-90'
207
- : ''}"
208
- />
271
+ {#if groupItems.length && !group.hideCounter}
272
+ <BaseCounter value={groupItems.length} />
273
+ {/if}
209
274
  </div>
210
- {#if groupItems.length}
211
- <BaseCounter value={groupItems.length} />
212
- {/if}
213
- </button>
275
+ {/if}
214
276
 
215
277
  {#if isOpen}
216
278
  <div
217
- class="w-full overflow-y-auto flex-1 min-h-0"
218
- transition:slide={{ duration: 200 }}
219
- bind:this={groupContainers[group.slug]}
279
+ class="w-full overflow-y-auto {collapsibleGroups ? 'flex-1 min-h-0' : ''}"
280
+ transition:slide={{ duration: collapsibleGroups ? 200 : 0 }}
220
281
  >
221
- {#if !groupItems.length}
282
+ {#if draggable}
283
+ <div
284
+ use:dndzone={{
285
+ items: groupDndItems[group.slug] || [],
286
+ flipDurationMs,
287
+ dropTargetStyle: {},
288
+ type: 'drawer-item',
289
+ transformDraggedElement
290
+ }}
291
+ onconsider={(e) => handleDndConsider(group.slug, e)}
292
+ onfinalize={(e) => handleDndFinalize(group.slug, e)}
293
+ >
294
+ {#if !groupItems.length}
295
+ <div class="px-1 pt-1 pb-5">
296
+ <EmptyState
297
+ iconSource={group.emptyIcon}
298
+ title={group.emptyTitle}
299
+ description={group.emptyDescription}
300
+ />
301
+ </div>
302
+ {:else}
303
+ {#each groupDndItems[group.slug] || [] as dndItem (dndItem.id)}
304
+ <div animate:flip={{ duration: isDragging ? flipDurationMs : 0 }}>
305
+ {@render drawerItem(dndItem)}
306
+ </div>
307
+ {/each}
308
+ {/if}
309
+ </div>
310
+ {:else if !groupItems.length}
222
311
  <div class="px-1 pt-1 pb-5">
223
312
  <EmptyState
224
313
  iconSource={group.emptyIcon}
225
- title={group.emptyTitle || 'No items here'}
226
- description={group.emptyDescription || 'Add items to get started'}
314
+ title={group.emptyTitle}
315
+ description={group.emptyDescription}
227
316
  />
228
317
  </div>
229
318
  {:else}
230
- {#each groupItems as item}
319
+ {#each groupItems as item (item.value)}
231
320
  {@render drawerItem(item)}
232
321
  {/each}
233
322
  {/if}
@@ -241,10 +330,31 @@
241
330
  {/if}
242
331
 
243
332
  {#if ungroupedItems.length}
244
- <div class="flex-shrink-0 overflow-y-auto max-h-[564px]" bind:this={ungroupedContainer}>
245
- {#each ungroupedItems as item (item.value)}
246
- {@render drawerItem(item)}
247
- {/each}
248
- </div>
333
+ {#if draggable}
334
+ <div
335
+ class="flex-shrink-0 overflow-y-auto max-h-[564px]"
336
+ use:dndzone={{
337
+ items: ungroupedDndItems,
338
+ flipDurationMs,
339
+ dropTargetStyle: {},
340
+ type: 'drawer-item',
341
+ transformDraggedElement
342
+ }}
343
+ onconsider={handleUngroupedDndConsider}
344
+ onfinalize={handleUngroupedDndFinalize}
345
+ >
346
+ {#each ungroupedDndItems as dndItem (dndItem.id)}
347
+ <div animate:flip={{ duration: isDragging ? flipDurationMs : 0 }}>
348
+ {@render drawerItem(dndItem)}
349
+ </div>
350
+ {/each}
351
+ </div>
352
+ {:else}
353
+ <div class="flex-shrink-0 overflow-y-auto max-h-[564px]">
354
+ {#each ungroupedItems as item (item.value)}
355
+ {@render drawerItem(item)}
356
+ {/each}
357
+ </div>
358
+ {/if}
249
359
  {/if}
250
360
  </div>
@@ -31,8 +31,12 @@
31
31
  </div>
32
32
  {/if}
33
33
  <div class="flex flex-col items-center gap-0.5 text-center">
34
- <h4 class="font-medium text-foreground text-base">{title}</h4>
35
- <p class="text-foreground-default-secondary text-base">{description}</p>
34
+ {#if title}
35
+ <h4 class="font-medium text-foreground text-base">{title}</h4>
36
+ {/if}
37
+ {#if description}
38
+ <p class="text-foreground-default-secondary text-base">{description}</p>
39
+ {/if}
36
40
  </div>
37
41
  {#if children}
38
42
  <div class="mt-4">
@@ -3,7 +3,7 @@
3
3
  import type { Snippet } from 'svelte'
4
4
  import { DataTableViewOptions } from './index.js'
5
5
 
6
- let { table, filters, frozenColumns }: { table: Table<TData>; filters?: Snippet; frozenColumns: Set<string> } = $props()
6
+ let { table, filters, frozenColumns, onFreezeColumn }: { table: Table<TData>; filters?: Snippet; frozenColumns: Set<string>; onFreezeColumn: (columnId: string) => void } = $props()
7
7
  </script>
8
8
 
9
9
  <div class="flex items-center justify-between px-4 py-3">
@@ -12,5 +12,5 @@
12
12
  {@render filters()}
13
13
  </div>
14
14
  {/if}
15
- <DataTableViewOptions {table} {frozenColumns} />
15
+ <DataTableViewOptions {table} {frozenColumns} {onFreezeColumn} />
16
16
  </div>
@@ -5,6 +5,7 @@ declare function $$render<TData>(): {
5
5
  table: Table<TData>;
6
6
  filters?: Snippet;
7
7
  frozenColumns: Set<string>;
8
+ onFreezeColumn: (columnId: string) => void;
8
9
  };
9
10
  exports: {};
10
11
  bindings: "";
@@ -53,6 +53,7 @@ export interface DataTableProps<TData> {
53
53
  columns: DataTableColumn<TData>[];
54
54
  disableSelection?: boolean;
55
55
  disablePagination?: boolean;
56
+ disableKeyboardNavigation?: boolean;
56
57
  rowActions?: TableAction[];
57
58
  getRowActions?: (row: TData) => TableAction[];
58
59
  onRowAction?: (action: AnyProp, row: TData) => void;
@@ -60,6 +61,7 @@ export interface DataTableProps<TData> {
60
61
  initialPage?: number;
61
62
  initialSortColumn?: string;
62
63
  initialSortDirection?: 'asc' | 'desc';
64
+ initialFrozenColumns?: string[];
63
65
  pageSizeOptions?: number[];
64
66
  emptyState?: Omit<EmptyStateProps, 'children' | 'check'>;
65
67
  onRowClick?: (row: TData) => void;
@@ -1,19 +1,39 @@
1
1
  <script lang="ts" generics="TData">
2
2
  import { Sliders, Drag } from '@invopop/ui-icons'
3
3
  import type { Table } from '@tanstack/table-core'
4
- import type { DrawerOption } from '../types.js'
4
+ import type { DrawerOption, DrawerGroup } from '../types.js'
5
5
  import BaseDropdown from '../BaseDropdown.svelte'
6
6
  import DrawerContext from '../DrawerContext.svelte'
7
7
  import InputToggle from '../InputToggle.svelte'
8
8
  import BaseButton from '../BaseButton.svelte'
9
9
  import { capitalize } from '../helpers.js'
10
10
 
11
- let { table, frozenColumns }: { table: Table<TData>; frozenColumns: Set<string> } = $props()
11
+ let {
12
+ table,
13
+ frozenColumns,
14
+ onFreezeColumn
15
+ }: {
16
+ table: Table<TData>
17
+ frozenColumns: Set<string>
18
+ onFreezeColumn: (columnId: string) => void
19
+ } = $props()
12
20
 
13
- let itemsWithActions = $state<DrawerOption[]>([])
21
+ const groups: DrawerGroup[] = [
22
+ {
23
+ label: 'Frozen columns',
24
+ slug: 'frozen',
25
+ emptyDescription: 'Drop items to this list',
26
+ hideCounter: true
27
+ },
28
+ {
29
+ label: 'Table options',
30
+ slug: 'regular',
31
+ emptyDescription: 'Drop items to this list',
32
+ hideCounter: true
33
+ }
34
+ ]
14
35
 
15
- // Initialize and update items when table state changes
16
- $effect(() => {
36
+ let itemsWithActions = $derived.by(() => {
17
37
  const columnOrder = table.getState().columnOrder
18
38
  const allColumns = table.getAllColumns()
19
39
 
@@ -27,46 +47,53 @@
27
47
  orderedColumns = allColumns.filter((col) => col.id !== 'select' && col.id !== 'actions')
28
48
  }
29
49
 
30
- const newItems = orderedColumns.map((col) => ({
31
- label: (col?.columnDef.header as string) || capitalize(col?.id || ''),
32
- value: col?.id,
33
- icon: Drag,
34
- action: toggleAction,
35
- locked: frozenColumns.has(col?.id || '')
36
- })) as DrawerOption[]
50
+ return orderedColumns.map((col) => {
51
+ const isFrozen = frozenColumns.has(col?.id || '')
52
+ return {
53
+ label: (col?.columnDef.header as string) || capitalize(col?.id || ''),
54
+ value: col?.id,
55
+ icon: Drag,
56
+ action: toggleAction,
57
+ groupBy: isFrozen ? 'frozen' : 'regular'
58
+ }
59
+ }) as DrawerOption[]
60
+ })
37
61
 
38
- // Only update if the order or locked status has changed (avoid overwriting during drag)
39
- const currentOrder = itemsWithActions.map((i) => i.value).join(',')
40
- const newOrder = newItems.map((i) => i.value).join(',')
41
- const currentLocked = itemsWithActions.map((i) => i.locked ? '1' : '0').join(',')
42
- const newLocked = newItems.map((i) => i.locked ? '1' : '0').join(',')
62
+ function handleDropItem(groupsDistribution: Record<string, DrawerOption[]>) {
63
+ const frozenItems = groupsDistribution['frozen'] || []
64
+ const regularItems = groupsDistribution['regular'] || []
43
65
 
44
- if (currentOrder !== newOrder || currentLocked !== newLocked) {
45
- itemsWithActions = newItems
46
- }
47
- })
66
+ // Build sets of what should be frozen and regular after the drop
67
+ const shouldBeFrozen = new Set(frozenItems.map((item) => item.value as string))
68
+ const shouldBeRegular = new Set(regularItems.map((item) => item.value as string))
69
+
70
+ // Freeze columns that are in the frozen group but not currently frozen
71
+ shouldBeFrozen.forEach((columnId) => {
72
+ if (!frozenColumns.has(columnId)) {
73
+ onFreezeColumn(columnId)
74
+ }
75
+ })
48
76
 
49
- function handleReorder(reorderedItems: any[]) {
50
- // Update local items to match the drag order immediately
51
- itemsWithActions = reorderedItems
77
+ // Unfreeze columns that are in the regular group but currently frozen
78
+ shouldBeRegular.forEach((columnId) => {
79
+ if (frozenColumns.has(columnId)) {
80
+ onFreezeColumn(columnId)
81
+ }
82
+ })
52
83
 
53
- const newOrder = reorderedItems.map((item) => item.value)
54
84
  // Get all column IDs from the table
55
85
  const allColumnIds = table.getAllColumns().map((col) => col.id)
56
-
57
- // Filter to keep select and actions in their fixed positions
58
86
  const selectColumn = 'select'
59
87
  const actionsColumn = 'actions'
60
88
 
61
- // Separate frozen and non-frozen columns from reordered items
62
- const frozenCols = newOrder.filter((id) => frozenColumns.has(id))
63
- const nonFrozenCols = newOrder.filter((id) => !frozenColumns.has(id) && id !== selectColumn && id !== actionsColumn)
89
+ // Build the final column order: select, frozen (in order), regular (in order), actions
90
+ const frozenOrder = frozenItems.map((item) => item.value as string)
91
+ const regularOrder = regularItems.map((item) => item.value as string)
64
92
 
65
- // Build final order: select first, then frozen columns (in order), then non-frozen columns, then actions
66
93
  const finalOrder = [
67
94
  ...(allColumnIds.includes(selectColumn) ? [selectColumn] : []),
68
- ...frozenCols,
69
- ...nonFrozenCols,
95
+ ...frozenOrder,
96
+ ...regularOrder,
70
97
  ...(allColumnIds.includes(actionsColumn) ? [actionsColumn] : [])
71
98
  ]
72
99
 
@@ -89,9 +116,11 @@
89
116
  {#snippet trigger()}
90
117
  <BaseButton icon={Sliders} variant="outline" size="md" />
91
118
  {/snippet}
92
- <DrawerContext items={itemsWithActions} draggable={true} onreorder={handleReorder}>
93
- <div class="p-3 py-1.5 text-sm font-medium text-foreground-default-secondary">
94
- Table options
95
- </div>
96
- </DrawerContext>
119
+ <DrawerContext
120
+ items={itemsWithActions}
121
+ {groups}
122
+ draggable
123
+ collapsibleGroups={false}
124
+ ondropitem={handleDropItem}
125
+ />
97
126
  </BaseDropdown>
@@ -3,6 +3,7 @@ declare function $$render<TData>(): {
3
3
  props: {
4
4
  table: Table<TData>;
5
5
  frozenColumns: Set<string>;
6
+ onFreezeColumn: (columnId: string) => void;
6
7
  };
7
8
  exports: {};
8
9
  bindings: "";
@@ -10,6 +10,7 @@
10
10
  type VisibilityState,
11
11
  type Column
12
12
  } from '@tanstack/table-core'
13
+ import { onMount, onDestroy } from 'svelte'
13
14
  import DataTableToolbar from './data-table-toolbar.svelte'
14
15
  import DataTablePagination from './data-table-pagination.svelte'
15
16
  import FlexRender from './flex-render.svelte'
@@ -33,6 +34,7 @@
33
34
  columns: columnConfig,
34
35
  disableSelection = false,
35
36
  disablePagination = false,
37
+ disableKeyboardNavigation = false,
36
38
  rowActions = [],
37
39
  getRowActions,
38
40
  onRowAction,
@@ -40,6 +42,7 @@
40
42
  initialPage = 0,
41
43
  initialSortColumn,
42
44
  initialSortDirection,
45
+ initialFrozenColumns = [],
43
46
  emptyState = {
44
47
  iconSource: Search,
45
48
  title: 'No results',
@@ -84,7 +87,9 @@
84
87
  let columnOrder = $state<ColumnOrderState>([])
85
88
  let containerRef = $state<HTMLDivElement | null>(null)
86
89
  let columnDropdowns: Record<string, BaseDropdown> = {}
87
- let frozenColumns = $state<Set<string>>(new Set())
90
+ let frozenColumns = $state<Set<string>>(new Set(initialFrozenColumns))
91
+ let focusedRowIndex = $state<number>(-1)
92
+ let tableBodyRef: HTMLTableSectionElement | null = null
88
93
 
89
94
  // Build TanStack columns from config
90
95
  const columns = $derived.by(() =>
@@ -112,6 +117,13 @@
112
117
  pagination.pageIndex = initialPage
113
118
  })
114
119
 
120
+ // Reorder initial frozen columns on mount
121
+ $effect(() => {
122
+ if (initialFrozenColumns.length > 0 && columnOrder.length === 0) {
123
+ initialFrozenColumns.forEach(columnId => reorderFrozenColumn(columnId))
124
+ }
125
+ })
126
+
115
127
  // Track selection changes
116
128
  $effect(() => {
117
129
  if (onSelectionChange) {
@@ -146,49 +158,44 @@
146
158
  setColumnOrder: (value) => (columnOrder = value)
147
159
  })
148
160
 
161
+ function reorderFrozenColumn(columnId: string) {
162
+ const currentOrder = table.getState().columnOrder.length > 0
163
+ ? table.getState().columnOrder
164
+ : table.getAllLeafColumns().map(col => col.id)
165
+
166
+ const newOrder = [...currentOrder]
167
+ const columnIndex = newOrder.indexOf(columnId)
168
+
169
+ if (columnIndex > -1) {
170
+ newOrder.splice(columnIndex, 1)
171
+
172
+ const selectIndex = newOrder.indexOf('select')
173
+ const insertIndex = selectIndex >= 0 ? selectIndex + 1 : 0
174
+
175
+ let lastFrozenIndex = insertIndex
176
+ for (let i = insertIndex; i < newOrder.length; i++) {
177
+ if (frozenColumns.has(newOrder[i])) {
178
+ lastFrozenIndex = i + 1
179
+ } else {
180
+ break
181
+ }
182
+ }
183
+
184
+ newOrder.splice(lastFrozenIndex, 0, columnId)
185
+ table.setColumnOrder(newOrder)
186
+ }
187
+ }
188
+
149
189
  function handleFreezeColumn(columnId: string) {
150
190
  const isFrozen = frozenColumns.has(columnId)
151
191
 
152
192
  if (isFrozen) {
153
- // Unfreeze
154
193
  frozenColumns.delete(columnId)
155
194
  frozenColumns = new Set(frozenColumns)
156
195
  } else {
157
- // Freeze
158
196
  frozenColumns.add(columnId)
159
197
  frozenColumns = new Set(frozenColumns)
160
-
161
- // Reorder columns to move frozen column to the left
162
- const currentOrder = table.getState().columnOrder.length > 0
163
- ? table.getState().columnOrder
164
- : table.getAllLeafColumns().map(col => col.id)
165
-
166
- const newOrder = [...currentOrder]
167
- const columnIndex = newOrder.indexOf(columnId)
168
-
169
- if (columnIndex > -1) {
170
- // Remove from current position
171
- newOrder.splice(columnIndex, 1)
172
-
173
- // Find position to insert (after select column if present, otherwise at start)
174
- const selectIndex = newOrder.indexOf('select')
175
- const insertIndex = selectIndex >= 0 ? selectIndex + 1 : 0
176
-
177
- // Find the last frozen column position
178
- let lastFrozenIndex = insertIndex
179
- for (let i = insertIndex; i < newOrder.length; i++) {
180
- if (frozenColumns.has(newOrder[i])) {
181
- lastFrozenIndex = i + 1
182
- } else {
183
- break
184
- }
185
- }
186
-
187
- // Insert after the last frozen column
188
- newOrder.splice(lastFrozenIndex, 0, columnId)
189
-
190
- table.setColumnOrder(newOrder)
191
- }
198
+ reorderFrozenColumn(columnId)
192
199
  }
193
200
  }
194
201
 
@@ -208,6 +215,88 @@
208
215
 
209
216
  return offset
210
217
  }
218
+
219
+ function handleKeydown(event: KeyboardEvent) {
220
+ const rows = table.getRowModel().rows
221
+ if (rows.length === 0) return
222
+
223
+ // Ignore if user is typing in an input or has a dropdown open
224
+ if ((event.target as HTMLElement).tagName === 'INPUT' ||
225
+ (event.target as HTMLElement).tagName === 'TEXTAREA') {
226
+ return
227
+ }
228
+
229
+ switch (event.key) {
230
+ case 'ArrowDown':
231
+ event.preventDefault()
232
+ if (focusedRowIndex === -1 && rows.length > 0) {
233
+ // No row focused, focus first row
234
+ focusedRowIndex = 0
235
+ scrollToFocusedRow()
236
+ if (event.shiftKey && enableSelection) {
237
+ rows[focusedRowIndex].toggleSelected(true)
238
+ }
239
+ } else if (focusedRowIndex < rows.length - 1) {
240
+ // Move down
241
+ focusedRowIndex++
242
+ scrollToFocusedRow()
243
+ if (event.shiftKey && enableSelection) {
244
+ // Always select when going down
245
+ rows[focusedRowIndex].toggleSelected(true)
246
+ }
247
+ }
248
+ break
249
+ case 'ArrowUp':
250
+ event.preventDefault()
251
+ if (event.shiftKey && enableSelection && focusedRowIndex >= 0) {
252
+ // Deselect current row first when going up with shift
253
+ rows[focusedRowIndex].toggleSelected(false)
254
+ }
255
+ if (focusedRowIndex === -1 && rows.length > 0) {
256
+ // No row focused, focus first row
257
+ focusedRowIndex = 0
258
+ scrollToFocusedRow()
259
+ } else if (focusedRowIndex > 0) {
260
+ // Move up
261
+ focusedRowIndex--
262
+ scrollToFocusedRow()
263
+ }
264
+ break
265
+ case ' ':
266
+ case 'Enter':
267
+ event.preventDefault()
268
+ if (focusedRowIndex >= 0 && focusedRowIndex < rows.length && enableSelection) {
269
+ const row = rows[focusedRowIndex]
270
+ row.toggleSelected()
271
+ }
272
+ break
273
+ case 'Escape':
274
+ focusedRowIndex = -1
275
+ break
276
+ }
277
+ }
278
+
279
+ function scrollToFocusedRow() {
280
+ if (focusedRowIndex >= 0 && tableBodyRef) {
281
+ const rowElement = tableBodyRef.querySelector(`[data-row-index="${focusedRowIndex}"]`)
282
+ if (rowElement) {
283
+ rowElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
284
+ }
285
+ }
286
+ }
287
+
288
+ // Add global keyboard navigation
289
+ onMount(() => {
290
+ if (!disableKeyboardNavigation) {
291
+ document.addEventListener('keydown', handleKeydown)
292
+ }
293
+ })
294
+
295
+ onDestroy(() => {
296
+ if (!disableKeyboardNavigation) {
297
+ document.removeEventListener('keydown', handleKeydown)
298
+ }
299
+ })
211
300
  </script>
212
301
 
213
302
  {#snippet StickyCellWrapper({
@@ -225,7 +314,7 @@
225
314
  })}
226
315
  <div
227
316
  class={cn(
228
- 'absolute inset-0 flex items-center bg-background group-hover/row:bg-background-default-secondary group-data-[state=selected]/row:bg-background-selected px-3',
317
+ 'absolute inset-0 flex items-center bg-background group-hover/row:bg-background-default-secondary group-data-[state=selected]/row:bg-background-selected group-data-[focused=true]/row:bg-background-default-secondary px-3',
229
318
  align === 'right' ? 'justify-end' : '',
230
319
  { 'pl-4': isFirst, 'pr-4': isLast, 'border-r border-border': isFrozen }
231
320
  )}
@@ -255,7 +344,7 @@
255
344
  }: { column: Column<TData>; title?: string } & HTMLAttributes<HTMLDivElement>)}
256
345
  {@const isCurrency = column.columnDef.meta?.cellType === 'currency'}
257
346
  <div class={cn('flex items-center w-full', className)} {...restProps}>
258
- <BaseDropdown bind:this={columnDropdowns[column.id]} fullWidth>
347
+ <BaseDropdown bind:this={columnDropdowns[column.id]} fullWidth usePortal={false}>
259
348
  {#snippet trigger()}
260
349
  <button
261
350
  class={clsx('data-[state=open]:bg-accent w-full flex items-center gap-1 py-2.5', {
@@ -310,7 +399,7 @@
310
399
  {/snippet}
311
400
 
312
401
  <div class="flex flex-col h-full">
313
- <DataTableToolbar {table} {filters} {frozenColumns} />
402
+ <DataTableToolbar {table} {filters} {frozenColumns} onFreezeColumn={handleFreezeColumn} />
314
403
  <div class="flex-1 overflow-hidden flex flex-col">
315
404
  {#if data.length === 0}
316
405
  <div class="flex-1 flex items-center justify-center bg-background">
@@ -397,10 +486,12 @@
397
486
  </Table.Row>
398
487
  {/each}
399
488
  </Table.Header>
400
- <Table.Body>
401
- {#each table.getRowModel().rows as row (row.id)}
489
+ <Table.Body bind:ref={tableBodyRef}>
490
+ {#each table.getRowModel().rows as row, rowIndex (row.id)}
402
491
  <Table.Row
403
492
  data-state={row.getIsSelected() ? 'selected' : undefined}
493
+ data-row-index={rowIndex}
494
+ data-focused={focusedRowIndex === rowIndex ? 'true' : undefined}
404
495
  class={cn('border-b border-border', getRowClassName?.(row.original as TData))}
405
496
  onclick={() => onRowClick?.(row.original as TData)}
406
497
  >
@@ -19,7 +19,7 @@
19
19
  bind:this={ref}
20
20
  data-slot="table-row"
21
21
  class={cn(
22
- 'group/row data-[state=selected]:bg-background-selected data-[state=checked]:bg-background-selected h-10 data-[state=selected]:hover:bg-background-selected data-[state=checked]:hover:bg-background-selected',
22
+ 'group/row data-[state=selected]:bg-background-selected data-[state=checked]:bg-background-selected h-10 data-[state=selected]:hover:bg-background-selected data-[state=checked]:hover:bg-background-selected data-[focused=true]:bg-background-default-secondary',
23
23
  className
24
24
  )}
25
25
  {oncontextmenu}
package/dist/types.d.ts CHANGED
@@ -23,6 +23,7 @@ export type DrawerGroup = {
23
23
  emptyIcon?: IconSource;
24
24
  emptyTitle?: string;
25
25
  emptyDescription?: string;
26
+ hideCounter?: boolean;
26
27
  };
27
28
  export type DrawerOption = SelectOption & {
28
29
  separator?: boolean;
@@ -217,6 +218,7 @@ export interface BaseDropdownProps {
217
218
  fullWidth?: boolean;
218
219
  placement?: Placement;
219
220
  matchParentWidth?: boolean;
221
+ usePortal?: boolean;
220
222
  trigger?: Snippet;
221
223
  children?: Snippet;
222
224
  [key: string]: any;
@@ -390,9 +392,11 @@ export interface DrawerContextProps {
390
392
  multiple?: boolean;
391
393
  draggable?: boolean;
392
394
  widthClass?: string;
395
+ collapsibleGroups?: boolean;
393
396
  onclick?: (value: AnyProp) => void;
394
397
  onselect?: (selected: DrawerOption[]) => void;
395
398
  onreorder?: (items: DrawerOption[]) => void;
399
+ ondropitem?: (groups: Record<string, DrawerOption[]>) => void;
396
400
  children?: Snippet;
397
401
  groups?: DrawerGroup[];
398
402
  }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@invopop/popui",
3
3
  "license": "MIT",
4
- "version": "0.1.4-beta.22",
4
+ "version": "0.1.4-beta.24",
5
5
  "repository": {
6
- "url": "https://github.com/invopop/popui"
6
+ "url": "https://github.com/invopop/popui"
7
7
  },
8
8
  "scripts": {
9
9
  "dev": "vite dev",
@@ -55,7 +55,6 @@
55
55
  "@tailwindcss/forms": "^0.5.9",
56
56
  "@tailwindcss/typography": "^0.5.15",
57
57
  "@types/lodash-es": "^4.17.12",
58
- "@types/sortablejs": "^1.15.9",
59
58
  "@typescript-eslint/eslint-plugin": "^6.0.0",
60
59
  "@typescript-eslint/parser": "^6.0.0",
61
60
  "eslint": "^8.28.0",
@@ -98,7 +97,7 @@
98
97
  "inter-ui": "^3.19.3",
99
98
  "lodash-es": "^4.17.21",
100
99
  "mode-watcher": "^1.1.0",
101
- "sortablejs": "^1.15.6",
100
+ "svelte-dnd-action": "^0.9.69",
102
101
  "svelte-floating-ui": "^1.5.8",
103
102
  "svelte-headlessui": "^0.0.46",
104
103
  "svelte-intersection-observer-action": "^0.0.4",