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