@marianmeres/stuic 3.86.0 → 3.88.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.
Files changed (64) hide show
  1. package/API.md +41 -0
  2. package/dist/actions/validate.svelte.d.ts +24 -4
  3. package/dist/actions/validate.svelte.js +18 -5
  4. package/dist/components/EmailVerifyForm/EmailVerifyForm.svelte +21 -0
  5. package/dist/components/EmailVerifyForm/EmailVerifyForm.svelte.d.ts +4 -1
  6. package/dist/components/Input/FieldAssets.svelte +48 -3
  7. package/dist/components/Input/FieldAssets.svelte.d.ts +8 -2
  8. package/dist/components/Input/FieldCheckbox.svelte +34 -3
  9. package/dist/components/Input/FieldCheckbox.svelte.d.ts +8 -1
  10. package/dist/components/Input/FieldCountry.svelte +64 -7
  11. package/dist/components/Input/FieldCountry.svelte.d.ts +8 -1
  12. package/dist/components/Input/FieldFile.svelte +34 -3
  13. package/dist/components/Input/FieldFile.svelte.d.ts +8 -1
  14. package/dist/components/Input/FieldInput.svelte +43 -3
  15. package/dist/components/Input/FieldInput.svelte.d.ts +8 -1
  16. package/dist/components/Input/FieldInputLocalized.svelte +41 -2
  17. package/dist/components/Input/FieldInputLocalized.svelte.d.ts +8 -2
  18. package/dist/components/Input/FieldKeyValues.svelte +37 -2
  19. package/dist/components/Input/FieldKeyValues.svelte.d.ts +8 -2
  20. package/dist/components/Input/FieldLikeButton.svelte +41 -4
  21. package/dist/components/Input/FieldLikeButton.svelte.d.ts +8 -1
  22. package/dist/components/Input/FieldObject.svelte +64 -6
  23. package/dist/components/Input/FieldObject.svelte.d.ts +8 -2
  24. package/dist/components/Input/FieldOptions.svelte +36 -3
  25. package/dist/components/Input/FieldOptions.svelte.d.ts +8 -2
  26. package/dist/components/Input/FieldPhoneNumber.svelte +51 -6
  27. package/dist/components/Input/FieldPhoneNumber.svelte.d.ts +8 -1
  28. package/dist/components/Input/FieldRadios.svelte +36 -2
  29. package/dist/components/Input/FieldRadios.svelte.d.ts +8 -1
  30. package/dist/components/Input/FieldSelect.svelte +34 -3
  31. package/dist/components/Input/FieldSelect.svelte.d.ts +8 -1
  32. package/dist/components/Input/FieldSwitch.svelte +41 -2
  33. package/dist/components/Input/FieldSwitch.svelte.d.ts +8 -1
  34. package/dist/components/Input/FieldTextarea.svelte +34 -3
  35. package/dist/components/Input/FieldTextarea.svelte.d.ts +8 -1
  36. package/dist/components/Input/_internal/FieldRadioInternal.svelte +34 -3
  37. package/dist/components/Input/_internal/FieldRadioInternal.svelte.d.ts +7 -1
  38. package/dist/components/LoginForm/LoginForm.svelte +35 -0
  39. package/dist/components/LoginForm/LoginForm.svelte.d.ts +5 -1
  40. package/dist/components/LoginOrRegisterForm/LoginOrRegisterForm.svelte +40 -0
  41. package/dist/components/LoginOrRegisterForm/LoginOrRegisterForm.svelte.d.ts +5 -1
  42. package/dist/components/RegisterForm/RegisterForm.svelte +46 -2
  43. package/dist/components/RegisterForm/RegisterForm.svelte.d.ts +5 -1
  44. package/dist/components/Switch/Switch.svelte +42 -4
  45. package/dist/components/Switch/Switch.svelte.d.ts +7 -1
  46. package/dist/components/UserAvatarMenu/README.md +188 -0
  47. package/dist/components/UserAvatarMenu/UserAvatarMenu.svelte +416 -0
  48. package/dist/components/UserAvatarMenu/UserAvatarMenu.svelte.d.ts +143 -0
  49. package/dist/components/UserAvatarMenu/index.css +95 -0
  50. package/dist/components/UserAvatarMenu/index.d.ts +1 -0
  51. package/dist/components/UserAvatarMenu/index.js +1 -0
  52. package/dist/icons/index.d.ts +3 -0
  53. package/dist/icons/index.js +3 -0
  54. package/dist/index.css +1 -0
  55. package/dist/index.d.ts +1 -0
  56. package/dist/index.js +1 -0
  57. package/dist/utils/index.d.ts +1 -0
  58. package/dist/utils/index.js +1 -0
  59. package/dist/utils/validate-fields.d.ts +72 -0
  60. package/dist/utils/validate-fields.js +73 -0
  61. package/docs/domains/actions.md +74 -0
  62. package/docs/domains/components.md +190 -0
  63. package/docs/domains/utils.md +38 -0
  64. package/package.json +1 -1
