@omnitend/dashboard-for-laravel 0.29.0 → 0.31.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.
@@ -4,9 +4,12 @@
4
4
 
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
- colour-coding its state (a neutral box when off, a filled primary box when on).
8
- Used standalone or, internally, by `DXField`'s `switch` field type so a form
9
- switch and a lone switch share one look.
7
+ colour-coding its state. By default (`onVariant="success"`) it reads the house
8
+ green-on / red-off style — a neutral box + red toggle when off, a soft-green
9
+ box + green toggle when on. `onVariant="neutral"` (brand primary on / grey off)
10
+ is for semantically-mixed switches that shouldn't imply good/bad (#158). Used
11
+ standalone or, internally, by `DXField`'s `switch` field type so a form switch
12
+ and a lone switch share one look.
10
13
 
11
14
  Usage:
12
15
  ```vue
@@ -18,8 +21,16 @@
18
21
  `label` prop for simple text, or the default slot for richer label content.
19
22
  -->
20
23
  <template>
21
- <div class="dx-switch" :class="{ 'dx-switch--on': isOn }">
22
- <DFormCheckbox v-model="model" switch v-bind="$attrs">
24
+ <div class="dx-switch" :class="[`dx-switch--${onVariant}`, { 'dx-switch--on': isOn }]">
25
+ <!-- For the neutral variant, tag the inner `.form-switch` with
26
+ `switch-neutral` so the global theme restores grey-off / primary-on for
27
+ the toggle itself (the box styling below handles the wrapper). -->
28
+ <DFormCheckbox
29
+ v-model="model"
30
+ switch
31
+ :class="onVariant === 'neutral' ? 'switch-neutral' : undefined"
32
+ v-bind="$attrs"
33
+ >
23
34
  <!--
24
35
  @slot Label content shown beside the toggle. Falls back to the contextual
25
36
  text / `label` prop. Scoped with the current on-state so a caller can vary
@@ -44,10 +55,19 @@ interface Props {
44
55
  textWhenTrue?: string;
45
56
  /** Text shown when off — overrides `label` in the off state (e.g. "Product is not current"). */
46
57
  textWhenFalse?: string;
58
+ /**
59
+ * On-state colour. `"success"` (default) reads the house green-on / red-off
60
+ * style — an active/enabled/good switch ("Product is current", "Stock is
61
+ * tracked", "Visible"). `"neutral"` is for semantically-mixed switches
62
+ * ("contains alcohol", an allergen toggle, "hidden on web shop") that
63
+ * shouldn't imply good/bad — it uses the brand primary on / grey off (#158).
64
+ */
65
+ onVariant?: "success" | "neutral";
47
66
  }
48
67
 
49
68
  const props = withDefaults(defineProps<Props>(), {
50
69
  modelValue: false,
70
+ onVariant: "success",
51
71
  });
52
72
 
53
73
  const emit = defineEmits<{
@@ -128,26 +148,55 @@ defineOptions({
128
148
  cursor: pointer;
129
149
  }
130
150
 
131
- /* On-state: filled primary. Green is reserved for success semantics, so a
132
- generic "on" toggle uses the brand action colour, matching the base switch,
133
- checkboxes and radios. */
134
- .dx-switch--on :deep(.form-check) {
135
- background-color: var(--bs-primary-bg-subtle);
136
- border-color: var(--bs-primary);
151
+ /* Filled box (#158): the WHOLE control carries the colour and the pill stays a
152
+ neutral grey affordance — the box IS the state, so it reads strongest at a
153
+ glance (the house style). `success` (default) is green ON / light-red OFF;
154
+ `neutral` keeps a brand primary panel for mixed-semantics switches. Pair with
155
+ `textWhenTrue`/`textWhenFalse` so the LABEL also names each state. */
156
+ .dx-switch--success :deep(.form-check) {
157
+ background-color: var(--dx-switch-off-bg);
158
+ border-color: var(--dx-switch-off-line);
137
159
  }
138
160
 
139
- .dx-switch--on :deep(.form-check-label) {
140
- color: var(--bs-primary-text-emphasis);
161
+ .dx-switch--success :deep(.form-check-label) {
162
+ color: var(--dx-switch-off-ink);
163
+ }
164
+
165
+ .dx-switch--success.dx-switch--on :deep(.form-check) {
166
+ background-color: var(--dx-switch-on-bg);
167
+ border-color: var(--dx-switch-on-line);
168
+ }
169
+
170
+ .dx-switch--success.dx-switch--on :deep(.form-check-label) {
171
+ color: var(--dx-switch-on-ink);
141
172
  font-weight: 500;
142
173
  }
143
174
 
144
- .dx-switch--on :deep(.form-check-input:checked) {
145
- background-color: var(--bs-primary);
146
- border-color: var(--bs-primary);
175
+ /* Neutral grey pill in BOTH states (white thumb, sliding) — overrides the global
176
+ green-on/red-off toggle colours within the box, so only the box carries the
177
+ state. Explicit `:checked`/`:focus` selectors so they out-specify the global
178
+ `.form-switch .form-check-input:checked` rule. */
179
+ .dx-switch--success :deep(.form-check-input),
180
+ .dx-switch--success :deep(.form-check-input:checked),
181
+ .dx-switch--success :deep(.form-check-input:focus) {
182
+ background-color: var(--dx-switch-pill);
183
+ border-color: var(--dx-switch-pill);
184
+ --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e");
147
185
  }
148
186
 
149
- .dx-switch--on :deep(.form-check-input:focus) {
187
+ .dx-switch--success :deep(.form-check-input:focus) {
188
+ box-shadow: 0 0 0 0.2rem rgba(0, 0, 0, 0.12);
189
+ }
190
+
191
+ /* neutral variant (mixed-semantics): brand primary panel when on; the toggle is
192
+ handled by `.switch-neutral` on the inner form-switch (primary-on / grey-off). */
193
+ .dx-switch--neutral.dx-switch--on :deep(.form-check) {
194
+ background-color: var(--bs-primary-bg-subtle);
150
195
  border-color: var(--bs-primary);
151
- box-shadow: 0 0 0 0.25rem rgba(var(--bs-primary-rgb), 0.25);
196
+ }
197
+
198
+ .dx-switch--neutral.dx-switch--on :deep(.form-check-label) {
199
+ color: var(--bs-primary-text-emphasis);
200
+ font-weight: 500;
152
201
  }
153
202
  </style>
@@ -57,6 +57,7 @@
57
57
  :striped="striped"
58
58
  :hover="hover"
59
59
  :responsive="responsive"
60
+ :fixed="fixedLayout"
60
61
  :tbody-tr-class="composeRowClass"
61
62
  v-bind="{ ...tableModeBindings, ...tablePassthroughProps }"
62
63
  @update:sort-by="handleSortChange"
@@ -65,6 +66,25 @@
65
66
  @update:busy="handleBusyChange"
66
67
  @row-clicked="handleRowClick"
67
68
  >
69
+ <!-- Per-column widths (#156) via a `<colgroup>`. bvn nests this
70
+ slot's `<col>`s inside a `<colgroup>` it renders. Only
71
+ emitted when a field declares a width AND the consumer hasn't
72
+ supplied their own `table-colgroup` (forwarded generically) —
73
+ so a table with no widths gets no colgroup and renders exactly
74
+ as before. `<col>` widths are authoritative under
75
+ `table-layout: fixed`, where the filter row (not the header)
76
+ is the width-determining first row. -->
77
+ <template
78
+ v-if="fieldsHaveWidths && !$slots['table-colgroup']"
79
+ #table-colgroup
80
+ >
81
+ <col
82
+ v-for="field in fields"
83
+ :key="`col-${field.key}`"
84
+ :style="columnStyleFor(field)"
85
+ />
86
+ </template>
87
+
68
88
  <!-- DXTable owns `thead-top` (its filter row lives there), so a
69
89
  consumer's own thead-top content is COMPOSED above it rather
70
90
  than being dropped: a grouped column-header banner or a pinned
@@ -177,6 +197,7 @@
177
197
  :per-page-options="perPageOptions"
178
198
  :show-pagination="showPagination"
179
199
  :show-per-page-selector="shouldShowPerPageSelector"
200
+ :show-count="showCount"
180
201
  :singular-item-name="singularItemName"
181
202
  :plural-item-name="pluralItemName"
182
203
  :has-active-filters="hasActiveFilters"
@@ -288,6 +309,21 @@ export interface TableField {
288
309
  filter?: FilterType;
289
310
  filterOptions?: FilterOption[];
290
311
  filterPlaceholder?: string;
312
+
313
+ /**
314
+ * Fixed width for this column, applied via a `<colgroup>` `<col>`. A number
315
+ * is treated as pixels (`200` → `200px`); a string is used verbatim
316
+ * (`"20%"`, `"12rem"`). Pair with the table-level `fixedLayout` prop so the
317
+ * width is authoritative and the column stops resizing to fit its content —
318
+ * without `fixedLayout` a wider cell can still stretch the column (#156).
319
+ */
320
+ width?: string | number;
321
+
322
+ /**
323
+ * Minimum width for this column, applied via the same `<col>` as `width`.
324
+ * A number is pixels; a string is used verbatim.
325
+ */
326
+ minWidth?: string | number;
291
327
  [key: string]: any;
292
328
  }
293
329
 
@@ -390,13 +426,30 @@ export interface Props<TItem = any> {
390
426
  /** Show per-page selector */
391
427
  showPerPageSelector?: boolean;
392
428
 
429
+ /**
430
+ * Show the footer's item-count caption ("N items." / "X to Y out of Z
431
+ * items."). Defaults to `true`. Set `false` to suppress just the caption,
432
+ * independent of the pager — a page ported from a plain `<b-table>` that
433
+ * hides the pager with `:show-pagination="false"` would otherwise gain a
434
+ * caption it never had (#127).
435
+ */
436
+ showCount?: boolean;
437
+
393
438
  /** Per-page options for selector */
394
439
  perPageOptions?: number[];
395
440
 
396
441
  /** Current page (for provider mode) */
397
442
  currentPage?: number;
398
443
 
399
- /** Items per page (for provider mode, v-model support) */
444
+ /**
445
+ * Items per page. With a `v-model:per-page` it is controlled state; a bare
446
+ * `:per-page="20"` is the INITIAL page size (see the initial-value vs
447
+ * controlled note above). When passed explicitly it takes PRECEDENCE over any
448
+ * per-page preference persisted in localStorage — `:per-page="20"` always
449
+ * starts at 20, never a size the user once picked on another table (#124).
450
+ * Omit it to let a stored preference (URL-keyed tables only) or the default
451
+ * apply.
452
+ */
400
453
  perPage?: number;
401
454
 
402
455
  /** Striped (banded) rows. Off by default — banding adds little on short
@@ -525,6 +578,16 @@ export interface Props<TItem = any> {
525
578
 
526
579
  /** Enable client-side filtering, sorting, and pagination on items array */
527
580
  clientSide?: boolean;
581
+
582
+ /**
583
+ * Apply `table-layout: fixed` to the underlying table. Off by default, so
584
+ * existing tables keep their content-sized columns. When on, column widths
585
+ * stop depending on cell content, so they no longer reshuffle when a filter
586
+ * narrows the rows — the table stays put as you filter (#156). Combine with
587
+ * per-field `width`/`minWidth` to control the proportions; columns with no
588
+ * declared width share the remaining space equally.
589
+ */
590
+ fixedLayout?: boolean;
528
591
  }
529
592
 
530
593
  const props = withDefaults(defineProps<Props<T>>(), {
@@ -542,6 +605,7 @@ const props = withDefaults(defineProps<Props<T>>(), {
542
605
  }),
543
606
  showPagination: true,
544
607
  showPerPageSelector: true,
608
+ showCount: true,
545
609
  perPageOptions: () => [10, 20, 50, 100],
546
610
  currentPage: 1,
547
611
  // perPage: 10, // Don't set default - let internalPerPage handle it
@@ -556,6 +620,7 @@ const props = withDefaults(defineProps<Props<T>>(), {
556
620
  showCreateButton: true,
557
621
  footClone: false,
558
622
  showEmpty: true,
623
+ fixedLayout: false,
559
624
  });
560
625
 
561
626
  const emit = defineEmits<{
@@ -906,6 +971,35 @@ const hasFieldValue = (item: unknown, key: string): boolean => {
906
971
  const dottedCellFields = (slots: Record<string, unknown>): TableField[] =>
907
972
  props.fields.filter((field) => field.key.includes('.') && !slots[`cell(${field.key})`]);
908
973
 
974
+ /*
975
+ * Per-column widths (#156). A field's `width`/`minWidth` is applied through a
976
+ * `<colgroup>` `<col>`, NOT the header `<th>`: with `table-layout: fixed` the
977
+ * column widths are taken from the FIRST row of cells, and DXTable's inline
978
+ * filter row (`thead-top`) is that first row — so a width on the header `<th>`
979
+ * would be ignored whenever filters are present. A `<col>` width is
980
+ * authoritative regardless of row order, in both fixed and auto layout.
981
+ */
982
+ const sizeToCss = (value: string | number): string =>
983
+ typeof value === 'number' ? `${value}px` : value;
984
+
985
+ const hasDeclaredWidth = (field: TableField): boolean =>
986
+ (field.width !== undefined && field.width !== null) ||
987
+ (field.minWidth !== undefined && field.minWidth !== null);
988
+
989
+ const columnStyleFor = (field: TableField): Record<string, string> | undefined => {
990
+ const style: Record<string, string> = {};
991
+ if (field.width !== undefined && field.width !== null) style.width = sizeToCss(field.width);
992
+ if (field.minWidth !== undefined && field.minWidth !== null) {
993
+ style.minWidth = sizeToCss(field.minWidth);
994
+ }
995
+ return Object.keys(style).length > 0 ? style : undefined;
996
+ };
997
+
998
+ // Only inject a `<colgroup>` when a column actually declares a width — otherwise
999
+ // the table renders exactly as before (no colgroup at all), preserving the
1000
+ // content-sized default byte-for-byte.
1001
+ const fieldsHaveWidths = computed(() => props.fields.some(hasDeclaredWidth));
1002
+
909
1003
  // Computed: Get effective filter options for a field
910
1004
  // The key a column's filter is SENT under, which is not always the column's own
911
1005
  // key: a display column can filter on a different server param via `filterKey`.
@@ -973,27 +1067,51 @@ const getFieldFilterOptions = (field: TableField): FilterOption[] => {
973
1067
  return [];
974
1068
  };
975
1069
 
976
- // LocalStorage key for perPage preference
977
- const perPageStorageKey = computed(() => {
978
- const url = props.inertiaUrl || props.apiUrl || 'table';
1070
+ // LocalStorage key for the per-page preference. Only tables identified by a URL
1071
+ // (inertiaUrl / apiUrl) persist their preference: a keyless client-side table
1072
+ // has no stable identity across reloads, and every such table would otherwise
1073
+ // share the literal `table` key and clobber each other's stored value (#124).
1074
+ // Returns null when there is nothing stable to key on — every read and write
1075
+ // guards on that, so a keyless table simply doesn't persist.
1076
+ const perPageStorageKey = computed<string | null>(() => {
1077
+ const url = props.inertiaUrl || props.apiUrl;
1078
+ if (!url) return null;
979
1079
  return `dxtable-perpage-${url.replace(/\//g, '-')}`;
980
1080
  });
