@omnitend/dashboard-for-laravel 0.33.1 → 0.35.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 (57) hide show
  1. package/dist/charts.js +82 -90
  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/base/DButton.vue.d.ts +1 -1
  6. package/dist/components/charts/DXBarChart.vue.d.ts +3 -18
  7. package/dist/components/charts/DXLineChart.vue.d.ts +3 -18
  8. package/dist/components/charts/useThemedChart.d.ts +35 -0
  9. package/dist/components/extended/DXChoiceField.vue.d.ts +26 -0
  10. package/dist/components/extended/DXDashboardSidebar.vue.d.ts +1 -1
  11. package/dist/components/extended/DXField.vue.d.ts +10 -113
  12. package/dist/components/extended/DXFieldShell.vue.d.ts +73 -0
  13. package/dist/components/extended/DXForm.vue.d.ts +28 -6
  14. package/dist/components/extended/DXNumericField.vue.d.ts +23 -0
  15. package/dist/components/extended/DXRepeater.vue.d.ts +4 -4
  16. package/dist/components/extended/DXRepeaterCards.vue.d.ts +45 -0
  17. package/dist/components/extended/DXTable.vue.d.ts +107 -2
  18. package/dist/components/extended/DXTableEditorModal.vue.d.ts +22 -2
  19. package/dist/components/extended/DXUserAvatar.vue.d.ts +1 -1
  20. package/dist/composables/useAsyncOptions.d.ts +20 -0
  21. package/dist/composables/useContainerWidth.d.ts +96 -0
  22. package/dist/composables/useResourceEditor.d.ts +2 -1
  23. package/dist/dashboard-for-laravel.js +10513 -10443
  24. package/dist/dashboard-for-laravel.js.map +1 -1
  25. package/dist/dashboard-for-laravel.umd.cjs +7 -7
  26. package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
  27. package/dist/index.d.ts +5 -2
  28. package/dist/style.css +1 -1
  29. package/dist/types/index.d.ts +146 -0
  30. package/dist/utils/formSchema.d.ts +59 -0
  31. package/docs/public/api-reference.json +494 -33
  32. package/docs/public/docs-map.md +1 -1
  33. package/docs/public/llms.txt +5 -2
  34. package/package.json +1 -1
  35. package/resources/css/theme.scss +80 -2
  36. package/resources/js/components/charts/DXBarChart.vue +4 -43
  37. package/resources/js/components/charts/DXLineChart.vue +4 -43
  38. package/resources/js/components/charts/useThemedChart.ts +69 -0
  39. package/resources/js/components/extended/DXChoiceField.vue +114 -0
  40. package/resources/js/components/extended/DXDashboardSidebar.vue +1 -1
  41. package/resources/js/components/extended/DXField.vue +328 -615
  42. package/resources/js/components/extended/DXFieldShell.vue +124 -0
  43. package/resources/js/components/extended/DXForm.vue +92 -14
  44. package/resources/js/components/extended/DXNumericField.vue +144 -0
  45. package/resources/js/components/extended/DXRepeater.vue +53 -175
  46. package/resources/js/components/extended/DXRepeaterCards.vue +133 -0
  47. package/resources/js/components/extended/DXTable.vue +418 -43
  48. package/resources/js/components/extended/DXTableEditorModal.vue +39 -2
  49. package/resources/js/components/extended/DXTablePagination.vue +19 -3
  50. package/resources/js/components/extended/DXTableShell.vue +70 -1
  51. package/resources/js/composables/defineForm.ts +8 -44
  52. package/resources/js/composables/useAsyncOptions.ts +61 -0
  53. package/resources/js/composables/useContainerWidth.ts +303 -0
  54. package/resources/js/composables/useResourceEditor.ts +24 -38
  55. package/resources/js/index.ts +40 -0
  56. package/resources/js/types/index.ts +231 -0
  57. package/resources/js/utils/formSchema.ts +126 -0
@@ -47,6 +47,9 @@
47
47
  :form="form"
48
48
  :fields="fields"
49
49
  :tabs="tabs"
50
+ :layout="layout"
51
+ :label-cols="labelCols"
52
+ :card="card"
50
53
  :card-tabs="false"
51
54
  :context="item ?? undefined"
52
55
  :show-submit="false"
