@openpen/module-api 1.1.1 → 1.3.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.
- package/CHANGELOG.md +16 -0
- package/dist/_chunks/AppButtonDropdown.vue_vue_type_style_index_0_lang-B9dHU-LE.js +246 -0
- package/dist/_chunks/AppButtonDropdown.vue_vue_type_style_index_0_lang-B9dHU-LE.js.map +1 -0
- package/dist/_chunks/inject-keys-CRXCpN-M.js +196 -0
- package/dist/_chunks/inject-keys-CRXCpN-M.js.map +1 -0
- package/dist/_chunks/module-context-registry-Cn1IWYu9.js +16 -0
- package/dist/_chunks/module-context-registry-Cn1IWYu9.js.map +1 -0
- package/dist/_chunks/passthrough-DKU7JYuQ.js +6 -0
- package/dist/_chunks/passthrough-DKU7JYuQ.js.map +1 -0
- package/dist/cursors.d.ts +83 -0
- package/dist/define-module.d.ts +17 -0
- package/dist/host/color-utils.d.ts +11 -0
- package/{src/host/event-bus.ts → dist/host/event-bus.d.ts} +7 -5
- package/dist/host/host-commands.d.ts +1 -0
- package/dist/host/index.d.ts +24 -0
- package/dist/host/index.js +51 -0
- package/dist/host/index.js.map +1 -0
- package/{src/host/module-context-registry.ts → dist/host/module-context-registry.d.ts} +5 -19
- package/dist/host/passthrough.d.ts +2 -0
- package/dist/host/popup-anchor.d.ts +3 -0
- package/dist/host/registry.d.ts +39 -0
- package/dist/host/registry.js +37 -0
- package/dist/host/registry.js.map +1 -0
- package/dist/host/slot-entries.d.ts +2 -0
- package/dist/host/stroke-store.d.ts +6 -0
- package/dist/host/stroke-style.d.ts +3 -0
- package/dist/host/types.d.ts +176 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +349 -0
- package/dist/index.js.map +1 -0
- package/{src/inject-keys.ts → dist/inject-keys.d.ts} +37 -66
- package/{src/locale.ts → dist/locale.d.ts} +3 -22
- package/dist/slots.d.ts +57 -0
- package/dist/types/contributions.d.ts +288 -0
- package/dist/types/control-bar-layout.d.ts +227 -0
- package/dist/types/module.d.ts +208 -0
- package/{src/types/notification.ts → dist/types/notification.d.ts} +12 -14
- package/dist/types/tool.d.ts +77 -0
- package/dist/uikit/components/AppBanner.vue.d.ts +34 -0
- package/dist/uikit/components/AppButton.vue.d.ts +30 -0
- package/dist/uikit/components/AppButtonDropdown.vue.d.ts +52 -0
- package/dist/uikit/components/AppDialog.vue.d.ts +53 -0
- package/dist/uikit/components/AppPopover.vue.d.ts +43 -0
- package/dist/uikit/components/AppSegmented.vue.d.ts +19 -0
- package/dist/uikit/components/AppSelect.vue.d.ts +26 -0
- package/dist/uikit/components/AppSlider.vue.d.ts +43 -0
- package/dist/uikit/components/AppTabs.vue.d.ts +35 -0
- package/dist/uikit/components/AppToggle.vue.d.ts +15 -0
- package/dist/uikit/components/AppTooltip.vue.d.ts +27 -0
- package/dist/uikit/components/DialogHost.vue.d.ts +3 -0
- package/{src/uikit/composables/dialog-internal-keys.ts → dist/uikit/composables/dialog-internal-keys.d.ts} +3 -5
- package/dist/uikit/composables/useDialog.d.ts +399 -0
- package/dist/uikit/composables/useDialogPluginComponent.d.ts +9 -0
- package/dist/uikit/index.d.ts +42 -0
- package/dist/uikit/index.js +898 -0
- package/dist/uikit/index.js.map +1 -0
- package/{src/uikit/internal.ts → dist/uikit/internal.d.ts} +1 -1
- package/dist/uikit/internal.js +157 -0
- package/dist/uikit/internal.js.map +1 -0
- package/dist/uikit/module-api.css +851 -0
- package/dist/uikit/primitives.d.ts +42 -0
- package/{src/use-module-context.ts → dist/use-module-context.d.ts} +2 -15
- package/{src/validation.ts → dist/validation.d.ts} +2 -6
- package/package.json +28 -14
- package/src/cursors.ts +0 -369
- package/src/define-module.ts +0 -55
- package/src/host/color-utils.ts +0 -18
- package/src/host/host-commands.ts +0 -9
- package/src/host/index.ts +0 -53
- package/src/host/passthrough.ts +0 -10
- package/src/host/popup-anchor.ts +0 -14
- package/src/host/slot-entries.ts +0 -9
- package/src/host/stroke-store.ts +0 -20
- package/src/host/stroke-style.ts +0 -15
- package/src/index.ts +0 -131
- package/src/slots.ts +0 -135
- package/src/types/contributions.ts +0 -333
- package/src/types/control-bar-layout.ts +0 -174
- package/src/types/module.ts +0 -225
- package/src/types/tool.ts +0 -82
- package/src/uikit/components/.gitkeep +0 -0
- package/src/uikit/components/AppBanner.vue +0 -131
- package/src/uikit/components/AppButton.vue +0 -135
- package/src/uikit/components/AppDialog.vue +0 -393
- package/src/uikit/components/AppPopover.vue +0 -268
- package/src/uikit/components/AppSegmented.vue +0 -128
- package/src/uikit/components/AppSelect.vue +0 -235
- package/src/uikit/components/AppSlider.vue +0 -204
- package/src/uikit/components/AppTabs.vue +0 -163
- package/src/uikit/components/AppToggle.vue +0 -96
- package/src/uikit/components/AppTooltip.vue +0 -118
- package/src/uikit/components/DialogHost.vue +0 -272
- package/src/uikit/composables/useDialog.ts +0 -141
- package/src/uikit/composables/useDialogPluginComponent.ts +0 -27
- package/src/uikit/index.ts +0 -79
- package/src/uikit/primitives.ts +0 -150
- /package/{src → dist}/uikit/tokens.css +0 -0
|
@@ -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>
|
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* AppSegmented — High-level wrapper (Layer 1).
|
|
4
|
-
*
|
|
5
|
-
* Wraps Reka UI RadioGroup (RadioGroupRoot / RadioGroupItem).
|
|
6
|
-
*
|
|
7
|
-
* Visual: flex gap 4px, 1.5px border var(--border-hi),
|
|
8
|
-
* active = var(--accent-bg) background + var(--accent) color.
|
|
9
|
-
*
|
|
10
|
-
* Styles use --openpen-* design tokens.
|
|
11
|
-
* No Reka UI types are leaked in the public API.
|
|
12
|
-
*/
|
|
13
|
-
import {
|
|
14
|
-
RadioGroupRoot,
|
|
15
|
-
RadioGroupItem,
|
|
16
|
-
} from '../primitives'
|
|
17
|
-
|
|
18
|
-
// ── Public API (MUST NOT reference reka-ui types) ─────────────────────────────
|
|
19
|
-
|
|
20
|
-
interface Option {
|
|
21
|
-
value: string
|
|
22
|
-
label: string
|
|
23
|
-
icon?: string
|
|
24
|
-
testid?: string
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
defineProps<{
|
|
28
|
-
modelValue: string
|
|
29
|
-
options: Option[]
|
|
30
|
-
/** When true, all options are non-interactive and visually dimmed. */
|
|
31
|
-
disabled?: boolean
|
|
32
|
-
}>()
|
|
33
|
-
|
|
34
|
-
const emit = defineEmits<{
|
|
35
|
-
'update:modelValue': [value: string]
|
|
36
|
-
}>()
|
|
37
|
-
|
|
38
|
-
// AcceptableValue from Reka UI can be null; we narrow to string so the public API has no Reka UI type leakage.
|
|
39
|
-
function onValueChange(val: unknown) {
|
|
40
|
-
if (typeof val === 'string') {
|
|
41
|
-
emit('update:modelValue', val)
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
</script>
|
|
45
|
-
|
|
46
|
-
<template>
|
|
47
|
-
<RadioGroupRoot
|
|
48
|
-
class="app-seg"
|
|
49
|
-
:class="{ 'app-seg--disabled': disabled }"
|
|
50
|
-
:model-value="modelValue"
|
|
51
|
-
:disabled="disabled"
|
|
52
|
-
orientation="horizontal"
|
|
53
|
-
@update:model-value="onValueChange"
|
|
54
|
-
>
|
|
55
|
-
<RadioGroupItem
|
|
56
|
-
v-for="opt in options"
|
|
57
|
-
:key="opt.value"
|
|
58
|
-
:value="opt.value"
|
|
59
|
-
as-child
|
|
60
|
-
>
|
|
61
|
-
<button
|
|
62
|
-
class="app-seg-btn"
|
|
63
|
-
:class="{ active: modelValue === opt.value }"
|
|
64
|
-
:data-testid="opt.testid"
|
|
65
|
-
>
|
|
66
|
-
<span
|
|
67
|
-
v-if="opt.icon"
|
|
68
|
-
class="app-seg-icon"
|
|
69
|
-
aria-hidden="true"
|
|
70
|
-
v-html="opt.icon"
|
|
71
|
-
/>
|
|
72
|
-
<span>{{ opt.label }}</span>
|
|
73
|
-
</button>
|
|
74
|
-
</RadioGroupItem>
|
|
75
|
-
</RadioGroupRoot>
|
|
76
|
-
</template>
|
|
77
|
-
|
|
78
|
-
<style scoped>
|
|
79
|
-
.app-seg {
|
|
80
|
-
display: flex;
|
|
81
|
-
gap: var(--openpen-space-xs);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
.app-seg-btn {
|
|
85
|
-
flex: 1;
|
|
86
|
-
display: flex;
|
|
87
|
-
align-items: center;
|
|
88
|
-
justify-content: center;
|
|
89
|
-
gap: 6px;
|
|
90
|
-
padding: 6px var(--openpen-space-lg);
|
|
91
|
-
border: 1.5px solid var(--openpen-color-border-hi);
|
|
92
|
-
border-radius: var(--openpen-radius-sm);
|
|
93
|
-
background: var(--openpen-color-input-bg);
|
|
94
|
-
color: var(--openpen-color-text-dim);
|
|
95
|
-
font-size: 12px;
|
|
96
|
-
font-weight: 600;
|
|
97
|
-
cursor: pointer;
|
|
98
|
-
white-space: nowrap;
|
|
99
|
-
transition:
|
|
100
|
-
background var(--openpen-duration-fast) var(--openpen-easing-standard),
|
|
101
|
-
color var(--openpen-duration-fast) var(--openpen-easing-standard),
|
|
102
|
-
border-color var(--openpen-duration-fast) var(--openpen-easing-standard);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
.app-seg-icon {
|
|
106
|
-
display: inline-flex;
|
|
107
|
-
align-items: center;
|
|
108
|
-
justify-content: center;
|
|
109
|
-
line-height: 0;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
.app-seg-btn:hover {
|
|
113
|
-
color: var(--openpen-color-text-primary);
|
|
114
|
-
background: var(--openpen-color-control-hover);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
.app-seg-btn.active {
|
|
118
|
-
background: var(--openpen-color-accent-bg);
|
|
119
|
-
color: var(--openpen-color-accent);
|
|
120
|
-
border-color: color-mix(in srgb, var(--openpen-color-accent) 40%, transparent);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
.app-seg--disabled {
|
|
124
|
-
opacity: 0.45;
|
|
125
|
-
pointer-events: none;
|
|
126
|
-
cursor: not-allowed;
|
|
127
|
-
}
|
|
128
|
-
</style>
|
|
@@ -1,235 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* AppSelect — High-level wrapper (Layer 1).
|
|
4
|
-
*
|
|
5
|
-
* Plugin authors use this component without needing to know about Reka UI's
|
|
6
|
-
* Select primitives. The API surface is OpenPen-owned and stable.
|
|
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
|
-
* - Controlled via modelValue / update:modelValue (standard Vue v-model pattern).
|
|
13
|
-
* - options[] is an OpenPen-owned type; no Reka UI types leak to the public API.
|
|
14
|
-
* - Popup styling mirrors surface-popup tokens (same as AppPopover content).
|
|
15
|
-
* - Styles are unscoped because SelectContent is teleported via SelectPortal.
|
|
16
|
-
*/
|
|
17
|
-
import {
|
|
18
|
-
SelectRoot,
|
|
19
|
-
SelectTrigger,
|
|
20
|
-
SelectPortal,
|
|
21
|
-
SelectContent,
|
|
22
|
-
SelectViewport,
|
|
23
|
-
SelectItem,
|
|
24
|
-
SelectItemText,
|
|
25
|
-
SelectItemIndicator,
|
|
26
|
-
SelectValue,
|
|
27
|
-
SelectIcon,
|
|
28
|
-
} from '../primitives'
|
|
29
|
-
|
|
30
|
-
// ── OpenPen-owned types (MUST NOT reference reka-ui types) ───────────────────
|
|
31
|
-
|
|
32
|
-
/** A single option in the dropdown list. */
|
|
33
|
-
export interface SelectOption {
|
|
34
|
-
/** The value emitted on selection. */
|
|
35
|
-
value: string
|
|
36
|
-
/** The label shown to the user. */
|
|
37
|
-
label: string
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface AppSelectProps {
|
|
41
|
-
/** Currently selected value (bind with v-model). */
|
|
42
|
-
modelValue: string
|
|
43
|
-
/** List of selectable options. */
|
|
44
|
-
options: SelectOption[]
|
|
45
|
-
/** Placeholder shown when no option is selected. */
|
|
46
|
-
placeholder: string
|
|
47
|
-
/** Whether the select is disabled. */
|
|
48
|
-
disabled?: boolean
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const props = withDefaults(defineProps<AppSelectProps>(), {
|
|
52
|
-
disabled: false,
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
const emit = defineEmits<{
|
|
56
|
-
'update:modelValue': [value: string]
|
|
57
|
-
}>()
|
|
58
|
-
|
|
59
|
-
function onValueChange(v: string) {
|
|
60
|
-
emit('update:modelValue', v)
|
|
61
|
-
}
|
|
62
|
-
</script>
|
|
63
|
-
|
|
64
|
-
<template>
|
|
65
|
-
<SelectRoot
|
|
66
|
-
:model-value="modelValue"
|
|
67
|
-
:disabled="disabled"
|
|
68
|
-
@update:model-value="onValueChange"
|
|
69
|
-
>
|
|
70
|
-
<SelectTrigger class="openpen-select-trigger">
|
|
71
|
-
<SelectValue :placeholder="placeholder" />
|
|
72
|
-
<SelectIcon class="openpen-select-icon" aria-hidden="true">
|
|
73
|
-
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
|
74
|
-
<path d="M2 4L6 8L10 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
75
|
-
</svg>
|
|
76
|
-
</SelectIcon>
|
|
77
|
-
</SelectTrigger>
|
|
78
|
-
<SelectPortal>
|
|
79
|
-
<SelectContent class="openpen-select-content" position="popper" :side-offset="4">
|
|
80
|
-
<SelectViewport class="openpen-select-viewport">
|
|
81
|
-
<SelectItem
|
|
82
|
-
v-for="option in options"
|
|
83
|
-
:key="option.value"
|
|
84
|
-
:value="option.value"
|
|
85
|
-
class="openpen-select-item"
|
|
86
|
-
>
|
|
87
|
-
<SelectItemIndicator class="openpen-select-item-indicator">
|
|
88
|
-
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
|
89
|
-
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
90
|
-
</svg>
|
|
91
|
-
</SelectItemIndicator>
|
|
92
|
-
<SelectItemText>{{ option.label }}</SelectItemText>
|
|
93
|
-
</SelectItem>
|
|
94
|
-
</SelectViewport>
|
|
95
|
-
</SelectContent>
|
|
96
|
-
</SelectPortal>
|
|
97
|
-
</SelectRoot>
|
|
98
|
-
</template>
|
|
99
|
-
|
|
100
|
-
<!-- Styles MUST be unscoped — Vue scoped CSS does not follow Teleport targets,
|
|
101
|
-
so .openpen-select-* classes would not receive the scoped data-v-* attribute
|
|
102
|
-
when rendered inside the portal. -->
|
|
103
|
-
<style>
|
|
104
|
-
/* ── Trigger ─────────────────────────────────────────────────────────────── */
|
|
105
|
-
.openpen-select-trigger {
|
|
106
|
-
display: inline-flex;
|
|
107
|
-
align-items: center;
|
|
108
|
-
justify-content: space-between;
|
|
109
|
-
gap: var(--openpen-space-sm);
|
|
110
|
-
min-width: 120px;
|
|
111
|
-
height: 32px;
|
|
112
|
-
padding: 0 var(--openpen-space-md);
|
|
113
|
-
background: var(--openpen-color-input-bg);
|
|
114
|
-
border: 1px solid var(--openpen-color-border-hi);
|
|
115
|
-
border-radius: var(--openpen-radius-md);
|
|
116
|
-
color: var(--openpen-color-text-primary);
|
|
117
|
-
font-size: 13px;
|
|
118
|
-
cursor: pointer;
|
|
119
|
-
transition:
|
|
120
|
-
background var(--openpen-duration-fast),
|
|
121
|
-
border-color var(--openpen-duration-fast);
|
|
122
|
-
outline: none;
|
|
123
|
-
white-space: nowrap;
|
|
124
|
-
user-select: none;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
.openpen-select-trigger:hover {
|
|
128
|
-
background: var(--openpen-color-control-hover);
|
|
129
|
-
border-color: var(--openpen-color-border-hi);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
.openpen-select-trigger:focus-visible {
|
|
133
|
-
border-color: var(--openpen-color-accent);
|
|
134
|
-
box-shadow: 0 0 0 2px var(--openpen-color-accent-glow);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
.openpen-select-trigger[data-disabled] {
|
|
138
|
-
opacity: 0.45;
|
|
139
|
-
cursor: not-allowed;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/* ── Trigger icon (chevron) ──────────────────────────────────────────────── */
|
|
143
|
-
.openpen-select-icon {
|
|
144
|
-
color: var(--openpen-color-text-dim);
|
|
145
|
-
flex-shrink: 0;
|
|
146
|
-
display: flex;
|
|
147
|
-
align-items: center;
|
|
148
|
-
transition: transform var(--openpen-duration-fast);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
.openpen-select-trigger[data-state='open'] .openpen-select-icon {
|
|
152
|
-
transform: rotate(180deg);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/* ── Dropdown content ────────────────────────────────────────────────────── */
|
|
156
|
-
.openpen-select-content {
|
|
157
|
-
background: var(--openpen-color-surface-popup);
|
|
158
|
-
border: 1px solid var(--openpen-color-border-hi);
|
|
159
|
-
border-radius: var(--openpen-radius-md);
|
|
160
|
-
box-shadow: var(--openpen-shadow);
|
|
161
|
-
backdrop-filter: var(--openpen-blur);
|
|
162
|
-
-webkit-backdrop-filter: var(--openpen-blur);
|
|
163
|
-
z-index: 100;
|
|
164
|
-
pointer-events: auto;
|
|
165
|
-
/* Match trigger width */
|
|
166
|
-
min-width: var(--reka-select-trigger-width, 120px);
|
|
167
|
-
max-height: 240px;
|
|
168
|
-
overflow: hidden;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/* ── Viewport scrollable area ────────────────────────────────────────────── */
|
|
172
|
-
.openpen-select-viewport {
|
|
173
|
-
padding: var(--openpen-space-xs);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/* ── Individual items ────────────────────────────────────────────────────── */
|
|
177
|
-
.openpen-select-item {
|
|
178
|
-
display: flex;
|
|
179
|
-
align-items: center;
|
|
180
|
-
gap: var(--openpen-space-sm);
|
|
181
|
-
height: 30px;
|
|
182
|
-
padding: 0 var(--openpen-space-md);
|
|
183
|
-
padding-left: 28px; /* indent to align text past indicator */
|
|
184
|
-
border-radius: var(--openpen-radius-sm);
|
|
185
|
-
font-size: 13px;
|
|
186
|
-
color: var(--openpen-color-text-primary);
|
|
187
|
-
cursor: pointer;
|
|
188
|
-
position: relative;
|
|
189
|
-
user-select: none;
|
|
190
|
-
outline: none;
|
|
191
|
-
transition: background var(--openpen-duration-fast);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
.openpen-select-item[data-highlighted] {
|
|
195
|
-
background: var(--openpen-color-control-hover);
|
|
196
|
-
color: var(--openpen-color-text-primary);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
.openpen-select-item[data-state='checked'] {
|
|
200
|
-
color: var(--openpen-color-accent);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
.openpen-select-item[data-disabled] {
|
|
204
|
-
opacity: 0.45;
|
|
205
|
-
cursor: not-allowed;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/* ── Selected item indicator (checkmark) ─────────────────────────────────── */
|
|
209
|
-
.openpen-select-item-indicator {
|
|
210
|
-
position: absolute;
|
|
211
|
-
left: var(--openpen-space-sm);
|
|
212
|
-
display: flex;
|
|
213
|
-
align-items: center;
|
|
214
|
-
justify-content: center;
|
|
215
|
-
color: var(--openpen-color-accent);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/* ── Open / close animations ─────────────────────────────────────────────────
|
|
219
|
-
Pattern matches AppPopover keyframes (200ms bounce enter, 150ms ease leave). */
|
|
220
|
-
.openpen-select-content[data-state='open'] {
|
|
221
|
-
animation: openpen-select-in 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
222
|
-
}
|
|
223
|
-
.openpen-select-content[data-state='closed'] {
|
|
224
|
-
animation: openpen-select-out 150ms ease forwards;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
@keyframes openpen-select-in {
|
|
228
|
-
from { opacity: 0; transform: scale(0.95); }
|
|
229
|
-
to { opacity: 1; transform: scale(1); }
|
|
230
|
-
}
|
|
231
|
-
@keyframes openpen-select-out {
|
|
232
|
-
from { opacity: 1; transform: scale(1); }
|
|
233
|
-
to { opacity: 0; transform: scale(0.95); }
|
|
234
|
-
}
|
|
235
|
-
</style>
|