This commit is contained in:
oneao committed 2026-10-09 22:05:02 +08:00
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, {
+10 -5
View File
@@ -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
+26 -20
View File
@@ -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);
}