@k3-tech/react-kit 0.0.61 → 0.0.62

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.
Files changed (33) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +482 -9
  4. package/dist/kit/builder/data-table/components/DataTable.d.ts +3 -1
  5. package/dist/kit/builder/data-table/components/DataTable.d.ts.map +1 -1
  6. package/dist/kit/components/data-table-headers/DataTableHeader.d.ts +25 -0
  7. package/dist/kit/components/data-table-headers/DataTableHeader.d.ts.map +1 -0
  8. package/dist/kit/components/data-table-headers/DebouncedFilterField.d.ts +23 -0
  9. package/dist/kit/components/data-table-headers/DebouncedFilterField.d.ts.map +1 -0
  10. package/dist/kit/components/data-table-headers/FilterFieldRenderer.d.ts +19 -0
  11. package/dist/kit/components/data-table-headers/FilterFieldRenderer.d.ts.map +1 -0
  12. package/dist/kit/components/data-table-headers/index.d.ts +7 -0
  13. package/dist/kit/components/data-table-headers/index.d.ts.map +1 -0
  14. package/dist/kit/components/data-table-headers/types.d.ts +76 -0
  15. package/dist/kit/components/data-table-headers/types.d.ts.map +1 -0
  16. package/dist/kit/components/data-table-headers/urlSerialization.d.ts +12 -0
  17. package/dist/kit/components/data-table-headers/urlSerialization.d.ts.map +1 -0
  18. package/dist/kit/components/data-table-headers/useDataTableHeaderUrl.d.ts +36 -0
  19. package/dist/kit/components/data-table-headers/useDataTableHeaderUrl.d.ts.map +1 -0
  20. package/dist/kit/themes/clean-slate.css +8 -0
  21. package/dist/kit/themes/default.css +8 -0
  22. package/dist/kit/themes/minimal-modern.css +8 -0
  23. package/dist/kit/themes/spotify.css +8 -0
  24. package/package.json +1 -1
  25. package/src/index.ts +1 -0
  26. package/src/kit/builder/data-table/components/DataTable.tsx +5 -2
  27. package/src/kit/components/data-table-headers/DataTableHeader.tsx +158 -0
  28. package/src/kit/components/data-table-headers/DebouncedFilterField.tsx +83 -0
  29. package/src/kit/components/data-table-headers/FilterFieldRenderer.tsx +194 -0
  30. package/src/kit/components/data-table-headers/index.ts +6 -0
  31. package/src/kit/components/data-table-headers/types.ts +89 -0
  32. package/src/kit/components/data-table-headers/urlSerialization.ts +173 -0
  33. package/src/kit/components/data-table-headers/useDataTableHeaderUrl.ts +107 -0
