qdadm 0.13.0

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.
Files changed (82) hide show
  1. package/CHANGELOG.md +270 -0
  2. package/LICENSE +21 -0
  3. package/README.md +166 -0
  4. package/package.json +48 -0
  5. package/src/assets/logo.svg +6 -0
  6. package/src/components/BoolCell.vue +28 -0
  7. package/src/components/dialogs/BulkStatusDialog.vue +43 -0
  8. package/src/components/dialogs/MultiStepDialog.vue +321 -0
  9. package/src/components/dialogs/SimpleDialog.vue +108 -0
  10. package/src/components/dialogs/UnsavedChangesDialog.vue +87 -0
  11. package/src/components/display/CardsGrid.vue +155 -0
  12. package/src/components/display/CopyableId.vue +92 -0
  13. package/src/components/display/EmptyState.vue +114 -0
  14. package/src/components/display/IntensityBar.vue +171 -0
  15. package/src/components/display/RichCardsGrid.vue +220 -0
  16. package/src/components/editors/JsonEditorFoldable.vue +467 -0
  17. package/src/components/editors/JsonStructuredField.vue +218 -0
  18. package/src/components/editors/JsonViewer.vue +91 -0
  19. package/src/components/editors/KeyValueEditor.vue +314 -0
  20. package/src/components/editors/LanguageEditor.vue +245 -0
  21. package/src/components/editors/ScopeEditor.vue +341 -0
  22. package/src/components/editors/VanillaJsonEditor.vue +185 -0
  23. package/src/components/forms/FormActions.vue +104 -0
  24. package/src/components/forms/FormField.vue +64 -0
  25. package/src/components/forms/FormTab.vue +217 -0
  26. package/src/components/forms/FormTabs.vue +108 -0
  27. package/src/components/index.js +44 -0
  28. package/src/components/layout/AppLayout.vue +430 -0
  29. package/src/components/layout/Breadcrumb.vue +106 -0
  30. package/src/components/layout/PageHeader.vue +75 -0
  31. package/src/components/layout/PageLayout.vue +93 -0
  32. package/src/components/lists/ActionButtons.vue +41 -0
  33. package/src/components/lists/ActionColumn.vue +37 -0
  34. package/src/components/lists/FilterBar.vue +53 -0
  35. package/src/components/lists/ListPage.vue +319 -0
  36. package/src/composables/index.js +19 -0
  37. package/src/composables/useApp.js +43 -0
  38. package/src/composables/useAuth.js +49 -0
  39. package/src/composables/useBareForm.js +143 -0
  40. package/src/composables/useBreadcrumb.js +221 -0
  41. package/src/composables/useDirtyState.js +103 -0
  42. package/src/composables/useEntityTitle.js +121 -0
  43. package/src/composables/useForm.js +254 -0
  44. package/src/composables/useGuardStore.js +37 -0
  45. package/src/composables/useJsonSyntax.js +101 -0
  46. package/src/composables/useListPageBuilder.js +1176 -0
  47. package/src/composables/useNavigation.js +89 -0
  48. package/src/composables/usePageBuilder.js +334 -0
  49. package/src/composables/useStatus.js +146 -0
  50. package/src/composables/useSubEditor.js +165 -0
  51. package/src/composables/useTabSync.js +110 -0
  52. package/src/composables/useUnsavedChangesGuard.js +122 -0
  53. package/src/entity/EntityManager.js +540 -0
  54. package/src/entity/index.js +11 -0
  55. package/src/entity/storage/ApiStorage.js +146 -0
  56. package/src/entity/storage/LocalStorage.js +220 -0
  57. package/src/entity/storage/MemoryStorage.js +201 -0
  58. package/src/entity/storage/index.js +10 -0
  59. package/src/index.js +29 -0
  60. package/src/kernel/Kernel.js +234 -0
  61. package/src/kernel/index.js +7 -0
  62. package/src/module/index.js +16 -0
  63. package/src/module/moduleRegistry.js +222 -0
  64. package/src/orchestrator/Orchestrator.js +141 -0
  65. package/src/orchestrator/index.js +8 -0
  66. package/src/orchestrator/useOrchestrator.js +61 -0
  67. package/src/plugin.js +142 -0
  68. package/src/styles/_alerts.css +48 -0
  69. package/src/styles/_code.css +33 -0
  70. package/src/styles/_dialogs.css +17 -0
  71. package/src/styles/_markdown.css +82 -0
  72. package/src/styles/_show-pages.css +84 -0
  73. package/src/styles/index.css +16 -0
  74. package/src/styles/main.css +845 -0
  75. package/src/styles/theme/components.css +286 -0
  76. package/src/styles/theme/index.css +10 -0
  77. package/src/styles/theme/tokens.css +125 -0
  78. package/src/styles/theme/utilities.css +172 -0
  79. package/src/utils/debugInjector.js +261 -0
  80. package/src/utils/formatters.js +165 -0
  81. package/src/utils/index.js +35 -0
  82. package/src/utils/transformers.js +105 -0
