@htlkg/components 0.0.11 → 0.0.13
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/{AdminWrapper.vue_vue_type_script_setup_true_lang-B32IylcT.js → AdminWrapper.vue_vue_type_script_setup_true_lang-BhnWQ-b0.js} +26 -29
- package/dist/AdminWrapper.vue_vue_type_script_setup_true_lang-BhnWQ-b0.js.map +1 -0
- package/dist/Alert.vue_vue_type_script_setup_true_lang-DxPCS-Hx.js.map +1 -1
- package/dist/DateRange.vue_vue_type_script_setup_true_lang-BLVg1Hah.js.map +1 -1
- package/dist/ProductBadge.vue_vue_type_script_setup_true_lang-Cmr2f4Cy.js.map +1 -1
- package/dist/components.css +4 -4
- package/dist/composables/index.js +23 -22
- package/dist/data/index.js +10 -10
- package/dist/{filterHelpers-DgRyoYSa.js → filterHelpers-DpHSlTuh.js} +11 -11
- package/dist/filterHelpers-DpHSlTuh.js.map +1 -0
- package/dist/index-QK97OdqQ.js.map +1 -1
- package/dist/index.js +34 -33
- package/dist/navigation/index.js +1 -1
- package/dist/{useAdminPage-GhgXp0x8.js → useAdminPage-AgWRvw6o.js} +150 -26
- package/dist/useAdminPage-AgWRvw6o.js.map +1 -0
- package/package.json +3 -3
- package/src/composables/index.ts +1 -0
- package/src/composables/useJsonForm.test.ts +272 -0
- package/src/composables/useJsonForm.ts +261 -0
- package/src/composables/useModal.test.ts +264 -0
- package/src/composables/useModal.ts +54 -8
- package/src/data/Chart/index.ts +2 -0
- package/src/data/DataList/index.ts +1 -0
- package/src/data/{DataTable.vue → DataTable/DataTable.vue} +2 -2
- package/src/data/DataTable/index.ts +8 -0
- package/src/data/SearchableSelect/index.ts +1 -0
- package/src/data/Table/index.ts +1 -0
- package/src/data/index.ts +5 -15
- package/src/domain/BrandCard/index.ts +1 -0
- package/src/domain/BrandSelector/index.ts +1 -0
- package/src/domain/ProductBadge/index.ts +1 -0
- package/src/domain/UserAvatar/index.ts +1 -0
- package/src/domain/index.ts +4 -4
- package/src/forms/DateRange/index.ts +2 -0
- package/src/forms/JsonSchemaForm/index.ts +1 -0
- package/src/forms/index.ts +2 -3
- package/src/navigation/{AdminWrapper.vue → AdminWrapper/AdminWrapper.vue} +41 -30
- package/src/navigation/AdminWrapper/index.ts +1 -0
- package/src/navigation/Breadcrumbs/index.ts +1 -0
- package/src/navigation/Stepper/index.ts +2 -0
- package/src/navigation/Tabs/index.ts +2 -0
- package/src/navigation/index.ts +4 -6
- package/src/overlays/Alert/index.ts +1 -0
- package/src/overlays/Drawer/index.ts +1 -0
- package/src/overlays/Modal/index.ts +1 -0
- package/src/overlays/Notification/index.ts +1 -0
- package/src/overlays/index.ts +4 -4
- package/src/patterns/DASHBOARD_PAGE.md +642 -0
- package/src/patterns/DETAIL_PAGE.md +446 -0
- package/src/patterns/FORM_PAGE.md +439 -0
- package/src/patterns/LIST_PAGE.md +340 -0
- package/src/patterns/PAGE_PATTERNS.md +110 -0
- package/src/patterns/WIZARD_PAGE.md +733 -0
- package/dist/AdminWrapper.vue_vue_type_script_setup_true_lang-B32IylcT.js.map +0 -1
- package/dist/filterHelpers-DgRyoYSa.js.map +0 -1
- package/dist/useAdminPage-GhgXp0x8.js.map +0 -1
- package/src/data/Table.vue +0 -295
- /package/src/data/{Chart.demo.vue → Chart/Chart.demo.vue} +0 -0
- /package/src/data/{Chart.md → Chart/Chart.md} +0 -0
- /package/src/data/{Chart.vue → Chart/Chart.vue} +0 -0
- /package/src/data/{DataList.md → DataList/DataList.md} +0 -0
- /package/src/data/{DataList.test.ts → DataList/DataList.test.ts} +0 -0
- /package/src/data/{DataList.vue → DataList/DataList.vue} +0 -0
- /package/src/data/{SearchableSelect.md → SearchableSelect/SearchableSelect.md} +0 -0
- /package/src/data/{SearchableSelect.vue → SearchableSelect/SearchableSelect.vue} +0 -0
- /package/src/data/{Table.demo.vue → Table/Table.demo.vue} +0 -0
- /package/src/data/{Table.md → Table/Table.md} +0 -0
- /package/src/data/{Table.property.test.ts → Table/Table.property.test.ts} +0 -0
- /package/src/data/{Table.test.ts → Table/Table.test.ts} +0 -0
- /package/src/data/{Table.unit.test.ts → Table/Table.unit.test.ts} +0 -0
- /package/src/domain/{BrandCard.md → BrandCard/BrandCard.md} +0 -0
- /package/src/domain/{BrandCard.vue → BrandCard/BrandCard.vue} +0 -0
- /package/src/domain/{BrandSelector.md → BrandSelector/BrandSelector.md} +0 -0
- /package/src/domain/{BrandSelector.vue → BrandSelector/BrandSelector.vue} +0 -0
- /package/src/domain/{ProductBadge.md → ProductBadge/ProductBadge.md} +0 -0
- /package/src/domain/{ProductBadge.vue → ProductBadge/ProductBadge.vue} +0 -0
- /package/src/domain/{UserAvatar.md → UserAvatar/UserAvatar.md} +0 -0
- /package/src/domain/{UserAvatar.vue → UserAvatar/UserAvatar.vue} +0 -0
- /package/src/forms/{DateRange.demo.vue → DateRange/DateRange.demo.vue} +0 -0
- /package/src/forms/{DateRange.md → DateRange/DateRange.md} +0 -0
- /package/src/forms/{DateRange.vue → DateRange/DateRange.vue} +0 -0
- /package/src/forms/{JsonSchemaForm.demo.vue → JsonSchemaForm/JsonSchemaForm.demo.vue} +0 -0
- /package/src/forms/{JsonSchemaForm.md → JsonSchemaForm/JsonSchemaForm.md} +0 -0
- /package/src/forms/{JsonSchemaForm.property.test.ts → JsonSchemaForm/JsonSchemaForm.property.test.ts} +0 -0
- /package/src/forms/{JsonSchemaForm.test.ts → JsonSchemaForm/JsonSchemaForm.test.ts} +0 -0
- /package/src/forms/{JsonSchemaForm.unit.test.ts → JsonSchemaForm/JsonSchemaForm.unit.test.ts} +0 -0
- /package/src/forms/{JsonSchemaForm.vue → JsonSchemaForm/JsonSchemaForm.vue} +0 -0
- /package/src/navigation/{Breadcrumbs.demo.vue → Breadcrumbs/Breadcrumbs.demo.vue} +0 -0
- /package/src/navigation/{Breadcrumbs.md → Breadcrumbs/Breadcrumbs.md} +0 -0
- /package/src/navigation/{Breadcrumbs.test.ts → Breadcrumbs/Breadcrumbs.test.ts} +0 -0
- /package/src/navigation/{Breadcrumbs.vue → Breadcrumbs/Breadcrumbs.vue} +0 -0
- /package/src/navigation/{Stepper.demo.vue → Stepper/Stepper.demo.vue} +0 -0
- /package/src/navigation/{Stepper.md → Stepper/Stepper.md} +0 -0
- /package/src/navigation/{Stepper.vue → Stepper/Stepper.vue} +0 -0
- /package/src/navigation/{Tabs.demo.vue → Tabs/Tabs.demo.vue} +0 -0
- /package/src/navigation/{Tabs.md → Tabs/Tabs.md} +0 -0
- /package/src/navigation/{Tabs.test.ts → Tabs/Tabs.test.ts} +0 -0
- /package/src/navigation/{Tabs.vue → Tabs/Tabs.vue} +0 -0
- /package/src/overlays/{Alert.demo.vue → Alert/Alert.demo.vue} +0 -0
- /package/src/overlays/{Alert.md → Alert/Alert.md} +0 -0
- /package/src/overlays/{Alert.test.ts → Alert/Alert.test.ts} +0 -0
- /package/src/overlays/{Alert.vue → Alert/Alert.vue} +0 -0
- /package/src/overlays/{Drawer.md → Drawer/Drawer.md} +0 -0
- /package/src/overlays/{Drawer.test.ts → Drawer/Drawer.test.ts} +0 -0
- /package/src/overlays/{Drawer.vue → Drawer/Drawer.vue} +0 -0
- /package/src/overlays/{Modal.demo.vue → Modal/Modal.demo.vue} +0 -0
- /package/src/overlays/{Modal.md → Modal/Modal.md} +0 -0
- /package/src/overlays/{Modal.test.ts → Modal/Modal.test.ts} +0 -0
- /package/src/overlays/{Modal.vue → Modal/Modal.vue} +0 -0
- /package/src/overlays/{Notification.md → Notification/Notification.md} +0 -0
- /package/src/overlays/{Notification.test.ts → Notification/Notification.test.ts} +0 -0
- /package/src/overlays/{Notification.vue → Notification/Notification.vue} +0 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JsonSchemaForm Composable
|
|
3
|
+
*
|
|
4
|
+
* Wraps JsonSchemaForm component with additional features:
|
|
5
|
+
* - isDirty tracking (has unsaved changes)
|
|
6
|
+
* - confirmOnLeave (warn before navigation with unsaved changes)
|
|
7
|
+
* - Form ref management
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ref, computed, watch, onMounted, onUnmounted, type Ref, type ComputedRef } from 'vue';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Options for useJsonForm
|
|
14
|
+
*/
|
|
15
|
+
export interface UseJsonFormOptions<T extends Record<string, unknown>> {
|
|
16
|
+
/** Initial form values */
|
|
17
|
+
initialValues?: T;
|
|
18
|
+
/** Warn before leaving with unsaved changes */
|
|
19
|
+
confirmOnLeave?: boolean;
|
|
20
|
+
/** Message to show in browser confirm dialog */
|
|
21
|
+
confirmMessage?: string;
|
|
22
|
+
/** Auto-save configuration */
|
|
23
|
+
autoSave?: {
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
debounceMs?: number;
|
|
26
|
+
onAutoSave: (values: T) => Promise<void>;
|
|
27
|
+
};
|
|
28
|
+
/** Callback when values change */
|
|
29
|
+
onChange?: (values: T) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Return type for useJsonForm
|
|
34
|
+
*/
|
|
35
|
+
export interface UseJsonFormReturn<T extends Record<string, unknown>> {
|
|
36
|
+
/** Ref to attach to JsonSchemaForm component */
|
|
37
|
+
formRef: Ref<JsonSchemaFormInstance | null>;
|
|
38
|
+
/** Current form values (v-model) */
|
|
39
|
+
values: Ref<T>;
|
|
40
|
+
/** Original values (for dirty checking) */
|
|
41
|
+
originalValues: Ref<T>;
|
|
42
|
+
/** Whether form has unsaved changes */
|
|
43
|
+
isDirty: ComputedRef<boolean>;
|
|
44
|
+
/** Trigger form validation */
|
|
45
|
+
validate: () => Array<{ field: string; message: string }>;
|
|
46
|
+
/** Reset form to original values */
|
|
47
|
+
reset: () => void;
|
|
48
|
+
/** Update both current and original values (after save) */
|
|
49
|
+
setValues: (newValues: Partial<T>) => void;
|
|
50
|
+
/** Mark form as saved (updates original values to current) */
|
|
51
|
+
markAsSaved: () => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* JsonSchemaForm component instance interface
|
|
56
|
+
*/
|
|
57
|
+
interface JsonSchemaFormInstance {
|
|
58
|
+
validate: () => Array<{ field: string; message: string }>;
|
|
59
|
+
reset: () => void;
|
|
60
|
+
formData: Ref<Record<string, unknown>>;
|
|
61
|
+
errors: Ref<Record<string, string>>;
|
|
62
|
+
touched: Ref<Record<string, boolean>>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Deep equality check for objects
|
|
67
|
+
*/
|
|
68
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
69
|
+
if (a === b) return true;
|
|
70
|
+
if (a == null || b == null) return false;
|
|
71
|
+
if (typeof a !== 'object' || typeof b !== 'object') return false;
|
|
72
|
+
|
|
73
|
+
const keysA = Object.keys(a as object);
|
|
74
|
+
const keysB = Object.keys(b as object);
|
|
75
|
+
|
|
76
|
+
if (keysA.length !== keysB.length) return false;
|
|
77
|
+
|
|
78
|
+
for (const key of keysA) {
|
|
79
|
+
if (!keysB.includes(key)) return false;
|
|
80
|
+
if (!deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Deep clone an object
|
|
90
|
+
*/
|
|
91
|
+
function deepClone<T>(obj: T): T {
|
|
92
|
+
if (obj === null || typeof obj !== 'object') return obj;
|
|
93
|
+
if (Array.isArray(obj)) return obj.map(deepClone) as unknown as T;
|
|
94
|
+
const clone = {} as T;
|
|
95
|
+
for (const key in obj) {
|
|
96
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
97
|
+
clone[key] = deepClone(obj[key]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return clone;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Debounce function
|
|
105
|
+
*/
|
|
106
|
+
function debounce<T extends (...args: unknown[]) => unknown>(fn: T, delay: number): T {
|
|
107
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
108
|
+
return ((...args: unknown[]) => {
|
|
109
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
110
|
+
timeoutId = setTimeout(() => fn(...args), delay);
|
|
111
|
+
}) as T;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* useJsonForm composable
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```vue
|
|
119
|
+
* <script setup>
|
|
120
|
+
* import { JsonSchemaForm } from '@htlkg/components/forms';
|
|
121
|
+
* import { useJsonForm } from '@htlkg/components/composables';
|
|
122
|
+
*
|
|
123
|
+
* const { formRef, values, isDirty, validate, reset } = useJsonForm({
|
|
124
|
+
* initialValues: props.brand,
|
|
125
|
+
* confirmOnLeave: true,
|
|
126
|
+
* });
|
|
127
|
+
*
|
|
128
|
+
* const schema = {
|
|
129
|
+
* type: 'object',
|
|
130
|
+
* properties: {
|
|
131
|
+
* name: { type: 'string', title: 'Name', minLength: 1 },
|
|
132
|
+
* },
|
|
133
|
+
* required: ['name'],
|
|
134
|
+
* };
|
|
135
|
+
*
|
|
136
|
+
* async function handleSubmit(data) {
|
|
137
|
+
* await saveBrand(data);
|
|
138
|
+
* // Update original values so isDirty becomes false
|
|
139
|
+
* setValues(data);
|
|
140
|
+
* }
|
|
141
|
+
* </script>
|
|
142
|
+
*
|
|
143
|
+
* <template>
|
|
144
|
+
* <div v-if="isDirty" class="warning">You have unsaved changes</div>
|
|
145
|
+
* <JsonSchemaForm
|
|
146
|
+
* ref="formRef"
|
|
147
|
+
* v-model="values"
|
|
148
|
+
* :schema="schema"
|
|
149
|
+
* @submit="handleSubmit"
|
|
150
|
+
* />
|
|
151
|
+
* </template>
|
|
152
|
+
* ```
|
|
153
|
+
*/
|
|
154
|
+
export function useJsonForm<T extends Record<string, unknown>>(
|
|
155
|
+
options: UseJsonFormOptions<T> = {}
|
|
156
|
+
): UseJsonFormReturn<T> {
|
|
157
|
+
const {
|
|
158
|
+
initialValues = {} as T,
|
|
159
|
+
confirmOnLeave = false,
|
|
160
|
+
confirmMessage = 'You have unsaved changes. Are you sure you want to leave?',
|
|
161
|
+
autoSave,
|
|
162
|
+
onChange,
|
|
163
|
+
} = options;
|
|
164
|
+
|
|
165
|
+
// Form ref
|
|
166
|
+
const formRef = ref<JsonSchemaFormInstance | null>(null);
|
|
167
|
+
|
|
168
|
+
// Current values
|
|
169
|
+
const values = ref<T>(deepClone(initialValues)) as Ref<T>;
|
|
170
|
+
|
|
171
|
+
// Original values (for dirty checking)
|
|
172
|
+
const originalValues = ref<T>(deepClone(initialValues)) as Ref<T>;
|
|
173
|
+
|
|
174
|
+
// Computed dirty state
|
|
175
|
+
const isDirty = computed(() => !deepEqual(values.value, originalValues.value));
|
|
176
|
+
|
|
177
|
+
// Validate form
|
|
178
|
+
function validate(): Array<{ field: string; message: string }> {
|
|
179
|
+
if (formRef.value?.validate) {
|
|
180
|
+
return formRef.value.validate();
|
|
181
|
+
}
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Reset to original values
|
|
186
|
+
function reset(): void {
|
|
187
|
+
values.value = deepClone(originalValues.value);
|
|
188
|
+
if (formRef.value?.reset) {
|
|
189
|
+
formRef.value.reset();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Update both current and original values
|
|
194
|
+
function setValues(newValues: Partial<T>): void {
|
|
195
|
+
const merged = { ...values.value, ...newValues } as T;
|
|
196
|
+
values.value = merged;
|
|
197
|
+
originalValues.value = deepClone(merged);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Mark current state as saved
|
|
201
|
+
function markAsSaved(): void {
|
|
202
|
+
originalValues.value = deepClone(values.value);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Handle beforeunload for confirmOnLeave
|
|
206
|
+
function handleBeforeUnload(e: BeforeUnloadEvent): string | undefined {
|
|
207
|
+
if (isDirty.value) {
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
e.returnValue = confirmMessage;
|
|
210
|
+
return confirmMessage;
|
|
211
|
+
}
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Setup auto-save
|
|
216
|
+
const debouncedAutoSave = autoSave?.enabled
|
|
217
|
+
? debounce(async (newValues: T) => {
|
|
218
|
+
try {
|
|
219
|
+
await autoSave.onAutoSave(newValues);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
console.error('Auto-save failed:', error);
|
|
222
|
+
}
|
|
223
|
+
}, autoSave.debounceMs ?? 2000)
|
|
224
|
+
: null;
|
|
225
|
+
|
|
226
|
+
// Watch for value changes
|
|
227
|
+
watch(
|
|
228
|
+
values,
|
|
229
|
+
(newValues) => {
|
|
230
|
+
onChange?.(newValues);
|
|
231
|
+
if (debouncedAutoSave && isDirty.value) {
|
|
232
|
+
debouncedAutoSave(newValues);
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
{ deep: true }
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
// Setup/cleanup beforeunload listener
|
|
239
|
+
onMounted(() => {
|
|
240
|
+
if (confirmOnLeave) {
|
|
241
|
+
window.addEventListener('beforeunload', handleBeforeUnload);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
onUnmounted(() => {
|
|
246
|
+
if (confirmOnLeave) {
|
|
247
|
+
window.removeEventListener('beforeunload', handleBeforeUnload);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
formRef,
|
|
253
|
+
values,
|
|
254
|
+
originalValues,
|
|
255
|
+
isDirty,
|
|
256
|
+
validate,
|
|
257
|
+
reset,
|
|
258
|
+
setValues,
|
|
259
|
+
markAsSaved,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { useModal } from './useModal';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Unit tests for useModal composable
|
|
6
|
+
* Tests data context support and type safety
|
|
7
|
+
*/
|
|
8
|
+
describe('useModal composable', () => {
|
|
9
|
+
describe('data context', () => {
|
|
10
|
+
it('initializes data as null by default', () => {
|
|
11
|
+
const { data } = useModal();
|
|
12
|
+
expect(data.value).toBe(null);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('initializes with provided initialData', () => {
|
|
16
|
+
const initialData = { id: 1, name: 'Test' };
|
|
17
|
+
const { data } = useModal({ initialData });
|
|
18
|
+
expect(data.value).toEqual(initialData);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('sets data when opening with context', () => {
|
|
22
|
+
interface Contact {
|
|
23
|
+
id: number;
|
|
24
|
+
name: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const modal = useModal<Contact>();
|
|
28
|
+
const contact = { id: 1, name: 'John' };
|
|
29
|
+
|
|
30
|
+
modal.open(contact);
|
|
31
|
+
|
|
32
|
+
expect(modal.isOpen.value).toBe(true);
|
|
33
|
+
expect(modal.data.value).toEqual(contact);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('opens without data when no context provided', () => {
|
|
37
|
+
const modal = useModal<{ id: number }>();
|
|
38
|
+
|
|
39
|
+
modal.open();
|
|
40
|
+
|
|
41
|
+
expect(modal.isOpen.value).toBe(true);
|
|
42
|
+
expect(modal.data.value).toBe(null);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('preserves existing data when opening without context', () => {
|
|
46
|
+
const modal = useModal<{ id: number }>({
|
|
47
|
+
initialData: { id: 1 },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
modal.open();
|
|
51
|
+
|
|
52
|
+
expect(modal.data.value).toEqual({ id: 1 });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('clears data when closing', () => {
|
|
56
|
+
const modal = useModal<{ id: number }>();
|
|
57
|
+
|
|
58
|
+
modal.open({ id: 1 });
|
|
59
|
+
expect(modal.data.value).toEqual({ id: 1 });
|
|
60
|
+
|
|
61
|
+
modal.close();
|
|
62
|
+
expect(modal.data.value).toBe(null);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('passes context to onOpen callback', () => {
|
|
66
|
+
const onOpen = vi.fn();
|
|
67
|
+
const modal = useModal<{ id: number }>({ onOpen });
|
|
68
|
+
const context = { id: 1 };
|
|
69
|
+
|
|
70
|
+
modal.open(context);
|
|
71
|
+
|
|
72
|
+
expect(onOpen).toHaveBeenCalledWith(context);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('calls onOpen without context when opening without data', () => {
|
|
76
|
+
const onOpen = vi.fn();
|
|
77
|
+
const modal = useModal<{ id: number }>({ onOpen });
|
|
78
|
+
|
|
79
|
+
modal.open();
|
|
80
|
+
|
|
81
|
+
expect(onOpen).toHaveBeenCalledWith(undefined);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('setData', () => {
|
|
86
|
+
it('updates data without changing open state', () => {
|
|
87
|
+
const modal = useModal<{ id: number; name?: string }>();
|
|
88
|
+
|
|
89
|
+
modal.open({ id: 1 });
|
|
90
|
+
expect(modal.isOpen.value).toBe(true);
|
|
91
|
+
|
|
92
|
+
modal.setData({ id: 1, name: 'Updated' });
|
|
93
|
+
|
|
94
|
+
expect(modal.isOpen.value).toBe(true);
|
|
95
|
+
expect(modal.data.value).toEqual({ id: 1, name: 'Updated' });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('can set data to null', () => {
|
|
99
|
+
const modal = useModal<{ id: number }>();
|
|
100
|
+
|
|
101
|
+
modal.open({ id: 1 });
|
|
102
|
+
modal.setData(null);
|
|
103
|
+
|
|
104
|
+
expect(modal.isOpen.value).toBe(true);
|
|
105
|
+
expect(modal.data.value).toBe(null);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('works when modal is closed', () => {
|
|
109
|
+
const modal = useModal<{ id: number }>();
|
|
110
|
+
|
|
111
|
+
modal.setData({ id: 1 });
|
|
112
|
+
|
|
113
|
+
expect(modal.isOpen.value).toBe(false);
|
|
114
|
+
expect(modal.data.value).toEqual({ id: 1 });
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('toggle with data', () => {
|
|
119
|
+
it('clears data when toggling closed', () => {
|
|
120
|
+
const modal = useModal<{ id: number }>();
|
|
121
|
+
|
|
122
|
+
modal.open({ id: 1 });
|
|
123
|
+
expect(modal.data.value).toEqual({ id: 1 });
|
|
124
|
+
|
|
125
|
+
modal.toggle(); // closes
|
|
126
|
+
expect(modal.isOpen.value).toBe(false);
|
|
127
|
+
expect(modal.data.value).toBe(null);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('preserves null data when toggling open', () => {
|
|
131
|
+
const modal = useModal<{ id: number }>();
|
|
132
|
+
|
|
133
|
+
modal.toggle(); // opens
|
|
134
|
+
expect(modal.isOpen.value).toBe(true);
|
|
135
|
+
expect(modal.data.value).toBe(null);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('type safety', () => {
|
|
140
|
+
it('supports generic type parameter', () => {
|
|
141
|
+
interface User {
|
|
142
|
+
id: number;
|
|
143
|
+
name: string;
|
|
144
|
+
email: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const modal = useModal<User>();
|
|
148
|
+
|
|
149
|
+
const user: User = { id: 1, name: 'John', email: 'john@example.com' };
|
|
150
|
+
modal.open(user);
|
|
151
|
+
|
|
152
|
+
// TypeScript should infer correct type
|
|
153
|
+
expect(modal.data.value?.id).toBe(1);
|
|
154
|
+
expect(modal.data.value?.name).toBe('John');
|
|
155
|
+
expect(modal.data.value?.email).toBe('john@example.com');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('works without generic type', () => {
|
|
159
|
+
const modal = useModal();
|
|
160
|
+
|
|
161
|
+
modal.open({ anyProperty: 'value' });
|
|
162
|
+
expect(modal.data.value).toEqual({ anyProperty: 'value' });
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('basic operations', () => {
|
|
167
|
+
it('opens and closes correctly', () => {
|
|
168
|
+
const modal = useModal();
|
|
169
|
+
|
|
170
|
+
expect(modal.isOpen.value).toBe(false);
|
|
171
|
+
|
|
172
|
+
modal.open();
|
|
173
|
+
expect(modal.isOpen.value).toBe(true);
|
|
174
|
+
|
|
175
|
+
modal.close();
|
|
176
|
+
expect(modal.isOpen.value).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('toggles correctly', () => {
|
|
180
|
+
const modal = useModal();
|
|
181
|
+
|
|
182
|
+
modal.toggle();
|
|
183
|
+
expect(modal.isOpen.value).toBe(true);
|
|
184
|
+
|
|
185
|
+
modal.toggle();
|
|
186
|
+
expect(modal.isOpen.value).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('respects initialOpen option', () => {
|
|
190
|
+
const modal = useModal({ initialOpen: true });
|
|
191
|
+
expect(modal.isOpen.value).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('calls onClose callback', () => {
|
|
195
|
+
const onClose = vi.fn();
|
|
196
|
+
const modal = useModal({ onClose });
|
|
197
|
+
|
|
198
|
+
modal.open();
|
|
199
|
+
modal.close();
|
|
200
|
+
|
|
201
|
+
expect(onClose).toHaveBeenCalled();
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('use case: edit modal', () => {
|
|
206
|
+
interface Contact {
|
|
207
|
+
id: number;
|
|
208
|
+
name: string;
|
|
209
|
+
email: string;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
it('handles edit workflow correctly', () => {
|
|
213
|
+
const editModal = useModal<Contact>();
|
|
214
|
+
|
|
215
|
+
// Click edit on contact
|
|
216
|
+
const contactToEdit = { id: 1, name: 'John', email: 'john@example.com' };
|
|
217
|
+
editModal.open(contactToEdit);
|
|
218
|
+
|
|
219
|
+
expect(editModal.isOpen.value).toBe(true);
|
|
220
|
+
expect(editModal.data.value).toEqual(contactToEdit);
|
|
221
|
+
|
|
222
|
+
// User closes modal
|
|
223
|
+
editModal.close();
|
|
224
|
+
|
|
225
|
+
expect(editModal.isOpen.value).toBe(false);
|
|
226
|
+
expect(editModal.data.value).toBe(null);
|
|
227
|
+
|
|
228
|
+
// Edit different contact
|
|
229
|
+
const anotherContact = { id: 2, name: 'Jane', email: 'jane@example.com' };
|
|
230
|
+
editModal.open(anotherContact);
|
|
231
|
+
|
|
232
|
+
expect(editModal.data.value).toEqual(anotherContact);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('use case: confirmation modal', () => {
|
|
237
|
+
interface ConfirmationData {
|
|
238
|
+
title: string;
|
|
239
|
+
message: string;
|
|
240
|
+
itemId: number;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
it('handles confirmation workflow correctly', () => {
|
|
244
|
+
const confirmModal = useModal<ConfirmationData>();
|
|
245
|
+
|
|
246
|
+
// Show confirmation for delete
|
|
247
|
+
confirmModal.open({
|
|
248
|
+
title: 'Delete Item',
|
|
249
|
+
message: 'Are you sure?',
|
|
250
|
+
itemId: 123,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expect(confirmModal.isOpen.value).toBe(true);
|
|
254
|
+
expect(confirmModal.data.value?.itemId).toBe(123);
|
|
255
|
+
|
|
256
|
+
// User confirms
|
|
257
|
+
const itemToDelete = confirmModal.data.value?.itemId;
|
|
258
|
+
confirmModal.close();
|
|
259
|
+
|
|
260
|
+
expect(itemToDelete).toBe(123);
|
|
261
|
+
expect(confirmModal.isOpen.value).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -1,28 +1,68 @@
|
|
|
1
1
|
import { ref, type Ref } from 'vue';
|
|
2
2
|
|
|
3
|
-
export interface UseModalOptions {
|
|
3
|
+
export interface UseModalOptions<T = unknown> {
|
|
4
4
|
initialOpen?: boolean;
|
|
5
|
-
|
|
5
|
+
initialData?: T | null;
|
|
6
|
+
onOpen?: (data?: T) => void;
|
|
6
7
|
onClose?: () => void;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
|
-
export interface UseModalReturn {
|
|
10
|
+
export interface UseModalReturn<T = unknown> {
|
|
11
|
+
/** Whether the modal is open */
|
|
10
12
|
isOpen: Ref<boolean>;
|
|
11
|
-
|
|
13
|
+
/** Data context passed when opening the modal */
|
|
14
|
+
data: Ref<T | null>;
|
|
15
|
+
/** Open the modal, optionally with data context */
|
|
16
|
+
open: (context?: T) => void;
|
|
17
|
+
/** Close the modal and clear data */
|
|
12
18
|
close: () => void;
|
|
19
|
+
/** Toggle modal open/closed state */
|
|
13
20
|
toggle: () => void;
|
|
21
|
+
/** Update the data without changing open state */
|
|
22
|
+
setData: (newData: T | null) => void;
|
|
14
23
|
}
|
|
15
24
|
|
|
16
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Modal state management composable with data context support.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```typescript
|
|
30
|
+
* // Type-safe modal for editing contacts
|
|
31
|
+
* const contactModal = useModal<Contact>();
|
|
32
|
+
*
|
|
33
|
+
* // Open with data
|
|
34
|
+
* contactModal.open(selectedContact);
|
|
35
|
+
*
|
|
36
|
+
* // Access in template
|
|
37
|
+
* <Modal :open="contactModal.isOpen.value">
|
|
38
|
+
* <p>Editing: {{ contactModal.data.value?.name }}</p>
|
|
39
|
+
* </Modal>
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```typescript
|
|
44
|
+
* // With callbacks
|
|
45
|
+
* const modal = useModal({
|
|
46
|
+
* onOpen: (data) => console.log('Opened with:', data),
|
|
47
|
+
* onClose: () => console.log('Closed'),
|
|
48
|
+
* });
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export function useModal<T = unknown>(options: UseModalOptions<T> = {}): UseModalReturn<T> {
|
|
17
52
|
const isOpen = ref(options.initialOpen ?? false);
|
|
53
|
+
const data = ref<T | null>(options.initialData ?? null) as Ref<T | null>;
|
|
18
54
|
|
|
19
|
-
function open() {
|
|
55
|
+
function open(context?: T) {
|
|
56
|
+
if (context !== undefined) {
|
|
57
|
+
data.value = context;
|
|
58
|
+
}
|
|
20
59
|
isOpen.value = true;
|
|
21
|
-
options.onOpen?.();
|
|
60
|
+
options.onOpen?.(context);
|
|
22
61
|
}
|
|
23
62
|
|
|
24
63
|
function close() {
|
|
25
64
|
isOpen.value = false;
|
|
65
|
+
data.value = null;
|
|
26
66
|
options.onClose?.();
|
|
27
67
|
}
|
|
28
68
|
|
|
@@ -34,10 +74,16 @@ export function useModal(options: UseModalOptions = {}): UseModalReturn {
|
|
|
34
74
|
}
|
|
35
75
|
}
|
|
36
76
|
|
|
77
|
+
function setData(newData: T | null) {
|
|
78
|
+
data.value = newData;
|
|
79
|
+
}
|
|
80
|
+
|
|
37
81
|
return {
|
|
38
82
|
isOpen,
|
|
83
|
+
data,
|
|
39
84
|
open,
|
|
40
85
|
close,
|
|
41
|
-
toggle
|
|
86
|
+
toggle,
|
|
87
|
+
setData,
|
|
42
88
|
};
|
|
43
89
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as DataList } from './DataList.vue';
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { computed, toRef, watch } from "vue";
|
|
10
|
-
import Table from "
|
|
11
|
-
import { useTable, type SmartFilter } from "
|
|
10
|
+
import Table from "../Table/Table.vue";
|
|
11
|
+
import { useTable, type SmartFilter } from "../../composables/useTable";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Column definition for DataTable
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as SearchableSelect } from './SearchableSelect.vue';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as Table } from './Table.vue';
|
package/src/data/index.ts
CHANGED
|
@@ -1,18 +1,8 @@
|
|
|
1
|
-
export
|
|
2
|
-
export
|
|
3
|
-
export
|
|
4
|
-
export
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
// DataTable - Simplified table with automatic useTable integration
|
|
8
|
-
export { default as DataTable } from './DataTable.vue';
|
|
9
|
-
export type {
|
|
10
|
-
DataTableColumn,
|
|
11
|
-
DataTableFilter,
|
|
12
|
-
BulkAction,
|
|
13
|
-
RowAction,
|
|
14
|
-
NoResultsConfig,
|
|
15
|
-
} from './DataTable.vue';
|
|
1
|
+
export * from './Table';
|
|
2
|
+
export * from './DataList';
|
|
3
|
+
export * from './SearchableSelect';
|
|
4
|
+
export * from './Chart';
|
|
5
|
+
export * from './DataTable';
|
|
16
6
|
|
|
17
7
|
// Column definition helpers
|
|
18
8
|
export {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as BrandCard } from './BrandCard.vue';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as BrandSelector } from './BrandSelector.vue';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as ProductBadge } from './ProductBadge.vue';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as UserAvatar } from './UserAvatar.vue';
|
package/src/domain/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export
|
|
2
|
-
export
|
|
3
|
-
export
|
|
4
|
-
export
|
|
1
|
+
export * from './BrandSelector';
|
|
2
|
+
export * from './BrandCard';
|
|
3
|
+
export * from './UserAvatar';
|
|
4
|
+
export * from './ProductBadge';
|