wip
This commit is contained in:
parent
6f76b598a1
commit
1efa7e037a
|
|
@ -5605,6 +5605,10 @@ export interface Locale extends ILocale {
|
|||
* 技術的なお問い合わせの際に、以下の情報を併記すると問題の解決に役立つことがあります。
|
||||
*/
|
||||
"deviceInfoDescription": string;
|
||||
/**
|
||||
* 入力が不正なためカレンダーを表示できません
|
||||
*/
|
||||
"calendarInvalidDateError": string;
|
||||
"_compression": {
|
||||
"_quality": {
|
||||
/**
|
||||
|
|
@ -9545,6 +9549,36 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"saturday": string;
|
||||
};
|
||||
"_weekdayShort": {
|
||||
/**
|
||||
* 日
|
||||
*/
|
||||
"sunday": string;
|
||||
/**
|
||||
* 月
|
||||
*/
|
||||
"monday": string;
|
||||
/**
|
||||
* 火
|
||||
*/
|
||||
"tuesday": string;
|
||||
/**
|
||||
* 水
|
||||
*/
|
||||
"wednesday": string;
|
||||
/**
|
||||
* 木
|
||||
*/
|
||||
"thursday": string;
|
||||
/**
|
||||
* 金
|
||||
*/
|
||||
"friday": string;
|
||||
/**
|
||||
* 土
|
||||
*/
|
||||
"saturday": string;
|
||||
};
|
||||
"_widgets": {
|
||||
/**
|
||||
* プロフィール
|
||||
|
|
|
|||
|
|
@ -1396,6 +1396,7 @@ scheduled: "予約"
|
|||
widgets: "ウィジェット"
|
||||
deviceInfo: "デバイス情報"
|
||||
deviceInfoDescription: "技術的なお問い合わせの際に、以下の情報を併記すると問題の解決に役立つことがあります。"
|
||||
calendarInvalidDateError: "入力が不正なためカレンダーを表示できません"
|
||||
|
||||
_compression:
|
||||
_quality:
|
||||
|
|
@ -2509,6 +2510,15 @@ _weekday:
|
|||
friday: "金曜日"
|
||||
saturday: "土曜日"
|
||||
|
||||
_weekdayShort:
|
||||
sunday: "日"
|
||||
monday: "月"
|
||||
tuesday: "火"
|
||||
wednesday: "水"
|
||||
thursday: "木"
|
||||
friday: "金"
|
||||
saturday: "土"
|
||||
|
||||
_widgets:
|
||||
profile: "プロフィール"
|
||||
instanceInfo: "サーバー情報"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { lang } from '@@/js/config.js';
|
|||
export const versatileLang = (lang ?? 'ja-JP').replace('ja-KS', 'ja-JP');
|
||||
|
||||
let _dateTimeFormat: Intl.DateTimeFormat;
|
||||
|
||||
try {
|
||||
_dateTimeFormat = new Intl.DateTimeFormat(versatileLang, {
|
||||
year: 'numeric',
|
||||
|
|
@ -32,6 +33,7 @@ try {
|
|||
second: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export const dateTimeFormat = _dateTimeFormat;
|
||||
|
||||
export const timeZone = dateTimeFormat.resolvedOptions().timeZone;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { versatileLang } from '@@/js/intl-const.js';
|
||||
|
||||
export function createDateTimeFormatter(options: Intl.DateTimeFormatOptions) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(versatileLang, options);
|
||||
} catch {
|
||||
// Fallback to en-US
|
||||
return new Intl.DateTimeFormat('en-US', options);
|
||||
}
|
||||
}
|
||||
|
||||
export function createNumberFormatter(options?: Intl.NumberFormatOptions) {
|
||||
try {
|
||||
return new Intl.NumberFormat(versatileLang, options);
|
||||
} catch {
|
||||
// Fallback to en-US
|
||||
return new Intl.NumberFormat('en-US', options);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,520 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkModal
|
||||
ref="modal"
|
||||
v-slot="{ type: modalType, maxHeight }"
|
||||
:zPriority="'high'"
|
||||
:anchorElement="anchorElement"
|
||||
:transparentBg="true"
|
||||
:returnFocusTo="returnFocusTo"
|
||||
@click="close"
|
||||
@close="onModalClose"
|
||||
@closed="onModalClosed"
|
||||
>
|
||||
<div
|
||||
class="_popup _shadow"
|
||||
:class="[$style.root, {
|
||||
[$style.asDrawer]: modalType === 'drawer',
|
||||
[$style.widthSpecified]: width != null,
|
||||
}]"
|
||||
:style="{
|
||||
'--width': width != null ? `${width}px` : 'auto',
|
||||
maxHeight: maxHeight ? `${maxHeight}px` : 'auto',
|
||||
}">
|
||||
<div :class="[$style.formRoot, { [$style.datetime]: type === 'datetime' }]">
|
||||
<div v-if="type === 'date' || type === 'datetime'" :class="$style.dateRoot">
|
||||
<div :class="$style.calendarRoot">
|
||||
<div :class="$style.calendarControls">
|
||||
<MkButton iconOnly @click="prevMonth"><i class="ti ti-chevron-left"></i></MkButton>
|
||||
<div>
|
||||
<input type="number" v-model.number="year" style="width: 4em; text-align: center;" />
|
||||
/
|
||||
<input type="number" v-model.number="month" style="width: 2em; text-align: center;" />
|
||||
</div>
|
||||
<MkButton iconOnly @click="nextMonth"><i class="ti ti-chevron-right"></i></MkButton>
|
||||
</div>
|
||||
<template v-if="isCalInvalid">
|
||||
<div :class="$style.calendarInvalid">{{ i18n.ts.calendarInvalidDateError }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div :class="$style.calendarHeader">
|
||||
<div>{{ i18n.ts._weekdayShort.sunday }}</div>
|
||||
<div>{{ i18n.ts._weekdayShort.monday }}</div>
|
||||
<div>{{ i18n.ts._weekdayShort.tuesday }}</div>
|
||||
<div>{{ i18n.ts._weekdayShort.wednesday }}</div>
|
||||
<div>{{ i18n.ts._weekdayShort.thursday }}</div>
|
||||
<div>{{ i18n.ts._weekdayShort.friday }}</div>
|
||||
<div>{{ i18n.ts._weekdayShort.saturday }}</div>
|
||||
</div>
|
||||
<div v-for="date in calDateArray" :key="date.radioValue">
|
||||
<input
|
||||
type="radio"
|
||||
:class="$style.calendarDayRadio"
|
||||
:name="id"
|
||||
:id="`${id}-day-${date.radioValue}`"
|
||||
:disabled="date.disabled"
|
||||
:value="date.radioValue"
|
||||
v-model="dateValue"
|
||||
/>
|
||||
<label
|
||||
class="_button"
|
||||
:class="[$style.calendarDayLabel, {
|
||||
[$style.today]: date.isToday,
|
||||
[$style.saturday]: date.day === 6,
|
||||
[$style.sunday]: date.day === 0,
|
||||
[$style.notCurrentMonth]: !date.isCurrentMonth,
|
||||
[$style.initiallySelected]: date.isInitiallySelected,
|
||||
[$style.disabled]: date.disabled,
|
||||
}]"
|
||||
:for="`${id}-day-${date.radioValue}`"
|
||||
>{{ date.date.date }}</label>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="type === 'time' || type === 'datetime'" :class="$style.timeRoot">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup generic="F extends FormType">
|
||||
import { computed, ref, shallowRef, useTemplateRef, watch } from 'vue';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import MkModal from '@/components/MkModal.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import type { FormType, MkDateTimeInputDateObject, MkDateTimeInputTimeObject, MkDateTimeInputValue } from './MkDateTimeInput.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
type: F;
|
||||
initialValue: MkDateTimeInputValue<F> | null;
|
||||
min?: MkDateTimeInputValue<F>;
|
||||
max?: MkDateTimeInputValue<F>;
|
||||
width?: number;
|
||||
anchorElement?: HTMLElement | null;
|
||||
returnFocusTo?: HTMLElement | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'chosen', value: MkDateTimeInputValue<F> | null): void;
|
||||
(ev: 'closing'): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const fixedInitialValue = props.initialValue;
|
||||
|
||||
const id = genId();
|
||||
|
||||
const modal = useTemplateRef('modal');
|
||||
|
||||
function close() {
|
||||
modal.value?.close();
|
||||
}
|
||||
|
||||
function onModalClose() {
|
||||
emit('closing');
|
||||
}
|
||||
|
||||
function onModalClosed() {
|
||||
emit('closed');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const minDate = shallowRef<Date | null>(null);
|
||||
const maxDate = shallowRef<Date | null>(null);
|
||||
watch(() => props.min, (to) => {
|
||||
minDate.value = props.type !== 'time' && to != null ? new Date((to as MkDateTimeInputDateObject).year, (to as MkDateTimeInputDateObject).month - 1, (to as MkDateTimeInputDateObject).date) : null;
|
||||
}, { immediate: true });
|
||||
watch(() => props.max, (to) => {
|
||||
maxDate.value = props.type !== 'time' && to != null ? new Date((to as MkDateTimeInputDateObject).year, (to as MkDateTimeInputDateObject).month - 1, (to as MkDateTimeInputDateObject).date) : null;
|
||||
}, { immediate: true });
|
||||
|
||||
//#region Calendar Controls
|
||||
const year = ref((props.initialValue as MkDateTimeInputDateObject)?.year ?? now.getFullYear());
|
||||
const month = ref((props.initialValue as MkDateTimeInputDateObject)?.month ?? now.getMonth() + 1);
|
||||
const isCalInvalid = computed(() => {
|
||||
return month.value < 1 || month.value > 12;
|
||||
});
|
||||
function prevMonth() {
|
||||
if (month.value <= 1) {
|
||||
month.value = 12;
|
||||
year.value -= 1;
|
||||
} else {
|
||||
month.value--;
|
||||
}
|
||||
}
|
||||
function nextMonth() {
|
||||
if (month.value >= 12) {
|
||||
month.value = 1;
|
||||
year.value += 1;
|
||||
} else {
|
||||
month.value++;
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region Calendar Values
|
||||
type CalendarValue = {
|
||||
radioValue: string;
|
||||
date: MkDateTimeInputDateObject;
|
||||
day: number;
|
||||
isToday: boolean;
|
||||
isCurrentMonth: boolean;
|
||||
isInitiallySelected: boolean;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
function isSameDay(date1: Date, date2: Date): boolean {
|
||||
return date1.getFullYear() === date2.getFullYear()
|
||||
&& date1.getMonth() === date2.getMonth()
|
||||
&& date1.getDate() === date2.getDate();
|
||||
}
|
||||
|
||||
function isSameDateObject(date1: MkDateTimeInputDateObject, date2: MkDateTimeInputDateObject): boolean {
|
||||
return date1.year === date2.year
|
||||
&& date1.month === date2.month
|
||||
&& date1.date === date2.date;
|
||||
}
|
||||
|
||||
function isDisabled(date: MkDateTimeInputDateObject): boolean {
|
||||
if (props.min == null && props.max == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dateInstance = new Date(date.year, date.month - 1, date.date);
|
||||
|
||||
if (minDate.value != null) {
|
||||
if (dateInstance < minDate.value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (maxDate.value != null) {
|
||||
if (dateInstance > maxDate.value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const calDateArray = computed<CalendarValue[]>(() => {
|
||||
const dateArray: CalendarValue[] = [];
|
||||
|
||||
const firstDay = new Date(year.value, month.value - 1, 1).getDay();
|
||||
const lastDate = new Date(year.value, month.value, 0).getDate();
|
||||
|
||||
for (let i = firstDay; i > 0; i--) {
|
||||
// 前月の日付で埋める
|
||||
const dateInstance = new Date(year.value, month.value - 1, 0);
|
||||
dateInstance.setDate(dateInstance.getDate() - i + 1);
|
||||
const dayYear = dateInstance.getFullYear();
|
||||
const dayMonth = dateInstance.getMonth() + 1;
|
||||
const dayDate = dateInstance.getDate();
|
||||
const dateString = `${dayYear.toString().padStart(4, '0')}-${dayMonth.toString().padStart(2, '0')}-${dayDate.toString().padStart(2, '0')}`;
|
||||
|
||||
dateArray.push({
|
||||
radioValue: dateString,
|
||||
date: { year: dayYear, month: dayMonth, date: dayDate },
|
||||
day: (firstDay - i) % 7,
|
||||
isCurrentMonth: false,
|
||||
isToday: isSameDay(dateInstance, now),
|
||||
isInitiallySelected: fixedInitialValue != null && isSameDateObject(fixedInitialValue as MkDateTimeInputDateObject, { year: dayYear, month: dayMonth, date: dayDate }),
|
||||
disabled: isDisabled({ year: dayYear, month: dayMonth, date: dayDate }),
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 1; i <= lastDate; i++) {
|
||||
const dateInstance = new Date(year.value, month.value - 1, i);
|
||||
const dateString = `${year.value.toString().padStart(4, '0')}-${month.value.toString().padStart(2, '0')}-${i.toString().padStart(2, '0')}`;
|
||||
|
||||
dateArray.push({
|
||||
radioValue: dateString,
|
||||
date: { year: year.value, month: month.value, date: i },
|
||||
day: (firstDay + i - 1) % 7,
|
||||
isCurrentMonth: true,
|
||||
isToday: isSameDay(dateInstance, now),
|
||||
isInitiallySelected: fixedInitialValue != null && isSameDateObject(fixedInitialValue as MkDateTimeInputDateObject, { year: year.value, month: month.value, date: i }),
|
||||
disabled: isDisabled({ year: year.value, month: month.value, date: i }),
|
||||
});
|
||||
}
|
||||
|
||||
const remainingDays = 7 - (dateArray.length % 7);
|
||||
|
||||
for (let i = 1; i <= remainingDays; i++) {
|
||||
// 翌月の日付で埋める
|
||||
const dateInstance = new Date(year.value, month.value, i);
|
||||
const dayYear = dateInstance.getFullYear();
|
||||
const dayMonth = dateInstance.getMonth() + 1;
|
||||
const dayDate = dateInstance.getDate();
|
||||
const dateString = `${dayYear.toString().padStart(4, '0')}-${dayMonth.toString().padStart(2, '0')}-${dayDate.toString().padStart(2, '0')}`;
|
||||
|
||||
dateArray.push({
|
||||
radioValue: dateString,
|
||||
date: { year: dayYear, month: dayMonth, date: dayDate },
|
||||
day: (firstDay + lastDate + i - 1) % 7,
|
||||
isCurrentMonth: false,
|
||||
isToday: isSameDay(dateInstance, now),
|
||||
isInitiallySelected: fixedInitialValue != null && isSameDateObject(fixedInitialValue as MkDateTimeInputDateObject, { year: dayYear, month: dayMonth, date: dayDate }),
|
||||
disabled: isDisabled({ year: dayYear, month: dayMonth, date: dayDate }),
|
||||
});
|
||||
}
|
||||
|
||||
return dateArray;
|
||||
});
|
||||
//#endregion
|
||||
|
||||
//#region Date Value
|
||||
const dateValue = ref<string>((() => {
|
||||
if (props.initialValue == null) {
|
||||
return '';
|
||||
}
|
||||
if (props.type === 'date' || props.type === 'datetime') {
|
||||
const val = props.initialValue as MkDateTimeInputDateObject;
|
||||
return `${val.year.toString().padStart(4, '0')}-${val.month.toString().padStart(2, '0')}-${val.date.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
})());
|
||||
const hourValue = ref<number>((() => {
|
||||
if (props.initialValue == null) {
|
||||
return 0;
|
||||
}
|
||||
if (props.type === 'time' || props.type === 'datetime') {
|
||||
const val = props.initialValue as MkDateTimeInputTimeObject;
|
||||
return val.hour;
|
||||
}
|
||||
|
||||
return 0;
|
||||
})());
|
||||
const minuteValue = ref<number>((() => {
|
||||
if (props.initialValue == null) {
|
||||
return 0;
|
||||
}
|
||||
if (props.type === 'time' || props.type === 'datetime') {
|
||||
const val = props.initialValue as MkDateTimeInputTimeObject;
|
||||
return val.minute;
|
||||
}
|
||||
return 0;
|
||||
})());
|
||||
const valueObject = computed<MkDateTimeInputValue<F> | null>(() => {
|
||||
if (props.type === 'date') {
|
||||
if (dateValue.value) {
|
||||
const [yearStr, monthStr, dateStr] = dateValue.value.split('-');
|
||||
return {
|
||||
year: parseInt(yearStr, 10),
|
||||
month: parseInt(monthStr, 10),
|
||||
date: parseInt(dateStr, 10),
|
||||
} as MkDateTimeInputDateObject as MkDateTimeInputValue<F>;
|
||||
}
|
||||
} else if (props.type === 'time') {
|
||||
return {
|
||||
hour: hourValue.value,
|
||||
minute: minuteValue.value,
|
||||
} as MkDateTimeInputTimeObject as MkDateTimeInputValue<F>;
|
||||
} else if (props.type === 'datetime') {
|
||||
if (dateValue.value) {
|
||||
const [yearStr, monthStr, dateStr] = dateValue.value.split('-');
|
||||
return {
|
||||
year: parseInt(yearStr, 10),
|
||||
month: parseInt(monthStr, 10),
|
||||
date: parseInt(dateStr, 10),
|
||||
hour: hourValue.value,
|
||||
minute: minuteValue.value,
|
||||
} as MkDateTimeInputDateObject & MkDateTimeInputTimeObject as MkDateTimeInputValue<F>;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
//#endregion
|
||||
|
||||
watch(valueObject, (to) => {
|
||||
emit('chosen', to);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.root {
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
max-width: 100vw;
|
||||
min-width: 200px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
container-type: inline-size;
|
||||
position: relative;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:not(.asDrawer).widthSpecified {
|
||||
width: var(--width);
|
||||
}
|
||||
|
||||
&:not(.asDrawer):not(.widthSpecified) {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
&.asDrawer {
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
padding: 24px 24px max(env(safe-area-inset-bottom, 0px), 24px);
|
||||
width: 100%;
|
||||
border-radius: 24px;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.formRoot {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--MI-margin);
|
||||
}
|
||||
|
||||
@container (min-width: 400px) {
|
||||
.formRoot.datetime {
|
||||
grid-template-columns: 2fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.calendarRoot {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 32px);
|
||||
grid-template-rows: auto auto repeat(6, 32px);
|
||||
text-align: center;
|
||||
width: max-content;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.calendarInvalid {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2 / -1;
|
||||
color: var(--MI_THEME-error);
|
||||
text-align: center;
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.calendarControls {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--MI-margin);
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.calendarHeader {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 32px);
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
font-size: 0.8em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.calendarDayRadio {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.calendarDayLabel {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
|
||||
&.today {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&.saturday {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
&.sunday {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&.notCurrentMonth {
|
||||
color: color(from var(--MI_THEME-fg) srgb r g b / 0.5);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 32px;
|
||||
background: var(--MI_THEME-fg);
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-radius: 50%;
|
||||
background-color: transparent;
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&.initiallySelected::before {
|
||||
border-color: color(from var(--MI_THEME-accent) srgb r g b / 0.5);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
&.today::before {
|
||||
border-color: color(from var(--MI_THEME-fg) srgb r g b / 0.5);
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
background: var(--MI_THEME-panelHighlight);
|
||||
}
|
||||
}
|
||||
|
||||
.calendarDayRadio:checked + .calendarDayLabel {
|
||||
color: var(--MI_THEME-fgOnAccent);
|
||||
|
||||
&:before {
|
||||
background: var(--MI_THEME-accent);
|
||||
border-color: var(--MI_THEME-accent);
|
||||
border-style: solid;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,361 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div :class="$style.label" @click="focus"><slot name="label"></slot></div>
|
||||
<div
|
||||
ref="container"
|
||||
tabindex="0"
|
||||
:class="[$style.input, { [$style.inline]: inline, [$style.disabled]: disabled, [$style.focused]: focused || opening }]"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
@mousedown.prevent="show"
|
||||
@keydown.space.enter="show"
|
||||
>
|
||||
<div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div>
|
||||
<div
|
||||
ref="inputEl"
|
||||
v-adaptive-border
|
||||
tabindex="-1"
|
||||
:class="$style.inputCore"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
:readonly="readonly"
|
||||
:placeholder="placeholder"
|
||||
@mousedown.prevent="() => {}"
|
||||
@keydown.prevent="() => {}"
|
||||
>
|
||||
<div style="pointer-events: none;">{{ currentValueText ?? '' }}</div>
|
||||
<div style="display: none;">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="suffixEl" :class="$style.suffix"><i class="ti ti-chevron-down" :class="[$style.chevron, { [$style.chevronOpening]: opening }]"></i></div>
|
||||
</div>
|
||||
<div :class="$style.caption"><slot name="caption"></slot></div>
|
||||
|
||||
<MkButton v-if="manualSave && changed" primary :class="$style.save" @click="update"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export interface MkDateTimeInputDateObject {
|
||||
year: number;
|
||||
month: number;
|
||||
date: number;
|
||||
}
|
||||
|
||||
export interface MkDateTimeInputTimeObject {
|
||||
hour: number;
|
||||
minute: number;
|
||||
}
|
||||
|
||||
export interface DateTimeObject extends MkDateTimeInputDateObject, MkDateTimeInputTimeObject {}
|
||||
|
||||
export type FormType = 'date' | 'time' | 'datetime';
|
||||
|
||||
export type MkDateTimeInputValue<F extends FormType> =
|
||||
F extends 'date'
|
||||
? MkDateTimeInputDateObject
|
||||
: F extends 'time'
|
||||
? MkDateTimeInputTimeObject
|
||||
: F extends 'datetime'
|
||||
? DateTimeObject
|
||||
: never;
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup generic="FORM extends FormType">
|
||||
import { onMounted, nextTick, ref, computed, toRefs, useTemplateRef, watch, shallowRef, defineAsyncComponent } from 'vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { useInterval } from '@@/js/use-interval.js';
|
||||
import { dateTimeFormat } from '@@/js/intl-const.js';
|
||||
import { getHTMLElementOrNull } from '@/utility/get-dom-node-or-null.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import * as os from '@/os.js';
|
||||
|
||||
const props = defineProps<{
|
||||
type: FORM;
|
||||
min?: MkDateTimeInputValue<FORM>;
|
||||
max?: MkDateTimeInputValue<FORM>;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
autofocus?: boolean;
|
||||
inline?: boolean;
|
||||
small?: boolean;
|
||||
large?: boolean;
|
||||
manualSave?: boolean;
|
||||
}>();
|
||||
|
||||
const model = defineModel<MkDateTimeInputValue<FORM> | null>({ required: true });
|
||||
const value = ref(model.value);
|
||||
const changed = computed(() => {
|
||||
if (model.value == null && value.value == null) {
|
||||
return false;
|
||||
} else if (model.value == null && value.value != null) {
|
||||
return true;
|
||||
} else if (model.value != null && value.value == null) {
|
||||
return true;
|
||||
} else {
|
||||
if (props.type === 'date') {
|
||||
const mv = model.value as MkDateTimeInputDateObject;
|
||||
const vv = value.value as MkDateTimeInputDateObject;
|
||||
return mv.year !== vv.year || mv.month !== vv.month || mv.date !== vv.date;
|
||||
} else if (props.type === 'time') {
|
||||
const mv = model.value as MkDateTimeInputTimeObject;
|
||||
const vv = value.value as MkDateTimeInputTimeObject;
|
||||
return mv.hour !== vv.hour || mv.minute !== vv.minute;
|
||||
} else if (props.type === 'datetime') {
|
||||
const mv = model.value as DateTimeObject;
|
||||
const vv = value.value as DateTimeObject;
|
||||
return mv.year !== vv.year || mv.month !== vv.month || mv.date !== vv.date || mv.hour !== vv.hour || mv.minute !== vv.minute;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
watch(model, () => {
|
||||
if (props.manualSave) {
|
||||
value.value = model.value;
|
||||
}
|
||||
});
|
||||
|
||||
watch(value, () => {
|
||||
if (!props.manualSave) {
|
||||
model.value = value.value;
|
||||
}
|
||||
});
|
||||
|
||||
function update() {
|
||||
model.value = value.value;
|
||||
}
|
||||
|
||||
function assertDateObject<F extends FormType | FORM>(obj: unknown, type: F): obj is MkDateTimeInputValue<F> {
|
||||
return props.type === type;
|
||||
}
|
||||
|
||||
const { autofocus } = toRefs(props);
|
||||
const focused = ref(false);
|
||||
const opening = ref(false);
|
||||
const currentValueText = computed<string | null>(() => {
|
||||
if (value.value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (assertDateObject(value.value, 'date')) {
|
||||
return `${value.value.year.toString().padStart(4, '0')}/${value.value.month.toString().padStart(2, '0')}/${value.value.date.toString().padStart(2, '0')}`;
|
||||
} else if (assertDateObject(value.value, 'time')) {
|
||||
return `${value.value.hour.toString().padStart(2, '0')}:${value.value.minute.toString().padStart(2, '0')}`;
|
||||
} else if (assertDateObject(value.value, 'datetime')) {
|
||||
const v = value.value as DateTimeObject;
|
||||
const date = new Date(
|
||||
v.year,
|
||||
v.month - 1,
|
||||
v.date,
|
||||
v.hour,
|
||||
v.minute,
|
||||
);
|
||||
return dateTimeFormat.format(date);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const inputEl = useTemplateRef('inputEl');
|
||||
const prefixEl = useTemplateRef('prefixEl');
|
||||
const suffixEl = useTemplateRef('suffixEl');
|
||||
const container = useTemplateRef('container');
|
||||
const height =
|
||||
props.small ? 33 :
|
||||
props.large ? 39 :
|
||||
36;
|
||||
|
||||
const focus = () => container.value?.focus();
|
||||
|
||||
// このコンポーネントが作成された時、非表示状態である場合がある
|
||||
// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
|
||||
useInterval(() => {
|
||||
if (inputEl.value == null) return;
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
function show() {
|
||||
if (opening.value || props.disabled || props.readonly) return;
|
||||
focus();
|
||||
|
||||
opening.value = true;
|
||||
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('./MkDateTimeInput.dialog.vue')), {
|
||||
initialValue: value.value,
|
||||
type: props.type,
|
||||
min: props.min,
|
||||
max: props.max,
|
||||
width: container.value?.offsetWidth,
|
||||
anchorElement: container.value || null,
|
||||
returnFocusTo: getHTMLElementOrNull(container.value) ?? getHTMLElementOrNull(window.document.activeElement),
|
||||
}, {
|
||||
chosen: (newValue: MkDateTimeInputValue<FORM> | null) => {
|
||||
value.value = newValue;
|
||||
},
|
||||
closing: () => {
|
||||
opening.value = false;
|
||||
},
|
||||
closed: () => {
|
||||
dispose();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if (autofocus.value) {
|
||||
focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.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: color(from var(--MI_THEME-fg) srgb r g b / 0.75);
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&.inline {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.focused {
|
||||
> .inputCore {
|
||||
border-color: var(--MI_THEME-accent) !important;
|
||||
//box-shadow: 0 0 0 4px var(--MI_THEME-focus);
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.7;
|
||||
|
||||
&,
|
||||
> .inputCore {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
> .inputCore {
|
||||
border-color: var(--MI_THEME-inputBorderHover) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inputCore {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: v-bind("height + 'px'");
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0 12px;
|
||||
font: inherit;
|
||||
font-weight: normal;
|
||||
font-size: 1em;
|
||||
color: var(--MI_THEME-fg);
|
||||
background: var(--MI_THEME-panel);
|
||||
border: solid 1px var(--MI_THEME-panel);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.1s ease-out;
|
||||
cursor: pointer;
|
||||
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'");
|
||||
min-width: 16px;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.prefix {
|
||||
left: 0;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.suffix {
|
||||
right: 0;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.save {
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
transition: transform 0.1s ease-out;
|
||||
}
|
||||
|
||||
.chevronOpening {
|
||||
transform: rotateX(180deg);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -46,10 +46,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</SearchMarker>
|
||||
|
||||
<SearchMarker :keywords="['birthday', 'birthdate', 'age']">
|
||||
<MkInput v-model="profile.birthday" type="date" manualSave>
|
||||
<MkDateTimeInput v-model="birthday" type="date" manualSave>
|
||||
<template #label><SearchLabel>{{ i18n.ts.birthday }}</SearchLabel></template>
|
||||
<template #prefix><i class="ti ti-cake"></i></template>
|
||||
</MkInput>
|
||||
</MkDateTimeInput>
|
||||
</SearchMarker>
|
||||
|
||||
<SearchMarker :keywords="['language', 'locale']">
|
||||
|
|
@ -185,6 +185,8 @@ import { store } from '@/store.js';
|
|||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import MkDateTimeInput from '@/components/MkDateTimeInput.vue';
|
||||
import type { MkDateTimeInputDateObject } from '@/components/MkDateTimeInput.vue';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
|
|
@ -207,6 +209,26 @@ const profile = reactive({
|
|||
isCat: $i.isCat ?? false,
|
||||
});
|
||||
|
||||
const birthday = computed<MkDateTimeInputDateObject | null>({
|
||||
get: () => {
|
||||
if (!profile.birthday) return null;
|
||||
const [year, month, date] = (profile.birthday ?? '').split('-').map(v => parseInt(v, 10));
|
||||
if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(date)) {
|
||||
return null;
|
||||
}
|
||||
return { year, month, date };
|
||||
},
|
||||
set: (value) => {
|
||||
if (value === null) {
|
||||
profile.birthday = null;
|
||||
} else {
|
||||
const monthStr = value.month.toString().padStart(2, '0');
|
||||
const dayStr = value.date.toString().padStart(2, '0');
|
||||
profile.birthday = `${value.year}-${monthStr}-${dayStr}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
watch(() => profile, () => {
|
||||
save();
|
||||
}, {
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { lang } from '@@/js/config.js';
|
||||
|
||||
export const versatileLang = (lang ?? 'ja-JP').replace('ja-KS', 'ja-JP');
|
||||
|
||||
let _dateTimeFormat: Intl.DateTimeFormat;
|
||||
try {
|
||||
_dateTimeFormat = new Intl.DateTimeFormat(versatileLang, {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(err);
|
||||
if (_DEV_) console.log('[Intl] Fallback to en-US');
|
||||
|
||||
// Fallback to en-US
|
||||
_dateTimeFormat = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
});
|
||||
}
|
||||
export const dateTimeFormat = _dateTimeFormat;
|
||||
|
||||
export const timeZone = dateTimeFormat.resolvedOptions().timeZone;
|
||||
|
||||
export const hemisphere = /^(australia|pacific|antarctica|indian)\//i.test(timeZone) ? 'S' : 'N';
|
||||
|
||||
let _numberFormat: Intl.NumberFormat;
|
||||
try {
|
||||
_numberFormat = new Intl.NumberFormat(versatileLang);
|
||||
} catch (err) {
|
||||
console.warn(err);
|
||||
if (_DEV_) console.log('[Intl] Fallback to en-US');
|
||||
|
||||
// Fallback to en-US
|
||||
_numberFormat = new Intl.NumberFormat('en-US');
|
||||
}
|
||||
export const numberFormat = _numberFormat;
|
||||
Loading…
Reference in New Issue