@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.
@@ -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>;
@@ -0,0 +1,2 @@
1
+ export { default as FilterableGrid } from './FilterableGrid.astro';
2
+ export * from './filter-types';
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.7",
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
+ }
@@ -0,0 +1,2 @@
1
+ export { default as FilterableGrid } from './FilterableGrid.astro';
2
+ export * from './filter-types';