@@ -0,0 +1,341 @@
1
+ <script setup>
2
+ import { ref, onMounted, watch, inject } from 'vue'
3
+ import AutoComplete from 'primevue/autocomplete'
4
+ import Select from 'primevue/select'
5
+ import Button from 'primevue/button'
6
+
7
+ const props = defineProps({
8
+ modelValue: {
9
+ type: Array,
10
+ default: () => []
11
+ },
12
+ disabled: {
13
+ type: Boolean,
14
+ default: false
15
+ },
16
+ // Scope configuration
17
+ scopeEndpoint: {
18
+ type: String,
19
+ default: '/reference/scopes' // Endpoint to load scope definition from API
20
+ },
21
+ scopePrefix: {
22
+ type: String,
23
+ default: 'faketual' // Prefix for scope strings (e.g., "faketual.resource:action")
24
+ },
25
+ // Default resources/actions if API not available
26
+ defaultResources: {
27
+ type: Array,
28
+ default: () => ['api', 'users', 'roles', 'apikeys']
29
+ },
30
+ defaultActions: {
31
+ type: Array,
32
+ default: () => ['read', 'write', 'grant']
33
+ }
34
+ })
35
+
36
+ // Get API adapter (optional)
37
+ const api = inject('apiAdapter', null)
38
+
39
+ const emit = defineEmits(['update:modelValue'])
40
+
41
+ // Scope structure from API
42
+ const scopeDefinition = ref({
43
+ resources: [],
44
+ actions: [],
45
+ })
46
+ const loading = ref(true)
47
+
48
+ // Local scopes state
49
+ const scopeRows = ref([])
50
+
51
+ // Resource suggestions for autocomplete
52
+ const resourceSuggestions = ref([])
53
+
54
+ // All resources with * prefix
55
+ const allResources = computed(() => ['*', ...scopeDefinition.value.resources])
56
+
57
+ // All actions with access prefix
58
+ const allActions = computed(() => ['access', ...scopeDefinition.value.actions])
59
+
60
+ import { computed } from 'vue'
61
+
62
+ /**
63
+ * Search resources for autocomplete
64
+ */
65
+ function searchResources(event) {
66
+ const query = (event.query || '').toLowerCase()
67
+ if (!query) {
68
+ resourceSuggestions.value = [...allResources.value]
69
+ } else {
70
+ resourceSuggestions.value = allResources.value.filter(r =>
71
+ r.toLowerCase().includes(query)
72
+ )
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Parse a scope string into resource and action
78
+ */
79
+ function parseScope(scope) {
80
+ if (!scope) return { resource: '', action: '' }
81
+ // prefix.resource:action
82
+ const regex = new RegExp(`^${props.scopePrefix}\\.([^:]+):(.+)$`)
83
+ const match = scope.match(regex)
84
+ if (match) {
85
+ return { resource: match[1], action: match[2] }
86
+ }
87
+ return { resource: '', action: '' }
88
+ }
89
+
90
+ /**
91
+ * Build scope string from resource and action
92
+ */
93
+ function buildScope(resource, action) {
94
+ if (!resource || !action) return ''
95
+ return `${props.scopePrefix}.${resource}:${action}`
96
+ }
97
+
98
+ // Initialize from modelValue
99
+ function initFromValue() {
100
+ if (!props.modelValue || props.modelValue.length === 0) {
101
+ scopeRows.value = []
102
+ return
103
+ }
104
+ scopeRows.value = props.modelValue.map(scope => {
105
+ const { resource, action } = parseScope(scope)
106
+ return { resource, action }
107
+ })
108
+ }
109
+
110
+ // Emit changes - only complete scopes
111
+ function emitChanges() {
112
+ const scopes = scopeRows.value
113
+ .map(row => buildScope(row.resource, row.action))
114
+ .filter(s => s) // Filter out empty
115
+ emit('update:modelValue', scopes)
116
+ }
117
+
118
+ // Add new scope row
119
+ function addRow() {
120
+ scopeRows.value.push({ resource: '', action: 'read' })
121
+ }
122
+
123
+ // Remove scope row
124
+ function removeRow(index) {
125
+ scopeRows.value.splice(index, 1)
126
+ emitChanges()
127
+ }
128
+
129
+ // Update resource
130
+ function updateResource(index, value) {
131
+ scopeRows.value[index].resource = value
132
+ if (value && scopeRows.value[index].action) {
133
+ emitChanges()
134
+ }
135
+ }
136
+
137
+ // Update action
138
+ function updateAction(index, value) {
139
+ scopeRows.value[index].action = value
140
+ if (scopeRows.value[index].resource && value) {
141
+ emitChanges()
142
+ }
143
+ }
144
+
145
+ // Check if row is complete
146
+ function isRowComplete(row) {
147
+ return row.resource && row.action
148
+ }
149
+
150
+ // Load scope definition from API
151
+ async function loadScopeDefinition() {
152
+ loading.value = true
153
+ try {
154
+ if (!api) {
155
+ // No API adapter, use defaults
156
+ scopeDefinition.value = {
157
+ resources: [...props.defaultResources],
158
+ actions: [...props.defaultActions],
159
+ }
160
+ return
161
+ }
162
+
163
+ const data = await api.request('GET', props.scopeEndpoint)
164
+ scopeDefinition.value = {
165
+ resources: data.resources || [...props.defaultResources],
166
+ actions: data.actions || [...props.defaultActions],
167
+ }
168
+ } catch (error) {
169
+ console.error('[ScopeEditor] Failed to load scope definition:', error)
170
+ scopeDefinition.value = {
171
+ resources: [...props.defaultResources],
172
+ actions: [...props.defaultActions],
173
+ }
174
+ } finally {
175
+ loading.value = false
176
+ }
177
+ }
178
+
179
+ // Watch for external changes
180
+ watch(() => props.modelValue, (newVal) => {
181
+ // Compare to avoid loops
182
+ const currentScopes = scopeRows.value
183
+ .map(row => buildScope(row.resource, row.action))
184
+ .filter(s => s)
185
+ .sort()
186
+ .join(',')
187
+ const newScopes = (newVal || []).sort().join(',')
188
+
189
+ if (currentScopes !== newScopes) {
190
+ initFromValue()
191
+ }
192
+ }, { deep: true })
193
+
194
+ onMounted(async () => {
195
+ await loadScopeDefinition()
196
+ initFromValue()
197
+ })
198
+ </script>
199
+
200
+ <template>
201
+ <div class="scope-editor">
202
+ <div v-if="scopeRows.length === 0" class="scope-empty">
203
+ <span class="text-surface-400">No scopes defined</span>
204
+ </div>
205
+
206
+ <div v-for="(row, index) in scopeRows" :key="index" class="scope-row">
207
+ <span class="scope-prefix">{{ scopePrefix }}.</span>
208
+ <AutoComplete
209
+ v-model="row.resource"
210
+ :suggestions="resourceSuggestions"
211
+ @complete="searchResources"
212
+ @change="updateResource(index, row.resource)"
213
+ @item-select="(e) => updateResource(index, e.value)"
214
+ :disabled="disabled || loading"
215
+ placeholder="resource"
216
+ class="scope-resource"
217
+ :dropdown="true"
218
+ :minLength="0"
219
+ />
220
+ <span class="scope-separator">:</span>
221
+ <Select
222
+ v-model="row.action"
223
+ :options="allActions"
224
+ @change="updateAction(index, row.action)"
225
+ :disabled="disabled || loading"
226
+ placeholder="action"
227
+ class="scope-action"
228
+ />
229
+ <span v-if="isRowComplete(row)" class="scope-valid">✓</span>
230
+ <span v-else class="scope-incomplete">...</span>
231
+ <Button
232
+ icon="pi pi-trash"
233
+ severity="danger"
234
+ text
235
+ rounded
236
+ size="small"
237
+ :disabled="disabled"
238
+ @click="removeRow(index)"
239
+ class="scope-remove"
240
+ />
241
+ </div>
242
+
243
+ <div class="scope-add">
244
+ <Button
245
+ label="Add Scope"
246
+ icon="pi pi-plus"
247
+ severity="secondary"
248
+ text
249
+ size="small"
250
+ :disabled="disabled"
251
+ @click="addRow"
252
+ />
253
+ </div>
254
+ </div>
255
+ </template>
256
+
257
+ <style scoped>
258
+ .scope-editor {
259
+ border: 1px solid var(--p-surface-200);
260
+ border-radius: 0.5rem;
261
+ padding: 0.75rem;
262
+ background: var(--p-surface-50);
263
+ }
264
+
265
+ .scope-empty {
266
+ padding: 1rem;
267
+ text-align: center;
268
+ font-size: 0.875rem;
269
+ }
270
+
271
+ .scope-row {
272
+ display: flex;
273
+ align-items: center;
274
+ gap: 0.25rem;
275
+ margin-bottom: 0.5rem;
276
+ }
277
+
278
+ .scope-prefix {
279
+ font-family: monospace;
280
+ font-size: 0.875rem;
281
+ color: var(--p-surface-500);
282
+ flex-shrink: 0;
283
+ }
284
+
285
+ .scope-separator {
286
+ font-family: monospace;
287
+ font-size: 0.875rem;
288
+ color: var(--p-surface-500);
289
+ flex-shrink: 0;
290
+ }
291
+
292
+ .scope-resource {
293
+ flex: 1 1 auto !important;
294
+ min-width: 200px !important;
295
+ }
296
+
297
+ .scope-action {
298
+ width: 120px !important;
299
+ min-width: 120px !important;
300
+ flex: 0 0 120px !important;
301
+ }
302
+
303
+ .scope-valid {
304
+ color: var(--p-green-500);
305
+ font-weight: bold;
306
+ width: 20px;
307
+ text-align: center;
308
+ }
309
+
310
+ .scope-incomplete {
311
+ color: var(--p-surface-400);
312
+ width: 20px;
313
+ text-align: center;
314
+ }
315
+
316
+ .scope-remove {
317
+ flex-shrink: 0;
318
+ }
319
+
320
+ .scope-add {
321
+ margin-top: 0.5rem;
322
+ padding-top: 0.5rem;
323
+ border-top: 1px solid var(--p-surface-200);
324
+ }
325
+
326
+ :deep(.scope-resource .p-autocomplete) {
327
+ width: 100% !important;
328
+ }
329
+
330
+ :deep(.scope-resource .p-autocomplete-input) {
331
+ width: 100%;
332
+ font-family: monospace;
333
+ font-size: 0.875rem;
334
+ }
335
+
336
+ :deep(.scope-action .p-select) {
337
+ width: 120px !important;
338
+ font-family: monospace;
339
+ font-size: 0.875rem;
340
+ }
341
+ </style>
@@ -0,0 +1,185 @@
1
+ <script setup>
2
+ /**
3
+ * VanillaJsonEditor - JSON editor using vanilla-jsoneditor
4
+ *
5
+ * Provides tree/text/table modes like jsoneditoronline.org
6
+ *
7
+ * Usage:
8
+ * <VanillaJsonEditor v-model="jsonData" mode="tree" />
9
+ */
10
+
11
+ import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
12
+ import { JSONEditor } from 'vanilla-jsoneditor'
13
+
14
+ const props = defineProps({
15
+ modelValue: {
16
+ type: [Object, Array, String, null],
17
+ default: () => ({})
18
+ },
19
+ mode: {
20
+ type: String,
21
+ default: 'tree', // 'tree', 'text', 'table'
22
+ validator: (v) => ['tree', 'text', 'table'].includes(v)
23
+ },
24
+ height: {
25
+ type: String,
26
+ default: '400px'
27
+ },
28
+ readOnly: {
29
+ type: Boolean,
30
+ default: false
31
+ },
32
+ mainMenuBar: {
33
+ type: Boolean,
34
+ default: true
35
+ },
36
+ navigationBar: {
37
+ type: Boolean,
38
+ default: true
39
+ },
40
+ statusBar: {
41
+ type: Boolean,
42
+ default: true
43
+ }
44
+ })
45
+
46
+ const emit = defineEmits(['update:modelValue', 'change', 'error'])
47
+
48
+ const containerRef = ref(null)
49
+ let editor = null
50
+ // Flag to prevent onChange from firing during programmatic updates (instance-specific)
51
+ const updatingFromProp = ref(false)
52
+
53
+ // Parse value to ensure it's an object/array
54
+ function parseValue(val) {
55
+ if (val === null || val === undefined) {
56
+ return {}
57
+ }
58
+ if (typeof val === 'string') {
59
+ try {
60
+ return JSON.parse(val)
61
+ } catch {
62
+ return {}
63
+ }
64
+ }
65
+ return val
66
+ }
67
+
68
+ // Initialize editor
69
+ onMounted(() => {
70
+ if (!containerRef.value) return
71
+
72
+ const content = {
73
+ json: parseValue(props.modelValue)
74
+ }
75
+
76
+ editor = new JSONEditor({
77
+ target: containerRef.value,
78
+ props: {
79
+ content,
80
+ mode: props.mode,
81
+ readOnly: props.readOnly,
82
+ mainMenuBar: props.mainMenuBar,
83
+ navigationBar: props.navigationBar,
84
+ statusBar: props.statusBar,
85
+ onChange: (updatedContent, previousContent, { contentErrors, patchResult: _patchResult }) => {
86
+ // Skip if this change came from a programmatic prop update
87
+ if (updatingFromProp.value) return
88
+
89
+ if (contentErrors) {
90
+ emit('error', contentErrors)
91
+ return
92
+ }
93
+
94
+ // Extract JSON from content
95
+ let newValue
96
+ if (updatedContent.json !== undefined) {
97
+ newValue = updatedContent.json
98
+ } else if (updatedContent.text !== undefined) {
99
+ try {
100
+ newValue = JSON.parse(updatedContent.text)
101
+ } catch {
102
+ // Invalid JSON in text mode, don't emit
103
+ return
104
+ }
105
+ }
106
+
107
+ if (newValue !== undefined) {
108
+ emit('update:modelValue', newValue)
109
+ emit('change', newValue)
110
+ }
111
+ }
112
+ }
113
+ })
114
+ })
115
+
116
+ // Watch for external changes
117
+ watch(() => props.modelValue, (newVal) => {
118
+ if (!editor) return
119
+
120
+ const currentContent = editor.get()
121
+ const newParsed = parseValue(newVal)
122
+
123
+ // Only update if different (avoid loops)
124
+ const currentJson = currentContent.json !== undefined
125
+ ? currentContent.json
126
+ : (currentContent.text ? JSON.parse(currentContent.text) : null)
127
+
128
+ if (JSON.stringify(currentJson) !== JSON.stringify(newParsed)) {
129
+ // Set flag to prevent onChange from emitting during this update
130
+ updatingFromProp.value = true
131
+ editor.set({ json: newParsed })
132
+ // Reset flag after the update is processed
133
+ nextTick(() => {
134
+ updatingFromProp.value = false
135
+ })
136
+ }
137
+ }, { deep: true })
138
+
139
+ // Watch mode changes
140
+ watch(() => props.mode, (newMode) => {
141
+ if (editor) {
142
+ editor.updateProps({ mode: newMode })
143
+ }
144
+ })
145
+
146
+ // Watch readOnly changes
147
+ watch(() => props.readOnly, (newReadOnly) => {
148
+ if (editor) {
149
+ editor.updateProps({ readOnly: newReadOnly })
150
+ }
151
+ })
152
+
153
+ // Cleanup
154
+ onUnmounted(() => {
155
+ if (editor) {
156
+ editor.destroy()
157
+ editor = null
158
+ }
159
+ })
160
+ </script>
161
+
162
+ <template>
163
+ <div class="vanilla-json-editor" :style="{ height }">
164
+ <div ref="containerRef" class="editor-container"></div>
165
+ </div>
166
+ </template>
167
+
168
+ <style scoped>
169
+ .vanilla-json-editor {
170
+ width: 100%;
171
+ border: 1px solid var(--p-surface-300);
172
+ border-radius: 0.375rem;
173
+ overflow: hidden;
174
+ }
175
+
176
+ .editor-container {
177
+ height: 100%;
178
+ }
179
+
180
+ /* Override some vanilla-jsoneditor styles for dark mode compatibility */
181
+ :deep(.jse-main) {
182
+ --jse-theme-color: var(--p-primary-color, #10b981);
183
+ --jse-theme-color-highlight: var(--p-primary-100, #d1fae5);
184
+ }
185
+ </style>
@@ -0,0 +1,104 @@
1
+ <script setup>
2
+ /**
3
+ * FormActions - Reusable form action buttons
4
+ *
5
+ * Props:
6
+ * - isEdit: Boolean - Edit mode (changes labels)
7
+ * - saving: Boolean - Loading state
8
+ * - dirty: Boolean - Form has unsaved changes
9
+ *
10
+ * Emits:
11
+ * - save: Save and stay on form
12
+ * - saveAndClose: Save and navigate back
13
+ * - cancel: Cancel/close without saving
14
+ */
15
+
16
+ import Button from 'primevue/button'
17
+
18
+ defineProps({
19
+ isEdit: {
20
+ type: Boolean,
21
+ default: false
22
+ },
23
+ saving: {
24
+ type: Boolean,
25
+ default: false
26
+ },
27
+ dirty: {
28
+ type: Boolean,
29
+ default: true // Default true for backwards compatibility
30
+ },
31
+ showSaveAndClose: {
32
+ type: Boolean,
33
+ default: true
34
+ }
35
+ })
36
+
37
+ const emit = defineEmits(['save', 'saveAndClose', 'cancel'])
38
+ </script>
39
+
40
+ <template>
41
+ <div class="form-actions">
42
+ <div class="form-actions-left">
43
+ <Button
44
+ type="button"
45
+ :label="isEdit ? 'Update' : 'Create'"
46
+ :loading="saving"
47
+ :disabled="!dirty || saving"
48
+ icon="pi pi-check"
49
+ @click="emit('save')"
50
+ v-tooltip.top="'Save and continue editing'"
51
+ />
52
+ <Button
53
+ v-if="showSaveAndClose"
54
+ type="button"
55
+ :label="isEdit ? 'Update & Close' : 'Create & Close'"
56
+ :loading="saving"
57
+ :disabled="!dirty || saving"
58
+ icon="pi pi-check-circle"
59
+ severity="success"
60
+ @click="emit('saveAndClose')"
61
+ v-tooltip.top="'Save and return to list'"
62
+ />
63
+ <span v-if="dirty" class="dirty-indicator" v-tooltip.top="'Unsaved changes'">
64
+ <i class="pi pi-circle-fill"></i>
65
+ </span>
66
+ </div>
67
+ <Button
68
+ type="button"
69
+ label="Cancel"
70
+ severity="secondary"
71
+ icon="pi pi-times"
72
+ @click="emit('cancel')"
73
+ :disabled="saving"
74
+ />
75
+ </div>
76
+ </template>
77
+
78
+ <style scoped>
79
+ .form-actions {
80
+ display: flex;
81
+ justify-content: space-between;
82
+ align-items: center;
83
+ margin-top: 1.5rem;
84
+ padding-top: 1.5rem;
85
+ border-top: 1px solid var(--p-surface-200);
86
+ }
87
+
88
+ .form-actions-left {
89
+ display: flex;
90
+ align-items: center;
91
+ gap: 0.5rem;
92
+ }
93
+
94
+ .dirty-indicator {
95
+ color: var(--p-orange-500);
96
+ font-size: 0.5rem;
97
+ animation: pulse 2s infinite;
98
+ }
99
+
100
+ @keyframes pulse {
101
+ 0%, 100% { opacity: 1; }
102
+ 50% { opacity: 0.4; }
103
+ }
104
+ </style>
@@ -0,0 +1,64 @@
1
+ <script setup>
2
+ /**
3
+ * FormField - Wrapper for form fields with automatic dirty state styling
4
+ *
5
+ * Usage:
6
+ * <FormField name="username" label="Username *">
7
+ * <InputText v-model="form.username" />
8
+ * </FormField>
9
+ *
10
+ * The parent form must provide isFieldDirty via useForm's provideFormContext()
11
+ */
12
+ import { inject, computed } from 'vue'
13
+
14
+ const props = defineProps({
15
+ name: {
16
+ type: String,
17
+ required: true
18
+ },
19
+ label: {
20
+ type: String,
21
+ default: ''
22
+ },
23
+ hint: {
24
+ type: String,
25
+ default: ''
26
+ },
27
+ fullWidth: {
28
+ type: Boolean,
29
+ default: false
30
+ }
31
+ })
32
+
33
+ // Inject isFieldDirty from parent form (provided by useForm)
34
+ const isFieldDirty = inject('isFieldDirty', () => false)
35
+
36
+ const isDirty = computed(() => isFieldDirty(props.name))
37
+
38
+ const fieldClasses = computed(() => [
39
+ 'form-field',
40
+ {
41
+ 'field-dirty': isDirty.value
42
+ }
43
+ ])
44
+
45
+ const fieldStyle = computed(() =>
46
+ props.fullWidth ? { gridColumn: '1 / -1' } : {}
47
+ )
48
+ </script>
49
+
50
+ <template>
51
+ <div :class="fieldClasses" :style="fieldStyle">
52
+ <label v-if="label" :for="name">{{ label }}</label>
53
+ <slot ></slot>
54
+ <small v-if="hint" class="field-hint">{{ hint }}</small>
55
+ </div>
56
+ </template>
57
+
58
+ <style scoped>
59
+ .field-hint {
60
+ color: var(--p-surface-500);
61
+ margin-top: 0.25rem;
62
+ display: block;
63
+ }
64
+ </style>