runeforge 0.0.45 → 0.0.47

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.
@@ -82,13 +82,46 @@
82
82
  if (!calendarDateEl) return;
83
83
  function handler() {
84
84
  if (fieldDisabled) return;
85
- record[field.attribute] = calendarDateEl!.value;
85
+ setDateTime(calendarDateEl!.value, timePart);
86
86
  datePopoverEl?.hidePopover();
87
87
  }
88
88
  calendarDateEl.addEventListener('change', handler);
89
89
  return () => calendarDateEl?.removeEventListener('change', handler);
90
90
  });
91
91
 
92
+ function pad(n: number): string {
93
+ return String(n).padStart(2, '0');
94
+ }
95
+
96
+ // `record[field.attribute]` may be empty, a bare "YYYY-MM-DD" (freshly
97
+ // picked, before any time is set) or a full ISO datetime (loaded from an
98
+ // existing instance) — parsed once here so the date button, the time
99
+ // input and cally's own `value` all derive from the same local-time
100
+ // breakdown instead of each re-parsing the raw string their own way.
101
+ const parsedDateTime = $derived.by(() => {
102
+ const raw = record[field.attribute];
103
+ if (raw == null || raw === '') return null;
104
+ const d = new Date(String(raw));
105
+ return isNaN(d.getTime()) ? null : d;
106
+ });
107
+ const datePart = $derived(
108
+ parsedDateTime
109
+ ? `${parsedDateTime.getFullYear()}-${pad(parsedDateTime.getMonth() + 1)}-${pad(parsedDateTime.getDate())}`
110
+ : ''
111
+ );
112
+ const timePart = $derived(
113
+ parsedDateTime ? `${pad(parsedDateTime.getHours())}:${pad(parsedDateTime.getMinutes())}` : ''
114
+ );
115
+ const formattedDatePart = $derived(
116
+ parsedDateTime
117
+ ? `${pad(parsedDateTime.getDate())}/${pad(parsedDateTime.getMonth() + 1)}/${parsedDateTime.getFullYear()}`
118
+ : ''
119
+ );
120
+
121
+ function setDateTime(nextDatePart: string, nextTimePart: string) {
122
+ record[field.attribute] = nextDatePart ? `${nextDatePart}T${nextTimePart || '00:00'}` : '';
123
+ }
124
+
92
125
  $effect(() => {
93
126
  if (!field.dependentOptions || isMultiValued) return;
94
127
  const current = record[field.attribute];
@@ -191,16 +224,25 @@
191
224
  value={String(record[field.attribute] ?? '')}
192
225
  disabled={fieldDisabled}
193
226
  />
194
- <button
195
- type="button"
196
- class="input input-bordered w-full text-left font-normal"
197
- class:opacity-40={!record[field.attribute]}
198
- disabled={fieldDisabled}
199
- popovertarget={datePopId}
200
- style="anchor-name:{dateAnchorName}"
201
- >
202
- {record[field.attribute] ? displayValue : (field.placeholder ?? '')}
203
- </button>
227
+ <div class="flex gap-2">
228
+ <button
229
+ type="button"
230
+ class="input input-bordered w-full text-left font-normal"
231
+ class:opacity-40={!record[field.attribute]}
232
+ disabled={fieldDisabled}
233
+ popovertarget={datePopId}
234
+ style="anchor-name:{dateAnchorName}"
235
+ >
236
+ {record[field.attribute] ? formattedDatePart : (field.placeholder ?? '')}
237
+ </button>
238
+ <input
239
+ type="time"
240
+ class="input input-bordered w-32 shrink-0"
241
+ value={timePart}
242
+ disabled={fieldDisabled || !record[field.attribute]}
243
+ onchange={(e) => setDateTime(datePart, (e.currentTarget as HTMLInputElement).value)}
244
+ />
245
+ </div>
204
246
  <div
205
247
  popover="auto"
206
248
  id={datePopId}
@@ -211,7 +253,7 @@
211
253
  <calendar-date
212
254
  class="cally"
213
255
  bind:this={calendarDateEl}
214
- value={String(record[field.attribute] ?? '')}
256
+ value={datePart}
215
257
  >
216
258
  <svg
217
259
  aria-label={strings.previous}
@@ -235,6 +277,7 @@
235
277
  {#if readonly}
236
278
  <textarea
237
279
  id={field.attribute}
280
+ rows={field.rows}
238
281
  class="textarea textarea-bordered bg-base-100 w-full"
239
282
  value={formattedValue}
240
283
  disabled
@@ -243,6 +286,7 @@
243
286
  <textarea
244
287
  id={field.attribute}
245
288
  {name}
289
+ rows={field.rows}
246
290
  placeholder={field.placeholder ?? ''}
247
291
  bind:value={record[field.attribute]}
248
292
  class="textarea textarea-bordered bg-base-100 w-full"
@@ -57,6 +57,7 @@ export function buildFieldDefinitions(meta, data, excludedFlag, excluded) {
57
57
  minLength: m.minLength,
58
58
  maxLength: m.maxLength,
59
59
  pattern: m.pattern,
60
+ rows: m.rows,
60
61
  fields: m.fields ? buildFieldDefinitions(m.fields, data, excludedFlag, new Set()) : undefined,
61
62
  itemLabel: m.itemLabel,
62
63
  defaultExpanded: m.defaultExpanded,
@@ -40,13 +40,30 @@
40
40
 
41
41
  let fieldErrors = $state<Record<string, string>>({});
42
42
  let internalError = $state('');
43
+ let successMessage = $state('');
44
+ let successTimeout: ReturnType<typeof setTimeout> | undefined;
43
45
  let continueCreating = $state(false);
46
+ let duplicating = $state(false);
47
+
48
+ function flashSuccess() {
49
+ successMessage = strings.saveSuccess;
50
+ clearTimeout(successTimeout);
51
+ successTimeout = setTimeout(() => (successMessage = ''), 4000);
52
+ }
44
53
 
45
54
  let record = $state<Record<string, unknown>>(untrack(() => emptyRecord(fields)));
46
55
 
47
56
  const groups = $derived(groupFields(fields));
48
57
  const hasFileField = $derived(fields.some((f) => f.type === 'file'));
49
58
 
59
+ const continueEnabled = $derived(creation.continue?.enabled ?? true);
60
+ const continueLabel = $derived(creation.continue?.label ?? strings.saveAndContinue);
61
+ const continueClass = $derived(creation.continue?.class ?? '');
62
+
63
+ const duplicationEnabled = $derived(creation.duplication?.enabled ?? false);
64
+ const duplicationLabel = $derived(creation.duplication?.label ?? strings.duplicate);
65
+ const duplicationClass = $derived(creation.duplication?.class ?? '');
66
+
50
67
  const errorEntries = $derived([
51
68
  ...((serverError || internalError) ? [['_global', internalError || serverError] as [string, string]] : []),
52
69
  ...Object.entries(fieldErrors),
@@ -63,6 +80,15 @@
63
80
  ]}
64
81
  />
65
82
 
83
+ {#if successMessage}
84
+ <div role="alert" class="alert alert-success">
85
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
86
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
87
+ </svg>
88
+ <span class="text-sm">{successMessage}</span>
89
+ </div>
90
+ {/if}
91
+
66
92
  {#if errorEntries.length > 0}
67
93
  <div role="alert" class="alert alert-error">
68
94
  <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -84,6 +110,8 @@
84
110
  use:enhance={({ formData, cancel }) => {
85
111
  fieldErrors = {};
86
112
  internalError = '';
113
+ successMessage = '';
114
+ clearTimeout(successTimeout);
87
115
  const errs = validateAll(fields, formData, strings);
88
116
  if (Object.keys(errs).length > 0) {
89
117
  fieldErrors = errs;
@@ -97,6 +125,11 @@
97
125
  record = emptyRecord(fields);
98
126
  fieldErrors = {};
99
127
  internalError = '';
128
+ flashSuccess();
129
+ } else if (duplicating) {
130
+ fieldErrors = {};
131
+ internalError = '';
132
+ flashSuccess();
100
133
  } else {
101
134
  onSuccess?.();
102
135
  }
@@ -145,10 +178,40 @@
145
178
  <Button variant="ghost" onclick={() => onCancel?.()}>
146
179
  {strings.cancel}
147
180
  </Button>
148
- <Button type="submit" variant="secondary" onclick={() => (continueCreating = true)}>
149
- {strings.saveAndContinue}
150
- </Button>
151
- <Button type="submit" variant="primary" onclick={() => (continueCreating = false)}>
181
+ {#if duplicationEnabled}
182
+ <Button
183
+ type="submit"
184
+ variant="warning"
185
+ class={duplicationClass}
186
+ onclick={() => {
187
+ continueCreating = false;
188
+ duplicating = true;
189
+ }}
190
+ >
191
+ {duplicationLabel}
192
+ </Button>
193
+ {/if}
194
+ {#if continueEnabled}
195
+ <Button
196
+ type="submit"
197
+ variant="secondary"
198
+ class={continueClass}
199
+ onclick={() => {
200
+ continueCreating = true;
201
+ duplicating = false;
202
+ }}
203
+ >
204
+ {continueLabel}
205
+ </Button>
206
+ {/if}
207
+ <Button
208
+ type="submit"
209
+ variant="primary"
210
+ onclick={() => {
211
+ continueCreating = false;
212
+ duplicating = false;
213
+ }}
214
+ >
152
215
  {strings.save}
153
216
  </Button>
154
217
  </div>
package/dist/i18n/en.js CHANGED
@@ -25,8 +25,10 @@ export const en = {
25
25
  exportExcel: 'Export as Excel',
26
26
  save: 'Save',
27
27
  saveAndContinue: 'Save and continue',
28
+ duplicate: 'Duplicate',
28
29
  cancel: 'Cancel',
29
30
  back: 'Back',
31
+ saveSuccess: 'Saved successfully.',
30
32
  add: 'Add',
31
33
  remove: 'Remove',
32
34
  noItems: 'No items added',
package/dist/i18n/es.js CHANGED
@@ -25,8 +25,10 @@ export const es = {
25
25
  exportExcel: 'Exportar a Excel',
26
26
  save: 'Guardar',
27
27
  saveAndContinue: 'Guardar y continuar',
28
+ duplicate: 'Duplicar',
28
29
  cancel: 'Cancelar',
29
30
  back: 'Volver',
31
+ saveSuccess: 'Guardado correctamente.',
30
32
  add: 'Agregar',
31
33
  remove: 'Quitar',
32
34
  noItems: 'Sin elementos agregados',
@@ -25,8 +25,13 @@ export interface RuneforgeStrings {
25
25
  exportExcel: string;
26
26
  save: string;
27
27
  saveAndContinue: string;
28
+ duplicate: string;
28
29
  cancel: string;
29
30
  back: string;
31
+ /** Create form only: shown after "Save and continue"/"Duplicate" save
32
+ * successfully but keep the user on the form (unlike a plain Save, which
33
+ * navigates away and needs no extra confirmation). */
34
+ saveSuccess: string;
30
35
  add: string;
31
36
  remove: string;
32
37
  noItems: string;
@@ -74,6 +74,8 @@ export type AttributeMetadata = {
74
74
  minLength?: number;
75
75
  maxLength?: number;
76
76
  pattern?: string;
77
+ /** Textarea fields only: the HTML `rows` attribute, controlling height. */
78
+ rows?: number;
77
79
  /** Embedded fields only: schema for each item added through the "+" modal. */
78
80
  fields?: InterfaceMetadata<any>;
79
81
  /** Embedded fields only: short label for an item in the list. */
@@ -43,6 +43,8 @@ export interface FieldDefinition<T extends object = Record<string, unknown>> {
43
43
  minLength?: number;
44
44
  maxLength?: number;
45
45
  pattern?: string;
46
+ /** Textarea fields only: the HTML `rows` attribute, controlling height. */
47
+ rows?: number;
46
48
  /** Embedded fields only: sub-field definitions for each item, built from
47
49
  * the metadata's `fields`. */
48
50
  fields?: FieldDefinition<Record<string, unknown>>[];
@@ -53,12 +55,33 @@ export interface FieldDefinition<T extends object = Record<string, unknown>> {
53
55
  /** Fields sharing the same `row` string render side by side. */
54
56
  row?: string;
55
57
  }
58
+ /** A create-form button whose visibility, label and styling can all be
59
+ * overridden — `enabled` falls back to the button's own default (see
60
+ * `continue`/`duplication` on `ActionConfiguration`), `label` falls back to
61
+ * the matching i18n string, and `class` adds extra classes (e.g. Tailwind
62
+ * utilities) alongside the button's default variant. */
63
+ export interface CreateFormButtonConfiguration {
64
+ enabled?: boolean;
65
+ label?: string;
66
+ class?: string;
67
+ }
56
68
  export interface ActionConfiguration<T extends object = Record<string, unknown>> {
57
69
  enabled?: boolean;
58
70
  label?: string;
59
71
  endpoint?: string;
60
72
  confirm?: boolean;
61
73
  callback?: (items: T[]) => void | Promise<void>;
74
+ /** Create form only: the "Save and continue" button, which submits to the
75
+ * same `endpoint` and then blanks the form so the user can create another
76
+ * record from scratch. Enabled by default — pass `{ enabled: false }` to
77
+ * hide it. */
78
+ continue?: CreateFormButtonConfiguration;
79
+ /** Create form only: shows a "Duplicate" button alongside Save/Cancel that
80
+ * submits to the same `endpoint`, but — unlike "Save and continue", which
81
+ * blanks the form — leaves the just-submitted values in place so the user
82
+ * can tweak a few fields and save again as a new record. Disabled by
83
+ * default — pass `{ enabled: true }` to show it. */
84
+ duplication?: CreateFormButtonConfiguration;
62
85
  }
63
86
  export interface CustomAction<T extends object = Record<string, unknown>> {
64
87
  label: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runeforge",
3
- "version": "0.0.45",
3
+ "version": "0.0.47",
4
4
  "description": "SvelteKit toolkit for building metadata-driven CRUD interfaces with tables, forms, and actions",
5
5
  "license": "MIT",
6
6
  "author": "Ezequiel Puerta",