@familyboat/mini-framework 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.
@@ -0,0 +1,198 @@
1
+ import { disableInteraction } from "../interaction";
2
+ import { hideElement, showElement } from "../router";
3
+ import { withResolvers } from "../util";
4
+ import "./index.css";
5
+
6
+ /**
7
+ * The CSS class and animation name used by an animation entry point.
8
+ * The class name and the `@keyframes` name must be identical.
9
+ */
10
+ export type AnimationName = string
11
+
12
+ /** Options for customizing one animation call. */
13
+ export type AnimationOptions = {
14
+ /** CSS time value, such as `300ms` or `1.5s`. */
15
+ duration?: string
16
+ /** CSS time value applied before the animation starts. */
17
+ delay?: string
18
+ }
19
+
20
+ type ActiveAnimation = {
21
+ cancel: () => void
22
+ }
23
+
24
+ const activeAnimations = new WeakMap<HTMLElement, ActiveAnimation>()
25
+
26
+ function animateElement(
27
+ root: HTMLElement,
28
+ animationType: AnimationName,
29
+ onEnd: () => void,
30
+ options?: AnimationOptions,
31
+ ): Promise<boolean> {
32
+ activeAnimations.get(root)?.cancel()
33
+
34
+ const {promise, resolve} = withResolvers<boolean>()
35
+ const restoreInteraction = disableInteraction(root)
36
+ const previousDuration = root.style.getPropertyValue('--duration')
37
+ const previousDelay = root.style.getPropertyValue('--delay')
38
+ let finished = false
39
+
40
+ if (options?.duration !== undefined) {
41
+ root.style.setProperty('--duration', options.duration)
42
+ }
43
+ if (options?.delay !== undefined) {
44
+ root.style.setProperty('--delay', options.delay)
45
+ }
46
+
47
+ const finish = (completed: boolean) => {
48
+ if (finished) {
49
+ return
50
+ }
51
+ finished = true
52
+ root.removeEventListener('animationend', handleAnimationEnd)
53
+ root.removeEventListener('animationcancel', handleAnimationCancel)
54
+ root.classList.remove(animationType)
55
+ if (completed) {
56
+ onEnd()
57
+ }
58
+ restoreInteraction()
59
+ root.style.setProperty('--duration', previousDuration)
60
+ root.style.setProperty('--delay', previousDelay)
61
+ if (activeAnimations.get(root)?.cancel === cancel) {
62
+ activeAnimations.delete(root)
63
+ }
64
+ resolve(completed)
65
+ }
66
+
67
+ const cancel = () => finish(false)
68
+
69
+ const handleAnimationEnd = (event: AnimationEvent) => {
70
+ if (event.target === root && event.animationName === animationType) {
71
+ finish(true)
72
+ }
73
+ }
74
+
75
+ const handleAnimationCancel = (event: AnimationEvent) => {
76
+ if (event.target === root && event.animationName === animationType) {
77
+ finish(false)
78
+ }
79
+ }
80
+
81
+ root.addEventListener('animationend', handleAnimationEnd)
82
+ root.addEventListener('animationcancel', handleAnimationCancel)
83
+ activeAnimations.set(root, {cancel})
84
+ root.classList.add(animationType)
85
+
86
+ return promise
87
+ }
88
+
89
+ /**
90
+ * Shows an element with any CSS animation class.
91
+ * The class name must match the CSS animation name.
92
+ * Starting another animation on the same element cancels the previous one.
93
+ *
94
+ * @param root Element to animate.
95
+ * @param enterType CSS class and animation name to apply.
96
+ * @param options Optional duration and delay overrides.
97
+ * @returns A promise that resolves to `true` when the animation ends, or
98
+ * `false` when it is cancelled.
99
+ */
100
+ export function enterElement(
101
+ root: HTMLElement,
102
+ enterType: AnimationName,
103
+ options?: AnimationOptions,
104
+ ): Promise<boolean> {
105
+ showElement(root)
106
+ return animateElement(root, enterType, () => {}, options)
107
+ }
108
+
109
+ /**
110
+ * Hides an element after any CSS animation class completes.
111
+ * The class name must match the CSS animation name.
112
+ * Starting another animation on the same element cancels the previous one.
113
+ *
114
+ * @param root Element to animate and hide.
115
+ * @param leaveType CSS class and animation name to apply.
116
+ * @param options Optional duration and delay overrides.
117
+ * @returns A promise that resolves to `true` when the animation ends, or
118
+ * `false` when it is cancelled.
119
+ */
120
+ export function leaveElement(
121
+ root: HTMLElement,
122
+ leaveType: AnimationName,
123
+ options?: AnimationOptions,
124
+ ): Promise<boolean> {
125
+ return animateElement(root, leaveType, () => hideElement(root), options)
126
+ }
127
+
128
+ /**
129
+ * Shows an element with a fade-in animation.
130
+ *
131
+ * @param root Element to animate.
132
+ * @param options Optional duration and delay overrides.
133
+ * @returns A promise that resolves to `true` when the animation ends, or
134
+ * `false` when it is cancelled.
135
+ */
136
+ export function fadeIn(root: HTMLElement, options?: AnimationOptions): Promise<boolean> {
137
+ return enterElement(root, 'fade-in', options)
138
+ }
139
+
140
+ /**
141
+ * Hides an element with a fade-out animation.
142
+ *
143
+ * @param root Element to animate and hide.
144
+ * @param options Optional duration and delay overrides.
145
+ * @returns A promise that resolves to `true` when the animation ends, or
146
+ * `false` when it is cancelled.
147
+ */
148
+ export function fadeOut(root: HTMLElement, options?: AnimationOptions): Promise<boolean> {
149
+ return leaveElement(root, 'fade-out', options)
150
+ }
151
+
152
+ /**
153
+ * Shows an element by sliding in from the left.
154
+ *
155
+ * @param root Element to animate.
156
+ * @param options Optional duration and delay overrides.
157
+ * @returns A promise that resolves to `true` when the animation ends, or
158
+ * `false` when it is cancelled.
159
+ */
160
+ export function leftSlideIn(root: HTMLElement, options?: AnimationOptions): Promise<boolean> {
161
+ return enterElement(root, 'left-slide-in', options)
162
+ }
163
+
164
+ /**
165
+ * Shows an element by sliding in from the right.
166
+ *
167
+ * @param root Element to animate.
168
+ * @param options Optional duration and delay overrides.
169
+ * @returns A promise that resolves to `true` when the animation ends, or
170
+ * `false` when it is cancelled.
171
+ */
172
+ export function rightSlideIn(root: HTMLElement, options?: AnimationOptions): Promise<boolean> {
173
+ return enterElement(root, 'right-slide-in', options)
174
+ }
175
+
176
+ /**
177
+ * Hides an element by sliding out to the left.
178
+ *
179
+ * @param root Element to animate and hide.
180
+ * @param options Optional duration and delay overrides.
181
+ * @returns A promise that resolves to `true` when the animation ends, or
182
+ * `false` when it is cancelled.
183
+ */
184
+ export function leftSlideOut(root: HTMLElement, options?: AnimationOptions): Promise<boolean> {
185
+ return leaveElement(root, 'left-slide-out', options)
186
+ }
187
+
188
+ /**
189
+ * Hides an element by sliding out to the right.
190
+ *
191
+ * @param root Element to animate and hide.
192
+ * @param options Optional duration and delay overrides.
193
+ * @returns A promise that resolves to `true` when the animation ends, or
194
+ * `false` when it is cancelled.
195
+ */
196
+ export function rightSlideOut(root: HTMLElement, options?: AnimationOptions): Promise<boolean> {
197
+ return leaveElement(root, 'right-slide-out', options)
198
+ }
package/lib/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ export {
2
+ Route,
3
+ Router,
4
+ hideElement,
5
+ showElement,
6
+ } from "./router";
7
+ export type { RouteProps } from "./router";
8
+ export {
9
+ fadeIn,
10
+ fadeOut,
11
+ enterElement,
12
+ leaveElement,
13
+ leftSlideIn,
14
+ rightSlideIn,
15
+ leftSlideOut,
16
+ rightSlideOut,
17
+ } from "./animate";
18
+ export type { AnimationOptions } from "./animate";
19
+ export type { AnimationName } from "./animate";
20
+ export { Modal } from "./modal";
21
+ export type { ModalProps } from "./modal";
22
+ export { Toast } from "./toast";
23
+ export type { ToastKind, ToastPosition, ToastProps } from "./toast";
24
+ export { html, renderTemplate, withResolvers } from './util'
25
+ export { disableInteraction } from './interaction';
@@ -0,0 +1,16 @@
1
+ type InteractionState = {
2
+ inert: boolean
3
+ }
4
+
5
+ /** Disables interaction and returns a function that restores the previous state. */
6
+ export function disableInteraction(root: HTMLElement): () => void {
7
+ const previousState: InteractionState = {
8
+ inert: root.inert,
9
+ }
10
+
11
+ root.inert = true
12
+
13
+ return () => {
14
+ root.inert = previousState.inert
15
+ }
16
+ }
@@ -0,0 +1,5 @@
1
+ .modal {
2
+ position: fixed;
3
+ inset: 0;
4
+ z-index: var(--z-index-modal);
5
+ }
@@ -0,0 +1,153 @@
1
+ import { hideElement } from "../router";
2
+ import "./index.css";
3
+
4
+ export type ModalProps = {
5
+ name: string;
6
+ };
7
+
8
+ const modalCache = new Map<string, Modal>();
9
+
10
+ export abstract class Modal {
11
+ private _props: ModalProps;
12
+ private _initialized = false;
13
+ private _destroyed = false;
14
+ private _operationId = 0;
15
+
16
+ protected get root() {
17
+ return this._root;
18
+ }
19
+ private _root: HTMLElement;
20
+
21
+ protected constructor(props: ModalProps) {
22
+ this._props = props;
23
+ if (modalCache.has(this._props.name)) {
24
+ throw new Error(`The modal named ${this._props.name} has existed.`);
25
+ }
26
+
27
+ this._root = document.createElement("div");
28
+ this._root.classList.add(`modal-${this._props.name}`, "modal");
29
+ hideElement(this._root);
30
+ document.body.appendChild(this._root);
31
+ }
32
+
33
+ protected abstract render(): void;
34
+
35
+ /** Creates, initializes, and registers a modal instance. */
36
+ static create<T extends Modal>(this: any, props: ModalProps): T {
37
+ const modal = new this(props);
38
+ modal.initialize();
39
+ return modal;
40
+ }
41
+
42
+ /** Shows this modal and starts a new modal operation. */
43
+ show(): void {
44
+ this.assertUsable();
45
+ const operationId = this.beginOperation();
46
+ this.onShow(operationId);
47
+ }
48
+
49
+ /** Hides this modal and starts a new modal operation. */
50
+ hide(): void {
51
+ this.assertUsable();
52
+ const operationId = this.beginOperation();
53
+ this.onHide(operationId);
54
+ }
55
+
56
+ protected abstract onShow(operationId: number): void;
57
+ protected abstract onHide(operationId: number): void;
58
+
59
+ /** Renders and registers this modal once. */
60
+ protected initialize(): void {
61
+ if (this._destroyed) {
62
+ throw new Error(`The modal named ${this._props.name} has been destroyed.`);
63
+ }
64
+
65
+ if (this._initialized) {
66
+ throw new Error(`The modal named ${this._props.name} has already been initialized.`);
67
+ }
68
+
69
+ try {
70
+ this.render();
71
+ if (modalCache.has(this._props.name)) {
72
+ throw new Error(`The modal named ${this._props.name} has existed.`);
73
+ }
74
+ modalCache.set(this._props.name, this);
75
+ this._initialized = true;
76
+ } catch (error) {
77
+ this._initialized = false;
78
+ if (modalCache.get(this._props.name) === this) {
79
+ modalCache.delete(this._props.name);
80
+ }
81
+ this._root.remove();
82
+ throw error;
83
+ }
84
+ }
85
+
86
+ /** Hook for subclasses to release resources they created. */
87
+ protected dispose(): void {}
88
+
89
+ /** Starts a new operation and invalidates callbacks from earlier operations. */
90
+ protected beginOperation(): number {
91
+ this._operationId += 1;
92
+ return this._operationId;
93
+ }
94
+
95
+ /** Returns whether an asynchronous operation is still current. */
96
+ protected isCurrentOperation(operationId: number): boolean {
97
+ return operationId === this._operationId;
98
+ }
99
+
100
+ /** Throws when a modal is used after it has been destroyed. */
101
+ private assertUsable(): void {
102
+ if (this._destroyed) {
103
+ throw new Error(`The modal named ${this._props.name} has been destroyed.`);
104
+ }
105
+ }
106
+
107
+ /** Shows a registered modal by name. */
108
+ static show(modalName: string): void {
109
+ const modal = modalCache.get(modalName);
110
+ if (!modal) {
111
+ throw new Error(`Can't show unknown modal: ${modalName}.`);
112
+ }
113
+
114
+ modal.show();
115
+ }
116
+
117
+ /** Hides a registered modal by name. */
118
+ static hide(modalName: string): void {
119
+ const modal = modalCache.get(modalName);
120
+ if (!modal) {
121
+ throw new Error(`Can't hide unknown modal: ${modalName}.`);
122
+ }
123
+
124
+ modal.hide();
125
+ }
126
+
127
+ /** Destroys a registered modal by name. */
128
+ static destroy(modalName: string): void {
129
+ const modal = modalCache.get(modalName);
130
+ if (!modal) {
131
+ throw new Error(`Can't destroy unknown modal: ${modalName}.`);
132
+ }
133
+
134
+ modal.destroy();
135
+ }
136
+
137
+ /** Removes this modal from the registry and document. */
138
+ destroy(): void {
139
+ if (this._destroyed) {
140
+ return;
141
+ }
142
+
143
+ this._destroyed = true;
144
+ this.beginOperation();
145
+ this.dispose();
146
+
147
+ if (modalCache.get(this._props.name) === this) {
148
+ modalCache.delete(this._props.name);
149
+ }
150
+
151
+ this._root.remove();
152
+ }
153
+ }
@@ -0,0 +1,9 @@
1
+ .page {
2
+ position: absolute;
3
+ inset: 0;
4
+ z-index: var(--z-index-page);
5
+ }
6
+
7
+ .hide {
8
+ display: none;
9
+ }
@@ -0,0 +1,130 @@
1
+ import "./index.css";
2
+
3
+ /** Configuration required to create a route. */
4
+ export type RouteProps = {
5
+ name: string;
6
+ };
7
+
8
+ const routeCache = new Map<string, Route>();
9
+
10
+ export abstract class Route {
11
+ private _props: RouteProps;
12
+ private _initialized = false;
13
+ private _operationId = 0;
14
+ protected get root() {
15
+ return this._root;
16
+ }
17
+ private _root: HTMLElement;
18
+
19
+ /**
20
+ * Base constructor for route factories.
21
+ * Concrete routes can use the inherited static `create()` method instead of
22
+ * exposing their own factory logic.
23
+ */
24
+ protected constructor(props: RouteProps) {
25
+ this._props = props;
26
+ if (routeCache.has(this._props.name)) {
27
+ throw new Error(`The route named ${this._props.name} has existed.`);
28
+ }
29
+
30
+ this._root = document.createElement("div");
31
+ this._root.classList.add(`page-${this._props.name}`, "page");
32
+ hideElement(this._root);
33
+ document.body.appendChild(this._root);
34
+ }
35
+
36
+ protected abstract render(): void;
37
+
38
+ /** Optional hook called when this route becomes active. */
39
+ protected abstract onEnter(operationId?: number): void
40
+
41
+ /** Optional hook called when this route becomes inactive. */
42
+ protected abstract onLeave(operationId?: number): void
43
+
44
+ /** Creates, initializes, and registers a route instance. */
45
+ static create<T extends Route>(this: any, props: RouteProps): T {
46
+ const route = new this(props);
47
+ route.initialize();
48
+ return route;
49
+ }
50
+
51
+ /** Renders and registers this route once. Called by a concrete route factory. */
52
+ protected initialize(): void {
53
+ if (this._initialized) {
54
+ throw new Error(`The route named ${this._props.name} has already been initialized.`);
55
+ }
56
+
57
+ try {
58
+ this.render();
59
+ if (routeCache.has(this._props.name)) {
60
+ throw new Error(`The route named ${this._props.name} has existed.`);
61
+ }
62
+ routeCache.set(this._props.name, this);
63
+ this._initialized = true;
64
+ } catch (error) {
65
+ this._root.remove();
66
+ throw error;
67
+ }
68
+ }
69
+
70
+ /** Starts a new operation and invalidates callbacks from earlier operations. */
71
+ protected beginOperation(): number {
72
+ this._operationId += 1;
73
+ return this._operationId;
74
+ }
75
+
76
+ /** Returns whether an asynchronous operation is still current. */
77
+ protected isCurrentOperation(operationId: number): boolean {
78
+ return operationId === this._operationId;
79
+ }
80
+
81
+ /** Shows the route and runs the enter hook. */
82
+ show(): void {
83
+ const operationId = this.beginOperation();
84
+ this.onEnter(operationId);
85
+ }
86
+
87
+ /** Hides the route and runs the leave hook. */
88
+ hide(): void {
89
+ const operationId = this.beginOperation();
90
+ this.onLeave(operationId);
91
+ }
92
+ }
93
+
94
+ export class Router {
95
+ private static _previousRoute: Route | null = null;
96
+ private static _navigationId = 0;
97
+
98
+ /**
99
+ * Navigates to a registered route.
100
+ * Repeated navigation to the active route is ignored.
101
+ */
102
+ static navigate(name: string): void {
103
+ if (!routeCache.has(name)) {
104
+ throw new Error(`Can't navigate to unknown route: ${name}.`);
105
+ }
106
+
107
+ const route = routeCache.get(name)!;
108
+ if (this._previousRoute === route) {
109
+ return;
110
+ }
111
+
112
+ this._navigationId += 1;
113
+ this._previousRoute?.hide();
114
+ route.show();
115
+ this._previousRoute = route;
116
+ }
117
+
118
+ /** Returns the id of the most recent route navigation. */
119
+ static getNavigationId(): number {
120
+ return this._navigationId;
121
+ }
122
+ }
123
+
124
+ export function hideElement(root: HTMLElement) {
125
+ root.classList.add("hide");
126
+ }
127
+
128
+ export function showElement(root: HTMLElement) {
129
+ root.classList.remove("hide");
130
+ }
@@ -0,0 +1,57 @@
1
+ .toast-container {
2
+ position: fixed;
3
+ left: 50%;
4
+ transform: translateX(-50%);
5
+ display: flex;
6
+ flex-direction: column;
7
+ gap: 12px;
8
+ z-index: var(--z-index-toast);
9
+ pointer-events: none;
10
+ }
11
+
12
+ .toast-container--top {
13
+ top: 24px;
14
+ }
15
+
16
+ .toast-container--bottom {
17
+ bottom: 24px;
18
+ }
19
+
20
+ .toast {
21
+ display: flex;
22
+ align-items: center;
23
+ justify-content: space-between;
24
+ gap: 12px;
25
+ min-width: 240px;
26
+ max-width: 420px;
27
+ padding: 12px 16px;
28
+ border-radius: 12px;
29
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18);
30
+ color: #ffffff;
31
+ background: rgba(31, 41, 55, 0.96);
32
+ pointer-events: auto;
33
+ }
34
+
35
+ .toast--success {
36
+ background: rgba(22, 163, 74, 0.96);
37
+ }
38
+
39
+ .toast--error {
40
+ background: rgba(220, 38, 38, 0.96);
41
+ }
42
+
43
+ .toast__message {
44
+ flex: 1;
45
+ font-size: 14px;
46
+ line-height: 1.4;
47
+ }
48
+
49
+ .toast__close {
50
+ border: 0;
51
+ background: transparent;
52
+ color: inherit;
53
+ cursor: pointer;
54
+ font-size: 18px;
55
+ line-height: 1;
56
+ opacity: 0.85;
57
+ }