@omnitend/dashboard-for-laravel 0.7.1 → 0.9.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.
- package/dist/components/extended/DXDashboard.vue.d.ts +13 -0
- package/dist/components/extended/DXDashboardSidebar.vue.d.ts +15 -0
- package/dist/components/extended/DXField.vue.d.ts +33 -16
- package/dist/components/extended/DXFieldLabel.vue.d.ts +8 -0
- package/dist/dashboard-for-laravel.js +7740 -7489
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +7 -7
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +28 -1
- package/dist/types/navigation.d.ts +6 -0
- package/docs/public/api-reference.json +59 -4
- package/docs/public/docs-map.md +1 -1
- package/docs/public/llms.txt +3 -2
- package/package.json +1 -1
- package/resources/css/theme.scss +62 -1
- package/resources/js/components/extended/DXDashboard.vue +17 -0
- package/resources/js/components/extended/DXDashboardSidebar.vue +302 -41
- package/resources/js/components/extended/DXField.vue +145 -6
- package/resources/js/components/extended/DXFieldLabel.vue +72 -0
- package/resources/js/components/extended/DXForm.vue +8 -1
- package/resources/js/components/extended/DXRepeater.vue +1 -0
- package/resources/js/components/extended/DXTable.vue +26 -5
- package/resources/js/composables/defineForm.ts +1 -0
- package/resources/js/index.ts +1 -0
- package/resources/js/types/index.ts +39 -2
- package/resources/js/types/navigation.ts +6 -0
|
@@ -22,52 +22,96 @@
|
|
|
22
22
|
|
|
23
23
|
<nav class="sidebar-nav p-3">
|
|
24
24
|
<template v-for="(group, groupIndex) in navigation" :key="groupIndex">
|
|
25
|
-
<div
|
|
25
|
+
<div
|
|
26
|
+
v-if="group.visible !== false"
|
|
27
|
+
class="nav-group mb-3"
|
|
28
|
+
:class="{ 'nav-group-open': isGroupExpanded(groupIndex, group) }"
|
|
29
|
+
>
|
|
30
|
+
<!-- Collapsible group header (accordion toggle) -->
|
|
31
|
+
<button
|
|
32
|
+
v-if="isGroupToggle(group)"
|
|
33
|
+
type="button"
|
|
34
|
+
class="nav-group-toggle text-uppercase small fw-semibold mb-2 px-2"
|
|
35
|
+
:aria-expanded="isGroupExpanded(groupIndex, group)"
|
|
36
|
+
:aria-controls="groupItemsId(groupIndex)"
|
|
37
|
+
@click="toggleGroup(groupIndex)"
|
|
38
|
+
>
|
|
39
|
+
<span class="nav-group-toggle-label">{{ group.label }}</span>
|
|
40
|
+
<svg
|
|
41
|
+
class="nav-group-chevron"
|
|
42
|
+
width="14"
|
|
43
|
+
height="14"
|
|
44
|
+
viewBox="0 0 14 14"
|
|
45
|
+
fill="none"
|
|
46
|
+
aria-hidden="true"
|
|
47
|
+
>
|
|
48
|
+
<!-- Wide, square-capped chevron matching Omni Tend's menu headers.
|
|
49
|
+
Points down when closed; rotated 180° when the group is open. -->
|
|
50
|
+
<path
|
|
51
|
+
d="M11.375 4.8125L7 9.1875L2.625 4.8125"
|
|
52
|
+
stroke="currentColor"
|
|
53
|
+
stroke-width="1.75"
|
|
54
|
+
stroke-linecap="square"
|
|
55
|
+
stroke-linejoin="round"
|
|
56
|
+
/>
|
|
57
|
+
</svg>
|
|
58
|
+
</button>
|
|
59
|
+
|
|
60
|
+
<!-- Static group label (non-collapsible, expanded sidebar) -->
|
|
26
61
|
<div
|
|
27
|
-
v-if="group.label && !collapsed"
|
|
62
|
+
v-else-if="group.label && !collapsed"
|
|
28
63
|
class="nav-group-label text-uppercase small fw-semibold mb-2 px-2"
|
|
29
64
|
>
|
|
30
65
|
{{ group.label }}
|
|
31
66
|
</div>
|
|
32
67
|
|
|
68
|
+
<!-- Divider shown in place of the label when the sidebar rail is collapsed -->
|
|
33
69
|
<div v-if="group.label && collapsed" class="nav-group-divider">
|
|
34
70
|
<hr class="my-2 border-secondary" />
|
|
35
71
|
</div>
|
|
36
72
|
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
>
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}"
|
|
73
|
+
<div
|
|
74
|
+
:id="groupItemsId(groupIndex)"
|
|
75
|
+
class="nav-group-items"
|
|
76
|
+
:class="{ 'nav-group-items--collapsible': isGroupToggle(group) }"
|
|
77
|
+
:inert="isGroupExpanded(groupIndex, group) ? undefined : true"
|
|
78
|
+
>
|
|
79
|
+
<ul class="nav flex-column gap-1">
|
|
80
|
+
<li v-for="(item, itemIndex) in group.items" :key="itemIndex" class="nav-item">
|
|
81
|
+
<slot
|
|
82
|
+
name="link"
|
|
83
|
+
:item="item"
|
|
84
|
+
:is-active="isActive(item.url)"
|
|
85
|
+
:collapsed="collapsed"
|
|
86
|
+
:is-expanded="isGroupExpanded(groupIndex, group)"
|
|
52
87
|
>
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
class="
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
<span
|
|
61
|
-
v-if="item.badge && !collapsed"
|
|
62
|
-
class="badge ms-auto"
|
|
63
|
-
:class="`bg-${item.badgeColor || 'primary'}`"
|
|
88
|
+
<a
|
|
89
|
+
:href="item.url"
|
|
90
|
+
class="nav-link d-flex align-items-center gap-2 rounded"
|
|
91
|
+
:class="{
|
|
92
|
+
'active': isActive(item.url),
|
|
93
|
+
'justify-content-center': collapsed
|
|
94
|
+
}"
|
|
64
95
|
>
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
96
|
+
<component
|
|
97
|
+
v-if="item.icon"
|
|
98
|
+
:is="item.icon"
|
|
99
|
+
class="nav-icon"
|
|
100
|
+
style="width: 20px; height: 20px;"
|
|
101
|
+
/>
|
|
102
|
+
<span v-if="!collapsed" class="nav-label">{{ item.label }}</span>
|
|
103
|
+
<span
|
|
104
|
+
v-if="item.badge && !collapsed"
|
|
105
|
+
class="badge ms-auto"
|
|
106
|
+
:class="`bg-${item.badgeColor || 'primary'}`"
|
|
107
|
+
>
|
|
108
|
+
{{ item.badge }}
|
|
109
|
+
</span>
|
|
110
|
+
</a>
|
|
111
|
+
</slot>
|
|
112
|
+
</li>
|
|
113
|
+
</ul>
|
|
114
|
+
</div>
|
|
71
115
|
</div>
|
|
72
116
|
</template>
|
|
73
117
|
</nav>
|
|
@@ -75,8 +119,8 @@
|
|
|
75
119
|
</template>
|
|
76
120
|
|
|
77
121
|
<script setup lang="ts">
|
|
78
|
-
import { computed, ref, onMounted, watch, nextTick } from 'vue';
|
|
79
|
-
import type { Navigation } from '../../types/navigation';
|
|
122
|
+
import { computed, ref, onMounted, watch, nextTick, useId } from 'vue';
|
|
123
|
+
import type { Navigation, NavigationGroup } from '../../types/navigation';
|
|
80
124
|
|
|
81
125
|
const props = withDefaults(defineProps<{
|
|
82
126
|
navigation: Navigation;
|
|
@@ -84,10 +128,24 @@ const props = withDefaults(defineProps<{
|
|
|
84
128
|
collapsed?: boolean;
|
|
85
129
|
hidden?: boolean;
|
|
86
130
|
title?: string;
|
|
131
|
+
/**
|
|
132
|
+
* Turn group headers into accordion toggles that collapse/expand their items.
|
|
133
|
+
* When off (default), every group is rendered permanently expanded.
|
|
134
|
+
*/
|
|
135
|
+
collapsibleGroups?: boolean;
|
|
136
|
+
/**
|
|
137
|
+
* Only relevant when `collapsibleGroups` is on.
|
|
138
|
+
* `true` (default): only the active-route group starts open, and opening one
|
|
139
|
+
* group closes the others (single-open accordion).
|
|
140
|
+
* `false`: all groups start open and toggle independently.
|
|
141
|
+
*/
|
|
142
|
+
autoCollapseInactiveGroups?: boolean;
|
|
87
143
|
}>(), {
|
|
88
144
|
collapsed: false,
|
|
89
145
|
hidden: false,
|
|
90
146
|
title: 'Dashboard',
|
|
147
|
+
collapsibleGroups: false,
|
|
148
|
+
autoCollapseInactiveGroups: true,
|
|
91
149
|
});
|
|
92
150
|
|
|
93
151
|
defineEmits<{
|
|
@@ -96,14 +154,115 @@ defineEmits<{
|
|
|
96
154
|
|
|
97
155
|
const sidebarRef = ref<HTMLElement | null>(null);
|
|
98
156
|
|
|
157
|
+
const uid = useId();
|
|
158
|
+
const groupItemsId = (index: number): string => `${uid}-nav-group-${index}`;
|
|
159
|
+
|
|
99
160
|
const brandInitial = computed(() => {
|
|
100
161
|
return props.title.charAt(0).toUpperCase();
|
|
101
162
|
});
|
|
102
163
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
164
|
+
// Normalize URLs for comparison: drop any query string / hash, lowercase, and
|
|
165
|
+
// remove a single trailing slash. Dropping ?query and #hash means an index page
|
|
166
|
+
// carrying filter/pagination params (e.g. `/rotas?page=2`) still matches its
|
|
167
|
+
// `/rotas` nav item.
|
|
168
|
+
const normalizeUrl = (url: string): string =>
|
|
169
|
+
url.toLowerCase().replace(/[?#].*$/, '').replace(/\/$/, '');
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The single best-matching nav item URL for the current route. Prefers an exact
|
|
173
|
+
* match; otherwise the longest ancestor path, so a detail page like
|
|
174
|
+
* `/rotas/507` activates the `/rotas` item. Root `/` only matches exactly — it
|
|
175
|
+
* is a prefix of every path, so it is never treated as an ancestor.
|
|
176
|
+
* Returns the normalized URL of the winning item, or null if nothing matches.
|
|
177
|
+
*/
|
|
178
|
+
const activeUrl = computed<string | null>(() => {
|
|
179
|
+
const current = normalizeUrl(props.currentUrl);
|
|
180
|
+
let best: string | null = null;
|
|
181
|
+
for (const group of props.navigation) {
|
|
182
|
+
if (group.visible === false) continue;
|
|
183
|
+
for (const item of group.items) {
|
|
184
|
+
if (item.visible === false) continue;
|
|
185
|
+
const candidate = normalizeUrl(item.url);
|
|
186
|
+
const matches =
|
|
187
|
+
candidate === current ||
|
|
188
|
+
(candidate !== '' && current.startsWith(candidate + '/'));
|
|
189
|
+
if (matches && (best === null || candidate.length > best.length)) {
|
|
190
|
+
best = candidate;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return best;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const isActive = (url: string): boolean =>
|
|
198
|
+
activeUrl.value !== null && normalizeUrl(url) === activeUrl.value;
|
|
199
|
+
|
|
200
|
+
// Index of the group containing the active route (-1 if none).
|
|
201
|
+
const activeGroupIndex = computed(() =>
|
|
202
|
+
props.navigation.findIndex(
|
|
203
|
+
(group) => group.visible !== false && group.items.some((item) => isActive(item.url))
|
|
204
|
+
)
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
// A group renders a clickable toggle header only when collapsible groups are
|
|
208
|
+
// enabled, the sidebar rail is expanded, the group has a label, and the group
|
|
209
|
+
// hasn't individually opted out via `collapsible: false`.
|
|
210
|
+
const isGroupToggle = (group: NavigationGroup): boolean =>
|
|
211
|
+
props.collapsibleGroups &&
|
|
212
|
+
!props.collapsed &&
|
|
213
|
+
!!group.label &&
|
|
214
|
+
group.collapsible !== false;
|
|
215
|
+
|
|
216
|
+
// Whether a group's items are currently shown. Rail-collapsed sidebars always
|
|
217
|
+
// show items (as icons); non-collapsible groups are always expanded.
|
|
218
|
+
const isGroupExpanded = (index: number, group: NavigationGroup): boolean => {
|
|
219
|
+
if (props.collapsed) return true;
|
|
220
|
+
if (!isGroupToggle(group)) return true;
|
|
221
|
+
return openGroups.value.has(index);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const openGroups = ref<Set<number>>(new Set());
|
|
225
|
+
|
|
226
|
+
const computeInitialOpenGroups = (): Set<number> => {
|
|
227
|
+
const next = new Set<number>();
|
|
228
|
+
if (props.autoCollapseInactiveGroups) {
|
|
229
|
+
if (activeGroupIndex.value >= 0) next.add(activeGroupIndex.value);
|
|
230
|
+
} else {
|
|
231
|
+
props.navigation.forEach((group, index) => {
|
|
232
|
+
if (group.visible !== false && group.collapsible !== false) next.add(index);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
return next;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// Initialise synchronously so the active group is already open on first paint —
|
|
239
|
+
// no post-mount height measurement, so no open/close flicker on load.
|
|
240
|
+
openGroups.value = computeInitialOpenGroups();
|
|
241
|
+
|
|
242
|
+
const toggleGroup = (index: number): void => {
|
|
243
|
+
const wasOpen = openGroups.value.has(index);
|
|
244
|
+
if (props.autoCollapseInactiveGroups) {
|
|
245
|
+
openGroups.value = wasOpen ? new Set() : new Set([index]);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const next = new Set(openGroups.value);
|
|
249
|
+
if (wasOpen) next.delete(index);
|
|
250
|
+
else next.add(index);
|
|
251
|
+
openGroups.value = next;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// Ensure the active-route group is open. In single-open mode this switches to
|
|
255
|
+
// it (closing others); otherwise it just adds it to the open set. No-op when
|
|
256
|
+
// there is no active group (e.g. a detail page not present in the nav).
|
|
257
|
+
const openActiveGroup = (): void => {
|
|
258
|
+
if (!props.collapsibleGroups || activeGroupIndex.value < 0) return;
|
|
259
|
+
if (props.autoCollapseInactiveGroups) {
|
|
260
|
+
openGroups.value = new Set([activeGroupIndex.value]);
|
|
261
|
+
} else {
|
|
262
|
+
const next = new Set(openGroups.value);
|
|
263
|
+
next.add(activeGroupIndex.value);
|
|
264
|
+
openGroups.value = next;
|
|
265
|
+
}
|
|
107
266
|
};
|
|
108
267
|
|
|
109
268
|
const scrollToActiveItem = async (smooth = false) => {
|
|
@@ -131,10 +290,19 @@ onMounted(() => {
|
|
|
131
290
|
scrollToActiveItem(false);
|
|
132
291
|
});
|
|
133
292
|
|
|
134
|
-
//
|
|
293
|
+
// Client-side route change: open the newly active group, then scroll to it.
|
|
135
294
|
watch(() => props.currentUrl, () => {
|
|
295
|
+
openActiveGroup();
|
|
136
296
|
scrollToActiveItem(true);
|
|
137
297
|
});
|
|
298
|
+
|
|
299
|
+
// The active group can change without a currentUrl change — e.g. navigation
|
|
300
|
+
// arrives/repopulates after mount (async data, permission gating), so the active
|
|
301
|
+
// route resolves late. Watch the derived index (not the array identity, which
|
|
302
|
+
// can churn on every parent re-render) and open the active group when it lands.
|
|
303
|
+
watch(activeGroupIndex, () => {
|
|
304
|
+
openActiveGroup();
|
|
305
|
+
});
|
|
138
306
|
</script>
|
|
139
307
|
|
|
140
308
|
<style scoped>
|
|
@@ -186,6 +354,99 @@ watch(() => props.currentUrl, () => {
|
|
|
186
354
|
letter-spacing: 0.5px;
|
|
187
355
|
}
|
|
188
356
|
|
|
357
|
+
/* Collapsible group toggle header */
|
|
358
|
+
.nav-group-toggle {
|
|
359
|
+
display: flex;
|
|
360
|
+
align-items: center;
|
|
361
|
+
justify-content: space-between;
|
|
362
|
+
width: 100%;
|
|
363
|
+
/* Comfortable, ergonomic click target (matches the nav links' vertical
|
|
364
|
+
rhythm). `px-2` on the element sets only left/right padding, so vertical
|
|
365
|
+
padding here does not fight Bootstrap's utility `!important`. */
|
|
366
|
+
min-height: 2.5rem;
|
|
367
|
+
padding-top: 0.5rem;
|
|
368
|
+
padding-bottom: 0.5rem;
|
|
369
|
+
font-size: 0.75rem;
|
|
370
|
+
letter-spacing: 0.5px;
|
|
371
|
+
background: transparent;
|
|
372
|
+
border: 0;
|
|
373
|
+
/* No `color` here on purpose: theme.scss sets `.nav-group-toggle` to
|
|
374
|
+
$navbar-dark-color so the toggle matches the static .nav-group-label.
|
|
375
|
+
A scoped `color` would override that (equal specificity, later source order). */
|
|
376
|
+
cursor: pointer;
|
|
377
|
+
text-align: left;
|
|
378
|
+
border-radius: var(--bs-border-radius, 0.375rem);
|
|
379
|
+
transition: background-color 0.2s ease;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.nav-group-toggle:hover {
|
|
383
|
+
background-color: rgba(255, 255, 255, 0.08);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
.nav-group-toggle:focus-visible {
|
|
387
|
+
outline: 2px solid rgba(255, 255, 255, 0.5);
|
|
388
|
+
outline-offset: 2px;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
.nav-group-toggle-label {
|
|
392
|
+
overflow: hidden;
|
|
393
|
+
text-overflow: ellipsis;
|
|
394
|
+
white-space: nowrap;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
.nav-group-chevron {
|
|
398
|
+
flex-shrink: 0;
|
|
399
|
+
margin-left: 0.5rem;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.nav-group-open > .nav-group-toggle .nav-group-chevron {
|
|
403
|
+
transform: rotate(180deg);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/*
|
|
407
|
+
* Grid-based collapse: animate rows 0fr -> 1fr so the container height follows
|
|
408
|
+
* its content with no JS measurement. The active group renders with
|
|
409
|
+
* `.nav-group-open` already applied, so its open state paints without a
|
|
410
|
+
* transition (no load flicker). Opacity fades the items in/out alongside the
|
|
411
|
+
* height change for a smoother reveal.
|
|
412
|
+
*
|
|
413
|
+
* This mechanism is applied ONLY to groups that actually collapse
|
|
414
|
+
* (`--collapsible`). A group that never collapses (feature off, rail-collapsed,
|
|
415
|
+
* or `collapsible: false`) keeps a plain wrapper, so its `overflow: hidden`
|
|
416
|
+
* never clips focus outlines / badge shadows for consumers who didn't opt in.
|
|
417
|
+
*/
|
|
418
|
+
.nav-group-items--collapsible {
|
|
419
|
+
display: grid;
|
|
420
|
+
grid-template-rows: 0fr;
|
|
421
|
+
opacity: 0;
|
|
422
|
+
transition: grid-template-rows 0.2s ease, opacity 0.2s ease;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
.nav-group-open > .nav-group-items--collapsible {
|
|
426
|
+
grid-template-rows: 1fr;
|
|
427
|
+
opacity: 1;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
.nav-group-items--collapsible > .nav {
|
|
431
|
+
overflow: hidden;
|
|
432
|
+
min-height: 0;
|
|
433
|
+
/*
|
|
434
|
+
* Bootstrap's `.nav` sets `flex-wrap: wrap`. While the grid row is collapsing
|
|
435
|
+
* (height near 0), a wrapping flex-column can't stack its items in the tiny
|
|
436
|
+
* height and wraps them into side-by-side columns instead — a visible reflow
|
|
437
|
+
* flash. `nowrap` keeps them stacked and simply clipped by `overflow: hidden`.
|
|
438
|
+
*/
|
|
439
|
+
flex-wrap: nowrap;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/* Respect reduced-motion: collapse instantly, no fade. */
|
|
443
|
+
@media (prefers-reduced-motion: reduce) {
|
|
444
|
+
.nav-group-items--collapsible,
|
|
445
|
+
.nav-group-toggle {
|
|
446
|
+
transition: none;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
189
450
|
:deep(.nav-link) {
|
|
190
451
|
padding: 0.625rem 0.75rem;
|
|
191
452
|
transition: all 0.2s ease;
|
|
@@ -26,14 +26,47 @@
|
|
|
26
26
|
:disabled="isDisabled || isReadonly"
|
|
27
27
|
v-bind="field.inputProps"
|
|
28
28
|
>
|
|
29
|
-
|
|
29
|
+
<DXFieldLabel :label="resolvedLabel" :info="resolvedInfo" />
|
|
30
30
|
</DFormCheckbox>
|
|
31
31
|
|
|
32
32
|
<DFormInvalidFeedback v-if="form.hasError(errorKey)" force-show>
|
|
33
33
|
{{ form.getError(errorKey) }}
|
|
34
34
|
</DFormInvalidFeedback>
|
|
35
35
|
<slot name="info" :field="field" :model="model" />
|
|
36
|
-
<DFormText v-if="resolvedHint" class="text-muted">
|
|
36
|
+
<DFormText v-if="resolvedHint || $slots.hint" class="text-muted">
|
|
37
|
+
<slot name="hint" :field="field" :model="model">{{ resolvedHint }}</slot>
|
|
38
|
+
</DFormText>
|
|
39
|
+
<DFormText v-if="field.help">{{ field.help }}</DFormText>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<!-- Switch: toggle with contextual on/off text and an on-state style -->
|
|
43
|
+
<div
|
|
44
|
+
v-else-if="field.type === 'switch'"
|
|
45
|
+
:class="[field.class || 'mb-3', 'dx-switch', { 'dx-switch--on': switchIsOn }]"
|
|
46
|
+
>
|
|
47
|
+
<slot
|
|
48
|
+
v-if="$slots.value"
|
|
49
|
+
name="value"
|
|
50
|
+
:field="field"
|
|
51
|
+
:model="model"
|
|
52
|
+
:value="fieldValue"
|
|
53
|
+
:update="setValue"
|
|
54
|
+
/>
|
|
55
|
+
<DFormCheckbox
|
|
56
|
+
v-else
|
|
57
|
+
v-model="switchModel"
|
|
58
|
+
switch
|
|
59
|
+
:disabled="isDisabled || isReadonly"
|
|
60
|
+
v-bind="field.inputProps"
|
|
61
|
+
>
|
|
62
|
+
<DXFieldLabel :label="switchText" :info="resolvedInfo" />
|
|
63
|
+
</DFormCheckbox>
|
|
64
|
+
|
|
65
|
+
<DFormInvalidFeedback v-if="form.hasError(errorKey)" force-show>
|
|
66
|
+
{{ form.getError(errorKey) }}
|
|
67
|
+
</DFormInvalidFeedback>
|
|
68
|
+
<slot name="info" :field="field" :model="model" />
|
|
69
|
+
<DFormText v-if="resolvedHint || $slots.hint" class="text-muted">
|
|
37
70
|
<slot name="hint" :field="field" :model="model">{{ resolvedHint }}</slot>
|
|
38
71
|
</DFormText>
|
|
39
72
|
<DFormText v-if="field.help">{{ field.help }}</DFormText>
|
|
@@ -41,7 +74,10 @@
|
|
|
41
74
|
|
|
42
75
|
<!-- Repeater: nested, repeatable sub-form -->
|
|
43
76
|
<div v-else-if="field.type === 'repeater'" :class="field.class || 'mb-3'">
|
|
44
|
-
<DFormGroup
|
|
77
|
+
<DFormGroup>
|
|
78
|
+
<template #label>
|
|
79
|
+
<DXFieldLabel :label="resolvedLabel" :info="resolvedInfo" />
|
|
80
|
+
</template>
|
|
45
81
|
<DXRepeater
|
|
46
82
|
:form="form"
|
|
47
83
|
:field="field"
|
|
@@ -55,14 +91,19 @@
|
|
|
55
91
|
</DXRepeater>
|
|
56
92
|
</DFormGroup>
|
|
57
93
|
<slot name="info" :field="field" :model="model" />
|
|
58
|
-
<DFormText v-if="resolvedHint" class="text-muted">
|
|
94
|
+
<DFormText v-if="resolvedHint || $slots.hint" class="text-muted">
|
|
59
95
|
<slot name="hint" :field="field" :model="model">{{ resolvedHint }}</slot>
|
|
60
96
|
</DFormText>
|
|
61
97
|
<DFormText v-if="field.help">{{ field.help }}</DFormText>
|
|
62
98
|
</div>
|
|
63
99
|
|
|
64
100
|
<!-- Standard labelled field -->
|
|
65
|
-
<DFormGroup v-else :
|
|
101
|
+
<DFormGroup v-else :class="field.class || 'mb-3'">
|
|
102
|
+
<!-- Label with optional info popover -->
|
|
103
|
+
<template #label>
|
|
104
|
+
<DXFieldLabel :label="resolvedLabel" :info="resolvedInfo" />
|
|
105
|
+
</template>
|
|
106
|
+
|
|
66
107
|
<!-- Custom value slot overrides the built-in control -->
|
|
67
108
|
<slot
|
|
68
109
|
v-if="$slots.value"
|
|
@@ -144,7 +185,7 @@
|
|
|
144
185
|
<span class="input-group-text">{{ field.currencySymbol || "£" }}</span>
|
|
145
186
|
</template>
|
|
146
187
|
<DFormInput
|
|
147
|
-
v-model="
|
|
188
|
+
v-model="numericInputValue"
|
|
148
189
|
type="number"
|
|
149
190
|
:required="field.required"
|
|
150
191
|
:placeholder="field.placeholder"
|
|
@@ -213,6 +254,7 @@ import DFormCheckbox from "../base/DFormCheckbox.vue";
|
|
|
213
254
|
import DFormInvalidFeedback from "../base/DFormInvalidFeedback.vue";
|
|
214
255
|
import DFormText from "../base/DFormText.vue";
|
|
215
256
|
import DInputGroup from "../base/DInputGroup.vue";
|
|
257
|
+
import DXFieldLabel from "./DXFieldLabel.vue";
|
|
216
258
|
import type { UseFormReturn } from "../../composables/useForm";
|
|
217
259
|
import type { FieldDefinition, FieldOption, FieldType } from "../../types";
|
|
218
260
|
import { getByPath, setByPath } from "../../utils/objectPath";
|
|
@@ -273,6 +315,41 @@ const fieldValue = computed({
|
|
|
273
315
|
set: (value: any) => setValue(value),
|
|
274
316
|
});
|
|
275
317
|
|
|
318
|
+
// For `percentage` fields with `asFraction`, the model holds a 0–1 fraction but
|
|
319
|
+
// the input shows/edits a 0–100 percentage. Scale on read/write, rounding away
|
|
320
|
+
// binary-float artefacts (0.2 * 100 = 20.000000000000004). Currency and plain
|
|
321
|
+
// percentage fields pass straight through.
|
|
322
|
+
const isFractionPercentage = computed(
|
|
323
|
+
() => props.field.type === "percentage" && props.field.asFraction === true,
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
const numericInputValue = computed({
|
|
327
|
+
get: () => {
|
|
328
|
+
const value = fieldValue.value;
|
|
329
|
+
if (!isFractionPercentage.value) return value;
|
|
330
|
+
if (value === null || value === undefined || value === "") return value;
|
|
331
|
+
const num = Number(value);
|
|
332
|
+
if (!Number.isFinite(num)) return value;
|
|
333
|
+
return Math.round(num * 100 * 1e6) / 1e6;
|
|
334
|
+
},
|
|
335
|
+
set: (value: any) => {
|
|
336
|
+
if (!isFractionPercentage.value) {
|
|
337
|
+
setValue(value);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (value === null || value === undefined || value === "") {
|
|
341
|
+
setValue(value);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const num = Number(value);
|
|
345
|
+
if (!Number.isFinite(num)) {
|
|
346
|
+
setValue(value);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
setValue(Math.round((num / 100) * 1e9) / 1e9);
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
|
|
276
353
|
const NUMERIC_TYPES: ReadonlySet<FieldType> = new Set([
|
|
277
354
|
"number",
|
|
278
355
|
"currency",
|
|
@@ -307,6 +384,46 @@ const resolvedLabel = computed(
|
|
|
307
384
|
|
|
308
385
|
const resolvedHint = computed(() => resolveMaybe(props.field.hint));
|
|
309
386
|
|
|
387
|
+
const resolvedInfo = computed(() => resolveMaybe(props.field.info));
|
|
388
|
+
|
|
389
|
+
// ————————————————— switch (toggle) field
|
|
390
|
+
|
|
391
|
+
// Whether a `switch` field is currently on. Coerces the model value to a
|
|
392
|
+
// boolean, but treats the common "falsey" string encodings a backend might send
|
|
393
|
+
// for a boolean ("0", "false", "") as off — plain `Boolean("0")` is `true`,
|
|
394
|
+
// which would wrongly render such a value on.
|
|
395
|
+
const switchIsOn = computed(() => {
|
|
396
|
+
const value = fieldValue.value;
|
|
397
|
+
if (typeof value === "string") {
|
|
398
|
+
const normalised = value.trim().toLowerCase();
|
|
399
|
+
return normalised !== "" && normalised !== "0" && normalised !== "false";
|
|
400
|
+
}
|
|
401
|
+
return Boolean(value);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// Bind the toggle to a normalised boolean. The underlying bvn checkbox only
|
|
405
|
+
// treats a literal `true` as checked, so a truthy non-boolean model (e.g.
|
|
406
|
+
// Laravel serialising a boolean column as `1`, or a `"1"` string) would render
|
|
407
|
+
// the toggle in the *off* position while `switchIsOn` styled it *on* — the
|
|
408
|
+
// control contradicting itself. Reading a real boolean keeps the checkbox
|
|
409
|
+
// position, the on-state style, and the contextual text in agreement; writing
|
|
410
|
+
// stores a clean boolean.
|
|
411
|
+
const switchModel = computed({
|
|
412
|
+
get: () => switchIsOn.value,
|
|
413
|
+
set: (value: boolean) => setValue(value),
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Contextual label for a `switch` field: `textWhenTrue`/`textWhenFalse`
|
|
418
|
+
* for the current state, falling back to the field's label.
|
|
419
|
+
*/
|
|
420
|
+
const switchText = computed(() => {
|
|
421
|
+
const contextual = switchIsOn.value
|
|
422
|
+
? resolveMaybe(props.field.textWhenTrue)
|
|
423
|
+
: resolveMaybe(props.field.textWhenFalse);
|
|
424
|
+
return contextual ?? resolvedLabel.value;
|
|
425
|
+
});
|
|
426
|
+
|
|
310
427
|
const isDisabled = computed(() => {
|
|
311
428
|
if (props.field.disabledWhen) {
|
|
312
429
|
return props.field.disabledWhen(effectiveModel.value);
|
|
@@ -399,4 +516,26 @@ onBeforeUnmount(() => {
|
|
|
399
516
|
border: 1px solid var(--bs-border-color);
|
|
400
517
|
border-radius: var(--bs-border-radius);
|
|
401
518
|
}
|
|
519
|
+
|
|
520
|
+
/* Switch field: contextual styling that responds to the on/off state.
|
|
521
|
+
Off is muted; on turns the control and label a filled success green. */
|
|
522
|
+
.dx-switch :deep(.form-check-label) {
|
|
523
|
+
color: var(--bs-secondary-color);
|
|
524
|
+
transition: color 0.15s ease-in-out;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
.dx-switch--on :deep(.form-check-label) {
|
|
528
|
+
color: var(--bs-success);
|
|
529
|
+
font-weight: 500;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
.dx-switch--on :deep(.form-check-input:checked) {
|
|
533
|
+
background-color: var(--bs-success);
|
|
534
|
+
border-color: var(--bs-success);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
.dx-switch--on :deep(.form-check-input:focus) {
|
|
538
|
+
border-color: var(--bs-success);
|
|
539
|
+
box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb), 0.25);
|
|
540
|
+
}
|
|
402
541
|
</style>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<span class="dx-field-label">
|
|
3
|
+
<span class="dx-field-label__text">{{ label }}</span>
|
|
4
|
+
<template v-if="info">
|
|
5
|
+
<button
|
|
6
|
+
:id="infoId"
|
|
7
|
+
type="button"
|
|
8
|
+
class="dx-field-label__info"
|
|
9
|
+
:aria-label="`More information: ${label}`"
|
|
10
|
+
@click.stop.prevent
|
|
11
|
+
>
|
|
12
|
+
<i-lucide-info aria-hidden="true" />
|
|
13
|
+
</button>
|
|
14
|
+
<DPopover
|
|
15
|
+
:target="infoId"
|
|
16
|
+
hover
|
|
17
|
+
focus
|
|
18
|
+
placement="top"
|
|
19
|
+
>
|
|
20
|
+
{{ info }}
|
|
21
|
+
</DPopover>
|
|
22
|
+
</template>
|
|
23
|
+
</span>
|
|
24
|
+
</template>
|
|
25
|
+
|
|
26
|
+
<script setup lang="ts">
|
|
27
|
+
import { useId } from "vue";
|
|
28
|
+
import DPopover from "../base/DPopover.vue";
|
|
29
|
+
|
|
30
|
+
interface Props {
|
|
31
|
+
/** Visible label text. */
|
|
32
|
+
label: string;
|
|
33
|
+
|
|
34
|
+
/** Optional help text revealed in a popover from an info affordance. */
|
|
35
|
+
info?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
defineProps<Props>();
|
|
39
|
+
|
|
40
|
+
// Stable, SSR-safe id so the popover can target the trigger button.
|
|
41
|
+
const infoId = `dx-field-info-${useId()}`;
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<style scoped>
|
|
45
|
+
.dx-field-label {
|
|
46
|
+
display: inline-flex;
|
|
47
|
+
align-items: center;
|
|
48
|
+
gap: 0.35rem;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.dx-field-label__info {
|
|
52
|
+
display: inline-flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
justify-content: center;
|
|
55
|
+
padding: 0;
|
|
56
|
+
border: 0;
|
|
57
|
+
background: none;
|
|
58
|
+
line-height: 1;
|
|
59
|
+
color: var(--bs-secondary-color);
|
|
60
|
+
cursor: help;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.dx-field-label__info:hover,
|
|
64
|
+
.dx-field-label__info:focus-visible {
|
|
65
|
+
color: var(--bs-primary);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.dx-field-label__info svg {
|
|
69
|
+
width: 0.9em;
|
|
70
|
+
height: 0.9em;
|
|
71
|
+
}
|
|
72
|
+
</style>
|