@wishbone-media/spark 0.52.0 → 0.53.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.52.0",
3
+ "version": "0.53.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -1,33 +1,92 @@
1
1
  <template>
2
2
  <div
3
- class="flex flex-col divide-y divide-gray-300 rounded-lg border border-gray-300 text-gray-700 bg-gray-100"
3
+ class="flex flex-col rounded-lg border border-gray-300 text-gray-700 bg-gray-100"
4
+ :class="props.scrollBody ? 'min-h-0' : ''"
4
5
  >
5
- <div v-if="$slots.header" class="px-[30px] py-5">
6
+ <div
7
+ v-if="$slots.header"
8
+ :class="['border-b border-gray-300 px-[30px] py-5', props.headerClass]"
9
+ >
6
10
  <slot name="header" />
7
11
  </div>
8
12
 
9
- <div :class="[props.padded ? props.paddedClass : '', 'min-h-0 flex-1', props.bodyClass]">
13
+ <div
14
+ v-else-if="hasDefaultHeader"
15
+ :class="[
16
+ 'border-b border-gray-300',
17
+ $slots.nav
18
+ ? 'flex items-stretch justify-between gap-4 px-6'
19
+ : 'flex items-center justify-between gap-4 px-6 py-4',
20
+ props.headerClass,
21
+ ]"
22
+ >
23
+ <!-- Left cluster: tab nav replaces back/title/extra when provided -->
24
+ <div v-if="$slots.nav" class="flex min-w-0 items-stretch">
25
+ <slot name="nav" />
26
+ </div>
27
+ <div v-else class="flex min-w-0 items-center gap-3">
28
+ <SparkButton
29
+ v-if="props.backTo"
30
+ variant="secondary"
31
+ button-class="w-10 h-10 grid place-content-center"
32
+ @click="router?.push(props.backTo)"
33
+ >
34
+ <FontAwesomeIcon :icon="Icons.farChevronLeft" class="text-gray-500" />
35
+ </SparkButton>
36
+ <h3
37
+ v-if="props.title"
38
+ class="min-w-0 truncate text-xl font-semibold leading-6 text-gray-800"
39
+ >
40
+ {{ props.title }}
41
+ </h3>
42
+ <slot name="title-extra" />
43
+ </div>
44
+
45
+ <!-- Right cluster: metadata then actions -->
46
+ <div
47
+ v-if="$slots.meta || $slots.actions"
48
+ class="flex shrink-0 items-center gap-4"
49
+ :class="$slots.nav ? 'py-3' : ''"
50
+ >
51
+ <slot name="meta" />
52
+ <slot name="actions" />
53
+ </div>
54
+ </div>
55
+
56
+ <div
57
+ ref="bodyEl"
58
+ :class="[
59
+ props.padded ? props.paddedClass : '',
60
+ 'min-h-0 flex-1',
61
+ props.scrollBody ? 'overflow-y-auto' : '',
62
+ props.bodyClass,
63
+ ]"
64
+ >
10
65
  <slot />
11
66
  </div>
12
67
 
13
- <div v-if="$slots.footer" class="p-5">
68
+ <div v-if="$slots.footer" :class="['border-t border-gray-300 p-5', props.footerClass]">
14
69
  <slot name="footer" />
15
70
  </div>
16
71
  </div>
17
72
  </template>
18
73
 
19
74
  <script setup>
