@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
@@ -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
  }
@@ -426,7 +426,7 @@ watch(activeGroupIndex, () => {
426
426
  overflow-x: hidden;
427
427
  }
428
428
 
429
- /* Dense rhythm (#95): greendragon's cutover values, adopted as the default —
429
+ /* Dense rhythm (#95): values proven in a production dashboard, adopted as the default —
430
430
  natural-case headers at 0.875rem (no uppercase/letter-spacing), tight group
431
431
  spacing. */
432
432
  .nav-group {
@@ -13,8 +13,9 @@
13
13
  -->
14
14
  <template>
15
15
  <BForm
16
+ ref="formRoot"
16
17
  @submit.prevent="handleSubmit"
17
- :class="{ 'dx-form--horizontal': layout === 'horizontal' }"
18
+ :class="{ 'dx-form--horizontal': resolvedLayout === 'horizontal' }"
18
19
  >
19
20
  <!-- Form-level error message -->
20
21
  <DAlert
@@ -71,7 +72,7 @@
71
72
  :field="field"
72
73
  :form="resolvedForm"
73
74
  :model="model"
74
- :layout="layout"
75
+ :layout="resolvedLayout"
75
76
  :label-cols="labelCols"
76
77
  >
77
78
  <!-- Forward every DXForm slot so the field can render
@@ -105,7 +106,7 @@
105
106
  :field="field"
106
107
  :form="resolvedForm"
107
108
  :model="model"
108
- :layout="layout"
109
+ :layout="resolvedLayout"
109
110
  :label-cols="labelCols"
110
111
  >
111
112
  <!-- Forward every DXForm slot so the field can render its
@@ -166,7 +167,7 @@
166
167
  </template>
167
168
 
168
169
  <script setup lang="ts">
169
- import { computed, watch } from "vue";
170
+ import { computed, ref, watch, type ComponentPublicInstance } from "vue";
170
171
  import { BForm } from "bootstrap-vue-next";
171
172
  import DAlert from "../base/DAlert.vue";
172
173
  import DButton from "../base/DButton.vue";
@@ -176,6 +177,7 @@ import { BTab as DTab } from "bootstrap-vue-next"; // raw BTab: BTabs scans slot
176
177
  import DXFormField from "./DXFormField.vue";
177
178
  import type { UseFormReturn } from "../../composables/useForm";
178
179
  import type { DefineFormReturn } from "../../composables/defineForm";
180
+ import { useContainerWidth } from "../../composables/useContainerWidth";
179
181
  import type { FieldDefinition, FormTab, LabelCols, MaybeFn } from "../../types";
180
182
  import {
181
183
  resolvePredicate as resolvePredicateFor,
@@ -235,12 +237,34 @@ interface Props {
235
237
  cardTabs?: boolean;
236
238
 
237
239
  /**
238
- * Form-wide field layout: "vertical" (default, label above input) or
239
- * "horizontal" (label left, input right). Overridable per-field via
240
- * `field.layout`. A field with `span: true` always renders full-width,
241
- * regardless of layout.
240
+ * Form-wide field layout:
241
+ *
242
+ * - `"vertical"` (default) — label above input, always.
243
+ * - `"horizontal"` — label left, input right, always.
244
+ * - `"auto"` — horizontal when the form's **own container** is at least
245
+ * `layoutThreshold` px wide, vertical below that. Container-driven, not
246
+ * viewport-driven: a page narrowed by the dashboard sidebar, or a form
247
+ * inside a modal, stacks even though the window is wide (which no
248
+ * Bootstrap media query can see).
249
+ *
250
+ * Overridable per-field via `field.layout`. A field with `span: true`
251
+ * always renders full-width, regardless of layout.
242
252
  */
243
- layout?: "vertical" | "horizontal";
253
+ layout?: "vertical" | "horizontal" | "auto";
254
+
255
+ /**
256
+ * Container width (px) at or above which `layout: "auto"` goes horizontal.
257
+ * Ignored for the explicit `"vertical"`/`"horizontal"` layouts.
258
+ *
259
+ * Default 640, measured rather than guessed: with the default 3-column
260
+ * label the label's text area is `containerWidth / 4 - 18` px, so 640 gives
261
+ * it 142px — enough for a ~20-character label ("Unit price (ex VAT)"
262
+ * measures 128px at the theme's label font) to stay on one line, with the
263
+ * control column still 474px. Below ~584px that label starts wrapping,
264
+ * which is the cramped-label symptom this exists to avoid. Raise it if your
265
+ * labels run longer, or lower `labelCols` instead.
266
+ */
267
+ layoutThreshold?: number;
244
268
 
245
269
  /**
246
270
  * Label column width for horizontal layout (mirrors BFormGroup's
@@ -258,6 +282,7 @@ const props = withDefaults(defineProps<Props>(), {
258
282
  card: false,
259
283
  cardTabs: true,
260
284
  layout: "vertical",
285
+ layoutThreshold: 640,
261
286
  });
262
287
 
263
288
  const emit = defineEmits<{
@@ -270,6 +295,55 @@ const slots = defineSlots<Record<string, (props: any) => any>>();
270
295
  /** v-model for the active tab index. */
271
296
  const activeTab = defineModel<number>("activeTab", { default: 0 });
272
297
 
298
+ // ————————————————— container-driven layout (`layout: "auto"`)
299
+
300
+ const formRoot = ref<ComponentPublicInstance | HTMLElement | null>(null);
301
+
302
+ /**
303
+ * The `<form>` element BForm renders. A template ref on a component yields the
304
+ * component instance, so unwrap `$el` — and tolerate either shape rather than
305
+ * assuming one, so a future BForm change (or a functional re-implementation)
306
+ * can't silently leave us observing nothing.
307
+ */
308
+ function resolveFormElement(): HTMLElement | null {
309
+ const rootValue = formRoot.value;
310
+ if (rootValue === null || rootValue === undefined) return null;
311
+ if (rootValue instanceof HTMLElement) return rootValue;
312
+ const element = (rootValue as ComponentPublicInstance).$el;
313
+ return element instanceof HTMLElement ? element : null;
314
+ }
315
+
316
+ // Observe ONLY in auto mode. Returning null for the other layouts means no
317
+ // ResizeObserver is ever attached for the (overwhelmingly common) explicit
318
+ // layouts — existing consumers pay nothing and behave identically.
319
+ const { isBelow: containerIsNarrow } = useContainerWidth(
320
+ () => (props.layout === "auto" ? resolveFormElement() : null),
321
+ {
322
+ // Getter, so a consumer binding `:layout-threshold` to something
323
+ // reactive re-evaluates instead of latching the mount-time value.
324
+ threshold: () => props.layoutThreshold,
325
+ // Guard against an observer feedback loop at the boundary: the vertical
326
+ // layout is TALLER, so a form inside an `overflow:auto` ancestor can
327
+ // gain a scrollbar when it stacks, shrinking its own container by
328
+ // ~15-17px — straight back over the threshold, and it flips forever.
329
+ // A band wider than any scrollbar (24px) makes the crossing one-way
330
+ // until the container genuinely grows.
331
+ hysteresis: 24,
332
+ },
333
+ );
334
+
335
+ /**
336
+ * The layout actually rendered. `vertical`/`horizontal` pass through
337
+ * unconditionally (unchanged behaviour); `auto` resolves from the measured
338
+ * container width, defaulting to `vertical` before the first measurement and
339
+ * under SSR — the stacked layout is legible at any width, so it is the safe
340
+ * thing to render when the width is unknown.
341
+ */
342
+ const resolvedLayout = computed<"vertical" | "horizontal">(() => {
343
+ if (props.layout !== "auto") return props.layout;
344
+ return containerIsNarrow.value ? "vertical" : "horizontal";
345
+ });
346
+
273
347
  // ————————————————— resolve form / fields (accept useForm or defineForm)
274
348
 
275
349
  function isDefineForm(
@@ -163,6 +163,35 @@
163
163
  <div class="fw-semibold">{{ headerLabel(field, label) }}</div>
164
164
  <small v-if="field.hint" class="text-muted d-block" style="font-weight: normal;">{{ field.hint }}</small>
165
165
  </div>
166
+
167
+ <!-- ADDITIVE per-column header content (#99), rendered
168
+ INSIDE DXTable's own header cell rather than as a
169
+ `head(<key>)` override — an override would silently
170
+ drop the sort indicator and the field hint, which is
171
+ exactly why `head(...)` is not forwarded (see
172
+ `isTableSlot`). Placed after the label block and
173
+ BEFORE the sort indicator so a period total sits at
174
+ the top-right of the text area without displacing the
175
+ sort arrows, and lands in the same place whether or
176
+ not the column happens to be sortable. The wrapper is
177
+ only emitted when the slot exists, so a table without
178
+ one renders exactly as before. -->
179
+ <div
180
+ v-if="$slots[`head-end(${field.key})`]"
181
+ class="dx-head-end flex-shrink-0 text-end"
182
+ >
183
+ <!--
184
+ @slot head-end(<fieldKey>) Additive content at the end of a column's own header — a period total above a numeric column, a small badge — keeping DXTable's sort indicator and field hint. Not a `head()` override.
185
+ @binding {object} field The column's field definition.
186
+ @binding {string} label The column's resolved header label.
187
+ -->
188
+ <slot
189
+ :name="`head-end(${field.key})`"
190
+ :field="field"
191
+ :label="headerLabel(field, label)"
192
+ />
193
+ </div>
194
+
166
195
  <div v-if="field.sortable" class="sort-indicator text-muted flex-shrink-0" style="font-size: 0.75rem; line-height: 1.1; display: flex; flex-direction: column; align-items: center;">
167
196
  <span :style="{ opacity: getFieldSortState(field.key) === 'asc' ? 1 : 0.3 }">▲</span>
168
197
  <span :style="{ opacity: getFieldSortState(field.key) === 'desc' ? 1 : 0.3 }">▼</span>
@@ -359,6 +388,22 @@ export interface TableField {
359
388
  filterOptions?: FilterOption[];
360
389
  filterPlaceholder?: string;
361
390
 
391
+ /**
392
+ * For a `select` filter on a **client-side** table: derive the dropdown's
393
+ * options from the distinct values present in the loaded rows. Defaults to
394
+ * `true` — with no `filterOptions` and no server `filterValues` the
395
+ * alternative is an empty dropdown, which can only ever be a bug.
396
+ *
397
+ * Set `false` to opt out (e.g. a column whose raw values are opaque ids you
398
+ * would rather label yourself via `filterOptions`).
399
+ *
400
+ * Explicit `filterOptions` and server-supplied `filterValues` still WIN, in
401
+ * that order. Provider/API and Inertia modes ignore this flag: they only
402
+ * ever hold one page of rows, so there is no complete value set to derive
403
+ * from (see `getFieldFilterOptions`).
404
+ */
405
+ deriveFilterOptions?: boolean;
406
+
362
407
  /**
363
408
  * Fixed width for this column, applied via a `<colgroup>` `<col>`. A number
364
409
  * is treated as pixels (`200` → `200px`); a string is used verbatim
@@ -559,6 +604,18 @@ export interface Props<TItem = any> {
559
604
  * borderless variant — the same header, filter row, table and pagination
560
605
  * rendered directly on the page background (no card border/shadow), for
561
606
  * data-heavy admin index pages that read as busy inside a card.
607
+ *
608
+ * The table renders FLUSH to the card border (rows and stripes reach the
609
+ * edge) while the header and pagination stay padded. Only the table region
610
+ * is rounded to follow the card's corners — the card itself does not clip
611
+ * (#166), so a `position: absolute` popover you render into `#header` or
612
+ * any other slot is free to overhang the card.
613
+ *
614
+ * The one place content is still clipped is INSIDE the table: Bootstrap's
615
+ * `.table-responsive` is a horizontal scroll container, so an overlay in a
616
+ * `cell(<key>)` slot is bounded by it. Teleport it (bvn's own menus already
617
+ * do — `DDropdown` teleports to `<body>`, the column-filter menus are
618
+ * `fixed`), or pass `:responsive="false"`.
562
619
  */
563
620
  card?: boolean;
564
621
 
@@ -585,8 +642,13 @@ export interface Props<TItem = any> {
585
642
  * **"horizontal"** to match the Omni Tend form convention (a raw modal form
586
643
  * previously fell back to DXForm's vertical default, which looked out of
587
644
  * place next to horizontal page forms). Pass `"vertical"` to opt out.
645
+ *
646
+ * `"auto"` hands the decision to `DXForm`'s container-width observer, which
647
+ * measures the FORM rather than the window — so a modal narrowed by its own
648
+ * breakpoint stacks to vertical even on a wide viewport, where a media query
649
+ * would still report "plenty of room". See `useContainerWidth`.
588
650
  */
589
- editLayout?: 'vertical' | 'horizontal';
651
+ editLayout?: 'vertical' | 'horizontal' | 'auto';
590
652
 
591
653
  /**
592
654
  * Label column width for the edit modal's horizontal layout, forwarded to
@@ -1020,16 +1082,7 @@ const clientSideFilteredItems = computed<T[]>(() => {
1020
1082
  return filterKeys.every(key => {
1021
1083
  // Resolve by the FILTER key, which may differ from the column's key.
1022
1084
  const field = props.fields.find(f => filterKeyFor(f) === key);
1023
- // `filterKey` names the param the SERVER filters on; a local row may
1024
- // not carry it at all (a `customer_name` row filtered on
1025
- // `customer_id`). Match on the filter key when the row has it,
1026
- // otherwise fall back to the column's own value — which is also the
1027
- // one the user can actually see and type against.
1028
- // Both lookups are dot-path aware, so a nested payload filters (#121).
1029
- const itemValue =
1030
- field && hasFieldValue(item, key)
1031
- ? fieldValueOf(item, key)
1032
- : fieldValueOf(item, field?.key ?? key);
1085
+ const itemValue = clientSideFilterValueOf(item, field, key);
1033
1086
 
1034
1087
  const matchesCandidate = (candidate: string): boolean => {
1035
1088
  // The "no value" option (e.g. Unassigned) is the one filter that
@@ -1151,6 +1204,28 @@ const hasFieldValue = (item: unknown, key: string): boolean => {
1151
1204
  return getByPath(item as Record<string, any>, key) !== undefined;
1152
1205
  };
1153
1206
 
1207
+ /*
1208
+ * The value a client-side filter compares against, for one row and one column.
1209
+ *
1210
+ * `filterKey` names the param the SERVER filters on; a local row may not carry
1211
+ * it at all (a `customer_name` row filtered on `customer_id`). Read the filter
1212
+ * key when the row has it, otherwise fall back to the column's own value —
1213
+ * which is also the one the user can actually see and type against. Both
1214
+ * lookups are dot-path aware, so a nested payload filters (#121).
1215
+ *
1216
+ * Extracted so the client-side row filter and the DERIVED select options (see
1217
+ * `derivedFilterOptions`) resolve values through exactly the same rule — a
1218
+ * derived option must never be a value the filter then fails to match.
1219
+ */
1220
+ const clientSideFilterValueOf = (
1221
+ item: unknown,
1222
+ field: TableField | undefined,
1223
+ filterKey: string,
1224
+ ): any =>
1225
+ field && hasFieldValue(item, filterKey)
1226
+ ? fieldValueOf(item, filterKey)
1227
+ : fieldValueOf(item, field?.key ?? filterKey);
1228
+
1154
1229
  /*
1155
1230
  * Dotted-key columns DXTable must render itself, because bvn renders them empty
1156
1231
  * (see `fieldValueOf`). A consumer's own `cell(<key>)` slot always wins — it is
@@ -1235,6 +1310,115 @@ const filterAllLabelFor = (field: TableField): string =>
1235
1310
  const headerLabel = (field: TableField, slotLabel?: string): string =>
1236
1311
  field.label !== undefined ? field.label : (slotLabel || field.key);
1237
1312
 
1313
+ /**
1314
+ * A select filter option's LABEL, honouring the column's own value→label
1315
+ * formatting so the dropdown reads the way the column does (a `formatter`
1316
+ * turning `19.99` into `$19.99`). The option's `value` stays RAW — that is what
1317
+ * the client-side filter compares against (`String(itemValue) === candidate`),
1318
+ * so formatting must never leak into it.
1319
+ *
1320
+ * `formatter` is bootstrap-vue-next's field formatter, `(value, key, item)`, and
1321
+ * its first argument is by contract the value of `field.key` — the DISPLAYED
1322
+ * column value. That matters when `filterKey` names a different property: the
1323
+ * option's value must stay the FILTER value (that is what the row filter
1324
+ * compares), but the formatter must still be handed the display value it was
1325
+ * written against. Passing the filter value here fed a `customer` column's
1326
+ * `(customer) => customer.name` formatter a numeric `customer_id`, which
1327
+ * mislabels at best and throws at worst.
1328
+ */
1329
+ const formattedFilterLabel = (field: TableField, item: unknown): string | null => {
1330
+ if (typeof field.formatter !== 'function') return null;
1331
+ const formatted = field.formatter(fieldValueOf(item, field.key), field.key, item);
1332
+ if (formatted === null || formatted === undefined) return null;
1333
+ return String(formatted);
1334
+ };
1335
+
1336
+ /**
1337
+ * Client-side auto-derived `filter: "select"` options, keyed by field key.
1338
+ *
1339
+ * Without this a client-side select column with no `filterOptions` and no
1340
+ * server `filterValues` rendered an EMPTY dropdown — dead UI that can only be a
1341
+ * bug — so this is on by default (opt out per column with
1342
+ * `deriveFilterOptions: false`).
1343
+ *
1344
+ * It derives from `resolvedItems` (the FULL loaded row set) and deliberately
1345
+ * NOT from `clientSideFilteredItems` or `clientSidePaginatedItems`:
1346
+ * - filtered set → picking a value would collapse the dropdown to that one
1347
+ * value, and the user could never get back to any other one;
1348
+ * - current page → the offered values would silently change as you page.
1349
+ * Being a computed over `props.fields` + `resolvedItems` only, it is also
1350
+ * structurally incapable of depending on the active filters — the cache is the
1351
+ * guard, not just the comment.
1352
+ */
1353
+ const derivedFilterOptions = computed<Record<string, FilterOption[]>>(() => {
1354
+ // Only client-side mode has the complete row set. Provider/API and Inertia
1355
+ // hold one page, so they take their options from the server instead.
1356
+ if (!isClientSideMode.value) return {};
1357
+
1358
+ const rows = resolvedItems.value;
1359
+ if (!rows || rows.length === 0) return {};
1360
+
1361
+ const optionsByFieldKey: Record<string, FilterOption[]> = {};
1362
+
1363
+ for (const field of props.fields) {
1364
+ if (field.filter !== 'select') continue;
1365
+ if (field.deriveFilterOptions === false) continue;
1366
+ // Explicit options win outright (see `getFieldFilterOptions`), so don't
1367
+ // spend a pass over every row producing a list nothing will read.
1368
+ if (field.filterOptions && field.filterOptions.length > 0) continue;
1369
+
1370
+ const filterKey = filterKeyFor(field);
1371
+
1372
+ // Distinct values keyed by their STRING form, because that is exactly
1373
+ // what the client-side select filter compares — so `1` and `"1"` are one
1374
+ // option, and every option offered is one the filter can match. The
1375
+ // first row carrying a value is kept alongside it, as the `item`
1376
+ // argument the column's formatter expects.
1377
+ const firstRowByValue = new Map<string, { rawValue: any; item: unknown }>();
1378
+
1379
+ // Candidates the filter reads as CONTROL values rather than data, so a
1380
+ // row whose value collides with one can never be matched by selecting it
1381
+ // (`handleSelectFilterChange` turns the "all" sentinel back into "no
1382
+ // filter", and the null sentinel is intercepted upstream of the select's
1383
+ // exact-match arm, where it means "has no value"). Offering such an
1384
+ // option would be a dropdown entry that returns zero rows, and the null
1385
+ // one would also duplicate the `filterNullText` entry's value.
1386
+ const reservedCandidates = new Set<string>([FILTER_ALL_VALUE]);
1387
+ if (field.filterNullText) reservedCandidates.add(filterNullValueFor(field));
1388
+
1389
+ for (const item of rows) {
1390
+ const value = clientSideFilterValueOf(item, field, filterKey);
1391
+ // null / undefined / '' are skipped deliberately: "has no value" is
1392
+ // already a first-class, LABELLED option via `filterNullText`, and
1393
+ // an unlabelled blank row in the list would be unusable.
1394
+ if (value === null || value === undefined || value === '') continue;
1395
+ const stringValue = String(value);
1396
+ if (reservedCandidates.has(stringValue)) continue;
1397
+ if (!firstRowByValue.has(stringValue)) {
1398
+ firstRowByValue.set(stringValue, { rawValue: value, item });
1399
+ }
1400
+ }
1401
+
1402
+ const entries = [...firstRowByValue.entries()];
1403
+
1404
+ // Stable, human ordering: numeric when both values are numbers,
1405
+ // otherwise a locale compare — the same rule the client-side sort uses,
1406
+ // with `numeric: true` so "Item 2" precedes "Item 10".
1407
+ entries.sort(([leftText, left], [rightText, right]) =>
1408
+ typeof left.rawValue === 'number' && typeof right.rawValue === 'number'
1409
+ ? left.rawValue - right.rawValue
1410
+ : leftText.localeCompare(rightText, undefined, { numeric: true, sensitivity: 'base' }),
1411
+ );
1412
+
1413
+ optionsByFieldKey[field.key] = entries.map(([stringValue, { item }]) => ({
1414
+ value: stringValue,
1415
+ text: formattedFilterLabel(field, item) ?? stringValue,
1416
+ }));
1417
+ }
1418
+
1419
+ return optionsByFieldKey;
1420
+ });
1421
+
1238
1422
  const getFieldFilterOptions = (field: TableField): FilterOption[] => {
1239
1423
  const options: FilterOption[] = [];
1240
1424
 
@@ -1274,6 +1458,15 @@ const getFieldFilterOptions = (field: TableField): FilterOption[] => {
1274
1458
  return [...options, ...serverValues.map(value => ({ value, text: value }))];
1275
1459
  }
1276
1460
 
1461
+ // Last resort, CLIENT-SIDE only: the distinct values present in the loaded
1462
+ // rows. Precedence is deliberate — explicit `filterOptions`, then the
1463
+ // server's `filterValues`, then what the data itself says. See
1464
+ // `derivedFilterOptions` for why it derives from the unfiltered row set.
1465
+ const derived = derivedFilterOptions.value[field.key];
1466
+ if (derived && derived.length > 0) {
1467
+ return [...options, ...derived];
1468
+ }
1469
+
1277
1470
  // No values to choose from — don't offer a lone "All x" that filters nothing.
1278
1471
  return [];
1279
1472
  };
@@ -1549,7 +1742,12 @@ const shouldShowPerPageSelector = computed(() => {
1549
1742
  return total >= smallestOption;
1550
1743
  });
1551
1744
 
1552
- // Detect which fields need server filter values
1745
+ // Detect which fields need server filter values.
1746
+ //
1747
+ // Client-side derived options (`derivedFilterOptions`) deliberately do NOT
1748
+ // shrink this list: it is only ever read by `internalProvider`, i.e. API mode,
1749
+ // and `isProviderMode` requires `!resolvedClientSide` — the two modes are
1750
+ // mutually exclusive, so a client-side derived column can never appear here.
1553
1751
  const fieldsNeedingFilterValues = computed(() => {
1554
1752
  return props.fields
1555
1753
  .filter(field => field.filter === 'select' && (!field.filterOptions || field.filterOptions.length === 0))
@@ -1956,6 +2154,12 @@ const refresh = () => {
1956
2154
  * - `head(<key>)` — DXTable draws its own column headers (sort indicators,
1957
2155
  * field hints). Forwarding a consumer's would silently drop those.
1958
2156
  * - `thead-top` — DXTable renders the inline filter row there.
2157
+ *
2158
+ * `head-end(<key>)` is DXTable's OWN additive header slot (#99) and is likewise
2159
+ * not forwarded: the inner table has no such slot, and the prefixes below are
2160
+ * matched with `startsWith`, so it must not begin with `cell(`/`foot(` either —
2161
+ * `head-end(` satisfies both. It is also excluded from `tableSlotSignature`
2162
+ * (which filters on `isTableSlot`), so adding one never remounts the table.
1959
2163
  */
1960
2164
  const TABLE_SLOT_PREFIXES = ['cell(', 'foot('];
1961
2165
  const TABLE_SLOT_NAMES = new Set([
@@ -208,8 +208,13 @@ interface Props {
208
208
  pendingAction: 'save' | 'delete' | null;
209
209
  /** Delete endpoint — gates the Delete button. */
210
210
  deleteUrl?: string;
211
- /** Field layout forwarded to the modal's DXForm (defaults handled upstream). */
212
- layout?: 'vertical' | 'horizontal';
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';
213
218
  /** Label column width forwarded to DXForm (horizontal layout). */
214
219
  labelCols?: LabelCols;
215
220
  /** Wrap the modal form in a card (opt-in — off by default). */