@wishbone-media/spark 0.51.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.51.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>
@@ -1,6 +1,8 @@
1
1
  <template>
2
2
  <TransitionRoot as="template" :show="sparkModalService.state.isVisible">
3
- <Dialog class="relative z-1000" @close="sparkModalService.hide">
3
+ <!-- Headless UI emits close with a payload (false); call hide() without it
4
+ so a backdrop/ESC dismiss resolves the show() promise with null -->
5
+ <Dialog class="relative z-1000" @close="() => sparkModalService.hide()">
4
6
  <TransitionChild
5
7
  as="template"
6
8
  enter="ease-out duration-300"
@@ -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']
@@ -9,18 +9,40 @@ class SparkModalService {
9
9
  props: {},
10
10
  eventHandlers: {},
11
11
  })
12
+ this._resolveShow = null
12
13
  }
13
14
 
15
+ /**
16
+ * Show a modal with a custom component.
17
+ *
18
+ * @param {Object} component - Vue component to render inside the modal
19
+ * @param {Object} [props] - Props passed to the component
20
+ * @param {Object} [eventHandlers] - Event handlers bound to the component
21
+ * @returns {Promise<any>} - Resolves when the modal closes: with the value
22
+ * passed to hide(result), or null on a plain hide() / backdrop / ESC dismiss
23
+ */
14
24
  show = (component, props = {}, eventHandlers = {}) => {
25
+ this._resolveShow?.(null) // a new modal supersedes a still-pending one
15
26
  this.state.content = markRaw(component)
16
27
  this.state.props = props
17
28
  this.state.eventHandlers = eventHandlers
18
29
  this.state.isVisible = true
30
+ return new Promise((resolve) => {
31
+ this._resolveShow = resolve
32
+ })
19
33
  }
20
34
 
21
- hide = () => {
35
+ /**
36
+ * Close the current modal, resolving the pending show() promise.
37
+ *
38
+ * @param {any} [result=null] - Value the show() promise resolves with
39
+ */
40
+ hide = (result = null) => {
22
41
  this.state.isVisible = false
23
42
  this.state.eventHandlers = {}
43
+ const resolve = this._resolveShow
44
+ this._resolveShow = null
45
+ resolve?.(result)
24
46
  }
25
47
 
26
48
  /**
@@ -33,42 +55,37 @@ class SparkModalService {
33
55
  * @param {string} [options.confirmText='Confirm'] - Confirm button text
34
56
  * @param {string} [options.cancelText='Cancel'] - Cancel button text
35
57
  * @param {string} [options.confirmVariant='primary'] - Confirm button variant
36
- * @returns {Promise<boolean>} - Resolves to true if confirmed, false if cancelled
58
+ * @returns {Promise<boolean>} - Resolves to true if confirmed, false if
59
+ * cancelled or dismissed (backdrop / ESC)
37
60
  */
38
- confirm = (options = {}) => {
39
- return new Promise((resolve) => {
40
- const {
41
- title = 'Confirm',
42
- message = 'Are you sure?',
43
- type = 'warning',
44
- confirmText = 'Confirm',
45
- cancelText = 'Cancel',
46
- confirmVariant = 'primary',
47
- } = options
61
+ confirm = async (options = {}) => {
62
+ const {
63
+ title = 'Confirm',
64
+ message = 'Are you sure?',
65
+ type = 'warning',
66
+ confirmText = 'Confirm',
67
+ cancelText = 'Cancel',
68
+ confirmVariant = 'primary',
69
+ } = options
48
70
 
49
- this.show(
50
- SparkModalDialog,
51
- {
52
- title,
53
- message,
54
- type,
55
- buttons: [
56
- { text: confirmText, variant: confirmVariant, event: 'confirm' },
57
- { text: cancelText, variant: 'secondary', event: 'cancel' },
58
- ],
59
- },
60
- {
61
- confirm: () => {
62
- this.hide()
63
- resolve(true)
64
- },
65
- cancel: () => {
66
- this.hide()
67
- resolve(false)
68
- },
69
- },
70
- )
71
- })
71
+ const result = await this.show(
72
+ SparkModalDialog,
73
+ {
74
+ title,
75
+ message,
76
+ type,
77
+ buttons: [
78
+ { text: confirmText, variant: confirmVariant, event: 'confirm' },
79
+ { text: cancelText, variant: 'secondary', event: 'cancel' },
80
+ ],
81
+ },
82
+ {
83
+ confirm: () => this.hide(true),
84
+ cancel: () => this.hide(false),
85
+ },
86
+ )
87
+
88
+ return Boolean(result)
72
89
  }
73
90
  }
74
91
 
@@ -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>