@omnitend/dashboard-for-laravel 0.41.1 → 0.42.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 (62) hide show
  1. package/README.md +2 -1
  2. package/dist/assets/MavenPro-Bold-35b16f06.woff2 +0 -0
  3. package/dist/assets/MavenPro-Medium-febc8674.woff2 +0 -0
  4. package/dist/assets/MavenPro-Regular-aee3df9e.woff2 +0 -0
  5. package/dist/assets/MavenPro-SemiBold-c555dbfa.woff2 +0 -0
  6. package/dist/assets/Poppins-Medium-5800fa57.woff2 +0 -0
  7. package/dist/assets/Poppins-SemiBold-94a36267.woff2 +0 -0
  8. package/dist/charts.js +148 -130
  9. package/dist/charts.js.map +1 -1
  10. package/dist/charts.umd.cjs +1 -1
  11. package/dist/charts.umd.cjs.map +1 -1
  12. package/dist/components/base/DModal.vue.d.ts +26 -2
  13. package/dist/components/charts/chartTheme.d.ts +14 -2
  14. package/dist/components/extended/DXField.vue.d.ts +16 -5
  15. package/dist/components/extended/DXForm.vue.d.ts +25 -9
  16. package/dist/components/extended/DXLoading.vue.d.ts +9 -0
  17. package/dist/components/extended/DXModalActions.vue.d.ts +12 -0
  18. package/dist/components/extended/DXNumberStepper.vue.d.ts +33 -0
  19. package/dist/components/extended/DXSaveButton.vue.d.ts +38 -0
  20. package/dist/components/extended/DXSwitch.vue.d.ts +1 -1
  21. package/dist/components/extended/DXTable.vue.d.ts +8 -2
  22. package/dist/composables/useForm.d.ts +7 -0
  23. package/dist/dashboard-for-laravel.js +9547 -9246
  24. package/dist/dashboard-for-laravel.js.map +1 -1
  25. package/dist/dashboard-for-laravel.umd.cjs +7 -7
  26. package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
  27. package/dist/index.d.ts +5 -0
  28. package/dist/style.css +2 -2
  29. package/dist/types/index.d.ts +20 -3
  30. package/docs/public/api-reference.json +219 -17
  31. package/docs/public/docs-map.md +4 -2
  32. package/docs/public/llms.txt +7 -2
  33. package/package.json +1 -1
  34. package/resources/css/theme.scss +524 -103
  35. package/resources/fonts/maven-pro/MavenPro-Bold.woff2 +0 -0
  36. package/resources/fonts/maven-pro/MavenPro-Medium.woff2 +0 -0
  37. package/resources/fonts/maven-pro/MavenPro-Regular.woff2 +0 -0
  38. package/resources/fonts/maven-pro/MavenPro-SemiBold.woff2 +0 -0
  39. package/resources/fonts/maven-pro/OFL.txt +93 -0
  40. package/resources/fonts/poppins/OFL.txt +93 -0
  41. package/resources/fonts/poppins/Poppins-Medium.woff2 +0 -0
  42. package/resources/fonts/poppins/Poppins-SemiBold.woff2 +0 -0
  43. package/resources/js/components/base/DModal.vue +13 -2
  44. package/resources/js/components/charts/DXBarChart.vue +2 -1
  45. package/resources/js/components/charts/DXDoughnutChart.vue +2 -1
  46. package/resources/js/components/charts/DXLineChart.vue +2 -1
  47. package/resources/js/components/charts/chartTheme.ts +68 -21
  48. package/resources/js/components/extended/DXDashboard.vue +4 -2
  49. package/resources/js/components/extended/DXDashboardSidebar.vue +52 -20
  50. package/resources/js/components/extended/DXField.vue +59 -28
  51. package/resources/js/components/extended/DXForm.vue +122 -21
  52. package/resources/js/components/extended/DXLoading.vue +47 -0
  53. package/resources/js/components/extended/DXModalActions.vue +13 -0
  54. package/resources/js/components/extended/DXNumberStepper.vue +243 -0
  55. package/resources/js/components/extended/DXSaveButton.vue +82 -0
  56. package/resources/js/components/extended/DXSwitch.vue +8 -5
  57. package/resources/js/components/extended/DXTable.vue +58 -22
  58. package/resources/js/components/extended/DXTablePagination.vue +1 -1
  59. package/resources/js/composables/useForm.ts +11 -0
  60. package/resources/js/composables/useToast.ts +1 -1
  61. package/resources/js/index.ts +5 -0
  62. package/resources/js/types/index.ts +21 -3
