@classic-homes/theme-svelte 1.13.2 → 1.15.1
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/__tests__/a11y/FilterToolbar.a11y.test.js +62 -0
- package/dist/__tests__/data-panel-viewport.test.js +45 -0
- package/dist/lib/components/ActionHeader.svelte +2 -2
- package/dist/lib/components/ActiveFilterChips.svelte +61 -0
- package/dist/lib/components/ActiveFilterChips.svelte.d.ts +27 -0
- package/dist/lib/components/DataPanel.svelte +22 -11
- package/dist/lib/components/FilterPanel.svelte +65 -0
- package/dist/lib/components/FilterPanel.svelte.d.ts +29 -0
- package/dist/lib/components/FilterToolbar.svelte +133 -0
- package/dist/lib/components/FilterToolbar.svelte.d.ts +46 -0
- package/dist/lib/index.d.ts +3 -0
- package/dist/lib/index.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/svelte';
|
|
3
|
+
import { axe } from 'vitest-axe';
|
|
4
|
+
import { createRawSnippet } from 'svelte';
|
|
5
|
+
import FilterToolbar from '../../lib/components/FilterToolbar.svelte';
|
|
6
|
+
import ActiveFilterChips from '../../lib/components/ActiveFilterChips.svelte';
|
|
7
|
+
const snippet = (html) => createRawSnippet(() => ({ render: () => html }));
|
|
8
|
+
/** Two labelled selects; labels are sr-only when inline. */
|
|
9
|
+
const filters = createRawSnippet((args) => ({
|
|
10
|
+
render: () => {
|
|
11
|
+
const labelClass = args().inline ? ' class="sr-only"' : '';
|
|
12
|
+
return `<div>
|
|
13
|
+
<label for="f-status"${labelClass}>Status</label><select id="f-status"><option>Any</option></select>
|
|
14
|
+
<label for="f-type"${labelClass}>Type</label><select id="f-type"><option>Any</option></select>
|
|
15
|
+
</div>`;
|
|
16
|
+
},
|
|
17
|
+
}));
|
|
18
|
+
const search = snippet('<input type="search" aria-label="Search signs" />');
|
|
19
|
+
describe('FilterToolbar', () => {
|
|
20
|
+
it('keeps filters inline at or under maxInline controls', () => {
|
|
21
|
+
render(FilterToolbar, {
|
|
22
|
+
props: { label: 'Sign tools', search, filters, filterCount: 2 },
|
|
23
|
+
});
|
|
24
|
+
expect(screen.queryByRole('button', { name: /Filters/ })).not.toBeInTheDocument();
|
|
25
|
+
expect(screen.getByLabelText('Status')).toBeInTheDocument();
|
|
26
|
+
});
|
|
27
|
+
it('folds filters behind a Filters button past maxInline', async () => {
|
|
28
|
+
const { container } = render(FilterToolbar, {
|
|
29
|
+
props: { label: 'Sign tools', search, filters, filterCount: 3, activeCount: 2 },
|
|
30
|
+
});
|
|
31
|
+
const button = screen.getByRole('button', { name: 'Filters, 2 active' });
|
|
32
|
+
expect(button).toHaveAttribute('aria-expanded', 'false');
|
|
33
|
+
const panel = screen.getByRole('region', { hidden: true });
|
|
34
|
+
expect(button).toHaveAttribute('aria-controls', panel.id);
|
|
35
|
+
// Svelte sets `inert` as a property; jsdom doesn't reflect it to an attribute.
|
|
36
|
+
const content = panel.firstElementChild;
|
|
37
|
+
expect(content.inert).toBe(true);
|
|
38
|
+
await fireEvent.click(button);
|
|
39
|
+
expect(button).toHaveAttribute('aria-expanded', 'true');
|
|
40
|
+
expect(content.inert).toBe(false);
|
|
41
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe('ActiveFilterChips', () => {
|
|
45
|
+
it('removes a single filter or all of them', async () => {
|
|
46
|
+
const onRemove = vi.fn();
|
|
47
|
+
const onClearAll = vi.fn();
|
|
48
|
+
const { container } = render(ActiveFilterChips, {
|
|
49
|
+
props: { chips: [{ key: 'status', label: 'Status: Installed', onRemove }], onClearAll },
|
|
50
|
+
});
|
|
51
|
+
expect(screen.getByRole('group', { name: 'Active filters' })).toBeInTheDocument();
|
|
52
|
+
await fireEvent.click(screen.getByRole('button', { name: 'Remove filter Status: Installed' }));
|
|
53
|
+
expect(onRemove).toHaveBeenCalledOnce();
|
|
54
|
+
await fireEvent.click(screen.getByRole('button', { name: 'Clear all' }));
|
|
55
|
+
expect(onClearAll).toHaveBeenCalledOnce();
|
|
56
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
57
|
+
});
|
|
58
|
+
it('renders nothing without chips', () => {
|
|
59
|
+
const { container } = render(ActiveFilterChips, { props: { chips: [] } });
|
|
60
|
+
expect(container.querySelector('[role="group"]')).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataPanel card sizing uses the layout viewport, not window.innerWidth.
|
|
3
|
+
*
|
|
4
|
+
* innerWidth includes a vertical scrollbar; the layout width does not. With
|
|
5
|
+
* a 15px scrollbar the card used to be 16px from the left edge and 1px from
|
|
6
|
+
* the right, because both its width and its centred offset came from the
|
|
7
|
+
* wider number.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
10
|
+
import { mount, unmount, flushSync, createRawSnippet } from 'svelte';
|
|
11
|
+
import DataPanel from '../lib/components/DataPanel.svelte';
|
|
12
|
+
const children = createRawSnippet(() => ({ render: () => '<p>Legend</p>' }));
|
|
13
|
+
let instance = null;
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
if (instance)
|
|
16
|
+
unmount(instance);
|
|
17
|
+
instance = null;
|
|
18
|
+
vi.restoreAllMocks();
|
|
19
|
+
});
|
|
20
|
+
describe('DataPanel card viewport width', () => {
|
|
21
|
+
it('insets the card equally when a scrollbar makes innerWidth wider than the layout', () => {
|
|
22
|
+
// 390px window with a 15px vertical scrollbar: 375px of layout
|
|
23
|
+
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390);
|
|
24
|
+
vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(375);
|
|
25
|
+
const host = document.createElement('div');
|
|
26
|
+
document.body.appendChild(host);
|
|
27
|
+
instance = mount(DataPanel, {
|
|
28
|
+
target: host,
|
|
29
|
+
props: {
|
|
30
|
+
title: 'Legend & Information',
|
|
31
|
+
variant: 'card',
|
|
32
|
+
expanded: false,
|
|
33
|
+
disableClose: true,
|
|
34
|
+
mobileBreakpoint: 768,
|
|
35
|
+
cardConfig: { maxWidth: 480, horizontalMargin: 16 },
|
|
36
|
+
children,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
flushSync();
|
|
40
|
+
const panel = host.querySelector('[role="dialog"]');
|
|
41
|
+
expect(panel.style.left).toBe('16px');
|
|
42
|
+
// 375 - 2 * 16, so the right margin is 16px too
|
|
43
|
+
expect(panel.style.width).toBe('343px');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -461,8 +461,8 @@
|
|
|
461
461
|
'lead badges badges badges';
|
|
462
462
|
}
|
|
463
463
|
|
|
464
|
-
/* h1
|
|
465
|
-
|
|
464
|
+
/* h1 matches the theme's 2.5rem / 2.75rem heading scale; stated here too
|
|
465
|
+
because the title clamps to two lines with overflow hidden */
|
|
466
466
|
[data-level='1'] .ah-grid {
|
|
467
467
|
--ah-title-size: 2.5rem;
|
|
468
468
|
--ah-title-leading: 2.75rem;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export interface ActiveFilterChip {
|
|
3
|
+
/** Stable key, e.g. "status" or "community:12" */
|
|
4
|
+
key: string;
|
|
5
|
+
/** Chip text, e.g. "Status: Installed" */
|
|
6
|
+
label: string;
|
|
7
|
+
/** Clears just this filter */
|
|
8
|
+
onRemove: () => void;
|
|
9
|
+
}
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<script lang="ts">
|
|
13
|
+
import type { Snippet } from 'svelte';
|
|
14
|
+
import { cn } from '../utils.js';
|
|
15
|
+
import Button from './Button.svelte';
|
|
16
|
+
import Icon from './Icon.svelte';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* ActiveFilterChips - one removable chip per filter narrowing the results,
|
|
20
|
+
* shown under a FilterToolbar so collapsed filters stay visible.
|
|
21
|
+
*
|
|
22
|
+
* Each chip clears only its own filter; "Clear all" resets everything.
|
|
23
|
+
* Renders nothing when there are no chips and no extra content.
|
|
24
|
+
*/
|
|
25
|
+
interface Props {
|
|
26
|
+
chips: ActiveFilterChip[];
|
|
27
|
+
/** Resets every filter; shows a trailing "Clear all" when set */
|
|
28
|
+
onClearAll?: () => void;
|
|
29
|
+
/** Extra notes after the chips (e.g. filters that don't apply here) */
|
|
30
|
+
children?: Snippet;
|
|
31
|
+
class?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let { chips, onClearAll, children, class: className }: Props = $props();
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
{#if chips.length > 0 || children}
|
|
38
|
+
<div
|
|
39
|
+
role="group"
|
|
40
|
+
aria-label="Active filters"
|
|
41
|
+
class={cn('flex flex-wrap items-center gap-2 text-sm', className)}
|
|
42
|
+
>
|
|
43
|
+
{#each chips as chip (chip.key)}
|
|
44
|
+
<button
|
|
45
|
+
type="button"
|
|
46
|
+
class="inline-flex min-h-11 max-w-full items-center gap-1 rounded-full border border-border bg-muted px-3 text-xs font-medium text-foreground hover:bg-muted-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:min-h-8"
|
|
47
|
+
aria-label={`Remove filter ${chip.label}`}
|
|
48
|
+
onclick={chip.onRemove}
|
|
49
|
+
>
|
|
50
|
+
<span class="truncate">{chip.label}</span>
|
|
51
|
+
<Icon name="close" size={12} class="shrink-0" />
|
|
52
|
+
</button>
|
|
53
|
+
{/each}
|
|
54
|
+
{@render children?.()}
|
|
55
|
+
{#if onClearAll && chips.length > 0}
|
|
56
|
+
<Button variant="ghost" size="sm" class="ml-auto min-h-11 sm:min-h-8" onclick={onClearAll}>
|
|
57
|
+
Clear all
|
|
58
|
+
</Button>
|
|
59
|
+
{/if}
|
|
60
|
+
</div>
|
|
61
|
+
{/if}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface ActiveFilterChip {
|
|
2
|
+
/** Stable key, e.g. "status" or "community:12" */
|
|
3
|
+
key: string;
|
|
4
|
+
/** Chip text, e.g. "Status: Installed" */
|
|
5
|
+
label: string;
|
|
6
|
+
/** Clears just this filter */
|
|
7
|
+
onRemove: () => void;
|
|
8
|
+
}
|
|
9
|
+
import type { Snippet } from 'svelte';
|
|
10
|
+
/**
|
|
11
|
+
* ActiveFilterChips - one removable chip per filter narrowing the results,
|
|
12
|
+
* shown under a FilterToolbar so collapsed filters stay visible.
|
|
13
|
+
*
|
|
14
|
+
* Each chip clears only its own filter; "Clear all" resets everything.
|
|
15
|
+
* Renders nothing when there are no chips and no extra content.
|
|
16
|
+
*/
|
|
17
|
+
interface Props {
|
|
18
|
+
chips: ActiveFilterChip[];
|
|
19
|
+
/** Resets every filter; shows a trailing "Clear all" when set */
|
|
20
|
+
onClearAll?: () => void;
|
|
21
|
+
/** Extra notes after the chips (e.g. filters that don't apply here) */
|
|
22
|
+
children?: Snippet;
|
|
23
|
+
class?: string;
|
|
24
|
+
}
|
|
25
|
+
declare const ActiveFilterChips: import("svelte").Component<Props, {}, "">;
|
|
26
|
+
type ActiveFilterChips = ReturnType<typeof ActiveFilterChips>;
|
|
27
|
+
export default ActiveFilterChips;
|
|
@@ -244,8 +244,19 @@
|
|
|
244
244
|
// Panel element ref
|
|
245
245
|
let panelRef = $state<HTMLDivElement | null>(null);
|
|
246
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Layout viewport width. Not layoutViewportWidth(): that includes a vertical
|
|
249
|
+
* scrollbar, and the card sheet's width and centred left offset are
|
|
250
|
+
* computed from this number, so with a scrollbar showing (a desktop
|
|
251
|
+
* browser at phone width, device toolbars) the card came out 16px from the
|
|
252
|
+
* left edge and a scrollbar's width short of the right one.
|
|
253
|
+
*/
|
|
254
|
+
function layoutViewportWidth(): number {
|
|
255
|
+
return document.documentElement.clientWidth;
|
|
256
|
+
}
|
|
257
|
+
|
|
247
258
|
// Viewport dimensions
|
|
248
|
-
let viewportWidth = $state(typeof
|
|
259
|
+
let viewportWidth = $state(typeof document !== 'undefined' ? layoutViewportWidth() : 1024);
|
|
249
260
|
let viewportHeight = $state(typeof window !== 'undefined' ? window.innerHeight : 768);
|
|
250
261
|
|
|
251
262
|
// Detect mobile
|
|
@@ -311,7 +322,7 @@
|
|
|
311
322
|
// Initialize from persistence on mount
|
|
312
323
|
onMount(() => {
|
|
313
324
|
// Update viewport dimensions
|
|
314
|
-
viewportWidth =
|
|
325
|
+
viewportWidth = layoutViewportWidth();
|
|
315
326
|
viewportHeight = window.innerHeight;
|
|
316
327
|
|
|
317
328
|
// Initialize card expanded height
|
|
@@ -319,7 +330,7 @@
|
|
|
319
330
|
|
|
320
331
|
// Listen for resize
|
|
321
332
|
const handleResize = () => {
|
|
322
|
-
viewportWidth =
|
|
333
|
+
viewportWidth = layoutViewportWidth();
|
|
323
334
|
viewportHeight = window.innerHeight;
|
|
324
335
|
cardExpandedHeight = viewportHeight * resolvedCardConfig.expandedRatio;
|
|
325
336
|
};
|
|
@@ -348,7 +359,7 @@
|
|
|
348
359
|
) {
|
|
349
360
|
detachedPosition = getInitialDetachedPosition(
|
|
350
361
|
detachedSize,
|
|
351
|
-
|
|
362
|
+
layoutViewportWidth(),
|
|
352
363
|
window.innerHeight
|
|
353
364
|
);
|
|
354
365
|
}
|
|
@@ -465,7 +476,7 @@
|
|
|
465
476
|
const snap = detectEdgeSnap(
|
|
466
477
|
newPosition,
|
|
467
478
|
detachedSize,
|
|
468
|
-
|
|
479
|
+
layoutViewportWidth(),
|
|
469
480
|
window.innerHeight,
|
|
470
481
|
snapThreshold
|
|
471
482
|
);
|
|
@@ -476,7 +487,7 @@
|
|
|
476
487
|
detachedPosition = constrainPosition(
|
|
477
488
|
newPosition,
|
|
478
489
|
detachedSize,
|
|
479
|
-
|
|
490
|
+
layoutViewportWidth(),
|
|
480
491
|
window.innerHeight
|
|
481
492
|
);
|
|
482
493
|
}
|
|
@@ -517,7 +528,7 @@
|
|
|
517
528
|
pinnedSize,
|
|
518
529
|
detachedSize,
|
|
519
530
|
edge,
|
|
520
|
-
|
|
531
|
+
layoutViewportWidth(),
|
|
521
532
|
window.innerHeight
|
|
522
533
|
);
|
|
523
534
|
|
|
@@ -708,7 +719,7 @@
|
|
|
708
719
|
result.position,
|
|
709
720
|
result.size,
|
|
710
721
|
resolvedConstraints,
|
|
711
|
-
|
|
722
|
+
layoutViewportWidth(),
|
|
712
723
|
window.innerHeight,
|
|
713
724
|
resizeHandle
|
|
714
725
|
);
|
|
@@ -725,7 +736,7 @@
|
|
|
725
736
|
const newSize = resizeSizeStart.width + delta * direction;
|
|
726
737
|
|
|
727
738
|
if (isHorizontalEdge(edge)) {
|
|
728
|
-
pinnedSize = constrainPinnedWidth(newSize, resolvedConstraints,
|
|
739
|
+
pinnedSize = constrainPinnedWidth(newSize, resolvedConstraints, layoutViewportWidth());
|
|
729
740
|
} else {
|
|
730
741
|
pinnedSize = constrainPinnedHeight(newSize, resolvedConstraints, window.innerHeight);
|
|
731
742
|
}
|
|
@@ -791,7 +802,7 @@
|
|
|
791
802
|
candidatePosition,
|
|
792
803
|
{ width, height },
|
|
793
804
|
resolvedConstraints,
|
|
794
|
-
|
|
805
|
+
layoutViewportWidth(),
|
|
795
806
|
window.innerHeight,
|
|
796
807
|
handle
|
|
797
808
|
);
|
|
@@ -829,7 +840,7 @@
|
|
|
829
840
|
if (handled) {
|
|
830
841
|
e.preventDefault();
|
|
831
842
|
pinnedSize = horizontal
|
|
832
|
-
? constrainPinnedWidth(newSize, resolvedConstraints,
|
|
843
|
+
? constrainPinnedWidth(newSize, resolvedConstraints, layoutViewportWidth())
|
|
833
844
|
: constrainPinnedHeight(newSize, resolvedConstraints, window.innerHeight);
|
|
834
845
|
debouncedSave();
|
|
835
846
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { cn } from '../utils.js';
|
|
4
|
+
import Button from './Button.svelte';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* FilterPanel - collapsible block of filter controls under a Toolbar.
|
|
8
|
+
*
|
|
9
|
+
* Opens and closes with a grid-rows height transition (no measured heights).
|
|
10
|
+
* While closed the content is `inert`, so the hidden controls are neither
|
|
11
|
+
* tabbable nor exposed to screen readers. Controls are laid out on a
|
|
12
|
+
* responsive grid; "Clear all" follows them when any filter is active.
|
|
13
|
+
*
|
|
14
|
+
* Usually rendered by FilterToolbar; use it directly only for a custom strip.
|
|
15
|
+
*/
|
|
16
|
+
interface Props {
|
|
17
|
+
/** Whether the panel is expanded */
|
|
18
|
+
open?: boolean;
|
|
19
|
+
/** id for the region, so the toggle button can point `aria-controls` at it */
|
|
20
|
+
id?: string;
|
|
21
|
+
/** Accessible name for the region (default "Filters") */
|
|
22
|
+
label?: string;
|
|
23
|
+
/** Active filter count; "Clear all" shows when > 0 and `onClear` is set */
|
|
24
|
+
activeCount?: number;
|
|
25
|
+
/** Resets every filter */
|
|
26
|
+
onClear?: () => void;
|
|
27
|
+
/** The filter controls, one grid cell each */
|
|
28
|
+
children: Snippet;
|
|
29
|
+
class?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let {
|
|
33
|
+
open = $bindable(false),
|
|
34
|
+
id,
|
|
35
|
+
label = 'Filters',
|
|
36
|
+
activeCount = 0,
|
|
37
|
+
onClear,
|
|
38
|
+
children,
|
|
39
|
+
class: className,
|
|
40
|
+
}: Props = $props();
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<div
|
|
44
|
+
{id}
|
|
45
|
+
role="region"
|
|
46
|
+
aria-label={label}
|
|
47
|
+
class={cn(
|
|
48
|
+
'grid transition-[grid-template-rows] duration-300 ease-in-out motion-reduce:transition-none',
|
|
49
|
+
open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
|
|
50
|
+
className
|
|
51
|
+
)}
|
|
52
|
+
>
|
|
53
|
+
<div class="overflow-hidden" inert={!open}>
|
|
54
|
+
<div class="mt-2 rounded-lg border border-border bg-muted/50 p-4">
|
|
55
|
+
<div class="grid items-end gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
56
|
+
{@render children()}
|
|
57
|
+
</div>
|
|
58
|
+
{#if onClear && activeCount > 0}
|
|
59
|
+
<div class="mt-3 flex justify-end">
|
|
60
|
+
<Button variant="ghost" size="sm" onclick={onClear}>Clear all</Button>
|
|
61
|
+
</div>
|
|
62
|
+
{/if}
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* FilterPanel - collapsible block of filter controls under a Toolbar.
|
|
4
|
+
*
|
|
5
|
+
* Opens and closes with a grid-rows height transition (no measured heights).
|
|
6
|
+
* While closed the content is `inert`, so the hidden controls are neither
|
|
7
|
+
* tabbable nor exposed to screen readers. Controls are laid out on a
|
|
8
|
+
* responsive grid; "Clear all" follows them when any filter is active.
|
|
9
|
+
*
|
|
10
|
+
* Usually rendered by FilterToolbar; use it directly only for a custom strip.
|
|
11
|
+
*/
|
|
12
|
+
interface Props {
|
|
13
|
+
/** Whether the panel is expanded */
|
|
14
|
+
open?: boolean;
|
|
15
|
+
/** id for the region, so the toggle button can point `aria-controls` at it */
|
|
16
|
+
id?: string;
|
|
17
|
+
/** Accessible name for the region (default "Filters") */
|
|
18
|
+
label?: string;
|
|
19
|
+
/** Active filter count; "Clear all" shows when > 0 and `onClear` is set */
|
|
20
|
+
activeCount?: number;
|
|
21
|
+
/** Resets every filter */
|
|
22
|
+
onClear?: () => void;
|
|
23
|
+
/** The filter controls, one grid cell each */
|
|
24
|
+
children: Snippet;
|
|
25
|
+
class?: string;
|
|
26
|
+
}
|
|
27
|
+
declare const FilterPanel: import("svelte").Component<Props, {}, "open">;
|
|
28
|
+
type FilterPanel = ReturnType<typeof FilterPanel>;
|
|
29
|
+
export default FilterPanel;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { MediaQuery } from 'svelte/reactivity';
|
|
4
|
+
import Button from './Button.svelte';
|
|
5
|
+
import Dialog from './Dialog.svelte';
|
|
6
|
+
import FilterPanel from './FilterPanel.svelte';
|
|
7
|
+
import Icon from './Icon.svelte';
|
|
8
|
+
import Toolbar from './Toolbar.svelte';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* FilterToolbar - a Toolbar that folds its filter controls behind a
|
|
12
|
+
* "Filters" button once there are too many to sit inline.
|
|
13
|
+
*
|
|
14
|
+
* Rule: when the left side would hold more than `maxInline` controls
|
|
15
|
+
* (search counts, count text does not), or on a narrow viewport, the
|
|
16
|
+
* filters move behind the button - a collapsible FilterPanel under the
|
|
17
|
+
* strip on desktop, a full-screen Dialog on mobile. Otherwise they render
|
|
18
|
+
* inline in the strip.
|
|
19
|
+
*
|
|
20
|
+
* The page renders its controls once, in `filters`; the `inline` argument
|
|
21
|
+
* says whether they sit in the strip (keep labels `sr-only`) or in the
|
|
22
|
+
* panel/dialog (show labels). Filters apply live in every mode - the
|
|
23
|
+
* dialog's "Done" only closes it.
|
|
24
|
+
*/
|
|
25
|
+
interface Props {
|
|
26
|
+
/** Accessible name for the strip, e.g. "Sign tools" */
|
|
27
|
+
label: string;
|
|
28
|
+
/** Search field; always inline */
|
|
29
|
+
search?: Snippet;
|
|
30
|
+
/** The filter controls; `inline` is false inside the panel/dialog */
|
|
31
|
+
filters: Snippet<[{ inline: boolean }]>;
|
|
32
|
+
/** How many controls `filters` renders (drives the collapse rule) */
|
|
33
|
+
filterCount: number;
|
|
34
|
+
/** Filters currently narrowing results (button badge) */
|
|
35
|
+
activeCount?: number;
|
|
36
|
+
/** Result count / summary text, after the filters or the button */
|
|
37
|
+
summary?: Snippet;
|
|
38
|
+
/** Right-aligned cluster (view toggles etc.) */
|
|
39
|
+
end?: Snippet;
|
|
40
|
+
/** Resets every filter ("Clear all" in the panel and dialog) */
|
|
41
|
+
onClear?: () => void;
|
|
42
|
+
/** Most left-side controls (search included) allowed inline (default 3) */
|
|
43
|
+
maxInline?: number;
|
|
44
|
+
/** Whether the panel/dialog is open */
|
|
45
|
+
open?: boolean;
|
|
46
|
+
/** Viewport query below which filters always collapse into a dialog */
|
|
47
|
+
mobileQuery?: string;
|
|
48
|
+
class?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let {
|
|
52
|
+
label,
|
|
53
|
+
search,
|
|
54
|
+
filters,
|
|
55
|
+
filterCount,
|
|
56
|
+
activeCount = 0,
|
|
57
|
+
summary,
|
|
58
|
+
end: endSnippet,
|
|
59
|
+
onClear,
|
|
60
|
+
maxInline = 3,
|
|
61
|
+
open = $bindable(false),
|
|
62
|
+
mobileQuery = '(max-width: 1023px)',
|
|
63
|
+
class: className,
|
|
64
|
+
}: Props = $props();
|
|
65
|
+
|
|
66
|
+
const uid = $props.id();
|
|
67
|
+
const panelId = `filter-panel-${uid}`;
|
|
68
|
+
|
|
69
|
+
const mobile = $derived(new MediaQuery(mobileQuery, false));
|
|
70
|
+
const collapsed = $derived(
|
|
71
|
+
filterCount > 0 && (mobile.current || (search ? 1 : 0) + filterCount > maxInline)
|
|
72
|
+
);
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<div class={className}>
|
|
76
|
+
<Toolbar {label} end={endSnippet}>
|
|
77
|
+
{@render search?.()}
|
|
78
|
+
{#if collapsed}
|
|
79
|
+
<Button
|
|
80
|
+
variant={open ? 'default' : 'secondary'}
|
|
81
|
+
size="sm"
|
|
82
|
+
class="min-h-11 shrink-0 sm:min-h-9"
|
|
83
|
+
aria-expanded={open}
|
|
84
|
+
aria-controls={mobile.current ? undefined : panelId}
|
|
85
|
+
aria-label={activeCount > 0 ? `Filters, ${activeCount} active` : 'Filters'}
|
|
86
|
+
onclick={() => (open = !open)}
|
|
87
|
+
>
|
|
88
|
+
<Icon name="filter" size={16} />
|
|
89
|
+
Filters
|
|
90
|
+
{#if activeCount > 0}
|
|
91
|
+
<span
|
|
92
|
+
class="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-background px-1.5 text-xs text-foreground"
|
|
93
|
+
aria-hidden="true"
|
|
94
|
+
>
|
|
95
|
+
{activeCount}
|
|
96
|
+
</span>
|
|
97
|
+
{/if}
|
|
98
|
+
</Button>
|
|
99
|
+
{:else}
|
|
100
|
+
{@render filters({ inline: true })}
|
|
101
|
+
{/if}
|
|
102
|
+
{@render summary?.()}
|
|
103
|
+
</Toolbar>
|
|
104
|
+
|
|
105
|
+
{#if collapsed && !mobile.current}
|
|
106
|
+
<FilterPanel bind:open id={panelId} {label} {activeCount} {onClear}>
|
|
107
|
+
{@render filters({ inline: false })}
|
|
108
|
+
</FilterPanel>
|
|
109
|
+
{/if}
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{#if collapsed && mobile.current}
|
|
113
|
+
<Dialog
|
|
114
|
+
bind:open
|
|
115
|
+
title="Filters"
|
|
116
|
+
description={activeCount > 0 ? `${activeCount} active` : undefined}
|
|
117
|
+
size="full"
|
|
118
|
+
>
|
|
119
|
+
<div class="space-y-4">
|
|
120
|
+
{@render filters({ inline: false })}
|
|
121
|
+
</div>
|
|
122
|
+
{#snippet footer()}
|
|
123
|
+
<div class="flex w-full justify-between gap-2">
|
|
124
|
+
{#if onClear}
|
|
125
|
+
<Button variant="ghost" class="min-h-11" disabled={activeCount === 0} onclick={onClear}>
|
|
126
|
+
Clear all
|
|
127
|
+
</Button>
|
|
128
|
+
{/if}
|
|
129
|
+
<Button class="ml-auto min-h-11" onclick={() => (open = false)}>Done</Button>
|
|
130
|
+
</div>
|
|
131
|
+
{/snippet}
|
|
132
|
+
</Dialog>
|
|
133
|
+
{/if}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* FilterToolbar - a Toolbar that folds its filter controls behind a
|
|
4
|
+
* "Filters" button once there are too many to sit inline.
|
|
5
|
+
*
|
|
6
|
+
* Rule: when the left side would hold more than `maxInline` controls
|
|
7
|
+
* (search counts, count text does not), or on a narrow viewport, the
|
|
8
|
+
* filters move behind the button - a collapsible FilterPanel under the
|
|
9
|
+
* strip on desktop, a full-screen Dialog on mobile. Otherwise they render
|
|
10
|
+
* inline in the strip.
|
|
11
|
+
*
|
|
12
|
+
* The page renders its controls once, in `filters`; the `inline` argument
|
|
13
|
+
* says whether they sit in the strip (keep labels `sr-only`) or in the
|
|
14
|
+
* panel/dialog (show labels). Filters apply live in every mode - the
|
|
15
|
+
* dialog's "Done" only closes it.
|
|
16
|
+
*/
|
|
17
|
+
interface Props {
|
|
18
|
+
/** Accessible name for the strip, e.g. "Sign tools" */
|
|
19
|
+
label: string;
|
|
20
|
+
/** Search field; always inline */
|
|
21
|
+
search?: Snippet;
|
|
22
|
+
/** The filter controls; `inline` is false inside the panel/dialog */
|
|
23
|
+
filters: Snippet<[{
|
|
24
|
+
inline: boolean;
|
|
25
|
+
}]>;
|
|
26
|
+
/** How many controls `filters` renders (drives the collapse rule) */
|
|
27
|
+
filterCount: number;
|
|
28
|
+
/** Filters currently narrowing results (button badge) */
|
|
29
|
+
activeCount?: number;
|
|
30
|
+
/** Result count / summary text, after the filters or the button */
|
|
31
|
+
summary?: Snippet;
|
|
32
|
+
/** Right-aligned cluster (view toggles etc.) */
|
|
33
|
+
end?: Snippet;
|
|
34
|
+
/** Resets every filter ("Clear all" in the panel and dialog) */
|
|
35
|
+
onClear?: () => void;
|
|
36
|
+
/** Most left-side controls (search included) allowed inline (default 3) */
|
|
37
|
+
maxInline?: number;
|
|
38
|
+
/** Whether the panel/dialog is open */
|
|
39
|
+
open?: boolean;
|
|
40
|
+
/** Viewport query below which filters always collapse into a dialog */
|
|
41
|
+
mobileQuery?: string;
|
|
42
|
+
class?: string;
|
|
43
|
+
}
|
|
44
|
+
declare const FilterToolbar: import("svelte").Component<Props, {}, "open">;
|
|
45
|
+
type FilterToolbar = ReturnType<typeof FilterToolbar>;
|
|
46
|
+
export default FilterToolbar;
|
package/dist/lib/index.d.ts
CHANGED
|
@@ -59,6 +59,9 @@ export { default as DataTable } from './components/DataTable.svelte';
|
|
|
59
59
|
export { default as Tabs } from './components/Tabs.svelte';
|
|
60
60
|
export { default as TabPanel } from './components/TabPanel.svelte';
|
|
61
61
|
export { default as Toolbar } from './components/Toolbar.svelte';
|
|
62
|
+
export { default as FilterToolbar } from './components/FilterToolbar.svelte';
|
|
63
|
+
export { default as FilterPanel } from './components/FilterPanel.svelte';
|
|
64
|
+
export { default as ActiveFilterChips, type ActiveFilterChip, } from './components/ActiveFilterChips.svelte';
|
|
62
65
|
export { default as SegmentedControl, type SegmentedControlOption, } from './components/SegmentedControl.svelte';
|
|
63
66
|
export { default as ViewToggle, type ViewToggleOption, defaultViewToggleOptions, } from './components/ViewToggle.svelte';
|
|
64
67
|
export { AspectRatio, Image, Video, Audio, MediaEmbed, MediaGrid, MediaCarousel, Lightbox, } from './components/media/index.js';
|
package/dist/lib/index.js
CHANGED
|
@@ -72,6 +72,9 @@ export { default as Tabs } from './components/Tabs.svelte';
|
|
|
72
72
|
export { default as TabPanel } from './components/TabPanel.svelte';
|
|
73
73
|
// Toolbar controls
|
|
74
74
|
export { default as Toolbar } from './components/Toolbar.svelte';
|
|
75
|
+
export { default as FilterToolbar } from './components/FilterToolbar.svelte';
|
|
76
|
+
export { default as FilterPanel } from './components/FilterPanel.svelte';
|
|
77
|
+
export { default as ActiveFilterChips, } from './components/ActiveFilterChips.svelte';
|
|
75
78
|
export { default as SegmentedControl, } from './components/SegmentedControl.svelte';
|
|
76
79
|
export { default as ViewToggle, defaultViewToggleOptions, } from './components/ViewToggle.svelte';
|
|
77
80
|
// Media Components
|