bonsai rings

This commit is contained in:
nicwands 2026-08-11 10:24:27 -04:00
parent d243f4caea
commit 71a0a97f4c
25 changed files with 1232 additions and 331 deletions

View file

@ -8,11 +8,6 @@
"build": "vite build",
"preview": "vite preview"
},
"prettier": {
"tabWidth": 4,
"semi": false,
"singleQuote": true
},
"dependencies": {
"@mapbox/tiny-sdf": "^2.2.0",
"@tiptap/pm": "^3.28.0",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/drops/bonsai/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bonsai</title>
<script type="module" crossorigin src="/drops/bonsai/assets/index-kYUuDunm.js"></script>
<link rel="stylesheet" crossorigin href="/drops/bonsai/assets/index-B4Fea1ir.css">
<script type="module" crossorigin src="/drops/bonsai/assets/index-ChADjOAS.js"></script>
<link rel="stylesheet" crossorigin href="/drops/bonsai/assets/index-CQxvOJT7.css">
</head>
<body>
<div id="app"></div>

View file

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/drops/image-melody/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Image Melody</title>
<script type="module" crossorigin src="/drops/image-melody/assets/index-C-dHDnAG.js"></script>
<link rel="stylesheet" crossorigin href="/drops/image-melody/assets/index-Dk6TxSdE.css">
<script type="module" crossorigin src="/drops/image-melody/assets/index-Bqx1iDV3.js"></script>
<link rel="stylesheet" crossorigin href="/drops/image-melody/assets/index-BfScccQB.css">
</head>
<body>
<div id="app"></div>

View file

@ -16,19 +16,28 @@
<h1 class="name h2">{{ displayTitle }}</h1>
<rich-text v-if="drop.description" class="description" :content="drop.description" />
<div class="description">
<rich-text
v-if="drop.description"
:content="drop.description"
/>
</div>
<div class="fade" />
</div>
<div class="btn-wrap">
<btn @click="emit('previous')" :disabled="!previousActive">Last Drop</btn>
<btn @click="emit('previous')" :disabled="!previousActive"
>Last Drop</btn
>
<btn @click="emit('next')" :disabled="!nextActive">Next Drop</btn>
</div>
</div>
</template>
<script setup>
import RichText from '@shared/RichText.vue';
import { format, parse } from 'fecha'
import RichText from '@shared/RichText.vue'
import { format } from 'fecha'
import Btn from '@shared/Btn.vue'
import { computed } from 'vue'
@ -37,7 +46,7 @@ const props = defineProps({
index: Number,
previousActive: Boolean,
nextActive: Boolean,
});
})
const emit = defineEmits(['previous', 'next'])
@ -69,56 +78,49 @@ const formattedDate = computed(() => {
grid-template-rows: auto 1fr auto;
display: grid;
gap: rs(30px);
height: calc(100dvh - (2 * var(--layout-margin)));
.header {
text-align: center;
}
.content {
text-align: center;
display: flex;
flex-direction: column;
grid-template-rows: auto auto 1fr;
display: grid;
gap: rs(15px);
min-height: 0;
position: relative;
.name {
margin-bottom: rs(5px);
}
.description, .date {
.description,
.date {
color: var(--theme-grey);
}
.description {
text-align: left;
max-width: rs(400px);
margin: 0 auto;
min-height: 0;
overflow: auto;
padding-bottom: rs(30px);
a {
word-break: break-word;
}
}
.meta-item {
font-size: rs(12px);
.meta-label {
opacity: 0.7;
margin-right: rs(8px);
}
.meta-value {
opacity: 0.9;
}
}
.tags {
display: flex;
flex-wrap: wrap;
gap: rs(8px);
justify-content: center;
.tag {
background: rgba(255, 255, 255, 0.1);
padding: rs(4px) rs(12px);
border-radius: rs(12px);
font-size: rs(11px);
text-transform: lowercase;
}
.fade {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: rs(30px);
background: linear-gradient(
to bottom,
transparent,
var(--theme-bg)
);
}
}
.btn-wrap {

View file

@ -18,8 +18,8 @@
<script setup>
import Panel from '@/components/Panel.vue'
import { computed, onMounted, ref, watchEffect } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { computed, onMounted, ref, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const props = defineProps({
manifest: Array,
@ -33,7 +33,9 @@ const data = ref()
onMounted(async () => {
try {
const meta = await fetch(`/drops/${name}/meta.json`).then((r) => r.json())
const meta = await fetch(`/drops/${name}/meta.json`).then((r) =>
r.json(),
)
data.value = {
...meta,
src: `/drops/${name}/index.html`,
@ -46,7 +48,9 @@ onMounted(async () => {
}
})
const dropIndex = computed(() => props.manifest.findIndex((drop) => drop === name))
const dropIndex = computed(() =>
props.manifest.findIndex((drop) => drop === name),
)
const prevDrop = computed(() => {
if (dropIndex.value === 0) return null
return props.manifest[dropIndex.value - 1]
@ -69,6 +73,7 @@ main.drop {
background: var(--theme-layout);
color: var(--theme-fg);
height: 100vh;
overflow: hidden;
padding-top: rs(20px);
padding-bottom: rs(20px);

View file

@ -8,11 +8,6 @@
"build": "vite build",
"preview": "vite preview"
},
"prettier": {
"tabWidth": 4,
"semi": false,
"singleQuote": true
},
"dependencies": {
"@tiptap/pm": "^3.28.0",
"@tiptap/starter-kit": "^3.28.0",

View file

@ -8,11 +8,6 @@
"build": "vite build",
"preview": "vite preview"
},
"prettier": {
"tabWidth": 4,
"semi": false,
"singleQuote": true
},
"dependencies": {
"@vueuse/core": "^14.4.0",
"alea": "^1.0.1",

View file

@ -2,8 +2,7 @@
<div class="bonsai">
<canvas ref="canvas" />
<div class="controls">
<div class="control">
<control-panel title="controls">
<range
v-model="numRings"
label="Rings"
@ -11,16 +10,12 @@
:max="80"
:step="1"
/>
</div>
<div class="control">
<range
v-model="noiseSpatialFreq"
label="Noise"
:min="0.1"
:max="2"
/>
</div>
<div class="control">
<range
v-model="noiseRingStep"
label="Variation"
@ -28,22 +23,16 @@
:max="1.5"
:step="0.05"
/>
</div>
<div class="control">
<range
v-model="mutationStrength"
label="Mutation"
:step="0.05"
/>
</div>
<range v-model="mutationStrength" label="Mutation" :step="0.05" />
<btn @click="changeSeed">New Seed</btn>
</div>
</control-panel>
</div>
</template>
<script setup>
import { computed, onMounted, ref, watchEffect } from 'vue'
import { useElementSize, useStorage } from '@vueuse/core'
import ControlPanel from '@shared/ControlPanel.vue'
import { createNoise2D } from 'simplex-noise'
import { v4 as uuid } from 'uuid'
import Range from '@shared/Range.vue'
@ -191,23 +180,5 @@ onMounted(() => {
height: 100%;
width: 100%;
}
.controls {
position: absolute;
bottom: var(--layout-margin);
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: flex-end;
gap: rs(16px);
white-space: nowrap;
.control {
display: flex;
flex-direction: column;
gap: rs(6px);
min-width: rs(150px);
}
}
}
</style>

View file

@ -1,152 +0,0 @@
<template>
<component
class="btn"
:is="tag"
>
<span v-if="arrow" class="leading-icon">→</span>
<span class="text">
<span><slot /></span>
<span><slot /></span>
</span>
<span v-if="arrow" class="arrow">→</span>
<!-- <span v-if="loading" class="loading">
<svg-util-spinner />
</span> -->
</component>
</template>
<script setup>
const props = defineProps({
tag: {
type: String,
default: () => 'button',
},
arrow: {
type: Boolean,
default: () => false,
},
loading: {
type: Boolean,
default: () => false,
},
})
</script>
<style lang="scss">
.btn {
--t-duration: 350ms;
--t-ease: var(--ease-out-quad);
display: inline-flex;
align-items: center;
padding: rs(6px) rs(16px);
background: var(--theme-contrast);
color: var(--ivory);
border-radius: rs(8px);
position: relative;
overflow: hidden;
cursor: pointer;
transition: color var(--t-duration) var(--t-ease);
.leading-icon {
position: absolute;
width: rs(10px);
height: 1em;
display: flex;
align-items: center;
transform: translateX(-100%);
opacity: 0;
transition: transform var(--t-duration) var(--t-ease),
opacity var(--t-duration) var(--t-ease);
}
.text {
position: relative;
display: block;
overflow: hidden;
transition: transform var(--t-duration) var(--t-ease);
span {
display: inline-block;
transition: transform var(--t-duration) var(--t-ease);
@include label;
}
span:first-child {
color: var(--ivory);
}
span:last-child {
position: absolute;
left: 0;
right: 0;
bottom: 0;
transform: translateY(105%);
color: var(--ivory);
}
}
.arrow {
transition: transform var(--t-duration) var(--t-ease),
opacity var(--t-duration) var(--t-ease);
&:not(:only-child) {
margin-left: 0.5em;
}
}
.loading {
width: rs(10px);
height: 1em;
display: flex;
align-items: center;
svg {
width: 100%;
height: auto;
}
}
&::before {
content: '';
display: block;
position: absolute;
inset: -2px;
background: var(--theme-contrast);
clip-path: inset(100% 0 0 0);
transition: clip-path var(--t-duration) var(--t-ease);
}
&:hover,
&.active,
&.router-link-active {
color: var(--ivory);
&::before {
clip-path: inset(0);
}
.text {
span:first-child {
transform: translateY(-105%);
}
span:last-child {
transform: none;
}
}
&.text-icon {
.leading-icon {
transform: none;
opacity: 1;
}
.text {
transform: translateX(1.5em);
}
.arrow {
transform: translateX(100%);
opacity: 0;
}
}
}
&:disabled,
&.disabled {
opacity: 0.16;
pointer-events: none;
}
}
</style>

View file

@ -2,8 +2,8 @@
<component class="btn" :is="tag">
<span v-if="arrow" class="leading-icon">→</span>
<span class="text">
<span><slot /></span>
<span><slot /></span>
<span class="transition"><slot /></span>
<span class="transition"><slot /></span>
</span>
<span v-if="arrow" class="arrow">→</span>
</component>
@ -60,15 +60,15 @@ const props = defineProps({
overflow: hidden;
transition: transform var(--t-duration) var(--t-ease);
span {
.transition {
display: inline-block;
transition: transform var(--t-duration) var(--t-ease);
@include label;
}
span:first-child {
.transition:first-child {
color: var(--ivory);
}
span:last-child {
.transition:last-child {
position: absolute;
left: 0;
right: 0;

95
shared/Checkbox.vue Normal file
View file

@ -0,0 +1,95 @@
<template>
<label class="shared-checkbox" :class="{ 'is-checked': model, 'is-disabled': disabled }">
<input
type="checkbox"
:disabled="disabled"
v-model="model"
/>
<span class="box">
<svg class="check" viewBox="0 0 10 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 4L3.5 6.5L9 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<span v-if="$slots.default" class="checkbox-label"><slot /></span>
</label>
</template>
<script setup>
const props = defineProps({
disabled: {
type: Boolean,
default: false,
},
})
const model = defineModel()
</script>
<style lang="scss">
.shared-checkbox {
--t-duration: 200ms;
--t-ease: var(--ease-out-quad);
display: inline-flex;
align-items: center;
gap: rs(10px);
cursor: pointer;
user-select: none;
input[type='checkbox'] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.box {
flex-shrink: 0;
width: rs(18px);
height: rs(18px);
border: rs(1px) solid var(--grey-400);
border-radius: rs(4px);
display: flex;
align-items: center;
justify-content: center;
background: transparent;
transition:
background var(--t-duration) var(--t-ease),
border-color var(--t-duration) var(--t-ease);
.check {
width: rs(10px);
height: auto;
color: var(--ivory);
opacity: 0;
transform: scale(0.5);
transition:
opacity var(--t-duration) var(--t-ease),
transform var(--t-duration) var(--t-ease);
}
}
.checkbox-label {
@include p;
}
&:hover .box {
border-color: var(--theme-contrast);
}
&.is-checked .box {
background: var(--theme-contrast);
border-color: var(--theme-contrast);
.check {
opacity: 1;
transform: none;
}
}
&.is-disabled {
opacity: 0.3;
pointer-events: none;
}
}
</style>

125
shared/ControlPanel.vue Normal file
View file

@ -0,0 +1,125 @@
<template>
<div class="shared-control-panel" :class="{ 'is-open': open }">
<btn
class="toggle-btn"
@click="open = !open"
:aria-expanded="open"
aria-label="Toggle control panel"
>
<!-- <span class="toggle-btn-inner"> -->
<span class="toggle-icon">{{ open ? '×' : '⊞' }}</span>
<!-- <span class="toggle-label">{{ title }}</span> -->
<!-- </span> -->
</btn>
<div class="panel-body">
<div class="panel-inner">
<slot />
</div>
</div>
</div>
</template>
<script setup>
import Btn from './Btn.vue'
import { ref } from 'vue'
const props = defineProps({
title: {
type: String,
default: 'Controls',
},
defaultOpen: {
type: Boolean,
default: true,
},
})
const open = ref(props.defaultOpen)
</script>
<style lang="scss">
.shared-control-panel {
--t-duration: 400ms;
--t-ease: var(--ease-out-quart);
--panel-width: #{columns(4)};
position: fixed;
top: 0;
right: 0;
padding: var(--layout-margin) 0;
display: flex;
align-items: center;
gap: rs(8px);
height: 100dvh;
pointer-events: none;
.toggle-btn {
pointer-events: all;
position: absolute;
top: var(--layout-margin);
right: 0;
z-index: 5;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
cursor: pointer;
.toggle-btn-inner {
display: flex;
align-items: center;
gap: rs(8px);
}
.toggle-icon {
@include label;
}
.toggle-label {
white-space: nowrap;
@include label;
}
}
.panel-body {
pointer-events: all;
width: var(--panel-width);
height: 100%;
background: var(--theme-bg);
border: 1px solid var(--grey-400);
border-radius: rs(10px);
overflow: hidden;
transform: translateX(100%);
transition: transform var(--t-duration) var(--t-ease);
display: flex;
flex-direction: column;
padding-top: rs(50px);
}
.panel-inner {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: rs(24px) rs(20px);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: rs(20px);
scrollbar-width: thin;
scrollbar-color: var(--grey-400) transparent;
&::-webkit-scrollbar {
width: rs(4px);
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--grey-400);
border-radius: rs(2px);
}
}
&.is-open {
.panel-body {
transform: translateX(0);
}
}
}
</style>

226
shared/FileInput.vue Normal file
View file

@ -0,0 +1,226 @@
<template>
<div
class="shared-file-input"
:class="{ 'has-file': files.length, 'is-dragging': dragging, 'is-disabled': disabled, 'is-error': error }"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
>
<label v-if="label" :for="id">{{ label }}</label>
<div class="drop-zone" @click="triggerInput">
<input
ref="inputRef"
:id="id"
type="file"
:multiple="multiple"
:accept="accept"
:disabled="disabled"
@change="onChange"
/>
<template v-if="files.length">
<ul class="file-list">
<li v-for="(file, i) in files" :key="i">
<span>{{ file.name }}</span>
<button type="button" @click.stop="removeFile(i)">×</button>
</li>
</ul>
</template>
<template v-else>
<span class="drop-prompt">
<span class="icon">↑</span>
<span>{{ placeholder }}</span>
</span>
</template>
</div>
<span v-if="error" class="error-msg">{{ error }}</span>
<span v-else-if="hint" class="hint">{{ hint }}</span>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
id: String,
label: String,
placeholder: {
type: String,
default: 'Drop files here or click to browse',
},
multiple: {
type: Boolean,
default: false,
},
accept: String,
disabled: {
type: Boolean,
default: false,
},
error: String,
hint: String,
})
const emit = defineEmits(['change'])
const inputRef = ref(null)
const files = ref([])
const dragging = ref(false)
function triggerInput() {
inputRef.value?.click()
}
function onChange(e) {
addFiles(Array.from(e.target.files))
e.target.value = ''
}
function onDrop(e) {
dragging.value = false
addFiles(Array.from(e.dataTransfer.files))
}
function addFiles(newFiles) {
if (!props.multiple) {
files.value = newFiles.slice(0, 1)
} else {
files.value = [...files.value, ...newFiles]
}
emit('change', files.value)
}
function removeFile(index) {
files.value.splice(index, 1)
emit('change', files.value)
}
</script>
<style lang="scss">
.shared-file-input {
--t-duration: 250ms;
--t-ease: var(--ease-out-quad);
display: flex;
flex-direction: column;
gap: rs(6px);
label {
@include label;
opacity: 0.45;
}
.drop-zone {
border: rs(1px) dashed var(--grey-400);
border-radius: rs(8px);
padding: rs(24px) rs(16px);
cursor: pointer;
transition:
border-color var(--t-duration) var(--t-ease),
background var(--t-duration) var(--t-ease);
input[type='file'] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
}
.drop-prompt {
display: flex;
flex-direction: column;
align-items: center;
gap: rs(8px);
text-align: center;
.icon {
@include h4;
font-style: normal;
font-family: var(--font-mono);
opacity: 0.35;
transition: transform var(--t-duration) var(--t-ease);
}
span:last-child {
@include label;
opacity: 0.35;
}
}
.file-list {
list-style: none;
display: flex;
flex-direction: column;
gap: rs(6px);
li {
@include p;
display: flex;
align-items: center;
justify-content: space-between;
gap: rs(8px);
padding: rs(4px) 0;
border-bottom: rs(1px) solid var(--grey-400);
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
button {
@include label;
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
color: var(--theme-fg);
opacity: 0.45;
transition: opacity var(--t-duration) var(--t-ease);
padding: 0 rs(4px);
&:hover {
opacity: 1;
}
}
}
}
&:hover .drop-zone,
&.is-dragging .drop-zone {
border-color: var(--theme-contrast);
border-style: solid;
.drop-prompt .icon {
transform: translateY(-rs(4px));
}
}
&.is-dragging .drop-zone {
background: color-mix(in srgb, var(--theme-contrast) 5%, transparent);
}
.error-msg,
.hint {
@include label;
font-size: rs(11px);
}
.hint {
opacity: 0.35;
}
&.is-error .drop-zone {
border-color: currentColor;
border-style: solid;
.error-msg {
opacity: 0.65;
}
}
&.is-disabled {
opacity: 0.3;
pointer-events: none;
}
}
</style>

139
shared/Input.vue Normal file
View file

@ -0,0 +1,139 @@
<template>
<div class="shared-input" :class="{ 'has-value': model, 'is-focused': focused, 'is-disabled': disabled, 'is-error': error }">
<label v-if="label" :for="id">{{ label }}</label>
<div class="input-wrap">
<input
:id="id"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:autocomplete="autocomplete"
v-model="model"
@focus="focused = true"
@blur="focused = false"
/>
<span class="underline" />
</div>
<span v-if="error" class="error-msg">{{ error }}</span>
<span v-else-if="hint" class="hint">{{ hint }}</span>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
id: String,
label: String,
type: {
type: String,
default: 'text',
},
placeholder: String,
disabled: {
type: Boolean,
default: false,
},
autocomplete: String,
error: String,
hint: String,
})
const model = defineModel()
const focused = ref(false)
</script>
<style lang="scss">
.shared-input {
--t-duration: 250ms;
--t-ease: var(--ease-out-quad);
display: flex;
flex-direction: column;
gap: rs(6px);
label {
@include label;
opacity: 0.45;
transition: opacity var(--t-duration) var(--t-ease);
}
.input-wrap {
position: relative;
input {
@include p;
width: 100%;
background: transparent;
border: none;
border-bottom: rs(1px) solid var(--grey-400);
padding: rs(8px) 0;
color: var(--theme-fg);
outline: none;
caret-color: var(--theme-contrast);
transition: border-color var(--t-duration) var(--t-ease);
&::placeholder {
color: var(--theme-fg);
opacity: 0.25;
}
}
.underline {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: rs(1px);
background: var(--theme-contrast);
transform: scaleX(0);
transform-origin: left;
transition: transform var(--t-duration) var(--t-ease);
}
}
.error-msg,
.hint {
@include label;
font-size: rs(11px);
}
.hint {
opacity: 0.35;
}
.error-msg {
color: var(--theme-contrast);
}
&.is-focused {
label {
opacity: 1;
}
.underline {
transform: scaleX(1);
}
}
&.is-disabled {
opacity: 0.3;
pointer-events: none;
}
&.is-error {
.input-wrap input {
border-color: transparent;
}
.underline {
background: currentColor;
transform: scaleX(1);
}
.error-msg {
color: inherit;
opacity: 0.65;
}
}
}
</style>

116
shared/Radio.vue Normal file
View file

@ -0,0 +1,116 @@
<template>
<div class="shared-radio-group" :class="{ 'is-disabled': disabled }">
<span v-if="label" class="group-label">{{ label }}</span>
<label
v-for="option in options"
:key="option.value ?? option"
class="shared-radio"
:class="{ 'is-checked': model === (option.value ?? option) }"
>
<input
type="radio"
:name="name"
:value="option.value ?? option"
:disabled="disabled"
v-model="model"
/>
<span class="dot-wrap">
<span class="dot" />
</span>
<span class="radio-label">{{ option.label ?? option }}</span>
</label>
</div>
</template>
<script setup>
const props = defineProps({
label: String,
name: String,
options: {
type: Array,
default: () => [],
},
disabled: {
type: Boolean,
default: false,
},
})
const model = defineModel()
</script>
<style lang="scss">
.shared-radio-group {
display: flex;
flex-direction: column;
gap: rs(8px);
.group-label {
@include label;
opacity: 0.45;
margin-bottom: rs(4px);
}
&.is-disabled {
opacity: 0.3;
pointer-events: none;
}
}
.shared-radio {
--t-duration: 200ms;
--t-ease: var(--ease-out-quad);
display: inline-flex;
align-items: center;
gap: rs(10px);
cursor: pointer;
user-select: none;
input[type='radio'] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.dot-wrap {
flex-shrink: 0;
width: rs(18px);
height: rs(18px);
border: rs(1px) solid var(--grey-400);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: border-color var(--t-duration) var(--t-ease);
}
.dot {
width: rs(8px);
height: rs(8px);
border-radius: 50%;
background: var(--theme-contrast);
transform: scale(0);
transition: transform var(--t-duration) var(--t-ease);
}
.radio-label {
@include p;
}
&:hover .dot-wrap {
border-color: var(--theme-contrast);
}
&.is-checked {
.dot-wrap {
border-color: var(--theme-contrast);
}
.dot {
transform: scale(1);
}
}
}
</style>

View file

@ -27,6 +27,8 @@ const model = defineModel()
<style lang="scss">
.shared-range {
width: 100%;
label {
@include label;
display: flex;

155
shared/Select.vue Normal file
View file

@ -0,0 +1,155 @@
<template>
<div class="shared-select" :class="{ 'has-value': model, 'is-focused': focused, 'is-disabled': disabled, 'is-error': error }">
<label v-if="label" :for="id">{{ label }}</label>
<div class="select-wrap">
<select
:id="id"
:disabled="disabled"
v-model="model"
@focus="focused = true"
@blur="focused = false"
>
<option v-if="placeholder" value="" disabled hidden>{{ placeholder }}</option>
<option
v-for="option in options"
:key="option.value ?? option"
:value="option.value ?? option"
>
{{ option.label ?? option }}
</option>
</select>
<span class="arrow">↓</span>
<span class="underline" />
</div>
<span v-if="error" class="error-msg">{{ error }}</span>
<span v-else-if="hint" class="hint">{{ hint }}</span>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
id: String,
label: String,
placeholder: String,
options: {
type: Array,
default: () => [],
},
disabled: {
type: Boolean,
default: false,
},
error: String,
hint: String,
})
const model = defineModel()
const focused = ref(false)
</script>
<style lang="scss">
.shared-select {
--t-duration: 250ms;
--t-ease: var(--ease-out-quad);
display: flex;
flex-direction: column;
gap: rs(6px);
label {
@include label;
opacity: 0.45;
transition: opacity var(--t-duration) var(--t-ease);
}
.select-wrap {
position: relative;
select {
@include p;
width: 100%;
background: transparent;
border: none;
border-bottom: rs(1px) solid var(--grey-400);
padding: rs(8px) rs(24px) rs(8px) 0;
color: var(--theme-fg);
outline: none;
appearance: none;
cursor: pointer;
option {
background: var(--theme-bg, #fff);
color: var(--theme-fg);
}
}
.arrow {
@include label;
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
opacity: 0.45;
transition: transform var(--t-duration) var(--t-ease), opacity var(--t-duration) var(--t-ease);
}
.underline {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: rs(1px);
background: var(--theme-contrast);
transform: scaleX(0);
transform-origin: left;
transition: transform var(--t-duration) var(--t-ease);
}
}
.error-msg,
.hint {
@include label;
font-size: rs(11px);
}
.hint {
opacity: 0.35;
}
&.is-focused {
label {
opacity: 1;
}
.underline {
transform: scaleX(1);
}
.arrow {
opacity: 1;
}
}
&.is-disabled {
opacity: 0.3;
pointer-events: none;
}
&.is-error {
.select-wrap select {
border-color: transparent;
}
.underline {
background: currentColor;
transform: scaleX(1);
}
.error-msg {
opacity: 0.65;
}
}
}
</style>

144
shared/Textarea.vue Normal file
View file

@ -0,0 +1,144 @@
<template>
<div class="shared-textarea" :class="{ 'has-value': model, 'is-focused': focused, 'is-disabled': disabled, 'is-error': error }">
<label v-if="label" :for="id">
<span>{{ label }}</span>
<span v-if="maxlength" class="char-count">{{ (model || '').length }} / {{ maxlength }}</span>
</label>
<div class="textarea-wrap">
<textarea
:id="id"
:placeholder="placeholder"
:disabled="disabled"
:maxlength="maxlength"
:rows="rows"
v-model="model"
@focus="focused = true"
@blur="focused = false"
/>
<span class="underline" />
</div>
<span v-if="error" class="error-msg">{{ error }}</span>
<span v-else-if="hint" class="hint">{{ hint }}</span>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
id: String,
label: String,
placeholder: String,
disabled: {
type: Boolean,
default: false,
},
maxlength: Number,
rows: {
type: Number,
default: 4,
},
error: String,
hint: String,
})
const model = defineModel()
const focused = ref(false)
</script>
<style lang="scss">
.shared-textarea {
--t-duration: 250ms;
--t-ease: var(--ease-out-quad);
display: flex;
flex-direction: column;
gap: rs(6px);
label {
@include label;
display: flex;
justify-content: space-between;
opacity: 0.45;
transition: opacity var(--t-duration) var(--t-ease);
.char-count {
font-weight: 400;
}
}
.textarea-wrap {
position: relative;
textarea {
@include p;
width: 100%;
background: transparent;
border: none;
border-bottom: rs(1px) solid var(--grey-400);
padding: rs(8px) 0;
color: var(--theme-fg);
outline: none;
resize: vertical;
caret-color: var(--theme-contrast);
min-height: rs(80px);
&::placeholder {
color: var(--theme-fg);
opacity: 0.25;
}
}
.underline {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: rs(1px);
background: var(--theme-contrast);
transform: scaleX(0);
transform-origin: left;
transition: transform var(--t-duration) var(--t-ease);
}
}
.error-msg,
.hint {
@include label;
font-size: rs(11px);
}
.hint {
opacity: 0.35;
}
&.is-focused {
label {
opacity: 1;
}
.underline {
transform: scaleX(1);
}
}
&.is-disabled {
opacity: 0.3;
pointer-events: none;
}
&.is-error {
.textarea-wrap textarea {
border-color: transparent;
}
.underline {
background: currentColor;
transform: scaleX(1);
}
.error-msg {
opacity: 0.65;
}
}
}
</style>

88
shared/Toggle.vue Normal file
View file

@ -0,0 +1,88 @@
<template>
<label class="shared-toggle" :class="{ 'is-on': model, 'is-disabled': disabled }">
<input
type="checkbox"
:disabled="disabled"
v-model="model"
/>
<span class="track">
<span class="thumb" />
</span>
<span v-if="$slots.default" class="toggle-label"><slot /></span>
</label>
</template>
<script setup>
const props = defineProps({
disabled: {
type: Boolean,
default: false,
},
})
const model = defineModel()
</script>
<style lang="scss">
.shared-toggle {
--t-duration: 250ms;
--t-ease: var(--ease-out-quad);
--track-w: #{rs(36px)};
--track-h: #{rs(20px)};
--thumb-size: #{rs(14px)};
display: inline-flex;
align-items: center;
gap: rs(10px);
cursor: pointer;
user-select: none;
input[type='checkbox'] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.track {
flex-shrink: 0;
width: var(--track-w);
height: var(--track-h);
border-radius: var(--track-h);
background: var(--grey-400);
position: relative;
transition: background var(--t-duration) var(--t-ease);
}
.thumb {
position: absolute;
top: 50%;
left: rs(3px);
transform: translateY(-50%);
width: var(--thumb-size);
height: var(--thumb-size);
border-radius: 50%;
background: var(--ivory);
transition: transform var(--t-duration) var(--t-ease), left var(--t-duration) var(--t-ease);
}
.toggle-label {
@include p;
}
&.is-on {
.track {
background: var(--theme-contrast);
}
.thumb {
left: calc(var(--track-w) - var(--thumb-size) - rs(3px));
}
}
&.is-disabled {
opacity: 0.3;
pointer-events: none;
}
}
</style>