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
+4 -2
View File
@@ -39,8 +39,10 @@ interactions. `/ui-preview` remains the smaller foundation smoke-test page.
See [`docs/ui-migration-standard.md`](docs/ui-migration-standard.md) for the reusable component
migration, theming, responsive implementation and browser QA standard.
The current catalog covers Button, Badge, Panel, Input, NumberField, Checkbox, RadioGroup, Switch,
Rating, Select, Slider, Progress, Tabs, ItemSlot, DataTable, Tooltip, DropdownMenu and Dialog.
The current catalog covers 28 component families: Button, Badge, Panel, Input, NumberField,
Checkbox, RadioGroup, Switch, Rating, Select, Slider, Progress, Tabs, ItemSlot, DataTable, Tooltip,
DropdownMenu, Dialog, Autocomplete, Navigation, DataSection, ScrollArea, Settings, ShopItemRow,
TreeSelect, Cascader, Pagination and Popover.
## Migration rule
+33 -2
View File
@@ -18,6 +18,10 @@ The implementation keeps the source board's full-width dark surface, 46 px panel
- NumberField: 30 px field, 23 px spinner, visible up/down arrows, and two 14 px `+10`/`-10` fast-step buttons match the source anatomy.
- Tabs: 40 px list, 30 px triggers, glow layers, divider line, animated 39 × 5 px arrow marker, and line-marker variant are present. Marker movement follows Reka selection state and respects reduced motion.
- Dialog: the titlebar uses the exact 45 raster frames from `windowheadereffect.img`, advances every 66 ms while focused, and hides the effect in the unfocused/reduced-motion state.
- TreeSelect: the Reka tree now opens the selected path, uses the source's flush 2 px row rhythm,
eight rarity colors, pixel branch glyph, selected border, and distinct hover/active/focus-visible
states. Desktop and 390 px evidence is captured at `tree-select-fixed-1440.png` and
`tree-select-fixed-390.png`.
- Responsive: document overflow is absent at 1920, 1280, 768, and 390 widths. At 390 px, DataTable keeps a 328 px viewport with an intentional 560 px internal scroll surface.
## Required fidelity surfaces
@@ -25,7 +29,10 @@ The implementation keeps the source board's full-width dark surface, 46 px panel
- Fonts and typography: DCalc/SimSun control typography and Segoe UI/Chinese fallbacks retain the source hierarchy and compact line-height.
- Spacing and layout rhythm: page gutters use `clamp()`, panels use container-relative padding, grids collapse at 1280/900/520 breakpoints, and atomic game-control sizes stay pixel-aligned.
- Colors and tokens: blue action surfaces, brass edges, gold selection, blue focus, disabled gray, semantic status colors, and rarity colors map to DCalc CSS variables.
- Image quality and assets: stars, rarity frames, item icons, select arrow, dialog close icon, and all 45 title-effect frames use original raster assets with zero broken images.
- Image quality and assets: stars, rarity frames, item icons, dialog close icon, and all 45
title-effect frames use original raster assets with zero broken images. Select, TreeSelect and
Autocomplete use the Design source's CSS gradient button and clipped arrow instead of a raster
arrow.
- Copy and content: catalog copy differs only where it documents the Reka implementation or exposes live state; component labels and source examples remain representative.
## Interaction verification
@@ -38,12 +45,32 @@ The implementation keeps the source board's full-width dark surface, 46 px panel
- Select: 装备 → 材料.
- DropdownMenu: `导出结果` updated live status.
- Dialog: title frame changed from `4.PNG` to `5.PNG`; cancel restored focus to `打开 WindowDialog`.
- Dialog drag: the standard window moved by `120 × 66 px`; the complex window moved by
`-100 × 39 px`, while both title center deltas remained `0 px`.
- Dialog responsive boundary: at 390 px, reopening reset the desktop offset and dragging to the
upper-left stopped at the 8 px viewport margin.
- TreeSelect parent toggle: collapsing and reopening `所有稀有度` preserved the `稀有` leaf value;
the parent never received `data-selected`.
- TreeSelect keyboard: ArrowDown moved focus to `稀有` with a solid 1 px blue outline at -2 px
offset, without applying the hover brightness filter.
- TreeSelect responsive boundary: at 390 px the 319 px popup remained between x=30 and x=349,
rendered all nine rows, and retained the selected leaf.
## Comparison history
- P1: NumberField step affordances were too small and FastStep was missing. Fixed with source-sized arrows and `+10`/`-10` controls.
- P1: Tabs used a trigger-local pseudo marker and could not reproduce source movement. Fixed with a list-owned marker driven by active Reka tab geometry.
- P1: Dialog titlebar had a static gradient only. Fixed with the exact focused 45-frame animation.
- P1: Dialog had no draggable window behavior and long header descriptions could offset the title.
Fixed with a bounded Pointer Events drag model, open/resize recentering, and title-only centered
header composition.
- P1: TreeSelect inherited native `ul` margin and padding, so rows were inset and narrowed; Portal
content also lost scoped rarity colors, and parent clicks replaced the selected leaf. Fixed with
a reset tree root, global rarity semantics, selected-path expansion, and prevented parent select
events while retaining Reka expansion.
- P2: Select, TreeSelect and Autocomplete imported the shared arrow PNG even though the Design
control anatomy is CSS-rendered. Replaced all three with one theme-aware CSS indicator contract;
retained raster assets only where the source treats them as game artwork.
- P2: Button showcase omitted source state rows. Fixed with the complete three-row state matrix.
- P2: shadcn-like radii, generic focus rings, menu surface, and tooltip transitions drifted from DNF styling. Replaced with source-specific square surfaces and state treatments.
- P2: layout relied on fixed page spacing. Replaced page-level spacing and grids with fluid/container-responsive rules while preserving atomic raster dimensions.
@@ -51,6 +78,10 @@ The implementation keeps the source board's full-width dark surface, 46 px panel
## Remaining intentional deviations
- The catalog header includes theme switching and a component index that are not present on Design board 2.
- Components not yet migrated (CommandInput, TreeSelect, Cascader, navigation, ScrollArea, and complex product dialogs) are out of this comparison and must be covered in later migration batches.
- The cited full-page 1920 px comparison predates Cascader, Pagination and Popover. TreeSelect has
since received a focused Design/source-CSS comparison and browser verification; the other three
still require refreshed design-side screenshots before claiming pixel-level comparison evidence.
- SearchTree/CommandInput and domain-specific complex product dialogs remain outside this comparison.
- Navigation and ScrollArea are migrated; business call-site replacement remains a separate phase.
final result: passed
+30 -20
View File
@@ -15,26 +15,27 @@ mean recreating the effect from screenshots.
## Matrix
| Design source | Current target | Status | Migration notes |
| ----------------------- | --------------------------------------------------------- | ----------- | -------------------------------------------------------- |
| `foundations`, `shared` | `style/tokens`, `style/themes`, shared UI contract | Migrated | Semantic color and overridable geometry contracts |
| `button` | `ui/button` | Migrated | Preserved three-slice states; exposed density and width |
| `input` | `ui/input`, `ui/number-field` | Migrated | Removed forced inline width; normalized text metrics |
| `form-controls` | `ui/checkbox`, `ui/radio-group`, `ui/switch`, `ui/rating` | Migrated | Tokenized state colors and aligned label/control anatomy |
| `select` | `ui/select` | Migrated | Preserved popup anatomy and Reka keyboard behavior |
| `tabs` | `ui/tabs` | Migrated | Preserved list-owned animated marker |
| `slider` | `ui/slider` | Migrated | Preserved the Reka coordinate system |
| `data-table` | `ui/data-table` | Migrated | Ported hover, selected and density behavior |
| `dialog` | `ui/dialog` | Migrated | Preserved the focused title effect and focus management |
| `menu` | `ui/dropdown-menu` | Migrated | Ported source menu spacing and state tokens |
| `rarity` | `ui/item-slot` | Migrated | Retained rarity presentation as data-semantic tokens |
| `autocomplete` | `ui/autocomplete` | Migrated | Reka Combobox composition with source popup anatomy |
| `navigation` | `ui/navigation` | Migrated | Selectable navigation primitive with source assets |
| `scrollbar` | `ui/scroll-area` | Migrated | Reusable Reka viewport/scrollbar composition |
| `data-section` | `ui/data-section` | Migrated | Collapsible section and vertically centered data rows |
| `shop-item-row` | `ui/shop-item-row` | Migrated | Ported source row structure and selectable states |
| `complex-dialog` | `ui/dialog` + migrated primitives | Composition | Kept domain-independent by composing existing primitives |
| `settings` | `ui/settings` | Migrated | Responsive settings group, section and row primitives |
| Design source | Current target | Status | Migration notes |
| ----------------------- | --------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------- |
| `foundations`, `shared` | `style/tokens`, `style/themes`, shared UI contract | Migrated | Semantic color and overridable geometry contracts |
| `button` | `ui/button` | Migrated | Preserved three-slice states; exposed density and width |
| `input` | `ui/input`, `ui/number-field` | Migrated | Removed forced inline width; normalized text metrics |
| `form-controls` | `ui/checkbox`, `ui/radio-group`, `ui/switch`, `ui/rating` | Migrated | Tokenized state colors and aligned label/control anatomy |
| `select` | `ui/select`, `ui/tree-select`, `ui/cascader` | Migrated | Flat, hierarchical and two-level responsive selection; TreeSelect preserves parent-toggle and rarity states |
| `tabs` | `ui/tabs`, `ui/pagination` | Migrated | Animated list marker and compact result navigation |
| `slider` | `ui/slider` | Migrated | Preserved the Reka coordinate system |
| `data-table` | `ui/data-table` | Migrated | Ported hover, selected and density behavior |
| `dialog` | `ui/dialog` | Migrated | Preserved the focused title effect and focus management |
| `menu` | `ui/dropdown-menu` | Migrated | Ported source menu spacing and state tokens |
| `rarity` | `ui/item-slot` | Migrated | Retained rarity presentation as data-semantic tokens |
| `autocomplete` | `ui/autocomplete` | Partial | Combobox is migrated; domain SearchTree remains deferred |
| `navigation` | `ui/navigation` | Migrated | Selectable navigation primitive with source assets |
| `scrollbar` | `ui/scroll-area` | Migrated | Reusable Reka viewport/scrollbar composition |
| shared overlay help | `ui/popover`, rich `ui/tooltip` | Migrated | Portal z-index, collision and responsive width contract |
| `data-section` | `ui/data-section` | Migrated | Collapsible section and vertically centered data rows |
| `shop-item-row` | `ui/shop-item-row` | Migrated | Ported source row structure and selectable states |
| `complex-dialog` | `ui/dialog` + migrated primitives | Composition | Kept domain-independent by composing existing primitives |
| `settings` | `ui/settings` | Migrated | Responsive settings group, section and row primitives |
## Legacy replacement order
@@ -45,3 +46,12 @@ Deferred: this component-library pass does not change business call sites.
3. Equipment dictionary filters, tree, cascader and pagination.
4. Skill tree, skill detail and VP controls.
5. Result tables and remaining domain-specific components.
## Remaining gaps
- SearchTree/CommandInput still requires a domain-neutral data and filtering contract before it can
join `src/components/ui`.
- Complex product dialogs are compositions, not a claim that every business dialog has reached
source-level visual parity.
- Existing `frontend` business call sites remain on their current components until each behavior
slice is migrated and browser-verified.
@@ -99,6 +99,8 @@ Token 分成两层:
- 建立完整状态矩阵:rest、hover、pressed、focus、disabled、selected/open。
- 动效需记录帧数、间隔、缓动、焦点条件和 reduced-motion 行为。
- 优先复用原始 PNG、字体、图标和 rarity frame,不用近似图标替代。
- 先核对 Design 的真实实现方式:源组件使用 CSS 渐变、伪元素或 `clip-path` 构造的
控制图标继续使用 CSS;只有被 Design 明确作为像素素材引用的图标才引入图片。
### 6.3 选择 Reka Primitive
@@ -177,6 +179,10 @@ Reka 的 Select、Tooltip、DropdownMenu、Popover 等通常经过 Portal 和 Po
- 使用确定的数字顺序加载帧,避免字符串排序造成 `10` 排在 `2` 前。
- 仅在 focused 状态推进和展示动效;unfocused 状态使用静态标题栏。
- 组件卸载时清理计时器,reduced-motion 下隐藏或静态化动效。
- 可拖动窗口仅以标题栏空白区域作为手柄,关闭按钮和标题栏操作控件不得触发拖动。
- 使用 Pointer Events 同时覆盖鼠标和触摸;拖动范围限制在当前视口安全边距内。
- 每次重新打开或视口尺寸变化时恢复几何居中,避免桌面偏移泄漏到移动端。
- 标题使用对称网格独立居中,长 Description 放入内容区,不参与标题栏宽度计算。
### 7.5 NumberField 扩展操作
@@ -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'
@@ -4,6 +4,7 @@ import { UiAutocomplete, type UiAutocompleteOption } from '@/components/ui/autoc
import { UiBadge } from '@/components/ui/badge'
import { buttonVariants, UiButton } from '@/components/ui/button'
import { UiCheckbox } from '@/components/ui/checkbox'
import { UiCascader, type UiCascaderGroup } from '@/components/ui/cascader'
import { UiDataRow, UiDataSection } from '@/components/ui/data-section'
import {
UiDataTable,
@@ -33,6 +34,8 @@ import { UiItemSlot } from '@/components/ui/item-slot'
import { UiNumberField } from '@/components/ui/number-field'
import { UiNavigation, type UiNavigationItem } from '@/components/ui/navigation'
import { UiPanel } from '@/components/ui/panel'
import { UiPagination } from '@/components/ui/pagination'
import { PopoverRoot, PopoverTrigger, UiPopoverContent } from '@/components/ui/popover'
import { UiProgress } from '@/components/ui/progress'
import { UiRadioGroup, UiRadioGroupItem } from '@/components/ui/radio-group'
import { UiRating } from '@/components/ui/rating'
@@ -49,6 +52,7 @@ import { UiSettingRow, UiSettingsGroup, UiSettingsSection } from '@/components/u
import { UiShopItemList, type UiShopItem } from '@/components/ui/shop-item-row'
import { UiSwitch } from '@/components/ui/switch'
import { TabsRoot, UiTabsContent, UiTabsList, UiTabsTrigger } from '@/components/ui/tabs'
import { UiTreeSelect, type UiTreeSelectItem } from '@/components/ui/tree-select'
import {
TooltipProvider,
TooltipRoot,
@@ -87,6 +91,9 @@ const dataSectionOpen = ref(true)
const shopSelected = ref('shop-rare')
const sortKey = ref('reinforce')
const sortDirection = ref<'ascending' | 'descending'>('ascending')
const treeValue = ref('rare')
const cascaderValue = ref(['celestial', 'silent-city'])
const currentPage = ref(1)
const progress = computed(() => Math.min(100, 18 + clickCount.value * 14))
@@ -115,6 +122,47 @@ const componentNames = [
'ScrollArea',
'Settings',
'ShopItemRow',
'TreeSelect',
'Cascader',
'Pagination',
'Popover',
]
const treeSelectItems: UiTreeSelectItem[] = [
{
value: 'rarity',
label: '所有稀有度',
children: [
{ value: 'normal', label: '普通', tone: 'normal' },
{ value: 'advanced', label: '高级', tone: 'advanced' },
{ value: 'rare', label: '稀有', tone: 'rare' },
{ value: 'artifact', label: '神器', tone: 'artifact' },
{ value: 'legend', label: '传说', tone: 'legend' },
{ value: 'epic', label: '史诗', tone: 'epic' },
{ value: 'myth', label: '神话', tone: 'myth' },
{ value: 'primeval', label: '太初', tone: 'primeval' },
],
},
]
const cascaderOptions: UiCascaderGroup[] = [
{
value: 'celestial',
label: '天界',
children: [
{ value: 'silent-city', label: '寂静城' },
{ value: 'noblesse-code', label: '贵族机要' },
{ value: 'meister-lab', label: '机械七战神实验室' },
],
},
{
value: 'seon',
label: '神界',
children: [
{ value: 'white-cloud', label: '白云溪谷' },
{ value: 'solidaris', label: '索利达里斯' },
],
},
]
const autocompleteOptions: UiAutocompleteOption[] = [
@@ -344,6 +392,57 @@ function handleSort(key: string) {
</UiPanel>
</div>
<div class="two-column adaptive-components-row">
<UiPanel
title="TreeSelect / Cascader"
note="Hierarchical value · collision-aware responsive popup"
>
<div class="adaptive-field-grid">
<label class="field-stack">
<span>TreeSelect · keyboard navigation</span>
<UiTreeSelect v-model="treeValue" :items="treeSelectItems" />
</label>
<label class="field-stack">
<span>Cascader · two-level filter</span>
<UiCascader v-model="cascaderValue" :options="cascaderOptions" />
</label>
</div>
<output class="adaptive-output">
{{ treeValue }} · {{ cascaderValue.join(' / ') }}
</output>
</UiPanel>
<UiPanel
title="Pagination / Popover / RichHelp"
note="Reka state · fluid overlay width · viewport collision"
>
<div class="adaptive-overlay-grid">
<UiPagination v-model:page="currentPage" :total="87" result-label="装备结果" />
<div class="adaptive-overlay-actions">
<PopoverRoot>
<PopoverTrigger as-child>
<UiButton variant="outline">打开属性说明</UiButton>
</PopoverTrigger>
<UiPopoverContent variant="rich" align="start">
<strong class="popover-title">属性计算说明</strong>
<p>最终结果会合并装备基础值、强化等级与当前筛选条件。</p>
<UiProgress :model-value="68" :marker="50" />
</UiPopoverContent>
</PopoverRoot>
<TooltipRoot>
<TooltipTrigger as-child>
<UiButton variant="outline">聚焦或悬停查看帮助</UiButton>
</TooltipTrigger>
<UiTooltipContent variant="rich">
<strong>高级帮助</strong
><br />支持键盘聚焦、碰撞翻转,并在窄屏内限制最大宽度。
</UiTooltipContent>
</TooltipRoot>
</div>
</div>
</UiPanel>
</div>
<div class="two-column controls-row">
<UiPanel
title="Checkbox / RadioGroup / Switch / Rate"
@@ -563,13 +662,11 @@ function handleSort(key: string) {
><UiButton variant="outline">打开组合设置窗口</UiButton></DialogTrigger
><DialogPortal
><UiDialogOverlay /><UiDialogContent class="complex-dialog-demo"
><UiDialogHeader
><UiDialogTitle>管理设置</UiDialogTitle>
><UiDialogHeader><UiDialogTitle>管理设置</UiDialogTitle></UiDialogHeader>
<div class="complex-dialog-body">
<UiDialogDescription
>组合展示已迁移的导航、设置行、滚动区与表单控件。</UiDialogDescription
></UiDialogHeader
>
<div class="complex-dialog-body">
>
<p>※ 直接组合迁移后的 Navigation、SettingsRow、ScrollArea 与表单控件。</p>
<div class="complex-dialog-main">
<UiNavigation
@@ -816,6 +913,37 @@ label > span,
background: #070807;
color: var(--ui-muted-foreground);
}
.adaptive-field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: clamp(8px, 1.2vw, 14px);
}
.adaptive-output {
display: block;
min-width: 0;
margin-top: 10px;
overflow-wrap: anywhere;
color: var(--ui-muted-foreground);
}
.adaptive-overlay-grid {
display: grid;
gap: 10px;
}
.adaptive-overlay-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
:global(.popover-title) {
display: block;
margin-bottom: 6px;
color: var(--ui-tooltip-strong);
font-weight: 400;
}
:global(.ui-popover-content p) {
margin: 0 0 10px;
color: var(--ui-muted-foreground);
}
.static-select {
margin-top: 8px;
border: 1px solid var(--ui-border);
@@ -904,7 +1032,7 @@ label > span,
color: var(--dcalc-quality-epic);
}
.quality-primeval {
color: #5aff62;
color: var(--dcalc-quality-primeval);
}
.status-success {
color: var(--dcalc-success);
@@ -1065,7 +1193,8 @@ footer {
font-size: 24px;
}
.field-grid,
.control-grid {
.control-grid,
.adaptive-field-grid {
grid-template-columns: 1fr;
}
.button-showcase {
+4 -4
View File
@@ -78,13 +78,13 @@ const accepted = ref(false)
<UiDialogContent>
<UiDialogHeader>
<UiDialogTitle>组合式弹窗</UiDialogTitle>
<UiDialogDescription>
Portal、Overlay、Content、Title 和 Close
均显式组合,业务逻辑不再隐藏在基础组件中。
</UiDialogDescription>
</UiDialogHeader>
<div class="border-y border-border py-4 text-xs text-foreground">
<UiDialogDescription class="mb-3">
Portal、Overlay、Content、Title 和 Close
均显式组合,业务逻辑不再隐藏在基础组件中。
</UiDialogDescription>
当前主题:{{ activeTheme }}
</div>
@@ -0,0 +1,39 @@
.ui-select-indicator {
box-sizing: border-box;
display: inline-flex;
width: var(--ui-select-icon-inline-size);
height: var(--ui-select-icon-block-size);
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 1px solid transparent;
background:
var(--ui-select-indicator-background) padding-box,
var(--ui-select-indicator-border) border-box;
box-shadow:
0 0 0 1px var(--ui-select-icon-shadow),
inset 0 1px 0 var(--ui-control-inner-highlight);
}
.ui-select-indicator__glyph {
width: var(--ui-select-glyph-inline-size);
height: var(--ui-select-glyph-block-size);
background: var(--ui-select-indicator-glyph);
clip-path: polygon(0 0, 100% 0, 50% 100%);
}
:where(.ui-select-trigger, .ui-tree-select__trigger):hover:not(:disabled) .ui-select-indicator,
:where(.ui-select-trigger, .ui-tree-select__trigger):focus-visible .ui-select-indicator,
:where(.ui-select-trigger, .ui-tree-select__trigger)[data-state='open'] .ui-select-indicator,
.ui-autocomplete__trigger:hover:not(:disabled),
.ui-autocomplete__trigger:focus-visible,
.ui-autocomplete__trigger[data-state='open'] {
background:
var(--ui-select-indicator-background-hover) padding-box,
var(--ui-select-indicator-border-hover) border-box;
}
:where(.ui-select-trigger, .ui-tree-select__trigger):active:not(:disabled) .ui-select-indicator,
.ui-autocomplete__trigger:active:not(:disabled) {
transform: translateY(1px);
}
+11 -2
View File
@@ -5,6 +5,7 @@
@import './themes/dcalc.css';
@import './themes/midnight.css';
@import './components/button.css';
@import './components/select-indicator.css';
@import 'tailwindcss/utilities.css' layer(utilities);
@source '../**/*.{vue,ts,tsx}';
@@ -52,13 +53,21 @@
@keyframes dialog-in {
from {
opacity: 0;
transform: translate(-50%, -48%) scale(0.98);
transform: translate(
calc(-50% + var(--ui-dialog-drag-x, 0px)),
calc(-48% + var(--ui-dialog-drag-y, 0px))
)
scale(0.98);
}
}
@keyframes dialog-out {
to {
opacity: 0;
transform: translate(-50%, -48%) scale(0.98);
transform: translate(
calc(-50% + var(--ui-dialog-drag-x, 0px)),
calc(-48% + var(--ui-dialog-drag-y, 0px))
)
scale(0.98);
}
}
+42 -1
View File
@@ -153,7 +153,12 @@
/* Reusable one-pixel control depth; kept separate from panel elevation. */
--ui-control-outer-shadow: rgb(0 0 0 / 40%);
--ui-control-inner-highlight: rgb(255 255 255 / 2.5%);
--ui-select-icon-shadow: rgb(0 0 0 / 60%); /* Outer pixel edge around the raster arrow button. */
--ui-select-icon-shadow: rgb(0 0 0 / 60%);
--ui-select-indicator-background: linear-gradient(180deg, #1f355e, #1a2849);
--ui-select-indicator-background-hover: linear-gradient(180deg, #29457f, #24386a);
--ui-select-indicator-border: linear-gradient(180deg, #684f2d, #483423);
--ui-select-indicator-border-hover: linear-gradient(180deg, #725f4e, #524444);
--ui-select-indicator-glyph: #b69260;
--ui-check-outer-shadow: rgb(0 0 0 / 30%);
--ui-check-mark-shadow: rgb(230 192 86 / 45%);
/* NumberField spinner and the source-derived +/-10 raster-like surfaces. */
@@ -198,6 +203,42 @@
--ui-tooltip-background: #020302;
--ui-tooltip-shadow: #000;
--ui-tooltip-foreground: #f5f5ef;
--ui-tooltip-strong: #e8c968;
--ui-popover-border: #5a4828;
--ui-popover-background: #020302;
--ui-popover-shadow: #000;
--ui-pagination-border: #514125;
--ui-pagination-background: #101211;
--ui-pagination-active-background: #1b315c;
--ui-pagination-active-foreground: #e3c977;
--ui-tree-background: #010201;
--ui-tree-item-border: #0d0f0d;
--ui-tree-item-background: linear-gradient(180deg, #2c2a2c 0, #202220 16%, #0c0e0c 100%);
--ui-tree-item-inset:
inset 0 1px 0 #3e3c3e, inset 1px 0 0 #252725, inset -1px 0 0 #111311, inset 0 -1px 0 #111311;
--ui-tree-item-hover-border: #4f4b3c;
--ui-tree-item-foreground: #d6c487;
--ui-tree-item-hover-foreground: #f2da85;
--ui-tree-item-active-border: #806329;
--ui-tree-selected-border: #efb62f;
--ui-tree-selected-shadow:
inset 0 0 0 1px #7c5d1d, inset 0 1px 0 #f1c75f, 0 0 4px rgb(239 182 47 / 48%);
--ui-tree-branch-border: #5f4a2b;
--ui-tree-branch-background: linear-gradient(180deg, #272720, #11120f);
--ui-tree-branch-foreground: #a98b46;
--ui-tree-branch-ring: #090a09;
--ui-tree-branch-highlight: rgb(255 255 255 / 4%);
--ui-cascader-underline: #583916;
--ui-cascader-shell-background: #030403;
--ui-cascader-trigger-border: #332a1b;
--ui-cascader-trigger-background: linear-gradient(180deg, #262721, #11120f);
--ui-cascader-trigger-foreground: #c3a15e;
--ui-cascader-trigger-hover-border: #756132;
--ui-cascader-trigger-hover-background: linear-gradient(180deg, #403a28, #191a15);
--ui-cascader-trigger-hover-foreground: #fff1a4;
--ui-cascader-trigger-highlight: #a89a61;
--ui-cascader-popup-border: #432b14;
--ui-cascader-option-highlight: #09284b;
--ui-popup-border: #5a4828;
--ui-popup-background: #050606;
--ui-popup-shadow: #000;
+42 -1
View File
@@ -134,7 +134,12 @@
--ui-slider-disabled-thumb-bottom: #29313d;
--ui-control-outer-shadow: rgb(0 0 0 / 42%);
--ui-control-inner-highlight: rgb(255 255 255 / 4%);
--ui-select-icon-shadow: rgb(0 0 0 / 60%); /* Keeps the arrow button legible on blue fields. */
--ui-select-icon-shadow: rgb(0 0 0 / 60%);
--ui-select-indicator-background: linear-gradient(180deg, #294d80, #172b4b);
--ui-select-indicator-background-hover: linear-gradient(180deg, #376aa7, #1e3b64);
--ui-select-indicator-border: linear-gradient(180deg, #526f98, #354b6e);
--ui-select-indicator-border-hover: linear-gradient(180deg, #9ac8ef, #658db8);
--ui-select-indicator-glyph: #9ac8ef;
--ui-check-outer-shadow: rgb(0 0 0 / 34%);
--ui-check-mark-shadow: rgb(111 181 230 / 42%);
--ui-number-spinner-divider: #40577a;
@@ -205,6 +210,42 @@
--ui-tooltip-background: #080d16;
--ui-tooltip-shadow: #020611;
--ui-tooltip-foreground: #edf5ff;
--ui-tooltip-strong: #9ac8ef;
--ui-popover-border: #526f98;
--ui-popover-background: #080d16;
--ui-popover-shadow: #020611;
--ui-pagination-border: #40577a;
--ui-pagination-background: #101a29;
--ui-pagination-active-background: #315c97;
--ui-pagination-active-foreground: #edf5ff;
--ui-tree-background: #080d16;
--ui-tree-item-border: #17243a;
--ui-tree-item-background: linear-gradient(180deg, #253650, #16243a 18%, #0b111d 100%);
--ui-tree-item-inset:
inset 0 1px 0 #40577a, inset 1px 0 0 #263b57, inset -1px 0 0 #101722, inset 0 -1px 0 #101722;
--ui-tree-item-hover-border: #6e91ba;
--ui-tree-item-foreground: #a9c9e7;
--ui-tree-item-hover-foreground: #edf5ff;
--ui-tree-item-active-border: #315c97;
--ui-tree-selected-border: #8ebce4;
--ui-tree-selected-shadow:
inset 0 0 0 1px #315c97, inset 0 1px 0 #b6d9f4, 0 0 4px rgb(111 181 230 / 48%);
--ui-tree-branch-border: #40577a;
--ui-tree-branch-background: linear-gradient(180deg, #263b57, #101722);
--ui-tree-branch-foreground: #9ac8ef;
--ui-tree-branch-ring: #050914;
--ui-tree-branch-highlight: rgb(255 255 255 / 4%);
--ui-cascader-underline: #263b57;
--ui-cascader-shell-background: #080d16;
--ui-cascader-trigger-border: #314968;
--ui-cascader-trigger-background: linear-gradient(180deg, #253650, #101722);
--ui-cascader-trigger-foreground: #a9c9e7;
--ui-cascader-trigger-hover-border: #6e91ba;
--ui-cascader-trigger-hover-background: linear-gradient(180deg, #385579, #17243a);
--ui-cascader-trigger-hover-foreground: #ffffff;
--ui-cascader-trigger-highlight: #8ebce4;
--ui-cascader-popup-border: #40577a;
--ui-cascader-option-highlight: #183d68;
--ui-popup-border: #40577a;
--ui-popup-background: #0c1220;
--ui-popup-shadow: #020611;
@@ -32,6 +32,8 @@
--dcalc-quality-artifact: #ef28c1;
--dcalc-quality-legend: #ff7700;
--dcalc-quality-epic: #fcb600;
--dcalc-quality-myth: #f8c63d;
--dcalc-quality-primeval: #5aff62;
--dcalc-quality-chronicle: #ff6666;
/*
@@ -80,6 +82,14 @@
--ui-slider-value-min-inline-size: 40px;
--ui-progress-block-size: 14px;
--ui-tooltip-max-inline-size: 260px;
--ui-tooltip-rich-inline-size: 310px;
--ui-popover-inline-size: clamp(240px, 32vw, 420px);
--ui-popover-rich-inline-size: clamp(270px, 36vw, 480px);
--ui-popover-padding: 10px;
--ui-pagination-block-size: 34px;
--ui-pagination-gap: 8px;
--ui-pagination-button-inline-size: 27px;
--ui-pagination-button-block-size: 25px;
--ui-menu-min-inline-size: 190px;
--ui-menu-item-block-size: 28px;
--ui-select-icon-inline-size: 21px;
@@ -111,6 +121,15 @@
--ui-autocomplete-max-block-size: 240px;
--ui-autocomplete-trigger-gap: 4px;
--ui-autocomplete-indicator-font-size: 8px;
--ui-tree-select-min-inline-size: 230px;
--ui-tree-select-max-block-size: 280px;
--ui-tree-item-block-size: 24px;
--ui-tree-item-font-size: 16px;
--ui-tree-depth-step: 14px;
--ui-cascader-primary-ratio: 39%;
--ui-cascader-popup-inline-size: clamp(280px, 42vw, 480px);
--ui-cascader-popup-max-block-size: 280px;
--ui-cascader-option-block-size: 24px;
--ui-navigation-inline-size: 250px;
--ui-navigation-row-block-size: 48px;
--ui-navigation-icon-size: 24px;