You've already forked AstralRinth
forked from didirus/AstralRinth
Add classes: Actions + Divider + Illustration + InfoTable + Stat, Add utilities (needs docs)
This commit is contained in:
98
src/package/components/Avatar.svelte
Normal file
98
src/package/components/Avatar.svelte
Normal file
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
// TODO: Make square icon `md` more rounded
|
||||
|
||||
import { onMount } from 'svelte'
|
||||
import { classCombine } from '$package/utils/classCombine'
|
||||
|
||||
/** Optional, as a default icon will be substituted if no image was specified */
|
||||
export let src: string | undefined
|
||||
export let size: 'xs' | 'sm' | 'md' | 'lg'
|
||||
export let circle = false
|
||||
export let floatUp = false
|
||||
|
||||
let className: string;
|
||||
$: className = classCombine(['avatar', circle && 'avatar--circle', `avatar--size-${size}`, floatUp && 'avatar--float-up'])
|
||||
|
||||
let img
|
||||
|
||||
onMount(() => {
|
||||
if (img && img.naturalWidth) {
|
||||
const isPixelated = () => {
|
||||
if (img.naturalWidth < 96 && img.naturalWidth > 0) {
|
||||
img.style = 'image-rendering: pixelated;'
|
||||
}
|
||||
}
|
||||
|
||||
if (img.naturalWidth) {
|
||||
isPixelated()
|
||||
} else {
|
||||
img.onload = isPixelated
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if src}
|
||||
<img {src} bind:this={img} class={className} alt=""/>
|
||||
{:else}
|
||||
<svg
|
||||
class={className}
|
||||
xml:space="preserve"
|
||||
fill-rule="evenodd"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="1.5"
|
||||
clip-rule="evenodd"
|
||||
viewBox="0 0 104 104"
|
||||
>
|
||||
<path fill="none" d="M0 0h103.4v103.4H0z"/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="#9a9a9a"
|
||||
stroke-width="5"
|
||||
d="M51.7 92.5V51.7L16.4 31.3l35.3 20.4L87 31.3 51.7 11 16.4 31.3v40.8l35.3 20.4L87 72V31.3L51.7 11"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
<style lang="postcss">
|
||||
.avatar {
|
||||
border-radius: var(--rounded);
|
||||
box-shadow: var(--shadow-inset-lg), var(--shadow-raised-lg);
|
||||
height: var(--size);
|
||||
width: var(--size);
|
||||
background-color: var(--color-button-bg);
|
||||
|
||||
&--size {
|
||||
&-xs {
|
||||
--size: 2.25rem;
|
||||
box-shadow: var(--shadow-inset), var(--shadow-raised);
|
||||
border-radius: var(--rounded-sm);
|
||||
}
|
||||
|
||||
&-sm {
|
||||
--size: 3rem;
|
||||
box-shadow: var(--shadow-inset), var(--shadow-raised);
|
||||
border-radius: var(--rounded-sm);
|
||||
}
|
||||
|
||||
&-md {
|
||||
--size: 6rem;
|
||||
}
|
||||
|
||||
&-lg {
|
||||
--size: 9rem;
|
||||
background-color: var(--color-brand-contrast);
|
||||
}
|
||||
}
|
||||
|
||||
&--float-up {
|
||||
margin-top: calc(var(--size) * (-2 / 3));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&--circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
51
src/package/components/Badge.svelte
Normal file
51
src/package/components/Badge.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
export let label = ''
|
||||
/** Supports `green`, `yellow`, `red`, & `gray` */
|
||||
export let color = 'gray'
|
||||
</script>
|
||||
|
||||
<div class="badge badge--color-{color}">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.badge {
|
||||
font-weight: var(--font-weight-bold);
|
||||
display: inline;
|
||||
position: relative;
|
||||
padding-left: 0.9rem;
|
||||
line-height: 1rem;
|
||||
|
||||
&--color-green {
|
||||
color: var(--color-badge-green-text);
|
||||
--color-dot: var(--color-badge-green-dot);
|
||||
}
|
||||
|
||||
&--color-yellow {
|
||||
color: var(--color-badge-yellow-text);
|
||||
--color-dot: var(--color-badge-yellow-dot);
|
||||
}
|
||||
|
||||
&--color-red {
|
||||
color: var(--color-badge-red-text);
|
||||
--color-dot: var(--color-badge-red-dot);
|
||||
}
|
||||
|
||||
&--color-gray {
|
||||
color: var(--color-badge-gray-text);
|
||||
--color-dot: var(--color-badge-gray-dot);
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-dot);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 25%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
151
src/package/components/Button.svelte
Normal file
151
src/package/components/Button.svelte
Normal file
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
// TODO: sizes
|
||||
// TODO: icon only buttons should have uniform padding
|
||||
// TODO: Could be a class
|
||||
|
||||
import { classCombine } from '$package/utils/classCombine'
|
||||
|
||||
/** The element to be styled as a button */
|
||||
export let as: 'button' | 'a' | 'summary' | 'input' = 'button'
|
||||
export let href = ''
|
||||
if (href) as = 'a'
|
||||
|
||||
/** Use `value` if the button is an `<input`> */
|
||||
export let value = ''
|
||||
|
||||
export let size: 'sm' | 'md' | 'lg' = 'md'
|
||||
export let color: 'raised' | 'primary' | 'primary-light' | 'danger'| 'danger-light' | 'transparent'
|
||||
|
||||
/** Show notification badge in the upper right of button */
|
||||
export let badge = false
|
||||
|
||||
export let disabled = false
|
||||
|
||||
let className: string
|
||||
$: className = classCombine(['button', `button--size-${size}`, `button--color-${color}`, badge && 'has-badge'])
|
||||
</script>
|
||||
|
||||
{#if as === 'button'}
|
||||
<button class={className} {disabled} on:click>
|
||||
<slot/>
|
||||
</button>
|
||||
{:else if as === 'a'}
|
||||
<a class={className} {href} {disabled} on:click>
|
||||
<slot/>
|
||||
</a>
|
||||
{:else if as === 'summary'}
|
||||
<summary class={className} {disabled} on:click>
|
||||
<slot/>
|
||||
</summary>
|
||||
{:else if as === 'input'}
|
||||
<input class={className} {value} {disabled} on:click/>
|
||||
{/if}
|
||||
|
||||
<style lang="postcss">
|
||||
.button {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: 0.25rem 1rem;
|
||||
grid-gap: 14px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
box-shadow: var(--shadow-inset-sm);
|
||||
|
||||
background-color: var(--color-button-bg);
|
||||
border-radius: var(--rounded);
|
||||
transition: opacity 0.5s ease-in-out, filter 0.5s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-button-bg-hover);
|
||||
}
|
||||
|
||||
&--color {
|
||||
&-raised {
|
||||
background-color: var(--color-raised-bg);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-raised-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&-primary {
|
||||
background-color: var(--color-brand);
|
||||
color: var(--color-brand-contrast);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-brand-dark);
|
||||
}
|
||||
}
|
||||
|
||||
&-primary-light {
|
||||
background-color: var(--color-brand-light);
|
||||
transition: filter 0s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-brand-light);
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
&-transparent {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&-danger {
|
||||
background-color: var(--color-badge-red-dot);
|
||||
color: var(--color-brand-contrast);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-badge-red-text);
|
||||
}
|
||||
}
|
||||
|
||||
&-danger-light {
|
||||
background-color: var(--color-popup-danger-bg);
|
||||
color: var(--color-popup-danger-text);
|
||||
transition: filter 0s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 50%;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(50%);
|
||||
|
||||
/* Not ideal, but preventing events being fired needs to be implemented */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&--pad-even {
|
||||
padding: 0.5rem;
|
||||
font-size: 1rem;
|
||||
line-height: 0;
|
||||
min-width: 2rem;
|
||||
min-height: 2rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&.has-badge::after {
|
||||
content: '';
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: var(--rounded-max);
|
||||
background-color: var(--color-brand);
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Only child doesn't work as intended because text is passed through as `innerText` */
|
||||
:global(.icon:only-child) {
|
||||
margin: 4px -6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
66
src/package/components/Checkbox.svelte
Normal file
66
src/package/components/Checkbox.svelte
Normal file
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import uniqueId from 'lodash.uniqueid'
|
||||
import IconCheck from 'virtual:icons/heroicons-outline/check'
|
||||
|
||||
export let checked = false;
|
||||
|
||||
const id = `checkbox-${uniqueId()}`
|
||||
</script>
|
||||
|
||||
<div class="checkbox">
|
||||
<input type="checkbox" bind:checked={checked} on:change id={id} name={id}>
|
||||
<label for={id} class="checkbox__label">
|
||||
<span class="checkbox__label__box">
|
||||
<IconCheck />
|
||||
</span>
|
||||
<slot/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.checkbox {
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
grid-gap: 0.5rem;
|
||||
|
||||
&__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
> :global(.icon) {
|
||||
width: 1.1rem;
|
||||
margin-right: -0.1rem;
|
||||
}
|
||||
|
||||
&__box {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
display: flex;
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--color-button-bg);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
> :global(.icon) {
|
||||
display: none;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: var(--color-raised-bg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[type=checkbox]:checked + label span {
|
||||
background-color: var(--color-brand);
|
||||
|
||||
:global(.icon) {
|
||||
display: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
48
src/package/components/CheckboxList.svelte
Normal file
48
src/package/components/CheckboxList.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import Checkbox from './Checkbox.svelte'
|
||||
import type { SvelteComponent } from 'svelte'
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
/** The element that will be in the `value` array while the option is checked */
|
||||
value: string | number;
|
||||
icon: SvelteComponent;
|
||||
}
|
||||
|
||||
export let value = []
|
||||
export let options: Option[] = []
|
||||
|
||||
const handleChange = (e, key) => {
|
||||
if (e.target.checked) {
|
||||
if (!value) value = []
|
||||
value = [key, ...value]
|
||||
} else {
|
||||
value = value.filter((it) => key !== it)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="checkbox-list">
|
||||
{#each options as option}
|
||||
<Checkbox on:change={(e) => handleChange(e, option.value)}>
|
||||
{#if option.icon}
|
||||
<svelte:component this={option.icon}/>
|
||||
{/if}
|
||||
{option.label}
|
||||
</Checkbox>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.checkbox-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
&.wrap {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
grid-gap: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
45
src/package/components/CheckboxVirtualList.svelte
Normal file
45
src/package/components/CheckboxVirtualList.svelte
Normal file
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
// TODO: Add fade out styling on top and bottom
|
||||
|
||||
import Checkbox from './Checkbox.svelte'
|
||||
import type { SvelteComponent } from 'svelte'
|
||||
import VirtualList from 'svelte-tiny-virtual-list'
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
/** The element that will be in the `value` array while the option is checked */
|
||||
value: string | number;
|
||||
icon: SvelteComponent;
|
||||
}
|
||||
|
||||
/** Height in pixels of list */
|
||||
export let height = 200
|
||||
|
||||
export let value = []
|
||||
export let options: Option[] = []
|
||||
|
||||
const handleChange = (e, key) => {
|
||||
if (e.target.checked) {
|
||||
if (!value) value = []
|
||||
value = [key, ...value]
|
||||
} else {
|
||||
value = value.filter((it) => key !== it)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<VirtualList
|
||||
width="100%"
|
||||
{height}
|
||||
itemCount={options.length}
|
||||
itemSize={26}>
|
||||
<div slot="item" let:index let:style {style} style:padding-bottom={(options.length) - 1 === index ? '2.5rem' : ''}>
|
||||
{@const option = options[index]}
|
||||
<Checkbox checked={value.includes(option.value)} on:change={(e) => handleChange(e, option.value)}>
|
||||
{#if option.icon}
|
||||
<svelte:component this={option.icon}/>
|
||||
{/if}
|
||||
{option.label}
|
||||
</Checkbox>
|
||||
</div>
|
||||
</VirtualList>
|
||||
48
src/package/components/Chips.svelte
Normal file
48
src/package/components/Chips.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import Button from "./Button.svelte";
|
||||
import IconCheck from 'virtual:icons/heroicons-outline/check'
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
export let options: Option[] = [];
|
||||
export let value: string | number;
|
||||
// If set to true, one chip is always selected
|
||||
export let neverEmpty = false;
|
||||
|
||||
let selected: Option | null = options.find((option) => option.value === (value || ''));
|
||||
|
||||
$: if (selected) {
|
||||
value = selected.value;
|
||||
} else {
|
||||
value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="chips">
|
||||
{#each options as option}
|
||||
{@const isSelected = selected?.value === option.value}
|
||||
<Button
|
||||
color={isSelected ? 'primary-light' : undefined}
|
||||
on:click={() => {
|
||||
isSelected && !neverEmpty
|
||||
? selected = null
|
||||
: selected = option
|
||||
}}
|
||||
>
|
||||
{#if isSelected}
|
||||
<IconCheck />
|
||||
{/if}
|
||||
{option.label}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.chips {
|
||||
display: flex;
|
||||
grid-gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
24
src/package/components/FormField.svelte
Normal file
24
src/package/components/FormField.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import uniqueId from 'lodash.uniqueid'
|
||||
|
||||
export let required = false;
|
||||
export let label: string;
|
||||
|
||||
const id = `form-field-${uniqueId()}`
|
||||
</script>
|
||||
|
||||
<div class="form-field">
|
||||
<label for={id} class="text-input__label" class:required>
|
||||
{label}
|
||||
</label>
|
||||
<slot {id} />
|
||||
</div>
|
||||
|
||||
|
||||
<style lang="postcss">
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
81
src/package/components/NavRow.svelte
Normal file
81
src/package/components/NavRow.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
|
||||
interface Link {
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export let links: Link[];
|
||||
export let query = '';
|
||||
|
||||
export let resetScroll = false
|
||||
|
||||
/** Path level in URL, zero-indexed */
|
||||
export let level = 0;
|
||||
|
||||
let path: string[];
|
||||
$: path = [
|
||||
...$page.url.pathname
|
||||
.substring(1)
|
||||
.split('/')
|
||||
.map((route) => '/' + route),
|
||||
'/',
|
||||
];
|
||||
|
||||
$: basePath = path.slice(0, level).join('');
|
||||
</script>
|
||||
|
||||
<nav class="navigation">
|
||||
{#each links as link}
|
||||
<a
|
||||
href={query
|
||||
? link.href
|
||||
? `?${query}=${link.href}`
|
||||
: '?'
|
||||
: level === 0
|
||||
? link.href
|
||||
: basePath + link.href}
|
||||
|
||||
on:click={() => { if (resetScroll) document.body.scrollTo(0,0)}}
|
||||
|
||||
class="navigation__link"
|
||||
class:is-active={query
|
||||
? ($page.url.searchParams.get(query) || '') === link.href
|
||||
: path[level] === link.href || path[level] === link.href.slice(0, -1)}
|
||||
sveltekit:noscroll={!resetScroll}
|
||||
>{link.label}</a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style lang="postcss">
|
||||
.navigation {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
grid-gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__link {
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-light);
|
||||
|
||||
&.is-active {
|
||||
color: var(--color-text);
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: -0.1rem;
|
||||
width: 100%;
|
||||
border-radius: var(--rounded-max);
|
||||
height: 0.2rem;
|
||||
background-color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
62
src/package/components/Pagination.svelte
Normal file
62
src/package/components/Pagination.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
// TODO: Fix mobile support, currently just cuts off buttons
|
||||
|
||||
import IconArrowRight from 'virtual:icons/heroicons-outline/arrow-right'
|
||||
import IconArrowLeft from 'virtual:icons/heroicons-outline/arrow-left'
|
||||
import IconMinus from 'virtual:icons/heroicons-outline/minus'
|
||||
import Button from "./Button.svelte";
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
export let page: number;
|
||||
export let count: number;
|
||||
|
||||
$: options = count > 4
|
||||
? page + 3 >= count
|
||||
? [1, '-', count - 4, count - 3, count - 2, count - 1, count]
|
||||
: page > 4
|
||||
? [1, '-', page - 1, page, page + 1, '-', count]
|
||||
: [1, 2, 3, 4, 5, '-', count]
|
||||
: Array.from({ length: count }, (_, i) => i + 1)
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
{#if count > 1}
|
||||
<div class="pagination">
|
||||
<Button color="raised" on:click={() => dispatch('change', page - 1)} disabled={page <= 1} title="Last page"><IconArrowLeft height="20px" />
|
||||
</Button>
|
||||
{#each options as option}
|
||||
{#if option === '-'}
|
||||
<IconMinus class="pagination__dash" />
|
||||
{:else}
|
||||
<Button color={option === page ? 'primary' : 'raised'} on:click={() => dispatch('change', option)} evenPadding={true}>{option}</Button>
|
||||
{/if}
|
||||
{/each}
|
||||
<Button color="raised" on:click={() => dispatch('change', page + 1)} disabled={page >= count} title="Next page">
|
||||
<IconArrowRight height="20px" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="postcss">
|
||||
.pagination {
|
||||
align-self: center;
|
||||
display: flex;
|
||||
grid-gap: 0.5rem;
|
||||
align-items: center;
|
||||
|
||||
:global(&__dash) {
|
||||
margin: 0 0.5rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
@media (width <= 500px) {
|
||||
grid-gap: 0.25rem;
|
||||
|
||||
:global(> *:nth-child(4)), :global(> *:nth-child(6)) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
204
src/package/components/Select.svelte
Normal file
204
src/package/components/Select.svelte
Normal file
@@ -0,0 +1,204 @@
|
||||
<script lang="ts">
|
||||
import IconChevronDown from 'virtual:icons/lucide/chevron-down'
|
||||
import { debounce } from 'throttle-debounce'
|
||||
import { clickOutside } from 'svelte-use-click-outside'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
export let options: Option[] = []
|
||||
export let value: string | number
|
||||
export let selected: Option = options.find((option) => option.value === (value || ''))
|
||||
export let color = ''
|
||||
export let label = ''
|
||||
export let icon = null
|
||||
|
||||
let open = false
|
||||
|
||||
$: if (selected) {
|
||||
value = selected.value
|
||||
}
|
||||
|
||||
const minWidth = options
|
||||
.map(it => it.label || it.value)
|
||||
.reduce((it, acc) => String(it).length > acc ? it : acc, '')
|
||||
.length * 9
|
||||
|
||||
let shouldOpenUp = false
|
||||
let element: HTMLElement
|
||||
|
||||
const checkShouldOpenUp = debounce(100, false, () => {
|
||||
if (element) {
|
||||
const bounding = element.getBoundingClientRect()
|
||||
|
||||
shouldOpenUp =
|
||||
bounding.bottom + 32 * options.length + 16 >
|
||||
(window.innerHeight || document.documentElement.clientHeight)
|
||||
}
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
checkShouldOpenUp()
|
||||
window.addEventListener('resize', checkShouldOpenUp)
|
||||
})
|
||||
|
||||
function keydown(event: KeyboardEvent) {
|
||||
if ((event.key === ' ' || event.key === 'Enter') && !open) {
|
||||
open = true
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
if (selected) {
|
||||
const index = options.findIndex((option) => option.value === selected.value)
|
||||
if (index > 0) {
|
||||
selected = options[index - 1]
|
||||
}
|
||||
}
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
if (selected) {
|
||||
const index = options.findIndex((option) => option.value === selected.value)
|
||||
if (index < options.length - 1) {
|
||||
selected = options[index + 1]
|
||||
}
|
||||
}
|
||||
} else if ((event.key === 'Escape' || event.key === 'Enter') && open) {
|
||||
open = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="select select--color-{color}"
|
||||
class:is-open={open}
|
||||
class:select--opens-up={shouldOpenUp}
|
||||
use:clickOutside={() => {
|
||||
open = false;
|
||||
}}
|
||||
bind:this={element}
|
||||
tabindex="0"
|
||||
on:keydown={keydown}
|
||||
>
|
||||
<div
|
||||
class="select__input"
|
||||
on:click={() => {
|
||||
open = !open;
|
||||
}}
|
||||
>
|
||||
{#if icon}
|
||||
<svelte:component this={icon}/>
|
||||
{/if}
|
||||
<span class="select__input__value" style:min-width="{minWidth}px">{label || selected?.label || value || 'Choose...'}</span>
|
||||
{#if !icon}
|
||||
<div class="select__input__arrow">
|
||||
<slot name="expandIcon">
|
||||
<IconChevronDown/>
|
||||
</slot>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if open}
|
||||
<ul class="select__options">
|
||||
{#each options as option (option.value)}
|
||||
<li
|
||||
on:click={() => {
|
||||
selected = option;
|
||||
open = false;
|
||||
}}
|
||||
class:is-selected={selected?.value === option.value}
|
||||
>
|
||||
{option.label || option.value}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/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 0.9rem;
|
||||
grid-gap: 0.4rem;
|
||||
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: 100%;
|
||||
background-color: var(--color-button-bg);
|
||||
border-radius: var(--rounded-bottom);
|
||||
box-shadow: var(--shadow-inset-sm), var(--shadow-floating);
|
||||
overflow: hidden;
|
||||
border-top: 0.1rem solid var(--color-divider);
|
||||
z-index: 5;
|
||||
|
||||
li {
|
||||
padding: 0.25rem 1rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-button-bg-hover);
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
background-color: var(--color-brand-dark);
|
||||
color: var(--color-brand-contrast);
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-open {
|
||||
z-index: 10;
|
||||
|
||||
.select__input {
|
||||
border-radius: var(--rounded-top);
|
||||
box-shadow: none;
|
||||
|
||||
&__arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--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: 0.1rem solid var(--color-divider);
|
||||
}
|
||||
|
||||
&.is-open .select__input {
|
||||
border-radius: var(--rounded-bottom);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
46
src/package/components/TextInput.svelte
Normal file
46
src/package/components/TextInput.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import type { SvelteComponent } from 'svelte'
|
||||
|
||||
export let placeholder = ''
|
||||
export let icon: SvelteComponent = ''
|
||||
export let value = ''
|
||||
export let multiline = false
|
||||
export let id: string = undefined
|
||||
</script>
|
||||
|
||||
<div class="text-input">
|
||||
{#if multiline}
|
||||
<textarea name={id} {placeholder} bind:value={value}/>
|
||||
{:else}
|
||||
<input type="text" name={id} {placeholder} bind:value={value}/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
<style lang="postcss">
|
||||
.text-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
input, textarea {
|
||||
border-radius: var(--rounded-sm);
|
||||
box-shadow: var(--shadow-inset-sm);
|
||||
background-color: var(--color-button-bg);
|
||||
border: none;
|
||||
padding: 0.25rem 0.75rem;
|
||||
width: 20rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&--fill {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
textarea {
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user