@marianmeres/stuic 3.101.1 → 3.103.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 +34 -25
- package/dist/attachments/auto-height.d.ts +52 -0
- package/dist/attachments/auto-height.js +89 -0
- package/dist/attachments/index.d.ts +1 -0
- package/dist/attachments/index.js +1 -0
- package/dist/components/Button/index.css +15 -9
- package/dist/components/LoginOrRegisterForm/LoginOrRegisterForm.svelte +42 -6
- package/dist/components/LoginOrRegisterForm/LoginOrRegisterForm.svelte.d.ts +7 -0
- package/dist/components/LoginOrRegisterForm/LoginOrRegisterFormModal.svelte +5 -0
- package/dist/components/LoginOrRegisterForm/LoginOrRegisterFormModal.svelte.d.ts +2 -0
- package/dist/components/LoginOrRegisterForm/README.md +67 -67
- package/dist/components/LoginOrRegisterForm/index.css +27 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/docs/domains/attachments.md +141 -0
- package/package.json +1 -1
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
|
|
62
|
-
|
|
|
63
|
-
| `--stuic-radius`
|
|
64
|
-
| `--stuic-radius-button`
|
|
65
|
-
| `--stuic-radius-container`
|
|
66
|
-
| `--stuic-shadow`
|
|
67
|
-
| `--stuic-shadow-hover`
|
|
68
|
-
| `--stuic-shadow-overlay`
|
|
69
|
-
| `--stuic-shadow-dialog`
|
|
70
|
-
| `--stuic-border-width`
|
|
71
|
-
| `--stuic-border-width-button` | `1px`
|
|
72
|
-
| `--stuic-transition`
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
135
|
-
|
|
|
136
|
-
| `src/lib/index.css`
|
|
137
|
-
| `src/lib/index.ts`
|
|
138
|
-
| `src/lib/utils/design-tokens.ts`
|
|
139
|
-
| `@marianmeres/design-tokens/css/*.css` | Theme CSS files (42 themes, `--stuic-` prefix)
|
|
140
|
-
| `src/lib/components/Button/`
|
|
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";
|
|
@@ -389,30 +389,36 @@
|
|
|
389
389
|
============================================================================ */
|
|
390
390
|
.stuic-button[data-aspect1] {
|
|
391
391
|
aspect-ratio: 1;
|
|
392
|
-
justify-items: center;
|
|
393
392
|
}
|
|
394
393
|
|
|
394
|
+
/*
|
|
395
|
+
* Declare both dimensions instead of deriving the square from aspect-ratio +
|
|
396
|
+
* min-* on a shrink-to-fit inline-flex box. WebKit (iOS Safari) resolves the
|
|
397
|
+
* cross-axis from content width before enforcing min-width, which squeezed
|
|
398
|
+
* icon buttons into a vertical ellipse. Fixed width == height removes that
|
|
399
|
+
* derivation, so every engine renders a true square/circle.
|
|
400
|
+
*/
|
|
395
401
|
.stuic-button[data-aspect1][data-size="sm"] {
|
|
396
|
-
|
|
397
|
-
|
|
402
|
+
width: var(--stuic-button-min-height-sm);
|
|
403
|
+
height: var(--stuic-button-min-height-sm);
|
|
398
404
|
padding: var(--stuic-button-padding-y-sm);
|
|
399
405
|
}
|
|
400
406
|
|
|
401
407
|
.stuic-button[data-aspect1][data-size="md"] {
|
|
402
|
-
|
|
403
|
-
|
|
408
|
+
width: var(--stuic-button-min-height-md);
|
|
409
|
+
height: var(--stuic-button-min-height-md);
|
|
404
410
|
padding: var(--stuic-button-padding-y-md);
|
|
405
411
|
}
|
|
406
412
|
|
|
407
413
|
.stuic-button[data-aspect1][data-size="lg"] {
|
|
408
|
-
|
|
409
|
-
|
|
414
|
+
width: var(--stuic-button-min-height-lg);
|
|
415
|
+
height: var(--stuic-button-min-height-lg);
|
|
410
416
|
padding: var(--stuic-button-padding-y-lg);
|
|
411
417
|
}
|
|
412
418
|
|
|
413
419
|
.stuic-button[data-aspect1][data-size="xl"] {
|
|
414
|
-
|
|
415
|
-
|
|
420
|
+
width: var(--stuic-button-min-height-xl);
|
|
421
|
+
height: var(--stuic-button-min-height-xl);
|
|
416
422
|
padding: var(--stuic-button-padding-y-xl);
|
|
417
423
|
}
|
|
418
424
|
|
|
@@ -67,7 +67,13 @@
|
|
|
67
67
|
/** Pass-through props for the inner EmailVerifyForm (spread). */
|
|
68
68
|
verifyProps?: Omit<
|
|
69
69
|
EmailVerifyFormProps,
|
|
70
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
|
10
|
-
|
|
|
11
|
-
| `LoginOrRegisterForm`
|
|
12
|
-
| `LoginOrRegisterFormModal`
|
|
13
|
-
| `LoginOrRegisterFormProps`
|
|
14
|
-
| `LoginOrRegisterFormModalProps`
|
|
15
|
-
| `LoginOrRegisterFormMode`
|
|
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
|
|
28
|
-
|
|
|
29
|
-
| `mode`
|
|
30
|
-
| `loginData`
|
|
31
|
-
| `registerData`
|
|
32
|
-
| `verifyEmail`
|
|
33
|
-
| `onLogin`
|
|
34
|
-
| `onRegister`
|
|
35
|
-
| `onVerify`
|
|
36
|
-
| `onResendCode`
|
|
37
|
-
| `isSubmitting`
|
|
38
|
-
| `onForgotPassword`
|
|
39
|
-
| `loginProps`
|
|
40
|
-
| `registerProps`
|
|
41
|
-
| `verifyProps`
|
|
42
|
-
| `modeSwitcher`
|
|
43
|
-
| `loginModeLabel`
|
|
44
|
-
| `registerModeLabel`
|
|
45
|
-
| `socialLogins`
|
|
46
|
-
| `socialDividerLabel`
|
|
47
|
-
| `footer`
|
|
48
|
-
| `notifications`
|
|
49
|
-
| `onModeChange`
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
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
|
|
58
|
-
| --------------------- |
|
|
59
|
-
| `title` | `string`
|
|
60
|
-
| `visible` | `boolean`
|
|
61
|
-
| `trigger` | `Snippet<[{ open }]>`
|
|
62
|
-
| `classModal` | `string`
|
|
63
|
-
| `classInner` | `string`
|
|
64
|
-
| `classForm` | `string`
|
|
65
|
-
| `noXClose` | `boolean`
|
|
66
|
-
| `onClose` | `() => false \| void`
|
|
67
|
-
| `noClickOutsideClose` | `boolean`
|
|
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
|
|
144
|
-
|
|
|
145
|
-
| `--stuic-login-or-register-form-gap`
|
|
146
|
-
| `--stuic-login-or-register-form-switcher-margin-bottom`
|
|
147
|
-
| `--stuic-login-or-register-form-social-margin-top`
|
|
148
|
-
| `--stuic-login-or-register-form-social-gap`
|
|
149
|
-
| `--stuic-login-or-register-form-social-divider-color`
|
|
150
|
-
| `--stuic-login-or-register-form-social-divider-line-color`
|
|
151
|
-
| `--stuic-login-or-register-form-social-divider-font-size`
|
|
152
|
-
| `--stuic-login-or-register-form-social-divider-margin-bottom`
|
|
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
|
|
157
|
-
|
|
|
158
|
-
| `login_or_register_form.mode_login`
|
|
159
|
-
| `login_or_register_form.mode_register`
|
|
160
|
-
| `login_or_register_form.mode_verify`
|
|
161
|
-
| `login_or_register_form.modal_title_login`
|
|
162
|
-
| `login_or_register_form.modal_title_register`
|
|
163
|
-
| `login_or_register_form.modal_title_verify`
|
|
164
|
-
| `login_or_register_form.social_divider`
|
|
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
package/dist/index.js
CHANGED
|
@@ -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 |
|