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.
- package/dist/sprintify-ui.es.js +8451 -8388
- package/dist/types/components/BaseDataIterator.vue.d.ts +11 -21
- package/dist/types/components/BaseDataIteratorSectionBox.vue.d.ts +2 -2
- package/dist/types/components/BaseDataTable.vue.d.ts +1 -7
- package/dist/types/composables/sectionCount.d.ts +11 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/utils/index.d.ts +2 -1
- package/dist/types/utils/isEmpty.d.ts +24 -0
- package/package.json +1 -1
- package/src/components/BaseDataIterator.mdx +152 -0
- package/src/components/BaseDataIterator.stories.js +123 -0
- package/src/components/BaseDataIterator.vue +100 -4
- package/src/components/BaseDataIteratorSectionBox.vue +27 -3
- package/src/components/BaseDataIteratorSectionButton.vue +12 -9
- package/src/composables/sectionCount.ts +18 -0
- package/src/lang/en.json +1 -0
- package/src/lang/fr.json +1 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/isEmpty.ts +54 -0
|
@@ -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
|
|
5
|
+
declare var __VLS_13: {};
|
|
6
6
|
type __VLS_Slots = {} & {
|
|
7
|
-
default?: (props: typeof
|
|
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
|
+
};
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -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="
|
|
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="
|
|
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="
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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>
|