@stacksjs/defaults 0.75.279 → 0.75.281

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 (60) hide show
  1. package/ai/skills/stacks-database/SKILL.md +1 -3
  2. package/ai/skills/stacks-native/CATALOG.md +4 -4
  3. package/ide/vscode/package.json +1 -1
  4. package/package.json +3 -3
  5. package/project/bun-version +1 -1
  6. package/resources/components/Dashboard/Analytics/CampaignAnalytics.stx +28 -30
  7. package/resources/components/Dashboard/Commerce/CommerceCategoriesDashboard.stx +8 -28
  8. package/resources/components/Dashboard/Commerce/CommerceCategoriesTable.stx +72 -29
  9. package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +8 -29
  10. package/resources/components/Dashboard/Commerce/CommerceCouponsTable.stx +69 -49
  11. package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +8 -27
  12. package/resources/components/Dashboard/Commerce/CommerceCustomersTable.stx +70 -48
  13. package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +8 -29
  14. package/resources/components/Dashboard/Commerce/CommerceGiftCardsTable.stx +72 -82
  15. package/resources/components/Dashboard/Commerce/CommerceManufacturersDashboard.stx +1 -1
  16. package/resources/components/Dashboard/Commerce/CommerceManufacturersTable.stx +3 -0
  17. package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +8 -28
  18. package/resources/components/Dashboard/Commerce/CommerceOrdersTable.stx +76 -49
  19. package/resources/components/Dashboard/Commerce/CommercePaymentsDashboard.stx +1 -1
  20. package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +3 -0
  21. package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +8 -28
  22. package/resources/components/Dashboard/Commerce/CommerceProductsTable.stx +81 -34
  23. package/resources/components/Dashboard/Commerce/CommerceReviewsDashboard.stx +11 -37
  24. package/resources/components/Dashboard/Commerce/CommerceReviewsTable.stx +106 -77
  25. package/resources/components/Dashboard/Commerce/CommerceTaxesDashboard.stx +1 -1
  26. package/resources/components/Dashboard/Commerce/CommerceTaxesTable.stx +3 -0
  27. package/resources/components/Dashboard/Commerce/CommerceUnitsDashboard.stx +1 -1
  28. package/resources/components/Dashboard/Commerce/CommerceUnitsTable.stx +3 -0
  29. package/resources/components/Dashboard/Commerce/CommerceVariantsDashboard.stx +1 -1
  30. package/resources/components/Dashboard/Commerce/CommerceVariantsTable.stx +3 -0
  31. package/resources/components/Dashboard/Commerce/Delivery/CouriersDashboard.stx +1 -11
  32. package/resources/components/Dashboard/Commerce/Delivery/CouriersTable.stx +55 -82
  33. package/resources/components/Dashboard/Commerce/Delivery/DeliveryRoutesDashboard.stx +1 -12
  34. package/resources/components/Dashboard/Commerce/Delivery/DeliveryRoutesTable.stx +57 -86
  35. package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveriesDashboard.stx +1 -24
  36. package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveryTable.stx +62 -80
  37. package/resources/components/Dashboard/Commerce/Delivery/LicenseKeysDashboard.stx +1 -12
  38. package/resources/components/Dashboard/Commerce/Delivery/LicenseKeysTable.stx +58 -99
  39. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsDashboard.stx +1 -23
  40. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsTable.stx +51 -86
  41. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +1 -12
  42. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesTable.stx +58 -71
  43. package/resources/components/Dashboard/Commerce/Delivery/ShippingZonesDashboard.stx +1 -12
  44. package/resources/components/Dashboard/Commerce/Delivery/ShippingZonesTable.stx +85 -110
  45. package/resources/components/Dashboard/Commerce/PrintDevicesDashboard.stx +8 -28
  46. package/resources/components/Dashboard/Commerce/PrintDevicesTable.stx +62 -57
  47. package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +7 -24
  48. package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +63 -59
  49. package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +7 -25
  50. package/resources/components/Dashboard/Marketing/CampaignsTable.stx +80 -57
  51. package/resources/components/Dashboard/Marketing/MarketingListsDashboard.stx +7 -27
  52. package/resources/components/Dashboard/Marketing/MarketingListsTable.stx +66 -71
  53. package/resources/components/Dashboard/Marketing/SocialPostsDashboard.stx +7 -25
  54. package/resources/components/Dashboard/Marketing/SocialPostsTable.stx +75 -62
  55. package/resources/components/Dashboard/Monitoring/ErrorDashboard.stx +9 -50
  56. package/resources/components/Dashboard/Monitoring/ErrorTable.stx +68 -97
  57. package/resources/components/Dashboard/Notifications/NotificationDeliveries.stx +22 -44
  58. package/resources/components/Dashboard/Notifications/NotificationDeliveryHistory.stx +13 -4
  59. package/resources/components/Dashboard/Notifications/NotificationDeliveryTable.stx +74 -86
  60. package/resources/components/Dashboard/UI/Table.stx +41 -2
