Skip to content

Commit

Permalink
Replace SelectList with SelectField.
Browse files Browse the repository at this point in the history
- Updates QuickSearch to use SelectField.
  • Loading branch information
willnationsdev committed Dec 10, 2023
1 parent e65d5e2 commit ff1131f
Show file tree
Hide file tree
Showing 7 changed files with 222 additions and 513 deletions.
5 changes: 5 additions & 0 deletions .changeset/quick-avocados-hope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte-ux': minor
---

Removes SelectList. Updates SelectField features to support SelectList's use case via property/attribute overrides. Updates QuickSearch to use SelectField.
13 changes: 9 additions & 4 deletions packages/svelte-ux/src/lib/components/QuickSearch.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
import Button from '$lib/components/Button.svelte';
import Dialog from '$lib/components/Dialog.svelte';
import SelectList from '$lib/components/SelectList.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import { getComponentTheme } from './theme';
import { cls } from '$lib/utils/styles';
import { smScreen } from '$lib/stores';
import { autoFocus, selectOnFocus } from '$lib/actions';
export let options: { name: string; value: string; group?: string }[] = [];
Expand All @@ -18,10 +19,11 @@
let open = false;
let fieldActions = (node: any) => [autoFocus(node), selectOnFocus(node)];
/*
TODO:
- [ ] Sticky search
- [ ] Refine SelectList / reuse with SelectField (and maybe MultiSelect)
- [ ] Improve size of Dialog (move class to Dialog without breaking overflow)
- [ ] Load descriptions/meta from +page.ts
- [ ] Improve dialog positioning on small viewports (consistent top/bottom with max height)
Expand Down Expand Up @@ -63,12 +65,15 @@
}}
>
<div class="overflow-auto max-h-[min(90dvh,600px)] min-w-[400px] py-1">
<SelectList
<SelectField
icon={mdiMagnify}
placeholder="Search..."
hideToggleIcon={true}
inlineOptions={true}
{options}
{fieldActions}
on:change
on:change={(e) => (open = false)}
on:change={() => (open = false)}
classes={{
field: {
container: 'border-none hover:shadow-none group-focus-within:shadow-none',
Expand Down
196 changes: 111 additions & 85 deletions packages/svelte-ux/src/lib/components/SelectField.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@
import { mdiChevronDown, mdiClose } from '@mdi/js';
import Logger from '../utils/logger';
import { selectOnFocus } from '../actions/input';
import { autoFocus, selectOnFocus } from '../actions/input';
import { cls } from '../utils/styles';
import Button from './Button.svelte';
import ProgressCircle from './ProgressCircle.svelte';
import Menu from './Menu.svelte';
import MenuItem from './MenuItem.svelte';
import SelectListOptions from './_SelectListOptions.svelte';
import TextField from './TextField.svelte';
import { getComponentTheme } from './theme';
import type { IconInput } from '$lib/utils/icons';
const dispatch = createEventDispatcher<{
change: { value: any; option: any };
Expand All @@ -31,16 +33,25 @@
export let loading: boolean = false;
export let disabled: boolean = false;
export let readonly: boolean = false;
export let icon: string | null = null;
export let icon: IconInput = undefined;
export let toggleIcon: IconInput = mdiChevronDown;
export let closeIcon: IconInput = mdiClose;
export let clearable = true;
export let base = false;
export let rounded = false;
export let dense = false;
export let clearSearchOnOpen = true;
export let tabindex = 0;
export let autofocus: ComponentProps<TextField>['autofocus'] = undefined;
export let fieldActions: ComponentProps<TextField>['actions'] = autofocus
? (node) => [autoFocus(node, typeof autofocus === 'object' ? autofocus : undefined), selectOnFocus(node)]
: undefined;
export let hideToggleIcon = false;
export let classes: {
root?: string;
field?: string;
field?: string | ComponentProps<TextField>['classes'];
options?: string;
option?: string;
selected?: string;
Expand All @@ -49,13 +60,17 @@
} = {};
const theme = getComponentTheme('SelectField');
let fieldClasses: ComponentProps<TextField>['classes'];
$: fieldClasses = typeof(classes.field) === "string" ? { root: classes.field } : classes.field;
// Menu props
export let placement: Placement = 'bottom-start';
export let autoPlacement = true;
export let matchWidth = true;
export let resize = true;
export let disableTransition = false;
export let menuProps: ComponentProps<Menu> | undefined = undefined;
export let inlineOptions = false;
$: filteredOptions = options ?? [];
let searchText = '';
Expand Down Expand Up @@ -331,12 +346,15 @@
logger.info('clear');
selectOption(null);
filteredOptions = options;
//inputEl?.focus();
}
</script>

<!-- svelte-ignore a11y-click-events-have-key-events -->
<div role="button" tabindex="-1" aria-pressed={open ? "true" : "false"} class={cls('SelectField', theme.root, classes.root, $$props.class)} on:click={onClick}>
<button
aria-haspopup={!inlineOptions ? "listbox" : undefined}
class={cls('SelectField block w-full cursor-default', theme.root, classes.root, $$props.class)}
on:click={onClick}>

<TextField
{label}
{placeholder}
Expand All @@ -352,10 +370,12 @@
on:blur={onBlur}
on:keydown={onKeyDown}
on:keypress={onKeyPress}
actions={(node) => [selectOnFocus(node)]}
class={cls('h-full', theme.field, classes.field)}
actions={fieldActions}
classes={{ container: inlineOptions ? 'border-none shadow-none hover:shadow-none group-focus-within:shadow-none' : undefined }}
class={cls('h-full', theme.field, fieldClasses)}
role="combobox"
aria-expanded={open ? "true" : "false"}
aria-autocomplete={!inlineOptions ? "list" : undefined}
{...$$restProps}
>
<slot slot="prepend" name="prepend" />
Expand All @@ -371,104 +391,110 @@
<!-- Do not show chevron or clear buttons -->
{:else if value && clearable}
<Button
icon={mdiClose}
icon={closeIcon}
class="text-black/50 p-1"
on:click={(e) => {
e.stopPropagation();
logger.debug("closeIcon clicked");
clear();
}}
/>
{:else}
{:else if !hideToggleIcon}
<Button
icon={mdiChevronDown}
icon={toggleIcon}
class="text-black/50 p-1 transform {open ? 'rotate-180' : ''}"
tabindex="-1"
on:click={() => {logger.debug("toggleIcon clicked")}}
/>
{/if}
</span>
</TextField>

<!-- Improve initial open display, still needs work when switching from No options found (options.length === 0) -->
{#if options?.length > 0 || loading !== true}
<Menu
{placement}
{autoPlacement}
{matchWidth}
{resize}
{disableTransition}
moveFocus={false}
bind:open
on:close={() => hide('menu on:close')}
{...menuProps}
>
<div
role="listbox"
tabindex="-1"
aria-expanded={open ? "true" : "false"}
class={cls('options group p-1 focus:outline-none', theme.options, classes.options)}
class:opacity-50={loading}
bind:this={menuOptionsEl}
on:click|stopPropagation={(e) => {
logger.debug('options container clicked');

if (e.target instanceof HTMLElement) {
// Find slot parent of click target option, fallback to `e.target` if slot is not overridden
// Use `.options > ` in case slot is nested (ex. GraphQLSelect with slot)
const slotEl = e.target.closest('.options > [slot=option]') ?? e.target;
// Find the index of the clicked on element (ignoring group headers)
const optionIndex = slotEl
? [...menuOptionsEl.children]
.filter((el) => !el.classList.contains('group-header'))
.indexOf(slotEl)
: -1;
logger.debug({ slotEl, optionIndex });
// ignore clicks on group options
if (optionIndex !== -1) {
selectIndex(optionIndex);
}
}
}}
>
{#each filteredOptions ?? [] as option, index (optionValue(option))}
{@const previousOption = filteredOptions[index - 1]}
{#if option.group && option.group !== previousOption?.group}
<div
class={cls(
'group-header text-xs leading-8 tracking-widest text-black/50 px-2',
theme.group,
classes.group
)}
>
{option.group}
{#if !inlineOptions}
<Menu
{placement}
{autoPlacement}
{matchWidth}
{resize}
{disableTransition}
moveFocus={false}
bind:open
on:close={() => hide('menu on:close')}
{...menuProps}
>
<!-- TODO: Rework into hierarchy of snippets in v2.0 -->
<SelectListOptions
bind:menuOptionsEl
{open} {loading} {highlightIndex} {searchText} {filteredOptions}
classes={{...classes, root: classes.options}}
{optionText} {optionValue} {selectIndex} {selectOption} {onKeyPress} {onKeyDown}>

<svelte:fragment slot="option" let:option let:index>
<slot name="option" {option} {index} {selected} {value} {highlightIndex}>
<MenuItem
class={cls(
index === highlightIndex && '[:not(.group:hover)>&]:bg-black/5',
option === selected && (classes.selected || 'font-semibold'),
option.group ? 'px-4' : 'px-2',
theme.option,
classes.option
)}
scrollIntoView={index === highlightIndex}
role="option"
aria-selected={option === selected ? "true" : "false"}
aria-disabled={option?.disabled ? "true" : "false"}
>
{optionText(option)}
</MenuItem>
</slot>
</svelte:fragment>

<slot name="empty" slot="empty" let:loading>
<div class={cls('p-3 text-black/50 italic text-sm', theme.empty, classes.empty)}>
{loading ? 'Loading...' : 'No options found'}
</div>
{/if}

<slot name="option" {option} {index} {selected} {value} {highlightIndex}>
<MenuItem
class={cls(
index === highlightIndex && '[:not(.group:hover)>&]:bg-black/5',
option === selected && (classes.selected || 'font-semibold'),
option.group ? 'px-4' : 'px-2',
theme.option,
classes.option
)}
scrollIntoView={index === highlightIndex}
role="option"
aria-selected={option === selected ? "true" : "false"}
aria-disabled={option?.disabled ? "true" : "false"}
>
{optionText(option)}
</MenuItem>
</slot>
{:else}
<slot name="empty" {loading} {searchText}>
</SelectListOptions>

<slot name="actions" {hide} />
</Menu>
{:else}
<!-- TODO: Rework into hierarchy of snippets in v2.0. -->
<!-- This code must be identical to the above block -->
<SelectListOptions
bind:menuOptionsEl
{open} {loading} {highlightIndex} {searchText} {filteredOptions}
classes={{...classes, root: classes.options}}
{optionText} {optionValue} {selectIndex} {selectOption} {onKeyPress} {onKeyDown}>

<svelte:fragment slot="option" let:option let:index>
<slot name="option" {option} {index} {selected} {value} {highlightIndex}>
<MenuItem
class={cls(
index === highlightIndex && '[:not(.group:hover)>&]:bg-black/5',
option === selected && (classes.selected || 'font-semibold'),
option.group ? 'px-4' : 'px-2',
theme.option,
classes.option
)}
scrollIntoView={index === highlightIndex}
role="option"
aria-selected={option === selected ? "true" : "false"}
aria-disabled={option?.disabled ? "true" : "false"}
>
{optionText(option)}
</MenuItem>
</slot>
</svelte:fragment>

<slot name="empty" slot="empty" let:loading>
<div class={cls('p-3 text-black/50 italic text-sm', theme.empty, classes.empty)}>
{loading ? 'Loading...' : 'No options found'}
</div>
</slot>
{/each}
</div>
<slot name="actions" {hide} />
</Menu>
</SelectListOptions>
{/if}
{/if}
</div>
</button>
Loading

0 comments on commit ff1131f

Please sign in to comment.