@arcote.tech/arc-ds 0.7.26 → 0.7.28

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-ds",
3
3
  "type": "module",
4
- "version": "0.7.26",
4
+ "version": "0.7.28",
5
5
  "private": false,
6
6
  "author": "Przemysław Krasiński [arcote.tech]",
7
7
  "description": "Design System for Arc framework — CVA-based components with display modes and variant overrides",
@@ -30,7 +30,7 @@
30
30
  "tailwind-merge": "^3.5.0"
31
31
  },
32
32
  "peerDependencies": {
33
- "@arcote.tech/arc": "^0.7.26",
33
+ "@arcote.tech/arc": "^0.7.28",
34
34
  "framer-motion": "^12.0.0",
35
35
  "lucide-react": ">=0.400.0",
36
36
  "radix-ui": "^1.0.0",
@@ -1,5 +1,7 @@
1
1
  export { TextField } from "./text-field";
2
2
  export type { TextFieldProps } from "./text-field";
3
+ export { NumberField } from "./number-field";
4
+ export type { NumberFieldProps } from "./number-field";
3
5
  export { TextareaField } from "./textarea-field";
4
6
  export type { TextareaFieldProps } from "./textarea-field";
5
7
  export { TagField } from "./tag-field";
@@ -0,0 +1,63 @@
1
+ import { useContext } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { Input } from "../../input/input";
4
+ import { FormFieldContext } from "../field";
5
+
6
+ export interface NumberFieldProps {
7
+ label?: ReactNode;
8
+ placeholder?: string;
9
+ /** Wartość liczbowa pola; `undefined` = puste. */
10
+ value?: number;
11
+ /** Emituje `number` dla poprawnej liczby albo `undefined` dla pustego/niepełnego wejścia. */
12
+ onChange?: (value: number | undefined) => void;
13
+ min?: number;
14
+ max?: number;
15
+ step?: number;
16
+ className?: string;
17
+ }
18
+
19
+ /**
20
+ * Pole liczbowe formularza. Bliźniak `TextField`, ale binduje wprost do pola
21
+ * `number()` schematu — emituje `number | undefined`, więc nie trzeba ręcznej
22
+ * koercji `Number(...)` na onSubmit (wcześniejszy wzorzec obejścia).
23
+ */
24
+ export function NumberField({
25
+ label,
26
+ placeholder,
27
+ value,
28
+ onChange,
29
+ min,
30
+ max,
31
+ step,
32
+ className,
33
+ }: NumberFieldProps) {
34
+ const fieldCtx = useContext(FormFieldContext);
35
+ const hasError = fieldCtx?.errors && fieldCtx.messages?.length > 0;
36
+
37
+ return (
38
+ <div className={className}>
39
+ {label && (
40
+ <label className="block text-sm font-medium text-muted-foreground mb-1.5">
41
+ {label}
42
+ </label>
43
+ )}
44
+ <Input
45
+ type="number"
46
+ inputMode="decimal"
47
+ value={value ?? ""}
48
+ min={min}
49
+ max={max}
50
+ step={step}
51
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
52
+ // valueAsNumber daje NaN dla pustego/niepełnego wejścia ("", "-") → undefined.
53
+ const next = e.target.valueAsNumber;
54
+ onChange?.(Number.isNaN(next) ? undefined : next);
55
+ }}
56
+ placeholder={placeholder}
57
+ />
58
+ {hasError && (
59
+ <p className="mt-1 text-xs text-destructive">{fieldCtx.messages[0]}</p>
60
+ )}
61
+ </div>
62
+ );
63
+ }
@@ -4,6 +4,8 @@ import {
4
4
  type ArcObjectAny,
5
5
  type ArcObjectKeys,
6
6
  type ArcObjectValueByKey,
7
+ type ArcOr,
8
+ type ArcOrAny,
7
9
  ArcOptional,
8
10
  deepMerge,
9
11
  } from "@arcote.tech/arc";
@@ -18,52 +20,83 @@ import React, {
18
20
  } from "react";
19
21
  import { FormField } from "./field";
20
22
 
