forked from didirus/AstralRinth
Refactor folder structure
This commit is contained in:
211
src/components/Select.svelte
Normal file
211
src/components/Select.svelte
Normal file
@@ -0,0 +1,211 @@
|
||||
<script lang="ts">
|
||||
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'
|
||||
|
||||
interface Option {
|
||||
label: string
|
||||
value: string | number
|
||||
}
|
||||
|
||||
export let options: Option[] = []
|
||||
export let value: string | number
|
||||
export let selected: Option
|
||||
export let color = ''
|
||||
export let label = ''
|
||||
export let icon = null
|
||||
|
||||
let open = false
|
||||
|
||||
$: 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) => {
|
||||
if (typeof document !== 'undefined') {
|
||||
const canvas =
|
||||
getTextWidth.canvas || (getTextWidth.canvas = document.createElement('canvas'))
|
||||
const context = canvas.getContext('2d')
|
||||
context.font = font
|
||||
const metrics = context.measureText(text)
|
||||
return metrics.width
|
||||
} else {
|
||||
// Return estimate if SSR
|
||||
return text.length * 7.75
|
||||
}
|
||||
},
|
||||
// Reuses the same canvas object
|
||||
{ canvas: null }
|
||||
)
|
||||
|
||||
const minWidth = Math.max(
|
||||
...options.map((it) => getTextWidth(String(it.label || it.value), '16px Inter'))
|
||||
)
|
||||
|
||||
let element: HTMLElement
|
||||
|
||||
function selectOption(option: Option) {
|
||||
selected = option
|
||||
open = false
|
||||
element.focus()
|
||||
}
|
||||
|
||||
function keydown(event: KeyboardEvent) {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
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)
|
||||
open = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="select select--color-{color}"
|
||||
class:select--opens-up={false}
|
||||
use:clickOutside={() => {
|
||||
open = false
|
||||
}}
|
||||
bind:this={element}
|
||||
tabindex="0"
|
||||
on:keydown={keydown}
|
||||
on:click>
|
||||
<div
|
||||
class="select__input"
|
||||
on:click={() => {
|
||||
open = true
|
||||
}}>
|
||||
{#if icon}
|
||||
<svelte:component this={icon} />
|
||||
{/if}
|
||||
<span class="select__input__value" style:min-width="{minWidth}px">
|
||||
{label || selected?.label || value || 'Choose...'}
|
||||
</span>
|
||||
<div class="select__input__arrow">
|
||||
<slot name="expandIcon">
|
||||
<IconChevronDown />
|
||||
</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>
|
||||
|
||||
<style lang="postcss">
|
||||
.select {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--color-button-text);
|
||||
cursor: pointer;
|
||||
border-radius: var(--rounded);
|
||||
align-self: flex-start;
|
||||
|
||||
&__input {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.25rem 1rem;
|
||||
gap: 0.5rem;
|
||||
background-color: var(--color-button-bg);
|
||||
box-shadow: var(--shadow-inset-sm);
|
||||
border-radius: var(--rounded);
|
||||
}
|
||||
|
||||
&__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;
|
||||
|
||||
button {
|
||||
padding: 0.25rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--color-brand-dark);
|
||||
color: var(--color-brand-dark-contrast);
|
||||
outline: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
background-color: var(--color-brand-light);
|
||||
color: var(--color-text);
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--color-raised {
|
||||
> * {
|
||||
background-color: var(--color-raised-bg);
|
||||
}
|
||||
}
|
||||
|
||||
&--opens-up {
|
||||
.select__options {
|
||||
bottom: 100%;
|
||||
top: auto;
|
||||
border-radius: var(--rounded-top);
|
||||
box-shadow: none;
|
||||
border-top: none;
|
||||
border-bottom: var(--border-width) solid var(--color-divider);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user