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
<script lang="ts">
import { Select } from 'omorphia'
@@ -15,3 +17,20 @@
]}
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 type: 'project' | 'version' | 'account' | 'image'
export let open = false
let data: { key?: string } = {}
const dispatch = createEventDispatcher()
</script>
<!-- @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>
<slot name="trigger" {trigger} />
</svelte:fragment>
@@ -32,10 +30,7 @@
{/if}
{@html markdown($t(`modal.deletion.${type}.description`))}
<Field label={$t('modal.deletion.generic.verify', { values: { key } })} let:id>
<TextInput
placeholder={$t('modal.deletion.generic.placeholder', { values: { key } })}
bind:value={data.key}
{id} />
<TextInput placeholder={$t('modal.deletion.generic.placeholder')} bind:value={data.key} {id} />
</Field>
<svelte:fragment slot="button" let:close>

View File

@@ -2,7 +2,8 @@
import IconChevronDown from 'virtual:icons/lucide/chevron-down'
import IconCheck from 'virtual:icons/heroicons-outline/check'
import { clickOutside } from 'svelte-use-click-outside'
import { fade } from 'svelte/transition'
import { onMount, tick } from 'svelte'
import { debounce } from 'throttle-debounce'
interface Option {
label: string
@@ -17,19 +18,18 @@
export let icon = null
let open = false
let direction = 'down'
let checkingDirection = false
let element: HTMLDivElement
$: if (options) {
setSelected()
}
$: if (selected) value = selected.value
$: if (options) setSelected()
function setSelected() {
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
const getTextWidth = Object.assign(
(text: string, font: string) => {
@@ -42,7 +42,7 @@
return metrics.width
} else {
// Return estimate if SSR
return text.length * 7.75
return text.length * 8.3
}
},
// Reuses the same canvas object
@@ -50,44 +50,101 @@
)
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) {
selected = option
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) {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault()
const currentIndex = options.indexOf(selected)
if (event.key === 'End') {
selected = options[options.length - 1]
} else if (['ArrowDown', 'ArrowUp'].includes(event.key)) {
event.preventDefault()
if (!open) {
open = true
// Needs delay before trying to move focus
setTimeout(() => (element.children[1].children[0] as HTMLButtonElement).focus(), 0)
} else {
const option = options.find(
({ label }) => label === document.activeElement.innerHTML.trim()
)
selectOption(option)
return
}
if (
(event.key === 'ArrowUp' && direction === 'down') ||
(event.key === 'ArrowDown' && direction === 'up')
) {
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
}
} 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>
<div
class="select select--color-{color}"
class:select--opens-up={false}
use:clickOutside={() => {
open = false
}}
use:clickOutside={() => (open = false)}
bind:this={element}
tabindex="0"
on:focus={() => (open = true)}
on:blur={() => (open = false)}
on:keydown={keydown}
on:click>
<div
@@ -107,28 +164,34 @@
</slot>
</div>
</div>
{#if open}
<div
transition:fade={{ duration: 70 }}
class="select__options"
style:--selected-index={options.indexOf(selected)}>
{#each options as option, index (option.value)}
{@const isSelected = selected?.value === option.value}
<button
on:click={() => selectOption(option)}
class:is-selected={isSelected}
tabindex={isSelected ? -1 : 0}
on:focusout={() => {
if (index + 1 === options.length) open = false
}}>
{option.label || option.value}
{#if selected?.value === option.value}
<IconCheck />
{/if}
</button>
{/each}
</div>
{/if}
<div
class="select__options select__options--direction-{direction}"
class:open
style:max-height={checkingDirection ? 'unset' : null}>
<button class="select__options__item current" on:click={() => (open = false)} tabindex="-1">
{#if icon}
<svelte:component this={icon} />
{/if}
<span>{label || selected?.label || value || 'Choose...'}</span>
<IconChevronDown class="icon-chevron" />
</button>
{#each options as option, index (option.value)}
{@const isSelected = selected?.value === option.value}
<button
class="select__options__item"
on:click={() => selectOption(option)}
class:is-selected={isSelected}
tabindex="-1"
on:focusout={() => {
if (index + 1 === options.length) open = false
}}>
{option.label || option.value}
{#if selected?.value === option.value}
<IconCheck />
{/if}
</button>
{/each}
</div>
</div>
<style lang="postcss">
@@ -140,6 +203,7 @@
cursor: pointer;
border-radius: var(--rounded);
align-self: flex-start;
user-select: none;
&__input {
display: flex;
@@ -154,35 +218,63 @@
}
&__options {
list-style-type: none;
display: flex;
flex-direction: column;
width: 100%;
padding: 0;
margin: 0;
position: absolute;
top: calc(100% * -1 * var(--selected-index));
background-color: var(--color-button-bg);
border-radius: var(--rounded);
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;
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;
display: flex;
align-items: center;
gap: 0.5rem;
&:hover,
&:focus {
&:hover:not(.current, .is-selected) {
background-color: var(--color-brand-dark);
color: var(--color-brand-dark-contrast);
outline: none;
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 {
background-color: var(--color-brand-light);
color: var(--color-text);

View File

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

View File

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