@marianmeres/stuic 3.153.0 → 3.155.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.
@@ -0,0 +1,187 @@
1
+ /* ============================================================================
2
+ FIELDS BUILDER COMPONENT TOKENS
3
+ Override globally: :root { --stuic-fields-builder-row-border: red; }
4
+ Override locally: <FieldsBuilder style="--stuic-fields-builder-chip-bg: ..." />
5
+ ============================================================================ */
6
+
7
+ /* prettier-ignore */
8
+ :root {
9
+ --stuic-fields-builder-row-border: var(--stuic-color-border);
10
+ --stuic-fields-builder-row-toggle-bg-hover: var(--stuic-color-muted);
11
+ --stuic-fields-builder-key-text: var(--stuic-color-muted-foreground);
12
+ --stuic-fields-builder-chip-bg: var(--stuic-color-muted);
13
+ --stuic-fields-builder-chip-text: var(--stuic-color-muted-foreground);
14
+ --stuic-fields-builder-muted-text: var(--stuic-color-muted-foreground);
15
+ --stuic-fields-builder-warning-text: var(--stuic-color-surface-warning-foreground);
16
+ --stuic-fields-builder-error-text: var(--stuic-color-destructive);
17
+ --stuic-fields-builder-drop-indicator-color: var(--stuic-color-primary);
18
+ --stuic-fields-builder-drop-indicator-height: 2px;
19
+ --stuic-fields-builder-row-opacity-dragging: 0.4;
20
+ --stuic-fields-builder-row-opacity-deleted: 0.55;
21
+ --stuic-fields-builder-preview-border: var(--stuic-color-border);
22
+ }
23
+
24
+ @layer components {
25
+ /* =============================================================================
26
+ ROWS
27
+ ============================================================================= */
28
+
29
+ .stuic-fields-builder .fb-row-divider {
30
+ border-top: 1px solid var(--stuic-fields-builder-row-border);
31
+ }
32
+
33
+ .stuic-fields-builder .fb-row[data-dragging] {
34
+ opacity: var(--stuic-fields-builder-row-opacity-dragging);
35
+ }
36
+
37
+ .stuic-fields-builder .fb-row[data-drop-position="before"] {
38
+ box-shadow: inset 0 var(--stuic-fields-builder-drop-indicator-height) 0
39
+ var(--stuic-fields-builder-drop-indicator-color);
40
+ }
41
+
42
+ .stuic-fields-builder .fb-row[data-drop-position="after"] {
43
+ box-shadow: inset 0 calc(-1 * var(--stuic-fields-builder-drop-indicator-height)) 0
44
+ var(--stuic-fields-builder-drop-indicator-color);
45
+ }
46
+
47
+ .stuic-fields-builder .fb-row-deleted .fb-row-header {
48
+ opacity: var(--stuic-fields-builder-row-opacity-deleted);
49
+ }
50
+
51
+ .stuic-fields-builder .fb-row-deleted .fb-row-label {
52
+ text-decoration: line-through;
53
+ }
54
+
55
+ /* =============================================================================
56
+ ROW HEADER
57
+ ============================================================================= */
58
+
59
+ .stuic-fields-builder .fb-row-header {
60
+ padding-block: calc(var(--spacing) * 1);
61
+ }
62
+
63
+ .stuic-fields-builder .fb-row-toggle {
64
+ padding: calc(var(--spacing) * 1.5) calc(var(--spacing) * 1);
65
+ transition: background var(--stuic-transition);
66
+ }
67
+
68
+ .stuic-fields-builder .fb-row-toggle:hover:not(:disabled) {
69
+ background: var(--stuic-fields-builder-row-toggle-bg-hover);
70
+ }
71
+
72
+ .stuic-fields-builder .fb-row-toggle:disabled {
73
+ cursor: default;
74
+ }
75
+
76
+ .stuic-fields-builder .fb-row-label {
77
+ font-weight: 500;
78
+ }
79
+
80
+ .stuic-fields-builder .fb-row-required {
81
+ opacity: 0.5;
82
+ }
83
+
84
+ .stuic-fields-builder .fb-key {
85
+ font-family: var(--font-mono);
86
+ font-size: var(--text-xs);
87
+ color: var(--stuic-fields-builder-key-text);
88
+ }
89
+
90
+ .stuic-fields-builder .fb-chip {
91
+ background: var(--stuic-fields-builder-chip-bg);
92
+ color: var(--stuic-fields-builder-chip-text);
93
+ font-size: var(--text-xs);
94
+ padding: calc(var(--spacing) * 0.5) calc(var(--spacing) * 1.5);
95
+ border-radius: var(--stuic-fields-builder-chip-radius, var(--stuic-radius));
96
+ white-space: nowrap;
97
+ }
98
+
99
+ .stuic-fields-builder .fb-chip-warning {
100
+ color: var(--stuic-fields-builder-warning-text);
101
+ }
102
+
103
+ .stuic-fields-builder .fb-chevron {
104
+ opacity: 0.5;
105
+ }
106
+
107
+ .stuic-fields-builder .fb-handle {
108
+ cursor: grab;
109
+ opacity: 0.4;
110
+ touch-action: none;
111
+ }
112
+
113
+ .stuic-fields-builder .fb-handle:hover {
114
+ opacity: 1;
115
+ }
116
+
117
+ .stuic-fields-builder .fb-handle-spacer {
118
+ width: 16px;
119
+ }
120
+
121
+ /* =============================================================================
122
+ ROW BODY (expanded editor)
123
+ ============================================================================= */
124
+
125
+ .stuic-fields-builder .fb-row-body {
126
+ display: flex;
127
+ flex-direction: column;
128
+ gap: calc(var(--spacing) * 3);
129
+ padding: calc(var(--spacing) * 1) calc(var(--spacing) * 2) calc(var(--spacing) * 3)
130
+ calc(var(--spacing) * 2);
131
+ }
132
+
133
+ .stuic-fields-builder .fb-sub-label {
134
+ display: block;
135
+ font-size: var(--text-xs);
136
+ font-weight: 500;
137
+ color: var(--stuic-fields-builder-muted-text);
138
+ margin-bottom: calc(var(--spacing) * 0.5);
139
+ }
140
+
141
+ .stuic-fields-builder .fb-hint {
142
+ color: var(--stuic-fields-builder-muted-text);
143
+ }
144
+
145
+ .stuic-fields-builder .fb-error-text {
146
+ color: var(--stuic-fields-builder-error-text);
147
+ }
148
+
149
+ .stuic-fields-builder .fb-warning-text {
150
+ color: var(--stuic-fields-builder-warning-text);
151
+ }
152
+
153
+ .stuic-fields-builder .fb-advanced-toggle {
154
+ color: var(--stuic-fields-builder-muted-text);
155
+ transition: color var(--stuic-transition);
156
+ }
157
+
158
+ .stuic-fields-builder .fb-advanced-toggle:hover {
159
+ color: var(--stuic-color-foreground);
160
+ }
161
+
162
+ /* =============================================================================
163
+ OPTIONS EDITOR
164
+ ============================================================================= */
165
+
166
+ .stuic-fields-builder .fb-option-divider {
167
+ border-top: 1px dashed var(--stuic-fields-builder-row-border);
168
+ }
169
+
170
+ /* =============================================================================
171
+ PREVIEW PANE
172
+ ============================================================================= */
173
+
174
+ .stuic-fields-builder .fb-preview-side {
175
+ border-left: 1px solid var(--stuic-fields-builder-preview-border);
176
+ }
177
+
178
+ .stuic-fields-builder .fb-preview-below {
179
+ border-top: 1px solid var(--stuic-fields-builder-preview-border);
180
+ }
181
+
182
+ .stuic-fields-builder .fb-preview-title {
183
+ color: var(--stuic-fields-builder-muted-text);
184
+ text-transform: uppercase;
185
+ letter-spacing: 0.05em;
186
+ }
187
+ }
@@ -0,0 +1,5 @@
1
+ export { default as FieldsBuilder, type Props as FieldsBuilderProps, } from "./FieldsBuilder.svelte";
2
+ export type { FieldDef, FieldLock, FieldOptionDef, FieldTypeDef, FieldTypeExtraDef, LocalizedText, } from "./types.js";
3
+ export { DEFAULT_FIELD_TYPES as FIELDS_BUILDER_DEFAULT_TYPES, DEFAULT_KEY_PATTERN as FIELDS_BUILDER_DEFAULT_KEY_PATTERN, getLocalizedText, slugifyKey, uniqueKey, validateFieldDefs, type FieldDefRowErrors, type FieldDefsValidationResult, type ValidateFieldDefsOptions, } from "./utils.js";
4
+ export { createFieldsBuilderT, FIELDS_BUILDER_MESSAGES_EN, type FieldsBuilderMessageKey, type FieldsBuilderMessages, } from "./i18n.js";
5
+ export { FIELDS_BUILDER_MESSAGES_SK, FIELDS_BUILDER_DEFAULT_TYPES_SK, } from "./i18n-sk.js";
@@ -0,0 +1,4 @@
1
+ export { default as FieldsBuilder, } from "./FieldsBuilder.svelte";
2
+ export { DEFAULT_FIELD_TYPES as FIELDS_BUILDER_DEFAULT_TYPES, DEFAULT_KEY_PATTERN as FIELDS_BUILDER_DEFAULT_KEY_PATTERN, getLocalizedText, slugifyKey, uniqueKey, validateFieldDefs, } from "./utils.js";
3
+ export { createFieldsBuilderT, FIELDS_BUILDER_MESSAGES_EN, } from "./i18n.js";
4
+ export { FIELDS_BUILDER_MESSAGES_SK, FIELDS_BUILDER_DEFAULT_TYPES_SK, } from "./i18n-sk.js";
@@ -0,0 +1,76 @@
1
+ import type { Snippet } from "svelte";
2
+ /** A localized string: either one plain string, or a per-language map. */
3
+ export type LocalizedText = string | Record<string, string>;
4
+ /** A single option of a choice-like field type (one declaring `supportsOptions`). */
5
+ export interface FieldOptionDef {
6
+ /** Machine value, unique within the field's options. */
7
+ value: string;
8
+ label: LocalizedText;
9
+ }
10
+ /** What the user may NOT change on a field. Absent flag = editable. */
11
+ export interface FieldLock {
12
+ key?: boolean;
13
+ type?: boolean;
14
+ required?: boolean;
15
+ options?: boolean;
16
+ /** Cannot be removed from the list. */
17
+ delete?: boolean;
18
+ /** Cannot be dragged, and other fields cannot be moved past it. */
19
+ reorder?: boolean;
20
+ }
21
+ /**
22
+ * One field definition — the unit of the `FieldsBuilder` value. The component
23
+ * emits an ordered `FieldDef[]`; what the list is compiled into (a form, a
24
+ * schema, a template...) is entirely the consumer's business.
25
+ */
26
+ export interface FieldDef {
27
+ /** Machine key. Unique within the list. */
28
+ key: string;
29
+ /** One of the `types` palette entries' `type`. */
30
+ type: string;
31
+ label: LocalizedText;
32
+ description?: LocalizedText;
33
+ required?: boolean;
34
+ /**
35
+ * Edited and validated only for palette entries declaring
36
+ * `supportsOptions`. NOTE: when a field's type is changed away from a
37
+ * choice type, existing `options` are deliberately RETAINED on the def
38
+ * (never silently drop data; switching back restores them) — consumers
39
+ * compiling the list should ignore `options` on non-choice types.
40
+ */
41
+ options?: FieldOptionDef[];
42
+ /**
43
+ * Per-type extra flags, driven by the palette entry's `extras`. An open bag
44
+ * on purpose — the component never interprets these, it only renders a
45
+ * control per declared extra and round-trips the value.
46
+ */
47
+ extras?: Record<string, unknown>;
48
+ /** What the user may NOT change. Absent = fully editable. */
49
+ lock?: FieldLock;
50
+ }
51
+ /** An extra per-field control declared by a palette entry (v1: booleans only). */
52
+ export interface FieldTypeExtraDef {
53
+ /** Stored under `FieldDef.extras[key]`. */
54
+ key: string;
55
+ label: LocalizedText;
56
+ description?: LocalizedText;
57
+ /** v1: booleans only (rendered as a checkbox). */
58
+ type: "boolean";
59
+ default?: boolean;
60
+ }
61
+ /** One entry of the type palette (the `types` prop). */
62
+ export interface FieldTypeDef {
63
+ /** Stored in `FieldDef.type`. */
64
+ type: string;
65
+ /** Shown in the type picker. */
66
+ label: LocalizedText;
67
+ description?: LocalizedText;
68
+ /** Icon html string (e.g. from `@marianmeres/icons-fns`) or a snippet. */
69
+ icon?: string | Snippet;
70
+ /** Renders the option editor and allows `FieldDef.options`. */
71
+ supportsOptions?: boolean;
72
+ /** Extra per-field controls, rendered into `FieldDef.extras[key]`. */
73
+ extras?: FieldTypeExtraDef[];
74
+ /** Optional live preview of a single field of this type. */
75
+ preview?: Snippet<[FieldDef]>;
76
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,66 @@
1
+ import type { FieldDef, FieldTypeDef, LocalizedText } from "./types.js";
2
+ /** Default machine-key policy: lowercase snake_case, starts with a letter, max 63 chars. */
3
+ export declare const DEFAULT_KEY_PATTERN: RegExp;
4
+ export declare const DEFAULT_KEY_MAX_LENGTH = 63;
5
+ /** Minimal translate signature the pure helpers below need. */
6
+ export type FieldsBuilderTranslate = (key: string, values?: Record<string, string | number>) => string;
7
+ /**
8
+ * Read the display text of a `LocalizedText`: the string itself, the preferred
9
+ * language's entry, or the first non-empty entry as a fallback.
10
+ */
11
+ export declare function getLocalizedText(text: LocalizedText | null | undefined, preferredLanguage?: string): string;
12
+ /**
13
+ * Derive a machine key from a human label: transliterates diacritics
14
+ * (`Ročník` → `rocnik`), lowercases, collapses everything else to `_`. A slug
15
+ * not starting with a letter is prefixed with `f_` (`2024` → `f_2024`) so the
16
+ * result always satisfies `DEFAULT_KEY_PATTERN`.
17
+ */
18
+ export declare function slugifyKey(input: string, maxLength?: number): string;
19
+ /**
20
+ * Make `base` unique against `isTaken` by suffixing `_2`, `_3`, ... The base is
21
+ * truncated when a suffixed candidate would exceed `maxLength`.
22
+ */
23
+ export declare function uniqueKey(base: string, isTaken: (key: string) => boolean, maxLength?: number): string;
24
+ export declare function isKeyReserved(key: string, reservedKeys?: string[] | ((key: string) => boolean)): boolean;
25
+ export interface FieldDefRowErrors {
26
+ label?: string;
27
+ key?: string;
28
+ options?: string;
29
+ }
30
+ export interface FieldDefsValidationResult {
31
+ valid: boolean;
32
+ /** First error found — the component-level summary message. Empty when valid. */
33
+ message: string;
34
+ /** Index-aligned with the input defs. `null` = row has no errors. */
35
+ rowErrors: (FieldDefRowErrors | null)[];
36
+ }
37
+ export interface ValidateFieldDefsOptions {
38
+ /**
39
+ * The type palette. When provided, defs with a `type` not present here are
40
+ * treated as unknown: they are NOT validated (they round-trip untouched and
41
+ * must never block the rest of the list), but their keys still count toward
42
+ * uniqueness.
43
+ */
44
+ types?: Pick<FieldTypeDef, "type" | "supportsOptions">[];
45
+ keyPattern?: RegExp;
46
+ keyMaxLength?: number;
47
+ reservedKeys?: string[] | ((key: string) => boolean);
48
+ maxFields?: number;
49
+ defaultLanguage?: string;
50
+ /** Translator for the error messages; defaults to returning the message key. */
51
+ t?: FieldsBuilderTranslate;
52
+ }
53
+ /**
54
+ * Validate a list of field defs: label non-empty, key present/pattern/length/
55
+ * unique/not-reserved, choice types have at least one option with non-empty
56
+ * unique values, `maxFields` not exceeded.
57
+ *
58
+ * This is client-side convenience only — a consumer persisting the list MUST
59
+ * re-validate server-side; this function is not a security boundary.
60
+ */
61
+ export declare function validateFieldDefs(defs: FieldDef[], opts?: ValidateFieldDefsOptions): FieldDefsValidationResult;
62
+ /**
63
+ * A small general-purpose palette — handy for demos and for consumers with no
64
+ * opinion. `types` is a required prop, so nobody gets this by accident.
65
+ */
66
+ export declare const DEFAULT_FIELD_TYPES: FieldTypeDef[];
@@ -0,0 +1,153 @@
1
+ /** Default machine-key policy: lowercase snake_case, starts with a letter, max 63 chars. */
2
+ export const DEFAULT_KEY_PATTERN = /^[a-z][a-z0-9_]{0,62}$/;
3
+ export const DEFAULT_KEY_MAX_LENGTH = 63;
4
+ /**
5
+ * Read the display text of a `LocalizedText`: the string itself, the preferred
6
+ * language's entry, or the first non-empty entry as a fallback.
7
+ */
8
+ export function getLocalizedText(text, preferredLanguage) {
9
+ if (text == null)
10
+ return "";
11
+ if (typeof text === "string")
12
+ return text;
13
+ if (preferredLanguage && text[preferredLanguage])
14
+ return text[preferredLanguage];
15
+ for (const v of Object.values(text))
16
+ if (v)
17
+ return v;
18
+ return "";
19
+ }
20
+ /**
21
+ * Derive a machine key from a human label: transliterates diacritics
22
+ * (`Ročník` → `rocnik`), lowercases, collapses everything else to `_`. A slug
23
+ * not starting with a letter is prefixed with `f_` (`2024` → `f_2024`) so the
24
+ * result always satisfies `DEFAULT_KEY_PATTERN`.
25
+ */
26
+ export function slugifyKey(input, maxLength = DEFAULT_KEY_MAX_LENGTH) {
27
+ let s = (input ?? "")
28
+ .normalize("NFKD")
29
+ .replace(/[\u0300-\u036f]/g, "")
30
+ .toLowerCase()
31
+ .replace(/[^a-z0-9]+/g, "_")
32
+ .replace(/^_+|_+$/g, "");
33
+ if (s && !/^[a-z]/.test(s))
34
+ s = `f_${s}`;
35
+ if (maxLength > 0 && s.length > maxLength) {
36
+ s = s.slice(0, maxLength).replace(/_+$/, "");
37
+ }
38
+ return s;
39
+ }
40
+ /**
41
+ * Make `base` unique against `isTaken` by suffixing `_2`, `_3`, ... The base is
42
+ * truncated when a suffixed candidate would exceed `maxLength`.
43
+ */
44
+ export function uniqueKey(base, isTaken, maxLength = DEFAULT_KEY_MAX_LENGTH) {
45
+ if (!base || !isTaken(base))
46
+ return base;
47
+ for (let i = 2; i < 1_000; i++) {
48
+ const suffix = `_${i}`;
49
+ let candidate = base + suffix;
50
+ if (maxLength > 0 && candidate.length > maxLength) {
51
+ candidate = base.slice(0, maxLength - suffix.length).replace(/_+$/, "") + suffix;
52
+ }
53
+ if (!isTaken(candidate))
54
+ return candidate;
55
+ }
56
+ return base;
57
+ }
58
+ export function isKeyReserved(key, reservedKeys) {
59
+ if (!reservedKeys)
60
+ return false;
61
+ return typeof reservedKeys === "function"
62
+ ? !!reservedKeys(key)
63
+ : reservedKeys.includes(key);
64
+ }
65
+ /**
66
+ * Validate a list of field defs: label non-empty, key present/pattern/length/
67
+ * unique/not-reserved, choice types have at least one option with non-empty
68
+ * unique values, `maxFields` not exceeded.
69
+ *
70
+ * This is client-side convenience only — a consumer persisting the list MUST
71
+ * re-validate server-side; this function is not a security boundary.
72
+ */
73
+ export function validateFieldDefs(defs, opts = {}) {
74
+ const t = opts.t ?? ((k) => k);
75
+ const keyPattern = opts.keyPattern ?? DEFAULT_KEY_PATTERN;
76
+ const keyMaxLength = opts.keyMaxLength ?? DEFAULT_KEY_MAX_LENGTH;
77
+ const typeMap = opts.types ? new Map(opts.types.map((td) => [td.type, td])) : null;
78
+ const rowErrors = defs.map(() => null);
79
+ const put = (i, field, msg) => {
80
+ rowErrors[i] ??= {};
81
+ rowErrors[i][field] ??= msg;
82
+ };
83
+ // keys of ALL defs (incl. unknown types) occupy the key space
84
+ const keyCounts = new Map();
85
+ for (const d of defs) {
86
+ const k = (d.key ?? "").trim();
87
+ if (k)
88
+ keyCounts.set(k, (keyCounts.get(k) ?? 0) + 1);
89
+ }
90
+ defs.forEach((d, i) => {
91
+ // unknown type: keep as-is, do not block (see the doc comment above)
92
+ if (typeMap && !typeMap.has(d.type))
93
+ return;
94
+ if (!getLocalizedText(d.label, opts.defaultLanguage).trim()) {
95
+ put(i, "label", t("err_label_required"));
96
+ }
97
+ // presence is checked trimmed, but pattern/length run on the RAW key —
98
+ // a whitespace-padded key must fail here, not at the consumer's gate
99
+ const k = d.key ?? "";
100
+ if (!k.trim()) {
101
+ put(i, "key", t("err_key_required"));
102
+ }
103
+ else {
104
+ if (k.length > keyMaxLength) {
105
+ put(i, "key", t("err_key_maxlength", { max: keyMaxLength }));
106
+ }
107
+ else if (!keyPattern.test(k)) {
108
+ put(i, "key", t("err_key_pattern"));
109
+ }
110
+ if ((keyCounts.get(k.trim()) ?? 0) > 1)
111
+ put(i, "key", t("err_key_duplicate"));
112
+ if (isKeyReserved(k, opts.reservedKeys))
113
+ put(i, "key", t("err_key_reserved"));
114
+ }
115
+ if (typeMap?.get(d.type)?.supportsOptions) {
116
+ const options = d.options ?? [];
117
+ const values = options.map((o) => (o.value ?? "").trim());
118
+ if (!options.length) {
119
+ put(i, "options", t("err_options_required"));
120
+ }
121
+ else if (values.some((v) => !v)) {
122
+ put(i, "options", t("err_option_value_required"));
123
+ }
124
+ else if (new Set(values).size !== values.length) {
125
+ put(i, "options", t("err_option_value_duplicate"));
126
+ }
127
+ }
128
+ });
129
+ let message = rowErrors.find(Boolean)
130
+ ? Object.values(rowErrors.find(Boolean))[0]
131
+ : "";
132
+ if (!message && opts.maxFields && defs.length > opts.maxFields) {
133
+ message = t("err_max_fields", { max: opts.maxFields });
134
+ }
135
+ return { valid: !message, message, rowErrors };
136
+ }
137
+ /**
138
+ * A small general-purpose palette — handy for demos and for consumers with no
139
+ * opinion. `types` is a required prop, so nobody gets this by accident.
140
+ */
141
+ export const DEFAULT_FIELD_TYPES = [
142
+ { type: "text", label: "Text", description: "A single line of text" },
143
+ { type: "longtext", label: "Long text", description: "Multiple lines of text" },
144
+ { type: "number", label: "Number", description: "A numeric value" },
145
+ { type: "checkbox", label: "Yes / no", description: "A single on/off checkbox" },
146
+ {
147
+ type: "select",
148
+ label: "Choice",
149
+ description: "Pick one from a list of choices",
150
+ supportsOptions: true,
151
+ },
152
+ { type: "date", label: "Date", description: "A calendar date" },
153
+ ];
@@ -291,6 +291,27 @@ Each size (sm, md, lg) has corresponding tokens:
291
291
  | `--stuic-input-font-size-{size}` | `--text-sm` | `--text-base` | `--text-lg` |
292
292
  | `--stuic-input-min-height-{size}` | `2.5rem` | `2.75rem` | `3rem` |
293
293
 
294
+ #### iOS zoom guard
295
+
296
+ iOS/iPadOS Safari zooms the whole page when a text control with a computed
297
+ font-size below 16px receives focus — which the `sm` size (14px) triggers. On
298
+ touch-capable Apple devices only, inputs, textareas and selects are therefore
299
+ raised to at least:
300
+
301
+ | Variable | Default | Description |
302
+ | ----------------------------------- | ------- | -------------------------------------------------- |
303
+ | `--stuic-input-font-size-touch-min` | `16px` | Minimum font-size on touch WebKit (iOS zoom guard) |
304
+
305
+ Desktop (including macOS Safari) and Android keep the original, smaller size —
306
+ neither zooms on focus. The guard never shrinks a control, so `md`/`lg` are
307
+ unaffected at their default sizes. Opt out with:
308
+
309
+ ```css
310
+ :root {
311
+ --stuic-input-font-size-touch-min: 0px;
312
+ }
313
+ ```
314
+
294
315
  ### Checkbox/Radio Tokens
295
316
 
296
317
  | Variable | Default | Description |
@@ -42,6 +42,10 @@
42
42
  --stuic-input-font-size-lg: var(--text-lg);
43
43
  --stuic-input-min-height-lg: 3rem;
44
44
 
45
+ /* Smallest font-size a text control may compute to on touch WebKit.
46
+ See the "IOS ZOOM GUARD" section at the bottom of this file. */
47
+ --stuic-input-font-size-touch-min: 16px;
48
+
45
49
  /* Range input */
46
50
  --stuic-input-range-thumb-size: 18px;
47
51
  --stuic-input-range-track-height: 4px;
@@ -674,3 +678,77 @@
674
678
  padding-bottom: 0;
675
679
  }
