Update Select component

This commit is contained in:
venashial
2022-06-30 17:42:15 -07:00
parent 846aadd7c6
commit dcc252b371
5 changed files with 178 additions and 66 deletions

View File

@@ -1,3 +1,5 @@
### Default option example
```svelte example raised ```svelte example raised
<script lang="ts"> <script lang="ts">
import { Select } from 'omorphia' import { Select } from 'omorphia'
@@ -15,3 +17,20 @@
]} ]}
bind:value={sortMethod} /> bind:value={sortMethod} />
``` ```
### Icon example
```svelte example raised
<script lang="ts">
import { Select } from 'omorphia'
import IconSun from 'virtual:icons/heroicons-outline/sun'
</script>
<Select
options={[
{ value: '1', label: 'Light' },
{ value: '2', label: 'Dark' },
{ value: '3', label: 'OLED' },
]}
icon={IconSun} />
```

View File

@@ -12,15 +12,13 @@
export let key: string export let key: string
export let type: 'project' | 'version' | 'account' | 'image' export let type: 'project' | 'version' | 'account' | 'image'
export let open = false
let data: { key?: string } = {} let data: { key?: string } = {}
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
</script> </script>
<!-- @ts-ignore --> <!-- @ts-ignore -->
<Modal title={$t(`modal.deletion.${type}.title`)} bind:open bind:data> <Modal title={$t(`modal.deletion.${type}.title`)} bind:data>
<svelte:fragment slot="trigger" let:trigger> <svelte:fragment slot="trigger" let:trigger>
<slot name="trigger" {trigger} /> <slot name="trigger" {trigger} />
</svelte:fragment> </svelte:fragment>
@@ -32,10 +30,7 @@
{/if} {/if}
{@html markdown($t(`modal.deletion.${type}.description`))} {@html markdown($t(`modal.deletion.${type}.description`))}
<Field label={$t('modal.deletion.generic.verify', { values: { key } })} let:id> <Field label={$t('modal.deletion.generic.verify', { values: { key } })} let:id>
<TextInput <TextInput placeholder={$t('modal.deletion.generic.placeholder')} bind:value={data.key} {id} />
placeholder={$t('modal.deletion.generic.placeholder', { values: { key } })}
bind:value={data.key}
{id} />
</Field> </Field>
<svelte:fragment slot="button" let:close> <svelte:fragment slot="button" let:close>

View File

