@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.
@@ -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',