@wishbone-media/spark 0.41.0 → 0.43.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wishbone-media/spark",
3
- "version": "0.41.0",
3
+ "version": "0.43.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -5,9 +5,17 @@
5
5
  </template>
6
6
 
7
7
  <script setup>
8
- import { provide, ref } from 'vue'
8
+ import { provide, ref, onMounted, onUpdated } from 'vue'
9
9
 
10
10
  const groupRef = ref(null)
11
+ const childCount = ref(0)
12
+
13
+ function syncChildCount() {
14
+ childCount.value = groupRef.value?.children.length || 0
15
+ }
16
+
17
+ onMounted(syncChildCount)
18
+ onUpdated(syncChildCount)
11
19
 
12
20
  const getButtonIndex = (buttonElement) => {
13
21
  if (!groupRef.value) return -1
@@ -16,7 +24,9 @@ const getButtonIndex = (buttonElement) => {
16
24
  }
17
25
 
18
26
  const getButtonCount = () => {
19
- return groupRef.value?.children.length || 0
27
+ // Reading childCount.value creates a reactive dependency so SparkButton's
28
+ // computed re-evaluates when items are added/removed
29
+ return childCount.value
20
30
  }
21
31
 
22
32
  provide('buttonGroup', {
@@ -17,6 +17,7 @@ export { default as SparkModalDialog } from './SparkModalDialog.vue'
17
17
  export { default as SparkOverlay } from './SparkOverlay.vue'
18
18
  export { default as SparkSubNav } from './SparkSubNav.vue'
19
19
  export { default as SparkTable } from './SparkTable.vue'
20
+ export { default as SparkTablePaginationDetails } from './SparkTablePaginationDetails.vue'
20
21
  export { default as SparkTablePaginationPaging } from './SparkTablePaginationPaging.vue'
21
22
  export { default as SparkTablePaginationPerPage } from './SparkTablePaginationPerPage.vue'
22
23
  export { default as SparkTableToolbar } from './SparkTableToolbar.vue'
@@ -9,9 +9,13 @@ import { sparkOverlayService } from './sparkOverlayService.js'
9
9
  * @param {Object} options - Configuration options
10
10
  * @param {Array} options.items - Navigation items array
11
11
  * @param {string} [options.defaultId] - Default active item ID (falls back to first visible item)
12
+ * @param {boolean|string} [options.syncToQuery=false] - Sync active tab to URL query param.
13
+ * `true` uses 'tab' as the param name, a string uses that as the param name.
14
+ * When enabled, items without a route/overlay/modal/action will update the query param on navigate.
12
15
  * @returns {Object} Sub-navigation utilities
13
16
  *
14
17
  * @example
18
+ * // Route-based tabs
15
19
  * const subNav = useSubNavigation({
16
20
  * items: [
17
21
  * { id: 'general', label: 'General', route: { name: 'brands.general' } },
@@ -21,6 +25,17 @@ import { sparkOverlayService } from './sparkOverlayService.js'
21
25
  * ]
22
26
  * })
23
27
  *
28
+ * @example
29
+ * // Local tabs with URL query sync (e.g. ?tab=addresses)
30
+ * const subNav = useSubNavigation({
31
+ * items: [
32
+ * { id: 'contacts', label: 'Contacts' },
33
+ * { id: 'addresses', label: 'Addresses' },
34
+ * ],
35
+ * syncToQuery: true,
36
+ * defaultId: 'contacts',
37
+ * })
38
+ *
24
39
  * // Item structure:
25
40
  * // {
26
41
  * // id: 'general', // Unique identifier (required)
@@ -41,7 +56,10 @@ import { sparkOverlayService } from './sparkOverlayService.js'
41
56
  * // }
42
57
  */
43
58
  export function useSubNavigation(options = {}) {
44
- const { items: initialItems = [], defaultId = null } = options
59
+ const { items: initialItems = [], defaultId = null, syncToQuery = false } = options
60
+
61
+ // Resolve syncToQuery: true → 'tab', string → custom param name, false → null
62
+ const syncToQueryParam = syncToQuery === true ? 'tab' : syncToQuery || null
45
63
 
46
64
  const router = useRouter()
47
65
  const route = useRoute()
@@ -95,7 +113,18 @@ export function useSubNavigation(options = {}) {
95
113
  */
96
114
  function isActive(item) {
97
115
  const itemObj = typeof item === 'string' ? items.value.find((i) => i.id === item) : item
98
- if (!itemObj || !itemObj.route) return false
116
+ if (!itemObj) return false
117
+
118
+ // syncToQuery mode: match against query param value for items without a route
119
+ if (syncToQueryParam && !itemObj.route) {
120
+ const queryValue = route.query[syncToQueryParam]
121
+ if (queryValue) return itemObj.id === queryValue
122
+ // No query param → active if this is the default/first visible item
123
+ const fallbackId = defaultId || visibleItems.value[0]?.id
124
+ return itemObj.id === fallbackId
125
+ }
126
+
127
+ if (!itemObj.route) return false
99
128
 
100
129
  // Handle route as string (route name) or object
101
130
  const itemRoute = typeof itemObj.route === 'string' ? { name: itemObj.route } : itemObj.route
@@ -191,6 +220,12 @@ export function useSubNavigation(options = {}) {
191
220
  return true
192
221
  }
193
222
 
223
+ // Handle syncToQuery for items without explicit navigation type
224
+ if (syncToQueryParam) {
225
+ await router.replace({ query: { ...route.query, [syncToQueryParam]: itemObj.id } })
226
+ return true
227
+ }
228
+
194
229
  return false
195
230
  }
196
231
 
@@ -1,102 +1,118 @@
1
1
  <template>
2
2
  <div :class="sidebarClass" class="fixed inset-y-0 flex transition-all z-100">
3
3
  <div class="flex grow m-2.5 p-[10px] rounded-lg">
4
- <nav class="flex flex-1 flex-col">
5
- <ul class="flex flex-1 flex-col gap-y-7" role="list">
6
- <li class="flex flex-1 flex-col">
7
- <ul role="list" class="flex flex-1 flex-col">
8
- <li class="flex items-center pb-8">
9
- <a
10
- class="grid w-[40px] h-[40px] place-items-center rounded-md bg-primary-600 text-white text-[13px] cursor-pointer"
11
- @click.prevent="mainNavStore.goto(appStore.state.homeRoute)"
12
- >
13
- <font-awesome-icon :icon="Icons[appIcon]" class="size-5" />
14
- </a>
15
- <a
4
+ <nav class="flex flex-1 flex-col min-h-0">
5
+ <!-- Logo (fixed at top) -->
6
+ <div class="flex-shrink-0 flex items-center pb-8">
7
+ <a
8
+ class="grid w-[40px] h-[40px] place-items-center rounded-md bg-primary-600 text-white text-[13px] cursor-pointer"
9
+ @click.prevent="mainNavStore.goto(appStore.state.homeRoute)"
10
+ >
11
+ <font-awesome-icon :icon="Icons[appIcon]" class="size-5" />
12
+ </a>
13
+ <a
14
+ v-if="!mainNavStore.state.collapsed"
15
+ class="font-medium text-gray-800 ml-[10px] cursor-pointer"
16
+ @click.prevent="mainNavStore.goto(appStore.state.homeRoute)"
17
+ >
18
+ {{ appStore.state.app }}
19
+ </a>
20
+ </div>
21
+
22
+ <!-- Menu items (scrollable) -->
23
+ <ul role="list" class="flex-1 overflow-y-auto">
24
+ <template v-for="item in menuItems" :key="item.name">
25
+ <li :class="{ 'mt-[10px]': item.children }">
26
+ <a
27
+ :class="{
28
+ 'bg-gray-100': isCurrentRoute(item),
29
+ 'hover:bg-gray-100': item?.href,
30
+ }"
31
+ :href="item?.href"
32
+ class="h-[37px] sgroup flex items-center gap-x-2 rounded-md p-3 text-gray-800 leading-5 transition-all duration-300 ease-in-out"
33
+ @click.prevent="mainNavStore.goto(item.href)"
34
+ >
35
+ <font-awesome-icon
36
+ v-if="item.icon"
37
+ :icon="Icons[item.icon]"
38
+ :class="[isCurrentRoute(item) ? 'text-gray-400' : 'text-gray-400']"
39
+ class="size-4"
40
+ />
41
+ <span
16
42
  v-if="!mainNavStore.state.collapsed"
17
- class="font-medium text-gray-800 ml-[10px] cursor-pointer"
18
- @click.prevent="mainNavStore.goto(appStore.state.homeRoute)"
19
- >
20
- {{ appStore.state.app }}
21
- </a>
22
- </li>
23
- <template v-for="item in mainNavStore.state.menu" :key="item.name">
24
- <li
25
43
  :class="{
26
- 'mt-[10px]': item.children,
27
- 'mt-auto': item.footerSection,
44
+ 'text-[11px]': item?.children,
45
+ 'text-[13px]': !item?.children,
46
+ 'font-semibold': item?.children,
47
+ 'text-gray-500': item?.children,
28
48
  }"
29
49
  >
50
+ {{ item.name }}
51
+ </span>
52
+ <div v-else-if="item?.children" class="w-full flex justify-center">
53
+ <div class="w-[10px] h-px bg-gray-400" />
54
+ </div>
55
+ </a>
56
+ <ul v-if="item.children" class="mt-[5px] flex flex-col gap-[5px]">
57
+ <li v-for="child in item.children" :key="child.name">
30
58
  <a
31
- :class="{
32
- 'bg-gray-100': isCurrentRoute(item),
33
- 'hover:bg-gray-100': item?.href,
34
- }"
35
- :href="item?.href"
59
+ :class="[isCurrentRoute(child) ? 'bg-gray-100' : '', 'hover:bg-gray-100']"
60
+ :href="child.href"
36
61
  class="h-[37px] sgroup flex items-center gap-x-2 rounded-md p-3 text-gray-800 leading-5 transition-all duration-300 ease-in-out"
37
- @click.prevent="mainNavStore.goto(item.href)"
62
+ @click.prevent="mainNavStore.goto(child.href)"
38
63
  >
39
64
  <font-awesome-icon
40
- v-if="item.icon"
41
- :icon="Icons[item.icon]"
42
- :class="[isCurrentRoute(item) ? 'text-gray-400' : 'text-gray-400']"
65
+ v-if="child.icon"
66
+ :icon="Icons[child.icon]"
67
+ :class="[isCurrentRoute(child) ? 'text-gray-400' : 'text-gray-400']"
43
68
  class="size-4"
44
69
  />
45
- <span
46
- v-if="!mainNavStore.state.collapsed"
47
- :class="{
48
- 'text-[11px]': item?.children,
49
- 'text-[13px]': !item?.children,
50
- 'font-semibold': item?.children,
51
- 'text-gray-500': item?.children,
52
- }"
53
- >
54
- {{ item.name }}
70
+ <span v-if="!mainNavStore.state.collapsed" class="text-[13px]">
71
+ {{ child.name }}
55
72
  </span>
56
- <div v-else-if="item?.children" class="w-full flex justify-center">
57
- <div class="w-[10px] h-px bg-gray-400" />
58
- </div>
59
73
  </a>
60
- <ul v-if="item.children" class="mt-[5px] flex flex-col gap-[5px]">
61
- <li v-for="child in item.children" :key="child.name">
62
- <a
63
- :class="[isCurrentRoute(child) ? 'bg-gray-100' : '', 'hover:bg-gray-100']"
64
- :href="child.href"
65
- class="h-[37px] sgroup flex items-center gap-x-2 rounded-md p-3 text-gray-800 leading-5 transition-all duration-300 ease-in-out"
66
- @click.prevent="mainNavStore.goto(child.href)"
67
- >
68
- <font-awesome-icon
69
- v-if="child.icon"
70
- :icon="Icons[child.icon]"
71
- :class="[isCurrentRoute(child) ? 'text-gray-400' : 'text-gray-400']"
72
- class="size-4"
73
- />
74
- <span v-if="!mainNavStore.state.collapsed" class="text-[13px]">
75
- {{ child.name }}
76
- </span>
77
- </a>
78
- </li>
79
- </ul>
80
74
  </li>
81
- </template>
82
- </ul>
83
- </li>
84
- <li class="mt-auto">
85
- <slot name="sidebar-footer" />
86
- <a
87
- class="font-medium grid place-content-center gap-x-3 rounded-md h-10 p-2.5 text-gray-800 text-[13px] hover:bg-gray-100 transition-all duration-300 ease-in-out"
88
- href="#"
89
- @click.prevent="mainNavStore.toggleCollapsed()"
90
- >
91
- <font-awesome-icon
92
- :icon="
93
- Icons[mainNavStore.state.collapsed ? 'farArrowRightToLine' : 'farArrowLeftToLine']
94
- "
95
- class="class-5"
96
- />
97
- </a>
98
- </li>
75
+ </ul>
76
+ </li>
77
+ </template>
99
78
  </ul>
79
+
80
+ <!-- Footer items (fixed at bottom) -->
81
+ <div class="flex-shrink-0 mt-auto">
82
+ <ul v-if="footerItems.length" role="list" class="flex flex-col gap-[5px]">
83
+ <li v-for="child in footerItems" :key="child.name">
84
+ <a
85
+ :class="[isCurrentRoute(child) ? 'bg-gray-100' : '', 'hover:bg-gray-100']"
86
+ :href="child.href"
87
+ class="h-[37px] sgroup flex items-center gap-x-2 rounded-md p-3 text-gray-800 leading-5 transition-all duration-300 ease-in-out"
88
+ @click.prevent="mainNavStore.goto(child.href)"
89
+ >
90
+ <font-awesome-icon
91
+ v-if="child.icon"
92
+ :icon="Icons[child.icon]"
93
+ :class="[isCurrentRoute(child) ? 'text-gray-400' : 'text-gray-400']"
94
+ class="size-4"
95
+ />
96
+ <span v-if="!mainNavStore.state.collapsed" class="text-[13px]">
97
+ {{ child.name }}
98
+ </span>
99
+ </a>
100
+ </li>
101
+ </ul>
102
+ <slot name="sidebar-footer" />
103
+ <a
104
+ class="font-medium grid place-content-center gap-x-3 rounded-md h-10 p-2.5 text-gray-800 text-[13px] hover:bg-gray-100 transition-all duration-300 ease-in-out"
105
+ href="#"
106
+ @click.prevent="mainNavStore.toggleCollapsed()"
107
+ >
108
+ <font-awesome-icon
109
+ :icon="
110
+ Icons[mainNavStore.state.collapsed ? 'farArrowRightToLine' : 'farArrowLeftToLine']
111
+ "
112
+ class="class-5"
113
+ />
114
+ </a>
115
+ </div>
100
116
  </nav>
101
117
  </div>
102
118
  </div>
@@ -213,6 +229,16 @@ const isCurrentRoute = (item) => {
213
229
  return false
214
230
  }
215
231
 
232
+ // Split menu items: scrollable items vs footer items (sticky at bottom)
233
+ const menuItems = computed(() =>
234
+ props.mainNavStore.state.menu.filter((item) => !item.footerSection),
235
+ )
236
+ const footerItems = computed(() =>
237
+ props.mainNavStore.state.menu
238
+ .filter((item) => item.footerSection)
239
+ .flatMap((item) => item.children || []),
240
+ )
241
+
216
242
  const sparkBrandFilterStore = useSparkBrandFilterStore()
217
243
  const sparkAppSelectorStore = useSparkAppSelectorStore()
218
244