@leavepulse/ui 0.20.9 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,16 @@
1
1
 
2
2
  /* Each result eases in; honours reduced motion. */
3
- .lp-cmd-item[data-v-75a17488] {
4
- animation: lp-cmd-in-75a17488 140ms ease-out both;
3
+ .lp-cmd-item[data-v-a23a2f5f] {
4
+ animation: lp-cmd-in-a23a2f5f 140ms ease-out both;
5
5
  }
6
- @keyframes lp-cmd-in-75a17488 {
6
+ @keyframes lp-cmd-in-a23a2f5f {
7
7
  from {
8
8
  opacity: 0;
9
9
  transform: translateY(3px);
10
10
  }
11
11
  }
12
12
  @media (prefers-reduced-motion: reduce) {
13
- .lp-cmd-item[data-v-75a17488] {
13
+ .lp-cmd-item[data-v-a23a2f5f] {
14
14
  animation: none;
15
15
  }
16
16
  }
@@ -0,0 +1,55 @@
1
+
2
+ /* Two columns where there is room: the list is long enough that one column
3
+ pushes the last group off a laptop screen. */
4
+ .shortcut-groups[data-v-d86b724b] {
5
+ display: grid;
6
+ gap: 18px;
7
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
8
+ }
9
+ .shortcut-group h3[data-v-d86b724b] {
10
+ margin: 0 0 6px;
11
+ color: var(--color-muted);
12
+ font-size: 0.7rem;
13
+ font-weight: 600;
14
+ letter-spacing: 0.06em;
15
+ text-transform: uppercase;
16
+ }
17
+ .shortcut-group dl[data-v-d86b724b] {
18
+ display: flex;
19
+ flex-direction: column;
20
+ gap: 1px;
21
+ margin: 0;
22
+ }
23
+ .shortcut-row[data-v-d86b724b] {
24
+ display: flex;
25
+ align-items: center;
26
+ justify-content: space-between;
27
+ gap: 12px;
28
+ padding: 5px 0;
29
+ }
30
+ .shortcut-row dt[data-v-d86b724b] {
31
+ color: var(--color-ink);
32
+ font-size: 0.82rem;
33
+ min-width: 0;
34
+ }
35
+ .shortcut-row dd[data-v-d86b724b] {
36
+ display: flex;
37
+ flex-shrink: 0;
38
+ gap: 3px;
39
+ margin: 0;
40
+ }
41
+ kbd[data-v-d86b724b] {
42
+ display: inline-flex;
43
+ min-width: 20px;
44
+ align-items: center;
45
+ justify-content: center;
46
+ padding: 2px 6px;
47
+ border: 1px solid var(--color-line-strong);
48
+ border-bottom-width: 2px;
49
+ border-radius: 5px;
50
+ background: color-mix(in srgb, var(--color-ink) 6%, transparent);
51
+ color: var(--color-muted-strong);
52
+ font-family: inherit;
53
+ font-size: 0.7rem;
54
+ line-height: 1.5;
55
+ }
@@ -0,0 +1,10 @@
1
+ const _export_sfc = (sfc, props) => {
2
+ const target = sfc.__vccOpts || sfc;
3
+ for (const [key, val] of props) {
4
+ target[key] = val;
5
+ }
6
+ return target;
7
+ };
8
+ export {
9
+ _export_sfc as _
10
+ };
@@ -1 +1 @@
1
- export declare const COMPONENT_NAMES: readonly ["LayoutCanvas", "LayoutNode", "LpAddressInput", "LpAlert", "LpAppShell", "LpAutocomplete", "LpAvatar", "LpBadge", "LpBreadcrumbs", "LpButton", "LpCalendar", "LpCard", "LpCheckbox", "LpCodeBlock", "LpCommandPalette", "LpConfirmDialog", "LpContextMenu", "LpDatePicker", "LpDisclosure", "LpDivider", "LpDrawer", "LpDropdownMenu", "LpEmptyState", "LpFileTree", "LpFileTreeNode", "LpFormField", "LpIcon", "LpInfraNode", "LpInput", "LpLaneNode", "LpLightbox", "LpLink", "LpLogViewer", "LpMapPicker", "LpModal", "LpNotificationBell", "LpNumberField", "LpOtpInput", "LpPagination", "LpPasswordInput", "LpPhoneInput", "LpPopover", "LpProgress", "LpRadio", "LpRadioGroup", "LpScrollArea", "LpSegmented", "LpSelect", "LpServiceNode", "LpSidebar", "LpSidebarNav", "LpSkeleton", "LpSlider", "LpStat", "LpStepper", "LpSwitch", "LpTable", "LpTableOfContents", "LpTabs", "LpTextarea", "LpThemeSwitcher", "LpTilt", "LpToaster", "LpTooltip", "LpTopologyCanvas", "LpUptimeBar"];
1
+ export declare const COMPONENT_NAMES: readonly ["LayoutCanvas", "LayoutNode", "LpAddressInput", "LpAlert", "LpAppShell", "LpAutocomplete", "LpAvatar", "LpBadge", "LpBreadcrumbs", "LpButton", "LpCalendar", "LpCard", "LpCheckbox", "LpCodeBlock", "LpCommandPalette", "LpConfirmDialog", "LpContextMenu", "LpDatePicker", "LpDisclosure", "LpDivider", "LpDrawer", "LpDropdownMenu", "LpEmptyState", "LpFileTree", "LpFileTreeNode", "LpFormField", "LpIcon", "LpInfraNode", "LpInput", "LpLaneNode", "LpLightbox", "LpLink", "LpLogViewer", "LpMapPicker", "LpModal", "LpNotificationBell", "LpNumberField", "LpOtpInput", "LpPagination", "LpPasswordInput", "LpPhoneInput", "LpPopover", "LpProgress", "LpRadio", "LpRadioGroup", "LpScrollArea", "LpSegmented", "LpSelect", "LpServiceNode", "LpShortcutsDialog", "LpSidebar", "LpSidebarNav", "LpSkeleton", "LpSlider", "LpStat", "LpStepper", "LpSwitch", "LpTable", "LpTableOfContents", "LpTabs", "LpTextarea", "LpThemeSwitcher", "LpTilt", "LpToaster", "LpTooltip", "LpTopologyCanvas", "LpUptimeBar"];
@@ -1,8 +1,10 @@
1
- import { defineComponent, ref, computed, watch, nextTick, onMounted, onBeforeUnmount, openBlock, createBlock, unref, withCtx, createVNode, withModifiers, createTextVNode, createElementVNode, withDirectives, withKeys, vModelText, createElementBlock, toDisplayString, createCommentVNode, Fragment, renderList, normalizeClass } from "vue";
1
+ import { defineComponent, ref, computed, watch, nextTick, openBlock, createBlock, unref, withCtx, createVNode, withModifiers, createTextVNode, createElementVNode, withDirectives, withKeys, vModelText, createElementBlock, toDisplayString, createCommentVNode, Fragment, renderList, normalizeClass } from "vue";
2
2
  import { DialogRoot, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription } from "reka-ui";
3
3
  import { _ as _sfc_main$1 } from "../chunks/LpIcon-CCnX5_2j.js";
4
4
  import { _ as _sfc_main$2 } from "../chunks/LpScrollArea-Du8tW8c5.js";
5
- import '../assets/LpCommandPalette-C3LdDNHk.css';const _hoisted_1 = { class: "flex shrink-0 items-center gap-2.5 border-b border-line px-4" };
5
+ import { useHotkeys } from "../composables/useHotkeys.js";
6
+ import { _ as _export_sfc } from "../chunks/_plugin-vue_export-helper-1tPrXgE0.js";
7
+ import '../assets/LpCommandPalette-CEY-pajO.css';const _hoisted_1 = { class: "flex shrink-0 items-center gap-2.5 border-b border-line px-4" };
6
8
  const _hoisted_2 = ["placeholder", "onKeydown"];
7
9
  const _hoisted_3 = {
8
10
  key: 0,
@@ -149,16 +151,17 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
149
151
  }
150
152
  }
151
153
  );
152
- function onHotkey(e) {
153
- if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
154
- e.preventDefault();
155
- emit("update:open", !props.open);
156
- }
157
- }
158
- onMounted(() => {
159
- if (props.hotkey) window.addEventListener("keydown", onHotkey);
160
- });
161
- onBeforeUnmount(() => window.removeEventListener("keydown", onHotkey));
154
+ useHotkeys(
155
+ () => props.hotkey ? [
156
+ {
157
+ key: "k",
158
+ code: "KeyK",
159
+ ctrl: true,
160
+ allowInInput: true,
161
+ handler: () => emit("update:open", !props.open)
162
+ }
163
+ ] : []
164
+ );
162
165
  return (_ctx, _cache) => {
163
166
  return openBlock(), createBlock(unref(DialogRoot), {
164
167
  open: __props.open,
@@ -297,14 +300,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
297
300
  };
298
301
  }
299
302
  });
