You've already forked AstralRinth
forked from didirus/AstralRinth
Start omorphia 2 (#8)
This commit is contained in:
@@ -1,104 +0,0 @@
|
||||
<script lang="ts">
|
||||
// TODO: Make square icon `md` more rounded
|
||||
|
||||
import { onMount } from 'svelte'
|
||||
import { classCombine } from '../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: HTMLImageElement
|
||||
|
||||
onMount(() => {
|
||||
if (img && img.naturalWidth) {
|
||||
const isPixelated = () => {
|
||||
if (img.naturalWidth < 96 && img.naturalWidth > 0) {
|
||||
img.style.imageRendering = '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"
|
||||
aria-hidden="true">
|
||||
<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);
|
||||
object-fit: contain;
|
||||
|
||||
&--size {
|
||||
&-xs {
|
||||
--size: 2.5rem;
|
||||
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;
|
||||
border-radius: var(--rounded-lg);
|
||||
}
|
||||
|
||||
&-lg {
|
||||
--size: 9rem;
|
||||
border-radius: var(--rounded-lg);
|
||||
}
|
||||
}
|
||||
|
||||
&--float-up {
|
||||
margin-top: calc(var(--size) * (-2 / 3));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&--circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,51 +0,0 @@
|
||||
<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: 1rem;
|
||||
line-height: 1em;
|
||||
|
||||
&--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;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-dot);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 25%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,199 +0,0 @@
|
||||
<script lang="ts">
|
||||
// TODO: sizes
|
||||
// TODO: icon only buttons should have uniform padding
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { classCombine } from '../utils/classCombine'
|
||||
|
||||
/** The element to be styled as a button */
|
||||
export let as: 'button' | 'a' | 'summary' | 'input' | 'file' = '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:
|
||||
| ''
|
||||
| 'primary'
|
||||
| 'primary-light'
|
||||
| 'secondary'
|
||||
| 'tertiary'
|
||||
| 'danger'
|
||||
| 'danger-light'
|
||||
| 'transparent' = ''
|
||||
|
||||
/** Applies a stronger shadow to the element. To be used when the button isn't on a card */
|
||||
export let raised = false
|
||||
|
||||
/** Show notification badge in the upper right of button */
|
||||
export let badge = false
|
||||
|
||||
export let disabled = false
|
||||
|
||||
/** Hover title for accessibility */
|
||||
export let title = ''
|
||||
|
||||
/** Link target */
|
||||
export let target = ''
|
||||
|
||||
let className: string
|
||||
$: className = classCombine([
|
||||
'button',
|
||||
`button--size-${size}`,
|
||||
`button--color-${color}`,
|
||||
raised && 'button--raised',
|
||||
badge && 'has-badge',
|
||||
])
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
function dispatchClick() {
|
||||
if (!disabled) dispatch('click')
|
||||
}
|
||||
|
||||
// Handle `change` event on file input
|
||||
function handleChangeFiles(event: Event) {
|
||||
if (!disabled) dispatch('files', (event.target as HTMLInputElement).files || new FileList())
|
||||
}
|
||||
|
||||
// Handle `drop` event on file input
|
||||
function handleDropFiles(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
if (!disabled) dispatch('files', event.dataTransfer.files || new FileList())
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if as === 'a'}
|
||||
<a class={className} {href} {disabled} {title} {target} on:click={dispatchClick}>
|
||||
<slot />
|
||||
</a>
|
||||
{:else if as === 'input'}
|
||||
<input class={className} {value} {disabled} {title} on:click={dispatchClick} />
|
||||
{:else if as === 'file'}
|
||||
<label
|
||||
class={className}
|
||||
{disabled}
|
||||
{title}
|
||||
on:drop={handleDropFiles}
|
||||
on:dragover={(event) => event.preventDefault()}>
|
||||
<input type="file" on:change={handleChangeFiles} />
|
||||
<slot />
|
||||
</label>
|
||||
{:else}
|
||||
<svelte:element this={as} class={className} {disabled} {title} on:click={dispatchClick}>
|
||||
<slot />
|
||||
</svelte:element>
|
||||
{/if}
|
||||
|
||||
<style lang="postcss">
|
||||
.button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem;
|
||||
min-width: min-content;
|
||||
grid-gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
line-height: 100%;
|
||||
|
||||
color: var(--color-bg-contrast);
|
||||
|
||||
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.2s ease-in-out, transform 0.05s ease-in-out,
|
||||
outline 0.2s ease-in-out;
|
||||
|
||||
&--raised {
|
||||
background-color: var(--color-raised-bg);
|
||||
box-shadow: var(--shadow-inset-sm), var(--shadow-raised);
|
||||
}
|
||||
|
||||
&:hover:not(&--color-transparent, &:disabled) {
|
||||
filter: brightness(0.85);
|
||||
}
|
||||
|
||||
&:active:not(&--color-transparent, &:disabled) {
|
||||
transform: scale(0.95);
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
|
||||
&--color {
|
||||
&-primary {
|
||||
background-color: var(--color-brand);
|
||||
color: var(--color-brand-contrast);
|
||||
}
|
||||
|
||||
&-primary-light {
|
||||
background-color: var(--color-brand-light);
|
||||
}
|
||||
|
||||
&-secondary {
|
||||
background-color: var(--color-secondary);
|
||||
color: var(--color-brand-contrast);
|
||||
}
|
||||
|
||||
&-tertiary {
|
||||
background-color: var(--color-tertiary);
|
||||
}
|
||||
|
||||
&-transparent {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
filter: brightness(1) !important;
|
||||
|
||||
&:hover {
|
||||
background-image: linear-gradient(rgba(0, 0, 0, 0.1) 0 0);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-image: linear-gradient(rgba(0, 0, 0, 0.2) 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
&-danger {
|
||||
background-color: var(--color-badge-red-dot);
|
||||
color: var(--color-brand-contrast);
|
||||
}
|
||||
|
||||
&-danger-light {
|
||||
background-color: var(--color-danger-bg);
|
||||
color: var(--color-danger-text);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 50%;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(50%);
|
||||
}
|
||||
|
||||
&--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;
|
||||
}
|
||||
|
||||
/* Hides default file input */
|
||||
input[type='file'] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,67 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { uniqueId } from '../utils/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 on:change {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;
|
||||
grid-gap: 0.5rem;
|
||||
|
||||
> :global(.icon) {
|
||||
/* Icon on checkbox helps to be a little larger than normal icons 16px -> 18px */
|
||||
width: 18px;
|
||||
margin-right: -2px;
|
||||
}
|
||||
|
||||
&__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>
|
||||
@@ -1,48 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Checkbox from './Checkbox.svelte'
|
||||
import type { Option } from './types'
|
||||
|
||||
export let value = []
|
||||
export let options: Option[] = []
|
||||
|
||||
/** Wrap the options horizontally */
|
||||
export let wrap = false
|
||||
|
||||
const handleChange = (event: any, key: string | number) => {
|
||||
if (event.target.checked) {
|
||||
if (!value) value = []
|
||||
value = [key, ...value]
|
||||
} else {
|
||||
value = value.filter((it) => key !== it)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="checkbox-list" class:wrap>
|
||||
{#each options as option}
|
||||
<Checkbox
|
||||
on:change={(e) => handleChange(e, option.value)}
|
||||
checked={value && value.includes(option.value)}>
|
||||
{#if option.icon && typeof option.icon === 'string'}
|
||||
{@html option.icon}
|
||||
{:else if option.icon}
|
||||
<svelte:component this={option.icon} />
|
||||
{/if}
|
||||
{option.label}
|
||||
</Checkbox>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.checkbox-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-gap: 2px;
|
||||
|
||||
&.wrap {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
grid-gap: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,55 +0,0 @@
|
||||
<script lang="ts">
|
||||
// TODO: Add fade out styling on top and bottom
|
||||
|
||||
import Checkbox from './Checkbox.svelte'
|
||||
import VirtualList from 'svelte-tiny-virtual-list'
|
||||
import type { Option } from './types'
|
||||
|
||||
/** Height in pixels of list */
|
||||
export let height = 200
|
||||
|
||||
export let value = []
|
||||
export let options: Option[] = []
|
||||
|
||||
// Scroll into view selected options when created
|
||||
let scrollToIndex =
|
||||
Math.min(...value.map((val) => options.map((it) => it.value).indexOf(val))) || null
|
||||
|
||||
const handleChange = (event: any, key: string | number) => {
|
||||
scrollToIndex = null
|
||||
if (event.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}
|
||||
{scrollToIndex}
|
||||
scrollToAlignment="center"
|
||||
scrollToBehaviour="smooth">
|
||||
<div
|
||||
slot="item"
|
||||
let:index
|
||||
let:style
|
||||
{style}
|
||||
style:padding-bottom={options.length - 1 === index ? '2.5rem' : ''}>
|
||||
{@const option = options[index]}
|
||||
<Checkbox
|
||||
checked={value && value.includes(option.value)}
|
||||
on:change={(e) => handleChange(e, option.value)}>
|
||||
{#if option.icon && typeof option.icon === 'string'}
|
||||
{@html option.icon}
|
||||
{:else if option.icon}
|
||||
<svelte:component this={option.icon} />
|
||||
{/if}
|
||||
{option.label}
|
||||
</Checkbox>
|
||||
</div>
|
||||
</VirtualList>
|
||||
@@ -1,45 +0,0 @@
|
||||
<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>
|
||||
@@ -1,45 +0,0 @@
|
||||
<script lang="ts">
|
||||
import IconClipboardCopy from 'virtual:icons/heroicons-outline/clipboard-copy'
|
||||
import IconCheck from 'virtual:icons/heroicons-outline/check'
|
||||
|
||||
export let text: string
|
||||
|
||||
let copied = false
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="code"
|
||||
class:copied
|
||||
title="Copy code to clipboard"
|
||||
on:click={async () => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copied = true
|
||||
}}>
|
||||
{text}
|
||||
{#if copied}
|
||||
<IconCheck />
|
||||
{:else}
|
||||
<IconClipboardCopy />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style lang="postcss">
|
||||
.code {
|
||||
display: flex;
|
||||
grid-gap: 0.5rem;
|
||||
font-family: var(--mono-font);
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--color-code-bg);
|
||||
width: min-content;
|
||||
border-radius: var(--rounded-sm);
|
||||
user-select: text;
|
||||
|
||||
&.copied {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:hover:not(.copied) {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,47 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { uniqueId } from '../utils/uniqueId'
|
||||
|
||||
export let required = false
|
||||
export let label: string
|
||||
export let helper = ''
|
||||
|
||||
const id = `field-${uniqueId()}`
|
||||
</script>
|
||||
|
||||
<div class="field">
|
||||
<label for={id} class="field__label" class:required>
|
||||
<span class="field__label__title">{@html label}</span>
|
||||
{#if helper}
|
||||
<span class="field__label__helper">{helper}</span>
|
||||
{/if}
|
||||
</label>
|
||||
<slot {id} />
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-gap: 0.5rem;
|
||||
min-width: 0;
|
||||
|
||||
&__label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-gap: 0;
|
||||
|
||||
&__title {
|
||||
font-weight: var(--font-weight-bold);
|
||||
|
||||
:global(i) {
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
}
|
||||
|
||||
&__helper {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,117 +0,0 @@
|
||||
<script lang="ts">
|
||||
import IconTrash from 'virtual:icons/heroicons-outline/trash'
|
||||
import IconUpload from 'virtual:icons/heroicons-outline/upload'
|
||||
import IconPhotograph from 'virtual:icons/heroicons-outline/photograph'
|
||||
import IconFile from 'virtual:icons/lucide/file'
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
import Button from './Button.svelte'
|
||||
import { classCombine } from '../utils/classCombine'
|
||||
|
||||
interface RemoteFile {
|
||||
name: string
|
||||
remote: true
|
||||
}
|
||||
|
||||
export let multiple = false
|
||||
export let accept = '*'
|
||||
/** Prevents width from expanding due to large file names or images */
|
||||
export let constrained = false
|
||||
|
||||
export let files: (File | RemoteFile)[] = []
|
||||
export let file: File | RemoteFile | undefined = undefined
|
||||
$: if (files) file = files[0] || undefined
|
||||
|
||||
let inputElement: HTMLInputElement
|
||||
|
||||
function addFiles(fileList: FileList) {
|
||||
for (const file of Array.from(fileList)) {
|
||||
// Check for duplicate files that aren't remote
|
||||
if (
|
||||
!files
|
||||
.filter((it) => !('remote' in file))
|
||||
.map((file) => file.name)
|
||||
.includes(file.name)
|
||||
) {
|
||||
if (files.map((file) => file.name).includes(file.name)) {
|
||||
// Remove remote file with the same name
|
||||
files = files.filter((it) => it.name !== file.name)
|
||||
}
|
||||
files = [...files, file]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={classCombine(['file-dropzone', constrained && 'file-dropzone--constrained'])}>
|
||||
{#if !file || multiple}
|
||||
<div
|
||||
class="file-dropzone__input"
|
||||
on:drop|preventDefault={(event) => addFiles(event.dataTransfer.files)}
|
||||
on:dragover|preventDefault
|
||||
on:click={() => {
|
||||
if (inputElement) inputElement.click()
|
||||
}}>
|
||||
<IconUpload />
|
||||
{$t('images.how_to')}
|
||||
|
||||
<input
|
||||
type="file"
|
||||
{multiple}
|
||||
{accept}
|
||||
style:display="none"
|
||||
bind:this={inputElement}
|
||||
on:change={() => addFiles(inputElement.files)} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each files as file (file.name)}
|
||||
<div class="file">
|
||||
<div class="file__tab">
|
||||
{#if !('remote' in file) && file.type.startsWith('image/')}
|
||||
<IconPhotograph />
|
||||
{:else}
|
||||
<IconFile />
|
||||
{/if}
|
||||
<div class="file__tab__name"><b>{file.name}</b></div>
|
||||
<Button
|
||||
color="tertiary"
|
||||
on:click={() => {
|
||||
files = files.filter((it) => it.name !== file.name)
|
||||
}}><IconTrash /> Remove</Button>
|
||||
</div>
|
||||
{#if !('remote' in file) && file.type.startsWith('image/')}
|
||||
<div class="file__preview">
|
||||
<img
|
||||
class="file__preview__image"
|
||||
src={URL.createObjectURL(file)}
|
||||
alt="Uploaded file preview" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.file-dropzone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-gap: 1rem;
|
||||
|
||||
&--constrained {
|
||||
width: 27rem;
|
||||
}
|
||||
|
||||
&__input {
|
||||
display: flex;
|
||||
padding: 1.5rem 2rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
grid-gap: 0.5rem;
|
||||
background-color: var(--color-button-bg);
|
||||
border-radius: var(--rounded-sm);
|
||||
border: dashed 0.3rem var(--color-text-lightest);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,159 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { fade, fly } from 'svelte/transition'
|
||||
import Button from './Button.svelte'
|
||||
import { classCombine } from '../utils/classCombine'
|
||||
import IconX from 'virtual:icons/heroicons-outline/x'
|
||||
|
||||
export let open = false
|
||||
|
||||
/** Set the width of the modal */
|
||||
export let size: 'sm' | 'md' | 'lg' = 'md'
|
||||
|
||||
export let title = ''
|
||||
|
||||
/** If enabled, clicking outside the modal with close it */
|
||||
export let dismissable = true
|
||||
|
||||
export let defaultData: Record<string, any> = {}
|
||||
export let data: Record<string, any> = defaultData
|
||||
|
||||
$: if (open === true && typeof window !== 'undefined') {
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
}, 300)
|
||||
}
|
||||
|
||||
$: if (open === false) {
|
||||
data = defaultData
|
||||
}
|
||||
|
||||
function close() {
|
||||
open = false
|
||||
}
|
||||
|
||||
function trigger() {
|
||||
open = !open
|
||||
}
|
||||
</script>
|
||||
|
||||
<slot name="trigger" {trigger} />
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="modal-background"
|
||||
transition:fade={{ duration: 300 }}
|
||||
on:click={() => {
|
||||
if (dismissable) close()
|
||||
}} />
|
||||
|
||||
<div
|
||||
class={classCombine(['modal', `modal--size-${size}`, 'card'])}
|
||||
transition:fly={{ y: 400, duration: 250 }}>
|
||||
{#if title}
|
||||
<div class="modal__top">
|
||||
<h2 class="title-secondary">{title}</h2>
|
||||
<Button title="Close" color="transparent" on:click={close}>
|
||||
<IconX width={20} />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<slot />
|
||||
|
||||
<div class="modal__buttons">
|
||||
<Button on:click={close}><IconX /> Cancel</Button>
|
||||
<slot name="button" {close} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="postcss">
|
||||
:global(.base.theme-dark > .modal, .base.theme-oled > .modal) {
|
||||
border: 1px solid var(--color-divider);
|
||||
}
|
||||
|
||||
.modal-background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: hsla(0, 0%, 0%, 0.5);
|
||||
backdrop-filter: blur(3px);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
width: 80%;
|
||||
z-index: 21;
|
||||
max-height: calc(100% - 2rem);
|
||||
overflow-y: auto;
|
||||
|
||||
&--size {
|
||||
&-sm {
|
||||
max-width: 450px;
|
||||
}
|
||||
&-md {
|
||||
max-width: 600px;
|
||||
}
|
||||
&-lg {
|
||||
max-width: 750px;
|
||||
}
|
||||
}
|
||||
|
||||
&__top {
|
||||
background-color: var(--color-bg);
|
||||
margin: -1rem -1rem 0.5rem -1rem;
|
||||
padding: 1rem 1rem 1rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__danger {
|
||||
margin: -1.5rem -1rem 0.5rem;
|
||||
background-color: var(--color-danger-bg);
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
grid-gap: 1rem;
|
||||
color: var(--color-danger-text);
|
||||
border-color: var(--color-danger-text);
|
||||
border-width: 0.15rem 0;
|
||||
border-style: solid;
|
||||
|
||||
:global(.icon) {
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
:global(p),
|
||||
:global(ul),
|
||||
:global(ol) {
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
|
||||
:global(a) {
|
||||
color: var(--color-link);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__buttons {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
grid-gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,68 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Modal from './Modal.svelte'
|
||||
import Button from './Button.svelte'
|
||||
import TextInput from './TextInput.svelte'
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
import IconExclamation from 'virtual:icons/heroicons-outline/exclamation'
|
||||
import IconTrash from 'virtual:icons/heroicons-outline/trash'
|
||||
import { markdown } from '../utils'
|
||||
import Field from './Field.svelte'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
export let key: string
|
||||
export let type: 'project' | 'version' | 'account' | 'image'
|
||||
|
||||
let data: { key?: string } = {}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
</script>
|
||||
|
||||
<!-- @ts-ignore -->
|
||||
<Modal title={$t(`modal.deletion.${type}.title`)} bind:data>
|
||||
<svelte:fragment slot="trigger" let:trigger>
|
||||
<slot name="trigger" {trigger} />
|
||||
</svelte:fragment>
|
||||
|
||||
{#if type === 'account' || 'project'}
|
||||
<div class="important-banner">
|
||||
<IconExclamation /><span>{$t('modal.deletion.generic.important')}</span>
|
||||
</div>
|
||||
{/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')} bind:value={data.key} {id} />
|
||||
</Field>
|
||||
|
||||
<svelte:fragment slot="button" let:close>
|
||||
<Button
|
||||
color="danger"
|
||||
disabled={key !== data.key}
|
||||
on:click={() => {
|
||||
close()
|
||||
dispatch('deletion')
|
||||
}}>
|
||||
<IconTrash />
|
||||
{$t(`modal.deletion.${type}.action`)}
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
|
||||
<style lang="postcss">
|
||||
.important-banner {
|
||||
margin: -1.5rem -1rem 0.5rem;
|
||||
background-color: var(--color-danger-bg);
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
grid-gap: 1rem;
|
||||
color: var(--color-danger-text);
|
||||
border-color: var(--color-danger-text);
|
||||
border-width: 0.15rem 0;
|
||||
border-style: solid;
|
||||
|
||||
:global(.icon) {
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,186 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { browser, prerendering } from '$app/env'
|
||||
import { page } from '$app/stores'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { debounce } from 'throttle-debounce'
|
||||
|
||||
interface Link {
|
||||
href: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export let links: Link[]
|
||||
/** Query param name to use (to not use queries, leave this prop blank) */
|
||||
export let query = ''
|
||||
|
||||
export let noScroll: true | null = null
|
||||
|
||||
/** 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('')
|
||||
|
||||
/* Animation */
|
||||
const FAST_TIMING = 'cubic-bezier(1,0,.3,1) -140ms'
|
||||
const SLOW_TIMING = 'cubic-bezier(.75,-0.01,.24,.99) -40ms'
|
||||
|
||||
$: activeIndex = query
|
||||
? prerendering
|
||||
? -1
|
||||
: links.findIndex((link) => ($page.url.searchParams.get(query) || '') === link.href)
|
||||
: links.findIndex((link) => path[level] === link.href || path[level] === link.href.slice(0, -1))
|
||||
|
||||
let oldIndex = -1
|
||||
|
||||
const linkElements: HTMLAnchorElement[] = []
|
||||
|
||||
const indicator = {
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 22,
|
||||
direction: 'right',
|
||||
}
|
||||
|
||||
$: if (activeIndex > -1 && browser && linkElements.length > 0) startAnimation()
|
||||
|
||||
function startAnimation() {
|
||||
// Avoids error that `linkElements[activeIndex]` is null
|
||||
if (linkElements[activeIndex]) {
|
||||
indicator.direction = activeIndex < oldIndex ? 'left' : 'right'
|
||||
|
||||
indicator.left = linkElements[activeIndex].offsetLeft
|
||||
indicator.right =
|
||||
linkElements[activeIndex].parentElement.offsetWidth -
|
||||
linkElements[activeIndex].offsetLeft -
|
||||
linkElements[activeIndex].offsetWidth
|
||||
indicator.top =
|
||||
linkElements[activeIndex].offsetTop + linkElements[activeIndex].offsetHeight - 2
|
||||
}
|
||||
|
||||
oldIndex = activeIndex
|
||||
}
|
||||
|
||||
const debounced = debounce(100, startAnimation)
|
||||
|
||||
let useAnimation = false
|
||||
|
||||
onMount(() => {
|
||||
setTimeout(() => {
|
||||
useAnimation = true
|
||||
}, 300)
|
||||
|
||||
window.addEventListener('resize', debounced)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
if (browser) window.removeEventListener('resize', debounced)
|
||||
})
|
||||
</script>
|
||||
|
||||
<nav class="navigation" class:use-animation={useAnimation}>
|
||||
{#each links as link, index}
|
||||
<a
|
||||
href={query
|
||||
? link.href
|
||||
? `?${query}=${link.href}`
|
||||
: '?'
|
||||
: level === 0
|
||||
? link.href
|
||||
: basePath + link.href}
|
||||
class="navigation__link"
|
||||
class:is-active={index === activeIndex}
|
||||
sveltekit:noscroll={noScroll}
|
||||
sveltekit:prefetch
|
||||
bind:this={linkElements[index]}>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
<div
|
||||
class="navigation__indicator"
|
||||
style:visibility={useAnimation && activeIndex !== -1 ? 'visible' : 'hidden'}
|
||||
style:left={indicator.left + 'px'}
|
||||
style:right={indicator.right + 'px'}
|
||||
style:top={indicator.top + 'px'}
|
||||
style:transition={`left 350ms ${
|
||||
indicator.direction === 'left' ? FAST_TIMING : SLOW_TIMING
|
||||
},right 350ms ${
|
||||
indicator.direction === 'right' ? FAST_TIMING : SLOW_TIMING
|
||||
}, top 100ms ease-in-out`} />
|
||||
</nav>
|
||||
|
||||
<style lang="postcss">
|
||||
.navigation {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
grid-gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
|
||||
&__link {
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-light);
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
width: 100%;
|
||||
border-radius: var(--rounded-max);
|
||||
height: 0.25rem;
|
||||
transition: opacity 0.1s ease-in-out;
|
||||
background-color: var(--color-brand);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
|
||||
&::after {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
&:active::after {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
color: var(--color-text);
|
||||
|
||||
&::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.use-animation {
|
||||
.navigation__link {
|
||||
&.is-active::after {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__indicator {
|
||||
position: absolute;
|
||||
height: 0.25rem;
|
||||
border-radius: var(--rounded-max);
|
||||
background-color: var(--color-brand);
|
||||
transition-property: left, right, top;
|
||||
transition-duration: 350ms;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,83 +0,0 @@
|
||||
<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
|
||||
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)}>{option}</Button>
|
||||
{/if}
|
||||
{/each}
|
||||
<Button
|
||||
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(.button) {
|
||||
box-shadow: var(--shadow-inset-sm), var(--shadow-raised);
|
||||
}
|
||||
|
||||
:global(.icon) {
|
||||
width: 1rem;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
:global(&__dash) {
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
@media (width <= 500px) {
|
||||
grid-gap: 0.25rem;
|
||||
|
||||
:global(> *:nth-child(4)),
|
||||
:global(> *:nth-child(6)) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,312 +0,0 @@
|
||||
<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 { onDestroy, onMount, tick } from 'svelte'
|
||||
import { debounce } from 'throttle-debounce'
|
||||
import { browser } from '$app/env'
|
||||
|
||||
interface Option {
|
||||
label: string
|
||||
value: string | number
|
||||
}
|
||||
|
||||
export let options: Option[] = []
|
||||
export let value: string | number
|
||||
export let color = ''
|
||||
export let label = ''
|
||||
export let icon = null
|
||||
|
||||
let selected: Option | undefined
|
||||
let open = false
|
||||
let direction = 'down'
|
||||
let checkingDirection = false
|
||||
let element: HTMLDivElement
|
||||
|
||||
$: if (selected) value = selected.value
|
||||
|
||||
$: if (options) setSelected()
|
||||
|
||||
function setSelected() {
|
||||
selected = options.find((option) => option.value === (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 * 8.3
|
||||
}
|
||||
},
|
||||
// Reuses the same canvas object
|
||||
{ canvas: null }
|
||||
)
|
||||
|
||||
const minWidth = Math.max(
|
||||
...options.map((it) => getTextWidth(String(it.label || it.value), '16px Inter')),
|
||||
...(!value ? [71] : []) // width of "Choose..." text
|
||||
)
|
||||
|
||||
function selectOption(option: Option) {
|
||||
selected = option
|
||||
open = false
|
||||
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) {
|
||||
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
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const debounced = debounce(100, checkDirection)
|
||||
|
||||
onMount(() => {
|
||||
checkDirection()
|
||||
|
||||
window.addEventListener('resize', debounced)
|
||||
document.body.addEventListener('scroll', debounced)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
if (browser) {
|
||||
window.removeEventListener('resize', debounced)
|
||||
document.body.removeEventListener('scroll', debounced)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="select select--color-{color}"
|
||||
use:clickOutside={() => (open = false)}
|
||||
bind:this={element}
|
||||
tabindex="0"
|
||||
on:focus={() => (open = true)}
|
||||
on:blur={() => (open = false)}
|
||||
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>
|
||||
<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">
|
||||
.select {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--color-button-text);
|
||||
cursor: pointer;
|
||||
border-radius: var(--rounded);
|
||||
align-self: flex-start;
|
||||
user-select: none;
|
||||
|
||||
&__input {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.25rem 1rem;
|
||||
grid-gap: 0.5rem;
|
||||
background-color: var(--color-button-bg);
|
||||
box-shadow: var(--shadow-inset-sm);
|
||||
border-radius: var(--rounded);
|
||||
}
|
||||
|
||||
&__options {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
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);
|
||||
overflow: hidden;
|
||||
z-index: 5;
|
||||
visibility: hidden;
|
||||
max-height: 32px;
|
||||
transition: max-height 0.3s ease-in-out, visibility 0.3s ease-in-out;
|
||||
|
||||
&.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;
|
||||
grid-gap: 0.5rem;
|
||||
|
||||
&: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);
|
||||
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>
|
||||
@@ -1,48 +0,0 @@
|
||||
<script lang="ts">
|
||||
export let value: number
|
||||
export let min: number
|
||||
export let max: number
|
||||
export let id: string = undefined
|
||||
</script>
|
||||
|
||||
<div class="slider">
|
||||
<input class="slider__input" type="range" {id} {min} {max} bind:value />
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.slider {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
grid-gap: 0.5rem;
|
||||
|
||||
&__input {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border-radius: var(--rounded-sm);
|
||||
box-shadow: var(--shadow-inset-sm);
|
||||
background-color: var(--color-button-bg);
|
||||
border: none;
|
||||
padding: 0.25rem 0;
|
||||
width: 20rem;
|
||||
height: 0.5rem;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
cursor: ew-resize;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: var(--rounded-sm);
|
||||
background-color: var(--color-brand);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-brand-dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,77 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { classCombine } from '$lib/utils/classCombine'
|
||||
|
||||
export let placeholder = ''
|
||||
/** A Svelte component */
|
||||
export let icon: any = undefined
|
||||
export let value = ''
|
||||
export let multiline = false
|
||||
/** An ID for better accessibility */
|
||||
export let id: string = undefined
|
||||
export let fill = false
|
||||
export let raised = false
|
||||
export let autofocus = false
|
||||
</script>
|
||||
|
||||
<div class={classCombine(['text-input', raised && 'text-input--raised'])} class:fill>
|
||||
{#if multiline}
|
||||
<textarea {id} {placeholder} {autofocus} bind:value />
|
||||
{:else}
|
||||
<input type="text" {id} {placeholder} {autofocus} bind:value class:has-icon={icon} />
|
||||
{#if icon}
|
||||
<svelte:component this={icon} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.text-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-gap: 0.5rem;
|
||||
position: relative;
|
||||
width: 20rem;
|
||||
|
||||
&.fill {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
border-radius: var(--rounded);
|
||||
box-shadow: var(--shadow-inset-sm);
|
||||
background-color: var(--color-button-bg);
|
||||
border: none;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&--raised > input,
|
||||
&--raised > textarea {
|
||||
border: 2px solid var(--color-text-lightest);
|
||||
box-shadow: var(--shadow-inset-sm), var(--shadow-raised);
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0.25rem 1rem;
|
||||
|
||||
&.has-icon {
|
||||
padding-left: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 2.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
:global(.icon) {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
left: 1rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script setup>
|
||||
import Icon from "@/components/base/Icon.vue";
|
||||
|
||||
import { computed, useSlots } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
link: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
external: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
action: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
design: {
|
||||
type: String,
|
||||
default: "default",
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "default",
|
||||
},
|
||||
});
|
||||
|
||||
const defaultDesign = computed(() => props.design === "default");
|
||||
const accentedButton = computed(
|
||||
() => defaultDesign.value && ["danger", "primary"].includes(props.color)
|
||||
);
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
const iconOnly = computed(() => {
|
||||
if (slots.default) {
|
||||
const slot = slots.default();
|
||||
return (
|
||||
slot.length === 1 &&
|
||||
slot[0].type &&
|
||||
slot[0].type.props &&
|
||||
slot[0].type.props["OMORPHIA_ICON_COMPONENT_IDENTIFIER"].default // I admit, this is stupid. Need a better way to do this
|
||||
);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- <nuxt-link-->
|
||||
<!-- v-if="link && link.startsWith('/')"-->
|
||||
<!-- class="omorphia__button button-base button-color-base padding-block-sm padding-inline-lg radius-sm"-->
|
||||
<!-- :class="{-->
|
||||
<!-- 'icon-only': iconOnly,-->
|
||||
<!-- 'bg-raised': raised,-->
|
||||
<!-- 'bg-red': danger,-->
|
||||
<!-- 'bg-brand': primary,-->
|
||||
<!-- 'color-accent-contrast': danger || primary,-->
|
||||
<!-- }"-->
|
||||
<!-- :to="link"-->
|
||||
<!-- :target="external ? '_blank' : '_self'"-->
|
||||
<!-- >-->
|
||||
<!-- <slot />-->
|
||||
<!-- <Icon v-if="external && !iconOnly" class="external-icon" icon="external" />-->
|
||||
<!-- <Icon v-if="!$slots.default" icon="unknown" />-->
|
||||
<!-- </nuxt-link>-->
|
||||
<a
|
||||
v-if="link"
|
||||
class="omorphia__button button-base padding-block-sm padding-inline-lg radius-sm"
|
||||
:class="{
|
||||
'standard-button': defaultDesign,
|
||||
'icon-only': iconOnly,
|
||||
'bg-raised': defaultDesign && color === 'raised',
|
||||
'bg-red': defaultDesign && color === 'danger',
|
||||
'bg-brand': defaultDesign && color === 'primary',
|
||||
'color-accent-contrast': accentedButton,
|
||||
}"
|
||||
:href="link"
|
||||
:target="external ? '_blank' : '_self'"
|
||||
>
|
||||
<slot />
|
||||
<Icon v-if="external && !iconOnly" class="external-icon" icon="external" />
|
||||
<Icon v-if="!$slots.default" icon="unknown" />
|
||||
</a>
|
||||
<button
|
||||
v-else-if="action"
|
||||
class="omorphia__button button-base padding-block-sm padding-inline-lg radius-sm"
|
||||
:class="{
|
||||
'standard-button': defaultDesign,
|
||||
'icon-only': iconOnly,
|
||||
'bg-raised': defaultDesign && color === 'raised',
|
||||
'bg-red': defaultDesign && color === 'danger',
|
||||
'bg-brand': defaultDesign && color === 'primary',
|
||||
'color-accent-contrast': accentedButton,
|
||||
}"
|
||||
@click="action"
|
||||
>
|
||||
<slot />
|
||||
<Icon v-if="!$slots.default" icon="unknown" />
|
||||
</button>
|
||||
<div
|
||||
v-else
|
||||
class="omorphia__button button-base button-color-base padding-block-sm padding-inline-lg radius-sm bg-red color-accent-contrast"
|
||||
>
|
||||
<Icon icon="issues" />
|
||||
Missing link or action!
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:where(button) {
|
||||
background: none;
|
||||
color: var(--color-base);
|
||||
}
|
||||
|
||||
.omorphia__button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
text-decoration: none;
|
||||
|
||||
:deep(svg) {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
:deep(.external-icon) {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
margin-bottom: auto;
|
||||
margin-left: 0.25rem;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
&.icon-only {
|
||||
padding: 0;
|
||||
height: 2.25rem;
|
||||
width: 2.25rem;
|
||||
|
||||
:deep(svg) {
|
||||
min-width: 1.25rem;
|
||||
max-width: 1.25rem;
|
||||
min-height: 1.25rem;
|
||||
max-height: 1.25rem;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
import Button from "@/components/base/Button.vue";
|
||||
import Icon from "@/components/base/Icon.vue";
|
||||
|
||||
import { reactive } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
collapsible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
defaultCollapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
noAutoBody: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const state = reactive({
|
||||
collapsed: props.defaultCollapsed,
|
||||
});
|
||||
|
||||
function toggleCollapsed() {
|
||||
state.collapsed = !state.collapsed;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="omorphia__card standard-body padding-xl bg-raised radius-lg margin-bottom-md"
|
||||
:class="{ 'auto-body': !noAutoBody }"
|
||||
>
|
||||
<div v-if="!!$slots.header || collapsible" class="header">
|
||||
<slot name="header"></slot>
|
||||
<div
|
||||
v-if="collapsible"
|
||||
class="button-group margin-left-auto margin-right-0"
|
||||
>
|
||||
<Button :action="toggleCollapsed">
|
||||
<Icon icon="dropdown" :rotate="state.collapsed ? 0 : 180" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<slot v-if="!state.collapsed" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.omorphia__card {
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
:deep(h1, h2, h3, h4) {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: var(--gap-lg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import { defineAsyncComponent } from "vue";
|
||||
import DefaultIcon from "@/assets/icons/unknown.svg";
|
||||
|
||||
const props = defineProps({
|
||||
icon: { type: String, required: true },
|
||||
rotate: { type: Number, required: false, default: 0 },
|
||||
OMORPHIA_ICON_COMPONENT_IDENTIFIER: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const IconSvg = defineAsyncComponent(() =>
|
||||
import(
|
||||
/* webpackChunkName: "icons" */
|
||||
/* webpackMode: "lazy-once" */
|
||||
`../../assets/icons/${props.icon}.svg`
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Suspense>
|
||||
<IconSvg
|
||||
class="omorphia__icon"
|
||||
viewBox="0 0 24 24"
|
||||
:style="{ transform: `rotate(${rotate}deg)` }"
|
||||
/>
|
||||
<template #fallback>
|
||||
<DefaultIcon class="omorphia__icon" viewBox="0 0 24 24" />
|
||||
</template>
|
||||
</Suspense>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.omorphia__icon {
|
||||
transition: transform 0.25s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
collapsible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rightSidebar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="omorphia__page"
|
||||
:class="{
|
||||
'right-sidebar': rightSidebar,
|
||||
'has-sidebar': !!$slots.sidebar,
|
||||
'has-header': !!$slots.header,
|
||||
'has-footer': !!$slots.footer,
|
||||
}"
|
||||
>
|
||||
<div v-if="!!$slots.header" class="header">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<div v-if="!!$slots.sidebar" class="sidebar">
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
<div class="content">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="!!$slots.footer" class="footer">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.omorphia__page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 0.75rem;
|
||||
|
||||
.header {
|
||||
grid-area: header;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
}
|
||||
|
||||
.footer {
|
||||
grid-area: footer;
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-area: content;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.omorphia__page {
|
||||
margin: 0 auto;
|
||||
max-width: 80rem;
|
||||
column-gap: 0.75rem;
|
||||
|
||||
&.has-sidebar {
|
||||
display: grid;
|
||||
grid-template:
|
||||
"sidebar content" auto
|
||||
"footer content" auto
|
||||
"dummy content" 1fr
|
||||
/ 20rem 1fr;
|
||||
|
||||
&.has-header {
|
||||
grid-template:
|
||||
"header header" auto
|
||||
"sidebar content" auto
|
||||
"footer content" auto
|
||||
"dummy content" 1fr
|
||||
/ 20rem 1fr;
|
||||
}
|
||||
|
||||
&.right-sidebar {
|
||||
grid-template:
|
||||
"content sidebar" auto
|
||||
"content footer" auto
|
||||
"content dummy" 1fr
|
||||
/ 1fr 20rem;
|
||||
|
||||
&.has-header {
|
||||
grid-template:
|
||||
"header header" auto
|
||||
"content sidebar" auto
|
||||
"content footer" auto
|
||||
"content dummy" 1fr
|
||||
/ 1fr 20rem;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: calc(60rem - 0.75rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
min-width: 20rem;
|
||||
width: 20rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 80rem) {
|
||||
.omorphia__page.has-sidebar {
|
||||
.content {
|
||||
width: calc(60rem - 0.75rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
import Button from "@/components/base/Button.vue";
|
||||
import Icon from "@/components/base/Icon.vue";
|
||||
|
||||
defineProps({
|
||||
link: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
external: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
action: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
selected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
:link="link"
|
||||
:external="external"
|
||||
:action="action"
|
||||
design="nav"
|
||||
class="quiet-disabled"
|
||||
:class="{
|
||||
selected: selected,
|
||||
}"
|
||||
:disabled="selected"
|
||||
:navlabel="label"
|
||||
>
|
||||
<Icon v-if="icon" :icon="icon" />
|
||||
{{ label }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
defineProps({});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="omorphia__navrow">
|
||||
<slot />
|
||||
<div class="margin-left-auto">
|
||||
<slot name="right" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.omorphia__navrow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
:deep(.omorphia__button) {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: calc(100% - var(--gap-lg) * 2);
|
||||
height: 4px;
|
||||
bottom: 4px;
|
||||
border-radius: var(--radius-max);
|
||||
background-color: var(--color-brand);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
opacity: 50%;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
color: var(--color-contrast);
|
||||
font-weight: bold;
|
||||
|
||||
&::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
defineProps({});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="omorphia__navstack">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.omorphia__navstack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.omorphia__button) {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&.selected {
|
||||
background-color: var(--color-button-bg);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Vendored
-8
@@ -1,8 +0,0 @@
|
||||
import type { SvelteComponentDev } from 'svelte/internal'
|
||||
|
||||
export interface Option {
|
||||
label: string
|
||||
/** The element that will be in the `value` array while the option is checked */
|
||||
value: string | number
|
||||
icon?: SvelteComponentDev | string | any // SvelteComponentDev fails to type check
|
||||
}
|
||||
Reference in New Issue
Block a user