@marianmeres/stuic 2.17.0 → 2.18.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.
@@ -14,6 +14,9 @@
14
14
  visible?: boolean;
15
15
  noScrollLock?: boolean;
16
16
  }
17
+
18
+ // Stack to track visible Backdrops - only topmost handles Escape
19
+ const escapeStack: Set<symbol> = new Set();
17
20
  </script>
18
21
 
19
22
  <script lang="ts">
@@ -104,17 +107,34 @@
104
107
  // Note, that this will also reset if nested... (which is not desired, but ignoring)
105
108
  onDestroy(BodyScroll.unlock);
106
109
 
110
+ // Unique ID for this Backdrop instance
111
+ const instanceId = Symbol();
112
+
107
113
  $effect(() => {
114
+ if (!visible || typeof onEscape !== "function") return;
115
+
116
+ // Add to stack when visible
117
+ escapeStack.add(instanceId);
118
+
108
119
  function onkeydown(e: KeyboardEvent) {
109
- if (e.key === "Escape" && typeof onEscape === "function") {
110
- e.stopPropagation();
111
- e.stopImmediatePropagation();
120
+ // Skip if already handled by another component (ModalDialog, DropdownMenu, etc.)
121
+ if (e.defaultPrevented) return;
122
+
123
+ // Only handle if this is the topmost Backdrop
124
+ const stack = [...escapeStack];
125
+ if (stack[stack.length - 1] !== instanceId) return;
126
+
127
+ if (e.key === "Escape") {
112
128
  e.preventDefault();
113
- onEscape();
129
+ onEscape?.();
114
130
  }
115
131
  }
116
- el?.addEventListener("keydown", onkeydown);
117
- return () => el?.removeEventListener("keydown", onkeydown);
132
+
133
+ window.addEventListener("keydown", onkeydown);
134
+ return () => {
135
+ escapeStack.delete(instanceId);
136
+ window.removeEventListener("keydown", onkeydown);
137
+ };
118
138
  });
119
139
  </script>
120
140
 
@@ -11,6 +11,7 @@ export * from "./file-from-bloburl.js";
11
11
  export * from "./force-download.js";
12
12
  export * from "./get-file-type-label.js";
13
13
  export * from "./get-id.js";
14
+ export * from "./input-history.svelte.js";
14
15
  export * from "./is-browser.js";
15
16
  export * from "./is-image.js";
16
17
  export * from "./is-mac.js";
@@ -11,6 +11,7 @@ export * from "./file-from-bloburl.js";
11
11
  export * from "./force-download.js";
12
12
  export * from "./get-file-type-label.js";
13
13
  export * from "./get-id.js";
14
+ export * from "./input-history.svelte.js";
14
15
  export * from "./is-browser.js";
15
16
  export * from "./is-image.js";
16
17
  export * from "./is-mac.js";
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Configuration options for InputHistory
3
+ */
4
+ export interface InputHistoryOptions {
5
+ /** Composite key parts for namespacing (e.g., [projectId, domain, entity, type]) */
6
+ keyParts: string[];
7
+ /** Maximum number of entries to store (default: 10) */
8
+ maxEntries?: number;
9
+ /** App ID prefix for the storage key (default: "app") */
10
+ appId?: string;
11
+ /** Feature name for the key (default: "input-history") */
12
+ featureName?: string;
13
+ }
14
+ /**
15
+ * A reactive input history manager with localStorage persistence and arrow key navigation.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * const history = new InputHistory({
20
+ * keyParts: [projectId, domain, entity, type],
21
+ * appId: "joy",
22
+ * featureName: "filter-history"
23
+ * });
24
+ *
25
+ * // Add entry on submit
26
+ * history.add(query);
27
+ *
28
+ * // Navigate with arrow keys
29
+ * history.navigateUp(); // Go to older entry
30
+ * history.navigateDown(); // Go to newer entry
31
+ *
32
+ * // Get current entry for display
33
+ * const current = history.getCurrent();
34
+ *
35
+ * // Reset navigation when user starts typing
36
+ * history.reset();
37
+ * ```
38
+ */
39
+ export declare class InputHistory {
40
+ #private;
41
+ /** Storage key for this history instance */
42
+ readonly key: string;
43
+ /** Maximum entries to store */
44
+ readonly maxEntries: number;
45
+ constructor(options: InputHistoryOptions);
46
+ /** Get the stored history entries (newest first) */
47
+ get entries(): string[];
48
+ /** Get current navigation index (-1 when not navigating) */
49
+ get navigationIndex(): number;
50
+ /** Check if currently navigating through history */
51
+ get isNavigating(): boolean;
52
+ /**
53
+ * Add a new query to history (called on Enter/submit).
54
+ * Deduplicates and limits to maxEntries.
55
+ */
56
+ add(query: string): void;
57
+ /**
58
+ * Navigate up (to older entries).
59
+ * On first call, saves current input value.
60
+ * @param currentValue - The current input value (saved on first navigation)
61
+ */
62
+ navigateUp(currentValue?: string): string | null;
63
+ /**
64
+ * Navigate down (to newer entries).
65
+ * When reaching past newest, returns to temp value.
66
+ */
67
+ navigateDown(): string | null;
68
+ /**
69
+ * Get the current history entry based on navigation index.
70
+ * Returns null if not navigating.
71
+ */
72
+ getCurrent(): string | null;
73
+ /**
74
+ * Reset navigation state (call when user starts typing).
75
+ */
76
+ reset(): void;
77
+ /**
78
+ * Clear all history for this key.
79
+ */
80
+ clear(): void;
81
+ /**
82
+ * Clear all histories matching a pattern prefix.
83
+ * Call this on logout to clean up user data.
84
+ *
85
+ * @param pattern - Key prefix to match (e.g., "joy:input-history")
86
+ *
87
+ * @example
88
+ * ```ts
89
+ * // On logout, clear all input histories
90
+ * InputHistory.clearAllMatching("joy:input-history");
91
+ * ```
92
+ */
93
+ static clearAllMatching(pattern: string): void;
94
+ /**
95
+ * Clear all registered histories (nuclear option).
96
+ */
97
+ static clearAll(): void;
98
+ /**
99
+ * Get all registered history keys (for debugging).
100
+ */
101
+ static getRegisteredKeys(): string[];
102
+ }
@@ -0,0 +1,197 @@
1
+ import { localStorageState } from "./persistent-state.svelte.js";
2
+ /**
3
+ * A reactive input history manager with localStorage persistence and arrow key navigation.
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * const history = new InputHistory({
8
+ * keyParts: [projectId, domain, entity, type],
9
+ * appId: "joy",
10
+ * featureName: "filter-history"
11
+ * });
12
+ *
13
+ * // Add entry on submit
14
+ * history.add(query);
15
+ *
16
+ * // Navigate with arrow keys
17
+ * history.navigateUp(); // Go to older entry
18
+ * history.navigateDown(); // Go to newer entry
19
+ *
20
+ * // Get current entry for display
21
+ * const current = history.getCurrent();
22
+ *
23
+ * // Reset navigation when user starts typing
24
+ * history.reset();
25
+ * ```
26
+ */
27
+ export class InputHistory {
28
+ /** Storage key for this history instance */
29
+ key;
30
+ /** Maximum entries to store */
31
+ maxEntries;
32
+ /** Persistent state for stored history entries */
33
+ #storage;
34
+ /** Current navigation index (-1 means "not navigating", 0 is newest, length-1 is oldest) */
35
+ #navigationIndex = $state(-1);
36
+ /** Temporary value holder for current input before navigation started */
37
+ #tempValue = $state("");
38
+ constructor(options) {
39
+ const { keyParts, maxEntries = 10, appId = "app", featureName = "input-history", } = options;
40
+ this.maxEntries = maxEntries;
41
+ // Build composite key: "joy:input-history:projectId:domain:entity:type"
42
+ this.key = [appId, featureName, ...keyParts].filter(Boolean).join(":");
43
+ // Initialize persistent storage
44
+ this.#storage = localStorageState(this.key, []);
45
+ // Register this instance for cleanup
46
+ InputHistory.#register(this.key);
47
+ }
48
+ // ─────────────────────────────────────────────────────────────
49
+ // Public API
50
+ // ─────────────────────────────────────────────────────────────
51
+ /** Get the stored history entries (newest first) */
52
+ get entries() {
53
+ return this.#storage.current;
54
+ }
55
+ /** Get current navigation index (-1 when not navigating) */
56
+ get navigationIndex() {
57
+ return this.#navigationIndex;
58
+ }
59
+ /** Check if currently navigating through history */
60
+ get isNavigating() {
61
+ return this.#navigationIndex >= 0;
62
+ }
63
+ /**
64
+ * Add a new query to history (called on Enter/submit).
65
+ * Deduplicates and limits to maxEntries.
66
+ */
67
+ add(query) {
68
+ query = query.trim();
69
+ if (!query)
70
+ return;
71
+ const current = [...this.#storage.current];
72
+ // Remove duplicates (case-sensitive)
73
+ const filtered = current.filter((item) => item !== query);
74
+ // Add to beginning (newest first)
75
+ filtered.unshift(query);
76
+ // Limit to maxEntries
77
+ this.#storage.current = filtered.slice(0, this.maxEntries);
78
+ // Reset navigation after adding
79
+ this.reset();
80
+ }
81
+ /**
82
+ * Navigate up (to older entries).
83
+ * On first call, saves current input value.
84
+ * @param currentValue - The current input value (saved on first navigation)
85
+ */
86
+ navigateUp(currentValue) {
87
+ const entries = this.entries;
88
+ if (entries.length === 0)
89
+ return null;
90
+ // If not navigating yet, save current value and start
91
+ if (this.#navigationIndex < 0) {
92
+ this.#tempValue = currentValue ?? "";
93
+ this.#navigationIndex = 0;
94
+ }
95
+ else if (this.#navigationIndex < entries.length - 1) {
96
+ // Move to older entry
97
+ this.#navigationIndex++;
98
+ }
99
+ // At oldest entry, stay there
100
+ return this.getCurrent();
101
+ }
102
+ /**
103
+ * Navigate down (to newer entries).
104
+ * When reaching past newest, returns to temp value.
105
+ */
106
+ navigateDown() {
107
+ if (this.#navigationIndex < 0)
108
+ return null;
109
+ if (this.#navigationIndex > 0) {
110
+ // Move to newer entry
111
+ this.#navigationIndex--;
112
+ return this.getCurrent();
113
+ }
114
+ else {
115
+ // At newest entry, go back to temp value
116
+ const temp = this.#tempValue;
117
+ this.reset();
118
+ return temp;
119
+ }
120
+ }
121
+ /**
122
+ * Get the current history entry based on navigation index.
123
+ * Returns null if not navigating.
124
+ */
125
+ getCurrent() {
126
+ if (this.#navigationIndex < 0)
127
+ return null;
128
+ return this.entries[this.#navigationIndex] ?? null;
129
+ }
130
+ /**
131
+ * Reset navigation state (call when user starts typing).
132
+ */
133
+ reset() {
134
+ this.#navigationIndex = -1;
135
+ this.#tempValue = "";
136
+ }
137
+ /**
138
+ * Clear all history for this key.
139
+ */
140
+ clear() {
141
+ this.#storage.current = [];
142
+ this.reset();
143
+ }
144
+ // ─────────────────────────────────────────────────────────────
145
+ // Static cleanup registration
146
+ // ─────────────────────────────────────────────────────────────
147
+ /** Registry of all history keys (for cleanup on logout) */
148
+ static #registeredKeys = new Set();
149
+ /** Register a key for potential cleanup */
150
+ static #register(key) {
151
+ InputHistory.#registeredKeys.add(key);
152
+ }
153
+ /**
154
+ * Clear all histories matching a pattern prefix.
155
+ * Call this on logout to clean up user data.
156
+ *
157
+ * @param pattern - Key prefix to match (e.g., "joy:input-history")
158
+ *
159
+ * @example
160
+ * ```ts
161
+ * // On logout, clear all input histories
162
+ * InputHistory.clearAllMatching("joy:input-history");
163
+ * ```
164
+ */
165
+ static clearAllMatching(pattern) {
166
+ // Clear from our registry
167
+ for (const key of InputHistory.#registeredKeys) {
168
+ if (key.startsWith(pattern)) {
169
+ localStorage.removeItem(key);
170
+ InputHistory.#registeredKeys.delete(key);
171
+ }
172
+ }
173
+ // Also scan localStorage for any keys we might have missed
174
+ // (e.g., from previous sessions)
175
+ for (let i = localStorage.length - 1; i >= 0; i--) {
176
+ const key = localStorage.key(i);
177
+ if (key?.startsWith(pattern)) {
178
+ localStorage.removeItem(key);
179
+ }
180
+ }
181
+ }
182
+ /**
183
+ * Clear all registered histories (nuclear option).
184
+ */
185
+ static clearAll() {
186
+ for (const key of InputHistory.#registeredKeys) {
187
+ localStorage.removeItem(key);
188
+ }
189
+ InputHistory.#registeredKeys.clear();
190
+ }
191
+ /**
192
+ * Get all registered history keys (for debugging).
193
+ */
194
+ static getRegisteredKeys() {
195
+ return [...InputHistory.#registeredKeys];
196
+ }
197
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",