@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.
- package/package.json +58 -0
- package/src/components/AppBadge.vue +155 -0
- package/src/components/AppButton.vue +171 -0
- package/src/components/AppConfirmDialog.vue +176 -0
- package/src/components/AppItemCard.vue +216 -0
- package/src/components/AppMenu.vue +197 -0
- package/src/components/AppMenuItem.vue +43 -0
- package/src/components/AppModal.vue +257 -0
- package/src/components/AppPopover.vue +275 -0
- package/src/components/AppSelect.vue +89 -0
- package/src/components/AppSelectField.vue +260 -0
- package/src/components/AppTabs.vue +310 -0
- package/src/components/AppTabsRouted.vue +409 -0
- package/src/components/AppTextArea.vue +128 -0
- package/src/components/AppTextField.vue +165 -0
- package/src/components/AppToast.vue +65 -0
- package/src/components/AppToggle.vue +105 -0
- package/src/components/Draggable.vue +178 -0
- package/src/components/GlobalConfirmDialog.vue +25 -0
- package/src/components/UnreadPill.vue +28 -0
- package/src/components/UserAvatar.vue +114 -0
- package/src/components/UserCard.vue +274 -0
- package/src/components/UserItem.vue +167 -0
- package/src/components/tab-types.ts +9 -0
- package/src/composables/use-app-shell.ts +65 -0
- package/src/composables/use-breakpoint.ts +14 -0
- package/src/composables/use-click-outside-stack.ts +67 -0
- package/src/composables/use-color-scheme.ts +75 -0
- package/src/composables/use-confirm.ts +36 -0
- package/src/composables/use-drawer.css +47 -0
- package/src/composables/use-drawer.ts +58 -0
- package/src/composables/use-escape-stack.ts +80 -0
- package/src/composables/use-file-drop.ts +47 -0
- package/src/composables/use-long-press.ts +62 -0
- package/src/composables/use-popover.css +32 -0
- package/src/composables/use-popover.ts +1324 -0
- package/src/composables/use-shift-key.ts +18 -0
- package/src/index.ts +75 -0
- package/src/lib/avatar.ts +35 -0
- package/src/reset.css +53 -0
- package/src/stores/toastStore.ts +26 -0
- 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>
|