@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.
@@ -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
- const chartData = computed(
47
- () =>
48
- ({
49
- labels: props.labels,
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 mergedOptions = computed(
55
- () =>
56
- mergeOptions(
57
- baseOptions({
58
- valueFormat: props.valueFormat,
59
- currencySymbol: props.currencySymbol,
60
- locale: props.locale,
61
- showLegend: props.showLegend ?? props.datasets.length > 1,
62
- hasValueAxis: true,
63
- }),
64
- props.options ?? {},
65
- ) as ChartOptions<TType>,
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
- export type FilterType = 'text' | 'select' | 'number' | 'date' | false;
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, which requires the card
610
- * to clip to its radius (`overflow: hidden`).
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
- * **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.
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.filter !== 'select') continue;
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
- if (!field.filterMultiple) {
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.filter === 'select' && (!field.filterOptions || field.filterOptions.length === 0))
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
- <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