@openpen/module-api 1.1.0 → 1.2.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 (98) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/_chunks/AppButtonDropdown.vue_vue_type_style_index_0_lang-DBVFUpxc.js +245 -0
  3. package/dist/_chunks/AppButtonDropdown.vue_vue_type_style_index_0_lang-DBVFUpxc.js.map +1 -0
  4. package/dist/_chunks/inject-keys-CRXCpN-M.js +196 -0
  5. package/dist/_chunks/inject-keys-CRXCpN-M.js.map +1 -0
  6. package/dist/_chunks/module-context-registry-Cn1IWYu9.js +16 -0
  7. package/dist/_chunks/module-context-registry-Cn1IWYu9.js.map +1 -0
  8. package/dist/_chunks/passthrough-DKU7JYuQ.js +6 -0
  9. package/dist/_chunks/passthrough-DKU7JYuQ.js.map +1 -0
  10. package/dist/cursors.d.ts +83 -0
  11. package/dist/define-module.d.ts +17 -0
  12. package/dist/host/color-utils.d.ts +11 -0
  13. package/{src/host/event-bus.ts → dist/host/event-bus.d.ts} +7 -5
  14. package/dist/host/host-commands.d.ts +1 -0
  15. package/dist/host/index.d.ts +24 -0
  16. package/dist/host/index.js +49 -0
  17. package/dist/host/index.js.map +1 -0
  18. package/{src/host/module-context-registry.ts → dist/host/module-context-registry.d.ts} +5 -19
  19. package/dist/host/passthrough.d.ts +2 -0
  20. package/dist/host/popup-anchor.d.ts +3 -0
  21. package/dist/host/registry.d.ts +39 -0
  22. package/dist/host/registry.js +37 -0
  23. package/dist/host/registry.js.map +1 -0
  24. package/dist/host/slot-entries.d.ts +2 -0
  25. package/dist/host/stroke-store.d.ts +5 -0
  26. package/dist/host/stroke-style.d.ts +3 -0
  27. package/dist/host/types.d.ts +164 -0
  28. package/dist/index.d.ts +28 -0
  29. package/dist/index.js +349 -0
  30. package/dist/index.js.map +1 -0
  31. package/{src/inject-keys.ts → dist/inject-keys.d.ts} +37 -66
  32. package/{src/locale.ts → dist/locale.d.ts} +3 -22
  33. package/dist/slots.d.ts +57 -0
  34. package/dist/types/contributions.d.ts +288 -0
  35. package/dist/types/control-bar-layout.d.ts +227 -0
  36. package/dist/types/module.d.ts +208 -0
  37. package/{src/types/notification.ts → dist/types/notification.d.ts} +12 -14
  38. package/dist/types/tool.d.ts +77 -0
  39. package/dist/uikit/components/AppBanner.vue.d.ts +34 -0
  40. package/dist/uikit/components/AppButton.vue.d.ts +30 -0
  41. package/dist/uikit/components/AppButtonDropdown.vue.d.ts +52 -0
  42. package/dist/uikit/components/AppDialog.vue.d.ts +53 -0
  43. package/dist/uikit/components/AppPopover.vue.d.ts +43 -0
  44. package/dist/uikit/components/AppSegmented.vue.d.ts +19 -0
  45. package/dist/uikit/components/AppSelect.vue.d.ts +26 -0
  46. package/dist/uikit/components/AppSlider.vue.d.ts +43 -0
  47. package/dist/uikit/components/AppTabs.vue.d.ts +35 -0
  48. package/dist/uikit/components/AppToggle.vue.d.ts +13 -0
  49. package/dist/uikit/components/AppTooltip.vue.d.ts +27 -0
  50. package/dist/uikit/components/DialogHost.vue.d.ts +3 -0
  51. package/{src/uikit/composables/dialog-internal-keys.ts → dist/uikit/composables/dialog-internal-keys.d.ts} +3 -5
  52. package/dist/uikit/composables/useDialog.d.ts +399 -0
  53. package/dist/uikit/composables/useDialogPluginComponent.d.ts +9 -0
  54. package/dist/uikit/index.d.ts +42 -0
  55. package/dist/uikit/index.js +884 -0
  56. package/dist/uikit/index.js.map +1 -0
  57. package/{src/uikit/internal.ts → dist/uikit/internal.d.ts} +1 -1
  58. package/dist/uikit/internal.js +157 -0
  59. package/dist/uikit/internal.js.map +1 -0
  60. package/dist/uikit/module-api.css +851 -0
  61. package/dist/uikit/primitives.d.ts +42 -0
  62. package/{src/use-module-context.ts → dist/use-module-context.d.ts} +2 -15
  63. package/{src/validation.ts → dist/validation.d.ts} +2 -6
  64. package/package.json +26 -12
  65. package/packages/module-api/CHANGELOG.md +0 -27
  66. package/src/cursors.ts +0 -369
  67. package/src/define-module.ts +0 -55
  68. package/src/host/color-utils.ts +0 -18
  69. package/src/host/host-commands.ts +0 -9
  70. package/src/host/index.ts +0 -53
  71. package/src/host/passthrough.ts +0 -10
  72. package/src/host/popup-anchor.ts +0 -14
  73. package/src/host/slot-entries.ts +0 -9
  74. package/src/host/stroke-store.ts +0 -20
  75. package/src/host/stroke-style.ts +0 -15
  76. package/src/index.ts +0 -131
  77. package/src/slots.ts +0 -135
  78. package/src/types/contributions.ts +0 -333
  79. package/src/types/control-bar-layout.ts +0 -174
  80. package/src/types/module.ts +0 -225
  81. package/src/types/tool.ts +0 -82
  82. package/src/uikit/components/.gitkeep +0 -0
  83. package/src/uikit/components/AppBanner.vue +0 -131
  84. package/src/uikit/components/AppButton.vue +0 -135
  85. package/src/uikit/components/AppDialog.vue +0 -393
  86. package/src/uikit/components/AppPopover.vue +0 -268
  87. package/src/uikit/components/AppSegmented.vue +0 -128
  88. package/src/uikit/components/AppSelect.vue +0 -235
  89. package/src/uikit/components/AppSlider.vue +0 -204
  90. package/src/uikit/components/AppTabs.vue +0 -163
  91. package/src/uikit/components/AppToggle.vue +0 -96
  92. package/src/uikit/components/AppTooltip.vue +0 -118
  93. package/src/uikit/components/DialogHost.vue +0 -272
  94. package/src/uikit/composables/useDialog.ts +0 -141
  95. package/src/uikit/composables/useDialogPluginComponent.ts +0 -27
  96. package/src/uikit/index.ts +0 -79
  97. package/src/uikit/primitives.ts +0 -150
  98. /package/{src → dist}/uikit/tokens.css +0 -0