@@ -0,0 +1,143 @@
1
+ import type { Snippet } from "svelte";
2
+ import type { DropdownMenuItem, DropdownMenuPosition } from "../DropdownMenu/DropdownMenu.svelte";
3
+ import type { Props as AvatarProps } from "../Avatar/Avatar.svelte";
4
+ /**
5
+ * Identity passed by the consumer. When `null` / `undefined`, the menu
6
+ * renders in unauthenticated mode. When provided, the menu renders the
7
+ * header tile (avatar + email) and the authenticated item set.
8
+ */
9
+ export interface UserAvatarMenuIdentity {
10
+ /** Used for initials + auto-color hashing. Required when `identity` is set. */
11
+ email: string;
12
+ /** Optional display name (preferred over email in the header tile if present). */
13
+ name?: string;
14
+ /** Optional photo URL (forwarded to Avatar `src`). */
15
+ src?: string;
16
+ /** Optional role list — rendered under the email if `showRoles` is enabled. */
17
+ roles?: string[];
18
+ }
19
+ /**
20
+ * Standard built-in actions. Consumers wire these by passing handlers;
21
+ * omitting a handler hides the corresponding item.
22
+ *
23
+ * The component never navigates, logs out, or toggles theme on its own —
24
+ * it only invokes callbacks. The only built-in side effect is
25
+ * `ColorScheme.toggle()` inside the color-scheme item (see `colorScheme`).
26
+ */
27
+ export interface UserAvatarMenuActions {
28
+ /** "View profile" item. Hidden when not provided. (Auth state only.) */
29
+ onProfile?: () => void;
30
+ /** "Settings" item. Hidden when not provided. (Auth state only.) */
31
+ onSettings?: () => void;
32
+ /** "Logout" item. Hidden when not provided. (Auth state only.) */
33
+ onLogout?: () => void;
34
+ /**
35
+ * "Login or register" item — combined affordance, typically wired to
36
+ * open a `LoginOrRegisterFormModal`. Hidden when not provided.
37
+ * (Unauth state only.) Independent of `onLogin` / `onRegister`; pass
38
+ * any combination of the three.
39
+ */
40
+ onLoginOrRegister?: () => void;
41
+ /** "Login" item. Hidden when not provided. (Unauth state only.) */
42
+ onLogin?: () => void;
43
+ /** "Register" item. Hidden when not provided. (Unauth state only.) */
44
+ onRegister?: () => void;
45
+ }
46
+ /**
47
+ * Labels for built-in items. All optional — defaults are English strings.
48
+ * Consumers running i18n pass already-translated strings here.
49
+ */
50
+ export interface UserAvatarMenuLabels {
51
+ viewProfile?: string;
52
+ settings?: string;
53
+ logout?: string;
54
+ loginOrRegister?: string;
55
+ login?: string;
56
+ register?: string;
57
+ lightMode?: string;
58
+ darkMode?: string;
59
+ /** Trigger aria-label when authenticated. Default: "User menu". */
60
+ triggerAuthed?: string;
61
+ /** Trigger aria-label when unauthenticated. Default: "Sign in". */
62
+ triggerAnon?: string;
63
+ }
64
+ /**
65
+ * Color-scheme item config. Defaults to enabled.
66
+ * Pass `false` to disable; pass an object to customize.
67
+ */
68
+ export type UserAvatarMenuColorScheme = boolean | {
69
+ enabled?: boolean;
70
+ /** Override the default `ColorScheme.toggle()` behavior. */
71
+ onToggle?: () => void;
72
+ /** Read current "is dark" state. Defaults to `ColorScheme.getValue() === "dark"`. */
73
+ isDark?: () => boolean;
74
+ };
75
+ export interface Props {
76
+ /** Current user. `null` / `undefined` → unauthenticated mode. */
77
+ identity?: UserAvatarMenuIdentity | null;
78
+ /** Action handlers (see `UserAvatarMenuActions`). */
79
+ actions?: UserAvatarMenuActions;
80
+ /** Translated / customized labels. */
81
+ labels?: UserAvatarMenuLabels;
82
+ /** Color-scheme toggle config. Default: enabled. */
83
+ colorScheme?: UserAvatarMenuColorScheme;
84
+ /** Render the identity header tile (avatar + email) in the dropdown. Default: `true`. */
85
+ showHeaderTile?: boolean;
86
+ /** Render roles under the email in the header tile. Default: `false`. */
87
+ showRoles?: boolean;
88
+ /**
89
+ * Extra items appended to the standard set (after Logout / Register).
90
+ * Use for app-specific actions ("Switch project", "Billing", etc.).
91
+ */
92
+ extraItems?: DropdownMenuItem[];
93
+ /**
94
+ * Full override. When provided, the standard item set is IGNORED and the
95
+ * component renders exactly these items. `identity` is still consulted for
96
+ * the trigger; everything else (`actions`, `labels`, `colorScheme`,
97
+ * `showHeaderTile`, `showRoles`) is not.
98
+ */
99
+ items?: DropdownMenuItem[];
100
+ /** Forwarded to the default `Avatar` trigger and header-tile avatar. */
101
+ avatar?: Partial<Omit<AvatarProps, "onclick" | "initials" | "src" | "el">>;
102
+ /** Forwarded to `DropdownMenu`. */
103
+ position?: DropdownMenuPosition;
104
+ offset?: string;
105
+ maxHeight?: string;
106
+ closeOnSelect?: boolean;
107
+ classDropdown?: string;
108
+ classTrigger?: string;
109
+ /** Bindable open state (forwarded to `DropdownMenu`). */
110
+ isOpen?: boolean;
111
+ /**
112
+ * Optional custom trigger snippet. Receives the same args as
113
+ * `DropdownMenu`'s `trigger` snippet — `isOpen`, `toggle`, `triggerProps`.
114
+ */
115
+ trigger?: Snippet<[
116
+ {
117
+ isOpen: boolean;
118
+ toggle: () => void;
119
+ triggerProps: {
120
+ id: string;
121
+ "aria-haspopup": "menu";
122
+ "aria-expanded": boolean;
123
+ "aria-controls": string;
124
+ };
125
+ }
126
+ ]>;
127
+ /**
128
+ * Optional custom header-tile snippet. Replaces the default
129
+ * avatar + email tile rendered at the top of the menu.
130
+ */
131
+ headerTile?: Snippet<[{
132
+ identity: UserAvatarMenuIdentity;
133
+ }]>;
134
+ /** Skip default styling. */
135
+ unstyled?: boolean;
136
+ /** Additional CSS classes on the wrapper. */
137
+ class?: string;
138
+ /** Bindable wrapper element. */
139
+ el?: HTMLDivElement;
140
+ }
141
+ declare const UserAvatarMenu: import("svelte").Component<Props, {}, "el" | "isOpen">;
142
+ type UserAvatarMenu = ReturnType<typeof UserAvatarMenu>;
143
+ export default UserAvatarMenu;
@@ -0,0 +1,95 @@
1
+ .stuic-user-avatar-menu {
2
+ display: inline-flex;
3
+ }
4
+
5
+ /* Fixed dropdown width so every instance opens at the same size, regardless
6
+ * of content length. Override the var, or pass `classDropdown` (Tailwind
7
+ * class wins via tw-merge). Long emails/names/roles truncate against this
8
+ * bounded width. */
9
+ .stuic-user-avatar-menu-dropdown {
10
+ width: var(--stuic-user-avatar-menu-dropdown-width, 16rem);
11
+ }
12
+
13
+ .stuic-user-avatar-menu-trigger {
14
+ display: inline-flex;
15
+ align-items: center;
16
+ justify-content: center;
17
+ background: transparent;
18
+ border: 0;
19
+ padding: 0;
20
+ margin: 0;
21
+ cursor: pointer;
22
+ border-radius: var(
23
+ --stuic-user-avatar-menu-trigger-radius,
24
+ var(--stuic-radius, var(--radius-md))
25
+ );
26
+ transition: opacity
27
+ var(--stuic-user-avatar-menu-transition, var(--stuic-transition, 150ms)) ease;
28
+ }
29
+
30
+ .stuic-user-avatar-menu-trigger:hover {
31
+ opacity: var(--stuic-user-avatar-menu-trigger-opacity-hover, 0.85);
32
+ }
33
+
34
+ .stuic-user-avatar-menu-trigger:focus-visible {
35
+ outline: 2px solid
36
+ var(
37
+ --stuic-user-avatar-menu-trigger-outline-color,
38
+ var(--stuic-color-primary, currentColor)
39
+ );
40
+ outline-offset: 2px;
41
+ }
42
+
43
+ .stuic-user-avatar-menu-header {
44
+ display: flex;
45
+ flex-direction: column;
46
+ align-items: center;
47
+ gap: var(--stuic-user-avatar-menu-header-gap, 0.5rem);
48
+ padding: var(--stuic-user-avatar-menu-header-padding, 0.75rem 0.5rem);
49
+ margin-bottom: var(--stuic-user-avatar-menu-header-margin-bottom, 0.25rem);
50
+ background: var(
51
+ --stuic-user-avatar-menu-header-bg,
52
+ var(--stuic-color-muted, transparent)
53
+ );
54
+ color: var(
55
+ --stuic-user-avatar-menu-header-color,
56
+ var(--stuic-color-muted-foreground, inherit)
57
+ );
58
+ border-radius: var(
59
+ --stuic-user-avatar-menu-header-radius,
60
+ var(--stuic-radius, var(--radius-md))
61
+ );
62
+ /* fill the dropdown's content width so children can truncate against a
63
+ * known boundary (instead of growing to their intrinsic width) */
64
+ width: 100%;
65
+ min-width: 0;
66
+ overflow: hidden;
67
+ }
68
+
69
+ /* Direct text children of the header tile must each get the full row width
70
+ * so `text-overflow: ellipsis` has something to clip against (under
71
+ * align-items: center, children otherwise shrink to their intrinsic width). */
72
+ .stuic-user-avatar-menu-header-email,
73
+ .stuic-user-avatar-menu-header-roles {
74
+ display: block;
75
+ width: 100%;
76
+ min-width: 0;
77
+ text-align: center;
78
+ overflow: hidden;
79
+ text-overflow: ellipsis;
80
+ white-space: nowrap;
81
+ }
82
+
83
+ .stuic-user-avatar-menu-header-email {
84
+ font-size: var(--stuic-user-avatar-menu-header-email-font-size, inherit);
85
+ color: var(--stuic-user-avatar-menu-header-email-color, inherit);
86
+ }
87
+
88
+ .stuic-user-avatar-menu-header-roles {
89
+ font-size: var(--stuic-user-avatar-menu-header-roles-font-size, 0.75rem);
90
+ color: var(
91
+ --stuic-user-avatar-menu-header-roles-color,
92
+ var(--stuic-color-muted-foreground, currentColor)
93
+ );
94
+ opacity: var(--stuic-user-avatar-menu-header-roles-opacity, 0.7);
95
+ }
@@ -0,0 +1 @@
1
+ export { default as UserAvatarMenu, type Props as UserAvatarMenuProps, type UserAvatarMenuIdentity, type UserAvatarMenuActions, type UserAvatarMenuLabels, type UserAvatarMenuColorScheme, } from "./UserAvatarMenu.svelte";
@@ -0,0 +1 @@
1
+ export { default as UserAvatarMenu, } from "./UserAvatarMenu.svelte";
@@ -40,11 +40,14 @@ export { iconLucideGripHorizontal as iconGripHorizontal } from "@marianmeres/ico
40
40
  export { iconLucideGripVertical as iconGripVertical } from "@marianmeres/icons-fns/lucide/iconLucideGripVertical.js";