@@ -13,8 +13,6 @@ const title = useReactiveProp('title', 'Notification Deliveries')
13
13
  const description = useReactiveProp('description', 'Monitor notification transport attempts.')
14
14
 
15
15
  const deliveries = state<NotificationDelivery[]>([])
16
- const currentPage = state(1)
17
- const perPage = state(10)
18
16
  const isLoading = state(true)
19
17
  const searchQuery = state('')
20
18
  const selectedStatus = state('all')
@@ -46,30 +44,27 @@ const sortedDeliveries = derived(() => [...filteredDeliveries()].sort((a, b) =>
46
44
  return sortDirection() === 'asc' ? comparison : -comparison
47
45
  }))
48
46
 
49
- const paginatedDeliveries = derived(() => {
50
- const start = (currentPage() - 1) * perPage()
51
- return sortedDeliveries().slice(start, start + perPage())
52
- })
53
-
54
- const totalItems = derived(() => filteredDeliveries().length)
55
- const totalPages = derived(() => Math.max(1, Math.ceil(totalItems() / perPage())))
56
- const rangeStart = derived(() => totalItems() === 0 ? 0 : (currentPage() - 1) * perPage() + 1)
57
- const rangeEnd = derived(() => Math.min(currentPage() * perPage(), totalItems()))
58
- const canGoBack = derived(() => currentPage() > 1)
59
- const canGoForward = derived(() => currentPage() < totalPages())
60
47
  const sentCount = derived(() => deliveries().filter(delivery => delivery.status === 'sent' || delivery.status === 'delivered').length)
61
48
  const failedCount = derived(() => deliveries().filter(delivery => delivery.status === 'failed').length)
62
49
  const pendingCount = derived(() => deliveries().filter(delivery => delivery.status === 'pending').length)
63
50
 
64
- effect(() => {
65
- searchQuery()
66
- selectedStatus()
67
- sortDirection()
68
- currentPage.set(1)
69
- })
51
+ /**
52
+ * What the table is showing: a change returns it to the first page, as this
53
+ * page's own pager did before the table took over paging.
54
+ */
55
+ const viewKey = derived(() => JSON.stringify([searchQuery(), selectedStatus(), sortDirection()]))
56
+
57
+ /**
58
+ * A component event reaches this handler as the emitted value, not as a
59
+ * CustomEvent, so reading `.detail` gave undefined: View opened an empty
60
+ * dialog and Retry did nothing (stacksjs/stacks#2977). Accept either.
61
+ */
62
+ function eventValue<T>(value: T | CustomEvent<T>): T {
63
+ return value instanceof CustomEvent ? value.detail : value
64
+ }
70
65
 
