@signal9/era-ui 2.1.0 → 2.2.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.
@@ -0,0 +1,38 @@
1
+ import type { Attachment } from 'svelte/attachments';
2
+ /**
3
+ * The stick-to-bottom contract, distilled from the reference implementations
4
+ * (use-stick-to-bottom, open-webui, vercel/ai-elements):
5
+ *
6
+ * - auto-follow content growth while pinned to the bottom;
7
+ * - ANY user-initiated upward intent breaks the pin instantly (wheel up,
8
+ * touch drag, PageUp/ArrowUp/Home, or a scroll event that moved up and
9
+ * wasn't ours);
10
+ * - scrolling back to within `threshold` px of the bottom re-pins;
11
+ * - content growth is watched with a ResizeObserver on the CONTENT (scroll
12
+ * events alone miss it — images, code blocks, expanding steps), and the
13
+ * scroll events a resize provokes are guarded so a reflow never reads as
14
+ * the user scrolling away;
15
+ * - an active text selection inside the viewport suspends following (never
16
+ * yank text out from under a selecting user);
17
+ * - scrolling is a direct scrollTop write on the container, coalesced through
18
+ * rAF — never scrollIntoView (it scrolls ancestors too).
19
+ *
20
+ * CSS `overflow-anchor` and `column-reverse` are deliberately not used: no
21
+ * Safari support for the former; reversed DOM order breaks screen readers,
22
+ * selection, and `role="log"` semantics for the latter.
23
+ */
24
+ export declare class StickToBottom {
25
+ #private;
26
+ /** True while auto-following. Reactive — drive the ↓ button off it. */
27
+ pinned: boolean;
28
+ /** Px from the bottom still considered "at bottom" for re-pinning. */
29
+ threshold: number;
30
+ /** Attach to the scroll container (`overflow-y-auto`). */
31
+ viewport: Attachment<HTMLElement>;
32
+ /** Attach to the content wrapper INSIDE the viewport. */
33
+ content: Attachment<HTMLElement>;
34
+ /** Jump the viewport to the bottom (rAF-coalesced; safe to call per token). */
35
+ scrollToBottom(): void;
36
+ /** The ↓ button's action: re-pin and go. */
37
+ repin(): void;
38
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The stick-to-bottom contract, distilled from the reference implementations
3
+ * (use-stick-to-bottom, open-webui, vercel/ai-elements):
4
+ *
5
+ * - auto-follow content growth while pinned to the bottom;
6
+ * - ANY user-initiated upward intent breaks the pin instantly (wheel up,
7
+ * touch drag, PageUp/ArrowUp/Home, or a scroll event that moved up and
8
+ * wasn't ours);
9
+ * - scrolling back to within `threshold` px of the bottom re-pins;
10
+ * - content growth is watched with a ResizeObserver on the CONTENT (scroll
11
+ * events alone miss it — images, code blocks, expanding steps), and the
12
+ * scroll events a resize provokes are guarded so a reflow never reads as
13
+ * the user scrolling away;
14
+ * - an active text selection inside the viewport suspends following (never
15
+ * yank text out from under a selecting user);
16
+ * - scrolling is a direct scrollTop write on the container, coalesced through
17
+ * rAF — never scrollIntoView (it scrolls ancestors too).
18
+ *
19
+ * CSS `overflow-anchor` and `column-reverse` are deliberately not used: no
20
+ * Safari support for the former; reversed DOM order breaks screen readers,
21
+ * selection, and `role="log"` semantics for the latter.
22
+ */
23
+ export class StickToBottom {
24
+ /** True while auto-following. Reactive — drive the ↓ button off it. */
25
+ pinned = $state(true);
26
+ /** Px from the bottom still considered "at bottom" for re-pinning. */
27
+ threshold = 70;
28
+ #el = null;
29
+ #lastTop = 0;
30
+ #programmatic = 0;
31
+ #resizeGuard = 0;
32
+ #raf = 0;
33
+ /** Attach to the scroll container (`overflow-y-auto`). */
34
+ viewport = (el) => {
35
+ this.#el = el;
36
+ this.#lastTop = el.scrollTop;
37
+ const unpin = () => (this.pinned = false);
38
+ const onWheel = (e) => {
39
+ if (e.deltaY < 0)
40
+ unpin();
41
+ };
42
+ const onTouchMove = () => {
43
+ // A touch drag is user intent; if it lands near the bottom the
44
+ // scroll handler re-pins right after.
45
+ if (!this.#nearBottom())
46
+ unpin();
47
+ };
48
+ const onKeyDown = (e) => {
49
+ if (e.key === 'PageUp' || e.key === 'ArrowUp' || e.key === 'Home')
50
+ unpin();
51
+ };
52
+ const onScroll = () => {
53
+ const top = el.scrollTop;
54
+ const wasOurs = this.#programmatic > 0 || this.#resizeGuard > 0;
55
+ if (!wasOurs) {
56
+ if (top < this.#lastTop - 1)
57
+ unpin();
58
+ else if (this.#nearBottom())
59
+ this.pinned = true;
60
+ }
61
+ this.#lastTop = top;
62
+ };
63
+ const onScrollEnd = () => {
64
+ this.#programmatic = 0;
65
+ };
66
+ el.addEventListener('wheel', onWheel, { passive: true });
67
+ el.addEventListener('touchmove', onTouchMove, { passive: true });
68
+ el.addEventListener('keydown', onKeyDown);
69
+ el.addEventListener('scroll', onScroll, { passive: true });
70
+ el.addEventListener('scrollend', onScrollEnd);
71
+ return () => {
72
+ el.removeEventListener('wheel', onWheel);
73
+ el.removeEventListener('touchmove', onTouchMove);
74
+ el.removeEventListener('keydown', onKeyDown);
75
+ el.removeEventListener('scroll', onScroll);
76
+ el.removeEventListener('scrollend', onScrollEnd);
77
+ if (this.#el === el)
78
+ this.#el = null;
79
+ };
80
+ };
81
+ /** Attach to the content wrapper INSIDE the viewport. */
82
+ content = (el) => {
83
+ const ro = new ResizeObserver(() => {
84
+ if (!this.#el)
85
+ return;
86
+ // Guard the scroll events this resize provokes for two frames so a
87
+ // code-block reflow never reads as the user scrolling away.
88
+ this.#resizeGuard++;
89
+ if (this.pinned && !this.#selecting())
90
+ this.scrollToBottom();
91
+ requestAnimationFrame(() => requestAnimationFrame(() => {
92
+ this.#resizeGuard = Math.max(0, this.#resizeGuard - 1);
93
+ }));
94
+ });
95
+ ro.observe(el);
96
+ return () => ro.disconnect();
97
+ };
98
+ #nearBottom() {
99
+ const el = this.#el;
100
+ if (!el)
101
+ return true;
102
+ return el.scrollHeight - el.scrollTop - el.clientHeight <= this.threshold;
103
+ }
104
+ #selecting() {
105
+ if (typeof window === 'undefined')
106
+ return false;
107
+ const sel = window.getSelection();
108
+ return !!sel && !sel.isCollapsed && this.#el?.contains(sel.anchorNode) === true;
109
+ }
110
+ /** Jump the viewport to the bottom (rAF-coalesced; safe to call per token). */
111
+ scrollToBottom() {
112
+ if (this.#raf)
113
+ return;
114
+ this.#raf = requestAnimationFrame(() => {
115
+ this.#raf = 0;
116
+ const el = this.#el;
117
+ if (!el)
118
+ return;
119
+ this.#programmatic++;
120
+ el.scrollTop = el.scrollHeight;
121
+ // scrollend clears the flag; the double-rAF is the fallback for
122
+ // browsers that don't fire it.
123
+ requestAnimationFrame(() => requestAnimationFrame(() => {
124
+ this.#programmatic = Math.max(0, this.#programmatic - 1);
125
+ }));
126
+ });
127
+ }
128
+ /** The ↓ button's action: re-pin and go. */
129
+ repin() {
130
+ this.pinned = true;
131
+ this.scrollToBottom();
132
+ }
133
+ }