@marianmeres/stuic 2.61.0 → 2.63.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.
- package/dist/actions/popover/popover.svelte.js +18 -4
- package/dist/actions/tooltip/tooltip.svelte.js +15 -11
- package/dist/components/AssetsPreview/AssetsPreview.svelte +15 -17
- package/dist/components/AssetsPreview/AssetsPreview.svelte.d.ts +2 -4
- package/dist/components/CommandMenu/CommandMenu.svelte +172 -148
- package/dist/components/CommandMenu/README.md +6 -0
- package/dist/components/DropdownMenu/README.md +2 -2
- package/dist/components/Input/FieldOptions.svelte +279 -286
- package/dist/components/Input/FieldOptions.svelte.d.ts +4 -5
- package/dist/components/Modal/Modal.svelte +37 -48
- package/dist/components/Modal/Modal.svelte.d.ts +3 -13
- package/dist/components/Modal/README.md +17 -6
- package/dist/components/ModalDialog/ModalDialog.svelte +50 -13
- package/dist/components/ModalDialog/ModalDialog.svelte.d.ts +9 -0
- package/dist/components/ModalDialog/README.md +27 -8
- package/dist/components/Notifications/Notifications.svelte +135 -96
- package/dist/components/Notifications/notifications-icons.js +4 -4
- package/dist/components/X/X.svelte +1 -1
- package/package.json +1 -1
|
@@ -146,6 +146,18 @@ function getStringContent(content) {
|
|
|
146
146
|
}
|
|
147
147
|
return "";
|
|
148
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Find the appropriate container for the popover element.
|
|
151
|
+
* If the anchor is inside an open dialog (modal), append to the dialog
|
|
152
|
+
* so the popover renders in the same top layer as the modal.
|
|
153
|
+
*/
|
|
154
|
+
function getPopoverContainer(anchorEl) {
|
|
155
|
+
const dialog = anchorEl.closest("dialog[open]");
|
|
156
|
+
if (dialog) {
|
|
157
|
+
return dialog;
|
|
158
|
+
}
|
|
159
|
+
return document.body;
|
|
160
|
+
}
|
|
149
161
|
/**
|
|
150
162
|
* A Svelte action that displays a popover anchored to an element using CSS Anchor Positioning.
|
|
151
163
|
*
|
|
@@ -329,7 +341,9 @@ export function popover(anchorEl, fn) {
|
|
|
329
341
|
anchorEl.setAttribute("aria-expanded", "true");
|
|
330
342
|
const offsetValue = currentOptions.offset || "0.25rem";
|
|
331
343
|
const useAnchorPositioning = isSupported && !currentOptions.forceFallback;
|
|
332
|
-
//
|
|
344
|
+
// Get appropriate container (dialog if inside one, otherwise body)
|
|
345
|
+
// This ensures popover renders in same stacking context as modal dialogs
|
|
346
|
+
const container = getPopoverContainer(anchorEl);
|
|
333
347
|
if (useAnchorPositioning) {
|
|
334
348
|
// CSS Anchor Positioning mode
|
|
335
349
|
popoverEl = document.createElement("div");
|
|
@@ -343,7 +357,7 @@ export function popover(anchorEl, fn) {
|
|
|
343
357
|
margin: ${offsetValue};
|
|
344
358
|
`;
|
|
345
359
|
popoverEl.classList.add(...twMerge("stuic-popover", _classPopover, currentOptions.class).split(/\s/));
|
|
346
|
-
|
|
360
|
+
container.appendChild(popoverEl);
|
|
347
361
|
}
|
|
348
362
|
else {
|
|
349
363
|
// Fallback centered modal mode
|
|
@@ -351,7 +365,7 @@ export function popover(anchorEl, fn) {
|
|
|
351
365
|
backdropEl = document.createElement("div");
|
|
352
366
|
backdropEl.classList.add(...twMerge("stuic-popover-backdrop", _classBackdrop).split(/\s/));
|
|
353
367
|
backdropEl.style.cssText = `transition-duration: ${TRANSITION}ms;`;
|
|
354
|
-
|
|
368
|
+
container.appendChild(backdropEl);
|
|
355
369
|
// Backdrop click closes popover
|
|
356
370
|
if (currentOptions.closeOnClickOutside !== false) {
|
|
357
371
|
backdropEl.addEventListener("click", hide);
|
|
@@ -383,7 +397,7 @@ export function popover(anchorEl, fn) {
|
|
|
383
397
|
`;
|
|
384
398
|
popoverEl.classList.add(...twMerge("stuic-popover-fallback", _classPopover, currentOptions.class).split(/\s/));
|
|
385
399
|
wrapperEl.appendChild(popoverEl);
|
|
386
|
-
|
|
400
|
+
container.appendChild(wrapperEl);
|
|
387
401
|
// Lock body scroll in fallback (modal) mode
|
|
388
402
|
BodyScroll.lock();
|
|
389
403
|
// Click on wrapper (outside popover) closes
|
|
@@ -48,6 +48,18 @@ const POSITION_MAP = {
|
|
|
48
48
|
left: "left",
|
|
49
49
|
right: "right",
|
|
50
50
|
};
|
|
51
|
+
/**
|
|
52
|
+
* Find the appropriate container for the tooltip element.
|
|
53
|
+
* If the anchor is inside an open dialog (modal), append to the dialog
|
|
54
|
+
* so the tooltip renders in the same top layer as the modal.
|
|
55
|
+
*/
|
|
56
|
+
function getTooltipContainer(anchorEl) {
|
|
57
|
+
const dialog = anchorEl.closest("dialog[open]");
|
|
58
|
+
if (dialog) {
|
|
59
|
+
return dialog;
|
|
60
|
+
}
|
|
61
|
+
return document.body;
|
|
62
|
+
}
|
|
51
63
|
// Global tooltip configuration - allows disabling all tooltips at runtime
|
|
52
64
|
const globalTooltipConfig = $state({ enabled: true });
|
|
53
65
|
// Touch device auto-detection (runs once on first tooltip init)
|
|
@@ -164,7 +176,9 @@ export function tooltip(anchorEl, fn) {
|
|
|
164
176
|
tooltipEl.setAttribute("role", "tooltip");
|
|
165
177
|
tooltipEl.style.cssText += `position-anchor: ${anchorName}; transition-duration: ${TRANSITION}ms; position-area: ${POSITION_MAP[position] || "top"};`;
|
|
166
178
|
tooltipEl.classList.add(...twMerge("stuic-tooltip", _classTooltip).split(/\s/));
|
|
167
|
-
|
|
179
|
+
// Append to dialog if inside one, otherwise body (for proper top-layer rendering in modals)
|
|
180
|
+
const container = getTooltipContainer(anchorEl);
|
|
181
|
+
container.appendChild(tooltipEl);
|
|
168
182
|
//
|
|
169
183
|
tooltipEl.addEventListener("mouseenter", schedule_show);
|
|
170
184
|
tooltipEl.addEventListener("mouseleave", schedule_hide);
|
|
@@ -216,11 +230,6 @@ export function tooltip(anchorEl, fn) {
|
|
|
216
230
|
tooltipEl.classList.add("tt-visible");
|
|
217
231
|
on_show?.();
|
|
218
232
|
});
|
|
219
|
-
// waitForTwoRepaints().then(() => {
|
|
220
|
-
// tooltipEl.classList.add("tt-visible");
|
|
221
|
-
// this approach is nicer, but I have a suspicion of the event handlers not being destroyed properly (maybe just a hot-reload issues...)
|
|
222
|
-
// waitForTransitionEnd(tooltipEl).then(() => on_show?.());
|
|
223
|
-
// });
|
|
224
233
|
}
|
|
225
234
|
}, TIMEOUT);
|
|
226
235
|
}
|
|
@@ -239,11 +248,6 @@ export function tooltip(anchorEl, fn) {
|
|
|
239
248
|
tooltipEl.classList.remove("tt-block");
|
|
240
249
|
on_hide?.();
|
|
241
250
|
}, TRANSITION);
|
|
242
|
-
// this approach is nicer, but I have a suspicion of the event handlers not being destroyed properly (maybe just a hot-reload issues...)
|
|
243
|
-
// waitForTransitionEnd(tooltipEl).then(() => {
|
|
244
|
-
// tooltipEl.classList.remove("tt-block");
|
|
245
|
-
// on_hide?.();
|
|
246
|
-
// });
|
|
247
251
|
}, TIMEOUT);
|
|
248
252
|
}
|
|
249
253
|
// "reactive" params re/set
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
iconZoomOut,
|
|
22
22
|
} from "../../icons/index.js";
|
|
23
23
|
import { getFileTypeLabel } from "../../utils/get-file-type-label.js";
|
|
24
|
-
import {
|
|
24
|
+
import { ModalDialog } from "../ModalDialog/index.js";
|
|
25
25
|
import { createClog } from "@marianmeres/clog";
|
|
26
26
|
import { isImage } from "../../utils/is-image.js";
|
|
27
27
|
import { isPlainObject } from "../../utils/is-plain-object.js";
|
|
@@ -60,10 +60,8 @@
|
|
|
60
60
|
assets: string[] | AssetPreview[];
|
|
61
61
|
classControls?: string;
|
|
62
62
|
//
|
|
63
|
-
|
|
64
|
-
modalClassInner?: string;
|
|
63
|
+
modalClassDialog?: string;
|
|
65
64
|
modalClass?: string;
|
|
66
|
-
modalClassMain?: string;
|
|
67
65
|
//
|
|
68
66
|
/** Optional translate function */
|
|
69
67
|
t?: TranslateFn;
|
|
@@ -176,10 +174,8 @@
|
|
|
176
174
|
const clog = createClog("AssetsPreview", { color: "auto" });
|
|
177
175
|
|
|
178
176
|
let {
|
|
179
|
-
|
|
180
|
-
modalClassInner = "",
|
|
177
|
+
modalClassDialog = "",
|
|
181
178
|
modalClass = "",
|
|
182
|
-
modalClassMain = "",
|
|
183
179
|
assets: _assets,
|
|
184
180
|
t = t_default,
|
|
185
181
|
classControls = "",
|
|
@@ -192,7 +188,7 @@
|
|
|
192
188
|
(_assets ?? []).map(normalizeInput).filter(Boolean) as AssetPreviewNormalized[]
|
|
193
189
|
);
|
|
194
190
|
let previewIdx = $state<number>(0);
|
|
195
|
-
let modal:
|
|
191
|
+
let modal: ModalDialog | undefined = $state();
|
|
196
192
|
let dotTooltip: string | undefined = $state();
|
|
197
193
|
|
|
198
194
|
// Zoom state
|
|
@@ -478,13 +474,15 @@
|
|
|
478
474
|
/>
|
|
479
475
|
|
|
480
476
|
{#if assets.length}
|
|
481
|
-
<
|
|
477
|
+
<ModalDialog
|
|
482
478
|
bind:this={modal}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
479
|
+
classDialog={modalClassDialog}
|
|
480
|
+
class={twMerge(
|
|
481
|
+
"max-w-full max-h-full h-full rounded-lg",
|
|
482
|
+
"flex items-center justify-center relative",
|
|
483
|
+
"stuic-assets-preview stuic-assets-preview-open",
|
|
484
|
+
modalClass
|
|
485
|
+
)}
|
|
488
486
|
>
|
|
489
487
|
{@const previewAsset = assets?.[previewIdx]}
|
|
490
488
|
{#if previewAsset}
|
|
@@ -561,7 +559,7 @@
|
|
|
561
559
|
onclick={zoomOut}
|
|
562
560
|
disabled={zoomLevelIdx === 0}
|
|
563
561
|
aria-label={t("zoom_out")}
|
|
564
|
-
use:tooltip
|
|
562
|
+
use:tooltip={() => ({ content: t("zoom_out") })}
|
|
565
563
|
>
|
|
566
564
|
{@html iconZoomOut({ class: "size-6" })}
|
|
567
565
|
</button>
|
|
@@ -572,7 +570,7 @@
|
|
|
572
570
|
onclick={zoomIn}
|
|
573
571
|
disabled={zoomLevelIdx === ZOOM_LEVELS.length - 1}
|
|
574
572
|
aria-label={t("zoom_in")}
|
|
575
|
-
use:tooltip
|
|
573
|
+
use:tooltip={() => ({ content: t("zoom_in") })}
|
|
576
574
|
>
|
|
577
575
|
{@html iconZoomIn({ class: "size-6" })}
|
|
578
576
|
</button>
|
|
@@ -651,5 +649,5 @@
|
|
|
651
649
|
</div>
|
|
652
650
|
{/if}
|
|
653
651
|
{/if}
|
|
654
|
-
</
|
|
652
|
+
</ModalDialog>
|
|
655
653
|
{/if}
|
|
@@ -12,10 +12,8 @@ export interface AssetPreview {
|
|
|
12
12
|
export interface Props {
|
|
13
13
|
assets: string[] | AssetPreview[];
|
|
14
14
|
classControls?: string;
|
|
15
|
-
|
|
16
|
-
modalClassInner?: string;
|
|
15
|
+
modalClassDialog?: string;
|
|
17
16
|
modalClass?: string;
|
|
18
|
-
modalClassMain?: string;
|
|
19
17
|
/** Optional translate function */
|
|
20
18
|
t?: TranslateFn;
|
|
21
19
|
/** Optional delete handler - receives the current asset and its index */
|
|
@@ -32,7 +30,7 @@ declare const AssetsPreview: import("svelte").Component<Props, {
|
|
|
32
30
|
open: (index?: number) => void;
|
|
33
31
|
close: () => void;
|
|
34
32
|
visibility: () => {
|
|
35
|
-
readonly visible: boolean;
|
|
33
|
+
readonly visible: boolean | undefined;
|
|
36
34
|
};
|
|
37
35
|
setOpener: (opener?: null | HTMLElement) => void;
|
|
38
36
|
}, "">;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts" module>
|
|
2
2
|
import { ItemCollection, type Item } from "@marianmeres/item-collection";
|
|
3
|
-
import {
|
|
3
|
+
import { ModalDialog } from "../ModalDialog/index.js";
|
|
4
4
|
import { createClog } from "@marianmeres/clog";
|
|
5
5
|
import { FieldInput } from "../Input/index.js";
|
|
6
6
|
import { twMerge } from "../../utils/tw-merge.js";
|
|
@@ -91,11 +91,11 @@
|
|
|
91
91
|
});
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
let
|
|
94
|
+
let modalDialog: ModalDialog = $state()!;
|
|
95
95
|
let isFetching = $state(false);
|
|
96
|
-
let modalEl: HTMLDivElement | undefined = $state();
|
|
97
96
|
let optionsBox: HTMLDivElement | undefined = $state();
|
|
98
97
|
let activeEl: HTMLButtonElement | undefined = $state();
|
|
98
|
+
let fetchRequestId = 0;
|
|
99
99
|
|
|
100
100
|
//
|
|
101
101
|
// svelte-ignore state_referenced_locally
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
|
|
110
110
|
// scroll the active option into view
|
|
111
111
|
$effect(() => {
|
|
112
|
-
if (
|
|
112
|
+
if (modalDialog.visibility().visible && options.active?.[itemIdPropName]) {
|
|
113
113
|
activeEl = qsa(`#${btn_id(options.active[itemIdPropName])}`, optionsBox)[0] as any;
|
|
114
114
|
activeEl?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
115
115
|
activeEl?.focus();
|
|
@@ -120,15 +120,18 @@
|
|
|
120
120
|
|
|
121
121
|
//
|
|
122
122
|
const debounced = new Debounced(() => q, 150);
|
|
123
|
-
watch([() => debounced.current], ([currQ], [
|
|
123
|
+
watch([() => debounced.current], ([currQ], [_oldQ]) => {
|
|
124
124
|
if (!currQ && !showAllOnEmptyQ) {
|
|
125
125
|
_optionsColl.clear();
|
|
126
126
|
return;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
isFetching = true;
|
|
130
|
+
const currentRequest = ++fetchRequestId;
|
|
130
131
|
getOptions(`${currQ}`, [])
|
|
131
132
|
.then((res) => {
|
|
133
|
+
// Ignore stale responses
|
|
134
|
+
if (currentRequest !== fetchRequestId) return;
|
|
132
135
|
_optionsColl.clear().addMany(res);
|
|
133
136
|
})
|
|
134
137
|
.catch((e) => {
|
|
@@ -151,15 +154,16 @@
|
|
|
151
154
|
}
|
|
152
155
|
|
|
153
156
|
export function close() {
|
|
154
|
-
|
|
157
|
+
modalDialog.close();
|
|
155
158
|
}
|
|
156
159
|
|
|
157
160
|
export function open(openerOrEvent?: null | HTMLElement | MouseEvent) {
|
|
158
|
-
|
|
161
|
+
modalDialog.open(openerOrEvent);
|
|
159
162
|
}
|
|
160
163
|
|
|
161
164
|
// internal DRY
|
|
162
165
|
const rand = Math.random().toString(36).slice(2);
|
|
166
|
+
const listId = `cmd-menu-list-${rand}`;
|
|
163
167
|
function btn_id(id: string | number, prefix = "btn-") {
|
|
164
168
|
return prefix + rand + strHash(`${id}`.repeat(3));
|
|
165
169
|
}
|
|
@@ -195,7 +199,7 @@
|
|
|
195
199
|
<!-- this must be on window as we're catching any typing anywhere -->
|
|
196
200
|
<svelte:window
|
|
197
201
|
onkeydown={(e) => {
|
|
198
|
-
if (
|
|
202
|
+
if (modalDialog.visibility().visible) {
|
|
199
203
|
// arrow navigation
|
|
200
204
|
if (["ArrowDown", "ArrowUp"].includes(e.key)) {
|
|
201
205
|
e.preventDefault();
|
|
@@ -213,154 +217,174 @@
|
|
|
213
217
|
}}
|
|
214
218
|
/>
|
|
215
219
|
|
|
216
|
-
<
|
|
217
|
-
bind:this={
|
|
218
|
-
|
|
219
|
-
class="bg-transparent
|
|
220
|
-
classInner="max-w-2xl"
|
|
221
|
-
bind:el={modalEl}
|
|
220
|
+
<ModalDialog
|
|
221
|
+
bind:this={modalDialog}
|
|
222
|
+
classDialog="items-start"
|
|
223
|
+
class="w-full max-w-3xl bg-transparent pointer-events-none"
|
|
222
224
|
{noScrollLock}
|
|
223
225
|
>
|
|
224
|
-
<
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}}
|
|
231
|
-
class=""
|
|
232
|
-
>
|
|
233
|
-
<FieldInput
|
|
234
|
-
type="text"
|
|
235
|
-
name="q"
|
|
236
|
-
bind:input
|
|
237
|
-
bind:value={q}
|
|
238
|
-
class="search m-4 mb-12 shadow-xl"
|
|
239
|
-
classLabelBox="m-0"
|
|
240
|
-
autocomplete="off"
|
|
241
|
-
placeholder={searchPlaceholder ?? t("search_placeholder")}
|
|
242
|
-
classInputBoxWrap={twMerge(
|
|
243
|
-
// always look like focused
|
|
244
|
-
`border-primary border-input-accent dark:border-input-accent-dark`,
|
|
245
|
-
`ring-input-accent/20 dark:ring-input-accent-dark/20 ring-4`
|
|
246
|
-
)}
|
|
247
|
-
onkeydown={(e) => {
|
|
248
|
-
if (e.key === "Enter") {
|
|
249
|
-
e.preventDefault();
|
|
250
|
-
submit();
|
|
251
|
-
}
|
|
226
|
+
<div class="pt-0 md:pt-[20vh] w-full">
|
|
227
|
+
<form
|
|
228
|
+
class="pointer-events-auto"
|
|
229
|
+
onsubmit={(e) => {
|
|
230
|
+
e.preventDefault();
|
|
231
|
+
modalDialog.close();
|
|
252
232
|
}}
|
|
253
233
|
>
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}}
|
|
282
|
-
>
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
<div
|
|
290
|
-
class={twMerge(
|
|
291
|
-
"options block space-y-1 p-1",
|
|
292
|
-
"overflow-y-auto overflow-x-hidden mb-1",
|
|
293
|
-
"border-t border-black/20",
|
|
294
|
-
"max-h-60"
|
|
295
|
-
)}
|
|
296
|
-
bind:this={optionsBox}
|
|
297
|
-
tabindex="-1"
|
|
298
|
-
>
|
|
299
|
-
{#each _normalize_and_group_options(options.items) as [_optgroup, _opts]}
|
|
300
|
-
<!-- {console.log(11111, _optgroup, _opts)} -->
|
|
301
|
-
<div class="p-1">
|
|
302
|
-
{#if _optgroup}
|
|
303
|
-
<div
|
|
304
|
-
class="text-sm capitalize opacity-50 border-b border-black/10 mb-1 p-1"
|
|
305
|
-
>
|
|
306
|
-
{_optgroup}
|
|
307
|
-
</div>
|
|
308
|
-
{/if}
|
|
309
|
-
<ul>
|
|
310
|
-
{#each _opts as item (item.id)}
|
|
311
|
-
{@const active =
|
|
312
|
-
item[itemIdPropName] === options.active?.[itemIdPropName]}
|
|
313
|
-
<!-- {@const isSelected = false} -->
|
|
314
|
-
<li class:active>
|
|
315
|
-
<button
|
|
316
|
-
class:active
|
|
317
|
-
type="button"
|
|
318
|
-
class={twMerge(
|
|
319
|
-
"no-focus-visible",
|
|
320
|
-
"text-left rounded-md py-2 px-2.5",
|
|
321
|
-
"min-w-0 w-full overflow-hidden text-ellipsis whitespace-nowrap",
|
|
322
|
-
"border border-transparent",
|
|
323
|
-
"focus:outline-0 focus:border-neutral-400 dark:focus:border-neutral-500",
|
|
324
|
-
"focus-visible:outline-0 focus-visible:ring-0",
|
|
325
|
-
"hover:border-neutral-400 dark:hover:border-neutral-500",
|
|
326
|
-
active && "bg-neutral-200 dark:bg-neutral-800",
|
|
327
|
-
classOption,
|
|
328
|
-
// active && "border-neutral-400",
|
|
329
|
-
active && classOptionActive
|
|
330
|
-
)}
|
|
331
|
-
id={btn_id(item[itemIdPropName])}
|
|
332
|
-
tabindex="-1"
|
|
333
|
-
onclick={() => {
|
|
334
|
-
_optionsColl.setActive(item);
|
|
335
|
-
submit();
|
|
336
|
-
}}
|
|
337
|
-
onkeydown={(e) => {
|
|
338
|
-
// need to handle tab here, because the tabindex="-1" is ignored
|
|
339
|
-
// in the focus-trap selectors... so, on Tab, manually focusin input
|
|
340
|
-
if (e.key === "Tab") {
|
|
341
|
-
e.preventDefault();
|
|
342
|
-
input?.focus();
|
|
343
|
-
}
|
|
344
|
-
}}
|
|
345
|
-
>
|
|
346
|
-
<!-- role="checkbox"
|
|
347
|
-
aria-checked={active} -->
|
|
348
|
-
{_renderOptionLabel(item)}
|
|
349
|
-
</button>
|
|
350
|
-
</li>
|
|
351
|
-
{/each}
|
|
352
|
-
</ul>
|
|
353
|
-
</div>
|
|
354
|
-
{/each}
|
|
234
|
+
<FieldInput
|
|
235
|
+
type="text"
|
|
236
|
+
name="q"
|
|
237
|
+
renderSize="lg"
|
|
238
|
+
bind:input
|
|
239
|
+
bind:value={q}
|
|
240
|
+
class="search m-2 shadow-xl"
|
|
241
|
+
classLabelBox="m-0"
|
|
242
|
+
autocomplete="off"
|
|
243
|
+
aria-autocomplete="list"
|
|
244
|
+
aria-controls={options.size ? listId : undefined}
|
|
245
|
+
aria-activedescendant={options.active ? btn_id(options.active[itemIdPropName]) : undefined}
|
|
246
|
+
placeholder={searchPlaceholder ?? t("search_placeholder")}
|
|
247
|
+
classInputBoxWrap={twMerge(
|
|
248
|
+
// always look like focused
|
|
249
|
+
`border-primary border-input-accent dark:border-input-accent-dark`,
|
|
250
|
+
`ring-input-accent/20 dark:ring-input-accent-dark/20 ring-4`
|
|
251
|
+
)}
|
|
252
|
+
onkeydown={(e) => {
|
|
253
|
+
if (e.key === "Enter") {
|
|
254
|
+
e.preventDefault();
|
|
255
|
+
submit();
|
|
256
|
+
}
|
|
257
|
+
}}
|
|
258
|
+
>
|
|
259
|
+
{#snippet inputBefore()}
|
|
260
|
+
<div class="flex flex-col items-center justify-center pl-3 opacity-75">
|
|
261
|
+
{@html iconSearch({ size: 19, strokeWidth: 3 })}
|
|
262
|
+
</div>
|
|
263
|
+
{/snippet}
|
|
264
|
+
{#snippet inputAfter()}
|
|
265
|
+
<div class="flex pl-2 items-center justify-center opacity-50">
|
|
266
|
+
{#if isFetching}
|
|
267
|
+
<Spinner class="w-4" />
|
|
268
|
+
{/if}
|
|
355
269
|
</div>
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
270
|
+
<div class="flex items-center justify-center">
|
|
271
|
+
<button
|
|
272
|
+
type="button"
|
|
273
|
+
class={twMerge(
|
|
274
|
+
"rounded m-1 opacity-75",
|
|
275
|
+
"hover:opacity-100 hover:bg-neutral-200 dark:hover:bg-neutral-800",
|
|
276
|
+
"focus-visible:opacity-100 focus-visible:outline-0",
|
|
277
|
+
"focus-visible:bg-neutral-200 dark:focus-visible:bg-neutral-800"
|
|
278
|
+
)}
|
|
279
|
+
onclick={(e) => {
|
|
280
|
+
e.preventDefault();
|
|
281
|
+
if (!`${q || ""}`.trim()) {
|
|
282
|
+
return modalDialog.close();
|
|
283
|
+
}
|
|
284
|
+
q = "";
|
|
285
|
+
input?.focus();
|
|
286
|
+
}}
|
|
287
|
+
>
|
|
288
|
+
<X class="m-2 size-6" />
|
|
289
|
+
</button>
|
|
290
|
+
</div>
|
|
291
|
+
{/snippet}
|
|
292
|
+
{#snippet inputBelow()}
|
|
293
|
+
<div class="sr-only" aria-live="polite" aria-atomic="true">
|
|
294
|
+
{#if options.size}
|
|
295
|
+
{options.size} results available
|
|
296
|
+
{/if}
|
|
297
|
+
</div>
|
|
298
|
+
{#if options.size}
|
|
299
|
+
<div
|
|
300
|
+
class={twMerge(
|
|
301
|
+
"options block space-y-1 p-1",
|
|
302
|
+
"overflow-y-auto overflow-x-hidden mb-1",
|
|
303
|
+
"border-t border-black/20",
|
|
304
|
+
"max-h-60"
|
|
305
|
+
)}
|
|
306
|
+
bind:this={optionsBox}
|
|
307
|
+
tabindex="-1"
|
|
308
|
+
role="listbox"
|
|
309
|
+
id={listId}
|
|
310
|
+
aria-label="Search results"
|
|
311
|
+
>
|
|
312
|
+
{#each _normalize_and_group_options(options.items) as [_optgroup, _opts]}
|
|
313
|
+
<!-- {console.log(11111, _optgroup, _opts)} -->
|
|
314
|
+
<div class="p-1">
|
|
315
|
+
{#if _optgroup}
|
|
316
|
+
<div
|
|
317
|
+
class="text-sm capitalize opacity-50 border-b border-black/10 mb-1 p-1"
|
|
318
|
+
>
|
|
319
|
+
{_optgroup}
|
|
320
|
+
</div>
|
|
321
|
+
{/if}
|
|
322
|
+
<ul role="presentation">
|
|
323
|
+
{#each _opts as item (item[itemIdPropName])}
|
|
324
|
+
{@const active =
|
|
325
|
+
item[itemIdPropName] === options.active?.[itemIdPropName]}
|
|
326
|
+
<!-- {@const isSelected = false} -->
|
|
327
|
+
<li class:active role="presentation">
|
|
328
|
+
<button
|
|
329
|
+
class:active
|
|
330
|
+
type="button"
|
|
331
|
+
role="option"
|
|
332
|
+
aria-selected={active}
|
|
333
|
+
class={twMerge(
|
|
334
|
+
"no-focus-visible",
|
|
335
|
+
"text-left rounded-md py-2 px-2.5",
|
|
336
|
+
"min-w-0 w-full overflow-hidden text-ellipsis whitespace-nowrap",
|
|
337
|
+
"border border-transparent",
|
|
338
|
+
"focus:outline-0 focus:border-neutral-400 dark:focus:border-neutral-500",
|
|
339
|
+
"focus-visible:outline-0 focus-visible:ring-0",
|
|
340
|
+
"hover:border-neutral-400 dark:hover:border-neutral-500",
|
|
341
|
+
active && "bg-neutral-200 dark:bg-neutral-800",
|
|
342
|
+
classOption,
|
|
343
|
+
active && classOptionActive
|
|
344
|
+
)}
|
|
345
|
+
id={btn_id(item[itemIdPropName])}
|
|
346
|
+
tabindex="-1"
|
|
347
|
+
onclick={() => {
|
|
348
|
+
_optionsColl.setActive(item);
|
|
349
|
+
submit();
|
|
350
|
+
}}
|
|
351
|
+
onkeydown={(e) => {
|
|
352
|
+
// need to handle tab here, because the tabindex="-1" is ignored
|
|
353
|
+
// in the focus-trap selectors... so, on Tab, manually focusin input
|
|
354
|
+
if (e.key === "Tab") {
|
|
355
|
+
e.preventDefault();
|
|
356
|
+
input?.focus();
|
|
357
|
+
}
|
|
358
|
+
}}
|
|
359
|
+
>
|
|
360
|
+
{_renderOptionLabel(item)}
|
|
361
|
+
</button>
|
|
362
|
+
</li>
|
|
363
|
+
{/each}
|
|
364
|
+
</ul>
|
|
365
|
+
</div>
|
|
366
|
+
{/each}
|
|
367
|
+
</div>
|
|
368
|
+
{/if}
|
|
369
|
+
{/snippet}
|
|
370
|
+
</FieldInput>
|
|
371
|
+
</form>
|
|
372
|
+
</div>
|
|
373
|
+
</ModalDialog>
|
|
361
374
|
|
|
362
375
|
<style>
|
|
363
376
|
.options {
|
|
364
377
|
scrollbar-width: thin;
|
|
365
378
|
}
|
|
379
|
+
.sr-only {
|
|
380
|
+
position: absolute;
|
|
381
|
+
width: 1px;
|
|
382
|
+
height: 1px;
|
|
383
|
+
padding: 0;
|
|
384
|
+
margin: -1px;
|
|
385
|
+
overflow: hidden;
|
|
386
|
+
clip: rect(0, 0, 0, 0);
|
|
387
|
+
white-space: nowrap;
|
|
388
|
+
border: 0;
|
|
389
|
+
}
|
|
366
390
|
</style>
|
|
@@ -87,6 +87,12 @@ A searchable command palette/menu modal for quick navigation and selection. Supp
|
|
|
87
87
|
/>
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
+
## Layout
|
|
91
|
+
|
|
92
|
+
The command menu uses `ModalDialog` internally with top-aligned positioning:
|
|
93
|
+
- **Mobile**: Input at top of screen with 1rem margins from edges
|
|
94
|
+
- **Desktop (md+)**: Input positioned at ~20% from top, max-width 768px, centered horizontally
|
|
95
|
+
|
|
90
96
|
## Keyboard Navigation
|
|
91
97
|
|
|
92
98
|
- **Arrow Up/Down**: Navigate options
|
|
@@ -241,7 +241,7 @@ interface DropdownMenuExpandableItem {
|
|
|
241
241
|
|
|
242
242
|
```svelte
|
|
243
243
|
<script lang="ts">
|
|
244
|
-
import {
|
|
244
|
+
import { Avatar, DropdownMenu } from 'stuic';
|
|
245
245
|
</script>
|
|
246
246
|
|
|
247
247
|
<DropdownMenu
|
|
@@ -252,7 +252,7 @@ interface DropdownMenuExpandableItem {
|
|
|
252
252
|
>
|
|
253
253
|
{#snippet trigger({ isOpen, toggle, triggerProps })}
|
|
254
254
|
<button {...triggerProps} onclick={toggle}>
|
|
255
|
-
<
|
|
255
|
+
<Avatar input="john.doe@example.com" autoColor />
|
|
256
256
|
</button>
|
|
257
257
|
{/snippet}
|
|
258
258
|
</DropdownMenu>
|