@marianmeres/stuic 2.17.0 → 2.19.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.
@@ -2,53 +2,87 @@
2
2
  export interface Props {
3
3
  class?: string;
4
4
  enabled?: boolean;
5
+ /** Animation cycle duration in ms (default: 1000) */
6
+ speed?: number;
5
7
  }
6
8
  </script>
7
9
 
8
10
  <script lang="ts">
9
- import { createTickerRAF } from "@marianmeres/ticker";
10
- import { onMount } from "svelte";
11
-
12
- let { class: _class, enabled = true }: Props = $props();
13
-
14
- const speed = 250;
15
- let visible = $state([false, false, false]);
16
- let i = $state(0);
17
-
18
- onMount(() => {
19
- const ticker = createTickerRAF(speed, true);
20
- const unsub = ticker.subscribe((t) => {
21
- if (i > visible.length - 1) {
22
- i = 0;
23
- visible = visible.map((v) => false);
24
- } else {
25
- visible[i] = true;
26
- i++;
27
- }
28
- });
29
- return () => {
30
- ticker.stop();
31
- unsub();
32
- };
33
- });
11
+ let { class: _class, enabled = true, speed = 1000 }: Props = $props();
34
12
  </script>
35
13
 
36
14
  <!-- prettier-ignore -->
37
- <span class={_class}>
38
- <span
39
- class={visible[0] || !enabled ? 'opacity-100' : 'opacity-0'}
40
- style="transition-duration: {speed}ms;"
41
- >.</span><span
42
- class={visible[1] || !enabled ? 'opacity-100' : 'opacity-0'}
43
- style="transition-duration: {speed}ms;"
44
- >.</span><span
45
- class={visible[2] || !enabled ? 'opacity-100' : 'opacity-0'}
46
- style="transition-duration: {speed}ms;"
47
- >.</span>
48
- </span>
15
+ <span class={_class} style:--duration="{speed}ms"
16
+ ><span class="dot dot1" class:paused={!enabled}>.</span
17
+ ><span class="dot dot2" class:paused={!enabled}>.</span
18
+ ><span class="dot dot3" class:paused={!enabled}>.</span
19
+ ></span>
49
20
 
50
21
  <style>
