@omnitend/dashboard-for-laravel 0.41.2 → 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.
- package/README.md +2 -1
- package/dist/assets/MavenPro-Bold-35b16f06.woff2 +0 -0
- package/dist/assets/MavenPro-Medium-febc8674.woff2 +0 -0
- package/dist/assets/MavenPro-Regular-aee3df9e.woff2 +0 -0
- package/dist/assets/MavenPro-SemiBold-c555dbfa.woff2 +0 -0
- package/dist/assets/Poppins-Medium-5800fa57.woff2 +0 -0
- package/dist/assets/Poppins-SemiBold-94a36267.woff2 +0 -0
- package/dist/charts.js +148 -130
- package/dist/charts.js.map +1 -1
- package/dist/charts.umd.cjs +1 -1
- package/dist/charts.umd.cjs.map +1 -1
- package/dist/components/charts/chartTheme.d.ts +14 -2
- package/dist/components/extended/DXField.vue.d.ts +16 -5
- package/dist/components/extended/DXForm.vue.d.ts +25 -9
- package/dist/components/extended/DXNumberStepper.vue.d.ts +33 -0
- package/dist/components/extended/DXSaveButton.vue.d.ts +38 -0
- package/dist/components/extended/DXTable.vue.d.ts +6 -2
- package/dist/composables/useForm.d.ts +7 -0
- package/dist/dashboard-for-laravel.js +9369 -9133
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +7 -7
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/style.css +2 -2
- package/dist/types/index.d.ts +20 -3
- package/docs/public/api-reference.json +165 -14
- package/docs/public/docs-map.md +4 -2
- package/docs/public/llms.txt +4 -2
- package/package.json +1 -1
- package/resources/css/theme.scss +512 -89
- package/resources/fonts/maven-pro/MavenPro-Bold.woff2 +0 -0
- package/resources/fonts/maven-pro/MavenPro-Medium.woff2 +0 -0
- package/resources/fonts/maven-pro/MavenPro-Regular.woff2 +0 -0
- package/resources/fonts/maven-pro/MavenPro-SemiBold.woff2 +0 -0
- package/resources/fonts/maven-pro/OFL.txt +93 -0
- package/resources/fonts/poppins/OFL.txt +93 -0
- package/resources/fonts/poppins/Poppins-Medium.woff2 +0 -0
- package/resources/fonts/poppins/Poppins-SemiBold.woff2 +0 -0
- package/resources/js/components/charts/DXBarChart.vue +2 -1
- package/resources/js/components/charts/DXDoughnutChart.vue +2 -1
- package/resources/js/components/charts/DXLineChart.vue +2 -1
- package/resources/js/components/charts/chartTheme.ts +68 -21
- package/resources/js/components/extended/DXDashboard.vue +4 -2
- package/resources/js/components/extended/DXDashboardSidebar.vue +52 -20
- package/resources/js/components/extended/DXField.vue +59 -28
- package/resources/js/components/extended/DXForm.vue +122 -21
- package/resources/js/components/extended/DXNumberStepper.vue +243 -0
- package/resources/js/components/extended/DXSaveButton.vue +82 -0
- package/resources/js/components/extended/DXTable.vue +44 -15
- package/resources/js/composables/useForm.ts +11 -0
- package/resources/js/composables/useToast.ts +1 -1
- package/resources/js/index.ts +2 -0
- package/resources/js/types/index.ts +21 -3
|
@@ -118,17 +118,18 @@
|
|
|
118
118
|
</DXFormField>
|
|
119
119
|
|
|
120
120
|
<!-- Submit button -->
|
|
121
|
-
<
|
|
121
|
+
<DXSaveButton
|
|
122
122
|
v-if="showSubmit"
|
|
123
123
|
type="submit"
|
|
124
|
-
variant="primary"
|
|
125
124
|
block
|
|
126
|
-
:
|
|
127
|
-
:
|
|
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
|
-
</
|
|
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
|
-
<
|
|
149
|
+
<DXSaveButton
|
|
149
150
|
v-if="showSubmit"
|
|
150
151
|
type="submit"
|
|
151
|
-
variant="primary"
|
|
152
152
|
block
|
|
153
|
-
:
|
|
154
|
-
:
|
|
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
|
-
</
|
|
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 {
|
|
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,
|
|
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
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
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,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>
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
<DFormInput
|
|
101
101
|
v-if="field.filter === 'text'"
|
|
102
102
|
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
103
|
-
:placeholder="field.filterPlaceholder ||
|
|
103
|
+
:placeholder="field.filterPlaceholder || `${field.label || field.key}…`"
|
|
104
104
|
size="sm"
|
|
105
105
|
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
106
106
|
/>
|
|
@@ -149,7 +149,7 @@
|
|
|
149
149
|
<DFormInput
|
|
150
150
|
v-else-if="field.filter === 'number'"
|
|
151
151
|
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
152
|
-
:placeholder="field.filterPlaceholder ||
|
|
152
|
+
:placeholder="field.filterPlaceholder || `${field.label || field.key}…`"
|
|
153
153
|
type="number"
|
|
154
154
|
size="sm"
|
|
155
155
|
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
@@ -577,7 +577,10 @@ export interface Props<TItem = any> {
|
|
|
577
577
|
/** Error message */
|
|
578
578
|
error?: string | null;
|
|
579
579
|
|
|
580
|
-
/**
|
|
580
|
+
/**
|
|
581
|
+
* Pagination data (Inertia mode). Omit it on a plain `items` table and the
|
|
582
|
+
* footer counts the rows given as one page.
|
|
583
|
+
*/
|
|
581
584
|
pagination?: PaginationData;
|
|
582
585
|
|
|
583
586
|
/** Show pagination controls */
|
|
@@ -685,7 +688,8 @@ export interface Props<TItem = any> {
|
|
|
685
688
|
/**
|
|
686
689
|
* Label column width for the edit modal's horizontal layout, forwarded to
|
|
687
690
|
* `DXForm`'s `labelCols` (a single width or a per-breakpoint object). Only
|
|
688
|
-
* meaningful when `editLayout` is "horizontal".
|
|
691
|
+
* meaningful when `editLayout` is "horizontal". Omitted: DXForm's default
|
|
692
|
+
* 45% label column.
|
|
689
693
|
*/
|
|
690
694
|
editLabelCols?: LabelCols;
|
|
691
695
|
|
|
@@ -989,8 +993,11 @@ if (
|
|
|
989
993
|
console.warn('[DXTable] clientSide mode ignores provider, apiUrl and inertiaUrl props. Data is processed locally from items.');
|
|
990
994
|
}
|
|
991
995
|
|
|
992
|
-
//
|
|
993
|
-
|
|
996
|
+
// Effective busy state. Provider mode: `busy` (two-way with BTable's own
|
|
997
|
+
// loading). Items and client-side modes: `busy`, the documented prop, or the
|
|
998
|
+
// deprecated `loading`; these modes used to read only `loading`, so a page
|
|
999
|
+
// passing `:busy` showed the empty text while its rows were still loading.
|
|
1000
|
+
const effectiveBusy = computed(() => isProviderMode.value ? props.busy : props.busy || props.loading);
|
|
994
1001
|
|
|
995
1002
|
/*
|
|
996
1003
|
* `perPage`, `sortBy` and `filters` are dual-purpose: with a `v-model` they are
|
|
@@ -2023,9 +2030,10 @@ const handleApiPageChange = (page: number) => {
|
|
|
2023
2030
|
* `currentPage` and `perPage`; `busy` is the raw prop (two-way with BTable's
|
|
2024
2031
|
* provider loading via `@update:busy`).
|
|
2025
2032
|
* - client-side: rows are pre-sliced by `clientSidePaginatedItems`, and local
|
|
2026
|
-
* sorting is off (we sort in `clientSideSortedItems`)
|
|
2033
|
+
* sorting is off (we sort in `clientSideSortedItems`); `busy` is
|
|
2034
|
+
* `effectiveBusy`.
|
|
2027
2035
|
* - inertia: server-paginated `items`, local sorting off, `busy` is
|
|
2028
|
-
* `effectiveBusy` (the `loading`
|
|
2036
|
+
* `effectiveBusy` (`busy` or the deprecated `loading` in this mode).
|
|
2029
2037
|
*
|
|
2030
2038
|
* A key absent from the returned object is simply not bound, which is what the
|
|
2031
2039
|
* original blocks did (e.g. non-provider modes never set `provider`/`currentPage`,
|
|
@@ -2041,7 +2049,7 @@ const tableModeBindings = computed<Record<string, unknown>>(() => {
|
|
|
2041
2049
|
};
|
|
2042
2050
|
}
|
|
2043
2051
|
if (isClientSideMode.value) {
|
|
2044
|
-
return { items: clientSidePaginatedItems.value, noLocalSorting: true };
|
|
2052
|
+
return { items: clientSidePaginatedItems.value, noLocalSorting: true, busy: effectiveBusy.value };
|
|
2045
2053
|
}
|
|
2046
2054
|
// inertia
|
|
2047
2055
|
return { items: resolvedItems.value, noLocalSorting: true, busy: effectiveBusy.value };
|
|
@@ -2050,13 +2058,32 @@ const tableModeBindings = computed<Record<string, unknown>>(() => {
|
|
|
2050
2058
|
/*
|
|
2051
2059
|
* The pagination metadata the single footer renders, by mode (#123). Null means
|
|
2052
2060
|
* no footer: only provider mode has that case (a custom provider with no
|
|
2053
|
-
* `pagination` prop). Client-side always
|
|
2054
|
-
*
|
|
2055
|
-
*
|
|
2061
|
+
* `pagination` prop). Client-side and inertia always render a footer.
|
|
2062
|
+
*
|
|
2063
|
+
* Inertia mode is also what a plain `:items` table with no `pagination` lands
|
|
2064
|
+
* in. Its footer read the `withDefaults` placeholder (`total: 0`), so two
|
|
2065
|
+
* rendered rows sat above "0 items.". With no `pagination` passed, the rows
|
|
2066
|
+
* given ARE the whole set: describe them as one page.
|
|
2056
2067
|
*/
|
|
2068
|
+
const unpaginatedItemsPagination = computed<PaginationData>(() => {
|
|
2069
|
+
const itemCount = resolvedItems.value?.length ?? 0;
|
|
2070
|
+
return {
|
|
2071
|
+
current_page: 1,
|
|
2072
|
+
// One page holding every row, so the pager (shown only when
|
|
2073
|
+
// total > per_page) never appears. Floor of 1 keeps the page
|
|
2074
|
+
// maths away from a zero divisor.
|
|
2075
|
+
per_page: Math.max(itemCount, 1),
|
|
2076
|
+
total: itemCount,
|
|
2077
|
+
from: itemCount > 0 ? 1 : 0,
|
|
2078
|
+
to: itemCount,
|
|
2079
|
+
};
|
|
2080
|
+
});
|
|
2081
|
+
|
|
2057
2082
|
const activePagination = computed<PaginationData | null>(() => {
|
|
2058
2083
|
if (isClientSideMode.value) return clientSidePagination.value;
|
|
2059
|
-
if (isInertiaMode.value)
|
|
2084
|
+
if (isInertiaMode.value) {
|
|
2085
|
+
return paginationWasProvided ? props.pagination : unpaginatedItemsPagination.value;
|
|
2086
|
+
}
|
|
2060
2087
|
if (isProviderMode.value) return providerPagination.value;
|
|
2061
2088
|
return null;
|
|
2062
2089
|
});
|
|
@@ -2347,7 +2374,9 @@ const tableSlotSignature = (slots: Record<string, unknown>): string => {
|
|
|
2347
2374
|
// three data modes from one place.
|
|
2348
2375
|
const tablePassthroughProps = computed(() => ({
|
|
2349
2376
|
footClone: props.footClone,
|
|
2350
|
-
|
|
2377
|
+
// No empty text while rows are loading: "No … found" during a load reads as
|
|
2378
|
+
// "there is no data". Provider mode is unchanged by this.
|
|
2379
|
+
showEmpty: props.showEmpty && (isProviderMode.value || effectiveBusy.value === false),
|
|
2351
2380
|
emptyText:
|
|
2352
2381
|
props.emptyText ??
|
|
2353
2382
|
(hasActiveFilters.value
|
|
@@ -2457,7 +2486,7 @@ defineExpose({
|
|
|
2457
2486
|
}
|
|
2458
2487
|
|
|
2459
2488
|
/* Muted header titles (#157): in a data table the CONTENT is what matters —
|
|
2460
|
-
near-black bold headers compete with it. Grey (
|
|
2489
|
+
near-black bold headers compete with it. Grey (weight 500) keeps the
|
|
2461
2490
|
structure without the shout. Consumers re-louden via the token. */
|
|
2462
2491
|
:deep(thead th) {
|
|
2463
2492
|
color: var(--dx-table-header-color, var(--bs-secondary-color));
|