@karyl-chan/ui 0.1.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 (42) hide show
  1. package/package.json +58 -0
  2. package/src/components/AppBadge.vue +155 -0
  3. package/src/components/AppButton.vue +171 -0
  4. package/src/components/AppConfirmDialog.vue +176 -0
  5. package/src/components/AppItemCard.vue +216 -0
  6. package/src/components/AppMenu.vue +197 -0
  7. package/src/components/AppMenuItem.vue +43 -0
  8. package/src/components/AppModal.vue +257 -0
  9. package/src/components/AppPopover.vue +275 -0
  10. package/src/components/AppSelect.vue +89 -0
  11. package/src/components/AppSelectField.vue +260 -0
  12. package/src/components/AppTabs.vue +310 -0
  13. package/src/components/AppTabsRouted.vue +409 -0
  14. package/src/components/AppTextArea.vue +128 -0
  15. package/src/components/AppTextField.vue +165 -0
  16. package/src/components/AppToast.vue +65 -0
  17. package/src/components/AppToggle.vue +105 -0
  18. package/src/components/Draggable.vue +178 -0
  19. package/src/components/GlobalConfirmDialog.vue +25 -0
  20. package/src/components/UnreadPill.vue +28 -0
  21. package/src/components/UserAvatar.vue +114 -0
  22. package/src/components/UserCard.vue +274 -0
  23. package/src/components/UserItem.vue +167 -0
  24. package/src/components/tab-types.ts +9 -0
  25. package/src/composables/use-app-shell.ts +65 -0
  26. package/src/composables/use-breakpoint.ts +14 -0
  27. package/src/composables/use-click-outside-stack.ts +67 -0
  28. package/src/composables/use-color-scheme.ts +75 -0
  29. package/src/composables/use-confirm.ts +36 -0
  30. package/src/composables/use-drawer.css +47 -0
  31. package/src/composables/use-drawer.ts +58 -0
  32. package/src/composables/use-escape-stack.ts +80 -0
  33. package/src/composables/use-file-drop.ts +47 -0
  34. package/src/composables/use-long-press.ts +62 -0
  35. package/src/composables/use-popover.css +32 -0
  36. package/src/composables/use-popover.ts +1324 -0
  37. package/src/composables/use-shift-key.ts +18 -0
  38. package/src/index.ts +75 -0
  39. package/src/lib/avatar.ts +35 -0
  40. package/src/reset.css +53 -0
  41. package/src/stores/toastStore.ts +26 -0
  42. package/src/tokens.css +159 -0
