Implement radial category selector
This commit is contained in:
parent
9e734babbb
commit
00e5e035a9
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { EntryType } from "@cents/domain";
|
import type { EntryType } from "@cents/domain";
|
||||||
import { ChevronRight } from "@lucide/vue";
|
import { ArrowLeft, Check } from "@lucide/vue";
|
||||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
||||||
import {
|
import {
|
||||||
categories,
|
categories,
|
||||||
entryTypes,
|
entryTypes,
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
} from "../data/categories";
|
} from "../data/categories";
|
||||||
|
|
||||||
type SelectorLevel = "type" | "category" | "subcategory";
|
type SelectorLevel = "type" | "category" | "subcategory";
|
||||||
|
type SelectorOption = EntryTypeOption | Category;
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -23,60 +24,93 @@ const emit = defineEmits<{
|
||||||
confirm: [];
|
confirm: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const FOCUS_ANGLE = 270;
|
||||||
const selectorLevel = ref<SelectorLevel>("type");
|
const selectorLevel = ref<SelectorLevel>("type");
|
||||||
const axisOffset = ref(0);
|
const wheelStage = ref<HTMLElement | null>(null);
|
||||||
let axisPointerId: number | null = null;
|
const wheelRotation = ref(0);
|
||||||
let axisDragStartY = 0;
|
const wheelPointerId = ref<number | null>(null);
|
||||||
let axisDragStartOffset = 0;
|
let pointerLastAngle = 0;
|
||||||
let axisDragged = false;
|
let pointerLastTime = 0;
|
||||||
let axisLastY = 0;
|
let pointerVelocity = 0;
|
||||||
let axisLastTime = 0;
|
let pointerTravel = 0;
|
||||||
let axisVelocity = 0;
|
let inertiaFrame: number | null = null;
|
||||||
let axisInertiaFrame: number | null = null;
|
|
||||||
|
|
||||||
const typeOption = computed(() => entryTypes.find((item) => item.id === props.entryType)!);
|
|
||||||
const selectedCategory = computed(() => props.categoryPath.at(-1));
|
|
||||||
const selectedParent = computed(() => props.categoryPath[0]);
|
const selectedParent = computed(() => props.categoryPath[0]);
|
||||||
const selectorOptions = computed<(EntryTypeOption | Category)[]>(() => {
|
const selectorOptions = computed<SelectorOption[]>(() => {
|
||||||
if (selectorLevel.value === "type") {
|
if (selectorLevel.value === "type") return entryTypes;
|
||||||
const current = entryTypes.find((option) => option.id === props.entryType);
|
|
||||||
return [...entryTypes.filter((option) => option.id !== props.entryType), ...(current ? [current] : [])];
|
|
||||||
}
|
|
||||||
if (selectorLevel.value === "category") return categories[props.entryType];
|
if (selectorLevel.value === "category") return categories[props.entryType];
|
||||||
return selectedParent.value?.children ?? [];
|
return selectedParent.value?.children ?? [];
|
||||||
});
|
});
|
||||||
const upperSelectorOptions = computed(() => {
|
const segmentAngle = computed(() => 360 / Math.max(1, selectorOptions.value.length));
|
||||||
const count = Math.min(2, Math.floor(selectorOptions.value.length / 2));
|
const selectedIndex = computed(() => {
|
||||||
return selectorOptions.value.slice(0, count);
|
const selectedId =
|
||||||
|
selectorLevel.value === "type"
|
||||||
|
? props.entryType
|
||||||
|
: selectorLevel.value === "category"
|
||||||
|
? selectedParent.value?.id
|
||||||
|
: props.categoryPath[1]?.id;
|
||||||
|
return selectorOptions.value.findIndex((option) => option.id === selectedId);
|
||||||
});
|
});
|
||||||
const upperAxisHeight = computed(() => {
|
const levelLabel = computed(() => {
|
||||||
const count = upperSelectorOptions.value.length;
|
if (selectorLevel.value === "type") return "选择收支类型";
|
||||||
return count * 72 + Math.max(0, count - 1) * 10 + 34;
|
if (selectorLevel.value === "category") return "选择大类";
|
||||||
|
return `选择${selectedParent.value?.label ?? ""}细类`;
|
||||||
});
|
});
|
||||||
const axisBaseOffset = computed(() => upperSelectorOptions.value.length * 82);
|
const canGoBack = computed(() => selectorLevel.value !== "type");
|
||||||
const axisMaxOffset = computed(() =>
|
const ringBackground = computed(() => {
|
||||||
Math.max(0, (selectorOptions.value.length - upperSelectorOptions.value.length - 2) * 82),
|
const options = selectorOptions.value;
|
||||||
|
if (!options.length) return "transparent";
|
||||||
|
const step = segmentAngle.value;
|
||||||
|
const stops = options
|
||||||
|
.map((option, index) => `${option.color} ${index * step}deg ${(index + 1) * step}deg`)
|
||||||
|
.join(",");
|
||||||
|
return `conic-gradient(from ${-step / 2}deg,${stops})`;
|
||||||
|
});
|
||||||
|
const rotorStyle = computed(() => ({
|
||||||
|
"--wheel-rotation": `${wheelRotation.value}deg`,
|
||||||
|
"--ring-background": ringBackground.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
async (open) => {
|
||||||
|
stopInertia();
|
||||||
|
if (!open) return;
|
||||||
|
selectorLevel.value = "type";
|
||||||
|
await nextTick();
|
||||||
|
resetShellScroll();
|
||||||
|
alignSelectedOption();
|
||||||
|
},
|
||||||
);
|
);
|
||||||
const selectorPath = computed<(EntryTypeOption | Category)[]>(() => {
|
|
||||||
if (selectorLevel.value === "type") return [];
|
watch(selectorOptions, async () => {
|
||||||
if (selectorLevel.value === "category") return [typeOption.value];
|
await nextTick();
|
||||||
return selectedParent.value ? [typeOption.value, selectedParent.value] : [typeOption.value];
|
resetShellScroll();
|
||||||
|
alignSelectedOption();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => props.open, (open) => {
|
onBeforeUnmount(stopInertia);
|
||||||
if (open) resetAxisScroll();
|
|
||||||
else stopAxisInertia();
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(stopAxisInertia);
|
function optionStyle(index: number) {
|
||||||
|
return {
|
||||||
|
"--option-angle": `${index * segmentAngle.value}deg`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOptionSelected(option: SelectorOption) {
|
||||||
|
if (selectorLevel.value === "type") return option.id === props.entryType;
|
||||||
|
if (selectorLevel.value === "category") return option.id === selectedParent.value?.id;
|
||||||
|
return option.id === props.categoryPath[1]?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseOption(option: SelectorOption) {
|
||||||
|
if (pointerTravel > 5) return;
|
||||||
|
|
||||||
function chooseSelectorOption(option: EntryTypeOption | Category) {
|
|
||||||
if (selectorLevel.value === "type") {
|
if (selectorLevel.value === "type") {
|
||||||
const nextType = option.id as EntryType;
|
const nextType = option.id as EntryType;
|
||||||
if (nextType !== props.entryType) emit("update:categoryPath", []);
|
if (nextType !== props.entryType) emit("update:categoryPath", []);
|
||||||
emit("update:entryType", nextType);
|
emit("update:entryType", nextType);
|
||||||
selectorLevel.value = "category";
|
selectorLevel.value = "category";
|
||||||
resetAxisScroll();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,12 +118,8 @@ function chooseSelectorOption(option: EntryTypeOption | Category) {
|
||||||
if (selectorLevel.value === "category") {
|
if (selectorLevel.value === "category") {
|
||||||
const currentChild = selectedParent.value?.id === category.id ? props.categoryPath[1] : undefined;
|
const currentChild = selectedParent.value?.id === category.id ? props.categoryPath[1] : undefined;
|
||||||
emit("update:categoryPath", currentChild ? [category, currentChild] : [category]);
|
emit("update:categoryPath", currentChild ? [category, currentChild] : [category]);
|
||||||
if (category.children?.length) {
|
if (category.children?.length) selectorLevel.value = "subcategory";
|
||||||
selectorLevel.value = "subcategory";
|
else emit("confirm");
|
||||||
resetAxisScroll();
|
|
||||||
} else {
|
|
||||||
emit("confirm");
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,184 +128,158 @@ function chooseSelectorOption(option: EntryTypeOption | Category) {
|
||||||
emit("confirm");
|
emit("confirm");
|
||||||
}
|
}
|
||||||
|
|
||||||
function choosePathLevel(index: number) {
|
function goBack() {
|
||||||
selectorLevel.value = index === 0 ? "type" : "category";
|
if (selectorLevel.value === "subcategory") {
|
||||||
resetAxisScroll();
|
selectorLevel.value = "category";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectorLevel.value === "category") selectorLevel.value = "type";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSelectorOptionSelected(option: EntryTypeOption | Category) {
|
function confirmSelection() {
|
||||||
if (selectorLevel.value === "type") return option.id === props.entryType;
|
if (selectorLevel.value === "type") {
|
||||||
if (selectorLevel.value === "category") return option.id === selectedParent.value?.id;
|
selectorLevel.value = "category";
|
||||||
return props.categoryPath.length > 1 && option.id === selectedCategory.value?.id;
|
return;
|
||||||
|
}
|
||||||
|
if (selectedParent.value) emit("confirm");
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetAxisScroll() {
|
function alignSelectedOption() {
|
||||||
stopAxisInertia();
|
const index = Math.max(0, selectedIndex.value);
|
||||||
axisOffset.value = 0;
|
wheelRotation.value = FOCUS_ANGLE - index * segmentAngle.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampAxisOffset(value: number) {
|
function pointerAngle(event: PointerEvent) {
|
||||||
return Math.max(0, Math.min(axisMaxOffset.value, value));
|
const bounds = wheelStage.value?.getBoundingClientRect();
|
||||||
|
if (!bounds) return 0;
|
||||||
|
const centerX = bounds.left + bounds.width / 2;
|
||||||
|
const centerY = bounds.top + bounds.height / 2;
|
||||||
|
return (Math.atan2(event.clientY - centerY, event.clientX - centerX) * 180) / Math.PI;
|
||||||
}
|
}
|
||||||
|
|
||||||
function startAxisDrag(event: PointerEvent) {
|
function startWheelDrag(event: PointerEvent) {
|
||||||
stopAxisInertia();
|
stopInertia();
|
||||||
axisPointerId = event.pointerId;
|
wheelPointerId.value = event.pointerId;
|
||||||
axisDragStartY = event.clientY;
|
pointerLastAngle = pointerAngle(event);
|
||||||
axisDragStartOffset = axisOffset.value;
|
pointerLastTime = performance.now();
|
||||||
axisDragged = false;
|
pointerVelocity = 0;
|
||||||
axisLastY = event.clientY;
|
pointerTravel = 0;
|
||||||
axisLastTime = performance.now();
|
|
||||||
axisVelocity = 0;
|
|
||||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveAxisDrag(event: PointerEvent) {
|
function moveWheelDrag(event: PointerEvent) {
|
||||||
if (axisPointerId !== event.pointerId) return;
|
if (wheelPointerId.value !== event.pointerId) return;
|
||||||
const distance = axisDragStartY - event.clientY;
|
const angle = pointerAngle(event);
|
||||||
if (Math.abs(distance) > 5) axisDragged = true;
|
const delta = normalizeAngle(angle - pointerLastAngle);
|
||||||
axisOffset.value = clampAxisOffset(axisDragStartOffset + distance);
|
|
||||||
|
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
const elapsed = Math.max(1, now - axisLastTime);
|
const elapsed = Math.max(1, now - pointerLastTime);
|
||||||
const instantaneousVelocity = (axisLastY - event.clientY) / elapsed;
|
wheelRotation.value += delta;
|
||||||
axisVelocity = axisVelocity * 0.65 + instantaneousVelocity * 0.35;
|
pointerTravel += Math.abs(delta);
|
||||||
axisLastY = event.clientY;
|
if (pointerTravel > 5 && wheelStage.value && !wheelStage.value.hasPointerCapture(event.pointerId)) {
|
||||||
axisLastTime = now;
|
wheelStage.value.setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
pointerVelocity = pointerVelocity * 0.62 + (delta / elapsed) * 0.38;
|
||||||
|
pointerLastAngle = angle;
|
||||||
|
pointerLastTime = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
function endAxisDrag(event: PointerEvent) {
|
function endWheelDrag(event: PointerEvent) {
|
||||||
if (axisPointerId !== event.pointerId) return;
|
if (wheelPointerId.value !== event.pointerId) return;
|
||||||
axisPointerId = null;
|
wheelPointerId.value = null;
|
||||||
const target = event.currentTarget as HTMLElement;
|
if (wheelStage.value?.hasPointerCapture(event.pointerId)) {
|
||||||
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
|
wheelStage.value.releasePointerCapture(event.pointerId);
|
||||||
if (axisDragged) startAxisInertia();
|
}
|
||||||
|
if (pointerTravel > 5 && Math.abs(pointerVelocity) > 0.015) startInertia();
|
||||||
|
else snapWheel();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAxisClick(event: MouseEvent) {
|
function cancelOptionClick(event: MouseEvent) {
|
||||||
if (!axisDragged) return;
|
if (pointerTravel <= 5) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
axisDragged = false;
|
pointerTravel = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollAxisBy(delta: number) {
|
function startInertia() {
|
||||||
stopAxisInertia();
|
|
||||||
axisOffset.value = clampAxisOffset(axisOffset.value + delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
function startAxisInertia() {
|
|
||||||
if (Math.abs(axisVelocity) < 0.04) return;
|
|
||||||
let previousTime = performance.now();
|
let previousTime = performance.now();
|
||||||
|
|
||||||
const step = (now: number) => {
|
const step = (now: number) => {
|
||||||
const elapsed = Math.min(32, now - previousTime);
|
const elapsed = Math.min(32, now - previousTime);
|
||||||
previousTime = now;
|
previousTime = now;
|
||||||
const current = axisOffset.value;
|
wheelRotation.value += pointerVelocity * elapsed;
|
||||||
const next = clampAxisOffset(current + axisVelocity * elapsed);
|
pointerVelocity *= Math.pow(0.92, elapsed / 16);
|
||||||
axisOffset.value = next;
|
if (Math.abs(pointerVelocity) < 0.012) {
|
||||||
|
inertiaFrame = null;
|
||||||
if (next === current && (next === 0 || next === axisMaxOffset.value)) {
|
snapWheel();
|
||||||
stopAxisInertia();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
inertiaFrame = requestAnimationFrame(step);
|
||||||
axisVelocity *= Math.pow(0.91, elapsed / 16);
|
|
||||||
if (Math.abs(axisVelocity) < 0.025) {
|
|
||||||
stopAxisInertia();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
axisInertiaFrame = requestAnimationFrame(step);
|
|
||||||
};
|
};
|
||||||
|
inertiaFrame = requestAnimationFrame(step);
|
||||||
axisInertiaFrame = requestAnimationFrame(step);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopAxisInertia() {
|
function snapWheel() {
|
||||||
if (axisInertiaFrame !== null) cancelAnimationFrame(axisInertiaFrame);
|
const step = segmentAngle.value;
|
||||||
axisInertiaFrame = null;
|
const target = Math.round((wheelRotation.value - FOCUS_ANGLE) / step) * step + FOCUS_ANGLE;
|
||||||
axisVelocity = 0;
|
wheelRotation.value = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopInertia() {
|
||||||
|
if (inertiaFrame !== null) cancelAnimationFrame(inertiaFrame);
|
||||||
|
inertiaFrame = null;
|
||||||
|
pointerVelocity = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAngle(value: number) {
|
||||||
|
return ((value + 180) % 360 + 360) % 360 - 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetShellScroll() {
|
||||||
|
const shell = wheelStage.value?.closest<HTMLElement>(".app-shell");
|
||||||
|
if (shell) shell.scrollLeft = 0;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Transition name="coordinate-selector">
|
<Transition name="coordinate-selector">
|
||||||
<div v-if="open" class="category-coordinate" aria-label="二维分类选择器" @click.stop>
|
<div v-if="open" class="category-wheel-selector" :aria-label="levelLabel" @click.stop>
|
||||||
<div class="selector-path" aria-label="已选分类路径">
|
<span class="wheel-level-label">{{ levelLabel }}</span>
|
||||||
<template v-for="(option, index) in selectorPath" :key="option.id">
|
|
||||||
<ChevronRight v-if="index > 0" :size="18" aria-hidden="true" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
:style="{ '--path-color': option.color }"
|
|
||||||
:aria-label="`返回${option.label}层重新选择`"
|
|
||||||
@click="choosePathLevel(index)"
|
|
||||||
>
|
|
||||||
<component :is="option.icon" :size="22" />
|
|
||||||
<small>{{ option.label }}</small>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
<ChevronRight v-if="selectorPath.length" :size="18" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="upperSelectorOptions.length"
|
ref="wheelStage"
|
||||||
class="candidate-above-axis"
|
class="category-wheel-stage"
|
||||||
:style="{ height: `${upperAxisHeight}px` }"
|
:class="{ dragging: wheelPointerId !== null }"
|
||||||
|
@pointerdown="startWheelDrag"
|
||||||
|
@pointermove="moveWheelDrag"
|
||||||
|
@pointerup="endWheelDrag"
|
||||||
|
@pointercancel="endWheelDrag"
|
||||||
|
@click.capture="cancelOptionClick"
|
||||||
>
|
>
|
||||||
<div
|
<div class="category-wheel-rotor" :style="rotorStyle">
|
||||||
class="candidate-axis-viewport"
|
<div class="category-wheel-disc"></div>
|
||||||
@pointerdown="startAxisDrag"
|
<button
|
||||||
@pointermove="moveAxisDrag"
|
v-for="(option, index) in selectorOptions"
|
||||||
@pointerup="endAxisDrag"
|
:key="option.id"
|
||||||
@pointercancel="endAxisDrag"
|
type="button"
|
||||||
@click.capture="handleAxisClick"
|
class="wheel-option"
|
||||||
@wheel.prevent="scrollAxisBy($event.deltaY)"
|
:class="{ selected: isOptionSelected(option) }"
|
||||||
>
|
:style="optionStyle(index)"
|
||||||
<div class="candidate-axis-list" :style="{ transform: `translate3d(0, -${axisOffset}px, 0)` }">
|
:aria-label="option.label"
|
||||||
<button
|
@click="chooseOption(option)"
|
||||||
v-for="option in selectorOptions"
|
>
|
||||||
:key="option.id"
|
<span>{{ option.label }}</span>
|
||||||
type="button"
|
<component :is="option.icon" :size="21" />
|
||||||
class="coordinate-option"
|
</button>
|
||||||
:class="{ selected: isSelectorOptionSelected(option) }"
|
|
||||||
:style="{ '--option-color': option.color, '--option-tint': option.tint }"
|
|
||||||
@click="chooseSelectorOption(option)"
|
|
||||||
>
|
|
||||||
<span class="coordinate-icon"><component :is="option.icon" :size="24" /></span>
|
|
||||||
<span>{{ option.label }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="candidate-below-axis">
|
<div class="wheel-center-controls">
|
||||||
<div
|
<button type="button" class="wheel-back" :disabled="!canGoBack" aria-label="返回上一级" @click="goBack">
|
||||||
class="candidate-axis-viewport"
|
<ArrowLeft :size="16" />
|
||||||
@pointerdown="startAxisDrag"
|
<span>返回</span>
|
||||||
@pointermove="moveAxisDrag"
|
</button>
|
||||||
@pointerup="endAxisDrag"
|
<button type="button" class="wheel-confirm" aria-label="确认当前分类" @click="confirmSelection">
|
||||||
@pointercancel="endAxisDrag"
|
<Check :size="17" />
|
||||||
@click.capture="handleAxisClick"
|
<span>确认</span>
|
||||||
@wheel.prevent="scrollAxisBy($event.deltaY)"
|
</button>
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="candidate-axis-list"
|
|
||||||
:style="{ transform: `translate3d(0, -${axisBaseOffset + axisOffset}px, 0)` }"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-for="option in selectorOptions"
|
|
||||||
:key="option.id"
|
|
||||||
type="button"
|
|
||||||
class="coordinate-option"
|
|
||||||
:class="{ selected: isSelectorOptionSelected(option) }"
|
|
||||||
:style="{ '--option-color': option.color, '--option-tint': option.tint }"
|
|
||||||
@click="chooseSelectorOption(option)"
|
|
||||||
>
|
|
||||||
<span class="coordinate-icon"><component :is="option.icon" :size="24" /></span>
|
|
||||||
<span>{{ option.label }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ input:focus-visible {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
overflow: clip;
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
background: #f7faf9;
|
background: #f7faf9;
|
||||||
box-shadow: 0 0 40px rgba(25, 53, 48, 0.14);
|
box-shadow: 0 0 40px rgba(25, 53, 48, 0.14);
|
||||||
|
|
@ -924,6 +925,8 @@ input:focus-visible {
|
||||||
background: color-mix(in srgb, var(--category-color) 13%, white);
|
background: color-mix(in srgb, var(--category-color) 13%, white);
|
||||||
box-shadow: 0 10px 24px rgba(29, 57, 50, 0.22);
|
box-shadow: 0 10px 24px rgba(29, 57, 50, 0.22);
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-category-button.active > * {
|
.meta-category-button.active > * {
|
||||||
|
|
@ -965,7 +968,10 @@ input:focus-visible {
|
||||||
-webkit-backdrop-filter: blur(7px);
|
-webkit-backdrop-filter: blur(7px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-coordinate {
|
.category-wheel-selector {
|
||||||
|
--wheel-size: clamp(286px, 88vw, 344px);
|
||||||
|
--center-size: clamp(122px, 35vw, 140px);
|
||||||
|
--option-radius: calc((var(--wheel-size) + var(--center-size)) / 4);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
|
|
@ -976,174 +982,149 @@ input:focus-visible {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selector-path {
|
.wheel-level-label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -2px;
|
width: 1px;
|
||||||
right: 100%;
|
height: 1px;
|
||||||
width: min(392px, calc(100vw - 88px));
|
|
||||||
height: 76px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 6px;
|
|
||||||
overflow: visible;
|
|
||||||
border: 0;
|
|
||||||
padding: 2px 6px;
|
|
||||||
background: transparent;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector-path > button {
|
|
||||||
width: 72px;
|
|
||||||
min-width: 72px;
|
|
||||||
height: 72px;
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 2px solid color-mix(in srgb, var(--path-color) 72%, #263b36);
|
clip: rect(0 0 0 0);
|
||||||
border-radius: 50%;
|
clip-path: inset(50%);
|
||||||
padding: 0;
|
|
||||||
background: var(--path-color);
|
|
||||||
color: #ffffff;
|
|
||||||
font-weight: 740;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
box-shadow: 0 9px 22px rgba(29, 57, 50, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.selector-path > button:active,
|
.category-wheel-stage {
|
||||||
.selector-path > button:focus-visible {
|
|
||||||
background: color-mix(in srgb, var(--path-color) 82%, #213832);
|
|
||||||
transform: translateY(1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector-path > button svg {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector-path small {
|
|
||||||
max-width: 62px;
|
|
||||||
overflow: hidden;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.1;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector-path > svg {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
color: #8ca09b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.candidate-above-axis,
|
|
||||||
.candidate-below-axis {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -24px;
|
top: 50%;
|
||||||
width: 120px;
|
left: 50%;
|
||||||
background: transparent;
|
width: var(--wheel-size);
|
||||||
pointer-events: auto;
|
height: var(--wheel-size);
|
||||||
}
|
transform: translate(-50%, -50%);
|
||||||
|
border-radius: 50%;
|
||||||
.candidate-above-axis {
|
|
||||||
bottom: 60px;
|
|
||||||
overflow: hidden;
|
|
||||||
mask-image: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
transparent 0,
|
|
||||||
#000 30px,
|
|
||||||
#000 calc(100% - 34px),
|
|
||||||
transparent calc(100% - 16px),
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
-webkit-mask-image: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
transparent 0,
|
|
||||||
#000 30px,
|
|
||||||
#000 calc(100% - 34px),
|
|
||||||
transparent calc(100% - 16px),
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.candidate-below-axis {
|
|
||||||
top: 70px;
|
|
||||||
height: 208px;
|
|
||||||
overflow: hidden;
|
|
||||||
mask-image: linear-gradient(to bottom, #000 0, #000 calc(100% - 30px), transparent 100%);
|
|
||||||
-webkit-mask-image: linear-gradient(to bottom, #000 0, #000 calc(100% - 30px), transparent 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.candidate-axis-viewport {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.candidate-axis-viewport:active {
|
.category-wheel-stage.dragging {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
.candidate-axis-list {
|
.category-wheel-rotor {
|
||||||
width: 120px;
|
position: absolute;
|
||||||
min-height: max-content;
|
inset: 0;
|
||||||
display: flex;
|
transform: rotate(var(--wheel-rotation));
|
||||||
flex-direction: column;
|
transform-origin: center;
|
||||||
gap: 10px;
|
will-change: transform;
|
||||||
padding: 10px 24px 240px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.coordinate-option {
|
.category-wheel-disc {
|
||||||
width: 72px;
|
position: absolute;
|
||||||
min-width: 72px;
|
inset: 0;
|
||||||
height: 72px;
|
border-radius: 50%;
|
||||||
min-height: 72px;
|
background: var(--ring-background);
|
||||||
|
box-shadow: 0 14px 34px rgba(29, 55, 48, 0.24);
|
||||||
|
-webkit-mask: radial-gradient(
|
||||||
|
circle,
|
||||||
|
transparent 0 calc(var(--center-size) / 2),
|
||||||
|
#000 calc(var(--center-size) / 2 + 1px)
|
||||||
|
);
|
||||||
|
mask: radial-gradient(
|
||||||
|
circle,
|
||||||
|
transparent 0 calc(var(--center-size) / 2),
|
||||||
|
#000 calc(var(--center-size) / 2 + 1px)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wheel-option {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 48px;
|
||||||
|
height: 54px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0;
|
gap: 3px;
|
||||||
border: 2px solid color-mix(in srgb, var(--option-color) 50%, white);
|
transform:
|
||||||
border-radius: 50%;
|
translate(-50%, -50%)
|
||||||
|
rotate(var(--option-angle))
|
||||||
|
translateY(calc(0px - var(--option-radius)));
|
||||||
|
border: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: color-mix(in srgb, var(--option-tint) 74%, white);
|
background: transparent;
|
||||||
color: var(--option-color);
|
color: #ffffff;
|
||||||
font-size: 13px;
|
font-size: 11px;
|
||||||
line-height: 1.05;
|
line-height: 1;
|
||||||
font-weight: 730;
|
font-weight: 760;
|
||||||
|
text-shadow: 0 1px 3px rgba(19, 35, 31, 0.3);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
box-shadow: 0 9px 22px rgba(29, 57, 50, 0.2);
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coordinate-option.selected {
|
.wheel-option span {
|
||||||
border-color: color-mix(in srgb, var(--option-color) 75%, #263b36);
|
max-width: 48px;
|
||||||
background: var(--option-color);
|
|
||||||
color: #ffffff;
|
|
||||||
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.78), 0 11px 25px rgba(29, 57, 50, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.coordinate-option.selected .coordinate-icon {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coordinate-icon {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
color: var(--option-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.coordinate-option > span:last-child {
|
|
||||||
max-width: 62px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wheel-option.selected::after {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -5px;
|
||||||
|
left: 50%;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 1px 3px rgba(19, 35, 31, 0.25);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.wheel-center-controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 2;
|
||||||
|
width: var(--center-size);
|
||||||
|
height: var(--center-size);
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr 1fr;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 8px 24px rgba(29, 55, 48, 0.22);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wheel-center-controls button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 740;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wheel-back {
|
||||||
|
border-bottom: 1px solid rgba(56, 72, 67, 0.14) !important;
|
||||||
|
background: #eef3f1;
|
||||||
|
color: #52635e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wheel-back:disabled {
|
||||||
|
color: #a6b0ad;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wheel-confirm {
|
||||||
|
background: #6f7f83;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
.share-sheet-layer {
|
.share-sheet-layer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue