@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.
Files changed (33) hide show
  1. package/dist/components/extended/DXDashboard.vue.d.ts +22 -6
  2. package/dist/components/extended/DXDashboardNavbar.vue.d.ts +16 -0
  3. package/dist/components/extended/DXFieldLabel.vue.d.ts +11 -1
  4. package/dist/components/extended/DXFormField.vue.d.ts +41 -0
  5. package/dist/components/extended/DXSwitch.vue.d.ts +30 -0
  6. package/dist/components/extended/dxFieldContext.d.ts +11 -0
  7. package/dist/dashboard-for-laravel.js +10913 -10827
  8. package/dist/dashboard-for-laravel.js.map +1 -1
  9. package/dist/dashboard-for-laravel.umd.cjs +8 -8
  10. package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
  11. package/dist/index.d.ts +2 -1
  12. package/dist/style.css +1 -1
  13. package/dist/types/index.d.ts +4 -1
  14. package/dist/types/navigation.d.ts +13 -0
  15. package/dist/utils/api.d.ts +1 -1
  16. package/docs/public/api-reference.json +255 -103
  17. package/docs/public/docs-map.md +3 -2
  18. package/docs/public/llms.txt +3 -2
  19. package/package.json +9 -5
  20. package/resources/css/theme.scss +22 -3
  21. package/resources/js/components/extended/DXDashboard.vue +32 -10
  22. package/resources/js/components/extended/DXDashboardNavbar.vue +104 -26
  23. package/resources/js/components/extended/DXField.vue +66 -77
  24. package/resources/js/components/extended/DXFieldLabel.vue +18 -3
  25. package/resources/js/components/extended/DXForm.vue +31 -109
  26. package/resources/js/components/extended/DXFormField.vue +98 -0
  27. package/resources/js/components/extended/DXSwitch.vue +153 -0
  28. package/resources/js/components/extended/dxFieldContext.ts +14 -0
  29. package/resources/js/composables/useForm.ts +10 -3
  30. package/resources/js/index.ts +3 -0
  31. package/resources/js/types/index.ts +4 -1
  32. package/resources/js/types/navigation.ts +15 -0
  33. 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.19.2",
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/mdx": "^4.3.8",
79
- "@astrojs/sitemap": "^3.6.0",
80
- "@astrojs/vue": "^5.1.1",
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": "^5.15.1",
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",
@@ -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: $dashboard-navbar-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: #f8f9fa;
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
- :logout-url="logoutUrl"
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
- <DCol cols="12" xl="10" :class="contentClass">
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 (`col-xl-10`) column. Use for data-heavy admin pages
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
- /** Logout URL for navbar dropdown */
111
- logoutUrl?: string;
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: #f8f9fa;
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: #fff;
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, centred
4
- search, page-level actions, and the user menu. Usually rendered by
5
- `DXDashboard`, which forwards these slots with a `navbar-` prefix.
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 justify-content-center"
43
+ class="dashboard-navbar__search d-flex"
44
+ :class="searchAlignClass"
43
45
  >
44
- <!-- @slot Search input or component. Centred in the bar on wider screens; drops to its own full-width row below the `md` breakpoint. -->
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
- <div class="dashboard-navbar__end d-flex align-items-center gap-3">
49
- <!-- Page-level primary actions, right-aligned next to the user menu -->
50
- <div
51
- v-if="$slots.actions"
52
- class="dashboard-navbar__actions d-flex align-items-center gap-2"
53
- >
54
- <!--
55
- @slot Page-level primary actions (e.g. a Create button), right-aligned next to the user menu.
56
- @binding {string} pageTitle The current page title, for context.
57
- -->
58
- <slot name="actions" :pageTitle="pageTitle" />
59
- </div>
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 / actions cluster to the right. */
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 sits on its own full-width row below the toggle /
150
- * title / user-menu row (order after both, flex-basis 100% forces the wrap).
151
- * From `md` up it moves inline between the title and the user menu and grows to
152
- * fill the middle, centring its content.
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
- width: auto;
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: 2;
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="[field.class || 'mb-3', 'dx-switch', { 'dx-switch--on': switchIsOn }]"
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
- <DFormCheckbox
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
- </DFormCheckbox>
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 and an on-state style -->
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="[field.class || 'mb-3', 'dx-switch', { 'dx-switch--on': switchIsOn }]"
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
- <DFormCheckbox
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
- </DFormCheckbox>
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="fieldValue"
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>