feat: 내정보 상세 설명 툴팁 이관
말·무기·서적·도구·병종·특기 설명과 국가 성향 정보를 API에 투영하고 Tippy.js 기반 공용 툴팁으로 표시한다. 실제 Chromium의 hover·focus와 모바일 경계를 회귀 테스트한다.
This commit is contained in:
@@ -64,6 +64,7 @@ type FixtureState = {
|
||||
joinConfig?: Record<string, unknown>;
|
||||
createGeneralInputs?: Array<Record<string, unknown>>;
|
||||
mainTraits?: { personal: string; specialDomestic: string; specialWar: string };
|
||||
richMyInfo?: boolean;
|
||||
hiddenSeedLogText?: string;
|
||||
recentRecords?: {
|
||||
global: Array<{ id: number; text: string; createdAt?: string }>;
|
||||
@@ -103,7 +104,22 @@ const myGeneral = (state: FixtureState) => ({
|
||||
turnTime: '2026-01-01 00:10:00',
|
||||
crewTypeId: 1,
|
||||
crewTypeName: '보병',
|
||||
crewTypeInfo: state.richMyInfo
|
||||
? {
|
||||
name: '보병',
|
||||
info: ['표준적인 보병입니다.', '보병은 방어특화이며,', '상대가 회피하기 어렵습니다.'],
|
||||
requirements: ['기술력 1000 이상 필요'],
|
||||
stats: { attack: 100, defence: 150, speed: 7, avoid: 10, magicCoef: 0, cost: 9, rice: 9 },
|
||||
}
|
||||
: null,
|
||||
traits: state.mainTraits ?? { personal: '-', specialDomestic: '-', specialWar: '-' },
|
||||
traitInfo: state.richMyInfo
|
||||
? {
|
||||
personal: '부상당할 확률이 감소합니다.',
|
||||
specialDomestic: '상업 내정 효율이 증가합니다.',
|
||||
specialWar: '계략 성공률이 증가합니다.<br>발동 순서는 레거시와 같습니다.',
|
||||
}
|
||||
: { personal: '', specialDomestic: '', specialWar: '' },
|
||||
progression: {
|
||||
experienceLevel: 1,
|
||||
dedicationLevel: 2,
|
||||
@@ -121,8 +137,20 @@ const myGeneral = (state: FixtureState) => ({
|
||||
killedCrew: 12_345,
|
||||
lostCrew: 6_789,
|
||||
},
|
||||
items: { horse: 'che_명마', weapon: null, book: null, item: null },
|
||||
itemNames: { horse: '명마', weapon: null, book: null, item: null },
|
||||
items: state.richMyInfo
|
||||
? { horse: 'che_명마', weapon: 'che_단도', book: 'che_효경전', item: 'che_납금박산로' }
|
||||
: { horse: 'che_명마', weapon: null, book: null, item: null },
|
||||
itemNames: state.richMyInfo
|
||||
? { horse: '명마', weapon: '단도', book: '효경전', item: '납금박산로' }
|
||||
: { horse: '명마', weapon: null, book: null, item: null },
|
||||
itemInfo: state.richMyInfo
|
||||
? {
|
||||
horse: '통솔 +3',
|
||||
weapon: '무력 +1',
|
||||
book: '지력 +1',
|
||||
item: '내정 실행 시 성공률이 증가합니다.<br>소모되지 않습니다.',
|
||||
}
|
||||
: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
city: {
|
||||
id: 1,
|
||||
@@ -201,6 +229,7 @@ const myGeneral = (state: FixtureState) => ({
|
||||
capitalCityName: '업',
|
||||
typePros: '금수입↑ 치안↑',
|
||||
typeCons: '인구↓ 민심↓',
|
||||
typeInfo: state.richMyInfo ? '법과 질서를 중시하여 국가 운영을 안정시킵니다.' : '',
|
||||
population: { cityCount: 1, current: 1_000, max: 2_000 },
|
||||
crew: { generalCount: 2, current: 500, max: 7_000 },
|
||||
power: 1_234,
|
||||
@@ -1349,6 +1378,106 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
|
||||
await persistParityArtifact(page, 'core-my-page-mobile', mobile);
|
||||
});
|
||||
|
||||
test('내 정보 항목과 국가 성향은 HTML 리치 툴팁을 마우스와 키보드로 표시한다', async ({ page }) => {
|
||||
const state: FixtureState = {
|
||||
permission: 'head',
|
||||
myset: 3,
|
||||
richMyInfo: true,
|
||||
mainTraits: { personal: '안전', specialDomestic: '상재', specialWar: '신산' },
|
||||
settingMutations: [],
|
||||
accessPages: [],
|
||||
};
|
||||
await install(page, state);
|
||||
|
||||
const visibleTooltip = page.locator('.tippy-box[data-theme~="sammo-rich"][data-state="visible"]');
|
||||
const showWithMouse = async (testId: string, expectedTexts: readonly string[]) => {
|
||||
const trigger = page.locator(`[data-rich-tooltip="${testId}"]`);
|
||||
await expect(trigger).toHaveAttribute('tabindex', '0');
|
||||
await trigger.hover();
|
||||
await expect(visibleTooltip).toHaveCount(1);
|
||||
await expect(visibleTooltip).toHaveAttribute('role', 'tooltip');
|
||||
for (const expectedText of expectedTexts) {
|
||||
await expect(visibleTooltip).toContainText(expectedText);
|
||||
}
|
||||
await expect(trigger).toHaveAttribute('aria-describedby', /tippy-/u);
|
||||
await page.mouse.move(1, 1);
|
||||
await expect(visibleTooltip).toHaveCount(0);
|
||||
};
|
||||
|
||||
await page.setViewportSize({ width: 1000, height: 900 });
|
||||
await page.goto('my-page');
|
||||
await expect(page.locator('[data-general-basic-card]')).toBeVisible();
|
||||
|
||||
const cardBefore = await page.locator('[data-general-basic-card]').boundingBox();
|
||||
await showWithMouse('horse', ['명마', '통솔 +3']);
|
||||
await showWithMouse('weapon', ['단도', '무력 +1']);
|
||||
await showWithMouse('book', ['효경전', '지력 +1']);
|
||||
await showWithMouse('item', ['납금박산로', '내정 실행 시 성공률이 증가합니다.', '소모되지 않습니다.']);
|
||||
await showWithMouse('crew-type', [
|
||||
'보병',
|
||||
'표준적인 보병입니다.',
|
||||
'전투 정보',
|
||||
'공격 100 · 방어 150',
|
||||
'병사 100명 기준 금 9 · 쌀 9',
|
||||
'생성 조건',
|
||||
'기술력 1000 이상 필요',
|
||||
]);
|
||||
await showWithMouse('personality', ['안전', '부상당할 확률이 감소합니다.']);
|
||||
await showWithMouse('special-domestic', ['내정특기 · 상재', '상업 내정 효율이 증가합니다.']);
|
||||
await showWithMouse('special-war', [
|
||||
'전투특기 · 신산',
|
||||
'계략 성공률이 증가합니다.',
|
||||
'발동 순서는 레거시와 같습니다.',
|
||||
]);
|
||||
expect(await page.locator('[data-general-basic-card]').boundingBox()).toEqual(cardBefore);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(1000);
|
||||
|
||||
const warTrigger = page.locator('[data-rich-tooltip="special-war"]');
|
||||
await warTrigger.focus();
|
||||
await expect(warTrigger).toBeFocused();
|
||||
await expect(visibleTooltip).toHaveCount(1);
|
||||
await expect(visibleTooltip.locator('.rich-tooltip-content__line')).toHaveCount(2);
|
||||
expect(await visibleTooltip.locator('.tippy-content').innerHTML()).not.toContain('<br');
|
||||
await persistParityArtifact(page, 'core-my-info-rich-tooltip-desktop', {
|
||||
trigger: await warTrigger.boundingBox(),
|
||||
tooltip: await visibleTooltip.boundingBox(),
|
||||
scrollWidth: await page.evaluate(() => document.documentElement.scrollWidth),
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.reload();
|
||||
const crewTrigger = page.locator('[data-rich-tooltip="crew-type"]');
|
||||
await crewTrigger.focus();
|
||||
await expect(visibleTooltip).toHaveCount(1);
|
||||
const mobileTooltip = await visibleTooltip.boundingBox();
|
||||
expect(mobileTooltip).not.toBeNull();
|
||||
expect(mobileTooltip!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(mobileTooltip!.x + mobileTooltip!.width).toBeLessThanOrEqual(390);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390);
|
||||
await persistParityArtifact(page, 'core-my-info-rich-tooltip-mobile', {
|
||||
trigger: await crewTrigger.boundingBox(),
|
||||
tooltip: mobileTooltip,
|
||||
scrollWidth: await page.evaluate(() => document.documentElement.scrollWidth),
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 1000, height: 900 });
|
||||
await page.goto('');
|
||||
const nationType = page.locator('[data-rich-tooltip="nation-type"]');
|
||||
await nationType.hover();
|
||||
await expect(visibleTooltip).toHaveCount(1);
|
||||
await expect(visibleTooltip).toContainText('국가 성향 · 법가');
|
||||
await expect(visibleTooltip).toContainText('법과 질서를 중시하여 국가 운영을 안정시킵니다.');
|
||||
await expect(visibleTooltip).toContainText('장점 금수입↑ 치안↑');
|
||||
await expect(visibleTooltip).toContainText('단점 인구↓ 민심↓');
|
||||
await nationType.focus();
|
||||
await expect(nationType).toBeFocused();
|
||||
await expect(visibleTooltip).toHaveCount(1);
|
||||
await persistParityArtifact(page, 'core-nation-type-rich-tooltip-desktop', {
|
||||
trigger: await nationType.boundingBox(),
|
||||
tooltip: await visibleTooltip.boundingBox(),
|
||||
});
|
||||
});
|
||||
|
||||
test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오른쪽에 정렬된다', async ({ page }) => {
|
||||
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||||
await install(page, state);
|
||||
@@ -1528,7 +1657,9 @@ test('실제 모바일 터치로 메인 패널 순서를 재정렬한다', async
|
||||
await dialog.screenshot({ path: testInfo.outputPath('mobile-main-panel-touch-dialog.png') });
|
||||
await dialog.getByRole('button', { name: '적용', exact: true }).click();
|
||||
await expect
|
||||
.poll(() => mobilePage.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]')))
|
||||
.poll(() =>
|
||||
mobilePage.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]'))
|
||||
)
|
||||
.toEqual([
|
||||
'nation-menu',
|
||||
'commands',
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"es-toolkit": "^1.43.0",
|
||||
"mitt": "^3.0.1",
|
||||
"pinia": "^3.0.4",
|
||||
"tippy.js": "6.3.7",
|
||||
"vue": "^3.5.26",
|
||||
"vue-draggable-plus": "0.6.1",
|
||||
"vue-router": "^4.6.4",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed } from 'vue';
|
||||
|
||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
|
||||
import RichTooltip from '../ui/RichTooltip.vue';
|
||||
import { formatLocalTimeSeconds } from '../../utils/legacyDateTime';
|
||||
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
|
||||
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon';
|
||||
@@ -30,6 +31,28 @@ interface ItemDisplayNames {
|
||||
item?: string | null;
|
||||
}
|
||||
|
||||
interface ItemDisplayInfo {
|
||||
horse?: string | null;
|
||||
weapon?: string | null;
|
||||
book?: string | null;
|
||||
item?: string | null;
|
||||
}
|
||||
|
||||
interface CrewTypeDisplayInfo {
|
||||
name: string;
|
||||
info: string[];
|
||||
requirements: string[];
|
||||
stats: {
|
||||
attack: number;
|
||||
defence: number;
|
||||
speed: number;
|
||||
avoid: number;
|
||||
magicCoef: number;
|
||||
cost: number;
|
||||
rice: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface GeneralTroopDisplay {
|
||||
name: string;
|
||||
status: 'inactive' | 'present' | 'away';
|
||||
@@ -73,9 +96,12 @@ interface GeneralInfo {
|
||||
refreshScore?: GeneralRefreshScore;
|
||||
crewTypeId?: number;
|
||||
crewTypeName?: string;
|
||||
crewTypeInfo?: CrewTypeDisplayInfo | null;
|
||||
traits?: { personal: string; specialWar: string; specialDomestic: string };
|
||||
traitInfo?: { personal: string; specialWar: string; specialDomestic: string };
|
||||
progression?: GeneralProgression;
|
||||
itemNames?: ItemDisplayNames;
|
||||
itemInfo?: ItemDisplayInfo;
|
||||
equipmentNames?: ItemDisplayNames;
|
||||
}
|
||||
|
||||
@@ -243,9 +269,36 @@ const specialText = computed(() => {
|
||||
</strong>
|
||||
</template>
|
||||
|
||||
<span class="cell-label">명마</span><strong>{{ itemNames.horse ?? '-' }}</strong>
|
||||
<span class="cell-label">무기</span><strong>{{ itemNames.weapon ?? '-' }}</strong>
|
||||
<span class="cell-label">서적</span><strong>{{ itemNames.book ?? '-' }}</strong>
|
||||
<span class="cell-label">명마</span>
|
||||
<strong>
|
||||
<RichTooltip
|
||||
:title="itemNames.horse ?? ''"
|
||||
:description="props.general.itemInfo?.horse"
|
||||
test-id="horse"
|
||||
>
|
||||
{{ itemNames.horse ?? '-' }}
|
||||
</RichTooltip>
|
||||
</strong>
|
||||
<span class="cell-label">무기</span>
|
||||
<strong>
|
||||
<RichTooltip
|
||||
:title="itemNames.weapon ?? ''"
|
||||
:description="props.general.itemInfo?.weapon"
|
||||
test-id="weapon"
|
||||
>
|
||||
{{ itemNames.weapon ?? '-' }}
|
||||
</RichTooltip>
|
||||
</strong>
|
||||
<span class="cell-label">서적</span>
|
||||
<strong>
|
||||
<RichTooltip
|
||||
:title="itemNames.book ?? ''"
|
||||
:description="props.general.itemInfo?.book"
|
||||
test-id="book"
|
||||
>
|
||||
{{ itemNames.book ?? '-' }}
|
||||
</RichTooltip>
|
||||
</strong>
|
||||
|
||||
<span
|
||||
class="general-image general-crew-type-icon"
|
||||
@@ -255,15 +308,91 @@ const specialText = computed(() => {
|
||||
/>
|
||||
<span class="cell-label">자금</span><strong>{{ props.general.gold.toLocaleString('ko-KR') }}</strong>
|
||||
<span class="cell-label">군량</span><strong>{{ props.general.rice.toLocaleString('ko-KR') }}</strong>
|
||||
<span class="cell-label">도구</span><strong>{{ itemNames.item ?? '-' }}</strong>
|
||||
<span class="cell-label">도구</span>
|
||||
<strong>
|
||||
<RichTooltip
|
||||
:title="itemNames.item ?? ''"
|
||||
:description="props.general.itemInfo?.item"
|
||||
test-id="item"
|
||||
>
|
||||
{{ itemNames.item ?? '-' }}
|
||||
</RichTooltip>
|
||||
</strong>
|
||||
|
||||
<span class="cell-label">병종</span><strong>{{ props.general.crewTypeName ?? '-' }}</strong>
|
||||
<span class="cell-label">병종</span>
|
||||
<strong>
|
||||
<RichTooltip
|
||||
:title="props.general.crewTypeName ?? ''"
|
||||
:description="props.general.crewTypeInfo?.info"
|
||||
test-id="crew-type"
|
||||
>
|
||||
{{ props.general.crewTypeName ?? '-' }}
|
||||
<template v-if="props.general.crewTypeInfo" #content>
|
||||
<span class="rich-tooltip-content__title">{{ props.general.crewTypeInfo.name }}</span>
|
||||
<span
|
||||
v-for="(line, index) in props.general.crewTypeInfo.info"
|
||||
:key="`crew-info:${index}`"
|
||||
class="rich-tooltip-content__line"
|
||||
>
|
||||
{{ line }}
|
||||
</span>
|
||||
<span class="rich-tooltip-content__section">전투 정보</span>
|
||||
<span class="rich-tooltip-content__meta">
|
||||
공격 {{ props.general.crewTypeInfo.stats.attack }} · 방어
|
||||
{{ props.general.crewTypeInfo.stats.defence }} · 속도
|
||||
{{ props.general.crewTypeInfo.stats.speed }} · 회피
|
||||
{{ props.general.crewTypeInfo.stats.avoid }}% · 계략
|
||||
{{ props.general.crewTypeInfo.stats.magicCoef }}%
|
||||
</span>
|
||||
<span class="rich-tooltip-content__meta">
|
||||
병사 100명 기준 금 {{ props.general.crewTypeInfo.stats.cost }} · 쌀
|
||||
{{ props.general.crewTypeInfo.stats.rice }}
|
||||
</span>
|
||||
<template v-if="props.general.crewTypeInfo.requirements.length">
|
||||
<span class="rich-tooltip-content__section">생성 조건</span>
|
||||
<span
|
||||
v-for="(requirement, index) in props.general.crewTypeInfo.requirements"
|
||||
:key="`crew-requirement:${index}`"
|
||||
class="rich-tooltip-content__line"
|
||||
>
|
||||
{{ requirement }}
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
</RichTooltip>
|
||||
</strong>
|
||||
<span class="cell-label">병사</span><strong>{{ props.general.crew.toLocaleString('ko-KR') }}</strong>
|
||||
<span class="cell-label">성격</span><strong>{{ props.general.traits?.personal ?? '-' }}</strong>
|
||||
<span class="cell-label">성격</span>
|
||||
<strong>
|
||||
<RichTooltip
|
||||
:title="props.general.traits?.personal ?? ''"
|
||||
:description="props.general.traitInfo?.personal"
|
||||
test-id="personality"
|
||||
>
|
||||
{{ props.general.traits?.personal ?? '-' }}
|
||||
</RichTooltip>
|
||||
</strong>
|
||||
|
||||
<span class="cell-label">훈련</span><strong>{{ props.general.train }}</strong>
|
||||
<span class="cell-label">사기</span><strong>{{ props.general.atmos }}</strong>
|
||||
<span class="cell-label">특기</span><strong :title="specialText">{{ specialText }}</strong>
|
||||
<span class="cell-label">특기</span>
|
||||
<strong class="special-value" :aria-label="specialText">
|
||||
<RichTooltip
|
||||
:title="`내정특기 · ${props.general.traits?.specialDomestic ?? '-'}`"
|
||||
:description="props.general.traitInfo?.specialDomestic"
|
||||
test-id="special-domestic"
|
||||
>
|
||||
{{ props.general.traits?.specialDomestic ?? '-' }}
|
||||
</RichTooltip>
|
||||
/
|
||||
<RichTooltip
|
||||
:title="`전투특기 · ${props.general.traits?.specialWar ?? '-'}`"
|
||||
:description="props.general.traitInfo?.specialWar"
|
||||
test-id="special-war"
|
||||
>
|
||||
{{ props.general.traits?.specialWar ?? '-' }}
|
||||
</RichTooltip>
|
||||
</strong>
|
||||
|
||||
<span class="cell-label level-label">Lv</span>
|
||||
<strong class="level-value">{{ props.general.progression?.experienceLevel ?? 0 }}</strong>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
import RichTooltip from '../ui/RichTooltip.vue';
|
||||
import { legacyLuminanceTextColor } from '../../utils/legacyNationColor';
|
||||
import { formatOfficerLevelText } from '../../utils/nationFormat';
|
||||
import { getNpcColor } from '../../utils/npcColor';
|
||||
@@ -19,6 +20,7 @@ interface NationInfo {
|
||||
rice: number;
|
||||
tech: number;
|
||||
typeName: string;
|
||||
typeInfo?: string;
|
||||
typePros: string;
|
||||
typeCons: string;
|
||||
population: { cityCount: number; current: number; max: number };
|
||||
@@ -67,9 +69,37 @@ const displayChiefName = (chief: NationChief | undefined): string => {
|
||||
|
||||
<span class="head">성향</span>
|
||||
<strong class="body type-body">
|
||||
{{ props.nation.typeName }} (<span class="pros">{{ props.nation.typePros }}</span>
|
||||
<span class="cons">{{ props.nation.typeCons }}</span
|
||||
>)
|
||||
<RichTooltip
|
||||
v-if="props.nation.typeInfo"
|
||||
:title="`국가 성향 · ${props.nation.typeName}`"
|
||||
:description="props.nation.typeInfo"
|
||||
test-id="nation-type"
|
||||
>
|
||||
{{ props.nation.typeName }} (<span class="pros">{{ props.nation.typePros }}</span>
|
||||
<span class="cons">{{ props.nation.typeCons }}</span
|
||||
>)
|
||||
<template #content="{ descriptionLines }">
|
||||
<span class="rich-tooltip-content__title">국가 성향 · {{ props.nation.typeName }}</span>
|
||||
<span
|
||||
v-for="(line, index) in descriptionLines"
|
||||
:key="`nation-type-info:${index}`"
|
||||
class="rich-tooltip-content__line"
|
||||
>
|
||||
{{ line }}
|
||||
</span>
|
||||
<span class="rich-tooltip-content__line rich-tooltip-content__pros">
|
||||
장점 {{ props.nation.typePros || '-' }}
|
||||
</span>
|
||||
<span class="rich-tooltip-content__line rich-tooltip-content__cons">
|
||||
단점 {{ props.nation.typeCons || '-' }}
|
||||
</span>
|
||||
</template>
|
||||
</RichTooltip>
|
||||
<template v-else>
|
||||
{{ props.nation.typeName }} (<span class="pros">{{ props.nation.typePros }}</span>
|
||||
<span class="cons">{{ props.nation.typeCons }}</span
|
||||
>)
|
||||
</template>
|
||||
</strong>
|
||||
|
||||
<span class="head">{{ formatOfficerLevelText(12, props.nation.level) }}</span>
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots, watch } from 'vue';
|
||||
import tippy, { type Instance, type Placement } from 'tippy.js';
|
||||
import 'tippy.js/dist/tippy.css';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
description?: string | readonly string[] | null;
|
||||
placement?: Placement;
|
||||
maxWidth?: number;
|
||||
testId?: string;
|
||||
}>(),
|
||||
{
|
||||
title: '',
|
||||
description: null,
|
||||
placement: 'top',
|
||||
maxWidth: 360,
|
||||
testId: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const slots = useSlots();
|
||||
const triggerElement = ref<HTMLElement | null>(null);
|
||||
const contentElement = ref<HTMLElement | null>(null);
|
||||
let instance: Instance | null = null;
|
||||
|
||||
const descriptionLines = computed(() => {
|
||||
const source = Array.isArray(props.description) ? props.description : [props.description ?? ''];
|
||||
return source
|
||||
.flatMap((line) => line.split(/<br\s*\/?\s*>|\r?\n/giu))
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
const hasContent = computed(() => Boolean(slots.content) || descriptionLines.value.length > 0);
|
||||
|
||||
const destroyTooltip = () => {
|
||||
instance?.destroy();
|
||||
instance = null;
|
||||
};
|
||||
|
||||
const installTooltip = async () => {
|
||||
destroyTooltip();
|
||||
await nextTick();
|
||||
if (!hasContent.value || !triggerElement.value || !contentElement.value) return;
|
||||
|
||||
instance = tippy(triggerElement.value, {
|
||||
allowHTML: true,
|
||||
appendTo: () => document.body,
|
||||
content: () => contentElement.value?.innerHTML ?? '',
|
||||
maxWidth: props.maxWidth,
|
||||
placement: props.placement,
|
||||
theme: 'sammo-rich',
|
||||
trigger: 'mouseenter focus',
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => void installTooltip());
|
||||
onBeforeUnmount(destroyTooltip);
|
||||
watch(
|
||||
() => [props.title, props.description, props.placement, props.maxWidth],
|
||||
() => void installTooltip(),
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
ref="triggerElement"
|
||||
class="rich-tooltip-trigger"
|
||||
:class="{ 'rich-tooltip-trigger--enabled': hasContent }"
|
||||
:tabindex="hasContent ? 0 : undefined"
|
||||
:data-rich-tooltip="props.testId"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
<span ref="contentElement" class="rich-tooltip-template" hidden aria-hidden="true">
|
||||
<slot name="content" :description-lines="descriptionLines">
|
||||
<span v-if="props.title" class="rich-tooltip-content__title">{{ props.title }}</span>
|
||||
<span
|
||||
v-for="(line, index) in descriptionLines"
|
||||
:key="`${index}:${line}`"
|
||||
class="rich-tooltip-content__line"
|
||||
>
|
||||
{{ line }}
|
||||
</span>
|
||||
</slot>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.rich-tooltip-trigger {
|
||||
display: inline;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rich-tooltip-trigger--enabled {
|
||||
cursor: help;
|
||||
text-decoration: underline dotted rgb(150 210 255 / 85%);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.rich-tooltip-trigger--enabled:focus-visible {
|
||||
border-radius: 2px;
|
||||
outline: 1px solid #6fc7ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='sammo-rich'] {
|
||||
border: 1px solid #8c8c8c;
|
||||
border-radius: 3px;
|
||||
background: #101010;
|
||||
box-shadow: 0 3px 12px rgb(0 0 0 / 65%);
|
||||
color: #f5f5f5;
|
||||
font-family: Pretendard, sans-serif;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='sammo-rich'][data-placement^='top'] > .tippy-arrow::before {
|
||||
border-top-color: #101010;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='sammo-rich'][data-placement^='bottom'] > .tippy-arrow::before {
|
||||
border-bottom-color: #101010;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='sammo-rich'][data-placement^='left'] > .tippy-arrow::before {
|
||||
border-left-color: #101010;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='sammo-rich'][data-placement^='right'] > .tippy-arrow::before {
|
||||
border-right-color: #101010;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='sammo-rich'] .tippy-content {
|
||||
padding: 7px 9px;
|
||||
}
|
||||
|
||||
.rich-tooltip-content__title,
|
||||
.rich-tooltip-content__line,
|
||||
.rich-tooltip-content__section,
|
||||
.rich-tooltip-content__meta {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rich-tooltip-content__title {
|
||||
margin-bottom: 4px;
|
||||
color: #7fd4ff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rich-tooltip-content__line + .rich-tooltip-content__line {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.rich-tooltip-content__section {
|
||||
margin-top: 5px;
|
||||
border-top: 1px solid #4d4d4d;
|
||||
padding-top: 4px;
|
||||
color: #ffdc76;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rich-tooltip-content__meta {
|
||||
color: #d5d5d5;
|
||||
}
|
||||
|
||||
.rich-tooltip-content__pros {
|
||||
color: cyan;
|
||||
}
|
||||
|
||||
.rich-tooltip-content__cons {
|
||||
color: magenta;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user