@@ -0,0 +1,257 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, onUnmounted, toRef, useId } from 'vue';
3
+ import { Icon } from '@iconify/vue';
4
+ import { useBreakpoint } from '../composables/use-breakpoint';
5
+ import { useDrawer } from '../composables/use-drawer';
6
+
7
+ /**
8
+ * Viewport-aware modal:
9
+ * - Desktop → centered card with a translucent backdrop, fade transition.
10
+ * - Mobile → bottom drawer using the shared `useDrawer` styles, slide-up
11
+ * transition, drag-handle visual cue, safe-area inset.
12
+ *
13
+ * The caller owns everything inside the slot — typical usage is to drop
14
+ * a `<form @submit.prevent>` containing fields + an actions footer
15
+ * directly into the default slot. AppModal supplies the chrome (backdrop,
16
+ * panel, optional title header, close button) only.
17
+ *
18
+ * `closeOnBackdrop` / `closeOnEscape` default to true. Escape closure on
19
+ * desktop is wired locally; on mobile it's handled by useDrawer's escape
20
+ * stack. Backdrop click emits `close` either way.
21
+ */
22
+ const props = withDefaults(defineProps<{
23
+ visible: boolean;
24
+ /** Heading rendered in the panel's header. Hidden when blank. */
25
+ title?: string;
26
+ closeOnBackdrop?: boolean;
27
+ closeOnEscape?: boolean;
28
+ /** Width of the desktop panel; mobile drawer ignores this. */
29
+ width?: string;
30
+ }>(), {
31
+ title: '',
32
+ closeOnBackdrop: true,
33
+ closeOnEscape: true,
34
+ width: 'min(440px, 92vw)'
35
+ });
36
+
37
+ const emit = defineEmits<{
38
+ (e: 'close'): void;
39
+ }>();
40
+
41
+ const { isMobile } = useBreakpoint();
42
+ const visibleRef = toRef(props, 'visible');
43
+ const desktopVisible = computed(() => props.visible && !isMobile.value);
44
+ const drawerVisible = computed(() => props.visible && isMobile.value);
45
+
46
+ const titleId = useId();
47
+
48
+ // Desktop Escape handler — useDrawer already covers the mobile branch
49
+ // via its escape stack, so we only listen when the popover branch is
50
+ // active. Listening unconditionally would let a single Escape close two
51
+ // stacked drawers (e.g. user-context-menu drawer over a modal drawer).
52
+ function onWindowKey(event: KeyboardEvent) {
53
+ if (!visibleRef.value || !props.closeOnEscape || isMobile.value) return;
54
+ if (event.key === 'Escape') {
55
+ event.preventDefault();
56
+ emit('close');
57
+ }
58
+ }
59
+ onMounted(() => window.addEventListener('keydown', onWindowKey));
60
+ onUnmounted(() => window.removeEventListener('keydown', onWindowKey));
61
+
62
+ const { backdropClass, panelClass, backdropTransition, panelTransition } = useDrawer({
63
+ visible: drawerVisible,
64
+ placement: 'bottom',
65
+ closeOnEscape: props.closeOnEscape,
66
+ onClose: () => emit('close')
67
+ });
68
+
69
+ function onBackdropClick() {
70
+ if (props.closeOnBackdrop) emit('close');
71
+ }
72
+ </script>
73
+
74
+ <template>
75
+ <Teleport to="body">
76
+ <!-- Desktop branch — centered card. -->
77
+ <Transition name="app-modal-fade">
78
+ <div
79
+ v-if="desktopVisible"
80
+ class="app-modal-backdrop"
81
+ @click.self="onBackdropClick"
82
+ >
83
+ <div
84
+ class="app-modal-panel"
85
+ role="dialog"
86
+ aria-modal="true"
87
+ :style="{ width }"
88
+ :aria-labelledby="title || $slots.header ? titleId : undefined"
89
+ :aria-label="title || $slots.header ? undefined : title || undefined"
90
+ >
91
+ <header v-if="title || $slots.header" class="app-modal-head">
92
+ <span :id="titleId" class="app-modal-title">
93
+ <slot name="header">{{ title }}</slot>
94
+ </span>
95
+ <button
96
+ type="button"
97
+ class="app-modal-close"
98
+ :aria-label="'Close'"
99
+ @click="emit('close')"
100
+ >
101
+ <Icon icon="material-symbols:close-rounded" width="18" height="18" />
102
+ </button>
103
+ </header>
104
+ <div class="app-modal-body">
105
+ <slot />
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </Transition>
110
+ </Teleport>
111
+
112
+ <!-- Mobile drawer branch — slides up from the bottom edge. -->
113
+ <Teleport to="body">
114
+ <Transition :name="backdropTransition">
115
+ <div
116
+ v-if="drawerVisible"
117
+ :class="[backdropClass, 'app-modal-drawer-backdrop']"
118
+ @click="onBackdropClick"
119
+ />
120
+ </Transition>
121
+ <Transition :name="panelTransition">
122
+ <div
123
+ v-if="drawerVisible"
124
+ :class="[panelClass, 'app-modal-drawer-panel']"
125
+ data-placement="bottom"
126
+ role="dialog"
127
+ aria-modal="true"
128
+ :aria-labelledby="title || $slots.header ? titleId : undefined"
129
+ :aria-label="title || $slots.header ? undefined : title || undefined"
130
+ >
131
+ <header v-if="title || $slots.header" class="app-modal-drawer-head">
132
+ <span :id="titleId" class="app-modal-title">
133
+ <slot name="header">{{ title }}</slot>
134
+ </span>
135
+ <button
136
+ type="button"
137
+ class="app-modal-close"
138
+ :aria-label="'Close'"
139
+ @click="emit('close')"
140
+ >
141
+ <Icon icon="material-symbols:close-rounded" width="18" height="18" />
142
+ </button>
143
+ </header>
144
+ <div class="app-modal-drawer-body">
145
+ <slot />
146
+ </div>
147
+ </div>
148
+ </Transition>
149
+ </Teleport>
150
+ </template>
151
+
152
+ <style scoped>
153
+ /* ── Desktop ────────────────────────────────────────────────────── */
154
+ .app-modal-backdrop {
155
+ position: fixed;
156
+ inset: 0;
157
+ background: rgba(0, 0, 0, 0.45);
158
+ display: flex;
159
+ align-items: center;
160
+ justify-content: center;
161
+ z-index: 1500;
162
+ }
163
+ .app-modal-panel {
164
+ max-height: 88vh;
165
+ background: var(--bg-surface);
166
+ border: 1px solid var(--border);
167
+ border-radius: var(--radius-lg);
168
+ box-shadow: 0 12px 36px rgba(0, 0, 0, 0.32);
169
+ display: flex;
170
+ flex-direction: column;
171
+ overflow: hidden;
172
+ }
173
+ .app-modal-head {
174
+ display: flex;
175
+ align-items: center;
176
+ gap: 0.5rem;
177
+ padding: 0.6rem 0.9rem;
178
+ border-bottom: 1px solid var(--border);
179
+ flex-shrink: 0;
180
+ }
181
+ .app-modal-title {
182
+ flex: 1;
183
+ font-weight: 600;
184
+ color: var(--text-strong);
185
+ }
186
+ .app-modal-close {
187
+ background: none;
188
+ border: none;
189
+ color: var(--text-muted);
190
+ cursor: pointer;
191
+ padding: 0.5rem;
192
+ min-width: 44px;
193
+ min-height: 44px;
194
+ display: inline-flex;
195
+ align-items: center;
196
+ justify-content: center;
197
+ }
198
+ .app-modal-close:hover { color: var(--text); }
199
+
200
+ .app-modal-fade-enter-active,
201
+ .app-modal-fade-leave-active { transition: opacity 0.18s ease; }
202
+ .app-modal-fade-enter-from,
203
+ .app-modal-fade-leave-to { opacity: 0; }
204
+
205
+ /* ── Mobile drawer ──────────────────────────────────────────────── */
206
+ /* useDrawer injects .drawer-backdrop / .drawer-panel positioning &
207
+ transitions; this layer adds the modal-specific chrome (rounded top,
208
+ shadow, max-height, safe-area inset, drag-handle). */
209
+ .app-modal-drawer-backdrop {
210
+ z-index: 1500;
211
+ }
212
+ .app-modal-drawer-panel {
213
+ z-index: 1501;
214
+ border-top: 1px solid var(--border);
215
+ border-radius: 14px 14px 0 0;
216
+ box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.28);
217
+ max-height: 88vh;
218
+ padding-bottom: env(safe-area-inset-bottom, 0px);
219
+ }
220
+ .app-modal-drawer-panel::before {
221
+ content: '';
222
+ display: block;
223
+ width: 36px;
224
+ height: 4px;
225
+ border-radius: 2px;
226
+ background: var(--text-muted);
227
+ opacity: 0.35;
228
+ margin: 0.4rem auto 0;
229
+ flex-shrink: 0;
230
+ }
231
+ .app-modal-drawer-head {
232
+ display: flex;
233
+ align-items: center;
234
+ gap: 0.5rem;
235
+ padding: 0.5rem 0.9rem 0.6rem;
236
+ border-bottom: 1px solid var(--border);
237
+ flex-shrink: 0;
238
+ }
239
+ .app-modal-drawer-body {
240
+ flex: 1;
241
+ min-height: 0;
242
+ overflow-y: auto;
243
+ overflow-x: hidden;
244
+ }
245
+
246
+ /* ── Desktop body wrapper ───────────────────────────────────────── */
247
+ /* Structural wrapper that matches the mobile .app-modal-drawer-body.
248
+ Padding is intentionally 0 — callers are responsible for spacing
249
+ their slot content. AppConfirmDialog and similar leaf components
250
+ supply their own inner padding. */
251
+ .app-modal-body {
252
+ flex: 1;
253
+ min-height: 0;
254
+ overflow-y: auto;
255
+ overflow-x: hidden;
256
+ }
257
+ </style>
@@ -0,0 +1,275 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue';
3
+ import { useBreakpoint } from '../composables/use-breakpoint';
4
+ import { usePopover, type Placement } from '../composables/use-popover';
5
+ import { useDrawer, type DrawerPlacement } from '../composables/use-drawer';
6
+
7
+ /**
8
+ * Viewport-aware popover:
9
+ * - Desktop → popover anchored to the trigger via usePopover.
10
+ * - Mobile → bottom drawer via useDrawer. Backdrop + panel z-index
11
+ * raised to 2000/2001 so a popover opened from inside the mobile
12
+ * hamburger nav (which sits at 1001) still renders on top.
13
+ *
14
+ * Two ways to wire the trigger:
15
+ * 1. `#trigger` slot — the slot is wrapped in a `display: contents`
16
+ * span so the wrapper doesn't affect the caller's layout. Clicks
17
+ * anywhere on the wrapped content toggle open/close.
18
+ * 2. `referenceEl` prop — for cases where the trigger can't live
19
+ * inside this component (e.g., per-row react buttons in a list
20
+ * that share a single popover instance). No click wiring; the
21
+ * caller drives open state directly.
22
+ *
23
+ * If both are provided, `referenceEl` wins.
24
+ *
25
+ * Open state is `v-model:open`; if the caller doesn't bind, the
26
+ * component manages it internally.
27
+ */
28
+ const props = withDefaults(defineProps<{
29
+ /** External reference element. Overrides the #trigger slot if set. */
30
+ referenceEl?: HTMLElement | null;
31
+ /** Controlled open state; two-way via v-model:open. Optional. */
32
+ open?: boolean;
33
+ placement?: Placement;
34
+ offset?: [number, number];
35
+ drawerPlacement?: DrawerPlacement;
36
+ /** Optional heading rendered at the top of the mobile drawer. */
37
+ drawerTitle?: string;
38
+ closeOnClickOutside?: boolean;
39
+ closeOnEscape?: boolean;
40
+ /** Close after any click inside the content (menu items etc). */
41
+ closeOnContentClick?: boolean;
42
+ /** Make the desktop popover match the trigger's width. */
43
+ sameWidth?: boolean;
44
+ }>(), {
45
+ referenceEl: null,
46
+ placement: 'bottom-start',
47
+ offset: () => [0, 8],
48
+ drawerPlacement: 'bottom',
49
+ closeOnClickOutside: true,
50
+ closeOnEscape: true,
51
+ closeOnContentClick: false,
52
+ sameWidth: false
53
+ });
54
+
55
+ const emit = defineEmits<{
56
+ (e: 'update:open', value: boolean): void;
57
+ }>();
58
+
59
+ const { isMobile } = useBreakpoint();
60
+
61
+ const internalOpen = ref(false);
62
+ const isOpen = computed<boolean>({
63
+ get: () => props.open ?? internalOpen.value,
64
+ set: (v) => {
65
+ internalOpen.value = v;
66
+ emit('update:open', v);
67
+ }
68
+ });
69
+
70
+ const triggerWrapRef = ref<HTMLElement | null>(null);
71
+ const contentEl = ref<HTMLElement | null>(null);
72
+
73
+ // Resolve the popover's anchor: caller's external prop wins; otherwise
74
+ // take the first real child of the slot wrapper. The wrapper itself uses
75
+ // display: contents, which means its getBoundingClientRect is unreliable
76
+ // (some browsers return zero) — reading firstElementChild gives Popper
77
+ // a real laid-out element to position against.
78
+ //
79
+ // Named `anchorRef` so it doesn't shadow the `referenceEl` prop in the
80
+ // template: `v-if="!referenceEl"` reads the prop, not this computed,
81
+ // which avoids a mount → unmount loop when the slot wrapper's presence
82
+ // feeds back into its own v-if condition.
83
+ const anchorRef = computed<HTMLElement | null>(() => {
84
+ if (props.referenceEl) return props.referenceEl;
85
+ const wrap = triggerWrapRef.value;
86
+ if (!wrap) return null;
87
+ return (wrap.firstElementChild as HTMLElement | null) ?? wrap;
88
+ });
89
+
90
+ // Visibility is purely derived: desktop popover shows iff we're open
91
+ // AND on a wide enough viewport (mobile switches to the drawer branch
92
+ // below). Keeping this a computed — rather than a ref with three sync
93
+ // watchers — means there's exactly one source of truth (`isOpen`) and
94
+ // `usePopover` just reflects it.
95
+ const popoverVisible = computed(() => !isMobile.value && isOpen.value);
96
+ const drawerVisible = computed(() => isMobile.value && isOpen.value);
97
+
98
+ usePopover(anchorRef, contentEl, {
99
+ placement: props.placement,
100
+ trigger: 'manual',
101
+ offset: props.offset,
102
+ teleportTo: 'body',
103
+ visible: popoverVisible,
104
+ closeOnClickOutside: props.closeOnClickOutside,
105
+ closeOnEscape: props.closeOnEscape,
106
+ closeOnContentClick: props.closeOnContentClick,
107
+ sameWidth: props.sameWidth,
108
+ // Surface self-close (click-outside / Escape / content click) back
109
+ // onto `isOpen` so the caller's v-model observes it. We only flip
110
+ // when `popoverVisible` is still truthy — the false branch means
111
+ // we're already mid-close (caller set open=false, or a mobile
112
+ // switch recomputed popoverVisible), and re-emitting would be
113
+ // redundant at best and a cycle at worst.
114
+ onHide: () => {
115
+ if (popoverVisible.value) isOpen.value = false;
116
+ }
117
+ });
118
+
119
+ const { placement: drawerPlace, backdropClass, panelClass, backdropTransition, panelTransition } = useDrawer({
120
+ visible: drawerVisible,
121
+ placement: props.drawerPlacement,
122
+ closeOnEscape: props.closeOnEscape,
123
+ onClose: () => { isOpen.value = false; }
124
+ });
125
+
126
+ function toggle() { isOpen.value = !isOpen.value; }
127
+ function open() { isOpen.value = true; }
128
+ function close() { isOpen.value = false; }
129
+
130
+ // Viewport-breakpoint flip while a popover is open: usePopover's hide()
131
+ // runs an async leave animation, so the desktop content keeps painting
132
+ // for ~150ms while the mobile drawer is already up — both surfaces
133
+ // visible in the same frame. Cut the leave short by zeroing the inline
134
+ // style usePopover manages, so only the drawer is on screen.
135
+ watch(isMobile, (mobile) => {
136
+ if (mobile && contentEl.value) {
137
+ contentEl.value.style.display = 'none';
138
+ contentEl.value.removeAttribute('data-show');
139
+ }
140
+ });
141
+ function onContentClick() {
142
+ if (props.closeOnContentClick) close();
143
+ }
144
+ </script>
145
+
146
+ <template>
147
+ <!-- Trigger wrapper. display: contents makes the span layout-invisible
148
+ so the slot children sit directly in the caller's flex/grid flow.
149
+ The span still exists in the event tree, so a click anywhere inside
150
+ bubbles up and toggles the popover. Suppressed entirely when the
151
+ caller supplies an external `referenceEl`. -->
152
+ <span
153
+ v-if="!referenceEl"
154
+ ref="triggerWrapRef"
155
+ class="app-popover-trigger"
156
+ @click="toggle"
157
+ >
158
+ <slot name="trigger" :is-open="isOpen" :toggle="toggle" :open="open" :close="close" />
159
+ </span>
160
+
161
+ <!-- Desktop popover content. Always rendered so usePopover can bind on
162
+ mount; visibility gated by popoverVisible. -->
163
+ <div
164
+ ref="contentEl"
165
+ class="app-popover-content app-popover-content--hidden"
166
+ @click="onContentClick"
167
+ >
168
+ <slot :close="close" :is-open="isOpen" />
169
+ </div>
170
+
171
+ <!-- Mobile drawer. -->
172
+ <Teleport v-if="isMobile" to="body">
173
+ <Transition :name="backdropTransition">
174
+ <div
175
+ v-if="drawerVisible"
176
+ :class="[backdropClass, 'app-popover-drawer-backdrop']"
177
+ :style="{ zIndex: 2000 }"
178
+ @click="close"
179
+ />
180
+ </Transition>
181
+ <Transition :name="panelTransition">
182
+ <div
183
+ v-if="drawerVisible"
184
+ :class="[panelClass, 'app-popover-drawer-panel']"
185
+ :data-placement="drawerPlace"
186
+ :style="{ zIndex: 2001 }"
187
+ role="dialog"
188
+ aria-modal="true"
189
+ @click="onContentClick"
190
+ >
191
+ <header v-if="drawerTitle" class="app-popover-drawer-title">{{ drawerTitle }}</header>
192
+ <div class="app-popover-drawer-body">
193
+ <slot :close="close" :is-open="isOpen" />
194
+ </div>
195
+ </div>
196
+ </Transition>
197
+ </Teleport>
198
+ </template>
199
+
200
+ <style scoped>
201
+ .app-popover-trigger {
202
+ display: contents;
203
+ }
204
+ .app-popover-content {
205
+ /* Above AppModal's backdrop (1500) — popovers opened from inside a
206
+ modal (e.g. the forward picker's destination AppSelect) need to
207
+ float above the modal's surface, not behind it. The mobile drawer
208
+ branch below already sits at 2000/2001 for the same reason. */
209
+ z-index: 1600;
210
+ }
211
+ /* .app-popover-content--hidden no longer sets display:none — visibility is
212
+ managed exclusively via inline style by use-popover (see use-popover.ts).
213
+ Keeping the class for semantic HTML only. */
214
+ .app-popover-drawer-panel {
215
+ max-height: 70vh;
216
+ background: var(--bg-surface);
217
+ border-top: 1px solid var(--border);
218
+ border-radius: 12px 12px 0 0;
219
+ display: flex;
220
+ flex-direction: column;
221
+ overflow: hidden;
222
+ }
223
+ /* Drag-handle visual cue at the screen-touching edge — mirrors AppModal's
224
+ bottom drawer so picker/menu drawers feel consistent with the rest of
225
+ the mobile chrome. Only emitted for top/bottom placements; side
226
+ drawers don't carry a handle. */
227
+ .app-popover-drawer-panel[data-placement="bottom"]::before,
228
+ .app-popover-drawer-panel[data-placement="top"]::after {
229
+ content: '';
230
+ display: block;
231
+ width: 36px;
232
+ height: 4px;
233
+ border-radius: 2px;
234
+ background: var(--text-muted);
235
+ opacity: 0.35;
236
+ flex-shrink: 0;
237
+ }
238
+ .app-popover-drawer-panel[data-placement="bottom"]::before {
239
+ margin: 0.4rem auto 0;
240
+ }
241
+ .app-popover-drawer-panel[data-placement="top"] {
242
+ border-top: none;
243
+ border-bottom: 1px solid var(--border);
244
+ border-radius: 0 0 12px 12px;
245
+ }
246
+ .app-popover-drawer-panel[data-placement="top"]::after {
247
+ margin: 0 auto 0.4rem;
248
+ }
249
+ .app-popover-drawer-panel[data-placement="left"],
250
+ .app-popover-drawer-panel[data-placement="right"] {
251
+ max-height: 100vh;
252
+ border-top: none;
253
+ border-radius: 0;
254
+ max-width: 85vw;
255
+ width: 360px;
256
+ }
257
+ .app-popover-drawer-title {
258
+ padding: 0.75rem 1rem;
259
+ font-weight: 600;
260
+ font-size: 0.95rem;
261
+ color: var(--text-strong);
262
+ border-bottom: 1px solid var(--border);
263
+ flex-shrink: 0;
264
+ }
265
+ .app-popover-drawer-body {
266
+ flex: 1;
267
+ min-height: 0;
268
+ overflow-y: auto;
269
+ /* Belt-and-braces: fixed-width content (e.g., MediaPicker's 420px
270
+ picker) shouldn't ever force horizontal overflow past the drawer
271
+ edge. Drawer panel already has overflow: hidden but body is the
272
+ scrolling region. */
273
+ overflow-x: hidden;
274
+ }
275
+ </style>
@@ -0,0 +1,89 @@
1
+ <script setup lang="ts">
2
+ import AppPopover from './AppPopover.vue';
3
+ import type { Placement } from '../composables/use-popover';
4
+ import type { DrawerPlacement } from '../composables/use-drawer';
5
+
6
+ /**
7
+ * Dropdown-select layer on top of AppPopover. Same viewport split
8
+ * (popover desktop / drawer mobile) and the same trigger-slot + v-model
9
+ * control surface, plus:
10
+ * - A styled content wrapper (surface background, border, shadow) so
11
+ * the dropdown doesn't render transparent.
12
+ * - `sameWidth` defaults to true so the menu matches the trigger's
13
+ * width, which is the usual expectation for a <select>-style picker.
14
+ * - Closes on item click by default.
15
+ *
16
+ * This component is a pure wrapper — `v-model:open` and all trigger /
17
+ * content slots pass straight through to AppPopover, so we don't have
18
+ * a second copy of the open-state dance to keep in sync.
19
+ *
20
+ * AppPopover stays the underlying primitive for cases where the caller
21
+ * wants to style the content themselves (e.g., MediaPicker with its
22
+ * own surface) or doesn't want the same-width behavior.
23
+ */
24
+ withDefaults(defineProps<{
25
+ open?: boolean;
26
+ placement?: Placement;
27
+ drawerPlacement?: DrawerPlacement;
28
+ drawerTitle?: string;
29
+ /** Close the dropdown after any click inside the menu. Default: true. */
30
+ closeOnItemClick?: boolean;
31
+ /** Make the popup width match the trigger's width. Default: true. */
32
+ sameWidth?: boolean;
33
+ }>(), {
34
+ placement: 'bottom-start',
35
+ drawerPlacement: 'bottom',
36
+ closeOnItemClick: true,
37
+ sameWidth: true
38
+ });
39
+
40
+ defineEmits<{
41
+ (e: 'update:open', value: boolean): void;
42
+ }>();
43
+ </script>
44
+
45
+ <template>
46
+ <AppPopover
47
+ :open="open"
48
+ :placement="placement"
49
+ :drawer-placement="drawerPlacement"
50
+ :drawer-title="drawerTitle"
51
+ :close-on-content-click="closeOnItemClick"
52
+ :same-width="sameWidth"
53
+ @update:open="(v: boolean) => $emit('update:open', v)"
54
+ >
55
+ <template #trigger="triggerScope">
56
+ <slot name="trigger" v-bind="triggerScope" />
57
+ </template>
58
+ <template #default="contentScope">
59
+ <div class="app-select-dropdown">
60
+ <slot v-bind="contentScope" />
61
+ </div>
62
+ </template>
63
+ </AppPopover>
64
+ </template>
65
+
66
+ <style scoped>
67
+ .app-select-dropdown {
68
+ min-width: 180px;
69
+ padding: 0.25rem 0;
70
+ background: var(--bg-surface);
71
+ border: 1px solid var(--border);
72
+ border-radius: var(--radius-base);
73
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
74
+ color: var(--text);
75
+ /* On mobile the dropdown renders inside AppPopover's drawer body
76
+ (which already provides its own surface). Flatten the local
77
+ chrome there so we don't double-box it. */
78
+ }
79
+ @media (max-width: 768px) {
80
+ .app-select-dropdown {
81
+ min-width: 0;
82
+ padding: 0;
83
+ background: transparent;
84
+ border: none;
85
+ border-radius: 0;
86
+ box-shadow: none;
87
+ }
88
+ }
89
+ </style>