@celestia-island/hikari 0.55.116 → 0.55.118
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/HkAuthMethodList.test.tsx +7 -2
- package/src/components/HkCopyBadge.test.tsx +53 -5
- package/src/components/HkCopyBadge.tsx +15 -6
- package/src/components/HkSectionCard.scss +22 -0
- package/src/components/HkSectionCard.styles.test.ts +42 -0
- package/src/components/HkSectionCard.test.tsx +156 -0
- package/src/components/HkSectionCard.tsx +122 -0
- package/src/components/HkToast.test.tsx +28 -0
- package/src/components/HkToast.tsx +12 -1
- package/src/components/HkTooltip.test.tsx +75 -13
- package/src/components/HkTooltip.tsx +39 -17
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -60,13 +60,18 @@ describe("HkAuthMethodList", () => {
|
|
|
60
60
|
expect(c.querySelector(".s-auth-methods [role='radiogroup']")).toBeNull();
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
it("wraps every tile in a tooltip carrying the provider label", () => {
|
|
63
|
+
it("wraps every tile in a tooltip carrying the provider label", async () => {
|
|
64
64
|
const c = mount(
|
|
65
65
|
h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })),
|
|
66
66
|
);
|
|
67
67
|
const wrappers = c.querySelectorAll(".hk-tooltip-wrapper");
|
|
68
68
|
expect(wrappers.length).toBe(2);
|
|
69
|
-
//
|
|
69
|
+
// Popups mount lazily on first engage — a touch tap mounts each one
|
|
70
|
+
// immediately — and then carry the label text.
|
|
71
|
+
for (const w of wrappers) {
|
|
72
|
+
w.dispatchEvent(new PointerEvent("pointerdown", { pointerType: "touch", bubbles: true }));
|
|
73
|
+
}
|
|
74
|
+
await Promise.resolve();
|
|
70
75
|
const popups = [...document.querySelectorAll(".hk-tooltip-popup .hk-tooltip-content")]
|
|
71
76
|
.map((el) => el.textContent);
|
|
72
77
|
expect(popups).toContain("GitHub");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { createApp, h, defineComponent } from "vue";
|
|
2
|
+
import { createApp, h, defineComponent, nextTick } from "vue";
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -8,6 +8,7 @@ import HkCopyBadge from "./HkCopyBadge";
|
|
|
8
8
|
import { useToast } from "../runtime/useToast";
|
|
9
9
|
|
|
10
10
|
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
11
|
+
const appUnmounts: Array<{ app: ReturnType<typeof createApp> }> = [];
|
|
11
12
|
|
|
12
13
|
function mount(renderNode: () => ReturnType<typeof h>) {
|
|
13
14
|
const container = document.createElement("div");
|
|
@@ -15,6 +16,7 @@ function mount(renderNode: () => ReturnType<typeof h>) {
|
|
|
15
16
|
const app = createApp({ render: renderNode });
|
|
16
17
|
app.mount(container);
|
|
17
18
|
mounts.push({ app, container });
|
|
19
|
+
appUnmounts.push({ app });
|
|
18
20
|
return container;
|
|
19
21
|
}
|
|
20
22
|
|
|
@@ -126,18 +128,64 @@ describe("HkCopyBadge", () => {
|
|
|
126
128
|
expect(writeText).not.toHaveBeenCalled();
|
|
127
129
|
});
|
|
128
130
|
|
|
129
|
-
|
|
131
|
+
/** Engage the lazy tooltip without hovering: a touch tap mounts the
|
|
132
|
+
* popup immediately (showNow), no fake timers needed. */
|
|
133
|
+
function engageTooltip(c: HTMLElement) {
|
|
134
|
+
c.querySelector(".hk-tooltip-wrapper")!.dispatchEvent(
|
|
135
|
+
new PointerEvent("pointerdown", { pointerType: "touch", bubbles: true }),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
it("renders the custom tooltip text", async () => {
|
|
130
140
|
const c = mount(() => h(HkCopyBadge, { tooltip: "Open details" }, () => "#d.132"));
|
|
131
|
-
|
|
141
|
+
// The popup mounts lazily on first engage (dense-list contract).
|
|
142
|
+
expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
|
|
143
|
+
engageTooltip(c);
|
|
144
|
+
await nextTick();
|
|
145
|
+
const content = document.body.querySelector(".hk-tooltip-popup .hk-tooltip-content");
|
|
132
146
|
expect(content?.textContent).toBe("Open details");
|
|
133
147
|
});
|
|
134
148
|
|
|
135
|
-
it("shows the localized click-to-copy tooltip by default", () => {
|
|
149
|
+
it("shows the localized click-to-copy tooltip by default", async () => {
|
|
136
150
|
const c = mount(() => h(HkCopyBadge, null, () => "#d.132"));
|
|
137
|
-
|
|
151
|
+
engageTooltip(c);
|
|
152
|
+
await nextTick();
|
|
153
|
+
const content = document.body.querySelector(".hk-tooltip-popup .hk-tooltip-content");
|
|
138
154
|
expect(content?.textContent).toBe("Click to copy");
|
|
139
155
|
});
|
|
140
156
|
|
|
157
|
+
it("links the badge to the popup via aria-describedby across the whole lifecycle", async () => {
|
|
158
|
+
const c = mount(() => h(HkCopyBadge, { tooltip: "Open details" }, () => "#d.132"));
|
|
159
|
+
const badge = c.querySelector(".hk-badge")!;
|
|
160
|
+
|
|
161
|
+
// Dormant: no popup, no describedby.
|
|
162
|
+
expect(badge.getAttribute("aria-describedby")).toBeNull();
|
|
163
|
+
expect(document.body.querySelector(".hk-tooltip-popup")).toBeNull();
|
|
164
|
+
|
|
165
|
+
// Engage: describedby == the popup id.
|
|
166
|
+
engageTooltip(c);
|
|
167
|
+
await nextTick();
|
|
168
|
+
const popup = document.body.querySelector(".hk-tooltip-popup")!;
|
|
169
|
+
expect(badge.getAttribute("aria-describedby")).toBe(popup.getAttribute("id"));
|
|
170
|
+
expect(popup.querySelector(".hk-tooltip-content")!.textContent).toBe("Open details");
|
|
171
|
+
|
|
172
|
+
// Leave: the attribute flips BACK (slot re-invocation must not stick).
|
|
173
|
+
c.querySelector(".hk-tooltip-wrapper")!.dispatchEvent(new MouseEvent("mouseleave"));
|
|
174
|
+
await nextTick();
|
|
175
|
+
expect(badge.getAttribute("aria-describedby")).toBeNull();
|
|
176
|
+
|
|
177
|
+
// Re-engage: returns, reusing the same popup element.
|
|
178
|
+
engageTooltip(c);
|
|
179
|
+
await nextTick();
|
|
180
|
+
expect(badge.getAttribute("aria-describedby")).toBe(
|
|
181
|
+
document.body.querySelector(".hk-tooltip-popup")!.getAttribute("id"),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// Unmount while visible: the teleported popup goes away.
|
|
185
|
+
appUnmounts.splice(0).forEach(({ app }) => app.unmount());
|
|
186
|
+
expect(document.body.querySelector(".hk-tooltip-popup")).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
|
|
141
189
|
it("renders the inert badge face when disabled", () => {
|
|
142
190
|
const c = mount(() => h(HkCopyBadge, { disabled: true }, () => "#d.132"));
|
|
143
191
|
const badge = c.querySelector(".hk-badge")!;
|
|
@@ -27,9 +27,12 @@ import "./HkCopyBadge.scss";
|
|
|
27
27
|
*
|
|
28
28
|
* A11y: the tag is a real button (role, tab order, Enter/Space), and the
|
|
29
29
|
* click is stopped from reaching clickable ancestors — a tag inside a
|
|
30
|
-
* selectable row copies without selecting the row.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* selectable row copies without selecting the row. The tooltip popup is
|
|
31
|
+
* linked to the badge via aria-describedby (HkTooltip hands the popup id
|
|
32
|
+
* down as a slot prop, so the describedby lands on the actual focus
|
|
33
|
+
* target). Consumer attrs (class/style/data-*) land on the badge element
|
|
34
|
+
* itself, so a call site skinning the pill keeps working; the tooltip
|
|
35
|
+
* wrapper stays clean.
|
|
33
36
|
*/
|
|
34
37
|
export default defineComponent({
|
|
35
38
|
name: "HkCopyBadge",
|
|
@@ -95,7 +98,7 @@ export default defineComponent({
|
|
|
95
98
|
// itself; only the interactive contract below is ours to add.
|
|
96
99
|
const { class: attrClass, style: attrStyle, ...restAttrs } = attrs;
|
|
97
100
|
|
|
98
|
-
const
|
|
101
|
+
const buildBadge = (describedBy?: string) => (
|
|
99
102
|
<HBadge
|
|
100
103
|
ref={badgeRef}
|
|
101
104
|
{...restAttrs}
|
|
@@ -110,6 +113,7 @@ export default defineComponent({
|
|
|
110
113
|
color={props.color}
|
|
111
114
|
bgColor={props.bgColor}
|
|
112
115
|
borderColor={props.borderColor}
|
|
116
|
+
aria-describedby={describedBy}
|
|
113
117
|
{...(props.disabled
|
|
114
118
|
? {}
|
|
115
119
|
: {
|
|
@@ -133,8 +137,13 @@ export default defineComponent({
|
|
|
133
137
|
</HBadge>
|
|
134
138
|
);
|
|
135
139
|
|
|
136
|
-
if (props.disabled) return
|
|
137
|
-
return
|
|
140
|
+
if (props.disabled) return buildBadge();
|
|
141
|
+
return (
|
|
142
|
+
<HTooltip text={tooltipText.value}>
|
|
143
|
+
{(tip: { popupId: string; visible: boolean }) =>
|
|
144
|
+
buildBadge(tip?.visible ? tip.popupId : undefined)}
|
|
145
|
+
</HTooltip>
|
|
146
|
+
);
|
|
138
147
|
};
|
|
139
148
|
},
|
|
140
149
|
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/* HkSectionCard — the standard titled section: heading + hint above one
|
|
2
|
+
* card. The ONLY spacing definition for that anatomy; consumers must not
|
|
3
|
+
* add their own margins/wrappers around it (2026-10-05 unification: two
|
|
4
|
+
* tabs of one page had drifted apart in hint position and body padding). */
|
|
5
|
+
.hk-section-card {
|
|
6
|
+
display: flex;
|
|
7
|
+
flex-direction: column;
|
|
8
|
+
/* Heading block → body. One token, both axes of the rhythm (the header
|
|
9
|
+
carries its own 4px title→hint gap). */
|
|
10
|
+
gap: var(--space-12, 0.75rem);
|
|
11
|
+
min-width: 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* Trailing notes under the card (the section's own footnote slot). No font
|
|
15
|
+
override: a footer may carry prose or a control — it only owns the
|
|
16
|
+
rhythm, so consumers stop inventing their own top margin. */
|
|
17
|
+
.hk-section-card-footer {
|
|
18
|
+
display: flex;
|
|
19
|
+
flex-direction: column;
|
|
20
|
+
gap: var(--space-4, 0.25rem);
|
|
21
|
+
min-width: 0;
|
|
22
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Style contract (house pattern: HkAboutModal.styles.test.ts) — the
|
|
3
|
+
* section card's class↔rule wiring. `sheetCompile.test.ts` proves every
|
|
4
|
+
* sheet COMPILES; nothing else proves the classes the component emits are
|
|
5
|
+
* the ones the sheet styles, so a rename on either side would silently
|
|
6
|
+
* orphan the rule (the #493 failure mode).
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const tsx = readFileSync(join(here, "HkSectionCard.tsx"), "utf-8");
|
|
15
|
+
const scss = readFileSync(join(here, "HkSectionCard.scss"), "utf-8");
|
|
16
|
+
|
|
17
|
+
/** Every `hk-section-card*` class the component emits. */
|
|
18
|
+
function emittedClasses(): string[] {
|
|
19
|
+
const found = new Set<string>();
|
|
20
|
+
for (const m of tsx.matchAll(/"(hk-section-card[a-z-]*)"/g)) found.add(m[1]!);
|
|
21
|
+
for (const m of tsx.matchAll(/\bhk-section-card[a-z-]*\b/g)) found.add(m[0]);
|
|
22
|
+
return [...found].sort();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("HkSectionCard styles", () => {
|
|
26
|
+
it("emits at least the wrapper and the footer (the guard is not vacuous)", () => {
|
|
27
|
+
const classes = emittedClasses();
|
|
28
|
+
expect(classes).toContain("hk-section-card");
|
|
29
|
+
expect(classes).toContain("hk-section-card-footer");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("styles every class the component emits", () => {
|
|
33
|
+
for (const cls of emittedClasses()) {
|
|
34
|
+
expect(scss, `${cls} must have a rule`).toMatch(new RegExp(`\\.${cls}\\s*\\{`));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("keeps the intra-section rhythm token on the wrapper and the footer", () => {
|
|
39
|
+
expect(scss).toMatch(/\.hk-section-card\s*\{[^}]*gap:\s*var\(--space-12/);
|
|
40
|
+
expect(scss).toMatch(/\.hk-section-card-footer\s*\{[^}]*gap:\s*var\(--space-4/);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import { HkSectionCard } from "./HkSectionCard";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HkSectionCard contract — the section-level chrome standard:
|
|
8
|
+
* - the heading block carries the title and the hint (its ONLY place: the
|
|
9
|
+
* drift this component exists to end was one section printing its hint
|
|
10
|
+
* above the table and another below it)
|
|
11
|
+
* - the body is ONE card: flush for tables (padded default false), padded
|
|
12
|
+
* when the body is not a table
|
|
13
|
+
* - `error` replaces the body with an alert and `loading` with a spinner,
|
|
14
|
+
* both inside a PADDED card — a state is content, not a table
|
|
15
|
+
* - the optional `actions` slot reaches the header (the heading row's
|
|
16
|
+
* trailing affordances), not the card
|
|
17
|
+
*
|
|
18
|
+
* (Repo test convention: raw createApp + document queries, no
|
|
19
|
+
* @vue/test-utils.)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
while (mounts.length) {
|
|
26
|
+
const { app, container } = mounts.pop()!;
|
|
27
|
+
app.unmount();
|
|
28
|
+
container.remove();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function mountCard(props: Record<string, unknown>, slots: Record<string, () => unknown> = {}) {
|
|
33
|
+
const container = document.createElement("div");
|
|
34
|
+
document.body.appendChild(container);
|
|
35
|
+
const app = createApp({
|
|
36
|
+
render: () => h(HkSectionCard, props as never, slots as never),
|
|
37
|
+
});
|
|
38
|
+
mounts.push({ app, container });
|
|
39
|
+
app.mount(container);
|
|
40
|
+
return container;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("HkSectionCard", () => {
|
|
44
|
+
it("renders the title and the hint in the heading block, above the card", () => {
|
|
45
|
+
const c = mountCard({ title: "Public domains", hint: "Where this instance is reachable." }, {
|
|
46
|
+
default: () => h("table", { class: "body-table" }, h("tbody")),
|
|
47
|
+
});
|
|
48
|
+
const header = c.querySelector(".hk-section-header")!;
|
|
49
|
+
expect(header.textContent).toContain("Public domains");
|
|
50
|
+
expect(header.textContent).toContain("Where this instance is reachable.");
|
|
51
|
+
// The hint belongs to the HEADER, never to the card body.
|
|
52
|
+
const card = c.querySelector(".hk-card")!;
|
|
53
|
+
expect(card.textContent).not.toContain("Where this instance is reachable.");
|
|
54
|
+
expect(card.querySelectorAll(".body-table")).toHaveLength(1);
|
|
55
|
+
// Header precedes the card in document order (the one rhythm).
|
|
56
|
+
expect(header.compareDocumentPosition(card) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("keeps a table body flush in the card and pads a non-table body on request", () => {
|
|
60
|
+
const flush = mountCard({ title: "T" }, { default: () => h("span", "row") });
|
|
61
|
+
// HkCard spells "no padding" as a body modifier — the body carries the
|
|
62
|
+
// padding decision, not the card shell.
|
|
63
|
+
expect(flush.querySelector(".hk-card-body")!.className).toContain("hk-card-body-unpadded");
|
|
64
|
+
const padded = mountCard({ title: "T", padded: true }, { default: () => h("span", "row") });
|
|
65
|
+
// HkCard carries the padding itself — no caller wrapper.
|
|
66
|
+
expect(padded.querySelector(".hk-card-body")!.className).not.toContain("hk-card-body-unpadded");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("replaces the body with an alert on error, inside a padded card", () => {
|
|
70
|
+
const c = mountCard({ title: "T", error: "boom" }, { default: () => h("span", "row") });
|
|
71
|
+
expect(c.querySelector(".hk-alert")).not.toBeNull();
|
|
72
|
+
expect(c.textContent).toContain("boom");
|
|
73
|
+
expect(c.querySelector(".hk-card-body")!.className).not.toContain("hk-card-body-unpadded");
|
|
74
|
+
expect(c.textContent).not.toContain("row");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("replaces the body with a centred spinner while loading", () => {
|
|
78
|
+
const c = mountCard({ title: "T", loading: true }, { default: () => h("span", "row") });
|
|
79
|
+
expect(c.querySelector(".hk-spinner")).not.toBeNull();
|
|
80
|
+
expect(c.querySelector(".hk-card-body")!.className).not.toContain("hk-card-body-unpadded");
|
|
81
|
+
expect(c.textContent).not.toContain("row");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("lets error win when both error and loading are set", () => {
|
|
85
|
+
const c = mountCard({ title: "T", error: "boom", loading: true }, {
|
|
86
|
+
default: () => h("span", "row"),
|
|
87
|
+
});
|
|
88
|
+
expect(c.querySelector(".hk-alert")).not.toBeNull();
|
|
89
|
+
expect(c.querySelector(".hk-spinner")).toBeNull();
|
|
90
|
+
expect(c.textContent).toContain("boom");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("forwards the description slot into the hint position", () => {
|
|
94
|
+
const c = mountCard({ title: "T" }, { description: () => h("span", "own hint") });
|
|
95
|
+
const header = c.querySelector(".hk-section-header")!;
|
|
96
|
+
expect(header.querySelector(".hk-section-header-description")!.textContent).toContain("own hint");
|
|
97
|
+
expect(c.querySelector(".hk-card")!.textContent).not.toContain("own hint");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("forwards icon, level and dense to the heading", () => {
|
|
101
|
+
const Icon = () => h("svg", { class: "own-icon" });
|
|
102
|
+
const c = mountCard({ title: "T", icon: Icon, level: "h2", dense: true });
|
|
103
|
+
const header = c.querySelector(".hk-section-header")!;
|
|
104
|
+
expect(header.classList.contains("hk-section-header-dense")).toBe(true);
|
|
105
|
+
expect(header.querySelector("h2")).not.toBeNull();
|
|
106
|
+
expect(header.querySelector(".own-icon")).not.toBeNull();
|
|
107
|
+
const plain = mountCard({ title: "T" });
|
|
108
|
+
expect(plain.querySelector(".hk-section-header h3")).not.toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("forwards the actions slot into the heading row", () => {
|
|
112
|
+
const c = mountCard({ title: "T" }, { actions: () => h("button", { class: "hdr-act" }, "x") });
|
|
113
|
+
expect(c.querySelector(".hk-section-header")!.querySelector(".hdr-act")).not.toBeNull();
|
|
114
|
+
expect(c.querySelector(".hk-card")!.querySelector(".hdr-act")).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("renders the footer under the card — and nothing when no footer is given", () => {
|
|
118
|
+
const c = mountCard({ title: "T" }, {
|
|
119
|
+
default: () => h("span", "row"),
|
|
120
|
+
footer: () => h("p", { class: "note" }, "keep this"),
|
|
121
|
+
});
|
|
122
|
+
const footer = c.querySelector(".hk-section-card-footer")!;
|
|
123
|
+
expect(footer.querySelector(".note")!.textContent).toBe("keep this");
|
|
124
|
+
// Under the CARD, not inside it, and after it in document order.
|
|
125
|
+
const card = c.querySelector(".hk-card")!;
|
|
126
|
+
expect(card.querySelector(".note")).toBeNull();
|
|
127
|
+
expect(card.compareDocumentPosition(footer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
128
|
+
|
|
129
|
+
const bare = mountCard({ title: "T" });
|
|
130
|
+
expect(bare.querySelector(".hk-section-card-footer")).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("renders no empty header wrapper when only one of the two slots is given", () => {
|
|
134
|
+
// R3 finding: forwarding an absent slot as a function returning
|
|
135
|
+
// undefined left an empty `.hk-section-header-actions` /
|
|
136
|
+
// `.hk-section-header-description` node behind — and in the column-flex
|
|
137
|
+
// heading a zero-height item still eats a gap slot.
|
|
138
|
+
const actionsOnly = mountCard({ title: "T" }, { actions: () => h("button", "x") });
|
|
139
|
+
expect(actionsOnly.querySelector(".hk-section-header-actions")).not.toBeNull();
|
|
140
|
+
expect(actionsOnly.querySelector(".hk-section-header-description")).toBeNull();
|
|
141
|
+
|
|
142
|
+
const descriptionOnly = mountCard({ title: "T" }, { description: () => h("span", "hint") });
|
|
143
|
+
expect(descriptionOnly.querySelector(".hk-section-header-description")).not.toBeNull();
|
|
144
|
+
expect(descriptionOnly.querySelector(".hk-section-header-actions")).toBeNull();
|
|
145
|
+
|
|
146
|
+
const neither = mountCard({ title: "T" });
|
|
147
|
+
expect(neither.querySelector(".hk-section-header-actions")).toBeNull();
|
|
148
|
+
expect(neither.querySelector(".hk-section-header-description")).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("carries the count into the title and renders no hint paragraph when none is given", () => {
|
|
152
|
+
const c = mountCard({ title: "T", count: "3" });
|
|
153
|
+
expect(c.querySelector(".hk-section-header-count")!.textContent).toBe("3");
|
|
154
|
+
expect(c.querySelector(".hk-section-header-description")).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { defineComponent, type Component, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import HkAlert from "./HkAlert";
|
|
4
|
+
import HkCard from "./HkCard";
|
|
5
|
+
import { HkSectionHeader } from "./HkSectionHeader";
|
|
6
|
+
import HkSpinner from "./HkSpinner";
|
|
7
|
+
import "./HkSectionCard.scss";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* HkSectionCard — the standard titled SECTION: a heading (with an optional
|
|
11
|
+
* hint line) above ONE card that carries the body.
|
|
12
|
+
*
|
|
13
|
+
* This is the section-level twin of the page-level scaffold
|
|
14
|
+
* (`HkAdminTablePage`): a page whose content is several lists (settings
|
|
15
|
+
* categories, tab panels, dashboards) renders each as an HkSectionCard, so
|
|
16
|
+
* the heading→body rhythm, the hint typography and the body padding come
|
|
17
|
+
* from ONE definition instead of every caller hand-rolling `mt-3`,
|
|
18
|
+
* `p-4`/`padded={false}` wrappers and its own hint classes — the drift the
|
|
19
|
+
* 2026-10-05 audit found between two tabs of the same page (one table flush
|
|
20
|
+
* in the card with its hint above, the other wrapped in a padded div with
|
|
21
|
+
* its hint below).
|
|
22
|
+
*
|
|
23
|
+
* The body states follow `HkAdminTablePage`'s contract: `error` replaces
|
|
24
|
+
* the body with an alert, `loading` with a centred spinner — both inside a
|
|
25
|
+
* PADDED card (a state is content, not a table), while the normal body
|
|
26
|
+
* honours `padded` (default false: tables carry their own cell padding and
|
|
27
|
+
* sit flush in the card). `error` wins when both are set, as on the
|
|
28
|
+
* scaffold.
|
|
29
|
+
*
|
|
30
|
+
* The section owns the INTRA-section rhythm only (heading block → card). The
|
|
31
|
+
* page owns the gap BETWEEN sections: several siblings on one page need the
|
|
32
|
+
* parent's own `gap` (a flex/grid column), because this component adds no
|
|
33
|
+
* outer margin by design — otherwise a page that renders three sections
|
|
34
|
+
* would stack them flush.
|
|
35
|
+
*
|
|
36
|
+
* A `footer` slot renders trailing notes under the card (placeholder
|
|
37
|
+
* grammars, "what this list feeds" details) with the same rhythm, so those
|
|
38
|
+
* notes stop being a per-consumer margin.
|
|
39
|
+
*
|
|
40
|
+
* One deliberate difference from the scaffold: `loading` replaces the body
|
|
41
|
+
* whenever it is true, where `HkAdminTablePage` only replaces an EMPTY body
|
|
42
|
+
* (`loading && !rows.length`) and keeps populated rows on screen through a
|
|
43
|
+
* refresh. A section therefore passes `loading` for its FIRST load, not for
|
|
44
|
+
* every refetch — or it blanks a table the user is reading.
|
|
45
|
+
*
|
|
46
|
+
* ```tsx
|
|
47
|
+
* <HkSectionCard title={t("…")} hint={t("…")} loading={loading}>
|
|
48
|
+
* <HkTable columns={columns} rows={rows} rowKey="id">{…}</HkTable>
|
|
49
|
+
* </HkSectionCard>
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export const HkSectionCard = defineComponent({
|
|
53
|
+
name: "HkSectionCard",
|
|
54
|
+
props: {
|
|
55
|
+
title: { type: String, required: true },
|
|
56
|
+
/** Explanatory line under the heading — the standard place for the
|
|
57
|
+
* "what this list is for" copy every section used to spell its own. */
|
|
58
|
+
hint: { type: String, default: undefined },
|
|
59
|
+
icon: { type: [Object, Function] as PropType<Component>, default: undefined },
|
|
60
|
+
/** Heading level only; the visual size is fixed by the header styles. */
|
|
61
|
+
level: { type: String as PropType<"h2" | "h3" | "div">, default: "h3" },
|
|
62
|
+
/** Trailing count beside the title (e.g. "3"). */
|
|
63
|
+
count: { type: String, default: undefined },
|
|
64
|
+
/** Tighter header spacing for dense panels. */
|
|
65
|
+
dense: { type: Boolean, default: false },
|
|
66
|
+
/** Hard failure: replaces the body with an alert. */
|
|
67
|
+
error: { type: String, default: undefined },
|
|
68
|
+
/** In-flight: replaces the body with a centred spinner. */
|
|
69
|
+
loading: { type: Boolean, default: false },
|
|
70
|
+
/** Body padding for non-table content (tables stay flush). */
|
|
71
|
+
padded: { type: Boolean, default: false },
|
|
72
|
+
},
|
|
73
|
+
setup(props, { slots }) {
|
|
74
|
+
return () => {
|
|
75
|
+
const state = props.error ? "error" : props.loading ? "loading" : "body";
|
|
76
|
+
// Hoisted to an identifier: the CONDITIONAL inline form
|
|
77
|
+
// (`{slots.actions ? { actions: … } : undefined}` as children) lands in
|
|
78
|
+
// a child array, where the plain object is no longer normalized to
|
|
79
|
+
// slots and the button silently disappears. Hoisting fixes it; the
|
|
80
|
+
// bare inline literal happens to work, which is exactly why the
|
|
81
|
+
// conditional one is a trap.
|
|
82
|
+
// Each slot is spread CONDITIONALLY: forwarding an absent one as a
|
|
83
|
+
// function that returns undefined defeats HkSectionHeader's own
|
|
84
|
+
// empty-node guard, which renders an empty wrapper (and, in the
|
|
85
|
+
// column-flex heading, a phantom gap slot) — a whitespace-only
|
|
86
|
+
// regression the R3 review caught.
|
|
87
|
+
const headerSlots =
|
|
88
|
+
slots.actions || slots.description
|
|
89
|
+
? {
|
|
90
|
+
...(slots.actions ? { actions: () => slots.actions?.() } : {}),
|
|
91
|
+
...(slots.description ? { description: () => slots.description?.() } : {}),
|
|
92
|
+
}
|
|
93
|
+
: undefined;
|
|
94
|
+
return (
|
|
95
|
+
<section class="hk-section-card">
|
|
96
|
+
<HkSectionHeader
|
|
97
|
+
title={props.title}
|
|
98
|
+
description={props.hint}
|
|
99
|
+
icon={props.icon}
|
|
100
|
+
level={props.level}
|
|
101
|
+
count={props.count}
|
|
102
|
+
dense={props.dense}
|
|
103
|
+
>
|
|
104
|
+
{headerSlots}
|
|
105
|
+
</HkSectionHeader>
|
|
106
|
+
<HkCard padded={state === "body" ? props.padded : true}>
|
|
107
|
+
{state === "error" ? (
|
|
108
|
+
<HkAlert message={props.error!} />
|
|
109
|
+
) : state === "loading" ? (
|
|
110
|
+
<HkSpinner center />
|
|
111
|
+
) : (
|
|
112
|
+
slots.default?.()
|
|
113
|
+
)}
|
|
114
|
+
</HkCard>
|
|
115
|
+
{slots.footer ? <div class="hk-section-card-footer">{slots.footer()}</div> : null}
|
|
116
|
+
</section>
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
export default HkSectionCard;
|
|
@@ -235,3 +235,31 @@ describe("HkToast", () => {
|
|
|
235
235
|
expect(items().length).toBe(0);
|
|
236
236
|
});
|
|
237
237
|
});
|
|
238
|
+
|
|
239
|
+
describe("HkToast live-region semantics", () => {
|
|
240
|
+
it("announces errors as alert/assertive and everything else as status/polite", async () => {
|
|
241
|
+
mountHost();
|
|
242
|
+
const toast = useToast();
|
|
243
|
+
toast.error("boom");
|
|
244
|
+
toast.success("nice");
|
|
245
|
+
toast.warning("hmm");
|
|
246
|
+
toast.info("fyi");
|
|
247
|
+
toast.loading("working");
|
|
248
|
+
await settle();
|
|
249
|
+
|
|
250
|
+
// The toast surface teleports to body — the host container never
|
|
251
|
+
// contains the items.
|
|
252
|
+
const items = [...document.body.querySelectorAll(".hk-toast-item")];
|
|
253
|
+
expect(items.length).toBe(5);
|
|
254
|
+
const byType = (type: string) =>
|
|
255
|
+
items.find((el) => el.classList.contains(`hk-toast-${type}`))!;
|
|
256
|
+
const err = byType("error");
|
|
257
|
+
expect(err.getAttribute("role")).toBe("alert");
|
|
258
|
+
expect(err.getAttribute("aria-live")).toBe("assertive");
|
|
259
|
+
for (const type of ["success", "warning", "info", "loading"]) {
|
|
260
|
+
const el = byType(type);
|
|
261
|
+
expect(el.getAttribute("role"), type).toBe("status");
|
|
262
|
+
expect(el.getAttribute("aria-live"), type).toBe("polite");
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -105,8 +105,19 @@ const HkToastItem = defineComponent({
|
|
|
105
105
|
const long = isLong(text);
|
|
106
106
|
const count = props.toast.messages.length;
|
|
107
107
|
|
|
108
|
+
// Live-region semantics: errors interrupt (role=alert implies
|
|
109
|
+
// assertive), everything else announces politely (role=status).
|
|
110
|
+
const a11y: { role: string; "aria-live": "assertive" | "polite" } =
|
|
111
|
+
props.toast.type === "error"
|
|
112
|
+
? { role: "alert", "aria-live": "assertive" }
|
|
113
|
+
: { role: "status", "aria-live": "polite" };
|
|
114
|
+
|
|
108
115
|
return (
|
|
109
|
-
<div
|
|
116
|
+
<div
|
|
117
|
+
class={["hk-toast-item", `hk-toast-${props.toast.type}`]}
|
|
118
|
+
role={a11y.role}
|
|
119
|
+
aria-live={a11y["aria-live"]}
|
|
120
|
+
>
|
|
110
121
|
<span class="hk-toast-icon">{renderIcon(props.toast.type)}</span>
|
|
111
122
|
<div class="hk-toast-body">
|
|
112
123
|
<Transition
|
|
@@ -82,8 +82,18 @@ afterEach(() => {
|
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
describe("HkTooltip popup-manager registration", () => {
|
|
85
|
+
it("defers the band entry until the first engage (lazy contract)", () => {
|
|
86
|
+
mount({ text: "dormant" });
|
|
87
|
+
// A never-engaged tooltip — the dense-list majority — costs no
|
|
88
|
+
// teleported popup and no registry entry at all.
|
|
89
|
+
expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
|
|
90
|
+
expect(tooltipEntries().length).toBe(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
85
93
|
it("registers one tooltip-kind entry at the tooltip band and stamps its z inline", async () => {
|
|
86
|
-
mount({ text: "hello" });
|
|
94
|
+
const { container } = mount({ text: "hello" });
|
|
95
|
+
enter(container);
|
|
96
|
+
await settle();
|
|
87
97
|
const entries = tooltipEntries();
|
|
88
98
|
expect(entries.length).toBe(1);
|
|
89
99
|
expect(entries[0]!.kind).toBe("tooltip");
|
|
@@ -91,25 +101,28 @@ describe("HkTooltip popup-manager registration", () => {
|
|
|
91
101
|
// Tooltips are transient annotations — they never lock scroll.
|
|
92
102
|
expect(entries[0]!.locksScroll).toBe(false);
|
|
93
103
|
expect(document.body.style.overflow).not.toBe("hidden");
|
|
94
|
-
// The band z lands on the teleported popup element
|
|
95
|
-
// onMounted, so the patch flushes on the next tick), overriding the
|
|
104
|
+
// The band z lands on the teleported popup element, overriding the
|
|
96
105
|
// --hi-z-tooltip SCSS fallback.
|
|
97
|
-
await nextTick();
|
|
98
106
|
expect(popup().style.zIndex).toBe(String(POPUP_Z_BANDS.tooltip));
|
|
99
107
|
});
|
|
100
108
|
|
|
101
|
-
it("stacks a second tooltip one step above within the band", () => {
|
|
102
|
-
mount({ text: "one" });
|
|
103
|
-
mount({ text: "two" });
|
|
109
|
+
it("stacks a second tooltip one step above within the band", async () => {
|
|
110
|
+
const a = mount({ text: "one" });
|
|
111
|
+
const b = mount({ text: "two" });
|
|
112
|
+
enter(a.container);
|
|
113
|
+
enter(b.container);
|
|
114
|
+
await settle();
|
|
104
115
|
const entries = tooltipEntries();
|
|
105
116
|
expect(entries.length).toBe(2);
|
|
106
|
-
const zs = entries.map((e) => e.zIndex).sort((
|
|
107
|
-
expect(zs[1]).toBe(zs[0] + POPUP_Z_STEP);
|
|
117
|
+
const zs = entries.map((e) => e.zIndex).sort((x, y) => x - y);
|
|
118
|
+
expect(zs[1]).toBe(zs[0]! + POPUP_Z_STEP);
|
|
108
119
|
expect(zs[0]).toBeGreaterThanOrEqual(POPUP_Z_BANDS.tooltip);
|
|
109
120
|
});
|
|
110
121
|
|
|
111
|
-
it("removes its registry entry on unmount", () => {
|
|
112
|
-
const { app } = mount({ text: "bye" });
|
|
122
|
+
it("removes its registry entry on unmount", async () => {
|
|
123
|
+
const { app, container } = mount({ text: "bye" });
|
|
124
|
+
enter(container);
|
|
125
|
+
await settle();
|
|
113
126
|
expect(tooltipEntries().length).toBe(1);
|
|
114
127
|
app.unmount();
|
|
115
128
|
expect(tooltipEntries().length).toBe(0);
|
|
@@ -128,8 +141,15 @@ describe("HkTooltip popup-manager registration", () => {
|
|
|
128
141
|
});
|
|
129
142
|
|
|
130
143
|
describe("HkTooltip show/hide", () => {
|
|
131
|
-
it("
|
|
144
|
+
it("mounts the popup on first engage and keeps it hidden after hide, with the text content", async () => {
|
|
132
145
|
const { container } = mount({ text: "the tip" });
|
|
146
|
+
// Nothing before the first engage…
|
|
147
|
+
expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
|
|
148
|
+
// …then engage and hide: the popup persists, hidden, for reuse.
|
|
149
|
+
enter(container);
|
|
150
|
+
await settle();
|
|
151
|
+
leave(container);
|
|
152
|
+
await nextTick();
|
|
133
153
|
const el = popup();
|
|
134
154
|
expect(document.body.contains(el)).toBe(true);
|
|
135
155
|
expect(el.parentElement).toBe(document.body);
|
|
@@ -139,6 +159,46 @@ describe("HkTooltip show/hide", () => {
|
|
|
139
159
|
expect(wrapper(container).querySelector(".hk-tooltip-trigger .anchor-probe")).not.toBeNull();
|
|
140
160
|
});
|
|
141
161
|
|
|
162
|
+
it("wires aria: role=tooltip + id on the popup, describedby on the wrapper while visible", async () => {
|
|
163
|
+
const { container } = mount({ text: "a11y tip", delay: 0 });
|
|
164
|
+
enter(container);
|
|
165
|
+
await settle();
|
|
166
|
+
const el = popup();
|
|
167
|
+
expect(el.getAttribute("role")).toBe("tooltip");
|
|
168
|
+
const id = el.getAttribute("id");
|
|
169
|
+
expect(id).toBeTruthy();
|
|
170
|
+
expect(wrapper(container).getAttribute("aria-describedby")).toBe(id);
|
|
171
|
+
leave(container);
|
|
172
|
+
await nextTick();
|
|
173
|
+
expect(wrapper(container).getAttribute("aria-describedby")).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("hands { popupId, visible } to the default slot for focus-target wiring", async () => {
|
|
177
|
+
const container = document.createElement("div");
|
|
178
|
+
document.body.appendChild(container);
|
|
179
|
+
let seen: { popupId?: string; visible?: boolean } = {};
|
|
180
|
+
const app = createApp({
|
|
181
|
+
render: () =>
|
|
182
|
+
h(
|
|
183
|
+
HkTooltip,
|
|
184
|
+
{ text: "slot props", delay: 0 } as never,
|
|
185
|
+
// The scoped-slot child needs a cast: HkTooltip does not declare
|
|
186
|
+
// slot prop types, so h() types the default slot as no-arg.
|
|
187
|
+
((slotProps: { popupId: string; visible: boolean }) => {
|
|
188
|
+
seen = slotProps;
|
|
189
|
+
return h("span", { class: "anchor-probe" }, "anchor");
|
|
190
|
+
}) as never,
|
|
191
|
+
),
|
|
192
|
+
});
|
|
193
|
+
app.mount(container);
|
|
194
|
+
mounts.push({ app, container });
|
|
195
|
+
await nextTick();
|
|
196
|
+
wrapper(container).dispatchEvent(new MouseEvent("mouseenter"));
|
|
197
|
+
await settle();
|
|
198
|
+
expect(seen.visible).toBe(true);
|
|
199
|
+
expect(seen.popupId).toBe(popup().getAttribute("id"));
|
|
200
|
+
});
|
|
201
|
+
|
|
142
202
|
it("waits out the default 300ms delay before showing", async () => {
|
|
143
203
|
vi.useFakeTimers();
|
|
144
204
|
const { container } = mount({ text: "delayed" });
|
|
@@ -236,7 +296,9 @@ describe("HkTooltip touch taps", () => {
|
|
|
236
296
|
new PointerEvent("pointerdown", { pointerType: "mouse", bubbles: true }),
|
|
237
297
|
);
|
|
238
298
|
await nextTick();
|
|
239
|
-
|
|
299
|
+
// A mouse press never engages the lazy tooltip at all — no popup
|
|
300
|
+
// node, no visible class.
|
|
301
|
+
expect(document.querySelector(".hk-tooltip-popup")).toBeNull();
|
|
240
302
|
});
|
|
241
303
|
|
|
242
304
|
it("does not re-open from the synthetic mouseenter right after a closing tap", async () => {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { computed, defineComponent, onBeforeUnmount,
|
|
1
|
+
import { computed, defineComponent, onBeforeUnmount, ref, Teleport, type CSSProperties, type PropType } from "vue";
|
|
2
2
|
import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
|
|
3
3
|
import { applyTooltipPosition, type TooltipPlacement } from "../runtime/tooltipPosition";
|
|
4
4
|
import "./HkTooltip.scss";
|
|
5
5
|
|
|
6
|
+
let popupSeq = 0;
|
|
7
|
+
|
|
6
8
|
export default defineComponent({
|
|
7
9
|
name: "HkTooltip",
|
|
8
10
|
props: {
|
|
@@ -13,22 +15,35 @@ export default defineComponent({
|
|
|
13
15
|
},
|
|
14
16
|
setup(props, { slots }) {
|
|
15
17
|
const visible = ref(false);
|
|
18
|
+
// Stable id for the aria-describedby link from the trigger wrapper to
|
|
19
|
+
// the popup (set while the popup is visible, mirroring the tooltip
|
|
20
|
+
// bridge's contract).
|
|
21
|
+
const popupId = `hk-tooltip-${++popupSeq}`;
|
|
16
22
|
const wrapperRef = ref<HTMLElement | null>(null);
|
|
17
23
|
const popupRef = ref<HTMLElement | null>(null);
|
|
18
24
|
let showTimer: ReturnType<typeof setTimeout> | null = null;
|
|
19
25
|
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
26
|
+
// The band handle and the popup DOM are both LAZY (dense-list contract,
|
|
27
|
+
// 2026-10-05): a tooltip that is never engaged — the overwhelming
|
|
28
|
+
// majority in a list of tags — costs no teleported node, no band
|
|
29
|
+
// registration and no pointer wiring beyond the wrapper's own handlers.
|
|
30
|
+
// The first engage registers the popup-manager handle (kind "tooltip",
|
|
31
|
+
// above modal/drawer overlays, below toasts) and mounts the popup;
|
|
32
|
+
// both are held until unmount so re-shows never churn.
|
|
24
33
|
const manager = usePopupManager();
|
|
25
34
|
let popupHandle: PopupHandle | null = null;
|
|
26
35
|
const zIndex = ref<number | null>(null);
|
|
36
|
+
const everShown = ref(false);
|
|
27
37
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
38
|
+
function ensureEngaged() {
|
|
39
|
+
if (!everShown.value) {
|
|
40
|
+
everShown.value = true;
|
|
41
|
+
if (!popupHandle) {
|
|
42
|
+
popupHandle = manager.register("tooltip", false);
|
|
43
|
+
}
|
|
44
|
+
zIndex.value = popupHandle.zIndex;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
32
47
|
|
|
33
48
|
function updatePosition() {
|
|
34
49
|
if (!wrapperRef.value || !popupRef.value) return;
|
|
@@ -45,6 +60,7 @@ export default defineComponent({
|
|
|
45
60
|
|
|
46
61
|
function show() {
|
|
47
62
|
clearShowTimer();
|
|
63
|
+
ensureEngaged();
|
|
48
64
|
showTimer = setTimeout(() => {
|
|
49
65
|
visible.value = true;
|
|
50
66
|
requestAnimationFrame(updatePosition);
|
|
@@ -53,6 +69,7 @@ export default defineComponent({
|
|
|
53
69
|
|
|
54
70
|
function showNow() {
|
|
55
71
|
clearShowTimer();
|
|
72
|
+
ensureEngaged();
|
|
56
73
|
visible.value = true;
|
|
57
74
|
requestAnimationFrame(updatePosition);
|
|
58
75
|
}
|
|
@@ -136,6 +153,7 @@ export default defineComponent({
|
|
|
136
153
|
ref={wrapperRef}
|
|
137
154
|
class="hk-tooltip-wrapper"
|
|
138
155
|
data-position={props.placement}
|
|
156
|
+
aria-describedby={visible.value ? popupId : undefined}
|
|
139
157
|
onPointerdown={onPointerdown}
|
|
140
158
|
onMouseenter={() => {
|
|
141
159
|
// Synthetic mouseenter right after a touch tap must not
|
|
@@ -148,16 +166,20 @@ export default defineComponent({
|
|
|
148
166
|
onFocusout={hide}
|
|
149
167
|
>
|
|
150
168
|
<span class="hk-tooltip-trigger">
|
|
151
|
-
{slots.default?.()}
|
|
169
|
+
{slots.default?.({ popupId, visible: visible.value })}
|
|
152
170
|
</span>
|
|
153
171
|
<Teleport to="body">
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
172
|
+
{everShown.value && (
|
|
173
|
+
<div
|
|
174
|
+
ref={popupRef}
|
|
175
|
+
id={popupId}
|
|
176
|
+
role="tooltip"
|
|
177
|
+
class={tooltipCls.value}
|
|
178
|
+
style={popupStyle.value}
|
|
179
|
+
>
|
|
180
|
+
<div class="hk-tooltip-content">{props.text}</div>
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
161
183
|
</Teleport>
|
|
162
184
|
</span>
|
|
163
185
|
);
|
package/src/index.ts
CHANGED
|
@@ -818,6 +818,8 @@ export { HkCardList, HkListRow } from "./components/HkCardList";
|
|
|
818
818
|
export { HkCardList as HCardList, HkListRow as HListRow } from "./components/HkCardList";
|
|
819
819
|
export { HkSectionHeader, type SectionHeaderVariant } from "./components/HkSectionHeader";
|
|
820
820
|
export { HkSectionHeader as HSectionHeader } from "./components/HkSectionHeader";
|
|
821
|
+
export { HkSectionCard } from "./components/HkSectionCard";
|
|
822
|
+
export { HkSectionCard as HSectionCard } from "./components/HkSectionCard";
|
|
821
823
|
export { default as HFilterBar } from "./components/HkFilterBar";
|
|
822
824
|
export { HkShareBar as HkShareBar } from "./components/HkShareBar";
|
|
823
825
|
export { HkSecretRevealModal as HkSecretRevealModal } from "./components/HkSecretRevealModal";
|