@marianmeres/stuic 3.177.0 → 3.179.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.
@@ -94,7 +94,9 @@
94
94
  <ModalDialog
95
95
  bind:this={modal}
96
96
  preEscapeClose={() => {
97
- return isPending ? false : acp?.current.onEscape?.();
97
+ // `escape()` runs the current dialog's `onEscape` (defaults to `shift`); the
98
+ // handler owns the close. Refused while the OK handler is still pending.
99
+ return isPending ? false : acp?.escape();
98
100
  }}
99
101
  preClose={() => !acp.length}
100
102
  noClickOutsideClose
@@ -78,6 +78,21 @@
78
78
 
79
79
  let current = $derived(acp?.current!);
80
80
 
81
+ // The prompt field is bound to the dialog object itself (`current.value`). Svelte's
82
+ // `bind:value` on the underlying <input> registers an ASYNC "input" listener that
83
+ // re-reads the binding getter after `await tick()` (to respect validation in
84
+ // accessors), and `createOnClick` below dispatches a synthetic "input" event on OK.
85
+ // If the `onOk` worker shifts the stack within that microtask window (e.g.
86
+ // `Promise.resolve().then(() => acp.shift())`), the deferred read finds `current`
87
+ // already undefined and a plain `bind:value={current.value}` throws
88
+ // "Cannot read properties of undefined (reading 'value')" as an unhandled
89
+ // rejection. So the field binds through these null-safe accessors instead: after
90
+ // the dialog is gone the read yields undefined and the write is dropped.
91
+ const getValue = () => current?.value;
92
+ const setValue = (v: any) => {
93
+ if (current) current.value = v;
94
+ };
95
+
81
96
  // Button config is layered: the dialog object's own value (most specific) wins over
82
97
  // the component level prop - same as CmpButtonOk/Cancel/Custom below.
83
98
 
@@ -200,7 +215,7 @@
200
215
  <div class={twMerge("input-box", "mt-3 p-1", classInputBox)}>
