@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.
@@ -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 v-if="group.visible !== false" class="nav-group mb-3">
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
- <ul class="nav flex-column gap-1">
38
- <li v-for="(item, itemIndex) in group.items" :key="itemIndex" class="nav-item">
39
- <slot
40
- name="link"
41
- :item="item"
42
- :is-active="isActive(item.url)"
43
- :collapsed="collapsed"
44
- >
45
- <a
46
- :href="item.url"
47
- class="nav-link d-flex align-items-center gap-2 rounded"
48
- :class="{
49
- 'active': isActive(item.url),
50
- 'justify-content-center': collapsed
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
- <component
54
- v-if="item.icon"
55
- :is="item.icon"
56
- class="nav-icon"
57
- style="width: 20px; height: 20px;"
58
- />
59
- <span v-if="!collapsed" class="nav-label">{{ item.label }}</span>
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
- {{ item.badge }}
66
- </span>
67
- </a>
68
- </slot>
69
- </li>
70
- </ul>
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
- const isActive = (url: string): boolean => {
104
- // Normalize URLs for comparison (remove trailing slash, lowercase)
105
- const normalizeUrl = (u: string) => u.toLowerCase().replace(/\/$/, '');
106
- return normalizeUrl(props.currentUrl) === normalizeUrl(url);
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
- // Watch for URL changes (client-side routing) and scroll smoothly
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
- {{ resolvedLabel }}
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 :label="resolvedLabel">
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 :label="resolvedLabel" :class="field.class || 'mb-3'">
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="fieldValue"
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>