@celestia-island/hikari 0.47.1 → 0.49.0
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/HkAboutModal.scss +70 -7
- package/src/components/HkAboutModal.styles.test.ts +136 -0
- package/src/components/HkAboutModal.test.tsx +171 -3
- package/src/components/HkAboutModal.tsx +114 -37
- package/src/components/HkBlockingToast.scss +3 -1
- package/src/components/HkMenu.tsx +9 -6
- package/src/components/HkModal.tsx +17 -1
- package/src/components/HkModal.width.test.tsx +6 -2
- package/src/components/HkPopover.scss +4 -3
- package/src/components/HkPopover.sheet-maxwidth.test.ts +3 -2
- package/src/components/HkPopover.surfacevars.test.ts +3 -1
- package/src/components/HkPopover.tsx +14 -11
- package/src/components/HkSelect.scss +11 -0
- package/src/components/HkSelectPanel.popoutwidth.test.ts +47 -0
- package/src/components/HkSelectPanel.test.tsx +12 -10
- package/src/components/HkSelectPanel.tsx +10 -10
- package/src/components/HkToast.scss +3 -1
- package/src/components/HkTooltip.scss +8 -0
- package/src/components/HkTooltip.test.tsx +12 -0
- package/src/components/HkTooltip.tsx +15 -9
- package/src/index.ts +2 -0
- package/src/runtime/index.ts +10 -0
- package/src/runtime/tooltipBridge.ts +10 -6
- package/src/runtime/tooltipPosition.test.ts +213 -2
- package/src/runtime/tooltipPosition.ts +159 -0
- package/src/runtime/viewportGutter.test.ts +98 -0
- package/src/runtime/viewportGutter.ts +73 -0
- package/src/scale.scss +17 -0
- package/src/styles/theme/scale.scss +17 -0
package/package.json
CHANGED
|
@@ -228,7 +228,7 @@
|
|
|
228
228
|
color: rgb(var(--color-warning));
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
-
/* ──
|
|
231
|
+
/* ── Links: licenses, external links, credits names and filings ───────── */
|
|
232
232
|
|
|
233
233
|
.s-about-modal-chips {
|
|
234
234
|
display: flex;
|
|
@@ -237,10 +237,10 @@
|
|
|
237
237
|
gap: var(--space-8, 0.5rem);
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
-
// One
|
|
240
|
+
// One link group (licenses / links). Must span the chips block — inside an
|
|
241
241
|
// `align-items: center` column a plain block would shrink to fit-content and
|
|
242
|
-
// a long
|
|
243
|
-
//
|
|
242
|
+
// a long list would then wrap against that collapsed width instead of the
|
|
243
|
+
// modal's.
|
|
244
244
|
.s-about-modal-links {
|
|
245
245
|
width: 100%;
|
|
246
246
|
}
|
|
@@ -250,10 +250,12 @@
|
|
|
250
250
|
flex-wrap: wrap;
|
|
251
251
|
justify-content: center;
|
|
252
252
|
gap: var(--space-6, 0.375rem);
|
|
253
|
+
// The row's own size, so it holds for either face: `plain` links inherit
|
|
254
|
+
// it (chips set their own).
|
|
255
|
+
font-size: var(--text-xs, 0.75rem);
|
|
253
256
|
}
|
|
254
257
|
|
|
255
|
-
//
|
|
256
|
-
// credits names, licenses, external links and the legal filings.
|
|
258
|
+
// Chip face (default) — the ghost pill.
|
|
257
259
|
.s-about-modal-link {
|
|
258
260
|
padding: var(--space-2, 0.125rem) var(--space-10, 0.625rem);
|
|
259
261
|
border: 1px solid var(--border-subtle, rgb(100 100 100 / 12%));
|
|
@@ -268,6 +270,55 @@
|
|
|
268
270
|
background var(--duration-short, 0.15s) ease;
|
|
269
271
|
}
|
|
270
272
|
|
|
273
|
+
// Plain face: a bare text link (user direction 2026-09-14 — the organization
|
|
274
|
+
// / author names, the site domains and the filing numbers read as ordinary
|
|
275
|
+
// sentence text, not as tags). No frame, no pill padding, no underline; the
|
|
276
|
+
// hover colour shift is the entire affordance. Colour and size are inherited
|
|
277
|
+
// so the link melts into the run it sits in — the credits sentence
|
|
278
|
+
// (full-contrast text) or a muted link row.
|
|
279
|
+
//
|
|
280
|
+
// The face never has to out-shout the base `:hover` rules below: this rule
|
|
281
|
+
// (0,2,0) beats the chip base (0,1,0), and the `[data-face="plain"]:hover` /
|
|
282
|
+
// `:active` rules (0,3,0) are what keep the hover surface-free and the colour
|
|
283
|
+
// shift alive. Drop those two and the plain face silently regains the chip's
|
|
284
|
+
// tinted hover box.
|
|
285
|
+
.s-about-modal-link[data-face="plain"] {
|
|
286
|
+
display: inline;
|
|
287
|
+
margin: 0;
|
|
288
|
+
padding: 0;
|
|
289
|
+
border: 0;
|
|
290
|
+
border-radius: 0;
|
|
291
|
+
background: none;
|
|
292
|
+
color: inherit;
|
|
293
|
+
font-size: inherit;
|
|
294
|
+
line-height: inherit;
|
|
295
|
+
vertical-align: baseline;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// A link carrying an icon is one aligned unit (the mark, then the optional
|
|
299
|
+
// label) — icon-only links are how the GitHub mark rides beside the domains.
|
|
300
|
+
// Same specificity as the `plain` block above, so source order decides: this
|
|
301
|
+
// one wins the tie, which is what turns an icon-only link into inline-flex
|
|
302
|
+
// instead of the plain `inline`.
|
|
303
|
+
.s-about-modal-link.s-about-modal-link-has-icon {
|
|
304
|
+
display: inline-flex;
|
|
305
|
+
align-items: center;
|
|
306
|
+
gap: var(--space-4, 0.25rem);
|
|
307
|
+
vertical-align: middle;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.s-about-modal-link-icon {
|
|
311
|
+
flex-shrink: 0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// An icon-only link carries no text to click, so the mark buys its target
|
|
315
|
+
// width here; its height is whatever the row's line box gives it (the mark is
|
|
316
|
+
// centred in that), which keeps a row of plain links exactly as tall as its
|
|
317
|
+
// text.
|
|
318
|
+
.s-about-modal-link[data-face="plain"].s-about-modal-link-has-icon {
|
|
319
|
+
padding: 0 var(--space-6, 0.375rem);
|
|
320
|
+
}
|
|
321
|
+
|
|
271
322
|
// Chips inside the credits sentence sit in a text run: keep them on the
|
|
272
323
|
// baseline and let the surrounding spaces do the spacing.
|
|
273
324
|
.s-about-modal-credit-link {
|
|
@@ -282,10 +333,21 @@
|
|
|
282
333
|
color: rgb(var(--color-primary));
|
|
283
334
|
}
|
|
284
335
|
|
|
336
|
+
// The plain face owns no surface to tint — only the glyph changes colour.
|
|
337
|
+
.s-about-modal-link[data-face="plain"]:hover {
|
|
338
|
+
background: none;
|
|
339
|
+
color: rgb(var(--color-primary));
|
|
340
|
+
}
|
|
341
|
+
|
|
285
342
|
.s-about-modal-link:active {
|
|
286
343
|
background: var(--c-primary-subtle, rgb(var(--color-primary) / 8%));
|
|
287
344
|
}
|
|
288
345
|
|
|
346
|
+
.s-about-modal-link[data-face="plain"]:active {
|
|
347
|
+
background: none;
|
|
348
|
+
color: var(--c-primary-strong, rgb(var(--color-primary) / 40%));
|
|
349
|
+
}
|
|
350
|
+
|
|
289
351
|
.s-about-modal-link:focus-visible {
|
|
290
352
|
outline: none;
|
|
291
353
|
box-shadow:
|
|
@@ -295,7 +357,7 @@
|
|
|
295
357
|
|
|
296
358
|
/* ── Legal footer: filing links + copyright ───────────────────────────── */
|
|
297
359
|
|
|
298
|
-
// Filing numbers are long, so the legal
|
|
360
|
+
// Filing numbers are long, so the legal row renders a notch smaller than
|
|
299
361
|
// the license / link rows.
|
|
300
362
|
.s-about-modal-footer-link {
|
|
301
363
|
font-size: var(--text-2xs, 0.625rem);
|
|
@@ -307,6 +369,7 @@
|
|
|
307
369
|
justify-content: center;
|
|
308
370
|
gap: var(--space-6, 0.375rem);
|
|
309
371
|
margin-top: calc(-1 * var(--space-4, 0.25rem));
|
|
372
|
+
font-size: var(--text-2xs, 0.625rem);
|
|
310
373
|
}
|
|
311
374
|
|
|
312
375
|
.s-about-modal-footer {
|
|
@@ -55,4 +55,140 @@ describe("HkAboutModal stylesheet contract", () => {
|
|
|
55
55
|
.sort();
|
|
56
56
|
expect(dead, "stylesheet selectors with no matching element").toEqual([]);
|
|
57
57
|
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Declaration-level guards for the two link faces.
|
|
61
|
+
*
|
|
62
|
+
* The checks above only compare class *names*, so the whole
|
|
63
|
+
* `[data-face="plain"]` contract — the part the user actually asked for —
|
|
64
|
+
* was invisible to them: emptying that rule (Dart Sass then drops the
|
|
65
|
+
* selector entirely), re-adding a pill padding, or losing `font-size:
|
|
66
|
+
* inherit` all left the suite green while turning bare text links back
|
|
67
|
+
* into chips. These assertions read the compiled declarations instead.
|
|
68
|
+
*/
|
|
69
|
+
describe("link faces", () => {
|
|
70
|
+
/**
|
|
71
|
+
* Body of one compiled rule, by exact selector (Sass drops the quotes
|
|
72
|
+
* around an attribute value: `[data-face=plain]`).
|
|
73
|
+
*
|
|
74
|
+
* The selector must compile exactly once: a second copy — say inside a
|
|
75
|
+
* media query, or a later override — would otherwise stay invisible here
|
|
76
|
+
* while still winning the cascade.
|
|
77
|
+
*/
|
|
78
|
+
function ruleBody(sheet: string, selector: string): string {
|
|
79
|
+
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
80
|
+
const matches = [...sheet.matchAll(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`, "g"))];
|
|
81
|
+
expect(matches, `${selector} compiles exactly once`).toHaveLength(1);
|
|
82
|
+
return matches[0]?.[1] ?? "";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One declaration's value inside a rule body.
|
|
87
|
+
*
|
|
88
|
+
* A property declared twice is a failure rather than a coincidence: CSS
|
|
89
|
+
* resolves a repeat last-wins, so a first-match reader would report the
|
|
90
|
+
* stale value and happily accept the override.
|
|
91
|
+
*/
|
|
92
|
+
function declaration(body: string, property: string): string {
|
|
93
|
+
const values = [
|
|
94
|
+
...body.matchAll(new RegExp(`(?:^|;)\\s*${property}\\s*:([^;]*)`, "g")),
|
|
95
|
+
].map((match) => match[1]!.trim());
|
|
96
|
+
expect(values.length, `${property} is declared at most once`).toBeLessThanOrEqual(1);
|
|
97
|
+
return values[0] ?? "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const PLAIN = ".s-about-modal-link[data-face=plain]";
|
|
101
|
+
|
|
102
|
+
it("keeps the plain face a bare text link", () => {
|
|
103
|
+
const plain = ruleBody(css, PLAIN);
|
|
104
|
+
expect(plain, "the plain-face rule must survive compilation").not.toBe("");
|
|
105
|
+
expect({
|
|
106
|
+
padding: declaration(plain, "padding"),
|
|
107
|
+
border: declaration(plain, "border"),
|
|
108
|
+
radius: declaration(plain, "border-radius"),
|
|
109
|
+
background: declaration(plain, "background"),
|
|
110
|
+
// Inherited colour and size are what let a name melt into the credits
|
|
111
|
+
// sentence (13px) or a domain into the 12px link row, instead of
|
|
112
|
+
// keeping the chip's own 12px scale.
|
|
113
|
+
color: declaration(plain, "color"),
|
|
114
|
+
fontSize: declaration(plain, "font-size"),
|
|
115
|
+
lineHeight: declaration(plain, "line-height"),
|
|
116
|
+
}).toEqual({
|
|
117
|
+
padding: "0",
|
|
118
|
+
border: "0",
|
|
119
|
+
radius: "0",
|
|
120
|
+
background: "none",
|
|
121
|
+
color: "inherit",
|
|
122
|
+
fontSize: "inherit",
|
|
123
|
+
lineHeight: "inherit",
|
|
124
|
+
});
|
|
125
|
+
// Both faces promise "no underline" (these rows are text with a click
|
|
126
|
+
// target, not document links): the base rule turns the UA underline off
|
|
127
|
+
// and the plain face must not bring one back.
|
|
128
|
+
expect(declaration(ruleBody(css, ".s-about-modal-link"), "text-decoration")).toBe("none");
|
|
129
|
+
expect(declaration(plain, "text-decoration")).not.toContain("underline");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("keeps the plain hover a colour shift, not a tinted box", () => {
|
|
133
|
+
const hover = ruleBody(css, `${PLAIN}:hover`);
|
|
134
|
+
expect(declaration(hover, "background")).toBe("none");
|
|
135
|
+
expect(declaration(hover, "color")).toBe("rgb(var(--color-primary))");
|
|
136
|
+
expect(declaration(ruleBody(css, `${PLAIN}:active`), "background")).toBe("none");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("keeps an icon link one aligned unit with a visible mark", () => {
|
|
140
|
+
const iconLink = ruleBody(css, ".s-about-modal-link.s-about-modal-link-has-icon");
|
|
141
|
+
expect(declaration(iconLink, "display")).toBe("inline-flex");
|
|
142
|
+
expect(declaration(iconLink, "align-items")).toBe("center");
|
|
143
|
+
// The wrapper is what the alignment hangs off — a hidden or collapsing
|
|
144
|
+
// one takes the mark with it, leaving an icon-only link as an invisible
|
|
145
|
+
// target (the failure the empty-entry filter exists to prevent).
|
|
146
|
+
const iconWrap = ruleBody(css, ".s-about-modal-link-icon");
|
|
147
|
+
expect(declaration(iconWrap, "flex-shrink")).toBe("0");
|
|
148
|
+
expect(declaration(iconWrap, "display")).not.toBe("none");
|
|
149
|
+
// Icon-only: the mark carries no text, so the padding is the target.
|
|
150
|
+
expect(declaration(ruleBody(css, `${PLAIN}.s-about-modal-link-has-icon`), "padding")).toBe(
|
|
151
|
+
"0 var(--space-6, 0.375rem)",
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("sizes every row the plain face inherits from", () => {
|
|
156
|
+
// The plain face inherits, so the rows own the type scale the chips used
|
|
157
|
+
// to bring themselves (16px body text otherwise): the credits sentence
|
|
158
|
+
// supplies the names' 13px, the link row the domains' 12px, the legal
|
|
159
|
+
// row the filings' 10px.
|
|
160
|
+
expect(declaration(ruleBody(css, ".s-about-modal-credits-line"), "font-size")).toBe(
|
|
161
|
+
"var(--text-sm, 0.8125rem)",
|
|
162
|
+
);
|
|
163
|
+
expect(declaration(ruleBody(css, ".s-about-modal-links-list"), "font-size")).toBe(
|
|
164
|
+
"var(--text-xs, 0.75rem)",
|
|
165
|
+
);
|
|
166
|
+
expect(declaration(ruleBody(css, ".s-about-modal-footer-links"), "font-size")).toBe(
|
|
167
|
+
"var(--text-2xs, 0.625rem)",
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("keeps the pill the hosts that ask for nothing already ship", () => {
|
|
172
|
+
// The plain face is additive: a consumer passing `{ label, href }` must
|
|
173
|
+
// keep exactly the tag it had, so the chip's geometry is pinned here
|
|
174
|
+
// instead of being assumed.
|
|
175
|
+
const chip = ruleBody(css, ".s-about-modal-link");
|
|
176
|
+
expect(declaration(chip, "padding")).toBe(
|
|
177
|
+
"var(--space-2, 0.125rem) var(--space-10, 0.625rem)",
|
|
178
|
+
);
|
|
179
|
+
expect(declaration(chip, "border")).toContain("1px solid var(--border-subtle");
|
|
180
|
+
expect(declaration(chip, "border-radius")).toBe("var(--radius-full, 9999px)");
|
|
181
|
+
expect(declaration(chip, "color")).toBe("rgb(var(--color-muted))");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("keeps the icon rule after the plain rule it refines", () => {
|
|
185
|
+
// Both are (0,2,0), so source order is the only thing that turns an
|
|
186
|
+
// icon link into inline-flex rather than the plain `inline` — the
|
|
187
|
+
// tie-break is part of the contract, not an accident of file layout.
|
|
188
|
+
const plainAt = css.indexOf(`${PLAIN} {`);
|
|
189
|
+
const iconAt = css.indexOf(".s-about-modal-link.s-about-modal-link-has-icon {");
|
|
190
|
+
expect(plainAt).toBeGreaterThanOrEqual(0);
|
|
191
|
+
expect(iconAt).toBeGreaterThan(plainAt);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
58
194
|
});
|
|
@@ -16,6 +16,9 @@ import { setLocale } from "../i18n/context";
|
|
|
16
16
|
* - the backdrop factory renders inside the clipped backdrop layer
|
|
17
17
|
* - licenses and links render as chips under one centered block
|
|
18
18
|
* - footer (filing) links render as external links
|
|
19
|
+
* - `face: "plain"` turns any of them into a bare text link (still a link,
|
|
20
|
+
* still a new tab), and an entry carrying an `icon` renders the mark, with
|
|
21
|
+
* an accessible name when it has no label to be named by
|
|
19
22
|
*
|
|
20
23
|
* (Repo test convention: raw createApp + document queries, no
|
|
21
24
|
* @vue/test-utils dependency.)
|
|
@@ -104,25 +107,190 @@ describe("HkAboutModal branding", () => {
|
|
|
104
107
|
{ name: "Celestia Island", href: "https://github.com/celestia-island" },
|
|
105
108
|
{ text: ",由 " },
|
|
106
109
|
{ name: "伊欧", href: "https://github.com/langyo" },
|
|
107
|
-
{ text: "
|
|
110
|
+
{ text: " 主创" },
|
|
108
111
|
],
|
|
109
112
|
});
|
|
110
113
|
await flushModal();
|
|
111
114
|
const line = query<HTMLElement>(".s-about-modal-credits-line");
|
|
112
|
-
expect(line.textContent).toBe("来自 Celestia Island,由 伊欧
|
|
115
|
+
expect(line.textContent).toBe("来自 Celestia Island,由 伊欧 主创");
|
|
113
116
|
const chips = [
|
|
114
117
|
...document.body.querySelectorAll<HTMLAnchorElement>(".s-about-modal-credit-link"),
|
|
115
118
|
];
|
|
116
119
|
expect(chips.map((chip) => chip.textContent)).toEqual(["Celestia Island", "伊欧"]);
|
|
117
|
-
// Every link in the dialog
|
|
120
|
+
// Every link in the dialog opens in a new tab; the chip is the default
|
|
121
|
+
// face, so an entry that asks for nothing keeps the tag.
|
|
118
122
|
for (const chip of chips) {
|
|
119
123
|
expect(chip.classList.contains("s-about-modal-link")).toBe(true);
|
|
124
|
+
expect(chip.getAttribute("data-face")).toBe("chip");
|
|
120
125
|
expect(chip.getAttribute("target")).toBe("_blank");
|
|
121
126
|
expect(chip.getAttribute("rel")).toContain("noopener");
|
|
122
127
|
}
|
|
123
128
|
expect(chips[0]!.getAttribute("href")).toBe("https://github.com/celestia-island");
|
|
124
129
|
});
|
|
125
130
|
|
|
131
|
+
it("renders a credit name as a plain text link when the entry asks for it", async () => {
|
|
132
|
+
mountAbout({
|
|
133
|
+
credits: [
|
|
134
|
+
{ text: "来自 " },
|
|
135
|
+
{ name: "Celestia Island", href: "https://github.com/celestia-island", face: "plain" },
|
|
136
|
+
{ text: ",由 " },
|
|
137
|
+
{ name: "伊欧", href: "https://github.com/langyo", face: "plain" },
|
|
138
|
+
{ text: " 主创" },
|
|
139
|
+
],
|
|
140
|
+
});
|
|
141
|
+
await flushModal();
|
|
142
|
+
// The names stay inside the sentence — same text, same flow, no frame.
|
|
143
|
+
expect(query<HTMLElement>(".s-about-modal-credits-line").textContent).toBe(
|
|
144
|
+
"来自 Celestia Island,由 伊欧 主创",
|
|
145
|
+
);
|
|
146
|
+
const links = [
|
|
147
|
+
...document.body.querySelectorAll<HTMLAnchorElement>(".s-about-modal-credit-link"),
|
|
148
|
+
];
|
|
149
|
+
expect(links.map((link) => link.textContent)).toEqual(["Celestia Island", "伊欧"]);
|
|
150
|
+
for (const link of links) {
|
|
151
|
+
expect(link.getAttribute("data-face")).toBe("plain");
|
|
152
|
+
expect(link.classList.contains("s-about-modal-link")).toBe(true);
|
|
153
|
+
expect(link.getAttribute("target")).toBe("_blank");
|
|
154
|
+
// A text link is named by its own text — no redundant aria-label.
|
|
155
|
+
expect(link.getAttribute("aria-label")).toBeNull();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("renders plain faces across the link rows and the legal filings", async () => {
|
|
160
|
+
mountAbout({
|
|
161
|
+
links: [
|
|
162
|
+
{ label: "celestia.world", href: "https://celestia.world", face: "plain" },
|
|
163
|
+
{ label: "celestia.ac.cn", href: "https://celestia.ac.cn", face: "plain" },
|
|
164
|
+
],
|
|
165
|
+
footerLinks: [
|
|
166
|
+
{ label: "苏ICP备2025155733号-2", href: "https://beian.miit.gov.cn/", face: "plain" },
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
await flushModal();
|
|
170
|
+
const links = [
|
|
171
|
+
...document.body.querySelectorAll<HTMLAnchorElement>(
|
|
172
|
+
".s-about-modal-links-list .s-about-modal-link",
|
|
173
|
+
),
|
|
174
|
+
];
|
|
175
|
+
expect(links.map((link) => link.textContent)).toEqual(["celestia.world", "celestia.ac.cn"]);
|
|
176
|
+
expect(links.map((link) => link.getAttribute("data-face"))).toEqual(["plain", "plain"]);
|
|
177
|
+
expect(
|
|
178
|
+
query<HTMLElement>(".s-about-modal-footer-link").getAttribute("data-face"),
|
|
179
|
+
).toBe("plain");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("renders an icon-only link with an accessible name beside the text links", async () => {
|
|
183
|
+
mountAbout({
|
|
184
|
+
links: [
|
|
185
|
+
{ label: "celestia.world", href: "https://celestia.world", face: "plain" },
|
|
186
|
+
{
|
|
187
|
+
icon: "github",
|
|
188
|
+
href: "https://github.com/celestia-island/shittim-chest",
|
|
189
|
+
face: "plain",
|
|
190
|
+
ariaLabel: "GitHub",
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
});
|
|
194
|
+
await flushModal();
|
|
195
|
+
const links = [
|
|
196
|
+
...document.body.querySelectorAll<HTMLAnchorElement>(
|
|
197
|
+
".s-about-modal-links-list .s-about-modal-link",
|
|
198
|
+
),
|
|
199
|
+
];
|
|
200
|
+
expect(links.length).toBe(2);
|
|
201
|
+
const mark = links[1]!;
|
|
202
|
+
expect(mark.textContent).toBe("");
|
|
203
|
+
expect(mark.classList.contains("s-about-modal-link-has-icon")).toBe(true);
|
|
204
|
+
expect(mark.getAttribute("aria-label")).toBe("GitHub");
|
|
205
|
+
expect(mark.getAttribute("href")).toBe("https://github.com/celestia-island/shittim-chest");
|
|
206
|
+
expect(mark.getAttribute("target")).toBe("_blank");
|
|
207
|
+
expect(mark.querySelector(".s-about-modal-link-icon svg")).toBeTruthy();
|
|
208
|
+
// …and it is the GitHub mark, not just any glyph.
|
|
209
|
+
expect(
|
|
210
|
+
mark
|
|
211
|
+
.querySelector(".s-about-modal-link-icon svg")!
|
|
212
|
+
.classList.contains("lucide-github-icon"),
|
|
213
|
+
).toBe(true);
|
|
214
|
+
// The mark is decoration: it must not join the link's accessible name.
|
|
215
|
+
expect(
|
|
216
|
+
mark.querySelector(".s-about-modal-link-icon")!.getAttribute("aria-hidden"),
|
|
217
|
+
).toBe("true");
|
|
218
|
+
// The text link next to it stays text: no mark, no extra naming.
|
|
219
|
+
expect(links[0]!.querySelector("svg")).toBeNull();
|
|
220
|
+
expect(links[0]!.getAttribute("aria-label")).toBeNull();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("names an icon-only link from the icon when none is given", async () => {
|
|
224
|
+
mountAbout({ links: [{ icon: "github", href: "https://example.test/repo", face: "plain" }] });
|
|
225
|
+
await flushModal();
|
|
226
|
+
const mark = query<HTMLElement>(".s-about-modal-link");
|
|
227
|
+
expect(mark.getAttribute("aria-label")).toBe("GitHub");
|
|
228
|
+
expect(mark.getAttribute("data-face")).toBe("plain");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("lets a host name a text link explicitly", async () => {
|
|
232
|
+
mountAbout({
|
|
233
|
+
links: [
|
|
234
|
+
{
|
|
235
|
+
label: "celestia.world",
|
|
236
|
+
href: "https://celestia.world",
|
|
237
|
+
ariaLabel: "Celestia World 官网",
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
});
|
|
241
|
+
await flushModal();
|
|
242
|
+
// An explicit name outranks the visible text (and the visible text stays).
|
|
243
|
+
const link = query<HTMLAnchorElement>(".s-about-modal-link");
|
|
244
|
+
expect(link.getAttribute("aria-label")).toBe("Celestia World 官网");
|
|
245
|
+
expect(link.textContent).toBe("celestia.world");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("drops a link entry with neither a label nor an icon", async () => {
|
|
249
|
+
mountAbout({
|
|
250
|
+
links: [
|
|
251
|
+
{ label: "celestia.world", href: "https://celestia.world", face: "plain" },
|
|
252
|
+
// No text and no mark: an invisible but focusable link named by its
|
|
253
|
+
// raw URL is worse than no link.
|
|
254
|
+
{ href: "https://example.test/blank" },
|
|
255
|
+
],
|
|
256
|
+
});
|
|
257
|
+
await flushModal();
|
|
258
|
+
const links = [
|
|
259
|
+
...document.body.querySelectorAll<HTMLAnchorElement>(
|
|
260
|
+
".s-about-modal-links-list .s-about-modal-link",
|
|
261
|
+
),
|
|
262
|
+
];
|
|
263
|
+
expect(links.map((link) => link.textContent)).toEqual(["celestia.world"]);
|
|
264
|
+
expect(document.body.textContent).not.toContain("example.test/blank");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("renders no link row when every entry is empty", async () => {
|
|
268
|
+
mountAbout({ links: [{ href: "https://example.test/blank" }] });
|
|
269
|
+
await flushModal();
|
|
270
|
+
expect(document.body.querySelector(".s-about-modal-links")).toBeNull();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("drops an empty legal entry too", async () => {
|
|
274
|
+
mountAbout({
|
|
275
|
+
footerLinks: [
|
|
276
|
+
{ label: "苏ICP备2025155733号-2", href: "https://beian.miit.gov.cn/" },
|
|
277
|
+
{ href: "https://example.test/blank-filing" },
|
|
278
|
+
],
|
|
279
|
+
});
|
|
280
|
+
await flushModal();
|
|
281
|
+
const links = [
|
|
282
|
+
...document.body.querySelectorAll<HTMLAnchorElement>(".s-about-modal-footer-link"),
|
|
283
|
+
];
|
|
284
|
+
expect(links.map((link) => link.textContent)).toEqual(["苏ICP备2025155733号-2"]);
|
|
285
|
+
expect(document.body.textContent).not.toContain("example.test/blank-filing");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("renders no legal row when every filing is empty", async () => {
|
|
289
|
+
mountAbout({ footerLinks: [{ href: "https://example.test/blank-filing" }] });
|
|
290
|
+
await flushModal();
|
|
291
|
+
expect(document.body.querySelector(".s-about-modal-footer-links")).toBeNull();
|
|
292
|
+
});
|
|
293
|
+
|
|
126
294
|
it("keeps a credit name as plain text when no href is given", async () => {
|
|
127
295
|
mountAbout({ credits: [{ text: "由 " }, { name: "伊欧" }, { text: " 主创" }] });
|
|
128
296
|
await flushModal();
|