@invopop/popui 0.1.4-beta.21 → 0.1.4-beta.23

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}
@@ -3,7 +3,7 @@
3
3
  import type { TableSortBy, DrawerOption, BaseTableHeaderOrderByProps } from './types.js'
4
4
  import DrawerContext from './DrawerContext.svelte'
5
5
 
6
- let { isActive = false, sortDirection, onOrderBy, onHide, onFilter, onFreeze, isFrozen = false, showSortOptions = true }: BaseTableHeaderOrderByProps = $props()
6
+ let { isActive = false, sortDirection, onOrderBy, onHide, onFilter, onFreeze, isFrozen = false, showSortOptions = true, showFilterOption = true }: BaseTableHeaderOrderByProps = $props()
7
7
 
8
8
  let items = $derived([
9
9
  ...(showSortOptions ? [
@@ -21,8 +21,10 @@
21
21
  },
22
22
  { label: '', value: 'sep-1', separator: true }
23
23
  ] : []),
24
- { icon: Filter, label: 'Filter by column', value: 'filter' },
25
- { label: '', value: 'sep-2', separator: true },
24
+ ...(showFilterOption ? [
25
+ { icon: Filter, label: 'Filter by column', value: 'filter' },
26
+ { label: '', value: 'sep-2', separator: true }
27
+ ] : []),
26
28
  { icon: Lock, label: isFrozen ? 'Unfreeze column' : 'Freeze column', value: 'freeze' },
27
29
  { label: '', value: 'sep-3', separator: true },
28
30
  { icon: Preview, label: 'Hide column', value: 'hide' }
@@ -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: flipDurationMs }}>
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: flipDurationMs }}>
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">
@@ -6,7 +6,7 @@
6
6
  let { table, filters, frozenColumns }: { table: Table<TData>; filters?: Snippet; frozenColumns: Set<string> } = $props()
7
7
  </script>
8
8
 
9
- <div class="flex items-center justify-between px-4 py-4">
9
+ <div class="flex items-center justify-between px-4 py-3">
10
10
  {#if filters}
11
11
  <div class="flex-1">
12
12
  {@render filters()}
@@ -53,11 +53,15 @@ 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;
59
60
  initialPageSize?: number;
60
61
  initialPage?: number;
62
+ initialSortColumn?: string;
63
+ initialSortDirection?: 'asc' | 'desc';
64
+ initialFrozenColumns?: string[];
61
65
  pageSizeOptions?: number[];
62
66
  emptyState?: Omit<EmptyStateProps, 'children' | 'check'>;
63
67
  onRowClick?: (row: TData) => void;
@@ -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,11 +34,15 @@
33
34
  columns: columnConfig,
34
35
  disableSelection = false,
35
36
  disablePagination = false,
37
+ disableKeyboardNavigation = false,
36
38
  rowActions = [],
37
39
  getRowActions,
38
40
  onRowAction,
39
41
  initialPageSize = 10,
40
42
  initialPage = 0,
43
+ initialSortColumn,
44
+ initialSortDirection,
45
+ initialFrozenColumns = [],
41
46
  emptyState = {
42
47
  iconSource: Search,
43
48
  title: 'No results',
@@ -64,7 +69,11 @@
64
69
 
65
70
  let rowSelection = $state<RowSelectionState>({})
66
71
  let columnVisibility = $state<VisibilityState>({})
67
- let sorting = $state<SortingState>([])
72
+ let sorting = $state<SortingState>(
73
+ initialSortColumn && initialSortDirection
74
+ ? [{ id: initialSortColumn, desc: initialSortDirection === 'desc' }]
75
+ : []
76
+ )
68
77
  let pagination = $state<PaginationState>({ pageIndex: initialPage, pageSize: initialPageSize })
69
78
  let columnSizing = $state<ColumnSizingState>({})
70
79
  let columnSizingInfo = $state<ColumnSizingInfoState>({
@@ -78,7 +87,9 @@
78
87
  let columnOrder = $state<ColumnOrderState>([])
79
88
  let containerRef = $state<HTMLDivElement | null>(null)
80
89
  let columnDropdowns: Record<string, BaseDropdown> = {}
81
- 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
82
93
 
83
94
  // Build TanStack columns from config
84
95
  const columns = $derived.by(() =>
@@ -106,6 +117,13 @@
106
117
  pagination.pageIndex = initialPage
107
118
  })
108
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
+
109
127
  // Track selection changes
110
128
  $effect(() => {
111
129
  if (onSelectionChange) {
@@ -140,49 +158,44 @@
140
158
  setColumnOrder: (value) => (columnOrder = value)
141
159
  })
142
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
+
143
189
  function handleFreezeColumn(columnId: string) {
144
190
  const isFrozen = frozenColumns.has(columnId)
145
191
 
146
192
  if (isFrozen) {
147
- // Unfreeze
148
193
  frozenColumns.delete(columnId)
149
194
  frozenColumns = new Set(frozenColumns)
150
195
  } else {
151
- // Freeze
152
196
  frozenColumns.add(columnId)
153
197
  frozenColumns = new Set(frozenColumns)
154
-
155
- // Reorder columns to move frozen column to the left
156
- const currentOrder = table.getState().columnOrder.length > 0
157
- ? table.getState().columnOrder
158
- : table.getAllLeafColumns().map(col => col.id)
159
-
160
- const newOrder = [...currentOrder]
161
- const columnIndex = newOrder.indexOf(columnId)
162
-
163
- if (columnIndex > -1) {
164
- // Remove from current position
165
- newOrder.splice(columnIndex, 1)
166
-
167
- // Find position to insert (after select column if present, otherwise at start)
168
- const selectIndex = newOrder.indexOf('select')
169
- const insertIndex = selectIndex >= 0 ? selectIndex + 1 : 0
170
-
171
- // Find the last frozen column position
172
- let lastFrozenIndex = insertIndex
173
- for (let i = insertIndex; i < newOrder.length; i++) {
174
- if (frozenColumns.has(newOrder[i])) {
175
- lastFrozenIndex = i + 1
176
- } else {
177
- break
178
- }
179
- }
180
-
181
- // Insert after the last frozen column
182
- newOrder.splice(lastFrozenIndex, 0, columnId)
183
-
184
- table.setColumnOrder(newOrder)
185
- }
198
+ reorderFrozenColumn(columnId)
186
199
  }
187
200
  }
188
201
 
@@ -202,6 +215,88 @@
202
215
 
203
216
  return offset
204
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
+ })
205
300
  </script>
206
301
 
207
302
  {#snippet StickyCellWrapper({
@@ -219,7 +314,7 @@
219
314
  })}
