feat(dev-indices): DevIndexOverlay (hover badge + click-copy + Esc + AppShell mount)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-12 12:02:38 +03:00
parent 901530ae41
commit d8c33b4cd6
3 changed files with 206 additions and 1 deletions
+7 -1
View File
@@ -9,7 +9,7 @@
* Источник дизайна: liderra_v8_handoff/concepts/v8_login.html (auth),
* v8_dashboard.html (app), v8_errors.html (error).
*/
import { computed } from 'vue';
import { computed, defineAsyncComponent, type Component } from 'vue';
import { RouterView, useRoute } from 'vue-router';
import AdminLayout from '../layouts/AdminLayout.vue';
import AppLayout from '../layouts/AppLayout.vue';
@@ -17,6 +17,11 @@ import AuthLayout from '../layouts/AuthLayout.vue';
const route = useRoute();
const layoutName = computed(() => route.meta.layout ?? 'app');
// Dev-only overlay: tree-shaken from production bundle via import.meta.env.DEV guard.
const DevIndexOverlay: Component | null = import.meta.env.DEV
? defineAsyncComponent(() => import('./DevIndexOverlay.vue'))
: null;
</script>
<template>
@@ -24,4 +29,5 @@ const layoutName = computed(() => route.meta.layout ?? 'app');
<RouterView v-else-if="layoutName === 'error'" />
<AdminLayout v-else-if="layoutName === 'admin'" />
<AppLayout v-else />
<component :is="DevIndexOverlay" v-if="DevIndexOverlay" />
</template>
@@ -0,0 +1,125 @@
<template>
<Teleport to="body">
<div
v-if="currentId !== null && currentTarget"
class="dx-badge"
:class="{ 'dx-badge--copied': justCopied }"
:style="badgePosition"
@click.stop="copyToClipboard"
>
<span class="dx-badge__num">#{{ currentId }}</span>
<span class="dx-badge__meta">{{ tagLabel }} · "{{ textPreview }}"</span>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
import { useDevIndices } from '../composables/useDevIndices';
const { currentId, currentTarget, hoverEnabled, setTarget, reset } = useDevIndices();
const cursorX = ref(0);
const cursorY = ref(0);
const justCopied = ref(false);
let mousemoveRAF: number | null = null;
const tagLabel = computed(() => {
const t = currentTarget.value;
if (!t) return '';
return t.tagName.toLowerCase();
});
const textPreview = computed(() => {
const t = currentTarget.value;
if (!t) return '';
const text = (t.textContent ?? '').trim().slice(0, 24);
return text || '—';
});
const badgePosition = computed(() => ({
left: `${cursorX.value + 12}px`,
top: `${cursorY.value + 12}px`,
}));
function onMousemove(e: MouseEvent) {
if (!hoverEnabled.value) return;
cursorX.value = e.clientX;
cursorY.value = e.clientY;
if (mousemoveRAF !== null) return;
mousemoveRAF = requestAnimationFrame(() => {
mousemoveRAF = null;
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
if (!el) {
setTarget(null);
return;
}
const withDx = el.closest('[data-dx]') as HTMLElement | null;
setTarget(withDx);
});
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
reset();
}
}
async function copyToClipboard() {
if (currentId.value === null) return;
try {
await navigator.clipboard.writeText(`#${currentId.value}`);
justCopied.value = true;
setTimeout(() => (justCopied.value = false), 400);
} catch {
// clipboard may be unavailable in some contexts; silent fail OK in dev tool
}
}
onMounted(() => {
document.addEventListener('mousemove', onMousemove);
document.addEventListener('keydown', onKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onMousemove);
document.removeEventListener('keydown', onKeydown);
if (mousemoveRAF !== null) cancelAnimationFrame(mousemoveRAF);
});
</script>
<style scoped>
.dx-badge {
position: fixed;
z-index: 999999;
pointer-events: auto;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
background: #0f6e56;
color: #fff;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11px;
line-height: 1.4;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.24);
user-select: none;
transition: background 120ms ease;
}
.dx-badge--copied {
background: #21a16e;
}
.dx-badge__num {
background: rgba(255, 255, 255, 0.22);
padding: 1px 6px;
border-radius: 3px;
font-weight: 600;
}
.dx-badge__meta {
letter-spacing: 0.02em;
opacity: 0.92;
}
</style>
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import DevIndexOverlay from '../../resources/js/components/DevIndexOverlay.vue';
import { useDevIndices } from '../../resources/js/composables/useDevIndices';
describe('DevIndexOverlay', () => {
beforeEach(() => useDevIndices().reset());
it('hidden when no current target', () => {
mount(DevIndexOverlay, { attachTo: document.body });
expect(document.querySelector('.dx-badge')).toBeNull();
});
it('shows badge with id + tag when target is set', async () => {
const el = document.createElement('button');
el.setAttribute('data-dx', '1030');
el.textContent = 'Создать';
document.body.appendChild(el);
const dx = useDevIndices();
dx.setTarget(el);
mount(DevIndexOverlay, { attachTo: document.body });
await nextTick();
const badge = document.querySelector('.dx-badge');
expect(badge).not.toBeNull();
expect(badge!.textContent).toContain('1030');
expect(badge!.textContent!.toLowerCase()).toContain('button');
document.body.removeChild(el);
});
it('Esc clears the target', async () => {
const el = document.createElement('div');
el.setAttribute('data-dx', '5');
document.body.appendChild(el);
const dx = useDevIndices();
dx.setTarget(el);
mount(DevIndexOverlay, { attachTo: document.body });
await nextTick();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await nextTick();
expect(dx.currentId.value).toBeNull();
document.body.removeChild(el);
});
it('clicking the badge copies "#<id>" to clipboard', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
});
const el = document.createElement('button');
el.setAttribute('data-dx', '1030');
document.body.appendChild(el);
useDevIndices().setTarget(el);
mount(DevIndexOverlay, { attachTo: document.body });
await nextTick();
const badge = document.querySelector('.dx-badge') as HTMLElement;
expect(badge).not.toBeNull();
badge.click();
await nextTick();
expect(writeText).toHaveBeenCalledWith('#1030');
document.body.removeChild(el);
});
});