300
- const _export_sfc = (sfc, props) => {
301
- const target = sfc.__vccOpts || sfc;
302
- for (const [key, val] of props) {
303
- target[key] = val;
304
- }
305
- return target;
306
- };
307
- const LpCommandPalette = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-75a17488"]]);
303
+ const LpCommandPalette = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-a23a2f5f"]]);
308
304
  export {
309
305
  LpCommandPalette as default
310
306
  };
@@ -0,0 +1,25 @@
1
+ export interface ShortcutEntry {
2
+ /** Rendered one <kbd> per "+"-separated key, so write it the way it reads. */
3
+ combo: string;
4
+ label: string;
5
+ }
6
+ export interface ShortcutGroup {
7
+ title: string;
8
+ entries: ShortcutEntry[];
9
+ }
10
+ type __VLS_Props = {
11
+ open?: boolean;
12
+ groups: ShortcutGroup[];
13
+ title?: string;
14
+ description?: string;
15
+ };
16
+ declare const __VLS_export: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {} & {
17
+ "update:open": (value: boolean) => any;
18
+ }, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
19
+ "onUpdate:open"?: ((value: boolean) => any) | undefined;
20
+ }>, {
21
+ title: string;
22
+ open: boolean;
23
+ }, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
24
+ declare const _default: typeof __VLS_export;
25
+ export default _default;
@@ -0,0 +1,63 @@
1
+ import { defineComponent, openBlock, createBlock, withCtx, createElementVNode, createElementBlock, Fragment, renderList, toDisplayString, unref } from "vue";
2
+ import { _ as _sfc_main$1 } from "../chunks/LpModal-DKFdzKwV.js";
3
+ import { appleModifierLabel } from "../composables/useHotkeys.js";
4
+ import { _ as _export_sfc } from "../chunks/_plugin-vue_export-helper-1tPrXgE0.js";
5
+ import '../assets/LpShortcutsDialog-B7H-RZLQ.css';const _hoisted_1 = { class: "shortcut-groups" };
6
+ const _sfc_main = /* @__PURE__ */ defineComponent({
7
+ __name: "LpShortcutsDialog",
8
+ props: {
9
+ open: { type: Boolean, default: false },
10
+ groups: {},
11
+ title: { default: "Keyboard shortcuts" },
12
+ description: {}
13
+ },
14
+ emits: ["update:open"],
15
+ setup(__props, { emit: __emit }) {
16
+ const props = __props;
17
+ const emit = __emit;
18
+ return (_ctx, _cache) => {
19
+ return openBlock(), createBlock(_sfc_main$1, {
20
+ open: props.open,
21
+ size: "lg",
22
+ title: props.title,
23
+ description: props.description,
24
+ "onUpdate:open": _cache[0] || (_cache[0] = (value) => emit("update:open", value))
25
+ }, {
26
+ default: withCtx(() => [
27
+ createElementVNode("div", _hoisted_1, [
28
+ (openBlock(true), createElementBlock(Fragment, null, renderList(props.groups, (group) => {
29
+ return openBlock(), createElementBlock("section", {
30
+ key: group.title,
31
+ class: "shortcut-group"
32
+ }, [
33
+ createElementVNode("h3", null, toDisplayString(group.title), 1),
34
+ createElementVNode("dl", null, [
35
+ (openBlock(true), createElementBlock(Fragment, null, renderList(group.entries, (entry) => {
36
+ return openBlock(), createElementBlock("div", {
37
+ key: entry.combo,
38
+ class: "shortcut-row"
39
+ }, [
40
+ createElementVNode("dt", null, toDisplayString(entry.label), 1),
41
+ createElementVNode("dd", null, [
42
+ (openBlock(true), createElementBlock(Fragment, null, renderList(entry.combo.split("+"), (key, index) => {
43
+ return openBlock(), createElementBlock("kbd", {
44
+ key: `${key}-${index}`
45
+ }, toDisplayString(unref(appleModifierLabel)(key.trim())), 1);
46
+ }), 128))
47
+ ])
48
+ ]);
49
+ }), 128))
50
+ ])
51
+ ]);
52
+ }), 128))
53
+ ])
54
+ ]),
55
+ _: 1
56
+ }, 8, ["open", "title", "description"]);
57
+ };
58
+ }
59
+ });
60
+ const LpShortcutsDialog = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-d86b724b"]]);
61
+ export {
62
+ LpShortcutsDialog as default
63
+ };
@@ -0,0 +1,45 @@
1
+ import { MaybeRefOrGetter } from 'vue';
2
+ export interface HotkeyBinding {
3
+ /** Layout-produced key, compared case-insensitively ("l", "f2", "escape"). */
4
+ key: string;
5
+ /**
6
+ * Physical key position ("KeyL", "F2", "Digit1"). Matches on its own, which
7
+ * is what makes non-Latin layouts work.
8
+ */
9
+ code?: string;
10
+ /** Ctrl on Windows/Linux, ⌘ on macOS — the two are treated as one. */
11
+ ctrl?: boolean;
12
+ shift?: boolean;
13
+ alt?: boolean;
14
+ /**
15
+ * Runs even while a text field has focus. Only for shortcuts that mean
16
+ * something there — Escape, and the palette the user reaches from anywhere.
17
+ */
18
+ allowInInput?: boolean;
19
+ /**
20
+ * Returning `false` declines the key: the event keeps its default and the
21
+ * search moves on. This is what lets a binding defer to a layer that owns the
22
+ * key better than it does — Escape belongs to whichever overlay is on top,
23
+ * and swallowing it here would close all of them.
24
+ */
25
+ handler: () => void | boolean;
26
+ }
27
+ /**
28
+ * Whether this machine prints ⌘ where the rest print Ctrl. Read once: the
29
+ * keyboard does not change under a running window, and the check is only ever
30
+ * used to pick a label.
31
+ */
32
+ export declare const isAppleKeyboard: boolean;
33
+ /**
34
+ * The symbol to print for a modifier on this platform. Bindings treat Ctrl and
35
+ * Meta as one modifier, so only the label may differ — never the binding.
36
+ */
37
+ export declare function appleModifierLabel(key: string): string;
38
+ /**
39
+ * Bind a list of hotkeys for as long as the calling component is mounted.
40
+ *
41
+ * The list is read on every keystroke rather than captured once, so bindings
42
+ * may come and go with the state that owns them — a getter is the point, not a
43
+ * formality.
44
+ */
45
+ export declare function useHotkeys(bindings: MaybeRefOrGetter<HotkeyBinding[]>): void;
@@ -0,0 +1,53 @@
1
+ import { onMounted, onBeforeUnmount, toValue } from "vue";
2
+ const isAppleKeyboard = typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.platform || "");
3
+ function appleModifierLabel(key) {
4
+ if (!isAppleKeyboard) return key;
5
+ if (key === "Ctrl") return "⌘";
6
+ if (key === "Alt") return "⌥";
7
+ return key;
8
+ }
9
+ const TEXT_INPUT_TYPES = /* @__PURE__ */ new Set([
10
+ "text",
11
+ "search",
12
+ "email",
13
+ "number",
14
+ "password",
15
+ "tel",
16
+ "url"
17
+ ]);
18
+ function isTextEntry(target) {
19
+ if (!(target instanceof HTMLElement)) return false;
20
+ if (target.isContentEditable) return true;
21
+ if (target instanceof HTMLTextAreaElement) return true;
22
+ if (target instanceof HTMLInputElement) {
23
+ return TEXT_INPUT_TYPES.has(target.type);
24
+ }
25
+ return false;
26
+ }
27
+ function matches(event, binding) {
28
+ const ctrlHeld = event.ctrlKey || event.metaKey;
29
+ if (ctrlHeld !== Boolean(binding.ctrl)) return false;
30
+ if (event.shiftKey !== Boolean(binding.shift)) return false;
31
+ if (event.altKey !== Boolean(binding.alt)) return false;
32
+ return event.key.toLowerCase() === binding.key.toLowerCase() || binding.code !== void 0 && event.code === binding.code;
33
+ }
34
+ function useHotkeys(bindings) {
35
+ function onKeydown(event) {
36
+ if (event.isComposing) return;
37
+ const inTextEntry = isTextEntry(event.target);
38
+ for (const binding of toValue(bindings)) {
39
+ if (inTextEntry && !binding.allowInInput) continue;
40
+ if (!matches(event, binding)) continue;
41
+ if (binding.handler() === false) continue;
42
+ event.preventDefault();
43
+ return;
44
+ }
45
+ }
46
+ onMounted(() => window.addEventListener("keydown", onKeydown));
47
+ onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
48
+ }
49
+ export {
50
+ appleModifierLabel,
51
+ isAppleKeyboard,
52
+ useHotkeys
53
+ };
package/dist/index.d.ts CHANGED
@@ -67,6 +67,8 @@ export { default as LpSelect } from './components/LpSelect.vue';
67
67
  export type { SelectOption, SelectValue } from './components/LpSelect.vue';
