Refining the speech-to-text conversion structure
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
open: boolean;
|
||||
/** start = لبه راست تریگر در RTL؛ end = لبه چپ */
|
||||
align?: 'start' | 'end';
|
||||
}>(),
|
||||
{ align: 'end' },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean];
|
||||
}>();
|
||||
|
||||
const anchorRef = ref<HTMLElement | null>(null);
|
||||
const panelRef = ref<HTMLElement | null>(null);
|
||||
const panelStyle = ref<Record<string, string>>({});
|
||||
|
||||
function toggle() {
|
||||
emit('update:open', !props.open);
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (props.open) emit('update:open', false);
|
||||
}
|
||||
|
||||
function onPanelClickCapture() {
|
||||
// بعد از handler آیتمها ببند (حتی اگر stopPropagation زده باشند)
|
||||
queueMicrotask(() => close());
|
||||
}
|
||||
|
||||
function place() {
|
||||
const anchor = anchorRef.value;
|
||||
const panel = panelRef.value;
|
||||
if (!anchor || !panel) return;
|
||||
|
||||
const r = anchor.getBoundingClientRect();
|
||||
const menuH = panel.offsetHeight || 140;
|
||||
const menuW = panel.offsetWidth || 160;
|
||||
const gap = 6;
|
||||
const pad = 8;
|
||||
|
||||
const spaceBelow = window.innerHeight - r.bottom;
|
||||
const openUp = spaceBelow < menuH + gap + pad && r.top > menuH + gap + pad;
|
||||
|
||||
let left =
|
||||
props.align === 'start' ? r.left : r.right - menuW;
|
||||
left = Math.max(pad, Math.min(left, window.innerWidth - menuW - pad));
|
||||
|
||||
let top = openUp ? r.top - menuH - gap : r.bottom + gap;
|
||||
top = Math.max(pad, Math.min(top, window.innerHeight - menuH - pad));
|
||||
|
||||
panelStyle.value = {
|
||||
position: 'fixed',
|
||||
top: `${Math.round(top)}px`,
|
||||
left: `${Math.round(left)}px`,
|
||||
zIndex: 'var(--z-dropdown, 50)',
|
||||
};
|
||||
}
|
||||
|
||||
async function placeSoon() {
|
||||
await nextTick();
|
||||
place();
|
||||
// بعد از رندر محتوا ارتفاع دقیقتر میشود
|
||||
requestAnimationFrame(place);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) void placeSoon();
|
||||
},
|
||||
);
|
||||
|
||||
function onPointerDown(ev: PointerEvent) {
|
||||
if (!props.open) return;
|
||||
const t = ev.target as Node;
|
||||
if (anchorRef.value?.contains(t) || panelRef.value?.contains(t)) return;
|
||||
close();
|
||||
}
|
||||
|
||||
function onKey(ev: KeyboardEvent) {
|
||||
if (ev.key === 'Escape') close();
|
||||
}
|
||||
|
||||
function onReposition() {
|
||||
if (props.open) place();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onPointerDown, true);
|
||||
document.addEventListener('keydown', onKey);
|
||||
window.addEventListener('resize', onReposition);
|
||||
window.addEventListener('scroll', onReposition, true);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onPointerDown, true);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('resize', onReposition);
|
||||
window.removeEventListener('scroll', onReposition, true);
|
||||
});
|
||||
|
||||
defineExpose({ toggle, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span ref="anchorRef" class="ui-dd-anchor">
|
||||
<slot name="trigger" :open="open" :toggle="toggle" :close="close" />
|
||||
</span>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
ref="panelRef"
|
||||
class="ui-dd-panel"
|
||||
role="menu"
|
||||
:style="panelStyle"
|
||||
@click.capture="onPanelClickCapture"
|
||||
>
|
||||
<slot :close="close" />
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ui-dd-anchor {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ui-dd-panel {
|
||||
min-width: 10rem;
|
||||
max-width: min(20rem, calc(100vw - 16px));
|
||||
background: var(--surface, #fff);
|
||||
border: 1px solid var(--line, #e2e8f0);
|
||||
border-radius: var(--radius, 10px);
|
||||
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.14);
|
||||
padding: 0.35rem;
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.ui-dd-panel :deep(button),
|
||||
.ui-dd-panel :deep(a) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: right;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: var(--radius, 10px);
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
color: var(--ink, #0f172a);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ui-dd-panel :deep(button:hover),
|
||||
.ui-dd-panel :deep(a:hover),
|
||||
.ui-dd-panel :deep(button.on),
|
||||
.ui-dd-panel :deep(a.on) {
|
||||
background: var(--accent-soft, #ecfdf8);
|
||||
color: var(--accent-deep, #0f766e);
|
||||
}
|
||||
|
||||
.ui-dd-panel :deep(button.danger),
|
||||
.ui-dd-panel :deep(a.danger) {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.ui-dd-panel :deep(button.danger:hover),
|
||||
.ui-dd-panel :deep(a.danger:hover) {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.ui-dd-panel :deep(li) {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ui-dd-panel :deep(ul) {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
}
|
||||
</style>
|
||||
@@ -13,3 +13,4 @@ export { default as UiSearch } from './UiSearch.vue';
|
||||
export { default as UiFilterBar } from './UiFilterBar.vue';
|
||||
export { default as UiSegmented } from './UiSegmented.vue';
|
||||
export { default as UiChart, CHART_PALETTE, type UiChartOption } from './UiChart.vue';
|
||||
export { default as UiDropdownMenu } from './UiDropdownMenu.vue';
|
||||
|
||||
Reference in New Issue
Block a user