npm package: @ly-js/dom
Description: dom utils of ly-js
Author: liyu
Latest version: 0.2.4

# `@ly-js/dom`

dom常用工具库，依赖`@ly-js/utils`

## Install

#### NPM

```shell
npm i @ly-js/utils @ly-js/dom --save
```

#### YARN

```shell
yarn add @ly-js/utils @ly-js/dom
```

#### pnpm

```shell
pnpm add @ly-js/utils @ly-js/dom
```

## Usage

### ESM

```ts
import {
  // element
  hasClass,
  addClass,
  removeClass,
  getStyle,
  setStyle,
  removeStyle,
  getOffsetTop,
  getOffsetTopDistance,
  // event
  on,
  off,
  once,
  stop,
  // keyboard shortcuts
  createKeyboardShortcutsManager,
  createDefaultShortcutGuard,
} from '@ly-js/dom'
```

### element

```ts
// class 操作
const el = document.createElement('div')
addClass(el, 'foo bar')
hasClass(el, 'foo') // true
removeClass(el, 'bar')

// style 读写
setStyle(el, 'width', '100px')
setStyle(el, { height: '200px' })
getStyle(el, 'width') // '100px'
getStyle(el, 'height') // '200px'
removeStyle(el, 'width')
getStyle(el, 'width') // ''

// 偏移计算
document.body.appendChild(el)
const top = getOffsetTop(el) // 从页面顶端到元素的总偏移
// 与容器之间的距离（取绝对值）
const distance = getOffsetTopDistance(el, document.body as unknown as HTMLElement)
```

注意：在非浏览器环境（如 Node）下，`getStyle` 会直接返回 `undefined`。

### event

```ts
const btn = document.createElement('button')

// 监听/移除
const handler = (e: Event) => {
  // ...
}
on(btn, 'click', handler)
off(btn, 'click', handler)

// 仅触发一次
once(btn as HTMLElement, 'click', () => {
  console.log('clicked once')
})

// 阻止冒泡
btn.addEventListener('click', e => {
  stop(e)
})
```

### keyboard-shortcuts

`keyboard-shortcuts` 是框架无关的快捷键核心模块，可以直接在原生 DOM、React、Vue 或其他前端框架里使用。

Vue 适配器不是必须依赖，如需使用，可单独从 `@ly-js/dom/keyboard-shortcuts/vue` 导入。

#### 基础使用

```ts
import { createKeyboardShortcutsManager, createDefaultShortcutGuard } from '@ly-js/dom'

const manager = createKeyboardShortcutsManager({
  guards: [createDefaultShortcutGuard()],
})

// 全局快捷键：优先于上下文快捷键执行
manager.registerGlobalShortcut({
  shortcut: 'Ctrl+K',
  eventType: 'keydown',
  preventDefault: true,
  handler() {
    console.log('open command panel')
  },
})

// 页面上下文快捷键
manager.registerContext({
  id: 'page',
  shortcuts: [
    {
      shortcut: 'A',
      handler() {
        console.log('page shortcut: A')
      },
    },
    {
      shortcut: 'Enter',
      eventType: 'keydown',
      interceptDefaultOnInteractive: true,
      preventDefault: true,
      handler() {
        console.log('handle enter before browser default behavior')
      },
    },
  ],
})

manager.setActiveContext('page')
manager.start()

// 页面销毁时释放
window.addEventListener('beforeunload', () => {
  manager.destroy()
})
```

#### Enter / Space 这类危险键的使用建议

`Enter`、`Space` 这类按键在浏览器里往往自带默认行为，例如：

- 聚焦按钮时按 `Enter` / `Space` 会触发原生 `click`
- 某些组件库弹窗打开后会自动把焦点放到“确认”按钮上
- 如果在 `keydown` 里立刻打开弹窗，后续同一轮 `keyup` 可能继续落到新焦点上

这几个配置项的职责不要混用：

- `preventDefault`: 阻止当前快捷键对应事件的原生默认行为。这是全局 `Enter` / `Space` 场景最常用、也最直接的保护手段。
- `interceptDefaultOnInteractive`: 仅在当前焦点位于 `button`、`a`、`tabindex`、`input` 等交互元素上时，要求快捷键系统在 capture `keydown` 阶段提前接管该按键，避免浏览器先把它解释成元素默认行为。
- `suppressFollowupKeyup`: 仅用于“`keydown` 会立刻打开弹窗并切走焦点”的特殊场景。开启后，快捷键系统会额外吞掉同一轮按键后续的 `keyup`，避免它误落到新焦点上。

推荐按这三个层级思考：

1. 如果这是一个页面级或全局 `Enter` 快捷键，优先使用 `preventDefault: true`。
2. 如果焦点明确在交互元素上，且你需要在 `keydown` 阶段先接管它，再加 `interceptDefaultOnInteractive: true`。
3. 只有当 `keydown` 里会立刻打开弹窗、抽屉、确认框，并导致焦点切换时，才额外加 `suppressFollowupKeyup: true`。

#### 场景 1：页面级 Enter 快捷键

这类场景的核心诉求通常不是“拦截交互元素默认行为”，而是“不要让当前这次 Enter 再触发浏览器或组件库自己的默认动作”。

```ts
manager.registerContext({
  id: 'page',
  shortcuts: [
    {
      key: 'Enter',
      code: 'Enter',
      eventType: 'keydown',
      preventDefault: true,
      handler() {
        console.log('submit current page action')
      },
    },
  ],
})
```

