@omnitend/dashboard-for-laravel 0.33.1 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/charts.js +82 -90
- 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/base/DButton.vue.d.ts +1 -1
- package/dist/components/charts/DXBarChart.vue.d.ts +3 -18
- package/dist/components/charts/DXLineChart.vue.d.ts +3 -18
- package/dist/components/charts/useThemedChart.d.ts +35 -0
- package/dist/components/extended/DXChoiceField.vue.d.ts +26 -0
- package/dist/components/extended/DXDashboardSidebar.vue.d.ts +1 -1
- package/dist/components/extended/DXField.vue.d.ts +10 -113
- package/dist/components/extended/DXFieldShell.vue.d.ts +73 -0
- package/dist/components/extended/DXForm.vue.d.ts +28 -6
- package/dist/components/extended/DXNumericField.vue.d.ts +23 -0
- package/dist/components/extended/DXRepeater.vue.d.ts +4 -4
- package/dist/components/extended/DXRepeaterCards.vue.d.ts +45 -0
- package/dist/components/extended/DXTable.vue.d.ts +107 -2
- package/dist/components/extended/DXTableEditorModal.vue.d.ts +22 -2
- package/dist/components/extended/DXUserAvatar.vue.d.ts +1 -1
- package/dist/composables/useAsyncOptions.d.ts +20 -0
- package/dist/composables/useContainerWidth.d.ts +96 -0
- package/dist/composables/useResourceEditor.d.ts +2 -1
- package/dist/dashboard-for-laravel.js +10513 -10443
- 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 +5 -2
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +146 -0
- package/dist/utils/formSchema.d.ts +59 -0
- package/docs/public/api-reference.json +494 -33
- package/docs/public/docs-map.md +1 -1
- package/docs/public/llms.txt +5 -2
- package/package.json +1 -1
- package/resources/css/theme.scss +80 -2
- package/resources/js/components/charts/DXBarChart.vue +4 -43
- package/resources/js/components/charts/DXLineChart.vue +4 -43
- package/resources/js/components/charts/useThemedChart.ts +69 -0
- package/resources/js/components/extended/DXChoiceField.vue +114 -0
- package/resources/js/components/extended/DXDashboardSidebar.vue +1 -1
- package/resources/js/components/extended/DXField.vue +328 -615
- package/resources/js/components/extended/DXFieldShell.vue +124 -0
- package/resources/js/components/extended/DXForm.vue +92 -14
- package/resources/js/components/extended/DXNumericField.vue +144 -0
- package/resources/js/components/extended/DXRepeater.vue +53 -175
- package/resources/js/components/extended/DXRepeaterCards.vue +133 -0
- package/resources/js/components/extended/DXTable.vue +418 -43
- package/resources/js/components/extended/DXTableEditorModal.vue +39 -2
- package/resources/js/components/extended/DXTablePagination.vue +19 -3
- package/resources/js/components/extended/DXTableShell.vue +70 -1
- package/resources/js/composables/defineForm.ts +8 -44
- package/resources/js/composables/useAsyncOptions.ts +61 -0
- package/resources/js/composables/useContainerWidth.ts +303 -0
- package/resources/js/composables/useResourceEditor.ts +24 -38
- package/resources/js/index.ts +40 -0
- package/resources/js/types/index.ts +231 -0
- package/resources/js/utils/formSchema.ts +126 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component (internal — not exported)
|
|
3
|
+
The chrome every DXField branch shares: the optional label (with its info
|
|
4
|
+
affordance and, in horizontal layout, its hint), the control (default slot),
|
|
5
|
+
and the trailing validation feedback + info block + hint + static help. Before
|
|
6
|
+
#135 this markup was re-rendered separately in each of DXField's ~7 branches;
|
|
7
|
+
it now lives here once.
|
|
8
|
+
|
|
9
|
+
Two shapes:
|
|
10
|
+
- `mode="group"` — root is a `DFormGroup` that owns the `#label` column and
|
|
11
|
+
holds the control + trailing block inside it (standard / horizontal
|
|
12
|
+
checkbox / horizontal switch).
|
|
13
|
+
- `mode="plain"` — root is a plain `div`; the control renders its own inline
|
|
14
|
+
label (vertical checkbox/switch), or a heading/rows (switch-list), or a
|
|
15
|
+
nested group (repeater). The trailing block follows the control in the div.
|
|
16
|
+
|
|
17
|
+
The `hint` and `info` slots are forwarded down from DXField (content AND slot
|
|
18
|
+
props). `hint` falls back to `{{ resolvedHint }}` when no slot is supplied.
|
|
19
|
+
-->
|
|
20
|
+
<template>
|
|
21
|
+
<DFormGroup
|
|
22
|
+
v-if="mode === 'group'"
|
|
23
|
+
:class="wrapperClass"
|
|
24
|
+
v-bind="groupAttrs"
|
|
25
|
+
>
|
|
26
|
+
<template v-if="!hideLabel" #label>
|
|
27
|
+
<DXFieldLabel :label="label" :info="info" />
|
|
28
|
+
<small
|
|
29
|
+
v-if="hintInLabel && (resolvedHint || $slots.hint)"
|
|
30
|
+
class="form-text text-muted d-block dx-field-hint"
|
|
31
|
+
>
|
|
32
|
+
<slot name="hint" :field="field" :model="model">{{
|
|
33
|
+
resolvedHint
|
|
34
|
+
}}</slot>
|
|
35
|
+
</small>
|
|
36
|
+
</template>
|
|
37
|
+
|
|
38
|
+
<slot />
|
|
39
|
+
|
|
40
|
+
<DFormInvalidFeedback v-if="form.hasError(errorKey)" force-show>
|
|
41
|
+
{{ form.getError(errorKey) }}
|
|
42
|
+
</DFormInvalidFeedback>
|
|
43
|
+
<slot name="info" :field="field" :model="model" />
|
|
44
|
+
<DFormText
|
|
45
|
+
v-if="!hintInLabel && (resolvedHint || $slots.hint)"
|
|
46
|
+
class="text-muted"
|
|
47
|
+
>
|
|
48
|
+
<slot name="hint" :field="field" :model="model">{{
|
|
49
|
+
resolvedHint
|
|
50
|
+
}}</slot>
|
|
51
|
+
</DFormText>
|
|
52
|
+
<DFormText v-if="field.help">{{ field.help }}</DFormText>
|
|
53
|
+
</DFormGroup>
|
|
54
|
+
|
|
55
|
+
<div v-else :class="wrapperClass">
|
|
56
|
+
<slot />
|
|
57
|
+
|
|
58
|
+
<DFormInvalidFeedback v-if="form.hasError(errorKey)" force-show>
|
|
59
|
+
{{ form.getError(errorKey) }}
|
|
60
|
+
</DFormInvalidFeedback>
|
|
61
|
+
<slot name="info" :field="field" :model="model" />
|
|
62
|
+
<DFormText v-if="resolvedHint || $slots.hint" class="text-muted">
|
|
63
|
+
<slot name="hint" :field="field" :model="model">{{
|
|
64
|
+
resolvedHint
|
|
65
|
+
}}</slot>
|
|
66
|
+
</DFormText>
|
|
67
|
+
<DFormText v-if="field.help">{{ field.help }}</DFormText>
|
|
68
|
+
</div>
|
|
69
|
+
</template>
|
|
70
|
+
|
|
71
|
+
<script setup lang="ts">
|
|
72
|
+
import DFormGroup from "../base/DFormGroup.vue";
|
|
73
|
+
import DFormInvalidFeedback from "../base/DFormInvalidFeedback.vue";
|
|
74
|
+
import DFormText from "../base/DFormText.vue";
|
|
75
|
+
import DXFieldLabel from "./DXFieldLabel.vue";
|
|
76
|
+
import type { UseFormReturn } from "../../composables/useForm";
|
|
77
|
+
import type { FieldDefinition } from "../../types";
|
|
78
|
+
|
|
79
|
+
interface Props {
|
|
80
|
+
/** The field definition (for `field.help` and slot props). */
|
|
81
|
+
field: FieldDefinition;
|
|
82
|
+
|
|
83
|
+
/** Form instance owning the field's errors. */
|
|
84
|
+
form: UseFormReturn<any>;
|
|
85
|
+
|
|
86
|
+
/** Error key for validation lookups. */
|
|
87
|
+
errorKey: string;
|
|
88
|
+
|
|
89
|
+
/** Model passed to `hint`/`info` slot props. */
|
|
90
|
+
model?: any;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Layout shape. `group` = DFormGroup root with a label column; `plain` =
|
|
94
|
+
* plain div, the control owning its own inline label/heading.
|
|
95
|
+
*/
|
|
96
|
+
mode: "group" | "plain";
|
|
97
|
+
|
|
98
|
+
/** Wrapper class (`field.class || 'mb-3'`). */
|
|
99
|
+
wrapperClass?: string;
|
|
100
|
+
|
|
101
|
+
/** BFormGroup attrs (horizontal label-cols etc.); group mode only. */
|
|
102
|
+
groupAttrs?: Record<string, any>;
|
|
103
|
+
|
|
104
|
+
/** Resolved label text (group mode). Always supplied by DXField. */
|
|
105
|
+
label: string;
|
|
106
|
+
|
|
107
|
+
/** Resolved info text for the label popover (group mode). */
|
|
108
|
+
info?: string;
|
|
109
|
+
|
|
110
|
+
/** Skip the label column entirely (group mode). */
|
|
111
|
+
hideLabel?: boolean;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Whether the hint sits in the label column (horizontal) rather than below
|
|
115
|
+
* the control. Group mode only; plain mode always renders the hint below.
|
|
116
|
+
*/
|
|
117
|
+
hintInLabel?: boolean;
|
|
118
|
+
|
|
119
|
+
/** Resolved hint text (fallback when no `hint` slot is supplied). */
|
|
120
|
+
resolvedHint?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
defineProps<Props>();
|
|
124
|
+
</script>
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
-->
|
|
14
14
|
<template>
|
|
15
15
|
<BForm
|
|
16
|
+
ref="formRoot"
|
|
16
17
|
@submit.prevent="handleSubmit"
|
|
17
|
-
:class="{ 'dx-form--horizontal':
|
|
18
|
+
:class="{ 'dx-form--horizontal': resolvedLayout === 'horizontal' }"
|
|
18
19
|
>
|
|
19
20
|
<!-- Form-level error message -->
|
|
20
21
|
<DAlert
|
|
@@ -71,7 +72,7 @@
|
|
|
71
72
|
:field="field"
|
|
72
73
|
:form="resolvedForm"
|
|
73
74
|
:model="model"
|
|
74
|
-
:layout="
|
|
75
|
+
:layout="resolvedLayout"
|
|
75
76
|
:label-cols="labelCols"
|
|
76
77
|
>
|
|
77
78
|
<!-- Forward every DXForm slot so the field can render
|
|
@@ -105,7 +106,7 @@
|
|
|
105
106
|
:field="field"
|
|
106
107
|
:form="resolvedForm"
|
|
107
108
|
:model="model"
|
|
108
|
-
:layout="
|
|
109
|
+
:layout="resolvedLayout"
|
|
109
110
|
:label-cols="labelCols"
|
|
110
111
|
>
|
|
111
112
|
<!-- Forward every DXForm slot so the field can render its
|
|
@@ -166,7 +167,7 @@
|
|
|
166
167
|
</template>
|
|
167
168
|
|
|
168
169
|
<script setup lang="ts">
|
|
169
|
-
import { computed, watch } from "vue";
|
|
170
|
+
import { computed, ref, watch, type ComponentPublicInstance } from "vue";
|
|
170
171
|
import { BForm } from "bootstrap-vue-next";
|
|
171
172
|
import DAlert from "../base/DAlert.vue";
|
|
172
173
|
import DButton from "../base/DButton.vue";
|
|
@@ -176,7 +177,12 @@ import { BTab as DTab } from "bootstrap-vue-next"; // raw BTab: BTabs scans slot
|
|
|
176
177
|
import DXFormField from "./DXFormField.vue";
|
|
177
178
|
import type { UseFormReturn } from "../../composables/useForm";
|
|
178
179
|
import type { DefineFormReturn } from "../../composables/defineForm";
|
|
180
|
+
import { useContainerWidth } from "../../composables/useContainerWidth";
|
|
179
181
|
import type { FieldDefinition, FormTab, LabelCols, MaybeFn } from "../../types";
|
|
182
|
+
import {
|
|
183
|
+
resolvePredicate as resolvePredicateFor,
|
|
184
|
+
isFieldVisible as isFieldVisibleFor,
|
|
185
|
+
} from "../../utils/formSchema";
|
|
180
186
|
|
|
181
187
|
interface Props {
|
|
182
188
|
/**
|
|
@@ -231,12 +237,34 @@ interface Props {
|
|
|
231
237
|
cardTabs?: boolean;
|
|
232
238
|
|
|
233
239
|
/**
|
|
234
|
-
* Form-wide field layout:
|
|
235
|
-
*
|
|
236
|
-
* `
|
|
237
|
-
*
|
|
240
|
+
* Form-wide field layout:
|
|
241
|
+
*
|
|
242
|
+
* - `"vertical"` (default) — label above input, always.
|
|
243
|
+
* - `"horizontal"` — label left, input right, always.
|
|
244
|
+
* - `"auto"` — horizontal when the form's **own container** is at least
|
|
245
|
+
* `layoutThreshold` px wide, vertical below that. Container-driven, not
|
|
246
|
+
* viewport-driven: a page narrowed by the dashboard sidebar, or a form
|
|
247
|
+
* inside a modal, stacks even though the window is wide (which no
|
|
248
|
+
* Bootstrap media query can see).
|
|
249
|
+
*
|
|
250
|
+
* Overridable per-field via `field.layout`. A field with `span: true`
|
|
251
|
+
* always renders full-width, regardless of layout.
|
|
238
252
|
*/
|
|
239
|
-
layout?: "vertical" | "horizontal";
|
|
253
|
+
layout?: "vertical" | "horizontal" | "auto";
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Container width (px) at or above which `layout: "auto"` goes horizontal.
|
|
257
|
+
* Ignored for the explicit `"vertical"`/`"horizontal"` layouts.
|
|
258
|
+
*
|
|
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.
|
|
266
|
+
*/
|
|
267
|
+
layoutThreshold?: number;
|
|
240
268
|
|
|
241
269
|
/**
|
|
242
270
|
* Label column width for horizontal layout (mirrors BFormGroup's
|
|
@@ -254,6 +282,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
254
282
|
card: false,
|
|
255
283
|
cardTabs: true,
|
|
256
284
|
layout: "vertical",
|
|
285
|
+
layoutThreshold: 640,
|
|
257
286
|
});
|
|
258
287
|
|
|
259
288
|
const emit = defineEmits<{
|
|
@@ -266,6 +295,55 @@ const slots = defineSlots<Record<string, (props: any) => any>>();
|
|
|
266
295
|
/** v-model for the active tab index. */
|
|
267
296
|
const activeTab = defineModel<number>("activeTab", { default: 0 });
|
|
268
297
|
|
|
298
|
+
// ————————————————— container-driven layout (`layout: "auto"`)
|
|
299
|
+
|
|
300
|
+
const formRoot = ref<ComponentPublicInstance | HTMLElement | null>(null);
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The `<form>` element BForm renders. A template ref on a component yields the
|
|
304
|
+
* component instance, so unwrap `$el` — and tolerate either shape rather than
|
|
305
|
+
* assuming one, so a future BForm change (or a functional re-implementation)
|
|
306
|
+
* can't silently leave us observing nothing.
|
|
307
|
+
*/
|
|
308
|
+
function resolveFormElement(): HTMLElement | null {
|
|
309
|
+
const rootValue = formRoot.value;
|
|
310
|
+
if (rootValue === null || rootValue === undefined) return null;
|
|
311
|
+
if (rootValue instanceof HTMLElement) return rootValue;
|
|
312
|
+
const element = (rootValue as ComponentPublicInstance).$el;
|
|
313
|
+
return element instanceof HTMLElement ? element : null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Observe ONLY in auto mode. Returning null for the other layouts means no
|
|
317
|
+
// ResizeObserver is ever attached for the (overwhelmingly common) explicit
|
|
318
|
+
// layouts — existing consumers pay nothing and behave identically.
|
|
319
|
+
const { isBelow: containerIsNarrow } = useContainerWidth(
|
|
320
|
+
() => (props.layout === "auto" ? resolveFormElement() : null),
|
|
321
|
+
{
|
|
322
|
+
// Getter, so a consumer binding `:layout-threshold` to something
|
|
323
|
+
// reactive re-evaluates instead of latching the mount-time value.
|
|
324
|
+
threshold: () => props.layoutThreshold,
|
|
325
|
+
// Guard against an observer feedback loop at the boundary: the vertical
|
|
326
|
+
// layout is TALLER, so a form inside an `overflow:auto` ancestor can
|
|
327
|
+
// gain a scrollbar when it stacks, shrinking its own container by
|
|
328
|
+
// ~15-17px — straight back over the threshold, and it flips forever.
|
|
329
|
+
// A band wider than any scrollbar (24px) makes the crossing one-way
|
|
330
|
+
// until the container genuinely grows.
|
|
331
|
+
hysteresis: 24,
|
|
332
|
+
},
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The layout actually rendered. `vertical`/`horizontal` pass through
|
|
337
|
+
* unconditionally (unchanged behaviour); `auto` resolves from the measured
|
|
338
|
+
* container width, defaulting to `vertical` before the first measurement and
|
|
339
|
+
* under SSR — the stacked layout is legible at any width, so it is the safe
|
|
340
|
+
* thing to render when the width is unknown.
|
|
341
|
+
*/
|
|
342
|
+
const resolvedLayout = computed<"vertical" | "horizontal">(() => {
|
|
343
|
+
if (props.layout !== "auto") return props.layout;
|
|
344
|
+
return containerIsNarrow.value ? "vertical" : "horizontal";
|
|
345
|
+
});
|
|
346
|
+
|
|
269
347
|
// ————————————————— resolve form / fields (accept useForm or defineForm)
|
|
270
348
|
|
|
271
349
|
function isDefineForm(
|
|
@@ -303,12 +381,14 @@ const model = computed(() => ({
|
|
|
303
381
|
...resolvedForm.value.data,
|
|
304
382
|
}));
|
|
305
383
|
|
|
384
|
+
// Thin wrappers binding the shared formSchema predicates to this form's live
|
|
385
|
+
// model (context + form data), so field/tab visibility follows the one rule
|
|
386
|
+
// every renderer shares (#134).
|
|
306
387
|
function resolvePredicate(
|
|
307
388
|
when: MaybeFn<boolean> | undefined,
|
|
308
389
|
fallback: boolean,
|
|
309
390
|
): boolean {
|
|
310
|
-
|
|
311
|
-
return typeof when === "function" ? when(model.value) : when;
|
|
391
|
+
return resolvePredicateFor(when, model.value, fallback);
|
|
312
392
|
}
|
|
313
393
|
|
|
314
394
|
/** Resolve a tab's (possibly function-valued) label against the live model. */
|
|
@@ -319,9 +399,7 @@ function resolveTabLabel(tab: FormTab): string {
|
|
|
319
399
|
}
|
|
320
400
|
|
|
321
401
|
function isFieldVisible(field: FieldDefinition): boolean {
|
|
322
|
-
|
|
323
|
-
const showOk = field.show ? field.show() : true;
|
|
324
|
-
return whenOk && showOk;
|
|
402
|
+
return isFieldVisibleFor(field, model.value);
|
|
325
403
|
}
|
|
326
404
|
|
|
327
405
|
// ————————————————— tabs
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component (internal — not exported)
|
|
3
|
+
The numeric control family for DXField: `currency`, `percentage`, `number`.
|
|
4
|
+
Extracted from DXField (#135) unchanged. The parent DXField still owns the
|
|
5
|
+
value/guards/`setValue` coercion — this component only renders the control and
|
|
6
|
+
emits the raw edited value back up. The `percentage` `asFraction` display
|
|
7
|
+
scaling (model holds a 0–1 fraction, input shows 0–100) lives here because it
|
|
8
|
+
is purely presentational to this control.
|
|
9
|
+
-->
|
|
10
|
+
<template>
|
|
11
|
+
<!-- Currency: the DXCurrencyInput leaf (#152) owns the symbol prepend, the
|
|
12
|
+
blur-padded display (#69), empty -> null, and the minor-units model
|
|
13
|
+
scaling (#116). -->
|
|
14
|
+
<DXCurrencyInput
|
|
15
|
+
v-if="field.type === 'currency'"
|
|
16
|
+
:model-value="modelValue"
|
|
17
|
+
:currency-symbol="field.currencySymbol || '£'"
|
|
18
|
+
:decimals="field.decimals ?? 2"
|
|
19
|
+
:minor-units="field.minorUnits ?? false"
|
|
20
|
+
:step="field.step"
|
|
21
|
+
:required="field.required"
|
|
22
|
+
:placeholder="field.placeholder"
|
|
23
|
+
:min="field.min"
|
|
24
|
+
:max="field.max"
|
|
25
|
+
:state="state"
|
|
26
|
+
:disabled="isDisabled"
|
|
27
|
+
:readonly="isReadonly || isPlaintext"
|
|
28
|
+
:plaintext="isPlaintext"
|
|
29
|
+
v-bind="inputProps"
|
|
30
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
31
|
+
/>
|
|
32
|
+
|
|
33
|
+
<!-- Percentage: numeric input with an affix -->
|
|
34
|
+
<DInputGroup v-else-if="field.type === 'percentage'">
|
|
35
|
+
<DFormInput
|
|
36
|
+
v-model="numericInputValue"
|
|
37
|
+
type="number"
|
|
38
|
+
:required="field.required"
|
|
39
|
+
:placeholder="field.placeholder"
|
|
40
|
+
:step="field.step"
|
|
41
|
+
:min="field.min"
|
|
42
|
+
:max="field.max"
|
|
43
|
+
:state="state"
|
|
44
|
+
:disabled="isDisabled"
|
|
45
|
+
:readonly="isReadonly || isPlaintext"
|
|
46
|
+
:plaintext="isPlaintext"
|
|
47
|
+
v-bind="inputProps"
|
|
48
|
+
/>
|
|
49
|
+
|
|
50
|
+
<template #append>
|
|
51
|
+
<span class="input-group-text">%</span>
|
|
52
|
+
</template>
|
|
53
|
+
</DInputGroup>
|
|
54
|
+
|
|
55
|
+
<!-- Number -->
|
|
56
|
+
<DFormInput
|
|
57
|
+
v-else
|
|
58
|
+
:model-value="modelValue"
|
|
59
|
+
type="number"
|
|
60
|
+
:required="field.required"
|
|
61
|
+
:placeholder="field.placeholder"
|
|
62
|
+
:step="field.step"
|
|
63
|
+
:min="field.min"
|
|
64
|
+
:max="field.max"
|
|
65
|
+
:state="state"
|
|
66
|
+
:disabled="isDisabled"
|
|
67
|
+
:readonly="isReadonly || isPlaintext"
|
|
68
|
+
:plaintext="isPlaintext"
|
|
69
|
+
v-bind="inputProps"
|
|
70
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
71
|
+
/>
|
|
72
|
+
</template>
|
|
73
|
+
|
|
74
|
+
<script setup lang="ts">
|
|
75
|
+
import { computed } from "vue";
|
|
76
|
+
import DFormInput from "../base/DFormInput.vue";
|
|
77
|
+
import DInputGroup from "../base/DInputGroup.vue";
|
|
78
|
+
import DXCurrencyInput from "./DXCurrencyInput.vue";
|
|
79
|
+
import type { FieldDefinition } from "../../types";
|
|
80
|
+
|
|
81
|
+
interface Props {
|
|
82
|
+
/** Field definition (type currency/percentage/number + numeric options). */
|
|
83
|
+
field: FieldDefinition;
|
|
84
|
+
|
|
85
|
+
/** Current model value. */
|
|
86
|
+
modelValue: any;
|
|
87
|
+
|
|
88
|
+
/** Validation state passed to the control. */
|
|
89
|
+
state?: boolean | null;
|
|
90
|
+
|
|
91
|
+
/** Disabled state (`isDisabled`). */
|
|
92
|
+
isDisabled?: boolean;
|
|
93
|
+
|
|
94
|
+
/** Readonly state (`isReadonly`). */
|
|
95
|
+
isReadonly?: boolean;
|
|
96
|
+
|
|
97
|
+
/** Plaintext (display-only) state (`isPlaintext`). */
|
|
98
|
+
isPlaintext?: boolean;
|
|
99
|
+
|
|
100
|
+
/** Escape-hatch input props, already guard-merged by DXField. */
|
|
101
|
+
inputProps?: Record<string, any>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const props = defineProps<Props>();
|
|
105
|
+
|
|
106
|
+
const emit = defineEmits<{
|
|
107
|
+
(event: "update:modelValue", value: any): void;
|
|
108
|
+
}>();
|
|
109
|
+
|
|
110
|
+
// For `percentage` fields with `asFraction`, the model holds a 0–1 fraction but
|
|
111
|
+
// the input shows/edits a 0–100 percentage. Scale on read/write, rounding away
|
|
112
|
+
// binary-float artefacts (0.2 * 100 = 20.000000000000004). Currency and plain
|
|
113
|
+
// percentage fields pass straight through.
|
|
114
|
+
const isFractionPercentage = computed(
|
|
115
|
+
() => props.field.type === "percentage" && props.field.asFraction === true,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const numericInputValue = computed({
|
|
119
|
+
get: () => {
|
|
120
|
+
const value = props.modelValue;
|
|
121
|
+
if (!isFractionPercentage.value) return value;
|
|
122
|
+
if (value === null || value === undefined || value === "") return value;
|
|
123
|
+
const num = Number(value);
|
|
124
|
+
if (!Number.isFinite(num)) return value;
|
|
125
|
+
return Math.round(num * 100 * 1e6) / 1e6;
|
|
126
|
+
},
|
|
127
|
+
set: (value: any) => {
|
|
128
|
+
if (!isFractionPercentage.value) {
|
|
129
|
+
emit("update:modelValue", value);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (value === null || value === undefined || value === "") {
|
|
133
|
+
emit("update:modelValue", value);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const num = Number(value);
|
|
137
|
+
if (!Number.isFinite(num)) {
|
|
138
|
+
emit("update:modelValue", value);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
emit("update:modelValue", Math.round((num / 100) * 1e9) / 1e9);
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
</script>
|