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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user