41
41
  export { iconLucideLanguages as iconLanguages } from "@marianmeres/icons-fns/lucide/iconLucideLanguages.js";
42
42
  export { iconLucideList as iconList } from "@marianmeres/icons-fns/lucide/iconLucideList.js";
43
+ export { iconLucideLogOut as iconLogOut } from "@marianmeres/icons-fns/lucide/iconLucideLogOut.js";
43
44
  export { iconLucideMenu as iconMenu } from "@marianmeres/icons-fns/lucide/iconLucideMenu.js";
45
+ export { iconLucideMoon as iconMoon } from "@marianmeres/icons-fns/lucide/iconLucideMoon.js";
44
46
  export { iconLucideSearch as iconSearch } from "@marianmeres/icons-fns/lucide/iconLucideSearch.js";
45
47
  export { iconLucideSlidersHorizontal as iconSlidersHorizontal } from "@marianmeres/icons-fns/lucide/iconLucideSlidersHorizontal.js";
46
48
  export { iconLucideSettings as iconSettings } from "@marianmeres/icons-fns/lucide/iconLucideSettings.js";
47
49
  export { iconLucideSquare as iconSquare } from "@marianmeres/icons-fns/lucide/iconLucideSquare.js";
50
+ export { iconLucideSun as iconSun } from "@marianmeres/icons-fns/lucide/iconLucideSun.js";
48
51
  export { iconLucideUser as iconUser } from "@marianmeres/icons-fns/lucide/iconLucideUser.js";