@@ -147,7 +150,7 @@
147
150
  :disabled="form?.processing || loading"
148
151
  @click="emit('delete')"
149
152
  >
150
- Delete
153
+ {{ deleteLabel }}
151
154
  </DButton>
152
155
  </div>
153
156
  <div class="d-flex gap-2">
@@ -161,7 +164,7 @@
161
164
  :disabled="form?.processing || loading"
162
165
  @click="emit('save')"
163
166
  >
164
- {{ isCreateMode ? 'Create' : 'Save Changes' }}
167
+ {{ isCreateMode ? createLabel : saveLabel }}
165
168
  </DButton>
166
169
  </div>
167
170
  </div>
@@ -176,6 +179,7 @@ import DButton from "../base/DButton.vue";
176
179
  import DSpinner from "../base/DSpinner.vue";
177
180
  import DXForm from "./DXForm.vue";
178
181
  import type { EditTab } from "./DXTable.vue";
182
+ import type { LabelCols } from "../../types";
179
183
 
180
184
  interface Props {
181
185
  /** Whether the modal is open (v-model:show). */
@@ -204,10 +208,43 @@ interface Props {
204
208
  pendingAction: 'save' | 'delete' | null;
205
209
  /** Delete endpoint — gates the Delete button. */
206
210
  deleteUrl?: string;
211
+ /**
212
+ * Field layout forwarded to the modal's DXForm (defaults handled upstream).
213
+ * Includes `"auto"`, which lets the form stack to vertical when the MODAL is
214
+ * narrow even though the viewport is wide — the container-query case a
215
+ * viewport media query cannot see.
216
+ */
217
+ layout?: 'vertical' | 'horizontal' | 'auto';
218
+ /** Label column width forwarded to DXForm (horizontal layout). */
219
+ labelCols?: LabelCols;
220
+ /** Wrap the modal form in a card (opt-in — off by default). */
221
+ card?: boolean;
222
+ /** Singular item noun for the default button labels (e.g. "customer"). */
223
+ itemName?: string;
224
+ /** Override the Save button label (edit mode). */
225
+ saveText?: string;
226
+ /** Override the Create button label (create mode). */
227
+ createText?: string;
228
+ /** Override the Delete button label. */
229
+ deleteText?: string;
207
230
  }
208
231
 
209
232
  const props = defineProps<Props>();
210
233
 
234
+ // Title-case the singular item noun for button labels ("customer" → "Customer",
235
+ // "sales order" → "Sales Order") — capitalize the first letter of each word.
236
+ const capitalizedItem = computed(() => {
237
+ const noun = props.itemName?.trim() || 'item';
238
+ return noun.replace(/\b\w/g, (char) => char.toUpperCase());
239
+ });
240
+
241
+ // Buttons default to item-named copy ("Save Customer" / "Create Customer" /
242
+ // "Delete Customer") — clearer than a generic "Save Changes" — overridable per
243
+ // action. Falls back to "Save Item" etc. when no `itemName` is set.
244
+ const saveLabel = computed(() => props.saveText ?? `Save ${capitalizedItem.value}`);
245
+ const createLabel = computed(() => props.createText ?? `Create ${capitalizedItem.value}`);
246
+ const deleteLabel = computed(() => props.deleteText ?? `Delete ${capitalizedItem.value}`);
247
+
211
248
  const emit = defineEmits<{
212
249
  /** v-model:show — the modal open state changed. */
213
250
  'update:show': [value: boolean];
@@ -135,7 +135,7 @@ const emit = defineEmits<{
135
135
  // except a single-page gap which shows the page itself (an ellipsis hiding one
136
136
  // page is silly). Client-computed from current + last page so it works in every
137
137
  // 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`.
138
+ // `links` array exists. Reproduces the house style: `1 2 … 8 9 10 [11] 12 13 14 … 44 45`.
139
139
  const BOUNDARY = 2;
140
140
  const AROUND = 3;
141
141
 
@@ -192,9 +192,25 @@ const goToPage = (page: number) => {
192
192
  </script>
193
193
 
194
194
  <style scoped>
195
+ /*
196
+ In `DXTable`'s card mode the card is rendered `no-body` so the table can sit
197
+ flush against the card border, which makes this footer a direct child of
198
+ `.card` — it no longer inherits any `.card-body` padding. Re-instate the inset
199
+ the card body used to give it, so the pager lines up with the padded card
200
+ header above it and keeps a bottom margin inside the (clipped) card.
201
+
202
+ Top padding is deliberately 0: the `mt-3` on the root already separates the
203
+ footer from the table above. `.dx-table-card` is DXTableShell's element and
204
+ needs no scope id — a scoped selector only stamps its LAST compound selector,
205
+ which here is this component's own root.
206
+ */
207
+ .dx-table-card > .dx-table-pagination {
208
+ padding: 0 var(--bs-card-spacer-x) var(--bs-card-spacer-y);
209
+ }
210
+
195
211
  /* 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
212
+ consistent min-width so they read as an even row of keys (the house pager
213
+ style), and let the whole row wrap on narrow widths rather than
198
214
  overflow. `:deep(.btn)` is anchored on the plain `.dx-pager` host, so the
199
215
  scope-id has a deterministic element to attach to. */
200
216
  .dx-pager :deep(.btn) {
@@ -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 (overflow: hidden)
21
+ ├─ .card-header (padded by Bootstrap's card-cap padding)
22
+ ├─ .table-responsive ← FLUSH
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
+ 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.
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,54 @@ 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
+ 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
87
+ scoped rule WOULD reach DCard's root (the renderer walks the component-root
88
+ chain), but that forwarding is the exact thing CLAUDE.md records as unreliable
89
+ across build pipelines, and a silently-unclipped card is invisible in review.
90
+ The lower specificity also leaves consumers free to override it.
91
+
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.
98
+ */
99
+ .dx-table-card {
100
+ overflow: hidden;
101
+ }
102
+
103
+ /*
104
+ The error alert is a full-width child of the card now that there is no
105
+ `.card-body`. Give it back the inset the card body used to provide, so it
106
+ doesn't bleed into the card's rounded corners.
107
+ */
108
+ .dx-table-card > .alert {
109
+ margin: var(--bs-card-spacer-y) var(--bs-card-spacer-x);
110
+ }
111
+
112
+ /*
113
+ Nothing may leave a trailing margin at the bottom of the card. bvn already
114
+ zeroes `.table-responsive > .table`, but the responsive wrapper itself carries
115
+ `margin-bottom: 1rem` (and an un-responsive `.table` carries Bootstrap's own),
116
+ which used to be absorbed by the card body and now shows as a white band
117
+ inside the clipped card whenever the table is the last child (no pager). The
118
+ gap ABOVE the pager is unaffected — that margin only drops on the last child.
119
+ */
120
+ .dx-table-card > :last-child {
121
+ margin-bottom: 0;
122
+ }
123
+ </style>
@@ -1,5 +1,6 @@
1
1
  import { useForm, type UseFormReturn } from "./useForm";
2
- import type { FieldDefinition, FieldType } from "../types";
2
+ import type { FieldDefinition } from "../types";
3
+ import { resolveFieldDefault } from "../utils/formSchema";
3
4
 
4
5
  /**
5
6
  * Extended field definition that includes default value
@@ -30,35 +31,6 @@ export interface DefineFormReturn<TData extends Record<string, any>> {
30
31
  fields: FieldDefinition[];
31
32
  }
32
33
 
33
- /**
34
- * Helper to get default value for a field type
35
- */
36
- function getDefaultValueForType(type: FieldType): any {
37
- switch (type) {
38
- case "checkbox":
39
- case "switch":
40
- return false;
41
- case "number":
42
- case "currency":
43
- case "percentage":
44
- return 0;
45
- case "repeater":
46
- // Array-valued selections: checkbox-group previously fell through to
47
- // the "" default, which is the wrong shape for an array model.
48
- case "checkbox-group":
49
- case "switch-list":
50
- return [];
51
- case "image":
52
- case "file":
53
- return null;
54
- case "select":
55
- case "radio":
56
- return "";
57
- default:
58
- return "";
59
- }
60
- }
61
-
62
34
  /**
63
35
  * Define a form with type-safe field definitions.
64
36
  * Reduces duplication by inferring initial values from field definitions.
@@ -75,22 +47,14 @@ export function defineForm<const TFields extends readonly FormFieldDefinition[]>
75
47
  ): DefineFormReturn<InferFormData<TFields>> {
76
48
  type FormData = InferFormData<TFields>;
77
49
 
78
- // Extract initial data from field definitions.
79
- //
80
- // #125: `field.default ?? getDefaultValueForType(type)` cannot express a
81
- // *null* default — a select whose "none" option is `value: null` (which is
82
- // what the column stores) seeded '' (the type default), matched no option,
83
- // and rendered blank. This is the same bug #122 fixed in DXTable. Decide on
84
- // definedness, not nullishness, so null survives; an absent (or explicitly
85
- // `undefined`) default still falls back to the type default rather than
86
- // seeding `undefined`, which would drop the key from the JSON payload. This
87
- // matches DXRepeater's `!== undefined` rule, so all seeding sites agree.
50
+ // Extract initial data from field definitions. Seeding (definedness over
51
+ // nullishness so a `null` default survives — #122/#125 — plus a type-aware
52
+ // fallback and deep-cloning) is the shared `resolveFieldDefault` rule, so
53
+ // every seeding site (defineForm / useResourceEditor / DXRepeater) agrees
54
+ // by construction (#134).
88
55
  const initialData = fieldDefinitions.reduce(
89
56
  (acc, field) => {
90
- acc[field.key] =
91
- field.default !== undefined
92
- ? field.default
93
- : getDefaultValueForType(field.type);
57
+ acc[field.key] = resolveFieldDefault(field);
94
58
  return acc;
95
59
  },
96
60
  {} as Record<string, any>,
@@ -0,0 +1,61 @@
1
+ import { onMounted, ref, watch, type ComputedRef, type Ref } from "vue";
2
+ import type { FieldDefinition, FieldOption } from "../types";
3
+
4
+ /**
5
+ * Async option loading for a `DXField`: runs `field.optionsLoader` on mount and
6
+ * (when `field.reloadOptionsOnChange` is set) whenever the model changes,
7
+ * exposing the loaded options. A monotonic request token guards against
8
+ * out-of-order responses clobbering newer ones; loader failures are swallowed
9
+ * so the last good options (or the static `field.options` fallback) stay put.
10
+ *
11
+ * Extracted verbatim from DXField's inline logic (#135) — behaviour unchanged.
12
+ * `getField` is an accessor (props aren't destructurable without losing
13
+ * reactivity); `effectiveModel` is the model passed to the loader.
14
+ */
15
+ export interface UseAsyncOptionsReturn {
16
+ /** Options returned by the most recent successful load, or null if none yet. */
17
+ loadedOptions: Ref<FieldOption[] | null>;
18
+ /** Trigger a load (no-op when the field has no `optionsLoader`). */
19
+ loadOptions: () => Promise<void>;
20
+ }
21
+
22
+ export function useAsyncOptions(
23
+ getField: () => FieldDefinition,
24
+ effectiveModel: ComputedRef<any> | Ref<any>,
25
+ ): UseAsyncOptionsReturn {
26
+ const loadedOptions = ref<FieldOption[] | null>(null);
27
+
28
+ // Monotonic token so out-of-order async responses can't clobber newer ones.
29
+ let optionsRequestToken = 0;
30
+
31
+ async function loadOptions(): Promise<void> {
32
+ const field = getField();
33
+ if (!field.optionsLoader) return;
34
+ const token = ++optionsRequestToken;
35
+ try {
36
+ const options = await field.optionsLoader(effectiveModel.value);
37
+ // Ignore a stale response superseded by a newer load.
38
+ if (token === optionsRequestToken) loadedOptions.value = options;
39
+ } catch (error) {
40
+ // Swallow loader failures: keep the last successfully loaded options
41
+ // (or the static `field.options` fallback when none have loaded yet).
42
+ if (token === optionsRequestToken) {
43
+ // eslint-disable-next-line no-console
44
+ console.error(
45
+ `optionsLoader failed for field "${field.key}"`,
46
+ error,
47
+ );
48
+ }
49
+ }
50
+ }
51
+
52
+ onMounted(() => {
53
+ void loadOptions();
54
+ });
55
+
56
+ if (getField().reloadOptionsOnChange) {
57
+ watch(effectiveModel, () => void loadOptions(), { deep: true });
58
+ }
59
+
60
+ return { loadedOptions, loadOptions };
61
+ }
@@ -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
+ }