51
- span span {
52
- transition-property: opacity;
22
+ .dot {
23
+ opacity: 0;
24
+ }
25
+
26
+ .dot1 {
27
+ animation: dot1 var(--duration, 1s) infinite linear;
28
+ }
29
+ .dot2 {
30
+ animation: dot2 var(--duration, 1s) infinite linear;
31
+ }
32
+ .dot3 {
33
+ animation: dot3 var(--duration, 1s) infinite linear;
34
+ }
35
+
36
+ .dot.paused {
37
+ animation: none;
38
+ opacity: 1;
39
+ }
40
+
41
+ @keyframes dot1 {
42
+ 0%,
43
+ 20% {
44
+ opacity: 0;
45
+ }
46
+ 25% {
47
+ opacity: 1;
48
+ }
49
+ 80% {
50
+ opacity: 1;
51
+ }
52
+ 100% {
53
+ opacity: 0;
54
+ }
55
+ }
56
+
57
+ @keyframes dot2 {
58
+ 0%,
59
+ 45% {
60
+ opacity: 0;
61
+ }
62
+ 50% {
63
+ opacity: 1;
64
+ }
65
+ 80% {
66
+ opacity: 1;
67
+ }
68
+ 100% {
69
+ opacity: 0;
70
+ }
71
+ }
72
+
73
+ @keyframes dot3 {
74
+ 0%,
75
+ 70% {
76
+ opacity: 0;
77
+ }
78
+ 75% {
79
+ opacity: 1;
80
+ }
81
+ 80% {
82
+ opacity: 1;
83
+ }
84
+ 100% {
85
+ opacity: 0;
86
+ }
53
87
  }
54
88
  </style>
@@ -1,6 +1,8 @@
1
1
  export interface Props {
2
2
  class?: string;
3
3
  enabled?: boolean;
4
+ /** Animation cycle duration in ms (default: 1000) */
5
+ speed?: number;
4
6
  }
5
7
  declare const AnimatedEllipsis: import("svelte").Component<Props, {}, "">;
6
8
  type AnimatedEllipsis = ReturnType<typeof AnimatedEllipsis>;
@@ -0,0 +1,169 @@
1
+ # AvatarInitials
2
+
3
+ A circular avatar component displaying initials extracted from names or emails, with optional auto-generated colors and size presets.
4
+
5
+ ## Props
6
+
7
+ | Prop | Type | Default | Description |
8
+ |------|------|---------|-------------|
9
+ | `input` | `string` | - | String to extract initials from (name, initials, or email) |
10
+ | `hashSource` | `string` | - | Optional string for color hash calculation (falls back to `input`) |
11
+ | `size` | `"sm" \| "md" \| "lg" \| "xl" \| string` | `"md"` | Size preset or custom Tailwind class |
12
+ | `onclick` | `(event: MouseEvent) => void` | - | Click handler (renders as button when provided) |
13
+ | `bg` | `string` | - | Background color Tailwind class (ignored if autoColor) |
14
+ | `textColor` | `string` | - | Text color Tailwind class (ignored if autoColor) |
15
+ | `autoColor` | `boolean` | `false` | Generate deterministic pastel colors from input |
16
+ | `class` | `string` | - | Additional CSS classes |
17
+ | `el` | `HTMLDivElement \| HTMLButtonElement` | - | Element reference (bindable) |
18
+
19
+ ## Size Presets
20
+
21
+ | Size | Dimensions | Font Size |
22
+ |------|------------|-----------|
23
+ | `sm` | 32px (size-8) | text-xs |
24
+ | `md` | 40px (size-10) | text-sm |
25
+ | `lg` | 56px (size-14) | text-base |
26
+ | `xl` | 64px (size-16) | text-lg |
27
+
28
+ Custom sizes can be passed as Tailwind classes: `size="size-20 text-2xl"`
29
+
30
+ ## Initials Extraction Logic
31
+
32
+ The component intelligently extracts up to 2 characters from the input:
33
+
34
+ 1. **Email addresses** (`john.doe@example.com`):
35
+ - Splits username by `.`, `_`, `+`, `-`
36
+ - Takes first letter of each part
37
+ - Result: `JD`
38
+
39
+ 2. **Full names** (`John Doe`):
40
+ - Splits by whitespace
41
+ - Takes first letter of each word
42
+ - Result: `JD`
43
+
44
+ 3. **Short strings** (`AB` or `Jo`):
45
+ - Takes first 2 characters
46
+ - Result: `AB` or `JO`
47
+
48
+ 4. **Empty input**:
49
+ - Returns `?`
50
+
51
+ All initials are uppercase.
52
+
53
+ ## Auto Color Generation
54
+
55
+ When `autoColor` is enabled, the component generates deterministic pastel colors:
56
+
57
+ - Colors are derived from a hash of `hashSource` or `input`
58
+ - Same input always produces the same color
59
+ - Colors are designed as accessible pastel tones
60
+ - Text color automatically contrasts with background
61
+
62
+ ## Usage
63
+
64
+ ### Basic Display
65
+
66
+ ```svelte
67
+ <script lang="ts">
68
+ import { AvatarInitials } from 'stuic';
69
+ </script>
70
+
71
+ <AvatarInitials input="John Doe" />
72
+ <AvatarInitials input="jane.smith@example.com" />
73
+ <AvatarInitials input="AB" />
74
+ ```
75
+
76
+ ### Size Variants
77
+
78
+ ```svelte
79
+ <AvatarInitials input="JD" size="sm" />
80
+ <AvatarInitials input="JD" size="md" />
81
+ <AvatarInitials input="JD" size="lg" />
82
+ <AvatarInitials input="JD" size="xl" />
83
+
84
+ <!-- Custom size -->
85
+ <AvatarInitials input="JD" size="size-24 text-3xl" />
86
+ ```
87
+
88
+ ### Auto Color (Deterministic)
89
+
90
+ ```svelte
91
+ <!-- Same email always produces same color -->
92
+ <AvatarInitials input="john@example.com" autoColor />
93
+ <AvatarInitials input="jane@example.com" autoColor />
94
+
95
+ <!-- Use ID for consistent color regardless of display name -->
96
+ <AvatarInitials input="John Doe" hashSource="user-123" autoColor />
97
+ ```
98
+
99
+ ### Custom Colors
100
+
101
+ ```svelte
102
+ <AvatarInitials
103
+ input="JD"
104
+ bg="bg-blue-500"
105
+ textColor="text-white"
106
+ />
107
+
108
+ <AvatarInitials
109
+ input="AB"
110
+ bg="bg-gradient-to-br from-purple-500 to-pink-500"
111
+ textColor="text-white"
112
+ />
113
+ ```
114
+
115
+ ### Clickable Avatar
116
+
117
+ ```svelte
118
+ <script lang="ts">
119
+ function handleClick() {
120
+ console.log('Avatar clicked');
121
+ }
122
+ </script>
123
+
124
+ <AvatarInitials
125
+ input="john@example.com"
126
+ autoColor
127
+ onclick={handleClick}
128
+ />
129
+ ```
130
+
131
+ ### In Header Dropdown
132
+
133
+ ```svelte
134
+ <script lang="ts">
135
+ import { AvatarInitials, DropdownMenu } from 'stuic';
136
+ </script>
137
+
138
+ <DropdownMenu
139
+ items={[
140
+ { type: "action", id: "profile", label: "View Profile" },
141
+ { type: "action", id: "logout", label: "Logout" },
142
+ ]}
143
+ >
144
+ {#snippet trigger({ toggle })}
145
+ <AvatarInitials
146
+ input={userEmail}
147
+ onclick={toggle}
148
+ autoColor
149
+ class="cursor-pointer hover:ring-2 hover:ring-blue-500"
150
+ />
151
+ {/snippet}
152
+ </DropdownMenu>
153
+ ```
154
+
155
+ ### Avatar List
156
+
157
+ ```svelte
158
+ <div class="flex -space-x-2">
159
+ {#each users as user}
160
+ <AvatarInitials
161
+ input={user.email}
162
+ hashSource={user.id}
163
+ autoColor
164
+ size="sm"
165
+ class="ring-2 ring-white"
166
+ />
167
+ {/each}
168
+ </div>
169
+ ```
@@ -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
 
@@ -0,0 +1,315 @@
1
+ # DropdownMenu
2
+
3
+ A feature-rich dropdown menu component with CSS Anchor Positioning (with fallback), full keyboard navigation, and support for multiple item types including expandable sections.
4
+
5
+ ## Props
6
+
7
+ | Prop | Type | Default | Description |
8
+ |------|------|---------|-------------|
9
+ | `items` | `DropdownMenuItem[]` | - | Menu items to display |
10
+ | `isOpen` | `boolean` | `false` | Controlled open state (bindable) |
11
+ | `position` | `DropdownMenuPosition` | `"bottom-span-left"` | Popover position relative to trigger |
12
+ | `offset` | `string` | `"0.25rem"` | Offset from trigger element (CSS value) |
13
+ | `maxHeight` | `string` | `"300px"` | Max height of dropdown |
14
+ | `closeOnSelect` | `boolean` | `true` | Close menu when action item is selected |
15
+ | `closeOnClickOutside` | `boolean` | `true` | Close on click outside |
16
+ | `closeOnEscape` | `boolean` | `true` | Close on Escape key |
17
+ | `forceFallback` | `boolean` | `false` | Force fallback positioning (for testing) |
18
+ | `class` | `string` | - | Classes for wrapper element |
19
+ | `classTrigger` | `string` | - | Classes for trigger button |
20
+ | `classDropdown` | `string` | - | Classes for dropdown container |
21
+ | `classItem` | `string` | - | Classes for action items |
22
+ | `classItemActive` | `string` | - | Classes for active/focused item |
23
+ | `classItemDisabled` | `string` | - | Classes for disabled items |
24
+ | `classDivider` | `string` | - | Classes for dividers |
25
+ | `classHeader` | `string` | - | Classes for header items |
26
+ | `classExpandable` | `string` | - | Classes for expandable section header |
27
+ | `classExpandableContent` | `string` | - | Classes for expandable section content |
28
+ | `triggerEl` | `HTMLButtonElement` | - | Trigger element reference (bindable) |
29
+ | `dropdownEl` | `HTMLDivElement` | - | Dropdown element reference (bindable) |
30
+
31
+ ## Snippets
32
+
33
+ | Snippet | Parameters | Description |
34
+ |---------|------------|-------------|
35
+ | `trigger` | `{ isOpen, toggle, triggerProps }` | Custom trigger with full ARIA control |
36
+ | `children` | - | Simple content for default trigger button |
37
+
38
+ ## Item Types
39
+
40
+ ### Action Item
41
+
42
+ Clickable menu item with optional icon and shortcut.
43
+
44
+ ```typescript
45
+ interface DropdownMenuActionItem {
46
+ type: "action";
47
+ id: string | number;
48
+ label: THC; // Text, HTML, or component
49
+ icon?: THC; // Optional leading icon
50
+ shortcut?: string; // Keyboard shortcut hint
51
+ disabled?: boolean;
52
+ onSelect?: () => void | boolean;
53
+ class?: string;
54
+ data?: Record<string, any>;
55
+ }
56
+ ```
57
+
58
+ ### Divider Item
59
+
60
+ Visual separator between items.
61
+
62
+ ```typescript
63
+ interface DropdownMenuDividerItem {
64
+ type: "divider";
65
+ id?: string | number;
66
+ class?: string;
67
+ }
68
+ ```
69
+
70
+ ### Header Item
71
+
72
+ Non-interactive section header.
73
+
74
+ ```typescript
75
+ interface DropdownMenuHeaderItem {
76
+ type: "header";
77
+ id?: string | number;
78
+ label: THC;
79
+ class?: string;
80
+ }
81
+ ```
82
+
83
+ ### Custom Item
84
+
85
+ Render arbitrary content (non-interactive).
86
+
87
+ ```typescript
88
+ interface DropdownMenuCustomItem {
89
+ type: "custom";
90
+ id?: string | number;
91
+ content: THC;
92
+ class?: string;
93
+ }
94
+ ```
95
+
96
+ ### Expandable Item
97
+
98
+ Collapsible section containing nested items.
99
+
100
+ ```typescript
101
+ interface DropdownMenuExpandableItem {
102
+ type: "expandable";
103
+ id: string | number;
104
+ label: THC;
105
+ icon?: THC;
106
+ items: DropdownMenuFlatItem[]; // Nested items (no nested expandables)
107
+ defaultExpanded?: boolean;
108
+ disabled?: boolean;
109
+ class?: string;
110
+ }
111
+ ```
112
+
113
+ ## Position Options
114
+
115
+ | Position | Description |
116
+ |----------|-------------|
117
+ | `top`, `bottom` | Centered above/below trigger |
118
+ | `top-left`, `top-right` | Above, aligned to left/right edge |
119
+ | `bottom-left`, `bottom-right` | Below, aligned to left/right edge |
120
+ | `top-span-left`, `top-span-right` | Above, spanning from left/right |
121
+ | `bottom-span-left`, `bottom-span-right` | Below, spanning from left/right |
122
+ | `left`, `right` | Side-by-side with trigger |
123
+
124
+ ## Callbacks
125
+
126
+ | Callback | Parameters | Description |
127
+ |----------|------------|-------------|
128
+ | `onOpen` | - | Called when menu opens |
129
+ | `onClose` | - | Called when menu closes |
130
+ | `onSelect` | `(item: DropdownMenuActionItem)` | Called when action item selected (fallback) |
131
+
132
+ ## Keyboard Navigation
133
+
134
+ | Key | Action |
135
+ |-----|--------|
136
+ | `Arrow Down` | Move to next item |
137
+ | `Arrow Up` | Move to previous item |
138
+ | `Home` | Move to first item |
139
+ | `End` | Move to last item |
140
+ | `Cmd/Ctrl + Arrow` | Jump to first/last |
141
+ | `Enter` / `Space` | Select item or toggle expandable |
142
+ | `Arrow Right` | Expand section (on expandable) |
143
+ | `Arrow Left` | Collapse section (on expandable) |
144
+ | `Escape` | Close menu |
145
+ | `Tab` | Close menu |
146
+
147
+ ## Usage
148
+
149
+ ### Basic Menu
150
+
151
+ ```svelte
152
+ <script lang="ts">
153
+ import { DropdownMenu } from 'stuic';
154
+
155
+ const items = [
156
+ { type: "action", id: "edit", label: "Edit" },
157
+ { type: "action", id: "duplicate", label: "Duplicate" },
158
+ { type: "divider" },
159
+ { type: "action", id: "delete", label: "Delete", class: "text-red-500" },
160
+ ];
161
+ </script>
162
+
163
+ <DropdownMenu
164
+ {items}
165
+ onSelect={(item) => console.log('Selected:', item.id)}
166
+ >
167
+ Actions
168
+ </DropdownMenu>
169
+ ```
170
+
171
+ ### With Icons and Shortcuts
172
+
173
+ ```svelte
174
+ <script lang="ts">
175
+ import { DropdownMenu } from 'stuic';
176
+ import { iconLucideEdit } from '@marianmeres/icons-fns/lucide/iconLucideEdit.js';
177
+ import { iconLucideTrash } from '@marianmeres/icons-fns/lucide/iconLucideTrash.js';
178
+
179
+ const items = [
180
+ {
181
+ type: "action",
182
+ id: "edit",
183
+ label: "Edit",
184
+ icon: iconLucideEdit({ size: 16 }),
185
+ shortcut: "Cmd+E",
186
+ onSelect: () => handleEdit(),
187
+ },
188
+ { type: "divider" },
189
+ {
190
+ type: "action",
191
+ id: "delete",
192
+ label: "Delete",
193
+ icon: iconLucideTrash({ size: 16 }),
194
+ shortcut: "Cmd+D",
195
+ onSelect: () => handleDelete(),
196
+ },
197
+ ];
198
+ </script>
199
+
200
+ <DropdownMenu {items} position="bottom-right">
201
+ More Options
202
+ </DropdownMenu>
203
+ ```
204
+
205
+ ### With Section Headers
206
+
207
+ ```svelte
208
+ <DropdownMenu items={[
209
+ { type: "header", label: "Navigation" },
210
+ { type: "action", id: "dashboard", label: "Dashboard" },
211
+ { type: "action", id: "settings", label: "Settings" },
212
+ { type: "divider" },
213
+ { type: "header", label: "Account" },
214
+ { type: "action", id: "profile", label: "Profile" },
215
+ { type: "action", id: "logout", label: "Logout" },
216
+ ]} />
217
+ ```
218
+
219
+ ### Expandable Sections
220
+
221
+ ```svelte
222
+ <DropdownMenu items={[
223
+ { type: "action", id: "new", label: "New File" },
224
+ {
225
+ type: "expandable",
226
+ id: "recent",
227
+ label: "Recent Files",
228
+ defaultExpanded: true,
229
+ items: [
230
+ { type: "action", id: "file1", label: "document.pdf" },
231
+ { type: "action", id: "file2", label: "report.xlsx" },
232
+ { type: "action", id: "file3", label: "notes.txt" },
233
+ ],
234
+ },
235
+ { type: "divider" },
236
+ { type: "action", id: "settings", label: "Settings" },
237
+ ]} />
238
+ ```
239
+
240
+ ### Custom Trigger
241
+
242
+ ```svelte
243
+ <script lang="ts">
244
+ import { AvatarInitials, DropdownMenu } from 'stuic';
245
+ </script>
246
+
247
+ <DropdownMenu
248
+ items={[
249
+ { type: "action", id: "profile", label: "View Profile" },
250
+ { type: "action", id: "logout", label: "Logout" },
251
+ ]}
252
+ >
253
+ {#snippet trigger({ isOpen, toggle, triggerProps })}
254
+ <button {...triggerProps} onclick={toggle}>
255
+ <AvatarInitials input="john.doe@example.com" autoColor />
256
+ </button>
257
+ {/snippet}
258
+ </DropdownMenu>
259
+ ```
260
+
261
+ ### With Custom Content
262
+
263
+ ```svelte
264
+ <script lang="ts">
265
+ import { DropdownMenu } from 'stuic';
266
+ </script>
267
+
268
+ <DropdownMenu items={[
269
+ {
270
+ type: "custom",
271
+ content: customHeader,
272
+ },
273
+ { type: "divider" },
274
+ { type: "action", id: "settings", label: "Settings" },
275
+ { type: "action", id: "logout", label: "Logout" },
276
+ ]} />
277
+
278
+ {#snippet customHeader()}
279
+ <div class="px-3 py-2 text-center">
280
+ <img src="/avatar.jpg" class="w-12 h-12 rounded-full mx-auto" alt="User" />
281
+ <div class="mt-2 font-semibold">John Doe</div>
282
+ <div class="text-sm text-gray-500">john@example.com</div>
283
+ </div>
284
+ {/snippet}
285
+ ```
286
+
287
+ ### Controlled State
288
+
289
+ ```svelte
290
+ <script lang="ts">
291
+ import { DropdownMenu } from 'stuic';
292
+
293
+ let isOpen = $state(false);
294
+ </script>
295
+
296
+ <button onclick={() => isOpen = true}>Open Menu</button>
297
+
298
+ <DropdownMenu
299
+ bind:isOpen
300
+ items={[
301
+ { type: "action", id: "option1", label: "Option 1" },
302
+ { type: "action", id: "option2", label: "Option 2" },
303
+ ]}
304
+ />
305
+ ```
306
+
307
+ ## Features
308
+
309
+ - **CSS Anchor Positioning**: Uses modern CSS anchor positioning with automatic fallback for unsupported browsers
310
+ - **Full Keyboard Navigation**: Complete arrow key navigation with Home/End support
311
+ - **Expandable Sections**: Collapsible groups with independent toggle state
312
+ - **ARIA Compliant**: Proper menu roles and keyboard interaction
313
+ - **Reduced Motion**: Respects user's reduced motion preference
314
+ - **Click Outside**: Automatically closes when clicking outside
315
+ - **Focus Management**: Returns focus to trigger on close
@@ -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.19.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",