71
- function openDelivery(event: CustomEvent<NotificationDelivery>): void {
72
- selectedDelivery.set(event.detail)
66
+ function openDelivery(event: NotificationDelivery | CustomEvent<NotificationDelivery>): void {
67
+ selectedDelivery.set(eventValue(event))
73
68
  showDetails.set(true)
74
69
  }
75
70
 
@@ -84,8 +79,8 @@ async function loadDeliveries(): Promise<void> {
84
79
  isLoading.set(false)
85
80
  }
86
81
 
87
- async function retryDelivery(event: CustomEvent<NotificationDelivery>): Promise<void> {
88
- const delivery = event.detail
82
+ async function retryDelivery(event: NotificationDelivery | CustomEvent<NotificationDelivery>): Promise<void> {
83
+ const delivery = eventValue(event)
89
84
  retryingId.set(delivery.id)
90
85
  const retried = await retryNotificationDelivery(delivery)
91
86
  if (retried)
@@ -99,16 +94,6 @@ function resetFilters(): void {
99
94
  sortDirection.set('desc')
100
95
  }
101
96
 
102
- function previousPage(): void {
103
- if (canGoBack())
104
- currentPage.update(page => page - 1)
105
- }
106
-
107
- function nextPage(): void {
108
- if (canGoForward())
109
- currentPage.update(page => page + 1)
110
- }
111
-
112
97
  onMount(loadDeliveries)
113
98
  </script>
114
99
 
@@ -183,7 +168,10 @@ onMount(loadDeliveries)
183
168
 
184
169
  <div class="mt-8">
185
170
  <NotificationDeliveryTable
186
- :deliveries="paginatedDeliveries"
171
+ :deliveries="sortedDeliveries"
172
+ :paginated="true"
173
+ :sort-reset-key="sortDirection"
174
+ :page-reset-key="viewKey"
187
175
  :loading="isLoading"
188
176
  :actions="true"
189
177
  :retryingId="retryingId"
@@ -192,16 +180,6 @@ onMount(loadDeliveries)
192
180
  @retry="retryDelivery($event)"
193
181
  />
194
182
  </div>
195
-
196
- <div class="flex items-center justify-between mt-4">
197
- <p class="text-gray-700 text-sm dark:text-neutral-300">
198
- Showing <span class="font-medium">{{ rangeStart }}</span> to <span class="font-medium">{{ rangeEnd }}</span> of <span class="font-medium">{{ totalItems }}</span> results
199
- </p>
200
- <div class="flex gap-2 items-center">
201
- <Button variant="secondary" :disabled="!canGoBack" @click="previousPage()">Previous</Button>
202
- <Button variant="secondary" :disabled="!canGoForward" @click="nextPage()">Next</Button>
203
- </div>
204
- </div>
205
183
  </div>
206
184
 
207
185
  <NotificationDeliveryDialog :open="showDetails" :delivery="selectedDelivery" @close="closeDelivery()" />
@@ -102,8 +102,17 @@ function nextPage(): void {
102
102
  void loadHistory()
103
103
  }
104
104
 
105
- function openDelivery(event: CustomEvent<NotificationDelivery>): void {
106
- selectedDelivery.set(event.detail)
105
+ /**
106
+ * A component event reaches this handler as the emitted value, not as a
107
+ * CustomEvent, so reading `.detail` gave undefined: View opened an empty
108
+ * dialog and Retry did nothing (stacksjs/stacks#2977). Accept either.
109
+ */
110
+ function eventValue<T>(value: T | CustomEvent<T>): T {
111
+ return value instanceof CustomEvent ? value.detail : value
112
+ }
113
+
114
+ function openDelivery(event: NotificationDelivery | CustomEvent<NotificationDelivery>): void {
115
+ selectedDelivery.set(eventValue(event))
107
116
  showDetails.set(true)
108
117
  }
109
118
 
@@ -112,8 +121,8 @@ function closeDelivery(): void {
112
121
  selectedDelivery.set(null)
113
122
  }
114
123
 
115
- async function retryDelivery(event: CustomEvent<NotificationDelivery>): Promise<void> {
116
- const delivery = event.detail
124
+ async function retryDelivery(event: NotificationDelivery | CustomEvent<NotificationDelivery>): Promise<void> {
125
+ const delivery = eventValue(event)
117
126
  retryingId.set(delivery.id)
118
127
  const retried = await retryNotificationDelivery(delivery)
119
128
  if (retried)
@@ -1,3 +1,5 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  import type { NotificationDelivery } from '../../../../functions/notifications/deliveries'
3
5
 
@@ -7,6 +9,16 @@ const actions = useReactiveProp('actions', false)
7
9
  const allowRetry = useReactiveProp('allowRetry', true)
8
10
  const retryingId = useReactiveProp('retryingId', null as number | null)
9
11
  const emptyMessage = useReactiveProp('emptyMessage', 'No notification delivery attempts have been recorded.')
12
+ /**
13
+ * Off by default: the delivery history pages on the server and hands this
14
+ * one page at a time, while the per-channel deliveries page passes every
15
+ * attempt it loaded and lets Table page them (stacksjs/stacks#2977).
16
+ */
17
+ const paginated = useReactiveProp('paginated', false)
18
+ /** The parent's sort select: a change hands the order back from a header sort. */
19
+ const sortResetKey = useReactiveProp<unknown>('sortResetKey', '')
20
+ /** The parent's search and filters: a change returns to the first page. */
21
+ const pageResetKey = useReactiveProp<unknown>('pageResetKey', '')
10
22
  const emit = defineEmits()
11
23
 
12
24
  function channelIcon(channel: string): string {
@@ -25,101 +37,77 @@ function channelIcon(channel: string): string {
25
37
  return 'i-hugeicons-notification-03'
26
38
  }
27
39
 
28
- function statusClass(status: string): string {
29
- if (status === 'failed')
30
- return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-950/40 dark:text-red-300'
31
- if (status === 'pending')
32
- return 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-950/40 dark:text-amber-300'
33
- if (status === 'sent')
34
- return 'bg-blue-50 text-blue-700 ring-blue-600/20 dark:bg-blue-950/40 dark:text-blue-300'
35
- return 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-950/40 dark:text-green-300'
36
- }
37
-
38
- function formatDate(value: string): string {
39
- if (!value)
40
- return 'Not recorded'
41
- const date = new Date(value)
42
- return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('en-US', {
43
- dateStyle: 'medium',
44
- timeStyle: 'short',
45
- }).format(date)
46
- }
47
-
48
40
  function provider(delivery: NotificationDelivery): string {
49
41
  const value = delivery.metadata.provider || delivery.metadata.driver
50
42
  return typeof value === 'string' && value ? value : 'Default'
51
43
  }
52
44
 
53
- function summary(delivery: NotificationDelivery): string {
54
- return delivery.subject || delivery.body || 'No content recorded'
55
- }
56
-
57
45
  function canRetry(delivery: NotificationDelivery): boolean {
58
46
  return allowRetry()
59
47
  && delivery.status === 'failed'
60
48
  && (delivery.channel === 'email' || delivery.channel === 'sms')
61
49
  }
50
+
51
+ /** The channel cell is slotted for its icon. */
52
+ const COLUMNS = [
53
+ { key: 'channel', label: 'Channel', sortable: true, slotted: true },
54
+ { key: 'recipient', label: 'Recipient', sortable: true, emptyText: 'Not recorded', class: 'whitespace-nowrap' },
55
+ { key: 'summary', label: 'Content', class: 'max-w-sm truncate' },
56
+ {
57
+ key: 'status',
58
+ label: 'Status',
59
+ type: 'badge' as const,
60
+ sortable: true,
61
+ badgeClasses: {
62
+ failed: 'capitalize bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300',
63
+ pending: 'capitalize bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300',
64
+ sent: 'capitalize bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300',
65
+ delivered: 'capitalize bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-300',
66
+ },
67
+ },
68
+ { key: 'providerLabel', label: 'Provider', sortable: true, class: 'whitespace-nowrap' },
69
+ { key: 'sent_at', label: 'Attempted', type: 'datetime' as const, sortable: true, align: 'right' as const, emptyText: 'Not recorded', class: 'whitespace-nowrap' },
70
+ ]
71
+
72
+ const ROW_ACTIONS = [
73
+ { action: 'retry', label: 'Retry delivery to', icon: 'i-hugeicons-reload', when: { key: 'retryable', equals: true } },
74
+ { action: 'view', label: 'View details of delivery to', icon: 'i-hugeicons-view' },
75
+ ]
76
+
77
+ const rows = derived(() => deliveries().map(delivery => ({
78
+ ...delivery,
79
+ icon: `h-5 w-5 ${channelIcon(delivery.channel)}`,
80
+ summary: delivery.subject || delivery.body || 'No content recorded',
81
+ providerLabel: provider(delivery),
82
+ retryable: canRetry(delivery),
83
+ actionName: delivery.recipient || `attempt ${delivery.id}`,
84
+ })))
85
+
86
+ function handleRowAction(payload: { action: string, row: NotificationDelivery }): void {
87
+ emit(payload.action, payload.row)
88
+ }
62
89
  </script>
63
90
 
64
- <div class="overflow-x-auto ring-1 ring-black/5 rounded-lg dark:ring-white/10 shadow-sm">
65
- <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-600">
66
- <thead class="bg-gray-50 dark:bg-neutral-700">
67
- <tr>
68
- <th scope="col" class="pl-4 pr-3 py-3.5 sm:pl-6 font-semibold text-gray-900 text-left text-sm dark:text-neutral-100">Channel</th>
69
- <th scope="col" class="px-3 py-3.5 font-semibold text-gray-900 text-left text-sm dark:text-neutral-100">Recipient</th>
70
- <th scope="col" class="px-3 py-3.5 font-semibold text-gray-900 text-left text-sm dark:text-neutral-100">Content</th>
71
- <th scope="col" class="px-3 py-3.5 font-semibold text-gray-900 text-left text-sm dark:text-neutral-100">Status</th>
72
- <th scope="col" class="px-3 py-3.5 font-semibold text-gray-900 text-left text-sm dark:text-neutral-100">Provider</th>
73
- <th scope="col" class="px-3 py-3.5 font-semibold text-gray-900 text-right text-sm dark:text-neutral-100">Attempted</th>
74
- <th :if="actions" scope="col" class="relative pl-3 pr-4 py-3.5 sm:pr-6"><span class="sr-only">Actions</span></th>
75
- </tr>
76
- </thead>
77
- <tbody class="bg-white dark:bg-neutral-800 divide-gray-200 divide-y dark:divide-neutral-700">
78
- <tr :if="loading">
79
- <td :colspan="actions ? 7 : 6" class="px-6 py-12 text-center text-gray-500 text-sm dark:text-neutral-400">
80
- Loading delivery attempts...
81
- </td>
82
- </tr>
83
- <tr :if="!loading && deliveries.length === 0">
84
- <td :colspan="actions ? 7 : 6" class="px-6 py-12 text-center text-gray-500 text-sm dark:text-neutral-400">
85
- {{ emptyMessage }}
86
- </td>
87
- </tr>
88
- <template :for="delivery in deliveries">
89
- <tr class="hover:bg-gray-50 dark:hover:bg-neutral-700/50">
90
- <td class="pl-4 pr-3 py-4 sm:pl-6 text-sm whitespace-nowrap">
91
- <span class="inline-flex gap-2 items-center font-medium text-gray-900 dark:text-neutral-100">
92
- <span :class="'h-5 w-5 ' + channelIcon(delivery.channel)"></span>
93
- <span class="capitalize">{{ delivery.channel }}</span>
94
- </span>
95
- </td>
96
- <td class="px-3 py-4 text-gray-600 text-sm whitespace-nowrap dark:text-neutral-300">{{ delivery.recipient || 'Not recorded' }}</td>
97
- <td class="px-3 py-4 text-gray-500 text-sm dark:text-neutral-400">
98
- <span class="block max-w-sm truncate">{{ summary(delivery) }}</span>
99
- </td>
100
- <td class="px-3 py-4 text-sm whitespace-nowrap">
101
- <span :class="'inline-flex items-center px-2 py-1 font-medium text-xs ring-1 ring-inset rounded-md capitalize ' + statusClass(delivery.status)">
102
- {{ delivery.status }}
103
- </span>
104
- </td>
105
- <td class="px-3 py-4 text-gray-500 text-sm whitespace-nowrap dark:text-neutral-400">{{ provider(delivery) }}</td>
106
- <td class="px-3 py-4 text-gray-500 text-right text-sm whitespace-nowrap dark:text-neutral-400">{{ formatDate(delivery.sent_at) }}</td>
107
- <td :if="actions" class="relative pl-3 pr-4 py-4 sm:pr-6 font-medium text-right text-sm whitespace-nowrap">
108
- <Button
109
- :if="canRetry(delivery)"
110
- variant="secondary"
111
- size="sm"
112
- :loading="retryingId === delivery.id"
113
- @click="emit('retry', delivery)"
114
- >
115
- Retry
116
- </Button>
117
- <Button variant="ghost" size="sm" @click="emit('view', delivery)">
118
- View Details
119
- </Button>
120
- </td>
121
- </tr>
122
- </template>
123
- </tbody>
124
- </table>
125
- </div>
91
+ <Table
92
+ :sort-reset-key="sortResetKey()"
93
+ :page-reset-key="pageResetKey()"
94
+ :columns="COLUMNS"
95
+ :data="rows()"
96
+ :loading="loading()"
97
+ :row-actions="actions() ? ROW_ACTIONS : []"
98
+ action-label-key="actionName"
99
+ busy-key="id"
100
+ :busy-value="retryingId()"
101
+ :paginated="paginated()"
102
+ :page-size="10"
103
+ empty-title="No delivery attempts"
104
+ :empty-description="emptyMessage()"
105
+ @row-action="handleRowAction"
106
+ >
107
+ <template #cell="{ row }">
108
+ <span class="inline-flex gap-2 items-center font-medium text-gray-900 dark:text-neutral-100">
109
+ <span :class="row.icon"></span>
110
+ <span class="capitalize" x-text="row.channel"></span>
111
+ </span>
112
+ </template>
113
+ </Table>
@@ -210,14 +210,43 @@ const actionLabelKey = useReactiveProp('actionLabelKey', '')
210
210
  */
211
211
  const busyKey = useReactiveProp('busyKey', '')
212
212
  const busyValue = useReactiveProp<unknown>('busyValue', '')
213
+ /**
214
+ * The parent's own ordering, for a page whose sort select orders the rows
215
+ * Table receives.
216
+ *
217
+ * A header click overrides that order, and nothing used to hand it back:
218
+ * once a column was sorted, changing the select changed nothing visible, so
219
+ * the page had two sort controls that disagreed (stacksjs/stacks#2977). Pass
220
+ * the select's value here and a change to it clears the header sort, so the
221
+ * order on screen is always the one the viewer chose last.
222
+ */
223
+ const sortResetKey = useReactiveProp<unknown>('sortResetKey', '')
224
+ /**
225
+ * The parent's search and filters, for a page that filters the rows Table
226
+ * receives. A change returns to the first page, as a parent that paged
227
+ * itself always did: a narrower result read from page 3 is a page that
228
+ * looks empty or unrelated to what was just typed (stacksjs/stacks#2977).
229
+ */
230
+ const pageResetKey = useReactiveProp<unknown>('pageResetKey', '')
213
231
 
214
232
  const selectedKeys = state<TableKey[]>([])
215
233
  const sortKey = state('')
216
234
  const sortDirection = state<SortDirection>('asc')
235
+
236
+ effect(() => {
237
+ sortResetKey()
238
+ sortKey.set('')
239
+ })
217
240
  const query = state('')
218
241
  const columnFilters = state<Record<string, string>>({})
219
242
  const hiddenColumns = state<string[]>([])
220
243
  const page = state(1)
244
+
245
+ effect(() => {
246
+ sortResetKey()
247
+ pageResetKey()
248
+ page.set(1)
249
+ })
221
250
  const columnMenuOpen = state(false)
222
251
  const emit = defineEmits()
223
252
 
@@ -486,10 +515,20 @@ function toggleRow(row: DashboardTableRow, event: Event): void {
486
515
 
487
516
  /* ------------------------------------------------------------------ sort */
488
517
 
518
+ /**
519
+ * Ascending, descending, then off. Off is the order the rows arrived in,
520
+ * which on a page with its own sort select is that select's order; without
521
+ * a third state a header click could never be undone.
522
+ */
489
523
  function sort(column: DashboardTableColumn): void {
490
524
  if (!column.sortable)
491
525
  return
492
- const direction: SortDirection = sortKey() === column.key && sortDirection() === 'asc' ? 'desc' : 'asc'
526
+ if (sortKey() === column.key && sortDirection() === 'desc') {
527
+ sortKey.set('')
528
+ emit('sort', { key: '', direction: 'asc' })
529
+ return
530
+ }
531
+ const direction: SortDirection = sortKey() === column.key ? 'desc' : 'asc'
493
532
  sortKey.set(column.key)
494
533
  sortDirection.set(direction)
495
534
  emit('sort', { key: column.key, direction })
@@ -498,7 +537,7 @@ function sort(column: DashboardTableColumn): void {
498
537
  function sortLabel(column: DashboardTableColumn): string {
499
538
  if (sortKey() !== column.key)
500
539
  return `Sort by ${column.label}`
501
- return `Sort by ${column.label} ${sortDirection() === 'asc' ? 'descending' : 'ascending'}`
540
+ return sortDirection() === 'asc' ? `Sort by ${column.label} descending` : `Stop sorting by ${column.label}`
502
541
  }
503
542
 
504
543
  /** Mirrors sort state into aria-sort so screen readers announce it. */