@praxisjs/composables 1.0.5 → 1.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.
package/src/browser.ts CHANGED
@@ -16,7 +16,9 @@ export class MediaQuery extends Composable {
16
16
  if (this._view) return this._view;
17
17
  this._mql = window.matchMedia(this.query);
18
18
  const matches = signal(this._mql.matches);
19
- this._handler = (e) => { matches.set(e.matches); };
19
+ this._handler = (e) => {
20
+ matches.set(e.matches);
21
+ };
20
22
  this._mql.addEventListener("change", this._handler);
21
23
  this._view = { matches };
22
24
  return this._view;
@@ -42,7 +44,9 @@ export class ColorScheme extends Composable {
42
44
  this._mql = window.matchMedia("(prefers-color-scheme: dark)");
43
45
  const isDark = signal(this._mql.matches);
44
46
  const isLight = computed(() => !isDark());
45
- this._handler = (e) => { isDark.set(e.matches); };
47
+ this._handler = (e) => {
48
+ isDark.set(e.matches);
49
+ };
46
50
  this._mql.addEventListener("change", this._handler);
47
51
  this._view = { isDark, isLight };
48
52
  return this._view;
@@ -59,15 +63,17 @@ export class Mouse extends Composable {
59
63
  declare x: number;
60
64
  declare y: number;
61
65
 
62
- // eslint-disable-next-line @typescript-eslint/no-empty-function
63
- private _handler = (_e: MouseEvent) => {};
66
+ private _handler!: (e: MouseEvent) => void;
64
67
  private _view?: Record<string, unknown>;
65
68
 
66
69
  setup() {
67
70
  if (this._view) return this._view;
68
71
  const x = signal(0);
69
72
  const y = signal(0);
70
- this._handler = (e) => { x.set(e.clientX); y.set(e.clientY); };
73
+ this._handler = (e) => {
74
+ x.set(e.clientX);
75
+ y.set(e.clientY);
76
+ };
71
77
  window.addEventListener("mousemove", this._handler);
72
78
  this._view = { x, y };
73
79
  return this._view;
@@ -83,8 +89,7 @@ export class KeyCombo extends Composable {
83
89
 
84
90
  // eslint-disable-next-line @typescript-eslint/no-empty-function
85
91
  private _keydownHandler = (_e: KeyboardEvent) => {};
86
- // eslint-disable-next-line @typescript-eslint/no-empty-function
87
- private _keyupHandler = () => {};
92
+ private _keyupHandler!: () => void;
88
93
  private _view?: Record<string, unknown>;
89
94
 
90
95
  constructor(private readonly combo: string) {
@@ -93,7 +98,10 @@ export class KeyCombo extends Composable {
93
98
 
94
99
  setup() {
95
100
  if (this._view) return this._view;
96
- const parts = this.combo.toLowerCase().split("+").map((p) => p.trim());
101
+ const parts = this.combo
102
+ .toLowerCase()
103
+ .split("+")
104
+ .map((p) => p.trim());
97
105
  const pressed = signal(false);
98
106
 
99
107
  const modifiers = ["ctrl", "shift", "alt", "meta"];
@@ -101,7 +109,9 @@ export class KeyCombo extends Composable {
101
109
 
102
110
  // A combo with no non-modifier key is invalid and should never fire
103
111
  if (!key) {
104
- this._keyupHandler = () => { pressed.set(false); };
112
+ this._keyupHandler = () => {
113
+ pressed.set(false);
114
+ };
105
115
  window.addEventListener("keydown", this._keydownHandler);
106
116
  window.addEventListener("keyup", this._keyupHandler);
107
117
  this._view = { pressed };
@@ -117,7 +127,9 @@ export class KeyCombo extends Composable {
117
127
  pressed.set(true);
118
128
  }
119
129
  };
120
- this._keyupHandler = () => { pressed.set(false); };
130
+ this._keyupHandler = () => {
131
+ pressed.set(false);
132
+ };
121
133
 
122
134
  window.addEventListener("keydown", this._keydownHandler);
123
135
  window.addEventListener("keyup", this._keyupHandler);
@@ -148,13 +160,17 @@ export class Idle extends Composable {
148
160
  const reset = () => {
149
161
  idle.set(false);
150
162
  clearTimeout(this._timer);
151
- this._timer = setTimeout(() => { idle.set(true); }, this.timeout);
163
+ this._timer = setTimeout(() => {
164
+ idle.set(true);
165
+ }, this.timeout);
152
166
  };
153
167
 
154
- ["mousemove", "keydown", "click", "scroll", "touchstart"].forEach((event) => {
155
- window.addEventListener(event, reset, { passive: true });
156
- this._listeners.push([event, reset]);
157
- });
168
+ ["mousemove", "keydown", "click", "scroll", "touchstart"].forEach(
169
+ (event) => {
170
+ window.addEventListener(event, reset, { passive: true });
171
+ this._listeners.push([event, reset]);
172
+ },
173
+ );
158
174
 
159
175
  reset();
160
176
  this._view = { idle };
package/src/dom.ts CHANGED
@@ -1,12 +1,11 @@
1
1
  import { Composable } from "@praxisjs/core";
2
- import { signal, effect } from "@praxisjs/core/internal";
2
+ import { signal } from "@praxisjs/core/internal";
3
3
 
4
4
  export class WindowSize extends Composable {
5
5
  declare width: number;
6
6
  declare height: number;
7
7
 
8
- // eslint-disable-next-line @typescript-eslint/no-empty-function
9
- private _handler = () => {};
8
+ private _handler!: () => void;
10
9
  private _view?: Record<string, unknown>;
11
10
 
12
11
  setup() {
@@ -31,31 +30,48 @@ export class ScrollPosition extends Composable {
31
30
  declare x: number;
32
31
  declare y: number;
33
32
 
34
- private readonly _target: HTMLElement | Window;
35
- // eslint-disable-next-line @typescript-eslint/no-empty-function
36
- private _handler = () => {};
37
- private _view?: Record<string, unknown>;
33
+ private readonly _ref?: { current: HTMLElement | null };
34
+ private readonly _staticTarget?: HTMLElement | Window;
35
+ private _handler!: () => void;
36
+ private _x!: ReturnType<typeof signal<number>>;
37
+ private _y!: ReturnType<typeof signal<number>>;
38
+ private _mounted = false;
38
39
 
39
- constructor(target: HTMLElement | Window = window) {
40
+ constructor(target: HTMLElement | Window | { current: HTMLElement | null } = window) {
40
41
  super();
41
- this._target = target;
42
+ if (typeof target === "object" && "current" in target) {
43
+ this._ref = target as { current: HTMLElement | null };
44
+ } else {
45
+ this._staticTarget = target;
46
+ }
47
+ }
48
+
49
+ private get _target(): HTMLElement | Window {
50
+ return this._ref ? (this._ref.current ?? window) : (this._staticTarget ?? window);
42
51
  }
43
52
 
44
53
  setup() {
45
- if (this._view) return this._view;
46
- const x = signal(0);
47
- const y = signal(0);
54
+ this._x = signal(0);
55
+ this._y = signal(0);
56
+ return { x: this._x, y: this._y };
57
+ }
58
+
59
+ onMount() {
60
+ if (this._mounted) return;
61
+ this._mounted = true;
48
62
  const t = this._target;
49
63
  this._handler = () => {
50
- x.set(t === window ? window.scrollX : (t as HTMLElement).scrollLeft);
51
- y.set(t === window ? window.scrollY : (t as HTMLElement).scrollTop);
64
+ this._x.set(t === window ? window.scrollX : (t as HTMLElement).scrollLeft);
65
+ this._y.set(t === window ? window.scrollY : (t as HTMLElement).scrollTop);
52
66
  };
53
67
  t.addEventListener("scroll", this._handler);
54
- this._view = { x, y };
55
- return this._view;
68
+ // set initial values
69
+ this._x.set(t === window ? window.scrollX : (t as HTMLElement).scrollLeft);
70
+ this._y.set(t === window ? window.scrollY : (t as HTMLElement).scrollTop);
56
71
  }
57
72
 
58
73
  onUnmount() {
74
+ this._mounted = false;
59
75
  this._target.removeEventListener("scroll", this._handler);
60
76
  }
61
77
  }
@@ -65,6 +81,8 @@ export class ElementSize extends Composable {
65
81
  declare height: number;
66
82
 
67
83
  private _observer?: ResizeObserver;
84
+ private _width!: ReturnType<typeof signal<number>>;
85
+ private _height!: ReturnType<typeof signal<number>>;
68
86
  private _view?: Record<string, unknown>;
69
87
 
70
88
  constructor(private readonly ref: { current: HTMLElement | null }) {
@@ -73,23 +91,24 @@ export class ElementSize extends Composable {
73
91
 
74
92
  setup() {
75
93
  if (this._view) return this._view;
76
- const width = signal(0);
77
- const height = signal(0);
94
+ this._width = signal(0);
95
+ this._height = signal(0);
78
96
  this._observer = new ResizeObserver(([entry]) => {
79
- width.set(entry.contentRect.width);
80
- height.set(entry.contentRect.height);
81
- });
82
- effect(() => {
83
- if (this.ref.current) {
84
- this._observer?.observe(this.ref.current);
85
- width.set(this.ref.current.offsetWidth);
86
- height.set(this.ref.current.offsetHeight);
87
- }
97
+ this._width.set(entry.contentRect.width);
98
+ this._height.set(entry.contentRect.height);
88
99
  });
89
- this._view = { width, height };
100
+ this._view = { width: this._width, height: this._height };
90
101
  return this._view;
91
102
  }
92
103
 
104
+ onMount() {
105
+ const el = this.ref.current;
106
+ if (!el) return;
107
+ this._observer?.observe(el);
108
+ this._width.set(el.offsetWidth);
109
+ this._height.set(el.offsetHeight);
110
+ }
111
+
93
112
  onUnmount() {
94
113
  this._observer?.disconnect();
95
114
  }
@@ -99,6 +118,7 @@ export class Intersection extends Composable {
99
118
  declare visible: boolean;
100
119
 
101
120
  private _observer?: IntersectionObserver;
121
+ private _visible!: ReturnType<typeof signal<boolean>>;
102
122
  private _view?: Record<string, unknown>;
103
123
 
104
124
  constructor(
@@ -110,17 +130,19 @@ export class Intersection extends Composable {
110
130
 
111
131
  setup() {
112
132
  if (this._view) return this._view;
113
- const visible = signal(false);
133
+ this._visible = signal(false);
114
134
  this._observer = new IntersectionObserver(([entry]) => {
115
- visible.set(entry.isIntersecting);
135
+ this._visible.set(entry.isIntersecting);
116
136
  }, this.options);
117
- effect(() => {
118
- if (this.ref.current) this._observer?.observe(this.ref.current);
119
- });
120
- this._view = { visible };
137
+ this._view = { visible: this._visible };
121
138
  return this._view;
122
139
  }
123
140
 
141
+ onMount() {
142
+ const el = this.ref.current;
143
+ if (el) this._observer?.observe(el);
144
+ }
145
+
124
146
  onUnmount() {
125
147
  this._observer?.disconnect();
126
148
  }
@@ -129,28 +151,34 @@ export class Intersection extends Composable {
129
151
  export class Focus extends Composable {
130
152
  declare focused: boolean;
131
153
 
132
- private _view?: Record<string, unknown>;
154
+ private _focused!: ReturnType<typeof signal<boolean>>;
155
+ private readonly _onFocus = () => { this._focused.set(true); };
156
+ private readonly _onBlur = () => { this._focused.set(false); };
157
+ private _mounted = false;
133
158
 
134
159
  constructor(private readonly ref: { current: HTMLElement | null }) {
135
160
  super();
136
161
  }
137
162
 
138
163
  setup() {
139
- if (this._view) return this._view;
140
- const focused = signal(false);
141
- effect(() => {
142
- const el = this.ref.current;
143
- if (!el) return;
144
- const onFocus = () => { focused.set(true); };
145
- const onBlur = () => { focused.set(false); };
146
- el.addEventListener("focus", onFocus);
147
- el.addEventListener("blur", onBlur);
148
- return () => {
149
- el.removeEventListener("focus", onFocus);
150
- el.removeEventListener("blur", onBlur);
151
- };
152
- });
153
- this._view = { focused };
154
- return this._view;
164
+ this._focused = signal(false);
165
+ return { focused: this._focused };
166
+ }
167
+
168
+ onMount() {
169
+ if (this._mounted) return;
170
+ this._mounted = true;
171
+ const el = this.ref.current;
172
+ if (!el) return;
173
+ el.addEventListener("focus", this._onFocus);
174
+ el.addEventListener("blur", this._onBlur);
175
+ }
176
+
177
+ onUnmount() {
178
+ this._mounted = false;
179
+ const el = this.ref.current;
180
+ if (!el) return;
181
+ el.removeEventListener("focus", this._onFocus);
182
+ el.removeEventListener("blur", this._onBlur);
155
183
  }
156
184
  }
package/src/index.ts CHANGED
@@ -13,3 +13,4 @@ export {
13
13
  Pagination,
14
14
  type PaginationOptions,
15
15
  } from "./utilities";
16
+ export { VirtualList, type VirtualItem } from "./list";
package/src/list.ts ADDED
@@ -0,0 +1,77 @@
1
+ import { Composable } from "@praxisjs/core";
2
+ import { computed, signal } from "@praxisjs/core/internal";
3
+
4
+ export interface VirtualItem<T> {
5
+ item: T;
6
+ index: number;
7
+ }
8
+
9
+ export class VirtualList<T = unknown> extends Composable {
10
+ declare visibleItems: Array<VirtualItem<T>>;
11
+ declare totalHeight: number;
12
+ declare offsetTop: number;
13
+ declare offsetBottom: number;
14
+
15
+ private readonly _scrollTop = signal(0);
16
+ private readonly _viewHeight = signal(600);
17
+ private _scrollCleanup?: () => void;
18
+
19
+ constructor(
20
+ private readonly containerRef: { current: HTMLElement | null },
21
+ private readonly getItems: () => T[],
22
+ private readonly itemHeight: number,
23
+ private readonly buffer = 3,
24
+ ) {
25
+ super();
26
+ }
27
+
28
+ setup() {
29
+ const { _scrollTop, _viewHeight, itemHeight, buffer, getItems } = this;
30
+
31
+ const items = computed(getItems);
32
+ const total = computed(() => items().length);
33
+
34
+ const startIdx = computed(() =>
35
+ Math.max(0, Math.floor(_scrollTop() / itemHeight) - buffer),
36
+ );
37
+ const endIdx = computed(() =>
38
+ Math.min(
39
+ total() - 1,
40
+ Math.ceil((_scrollTop() + _viewHeight()) / itemHeight) + buffer,
41
+ ),
42
+ );
43
+
44
+ const visibleItems = computed<Array<VirtualItem<T>>>(() => {
45
+ const arr = items();
46
+ const result: Array<VirtualItem<T>> = [];
47
+ for (let i = startIdx(); i <= endIdx(); i++) {
48
+ result.push({ item: arr[i], index: i });
49
+ }
50
+ return result;
51
+ });
52
+
53
+ const totalHeight = computed(() => total() * itemHeight);
54
+ const offsetTop = computed(() => startIdx() * itemHeight);
55
+ const offsetBottom = computed(() =>
56
+ Math.max(0, total() - 1 - endIdx()) * itemHeight,
57
+ );
58
+
59
+ return { visibleItems, totalHeight, offsetTop, offsetBottom };
60
+ }
61
+
62
+ onMount() {
63
+ const el = this.containerRef.current;
64
+ if (!el) return;
65
+
66
+ const h = el.clientHeight;
67
+ if (h > 0) this._viewHeight.set(h);
68
+
69
+ const handler = () => { this._scrollTop.set(el.scrollTop); };
70
+ el.addEventListener("scroll", handler);
71
+ this._scrollCleanup = () => { el.removeEventListener("scroll", handler); };
72
+ }
73
+
74
+ onUnmount() {
75
+ this._scrollCleanup?.();
76
+ }
77
+ }