49
52
  export { iconLucideX as iconX } from "@marianmeres/icons-fns/lucide/iconLucideX.js";
50
53
  export { iconLucidePencil as iconPencil } from "@marianmeres/icons-fns/lucide/iconLucidePencil.js";
@@ -45,11 +45,14 @@ export { iconLucideGripHorizontal as iconGripHorizontal } from "@marianmeres/ico
45
45
  export { iconLucideGripVertical as iconGripVertical } from "@marianmeres/icons-fns/lucide/iconLucideGripVertical.js";
46
46
  export { iconLucideLanguages as iconLanguages } from "@marianmeres/icons-fns/lucide/iconLucideLanguages.js";
47
47
  export { iconLucideList as iconList } from "@marianmeres/icons-fns/lucide/iconLucideList.js";
48
+ export { iconLucideLogOut as iconLogOut } from "@marianmeres/icons-fns/lucide/iconLucideLogOut.js";
48
49
  export { iconLucideMenu as iconMenu } from "@marianmeres/icons-fns/lucide/iconLucideMenu.js";
50
+ export { iconLucideMoon as iconMoon } from "@marianmeres/icons-fns/lucide/iconLucideMoon.js";
49
51
  export { iconLucideSearch as iconSearch } from "@marianmeres/icons-fns/lucide/iconLucideSearch.js";
