init
This commit is contained in:
@@ -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'
|
||||
Reference in New Issue
Block a user