@praxisjs/composables 0.1.5 → 1.0.1

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
@@ -1,63 +1,171 @@
1
+ import { Composable } from "@praxisjs/core";
1
2
  import { signal, computed } from "@praxisjs/core/internal";
2
- import type { Computed } from "@praxisjs/shared";
3
-
4
- export function useMediaQuery(query: string): Computed<boolean> {
5
- const mql = window.matchMedia(query);
6
- const matches = signal(mql.matches);
7
- mql.addEventListener("change", (e: MediaQueryListEvent) => {
8
- matches.set(e.matches);
9
- });
10
- return computed(() => matches());
3
+
4
+ export class MediaQuery extends Composable {
5
+ declare matches: boolean;
6
+
7
+ private _mql?: MediaQueryList;
8
+ private _handler?: (e: MediaQueryListEvent) => void;
9
+ private _view?: Record<string, unknown>;
10
+
11
+ constructor(private readonly query: string) {
12
+ super();
13
+ }
14
+
15
+ setup() {
16
+ if (this._view) return this._view;
17
+ this._mql = window.matchMedia(this.query);
18
+ const matches = signal(this._mql.matches);
19
+ this._handler = (e) => { matches.set(e.matches); };
20
+ this._mql.addEventListener("change", this._handler);
21
+ this._view = { matches };
22
+ return this._view;
23
+ }
24
+
25
+ onUnmount() {
26
+ if (this._mql && this._handler) {
27
+ this._mql.removeEventListener("change", this._handler);
28
+ }
29
+ }
11
30
  }
12
31
 
13
- export function useColorScheme() {
14
- const isDark = useMediaQuery("(prefers-color-scheme: dark)");
15
- const isLight = computed(() => !isDark());
16
- return { isDark, isLight };
32
+ export class ColorScheme extends Composable {
33
+ declare isDark: boolean;
34
+ declare isLight: boolean;
35
+
36
+ private _mql?: MediaQueryList;
37
+ private _handler?: (e: MediaQueryListEvent) => void;
38
+ private _view?: Record<string, unknown>;
39
+
40
+ setup() {
41
+ if (this._view) return this._view;
42
+ this._mql = window.matchMedia("(prefers-color-scheme: dark)");
43
+ const isDark = signal(this._mql.matches);
44
+ const isLight = computed(() => !isDark());
45
+ this._handler = (e) => { isDark.set(e.matches); };
46
+ this._mql.addEventListener("change", this._handler);
47
+ this._view = { isDark, isLight };
48
+ return this._view;
49
+ }
50
+
51
+ onUnmount() {
52
+ if (this._mql && this._handler) {
53
+ this._mql.removeEventListener("change", this._handler);
54
+ }
55
+ }
17
56
  }
18
57
 
19
- export function useMouse() {
20
- const x = signal(0);
21
- const y = signal(0);
22
- window.addEventListener("mousemove", (e) => {
23
- x.set(e.clientX);
24
- y.set(e.clientY);
25
- });
26
- return { x: computed(() => x()), y: computed(() => y()) };
58
+ export class Mouse extends Composable {
59
+ declare x: number;
60
+ declare y: number;
61
+
62
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
63
+ private _handler = (_e: MouseEvent) => {};
64
+ private _view?: Record<string, unknown>;
65
+
66
+ setup() {
67
+ if (this._view) return this._view;
68
+ const x = signal(0);
69
+ const y = signal(0);
70
+ this._handler = (e) => { x.set(e.clientX); y.set(e.clientY); };
71
+ window.addEventListener("mousemove", this._handler);
72
+ this._view = { x, y };
73
+ return this._view;
74
+ }
75
+
76
+ onUnmount() {
77
+ window.removeEventListener("mousemove", this._handler);
78
+ }
27
79
  }
28
80
 
29
- export function useKeyCombo(combo: string): Computed<boolean> {
30
- const parts = combo.toLowerCase().split("+");
31
- const pressed = signal(false);
32
- window.addEventListener("keydown", (e: KeyboardEvent) => {
33
- const ctrl = parts.includes("ctrl") ? e.ctrlKey : true;
34
- const shift = parts.includes("shift") ? e.shiftKey : true;
35
- const alt = parts.includes("alt") ? e.altKey : true;
36
- const key = parts.find(
37
- (p) => !["ctrl", "shift", "alt", "meta"].includes(p),
38
- );
39
- if (ctrl && shift && alt && (!key || e.key.toLowerCase() === key))
40
- pressed.set(true);
41
- });
42
- window.addEventListener("keyup", () => {
43
- pressed.set(false);
44
- });
45
- return computed(() => pressed());
81
+ export class KeyCombo extends Composable {
82
+ declare pressed: boolean;
83
+
84
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
85
+ private _keydownHandler = (_e: KeyboardEvent) => {};
86
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
87
+ private _keyupHandler = () => {};
88
+ private _view?: Record<string, unknown>;
89
+
90
+ constructor(private readonly combo: string) {
91
+ super();
92
+ }
93
+
94
+ setup() {
95
+ if (this._view) return this._view;
96
+ const parts = this.combo.toLowerCase().split("+").map((p) => p.trim());
97
+ const pressed = signal(false);
98
+
99
+ const modifiers = ["ctrl", "shift", "alt", "meta"];
100
+ const key = parts.find((p) => !modifiers.includes(p));
101
+
102
+ // A combo with no non-modifier key is invalid and should never fire
103
+ if (!key) {
104
+ this._keyupHandler = () => { pressed.set(false); };
105
+ window.addEventListener("keydown", this._keydownHandler);
106
+ window.addEventListener("keyup", this._keyupHandler);
107
+ this._view = { pressed };
108
+ return this._view;
109
+ }
110
+
111
+ this._keydownHandler = (e) => {
112
+ const ctrl = parts.includes("ctrl") ? e.ctrlKey : true;
113
+ const shift = parts.includes("shift") ? e.shiftKey : true;
114
+ const alt = parts.includes("alt") ? e.altKey : true;
115
+ const meta = parts.includes("meta") ? e.metaKey : true;
116
+ if (ctrl && shift && alt && meta && e.key.toLowerCase() === key) {
117
+ pressed.set(true);
118
+ }
119
+ };
120
+ this._keyupHandler = () => { pressed.set(false); };
121
+
122
+ window.addEventListener("keydown", this._keydownHandler);
123
+ window.addEventListener("keyup", this._keyupHandler);
124
+ this._view = { pressed };
125
+ return this._view;
126
+ }
127
+
128
+ onUnmount() {
129
+ window.removeEventListener("keydown", this._keydownHandler);
130
+ window.removeEventListener("keyup", this._keyupHandler);
131
+ }
46
132
  }
47
133
 
48
- export function useIdle(timeout = 60_000): Computed<boolean> {
49
- const idle = signal(false);
50
- let timer: ReturnType<typeof setTimeout>;
51
- const reset = () => {
52
- idle.set(false);
53
- clearTimeout(timer);
54
- timer = setTimeout(() => {
55
- idle.set(true);
56
- }, timeout);
57
- };
58
- ["mousemove", "keydown", "click", "scroll", "touchstart"].forEach((e) => {
59
- window.addEventListener(e, reset, { passive: true });
60
- });
61
- reset();
62
- return computed(() => idle());
134
+ export class Idle extends Composable {
135
+ declare idle: boolean;
136
+
137
+ private _listeners: Array<[string, () => void]> = [];
138
+ private _timer?: ReturnType<typeof setTimeout>;
139
+ private _view?: Record<string, unknown>;
140
+
141
+ constructor(private readonly timeout = 60_000) {
142
+ super();
143
+ }
144
+
145
+ setup() {
146
+ if (this._view) return this._view;
147
+ const idle = signal(false);
148
+ const reset = () => {
149
+ idle.set(false);
150
+ clearTimeout(this._timer);
151
+ this._timer = setTimeout(() => { idle.set(true); }, this.timeout);
152
+ };
153
+
154
+ ["mousemove", "keydown", "click", "scroll", "touchstart"].forEach((event) => {
155
+ window.addEventListener(event, reset, { passive: true });
156
+ this._listeners.push([event, reset]);
157
+ });
158
+
159
+ reset();
160
+ this._view = { idle };
161
+ return this._view;
162
+ }
163
+
164
+ onUnmount() {
165
+ clearTimeout(this._timer);
166
+ this._listeners.forEach(([event, handler]) => {
167
+ window.removeEventListener(event, handler);
168
+ });
169
+ this._listeners = [];
170
+ }
63
171
  }
package/src/dom.ts CHANGED
@@ -1,93 +1,156 @@
1
- import { signal, computed, effect } from "@praxisjs/core/internal";
2
- import type { Computed } from "@praxisjs/shared";
1
+ import { Composable } from "@praxisjs/core";
2
+ import { signal, effect } from "@praxisjs/core/internal";
3
3
 
4
- export function createRef(): { current: HTMLElement | null } {
5
- return { current: null };
6
- }
4
+ export class WindowSize extends Composable {
5
+ declare width: number;
6
+ declare height: number;
7
+
8
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
9
+ private _handler = () => {};
10
+ private _view?: Record<string, unknown>;
11
+
12
+ setup() {
13
+ if (this._view) return this._view;
14
+ const width = signal(window.innerWidth);
15
+ const height = signal(window.innerHeight);
16
+ this._handler = () => {
17
+ width.set(window.innerWidth);
18
+ height.set(window.innerHeight);
19
+ };
20
+ window.addEventListener("resize", this._handler);
21
+ this._view = { width, height };
22
+ return this._view;
23
+ }
7
24
 
8
- export function useElementSize(ref: { current: HTMLElement | null }) {
9
- const width = signal(0);
10
- const height = signal(0);
11
- const observer = new ResizeObserver(([entry]) => {
12
- width.set(entry.contentRect.width);
13
- height.set(entry.contentRect.height);
14
- });
15
- effect(() => {
16
- if (ref.current) {
17
- observer.observe(ref.current);
18
- width.set(ref.current.offsetWidth);
19
- height.set(ref.current.offsetHeight);
20
- }
21
- });
22
- return {
23
- width: computed(() => width()),
24
- height: computed(() => height()),
25
- stop: () => {
26
- observer.disconnect();
27
- },
28
- };
25
+ onUnmount() {
26
+ window.removeEventListener("resize", this._handler);
27
+ }
29
28
  }
30
29
 
31
- export function useWindowSize() {
32
- const width = signal(window.innerWidth);
33
- const height = signal(window.innerHeight);
34
- window.addEventListener("resize", () => {
35
- width.set(window.innerWidth);
36
- height.set(window.innerHeight);
37
- });
38
- return { width: computed(() => width()), height: computed(() => height()) };
30
+ export class ScrollPosition extends Composable {
31
+ declare x: number;
32
+ declare y: number;
33
+
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>;
38
+
39
+ constructor(target: HTMLElement | Window = window) {
40
+ super();
41
+ this._target = target;
42
+ }
43
+
44
+ setup() {
45
+ if (this._view) return this._view;
46
+ const x = signal(0);
47
+ const y = signal(0);
48
+ const t = this._target;
49
+ this._handler = () => {
50
+ x.set(t === window ? window.scrollX : (t as HTMLElement).scrollLeft);
51
+ y.set(t === window ? window.scrollY : (t as HTMLElement).scrollTop);
52
+ };
53
+ t.addEventListener("scroll", this._handler);
54
+ this._view = { x, y };
55
+ return this._view;
56
+ }
57
+
58
+ onUnmount() {
59
+ this._target.removeEventListener("scroll", this._handler);
60
+ }
39
61
  }
40
62
 
41
- export function useScrollPosition(target: HTMLElement | Window = window) {
42
- const x = signal(0);
43
- const y = signal(0);
44
- target.addEventListener("scroll", () => {
45
- x.set(
46
- target === window ? window.scrollX : (target as HTMLElement).scrollLeft,
47
- );
48
- y.set(
49
- target === window ? window.scrollY : (target as HTMLElement).scrollTop,
50
- );
51
- });
52
- return { x: computed(() => x()), y: computed(() => y()) };
63
+ export class ElementSize extends Composable {
64
+ declare width: number;
65
+ declare height: number;
66
+
67
+ private _observer?: ResizeObserver;
68
+ private _view?: Record<string, unknown>;
69
+
70
+ constructor(private readonly ref: { current: HTMLElement | null }) {
71
+ super();
72
+ }
73
+
74
+ setup() {
75
+ if (this._view) return this._view;
76
+ const width = signal(0);
77
+ const height = signal(0);
78
+ 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
+ }
88
+ });
89
+ this._view = { width, height };
90
+ return this._view;
91
+ }
92
+
93
+ onUnmount() {
94
+ this._observer?.disconnect();
95
+ }
53
96
  }
54
97
 
55
- export function useIntersection(
56
- ref: { current: HTMLElement | null },
57
- options?: IntersectionObserverInit,
58
- ): Computed<boolean> {
59
- const visible = signal(false);
60
- const observer = new IntersectionObserver(([entry]) => {
61
- visible.set(entry.isIntersecting);
62
- }, options);
63
- effect(() => {
64
- if (ref.current) observer.observe(ref.current);
65
- return () => {
66
- observer.disconnect();
67
- };
68
- });
69
- return computed(() => visible());
98
+ export class Intersection extends Composable {
99
+ declare visible: boolean;
100
+
101
+ private _observer?: IntersectionObserver;
102
+ private _view?: Record<string, unknown>;
103
+
104
+ constructor(
105
+ private readonly ref: { current: HTMLElement | null },
106
+ private readonly options?: IntersectionObserverInit,
107
+ ) {
108
+ super();
109
+ }
110
+
111
+ setup() {
112
+ if (this._view) return this._view;
113
+ const visible = signal(false);
114
+ this._observer = new IntersectionObserver(([entry]) => {
115
+ visible.set(entry.isIntersecting);
116
+ }, this.options);
117
+ effect(() => {
118
+ if (this.ref.current) this._observer?.observe(this.ref.current);
119
+ });
120
+ this._view = { visible };
121
+ return this._view;
122
+ }
123
+
124
+ onUnmount() {
125
+ this._observer?.disconnect();
126
+ }
70
127
  }
71
128
 
72
- export function useFocus(ref: {
73
- current: HTMLElement | null;
74
- }): Computed<boolean> {
75
- const focused = signal(false);
76
- effect(() => {
77
- const el = ref.current;
78
- if (!el) return;
79
- const onFocus = () => {
80
- focused.set(true);
81
- };
82
- const onBlur = () => {
83
- focused.set(false);
84
- };
85
- el.addEventListener("focus", onFocus);
86
- el.addEventListener("blur", onBlur);
87
- return () => {
88
- el.removeEventListener("focus", onFocus);
89
- el.removeEventListener("blur", onBlur);
90
- };
91
- });
92
- return computed(() => focused());
129
+ export class Focus extends Composable {
130
+ declare focused: boolean;
131
+
132
+ private _view?: Record<string, unknown>;
133
+
134
+ constructor(private readonly ref: { current: HTMLElement | null }) {
135
+ super();
136
+ }
137
+
138
+ 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;
155
+ }
93
156
  }
package/src/index.ts CHANGED
@@ -1,22 +1,15 @@
1
1
  export {
2
- createRef,
3
- useElementSize,
4
- useWindowSize,
5
- useScrollPosition,
6
- useIntersection,
7
- useFocus,
2
+ WindowSize,
3
+ ScrollPosition,
4
+ ElementSize,
5
+ Intersection,
6
+ Focus,
8
7
  } from "./dom";
8
+ export { MediaQuery, ColorScheme, Mouse, KeyCombo, Idle } from "./browser";
9
9
  export {
10
- useMediaQuery,
11
- useColorScheme,
12
- useMouse,
13
- useKeyCombo,
14
- useIdle,
15
- } from "./browser";
16
- export {
17
- useClipboard,
18
- useGeolocation,
19
- useTimeAgo,
20
- usePagination,
10
+ Clipboard,
11
+ Geolocation,
12
+ TimeAgo,
13
+ Pagination,
14
+ type PaginationOptions,
21
15
  } from "./utilities";
22
- export type { PaginationOptions } from "./utilities";