@@ -1,393 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * AppDialog — High-level wrapper (Layer 1).
4
- *
5
- * Plugin authors use this component without needing to know about inject
6
- * keys, Reka UI, modal manager mutual exclusion, or the animating guard.
7
- *
8
- * Public API is stable: switching the underlying headless library in the
9
- * future does not break the plugin author's code.
10
- *
11
- * Implementation notes:
12
- * - `open` prop is the single source of truth (controlled mode). The
13
- * component emits `update:open` for two-way binding. Internally it also
14
- * registers with the modal manager for mutual exclusion (same pattern as
15
- * AppPopover): when `open` turns true we call manager.open(); when
16
- * another panel causes the manager to close us we emit update:open(false).
17
- * - `persistent`: when true, ESC and backdrop clicks are suppressed via
18
- * @interact-outside.prevent and @escape-key-down.prevent on DialogContent.
19
- * - `danger`: adds class `openpen-modal-danger` on the content element as a
20
- * styling hook for destructive-action dialogs (no default style yet).
21
- * - animating guard: auto-close during ControlBar scaleY animation.
22
- * - usePassthroughGuard: registered on the dialog content element so the
23
- * transparent Electron window remains clickable when dialog is open.
24
- * - Styles are unscoped because DialogContent is teleported outside the
25
- * component's DOM subtree (same reason as AppPopover).
26
- *
27
- * CSS class names (.openpen-modal-*) are intentionally kept as the carried-over
28
- * internal styling tokens — renaming carries no DX benefit and risks accidental
29
- * style breakage in any code path still referencing them.
30
- */
31
- import { computed, inject, onBeforeUnmount, ref, useTemplateRef, watch } from 'vue'
32
- import {
33
- DialogRoot,
34
- DialogTrigger,
35
- DialogPortal,
36
- DialogContent,
37
- DialogOverlay,
38
- DialogClose,
39
- DialogTitle,
40
- DialogDescription,
41
- } from '../primitives'
42
- // Inject keys MUST come from the package barrel — see AppPopover.vue
43
- // header comment for why relative imports break Symbol identity in prod.
44
- import {
45
- MODAL_MANAGER_KEY,
46
- CONTROL_BAR_ANIMATING_KEY,
47
- HOST_DIALOG_OPEN_COUNT_KEY,
48
- } from '@openpen/module-api'
49
- import { usePassthroughGuard } from '../../host/passthrough'
50
-
51
- // ── OpenPen-owned types (MUST NOT reference reka-ui types) ───────────────────
52
-
53
- export interface AppDialogProps {
54
- /** Globally unique modal id; used for modal-stack mutual exclusion. */
55
- modalId: string
56
- /** Dialog title displayed in the dialog header. */
57
- title: string
58
- /** Controlled open state. The parent must handle update:open to toggle. */
59
- open: boolean
60
- /**
61
- * When true, ESC key and backdrop clicks do not close the dialog.
62
- * Use for confirmations that require an explicit button press.
63
- */
64
- persistent?: boolean
65
- /**
66
- * When true, adds the `openpen-modal-danger` CSS class on the content
67
- * element — a hook for styling destructive-action dialogs.
68
- */
69
- danger?: boolean
70
- }
71
-
72
- export interface AppDialogSlots {
73
- /**
74
- * Trigger slot; scope exposes active state + toggle / open / close actions.
75
- * MUST NOT write @click="toggle" on the trigger — DialogTrigger's as-child
76
- * handles activation automatically; toggle/open/close are escape hatches for
77
- * programmatic control only.
78
- */
79
- trigger: (scope: {
80
- active: boolean
81
- toggle: () => void
82
- open: () => void
83
- close: () => void
84
- }) => unknown
85
- /** Dialog body content slot. */
86
- default: () => unknown
87
- /** Optional footer slot (action buttons etc.). */
88
- footer?: () => unknown
89
- }
90
-
91
- const props = withDefaults(defineProps<AppDialogProps>(), {
92
- persistent: false,
93
- danger: false,
94
- })
95
-
96
- const emit = defineEmits<{
97
- 'update:open': [value: boolean]
98
- }>()
99
-
100
- defineSlots<AppDialogSlots>()
101
-
102
- // ── 1. Inject host context (plugin author never sees these) ───────────────────
103
-
104
- const modalManager = inject(MODAL_MANAGER_KEY)
105
- const animating = inject(CONTROL_BAR_ANIMATING_KEY)
106
- const dialogOpenCount = inject(HOST_DIALOG_OPEN_COUNT_KEY, null)
107
-
108
- // Track whether THIS instance currently holds a count, so we never double
109
- // increment / decrement (e.g. on rapid open / close cycles or unmount-while-open).
110
- let counted = false
111
-
112
- function acquireCount() {
113
- if (counted || !dialogOpenCount) return
114
- dialogOpenCount.value += 1
115
- counted = true
116
- }
117
-
118
- function releaseCount() {
119
- if (!counted || !dialogOpenCount) return
120
- dialogOpenCount.value -= 1
121
- counted = false
122
- }
123
-
124
- onBeforeUnmount(releaseCount)
125
-
126
- // ── 2. Modal manager mutual exclusion + controlled open prop ─────────────────
127
- //
128
- // The `open` prop is the canonical state. We watch it and push changes into
129
- // the modal manager. When the manager closes us (e.g. another panel opens),
130
- // we detect that via the computed isManagerOpen and emit update:open(false).
131
-
132
- function openDialog() {
133
- modalManager?.open(props.modalId)
134
- emit('update:open', true)
135
- }
136
-
137
- function closeDialog() {
138
- modalManager?.close(props.modalId)
139
- emit('update:open', false)
140
- }
141
-
142
- function toggleDialog() {
143
- if (props.open) {
144
- closeDialog()
145
- } else {
146
- openDialog()
147
- }
148
- }
149
-
150
- // When the prop turns true: register with the manager + bump host dialog count.
151
- // When it turns false: unregister + release count. Keeps everything in sync.
152
- watch(
153
- () => props.open,
154
- (newOpen) => {
155
- if (newOpen) {
156
- modalManager?.open(props.modalId)
157
- acquireCount()
158
- } else {
159
- modalManager?.close(props.modalId)
160
- releaseCount()
161
- }
162
- },
163
- { immediate: true }
164
- )
165
-
166
- // When the manager closes us (another panel opened), emit update:open(false)
167
- // so the parent's v-model updates.
168
- const isManagerOpen = computed(() => modalManager?.isOpen(props.modalId) ?? props.open)
169
-
170
- watch(isManagerOpen, (managerOpen) => {
171
- if (!managerOpen && props.open) {
172
- emit('update:open', false)
173
- }
174
- })
175
-
176
- // Effective open state passed to Reka UI's DialogRoot.
177
- const isOpen = computed(() => props.open && isManagerOpen.value)
178
-
179
- // Sync Reka UI's internal close events (ESC, overlay click) back to caller
180
- // unless `persistent` suppresses them.
181
- function onUpdateOpen(v: boolean) {
182
- if (!v && props.persistent) {
183
- // Persistent mode: ignore Reka UI close attempts; stay open.
184
- return
185
- }
186
- if (v) {
187
- openDialog()
188
- } else {
189
- closeDialog()
190
- }
191
- }
192
-
193
- // ── 3. ControlBar animation guard — auto-close during transitions ─────────────
194
-
195
- watch(
196
- () => animating?.value,
197
- (isAnimating) => {
198
- if (isAnimating && props.open) {
199
- closeDialog()
200
- }
201
- }
202
- )
203
-
204
- // ── 4. Passthrough guard integration ─────────────────────────────────────────
205
- //
206
- // DialogContent is a Vue component; normalize to HTMLElement via watcher.
207
-
208
- const contentInstance = useTemplateRef<HTMLElement | { $el?: HTMLElement } | null>('content')
209
- const contentEl = ref<HTMLElement | null>(null)
210
-
211
- watch(contentInstance, (instance) => {
212
- if (!instance) {
213
- contentEl.value = null
214
- } else if (instance instanceof HTMLElement) {
215
- contentEl.value = instance
216
- } else if (typeof instance === 'object' && '$el' in instance && instance.$el instanceof HTMLElement) {
217
- contentEl.value = instance.$el
218
- } else {
219
- contentEl.value = null
220
- }
221
- })
222
-
223
- usePassthroughGuard(contentEl)
224
- </script>
225
-
226
- <template>
227
- <DialogRoot :open="isOpen" @update:open="onUpdateOpen">
228
- <DialogTrigger as-child>
229
- <slot
230
- name="trigger"
231
- :active="isOpen"
232
- :toggle="toggleDialog"
233
- :open="openDialog"
234
- :close="closeDialog"
235
- />
236
- </DialogTrigger>
237
- <DialogPortal>
238
- <DialogOverlay class="openpen-modal-overlay" />
239
- <DialogContent
240
- ref="content"
241
- class="openpen-modal-content openpen-interactive"
242
- :class="{ 'openpen-modal-danger': danger }"
243
- @interact-outside="(e: Event) => persistent && e.preventDefault()"
244
- @escape-key-down="(e: KeyboardEvent) => persistent && e.preventDefault()"
245
- >
246
- <DialogTitle class="openpen-modal-title">{{ title }}</DialogTitle>
247
- <DialogDescription class="openpen-modal-description sr-only" />
248
- <div class="openpen-modal-body">
249
- <slot />
250
- </div>
251
- <div v-if="$slots.footer" class="openpen-modal-footer">
252
- <slot name="footer" />
253
- </div>
254
- <DialogClose class="openpen-modal-close" aria-label="Close">
255
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
256
- <path d="M1 1L13 13M13 1L1 13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
257
- </svg>
258
- </DialogClose>
259
- </DialogContent>
260
- </DialogPortal>
261
- </DialogRoot>
262
- </template>
263
-
264
- <!-- Styles MUST be unscoped — Vue scoped CSS does not follow Teleport targets,
265
- so .openpen-modal-* classes would not receive the scoped data-v-* attribute
266
- when rendered inside the portal. -->
267
- <style>
268
- /* ── Overlay (transparent click absorber, NOT a dim backdrop) ─────────────────
269
- OpenPen's overlay window is transparent + frameless. A traditional dim/blur
270
- backdrop renders as a small dark patch only over the visible chrome — visually
271
- jarring. The overlay is therefore kept fully transparent here; its only job
272
- is to absorb outside clicks (so Reka can dispatch interact-outside → close
273
- when not persistent) and to protect the canvas from stray clicks while the
274
- dialog is open. Host containers (e.g. ControlBar) render their own "locked"
275
- visual state via HOST_DIALOG_OPEN_COUNT_KEY. */
276
- .openpen-modal-overlay {
277
- position: fixed;
278
- inset: 0;
279
- background: transparent;
280
- z-index: 200;
281
- pointer-events: auto;
282
- }
283
-
284
- /* ── Dialog content panel ────────────────────────────────────────────────── */
285
- .openpen-modal-content {
286
- position: fixed;
287
- top: 50%;
288
- left: 50%;
289
- transform: translate(-50%, -50%);
290
- z-index: 201;
291
- min-width: 320px;
292
- max-width: min(480px, calc(100vw - 48px));
293
- max-height: calc(100vh - 96px);
294
- overflow-y: auto;
295
-
296
- background: var(--openpen-color-surface-popup);
297
- border: 1px solid var(--openpen-color-border-hi);
298
- border-radius: var(--openpen-radius-lg);
299
- box-shadow: var(--openpen-shadow);
300
- backdrop-filter: var(--openpen-blur);
301
- -webkit-backdrop-filter: var(--openpen-blur);
302
-
303
- color: var(--openpen-color-text-primary);
304
- font-size: 13px;
305
- padding: var(--openpen-space-lg);
306
- pointer-events: auto;
307
- }
308
-
309
- /* ── Title ───────────────────────────────────────────────────────────────── */
310
- .openpen-modal-title {
311
- font-size: 15px;
312
- font-weight: 600;
313
- color: var(--openpen-color-text-primary);
314
- margin: 0 0 var(--openpen-space-md) 0;
315
- padding-right: 28px; /* leave room for close button */
316
- line-height: 1.4;
317
- }
318
-
319
- /* ── Visually hidden description (a11y) ──────────────────────────────────── */
320
- .openpen-modal-description.sr-only {
321
- position: absolute;
322
- width: 1px;
323
- height: 1px;
324
- padding: 0;
325
- margin: -1px;
326
- overflow: hidden;
327
- clip: rect(0, 0, 0, 0);
328
- white-space: nowrap;
329
- border-width: 0;
330
- }
331
-
332
- /* ── Body ────────────────────────────────────────────────────────────────── */
333
- .openpen-modal-body {
334
- color: var(--openpen-color-text-primary);
335
- }
336
-
337
- /* ── Footer ──────────────────────────────────────────────────────────────── */
338
- .openpen-modal-footer {
339
- display: flex;
340
- justify-content: flex-end;
341
- gap: var(--openpen-space-sm);
342
- margin-top: var(--openpen-space-lg);
343
- padding-top: var(--openpen-space-md);
344
- border-top: 1px solid var(--openpen-color-border);
345
- }
346
-
347
- /* ── Close button ────────────────────────────────────────────────────────── */
348
- .openpen-modal-close {
349
- position: absolute;
350
- top: var(--openpen-space-md);
351
- right: var(--openpen-space-md);
352
- width: 24px;
353
- height: 24px;
354
- display: flex;
355
- align-items: center;
356
- justify-content: center;
357
- border: none;
358
- background: transparent;
359
- color: var(--openpen-color-text-dim);
360
- border-radius: var(--openpen-radius-sm);
361
- cursor: pointer;
362
- transition: background var(--openpen-duration-fast), color var(--openpen-duration-fast);
363
- }
364
-
365
- .openpen-modal-close:hover {
366
- background: var(--openpen-color-control-hover);
367
- color: var(--openpen-color-text-primary);
368
- }
369
-
370
- /* ── Open / close animations ─────────────────────────────────────────────────
371
- Reka UI's Presence keeps the element in the DOM until the CSS animation
372
- ends, so [data-state="closed"] animations DO play on close.
373
- Pattern matches AppPopover keyframes (200ms bounce enter, 150ms ease leave). */
374
-
375
- /* Overlay is always transparent — no fade-in/out animation needed.
376
- Only the dialog content panel animates. */
377
-
378
- .openpen-modal-content[data-state='open'] {
379
- animation: openpen-modal-in 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
380
- }
381
- .openpen-modal-content[data-state='closed'] {
382
- animation: openpen-modal-out 150ms ease forwards;
383
- }
384
-
385
- @keyframes openpen-modal-in {
386
- from { opacity: 0; transform: translate(-50%, -50%) scale(0.92); }
387
- to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
388
- }
389
- @keyframes openpen-modal-out {
390
- from { opacity: 1; transform: translate(-50%, -50%) scale(1); }
391
- to { opacity: 0; transform: translate(-50%, -50%) scale(0.92); }
392
- }
393
- </style>
@@ -1,268 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * AppPopover — High-level wrapper (Layer 1).
4
- *
5
- * Plugin authors use this component without needing to know about inject
6
- * keys, Reka UI, modal manager mutual exclusion, or the animating guard.
7
- *
8
- * Public API is stable: switching the underlying headless library in the
9
- * future does not break the plugin author's code.
10
- *
11
- * Implementation notes:
12
- * - placement 'auto' picks the initial Reka UI side based on snap edge:
13
- * vbar-left → 'right', vbar-right → 'left', horizontal → 'bottom'.
14
- * Reka UI's built-in flip middleware then handles viewport-edge collision.
15
- * - usePassthroughGuard: called with the content element ref. The contentEl
16
- * ref is passed to usePassthroughGuard, which attaches mousemove/leave
17
- * handlers that toggle mouse passthrough based on pointer position.
18
- */
19
- import { computed, inject, onMounted, ref, useTemplateRef, watch } from 'vue'
20
- import {
21
- PopoverRoot,
22
- PopoverTrigger,
23
- PopoverPortal,
24
- PopoverContent,
25
- PopoverArrow,
26
- } from '../primitives'
27
- // Inject keys MUST come from the package barrel — the runtime build
28
- // externalises '@openpen/module-api' so host + uikit share the same Symbol
29
- // instances. Importing from the relative '../../inject-keys' path bundles a
30
- // duplicate Symbol into module-api-uikit.js, causing host's provide() and
31
- // uikit's inject() to miss each other (popover never opens, etc).
32
- import {
33
- WRAPPER_EL_KEY,
34
- MODAL_MANAGER_KEY,
35
- CONTROL_BAR_ANIMATING_KEY,
36
- POPOVER_PLACEMENT_HINT_KEY,
37
- } from '@openpen/module-api'
38
- import { usePassthroughGuard } from '../../host/passthrough'
39
-
40
- // ── OpenPen-owned type (MUST NOT reference reka-ui types) ─────────────────────
41
-
42
- /** Preferred placement for the popover content relative to the trigger. */
43
- export type Placement = 'auto' | 'top' | 'bottom' | 'left' | 'right'
44
-
45
- export interface AppPopoverProps {
46
- /** Globally unique popover id; used for modal-stack mutual exclusion. */
47
- popoverId: string
48
- /**
49
- * Popover placement direction.
50
- * - 'auto' (default, recommended): the host's snap-edge hint picks the side that
51
- * keeps the popover on-screen across all control-bar modes.
52
- * - explicit values ('top' | 'bottom' | 'left' | 'right'): bypass the auto hint.
53
- * In dev mode a console warning is emitted because explicit values can position
54
- * popovers off-screen or away from the trigger button when the control bar is
55
- * in a vertical mode. Only use when you have verified the placement stays
56
- * on-screen across all snap layouts.
57
- */
58
- placement?: Placement
59
- /** Gap between trigger and content in px. Default 8. */
60
- gap?: number
61
- }
62
-
63
- export interface AppPopoverSlots {
64
- /** Trigger slot; scope exposes active state + toggle / open / close actions. */
65
- trigger: (scope: {
66
- active: boolean
67
- toggle: () => void
68
- open: () => void
69
- close: () => void
70
- }) => unknown
71
- /** Popover content slot. */
72
- content: () => unknown
73
- }
74
-
75
- const props = withDefaults(defineProps<AppPopoverProps>(), {
76
- placement: 'auto',
77
- gap: 8,
78
- })
79
-
80
- defineSlots<AppPopoverSlots>()
81
-
82
- // ── 0. Dev-mode guard: warn plugin authors about risky explicit placement ─────
83
- onMounted(() => {
84
- if (!import.meta.env.DEV) return
85
- if (props.placement !== 'auto' && props.placement !== undefined) {
86
- console.warn(
87
- `[AppPopover] Explicit placement="${props.placement}" bypasses ` +
88
- `the host's snap-edge auto detection. In vertical control-bar modes ` +
89
- `this can position popups off-screen or away from the trigger button. ` +
90
- `Prefer placement="auto" (default) and let the host pick the side; ` +
91
- `use an explicit value only when you have verified it stays on-screen ` +
92
- `across snap-left / snap-right / snap-free / horizontal layouts.`
93
- )
94
- }
95
- })
96
-
97
- // ── 1. Inject host context (plugin author never sees these) ───────────────────
98
-
99
- const wrapperElRef = inject(WRAPPER_EL_KEY)
100
- const wrapperEl = computed<HTMLElement | null>(() => wrapperElRef?.value ?? null)
101
- const modalManager = inject(MODAL_MANAGER_KEY)
102
- const animating = inject(CONTROL_BAR_ANIMATING_KEY)
103
- const popoverPlacementHint = inject(POPOVER_PLACEMENT_HINT_KEY)
104
-
105
- // ── 2. Modal manager mutual exclusion ────────────────────────────────────────
106
-
107
- const isOpen = computed(() => modalManager?.isOpen(props.popoverId) ?? false)
108
-
109
- function open() {
110
- modalManager?.open(props.popoverId)
111
- }
112
-
113
- function close() {
114
- modalManager?.close(props.popoverId)
115
- }
116
-
117
- function toggle() {
118
- if (isOpen.value) {
119
- close()
120
- } else {
121
- open()
122
- }
123
- }
124
-
125
- // Sync Reka UI's controlled open state back to modal manager.
126
- // Reka UI may close via Escape / outside-click; we must keep manager in sync.
127
- function onUpdateOpen(v: boolean) {
128
- if (v) {
129
- open()
130
- } else {
131
- close()
132
- }
133
- }
134
-
135
- // ── 3. ControlBar animation guard — auto-close during transitions ────────────
136
-
137
- watch(
138
- () => animating?.value,
139
- (isAnimating) => {
140
- if (isAnimating && isOpen.value) {
141
- close()
142
- }
143
- }
144
- )
145
-
146
- // ── 4. placement → Reka UI side mapping ──────────────────────────────────────
147
- //
148
- // 'auto' uses the POPOVER_PLACEMENT_HINT_KEY provided by ControlBar. The hint
149
- // encodes a two-tier UX rule:
150
- // - Snap edge set → open away from the edge (left→right, right→left, etc.)
151
- // - Snap off → open toward the side with more screen space on the relevant axis
152
- // (horizontal bar: top/bottom; vertical bar: left/right)
153
- // Reka UI's built-in flip middleware handles secondary overflow. The window is
154
- // workArea-sized, so the viewport equals the workArea and floating-ui's collision
155
- // detection is correct without any boundary-proxy workaround.
156
-
157
- type RekaSide = 'top' | 'bottom' | 'left' | 'right'
158
-
159
- const rekaSide = computed<RekaSide>(() => {
160
- if (props.placement !== 'auto') return props.placement as RekaSide
161
- // Fall back to 'bottom' only if no hint is provided (e.g. standalone use outside ControlBar).
162
- return popoverPlacementHint?.value ?? 'bottom'
163
- })
164
-
165
- // ── 5. Passthrough guard integration ─────────────────────────────────────────
166
- //
167
- // usePassthroughGuard is called with the content element ref. Reka UI's
168
- // PopoverContent is a Vue component, so the template ref resolves to a
169
- // component instance (with `$el`) rather than a raw HTMLElement. We normalize
170
- // via a watcher so the guard receives a plain element ref.
171
- // openpen-interactive class enables the ControlBar passthrough guard's CSS selector path.
172
- const contentInstance = useTemplateRef<HTMLElement | { $el?: HTMLElement } | null>('content')
173
- const contentEl = ref<HTMLElement | null>(null)
174
- watch(contentInstance, (instance) => {
175
- if (!instance) {
176
- contentEl.value = null
177
- } else if (instance instanceof HTMLElement) {
178
- contentEl.value = instance
179
- } else if (typeof instance === 'object' && '$el' in instance && instance.$el instanceof HTMLElement) {
180
- contentEl.value = instance.$el
181
- } else {
182
- contentEl.value = null
183
- }
184
- })
185
- usePassthroughGuard(contentEl)
186
- </script>
187
-
188
- <template>
189
- <PopoverRoot :open="isOpen" @update:open="onUpdateOpen">
190
- <PopoverTrigger as-child>
191
- <slot
192
- name="trigger"
193
- :active="isOpen"
194
- :toggle="toggle"
195
- :open="open"
196
- :close="close"
197
- />
198
- </PopoverTrigger>
199
- <PopoverPortal :to="wrapperEl ?? undefined">
200
- <PopoverContent
201
- ref="content"
202
- :side="rekaSide"
203
- :avoid-collisions="true"
204
- :side-offset="props.gap"
205
- class="openpen-popover-content openpen-interactive"
206
- >
207
- <slot name="content" />
208
- <!-- Arrow geometry: 14×8 sharp triangle. Fill shares
209
- --openpen-color-popover-frame with the popup border so the arrow
210
- reads as a continuous extension of the frame. See tokens.css for
211
- per-theme values. -->
212
- <PopoverArrow class="openpen-popover-arrow" :width="14" :height="8" />
213
- </PopoverContent>
214
- </PopoverPortal>
215
- </PopoverRoot>
216
- </template>
217
-
218
- <!-- Styles for teleported PopoverContent MUST be unscoped — Vue scoped CSS
219
- does not follow Teleport targets, so .openpen-popover-content would not
220
- receive the scoped data-v-* attribute when rendered inside the portal. -->
221
- <style>
222
- .openpen-popover-content {
223
- background: var(--openpen-color-surface-popup);
224
- border: 1px solid var(--openpen-color-popover-frame);
225
- border-radius: var(--openpen-radius-lg);
226
- padding: 14px 16px;
227
- color: var(--openpen-color-text-primary);
228
- font-size: 13px;
229
- box-shadow: var(--openpen-shadow);
230
- backdrop-filter: var(--openpen-blur);
231
- -webkit-backdrop-filter: var(--openpen-blur);
232
- z-index: 100;
233
- /* Prevent pointer events from leaking through transparent gaps. */
234
- pointer-events: auto;
235
- }
236
-
237
- .openpen-popover-arrow {
238
- fill: var(--openpen-color-popover-frame);
239
- }
240
-
241
- /* ── Open / close animations ────────────────────────────────────────────────
242
- Reka UI's Presence keeps the element in the DOM until the running CSS
243
- animation ends, so animations targeting [data-state="closed"] DO play
244
- on close. transform-origin tracks data-side so the popover scales out
245
- from the trigger instead of from its own center.
246
-
247
- Timings: 200ms easeOutBack enter, 150ms ease leave. */
248
- .openpen-popover-content[data-state='open'] {
249
- animation: openpen-popover-in 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
250
- }
251
- .openpen-popover-content[data-state='closed'] {
252
- animation: openpen-popover-out 150ms ease forwards;
253
- }
254
-
255
- .openpen-popover-content[data-side='bottom'] { transform-origin: top center; }
256
- .openpen-popover-content[data-side='top'] { transform-origin: bottom center; }
257
- .openpen-popover-content[data-side='left'] { transform-origin: right center; }
258
- .openpen-popover-content[data-side='right'] { transform-origin: left center; }
259
-
260
- @keyframes openpen-popover-in {
261
- from { opacity: 0; transform: scale(0.92); }
262
- to { opacity: 1; transform: scale(1); }
263
- }
264
- @keyframes openpen-popover-out {
265
- from { opacity: 1; transform: scale(1); }
266
- to { opacity: 0; transform: scale(0.92); }
267
- }
268
- </style>