@ponti-studios/ui 0.6.0 → 0.7.0
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/package.json +5 -1
- package/src/components/data-display/index.ts +4 -2
- package/src/components/filters/active-filters-bar.stories.tsx +114 -0
- package/src/components/filters/active-filters-bar.tsx +63 -0
- package/src/components/filters/filter-chip.tsx +57 -0
- package/src/components/filters/filter-select.stories.tsx +71 -0
- package/src/components/filters/filter-select.tsx +75 -0
- package/src/components/filters/filters.stories.tsx +72 -0
- package/src/components/filters/filters.tsx +78 -0
- package/src/components/filters/index.ts +10 -0
- package/src/components/filters/search-filter-bar.stories.tsx +41 -0
- package/src/components/filters/search-filter-bar.tsx +48 -0
- package/src/components/forms/index.ts +2 -2
- package/src/components/layout/index.ts +2 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/use-derived-filter-state.stories.tsx +136 -0
- package/src/hooks/use-derived-filter-state.ts +72 -0
- package/src/hooks/use-filter-state.stories.tsx +102 -0
- package/src/hooks/use-filter-state.ts +21 -22
- package/src/index.ts +2 -0
- package/src/styles/index.css +31 -0
- package/src/components/data-display/filter-chip.stories.tsx +0 -38
- package/src/components/data-display/filter-chip.tsx +0 -47
- package/src/components/forms/entity-select.stories.tsx +0 -65
- package/src/components/forms/entity-select.tsx +0 -79
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ponti-studios/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Shared UI component library for Ponti Studios",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
"types": "./src/components/feedback/index.ts",
|
|
35
35
|
"import": "./src/components/feedback/index.ts"
|
|
36
36
|
},
|
|
37
|
+
"./filters": {
|
|
38
|
+
"types": "./src/components/filters/index.ts",
|
|
39
|
+
"import": "./src/components/filters/index.ts"
|
|
40
|
+
},
|
|
37
41
|
"./overlays": {
|
|
38
42
|
"types": "./src/components/overlays/index.ts",
|
|
39
43
|
"import": "./src/components/overlays/index.ts"
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./accordion";
|
|
2
|
-
export {
|
|
3
|
-
export type {
|
|
2
|
+
export { ActiveFiltersBar } from "../filters/active-filters-bar";
|
|
3
|
+
export type { ActiveFilter, ActiveFiltersBarProps } from "../filters/active-filters-bar";
|
|
4
|
+
export { FilterChip } from "../filters/filter-chip";
|
|
5
|
+
export type { FilterChipProps } from "../filters/filter-chip";
|
|
4
6
|
export { MetricCard } from "./metric-card";
|
|
5
7
|
export { SortControls } from "./sort-controls";
|
|
6
8
|
export type { SortControlsProps } from "./sort-controls";
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { expect, userEvent, within } from 'storybook/test';
|
|
3
|
+
import { fn } from 'storybook/test';
|
|
4
|
+
|
|
5
|
+
import { ActiveFiltersBar } from './active-filters-bar';
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
title: 'Filters/ActiveFiltersBar',
|
|
9
|
+
component: ActiveFiltersBar,
|
|
10
|
+
tags: ['autodocs'],
|
|
11
|
+
decorators: [
|
|
12
|
+
(Story) => (
|
|
13
|
+
<div className="w-[600px]">
|
|
14
|
+
<Story />
|
|
15
|
+
</div>
|
|
16
|
+
),
|
|
17
|
+
],
|
|
18
|
+
} satisfies Meta<typeof ActiveFiltersBar>;
|
|
19
|
+
|
|
20
|
+
export default meta;
|
|
21
|
+
type Story = StoryObj<typeof meta>;
|
|
22
|
+
|
|
23
|
+
export const Default: Story = {
|
|
24
|
+
args: {
|
|
25
|
+
filters: [
|
|
26
|
+
{ id: '1', label: 'Status: Active', onRemove: fn() },
|
|
27
|
+
{ id: '2', label: 'Created after Jan 1', onRemove: fn() },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
play: async ({ canvasElement }) => {
|
|
31
|
+
const canvas = within(canvasElement);
|
|
32
|
+
const list = canvas.getByRole('list');
|
|
33
|
+
await expect(list).toBeInTheDocument();
|
|
34
|
+
const items = within(list).getAllByRole('listitem');
|
|
35
|
+
expect(items.length).toBe(2);
|
|
36
|
+
await expect(canvas.getByText('Status: Active')).toBeInTheDocument();
|
|
37
|
+
await expect(canvas.getByText('Created after Jan 1')).toBeInTheDocument();
|
|
38
|
+
await expect(canvas.getByRole('button', { name: 'Clear all' })).toBeInTheDocument();
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const WithClearAll: Story = {
|
|
43
|
+
args: {
|
|
44
|
+
filters: [
|
|
45
|
+
{ id: '1', label: 'Status: Active', onRemove: fn() },
|
|
46
|
+
{ id: '2', label: 'Role: Engineer', onRemove: fn() },
|
|
47
|
+
],
|
|
48
|
+
onClearAll: fn(),
|
|
49
|
+
},
|
|
50
|
+
play: async ({ canvasElement, args }) => {
|
|
51
|
+
const canvas = within(canvasElement);
|
|
52
|
+
const list = canvas.getByRole('list');
|
|
53
|
+
const items = within(list).getAllByRole('listitem');
|
|
54
|
+
expect(items.length).toBe(2);
|
|
55
|
+
const clearButton = canvas.getByRole('button', { name: 'Clear all' });
|
|
56
|
+
await expect(clearButton).toBeInTheDocument();
|
|
57
|
+
await userEvent.click(clearButton);
|
|
58
|
+
await expect(args.onClearAll).toHaveBeenCalledTimes(1);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const WithSortOnly: Story = {
|
|
63
|
+
args: {
|
|
64
|
+
filters: [
|
|
65
|
+
{ id: '1', label: 'Date', onRemove: fn(), variant: 'sort', direction: 'desc' },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
play: async ({ canvasElement }) => {
|
|
69
|
+
const canvas = within(canvasElement);
|
|
70
|
+
const list = canvas.getByRole('list');
|
|
71
|
+
const items = within(list).getAllByRole('listitem');
|
|
72
|
+
expect(items.length).toBe(1);
|
|
73
|
+
await expect(canvas.getByText('Date')).toBeInTheDocument();
|
|
74
|
+
await expect(canvas.getByRole('button', { name: 'Clear all' })).toBeInTheDocument();
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const WithSortAndFilter: Story = {
|
|
79
|
+
args: {
|
|
80
|
+
filters: [
|
|
81
|
+
{ id: 'filter-1', label: 'Status: Active', onRemove: fn(), variant: 'filter' },
|
|
82
|
+
{ id: 'sort-1', label: 'Date', onRemove: fn(), variant: 'sort', direction: 'desc' },
|
|
83
|
+
],
|
|
84
|
+
onClearAll: fn(),
|
|
85
|
+
},
|
|
86
|
+
play: async ({ canvasElement }) => {
|
|
87
|
+
const canvas = within(canvasElement);
|
|
88
|
+
const list = canvas.getByRole('list');
|
|
89
|
+
const items = within(list).getAllByRole('listitem');
|
|
90
|
+
expect(items.length).toBe(2);
|
|
91
|
+
await expect(canvas.getByText('Status: Active')).toBeInTheDocument();
|
|
92
|
+
await expect(canvas.getByText('Date')).toBeInTheDocument();
|
|
93
|
+
await expect(canvas.getByRole('button', { name: 'Clear all' })).toBeInTheDocument();
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const Empty: Story = {
|
|
98
|
+
args: { filters: [] },
|
|
99
|
+
play: async ({ canvasElement }) => {
|
|
100
|
+
const canvas = within(canvasElement);
|
|
101
|
+
expect(canvas.queryByRole('list')).toBeNull();
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const WithClassName: Story = {
|
|
106
|
+
args: {
|
|
107
|
+
filters: [{ id: '1', label: 'Status: Active', onRemove: fn() }],
|
|
108
|
+
className: 'mt-4',
|
|
109
|
+
},
|
|
110
|
+
play: async ({ canvasElement }) => {
|
|
111
|
+
const container = canvasElement.querySelector('.mt-4');
|
|
112
|
+
await expect(container).not.toBeNull();
|
|
113
|
+
},
|
|
114
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../../lib/utils";
|
|
4
|
+
import { FilterChip } from "./filter-chip";
|
|
5
|
+
|
|
6
|
+
export interface ActiveFilter {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
onRemove: () => void;
|
|
10
|
+
onClick?: () => void;
|
|
11
|
+
variant?: "filter" | "sort";
|
|
12
|
+
direction?: "asc" | "desc";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ActiveFiltersBarProps {
|
|
16
|
+
filters: ActiveFilter[];
|
|
17
|
+
className?: string;
|
|
18
|
+
onClearAll?: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ActiveFiltersBar({ filters, className, onClearAll }: ActiveFiltersBarProps) {
|
|
22
|
+
if (filters.length === 0) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
className={cn(
|
|
29
|
+
"flex w-full items-center h-10 gap-2 rounded-lg border border-border pr-0",
|
|
30
|
+
className,
|
|
31
|
+
)}
|
|
32
|
+
>
|
|
33
|
+
<span className="text-muted-foreground shrink-0 self-stretch flex items-center text-sm border-r px-4">
|
|
34
|
+
Filters
|
|
35
|
+
</span>
|
|
36
|
+
<div
|
|
37
|
+
role="list"
|
|
38
|
+
className="flex flex-1 min-w-0 items-center gap-2 overflow-x-auto scroll-fade-x pl-4 py-1.5"
|
|
39
|
+
>
|
|
40
|
+
{filters.map((filter) => (
|
|
41
|
+
<FilterChip
|
|
42
|
+
key={filter.id}
|
|
43
|
+
role="listitem"
|
|
44
|
+
className="shrink-0"
|
|
45
|
+
label={filter.label}
|
|
46
|
+
onRemove={filter.onRemove}
|
|
47
|
+
onClick={filter.onClick}
|
|
48
|
+
variant={filter.variant === "sort" ? "sort" : "default"}
|
|
49
|
+
direction={filter.direction}
|
|
50
|
+
/>
|
|
51
|
+
))}
|
|
52
|
+
</div>
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
onClick={onClearAll}
|
|
56
|
+
className="hover:bg-muted text-muted-foreground hover:text-foreground inline-flex shrink-0 items-center justify-center self-stretch aspect-square rounded-r-lg border-l border-border transition-colors"
|
|
57
|
+
aria-label="Clear all"
|
|
58
|
+
>
|
|
59
|
+
<X className="size-4" />
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ArrowDown, ArrowUp, X } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../../lib/utils";
|
|
5
|
+
|
|
6
|
+
export interface FilterChipProps {
|
|
7
|
+
label: ReactNode;
|
|
8
|
+
onRemove: () => void;
|
|
9
|
+
onClick?: () => void;
|
|
10
|
+
className?: string;
|
|
11
|
+
variant?: "default" | "sort";
|
|
12
|
+
direction?: "asc" | "desc";
|
|
13
|
+
role?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function FilterChip({
|
|
17
|
+
label,
|
|
18
|
+
onRemove,
|
|
19
|
+
onClick,
|
|
20
|
+
className,
|
|
21
|
+
variant = "default",
|
|
22
|
+
direction,
|
|
23
|
+
role,
|
|
24
|
+
}: FilterChipProps) {
|
|
25
|
+
const isSort = variant === "sort";
|
|
26
|
+
const DirIcon = direction === "asc" ? ArrowUp : direction === "desc" ? ArrowDown : null;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<span
|
|
30
|
+
role={role}
|
|
31
|
+
aria-label={typeof label === "string" ? `${label} filter` : "filter"}
|
|
32
|
+
className={cn(
|
|
33
|
+
"border-border bg-secondary text-secondary-foreground inline-flex items-center rounded-full border text-xs font-medium",
|
|
34
|
+
isSort && "border-dashed",
|
|
35
|
+
className,
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
disabled={!onClick}
|
|
41
|
+
className="focus-visible:ring-ring inline-flex items-center rounded-full px-3 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-default"
|
|
42
|
+
onClick={onClick}
|
|
43
|
+
>
|
|
44
|
+
{isSort && DirIcon ? <DirIcon className="mr-1 size-4" /> : null}
|
|
45
|
+
{label}
|
|
46
|
+
</button>
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring mr-0.5 flex size-6 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
|
50
|
+
onClick={onRemove}
|
|
51
|
+
aria-label={`Remove filter: ${typeof label === "string" ? label : "selected filter"}`}
|
|
52
|
+
>
|
|
53
|
+
<X className="size-4" aria-hidden />
|
|
54
|
+
</button>
|
|
55
|
+
</span>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { expect, within } from 'storybook/test';
|
|
3
|
+
|
|
4
|
+
import { FilterSelect } from './filter-select';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Filters/FilterSelect',
|
|
8
|
+
component: FilterSelect,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
args: {
|
|
11
|
+
options: [
|
|
12
|
+
{ value: 'active', label: 'Active' },
|
|
13
|
+
{ value: 'interview', label: 'Interview' },
|
|
14
|
+
{ value: 'offer', label: 'Offer' },
|
|
15
|
+
{ value: 'rejected', label: 'Rejected' },
|
|
16
|
+
{ value: 'archived', label: 'Archived' },
|
|
17
|
+
],
|
|
18
|
+
placeholder: 'All statuses',
|
|
19
|
+
label: 'Status',
|
|
20
|
+
onChange: () => {},
|
|
21
|
+
},
|
|
22
|
+
} satisfies Meta<typeof FilterSelect>;
|
|
23
|
+
|
|
24
|
+
export default meta;
|
|
25
|
+
type Story = StoryObj<typeof meta>;
|
|
26
|
+
|
|
27
|
+
export const Default: Story = {
|
|
28
|
+
args: { value: '' },
|
|
29
|
+
play: async ({ canvasElement }) => {
|
|
30
|
+
const canvas = within(canvasElement);
|
|
31
|
+
const trigger = canvas.getByRole('combobox', { name: 'Status' });
|
|
32
|
+
await expect(trigger).toBeInTheDocument();
|
|
33
|
+
await expect(trigger).toHaveTextContent('All statuses');
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const WithValue: Story = {
|
|
38
|
+
args: { value: 'active' },
|
|
39
|
+
play: async ({ canvasElement }) => {
|
|
40
|
+
const canvas = within(canvasElement);
|
|
41
|
+
const trigger = canvas.getByRole('combobox', { name: 'Status' });
|
|
42
|
+
await expect(trigger).toHaveTextContent('Active');
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const Loading: Story = {
|
|
47
|
+
args: { value: '', isLoading: true, options: [] },
|
|
48
|
+
play: async ({ canvasElement }) => {
|
|
49
|
+
const canvas = within(canvasElement);
|
|
50
|
+
await expect(canvas.getByRole('combobox', { name: 'Status' })).toBeInTheDocument();
|
|
51
|
+
await expect(canvas.getByText('Status')).toBeInTheDocument();
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const Empty: Story = {
|
|
56
|
+
args: { value: '', options: [], emptyLabel: 'No statuses found' },
|
|
57
|
+
play: async ({ canvasElement }) => {
|
|
58
|
+
const canvas = within(canvasElement);
|
|
59
|
+
await expect(canvas.getByRole('combobox', { name: 'Status' })).toBeInTheDocument();
|
|
60
|
+
await expect(canvas.getByText('Status')).toBeInTheDocument();
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const Disabled: Story = {
|
|
65
|
+
args: { value: 'active', disabled: true },
|
|
66
|
+
play: async ({ canvasElement }) => {
|
|
67
|
+
const canvas = within(canvasElement);
|
|
68
|
+
const trigger = canvas.getByRole('combobox', { name: 'Status' });
|
|
69
|
+
await expect(trigger).toBeDisabled();
|
|
70
|
+
},
|
|
71
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useId, useMemo } from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../../lib/utils";
|
|
4
|
+
import { Label } from "../primitives/label";
|
|
5
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../forms/select";
|
|
6
|
+
|
|
7
|
+
export interface FilterSelectOption {
|
|
8
|
+
value: string;
|
|
9
|
+
label: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface FilterSelectProps {
|
|
13
|
+
value: string;
|
|
14
|
+
onChange: (value: string) => void;
|
|
15
|
+
options: FilterSelectOption[];
|
|
16
|
+
label: string;
|
|
17
|
+
placeholder?: string;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
isLoading?: boolean;
|
|
20
|
+
emptyLabel?: string;
|
|
21
|
+
className?: string;
|
|
22
|
+
id?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ALL_VALUE = "";
|
|
26
|
+
|
|
27
|
+
export function FilterSelect({
|
|
28
|
+
label,
|
|
29
|
+
value,
|
|
30
|
+
options,
|
|
31
|
+
onChange,
|
|
32
|
+
placeholder = "All",
|
|
33
|
+
disabled = false,
|
|
34
|
+
isLoading = false,
|
|
35
|
+
emptyLabel = "No options available",
|
|
36
|
+
className,
|
|
37
|
+
id,
|
|
38
|
+
}: FilterSelectProps) {
|
|
39
|
+
const generatedId = useId();
|
|
40
|
+
const selectId = id ?? generatedId;
|
|
41
|
+
const items = useMemo(
|
|
42
|
+
() => [{ value: ALL_VALUE, label: placeholder }, ...options],
|
|
43
|
+
[options, placeholder],
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className={cn("space-y-2 min-w-36", className)}>
|
|
48
|
+
<Label htmlFor={selectId}>{label}</Label>
|
|
49
|
+
<Select value={value} onValueChange={onChange} items={items}>
|
|
50
|
+
<SelectTrigger
|
|
51
|
+
id={selectId}
|
|
52
|
+
disabled={disabled}
|
|
53
|
+
aria-label={label}
|
|
54
|
+
className="w-full"
|
|
55
|
+
>
|
|
56
|
+
<SelectValue placeholder={placeholder} />
|
|
57
|
+
</SelectTrigger>
|
|
58
|
+
<SelectContent className="bg-background max-h-[250px] min-w-36 overflow-y-auto">
|
|
59
|
+
<SelectItem value={ALL_VALUE}>{placeholder}</SelectItem>
|
|
60
|
+
{isLoading ? (
|
|
61
|
+
<div className="text-muted-foreground px-2 py-1.5 text-sm">Loading…</div>
|
|
62
|
+
) : options.length === 0 ? (
|
|
63
|
+
<div className="text-muted-foreground px-2 py-1.5 text-sm">{emptyLabel}</div>
|
|
64
|
+
) : (
|
|
65
|
+
options.map((option) => (
|
|
66
|
+
<SelectItem key={option.value} value={option.value}>
|
|
67
|
+
{option.label}
|
|
68
|
+
</SelectItem>
|
|
69
|
+
))
|
|
70
|
+
)}
|
|
71
|
+
</SelectContent>
|
|
72
|
+
</Select>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { expect, within } from 'storybook/test';
|
|
3
|
+
|
|
4
|
+
import { Input } from '../forms/input';
|
|
5
|
+
import { Filters } from './filters';
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
title: 'Filters/Compound',
|
|
9
|
+
component: Filters,
|
|
10
|
+
tags: ['autodocs'],
|
|
11
|
+
args: {
|
|
12
|
+
children: null,
|
|
13
|
+
},
|
|
14
|
+
} satisfies Meta<typeof Filters>;
|
|
15
|
+
|
|
16
|
+
export default meta;
|
|
17
|
+
type Story = StoryObj<typeof meta>;
|
|
18
|
+
|
|
19
|
+
const statusOptions = [
|
|
20
|
+
{ value: 'active', label: 'Active' },
|
|
21
|
+
{ value: 'interview', label: 'Interview' },
|
|
22
|
+
{ value: 'offer', label: 'Offer' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export const Default: Story = {
|
|
26
|
+
render: () => (
|
|
27
|
+
<div className="w-[720px]">
|
|
28
|
+
<Filters>
|
|
29
|
+
<Filters.Search label="Search">
|
|
30
|
+
<Input placeholder="Search applications..." aria-label="Search applications" />
|
|
31
|
+
</Filters.Search>
|
|
32
|
+
<Filters.Select
|
|
33
|
+
value="active"
|
|
34
|
+
options={statusOptions}
|
|
35
|
+
onChange={() => {}}
|
|
36
|
+
placeholder="All statuses"
|
|
37
|
+
label="Status"
|
|
38
|
+
/>
|
|
39
|
+
<Filters.Select
|
|
40
|
+
value=""
|
|
41
|
+
options={[{ value: 'engineer', label: 'Engineer' }]}
|
|
42
|
+
onChange={() => {}}
|
|
43
|
+
placeholder="All roles"
|
|
44
|
+
label="Role"
|
|
45
|
+
/>
|
|
46
|
+
<Filters.Active
|
|
47
|
+
filters={[
|
|
48
|
+
{ id: 'status', label: 'Status: Active', onRemove: () => {} },
|
|
49
|
+
{ id: 'sort', label: 'Date', onRemove: () => {}, variant: 'sort', direction: 'desc' },
|
|
50
|
+
]}
|
|
51
|
+
onClearAll={() => {}}
|
|
52
|
+
/>
|
|
53
|
+
</Filters>
|
|
54
|
+
</div>
|
|
55
|
+
),
|
|
56
|
+
play: async ({ canvasElement }) => {
|
|
57
|
+
const canvas = within(canvasElement);
|
|
58
|
+
|
|
59
|
+
await expect(canvas.getByText('Search')).toBeInTheDocument();
|
|
60
|
+
await expect(canvas.getByRole('textbox', { name: 'Search applications' })).toBeInTheDocument();
|
|
61
|
+
|
|
62
|
+
await expect(canvas.getByText('Status')).toBeInTheDocument();
|
|
63
|
+
await expect(canvas.getByRole('combobox', { name: 'Status' })).toBeInTheDocument();
|
|
64
|
+
|
|
65
|
+
await expect(canvas.getByText('Role')).toBeInTheDocument();
|
|
66
|
+
await expect(canvas.getByRole('combobox', { name: 'Role' })).toBeInTheDocument();
|
|
67
|
+
|
|
68
|
+
await expect(canvas.getByText('Status: Active')).toBeInTheDocument();
|
|
69
|
+
await expect(canvas.getByText('Date')).toBeInTheDocument();
|
|
70
|
+
await expect(canvas.getByRole('button', { name: 'Clear all' })).toBeInTheDocument();
|
|
71
|
+
},
|
|
72
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Children, isValidElement, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../../lib/utils";
|
|
4
|
+
import { ActiveFiltersBar, type ActiveFiltersBarProps } from "./active-filters-bar";
|
|
5
|
+
import { FilterSelect, type FilterSelectProps } from "./filter-select";
|
|
6
|
+
import { Label } from "../primitives/label";
|
|
7
|
+
|
|
8
|
+
export function FiltersSearch({
|
|
9
|
+
label,
|
|
10
|
+
children,
|
|
11
|
+
}: {
|
|
12
|
+
label: string;
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
}) {
|
|
15
|
+
const id = `filters-search-${label.toLowerCase().replace(/\s+/g, "-")}`;
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex-1 space-y-2">
|
|
18
|
+
<Label htmlFor={id}>{label}</Label>
|
|
19
|
+
{children}
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
FiltersSearch.slot = "search";
|
|
24
|
+
|
|
25
|
+
export function FiltersSelect(props: FilterSelectProps) {
|
|
26
|
+
return <FilterSelect {...props} label={props.label ?? props.placeholder} />;
|
|
27
|
+
}
|
|
28
|
+
FiltersSelect.slot = "select";
|
|
29
|
+
|
|
30
|
+
export function FiltersActive(props: ActiveFiltersBarProps) {
|
|
31
|
+
return <ActiveFiltersBar {...props} />;
|
|
32
|
+
}
|
|
33
|
+
FiltersActive.slot = "active";
|
|
34
|
+
|
|
35
|
+
export interface FiltersProps {
|
|
36
|
+
children: ReactNode;
|
|
37
|
+
className?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function FiltersRoot({ children, className }: FiltersProps) {
|
|
41
|
+
const search: ReactNode[] = [];
|
|
42
|
+
const selects: ReactNode[] = [];
|
|
43
|
+
const active: ReactNode[] = [];
|
|
44
|
+
|
|
45
|
+
Children.forEach(children, (child) => {
|
|
46
|
+
if (!isValidElement(child)) return;
|
|
47
|
+
const slot = (child.type as any)?.slot;
|
|
48
|
+
if (slot === "search") search.push(child);
|
|
49
|
+
else if (slot === "select") selects.push(child);
|
|
50
|
+
else if (slot === "active") active.push(child);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className={cn("flex flex-col gap-4", className)}>
|
|
55
|
+
<div className="flex flex-col gap-3 lg:flex-row lg:items-start">
|
|
56
|
+
{search}
|
|
57
|
+
{selects.map((s, i) => (
|
|
58
|
+
<div key={i} className="sm:w-48">
|
|
59
|
+
{s}
|
|
60
|
+
</div>
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
{active}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type FiltersComponent = typeof FiltersRoot & {
|
|
69
|
+
Search: typeof FiltersSearch;
|
|
70
|
+
Select: typeof FiltersSelect;
|
|
71
|
+
Active: typeof FiltersActive;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const Filters: FiltersComponent = Object.assign(FiltersRoot, {
|
|
75
|
+
Search: FiltersSearch,
|
|
76
|
+
Select: FiltersSelect,
|
|
77
|
+
Active: FiltersActive,
|
|
78
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { ActiveFiltersBar } from "./active-filters-bar";
|
|
2
|
+
export type { ActiveFilter, ActiveFiltersBarProps } from "./active-filters-bar";
|
|
3
|
+
export { FilterChip } from "./filter-chip";
|
|
4
|
+
export type { FilterChipProps } from "./filter-chip";
|
|
5
|
+
export { FilterSelect } from "./filter-select";
|
|
6
|
+
export type { FilterSelectOption, FilterSelectProps } from "./filter-select";
|
|
7
|
+
export { Filters } from "./filters";
|
|
8
|
+
export type { FiltersProps } from "./filters";
|
|
9
|
+
export { SearchFilterBar } from "./search-filter-bar";
|
|
10
|
+
export type { SearchFilterBarProps } from "./search-filter-bar";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
|
|
3
|
+
import { Input } from '../forms/input';
|
|
4
|
+
import { FilterSelect } from './filter-select';
|
|
5
|
+
import { SearchFilterBar } from './search-filter-bar';
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
title: 'Filters/SearchFilterBar',
|
|
9
|
+
component: SearchFilterBar,
|
|
10
|
+
tags: ['autodocs'],
|
|
11
|
+
} satisfies Meta<typeof SearchFilterBar>;
|
|
12
|
+
|
|
13
|
+
export default meta;
|
|
14
|
+
type Story = StoryObj<typeof meta>;
|
|
15
|
+
|
|
16
|
+
export const Legacy: Story = {
|
|
17
|
+
args: {
|
|
18
|
+
activeFilters: [],
|
|
19
|
+
},
|
|
20
|
+
render: () => (
|
|
21
|
+
<div className="w-[720px]">
|
|
22
|
+
<SearchFilterBar
|
|
23
|
+
activeFilters={[
|
|
24
|
+
{ id: 'status', label: 'Status: Active', onRemove: () => {} },
|
|
25
|
+
]}
|
|
26
|
+
onClearAll={() => {}}
|
|
27
|
+
search={<Input placeholder="Search..." aria-label="Search" />}
|
|
28
|
+
filters={[
|
|
29
|
+
<FilterSelect
|
|
30
|
+
key="status"
|
|
31
|
+
value="active"
|
|
32
|
+
options={[{ value: 'active', label: 'Active' }]}
|
|
33
|
+
onChange={() => {}}
|
|
34
|
+
placeholder="All"
|
|
35
|
+
label="Status"
|
|
36
|
+
/>,
|
|
37
|
+
]}
|
|
38
|
+
/>
|
|
39
|
+
</div>
|
|
40
|
+
),
|
|
41
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../lib/utils';
|
|
4
|
+
import { ActiveFiltersBar, type ActiveFilter } from './active-filters-bar';
|
|
5
|
+
|
|
6
|
+
export interface SearchFilterBarProps {
|
|
7
|
+
activeFilters: ActiveFilter[];
|
|
8
|
+
onClearAll?: () => void;
|
|
9
|
+
search?: ReactNode;
|
|
10
|
+
filters?: ReactNode[];
|
|
11
|
+
results?: ReactNode;
|
|
12
|
+
children?: ReactNode;
|
|
13
|
+
className?: string;
|
|
14
|
+
filterItemClassName?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function SearchFilterBar({
|
|
18
|
+
activeFilters,
|
|
19
|
+
onClearAll,
|
|
20
|
+
search,
|
|
21
|
+
filters,
|
|
22
|
+
results,
|
|
23
|
+
children,
|
|
24
|
+
className,
|
|
25
|
+
filterItemClassName = 'sm:w-48',
|
|
26
|
+
}: SearchFilterBarProps) {
|
|
27
|
+
return (
|
|
28
|
+
<div className={cn('flex flex-col gap-4', className)}>
|
|
29
|
+
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
|
30
|
+
{search ? <div className="flex-1">{search}</div> : null}
|
|
31
|
+
|
|
32
|
+
{filters?.map((filter, index) => (
|
|
33
|
+
<div key={index} className={filterItemClassName}>
|
|
34
|
+
{filter}
|
|
35
|
+
</div>
|
|
36
|
+
))}
|
|
37
|
+
|
|
38
|
+
{results ? (
|
|
39
|
+
<div className="self-start lg:ml-auto lg:self-auto">{results}</div>
|
|
40
|
+
) : null}
|
|
41
|
+
|
|
42
|
+
{children}
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<ActiveFiltersBar filters={activeFilters} onClearAll={onClearAll} />
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -6,10 +6,10 @@ export { DateMonthSelect, getCurrentMonthYear } from "./date-month-select";
|
|
|
6
6
|
export type { DateMonthSelectProps } from "./date-month-select";
|
|
7
7
|
export { DropZone } from "./drop-zone";
|
|
8
8
|
export type { DropZoneProps } from "./drop-zone";
|
|
9
|
-
export { EntitySelect } from "./entity-select";
|
|
10
|
-
export type { EntitySelectOption, EntitySelectProps } from "./entity-select";
|
|
11
9
|
export { Field } from "./field";
|
|
12
10
|
export type { FieldProps } from "./field";
|
|
11
|
+
export { FilterSelect } from "../filters/filter-select";
|
|
12
|
+
export type { FilterSelectOption, FilterSelectProps } from "../filters/filter-select";
|
|
13
13
|
export { GroupBySelect } from "./group-by-select";
|
|
14
14
|
export type { GroupBySelectProps } from "./group-by-select";
|
|
15
15
|
export { Input } from "./input";
|