@@ -2,7 +2,8 @@
import IconChevronDown from 'virtual:icons/lucide/chevron-down' import IconChevronDown from 'virtual:icons/lucide/chevron-down'
import IconCheck from 'virtual:icons/heroicons-outline/check' import IconCheck from 'virtual:icons/heroicons-outline/check'
import { clickOutside } from 'svelte-use-click-outside' import { clickOutside } from 'svelte-use-click-outside'
import { fade } from 'svelte/transition' import { onMount, tick } from 'svelte'
import { debounce } from 'throttle-debounce'
interface Option { interface Option {
label: string label: string
@@ -17,19 +18,18 @@
export let icon = null export let icon = null
let open = false let open = false
let direction = 'down'
let checkingDirection = false
let element: HTMLDivElement
$: if (options) { $: if (selected) value = selected.value
setSelected()
} $: if (options) setSelected()
function setSelected() { function setSelected() {
selected = options.find((option) => option.value === (value || '')) selected = options.find((option) => option.value === (value || ''))
} }
$: if (selected) {
value = selected.value
}
// Returns the width of a string based on the font size and family // Returns the width of a string based on the font size and family
const getTextWidth = Object.assign( const getTextWidth = Object.assign(
(text: string, font: string) => { (text: string, font: string) => {
@@ -42,7 +42,7 @@
return metrics.width return metrics.width
} else { } else {
// Return estimate if SSR // Return estimate if SSR
return text.length * 7.75 return text.length * 8.3
} }
}, },
// Reuses the same canvas object // Reuses the same canvas object
@@ -50,44 +50,101 @@
) )
const minWidth = Math.max( const minWidth = Math.max(
...options.map((it) => getTextWidth(String(it.label || it.value), '16px Inter')) ...options.map((it) => getTextWidth(String(it.label || it.value), '16px Inter')),
...(!value ? [71] : []) // width of "Choose..." text
) )
let element: HTMLElement
function selectOption(option: Option) { function selectOption(option: Option) {
selected = option selected = option
open = false open = false
element.focus() element.blur()
}
// Checks if there is enough room below the element to show the dropdown, if not, show above the element
async function checkDirection() {
checkingDirection = true
await tick()
const { bottom } = element.children[0].getBoundingClientRect()
const height = (element.children[1] as HTMLDivElement).offsetHeight
const windowBottom = window.scrollY + window.innerHeight
const spaceBelow = windowBottom - bottom
if (spaceBelow < height) {
direction = 'up'
} else {
direction = 'down'
}
checkingDirection = false
} }
function keydown(event: KeyboardEvent) { function keydown(event: KeyboardEvent) {
if (event.key === ' ' || event.key === 'Enter') { const currentIndex = options.indexOf(selected)
event.preventDefault()
if (event.key === 'End') {
selected = options[options.length - 1]
} else if (['ArrowDown', 'ArrowUp'].includes(event.key)) {
event.preventDefault()
if (!open) { if (!open) {
open = true open = true
// Needs delay before trying to move focus return
setTimeout(() => (element.children[1].children[0] as HTMLButtonElement).focus(), 0) }
} else {
const option = options.find( if (
({ label }) => label === document.activeElement.innerHTML.trim() (event.key === 'ArrowUp' && direction === 'down') ||
) (event.key === 'ArrowDown' && direction === 'up')
selectOption(option) ) {
if (currentIndex > 0) {
selected = options[currentIndex - 1]
} else {
selected = options[options.length - 1]
}
} else if (
(event.key === 'ArrowDown' && direction === 'down') ||
(event.key === 'ArrowUp' && direction === 'up')
) {
if (currentIndex < options.length - 1) {
selected = options[currentIndex + 1]
} else {
selected = options[0]
}
}
} else if (event.key === 'Home') {
selected = options[0]
} else if (event.key === 'Escape') {
if (open) {
// prevent ESC bubble in this case (interfering with modal closing etc)
event.preventDefault()
event.stopPropagation()
open = false open = false
} }
} else if (['Enter', ' '].includes(event.key)) {
if (!open) {
open = true
} else {
open = false
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
}
} }
} }
onMount(() => {
checkDirection()
const debounced = debounce(100, checkDirection)
window.addEventListener('resize', debounced)
window.addEventListener('scroll;', debounced)
})
</script> </script>
<div <div
class="select select--color-{color}" class="select select--color-{color}"
class:select--opens-up={false} use:clickOutside={() => (open = false)}
use:clickOutside={() => {
open = false
}}
bind:this={element} bind:this={element}
tabindex="0" tabindex="0"
on:focus={() => (open = true)}
on:blur={() => (open = false)}
on:keydown={keydown} on:keydown={keydown}
on:click> on:click>
<div <div
@@ -107,28 +164,34 @@
</slot> </slot>
</div> </div>
</div> </div>
{#if open} <div
<div class="select__options select__options--direction-{direction}"
transition:fade={{ duration: 70 }} class:open
class="select__options" style:max-height={checkingDirection ? 'unset' : null}>
style:--selected-index={options.indexOf(selected)}> <button class="select__options__item current" on:click={() => (open = false)} tabindex="-1">
{#each options as option, index (option.value)} {#if icon}
{@const isSelected = selected?.value === option.value} <svelte:component this={icon} />
<button {/if}
on:click={() => selectOption(option)} <span>{label || selected?.label || value || 'Choose...'}</span>
class:is-selected={isSelected} <IconChevronDown class="icon-chevron" />
tabindex={isSelected ? -1 : 0} </button>
on:focusout={() => { {#each options as option, index (option.value)}
if (index + 1 === options.length) open = false {@const isSelected = selected?.value === option.value}
}}> <button
{option.label || option.value} class="select__options__item"
{#if selected?.value === option.value} on:click={() => selectOption(option)}
<IconCheck /> class:is-selected={isSelected}
{/if} tabindex="-1"
</button> on:focusout={() => {
{/each} if (index + 1 === options.length) open = false
</div> }}>
{/if} {option.label || option.value}
{#if selected?.value === option.value}
<IconCheck />
{/if}
</button>
{/each}
</div>
</div> </div>
<style lang="postcss"> <style lang="postcss">
@@ -140,6 +203,7 @@
cursor: pointer; cursor: pointer;
border-radius: var(--rounded); border-radius: var(--rounded);
align-self: flex-start; align-self: flex-start;
user-select: none;
&__input { &__input {
display: flex; display: flex;
@@ -154,35 +218,63 @@
} }
&__options { &__options {
list-style-type: none;
display: flex; display: flex;
flex-direction: column;
width: 100%; width: 100%;
padding: 0; padding: 0;
margin: 0; margin: 0;
position: absolute; position: absolute;
top: calc(100% * -1 * var(--selected-index));
background-color: var(--color-button-bg); background-color: var(--color-button-bg);
border-radius: var(--rounded); border-radius: var(--rounded);
box-shadow: var(--shadow-inset-sm), var(--shadow-floating), 0 0 0 1px var(--color-tertiary); box-shadow: var(--shadow-inset-sm), var(--shadow-floating), 0 0 0 1px var(--color-tertiary);
/* border: var(--border-width) solid var(--color-tertiary); */
overflow: hidden; overflow: hidden;
z-index: 5; z-index: 5;
visibility: hidden;
max-height: 32px;
transition: max-height 0.3s ease-in-out, visibility 0.3s ease-in-out;
button { &.open {
visibility: visible;
max-height: 100vh;
.select__options__item.current :global(.icon-chevron) {
transform: rotate(180deg);
}
}
&--direction {
&-down {
flex-direction: column;
}
&-up {
flex-direction: column-reverse;
bottom: 0;
}
}
&__item {
padding: 0.25rem 1rem; padding: 0.25rem 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
&:hover, &:hover:not(.current, .is-selected) {
&:focus {
background-color: var(--color-brand-dark); background-color: var(--color-brand-dark);
color: var(--color-brand-dark-contrast); color: var(--color-brand-dark-contrast);
outline: none; outline: none;
border-radius: 0; border-radius: 0;
} }
&.current {
box-shadow: 0 0 0 1px var(--color-tertiary);
:global(.icon-chevron) {
margin-left: auto;
margin-top: 0.2rem;
transition: transform 0.2s ease-in-out;
}
}
&.is-selected { &.is-selected {
background-color: var(--color-brand-light); background-color: var(--color-brand-light);
color: var(--color-text); color: var(--color-text);

View File

@@ -62,7 +62,7 @@
align-items: flex-end; align-items: flex-end;
gap: 0.5rem; gap: 0.5rem;
z-index: 1; z-index: 2;
&--row { &--row {
flex-direction: row; flex-direction: row;

View File

@@ -37,13 +37,19 @@ input:focus-visible,
input, input,
button, button,
a, a,
textarea {
transition: outline 0.1s ease-in-out;
}
input:not([type='text']),
button,
a,
.select { .select {
outline: 0 solid hsla(290, 100%, 40%, 0); outline: 0 solid hsla(155, 58%, 44%, 0.7);
transition: outline 0.2s ease-in-out;
} }
input[type='text']:focus-visible, input[type='text']:focus-visible,
input[type='textarea']:focus-visible { textarea:focus-visible {
outline: 0.25rem solid hsla(155, 58%, 44%, 0.7); outline: 0.25rem solid hsla(155, 58%, 44%, 0.7);
} }