@netlify/spark-ui 1.26.0-alpha.7 → 1.26.0-alpha.8
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/components/FilterableGrid/filter-types.d.ts +40 -0
- package/dist/components/FilterableGrid/index.d.ts +2 -0
- package/package.json +4 -1
- package/packages/components/Button.astro +3 -0
- package/packages/components/FilterableGrid/FilterableGrid.astro +403 -0
- package/packages/components/FilterableGrid/filter-types.ts +48 -0
- package/packages/components/FilterableGrid/index.ts +2 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface FilterOption {
|
|
2
|
+
value: string;
|
|
3
|
+
label: string;
|
|
4
|
+
}
|
|
5
|
+
export interface FilterCategory {
|
|
6
|
+
key: string;
|
|
7
|
+
label: string;
|
|
8
|
+
legend?: string;
|
|
9
|
+
control?: 'checkbox' | 'radio';
|
|
10
|
+
/** Radio-only: prepend a checked `value=""` reset row. */
|
|
11
|
+
anyOption?: {
|
|
12
|
+
label: string;
|
|
13
|
+
};
|
|
14
|
+
matchMode?: 'any' | 'all';
|
|
15
|
+
options: FilterOption[];
|
|
16
|
+
}
|
|
17
|
+
export interface FilterableGridProps {
|
|
18
|
+
filters: FilterCategory[];
|
|
19
|
+
results: {
|
|
20
|
+
singular: string;
|
|
21
|
+
plural: string;
|
|
22
|
+
};
|
|
23
|
+
/** Server-rendered initial count, shown before any client JS runs. */
|
|
24
|
+
totalCount: number;
|
|
25
|
+
/** Global match mode; per-category `matchMode` overrides it for that category. */
|
|
26
|
+
matchMode: 'any' | 'all';
|
|
27
|
+
id?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Builds the `data-filter-{key}` attributes a slotted grid item needs so
|
|
31
|
+
* `<filter-container>` can match it against the active facets. Spread the
|
|
32
|
+
* result onto whatever bespoke element you render inside <FilterableGrid>.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* <article {...getFilterAttrs({ topic: ['react', 'astro'], level: 'beginner' })}>
|
|
36
|
+
* ...
|
|
37
|
+
* </article>
|
|
38
|
+
* // -> data-filter-topic="react,astro" data-filter-level="beginner"
|
|
39
|
+
*/
|
|
40
|
+
export declare function getFilterAttrs(values: Record<string, string | string[]>, delimiter?: string): Record<`data-filter-${string}`, string>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@netlify/spark-ui",
|
|
3
3
|
"description": "Assets, design tokens, components, and utilities",
|
|
4
|
-
"version": "1.26.0-alpha.
|
|
4
|
+
"version": "1.26.0-alpha.8",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/components/preact/index.js",
|
|
7
7
|
"types": "dist/components/preact/index.d.ts",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"./styles/*.css": "./packages/styles/*.css",
|
|
32
32
|
"./assets/*": "./packages/assets/*",
|
|
33
33
|
"./components": "./packages/components/index.ts",
|
|
34
|
+
"./components/FilterableGrid": "./packages/components/FilterableGrid/index.ts",
|
|
34
35
|
"./components/*": "./packages/components/*",
|
|
35
36
|
"./utils": "./packages/utils/index.ts",
|
|
36
37
|
"./analytics": "./packages/analytics/index.ts"
|
|
@@ -39,6 +40,8 @@
|
|
|
39
40
|
"@astro-community/astro-embed-youtube": "^0.5.10",
|
|
40
41
|
"@kapaai/react-sdk": "^0.9.0",
|
|
41
42
|
"@shikijs/transformers": "^3.3.0",
|
|
43
|
+
"@zachleat/details-utils": "^2.0.2",
|
|
44
|
+
"@zachleat/filter-container": "^4.0.0",
|
|
42
45
|
"fflate": "^0.8.2",
|
|
43
46
|
"marked": "^15.0.11",
|
|
44
47
|
"rusha": "^0.8.14"
|
|
@@ -8,6 +8,7 @@ interface Props {
|
|
|
8
8
|
target?: string;
|
|
9
9
|
variant?: BUTTON_VARIANTS;
|
|
10
10
|
href?: string;
|
|
11
|
+
type?: 'button' | 'submit' | 'reset';
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
const {
|
|
@@ -16,6 +17,7 @@ const {
|
|
|
16
17
|
variant = 'primary',
|
|
17
18
|
href,
|
|
18
19
|
target = !href?.includes(Astro.url.origin) ? '_blank' : undefined,
|
|
20
|
+
type,
|
|
19
21
|
...rest
|
|
20
22
|
} = Astro.props;
|
|
21
23
|
|
|
@@ -28,6 +30,7 @@ const Element = href ? 'a' : 'button';
|
|
|
28
30
|
data-button-variant={variant}
|
|
29
31
|
{href}
|
|
30
32
|
{target}
|
|
33
|
+
type={!href ? type : undefined}
|
|
31
34
|
{...rest}
|
|
32
35
|
>
|
|
33
36
|
<slot />
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { FilterableGridProps } from './filter-types';
|
|
3
|
+
import Button from '../Button.astro';
|
|
4
|
+
|
|
5
|
+
const { filters, results, totalCount, matchMode, id } = Astro.props as FilterableGridProps;
|
|
6
|
+
|
|
7
|
+
const filterModeAttrs = Object.fromEntries(
|
|
8
|
+
filters
|
|
9
|
+
.filter(category => category.matchMode)
|
|
10
|
+
.map(category => [`filter-mode-${category.key}`, category.matchMode])
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const initialResultsLabel = `${totalCount} ${totalCount === 1 ? results.singular : results.plural}`;
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<div id={id} data-filter-root>
|
|
17
|
+
{
|
|
18
|
+
filters.length > 0 && (
|
|
19
|
+
<filter-container oninit filter-mode={matchMode} {...filterModeAttrs}>
|
|
20
|
+
<form class="n-filter-form" data-filter-form>
|
|
21
|
+
<details-utils
|
|
22
|
+
class="n-filter-dropdowns"
|
|
23
|
+
force-close="(min-width: 37.5em)"
|
|
24
|
+
force-restore
|
|
25
|
+
close-click-outside="(min-width: 37.5em)"
|
|
26
|
+
animate
|
|
27
|
+
>
|
|
28
|
+
{filters.map(category => (
|
|
29
|
+
<details class="n-filter-facet">
|
|
30
|
+
<summary>
|
|
31
|
+
<span class="n-filter-label">
|
|
32
|
+
{category.label}
|
|
33
|
+
<span class="n-filter-count" data-filter-count-badge data-filter-category={category.key}>
|
|
34
|
+
0
|
|
35
|
+
</span>
|
|
36
|
+
</span>
|
|
37
|
+
<svg
|
|
38
|
+
class="n-filter-chevron-icon"
|
|
39
|
+
viewBox="0 0 512 512"
|
|
40
|
+
fill="none"
|
|
41
|
+
role="presentation"
|
|
42
|
+
>
|
|
43
|
+
<path
|
|
44
|
+
d="M233.4 406.6c12.5 12.5 32.8 12.5 45.3 0l192-192c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 338.7 86.6 169.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l192 192z"
|
|
45
|
+
fill="currentcolor"
|
|
46
|
+
/>
|
|
47
|
+
</svg>
|
|
48
|
+
</summary>
|
|
49
|
+
<fieldset class="n-filter-menu">
|
|
50
|
+
<legend class="n-filter-legend">{category.legend ?? `${category.label}:`}</legend>
|
|
51
|
+
{category.control === 'radio' && category.anyOption && (
|
|
52
|
+
<label>
|
|
53
|
+
<input
|
|
54
|
+
type="radio"
|
|
55
|
+
name={category.key}
|
|
56
|
+
value=""
|
|
57
|
+
data-filter-key={category.key}
|
|
58
|
+
checked
|
|
59
|
+
/>
|
|
60
|
+
{category.anyOption.label}
|
|
61
|
+
</label>
|
|
62
|
+
)}
|
|
63
|
+
{category.options.map(option => (
|
|
64
|
+
<label>
|
|
65
|
+
<input
|
|
66
|
+
type={category.control === 'radio' ? 'radio' : 'checkbox'}
|
|
67
|
+
name={category.key}
|
|
68
|
+
value={option.value}
|
|
69
|
+
data-filter-key={category.key}
|
|
70
|
+
/>
|
|
71
|
+
{option.label}
|
|
72
|
+
</label>
|
|
73
|
+
))}
|
|
74
|
+
</fieldset>
|
|
75
|
+
</details>
|
|
76
|
+
))}
|
|
77
|
+
</details-utils>
|
|
78
|
+
</form>
|
|
79
|
+
|
|
80
|
+
<div class="n-filter-meta">
|
|
81
|
+
<p
|
|
82
|
+
class="n-filter-results"
|
|
83
|
+
data-filter-results={`${results.singular}/${results.plural}`}
|
|
84
|
+
aria-live="polite"
|
|
85
|
+
>
|
|
86
|
+
{initialResultsLabel}
|
|
87
|
+
</p>
|
|
88
|
+
<ul class="n-filter-tags" role="list" data-filter-tags>
|
|
89
|
+
{filters.flatMap(category =>
|
|
90
|
+
category.options.map(option => (
|
|
91
|
+
<li
|
|
92
|
+
class="n-filter-tag"
|
|
93
|
+
data-tag-key={category.key}
|
|
94
|
+
data-tag-value={option.value}
|
|
95
|
+
hidden
|
|
96
|
+
>
|
|
97
|
+
<Button
|
|
98
|
+
type="button"
|
|
99
|
+
variant="outline"
|
|
100
|
+
data-tag-key={category.key}
|
|
101
|
+
data-tag-value={option.value}
|
|
102
|
+
>
|
|
103
|
+
{option.label}
|
|
104
|
+
<svg class="n-close-icon" viewBox="0 0 512 512" fill="none" role="presentation">
|
|
105
|
+
<path
|
|
106
|
+
d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3l105.4 105.3c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256z"
|
|
107
|
+
fill="currentcolor"
|
|
108
|
+
/>
|
|
109
|
+
</svg>
|
|
110
|
+
</Button>
|
|
111
|
+
</li>
|
|
112
|
+
))
|
|
113
|
+
)}
|
|
114
|
+
</ul>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<slot />
|
|
118
|
+
</filter-container>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
{filters.length === 0 && <slot />}
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<script src="@zachleat/filter-container/filter-container.js"></script>
|
|
125
|
+
<script src="@zachleat/details-utils/details-utils.js"></script>
|
|
126
|
+
<script>
|
|
127
|
+
function initFilterableGrid(root: HTMLElement): void {
|
|
128
|
+
const form = root.querySelector<HTMLFormElement>('[data-filter-form]');
|
|
129
|
+
if (!form) return;
|
|
130
|
+
|
|
131
|
+
const tagsList = root.querySelector<HTMLUListElement>('[data-filter-tags]');
|
|
132
|
+
|
|
133
|
+
const updateCount = (key: string) => {
|
|
134
|
+
const count = Array.from(
|
|
135
|
+
form.querySelectorAll<HTMLInputElement>(`[data-filter-key="${CSS.escape(key)}"]:checked`)
|
|
136
|
+
).filter(entry => !!entry.value).length;
|
|
137
|
+
const counter = root.querySelector<HTMLElement>(
|
|
138
|
+
`[data-filter-count-badge][data-filter-category="${CSS.escape(key)}"]`
|
|
139
|
+
);
|
|
140
|
+
if (counter) {
|
|
141
|
+
counter.textContent = String(count);
|
|
142
|
+
counter.classList.toggle('is-visible', count > 0);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const showChip = (chipLi: HTMLLIElement) => {
|
|
147
|
+
if (!tagsList) return;
|
|
148
|
+
chipLi.hidden = false;
|
|
149
|
+
tagsList.appendChild(chipLi);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const hideChip = (chipLi: HTMLLIElement) => {
|
|
153
|
+
chipLi.hidden = true;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const findChip = (key: string, value: string): HTMLLIElement | null =>
|
|
157
|
+
tagsList?.querySelector<HTMLLIElement>(
|
|
158
|
+
`li[data-tag-key="${CSS.escape(key)}"][data-tag-value="${CSS.escape(value)}"]`
|
|
159
|
+
) ?? null;
|
|
160
|
+
|
|
161
|
+
const onCheckboxInput = (key: string, value: string, checked: boolean) => {
|
|
162
|
+
const chip = findChip(key, value);
|
|
163
|
+
if (!chip) return;
|
|
164
|
+
if (checked) showChip(chip);
|
|
165
|
+
else hideChip(chip);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const onRadioInput = (key: string, value: string) => {
|
|
169
|
+
if (!tagsList) return;
|
|
170
|
+
tagsList
|
|
171
|
+
.querySelectorAll<HTMLLIElement>(`li[data-tag-key="${CSS.escape(key)}"]`)
|
|
172
|
+
.forEach(chip => {
|
|
173
|
+
if (value && chip.dataset.tagValue === value) showChip(chip);
|
|
174
|
+
else hideChip(chip);
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const onTagInput = (input: HTMLInputElement) => {
|
|
179
|
+
if (!tagsList) return;
|
|
180
|
+
const key = input.dataset.filterKey;
|
|
181
|
+
if (!key) return;
|
|
182
|
+
if (input.type === 'radio') onRadioInput(key, input.value);
|
|
183
|
+
else onCheckboxInput(key, input.value, input.checked);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
form.addEventListener('input', event => {
|
|
187
|
+
if (!(event.target instanceof HTMLInputElement)) return;
|
|
188
|
+
const key = event.target.dataset.filterKey;
|
|
189
|
+
if (key) updateCount(key);
|
|
190
|
+
if (tagsList) onTagInput(event.target);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
tagsList?.addEventListener('click', event => {
|
|
194
|
+
const button = (event.target as HTMLElement).closest('button');
|
|
195
|
+
if (!button || !tagsList.contains(button)) return;
|
|
196
|
+
const key = button.dataset.tagKey;
|
|
197
|
+
const value = button.dataset.tagValue;
|
|
198
|
+
if (!key || value === undefined) return;
|
|
199
|
+
const input = form.querySelector<HTMLInputElement>(
|
|
200
|
+
`input[name="${CSS.escape(key)}"][value="${CSS.escape(value)}"]`
|
|
201
|
+
);
|
|
202
|
+
if (!input) return;
|
|
203
|
+
if (input.type === 'radio') {
|
|
204
|
+
const resetRadio = form.querySelector<HTMLInputElement>(
|
|
205
|
+
`input[type="radio"][name="${CSS.escape(key)}"][value=""]`
|
|
206
|
+
);
|
|
207
|
+
resetRadio?.dispatchEvent(
|
|
208
|
+
new MouseEvent('click', { view: window, bubbles: true, cancelable: true })
|
|
209
|
+
);
|
|
210
|
+
} else {
|
|
211
|
+
input.dispatchEvent(
|
|
212
|
+
new MouseEvent('click', { view: window, bubbles: true, cancelable: true })
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Seed counts/chips from state restored by filter-container's `oninit` (e.g. from URL params).
|
|
218
|
+
window.addEventListener('load', () => {
|
|
219
|
+
const checked = form.querySelectorAll<HTMLInputElement>('[data-filter-key]:checked');
|
|
220
|
+
checked.forEach(input => {
|
|
221
|
+
const key = input.dataset.filterKey;
|
|
222
|
+
if (key) updateCount(key);
|
|
223
|
+
if (tagsList) onTagInput(input);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const filterRoot = document.querySelector<HTMLElement>('[data-filter-root]');
|
|
229
|
+
if (filterRoot) initFilterableGrid(filterRoot);
|
|
230
|
+
</script>
|
|
231
|
+
|
|
232
|
+
<style>
|
|
233
|
+
.n-filter-form {
|
|
234
|
+
display: flex;
|
|
235
|
+
justify-content: center;
|
|
236
|
+
margin-block-end: var(--ntl-space-s);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.n-filter-dropdowns {
|
|
240
|
+
display: flex;
|
|
241
|
+
flex-wrap: wrap;
|
|
242
|
+
justify-content: center;
|
|
243
|
+
width: 100%;
|
|
244
|
+
border-radius: var(--ntl-radius-m);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
@media (min-width: 37.5em) {
|
|
248
|
+
.n-filter-dropdowns {
|
|
249
|
+
display: inline-flex;
|
|
250
|
+
width: auto;
|
|
251
|
+
gap: var(--ntl-space-s);
|
|
252
|
+
padding: var(--ntl-space-s);
|
|
253
|
+
background-color: var(--ntl-color-bg-2);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.n-filter-facet {
|
|
258
|
+
width: 100%;
|
|
259
|
+
border-top: 1px solid var(--ntl-color-border-strong);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.n-filter-facet:last-child {
|
|
263
|
+
border-bottom: 1px solid var(--ntl-color-border-strong);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
@media (min-width: 37.5em) {
|
|
267
|
+
.n-filter-facet,
|
|
268
|
+
.n-filter-facet:last-child {
|
|
269
|
+
width: auto;
|
|
270
|
+
border: none;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.n-filter-facet > summary {
|
|
275
|
+
position: relative;
|
|
276
|
+
display: flex;
|
|
277
|
+
align-items: center;
|
|
278
|
+
justify-content: space-between;
|
|
279
|
+
gap: var(--ntl-space-2xs);
|
|
280
|
+
padding-block: var(--ntl-space-2xs);
|
|
281
|
+
color: var(--ntl-heading-text-color);
|
|
282
|
+
cursor: pointer;
|
|
283
|
+
list-style: none;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.n-filter-facet > summary::-webkit-details-marker {
|
|
287
|
+
display: none;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
@media (min-width: 37.5em) {
|
|
291
|
+
.n-filter-facet > summary {
|
|
292
|
+
padding: var(--ntl-space-2xs) var(--ntl-space-s);
|
|
293
|
+
border: 1px solid var(--ntl-color-border-strong);
|
|
294
|
+
border-radius: var(--ntl-radius-m);
|
|
295
|
+
background-color: var(--ntl-color-bg);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.n-filter-facet[open] > summary {
|
|
299
|
+
border-color: var(--ntl-blue-700);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.n-filter-label {
|
|
304
|
+
display: flex;
|
|
305
|
+
align-items: center;
|
|
306
|
+
gap: var(--ntl-space-3xs);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.n-filter-chevron-icon {
|
|
310
|
+
width: 0.7em;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.n-filter-count {
|
|
314
|
+
display: none;
|
|
315
|
+
align-items: center;
|
|
316
|
+
justify-content: center;
|
|
317
|
+
width: 1.5em;
|
|
318
|
+
height: 1.5em;
|
|
319
|
+
border-radius: 50%;
|
|
320
|
+
background: var(--ntl-blue-700);
|
|
321
|
+
color: var(--ntl-neutral-000);
|
|
322
|
+
font-size: 0.75em;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.n-filter-facet[open] .n-filter-count,
|
|
326
|
+
.n-filter-facet:not([open]) .n-filter-count.is-visible {
|
|
327
|
+
display: inline-flex;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
@media (min-width: 37.5em) {
|
|
331
|
+
.n-filter-count {
|
|
332
|
+
position: absolute;
|
|
333
|
+
top: -0.5em;
|
|
334
|
+
right: -0.75em;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.n-filter-menu {
|
|
339
|
+
display: flex;
|
|
340
|
+
flex-direction: column;
|
|
341
|
+
padding-block: var(--ntl-space-2xs) var(--ntl-space-s);
|
|
342
|
+
border: none;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.n-filter-menu label {
|
|
346
|
+
display: flex;
|
|
347
|
+
align-items: center;
|
|
348
|
+
white-space: nowrap;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
.n-filter-menu label:not(:last-child) {
|
|
352
|
+
margin-block-end: var(--ntl-space-2xs);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
.n-filter-menu input[type='checkbox'],
|
|
356
|
+
.n-filter-menu input[type='radio'] {
|
|
357
|
+
margin-inline-end: var(--ntl-space-2xs);
|
|
358
|
+
accent-color: var(--ntl-blue-700);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.n-filter-legend {
|
|
362
|
+
position: absolute;
|
|
363
|
+
width: 1px;
|
|
364
|
+
height: 1px;
|
|
365
|
+
padding: 0;
|
|
366
|
+
margin: -1px;
|
|
367
|
+
overflow: hidden;
|
|
368
|
+
clip: rect(0, 0, 0, 0);
|
|
369
|
+
white-space: nowrap;
|
|
370
|
+
border: 0;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
@media (min-width: 37.5em) {
|
|
374
|
+
.n-filter-menu {
|
|
375
|
+
position: absolute;
|
|
376
|
+
z-index: 1;
|
|
377
|
+
padding: var(--ntl-space-s);
|
|
378
|
+
border-radius: var(--ntl-radius-m);
|
|
379
|
+
background-color: var(--ntl-color-bg);
|
|
380
|
+
box-shadow: var(--ntl-shadow-deep, 0 0.5rem 1.5rem rgba(0, 0, 0, 0.2));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
.n-filter-meta {
|
|
385
|
+
display: flex;
|
|
386
|
+
flex-direction: column;
|
|
387
|
+
align-items: center;
|
|
388
|
+
gap: var(--ntl-space-s);
|
|
389
|
+
margin-block-end: var(--ntl-space-l);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
.n-filter-results {
|
|
393
|
+
color: var(--ntl-color-text-weak);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
.n-filter-tags {
|
|
397
|
+
display: flex;
|
|
398
|
+
flex-wrap: wrap;
|
|
399
|
+
justify-content: center;
|
|
400
|
+
gap: var(--ntl-space-s);
|
|
401
|
+
list-style: none;
|
|
402
|
+
}
|
|
403
|
+
</style>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export interface FilterOption {
|
|
2
|
+
value: string;
|
|
3
|
+
label: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface FilterCategory {
|
|
7
|
+
key: string;
|
|
8
|
+
label: string;
|
|
9
|
+
legend?: string;
|
|
10
|
+
control?: 'checkbox' | 'radio';
|
|
11
|
+
/** Radio-only: prepend a checked `value=""` reset row. */
|
|
12
|
+
anyOption?: { label: string };
|
|
13
|
+
matchMode?: 'any' | 'all';
|
|
14
|
+
options: FilterOption[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface FilterableGridProps {
|
|
18
|
+
filters: FilterCategory[];
|
|
19
|
+
results: { singular: string; plural: string };
|
|
20
|
+
/** Server-rendered initial count, shown before any client JS runs. */
|
|
21
|
+
totalCount: number;
|
|
22
|
+
/** Global match mode; per-category `matchMode` overrides it for that category. */
|
|
23
|
+
matchMode: 'any' | 'all';
|
|
24
|
+
id?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Builds the `data-filter-{key}` attributes a slotted grid item needs so
|
|
29
|
+
* `<filter-container>` can match it against the active facets. Spread the
|
|
30
|
+
* result onto whatever bespoke element you render inside <FilterableGrid>.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* <article {...getFilterAttrs({ topic: ['react', 'astro'], level: 'beginner' })}>
|
|
34
|
+
* ...
|
|
35
|
+
* </article>
|
|
36
|
+
* // -> data-filter-topic="react,astro" data-filter-level="beginner"
|
|
37
|
+
*/
|
|
38
|
+
export function getFilterAttrs(
|
|
39
|
+
values: Record<string, string | string[]>,
|
|
40
|
+
delimiter = ','
|
|
41
|
+
): Record<`data-filter-${string}`, string> {
|
|
42
|
+
return Object.fromEntries(
|
|
43
|
+
Object.entries(values).map(([key, value]) => [
|
|
44
|
+
`data-filter-${key}`,
|
|
45
|
+
Array.isArray(value) ? value.join(delimiter) : value,
|
|
46
|
+
])
|
|
47
|
+
) as Record<`data-filter-${string}`, string>;
|
|
48
|
+
}
|