@r2digisolutions/components 0.15.3 → 0.15.4

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.
@@ -48,6 +48,11 @@
48
48
 
49
49
  let query = $state('');
50
50
  let activeIndex = $state(0);
51
+ let dialogEl = $state<HTMLDialogElement | null>(null);
52
+ let listEl = $state<HTMLDivElement | null>(null);
53
+ /** El click que abre la paleta llega al ::backdrop y la cerraría al instante. */
54
+ let ignoreBackdropUntil = 0;
55
+ const listId = 'command-palette-list';
51
56
 
52
57
  const resolvedPlaceholder = $derived(placeholder ?? i18n.t('commandPalettePlaceholder'));
53
58
  const resolvedEmpty = $derived(emptyLabel ?? i18n.t('noResults'));
@@ -72,36 +77,89 @@
72
77
  });
73
78
 
74
79
  const groups = $derived.by(() => {
75
- const map = new Map<string, CommandItem[]>();
76
- for (const item of filtered) {
80
+ const map = new Map<string, { item: CommandItem; index: number }[]>();
81
+ filtered.forEach((item, index) => {
77
82
  const key = item.group || dialogLabel;
78
83
  const list = map.get(key) ?? [];
79
- list.push(item);
84
+ list.push({ item, index });
80
85
  map.set(key, list);
81
- }
86
+ });
82
87
  return [...map.entries()];
83
88
  });
84
89
 
85
90
  const flat = $derived(filtered);
86
91
  const showIconColumn = $derived(flat.some((item) => item.icon));
92
+ const activeItem = $derived(flat[activeIndex] ?? null);
93
+ const activeOptionId = $derived(activeItem ? optionId(activeItem.id) : undefined);
94
+
95
+ function optionId(id: string) {
96
+ return `${listId}-${id}`;
97
+ }
98
+
99
+ function firstEnabledIndex() {
100
+ return flat.findIndex((item) => !item.disabled);
101
+ }
102
+
103
+ function nextEnabled(from: number, dir: 1 | -1) {
104
+ if (!flat.length) return 0;
105
+ let i = from;
106
+ for (let n = 0; n < flat.length; n++) {
107
+ i = (i + dir + flat.length) % flat.length;
108
+ if (!flat[i]?.disabled) return i;
109
+ }
110
+ return from;
111
+ }
112
+
113
+ /** Desplaza solo el listado, no el <dialog> (scrollIntoView arrastra ancestros). */
114
+ function keepVisible(node: HTMLElement) {
115
+ const frame = requestAnimationFrame(() => {
116
+ const list = listEl;
117
+ if (!list) return;
118
+ const listRect = list.getBoundingClientRect();
119
+ const rect = node.getBoundingClientRect();
120
+ if (rect.bottom > listRect.bottom) {
121
+ list.scrollTop += rect.bottom - listRect.bottom;
122
+ } else if (rect.top < listRect.top) {
123
+ list.scrollTop -= listRect.top - rect.top;
124
+ }
125
+ });
126
+ return () => cancelAnimationFrame(frame);
127
+ }
87
128
 