50
52
  export { iconLucideSlidersHorizontal as iconSlidersHorizontal } from "@marianmeres/icons-fns/lucide/iconLucideSlidersHorizontal.js";
51
53
  export { iconLucideSettings as iconSettings } from "@marianmeres/icons-fns/lucide/iconLucideSettings.js";
52
54
  export { iconLucideSquare as iconSquare } from "@marianmeres/icons-fns/lucide/iconLucideSquare.js";
55
+ export { iconLucideSun as iconSun } from "@marianmeres/icons-fns/lucide/iconLucideSun.js";
53
56
  export { iconLucideUser as iconUser } from "@marianmeres/icons-fns/lucide/iconLucideUser.js";
54
57
  export { iconLucideX as iconX } from "@marianmeres/icons-fns/lucide/iconLucideX.js";
55
58
  export { iconLucidePencil as iconPencil } from "@marianmeres/icons-fns/lucide/iconLucidePencil.js";
package/dist/index.css CHANGED
@@ -95,6 +95,7 @@ In practice:
95
95
  @import "./components/ThemePreview/index.css";
96
96
  @import "./components/Tree/index.css";
97
97
  @import "./components/TwCheck/index.css";
98
+ @import "./components/UserAvatarMenu/index.css";
98
99
  @import "./components/WithSidePanel/index.css";