@@ -118,17 +118,18 @@
118
118
  </DXFormField>
119
119
 
120
120
  <!-- Submit button -->
121
- <DButton
121
+ <DXSaveButton
122
122
  v-if="showSubmit"
123
123
  type="submit"
124
- variant="primary"
125
124
  block
126
- :loading="resolvedForm.processing"
127
- :loading-text="submitLoadingText"
125
+ :saving="resolvedForm.processing"
126
+ :saving-text="submitLoadingText"
127
+ :saved="isSaved"
128
+ :saved-text="submitSavedText"
128
129
  class="mt-3"
129
130
  >
130
131
  {{ submitText }}
131
- </DButton>
132
+ </DXSaveButton>
132
133
 
133
134
  <!--
134
135
  @slot Content rendered below the submit button (e.g. a cancel link or secondary actions).
@@ -145,17 +146,18 @@
145
146
  trailing form-level actions. -->
146
147
  <template v-else>
147
148
  <!-- Submit button -->
148
- <DButton
149
+ <DXSaveButton
149
150
  v-if="showSubmit"
150
151
  type="submit"
151
- variant="primary"
152
152
  block
153
- :loading="resolvedForm.processing"
154
- :loading-text="submitLoadingText"
153
+ :saving="resolvedForm.processing"
154
+ :saving-text="submitLoadingText"
155
+ :saved="isSaved"
156
+ :saved-text="submitSavedText"
155
157
  class="mt-3"
156
158
  >
157
159
  {{ submitText }}
158
- </DButton>
160
+ </DXSaveButton>
159
161
 
160
162
  <!--
161
163
  @slot Content rendered below the submit button (e.g. a cancel link or secondary actions).
@@ -167,14 +169,20 @@
167
169
  </template>
168
170
 
169
171
  <script setup lang="ts">
170
- import { computed, ref, watch, type ComponentPublicInstance } from "vue";
172
+ import {
173
+ computed,
174
+ onBeforeUnmount,
175
+ ref,
176
+ watch,
177
+ type ComponentPublicInstance,
178
+ } from "vue";
171
179
  import { BForm } from "bootstrap-vue-next";
172
180
  import DAlert from "../base/DAlert.vue";
173
- import DButton from "../base/DButton.vue";
174
181
  import DCard from "../base/DCard.vue";
175
182
  import DTabs from "../base/DTabs.vue";
176
183
  import { BTab as DTab } from "bootstrap-vue-next"; // raw BTab: BTabs scans slot vnodes for it (#119)
177
184
  import DXFormField from "./DXFormField.vue";
185
+ import DXSaveButton from "./DXSaveButton.vue";
178
186
  import type { UseFormReturn } from "../../composables/useForm";
179
187
  import type { DefineFormReturn } from "../../composables/defineForm";
180
188
  import { useContainerWidth } from "../../composables/useContainerWidth";