88
129
  $effect(() => {
89
- if (open) {
130
+ if (!dialogEl) return;
131
+ if (open && !dialogEl.open) {
90
132
  query = '';
91
- activeIndex = 0;
133
+ activeIndex = Math.max(0, firstEnabledIndex());
92
134
  untrack(() => onquery?.(''));
135
+ ignoreBackdropUntil = Date.now() + 400;
136
+ dialogEl.showModal();
137
+ } else if (!open && dialogEl.open) {
138
+ dialogEl.close();
93
139
  }
94
140
  });
95
141
 
96
- function focusInput(node: HTMLInputElement) {
97
- queueMicrotask(() => node.focus());
98
- }
99
-
100
142
  function close() {
101
143
  open = false;
102
144
  onclose?.();
103
145
  }
104
146
 
147
+ function handleDialogClose() {
148
+ if (open) close();
149
+ }
150
+
151
+ function handleBackdropClick(event: MouseEvent) {
152
+ if (!dialogEl) return;
153
+ if (Date.now() < ignoreBackdropUntil) return;
154
+ const rect = dialogEl.getBoundingClientRect();
155
+ const inside =
156
+ event.clientX >= rect.left &&
157
+ event.clientX <= rect.right &&
158
+ event.clientY >= rect.top &&
159
+ event.clientY <= rect.bottom;
160
+ if (!inside) close();
161
+ }
162
+
105
163
  function choose(item: CommandItem) {
106
164
  if (item.disabled) return;
107
165
  onselect?.(item);
@@ -109,18 +167,19 @@
109
167
  }
110
168
 
111
169
  function onKeydown(e: KeyboardEvent) {
112
- if (!open) return;
113
- if (e.key === 'Escape') {
114
- e.preventDefault();
115
- close();
116
- return;
117
- }
170
+ if (!open || !flat.length) return;
118
171
  if (e.key === 'ArrowDown') {
119
172
  e.preventDefault();
120
- activeIndex = Math.min(Math.max(flat.length - 1, 0), activeIndex + 1);
173
+ activeIndex = nextEnabled(activeIndex, 1);
121
174
  } else if (e.key === 'ArrowUp') {
122
175
  e.preventDefault();
123
- activeIndex = Math.max(0, activeIndex - 1);
176
+ activeIndex = nextEnabled(activeIndex, -1);
177
+ } else if (e.key === 'Home') {
178
+ e.preventDefault();
179
+ activeIndex = nextEnabled(-1, 1);
180
+ } else if (e.key === 'End') {
181
+ e.preventDefault();
182
+ activeIndex = nextEnabled(flat.length, -1);
124
183
  } else if (e.key === 'Enter') {
125
184
  e.preventDefault();
126
185
  const item = flat[activeIndex];
@@ -129,128 +188,175 @@
129
188
  }
130
189
 
131
190
  function onInput() {
132
- activeIndex = 0;
191
+ activeIndex = Math.max(0, firstEnabledIndex());
133
192
  onquery?.(query);
134
193
  }
135
194
  </script>
136
195
 
137
- {#if open}
138
- <button
139
- type="button"
140
- class="inset-0 bg-black/60 p-0 dark:bg-black/70 backdrop-blur-sm fixed z-50 border-0"
141
- aria-label={i18n.t('close')}
142
- onclick={close}
143
- ></button>
144
-
196
+ <dialog
197
+ bind:this={dialogEl}
198
+ class={['command-palette', className]}
199
+ aria-label={dialogLabel}
200
+ onclose={handleDialogClose}
201
+ onclick={handleBackdropClick}
202
+ >
145
203
  <div
146
- class="px-4 inset-0 pointer-events-none fixed z-[51] flex items-start justify-center pt-[12vh]"
204
+ class="rounded-2xl border-border bg-surface-elevated shadow-2xl w-full overflow-hidden border"
147
205
  >
148
- <div
149
- role="dialog"
150
- aria-modal="true"
151
- aria-label={dialogLabel}
152
- tabindex="-1"
153
- class={[
154
- 'max-w-xl rounded-2xl border-border bg-surface-elevated shadow-2xl pointer-events-auto w-full overflow-hidden border',
155
- className
156
- ]}
157
- onkeydown={onKeydown}
158
- >
159
- <div class="gap-2 border-border px-3 flex items-center border-b">
160
- <svg
161
- class="h-4 w-4 text-muted shrink-0"
162
- viewBox="0 0 24 24"
163
- fill="none"
164
- stroke="currentColor"
165
- stroke-width="2"
166
- aria-hidden="true"
167
- >
168
- <path
169
- stroke-linecap="round"
170
- stroke-linejoin="round"
171
- d="M21 21l-4.35-4.35M11 18a7 7 0 100-14 7 7 0 000 14z"
172
- />
173
- </svg>
174
- <input
175
- bind:value={query}
176
- {@attach focusInput}
177
- placeholder={resolvedPlaceholder}
178
- class="h-12 text-sm text-primary placeholder:text-muted w-full bg-transparent outline-none"
179
- autocomplete="off"
180
- spellcheck="false"
181
- aria-autocomplete="list"
182
- oninput={onInput}
206
+ <div class="gap-2 border-border px-3 flex items-center border-b">
207
+ <svg
208
+ class="h-4 w-4 text-muted shrink-0"
209
+ viewBox="0 0 24 24"
210
+ fill="none"
211
+ stroke="currentColor"
212
+ stroke-width="2"
213
+ aria-hidden="true"
214
+ >
215
+ <path
216
+ stroke-linecap="round"
217
+ stroke-linejoin="round"
218
+ d="M21 21l-4.35-4.35M11 18a7 7 0 100-14 7 7 0 000 14z"
183
219
  />
184
- {#if loading}
185
- <span
186
- class="h-4 w-4 animate-spin border-border border-t-brand-500 shrink-0 rounded-full border-2"
187
- aria-hidden="true"
188
- ></span>
189
- {/if}
190
- <Kbd keys={['Esc']} size="sm" />
191
- </div>
220
+ </svg>
221
+ <input
222
+ bind:value={query}
223
+ placeholder={resolvedPlaceholder}
224
+ class="h-12 text-sm text-primary placeholder:text-muted w-full bg-transparent outline-none"
225
+ autocomplete="off"
226
+ spellcheck="false"
227
+ role="combobox"
228
+ aria-autocomplete="list"
229
+ aria-expanded="true"
230
+ aria-controls={listId}
231
+ aria-activedescendant={activeOptionId}
232
+ oninput={onInput}
233
+ onkeydown={onKeydown}
234
+ />
235
+ {#if loading}
236
+ <span
237
+ class="h-4 w-4 animate-spin border-border border-t-brand-500 shrink-0 rounded-full border-2"
238
+ aria-hidden="true"
239
+ ></span>
240
+ {/if}
241
+ <Kbd keys={['Esc']} size="sm" />
242
+ </div>
192
243
 
193
- <div class="max-h-80 p-2 overflow-y-auto">
194
- {#if flat.length === 0}
195
- <p class="px-3 py-6 text-sm text-muted text-center">{resolvedEmpty}</p>
196
- {:else}
197
- {#each groups as [groupName, groupItems] (groupName)}
198
- <p class="px-2 py-1.5 font-semibold tracking-wide text-muted text-[11px] uppercase">
199
- {groupName}
200
- </p>
201
- <ul class="mb-2 gap-0.5 flex flex-col">
202
- {#each groupItems as item (item.id)}
203
- {@const index = flat.findIndex((f) => f.id === item.id)}
204
- {@const Icon = item.icon}
205
- <li>
206
- <button
207
- type="button"
208
- disabled={item.disabled}
209
- onclick={() => choose(item)}
210
- onmouseenter={() => (activeIndex = index)}
211
- class={[
212
- 'gap-3 rounded-xl px-3 py-2 text-sm flex w-full items-center justify-between text-left transition-colors',
213
- index === activeIndex
214
- ? 'bg-brand-50 text-brand-700 dark:bg-brand-950/40 dark:text-brand-300'
215
- : 'text-primary hover:bg-surface-overlay',
216
- item.disabled && 'cursor-not-allowed opacity-40'
217
- ]}
218
- >
219
- <span class="gap-3 min-w-0 flex flex-1 items-center">
220
- {#if showIconColumn}
221
- <span
222
- class={[
223
- 'h-8 w-8 rounded-lg flex shrink-0 items-center justify-center',
224
- index === activeIndex
225
- ? 'bg-brand-100/80 text-brand-700 dark:bg-brand-900/50 dark:text-brand-300'
226
- : 'bg-surface-overlay text-muted'
227
- ]}
228
- aria-hidden="true"
229
- >
230
- {#if Icon}
231
- <Icon size={16} strokeWidth={2} />
232
- {/if}
233
- </span>
234
- {/if}
235
- <span class="min-w-0 flex-1">
236
- <span class="block truncate">{item.label}</span>
237
- {#if item.subtitle}
238
- <span class="mt-0.5 text-xs text-muted block truncate"
239
- >{item.subtitle}</span
240
- >
244
+ <div bind:this={listEl} id={listId} class="max-h-80 p-2 overflow-y-auto" role="listbox">
245
+ {#if flat.length === 0}
246
+ <p class="px-3 py-6 text-sm text-muted text-center">{resolvedEmpty}</p>
247
+ {:else}
248
+ {#each groups as [groupName, groupItems] (groupName)}
249
+ <p class="px-2 py-1.5 font-semibold tracking-wide text-muted text-[11px] uppercase">
250
+ {groupName}
251
+ </p>
252
+ <ul class="mb-2 gap-0.5 flex flex-col">
253
+ {#each groupItems as { item, index } (item.id)}
254
+ {@const Icon = item.icon}
255
+ {@const active = index === activeIndex}
256
+ <li>
257
+ <button
258
+ id={optionId(item.id)}
259
+ type="button"
260
+ role="option"
261
+ tabindex="-1"
262
+ aria-selected={active}
263
+ disabled={item.disabled}
264
+ onclick={() => choose(item)}
265
+ onmouseenter={() => {
266
+ if (!item.disabled) activeIndex = index;
267
+ }}
268
+ {@attach active ? keepVisible : undefined}
269
+ class={[
270
+ 'gap-3 rounded-xl px-3 py-2 text-sm flex w-full items-center justify-between text-left transition-colors',
271
+ active
272
+ ? 'bg-brand-50 text-brand-700 dark:bg-brand-950/40 dark:text-brand-300'
273
+ : 'text-primary hover:bg-surface-overlay',
274
+ item.disabled && 'cursor-not-allowed opacity-40'
275
+ ]}
276
+ >
277
+ <span class="gap-3 min-w-0 flex flex-1 items-center">
278
+ {#if showIconColumn}
279
+ <span
280
+ class={[
281
+ 'h-8 w-8 rounded-lg flex shrink-0 items-center justify-center',
282
+ active
283
+ ? 'bg-brand-100/80 text-brand-700 dark:bg-brand-900/50 dark:text-brand-300'
284
+ : 'bg-surface-overlay text-muted'
285
+ ]}
286
+ aria-hidden="true"
287
+ >
288
+ {#if Icon}
289
+ <Icon size={16} strokeWidth={2} />
241
290
  {/if}
242
291
  </span>
243
- </span>
244
- {#if item.shortcut?.length}
245
- <Kbd keys={item.shortcut} size="sm" />
246
292
  {/if}
247
- </button>
248
- </li>
249
- {/each}
250
- </ul>
251
- {/each}
252
- {/if}
253
- </div>
293
+ <span class="min-w-0 flex-1">
294
+ <span class="block truncate">{item.label}</span>
295
+ {#if item.subtitle}
296
+ <span class="mt-0.5 text-xs text-muted block truncate">{item.subtitle}</span
297
+ >
298
+ {/if}
299
+ </span>
300
+ </span>
301
+ {#if item.shortcut?.length}
302
+ <Kbd keys={item.shortcut} size="sm" />
303
+ {/if}
304
+ </button>
305
+ </li>
306
+ {/each}
307
+ </ul>
308
+ {/each}
309
+ {/if}
254
310
  </div>
255
311
  </div>
256
- {/if}
312
+ </dialog>
313
+
314
+ <style>
315
+ .command-palette {
316
+ margin: 12vh auto auto;
317
+ padding: 0;
318
+ border: none;
319
+ background: transparent;
320
+ color: inherit;
321
+ width: calc(100% - 2rem);
322
+ max-width: 36rem;
323
+ overflow: visible;
324
+ }
325
+
326
+ .command-palette::backdrop {
327
+ background: oklch(15% 0.02 265 / 0.45);
328
+ backdrop-filter: blur(6px);
329
+ }
330
+
331
+ :global(.dark) .command-palette::backdrop {
332
+ background: oklch(0% 0 0 / 0.65);
333
+ }
334
+
335
+ .command-palette[open] {
336
+ animation: palette-in 140ms ease-out;
337
+ }
338
+
339
+ .command-palette[open]::backdrop {
340
+ animation: palette-backdrop-in 140ms ease-out;
341
+ }
342
+
343
+ @keyframes palette-in {
344
+ from {
345
+ opacity: 0;
346
+ transform: translateY(6px) scale(0.98);
347
+ }
348
+ to {
349
+ opacity: 1;
350
+ transform: translateY(0) scale(1);
351
+ }
352
+ }
353
+
354
+ @keyframes palette-backdrop-in {
355
+ from {
356
+ opacity: 0;
357
+ }
358
+ to {
359
+ opacity: 1;
360
+ }
361
+ }
362
+ </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r2digisolutions/components",
3
- "version": "0.15.3",
3
+ "version": "0.15.4",
4
4
  "private": false,
5
5
  "description": "R2DigiSolutions Svelte 5 component library — Atomic Design, Tailwind 4, Light/Dark mode",
6
6
  "license": "MIT",