676
680
  }
681
+
682
+ /* ============================================================================
683
+ IOS ZOOM GUARD
684
+
685
+ Mobile WebKit (iOS/iPadOS) auto-zooms the viewport when a text control whose
686
+ computed font-size is below 16px receives focus. The `sm` size (14px) trips
687
+ it, so tapping into e.g. a FieldsBuilder input jumps the whole page.
688
+
689
+ Raise the font-size to at least `--stuic-input-font-size-touch-min`, but only
690
+ where the zoom actually happens. Two conditions, both required:
691
+ - `@supports (font: -apple-system-body) or (-webkit-touch-callout: none)`
692
+ → an Apple/WebKit engine (verified: true in WebKit, false in Blink and
693
+ Gecko). Two signals OR-ed because `-webkit-touch-callout` is iOS-only
694
+ while `-apple-system-body` covers Apple platforms generally.
695
+ - `@media (any-pointer: coarse)` → ...on a touch-capable device, which on
696
+ Apple means iOS/iPadOS. This is what excludes desktop Safari.
697
+
698
+ Desktop (incl. Safari) and Android/Blink — neither of which zooms on focus —
699
+ keep the original, smaller size.
700
+
701
+ `max()` respects consumer overrides of the size tokens and never shrinks a
702
+ control. Opt out with `--stuic-input-font-size-touch-min: 0px`.
703
+
704
+ Deliberately NOT in `@layer components`: unlayered rules beat layered ones,
705
+ so this also wins over the Tailwind `text-sm` utilities some components put
706
+ on their own raw inputs (FieldsBuilder's mono key/value inputs, ...), which
707
+ would otherwise stay at 14px regardless of the size tokens.
708
+ ============================================================================ */
709
+
710
+ @supports (font: -apple-system-body) or (-webkit-touch-callout: none) {
711
+ @media (any-pointer: coarse) {
712
+ .stuic-input[data-size="sm"]
713
+ input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not(
714
+ [type="file"]
715
+ ),
716
+ .stuic-input[data-size="sm"] textarea,
717
+ .stuic-input[data-size="sm"] select {
718
+ font-size: max(
719
+ var(--stuic-input-font-size-touch-min),
720
+ var(--stuic-input-font-size-sm)
721
+ );
722
+ }
723
+
724
+ .stuic-input[data-size="md"]
725
+ input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not(
726
+ [type="file"]
727
+ ),
728
+ .stuic-input[data-size="md"] textarea,
729
+ .stuic-input[data-size="md"] select,
730
+ .stuic-input:not([data-size])
731
+ input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not(
732
+ [type="file"]
733
+ ),
734
+ .stuic-input:not([data-size]) textarea,
735
+ .stuic-input:not([data-size]) select {
736
+ font-size: max(
737
+ var(--stuic-input-font-size-touch-min),
738
+ var(--stuic-input-font-size-md)
739
+ );
740
+ }
741
+
742
+ .stuic-input[data-size="lg"]
743
+ input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not(
744
+ [type="file"]
745
+ ),
746
+ .stuic-input[data-size="lg"] textarea,
747
+ .stuic-input[data-size="lg"] select {
748
+ font-size: max(
749
+ var(--stuic-input-font-size-touch-min),
750
+ var(--stuic-input-font-size-lg)
751
+ );
752
+ }
753
+ }
754
+ }
package/dist/index.css CHANGED
@@ -80,6 +80,7 @@ In practice:
80
80
  @import "./components/DismissibleMessage/index.css";
