@marianmeres/stuic 2.16.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.
@@ -5,7 +5,7 @@
5
5
  /** Optional string for color hash calculation (e.g., email, user ID). Falls back to `input` */
6
6
  hashSource?: string;
7
7
  /** Size preset or custom Tailwind size class */
8
- size?: "sm" | "md" | "lg" | string;
8
+ size?: "sm" | "md" | "lg" | "xl" | string;
9
9
  /** Click handler - when provided, renders as a button */
10
10
  onclick?: (event: MouseEvent) => void;
11
11
  /** Background color (Tailwind class). Ignored if autoColor=true */
@@ -41,6 +41,7 @@
41
41
  sm: "size-8 text-xs",
42
42
  md: "size-10 text-sm",
43
43
  lg: "size-14 text-base",
44
+ xl: "size-16 text-lg",
44
45
  };
45
46
 
46
47
  let initials = $derived.by(() => {
@@ -52,7 +53,7 @@
52
53
  if (_input.includes("@")) {
53
54
  const username = _input.split("@")[0];
54
55
  // Split by common separators (., _, -)
55
- const parts = username.split(/[._-]/).filter(Boolean);
56
+ const parts = username.split(/[._+-]/).filter(Boolean);
56
57
  if (parts.length > 1) {
57
58
  _input = parts.map((p) => p.charAt(0)).join("");
58
59
  } else {
@@ -4,7 +4,7 @@ export interface Props {
4
4
  /** Optional string for color hash calculation (e.g., email, user ID). Falls back to `input` */
5
5
  hashSource?: string;
6
6
  /** Size preset or custom Tailwind size class */
7
- size?: "sm" | "md" | "lg" | string;
7
+ size?: "sm" | "md" | "lg" | "xl" | string;
8
8
  /** Click handler - when provided, renders as a button */
9
9
  onclick?: (event: MouseEvent) => void;
10
10
  /** Background color (Tailwind class). Ignored if autoColor=true */
@@ -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
 
@@ -213,7 +213,7 @@
213
213
  bg-white dark:bg-neutral-800
214
214
  text-neutral-900 dark:text-neutral-100
215
215
  border border-neutral-200 dark:border-neutral-700
216
- rounded-md shadow-md
216
+ rounded-md shadow-sm
217
217
  p-1
218
218
  overflow-y-auto
219
219
  z-50
@@ -138,7 +138,7 @@ export interface Props extends Omit<HTMLButtonAttributes, "children"> {
138
138
  }
139
139
  export declare const DROPDOWN_MENU_BASE_CLASSES = "stuic-dropdown-menu relative inline-block";
140
140
  export declare const DROPDOWN_MENU_TRIGGER_CLASSES = "\n\t\tinline-flex items-center justify-center gap-2\n\t\tpx-3 py-2\n\t\trounded-md border\n\t\tbg-white dark:bg-neutral-800\n\t\ttext-neutral-900 dark:text-neutral-100\n\t\tborder-neutral-200 dark:border-neutral-700\n\t\thover:brightness-95 dark:hover:brightness-110\n\t\tfocus-visible:outline-2 focus-visible:outline-offset-2\n\t\tcursor-pointer\n\t";
141
- export declare const DROPDOWN_MENU_DROPDOWN_CLASSES = "\n\t\tstuic-dropdown-menu-dropdown\n\t\tbg-white dark:bg-neutral-800\n\t\ttext-neutral-900 dark:text-neutral-100\n\t\tborder border-neutral-200 dark:border-neutral-700\n\t\trounded-md shadow-md\n\t\tp-1\n\t\toverflow-y-auto\n\t\tz-50\n\t\tmin-w-48\n\t";
141
+ export declare const DROPDOWN_MENU_DROPDOWN_CLASSES = "\n\t\tstuic-dropdown-menu-dropdown\n\t\tbg-white dark:bg-neutral-800\n\t\ttext-neutral-900 dark:text-neutral-100\n\t\tborder border-neutral-200 dark:border-neutral-700\n\t\trounded-md shadow-sm\n\t\tp-1\n\t\toverflow-y-auto\n\t\tz-50\n\t\tmin-w-48\n\t";
142
142
  export declare const DROPDOWN_MENU_ITEM_CLASSES = "\n\t\tw-full\n\t\tflex items-center gap-2\n\t\tpx-2 py-1.5\n\t\tmin-h-[44px]\n\t\ttext-left text-sm\n\t\trounded-sm\n\t\tcursor-pointer\n\t\ttouch-action-manipulation\n\t\thover:bg-neutral-100 dark:hover:bg-neutral-700\n\t\tfocus:outline-none\n\t\tfocus-visible:bg-neutral-200 dark:focus-visible:bg-neutral-600\n\t";
143
143
  export declare const DROPDOWN_MENU_DIVIDER_CLASSES = "\n\t\th-px my-1\n\t\tbg-neutral-200 dark:bg-neutral-700\n\t";
144
144
  export declare const DROPDOWN_MENU_HEADER_CLASSES = "\n\t\tpx-2 py-1.5\n\t\ttext-xs font-semibold uppercase tracking-wide\n\t\ttext-neutral-500 dark:text-neutral-400\n\t\tselect-none\n\t";
@@ -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
+ }
@@ -28,7 +28,7 @@ export function observeExists(selector, options = {}) {
28
28
  throw new TypeError("Expecting non empty selector");
29
29
  const { rootElement = document.body } = options;
30
30
  const ns = `[observeExists] [${selector}]`;
31
- const clog = (...args) => console.debug(ns, ...args);
31
+ const clogDebug = (...args) => console.debug(ns, ...args);
32
32
  const check = () => rootElement.querySelector(selector) !== null;
33
33
  let current = $state(check());
34
34
  //
@@ -47,14 +47,14 @@ export function observeExists(selector, options = {}) {
47
47
  current = check();
48
48
  });
49
49
  // start observing now
50
- clog(`connecting...`);
50
+ clogDebug(`connecting...`);
51
51
  observer.observe(rootElement, { childList: true, subtree: true });
52
52
  return {
53
53
  get current() {
54
54
  return current;
55
55
  },
56
56
  disconnect() {
57
- clog(`disconnecting...`);
57
+ clogDebug(`disconnecting...`);
58
58
  observer.disconnect();
59
59
  },
60
60
  forceCheck() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "2.16.0",
3
+ "version": "2.18.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",