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