|
19 | 19 | import type { MenuOption } from '$lib/types/options';
|
20 | 20 | import type { ScrollIntoViewOptions } from '$lib/actions';
|
21 | 21 |
|
| 22 | + type LogReason<T extends Event = any> = { |
| 23 | + reason: string, |
| 24 | + event?: T |
| 25 | + } |
| 26 | +
|
22 | 27 | const dispatch = createEventDispatcher<{
|
23 | 28 | change: { value: any; option: any };
|
24 | 29 | inputChange: string;
|
|
51 | 56 | : undefined;
|
52 | 57 |
|
53 | 58 | let originalIcon = icon;
|
| 59 | + let toggleButtonElement: ComponentProps<Button>['element'] = undefined; |
| 60 | + let toggleButtonIconSpan: ComponentProps<Button>['iconElement'] = undefined; |
54 | 61 |
|
55 | 62 | export let scrollIntoView: Partial<ScrollIntoViewOptions> = {};
|
56 | 63 |
|
|
59 | 66 | field?: string | ComponentProps<TextField>['classes'];
|
60 | 67 | options?: string;
|
61 | 68 | option?: string;
|
| 69 | + optionIcon?: string; |
| 70 | + optionLoading?: string; |
62 | 71 | selected?: string;
|
63 | 72 | group?: string;
|
64 | 73 | empty?: string;
|
65 | 74 | } = {};
|
66 | 75 | const theme = getComponentTheme('SelectField');
|
67 | 76 |
|
68 |
| - let fieldClasses: ComponentProps<TextField>['classes']; |
69 |
| - $: fieldClasses = typeof(classes.field) === "string" ? { root: classes.field } : classes.field; |
| 77 | + let fieldClasses: Partial<ComponentProps<TextField>['classes']>; |
| 78 | + $: { |
| 79 | + fieldClasses = (typeof(classes.field) === "string" || classes.field == null) ? { root: classes.field } : classes.field; |
| 80 | + if (activeOptionIcon) |
| 81 | + console.log(fieldClasses); |
| 82 | + } |
70 | 83 |
|
71 | 84 | // Menu props
|
72 | 85 | export let placement: Placement = 'bottom-start';
|
|
194 | 207 | });
|
195 | 208 | }
|
196 | 209 |
|
| 210 | + function isToggleButtonClicked(ev: MouseEvent) { |
| 211 | + return toggleButtonIconSpan && toggleButtonIconSpan === ev.target; |
| 212 | + } |
| 213 | +
|
| 214 | + function isToggleButtonRelated(ev: MouseEvent|FocusEvent) { |
| 215 | + return toggleButtonElement && toggleButtonElement === ev.relatedTarget; |
| 216 | + } |
| 217 | +
|
197 | 218 | function onChange(e: ComponentEvents<TextField>['change']) {
|
198 | 219 | logger.debug('onChange');
|
199 | 220 |
|
200 | 221 | searchText = e.detail.inputValue as string;
|
201 | 222 | dispatch('inputChange', searchText);
|
202 |
| - show(); |
| 223 | + show({ reason: "onChange", event: e }); |
203 | 224 | }
|
204 | 225 |
|
205 |
| - function onFocus() { |
206 |
| - logger.debug('onFocus'); |
207 |
| - show(); |
| 226 | + function onFocus(event: FocusEvent) { |
| 227 | + if (isToggleButtonRelated(event)) { |
| 228 | + return; |
| 229 | + } |
| 230 | + show({ reason: "onFocus", event }); |
208 | 231 | }
|
209 | 232 |
|
210 | 233 | function onBlur(e: FocusEvent|CustomEvent<any>) {
|
|
216 | 239 | fe.relatedTarget instanceof HTMLElement &&
|
217 | 240 | !menuOptionsEl?.contains(fe.relatedTarget) && // TODO: Oddly Safari does not set `relatedTarget` to the clicked on menu option (like Chrome and Firefox) but instead appears to take `tabindex` into consideration. Currently resolves to `.options` after setting `tabindex="-1"
|
218 | 241 | fe.relatedTarget !== menuOptionsEl?.offsetParent && // click on scroll bar
|
219 |
| - !fe.relatedTarget.closest('menu > [slot=actions]') // click on action item |
| 242 | + !fe.relatedTarget.closest('menu > [slot=actions]') && // click on action item |
| 243 | + !isToggleButtonRelated(fe) // click on toggle button |
220 | 244 | ) {
|
221 |
| - hide('blur'); |
| 245 | + hide({ reason: 'blur', event: e }); |
222 | 246 | } else {
|
223 | 247 | logger.debug('ignoring blur');
|
224 | 248 | }
|
|
237 | 261 | break;
|
238 | 262 |
|
239 | 263 | case 'ArrowDown':
|
240 |
| - show(); |
| 264 | + show({ reason: `onKeyDown: '${e.key}'`, event: e }); |
241 | 265 | if (highlightIndex < filteredOptions.length - 1) {
|
242 | 266 | highlightIndex++;
|
243 | 267 | } else {
|
|
247 | 271 | break;
|
248 | 272 |
|
249 | 273 | case 'ArrowUp':
|
250 |
| - show(); |
| 274 | + show({ reason: `onKeyDown: '${e.key}'`, event: e }); |
251 | 275 | if (highlightIndex > 0) {
|
252 | 276 | highlightIndex--;
|
253 | 277 | } else {
|
|
259 | 283 | case 'Escape':
|
260 | 284 | if (open) {
|
261 | 285 | inputEl?.focus();
|
262 |
| - hide('escape'); |
| 286 | + hide({ reason: 'escape', event: e }); |
263 | 287 | }
|
264 | 288 | break;
|
265 | 289 | }
|
|
274 | 298 | }
|
275 | 299 | }
|
276 | 300 |
|
277 |
| - function onClick() { |
278 |
| - logger.debug('onClick'); |
279 |
| - show(); |
| 301 | + function onClick(event: MouseEvent) { |
| 302 | + if (isToggleButtonClicked(event) || isToggleButtonRelated(event)) { |
| 303 | + return; |
| 304 | + } |
| 305 | + show({ reason: 'onClick', event }); |
280 | 306 | }
|
281 | 307 |
|
282 |
| - function show() { |
283 |
| - logger.debug('show'); |
| 308 | + function show<T extends LogReason = any>(reason: string|T = '') { |
| 309 | + const doShow = !disabled && !readonly; |
| 310 | + logger.debug('show', { ...(typeof(reason) === "string" ? { reason } : reason), openBefore: open, openAfter: doShow }); |
284 | 311 |
|
285 |
| - if (!disabled && !readonly) { |
| 312 | + if (doShow) { |
286 | 313 | if (open === false && clearSearchOnOpen) {
|
287 | 314 | searchText = ''; // Show all options on open
|
288 | 315 | }
|
|
291 | 318 | }
|
292 | 319 | }
|
293 | 320 |
|
294 |
| - function hide(reason = '') { |
295 |
| - logger.debug('hide', { reason }); |
| 321 | + function hide<T extends LogReason = any>(reason: string|T = '') { |
| 322 | + logger.debug('hide', { ...(typeof(reason) === "string" ? { reason } : reason), openBefore: open, openAfter: false }); |
296 | 323 | open = false;
|
297 | 324 | highlightIndex = -1;
|
298 | 325 | }
|
|
384 | 411 | on:keydown={onKeyDown}
|
385 | 412 | on:keypress={onKeyPress}
|
386 | 413 | actions={fieldActions}
|
387 |
| - classes={{ container: inlineOptions ? 'border-none shadow-none hover:shadow-none group-focus-within:shadow-none' : undefined }} |
388 |
| - class={cls('h-full', theme.field, fieldClasses)} |
| 414 | + classes={{ ...(fieldClasses ?? {}), container: inlineOptions ? 'border-none shadow-none hover:shadow-none group-focus-within:shadow-none' : undefined }} |
| 415 | + class={cls('h-full', theme.field)} |
389 | 416 | role="combobox"
|
390 | 417 | aria-expanded={open ? "true" : "false"}
|
391 | 418 | aria-autocomplete={!inlineOptions ? "list" : undefined}
|
|
417 | 444 | icon={toggleIcon}
|
418 | 445 | class="text-black/50 p-1 transform {open ? 'rotate-180' : ''}"
|
419 | 446 | tabindex="-1"
|
420 |
| - on:click={() => {logger.debug("toggleIcon clicked")}} |
| 447 | + bind:element={toggleButtonElement} |
| 448 | + bind:iconElement={toggleButtonIconSpan} |
| 449 | + on:click={(e) => { |
| 450 | + logger.debug("toggleIcon clicked", { event: e, open }) |
| 451 | + const func = !open ? show : hide; |
| 452 | + func({ reason: "toggleIcon", event: e }); |
| 453 | + }} |
421 | 454 | />
|
422 | 455 | {/if}
|
423 | 456 | </span>
|
|
434 | 467 | {disableTransition}
|
435 | 468 | moveFocus={false}
|
436 | 469 | bind:open
|
437 |
| - on:close={() => hide('menu on:close')} |
| 470 | + on:close={e => hide({ reason: 'menu on:close', event: e})} |
438 | 471 | {...menuProps}
|
439 | 472 | >
|
440 | 473 | <!-- TODO: Rework into hierarchy of snippets in v2.0 -->
|
|
447 | 480 | <svelte:fragment slot="option" let:option let:index>
|
448 | 481 | <slot name="option" {option} {index} {selected} {value} {highlightIndex}>
|
449 | 482 | <MenuItem
|
| 483 | + classes={{ icon: classes.optionIcon, loading: classes.optionLoading }} |
450 | 484 | class={cls(
|
451 | 485 | index === highlightIndex && '[:not(.group:hover)>&]:bg-black/5',
|
452 | 486 | option === selected && (classes.selected || 'font-semibold'),
|
453 | 487 | option.group ? 'px-4' : 'px-2',
|
454 | 488 | theme.option,
|
455 | 489 | classes.option
|
456 | 490 | )}
|
| 491 | + icon={option.icon} |
457 | 492 | scrollIntoView={{ condition: index === highlightIndex, onlyIfNeeded: inlineOptions, ...scrollIntoView }}
|
458 | 493 | role="option"
|
459 | 494 | aria-selected={option === selected ? "true" : "false"}
|
|
485 | 520 | <svelte:fragment slot="option" let:option let:index>
|
486 | 521 | <slot name="option" {option} {index} {selected} {value} {highlightIndex}>
|
487 | 522 | <MenuItem
|
| 523 | + classes={{ icon: classes.optionIcon, loading: classes.optionLoading }} |
488 | 524 | class={cls(
|
489 | 525 | index === highlightIndex && '[:not(.group:hover)>&]:bg-black/5',
|
490 | 526 | option === selected && (classes.selected || 'font-semibold'),
|
491 | 527 | option.group ? 'px-4' : 'px-2',
|
492 | 528 | theme.option,
|
493 | 529 | classes.option
|
494 | 530 | )}
|
| 531 | + icon={option.icon} |
495 | 532 | scrollIntoView={{ condition: index === highlightIndex, onlyIfNeeded: inlineOptions, ...scrollIntoView }}
|
496 | 533 | role="option"
|
497 | 534 | aria-selected={option === selected ? "true" : "false"}
|
|
0 commit comments