@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.
- package/dist/components/AlertConfirmPrompt/AlertConfirmPrompt.svelte +3 -1
- package/dist/components/AlertConfirmPrompt/Current.svelte +17 -2
- package/dist/components/AlertConfirmPrompt/README.md +36 -1
- package/dist/components/AlertConfirmPrompt/alert-confirm-prompt-stack.svelte.d.ts +49 -8
- package/dist/components/AlertConfirmPrompt/alert-confirm-prompt-stack.svelte.js +50 -10
- package/dist/components/ContactUsForm/ContactUsForm.svelte +78 -15
- package/dist/components/ContactUsForm/ContactUsForm.svelte.d.ts +22 -0
- package/dist/components/ContactUsForm/README.md +49 -0
- package/dist/components/Input/README.md +19 -0
- package/dist/components/Input/index.css +25 -0
- package/package.json +2 -2
|
@@ -94,7 +94,9 @@
|
|
|
94
94
|
<ModalDialog
|
|
95
95
|
bind:this={modal}
|
|
96
96
|
preEscapeClose={() => {
|
|
97
|
-
|
|
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={
|
|
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={
|
|
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
|
-
* () =>
|
|
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) =>
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* () =>
|
|
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) =>
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
192
|
-
|
|
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.
|
|
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.
|
|
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"
|