@leavepulse/ui 0.40.0 → 0.41.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.
Files changed (41) hide show
  1. package/dist/assets/LpCommandPalette-DyT6aGdW.css +85 -0
  2. package/dist/assets/{LpShortcutsDialog-Cap_ALQM.css → LpShortcutsDialog-C6YoQ8_M.css} +7 -7
  3. package/dist/chunks/{LpAddressInput-H5NdhK6c.js → LpAddressInput-CMjaPQVw.js} +1 -1
  4. package/dist/chunks/{LpAppShell-BfqSA1ba.js → LpAppShell-Bcv6EhqU.js} +1 -1
  5. package/dist/chunks/{LpConfirmDialog-BBRnMkV4.js → LpConfirmDialog-BNAvOR24.js} +2 -2
  6. package/dist/chunks/{LpDrawer-B8vBnCWF.js → LpDrawer-DgaS2s2i.js} +6 -4
  7. package/dist/chunks/{LpModal-EyEdRuqF.js → LpModal-8bja5bWS.js} +5 -3
  8. package/dist/chunks/{LpModalNav-BrDtP7b_.js → LpModalNav-w9kfwHqO.js} +3 -3
  9. package/dist/chunks/{LpSidebar-DZ20iBxY.js → LpSidebar-DlTn5nXc.js} +1 -1
  10. package/dist/components/LpAddressInput.vue.js +1 -1
  11. package/dist/components/LpAppShell.vue.js +1 -1
  12. package/dist/components/LpCommandPalette.vue.d.ts +2 -1
  13. package/dist/components/LpCommandPalette.vue.js +12 -13
  14. package/dist/components/LpConfirmDialog.vue.d.ts +2 -1
  15. package/dist/components/LpConfirmDialog.vue.js +1 -1
  16. package/dist/components/LpDrawer.vue.d.ts +4 -2
  17. package/dist/components/LpDrawer.vue.js +1 -1
  18. package/dist/components/LpModal.vue.d.ts +2 -1
  19. package/dist/components/LpModal.vue.js +1 -1
  20. package/dist/components/LpModalNav.vue.d.ts +2 -1
  21. package/dist/components/LpModalNav.vue.js +1 -1
  22. package/dist/components/LpShortcutsDialog.vue.d.ts +8 -11
  23. package/dist/components/LpShortcutsDialog.vue.js +14 -9
  24. package/dist/components/LpSidebar.vue.js +1 -1
  25. package/dist/composables/useGlass.d.ts +9 -0
  26. package/dist/composables/useGlass.js +13 -0
  27. package/dist/composables/useHotkeys.d.ts +41 -2
  28. package/dist/composables/useHotkeys.js +57 -4
  29. package/dist/index.d.ts +3 -3
  30. package/dist/index.js +13 -8
  31. package/package.json +1 -1
  32. package/src/components/LpCommandPalette.vue +48 -26
  33. package/src/components/LpConfirmDialog.vue +2 -2
  34. package/src/components/LpDrawer.vue +9 -5
  35. package/src/components/LpModal.vue +8 -4
  36. package/src/components/LpModalNav.vue +2 -1
  37. package/src/components/LpShortcutsDialog.vue +32 -23
  38. package/src/composables/useGlass.ts +25 -0
  39. package/src/composables/useHotkeys.ts +107 -6
  40. package/src/index.ts +9 -3
  41. package/dist/assets/LpCommandPalette-B5eZBHox.css +0 -64
@@ -16,13 +16,40 @@ export interface HotkeyBinding {
16
16
  * something there — Escape, and the palette the user reaches from anywhere.
17
17
  */
18
18
  allowInInput?: boolean;
19
+ /**
20
+ * How the key reference lists this binding while it is mounted. Bindings
21
+ * without it still work, they just stay out of `activeShortcuts`.
22
+ */
23
+ help?: HotkeyHelp;
19
24
  /**
20
25
  * Returning `false` declines the key: the event keeps its default and the
21
26
  * search moves on. This is what lets a binding defer to a layer that owns the
22
27
  * key better than it does — Escape belongs to whichever overlay is on top,
23
- * and swallowing it here would close all of them.
28
+ * and swallowing it here would close all of them. The event is passed for
29
+ * the checks only it can answer: where the focus is, whether an inner
30
+ * control already took the key.
31
+ */
32
+ handler: (event: KeyboardEvent) => void | boolean;
33
+ }
34
+ export interface HotkeyHelp {
35
+ /** Section of the reference the entry goes under. */
36
+ group: string;
37
+ label: string;
38
+ /**
39
+ * Written the way it reads, "+"-separated. Defaults to one built from the
40
+ * binding; set it where the binding is not the whole story, such as the
41
+ * second key of a chord ("G+D") or a key that needs Shift to type ("?").
24
42
  */
25
- handler: () => void | boolean;
43
+ combo?: string;
44
+ }
45
+ export interface ShortcutEntry {
46
+ /** Rendered one <kbd> per "+"-separated key, so write it the way it reads. */
47
+ combo: string;
48
+ label: string;
49
+ }
50
+ export interface ShortcutGroup {
51
+ title: string;
52
+ entries: ShortcutEntry[];
26
53
  }
