diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-28 20:07:37 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-28 20:07:37 +0900 |
| commit | e8005c8d3a6edf2c8cdce3fe098fb9acff8a57c6 (patch) | |
| tree | 4283a0a36f5cb03f0fb3a534142c06783f8ff725 /packages/client/src/components | |
| parent | /antennas/notes API で日付による絞り込みができるようにする... (diff) | |
| download | sharkey-e8005c8d3a6edf2c8cdce3fe098fb9acff8a57c6.tar.gz sharkey-e8005c8d3a6edf2c8cdce3fe098fb9acff8a57c6.tar.bz2 sharkey-e8005c8d3a6edf2c8cdce3fe098fb9acff8a57c6.zip | |
client: refine ui
Diffstat (limited to 'packages/client/src/components')
24 files changed, 840 insertions, 260 deletions
diff --git a/packages/client/src/components/dialog.vue b/packages/client/src/components/dialog.vue index 1b03e65a3d..5d6678531d 100644 --- a/packages/client/src/components/dialog.vue +++ b/packages/client/src/components/dialog.vue @@ -14,7 +14,9 @@ </div> <header v-if="title"><Mfm :text="title"/></header> <div v-if="text" class="body"><Mfm :text="text"/></div> - <MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"></MkInput> + <MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"> + <template v-if="input.type === 'password'" #prefix><i class="fas fa-lock"></i></template> + </MkInput> <MkSelect v-if="select" v-model="selectedValue" autofocus> <template v-if="select.items"> <option v-for="item in select.items" :value="item.value">{{ item.text }}</option> @@ -165,6 +167,10 @@ export default defineComponent({ > .icon { font-size: 32px; + &.info { + color: #55c4dd; + } + &.success { color: var(--success); } diff --git a/packages/client/src/components/emoji-picker-dialog.vue b/packages/client/src/components/emoji-picker-dialog.vue index 4517a90db9..c1a9f73bcc 100644 --- a/packages/client/src/components/emoji-picker-dialog.vue +++ b/packages/client/src/components/emoji-picker-dialog.vue @@ -1,5 +1,5 @@ <template> -<MkPopup ref="popup" #default="{point}" :manual-showing="manualShowing" :src="src" :front="true" @click="$refs.popup.close()" @opening="opening" @close="$emit('close')" @closed="$emit('closed')"> +<MkPopup ref="popup" v-slot="{ point, close }" :manual-showing="manualShowing" :src="src" :front="true" @click="close()" @opening="opening" @close="$emit('close')" @closed="$emit('closed')"> <MkEmojiPicker ref="picker" class="ryghynhb _popup _shadow" :class="{ pointer: point === 'top' }" :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" @chosen="chosen"/> </MkPopup> </template> diff --git a/packages/client/src/components/form/group.vue b/packages/client/src/components/form/group.vue new file mode 100644 index 0000000000..2fc203f1b9 --- /dev/null +++ b/packages/client/src/components/form/group.vue @@ -0,0 +1,35 @@ +<template> +<div v-sticky-container v-panel class="adfeebaf _formBlock"> + <div class="label"><slot name="label"></slot></div> + <div class="main _formRoot"> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ +}); +</script> + +<style lang="scss" scoped> +.adfeebaf { + padding: 24px 24px; + border-radius: var(--radius); + + > .label { + font-weight: bold; + padding: 0 0 16px 0; + + &:empty { + display: none; + } + } + + > .main { + + } +} +</style> diff --git a/packages/client/src/components/form/input.vue b/packages/client/src/components/form/input.vue index 99267f9231..c990b693f1 100644 --- a/packages/client/src/components/form/input.vue +++ b/packages/client/src/components/form/input.vue @@ -5,6 +5,7 @@ <div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div> <input ref="inputEl" v-model="v" + v-panel :type="type" :disabled="disabled" :required="required" @@ -27,7 +28,7 @@ </div> <div class="caption"><slot name="caption"></slot></div> - <MkButton v-if="manualSave && changed" primary @click="updated"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> + <MkButton v-if="manualSave && changed" primary class="save" @click="updated"><i class="fas fa-check"></i> {{ $ts.save }}</MkButton> </div> </template> @@ -114,9 +115,9 @@ export default defineComponent({ 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 inputEl = ref<HTMLElement>(); + const prefixEl = ref<HTMLElement>(); + const suffixEl = ref<HTMLElement>(); const focus = () => inputEl.value.focus(); const onInput = (ev) => { @@ -208,7 +209,7 @@ export default defineComponent({ .matxzzsk { > .label { font-size: 0.85em; - padding: 0 0 8px 12px; + padding: 0 0 8px 0; user-select: none; &:empty { @@ -217,8 +218,8 @@ export default defineComponent({ } > .caption { - font-size: 0.8em; - padding: 8px 0 0 12px; + font-size: 0.85em; + padding: 8px 0 0 0; color: var(--fgTransparentWeak); &:empty { @@ -242,8 +243,7 @@ export default defineComponent({ font-weight: normal; font-size: 1em; color: var(--fg); - background: var(--panel); - border: solid 0.5px var(--inputBorder); + border: solid 0.5px var(--panel); border-radius: 6px; outline: none; box-shadow: none; @@ -311,5 +311,9 @@ export default defineComponent({ } } } + + > .save { + margin: 8px 0 0 0; + } } </style> diff --git a/packages/client/src/components/form/link.vue b/packages/client/src/components/form/link.vue new file mode 100644 index 0000000000..3eb74425b0 --- /dev/null +++ b/packages/client/src/components/form/link.vue @@ -0,0 +1,112 @@ +<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="fas fa-external-link-alt 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="fas fa-chevron-right icon"></i> + </span> + </MkA> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + to: { + type: String, + required: true + }, + active: { + type: Boolean, + required: false + }, + external: { + type: Boolean, + required: false + }, + behavior: { + type: String, + required: false, + }, + inline: { + type: Boolean, + required: false + }, + }, +}); +</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: 12px 14px 12px 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; + 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; + } + } + } +} +</style> diff --git a/packages/client/src/components/form/pagination.vue b/packages/client/src/components/form/pagination.vue new file mode 100644 index 0000000000..3d3b40a783 --- /dev/null +++ b/packages/client/src/components/form/pagination.vue @@ -0,0 +1,44 @@ +<template> +<FormSlot> + <template #label><slot name="label"></slot></template> + <div class="abcaccfa"> + <slot :items="items"></slot> + <div v-if="empty" key="_empty_" class="empty"> + <slot name="empty"></slot> + </div> + <MkButton v-show="more" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore"> + <template v-if="!moreFetching">{{ $ts.loadMore }}</template> + <template v-if="moreFetching"><MkLoading inline/></template> + </MkButton> + </div> +</FormSlot> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import FormSlot from './slot.vue'; +import paging from '@/scripts/paging'; + +export default defineComponent({ + components: { + MkButton, + FormSlot, + }, + + mixins: [ + paging({}), + ], + + props: { + pagination: { + required: true + }, + }, +}); +</script> + +<style lang="scss" scoped> +.abcaccfa { +} +</style> diff --git a/packages/client/src/components/form/radio.vue b/packages/client/src/components/form/radio.vue index 0f31d8fa0a..f0b8c71376 100644 --- a/packages/client/src/components/form/radio.vue +++ b/packages/client/src/components/form/radio.vue @@ -1,5 +1,6 @@ <template> <div + v-panel class="novjtctn" :class="{ disabled, checked }" :aria-checked="checked" @@ -50,9 +51,10 @@ export default defineComponent({ .novjtctn { position: relative; display: inline-block; - margin: 8px 20px 0 0; text-align: left; cursor: pointer; + padding: 11px 14px; + border-radius: 6px; transition: all 0.3s; > * { @@ -68,6 +70,14 @@ export default defineComponent({ } &.checked { + background: var(--accentedBg) !important; + border-color: var(--accent); + color: var(--accent); + + &, * { + cursor: default !important; + } + > .button { border-color: var(--accent); @@ -79,6 +89,11 @@ export default defineComponent({ } } + &:hover { + border-color: var(--inputBorderHover); + color: var(--accent); + } + > input { position: absolute; width: 0; @@ -89,8 +104,8 @@ export default defineComponent({ > .button { position: absolute; - width: 20px; - height: 20px; + width: 14px; + height: 14px; background: none; border: solid 2px var(--inputBorder); border-radius: 100%; @@ -114,7 +129,6 @@ export default defineComponent({ > .label { margin-left: 28px; display: block; - font-size: 16px; line-height: 20px; cursor: pointer; } diff --git a/packages/client/src/components/form/radios.vue b/packages/client/src/components/form/radios.vue index 998a738202..ff5d51f9c7 100644 --- a/packages/client/src/components/form/radios.vue +++ b/packages/client/src/components/form/radios.vue @@ -23,6 +23,8 @@ export default defineComponent({ }, 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; @@ -30,12 +32,21 @@ export default defineComponent({ return h('div', { class: 'novjtcto' }, [ - ...options.map(option => h(MkRadio, { - key: option.key, - value: option.props.value, - modelValue: this.value, - 'onUpdate:modelValue': value => this.value = value, - }, option.children)) + ...(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])] : []), ]); } }); @@ -43,12 +54,30 @@ export default defineComponent({ <style lang="scss"> .novjtcto { - &:first-child { - margin-top: 0; + > .label { + font-size: 0.85em; + padding: 0 0 8px 0; + user-select: none; + + &:empty { + display: none; + } + } + + > .body { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + grid-gap: 12px; } - &:last-child { - margin-bottom: 0; + > .caption { + font-size: 0.85em; + padding: 8px 0 0 0; + color: var(--fgTransparentWeak); + + &:empty { + display: none; + } } } </style> diff --git a/packages/client/src/components/form/range.vue b/packages/client/src/components/form/range.vue index dd771abfe2..79a83d6a93 100644 --- a/packages/client/src/components/form/range.vue +++ b/packages/client/src/components/form/range.vue @@ -1,29 +1,27 @@ <template> -<div class="timctyfi" :class="{ focused, disabled }"> - <div class="icon"><slot name="icon"></slot></div> - <span class="label"><slot name="label"></slot></span> - <input - ref="input" - v-model="v" - type="range" - :disabled="disabled" - :min="min" - :max="max" - :step="step" - :autofocus="autofocus" - @focus="focused = true" - @blur="focused = false" - @input="$emit('update:value', $event.target.value)" - /> +<div class="timctyfi" :class="{ disabled }"> + <div class="label"><slot name="label"></slot></div> + <div v-panel class="body"> + <div ref="containerEl" class="container"> + <div class="track"> + <div class="highlight" :style="{ width: (steppedValue * 100) + '%' }"></div> + </div> + <div v-if="steps" 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> </template> <script lang="ts"> -import { defineComponent } from 'vue'; +import { computed, defineComponent, ref, watch } from 'vue'; +import * as os from '@/os'; export default defineComponent({ props: { - value: { + modelValue: { type: Number, required: false, default: 0 @@ -51,88 +49,198 @@ export default defineComponent({ autofocus: { type: Boolean, required: false - } + }, + textConverter: { + type: Function, + required: false, + default: (v) => v.toString(), + }, }, - data() { + + setup(props, context) { + const rawValue = ref((props.modelValue - props.min) / (props.max - props.min)); + const steppedValue = 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(() => { + return (steppedValue.value * (props.max - props.min)) + props.min; + }); + watch(finalValue, () => { + context.emit('update:modelValue', finalValue.value); + }); + + const thumbWidth = computed(() => { + if (thumbEl.value == null) return 0; + return thumbEl.value!.offsetWidth; + }); + const thumbPosition = computed(() => { + if (containerEl.value == null) return 0; + return (containerEl.value.offsetWidth - thumbWidth.value) * steppedValue.value; + }); + const steps = computed(() => { + if (props.step) { + return (props.max - props.min) / props.step; + } else { + return 0; + } + }); + const containerEl = ref<HTMLElement>(); + const thumbEl = ref<HTMLElement>(); + + const onMousedown = (ev: MouseEvent | TouchEvent) => { + ev.preventDefault(); + + const tooltipShowing = ref(true); + os.popup(import('@/components/ui/tooltip.vue'), { + showing: tooltipShowing, + text: computed(() => { + return props.textConverter(finalValue.value); + }), + source: 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))); + }; + + 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); + }; + + window.addEventListener('mousemove', onDrag); + window.addEventListener('touchmove', onDrag); + window.addEventListener('mouseup', onMouseup, { once: true }); + window.addEventListener('touchend', onMouseup, { once: true }); + }; + return { - v: this.value, - focused: false + rawValue, + finalValue, + steppedValue, + onMousedown, + containerEl, + thumbEl, + thumbPosition, + steps, }; }, - watch: { - value(v) { - this.v = parseFloat(v); - } - }, - mounted() { - if (this.autofocus) { - this.$nextTick(() => { - this.$refs.input.focus(); - }); - } - } }); </script> <style lang="scss" scoped> +@use "sass:math"; + .timctyfi { position: relative; - margin: 8px; - > .icon { - display: inline-block; - width: 24px; - text-align: center; - } + > .label { + font-size: 0.85em; + padding: 0 0 8px 0; + user-select: none; - > .title { - pointer-events: none; - font-size: 16px; - color: var(--inputLabel); - overflow: hidden; + &:empty { + display: none; + } } - > input { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - background: var(--X10); - height: 7px; - margin: 0 8px; - outline: 0; - border: 0; - border-radius: 7px; + > .caption { + font-size: 0.85em; + padding: 8px 0 0 0; + color: var(--fgTransparentWeak); - &.disabled { - opacity: 0.6; - cursor: not-allowed; + &:empty { + display: none; } + } - &::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - cursor: pointer; - width: 20px; - height: 20px; - display: block; - border-radius: 50%; - border: none; - background: var(--accent); - box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); - box-sizing: content-box; - } + $thumbHeight: 20px; + $thumbWidth: 20px; + + > .body { + padding: 12px; + 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; + transition: width 0.2s cubic-bezier(0,0,0,1); + } + } + + > .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; + transition: left 0.2s cubic-bezier(0,0,0,1); - &::-moz-range-thumb { - -moz-appearance: none; - appearance: none; - cursor: pointer; - width: 20px; - height: 20px; - display: block; - border-radius: 50%; - border: none; - background: var(--accent); - box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); + &:hover { + background: var(--accentLighten); + } + } } } } diff --git a/packages/client/src/components/form/section.vue b/packages/client/src/components/form/section.vue index 76db7ac5c3..bc2ab966b8 100644 --- a/packages/client/src/components/form/section.vue +++ b/packages/client/src/components/form/section.vue @@ -1,7 +1,7 @@ <template> -<div v-size="{ max: [500] }" v-sticky-container class="vrtktovh"> +<div v-size="{ max: [500] }" v-sticky-container class="vrtktovh _formBlock"> <div class="label"><slot name="label"></slot></div> - <div class="main"> + <div class="main _formRoot"> <slot></slot> </div> </div> @@ -17,15 +17,33 @@ export default defineComponent({ <style lang="scss" scoped> .vrtktovh { + margin: 0; border-top: solid 0.5px var(--divider); + border-bottom: solid 0.5px var(--divider); + padding: 24px 0; + + & + .vrtktovh { + border-top: none; + } + + &:first-child { + border-top: none; + } + + &:last-child { + border-bottom: none; + } > .label { font-weight: bold; - padding: 24px 0 16px 0; + padding: 0 0 16px 0; + + &:empty { + display: none; + } } > .main { - margin-bottom: 32px; } } </style> diff --git a/packages/client/src/components/form/select.vue b/packages/client/src/components/form/select.vue index fe2a4e3a7d..9ecff1aa6f 100644 --- a/packages/client/src/components/form/select.vue +++ b/packages/client/src/components/form/select.vue @@ -3,7 +3,7 @@ <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" + <select ref="inputEl" v-model="v" v-panel class="select" :disabled="disabled" :required="required" @@ -201,7 +201,7 @@ export default defineComponent({ .vblkjoeq { > .label { font-size: 0.85em; - padding: 0 0 8px 12px; + padding: 0 0 8px 0; user-select: none; &:empty { @@ -210,8 +210,8 @@ export default defineComponent({ } > .caption { - font-size: 0.8em; - padding: 8px 0 0 12px; + font-size: 0.85em; + padding: 8px 0 0 0; color: var(--fgTransparentWeak); &:empty { @@ -242,8 +242,7 @@ export default defineComponent({ font-weight: normal; font-size: 1em; color: var(--fg); - background: var(--panel); - border: solid 1px var(--inputBorder); + border: solid 1px var(--panel); border-radius: 6px; outline: none; box-shadow: none; diff --git a/packages/client/src/components/form/slot.vue b/packages/client/src/components/form/slot.vue index 8580c1307d..d031b2effc 100644 --- a/packages/client/src/components/form/slot.vue +++ b/packages/client/src/components/form/slot.vue @@ -18,11 +18,9 @@ export default defineComponent({ <style lang="scss" scoped> .adhpbeou { - margin: 1.5em 0; - > .label { font-size: 0.85em; - padding: 0 0 8px 12px; + padding: 0 0 8px 0; user-select: none; &:empty { @@ -31,20 +29,13 @@ export default defineComponent({ } > .caption { - font-size: 0.8em; - padding: 8px 0 0 12px; + font-size: 0.85em; + padding: 8px 0 0 0; color: var(--fgTransparentWeak); &:empty { display: none; } } - - > .content { - position: relative; - background: var(--panel); - border: solid 0.5px var(--inputBorder); - border-radius: 6px; - } } </style> diff --git a/packages/client/src/components/form/suspense.vue b/packages/client/src/components/form/suspense.vue new file mode 100644 index 0000000000..4d5debe604 --- /dev/null +++ b/packages/client/src/components/form/suspense.vue @@ -0,0 +1,98 @@ +<template> +<transition name="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="fas fa-exclamation-triangle"></i> {{ $ts.somethingHappened }}</div> + <MkButton inline class="retry" @click="retry"><i class="fas fa-redo-alt"></i> {{ $ts.retry }}</MkButton> + </div> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent, PropType, ref, watch } from 'vue'; +import MkButton from '@/components/ui/button.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/client/src/components/form/switch.vue b/packages/client/src/components/form/switch.vue index d6df68a07f..239303a55a 100644 --- a/packages/client/src/components/form/switch.vue +++ b/packages/client/src/components/form/switch.vue @@ -18,7 +18,7 @@ </span> <span class="label"> <span><slot></slot></span> - <p><slot name="caption"></slot></p> + <p class="caption"><slot name="caption"></slot></p> </span> </div> </template> @@ -118,10 +118,14 @@ export default defineComponent({ transition: inherit; } - > p { - margin: 0; + > .caption { + margin: 8px 0 0 0; color: var(--fgTransparentWeak); - font-size: 90%; + font-size: 0.85em; + + &:empty { + display: none; + } } } diff --git a/packages/client/src/components/form/textarea.vue b/packages/client/src/components/form/textarea.vue index f3a2c394f1..98fd0da94b 100644 --- a/packages/client/src/components/form/textarea.vue +++ b/packages/client/src/components/form/textarea.vue @@ -4,6 +4,7 @@ <div class="input" :class="{ disabled, focused, tall, pre }"> <textarea ref="inputEl" v-model="v" + v-panel :class="{ code, _monospace: code }" :disabled="disabled" :required="required" @@ -20,7 +21,7 @@ </div> <div class="caption"><slot name="caption"></slot></div> - <MkButton v-if="manualSave && changed" primary @click="updated"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> + <MkButton v-if="manualSave && changed" primary class="save" @click="updated"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> </div> </template> @@ -174,7 +175,7 @@ export default defineComponent({ .adhpbeos { > .label { font-size: 0.85em; - padding: 0 0 8px 12px; + padding: 0 0 8px 0; user-select: none; &:empty { @@ -183,8 +184,8 @@ export default defineComponent({ } > .caption { - font-size: 0.8em; - padding: 8px 0 0 12px; + font-size: 0.85em; + padding: 8px 0 0 0; color: var(--fgTransparentWeak); &:empty { @@ -209,8 +210,7 @@ export default defineComponent({ font-weight: normal; font-size: 1em; color: var(--fg); - background: var(--panel); - border: solid 0.5px var(--inputBorder); + border: solid 0.5px var(--panel); border-radius: 6px; outline: none; box-shadow: none; @@ -248,5 +248,9 @@ export default defineComponent({ } } } + + > .save { + margin: 8px 0 0 0; + } } </style> diff --git a/packages/client/src/components/global/spacer.vue b/packages/client/src/components/global/spacer.vue index 1129d54c71..34297a3c8b 100644 --- a/packages/client/src/components/global/spacer.vue +++ b/packages/client/src/components/global/spacer.vue @@ -15,19 +15,29 @@ export default defineComponent({ type: Number, required: false, default: null, - } + }, + marginMin: { + type: Number, + required: false, + default: 12, + }, + marginMax: { + type: Number, + required: false, + default: 32, + }, }, setup(props, context) { let ro: ResizeObserver; - const root = ref<HTMLElement>(null); - const content = ref<HTMLElement>(null); + const root = ref<HTMLElement>(); + const content = ref<HTMLElement>(); const margin = ref(0); const adjust = (rect: { width: number; height: number; }) => { if (rect.width > (props.contentMax || 500)) { - margin.value = 32; + margin.value = props.marginMax; } else { - margin.value = 12; + margin.value = props.marginMin; } }; @@ -40,14 +50,14 @@ export default defineComponent({ }); */ adjust({ - width: root.value.offsetWidth, - height: root.value.offsetHeight, + width: root.value!.offsetWidth, + height: root.value!.offsetHeight, }); }); - ro.observe(root.value); + ro.observe(root.value!); if (props.contentMax) { - content.value.style.maxWidth = `${props.contentMax}px`; + content.value!.style.maxWidth = `${props.contentMax}px`; } }); diff --git a/packages/client/src/components/key-value.vue b/packages/client/src/components/key-value.vue new file mode 100644 index 0000000000..6a9a948ce9 --- /dev/null +++ b/packages/client/src/components/key-value.vue @@ -0,0 +1,48 @@ +<template> +<div class="alqyeyti"> + <div class="key"> + <slot name="key"></slot> + </div> + <div class="value"> + <slot name="value"></slot> + <button v-if="copy" v-tooltip="$ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copy_"><i class="far fa-copy"></i></button> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + copy: { + type: String, + required: false, + default: null, + }, + }, + + setup(props) { + const copy_ = () => { + copyToClipboard(props.copy); + os.success(); + }; + + return { + copy_ + }; + }, +}); +</script> + +<style lang="scss" scoped> +.alqyeyti { + > .key { + font-size: 0.85em; + padding: 0 0 0.25em 0; + opacity: 0.75; + } +} +</style> diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue index 1a458b45f7..25d4b48147 100644 --- a/packages/client/src/components/note.vue +++ b/packages/client/src/components/note.vue @@ -858,6 +858,7 @@ export default defineComponent({ .tkcbzcuz { position: relative; transition: box-shadow 0.1s ease; + font-size: 1.05em; overflow: clip; contain: content; diff --git a/packages/client/src/components/ui/button.vue b/packages/client/src/components/ui/button.vue index b5f4547c84..804a2e2720 100644 --- a/packages/client/src/components/ui/button.vue +++ b/packages/client/src/components/ui/button.vue @@ -142,12 +142,12 @@ export default defineComponent({ padding: 8px 14px; text-align: center; font-weight: normal; - font-size: 0.8em; + font-size: 0.9em; line-height: 22px; box-shadow: none; text-decoration: none; background: var(--buttonBg); - border-radius: 4px; + border-radius: 5px; overflow: clip; box-sizing: border-box; transition: background 0.1s ease; diff --git a/packages/client/src/components/ui/menu.vue b/packages/client/src/components/ui/menu.vue index 0e237432ae..6ca5e32555 100644 --- a/packages/client/src/components/ui/menu.vue +++ b/packages/client/src/components/ui/menu.vue @@ -2,7 +2,7 @@ <div ref="items" v-hotkey="keymap" class="rrevdjwt" :class="{ center: align === 'center' }" - :style="{ width: width ? width + 'px' : null }" + :style="{ width: width ? width + 'px' : null, maxHeight: maxHeight ? maxHeight + 'px' : null }" @contextmenu.self="e => e.preventDefault()" > <template v-for="(item, i) in items2"> @@ -64,6 +64,10 @@ export default defineComponent({ type: Number, required: false }, + maxHeight: { + type: Number, + required: false + }, }, emits: ['close'], data() { @@ -146,8 +150,8 @@ export default defineComponent({ <style lang="scss" scoped> .rrevdjwt { padding: 8px 0; + box-sizing: border-box; min-width: 200px; - max-height: 90vh; overflow: auto; &.center { diff --git a/packages/client/src/components/ui/popup-menu.vue b/packages/client/src/components/ui/popup-menu.vue index ae2c044282..93bafddaee 100644 --- a/packages/client/src/components/ui/popup-menu.vue +++ b/packages/client/src/components/ui/popup-menu.vue @@ -1,6 +1,6 @@ <template> -<MkPopup ref="popup" :src="src" @closed="$emit('closed')"> - <MkMenu :items="items" :align="align" :width="width" class="_popup _shadow" @close="$refs.popup.close()"/> +<MkPopup ref="popup" v-slot="{ maxHeight, close }" :src="src" @closed="$emit('closed')"> + <MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" class="_popup _shadow" @close="close()"/> </MkPopup> </template> diff --git a/packages/client/src/components/ui/popup.vue b/packages/client/src/components/ui/popup.vue index 77e928542d..2374de2eaf 100644 --- a/packages/client/src/components/ui/popup.vue +++ b/packages/client/src/components/ui/popup.vue @@ -1,15 +1,15 @@ <template> -<transition :name="$store.state.animation ? 'popup-menu' : ''" appear @after-leave="onClosed" @enter="$emit('opening')" @after-enter="childRendered"> +<transition :name="$store.state.animation ? 'popup-menu' : ''" appear @after-leave="$emit('closed')" @enter="$emit('opening')"> <div v-show="manualShowing != null ? manualShowing : showing" ref="content" class="ccczpooj" :class="{ front, fixed, top: position === 'top' }" :style="{ pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }"> - <slot></slot> + <slot :max-height="maxHeight" :close="close"></slot> </div> </transition> </template> <script lang="ts"> -import { defineComponent, PropType } from 'vue'; +import { defineComponent, nextTick, onMounted, onUnmounted, PropType, ref, watch } from 'vue'; -function getFixedContainer(el: Element | null): Element | null { +function getFixedContainer(el: Element | null | undefined): Element | null { if (el == null || el.tagName === 'BODY') return null; const position = window.getComputedStyle(el).getPropertyValue('position'); if (position === 'fixed') { @@ -41,55 +41,40 @@ export default defineComponent({ type: Boolean, required: false, default: false, - } + }, + noOverlap: { + type: Boolean, + required: false, + default: true, + }, }, emits: ['opening', 'click', 'esc', 'close', 'closed'], - data() { - return { - showing: true, - fixed: false, - transformOrigin: 'center', - contentClicking: false, - }; - }, - - mounted() { - this.$watch('src', () => { - if (this.src) { - // eslint-disable-next-line vue/no-mutating-props - this.src.style.pointerEvents = 'none'; - } - this.fixed = getFixedContainer(this.src) != null; - this.$nextTick(() => { - this.align(); - }); - }, { immediate: true }); - - this.$nextTick(() => { - const popover = this.$refs.content as any; - new ResizeObserver((entries, observer) => { - this.align(); - }).observe(popover); - }); + setup(props, context) { + const maxHeight = ref<number>(); + const fixed = ref(false); + const transformOrigin = ref('center'); + const showing = ref(true); + const content = ref<HTMLElement>(); - document.addEventListener('mousedown', this.onDocumentClick, { passive: true }); - }, + const close = () => { + // eslint-disable-next-line vue/no-mutating-props + if (props.src) props.src.style.pointerEvents = 'auto'; + showing.value = false; + context.emit('close'); + }; - beforeUnmount() { - document.removeEventListener('mousedown', this.onDocumentClick); - }, + const MARGIN = 16; - methods: { - align() { - if (this.src == null) return; + const align = () => { + if (props.src == null) return; - const popover = this.$refs.content as any; + const popover = content.value!; if (popover == null) return; - const rect = this.src.getBoundingClientRect(); + const rect = props.src.getBoundingClientRect(); const width = popover.offsetWidth; const height = popover.offsetHeight; @@ -97,81 +82,84 @@ export default defineComponent({ let left; let top; - if (this.srcCenter) { - const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2); - const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + (this.src.offsetHeight / 2); + if (props.srcCenter) { + const x = rect.left + (fixed.value ? 0 : window.pageXOffset) + (props.src.offsetWidth / 2); + const y = rect.top + (fixed.value ? 0 : window.pageYOffset) + (props.src.offsetHeight / 2); left = (x - (width / 2)); top = (y - (height / 2)); } else { - const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2); - const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + this.src.offsetHeight; + const x = rect.left + (fixed.value ? 0 : window.pageXOffset) + (props.src.offsetWidth / 2); + const y = rect.top + (fixed.value ? 0 : window.pageYOffset) + props.src.offsetHeight; left = (x - (width / 2)); top = y; } - if (this.fixed) { + if (fixed.value) { + // 画面から横にはみ出る場合 if (left + width > window.innerWidth) { left = window.innerWidth - width; } - if (top + height > window.innerHeight) { - top = window.innerHeight - height; + // 画面から縦にはみ出る場合 + if (top + height > (window.innerHeight - MARGIN)) { + if (props.noOverlap) { + const underSpace = (window.innerHeight - MARGIN) - top; + const upperSpace = (rect.top - MARGIN); + if (underSpace >= (upperSpace / 3)) { + maxHeight.value = underSpace; + } else { + maxHeight.value = upperSpace; + top = (upperSpace + MARGIN) - height; + } + } else { + top = (window.innerHeight - MARGIN) - height; + } } } else { + // 画面から横にはみ出る場合 if (left + width - window.pageXOffset > window.innerWidth) { left = window.innerWidth - width + window.pageXOffset - 1; } - if (top + height - window.pageYOffset > window.innerHeight) { - top = window.innerHeight - height + window.pageYOffset - 1; + // 画面から縦にはみ出る場合 + if (top + height - window.pageYOffset > (window.innerHeight - MARGIN)) { + if (props.noOverlap) { + const underSpace = (window.innerHeight - MARGIN) - (top - window.pageYOffset); + const upperSpace = (rect.top - MARGIN); + if (underSpace >= (upperSpace / 3)) { + maxHeight.value = underSpace; + } else { + maxHeight.value = upperSpace; + top = window.pageYOffset + ((upperSpace + MARGIN) - height); + } + } else { + top = (window.innerHeight - MARGIN) - height + window.pageYOffset - 1; + } } } if (top < 0) { - top = 0; + top = MARGIN; } if (left < 0) { left = 0; } - if (top > rect.top + (this.fixed ? 0 : window.pageYOffset)) { - this.transformOrigin = 'center top'; + if (top > rect.top + (fixed.value ? 0 : window.pageYOffset)) { + transformOrigin.value = 'center top'; + } else if ((top + height) <= rect.top + (fixed.value ? 0 : window.pageYOffset)) { + transformOrigin.value = 'center bottom'; } else { - this.transformOrigin = 'center'; + transformOrigin.value = 'center'; } popover.style.left = left + 'px'; popover.style.top = top + 'px'; - }, - - childRendered() { - // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する - const content = this.$refs.content.children[0]; - content.addEventListener('mousedown', e => { - this.contentClicking = true; - window.addEventListener('mouseup', e => { - // click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ - setTimeout(() => { - this.contentClicking = false; - }, 100); - }, { passive: true, once: true }); - }, { passive: true }); - }, - - close() { - // eslint-disable-next-line vue/no-mutating-props - if (this.src) this.src.style.pointerEvents = 'auto'; - this.showing = false; - this.$emit('close'); - }, - - onClosed() { - this.$emit('closed'); - }, + }; - onDocumentClick(ev) { - const flyoutElement = this.$refs.content; + const onDocumentClick = (ev: MouseEvent) => { + const flyoutElement = content.value; let targetElement = ev.target; do { if (targetElement === flyoutElement) { @@ -179,9 +167,45 @@ export default defineComponent({ } targetElement = targetElement.parentNode; } while (targetElement); - this.close(); - } - } + close(); + }; + + onMounted(() => { + watch(() => props.src, async () => { + if (props.src) { + // eslint-disable-next-line vue/no-mutating-props + props.src.style.pointerEvents = 'none'; + } + fixed.value = getFixedContainer(props.src) != null; + + await nextTick() + + align(); + }, { immediate: true, }); + + nextTick(() => { + const popover = content.value; + new ResizeObserver((entries, observer) => { + align(); + }).observe(popover!); + }); + + document.addEventListener('mousedown', onDocumentClick, { passive: true }); + + onUnmounted(() => { + document.removeEventListener('mousedown', onDocumentClick); + }); + }); + + return { + showing, + fixed, + content, + transformOrigin, + maxHeight, + close, + }; + }, }); </script> diff --git a/packages/client/src/components/ui/super-menu.vue b/packages/client/src/components/ui/super-menu.vue index b438d5dbc8..cb2154c48d 100644 --- a/packages/client/src/components/ui/super-menu.vue +++ b/packages/client/src/components/ui/super-menu.vue @@ -51,7 +51,6 @@ export default defineComponent({ } > .title { - font-size: 0.9em; opacity: 0.7; margin: 0 0 8px 12px; } @@ -64,7 +63,6 @@ export default defineComponent({ box-sizing: border-box; padding: 10px 16px 10px 8px; border-radius: 9px; - font-size: 0.9em; &:hover { text-decoration: none; diff --git a/packages/client/src/components/ui/tooltip.vue b/packages/client/src/components/ui/tooltip.vue index dbc1a1c9b7..2a63c207fd 100644 --- a/packages/client/src/components/ui/tooltip.vue +++ b/packages/client/src/components/ui/tooltip.vue @@ -1,13 +1,13 @@ <template> <transition name="tooltip" appear @after-leave="$emit('closed')"> - <div v-show="showing" ref="content" class="buebdbiu _acrylic _shadow" :style="{ maxWidth: maxWidth + 'px' }"> + <div v-show="showing" ref="el" class="buebdbiu _acrylic _shadow" :style="{ maxWidth: maxWidth + 'px' }"> <slot>{{ text }}</slot> </div> </transition> </template> <script lang="ts"> -import { defineComponent } from 'vue'; +import { defineComponent, nextTick, onMounted, onUnmounted, ref } from 'vue'; export default defineComponent({ props: { @@ -31,35 +31,64 @@ export default defineComponent({ emits: ['closed'], - mounted() { - this.$nextTick(() => { - if (this.source == null) { - this.$emit('closed'); - return; - } + setup(props, context) { + const el = ref<HTMLElement>(); + + const setPosition = () => { + if (el.value == null) return; - const rect = this.source.getBoundingClientRect(); + const rect = props.source.getBoundingClientRect(); - const contentWidth = this.$refs.content.offsetWidth; - const contentHeight = this.$refs.content.offsetHeight; + const contentWidth = el.value.offsetWidth; + const contentHeight = el.value.offsetHeight; - let left = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + let left = rect.left + window.pageXOffset + (props.source.offsetWidth / 2); let top = rect.top + window.pageYOffset - contentHeight; - left -= (this.$el.offsetWidth / 2); + left -= (el.value.offsetWidth / 2); if (left + contentWidth - window.pageXOffset > window.innerWidth) { left = window.innerWidth - contentWidth + window.pageXOffset - 1; } if (top - window.pageYOffset < 0) { - top = rect.top + window.pageYOffset + this.source.offsetHeight; - this.$refs.content.style.transformOrigin = 'center top'; + top = rect.top + window.pageYOffset + props.source.offsetHeight; + el.value.style.transformOrigin = 'center top'; } - this.$el.style.left = left + 'px'; - this.$el.style.top = top + 'px'; + el.value.style.left = left + 'px'; + el.value.style.top = top + 'px'; + }; + + onMounted(() => { + nextTick(() => { + if (props.source == null) { + context.emit('closed'); + return; + } + + setPosition(); + + let loopHandler; + + const loop = () => { + loopHandler = window.requestAnimationFrame(() => { + setPosition(); + loop(); + }); + }; + + loop(); + + onUnmounted(() => { + window.cancelAnimationFrame(loopHandler); + }); + }); }); + + return { + el, + }; }, }) </script> |