@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.
Files changed (57) hide show
  1. package/dist/charts.js +82 -90
  2. package/dist/charts.js.map +1 -1
  3. package/dist/charts.umd.cjs +1 -1
  4. package/dist/charts.umd.cjs.map +1 -1
  5. package/dist/components/base/DButton.vue.d.ts +1 -1
  6. package/dist/components/charts/DXBarChart.vue.d.ts +3 -18
  7. package/dist/components/charts/DXLineChart.vue.d.ts +3 -18
  8. package/dist/components/charts/useThemedChart.d.ts +35 -0
  9. package/dist/components/extended/DXChoiceField.vue.d.ts +26 -0
  10. package/dist/components/extended/DXDashboardSidebar.vue.d.ts +1 -1
  11. package/dist/components/extended/DXField.vue.d.ts +10 -113
  12. package/dist/components/extended/DXFieldShell.vue.d.ts +73 -0
  13. package/dist/components/extended/DXForm.vue.d.ts +28 -6
  14. package/dist/components/extended/DXNumericField.vue.d.ts +23 -0
  15. package/dist/components/extended/DXRepeater.vue.d.ts +4 -4
  16. package/dist/components/extended/DXRepeaterCards.vue.d.ts +45 -0
  17. package/dist/components/extended/DXTable.vue.d.ts +107 -2
  18. package/dist/components/extended/DXTableEditorModal.vue.d.ts +22 -2
  19. package/dist/components/extended/DXUserAvatar.vue.d.ts +1 -1
  20. package/dist/composables/useAsyncOptions.d.ts +20 -0
  21. package/dist/composables/useContainerWidth.d.ts +96 -0
  22. package/dist/composables/useResourceEditor.d.ts +2 -1
  23. package/dist/dashboard-for-laravel.js +10513 -10443
  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 -2
  28. package/dist/style.css +1 -1
  29. package/dist/types/index.d.ts +146 -0
  30. package/dist/utils/formSchema.d.ts +59 -0
  31. package/docs/public/api-reference.json +494 -33
  32. package/docs/public/docs-map.md +1 -1
  33. package/docs/public/llms.txt +5 -2
  34. package/package.json +1 -1
  35. package/resources/css/theme.scss +80 -2
  36. package/resources/js/components/charts/DXBarChart.vue +4 -43
  37. package/resources/js/components/charts/DXLineChart.vue +4 -43
  38. package/resources/js/components/charts/useThemedChart.ts +69 -0
  39. package/resources/js/components/extended/DXChoiceField.vue +114 -0
  40. package/resources/js/components/extended/DXDashboardSidebar.vue +1 -1
  41. package/resources/js/components/extended/DXField.vue +328 -615
  42. package/resources/js/components/extended/DXFieldShell.vue +124 -0
  43. package/resources/js/components/extended/DXForm.vue +92 -14
  44. package/resources/js/components/extended/DXNumericField.vue +144 -0
  45. package/resources/js/components/extended/DXRepeater.vue +53 -175
  46. package/resources/js/components/extended/DXRepeaterCards.vue +133 -0
  47. package/resources/js/components/extended/DXTable.vue +418 -43
  48. package/resources/js/components/extended/DXTableEditorModal.vue +39 -2
  49. package/resources/js/components/extended/DXTablePagination.vue +19 -3
  50. package/resources/js/components/extended/DXTableShell.vue +70 -1
  51. package/resources/js/composables/defineForm.ts +8 -44
  52. package/resources/js/composables/useAsyncOptions.ts +61 -0
  53. package/resources/js/composables/useContainerWidth.ts +303 -0
  54. package/resources/js/composables/useResourceEditor.ts +24 -38
  55. package/resources/js/index.ts +40 -0
  56. package/resources/js/types/index.ts +231 -0
  57. 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': layout === '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="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="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: "vertical" (default, label above input) or
235
- * "horizontal" (label left, input right). Overridable per-field via
236
- * `field.layout`. A field with `span: true` always renders full-width,
237
- * regardless of layout.
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
- if (when === undefined) return fallback;
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
- const whenOk = resolvePredicate(field.when, true);
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>