将初始化向导辅助说明改为可聚焦悬浮提示
This commit is contained in:
parent
68c42084f3
commit
43d313612b
@ -4,7 +4,7 @@
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test src/App.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js src/views/UserManagement/UserManagement.test.js",
|
||||
"test": "node --test src/App.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js src/views/UserManagement/UserManagement.test.js src/views/components/HelpTip/HelpTip.test.js",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import HelpTip from "../components/HelpTip/HelpTip.vue";
|
||||
defineProps({
|
||||
form: { type: Object, required: true },
|
||||
testing: Boolean,
|
||||
@ -32,8 +33,12 @@ const emit = defineEmits(["test"]);
|
||||
</div>
|
||||
</div>
|
||||
<div class="setup-section-heading">
|
||||
<strong>数据库存储</strong>
|
||||
<span>选择一种存储方式,验证连接和写权限后继续。不会清空已有数据。</span>
|
||||
<strong>
|
||||
数据库存储
|
||||
<HelpTip>
|
||||
选择一种存储方式,验证连接和写权限后继续。不会清空已有数据。
|
||||
</HelpTip>
|
||||
</strong>
|
||||
</div>
|
||||
<div v-if="form.driver !== 'sqlite'" class="setup-grid two-columns database-connection-grid">
|
||||
<label>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import HelpTip from "../components/HelpTip/HelpTip.vue";
|
||||
import { useSetupWizard } from "./SetupWizard.js";
|
||||
import AccessShell from "../components/AccessShell/AccessShell.vue";
|
||||
import DatabaseSetup from "./DatabaseSetup.vue";
|
||||
@ -142,8 +143,12 @@ const {
|
||||
placeholder="例如 scert-admin"
|
||||
/></label>
|
||||
<label class="email-field"
|
||||
><span>绑定邮箱</span
|
||||
><div class="email-input-group">
|
||||
><span>
|
||||
绑定邮箱
|
||||
<HelpTip>
|
||||
可直接输入完整邮箱,也可先输入邮箱名称再选择常用邮箱。
|
||||
</HelpTip>
|
||||
</span><div class="email-input-group">
|
||||
<input
|
||||
ref="emailAutofillElement"
|
||||
class="email-autofill-probe"
|
||||
@ -188,7 +193,6 @@ const {
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<small>可直接输入完整邮箱,也可先输入邮箱名称再选择常用邮箱。</small>
|
||||
</label>
|
||||
<label
|
||||
><span>密码</span
|
||||
@ -218,8 +222,12 @@ const {
|
||||
/></label>
|
||||
</div>
|
||||
<div class="setup-section-heading">
|
||||
<strong>服务访问配置</strong>
|
||||
<span>先确定 Scert 主服务和管理页面的访问方式,后续步骤会使用这里的配置。</span>
|
||||
<strong>
|
||||
服务访问配置
|
||||
<HelpTip>
|
||||
先确定 Scert 主服务和管理页面的访问方式,后续步骤会使用这里的配置。
|
||||
</HelpTip>
|
||||
</strong>
|
||||
</div>
|
||||
<div class="setup-grid service-access-grid" :class="{ 'has-database': isBootstrap }">
|
||||
<div class="access-target-field">
|
||||
@ -264,7 +272,12 @@ const {
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Scert 主服务端口</span>
|
||||
<span>
|
||||
Scert 主服务端口
|
||||
<HelpTip>
|
||||
管理页面和 API 对外提供服务使用的端口。
|
||||
</HelpTip>
|
||||
</span>
|
||||
<input
|
||||
id="server-port"
|
||||
name="server_port"
|
||||
@ -274,7 +287,6 @@ const {
|
||||
max="65535"
|
||||
type="number"
|
||||
/>
|
||||
<small>管理页面和 API 对外提供服务使用的端口。</small>
|
||||
</label>
|
||||
<DatabaseSetup
|
||||
v-if="isBootstrap"
|
||||
@ -300,8 +312,12 @@ const {
|
||||
|
||||
<div class="notification-layout">
|
||||
<div class="notification-guide">
|
||||
<strong>先完成基础配置</strong>
|
||||
<span>填写邮件服务地址即可继续,其他项目已经提供合理默认值。</span>
|
||||
<strong>
|
||||
先完成基础配置
|
||||
<HelpTip>
|
||||
填写邮件服务地址即可继续,其他项目已经提供合理默认值。
|
||||
</HelpTip>
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<div class="notification-row notification-service-row">
|
||||
@ -312,8 +328,12 @@ const {
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>
|
||||
<strong>启用 HTTP 邮件通知</strong>
|
||||
<small>通过第三方邮件 API 发送任务通知。</small>
|
||||
<strong>
|
||||
启用 HTTP 邮件通知
|
||||
<HelpTip>
|
||||
通过第三方邮件 API 发送任务通知。
|
||||
</HelpTip>
|
||||
</strong>
|
||||
</span>
|
||||
</label>
|
||||
<label class="confirm-row compact-row">
|
||||
@ -323,8 +343,12 @@ const {
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>
|
||||
<strong>启用 SMTP 通知</strong>
|
||||
<small>通过 SMTP 出站客户端发送任务通知。</small>
|
||||
<strong>
|
||||
启用 SMTP 通知
|
||||
<HelpTip>
|
||||
通过 SMTP 出站客户端发送任务通知。
|
||||
</HelpTip>
|
||||
</strong>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
@ -332,8 +356,12 @@ const {
|
||||
<div v-if="form.email_smtp_enabled" class="notification-basic-panel smtp-basic-panel">
|
||||
<div class="notification-section-heading">
|
||||
<div>
|
||||
<strong>SMTP 通知配置</strong>
|
||||
<span>Scert 只作为 SMTP 出站客户端,不提供 SMTP、IMAP 或 POP3 服务端。</span>
|
||||
<strong>
|
||||
SMTP 通知配置
|
||||
<HelpTip>
|
||||
Scert 只作为 SMTP 出站客户端,不提供 SMTP、IMAP 或 POP3 服务端。
|
||||
</HelpTip>
|
||||
</strong>
|
||||
</div>
|
||||
<span class="notification-recommended-badge">推荐 STARTTLS</span>
|
||||
</div>
|
||||
@ -414,15 +442,24 @@ const {
|
||||
<div v-if="form.email_http_enabled" class="notification-basic-panel">
|
||||
<div class="notification-section-heading">
|
||||
<div>
|
||||
<strong>邮件通知基础配置</strong>
|
||||
<span>填写服务地址、发件人和收件人即可,技术参数无需修改。</span>
|
||||
<strong>
|
||||
邮件通知基础配置
|
||||
<HelpTip>
|
||||
填写服务地址、发件人和收件人即可,技术参数无需修改。
|
||||
</HelpTip>
|
||||
</strong>
|
||||
</div>
|
||||
<span class="notification-recommended-badge">推荐配置</span>
|
||||
</div>
|
||||
|
||||
<div class="notification-row notification-basic-row">
|
||||
<label>
|
||||
<span>邮件服务地址</span>
|
||||
<span>
|
||||
邮件服务地址
|
||||
<HelpTip>
|
||||
填写邮件服务提供的地址,不需要自行拼接接口路径。
|
||||
</HelpTip>
|
||||
</span>
|
||||
<input
|
||||
id="email-endpoint"
|
||||
name="email_endpoint"
|
||||
@ -430,17 +467,20 @@ const {
|
||||
type="url"
|
||||
placeholder="https://mail.example.com"
|
||||
/>
|
||||
<small>填写邮件服务提供的地址,不需要自行拼接接口路径。</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>邮件服务名称(可选)</span>
|
||||
<span>
|
||||
邮件服务名称(可选)
|
||||
<HelpTip>
|
||||
仅用于日志和通知内容中的识别。
|
||||
</HelpTip>
|
||||
</span>
|
||||
<input
|
||||
id="email-provider"
|
||||
name="email_provider"
|
||||
v-model.trim="form.email_provider"
|
||||
placeholder="例如:Smail"
|
||||
/>
|
||||
<small>仅用于日志和通知内容中的识别。</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@ -463,14 +503,18 @@ const {
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>通知收件人</span>
|
||||
<span>
|
||||
通知收件人
|
||||
<HelpTip>
|
||||
续期、部署和错误通知将发送到这些地址。
|
||||
</HelpTip>
|
||||
</span>
|
||||
<input
|
||||
id="recipients"
|
||||
name="recipients"
|
||||
v-model="form.recipients"
|
||||
placeholder="多个地址使用逗号分隔"
|
||||
/>
|
||||
<small>续期、部署和错误通知将发送到这些地址。</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@ -689,22 +733,29 @@ const {
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
><span>DNS 权限测试域名</span
|
||||
><input
|
||||
><span>
|
||||
DNS 权限测试域名
|
||||
<HelpTip>
|
||||
使用域名时可填写管理页面域名;使用公网 IP
|
||||
时必须另填一个由当前 DNS 账号管理的域名。
|
||||
</HelpTip>
|
||||
</span><input
|
||||
id="dns-test-domain"
|
||||
name="dns_test_domain"
|
||||
v-model.trim="dnsTestDomain"
|
||||
required
|
||||
placeholder="例如 example.com"
|
||||
/><small
|
||||
>使用域名时可填写管理页面域名;使用公网 IP
|
||||
时必须另填一个由当前 DNS 账号管理的域名。</small
|
||||
></label
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
<div class="setup-grid two-columns propagation-grid">
|
||||
<label>
|
||||
<span>DNS 传播最大等待时间(秒)</span>
|
||||
<span>
|
||||
DNS 传播最大等待时间(秒)
|
||||
<HelpTip>
|
||||
默认 300 秒,最长允许等待 86400 秒。
|
||||
</HelpTip>
|
||||
</span>
|
||||
<input
|
||||
id="propagation-timeout-secs"
|
||||
name="propagation_timeout_secs"
|
||||
@ -714,10 +765,14 @@ const {
|
||||
max="86400"
|
||||
required
|
||||
/>
|
||||
<small>默认 300 秒,最长允许等待 86400 秒。</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>DNS 传播检测间隔(秒)</span>
|
||||
<span>
|
||||
DNS 传播检测间隔(秒)
|
||||
<HelpTip>
|
||||
默认 5 秒,不能大于最大等待时间。
|
||||
</HelpTip>
|
||||
</span>
|
||||
<input
|
||||
id="propagation-polling-interval-secs"
|
||||
name="propagation_polling_interval_secs"
|
||||
@ -727,7 +782,6 @@ const {
|
||||
:max="form.propagation_timeout_secs"
|
||||
required
|
||||
/>
|
||||
<small>默认 5 秒,不能大于最大等待时间。</small>
|
||||
</label>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
@ -935,8 +989,12 @@ const {
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>
|
||||
<strong>初始化完成后开启管理页面</strong>
|
||||
<small>桌面端可通过此开关决定是否启用浏览器管理页面。</small>
|
||||
<strong>
|
||||
初始化完成后开启管理页面
|
||||
<HelpTip>
|
||||
桌面端可通过此开关决定是否启用浏览器管理页面。
|
||||
</HelpTip>
|
||||
</strong>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
@ -68,6 +68,14 @@
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.access-shell--setup label:not(.confirm-row) > span,
|
||||
.access-shell--setup .access-target-field > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 20px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.access-shell--setup input:not([type="checkbox"]),
|
||||
.access-shell--setup select {
|
||||
height: 36px;
|
||||
@ -149,7 +157,7 @@
|
||||
width: 92px;
|
||||
height: auto;
|
||||
min-height: 36px;
|
||||
margin-top: calc(8px + 1.35 * 12px);
|
||||
margin-top: 28px;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 6px 10px;
|
||||
|
||||
57
frontend/src/views/components/HelpTip/HelpTip.css
Normal file
57
frontend/src/views/components/HelpTip/HelpTip.css
Normal file
@ -0,0 +1,57 @@
|
||||
.help-tip-trigger {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
margin-left: 6px;
|
||||
border: 1px solid #7d91ae;
|
||||
border-radius: 50%;
|
||||
color: #526b8e;
|
||||
background: #f4f7fc;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
cursor: help;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.help-tip-trigger:hover,
|
||||
.help-tip-trigger:focus-visible {
|
||||
border-color: #315faa;
|
||||
color: #234f93;
|
||||
background: #eaf1fc;
|
||||
}
|
||||
|
||||
.help-tip-trigger:focus-visible {
|
||||
outline: 2px solid #315faa;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.help-tip-content {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
max-width: min(340px, calc(100vw - 24px));
|
||||
max-height: calc(100dvh - 24px);
|
||||
overflow: auto;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #334763;
|
||||
border-radius: 8px;
|
||||
color: #ffffff;
|
||||
background: #25364f;
|
||||
box-shadow: 0 6px 22px rgb(23 32 51 / 18%);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
letter-spacing: normal;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
77
frontend/src/views/components/HelpTip/HelpTip.js
Normal file
77
frontend/src/views/components/HelpTip/HelpTip.js
Normal file
@ -0,0 +1,77 @@
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, useId } from "vue";
|
||||
import { placeTooltip } from "./HelpTipPosition.js";
|
||||
|
||||
export function useHelpTip() {
|
||||
const trigger = ref(null);
|
||||
const tooltip = ref(null);
|
||||
const visible = ref(false);
|
||||
const position = ref({ left: "0px", top: "0px" });
|
||||
const tooltipId = `help-tip-${useId()}`;
|
||||
let timer;
|
||||
|
||||
function cancelHide() {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
function hide() {
|
||||
cancelHide();
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function show() {
|
||||
cancelHide();
|
||||
visible.value = true;
|
||||
await nextTick();
|
||||
if (!visible.value || !trigger.value || !tooltip.value) return;
|
||||
const point = placeTooltip(
|
||||
trigger.value.getBoundingClientRect(),
|
||||
tooltip.value.getBoundingClientRect(),
|
||||
{ width: window.innerWidth, height: window.innerHeight },
|
||||
);
|
||||
position.value = { left: `${point.left}px`, top: `${point.top}px` };
|
||||
}
|
||||
|
||||
function scheduleHide() {
|
||||
cancelHide();
|
||||
timer = setTimeout(() => {
|
||||
if (document.activeElement !== trigger.value) hide();
|
||||
}, 140);
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (visible.value) hide();
|
||||
else show();
|
||||
}
|
||||
|
||||
function dismissOnEscape(event) {
|
||||
if (event.key === "Escape") hide();
|
||||
}
|
||||
|
||||
function dismissOutside(event) {
|
||||
if (!trigger.value?.contains(event.target) && !tooltip.value?.contains(event.target)) hide();
|
||||
}
|
||||
|
||||
function dismissOnScroll(event) {
|
||||
if (!tooltip.value?.contains(event.target)) hide();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("keydown", dismissOnEscape);
|
||||
document.addEventListener("pointerdown", dismissOutside);
|
||||
window.addEventListener("resize", hide);
|
||||
window.addEventListener("scroll", dismissOnScroll, true);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
hide();
|
||||
document.removeEventListener("keydown", dismissOnEscape);
|
||||
document.removeEventListener("pointerdown", dismissOutside);
|
||||
window.removeEventListener("resize", hide);
|
||||
window.removeEventListener("scroll", dismissOnScroll, true);
|
||||
});
|
||||
|
||||
return {
|
||||
trigger, tooltip, tooltipId, visible, position,
|
||||
show, hide, scheduleHide, cancelHide, toggle,
|
||||
};
|
||||
}
|
||||
108
frontend/src/views/components/HelpTip/HelpTip.test.js
Normal file
108
frontend/src/views/components/HelpTip/HelpTip.test.js
Normal file
@ -0,0 +1,108 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { placeTooltip } from "./HelpTipPosition.js";
|
||||
import { useHelpTip } from "./HelpTip.js";
|
||||
import { mountComposable } from "../../../test-support/mount.js";
|
||||
|
||||
const viewport = { width: 1366, height: 768 };
|
||||
const popup = { width: 340, height: 70 };
|
||||
|
||||
test("tooltip stays below the trigger when space is available", () => {
|
||||
assert.deepEqual(placeTooltip({ left: 100, top: 100, bottom: 117 }, popup, viewport), {
|
||||
left: 100, top: 125,
|
||||
});
|
||||
});
|
||||
|
||||
test("tooltip flips above the trigger near the bottom edge", () => {
|
||||
assert.deepEqual(placeTooltip({ left: 100, top: 720, bottom: 737 }, popup, viewport), {
|
||||
left: 100, top: 642,
|
||||
});
|
||||
});
|
||||
|
||||
test("tooltip clamps at both horizontal edges", () => {
|
||||
assert.equal(placeTooltip({ left: 1320, top: 100, bottom: 117 }, popup, viewport).left, 1014);
|
||||
assert.equal(placeTooltip({ left: -10, top: 100, bottom: 117 }, popup, viewport).left, 12);
|
||||
});
|
||||
|
||||
test("tooltip handles a viewport smaller than its measured content", () => {
|
||||
assert.deepEqual(placeTooltip({ left: 0, top: 0, bottom: 17 }, popup, { width: 100, height: 50 }), {
|
||||
left: 12, top: 12,
|
||||
});
|
||||
});
|
||||
|
||||
async function setup(t) {
|
||||
const events = new Map();
|
||||
function addEventListener(name, callback) {
|
||||
events.set(name, callback);
|
||||
}
|
||||
function removeEventListener(name, callback) {
|
||||
assert.equal(events.get(name), callback);
|
||||
events.delete(name);
|
||||
}
|
||||
const mounted = await mountComposable(t, () => {
|
||||
Object.assign(window, { ...viewport, innerWidth: viewport.width, innerHeight: viewport.height, addEventListener, removeEventListener });
|
||||
Object.assign(document, { activeElement: null, addEventListener, removeEventListener });
|
||||
return useHelpTip();
|
||||
});
|
||||
mounted.state.trigger.value = {
|
||||
getBoundingClientRect: () => ({ left: 100, top: 100, bottom: 117 }),
|
||||
contains: () => false,
|
||||
};
|
||||
mounted.state.tooltip.value = {
|
||||
getBoundingClientRect: () => popup,
|
||||
contains: () => false,
|
||||
};
|
||||
return { ...mounted, events };
|
||||
}
|
||||
|
||||
test("tooltip opens, positions and closes on Escape", async (t) => {
|
||||
const { state, events } = await setup(t);
|
||||
assert.equal(state.visible.value, false);
|
||||
await state.show();
|
||||
assert.equal(state.visible.value, true);
|
||||
assert.deepEqual(state.position.value, { left: "100px", top: "125px" });
|
||||
events.get("keydown")({ key: "Escape" });
|
||||
assert.equal(state.visible.value, false);
|
||||
});
|
||||
|
||||
test("tooltip can be reached by pointer without closing", async (t) => {
|
||||
const { state } = await setup(t);
|
||||
await state.show();
|
||||
state.scheduleHide();
|
||||
state.cancelHide();
|
||||
await new Promise(resolve => setTimeout(resolve, 180));
|
||||
assert.equal(state.visible.value, true);
|
||||
state.scheduleHide();
|
||||
await new Promise(resolve => setTimeout(resolve, 180));
|
||||
assert.equal(state.visible.value, false);
|
||||
});
|
||||
|
||||
test("focused tooltip remains open on pointer leave", async (t) => {
|
||||
const { state } = await setup(t);
|
||||
document.activeElement = state.trigger.value;
|
||||
await state.show();
|
||||
state.scheduleHide();
|
||||
await new Promise(resolve => setTimeout(resolve, 180));
|
||||
assert.equal(state.visible.value, true);
|
||||
state.hide();
|
||||
assert.equal(state.visible.value, false);
|
||||
});
|
||||
|
||||
test("outside click and viewport changes dismiss the tooltip", async (t) => {
|
||||
const { state, events } = await setup(t);
|
||||
for (const event of ["pointerdown", "resize", "scroll"]) {
|
||||
await state.show();
|
||||
events.get(event)({ target: {} });
|
||||
assert.equal(state.visible.value, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("unmount cancels pending work and removes all event listeners", async (t) => {
|
||||
const { state, events, unmount } = await setup(t);
|
||||
const pending = state.show();
|
||||
state.scheduleHide();
|
||||
unmount();
|
||||
await pending;
|
||||
assert.equal(state.visible.value, false);
|
||||
assert.equal(events.size, 0);
|
||||
});
|
||||
53
frontend/src/views/components/HelpTip/HelpTip.vue
Normal file
53
frontend/src/views/components/HelpTip/HelpTip.vue
Normal file
@ -0,0 +1,53 @@
|
||||
<script setup>
|
||||
import { useHelpTip } from "./HelpTip.js";
|
||||
|
||||
const {
|
||||
trigger,
|
||||
tooltip,
|
||||
tooltipId,
|
||||
visible,
|
||||
position,
|
||||
show,
|
||||
hide,
|
||||
scheduleHide,
|
||||
cancelHide,
|
||||
toggle,
|
||||
} = useHelpTip();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
ref="trigger"
|
||||
class="help-tip-trigger"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-labelledby="tooltipId"
|
||||
:aria-expanded="visible"
|
||||
@pointerenter="show"
|
||||
@pointerleave="scheduleHide"
|
||||
@focus="show"
|
||||
@blur="hide"
|
||||
@click.stop.prevent="toggle"
|
||||
@keydown.enter.stop.prevent="toggle"
|
||||
@keydown.space.stop.prevent="toggle"
|
||||
@keydown.esc.stop.prevent="hide"
|
||||
>
|
||||
<span aria-hidden="true">?</span>
|
||||
</span>
|
||||
<Teleport to="body">
|
||||
<span
|
||||
v-show="visible"
|
||||
:id="tooltipId"
|
||||
ref="tooltip"
|
||||
class="help-tip-content"
|
||||
role="tooltip"
|
||||
:style="position"
|
||||
@pointerenter="cancelHide"
|
||||
@pointerleave="scheduleHide"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped src="./HelpTip.css"></style>
|
||||
14
frontend/src/views/components/HelpTip/HelpTipPosition.js
Normal file
14
frontend/src/views/components/HelpTip/HelpTipPosition.js
Normal file
@ -0,0 +1,14 @@
|
||||
export function placeTooltip(anchor, popup, viewport) {
|
||||
const padding = 12;
|
||||
const gap = 8;
|
||||
const maxLeft = Math.max(padding, viewport.width - popup.width - padding);
|
||||
const left = Math.max(padding, Math.min(anchor.left, maxLeft));
|
||||
const below = anchor.bottom + gap;
|
||||
const above = anchor.top - popup.height - gap;
|
||||
const preferred = below + popup.height <= viewport.height - padding ? below : above;
|
||||
const maxTop = Math.max(padding, viewport.height - popup.height - padding);
|
||||
return {
|
||||
left,
|
||||
top: Math.max(padding, Math.min(preferred, maxTop)),
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user