@omnitend/dashboard-for-laravel 0.19.2 → 0.22.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 +22 -6
- package/dist/components/extended/DXDashboardNavbar.vue.d.ts +16 -0
- package/dist/components/extended/DXFieldLabel.vue.d.ts +11 -1
- package/dist/components/extended/DXFormField.vue.d.ts +41 -0
- package/dist/components/extended/DXSwitch.vue.d.ts +30 -0
- package/dist/components/extended/dxFieldContext.d.ts +11 -0
- package/dist/dashboard-for-laravel.js +10913 -10827
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +8 -8
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +4 -1
- package/dist/types/navigation.d.ts +13 -0
- package/dist/utils/api.d.ts +1 -1
- package/docs/public/api-reference.json +255 -103
- package/docs/public/docs-map.md +3 -2
- package/docs/public/llms.txt +3 -2
- package/package.json +9 -5
- package/resources/css/theme.scss +22 -3
- package/resources/js/components/extended/DXDashboard.vue +32 -10
- package/resources/js/components/extended/DXDashboardNavbar.vue +104 -26
- package/resources/js/components/extended/DXField.vue +66 -77
- package/resources/js/components/extended/DXFieldLabel.vue +18 -3
- package/resources/js/components/extended/DXForm.vue +31 -109
- package/resources/js/components/extended/DXFormField.vue +98 -0
- package/resources/js/components/extended/DXSwitch.vue +153 -0
- package/resources/js/components/extended/dxFieldContext.ts +14 -0
- package/resources/js/composables/useForm.ts +10 -3
- package/resources/js/index.ts +3 -0
- package/resources/js/types/index.ts +4 -1
- package/resources/js/types/navigation.ts +15 -0
- package/resources/js/utils/api.ts +19 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omnitend/dashboard-for-laravel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Vue 3 dashboard components for Laravel with Bootstrap Vue Next",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/dashboard-for-laravel.umd.cjs",
|
|
@@ -31,8 +31,11 @@
|
|
|
31
31
|
"dev": "vite build --watch",
|
|
32
32
|
"build": "vite build && vue-tsc --declaration --emitDeclarationOnly --outDir dist",
|
|
33
33
|
"typecheck": "vue-tsc --noEmit",
|
|
34
|
+
"pretest": "vite build",
|
|
34
35
|
"test": "vitest",
|
|
36
|
+
"pretest:ui": "vite build",
|
|
35
37
|
"test:ui": "vitest --ui",
|
|
38
|
+
"pretest:headless": "vite build",
|
|
36
39
|
"test:headless": "vitest --run --browser.headless",
|
|
37
40
|
"docs:dev": "astro dev",
|
|
38
41
|
"docs:build": "npm run docs:generate:ai && astro build",
|
|
@@ -75,9 +78,10 @@
|
|
|
75
78
|
"vue-docgen-api": "^4.79.2"
|
|
76
79
|
},
|
|
77
80
|
"devDependencies": {
|
|
78
|
-
"@astrojs/
|
|
79
|
-
"@astrojs/
|
|
80
|
-
"@astrojs/
|
|
81
|
+
"@astrojs/markdown-remark": "^7.2.0",
|
|
82
|
+
"@astrojs/mdx": "^7.0.1",
|
|
83
|
+
"@astrojs/sitemap": "^3.7.3",
|
|
84
|
+
"@astrojs/vue": "^7.0.1",
|
|
81
85
|
"@iconify-json/lucide": "^1.2.71",
|
|
82
86
|
"@iconify/json": "^2.2.397",
|
|
83
87
|
"@inertiajs/vue3": "^2.2.15",
|
|
@@ -86,7 +90,7 @@
|
|
|
86
90
|
"@vitest/browser": "^4.1.8",
|
|
87
91
|
"@vitest/browser-playwright": "^4.1.8",
|
|
88
92
|
"@vue/test-utils": "^2.4.6",
|
|
89
|
-
"astro": "
|
|
93
|
+
"astro": "7.0.5",
|
|
90
94
|
"bootstrap": "^5.3.8",
|
|
91
95
|
"bootstrap-icons": "^1.13.1",
|
|
92
96
|
"chart.js": "^4.5.1",
|
package/resources/css/theme.scss
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
// Custom Bootstrap theme for the Laravel Dashboard library
|
|
5
5
|
// Override Bootstrap variables BEFORE importing Bootstrap
|
|
6
6
|
|
|
7
|
+
// The Sass module for `color.mix()` etc. `@use` must precede all other
|
|
8
|
+
// statements (variable overrides, `@import`), so it stays at the very top.
|
|
9
|
+
@use "sass:color";
|
|
10
|
+
|
|
7
11
|
// ----------------------------------------------------------------------------
|
|
8
12
|
// Colors
|
|
9
13
|
// ----------------------------------------------------------------------------
|
|
@@ -283,11 +287,26 @@ $bootstrap-icons-font-src: url("bootstrap-icons/font/fonts/bootstrap-icons.woff2
|
|
|
283
287
|
}
|
|
284
288
|
|
|
285
289
|
.dashboard-navbar {
|
|
286
|
-
height:
|
|
290
|
+
// Floor, not a fixed height: below `md` the search (and actions) drop to
|
|
291
|
+
// their own rows and the bar must grow to contain them (#93). Single-row
|
|
292
|
+
// content is shorter than this, so desktop stays exactly this height,
|
|
293
|
+
// aligned with the sidebar header.
|
|
294
|
+
min-height: $dashboard-navbar-height;
|
|
287
295
|
// A light grey top bar over the white content panel (higher contrast than a
|
|
288
296
|
// grey-everywhere layout). The navbar markup drops its `bg-white` class so
|
|
289
297
|
// this wins without `!important`.
|
|
290
|
-
background-color:
|
|
298
|
+
background-color: var(--bs-light);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// The avatar dropdown trigger (DXDashboardNavbar sets this via toggle-class,
|
|
302
|
+
// so it lands on the BVN-rendered button — global rule, scoped CSS can't
|
|
303
|
+
// reach it). The themed $btn-padding-y (10px) would make it ~54px tall and
|
|
304
|
+
// blow the navbar's single-row content budget: 64px header minus the bar's
|
|
305
|
+
// 16px padding = 48px. 6px keeps a ~46px tap target (≥ the 44px WCAG
|
|
306
|
+
// guideline) while still fitting the budget.
|
|
307
|
+
.dashboard-navbar__user-menu-toggle {
|
|
308
|
+
padding-top: 0.375rem;
|
|
309
|
+
padding-bottom: 0.375rem;
|
|
291
310
|
}
|
|
292
311
|
|
|
293
312
|
// ----------------------------------------------------------------------------
|
|
@@ -349,7 +368,7 @@ $bootstrap-icons-font-src: url("bootstrap-icons/font/fonts/bootstrap-icons.woff2
|
|
|
349
368
|
"info": $info
|
|
350
369
|
) {
|
|
351
370
|
&.toast-#{$name} {
|
|
352
|
-
--bs-toast-bg: #{mix($color, $white, 8%)};
|
|
371
|
+
--bs-toast-bg: #{color.mix($color, $white, 8%)};
|
|
353
372
|
}
|
|
354
373
|
}
|
|
355
374
|
}
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
<DXDashboardNavbar
|
|
37
37
|
:page-title="pageTitle"
|
|
38
38
|
:user="user"
|
|
39
|
-
:
|
|
39
|
+
:search-align="searchAlign"
|
|
40
|
+
:actions-on-mobile="actionsOnMobile"
|
|
40
41
|
@toggle-sidebar="toggleSidebar"
|
|
41
42
|
>
|
|
42
43
|
<!-- Dynamically forward all navbar-* slots by stripping the prefix -->
|
|
@@ -60,7 +61,10 @@
|
|
|
60
61
|
</DContainer>
|
|
61
62
|
<DContainer v-else fluid>
|
|
62
63
|
<DRow class="justify-content-center">
|
|
63
|
-
|
|
64
|
+
<!-- A genuine max-width cap (not the proportional col-xl-10), so the
|
|
65
|
+
reading column doesn't stretch to ~2000px on a wide display. The
|
|
66
|
+
centred row handles the horizontal centring. -->
|
|
67
|
+
<DCol cols="12" :style="{ maxWidth: contentMaxWidth }" :class="contentClass">
|
|
64
68
|
<slot />
|
|
65
69
|
</DCol>
|
|
66
70
|
</DRow>
|
|
@@ -77,7 +81,7 @@ import DXDashboardNavbar from './DXDashboardNavbar.vue';
|
|
|
77
81
|
import DContainer from '../base/DContainer.vue';
|
|
78
82
|
import DRow from '../base/DRow.vue';
|
|
79
83
|
import DCol from '../base/DCol.vue';
|
|
80
|
-
import type { Navigation } from '../../types/navigation';
|
|
84
|
+
import type { Navigation, NavbarActionsOnMobile, NavbarSearchAlign } from '../../types/navigation';
|
|
81
85
|
|
|
82
86
|
const slots = useSlots();
|
|
83
87
|
|
|
@@ -96,19 +100,35 @@ interface Props {
|
|
|
96
100
|
|
|
97
101
|
/**
|
|
98
102
|
* Render the page content full-width and left-aligned instead of the default
|
|
99
|
-
* centred, reading-width
|
|
100
|
-
* (wide tables).
|
|
103
|
+
* centred, reading-width column. Use for data-heavy admin pages (wide tables).
|
|
101
104
|
*/
|
|
102
105
|
fluid?: boolean;
|
|
103
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Max width of the centred content column (any CSS length). A genuine cap, so
|
|
109
|
+
* forms and text-heavy pages don't stretch to ~2000px on a wide display.
|
|
110
|
+
* Ignored when `fluid`. Set a large value (or use `fluid`) for full width.
|
|
111
|
+
*/
|
|
112
|
+
contentMaxWidth?: string;
|
|
113
|
+
|
|
104
114
|
/** Extra class(es) applied to the content container/column. */
|
|
105
115
|
contentClass?: string;
|
|
106
116
|
|
|
107
117
|
/** User object for navbar dropdown */
|
|
108
118
|
user?: { name: string; email: string } | null;
|
|
109
119
|
|
|
110
|
-
/**
|
|
111
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Horizontal alignment of the navbar search slot content (`"start"` = flush
|
|
122
|
+
* left, `"center"` = centred), forwarded to DXDashboardNavbar.
|
|
123
|
+
*/
|
|
124
|
+
searchAlign?: NavbarSearchAlign;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* What the navbar actions slot does below `md`: `"wrap"` to its own
|
|
128
|
+
* full-width row, `"hide"` to remove it (relocate actions into the page on
|
|
129
|
+
* phones). Forwarded to DXDashboardNavbar.
|
|
130
|
+
*/
|
|
131
|
+
actionsOnMobile?: NavbarActionsOnMobile;
|
|
112
132
|
|
|
113
133
|
/**
|
|
114
134
|
* Turn sidebar group headers into accordion toggles that collapse/expand
|
|
@@ -138,11 +158,13 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
138
158
|
title: 'Dashboard',
|
|
139
159
|
pageTitle: '',
|
|
140
160
|
user: null,
|
|
141
|
-
logoutUrl: '/logout',
|
|
142
161
|
collapsibleGroups: false,
|
|
143
162
|
autoCollapseInactiveGroups: true,
|
|
144
163
|
storageKey: 'dashboard-sidebar-hidden',
|
|
145
164
|
dashboardId: '',
|
|
165
|
+
contentMaxWidth: '1140px',
|
|
166
|
+
searchAlign: 'start',
|
|
167
|
+
actionsOnMobile: 'wrap',
|
|
146
168
|
});
|
|
147
169
|
|
|
148
170
|
const collapsed = ref(false);
|
|
@@ -235,7 +257,7 @@ const toggleSidebar = () => {
|
|
|
235
257
|
<style scoped>
|
|
236
258
|
.dashboard-layout {
|
|
237
259
|
min-height: 100vh;
|
|
238
|
-
background-color:
|
|
260
|
+
background-color: var(--bs-light);
|
|
239
261
|
}
|
|
240
262
|
|
|
241
263
|
.dashboard-content {
|
|
@@ -249,6 +271,6 @@ const toggleSidebar = () => {
|
|
|
249
271
|
max-width: 100%;
|
|
250
272
|
/* A white content panel under the light grey top bar / layout, so forms and
|
|
251
273
|
content sit on white (higher contrast than a grey-everywhere dashboard). */
|
|
252
|
-
background-color:
|
|
274
|
+
background-color: var(--bs-white);
|
|
253
275
|
}
|
|
254
276
|
</style>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component
|
|
3
|
-
Top navigation bar for the dashboard shell: menu toggle, page title,
|
|
4
|
-
|
|
5
|
-
`DXDashboard`, which forwards these slots
|
|
3
|
+
Top navigation bar for the dashboard shell: menu toggle, page title, search
|
|
4
|
+
(aligned per `searchAlign`, left by default), page-level actions, and the
|
|
5
|
+
user menu. Usually rendered by `DXDashboard`, which forwards these slots
|
|
6
|
+
with a `navbar-` prefix.
|
|
6
7
|
-->
|
|
7
8
|
<template>
|
|
8
9
|
<header class="dashboard-navbar border-bottom">
|
|
@@ -39,25 +40,35 @@
|
|
|
39
40
|
|
|
40
41
|
<div
|
|
41
42
|
v-if="$slots.search"
|
|
42
|
-
class="dashboard-navbar__search d-flex
|
|
43
|
+
class="dashboard-navbar__search d-flex"
|
|
44
|
+
:class="searchAlignClass"
|
|
43
45
|
>
|
|
44
|
-
<!-- @slot Search input or component.
|
|
46
|
+
<!-- @slot Search input or component. Aligned per `searchAlign` within its region (inline in the bar from `md` up; its own full-width row below). -->
|
|
45
47
|
<slot name="search" />
|
|
46
48
|
</div>
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
50
|
+
<!-- Page-level primary actions: right-aligned next to the user menu
|
|
51
|
+
from `md` up; their own wrappable full-width row below (#93),
|
|
52
|
+
or hidden below `md` when `actionsOnMobile` is "hide". -->
|
|
53
|
+
<div
|
|
54
|
+
v-if="$slots.actions"
|
|
55
|
+
class="dashboard-navbar__actions align-items-center gap-2"
|
|
56
|
+
:class="actionsDisplayClass"
|
|
57
|
+
>
|
|
58
|
+
<!--
|
|
59
|
+
@slot Page-level primary actions (e.g. a Create button). Right-aligned next to the user menu from `md` up; below, wraps to its own full-width row or is hidden entirely per `actionsOnMobile`.
|
|
60
|
+
@binding {string} pageTitle The current page title, for context.
|
|
61
|
+
-->
|
|
62
|
+
<slot name="actions" :pageTitle="pageTitle" />
|
|
63
|
+
</div>
|
|
60
64
|
|
|
65
|
+
<!-- Only render the user-menu cluster when it has content: an empty
|
|
66
|
+
flex item would still cost a bar gap, pushing actions off the
|
|
67
|
+
right edge in guest (user-less) layouts. -->
|
|
68
|
+
<div
|
|
69
|
+
v-if="$slots['user-menu'] || user"
|
|
70
|
+
class="dashboard-navbar__end d-flex align-items-center gap-3"
|
|
71
|
+
>
|
|
61
72
|
<!--
|
|
62
73
|
@slot Replaces the entire user menu (the default avatar dropdown).
|
|
63
74
|
@binding {object} user The signed-in user.
|
|
@@ -68,6 +79,7 @@
|
|
|
68
79
|
variant="link"
|
|
69
80
|
class="text-dark"
|
|
70
81
|
menu-class="dropdown-menu-end"
|
|
82
|
+
toggle-class="dashboard-navbar__user-menu-toggle"
|
|
71
83
|
no-caret
|
|
72
84
|
>
|
|
73
85
|
<template #button-content>
|
|
@@ -96,11 +108,13 @@
|
|
|
96
108
|
</template>
|
|
97
109
|
|
|
98
110
|
<script setup lang="ts">
|
|
111
|
+
import { computed } from "vue";
|
|
99
112
|
import DContainer from "../base/DContainer.vue";
|
|
100
113
|
import DButton from "../base/DButton.vue";
|
|
101
114
|
import DDropdown from "../base/DDropdown.vue";
|
|
115
|
+
import type { NavbarActionsOnMobile, NavbarSearchAlign } from "../../types/navigation";
|
|
102
116
|
|
|
103
|
-
withDefaults(
|
|
117
|
+
const props = withDefaults(
|
|
104
118
|
defineProps<{
|
|
105
119
|
/** The signed-in user shown in the avatar dropdown. `null` hides the menu. */
|
|
106
120
|
user?: {
|
|
@@ -110,13 +124,53 @@ withDefaults(
|
|
|
110
124
|
} | null;
|
|
111
125
|
/** Page title shown at the left of the navbar (hidden below the `md` breakpoint). */
|
|
112
126
|
pageTitle?: string;
|
|
127
|
+
/**
|
|
128
|
+
* Horizontal alignment of the search slot content within its region
|
|
129
|
+
* (`"start"` = flush left, `"center"` = centred). Applies at every size:
|
|
130
|
+
* the region sits inline after the title from `md` up, and is its own
|
|
131
|
+
* full-width row below.
|
|
132
|
+
*/
|
|
133
|
+
searchAlign?: NavbarSearchAlign;
|
|
134
|
+
/**
|
|
135
|
+
* What the actions slot does below the `md` breakpoint: `"wrap"` moves it
|
|
136
|
+
* to its own full-width row (the bar grows), `"hide"` removes it entirely
|
|
137
|
+
* (for apps that relocate page actions into the page on phones).
|
|
138
|
+
*/
|
|
139
|
+
actionsOnMobile?: NavbarActionsOnMobile;
|
|
113
140
|
}>(),
|
|
114
141
|
{
|
|
115
142
|
user: null,
|
|
116
143
|
pageTitle: "",
|
|
144
|
+
searchAlign: "start",
|
|
145
|
+
actionsOnMobile: "wrap",
|
|
117
146
|
},
|
|
118
147
|
);
|
|
119
148
|
|
|
149
|
+
// Explicit map (not string interpolation) so an out-of-union value at runtime
|
|
150
|
+
// falls back to a valid class instead of a silent Bootstrap no-op, and the
|
|
151
|
+
// full class names stay greppable.
|
|
152
|
+
const SEARCH_ALIGN_CLASSES: Record<NavbarSearchAlign, string> = {
|
|
153
|
+
start: "justify-content-start",
|
|
154
|
+
center: "justify-content-center",
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const searchAlignClass = computed(
|
|
158
|
+
() => SEARCH_ALIGN_CLASSES[props.searchAlign] ?? SEARCH_ALIGN_CLASSES.start,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// "hide" swaps d-flex for d-none/d-md-flex on the WRAPPER — hiding only the
|
|
162
|
+
// slot content would leave a zero-height full-width row still costing a flex
|
|
163
|
+
// row-gap on phones. Explicit map + fallback, same rationale as
|
|
164
|
+
// SEARCH_ALIGN_CLASSES above.
|
|
165
|
+
const ACTIONS_DISPLAY_CLASSES: Record<NavbarActionsOnMobile, string> = {
|
|
166
|
+
wrap: "d-flex",
|
|
167
|
+
hide: "d-none d-md-flex",
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const actionsDisplayClass = computed(
|
|
171
|
+
() => ACTIONS_DISPLAY_CLASSES[props.actionsOnMobile] ?? ACTIONS_DISPLAY_CLASSES.wrap,
|
|
172
|
+
);
|
|
173
|
+
|
|
120
174
|
defineEmits<{
|
|
121
175
|
/** Emitted when the hamburger menu button is clicked. */
|
|
122
176
|
toggleSidebar: [];
|
|
@@ -140,32 +194,56 @@ const getUserInitial = (user: { name: string } | null) => {
|
|
|
140
194
|
padding: 0.5rem 0;
|
|
141
195
|
}
|
|
142
196
|
|
|
143
|
-
/* Push the user-menu
|
|
197
|
+
/* Push the user-menu cluster to the right. (The actions region carries its
|
|
198
|
+
own auto margin from `md` up — see the sibling rule below.) */
|
|
144
199
|
.dashboard-navbar__end {
|
|
145
200
|
margin-left: auto;
|
|
146
201
|
}
|
|
147
202
|
|
|
148
203
|
/*
|
|
149
|
-
* Mobile-first: the search
|
|
150
|
-
* title / user-menu row (
|
|
151
|
-
*
|
|
152
|
-
*
|
|
204
|
+
* Mobile-first: the actions and the search each sit on their own full-width
|
|
205
|
+
* row below the toggle / title / user-menu row (flex-basis 100% forces the
|
|
206
|
+
* wrap; the bar and the sticky header grow to contain them — #93). The
|
|
207
|
+
* actions row itself wraps so a wide button group stacks instead of
|
|
208
|
+
* overflowing the viewport.
|
|
209
|
+
* From `md` up everything moves inline: search between the title and the
|
|
210
|
+
* actions (grows to fill the middle; `searchAlign` controls where its content
|
|
211
|
+
* sits), actions right-aligned next to the user menu. The actions' order 2
|
|
212
|
+
* holds at every size — only search and end change order across the
|
|
213
|
+
* breakpoint.
|
|
153
214
|
*/
|
|
215
|
+
.dashboard-navbar__actions {
|
|
216
|
+
order: 2;
|
|
217
|
+
flex: 0 0 100%;
|
|
218
|
+
flex-wrap: wrap;
|
|
219
|
+
}
|
|
220
|
+
|
|
154
221
|
.dashboard-navbar__search {
|
|
155
222
|
order: 3;
|
|
156
223
|
flex: 0 0 100%;
|
|
157
|
-
width: 100%;
|
|
158
224
|
}
|
|
159
225
|
|
|
160
226
|
@media (min-width: 768px) {
|
|
161
227
|
.dashboard-navbar__search {
|
|
162
228
|
order: 1;
|
|
163
229
|
flex: 1 1 auto;
|
|
164
|
-
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.dashboard-navbar__actions {
|
|
233
|
+
flex: 0 1 auto;
|
|
234
|
+
flex-wrap: nowrap;
|
|
235
|
+
margin-left: auto;
|
|
165
236
|
}
|
|
166
237
|
|
|
167
238
|
.dashboard-navbar__end {
|
|
168
|
-
order:
|
|
239
|
+
order: 3;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* When the actions region is present it carries the push-right auto margin;
|
|
243
|
+
zero the user-menu cluster's so the two sit adjacent instead of having
|
|
244
|
+
the free space split between them. */
|
|
245
|
+
.dashboard-navbar__actions ~ .dashboard-navbar__end {
|
|
246
|
+
margin-left: 0;
|
|
169
247
|
}
|
|
170
248
|
}
|
|
171
249
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component
|
|
3
|
-
Renders a single `FieldDefinition` of any type (text, select, autocomplete, checkbox, switch, repeater, currency, percentage, file/image, component, etc.) with its label, validation error, hint and help. Used by `DXForm` and `DXTable`'s edit modal; exposes `value`, `span`, `info`, `hint` and `repeater-row` slots for per-field customisation.
|
|
3
|
+
Renders a single `FieldDefinition` of any type (text, select, autocomplete, checkbox, switch, repeater, currency, percentage, file/image, component, etc.) with its label, validation error, hint and help. Used by `DXForm` and `DXTable`'s edit modal; exposes `value`, `span`, `info`, `info-popover`, `hint` and `repeater-row` slots for per-field customisation.
|
|
4
4
|
-->
|
|
5
5
|
<template>
|
|
6
6
|
<!-- Full-width span field: delegate entirely to the #span slot -->
|
|
@@ -128,7 +128,7 @@
|
|
|
128
128
|
duplicated popover trigger. -->
|
|
129
129
|
<DFormGroup
|
|
130
130
|
v-else-if="field.type === 'switch' && isHorizontal"
|
|
131
|
-
:class="
|
|
131
|
+
:class="field.class || 'mb-3'"
|
|
132
132
|
v-bind="horizontalAttrs"
|
|
133
133
|
>
|
|
134
134
|
<!-- The row label (left column) names the control. The switch's own
|
|
@@ -156,10 +156,9 @@
|
|
|
156
156
|
:value="fieldValue"
|
|
157
157
|
:update="setValue"
|
|
158
158
|
/>
|
|
159
|
-
<
|
|
159
|
+
<DXSwitch
|
|
160
160
|
v-else
|
|
161
161
|
v-model="switchModel"
|
|
162
|
-
switch
|
|
163
162
|
:disabled="isDisabled || isReadonly"
|
|
164
163
|
v-bind="field.inputProps"
|
|
165
164
|
>
|
|
@@ -167,7 +166,7 @@
|
|
|
167
166
|
:label="switchText"
|
|
168
167
|
:class="switchHasContextualText ? undefined : 'visually-hidden'"
|
|
169
168
|
/>
|
|
170
|
-
</
|
|
169
|
+
</DXSwitch>
|
|
171
170
|
|
|
172
171
|
<DFormInvalidFeedback v-if="form.hasError(errorKey)" force-show>
|
|
173
172
|
{{ form.getError(errorKey) }}
|
|
@@ -179,10 +178,10 @@
|
|
|
179
178
|
<DFormText v-if="field.help">{{ field.help }}</DFormText>
|
|
180
179
|
</DFormGroup>
|
|
181
180
|
|
|
182
|
-
<!-- Switch (vertical, default): toggle with contextual on/off text
|
|
181
|
+
<!-- Switch (vertical, default): toggle with contextual on/off text (DXSwitch owns the filled-box on-state style) -->
|
|
183
182
|
<div
|
|
184
183
|
v-else-if="field.type === 'switch'"
|
|
185
|
-
:class="
|
|
184
|
+
:class="field.class || 'mb-3'"
|
|
186
185
|
>
|
|
187
186
|
<!--
|
|
188
187
|
@slot Replaces the built-in control with a custom value editor.
|
|
@@ -199,15 +198,14 @@
|
|
|
199
198
|
:value="fieldValue"
|
|
200
199
|
:update="setValue"
|
|
201
200
|
/>
|
|
202
|
-
<
|
|
201
|
+
<DXSwitch
|
|
203
202
|
v-else
|
|
204
203
|
v-model="switchModel"
|
|
205
|
-
switch
|
|
206
204
|
:disabled="isDisabled || isReadonly"
|
|
207
205
|
v-bind="field.inputProps"
|
|
208
206
|
>
|
|
209
207
|
<DXFieldLabel :label="switchText" :info="resolvedInfo" />
|
|
210
|
-
</
|
|
208
|
+
</DXSwitch>
|
|
211
209
|
|
|
212
210
|
<DFormInvalidFeedback v-if="form.hasError(errorKey)" force-show>
|
|
213
211
|
{{ form.getError(errorKey) }}
|
|
@@ -442,7 +440,7 @@
|
|
|
442
440
|
<!-- Text-based inputs (text/email/password/number/url/tel/date/time/datetime) -->
|
|
443
441
|
<DFormInput
|
|
444
442
|
v-else
|
|
445
|
-
v-model="
|
|
443
|
+
v-model="textFieldModel"
|
|
446
444
|
:type="inputType"
|
|
447
445
|
:required="field.required"
|
|
448
446
|
:placeholder="field.placeholder"
|
|
@@ -490,16 +488,20 @@ import {
|
|
|
490
488
|
defineAsyncComponent,
|
|
491
489
|
onBeforeUnmount,
|
|
492
490
|
onMounted,
|
|
491
|
+
provide,
|
|
493
492
|
ref,
|
|
494
493
|
useId,
|
|
494
|
+
useSlots,
|
|
495
495
|
watch,
|
|
496
496
|
} from "vue";
|
|
497
|
+
import { dxFieldInfoPopoverKey } from "./dxFieldContext";
|
|
497
498
|
import DFormGroup from "../base/DFormGroup.vue";
|
|
498
499
|
import DFormInput from "../base/DFormInput.vue";
|
|
499
500
|
import DFormTextarea from "../base/DFormTextarea.vue";
|
|
500
501
|
import DFormSelect from "../base/DFormSelect.vue";
|
|
501
502
|
import DFormRadioGroup from "../base/DFormRadioGroup.vue";
|
|
502
503
|
import DFormCheckbox from "../base/DFormCheckbox.vue";
|
|
504
|
+
import DXSwitch from "./DXSwitch.vue";
|
|
503
505
|
import DFormInvalidFeedback from "../base/DFormInvalidFeedback.vue";
|
|
504
506
|
import DFormText from "../base/DFormText.vue";
|
|
505
507
|
import DInputGroup from "../base/DInputGroup.vue";
|
|
@@ -559,6 +561,24 @@ const props = defineProps<Props>();
|
|
|
559
561
|
|
|
560
562
|
const effectiveModel = computed(() => props.model ?? props.form.data);
|
|
561
563
|
|
|
564
|
+
// Forward the `info-popover` slot to whichever DXFieldLabel this field renders,
|
|
565
|
+
// once — so the label sites stay plain `<DXFieldLabel :label :info />` and can't
|
|
566
|
+
// drift out of sync (each rendering its own copy of the forward). DXFieldLabel
|
|
567
|
+
// injects this; standalone use ignores it.
|
|
568
|
+
const slots = useSlots();
|
|
569
|
+
provide(
|
|
570
|
+
dxFieldInfoPopoverKey,
|
|
571
|
+
computed(() =>
|
|
572
|
+
slots["info-popover"]
|
|
573
|
+
? () =>
|
|
574
|
+
slots["info-popover"]!({
|
|
575
|
+
field: props.field,
|
|
576
|
+
model: props.model,
|
|
577
|
+
})
|
|
578
|
+
: null,
|
|
579
|
+
),
|
|
580
|
+
);
|
|
581
|
+
|
|
562
582
|
// ————————————————— horizontal layout (#66)
|
|
563
583
|
|
|
564
584
|
// A `span` field always bypasses the label/content column split — it's
|
|
@@ -662,6 +682,41 @@ const fieldValue = computed({
|
|
|
662
682
|
set: (value: any) => setValue(value),
|
|
663
683
|
});
|
|
664
684
|
|
|
685
|
+
// Native <input type=date/datetime-local/time> only accept a strict format
|
|
686
|
+
// (YYYY-MM-DD, YYYY-MM-DDTHH:MM, HH:MM) and render EMPTY for anything else —
|
|
687
|
+
// including Laravel's ISO-8601 (`…Z`) and `Y-m-d H:i:s` serialisations, so a
|
|
688
|
+
// field seeded with `created_at` shows a blank placeholder. Reshape the
|
|
689
|
+
// *displayed* value to the input's format for those types; the setter writes
|
|
690
|
+
// the input's native value straight back. This is a pure string reshape, not a
|
|
691
|
+
// timezone conversion — the stored wall-clock is shown as-is, so a UTC `…Z`
|
|
692
|
+
// value displays its UTC time (convert before seeding if you need local).
|
|
693
|
+
const DATE_FAMILY_TYPES = new Set<FieldType>(["date", "datetime", "time"]);
|
|
694
|
+
|
|
695
|
+
function normaliseDateForInput(value: unknown, type: FieldType): string {
|
|
696
|
+
if (value === null || value === undefined || value === "") return "";
|
|
697
|
+
const iso = String(value).replace(" ", "T"); // `Y-m-d H:i:s` → ISO-ish
|
|
698
|
+
if (type === "date") {
|
|
699
|
+
return iso.match(/^(\d{4}-\d{2}-\d{2})/)?.[1] ?? "";
|
|
700
|
+
}
|
|
701
|
+
if (type === "time") {
|
|
702
|
+
// Anchor to the start or the time part of an ISO value, so a malformed
|
|
703
|
+
// string can't yield a stray HH:MM from the middle.
|
|
704
|
+
return iso.match(/(?:^|T)(\d{2}:\d{2})/)?.[1] ?? "";
|
|
705
|
+
}
|
|
706
|
+
// datetime → datetime-local
|
|
707
|
+
return iso.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})/)?.[1] ?? "";
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Model for the text-based input branch. Reshapes date-family values for the
|
|
711
|
+
// native picker; every other type passes straight through.
|
|
712
|
+
const textFieldModel = computed({
|
|
713
|
+
get: () =>
|
|
714
|
+
DATE_FAMILY_TYPES.has(props.field.type)
|
|
715
|
+
? normaliseDateForInput(fieldValue.value, props.field.type)
|
|
716
|
+
: fieldValue.value,
|
|
717
|
+
set: (value: any) => setValue(value),
|
|
718
|
+
});
|
|
719
|
+
|
|
665
720
|
// For `percentage` fields with `asFraction`, the model holds a 0–1 fraction but
|
|
666
721
|
// the input shows/edits a 0–100 percentage. Scale on read/write, rounding away
|
|
667
722
|
// binary-float artefacts (0.2 * 100 = 20.000000000000004). Currency and plain
|
|
@@ -913,70 +968,4 @@ onBeforeUnmount(() => {
|
|
|
913
968
|
border-radius: var(--bs-border-radius);
|
|
914
969
|
}
|
|
915
970
|
|
|
916
|
-
/* Switch field: a compact "filled box" toggle, sized to its content — like the
|
|
917
|
-
plain `checkbox` field type, it doesn't stretch to fill the form row — but
|
|
918
|
-
matching standard input height so it lines up with sibling text/select
|
|
919
|
-
fields. Colour-codes its state: a neutral box when off, a filled success
|
|
920
|
-
green when on. The label sits on the left and the toggle on the right.
|
|
921
|
-
Renders the underlying `.form-check` (from the switch checkbox) as the box.
|
|
922
|
-
`justify-content: space-between` on a full-width box used to leave a wide
|
|
923
|
-
unclickable gap between the label and the toggle (only the tiny toggle
|
|
924
|
-
itself was a real click target); shrinking the box to fit its content
|
|
925
|
-
removes that dead zone entirely. Uses block-level `flex` (not `inline-flex`)
|
|
926
|
-
so the box stays shrink-to-fit yet still takes its own line: an inline-level
|
|
927
|
-
box let a following `<small class="form-text">` hint flow inline beside it
|
|
928
|
-
instead of dropping below the control like every other field type (#79). */
|
|
929
|
-
.dx-switch :deep(.form-check) {
|
|
930
|
-
display: flex;
|
|
931
|
-
align-items: center;
|
|
932
|
-
gap: 0.75rem;
|
|
933
|
-
width: fit-content;
|
|
934
|
-
max-width: 100%; /* wrap/ellipsize rather than overflow a narrow field */
|
|
935
|
-
min-height: var(--dx-input-height, calc(1.5em + 0.75rem + 2px)); /* match .form-control height */
|
|
936
|
-
margin: 0;
|
|
937
|
-
/* Override Bootstrap `.form-switch`'s 2.5em left padding for the floated input. */
|
|
938
|
-
padding: 0.375rem 0.75rem;
|
|
939
|
-
border: 1px solid var(--bs-border-color);
|
|
940
|
-
border-radius: var(--bs-border-radius);
|
|
941
|
-
background-color: var(--bs-body-bg);
|
|
942
|
-
transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
/* Label on the left, toggle on the right (DOM order is input then label). */
|
|
946
|
-
.dx-switch :deep(.form-check-label) {
|
|
947
|
-
order: 1;
|
|
948
|
-
margin: 0;
|
|
949
|
-
color: var(--bs-secondary-color);
|
|
950
|
-
cursor: pointer;
|
|
951
|
-
transition: color 0.15s ease-in-out;
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
.dx-switch :deep(.form-check-input) {
|
|
955
|
-
order: 2;
|
|
956
|
-
flex-shrink: 0;
|
|
957
|
-
/* Override `.form-switch`'s negative left margin now that we're flex-laid-out. */
|
|
958
|
-
margin: 0;
|
|
959
|
-
cursor: pointer;
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
/* On-state: filled success green. */
|
|
963
|
-
.dx-switch--on :deep(.form-check) {
|
|
964
|
-
background-color: var(--bs-success-bg-subtle);
|
|
965
|
-
border-color: var(--bs-success);
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
.dx-switch--on :deep(.form-check-label) {
|
|
969
|
-
color: var(--bs-success-text-emphasis);
|
|
970
|
-
font-weight: 500;
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
.dx-switch--on :deep(.form-check-input:checked) {
|
|
974
|
-
background-color: var(--bs-success);
|
|
975
|
-
border-color: var(--bs-success);
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
.dx-switch--on :deep(.form-check-input:focus) {
|
|
979
|
-
border-color: var(--bs-success);
|
|
980
|
-
box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb), 0.25);
|
|
981
|
-
}
|
|
982
971
|
</style>
|