@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,164 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import * as fc from 'fast-check';
|
|
3
|
+
import { useModal } from './useModal';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Feature: htlkg-modular-architecture, Property 9: Components support v-model
|
|
7
|
+
* Validates: Requirements 12.2
|
|
8
|
+
*/
|
|
9
|
+
describe('useModal property tests', () => {
|
|
10
|
+
it('should manage open/close state reactively', () => {
|
|
11
|
+
fc.assert(
|
|
12
|
+
fc.property(
|
|
13
|
+
fc.boolean(),
|
|
14
|
+
(initialOpen) => {
|
|
15
|
+
const { isOpen, open, close, toggle } = useModal({ initialOpen });
|
|
16
|
+
|
|
17
|
+
// Property: Initial state should match initialOpen
|
|
18
|
+
expect(isOpen.value).toBe(initialOpen);
|
|
19
|
+
|
|
20
|
+
// Property: open() should set isOpen to true
|
|
21
|
+
open();
|
|
22
|
+
expect(isOpen.value).toBe(true);
|
|
23
|
+
|
|
24
|
+
// Property: close() should set isOpen to false
|
|
25
|
+
close();
|
|
26
|
+
expect(isOpen.value).toBe(false);
|
|
27
|
+
|
|
28
|
+
// Property: toggle() should flip the state
|
|
29
|
+
const beforeToggle = isOpen.value;
|
|
30
|
+
toggle();
|
|
31
|
+
expect(isOpen.value).toBe(!beforeToggle);
|
|
32
|
+
|
|
33
|
+
// Property: toggle() again should return to original state
|
|
34
|
+
toggle();
|
|
35
|
+
expect(isOpen.value).toBe(beforeToggle);
|
|
36
|
+
}
|
|
37
|
+
),
|
|
38
|
+
{ numRuns: 100 }
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should call callbacks when opening and closing', () => {
|
|
43
|
+
fc.assert(
|
|
44
|
+
fc.property(
|
|
45
|
+
fc.boolean(),
|
|
46
|
+
(initialOpen) => {
|
|
47
|
+
let openCallCount = 0;
|
|
48
|
+
let closeCallCount = 0;
|
|
49
|
+
|
|
50
|
+
const { open, close, toggle } = useModal({
|
|
51
|
+
initialOpen,
|
|
52
|
+
onOpen: () => { openCallCount++; },
|
|
53
|
+
onClose: () => { closeCallCount++; }
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const initialOpenCalls = openCallCount;
|
|
57
|
+
const initialCloseCalls = closeCallCount;
|
|
58
|
+
|
|
59
|
+
// Property: open() should call onOpen callback
|
|
60
|
+
open();
|
|
61
|
+
expect(openCallCount).toBe(initialOpenCalls + 1);
|
|
62
|
+
|
|
63
|
+
// Property: close() should call onClose callback
|
|
64
|
+
close();
|
|
65
|
+
expect(closeCallCount).toBe(initialCloseCalls + 1);
|
|
66
|
+
|
|
67
|
+
// Property: toggle() should call appropriate callback
|
|
68
|
+
const beforeToggleOpen = openCallCount;
|
|
69
|
+
const beforeToggleClose = closeCallCount;
|
|
70
|
+
toggle(); // Should open
|
|
71
|
+
expect(openCallCount).toBe(beforeToggleOpen + 1);
|
|
72
|
+
|
|
73
|
+
toggle(); // Should close
|
|
74
|
+
expect(closeCallCount).toBe(beforeToggleClose + 1);
|
|
75
|
+
}
|
|
76
|
+
),
|
|
77
|
+
{ numRuns: 100 }
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should maintain state consistency across multiple operations', () => {
|
|
82
|
+
fc.assert(
|
|
83
|
+
fc.property(
|
|
84
|
+
fc.array(fc.constantFrom('open', 'close', 'toggle'), { minLength: 1, maxLength: 20 }),
|
|
85
|
+
(operations) => {
|
|
86
|
+
const { isOpen, open, close, toggle } = useModal({ initialOpen: false });
|
|
87
|
+
|
|
88
|
+
let expectedState = false;
|
|
89
|
+
|
|
90
|
+
for (const operation of operations) {
|
|
91
|
+
switch (operation) {
|
|
92
|
+
case 'open':
|
|
93
|
+
open();
|
|
94
|
+
expectedState = true;
|
|
95
|
+
break;
|
|
96
|
+
case 'close':
|
|
97
|
+
close();
|
|
98
|
+
expectedState = false;
|
|
99
|
+
break;
|
|
100
|
+
case 'toggle':
|
|
101
|
+
toggle();
|
|
102
|
+
expectedState = !expectedState;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Property: State should always match expected state after operation
|
|
107
|
+
expect(isOpen.value).toBe(expectedState);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
),
|
|
111
|
+
{ numRuns: 100 }
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should handle rapid state changes correctly', () => {
|
|
116
|
+
fc.assert(
|
|
117
|
+
fc.property(
|
|
118
|
+
fc.integer({ min: 1, max: 10 }),
|
|
119
|
+
(toggleCount) => {
|
|
120
|
+
const { isOpen, toggle } = useModal({ initialOpen: false });
|
|
121
|
+
|
|
122
|
+
const initialState = isOpen.value;
|
|
123
|
+
|
|
124
|
+
// Property: Toggling an even number of times should return to initial state
|
|
125
|
+
for (let i = 0; i < toggleCount * 2; i++) {
|
|
126
|
+
toggle();
|
|
127
|
+
}
|
|
128
|
+
expect(isOpen.value).toBe(initialState);
|
|
129
|
+
|
|
130
|
+
// Property: Toggling an odd number of times should flip the state
|
|
131
|
+
toggle();
|
|
132
|
+
expect(isOpen.value).toBe(!initialState);
|
|
133
|
+
}
|
|
134
|
+
),
|
|
135
|
+
{ numRuns: 100 }
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should support v-model pattern with reactive updates', () => {
|
|
140
|
+
fc.assert(
|
|
141
|
+
fc.property(
|
|
142
|
+
fc.boolean(),
|
|
143
|
+
fc.boolean(),
|
|
144
|
+
(initialState, targetState) => {
|
|
145
|
+
const { isOpen, open, close } = useModal({ initialOpen: initialState });
|
|
146
|
+
|
|
147
|
+
// Property: Directly setting isOpen.value should update state (v-model pattern)
|
|
148
|
+
isOpen.value = targetState;
|
|
149
|
+
expect(isOpen.value).toBe(targetState);
|
|
150
|
+
|
|
151
|
+
// Property: Methods should still work after direct state change
|
|
152
|
+
if (targetState) {
|
|
153
|
+
close();
|
|
154
|
+
expect(isOpen.value).toBe(false);
|
|
155
|
+
} else {
|
|
156
|
+
open();
|
|
157
|
+
expect(isOpen.value).toBe(true);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
),
|
|
161
|
+
{ numRuns: 100 }
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { ref, type Ref } from 'vue';
|
|
2
|
+
|
|
3
|
+
export interface UseModalOptions {
|
|
4
|
+
initialOpen?: boolean;
|
|
5
|
+
onOpen?: () => void;
|
|
6
|
+
onClose?: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface UseModalReturn {
|
|
10
|
+
isOpen: Ref<boolean>;
|
|
11
|
+
open: () => void;
|
|
12
|
+
close: () => void;
|
|
13
|
+
toggle: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useModal(options: UseModalOptions = {}): UseModalReturn {
|
|
17
|
+
const isOpen = ref(options.initialOpen ?? false);
|
|
18
|
+
|
|
19
|
+
function open() {
|
|
20
|
+
isOpen.value = true;
|
|
21
|
+
options.onOpen?.();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function close() {
|
|
25
|
+
isOpen.value = false;
|
|
26
|
+
options.onClose?.();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toggle() {
|
|
30
|
+
if (isOpen.value) {
|
|
31
|
+
close();
|
|
32
|
+
} else {
|
|
33
|
+
open();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
isOpen,
|
|
39
|
+
open,
|
|
40
|
+
close,
|
|
41
|
+
toggle
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { useNotifications } from './useNotifications';
|
|
3
|
+
|
|
4
|
+
describe('useNotifications composable', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
vi.useFakeTimers();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.restoreAllMocks();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('initializes with empty notifications', () => {
|
|
14
|
+
const { notifications, clearAll } = useNotifications();
|
|
15
|
+
clearAll(); // Clear any existing notifications
|
|
16
|
+
expect(notifications.value).toEqual([]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('adds notification', () => {
|
|
20
|
+
const { notifications, addNotification, clearAll } = useNotifications();
|
|
21
|
+
clearAll();
|
|
22
|
+
|
|
23
|
+
const id = addNotification({
|
|
24
|
+
message: 'Test notification',
|
|
25
|
+
type: 'info'
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(notifications.value).toHaveLength(1);
|
|
29
|
+
expect(notifications.value[0].id).toBe(id);
|
|
30
|
+
expect(notifications.value[0].message).toBe('Test notification');
|
|
31
|
+
expect(notifications.value[0].type).toBe('info');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('generates unique IDs for notifications', () => {
|
|
35
|
+
const { addNotification, clearAll } = useNotifications();
|
|
36
|
+
clearAll();
|
|
37
|
+
|
|
38
|
+
const id1 = addNotification({ message: 'Test 1', type: 'info' });
|
|
39
|
+
const id2 = addNotification({ message: 'Test 2', type: 'info' });
|
|
40
|
+
|
|
41
|
+
expect(id1).not.toBe(id2);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('removes notification by ID', () => {
|
|
45
|
+
const { notifications, addNotification, removeNotification, clearAll } = useNotifications();
|
|
46
|
+
clearAll();
|
|
47
|
+
|
|
48
|
+
const id = addNotification({ message: 'Test', type: 'info' });
|
|
49
|
+
expect(notifications.value).toHaveLength(1);
|
|
50
|
+
|
|
51
|
+
removeNotification(id);
|
|
52
|
+
expect(notifications.value).toHaveLength(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('clears all notifications', () => {
|
|
56
|
+
const { notifications, addNotification, clearAll } = useNotifications();
|
|
57
|
+
clearAll();
|
|
58
|
+
|
|
59
|
+
addNotification({ message: 'Test 1', type: 'info' });
|
|
60
|
+
addNotification({ message: 'Test 2', type: 'success' });
|
|
61
|
+
expect(notifications.value).toHaveLength(2);
|
|
62
|
+
|
|
63
|
+
clearAll();
|
|
64
|
+
expect(notifications.value).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('auto-removes notification after duration', () => {
|
|
68
|
+
const { notifications, addNotification, clearAll } = useNotifications();
|
|
69
|
+
clearAll();
|
|
70
|
+
|
|
71
|
+
addNotification({
|
|
72
|
+
message: 'Test',
|
|
73
|
+
type: 'info',
|
|
74
|
+
duration: 3000
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(notifications.value).toHaveLength(1);
|
|
78
|
+
|
|
79
|
+
vi.advanceTimersByTime(3000);
|
|
80
|
+
expect(notifications.value).toHaveLength(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('adds info notification with default duration', () => {
|
|
84
|
+
const { notifications, info, clearAll } = useNotifications();
|
|
85
|
+
clearAll();
|
|
86
|
+
|
|
87
|
+
info('Info message');
|
|
88
|
+
expect(notifications.value).toHaveLength(1);
|
|
89
|
+
expect(notifications.value[0].type).toBe('info');
|
|
90
|
+
expect(notifications.value[0].message).toBe('Info message');
|
|
91
|
+
|
|
92
|
+
vi.advanceTimersByTime(3000);
|
|
93
|
+
expect(notifications.value).toHaveLength(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('adds success notification with default duration', () => {
|
|
97
|
+
const { notifications, success, clearAll } = useNotifications();
|
|
98
|
+
clearAll();
|
|
99
|
+
|
|
100
|
+
success('Success message');
|
|
101
|
+
expect(notifications.value).toHaveLength(1);
|
|
102
|
+
expect(notifications.value[0].type).toBe('success');
|
|
103
|
+
expect(notifications.value[0].message).toBe('Success message');
|
|
104
|
+
|
|
105
|
+
vi.advanceTimersByTime(3000);
|
|
106
|
+
expect(notifications.value).toHaveLength(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('adds warning notification with default duration', () => {
|
|
110
|
+
const { notifications, warning, clearAll } = useNotifications();
|
|
111
|
+
clearAll();
|
|
112
|
+
|
|
113
|
+
warning('Warning message');
|
|
114
|
+
expect(notifications.value).toHaveLength(1);
|
|
115
|
+
expect(notifications.value[0].type).toBe('warning');
|
|
116
|
+
expect(notifications.value[0].message).toBe('Warning message');
|
|
117
|
+
|
|
118
|
+
vi.advanceTimersByTime(3000);
|
|
119
|
+
expect(notifications.value).toHaveLength(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('adds error notification with longer default duration', () => {
|
|
123
|
+
const { notifications, error, clearAll } = useNotifications();
|
|
124
|
+
clearAll();
|
|
125
|
+
|
|
126
|
+
error('Error message');
|
|
127
|
+
expect(notifications.value).toHaveLength(1);
|
|
128
|
+
expect(notifications.value[0].type).toBe('error');
|
|
129
|
+
expect(notifications.value[0].message).toBe('Error message');
|
|
130
|
+
|
|
131
|
+
vi.advanceTimersByTime(5000);
|
|
132
|
+
expect(notifications.value).toHaveLength(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('allows custom duration for helper methods', () => {
|
|
136
|
+
const { notifications, info, clearAll } = useNotifications();
|
|
137
|
+
clearAll();
|
|
138
|
+
|
|
139
|
+
info('Custom duration', 1000);
|
|
140
|
+
expect(notifications.value).toHaveLength(1);
|
|
141
|
+
|
|
142
|
+
vi.advanceTimersByTime(1000);
|
|
143
|
+
expect(notifications.value).toHaveLength(0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('handles removing non-existent notification gracefully', () => {
|
|
147
|
+
const { notifications, removeNotification, clearAll } = useNotifications();
|
|
148
|
+
clearAll();
|
|
149
|
+
|
|
150
|
+
expect(() => removeNotification('non-existent-id')).not.toThrow();
|
|
151
|
+
expect(notifications.value).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('maintains notification order', () => {
|
|
155
|
+
const { notifications, addNotification, clearAll } = useNotifications();
|
|
156
|
+
clearAll();
|
|
157
|
+
|
|
158
|
+
addNotification({ message: 'First', type: 'info' });
|
|
159
|
+
addNotification({ message: 'Second', type: 'success' });
|
|
160
|
+
addNotification({ message: 'Third', type: 'warning' });
|
|
161
|
+
|
|
162
|
+
expect(notifications.value[0].message).toBe('First');
|
|
163
|
+
expect(notifications.value[1].message).toBe('Second');
|
|
164
|
+
expect(notifications.value[2].message).toBe('Third');
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ref, type Ref } from 'vue';
|
|
2
|
+
|
|
3
|
+
export interface Notification {
|
|
4
|
+
id: string;
|
|
5
|
+
message: string;
|
|
6
|
+
type: 'info' | 'success' | 'warning' | 'error';
|
|
7
|
+
duration?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface UseNotificationsReturn {
|
|
11
|
+
notifications: Ref<Notification[]>;
|
|
12
|
+
addNotification: (notification: Omit<Notification, 'id'>) => string;
|
|
13
|
+
removeNotification: (id: string) => void;
|
|
14
|
+
clearAll: () => void;
|
|
15
|
+
info: (message: string, duration?: number) => string;
|
|
16
|
+
success: (message: string, duration?: number) => string;
|
|
17
|
+
warning: (message: string, duration?: number) => string;
|
|
18
|
+
error: (message: string, duration?: number) => string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Global notifications state
|
|
22
|
+
const notifications = ref<Notification[]>([]);
|
|
23
|
+
|
|
24
|
+
export function useNotifications(): UseNotificationsReturn {
|
|
25
|
+
function addNotification(notification: Omit<Notification, 'id'>): string {
|
|
26
|
+
const id = `notification-${Date.now()}-${Math.random()}`;
|
|
27
|
+
const newNotification: Notification = {
|
|
28
|
+
...notification,
|
|
29
|
+
id
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
notifications.value.push(newNotification);
|
|
33
|
+
|
|
34
|
+
// Auto-remove after duration
|
|
35
|
+
if (notification.duration) {
|
|
36
|
+
setTimeout(() => {
|
|
37
|
+
removeNotification(id);
|
|
38
|
+
}, notification.duration);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return id;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function removeNotification(id: string) {
|
|
45
|
+
const index = notifications.value.findIndex(n => n.id === id);
|
|
46
|
+
if (index !== -1) {
|
|
47
|
+
notifications.value.splice(index, 1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function clearAll() {
|
|
52
|
+
notifications.value = [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function info(message: string, duration = 3000): string {
|
|
56
|
+
return addNotification({ message, type: 'info', duration });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function success(message: string, duration = 3000): string {
|
|
60
|
+
return addNotification({ message, type: 'success', duration });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function warning(message: string, duration = 3000): string {
|
|
64
|
+
return addNotification({ message, type: 'warning', duration });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function error(message: string, duration = 5000): string {
|
|
68
|
+
return addNotification({ message, type: 'error', duration });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
notifications,
|
|
73
|
+
addNotification,
|
|
74
|
+
removeNotification,
|
|
75
|
+
clearAll,
|
|
76
|
+
info,
|
|
77
|
+
success,
|
|
78
|
+
warning,
|
|
79
|
+
error
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import * as fc from 'fast-check';
|
|
3
|
+
import { useTable } from './useTable';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Feature: htlkg-modular-architecture, Property 8: Stateful components manage state
|
|
7
|
+
* Validates: Requirements 12.1
|
|
8
|
+
*/
|
|
9
|
+
describe('useTable property tests', () => {
|
|
10
|
+
it('should manage pagination state reactively', () => {
|
|
11
|
+
fc.assert(
|
|
12
|
+
fc.property(
|
|
13
|
+
fc.array(fc.record({
|
|
14
|
+
id: fc.string(),
|
|
15
|
+
name: fc.string(),
|
|
16
|
+
value: fc.integer()
|
|
17
|
+
}), { minLength: 0, maxLength: 100 }),
|
|
18
|
+
fc.integer({ min: 1, max: 50 }),
|
|
19
|
+
fc.integer({ min: 1, max: 10 }),
|
|
20
|
+
(items, pageSize, targetPage) => {
|
|
21
|
+
const { currentPage, paginatedItems, totalPages, setPage } = useTable({
|
|
22
|
+
items,
|
|
23
|
+
pageSize
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Property: Setting page should update currentPage
|
|
27
|
+
const validPage = Math.min(targetPage, totalPages.value);
|
|
28
|
+
if (validPage >= 1) {
|
|
29
|
+
setPage(validPage);
|
|
30
|
+
expect(currentPage.value).toBe(validPage);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Property: Paginated items should not exceed page size
|
|
34
|
+
expect(paginatedItems.value.length).toBeLessThanOrEqual(pageSize);
|
|
35
|
+
|
|
36
|
+
// Property: Total pages should be correct
|
|
37
|
+
const expectedPages = Math.ceil(items.length / pageSize);
|
|
38
|
+
expect(totalPages.value).toBe(expectedPages);
|
|
39
|
+
}
|
|
40
|
+
),
|
|
41
|
+
{ numRuns: 100 }
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should manage sorting state reactively', () => {
|
|
46
|
+
fc.assert(
|
|
47
|
+
fc.property(
|
|
48
|
+
fc.array(fc.record({
|
|
49
|
+
id: fc.string(),
|
|
50
|
+
name: fc.string(),
|
|
51
|
+
value: fc.integer()
|
|
52
|
+
}), { minLength: 2, maxLength: 50 }),
|
|
53
|
+
fc.constantFrom('id', 'name', 'value'),
|
|
54
|
+
fc.constantFrom('asc' as const, 'desc' as const),
|
|
55
|
+
(items, sortField, sortDirection) => {
|
|
56
|
+
const { sortKey, sortOrder, sortedItems, setSorting } = useTable({
|
|
57
|
+
items
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Property: Setting sorting should update sortKey and sortOrder
|
|
61
|
+
setSorting(sortField, sortDirection);
|
|
62
|
+
expect(sortKey.value).toBe(sortField);
|
|
63
|
+
expect(sortOrder.value).toBe(sortDirection);
|
|
64
|
+
|
|
65
|
+
// Property: Sorted items should maintain same length
|
|
66
|
+
expect(sortedItems.value.length).toBe(items.length);
|
|
67
|
+
|
|
68
|
+
// Property: Sorted items should be in correct order
|
|
69
|
+
if (sortedItems.value.length > 1) {
|
|
70
|
+
for (let i = 0; i < sortedItems.value.length - 1; i++) {
|
|
71
|
+
const current = sortedItems.value[i][sortField];
|
|
72
|
+
const next = sortedItems.value[i + 1][sortField];
|
|
73
|
+
|
|
74
|
+
if (current != null && next != null) {
|
|
75
|
+
if (sortDirection === 'asc') {
|
|
76
|
+
expect(current <= next).toBe(true);
|
|
77
|
+
} else {
|
|
78
|
+
expect(current >= next).toBe(true);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
),
|
|
85
|
+
{ numRuns: 100 }
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should manage selection state reactively', () => {
|
|
90
|
+
fc.assert(
|
|
91
|
+
fc.property(
|
|
92
|
+
fc.array(fc.record({
|
|
93
|
+
id: fc.string(),
|
|
94
|
+
name: fc.string()
|
|
95
|
+
}), { minLength: 1, maxLength: 50 }),
|
|
96
|
+
(items) => {
|
|
97
|
+
const { selectedItems, selectItem, deselectItem, selectAll, clearSelection, isSelected } = useTable({
|
|
98
|
+
items
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Property: Initially no items selected
|
|
102
|
+
expect(selectedItems.value.length).toBe(0);
|
|
103
|
+
|
|
104
|
+
// Property: Selecting an item should add it to selection
|
|
105
|
+
const itemToSelect = items[0];
|
|
106
|
+
selectItem(itemToSelect);
|
|
107
|
+
expect(isSelected(itemToSelect)).toBe(true);
|
|
108
|
+
expect(selectedItems.value.length).toBe(1);
|
|
109
|
+
|
|
110
|
+
// Property: Deselecting an item should remove it from selection
|
|
111
|
+
deselectItem(itemToSelect);
|
|
112
|
+
expect(isSelected(itemToSelect)).toBe(false);
|
|
113
|
+
expect(selectedItems.value.length).toBe(0);
|
|
114
|
+
|
|
115
|
+
// Property: Select all should select all items
|
|
116
|
+
selectAll();
|
|
117
|
+
expect(selectedItems.value.length).toBe(items.length);
|
|
118
|
+
items.forEach(item => {
|
|
119
|
+
expect(isSelected(item)).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Property: Clear selection should remove all selections
|
|
123
|
+
clearSelection();
|
|
124
|
+
expect(selectedItems.value.length).toBe(0);
|
|
125
|
+
items.forEach(item => {
|
|
126
|
+
expect(isSelected(item)).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
),
|
|
130
|
+
{ numRuns: 100 }
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should handle filtering with pagination correctly', () => {
|
|
135
|
+
fc.assert(
|
|
136
|
+
fc.property(
|
|
137
|
+
fc.array(fc.record({
|
|
138
|
+
id: fc.string(),
|
|
139
|
+
name: fc.string(),
|
|
140
|
+
active: fc.boolean()
|
|
141
|
+
}), { minLength: 0, maxLength: 100 }),
|
|
142
|
+
fc.integer({ min: 5, max: 20 }),
|
|
143
|
+
(items, pageSize) => {
|
|
144
|
+
const { paginatedItems, totalPages, currentPage } = useTable({
|
|
145
|
+
items,
|
|
146
|
+
pageSize
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Property: First page should start from index 0
|
|
150
|
+
if (items.length > 0) {
|
|
151
|
+
const firstPageItem = paginatedItems.value[0];
|
|
152
|
+
expect(items.slice(0, pageSize)).toContainEqual(firstPageItem);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Property: Total pages calculation should be consistent
|
|
156
|
+
const expectedPages = Math.ceil(items.length / pageSize);
|
|
157
|
+
expect(totalPages.value).toBe(expectedPages);
|
|
158
|
+
|
|
159
|
+
// Property: Current page should always be valid
|
|
160
|
+
expect(currentPage.value).toBeGreaterThanOrEqual(1);
|
|
161
|
+
expect(currentPage.value).toBeLessThanOrEqual(Math.max(1, totalPages.value));
|
|
162
|
+
}
|
|
163
|
+
),
|
|
164
|
+
{ numRuns: 100 }
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should maintain state consistency when changing page size', () => {
|
|
169
|
+
fc.assert(
|
|
170
|
+
fc.property(
|
|
171
|
+
fc.array(fc.record({
|
|
172
|
+
id: fc.string(),
|
|
173
|
+
value: fc.integer()
|
|
174
|
+
}), { minLength: 10, maxLength: 100 }),
|
|
175
|
+
fc.integer({ min: 5, max: 20 }),
|
|
176
|
+
fc.integer({ min: 5, max: 30 }),
|
|
177
|
+
(items, initialPageSize, newPageSize) => {
|
|
178
|
+
const { currentPage, pageSize, totalPages, setPageSize } = useTable({
|
|
179
|
+
items,
|
|
180
|
+
pageSize: initialPageSize
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const initialTotalPages = totalPages.value;
|
|
184
|
+
|
|
185
|
+
// Property: Changing page size should reset to page 1
|
|
186
|
+
setPageSize(newPageSize);
|
|
187
|
+
expect(currentPage.value).toBe(1);
|
|
188
|
+
expect(pageSize.value).toBe(newPageSize);
|
|
189
|
+
|
|
190
|
+
// Property: Total pages should update correctly
|
|
191
|
+
const expectedPages = Math.ceil(items.length / newPageSize);
|
|
192
|
+
expect(totalPages.value).toBe(expectedPages);
|
|
193
|
+
}
|
|
194
|
+
),
|
|
195
|
+
{ numRuns: 100 }
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
});
|