99
100
  @import "./components/X/index.css";
100
101
 
package/dist/index.d.ts CHANGED
@@ -74,6 +74,7 @@ export * from "./components/ThemePreview/index.js";
74
74
  export * from "./components/Tree/index.js";
75
75
  export * from "./components/TwCheck/index.js";
76
76
  export * from "./components/TypeaheadInput/index.js";
77
+ export * from "./components/UserAvatarMenu/index.js";
77
78
  export * from "./components/WithSidePanel/index.js";
78
79
  export * from "./components/X/index.js";
79
80
  export * from "./utils/index.js";
package/dist/index.js CHANGED
@@ -75,6 +75,7 @@ export * from "./components/ThemePreview/index.js";
75
75
  export * from "./components/Tree/index.js";
76
76
  export * from "./components/TwCheck/index.js";
77
77
  export * from "./components/TypeaheadInput/index.js";
78
+ export * from "./components/UserAvatarMenu/index.js";
78
79
  export * from "./components/WithSidePanel/index.js";
79
80
  export * from "./components/X/index.js";
80
81
  // utils
@@ -41,3 +41,4 @@ export * from "./tr.js";
41
41
  export * from "./tw-merge.js";
42
42
  export * from "./ucfirst.js";
43
43
  export * from "./unaccent.js";
44
+ export * from "./validate-fields.js";
@@ -41,3 +41,4 @@ export * from "./tr.js";
41
41
  export * from "./tw-merge.js";
42
42
  export * from "./ucfirst.js";
43
43
  export * from "./unaccent.js";
