@omnitend/dashboard-for-laravel 0.34.0 → 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/components/extended/DXForm.vue.d.ts +28 -6
- package/dist/components/extended/DXTable.vue.d.ts +39 -1
- package/dist/components/extended/DXTableEditorModal.vue.d.ts +7 -2
- package/dist/composables/useContainerWidth.d.ts +96 -0
- package/dist/dashboard-for-laravel.js +8138 -7998
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +7 -7
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/style.css +1 -1
- package/docs/public/api-reference.json +40 -7
- package/docs/public/docs-map.md +1 -1
- package/package.json +1 -1
- package/resources/css/theme.scss +80 -2
- package/resources/js/components/extended/DXDashboardSidebar.vue +1 -1
- package/resources/js/components/extended/DXForm.vue +83 -9
- package/resources/js/components/extended/DXTable.vue +217 -12
- package/resources/js/components/extended/DXTableEditorModal.vue +7 -2
- package/resources/js/components/extended/DXTablePagination.vue +19 -3
- package/resources/js/components/extended/DXTableShell.vue +70 -1
- package/resources/js/composables/useContainerWidth.ts +303 -0
- package/resources/js/index.ts +7 -0
|
@@ -163,6 +163,35 @@
|
|
|
163
163
|
<div class="fw-semibold">{{ headerLabel(field, label) }}</div>
|
|
164
164
|
<small v-if="field.hint" class="text-muted d-block" style="font-weight: normal;">{{ field.hint }}</small>
|
|
165
165
|
</div>
|
|
166
|
+
|
|
167
|
+
<!-- ADDITIVE per-column header content (#99), rendered
|
|
168
|
+
INSIDE DXTable's own header cell rather than as a
|
|
169
|
+
`head(<key>)` override — an override would silently
|
|
170
|
+
drop the sort indicator and the field hint, which is
|
|
171
|
+
exactly why `head(...)` is not forwarded (see
|
|
172
|
+
`isTableSlot`). Placed after the label block and
|
|
173
|
+
BEFORE the sort indicator so a period total sits at
|
|
174
|
+
the top-right of the text area without displacing the
|
|
175
|
+
sort arrows, and lands in the same place whether or
|
|
176
|
+
not the column happens to be sortable. The wrapper is
|
|
177
|
+
only emitted when the slot exists, so a table without
|
|
178
|
+
one renders exactly as before. -->
|
|
179
|
+
<div
|
|
180
|
+
v-if="$slots[`head-end(${field.key})`]"
|
|
181
|
+
class="dx-head-end flex-shrink-0 text-end"
|
|
182
|
+
>
|
|
183
|
+
<!--
|
|
184
|
+
@slot head-end(<fieldKey>) Additive content at the end of a column's own header — a period total above a numeric column, a small badge — keeping DXTable's sort indicator and field hint. Not a `head()` override.
|
|
185
|
+
@binding {object} field The column's field definition.
|
|
186
|
+
@binding {string} label The column's resolved header label.
|
|
187
|
+
-->
|
|
188
|
+
<slot
|
|
189
|
+
:name="`head-end(${field.key})`"
|
|
190
|
+
:field="field"
|
|
191
|
+
:label="headerLabel(field, label)"
|
|
192
|
+
/>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
166
195
|
<div v-if="field.sortable" class="sort-indicator text-muted flex-shrink-0" style="font-size: 0.75rem; line-height: 1.1; display: flex; flex-direction: column; align-items: center;">
|
|
167
196
|
<span :style="{ opacity: getFieldSortState(field.key) === 'asc' ? 1 : 0.3 }">▲</span>
|
|
168
197
|
<span :style="{ opacity: getFieldSortState(field.key) === 'desc' ? 1 : 0.3 }">▼</span>
|
|
@@ -359,6 +388,22 @@ export interface TableField {
|
|
|
359
388
|
filterOptions?: FilterOption[];
|
|
360
389
|
filterPlaceholder?: string;
|
|
361
390
|
|
|
391
|
+
/**
|
|
392
|
+
* For a `select` filter on a **client-side** table: derive the dropdown's
|
|
393
|
+
* options from the distinct values present in the loaded rows. Defaults to
|
|
394
|
+
* `true` — with no `filterOptions` and no server `filterValues` the
|
|
395
|
+
* alternative is an empty dropdown, which can only ever be a bug.
|
|
396
|
+
*
|
|
397
|
+
* Set `false` to opt out (e.g. a column whose raw values are opaque ids you
|
|
398
|
+
* would rather label yourself via `filterOptions`).
|
|
399
|
+
*
|
|
400
|
+
* Explicit `filterOptions` and server-supplied `filterValues` still WIN, in
|
|
401
|
+
* that order. Provider/API and Inertia modes ignore this flag: they only
|
|
402
|
+
* ever hold one page of rows, so there is no complete value set to derive
|
|
403
|
+
* from (see `getFieldFilterOptions`).
|
|
404
|
+
*/
|
|
405
|
+
deriveFilterOptions?: boolean;
|
|
406
|
+
|
|
362
407
|
/**
|
|
363
408
|
* Fixed width for this column, applied via a `<colgroup>` `<col>`. A number
|
|
364
409
|
* is treated as pixels (`200` → `200px`); a string is used verbatim
|
|
@@ -559,6 +604,19 @@ export interface Props<TItem = any> {
|
|
|
559
604
|
* borderless variant — the same header, filter row, table and pagination
|
|
560
605
|
* rendered directly on the page background (no card border/shadow), for
|
|
561
606
|
* data-heavy admin index pages that read as busy inside a card.
|
|
607
|
+
*
|
|
608
|
+
* The table renders FLUSH to the card border (rows and stripes reach the
|
|
609
|
+
* edge) while the header and pagination stay padded, which requires the card
|
|
610
|
+
* to clip to its radius (`overflow: hidden`).
|
|
611
|
+
*
|
|
612
|
+
* **Consequence for slot content:** anything you render into a slot that
|
|
613
|
+
* positions itself OUTSIDE the card and does not teleport is clipped at the
|
|
614
|
+
* card edge. Library components are unaffected — `DDropdown` teleports to
|
|
615
|
+
* `<body>` by default, and the column-filter menus position themselves
|
|
616
|
+
* `fixed` — so this only bites a hand-rolled `position: absolute` overlay
|
|
617
|
+
* (a custom popover in a `cell(<key>)` or `#header` slot). Teleport it, or
|
|
618
|
+
* use `:card="false"`. Narrowing the clip to the table region is tracked
|
|
619
|
+
* as #166.
|
|
562
620
|
*/
|
|
563
621
|
card?: boolean;
|
|
564
622
|
|
|
@@ -585,8 +643,13 @@ export interface Props<TItem = any> {
|
|
|
585
643
|
* **"horizontal"** to match the Omni Tend form convention (a raw modal form
|
|
586
644
|
* previously fell back to DXForm's vertical default, which looked out of
|
|
587
645
|
* place next to horizontal page forms). Pass `"vertical"` to opt out.
|
|
646
|
+
*
|
|
647
|
+
* `"auto"` hands the decision to `DXForm`'s container-width observer, which
|
|
648
|
+
* measures the FORM rather than the window — so a modal narrowed by its own
|
|
649
|
+
* breakpoint stacks to vertical even on a wide viewport, where a media query
|
|
650
|
+
* would still report "plenty of room". See `useContainerWidth`.
|
|
588
651
|
*/
|
|
589
|
-
editLayout?: 'vertical' | 'horizontal';
|
|
652
|
+
editLayout?: 'vertical' | 'horizontal' | 'auto';
|
|
590
653
|
|
|
591
654
|
/**
|
|
592
655
|
* Label column width for the edit modal's horizontal layout, forwarded to
|
|
@@ -1020,16 +1083,7 @@ const clientSideFilteredItems = computed<T[]>(() => {
|
|
|
1020
1083
|
return filterKeys.every(key => {
|
|
1021
1084
|
// Resolve by the FILTER key, which may differ from the column's key.
|
|
1022
1085
|
const field = props.fields.find(f => filterKeyFor(f) === key);
|
|
1023
|
-
|
|
1024
|
-
// not carry it at all (a `customer_name` row filtered on
|
|
1025
|
-
// `customer_id`). Match on the filter key when the row has it,
|
|
1026
|
-
// otherwise fall back to the column's own value — which is also the
|
|
1027
|
-
// one the user can actually see and type against.
|
|
1028
|
-
// Both lookups are dot-path aware, so a nested payload filters (#121).
|
|
1029
|
-
const itemValue =
|
|
1030
|
-
field && hasFieldValue(item, key)
|
|
1031
|
-
? fieldValueOf(item, key)
|
|
1032
|
-
: fieldValueOf(item, field?.key ?? key);
|
|
1086
|
+
const itemValue = clientSideFilterValueOf(item, field, key);
|
|
1033
1087
|
|
|
1034
1088
|
const matchesCandidate = (candidate: string): boolean => {
|
|
1035
1089
|
// The "no value" option (e.g. Unassigned) is the one filter that
|
|
@@ -1151,6 +1205,28 @@ const hasFieldValue = (item: unknown, key: string): boolean => {
|
|
|
1151
1205
|
return getByPath(item as Record<string, any>, key) !== undefined;
|
|
1152
1206
|
};
|
|
1153
1207
|
|
|
1208
|
+
/*
|
|
1209
|
+
* The value a client-side filter compares against, for one row and one column.
|
|
1210
|
+
*
|
|
1211
|
+
* `filterKey` names the param the SERVER filters on; a local row may not carry
|
|
1212
|
+
* it at all (a `customer_name` row filtered on `customer_id`). Read the filter
|
|
1213
|
+
* key when the row has it, otherwise fall back to the column's own value —
|
|
1214
|
+
* which is also the one the user can actually see and type against. Both
|
|
1215
|
+
* lookups are dot-path aware, so a nested payload filters (#121).
|
|
1216
|
+
*
|
|
1217
|
+
* Extracted so the client-side row filter and the DERIVED select options (see
|
|
1218
|
+
* `derivedFilterOptions`) resolve values through exactly the same rule — a
|
|
1219
|
+
* derived option must never be a value the filter then fails to match.
|
|
1220
|
+
*/
|
|
1221
|
+
const clientSideFilterValueOf = (
|
|
1222
|
+
item: unknown,
|
|
1223
|
+
field: TableField | undefined,
|
|
1224
|
+
filterKey: string,
|
|
1225
|
+
): any =>
|
|
1226
|
+
field && hasFieldValue(item, filterKey)
|
|
1227
|
+
? fieldValueOf(item, filterKey)
|
|
1228
|
+
: fieldValueOf(item, field?.key ?? filterKey);
|
|
1229
|
+
|
|
1154
1230
|
/*
|
|
1155
1231
|
* Dotted-key columns DXTable must render itself, because bvn renders them empty
|
|
1156
1232
|
* (see `fieldValueOf`). A consumer's own `cell(<key>)` slot always wins — it is
|
|
@@ -1235,6 +1311,115 @@ const filterAllLabelFor = (field: TableField): string =>
|
|
|
1235
1311
|
const headerLabel = (field: TableField, slotLabel?: string): string =>
|
|
1236
1312
|
field.label !== undefined ? field.label : (slotLabel || field.key);
|
|
1237
1313
|
|
|
1314
|
+
/**
|
|
1315
|
+
* A select filter option's LABEL, honouring the column's own value→label
|
|
1316
|
+
* formatting so the dropdown reads the way the column does (a `formatter`
|
|
1317
|
+
* turning `19.99` into `$19.99`). The option's `value` stays RAW — that is what
|
|
1318
|
+
* the client-side filter compares against (`String(itemValue) === candidate`),
|
|
1319
|
+
* so formatting must never leak into it.
|
|
1320
|
+
*
|
|
1321
|
+
* `formatter` is bootstrap-vue-next's field formatter, `(value, key, item)`, and
|
|
1322
|
+
* its first argument is by contract the value of `field.key` — the DISPLAYED
|
|
1323
|
+
* column value. That matters when `filterKey` names a different property: the
|
|
1324
|
+
* option's value must stay the FILTER value (that is what the row filter
|
|
1325
|
+
* compares), but the formatter must still be handed the display value it was
|
|
1326
|
+
* written against. Passing the filter value here fed a `customer` column's
|
|
1327
|
+
* `(customer) => customer.name` formatter a numeric `customer_id`, which
|
|
1328
|
+
* mislabels at best and throws at worst.
|
|
1329
|
+
*/
|
|
1330
|
+
const formattedFilterLabel = (field: TableField, item: unknown): string | null => {
|
|
1331
|
+
if (typeof field.formatter !== 'function') return null;
|
|
1332
|
+
const formatted = field.formatter(fieldValueOf(item, field.key), field.key, item);
|
|
1333
|
+
if (formatted === null || formatted === undefined) return null;
|
|
1334
|
+
return String(formatted);
|
|
1335
|
+
};
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* Client-side auto-derived `filter: "select"` options, keyed by field key.
|
|
1339
|
+
*
|
|
1340
|
+
* Without this a client-side select column with no `filterOptions` and no
|
|
1341
|
+
* server `filterValues` rendered an EMPTY dropdown — dead UI that can only be a
|
|
1342
|
+
* bug — so this is on by default (opt out per column with
|
|
1343
|
+
* `deriveFilterOptions: false`).
|
|
1344
|
+
*
|
|
1345
|
+
* It derives from `resolvedItems` (the FULL loaded row set) and deliberately
|
|
1346
|
+
* NOT from `clientSideFilteredItems` or `clientSidePaginatedItems`:
|
|
1347
|
+
* - filtered set → picking a value would collapse the dropdown to that one
|
|
1348
|
+
* value, and the user could never get back to any other one;
|
|
1349
|
+
* - current page → the offered values would silently change as you page.
|
|
1350
|
+
* Being a computed over `props.fields` + `resolvedItems` only, it is also
|
|
1351
|
+
* structurally incapable of depending on the active filters — the cache is the
|
|
1352
|
+
* guard, not just the comment.
|
|
1353
|
+
*/
|
|
1354
|
+
const derivedFilterOptions = computed<Record<string, FilterOption[]>>(() => {
|
|
1355
|
+
// Only client-side mode has the complete row set. Provider/API and Inertia
|
|
1356
|
+
// hold one page, so they take their options from the server instead.
|
|
1357
|
+
if (!isClientSideMode.value) return {};
|
|
1358
|
+
|
|
1359
|
+
const rows = resolvedItems.value;
|
|
1360
|
+
if (!rows || rows.length === 0) return {};
|
|
1361
|
+
|
|
1362
|
+
const optionsByFieldKey: Record<string, FilterOption[]> = {};
|
|
1363
|
+
|
|
1364
|
+
for (const field of props.fields) {
|
|
1365
|
+
if (field.filter !== 'select') continue;
|
|
1366
|
+
if (field.deriveFilterOptions === false) continue;
|
|
1367
|
+
// Explicit options win outright (see `getFieldFilterOptions`), so don't
|
|
1368
|
+
// spend a pass over every row producing a list nothing will read.
|
|
1369
|
+
if (field.filterOptions && field.filterOptions.length > 0) continue;
|
|
1370
|
+
|
|
1371
|
+
const filterKey = filterKeyFor(field);
|
|
1372
|
+
|
|
1373
|
+
// Distinct values keyed by their STRING form, because that is exactly
|
|
1374
|
+
// what the client-side select filter compares — so `1` and `"1"` are one
|
|
1375
|
+
// option, and every option offered is one the filter can match. The
|
|
1376
|
+
// first row carrying a value is kept alongside it, as the `item`
|
|
1377
|
+
// argument the column's formatter expects.
|
|
1378
|
+
const firstRowByValue = new Map<string, { rawValue: any; item: unknown }>();
|
|
1379
|
+
|
|
1380
|
+
// Candidates the filter reads as CONTROL values rather than data, so a
|
|
1381
|
+
// row whose value collides with one can never be matched by selecting it
|
|
1382
|
+
// (`handleSelectFilterChange` turns the "all" sentinel back into "no
|
|
1383
|
+
// filter", and the null sentinel is intercepted upstream of the select's
|
|
1384
|
+
// exact-match arm, where it means "has no value"). Offering such an
|
|
1385
|
+
// option would be a dropdown entry that returns zero rows, and the null
|
|
1386
|
+
// one would also duplicate the `filterNullText` entry's value.
|
|
1387
|
+
const reservedCandidates = new Set<string>([FILTER_ALL_VALUE]);
|
|
1388
|
+
if (field.filterNullText) reservedCandidates.add(filterNullValueFor(field));
|
|
1389
|
+
|
|
1390
|
+
for (const item of rows) {
|
|
1391
|
+
const value = clientSideFilterValueOf(item, field, filterKey);
|
|
1392
|
+
// null / undefined / '' are skipped deliberately: "has no value" is
|
|
1393
|
+
// already a first-class, LABELLED option via `filterNullText`, and
|
|
1394
|
+
// an unlabelled blank row in the list would be unusable.
|
|
1395
|
+
if (value === null || value === undefined || value === '') continue;
|
|
1396
|
+
const stringValue = String(value);
|
|
1397
|
+
if (reservedCandidates.has(stringValue)) continue;
|
|
1398
|
+
if (!firstRowByValue.has(stringValue)) {
|
|
1399
|
+
firstRowByValue.set(stringValue, { rawValue: value, item });
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
const entries = [...firstRowByValue.entries()];
|
|
1404
|
+
|
|
1405
|
+
// Stable, human ordering: numeric when both values are numbers,
|
|
1406
|
+
// otherwise a locale compare — the same rule the client-side sort uses,
|
|
1407
|
+
// with `numeric: true` so "Item 2" precedes "Item 10".
|
|
1408
|
+
entries.sort(([leftText, left], [rightText, right]) =>
|
|
1409
|
+
typeof left.rawValue === 'number' && typeof right.rawValue === 'number'
|
|
1410
|
+
? left.rawValue - right.rawValue
|
|
1411
|
+
: leftText.localeCompare(rightText, undefined, { numeric: true, sensitivity: 'base' }),
|
|
1412
|
+
);
|
|
1413
|
+
|
|
1414
|
+
optionsByFieldKey[field.key] = entries.map(([stringValue, { item }]) => ({
|
|
1415
|
+
value: stringValue,
|
|
1416
|
+
text: formattedFilterLabel(field, item) ?? stringValue,
|
|
1417
|
+
}));
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
return optionsByFieldKey;
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1238
1423
|
const getFieldFilterOptions = (field: TableField): FilterOption[] => {
|
|
1239
1424
|
const options: FilterOption[] = [];
|
|
1240
1425
|
|
|
@@ -1274,6 +1459,15 @@ const getFieldFilterOptions = (field: TableField): FilterOption[] => {
|
|
|
1274
1459
|
return [...options, ...serverValues.map(value => ({ value, text: value }))];
|
|
1275
1460
|
}
|
|
1276
1461
|
|
|
1462
|
+
// Last resort, CLIENT-SIDE only: the distinct values present in the loaded
|
|
1463
|
+
// rows. Precedence is deliberate — explicit `filterOptions`, then the
|
|
1464
|
+
// server's `filterValues`, then what the data itself says. See
|
|
1465
|
+
// `derivedFilterOptions` for why it derives from the unfiltered row set.
|
|
1466
|
+
const derived = derivedFilterOptions.value[field.key];
|
|
1467
|
+
if (derived && derived.length > 0) {
|
|
1468
|
+
return [...options, ...derived];
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1277
1471
|
// No values to choose from — don't offer a lone "All x" that filters nothing.
|
|
1278
1472
|
return [];
|
|
1279
1473
|
};
|
|
@@ -1549,7 +1743,12 @@ const shouldShowPerPageSelector = computed(() => {
|
|
|
1549
1743
|
return total >= smallestOption;
|
|
1550
1744
|
});
|
|
1551
1745
|
|
|
1552
|
-
// Detect which fields need server filter values
|
|
1746
|
+
// Detect which fields need server filter values.
|
|
1747
|
+
//
|
|
1748
|
+
// Client-side derived options (`derivedFilterOptions`) deliberately do NOT
|
|
1749
|
+
// shrink this list: it is only ever read by `internalProvider`, i.e. API mode,
|
|
1750
|
+
// and `isProviderMode` requires `!resolvedClientSide` — the two modes are
|
|
1751
|
+
// mutually exclusive, so a client-side derived column can never appear here.
|
|
1553
1752
|
const fieldsNeedingFilterValues = computed(() => {
|
|
1554
1753
|
return props.fields
|
|
1555
1754
|
.filter(field => field.filter === 'select' && (!field.filterOptions || field.filterOptions.length === 0))
|
|
@@ -1956,6 +2155,12 @@ const refresh = () => {
|
|
|
1956
2155
|
* - `head(<key>)` — DXTable draws its own column headers (sort indicators,
|
|
1957
2156
|
* field hints). Forwarding a consumer's would silently drop those.
|
|
1958
2157
|
* - `thead-top` — DXTable renders the inline filter row there.
|
|
2158
|
+
*
|
|
2159
|
+
* `head-end(<key>)` is DXTable's OWN additive header slot (#99) and is likewise
|
|
2160
|
+
* not forwarded: the inner table has no such slot, and the prefixes below are
|
|
2161
|
+
* matched with `startsWith`, so it must not begin with `cell(`/`foot(` either —
|
|
2162
|
+
* `head-end(` satisfies both. It is also excluded from `tableSlotSignature`
|
|
2163
|
+
* (which filters on `isTableSlot`), so adding one never remounts the table.
|
|
1959
2164
|
*/
|
|
1960
2165
|
const TABLE_SLOT_PREFIXES = ['cell(', 'foot('];
|
|
1961
2166
|
const TABLE_SLOT_NAMES = new Set([
|
|
@@ -208,8 +208,13 @@ interface Props {
|
|
|
208
208
|
pendingAction: 'save' | 'delete' | null;
|
|
209
209
|
/** Delete endpoint — gates the Delete button. */
|
|
210
210
|
deleteUrl?: string;
|
|
211
|
-
/**
|
|
212
|
-
|
|
211
|
+
/**
|
|
212
|
+
* Field layout forwarded to the modal's DXForm (defaults handled upstream).
|
|
213
|
+
* Includes `"auto"`, which lets the form stack to vertical when the MODAL is
|
|
214
|
+
* narrow even though the viewport is wide — the container-query case a
|
|
215
|
+
* viewport media query cannot see.
|
|
216
|
+
*/
|
|
217
|
+
layout?: 'vertical' | 'horizontal' | 'auto';
|
|
213
218
|
/** Label column width forwarded to DXForm (horizontal layout). */
|
|
214
219
|
labelCols?: LabelCols;
|
|
215
220
|
/** Wrap the modal form in a card (opt-in — off by default). */
|
|
@@ -135,7 +135,7 @@ const emit = defineEmits<{
|
|
|
135
135
|
// except a single-page gap which shows the page itself (an ellipsis hiding one
|
|
136
136
|
// page is silly). Client-computed from current + last page so it works in every
|
|
137
137
|
// DXTable mode (provider / client-side / inertia) — not just where a server
|
|
138
|
-
// `links` array exists. Reproduces
|
|
138
|
+
// `links` array exists. Reproduces the house style: `1 2 … 8 9 10 [11] 12 13 14 … 44 45`.
|
|
139
139
|
const BOUNDARY = 2;
|
|
140
140
|
const AROUND = 3;
|
|
141
141
|
|
|
@@ -192,9 +192,25 @@ const goToPage = (page: number) => {
|
|
|
192
192
|
</script>
|
|
193
193
|
|
|
194
194
|
<style scoped>
|
|
195
|
+
/*
|
|
196
|
+
In `DXTable`'s card mode the card is rendered `no-body` so the table can sit
|
|
197
|
+
flush against the card border, which makes this footer a direct child of
|
|
198
|
+
`.card` — it no longer inherits any `.card-body` padding. Re-instate the inset
|
|
199
|
+
the card body used to give it, so the pager lines up with the padded card
|
|
200
|
+
header above it and keeps a bottom margin inside the (clipped) card.
|
|
201
|
+
|
|
202
|
+
Top padding is deliberately 0: the `mt-3` on the root already separates the
|
|
203
|
+
footer from the table above. `.dx-table-card` is DXTableShell's element and
|
|
204
|
+
needs no scope id — a scoped selector only stamps its LAST compound selector,
|
|
205
|
+
which here is this component's own root.
|
|
206
|
+
*/
|
|
207
|
+
.dx-table-card > .dx-table-pagination {
|
|
208
|
+
padding: 0 var(--bs-card-spacer-x) var(--bs-card-spacer-y);
|
|
209
|
+
}
|
|
210
|
+
|
|
195
211
|
/* Windowed pager (#155). The buttons are DButtons; give the numbered ones a
|
|
196
|
-
consistent min-width so they read as an even row of keys (
|
|
197
|
-
|
|
212
|
+
consistent min-width so they read as an even row of keys (the house pager
|
|
213
|
+
style), and let the whole row wrap on narrow widths rather than
|
|
198
214
|
overflow. `:deep(.btn)` is anchored on the plain `.dx-pager` host, so the
|
|
199
215
|
scope-id has a deterministic element to attach to. */
|
|
200
216
|
.dx-pager :deep(.btn) {
|
|
@@ -9,9 +9,27 @@
|
|
|
9
9
|
- `#header` — the title / action row. In card mode it fills `DCard`'s header;
|
|
10
10
|
in plain mode it renders above the table with a subtle bottom rule.
|
|
11
11
|
- default — the table body (spinner/error/table/pagination).
|
|
12
|
+
|
|
13
|
+
Card mode renders `DCard` in **`no-body`** mode so the table sits FLUSH against
|
|
14
|
+
the card border. With a `.card-body` in between, a striped table's rows were
|
|
15
|
+
inset by `--bs-card-spacer-x` (24px) and left a white gutter between the
|
|
16
|
+
stripes and the border. Without it, DXTable's children — error alert, loading
|
|
17
|
+
block, table, pagination — become direct children of `.card`, which is already
|
|
18
|
+
a `flex-direction: column` container, so they still stack in order:
|
|
19
|
+
|
|
20
|
+
.card (overflow: hidden)
|
|
21
|
+
├─ .card-header (padded by Bootstrap's card-cap padding)
|
|
22
|
+
├─ .table-responsive ← FLUSH
|
|
23
|
+
│ └─ table
|
|
24
|
+
└─ .dx-table-pagination (pads itself — see DXTablePagination.vue)
|
|
25
|
+
|
|
26
|
+
Bootstrap 5 has no `.card > .table` rounding rules (that was BS4), so the
|
|
27
|
+
clipping is done with `overflow: hidden` on the card: the flush table's square
|
|
28
|
+
top corners then follow the card's radius whether or not a header is present.
|
|
29
|
+
bvn's popups position `fixed` by default, so they are not clipped by it.
|
|
12
30
|
-->
|
|
13
31
|
<template>
|
|
14
|
-
<DCard v-if="card">
|
|
32
|
+
<DCard v-if="card" no-body class="dx-table-card">
|
|
15
33
|
<template v-if="$slots.header" #header>
|
|
16
34
|
<slot name="header" />
|
|
17
35
|
</template>
|
|
@@ -52,3 +70,54 @@ withDefaults(defineProps<Props>(), {
|
|
|
52
70
|
border-bottom: 1px solid var(--bs-border-color);
|
|
53
71
|
}
|
|
54
72
|
</style>
|
|
73
|
+
|
|
74
|
+
<style>
|
|
75
|
+
/*
|
|
76
|
+
GLOBAL, not scoped, on purpose: these rules target slot content supplied by
|
|
77
|
+
`DXTable`, which is compiled in *DXTable's* scope. A scoped selector here would
|
|
78
|
+
get this component's `data-v-*` appended to its last compound selector and so
|
|
79
|
+
would match nothing — the same silent-inertness trap documented in CLAUDE.md.
|
|
80
|
+
Every selector is namespaced under `.dx-table-card`, a class only this
|
|
81
|
+
component applies, so nothing leaks into a consumer's own cards.
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/*
|
|
85
|
+
Clip the card to its radius so the flush table's square corners follow the
|
|
86
|
+
border. Kept in this global block with the rest of the card-mode layout: a
|
|
87
|
+
scoped rule WOULD reach DCard's root (the renderer walks the component-root
|
|
88
|
+
chain), but that forwarding is the exact thing CLAUDE.md records as unreliable
|
|
89
|
+
across build pipelines, and a silently-unclipped card is invisible in review.
|
|
90
|
+
The lower specificity also leaves consumers free to override it.
|
|
91
|
+
|
|
92
|
+
KNOWN LIMITATION (#166): this clips the WHOLE card, so a consumer's own
|
|
93
|
+
non-teleported `position: absolute` overlay in a slot is cut off at the card
|
|
94
|
+
edge. dfl's own components are fine — DDropdown teleports to <body>, the
|
|
95
|
+
filter menus are `fixed` — so the residual case is a hand-rolled popover.
|
|
96
|
+
The fix is to move the radius clipping onto the flush table region, which
|
|
97
|
+
`.table-responsive`'s `overflow-x: auto` complicates; see the issue.
|
|
98
|
+
*/
|
|
99
|
+
.dx-table-card {
|
|
100
|
+
overflow: hidden;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/*
|
|
104
|
+
The error alert is a full-width child of the card now that there is no
|
|
105
|
+
`.card-body`. Give it back the inset the card body used to provide, so it
|
|
106
|
+
doesn't bleed into the card's rounded corners.
|
|
107
|
+
*/
|
|
108
|
+
.dx-table-card > .alert {
|
|
109
|
+
margin: var(--bs-card-spacer-y) var(--bs-card-spacer-x);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/*
|
|
113
|
+
Nothing may leave a trailing margin at the bottom of the card. bvn already
|
|
114
|
+
zeroes `.table-responsive > .table`, but the responsive wrapper itself carries
|
|
115
|
+
`margin-bottom: 1rem` (and an un-responsive `.table` carries Bootstrap's own),
|
|
116
|
+
which used to be absorbed by the card body and now shows as a white band
|
|
117
|
+
inside the clipped card whenever the table is the last child (no pager). The
|
|
118
|
+
gap ABOVE the pager is unaffected — that margin only drops on the last child.
|
|
119
|
+
*/
|
|
120
|
+
.dx-table-card > :last-child {
|
|
121
|
+
margin-bottom: 0;
|
|
122
|
+
}
|
|
123
|
+
</style>
|