u
This commit is contained in:
1 parent
0be0b0767a
commit
5f28a10d89
852 files changed
+8962
-111755
No files matched your search
@@ -0,0 +1,50 @@
|
||||
import { computed, ref, type ComputedRef } from 'vue'
|
||||
import { useConfig } from '../config-provider/context'
|
||||
import { getGlobalConfig } from '../_utils/global-config'
|
||||
import type { Direction } from '../_utils/direction'
|
||||
|
||||
/**
|
||||
* 取当前文本方向:就近 ConfigProvider → 全局单例 → 文档方向 → 'ltr'。
|
||||
*
|
||||
* 视觉镜像由 CSS 的 `[dir='rtl']` 自动处理,**不依赖本函数**;这里只给需要方向语义的
|
||||
* JS 逻辑用(浮层对齐轴、键盘左右键、拖拽 / 定位符号)。
|
||||
*
|
||||
* 文档方向(`<html dir="rtl">` 或 `html { direction: rtl }`)在客户端首次调用时读取一次,
|
||||
* 之后由 MutationObserver 跟随运行时切换 —— 这样宿主直接设 `<html dir>` 时无需任何配置,
|
||||
* 也不会因为「切了方向但 JS 没跟上」导致浮层对齐轴反着走。
|
||||
* SSR 下读不到 DOM,退回 'ltr';水合后由 observer 的首次赋值纠正。
|
||||
*/
|
||||
const documentDirection = ref<Direction>('ltr')
|
||||
let documentObserved = false
|
||||
|
||||
function readDocumentDirection(): Direction {
|
||||
if (typeof document === 'undefined') return 'ltr'
|
||||
const el = document.documentElement
|
||||
if (!el) return 'ltr'
|
||||
const attr = el.getAttribute('dir')
|
||||
if (attr === 'rtl') return 'rtl'
|
||||
if (attr === 'ltr') return 'ltr'
|
||||
return getComputedStyle(el).direction === 'rtl' ? 'rtl' : 'ltr'
|
||||
}
|
||||
|
||||
/** 只装一次观察器:跟随 <html> 上 dir / class / style 的变化(三者都可能改变计算方向)。 */
|
||||
function ensureDocumentObserver(): void {
|
||||
if (documentObserved || typeof document === 'undefined' || !document.documentElement) return
|
||||
documentObserved = true
|
||||
documentDirection.value = readDocumentDirection()
|
||||
if (typeof MutationObserver === 'undefined') return
|
||||
new MutationObserver(() => {
|
||||
documentDirection.value = readDocumentDirection()
|
||||
}).observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['dir', 'class', 'style'],
|
||||
})
|
||||
}
|
||||
|
||||
export function useDirection(): ComputedRef<Direction> {
|
||||
const config = useConfig()
|
||||
ensureDocumentObserver()
|
||||
return computed(
|
||||
() => config?.direction.value ?? getGlobalConfig().direction ?? documentDirection.value,
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ComputedRef,
|
||||
type Ref,
|
||||
} from 'vue'
|
||||
import { useDirection } from './useDirection'
|
||||
import { computePosition, type Placement, type RectLike } from '../_utils/position'
|
||||
|
||||
export interface UseFloatingOptions {
|
||||
@@ -53,6 +54,7 @@ export function useFloating(options: UseFloatingOptions): UseFloatingReturn {
|
||||
const floatingRef = ref<HTMLElement | null>(null)
|
||||
const resolvedPlacement = ref<Placement>(options.placement())
|
||||
const floatingStyle = ref<CSSProperties>({})
|
||||
const direction = useDirection()
|
||||
|
||||
let rafId: number | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
@@ -76,6 +78,7 @@ export function useFloating(options: UseFloatingOptions): UseFloatingReturn {
|
||||
triggerRect,
|
||||
floatSize: { width: floating.offsetWidth, height: floating.offsetHeight },
|
||||
offset: options.offset?.() ?? 0,
|
||||
direction: direction.value,
|
||||
})
|
||||
resolvedPlacement.value = placement
|
||||
floatingStyle.value = { left: `${x}px`, top: `${y}px` }
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* RTL 视觉补充。
|
||||
*
|
||||
* 绝大部分镜像**已经在各组件的 `<style>` 里用逻辑属性**(padding-inline-start /
|
||||
* inset-inline-end / border-inline-start / margin-inline-start)表达,跟随 `dir` 自动翻转。
|
||||
* 这里只放两类没法用逻辑属性表达的东西:
|
||||
*
|
||||
* 1. 方向性图标:水平箭头必须翻面(逻辑属性管不到 SVG 内部)
|
||||
* 2. 需要「带符号」的 transform:走 tokens 的 `--g3-dir-sign`(LTR 1 / RTL -1)
|
||||
*
|
||||
* 硬规矩:**不要用 `scaleX(-1)` 做整块镜像** —— 文字会变成反字。只作用于图标。
|
||||
*
|
||||
* 本文件不写 scoped(全库约定),靠 `[dir='rtl']` 作用域 + 具体选择器隔离。
|
||||
*/
|
||||
|
||||
/* 水平方向图标翻面。@lucide/vue 会在 svg 上带 `lucide-<name>` 类名,直接按名匹配,
|
||||
不必逐个组件加标记类。竖直图标(chevron-down / arrow-up)不在此列。 */
|
||||
[dir='rtl'] {
|
||||
.lucide-chevron-left,
|
||||
.lucide-chevron-right,
|
||||
.lucide-chevrons-left,
|
||||
.lucide-chevrons-right,
|
||||
.lucide-arrow-left,
|
||||
.lucide-arrow-right,
|
||||
.lucide-move-horizontal {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { horizontalArrowStep } from './direction'
|
||||
|
||||
describe('horizontalArrowStep', () => {
|
||||
it('LTR:右 = 朝行内结束,左 = 朝行内起始', () => {
|
||||
expect(horizontalArrowStep('ArrowRight', 'ltr')).toBe(1)
|
||||
expect(horizontalArrowStep('ArrowLeft', 'ltr')).toBe(-1)
|
||||
})
|
||||
|
||||
it('RTL:左右互换', () => {
|
||||
expect(horizontalArrowStep('ArrowLeft', 'rtl')).toBe(1)
|
||||
expect(horizontalArrowStep('ArrowRight', 'rtl')).toBe(-1)
|
||||
})
|
||||
|
||||
it('非水平方向键返回 0(不吞事件)', () => {
|
||||
for (const key of ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' ']) {
|
||||
expect(horizontalArrowStep(key, 'ltr')).toBe(0)
|
||||
expect(horizontalArrowStep(key, 'rtl')).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('两个方向互为相反数', () => {
|
||||
for (const dir of ['ltr', 'rtl'] as const) {
|
||||
expect(horizontalArrowStep('ArrowRight', dir)).toBe(
|
||||
-horizontalArrowStep('ArrowLeft', dir),
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
/** 文本方向:ltr = 左→右(默认),rtl = 右→左。 */
|
||||
export type Direction = 'ltr' | 'rtl'
|
||||
|
||||
/**
|
||||
* 把水平方向键换算成「行内方向」的步进:
|
||||
*
|
||||
* - `+1` = 朝**行内结束**方向(LTR 是右、RTL 是左)
|
||||
* - `-1` = 朝**行内起始**方向(LTR 是左、RTL 是右)
|
||||
* - `0` = 不是水平方向键
|
||||
*
|
||||
* 用途:Tabs / Segmented / Radio 的左右切换、日期网格的左右移动、Splitter 的左右调整。
|
||||
* 这些交互的「下一个」是按**视觉顺序**走的,而 RTL 下视觉顺序与 DOM 顺序相反,
|
||||
* 所以直接用 `ArrowRight` 当「下一个」会让键盘走向和画面走向相反。
|
||||
*/
|
||||
export function horizontalArrowStep(key: string, direction: Direction): 1 | -1 | 0 {
|
||||
const towardEnd = direction === 'rtl' ? 'ArrowLeft' : 'ArrowRight'
|
||||
const towardStart = direction === 'rtl' ? 'ArrowRight' : 'ArrowLeft'
|
||||
if (key === towardEnd) return 1
|
||||
if (key === towardStart) return -1
|
||||
return 0
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Direction } from './direction'
|
||||
import type { Locale } from '../locale'
|
||||
|
||||
/** 弹层挂载容器:选择器 / 元素 / 返回元素的函数。 */
|
||||
@@ -13,6 +14,8 @@ export interface GlobalConfig {
|
||||
namespace?: string
|
||||
zIndex?: number
|
||||
locale?: Locale
|
||||
/** 文本方向兜底(组件树外实例用) */
|
||||
direction?: Direction
|
||||
popupContainer?: PopupContainer
|
||||
/** 按组件维度的默认 props,如 { button: { size: 'small' } }(ConfigProvider 的组件外兜底) */
|
||||
componentDefaults?: Record<string, Record<string, unknown>>
|
||||
|
||||
@@ -14,6 +14,7 @@ function compute(placement: Placement, options: {
|
||||
rect?: RectLike
|
||||
floatSize?: { width: number; height: number }
|
||||
offset?: number
|
||||
direction?: 'ltr' | 'rtl'
|
||||
} = {}) {
|
||||
return computePosition({
|
||||
placement,
|
||||
@@ -21,6 +22,7 @@ function compute(placement: Placement, options: {
|
||||
floatSize: options.floatSize ?? { width: 120, height: 60 },
|
||||
offset: options.offset ?? 0,
|
||||
viewport: VIEWPORT,
|
||||
direction: options.direction ?? 'ltr',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -141,6 +143,34 @@ describe('computePosition 视口钳制(shift)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('computePosition RTL', () => {
|
||||
it('水平对齐轴镜像:bottom-start 在 RTL 下对齐到触发器右边', () => {
|
||||
// 触发器 left 100 / width 100,浮层宽 120
|
||||
expect(compute('bottom-start', { offset: 8 })).toMatchObject({ x: 100 })
|
||||
expect(compute('bottom-start', { offset: 8, direction: 'rtl' })).toMatchObject({ x: 80, y: 128 })
|
||||
})
|
||||
|
||||
it('end 在 RTL 下反转为 start(左对齐)', () => {
|
||||
expect(compute('bottom-end', { offset: 8, direction: 'rtl' })).toMatchObject({ x: 100 })
|
||||
expect(compute('top-end', { offset: 8, direction: 'rtl' })).toMatchObject({ x: 100 })
|
||||
})
|
||||
|
||||
it('left / right 主方向保持物理语义,不随 RTL 镜像', () => {
|
||||
expect(compute('right-start', { offset: 8, direction: 'rtl' })).toMatchObject({ x: 208, y: 100 })
|
||||
})
|
||||
|
||||
it('翻转在 RTL 下同样镜像对齐轴', () => {
|
||||
const result = compute('bottom-end', {
|
||||
rect: trigger({ top: 700, bottom: 720 }),
|
||||
floatSize: { width: 120, height: 200 },
|
||||
direction: 'rtl',
|
||||
})
|
||||
expect(result.placement).toBe('top-end')
|
||||
// RTL 的 end 反转为 start → 对齐触发器左缘
|
||||
expect(result.x).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computePosition 纯函数性质', () => {
|
||||
it('不修改传入的 rect / floatSize', () => {
|
||||
const rect = trigger()
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* 输入触发器与浮层尺寸、期望方位、偏移量与视口,返回无碰撞的落点坐标。
|
||||
* 纯函数、无副作用,便于单测;组件层只负责取 rect 与写 style。
|
||||
*/
|
||||
import type { Direction } from './direction'
|
||||
|
||||
/** 浮层主方向。 */
|
||||
export type PlacementBase = 'top' | 'bottom' | 'left' | 'right'
|
||||
@@ -77,6 +78,11 @@ export interface ComputePositionOptions {
|
||||
offset?: number
|
||||
/** 视口尺寸,默认取 window 当前尺寸 */
|
||||
viewport?: SizeLike
|
||||
/**
|
||||
* 文本方向。rtl 时镜像 *-start / *-end 的**水平对齐轴**(start ↔ end);
|
||||
* `left` / `right` 作为主方向保持物理语义(与 floating-ui 一致)。
|
||||
*/
|
||||
direction?: Direction
|
||||
}
|
||||
|
||||
export interface ComputePositionResult {
|
||||
@@ -106,6 +112,7 @@ function align(
|
||||
function place(
|
||||
base: PlacementBase,
|
||||
alignment: PlacementAlign,
|
||||
direction: Direction,
|
||||
rect: RectLike,
|
||||
size: SizeLike,
|
||||
offset: number,
|
||||
@@ -113,9 +120,17 @@ function place(
|
||||
let x = 0
|
||||
let y = 0
|
||||
if (base === 'top' || base === 'bottom') {
|
||||
x = align(alignment, rect.left, rect.width, size.width)
|
||||
// 水平对齐轴在 RTL 下镜像 start ↔ end(center 不变)
|
||||
const resolvedAlignment =
|
||||
direction === 'rtl' && alignment !== 'center'
|
||||
? alignment === 'start'
|
||||
? 'end'
|
||||
: 'start'
|
||||
: alignment
|
||||
x = align(resolvedAlignment, rect.left, rect.width, size.width)
|
||||
y = base === 'top' ? rect.top - size.height - offset : rect.bottom + offset
|
||||
} else {
|
||||
// left / right 主方向保持物理语义;垂直对齐轴不受方向影响
|
||||
y = align(alignment, rect.top, rect.height, size.height)
|
||||
x = base === 'left' ? rect.left - size.width - offset : rect.right + offset
|
||||
}
|
||||
@@ -148,15 +163,22 @@ export function getViewport(): SizeLike {
|
||||
* 浮层比视口还大时 shift 失去意义,此时钳制到安全边距内。
|
||||
*/
|
||||
export function computePosition(options: ComputePositionOptions): ComputePositionResult {
|
||||
const { placement, triggerRect, floatSize, offset = 0, viewport = getViewport() } = options
|
||||
const {
|
||||
placement,
|
||||
triggerRect,
|
||||
floatSize,
|
||||
offset = 0,
|
||||
viewport = getViewport(),
|
||||
direction = 'ltr',
|
||||
} = options
|
||||
const { base, alignment } = parsePlacement(placement)
|
||||
const initial = place(base, alignment, triggerRect, floatSize, offset)
|
||||
const initial = place(base, alignment, direction, triggerRect, floatSize, offset)
|
||||
let { x, y } = initial
|
||||
|
||||
let resolvedBase = base
|
||||
if (overflows(x, y, floatSize, viewport)) {
|
||||
const flipped = OPPOSITE[base]
|
||||
const flippedPos = place(flipped, alignment, triggerRect, floatSize, offset)
|
||||
const flippedPos = place(flipped, alignment, direction, triggerRect, floatSize, offset)
|
||||
if (!overflows(flippedPos.x, flippedPos.y, floatSize, viewport)) {
|
||||
resolvedBase = flipped
|
||||
x = flippedPos.x
|
||||
|
||||
@@ -41,18 +41,18 @@ const classes = computed(() => [
|
||||
position: relative;
|
||||
min-height: v('control-height', 32px);
|
||||
border-radius: 0;
|
||||
margin-left: -1px;
|
||||
margin-inline-start: -1px;
|
||||
}
|
||||
|
||||
> .#{b('button')}:first-child {
|
||||
margin-left: 0;
|
||||
border-top-left-radius: v('radius', 6px);
|
||||
border-bottom-left-radius: v('radius', 6px);
|
||||
margin-inline-start: 0;
|
||||
border-start-start-radius: v('radius', 6px);
|
||||
border-end-start-radius: v('radius', 6px);
|
||||
}
|
||||
|
||||
> .#{b('button')}:last-child {
|
||||
border-top-right-radius: v('radius', 6px);
|
||||
border-bottom-right-radius: v('radius', 6px);
|
||||
border-start-end-radius: v('radius', 6px);
|
||||
border-end-end-radius: v('radius', 6px);
|
||||
}
|
||||
|
||||
/* 悬停 / 聚焦时都要浮到上层:相邻按钮靠 -1px 负边距让边框重叠,
|
||||
@@ -69,7 +69,7 @@ const classes = computed(() => [
|
||||
|
||||
> .#{b('button')} {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
margin-inline-start: 0;
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { configProviderKey } from './context'
|
||||
import type { G3ThemeConfig } from './types'
|
||||
import type { Locale } from '../locale'
|
||||
import type { PopupContainer } from '../_utils/global-config'
|
||||
import type { Direction } from '../_utils/direction'
|
||||
|
||||
const EMPTY_VARS: Record<string, string> = {}
|
||||
|
||||
@@ -47,6 +48,7 @@ export default defineComponent({
|
||||
default: undefined,
|
||||
},
|
||||
emptyText: { type: String, default: undefined },
|
||||
direction: { type: String as PropType<Direction>, default: undefined },
|
||||
},
|
||||
setup(props, { slots, attrs }) {
|
||||
// 在 provide 之前调用:useNamespace 读到的 inject 是「父级 ConfigProvider」,用于就近覆盖。
|
||||
@@ -75,6 +77,7 @@ export default defineComponent({
|
||||
popupContainer: computed(() => props.popupContainer),
|
||||
componentDefaults: computed(() => props.componentDefaults),
|
||||
emptyText: computed(() => props.emptyText),
|
||||
direction: computed(() => props.direction),
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
@@ -106,14 +109,22 @@ export default defineComponent({
|
||||
appliedGlobalVars = []
|
||||
})
|
||||
|
||||
// direction 落到子树根:让 [dir='rtl'] 选择器与原生控件(光标 / 滚动条)一起生效。
|
||||
const dirProp = computed(() => (props.direction ? { dir: props.direction } : {}))
|
||||
|
||||
return () => {
|
||||
const children = slots.default?.() ?? []
|
||||
const dir = dirProp.value
|
||||
|
||||
// global 或无主题:不干预 DOM,直接渲染子节点。
|
||||
if (scope.value === 'global' || Object.keys(themeVars.value).length === 0) {
|
||||
// 透传非配置类 attrs 到单个根子节点,尽量不丢用户属性。
|
||||
if (children.length === 1 && Object.keys(attrs).length > 0) {
|
||||
return cloneVNode(children[0], attrs)
|
||||
// 透传非配置类 attrs + dir 到单个根子节点,尽量不丢用户属性。
|
||||
if (children.length === 1 && (Object.keys(attrs).length > 0 || Object.keys(dir).length > 0)) {
|
||||
return cloneVNode(children[0], { ...attrs, ...dir })
|
||||
}
|
||||
// 多根但有 direction:用 display:contents 包装以挂 dir(不产生盒子)
|
||||
if (children.length > 1 && Object.keys(dir).length > 0) {
|
||||
return h('div', { ...dir, style: { display: 'contents' } }, children)
|
||||
}
|
||||
return children
|
||||
}
|
||||
@@ -122,11 +133,11 @@ export default defineComponent({
|
||||
|
||||
// 单根子节点:合并 style,零额外 DOM。
|
||||
if (children.length === 1) {
|
||||
return cloneVNode(children[0], { ...attrs, style: [attrs.style, style] })
|
||||
return cloneVNode(children[0], { ...attrs, ...dir, style: [attrs.style, style] })
|
||||
}
|
||||
|
||||
// 多根子节点:回退到 display:contents 包装(不产生盒子,尽量避免破坏布局)。
|
||||
return h('div', { ...attrs, style: [attrs.style, style, { display: 'contents' }] }, children)
|
||||
return h('div', { ...attrs, ...dir, style: [attrs.style, style, { display: 'contents' }] }, children)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { inject, type ComputedRef, type InjectionKey } from 'vue'
|
||||
import type { Direction } from '../_utils/direction'
|
||||
import type { PopupContainer } from '../_utils/global-config'
|
||||
import type { Locale } from '../locale'
|
||||
|
||||
/** ConfigProvider 通过 provide 下发的上下文。 */
|
||||
export interface ConfigContext {
|
||||
namespace: ComputedRef<string | undefined>
|
||||
direction: ComputedRef<Direction | undefined>
|
||||
locale: ComputedRef<Locale | undefined>
|
||||
zIndex: ComputedRef<number | undefined>
|
||||
popupContainer: ComputedRef<PopupContainer | undefined>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Locale } from '../locale'
|
||||
import type { Direction } from '../_utils/direction'
|
||||
import type { PopupContainer } from '../_utils/global-config'
|
||||
|
||||
export type ThemeScope = 'local' | 'global'
|
||||
@@ -18,6 +19,8 @@ export interface ConfigProviderProps {
|
||||
* 与编译期不一致时 dev 会告警(见 useNamespace)。
|
||||
*/
|
||||
namespace?: string
|
||||
/** 文本方向;不传则跟随宿主(就近 `dir` / `getComputedStyle`),默认 ltr */
|
||||
direction?: Direction
|
||||
/** 主题配置 */
|
||||
theme?: G3ThemeConfig
|
||||
/** 弹层基准层级,默认 2000 */
|
||||
|
||||
@@ -3,6 +3,8 @@ import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from '@lucide/vue'
|
||||
import { useLocale } from '../_composables/useLocale'
|
||||
import { useNamespace } from '../_composables/useNamespace'
|
||||
import { useDirection } from '../_composables/useDirection'
|
||||
import { horizontalArrowStep } from '../_utils/direction'
|
||||
import G3Button from '../button/Button.vue'
|
||||
import G3TimeSelect from './TimeSelect.vue'
|
||||
import {
|
||||
@@ -50,6 +52,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { b, e, m, is } = useNamespace('calendar')
|
||||
const textDirection = useDirection()
|
||||
const locale = useLocale()
|
||||
|
||||
const config = computed(() => props.pickerConfig ?? analyzeDateFormat(DEFAULT_DATE_FORMAT))
|
||||
@@ -272,14 +275,14 @@ function moveFocus(deltaDays: number) {
|
||||
|
||||
function onCellKeydown(cell: Cell, event: KeyboardEvent) {
|
||||
switch (event.key) {
|
||||
// 日期网格在 RTL 下整列镜像(周日列落到最右),左右键要跟着反过来
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowRight': {
|
||||
event.preventDefault()
|
||||
moveFocus(-1)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
event.preventDefault()
|
||||
moveFocus(1)
|
||||
const step = horizontalArrowStep(event.key, textDirection.value)
|
||||
if (step) moveFocus(step)
|
||||
break
|
||||
}
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
moveFocus(-7)
|
||||
@@ -829,8 +832,9 @@ const titleText = computed(() => {
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
gap: v('space-4', 16px);
|
||||
padding-left: v('space-3', 12px);
|
||||
border-left: 1px solid v('border-color', #d9d9d9);
|
||||
/* 逻辑属性:RTL 下时间列落到日历区左侧,分隔线自动跟到那一侧 */
|
||||
padding-inline-start: v('space-3', 12px);
|
||||
border-inline-start: 1px solid v('border-color', #d9d9d9);
|
||||
}
|
||||
|
||||
/* 时间列自身固定高度(= 日历区)。
|
||||
@@ -843,8 +847,8 @@ const titleText = computed(() => {
|
||||
|
||||
/* 纯时间面板(format 只有时分秒):没有左侧日历,去掉分隔线 */
|
||||
.#{b('calendar')}.#{is('time-only')} .#{e('calendar', 'time')} {
|
||||
padding-left: 0;
|
||||
border-left: none;
|
||||
padding-inline-start: 0;
|
||||
border-inline-start: none;
|
||||
}
|
||||
|
||||
/* 范围模式:开始 / 结束两组并排,中间一条分隔线 */
|
||||
@@ -857,7 +861,7 @@ const titleText = computed(() => {
|
||||
.#{b('calendar')}.#{m('calendar', 'range')}
|
||||
.#{e('calendar', 'time-group')}
|
||||
+ .#{e('calendar', 'time-group')} {
|
||||
border-left: 1px solid v('border-color', #d9d9d9);
|
||||
border-inline-start: 1px solid v('border-color', #d9d9d9);
|
||||
}
|
||||
|
||||
.#{e('calendar', 'time-group')} {
|
||||
|
||||
@@ -34,6 +34,7 @@ const props = withDefaults(defineProps<DatePickerProps>(), {
|
||||
disabled: false,
|
||||
disabledDate: undefined,
|
||||
timeDefault: '',
|
||||
placeholder: undefined,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -224,8 +225,8 @@ watch(open, (value) => {
|
||||
type="text"
|
||||
:disabled="disabled"
|
||||
:value="inputText"
|
||||
:placeholder="locale.date.placeholder"
|
||||
:aria-label="locale.date.placeholder"
|
||||
:placeholder="placeholder ?? locale.date.placeholder"
|
||||
:aria-label="placeholder ?? locale.date.placeholder"
|
||||
aria-haspopup="dialog"
|
||||
:aria-expanded="open"
|
||||
:aria-invalid="invalid || undefined"
|
||||
|
||||
@@ -31,6 +31,8 @@ const props = withDefaults(defineProps<RangePickerProps>(), {
|
||||
clearable: true,
|
||||
disabled: false,
|
||||
disabledDate: undefined,
|
||||
startPlaceholder: undefined,
|
||||
endPlaceholder: undefined,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -219,8 +221,8 @@ watch(open, (value) => {
|
||||
type="text"
|
||||
:disabled="disabled"
|
||||
:value="startText"
|
||||
:placeholder="locale.date.startPlaceholder"
|
||||
:aria-label="locale.date.startPlaceholder"
|
||||
:placeholder="startPlaceholder ?? locale.date.startPlaceholder"
|
||||
:aria-label="startPlaceholder ?? locale.date.startPlaceholder"
|
||||
aria-haspopup="dialog"
|
||||
:aria-expanded="open"
|
||||
autocomplete="off"
|
||||
@@ -238,8 +240,8 @@ watch(open, (value) => {
|
||||
type="text"
|
||||
:disabled="disabled"
|
||||
:value="endText"
|
||||
:placeholder="locale.date.endPlaceholder"
|
||||
:aria-label="locale.date.endPlaceholder"
|
||||
:placeholder="endPlaceholder ?? locale.date.endPlaceholder"
|
||||
:aria-label="endPlaceholder ?? locale.date.endPlaceholder"
|
||||
aria-haspopup="dialog"
|
||||
:aria-expanded="open"
|
||||
autocomplete="off"
|
||||
|
||||
@@ -195,7 +195,7 @@ function setColumnRef(key: string, el: unknown) {
|
||||
}
|
||||
|
||||
.#{e('time-select', 'col')} + .#{e('time-select', 'col')} {
|
||||
border-left: 1px solid v('border-color', #d9d9d9);
|
||||
border-inline-start: 1px solid v('border-color', #d9d9d9);
|
||||
}
|
||||
|
||||
/* 当前列高亮:antd 用 JS 记录 active 列,这里用 :hover 近似,不引入额外状态 */
|
||||
@@ -232,7 +232,7 @@ function setColumnRef(key: string, el: unknown) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
/* 数字靠左并对齐到统一缩进(antd 的时间列不是居中的) */
|
||||
padding-inline-start: 14px;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface DatePickerProps {
|
||||
disabledDate?: (date: Date) => boolean
|
||||
/** 无值时的默认时间 */
|
||||
timeDefault?: DateTimeDefault
|
||||
/** 占位文案;不传则取语言包(`date.placeholder`) */
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export interface RangePickerProps {
|
||||
@@ -39,6 +41,10 @@ export interface RangePickerProps {
|
||||
disabled?: boolean
|
||||
/** 禁用日期判定 */
|
||||
disabledDate?: (date: Date) => boolean
|
||||
/** 起始端占位文案;不传则取语言包(`date.startPlaceholder`) */
|
||||
startPlaceholder?: string
|
||||
/** 结束端占位文案;不传则取语言包(`date.endPlaceholder`) */
|
||||
endPlaceholder?: string
|
||||
}
|
||||
|
||||
export interface TimeSelectProps {
|
||||
|
||||
@@ -135,10 +135,10 @@ $cols: 24;
|
||||
}
|
||||
}
|
||||
|
||||
/* offset 左偏移 */
|
||||
/* offset 行内起始偏移(逻辑属性:RTL 下自动变成右侧偏移) */
|
||||
@for $n from 0 through $cols {
|
||||
.#{b('col')}-offset-#{$n} {
|
||||
margin-left: math.percentage(math.div($n, $cols));
|
||||
margin-inline-start: math.percentage(math.div($n, $cols));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,25 +149,26 @@ $cols: 24;
|
||||
}
|
||||
}
|
||||
|
||||
/* push 右移 / pull 左移(相对定位,不脱离文档流) */
|
||||
/* push / pull 沿**行内方向**位移(相对定位,不脱离文档流)。
|
||||
用逻辑属性而非 left/right:RTL 下 push 自动变成往左推,与阅读方向一致。 */
|
||||
@for $n from 0 through $cols {
|
||||
.#{b('col')}-push-#{$n} {
|
||||
@if $n == 0 {
|
||||
position: static;
|
||||
left: auto;
|
||||
inset-inline-start: auto;
|
||||
} @else {
|
||||
position: relative;
|
||||
left: math.percentage(math.div($n, $cols));
|
||||
inset-inline-start: math.percentage(math.div($n, $cols));
|
||||
}
|
||||
}
|
||||
|
||||
.#{b('col')}-pull-#{$n} {
|
||||
@if $n == 0 {
|
||||
position: static;
|
||||
right: auto;
|
||||
inset-inline-end: auto;
|
||||
} @else {
|
||||
position: relative;
|
||||
right: math.percentage(math.div($n, $cols));
|
||||
inset-inline-end: math.percentage(math.div($n, $cols));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { App, Plugin } from 'vue'
|
||||
import { setGlobalConfig, type GlobalConfig } from './_utils/global-config'
|
||||
import { setBaseZIndex } from './_utils/z-index'
|
||||
// RTL 补充样式(方向性图标翻面等),随组件样式一起进 dist/style.css
|
||||
import './_styles/rtl.scss'
|
||||
|
||||
// ---------------- 组件 ----------------
|
||||
import { G3Button } from './button'
|
||||
@@ -67,6 +69,7 @@ export type { ConfigProviderProps, G3ThemeConfig, ThemeScope } from './config-pr
|
||||
export { useNamespace, COMPILE_TIME_NAMESPACE, COMPILE_TIME_STATE_PREFIX } from './_composables/useNamespace'
|
||||
export type { UseNamespaceReturn } from './_composables/useNamespace'
|
||||
export { useLocale } from './_composables/useLocale'
|
||||
export { useDirection } from './_composables/useDirection'
|
||||
export { useComponentDefaults } from './_composables/useComponentDefaults'
|
||||
export { useFloating } from './_composables/useFloating'
|
||||
export type { UseFloatingOptions, UseFloatingReturn } from './_composables/useFloating'
|
||||
@@ -80,6 +83,7 @@ export { usePopupContainer, resolvePopupContainer } from './_composables/usePopu
|
||||
// utils
|
||||
export { getGlobalConfig, setGlobalConfig } from './_utils/global-config'
|
||||
export type { GlobalConfig, PopupContainer } from './_utils/global-config'
|
||||
export type { Direction } from './_utils/direction'
|
||||
export {
|
||||
getBaseZIndex,
|
||||
nextZIndex,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import G3Input from './Input.vue'
|
||||
|
||||
function mountInput(props: Record<string, unknown> = {}) {
|
||||
@@ -59,8 +59,8 @@ describe('Input 单行模式', () => {
|
||||
})
|
||||
|
||||
describe('Input 清除与计数', () => {
|
||||
it('allowClear:聚焦且有值时出现清除按钮,点击后清空并 emit clear', async () => {
|
||||
const wrapper = mountInput({ allowClear: true, modelValue: 'abc' })
|
||||
it('clearable:聚焦且有值时出现清除按钮,点击后清空并 emit clear', async () => {
|
||||
const wrapper = mountInput({ clearable: true, modelValue: 'abc' })
|
||||
expect(wrapper.find('.g3-input__clear').exists()).toBe(false)
|
||||
|
||||
await wrapper.find('input').trigger('focus')
|
||||
@@ -72,6 +72,16 @@ describe('Input 清除与计数', () => {
|
||||
expect(wrapper.emitted('clear')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('allowClear 是兼容别名:仍然生效,且 dev 下提示迁移', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const wrapper = mountInput({ allowClear: true, modelValue: 'abc' })
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('allowClear'))
|
||||
|
||||
await wrapper.find('input').trigger('focus')
|
||||
expect(wrapper.find('.g3-input__clear').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('showCount 显示「当前 / 上限」,无上限时只显示当前', async () => {
|
||||
const withMax = mountInput({ showCount: true, maxlength: 20, modelValue: 'abcd' })
|
||||
expect(withMax.find('.g3-input__count').text()).toBe('4 / 20')
|
||||
@@ -101,7 +111,7 @@ describe('Input 多行模式', () => {
|
||||
})
|
||||
|
||||
it('多行模式不渲染单行的清除按钮与前缀插槽位置', () => {
|
||||
const wrapper = mountInput({ type: 'textarea', allowClear: true, modelValue: 'abc' })
|
||||
const wrapper = mountInput({ type: 'textarea', clearable: true, modelValue: 'abc' })
|
||||
expect(wrapper.find('.g3-input__clear').exists()).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ const props = withDefaults(defineProps<InputProps>(), {
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
status: '',
|
||||
allowClear: false,
|
||||
// clearable / allowClear 都不给默认值:要区分「没传」与「传了 false」才能提示迁移
|
||||
clearable: undefined,
|
||||
allowClear: undefined,
|
||||
showCount: false,
|
||||
maxlength: undefined,
|
||||
rows: 3,
|
||||
@@ -26,6 +28,13 @@ const props = withDefaults(defineProps<InputProps>(), {
|
||||
autocomplete: undefined,
|
||||
})
|
||||
|
||||
if (import.meta.env?.DEV && props.allowClear !== undefined && props.clearable === undefined) {
|
||||
console.warn('[g3soft/ui] G3Input 的 `allowClear` 已废弃,请改用 `clearable`(与 Select / DatePicker 统一)。')
|
||||
}
|
||||
|
||||
/** 清除能力:`clearable` 为准,`allowClear` 是兼容旧代码的别名 */
|
||||
const clearable = computed(() => props.clearable ?? props.allowClear ?? false)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number]
|
||||
change: [value: string | number, ev: Event]
|
||||
@@ -69,7 +78,7 @@ const inputValue = computed(() =>
|
||||
const hasValue = computed(() => String(inputValue.value ?? '').length > 0)
|
||||
const showClear = computed(
|
||||
() =>
|
||||
props.allowClear &&
|
||||
clearable.value &&
|
||||
!props.disabled &&
|
||||
!props.readonly &&
|
||||
hasValue.value &&
|
||||
@@ -544,7 +553,7 @@ defineExpose({
|
||||
|
||||
.#{e('input', 'footer')} {
|
||||
position: absolute;
|
||||
right: v('space-1', 4px);
|
||||
inset-inline-end: v('space-1', 4px);
|
||||
bottom: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -16,6 +16,12 @@ export interface InputProps {
|
||||
/** 校验状态 */
|
||||
status?: InputStatus
|
||||
/** 有值时展示清除按钮(hover / 聚焦时出现) */
|
||||
clearable?: boolean
|
||||
/**
|
||||
* @deprecated 已统一为 `clearable`(与 Select / DatePicker 一致)。
|
||||
* 保留只为不打断既有调用方 —— Vue 会静默吞掉未声明的 prop,直接删掉会让消费方的
|
||||
* 清除按钮无声消失。传它时 dev 环境会打印迁移提示;两者同时传以 `clearable` 为准。
|
||||
*/
|
||||
allowClear?: boolean
|
||||
/** 展示字数统计(配合 maxlength 显示 当前 / 上限) */
|
||||
showCount?: boolean
|
||||
|
||||
@@ -85,9 +85,16 @@ function update() {
|
||||
horizontal.overflow = scrollWidth - clientWidth > 1
|
||||
horizontal.size = horizontal.overflow ? Math.max((clientWidth / scrollWidth) * clientWidth, 20) : 0
|
||||
const hRange = clientWidth - horizontal.size
|
||||
// RTL 下原生 scrollLeft 的约定是「0 = 行内起始(右端),向左滚动为负」,
|
||||
// 直接按比例换算会得到负偏移、滑块跑出轨道。RTL 用「离行内起始的距离」换算:
|
||||
// 起始时滑块贴右端(offset = hRange),滚到末尾回到左端(offset = 0)。
|
||||
const hMax = scrollWidth - clientWidth
|
||||
const rtl = getComputedStyle(el).direction === 'rtl'
|
||||
horizontal.offset =
|
||||
horizontal.overflow && hRange > 0
|
||||
? (scrollLeft / (scrollWidth - clientWidth)) * hRange
|
||||
? rtl
|
||||
? hRange * (1 - Math.abs(scrollLeft) / hMax)
|
||||
: (scrollLeft / hMax) * hRange
|
||||
: 0
|
||||
}
|
||||
|
||||
@@ -293,7 +300,8 @@ defineExpose({
|
||||
|
||||
.#{e('scrollbar', 'track-v')} {
|
||||
top: 0;
|
||||
right: 0;
|
||||
/* 竖直轨道贴行内结束边:LTR 在右、RTL 在左(与原生滚动条行为一致) */
|
||||
inset-inline-end: 0;
|
||||
width: v('scrollbar-size', 8px);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type VNode,
|
||||
} from 'vue'
|
||||
import { useNamespace } from '../_composables/useNamespace'
|
||||
import { useDirection } from '../_composables/useDirection'
|
||||
import type { SegmentedOptionInput, SegmentedProps, SegmentedValue } from './types'
|
||||
|
||||
/**
|
||||
@@ -133,7 +134,13 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
// 选中项、选项集或排列方向变化时重新测量;v-for 更新在 nextTick 后才落到 DOM
|
||||
watch([currentValue, () => props.options, () => props.vertical], () => nextTick(measureThumb))
|
||||
// 方向变化也要重测:滑块的位移是 offsetLeft(物理像素),RTL 下同一项的 offsetLeft 不同;
|
||||
// ResizeObserver 只在尺寸变化时触发,光切方向容器尺寸不变,不重测就会停在旧位置。
|
||||
const direction = useDirection()
|
||||
watch(
|
||||
[currentValue, () => props.options, () => props.vertical, direction],
|
||||
() => nextTick(measureThumb),
|
||||
)
|
||||
|
||||
const classes = computed(() => [
|
||||
b(),
|
||||
|
||||
@@ -23,6 +23,7 @@ const props = withDefaults(defineProps<SelectProps>(), {
|
||||
filterOption: undefined,
|
||||
remote: false,
|
||||
loading: false,
|
||||
placeholder: undefined,
|
||||
notFoundText: undefined,
|
||||
allowFreeInput: false,
|
||||
labelKey: 'label',
|
||||
@@ -516,6 +517,7 @@ defineExpose({
|
||||
:disabled="disabled"
|
||||
:readonly="!filterable && !allowFreeInput"
|
||||
:value="inputDisplay"
|
||||
:placeholder="placeholder ?? locale.common.selectPlaceholder"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
role="combobox"
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface SelectProps {
|
||||
remote?: boolean
|
||||
/** 远程数据加载状态;开启时右侧仅展示加载图标 */
|
||||
loading?: boolean
|
||||
/** 无值时的占位文案;不传则取语言包(`common.selectPlaceholder`) */
|
||||
placeholder?: string
|
||||
/** 无匹配项时的文案;不传则取语言包 */
|
||||
notFoundText?: string
|
||||
/** 是否允许自由输入:true 时可输入任意文本并作为值 */
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { cloneVNode, computed, onBeforeUnmount, onMounted, ref, useSlots, type VNode } from 'vue'
|
||||
import { useNamespace } from '../_composables/useNamespace'
|
||||
import { useDirection } from '../_composables/useDirection'
|
||||
import { horizontalArrowStep } from '../_utils/direction'
|
||||
import G3SplitterPanel from './Panel.vue'
|
||||
import type { SplitterPanelProps, SplitterProps, SplitterSize } from './types'
|
||||
|
||||
@@ -25,6 +27,8 @@ const emit = defineEmits<{
|
||||
|
||||
const slots = useSlots()
|
||||
const { b, e, m, is } = useNamespace('splitter')
|
||||
// 命名避开 props.direction(那是分割方向),否则会在模板里遮蔽它
|
||||
const textDirection = useDirection()
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const activeBar = ref(-1)
|
||||
@@ -231,9 +235,12 @@ function onBarKeydown(index: number, event: KeyboardEvent, items: Array<{ props:
|
||||
let delta = 0
|
||||
if (vertical && event.key === 'ArrowUp') delta = -step
|
||||
else if (vertical && event.key === 'ArrowDown') delta = step
|
||||
else if (!vertical && event.key === 'ArrowLeft') delta = -step
|
||||
else if (!vertical && event.key === 'ArrowRight') delta = step
|
||||
else return
|
||||
else if (!vertical) {
|
||||
// 水平分割条:面板从行内起始边开始排,RTL 下左右键语义反转
|
||||
const sign = horizontalArrowStep(event.key, textDirection.value)
|
||||
if (!sign) return
|
||||
delta = sign * step
|
||||
} else return
|
||||
if (!canResize(index, items)) return
|
||||
event.preventDefault()
|
||||
const container = measureContainer()
|
||||
|
||||
@@ -90,7 +90,8 @@ function toggle(event: MouseEvent) {
|
||||
.#{e('switch', 'thumb')} {
|
||||
position: absolute;
|
||||
top: v('switch-thumb-offset', 2px);
|
||||
left: v('switch-thumb-offset', 2px);
|
||||
/* 逻辑属性:RTL 下滑块从右侧起步 */
|
||||
inset-inline-start: v('switch-thumb-offset', 2px);
|
||||
width: v('switch-thumb-size', 14px);
|
||||
height: v('switch-thumb-size', 14px);
|
||||
border-radius: 50%;
|
||||
@@ -104,10 +105,13 @@ function toggle(event: MouseEvent) {
|
||||
}
|
||||
|
||||
.#{b('switch')}.#{is('checked')} .#{e('switch', 'thumb')} {
|
||||
/* 乘 --g3-dir-sign(LTR 1 / RTL -1):RTL 下选中态滑块向左移动 */
|
||||
transform: translateX(
|
||||
calc(
|
||||
#{v('switch-width', 32px)} - #{v('switch-thumb-size', 14px)} - #{v('switch-thumb-offset', 2px)} *
|
||||
2
|
||||
#{v('dir-sign', 1)} * (
|
||||
#{v('switch-width', 32px)} - #{v('switch-thumb-size', 14px)} - #{v('switch-thumb-offset', 2px)} *
|
||||
2
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { setGlobalConfig } from '../_utils/global-config'
|
||||
import G3TabPane from './TabPane.vue'
|
||||
import G3Tabs from './Tabs.vue'
|
||||
|
||||
@@ -97,6 +98,53 @@ describe('Tabs 渲染', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 三个可用标签:只有 ≥3 项时「前进 / 后退」才区分得开
|
||||
* (两项时左右键的结果都是另一个,测不出方向)。
|
||||
*/
|
||||
function mountThreeTabs() {
|
||||
return mount({
|
||||
components: { G3Tabs, G3TabPane },
|
||||
data() {
|
||||
return { active: 'a' }
|
||||
},
|
||||
template: `
|
||||
<G3Tabs v-model="active">
|
||||
<G3TabPane tab-key="a" label="一"><p class="pane-a">A</p></G3TabPane>
|
||||
<G3TabPane tab-key="b" label="二"><p class="pane-b">B</p></G3TabPane>
|
||||
<G3TabPane tab-key="c" label="三"><p class="pane-c">C</p></G3TabPane>
|
||||
</G3Tabs>
|
||||
`,
|
||||
})
|
||||
}
|
||||
|
||||
describe('Tabs 键盘导航的方向语义', () => {
|
||||
it('RTL:ArrowLeft 前进、ArrowRight 后退(与视觉顺序一致)', async () => {
|
||||
// 方向必须在挂载前设好:useDirection 读的是全局单例,它不是响应式的
|
||||
setGlobalConfig({ direction: 'rtl' })
|
||||
try {
|
||||
const wrapper = mountThreeTabs()
|
||||
const list = wrapper.find('.g3-tabs__nav-list')
|
||||
|
||||
await list.trigger('keydown', { key: 'ArrowLeft' })
|
||||
expect(wrapper.find('.pane-b').isVisible()).toBe(true)
|
||||
|
||||
await list.trigger('keydown', { key: 'ArrowRight' })
|
||||
expect(wrapper.find('.pane-a').isVisible()).toBe(true)
|
||||
} finally {
|
||||
setGlobalConfig({ direction: undefined })
|
||||
}
|
||||
})
|
||||
|
||||
it('LTR(默认)语义不变', async () => {
|
||||
const wrapper = mountThreeTabs()
|
||||
const list = wrapper.find('.g3-tabs__nav-list')
|
||||
|
||||
await list.trigger('keydown', { key: 'ArrowRight' })
|
||||
expect(wrapper.find('.pane-b').isVisible()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tabs items 数据源', () => {
|
||||
it('提供 items 时优先于声明式子组件', async () => {
|
||||
const wrapper = mount(G3Tabs, {
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
type VNode,
|
||||
} from 'vue'
|
||||
import { useNamespace } from '../_composables/useNamespace'
|
||||
import { useDirection } from '../_composables/useDirection'
|
||||
import { horizontalArrowStep } from '../_utils/direction'
|
||||
import G3TabPane from './TabPane.vue'
|
||||
import type { TabItem, TabKey, TabsProps } from './types'
|
||||
|
||||
@@ -41,6 +43,7 @@ const emit = defineEmits<{
|
||||
|
||||
const slots = useSlots()
|
||||
const { b, e, m, is } = useNamespace('tabs')
|
||||
const textDirection = useDirection()
|
||||
|
||||
const baseId = useId()
|
||||
const innerKey = ref<string | undefined>(undefined)
|
||||
@@ -185,14 +188,16 @@ function onNavKeydown(event: KeyboardEvent) {
|
||||
const horizontal = props.placement === 'top'
|
||||
let next = -1
|
||||
switch (event.key) {
|
||||
// 左右键按「行内方向」解释:RTL 下屏幕左侧才是「下一个」,
|
||||
// 否则键盘走向与视觉走向相反。
|
||||
case 'ArrowRight':
|
||||
case 'ArrowLeft': {
|
||||
if (!horizontal) return
|
||||
next = (pos + 1) % keys.length
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
if (!horizontal) return
|
||||
next = (pos - 1 + keys.length) % keys.length
|
||||
const step = horizontalArrowStep(event.key, textDirection.value)
|
||||
if (!step) return
|
||||
next = (pos + step + keys.length) % keys.length
|
||||
break
|
||||
}
|
||||
case 'ArrowDown':
|
||||
if (horizontal) return
|
||||
next = (pos + 1) % keys.length
|
||||
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
watch,
|
||||
type VNode,
|
||||
} from 'vue'
|
||||
import { useDirection } from '../_composables/useDirection'
|
||||
import { useLocale } from '../_composables/useLocale'
|
||||
import { useNamespace } from '../_composables/useNamespace'
|
||||
import { usePopupContainer } from '../_composables/usePopupContainer'
|
||||
import { horizontalArrowStep } from '../_utils/direction'
|
||||
import { computePosition, type RectLike } from '../_utils/position'
|
||||
import { nextZIndex, releaseZIndex } from '../_utils/z-index'
|
||||
import { treeContextKey } from './context'
|
||||
@@ -92,6 +94,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { b, e, is } = useNamespace('tree')
|
||||
const textDirection = useDirection()
|
||||
const locale = useLocale()
|
||||
const teleportTo = usePopupContainer()
|
||||
|
||||
@@ -437,28 +440,31 @@ function onKeydown(event: KeyboardEvent) {
|
||||
moveActive(-1)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
event.preventDefault()
|
||||
if (!effectiveExpanded.value.has(key)) toggleExpand(node, key)
|
||||
else {
|
||||
const children = getNodeChildren(node, fieldNames.value)
|
||||
const firstChild = children?.[0]
|
||||
if (firstChild) {
|
||||
const childKey = String(getNodeKey(firstChild, fieldNames.value))
|
||||
activeKey.value = childKey
|
||||
nextTick(() => nodeEls.get(childKey)?.focus())
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'ArrowLeft': {
|
||||
event.preventDefault()
|
||||
if (effectiveExpanded.value.has(key)) {
|
||||
toggleExpand(node, key)
|
||||
break
|
||||
}
|
||||
const parent = flat.value.parentMap.get(key)
|
||||
if (parent) {
|
||||
activeKey.value = parent
|
||||
nextTick(() => nodeEls.get(parent)?.focus())
|
||||
// 朝行内结束方向(LTR 右 / RTL 左)= 展开或进第一个子节点;反向 = 收起或回父节点。
|
||||
// 树的缩进与展开箭头都跟着方向走,键盘语义必须与画面一致。
|
||||
if (horizontalArrowStep(event.key, textDirection.value) === 1) {
|
||||
if (!effectiveExpanded.value.has(key)) toggleExpand(node, key)
|
||||
else {
|
||||
const children = getNodeChildren(node, fieldNames.value)
|
||||
const firstChild = children?.[0]
|
||||
if (firstChild) {
|
||||
const childKey = String(getNodeKey(firstChild, fieldNames.value))
|
||||
activeKey.value = childKey
|
||||
nextTick(() => nodeEls.get(childKey)?.focus())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (effectiveExpanded.value.has(key)) {
|
||||
toggleExpand(node, key)
|
||||
break
|
||||
}
|
||||
const parent = flat.value.parentMap.get(key)
|
||||
if (parent) {
|
||||
activeKey.value = parent
|
||||
nextTick(() => nodeEls.get(parent)?.focus())
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@@ -265,11 +265,12 @@ const VNodeRenderer = (renderProps: { node: VNode }) => renderProps.node
|
||||
gap: v('space-1', 4px);
|
||||
box-sizing: border-box;
|
||||
min-height: v('tree-node-height', 28px);
|
||||
/* 缩进 = 基础留白 + 每层缩进量 × 层级(层级由行内 CSS 变量下发) */
|
||||
padding-left: calc(
|
||||
/* 缩进 = 基础留白 + 每层缩进量 × 层级(层级由行内 CSS 变量下发)。
|
||||
用逻辑属性:RTL 下缩进自动落到右侧、箭头列跟着镜像。 */
|
||||
padding-inline-start: calc(
|
||||
#{v('tree-gutter-x', 4px)} + #{v('tree-indent', 18px)} * var(--g3-tree-level, 0)
|
||||
);
|
||||
padding-right: v('tree-gutter-x', 4px);
|
||||
padding-inline-end: v('tree-gutter-x', 4px);
|
||||
border-radius: v('radius-sm', 4px);
|
||||
color: v('text-1', rgba(0, 0, 0, 0.88));
|
||||
font-size: v('font-size', 14px);
|
||||
@@ -311,8 +312,10 @@ const VNodeRenderer = (renderProps: { node: VNode }) => renderProps.node
|
||||
.#{b('tree-node')}.#{is('drop-after')}::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: calc(#{v('tree-gutter-x', 4px)} + #{v('tree-indent', 18px)} * var(--g3-tree-level, 0));
|
||||
right: 0;
|
||||
inset-inline-start: calc(
|
||||
#{v('tree-gutter-x', 4px)} + #{v('tree-indent', 18px)} * var(--g3-tree-level, 0)
|
||||
);
|
||||
inset-inline-end: 0;
|
||||
height: v('tree-drop-line-size', 2px);
|
||||
background: v('color-primary', #18181b);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user