27
54
  /**
28
55
  * Whether this machine prints ⌘ where the rest print Ctrl. Read once: the
@@ -58,3 +85,15 @@ export interface UseHotkeysOptions {
58
85
  scope?: MaybeRefOrGetter<HTMLElement | null | undefined>;
59
86
  }
60
87
  export declare function useHotkeys(bindings: MaybeRefOrGetter<HotkeyBinding[]>, options?: UseHotkeysOptions): void;
88
+ /**
89
+ * The shortcuts that work right now, grouped for `LpShortcutsDialog`: every
90
+ * binding with `help` from every mounted `useHotkeys`. A screen that binds its
91
+ * own keys adds its section while it is open, so the reference never lists a
92
+ * key that does nothing here or misses one that does.
93
+ */
94
+ export declare const activeShortcuts: import('vue').ComputedRef<ShortcutGroup[]>;
95
+ /**
96
+ * Groups merged by title, entries kept once per combo: what a reference shows
97
+ * when it lists both its own entries and the live ones.
98
+ */
99
+ export declare function mergeShortcutGroups(...lists: ShortcutGroup[][]): ShortcutGroup[];
@@ -1,4 +1,4 @@
1
- import { onMounted, onBeforeUnmount, toValue } from "vue";
1
+ import { shallowReactive, computed, onMounted, onBeforeUnmount, toValue } from "vue";
2
2
  const isAppleKeyboard = typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.platform || "");
3
3
  function appleModifierLabel(key) {
4
4
  if (!isAppleKeyboard) return key;
@@ -45,16 +45,69 @@ function useHotkeys(bindings, options = {}) {
45
45
  for (const binding of toValue(bindings)) {
46
46
  if (inTextEntry && !binding.allowInInput) continue;
47
47
  if (!matches(event, binding)) continue;
48
- if (binding.handler() === false) continue;
48
+ if (binding.handler(event) === false) continue;
49
49
  event.preventDefault();
50
50
  return;
51
51
  }
52
52
  }
53
- onMounted(() => window.addEventListener("keydown", onKeydown));
54
- onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
53
+ const read = () => toValue(bindings);
54
+ onMounted(() => {
55
+ window.addEventListener("keydown", onKeydown);
56
+ mounted.add(read);
57
+ });
58
+ onBeforeUnmount(() => {
59
+ window.removeEventListener("keydown", onKeydown);
60
+ mounted.delete(read);
61
+ });
62
+ }
63
+ const mounted = shallowReactive(/* @__PURE__ */ new Set());
64
+ const KEY_NAMES = {
65
+ escape: "Esc",
66
+ enter: "Enter",
67
+ backspace: "Backspace",
68
+ delete: "Delete",
69
+ tab: "Tab",
70
+ " ": "Space",
71
+ arrowup: "↑",
72
+ arrowdown: "↓",
73
+ arrowleft: "←",
74
+ arrowright: "→"
75
+ };
76
+ function comboOf(binding) {
77
+ const key = binding.key.toLowerCase();
78
+ const name = KEY_NAMES[key] ?? (key.length === 1 || /^f\d+$/.test(key) ? key.toUpperCase() : binding.key);
79
+ return [binding.ctrl && "Ctrl", binding.alt && "Alt", binding.shift && "Shift", name].filter(Boolean).join("+");
80
+ }
81
+ const activeShortcuts = computed(() => {
82
+ const groups = /* @__PURE__ */ new Map();
83
+ for (const read of mounted) {
84
+ for (const binding of read()) {
85
+ if (!binding.help) continue;
86
+ const combo = binding.help.combo ?? comboOf(binding);
87
+ const entries = groups.get(binding.help.group) ?? [];
88
+ if (!entries.some((e) => e.combo === combo)) entries.push({ combo, label: binding.help.label });
89
+ groups.set(binding.help.group, entries);
90
+ }
91
+ }
92
+ return [...groups].map(([title, entries]) => ({ title, entries }));
93
+ });
94
+ function mergeShortcutGroups(...lists) {
95
+ const groups = /* @__PURE__ */ new Map();
96
+ for (const list of lists) {
97
+ for (const group of list) {
98
+ const entries = groups.get(group.title) ?? [];
99
+ for (const entry of group.entries) {
100
+ if (!entries.some((e) => e.combo === entry.combo)) entries.push(entry);
101
+ }
102
+ groups.set(group.title, entries);
103
+ }
104
+ }
105
+ return [...groups].map(([title, entries]) => ({ title, entries }));
55
106
  }
56
107
  export {
108
+ activeShortcuts,
57
109
  appleModifierLabel,
58
110
  isAppleKeyboard,
111
+ mergeShortcutGroups,
59
112
  useHotkeys
60
113
  };
