@omnitend/dashboard-for-laravel 0.35.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.
@@ -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
- <div class="dx-pager-row d-flex justify-content-between align-items-center gap-2 mb-2">
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
  >&laquo; Previous</DButton>
37
52
 
38
- <template v-for="(item, index) in pageItems" :key="item.type === 'page' ? `p${item.page}` : `e${index}`">
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 (overflow: hidden)
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
- 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.
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
- 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
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
- 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.
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