44
+ export * from "./validate-fields.js";
@@ -0,0 +1,72 @@
1
+ import type { ValidationResult } from "../actions/validate.svelte.js";
2
+ /**
3
+ * Minimal shape any STUIC field component implements once it exposes the
4
+ * imperative validate API. Form-level helpers and consumer aggregators consume
5
+ * this — they don't care what specific field type they're dealing with.
6
+ *
7
+ * Every STUIC `Field*` component (FieldInput, FieldPhoneNumber, FieldCountry,
8
+ * FieldSelect, FieldCheckbox, FieldTextarea, FieldFile, FieldObject,
9
+ * FieldAssets, FieldInputLocalized, FieldKeyValues, FieldLikeButton,
10
+ * FieldRadios, FieldSwitch) satisfies this interface via `export function`.
11
+ */
12
+ export interface ValidatableField {
13
+ /** Run the validator now. Renders the inline error if invalid. */
14
+ validate(): ValidationResult | undefined;
15
+ /** Reset the inline error and clear `el.setCustomValidity`. */
16
+ clearValidation?(): void;
17
+ /** Current validation state, or undefined if validator has never run. */
18
+ getValidation?(): ValidationResult | undefined;
19
+ /** Focus the visible interactive element. */
20
+ focus?(): void;
21
+ /** Scroll the field into view. Defaults to smooth + center. */
22
+ scrollIntoView?(opts?: ScrollIntoViewOptions): void;
23
+ }
24
+ type FieldArg = ValidatableField | undefined | null;
25
+ /**
26
+ * Run `validate()` on every provided field. Returns `true` if all are valid.
27
+ *
28
+ * `undefined` / `null` entries are skipped so callers can spread conditional
29
+ * refs without filtering first.
30
+ *
31
+ * @example
32
+ * ```svelte
33
+ * <script>
34
+ * let nameField = $state();
35
+ * let emailField = $state();
36
+ *
37
+ * function handleSubmit() {
38
+ * if (!validateAllFields([nameField, emailField])) {
39
+ * scrollToFirstInvalidField([nameField, emailField]);
40
+ * return;
41
+ * }
42
+ * // ...submit
43
+ * }
44
+ * </script>
45
+ * ```
46
+ */
47
+ export declare function validateAllFields(fields: FieldArg[]): boolean;
48
+ /**
49
+ * Return the first field whose current validation state is invalid, or
50
+ * `undefined` if all are valid (or never validated).
51
+ *
52
+ * Reads cached state via `getValidation()` — call `validateAllFields()` first
53
+ * if you need fresh results.
54
+ */
55
+ export declare function findFirstInvalidField(fields: FieldArg[]): ValidatableField | undefined;
56
+ /**
57
+ * Scroll the first invalid field into view and (by default) focus it.
58
+ * Returns `true` if a field was scrolled, `false` if all were valid.
59
+ *
60
+ * Call **after** `validateAllFields()` — this reads cached validation state.
61
+ *
62
+ * @param fields - Field refs (in display order — first match wins)
63
+ * @param opts.focus - Whether to also call `focus()` on the field. Default true.
64
+ * @param opts.behavior - ScrollIntoView behavior. Default `"smooth"`.
65
+ * @param opts.block - ScrollIntoView block alignment. Default `"center"`.
66
+ */
67
+ export declare function scrollToFirstInvalidField(fields: FieldArg[], opts?: {
68
+ focus?: boolean;
69
+ behavior?: ScrollBehavior;
70
+ block?: ScrollLogicalPosition;
71
+ }): boolean;
72
+ export {};
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Run `validate()` on every provided field. Returns `true` if all are valid.
3
+ *
4
+ * `undefined` / `null` entries are skipped so callers can spread conditional
5
+ * refs without filtering first.
6
+ *
7
+ * @example
8
+ * ```svelte
9
+ * <script>
10
+ * let nameField = $state();
11
+ * let emailField = $state();
12
+ *
13
+ * function handleSubmit() {
14
+ * if (!validateAllFields([nameField, emailField])) {
15
+ * scrollToFirstInvalidField([nameField, emailField]);
16
+ * return;
17
+ * }
18
+ * // ...submit
19
+ * }
20
+ * </script>
21
+ * ```
22
+ */
23
+ export function validateAllFields(fields) {
24
+ let allValid = true;
25
+ for (const f of fields) {
26
+ if (!f)
27
+ continue;
28
+ const res = f.validate();
29
+ if (res && !res.valid)
30
+ allValid = false;
31
+ }
32
+ return allValid;
33
+ }
34
+ /**
35
+ * Return the first field whose current validation state is invalid, or
36
+ * `undefined` if all are valid (or never validated).
37
+ *
38
+ * Reads cached state via `getValidation()` — call `validateAllFields()` first
39
+ * if you need fresh results.
40
+ */
41
+ export function findFirstInvalidField(fields) {
42
+ for (const f of fields) {
43
+ if (!f)
44
+ continue;
45
+ const v = f.getValidation?.();
46
+ if (v && !v.valid)
47
+ return f;
48
+ }
49
+ return undefined;
50
+ }
51
+ /**
52
+ * Scroll the first invalid field into view and (by default) focus it.
53
+ * Returns `true` if a field was scrolled, `false` if all were valid.
54
+ *
55
+ * Call **after** `validateAllFields()` — this reads cached validation state.
56
+ *
57
+ * @param fields - Field refs (in display order — first match wins)
58
+ * @param opts.focus - Whether to also call `focus()` on the field. Default true.
59
+ * @param opts.behavior - ScrollIntoView behavior. Default `"smooth"`.
60
+ * @param opts.block - ScrollIntoView block alignment. Default `"center"`.
61
+ */
62
+ export function scrollToFirstInvalidField(fields, opts) {
63
+ const field = findFirstInvalidField(fields);
64
+ if (!field)
65
+ return false;
66
+ field.scrollIntoView?.({
67
+ behavior: opts?.behavior ?? "smooth",
68
+ block: opts?.block ?? "center",
69
+ });
70
+ if (opts?.focus !== false)
71
+ field.focus?.();
72
+ return true;
73
+ }
@@ -64,6 +64,80 @@ Actions using `$effect()` accept a function returning options:
64
64
  />