68
68
  export { default as LpServiceNode } from './components/LpServiceNode.vue';
69
69
  export type { ServiceNodeData } from './components/LpServiceNode.vue';
70
+ export { default as LpShortcutsDialog } from './components/LpShortcutsDialog.vue';
71
+ export type { ShortcutEntry, ShortcutGroup } from './components/LpShortcutsDialog.vue';
70
72
  export { default as LpSidebar } from './components/LpSidebar.vue';
71
73
  export type { SidebarItem, SidebarSection } from './components/LpSidebar.vue';
72
74
  export { default as LpSkeleton } from './components/LpSkeleton.vue';
@@ -107,6 +109,8 @@ export { amber, dark, leavepulse, light, lime, nord, presets, rose, violet } fro
107
109
  export type { PresetName } from './theme/presets';
108
110
  export { parseConfig, serializeConfig, UI_CONFIG_VERSION } from './theme/config';
109
111
  export type { UiConfig } from './theme/config';
112
+ export { useHotkeys, isAppleKeyboard, appleModifierLabel } from './composables/useHotkeys';
113
+ export type { HotkeyBinding } from './composables/useHotkeys';
110
114
  export { useInputFilter } from './composables/useInputFilter';
111
115
  export type { UseInputFilterOptions } from './composables/useInputFilter';
