sprintify-ui 0.12.13 → 0.12.15

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.
@@ -118,6 +118,8 @@ declare const _default: __VLS_WithSlots<import("vue").DefineComponent<import("vu
118
118
  filter?: Record<string, any> | undefined;
119
119
  } & Record<string, any>>;
120
120
  data: import("vue").ComputedRef<ResourceCollection | Collection | PaginatedCollection | null | undefined>;
121
+ activeFilterCount: import("vue").ComputedRef<number>;
122
+ hasActiveFilters: import("vue").ComputedRef<boolean>;
121
123
  }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
122
124
  click: (...args: any[]) => void;
123
125
  delete: (...args: any[]) => void;
@@ -255,42 +257,30 @@ declare const _default: __VLS_WithSlots<import("vue").DefineComponent<import("vu
255
257
  perPage: number;
256
258
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>, {
257
259
  [x: string]: ((props: {
258
- query: {
259
- [x: string]: any;
260
- page?: number | undefined;
261
- sort?: string | undefined;
262
- search?: string | undefined;
263
- filter?: Record<string, any> | undefined;
264
- };
260
+ query: DataTableQuery;
265
261
  updateQuery: (newQuery: DataTableQuery) => void;
266
262
  updateQueryValue: (key: string | DataTableQuery, value?: any) => void;
267
263
  updateQueryValueDebounce: import("lodash").DebouncedFunc<(key: string, value: any) => void>;
264
+ activeFilterCount: number;
265
+ hasActiveFilters: boolean;
268
266
  }) => any) | undefined;
269
267
  } & {
270
268
  [x: string]: ((props: {
271
- query: {
272
- [x: string]: any;
273
- page?: number | undefined;
274
- sort?: string | undefined;
275
- search?: string | undefined;
276
- filter?: Record<string, any> | undefined;
277
- };
269
+ query: DataTableQuery;
278
270
  updateQuery: (newQuery: DataTableQuery) => void;
279
271
  updateQueryValue: (key: string | DataTableQuery, value?: any) => void;
280
272
  updateQueryValueDebounce: import("lodash").DebouncedFunc<(key: string, value: any) => void>;
273
+ activeFilterCount: number;
274
+ hasActiveFilters: boolean;
281
275
  }) => any) | undefined;
282
276
  } & {
283
277
  [x: string]: ((props: {
284
- query: {
285
- [x: string]: any;
286
- page?: number | undefined;
287
- sort?: string | undefined;
288
- search?: string | undefined;
289
- filter?: Record<string, any> | undefined;
290
- };
278
+ query: DataTableQuery;
291
279
  updateQuery: (newQuery: DataTableQuery) => void;
292
280
  updateQueryValue: (key: string | DataTableQuery, value?: any) => void;
293
281
  updateQueryValueDebounce: import("lodash").DebouncedFunc<(key: string, value: any) => void>;
282
+ activeFilterCount: number;
283
+ hasActiveFilters: boolean;
294
284
  }) => any) | undefined;
295
285
  } & {
296
286
  default?: (props: {
@@ -2,9 +2,9 @@ import { DataIteratorSection } from '@/types';
2
2
  type __VLS_Props = {
3
3
  section: DataIteratorSection;
4
4
  };
5
- declare var __VLS_10: {};
5
+ declare var __VLS_13: {};
6
6
  type __VLS_Slots = {} & {
7
- default?: (props: typeof __VLS_10) => any;
7
+ default?: (props: typeof __VLS_13) => any;
8
8
  };
9
9
  declare const __VLS_component: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
10
10
  declare const _default: __VLS_WithSlots<typeof __VLS_component, __VLS_Slots>;
@@ -25,13 +25,7 @@ declare var __VLS_28: {
25
25
  row: import("@/types").Row;
26
26
  index: number;
27
27
  }, __VLS_44: any, __VLS_50: {}, __VLS_55: {
28
- query: {
29
- [x: string]: any;
30
- page?: number | undefined;
31
- sort?: string | undefined;
32
- search?: string | undefined;
33
- filter?: Record<string, any> | undefined;
34
- };
28
+ query: DataTableQuery;
35
29
  updateQuery: (newQuery: DataTableQuery) => void;
36
30
  updateQueryValue: (key: string | DataTableQuery, value?: any) => void;
37
31
  updateQueryValueDebounce: import("lodash").DebouncedFunc<(key: string, value: any) => void>;
@@ -0,0 +1,11 @@
1
+ import { DataIteratorSection } from '@/types';
2
+ /**
3
+ * Shared active-filter badge state for the data-iterator section chrome (the
4
+ * box header and the compact funnel button). Each renders the count with its
5
+ * own markup, but the "is there a count to show?" test and the accessible
6
+ * plural label are identical — so they live here.
7
+ */
8
+ export declare function useSectionCount(getSection: () => DataIteratorSection): {
9
+ hasCount: import("vue").ComputedRef<boolean>;
10
+ activeFiltersLabel: import("vue").ComputedRef<string>;
11
+ };
@@ -101,6 +101,7 @@ declare const messages: {
101
101
  up_to_x: string;
102
102
  upload_failed: string;
103
103
  whoops: string;
104
+ x_active_filters: string;
104
105
  x_ago: string;
105
106
  x_rows_selected: string;
106
107
  year: string;
@@ -207,6 +208,7 @@ declare const messages: {
207
208
  up_to_x: string;
208
209
  upload_failed: string;
209
210
  whoops: string;
211
+ x_active_filters: string;
210
212
  x_ago: string;
211
213
  x_rows_selected: string;
212
214
  year: string;
@@ -6,4 +6,5 @@ import { disableScroll, enableScroll } from './scrollPreventer';
6
6
  import { blobToBase64, validateBase64, base64ToBlob } from './blob';
7
7
  import { getColorConfig } from './colors';
8
8
  import { getInitials, getAvatarColor } from './avatar';
9
- export { toHumanList, fileSizeFormat, disableScroll, enableScroll, resizeImageFromURI, resizeImageForUpload, blobToBase64, validateBase64, base64ToBlob, getColorConfig, getInitials, getAvatarColor, };
9
+ import { isEmpty } from './isEmpty';
10
+ export { toHumanList, fileSizeFormat, disableScroll, enableScroll, resizeImageFromURI, resizeImageForUpload, blobToBase64, validateBase64, base64ToBlob, getColorConfig, getInitials, getAvatarColor, isEmpty, };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Deep emptiness predicate — the default "is this filter active?" rule.
3
+ *
4
+ * A value counts as *empty* (i.e. not an active filter) when it carries no
5
+ * user intent. This is deliberately NOT lodash's `isEmpty`: here `0` and
6
+ * `false` are meaningful, exposed choices and therefore count as active.
7
+ *
8
+ * Reference table (WIT-159):
9
+ *
10
+ * | Value | Empty (does not count) |
11
+ * |------------------------------------|------------------------|
12
+ * | `undefined`, `null` | yes |
13
+ * | `''`, `' '` (trimmed) | yes |
14
+ * | `[]`, `[null]`, `['']` (deep) | yes |
15
+ * | `{}`, `{from:null,to:null}` (deep) | yes |
16
+ * | `0` | **no → counts** |
17
+ * | `false` | **no → counts** |
18
+ * | `'jedi'`, `5`, `['a']`, `Date` | no → counts |
19
+ *
20
+ * Arrays and plain objects are inspected recursively: a container is empty
21
+ * only when every leaf it holds is itself empty. This keeps a grouped filter
22
+ * (`{ from, to }`) counting as a single active filter when any leaf is set.
23
+ */
24
+ export declare function isEmpty(value: unknown): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprintify-ui",
3
- "version": "0.12.13",
3
+ "version": "0.12.15",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "generate-llm-txt": "node scripts/generate-llm-txt.js",
@@ -0,0 +1,152 @@
1
+ import { Canvas, Meta, Primary, Controls } from '@storybook/addon-docs/blocks';
2
+
3
+ import * as BaseDataIteratorStories from './BaseDataIterator.stories';
4
+
5
+ <Meta of={BaseDataIteratorStories} />
6
+
7
+ # BaseDataIterator
8
+
9
+ ## Introduction
10
+
11
+ `BaseDataIterator` fetches, filters, sorts and paginates a collection, and
12
+ renders it through slots. Filtering is driven entirely by the consumer: you
13
+ provide the filter controls in the `filters` slot and write into `query.filter`
14
+ through the slot's `updateQueryValue` helper.
15
+
16
+ <Primary />
17
+
18
+ ## Active filter counter
19
+
20
+ The component counts how many filters are currently active and surfaces that
21
+ count as a badge in each of the three filter positions. **This is automatic —
22
+ no configuration and no new props.** If you never touch filters, nothing about
23
+ your existing usage changes.
24
+
25
+ ### How a filter is counted
26
+
27
+ When the `filters` slot renders, every field it reads from `query.filter.*` is
28
+ **discovered by that read**. A discovered field counts as an *active* filter
29
+ when its value is **not empty**, according to a deep `isEmpty` predicate. The
30
+ count is a pure function of `discovered keys × current values`.
31
+
32
+ - **Scope.** Only `query.filter.*` participates. `search`, `sort` and `page`
33
+ are never counted.
34
+ - **Grouped filters count as one.** A grouped filter such as
35
+ `query.filter.range = { from, to }` is discovered as the single key `range`,
36
+ so it contributes **1** to the count, not one per leaf.
37
+ - **Route-hardcoded filters are never counted.** A filter that is fixed in the
38
+ route (e.g. via `urlQuery`) and never rendered as a control in the `filters`
39
+ slot is never read, therefore never discovered, therefore never counted — no
40
+ config needed to exclude it.
41
+
42
+ ### What "empty" means
43
+
44
+ `isEmpty` is deliberately **not** lodash's `isEmpty`: here `0` and `false` are
45
+ real, user-made choices and therefore **count** as active filters.
46
+
47
+ | Value | Empty? (counts as active?) |
48
+ | --------------------------------------- | -------------------------- |
49
+ | `undefined`, `null` | empty — does not count |
50
+ | `''`, `' '` (trimmed) | empty — does not count |
51
+ | `[]`, `[null]`, `['']` (deep) | empty — does not count |
52
+ | `{}`, `{ from: null, to: null }` (deep) | empty — does not count |
53
+ | `0` | **not empty — counts** |
54
+ | `false` | **not empty — counts** |
55
+ | `'jedi'`, `5`, `['a']`, a `Date` | not empty — counts |
56
+
57
+ `isEmpty` is also exported from the package if you want to reuse the exact same
58
+ rule in your own code:
59
+
60
+ ```ts
61
+ import { isEmpty } from 'sprintify-ui';
62
+ ```
63
+
64
+ ### The consumer contract: "not filtered = empty value"
65
+
66
+ There is **no per-filter override API**. Because the count is derived purely
67
+ from values, the way to make a field *not* count is to model "no filter" as an
68
+ empty value **at the source**. Two common cases:
69
+
70
+ **1. An "all" sentinel.** Don't store a magic string like `'all'` — it is a
71
+ non-empty string, so it would count. Write `undefined` (or omit the key)
72
+ instead.
73
+
74
+ ```ts
75
+ // ❌ Before — 'all' is a non-empty string → counts as an active filter
76
+ updateQueryValue('filter.status', 'all');
77
+
78
+ // ✅ After — clearing the filter means removing the value
79
+ updateQueryValue('filter.status', undefined);
80
+ ```
81
+
82
+ ```vue
83
+ <!-- Model the "All" option as an empty value in the control itself -->
84
+ <BaseSelect
85
+ :model-value="query.filter.status ?? null"
86
+ placeholder="All"
87
+ @update:model-value="updateQueryValue('filter.status', $event)"
88
+ >
89
+ <option value="open">Open</option>
90
+ <option value="closed">Closed</option>
91
+ </BaseSelect>
92
+ ```
93
+
94
+ **2. A checkbox you don't want counted when off.** Remember `false` counts.
95
+ Store `undefined` when the box is unchecked rather than `false`.
96
+
97
+ ```ts
98
+ // ❌ Before — `false` is a real choice ("exclude archived") → it counts
99
+ updateQueryValue('filter.archived', event.target.checked); // false when off
100
+
101
+ // ✅ After — "off" means "no filter" → store undefined when unchecked
102
+ updateQueryValue('filter.archived', event.target.checked ? true : undefined);
103
+ ```
104
+
105
+ Keep `false`/`0` **only** when the off/zero state is a genuine filter the user
106
+ should see reflected in the count.
107
+
108
+ ### Reading the count
109
+
110
+ The count is exposed on three channels — pick whichever fits:
111
+
112
+ | Channel | Shape | Use it for |
113
+ | ------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------- |
114
+ | Slot scope | `#filters="{ activeFilterCount, hasActiveFilters }"` | Rendering your own badge next to the filter controls |
115
+ | `defineExpose` | `iteratorRef.value.activeFilterCount` / `.hasActiveFilters` | Reading the count programmatically from the parent |
116
+ | `provide` / `inject` | `inject('dataIterator:activeFilterCount')` / `':hasActiveFilters'` | Deeply nested chrome that renders the built-in badge |
117
+
118
+ The built-in badge in the three filter positions already consumes this — you
119
+ only need these channels for custom rendering.
120
+
121
+ ### Positions and visual treatment
122
+
123
+ The badge is consistent across the three positions. **0 filters** → neutral
124
+ control, no chip. **≥ 1 filter** → the control fills blue and a chip shows the
125
+ exact count. There is intentionally no light-blue header background.
126
+
127
+ Position 1 — `top` inline box header:
128
+
129
+ <Canvas of={BaseDataIteratorStories.FilterCountTop} />
130
+
131
+ Position 2 — `sidebar` box header (default layout):
132
+
133
+ <Canvas of={BaseDataIteratorStories.FilterCountSidebar} />
134
+
135
+ Position 3 — compact funnel button + drawer:
136
+
137
+ <Canvas of={BaseDataIteratorStories.FilterCountCompact} />
138
+
139
+ ### Backward compatibility
140
+
141
+ - **No new prop, no forced migration.** Discovery is fully transparent; existing
142
+ `query.filter.x` reads and `updateQueryValue('filter.x', …)` writes behave
143
+ exactly as before.
144
+ - **Non-`filter` query roots are untouched.** They pass through the slot as
145
+ before — readable and writable — they are simply not counted.
146
+ - **Nothing to opt into.** With no filters, or with filters modelled as above,
147
+ the counter stays at `0` and the controls render exactly as they did before
148
+ this feature existed.
149
+
150
+ ## Props
151
+
152
+ <Controls />
@@ -290,3 +290,126 @@ Local.args = {
290
290
  actions: [],
291
291
  perPage: 4,
292
292
  };
293
+
294
+ /*
295
+ | Active filter counter (WIT-169) — demonstrated in the 3 filter positions.
296
+ |
297
+ | The counter is fully automatic: any field read from `query.filter.*` in the
298
+ | filters slot is discovered and, when it carries a non-empty value (isEmpty —
299
+ | note `0` counts as a real choice), adds to the count. Zero filters → neutral
300
+ | control, no chip. One or more → filled-blue control + exact-count chip.
301
+ */
302
+
303
+ const FilterCountTemplate = (args) => ({
304
+ components: {
305
+ BaseDataIterator,
306
+ BaseCard,
307
+ BaseCardRow,
308
+ BaseLoadingCover,
309
+ BaseSelect,
310
+ },
311
+ setup() {
312
+ const iteratorRef = ref(null);
313
+
314
+ const filteredItems = computed(() => {
315
+ const filter = iteratorRef.value?.query?.filter ?? {};
316
+
317
+ return options.filter((item) => {
318
+ if (filter.type && item.type !== filter.type) {
319
+ return false;
320
+ }
321
+ if (filter.min_size != null && item.size < Number(filter.min_size)) {
322
+ return false;
323
+ }
324
+ return true;
325
+ });
326
+ });
327
+
328
+ return { args, filteredItems, iteratorRef };
329
+ },
330
+ template: `
331
+ <BaseDataIterator ref="iteratorRef" v-bind="args" :items="filteredItems">
332
+ <template #default="{ items }">
333
+ <div class="space-y-1.5">
334
+ <BaseCard v-for="item in items" :key="item.value">
335
+ <BaseCardRow size="xs">
336
+ <div class="font-medium text-slate-900">{{ item.label }}</div>
337
+ <p class="text-xs leading-tight text-slate-500">{{ item.type }} · size {{ item.size }}</p>
338
+ </BaseCardRow>
339
+ </BaseCard>
340
+ </div>
341
+ </template>
342
+
343
+ <template #filters="{ query, updateQueryValue, activeFilterCount, hasActiveFilters }">
344
+ <div class="space-y-3">
345
+ <p class="rounded bg-slate-100 px-2 py-1 text-xs text-slate-500">
346
+ activeFilterCount = <b>{{ activeFilterCount }}</b> · hasActiveFilters = <b>{{ hasActiveFilters }}</b>
347
+ </p>
348
+ <div>
349
+ <p class="mb-1 text-sm">Type</p>
350
+ <BaseSelect
351
+ :model-value="query.filter.type ?? null"
352
+ class="w-full rounded border-slate-300"
353
+ placeholder="-"
354
+ @update:model-value="updateQueryValue('filter.type', $event)"
355
+ >
356
+ <option value="jedi">Jedi</option>
357
+ <option value="sith">Sith</option>
358
+ </BaseSelect>
359
+ </div>
360
+ <div>
361
+ <p class="mb-1 text-sm">Minimum size</p>
362
+ <BaseSelect
363
+ :model-value="query.filter.min_size ?? null"
364
+ class="w-full rounded border-slate-300"
365
+ placeholder="-"
366
+ @update:model-value="updateQueryValue('filter.min_size', $event)"
367
+ >
368
+ <option value="0">0 (counts — 0 is a real choice)</option>
369
+ <option value="5">5</option>
370
+ <option value="20">20</option>
371
+ </BaseSelect>
372
+ </div>
373
+ </div>
374
+ </template>
375
+ </BaseDataIterator>
376
+ `,
377
+ });
378
+
379
+ // Position 1 — "top" inline box header.
380
+ export const FilterCountTop = FilterCountTemplate.bind({});
381
+ FilterCountTop.args = {
382
+ url: null,
383
+ actions: [],
384
+ searchable: false,
385
+ perPage: 5,
386
+ filtersPosition: 'top',
387
+ };
388
+
389
+ // Position 2 — "sidebar" box header (default layout).
390
+ // The sidebar only renders above 1024px (below that the layout auto-collapses
391
+ // to the compact funnel button), so force a wide container to demonstrate it
392
+ // reliably even inside a narrow docs <Canvas>.
393
+ export const FilterCountSidebar = FilterCountTemplate.bind({});
394
+ FilterCountSidebar.args = {
395
+ url: null,
396
+ actions: [],
397
+ searchable: false,
398
+ perPage: 5,
399
+ filtersPosition: 'section',
400
+ layout: 'default',
401
+ };
402
+ FilterCountSidebar.decorators = [
403
+ () => ({ template: '<div style="width: 1080px; max-width: none"><story/></div>' }),
404
+ ];
405
+
406
+ // Position 3 — compact funnel button + drawer.
407
+ export const FilterCountCompact = FilterCountTemplate.bind({});
408
+ FilterCountCompact.args = {
409
+ url: null,
410
+ actions: [],
411
+ searchable: false,
412
+ perPage: 5,
413
+ filtersPosition: 'section',
414
+ layout: 'compact',
415
+ };
@@ -77,10 +77,12 @@
77
77
  <BaseDataIteratorSectionBox :section="sectionFilter">
78
78
  <slot
79
79
  :name="sectionFilter.name"
80
- :query="query"
80
+ :query="filterQuery"
81
81
  :update-query="updateFilterQuery"
82
82
  :update-query-value="updateFilterQueryValue"
83
83
  :update-query-value-debounce="updateFilterQueryValueDebounce"
84
+ :active-filter-count="activeFilterCount"
85
+ :has-active-filters="hasActiveFilters"
84
86
  />
85
87
  </BaseDataIteratorSectionBox>
86
88
  </div>
@@ -145,10 +147,12 @@
145
147
  <BaseDataIteratorSectionBox :section="section">
146
148
  <slot
147
149
  :name="section.name"
148
- :query="query"
150
+ :query="filterQuery"
149
151
  :update-query="updateFilterQuery"
150
152
  :update-query-value="updateFilterQueryValue"
151
153
  :update-query-value-debounce="updateFilterQueryValueDebounce"
154
+ :active-filter-count="activeFilterCount"
155
+ :has-active-filters="hasActiveFilters"
152
156
  />
153
157
  </BaseDataIteratorSectionBox>
154
158
  </template>
@@ -171,10 +175,12 @@
171
175
  >
172
176
  <slot
173
177
  :name="section.name"
174
- :query="query"
178
+ :query="filterQuery"
175
179
  :update-query="updateFilterQuery"
176
180
  :update-query-value="updateFilterQueryValue"
177
181
  :update-query-value-debounce="updateFilterQueryValueDebounce"
182
+ :active-filter-count="activeFilterCount"
183
+ :has-active-filters="hasActiveFilters"
178
184
  />
179
185
  </BaseDataIteratorSectionModal>
180
186
  </template>
@@ -189,6 +195,7 @@ import { Size } from '@/utils/sizes';
189
195
  import BaseInput from './BaseInput.vue';
190
196
  import { useInputSize } from '@/composables/inputSize';
191
197
  import { deepIncludes } from '@/utils/deepIncludes';
198
+ import { isEmpty } from '@/utils/isEmpty';
192
199
 
193
200
  const DEFAULT_QUERY = {
194
201
  page: 1,
@@ -523,6 +530,90 @@ function queryHash(query: DataTableQuery): string {
523
530
  return hash(query);
524
531
  }
525
532
 
533
+ /*
534
+ |--------------------------------------------------------------------------
535
+ | Active filter discovery + count
536
+ |--------------------------------------------------------------------------
537
+ |
538
+ | "Active" = a filter field *discovered by being read through the slot* (via a
539
+ | Proxy on query.filter) that carries a non-empty value (isEmpty, WIT-159). A
540
+ | route-hardcoded filter is never rendered in the slot → never read → never
541
+ | discovered → never counted, which is how modifiable filters are told apart
542
+ | from non-modifiable ones without any config (WIT-159 / WIT-161).
543
+ */
544
+
545
+ // Single internal source of truth for which query roots get wrapped for
546
+ // discovery. Defaults to `filter` only; widening the scope (WIT-160) means
547
+ // touching only this function.
548
+ function getDiscoverableRoots(): string[] {
549
+ return ['filter'];
550
+ }
551
+
552
+ // Non-reactive registry filled by the get-trap during render. Mutating a plain
553
+ // Set notifies no effect → no "mutation during render" warning, no loop.
554
+ const discoveredKeys = new Set<string>();
555
+
556
+ // Reactive snapshot of the discovered keys, promoted once per new key outside
557
+ // of render. The count then reacts only to *values* over a stable key set.
558
+ const modifiableKeys = ref<Set<string>>(new Set());
559
+
560
+ let promotionScheduled = false;
561
+
562
+ // Promote discoveredKeys → modifiableKeys on nextTick, deduplicated. Triggered
563
+ // on every newly discovered key, so it self-heals: it covers the initial mount
564
+ // and a control rendered late behind a v-if alike. A discovered key is never
565
+ // removed — a filter whose control disappears but whose value persists keeps
566
+ // counting.
567
+ function schedulePromotion() {
568
+ if (promotionScheduled) {
569
+ return;
570
+ }
571
+ promotionScheduled = true;
572
+ nextTick(() => {
573
+ promotionScheduled = false;
574
+ modifiableKeys.value = new Set(discoveredKeys);
575
+ });
576
+ }
577
+
578
+ function createDiscoveryProxy(target: Record<string, any>): Record<string, any> {
579
+ return new Proxy(target, {
580
+ get(obj, key, receiver) {
581
+ if (typeof key === 'string' && !discoveredKeys.has(key)) {
582
+ discoveredKeys.add(key);
583
+ schedulePromotion();
584
+ }
585
+ // Reflect.get delegates to the reactive object → reactive tracking of the
586
+ // read is preserved for the consumer's own dependencies.
587
+ return Reflect.get(obj, key, receiver);
588
+ },
589
+ });
590
+ }
591
+
592
+ // Slot-facing query wrapper. Memoized in a computed keyed on `query`, so the
593
+ // Proxy identity only churns when the query actually changes. Non-discoverable
594
+ // roots are spread as-is → existing reads/writes stay identical, just uncounted.
595
+ const filterQuery = computed<DataTableQuery>(() => {
596
+ const raw = query.value;
597
+
598
+ const wrapped: Record<string, any> = { ...raw };
599
+ for (const root of getDiscoverableRoots()) {
600
+ if (raw[root] && typeof raw[root] === 'object') {
601
+ wrapped[root] = createDiscoveryProxy(raw[root]);
602
+ }
603
+ }
604
+
605
+ return wrapped as DataTableQuery;
606
+ });
607
+
608
+ // Pure computeds reading the RAW reactive source (never through the Proxy, to
609
+ // avoid circular re-discovery). Grouped filter = one discovered key = 1.
610
+ const activeFilterCount = computed<number>(() => {
611
+ const filter = query.value.filter ?? {};
612
+ return [...modifiableKeys.value].filter((key) => !isEmpty(filter[key])).length;
613
+ });
614
+
615
+ const hasActiveFilters = computed<boolean>(() => activeFilterCount.value > 0);
616
+
526
617
  /*
527
618
  |--------------------------------------------------------------------------
528
619
  | Data fetching
@@ -926,13 +1017,14 @@ onMounted(() => {
926
1017
 
927
1018
  const sectionModalActive = ref<null | number>(null);
928
1019
 
929
- const sectionFilter = computed(() => {
1020
+ const sectionFilter = computed<DataIteratorSection>(() => {
930
1021
  return {
931
1022
  name: 'filters',
932
1023
  title: t('sui.filters'),
933
1024
  closeText: t('sui.apply_filters'),
934
1025
  icon: 'heroicons:funnel',
935
1026
  opened: true,
1027
+ count: activeFilterCount.value,
936
1028
  };
937
1029
  });
938
1030
 
@@ -968,6 +1060,8 @@ function closeSection() {
968
1060
  */
969
1061
 
970
1062
  provide('dataIterator:width', width);
1063
+ provide('dataIterator:activeFilterCount', activeFilterCount);
1064
+ provide('dataIterator:hasActiveFilters', hasActiveFilters);
971
1065
 
972
1066
  /*
973
1067
  |--------------------------------------------------------------------------
@@ -982,5 +1076,7 @@ defineExpose({
982
1076
  scrollIntoView: scrollIntoViewAction,
983
1077
  query: computed(() => fullQuery.value),
984
1078
  data: computed(() => dataInternal.value),
1079
+ activeFilterCount,
1080
+ hasActiveFilters,
985
1081
  });
986
1082
  </script>
@@ -5,9 +5,29 @@
5
5
  class="flex w-full items-center justify-between px-4 py-3"
6
6
  @click="active = !active"
7
7
  >
8
- <h2 class="text-sm font-semibold">
9
- {{ section.title }}
10
- </h2>
8
+ <!--
9
+ Variant C — filled active state on the box header. Emphasis is the blue
10
+ title + solid blue count chip; deliberately NO light-blue header
11
+ background (bg-blue-50 was rejected in WIT-162).
12
+ -->
13
+ <div class="flex items-center">
14
+ <h2
15
+ class="text-sm font-semibold"
16
+ :class="hasCount ? 'text-blue-700' : ''"
17
+ >
18
+ {{ section.title }}
19
+ </h2>
20
+ <BaseBadge
21
+ v-if="hasCount"
22
+ class="ml-2"
23
+ color="blue"
24
+ contrast="high"
25
+ size="sm"
26
+ >
27
+ <span aria-hidden="true">{{ section.count }}</span>
28
+ <span class="sr-only">{{ activeFiltersLabel }}</span>
29
+ </BaseBadge>
30
+ </div>
11
31
 
12
32
  <BaseIcon
13
33
  class="h-5 w-5 text-slate-500 duration-300"
@@ -27,10 +47,14 @@
27
47
  <script lang="ts" setup>
28
48
  import { DataIteratorSection } from '@/types';
29
49
  import { BaseCard, BaseIcon } from '.';
50
+ import BaseBadge from './BaseBadge.vue';
51
+ import { useSectionCount } from '@/composables/sectionCount';
30
52
 
31
53
  const props = defineProps<{
32
54
  section: DataIteratorSection;
33
55
  }>();
34
56
 
35
57
  const active = ref<boolean>(props.section.opened ?? false);
58
+
59
+ const { hasCount, activeFiltersLabel } = useSectionCount(() => props.section);
36
60
  </script>