75
+ import { computed, ref, useSlots } from 'vue'
76
+ import { useRouter } from 'vue-router'
77
+ import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
78
+ import { Icons } from '../plugins/fontawesome.js'
79
+ import SparkButton from './SparkButton.vue'
80
+
20
81
  const props = defineProps({
21
82
  padded: {
22
83
  type: Boolean,
23
84
  default: true,
24
85
  },
25
-
26
86
  paddedClass: {
27
87
  type: String,
28
88
  default: 'p-5',
29
89
  },
30
-
31
90
  // Extra classes for the body wrapper, e.g. 'flex flex-col' so slot content
32
91
  // can use flex-1/min-h-0 for bounded-height (internally scrolling) layouts.
33
92
  // The wrapper is a plain block by default; child flex-item utilities are
@@ -36,5 +95,51 @@ const props = defineProps({
36
95
  type: String,
37
96
  default: '',
38
97
  },
98
+ // Bounded-height card: the body scrolls internally instead of growing the
99
+ // card. Sizing stays on the consumer ('min-h-0 flex-1' in a flex column;
100
+ // width classes + stretch in a flex row) — see spark-card.md "Fill layout".
101
+ scrollBody: {
102
+ type: Boolean,
103
+ default: false,
104
+ },
105
+ // Extra classes for whichever header wrapper renders (slot or default).
106
+ headerClass: {
107
+ type: String,
108
+ default: '',
109
+ },
110
+ // Extra classes for the footer wrapper.
111
+ footerClass: {
112
+ type: String,
113
+ default: '',
114
+ },
115
+ // Default header: main heading text. Renders the header without needing
116
+ // the #header slot; combine with backTo and the title-extra/nav/meta/actions
117
+ // slots. The #header slot overrides the default header entirely.
118
+ title: {
119
+ type: String,
120
+ default: null,
121
+ },
122
+ // Default header: route location for the standard back (chevron) button.
123
+ backTo: {
124
+ type: [Object, String],
125
+ default: null,
126
+ },
39
127
  })
128
+
129
+ const slots = useSlots()
130
+ const router = useRouter()
131
+ const bodyEl = ref(null)
132
+
133
+ const hasDefaultHeader = computed(() =>
134
+ Boolean(
135
+ props.title || props.backTo || slots['title-extra'] || slots.nav || slots.meta || slots.actions,
136
+ ),
137
+ )
138
+
139
+ // Reset the body's internal scroll (e.g. on tab changes within a scrollBody card)
140
+ function scrollBodyToTop() {
141
+ if (bodyEl.value) bodyEl.value.scrollTop = 0
142
+ }
143
+
144
+ defineExpose({ scrollBodyToTop })
40
145
  </script>
@@ -10,6 +10,7 @@
10
10
  >
11
11
  <SparkAlert
12
12
  v-if="outlet.state.isVisible"
13
+ ref="alertRef"
13
14
  :key="notificationKey"
14
15
  :type="outlet.state.type"
15
16
  :closeable="outlet.state.closeable"
@@ -30,7 +31,7 @@
30
31
  </template>
31
32
 
32
33
  <script setup>
33
- import { computed, ref, watch, onMounted } from 'vue'
34
+ import { computed, nextTick, ref, watch, onMounted } from 'vue'
34
35
  import { useRoute } from 'vue-router'
35
36
  import { sparkNotificationService } from '@/composables/sparkNotificationService'
36
37
  import SparkAlert from '@/components/SparkAlert.vue'
@@ -71,6 +72,18 @@ watch(
71
72
  { immediate: true },
72
73
  )
73
74
 
75
+ // Reveal the outlet when a notification appears — scrolls whichever ancestor
76
+ // scrolls (window or a scrollBody card), replacing window-level scrollTo calls
77
+ const alertRef = ref(null)
78
+ watch(
79
+ () => [outlet.value.state.isVisible, notificationKey.value],
80
+ async ([isVisible]) => {
81
+ if (!isVisible) return
82
+ await nextTick()
83
+ alertRef.value?.$el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
84
+ },
85
+ )
86
+
74
87
  // On mount, check if notification was shown on a different route
75
88
  onMounted(() => {
76
89
  if (!props.clearOnRouteChange) return
@@ -2,7 +2,7 @@
2
2
  <nav :class="containerClasses">
3
3
  <!-- Custom layout slot -->
4
4
  <slot
5
- v-if="props.layout === 'custom'"
5
+ v-if="layout === 'custom'"
6
6
  :items="computedItems"
7
7
  :active-id="computedActiveId"
8
8
  :is-active="isItemActive"
@@ -11,7 +11,7 @@
11
11
  />
12
12
 
13
13
  <!-- Horizontal: SparkButtonGroup -->
14
- <SparkButtonGroup v-else-if="props.layout === 'horizontal'" class="isolate">
14
+ <SparkButtonGroup v-else-if="layout === 'horizontal'" class="isolate">
15
15
  <SparkButton
16
16
  v-for="item in computedItems"
17
17
  :key="item.id"
@@ -39,13 +39,13 @@
39
39
  >
40
40
  <!-- Active checkmark icon for vertical layout -->
41
41
  <FontAwesomeIcon
42
- v-if="isItemActive(item) && props.layout === 'vertical'"
42
+ v-if="isItemActive(item) && layout === 'vertical'"
43
43
  :icon="Icons.farCircleCheck"
44
44
  class="mr-2 size-4"
45
45
  />
46
46
  <!-- Item icon (not for vertical active state) -->
47
47
  <FontAwesomeIcon
48
- v-if="item.icon && !(isItemActive(item) && props.layout === 'vertical')"
48
+ v-if="item.icon && !(isItemActive(item) && layout === 'vertical')"
49
49
  :icon="Icons[item.icon]"
50
50
  class="text-gray-400 mr-2"
51
51
  :class="iconClasses"
@@ -86,15 +86,20 @@ const props = defineProps({
86
86
 
87
87
  /**
88
88
  * Layout style
89
- * - 'tabs': Underline-style tabs (for card body)
89
+ * - 'underline': Underline tabs; the active underline overlaps the card divider
90
+ * when used in SparkCard's #nav header slot ('tabs' is a legacy alias)
91
+ * - 'button': Plain-text items; the active item renders as a white bordered button
92
+ * - 'button-group': SparkButtonGroup of buttons ('horizontal' is a legacy alias)
90
93
  * - 'vertical': Vertical sidebar links
91
- * - 'horizontal': SparkButtonGroup with buttons
92
94
  * - 'custom': Use scoped slot for full control
93
95
  */
94
96
  layout: {
95
97
  type: String,
96
98
  default: 'tabs',
97
- validator: (v) => ['tabs', 'vertical', 'horizontal', 'custom'].includes(v),
99
+ validator: (v) =>
100
+ ['tabs', 'underline', 'vertical', 'horizontal', 'button-group', 'button', 'custom'].includes(
101
+ v,
102
+ ),
98
103
  },
99
104
 
100
105
  /**
@@ -116,6 +121,10 @@ const props = defineProps({
116
121
 
117
122
  const emit = defineEmits(['navigate', 'update:activeId'])
118
123
 
124
+ // Legacy layout aliases kept for back-compat; docs teach the new names
125
+ const LAYOUT_ALIASES = { underline: 'tabs', 'button-group': 'horizontal' }
126
+ const layout = computed(() => LAYOUT_ALIASES[props.layout] ?? props.layout)
127
+
119
128
  // Compute items from navInstance or props
120
129
  const computedItems = computed(() => {
121
130
  if (props.navInstance) {
@@ -168,12 +177,13 @@ async function handleNavigate(item) {
168
177
  const containerClasses = computed(() => {
169
178
  const base = 'spark-sub-nav'
170
179
  const layoutClasses = {
171
- tabs: 'flex items-center gap-1 -mb-px',
180
+ tabs: 'flex items-stretch gap-1 -mb-px',
181
+ button: 'flex items-center gap-2',
172
182
  vertical: 'flex flex-col gap-1',
173
183
  horizontal: '', // SparkButtonGroup handles its own styling
174
184
  custom: '',
175
185
  }
176
- return [base, layoutClasses[props.layout]]
186
+ return [base, layoutClasses[layout.value]]
177
187
  })
178
188
 
179
189
  // Icon classes
@@ -197,9 +207,11 @@ function getItemClasses(item) {
197
207
  }
198
208
 
199
209
  // Layout-specific styles
200
- if (props.layout === 'tabs') {
210
+ if (layout.value === 'tabs') {
201
211
  base.push(...getTabsClasses(isActive, isDisabled))
202
- } else if (props.layout === 'vertical') {
212
+ } else if (layout.value === 'button') {
213
+ base.push(...getButtonClasses(isActive, isDisabled))
214
+ } else if (layout.value === 'vertical') {
203
215
  base.push(...getVerticalClasses(isActive, isDisabled))
204
216
  }
205
217
 
@@ -225,6 +237,23 @@ function getTabsClasses(isActive, isDisabled) {
225
237
  return classes
226
238
  }
227
239
 
240
+ // Button layout (active item = white bordered button, inactive = plain text)
241
+ function getButtonClasses(isActive, isDisabled) {
242
+ const classes = ['rounded-lg border font-medium']
243
+
244
+ classes.push(props.compact ? 'text-sm px-3 py-1.5' : 'text-sm px-4 py-2')
245
+
246
+ if (isActive) {
247
+ classes.push('border-gray-300 bg-white text-gray-900')
248
+ } else if (!isDisabled) {
249
+ classes.push('border-transparent text-gray-600 hover:text-gray-900')
250
+ } else {
251
+ classes.push('border-transparent text-gray-400')
252
+ }
253
+
254
+ return classes
255
+ }
256
+
228
257
  // Vertical layout (simplified with checkmark)
229
258
  function getVerticalClasses(isActive, isDisabled) {
230
259
  const classes = ['font-medium text-[15px]/[20px] py-2']
@@ -111,19 +111,19 @@ export function useFormSubmission(options = {}) {
111
111
  }
112
112
 
113
113
  if (setFormErrors) {
114
+ // The notification outlet reveals itself on show (scrollIntoView)
114
115
  sparkNotificationService.show({
115
116
  type: 'danger',
116
117
  message: getFormLevelMessage(response),
117
118
  })
118
- window.scrollTo({ top: 0, behavior: 'smooth' })
119
119
  }
120
120
  } else {
121
121
  // Handle non-validation errors
122
+ // The notification outlet reveals itself on show (scrollIntoView)
122
123
  sparkNotificationService.show({
123
124
  type: 'danger',
124
125
  message: getFormLevelMessage(response),
125
126
  })
126
- window.scrollTo({ top: 0, behavior: 'smooth' })
127
127
  }
128
128
 
129
129
  return { success: false, data: null, error }
@@ -162,7 +162,7 @@
162
162
  </div>
163
163
  </div>
164
164
 
165
- <main class="mr-[10px] pb-[10px] flex-1 flex flex-col">
165
+ <main class="mr-[10px] pb-[10px] flex-1 min-h-0 flex flex-col">
166
166
  <router-view />
167
167
  </main>
168
168
  </div>