修改树的显示

This commit is contained in:
Qiubo Huang 2026-03-01 11:45:27 +08:00
parent bbcb734f72
commit d4b1e41171
1 changed files with 351 additions and 27 deletions

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { import {
Folder, Folder,
FolderOpen, FolderOpen,
@ -624,8 +624,15 @@ watch(mockTreeData, (newData) => {
}, { deep: true, immediate: true }) }, { deep: true, immediate: true })
const selectedId = ref<string | null>('1') const selectedId = ref<string | null>('1')
const focusedId = ref<string | null>('1')
const expandedIds = ref<Set<string>>(new Set()) const expandedIds = ref<Set<string>>(new Set())
const dialogExpandedIds = ref<Set<string>>(new Set()) const dialogExpandedIds = ref<Set<string>>(new Set())
const treeRootRef = ref<HTMLElement | null>(null)
const contextMenuRef = ref<HTMLElement | null>(null)
const addNodeDialogRef = ref<HTMLElement | null>(null)
const operationDialogRef = ref<HTMLElement | null>(null)
const liveMessage = ref('')
const contextMenuTriggerId = ref<string | null>(null)
const contextMenu = ref({ const contextMenu = ref({
show: false, show: false,
@ -818,8 +825,17 @@ const handleToggle = (id: string, isDialog = false) => {
const targetSet = isDialog ? dialogExpandedIds : expandedIds const targetSet = isDialog ? dialogExpandedIds : expandedIds
if (targetSet.value.has(id)) { if (targetSet.value.has(id)) {
targetSet.value.delete(id) targetSet.value.delete(id)
if (!isDialog && focusedId.value && focusedId.value !== id && isDescendantId(id, focusedId.value)) {
setActiveNode(id, true, true)
}
if (!isDialog) {
announce('Node collapsed')
}
} else { } else {
targetSet.value.add(id) targetSet.value.add(id)
if (!isDialog) {
announce('Node expanded')
}
} }
} }
@ -830,6 +846,7 @@ const handleDoubleClick = (id: string, node: TreeNode) => {
} }
const handleSelect = (id: string, node: TreeNode) => { const handleSelect = (id: string, node: TreeNode) => {
focusedId.value = id
selectedId.value = id selectedId.value = id
emit('update:modelValue', id) emit('update:modelValue', id)
emit('select', node) emit('select', node)
@ -846,11 +863,13 @@ const expandAll = () => {
}) })
} }
expandNode(mockTreeData.value) expandNode(mockTreeData.value)
announce('All nodes expanded')
} }
// //
const collapseAll = () => { const collapseAll = () => {
expandedIds.value.clear() expandedIds.value.clear()
announce('All nodes collapsed')
} }
const isExpanded = (id: string, isDialog = false) => { const isExpanded = (id: string, isDialog = false) => {
@ -858,6 +877,202 @@ const isExpanded = (id: string, isDialog = false) => {
return targetSet.value.has(id) return targetSet.value.has(id)
} }
const isSelected = (id: string) => selectedId.value === id const isSelected = (id: string) => selectedId.value === id
const getNodeById = (id: string) => findNode(mockTreeData.value, id)
const getVisibleNodeIndex = (id: string) => flattenedNodes.value.findIndex(item => item.node.id === id)
const getNodeElement = (id: string): HTMLElement | null => {
if (!treeRootRef.value) return null
const safeId = id.replace(/"/g, '\\"')
return treeRootRef.value.querySelector(`[data-node-id="${safeId}"]`) as HTMLElement | null
}
const focusNodeElement = (id: string) => {
nextTick(() => {
getNodeElement(id)?.focus()
})
}
const setActiveNode = (id: string, select = true, focus = false) => {
const node = getNodeById(id)
if (!node) return
focusedId.value = id
if (select) {
handleSelect(id, node)
}
if (focus) {
focusNodeElement(id)
}
}
const isTabStop = (id: string, index: number) => {
if (focusedId.value) return focusedId.value === id
return index === 0
}
const moveFocusByOffset = (offset: number) => {
if (flattenedNodes.value.length === 0) return
const currentId = focusedId.value || selectedId.value || flattenedNodes.value[0].node.id
const currentIndex = getVisibleNodeIndex(currentId)
const fallbackIndex = currentIndex < 0 ? 0 : currentIndex
const nextIndex = Math.min(flattenedNodes.value.length - 1, Math.max(0, fallbackIndex + offset))
const nextNode = flattenedNodes.value[nextIndex]?.node
if (nextNode) {
setActiveNode(nextNode.id, true, true)
}
}
const focusFirstVisibleNode = () => {
const first = flattenedNodes.value[0]?.node
if (first) setActiveNode(first.id, true, true)
}
const focusLastVisibleNode = () => {
const last = flattenedNodes.value[flattenedNodes.value.length - 1]?.node
if (last) setActiveNode(last.id, true, true)
}
const isDescendantId = (ancestorId: string, nodeId: string) => {
if (ancestorId === nodeId) return true
let currentParent = findParentNode(mockTreeData.value, nodeId)
while (currentParent) {
if (currentParent.id === ancestorId) return true
currentParent = findParentNode(mockTreeData.value, currentParent.id)
}
return false
}
const announce = (message: string) => {
liveMessage.value = ''
nextTick(() => {
liveMessage.value = message
})
}
const openContextMenuFromKeyboard = (nodeId: string) => {
const targetEl = getNodeElement(nodeId)
const rect = targetEl?.getBoundingClientRect()
const x = rect ? Math.min(rect.left + 24, window.innerWidth - 200) : 24
const y = rect ? Math.min(rect.bottom, window.innerHeight - 320) : 24
contextMenuTriggerId.value = nodeId
contextMenu.value = {
show: true,
x,
y,
nodeId
}
}
const focusFirstMenuItem = () => {
const item = contextMenuRef.value?.querySelector('.menu-item[tabindex="0"]') as HTMLElement | null
item?.focus()
}
const focusNodeAfterContextMenu = () => {
if (!contextMenuTriggerId.value) return
const triggerId = contextMenuTriggerId.value
contextMenuTriggerId.value = null
focusNodeElement(triggerId)
}
const handleMenuKeydown = (event: KeyboardEvent) => {
if (!contextMenu.value.show || !contextMenuRef.value) return
const items = Array.from(contextMenuRef.value.querySelectorAll('.menu-item[tabindex="0"]')) as HTMLElement[]
if (items.length === 0) return
const currentIndex = items.findIndex(item => item === document.activeElement)
if (event.key === 'ArrowDown') {
event.preventDefault()
const next = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length
items[next].focus()
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
const prev = currentIndex < 0 ? items.length - 1 : (currentIndex - 1 + items.length) % items.length
items[prev].focus()
return
}
if (event.key === 'Home') {
event.preventDefault()
items[0].focus()
return
}
if (event.key === 'End') {
event.preventDefault()
items[items.length - 1].focus()
return
}
if (event.key === 'Escape') {
event.preventDefault()
closeContextMenu()
return
}
if ((event.key === 'Enter' || event.key === ' ') && document.activeElement instanceof HTMLElement) {
event.preventDefault()
document.activeElement.click()
}
}
const handleNodeFocus = (id: string, node: TreeNode) => {
focusedId.value = id
if (!isSelected(id)) {
handleSelect(id, node)
}
}
const handleTreeKeydown = (event: KeyboardEvent, node: TreeNode, level: number) => {
const { key } = event
if (key === 'ArrowDown') {
event.preventDefault()
moveFocusByOffset(1)
return
}
if (key === 'ArrowUp') {
event.preventDefault()
moveFocusByOffset(-1)
return
}
if (key === 'Home') {
event.preventDefault()
focusFirstVisibleNode()
return
}
if (key === 'End') {
event.preventDefault()
focusLastVisibleNode()
return
}
if (key === 'ArrowRight') {
event.preventDefault()
if (hasChildren(node)) {
if (!isExpanded(node.id)) {
handleToggle(node.id)
} else {
const currentIndex = getVisibleNodeIndex(node.id)
const nextNode = flattenedNodes.value[currentIndex + 1]
if (nextNode && nextNode.level === level + 1) {
setActiveNode(nextNode.node.id, true, true)
}
}
}
return
}
if (key === 'ArrowLeft') {
event.preventDefault()
if (hasChildren(node) && isExpanded(node.id)) {
handleToggle(node.id)
} else {
const parentNode = findParentNode(mockTreeData.value, node.id)
if (parentNode) {
setActiveNode(parentNode.id, true, true)
}
}
return
}
if (key === 'Enter') {
event.preventDefault()
handleSelect(node.id, node)
return
}
if (key === ' ') {
event.preventDefault()
if (hasChildren(node)) {
handleToggle(node.id)
} else {
handleSelect(node.id, node)
}
return
}
if (key === 'ContextMenu' || (key === 'F10' && event.shiftKey)) {
event.preventDefault()
handleSelect(node.id, node)
openContextMenuFromKeyboard(node.id)
}
}
const hasChildren = (node: TreeNode) => node.children && node.children.length > 0 const hasChildren = (node: TreeNode) => node.children && node.children.length > 0
// isTerminal // isTerminal
@ -896,10 +1111,15 @@ const handleContextMenu = (e: MouseEvent, nodeId: string) => {
y, y,
nodeId nodeId
} }
contextMenuTriggerId.value = nodeId
} }
const closeContextMenu = () => { const closeContextMenu = (restoreFocus = true) => {
const wasOpen = contextMenu.value.show
contextMenu.value.show = false contextMenu.value.show = false
if (wasOpen && restoreFocus) {
focusNodeAfterContextMenu()
}
} }
const openOperationDialog = (mode: 'copy' | 'clone' | 'branch') => { const openOperationDialog = (mode: 'copy' | 'clone' | 'branch') => {
@ -914,7 +1134,7 @@ const openOperationDialog = (mode: 'copy' | 'clone' | 'branch') => {
owner: currentNode?.owner || 'admin' owner: currentNode?.owner || 'admin'
} }
dialogExpandedIds.value = new Set(expandedIds.value) dialogExpandedIds.value = new Set(expandedIds.value)
closeContextMenu() closeContextMenu(false)
} }
const handleDelete = () => { const handleDelete = () => {
@ -929,8 +1149,15 @@ const handleDelete = () => {
if (selectedId.value === nodeId) { if (selectedId.value === nodeId) {
selectedId.value = null selectedId.value = null
} }
const fallbackId = selectedId.value && getNodeById(selectedId.value)
? selectedId.value
: flattenedNodes.value[0]?.node.id
if (fallbackId) {
setActiveNode(fallbackId, true, true)
}
contextMenuTriggerId.value = null
} }
closeContextMenu() closeContextMenu(false)
} }
// //
@ -959,7 +1186,7 @@ const handleAddSibling = () => {
} }
console.log('addNodeDialog set to show') console.log('addNodeDialog set to show')
closeContextMenu() closeContextMenu(false)
} }
// //
@ -983,7 +1210,7 @@ const handleAddChild = () => {
} }
console.log('addNodeDialog set to show') console.log('addNodeDialog set to show')
closeContextMenu() closeContextMenu(false)
} }
// //
@ -1293,6 +1520,49 @@ onMounted(() => {
initializeReviewHistory() initializeReviewHistory()
}) })
watch(() => contextMenu.value.show, (show) => {
if (show) {
nextTick(() => {
contextMenuRef.value?.focus()
focusFirstMenuItem()
})
}
})
watch(() => addNodeDialog.value.show, (show) => {
if (show) {
nextTick(() => {
addNodeDialogRef.value?.focus()
})
} else if (contextMenuTriggerId.value) {
focusNodeAfterContextMenu()
}
})
watch(() => operationDialog.value.show, (show) => {
if (show) {
nextTick(() => {
operationDialogRef.value?.focus()
})
} else if (contextMenuTriggerId.value) {
focusNodeAfterContextMenu()
}
})
watch(flattenedNodes, (nodes) => {
if (nodes.length === 0) {
focusedId.value = null
selectedId.value = null
return
}
if (focusedId.value && !nodes.some(item => item.node.id === focusedId.value)) {
focusedId.value = nodes[0].node.id
}
if (selectedId.value && !nodes.some(item => item.node.id === selectedId.value)) {
selectedId.value = focusedId.value || nodes[0].node.id
}
}, { immediate: true })
// //
defineExpose({ defineExpose({
updateNodeContent updateNodeContent
@ -1309,15 +1579,15 @@ defineExpose({
<span>Vehicle level - Default(modified)</span> <span>Vehicle level - Default(modified)</span>
</div> </div>
<div class="tree-header-actions"> <div class="tree-header-actions">
<button class="tree-header-btn" title="Expand All" @click="expandAll"> <button class="tree-header-btn" title="Expand All" aria-label="Expand all nodes" @click="expandAll">
<Plus :size="12" /> <Plus :size="12" />
</button> </button>
<button class="tree-header-btn" title="Collapse All" @click="collapseAll"> <button class="tree-header-btn" title="Collapse All" aria-label="Collapse all nodes" @click="collapseAll">
<Minus :size="12" /> <Minus :size="12" />
</button> </button>
</div> </div>
</div> </div>
<div class="tree-content"> <div ref="treeRootRef" class="tree-content" role="tree" aria-label="Document tree">
<div class="tree-column-headers"> <div class="tree-column-headers">
<span class="tree-col-name">Name</span> <span class="tree-col-name">Name</span>
<span class="tree-col-status">Status</span> <span class="tree-col-status">Status</span>
@ -1325,13 +1595,21 @@ defineExpose({
</div> </div>
<transition-group name="tree-row" tag="div" class="tree-nodes"> <transition-group name="tree-row" tag="div" class="tree-nodes">
<div <div
v-for="{ node, level } in flattenedNodes" v-for="({ node, level }, index) in flattenedNodes"
:key="node.id" :key="node.id"
:class="['tree-node-wrapper', { 'has-children': hasChildren(node) }]" :class="['tree-node-wrapper', { 'has-children': hasChildren(node) }]"
> >
<div <div
:class="['tree-node', { selected: isSelected(node.id) }]" :class="['tree-node', { selected: isSelected(node.id) }]"
:data-node-id="node.id"
role="treeitem"
:aria-level="level + 1"
:aria-selected="isSelected(node.id)"
:aria-expanded="hasChildren(node) ? isExpanded(node.id) : undefined"
:tabindex="isTabStop(node.id, index) ? 0 : -1"
@click="handleSelect(node.id, node)" @click="handleSelect(node.id, node)"
@focus="handleNodeFocus(node.id, node)"
@keydown="handleTreeKeydown($event, node, level)"
@dblclick="handleDoubleClick(node.id, node)" @dblclick="handleDoubleClick(node.id, node)"
@contextmenu="handleContextMenu($event, node.id)" @contextmenu="handleContextMenu($event, node.id)"
> >
@ -1339,11 +1617,18 @@ defineExpose({
class="tree-node-main" class="tree-node-main"
:style="{ paddingLeft: `${level * 16 + 4}px` }" :style="{ paddingLeft: `${level * 16 + 4}px` }"
> >
<span class="tree-node-toggle" @click.stop="handleToggle(node.id)"> <button
v-if="hasChildren(node)"
type="button"
class="tree-node-toggle"
:aria-label="isExpanded(node.id) ? 'Collapse node' : 'Expand node'"
:aria-expanded="isExpanded(node.id)"
@click.stop="handleToggle(node.id)"
>
<Minus v-if="hasChildren(node) && isExpanded(node.id)" :size="12" /> <Minus v-if="hasChildren(node) && isExpanded(node.id)" :size="12" />
<Plus v-else-if="hasChildren(node)" :size="12" /> <Plus v-else-if="hasChildren(node)" :size="12" />
<span v-else class="tree-node-spacer" /> </button>
</span> <span v-else class="tree-node-spacer" />
<span class="tree-node-icon"> <span class="tree-node-icon">
<Link v-if="isMappedNode(node)" :size="14" class="icon-mapped" /> <Link v-if="isMappedNode(node)" :size="14" class="icon-mapped" />
<FileText v-else-if="isTerminalNode(node)" :size="14" class="icon-terminal" /> <FileText v-else-if="isTerminalNode(node)" :size="14" class="icon-terminal" />
@ -1384,44 +1669,48 @@ defineExpose({
</div> </div>
<!-- 右键菜单 --> <!-- 右键菜单 -->
<div v-if="contextMenu.show" class="context-menu" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }" <div v-if="contextMenu.show" ref="contextMenuRef" class="context-menu" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
@click.stop> role="menu" tabindex="-1" @keydown="handleMenuKeydown" @click.stop>
<div class="menu-group"> <div class="menu-group">
<div class="menu-item" @click="handleAddSibling"> <div class="menu-item" role="menuitem" tabindex="0" @click="handleAddSibling">
<CornerUpLeft :size="14" /> <CornerUpLeft :size="14" />
<span>添加同级节点</span> <span>添加同级节点</span>
</div> </div>
<div class="menu-item" @click="handleAddChild"> <div class="menu-item" role="menuitem" tabindex="0" @click="handleAddChild">
<CornerDownLeft :size="14" /> <CornerDownLeft :size="14" />
<span>添加下级节点</span> <span>添加下级节点</span>
</div> </div>
<div class="menu-item delete" @click="handleDelete"> <div class="menu-item delete" role="menuitem" tabindex="0" @click="handleDelete">
<Trash2 :size="14" /> <Trash2 :size="14" />
<span>edit</span> <span>edit</span>
</div> </div>
<div class="menu-item delete" @click="handleDelete"> <div class="menu-item delete" role="menuitem" tabindex="0" @click="handleDelete">
<Trash2 :size="14" /> <Trash2 :size="14" />
<span>Delete</span> <span>Delete</span>
</div> </div>
</div> </div>
<div class="menu-divider"></div> <div class="menu-divider"></div>
<div class="menu-group"> <div class="menu-group">
<div class="menu-item" :class="{ disabled: !isCopyEnabled(contextMenuNode) }" <div class="menu-item" :class="{ disabled: !isCopyEnabled(contextMenuNode) }" role="menuitem"
:tabindex="isCopyEnabled(contextMenuNode) ? 0 : -1"
@click="isCopyEnabled(contextMenuNode) && openOperationDialog('copy')"> @click="isCopyEnabled(contextMenuNode) && openOperationDialog('copy')">
<Copy :size="14" /> <Copy :size="14" />
<span>添加文档 (Copy From)...</span> <span>添加文档 (Copy From)...</span>
</div> </div>
<div class="menu-item" :class="{ disabled: !isCloneEnabled(contextMenuNode) }" <div class="menu-item" :class="{ disabled: !isCloneEnabled(contextMenuNode) }" role="menuitem"
:tabindex="isCloneEnabled(contextMenuNode) ? 0 : -1"
@click="isCloneEnabled(contextMenuNode) && openOperationDialog('clone')"> @click="isCloneEnabled(contextMenuNode) && openOperationDialog('clone')">
<Database :size="14" /> <Database :size="14" />
<span>添加文档 (Clone From)...</span> <span>添加文档 (Clone From)...</span>
</div> </div>
<div class="menu-item" :class="{ disabled: !isBranchEnabled(contextMenuNode) }" <div class="menu-item" :class="{ disabled: !isBranchEnabled(contextMenuNode) }" role="menuitem"
:tabindex="isBranchEnabled(contextMenuNode) ? 0 : -1"
@click="isBranchEnabled(contextMenuNode) && openOperationDialog('branch')"> @click="isBranchEnabled(contextMenuNode) && openOperationDialog('branch')">
<GitBranch :size="14" /> <GitBranch :size="14" />
<span>Branch From...</span> <span>Branch From...</span>
</div> </div>
<div class="menu-item" :class="{ disabled: !isMapEnabled(contextMenuNode) }" <div class="menu-item" :class="{ disabled: !isMapEnabled(contextMenuNode) }" role="menuitem"
:tabindex="isMapEnabled(contextMenuNode) ? 0 : -1"
@click="isMapEnabled(contextMenuNode) && handleMapTo()"> @click="isMapEnabled(contextMenuNode) && handleMapTo()">
<Plus :size="14" /> <Plus :size="14" />
<span>Map To...</span> <span>Map To...</span>
@ -1431,10 +1720,10 @@ defineExpose({
<!-- 添加节点对话框 --> <!-- 添加节点对话框 -->
<div v-if="addNodeDialog.show" class="dialog-overlay" @click="addNodeDialog.show = false"> <div v-if="addNodeDialog.show" class="dialog-overlay" @click="addNodeDialog.show = false">
<div class="add-node-dialog" @click.stop> <div ref="addNodeDialogRef" class="add-node-dialog" role="dialog" aria-modal="true" aria-label="Add node dialog" tabindex="-1" @keydown.esc="addNodeDialog.show = false" @click.stop>
<div class="dialog-header"> <div class="dialog-header">
<h3>{{ addNodeDialog.mode === 'sibling' ? '添加同级节点' : '添加下级节点' }}</h3> <h3>{{ addNodeDialog.mode === 'sibling' ? '添加同级节点' : '添加下级节点' }}</h3>
<button class="close-btn" @click="addNodeDialog.show = false"> <button class="close-btn" aria-label="Close dialog" @click="addNodeDialog.show = false">
<X :size="18" /> <X :size="18" />
</button> </button>
</div> </div>
@ -1503,11 +1792,11 @@ defineExpose({
<!-- 操作选择弹窗 --> <!-- 操作选择弹窗 -->
<div v-if="operationDialog.show" class="dialog-overlay" @click="operationDialog.show = false"> <div v-if="operationDialog.show" class="dialog-overlay" @click="operationDialog.show = false">
<div class="operation-dialog" @click.stop> <div ref="operationDialogRef" class="operation-dialog" role="dialog" aria-modal="true" aria-label="Operation dialog" tabindex="-1" @keydown.esc="operationDialog.show = false" @click.stop>
<div class="dialog-header"> <div class="dialog-header">
<h3>{{ operationDialog.mode === 'copy' ? 'Copy Node' : operationDialog.mode === 'clone' ? 'Clone Node' : <h3>{{ operationDialog.mode === 'copy' ? 'Copy Node' : operationDialog.mode === 'clone' ? 'Clone Node' :
'Branch Node' }}</h3> 'Branch Node' }}</h3>
<button class="close-btn" @click="operationDialog.show = false"> <button class="close-btn" aria-label="Close dialog" @click="operationDialog.show = false">
<X :size="18" /> <X :size="18" />
</button> </button>
</div> </div>
@ -1566,6 +1855,7 @@ defineExpose({
</div> </div>
</div> </div>
</div> </div>
<div class="sr-only" aria-live="polite" aria-atomic="true">{{ liveMessage }}</div>
</div> </div>
</template> </template>
@ -1710,6 +2000,13 @@ defineExpose({
user-select: none; user-select: none;
font-size: 12px; font-size: 12px;
will-change: background-color, box-shadow, color; will-change: background-color, box-shadow, color;
outline: none;
}
.tree-node:focus-visible {
box-shadow:
0 0 0 2px rgba(0, 120, 212, 0.9),
0 0 0 4px rgba(255, 255, 255, 0.85);
} }
.tree-node.selected { .tree-node.selected {
@ -1750,12 +2047,21 @@ defineExpose({
margin-right: 2px; margin-right: 2px;
cursor: pointer; cursor: pointer;
color: #666; color: #666;
background: transparent;
border: none;
padding: 0;
transition: transition:
background-color 160ms ease-out, background-color 160ms ease-out,
color 160ms ease-out, color 160ms ease-out,
transform 220ms ease-out; transform 220ms ease-out;
} }
.tree-node-toggle:focus-visible {
outline: 2px solid #0078d4;
outline-offset: 1px;
border-radius: 2px;
}
.tree-node:hover .tree-node-toggle { .tree-node:hover .tree-node-toggle {
color: #005a9e; color: #005a9e;
transform: scale(1.18); transform: scale(1.18);
@ -1992,6 +2298,12 @@ defineExpose({
transform: translateX(2px); transform: translateX(2px);
} }
.menu-item:focus-visible {
outline: 2px solid #0078d4;
outline-offset: -2px;
background: #eef6ff;
}
.menu-item.disabled { .menu-item.disabled {
opacity: 0.4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
@ -2404,4 +2716,16 @@ defineExpose({
.node-owner-input::placeholder { .node-owner-input::placeholder {
color: #999; color: #999;
} }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style> </style>