bonsai rings
This commit is contained in:
parent
d243f4caea
commit
71a0a97f4c
25 changed files with 1232 additions and 331 deletions
|
|
@ -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
1
app/public/drops/bonsai/assets/index-CQxvOJT7.css
Normal file
1
app/public/drops/bonsai/assets/index-CQxvOJT7.css
Normal file
File diff suppressed because one or more lines are too long
1
app/public/drops/bonsai/assets/index-ChADjOAS.js
Normal file
1
app/public/drops/bonsai/assets/index-ChADjOAS.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,54 +1,63 @@
|
|||
<template>
|
||||
<div class="panel">
|
||||
<div class="header">
|
||||
<!-- <p class="label">Braindrops #{{ String(index + 1).padStart(2, '0') }}</p> -->
|
||||
<router-link class="label" to="/">Braindrops</router-link>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="header">
|
||||
<!-- <p class="label">Braindrops #{{ String(index + 1).padStart(2, '0') }}</p> -->
|
||||
<router-link class="label" to="/">Braindrops</router-link>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div v-if="drop.date" class="date meta-item label">
|
||||
<span class="meta-value">{{ formattedDate }}</span>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div v-if="drop.date" class="date meta-item label">
|
||||
<span class="meta-value">{{ formattedDate }}</span>
|
||||
</div>
|
||||
|
||||
<!-- <div v-if="drop.tags && drop.tags.length" class="tags">
|
||||
<!-- <div v-if="drop.tags && drop.tags.length" class="tags">
|
||||
<span v-for="tag in drop.tags" :key="tag" class="tag">{{ tag }}</span>
|
||||
</div> -->
|
||||
|
||||
<h1 class="name h2">{{ displayTitle }}</h1>
|
||||
<h1 class="name h2">{{ displayTitle }}</h1>
|
||||
|
||||
<rich-text v-if="drop.description" class="description" :content="drop.description" />
|
||||
</div>
|
||||
<div class="description">
|
||||
<rich-text
|
||||
v-if="drop.description"
|
||||
:content="drop.description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="btn-wrap">
|
||||
<btn @click="emit('previous')" :disabled="!previousActive">Last Drop</btn>
|
||||
<btn @click="emit('next')" :disabled="!nextActive">Next Drop</btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fade" />
|
||||
</div>
|
||||
|
||||
<div class="btn-wrap">
|
||||
<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'
|
||||
|
||||
const props = defineProps({
|
||||
drop: Object,
|
||||
index: Number,
|
||||
previousActive: Boolean,
|
||||
nextActive: Boolean,
|
||||
});
|
||||
drop: Object,
|
||||
index: Number,
|
||||
previousActive: Boolean,
|
||||
nextActive: Boolean,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['previous', 'next'])
|
||||
|
||||
const displayTitle = computed(() => {
|
||||
if (props.drop.title) return props.drop.title
|
||||
if (props.drop.title) return props.drop.title
|
||||
|
||||
// Fallback: format name as title
|
||||
return props.drop.name
|
||||
?.split('-')
|
||||
?.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
// Fallback: format name as title
|
||||
return props.drop.name
|
||||
?.split('-')
|
||||
?.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
})
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
|
|
@ -57,7 +66,7 @@ const formattedDate = computed(() => {
|
|||
const [year, month, day] = props.drop.date.split('-')
|
||||
const date = new Date(year, month - 1, day)
|
||||
|
||||
return format(date, 'MMMM Do, YYYY')
|
||||
return format(date, 'MMMM Do, YYYY')
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<main v-if="data" class="drop theme-dark layout-grid">
|
||||
<main v-if="data" class="drop theme-dark layout-grid">
|
||||
<panel
|
||||
:drop="data"
|
||||
:index="dropIndex"
|
||||
|
|
@ -13,13 +13,13 @@
|
|||
<div class="drop-wrap">
|
||||
<iframe :src="data.src" />
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,30 +1,25 @@
|
|||
{
|
||||
"name": "bonsai",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"prettier": {
|
||||
"tabWidth": 4,
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"alea": "^1.0.1",
|
||||
"intrinsic-scale": "^5.0.0",
|
||||
"sass": "^1.98.0",
|
||||
"sass-embedded": "^1.98.0",
|
||||
"simplex-noise": "^4.0.3",
|
||||
"uuid": "^14.0.1",
|
||||
"vue": "^3.5.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
"name": "bonsai",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"alea": "^1.0.1",
|
||||
"intrinsic-scale": "^5.0.0",
|
||||
"sass": "^1.98.0",
|
||||
"sass-embedded": "^1.98.0",
|
||||
"simplex-noise": "^4.0.3",
|
||||
"uuid": "^14.0.1",
|
||||
"vue": "^3.5.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,48 +2,37 @@
|
|||
<div class="bonsai">
|
||||
<canvas ref="canvas" />
|
||||
|
||||
<div class="controls">
|
||||
<div class="control">
|
||||
<range
|
||||
v-model="numRings"
|
||||
label="Rings"
|
||||
:min="2"
|
||||
: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"
|
||||
:min="0"
|
||||
:max="1.5"
|
||||
:step="0.05"
|
||||
/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<range
|
||||
v-model="mutationStrength"
|
||||
label="Mutation"
|
||||
:step="0.05"
|
||||
/>
|
||||
</div>
|
||||
<control-panel title="controls">
|
||||
<range
|
||||
v-model="numRings"
|
||||
label="Rings"
|
||||
:min="2"
|
||||
:max="80"
|
||||
:step="1"
|
||||
/>
|
||||
<range
|
||||
v-model="noiseSpatialFreq"
|
||||
label="Noise"
|
||||
:min="0.1"
|
||||
:max="2"
|
||||
/>
|
||||
<range
|
||||
v-model="noiseRingStep"
|
||||
label="Variation"
|
||||
:min="0"
|
||||
:max="1.5"
|
||||
:step="0.05"
|
||||
/>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
95
shared/Checkbox.vue
Normal 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
125
shared/ControlPanel.vue
Normal 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
226
shared/FileInput.vue
Normal 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
139
shared/Input.vue
Normal 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
116
shared/Radio.vue
Normal 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>
|
||||
|
|
@ -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
155
shared/Select.vue
Normal 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
144
shared/Textarea.vue
Normal 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
88
shared/Toggle.vue
Normal 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>
|
||||
Loading…
Reference in a new issue