@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.
Files changed (34) hide show
  1. package/dist/charts.js +137 -97
  2. package/dist/charts.js.map +1 -1
  3. package/dist/charts.umd.cjs +1 -1
  4. package/dist/charts.umd.cjs.map +1 -1
  5. package/dist/components/charts/chartTheme.d.ts +29 -3
  6. package/dist/components/charts/useThemedChart.d.ts +7 -2
  7. package/dist/components/extended/DXForm.vue.d.ts +28 -6
  8. package/dist/components/extended/DXTable.vue.d.ts +38 -1
  9. package/dist/components/extended/DXTableEditorModal.vue.d.ts +7 -2
  10. package/dist/components/extended/DXTablePagination.vue.d.ts +22 -1
  11. package/dist/composables/useContainerWidth.d.ts +96 -0
  12. package/dist/dashboard-for-laravel.js +8288 -8105
  13. package/dist/dashboard-for-laravel.js.map +1 -1
  14. package/dist/dashboard-for-laravel.umd.cjs +7 -7
  15. package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
  16. package/dist/index.d.ts +2 -0
  17. package/dist/style.css +1 -1
  18. package/docs/public/api-reference.json +48 -8
  19. package/docs/public/docs-map.md +1 -1
  20. package/package.json +1 -1
  21. package/resources/css/theme.scss +96 -4
  22. package/resources/js/components/charts/DXBarChart.vue +7 -2
  23. package/resources/js/components/charts/DXDoughnutChart.vue +33 -10
  24. package/resources/js/components/charts/DXLineChart.vue +7 -2
  25. package/resources/js/components/charts/chartTheme.ts +106 -11
  26. package/resources/js/components/charts/useThemedChart.ts +39 -21
  27. package/resources/js/components/extended/DXDashboardSidebar.vue +1 -1
  28. package/resources/js/components/extended/DXForm.vue +83 -9
  29. package/resources/js/components/extended/DXTable.vue +216 -12
  30. package/resources/js/components/extended/DXTableEditorModal.vue +7 -2
  31. package/resources/js/components/extended/DXTablePagination.vue +165 -8
  32. package/resources/js/components/extended/DXTableShell.vue +105 -1
  33. package/resources/js/composables/useContainerWidth.ts +303 -0
  34. 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
- <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. */
@@ -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 custard's `1 2 … 8 9 10 [11] 12 13 14 … 44 45`.
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 (like the house
197
- custard pager), and let the whole row wrap on narrow widths rather than
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
+ }
@@ -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,