112
116
  export { useSettled } from './composables/useSettled';
package/dist/index.js CHANGED
@@ -48,6 +48,7 @@ import { _ as _42 } from "./chunks/LpScrollArea-Du8tW8c5.js";
48
48
  import { _ as _43 } from "./chunks/LpSegmented-BmpanyAo.js";
49
49
  import { _ as _44 } from "./chunks/LpSelect-D5mU4cZO.js";
50
50
  import { _ as _45 } from "./chunks/LpServiceNode-Dc-Ut5RS.js";
51
+ import { default as default3 } from "./components/LpShortcutsDialog.vue.js";
51
52
  import { _ as _46 } from "./chunks/LpSidebar-Bd0148dd.js";
52
53
  import { _ as _47 } from "./chunks/LpSkeleton-eSFIo4kD.js";
53
54
  import { _ as _48 } from "./chunks/LpSlider-B1SF3pRV.js";
@@ -69,6 +70,7 @@ import { deserializeLayout, serializeLayout } from "./layout/tree.js";
69
70
  import { addLeaf, addTab, countBlocks, countLeaves, makeLayout, moveLeaf, removeBlock, removeLeaf, reorderTab, resizeAt, setActiveTab } from "./layout/tree.js";
70
71
  import { blockTitle, defineBlocks } from "./layout/registry.js";