220
315
  <div
221
316
  class={cn(
222
- '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',
223
318
  align === 'right' ? 'justify-end' : '',
224
319
  { 'pl-4': isFirst, 'pr-4': isLast, 'border-r border-border': isFrozen }
225
320
  )}
@@ -249,7 +344,7 @@
249
344
  }: { column: Column<TData>; title?: string } & HTMLAttributes<HTMLDivElement>)}
250
345
  {@const isCurrency = column.columnDef.meta?.cellType === 'currency'}
251
346
  <div class={cn('flex items-center w-full', className)} {...restProps}>
252
- <BaseDropdown bind:this={columnDropdowns[column.id]} fullWidth>
347
+ <BaseDropdown bind:this={columnDropdowns[column.id]} fullWidth usePortal={false}>
253
348
  {#snippet trigger()}
254
349
  <button
255
350
  class={clsx('data-[state=open]:bg-accent w-full flex items-center gap-1 py-2.5', {
@@ -272,6 +367,7 @@
272
367
  isActive={column.getIsSorted() !== false}
273
368
  isFrozen={frozenColumns.has(column.id)}
274
369
  showSortOptions={column.getCanSort()}
370
+ showFilterOption={!column.columnDef.disableColumnFilter}
275
371
  onOrderBy={(direction) => {
276
372
  column.toggleSorting(direction === 'desc')
277
373
  // Reset to first page when sorting changes (same as page size change)
@@ -305,15 +401,24 @@
305
401
  <div class="flex flex-col h-full">
306
402
  <DataTableToolbar {table} {filters} {frozenColumns} />
307
403
  <div class="flex-1 overflow-hidden flex flex-col">
308
- <div
309
- bind:this={containerRef}
310
- class="relative bg-background flex-1 overflow-auto"
311
- style="overscroll-behavior-x: none;"
312
- >
313
- <Table.Root class={data.length === 0 ? 'h-full' : 'h-auto'}>
314
- <Table.Header>
315
- {#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
316
- <Table.Row class="hover:!bg-transparent border-b border-border">
404
+ {#if data.length === 0}
405
+ <div class="flex-1 flex items-center justify-center bg-background">
406
+ <EmptyState
407
+ iconSource={emptyState.iconSource}
408
+ title={emptyState.title}
409
+ description={emptyState.description}
410
+ />
411
+ </div>
412
+ {:else}
413
+ <div
414
+ bind:this={containerRef}
415
+ class="relative bg-background flex-1 overflow-auto"
416
+ style="overscroll-behavior-x: none;"
417
+ >
418
+ <Table.Root>
419
+ <Table.Header>
420
+ {#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
421
+ <Table.Row class="hover:!bg-transparent border-t border-b border-border">
317
422
  {#each headerGroup.headers as header, index (header.id)}
318
423
  {@const isLastScrollable = index === headerGroup.headers.length - 2}
319
424
  {@const isFirstHeader = index === 0}
@@ -341,13 +446,6 @@
341
446
  {/if}
342
447
  <!-- Left resize handler (resizes previous column) -->
343
448
  {#if prevHeader && prevHeader.column.getCanResize()}
344
- <!-- Always visible vertical border on left -->
345
- <div
346
- class={cn(
347
- 'absolute left-0 top-1/2 -translate-y-1/2 h-3 w-px bg-background-default-tertiary',
348
- prevHeader.column.getIsResizing() && 'opacity-0'
349
- )}
350
- ></div>
351
449
  <!-- Left resize handler -->
352
450
  <div
353
451
  role="button"
@@ -366,13 +464,6 @@
366
464
  </div>
367
465
  {/if}
368
466
  {#if header.column.getCanResize()}
369
- <!-- Always visible vertical border -->
370
- <div
371
- class={cn(
372
- 'absolute right-0 top-1/2 -translate-y-1/2 h-3 w-px bg-background-default-tertiary',
373
- header.column.getIsResizing() && 'opacity-0'
374
- )}
375
- ></div>
376
467
  <!-- Resize handler (larger interactive area, enhanced on hover) -->
377
468
  <div
378
469
  role="button"
@@ -395,10 +486,12 @@
395
486
  </Table.Row>
396
487
  {/each}
397
488
  </Table.Header>
398
- <Table.Body>
399
- {#each table.getRowModel().rows as row (row.id)}
489
+ <Table.Body bind:ref={tableBodyRef}>
490
+ {#each table.getRowModel().rows as row, rowIndex (row.id)}
400
491
  <Table.Row
401
492
  data-state={row.getIsSelected() ? 'selected' : undefined}
493
+ data-row-index={rowIndex}
494
+ data-focused={focusedRowIndex === rowIndex ? 'true' : undefined}
402
495
  class={cn('border-b border-border', getRowClassName?.(row.original as TData))}
403
496
  onclick={() => onRowClick?.(row.original as TData)}
404
497
  >
@@ -457,22 +550,11 @@
457
550
  </Table.Cell>
458
551
  {/each}
459
552
  </Table.Row>
460
- {:else}
461
- <Table.Row class="hover:!bg-transparent h-full">
462
- <Table.Cell colspan={columns.length} class="h-full !p-0">
463
- <div class="flex items-center justify-center h-full w-full">
464
- <EmptyState
465
- iconSource={emptyState.iconSource}
466
- title={emptyState.title}
467
- description={emptyState.description}
468
- />
469
- </div>
470
- </Table.Cell>
471
- </Table.Row>
472
553
  {/each}
473
554
  </Table.Body>
474
555
  </Table.Root>
475
556
  </div>
557
+ {/if}
476
558
  {#if enablePagination}
477
559
  <DataTablePagination
478
560
  {table}
@@ -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;
@@ -286,6 +288,7 @@ export interface BaseTableHeaderOrderByProps {
286
288
  onFreeze?: () => void;
287
289
  isFrozen?: boolean;
288
290
  showSortOptions?: boolean;
291
+ showFilterOption?: boolean;
289
292
  }
290
293
  export interface BaseTableRowProps {
291
294
  row: TableDataRow;
@@ -389,9 +392,11 @@ export interface DrawerContextProps {
389
392
  multiple?: boolean;
390
393
  draggable?: boolean;
391
394
  widthClass?: string;
395
+ collapsibleGroups?: boolean;
392
396
  onclick?: (value: AnyProp) => void;
393
397
  onselect?: (selected: DrawerOption[]) => void;
394
398
  onreorder?: (items: DrawerOption[]) => void;
399
+ ondropitem?: (groups: Record<string, DrawerOption[]>) => void;
395
400
  children?: Snippet;
396
401
  groups?: DrawerGroup[];
397
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.21",
4
+ "version": "0.1.4-beta.23",
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",