This commit is contained in:
2026-08-05 19:01:52 +08:00
parent cef594cc86
commit 6a1b49c385
25 changed files with 1354 additions and 109 deletions
@@ -12,7 +12,6 @@ import {
ComboboxTrigger,
ComboboxViewport,
} from 'reka-ui'
import selectArrow from '../../../../../design/src-web/shared/img/sprite_interface.NPK/windowcommon_2x.img/0.PNG'
import { cn } from '@/lib/utils'
export interface UiAutocompleteOption {
@@ -56,12 +55,17 @@ function displayValue(value: string | undefined) {
:display-value="displayValue"
:placeholder="placeholder"
/>
<ComboboxTrigger class="ui-autocomplete__trigger" aria-label="展开候选项">
<img :src="selectArrow" alt="" />
<ComboboxTrigger class="ui-autocomplete__trigger ui-select-indicator" aria-label="展开候选项">
<span class="ui-select-indicator__glyph" />
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxPortal>
<ComboboxContent class="ui-autocomplete__content" position="popper" :side-offset="2">
<ComboboxContent
class="ui-autocomplete__content"
position="popper"
:side-offset="2"
:collision-padding="8"
>
<ComboboxViewport class="ui-autocomplete__viewport">
<ComboboxEmpty class="ui-autocomplete__empty">{{ emptyText }}</ComboboxEmpty>
<ComboboxItem
@@ -82,6 +86,10 @@ function displayValue(value: string | undefined) {
</template>
<style lang="scss">
[data-reka-popper-content-wrapper]:has(> .ui-autocomplete__content) {
z-index: 80 !important;
}
.ui-autocomplete {
display: block;
min-width: 0;
@@ -127,38 +135,18 @@ function displayValue(value: string | undefined) {
}
.ui-autocomplete__trigger {
display: inline-flex;
width: var(--ui-select-icon-inline-size);
height: var(--ui-select-icon-block-size);
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid var(--ui-control-border);
outline: none;
background: var(--ui-control-background);
box-shadow: 0 0 0 1px var(--ui-select-icon-shadow);
cursor: pointer;
&:hover,
&:focus-visible {
border-color: var(--ui-control-border-active);
background: var(--ui-control-background-hover);
}
&:active img {
transform: translateY(1px);
}
img {
width: var(--ui-select-glyph-inline-size);
height: var(--ui-select-glyph-block-size);
image-rendering: pixelated;
}
}
.ui-autocomplete__content {
position: relative;
z-index: 80;
min-width: var(--reka-combobox-trigger-width);
box-sizing: border-box;
width: min(var(--reka-combobox-trigger-width), calc(100vw - 16px));
min-width: min(var(--reka-combobox-trigger-width), calc(100vw - 16px));
max-width: calc(100vw - 16px);
max-height: min(
var(--ui-autocomplete-max-block-size),
var(--reka-combobox-content-available-height)
@@ -0,0 +1,257 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui'
import { cn } from '@/lib/utils'
export interface UiCascaderItem {
value: string
label: string
disabled?: boolean
}
export interface UiCascaderGroup extends UiCascaderItem {
children: UiCascaderItem[]
}
const props = withDefaults(
defineProps<{
class?: HTMLAttributes['class']
options: UiCascaderGroup[]
label?: string
placeholder?: string
disabled?: boolean
}>(),
{ label: '级联筛选', placeholder: '请选择' },
)
const model = defineModel<string[]>({ default: () => [] })
const open = ref(false)
const activeGroupValue = ref<string>()
const selectedGroup = computed(
() => props.options.find((group) => group.value === model.value[0]) ?? props.options[0],
)
const activeGroup = computed(
() =>
props.options.find((group) => group.value === activeGroupValue.value) ?? selectedGroup.value,
)
const selectedChild = computed(() =>
selectedGroup.value?.children.find((child) => child.value === model.value[1]),
)
function chooseGroup(group: UiCascaderGroup) {
if (group.disabled) return
activeGroupValue.value = group.value
}
function chooseChild(child: UiCascaderItem) {
if (child.disabled || !activeGroup.value) return
model.value = [activeGroup.value.value, child.value]
open.value = false
}
</script>
<template>
<PopoverRoot v-model:open="open">
<PopoverTrigger as-child>
<button
type="button"
:class="cn('ui-cascader__trigger', props.class)"
:disabled="disabled"
:aria-label="label"
>
<span>{{ selectedGroup?.label ?? placeholder }}</span>
<span>{{ selectedChild?.label ?? selectedGroup?.children[0]?.label ?? placeholder }}</span>
</button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
class="ui-cascader__content"
position="popper"
:side-offset="2"
:collision-padding="8"
>
<div class="ui-cascader__column" role="listbox" aria-label="一级分类">
<button
v-for="group in options"
:key="group.value"
type="button"
role="option"
class="ui-cascader__option"
:class="{ 'is-active': activeGroup?.value === group.value }"
:aria-selected="activeGroup?.value === group.value"
:disabled="group.disabled"
@click="chooseGroup(group)"
>
{{ group.label }}<span aria-hidden="true">›</span>
</button>
</div>
<div class="ui-cascader__column" role="listbox" aria-label="二级分类">
<button
v-for="child in activeGroup?.children ?? []"
:key="child.value"
type="button"
role="option"
class="ui-cascader__option"
:class="{
'is-active':
activeGroup?.value === selectedGroup?.value && child.value === selectedChild?.value,
}"
:aria-selected="
activeGroup?.value === selectedGroup?.value && child.value === selectedChild?.value
"
:disabled="child.disabled"
@click="chooseChild(child)"
>
{{ child.label }}
</button>
</div>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>
<style lang="scss">
[data-reka-popper-content-wrapper]:has(> .ui-cascader__content) {
z-index: 80 !important;
}
.ui-cascader__trigger {
appearance: none;
box-sizing: border-box;
display: grid;
width: 100%;
min-width: 0;
height: var(--ui-control-height-default);
grid-template-columns: minmax(0, var(--ui-cascader-primary-ratio)) minmax(0, 1fr);
padding: 0;
border: 0;
border-bottom: 1px solid var(--ui-cascader-underline);
outline: none;
background: var(--ui-cascader-shell-background);
cursor: pointer;
span {
position: relative;
display: flex;
min-width: 0;
height: calc(var(--ui-control-height-default) - 1px);
align-items: center;
padding: 0 22px 0 10px;
overflow: hidden;
border: 1px solid var(--ui-cascader-trigger-border);
border-bottom: 0;
background: var(--ui-cascader-trigger-background);
color: var(--ui-cascader-trigger-foreground);
text-overflow: ellipsis;
white-space: nowrap;
&::after {
content: '';
position: absolute;
top: 50%;
right: 9px;
border-top: 5px solid var(--ui-cascader-trigger-foreground);
border-right: 4px solid transparent;
border-left: 4px solid transparent;
filter: drop-shadow(0 1px 0 var(--ui-popup-shadow));
transform: translateY(-50%);
transition: transform 90ms ease-out;
}
+ span {
border-left: 0;
}
}
&:hover:not(:disabled) span,
&:focus-visible span,
&[data-state='open'] span {
border-color: var(--ui-cascader-trigger-hover-border);
background: var(--ui-cascader-trigger-hover-background);
color: var(--ui-cascader-trigger-hover-foreground);
box-shadow: inset 0 1px 0 var(--ui-cascader-trigger-highlight);
}
&[data-state='open'] span::after {
transform: translateY(-50%) rotate(180deg);
}
&:disabled {
cursor: not-allowed;
opacity: 0.48;
}
}
.ui-cascader__content {
position: relative;
z-index: 80;
box-sizing: border-box;
display: grid;
width: min(var(--ui-cascader-popup-inline-size), calc(100vw - 16px));
max-height: min(var(--ui-cascader-popup-max-block-size), var(--reka-popper-available-height));
grid-template-columns: repeat(2, minmax(0, 1fr));
overflow: hidden;
border: 1px solid var(--ui-cascader-popup-border);
outline: none;
background: var(--ui-tree-background);
box-shadow: 0 8px 16px var(--ui-popup-shadow);
}
.ui-cascader__column {
min-width: 0;
overflow-y: auto;
+ .ui-cascader__column {
border-left: 1px solid var(--ui-option-divider);
}
}
.ui-cascader__option {
appearance: none;
display: flex;
width: 100%;
height: var(--ui-cascader-option-block-size);
align-items: center;
justify-content: space-between;
padding: 0 7px;
border: 0;
outline: none;
background: var(--ui-tree-background);
color: var(--ui-primary-foreground);
text-align: left;
cursor: pointer;
&:hover:not(:disabled),
&:focus-visible,
&.is-active {
background: var(--ui-cascader-option-highlight);
box-shadow: inset 2px 0 0 var(--ui-ring);
color: var(--ui-option-highlight-foreground);
}
&:disabled {
cursor: not-allowed;
opacity: 0.45;
}
}
@media (max-width: 420px) {
.ui-cascader__trigger {
--ui-cascader-primary-ratio: 44%;
}
.ui-cascader__content {
grid-template-columns: 1fr;
}
.ui-cascader__column {
max-height: 132px;
+ .ui-cascader__column {
border-top: 1px solid var(--ui-option-divider);
border-left: 0;
}
}
}
</style>
@@ -0,0 +1 @@
export { default as UiCascader, type UiCascaderGroup, type UiCascaderItem } from './UiCascader.vue'
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { CSSProperties, HTMLAttributes } from 'vue'
import {
DialogContent,
type DialogContentEmits,
@@ -8,23 +8,108 @@ import {
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
const props = withDefaults(
defineProps<
DialogContentProps & {
class?: HTMLAttributes['class']
draggable?: boolean
}
>(),
{ draggable: true },
)
const emits = defineEmits<DialogContentEmits>()
const dragOffset = ref({ x: 0, y: 0 })
let activePointerId: number | undefined
let dragOrigin = { clientX: 0, clientY: 0, x: 0, y: 0 }
let dragBounds = { minX: 0, maxX: 0, minY: 0, maxY: 0 }
const delegatedProps = computed(() => {
const { class: _class, ...delegated } = props
const { class: _class, draggable: _draggable, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const dragStyle = computed<CSSProperties>(() => ({
'--ui-dialog-drag-x': `${dragOffset.value.x}px`,
'--ui-dialog-drag-y': `${dragOffset.value.y}px`,
}))
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function stopDragging() {
activePointerId = undefined
document.documentElement.classList.remove('is-dialog-dragging')
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', stopDragging)
window.removeEventListener('pointercancel', stopDragging)
}
function resetPosition() {
stopDragging()
dragOffset.value = { x: 0, y: 0 }
}
function handlePointerMove(event: PointerEvent) {
if (activePointerId !== event.pointerId) return
const deltaX = event.clientX - dragOrigin.clientX
const deltaY = event.clientY - dragOrigin.clientY
dragOffset.value = {
x: dragOrigin.x + clamp(deltaX, dragBounds.minX, dragBounds.maxX),
y: dragOrigin.y + clamp(deltaY, dragBounds.minY, dragBounds.maxY),
}
}
function handlePointerDown(event: PointerEvent) {
if (!props.draggable || event.button !== 0) return
const target = event.target
if (!(target instanceof Element)) return
const handle = target.closest<HTMLElement>('[data-ui-dialog-drag-handle]')
if (!handle || target.closest('button, a, input, textarea, select, [data-ui-dialog-no-drag]'))
return
const content = event.currentTarget
if (!(content instanceof HTMLElement)) return
const rect = content.getBoundingClientRect()
const viewportMargin = 8
activePointerId = event.pointerId
dragOrigin = {
clientX: event.clientX,
clientY: event.clientY,
x: dragOffset.value.x,
y: dragOffset.value.y,
}
dragBounds = {
minX: viewportMargin - rect.left,
maxX: window.innerWidth - viewportMargin - rect.right,
minY: viewportMargin - rect.top,
maxY: window.innerHeight - viewportMargin - rect.bottom,
}
document.documentElement.classList.add('is-dialog-dragging')
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', stopDragging)
window.addEventListener('pointercancel', stopDragging)
event.preventDefault()
}
onMounted(() => window.addEventListener('resize', resetPosition))
onBeforeUnmount(() => {
stopDragging()
window.removeEventListener('resize', resetPosition)
})
</script>
<template>
<DialogContent
v-bind="forwarded"
:style="dragStyle"
:data-draggable="draggable ? '' : undefined"
@pointerdown="handlePointerDown"
@open-auto-focus="resetPosition"
:class="
cn(
'ui-dialog-content fixed left-1/2 top-1/2 z-50 grid -translate-x-1/2 -translate-y-1/2 outline-none data-[state=closed]:animate-[dialog-out_120ms_ease-in] data-[state=open]:animate-[dialog-in_160ms_ease-out]',
'ui-dialog-content fixed left-1/2 top-1/2 z-50 grid outline-none data-[state=closed]:animate-[dialog-out_120ms_ease-in] data-[state=open]:animate-[dialog-in_160ms_ease-out]',
props.class,
)
"
@@ -36,6 +121,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<style scoped>
.ui-dialog-content {
width: min(var(--ui-dialog-viewport-inline-size), var(--ui-dialog-max-inline-size));
max-height: calc(100dvh - 16px);
overflow: hidden;
border: 1px solid var(--ui-dialog-border);
border-radius: 0;
@@ -44,5 +130,16 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
0 18px 38px var(--ui-dialog-shadow),
inset 0 0 0 1px var(--ui-dialog-inset);
color: var(--ui-surface-foreground);
transform: translate(
calc(-50% + var(--ui-dialog-drag-x, 0px)),
calc(-50% + var(--ui-dialog-drag-y, 0px))
);
will-change: transform;
}
:global(.is-dialog-dragging),
:global(.is-dialog-dragging *) {
cursor: grabbing !important;
user-select: none !important;
}
</style>
@@ -28,7 +28,11 @@ onBeforeUnmount(() => clearInterval(timer))
</script>
<template>
<div class="ui-dialog-header" :class="{ 'is-focused': focused, 'is-unfocused': !focused }">
<div
class="ui-dialog-header"
:class="{ 'is-focused': focused, 'is-unfocused': !focused }"
data-ui-dialog-drag-handle
>
<img
v-if="focused && effectFrames.length"
class="ui-dialog-header__effect"
@@ -60,6 +64,9 @@ onBeforeUnmount(() => clearInterval(timer))
background: var(--ui-dialog-titlebar-background);
color: var(--ui-titlebar-foreground);
text-shadow: 0 1px 1px var(--ui-dialog-titlebar-text-shadow);
touch-action: none;
user-select: none;
cursor: grab;
}
.ui-dialog-header.is-focused {
@@ -87,6 +94,10 @@ onBeforeUnmount(() => clearInterval(timer))
position: relative;
z-index: 2;
grid-column: 2;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-align: center;
}
.ui-dialog-header__actions {
@@ -18,9 +18,15 @@ const delegatedProps = computed(() => {
<style scoped>
.ui-dialog-title {
display: block;
min-width: 0;
overflow: hidden;
color: var(--ui-titlebar-foreground);
font-size: var(--ui-control-font-size-default);
font-weight: 400;
line-height: var(--ui-control-line-height);
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -0,0 +1,118 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { PaginationNext, PaginationPrev, PaginationRoot } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<{
class?: HTMLAttributes['class']
total: number
itemsPerPage?: number
disabled?: boolean
label?: string
resultLabel?: string
showResult?: boolean
}>(),
{
itemsPerPage: 10,
label: '分页',
resultLabel: '搜索结果',
showResult: true,
},
)
const page = defineModel<number>('page', { default: 1 })
</script>
<template>
<PaginationRoot
v-slot="{ page: currentPage, pageCount }"
v-model:page="page"
:total="total"
:items-per-page="itemsPerPage"
:disabled="disabled"
:class="cn('ui-pagination', props.class)"
:aria-label="label"
>
<PaginationPrev class="ui-pagination__button" aria-label="上一页">◀</PaginationPrev>
<output class="ui-pagination__status" aria-live="polite">
<strong>{{ currentPage }}</strong
><span>/</span><span>{{ pageCount }}</span>
</output>
<PaginationNext class="ui-pagination__button" aria-label="下一页">▶</PaginationNext>
<span v-if="showResult" class="ui-pagination__result">{{ resultLabel }} {{ total }} 个</span>
</PaginationRoot>
</template>
<style scoped>
.ui-pagination {
display: flex;
max-width: 100%;
min-height: var(--ui-pagination-block-size);
align-items: center;
gap: var(--ui-pagination-gap);
color: var(--ui-primary-foreground);
}
.ui-pagination__button {
appearance: none;
display: inline-flex;
width: var(--ui-pagination-button-inline-size);
height: var(--ui-pagination-button-block-size);
flex: 0 0 auto;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid var(--ui-pagination-border);
outline: none;
background: var(--ui-pagination-background);
color: var(--ui-control-disabled-foreground);
cursor: pointer;
}
.ui-pagination__button:not(:disabled):hover,
.ui-pagination__button:not(:disabled):focus-visible {
border-color: var(--ui-control-border-active);
background: var(--ui-pagination-active-background);
color: var(--ui-pagination-active-foreground);
}
.ui-pagination__button:not(:disabled):active {
transform: translateY(1px);
}
.ui-pagination__button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.ui-pagination__status {
display: inline-flex;
min-width: 46px;
align-items: baseline;
justify-content: center;
gap: 4px;
white-space: nowrap;
}
.ui-pagination__status strong {
color: var(--ui-selected);
font-weight: 400;
}
.ui-pagination__result {
min-width: 0;
color: var(--ui-muted-foreground);
white-space: nowrap;
}
@media (max-width: 420px) {
.ui-pagination {
flex-wrap: wrap;
}
.ui-pagination__result {
width: 100%;
}
}
</style>
@@ -0,0 +1 @@
export { default as UiPagination } from './UiPagination.vue'
@@ -0,0 +1,94 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import {
PopoverContent,
type PopoverContentEmits,
type PopoverContentProps,
PopoverPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<
PopoverContentProps & {
class?: HTMLAttributes['class']
variant?: 'default' | 'rich'
}
>(),
{ collisionPadding: 8, sideOffset: 6, variant: 'default' },
)
const emits = defineEmits<PopoverContentEmits>()
const delegatedProps = computed(() => {
const { class: _class, variant: _variant, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<PopoverPortal>
<PopoverContent
v-bind="forwarded"
:class="cn('ui-popover-content', `ui-popover-content--${variant}`, props.class)"
>
<slot />
</PopoverContent>
</PopoverPortal>
</template>
<style>
[data-reka-popper-content-wrapper]:has(> .ui-popover-content) {
z-index: 80 !important;
}
.ui-popover-content {
position: relative;
z-index: 80;
box-sizing: border-box;
width: min(var(--ui-popover-inline-size), var(--reka-popper-available-width));
max-width: calc(100vw - 16px);
padding: var(--ui-popover-padding);
border: 1px solid var(--ui-popover-border);
border-radius: 0;
outline: none;
background: var(--ui-popover-background);
box-shadow: 0 8px 18px var(--ui-popover-shadow);
color: var(--ui-popover-foreground);
transform-origin: var(--reka-popover-content-transform-origin);
}
.ui-popover-content--rich {
--ui-popover-inline-size: var(--ui-popover-rich-inline-size);
line-height: 1.45;
}
.ui-popover-content[data-state='open'] {
animation: ui-popover-in 100ms ease-out;
}
.ui-popover-content[data-state='closed'] {
animation: ui-popover-out 80ms ease-in;
}
@keyframes ui-popover-in {
from {
opacity: 0;
transform: translateY(2px);
}
}
@keyframes ui-popover-out {
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.ui-popover-content {
animation: none !important;
}
}
</style>
@@ -0,0 +1,2 @@
export { PopoverAnchor, PopoverClose, PopoverRoot, PopoverTrigger } from 'reka-ui'
export { default as UiPopoverContent } from './UiPopoverContent.vue'
@@ -2,7 +2,6 @@
import type { HTMLAttributes } from 'vue'
import { SelectIcon, SelectTrigger, type SelectTriggerProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import selectArrow from '../../../../../design/src-web/shared/img/sprite_interface.NPK/windowcommon_2x.img/0.PNG'
const props = defineProps<SelectTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
@@ -14,9 +13,9 @@ const delegatedProps = computed(() => {
<template>
<SelectTrigger v-bind="delegatedProps" :class="cn('ui-select-trigger', props.class)">
<slot />
<SelectIcon class="ui-select-trigger__icon" aria-hidden="true"
><img :src="selectArrow" alt=""
/></SelectIcon>
<SelectIcon class="ui-select-indicator" aria-hidden="true">
<span class="ui-select-indicator__glyph" />
</SelectIcon>
</SelectTrigger>
</template>
@@ -54,32 +53,4 @@ const delegatedProps = computed(() => {
cursor: not-allowed;
opacity: 0.48;
}
.ui-select-trigger__icon {
display: inline-flex;
width: var(--ui-select-icon-inline-size);
height: var(--ui-select-icon-block-size);
align-items: center;
justify-content: center;
border: 1px solid var(--ui-control-border);
background: var(--ui-control-background);
box-shadow: 0 0 0 1px var(--ui-select-icon-shadow);
}
.ui-select-trigger__icon img {
width: var(--ui-select-glyph-inline-size);
height: var(--ui-select-glyph-block-size);
image-rendering: pixelated;
}
.ui-select-trigger:hover .ui-select-trigger__icon,
.ui-select-trigger:focus-visible .ui-select-trigger__icon {
border-color: var(--ui-control-border-active);
background: var(--ui-control-background-hover);
}
.ui-select-trigger:active .ui-select-trigger__icon,
.ui-select-trigger:active .ui-select-trigger__icon img {
transform: translateY(1px);
}
</style>
@@ -10,12 +10,17 @@ import {
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(),
{ collisionPadding: 8, sideOffset: 6 },
defineProps<
TooltipContentProps & {
class?: HTMLAttributes['class']
variant?: 'simple' | 'rich'
}
>(),
{ collisionPadding: 8, sideOffset: 6, variant: 'simple' },
)
const emits = defineEmits<TooltipContentEmits>()
const delegatedProps = computed(() => {
const { class: _class, ...delegated } = props
const { class: _class, variant: _variant, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
@@ -23,7 +28,10 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<template>
<TooltipPortal>
<TooltipContent v-bind="forwarded" :class="cn('ui-tooltip-content', props.class)">
<TooltipContent
v-bind="forwarded"
:class="cn('ui-tooltip-content', `ui-tooltip-content--${variant}`, props.class)"
>
<slot />
</TooltipContent>
</TooltipPortal>
@@ -49,6 +57,19 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
transform-origin: var(--reka-tooltip-content-transform-origin);
}
.ui-tooltip-content--rich {
box-sizing: border-box;
width: min(var(--ui-tooltip-rich-inline-size), calc(100vw - 16px));
max-width: calc(100vw - 16px);
padding: 9px 10px;
line-height: 1.45;
}
.ui-tooltip-content--rich strong {
color: var(--ui-tooltip-strong);
font-weight: 400;
}
.ui-tooltip-content[data-state='delayed-open'],
.ui-tooltip-content[data-state='instant-open'] {
animation: tooltip-in 100ms ease-out;
@@ -0,0 +1,350 @@
<script setup lang="ts">
import type { CSSProperties, HTMLAttributes } from 'vue'
import {
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger,
TreeItem,
TreeRoot,
} from 'reka-ui'
import { cn } from '@/lib/utils'
export interface UiTreeSelectItem {
value: string
label: string
tone?: 'normal' | 'advanced' | 'rare' | 'artifact' | 'legend' | 'epic' | 'myth' | 'primeval'
disabled?: boolean
children?: UiTreeSelectItem[]
}
const props = withDefaults(
defineProps<{
class?: HTMLAttributes['class']
items: UiTreeSelectItem[]
placeholder?: string
label?: string
disabled?: boolean
}>(),
{ placeholder: '请选择', label: '树形选择' },
)
const model = defineModel<string>()
const open = ref(false)
const expanded = ref<string[]>([])
function findItem(
items: UiTreeSelectItem[],
value: string | undefined,
): UiTreeSelectItem | undefined {
for (const item of items) {
if (item.value === value) return item
const nested = item.children && findItem(item.children, value)
if (nested) return nested
}
}
const selectedItem = computed(() => findItem(props.items, model.value))
function findAncestorValues(
items: UiTreeSelectItem[],
value: string,
ancestors: string[] = [],
): string[] | undefined {
for (const item of items) {
if (item.value === value) return ancestors
if (!item.children?.length) continue
const nested = findAncestorValues(item.children, value, [...ancestors, item.value])
if (nested) return nested
}
}
function expandSelectedPath() {
if (!model.value) return
const ancestors = findAncestorValues(props.items, model.value)
if (!ancestors?.length) return
expanded.value = [...new Set([...expanded.value, ...ancestors])]
}
watch(open, (isOpen) => {
if (isOpen) expandSelectedPath()
})
watch(
() => [props.items, model.value] as const,
() => {
if (open.value) expandSelectedPath()
},
)
function handleSelect(item: UiTreeSelectItem | undefined) {
if (!item || item.disabled || item.children?.length) return
model.value = item.value
open.value = false
}
function preventParentSelection(event: Event, item: UiTreeSelectItem) {
if (item.children?.length) event.preventDefault()
}
function depthStyle(level: number): CSSProperties {
return { '--ui-tree-depth': Math.max(0, level - 1) } as CSSProperties
}
</script>
<template>
<PopoverRoot v-model:open="open">
<PopoverTrigger as-child>
<button
type="button"
:class="cn('ui-tree-select__trigger', props.class)"
:disabled="disabled"
:aria-label="label"
>
<span :class="selectedItem?.tone && `quality-${selectedItem.tone}`">
{{ selectedItem?.label ?? placeholder }}
</span>
<i class="ui-select-indicator" aria-hidden="true">
<span class="ui-select-indicator__glyph" />
</i>
</button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
class="ui-tree-select__content"
position="popper"
:side-offset="2"
:collision-padding="8"
>
<TreeRoot
v-slot="{ flattenItems }"
class="ui-tree-select__tree"
:model-value="selectedItem"
v-model:expanded="expanded"
:items="items"
:get-key="(item) => item.value"
:get-children="(item) => item.children"
aria-label="树形选项"
@update:model-value="handleSelect"
>
<TreeItem
v-for="item in flattenItems"
:key="item._id"
v-slot="{ isExpanded }"
v-bind="item.bind"
class="ui-tree-select__item"
:class="[
item.hasChildren && 'is-parent',
item.value.tone && `quality-${item.value.tone}`,
]"
:disabled="item.value.disabled"
:style="depthStyle(item.level)"
@select="preventParentSelection($event, item.value)"
>
<span
v-if="item.hasChildren"
class="ui-tree-select__branch"
:class="{ 'is-expanded': isExpanded }"
aria-hidden="true"
/>
<span>{{ item.value.label }}</span>
</TreeItem>
</TreeRoot>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>
<style lang="scss">
[data-reka-popper-content-wrapper]:has(> .ui-tree-select__content) {
z-index: 80 !important;
}
.ui-tree-select__trigger {
box-sizing: border-box;
display: inline-flex;
width: 100%;
min-width: 0;
height: var(--ui-control-height-default);
align-items: center;
justify-content: space-between;
gap: var(--ui-control-gap);
padding: 0 4px 0 var(--ui-field-padding-inline);
border: 1px solid var(--ui-input);
border-radius: 0;
outline: none;
background: var(--ui-field-background);
box-shadow: inset 0 0 0 1px var(--ui-field-inset);
color: var(--ui-primary-foreground);
cursor: pointer;
> span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:focus-visible,
&[data-state='open'] {
border-color: var(--ui-ring);
box-shadow:
inset 0 0 0 1px var(--ui-field-focus-inset),
0 0 0 1px var(--ui-field-focus-edge);
}
&:disabled {
cursor: not-allowed;
opacity: 0.48;
}
}
.ui-tree-select__content {
position: relative;
z-index: 80;
box-sizing: border-box;
width: min(var(--reka-popper-anchor-width), calc(100vw - 16px));
min-width: min(var(--ui-tree-select-min-inline-size), calc(100vw - 16px));
max-height: min(var(--ui-tree-select-max-block-size), var(--reka-popper-available-height));
padding: 2px 3px;
overflow-y: auto;
border: 1px solid var(--ui-popup-border);
outline: none;
background: var(--ui-tree-background);
box-shadow: 0 8px 18px var(--ui-popup-shadow);
}
.ui-tree-select__tree {
display: flex;
width: 100%;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 0;
outline: none;
list-style: none;
font-size: var(--ui-tree-item-font-size);
}
.ui-tree-select__trigger .quality-normal,
.ui-tree-select__item.quality-normal {
color: var(--dcalc-quality-normal);
}
.ui-tree-select__trigger .quality-advanced,
.ui-tree-select__item.quality-advanced {
color: var(--dcalc-quality-advanced);
}
.ui-tree-select__trigger .quality-rare,
.ui-tree-select__item.quality-rare {
color: var(--dcalc-quality-rare);
}
.ui-tree-select__trigger .quality-artifact,
.ui-tree-select__item.quality-artifact {
color: var(--dcalc-quality-artifact);
}
.ui-tree-select__trigger .quality-legend,
.ui-tree-select__item.quality-legend {
color: var(--dcalc-quality-legend);
}
.ui-tree-select__trigger .quality-epic,
.ui-tree-select__item.quality-epic {
color: var(--dcalc-quality-epic);
}
.ui-tree-select__trigger .quality-myth,
.ui-tree-select__item.quality-myth {
color: var(--dcalc-quality-myth) !important;
}
.ui-tree-select__trigger .quality-primeval,
.ui-tree-select__item.quality-primeval {
color: var(--dcalc-quality-primeval) !important;
}
.ui-tree-select__item {
box-sizing: border-box;
display: flex;
min-height: var(--ui-tree-item-block-size);
align-items: center;
gap: 6px;
padding: 0 7px 0 calc(7px + var(--ui-tree-depth) * var(--ui-tree-depth-step));
border: 1px solid var(--ui-tree-item-border);
outline: none;
background: var(--ui-tree-item-background);
box-shadow: var(--ui-tree-item-inset);
color: var(--ui-tree-item-foreground);
font-size: var(--ui-tree-item-font-size);
cursor: pointer;
&:hover {
border-color: var(--ui-tree-item-hover-border);
color: var(--ui-tree-item-hover-foreground);
filter: brightness(1.12);
}
&:active {
border-color: var(--ui-tree-item-active-border);
filter: brightness(0.86);
}
&:focus-visible {
outline: 1px solid var(--ui-ring);
outline-offset: -2px;
}
&[data-selected] {
border-color: var(--ui-tree-selected-border);
box-shadow: var(--ui-tree-selected-shadow);
}
&[data-disabled] {
cursor: not-allowed;
opacity: 0.45;
}
}
.ui-tree-select__branch {
position: relative;
display: inline-flex;
width: 13px;
height: 13px;
flex: 0 0 auto;
border: 1px solid var(--ui-tree-branch-border);
background: var(--ui-tree-branch-background);
box-shadow:
0 0 0 1px var(--ui-tree-branch-ring),
inset 0 1px 0 var(--ui-tree-branch-highlight);
&::before,
&::after {
position: absolute;
top: 50%;
left: 50%;
background: var(--ui-tree-branch-foreground);
box-shadow: 0 1px #000;
content: '';
transform: translate(-50%, -50%);
}
&::before {
width: 7px;
height: 1px;
}
&::after {
width: 1px;
height: 7px;
}
&.is-expanded::after {
display: none;
}
}
</style>
@@ -0,0 +1 @@
export { default as UiTreeSelect, type UiTreeSelectItem } from './UiTreeSelect.vue'