71
72
  import { useLayout } from "./layout/useLayout.js";
73
+ import { appleModifierLabel, isAppleKeyboard, useHotkeys } from "./composables/useHotkeys.js";
72
74
  import { useInputFilter } from "./composables/useInputFilter.js";
73
75
  import { useSettled } from "./composables/useSettled.js";
74
76
  import { useTilt } from "./composables/useTilt.js";
@@ -152,6 +154,7 @@ export {
152
154
  _43 as LpSegmented,
153
155
  _44 as LpSelect,
154
156
  _45 as LpServiceNode,
157
+ default3 as LpShortcutsDialog,
155
158
  _46 as LpSidebar,
156
159
  _47 as LpSkeleton,
157
160
  _48 as LpSlider,
@@ -176,6 +179,7 @@ export {
176
179
  addTab,
177
180
  b as amber,
178
181
  ancestorIds,
182
+ appleModifierLabel,
179
183
  c as applyTheme,
180
184
  d as applyThemeWithTransition,
181
185
  blockTitle,
@@ -198,6 +202,7 @@ export {
198
202
  formatAddress,
199
203
  formatModified,
200
204
  formatSize,
205
+ isAppleKeyboard,
201
206
  i as isBrokenBackdropEngine,
202
207
  l as leavepulse,
203
208
  h as light,
@@ -228,6 +233,7 @@ export {
228
233
  m as themeToCssVars,
229
234
  tileUrl,
230
235
  unproject,
236
+ useHotkeys,
231
237
  useInputFilter,
232
238
  useLayout,
233
239
  useSettled,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.20.9",
3
+ "version": "0.21.0",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -51,6 +51,7 @@ export const COMPONENT_NAMES = [
51
51
  "LpSegmented",
52
52
  "LpSelect",
53
53
  "LpServiceNode",
54
+ "LpShortcutsDialog",
54
55
  "LpSidebar",
55
56
  "LpSidebarNav",
56
57
  "LpSkeleton",
@@ -18,9 +18,10 @@ import {
18
18
  DialogRoot,
19
19
  DialogTitle,
20
20
  } from "reka-ui"
21
- import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
21
+ import { computed, nextTick, ref, watch } from "vue"
22
22
  import LpIcon from "./LpIcon.vue"
23
23
  import LpScrollArea from "./LpScrollArea.vue"
24
+ import { useHotkeys } from "../composables/useHotkeys"
24
25
 
25
26
  export interface Command {
26
27
  id: string
@@ -196,16 +197,22 @@ watch(
196
197
  },
197
198
  )
198
199
 
199
- function onHotkey(e: KeyboardEvent) {
200
- if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
201
- e.preventDefault()
202
- emit("update:open", !props.open)
203
- }
204
- }
205
- onMounted(() => {
206
- if (props.hotkey) window.addEventListener("keydown", onHotkey)
207
- })
208
- onBeforeUnmount(() => window.removeEventListener("keydown", onHotkey))
200
+ // Through useHotkeys rather than a listener of its own: the palette is the one
201
+ // shortcut that has to work from inside a text field, and `code` is what makes
202
+ // it fire on a layout where Ctrl-K arrives as "л".
203
+ useHotkeys(() =>
204
+ props.hotkey
205
+ ? [
206
+ {
207
+ key: "k",
208
+ code: "KeyK",
209
+ ctrl: true,
210
+ allowInInput: true,
211
+ handler: () => emit("update:open", !props.open),
212
+ },
213
+ ]
214
+ : [],
215
+ )
209
216
  </script>
210
217
 
211
218
  <template>
@@ -0,0 +1,127 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * A keyboard reference, conventionally opened with F1.
4
+ *
5
+ * Shortcuts are passed in as data rather than read off a live binding list: a
6
+ * binding knows its key but not what to call it, and in most apps half of them
7
+ * are only mounted in the section they belong to — a reference that lists what
8
+ * happens to be active is not a reference.
9
+ *
10
+ * Combos are written the way they read ("Ctrl+Shift+M", "←→↑↓") and split on
11
+ * "+" into one <kbd> each.
12
+ */
13
+ import LpModal from "./LpModal.vue"
14
+ import { appleModifierLabel } from "../composables/useHotkeys"
15
+
16
+ export interface ShortcutEntry {
17
+ /** Rendered one <kbd> per "+"-separated key, so write it the way it reads. */
18
+ combo: string
19
+ label: string
20
+ }
21
+
22
+ export interface ShortcutGroup {
23
+ title: string
24
+ entries: ShortcutEntry[]
25
+ }
26
+
27
+ const props = withDefaults(
28
+ defineProps<{
29
+ open?: boolean
30
+ groups: ShortcutGroup[]
31
+ title?: string
32
+ description?: string
33
+ }>(),
34
+ { open: false, title: "Keyboard shortcuts" },
35
+ )
36
+
37
+ const emit = defineEmits<{ (e: "update:open", value: boolean): void }>()
38
+ </script>
39
+
40
+ <template>
41
+ <LpModal
42
+ :open="props.open"
43
+ size="lg"
44
+ :title="props.title"
45
+ :description="props.description"
46
+ @update:open="(value: boolean) => emit('update:open', value)"
47
+ >
48
+ <div class="shortcut-groups">
49
+ <section v-for="group in props.groups" :key="group.title" class="shortcut-group">
50
+ <h3>{{ group.title }}</h3>
51
+ <dl>
52
+ <div v-for="entry in group.entries" :key="entry.combo" class="shortcut-row">
53
+ <dt>{{ entry.label }}</dt>
54
+ <dd>
55
+ <kbd v-for="(key, index) in entry.combo.split('+')" :key="`${key}-${index}`">
56
+ {{ appleModifierLabel(key.trim()) }}
57
+ </kbd>
58
+ </dd>
59
+ </div>
60
+ </dl>
61
+ </section>
62
+ </div>
63
+ </LpModal>
64
+ </template>
65
+
66
+ <style scoped>
67
+ /* Two columns where there is room: the list is long enough that one column
68
+ pushes the last group off a laptop screen. */
69
+ .shortcut-groups {
70
+ display: grid;
71
+ gap: 18px;
72
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
73
+ }
74
+
75
+ .shortcut-group h3 {
76
+ margin: 0 0 6px;
77
+ color: var(--color-muted);
78
+ font-size: 0.7rem;
79
+ font-weight: 600;
80
+ letter-spacing: 0.06em;
81
+ text-transform: uppercase;
82
+ }
83
+
84
+ .shortcut-group dl {
85
+ display: flex;
86
+ flex-direction: column;
87
+ gap: 1px;
88
+ margin: 0;
89
+ }
90
+
91
+ .shortcut-row {
92
+ display: flex;
93
+ align-items: center;
94
+ justify-content: space-between;
95
+ gap: 12px;
96
+ padding: 5px 0;
97
+ }
98
+
99
+ .shortcut-row dt {
100
+ color: var(--color-ink);
101
+ font-size: 0.82rem;
102
+ min-width: 0;
103
+ }
104
+
105
+ .shortcut-row dd {
106
+ display: flex;
107
+ flex-shrink: 0;
108
+ gap: 3px;
109
+ margin: 0;
110
+ }
111
+
112
+ kbd {
113
+ display: inline-flex;
114
+ min-width: 20px;
115
+ align-items: center;
116
+ justify-content: center;
117
+ padding: 2px 6px;
118
+ border: 1px solid var(--color-line-strong);
119
+ border-bottom-width: 2px;
120
+ border-radius: 5px;
121
+ background: color-mix(in srgb, var(--color-ink) 6%, transparent);
122
+ color: var(--color-muted-strong);
123
+ font-family: inherit;
124
+ font-size: 0.7rem;
125
+ line-height: 1.5;
126
+ }
127
+ </style>
@@ -0,0 +1,131 @@
1
+ import { onBeforeUnmount, onMounted, type MaybeRefOrGetter, toValue } from "vue"
2
+
3
+ /*
4
+ * One keydown listener over a registry of bindings, so a shortcut is declared
5
+ * next to the action it runs instead of growing another window-level handler.
6
+ *
7
+ * Three things every hand-rolled shortcut handler tends to get wrong, and the
8
+ * reason this is worth sharing rather than rewriting per app:
9
+ *
10
+ * layout — a binding names its key twice over. `key` is what the layout
11
+ * produced, `code` is the physical position, and either matching counts. On
12
+ * a Cyrillic layout Ctrl+K arrives as `key: "л"` with `code: "KeyK"`, and a
13
+ * user pressing the key printed "K" expects it to work either way.
14
+ *
15
+ * IME — a composing input delivers every keystroke as its own event, so
16
+ * acting on them fires shortcuts mid-word.
17
+ *
18
+ * text entry — a shortcut that steals a key from someone typing is a bug,
19
+ * except for the few that mean something in a field (Escape, a palette).
20
+ *
21
+ * Modifier note: Ctrl and Meta are one modifier here, since macOS binds ⌘ where
22
+ * the rest bind Ctrl. Label the difference in the UI, not in the binding.
23
+ */
24
+
25
+ export interface HotkeyBinding {
26
+ /** Layout-produced key, compared case-insensitively ("l", "f2", "escape"). */
27
+ key: string
28
+ /**
29
+ * Physical key position ("KeyL", "F2", "Digit1"). Matches on its own, which
30
+ * is what makes non-Latin layouts work.
31
+ */
32
+ code?: string
33
+ /** Ctrl on Windows/Linux, ⌘ on macOS — the two are treated as one. */
34
+ ctrl?: boolean
35
+ shift?: boolean
36
+ alt?: boolean
37
+ /**
38
+ * Runs even while a text field has focus. Only for shortcuts that mean
39
+ * something there — Escape, and the palette the user reaches from anywhere.
40
+ */
41
+ allowInInput?: boolean
42
+ /**
43
+ * Returning `false` declines the key: the event keeps its default and the
44
+ * search moves on. This is what lets a binding defer to a layer that owns the
45
+ * key better than it does — Escape belongs to whichever overlay is on top,
46
+ * and swallowing it here would close all of them.
47
+ */
48
+ handler: () => void | boolean
49
+ }
50
+
51
+ /**
52
+ * Whether this machine prints ⌘ where the rest print Ctrl. Read once: the
53
+ * keyboard does not change under a running window, and the check is only ever
54
+ * used to pick a label.
55
+ */
56
+ export const isAppleKeyboard =
57
+ typeof navigator !== "undefined"
58
+ && /Mac|iPhone|iPad|iPod/.test(navigator.platform || "")
59
+
60
+ /**
61
+ * The symbol to print for a modifier on this platform. Bindings treat Ctrl and
62
+ * Meta as one modifier, so only the label may differ — never the binding.
63
+ */
64
+ export function appleModifierLabel(key: string): string {
65
+ if (!isAppleKeyboard) return key
66
+ if (key === "Ctrl") return "⌘"
67
+ if (key === "Alt") return "⌥"
68
+ return key
69
+ }
70
+
71
+ const TEXT_INPUT_TYPES = new Set([
72
+ "text",
73
+ "search",
74
+ "email",
75
+ "number",
76
+ "password",
77
+ "tel",
78
+ "url",
79
+ ])
80
+
81
+ /**
82
+ * Whether the event lands in something the user is typing into. Checked off the
83
+ * event target rather than `document.activeElement` so it stays right inside a
84
+ * dialog's focus trap.
85
+ */
86
+ function isTextEntry(target: EventTarget | null): boolean {
87
+ if (!(target instanceof HTMLElement)) return false
88
+ if (target.isContentEditable) return true
89
+ if (target instanceof HTMLTextAreaElement) return true
90
+ if (target instanceof HTMLInputElement) {
91
+ return TEXT_INPUT_TYPES.has(target.type)
92
+ }
93
+ return false
94
+ }
95
+
96
+ function matches(event: KeyboardEvent, binding: HotkeyBinding): boolean {
97
+ const ctrlHeld = event.ctrlKey || event.metaKey
98
+ if (ctrlHeld !== Boolean(binding.ctrl)) return false
99
+ if (event.shiftKey !== Boolean(binding.shift)) return false
100
+ if (event.altKey !== Boolean(binding.alt)) return false
101
+ return (
102
+ event.key.toLowerCase() === binding.key.toLowerCase()
103
+ || (binding.code !== undefined && event.code === binding.code)
104
+ )
105
+ }
106
+
107
+ /**
108
+ * Bind a list of hotkeys for as long as the calling component is mounted.
109
+ *
110
+ * The list is read on every keystroke rather than captured once, so bindings
111
+ * may come and go with the state that owns them — a getter is the point, not a
112
+ * formality.
113
+ */
114
+ export function useHotkeys(bindings: MaybeRefOrGetter<HotkeyBinding[]>) {
115
+ function onKeydown(event: KeyboardEvent) {
116
+ if (event.isComposing) return
117
+ const inTextEntry = isTextEntry(event.target)
118
+ for (const binding of toValue(bindings)) {
119
+ if (inTextEntry && !binding.allowInInput) continue
120
+ if (!matches(event, binding)) continue
121
+ // Claimed only once the handler has run and not declined, so a binding
122
+ // that stands down leaves the key untouched for whoever handles it next.
123
+ if (binding.handler() === false) continue
124
+ event.preventDefault()
125
+ return
126
+ }
127
+ }
128
+
129
+ onMounted(() => window.addEventListener("keydown", onKeydown))
130
+ onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown))
131
+ }
package/src/index.ts CHANGED
@@ -99,6 +99,8 @@ export { default as LpSelect } from "./components/LpSelect.vue"
99
99
  export type { SelectOption, SelectValue } from "./components/LpSelect.vue"
100
100
  export { default as LpServiceNode } from "./components/LpServiceNode.vue"
101
101
  export type { ServiceNodeData } from "./components/LpServiceNode.vue"
102
+ export { default as LpShortcutsDialog } from "./components/LpShortcutsDialog.vue"
103
+ export type { ShortcutEntry, ShortcutGroup } from "./components/LpShortcutsDialog.vue"
102
104
  export { default as LpSidebar } from "./components/LpSidebar.vue"
103
105
  export type { SidebarItem, SidebarSection } from "./components/LpSidebar.vue"
104
106
  export { default as LpSkeleton } from "./components/LpSkeleton.vue"
@@ -182,6 +184,8 @@ export type { PresetName } from "./theme/presets"
182
184
  export { parseConfig, serializeConfig, UI_CONFIG_VERSION } from "./theme/config"
183
185
  export type { UiConfig } from "./theme/config"
184
186
 
187
+ export { useHotkeys, isAppleKeyboard, appleModifierLabel } from "./composables/useHotkeys"
188
+ export type { HotkeyBinding } from "./composables/useHotkeys"
185
189
  export { useInputFilter } from "./composables/useInputFilter"
186
190
  export type { UseInputFilterOptions } from "./composables/useInputFilter"
187
191
  export { useSettled } from "./composables/useSettled"