diff --git a/demo/components/DemoButtons.svelte b/demo/components/DemoButtons.svelte index a20a1d5..2ca277a 100644 --- a/demo/components/DemoButtons.svelte +++ b/demo/components/DemoButtons.svelte @@ -14,7 +14,7 @@ import NeoRadioButton from '~/buttons/NeoRadioButton.svelte'; import NeoSwitchButton from '~/buttons/NeoSwitchButton.svelte'; import IconAccount from '~/icons/IconAccount.svelte'; - import NeoNativeSelect from '~/inputs/NeoNativeSelect.svelte'; + import NeoSelect from '~/inputs/NeoSelect.svelte'; import { Colors } from '~/utils/colors.utils'; const { onClick, loading: isLoading, onLoading } = useButtonState('DemoButtonClick'); @@ -87,20 +87,15 @@ Skeleton - diff --git a/demo/components/DemoInputs.svelte b/demo/components/DemoInputs.svelte index 185aff3..0888b6d 100644 --- a/demo/components/DemoInputs.svelte +++ b/demo/components/DemoInputs.svelte @@ -6,6 +6,7 @@ import type { NeoFilePickerProps } from '~/inputs/neo-file-picker.model.js'; import type { NeoRangeHTMLElement } from '~/inputs/neo-range.model.js'; + import type { NeoNativeSelectOption } from '~/inputs/neo-select.model.js'; import type { NeoListItem } from '~/list/neo-list.model.js'; import NeoButton from '~/buttons/NeoButton.svelte'; @@ -481,6 +482,12 @@ }, ]; + const nativeSelectOptions: NeoNativeSelectOption[] = [ + { value: 'value 1', label: 'Label for value 1' }, + { value: 'value 2', label: 'Label for value 2' }, + 'value 3', + ]; + const items: NeoListItem = [ { value: 'value 1', label: 'Label for value 1' }, { value: 'value 2', label: 'Label for value 2' }, @@ -648,6 +655,7 @@ required multiple label="Native Multiple Select" + options={nativeSelectOptions} bind:ref={selectMultipleState.ref} bind:touched={selectMultipleState.touched} bind:dirty={selectMultipleState.dirty} @@ -655,11 +663,7 @@ bind:value={selectMultipleState.value} {...options} size={undefined} - > - - - - + /> diff --git a/demo/components/DemoTooltips.svelte b/demo/components/DemoTooltips.svelte index 1f38086..2f42504 100644 --- a/demo/components/DemoTooltips.svelte +++ b/demo/components/DemoTooltips.svelte @@ -2,14 +2,16 @@ import { getUUID } from '@dvcol/common-utils/common/string'; import { height } from '@dvcol/svelte-utils/transition'; + import type { NeoListSelectedItem } from '~'; import type { NeoTooltipProps } from '~/tooltips/neo-tooltip.model'; import NeoButton from '~/buttons/NeoButton.svelte'; import NeoButtonGroup from '~/buttons/NeoButtonGroup.svelte'; import IconAccount from '~/icons/IconAccount.svelte'; - import NeoNativeSelect from '~/inputs/NeoNativeSelect.svelte'; import NeoNumberStep from '~/inputs/NeoNumberStep.svelte'; + import NeoSelect from '~/inputs/NeoSelect.svelte'; import NeoInput from '~/inputs/common/NeoInput.svelte'; + import NeoListBaseItem from '~/list/NeoListBaseItem.svelte'; import NeoPopSelect from '~/tooltips/NeoPopSelect.svelte'; import NeoTooltip from '~/tooltips/NeoTooltip.svelte'; @@ -46,50 +48,65 @@ { value: 'left-end', label: 'Left End' }, ]; - const items = $state( - [ - { label: 'John Doe', value: 'John', description: 'john.doe@gmail.com' }, - { label: 'Peter Jackson', value: 'Peter', description: 'peter.jackson@icloud.me' }, - { label: 'John Smith', value: 'Smith', description: 'john.smith@hotmal.com' }, - { label: 'Alice Johnson', value: 'Alice', description: 'alice.johnson@outlook.com' }, - { label: 'Bob Brown', value: 'Bob', description: 'bob.brown@gmail.com' }, - { label: 'Charlie Davis', value: 'Charlie', description: 'charlie.davis@icloud.com' }, - { label: 'Diana Evans', value: 'Diana', description: 'diana.evans@hotmail.com' }, - { label: 'Eve Foster', value: 'Eve', description: 'eve.foster@yahoo.com' }, - { label: 'Frank Green', value: 'Frank', description: 'frank.green@outlook.com' }, - { label: 'Grace Harris', value: 'Grace', description: 'grace.harris@gmail.com' }, - { label: 'Henry Irving', value: 'Henry', description: 'henry.irving@icloud.com' }, - { label: 'Ivy Johnson', value: 'Ivy', description: 'ivy.johnson@hotmail.com' }, - { label: 'Jack King', value: 'Jack', description: 'jack.king@yahoo.com' }, - { label: 'Karen Lee', value: 'Karen', description: 'karen.lee@outlook.com' }, - { - label: 'Directors', - divider: true, - sticky: true, - items: [ - { label: 'Denis VVilleneuve', value: 'Denis', description: '+33 1 25 48 45 45' }, - { label: 'Christopher Nolan', value: 'Christopher', description: '+44 2 07 94 60 95' }, - { label: 'Quentin Tarantino', value: 'Quentin', description: '+33 1 05 55 12 34' }, - { label: 'Martin Scorsese', value: 'Martin', description: '+33 1 25 55 56 78' }, - { label: 'Steven Spielberg', value: 'Steven', description: '+33 1 85 55 87 65' }, - ].map(item => ({ ...item, id: getUUID(), before: avatar })), - }, - { - label: 'Actors', - divider: true, - sticky: true, - items: [ - { label: 'Leonardo DiCaprio', value: 'Leonardo', description: '+1 310 555 1234' }, - { label: 'Brad Pitt', value: 'Brad', description: '+1 323 555 5678' }, - { label: 'Meryl Streep', value: 'Meryl', description: '+1 212 555 8765' }, - { label: 'Tom Hanks', value: 'Tom', description: '+1 310 555 4321' }, - { label: 'Natalie Portman', value: 'Natalie', description: '+1 818 555 6789' }, - ].map(item => ({ ...item, id: getUUID(), before: avatar })), - }, - ].map(item => ({ ...item, id: getUUID(), before: avatar })), - ); - - let selected = $state(); + const simpleItems = [ + 'John Doe', + 'Peter Jackson', + 'John Smith', + 'Alice Johnson', + 'Bob Brown', + 'Charlie Davis', + 'Diana Evans', + 'Eve Foster', + 'Frank Green', + 'Grace Harris', + 'Henry Irving', + 'Ivy Johnson', + ]; + + let simpleSelected = $state(); + + const complexItems = [ + { label: 'John Doe', value: 'John', description: 'john.doe@gmail.com' }, + { label: 'Peter Jackson', value: 'Peter', description: 'peter.jackson@icloud.me' }, + { label: 'John Smith', value: 'Smith', description: 'john.smith@hotmal.com' }, + { label: 'Alice Johnson', value: 'Alice', description: 'alice.johnson@outlook.com' }, + { label: 'Bob Brown', value: 'Bob', description: 'bob.brown@gmail.com' }, + { label: 'Charlie Davis', value: 'Charlie', description: 'charlie.davis@icloud.com' }, + { label: 'Diana Evans', value: 'Diana', description: 'diana.evans@hotmail.com' }, + { label: 'Eve Foster', value: 'Eve', description: 'eve.foster@yahoo.com' }, + { label: 'Frank Green', value: 'Frank', description: 'frank.green@outlook.com' }, + { label: 'Grace Harris', value: 'Grace', description: 'grace.harris@gmail.com' }, + { label: 'Henry Irving', value: 'Henry', description: 'henry.irving@icloud.com' }, + { label: 'Ivy Johnson', value: 'Ivy', description: 'ivy.johnson@hotmail.com' }, + { label: 'Jack King', value: 'Jack', description: 'jack.king@yahoo.com' }, + { label: 'Karen Lee', value: 'Karen', description: 'karen.lee@outlook.com' }, + { + label: 'Directors', + divider: true, + sticky: true, + items: [ + { label: 'Denis VVilleneuve', value: 'Denis', description: '+33 1 25 48 45 45' }, + { label: 'Christopher Nolan', value: 'Christopher', description: '+44 2 07 94 60 95' }, + { label: 'Quentin Tarantino', value: 'Quentin', description: '+33 1 05 55 12 34' }, + { label: 'Martin Scorsese', value: 'Martin', description: '+33 1 25 55 56 78' }, + { label: 'Steven Spielberg', value: 'Steven', description: '+33 1 85 55 87 65' }, + ].map(item => ({ ...item, id: getUUID(), before: avatar })), + }, + { + label: 'Actors', + divider: true, + sticky: true, + items: [ + { label: 'Leonardo DiCaprio', value: 'Leonardo', description: '+1 310 555 1234' }, + { label: 'Brad Pitt', value: 'Brad', description: '+1 323 555 5678' }, + { label: 'Meryl Streep', value: 'Meryl', description: '+1 212 555 8765' }, + { label: 'Tom Hanks', value: 'Tom', description: '+1 310 555 4321' }, + { label: 'Natalie Portman', value: 'Natalie', description: '+1 818 555 6789' }, + ].map(item => ({ ...item, id: getUUID(), before: avatar })), + }, + ].map(item => ({ ...item, id: getUUID(), before: avatar })); + + let complexSelected = $state(); {#snippet avatar()} @@ -116,13 +133,16 @@ Dismiss -
+
+ Simple hover Select + console.info('selected', e)} + > + Hover select: {simpleSelected?.item?.value ?? 'none selected'} + +
+
PopSelect console.info('selected', e)} > - Hover select: {selected?.item?.label ?? 'none selected'} +
diff --git a/src/lib/icons/IconDoubleChevron.svelte b/src/lib/icons/IconDoubleChevron.svelte index bb94d5d..c7c8237 100644 --- a/src/lib/icons/IconDoubleChevron.svelte +++ b/src/lib/icons/IconDoubleChevron.svelte @@ -31,6 +31,6 @@ diff --git a/src/lib/inputs/NeoNativeSelect.svelte b/src/lib/inputs/NeoNativeSelect.svelte index 12ede90..f823286 100644 --- a/src/lib/inputs/NeoNativeSelect.svelte +++ b/src/lib/inputs/NeoNativeSelect.svelte @@ -1,6 +1,4 @@ @@ -89,7 +87,7 @@ {/snippet} {#snippet content()} - {#each options as { label, ...option }} + {#each items as { label, ...option }} {/each} {@render children?.()} @@ -111,7 +109,7 @@ {multiple} floating={multiple ? false : floating} after={multiple ? undefined : after} - children={options?.length ? content : children} + children={items?.length ? content : children} {onpointerdown} {onpointerup} {...rest} diff --git a/src/lib/inputs/NeoSelect.svelte b/src/lib/inputs/NeoSelect.svelte index 1033cf2..f819665 100644 --- a/src/lib/inputs/NeoSelect.svelte +++ b/src/lib/inputs/NeoSelect.svelte @@ -6,7 +6,7 @@ import NeoButton from '~/buttons/NeoButton.svelte'; import IconDoubleChevron from '~/icons/IconDoubleChevron.svelte'; import NeoInput from '~/inputs/common/NeoInput.svelte'; - import { displayValue, type NeoSelectProps } from '~/inputs/neo-select.model.js'; + import { type NeoSelectProps, transformValue } from '~/inputs/neo-select.model.js'; import NeoPopSelect from '~/tooltips/NeoPopSelect.svelte'; import { coerce, computeButtonShadows, getDefaultElevation } from '~/utils/shadow.utils.js'; @@ -17,8 +17,8 @@ icon: customIcon, // State - options = [], - display = displayValue, + options: items = [], + transform = transformValue, // Input Props ref = $bindable(), @@ -32,6 +32,7 @@ multiple, floating, rounded, + readonly, // Pop Select Props listRef = $bindable(), @@ -76,11 +77,11 @@ class: ['neo-select-toggle', buttonProps?.class], }); - const space = $derived(open ? 9 : 6); + const space = $derived(open ? 8 : 6); watch( () => { - value = display(selected); + value = transform(selected); touched = true; }, () => selected, @@ -90,11 +91,11 @@ }, ); + // TODO - disaply input ??? // TODO - rework focus highlights - // implement readonly + // TODO - custom render trigger popselect ? + // TODO - pill // make clearable work - // list padding ? - // validation {#snippet after()} @@ -139,7 +140,8 @@ bind:tooltipRef bind:triggerRef bind:open - items={options} + {readonly} + {items} multiple={!!multiple} {rounded} {search} diff --git a/src/lib/inputs/NeoTextarea.svelte b/src/lib/inputs/NeoTextarea.svelte index 226f3a1..793b05c 100644 --- a/src/lib/inputs/NeoTextarea.svelte +++ b/src/lib/inputs/NeoTextarea.svelte @@ -30,6 +30,7 @@ getDefaultHoverElevation, isShadowFlat, } from '~/utils/shadow.utils.js'; + import { toSize } from '~/utils/style.utils.js'; /* eslint-disable prefer-const -- necessary for binding checked */ let { @@ -62,7 +63,12 @@ validateOnInput, validateOnBlur, position = NeoInputLabelPosition.Inside, + + // Size + width: _width, + height: _height, autoResize = true, + fitContent, // Styles borderless, @@ -353,6 +359,9 @@ const useFn = $derived(toAction(use)); const useProps = $derived(toActionProps(use)); + + const width = $derived(toSize(_width)); + const height = $derived(toSize(_height)); {#snippet suffix()} @@ -391,6 +400,13 @@ class:neo-textarea={true} class:neo-scroll={scrollbar} class:neo-affix={affix || after} + class:neo-fit-content={fitContent} + style:width={width?.absolute} + style:min-width={width?.min} + style:max-width={width?.max} + style:height={height?.absolute} + style:min-height={height?.min} + style:max-height={height?.max} {rows} onblur={onBlur} onfocus={onFocus} @@ -542,6 +558,10 @@ border-radius: var(--neo-textarea-border-radius, var(--neo-border-radius)); outline: none; + &.neo-fit-content { + field-sizing: content; + } + &.neo-affix { padding: 0.75rem 2.25rem 0.75rem 0.95rem; } diff --git a/src/lib/inputs/common/NeoBaseInput.svelte b/src/lib/inputs/common/NeoBaseInput.svelte index 7c60e4b..afe7568 100644 --- a/src/lib/inputs/common/NeoBaseInput.svelte +++ b/src/lib/inputs/common/NeoBaseInput.svelte @@ -7,6 +7,7 @@ import { type NeoBaseInputProps, type NeoInputMethods, type NeoInputState, type NeoInputValue } from '~/inputs/common/neo-input.model.js'; import { toAction, toActionProps } from '~/utils/action.utils.js'; + import { toSize } from '~/utils/style.utils.js'; /* eslint-disable prefer-const -- necessary for binding checked */ let { @@ -38,6 +39,11 @@ validateOnBlur, validationMessage = $bindable(), + // Size + width: _width, + height: _height, + fitContent, + // Styles before, after, @@ -198,6 +204,9 @@ const useFn = $derived(toAction(use)); const useProps = $derived(toActionProps(use)); + + const width = $derived(toSize(_width)); + const height = $derived(toSize(_height)); {#if rest.type === 'select'} @@ -210,6 +219,13 @@ class:neo-input={true} class:neo-after={after} class:neo-before={before} + class:neo-fit-content={fitContent} + style:width={width?.absolute} + style:min-width={width?.min} + style:max-width={width?.max} + style:height={height?.absolute} + style:min-height={height?.min} + style:max-height={height?.max} onblur={onBlur} onfocus={onFocus} oninput={onInput} @@ -235,6 +251,13 @@ class:neo-input={true} class:neo-after={after} class:neo-before={before} + class:neo-fit-content={fitContent} + style:width={width?.absolute} + style:min-width={width?.min} + style:max-width={width?.max} + style:height={height?.absolute} + style:min-height={height?.min} + style:max-height={height?.max} onblur={onBlur} onfocus={onFocus} oninput={onInput} @@ -261,6 +284,13 @@ class:neo-input={true} class:neo-after={after} class:neo-before={before} + class:neo-fit-content={fitContent} + style:width={width?.absolute} + style:min-width={width?.min} + style:max-width={width?.max} + style:height={height?.absolute} + style:min-height={height?.min} + style:max-height={height?.max} onblur={onBlur} onfocus={onFocus} oninput={onInput} @@ -285,6 +315,13 @@ class:neo-input={true} class:neo-after={after} class:neo-before={before} + class:neo-fit-content={fitContent} + style:width={width?.absolute} + style:min-width={width?.min} + style:max-width={width?.max} + style:height={height?.absolute} + style:min-height={height?.min} + style:max-height={height?.max} onblur={onBlur} onfocus={onFocus} oninput={onInput} @@ -309,6 +346,13 @@ class:neo-input={true} class:neo-after={after} class:neo-before={before} + class:neo-fit-content={fitContent} + style:width={width?.absolute} + style:min-width={width?.min} + style:max-width={width?.max} + style:height={height?.absolute} + style:min-height={height?.min} + style:max-height={height?.max} onblur={onBlur} onfocus={onFocus} oninput={onInput} @@ -352,6 +396,10 @@ box-shadow 0.3s ease-out; appearance: none; + &.neo-fit-content { + field-sizing: content; + } + &.neo-before { padding-left: 0; border-top-left-radius: 0; diff --git a/src/lib/inputs/common/neo-input.model.ts b/src/lib/inputs/common/neo-input.model.ts index ef4d4ce..d055cd0 100644 --- a/src/lib/inputs/common/neo-input.model.ts +++ b/src/lib/inputs/common/neo-input.model.ts @@ -12,6 +12,7 @@ import type { NeoValidationFieldContext, NeoValidationState } from '~/inputs/com import type { HTMLTransitionProps, HTMLUseProps } from '~/utils/action.utils.js'; import type { HTMLNeoBaseElement, HTMLRefProps, SvelteEvent } from '~/utils/html-element.utils.js'; import type { ShadowElevation, ShadowElevationString, ShadowHoverElevation, ShadowHoverElevationsString } from '~/utils/shadow.utils.js'; +import type { SizeInput } from '~/utils/style.utils.js'; export type NeoInputValue = T extends HTMLTextAreaElement ? HTMLTextareaAttributes['value'] @@ -160,6 +161,22 @@ export type NeoBaseInputProps; + /** + * Optional height constraints. + */ + height?: SizeInput<'height'>; + /** + * If true, the input will adjust its size to fit the content. + * + * @see [field-sizing](https://developer.mozilla.org/en-US/docs/Web/CSS/field-sizing) for browser support + */ + fitContent?: boolean; + // Validation /** * If `true`, the input dirty state will update on input events. diff --git a/src/lib/inputs/neo-select.model.ts b/src/lib/inputs/neo-select.model.ts index d71713f..627a374 100644 --- a/src/lib/inputs/neo-select.model.ts +++ b/src/lib/inputs/neo-select.model.ts @@ -5,10 +5,13 @@ import type { NeoInputProps } from '~/inputs/common/neo-input.model.js'; import type { NeoListItemOrSection, NeoListSelectedItem } from '~/list/neo-list.model.js'; import type { NeoPopSelectProps } from '~/tooltips/neo-pop-select.model.js'; -export type NeoNativeSelectOption = { - value: Value; - label?: string | Snippet; -} & HTMLOptionAttributes; +export type NeoNativeSelectOption = + | string + | number + | ({ + value: Value; + label?: string | Snippet; + } & HTMLOptionAttributes); export type NeoNativeSelectProps = { /** @@ -25,6 +28,8 @@ export type NeoNativeSelectProps = { options?: NeoNativeSelectOption[]; } & NeoInputProps; +export type NeoSelectOption = NeoListItemOrSection; + export type NeoSelectProps = { // Snippets @@ -37,12 +42,12 @@ export type NeoSelectProps = { /** * The array of options to display in the select. */ - options?: NeoListItemOrSection[]; + options?: NeoSelectOption[]; /** * Transform the selected item(s) into a displayable string. * @param selection */ - display?: (selection?: NeoListSelectedItem | NeoListSelectedItem[]) => string | string[]; + transform?: (selection?: NeoListSelectedItem | NeoListSelectedItem[]) => string | string[]; // ListProps /** @@ -98,10 +103,10 @@ export type NeoSelectProps = { buttonProps?: NeoButtonProps; } & NeoInputProps; -export const displayValue = (selection?: NeoListSelectedItem | NeoListSelectedItem[]): string => { +export const transformValue = (selection?: NeoListSelectedItem | NeoListSelectedItem[]): string => { if (Array.isArray(selection)) { if (selection?.length > 2) return `${selection?.length} items selected`; - return selection?.map?.(s => s?.item?.label).join(', ') ?? ''; + return selection?.map?.(s => s?.item?.value).join(', ') ?? ''; } - return selection?.item?.label?.toString() ?? ''; + return selection?.item?.value?.toString() ?? ''; }; diff --git a/src/lib/list/NeoList.svelte b/src/lib/list/NeoList.svelte index 240a1cb..cd8f531 100644 --- a/src/lib/list/NeoList.svelte +++ b/src/lib/list/NeoList.svelte @@ -424,6 +424,7 @@ display: flex; flex-direction: column; height: 100%; + max-height: 100%; margin: 0; padding: 0; border-radius: var(--neo-border-radius); diff --git a/src/lib/styles/common/easing.scss b/src/lib/styles/common/easing.scss new file mode 100644 index 0000000..90775be --- /dev/null +++ b/src/lib/styles/common/easing.scss @@ -0,0 +1,36 @@ +@mixin easing { + --neo-easing-overshoot-smooth: linear( + 0, + 0.276 3.6%, + 0.52 7.3%, + 0.732 11.1%, + 0.828 13.1%, + 0.916 15.1%, + 0.994 17.1%, + 1.066 19.2%, + 1.13 21.3%, + 1.185 23.4%, + 1.234 25.6%, + 1.275 27.8%, + 1.31 30.1%, + 1.338 32.5%, + 1.353 34.2%, + 1.365 36%, + 1.373 37.8%, + 1.378 39.7%, + 1.379 41.6%, + 1.377 43.6%, + 1.371 45.7%, + 1.362 47.8%, + 1.339 51.7%, + 1.304 56%, + 1.261 60.5%, + 1.127 73.8%, + 1.089 78.1%, + 1.059 82%, + 1.032 86.5%, + 1.013 90.9%, + 1.003 95.3%, + 1 + ); +} diff --git a/src/lib/styles/theme.scss b/src/lib/styles/theme.scss index bbd892f..6b4c1f9 100644 --- a/src/lib/styles/theme.scss +++ b/src/lib/styles/theme.scss @@ -1,4 +1,5 @@ @use 'src/lib/styles/common/colors' as colors; +@use 'src/lib/styles/common/easing' as easing; @use 'src/lib/styles/common/typography' as typography; @use 'src/lib/styles/common/shadows' as shadows; @use 'src/lib/styles/common/spacing' as spacing; @@ -15,6 +16,7 @@ /* touch highlight */ -webkit-tap-highlight-color: transparent; + @include easing.easing; @include utils.utils; @include utils.z-index; @include spacing.spacing; diff --git a/src/lib/tooltips/NeoPopSelect.svelte b/src/lib/tooltips/NeoPopSelect.svelte index cf7d71c..688f5c6 100644 --- a/src/lib/tooltips/NeoPopSelect.svelte +++ b/src/lib/tooltips/NeoPopSelect.svelte @@ -1,6 +1,6 @@ {#snippet beforeList(context: NeoListContext)} @@ -66,6 +71,7 @@ select reverse={tooltipProps?.placement?.startsWith('top')} before={search ? beforeList : before} + {items} {...rest} buttonProps={{ rounded, ...rest.buttonProps }} class={['neo-pop-select-list', rest.class]} @@ -83,6 +89,8 @@ padding="0.25rem" {target} {rounded} + {width} + {height} {...tooltipProps} > {#snippet children(floating: UseFloatingReturn)} diff --git a/src/lib/tooltips/NeoTooltip.svelte b/src/lib/tooltips/NeoTooltip.svelte index 39a1ec1..3e5a1a1 100644 --- a/src/lib/tooltips/NeoTooltip.svelte +++ b/src/lib/tooltips/NeoTooltip.svelte @@ -3,11 +3,11 @@ import { toStyle } from '@dvcol/common-utils/common/class'; import { clamp } from '@dvcol/common-utils/common/math'; - import { resize } from '@dvcol/svelte-utils/resize'; import { autoPlacement, flip, offset, + size, useDismiss, useFloating, useFocus, @@ -16,12 +16,13 @@ useRole, } from '@skeletonlabs/floating-ui-svelte'; - import type { NeoTooltipProps } from '~/tooltips/neo-tooltip.model.js'; - import type { HTMLNeoBaseElement } from '~/utils/html-element.utils.js'; + import { type NeoTooltipProps, NeoTooltipSizeStrategy } from '~/tooltips/neo-tooltip.model.js'; + import { toAction, toActionProps, toTransition, toTransitionProps } from '~/utils/action.utils.js'; import { coerce, DefaultShadowShallowElevation, MaxShadowElevation } from '~/utils/shadow.utils.js'; + import { type SizeOption, toPixel, toSize } from '~/utils/style.utils.js'; import { scaleTransition } from '~/utils/transition.utils.js'; let { @@ -42,7 +43,8 @@ // Styles padding, rounded, - width, + width: inputWith, + height: inputHeight, // Hover openOnHover = true, @@ -84,6 +86,8 @@ return target; }); + const available = $state<{ width?: number; height?: number }>({}); + let focus = $state(false); const floating = useFloating({ get elements() { @@ -106,8 +110,23 @@ open = _open; }, get middleware() { - if (placement === 'auto') return [autoPlacement(), offset(spacing)]; - return [flip(), offset(spacing)]; + const middleware = [ + offset(spacing), + size({ + apply({ availableWidth, availableHeight }) { + available.width = availableWidth; + available.height = availableHeight; + }, + }), + ]; + if (placement === 'auto') middleware.push(autoPlacement()); + else + middleware.push( + flip({ + fallbackAxisSideDirection: 'end', + }), + ); + return middleware; }, get placement() { if (placement === 'auto') return undefined; @@ -215,13 +234,18 @@ $effect(() => addMethods(ref)); $effect(() => addMethods(triggerRef)); - const tooltipWidth = $derived.by(() => { - if (!triggerRef?.offsetWidth) return; - if (width === true) return `width: ${triggerRef?.offsetWidth}px;`; - if (width === 'min') return `min-width: ${triggerRef?.offsetWidth}px;`; - if (width === 'max') return `max-width: ${triggerRef?.offsetWidth}px;`; - if (typeof width === 'string') return `width: ${width};`; - }); + const computeSize = (value: NeoTooltipProps[T], dimension: T): SizeOption | undefined => { + if (!open) return; + const tSize = dimension === 'width' ? triggerRef?.offsetWidth : triggerRef?.offsetHeight; + if (value === NeoTooltipSizeStrategy.Match) return { absolute: toPixel(tSize) }; + if (value === NeoTooltipSizeStrategy.Min) return { max: toPixel(available[dimension]), min: toPixel(tSize) }; + if (value === NeoTooltipSizeStrategy.Max) return { max: toPixel(tSize) }; + const iSize = toSize(value); + return { max: iSize?.absolute ? undefined : toPixel(available[dimension]), ...iSize }; + }; + + const width = $derived(computeSize(inputWith, 'width')); + const height = $derived(computeSize(inputHeight, 'height')); {#if !target} @@ -249,12 +273,17 @@ in:inFn={inProps} out:outFn={outProps} use:useFn={useProps} - use:resize={floating.update} {...tooltipHandler} {...rest} style:transform-origin={tooltipOrigin} style:--neo-tooltip-padding={padding} - style={toStyle(tooltipStyle, tooltipWidth, rest.style)} + style:width={width?.absolute} + style:min-width={width?.min} + style:max-width={width?.max} + style:height={height?.absolute} + style:min-height={height?.min} + style:max-height={height?.max} + style={toStyle(tooltipStyle, rest.style)} > {#if typeof tooltip === 'string'} {tooltip} @@ -270,7 +299,11 @@ .neo-tooltip { @include mixin.tooltip; - overflow: auto; + :global(> .neo-list) { + height: inherit; + min-height: inherit; + max-height: inherit; + } &.neo-rounded { --neo-tooltip-border-radius: var(--neo-tooltip-border-radius-lg, var(--neo-border-radius-lg)); diff --git a/src/lib/tooltips/neo-pop-select.model.ts b/src/lib/tooltips/neo-pop-select.model.ts index 207f1ce..e25e5d0 100644 --- a/src/lib/tooltips/neo-pop-select.model.ts +++ b/src/lib/tooltips/neo-pop-select.model.ts @@ -1,6 +1,6 @@ import type { NeoInputProps } from '~/inputs/common/neo-input.model.js'; import type { NeoListSearchProps } from '~/list/neo-list-search.model.js'; -import type { NeoListProps } from '~/list/neo-list.model.js'; +import type { NeoListItemOrSection, NeoListProps } from '~/list/neo-list.model.js'; import type { NeoTooltipProps } from '~/tooltips/neo-tooltip.model.js'; export type NeoPopSelectProps = { @@ -23,6 +23,10 @@ export type NeoPopSelectProps = { rounded?: boolean; // List props + /** + * List items to select from. + */ + items?: (string | number | NeoListItemOrSection)[]; /** * We use the list's search is focused. * @@ -55,8 +59,30 @@ export type NeoPopSelectProps = { * The target element to attach the tooltip to. */ target?: NeoTooltipProps['target']; + /** + * Width strategy for the tooltip. + * - `match`: the tooltip will match the width of the trigger. + * - `min`: the tooltip will be at least as wide as the trigger. + * - `max`: the tooltip will be at most as wide as the trigger. + * - `string`: a css width value will be applied to the tooltip. + * - `{ min: string, max: string, absolute: string }`: a css value will be applied to the tooltip. + * + * @default 'min' + */ + width?: NeoTooltipProps['width']; + /** + * Width strategy for the tooltip. + * - `match`: the tooltip will match the width of the trigger. + * - `min`: the tooltip will be at least as wide as the trigger. + * - `max`: the tooltip will be at most as wide as the trigger. + * - `string`: a css width value will be applied to the tooltip. + * - `{ min: string, max: string, absolute: string }`: a css value will be applied to the tooltip. + * + * @default 'min' + */ + height?: NeoTooltipProps['height']; /** * Optional props to pass to the tooltip. */ tooltipProps?: Omit; -} & Omit, 'ref' | 'children'>; +} & Omit, 'ref' | 'children' | 'width' | 'height'>; diff --git a/src/lib/tooltips/neo-tooltip.model.ts b/src/lib/tooltips/neo-tooltip.model.ts index 5124e3b..9c94966 100644 --- a/src/lib/tooltips/neo-tooltip.model.ts +++ b/src/lib/tooltips/neo-tooltip.model.ts @@ -11,6 +11,15 @@ import type { Snippet } from 'svelte'; import type { HTMLActionProps } from '~/utils/action.utils.js'; import type { HTMLNeoBaseElement, HTMLRefProps } from '~/utils/html-element.utils.js'; import type { PositiveShadowElevation, PositiveShadowElevationString } from '~/utils/shadow.utils.js'; +import type { SizeInput } from '~/utils/style.utils.js'; + +export const NeoTooltipSizeStrategy = { + Match: 'match' as const, + Min: 'min' as const, + Max: 'max' as const, +} as const; + +export type NeoTooltipSizeStrategies = (typeof NeoTooltipSizeStrategy)[keyof typeof NeoTooltipSizeStrategy]; export type NeoTooltipElevation = PositiveShadowElevation | PositiveShadowElevationString; @@ -80,12 +89,22 @@ export type NeoTooltipProps = { blur?: NeoTooltipElevation; /** * Width strategy for the tooltip. - * - `true`: the tooltip will match the width of the trigger. + * - `match`: the tooltip will match the width of the trigger. + * - `min`: the tooltip will be at least as wide as the trigger. + * - `max`: the tooltip will be at most as wide as the trigger. + * - `string`: a css width value will be applied to the tooltip. + * - `{ min: string, max: string, absolute: string }`: a css value will be applied to the tooltip. + */ + width?: NeoTooltipSizeStrategies | SizeInput<'width'>; + /** + * Width strategy for the tooltip. + * - `match`: the tooltip will match the width of the trigger. * - `min`: the tooltip will be at least as wide as the trigger. * - `max`: the tooltip will be at most as wide as the trigger. * - `string`: a css width value will be applied to the tooltip. + * - `{ min: string, max: string, absolute: string }`: a css value will be applied to the tooltip. */ - width?: boolean | 'min' | 'max' | string; + height?: NeoTooltipSizeStrategies | SizeInput<'height'>; /** * Padding override for the tooltip. */ diff --git a/src/lib/utils/style.utils.ts b/src/lib/utils/style.utils.ts index dae8c02..f029259 100644 --- a/src/lib/utils/style.utils.ts +++ b/src/lib/utils/style.utils.ts @@ -12,13 +12,18 @@ export type SizeOption = number | SizeValue | SizeOption>; +export const toPixel = (value?: number | string): string | undefined => { + if (!value) return; + return typeof value === 'number' ? `${value}px` : value; +}; + export const toSize = ( size?: SizeInput, ): SizeOption> | undefined => { if (!size) return; - if (typeof size === 'number') return { absolute: `${size}px` }; + if (typeof size === 'number') return { absolute: toPixel(size) }; if (typeof size === 'string') return { absolute: size }; return Object.entries(size).reduce>((acc, [key, value]) => { - return { ...acc, [key]: typeof value === 'number' ? `${value}px` : value }; + return { ...acc, [key]: toPixel(value) }; }, {}); };