From 287f6d4dfa80d8417e214baccbfade01f2f641e9 Mon Sep 17 00:00:00 2001 From: willnationsdev Date: Wed, 8 Nov 2023 00:33:18 -0600 Subject: [PATCH] Replace SelectList with SelectField w/ MenuField API sync. - Updates SelectField to support the same features as SelectList. - Updates QuickSearch to use SelectField. - Declared MenuOption & moved to types folder. - Updated MenuField/SelectField to reference MenuOption. - Switched to use of 'label' instead of 'name' for options. - Added `activeOptionIcon` to SelectField. --- .changeset/quick-avocados-hope.md | 5 + packages/svelte-ux/src/lib/actions/scroll.ts | 7 +- .../svelte-ux/src/lib/components/Menu.svelte | 2 +- .../src/lib/components/MenuField.svelte | 11 +- .../src/lib/components/MenuItem.svelte | 9 +- .../src/lib/components/QuickSearch.svelte | 16 +- .../src/lib/components/SelectField.svelte | 251 ++++++----- .../src/lib/components/SelectList.svelte | 420 ------------------ .../lib/components/_SelectListOptions.svelte | 92 ++++ .../svelte-ux/src/lib/components/index.ts | 1 - .../svelte-ux/src/lib/stores/formStore.ts | 27 +- packages/svelte-ux/src/lib/types/options.ts | 7 + packages/svelte-ux/src/lib/utils/dom.ts | 4 +- packages/svelte-ux/src/lib/utils/styles.ts | 8 +- packages/svelte-ux/src/routes/+layout.svelte | 2 +- .../docs/components/SelectField/+page.svelte | 85 ++-- 16 files changed, 347 insertions(+), 600 deletions(-) create mode 100644 .changeset/quick-avocados-hope.md delete mode 100644 packages/svelte-ux/src/lib/components/SelectList.svelte create mode 100644 packages/svelte-ux/src/lib/components/_SelectListOptions.svelte create mode 100644 packages/svelte-ux/src/lib/types/options.ts diff --git a/.changeset/quick-avocados-hope.md b/.changeset/quick-avocados-hope.md new file mode 100644 index 000000000..3b6ba6138 --- /dev/null +++ b/.changeset/quick-avocados-hope.md @@ -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. Defines MenuOption type & updates MenuField & SelectField to use it; this results in renaming of SelectField options' `name` field to become `label`, standardizing the API across the two. Also adds `activeOptionIcon` to SelectField so users can opt-in to dynamically updating the field icon based on the selected option. Also fixed a bug with the `scrollIntoView` action related to its `onlyIfNeeded` flag. diff --git a/packages/svelte-ux/src/lib/actions/scroll.ts b/packages/svelte-ux/src/lib/actions/scroll.ts index d33e86407..1ad61b630 100644 --- a/packages/svelte-ux/src/lib/actions/scroll.ts +++ b/packages/svelte-ux/src/lib/actions/scroll.ts @@ -2,7 +2,7 @@ import type { Action, ActionReturn } from 'svelte/action'; import { isVisibleInScrollParent, scrollIntoView as scrollIntoViewUtil } from '$lib/utils/dom'; import type { EventWithTarget } from '$lib/types'; -type ScrollIntoViewOptions = { +export type ScrollIntoViewOptions = { condition: boolean | ((node: HTMLElement) => boolean); /** Only scroll if needed (not visible in scroll parent). Similar to non-standard `scrollIntoViewIfNeeded()` */ onlyIfNeeded?: boolean; @@ -18,10 +18,7 @@ export const scrollIntoView: Action; - - export let options: Options; + export let options: MenuOption[] = []; export let value: any = null; export let menuProps: ComponentProps | undefined = { autoPlacement: true, @@ -59,6 +58,10 @@ const dispatch = createEventDispatcher(); $: dispatch('change', { value }); + + function setValue(val: any): void { + value = val; + } - (open = false)} setValue={(val) => (value = val)}> + (open = false)} {setValue}> {#each options as option, index (option.value)} {@const previousOption = options[index - 1]} diff --git a/packages/svelte-ux/src/lib/components/MenuItem.svelte b/packages/svelte-ux/src/lib/components/MenuItem.svelte index d5e0d729a..befcad634 100644 --- a/packages/svelte-ux/src/lib/components/MenuItem.svelte +++ b/packages/svelte-ux/src/lib/components/MenuItem.svelte @@ -2,7 +2,7 @@ import Button from './Button.svelte'; import type { ComponentProps } from '../types'; import { cls } from '../utils/styles'; - import { scrollIntoView as scrollIntoViewAction } from '../actions/scroll'; + import { scrollIntoView as scrollIntoViewAction, type ScrollIntoViewOptions } from '../actions/scroll'; import { setButtonGroup } from './ButtonGroup.svelte'; import { getComponentTheme } from './theme'; import { settings, getSettings } from './settings'; @@ -10,7 +10,7 @@ type ButtonProps = ComponentProps diff --git a/packages/svelte-ux/src/lib/components/SelectList.svelte b/packages/svelte-ux/src/lib/components/SelectList.svelte deleted file mode 100644 index fe970e716..000000000 --- a/packages/svelte-ux/src/lib/components/SelectList.svelte +++ /dev/null @@ -1,420 +0,0 @@ - - - -
- [autoFocus(node), selectOnFocus(node)]} - class={cls('h-full')} - classes={{ ...theme.field, ...classes.field }} - {...$$restProps} - > - - - - - - {#if loading} - - - - {:else if readonly} - - {:else if value && clearable} -
diff --git a/packages/svelte-ux/src/lib/components/_SelectListOptions.svelte b/packages/svelte-ux/src/lib/components/_SelectListOptions.svelte new file mode 100644 index 000000000..e8106dff3 --- /dev/null +++ b/packages/svelte-ux/src/lib/components/_SelectListOptions.svelte @@ -0,0 +1,92 @@ + + +
{ + 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); + } + } + }} + on:keydown={e => { + logger.debug('keydown: calling given onKeyDown...'); + onKeyDown(e); + }} + on:keypress={e => { + logger.debug('keypress: calling given onKeyPress...'); + onKeyPress(e); + }} +> + {#each filteredOptions ?? [] as option, index (optionValue(option))} + {@const previousOption = filteredOptions[index - 1]} + {#if option.group && option.group !== previousOption?.group} +
+ {option.group} +
+ {/if} + + + {:else} + + {/each} +
\ No newline at end of file diff --git a/packages/svelte-ux/src/lib/components/index.ts b/packages/svelte-ux/src/lib/components/index.ts index 82ecd151d..2db586d8c 100644 --- a/packages/svelte-ux/src/lib/components/index.ts +++ b/packages/svelte-ux/src/lib/components/index.ts @@ -68,7 +68,6 @@ export { default as ScrollingValue } from './ScrollingValue.svelte'; export { default as SectionDivider } from './SectionDivider.svelte'; export { default as Selection } from './Selection.svelte'; export { default as SelectField } from './SelectField.svelte'; -export { default as SelectList } from './SelectList.svelte'; export { default as Settings } from './Settings.svelte'; export { default as Shine } from './Shine.svelte'; export { default as SpringValue } from './SpringValue.svelte'; diff --git a/packages/svelte-ux/src/lib/stores/formStore.ts b/packages/svelte-ux/src/lib/stores/formStore.ts index b144ffa3b..34a729b92 100644 --- a/packages/svelte-ux/src/lib/stores/formStore.ts +++ b/packages/svelte-ux/src/lib/stores/formStore.ts @@ -6,6 +6,8 @@ import { enablePatches, setAutoFreeze, current, + type Objectish, + type Patch, } from 'immer'; import type { Schema } from 'zod'; import { set } from 'lodash-es'; @@ -20,20 +22,20 @@ type FormStoreOptions = { schema?: Schema; }; -export default function formStore(initialState: T, options?: FormStoreOptions) { +export default function formStore(initialState: T, options?: FormStoreOptions) { const stateStore = writable(initialState); const draftStore = writable(createDraft(initialState)); const errorsStore = writable({} as { [key: string]: string }); // TODO: Improve type (`{ [key in keyof T]: string }`?) - const undoList = []; + const undoList: Patch[][] = []; const storeApi = { subscribe: stateStore.subscribe }; - let currentDraftValue = writable(current(get(draftStore))); + let currentDraftValue = writable(current(get(draftStore)) as T); const draftApi = { ...draftStore, - set(newState) { + set(newState: T) { draftStore.set(createDraft(newState)); }, /** Apply draft to state after verifying with schema (if available). Append change to undo stack */ @@ -79,20 +81,19 @@ export default function formStore(initialState: T, options?: FormStoreO }, /** Undo last committed change */ undo() { - if (undoList.length) { - const undo = undoList.pop(); + const undo = undoList.pop(); + if (undo == null) return; - const currentState = get(stateStore); - const newState = applyPatches(currentState, undo); + const currentState = get(stateStore); + const newState = applyPatches(currentState, undo); - stateStore.set(newState); - draftStore.set(createDraft(newState)); - currentDraftValue.set(newState); - } + stateStore.set(newState); + draftStore.set(createDraft(newState)); + currentDraftValue.set(newState); }, /** Refresh `current` draft value (un-proxied) */ refresh() { - currentDraftValue.set(current(get(draftStore))); + currentDraftValue.set(current(get(draftStore)) as T); }, current: currentDraftValue, }; diff --git a/packages/svelte-ux/src/lib/types/options.ts b/packages/svelte-ux/src/lib/types/options.ts new file mode 100644 index 000000000..f70cce646 --- /dev/null +++ b/packages/svelte-ux/src/lib/types/options.ts @@ -0,0 +1,7 @@ + +export type MenuOption = { + label: string; + value: any; + icon?: string; + group?: string +} diff --git a/packages/svelte-ux/src/lib/utils/dom.ts b/packages/svelte-ux/src/lib/utils/dom.ts index 25f8001c2..c34a33d98 100644 --- a/packages/svelte-ux/src/lib/utils/dom.ts +++ b/packages/svelte-ux/src/lib/utils/dom.ts @@ -7,12 +7,12 @@ export function getScrollParent(node: HTMLElement): HTMLElement { const isElement = node instanceof HTMLElement; const overflowX = isElement ? window?.getComputedStyle(node).overflowX ?? 'visible' : 'unknown'; const overflowY = isElement ? window?.getComputedStyle(node).overflowY ?? 'visible' : 'unknown'; - const isHorozontalScrollable = + const isHorizontalScrollable = !['visible', 'hidden'].includes(overflowX) && node.scrollWidth > node.clientWidth; const isVerticalScrollable = !['visible', 'hidden'].includes(overflowY) && node.scrollHeight > node.clientHeight; - if (isHorozontalScrollable || isVerticalScrollable) { + if (isHorizontalScrollable || isVerticalScrollable) { return node; } else if (node.parentElement) { return getScrollParent(node.parentElement); diff --git a/packages/svelte-ux/src/lib/utils/styles.ts b/packages/svelte-ux/src/lib/utils/styles.ts index 316c78b92..dc550a385 100644 --- a/packages/svelte-ux/src/lib/utils/styles.ts +++ b/packages/svelte-ux/src/lib/utils/styles.ts @@ -23,9 +23,11 @@ export function objectToString(styleObj: { [key: string]: string }) { * Wrapper around `tailwind-merge` and `clsx` */ const twMerge = extendTailwindMerge({ - classGroups: { - shadow: ['shadow-border-l', 'shadow-border-r', 'shadow-border-t', 'shadow-border-b'], - }, + extend: { + classGroups: { + shadow: ['shadow-border-l', 'shadow-border-r', 'shadow-border-t', 'shadow-border-b'], + }, + } }); export const cls = (...inputs: ClassValue[]) => twMerge(clsx(...inputs)); diff --git a/packages/svelte-ux/src/routes/+layout.svelte b/packages/svelte-ux/src/routes/+layout.svelte index ee0a8597b..ef9a28210 100644 --- a/packages/svelte-ux/src/routes/+layout.svelte +++ b/packages/svelte-ux/src/routes/+layout.svelte @@ -43,7 +43,7 @@ const url = file.replace('.', '').replace(/\/\+page.(md|svelte)/, ''); const [_, docs, group, name] = url.split('/'); return { - name, + label: name, value: url, group: group, }; diff --git a/packages/svelte-ux/src/routes/docs/components/SelectField/+page.svelte b/packages/svelte-ux/src/routes/docs/components/SelectField/+page.svelte index 650926087..21fc6a4ab 100644 --- a/packages/svelte-ux/src/routes/docs/components/SelectField/+page.svelte +++ b/packages/svelte-ux/src/routes/docs/components/SelectField/+page.svelte @@ -14,37 +14,40 @@ import { delay } from '$lib/utils/promise'; import { cls } from '$lib/utils/styles'; import Icon from '$lib/components/Icon.svelte'; + import type { MenuOption } from '$lib/types/options'; - let options = [ - { name: 'One', value: 1, icon: mdiMagnify }, - { name: 'Two', value: 2, icon: mdiPlus }, - { name: 'Three', value: 3, icon: mdiPencil }, - { name: 'Four', value: 4, icon: mdiAccount }, + let options: MenuOption[] = [ + { label: 'One', value: 1, icon: mdiMagnify }, + { label: 'Two', value: 2, icon: mdiPlus }, + { label: 'Three', value: 3, icon: mdiPencil }, + { label: 'Four', value: 4, icon: mdiAccount }, ]; - const optionsWithGroup = [ - { name: 'One', value: 1, group: 'First' }, - { name: 'Two', value: 2, group: 'First' }, - { name: 'Three', value: 3, group: 'Second' }, - { name: 'Four', value: 4, group: 'Second' }, - { name: 'Five', value: 5, group: 'Second' }, - { name: 'Six', value: 6, group: 'Third' }, - { name: 'Seven', value: 7, group: 'Third' }, + const optionsWithGroup: MenuOption[] = [ + { label: 'One', value: 1, group: 'First' }, + { label: 'Two', value: 2, group: 'First' }, + { label: 'Three', value: 3, group: 'Second' }, + { label: 'Four', value: 4, group: 'Second' }, + { label: 'Five', value: 5, group: 'Second' }, + { label: 'Six', value: 6, group: 'Third' }, + { label: 'Seven', value: 7, group: 'Third' }, ]; - const manyOptions = Array.from({ length: 100 }).map((_, i) => ({ - name: `${i + 1}`, + const manyOptions: MenuOption[] = Array.from({ length: 100 }).map((_, i) => ({ + label: `${i + 1}`, value: i + 1, })); - const newOptions = [ - { name: 'Empty', value: null }, - { name: 'Foo', value: 1 }, - { name: 'Bar', value: 2 }, - { name: 'Baz', value: 3 }, + const newOptions: MenuOption[] = [ + { label: 'Empty', value: null }, + { label: 'Foo', value: 1 }, + { label: 'Bar', value: 2 }, + { label: 'Baz', value: 3 }, ]; - let optionsAsync: { name: string; value: number }[] = []; + const newOption: () => MenuOption = () => { return { label: "", value: null }} + + let optionsAsync: MenuOption[] = []; let loading = false; let value = 3; @@ -158,7 +161,7 @@ scrollIntoView={index === highlightIndex} >
-
{option.name}
+
{option.label}
{option.value}
@@ -166,13 +169,13 @@ -

option slot with icon

+

option slot with icon (field icon updates based on selected option)

c.value === value)?.icon} + activeOptionIcon={true} on:change={(e) => console.log('on:change', e.detail)} >
@@ -185,7 +188,7 @@ scrollIntoView={index === highlightIndex} icon={{ data: option.icon, style: 'color: #0000FF;' }} > - {option.name} + {option.label}
@@ -206,7 +209,7 @@ >
-
{option.name}
+
{option.label}
{option.value}
@@ -218,7 +221,7 @@ />
- Editing option: {option.name} + Editing option: {option.label}
{ options = [e.detail, ...options]; }} @@ -279,10 +282,10 @@
Create new option
{ - draft.name = e.detail.value; + draft.label = e.detail.value; }} autofocus /> @@ -311,11 +314,12 @@ { options = [e.detail, ...options]; }} let:draft + let:current let:commit let:revert > @@ -329,10 +333,10 @@
Create new option
{ - draft.name = e.detail.value; + draft.label = e.detail.value; }} autofocus /> @@ -428,3 +432,14 @@ classes={{ selected: 'bg-accent-500 text-white' }} /> + +

Inline options with icon (used by search bar dialog in top-right)

+ + + +