package/dist/index.d.ts CHANGED
@@ -91,7 +91,6 @@ export { default as LpServiceNode } from './components/LpServiceNode.vue';
91
91
  export type { ServiceNodeData } from './components/LpServiceNode.vue';
92
92
  export { default as LpShift } from './components/LpShift.vue';
93
93
  export { default as LpShortcutsDialog } from './components/LpShortcutsDialog.vue';
94
- export type { ShortcutEntry, ShortcutGroup } from './components/LpShortcutsDialog.vue';
95
94
  export { default as LpSidebar } from './components/LpSidebar.vue';
96
95
  export type { SidebarItem, SidebarSection } from './components/LpSidebar.vue';
97
96
  export { default as LpSkeleton } from './components/LpSkeleton.vue';
@@ -135,8 +134,9 @@ export { parseConfig, serializeConfig, UI_CONFIG_VERSION } from './theme/config'
135
134
  export type { UiConfig } from './theme/config';
136
135
  export { useClipboard } from './composables/useClipboard';
137
136
  export type { CopyOptions, UseClipboard } from './composables/useClipboard';
138
- export { useHotkeys, isAppleKeyboard, appleModifierLabel } from './composables/useHotkeys';
139
- export type { HotkeyBinding } from './composables/useHotkeys';
137
+ export { useHotkeys, isAppleKeyboard, appleModifierLabel, activeShortcuts, mergeShortcutGroups, } from './composables/useHotkeys';
138
+ export type { HotkeyBinding, HotkeyHelp, ShortcutEntry, ShortcutGroup } from './composables/useHotkeys';
139
+ export { provideGlass, useGlass } from './composables/useGlass';
140
140
  export { useMergedClass, useMergedAttrs, mergeClass } from './composables/useMergedClass';
141
141
  export { useInputFilter } from './composables/useInputFilter';
142
142
  export type { UseInputFilterOptions } from './composables/useInputFilter';
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { _ } from "./chunks/LayoutCanvas-Dd20boUG.js";
2
- import { _ as _2 } from "./chunks/LpAddressInput-H5NdhK6c.js";
2
+ import { _ as _2 } from "./chunks/LpAddressInput-CMjaPQVw.js";
3
3
  import { _ as _3 } from "./chunks/LpMapPicker-ul4oYno6.js";
4
4
  import { OSM_TILES, createNominatimProvider, emptyAddress, formatAddress, project, tileUrl, unproject } from "./components/address.js";
5
5
  import { _ as _4 } from "./chunks/LpActionBar-BlvtGB2P.js";
6
6
  import { _ as _5 } from "./chunks/LpAlert-BAYHDSS1.js";
7
- import { _ as _6 } from "./chunks/LpAppShell-BfqSA1ba.js";
7
+ import { _ as _6 } from "./chunks/LpAppShell-Bcv6EhqU.js";
8
8
  import { _ as _7 } from "./chunks/LpAutocomplete-DExlIZe_.js";
9
9
  import { _ as _8 } from "./chunks/LpAvatar-B3-xAOH3.js";
10
10
  import { _ as _9 } from "./chunks/LpBadge-Fe0D1bYx.js";
@@ -19,12 +19,12 @@ import { _ as _15 } from "./chunks/LpCodeBlock-Bo0B4TFR.js";
19
19
  import { _ as _16 } from "./chunks/LpColorPicker-BlXea7LM.js";
20
20
  import { DEFAULT_SWATCHES, clampHsv, formatColor, hsvToHsl, hsvToRgb, isLight, normalizeHue, opaqueCss, parseColor, parseHex, rgbToHex, rgbToHsv } from "./components/color.js";
21
21
  import { default as default3 } from "./components/LpCommandPalette.vue.js";
22
- import { _ as _17 } from "./chunks/LpConfirmDialog-BBRnMkV4.js";
22
+ import { _ as _17 } from "./chunks/LpConfirmDialog-BNAvOR24.js";
23
23
  import { _ as _18 } from "./chunks/LpContextMenu-CwW1QXe_.js";
24
24
  import { _ as _19 } from "./chunks/LpDatePicker-CwOToxGq.js";
25
25
  import { _ as _20 } from "./chunks/LpDisclosure-ChQYmjlR.js";
26
26
  import { _ as _21 } from "./chunks/LpDivider-CUX46KR0.js";
27
- import { _ as _22 } from "./chunks/LpDrawer-B8vBnCWF.js";
27
+ import { _ as _22 } from "./chunks/LpDrawer-DgaS2s2i.js";
28
28
  import { _ as _23 } from "./chunks/LpDropdownMenu-DJpoqRCb.js";
29
29
  import { default as default4 } from "./components/LpDropzone.vue.js";
30
30
  import { _ as _24 } from "./chunks/LpEmptyState-Dsdqz1Wj.js";
@@ -40,8 +40,8 @@ import { fileNameOf } from "./components/lightbox.js";
40
40
  import { _ as _31 } from "./chunks/LpLink-DCND-zyu.js";
41
41
  import { _ as _32 } from "./chunks/LpLogViewer-CaRy5MeS.js";