65
65
  ```
66
66
 
67
+ ### Imperative validate() trigger
68
+
69
+ The `validate` action only runs `_doValidate` in response to user-driven DOM
70
+ events (`change`, first `blur`). On a pristine, never-touched field the inline
71
+ validation message never mounts — which silently breaks any flow that
72
+ pre-populates errors via `customValidator` on a fresh form.
73
+
74
+ Pass a `setDoValidate` callback to capture a reference to the action's internal
75
+ validator function and trigger it imperatively (e.g., from a submit handler):
76
+
77
+ ```svelte
78
+ <script>
79
+ let doValidate: (() => void) | undefined = $state();
80
+
81
+ function handleSubmit() {
82
+ // Force every "sleeping" field's validator to run, rendering inline
83
+ // errors even on fields the user never touched.
84
+ doValidate?.();
85
+ // ...check validationResult.valid here, or use validateAllFields().
86
+ }
87
+ </script>
88
+
89
+ <input
90
+ required
91
+ use:validate={() => ({
92
+ enabled: true,
93
+ setValidationResult: (res) => (validationResult = res),
94
+ setDoValidate: (fn) => (doValidate = fn),
95
+ })}
96
+ />
97
+ ```
98
+
99
+ > **You generally don't write this by hand.** Every STUIC `Field*` component
100
+ > already wires `setDoValidate` internally and exposes the result as
101
+ > `export function validate()` on its component reference. See the
102
+ > [Components domain doc](./components.md#imperative-validate-api) for the
103
+ > per-field method list and the [validate-fields utility](./utils.md) for
104
+ > `validateAllFields()` / `scrollToFirstInvalidField()` aggregators.
105
+
106
+ ### Pristine forms and external errors
107
+
108
+ A common trap: setting an `errors` prop on a brand-new form and expecting the
109
+ inline messages to render. They won't — the `validate` action's
110
+ `customValidator` is only invoked on user events. Two fixes:
111
+
112
+ 1. **Wrap the form in `<form use:onSubmitValidityCheck>`** and listen for
113
+ `submit_valid` / `submit_invalid` (works for native submit flows).
114
+ 2. **Call the field component's imperative `validate()` from your submit
115
+ handler** (works for any flow — wizards, multi-step, custom CTAs).
116
+
117
+ ### Hidden inputs and `required`
118
+
119
+ Per the HTML spec, `<input type="hidden">` is *barred from constraint
120
+ validation* — `validity.valueMissing` stays `false` regardless of the
121
+ `required` attribute, and native browser submit blocking is skipped. Several
122
+ STUIC field components (`FieldPhoneNumber`, `FieldCountry`, `FieldObject`,
123
+ `FieldAssets`, `FieldInputLocalized`, `FieldKeyValues`, `FieldLikeButton`)
124
+ use a hidden input to participate in `FormData`, so they each enforce
125
+ `required` themselves inside their `customValidator`:
126
+
127
+ ```ts
128
+ customValidator(val, ctx, el) {
129
+ if (required && (val == null || val === "")) {
130
+ return "This field requires attention. Please review and try again.";
131
+ }
132
+ return userValidator?.(val, ctx, el) || "";
133
+ }
134
+ ```
135
+
136
+ This means `<FieldCountry required />` and `<FieldPhoneNumber required />`
137
+ correctly fail validation when empty — both through the imperative
138
+ `validate()` path and via `use:onSubmitValidityCheck`. Without this wrap
139
+ they'd silently accept empty values.
140
+
67
141
  ### File Dropzone
68
142
 
69
143
  ```svelte