@iankibetsh/sh-tailwind 0.1.1 → 0.1.3
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/README.md +36 -314
- package/dist/sh-tailwind.cjs.js +1 -1
- package/dist/sh-tailwind.es.js +723 -492
- package/documentation/actions.md +26 -0
- package/documentation/forms.md +96 -0
- package/documentation/getting-started.md +62 -0
- package/documentation/inputs.md +55 -0
- package/documentation/overlays.md +42 -0
- package/documentation/table.md +98 -0
- package/documentation/tabs.md +138 -0
- package/documentation/theming.md +42 -0
- package/package.json +3 -2
- package/src/components/navigation/ShTabs.vue +246 -0
- package/src/index.js +3 -0
- package/src/theme/defaultTheme.js +20 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, getCurrentInstance, onMounted, ref, resolveComponent, watch } from 'vue'
|
|
3
|
+
import { shApis, useUserStore } from '@iankibetsh/sh-core'
|
|
4
|
+
import { useTheme } from '../../theme/useTheme.js'
|
|
5
|
+
import { startCase } from '../../utils/strings.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A single, unified tabs component that replaces shframework's ShTabs +
|
|
9
|
+
* ShDynamicTabs. It supports three content strategies and degrades gracefully
|
|
10
|
+
* when vue-router is not installed.
|
|
11
|
+
*
|
|
12
|
+
* - slots: define `#tab-<key>` named slots (or a default slot) and ShTabs
|
|
13
|
+
* owns the active state. Most flexible.
|
|
14
|
+
* - component: give a tab a `component` and it is rendered/swapped in place.
|
|
15
|
+
* - router: set `router` (or a `baseUrl`) and the active tab is driven by
|
|
16
|
+
* the route (`/{base}/tab/{key}`) with a nested <router-view>.
|
|
17
|
+
*
|
|
18
|
+
* A tab is a string, an object, or a function (called with `data`):
|
|
19
|
+
* 'pending'
|
|
20
|
+
* { key, label, component, icon, count, badge, permission, validator, disabled }
|
|
21
|
+
*/
|
|
22
|
+
const props = defineProps({
|
|
23
|
+
tabs: { type: Array, required: true },
|
|
24
|
+
// active tab key — supports v-model:tab. Null = uncontrolled (ShTabs owns it).
|
|
25
|
+
modelValue: { type: String, default: null },
|
|
26
|
+
// passed to tab functions / validators and bound to rendered content
|
|
27
|
+
data: { type: Object, default: () => ({}) },
|
|
28
|
+
// counts shown as a bubble: object map { key: n } or an API endpoint string
|
|
29
|
+
counts: { type: [Object, String], default: null },
|
|
30
|
+
variant: { type: String, default: 'underline' }, // underline | pills | boxed
|
|
31
|
+
// URL sync for inline modes: 'none' | 'query'
|
|
32
|
+
sync: { type: String, default: 'none' },
|
|
33
|
+
queryKey: { type: String, default: 'tab' },
|
|
34
|
+
// router mode: render a nested <router-view> instead of inline content
|
|
35
|
+
router: { type: Boolean, default: false },
|
|
36
|
+
baseUrl: { type: String, default: null },
|
|
37
|
+
// only mount a panel the first time it becomes active
|
|
38
|
+
lazy: { type: Boolean, default: false },
|
|
39
|
+
emptyMessage: { type: String, default: 'No tabs available' },
|
|
40
|
+
forbiddenMessage: { type: String, default: '403 — not allowed' },
|
|
41
|
+
classes: { type: Object, default: null }
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const emit = defineEmits(['update:modelValue', 'change'])
|
|
45
|
+
|
|
46
|
+
defineOptions({ inheritAttrs: false })
|
|
47
|
+
|
|
48
|
+
const base = useTheme('tabs', computed(() => props.classes))
|
|
49
|
+
const t = computed(() => {
|
|
50
|
+
const variant = base.value[props.variant]
|
|
51
|
+
return variant ? { ...base.value, ...variant } : base.value
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const userStore = useUserStore()
|
|
55
|
+
|
|
56
|
+
// --- optional router -------------------------------------------------------
|
|
57
|
+
const $router = getCurrentInstance()?.appContext.config.globalProperties.$router ?? null
|
|
58
|
+
const routerMode = computed(() => !!(props.router || props.baseUrl) && !!$router)
|
|
59
|
+
const route = computed(() => $router?.currentRoute.value ?? null)
|
|
60
|
+
const RouterLink = $router ? resolveComponent('RouterLink') : null
|
|
61
|
+
const RouterView = $router ? resolveComponent('RouterView') : null
|
|
62
|
+
|
|
63
|
+
// --- normalise + filter tabs ----------------------------------------------
|
|
64
|
+
const slugify = (value) => String(value).trim().replace(/\s+/g, '_').toLowerCase()
|
|
65
|
+
|
|
66
|
+
const tabList = computed(() =>
|
|
67
|
+
(props.tabs ?? [])
|
|
68
|
+
.map(raw => {
|
|
69
|
+
const tab = typeof raw === 'string'
|
|
70
|
+
? { key: raw }
|
|
71
|
+
: typeof raw === 'function'
|
|
72
|
+
? { ...raw(props.data) }
|
|
73
|
+
: { ...raw }
|
|
74
|
+
tab.key = tab.key ?? tab.name ?? (tab.label ? slugify(tab.label) : '')
|
|
75
|
+
tab.label = tab.label ?? startCase(tab.key)
|
|
76
|
+
return tab
|
|
77
|
+
})
|
|
78
|
+
.filter(tab => {
|
|
79
|
+
if (tab.validator && !tab.validator(props.data)) return false
|
|
80
|
+
if (tab.permission && !userStore.isAllowedTo(tab.permission)) return false
|
|
81
|
+
return true
|
|
82
|
+
})
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
const hidden = computed(() => props.tabs.length > 0 && tabList.value.length === 0)
|
|
86
|
+
|
|
87
|
+
// --- counts ----------------------------------------------------------------
|
|
88
|
+
const fetchedCounts = ref({})
|
|
89
|
+
const countFor = (tab) => {
|
|
90
|
+
const value = fetchedCounts.value[tab.key]
|
|
91
|
+
?? (props.counts && typeof props.counts === 'object' ? props.counts[tab.key] : undefined)
|
|
92
|
+
?? tab.count ?? tab.counts ?? tab.badge
|
|
93
|
+
return value === 0 || value ? value : null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- active state (single source of truth) ---------------------------------
|
|
97
|
+
const activeKey = ref(null)
|
|
98
|
+
const activeTab = computed(() => tabList.value.find(tab => tab.key === activeKey.value) ?? null)
|
|
99
|
+
|
|
100
|
+
const mountedKeys = ref([])
|
|
101
|
+
const isMounted = (tab) => !props.lazy || mountedKeys.value.includes(tab.key)
|
|
102
|
+
const markMounted = (key) => { if (key && !mountedKeys.value.includes(key)) mountedKeys.value.push(key) }
|
|
103
|
+
|
|
104
|
+
const select = (tab) => {
|
|
105
|
+
if (!tab || tab.disabled || tab.key === activeKey.value) return
|
|
106
|
+
activeKey.value = tab.key
|
|
107
|
+
markMounted(tab.key)
|
|
108
|
+
emit('update:modelValue', tab.key)
|
|
109
|
+
emit('change', tab.key, tab)
|
|
110
|
+
if (props.sync === 'query' && $router && route.value) {
|
|
111
|
+
$router.replace({ query: { ...route.value.query, [props.queryKey]: tab.key } })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// keep internal state in sync with external v-model changes
|
|
116
|
+
watch(() => props.modelValue, (value) => {
|
|
117
|
+
if (value != null && value !== activeKey.value && tabList.value.some(tab => tab.key === value)) {
|
|
118
|
+
activeKey.value = value
|
|
119
|
+
markMounted(value)
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// --- router mode helpers ---------------------------------------------------
|
|
124
|
+
const cleanBase = computed(() =>
|
|
125
|
+
(props.baseUrl ?? route.value?.path ?? '').replace(/\/tab\/[^/]+\/?$/, '')
|
|
126
|
+
)
|
|
127
|
+
const tabLink = (tab) => `${cleanBase.value}/tab/${tab.key}`
|
|
128
|
+
const routerActiveKey = computed(() => {
|
|
129
|
+
const path = route.value?.path ?? ''
|
|
130
|
+
return tabList.value.find(tab => path.includes(`/tab/${tab.key}`))?.key ?? null
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// --- init ------------------------------------------------------------------
|
|
134
|
+
onMounted(() => {
|
|
135
|
+
if (typeof props.counts === 'string') {
|
|
136
|
+
shApis.doGet(props.counts).then(res => { fetchedCounts.value = { ...res.data } }).catch(() => {})
|
|
137
|
+
}
|
|
138
|
+
if (tabList.value.length === 0) return
|
|
139
|
+
|
|
140
|
+
if (routerMode.value) {
|
|
141
|
+
if (!routerActiveKey.value) $router.replace(tabLink(tabList.value[0]))
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let initial = props.modelValue
|
|
146
|
+
if (!initial && props.sync === 'query') initial = route.value?.query[props.queryKey]
|
|
147
|
+
if (!tabList.value.some(tab => tab.key === initial)) initial = tabList.value[0].key
|
|
148
|
+
|
|
149
|
+
activeKey.value = initial
|
|
150
|
+
markMounted(initial)
|
|
151
|
+
if (props.modelValue == null) emit('update:modelValue', initial)
|
|
152
|
+
if (props.sync === 'query' && $router && route.value?.query[props.queryKey] !== initial) {
|
|
153
|
+
$router.replace({ query: { ...route.value.query, [props.queryKey]: initial } })
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
// --- keyboard navigation (roving tabindex) ---------------------------------
|
|
158
|
+
const tabRefs = ref([])
|
|
159
|
+
const focusTab = (index) => tabRefs.value[index]?.focus()
|
|
160
|
+
const enabledStep = (from, dir) => {
|
|
161
|
+
const n = tabList.value.length
|
|
162
|
+
for (let step = 1; step <= n; step++) {
|
|
163
|
+
const index = (from + dir * step % n + n) % n
|
|
164
|
+
if (!tabList.value[index].disabled) return index
|
|
165
|
+
}
|
|
166
|
+
return from
|
|
167
|
+
}
|
|
168
|
+
const onKeydown = (event, index) => {
|
|
169
|
+
const map = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }
|
|
170
|
+
let next
|
|
171
|
+
if (event.key in map) next = enabledStep(index, map[event.key])
|
|
172
|
+
else if (event.key === 'Home') next = tabList.value.findIndex(tab => !tab.disabled)
|
|
173
|
+
else if (event.key === 'End') next = tabList.value.length - 1 - [...tabList.value].reverse().findIndex(tab => !tab.disabled)
|
|
174
|
+
else return
|
|
175
|
+
event.preventDefault()
|
|
176
|
+
select(tabList.value[next])
|
|
177
|
+
focusTab(next)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const isActive = (tab) => (routerMode.value ? routerActiveKey.value : activeKey.value) === tab.key
|
|
181
|
+
|
|
182
|
+
defineExpose({ active: activeKey, select })
|
|
183
|
+
</script>
|
|
184
|
+
|
|
185
|
+
<template>
|
|
186
|
+
<div v-if="hidden" :class="t.empty">{{ forbiddenMessage }}</div>
|
|
187
|
+
<div v-else-if="!tabList.length" :class="t.empty">{{ emptyMessage }}</div>
|
|
188
|
+
|
|
189
|
+
<div v-else>
|
|
190
|
+
<div :class="t.nav" role="tablist">
|
|
191
|
+
<template v-for="(tab, index) in tabList" :key="tab.key">
|
|
192
|
+
<!-- router mode: real links so tabs are deep-linkable & SEO friendly -->
|
|
193
|
+
<component
|
|
194
|
+
:is="RouterLink"
|
|
195
|
+
v-if="routerMode"
|
|
196
|
+
:ref="el => (tabRefs[index] = el?.$el ?? el)"
|
|
197
|
+
:to="tabLink(tab)"
|
|
198
|
+
role="tab"
|
|
199
|
+
:aria-selected="isActive(tab)"
|
|
200
|
+
:tabindex="isActive(tab) ? 0 : -1"
|
|
201
|
+
:class="[isActive(tab) ? t.tabActive : t.tab, tab.class]"
|
|
202
|
+
@keydown="onKeydown($event, index)"
|
|
203
|
+
>
|
|
204
|
+
<component :is="tab.icon" v-if="tab.icon" :class="t.icon" />
|
|
205
|
+
{{ tab.label }}
|
|
206
|
+
<span v-if="countFor(tab) != null" :class="isActive(tab) ? t.countActive : t.count">{{ countFor(tab) }}</span>
|
|
207
|
+
</component>
|
|
208
|
+
|
|
209
|
+
<!-- inline mode: buttons drive local state -->
|
|
210
|
+
<button
|
|
211
|
+
v-else
|
|
212
|
+
:ref="el => (tabRefs[index] = el)"
|
|
213
|
+
type="button"
|
|
214
|
+
role="tab"
|
|
215
|
+
:aria-selected="isActive(tab)"
|
|
216
|
+
:tabindex="isActive(tab) ? 0 : -1"
|
|
217
|
+
:disabled="tab.disabled"
|
|
218
|
+
:class="[isActive(tab) ? t.tabActive : t.tab, tab.class]"
|
|
219
|
+
@click="select(tab)"
|
|
220
|
+
@keydown="onKeydown($event, index)"
|
|
221
|
+
>
|
|
222
|
+
<component :is="tab.icon" v-if="tab.icon" :class="t.icon" />
|
|
223
|
+
{{ tab.label }}
|
|
224
|
+
<span v-if="countFor(tab) != null" :class="isActive(tab) ? t.countActive : t.count">{{ countFor(tab) }}</span>
|
|
225
|
+
</button>
|
|
226
|
+
</template>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
<!-- router mode: nested route renders the panel -->
|
|
230
|
+
<div v-if="routerMode" :class="t.panel" role="tabpanel">
|
|
231
|
+
<component :is="RouterView" v-bind="$attrs" :data="data" />
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<!-- inline mode: component / slot content, kept alive via v-show -->
|
|
235
|
+
<div v-else :class="t.panel" role="tabpanel" tabindex="0">
|
|
236
|
+
<template v-for="tab in tabList" :key="tab.key">
|
|
237
|
+
<div v-if="isMounted(tab)" v-show="tab.key === activeKey">
|
|
238
|
+
<component :is="tab.component" v-if="tab.component" v-bind="$attrs" :data="data" />
|
|
239
|
+
<slot v-else :name="`tab-${tab.key}`" :tab="tab" :active="tab.key === activeKey">
|
|
240
|
+
<slot :tab="tab" :active="tab.key === activeKey" />
|
|
241
|
+
</slot>
|
|
242
|
+
</div>
|
|
243
|
+
</template>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
</template>
|
package/src/index.js
CHANGED
|
@@ -25,6 +25,9 @@ export { useTableData } from './table/useTableData.js'
|
|
|
25
25
|
export { localQuery } from './table/localQuery.js'
|
|
26
26
|
export { default as shTableCache, clearTableCache } from './table/tableCache.js'
|
|
27
27
|
|
|
28
|
+
// Navigation
|
|
29
|
+
export { default as ShTabs } from './components/navigation/ShTabs.vue'
|
|
30
|
+
|
|
28
31
|
// Actions
|
|
29
32
|
export { default as ShConfirmAction } from './components/actions/ShConfirmAction.vue'
|
|
30
33
|
export { default as ShSilentAction } from './components/actions/ShSilentAction.vue'
|
|
@@ -145,6 +145,26 @@ export const defaultTheme = {
|
|
|
145
145
|
multiCount: 'inline-flex items-center justify-center rounded-full bg-blue-600 px-2 py-0.5 text-xs font-semibold text-white',
|
|
146
146
|
multiBtn: 'inline-flex items-center justify-center gap-1 rounded-md border border-blue-200 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-50'
|
|
147
147
|
},
|
|
148
|
+
tabs: {
|
|
149
|
+
nav: 'flex flex-wrap items-center gap-1 border-b border-gray-200',
|
|
150
|
+
tab: 'group relative -mb-px inline-flex items-center gap-2 border-b-2 border-transparent px-3 py-2 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:pointer-events-none disabled:opacity-40',
|
|
151
|
+
tabActive: 'group relative -mb-px inline-flex items-center gap-2 border-b-2 border-blue-600 px-3 py-2 text-sm font-medium text-blue-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40',
|
|
152
|
+
icon: 'size-4 shrink-0',
|
|
153
|
+
count: 'inline-flex min-w-5 items-center justify-center rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-semibold text-gray-600',
|
|
154
|
+
countActive: 'inline-flex min-w-5 items-center justify-center rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-semibold text-blue-700',
|
|
155
|
+
panel: 'pt-4 focus:outline-none',
|
|
156
|
+
empty: 'rounded-md bg-amber-50 px-4 py-3 text-sm text-amber-700',
|
|
157
|
+
pills: {
|
|
158
|
+
nav: 'flex flex-wrap items-center gap-1.5',
|
|
159
|
+
tab: 'inline-flex items-center gap-2 rounded-full px-3.5 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:pointer-events-none disabled:opacity-40',
|
|
160
|
+
tabActive: 'inline-flex items-center gap-2 rounded-full bg-blue-600 px-3.5 py-1.5 text-sm font-medium text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40'
|
|
161
|
+
},
|
|
162
|
+
boxed: {
|
|
163
|
+
nav: 'flex flex-wrap items-center gap-1 border-b border-gray-200',
|
|
164
|
+
tab: 'inline-flex items-center gap-2 rounded-t-lg border border-transparent px-3.5 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 hover:text-gray-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:pointer-events-none disabled:opacity-40',
|
|
165
|
+
tabActive: 'inline-flex items-center gap-2 -mb-px rounded-t-lg border border-gray-200 border-b-white bg-white px-3.5 py-2 text-sm font-medium text-blue-600 focus:outline-none'
|
|
166
|
+
}
|
|
167
|
+
},
|
|
148
168
|
buttons: {
|
|
149
169
|
primary: 'inline-flex items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500/40 disabled:opacity-60',
|
|
150
170
|
secondary: 'inline-flex items-center justify-center gap-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none',
|