@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/dist/index.js +2833 -2728
- package/package.json +1 -1
- package/src/components/SparkCard.vue +111 -6
- package/src/components/SparkNotificationOutlet.vue +14 -1
- package/src/components/SparkSubNav.vue +40 -11
- package/src/composables/useFormSubmission.js +2 -2
- package/src/containers/SparkDefaultContainer.vue +1 -1
package/package.json
CHANGED
|
@@ -1,33 +1,92 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div
|
|
3
|
-
class="flex flex-col
|
|
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
|
|
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
|
|
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="
|
|
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="
|
|
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) &&
|
|
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) &&
|
|
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
|
-
* - '
|
|
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) =>
|
|
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-
|
|
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[
|
|
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 (
|
|
210
|
+
if (layout.value === 'tabs') {
|
|
201
211
|
base.push(...getTabsClasses(isActive, isDisabled))
|
|
202
|
-
} else if (
|
|
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 }
|