@omnitend/dashboard-for-laravel 0.34.0 → 0.36.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/charts/chartTheme.d.ts +29 -3
- package/dist/components/charts/useThemedChart.d.ts +7 -2
- package/dist/components/extended/DXForm.vue.d.ts +28 -6
- package/dist/components/extended/DXTable.vue.d.ts +38 -1
- package/dist/components/extended/DXTableEditorModal.vue.d.ts +7 -2
- package/dist/components/extended/DXTablePagination.vue.d.ts +22 -1
- package/dist/composables/useContainerWidth.d.ts +96 -0
- package/dist/dashboard-for-laravel.js +8288 -8105
- 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 +48 -8
- package/docs/public/docs-map.md +1 -1
- package/package.json +1 -1
- package/resources/css/theme.scss +96 -4
- 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/DXDashboardSidebar.vue +1 -1
- package/resources/js/components/extended/DXForm.vue +83 -9
- package/resources/js/components/extended/DXTable.vue +216 -12
- package/resources/js/components/extended/DXTableEditorModal.vue +7 -2
- package/resources/js/components/extended/DXTablePagination.vue +165 -8
- package/resources/js/components/extended/DXTableShell.vue +105 -1
- package/resources/js/composables/useContainerWidth.ts +303 -0
- package/resources/js/index.ts +7 -0
|
@@ -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. */
|
|
@@ -135,7 +194,7 @@ const emit = defineEmits<{
|
|
|
135
194
|
// except a single-page gap which shows the page itself (an ellipsis hiding one
|
|
136
195
|
// page is silly). Client-computed from current + last page so it works in every
|
|
137
196
|
// DXTable mode (provider / client-side / inertia) — not just where a server
|
|
138
|
-
// `links` array exists. Reproduces
|
|
197
|
+
// `links` array exists. Reproduces the house style: `1 2 … 8 9 10 [11] 12 13 14 … 44 45`.
|
|
139
198
|
const BOUNDARY = 2;
|
|
140
199
|
const AROUND = 3;
|
|
141
200
|
|
|
@@ -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
|
|
|
@@ -192,9 +316,25 @@ const goToPage = (page: number) => {
|
|
|
192
316
|
</script>
|
|
193
317
|
|
|
194
318
|
<style scoped>
|
|
319
|
+
/*
|
|
320
|
+
In `DXTable`'s card mode the card is rendered `no-body` so the table can sit
|
|
321
|
+
flush against the card border, which makes this footer a direct child of
|
|
322
|
+
`.card` — it no longer inherits any `.card-body` padding. Re-instate the inset
|
|
323
|
+
the card body used to give it, so the pager lines up with the padded card
|
|
324
|
+
header above it and keeps a bottom margin inside the (clipped) card.
|
|
325
|
+
|
|
326
|
+
Top padding is deliberately 0: the `mt-3` on the root already separates the
|
|
327
|
+
footer from the table above. `.dx-table-card` is DXTableShell's element and
|
|
328
|
+
needs no scope id — a scoped selector only stamps its LAST compound selector,
|
|
329
|
+
which here is this component's own root.
|
|
330
|
+
*/
|
|
331
|
+
.dx-table-card > .dx-table-pagination {
|
|
332
|
+
padding: 0 var(--bs-card-spacer-x) var(--bs-card-spacer-y);
|
|
333
|
+
}
|
|
334
|
+
|
|
195
335
|
/* 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
|
-
|
|
336
|
+
consistent min-width so they read as an even row of keys (the house pager
|
|
337
|
+
style), and let the whole row wrap on narrow widths rather than
|
|
198
338
|
overflow. `:deep(.btn)` is anchored on the plain `.dx-pager` host, so the
|
|
199
339
|
scope-id has a deterministic element to attach to. */
|
|
200
340
|
.dx-pager :deep(.btn) {
|
|
@@ -207,6 +347,23 @@ const goToPage = (page: number) => {
|
|
|
207
347
|
min-width: auto;
|
|
208
348
|
}
|
|
209
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
|
+
|
|
210
367
|
.dx-pager-ellipsis {
|
|
211
368
|
display: inline-flex;
|
|
212
369
|
align-items: flex-end;
|
|
@@ -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
|
|
21
|
+
├─ .card-header (padded by Bootstrap's card-cap padding)
|
|
22
|
+
├─ .table-responsive ← FLUSH, rounded when it is at a card corner
|
|
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
|
+
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).
|
|
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,89 @@ 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
|
+
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
|
|
98
|
+
scoped rule WOULD reach DCard's root (the renderer walks the component-root
|
|
99
|
+
chain), but that forwarding is the exact thing CLAUDE.md records as unreliable
|
|
100
|
+
across build pipelines, and a silently-unclipped card is invisible in review.
|
|
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.
|
|
123
|
+
|
|
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.
|
|
132
|
+
*/
|
|
133
|
+
.dx-table-card > .table:first-child,
|
|
134
|
+
.dx-table-card > .table:last-child {
|
|
135
|
+
overflow: hidden;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/*
|
|
139
|
+
The error alert is a full-width child of the card now that there is no
|
|
140
|
+
`.card-body`. Give it back the inset the card body used to provide, so it
|
|
141
|
+
doesn't bleed into the card's rounded corners.
|
|
142
|
+
*/
|
|
143
|
+
.dx-table-card > .alert {
|
|
144
|
+
margin: var(--bs-card-spacer-y) var(--bs-card-spacer-x);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/*
|
|
148
|
+
Nothing may leave a trailing margin at the bottom of the card. bvn already
|
|
149
|
+
zeroes `.table-responsive > .table`, but the responsive wrapper itself carries
|
|
150
|
+
`margin-bottom: 1rem` (and an un-responsive `.table` carries Bootstrap's own),
|
|
151
|
+
which used to be absorbed by the card body and now shows as a white band
|
|
152
|
+
inside the clipped card whenever the table is the last child (no pager). The
|
|
153
|
+
gap ABOVE the pager is unaffected — that margin only drops on the last child.
|
|
154
|
+
*/
|
|
155
|
+
.dx-table-card > :last-child {
|
|
156
|
+
margin-bottom: 0;
|
|
157
|
+
}
|
|
158
|
+
</style>
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useContainerWidth — container-query-style responsiveness for Vue components.
|
|
3
|
+
*
|
|
4
|
+
* Bootstrap's breakpoints are **media** queries: they only ever see the WINDOW.
|
|
5
|
+
* A dashboard page is narrowed by the persistent sidebar (and a form inside a
|
|
6
|
+
* modal is narrower still), so a component can have very little room while the
|
|
7
|
+
* viewport is wide — and no media query ever fires. This composable closes that
|
|
8
|
+
* gap by observing an element's **own rendered width** with a `ResizeObserver`
|
|
9
|
+
* and exposing it reactively, so the template can pick a layout from the space
|
|
10
|
+
* it actually has.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* const { containerRef, width, isBelow, isNarrowerThan } = useContainerWidth({
|
|
14
|
+
* threshold: 640,
|
|
15
|
+
* });
|
|
16
|
+
* // <div ref="containerRef"> … </div>
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* One observer can drive several independent decisions — `isBelow` uses the
|
|
20
|
+
* configured `threshold`, and `isNarrowerThan(n)` answers for any other width:
|
|
21
|
+
*
|
|
22
|
+
* ```ts
|
|
23
|
+
* const stacked = computed(() => isNarrowerThan(576));
|
|
24
|
+
* const hideDetailColumn = computed(() => isNarrowerThan(900));
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
import {
|
|
28
|
+
getCurrentScope,
|
|
29
|
+
onScopeDispose,
|
|
30
|
+
ref,
|
|
31
|
+
watch,
|
|
32
|
+
type Ref,
|
|
33
|
+
} from "vue";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What to observe: a template ref, or a getter (so the element can be resolved
|
|
37
|
+
* lazily / conditionally — e.g. `() => enabled ? el.value : null` to observe
|
|
38
|
+
* nothing at all).
|
|
39
|
+
*/
|
|
40
|
+
export type ContainerWidthTarget =
|
|
41
|
+
| Ref<HTMLElement | null | undefined>
|
|
42
|
+
| (() => HTMLElement | null | undefined);
|
|
43
|
+
|
|
44
|
+
export interface UseContainerWidthOptions {
|
|
45
|
+
/**
|
|
46
|
+
* Width (px) that `isBelow` compares against. Omit if you only need
|
|
47
|
+
* `width` / `isNarrowerThan`. Pass a getter (`() => props.threshold`) when
|
|
48
|
+
* it can change at runtime — `isBelow` re-evaluates when it does.
|
|
49
|
+
*/
|
|
50
|
+
threshold?: number | (() => number | undefined);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Hysteresis band (px) applied to `isBelow` only, to stop a layout flipping
|
|
54
|
+
* back and forth at the boundary. Once `isBelow` is true it stays true
|
|
55
|
+
* until the width reaches `threshold + hysteresis`.
|
|
56
|
+
*
|
|
57
|
+
* Defaults to `0` so `isBelow` means exactly `width < threshold` unless you
|
|
58
|
+
* ask for otherwise. Opt in whenever the layout you drive from it can
|
|
59
|
+
* change the observed width — the classic loop is: narrow layout is taller
|
|
60
|
+
* → an ancestor with `overflow:auto` gains a vertical scrollbar → the
|
|
61
|
+
* container loses ~15-17px → it crosses back over the threshold → repeat.
|
|
62
|
+
* A band wider than a scrollbar (24px is comfortable) breaks that cycle.
|
|
63
|
+
* (`isNarrowerThan` is deliberately raw — it holds no per-width state.)
|
|
64
|
+
*/
|
|
65
|
+
hysteresis?: number;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Width assumed before the first measurement, and permanently where there
|
|
69
|
+
* is no `ResizeObserver` (SSR, or a very old browser).
|
|
70
|
+
*
|
|
71
|
+
* Defaults to `0` — i.e. "assume the narrowest until proven otherwise", so
|
|
72
|
+
* an unmeasured render picks the STACKED layout. That is the mobile-first
|
|
73
|
+
* choice and the safe one: a stacked layout is legible at every width,
|
|
74
|
+
* whereas a wide layout guessed wrongly truncates content. It also means
|
|
75
|
+
* the server-rendered markup matches the first client render.
|
|
76
|
+
*/
|
|
77
|
+
initialWidth?: number;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Which box to report. `content-box` (default) is the space actually
|
|
81
|
+
* available to children — what a layout decision usually wants.
|
|
82
|
+
* `border-box` matches `getBoundingClientRect().width`.
|
|
83
|
+
*/
|
|
84
|
+
box?: "content-box" | "border-box";
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Extra debounce (ms) before publishing a new width. Defaults to `0`:
|
|
88
|
+
* `ResizeObserver` already batches to one delivery per frame, and the
|
|
89
|
+
* callback reads the size the observer *hands* it (no forced layout), so
|
|
90
|
+
* there is nothing to thrash. Set it only for genuinely expensive
|
|
91
|
+
* downstream work — a timer adds a frame of visible lag.
|
|
92
|
+
*/
|
|
93
|
+
debounce?: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface UseContainerWidthReturn {
|
|
97
|
+
/** Bind to the element you want measured (`<div ref="containerRef">`). */
|
|
98
|
+
containerRef: Ref<HTMLElement | null>;
|
|
99
|
+
/** The observed width in px. `initialWidth` until first measured. */
|
|
100
|
+
width: Readonly<Ref<number>>;
|
|
101
|
+
/** False until a real measurement has landed (always false without a `ResizeObserver`). */
|
|
102
|
+
hasMeasured: Readonly<Ref<boolean>>;
|
|
103
|
+
/** `width < threshold`, with `hysteresis` applied. False when no `threshold` was given. */
|
|
104
|
+
isBelow: Readonly<Ref<boolean>>;
|
|
105
|
+
/** Raw `width < px`, for driving other decisions off the same observer. */
|
|
106
|
+
isNarrowerThan: (px: number) => boolean;
|
|
107
|
+
/** Disconnect early. Called automatically when the owning scope is disposed. */
|
|
108
|
+
stop: () => void;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function useContainerWidth(
|
|
112
|
+
options?: UseContainerWidthOptions,
|
|
113
|
+
): UseContainerWidthReturn;
|
|
114
|
+
export function useContainerWidth(
|
|
115
|
+
target: ContainerWidthTarget,
|
|
116
|
+
options?: UseContainerWidthOptions,
|
|
117
|
+
): UseContainerWidthReturn;
|
|
118
|
+
export function useContainerWidth(
|
|
119
|
+
targetOrOptions?: ContainerWidthTarget | UseContainerWidthOptions,
|
|
120
|
+
maybeOptions?: UseContainerWidthOptions,
|
|
121
|
+
): UseContainerWidthReturn {
|
|
122
|
+
// Disambiguate the two overloads: a ref (has `.value`) or a function is a
|
|
123
|
+
// target; anything else is the options object.
|
|
124
|
+
const targetGiven =
|
|
125
|
+
typeof targetOrOptions === "function" ||
|
|
126
|
+
(targetOrOptions !== null &&
|
|
127
|
+
targetOrOptions !== undefined &&
|
|
128
|
+
typeof targetOrOptions === "object" &&
|
|
129
|
+
"value" in targetOrOptions);
|
|
130
|
+
|
|
131
|
+
const target = targetGiven
|
|
132
|
+
? (targetOrOptions as ContainerWidthTarget)
|
|
133
|
+
: undefined;
|
|
134
|
+
const options =
|
|
135
|
+
(targetGiven
|
|
136
|
+
? maybeOptions
|
|
137
|
+
: (targetOrOptions as UseContainerWidthOptions | undefined)) ?? {};
|
|
138
|
+
|
|
139
|
+
const {
|
|
140
|
+
threshold,
|
|
141
|
+
hysteresis = 0,
|
|
142
|
+
initialWidth = 0,
|
|
143
|
+
box = "content-box",
|
|
144
|
+
debounce = 0,
|
|
145
|
+
} = options;
|
|
146
|
+
|
|
147
|
+
/** Returned for the common `<div ref="containerRef">` case. */
|
|
148
|
+
const containerRef = ref<HTMLElement | null>(null);
|
|
149
|
+
|
|
150
|
+
const resolveTarget = (): HTMLElement | null => {
|
|
151
|
+
if (target === undefined) return containerRef.value;
|
|
152
|
+
if (typeof target === "function") return target() ?? null;
|
|
153
|
+
return target.value ?? null;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const startWidth = Math.round(initialWidth);
|
|
157
|
+
const width = ref<number>(startWidth);
|
|
158
|
+
const hasMeasured = ref<boolean>(false);
|
|
159
|
+
|
|
160
|
+
let observer: ResizeObserver | null = null;
|
|
161
|
+
let observedElement: HTMLElement | null = null;
|
|
162
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
163
|
+
|
|
164
|
+
function publish(nextWidth: number): void {
|
|
165
|
+
// Sub-pixel churn (a 0.3px reflow) must not wake every dependent
|
|
166
|
+
// computed, so publish at whole-pixel resolution and skip no-ops.
|
|
167
|
+
const rounded = Math.round(nextWidth);
|
|
168
|
+
if (!hasMeasured.value) hasMeasured.value = true;
|
|
169
|
+
if (rounded !== width.value) width.value = rounded;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function schedule(nextWidth: number): void {
|
|
173
|
+
if (debounce <= 0) {
|
|
174
|
+
publish(nextWidth);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (debounceTimer !== null) clearTimeout(debounceTimer);
|
|
178
|
+
debounceTimer = setTimeout(() => {
|
|
179
|
+
debounceTimer = null;
|
|
180
|
+
publish(nextWidth);
|
|
181
|
+
}, debounce);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function measure(entry: ResizeObserverEntry): number {
|
|
185
|
+
if (box === "border-box") {
|
|
186
|
+
// borderBoxSize is an array in the spec (fragmented inline boxes);
|
|
187
|
+
// some engines shipped a bare object first, hence the shape check.
|
|
188
|
+
const sizes = entry.borderBoxSize as unknown;
|
|
189
|
+
if (Array.isArray(sizes) && sizes.length > 0) {
|
|
190
|
+
return sizes[0].inlineSize;
|
|
191
|
+
}
|
|
192
|
+
if (
|
|
193
|
+
sizes !== null &&
|
|
194
|
+
sizes !== undefined &&
|
|
195
|
+
typeof sizes === "object" &&
|
|
196
|
+
"inlineSize" in (sizes as ResizeObserverSize)
|
|
197
|
+
) {
|
|
198
|
+
return (sizes as ResizeObserverSize).inlineSize;
|
|
199
|
+
}
|
|
200
|
+
// No borderBoxSize: fall back to the element's own box.
|
|
201
|
+
return (entry.target as HTMLElement).getBoundingClientRect().width;
|
|
202
|
+
}
|
|
203
|
+
return entry.contentRect.width;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// `stop()` is final: it also tears down the watcher that re-observes when
|
|
207
|
+
// the target changes, so a later ref change cannot silently restart a
|
|
208
|
+
// stopped observer.
|
|
209
|
+
let stopTargetWatch: (() => void) | null = null;
|
|
210
|
+
let stopped = false;
|
|
211
|
+
|
|
212
|
+
function stop(): void {
|
|
213
|
+
stopped = true;
|
|
214
|
+
if (stopTargetWatch !== null) {
|
|
215
|
+
stopTargetWatch();
|
|
216
|
+
stopTargetWatch = null;
|
|
217
|
+
}
|
|
218
|
+
if (debounceTimer !== null) {
|
|
219
|
+
clearTimeout(debounceTimer);
|
|
220
|
+
debounceTimer = null;
|
|
221
|
+
}
|
|
222
|
+
if (observer !== null) {
|
|
223
|
+
observer.disconnect();
|
|
224
|
+
observer = null;
|
|
225
|
+
}
|
|
226
|
+
observedElement = null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* SSR guard: `ResizeObserver` (and `window`) do not exist while the docs
|
|
231
|
+
* site server-renders these components. We stay at `initialWidth`, so the
|
|
232
|
+
* markup is the stacked/narrow one — see `initialWidth` above.
|
|
233
|
+
*/
|
|
234
|
+
const canObserve =
|
|
235
|
+
typeof window !== "undefined" && typeof ResizeObserver !== "undefined";
|
|
236
|
+
|
|
237
|
+
function observe(element: HTMLElement | null): void {
|
|
238
|
+
if (stopped) return;
|
|
239
|
+
if (element === observedElement) return;
|
|
240
|
+
if (!canObserve) return;
|
|
241
|
+
|
|
242
|
+
if (observer === null) {
|
|
243
|
+
observer = new ResizeObserver((entries) => {
|
|
244
|
+
// Only the last entry matters — a single element is observed and
|
|
245
|
+
// the observer coalesces to one delivery per frame.
|
|
246
|
+
const entry = entries[entries.length - 1];
|
|
247
|
+
if (entry === undefined) return;
|
|
248
|
+
schedule(measure(entry));
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (observedElement !== null) observer.unobserve(observedElement);
|
|
253
|
+
observedElement = element;
|
|
254
|
+
if (element !== null) observer.observe(element, { box });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// `post` flush so the element exists when a v-if/v-for swaps the target.
|
|
258
|
+
stopTargetWatch = watch(resolveTarget, observe, {
|
|
259
|
+
immediate: true,
|
|
260
|
+
flush: "post",
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
if (getCurrentScope() !== undefined) onScopeDispose(stop);
|
|
264
|
+
|
|
265
|
+
const resolveThreshold = (): number | undefined =>
|
|
266
|
+
typeof threshold === "function" ? threshold() : threshold;
|
|
267
|
+
|
|
268
|
+
// `isBelow` is a latched ref rather than a computed BECAUSE of hysteresis:
|
|
269
|
+
// the next answer depends on the current one, which a pure getter cannot
|
|
270
|
+
// express without mutating during evaluation. With `hysteresis: 0` it is
|
|
271
|
+
// exactly `width < threshold`. The watcher is `sync` so `isBelow` is never
|
|
272
|
+
// observed one tick behind `width`.
|
|
273
|
+
const initialThreshold = resolveThreshold();
|
|
274
|
+
const isBelow = ref<boolean>(
|
|
275
|
+
initialThreshold === undefined ? false : startWidth < initialThreshold,
|
|
276
|
+
);
|
|
277
|
+
watch(
|
|
278
|
+
[width, () => resolveThreshold()],
|
|
279
|
+
([currentWidth, currentThreshold]) => {
|
|
280
|
+
if (currentThreshold === undefined) {
|
|
281
|
+
isBelow.value = false;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
isBelow.value = isBelow.value
|
|
285
|
+
? currentWidth < currentThreshold + hysteresis
|
|
286
|
+
: currentWidth < currentThreshold;
|
|
287
|
+
},
|
|
288
|
+
{ flush: "sync" },
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
function isNarrowerThan(px: number): boolean {
|
|
292
|
+
return width.value < px;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
containerRef,
|
|
297
|
+
width,
|
|
298
|
+
hasMeasured,
|
|
299
|
+
isBelow,
|
|
300
|
+
isNarrowerThan,
|
|
301
|
+
stop,
|
|
302
|
+
};
|
|
303
|
+
}
|
package/resources/js/index.ts
CHANGED
|
@@ -110,6 +110,7 @@ export { default as DTooltip } from "./components/base/DTooltip.vue";
|
|
|
110
110
|
export { useForm } from "./composables/useForm";
|
|
111
111
|
export { defineForm } from "./composables/defineForm";
|
|
112
112
|
export { useToast } from "./composables/useToast";
|
|
113
|
+
export { useContainerWidth } from "./composables/useContainerWidth";
|
|
113
114
|
|
|
114
115
|
// Utils
|
|
115
116
|
export { api } from "./utils/api";
|
|
@@ -168,6 +169,12 @@ export type {
|
|
|
168
169
|
DefineFormReturn,
|
|
169
170
|
} from "./composables/defineForm";
|
|
170
171
|
|
|
172
|
+
export type {
|
|
173
|
+
ContainerWidthTarget,
|
|
174
|
+
UseContainerWidthOptions,
|
|
175
|
+
UseContainerWidthReturn,
|
|
176
|
+
} from "./composables/useContainerWidth";
|
|
177
|
+
|
|
171
178
|
export type {
|
|
172
179
|
ApiError,
|
|
173
180
|
ApiResponse,
|