summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components/form
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2022-12-27 14:36:33 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2022-12-27 14:36:33 +0900
commit9384f5399da39e53855beb8e7f8ded1aa56bf72e (patch)
treece5959571a981b9c4047da3c7b3fd080aa44222c /packages/frontend/src/components/form
parentwip: retention for dashboard (diff)
downloadsharkey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.gz
sharkey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.bz2
sharkey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.zip
rename: client -> frontend
Diffstat (limited to 'packages/frontend/src/components/form')
-rw-r--r--packages/frontend/src/components/form/checkbox.vue144
-rw-r--r--packages/frontend/src/components/form/folder.vue107
-rw-r--r--packages/frontend/src/components/form/input.vue263
-rw-r--r--packages/frontend/src/components/form/link.vue95
-rw-r--r--packages/frontend/src/components/form/radio.vue132
-rw-r--r--packages/frontend/src/components/form/radios.vue83
-rw-r--r--packages/frontend/src/components/form/range.vue259
-rw-r--r--packages/frontend/src/components/form/section.vue43
-rw-r--r--packages/frontend/src/components/form/select.vue279
-rw-r--r--packages/frontend/src/components/form/slot.vue41
-rw-r--r--packages/frontend/src/components/form/split.vue27
-rw-r--r--packages/frontend/src/components/form/suspense.vue98
-rw-r--r--packages/frontend/src/components/form/switch.vue144
-rw-r--r--packages/frontend/src/components/form/textarea.vue260
14 files changed, 1975 insertions, 0 deletions
diff --git a/packages/frontend/src/components/form/checkbox.vue b/packages/frontend/src/components/form/checkbox.vue
new file mode 100644
index 0000000000..ba3b2dc146
--- /dev/null
+++ b/packages/frontend/src/components/form/checkbox.vue
@@ -0,0 +1,144 @@
+<template>
+<div
+ class="ziffeoms"
+ :class="{ disabled, checked }"
+>
+ <input
+ ref="input"
+ type="checkbox"
+ :disabled="disabled"
+ @keydown.enter="toggle"
+ >
+ <span ref="button" v-adaptive-border v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" class="button" @click.prevent="toggle">
+ <i class="check ti ti-check"></i>
+ </span>
+ <span class="label">
+ <!-- TODO: 無名slotの方は廃止 -->
+ <span @click="toggle"><slot name="label"></slot><slot></slot></span>
+ <p class="caption"><slot name="caption"></slot></p>
+ </span>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { toRefs, Ref } from 'vue';
+import * as os from '@/os';
+import Ripple from '@/components/MkRipple.vue';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+ modelValue: boolean | Ref<boolean>;
+ disabled?: boolean;
+}>();
+
+const emit = defineEmits<{
+ (ev: 'update:modelValue', v: boolean): void;
+}>();
+
+let button = $ref<HTMLElement>();
+const checked = toRefs(props).modelValue;
+const toggle = () => {
+ if (props.disabled) return;
+ emit('update:modelValue', !checked.value);
+
+ if (!checked.value) {
+ const rect = button.getBoundingClientRect();
+ const x = rect.left + (button.offsetWidth / 2);
+ const y = rect.top + (button.offsetHeight / 2);
+ os.popup(Ripple, { x, y, particle: false }, {}, 'end');
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+.ziffeoms {
+ position: relative;
+ display: flex;
+ transition: all 0.2s ease;
+
+ > * {
+ user-select: none;
+ }
+
+ > input {
+ position: absolute;
+ width: 0;
+ height: 0;
+ opacity: 0;
+ margin: 0;
+ }
+
+ > .button {
+ position: relative;
+ display: inline-flex;
+ flex-shrink: 0;
+ margin: 0;
+ box-sizing: border-box;
+ width: 23px;
+ height: 23px;
+ outline: none;
+ background: var(--panel);
+ border: solid 1px var(--panel);
+ border-radius: 4px;
+ cursor: pointer;
+ transition: inherit;
+
+ > .check {
+ margin: auto;
+ opacity: 0;
+ color: var(--fgOnAccent);
+ font-size: 13px;
+ transform: scale(0.5);
+ transition: all 0.2s ease;
+ }
+ }
+
+ &:hover {
+ > .button {
+ border-color: var(--inputBorderHover) !important;
+ }
+ }
+
+ > .label {
+ margin-left: 12px;
+ margin-top: 2px;
+ display: block;
+ transition: inherit;
+ color: var(--fg);
+
+ > span {
+ display: block;
+ line-height: 20px;
+ cursor: pointer;
+ transition: inherit;
+ }
+
+ > .caption {
+ margin: 8px 0 0 0;
+ color: var(--fgTransparentWeak);
+ font-size: 0.85em;
+
+ &:empty {
+ display: none;
+ }
+ }
+ }
+
+ &.disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+
+ &.checked {
+ > .button {
+ background-color: var(--accent) !important;
+ border-color: var(--accent) !important;
+
+ > .check {
+ opacity: 1;
+ transform: scale(1);
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/form/folder.vue b/packages/frontend/src/components/form/folder.vue
new file mode 100644
index 0000000000..1256dfcbb4
--- /dev/null
+++ b/packages/frontend/src/components/form/folder.vue
@@ -0,0 +1,107 @@
+<template>
+<div class="dwzlatin" :class="{ opened }">
+ <div class="header _button" @click="toggle">
+ <span class="icon"><slot name="icon"></slot></span>
+ <span class="text"><slot name="label"></slot></span>
+ <span class="right">
+ <span class="text"><slot name="suffix"></slot></span>
+ <i v-if="opened" class="ti ti-chevron-up icon"></i>
+ <i v-else class="ti ti-chevron-down icon"></i>
+ </span>
+ </div>
+ <KeepAlive>
+ <div v-if="openedAtLeastOnce" v-show="opened" class="body">
+ <MkSpacer :margin-min="14" :margin-max="22">
+ <slot></slot>
+ </MkSpacer>
+ </div>
+ </KeepAlive>
+</div>
+</template>
+
+<script lang="ts" setup>
+const props = withDefaults(defineProps<{
+ defaultOpen: boolean;
+}>(), {
+ defaultOpen: false,
+});
+
+let opened = $ref(props.defaultOpen);
+let openedAtLeastOnce = $ref(props.defaultOpen);
+
+const toggle = () => {
+ opened = !opened;
+ if (opened) {
+ openedAtLeastOnce = true;
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+.dwzlatin {
+ display: block;
+
+ > .header {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 10px 14px 10px 14px;
+ background: var(--buttonBg);
+ border-radius: 6px;
+
+ &:hover {
+ text-decoration: none;
+ background: var(--buttonHoverBg);
+ }
+
+ &.active {
+ color: var(--accent);
+ background: var(--buttonHoverBg);
+ }
+
+ > .icon {
+ margin-right: 0.75em;
+ flex-shrink: 0;
+ text-align: center;
+ opacity: 0.8;
+
+ &:empty {
+ display: none;
+
+ & + .text {
+ padding-left: 4px;
+ }
+ }
+ }
+
+ > .text {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ padding-right: 12px;
+ }
+
+ > .right {
+ margin-left: auto;
+ opacity: 0.7;
+ white-space: nowrap;
+
+ > .text:not(:empty) {
+ margin-right: 0.75em;
+ }
+ }
+ }
+
+ > .body {
+ background: var(--panel);
+ border-radius: 0 0 6px 6px;
+ }
+
+ &.opened {
+ > .header {
+ border-radius: 6px 6px 0 0;
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/form/input.vue b/packages/frontend/src/components/form/input.vue
new file mode 100644
index 0000000000..939e9691a6
--- /dev/null
+++ b/packages/frontend/src/components/form/input.vue
@@ -0,0 +1,263 @@
+<template>
+<div class="matxzzsk">
+ <div class="label" @click="focus"><slot name="label"></slot></div>
+ <div class="input" :class="{ inline, disabled, focused }">
+ <div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div>
+ <input
+ ref="inputEl"
+ v-model="v"
+ v-adaptive-border
+ :type="type"
+ :disabled="disabled"
+ :required="required"
+ :readonly="readonly"
+ :placeholder="placeholder"
+ :pattern="pattern"
+ :autocomplete="autocomplete"
+ :spellcheck="spellcheck"
+ :step="step"
+ :list="id"
+ @focus="focused = true"
+ @blur="focused = false"
+ @keydown="onKeydown($event)"
+ @input="onInput"
+ >
+ <datalist v-if="datalist" :id="id">
+ <option v-for="data in datalist" :value="data"/>
+ </datalist>
+ <div ref="suffixEl" class="suffix"><slot name="suffix"></slot></div>
+ </div>
+ <div class="caption"><slot name="caption"></slot></div>
+
+ <MkButton v-if="manualSave && changed" primary class="save" @click="updated"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue';
+import { debounce } from 'throttle-debounce';
+import MkButton from '@/components/MkButton.vue';
+import { useInterval } from '@/scripts/use-interval';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+ modelValue: string | number;
+ type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search';
+ required?: boolean;
+ readonly?: boolean;
+ disabled?: boolean;
+ pattern?: string;
+ placeholder?: string;
+ autofocus?: boolean;
+ autocomplete?: boolean;
+ spellcheck?: boolean;
+ step?: any;
+ datalist?: string[];
+ inline?: boolean;
+ debounce?: boolean;
+ manualSave?: boolean;
+ small?: boolean;
+ large?: boolean;
+}>();
+
+const emit = defineEmits<{
+ (ev: 'change', _ev: KeyboardEvent): void;
+ (ev: 'keydown', _ev: KeyboardEvent): void;
+ (ev: 'enter'): void;
+ (ev: 'update:modelValue', value: string | number): void;
+}>();
+
+const { modelValue, type, autofocus } = toRefs(props);
+const v = ref(modelValue.value);
+const id = Math.random().toString(); // TODO: uuid?
+const focused = ref(false);
+const changed = ref(false);
+const invalid = ref(false);
+const filled = computed(() => v.value !== '' && v.value != null);
+const inputEl = ref<HTMLElement>();
+const prefixEl = ref<HTMLElement>();
+const suffixEl = ref<HTMLElement>();
+const height =
+ props.small ? 36 :
+ props.large ? 40 :
+ 38;
+
+const focus = () => inputEl.value.focus();
+const onInput = (ev: KeyboardEvent) => {
+ changed.value = true;
+ emit('change', ev);
+};
+const onKeydown = (ev: KeyboardEvent) => {
+ emit('keydown', ev);
+
+ if (ev.code === 'Enter') {
+ emit('enter');
+ }
+};
+
+const updated = () => {
+ changed.value = false;
+ if (type.value === 'number') {
+ emit('update:modelValue', parseFloat(v.value));
+ } else {
+ emit('update:modelValue', v.value);
+ }
+};
+
+const debouncedUpdated = debounce(1000, updated);
+
+watch(modelValue, newValue => {
+ v.value = newValue;
+});
+
+watch(v, newValue => {
+ if (!props.manualSave) {
+ if (props.debounce) {
+ debouncedUpdated();
+ } else {
+ updated();
+ }
+ }
+
+ invalid.value = inputEl.value.validity.badInput;
+});
+
+// このコンポーネントが作成された時、非表示状態である場合がある
+// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
+useInterval(() => {
+ if (prefixEl.value) {
+ if (prefixEl.value.offsetWidth) {
+ inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
+ }
+ }
+ if (suffixEl.value) {
+ if (suffixEl.value.offsetWidth) {
+ inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px';
+ }
+ }
+}, 100, {
+ immediate: true,
+ afterMounted: true,
+});
+
+onMounted(() => {
+ nextTick(() => {
+ if (autofocus.value) {
+ focus();
+ }
+ });
+});
+</script>
+
+<style lang="scss" scoped>
+.matxzzsk {
+ > .label {
+ font-size: 0.85em;
+ padding: 0 0 8px 0;
+ user-select: none;
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .caption {
+ font-size: 0.85em;
+ padding: 8px 0 0 0;
+ color: var(--fgTransparentWeak);
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .input {
+ position: relative;
+
+ > input {
+ appearance: none;
+ -webkit-appearance: none;
+ display: block;
+ height: v-bind("height + 'px'");
+ width: 100%;
+ margin: 0;
+ padding: 0 12px;
+ font: inherit;
+ font-weight: normal;
+ font-size: 1em;
+ color: var(--fg);
+ background: var(--panel);
+ border: solid 1px var(--panel);
+ border-radius: 6px;
+ outline: none;
+ box-shadow: none;
+ box-sizing: border-box;
+ transition: border-color 0.1s ease-out;
+
+ &:hover {
+ border-color: var(--inputBorderHover) !important;
+ }
+ }
+
+ > .prefix,
+ > .suffix {
+ display: flex;
+ align-items: center;
+ position: absolute;
+ z-index: 1;
+ top: 0;
+ padding: 0 12px;
+ font-size: 1em;
+ height: v-bind("height + 'px'");
+ pointer-events: none;
+
+ &:empty {
+ display: none;
+ }
+
+ > * {
+ display: inline-block;
+ min-width: 16px;
+ max-width: 150px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ }
+
+ > .prefix {
+ left: 0;
+ padding-right: 6px;
+ }
+
+ > .suffix {
+ right: 0;
+ padding-left: 6px;
+ }
+
+ &.inline {
+ display: inline-block;
+ margin: 0;
+ }
+
+ &.focused {
+ > input {
+ border-color: var(--accent) !important;
+ //box-shadow: 0 0 0 4px var(--focus);
+ }
+ }
+
+ &.disabled {
+ opacity: 0.7;
+
+ &, * {
+ cursor: not-allowed !important;
+ }
+ }
+ }
+
+ > .save {
+ margin: 8px 0 0 0;
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/form/link.vue b/packages/frontend/src/components/form/link.vue
new file mode 100644
index 0000000000..a1775c0bdb
--- /dev/null
+++ b/packages/frontend/src/components/form/link.vue
@@ -0,0 +1,95 @@
+<template>
+<div class="ffcbddfc" :class="{ inline }">
+ <a v-if="external" class="main _button" :href="to" target="_blank">
+ <span class="icon"><slot name="icon"></slot></span>
+ <span class="text"><slot></slot></span>
+ <span class="right">
+ <span class="text"><slot name="suffix"></slot></span>
+ <i class="ti ti-external-link icon"></i>
+ </span>
+ </a>
+ <MkA v-else class="main _button" :class="{ active }" :to="to" :behavior="behavior">
+ <span class="icon"><slot name="icon"></slot></span>
+ <span class="text"><slot></slot></span>
+ <span class="right">
+ <span class="text"><slot name="suffix"></slot></span>
+ <i class="ti ti-chevron-right icon"></i>
+ </span>
+ </MkA>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+
+const props = defineProps<{
+ to: string;
+ active?: boolean;
+ external?: boolean;
+ behavior?: null | 'window' | 'browser' | 'modalWindow';
+ inline?: boolean;
+}>();
+</script>
+
+<style lang="scss" scoped>
+.ffcbddfc {
+ display: block;
+
+ &.inline {
+ display: inline-block;
+ }
+
+ > .main {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 10px 14px;
+ background: var(--buttonBg);
+ border-radius: 6px;
+ font-size: 0.9em;
+
+ &:hover {
+ text-decoration: none;
+ background: var(--buttonHoverBg);
+ }
+
+ &.active {
+ color: var(--accent);
+ background: var(--buttonHoverBg);
+ }
+
+ > .icon {
+ margin-right: 0.75em;
+ flex-shrink: 0;
+ text-align: center;
+ color: var(--fgTransparentWeak);
+
+ &:empty {
+ display: none;
+
+ & + .text {
+ padding-left: 4px;
+ }
+ }
+ }
+
+ > .text {
+ flex-shrink: 1;
+ white-space: normal;
+ padding-right: 12px;
+ text-align: center;
+ }
+
+ > .right {
+ margin-left: auto;
+ opacity: 0.7;
+ white-space: nowrap;
+
+ > .text:not(:empty) {
+ margin-right: 0.75em;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/form/radio.vue b/packages/frontend/src/components/form/radio.vue
new file mode 100644
index 0000000000..fcf454c77a
--- /dev/null
+++ b/packages/frontend/src/components/form/radio.vue
@@ -0,0 +1,132 @@
+<template>
+<div
+ v-adaptive-border
+ class="novjtctn"
+ :class="{ disabled, checked }"
+ :aria-checked="checked"
+ :aria-disabled="disabled"
+ @click="toggle"
+>
+ <input
+ type="radio"
+ :disabled="disabled"
+ >
+ <span class="button">
+ <span></span>
+ </span>
+ <span class="label"><slot></slot></span>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+
+const props = defineProps<{
+ modelValue: any;
+ value: any;
+ disabled: boolean;
+}>();
+
+const emit = defineEmits<{
+ (ev: 'update:modelValue', value: any): void;
+}>();
+
+let checked = $computed(() => props.modelValue === props.value);
+
+function toggle(): void {
+ if (props.disabled) return;
+ emit('update:modelValue', props.value);
+}
+</script>
+
+<style lang="scss" scoped>
+.novjtctn {
+ position: relative;
+ display: inline-block;
+ text-align: left;
+ cursor: pointer;
+ padding: 7px 10px;
+ min-width: 60px;
+ background-color: var(--panel);
+ background-clip: padding-box !important;
+ border: solid 1px var(--panel);
+ border-radius: 6px;
+ font-size: 90%;
+ transition: all 0.2s;
+
+ > * {
+ user-select: none;
+ }
+
+ &.disabled {
+ opacity: 0.6;
+
+ &, * {
+ cursor: not-allowed !important;
+ }
+ }
+
+ &:hover {
+ border-color: var(--inputBorderHover) !important;
+ }
+
+ &.checked {
+ background-color: var(--accentedBg) !important;
+ border-color: var(--accentedBg) !important;
+ color: var(--accent);
+
+ &, * {
+ cursor: default !important;
+ }
+
+ > .button {
+ border-color: var(--accent);
+
+ &:after {
+ background-color: var(--accent);
+ transform: scale(1);
+ opacity: 1;
+ }
+ }
+ }
+
+ > input {
+ position: absolute;
+ width: 0;
+ height: 0;
+ opacity: 0;
+ margin: 0;
+ }
+
+ > .button {
+ position: absolute;
+ width: 14px;
+ height: 14px;
+ background: none;
+ border: solid 2px var(--inputBorder);
+ border-radius: 100%;
+ transition: inherit;
+
+ &:after {
+ content: '';
+ display: block;
+ position: absolute;
+ top: 3px;
+ right: 3px;
+ bottom: 3px;
+ left: 3px;
+ border-radius: 100%;
+ opacity: 0;
+ transform: scale(0);
+ transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
+ }
+ }
+
+ > .label {
+ margin-left: 28px;
+ display: block;
+ line-height: 20px;
+ cursor: pointer;
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/form/radios.vue b/packages/frontend/src/components/form/radios.vue
new file mode 100644
index 0000000000..bde4a8fb00
--- /dev/null
+++ b/packages/frontend/src/components/form/radios.vue
@@ -0,0 +1,83 @@
+<script lang="ts">
+import { defineComponent, h } from 'vue';
+import MkRadio from './radio.vue';
+
+export default defineComponent({
+ components: {
+ MkRadio,
+ },
+ props: {
+ modelValue: {
+ required: false,
+ },
+ },
+ data() {
+ return {
+ value: this.modelValue,
+ };
+ },
+ watch: {
+ value() {
+ this.$emit('update:modelValue', this.value);
+ },
+ },
+ render() {
+ let options = this.$slots.default();
+ const label = this.$slots.label && this.$slots.label();
+ const caption = this.$slots.caption && this.$slots.caption();
+
+ // なぜかFragmentになることがあるため
+ if (options.length === 1 && options[0].props == null) options = options[0].children;
+
+ return h('div', {
+ class: 'novjtcto',
+ }, [
+ ...(label ? [h('div', {
+ class: 'label',
+ }, [label])] : []),
+ h('div', {
+ class: 'body',
+ }, options.map(option => h(MkRadio, {
+ key: option.key,
+ value: option.props.value,
+ modelValue: this.value,
+ 'onUpdate:modelValue': value => this.value = value,
+ }, option.children)),
+ ),
+ ...(caption ? [h('div', {
+ class: 'caption',
+ }, [caption])] : []),
+ ]);
+ },
+});
+</script>
+
+<style lang="scss">
+.novjtcto {
+ > .label {
+ font-size: 0.85em;
+ padding: 0 0 8px 0;
+ user-select: none;
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .body {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+ }
+
+ > .caption {
+ font-size: 0.85em;
+ padding: 8px 0 0 0;
+ color: var(--fgTransparentWeak);
+
+ &:empty {
+ display: none;
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/form/range.vue b/packages/frontend/src/components/form/range.vue
new file mode 100644
index 0000000000..db21c35717
--- /dev/null
+++ b/packages/frontend/src/components/form/range.vue
@@ -0,0 +1,259 @@
+<template>
+<div class="timctyfi" :class="{ disabled, easing }">
+ <div class="label"><slot name="label"></slot></div>
+ <div v-adaptive-border class="body">
+ <div ref="containerEl" class="container">
+ <div class="track">
+ <div class="highlight" :style="{ width: (steppedRawValue * 100) + '%' }"></div>
+ </div>
+ <div v-if="steps && showTicks" class="ticks">
+ <div v-for="i in (steps + 1)" class="tick" :style="{ left: (((i - 1) / steps) * 100) + '%' }"></div>
+ </div>
+ <div ref="thumbEl" v-tooltip="textConverter(finalValue)" class="thumb" :style="{ left: thumbPosition + 'px' }" @mousedown="onMousedown" @touchstart="onMousedown"></div>
+ </div>
+ </div>
+ <div class="caption"><slot name="caption"></slot></div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from 'vue';
+import * as os from '@/os';
+
+const props = withDefaults(defineProps<{
+ modelValue: number;
+ disabled?: boolean;
+ min: number;
+ max: number;
+ step?: number;
+ textConverter?: (value: number) => string,
+ showTicks?: boolean;
+ easing?: boolean;
+}>(), {
+ step: 1,
+ textConverter: (v) => v.toString(),
+ easing: false,
+});
+
+const emit = defineEmits<{
+ (ev: 'update:modelValue', value: number): void;
+}>();
+
+const containerEl = ref<HTMLElement>();
+const thumbEl = ref<HTMLElement>();
+
+const rawValue = ref((props.modelValue - props.min) / (props.max - props.min));
+const steppedRawValue = computed(() => {
+ if (props.step) {
+ const step = props.step / (props.max - props.min);
+ return (step * Math.round(rawValue.value / step));
+ } else {
+ return rawValue.value;
+ }
+});
+const finalValue = computed(() => {
+ if (Number.isInteger(props.step)) {
+ return Math.round((steppedRawValue.value * (props.max - props.min)) + props.min);
+ } else {
+ return (steppedRawValue.value * (props.max - props.min)) + props.min;
+ }
+});
+
+const thumbWidth = computed(() => {
+ if (thumbEl.value == null) return 0;
+ return thumbEl.value!.offsetWidth;
+});
+const thumbPosition = ref(0);
+const calcThumbPosition = () => {
+ if (containerEl.value == null) {
+ thumbPosition.value = 0;
+ } else {
+ thumbPosition.value = (containerEl.value.offsetWidth - thumbWidth.value) * steppedRawValue.value;
+ }
+};
+watch([steppedRawValue, containerEl], calcThumbPosition);
+
+let ro: ResizeObserver | undefined;
+
+onMounted(() => {
+ ro = new ResizeObserver((entries, observer) => {
+ calcThumbPosition();
+ });
+ ro.observe(containerEl.value);
+});
+
+onUnmounted(() => {
+ if (ro) ro.disconnect();
+});
+
+const steps = computed(() => {
+ if (props.step) {
+ return (props.max - props.min) / props.step;
+ } else {
+ return 0;
+ }
+});
+
+const onMousedown = (ev: MouseEvent | TouchEvent) => {
+ ev.preventDefault();
+
+ const tooltipShowing = ref(true);
+ os.popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), {
+ showing: tooltipShowing,
+ text: computed(() => {
+ return props.textConverter(finalValue.value);
+ }),
+ targetElement: thumbEl,
+ }, {}, 'closed');
+
+ const style = document.createElement('style');
+ style.appendChild(document.createTextNode('* { cursor: grabbing !important; } body * { pointer-events: none !important; }'));
+ document.head.appendChild(style);
+
+ const onDrag = (ev: MouseEvent | TouchEvent) => {
+ ev.preventDefault();
+ const containerRect = containerEl.value!.getBoundingClientRect();
+ const pointerX = ev.touches && ev.touches.length > 0 ? ev.touches[0].clientX : ev.clientX;
+ const pointerPositionOnContainer = pointerX - (containerRect.left + (thumbWidth.value / 2));
+ rawValue.value = Math.min(1, Math.max(0, pointerPositionOnContainer / (containerEl.value!.offsetWidth - thumbWidth.value)));
+ };
+
+ let beforeValue = finalValue.value;
+
+ const onMouseup = () => {
+ document.head.removeChild(style);
+ tooltipShowing.value = false;
+ window.removeEventListener('mousemove', onDrag);
+ window.removeEventListener('touchmove', onDrag);
+ window.removeEventListener('mouseup', onMouseup);
+ window.removeEventListener('touchend', onMouseup);
+
+ // 値が変わってたら通知
+ if (beforeValue !== finalValue.value) {
+ emit('update:modelValue', finalValue.value);
+ }
+ };
+
+ window.addEventListener('mousemove', onDrag);
+ window.addEventListener('touchmove', onDrag);
+ window.addEventListener('mouseup', onMouseup, { once: true });
+ window.addEventListener('touchend', onMouseup, { once: true });
+};
+</script>
+
+<style lang="scss" scoped>
+@use "sass:math";
+
+.timctyfi {
+ position: relative;
+
+ > .label {
+ font-size: 0.85em;
+ padding: 0 0 8px 0;
+ user-select: none;
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .caption {
+ font-size: 0.85em;
+ padding: 8px 0 0 0;
+ color: var(--fgTransparentWeak);
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ $thumbHeight: 20px;
+ $thumbWidth: 20px;
+
+ > .body {
+ padding: 10px 12px;
+ background: var(--panel);
+ border: solid 1px var(--panel);
+ border-radius: 6px;
+
+ > .container {
+ position: relative;
+ height: $thumbHeight;
+
+ > .track {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ margin: auto;
+ width: calc(100% - #{$thumbWidth});
+ height: 3px;
+ background: rgba(0, 0, 0, 0.1);
+ border-radius: 999px;
+ overflow: clip;
+
+ > .highlight {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ background: var(--accent);
+ opacity: 0.5;
+ }
+ }
+
+ > .ticks {
+ $tickWidth: 3px;
+
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ margin: auto;
+ width: calc(100% - #{$thumbWidth});
+
+ > .tick {
+ position: absolute;
+ bottom: 0;
+ width: $tickWidth;
+ height: 3px;
+ margin-left: - math.div($tickWidth, 2);
+ background: var(--divider);
+ border-radius: 999px;
+ }
+ }
+
+ > .thumb {
+ position: absolute;
+ width: $thumbWidth;
+ height: $thumbHeight;
+ cursor: grab;
+ background: var(--accent);
+ border-radius: 999px;
+
+ &:hover {
+ background: var(--accentLighten);
+ }
+ }
+ }
+ }
+
+ &.easing {
+ > .body {
+ > .container {
+ > .track {
+ > .highlight {
+ transition: width 0.2s cubic-bezier(0,0,0,1);
+ }
+ }
+
+ > .thumb {
+ transition: left 0.2s cubic-bezier(0,0,0,1);
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/form/section.vue b/packages/frontend/src/components/form/section.vue
new file mode 100644
index 0000000000..c6e34ef1cc
--- /dev/null
+++ b/packages/frontend/src/components/form/section.vue
@@ -0,0 +1,43 @@
+<template>
+<div class="vrtktovh _formBlock">
+ <div class="label"><slot name="label"></slot></div>
+ <div class="main _formRoot">
+ <slot></slot>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+</script>
+
+<style lang="scss" scoped>
+.vrtktovh {
+ border-top: solid 0.5px var(--divider);
+ border-bottom: solid 0.5px var(--divider);
+
+ & + .vrtktovh {
+ border-top: none;
+ }
+
+ &:first-child {
+ border-top: none;
+ }
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ > .label {
+ font-weight: bold;
+ margin: 1.5em 0 16px 0;
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .main {
+ margin: 1.5em 0;
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/form/select.vue b/packages/frontend/src/components/form/select.vue
new file mode 100644
index 0000000000..eaf4b131cd
--- /dev/null
+++ b/packages/frontend/src/components/form/select.vue
@@ -0,0 +1,279 @@
+<template>
+<div class="vblkjoeq">
+ <div class="label" @click="focus"><slot name="label"></slot></div>
+ <div ref="container" class="input" :class="{ inline, disabled, focused }" @click.prevent="onClick">
+ <div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div>
+ <select
+ ref="inputEl"
+ v-model="v"
+ v-adaptive-border
+ class="select"
+ :disabled="disabled"
+ :required="required"
+ :readonly="readonly"
+ :placeholder="placeholder"
+ @focus="focused = true"
+ @blur="focused = false"
+ @input="onInput"
+ >
+ <slot></slot>
+ </select>
+ <div ref="suffixEl" class="suffix"><i class="ti ti-chevron-down"></i></div>
+ </div>
+ <div class="caption"><slot name="caption"></slot></div>
+
+ <MkButton v-if="manualSave && changed" primary @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, VNode, useSlots } from 'vue';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+ modelValue: string;
+ required?: boolean;
+ readonly?: boolean;
+ disabled?: boolean;
+ placeholder?: string;
+ autofocus?: boolean;
+ inline?: boolean;
+ manualSave?: boolean;
+ small?: boolean;
+ large?: boolean;
+}>();
+
+const emit = defineEmits<{
+ (ev: 'change', _ev: KeyboardEvent): void;
+ (ev: 'update:modelValue', value: string): void;
+}>();
+
+const slots = useSlots();
+
+const { modelValue, autofocus } = toRefs(props);
+const v = ref(modelValue.value);
+const focused = ref(false);
+const changed = ref(false);
+const invalid = ref(false);
+const filled = computed(() => v.value !== '' && v.value != null);
+const inputEl = ref(null);
+const prefixEl = ref(null);
+const suffixEl = ref(null);
+const container = ref(null);
+const height =
+ props.small ? 36 :
+ props.large ? 40 :
+ 38;
+
+const focus = () => inputEl.value.focus();
+const onInput = (ev) => {
+ changed.value = true;
+ emit('change', ev);
+};
+
+const updated = () => {
+ changed.value = false;
+ emit('update:modelValue', v.value);
+};
+
+watch(modelValue, newValue => {
+ v.value = newValue;
+});
+
+watch(v, newValue => {
+ if (!props.manualSave) {
+ updated();
+ }
+
+ invalid.value = inputEl.value.validity.badInput;
+});
+
+// このコンポーネントが作成された時、非表示状態である場合がある
+// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
+useInterval(() => {
+ if (prefixEl.value) {
+ if (prefixEl.value.offsetWidth) {
+ inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
+ }
+ }
+ if (suffixEl.value) {
+ if (suffixEl.value.offsetWidth) {
+ inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px';
+ }
+ }
+}, 100, {
+ immediate: true,
+ afterMounted: true,
+});
+
+onMounted(() => {
+ nextTick(() => {
+ if (autofocus.value) {
+ focus();
+ }
+ });
+});
+
+const onClick = (ev: MouseEvent) => {
+ focused.value = true;
+
+ const menu = [];
+ let options = slots.default!();
+
+ const pushOption = (option: VNode) => {
+ menu.push({
+ text: option.children,
+ active: v.value === option.props.value,
+ action: () => {
+ v.value = option.props.value;
+ },
+ });
+ };
+
+ const scanOptions = (options: VNode[]) => {
+ for (const vnode of options) {
+ if (vnode.type === 'optgroup') {
+ const optgroup = vnode;
+ menu.push({
+ type: 'label',
+ text: optgroup.props.label,
+ });
+ scanOptions(optgroup.children);
+ } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある
+ const fragment = vnode;
+ scanOptions(fragment.children);
+ } else if (vnode.props == null) { // v-if で条件が false のときにこうなる
+ // nop?
+ } else {
+ const option = vnode;
+ pushOption(option);
+ }
+ }
+ };
+
+ scanOptions(options);
+
+ os.popupMenu(menu, container.value, {
+ width: container.value.offsetWidth,
+ }).then(() => {
+ focused.value = false;
+ });
+};
+</script>
+
+<style lang="scss" scoped>
+.vblkjoeq {
+ > .label {
+ font-size: 0.85em;
+ padding: 0 0 8px 0;
+ user-select: none;
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .caption {
+ font-size: 0.85em;
+ padding: 8px 0 0 0;
+ color: var(--fgTransparentWeak);
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .input {
+ position: relative;
+ cursor: pointer;
+
+ &:hover {
+ > .select {
+ border-color: var(--inputBorderHover) !important;
+ }
+ }
+
+ > .select {
+ appearance: none;
+ -webkit-appearance: none;
+ display: block;
+ height: v-bind("height + 'px'");
+ width: 100%;
+ margin: 0;
+ padding: 0 12px;
+ font: inherit;
+ font-weight: normal;
+ font-size: 1em;
+ color: var(--fg);
+ background: var(--panel);
+ border: solid 1px var(--panel);
+ border-radius: 6px;
+ outline: none;
+ box-shadow: none;
+ box-sizing: border-box;
+ cursor: pointer;
+ transition: border-color 0.1s ease-out;
+ pointer-events: none;
+ user-select: none;
+ }
+
+ > .prefix,
+ > .suffix {
+ display: flex;
+ align-items: center;
+ position: absolute;
+ z-index: 1;
+ top: 0;
+ padding: 0 12px;
+ font-size: 1em;
+ height: v-bind("height + 'px'");
+ pointer-events: none;
+
+ &:empty {
+ display: none;
+ }
+
+ > * {
+ display: inline-block;
+ min-width: 16px;
+ max-width: 150px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ }
+
+ > .prefix {
+ left: 0;
+ padding-right: 6px;
+ }
+
+ > .suffix {
+ right: 0;
+ padding-left: 6px;
+ }
+
+ &.inline {
+ display: inline-block;
+ margin: 0;
+ }
+
+ &.focused {
+ > select {
+ border-color: var(--accent) !important;
+ }
+ }
+
+ &.disabled {
+ opacity: 0.7;
+
+ &, * {
+ cursor: not-allowed !important;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/form/slot.vue b/packages/frontend/src/components/form/slot.vue
new file mode 100644
index 0000000000..79ce8fe51f
--- /dev/null
+++ b/packages/frontend/src/components/form/slot.vue
@@ -0,0 +1,41 @@
+<template>
+<div class="adhpbeou">
+ <div class="label" @click="focus"><slot name="label"></slot></div>
+ <div class="content">
+ <slot></slot>
+ </div>
+ <div class="caption"><slot name="caption"></slot></div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+
+function focus() {
+ // TODO
+}
+</script>
+
+<style lang="scss" scoped>
+.adhpbeou {
+ > .label {
+ font-size: 0.85em;
+ padding: 0 0 8px 0;
+ user-select: none;
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .caption {
+ font-size: 0.85em;
+ padding: 8px 0 0 0;
+ color: var(--fgTransparentWeak);
+
+ &:empty {
+ display: none;
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/form/split.vue b/packages/frontend/src/components/form/split.vue
new file mode 100644
index 0000000000..301a8a84e5
--- /dev/null
+++ b/packages/frontend/src/components/form/split.vue
@@ -0,0 +1,27 @@
+<template>
+<div class="terlnhxf _formBlock">
+ <slot></slot>
+</div>
+</template>
+
+<script lang="ts" setup>
+const props = withDefaults(defineProps<{
+ minWidth?: number;
+}>(), {
+ minWidth: 210,
+});
+
+const minWidth = props.minWidth + 'px';
+</script>
+
+<style lang="scss" scoped>
+.terlnhxf {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(v-bind('minWidth'), 1fr));
+ grid-gap: 12px;
+
+ > ::v-deep(*) {
+ margin: 0 !important;
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/form/suspense.vue b/packages/frontend/src/components/form/suspense.vue
new file mode 100644
index 0000000000..7efa501f27
--- /dev/null
+++ b/packages/frontend/src/components/form/suspense.vue
@@ -0,0 +1,98 @@
+<template>
+<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
+ <div v-if="pending">
+ <MkLoading/>
+ </div>
+ <div v-else-if="resolved">
+ <slot :result="result"></slot>
+ </div>
+ <div v-else>
+ <div class="wszdbhzo">
+ <div><i class="ti ti-alert-triangle"></i> {{ $ts.somethingHappened }}</div>
+ <MkButton inline class="retry" @click="retry"><i class="ti ti-reload"></i> {{ $ts.retry }}</MkButton>
+ </div>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType, ref, watch } from 'vue';
+import MkButton from '@/components/MkButton.vue';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ },
+
+ props: {
+ p: {
+ type: Function as PropType<() => Promise<any>>,
+ required: true,
+ },
+ },
+
+ setup(props, context) {
+ const pending = ref(true);
+ const resolved = ref(false);
+ const rejected = ref(false);
+ const result = ref(null);
+
+ const process = () => {
+ if (props.p == null) {
+ return;
+ }
+ const promise = props.p();
+ pending.value = true;
+ resolved.value = false;
+ rejected.value = false;
+ promise.then((_result) => {
+ pending.value = false;
+ resolved.value = true;
+ result.value = _result;
+ });
+ promise.catch(() => {
+ pending.value = false;
+ rejected.value = true;
+ });
+ };
+
+ watch(() => props.p, () => {
+ process();
+ }, {
+ immediate: true,
+ });
+
+ const retry = () => {
+ process();
+ };
+
+ return {
+ pending,
+ resolved,
+ rejected,
+ result,
+ retry,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+ opacity: 0;
+}
+
+.wszdbhzo {
+ padding: 16px;
+ text-align: center;
+
+ > .retry {
+ margin-top: 16px;
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/form/switch.vue b/packages/frontend/src/components/form/switch.vue
new file mode 100644
index 0000000000..1ed00ae655
--- /dev/null
+++ b/packages/frontend/src/components/form/switch.vue
@@ -0,0 +1,144 @@
+<template>
+<div
+ class="ziffeomt"
+ :class="{ disabled, checked }"
+>
+ <input
+ ref="input"
+ type="checkbox"
+ :disabled="disabled"
+ @keydown.enter="toggle"
+ >
+ <span ref="button" v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" class="button" @click.prevent="toggle">
+ <div class="knob"></div>
+ </span>
+ <span class="label">
+ <!-- TODO: 無名slotの方は廃止 -->
+ <span @click="toggle"><slot name="label"></slot><slot></slot></span>
+ <p class="caption"><slot name="caption"></slot></p>
+ </span>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { toRefs, Ref } from 'vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+ modelValue: boolean | Ref<boolean>;
+ disabled?: boolean;
+}>();
+
+const emit = defineEmits<{
+ (ev: 'update:modelValue', v: boolean): void;
+}>();
+
+let button = $ref<HTMLElement>();
+const checked = toRefs(props).modelValue;
+const toggle = () => {
+ if (props.disabled) return;
+ emit('update:modelValue', !checked.value);
+
+ if (!checked.value) {
+
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+.ziffeomt {
+ position: relative;
+ display: flex;
+ transition: all 0.2s ease;
+
+ > * {
+ user-select: none;
+ }
+
+ > input {
+ position: absolute;
+ width: 0;
+ height: 0;
+ opacity: 0;
+ margin: 0;
+ }
+
+ > .button {
+ position: relative;
+ display: inline-flex;
+ flex-shrink: 0;
+ margin: 0;
+ box-sizing: border-box;
+ width: 32px;
+ height: 23px;
+ outline: none;
+ background: var(--swutchOffBg);
+ background-clip: content-box;
+ border: solid 1px var(--swutchOffBg);
+ border-radius: 999px;
+ cursor: pointer;
+ transition: inherit;
+ user-select: none;
+
+ > .knob {
+ position: absolute;
+ top: 3px;
+ left: 3px;
+ width: 15px;
+ height: 15px;
+ background: var(--swutchOffFg);
+ border-radius: 999px;
+ transition: all 0.2s ease;
+ }
+ }
+
+ &:hover {
+ > .button {
+ border-color: var(--inputBorderHover) !important;
+ }
+ }
+
+ > .label {
+ margin-left: 12px;
+ margin-top: 2px;
+ display: block;
+ transition: inherit;
+ color: var(--fg);
+
+ > span {
+ display: block;
+ line-height: 20px;
+ cursor: pointer;
+ transition: inherit;
+ }
+
+ > .caption {
+ margin: 8px 0 0 0;
+ color: var(--fgTransparentWeak);
+ font-size: 0.85em;
+
+ &:empty {
+ display: none;
+ }
+ }
+ }
+
+ &.disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+
+ &.checked {
+ > .button {
+ background-color: var(--swutchOnBg) !important;
+ border-color: var(--swutchOnBg) !important;
+
+ > .knob {
+ left: 12px;
+ background: var(--swutchOnFg);
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/form/textarea.vue b/packages/frontend/src/components/form/textarea.vue
new file mode 100644
index 0000000000..d34d7b1775
--- /dev/null
+++ b/packages/frontend/src/components/form/textarea.vue
@@ -0,0 +1,260 @@
+<template>
+<div class="adhpbeos">
+ <div class="label" @click="focus"><slot name="label"></slot></div>
+ <div class="input" :class="{ disabled, focused, tall, pre }">
+ <textarea
+ ref="inputEl"
+ v-model="v"
+ v-adaptive-border
+ :class="{ code, _monospace: code }"
+ :disabled="disabled"
+ :required="required"
+ :readonly="readonly"
+ :placeholder="placeholder"
+ :pattern="pattern"
+ :autocomplete="autocomplete"
+ :spellcheck="spellcheck"
+ @focus="focused = true"
+ @blur="focused = false"
+ @keydown="onKeydown($event)"
+ @input="onInput"
+ ></textarea>
+ </div>
+ <div class="caption"><slot name="caption"></slot></div>
+
+ <MkButton v-if="manualSave && changed" primary class="save" @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue';
+import { debounce } from 'throttle-debounce';
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ },
+
+ props: {
+ modelValue: {
+ required: true,
+ },
+ type: {
+ type: String,
+ required: false,
+ },
+ required: {
+ type: Boolean,
+ required: false,
+ },
+ readonly: {
+ type: Boolean,
+ required: false,
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ },
+ pattern: {
+ type: String,
+ required: false,
+ },
+ placeholder: {
+ type: String,
+ required: false,
+ },
+ autofocus: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ autocomplete: {
+ required: false,
+ },
+ spellcheck: {
+ required: false,
+ },
+ code: {
+ type: Boolean,
+ required: false,
+ },
+ tall: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ pre: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ debounce: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ manualSave: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ emits: ['change', 'keydown', 'enter', 'update:modelValue'],
+
+ setup(props, context) {
+ const { modelValue, autofocus } = toRefs(props);
+ const v = ref(modelValue.value);
+ const focused = ref(false);
+ const changed = ref(false);
+ const invalid = ref(false);
+ const filled = computed(() => v.value !== '' && v.value != null);
+ const inputEl = ref(null);
+
+ const focus = () => inputEl.value.focus();
+ const onInput = (ev) => {
+ changed.value = true;
+ context.emit('change', ev);
+ };
+ const onKeydown = (ev: KeyboardEvent) => {
+ context.emit('keydown', ev);
+
+ if (ev.code === 'Enter') {
+ context.emit('enter');
+ }
+ };
+
+ const updated = () => {
+ changed.value = false;
+ context.emit('update:modelValue', v.value);
+ };
+
+ const debouncedUpdated = debounce(1000, updated);
+
+ watch(modelValue, newValue => {
+ v.value = newValue;
+ });
+
+ watch(v, newValue => {
+ if (!props.manualSave) {
+ if (props.debounce) {
+ debouncedUpdated();
+ } else {
+ updated();
+ }
+ }
+
+ invalid.value = inputEl.value.validity.badInput;
+ });
+
+ onMounted(() => {
+ nextTick(() => {
+ if (autofocus.value) {
+ focus();
+ }
+ });
+ });
+
+ return {
+ v,
+ focused,
+ invalid,
+ changed,
+ filled,
+ inputEl,
+ focus,
+ onInput,
+ onKeydown,
+ updated,
+ i18n,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.adhpbeos {
+ > .label {
+ font-size: 0.85em;
+ padding: 0 0 8px 0;
+ user-select: none;
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .caption {
+ font-size: 0.85em;
+ padding: 8px 0 0 0;
+ color: var(--fgTransparentWeak);
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .input {
+ position: relative;
+
+ > textarea {
+ appearance: none;
+ -webkit-appearance: none;
+ display: block;
+ width: 100%;
+ min-width: 100%;
+ max-width: 100%;
+ min-height: 130px;
+ margin: 0;
+ padding: 12px;
+ font: inherit;
+ font-weight: normal;
+ font-size: 1em;
+ color: var(--fg);
+ background: var(--panel);
+ border: solid 1px var(--panel);
+ border-radius: 6px;
+ outline: none;
+ box-shadow: none;
+ box-sizing: border-box;
+ transition: border-color 0.1s ease-out;
+
+ &:hover {
+ border-color: var(--inputBorderHover) !important;
+ }
+ }
+
+ &.focused {
+ > textarea {
+ border-color: var(--accent) !important;
+ }
+ }
+
+ &.disabled {
+ opacity: 0.7;
+
+ &, * {
+ cursor: not-allowed !important;
+ }
+ }
+
+ &.tall {
+ > textarea {
+ min-height: 200px;
+ }
+ }
+
+ &.pre {
+ > textarea {
+ white-space: pre;
+ }
+ }
+ }
+
+ > .save {
+ margin: 8px 0 0 0;
+ }
+}
+</style>