@excom/scroll-into-view 0.1.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 (36) hide show
  1. package/.rush/temp/chunked-rush-logs/scroll-into-view.apply-exports.chunks.jsonl +1 -0
  2. package/.rush/temp/chunked-rush-logs/scroll-into-view.build_docs.chunks.jsonl +1 -0
  3. package/.rush/temp/chunked-rush-logs/scroll-into-view.build_package-metas.chunks.jsonl +1 -0
  4. package/.rush/temp/operation/apply-exports/all.log +1 -0
  5. package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
  6. package/.rush/temp/operation/apply-exports/state.json +3 -0
  7. package/.rush/temp/operation/build_docs/all.log +1 -0
  8. package/.rush/temp/operation/build_docs/log-chunks.jsonl +1 -0
  9. package/.rush/temp/operation/build_docs/state.json +3 -0
  10. package/.rush/temp/operation/build_package-metas/all.log +1 -0
  11. package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
  12. package/.rush/temp/operation/build_package-metas/state.json +3 -0
  13. package/.rush/temp/shrinkwrap-deps.json +3 -0
  14. package/config/rig.json +5 -0
  15. package/index.css +5 -0
  16. package/index.ts +17 -0
  17. package/package.json +45 -0
  18. package/rush-logs/scroll-into-view.apply-exports.cache.log +1 -0
  19. package/rush-logs/scroll-into-view.apply-exports.log +1 -0
  20. package/rush-logs/scroll-into-view.build_docs.cache.log +1 -0
  21. package/rush-logs/scroll-into-view.build_docs.log +1 -0
  22. package/rush-logs/scroll-into-view.build_package-metas.cache.log +1 -0
  23. package/rush-logs/scroll-into-view.build_package-metas.log +1 -0
  24. package/scroll-into-view.ts +112 -0
  25. package/src/scroll-into-view.css +23 -0
  26. package/support/custom-elements.json +189 -0
  27. package/support/demos/simple.html +12 -0
  28. package/support/demos/sticky-header.html +22 -0
  29. package/support/dist-docs/scroll-into-view.md +130 -0
  30. package/support/docs/README.md +40 -0
  31. package/support/package-meta.json +220 -0
  32. package/support/tests/scroll-into-view.test.ts +77 -0
  33. package/support/tests/scroll-options.test.ts +169 -0
  34. package/support/tests/simple.view.test.ts +21 -0
  35. package/support/tests/sticky-header.view.test.ts +27 -0
  36. package/tsconfig.json +5 -0
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: cd \"$RUSH_PROJECT_FOLDER\" && node ../heft-rig/scripts/apply-exports.mjs \n"}
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-docs.mjs \n"}
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs \n"}
@@ -0,0 +1 @@
1
+ Invoking: cd "$RUSH_PROJECT_FOLDER" && node ../heft-rig/scripts/apply-exports.mjs
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: cd \"$RUSH_PROJECT_FOLDER\" && node ../heft-rig/scripts/apply-exports.mjs \n"}
@@ -0,0 +1,3 @@
1
+ {
2
+ "nonCachedDurationMs": 33.73985300000004
3
+ }
@@ -0,0 +1 @@
1
+ Invoking: node node_modules/@excom/heft-rig/scripts/build-docs.mjs
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-docs.mjs \n"}
@@ -0,0 +1,3 @@
1
+ {
2
+ "nonCachedDurationMs": 53.71328399999993
3
+ }
@@ -0,0 +1 @@
1
+ Invoking: node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs \n"}
@@ -0,0 +1,3 @@
1
+ {
2
+ "nonCachedDurationMs": 96.59766100000024
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "../../packages/scroll-into-view": "../../packages/scroll-into-view:FiQhO2kLyBFBN2Sjs0m2s+Zh0vbMGja8Orzrnv4l1do=:"
3
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
3
+ "rigPackageName": "@excom/heft-rig",
4
+ "rigProfile": "default"
5
+ }
package/index.css ADDED
@@ -0,0 +1,5 @@
1
+ @import "./src/scroll-into-view.css";
2
+
3
+ @layer {
4
+ @mixin module-scroll-into-view;
5
+ }
package/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { ScrollIntoView } from "./scroll-into-view";
2
+
3
+ ScrollIntoView.define();
4
+
5
+ export { ScrollIntoView };
6
+
7
+ type T_HTMLScrollIntoViewElement = typeof ScrollIntoView.CustomElement;
8
+ declare global {
9
+ interface HTMLScrollIntoViewElement extends T_HTMLScrollIntoViewElement {}
10
+ interface Window {
11
+ HTMLScrollIntoViewElement: HTMLScrollIntoViewElement;
12
+ }
13
+ interface HTMLElementTagNameMap {
14
+ "scroll-into-view": HTMLScrollIntoViewElement;
15
+ }
16
+ }
17
+ export type { HTMLScrollIntoViewElement };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@excom/scroll-into-view",
3
+ "version": "0.1.0",
4
+ "description": "<scroll-into-view> custom element",
5
+ "license": "MIT",
6
+ "engines": {
7
+ "node": ">=24.13.0"
8
+ },
9
+ "type": "module",
10
+ "dependencies": {
11
+ "@excom/kit-scroller": "^0.1.0",
12
+ "@excom/neutron": "^0.1.0",
13
+ "@excom/kit-utils": "^0.1.0",
14
+ "@excom/listenable-element": "^0.1.0"
15
+ },
16
+ "peerDependencies": {},
17
+ "devDependencies": {
18
+ "@excom/heft-rig": "^0.1.0"
19
+ },
20
+ "repository": {
21
+ "url": "excom-dev/nucleus",
22
+ "directory": "packages/scroll-into-view"
23
+ },
24
+ "homepage": "https://github.com/excom-dev/nucleus/tree/main/packages/scroll-into-view/support/docs/README.md",
25
+ "bugs": "https://github.com/excom-dev/nucleus/issues",
26
+ "keywords": [
27
+ "scroll-into-view",
28
+ "neutron",
29
+ "custom-elements"
30
+ ],
31
+ "excom": {
32
+ "packageType": "kit-element"
33
+ },
34
+ "scripts": {
35
+ "build": "node node_modules/@excom/heft-rig/scripts/vite-build.mjs",
36
+ "build:watch": "node node_modules/@excom/heft-rig/scripts/vite-build-watch.mjs",
37
+ "format": "node node_modules/@excom/heft-rig/scripts/format.mjs",
38
+ "test": "node node_modules/@excom/heft-rig/scripts/vitest.mjs",
39
+ "coverage": "node node_modules/@excom/heft-rig/scripts/coverage.mjs",
40
+ "dev": "node node_modules/@excom/heft-rig/scripts/vite-dev.mjs",
41
+ "preview": "node node_modules/@excom/heft-rig/scripts/vite-preview.mjs",
42
+ "build:package-metas": "node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs",
43
+ "build:docs": "node node_modules/@excom/heft-rig/scripts/build-docs.mjs"
44
+ }
45
+ }
@@ -0,0 +1 @@
1
+ Caching has been disabled for this project's "apply-exports" command.
@@ -0,0 +1 @@
1
+ Invoking: cd "$RUSH_PROJECT_FOLDER" && node ../heft-rig/scripts/apply-exports.mjs
@@ -0,0 +1 @@
1
+ This project does not define the caching behavior of the "build:docs" command, so caching has been disabled.
@@ -0,0 +1 @@
1
+ Invoking: node node_modules/@excom/heft-rig/scripts/build-docs.mjs
@@ -0,0 +1 @@
1
+ This project does not define the caching behavior of the "build:package-metas" command, so caching has been disabled.
@@ -0,0 +1 @@
1
+ Invoking: node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs
@@ -0,0 +1,112 @@
1
+ import { scrollElementIntoView } from "@excom/kit-scroller";
2
+ import { selectOne } from "@excom/kit-utils";
3
+ import { isNumber } from "@excom/kit-utils";
4
+ import { ListenableElement } from "@excom/listenable-element";
5
+ import { Neutron, TokenList } from "@excom/neutron";
6
+
7
+ /**
8
+ * Scrolls an element into view, based on configuration. By default it
9
+ * scrolls itself into view as soon as it connects to the DOM; combine with
10
+ * the inherited `listen-for` attribute to scroll on clicks, custom events,
11
+ * or lifecycles instead.
12
+ *
13
+ * @summary Scroll an element into view — on connect, click, or any event.
14
+ *
15
+ * @example
16
+ * <!-- Jump to a section on click -->
17
+ * <scroll-into-view target-ref="#pricing" listen-for="click">
18
+ * See pricing
19
+ * </scroll-into-view>
20
+ */
21
+ export const ScrollIntoView = Neutron.compose([
22
+ ListenableElement,
23
+ Neutron({
24
+ tag: "scroll-into-view",
25
+ props: {
26
+ /**
27
+ * @option
28
+ * CSS selector of the element to scroll to. Unset scrolls this
29
+ * element itself.
30
+ * @values <CSS Selector>
31
+ */
32
+ targetRef: String,
33
+ /**
34
+ * @option
35
+ * Two tokens `<inline> <block>` (x and y, respectively) controlling
36
+ * how the target aligns inside the scrollport. Each token is
37
+ * `start`, `center`, `end`, `nearest`, or `none` (skip alignment on
38
+ * that axis).
39
+ * @default nearest start
40
+ * @values start | center | end | nearest | none
41
+ */
42
+ scrollAlign: {
43
+ type: TokenList,
44
+ isValid: (value: string) =>
45
+ ["start", "center", "end", "nearest", "none"].includes(value),
46
+ defaultValue: () => ["nearest", "start"],
47
+ },
48
+ /**
49
+ * @option
50
+ * Two pixel offsets `<x> <y>` applied after alignment — e.g. leave
51
+ * room for a sticky header by using a negative `<y>`.
52
+ * @default 0 0
53
+ * @values <px> <px>
54
+ */
55
+ scrollOffset: { type: TokenList, defaultValue: () => ["0", "0"] },
56
+ /**
57
+ * @option
58
+ * Scroll animation.
59
+ * @default auto
60
+ * @values auto | smooth | instant
61
+ */
62
+ scrollBehavior: { type: String, defaultValue: () => "auto" },
63
+ /**
64
+ * @option
65
+ * Only scroll if the target isn't already fully visible.
66
+ */
67
+ ifNeeded: Boolean,
68
+ },
69
+ }),
70
+ ])
71
+ .defineMethods({
72
+ actionHandler: (element) => {
73
+ const target = (
74
+ element.targetRef
75
+ ? selectOne(element.targetRef, {
76
+ scope: element,
77
+ })
78
+ : element
79
+ ) as HTMLElement;
80
+ requestAnimationFrame(() => {
81
+ // wait until after next paint to scroll
82
+ setTimeout(() => {
83
+ const scrollInline = element.scrollAlign[0] || "nearest";
84
+ const scrollBlock = element.scrollAlign[1] || "start";
85
+ const offsetX = Number(element.scrollOffset[0]);
86
+ const offsetY = Number(element.scrollOffset[1]);
87
+ scrollElementIntoView(target, {
88
+ block:
89
+ scrollBlock && scrollBlock !== "none"
90
+ ? (scrollBlock as ScrollLogicalPosition)
91
+ : undefined,
92
+ inline:
93
+ scrollInline && scrollInline !== "none"
94
+ ? (scrollInline as ScrollLogicalPosition)
95
+ : undefined,
96
+ offsetInline: isNumber(offsetX) ? offsetX : undefined,
97
+ offsetBlock: isNumber(offsetY) ? offsetY : undefined,
98
+ behavior: element.scrollBehavior as ScrollBehavior,
99
+ onlyIfNeeded: element.ifNeeded,
100
+ });
101
+ }, 0);
102
+ });
103
+ },
104
+ })
105
+ .onConnected(
106
+ ({ listenFor, listenForLifecycle, isMoving }) =>
107
+ !isMoving &&
108
+ !listenFor?.length &&
109
+ !listenForLifecycle?.length && {
110
+ handleEvent: ["connected"],
111
+ }
112
+ );
@@ -0,0 +1,23 @@
1
+ @import "@excom/listenable-element/src/index.css";
2
+
3
+ /**
4
+ * Styles for `<scroll-into-view>`.
5
+ * @element scroll-into-view
6
+ */
7
+
8
+ /* Aliases */
9
+ /**
10
+ * Apply `<scroll-into-view>` host styles (positioning + the inherited
11
+ * pointer cursor when clickable) to any element, without registering the
12
+ * custom element — e.g. server-rendered markup.
13
+ * @cssalias
14
+ */
15
+ @custom-selector :--scroll-into-view scroll-into-view, .tag-scroll-into-view;
16
+
17
+ @define-mixin module-scroll-into-view {
18
+ :--scroll-into-view {
19
+ display: block;
20
+ position: relative;
21
+ @mixin listenable-element;
22
+ }
23
+ }
@@ -0,0 +1,189 @@
1
+ {
2
+ "schemaVersion": "1.0.0",
3
+ "modules": [
4
+ {
5
+ "kind": "javascript-module",
6
+ "path": "scroll-into-view.ts",
7
+ "declarations": [
8
+ {
9
+ "kind": "class",
10
+ "name": "ScrollIntoView",
11
+ "customElement": true,
12
+ "tagName": "scroll-into-view",
13
+ "mixins": [
14
+ {
15
+ "name": "ListenableElement",
16
+ "package": "@excom/listenable-element"
17
+ }
18
+ ],
19
+ "summary": "Scroll an element into view — on connect, click, or any event.",
20
+ "description": "Scrolls an element into view, based on configuration. By default it scrolls itself into view as soon as it connects to the DOM; combine with the inherited `listen-for` attribute to scroll on clicks, custom events, or lifecycles instead.",
21
+ "attributes": [
22
+ {
23
+ "name": "target-ref",
24
+ "type": {
25
+ "text": "string"
26
+ },
27
+ "description": "CSS selector of the element to scroll to. Unset scrolls this element itself.",
28
+ "fieldName": "targetRef",
29
+ "values": [
30
+ "<CSS Selector>"
31
+ ]
32
+ },
33
+ {
34
+ "name": "scroll-align",
35
+ "type": {
36
+ "text": "tokenlist"
37
+ },
38
+ "description": "Two tokens `<inline> <block>` (x and y, respectively) controlling how the target aligns inside the scrollport. Each token is `start`, `center`, `end`, `nearest`, or `none` (skip alignment on that axis).",
39
+ "fieldName": "scrollAlign",
40
+ "default": "nearest start",
41
+ "values": [
42
+ "start",
43
+ "center",
44
+ "end",
45
+ "nearest",
46
+ "none"
47
+ ]
48
+ },
49
+ {
50
+ "name": "scroll-offset",
51
+ "type": {
52
+ "text": "tokenlist"
53
+ },
54
+ "description": "Two pixel offsets `<x> <y>` applied after alignment — e.g. leave room for a sticky header by using a negative `<y>`.",
55
+ "fieldName": "scrollOffset",
56
+ "default": "0 0",
57
+ "values": [
58
+ "<px> <px>"
59
+ ]
60
+ },
61
+ {
62
+ "name": "scroll-behavior",
63
+ "type": {
64
+ "text": "string"
65
+ },
66
+ "description": "Scroll animation.",
67
+ "fieldName": "scrollBehavior",
68
+ "default": "auto",
69
+ "values": [
70
+ "auto",
71
+ "smooth",
72
+ "instant"
73
+ ]
74
+ },
75
+ {
76
+ "name": "if-needed",
77
+ "type": {
78
+ "text": "boolean"
79
+ },
80
+ "description": "Only scroll if the target isn't already fully visible.",
81
+ "fieldName": "ifNeeded"
82
+ }
83
+ ],
84
+ "members": [
85
+ {
86
+ "kind": "field",
87
+ "name": "targetRef",
88
+ "type": {
89
+ "text": "string"
90
+ },
91
+ "privacy": "public",
92
+ "readonly": false,
93
+ "description": "CSS selector of the element to scroll to. Unset scrolls this element itself.",
94
+ "_neutron": {
95
+ "surface": "option"
96
+ }
97
+ },
98
+ {
99
+ "kind": "field",
100
+ "name": "scrollAlign",
101
+ "type": {
102
+ "text": "tokenlist"
103
+ },
104
+ "privacy": "public",
105
+ "readonly": false,
106
+ "description": "Two tokens `<inline> <block>` (x and y, respectively) controlling how the target aligns inside the scrollport. Each token is `start`, `center`, `end`, `nearest`, or `none` (skip alignment on that axis).",
107
+ "default": "nearest start",
108
+ "_neutron": {
109
+ "surface": "option"
110
+ }
111
+ },
112
+ {
113
+ "kind": "field",
114
+ "name": "scrollOffset",
115
+ "type": {
116
+ "text": "tokenlist"
117
+ },
118
+ "privacy": "public",
119
+ "readonly": false,
120
+ "description": "Two pixel offsets `<x> <y>` applied after alignment — e.g. leave room for a sticky header by using a negative `<y>`.",
121
+ "default": "0 0",
122
+ "_neutron": {
123
+ "surface": "option"
124
+ }
125
+ },
126
+ {
127
+ "kind": "field",
128
+ "name": "scrollBehavior",
129
+ "type": {
130
+ "text": "string"
131
+ },
132
+ "privacy": "public",
133
+ "readonly": false,
134
+ "description": "Scroll animation.",
135
+ "default": "auto",
136
+ "_neutron": {
137
+ "surface": "option"
138
+ }
139
+ },
140
+ {
141
+ "kind": "field",
142
+ "name": "ifNeeded",
143
+ "type": {
144
+ "text": "boolean"
145
+ },
146
+ "privacy": "public",
147
+ "readonly": false,
148
+ "description": "Only scroll if the target isn't already fully visible.",
149
+ "_neutron": {
150
+ "surface": "option"
151
+ }
152
+ }
153
+ ],
154
+ "_neutron": {
155
+ "cssAliases": [
156
+ {
157
+ "name": ":--scroll-into-view",
158
+ "selectors": [
159
+ "scroll-into-view",
160
+ ".tag-scroll-into-view"
161
+ ],
162
+ "kind": "element",
163
+ "description": "Apply `<scroll-into-view>` host styles (positioning + the inherited pointer cursor when clickable) to any element, without registering the custom element — e.g. server-rendered markup."
164
+ }
165
+ ]
166
+ }
167
+ }
168
+ ],
169
+ "exports": [
170
+ {
171
+ "kind": "js",
172
+ "name": "ScrollIntoView",
173
+ "declaration": {
174
+ "name": "ScrollIntoView",
175
+ "module": "scroll-into-view.ts"
176
+ }
177
+ },
178
+ {
179
+ "kind": "custom-element-definition",
180
+ "name": "scroll-into-view",
181
+ "declaration": {
182
+ "name": "ScrollIntoView",
183
+ "module": "scroll-into-view.ts"
184
+ }
185
+ }
186
+ ]
187
+ }
188
+ ]
189
+ }
@@ -0,0 +1,12 @@
1
+ <div>
2
+ <div class="scroll-area">
3
+ <p>Keep scrolling…</p>
4
+ <p>Keep scrolling…</p>
5
+ <p>Keep scrolling…</p>
6
+ <scroll-into-view>
7
+ <p role="note">👋 Scrolled into view on connect — no attributes needed.</p>
8
+ </scroll-into-view>
9
+ <p>More content below.</p>
10
+ <p>More content below.</p>
11
+ </div>
12
+ </div>
@@ -0,0 +1,22 @@
1
+ <div>
2
+ <scroll-into-view role="button" target-ref="#section-3" listen-for="click"
3
+ scroll-offset="0 -50" scroll-behavior="smooth">
4
+ Jump to Section 3
5
+ </scroll-into-view>
6
+ <div class="scroll-area">
7
+ <header>Sticky header</header>
8
+ <section id="section-1">
9
+ <h4>Section 1</h4>
10
+ <p>The target section is further down.</p>
11
+ </section>
12
+ <section id="section-2">
13
+ <h4>Section 2</h4>
14
+ <p>Keep scrolling…</p>
15
+ </section>
16
+ <section id="section-3">
17
+ <h4>Section 3</h4>
18
+ <p>The sticky header would normally cover this —
19
+ <code>scroll-offset="0 -50"</code> leaves room for it.</p>
20
+ </section>
21
+ </div>
22
+ </div>
@@ -0,0 +1,130 @@
1
+ # scroll-into-view
2
+
3
+ Scrolls an element into view — on connect by default, or on click / any event via the inherited `listen-for`.
4
+
5
+
6
+ ```html
7
+ <div>
8
+ <div class="scroll-area">
9
+ <p>Keep scrolling…</p>
10
+ <p>Keep scrolling…</p>
11
+ <p>Keep scrolling…</p>
12
+ <scroll-into-view>
13
+ <p role="note">👋 Scrolled into view on connect — no attributes needed.</p>
14
+ </scroll-into-view>
15
+ <p>More content below.</p>
16
+ <p>More content below.</p>
17
+ </div>
18
+ </div>
19
+ ```
20
+
21
+
22
+ ## Features
23
+
24
+ - **Scroll on connect** Works with no attributes needed
25
+ - **Any trigger** Combine with `listen-for` to scroll on click, custom events, or lifecycles
26
+ - **Alignment control** `scroll-align` picks start / center / end / nearest per axis
27
+ - **Offset for fixed headers** `scroll-offset` nudges the final position after alignment
28
+ - **Skip redundant scrolls** `if-needed` scrolls only when the target isn't already visible
29
+
30
+ ## Installation
31
+
32
+
33
+ `@excom/scroll-into-view` v0.1.0
34
+
35
+ ```bash
36
+ pnpm add @excom/scroll-into-view
37
+ ```
38
+
39
+ ```bash
40
+ npm install @excom/scroll-into-view
41
+ ```
42
+
43
+ ```bash
44
+ yarn add @excom/scroll-into-view
45
+ ```
46
+
47
+ ### Import
48
+
49
+ ```ts
50
+ import "@excom/scroll-into-view";
51
+ ```
52
+
53
+
54
+
55
+ ## Usage
56
+
57
+ By default, `<scroll-into-view>` scrolls *itself* into view as soon as it connects to the DOM. Set `target-ref` to scroll a different element instead, and pair with the inherited `listen-for` to trigger on click or any event rather than on connect.
58
+
59
+ ```html
60
+ <!-- Jump to a section on click -->
61
+ <scroll-into-view target-ref="#pricing" listen-for="click">
62
+ See pricing
63
+ </scroll-into-view>
64
+ ```
65
+
66
+ ### API Reference
67
+
68
+
69
+ #### Attributes
70
+
71
+ | Name | Surface | Type | Default | Values | Description | Inherited from |
72
+ | --- | --- | --- | --- | --- | --- | --- |
73
+ | `target-ref` | option | `string` | | `<CSS Selector>` | CSS selector of the element to scroll to. Unset scrolls this element itself. | |
74
+ | `scroll-align` | option | `tokenlist` | `"nearest start"` | `"start"` \| `"center"` \| `"end"` \| `"nearest"` \| `"none"` | Two tokens `<inline> <block>` (x and y, respectively) controlling how the target aligns inside the scrollport. Each token is `start`, `center`, `end`, `nearest`, or `none` (skip alignment on that axis). | |
75
+ | `scroll-offset` | option | `tokenlist` | `"0 0"` | `<px> <px>` | Two pixel offsets `<x> <y>` applied after alignment — e.g. leave room for a sticky header by using a negative `<y>`. | |
76
+ | `scroll-behavior` | option | `string` | `"auto"` | `"auto"` \| `"smooth"` \| `"instant"` | Scroll animation. | |
77
+ | `if-needed` | option | `boolean` | | | Only scroll if the target isn't already fully visible. | |
78
+ | `host-ref` | option | `string` | | `<CSS Selector>` \| `"window"` \| `"document"` \| `"html"` \| `"body"` \| `"head"` | Listen on another element / `window` / `document` — e.g. Escape to dismiss a dialog from a global `keydown`. Defaults to `:scope`. Used with `listen-for`. Not compatible with `listen-for-lifecycle`. The selector MUST resolve when `host-ref` is set — it will not wait for a match to appear. | `@excom/listenable-element` |
79
+ | `listen-for` | option | `tokenlist` | | `<EventName>…` | Space-separated event names to listen for. Defaults to `click` when unset (and no lifecycle list is set). | `@excom/listenable-element` |
80
+ | `listen-for-lifecycle` | option | `tokenlist` | | `"connected"` \| `"disconnected"` \| `"adopted"` | Space-separated element lifecycles to handle. | `@excom/listenable-element` |
81
+ | `listen-once` | option | `boolean` | | | Handle each distinct event name / lifecycle at most once. | `@excom/listenable-element` |
82
+ | `selector-filter` | option | `string` | | `<CSS Selector>` | Only handle events whose `event.target` matches this CSS selector. Does not support `:scope` in the selector. | `@excom/listenable-element` |
83
+ | `keycode-filter` | option | `tokenlist` | | `<key` \| `mod+key>…` | Space-separated key filters (OR). Join modifiers with `+` (AND, any order): `shift+k tab` → Shift+K or Tab. Modifiers: `shift`, `alt`, `ctrl`/`control`, `meta`. Case-insensitive. | `@excom/listenable-element` |
84
+ | `pathname-filter` | option | `tokenlist` | | `<pathname>…` | Only handle when `location.pathname` is one of these values — route-aware behaviors without a separate router element. | `@excom/listenable-element` |
85
+ | `prevent-default` | option | `boolean` | | | Call `preventDefault()` on matched events (ignored for lifecycles). | `@excom/listenable-element` |
86
+ | `stop-propagation` | option | `boolean` | | | Call `stopPropagation()` on matched events (ignored for lifecycles). | `@excom/listenable-element` |
87
+ | `stop-immediate-propagation` | option | `boolean` | | | Call `stopImmediatePropagation()` on matched events (ignored for lifecycles). | `@excom/listenable-element` |
88
+ | `vibrate-ms` | option | `number` | `"20 (when attribute is present with no value)"` | | Vibrate on handle (`navigator.vibrate`). Empty / `0` uses a 20ms pulse. | `@excom/listenable-element` |
89
+ | `delay-ms` | option | `number` | | | Delay handling by this many milliseconds. | `@excom/listenable-element` |
90
+ | `is-debounced` | option | `boolean` | | | With `delay-ms`, coalesce bursts into one trailing call (debounce). | `@excom/listenable-element` |
91
+
92
+ #### CSS Aliases
93
+
94
+ | Alias | Kind | Matches | Description |
95
+ | --- | --- | --- | --- |
96
+ | `:--scroll-into-view` | element | `scroll-into-view`, `.tag-scroll-into-view` | Apply `<scroll-into-view>` host styles (positioning + the inherited pointer cursor when clickable) to any element, without registering the custom element — e.g. server-rendered markup. |
97
+
98
+
99
+
100
+ ### Examples
101
+
102
+ #### Offset for a sticky header
103
+
104
+ `scroll-offset="0 -50"` (negative `<y>`) leaves room for a sticky header after alignment; `scroll-behavior="smooth"` animates the scroll. This is the pattern used to jump between sections without the header covering the target's heading.
105
+
106
+
107
+ ```html
108
+ <div>
109
+ <scroll-into-view role="button" target-ref="#section-3" listen-for="click"
110
+ scroll-offset="0 -50" scroll-behavior="smooth">
111
+ Jump to Section 3
112
+ </scroll-into-view>
113
+ <div class="scroll-area">
114
+ <header>Sticky header</header>
115
+ <section id="section-1">
116
+ <h4>Section 1</h4>
117
+ <p>The target section is further down.</p>
118
+ </section>
119
+ <section id="section-2">
120
+ <h4>Section 2</h4>
121
+ <p>Keep scrolling…</p>
122
+ </section>
123
+ <section id="section-3">
124
+ <h4>Section 3</h4>
125
+ <p>The sticky header would normally cover this —
126
+ <code>scroll-offset="0 -50"</code> leaves room for it.</p>
127
+ </section>
128
+ </div>
129
+ </div>
130
+ ```
@@ -0,0 +1,40 @@
1
+ # scroll-into-view
2
+
3
+ Scrolls an element into view — on connect by default, or on click / any event via the inherited `listen-for`.
4
+
5
+ <include-content data-demo="simple"></include-content>
6
+
7
+ ## Features
8
+
9
+ - **Scroll on connect** Works with no attributes needed
10
+ - **Any trigger** Combine with `listen-for` to scroll on click, custom events, or lifecycles
11
+ - **Alignment control** `scroll-align` picks start / center / end / nearest per axis
12
+ - **Offset for fixed headers** `scroll-offset` nudges the final position after alignment
13
+ - **Skip redundant scrolls** `if-needed` scrolls only when the target isn't already visible
14
+
15
+ ## Installation
16
+
17
+ <include-content is-active template-ref="/views/install-section/install-section.html"></include-content>
18
+
19
+ ## Usage
20
+
21
+ By default, `<scroll-into-view>` scrolls *itself* into view as soon as it connects to the DOM. Set `target-ref` to scroll a different element instead, and pair with the inherited `listen-for` to trigger on click or any event rather than on connect.
22
+
23
+ ```html
24
+ <!-- Jump to a section on click -->
25
+ <scroll-into-view target-ref="#pricing" listen-for="click">
26
+ See pricing
27
+ </scroll-into-view>
28
+ ```
29
+
30
+ ### API Reference
31
+
32
+ <include-content is-active template-ref="/views/api-reference/api-reference.html"></include-content>
33
+
34
+ ### Examples
35
+
36
+ #### Offset for a sticky header
37
+
38
+ `scroll-offset="0 -50"` (negative `<y>`) leaves room for a sticky header after alignment; `scroll-behavior="smooth"` animates the scroll. This is the pattern used to jump between sections without the header covering the target's heading.
39
+
40
+ <include-content data-demo="sticky-header"></include-content>
@@ -0,0 +1,220 @@
1
+ {
2
+ "shortName": "scroll-into-view",
3
+ "package": {
4
+ "name": "@excom/scroll-into-view",
5
+ "version": "0.1.0",
6
+ "description": "<scroll-into-view> custom element",
7
+ "peerDependencies": {},
8
+ "excom": {
9
+ "packageType": "kit-element"
10
+ }
11
+ },
12
+ "demos": {
13
+ "simple": "<div>\n <div class=\"scroll-area\">\n <p>Keep scrolling…</p>\n <p>Keep scrolling…</p>\n <p>Keep scrolling…</p>\n <scroll-into-view>\n <p role=\"note\">👋 Scrolled into view on connect — no attributes needed.</p>\n </scroll-into-view>\n <p>More content below.</p>\n <p>More content below.</p>\n </div>\n</div>\n",
14
+ "sticky-header": "<div>\n <scroll-into-view role=\"button\" target-ref=\"#section-3\" listen-for=\"click\"\n scroll-offset=\"0 -50\" scroll-behavior=\"smooth\">\n Jump to Section 3\n </scroll-into-view>\n <div class=\"scroll-area\">\n <header>Sticky header</header>\n <section id=\"section-1\">\n <h4>Section 1</h4>\n <p>The target section is further down.</p>\n </section>\n <section id=\"section-2\">\n <h4>Section 2</h4>\n <p>Keep scrolling…</p>\n </section>\n <section id=\"section-3\">\n <h4>Section 3</h4>\n <p>The sticky header would normally cover this —\n <code>scroll-offset=\"0 -50\"</code> leaves room for it.</p>\n </section>\n </div>\n</div>\n"
15
+ },
16
+ "readme": "<h1 id=\"md-scroll-into-view\">scroll-into-view</h1>\n<p>Scrolls an element into view — on connect by default, or on click / any event via the inherited <code>listen-for</code>.</p>\n<p><include-content data-demo=\"simple\"></include-content></p>\n<h2 id=\"md-features\">Features</h2>\n<ul>\n<li><strong>Scroll on connect</strong> Works with no attributes needed</li>\n<li><strong>Any trigger</strong> Combine with <code>listen-for</code> to scroll on click, custom events, or lifecycles</li>\n<li><strong>Alignment control</strong> <code>scroll-align</code> picks start / center / end / nearest per axis</li>\n<li><strong>Offset for fixed headers</strong> <code>scroll-offset</code> nudges the final position after alignment</li>\n<li><strong>Skip redundant scrolls</strong> <code>if-needed</code> scrolls only when the target isn&#39;t already visible</li>\n</ul>\n<h2 id=\"md-installation\">Installation</h2>\n<p><include-content is-active template-ref=\"/views/install-section/install-section.html\"></include-content></p>\n<h2 id=\"md-usage\">Usage</h2>\n<p>By default, <code>&lt;scroll-into-view&gt;</code> scrolls <em>itself</em> into view as soon as it connects to the DOM. Set <code>target-ref</code> to scroll a different element instead, and pair with the inherited <code>listen-for</code> to trigger on click or any event rather than on connect.</p>\n<include-content data-language=\"html\"><template>&lt;!-- Jump to a section on click --&gt;\n&lt;scroll-into-view target-ref=\"#pricing\" listen-for=\"click\"&gt;\n See pricing\n&lt;/scroll-into-view&gt;</template></include-content>\n<h3 id=\"md-api-reference\">API Reference</h3>\n<p><include-content is-active template-ref=\"/views/api-reference/api-reference.html\"></include-content></p>\n<h3 id=\"md-examples\">Examples</h3>\n<h4 id=\"md-offset-for-a-sticky-header\">Offset for a sticky header</h4>\n<p><code>scroll-offset=&quot;0 -50&quot;</code> (negative <code>&lt;y&gt;</code>) leaves room for a sticky header after alignment; <code>scroll-behavior=&quot;smooth&quot;</code> animates the scroll. This is the pattern used to jump between sections without the header covering the target&#39;s heading.</p>\n<p><include-content data-demo=\"sticky-header\"></include-content></p>\n",
17
+ "docs": {
18
+ "readme": "<h1 id=\"md-scroll-into-view\">scroll-into-view</h1>\n<p>Scrolls an element into view — on connect by default, or on click / any event via the inherited <code>listen-for</code>.</p>\n<p><include-content data-demo=\"simple\"></include-content></p>\n<h2 id=\"md-features\">Features</h2>\n<ul>\n<li><strong>Scroll on connect</strong> Works with no attributes needed</li>\n<li><strong>Any trigger</strong> Combine with <code>listen-for</code> to scroll on click, custom events, or lifecycles</li>\n<li><strong>Alignment control</strong> <code>scroll-align</code> picks start / center / end / nearest per axis</li>\n<li><strong>Offset for fixed headers</strong> <code>scroll-offset</code> nudges the final position after alignment</li>\n<li><strong>Skip redundant scrolls</strong> <code>if-needed</code> scrolls only when the target isn&#39;t already visible</li>\n</ul>\n<h2 id=\"md-installation\">Installation</h2>\n<p><include-content is-active template-ref=\"/views/install-section/install-section.html\"></include-content></p>\n<h2 id=\"md-usage\">Usage</h2>\n<p>By default, <code>&lt;scroll-into-view&gt;</code> scrolls <em>itself</em> into view as soon as it connects to the DOM. Set <code>target-ref</code> to scroll a different element instead, and pair with the inherited <code>listen-for</code> to trigger on click or any event rather than on connect.</p>\n<include-content data-language=\"html\"><template>&lt;!-- Jump to a section on click --&gt;\n&lt;scroll-into-view target-ref=\"#pricing\" listen-for=\"click\"&gt;\n See pricing\n&lt;/scroll-into-view&gt;</template></include-content>\n<h3 id=\"md-api-reference\">API Reference</h3>\n<p><include-content is-active template-ref=\"/views/api-reference/api-reference.html\"></include-content></p>\n<h3 id=\"md-examples\">Examples</h3>\n<h4 id=\"md-offset-for-a-sticky-header\">Offset for a sticky header</h4>\n<p><code>scroll-offset=&quot;0 -50&quot;</code> (negative <code>&lt;y&gt;</code>) leaves room for a sticky header after alignment; <code>scroll-behavior=&quot;smooth&quot;</code> animates the scroll. This is the pattern used to jump between sections without the header covering the target&#39;s heading.</p>\n<p><include-content data-demo=\"sticky-header\"></include-content></p>\n"
19
+ },
20
+ "installation": {
21
+ "name": "@excom/scroll-into-view",
22
+ "shortName": "scroll-into-view",
23
+ "version": "0.1.0",
24
+ "description": "<scroll-into-view> custom element",
25
+ "packageType": "kit-element",
26
+ "cdn": "<script src=\"https://unpkg.com/@excom/kit-utils/dist/index.umd.min.js\"></script>\n<script src=\"https://unpkg.com/@excom/neutron/dist/index.umd.min.js\"></script>\n<script src=\"https://unpkg.com/@excom/scroll-into-view@0.1.0/dist/index.umd.min.js\"></script>\n<link rel=\"stylesheet\" href=\"https://unpkg.com/@excom/scroll-into-view@0.1.0/dist/index.css\">",
27
+ "install": {
28
+ "npm": "npm install @excom/scroll-into-view"
29
+ },
30
+ "imports": {
31
+ "js": "import \"@excom/scroll-into-view\";",
32
+ "css": "@import \"@excom/scroll-into-view\";",
33
+ "html": "<!-- import path to `node_modules` will depend on your build setup -->\n<script type=\"module\" src=\"/node_modules/@excom/scroll-into-view\"></script>\n<link rel=\"stylesheet\" href=\"/node_modules/@excom/scroll-into-view\">"
34
+ },
35
+ "peerDependencies": []
36
+ },
37
+ "elementApis": [
38
+ {
39
+ "tag": "scroll-into-view",
40
+ "summary": "Scroll an element into view — on connect, click, or any event.",
41
+ "kind": "class",
42
+ "attributes": [
43
+ {
44
+ "name": "delay-ms",
45
+ "type": "number",
46
+ "description": "Delay handling by this many milliseconds.",
47
+ "fieldName": "delayMs",
48
+ "surface": "option",
49
+ "inheritedFrom": "@excom/listenable-element"
50
+ },
51
+ {
52
+ "name": "host-ref",
53
+ "type": "string",
54
+ "description": "Listen on another element / <code>window</code> / <code>document</code> — e.g. Escape to dismiss a dialog from a global <code>keydown</code>. Defaults to <code>:scope</code>. Used with <code>listen-for</code>. Not compatible with <code>listen-for-lifecycle</code>. The selector MUST resolve when <code>host-ref</code> is set — it will not wait for a match to appear.",
55
+ "fieldName": "hostRef",
56
+ "surface": "option",
57
+ "values": "<CSS Selector> | \"window\" | \"document\" | \"html\" | \"body\" | \"head\"",
58
+ "inheritedFrom": "@excom/listenable-element"
59
+ },
60
+ {
61
+ "name": "if-needed",
62
+ "type": "boolean",
63
+ "description": "Only scroll if the target isn&#39;t already fully visible.",
64
+ "fieldName": "ifNeeded",
65
+ "surface": "option"
66
+ },
67
+ {
68
+ "name": "is-debounced",
69
+ "type": "boolean",
70
+ "description": "With <code>delay-ms</code>, coalesce bursts into one trailing call (debounce).",
71
+ "fieldName": "isDebounced",
72
+ "surface": "option",
73
+ "inheritedFrom": "@excom/listenable-element"
74
+ },
75
+ {
76
+ "name": "keycode-filter",
77
+ "type": "tokenlist",
78
+ "description": "Space-separated key filters (OR). Join modifiers with <code>+</code> (AND, any order): <code>shift+k tab</code> → Shift+K or Tab. Modifiers: <code>shift</code>, <code>alt</code>, <code>ctrl</code>/<code>control</code>, <code>meta</code>. Case-insensitive.",
79
+ "fieldName": "keycodeFilter",
80
+ "surface": "option",
81
+ "values": "<key | mod+key>…",
82
+ "inheritedFrom": "@excom/listenable-element"
83
+ },
84
+ {
85
+ "name": "listen-for",
86
+ "type": "tokenlist",
87
+ "description": "Space-separated event names to listen for. Defaults to <code>click</code> when unset (and no lifecycle list is set).",
88
+ "fieldName": "listenFor",
89
+ "surface": "option",
90
+ "values": "<EventName>…",
91
+ "inheritedFrom": "@excom/listenable-element"
92
+ },
93
+ {
94
+ "name": "listen-for-lifecycle",
95
+ "type": "tokenlist",
96
+ "description": "Space-separated element lifecycles to handle.",
97
+ "fieldName": "listenForLifecycle",
98
+ "surface": "option",
99
+ "values": "\"connected\" | \"disconnected\" | \"adopted\"",
100
+ "inheritedFrom": "@excom/listenable-element"
101
+ },
102
+ {
103
+ "name": "listen-once",
104
+ "type": "boolean",
105
+ "description": "Handle each distinct event name / lifecycle at most once.",
106
+ "fieldName": "listenOnce",
107
+ "surface": "option",
108
+ "inheritedFrom": "@excom/listenable-element"
109
+ },
110
+ {
111
+ "name": "pathname-filter",
112
+ "type": "tokenlist",
113
+ "description": "Only handle when <code>location.pathname</code> is one of these values — route-aware behaviors without a separate router element.",
114
+ "fieldName": "pathnameFilter",
115
+ "surface": "option",
116
+ "values": "<pathname>…",
117
+ "inheritedFrom": "@excom/listenable-element"
118
+ },
119
+ {
120
+ "name": "prevent-default",
121
+ "type": "boolean",
122
+ "description": "Call <code>preventDefault()</code> on matched events (ignored for lifecycles).",
123
+ "fieldName": "preventDefault",
124
+ "surface": "option",
125
+ "inheritedFrom": "@excom/listenable-element"
126
+ },
127
+ {
128
+ "name": "scroll-align",
129
+ "type": "tokenlist",
130
+ "description": "Two tokens <code>&lt;inline&gt; &lt;block&gt;</code> (x and y, respectively) controlling how the target aligns inside the scrollport. Each token is <code>start</code>, <code>center</code>, <code>end</code>, <code>nearest</code>, or <code>none</code> (skip alignment on that axis).",
131
+ "fieldName": "scrollAlign",
132
+ "surface": "option",
133
+ "default": "\"nearest start\"",
134
+ "values": "\"start\" | \"center\" | \"end\" | \"nearest\" | \"none\""
135
+ },
136
+ {
137
+ "name": "scroll-behavior",
138
+ "type": "string",
139
+ "description": "Scroll animation.",
140
+ "fieldName": "scrollBehavior",
141
+ "surface": "option",
142
+ "default": "\"auto\"",
143
+ "values": "\"auto\" | \"smooth\" | \"instant\""
144
+ },
145
+ {
146
+ "name": "scroll-offset",
147
+ "type": "tokenlist",
148
+ "description": "Two pixel offsets <code>&lt;x&gt; &lt;y&gt;</code> applied after alignment — e.g. leave room for a sticky header by using a negative <code>&lt;y&gt;</code>.",
149
+ "fieldName": "scrollOffset",
150
+ "surface": "option",
151
+ "default": "\"0 0\"",
152
+ "values": "<px> <px>"
153
+ },
154
+ {
155
+ "name": "selector-filter",
156
+ "type": "string",
157
+ "description": "Only handle events whose <code>event.target</code> matches this CSS selector. Does not support <code>:scope</code> in the selector.",
158
+ "fieldName": "selectorFilter",
159
+ "surface": "option",
160
+ "values": "<CSS Selector>",
161
+ "inheritedFrom": "@excom/listenable-element"
162
+ },
163
+ {
164
+ "name": "stop-immediate-propagation",
165
+ "type": "boolean",
166
+ "description": "Call <code>stopImmediatePropagation()</code> on matched events (ignored for lifecycles).",
167
+ "fieldName": "stopImmediatePropagation",
168
+ "surface": "option",
169
+ "inheritedFrom": "@excom/listenable-element"
170
+ },
171
+ {
172
+ "name": "stop-propagation",
173
+ "type": "boolean",
174
+ "description": "Call <code>stopPropagation()</code> on matched events (ignored for lifecycles).",
175
+ "fieldName": "stopPropagation",
176
+ "surface": "option",
177
+ "inheritedFrom": "@excom/listenable-element"
178
+ },
179
+ {
180
+ "name": "target-ref",
181
+ "type": "string",
182
+ "description": "CSS selector of the element to scroll to. Unset scrolls this element itself.",
183
+ "fieldName": "targetRef",
184
+ "surface": "option",
185
+ "values": "<CSS Selector>"
186
+ },
187
+ {
188
+ "name": "vibrate-ms",
189
+ "type": "number",
190
+ "description": "Vibrate on handle (<code>navigator.vibrate</code>). Empty / <code>0</code> uses a 20ms pulse.",
191
+ "fieldName": "vibrateMs",
192
+ "surface": "option",
193
+ "default": "\"20 (when attribute is present with no value)\"",
194
+ "inheritedFrom": "@excom/listenable-element"
195
+ }
196
+ ],
197
+ "events": [],
198
+ "slots": [],
199
+ "cssProperties": [],
200
+ "cssClasses": [],
201
+ "cssAliases": [
202
+ {
203
+ "name": ":--scroll-into-view",
204
+ "selectors": [
205
+ "scroll-into-view",
206
+ ".tag-scroll-into-view"
207
+ ],
208
+ "kind": "element",
209
+ "description": "Apply <code>&lt;scroll-into-view&gt;</code> host styles (positioning + the inherited pointer cursor when clickable) to any element, without registering the custom element — e.g. server-rendered markup."
210
+ }
211
+ ],
212
+ "listens": [],
213
+ "commands": [],
214
+ "defaultActions": [],
215
+ "expectedChildren": [],
216
+ "provisions": []
217
+ }
218
+ ],
219
+ "exportedFiles": {}
220
+ }
@@ -0,0 +1,77 @@
1
+ import {
2
+ afterEach,
3
+ describe,
4
+ expect,
5
+ fixture,
6
+ it,
7
+ vi,
8
+ wait,
9
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
10
+ import "../../index";
11
+
12
+ describe("scroll-into-view", () => {
13
+ afterEach(() => {
14
+ document.body.innerHTML = "";
15
+ vi.restoreAllMocks();
16
+ });
17
+
18
+ it("invokes scroll on connect when no triggers configured", async () => {
19
+ const scrollToSpy = vi
20
+ .spyOn(window, "scrollTo")
21
+ .mockImplementation(() => {});
22
+ fixture<HTMLScrollIntoViewElement>(
23
+ `<scroll-into-view></scroll-into-view>`,
24
+ );
25
+ await wait(50);
26
+ expect(scrollToSpy).toHaveBeenCalled();
27
+ });
28
+
29
+ it("scrolls target element when targetRef is set", async () => {
30
+ const target = document.createElement("div");
31
+ target.id = "scroll-target";
32
+ document.body.appendChild(target);
33
+ const scrollToSpy = vi
34
+ .spyOn(window, "scrollTo")
35
+ .mockImplementation(() => {});
36
+ fixture<HTMLScrollIntoViewElement>(
37
+ `<scroll-into-view target-ref="#scroll-target"></scroll-into-view>`,
38
+ );
39
+ await wait(50);
40
+ expect(scrollToSpy).toHaveBeenCalled();
41
+ });
42
+
43
+ it("defaults scrollAlign to nearest/start", () => {
44
+ const el = fixture<HTMLScrollIntoViewElement>(
45
+ `<scroll-into-view></scroll-into-view>`,
46
+ );
47
+ expect(el.scrollAlign).toEqual(["nearest", "start"]);
48
+ });
49
+
50
+ it("defaults scrollBehavior to auto", () => {
51
+ const el = fixture<HTMLScrollIntoViewElement>(
52
+ `<scroll-into-view></scroll-into-view>`,
53
+ );
54
+ expect(el.scrollBehavior).toBe("auto");
55
+ });
56
+
57
+ it("defaults scrollOffset to 0 0", () => {
58
+ const el = fixture<HTMLScrollIntoViewElement>(
59
+ `<scroll-into-view></scroll-into-view>`,
60
+ );
61
+ expect(el.scrollOffset).toEqual(["0", "0"]);
62
+ });
63
+
64
+ it("accepts custom scroll-align", () => {
65
+ const el = fixture<HTMLScrollIntoViewElement>(
66
+ `<scroll-into-view scroll-align="center end"></scroll-into-view>`,
67
+ );
68
+ expect(el.scrollAlign).toEqual(["center", "end"]);
69
+ });
70
+
71
+ it("accepts custom scroll-offset", () => {
72
+ const el = fixture<HTMLScrollIntoViewElement>(
73
+ `<scroll-into-view scroll-offset="10 20"></scroll-into-view>`,
74
+ );
75
+ expect(el.scrollOffset).toEqual(["10", "20"]);
76
+ });
77
+ });
@@ -0,0 +1,169 @@
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ fixture,
7
+ it,
8
+ wait,
9
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
10
+ // `vi` must come from "vitest" itself so `vi.mock` is hoisted with it
11
+ import { vi } from "vitest";
12
+ import { scrollElementIntoView } from "@excom/kit-scroller";
13
+ import "../../index";
14
+
15
+ // Intercept the scroller so the options `<scroll-into-view>` derives from its
16
+ // attributes can be asserted directly (layout is meaningless in the test DOM).
17
+ vi.mock("@excom/kit-scroller", async (importOriginal) => {
18
+ const original =
19
+ await importOriginal<typeof import("@excom/kit-scroller")>();
20
+ return { ...original, scrollElementIntoView: vi.fn(() => ({})) };
21
+ });
22
+
23
+ const scrollMock = vi.mocked(scrollElementIntoView);
24
+
25
+ // actionHandler waits for a frame and then a macrotask before scrolling
26
+ const settle = () => wait(50);
27
+
28
+ describe("scroll-into-view (option parsing)", () => {
29
+ beforeEach(() => {
30
+ scrollMock.mockClear();
31
+ });
32
+
33
+ afterEach(() => {
34
+ document.body.innerHTML = "";
35
+ });
36
+
37
+ it("passes the default alignment, offsets and behavior", async () => {
38
+ const el = fixture<HTMLScrollIntoViewElement>(
39
+ `<scroll-into-view></scroll-into-view>`,
40
+ );
41
+ await settle();
42
+ expect(scrollMock).toHaveBeenCalledTimes(1);
43
+ expect(scrollMock).toHaveBeenCalledWith(el, {
44
+ block: "start",
45
+ inline: "nearest",
46
+ offsetInline: 0,
47
+ offsetBlock: 0,
48
+ behavior: "auto",
49
+ onlyIfNeeded: false,
50
+ });
51
+ });
52
+
53
+ it("maps scroll-align, scroll-offset, scroll-behavior and if-needed", async () => {
54
+ const el = fixture<HTMLScrollIntoViewElement>(
55
+ `<scroll-into-view scroll-align="center end" scroll-offset="10 -20" scroll-behavior="smooth" if-needed></scroll-into-view>`,
56
+ );
57
+ await settle();
58
+ expect(scrollMock).toHaveBeenCalledWith(el, {
59
+ block: "end",
60
+ inline: "center",
61
+ offsetInline: 10,
62
+ offsetBlock: -20,
63
+ behavior: "smooth",
64
+ onlyIfNeeded: true,
65
+ });
66
+ });
67
+
68
+ it("skips an axis whose alignment token is `none`", async () => {
69
+ const el = fixture<HTMLScrollIntoViewElement>(
70
+ `<scroll-into-view scroll-align="none none"></scroll-into-view>`,
71
+ );
72
+ await settle();
73
+ expect(scrollMock).toHaveBeenCalledWith(el, {
74
+ block: undefined,
75
+ inline: undefined,
76
+ offsetInline: 0,
77
+ offsetBlock: 0,
78
+ behavior: "auto",
79
+ onlyIfNeeded: false,
80
+ });
81
+ });
82
+
83
+ it("falls back to nearest / start for missing alignment tokens", async () => {
84
+ const el = fixture<HTMLScrollIntoViewElement>(
85
+ `<scroll-into-view scroll-align="end"></scroll-into-view>`,
86
+ );
87
+ expect(el.scrollAlign).toEqual(["end"]);
88
+ await settle();
89
+ expect(scrollMock).toHaveBeenLastCalledWith(
90
+ el,
91
+ expect.objectContaining({ inline: "end", block: "start" }),
92
+ );
93
+
94
+ scrollMock.mockClear();
95
+ el.scrollAlign = [];
96
+ el.actionHandler();
97
+ await settle();
98
+ expect(scrollMock).toHaveBeenLastCalledWith(
99
+ el,
100
+ expect.objectContaining({ inline: "nearest", block: "start" }),
101
+ );
102
+ });
103
+
104
+ it("drops non-numeric offsets", async () => {
105
+ const el = fixture<HTMLScrollIntoViewElement>(
106
+ `<scroll-into-view scroll-offset="abc xyz"></scroll-into-view>`,
107
+ );
108
+ await settle();
109
+ expect(scrollMock).toHaveBeenCalledWith(
110
+ el,
111
+ expect.objectContaining({
112
+ offsetInline: undefined,
113
+ offsetBlock: undefined,
114
+ }),
115
+ );
116
+ });
117
+
118
+ it("resolves target-ref relative to itself", async () => {
119
+ const wrapper = fixture<HTMLDivElement>(
120
+ `<div>
121
+ <p id="scroll-target-a">a</p>
122
+ <scroll-into-view target-ref="#scroll-target-a"></scroll-into-view>
123
+ </div>`,
124
+ );
125
+ await settle();
126
+ expect(scrollMock).toHaveBeenCalledWith(
127
+ wrapper.querySelector("#scroll-target-a"),
128
+ expect.any(Object),
129
+ );
130
+ });
131
+
132
+ it("hands a missing target-ref to the scroller as null", async () => {
133
+ fixture<HTMLScrollIntoViewElement>(
134
+ `<scroll-into-view target-ref="#does-not-exist"></scroll-into-view>`,
135
+ );
136
+ await settle();
137
+ expect(scrollMock).toHaveBeenCalledWith(null, expect.any(Object));
138
+ });
139
+ });
140
+
141
+ describe("scroll-into-view (triggers)", () => {
142
+ beforeEach(() => {
143
+ scrollMock.mockClear();
144
+ });
145
+
146
+ afterEach(() => {
147
+ document.body.innerHTML = "";
148
+ });
149
+
150
+ it("does not scroll on connect when listen-for is set, only on the event", async () => {
151
+ const el = fixture<HTMLScrollIntoViewElement>(
152
+ `<scroll-into-view listen-for="click">Go</scroll-into-view>`,
153
+ );
154
+ await settle();
155
+ expect(scrollMock).not.toHaveBeenCalled();
156
+ el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
157
+ await settle();
158
+ expect(scrollMock).toHaveBeenCalledTimes(1);
159
+ expect(scrollMock).toHaveBeenCalledWith(el, expect.any(Object));
160
+ });
161
+
162
+ it("does not scroll on connect when listen-for-lifecycle is set", async () => {
163
+ fixture<HTMLScrollIntoViewElement>(
164
+ `<scroll-into-view listen-for-lifecycle="disconnected"></scroll-into-view>`,
165
+ );
166
+ await settle();
167
+ expect(scrollMock).not.toHaveBeenCalled();
168
+ });
169
+ });
@@ -0,0 +1,21 @@
1
+ import "../../index";
2
+ import {
3
+ afterEach,
4
+ describe,
5
+ expect,
6
+ it,
7
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
8
+ import { mountView, readDemo } from "@excom/quark/support/tests/view-helpers";
9
+
10
+ describe("simple view", () => {
11
+ afterEach(() => {
12
+ document.body.innerHTML = "";
13
+ });
14
+
15
+ it("mounts the auto-scroll target", async () => {
16
+ const { root } = await mountView(readDemo(import.meta.url, "simple"));
17
+ expect(root.querySelector("scroll-into-view")?.textContent).toMatch(
18
+ /Scrolled into view/,
19
+ );
20
+ });
21
+ });
@@ -0,0 +1,27 @@
1
+ import "../../index";
2
+ import {
3
+ afterEach,
4
+ describe,
5
+ expect,
6
+ it,
7
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
8
+ import {
9
+ click,
10
+ flush,
11
+ mountView,
12
+ readDemo,
13
+ } from "@excom/quark/support/tests/view-helpers";
14
+
15
+ describe("sticky-header view", () => {
16
+ afterEach(() => {
17
+ document.body.innerHTML = "";
18
+ });
19
+
20
+ it("jumps to section 3 on click", async () => {
21
+ const { root } = await mountView(readDemo(import.meta.url, "sticky-header"));
22
+ const target = root.querySelector("#section-3")!;
23
+ click(root.querySelector("[role='button']"));
24
+ await flush();
25
+ expect(target).toBeTruthy();
26
+ });
27
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "@excom/heft-rig/profiles/default/config/tsconfig.json",
3
+ "include": ["./*.ts"],
4
+ "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
5
+ }