@marianmeres/stuic 3.152.0 → 3.154.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/AGENTS.md +3 -0
- package/API.md +1 -0
- package/README.md +72 -0
- package/dist/components/FieldsBuilder/FieldsBuilder.svelte +1214 -0
- package/dist/components/FieldsBuilder/FieldsBuilder.svelte.d.ts +102 -0
- package/dist/components/FieldsBuilder/README.md +248 -0
- package/dist/components/FieldsBuilder/_internal/LocalizedTextInput.svelte +229 -0
- package/dist/components/FieldsBuilder/_internal/LocalizedTextInput.svelte.d.ts +30 -0
- package/dist/components/FieldsBuilder/_internal/OptionsEditor.svelte +296 -0
- package/dist/components/FieldsBuilder/_internal/OptionsEditor.svelte.d.ts +18 -0
- package/dist/components/FieldsBuilder/i18n-sk.d.ts +22 -0
- package/dist/components/FieldsBuilder/i18n-sk.js +81 -0
- package/dist/components/FieldsBuilder/i18n.d.ts +84 -0
- package/dist/components/FieldsBuilder/i18n.js +90 -0
- package/dist/components/FieldsBuilder/index.css +187 -0
- package/dist/components/FieldsBuilder/index.d.ts +5 -0
- package/dist/components/FieldsBuilder/index.js +4 -0
- package/dist/components/FieldsBuilder/types.d.ts +76 -0
- package/dist/components/FieldsBuilder/types.js +1 -0
- package/dist/components/FieldsBuilder/utils.d.ts +66 -0
- package/dist/components/FieldsBuilder/utils.js +153 -0
- package/dist/css/frame.css +109 -0
- package/dist/index.css +4 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/docs/RATIO_LOCKED_FRAME.md +466 -0
- package/docs/architecture.md +6 -0
- package/docs/domains/components.md +61 -1
- package/docs/domains/css-presets.md +304 -0
- package/docs/domains/theming.md +2 -0
- package/package.json +1 -1
|
@@ -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
|
+
];
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/* ============================================================================
|
|
2
|
+
RATIO-LOCKED FRAME (letterbox)
|
|
3
|
+
|
|
4
|
+
Lock a box to an aspect ratio, size it to whichever axis binds first, centre
|
|
5
|
+
it, and let the leftover space become letterboxing.
|
|
6
|
+
|
|
7
|
+
See docs/domains/css-presets.md for the classes, the token contract and the
|
|
8
|
+
decision tree, and docs/RATIO_LOCKED_FRAME.md for the recipes and the measured
|
|
9
|
+
gotcha list — the CSS here is nine declarations; the knowledge is the deliverable.
|
|
10
|
+
|
|
11
|
+
LAYERED (unlike the `.scrollbar-thin` / `.stuic-safe-area-*` utilities at the
|
|
12
|
+
end of index.css) because these are opt-in layout presets a consumer puts on
|
|
13
|
+
their OWN element and WILL tweak: Tailwind emits
|
|
14
|
+
`@layer theme, base, components, utilities`, so a utility always wins —
|
|
15
|
+
`class="stuic-frame h-dvh overflow-y-auto bg-white"` overrides everything
|
|
16
|
+
below. That is the escape hatch, by design.
|
|
17
|
+
|
|
18
|
+
This preset deliberately ships NO background, NO containment, NO scroll
|
|
19
|
+
container and NO letterbox-bars class: `grid`, `overflow-hidden`,
|
|
20
|
+
`fixed inset-0`, `bg-*`, `contain-layout`, `contain-paint`, `@container-size`
|
|
21
|
+
and `overflow-y-auto` are all Tailwind v4 utilities (stuic already requires
|
|
22
|
+
Tailwind v4). Only the sizing formula is ours, because it is the only part
|
|
23
|
+
Tailwind cannot express.
|
|
24
|
+
============================================================================ */
|
|
25
|
+
|
|
26
|
+
@layer components {
|
|
27
|
+
/* The ratio-locked box. Centres itself in a block, grid or flex parent
|
|
28
|
+
(`margin: auto` centres both axes in grid/flex; in normal flow the block-
|
|
29
|
+
axis autos compute to 0 and it behaves as `margin-inline: auto`).
|
|
30
|
+
|
|
31
|
+
One parent shape is NOT safe: a flex COLUMN. The ratio-derived height becomes
|
|
32
|
+
the flex base size and `min-height: 0` below removes the floor that would stop
|
|
33
|
+
it shrinking, so the frame silently goes off-ratio (measured 400x740, r=0.5405,
|
|
34
|
+
where a grid parent gives 400x800, r=0.5). Add `shrink-0`. See G22.
|
|
35
|
+
|
|
36
|
+
Why min() and not `max-width:100%; max-height:100%; aspect-ratio:R` — the
|
|
37
|
+
formulation everyone tries first: `max-*` never GROWS a box, so in a
|
|
38
|
+
centred grid/flex parent an empty frame measures 0x0, and one with content
|
|
39
|
+
shrink-wraps that content and overflows the parent. The ratio usually
|
|
40
|
+
survives; the SIZE is what's wrong. (There is a working `max-*` variant —
|
|
41
|
+
it needs a positioned parent — see Recipe D in the docs.)
|
|
42
|
+
|
|
43
|
+
`aspect-ratio` supplies the height, so the frame is ratio-locked at every
|
|
44
|
+
viewport, with bars on exactly one axis. Deriving the width from the
|
|
45
|
+
height and then letting `aspect-ratio` derive the height back is not
|
|
46
|
+
circular: `width` is resolved first, `aspect-ratio` only ever fills an
|
|
47
|
+
`auto` axis.
|
|
48
|
+
|
|
49
|
+
`min-height: 0` and `overflow: hidden` are load-bearing for the ratio, not
|
|
50
|
+
cosmetics: a grid/flex item's automatic minimum size overrides
|
|
51
|
+
`aspect-ratio` outright, so a tall child stretches an otherwise correct
|
|
52
|
+
frame off-ratio. Either one alone fixes it; both are set so a consumer's
|
|
53
|
+
`overflow-visible` stays survivable. */
|
|
54
|
+
.stuic-frame {
|
|
55
|
+
width: var(
|
|
56
|
+
--stuic-frame-width,
|
|
57
|
+
min(100vw, calc(100dvh * (var(--stuic-frame-aspect-ratio, 1))))
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
/* `auto` = ratio-locked (the default). Any explicit length here WINS over
|
|
61
|
+
`aspect-ratio` unconditionally — that is the supported way to opt into
|
|
62
|
+
"fill the height, derive the width" inside a bail-out media query, with
|
|
63
|
+
no `!important` and no specificity game. */
|
|
64
|
+
height: var(--stuic-frame-height, auto);
|
|
65
|
+
|
|
66
|
+
aspect-ratio: var(--stuic-frame-aspect-ratio, 1);
|
|
67
|
+
min-height: 0;
|
|
68
|
+
margin: auto;
|
|
69
|
+
overflow: hidden;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* Opt-in: size against the nearest ANCESTOR query container instead of the
|
|
73
|
+
viewport — the nested case (a frame under a header, inside a flex column).
|
|
74
|
+
Combine with `.stuic-frame`; this rule must stay AFTER it in source order,
|
|
75
|
+
since both declare `width` at equal specificity.
|
|
76
|
+
|
|
77
|
+
REQUIRES an ancestor with `container-type: size` (Tailwind:
|
|
78
|
+
`@container-size`). `inline-size` is NOT enough: `cqh` then falls THROUGH
|
|
79
|
+
to the next container, or silently to the small viewport, and you get a
|
|
80
|
+
ratio-correct but wrongly-scaled frame that overflows its parent and
|
|
81
|
+
tracks the window as you resize. Same failure with no container ancestor
|
|
82
|
+
at all. Size containment is safe here precisely because this frame's own
|
|
83
|
+
height is always determined. */
|
|
84
|
+
.stuic-frame-cq {
|
|
85
|
+
width: var(
|
|
86
|
+
--stuic-frame-width,
|
|
87
|
+
min(100cqw, calc(100cqh * (var(--stuic-frame-aspect-ratio, 1))))
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* Re-align a VIEWPORT-space element onto the frame's column: a top-layer
|
|
92
|
+
`<dialog>`, or an overlay portalled to `<body>` (stuic's popover /
|
|
93
|
+
spotlight / dimBehind default to `document.body` — pass their `container`
|
|
94
|
+
option instead where you can).
|
|
95
|
+
|
|
96
|
+
The whole fallback expression is repeated on purpose. Nothing declares
|
|
97
|
+
`--stuic-frame-width`, so a bare `var(--stuic-frame-width)` would be
|
|
98
|
+
invalid-at-computed-value-time -> `width: auto` -> silently full-bleed. */
|
|
99
|
+
.stuic-frame-col {
|
|
100
|
+
width: min(
|
|
101
|
+
100%,
|
|
102
|
+
var(
|
|
103
|
+
--stuic-frame-width,
|
|
104
|
+
min(100vw, calc(100dvh * (var(--stuic-frame-aspect-ratio, 1))))
|
|
105
|
+
)
|
|
106
|
+
);
|
|
107
|
+
margin-inline: auto;
|
|
108
|
+
}
|
|
109
|
+
}
|
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";
|
|
@@ -118,6 +119,9 @@ In practice:
|
|
|
118
119
|
@import "./actions/spotlight/index.css";
|
|
119
120
|
@import "./actions/tooltip/index.css";
|
|
120
121
|
|
|
122
|
+
/* Layout preset CSS (classes only, no component) */
|
|
123
|
+
@import "./css/frame.css";
|
|
124
|
+
|
|
121
125
|
/* Base styles for STUIC components */
|
|
122
126
|
@layer base {
|
|
123
127
|
button:not(:disabled),
|
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";
|