@omnitend/dashboard-for-laravel 0.35.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/charts.js +137 -97
- package/dist/charts.js.map +1 -1
- package/dist/charts.umd.cjs +1 -1
- package/dist/charts.umd.cjs.map +1 -1
- package/dist/components/base/DButton.vue.d.ts +7 -2
- package/dist/components/charts/chartTheme.d.ts +29 -3
- package/dist/components/charts/useThemedChart.d.ts +7 -2
- package/dist/components/extended/DXTable.vue.d.ts +28 -11
- package/dist/components/extended/DXTablePagination.vue.d.ts +22 -1
- package/dist/dashboard-for-laravel.js +6704 -6645
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +6 -6
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/style.css +2 -2
- package/docs/public/api-reference.json +20 -7
- package/docs/public/docs-map.md +1 -1
- package/package.json +1 -1
- package/resources/css/theme.scss +108 -4
- package/resources/js/components/base/DButton.vue +8 -3
- package/resources/js/components/charts/DXBarChart.vue +7 -2
- package/resources/js/components/charts/DXDoughnutChart.vue +33 -10
- package/resources/js/components/charts/DXLineChart.vue +7 -2
- package/resources/js/components/charts/chartTheme.ts +106 -11
- package/resources/js/components/charts/useThemedChart.ts +39 -21
- package/resources/js/components/extended/DXTable.vue +87 -15
- package/resources/js/components/extended/DXTablePagination.vue +146 -5
- package/resources/js/components/extended/DXTableShell.vue +49 -14
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { computed, type ComputedRef } from "vue";
|
|
1
|
+
import { computed, type ComputedRef, type Ref } from "vue";
|
|
2
2
|
import type { ChartData, ChartDataset, ChartOptions } from "chart.js";
|
|
3
3
|
import {
|
|
4
4
|
baseOptions,
|
|
5
5
|
applyPalette,
|
|
6
6
|
mergeOptions,
|
|
7
|
+
useColorModeVersion,
|
|
7
8
|
type ChartValueFormat,
|
|
8
9
|
} from "./chartTheme";
|
|
9
10
|
|
|
@@ -35,35 +36,52 @@ export interface ThemedChartProps<TType extends "bar" | "line"> {
|
|
|
35
36
|
* data-viz palette to un-coloured datasets and deep-merges the consumer's
|
|
36
37
|
* `options` over the dashboard-friendly defaults. Pass the reactive `props`
|
|
37
38
|
* object (property access stays reactive inside the computeds).
|
|
39
|
+
*
|
|
40
|
+
* `host` is the chart's own container element (a template ref). It is BOTH the
|
|
41
|
+
* `data-bs-theme` scope the palette resolves against and — being a ref read
|
|
42
|
+
* inside the computeds — the trigger that re-themes once it is populated on
|
|
43
|
+
* mount, since the first render happens before a template ref exists (#161).
|
|
38
44
|
*/
|
|
39
45
|
export function useThemedChart<TType extends "bar" | "line">(
|
|
40
46
|
type: TType,
|
|
41
47
|
props: ThemedChartProps<TType>,
|
|
48
|
+
host: Ref<HTMLElement | null>,
|
|
42
49
|
): {
|
|
43
50
|
chartData: ComputedRef<ChartData<TType>>;
|
|
44
51
|
mergedOptions: ComputedRef<ChartOptions<TType>>;
|
|
45
52
|
} {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
datasets: applyPalette(props.datasets, type, props.labels.length),
|
|
51
|
-
}) as ChartData<TType>,
|
|
52
|
-
);
|
|
53
|
+
// Bumped by a MutationObserver on every `data-bs-theme` change; read below
|
|
54
|
+
// so the CSS-variable palette — invisible to Vue's reactivity — is
|
|
55
|
+
// re-resolved when the colour mode flips under a mounted chart.
|
|
56
|
+
const colorModeVersion = useColorModeVersion();
|
|
53
57
|
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
58
|
+
const chartData = computed(() => {
|
|
59
|
+
void colorModeVersion.value;
|
|
60
|
+
return {
|
|
61
|
+
labels: props.labels,
|
|
62
|
+
datasets: applyPalette(
|
|
63
|
+
props.datasets,
|
|
64
|
+
type,
|
|
65
|
+
props.labels.length,
|
|
66
|
+
host.value,
|
|
67
|
+
),
|
|
68
|
+
} as ChartData<TType>;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const mergedOptions = computed(() => {
|
|
72
|
+
void colorModeVersion.value;
|
|
73
|
+
return mergeOptions(
|
|
74
|
+
baseOptions({
|
|
75
|
+
valueFormat: props.valueFormat,
|
|
76
|
+
currencySymbol: props.currencySymbol,
|
|
77
|
+
locale: props.locale,
|
|
78
|
+
showLegend: props.showLegend ?? props.datasets.length > 1,
|
|
79
|
+
hasValueAxis: true,
|
|
80
|
+
scope: host.value,
|
|
81
|
+
}),
|
|
82
|
+
props.options ?? {},
|
|
83
|
+
) as ChartOptions<TType>;
|
|
84
|
+
});
|
|
67
85
|
|
|
68
86
|
return { chartData, mergedOptions };
|
|
69
87
|
}
|
|
@@ -131,6 +131,23 @@
|
|
|
131
131
|
@update:model-value="handleSelectFilterChange(field, $event)"
|
|
132
132
|
/>
|
|
133
133
|
|
|
134
|
+
<!-- Native Select Filter (S3b): a plain <select> for consumers who
|
|
135
|
+
want OS-native behaviour (full-height menu, native keyboard/scroll)
|
|
136
|
+
instead of the DAutocomplete typeahead. Shares the select filter's
|
|
137
|
+
option list (getFieldFilterOptions) and change handler
|
|
138
|
+
(handleSelectFilterChange) EXACTLY — so it behaves identically to
|
|
139
|
+
filter:"select" in every way but the control. Single-select only:
|
|
140
|
+
`filterMultiple` is ignored here. The model maps "no filter" to the
|
|
141
|
+
FILTER_ALL_VALUE sentinel (see nativeSelectFilterValue) so the "All …"
|
|
142
|
+
option shows selected in a native control. -->
|
|
143
|
+
<DFormSelect
|
|
144
|
+
v-else-if="field.filter === 'select-native'"
|
|
145
|
+
:model-value="nativeSelectFilterValue(field)"
|
|
146
|
+
:options="getFieldFilterOptions(field) as unknown as Record<string, unknown>[]"
|
|
147
|
+
size="sm"
|
|
148
|
+
@update:model-value="handleSelectFilterChange(field, $event)"
|
|
149
|
+
/>
|
|
150
|
+
|
|
134
151
|
<!-- Number Filter -->
|
|
135
152
|
<DFormInput
|
|
136
153
|
v-else-if="field.filter === 'number'"
|
|
@@ -310,11 +327,17 @@ import DXTableShell from "./DXTableShell.vue";
|
|
|
310
327
|
import DSpinner from "../base/DSpinner.vue";
|
|
311
328
|
import DTable from "../base/DTable.vue";
|
|
312
329
|
import DFormInput from "../base/DFormInput.vue";
|
|
330
|
+
import DFormSelect from "../base/DFormSelect.vue";
|
|
313
331
|
import DAutocomplete from "../base/DAutocomplete.vue";
|
|
314
332
|
import DButton from "../base/DButton.vue";
|
|
315
333
|
import DXTablePagination from "./DXTablePagination.vue";
|
|
316
334
|
import DXTableEditorModal from "./DXTableEditorModal.vue";
|
|
317
|
-
|
|
335
|
+
// 'select' — DAutocomplete typeahead (browse-on-focus + type-to-narrow).
|
|
336
|
+
// 'select-native' — a plain native <select> (DFormSelect). Same options, value
|
|
337
|
+
// semantics and client-side matching as 'select'; differs ONLY in the control,
|
|
338
|
+
// for consumers who want OS-native menu behaviour (full-height menu, native
|
|
339
|
+
// keyboard/scroll). Single-select only — `filterMultiple` is ignored for it.
|
|
340
|
+
export type FilterType = 'text' | 'select' | 'select-native' | 'number' | 'date' | false;
|
|
318
341
|
|
|
319
342
|
export interface FilterOption {
|
|
320
343
|
value: string;
|
|
@@ -384,6 +407,12 @@ export interface TableField {
|
|
|
384
407
|
filterAllText?: string;
|
|
385
408
|
sortable?: boolean;
|
|
386
409
|
hint?: string;
|
|
410
|
+
/**
|
|
411
|
+
* The inline filter control for this column, or `false`/omit for none. See
|
|
412
|
+
* `FilterType`. `'select'` (typeahead) and `'select-native'` (plain
|
|
413
|
+
* `<select>`) share the same options and value semantics — pick the native
|
|
414
|
+
* one when OS-native menu behaviour matters more than type-to-narrow.
|
|
415
|
+
*/
|
|
387
416
|
filter?: FilterType;
|
|
388
417
|
filterOptions?: FilterOption[];
|
|
389
418
|
filterPlaceholder?: string;
|
|
@@ -606,17 +635,16 @@ export interface Props<TItem = any> {
|
|
|
606
635
|
* data-heavy admin index pages that read as busy inside a card.
|
|
607
636
|
*
|
|
608
637
|
* The table renders FLUSH to the card border (rows and stripes reach the
|
|
609
|
-
* edge) while the header and pagination stay padded
|
|
610
|
-
*
|
|
638
|
+
* edge) while the header and pagination stay padded. Only the table region
|
|
639
|
+
* is rounded to follow the card's corners — the card itself does not clip
|
|
640
|
+
* (#166), so a `position: absolute` popover you render into `#header` or
|
|
641
|
+
* any other slot is free to overhang the card.
|
|
611
642
|
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
* `fixed`
|
|
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.
|
|
643
|
+
* The one place content is still clipped is INSIDE the table: Bootstrap's
|
|
644
|
+
* `.table-responsive` is a horizontal scroll container, so an overlay in a
|
|
645
|
+
* `cell(<key>)` slot is bounded by it. Teleport it (bvn's own menus already
|
|
646
|
+
* do — `DDropdown` teleports to `<body>`, the column-filter menus are
|
|
647
|
+
* `fixed`), or pass `:responsive="false"`.
|
|
620
648
|
*/
|
|
621
649
|
card?: boolean;
|
|
622
650
|
|
|
@@ -770,6 +798,19 @@ export interface Props<TItem = any> {
|
|
|
770
798
|
* declared width share the remaining space equally.
|
|
771
799
|
*/
|
|
772
800
|
fixedLayout?: boolean;
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Field on each row whose value uniquely identifies it, forwarded to the
|
|
804
|
+
* inner bvn table's `primaryKey` (all three data modes). Without it bvn keys
|
|
805
|
+
* client-side rows by their INDEX, which mis-associates per-row STATEFUL cell
|
|
806
|
+
* components when a row is inserted/removed mid-interaction — a debounced
|
|
807
|
+
* edit in one row can end up saved against the wrong record after a
|
|
808
|
+
* concurrent delete of a row above. Set it (e.g. `primary-key="id"`) so bvn
|
|
809
|
+
* keys rows by that field's value and each row keeps its identity across
|
|
810
|
+
* data mutations. Non-breaking: omit it and rows key by index exactly as
|
|
811
|
+
* before.
|
|
812
|
+
*/
|
|
813
|
+
primaryKey?: string;
|
|
773
814
|
}
|
|
774
815
|
|
|
775
816
|
const props = withDefaults(defineProps<Props<T>>(), {
|
|
@@ -1103,7 +1144,9 @@ const clientSideFilteredItems = computed<T[]>(() => {
|
|
|
1103
1144
|
// Case-insensitive contains search
|
|
1104
1145
|
return String(itemValue).toLowerCase().includes(filterValue);
|
|
1105
1146
|
|
|
1147
|
+
// Both select controls match identically (exact string match).
|
|
1106
1148
|
case 'select':
|
|
1149
|
+
case 'select-native':
|
|
1107
1150
|
// Exact match
|
|
1108
1151
|
return String(itemValue) === candidate;
|
|
1109
1152
|
|
|
@@ -1273,6 +1316,15 @@ const fieldsHaveWidths = computed(() => props.fields.some(hasDeclaredWidth));
|
|
|
1273
1316
|
// key: a display column can filter on a different server param via `filterKey`.
|
|
1274
1317
|
const filterKeyFor = (field: TableField): string => field.filterKey ?? field.key;
|
|
1275
1318
|
|
|
1319
|
+
// Both select-style filters — 'select' (DAutocomplete typeahead) and
|
|
1320
|
+
// 'select-native' (plain <select>) — share the SAME option list, value
|
|
1321
|
+
// semantics and client-side matching; they differ only in the rendered control.
|
|
1322
|
+
// This predicate keeps the two in lockstep everywhere the option-derivation and
|
|
1323
|
+
// server-value logic runs, so a `select-native` column gets identical options to
|
|
1324
|
+
// what `select` would offer.
|
|
1325
|
+
const isSelectFilter = (field: TableField): boolean =>
|
|
1326
|
+
field.filter === 'select' || field.filter === 'select-native';
|
|
1327
|
+
|
|
1276
1328
|
// The value that means "has no value" for a select filter (#106).
|
|
1277
1329
|
const filterNullValueFor = (field: TableField): string => field.filterNullValue ?? 'null';
|
|
1278
1330
|
|
|
@@ -1287,6 +1339,17 @@ const handleSelectFilterChange = (field: TableField, value: unknown) => {
|
|
|
1287
1339
|
handleFilterChange(filterKeyFor(field), selected === FILTER_ALL_VALUE ? '' : selected);
|
|
1288
1340
|
};
|
|
1289
1341
|
|
|
1342
|
+
// The model value for a `select-native` control. Unlike DAutocomplete (which
|
|
1343
|
+
// falls back to '' and shows its placeholder), a native <select> selects its
|
|
1344
|
+
// FIRST option when the bound value matches none — which would desync the
|
|
1345
|
+
// control from the model. So the "no filter" state must map to FILTER_ALL_VALUE,
|
|
1346
|
+
// the value carried by the "All …" option, so that option shows as selected.
|
|
1347
|
+
// This is the read-side mirror of the write-side translation
|
|
1348
|
+
// `handleSelectFilterChange` already does (FILTER_ALL_VALUE → '') — the sentinel
|
|
1349
|
+
// is handled in one conceptual place, not reinvented.
|
|
1350
|
+
const nativeSelectFilterValue = (field: TableField): string =>
|
|
1351
|
+
(effectiveFilters.value[filterKeyFor(field)] as string) || FILTER_ALL_VALUE;
|
|
1352
|
+
|
|
1290
1353
|
// Multi-value select filter (#51): the autocomplete's array model maps
|
|
1291
1354
|
// straight onto the filters entry; an emptied selection removes the entry.
|
|
1292
1355
|
const multiFilterValueFor = (field: TableField): string[] => {
|
|
@@ -1362,7 +1425,7 @@ const derivedFilterOptions = computed<Record<string, FilterOption[]>>(() => {
|
|
|
1362
1425
|
const optionsByFieldKey: Record<string, FilterOption[]> = {};
|
|
1363
1426
|
|
|
1364
1427
|
for (const field of props.fields) {
|
|
1365
|
-
if (field
|
|
1428
|
+
if (!isSelectFilter(field)) continue;
|
|
1366
1429
|
if (field.deriveFilterOptions === false) continue;
|
|
1367
1430
|
// Explicit options win outright (see `getFieldFilterOptions`), so don't
|
|
1368
1431
|
// spend a pass over every row producing a list nothing will read.
|
|
@@ -1435,8 +1498,10 @@ const getFieldFilterOptions = (field: TableField): FilterOption[] => {
|
|
|
1435
1498
|
//
|
|
1436
1499
|
// A `filterMultiple` column omits it (#51): "no filter" inside a
|
|
1437
1500
|
// multi-select reads as a selectable value, and clearing is already
|
|
1438
|
-
// discoverable there — remove the chips, or the ✕.
|
|
1439
|
-
|
|
1501
|
+
// discoverable there — remove the chips, or the ✕. `select-native` is the
|
|
1502
|
+
// exception: it is ALWAYS single-select (it ignores `filterMultiple`), so it
|
|
1503
|
+
// still needs the "All" reset option even when `filterMultiple` is set.
|
|
1504
|
+
if (!field.filterMultiple || field.filter === 'select-native') {
|
|
1440
1505
|
options.push({ value: FILTER_ALL_VALUE, text: filterAllLabelFor(field) });
|
|
1441
1506
|
}
|
|
1442
1507
|
|
|
@@ -1751,7 +1816,7 @@ const shouldShowPerPageSelector = computed(() => {
|
|
|
1751
1816
|
// mutually exclusive, so a client-side derived column can never appear here.
|
|
1752
1817
|
const fieldsNeedingFilterValues = computed(() => {
|
|
1753
1818
|
return props.fields
|
|
1754
|
-
.filter(field => field
|
|
1819
|
+
.filter(field => isSelectFilter(field) && (!field.filterOptions || field.filterOptions.length === 0))
|
|
1755
1820
|
.map(field => filterKeyFor(field));
|
|
1756
1821
|
});
|
|
1757
1822
|
|
|
@@ -2244,6 +2309,13 @@ const tablePassthroughProps = computed(() => ({
|
|
|
2244
2309
|
? `No ${pluralItemName.value} match your filters`
|
|
2245
2310
|
: `No ${pluralItemName.value} found`),
|
|
2246
2311
|
expandedItems: props.expandedItems,
|
|
2312
|
+
// Row identity (#B16). Forwarded here rather than in `tableModeBindings`
|
|
2313
|
+
// because it is mode-INDEPENDENT — it must key rows by value in provider,
|
|
2314
|
+
// client-side AND inertia mode alike. `undefined` when unset, so bvn falls
|
|
2315
|
+
// back to index keys (its default) and nothing changes for tables that don't
|
|
2316
|
+
// opt in. Reaches BTable through DTable's `v-bind="$attrs"` (its prop is
|
|
2317
|
+
// `primaryKey`); DTable needs no change.
|
|
2318
|
+
primaryKey: props.primaryKey,
|
|
2247
2319
|
}));
|
|
2248
2320
|
// Computed: Singular and plural item names
|
|
2249
2321
|
const singularItemName = computed(() => props.itemName);
|
|
@@ -15,17 +15,32 @@
|
|
|
15
15
|
once it lives in a child component — the scope-id doesn't cross the boundary.
|
|
16
16
|
Anchoring the rules on this component's own `.dx-table-pagination` root (a
|
|
17
17
|
plain element it owns) gives the scope-id a deterministic host.
|
|
18
|
+
|
|
19
|
+
In a row too narrow for the full windowed pager, the number strip collapses to
|
|
20
|
+
a "current / last" status with Previous/Next either side (#162). The decision
|
|
21
|
+
is CONTAINER-driven, not viewport-driven — see `compactThreshold`.
|
|
18
22
|
-->
|
|
19
23
|
<template>
|
|
20
24
|
<div class="dx-table-pagination mt-3">
|
|
21
|
-
<!-- Top row: Pagination and Per-page selector
|
|
22
|
-
|
|
25
|
+
<!-- Top row: Pagination and Per-page selector.
|
|
26
|
+
`containerRef` measures THIS row (the space the pager actually
|
|
27
|
+
competes for with the per-page selector) rather than the window —
|
|
28
|
+
a table narrowed by a sidebar or a modal is cramped even on a wide
|
|
29
|
+
viewport, which no media query can see. See `useContainerWidth`. -->
|
|
30
|
+
<div
|
|
31
|
+
ref="containerRef"
|
|
32
|
+
class="dx-pager-row d-flex justify-content-between align-items-center gap-2 mb-2"
|
|
33
|
+
>
|
|
23
34
|
<!-- Windowed pager (#155): «Previous / leading / … / window / … /
|
|
24
35
|
trailing / Next». Client-computed from current + last page, so it
|
|
25
|
-
works in every DXTable mode. Only shown when >1 page.
|
|
36
|
+
works in every DXTable mode. Only shown when >1 page.
|
|
37
|
+
In a row too narrow to lay the whole thing out, the number
|
|
38
|
+
window collapses to a "current / last" status (#162) — see
|
|
39
|
+
`isCompact`. -->
|
|
26
40
|
<nav
|
|
27
41
|
v-if="showPagination && pagination.total > pagination.per_page"
|
|
28
42
|
class="dx-pager d-flex flex-wrap align-items-center gap-1"
|
|
43
|
+
:class="{ 'dx-pager--compact': isCompact }"
|
|
29
44
|
aria-label="Pagination"
|
|
30
45
|
>
|
|
31
46
|
<DButton
|
|
@@ -35,7 +50,28 @@
|
|
|
35
50
|
@click="goToPage(currentPage - 1)"
|
|
36
51
|
>« Previous</DButton>
|
|
37
52
|
|
|
38
|
-
|
|
53
|
+
<!-- Compact: one status in place of the whole number window.
|
|
54
|
+
With no page buttons there is no `aria-current` to carry
|
|
55
|
+
"you are here", so the position becomes a live `status`
|
|
56
|
+
region — it is announced when paging changes it, which the
|
|
57
|
+
numbered pager achieved through focus moving to the newly
|
|
58
|
+
active button. The terse "11 / 45" is for the eye only
|
|
59
|
+
(a screen reader would say "eleven slash forty-five"); the
|
|
60
|
+
announced text is the visually-hidden long form. -->
|
|
61
|
+
<span
|
|
62
|
+
v-if="isCompact"
|
|
63
|
+
class="dx-pager-status small"
|
|
64
|
+
role="status"
|
|
65
|
+
aria-live="polite"
|
|
66
|
+
>
|
|
67
|
+
<span class="visually-hidden">Page {{ currentPage }} of {{ lastPage }}</span>
|
|
68
|
+
<span aria-hidden="true">{{ currentPage }} / {{ lastPage }}</span>
|
|
69
|
+
</span>
|
|
70
|
+
|
|
71
|
+
<template
|
|
72
|
+
v-for="(item, index) in visiblePageItems"
|
|
73
|
+
:key="item.type === 'page' ? `p${item.page}` : `e${index}`"
|
|
74
|
+
>
|
|
39
75
|
<DButton
|
|
40
76
|
v-if="item.type === 'page'"
|
|
41
77
|
size="sm"
|
|
@@ -94,6 +130,7 @@
|
|
|
94
130
|
import { computed } from "vue";
|
|
95
131
|
import DButton from "../base/DButton.vue";
|
|
96
132
|
import DFormSelect from "../base/DFormSelect.vue";
|
|
133
|
+
import { useContainerWidth } from "../../composables/useContainerWidth";
|
|
97
134
|
import type { PaginationData } from "./DXTable.vue";
|
|
98
135
|
|
|
99
136
|
interface Props {
|
|
@@ -115,9 +152,31 @@ interface Props {
|
|
|
115
152
|
pluralItemName: string;
|
|
116
153
|
/** Whether a column filter is active — gates the "Filtered from N" note. */
|
|
117
154
|
hasActiveFilters: boolean;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Upper bound (px) on the pager ROW width at which the number window may
|
|
158
|
+
* collapse to the compact "current / last" status (#162). At or above it
|
|
159
|
+
* the full windowed pager always renders, whatever the page count.
|
|
160
|
+
*
|
|
161
|
+
* Default **576** — Bootstrap's `sm` breakpoint, i.e. the width the rest of
|
|
162
|
+
* the design system already treats as phone-sized. Its job is to GUARANTEE
|
|
163
|
+
* that a desktop-width table is never quietly downgraded: a 45-page window
|
|
164
|
+
* that doesn't quite fit at 800px should wrap (today's behaviour), not lose
|
|
165
|
+
* its numbers.
|
|
166
|
+
*
|
|
167
|
+
* Below it, compact engages only when the full pager genuinely would not
|
|
168
|
+
* fit on one line (see `fullPagerWidth`) — so a three-page pager still
|
|
169
|
+
* shows `1 2 3` on a phone, where "2 / 3" would be strictly worse.
|
|
170
|
+
*
|
|
171
|
+
* Measured on the ROW, not the viewport, so a table squeezed by a sidebar
|
|
172
|
+
* or inside a modal collapses even though the window is wide.
|
|
173
|
+
*/
|
|
174
|
+
compactThreshold?: number;
|
|
118
175
|
}
|
|
119
176
|
|
|
120
|
-
const props = defineProps<Props>()
|
|
177
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
178
|
+
compactThreshold: 576,
|
|
179
|
+
});
|
|
121
180
|
|
|
122
181
|
const emit = defineEmits<{
|
|
123
182
|
/** The user picked a page. Payload is the new 1-based page number. */
|
|
@@ -182,6 +241,71 @@ const pageItems = computed<PageItem[]>(() => {
|
|
|
182
241
|
return items;
|
|
183
242
|
});
|
|
184
243
|
|
|
244
|
+
// Compact pager (#162). Container-driven, not viewport-driven — see the
|
|
245
|
+
// `compactThreshold` prop.
|
|
246
|
+
//
|
|
247
|
+
// Approximate intrinsic widths of the pager's parts, in rem, mirroring this
|
|
248
|
+
// component's own CSS and button labels: a page key is `min-width: 2.5rem`, the
|
|
249
|
+
// row is `gap-1` (0.25rem), the ellipsis is a glyph plus 0.15rem side padding,
|
|
250
|
+
// and "« Previous" + "Next »" together come to about 10rem at `.btn-sm`.
|
|
251
|
+
// Deliberately an ESTIMATE, not a measurement: being wrong by a few px only
|
|
252
|
+
// shifts the collapse by one page key, which is cosmetic — whereas measuring
|
|
253
|
+
// the rendered strip would mean measuring the very thing this decision
|
|
254
|
+
// removes, i.e. a feedback loop.
|
|
255
|
+
const PAGE_KEY_REM = 2.5;
|
|
256
|
+
const ITEM_GAP_REM = 0.25;
|
|
257
|
+
const ELLIPSIS_REM = 1;
|
|
258
|
+
const PREV_NEXT_REM = 10;
|
|
259
|
+
|
|
260
|
+
/** px per rem, so a consumer scaling the root font size is tracked. */
|
|
261
|
+
function rootFontSizePx(): number {
|
|
262
|
+
// Only ever reached in a browser (see `isCompact`), but stay SSR-safe.
|
|
263
|
+
if (typeof document === "undefined") return 16;
|
|
264
|
+
const size = Number.parseFloat(
|
|
265
|
+
getComputedStyle(document.documentElement).fontSize,
|
|
266
|
+
);
|
|
267
|
+
return Number.isFinite(size) && size > 0 ? size : 16;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Width the row needs to lay the full windowed pager out on ONE line. */
|
|
271
|
+
const fullPagerWidth = computed(() => {
|
|
272
|
+
const remPx = rootFontSizePx();
|
|
273
|
+
let widthRem = PREV_NEXT_REM;
|
|
274
|
+
for (const item of pageItems.value) {
|
|
275
|
+
widthRem +=
|
|
276
|
+
(item.type === "page" ? PAGE_KEY_REM : ELLIPSIS_REM) + ITEM_GAP_REM;
|
|
277
|
+
}
|
|
278
|
+
return widthRem * remPx;
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const { containerRef, hasMeasured, isBelow } = useContainerWidth({
|
|
282
|
+
// Compact means "narrower than the breakpoint AND too narrow for the full
|
|
283
|
+
// pager", which is exactly `width < min(threshold, needed)` — expressing it
|
|
284
|
+
// as one threshold rather than two conditions keeps the hysteresis below
|
|
285
|
+
// applied to the WHOLE decision instead of just half of it. A getter, so
|
|
286
|
+
// both a reactive `compactThreshold` and a changing page count re-evaluate
|
|
287
|
+
// instead of latching the mount-time value.
|
|
288
|
+
threshold: () => Math.min(props.compactThreshold, fullPagerWidth.value),
|
|
289
|
+
// The compact row is SHORTER than the wrapped full pager, so an ancestor with
|
|
290
|
+
// `overflow:auto` can lose its vertical scrollbar when we collapse, widening
|
|
291
|
+
// this row by ~15-17px, back over the threshold, and it flips forever. A band
|
|
292
|
+
// wider than any scrollbar makes the crossing one-way.
|
|
293
|
+
hysteresis: 24,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Gated on `hasMeasured` so an UNMEASURED render (the first frame, and every
|
|
298
|
+
* SSR render, where there is no `ResizeObserver`) is byte-identical to the
|
|
299
|
+
* pre-#162 full pager. `initialWidth` defaults to 0, i.e. "assume narrowest",
|
|
300
|
+
* which would otherwise make every desktop render start compact and flip.
|
|
301
|
+
*/
|
|
302
|
+
const isCompact = computed(() => hasMeasured.value && isBelow.value);
|
|
303
|
+
|
|
304
|
+
/** Empty in compact mode — the status replaces the whole number window. */
|
|
305
|
+
const visiblePageItems = computed<PageItem[]>(() =>
|
|
306
|
+
isCompact.value ? [] : pageItems.value,
|
|
307
|
+
);
|
|
308
|
+
|
|
185
309
|
const canPrev = computed(() => currentPage.value > 1);
|
|
186
310
|
const canNext = computed(() => currentPage.value < lastPage.value);
|
|
187
311
|
|
|
@@ -223,6 +347,23 @@ const goToPage = (page: number) => {
|
|
|
223
347
|
min-width: auto;
|
|
224
348
|
}
|
|
225
349
|
|
|
350
|
+
/* Compact pager status (#162): "11 / 45" standing in for the number window.
|
|
351
|
+
A plain element this component owns, so it needs no `:deep()`. It is not a
|
|
352
|
+
button — deliberately, since there is nothing to click — so it is styled to
|
|
353
|
+
read as a label rather than a disabled key. */
|
|
354
|
+
.dx-pager--compact .dx-pager-status {
|
|
355
|
+
padding: 0 0.35rem;
|
|
356
|
+
color: var(--bs-secondary-color);
|
|
357
|
+
white-space: nowrap;
|
|
358
|
+
user-select: none;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/* Nothing can wrap in compact mode — the row is three items wide by design, and
|
|
362
|
+
a wrap here would defeat the whole point of collapsing it. */
|
|
363
|
+
.dx-pager--compact {
|
|
364
|
+
flex-wrap: nowrap;
|
|
365
|
+
}
|
|
366
|
+
|
|
226
367
|
.dx-pager-ellipsis {
|
|
227
368
|
display: inline-flex;
|
|
228
369
|
align-items: flex-end;
|
|
@@ -17,16 +17,16 @@
|
|
|
17
17
|
block, table, pagination — become direct children of `.card`, which is already
|
|
18
18
|
a `flex-direction: column` container, so they still stack in order:
|
|
19
19
|
|
|
20
|
-
.card
|
|
20
|
+
.card
|
|
21
21
|
├─ .card-header (padded by Bootstrap's card-cap padding)
|
|
22
|
-
├─ .table-responsive ← FLUSH
|
|
22
|
+
├─ .table-responsive ← FLUSH, rounded when it is at a card corner
|
|
23
23
|
│ └─ table
|
|
24
24
|
└─ .dx-table-pagination (pads itself — see DXTablePagination.vue)
|
|
25
25
|
|
|
26
26
|
Bootstrap 5 has no `.card > .table` rounding rules (that was BS4), so the
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
flush table's square corners have to be rounded here. The rounding is applied
|
|
28
|
+
to the TABLE REGION, never to the card: clipping the whole card would cut off
|
|
29
|
+
any non-teleported positioned content a consumer puts in a slot (#166).
|
|
30
30
|
-->
|
|
31
31
|
<template>
|
|
32
32
|
<DCard v-if="card" no-body class="dx-table-card">
|
|
@@ -82,21 +82,56 @@ withDefaults(defineProps<Props>(), {
|
|
|
82
82
|
*/
|
|
83
83
|
|
|
84
84
|
/*
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
Round the flush table region so its square corners follow the card border.
|
|
86
|
+
|
|
87
|
+
This used to be `overflow: hidden` on the WHOLE card, which clipped every
|
|
88
|
+
non-teleported positioned descendant — including a consumer's own
|
|
89
|
+
`position: absolute` popover in a `#header` or `cell(<key>)` slot (#166).
|
|
90
|
+
The clip now sits on the table region itself, and only on the corners the
|
|
91
|
+
region actually occupies: `.card-header` and `.dx-table-pagination` round the
|
|
92
|
+
corners themselves when they are present, so the radius is applied via
|
|
93
|
+
`:first-child` / `:last-child` rather than unconditionally. Vue renders a
|
|
94
|
+
false `v-if` as a comment node, which `:first-child` ignores, so the error
|
|
95
|
+
alert and spinner branches don't disturb the match.
|
|
96
|
+
|
|
97
|
+
These rules stay in this global block with the rest of the card-mode layout: a
|
|
87
98
|
scoped rule WOULD reach DCard's root (the renderer walks the component-root
|
|
88
99
|
chain), but that forwarding is the exact thing CLAUDE.md records as unreliable
|
|
89
100
|
across build pipelines, and a silently-unclipped card is invisible in review.
|
|
90
101
|
The lower specificity also leaves consumers free to override it.
|
|
102
|
+
*/
|
|
103
|
+
.dx-table-card > .table-responsive:first-child,
|
|
104
|
+
.dx-table-card > .table:first-child {
|
|
105
|
+
border-top-left-radius: var(--bs-card-inner-border-radius);
|
|
106
|
+
border-top-right-radius: var(--bs-card-inner-border-radius);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.dx-table-card > .table-responsive:last-child,
|
|
110
|
+
.dx-table-card > .table:last-child {
|
|
111
|
+
border-bottom-left-radius: var(--bs-card-inner-border-radius);
|
|
112
|
+
border-bottom-right-radius: var(--bs-card-inner-border-radius);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/*
|
|
116
|
+
`.table-responsive` needs NO `overflow` of its own: Bootstrap already gives it
|
|
117
|
+
`overflow-x: auto`, which computes `overflow-y` to `auto` as well, so it is
|
|
118
|
+
already a clipping context and the radius above rounds that clip. Measured in
|
|
119
|
+
a real browser (see DXTable-CardFlush.test.ts): without the radius a hit-test
|
|
120
|
+
3px inside the card's corner lands on a `<th>`; with it, on the card.
|
|
121
|
+
Do NOT add `overflow: hidden` here — it would kill the horizontal scrolling
|
|
122
|
+
that makes a wide table usable, and the flush look would survive to hide it.
|
|
91
123
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
124
|
+
A bare `<table>` (`:responsive="false"`) is a different story: it is not a
|
|
125
|
+
clipping context, and `border-radius` on it leaves the collapsed-border cell
|
|
126
|
+
backgrounds square (measured — the corner still hit-tests to a `<th>`). That
|
|
127
|
+
one needs an explicit clip, scoped to the flush table so it is the table
|
|
128
|
+
region and not the card that clips. Note this restores the pre-0.35 behaviour
|
|
129
|
+
where a `:responsive="false"` table too wide for its card overflows visibly
|
|
130
|
+
rather than being silently cut off — that mode opts out of a scroll container
|
|
131
|
+
by definition.
|
|
98
132
|
*/
|
|
99
|
-
.dx-table-card
|
|
133
|
+
.dx-table-card > .table:first-child,
|
|
134
|
+
.dx-table-card > .table:last-child {
|
|
100
135
|
overflow: hidden;
|
|
101
136
|
}
|
|
102
137
|
|