You've already forked AstralRinth
forked from xxxOFFxxx/AstralRinth
Update Select component
This commit is contained in:
@@ -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} />
|
||||
```
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
|
||||
z-index: 1;
|
||||
z-index: 2;
|
||||
|
||||
&--row {
|
||||
flex-direction: row;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user