21
- export type FormContextValue<T extends ArcObjectAny> = {
22
- values: Partial<Record<ArcObjectKeys<T>, any>>;
23
- errors: Partial<Record<ArcObjectKeys<T>, Record<string, any>>>;
24
- dirty: Set<ArcObjectKeys<T>>;
23
+ // Schema akceptowane przez Form: pojedynczy obiekt LUB union obiektów (`or()`).
24
+ // Dla unionu pola = suma pól wariantów, walidacja = wariant dopasowany do
25
+ // bieżącej wartości (dyskryminowany union — patrz ArcOr.validate).
26
+ export type FormSchemaAny = ArcObjectAny | ArcOrAny;
27
+
28
+ // Warianty schematu: dla `or(...)` obiektowe człony, w przeciwnym razie sam typ.
29
+ type SchemaVariant<T> = T extends ArcOr<infer Els>
30
+ ? Extract<Els[number], ArcObjectAny>
31
+ : T;
32
+
33
+ // Klucze pól = suma kluczy wszystkich wariantów (dystrybucja po unionie).
34
+ type SchemaKeys<T> = SchemaVariant<T> extends infer V
35
+ ? V extends ArcObjectAny
36
+ ? ArcObjectKeys<V>
37
+ : never
38
+ : never;
39
+
40
+ // Element pola pod kluczem K — z wariantu(ów), które ten klucz posiadają.
41
+ type SchemaValueByKey<T, K extends PropertyKey> = SchemaVariant<T> extends infer V
42
+ ? V extends ArcObjectAny
43
+ ? K extends ArcObjectKeys<V>
44
+ ? ArcObjectValueByKey<V, K>
45
+ : never
46
+ : never
47
+ : never;
48
+
49
+ export type FormContextValue<T extends FormSchemaAny> = {
50
+ values: Partial<Record<SchemaKeys<T>, any>>;
51
+ errors: Partial<Record<SchemaKeys<T>, Record<string, any>>>;
52
+ dirty: Set<SchemaKeys<T>>;
25
53
  isSubmitted: boolean;
26
- setFieldValue: (field: ArcObjectKeys<T>, value: any) => void;
27
- setFieldDirty: (field: ArcObjectKeys<T>) => void;
28
- validatePartial: (keys: ArcObjectKeys<T>[]) => boolean;
54
+ setFieldValue: (field: SchemaKeys<T>, value: any) => void;
55
+ setFieldDirty: (field: SchemaKeys<T>) => void;
56
+ validatePartial: (keys: SchemaKeys<T>[]) => boolean;
29
57
  };
30
58
 
31
- export type FormRef<T extends ArcObjectAny> = {
59
+ export type FormRef<T extends FormSchemaAny> = {
32
60
  submit: () => Promise<void>;
33
61
  getValues: () => Partial<$type<T>>;
34
62
  validate: () => boolean;
35
- setFieldValue: (field: ArcObjectKeys<T>, value: any) => void;
63
+ setFieldValue: (field: SchemaKeys<T>, value: any) => void;
36
64
  };
37
65
 
38
- export type FormFields<T extends ArcObjectAny> = {
39
- [K in ArcObjectKeys<T> as Capitalize<`${K}`>]: ArcObjectValueByKey<
66
+ export type FormFields<T extends FormSchemaAny> = {
67
+ [K in SchemaKeys<T> as Capitalize<`${K}`>]: SchemaValueByKey<
40
68
  T,
41
69
  K
42
70
  > extends ArcObjectAny
43
71
  ? ReturnType<
44
72
  typeof FormField<
45
- ArcObjectValueByKey<T, K>,
46
- FormFields<ArcObjectValueByKey<T, K>>
73
+ SchemaValueByKey<T, K>,
74
+ FormFields<SchemaValueByKey<T, K>>
47
75
  >
48
76
  >
49
- : ArcObjectValueByKey<T, K> extends ArcOptional<
50
- infer U extends ArcObjectAny
51
- >
52
- ? ReturnType<typeof FormField<ArcObjectValueByKey<T, K>, FormFields<U>>>
53
- : ReturnType<typeof FormField<ArcObjectValueByKey<T, K>>>;
77
+ : SchemaValueByKey<T, K> extends ArcOptional<infer U extends ArcObjectAny>
78
+ ? ReturnType<typeof FormField<SchemaValueByKey<T, K>, FormFields<U>>>
79
+ : ReturnType<typeof FormField<SchemaValueByKey<T, K>>>;
54
80
  };
55
81
 
56
- export type FormProps<T extends ArcObjectAny> = {
82
+ export type FormProps<T extends FormSchemaAny> = {
57
83
  render: (props: FormFields<T>, values: Partial<$type<T>>) => React.ReactNode;
58
84
  schema: T;
59
85
  onSubmit: (values: $type<T>) => void | Promise<void>;
60
86
  onUnvalidatedSubmit?: (values: Partial<$type<T>>, errors: any) => void;
61
87
  defaults?: Partial<$type<T>> | null;
88
+ /**
89
+ * Wywoływane przy każdej zmianie wartości — `isValid` mówi, czy bieżące
90
+ * wartości przechodzą walidację schematu (dla `or()` — wariantu aktywnego).
91
+ * Pozwala stronie obserwować wartości i walidność bez własnego stanu pól
92
+ * (np. by gatować zewnętrzny przycisk lub zbudować snapshot z wartości).
93
+ */
94
+ onValuesChange?: (values: Partial<$type<T>>, isValid: boolean) => void;
62
95
  };
63
96
 
64
97
  export const FormContext = createContext<FormContextValue<any> | null>(null);
65
98
 
66
- export function useForm<T extends ArcObjectAny>(): FormContextValue<T> {
99
+ export function useForm<T extends FormSchemaAny>(): FormContextValue<T> {
67
100
  const context = React.useContext(FormContext);
68
101
  if (!context) {
69
102
  throw new Error("useForm must be used within a Form component");
@@ -100,16 +133,17 @@ function getNestedValue(obj: any, path: string): any {
100
133
  return path.split(".").reduce((current, key) => current?.[key], obj);
101
134
  }
102
135
 
103
- export const Form = forwardRef(function Form<T extends ArcObjectAny>(
136
+ export const Form = forwardRef(function Form<T extends FormSchemaAny>(
104
137
  props: FormProps<T>,
105
138
  ref: React.Ref<FormRef<T>>,
106
139
  ) {
107
- const { render, schema, onSubmit, defaults, onUnvalidatedSubmit } = props;
140
+ const { render, schema, onSubmit, defaults, onUnvalidatedSubmit, onValuesChange } =
141
+ props;
108
142
  const [values, setValues] = useState<Partial<$type<T>>>({});
109
143
  const [errors, setErrors] = useState<
110
- Partial<Record<ArcObjectKeys<T>, Record<string, any>>>
144
+ Partial<Record<SchemaKeys<T>, Record<string, any>>>
111
145
  >({});
112
- const [dirty, setDirty] = useState<Set<ArcObjectKeys<T>>>(new Set());
146
+ const [dirty, setDirty] = useState<Set<SchemaKeys<T>>>(new Set());
113
147
  const [isSubmitted, setIsSubmitted] = useState(false);
114
148
 
115
149
  // Set initial values from defaults prop
@@ -125,12 +159,12 @@ export const Form = forwardRef(function Form<T extends ArcObjectAny>(
125
159
  const validate = useCallback(() => {
126
160
  const errors = schema.validate(values);
127
161
  setErrors(errors as any);
128
- setDirty(new Set(schema.entries().map(([key]) => key as ArcObjectKeys<T>)));
129
- return Object.values(errors).some((result) => result);
162
+ setDirty(new Set(schema.entries().map(([key]) => key as SchemaKeys<T>)));
163
+ return Object.values(errors as any).some((result) => result);
130
164
  }, [schema, values]);
131
165
 
132
166
  const validatePartial = useCallback(
133
- (keys: ArcObjectKeys<T>[]) => {
167
+ (keys: SchemaKeys<T>[]) => {
134
168
  const partialValues = keys.reduce(
135
169
  (acc, key) => {
136
170
  const value = getNestedValue(values, key as string);
@@ -139,7 +173,7 @@ export const Form = forwardRef(function Form<T extends ArcObjectAny>(
139
173
  }
140
174
  return acc;
141
175
  },
142
- {} as Partial<Record<ArcObjectKeys<T>, any>>,
176
+ {} as Partial<Record<SchemaKeys<T>, any>>,
143
177
  );
144
178
  const errors = schema.validatePartial(partialValues);
145
179
  if (errors) setErrors((prev) => deepMerge(prev, errors as any));
@@ -153,16 +187,16 @@ export const Form = forwardRef(function Form<T extends ArcObjectAny>(
153
187
  }
154
188
  return newSet;
155
189
  });
156
- return Object.values(errors).some((result) => result);
190
+ return Object.values(errors as any).some((result) => result);
157
191
  },
158
192
  [schema, values, dirty],
159
193
  );
160
194
 
161
- const setFieldValue = useCallback((field: ArcObjectKeys<T>, value: any) => {
195
+ const setFieldValue = useCallback((field: SchemaKeys<T>, value: any) => {
162
196
  setValues((prev) => setNestedValue(prev, field as string, value));
163
197
  }, []);
164
198
 
165
- const setFieldDirty = useCallback((field: ArcObjectKeys<T>) => {
199
+ const setFieldDirty = useCallback((field: SchemaKeys<T>) => {
166
200
  setDirty((prev) => new Set([...prev, field]));
167
201
  }, []);
168
202
 
@@ -176,12 +210,19 @@ export const Form = forwardRef(function Form<T extends ArcObjectAny>(
176
210
  }
177
211
  return acc;
178
212
  },
179
- {} as Partial<Record<ArcObjectKeys<T>, any>>,
213
+ {} as Partial<Record<SchemaKeys<T>, any>>,
180
214
  );
181
215
  const errors = schema.validatePartial(partialValues);
182
216
  setErrors(errors as any);
183
217
  }, [values, dirty]);
184
218
 
219
+ // Lift wartości + walidności do rodzica (obserwowalny formularz). Walidność =
220
+ // pełna walidacja schematu (dla `or()` wariantu aktywnego) bez efektów ubocznych.
221
+ useEffect(() => {
222
+ if (!onValuesChange) return;
223
+ onValuesChange(values, !schema.validate(values));
224
+ }, [values, schema, onValuesChange]);
225
+
185
226
  const handleSubmit = useCallback(
186
227
  async (e?: React.FormEvent) => {
187
228
  if (e) {
@@ -263,7 +304,7 @@ export const Form = forwardRef(function Form<T extends ArcObjectAny>(
263
304
  .entries()
264
305
  .map(([key, value]) => [
265
306
  key.charAt(0).toUpperCase() + key.slice(1),
266
- buildFieldsStructure(value, key, defaults?.[key]),
307
+ buildFieldsStructure(value, key, (defaults as any)?.[key]),
267
308
  ]),
268
309
  );
269
310
  }, [schema, defaults, buildFieldsStructure]);
@@ -294,6 +335,6 @@ export const Form = forwardRef(function Form<T extends ArcObjectAny>(
294
335
  <form onSubmit={handleSubmit}>{render(Fields as any, values)}</form>
295
336
  </FormContext.Provider>
296
337
  );
297
- }) as <T extends ArcObjectAny>(
338
+ }) as <T extends FormSchemaAny>(
298
339
  props: FormProps<T> & React.RefAttributes<FormRef<T>>,
299
340
  ) => React.JSX.Element;
package/src/index.ts CHANGED
@@ -24,6 +24,7 @@ export {
24
24
  useFormField,
25
25
  useFormPart,
26
26
  TextField,
27
+ NumberField,
27
28
  TextareaField,
28
29
  TagField,
29
30
  SelectField,
@@ -37,6 +38,7 @@ export type {
37
38
  FormFields,
38
39
  FormFieldData,
39
40
  TextFieldProps,
41
+ NumberFieldProps,
40
42
  TextareaFieldProps,
41
43
  TagFieldProps,
42
44
  SelectFieldProps,
@@ -310,6 +310,8 @@ function MobileToolbar() {
310
310
  const toolbarRef = useRef<HTMLDivElement>(null);
311
311
  const collapseRef = useRef(collapse);
312
312
  collapseRef.current = collapse;
313
+ const isExpandedRef = useRef(isExpanded);
314
+ isExpandedRef.current = isExpanded;
313
315
 
314
316
  useEffect(() => {
315
317
  if (isExpanded) {
@@ -319,10 +321,27 @@ function MobileToolbar() {
319
321
  }
320
322
  }, [isExpanded, requestOverlay, releaseOverlay]);
321
323
 
324
+ // Blokada scrolla treści pod spodem gdy panel jest rozwinięty — panel nakłada
325
+ // się na stronę (overlay), więc scroll strony musi być zamrożony na miejscu.
326
+ useEffect(() => {
327
+ if (!isExpanded) return;
328
+ const prev = document.body.style.overflow;
329
+ document.body.style.overflow = "hidden";
330
+ return () => {
331
+ document.body.style.overflow = prev;
332
+ };
333
+ }, [isExpanded]);
334
+
322
335
  useEffect(() => {
323
336
  const el = toolbarRef.current;
324
337
  if (!el) return;
325
- const update = () => setTopHeight(el.offsetHeight);
338
+ // Mierzymy tylko wysokość ZWINIĘTEGO paska. Gdy panel jest rozwinięty,
339
+ // ignorujemy zmiany rozmiaru — rozwinięty panel ma się nakładać na treść,
340
+ // a nie wypychać jej w dół rosnącym spacerem.
341
+ const update = () => {
342
+ if (isExpandedRef.current) return;
343
+ setTopHeight(el.offsetHeight);
344
+ };
326
345
  update();
327
346
  const ro = new ResizeObserver(update);
328
347
  ro.observe(el);