如果验证后发现还有别的文档级监听继续收到这次事件，再考虑补 `stopPropagation: true`。不要默认把它加到所有快捷键上。

#### 场景 2：交互元素上的 Enter / Space

如果当前焦点就在按钮或链接上，且你希望快捷键系统优先接管这个按键，再阻止浏览器生成默认 `click`，使用 `interceptDefaultOnInteractive`。

```ts
manager.registerContext({
  id: 'page',
  shortcuts: [
    {
      key: 'Enter',
      code: 'Enter',
      eventType: 'keydown',
      interceptDefaultOnInteractive: true,
      preventDefault: true,
      handler() {
        console.log('run action on focused button before native click')
      },
    },
  ],
})
```

这里推荐优先使用 `keydown`，因为 `keyup` 往往太晚，浏览器已经有机会先触发默认行为。

#### 场景 3：keydown 打开弹窗，并且弹窗会自动聚焦确认按钮

这是最容易出现“一次 Enter 触发两次动作”的场景。例如：

1. 页面上的 `Enter` 快捷键在 `keydown` 中打开确认框
2. 确认框出现后，组件库自动把焦点放到“确认”按钮
3. 同一轮按键的后续 `keyup` 又落到新焦点上

这时除了 `preventDefault` 与 `interceptDefaultOnInteractive`，还需要显式声明 `suppressFollowupKeyup: true`：

```ts
manager.registerContext({
  id: 'page',
  shortcuts: [
    {
      key: 'Enter',
      code: 'Enter',
      eventType: 'keydown',
      interceptDefaultOnInteractive: true,
      suppressFollowupKeyup: true,
      preventDefault: true,
      handler() {
        console.log('open confirm dialog on keydown')
      },
    },
  ],
})
```

`@ly-js/dom` 只会在显式声明 `suppressFollowupKeyup: true` 时吞掉同一轮后续 `keyup`。如果你没有开启它，快捷键系统不会擅自改写 `keyup` 语义。

#### 配置建议总结

- 全局或页面级 `Enter`：先试 `eventType: 'keydown' + preventDefault: true`
- 聚焦在按钮、链接等交互元素上：再加 `interceptDefaultOnInteractive: true`
- `keydown` 中会立刻打开弹窗并切焦点：再加 `suppressFollowupKeyup: true`
- 只有在确认还有别的监听串进来时，才补 `stopPropagation: true`

不推荐把所有能力一次性都打开。先用最小配置解决当前场景，再逐层增加约束，副作用会更可控。

#### 上下文切换

适合页面、弹层、抽屉这类有层级覆盖关系的场景。

```ts
import { createKeyboardShortcutsManager } from '@ly-js/dom'

const manager = createKeyboardShortcutsManager()

manager.registerContext({
  id: 'page',
  shortcuts: [
    {
      shortcut: 'A',
      handler() {
        console.log('page action')
      },
    },
  ],
})

manager.registerContext({
  id: 'dialog',
  shortcuts: [
    {
      shortcut: 'S',
      handler() {
        console.log('dialog action')
      },
    },
  ],
})

manager.setActiveContext('page')

// 打开弹层时压入新上下文
manager.pushContext('dialog')

// 关闭弹层时移除该上下文，自动恢复到 page
manager.popContext('dialog')
```

#### 双击快捷键

适合“第一次提示、第二次确认执行”的场景，例如双击 `Escape` 关闭弹层。

```ts
import { createKeyboardShortcutsManager } from '@ly-js/dom'

const manager = createKeyboardShortcutsManager()

manager.registerContext({
  id: 'dialog',
  shortcuts: [
    {
      type: 'double-press',
      key: 'Escape',
      timeout: 1500,
      onPending() {
        console.log('press Escape again to close dialog')
      },
      handler() {
        console.log('dialog closed')
      },
    },
  ],
})
```

#### 守卫

默认守卫会尽量保护输入体验：

- 输入框、文本域、可编辑区域中，只允许全局快捷键
- 输入法组合输入时，不进入快捷键系统
- 对按钮上的空格键等浏览器默认行为做保护

```ts
import {
  createDefaultShortcutGuard,
  createKeyboardShortcutsManager,
  createSelectorPresenceGuard,
} from '@ly-js/dom'

const manager = createKeyboardShortcutsManager({
  guards: [
    createDefaultShortcutGuard(),
    createSelectorPresenceGuard('.el-message-box, .custom-dialog'),
  ],
})
```

#### Vue 适配器（可选）

如果你在 Vue 中希望由页面向子组件透传当前快捷键上下文，可以使用独立适配器子路径：

```ts
import { provideShortcutScope, useRegisterScopedShortcuts } from '@ly-js/dom/keyboard-shortcuts/vue'
```

页面根组件：

```ts
import { onMounted } from 'vue'
import { createKeyboardShortcutsManager } from '@ly-js/dom'
import { provideShortcutScope } from '@ly-js/dom/keyboard-shortcuts/vue'

const manager = createKeyboardShortcutsManager()

manager.registerContext({
  id: 'page',
  shortcuts: [],
})

manager.setActiveContext('page')
provideShortcutScope(manager, 'page')

onMounted(() => {
  manager.start()
})
```

子组件：

```ts
import { useRegisterScopedShortcuts } from '@ly-js/dom/keyboard-shortcuts/vue'

useRegisterScopedShortcuts([
  {
    shortcut: 'S',
    handler() {
      console.log('scoped shortcut from child component')
    },
  },
])
```

#### 导出路径

- 核心能力：`@ly-js/dom`
- Vue 适配器：`@ly-js/dom/keyboard-shortcuts/vue`