@arqel-dev/fields 0.14.0 → 0.15.1

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/dist/register.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { registerField } from '@arqel-dev/ui/form';
2
2
  import { cn } from '@arqel-dev/ui/utils';
3
3
  import { jsx, jsxs } from 'react/jsx-runtime';
4
+ import { useArqelTranslations } from '@arqel-dev/react/utils';
4
5
  import { useState, useRef, useEffect } from 'react';
5
6
 
6
7
  // src/register.ts
@@ -99,6 +100,7 @@ function ColorInput({
99
100
  describedBy
100
101
  }) {
101
102
  const f = field;
103
+ const t = useArqelTranslations();
102
104
  const hasError = errors !== void 0 && errors.length > 0;
103
105
  const isDisabled = disabled || f.disabled || f.readonly;
104
106
  const current = typeof value === "string" && value.length > 0 ? value : "#000000";
@@ -134,7 +136,7 @@ function ColorInput({
134
136
  "button",
135
137
  {
136
138
  type: "button",
137
- "aria-label": `Preset ${preset}`,
139
+ "aria-label": t("arqel.fields.color.preset_aria", "Preset :color", { color: preset }),
138
140
  disabled: isDisabled,
139
141
  onClick: () => onChange(preset),
140
142
  className: "h-6 w-6 rounded-full border border-border disabled:cursor-not-allowed disabled:opacity-50",
@@ -173,6 +175,65 @@ function DateInput({
173
175
  }
174
176
  );
175
177
  }
178
+ function hasZone(value) {
179
+ return /[zZ]$|[+-]\d{2}:?\d{2}$/.test(value.trim());
180
+ }
181
+ function toZonedLocalInput(date, timeZone, withSeconds) {
182
+ const options = {
183
+ year: "numeric",
184
+ month: "2-digit",
185
+ day: "2-digit",
186
+ hour: "2-digit",
187
+ minute: "2-digit",
188
+ hour12: false,
189
+ ...withSeconds ? { second: "2-digit" } : {}
190
+ };
191
+ let parts;
192
+ try {
193
+ parts = new Intl.DateTimeFormat("en-CA", { ...options, timeZone }).formatToParts(date);
194
+ } catch {
195
+ parts = new Intl.DateTimeFormat("en-CA", options).formatToParts(date);
196
+ }
197
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? "00";
198
+ const hour = get("hour") === "24" ? "00" : get("hour");
199
+ const date_ = `${get("year")}-${get("month")}-${get("day")}`;
200
+ const time = withSeconds ? `${hour}:${get("minute")}:${get("second")}` : `${hour}:${get("minute")}`;
201
+ return `${date_}T${time}`;
202
+ }
203
+ function zonedLocalInputToIso(wallClock, timeZone) {
204
+ const asUtc = /* @__PURE__ */ new Date(`${wallClock}Z`);
205
+ if (Number.isNaN(asUtc.getTime())) return wallClock;
206
+ const offsetMs = zoneOffsetMs(asUtc, timeZone);
207
+ return new Date(asUtc.getTime() - offsetMs).toISOString();
208
+ }
209
+ function zoneOffsetMs(date, timeZone) {
210
+ try {
211
+ const dtf = new Intl.DateTimeFormat("en-US", {
212
+ timeZone,
213
+ year: "numeric",
214
+ month: "2-digit",
215
+ day: "2-digit",
216
+ hour: "2-digit",
217
+ minute: "2-digit",
218
+ second: "2-digit",
219
+ hour12: false
220
+ });
221
+ const parts = dtf.formatToParts(date);
222
+ const get = (type) => Number(parts.find((p) => p.type === type)?.value ?? "0");
223
+ const hour = get("hour") === 24 ? 0 : get("hour");
224
+ const asIfUtc = Date.UTC(
225
+ get("year"),
226
+ get("month") - 1,
227
+ get("day"),
228
+ hour,
229
+ get("minute"),
230
+ get("second")
231
+ );
232
+ return asIfUtc - date.getTime();
233
+ } catch {
234
+ return 0;
235
+ }
236
+ }
176
237
  function DateTimeInput({
177
238
  field,
178
239
  value,
@@ -184,22 +245,43 @@ function DateTimeInput({
184
245
  }) {
185
246
  const f = field;
186
247
  const hasError = errors !== void 0 && errors.length > 0;
248
+ const timeZone = f.props.timezone;
249
+ const withSeconds = f.props.seconds === true;
250
+ const raw = typeof value === "string" ? value : "";
251
+ let display = raw;
252
+ if (timeZone !== void 0 && timeZone !== "" && raw !== "" && hasZone(raw)) {
253
+ const parsed = new Date(raw);
254
+ if (!Number.isNaN(parsed.getTime())) {
255
+ display = toZonedLocalInput(parsed, timeZone, withSeconds);
256
+ }
257
+ }
187
258
  return /* @__PURE__ */ jsx(
188
259
  "input",
189
260
  {
190
261
  id: inputId,
191
262
  type: "datetime-local",
192
263
  className: inputClasses,
193
- value: typeof value === "string" ? value : "",
264
+ value: display,
194
265
  disabled: disabled || f.disabled || f.readonly,
195
266
  readOnly: f.readonly === true,
196
267
  required: f.required === true,
197
268
  min: f.props.minDate,
198
269
  max: f.props.maxDate,
199
- step: f.props.seconds ? 1 : void 0,
270
+ step: withSeconds ? 1 : void 0,
200
271
  "aria-invalid": hasError || void 0,
201
272
  "aria-describedby": describedBy,
202
- onChange: (e) => onChange(e.target.value === "" ? null : e.target.value)
273
+ onChange: (e) => {
274
+ const next = e.target.value;
275
+ if (next === "") {
276
+ onChange(null);
277
+ return;
278
+ }
279
+ if (timeZone !== void 0 && timeZone !== "") {
280
+ onChange(zonedLocalInputToIso(next, timeZone));
281
+ return;
282
+ }
283
+ onChange(next);
284
+ }
203
285
  }
204
286
  );
205
287
  }
@@ -213,6 +295,7 @@ function FileInput({
213
295
  describedBy
214
296
  }) {
215
297
  const f = field;
298
+ const t = useArqelTranslations();
216
299
  const hasError = errors !== void 0 && errors.length > 0;
217
300
  const isDisabled = disabled || f.disabled || f.readonly;
218
301
  const [dragOver, setDragOver] = useState(false);
@@ -225,7 +308,7 @@ function FileInput({
225
308
  return /* @__PURE__ */ jsxs(
226
309
  "section",
227
310
  {
228
- "aria-label": "File upload",
311
+ "aria-label": t("arqel.fields.file.upload", "File upload"),
229
312
  onDragOver: (e) => {
230
313
  e.preventDefault();
231
314
  if (!isDisabled) setDragOver(true);
@@ -243,9 +326,9 @@ function FileInput({
243
326
  isDisabled && "opacity-50"
244
327
  ),
245
328
  children: [
246
- filename ? /* @__PURE__ */ jsx("span", { className: "font-medium", children: filename }) : /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "Drag a file here or click to browse" }),
329
+ filename ? /* @__PURE__ */ jsx("span", { className: "font-medium", children: filename }) : /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: t("arqel.fields.file.drop_hint", "Drag a file here or click to browse") }),
247
330
  /* @__PURE__ */ jsxs("label", { className: "cursor-pointer text-xs text-primary hover:underline", children: [
248
- filename ? "Choose another file" : "Browse",
331
+ filename ? t("arqel.fields.file.choose_another", "Choose another file") : t("arqel.fields.file.browse", "Browse"),
249
332
  /* @__PURE__ */ jsx(
250
333
  "input",
251
334
  {
@@ -273,6 +356,7 @@ function ImageInput({
273
356
  describedBy
274
357
  }) {
275
358
  const f = field;
359
+ const t = useArqelTranslations();
276
360
  const hasError = errors !== void 0 && errors.length > 0;
277
361
  const isDisabled = disabled || f.disabled || f.readonly;
278
362
  const [previewUrl, setPreviewUrl] = useState(null);
@@ -294,7 +378,7 @@ function ImageInput({
294
378
  "img",
295
379
  {
296
380
  src: previewUrl,
297
- alt: "Preview",
381
+ alt: t("arqel.fields.image.preview_alt", "Preview"),
298
382
  className: cn("max-h-40 w-auto rounded-sm border border-border")
299
383
  }
300
384
  ),
@@ -307,7 +391,7 @@ function ImageInput({
307
391
  isDisabled && "cursor-not-allowed opacity-50"
308
392
  ),
309
393
  children: [
310
- previewUrl ? "Replace image" : "Choose image",
394
+ previewUrl ? t("arqel.fields.image.replace", "Replace image") : t("arqel.fields.image.choose", "Choose image"),
311
395
  /* @__PURE__ */ jsx(
312
396
  "input",
313
397
  {
@@ -351,18 +435,20 @@ function CurrencyInput({
351
435
  describedBy
352
436
  }) {
353
437
  const f = field;
354
- const [focused, setFocused] = useState(false);
438
+ const [draft, setDraft] = useState(null);
355
439
  const hasError = errors !== void 0 && errors.length > 0;
356
440
  const isDisabled = disabled || f.disabled || f.readonly;
357
441
  const numeric = typeof value === "number" ? value : value === "" || value == null ? null : Number(value);
358
442
  const decimals = f.props.decimals ?? 2;
359
- const formatted = numeric === null || Number.isNaN(numeric) ? "" : `${f.props.prefix}${formatNumber(numeric, decimals, f.props.thousandsSeparator, f.props.decimalSeparator)}${f.props.suffix ?? ""}`;
360
- const display = focused ? numeric === null ? "" : String(numeric) : formatted;
443
+ const decimalSeparator = f.props.decimalSeparator ?? ".";
444
+ const focused = draft !== null;
445
+ const formatted = numeric === null || Number.isNaN(numeric) ? "" : `${f.props.prefix}${formatNumber(numeric, decimals, f.props.thousandsSeparator, decimalSeparator)}${f.props.suffix ?? ""}`;
446
+ const display = focused ? draft : formatted;
361
447
  return /* @__PURE__ */ jsx(
362
448
  "input",
363
449
  {
364
450
  id: inputId,
365
- type: focused ? "number" : "text",
451
+ type: "text",
366
452
  inputMode: "decimal",
367
453
  className: inputClasses,
368
454
  value: display,
@@ -370,25 +456,33 @@ function CurrencyInput({
370
456
  disabled: isDisabled,
371
457
  readOnly: f.readonly === true,
372
458
  required: f.required === true,
373
- min: f.props.min,
374
- max: f.props.max,
375
- step: f.props.step ?? 10 ** -decimals,
376
459
  "aria-invalid": hasError || void 0,
377
460
  "aria-describedby": describedBy,
378
- onFocus: () => setFocused(true),
379
- onBlur: () => setFocused(false),
461
+ onFocus: () => setDraft(numeric === null ? "" : toEditable(numeric, decimalSeparator)),
462
+ onBlur: () => setDraft(null),
380
463
  onChange: (e) => {
381
- const raw = e.target.value;
382
- if (raw === "") {
383
- onChange(null);
384
- return;
385
- }
386
- const num = Number(raw);
387
- onChange(Number.isNaN(num) ? null : num);
464
+ setDraft(e.target.value);
465
+ onChange(parseEditable(e.target.value, decimalSeparator));
388
466
  }
389
467
  }
390
468
  );
391
469
  }
470
+ function toEditable(num, decimal) {
471
+ const s = String(num);
472
+ return decimal === "." ? s : s.replace(".", decimal);
473
+ }
474
+ function parseEditable(raw, decimal) {
475
+ if (raw.trim() === "") {
476
+ return null;
477
+ }
478
+ let normalised = raw;
479
+ if (decimal !== ".") {
480
+ const escaped = decimal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
481
+ normalised = raw.replace(new RegExp(`[^0-9${escaped}+-]`, "g"), "").replace(decimal, ".");
482
+ }
483
+ const num = Number(normalised);
484
+ return Number.isNaN(num) ? null : num;
485
+ }
392
486
  function formatNumber(num, decimals, thousands, decimal) {
393
487
  const fixed = num.toFixed(decimals);
394
488
  const [whole, frac] = fixed.split(".");
@@ -405,6 +499,7 @@ function NumberInput({
405
499
  describedBy
406
500
  }) {
407
501
  const f = field;
502
+ const t = useArqelTranslations();
408
503
  const hasError = errors !== void 0 && errors.length > 0;
409
504
  const isDisabled = disabled || f.disabled || f.readonly;
410
505
  const setValue = (raw) => {
@@ -449,7 +544,7 @@ function NumberInput({
449
544
  "button",
450
545
  {
451
546
  type: "button",
452
- "aria-label": "Increment",
547
+ "aria-label": t("arqel.fields.increment", "Increment"),
453
548
  disabled: isDisabled,
454
549
  onClick: () => setValue((current ?? 0) + step),
455
550
  className: "flex h-1/2 w-8 items-center justify-center text-xs text-muted-foreground hover:text-foreground disabled:opacity-50",
@@ -460,7 +555,7 @@ function NumberInput({
460
555
  "button",
461
556
  {
462
557
  type: "button",
463
- "aria-label": "Decrement",
558
+ "aria-label": t("arqel.fields.decrement", "Decrement"),
464
559
  disabled: isDisabled,
465
560
  onClick: () => setValue((current ?? 0) - step),
466
561
  className: "flex h-1/2 w-8 items-center justify-center text-xs text-muted-foreground hover:text-foreground disabled:opacity-50",
@@ -480,6 +575,7 @@ function BelongsToInput({
480
575
  describedBy
481
576
  }) {
482
577
  const f = field;
578
+ const t = useArqelTranslations();
483
579
  const hasError = errors !== void 0 && errors.length > 0;
484
580
  const isDisabled = disabled || f.disabled || f.readonly;
485
581
  const [query, setQuery] = useState("");
@@ -516,7 +612,9 @@ function BelongsToInput({
516
612
  type: "text",
517
613
  className: inputClasses,
518
614
  value: open ? query : selectedLabel ?? (value === null || value === void 0 ? "" : String(value)),
519
- placeholder: f.placeholder ?? `Search ${f.props.relatedResource}\u2026`,
615
+ placeholder: f.placeholder ?? t("arqel.fields.belongsto.search", "Search :resource\u2026", {
616
+ resource: f.props.relatedResource
617
+ }),
520
618
  disabled: isDisabled,
521
619
  readOnly: !(f.readonly === true || f.readonly === void 0),
522
620
  "aria-invalid": hasError || void 0,
@@ -560,13 +658,12 @@ function BelongsToInput({
560
658
  }
561
659
  function HasManyReadonly({ field, value, inputId, describedBy }) {
562
660
  const f = field;
661
+ const t = useArqelTranslations();
563
662
  const items = Array.isArray(value) ? value : [];
564
663
  if (items.length === 0) {
565
- return /* @__PURE__ */ jsxs("p", { id: inputId, "aria-describedby": describedBy, className: "text-sm text-muted-foreground", children: [
566
- "No ",
567
- f.props.relatedResource,
568
- " linked."
569
- ] });
664
+ return /* @__PURE__ */ jsx("p", { id: inputId, "aria-describedby": describedBy, className: "text-sm text-muted-foreground", children: t("arqel.fields.has_many_empty", "No :resource linked.", {
665
+ resource: f.props.relatedResource
666
+ }) });
570
667
  }
571
668
  return /* @__PURE__ */ jsx(
572
669
  "ul",
@@ -615,6 +712,7 @@ function MultiSelectInput({
615
712
  describedBy
616
713
  }) {
617
714
  const f = field;
715
+ const t = useArqelTranslations();
618
716
  const hasError = errors !== void 0 && errors.length > 0;
619
717
  const options = normaliseOptions(f.props.options);
620
718
  const arr = Array.isArray(value) ? value : [];
@@ -636,7 +734,9 @@ function MultiSelectInput({
636
734
  "button",
637
735
  {
638
736
  type: "button",
639
- "aria-label": `Remove ${opt?.label ?? String(v)}`,
737
+ "aria-label": t("arqel.fields.multiselect.remove", "Remove :label", {
738
+ label: opt?.label ?? String(v)
739
+ }),
640
740
  onClick: () => remove(v),
641
741
  className: "text-muted-foreground hover:text-foreground",
642
742
  children: "\u2715"
@@ -747,6 +847,7 @@ function SlugInput({
747
847
  describedBy
748
848
  }) {
749
849
  const f = field;
850
+ const t = useArqelTranslations();
750
851
  const hasError = errors !== void 0 && errors.length > 0;
751
852
  return /* @__PURE__ */ jsx(
752
853
  "input",
@@ -755,7 +856,7 @@ function SlugInput({
755
856
  type: "text",
756
857
  className: inputClasses,
757
858
  value: typeof value === "string" ? value : "",
758
- placeholder: f.placeholder ?? "my-resource-slug",
859
+ placeholder: f.placeholder ?? t("arqel.fields.slug.placeholder", "my-resource-slug"),
759
860
  disabled: disabled || f.disabled || f.readonly,
760
861
  readOnly: f.readonly === true,
761
862
  required: f.required === true,
@@ -810,6 +911,7 @@ function PasswordInput({
810
911
  describedBy
811
912
  }) {
812
913
  const f = field;
914
+ const t = useArqelTranslations();
813
915
  const [revealed, setRevealed] = useState(false);
814
916
  const hasError = errors !== void 0 && errors.length > 0;
815
917
  const isDisabled = disabled || f.disabled || f.readonly;
@@ -835,7 +937,7 @@ function PasswordInput({
835
937
  "button",
836
938
  {
837
939
  type: "button",
838
- "aria-label": revealed ? "Hide password" : "Show password",
940
+ "aria-label": revealed ? t("arqel.fields.password.hide", "Hide password") : t("arqel.fields.password.show", "Show password"),
839
941
  "aria-pressed": revealed,
840
942
  disabled: isDisabled,
841
943
  onClick: () => setRevealed((v) => !v),