201
216
  {#if current?.promptFieldProps?.options?.length}
202
217
  <FieldSelect
203
- bind:value={current.value}
218
+ bind:value={getValue, setValue}
204
219
  bind:input={inputEl}
205
220
  class={twMerge("input", "m-0", classInput)}
206
221
  options={current.promptFieldProps.options}
@@ -210,7 +225,7 @@
210
225
  />
211
226
  {:else}
212
227
  <FieldInput
213
- bind:value={current.value}
228
+ bind:value={getValue, setValue}
214
229
  bind:input={inputEl}
215
230
  class={twMerge("input", "m-0", classInput)}
216
231
  renderSize="sm"
@@ -37,8 +37,38 @@ A modern, customizable replacement for native browser `alert()`, `confirm()`, an
37
37
  - `alert(options)` - Show an alert dialog
38
38
  - `confirm(onOk, options)` - Show a confirm dialog with callback
39
39
  - `prompt(onOk, options)` - Show a prompt dialog with input field
40
- - `shift()` - Remove current dialog from queue
40
+ - `shift()` - Remove current dialog from queue (revealing the next one, if any)
41
+ - `escape()` - Run the current dialog's `onEscape` handler (defaults to `shift`); no-op on an empty queue
41
42
  - `reset()` - Clear all dialogs
43
+ - `dump()` - Snapshot of the queue (current dialog first)
44
+ - `current` / `length` - The dialog being shown / number of queued dialogs (reactive)
45
+
46
+ ### Closing a dialog: the handler owns the close
47
+
48
+ A dialog leaves the queue only through `shift()`. Each of `onOk`, `onCancel` and `onEscape`
49
+ defaults to `shift` when you do not supply it (so a plain `alert()` closes itself), but as
50
+ soon as you pass your own handler, closing is your job: call `acp.shift()` from it when
51
+ done. The stack deliberately does not close for you, so an async handler can validate,
52
+ stage a second step, or keep a failed action's message on screen. The classic mistake is an
53
+ `onOk` that does its work and never shifts: the work happens, the dialog stays, and the user
54
+ clicks OK again.
55
+
56
+ While the promise returned by the handler is pending, the dialog is in a "pending" state
57
+ (buttons disabled, spinner shown, Escape refused). Return the promise rather than `void`-ing
58
+ it to keep that state for the whole of your work:
59
+
60
+ ```ts
61
+ acp.confirm(
62
+ async () => {
63
+ await deleteItem(); // dialog stays up and disabled until this settles
64
+ acp.shift();
65
+ },
66
+ { title: "Delete?", variant: "warn" }
67
+ );
68
+ ```
69
+
70
+ If all you want is a dialog that closes when the user answers, use the Promise-based
71
+ wrappers (`createAlert`, `createConfirm`, `createPrompt`) below; they shift for you.
42
72
 
43
73
  ### Dialog Options
44
74
 
@@ -87,6 +117,8 @@ A modern, customizable replacement for native browser `alert()`, `confirm()`, an
87
117
 
88
118
  ### Confirm
89
119
 
120
+ Supplying `onOk` makes you responsible for closing (see above), hence the `acp.shift()`:
121
+
90
122
  ```svelte
91
123
  <script lang="ts">
92
124
  acp.confirm(
@@ -105,6 +137,9 @@ A modern, customizable replacement for native browser `alert()`, `confirm()`, an
105
137
 
106
138
  ### Prompt
107
139
 
140
+ Same rule: `onOk` receives the entered value and owns the close. The shift may happen at
141
+ any time, synchronously or after an `await`:
142
+
108
143
  ```svelte
109
144
  <script lang="ts">
110
145
  acp.prompt(
@@ -57,6 +57,23 @@ export interface AlertConfirmPromptObj extends Record<string, any> {
57
57
  * Manages a FIFO queue of dialogs, allowing one dialog to be displayed at a time.
58
58
  * Provides a modern, customizable replacement for native browser dialogs.
59
59
  *
60
+ * **The handler owns the close.** A dialog is removed from the queue only by
61
+ * `shift()`. Each of `onOk`, `onCancel` and `onEscape` defaults to `shift` when the
62
+ * caller does not supply it, so an `alert()` with no callbacks closes itself. As soon
63
+ * as you pass your own handler, closing becomes your job: call `acp.shift()` when you
64
+ * are done. The stack does not close for you, so that an async handler can validate,
65
+ * stage a second step, or keep a failed action's message on screen. (An `onOk` that
66
+ * runs its work but never shifts is the classic mistake: the work happens, the dialog
67
+ * stays, and the user clicks OK again.)
68
+ *
69
+ * While a handler's returned promise is pending, the rendered dialog is in a "pending"
70
+ * state (buttons disabled, spinner shown, Escape refused). Return the promise - do not
71
+ * `void` it - to get that for the duration of your work.
72
+ *
73
+ * If all you want is "a dialog that closes when the user answers", use the
74
+ * Promise-based wrappers {@link createAlert}, {@link createConfirm} and
75
+ * {@link createPrompt} instead; they shift for you.
76
+ *
60
77
  * @example
61
78
  * ```ts
62
79
  * const acp = new AlertConfirmPromptStack({
@@ -64,18 +81,24 @@ export interface AlertConfirmPromptObj extends Record<string, any> {
64
81
  * labelCancel: 'Dismiss'
65
82
  * });
66
83
  *
67
- * // Simple alert
84
+ * // Simple alert (no handler given, so OK/Escape default to `shift`)
68
85
  * acp.alert({ title: 'Notice', content: 'Operation complete' });
69
86
  *
70
- * // Confirm with callback
87
+ * // Confirm with callback. Supplying `onOk` makes YOU responsible for closing.
71
88
  * acp.confirm(
72
- * () => console.log('Confirmed!'),
89
+ * async () => {
90
+ * await deleteItem(); // dialog is pending (OK disabled) until this settles
91
+ * acp.shift();
92
+ * },
73
93
  * { title: 'Delete?', content: 'This cannot be undone', variant: 'warn' }
74
94
  * );
75
95
  *
76
- * // Prompt for input
96
+ * // Prompt for input. Same rule: shift when done.
77
97
  * acp.prompt(
78
- * (value) => console.log('User entered:', value),
98
+ * (value) => {
99
+ * console.log('User entered:', value);
100
+ * acp.shift();
101
+ * },
79
102
  * { title: 'Name', content: 'Enter your name', value: 'Default' }
80
103
  * );
81
104
  * ```
@@ -86,20 +109,38 @@ export declare class AlertConfirmPromptStack {
86
109
  constructor(defaults?: Partial<AlertConfirmPromptObj>);
87
110
  get length(): number;
88
111
  get current(): AlertConfirmPromptObj;
112
+ /** Removes the current dialog from the queue, revealing the next one (if any). */
89
113
  shift: () => AlertConfirmPromptObj | undefined;
114
+ /** Clears the whole queue. */
90
115
  reset: () => void;
116
+ /**
117
+ * Runs the current dialog's `onEscape` handler (which defaults to `shift`). Does
118
+ * nothing on an empty stack. The handler owns the close, exactly as with `onOk`
119
+ * and `onCancel`, so this does NOT shift on its own: a custom `onEscape` that
120
+ * does not shift keeps the dialog open, and one that does shift pops exactly one
121
+ * entry (not the one queued behind it as well).
122
+ */
91
123
  escape: () => void;
124
+ /** Snapshot of the queue (current dialog first). */
92
125
  dump: () => AlertConfirmPromptObj[];
93
126
  /**
94
- * Main api.
127
+ * Queues an alert dialog (OK button only). With no `onOk`/`onEscape` given, both
128
+ * default to `shift`, so a plain alert closes itself. If you supply either, call
129
+ * `shift()` from it yourself.
95
130
  */
96
131
  alert: (o?: Partial<AlertConfirmPromptObj> | string) => void;
97
132
  /**
98
- * Main api.
133
+ * Queues a confirm dialog (Cancel + OK). `onOk` owns the close: call `acp.shift()`
134
+ * from it when done (a returned promise keeps the dialog pending until it settles).
135
+ * `onCancel`/`onEscape` default to `shift`. For a self-closing, Promise-based
136
+ * confirm use {@link createConfirm}.
99
137
  */
100
138
  confirm: (onOk: FnOnOK, o?: Partial<AlertConfirmPromptObj>) => void;
101
139
  /**
102
- * Main api.
140
+ * Queues a prompt dialog (input + Cancel + OK). `onOk` receives the entered value
141
+ * and owns the close: call `acp.shift()` from it when done (a returned promise
142
+ * keeps the dialog pending until it settles). `onCancel`/`onEscape` default to
143
+ * `shift`. For a self-closing, Promise-based prompt use {@link createPrompt}.
103
144
  */
104
145
  prompt: (onOk: FnOnOK, o?: Partial<AlertConfirmPromptObj>) => void;
105
146
  }
@@ -16,6 +16,23 @@ const ucf = (s) => `${s}`[0].toUpperCase() + `${s}`.slice(1);
16
16
  * Manages a FIFO queue of dialogs, allowing one dialog to be displayed at a time.
17
17
  * Provides a modern, customizable replacement for native browser dialogs.
18
18
  *
19
+ * **The handler owns the close.** A dialog is removed from the queue only by
20
+ * `shift()`. Each of `onOk`, `onCancel` and `onEscape` defaults to `shift` when the
21
+ * caller does not supply it, so an `alert()` with no callbacks closes itself. As soon
22
+ * as you pass your own handler, closing becomes your job: call `acp.shift()` when you
23
+ * are done. The stack does not close for you, so that an async handler can validate,
24
+ * stage a second step, or keep a failed action's message on screen. (An `onOk` that
25
+ * runs its work but never shifts is the classic mistake: the work happens, the dialog
26
+ * stays, and the user clicks OK again.)
27
+ *
28
+ * While a handler's returned promise is pending, the rendered dialog is in a "pending"
29
+ * state (buttons disabled, spinner shown, Escape refused). Return the promise - do not
30
+ * `void` it - to get that for the duration of your work.
31
+ *
32
+ * If all you want is "a dialog that closes when the user answers", use the
33
+ * Promise-based wrappers {@link createAlert}, {@link createConfirm} and
34
+ * {@link createPrompt} instead; they shift for you.
35
+ *
19
36
  * @example
20
37
  * ```ts
21
38
  * const acp = new AlertConfirmPromptStack({
@@ -23,18 +40,24 @@ const ucf = (s) => `${s}`[0].toUpperCase() + `${s}`.slice(1);
23
40
  * labelCancel: 'Dismiss'
24
41
  * });
25
42
  *
26
- * // Simple alert
43
+ * // Simple alert (no handler given, so OK/Escape default to `shift`)
27
44
  * acp.alert({ title: 'Notice', content: 'Operation complete' });
28
45
  *
29
- * // Confirm with callback
46
+ * // Confirm with callback. Supplying `onOk` makes YOU responsible for closing.
30
47
  * acp.confirm(
31
- * () => console.log('Confirmed!'),
48
+ * async () => {
49
+ * await deleteItem(); // dialog is pending (OK disabled) until this settles
50
+ * acp.shift();
51
+ * },
32
52
  * { title: 'Delete?', content: 'This cannot be undone', variant: 'warn' }
33
53
  * );
34
54
  *
35
- * // Prompt for input
55
+ * // Prompt for input. Same rule: shift when done.
36
56
  * acp.prompt(
37
- * (value) => console.log('User entered:', value),
57
+ * (value) => {
58
+ * console.log('User entered:', value);
59
+ * acp.shift();
60
+ * },
38
61
  * { title: 'Name', content: 'Enter your name', value: 'Default' }
39
62
  * );
40
63
  * ```
@@ -80,19 +103,30 @@ export class AlertConfirmPromptStack {
80
103
  o._id = Math.random().toString(36).slice(2);
81
104
  this.#stack.push(o);
82
105
  };
106
+ /** Removes the current dialog from the queue, revealing the next one (if any). */
83
107
  shift = () => this.#stack.shift();
108
+ /** Clears the whole queue. */
84
109
  reset = () => {
85
110
  this.#stack = [];
86
111
  };
112
+ /**
113
+ * Runs the current dialog's `onEscape` handler (which defaults to `shift`). Does
114
+ * nothing on an empty stack. The handler owns the close, exactly as with `onOk`
115
+ * and `onCancel`, so this does NOT shift on its own: a custom `onEscape` that
116
+ * does not shift keeps the dialog open, and one that does shift pops exactly one
117
+ * entry (not the one queued behind it as well).
118
+ */
87
119
  escape = () => {
88
- this.#stack?.[0]?.onEscape?.();
89
- this.shift();
120
+ return this.#stack[0]?.onEscape?.();
90
121
  };
122
+ /** Snapshot of the queue (current dialog first). */
91
123
  dump = () => {
92
124
  return [...this.#stack];
93
125
  };
94
126
  /**
95
- * Main api.
127
+ * Queues an alert dialog (OK button only). With no `onOk`/`onEscape` given, both
128
+ * default to `shift`, so a plain alert closes itself. If you supply either, call
129
+ * `shift()` from it yourself.
96
130
  */
97
131
  alert = (o) => {
98
132
  if (typeof o === "string")
@@ -100,13 +134,19 @@ export class AlertConfirmPromptStack {
100
134
  this.#push({ ...(o || {}), type: AlertConfirmPromptType.ALERT });
101
135
  };
102
136
  /**
103
- * Main api.
137
+ * Queues a confirm dialog (Cancel + OK). `onOk` owns the close: call `acp.shift()`
138
+ * from it when done (a returned promise keeps the dialog pending until it settles).
139
+ * `onCancel`/`onEscape` default to `shift`. For a self-closing, Promise-based
140
+ * confirm use {@link createConfirm}.
104
141
  */
105
142
  confirm = (onOk, o) => {
106
143
  this.#push({ onOk, value: false, ...o, type: AlertConfirmPromptType.CONFIRM });
107
144
  };
108
145
  /**
109
- * Main api.
146
+ * Queues a prompt dialog (input + Cancel + OK). `onOk` receives the entered value
147
+ * and owns the close: call `acp.shift()` from it when done (a returned promise
148
+ * keeps the dialog pending until it settles). `onCancel`/`onEscape` default to
149
+ * `shift`. For a self-closing, Promise-based prompt use {@link createPrompt}.
110
150
  */
111
151
  prompt = (onOk, o) => {
112
152
  this.#push({ onOk, value: "", ...o, type: AlertConfirmPromptType.PROMPT });
@@ -60,6 +60,29 @@
60
60
  /** Minimum message length. 0 (default) disables the check. */
61
61
  messageMinLength?: number;
62
62
 
63
+ /**
64
+ * Names of fields rendered **read-only**: the value is visible and still
65
+ * reaches `onSubmit`, but the user cannot change it. The typical case is a
66
+ * signed-in visitor whose name/email the server already knows — prefill
67
+ * them through `formData` and list them here.
68
+ *
69
+ * Accepts the built-in names (`"name"`, `"email"`, `"phone"`, `"subject"`,
70
+ * `"company"`, `"message"`) and any `extraFields` name. Unknown names are
71
+ * ignored. Listing a field does NOT show it — pair with the matching
72
+ * `show*` toggle.
73
+ *
74
+ * Read-only is not the same as `disabled`: the value keeps full contrast
75
+ * and stays focusable/selectable/copyable. A read-only Subject rendered
76
+ * from `subjectValues` falls back to a read-only text input, because
77
+ * `<select>` has no read-only counterpart.
78
+ *
79
+ * Prefill whatever you lock: a read-only control is barred from *native*
80
+ * constraint validation, so an empty required one is a dead end for the
81
+ * user. The form still reports it (see `validateContactForm`), it just
82
+ * cannot be fixed in the UI.
83
+ */
84
+ readonlyFields?: string[];
85
+
63
86
  /**
64
87
  * Declarative extra fields rendered as FieldInput entries.
65
88
  * Values bind into `formData.extra[name]`.
@@ -153,6 +176,7 @@
153
176
  showCompany = false,
154
177
  requireCompany = false,
155
178
  messageMinLength = 0,
179
+ readonlyFields,
156
180
  extraFields = [],
157
181
  extraFieldsSlot,
158
182
  useHoneypot = true,
@@ -184,12 +208,24 @@
184
208
  let topFields = $derived(extraFields.filter((f) => f.position === "top"));
185
209
  let bottomFields = $derived(extraFields.filter((f) => f.position !== "top"));
186
210
 
211
+ let readonlySet = $derived(new Set(readonlyFields ?? []));
212
+ function isReadonly(field: string): boolean {
213
+ return readonlySet.has(field);
214
+ }
215
+
187
216
  // Subject: render a <select> when subjectValues is non-empty (which also shows
188
217
  // the field regardless of showSubject); otherwise a free-text input gated by
189
218
  // showSubject. The select gets a prepended blank "prompt" option so the initial
190
219
  // empty subject isn't silently auto-selected to the first real value.
191
- let subjectAsSelect = $derived((subjectValues?.length ?? 0) > 0);
192
- let subjectShown = $derived(showSubject || subjectAsSelect);
220
+ //
221
+ // A read-only subject downgrades to a read-only text input — <select> has no
222
+ // readonly counterpart, and `disabled` would grey out the very value we mean
223
+ // to show. Hence `subjectShown` keys off *having* values rather than off
224
+ // rendering a select: sharing one flag would have made `subjectValues` +
225
+ // readonly hide the field outright.
226
+ let subjectHasValues = $derived((subjectValues?.length ?? 0) > 0);
227
+ let subjectAsSelect = $derived(subjectHasValues && !isReadonly("subject"));
228
+ let subjectShown = $derived(showSubject || subjectHasValues);
193
229
  let subjectOptions = $derived([
194
230
  { label: t("contact_form.subject_select_prompt"), value: "" },
195
231
  ...(subjectValues ?? []).map((v) => ({ label: v, value: v })),
@@ -280,6 +316,21 @@
280
316
  },
281
317
  });
282
318
 
319
+ // Single source for `validateContactForm`'s options — the submit path and
320
+ // `readonlyMissing()` below must agree on which fields are shown/required.
321
+ let validationOptions = $derived({
322
+ showName,
323
+ requireName,
324
+ showPhone,
325
+ requirePhone,
326
+ showSubject: subjectShown,
327
+ requireSubject,
328
+ showCompany,
329
+ requireCompany,
330
+ messageMinLength,
331
+ extraFields,
332
+ });
333
+
283
334
  // Merge internal + external errors; external takes precedence per field.
284
335
  let allErrors = $derived.by(() => {
285
336
  const map = new Map<string, string>();
@@ -289,7 +340,22 @@
289
340
  });
290
341
 
291
342
  function fieldError(field: string): string | undefined {
292
- return allErrors.find((e) => e.field === field)?.message;
343
+ return allErrors.find((e) => e.field === field)?.message ?? readonlyMissing(field);
344
+ }
345
+
346
+ // A read-only control is barred from *native* constraint validation:
347
+ // `validity.valueMissing` stays false however empty it is (the spec requires
348
+ // the control to be "mutable"). Without this the field walk behind the
349
+ // exported `validate()` would green-light an empty read-only required field,
350
+ // and the consumer would post it. Run the form's own validator for that one
351
+ // field instead — same rules, same messages, no duplicated logic. The
352
+ // built-in submit path reaches `validateContactForm` anyway; this only
353
+ // closes the pre-submit / imperative window, and only for read-only fields.
354
+ function readonlyMissing(field: string): string | undefined {
355
+ if (!readonlySet.size || !isReadonly(field)) return;
356
+ return validateContactForm(formData, t, validationOptions).find(
357
+ (e) => e.field === field
358
+ )?.message;
293
359
  }
294
360
 
295
361
  function extraValue(cfg: ContactFieldConfig): string {
@@ -321,18 +387,7 @@
321
387
  }
322
388
 
323
389
  function handleSubmitValid() {
324
- const validationErrors = validateContactForm(formData, t, {
325
- showName,
326
- requireName,
327
- showPhone,
328
- requirePhone,
329
- showSubject: subjectShown,
330
- requireSubject,
331
- showCompany,
332
- requireCompany,
333
- messageMinLength,
334
- extraFields,
335
- });
390
+ const validationErrors = validateContactForm(formData, t, validationOptions);
336
391
  internalErrors = validationErrors;
337
392
 
338
393
  // Report-only on bot signals: we still submit when field validation passes
@@ -452,6 +507,7 @@
452
507
  placeholder={cfg.placeholder}
453
508
  autocomplete={cfg.autocomplete}
454
509
  required={cfg.required}
510
+ readonly={isReadonly(cfg.name)}
455
511
  name={`contact-extra-${cfg.name}`}
456
512
  labelLeftBreakpoint={0}
457
513
  validate={{
@@ -481,6 +537,7 @@
481
537
  placeholder={t("contact_form.name_placeholder")}
482
538
  autocomplete="name"
483
539
  required={requireName}
540
+ readonly={isReadonly("name")}
484
541
  name="contact-name"
485
542
  labelLeftBreakpoint={0}
486
543
  validate={{
@@ -501,6 +558,7 @@
501
558
  placeholder={t("contact_form.email_placeholder")}
502
559
  autocomplete="email"
503
560
  required
561
+ readonly={isReadonly("email")}
504
562
  name="contact-email"
505
563
  labelLeftBreakpoint={0}
506
564
  validate={{
@@ -521,6 +579,7 @@
521
579
  placeholder={t("contact_form.phone_placeholder")}
522
580
  autocomplete="tel"
523
581
  required={requirePhone}
582
+ readonly={isReadonly("phone")}
524
583
  name="contact-phone"
525
584
  labelLeftBreakpoint={0}
526
585
  validate={{
@@ -542,6 +601,7 @@
542
601
  placeholder={t("contact_form.company_placeholder")}
543
602
  autocomplete="organization"
544
603
  required={requireCompany}
604
+ readonly={isReadonly("company")}
545
605
  name="contact-company"
546
606
  labelLeftBreakpoint={0}
547
607
  validate={{
@@ -579,6 +639,7 @@
579
639
  type="text"
580
640
  placeholder={t("contact_form.subject_placeholder")}
581
641
  required={requireSubject}
642
+ readonly={isReadonly("subject")}
582
643
  name="contact-subject"
583
644
  labelLeftBreakpoint={0}
584
645
  validate={{
@@ -598,6 +659,7 @@
598
659
  label={t("contact_form.message_label")}
599
660
  placeholder={t("contact_form.message_placeholder")}
600
661
  required
662
+ readonly={isReadonly("message")}
601
663
  name="contact-message"
602
664
  labelLeftBreakpoint={0}
603
665
  validate={{
@@ -619,6 +681,7 @@
619
681
  placeholder={cfg.placeholder}
620
682
  autocomplete={cfg.autocomplete}
621
683
  required={cfg.required}
684
+ readonly={isReadonly(cfg.name)}
622
685
  name={`contact-extra-${cfg.name}`}
623
686
  labelLeftBreakpoint={0}
624
687
  validate={{
@@ -47,6 +47,28 @@ export interface Props extends Omit<HTMLAttributes<HTMLFormElement>, "children">
47
47
  requireCompany?: boolean;
48
48
  /** Minimum message length. 0 (default) disables the check. */
49
49
  messageMinLength?: number;
50
+ /**
51
+ * Names of fields rendered **read-only**: the value is visible and still
52
+ * reaches `onSubmit`, but the user cannot change it. The typical case is a
53
+ * signed-in visitor whose name/email the server already knows — prefill
54
+ * them through `formData` and list them here.
55
+ *
56
+ * Accepts the built-in names (`"name"`, `"email"`, `"phone"`, `"subject"`,
57
+ * `"company"`, `"message"`) and any `extraFields` name. Unknown names are
58
+ * ignored. Listing a field does NOT show it — pair with the matching
59
+ * `show*` toggle.
60
+ *
61
+ * Read-only is not the same as `disabled`: the value keeps full contrast
62
+ * and stays focusable/selectable/copyable. A read-only Subject rendered
63
+ * from `subjectValues` falls back to a read-only text input, because
64
+ * `<select>` has no read-only counterpart.
65
+ *
66
+ * Prefill whatever you lock: a read-only control is barred from *native*
67
+ * constraint validation, so an empty required one is a dead end for the
68
+ * user. The form still reports it (see `validateContactForm`), it just
69
+ * cannot be fixed in the UI.
70
+ */
71
+ readonlyFields?: string[];
50
72
  /**
51
73
  * Declarative extra fields rendered as FieldInput entries.
52
74
  * Values bind into `formData.extra[name]`.
@@ -75,6 +75,7 @@ interface ContactFieldConfig {
75
75
  | `showCompany` | `boolean` | `false` | Render the Company field. |
76
76
  | `requireCompany` | `boolean` | `false` | Require Company (only applies when shown). |
77
77
  | `messageMinLength` | `number` | `0` | Minimum message length. `0` disables the check. |
78
+ | `readonlyFields` | `string[]` | - | Field names rendered read-only — prefilled, visible, submitted, not editable (see below). |
78
79
  | `extraFields` | `ContactFieldConfig[]` | `[]` | Declarative extra fields, positioned top or bottom. |
79
80
  | `extraFieldsSlot` | `Snippet<[{ formData, fieldError }]>` | - | Escape hatch for non-FieldInput extras (consent checkbox, captcha widget). |
80
81
  | `useHoneypot` | `boolean` | `true` | Render the hidden honeypot trap. |
@@ -182,6 +183,54 @@ automatically; the bound value is still the chosen string in `formData.subject`)
182
183
  </ContactUsForm>
183
184
  ```
184
185
 
186
+ ### Prefilled, read-only fields (signed-in visitor)
187
+
188
+ When the server already knows who is writing, prefill `formData` and list those
189
+ field names in `readonlyFields`. The values stay visible, keep full contrast, are
190
+ selectable/copyable, and still reach `onSubmit` — the user simply cannot change
191
+ them.
192
+
193
+ ```svelte
194
+ <script lang="ts">
195
+ import { ContactUsForm, createEmptyContactFormData } from "@marianmeres/stuic";
196
+
197
+ let { user } = $props();
198
+
199
+ let formData = $state({
200
+ ...createEmptyContactFormData(),
201
+ name: user.name,
202
+ email: user.email,
203
+ });
204
+ </script>
205
+
206
+ <ContactUsForm
207
+ bind:formData
208
+ onSubmit={send}
209
+ showName
210
+ readonlyFields={["name", "email"]}
211
+ />
212
+ ```
213
+
214
+ Notes:
215
+
216
+ - `readonlyFields` does **not** show a field — pair it with the matching `show*` toggle.
217
+ - It accepts the built-in names (`name`, `email`, `phone`, `subject`, `company`,
218
+ `message`) and any `extraFields` name. Unknown names are ignored.
219
+ - Read-only is **not** `disabled`. `disabled` reads as "unavailable", greys the
220
+ value out and drops it from the tab order; read-only says "this is your value,
221
+ it just isn't yours to change here".
222
+ - A read-only Subject falls back to a read-only **text input** even when
223
+ `subjectValues` is set, because `<select>` has no read-only counterpart and
224
+ disabling it would grey out the very value you meant to show.
225
+ - **Prefill whatever you lock.** A read-only control is barred from _native_
226
+ constraint validation, so an empty required one is a dead end for the user.
227
+ The form still reports it (`validateContactForm` runs on submit, and
228
+ `validate()` covers the same ground for read-only fields), but nobody can fix
229
+ it from the UI.
230
+ - Read-only is a **UI** affordance, not a security boundary: anything the browser
231
+ holds can be edited from devtools. Re-derive the trusted values server-side
232
+ from the session instead of trusting the posted ones.
233
+
185
234
  ### Declarative extra field
186
235
 
187
236
  ```svelte
@@ -314,6 +314,25 @@ Override globally in `:root` or locally via `style` prop:
314
314
  | `--stuic-input-text` | `--stuic-color-foreground` | Text color |
315
315
  | `--stuic-input-placeholder` | `--stuic-color-muted-foreground` | Placeholder color |
316
316
 
317
+ ### Readonly Tokens
318
+
319
+ Any `Field*` control that reaches the DOM with a `readonly` attribute (via the
320
+ `readonly` prop passthrough) gets a muted wrapper — a "shown, but not yours to
321
+ change" treatment. Deliberately **not** the `disabled` treatment: `disabled`
322
+ fades the whole wrap to `opacity: 0.5` and leaves the tab order, while readonly
323
+ keeps full text contrast and stays focusable, selectable and copyable. Applies
324
+ to `<input>` and `<textarea>` only — `<select>` has no readonly counterpart.
325
+
326
+ | Variable | Default | Description |
327
+ | ------------------------------- | ------------------------------------------------------------------------ | -------------------------------- |
328
+ | `--stuic-input-bg-readonly` | `--stuic-color-input` mixed 10% toward `--stuic-color-muted-foreground` | Wrapper background when readonly |
329
+ | `--stuic-input-border-readonly` | `--stuic-input-border` mixed 15% toward `--stuic-color-muted-foreground` | Wrapper border when readonly |
330
+
331
+ Both are _mixed off_ the editable values rather than pointing at
332
+ `--stuic-color-muted`: in several themes (stone, for one) `muted` and `input`
333
+ sit ~2% apart, which is no visual cue at all. Mixing toward the muted
334
+ _foreground_ also reverses direction on its own in dark themes.
335
+
317
336
  ### Size Tokens
318
337
 
319
338
  Each size (sm, md, lg) has corresponding tokens:
@@ -24,6 +24,15 @@
24
24
  --stuic-input-text: var(--stuic-color-foreground);
25
25
  --stuic-input-placeholder: color-mix(in oklab, var(--stuic-color-muted-foreground) 75%, transparent);
26
26
 
27
+ /* Readonly: a value shown but not editable. Deliberately NOT the disabled
28
+ treatment (opacity 0.5) — the text must stay fully legible. Mixed off the
29
+ input bg rather than set to --stuic-color-muted: in several themes (stone,
30
+ the demo default) muted and input sit ~2% apart, which is no cue at all.
31
+ Mixing toward the muted *foreground* also flips direction on its own in
32
+ dark themes, so the field reads as "settled" either way. */
33
+ --stuic-input-bg-readonly: color-mix(in oklab, var(--stuic-color-muted-foreground) 10%, var(--stuic-color-input));
34
+ --stuic-input-border-readonly: color-mix(in oklab, var(--stuic-color-muted-foreground) 15%, var(--stuic-input-border));
35
+
27
36
  /* Size: sm */
28
37
  --stuic-input-padding-x-sm: calc(var(--spacing) * 2.5);
29
38
  --stuic-input-padding-y-sm: calc(var(--spacing) * 2);
@@ -254,6 +263,22 @@
254
263
  cursor: not-allowed;
255
264
  }
256
265
 
266
+ /* Readonly state — "this value is known, you just can't change it".
267
+ Scoped to the wrap that directly contains the control (not the whole
268
+ .stuic-input) so a nested readonly control can't mute an editable parent.
269
+ Unlike disabled it keeps full text contrast and the focus ring: a readonly
270
+ control is still focusable, and hiding that would cost keyboard users. */
271
+ .stuic-input .input-wrap:has(input[readonly]),
272
+ .stuic-input .input-wrap:has(textarea[readonly]) {
273
+ background: var(--stuic-input-bg-readonly);
274
+ border-color: var(--stuic-input-border-readonly);
275
+ }
276
+
277
+ .stuic-input input[readonly],
278
+ .stuic-input textarea[readonly] {
279
+ cursor: default;
280
+ }
281
+
257
282
  /* Transparent wrapper utility - for components like Switch that don't need wrapper styling */
258
283
  .stuic-input .input-wrap.input-wrap-transparent,
259
284
  .stuic-input.invalid .input-wrap.input-wrap-transparent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.177.0",
3
+ "version": "3.179.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -177,7 +177,7 @@
177
177
  "tailwindcss": "^4.3.3",
178
178
  "tsx": "^4.23.13",
179
179
  "typescript": "^5.9.3",
180
- "typescript-eslint": "^8.69.0",
180
+ "typescript-eslint": "^8.70.0",
181
181
  "vite": "^7.3.6",
182
182
  "vitest": "^4.1.11",
183
183
  "vitest-browser-svelte": "^2.2.1"