@ponti-studios/ui 0.4.4 → 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/README.md +9 -10
- package/package.json +6 -2
- 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/forms/input.tsx +1 -1
- 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
|
@@ -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";
|
|
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|
|
8
8
|
type={type}
|
|
9
9
|
data-slot="input"
|
|
10
10
|
className={cn(
|
|
11
|
-
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-background focus-visible:ring-ring border-input h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base transition-colors file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
|
11
|
+
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-background text-foreground focus-visible:ring-ring border-input h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base transition-colors file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
|
12
12
|
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
|
|
13
13
|
className,
|
|
14
14
|
)}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { ScrollArea } from "./scroll-area";
|
|
2
2
|
export type { ScrollAreaProps } from "./scroll-area";
|
|
3
|
+
export { SearchFilterBar } from "../filters/search-filter-bar";
|
|
4
|
+
export type { SearchFilterBarProps } from "../filters/search-filter-bar";
|
|
3
5
|
export { SectionIntro } from "./section-intro";
|
|
4
6
|
export type { SectionIntroProps } from "./section-intro";
|
|
5
7
|
export { SurfacePanel } from "./surface-panel";
|
package/src/hooks/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { useApiClient } from "./use-api-client";
|
|
2
2
|
export { useDebouncedValue } from "./use-debounced-value";
|
|
3
|
+
export { useDerivedFilterState } from "./use-derived-filter-state";
|
|
4
|
+
export type { FilterConfig } from "./use-derived-filter-state";
|
|
3
5
|
export { useFilterState } from "./use-filter-state";
|
|
4
6
|
export { useMediaQuery } from "./use-media-query";
|
|
5
7
|
export { useIsMobile } from "./use-mobile";
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { expect, userEvent, within } from 'storybook/test';
|
|
3
|
+
import { useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
import { useDerivedFilterState, type FilterConfig } from './use-derived-filter-state';
|
|
6
|
+
|
|
7
|
+
function createMemorySync(initial: Record<string, string>) {
|
|
8
|
+
const store = { ...initial };
|
|
9
|
+
return {
|
|
10
|
+
read: () => ({ ...store }),
|
|
11
|
+
write: (values: Record<string, string>) => Object.assign(store, values),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const fields = {
|
|
16
|
+
status: { default: "", label: (v: string) => `Status: ${v}` } satisfies FilterConfig,
|
|
17
|
+
search: { default: "" } satisfies FilterConfig,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function Harness() {
|
|
21
|
+
const sync = useRef(createMemorySync({ status: "", search: "" }));
|
|
22
|
+
const { values, setValue, activeFilters, clearAll, hasActive } =
|
|
23
|
+
useDerivedFilterState({ fields, sync: sync.current });
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div data-testid="harness">
|
|
27
|
+
<span data-testid="values">{JSON.stringify(values)}</span>
|
|
28
|
+
<span data-testid="active-filters">
|
|
29
|
+
{activeFilters.map((f) => f.label).join(',')}
|
|
30
|
+
</span>
|
|
31
|
+
<span data-testid="has-active">{String(hasActive)}</span>
|
|
32
|
+
<button
|
|
33
|
+
data-testid="set-status"
|
|
34
|
+
onClick={() => setValue('status', 'active')}
|
|
35
|
+
>
|
|
36
|
+
Set status
|
|
37
|
+
</button>
|
|
38
|
+
<button
|
|
39
|
+
data-testid="set-search"
|
|
40
|
+
onClick={() => setValue('search', 'hello')}
|
|
41
|
+
>
|
|
42
|
+
Set search
|
|
43
|
+
</button>
|
|
44
|
+
<button
|
|
45
|
+
data-testid="clear-status"
|
|
46
|
+
onClick={() => setValue('status', '')}
|
|
47
|
+
>
|
|
48
|
+
Clear status
|
|
49
|
+
</button>
|
|
50
|
+
<button data-testid="clear-all" onClick={() => clearAll()}>
|
|
51
|
+
Clear all
|
|
52
|
+
</button>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const meta: Meta<typeof Harness> = {
|
|
58
|
+
title: 'Internal/useDerivedFilterState',
|
|
59
|
+
component: Harness,
|
|
60
|
+
tags: ['autodocs'],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export default meta;
|
|
64
|
+
type Story = StoryObj<typeof meta>;
|
|
65
|
+
|
|
66
|
+
export const InitialState: Story = {
|
|
67
|
+
play: async ({ canvasElement }) => {
|
|
68
|
+
const canvas = within(canvasElement);
|
|
69
|
+
await expect(canvas.getByTestId('values')).toHaveTextContent(
|
|
70
|
+
'{"status":"","search":""}',
|
|
71
|
+
);
|
|
72
|
+
await expect(canvas.getByTestId('has-active')).toHaveTextContent('false');
|
|
73
|
+
await expect(canvas.getByTestId('active-filters')).toHaveTextContent('');
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const SetValueCreatesActiveFilter: Story = {
|
|
78
|
+
play: async ({ canvasElement }) => {
|
|
79
|
+
const canvas = within(canvasElement);
|
|
80
|
+
await userEvent.click(canvas.getByTestId('set-status'));
|
|
81
|
+
await expect(canvas.getByTestId('values')).toHaveTextContent(
|
|
82
|
+
'{"status":"active","search":""}',
|
|
83
|
+
);
|
|
84
|
+
await expect(canvas.getByTestId('active-filters')).toHaveTextContent(
|
|
85
|
+
'Status: active',
|
|
86
|
+
);
|
|
87
|
+
await expect(canvas.getByTestId('has-active')).toHaveTextContent('true');
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const ClearValueRemovesActiveFilter: Story = {
|
|
92
|
+
play: async ({ canvasElement }) => {
|
|
93
|
+
const canvas = within(canvasElement);
|
|
94
|
+
await userEvent.click(canvas.getByTestId('set-status'));
|
|
95
|
+
await userEvent.click(canvas.getByTestId('clear-status'));
|
|
96
|
+
await expect(canvas.getByTestId('values')).toHaveTextContent(
|
|
97
|
+
'{"status":"","search":""}',
|
|
98
|
+
);
|
|
99
|
+
await expect(canvas.getByTestId('has-active')).toHaveTextContent('false');
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const ClearAllResetsEverything: Story = {
|
|
104
|
+
play: async ({ canvasElement }) => {
|
|
105
|
+
const canvas = within(canvasElement);
|
|
106
|
+
await userEvent.click(canvas.getByTestId('set-status'));
|
|
107
|
+
await userEvent.click(canvas.getByTestId('set-search'));
|
|
108
|
+
await userEvent.click(canvas.getByTestId('clear-all'));
|
|
109
|
+
await expect(canvas.getByTestId('values')).toHaveTextContent(
|
|
110
|
+
'{"status":"","search":""}',
|
|
111
|
+
);
|
|
112
|
+
await expect(canvas.getByTestId('has-active')).toHaveTextContent('false');
|
|
113
|
+
await expect(canvas.getByTestId('active-filters')).toHaveTextContent('');
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const ActiveFilterOnRemoveClearsValue: Story = {
|
|
118
|
+
play: async ({ canvasElement }) => {
|
|
119
|
+
const canvas = within(canvasElement);
|
|
120
|
+
await userEvent.click(canvas.getByTestId('set-status'));
|
|
121
|
+
await expect(canvas.getByTestId('active-filters')).toHaveTextContent(
|
|
122
|
+
'Status: active',
|
|
123
|
+
);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const MultipleActiveFilters: Story = {
|
|
128
|
+
play: async ({ canvasElement }) => {
|
|
129
|
+
const canvas = within(canvasElement);
|
|
130
|
+
await userEvent.click(canvas.getByTestId('set-status'));
|
|
131
|
+
await userEvent.click(canvas.getByTestId('set-search'));
|
|
132
|
+
await expect(canvas.getByTestId('active-filters')).toHaveTextContent(
|
|
133
|
+
'Status: active,search: hello',
|
|
134
|
+
);
|
|
135
|
+
},
|
|
136
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import type { ActiveFilter } from "../components/filters/active-filters-bar";
|
|
4
|
+
|
|
5
|
+
export interface FilterConfig {
|
|
6
|
+
default: string;
|
|
7
|
+
label?: (value: string) => string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface UseDerivedFilterStateOptions<T extends Record<string, FilterConfig>> {
|
|
11
|
+
fields: T;
|
|
12
|
+
sync: {
|
|
13
|
+
read: () => Record<string, string>;
|
|
14
|
+
write: (values: Record<string, string>) => void;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function useDerivedFilterState<T extends Record<string, FilterConfig>>({
|
|
19
|
+
fields,
|
|
20
|
+
sync,
|
|
21
|
+
}: UseDerivedFilterStateOptions<T>) {
|
|
22
|
+
const [values, setValues] = useState<Record<string, string>>(() => {
|
|
23
|
+
const read = sync.read();
|
|
24
|
+
const initial: Record<string, string> = {};
|
|
25
|
+
for (const key of Object.keys(fields)) {
|
|
26
|
+
initial[key] = read[key] ?? fields[key].default;
|
|
27
|
+
}
|
|
28
|
+
return initial;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const setValue = useCallback(
|
|
32
|
+
(key: string, value: string) => {
|
|
33
|
+
setValues((prev) => {
|
|
34
|
+
const next = { ...prev, [key]: value };
|
|
35
|
+
sync.write(next);
|
|
36
|
+
return next;
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
[sync],
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const clearAll = useCallback(() => {
|
|
43
|
+
const reset: Record<string, string> = {};
|
|
44
|
+
for (const key of Object.keys(fields)) {
|
|
45
|
+
reset[key] = fields[key].default;
|
|
46
|
+
}
|
|
47
|
+
setValues(reset);
|
|
48
|
+
sync.write(reset);
|
|
49
|
+
}, [fields, sync]);
|
|
50
|
+
|
|
51
|
+
const activeFilters: ActiveFilter[] = useMemo(() => {
|
|
52
|
+
return Object.entries(values)
|
|
53
|
+
.filter(([, v]) => v !== "")
|
|
54
|
+
.map(([key, v]) => ({
|
|
55
|
+
id: key,
|
|
56
|
+
label: fields[key]?.label
|
|
57
|
+
? fields[key].label!(v)
|
|
58
|
+
: `${key}: ${v}`,
|
|
59
|
+
onRemove: () => setValue(key, ""),
|
|
60
|
+
}));
|
|
61
|
+
}, [values, fields, setValue]);
|
|
62
|
+
|
|
63
|
+
const hasActive = activeFilters.length > 0;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
values,
|
|
67
|
+
setValue,
|
|
68
|
+
activeFilters,
|
|
69
|
+
clearAll,
|
|
70
|
+
hasActive,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { expect, userEvent, within } from 'storybook/test';
|
|
3
|
+
import { useFilterState } from './use-filter-state';
|
|
4
|
+
|
|
5
|
+
type Filters = Record<string, string>;
|
|
6
|
+
|
|
7
|
+
function Harness({
|
|
8
|
+
initialFilters,
|
|
9
|
+
debounceMs,
|
|
10
|
+
}: {
|
|
11
|
+
initialFilters: Filters;
|
|
12
|
+
debounceMs?: number;
|
|
13
|
+
}) {
|
|
14
|
+
const { filters, updateFilter, setFilters, clearFilters } = useFilterState<Filters>({
|
|
15
|
+
initialFilters,
|
|
16
|
+
debounceMs,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div data-testid="harness">
|
|
21
|
+
<span data-testid="filters">{JSON.stringify(filters)}</span>
|
|
22
|
+
<button
|
|
23
|
+
data-testid="update-status"
|
|
24
|
+
onClick={() => updateFilter('status', 'active')}
|
|
25
|
+
>
|
|
26
|
+
Set status to active
|
|
27
|
+
</button>
|
|
28
|
+
<button
|
|
29
|
+
data-testid="update-search"
|
|
30
|
+
onClick={() => updateFilter('search', 'hello')}
|
|
31
|
+
>
|
|
32
|
+
Set search to hello
|
|
33
|
+
</button>
|
|
34
|
+
<button
|
|
35
|
+
data-testid="set-filters"
|
|
36
|
+
onClick={() => setFilters({ status: 'rejected', search: 'test' })}
|
|
37
|
+
>
|
|
38
|
+
Set all filters
|
|
39
|
+
</button>
|
|
40
|
+
<button data-testid="clear" onClick={() => clearFilters()}>
|
|
41
|
+
Clear
|
|
42
|
+
</button>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const meta: Meta<typeof Harness> = {
|
|
48
|
+
title: 'Internal/useFilterState',
|
|
49
|
+
component: Harness,
|
|
50
|
+
tags: ['autodocs'],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export default meta;
|
|
54
|
+
type Story = StoryObj<typeof meta>;
|
|
55
|
+
|
|
56
|
+
export const InitialState: Story = {
|
|
57
|
+
args: {
|
|
58
|
+
initialFilters: { status: '', search: '' },
|
|
59
|
+
},
|
|
60
|
+
play: async ({ canvasElement }) => {
|
|
61
|
+
const canvas = within(canvasElement);
|
|
62
|
+
const output = canvas.getByTestId('filters');
|
|
63
|
+
await expect(output).toHaveTextContent('{"status":"","search":""}');
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const UpdateSingleFilter: Story = {
|
|
68
|
+
args: {
|
|
69
|
+
initialFilters: { status: '', search: '' },
|
|
70
|
+
},
|
|
71
|
+
play: async ({ canvasElement }) => {
|
|
72
|
+
const canvas = within(canvasElement);
|
|
73
|
+
await userEvent.click(canvas.getByTestId('update-status'));
|
|
74
|
+
const output = canvas.getByTestId('filters');
|
|
75
|
+
await expect(output).toHaveTextContent('{"status":"active","search":""}');
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const SetAllFilters: Story = {
|
|
80
|
+
args: {
|
|
81
|
+
initialFilters: { status: '', search: '' },
|
|
82
|
+
},
|
|
83
|
+
play: async ({ canvasElement }) => {
|
|
84
|
+
const canvas = within(canvasElement);
|
|
85
|
+
await userEvent.click(canvas.getByTestId('set-filters'));
|
|
86
|
+
const output = canvas.getByTestId('filters');
|
|
87
|
+
await expect(output).toHaveTextContent('{"status":"rejected","search":"test"}');
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const ClearResetsToInitial: Story = {
|
|
92
|
+
args: {
|
|
93
|
+
initialFilters: { status: 'pending', search: 'default' },
|
|
94
|
+
},
|
|
95
|
+
play: async ({ canvasElement }) => {
|
|
96
|
+
const canvas = within(canvasElement);
|
|
97
|
+
await userEvent.click(canvas.getByTestId('update-status'));
|
|
98
|
+
await userEvent.click(canvas.getByTestId('clear'));
|
|
99
|
+
const output = canvas.getByTestId('filters');
|
|
100
|
+
await expect(output).toHaveTextContent('{"status":"pending","search":"default"}');
|
|
101
|
+
},
|
|
102
|
+
};
|
|
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
3
3
|
interface UseFilterStateOptions<T> {
|
|
4
4
|
initialFilters: T;
|
|
5
5
|
onFiltersChange?: (filters: T) => void;
|
|
6
|
-
debounceMs?: number;
|
|
6
|
+
debounceMs?: number;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
export function useFilterState<T extends Record<string, unknown>>(
|
|
@@ -12,24 +12,30 @@ export function useFilterState<T extends Record<string, unknown>>(
|
|
|
12
12
|
const { initialFilters, onFiltersChange, debounceMs } = options;
|
|
13
13
|
const [filters, setFiltersState] = useState<T>(initialFilters);
|
|
14
14
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
15
|
+
const onFiltersChangeRef = useRef(onFiltersChange);
|
|
16
|
+
onFiltersChangeRef.current = onFiltersChange;
|
|
15
17
|
|
|
16
18
|
const setFilters = useCallback(
|
|
17
19
|
(newFilters: T | ((prev: T) => T)) => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
setFiltersState((prev) => {
|
|
21
|
+
const updatedFilters =
|
|
22
|
+
typeof newFilters === "function" ? newFilters(prev) : newFilters;
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
if (debounceMs && debounceMs > 0) {
|
|
25
|
+
if (debounceTimerRef.current) {
|
|
26
|
+
clearTimeout(debounceTimerRef.current);
|
|
27
|
+
}
|
|
28
|
+
debounceTimerRef.current = setTimeout(() => {
|
|
29
|
+
onFiltersChangeRef.current?.(updatedFilters);
|
|
30
|
+
}, debounceMs);
|
|
31
|
+
} else {
|
|
32
|
+
onFiltersChangeRef.current?.(updatedFilters);
|
|
24
33
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
} else {
|
|
29
|
-
onFiltersChange?.(updatedFilters);
|
|
30
|
-
}
|
|
34
|
+
|
|
35
|
+
return updatedFilters;
|
|
36
|
+
});
|
|
31
37
|
},
|
|
32
|
-
[
|
|
38
|
+
[debounceMs],
|
|
33
39
|
);
|
|
34
40
|
|
|
35
41
|
const updateFilter = useCallback(
|
|
@@ -41,15 +47,9 @@ export function useFilterState<T extends Record<string, unknown>>(
|
|
|
41
47
|
|
|
42
48
|
const clearFilters = useCallback(() => {
|
|
43
49
|
setFiltersState(initialFilters);
|
|
44
|
-
|
|
45
|
-
}, [initialFilters
|
|
46
|
-
|
|
47
|
-
const resetFilters = useCallback(() => {
|
|
48
|
-
setFiltersState(initialFilters);
|
|
49
|
-
onFiltersChange?.(initialFilters);
|
|
50
|
-
}, [initialFilters, onFiltersChange]);
|
|
50
|
+
onFiltersChangeRef.current?.(initialFilters);
|
|
51
|
+
}, [initialFilters]);
|
|
51
52
|
|
|
52
|
-
// Cleanup debounce timer on unmount
|
|
53
53
|
useEffect(() => {
|
|
54
54
|
return () => {
|
|
55
55
|
if (debounceTimerRef.current) {
|
|
@@ -63,6 +63,5 @@ export function useFilterState<T extends Record<string, unknown>>(
|
|
|
63
63
|
setFilters,
|
|
64
64
|
updateFilter,
|
|
65
65
|
clearFilters,
|
|
66
|
-
resetFilters,
|
|
67
66
|
};
|
|
68
67
|
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,8 @@ export { CHART_COLORS, CHART_CSS_VARS } from "./constants/chart-colors";
|
|
|
2
2
|
export type { SortDirection, SortField, SortOption } from "./hooks/sort.types";
|
|
3
3
|
export { useApiClient } from "./hooks/use-api-client";
|
|
4
4
|
export { useDebouncedValue } from "./hooks/use-debounced-value";
|
|
5
|
+
export { useDerivedFilterState } from "./hooks/use-derived-filter-state";
|
|
6
|
+
export type { FilterConfig } from "./hooks/use-derived-filter-state";
|
|
5
7
|
export { useFilterState } from "./hooks/use-filter-state";
|
|
6
8
|
export { useMediaQuery } from "./hooks/use-media-query";
|
|
7
9
|
export { useIsMobile } from "./hooks/use-mobile";
|
package/src/styles/index.css
CHANGED
|
@@ -90,6 +90,37 @@
|
|
|
90
90
|
@utility min-h-screen-dynamic {
|
|
91
91
|
min-height: 100dvh;
|
|
92
92
|
}
|
|
93
|
+
/* ── scroll-fade ──
|
|
94
|
+
* Progressive opacity mask on scroll-container edges.
|
|
95
|
+
* Uses CSS custom properties so consumers can override the fade size
|
|
96
|
+
* or disable the mask without a class order dependency:
|
|
97
|
+
*
|
|
98
|
+
* <div class="scroll-fade-x overflow-x-auto [--scroll-fade-size:24px]">
|
|
99
|
+
* <div class="scroll-fade-y overflow-y-auto scroll-fade-none md:scroll-fade">
|
|
100
|
+
*
|
|
101
|
+
* Default fade size: 16 px (scoped to the element via inline var).
|
|
102
|
+
* Disable with scroll-fade-none (sets --scroll-fade-mask: none). */
|
|
103
|
+
@utility scroll-fade-x {
|
|
104
|
+
mask-image: var(--scroll-fade-mask, linear-gradient(to right, transparent, black var(--scroll-fade-size, 16px), black calc(100% - var(--scroll-fade-size, 16px)), transparent));
|
|
105
|
+
}
|
|
106
|
+
@utility scroll-fade-y {
|
|
107
|
+
mask-image: var(--scroll-fade-mask, linear-gradient(to bottom, transparent, black var(--scroll-fade-size, 16px), black calc(100% - var(--scroll-fade-size, 16px)), transparent));
|
|
108
|
+
}
|
|
109
|
+
@utility scroll-fade-l {
|
|
110
|
+
mask-image: var(--scroll-fade-mask, linear-gradient(to right, transparent, black var(--scroll-fade-size, 16px)));
|
|
111
|
+
}
|
|
112
|
+
@utility scroll-fade-r {
|
|
113
|
+
mask-image: var(--scroll-fade-mask, linear-gradient(to left, transparent, black var(--scroll-fade-size, 16px)));
|
|
114
|
+
}
|
|
115
|
+
@utility scroll-fade-t {
|
|
116
|
+
mask-image: var(--scroll-fade-mask, linear-gradient(to bottom, transparent, black var(--scroll-fade-size, 16px)));
|
|
117
|
+
}
|
|
118
|
+
@utility scroll-fade-b {
|
|
119
|
+
mask-image: var(--scroll-fade-mask, linear-gradient(to top, transparent, black var(--scroll-fade-size, 16px)));
|
|
120
|
+
}
|
|
121
|
+
@utility scroll-fade-none {
|
|
122
|
+
--scroll-fade-mask: none;
|
|
123
|
+
}
|
|
93
124
|
@utility h-screen {
|
|
94
125
|
height: 100vh;
|
|
95
126
|
height: 100dvh;
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
|
|
3
|
-
import { FilterChip } from "./filter-chip";
|
|
4
|
-
|
|
5
|
-
const meta = {
|
|
6
|
-
title: "DataDisplay/FilterChip",
|
|
7
|
-
component: FilterChip,
|
|
8
|
-
tags: ["autodocs"],
|
|
9
|
-
args: {
|
|
10
|
-
onRemove: () => {},
|
|
11
|
-
},
|
|
12
|
-
} satisfies Meta<typeof FilterChip>;
|
|
13
|
-
|
|
14
|
-
export default meta;
|
|
15
|
-
type Story = StoryObj<typeof meta>;
|
|
16
|
-
|
|
17
|
-
export const Default: Story = {
|
|
18
|
-
args: { label: "Status: Active" },
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export const Editable: Story = {
|
|
22
|
-
args: { label: "Status: Active", onClick: () => {} },
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
export const LongLabel: Story = {
|
|
26
|
-
args: { label: "Created between Jan 1, 2026 and Jul 24, 2026" },
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
export const Group: Story = {
|
|
30
|
-
args: { label: "Filters" },
|
|
31
|
-
render: () => (
|
|
32
|
-
<div className="flex flex-wrap gap-2">
|
|
33
|
-
<FilterChip label="Status: Active" onRemove={() => {}} onClick={() => {}} />
|
|
34
|
-
<FilterChip label="Team: Design" onRemove={() => {}} onClick={() => {}} />
|
|
35
|
-
<FilterChip label="Archived" onRemove={() => {}} />
|
|
36
|
-
</div>
|
|
37
|
-
),
|
|
38
|
-
};
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { 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
|
-
}
|
|
12
|
-
|
|
13
|
-
/** An applied filter with an optional edit action and an explicit remove action. */
|
|
14
|
-
export function FilterChip({ label, onRemove, onClick, className }: FilterChipProps) {
|
|
15
|
-
const labelContent = onClick ? (
|
|
16
|
-
<button
|
|
17
|
-
type="button"
|
|
18
|
-
className="focus-visible:ring-ring min-h-9 rounded-full px-3 text-left transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
|
19
|
-
onClick={onClick}
|
|
20
|
-
>
|
|
21
|
-
{label}
|
|
22
|
-
</button>
|
|
23
|
-
) : (
|
|
24
|
-
<span className="flex min-h-9 items-center px-3">{label}</span>
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
return (
|
|
28
|
-
<span
|
|
29
|
-
role="group"
|
|
30
|
-
aria-label={typeof label === "string" ? `${label} filter` : "filter"}
|
|
31
|
-
className={cn(
|
|
32
|
-
"border-border bg-secondary text-secondary-foreground inline-flex items-center rounded-full border text-sm font-medium",
|
|
33
|
-
className,
|
|
34
|
-
)}
|
|
35
|
-
>
|
|
36
|
-
{labelContent}
|
|
37
|
-
<button
|
|
38
|
-
type="button"
|
|
39
|
-
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring mr-1 flex size-9 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
|
40
|
-
onClick={onRemove}
|
|
41
|
-
aria-label={`Remove filter: ${typeof label === "string" ? label : "selected filter"}`}
|
|
42
|
-
>
|
|
43
|
-
<X className="size-4" aria-hidden />
|
|
44
|
-
</button>
|
|
45
|
-
</span>
|
|
46
|
-
);
|
|
47
|
-
}
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
import { useState } from "react";
|
|
3
|
-
|
|
4
|
-
import { EntitySelect } from "./entity-select";
|
|
5
|
-
|
|
6
|
-
const meta = {
|
|
7
|
-
title: "Forms/EntitySelect",
|
|
8
|
-
component: EntitySelect,
|
|
9
|
-
tags: ["autodocs"],
|
|
10
|
-
} satisfies Meta<typeof EntitySelect>;
|
|
11
|
-
|
|
12
|
-
export default meta;
|
|
13
|
-
type Story = StoryObj<typeof meta>;
|
|
14
|
-
|
|
15
|
-
const options = [
|
|
16
|
-
{ id: "checking", name: "Checking" },
|
|
17
|
-
{ id: "savings", name: "Savings" },
|
|
18
|
-
{ id: "credit-card", name: "Credit Card" },
|
|
19
|
-
];
|
|
20
|
-
|
|
21
|
-
function EntitySelectPreview() {
|
|
22
|
-
const [value, setValue] = useState("all");
|
|
23
|
-
return (
|
|
24
|
-
<EntitySelect
|
|
25
|
-
value={value}
|
|
26
|
-
onValueChange={setValue}
|
|
27
|
-
options={options}
|
|
28
|
-
allOptionLabel="All accounts"
|
|
29
|
-
placeholder="All accounts"
|
|
30
|
-
label="Account"
|
|
31
|
-
showLabel
|
|
32
|
-
/>
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export const Default: Story = {
|
|
37
|
-
args: {
|
|
38
|
-
value: "all",
|
|
39
|
-
onValueChange: () => {},
|
|
40
|
-
options,
|
|
41
|
-
},
|
|
42
|
-
render: () => <EntitySelectPreview />,
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
export const Loading: Story = {
|
|
46
|
-
args: {
|
|
47
|
-
value: "all",
|
|
48
|
-
onValueChange: () => {},
|
|
49
|
-
options: [],
|
|
50
|
-
isLoading: true,
|
|
51
|
-
label: "Account",
|
|
52
|
-
showLabel: true,
|
|
53
|
-
},
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
export const Empty: Story = {
|
|
57
|
-
args: {
|
|
58
|
-
value: "all",
|
|
59
|
-
onValueChange: () => {},
|
|
60
|
-
options: [],
|
|
61
|
-
label: "Account",
|
|
62
|
-
showLabel: true,
|
|
63
|
-
emptyLabel: "No accounts available",
|
|
64
|
-
},
|
|
65
|
-
};
|