@marianmeres/stuic 3.101.0 → 3.102.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 CHANGED
@@ -24,13 +24,20 @@
24
24
  ```
25
25
  src/lib/
26
26
  ├── components/ # 57 component directories
27
- ├── actions/ # 15 Svelte actions
27
+ ├── actions/ # 15 Svelte actions (use: directives)
28
+ ├── attachments/ # Svelte attachments ({@attach} — preferred for new DOM helpers)
28
29
  ├── utils/ # 44 utility modules
29
30
  ├── icons/ # Icon re-exports from @marianmeres/icons-fns
30
31
  ├── index.css # Centralized CSS imports
31
32
  └── index.ts # Main exports
32
33
  ```
33
34
 
35
+ > **Actions vs attachments:** for new DOM-enhancement helpers prefer a Svelte
36
+ > [attachment](https://svelte.dev/docs/svelte/@attach) (`{@attach}`, since Svelte 5.29) over a
37
+ > `use:` action — they are reactive, composable, and forwardable through components. Put new
38
+ > attachments in `src/lib/attachments/` (export from its `index.ts`). The existing `actions/`
39
+ > are kept as-is for back-compat; no need to migrate them.
40
+
34
41
  Theme CSS files are not bundled in this package — they're provided by `@marianmeres/design-tokens/css/*.css` (42 themes) and imported by `src/lib/index.css`.
35
42
 
36
43
  ---
@@ -58,39 +65,40 @@ Theme CSS files are not bundled in this package — they're provided by `@marian
58
65
 
59
66
  Global tokens that control cross-component visual properties. Defined in `src/lib/index.css`:
60
67
 
61
- | Token | Default | Purpose |
62
- | -------------------------- | -------------------- | --------------------------------------------- |
63
- | `--stuic-radius` | `var(--radius-md)` | Element-level radius (inputs, badges, list items) |
64
- | `--stuic-radius-button` | `var(--radius-md)` | Button-specific radius (independent from general elements) |
65
- | `--stuic-radius-container` | `var(--radius-lg)` | Container-level radius (cards, modals, dropdowns) |
66
- | `--stuic-shadow` | `var(--shadow-sm)` | Default resting shadow |
67
- | `--stuic-shadow-hover` | `var(--shadow-md)` | Hover/elevated shadow |
68
- | `--stuic-shadow-overlay` | `var(--shadow-lg)` | Overlays (dropdowns, notifications) |
69
- | `--stuic-shadow-dialog` | `var(--shadow-xl)` | Dialogs/modals |
70
- | `--stuic-border-width` | `1px` | Default border width |
71
- | `--stuic-border-width-button` | `1px` | Button-specific border width (independent from general elements) |
72
- | `--stuic-transition` | `150ms` | Default transition duration |
68
+ | Token | Default | Purpose |
69
+ | ----------------------------- | ------------------ | ---------------------------------------------------------------- |
70
+ | `--stuic-radius` | `var(--radius-md)` | Element-level radius (inputs, badges, list items) |
71
+ | `--stuic-radius-button` | `var(--radius-md)` | Button-specific radius (independent from general elements) |
72
+ | `--stuic-radius-container` | `var(--radius-lg)` | Container-level radius (cards, modals, dropdowns) |
73
+ | `--stuic-shadow` | `var(--shadow-sm)` | Default resting shadow |
74
+ | `--stuic-shadow-hover` | `var(--shadow-md)` | Hover/elevated shadow |
75
+ | `--stuic-shadow-overlay` | `var(--shadow-lg)` | Overlays (dropdowns, notifications) |
76
+ | `--stuic-shadow-dialog` | `var(--shadow-xl)` | Dialogs/modals |
77
+ | `--stuic-border-width` | `1px` | Default border width |
78
+ | `--stuic-border-width-button` | `1px` | Button-specific border width (independent from general elements) |
79
+ | `--stuic-transition` | `150ms` | Default transition duration |
73
80
 
74
81
  **When creating new components**, use the fallback pattern at CSS usage sites:
75
82
 
76
83
  ```css
77
84
  /* CORRECT: fallback resolved at element level — scoped overrides work */
78
85
  .stuic-my-component {
79
- border-radius: var(--stuic-my-component-radius, var(--stuic-radius));
80
- box-shadow: var(--stuic-my-component-shadow, var(--stuic-shadow));
81
- border-width: var(--stuic-my-component-border-width, var(--stuic-border-width));
82
- transition: background var(--stuic-my-component-transition, var(--stuic-transition));
86
+ border-radius: var(--stuic-my-component-radius, var(--stuic-radius));
87
+ box-shadow: var(--stuic-my-component-shadow, var(--stuic-shadow));
88
+ border-width: var(--stuic-my-component-border-width, var(--stuic-border-width));
89
+ transition: background var(--stuic-my-component-transition, var(--stuic-transition));
83
90
  }
84
91
  ```
85
92
 
86
93
  ```css
87
94
  /* WRONG: :root declarations resolve eagerly — scoped overrides on child elements are ignored */
88
95
  :root {
89
- --stuic-my-component-radius: var(--stuic-radius); /* DO NOT DO THIS */
96
+ --stuic-my-component-radius: var(--stuic-radius); /* DO NOT DO THIS */
90
97
  }
91
98
  ```
92
99
 
93
100
  **Element vs Container classification:**
101
+
94
102
  - **Element** (`--stuic-radius`): inputs, badges, list items, checkboxes, tabs — interactive controls
95
103
  - **Button** (`--stuic-radius-button`): buttons, button groups — allows rounded buttons even with flat elements
96
104
  - **Container** (`--stuic-radius-container`): cards, modals, dropdowns, notifications, accordions — content wrappers
@@ -120,6 +128,7 @@ Global tokens that control cross-component visual properties. Defined in `src/li
120
128
  - [Components](./docs/domains/components.md) — 57 component directories, Props pattern, snippets
121
129
  - [Theming](./docs/domains/theming.md) — CSS tokens, dark mode, themes
122
130
  - [Actions](./docs/domains/actions.md) — 15 Svelte directives
131
+ - [Attachments](./docs/domains/attachments.md) — `{@attach}` DOM helpers (preferred for new ones)
123
132
  - [Utils](./docs/domains/utils.md) — 44 utility modules
124
133
 
125
134
  ### Reference
@@ -131,13 +140,13 @@ Global tokens that control cross-component visual properties. Defined in `src/li
131
140
 
132
141
  ## Key Files
133
142
 
134
- | File | Purpose |
135
- | -------------------------------- | ---------------------------------------------------------------------- |
136
- | `src/lib/index.css` | CSS entry point |
137
- | `src/lib/index.ts` | JS entry point |
138
- | `src/lib/utils/design-tokens.ts` | Re-exports from `@marianmeres/design-tokens` |
139
- | `@marianmeres/design-tokens/css/*.css` | Theme CSS files (42 themes, `--stuic-` prefix) |
140
- | `src/lib/components/Button/` | Reference component |
143
+ | File | Purpose |
144
+ | -------------------------------------- | ---------------------------------------------- |
145
+ | `src/lib/index.css` | CSS entry point |
146
+ | `src/lib/index.ts` | JS entry point |
147
+ | `src/lib/utils/design-tokens.ts` | Re-exports from `@marianmeres/design-tokens` |
148
+ | `@marianmeres/design-tokens/css/*.css` | Theme CSS files (42 themes, `--stuic-` prefix) |
149
+ | `src/lib/components/Button/` | Reference component |
141
150
 
142
151
  ---
143
152
 
@@ -0,0 +1,52 @@
1
+ import type { Attachment } from "svelte/attachments";
2
+ /**
3
+ * Svelte attachment that drives the host element's `height` to match the natural
4
+ * height of its single child, re-measuring whenever that child resizes, so the host
5
+ * can animate smoothly between content sizes.
6
+ *
7
+ * Pair it with a CSS `height` transition on the host (gate it behind
8
+ * `prefers-reduced-motion`). The attachment owns two things on the host: the inline
9
+ * `height`, and — only **while that height is transitioning** — `overflow: clip`.
10
+ * Clipping during the transition stops growing content from spilling out as the box
11
+ * opens; clearing it at rest means focus rings, borders and shadows that paint outside
12
+ * the box are **not** cut off when the animation is idle. Do not set `overflow`
13
+ * yourself on the host (it would override the at-rest reset and clip permanently).
14
+ *
15
+ * To keep focus rings / borders visible *during* the transition too, set
16
+ * `overflow-clip-margin` on the host in CSS — `clip` honours it, so paint within that
17
+ * margin bleeds past the clip edge instead of being sliced. Size it per consumer to the
18
+ * largest thing that paints outside a child's box (focus outline width + offset, or a
19
+ * shadow's blur/spread); too small still clips, too large lets growing content peek a
20
+ * little further before it's clipped. A focus outline of a few px typically needs
21
+ * `~0.5rem`. Expose it as a custom property if downstream consumers may need to tune it.
22
+ *
23
+ * The host should contain exactly one element child (the thing being measured); give
24
+ * that child its natural, content-driven height. On mount the host's `height` is locked
25
+ * from `auto` to a px value (no first-paint animation — `auto` is not interpolatable),
26
+ * then a `ResizeObserver` keeps it in sync. With no transition configured, or under
27
+ * `prefers-reduced-motion`, the height simply snaps and nothing is ever clipped.
28
+ *
29
+ * @example
30
+ * ```svelte
31
+ * <div class="viewport" {@attach autoHeight}>
32
+ * <div class="inner">
33
+ * <!-- variable-height content; swapping it animates the viewport height -->
34
+ * </div>
35
+ * </div>
36
+ *
37
+ * <style>
38
+ * .inner { display: flex; flex-direction: column; }
39
+ * @media (prefers-reduced-motion: no-preference) {
40
+ * .viewport { transition: height 250ms ease; }
41
+ * }
42
+ * </style>
43
+ * ```
44
+ *
45
+ * (Note: do not set `overflow` on `.viewport` — the attachment manages it.)
46
+ *
47
+ * Conditional usage (a falsy value means "no attachment"):
48
+ * ```svelte
49
+ * <div {@attach enabled && autoHeight}>...</div>
50
+ * ```
51
+ */
52
+ export declare const autoHeight: Attachment<HTMLElement>;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Svelte attachment that drives the host element's `height` to match the natural
3
+ * height of its single child, re-measuring whenever that child resizes, so the host
4
+ * can animate smoothly between content sizes.
5
+ *
6
+ * Pair it with a CSS `height` transition on the host (gate it behind
7
+ * `prefers-reduced-motion`). The attachment owns two things on the host: the inline
8
+ * `height`, and — only **while that height is transitioning** — `overflow: clip`.
9
+ * Clipping during the transition stops growing content from spilling out as the box
10
+ * opens; clearing it at rest means focus rings, borders and shadows that paint outside
11
+ * the box are **not** cut off when the animation is idle. Do not set `overflow`
12
+ * yourself on the host (it would override the at-rest reset and clip permanently).
13
+ *
14
+ * To keep focus rings / borders visible *during* the transition too, set
15
+ * `overflow-clip-margin` on the host in CSS — `clip` honours it, so paint within that
16
+ * margin bleeds past the clip edge instead of being sliced. Size it per consumer to the
17
+ * largest thing that paints outside a child's box (focus outline width + offset, or a
18
+ * shadow's blur/spread); too small still clips, too large lets growing content peek a
19
+ * little further before it's clipped. A focus outline of a few px typically needs
20
+ * `~0.5rem`. Expose it as a custom property if downstream consumers may need to tune it.
21
+ *
22
+ * The host should contain exactly one element child (the thing being measured); give
23
+ * that child its natural, content-driven height. On mount the host's `height` is locked
24
+ * from `auto` to a px value (no first-paint animation — `auto` is not interpolatable),
25
+ * then a `ResizeObserver` keeps it in sync. With no transition configured, or under
26
+ * `prefers-reduced-motion`, the height simply snaps and nothing is ever clipped.
27
+ *
28
+ * @example
29
+ * ```svelte
30
+ * <div class="viewport" {@attach autoHeight}>
31
+ * <div class="inner">
32
+ * <!-- variable-height content; swapping it animates the viewport height -->
33
+ * </div>
34
+ * </div>
35
+ *
36
+ * <style>
37
+ * .inner { display: flex; flex-direction: column; }
38
+ * @media (prefers-reduced-motion: no-preference) {
39
+ * .viewport { transition: height 250ms ease; }
40
+ * }
41
+ * </style>
42
+ * ```
43
+ *
44
+ * (Note: do not set `overflow` on `.viewport` — the attachment manages it.)
45
+ *
46
+ * Conditional usage (a falsy value means "no attachment"):
47
+ * ```svelte
48
+ * <div {@attach enabled && autoHeight}>...</div>
49
+ * ```
50
+ */
51
+ export const autoHeight = (node) => {
52
+ const measure = () => {
53
+ const inner = node.firstElementChild;
54
+ if (!inner)
55
+ return;
56
+ const next = `${inner.offsetHeight}px`;
57
+ if (node.style.height === next)
58
+ return;
59
+ // Clip *only* while a real transition will play, so growing content doesn't
60
+ // spill out mid-animation — but focus rings / borders show fully at rest.
61
+ // `transitionDuration` is "0s" with no transition configured or under
62
+ // prefers-reduced-motion; the first measure (from `auto`) doesn't animate either.
63
+ // Use `clip` (not `hidden`) so a CSS `overflow-clip-margin` on the host can let
64
+ // focus rings / borders paint just outside the box instead of being sliced.
65
+ const willAnimate = node.style.height !== "" &&
66
+ parseFloat(getComputedStyle(node).transitionDuration) > 0;
67
+ if (willAnimate)
68
+ node.style.overflow = "clip";
69
+ node.style.height = next;
70
+ };
71
+ // Restore visibility once the height settles (end or interrupt).
72
+ const reveal = (e) => {
73
+ if (e.target === node && e.propertyName === "height")
74
+ node.style.overflow = "";
75
+ };
76
+ measure();
77
+ const ro = new ResizeObserver(measure);
78
+ if (node.firstElementChild)
79
+ ro.observe(node.firstElementChild);
80
+ node.addEventListener("transitionend", reveal);
81
+ node.addEventListener("transitioncancel", reveal);
82
+ return () => {
83
+ ro.disconnect();
84
+ node.removeEventListener("transitionend", reveal);
85
+ node.removeEventListener("transitioncancel", reveal);
86
+ node.style.height = "";
87
+ node.style.overflow = "";
88
+ };
89
+ };
@@ -0,0 +1 @@
1
+ export * from "./auto-height.js";
@@ -0,0 +1 @@
1
+ export * from "./auto-height.js";
@@ -67,7 +67,13 @@
67
67
  /** Pass-through props for the inner EmailVerifyForm (spread). */
68
68
  verifyProps?: Omit<
69
69
  EmailVerifyFormProps,
70
- "email" | "onSubmit" | "onResend" | "isSubmitting" | "t" | "notifications" | "footer"
70
+ | "email"
71
+ | "onSubmit"
72
+ | "onResend"
73
+ | "isSubmitting"
74
+ | "t"
75
+ | "notifications"
76
+ | "footer"
71
77
  >;
72
78
 
73
79
  /** Reserved for future use (verify mode is not exposed in the default switcher). */
@@ -126,6 +132,14 @@
126
132
  */
127
133
  onModeChange?: (next: LoginOrRegisterFormMode, prev: LoginOrRegisterFormMode) => void;
128
134
 
135
+ /**
136
+ * Smoothly animate the content height when the mode or content changes
137
+ * (login ↔ register ↔ verify, error messages appearing, etc.) instead of
138
+ * snapping. Respects `prefers-reduced-motion` (snaps when reduce is set).
139
+ * Has no effect when `unstyled`. Default: true.
140
+ */
141
+ animateHeight?: boolean;
142
+
129
143
  t?: TranslateFn;
130
144
  unstyled?: boolean;
131
145
  class?: string;
@@ -141,6 +155,7 @@
141
155
  import { createEmptyRegisterFormData } from "../RegisterForm/_internal/register-form-utils.js";
142
156
  import EmailVerifyForm from "../EmailVerifyForm/EmailVerifyForm.svelte";
143
157
  import ButtonGroupRadio from "../ButtonGroupRadio/ButtonGroupRadio.svelte";
158
+ import { autoHeight } from "../../attachments/auto-height.js";
144
159
  import type { scrollToFirstInvalidField } from "../../utils/validate-fields.js";
145
160
 
146
161
  let {
@@ -166,6 +181,7 @@
166
181
  footer,
167
182
  notifications,
168
183
  onModeChange,
184
+ animateHeight = true,
169
185
  t: tProp,
170
186
  unstyled = false,
171
187
  class: classProp,
@@ -221,10 +237,14 @@
221
237
  let registerFormRef = $state<RegisterForm>();
222
238
  let verifyFormRef = $state<EmailVerifyForm>();
223
239
 
224
- function _activeForm(): {
225
- validate?(): boolean;
226
- scrollToFirstError?(opts?: Parameters<typeof scrollToFirstInvalidField>[1]): boolean;
227
- } | undefined {
240
+ function _activeForm():
241
+ | {
242
+ validate?(): boolean;
243
+ scrollToFirstError?(
244
+ opts?: Parameters<typeof scrollToFirstInvalidField>[1]
245
+ ): boolean;
246
+ }
247
+ | undefined {
228
248
  if (mode === "login") return loginFormRef;
229
249
  if (mode === "register") return registerFormRef;
230
250
  if (mode === "verify") return verifyFormRef;
@@ -251,7 +271,7 @@
251
271
  }
252
272
  </script>
253
273
 
254
- <div class={_class} {...rest}>
274
+ {#snippet formContent()}
255
275
  <!-- Mode switcher (verify mode is never rendered as a tab — it's an outcome state) -->
256
276
  {#if mode !== "verify"}
257
277
  <div class={unstyled ? undefined : "stuic-login-or-register-form-switcher"}>
@@ -331,4 +351,20 @@
331
351
  {#if footer}
332
352
  {@render footer({ mode, setMode })}
333
353
  {/if}
354
+ {/snippet}
355
+
356
+ <div class={_class} {...rest}>
357
+ {#if !unstyled && animateHeight}
358
+ <!-- Height-animated viewport: drives its own height to the inner's natural
359
+ height (via the autoHeight attachment) and clips overflow while it transits.
360
+ Both wrappers exist only when the feature is active, so a disabled / unstyled
361
+ form renders byte-for-byte identically to before (children flatten into the root). -->
362
+ <div class="stuic-login-or-register-form-viewport" {@attach autoHeight}>
363
+ <div class="stuic-login-or-register-form-inner">
364
+ {@render formContent()}
365
+ </div>
366
+ </div>
367
+ {:else}
368
+ {@render formContent()}
369
+ {/if}
334
370
  </div>
@@ -85,6 +85,13 @@ export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children">
85
85
  * and Sign up.
86
86
  */
87
87
  onModeChange?: (next: LoginOrRegisterFormMode, prev: LoginOrRegisterFormMode) => void;
88
+ /**
89
+ * Smoothly animate the content height when the mode or content changes
90
+ * (login ↔ register ↔ verify, error messages appearing, etc.) instead of
91
+ * snapping. Respects `prefers-reduced-motion` (snaps when reduce is set).
92
+ * Has no effect when `unstyled`. Default: true.
93
+ */
94
+ animateHeight?: boolean;
88
95
  t?: TranslateFn;
89
96
  unstyled?: boolean;
90
97
  class?: string;
@@ -99,6 +99,9 @@
99
99
  * and Sign up.
100
100
  */
101
101
  onModeChange?: (next: LoginOrRegisterFormMode, prev: LoginOrRegisterFormMode) => void;
102
+
103
+ /** Forwarded to `LoginOrRegisterForm`. Animate content height on mode change. Default: true. */
104
+ animateHeight?: boolean;
102
105
  }
103
106
  </script>
104
107
 
@@ -146,6 +149,7 @@
146
149
  onClose,
147
150
  noClickOutsideClose = true,
148
151
  onModeChange,
152
+ animateHeight,
149
153
  }: Props = $props();
150
154
 
151
155
  let t = $derived(tProp ?? t_default);
@@ -240,6 +244,7 @@
240
244
  {footer}
241
245
  {notifications}
242
246
  {onModeChange}
247
+ {animateHeight}
243
248
  t={tProp}
244
249
  {unstyled}
245
250
  class={classForm}
@@ -72,6 +72,8 @@ export interface Props {
72
72
  * and Sign up.
73
73
  */
74
74
  onModeChange?: (next: LoginOrRegisterFormMode, prev: LoginOrRegisterFormMode) => void;
75
+ /** Forwarded to `LoginOrRegisterForm`. Animate content height on mode change. Default: true. */
76
+ animateHeight?: boolean;
75
77
  }
76
78
  declare const LoginOrRegisterFormModal: import("svelte").Component<Props, {
77
79
  open: (openerOrEvent?: null | HTMLElement | MouseEvent) => void;
@@ -6,13 +6,13 @@ Composite form that toggles between [`LoginForm`](../LoginForm/README.md), [`Reg
6
6
 
7
7
  ## Exports
8
8
 
9
- | Export | Kind | Description |
10
- | --------------------------------- | --------- | ------------------------------------------------- |
11
- | `LoginOrRegisterForm` | component | Composite form |
12
- | `LoginOrRegisterFormModal` | component | Modal-wrapped composite form |
13
- | `LoginOrRegisterFormProps` | type | Props for `LoginOrRegisterForm` |
14
- | `LoginOrRegisterFormModalProps` | type | Props for `LoginOrRegisterFormModal` |
15
- | `LoginOrRegisterFormMode` | type | `"login" \| "register" \| "verify"` |
9
+ | Export | Kind | Description |
10
+ | ------------------------------- | --------- | ------------------------------------ |
11
+ | `LoginOrRegisterForm` | component | Composite form |
12
+ | `LoginOrRegisterFormModal` | component | Modal-wrapped composite form |
13
+ | `LoginOrRegisterFormProps` | type | Props for `LoginOrRegisterForm` |
14
+ | `LoginOrRegisterFormModalProps` | type | Props for `LoginOrRegisterFormModal` |
15
+ | `LoginOrRegisterFormMode` | type | `"login" \| "register" \| "verify"` |
16
16
 
17
17
  ## Mode behavior
18
18
 
@@ -24,47 +24,48 @@ When the active mode changes, the relevant email is **one-shot copied** to the d
24
24
 
25
25
  ## LoginOrRegisterForm — Props
26
26
 
27
- | Prop | Type | Default | Description |
28
- | --------------------- | ------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------ |
29
- | `mode` | `LoginOrRegisterFormMode` | `"login"` | Bindable active mode. |
30
- | `loginData` | `LoginFormData` | empty | Bindable login form data (forwarded to `LoginForm`). |
31
- | `registerData` | `RegisterFormData` | empty | Bindable register form data (forwarded to `RegisterForm`). |
32
- | `verifyEmail` | `string` | `""` | Bindable email used by `EmailVerifyForm` (auto-seeded on transitions). |
33
- | `onLogin` | `(data: LoginFormData) => void` | required | Login submit callback. |
34
- | `onRegister` | `(data: RegisterFormData) => void` | required | Register submit callback. |
35
- | `onVerify` | `(code: string) => void` | - | Verify submit callback (required only when using verify mode). |
36
- | `onResendCode` | `() => Promise<void> \| void` | - | Resend handler — when set, `EmailVerifyForm` renders the resend control. |
37
- | `isSubmitting` | `boolean` | `false` | Forwarded to all three forms. |
38
- | `onForgotPassword` | `() => void` | - | Forgot-password handler for login mode. |
39
- | `loginProps` | `Partial<LoginFormProps>` | - | Pass-through to the inner `LoginForm`. |
40
- | `registerProps` | `Partial<RegisterFormProps>` | - | Pass-through to the inner `RegisterForm`. |
41
- | `verifyProps` | `Partial<EmailVerifyFormProps>` | - | Pass-through to the inner `EmailVerifyForm` (e.g., `error`, `attemptsRemaining`). |
42
- | `modeSwitcher` | `Snippet<[{ mode, setMode, t }]>` | - | Override the built-in `ButtonGroupRadio` switcher. |
43
- | `loginModeLabel` | `string` | i18n | Override the "Log in" tab label. |
44
- | `registerModeLabel` | `string` | i18n | Override the "Sign up" tab label. |
45
- | `socialLogins` | `Snippet` | - | Shared OAuth buttons rendered once below the active form (hidden in verify mode). |
46
- | `socialDividerLabel` | `string \| false` | i18n | Override (or hide with `false`) the divider above social buttons. |
47
- | `footer` | `Snippet<[{ mode, setMode }]>` | - | Mode-aware footer. |
48
- | `notifications` | `NotificationsStack` | - | Forwarded to inner forms. |
49
- | `onModeChange` | `(next, prev) => void` | - | Called when the active mode changes. Use to clear parent-owned mode-specific state. |
50
- | `t` | `TranslateFn` | English | i18n function. |
51
- | `unstyled` / `class` | - | - | Standard styling escape hatches. |
27
+ | Prop | Type | Default | Description |
28
+ | -------------------- | ---------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------- |
29
+ | `mode` | `LoginOrRegisterFormMode` | `"login"` | Bindable active mode. |
30
+ | `loginData` | `LoginFormData` | empty | Bindable login form data (forwarded to `LoginForm`). |
31
+ | `registerData` | `RegisterFormData` | empty | Bindable register form data (forwarded to `RegisterForm`). |
32
+ | `verifyEmail` | `string` | `""` | Bindable email used by `EmailVerifyForm` (auto-seeded on transitions). |
33
+ | `onLogin` | `(data: LoginFormData) => void` | required | Login submit callback. |
34
+ | `onRegister` | `(data: RegisterFormData) => void` | required | Register submit callback. |
35
+ | `onVerify` | `(code: string) => void` | - | Verify submit callback (required only when using verify mode). |
36
+ | `onResendCode` | `() => Promise<void> \| void` | - | Resend handler — when set, `EmailVerifyForm` renders the resend control. |
37
+ | `isSubmitting` | `boolean` | `false` | Forwarded to all three forms. |
38
+ | `onForgotPassword` | `() => void` | - | Forgot-password handler for login mode. |
39
+ | `loginProps` | `Partial<LoginFormProps>` | - | Pass-through to the inner `LoginForm`. |
40
+ | `registerProps` | `Partial<RegisterFormProps>` | - | Pass-through to the inner `RegisterForm`. |
41
+ | `verifyProps` | `Partial<EmailVerifyFormProps>` | - | Pass-through to the inner `EmailVerifyForm` (e.g., `error`, `attemptsRemaining`). |
42
+ | `modeSwitcher` | `Snippet<[{ mode, setMode, t }]>` | - | Override the built-in `ButtonGroupRadio` switcher. |
43
+ | `loginModeLabel` | `string` | i18n | Override the "Log in" tab label. |
44
+ | `registerModeLabel` | `string` | i18n | Override the "Sign up" tab label. |
45
+ | `socialLogins` | `Snippet` | - | Shared OAuth buttons rendered once below the active form (hidden in verify mode). |
46
+ | `socialDividerLabel` | `string \| false` | i18n | Override (or hide with `false`) the divider above social buttons. |
47
+ | `footer` | `Snippet<[{ mode, setMode }]>` | - | Mode-aware footer. |
48
+ | `notifications` | `NotificationsStack` | - | Forwarded to inner forms. |
49
+ | `onModeChange` | `(next, prev) => void` | - | Called when the active mode changes. Use to clear parent-owned mode-specific state. |
50
+ | `animateHeight` | `boolean` | `true` | Smoothly animate content height on mode/content change. Respects `prefers-reduced-motion`; no effect when `unstyled`. |
51
+ | `t` | `TranslateFn` | English | i18n function. |
52
+ | `unstyled` / `class` | - | - | Standard styling escape hatches. |
52
53
 
53
54
  ## LoginOrRegisterFormModal — extra props
54
55
 
55
56
  Inherits all `LoginOrRegisterForm` props, plus:
56
57
 
57
- | Prop | Type | Default | Description |
58
- | --------------------- | ------------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
59
- | `title` | `string` | mode-aware ("Log In" / "Create account" / "Verify your email") | Modal title. |
60
- | `visible` | `boolean` | `false` | Bindable modal visibility. |
61
- | `trigger` | `Snippet<[{ open }]>` | - | Optional trigger element rendered outside the modal. |
62
- | `classModal` | `string` | - | Class for the Modal box. |
63
- | `classInner` | `string` | - | Class for the Modal inner width container. |
64
- | `classForm` | `string` | - | Class forwarded to the inner `LoginOrRegisterForm`. |
65
- | `noXClose` | `boolean` | `false` | Hide the close (X) button. |
66
- | `onClose` | `() => false \| void` | - | Pre-close hook. Return `false` to prevent close. |
67
- | `noClickOutsideClose` | `boolean` | `true` | Disable backdrop-click close (default-on to protect typed credentials). |
58
+ | Prop | Type | Default | Description |
59
+ | --------------------- | --------------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------- |
60
+ | `title` | `string` | mode-aware ("Log In" / "Create account" / "Verify your email") | Modal title. |
61
+ | `visible` | `boolean` | `false` | Bindable modal visibility. |
62
+ | `trigger` | `Snippet<[{ open }]>` | - | Optional trigger element rendered outside the modal. |
63
+ | `classModal` | `string` | - | Class for the Modal box. |
64
+ | `classInner` | `string` | - | Class for the Modal inner width container. |
65
+ | `classForm` | `string` | - | Class forwarded to the inner `LoginOrRegisterForm`. |
66
+ | `noXClose` | `boolean` | `false` | Hide the close (X) button. |
67
+ | `onClose` | `() => false \| void` | - | Pre-close hook. Return `false` to prevent close. |
68
+ | `noClickOutsideClose` | `boolean` | `true` | Disable backdrop-click close (default-on to protect typed credentials). |
68
69
 
69
70
  **Methods:** `open(openerOrEvent?)`, `close()` — exposed via `bind:this`.
70
71
 
@@ -121,11 +122,7 @@ Inherits all `LoginOrRegisterForm` props, plus:
121
122
  ### Modal with trigger + shared OAuth
122
123
 
123
124
  ```svelte
124
- <LoginOrRegisterFormModal
125
- bind:mode
126
- onLogin={api.login}
127
- onRegister={api.register}
128
- >
125
+ <LoginOrRegisterFormModal bind:mode onLogin={api.login} onRegister={api.register}>
129
126
  {#snippet trigger({ open })}
130
127
  <Button onclick={open}>Sign in / Sign up</Button>
131
128
  {/snippet}
@@ -140,28 +137,31 @@ Inherits all `LoginOrRegisterForm` props, plus:
140
137
 
141
138
  Prefix: `--stuic-login-or-register-form-*`
142
139
 
143
- | Variable | Purpose |
144
- | -------------------------------------------------------------- | ---------------------------------- |
145
- | `--stuic-login-or-register-form-gap` | Vertical gap |
146
- | `--stuic-login-or-register-form-switcher-margin-bottom` | Spacing below the mode switcher |
147
- | `--stuic-login-or-register-form-social-margin-top` | Margin above social block |
148
- | `--stuic-login-or-register-form-social-gap` | Gap between social buttons |
149
- | `--stuic-login-or-register-form-social-divider-color` | Divider text color |
150
- | `--stuic-login-or-register-form-social-divider-line-color` | Divider line color |
151
- | `--stuic-login-or-register-form-social-divider-font-size` | Divider text size |
152
- | `--stuic-login-or-register-form-social-divider-margin-bottom` | Divider bottom margin |
140
+ | Variable | Purpose |
141
+ | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
142
+ | `--stuic-login-or-register-form-gap` | Vertical gap |
143
+ | `--stuic-login-or-register-form-switcher-margin-bottom` | Spacing below the mode switcher |
144
+ | `--stuic-login-or-register-form-social-margin-top` | Margin above social block |
145
+ | `--stuic-login-or-register-form-social-gap` | Gap between social buttons |
146
+ | `--stuic-login-or-register-form-social-divider-color` | Divider text color |
147
+ | `--stuic-login-or-register-form-social-divider-line-color` | Divider line color |
148
+ | `--stuic-login-or-register-form-social-divider-font-size` | Divider text size |
149
+ | `--stuic-login-or-register-form-social-divider-margin-bottom` | Divider bottom margin |
150
+ | `--stuic-login-or-register-form-height-transition-duration` | Height animation duration (`animateHeight`) |
151
+ | `--stuic-login-or-register-form-height-transition-easing` | Height animation easing (`animateHeight`) |
152
+ | `--stuic-login-or-register-form-height-clip-margin` | `overflow-clip-margin` while the height animates — how far focus rings / borders may paint past the clip edge so they aren't sliced mid-transition. Default `0.5rem`; raise it if inner controls have larger rings/shadows. |
153
153
 
154
154
  ## i18n keys
155
155
 
156
- | Key | English default |
157
- | ------------------------------------------------ | -------------------- |
158
- | `login_or_register_form.mode_login` | `Log in` |
159
- | `login_or_register_form.mode_register` | `Sign up` |
160
- | `login_or_register_form.mode_verify` | `Verify` |
161
- | `login_or_register_form.modal_title_login` | `Log In` |
162
- | `login_or_register_form.modal_title_register` | `Create account` |
163
- | `login_or_register_form.modal_title_verify` | `Verify your email` |
164
- | `login_or_register_form.social_divider` | `or continue with` |
156
+ | Key | English default |
157
+ | --------------------------------------------- | ------------------- |
158
+ | `login_or_register_form.mode_login` | `Log in` |
159
+ | `login_or_register_form.mode_register` | `Sign up` |
160
+ | `login_or_register_form.mode_verify` | `Verify` |
161
+ | `login_or_register_form.modal_title_login` | `Log In` |
162
+ | `login_or_register_form.modal_title_register` | `Create account` |
163
+ | `login_or_register_form.modal_title_verify` | `Verify your email` |
164
+ | `login_or_register_form.social_divider` | `or continue with` |
165
165
 
166
166
  ## See also
167
167
 
@@ -3,6 +3,10 @@
3
3
  --stuic-login-or-register-form-gap: 0rem;
4
4
  --stuic-login-or-register-form-switcher-margin-bottom: 2rem;
5
5
 
6
+ /* Height animation on mode/content change (animateHeight prop) */
7
+ --stuic-login-or-register-form-height-transition-duration: 250ms;
8
+ --stuic-login-or-register-form-height-transition-easing: ease;
9
+
6
10
  /* Social login section (shared, rendered at composite level) */
7
11
  --stuic-login-or-register-form-social-margin-top: 1.5rem;
8
12
  --stuic-login-or-register-form-social-gap: 0.75rem;
@@ -35,6 +39,29 @@
35
39
  display: contents;
36
40
  }
37
41
 
42
+ /* Height-animated wrappers (rendered only when animateHeight && !unstyled).
43
+ The autoHeight attachment drives the viewport's height and toggles
44
+ `overflow: clip` *only while the height transitions* — so growing content
45
+ doesn't spill out mid-animation, while focus rings / borders aren't clipped at
46
+ rest. `overflow-clip-margin` lets those rings/borders (~4px outline on the
47
+ switcher) paint just past the clip edge so they aren't sliced mid-transition
48
+ either; it only takes effect while the attachment is clipping. The inner is the
49
+ natural-height flex column the attachment measures. */
50
+ .stuic-login-or-register-form-viewport {
51
+ overflow-clip-margin: var(--stuic-login-or-register-form-height-clip-margin, 0.5rem);
52
+ }
53
+ .stuic-login-or-register-form-inner {
54
+ display: flex;
55
+ flex-direction: column;
56
+ gap: var(--stuic-login-or-register-form-gap);
57
+ }
58
+ @media (prefers-reduced-motion: no-preference) {
59
+ .stuic-login-or-register-form-viewport {
60
+ transition: height var(--stuic-login-or-register-form-height-transition-duration)
61
+ var(--stuic-login-or-register-form-height-transition-easing);
62
+ }
63
+ }
64
+
38
65
  /* Social login container */
39
66
  .stuic-login-or-register-form-social {
40
67
  margin-top: var(--stuic-login-or-register-form-social-margin-top);
package/dist/index.d.ts CHANGED
@@ -79,4 +79,5 @@ export * from "./components/WithSidePanel/index.js";
79
79
  export * from "./components/X/index.js";
80
80
  export * from "./utils/index.js";
81
81
  export * from "./actions/index.js";
82
+ export * from "./attachments/index.js";
82
83
  export * from "./icons/index.js";
package/dist/index.js CHANGED
@@ -82,5 +82,7 @@ export * from "./components/X/index.js";
82
82
  export * from "./utils/index.js";
83
83
  // actions
84
84
  export * from "./actions/index.js";
85
+ // attachments
86
+ export * from "./attachments/index.js";
85
87
  // icons
86
88
  export * from "./icons/index.js";
@@ -0,0 +1,141 @@
1
+ # Attachments Domain
2
+
3
+ ## Overview
4
+
5
+ Svelte [attachments](https://svelte.dev/docs/svelte/@attach) (`{@attach ...}`, Svelte 5.29+)
6
+ for reusable DOM behavior. Attachments are the **preferred API for new DOM helpers** — they
7
+ run in an effect (so they're reactive), compose (any number per element), and can be forwarded
8
+ through components. The older [`actions/`](./actions.md) domain is kept for back-compat; new
9
+ helpers go here.
10
+
11
+ Live under `src/lib/attachments/`, exported from `src/lib/attachments/index.ts`, which is
12
+ re-exported from the package root (`src/lib/index.ts`).
13
+
14
+ ---
15
+
16
+ ## Available Attachments
17
+
18
+ | Attachment | Purpose | File |
19
+ | ------------ | ------------------------------------------------------------ | ---------------- |
20
+ | `autoHeight` | Animate a host's height to its single child's natural height | `auto-height.ts` |
21
+
22
+ ---
23
+
24
+ ## `autoHeight`
25
+
26
+ Drives the host element's `height` to match the natural height of its **single element child**,
27
+ re-measuring on resize via a `ResizeObserver`. Pair it with a CSS `height` transition to get a
28
+ smooth grow/shrink as the child's content changes (e.g. swapping between differently-sized views
29
+ that can't animate via a consumer-level `transition:` because the host stays mounted).
30
+
31
+ ```svelte
32
+ <div class="viewport" {@attach autoHeight}>
33
+ <div class="inner">
34
+ <!-- variable-height content; changing it animates the viewport height -->
35
+ </div>
36
+ </div>
37
+
38
+ <style>
39
+ .inner {
40
+ display: flex;
41
+ flex-direction: column;
42
+ }
43
+ @media (prefers-reduced-motion: no-preference) {
44
+ .viewport {
45
+ transition: height 250ms ease;
46
+ }
47
+ }
48
+ </style>
49
+ ```
50
+
51
+ ### What the attachment owns vs. what you own
52
+
53
+ The attachment owns two inline styles on the host: the `height`, and — **only while that height
54
+ is transitioning** — `overflow: clip`. Clipping during the transition stops growing content from
55
+ spilling out as the box opens; clearing it at rest means focus rings, borders and shadows that
56
+ paint outside the box are **not** cut off when idle. So:
57
+
58
+ - **Don't set `overflow` on the host yourself** — it would override the at-rest reset and clip
59
+ permanently.
60
+ - **You own the `transition`** (and gating it behind `prefers-reduced-motion`). With no
61
+ transition configured, or under reduced-motion, the height just snaps and nothing is ever
62
+ clipped.
63
+
64
+ ### `overflow-clip-margin` (the per-consumer knob)
65
+
66
+ By default the clip edge sits at the box, so focus rings / borders that paint _outside_ a child's
67
+ box are sliced **while the transition runs** (they're fine at rest). To keep them visible during
68
+ the transition too, set `overflow-clip-margin` on the host — `overflow: clip` honours it, so paint
69
+ within that margin bleeds past the clip edge:
70
+
71
+ ```css
72
+ .viewport {
73
+ overflow-clip-margin: 0.5rem; /* let ~few-px focus outlines bleed through */
74
+ }
75
+ ```
76
+
77
+ **This is consumer-specific** — different hosts wrap controls with different ring/shadow sizes:
78
+
79
+ - Size it to the largest thing painting outside a child's box: `outline-width + outline-offset`,
80
+ or a shadow's blur + spread.
81
+ - Too small → rings still clip mid-transition; too large → growing content peeks a little further
82
+ past the edge before it's clipped.
83
+ - If downstream consumers may need to tune it, expose it as a custom property with a default.
84
+
85
+ Example (`LoginOrRegisterForm`): the switcher's focus outline is ~4px, so the component sets
86
+ `overflow-clip-margin: var(--stuic-login-or-register-form-height-clip-margin, 0.5rem)`, letting an
87
+ app override the margin per its theme.
88
+
89
+ ### Conditional / disabled
90
+
91
+ A falsy value means "no attachment" — toggling it removes the attachment (its cleanup clears the
92
+ inline `height` and `overflow`):
93
+
94
+ ```svelte
95
+ <div {@attach enabled && autoHeight}>...</div>
96
+ ```
97
+
98
+ ### Notes
99
+
100
+ - **Single child:** the host must contain exactly one element child — that's what gets measured.
101
+ - **First paint:** the initial `auto → px` lock doesn't animate (`auto` isn't interpolatable), so
102
+ there's no unwanted mount animation; px → px changes after that animate.
103
+ - **Async reflow:** web-font / image loads that change the child's height are caught by the
104
+ `ResizeObserver`.
105
+
106
+ ---
107
+
108
+ ## Attachment File Pattern
109
+
110
+ An attachment is a function `(node) => cleanup?`, typed `Attachment<T>` from `svelte/attachments`.
111
+ It runs in an effect when the element mounts (and re-runs if reactive state read inside it
112
+ changes); the returned function runs before a re-run and on unmount.
113
+
114
+ ```ts
115
+ // auto-height.ts
116
+ import type { Attachment } from "svelte/attachments";
117
+
118
+ export const autoHeight: Attachment<HTMLElement> = (node) => {
119
+ // setup (reads no reactive state here, so it runs once on mount)...
120
+ return () => {
121
+ /* cleanup */
122
+ };
123
+ };
124
+ ```
125
+
126
+ Contrast with an [action](./actions.md): an action runs once and is **not** reactive to its
127
+ argument (the codebase works around that by passing a `() => options` thunk read inside an
128
+ `$effect`). An attachment is the effect, so reactivity is built in, and unlike actions it can be
129
+ spread/forwarded onto a component's inner element.
130
+
131
+ A plain `.ts` file is fine when the attachment uses no runes (as above). Use `.svelte.ts` only if
132
+ it needs `$state` / `$derived` / a nested `$effect`.
133
+
134
+ ---
135
+
136
+ ## Key Files
137
+
138
+ | File | Purpose |
139
+ | ---------------------------------- | --------------------------- |
140
+ | src/lib/attachments/index.ts | All attachment exports |
141
+ | src/lib/attachments/auto-height.ts | Height-animation attachment |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.101.0",
3
+ "version": "3.102.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && pnpm run prepack",
@@ -55,7 +55,7 @@
55
55
  "@eslint/js": "^9.39.4",
56
56
  "@marianmeres/random-human-readable": "^1.9.0",
57
57
  "@sveltejs/adapter-auto": "^4.0.0",
58
- "@sveltejs/kit": "^2.61.0",
58
+ "@sveltejs/kit": "^2.61.1",
59
59
  "@sveltejs/package": "^2.5.7",
60
60
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
61
61
  "@tailwindcss/cli": "^4.3.0",
@@ -69,12 +69,12 @@
69
69
  "prettier": "^3.8.3",
70
70
  "prettier-plugin-svelte": "^3.5.2",
71
71
  "publint": "^0.3.21",
72
- "svelte": "^5.55.9",
72
+ "svelte": "^5.55.10",
73
73
  "svelte-check": "^4.4.8",
74
74
  "tailwindcss": "^4.3.0",
75
75
  "tsx": "^4.22.3",
76
76
  "typescript": "^5.9.3",
77
- "typescript-eslint": "^8.59.4",
77
+ "typescript-eslint": "^8.60.0",
78
78
  "vite": "^7.3.3",
79
79
  "vitest": "^3.2.4"
80
80
  },