mirror of
https://github.com/sim1222/misskey.git
synced 2025-08-03 23:16:28 +09:00
rename: client -> frontend
This commit is contained in:
144
packages/frontend/src/components/form/checkbox.vue
Normal file
144
packages/frontend/src/components/form/checkbox.vue
Normal file
@ -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>
|
107
packages/frontend/src/components/form/folder.vue
Normal file
107
packages/frontend/src/components/form/folder.vue
Normal file
@ -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>
|
263
packages/frontend/src/components/form/input.vue
Normal file
263
packages/frontend/src/components/form/input.vue
Normal file
@ -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>
|
95
packages/frontend/src/components/form/link.vue
Normal file
95
packages/frontend/src/components/form/link.vue
Normal file
@ -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>
|
132
packages/frontend/src/components/form/radio.vue
Normal file
132
packages/frontend/src/components/form/radio.vue
Normal file
@ -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>
|
83
packages/frontend/src/components/form/radios.vue
Normal file
83
packages/frontend/src/components/form/radios.vue
Normal file
@ -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>
|
259
packages/frontend/src/components/form/range.vue
Normal file
259
packages/frontend/src/components/form/range.vue
Normal file
@ -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>
|
43
packages/frontend/src/components/form/section.vue
Normal file
43
packages/frontend/src/components/form/section.vue
Normal file
@ -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>
|
279
packages/frontend/src/components/form/select.vue
Normal file
279
packages/frontend/src/components/form/select.vue
Normal file
@ -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>
|
41
packages/frontend/src/components/form/slot.vue
Normal file
41
packages/frontend/src/components/form/slot.vue
Normal file
@ -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>
|
27
packages/frontend/src/components/form/split.vue
Normal file
27
packages/frontend/src/components/form/split.vue
Normal file
@ -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>
|
98
packages/frontend/src/components/form/suspense.vue
Normal file
98
packages/frontend/src/components/form/suspense.vue
Normal file
@ -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>
|
144
packages/frontend/src/components/form/switch.vue
Normal file
144
packages/frontend/src/components/form/switch.vue
Normal file
@ -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>
|
260
packages/frontend/src/components/form/textarea.vue
Normal file
260
packages/frontend/src/components/form/textarea.vue
Normal file
@ -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>
|
Reference in New Issue
Block a user