@@ -211,6 +219,17 @@ interface Props {
211
219
  /** Submit button loading text */
212
220
  submitLoadingText?: string;
213
221
 
222
+ /** Submit button label once the form's contents are saved. */
223
+ submitSavedText?: string;
224
+
225
+ /**
226
+ * After a successful submission through the form (`form.post/put/…`,
227
+ * read from `form.wasSuccessful`), turn the submit button into a
228
+ * disabled "✓ Saved" until any field changes. On by default; set
229
+ * `false` for forms whose submit is not a save (search, filter).
230
+ */
231
+ savedState?: boolean;
232
+
214
233
  /** Show the submit button */
215
234
  showSubmit?: boolean;
216
235
 
@@ -240,7 +259,10 @@ interface Props {
240
259
  * Form-wide field layout:
241
260
  *
242
261
  * - `"vertical"` (default) — label above input, always.
243
- * - `"horizontal"` — label left, input right, always.
262
+ * - `"horizontal"` — label left, input right, whatever the container
263
+ * width. With the default label column that holds from the `sm`
264
+ * viewport breakpoint up (the label stacks on a phone); a numeric
265
+ * `labelCols` keeps the split at every width.
244
266
  * - `"auto"` — horizontal when the form's **own container** is at least
245
267
  * `layoutThreshold` px wide, vertical below that. Container-driven, not
246
268
  * viewport-driven: a page narrowed by the dashboard sidebar, or a form
@@ -256,20 +278,22 @@ interface Props {
256
278
  * Container width (px) at or above which `layout: "auto"` goes horizontal.
257
279
  * Ignored for the explicit `"vertical"`/`"horizontal"` layouts.
258
280
  *
259
- * Default 640, measured rather than guessed: with the default 3-column
260
- * label the label's text area is `containerWidth / 4 - 18` px, so 640 gives
261
- * it 142px — enough for a ~20-character label ("Unit price (ex VAT)"
262
- * measures 128px at the theme's label font) to stay on one line, with the
263
- * control column still 474px. Below ~584px that label starts wrapping,
264
- * which is the cramped-label symptom this exists to avoid. Raise it if your
265
- * labels run longer, or lower `labelCols` instead.
281
+ * Default 640, measured when the default label was 3 columns: that gave
282
+ * the label 142px of text at 640px, just enough for a ~20-character label
283
+ * ("Unit price (ex VAT)" measures 128px at the theme's label font) to stay
284
+ * on one line. With the 45% default label column the same 640px gives the
285
+ * label ~275px and the control ~341px, so the threshold now mostly keeps
286
+ * the control column usable. Raise it if your controls need more room, or
287
+ * set a narrower `labelCols`.
266
288
  */
267
289
  layoutThreshold?: number;
268
290
 
269
291
  /**
270
292
  * Label column width for horizontal layout (mirrors BFormGroup's
271
293
  * `labelCols`/`labelCols*` props). Overridable per-field via
272
- * `field.labelCols`. Ignored when `layout` is "vertical".
294
+ * `field.labelCols`. Ignored when `layout` is "vertical". Omitted (here
295
+ * and on the field): the label takes `--dx-form-label-width` (45%) from
296
+ * `sm` up and stacks above the input below `sm`.
273
297
  */
274
298
  labelCols?: LabelCols;
275
299
  }
@@ -277,6 +301,8 @@ interface Props {
277
301
  const props = withDefaults(defineProps<Props>(), {
278
302
  submitText: "Submit",
279
303
  submitLoadingText: "Submitting...",
304
+ submitSavedText: "Saved",
305
+ savedState: true,
280
306
  showSubmit: true,
281
307
  autoErrorTab: true,
282
308
  card: false,
@@ -480,6 +506,81 @@ watch(
480
506
  { immediate: true },
481
507
  );
482
508
 
509
+ // ————————————————— saved state (submit button shows "✓ Saved")
510
+
511
+ /**
512
+ * True from a successful save until the form's data next changes.
513
+ *
514
+ * Three kinds of data write have to be told apart:
515
+ *
516
+ * - a user edit BEFORE the response (while the request is in flight) — not
517
+ * in the payload, so the save must not be reported as covering it;
518
+ * - the save's own follow-up writes — `onSuccess` copying the response into
519
+ * the form, `resetOnSuccess`, consumer code after `await form.post()` —
520
+ * which all run in the response's macrotask and are not edits;
521
+ * - a user edit AFTER the save — clears the saved state.
522
+ *
523
+ * So the success (`wasSuccessful` turning true) opens a one-macrotask window
524
+ * in which writes are treated as part of the save, and `isSaved` is set when
525
+ * that window closes, unless the user edited between submit and response.
526
+ * All three watchers are synchronous so the ordering is exact, not dependent
527
+ * on Vue's scheduler.
528
+ */
529
+ const isSaved = ref(false);
530
+ let savedTimer: ReturnType<typeof setTimeout> | null = null;
531
+ let isInSaveResponse = false;
532
+ let wasEditedSinceSubmit = false;
533
+
534
+ function cancelPendingSaved(): void {
535
+ if (savedTimer !== null) clearTimeout(savedTimer);
536
+ savedTimer = null;
537
+ isInSaveResponse = false;
538
+ }
539
+
540
+ watch(
541
+ () => resolvedForm.value.processing,
542
+ (isProcessing) => {
543
+ if (!isProcessing) return;
544
+ cancelPendingSaved();
545
+ isSaved.value = false;
546
+ wasEditedSinceSubmit = false;
547
+ },
548
+ { flush: "sync" },
549
+ );
550
+
551
+ watch(
552
+ () => resolvedForm.value.wasSuccessful,
553
+ (wasSuccessful) => {
554
+ if (!wasSuccessful || !props.savedState) return;
555
+ cancelPendingSaved();
556
+ isInSaveResponse = true;
557
+ savedTimer = setTimeout(() => {
558
+ savedTimer = null;
559
+ isInSaveResponse = false;
560
+ // A new submit or a form swap in the meantime cancels this timer.
561
+ if (!wasEditedSinceSubmit) isSaved.value = true;
562
+ }, 0);
563
+ },
564
+ { flush: "sync" },
565
+ );
566
+
567
+ watch(
568
+ () => resolvedForm.value.data,
569
+ () => {
570
+ isSaved.value = false;
571
+ if (!isInSaveResponse) wasEditedSinceSubmit = true;
572
+ },
573
+ { deep: true, flush: "sync" },
574
+ );
575
+
576
+ // A different form instance (or turning the feature off) starts unsaved.
577
+ watch([resolvedForm, () => props.savedState], () => {
578
+ cancelPendingSaved();
579
+ isSaved.value = false;
580
+ });
581
+
582
+ onBeforeUnmount(cancelPendingSaved);
583
+
483
584
  function handleSubmit(): void {
484
585
  emit("submit");
485
586
  }
@@ -0,0 +1,47 @@
1
+ <template>
2
+ <div
3
+ v-if="isVisible"
4
+ class="dx-loading text-center text-muted py-5"
5
+ role="status"
6
+ aria-live="polite"
7
+ >
8
+ <DSpinner variant="primary" aria-hidden="true" />
9
+ <div class="mt-2">{{ text }}</div>
10
+ </div>
11
+ </template>
12
+
13
+ <script setup lang="ts">
14
+ import { onBeforeUnmount, onMounted, ref } from "vue";
15
+ import DSpinner from "../base/DSpinner.vue";
16
+
17
+ const props = withDefaults(
18
+ defineProps<{
19
+ text?: string;
20
+ delay?: number;
21
+ }>(),
22
+ {
23
+ text: "Loading…",
24
+ delay: 300,
25
+ },
26
+ );
27
+
28
+ const isVisible = ref(false);
29
+ let showTimer: ReturnType<typeof setTimeout> | undefined;
30
+
31
+ onMounted(() => {
32
+ if (props.delay <= 0) {
33
+ isVisible.value = true;
34
+ return;
35
+ }
36
+
37
+ showTimer = setTimeout(() => {
38
+ isVisible.value = true;
39
+ }, props.delay);
40
+ });
41
+
42
+ onBeforeUnmount(() => {
43
+ if (showTimer !== undefined) {
44
+ clearTimeout(showTimer);
45
+ }
46
+ });
47
+ </script>
@@ -0,0 +1,13 @@
1
+ <!-- Shared secondary modal actions; distinct from the modal footer controls. -->
2
+ <template>
3
+ <div class="dx-modal-actions"><slot /></div>
4
+ </template>
5
+
6
+ <style scoped>
7
+ .dx-modal-actions {
8
+ display: flex;
9
+ flex-direction: column;
10
+ align-items: stretch;
11
+ gap: 0.75rem;
12
+ }
13
+ </style>
@@ -0,0 +1,243 @@
1
+ <!--
2
+ DXNumberStepper — a number input between a decrease and an increase button.
3
+
4
+ `[ − ][ 12 ][ + ]` as one input group, for quantities that are usually
5
+ nudged rather than typed (cases on an order line, covers on a booking).
6
+
7
+ - The model is a number, or `null` when the input is empty (never `NaN`,
8
+ never `""`). Every step and every valid keystroke emits `update:modelValue`;
9
+ debounce on the consumer's side if each change saves.
10
+ - A step adds or subtracts `step` from the current value and clamps to
11
+ `min`/`max`. It does not snap to a grid, so 1.05 + 0.1 is 1.15. The sum is
12
+ rounded to the decimal places of the value and step, so 0.1 three times is
13
+ 0.3, not 0.30000000000000004. From empty, a step goes to the in-range value
14
+ nearest zero.
15
+ - The button at a bound is disabled (decrease at `min`, increase at `max`).
16
+ - Typing is free: the value is emitted as typed, even out of range, and is
17
+ CLAMPED ON BLUR to `min`/`max` (emitting the clamped value). Half-typed
18
+ input the browser cannot parse ("-", "1e") emits nothing until it parses.
19
+ - ArrowUp/ArrowDown on the input go through the same step as the buttons
20
+ (the browser's own spin-button stepping snaps to a grid; this does not).
21
+ The native spin arrows are hidden. The buttons are `tabindex="-1"`, so a
22
+ list of steppers is one Tab stop each; keyboard users step with the arrows.
23
+ - The buttons are square icon buttons at the input's height (never narrower
24
+ than tall), soft `secondary` like other in-row actions, labelled
25
+ "Decrease"/"Increase" plus the input's `aria-label` when it has one.
26
+
27
+ `class` and `style` go on the group; every other attribute (`aria-label`,
28
+ `name`, `id`, `placeholder`, `required`, `state`, listeners…) goes on the
29
+ input. Styles live in theme.scss under `.dx-number-stepper`.
30
+ -->
31
+ <template>
32
+ <DInputGroup
33
+ :size="groupSize"
34
+ :class="['dx-number-stepper', attrs.class]"
35
+ :style="attrs.style as StyleValue"
36
+ >
37
+ <template #prepend>
38
+ <DButton
39
+ class="dx-number-stepper__decrease"
40
+ variant="secondary"
41
+ icon="dash-lg"
42
+ icon-only
43
+ :size="groupSize"
44
+ :disabled="disabled || isAtMin"
45
+ :aria-label="decreaseAriaLabel"
46
+ tabindex="-1"
47
+ @click="stepBy(-1)"
48
+ />
49
+ </template>
50
+ <DFormInput
51
+ v-bind="inputAttrs"
52
+ :model-value="displayValue"
53
+ type="number"
54
+ inputmode="decimal"
55
+ :min="min"
56
+ :max="max"
57
+ :step="step"
58
+ :disabled="disabled"
59
+ class="dx-number-stepper__input"
60
+ @input="handleInput"
61
+ @focus="isFocused = true"
62
+ @blur="handleBlur"
63
+ @keydown="handleKeydown"
64
+ />
65
+ <template #append>
66
+ <DButton
67
+ class="dx-number-stepper__increase"
68
+ variant="secondary"
69
+ icon="plus-lg"
70
+ icon-only
71
+ :size="groupSize"
72
+ :disabled="disabled || isAtMax"
73
+ :aria-label="increaseAriaLabel"
74
+ tabindex="-1"
75
+ @click="stepBy(1)"
76
+ />
77
+ </template>
78
+ </DInputGroup>
79
+ </template>
80
+
81
+ <script setup lang="ts">
82
+ import { computed, ref, useAttrs, watch } from "vue";
83
+ import type { StyleValue } from "vue";
84
+ import DInputGroup from "../base/DInputGroup.vue";
85
+ import DFormInput from "../base/DFormInput.vue";
86
+ import DButton from "../base/DButton.vue";
87
+
88
+ defineOptions({ inheritAttrs: false });
89
+
90
+ interface Props {
91
+ /** The value (v-model). `null` when the input is empty. */
92
+ modelValue?: number | null;
93
+ /** Lowest value; the decrease button disables here and blur clamps to it. */
94
+ min?: number;
95
+ /** Highest value; the increase button disables here and blur clamps to it. */
96
+ max?: number;
97
+ /** How much one press (or ArrowUp/ArrowDown) changes the value. @default 1 */
98
+ step?: number;
99
+ /** Control size, matching the other inputs. @default 'md' */
100
+ size?: "sm" | "md" | "lg";
101
+ /** Disable the input and both buttons. */
102
+ disabled?: boolean;
103
+ /** Override the decrease button's accessible label. Default: "Decrease", plus the input's aria-label. */
104
+ decreaseLabel?: string;
105
+ /** Override the increase button's accessible label. Default: "Increase", plus the input's aria-label. */
106
+ increaseLabel?: string;
107
+ }
108
+
109
+ const props = withDefaults(defineProps<Props>(), {
110
+ modelValue: null,
111
+ min: undefined,
112
+ max: undefined,
113
+ step: 1,
114
+ size: "md",
115
+ disabled: false,
116
+ decreaseLabel: undefined,
117
+ increaseLabel: undefined,
118
+ });
119
+
120
+ const emit = defineEmits<{
121
+ "update:modelValue": [value: number | null];
122
+ }>();
123
+
124
+ const attrs = useAttrs();
125
+
126
+ // `class`/`style` belong to the group; everything else is for the input.
127
+ const inputAttrs = computed(() => {
128
+ const { class: _class, style: _style, ...rest } = attrs;
129
+ return rest;
130
+ });
131
+
132
+ // bvn's Size has no 'md' (it is the default), so md means "no size".
133
+ const groupSize = computed(() => (props.size === "md" ? undefined : props.size));
134
+
135
+ const fieldLabel = computed(() => {
136
+ const label = attrs["aria-label"];
137
+ return typeof label === "string" && label.trim() !== "" ? label.trim() : "";
138
+ });
139
+ const decreaseAriaLabel = computed(
140
+ () => props.decreaseLabel ?? (fieldLabel.value === "" ? "Decrease" : `Decrease ${fieldLabel.value}`),
141
+ );
142
+ const increaseAriaLabel = computed(
143
+ () => props.increaseLabel ?? (fieldLabel.value === "" ? "Increase" : `Increase ${fieldLabel.value}`),
144
+ );
145
+
146
+ // The value the buttons step from: the last value emitted or received.
147
+ const currentValue = ref<number | null>(props.modelValue);
148
+ // The shown text tracks a local ref so typing is never rewritten mid-edit
149
+ // ("0." must not become "0"); it resyncs from the model while not focused.
150
+ const displayValue = ref(format(props.modelValue));
151
+ const isFocused = ref(false);
152
+
153
+ watch(
154
+ () => props.modelValue,
155
+ (value) => {
156
+ currentValue.value = value;
157
+ if (!isFocused.value) displayValue.value = format(value);
158
+ },
159
+ );
160
+
161
+ const isAtMin = computed(
162
+ () => props.min !== undefined && currentValue.value !== null && currentValue.value <= props.min,
163
+ );
164
+ const isAtMax = computed(
165
+ () => props.max !== undefined && currentValue.value !== null && currentValue.value >= props.max,
166
+ );
167
+
168
+ function format(value: number | null | undefined): string {
169
+ if (value === null || value === undefined || !Number.isFinite(value)) return "";
170
+ return String(value);
171
+ }
172
+
173
+ /** Decimal places in a number's shortest representation (handles 1e-7). */
174
+ function decimalPlaces(value: number): number {
175
+ if (!Number.isFinite(value)) return 0;
176
+ const [mantissa, exponent] = String(value).split("e");
177
+ const fraction = mantissa.split(".")[1] ?? "";
178
+ const places = fraction.length - (exponent === undefined ? 0 : Number(exponent));
179
+ return Math.min(20, Math.max(0, places));
180
+ }
181
+
182
+ function clamp(value: number): number {
183
+ let clamped = value;
184
+ if (props.min !== undefined && clamped < props.min) clamped = props.min;
185
+ if (props.max !== undefined && clamped > props.max) clamped = props.max;
186
+ return clamped;
187
+ }
188
+
189
+ function commit(value: number | null): void {
190
+ currentValue.value = value;
191
+ displayValue.value = format(value);
192
+ emit("update:modelValue", value);
193
+ }
194
+
195
+ function stepBy(direction: 1 | -1): void {
196
+ if (props.disabled) return;
197
+ const stepSize = Number.isFinite(props.step) && props.step > 0 ? props.step : 1;
198
+ const from = currentValue.value;
199
+ if (from === null) {
200
+ commit(clamp(0));
201
+ return;
202
+ }
203
+ const places = Math.max(decimalPlaces(from), decimalPlaces(stepSize));
204
+ const next = clamp(Number((from + direction * stepSize).toFixed(places)));
205
+ if (next !== from) commit(next);
206
+ }
207
+
208
+ function handleInput(event: Event): void {
209
+ const target = event.target as HTMLInputElement;
210
+ const raw = target.value;
211
+ displayValue.value = raw;
212
+ if (raw.trim() === "") {
213
+ // A number input reports "" for unparseable text too ("-", "1e");
214
+ // wait for it to parse rather than emitting null mid-typing.
215
+ if (target.validity?.badInput) return;
216
+ currentValue.value = null;
217
+ emit("update:modelValue", null);
218
+ return;
219
+ }
220
+ const parsed = Number(raw);
221
+ if (!Number.isFinite(parsed)) return;
222
+ currentValue.value = parsed;
223
+ emit("update:modelValue", parsed);
224
+ }
225
+
226
+ // Consumer @blur/@focus/@keydown listeners arrive through `inputAttrs` and are
227
+ // merged with these by Vue, so they are not re-emitted.
228
+ function handleBlur(): void {
229
+ isFocused.value = false;
230
+ const value = currentValue.value;
231
+ if (value !== null && clamp(value) !== value) {
232
+ commit(clamp(value));
233
+ return;
234
+ }
235
+ displayValue.value = format(value);
236
+ }
237
+
238
+ function handleKeydown(event: KeyboardEvent): void {
239
+ if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
240
+ event.preventDefault();
241
+ stepBy(event.key === "ArrowUp" ? 1 : -1);
242
+ }
243
+ </script>
@@ -0,0 +1,82 @@
1
+ <!--
2
+ DXSaveButton — a save button that confirms the save in place.
3
+
4
+ Three states, driven by two booleans the consumer owns:
5
+
6
+ - idle (`saving` and `saved` both false) — the normal Save button.
7
+ - saving (`saving`) — DButton's busy state: disabled at once, spinner after
8
+ the anti-flash delay, `savingText` if given.
9
+ - saved (`saved` and not `saving`) — "✓ Saved" (tick icon) and genuinely
10
+ `disabled`, until the consumer clears `saved` (typically on the next edit
11
+ to the form). The colour does not change: the button keeps its own
12
+ variant (navy primary by default) at full strength, neither the neutral
13
+ grey of a disabled button nor a faded one. Only the label and the
14
+ disabled state say it is saved.
15
+
16
+ `saving` wins over `saved`, so re-saving from the saved state shows busy.
17
+ The label change is announced: the button itself is a polite live region,
18
+ and while it is `aria-busy` (saving) announcements wait until it settles,
19
+ so a screen reader hears "Saved" once the save lands.
20
+
21
+ Every other attribute (type, block, size, class, listeners…) passes through
22
+ to DButton. DXForm uses this for its submit button.
23
+ -->
24
+ <template>
25
+ <DButton
26
+ v-bind="$attrs"
27
+ :variant="variant"
28
+ :icon="isShowingSaved ? 'check-lg' : icon"
29
+ :loading="saving"
30
+ :loading-text="savingText"
31
+ :disabled="disabled || isShowingSaved"
32
+ :class="{ 'dx-save-button--saved': isShowingSaved }"
33
+ aria-live="polite"
34
+ >
35
+ <template v-if="isShowingSaved">{{ savedText }}</template>
36
+ <slot v-else>Save</slot>
37
+ </DButton>
38
+ </template>
39
+
40
+ <script setup lang="ts">
41
+ import { computed } from "vue";
42
+ import type { ButtonVariant } from "bootstrap-vue-next";
43
+ import DButton from "../base/DButton.vue";
44
+
45
+ defineOptions({ inheritAttrs: false });
46
+
47
+ interface Props {
48
+ /** A save is in flight: busy/spinner state (DButton's `loading`). */
49
+ saving?: boolean;
50
+
51
+ /**
52
+ * The form's current contents have been saved: show `savedText` with a
53
+ * tick and disable the button. Clear it when the form changes.
54
+ */
55
+ saved?: boolean;
56
+
57
+ /** Label for the saved state. @default 'Saved' */
58
+ savedText?: string;
59
+
60
+ /** Label shown beside the spinner while saving (DButton's `loadingText`). */
61
+ savingText?: string;
62
+
63
+ /** Variant in every state; the saved state keeps it. @default 'primary' */
64
+ variant?: ButtonVariant | null;
65
+
66
+ /** Leading icon when not saved. The saved state shows `check-lg`. */
67
+ icon?: string;
68
+
69
+ /** Disable the button regardless of state. */
70
+ disabled?: boolean;
71
+ }
72
+
73
+ const props = withDefaults(defineProps<Props>(), {
74
+ saving: false,
75
+ saved: false,
76
+ savedText: "Saved",
77
+ variant: "primary",
78
+ disabled: false,
79
+ });
80
+
81
+ const isShowingSaved = computed(() => props.saved && !props.saving);
82
+ </script>
@@ -5,7 +5,7 @@
5
5
  An opinionated switch built on `DFormCheckbox` (`switch`): a bordered box sized
6
6
  like a `.form-control` with the label on the left and the toggle on the right,
7
7
  colour-coding its state. By default (`onVariant="success"`) it reads the house
8
- green-on / red-off style: a light-red box with a grey toggle when off, a
8
+ green-on / red-off style: a light-red box with a muted red-grey toggle when off, a
9
9
  soft-green box with a muted green-grey toggle when on. `onVariant="neutral"` (brand primary on / grey off)
10
10
  is for semantically-mixed switches that shouldn't imply good/bad (#158). Used
11
11
  standalone or, internally, by `DXField`'s `switch` field type so a form switch
@@ -64,7 +64,7 @@ interface Props {
64
64
  */
65
65
  onVariant?: "success" | "neutral";
66
66
  /**
67
- * `"sm"` renders a compact box (a few px shorter than the default, which
67
+ * `"sm"` renders a compact rounded pill (a few px shorter than the default, which
68
68
  * matches the input height) for dense rows that repeat the switch per item (a
69
69
  * per-line visibility toggle in a modal). Omit for the standard size, which
70
70
  * lines the switch up with the `.form-control`
@@ -147,6 +147,7 @@ defineOptions({
147
147
  chrome tightens. */
148
148
  .dx-switch--sm :deep(.form-check) {
149
149
  font-size: 0.875rem;
150
+ border-radius: var(--bs-border-radius-pill, 50rem);
150
151
  min-height: auto;
151
152
  padding: 0.25rem 0.625rem;
152
153
  }
@@ -173,6 +174,10 @@ defineOptions({
173
174
  glance (the house style). `success` (default) is green ON / light-red OFF;
174
175
  `neutral` keeps a brand primary panel for mixed-semantics switches. Pair with
175
176
  `textWhenTrue`/`textWhenFalse` so the LABEL also names each state. */
177
+ .dx-switch--success {
178
+ --dx-switch-pill: var(--dx-switch-off-pill);
179
+ }
180
+
176
181
  .dx-switch--success :deep(.form-check) {
177
182
  background-color: var(--dx-switch-off-bg);
178
183
  border-color: var(--dx-switch-off-line);
@@ -189,14 +194,13 @@ defineOptions({
189
194
 
190
195
  .dx-switch--success.dx-switch--on :deep(.form-check-label) {
191
196
  color: var(--dx-switch-on-ink);
192
- font-weight: 500;
193
197
  }
194
198
 
195
199
  .dx-switch--success.dx-switch--on {
196
200
  --dx-switch-pill: var(--dx-switch-on-pill);
197
201
  }
198
202
 
199
- /* Muted pill with a white sliding thumb: green-grey ON, neutral grey OFF.
203
+ /* Muted pill with a white sliding thumb: green-grey ON, red-grey OFF.
200
204
  The stronger box colour and position continue to communicate the state. Explicit `:checked`/`:focus` selectors so they out-specify the global
201
205
  `.form-switch .form-check-input:checked` rule. */
202
206
  .dx-switch--success :deep(.form-check-input),
@@ -220,6 +224,5 @@ defineOptions({
220
224
 
221
225
  .dx-switch--neutral.dx-switch--on :deep(.form-check-label) {
222
226
  color: var(--bs-primary-text-emphasis);
223
- font-weight: 500;
224
227
  }
225
228
  </style>