diff options
Diffstat (limited to 'packages/frontend/src/components/form')
| -rw-r--r-- | packages/frontend/src/components/form/checkbox.vue | 144 | ||||
| -rw-r--r-- | packages/frontend/src/components/form/folder.vue | 107 | ||||
| -rw-r--r-- | packages/frontend/src/components/form/input.vue | 263 | ||||
| -rw-r--r-- | packages/frontend/src/components/form/link.vue | 95 | ||||
| -rw-r--r-- | packages/frontend/src/components/form/radio.vue | 132 | ||||
| -rw-r--r-- | packages/frontend/src/components/form/radios.vue | 83 | ||||
| -rw-r--r-- | packages/frontend/src/components/form/range.vue | 259 | ||||
| -rw-r--r-- | packages/frontend/src/components/form/section.vue | 43 | ||||
| -rw-r--r-- | packages/frontend/src/components/form/select.vue | 279 | ||||
| -rw-r--r-- | packages/frontend/src/components/form/slot.vue | 41 | ||||
| -rw-r--r-- | packages/frontend/src/components/form/split.vue | 27 | ||||
| -rw-r--r-- | packages/frontend/src/components/form/suspense.vue | 98 | ||||
| -rw-r--r-- | packages/frontend/src/components/form/switch.vue | 144 | ||||
| -rw-r--r-- | packages/frontend/src/components/form/textarea.vue | 260 |
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> |