@@ -0,0 +1,194 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { Control, FieldValues } from 'react-hook-form';
3
+ import { cn } from '@/shadcn/lib/utils';
4
+ import { Label } from '@/shadcn/ui/label';
5
+ import {
6
+ AutocompleteField,
7
+ CheckboxField,
8
+ DateField,
9
+ DatePickerField,
10
+ DateRangePickerField,
11
+ DateTimePickerField,
12
+ DateTimeRangePickerField,
13
+ FileField,
14
+ MonthPickerField,
15
+ MonthRangePickerField,
16
+ RadioField,
17
+ SelectField,
18
+ SwitchField,
19
+ TimePickerField,
20
+ TimeRangePickerField,
21
+ } from '@/kit/builder/form/components/fields';
22
+ import type { FieldRenderProps } from '@/kit/builder/form/components/fields/types';
23
+ import { DebouncedFilterField } from './DebouncedFilterField';
24
+ import type { FilterFieldConfig } from './types';
25
+
26
+ /** Free-typing types that get an internal debounce. */
27
+ const DEBOUNCED_TYPES = new Set([
28
+ 'text',
29
+ 'email',
30
+ 'password',
31
+ 'number',
32
+ 'textarea',
33
+ ]);
34
+
35
+ /** Types not supported inside a filter header (require RHF or are non-inputs). */
36
+ const UNSUPPORTED_TYPES = new Set([
37
+ 'object',
38
+ 'array',
39
+ 'custom_field',
40
+ 'hidden',
41
+ ]);
42
+
43
+ const warned = new Set<string>();
44
+ const warnUnsupported = (type: string) => {
45
+ if (process.env.NODE_ENV !== 'production' && !warned.has(type)) {
46
+ warned.add(type);
47
+ // eslint-disable-next-line no-console
48
+ console.warn(
49
+ `[DataTableHeader] field type '${type}' is not supported in filter headers and was skipped.`,
50
+ );
51
+ }
52
+ };
53
+
54
+ export interface FilterFieldRendererProps {
55
+ field: FilterFieldConfig;
56
+ /** Current value for this field (from the external values map). */
57
+ value: unknown;
58
+ /** Push a new value for this field upstream. */
59
+ onChange: (value: unknown) => void;
60
+ debounceMs: number;
61
+ /** Throwaway control shim so date fields' `useWatch` does not throw. */
62
+ control: Control<FieldValues>;
63
+ }
64
+
65
+ /**
66
+ * Renders a single filter field by mirroring FormBuilderField's type → component
67
+ * mapping, but WITHOUT react-hook-form's `useController`. Values flow in via
68
+ * props and out via `onChange`; the header owns no field state.
69
+ */
70
+ export function FilterFieldRenderer({
71
+ field,
72
+ value,
73
+ onChange,
74
+ debounceMs,
75
+ control,
76
+ }: FilterFieldRendererProps) {
77
+ if (UNSUPPORTED_TYPES.has(field.type)) {
78
+ warnUnsupported(field.type);
79
+ return null;
80
+ }
81
+
82
+ const inputClassName = cn('bg-background', field.className);
83
+
84
+ let input: ReactNode;
85
+
86
+ if (DEBOUNCED_TYPES.has(field.type)) {
87
+ input = (
88
+ <DebouncedFilterField
89
+ field={field}
90
+ value={value}
91
+ onChange={onChange}
92
+ debounceMs={debounceMs}
93
+ control={control}
94
+ className={inputClassName}
95
+ />
96
+ );
97
+ } else {
98
+ const common: FieldRenderProps = {
99
+ field,
100
+ control,
101
+ fieldPath: field.name,
102
+ value,
103
+ onChange,
104
+ className: inputClassName,
105
+ };
106
+
107
+ switch (field.type) {
108
+ case 'select':
109
+ input = <SelectField {...common} />;
110
+ break;
111
+ case 'autocomplete':
112
+ input = <AutocompleteField {...common} />;
113
+ break;
114
+ case 'checkbox':
115
+ input = <CheckboxField {...common} />;
116
+ break;
117
+ case 'switch':
118
+ input = <SwitchField {...common} />;
119
+ break;
120
+ case 'radio':
121
+ input = <RadioField {...common} />;
122
+ break;
123
+ case 'date':
124
+ input = <DateField {...common} />;
125
+ break;
126
+ case 'date_picker':
127
+ input = <DatePickerField {...common} />;
128
+ break;
129
+ case 'date_range':
130
+ input = <DateRangePickerField {...common} />;
131
+ break;
132
+ case 'date_time':
133
+ input = <DateTimePickerField {...common} />;
134
+ break;
135
+ case 'date_time_range':
136
+ input = <DateTimeRangePickerField {...common} />;
137
+ break;
138
+ case 'month':
139
+ input = <MonthPickerField {...common} />;
140
+ break;
141
+ case 'month_range':
142
+ input = <MonthRangePickerField {...common} />;
143
+ break;
144
+ case 'time':
145
+ input = <TimePickerField {...common} />;
146
+ break;
147
+ case 'time_range':
148
+ input = <TimeRangePickerField {...common} />;
149
+ break;
150
+ case 'file':
151
+ input = <FileField {...common} />;
152
+ break;
153
+ default:
154
+ // Unknown but not explicitly unsupported: fall back to plain text.
155
+ input = (
156
+ <DebouncedFilterField
157
+ field={field}
158
+ value={value}
159
+ onChange={onChange}
160
+ debounceMs={debounceMs}
161
+ control={control}
162
+ className={inputClassName}
163
+ />
164
+ );
165
+ }
166
+ }
167
+
168
+ // Show a label only when an explicit, non-blank label is provided and not
169
+ // hidden — filters commonly rely on placeholders alone.
170
+ const labelText =
171
+ typeof field.label === 'string' ? field.label.trim() : field.label;
172
+ const showLabel =
173
+ field.labelPlacement !== 'hidden' &&
174
+ labelText != null &&
175
+ labelText !== '' &&
176
+ field.type !== 'checkbox' &&
177
+ field.type !== 'switch';
178
+
179
+ return (
180
+ <div
181
+ className={cn(
182
+ 'w-full space-y-1 sm:w-auto sm:min-w-[180px]',
183
+ field.wrapperClassName,
184
+ )}
185
+ >
186
+ {showLabel && (
187
+ <Label htmlFor={field.name} className="text-xs text-muted-foreground">
188
+ {field.label}
189
+ </Label>
190
+ )}
191
+ {input}
192
+ </div>
193
+ );
194
+ }
@@ -0,0 +1,6 @@
1
+ export * from './DataTableHeader';
2
+ export * from './FilterFieldRenderer';
3
+ export * from './DebouncedFilterField';
4
+ export * from './useDataTableHeaderUrl';
5
+ export * from './urlSerialization';
6
+ export * from './types';
@@ -0,0 +1,89 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { DataTableAction } from '@/kit/builder/data-table/types';
3
+ import type { FormBuilderFieldConfig } from '@/kit/builder/form/types';
4
+
5
+ /**
6
+ * A single filter field definition. Reuses {@link FormBuilderFieldConfig} so
7
+ * field definitions for the header work exactly like FormBuilder fields, but
8
+ * the header renders them WITHOUT react-hook-form and holds no value state.
9
+ *
10
+ * Supported `type`s: text, email, password, number (plain), textarea, select,
11
+ * autocomplete, checkbox, switch, radio, month, month_range, time, time_range,
12
+ * and the date family (date, date_picker, date_range, date_time,
13
+ * date_time_range).
14
+ *
15
+ * Unsupported in a filter header: object, array, custom_field, hidden — these
16
+ * are skipped with a dev warning.
17
+ */
18
+ export type FilterFieldConfig = FormBuilderFieldConfig;
19
+
20
+ /**
21
+ * Action shown on the right side of the header. Reuses the DataTable action
22
+ * shape and rendering, mirroring DataTable's `renderActionButton`.
23
+ */
24
+ export type DataTableHeaderAction = DataTableAction;
25
+
26
+ /**
27
+ * Adapter that lets the URL sync work against an arbitrary URL source instead
28
+ * of the browser History API. Provide this in environments with their own
29
+ * router (e.g. TanStack Router) so the header does not fight the router for
30
+ * control of the URL.
31
+ */
32
+ export interface UrlSyncAdapter {
33
+ /** Read the current query params as a flat map (repeated keys -> string[]). */
34
+ read: () => Record<string, string | string[]>;
35
+ /** Write the given query params back to the URL. */
36
+ write: (params: Record<string, string | string[]>) => void;
37
+ }
38
+
39
+ /**
40
+ * URL sync option.
41
+ * - `false` / `undefined`: disabled.
42
+ * - `true`: use the native History API (URLSearchParams + replaceState).
43
+ * - `UrlSyncAdapter`: use the provided adapter (recommended when a router owns
44
+ * the URL).
45
+ */
46
+ export type UrlSyncOption = boolean | UrlSyncAdapter;
47
+
48
+ export interface DataTableHeaderProps {
49
+ /** Filter field definitions (reuses FormBuilder field config). */
50
+ fields: FilterFieldConfig[];
51
+
52
+ /**
53
+ * Externally-owned filter values, keyed by `field.name`. This is the single
54
+ * source of truth — the header never holds its own copy.
55
+ */
56
+ values: Record<string, unknown>;
57
+
58
+ /** Called whenever a single field changes (debounced for free-typing inputs). */
59
+ onChange: (name: string, value: unknown) => void;
60
+
61
+ /**
62
+ * Bulk setter used to hydrate values from the URL on mount. If omitted, the
63
+ * header falls back to issuing one `onChange` per hydrated field.
64
+ */
65
+ onChangeAll?: (next: Record<string, unknown>) => void;
66
+
67
+ /** Debounce (ms) applied to free-typing inputs. Default 400. */
68
+ debounceMs?: number;
69
+
70
+ /** Enable URL sync. See {@link UrlSyncOption}. Default disabled. */
71
+ url?: UrlSyncOption;
72
+
73
+ /** Right-side action buttons. */
74
+ actions?: DataTableHeaderAction[];
75
+
76
+ /**
77
+ * Refresh handler. When provided, a default refresh button (mirroring the
78
+ * DataTable's standard refresh button) is rendered before the actions. Omit
79
+ * to hide it.
80
+ */
81
+ onRefresh?: () => void | Promise<void>;
82
+
83
+ /** Optional content rendered between filters and actions. */
84
+ children?: ReactNode;
85
+
86
+ className?: string;
87
+ filtersClassName?: string;
88
+ actionsClassName?: string;
89
+ }
@@ -0,0 +1,173 @@
1
+ import type { FilterFieldConfig } from './types';
2
+
3
+ /**
4
+ * Type-aware (de)serialization between filter values and URL query params.
5
+ *
6
+ * Design rules:
7
+ * - `null` / `''` / `undefined` values are OMITTED (a missing key means "no
8
+ * filter"); we never serialize empty values.
9
+ * - Numbers round-trip via String/Number (NaN dropped).
10
+ * - Booleans (checkbox/switch) round-trip via "true"/"false".
11
+ * - Multiple values (multi-select / multi autocomplete / arrays) use repeated
12
+ * keys, read back via `getAll`.
13
+ * - Date-family values serialize to ISO; ranges use two suffixed keys
14
+ * `<name>_from` / `<name>_to`.
15
+ * - select/radio values are recovered against `field.options` so the original
16
+ * typed value (string | number | boolean | null) is preserved.
17
+ * - Unsupported field types (object/array/custom_field/hidden) are skipped.
18
+ */
19
+
20
+ const DATE_TYPES = new Set([
21
+ 'date',
22
+ 'date_picker',
23
+ 'month',
24
+ 'time',
25
+ 'date_time',
26
+ ]);
27
+ const RANGE_TYPES = new Set([
28
+ 'date_range',
29
+ 'date_time_range',
30
+ 'month_range',
31
+ 'time_range',
32
+ ]);
33
+ const SKIP_TYPES = new Set(['object', 'array', 'custom_field', 'hidden']);
34
+
35
+ const isEmpty = (v: unknown) => v === null || v === undefined || v === '';
36
+
37
+ const toIso = (v: unknown): string | null => {
38
+ const d = v instanceof Date ? v : new Date(v as string);
39
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
40
+ };
41
+
42
+ const fromIso = (raw: string): Date | null => {
43
+ const d = new Date(raw);
44
+ return Number.isNaN(d.getTime()) ? null : d;
45
+ };
46
+
47
+ /** Recover a select/radio value's original type by matching against options. */
48
+ const recoverOptionValue = (field: FilterFieldConfig, raw: string): unknown => {
49
+ const match = field.options?.find((o) => String(o.value) === raw);
50
+ return match ? match.value : raw;
51
+ };
52
+
53
+ /** Numeric-looking strings become numbers (autocomplete IDs); else stay strings. */
54
+ const coerceScalar = (raw: string): string | number =>
55
+ raw !== '' && !Number.isNaN(Number(raw)) ? Number(raw) : raw;
56
+
57
+ /**
58
+ * Serialize a full values object into a query-param map. Repeated keys are
59
+ * represented as string[]; everything else as string.
60
+ */
61
+ export function serializeAll(
62
+ fields: FilterFieldConfig[],
63
+ values: Record<string, unknown>,
64
+ ): Record<string, string | string[]> {
65
+ const params: Record<string, string | string[]> = {};
66
+
67
+ for (const field of fields) {
68
+ if (SKIP_TYPES.has(field.type)) continue;
69
+ const name = field.name;
70
+ const value = values[name];
71
+
72
+ if (RANGE_TYPES.has(field.type)) {
73
+ const range = value as { from?: unknown; to?: unknown } | null | undefined;
74
+ const from = range && !isEmpty(range.from) ? toIso(range.from) : null;
75
+ const to = range && !isEmpty(range.to) ? toIso(range.to) : null;
76
+ if (from) params[`${name}_from`] = from;
77
+ if (to) params[`${name}_to`] = to;
78
+ continue;
79
+ }
80
+
81
+ if (isEmpty(value)) continue;
82
+
83
+ if (Array.isArray(value)) {
84
+ const arr = value
85
+ .filter((v) => !isEmpty(v))
86
+ .map((v) => String(v));
87
+ if (arr.length) params[name] = arr;
88
+ continue;
89
+ }
90
+
91
+ if (DATE_TYPES.has(field.type)) {
92
+ const iso = toIso(value);
93
+ if (iso) params[name] = iso;
94
+ continue;
95
+ }
96
+
97
+ params[name] = String(value);
98
+ }
99
+
100
+ return params;
101
+ }
102
+
103
+ /**
104
+ * Deserialize a query-param map back into typed filter values, guided by field
105
+ * definitions. Only keys present in the params are included in the result.
106
+ */
107
+ export function deserializeAll(
108
+ fields: FilterFieldConfig[],
109
+ params: Record<string, string | string[]>,
110
+ ): Record<string, unknown> {
111
+ const result: Record<string, unknown> = {};
112
+ const first = (v: string | string[] | undefined) =>
113
+ Array.isArray(v) ? v[0] : v;
114
+
115
+ for (const field of fields) {
116
+ if (SKIP_TYPES.has(field.type)) continue;
117
+ const name = field.name;
118
+
119
+ if (RANGE_TYPES.has(field.type)) {
120
+ const from = first(params[`${name}_from`]);
121
+ const to = first(params[`${name}_to`]);
122
+ const range: { from?: Date; to?: Date } = {};
123
+ if (from) {
124
+ const d = fromIso(from);
125
+ if (d) range.from = d;
126
+ }
127
+ if (to) {
128
+ const d = fromIso(to);
129
+ if (d) range.to = d;
130
+ }
131
+ if (range.from || range.to) result[name] = range;
132
+ continue;
133
+ }
134
+
135
+ const raw = params[name];
136
+ if (raw === undefined) continue;
137
+
138
+ if (field.multiple || Array.isArray(raw)) {
139
+ const arr = (Array.isArray(raw) ? raw : [raw]).map(coerceScalar);
140
+ result[name] = arr;
141
+ continue;
142
+ }
143
+
144
+ const value = raw as string;
145
+
146
+ switch (field.type) {
147
+ case 'number':
148
+ if (value !== '' && !Number.isNaN(Number(value)))
149
+ result[name] = Number(value);
150
+ break;
151
+ case 'checkbox':
152
+ case 'switch':
153
+ result[name] = value === 'true';
154
+ break;
155
+ case 'select':
156
+ case 'radio':
157
+ result[name] = recoverOptionValue(field, value);
158
+ break;
159
+ case 'autocomplete':
160
+ result[name] = coerceScalar(value);
161
+ break;
162
+ default:
163
+ if (DATE_TYPES.has(field.type)) {
164
+ const d = fromIso(value);
165
+ if (d) result[name] = d;
166
+ } else {
167
+ result[name] = value;
168
+ }
169
+ }
170
+ }
171
+
172
+ return result;
173
+ }
@@ -0,0 +1,107 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { useForm } from 'react-hook-form';
3
+ import type { Control, FieldValues } from 'react-hook-form';
4
+ import type { FilterFieldConfig, UrlSyncAdapter } from './types';
5
+ import { deserializeAll, serializeAll } from './urlSerialization';
6
+
7
+ /**
8
+ * Default URL adapter backed by the browser History API. Suitable for
9
+ * router-less contexts (plain apps, Storybook). In an app whose router owns the
10
+ * URL (e.g. TanStack Router), pass a custom {@link UrlSyncAdapter} instead so
11
+ * the header does not fight the router.
12
+ */
13
+ export const nativeAdapter: UrlSyncAdapter = {
14
+ read: () => {
15
+ if (typeof window === 'undefined') return {};
16
+ const usp = new URLSearchParams(window.location.search);
17
+ const out: Record<string, string | string[]> = {};
18
+ for (const key of usp.keys()) {
19
+ if (key in out) continue;
20
+ const all = usp.getAll(key);
21
+ out[key] = all.length > 1 ? all : all[0];
22
+ }
23
+ return out;
24
+ },
25
+ write: (params) => {
26
+ if (typeof window === 'undefined') return;
27
+ const usp = new URLSearchParams();
28
+ for (const [key, value] of Object.entries(params)) {
29
+ if (Array.isArray(value))
30
+ value.forEach((v) => {
31
+ usp.append(key, v);
32
+ });
33
+ else usp.append(key, value);
34
+ }
35
+ const query = usp.toString();
36
+ const url = query
37
+ ? `${window.location.pathname}?${query}`
38
+ : window.location.pathname;
39
+ window.history.replaceState(null, '', url);
40
+ },
41
+ };
42
+
43
+ export interface UseDataTableHeaderUrlOptions {
44
+ enabled: boolean;
45
+ fields: FilterFieldConfig[];
46
+ values: Record<string, unknown>;
47
+ adapter: UrlSyncAdapter;
48
+ /** Bulk hydrate used on mount. */
49
+ onHydrate: (next: Record<string, unknown>) => void;
50
+ }
51
+
52
+ /**
53
+ * Two-way URL sync for the filter header:
54
+ * - On mount, reads params from the adapter and hydrates the external values.
55
+ * - On every values change, writes the serialized params back to the adapter.
56
+ *
57
+ * Holds no filter state of its own; the external `values` remain the single
58
+ * source of truth.
59
+ */
60
+ export function useDataTableHeaderUrl({
61
+ enabled,
62
+ fields,
63
+ values,
64
+ adapter,
65
+ onHydrate,
66
+ }: UseDataTableHeaderUrlOptions): void {
67
+ const hydratedRef = useRef(false);
68
+ const lastWrittenRef = useRef<string | null>(null);
69
+
70
+ // Mount: read from URL -> hydrate external state (once).
71
+ // biome-ignore lint: _
72
+ useEffect(() => {
73
+ if (!enabled || hydratedRef.current) return;
74
+ hydratedRef.current = true;
75
+ const params = adapter.read();
76
+ const hydrated = deserializeAll(fields, params);
77
+ // Record what we're hydrating so the write-effect doesn't echo it back.
78
+ lastWrittenRef.current = JSON.stringify(serializeAll(fields, hydrated));
79
+ if (Object.keys(hydrated).length) onHydrate(hydrated);
80
+ // eslint-disable-next-line react-hooks/exhaustive-deps
81
+ }, [enabled]);
82
+
83
+ // Changes: write serialized values back to the URL (deduped).
84
+ // biome-ignore lint: _
85
+ useEffect(() => {
86
+ if (!enabled) return;
87
+ const params = serializeAll(fields, values);
88
+ const serialized = JSON.stringify(params);
89
+ if (serialized === lastWrittenRef.current) return;
90
+ lastWrittenRef.current = serialized;
91
+ adapter.write(params);
92
+ // eslint-disable-next-line react-hooks/exhaustive-deps
93
+ }, [enabled, values]);
94
+ }
95
+
96
+ /**
97
+ * A throwaway react-hook-form control used solely to satisfy date field
98
+ * components that call `useWatch({ control })`. It holds NO filter data —
99
+ * nothing ever writes to it — so it does not violate the "no internal state"
100
+ * rule. Cross-field date constraints (`minDateFromField`/`maxDateFromField`)
101
+ * resolve against this empty form and therefore won't apply in filters; static
102
+ * `minDate`/`maxDate` still work.
103
+ */
104
+ export function useFilterControl(): Control<FieldValues> {
105
+ const { control } = useForm<FieldValues>();
106
+ return control;
107
+ }