81
81
  @import "./components/DropdownMenu/index.css";
82
82
  @import "./components/EmailVerifyForm/index.css";
83
+ @import "./components/FieldsBuilder/index.css";
83
84
  @import "./components/Float/index.css";
84
85
  @import "./components/H/index.css";
85
86
  @import "./components/Header/index.css";
package/dist/index.d.ts CHANGED
@@ -45,6 +45,7 @@ export * from "./components/DismissibleMessage/index.js";
45
45
  export * from "./components/Drawer/index.js";
46
46
  export * from "./components/DropdownMenu/index.js";
47
47
  export * from "./components/EmailVerifyForm/index.js";
48
+ export * from "./components/FieldsBuilder/index.js";
48
49
  export * from "./components/Float/index.js";
49
50
  export * from "./components/H/index.js";
50
51
  export * from "./components/Header/index.js";
package/dist/index.js CHANGED
@@ -51,6 +51,7 @@ export * from "./components/DismissibleMessage/index.js";
51
51
  export * from "./components/Drawer/index.js";
52
52
  export * from "./components/DropdownMenu/index.js";
53
53
  export * from "./components/EmailVerifyForm/index.js";
54
+ export * from "./components/FieldsBuilder/index.js";
54
55
  export * from "./components/Float/index.js";
55
56
  export * from "./components/H/index.js";
56
57
  export * from "./components/Header/index.js";