42
42
  import { _ as _33 } from "./chunks/LpMediaGallery-Cid7PGYx.js";
43
- import { _ as _34 } from "./chunks/LpModal-EyEdRuqF.js";
44
- import { _ as _35 } from "./chunks/LpModalNav-BrDtP7b_.js";
43
+ import { _ as _34 } from "./chunks/LpModal-8bja5bWS.js";
44
+ import { _ as _35 } from "./chunks/LpModalNav-w9kfwHqO.js";
45
45
  import { _ as _36 } from "./chunks/LpNotificationBell-DyGGsMAM.js";
46
46
  import { _ as _37 } from "./chunks/LpNumberField-CWuX08iX.js";
47
47
  import { _ as _38 } from "./chunks/LpTimeField-K6nAGboX.js";
@@ -64,7 +64,7 @@ import { _ as _53 } from "./chunks/LpSelect-ANIVtrpM.js";
64
64
  import { _ as _54 } from "./chunks/LpServiceNode-D8uJ8sGk.js";
65
65
  import { _ as _55 } from "./chunks/LpShift-DXNsauQZ.js";
66
66
  import { default as default6 } from "./components/LpShortcutsDialog.vue.js";
67
- import { _ as _56 } from "./chunks/LpSidebar-DZ20iBxY.js";
67
+ import { _ as _56 } from "./chunks/LpSidebar-DlTn5nXc.js";
68
68
  import { _ as _57 } from "./chunks/LpSkeleton-CTI-vBgW.js";
69
69
  import { _ as _58 } from "./chunks/LpSlider-BwNIBkAK.js";
70
70
  import { _ as _59 } from "./chunks/LpSparkline-C6xQ2dwv.js";