981
1081
 
1082
+ // Whether the consumer EXPLICITLY passed `perPage`. A defaulted/omitted prop is
1083
+ // indistinguishable via `props`, so this reads the raw vnode props — the same
1084
+ // technique as `hasListener` above (Vue keeps passed props on the vnode).
1085
+ const wasPerPagePassed = ((): boolean => {
1086
+ const vnodeProps = instance?.vnode.props ?? {};
1087
+ return 'perPage' in vnodeProps || 'per-page' in vnodeProps;
1088
+ })();
1089
+
982
1090
  // Load perPage from localStorage or use default
983
1091
  const getInitialPerPage = (): number => {
1092
+ // An explicitly-passed `perPage` wins over any stored preference (#124):
1093
+ // `:per-page="20"` means "start at 20" (since #110) and must not be silently
1094
+ // overridden by a size the user once picked on a different table.
1095
+ if (wasPerPagePassed && props.perPage !== undefined) {
1096
+ return props.perPage;
1097
+ }
1098
+
984
1099
  if (typeof window === 'undefined') return props.perPage || 10;
985
1100
 
986
- try {
987
- const saved = localStorage.getItem(perPageStorageKey.value);
988
- if (saved !== null) {
989
- const parsed = parseInt(saved, 10);
990
- // Validate it's in the allowed options
991
- if (props.perPageOptions.includes(parsed)) {
992
- return parsed;
1101
+ const storageKey = perPageStorageKey.value;
1102
+ if (storageKey !== null) {
1103
+ try {
1104
+ const saved = localStorage.getItem(storageKey);
1105
+ if (saved !== null) {
1106
+ const parsed = parseInt(saved, 10);
1107
+ // Validate it's in the allowed options
1108
+ if (props.perPageOptions.includes(parsed)) {
1109
+ return parsed;
1110
+ }
993
1111
  }
1112
+ } catch (error) {
1113
+ console.error('Error loading perPage from localStorage:', error);
994
1114
  }
995
- } catch (error) {
996
- console.error('Error loading perPage from localStorage:', error);
997
1115
  }
998
1116
 
999
1117
  return props.perPage || 10;
@@ -1006,10 +1124,11 @@ const internalPerPage = ref<number>(getInitialPerPage());
1006
1124
  watch(() => props.pagination?.per_page, (newPerPage) => {
1007
1125
  if (newPerPage && newPerPage !== internalPerPage.value) {
1008
1126
  internalPerPage.value = newPerPage;
1009
- // Also update localStorage to stay in sync
1010
- if (typeof window !== 'undefined') {
1127
+ // Also update localStorage to stay in sync (only for URL-keyed tables).
1128
+ const storageKey = perPageStorageKey.value;
1129
+ if (typeof window !== 'undefined' && storageKey !== null) {
1011
1130
  try {
1012
- localStorage.setItem(perPageStorageKey.value, newPerPage.toString());
1131
+ localStorage.setItem(storageKey, newPerPage.toString());
1013
1132
  } catch (error) {
1014
1133
  console.error('Error saving perPage from watcher:', error);
1015
1134
  }
@@ -1445,10 +1564,11 @@ const handlePerPageChange = (newPerPage: number | string | null | (number | stri
1445
1564
  internalPerPage.value = perPageNum;
1446
1565
  }
1447
1566
 
1448
- // Save to localStorage
1449
- if (typeof window !== 'undefined') {
1567
+ // Save to localStorage (only for URL-keyed tables — see perPageStorageKey).
1568
+ const storageKey = perPageStorageKey.value;
1569
+ if (typeof window !== 'undefined' && storageKey !== null) {
1450
1570
  try {
1451
- localStorage.setItem(perPageStorageKey.value, perPageNum.toString());
1571
+ localStorage.setItem(storageKey, perPageNum.toString());
1452
1572
  } catch (error) {
1453
1573
  console.error('Error saving perPage to localStorage:', error);
1454
1574
  }
@@ -37,6 +37,9 @@
37
37
  from one row into the next. The key gives each record a fresh
38
38
  field subtree; the data still lives in `form`, so nothing
39
39
  is lost by remounting. -->
40
+ <!-- Tabs stay bare here: the modal body already provides the panel
41
+ boundary, so DXForm's default card-panel-around-tabs (#159) would be
42
+ redundant nested chrome inside the modal. -->
40
43
  <DXForm
41
44
  v-if="form"
42
45
  :key="formInstanceKey"
@@ -44,6 +47,7 @@
44
47
  :form="form"
45
48
  :fields="fields"
46
49
  :tabs="tabs"
50
+ :card-tabs="false"
47
51
  :context="item ?? undefined"
48
52
  :show-submit="false"
49
53
  @update:active-tab="emit('update:activeTab', $event)"
@@ -45,8 +45,9 @@
45
45
  </div>
46
46
  </div>
47
47
 
48
- <!-- Bottom row: Info text -->
49
- <div class="small text-muted">
48
+ <!-- Bottom row: Info text (the item-count caption). Suppressed by
49
+ `showCount` independent of the pager (#127). -->
50
+ <div v-if="showCount" class="small text-muted">
50
51
  <div>
51
52
  <template v-if="pagination.total > pagination.per_page">
52
53
  {{ pagination.from }} to {{ pagination.to }} out of {{ pagination.total }} {{ pagination.total === 1 ? singularItemName : pluralItemName }}.
@@ -81,6 +82,8 @@ interface Props {
81
82
  showPagination: boolean;
82
83
  /** Whether to render the per-page selector. */
83
84
  showPerPageSelector: boolean;
85
+ /** Whether to render the item-count caption (the "X out of Z items" line). */
86
+ showCount: boolean;
84
87
  /** Singular item noun for the info line (e.g. "product"). */
85
88
  singularItemName: string;
86
89
  /** Plural item noun for the info line (e.g. "products"). */
@@ -154,6 +154,17 @@ export function useForm<TData extends Record<string, any>>(
154
154
  ): UseFormReturn<TData> {
155
155
  const snapshot = deepClone(initialData);
156
156
 
157
+ // Concurrency guards (#133). `useForm` allows overlapping submissions (a
158
+ // consumer can fire two before the first resolves), so:
159
+ // - `inFlightCount` tracks how many requests are active; `processing` only
160
+ // flips false when the LAST one finishes (a per-request `finally` flipping
161
+ // it unconditionally re-enabled the button while another was still running).
162
+ // - `successTimer` is a single handle for the `recentlySuccessful` reset; a
163
+ // new success clears any pending timer before scheduling its own, so an
164
+ // earlier success's timer can't clear the flag a later success just set.
165
+ let inFlightCount = 0;
166
+ let successTimer: ReturnType<typeof setTimeout> | null = null;
167
+
157
168
  const state = reactive<FormState<TData>>({
158
169
  data: deepClone(snapshot) as TData,
159
170
  errors: {},
@@ -250,6 +261,7 @@ export function useForm<TData extends Record<string, any>>(
250
261
  url: string,
251
262
  options: FormSubmitOptions<TData, TResponse> = {},
252
263
  ): Promise<TResponse> => {
264
+ inFlightCount += 1;
253
265
  state.processing = true;
254
266
  if (!options.preserveErrors) clearErrors();
255
267
 
@@ -314,7 +326,11 @@ export function useForm<TData extends Record<string, any>>(
314
326
  });
315
327
 
316
328
  state.recentlySuccessful = true;
317
- setTimeout(() => (state.recentlySuccessful = false), 1500);
329
+ if (successTimer !== null) clearTimeout(successTimer);
330
+ successTimer = setTimeout(() => {
331
+ state.recentlySuccessful = false;
332
+ successTimer = null;
333
+ }, 1500);
318
334
 
319
335
  options.onSuccess?.(data);
320
336
  if (options.resetOnSuccess) {
@@ -328,7 +344,9 @@ export function useForm<TData extends Record<string, any>>(
328
344
  options.onError?.(err as FormError);
329
345
  throw err;
330
346
  } finally {
331
- state.processing = false;
347
+ inFlightCount -= 1;
348
+ // Only clear `processing` once no submission is still in flight.
349
+ if (inFlightCount === 0) state.processing = false;
332
350
  options.onFinish?.();
333
351
  }
334
352
  };
@@ -196,6 +196,15 @@ export interface FieldDefinition {
196
196
  */
197
197
  textWhenFalse?: MaybeFn<string>;
198
198
 
199
+ /**
200
+ * For `switch` fields: the on-state colour. `"success"` (default) is the
201
+ * house green-on / red-off style for an active/enabled/good switch;
202
+ * `"neutral"` (brand primary on / grey off) is for semantically-mixed
203
+ * switches that shouldn't imply good/bad ("contains alcohol", an allergen
204
+ * toggle, "hidden on web shop"). See DXSwitch `onVariant` (#158).
205
+ */
206
+ switchVariant?: "success" | "neutral";
207
+
199
208
  /** CSS class for the form group */
200
209
  class?: string;
201
210