@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.
@@ -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";
@@ -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; // For search-like filters
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
- const updatedFilters = typeof newFilters === "function" ? newFilters(filters) : newFilters;
19
- setFiltersState(updatedFilters);
20
+ setFiltersState((prev) => {
21
+ const updatedFilters =
22
+ typeof newFilters === "function" ? newFilters(prev) : newFilters;
20
23
 
21
- if (debounceMs && debounceMs > 0) {
22
- if (debounceTimerRef.current) {
23
- clearTimeout(debounceTimerRef.current);
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
- debounceTimerRef.current = setTimeout(() => {
26
- onFiltersChange?.(updatedFilters);
27
- }, debounceMs);
28
- } else {
29
- onFiltersChange?.(updatedFilters);
30
- }
34
+
35
+ return updatedFilters;
36
+ });
31
37
  },
32
- [filters, onFiltersChange, debounceMs],
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
- onFiltersChange?.(initialFilters);
45
- }, [initialFilters, onFiltersChange]);
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";
@@ -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
- };
@@ -1,79 +0,0 @@
1
- import { useId } from "react";
2
-
3
- import { Label } from "../primitives/label";
4
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
5
-
6
- export interface EntitySelectOption {
7
- id: string;
8
- name: string;
9
- }
10
-
11
- export interface EntitySelectProps {
12
- value: string;
13
- onValueChange: (value: string) => void;
14
- options: EntitySelectOption[];
15
- isLoading?: boolean;
16
- placeholder?: string;
17
- label?: string;
18
- showLabel?: boolean;
19
- allOptionLabel?: string;
20
- emptyLabel?: string;
21
- className?: string;
22
- }
23
-
24
- /** Labeled `Select` over an `{ id, name }[]` list, with loading/empty states and an "all" option. */
25
- export function EntitySelect({
26
- value,
27
- onValueChange,
28
- options,
29
- isLoading = false,
30
- placeholder = "All",
31
- label = "Filter",
32
- showLabel = false,
33
- allOptionLabel = "All",
34
- emptyLabel = "No options available",
35
- className,
36
- }: EntitySelectProps) {
37
- const id = useId();
38
-
39
- const selectElement = (
40
- <Select
41
- name={label}
42
- value={value}
43
- onValueChange={(v: string | null) => v != null && onValueChange(v)}
44
- >
45
- <SelectTrigger id={id} className={className}>
46
- <SelectValue placeholder={placeholder} />
47
- </SelectTrigger>
48
- <SelectContent className="max-h-[250px] overflow-y-auto">
49
- <SelectItem value="all">{allOptionLabel}</SelectItem>
50
- {isLoading ? (
51
- <SelectItem value="loading" disabled>
52
- Loading...
53
- </SelectItem>
54
- ) : options.length === 0 ? (
55
- <SelectItem value="empty" disabled>
56
- {emptyLabel}
57
- </SelectItem>
58
- ) : (
59
- options.map((option) => (
60
- <SelectItem key={option.id} value={option.id}>
61
- {option.name}
62
- </SelectItem>
63
- ))
64
- )}
65
- </SelectContent>
66
- </Select>
67
- );
68
-
69
- if (showLabel) {
70
- return (
71
- <div className="space-y-2">
72
- <Label htmlFor={id}>{label}</Label>
73
- {selectElement}
74
- </div>
75
- );
76
- }
77
-
78
- return selectElement;
79
- }