@@ -87,7 +87,8 @@ import { addLeaf, addTab, countBlocks, countLeaves, makeLayout, moveLeaf, remove
87
87
  import { blockTitle, defineBlocks } from "./layout/registry.js";
88
88
  import { useLayout } from "./layout/useLayout.js";
89
89
  import { useClipboard } from "./composables/useClipboard.js";
90
- import { appleModifierLabel, isAppleKeyboard, useHotkeys } from "./composables/useHotkeys.js";
90
+ import { activeShortcuts, appleModifierLabel, isAppleKeyboard, mergeShortcutGroups, useHotkeys } from "./composables/useHotkeys.js";
91
+ import { provideGlass, useGlass } from "./composables/useGlass.js";
91
92
  import { mergeClass, useMergedAttrs, useMergedClass } from "./composables/useMergedClass.js";
92
93
  import { useInputFilter } from "./composables/useInputFilter.js";
93
94
  import { easeOut, easeTravel, easingToken, prefersReducedMotion } from "./composables/easing.js";
@@ -215,6 +216,7 @@ export {
215
216
  OSM_TILES,
216
217
  T as THEME_COOKIE,
217
218
  UI_CONFIG_VERSION,
219
+ activeShortcuts,
218
220
  addLeaf,
219
221
  addTab,
220
222
  b as amber,
@@ -261,6 +263,7 @@ export {
261
263
  makeLayout,
262
264
  matchCountryByValue,
263
265
  mergeClass,
266
+ mergeShortcutGroups,
264
267
  moveLeaf,
265
268
  niceScale,
266
269
  n as nord,
@@ -273,6 +276,7 @@ export {
273
276
  prefersReducedMotion,
274
277
  presets,
275
278
  project,
279
+ provideGlass,
276
280
  removeBlock,
277
281
  removeLeaf,
278
282
  reorderTab,
@@ -296,6 +300,7 @@ export {
296
300
  useAsyncTask,
297
301
  useClipboard,
298
302
  useFormDraft,
303
+ useGlass,
299
304
  useHotkeys,
300
305
  useInputFilter,
301
306
  useLayout,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -40,6 +40,7 @@ import { computed, nextTick, ref, shallowRef, useId, watch } from "vue"
40
40
  import LpIcon from "./LpIcon.vue"
41
41
  import LpScrollArea from "./LpScrollArea.vue"
42
42
  import { useAsyncTask } from "../composables/useAsyncTask"
43
+ import { useGlass } from "../composables/useGlass"
43
44
  import { useClipboard } from "../composables/useClipboard"
44
45
  import { useHotkeys } from "../composables/useHotkeys"
45
46
 
@@ -210,7 +211,8 @@ const props = withDefaults(
210
211
  /**
211
212
  * Frosted glass instead of a solid panel: the page shows through,
212
213
  * blurred, under a lighter scrim (.lp-glass-overlay, .lp-scrim--light).
213
- * A look, not a behaviour, so it is the app's call and off by default.
214
+ * A look, not a behaviour, so it is the app's call: unset follows
215
+ * `provideGlass`, and without one it is off.
214
216
  */
215
217
  glass?: boolean
216
218
  }>(),
@@ -227,10 +229,12 @@ const props = withDefaults(
227
229
  groupLimit: 0,
228
230
  layoutFallback: true,
229
231
  previewPane: false,
230
- glass: false,
232
+ glass: undefined,
231
233
  },
232
234
  )
233
235
 
236
+ const frosted = useGlass(() => props.glass)
237
+
234
238
  const DEFAULT_LABELS: PaletteLabels = {
235
239
  navigate: "navigate",
236
240
  select: "select",
@@ -864,16 +868,13 @@ useHotkeys(() =>
864
868
  <DialogRoot :open="open" @update:open="(v) => emit('update:open', v)">
865
869
  <DialogPortal>
866
870
  <DialogOverlay
867
- :class="{ 'lp-scrim--light': glass }"
871
+ :class="{ 'lp-scrim--light': frosted }"
868
872
  class="lp-scrim fixed inset-0 z-(--z-overlay) data-[state=open]:animate-[fade-in_150ms_ease] data-[state=closed]:animate-[fade-out_130ms_ease]"
869
873
  />
870
874
  <DialogContent
871
- class="fixed left-1/2 top-[12vh] z-(--z-modal) flex max-h-[76vh] -translate-x-1/2 flex-col overflow-hidden rounded-card border border-line shadow-panel outline-none data-[state=open]:animate-[pop-in_160ms_var(--ease-emphasized)] data-[state=closed]:animate-[pop-out_130ms_ease]"
872
- :class="[glass ? 'lp-glass-overlay' : 'bg-surface-raised', previewPane && 'lp-cmd-resizing']"
873
- :style="{
874
- width: paneOpen ? 'min(92vw, 64rem)' : 'min(92vw, 40rem)',
875
- minHeight: paneOpen ? 'min(76vh, 36rem)' : '0px',
876
- }"
875
+ class="lp-cmd-dialog fixed left-1/2 top-[12vh] z-(--z-modal) flex max-h-[76vh] -translate-x-1/2 flex-col overflow-hidden rounded-card border border-line shadow-panel outline-none data-[state=open]:animate-[pop-in_160ms_var(--ease-emphasized)] data-[state=closed]:animate-[pop-out_130ms_ease]"
876
+ :class="[frosted ? 'lp-glass-overlay' : 'bg-surface-raised', previewPane && 'lp-cmd-split']"
877
+ :data-pane="paneOpen ? 'open' : undefined"
877
878
  @open-auto-focus.prevent
878
879
  >
879
880
  <DialogTitle class="sr-only">Command palette</DialogTitle>
@@ -927,7 +928,7 @@ useHotkeys(() =>
927
928
 
928
929
  <!-- Results -->
929
930
  <div class="flex min-h-0 flex-1">
930
- <LpScrollArea ref="scrollRef" class="min-h-0 min-w-0 flex-1" content-class="p-2">
931
+ <LpScrollArea ref="scrollRef" class="lp-cmd-list min-h-0 min-w-0 flex-1" content-class="p-2">
931
932
  <!-- While a request is out there is nothing to say yet: "no results"
932
933
  under a spinner reads as an answer, and it is not one. -->
933
934
  <p v-if="flat.length === 0 && !failed" class="px-2 py-10 text-center text-sm text-muted">
@@ -1040,7 +1041,7 @@ useHotkeys(() =>
1040
1041
  <Transition name="lp-cmd-pane">
1041
1042
  <aside
1042
1043
  v-if="paneOpen"
1043
- class="hidden min-h-0 w-[56%] shrink-0 flex-col border-l border-line md:flex"
1044
+ class="lp-cmd-pane hidden min-h-0 shrink-0 flex-col border-l border-line md:flex"
1044
1045
  >
1045
1046
  <LpScrollArea ref="previewRef" class="min-h-0 flex-1" content-class="px-5 py-4">
1046
1047
  <!-- Keyed by the row, so moving the highlight replays the
@@ -1107,28 +1108,49 @@ useHotkeys(() =>
1107
1108
 
1108
1109
  <style scoped>
1109
1110
  /*
1110
- * The dialog changes width and height when the preview pane comes and goes;
1111
- * eased, so the list does not jump sideways under the pointer.
1111
+ * Sizes for the split layout. Only the dialog's width animates: the list and
1112
+ * the pane keep fixed widths, and the dialog — overflow hidden — uncovers the
1113
+ * pane from its right edge as it grows. Animating the columns themselves made
1114
+ * every line of the list and the pane re-wrap on every frame.
1112
1115
  */
1113
- .lp-cmd-resizing {
1114
- transition:
1115
- width 260ms var(--ease-emphasized, ease),
1116
- min-height 260ms var(--ease-emphasized, ease);
1116
+ .lp-cmd-dialog {
1117
+ --lp-cmd-list: min(92vw, 40rem);
1118
+ --lp-cmd-pane: max(0px, min(26rem, 96vw - var(--lp-cmd-list)));
1119
+ width: var(--lp-cmd-list);
1117
1120
  }
1118
- .lp-cmd-pane-enter-active {
1121
+ .lp-cmd-split {
1119
1122
  transition:
1120
- opacity 220ms ease 60ms,
1121
- transform 260ms var(--ease-emphasized, ease);
1123
+ width 300ms var(--ease-emphasized, ease),
1124
+ min-height 300ms var(--ease-emphasized, ease);
1122
1125
  }
1123
- .lp-cmd-pane-leave-active {
1124
- transition: opacity 120ms ease;
1126
+ @media (min-width: 768px) {
1127
+ .lp-cmd-split .lp-cmd-list {
1128
+ flex: 0 0 auto;
1129
+ /* the dialog's 1px border on each side */
1130
+ width: calc(var(--lp-cmd-list) - 2px);
1131
+ }
1132
+ .lp-cmd-dialog[data-pane="open"] {
1133
+ width: calc(var(--lp-cmd-list) + var(--lp-cmd-pane));
1134
+ min-height: min(76vh, 36rem);
1135
+ }
1125
1136
  }
1126
- .lp-cmd-pane-enter-from {
1127
- opacity: 0;
1128
- transform: translateX(16px);
1137
+ .lp-cmd-pane {
1138
+ width: var(--lp-cmd-pane);
1139
+ }
1140
+ /*
1141
+ * The pane rides in from the right edge while the dialog opens up to it, and
1142
+ * leaves the same way, for as long as the dialog takes to close over it.
1143
+ */
1144
+ .lp-cmd-pane-enter-active,
1145
+ .lp-cmd-pane-leave-active {
1146
+ transition:
1147
+ transform 300ms var(--ease-emphasized, ease),
1148
+ opacity 300ms ease;
1129
1149
  }
1150
+ .lp-cmd-pane-enter-from,
1130
1151
  .lp-cmd-pane-leave-to {
1131
1152
  opacity: 0;
1153
+ transform: translateX(40%);
1132
1154
  }
1133
1155
  .lp-cmd-para {
1134
1156
  animation: lp-cmd-para-in 260ms ease-out both;
@@ -1140,7 +1162,7 @@ useHotkeys(() =>
1140
1162
  }
1141
1163
  }
1142
1164
  @media (prefers-reduced-motion: reduce) {
1143
- .lp-cmd-resizing,
1165
+ .lp-cmd-split,
1144
1166
  .lp-cmd-pane-enter-active,
1145
1167
  .lp-cmd-pane-leave-active {
1146
1168
  transition: none;
@@ -36,10 +36,10 @@ const props = withDefaults(
36
36
  */
37
37
  loading?: boolean
38
38
  loadingLabel?: string
39
- /** Frosted glass panel, passed to LpModal. Off by default. */
39
+ /** Frosted glass panel, passed to LpModal. Follows `provideGlass` when unset. */
40
40
  glass?: boolean
41
41
  }>(),
42
- { confirmLabel: "Confirm", cancelLabel: "Cancel", loadingLabel: "Working…" },
42
+ { confirmLabel: "Confirm", cancelLabel: "Cancel", loadingLabel: "Working…", glass: undefined },
43
43
  )
44
44
 
45
45
  const emit = defineEmits<{
@@ -31,6 +31,7 @@ import {
31
31
  } from "vaul-vue"
32
32
  import { computed, onBeforeUnmount, ref, useSlots, watch } from "vue"
33
33
  import { CLOSE_ICON } from "./dropdown"
34
+ import { useGlass } from "../composables/useGlass"
34
35
  import LpIcon from "./LpIcon.vue"
35
36
  import LpScrollArea from "./LpScrollArea.vue"
36
37
  import { anyModalOpen, useModalLayer } from "./modalDepth"
@@ -104,7 +105,8 @@ const props = withDefaults(
104
105
  /**
105
106
  * Frosted glass instead of a solid panel: the page shows through,
106
107
  * blurred, under a lighter scrim (.lp-glass-overlay, .lp-scrim--light).
107
- * A look, not a behaviour, so it is the app's call and off by default.
108
+ * A look, not a behaviour, so it is the app's call: unset follows
109
+ * `provideGlass`, and without one it is off.
108
110
  */
109
111
  glass?: boolean
110
112
  }>(),
@@ -117,10 +119,12 @@ const props = withDefaults(
117
119
  edgeOpenThreshold: 0.3,
118
120
  minSize: "22rem",
119
121
  maxSize: "96vw",
120
- glass: false,
122
+ glass: undefined,
121
123
  },
122
124
  )
123
125
 
126
+ const frosted = useGlass(() => props.glass)
127
+
124
128
  const emit = defineEmits<{
125
129
  (e: "update:open", value: boolean): void
126
130
  }>()
@@ -149,7 +153,7 @@ watch(() => props.open, (open) => (open ? claim() : release()), { immediate: tru
149
153
  onBeforeUnmount(release)
150
154
 
151
155
  const contentClass = computed(() => {
152
- const surface = props.glass ? "lp-glass-overlay" : "bg-surface-raised"
156
+ const surface = frosted.value ? "lp-glass-overlay" : "bg-surface-raised"
153
157
  const base = `fixed z-(--z-modal) flex flex-col border-line ${surface} shadow-panel outline-none`
154
158
  const map: Record<Direction, string> = {
155
159
  left: "inset-y-0 left-0 border-r rounded-r-card",
@@ -439,7 +443,7 @@ const previewStyle = computed(() => {
439
443
  <div
440
444
  v-show="peeking || peek > 0"
441
445
  class="lp-scrim pointer-events-none fixed inset-0"
442
- :class="{ 'lp-scrim--light': glass }"
446
+ :class="{ 'lp-scrim--light': frosted }"
443
447
  :style="{ zIndex: layer.scrim, opacity: peek, transition: peeking || reducedMotion() ? 'none' : 'opacity 0.18s ease-out' }"
444
448
  />
445
449
  <div
@@ -472,7 +476,7 @@ const previewStyle = computed(() => {
472
476
  <DrawerOverlay
473
477
  :style="{ zIndex: layer.scrim }"
474
478
  class="lp-scrim fixed inset-0 z-(--z-overlay) data-[state=open]:animate-[fade-in_150ms_ease] data-[state=closed]:animate-[fade-out_130ms_ease]"
475
- :class="{ 'lp-scrim--light': glass }"
479
+ :class="{ 'lp-scrim--light': frosted }"
476
480
  />
477
481
  <DrawerContent
478
482
  :class="contentClass"
@@ -9,6 +9,7 @@ import {
9
9
  DialogTitle,
10
10
  } from "reka-ui"
11
11
  import { computed, onBeforeUnmount, ref, useSlots, watch } from "vue"
12
+ import { useGlass } from "../composables/useGlass"
12
13
  import { useShift } from "../composables/useShift"
13
14
  import { useModalLayer } from "./modalDepth"
14
15
  import { CLOSE_ICON } from "./dropdown"
@@ -65,13 +66,16 @@ const props = withDefaults(
65
66
  /**
66
67
  * Frosted glass instead of a solid panel: the page shows through,
67
68
  * blurred, under a lighter scrim (.lp-glass-overlay, .lp-scrim--light).
68
- * A look, not a behaviour, so it is the app's call and off by default.
69
+ * A look, not a behaviour, so it is the app's call: unset follows
70
+ * `provideGlass`, and without one it is off.
69
71
  */
70
72
  glass?: boolean
71
73
  }>(),
72
- { size: "md", autoFocus: "auto", glass: false },
74
+ { size: "md", autoFocus: "auto", glass: undefined },
73
75
  )
74
76
 
77
+ const frosted = useGlass(() => props.glass)
78
+
75
79
  defineEmits<{ (e: "update:open", value: boolean): void }>()
76
80
 
77
81
  // Spread rather than a plain `:aria-describedby` binding: with a description
@@ -228,7 +232,7 @@ watch(
228
232
  <DialogRoot :open="open" @update:open="(v) => $emit('update:open', v)">
229
233
  <DialogPortal>
230
234
  <DialogOverlay
231
- :class="{ 'lp-scrim--light': glass }"
235
+ :class="{ 'lp-scrim--light': frosted }"
232
236
  class="lp-scrim fixed inset-0 data-[state=open]:animate-[fade-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[fade-out_120ms_ease]"
233
237
  :style="{ zIndex: layer.scrim }"
234
238
  />
@@ -298,7 +302,7 @@ watch(
298
302
  <div
299
303
  ref="panelRef"
300
304
  class="flex max-h-[min(90vh,calc(100dvh-2rem))] min-h-0 flex-col overflow-hidden rounded-card border border-line shadow-panel"
301
- :class="[glass ? 'lp-glass-overlay' : 'bg-surface-raised', widthClass, tweening ? 'transition-[height] duration-fast ease-[var(--ease-emphasized)] motion-reduce:transition-none' : '']"
305
+ :class="[frosted ? 'lp-glass-overlay' : 'bg-surface-raised', widthClass, tweening ? 'transition-[height] duration-fast ease-[var(--ease-emphasized)] motion-reduce:transition-none' : '']"
302
306
  :style="{ ...(width ? { width } : {}), ...(hasAside ? { alignSelf: 'stretch' } : {}) }"
303
307
  >
304
308
  <header v-if="title || $slots.title" class="flex shrink-0 items-start justify-between gap-4 p-5 pb-3">
@@ -55,7 +55,7 @@ const props = withDefaults(
55
55
  * column inside the body, and the rail is no longer in the body.
56
56
  */
57
57
  fillBody?: boolean
58
- /** Frosted glass panel, passed to LpModal. Off by default. */
58
+ /** Frosted glass panel, passed to LpModal. Follows `provideGlass` when unset. */
59
59
  glass?: boolean
60
60
  }>(),
61
61
  {
@@ -64,6 +64,7 @@ const props = withDefaults(
64
64
  mobileBreakpoint: "lg",
65
65
  navLabel: "Sections",
66
66
  navSide: "start",
67
+ glass: undefined,
67
68
  },
68
69
  )
69
70
 
@@ -2,40 +2,49 @@
2
2
  /*
3
3
  * A keyboard reference, conventionally opened with F1.
4
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.
5
+ * Two sources, used together or apart. `groups` is the written list: keys
6
+ * that are not bindings at all (arrows inside a list, Backspace in a palette)
7
+ * and the ones worth knowing before their screen is open. `live` adds every
8
+ * mounted `useHotkeys` binding that carries `help`, so a screen with its own
9
+ * keys documents them while it is open and drops them when it closes. Groups
10
+ * of the same title merge, and a combo is listed once.
9
11
  *
10
- * Combos are written the way they read ("Ctrl+Shift+M", "←→↑↓") and split on
11
- * "+" into one <kbd> each.
12
+ * Combos are written the way they read ("Ctrl+Shift+M", "Ctrl++", "←→↑↓") and
13
+ * split on "+" into one <kbd> each.
12
14
  */
15
+ import { computed } from "vue"
13
16
  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
- }
17
+ import {
18
+ activeShortcuts,
19
+ appleModifierLabel,
20
+ mergeShortcutGroups,
21
+ type ShortcutGroup,
22
+ } from "../composables/useHotkeys"
26
23
 
27
24
  const props = withDefaults(
28
25
  defineProps<{
29
26
  open?: boolean
30
- groups: ShortcutGroup[]
27
+ groups?: ShortcutGroup[]
28
+ /** Also list the bindings mounted right now (`useHotkeys` with `help`). */
29
+ live?: boolean
31
30
  title?: string
32
31
  description?: string
33
- /** Frosted glass panel, passed to LpModal. Off by default. */
32
+ /** Frosted glass panel, passed to LpModal. Follows `provideGlass` when unset. */
34
33
  glass?: boolean
35
34
  }>(),
36
- { open: false, title: "Keyboard shortcuts" },
35
+ { open: false, groups: () => [], live: false, title: "Keyboard shortcuts", glass: undefined },
37
36
  )
38
37
 
38
+ /*
39
+ * One key per "+", except a "+" that is the key itself: "Ctrl++" is Ctrl and
40
+ * plus, not Ctrl and two blanks.
41
+ */
42
+ function keysOf(combo: string): string[] {
43
+ return combo.split(/\+(?!$)/)
44
+ }
45
+
46
+ const shown = computed(() => (props.live ? mergeShortcutGroups(props.groups, activeShortcuts.value) : props.groups))
47
+
39
48
  const emit = defineEmits<{ (e: "update:open", value: boolean): void }>()
40
49
  </script>
41
50
 
@@ -49,13 +58,13 @@ const emit = defineEmits<{ (e: "update:open", value: boolean): void }>()
49
58
  @update:open="(value: boolean) => emit('update:open', value)"
50
59
  >
51
60
  <div class="shortcut-groups">
52
- <section v-for="group in props.groups" :key="group.title" class="shortcut-group">
61
+ <section v-for="group in shown" :key="group.title" class="shortcut-group">
53
62
  <h3>{{ group.title }}</h3>
54
63
  <dl>
55
64
  <div v-for="entry in group.entries" :key="entry.combo" class="shortcut-row">
56
65
  <dt>{{ entry.label }}</dt>
57
66
  <dd>
58
- <kbd v-for="(key, index) in entry.combo.split('+')" :key="`${key}-${index}`">
67
+ <kbd v-for="(key, index) in keysOf(entry.combo)" :key="`${key}-${index}`">
59
68
  {{ appleModifierLabel(key.trim()) }}
60
69
  </kbd>
61
70
  </dd>
@@ -0,0 +1,25 @@
1
+ import { computed, inject, provide, toValue, type ComputedRef, type InjectionKey, type MaybeRefOrGetter } from "vue"
2
+
3
+ /*
4
+ * Frosted glass as an app-wide default for overlays (modals, drawers, dialogs,
5
+ * the command palette). An app that wants every overlay frosted says so once
6
+ * at its root instead of on each of dozens of call sites, where the next new
7
+ * dialog would be the one that forgets. An explicit `glass` prop on a single
8
+ * overlay still wins, either way.
9
+ */
10
+ const GLASS: InjectionKey<MaybeRefOrGetter<boolean>> = Symbol("lp-glass")
11
+
12
+ /** Make `glass` the default for every overlay below the calling component. */
13
+ export function provideGlass(enabled: MaybeRefOrGetter<boolean> = true): void {
14
+ provide(GLASS, enabled)
15
+ }
16
+
17
+ /**
18
+ * The glass flag an overlay renders with: its own prop when set, otherwise the
19
+ * nearest `provideGlass`, otherwise off. The prop must default to `undefined`,
20
+ * not `false`, or an unset prop could not be told apart from an explicit off.
21
+ */
22
+ export function useGlass(own: () => boolean | undefined): ComputedRef<boolean> {
23
+ const inherited = inject(GLASS, false)
24
+ return computed(() => own() ?? toValue(inherited))
25
+ }