@htlkg/components 0.0.1
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/composables/index.js +388 -0
- package/dist/composables/index.js.map +1 -0
- package/package.json +41 -0
- package/src/composables/index.ts +6 -0
- package/src/composables/useForm.test.ts +229 -0
- package/src/composables/useForm.ts +130 -0
- package/src/composables/useFormValidation.test.ts +189 -0
- package/src/composables/useFormValidation.ts +83 -0
- package/src/composables/useModal.property.test.ts +164 -0
- package/src/composables/useModal.ts +43 -0
- package/src/composables/useNotifications.test.ts +166 -0
- package/src/composables/useNotifications.ts +81 -0
- package/src/composables/useTable.property.test.ts +198 -0
- package/src/composables/useTable.ts +134 -0
- package/src/composables/useTabs.property.test.ts +247 -0
- package/src/composables/useTabs.ts +101 -0
- package/src/data/Chart.demo.vue +340 -0
- package/src/data/Chart.md +525 -0
- package/src/data/Chart.vue +133 -0
- package/src/data/DataList.md +80 -0
- package/src/data/DataList.test.ts +69 -0
- package/src/data/DataList.vue +46 -0
- package/src/data/SearchableSelect.md +107 -0
- package/src/data/SearchableSelect.vue +124 -0
- package/src/data/Table.demo.vue +296 -0
- package/src/data/Table.md +588 -0
- package/src/data/Table.property.test.ts +548 -0
- package/src/data/Table.test.ts +562 -0
- package/src/data/Table.unit.test.ts +544 -0
- package/src/data/Table.vue +321 -0
- package/src/data/index.ts +5 -0
- package/src/domain/BrandCard.md +81 -0
- package/src/domain/BrandCard.vue +63 -0
- package/src/domain/BrandSelector.md +84 -0
- package/src/domain/BrandSelector.vue +65 -0
- package/src/domain/ProductBadge.md +60 -0
- package/src/domain/ProductBadge.vue +47 -0
- package/src/domain/UserAvatar.md +84 -0
- package/src/domain/UserAvatar.vue +60 -0
- package/src/domain/domain-components.property.test.ts +449 -0
- package/src/domain/index.ts +4 -0
- package/src/forms/DateRange.demo.vue +273 -0
- package/src/forms/DateRange.md +337 -0
- package/src/forms/DateRange.vue +110 -0
- package/src/forms/JsonSchemaForm.demo.vue +549 -0
- package/src/forms/JsonSchemaForm.md +112 -0
- package/src/forms/JsonSchemaForm.property.test.ts +817 -0
- package/src/forms/JsonSchemaForm.test.ts +601 -0
- package/src/forms/JsonSchemaForm.unit.test.ts +801 -0
- package/src/forms/JsonSchemaForm.vue +615 -0
- package/src/forms/index.ts +3 -0
- package/src/index.ts +17 -0
- package/src/navigation/Breadcrumbs.demo.vue +142 -0
- package/src/navigation/Breadcrumbs.md +102 -0
- package/src/navigation/Breadcrumbs.test.ts +69 -0
- package/src/navigation/Breadcrumbs.vue +58 -0
- package/src/navigation/Stepper.demo.vue +337 -0
- package/src/navigation/Stepper.md +174 -0
- package/src/navigation/Stepper.vue +146 -0
- package/src/navigation/Tabs.demo.vue +293 -0
- package/src/navigation/Tabs.md +163 -0
- package/src/navigation/Tabs.test.ts +176 -0
- package/src/navigation/Tabs.vue +104 -0
- package/src/navigation/index.ts +5 -0
- package/src/overlays/Alert.demo.vue +377 -0
- package/src/overlays/Alert.md +248 -0
- package/src/overlays/Alert.test.ts +166 -0
- package/src/overlays/Alert.vue +70 -0
- package/src/overlays/Drawer.md +140 -0
- package/src/overlays/Drawer.test.ts +92 -0
- package/src/overlays/Drawer.vue +76 -0
- package/src/overlays/Modal.demo.vue +149 -0
- package/src/overlays/Modal.md +385 -0
- package/src/overlays/Modal.test.ts +128 -0
- package/src/overlays/Modal.vue +86 -0
- package/src/overlays/Notification.md +150 -0
- package/src/overlays/Notification.test.ts +96 -0
- package/src/overlays/Notification.vue +58 -0
- package/src/overlays/index.ts +4 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { ref, computed, type Ref, type ComputedRef } from 'vue';
|
|
2
|
+
|
|
3
|
+
export interface UseTableOptions<T> {
|
|
4
|
+
items: T[];
|
|
5
|
+
pageSize?: number;
|
|
6
|
+
sortKey?: string;
|
|
7
|
+
sortOrder?: 'asc' | 'desc';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface UseTableReturn<T> {
|
|
11
|
+
currentPage: Ref<number>;
|
|
12
|
+
pageSize: Ref<number>;
|
|
13
|
+
sortKey: Ref<string>;
|
|
14
|
+
sortOrder: Ref<'asc' | 'desc'>;
|
|
15
|
+
selectedItems: Ref<T[]>;
|
|
16
|
+
paginatedItems: ComputedRef<T[]>;
|
|
17
|
+
totalPages: ComputedRef<number>;
|
|
18
|
+
sortedItems: ComputedRef<T[]>;
|
|
19
|
+
setPage: (page: number) => void;
|
|
20
|
+
setPageSize: (size: number) => void;
|
|
21
|
+
setSorting: (key: string, order?: 'asc' | 'desc') => void;
|
|
22
|
+
selectItem: (item: T) => void;
|
|
23
|
+
deselectItem: (item: T) => void;
|
|
24
|
+
selectAll: () => void;
|
|
25
|
+
clearSelection: () => void;
|
|
26
|
+
isSelected: (item: T) => boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useTable<T extends Record<string, any>>(
|
|
30
|
+
options: UseTableOptions<T>
|
|
31
|
+
): UseTableReturn<T> {
|
|
32
|
+
const currentPage = ref(1);
|
|
33
|
+
const pageSize = ref(options.pageSize ?? 10);
|
|
34
|
+
const sortKey = ref(options.sortKey ?? '');
|
|
35
|
+
const sortOrder = ref<'asc' | 'desc'>(options.sortOrder ?? 'asc');
|
|
36
|
+
const selectedItems = ref<T[]>([]);
|
|
37
|
+
|
|
38
|
+
const sortedItems = computed(() => {
|
|
39
|
+
if (!sortKey.value) return options.items;
|
|
40
|
+
|
|
41
|
+
return [...options.items].sort((a, b) => {
|
|
42
|
+
const aVal = a[sortKey.value];
|
|
43
|
+
const bVal = b[sortKey.value];
|
|
44
|
+
const order = sortOrder.value === 'asc' ? 1 : -1;
|
|
45
|
+
|
|
46
|
+
if (aVal === bVal) return 0;
|
|
47
|
+
if (aVal == null) return 1;
|
|
48
|
+
if (bVal == null) return -1;
|
|
49
|
+
|
|
50
|
+
return aVal > bVal ? order : -order;
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const paginatedItems = computed(() => {
|
|
55
|
+
const start = (currentPage.value - 1) * pageSize.value;
|
|
56
|
+
const end = start + pageSize.value;
|
|
57
|
+
return sortedItems.value.slice(start, end);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const totalPages = computed(() =>
|
|
61
|
+
Math.ceil(options.items.length / pageSize.value)
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
function setPage(page: number) {
|
|
65
|
+
if (page >= 1 && page <= totalPages.value) {
|
|
66
|
+
currentPage.value = page;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function setPageSize(size: number) {
|
|
71
|
+
pageSize.value = size;
|
|
72
|
+
currentPage.value = 1; // Reset to first page
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function setSorting(key: string, order: 'asc' | 'desc' = 'asc') {
|
|
76
|
+
if (sortKey.value === key) {
|
|
77
|
+
// Toggle order if same key
|
|
78
|
+
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
|
|
79
|
+
} else {
|
|
80
|
+
sortKey.value = key;
|
|
81
|
+
sortOrder.value = order;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function selectItem(item: T) {
|
|
86
|
+
if (!isSelected(item)) {
|
|
87
|
+
selectedItems.value.push(item);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function deselectItem(item: T) {
|
|
92
|
+
// Use deep comparison for objects
|
|
93
|
+
const index = selectedItems.value.findIndex(i =>
|
|
94
|
+
JSON.stringify(i) === JSON.stringify(item)
|
|
95
|
+
);
|
|
96
|
+
if (index !== -1) {
|
|
97
|
+
selectedItems.value.splice(index, 1);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function selectAll() {
|
|
102
|
+
selectedItems.value = [...options.items];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function clearSelection() {
|
|
106
|
+
selectedItems.value = [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isSelected(item: T): boolean {
|
|
110
|
+
// Use deep comparison for objects
|
|
111
|
+
return selectedItems.value.some(i =>
|
|
112
|
+
JSON.stringify(i) === JSON.stringify(item)
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
currentPage,
|
|
118
|
+
pageSize,
|
|
119
|
+
sortKey,
|
|
120
|
+
sortOrder,
|
|
121
|
+
selectedItems,
|
|
122
|
+
paginatedItems,
|
|
123
|
+
totalPages,
|
|
124
|
+
sortedItems,
|
|
125
|
+
setPage,
|
|
126
|
+
setPageSize,
|
|
127
|
+
setSorting,
|
|
128
|
+
selectItem,
|
|
129
|
+
deselectItem,
|
|
130
|
+
selectAll,
|
|
131
|
+
clearSelection,
|
|
132
|
+
isSelected
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import * as fc from 'fast-check';
|
|
3
|
+
import { useTabs, type Tab } from './useTabs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Feature: htlkg-modular-architecture, Property 9: Components support v-model
|
|
7
|
+
* Validates: Requirements 12.3
|
|
8
|
+
*/
|
|
9
|
+
describe('useTabs property tests', () => {
|
|
10
|
+
it('should manage active tab state reactively', () => {
|
|
11
|
+
fc.assert(
|
|
12
|
+
fc.property(
|
|
13
|
+
fc.array(
|
|
14
|
+
fc.record({
|
|
15
|
+
id: fc.string({ minLength: 1 }),
|
|
16
|
+
label: fc.string(),
|
|
17
|
+
disabled: fc.boolean()
|
|
18
|
+
}),
|
|
19
|
+
{ minLength: 1, maxLength: 10 }
|
|
20
|
+
),
|
|
21
|
+
(tabs) => {
|
|
22
|
+
// Ensure unique IDs
|
|
23
|
+
const uniqueTabs = tabs.map((tab, index) => ({
|
|
24
|
+
...tab,
|
|
25
|
+
id: `tab-${index}`
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const { activeTab, setActiveTab, isTabActive } = useTabs({
|
|
29
|
+
tabs: uniqueTabs
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Property: Initial active tab should be the first tab
|
|
33
|
+
expect(activeTab.value).toBe(uniqueTabs[0].id);
|
|
34
|
+
expect(isTabActive(uniqueTabs[0].id)).toBe(true);
|
|
35
|
+
|
|
36
|
+
// Property: Setting active tab should update state
|
|
37
|
+
if (uniqueTabs.length > 1) {
|
|
38
|
+
const targetTab = uniqueTabs[1];
|
|
39
|
+
if (!targetTab.disabled) {
|
|
40
|
+
setActiveTab(targetTab.id);
|
|
41
|
+
expect(activeTab.value).toBe(targetTab.id);
|
|
42
|
+
expect(isTabActive(targetTab.id)).toBe(true);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
),
|
|
47
|
+
{ numRuns: 100 }
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should navigate between tabs correctly', () => {
|
|
52
|
+
fc.assert(
|
|
53
|
+
fc.property(
|
|
54
|
+
fc.integer({ min: 2, max: 10 }),
|
|
55
|
+
(tabCount) => {
|
|
56
|
+
const tabs: Tab[] = Array.from({ length: tabCount }, (_, i) => ({
|
|
57
|
+
id: `tab-${i}`,
|
|
58
|
+
label: `Tab ${i}`,
|
|
59
|
+
disabled: false
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
const { activeTab, nextTab, previousTab, isFirstTab, isLastTab } = useTabs({
|
|
63
|
+
tabs
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Property: Initially on first tab
|
|
67
|
+
expect(activeTab.value).toBe(tabs[0].id);
|
|
68
|
+
expect(isFirstTab.value).toBe(true);
|
|
69
|
+
expect(isLastTab.value).toBe(false);
|
|
70
|
+
|
|
71
|
+
// Property: nextTab should move to next tab
|
|
72
|
+
nextTab();
|
|
73
|
+
expect(activeTab.value).toBe(tabs[1].id);
|
|
74
|
+
expect(isFirstTab.value).toBe(false);
|
|
75
|
+
|
|
76
|
+
// Property: previousTab should move back
|
|
77
|
+
previousTab();
|
|
78
|
+
expect(activeTab.value).toBe(tabs[0].id);
|
|
79
|
+
expect(isFirstTab.value).toBe(true);
|
|
80
|
+
|
|
81
|
+
// Property: Navigate to last tab
|
|
82
|
+
for (let i = 0; i < tabCount - 1; i++) {
|
|
83
|
+
nextTab();
|
|
84
|
+
}
|
|
85
|
+
expect(activeTab.value).toBe(tabs[tabCount - 1].id);
|
|
86
|
+
expect(isLastTab.value).toBe(true);
|
|
87
|
+
|
|
88
|
+
// Property: nextTab on last tab should not change state
|
|
89
|
+
const lastTabId = activeTab.value;
|
|
90
|
+
nextTab();
|
|
91
|
+
expect(activeTab.value).toBe(lastTabId);
|
|
92
|
+
}
|
|
93
|
+
),
|
|
94
|
+
{ numRuns: 100 }
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should handle disabled tabs correctly', () => {
|
|
99
|
+
fc.assert(
|
|
100
|
+
fc.property(
|
|
101
|
+
fc.integer({ min: 3, max: 10 }),
|
|
102
|
+
fc.integer({ min: 1, max: 8 }),
|
|
103
|
+
(tabCount, disabledIndex) => {
|
|
104
|
+
const validDisabledIndex = Math.min(disabledIndex, tabCount - 1);
|
|
105
|
+
|
|
106
|
+
const tabs: Tab[] = Array.from({ length: tabCount }, (_, i) => ({
|
|
107
|
+
id: `tab-${i}`,
|
|
108
|
+
label: `Tab ${i}`,
|
|
109
|
+
disabled: i === validDisabledIndex
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
const { setActiveTab, activeTab, isTabDisabled } = useTabs({
|
|
113
|
+
tabs
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Property: Disabled tab should be marked as disabled
|
|
117
|
+
expect(isTabDisabled(tabs[validDisabledIndex].id)).toBe(true);
|
|
118
|
+
|
|
119
|
+
// Property: Setting active tab to disabled tab should not change state
|
|
120
|
+
const initialTab = activeTab.value;
|
|
121
|
+
setActiveTab(tabs[validDisabledIndex].id);
|
|
122
|
+
expect(activeTab.value).toBe(initialTab);
|
|
123
|
+
|
|
124
|
+
// Property: Setting active tab to enabled tab should work
|
|
125
|
+
const enabledTabIndex = validDisabledIndex === 0 ? 1 : 0;
|
|
126
|
+
setActiveTab(tabs[enabledTabIndex].id);
|
|
127
|
+
expect(activeTab.value).toBe(tabs[enabledTabIndex].id);
|
|
128
|
+
}
|
|
129
|
+
),
|
|
130
|
+
{ numRuns: 100 }
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should maintain tab index consistency', () => {
|
|
135
|
+
fc.assert(
|
|
136
|
+
fc.property(
|
|
137
|
+
fc.array(
|
|
138
|
+
fc.record({
|
|
139
|
+
id: fc.string({ minLength: 1 }),
|
|
140
|
+
label: fc.string(),
|
|
141
|
+
disabled: fc.boolean()
|
|
142
|
+
}),
|
|
143
|
+
{ minLength: 1, maxLength: 10 }
|
|
144
|
+
),
|
|
145
|
+
(tabs) => {
|
|
146
|
+
// Ensure unique IDs
|
|
147
|
+
const uniqueTabs = tabs.map((tab, index) => ({
|
|
148
|
+
...tab,
|
|
149
|
+
id: `tab-${index}`
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
const { activeTab, currentTabIndex, setActiveTab } = useTabs({
|
|
153
|
+
tabs: uniqueTabs
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Property: Current tab index should match active tab position
|
|
157
|
+
const expectedIndex = uniqueTabs.findIndex(t => t.id === activeTab.value);
|
|
158
|
+
expect(currentTabIndex.value).toBe(expectedIndex);
|
|
159
|
+
|
|
160
|
+
// Property: After setting active tab, index should update
|
|
161
|
+
if (uniqueTabs.length > 1) {
|
|
162
|
+
const targetIndex = uniqueTabs.length > 2 ? 2 : 1;
|
|
163
|
+
const targetTab = uniqueTabs[targetIndex];
|
|
164
|
+
if (!targetTab.disabled) {
|
|
165
|
+
setActiveTab(targetTab.id);
|
|
166
|
+
expect(currentTabIndex.value).toBe(targetIndex);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
),
|
|
171
|
+
{ numRuns: 100 }
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should support v-model pattern with reactive updates', () => {
|
|
176
|
+
fc.assert(
|
|
177
|
+
fc.property(
|
|
178
|
+
fc.integer({ min: 2, max: 10 }),
|
|
179
|
+
fc.integer({ min: 0, max: 9 }),
|
|
180
|
+
(tabCount, targetIndex) => {
|
|
181
|
+
const tabs: Tab[] = Array.from({ length: tabCount }, (_, i) => ({
|
|
182
|
+
id: `tab-${i}`,
|
|
183
|
+
label: `Tab ${i}`,
|
|
184
|
+
disabled: false
|
|
185
|
+
}));
|
|
186
|
+
|
|
187
|
+
const validTargetIndex = Math.min(targetIndex, tabCount - 1);
|
|
188
|
+
|
|
189
|
+
const { activeTab, setActiveTab, isTabActive } = useTabs({
|
|
190
|
+
tabs
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Property: Directly setting activeTab.value should update state (v-model pattern)
|
|
194
|
+
activeTab.value = tabs[validTargetIndex].id;
|
|
195
|
+
expect(activeTab.value).toBe(tabs[validTargetIndex].id);
|
|
196
|
+
expect(isTabActive(tabs[validTargetIndex].id)).toBe(true);
|
|
197
|
+
|
|
198
|
+
// Property: Methods should still work after direct state change
|
|
199
|
+
if (validTargetIndex > 0) {
|
|
200
|
+
setActiveTab(tabs[0].id);
|
|
201
|
+
expect(activeTab.value).toBe(tabs[0].id);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
),
|
|
205
|
+
{ numRuns: 100 }
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should handle onChange callback correctly', () => {
|
|
210
|
+
fc.assert(
|
|
211
|
+
fc.property(
|
|
212
|
+
fc.integer({ min: 3, max: 10 }),
|
|
213
|
+
(tabCount) => {
|
|
214
|
+
const tabs: Tab[] = Array.from({ length: tabCount }, (_, i) => ({
|
|
215
|
+
id: `tab-${i}`,
|
|
216
|
+
label: `Tab ${i}`,
|
|
217
|
+
disabled: false
|
|
218
|
+
}));
|
|
219
|
+
|
|
220
|
+
let changeCallCount = 0;
|
|
221
|
+
let lastChangedTab = '';
|
|
222
|
+
|
|
223
|
+
const { setActiveTab, nextTab } = useTabs({
|
|
224
|
+
tabs,
|
|
225
|
+
onChange: (tabId) => {
|
|
226
|
+
changeCallCount++;
|
|
227
|
+
lastChangedTab = tabId;
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const initialCallCount = changeCallCount;
|
|
232
|
+
|
|
233
|
+
// Property: setActiveTab should trigger onChange
|
|
234
|
+
setActiveTab(tabs[1].id);
|
|
235
|
+
expect(changeCallCount).toBe(initialCallCount + 1);
|
|
236
|
+
expect(lastChangedTab).toBe(tabs[1].id);
|
|
237
|
+
|
|
238
|
+
// Property: nextTab should trigger onChange (we're at tab 1, so can move to tab 2)
|
|
239
|
+
nextTab();
|
|
240
|
+
expect(changeCallCount).toBe(initialCallCount + 2);
|
|
241
|
+
expect(lastChangedTab).toBe(tabs[2].id);
|
|
242
|
+
}
|
|
243
|
+
),
|
|
244
|
+
{ numRuns: 100 }
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { ref, computed, type Ref, type ComputedRef } from 'vue';
|
|
2
|
+
|
|
3
|
+
export interface Tab {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface UseTabsOptions {
|
|
10
|
+
tabs: Tab[];
|
|
11
|
+
initialTab?: string;
|
|
12
|
+
onChange?: (tabId: string) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface UseTabsReturn {
|
|
16
|
+
activeTab: Ref<string>;
|
|
17
|
+
tabs: Ref<Tab[]>;
|
|
18
|
+
currentTabIndex: ComputedRef<number>;
|
|
19
|
+
isFirstTab: ComputedRef<boolean>;
|
|
20
|
+
isLastTab: ComputedRef<boolean>;
|
|
21
|
+
setActiveTab: (tabId: string) => void;
|
|
22
|
+
nextTab: () => void;
|
|
23
|
+
previousTab: () => void;
|
|
24
|
+
isTabActive: (tabId: string) => boolean;
|
|
25
|
+
isTabDisabled: (tabId: string) => boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useTabs(options: UseTabsOptions): UseTabsReturn {
|
|
29
|
+
const tabs = ref(options.tabs);
|
|
30
|
+
const activeTab = ref(
|
|
31
|
+
options.initialTab || (tabs.value.length > 0 ? tabs.value[0].id : '')
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const currentTabIndex = computed(() =>
|
|
35
|
+
tabs.value.findIndex(tab => tab.id === activeTab.value)
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const isFirstTab = computed(() => currentTabIndex.value === 0);
|
|
39
|
+
|
|
40
|
+
const isLastTab = computed(() =>
|
|
41
|
+
currentTabIndex.value === tabs.value.length - 1
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
function setActiveTab(tabId: string) {
|
|
45
|
+
const tab = tabs.value.find(t => t.id === tabId);
|
|
46
|
+
if (tab && !tab.disabled) {
|
|
47
|
+
activeTab.value = tabId;
|
|
48
|
+
options.onChange?.(tabId);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function nextTab() {
|
|
53
|
+
if (!isLastTab.value) {
|
|
54
|
+
const nextIndex = currentTabIndex.value + 1;
|
|
55
|
+
const nextTab = tabs.value[nextIndex];
|
|
56
|
+
if (nextTab && !nextTab.disabled) {
|
|
57
|
+
setActiveTab(nextTab.id);
|
|
58
|
+
} else if (nextIndex < tabs.value.length - 1) {
|
|
59
|
+
// Skip disabled tab and try next
|
|
60
|
+
activeTab.value = nextTab.id;
|
|
61
|
+
nextTab();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function previousTab() {
|
|
67
|
+
if (!isFirstTab.value) {
|
|
68
|
+
const prevIndex = currentTabIndex.value - 1;
|
|
69
|
+
const prevTab = tabs.value[prevIndex];
|
|
70
|
+
if (prevTab && !prevTab.disabled) {
|
|
71
|
+
setActiveTab(prevTab.id);
|
|
72
|
+
} else if (prevIndex > 0) {
|
|
73
|
+
// Skip disabled tab and try previous
|
|
74
|
+
activeTab.value = prevTab.id;
|
|
75
|
+
previousTab();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isTabActive(tabId: string): boolean {
|
|
81
|
+
return activeTab.value === tabId;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isTabDisabled(tabId: string): boolean {
|
|
85
|
+
const tab = tabs.value.find(t => t.id === tabId);
|
|
86
|
+
return tab?.disabled ?? false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
activeTab,
|
|
91
|
+
tabs,
|
|
92
|
+
currentTabIndex,
|
|
93
|
+
isFirstTab,
|
|
94
|
+
isLastTab,
|
|
95
|
+
setActiveTab,
|
|
96
|
+
nextTab,
|
|
97
|
+
previousTab,
|
|
98
|
+
isTabActive,
|
|
99
|
+
isTabDisabled
|
|
100
|
+
};
|
|
101
|
+
}
|