@omnitend/dashboard-for-laravel 0.24.0 → 0.26.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.
@@ -172,7 +172,7 @@ import DAlert from "../base/DAlert.vue";
172
172
  import DButton from "../base/DButton.vue";
173
173
  import DCard from "../base/DCard.vue";
174
174
  import DTabs from "../base/DTabs.vue";
175
- import DTab from "../base/DTab.vue";
175
+ import { BTab as DTab } from "bootstrap-vue-next"; // raw BTab: BTabs scans slot vnodes for it (#119)
176
176
  import DXFormField from "./DXFormField.vue";
177
177
  import type { UseFormReturn } from "../../composables/useForm";
178
178
  import type { DefineFormReturn } from "../../composables/defineForm";
@@ -44,6 +44,7 @@
44
44
  <DTable
45
45
  v-else-if="isProviderMode"
46
46
  ref="tableRef"
47
+ :key="tableSlotSignature($slots)"
47
48
  :provider="effectiveProvider"
48
49
  :fields="fields"
49
50
  :sort-by="effectiveSortBy"
@@ -55,6 +56,7 @@
55
56
  :hover="hover"
56
57
  :responsive="responsive"
57
58
  :busy="busy"
59
+ :tbody-tr-class="composeRowClass"
58
60
  v-bind="tablePassthroughProps"
59
61
  @update:sort-by="handleSortChange"
60
62
  @update:expanded-items="emit('update:expandedItems', $event)"
@@ -62,9 +64,19 @@
62
64
  @update:busy="handleBusyChange"
63
65
  @row-clicked="handleRowClick"
64
66
  >
65
- <!-- Inline Filter Row -->
66
- <template v-if="hasFilters" #thead-top>
67
- <tr class="filter-row">
67
+ <!-- DXTable owns `thead-top` (its filter row lives there), so a
68
+ consumer's own thead-top content is COMPOSED above it rather
69
+ than being dropped: a grouped column-header banner or a pinned
70
+ totals row sits above the headers where it belongs (#120). -->
71
+ <template v-if="hasFilters || $slots['thead-top']" #thead-top="theadScope">
72
+ <!--
73
+ @slot A row rendered ABOVE the column headers — a grouped-column banner (a `<th colspan>` spanning several columns), or a pinned totals row. Renders above DXTable's own filter row. Give it `<tr>`s.
74
+ @binding {object} columns The number of columns in the table.
75
+ @binding {object} fields The table's fields.
76
+ -->
77
+ <slot name="thead-top" v-bind="theadScope" />
78
+
79
+ <tr v-if="hasFilters" class="filter-row">
68
80
  <th v-for="field in fields" :key="`filter-${field.key}`" class="p-2">
69
81
  <!-- Text Filter -->
70
82
  <DFormInput
@@ -80,10 +92,10 @@
80
92
  v-else-if="field.filter === 'select'"
81
93
  :model-value="effectiveFilters[filterKeyFor(field)] || ''"
82
94
  :options="getFieldFilterOptions(field)"
83
- :placeholder="field.filterPlaceholder || `All ${field.label || field.key}`"
95
+ :placeholder="field.filterPlaceholder || filterAllLabelFor(field)"
84
96
  size="sm"
85
97
  open-on-focus
86
- @update:model-value="handleFilterChange(filterKeyFor(field), ($event ?? '') as string)"
98
+ @update:model-value="handleSelectFilterChange(field, $event)"
87
99
  />
88
100
 
89
101
  <!-- Number Filter -->
@@ -130,7 +142,7 @@
130
142
  table-busy/caption/colgroup, thead-sub, top-row, bottom-row.
131
143
  See `isTableSlot` for the two DXTable renders itself. -->
132
144
  <template
133
- v-for="name in forwardedTableSlotNames"
145
+ v-for="name in forwardableSlotNames($slots)"
134
146
  :key="name"
135
147
  #[name]="slotProps"
136
148
  >
@@ -144,6 +156,7 @@
144
156
  <!-- Client-Side Mode: Local filtering, sorting, pagination -->
145
157
  <DTable
146
158
  v-else-if="isClientSideMode"
159
+ :key="tableSlotSignature($slots)"
147
160
  :items="clientSidePaginatedItems"
148
161
  :fields="fields"
149
162
  :sort-by="effectiveSortBy"
@@ -153,14 +166,25 @@
153
166
  :striped="striped"
154
167
  :hover="hover"
155
168
  :responsive="responsive"
169
+ :tbody-tr-class="composeRowClass"
156
170
  v-bind="tablePassthroughProps"
157
171
  @update:sort-by="handleSortChange"
158
172
  @update:expanded-items="emit('update:expandedItems', $event)"
159
173
  @row-clicked="handleRowClick"
160
174
  >
161
- <!-- Inline Filter Row -->
162
- <template v-if="hasFilters" #thead-top>
163
- <tr class="filter-row">
175
+ <!-- DXTable owns `thead-top` (its filter row lives there), so a
176
+ consumer's own thead-top content is COMPOSED above it rather
177
+ than being dropped: a grouped column-header banner or a pinned
178
+ totals row sits above the headers where it belongs (#120). -->
179
+ <template v-if="hasFilters || $slots['thead-top']" #thead-top="theadScope">
180
+ <!--
181
+ @slot A row rendered ABOVE the column headers — a grouped-column banner (a `<th colspan>` spanning several columns), or a pinned totals row. Renders above DXTable's own filter row. Give it `<tr>`s.
182
+ @binding {object} columns The number of columns in the table.
183
+ @binding {object} fields The table's fields.
184
+ -->
185
+ <slot name="thead-top" v-bind="theadScope" />
186
+
187
+ <tr v-if="hasFilters" class="filter-row">
164
188
  <th v-for="field in fields" :key="`filter-${field.key}`" class="p-2">
165
189
  <!-- Text Filter -->
166
190
  <DFormInput
@@ -176,10 +200,10 @@
176
200
  v-else-if="field.filter === 'select'"
177
201
  :model-value="effectiveFilters[filterKeyFor(field)] || ''"
178
202
  :options="getFieldFilterOptions(field)"
179
- :placeholder="field.filterPlaceholder || `All ${field.label || field.key}`"
203
+ :placeholder="field.filterPlaceholder || filterAllLabelFor(field)"
180
204
  size="sm"
181
205
  open-on-focus
182
- @update:model-value="handleFilterChange(filterKeyFor(field), ($event ?? '') as string)"
206
+ @update:model-value="handleSelectFilterChange(field, $event)"
183
207
  />
184
208
 
185
209
  <!-- Number Filter -->
@@ -226,7 +250,7 @@
226
250
  table-busy/caption/colgroup, thead-sub, top-row, bottom-row.
227
251
  See `isTableSlot` for the two DXTable renders itself. -->
228
252
  <template
229
- v-for="name in forwardedTableSlotNames"
253
+ v-for="name in forwardableSlotNames($slots)"
230
254
  :key="name"
231
255
  #[name]="slotProps"
232
256
  >
@@ -240,6 +264,7 @@
240
264
  <!-- Inertia Mode: Use items prop -->
241
265
  <DTable
242
266
  v-else-if="isInertiaMode"
267
+ :key="tableSlotSignature($slots)"
243
268
  :items="items"
244
269
  :fields="fields"
245
270
  :sort-by="effectiveSortBy"
@@ -250,14 +275,25 @@
250
275
  :hover="hover"
251
276
  :responsive="responsive"
252
277
  :busy="effectiveBusy"
278
+ :tbody-tr-class="composeRowClass"
253
279
  v-bind="tablePassthroughProps"
254
280
  @update:sort-by="handleSortChange"
255
281
  @update:expanded-items="emit('update:expandedItems', $event)"
256
282
  @row-clicked="handleRowClick"
257
283
  >
258
- <!-- Inline Filter Row -->
259
- <template v-if="hasFilters" #thead-top>
260
- <tr class="filter-row">
284
+ <!-- DXTable owns `thead-top` (its filter row lives there), so a
285
+ consumer's own thead-top content is COMPOSED above it rather
286
+ than being dropped: a grouped column-header banner or a pinned
287
+ totals row sits above the headers where it belongs (#120). -->
288
+ <template v-if="hasFilters || $slots['thead-top']" #thead-top="theadScope">
289
+ <!--
290
+ @slot A row rendered ABOVE the column headers — a grouped-column banner (a `<th colspan>` spanning several columns), or a pinned totals row. Renders above DXTable's own filter row. Give it `<tr>`s.
291
+ @binding {object} columns The number of columns in the table.
292
+ @binding {object} fields The table's fields.
293
+ -->
294
+ <slot name="thead-top" v-bind="theadScope" />
295
+
296
+ <tr v-if="hasFilters" class="filter-row">
261
297
  <th v-for="field in fields" :key="`filter-${field.key}`" class="p-2">
262
298
  <!-- Text Filter -->
263
299
  <DFormInput
@@ -273,10 +309,10 @@
273
309
  v-else-if="field.filter === 'select'"
274
310
  :model-value="effectiveFilters[filterKeyFor(field)] || ''"
275
311
  :options="getFieldFilterOptions(field)"
276
- :placeholder="field.filterPlaceholder || `All ${field.label || field.key}`"
312
+ :placeholder="field.filterPlaceholder || filterAllLabelFor(field)"
277
313
  size="sm"
278
314
  open-on-focus
279
- @update:model-value="handleFilterChange(filterKeyFor(field), ($event ?? '') as string)"
315
+ @update:model-value="handleSelectFilterChange(field, $event)"
280
316
  />
281
317
 
282
318
  <!-- Number Filter -->
@@ -323,7 +359,7 @@
323
359
  table-busy/caption/colgroup, thead-sub, top-row, bottom-row.
324
360
  See `isTableSlot` for the two DXTable renders itself. -->
325
361
  <template
326
- v-for="name in forwardedTableSlotNames"
362
+ v-for="name in forwardableSlotNames($slots)"
327
363
  :key="name"
328
364
  #[name]="slotProps"
329
365
  >
@@ -685,6 +721,14 @@ export interface TableField {
685
721
 
686
722
  /** The value sent when the `filterNullText` option is chosen. Default `"null"`. */
687
723
  filterNullValue?: string;
724
+
725
+ /**
726
+ * Text of the "clear this filter" option at the top of a `select` filter's
727
+ * list (default: the filter's placeholder, e.g. "All statuses"). The list
728
+ * otherwise offers no way back to *unfiltered* once a value is picked —
729
+ * only the ✕, which isn't discoverable from inside the open dropdown.
730
+ */
731
+ filterAllText?: string;
688
732
  sortable?: boolean;
689
733
  hint?: string;
690
734
  filter?: FilterType;
@@ -909,6 +953,22 @@ export interface Props<TItem = any> {
909
953
  */
910
954
  expandedItems?: TItem[];
911
955
 
956
+ /**
957
+ * Class(es) applied to each row's `<tr>` — a string, or a function of the
958
+ * row. Use for conditional row styling (a variant, a muted/disabled look)
959
+ * without reaching into the table's internal DOM from global CSS.
960
+ */
961
+ rowClass?: string | ((item: TItem, index: number) => string | string[] | Record<string, boolean>);
962
+
963
+ /**
964
+ * Whether a given row is actionable. Only relevant when rows are clickable
965
+ * (an `editFields` modal or a `row-clicked` listener). A row this returns
966
+ * `false` for gets no pointer cursor, no hover highlight, and does not fire
967
+ * `row-clicked` or open the edit modal — so a row that isn't clickable
968
+ * doesn't *look* clickable, and a click on it can't quietly do something.
969
+ */
970
+ rowClickable?: (item: TItem, index: number) => boolean;
971
+
912
972
  /** Enable client-side filtering, sorting, and pagination on items array */
913
973
  clientSide?: boolean;
914
974
  }
@@ -928,7 +988,7 @@ const props = withDefaults(defineProps<Props<T>>(), {
928
988
  }),
929
989
  showPagination: true,
930
990
  showPerPageSelector: true,
931
- perPageOptions: () => [10, 25, 50, 100],
991
+ perPageOptions: () => [10, 20, 50, 100],
932
992
  currentPage: 1,
933
993
  // perPage: 10, // Don't set default - let internalPerPage handle it
934
994
  striped: false,
@@ -1037,10 +1097,30 @@ const rowsAreInteractive = computed(
1037
1097
  () => (props.editFields?.length ?? 0) > 0 || hasListener('rowClicked'),
1038
1098
  );
1039
1099
 
1040
- const rowCursor = computed(() => (rowsAreInteractive.value ? 'pointer' : 'default'));
1041
- const rowHoverBackground = computed(() =>
1042
- rowsAreInteractive.value ? 'var(--bs-table-hover-bg)' : 'inherit',
1043
- );
1100
+ /**
1101
+ * A row is actionable when the table is interactive AND the consumer's
1102
+ * `rowClickable` predicate (if any) says so — a row-clicked table can have rows
1103
+ * that aren't navigable (#115).
1104
+ */
1105
+ const isRowActionable = (item: T, index: number): boolean =>
1106
+ rowsAreInteractive.value && (props.rowClickable?.(item, index) ?? true);
1107
+
1108
+ /**
1109
+ * The `<tr>` class, composed from the consumer's `rowClass` and our own
1110
+ * actionable marker. The pointer/hover affordance hangs off the marker class
1111
+ * rather than a blanket `tbody tr` rule, so a non-actionable row simply doesn't
1112
+ * get it — which is what let consumers drop their global
1113
+ * `tbody tr:has(.marker)` CSS hack.
1114
+ */
1115
+ const composeRowClass = (item: T, index: number) => {
1116
+ const consumerClass =
1117
+ typeof props.rowClass === 'function' ? props.rowClass(item, index) : props.rowClass;
1118
+
1119
+ const classes: any[] = [];
1120
+ if (consumerClass) classes.push(consumerClass);
1121
+ if (isRowActionable(item, index)) classes.push('dx-row-actionable');
1122
+ return classes;
1123
+ };
1044
1124
 
1045
1125
  // Internal sortBy state, seeded from the prop when it's an initial value.
1046
1126
  const internalSortBy = ref<BTableSortBy[]>(props.sortBy ? [...props.sortBy] : []);
@@ -1228,6 +1308,20 @@ const filterKeyFor = (field: TableField): string => field.filterKey ?? field.key
1228
1308
  // The value that means "has no value" for a select filter (#106).
1229
1309
  const filterNullValueFor = (field: TableField): string => field.filterNullValue ?? 'null';
1230
1310
 
1311
+ // The filter's placeholder, and the label of its "no filter" option — the same
1312
+ // words, so picking the top option visibly returns the field to its resting state.
1313
+ // See getFieldFilterOptions: an empty-string option value makes bvn render no
1314
+ // options at all, so "no filter" needs a value that isn't empty.
1315
+ const FILTER_ALL_VALUE = '__dx_filter_all__';
1316
+
1317
+ const handleSelectFilterChange = (field: TableField, value: unknown) => {
1318
+ const selected = (value ?? '') as string;
1319
+ handleFilterChange(filterKeyFor(field), selected === FILTER_ALL_VALUE ? '' : selected);
1320
+ };
1321
+
1322
+ const filterAllLabelFor = (field: TableField): string =>
1323
+ field.filterAllText ?? field.filterPlaceholder ?? `All ${field.label || field.key}`;
1324
+
1231
1325
  /**
1232
1326
  * Column header text. An explicitly empty `label` means an EMPTY header — an
1233
1327
  * actions column headed `actions` (lowercase, as-keyed) is a leak, not a
@@ -1239,7 +1333,19 @@ const headerLabel = (field: TableField, slotLabel?: string): string =>
1239
1333
  const getFieldFilterOptions = (field: TableField): FilterOption[] => {
1240
1334
  const options: FilterOption[] = [];
1241
1335
 
1242
- // An "Unassigned"-style option, so a select filter can express "no value".
1336
+ // The way back to *unfiltered*. Without it the dropdown lists only the
1337
+ // values, so once a filter is picked the only way to clear it is the ✕ —
1338
+ // which isn't discoverable from inside the open list.
1339
+ //
1340
+ // It carries a SENTINEL, not `''`. bvn drops the ENTIRE option list if any
1341
+ // option's value is an empty string — not just that entry, the whole list —
1342
+ // so the obvious encoding of "no filter" silently empties the dropdown.
1343
+ // `handleSelectFilterChange` translates it back to `''`, which is already
1344
+ // how the filter map represents unset.
1345
+ options.push({ value: FILTER_ALL_VALUE, text: filterAllLabelFor(field) });
1346
+
1347
+ // An "Unassigned"-style option, so a select filter can express "has no value"
1348
+ // — a different thing from "no filter" above.
1243
1349
  if (field.filterNullText) {
1244
1350
  options.push({ value: filterNullValueFor(field), text: field.filterNullText });
1245
1351
  }
@@ -1257,7 +1363,8 @@ const getFieldFilterOptions = (field: TableField): FilterOption[] => {
1257
1363
  return [...options, ...serverValues.map(value => ({ value, text: value }))];
1258
1364
  }
1259
1365
 
1260
- return options;
1366
+ // No values to choose from — don't offer a lone "All x" that filters nothing.
1367
+ return [];
1261
1368
  };
1262
1369
 
1263
1370
  // LocalStorage key for perPage preference
@@ -1379,12 +1486,37 @@ const effectivePerPage = computed(() => {
1379
1486
  // Client-Side Mode: Pagination (requires effectivePerPage)
1380
1487
  // ============================================
1381
1488
 
1489
+ /*
1490
+ * The page the client-side view actually renders (#118).
1491
+ *
1492
+ * `clientSideCurrentPage` is only reset on a filter or per-page change, so when
1493
+ * the `items` prop SHRINKS — a consumer refetching a narrower dataset — it can
1494
+ * point past the end of the new set. The pagination metadata below already
1495
+ * clamped for display, but the slice used the raw value, so the pager said
1496
+ * "page 2 of 2" while the table sliced page 6 and rendered zero rows. Both read
1497
+ * from this now, so the view and its pager can't disagree.
1498
+ */
1499
+ const clientSideLastPage = computed(() =>
1500
+ Math.max(1, Math.ceil(clientSideFilteredItems.value.length / effectivePerPage.value)),
1501
+ );
1502
+
1503
+ const clientSidePage = computed(() =>
1504
+ Math.min(Math.max(1, clientSideCurrentPage.value), clientSideLastPage.value),
1505
+ );
1506
+
1507
+ // Write the clamp back, so the NEXT interaction starts from a real page rather
1508
+ // than a stale one. Clamping to the last page (not resetting to 1) keeps the
1509
+ // user near where they were.
1510
+ watch(clientSideLastPage, (lastPage) => {
1511
+ if (clientSideCurrentPage.value > lastPage) clientSideCurrentPage.value = lastPage;
1512
+ });
1513
+
1382
1514
  // Client-side paginated items (final output)
1383
1515
  const clientSidePaginatedItems = computed<T[]>(() => {
1384
1516
  if (!isClientSideMode.value) return [];
1385
1517
 
1386
1518
  const perPage = effectivePerPage.value;
1387
- const start = (clientSideCurrentPage.value - 1) * perPage;
1519
+ const start = (clientSidePage.value - 1) * perPage;
1388
1520
  const end = start + perPage;
1389
1521
 
1390
1522
  return clientSideSortedItems.value.slice(start, end);
@@ -1395,11 +1527,10 @@ const clientSidePagination = computed<PaginationData>(() => {
1395
1527
  const total = clientSideFilteredItems.value.length;
1396
1528
  const totalUnfiltered = props.items?.length || 0;
1397
1529
  const perPage = effectivePerPage.value;
1398
- const currentPage = clientSideCurrentPage.value;
1399
- const lastPage = Math.max(1, Math.ceil(total / perPage));
1530
+ const lastPage = clientSideLastPage.value;
1400
1531
 
1401
- // Ensure current page is valid
1402
- const validPage = Math.min(Math.max(1, currentPage), lastPage);
1532
+ // Same clamped page the slice uses — see `clientSidePage` (#118).
1533
+ const validPage = clientSidePage.value;
1403
1534
 
1404
1535
  const from = total > 0 ? (validPage - 1) * perPage + 1 : 0;
1405
1536
  const to = Math.min(validPage * perPage, total);
@@ -1798,6 +1929,43 @@ try {
1798
1929
  // Forward only the keyed edit slots the consumer actually provided, so
1799
1930
  // DXForm doesn't mistake an always-present (but empty) wrapper for
1800
1931
  // a real custom-value override.
1932
+ /*
1933
+ * Seeding an EDIT form from a row (#117).
1934
+ *
1935
+ * `row[key] ?? field.default` is wrong: it can't tell "the row has no such key"
1936
+ * from "the row's value IS null", so an explicitly-null column gets overwritten
1937
+ * by the field's default — a value the user never saw, on a field they may not
1938
+ * even be able to see. Presence, not nullishness, decides.
1939
+ */
1940
+ const seedValueFor = (field: any, row: T): any => {
1941
+ if (Object.prototype.hasOwnProperty.call(row as any, field.key)) {
1942
+ return (row as any)[field.key];
1943
+ }
1944
+ return field.default ?? '';
1945
+ };
1946
+
1947
+ /*
1948
+ * Whether a field is currently VISIBLE, by the same rule DXForm renders by
1949
+ * (`when` + the legacy `show`), against the same model (context + live data).
1950
+ * Kept in step with DXForm.isFieldVisible.
1951
+ */
1952
+ const editPredicateModel = computed(() => ({
1953
+ ...((selectedItem.value as any) ?? {}),
1954
+ ...(editForm.value?.data ?? {}),
1955
+ }));
1956
+
1957
+ const isEditFieldVisible = (field: any): boolean => {
1958
+ const when = field.when;
1959
+ const whenOk =
1960
+ when === undefined
1961
+ ? true
1962
+ : typeof when === 'function'
1963
+ ? when(editPredicateModel.value)
1964
+ : when;
1965
+ const showOk = field.show ? field.show() : true;
1966
+ return whenOk && showOk;
1967
+ };
1968
+
1801
1969
  // Fields whose value actually belongs in the payload. A presentational field
1802
1970
  // (submit: false) renders but holds no data — see FieldDefinition.submit.
1803
1971
  const submittableEditFields = computed(() =>
@@ -1808,7 +1976,21 @@ const submittableEditFields = computed(() =>
1808
1976
  // so a `submit: false` control — or a `span` slot calling `update` — can write
1809
1977
  // its key back into the form data after seeding. Strip them on the way out.
1810
1978
  const nonSubmittedFieldKeys = computed(
1811
- () => new Set((props.editFields ?? []).filter((field) => field.submit === false).map((field) => field.key)),
1979
+ () =>
1980
+ new Set(
1981
+ (props.editFields ?? [])
1982
+ .filter(
1983
+ (field) =>
1984
+ // Presentational: lays the form out, holds no data (#110).
1985
+ field.submit === false ||
1986
+ // Hidden by `when` at submit time (#117). Submitting a field
1987
+ // the user cannot see is a silent write — and with `default`
1988
+ // set it writes a value they never chose. Omitting the key
1989
+ // leaves the stored value alone.
1990
+ !isEditFieldVisible(field),
1991
+ )
1992
+ .map((field) => field.key),
1993
+ ),
1812
1994
  );
1813
1995
 
1814
1996
  const stripNonSubmittedFields = (data: Record<string, any>): Record<string, any> => {
@@ -1850,9 +2032,50 @@ const TABLE_SLOT_NAMES = new Set([
1850
2032
  const isTableSlot = (name: string) =>
1851
2033
  TABLE_SLOT_NAMES.has(name) || TABLE_SLOT_PREFIXES.some((prefix) => name.startsWith(prefix));
1852
2034
 
1853
- const forwardedTableSlotNames = computed(() =>
1854
- Object.keys(tableSlots).filter((name) => isTableSlot(name)),
1855
- );
2035
+ /*
2036
+ * The inner table is KEYED on the set of forwarded slot names (#114).
2037
+ *
2038
+ * bootstrap-vue-next's BTable captures its slot set at mount: a `cell(...)` slot
2039
+ * that comes into existence LATER — a consumer whose columns are data-driven and
2040
+ * only known once a fetch resolves — never reaches the cells, and the column
2041
+ * renders raw values as though no cell slot existed. Verified against raw BTable,
2042
+ * so it isn't something our forwarding chain drops.
2043
+ *
2044
+ * Nothing in the forwarding can fix that, so the inner table remounts when the
2045
+ * slot SET changes. Consumers were already remounting to work around it, but at
2046
+ * the DXTable level, which throws away per-page, filters, sort and page — all of
2047
+ * which live out here and now survive. The signature is names-only, so a slot's
2048
+ * CONTENT changing (the common case) doesn't remount anything.
2049
+ *
2050
+ * In provider mode a remount refetches; that only happens when the column set
2051
+ * itself changes, which is already a refetch in practice.
2052
+ */
2053
+ /*
2054
+ * A FUNCTION, not a computed: `useSlots()` returns an object Vue mutates in
2055
+ * place with nothing to track, so a computed over its keys is captured on the
2056
+ * first render and never updates — which is what made late `cell(...)` slots
2057
+ * invisible (#114). Called from the template, it re-evaluates every render.
2058
+ *
2059
+ * It also has to EXCLUDE the slots DXTable renders itself: declaring `#thead-top`
2060
+ * here (even rendering nothing into it) would override DXTable's own thead-top
2061
+ * template, silently dropping the filter row and the consumer's banner (#120).
2062
+ */
2063
+ const forwardableSlotNames = (slots: Record<string, unknown>): string[] =>
2064
+ Object.keys(slots).filter((name) => isTableSlot(name));
2065
+
2066
+ const tableSlotSignature = (slots: Record<string, unknown>): string =>
2067
+ Object.keys(slots).filter(isTableSlot).sort().join('|');
2068
+
2069
+ /*
2070
+ * The template iterates `$slots` DIRECTLY rather than a computed over
2071
+ * `useSlots()`. It has to: `useSlots()` returns an object that Vue MUTATES in
2072
+ * place, with no reactive dependency to track — so a computed over its keys is
2073
+ * captured on first render and never recomputes. A consumer whose `cell(...)`
2074
+ * slots come into existence after a fetch resolves (data-driven columns) got
2075
+ * columns with raw values and no cell templates, and had to remount the whole
2076
+ * table to fix it (#114). Reading `$slots` inside the render function re-reads
2077
+ * the current slot set on every update.
2078
+ */
1856
2079
 
1857
2080
  // Table-level features of the inner BTable, exposed verbatim and bound to all
1858
2081
  // three data modes from one place.
@@ -1921,6 +2144,11 @@ const getFieldSortState = (fieldKey: string) => {
1921
2144
 
1922
2145
  // Handle row click for editing
1923
2146
  const handleRowClick = (item: T, index: number, event: MouseEvent) => {
2147
+ // A row the consumer marked non-actionable doesn't look clickable, so it
2148
+ // must not BE clickable either — otherwise a click that looks dead quietly
2149
+ // navigates or opens the modal (#115).
2150
+ if (props.rowClickable && !props.rowClickable(item, index)) return;
2151
+
1924
2152
  // Always emit rowClicked for custom handling
1925
2153
  emit('rowClicked', item, index, event);
1926
2154
 
@@ -1940,13 +2168,13 @@ const handleRowClick = (item: T, index: number, event: MouseEvent) => {
1940
2168
  // Presentational fields (submit: false) lay the form out; they hold
1941
2169
  // no data and must not be POSTed just because they were declared.
1942
2170
  submittableEditFields.value.forEach(field => {
1943
- formData[field.key] = (item as any)[field.key] ?? field.default ?? '';
2171
+ formData[field.key] = seedValueFor(field, item);
1944
2172
  });
1945
2173
  editForm.value = useForm(formData);
1946
2174
  } else {
1947
2175
  // Update existing form
1948
2176
  submittableEditFields.value.forEach(field => {
1949
- editForm.value.data[field.key] = (item as any)[field.key] ?? field.default ?? '';
2177
+ editForm.value.data[field.key] = seedValueFor(field, item);
1950
2178
  });
1951
2179
  editForm.value.clearErrors();
1952
2180
  }
@@ -2249,13 +2477,15 @@ defineExpose({
2249
2477
  </script>
2250
2478
 
2251
2479
  <style scoped>
2252
- /* Rows that do something on click look like it — see `rowsAreInteractive`. */
2253
- :deep(tbody tr) {
2254
- cursor: v-bind(rowCursor);
2480
+ /* Rows that do something on click look like it. Hung off a marker class rather
2481
+ than a blanket `tbody tr` rule, so a row the consumer's `rowClickable` says is
2482
+ not actionable simply doesn't get the affordance (#107, #115). */
2483
+ :deep(tbody tr.dx-row-actionable) {
2484
+ cursor: pointer;
2255
2485
  }
2256
2486
 
2257
- :deep(tbody tr:hover) {
2258
- background-color: v-bind(rowHoverBackground);
2487
+ :deep(tbody tr.dx-row-actionable:hover) {
2488
+ background-color: var(--bs-table-hover-bg);
2259
2489
  }
2260
2490
 
2261
2491
  /* Improve pagination button sizing to match form controls */
@@ -15,7 +15,11 @@
15
15
  <div class="dx-user-avatar">
16
16
  <!-- `.user-avatar` (not a `dx-`-prefixed name) because it's the documented
17
17
  theming hook consumers already override — see the theming guide. -->
18
- <span class="user-avatar">
18
+ <!-- The initial is decorative: it tells a screen-reader user nothing they
19
+ don't know, and it would otherwise leak into the accessible name of
20
+ whatever contains the avatar (the navbar's user-menu trigger). The
21
+ badge's label below stays announced. -->
22
+ <span class="user-avatar" aria-hidden="true">
19
23
  <!-- @slot Replaces the initial inside the disc (e.g. a photo or an icon). -->
20
24
  <slot>{{ resolvedInitial }}</slot>
21
25
  </span>
@@ -85,7 +85,20 @@ export { default as DProgress } from "./components/base/DProgress.vue";
85
85
  export { default as DRow } from "./components/base/DRow.vue";
86
86
  export { default as DSpinner } from "./components/base/DSpinner.vue";
87
87
  export { default as DTable } from "./components/base/DTable.vue";
88
- export { default as DTab } from "./components/base/DTab.vue";
88
+ /*
89
+ * DTab is a RAW RE-EXPORT of BTab, not a wrapper — same exception as
90
+ * DCarouselSlide above, and for the same reason.
91
+ *
92
+ * BTabs decides which tab to activate on mount by scanning its slot vnodes for
93
+ * the BTab component type (its initial-selection branch is gated on
94
+ * `tabElementsArray.length > 0`). A wrapper component in between hides that, so
95
+ * BTabs found no tabs to select and NO pane got `active`/`show`: the nav
96
+ * rendered and every body was invisible until the user clicked (#119).
97
+ *
98
+ * Bisected — BTabs+BTab and DTabs+BTab both activate the first tab; BTabs+DTab
99
+ * does not. So it is the child wrapper, and only the child, that has to go.
100
+ */
101
+ export { BTab as DTab } from "bootstrap-vue-next";
89
102
  export { default as DTabs } from "./components/base/DTabs.vue";
90
103
  export { default as DToast } from "./components/base/DToast.vue";
91
104
  export { default as DToaster } from "./components/base/DToaster.vue";
@@ -1,12 +0,0 @@
1
- declare var __VLS_7: string | number, __VLS_8: any;
2
- type __VLS_Slots = {} & {
3
- [K in NonNullable<typeof __VLS_7>]?: (props: typeof __VLS_8) => any;
4
- };
5
- declare const __VLS_component: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
6
- declare const _default: __VLS_WithSlots<typeof __VLS_component, __VLS_Slots>;
7
- export default _default;
8
- type __VLS_WithSlots<T, S> = T & {
9
- new (): {
10
- $slots: S;
11
- };
12
- };
@@ -1,14 +0,0 @@
1
- <script setup lang="ts">
2
- import { BTab } from "bootstrap-vue-next";
3
- import { useSlots } from "vue";
4
-
5
- const slots = useSlots();
6
- </script>
7
-
8
- <template>
9
- <BTab v-bind="$attrs">
10
- <template v-for="(_, name) in slots" :key="name" #[name]="slotProps">
11
- <slot :name="name" v-bind="slotProps" />
12
- </template>
13
- </BTab>
14
- </template>