@celestia-island/hikari 0.45.9 → 0.46.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/DemoApp.tsx +37 -1
- package/src/components/HkAuthMethodList.scss +13 -39
- package/src/components/HkAuthMethodList.test.tsx +55 -73
- package/src/components/HkAuthMethodList.tsx +40 -149
- package/src/components/HkIconButtonGroup.scss +104 -0
- package/src/components/HkIconButtonGroup.test.tsx +182 -0
- package/src/components/HkIconButtonGroup.tsx +152 -0
- package/src/components/HkTooltip.tsx +7 -42
- package/src/index.ts +5 -0
- package/src/runtime/imageFallback.scss +13 -0
- package/src/runtime/imageFallback.test.ts +149 -0
- package/src/runtime/imageFallback.ts +125 -0
- package/src/runtime/index.ts +13 -0
- package/src/runtime/tooltipBridge.test.ts +213 -0
- package/src/runtime/tooltipBridge.ts +292 -0
- package/src/runtime/tooltipPosition.test.ts +41 -0
- package/src/runtime/tooltipPosition.ts +61 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkIconButtonGroup from "./HkIconButtonGroup";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HkIconButtonGroup contract tests:
|
|
8
|
+
* - "buttons" mode: no selection state, select fires on every click
|
|
9
|
+
* - "single" mode: radiogroup semantics — v-model update + data-active,
|
|
10
|
+
* no re-fire on the already-active key
|
|
11
|
+
* - "multiple" mode: toggle set in v-model, aria-pressed mirrors state
|
|
12
|
+
* - tooltips: every labeled item rides an HkTooltip wrapper (label text),
|
|
13
|
+
* an explicit `tooltip: ""` suppresses it
|
|
14
|
+
* - disabled items / disabled group stay dead
|
|
15
|
+
* - extra attrs (aria-label) fall through onto the root
|
|
16
|
+
*
|
|
17
|
+
* House style: no @vue/test-utils dependency — raw createApp mounts on a
|
|
18
|
+
* shared container list torn down after each case.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const mounts: Array<ReturnType<typeof createApp>> = [];
|
|
22
|
+
|
|
23
|
+
function mountComp(render: () => ReturnType<typeof h>): HTMLElement {
|
|
24
|
+
const container = document.createElement("div");
|
|
25
|
+
document.body.appendChild(container);
|
|
26
|
+
const app = createApp(defineComponent({ setup: () => render }));
|
|
27
|
+
mounts.push(app);
|
|
28
|
+
app.mount(container);
|
|
29
|
+
return container;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
for (const app of mounts.splice(0)) app.unmount();
|
|
34
|
+
for (const popup of [...document.querySelectorAll(".hk-tooltip-popup")]) popup.remove();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const options = [
|
|
38
|
+
{ key: "totp", label: "验证器 App" },
|
|
39
|
+
{ key: "passkey", label: "Passkey" },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
describe("HkIconButtonGroup", () => {
|
|
43
|
+
it("renders plain action buttons in buttons mode and emits select on every click", async () => {
|
|
44
|
+
const picked: string[] = [];
|
|
45
|
+
const c = mountComp(() =>
|
|
46
|
+
h(HkIconButtonGroup, { options, onSelect: (k: string) => picked.push(k) }),
|
|
47
|
+
);
|
|
48
|
+
const group = c.querySelector<HTMLElement>(".hk-icon-group")!;
|
|
49
|
+
expect(group.getAttribute("role")).toBe("group");
|
|
50
|
+
expect(group.classList.contains("hk-icon-group-buttons")).toBe(true);
|
|
51
|
+
const buttons = c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item");
|
|
52
|
+
expect(buttons.length).toBe(2);
|
|
53
|
+
expect(group.querySelectorAll(".hk-tooltip-wrapper").length).toBe(2);
|
|
54
|
+
buttons[0]!.click();
|
|
55
|
+
await Promise.resolve();
|
|
56
|
+
buttons[0]!.click();
|
|
57
|
+
await Promise.resolve();
|
|
58
|
+
expect(picked).toEqual(["totp", "totp"]);
|
|
59
|
+
// No selection state exists in buttons mode.
|
|
60
|
+
expect(c.querySelectorAll("[data-active]").length).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("updates v-model and marks the active item in single mode", async () => {
|
|
64
|
+
const model = ref<string | null>(null);
|
|
65
|
+
const c = mountComp(() =>
|
|
66
|
+
h(HkIconButtonGroup, {
|
|
67
|
+
options,
|
|
68
|
+
mode: "single",
|
|
69
|
+
modelValue: model.value,
|
|
70
|
+
"onUpdate:modelValue": (v: string | string[]) => {
|
|
71
|
+
model.value = v as string;
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
const group = c.querySelector<HTMLElement>(".hk-icon-group")!;
|
|
76
|
+
expect(group.getAttribute("role")).toBe("radiogroup");
|
|
77
|
+
const buttons = [...c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item")];
|
|
78
|
+
expect(buttons[0]!.getAttribute("role")).toBe("radio");
|
|
79
|
+
expect(buttons[0]!.getAttribute("aria-checked")).toBe("false");
|
|
80
|
+
buttons[0]!.click();
|
|
81
|
+
await nextTick();
|
|
82
|
+
expect(model.value).toBe("totp");
|
|
83
|
+
expect(buttons[0]!.dataset.active).toBe("true");
|
|
84
|
+
expect(buttons[0]!.getAttribute("aria-checked")).toBe("true");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("does not re-fire when the active key is re-clicked in single mode", async () => {
|
|
88
|
+
const events: string[] = [];
|
|
89
|
+
const c = mountComp(() =>
|
|
90
|
+
h(HkIconButtonGroup, {
|
|
91
|
+
options,
|
|
92
|
+
mode: "single",
|
|
93
|
+
modelValue: "totp",
|
|
94
|
+
"onUpdate:modelValue": () => events.push("update"),
|
|
95
|
+
onSelect: (k: string) => events.push(`select:${k}`),
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
const button = c.querySelector<HTMLButtonElement>(".hk-icon-group-item")!;
|
|
99
|
+
expect(button.dataset.active).toBe("true");
|
|
100
|
+
button.click();
|
|
101
|
+
await Promise.resolve();
|
|
102
|
+
expect(events).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("toggles keys into and out of the v-model array in multiple mode", async () => {
|
|
106
|
+
const seen: Array<string | string[]> = [];
|
|
107
|
+
const c = mountComp(() =>
|
|
108
|
+
h(HkIconButtonGroup, {
|
|
109
|
+
options,
|
|
110
|
+
mode: "multiple",
|
|
111
|
+
modelValue: ["passkey"],
|
|
112
|
+
"onUpdate:modelValue": (v: string | string[]) => seen.push(v),
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
const group = c.querySelector<HTMLElement>(".hk-icon-group")!;
|
|
116
|
+
expect(group.getAttribute("role")).toBe("group");
|
|
117
|
+
const buttons = [...c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item")];
|
|
118
|
+
expect(buttons[1]!.getAttribute("aria-pressed")).toBe("true");
|
|
119
|
+
buttons[0]!.click();
|
|
120
|
+
buttons[1]!.click();
|
|
121
|
+
await Promise.resolve();
|
|
122
|
+
expect(seen).toEqual([["passkey", "totp"], []]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("suppresses the tooltip with an explicit empty override and keeps the label as the a11y name", () => {
|
|
126
|
+
const c = mountComp(() =>
|
|
127
|
+
h(HkIconButtonGroup, {
|
|
128
|
+
options: [
|
|
129
|
+
{ key: "a", label: "Alpha" },
|
|
130
|
+
{ key: "b", label: "Beta", tooltip: "" },
|
|
131
|
+
],
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
expect(c.querySelectorAll(".hk-tooltip-wrapper").length).toBe(1);
|
|
135
|
+
const beta = c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item")[1]!;
|
|
136
|
+
expect(beta.getAttribute("aria-label")).toBe("Beta");
|
|
137
|
+
// The HkTooltip popup owns the hover text — the button carries no title.
|
|
138
|
+
expect(beta.getAttribute("title")).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("renders the given icon vnode and falls back to the label initial without one", () => {
|
|
142
|
+
const c = mountComp(() =>
|
|
143
|
+
h(HkIconButtonGroup, {
|
|
144
|
+
options: [
|
|
145
|
+
{ key: "a", label: "Alpha", icon: h("span", { class: "glyph" }) },
|
|
146
|
+
{ key: "b", label: "Beta" },
|
|
147
|
+
],
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
const buttons = c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item");
|
|
151
|
+
expect(buttons[0]!.querySelector(".glyph")).toBeTruthy();
|
|
152
|
+
expect(buttons[1]!.querySelector<HTMLElement>(".hk-icon-group-item-initial")?.textContent).toBe("B");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("keeps disabled items and the group-level disable inert", async () => {
|
|
156
|
+
const picked: string[] = [];
|
|
157
|
+
const c = mountComp(() =>
|
|
158
|
+
h(HkIconButtonGroup, {
|
|
159
|
+
options: [
|
|
160
|
+
{ key: "a", label: "Alpha", disabled: true },
|
|
161
|
+
{ key: "b", label: "Beta" },
|
|
162
|
+
],
|
|
163
|
+
disabled: true,
|
|
164
|
+
onSelect: (k: string) => picked.push(k),
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
const buttons = [...c.querySelectorAll<HTMLButtonElement>(".hk-icon-group-item")];
|
|
168
|
+
expect(buttons.every((b) => b.disabled)).toBe(true);
|
|
169
|
+
buttons[1]!.click();
|
|
170
|
+
await Promise.resolve();
|
|
171
|
+
expect(picked).toEqual([]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("falls extra attrs through onto the group root", () => {
|
|
175
|
+
const c = mountComp(() =>
|
|
176
|
+
h(HkIconButtonGroup, { options, "aria-label": "验证方式", "data-test": "probe" }),
|
|
177
|
+
);
|
|
178
|
+
const group = c.querySelector<HTMLElement>(".hk-icon-group")!;
|
|
179
|
+
expect(group.getAttribute("aria-label")).toBe("验证方式");
|
|
180
|
+
expect(group.getAttribute("data-test")).toBe("probe");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { computed, defineComponent, type PropType } from "vue";
|
|
2
|
+
import HkTooltip from "./HkTooltip";
|
|
3
|
+
import type { TooltipPlacement } from "../runtime/tooltipPosition";
|
|
4
|
+
import "./HkIconButtonGroup.scss";
|
|
5
|
+
|
|
6
|
+
/** One selectable icon in the group. */
|
|
7
|
+
export interface HkIconButtonGroupOption {
|
|
8
|
+
/** Stable identity — the value surfaced through v-model / select. */
|
|
9
|
+
key: string;
|
|
10
|
+
/** Accessible name AND the default tooltip text. Required on purpose:
|
|
11
|
+
* an icon-only control without a label is an a11y hole. */
|
|
12
|
+
label: string;
|
|
13
|
+
/** Icon-only content: a prebuilt vnode (brand SVG, lucide component,
|
|
14
|
+
* <img>) or anything renderable. Typed loose on purpose (same as
|
|
15
|
+
* HkAuthMethodList entries): hosts materialize hikari against their
|
|
16
|
+
* own vue minor, and a hard VNode type breaks typecheck whenever the
|
|
17
|
+
* host's vue differs. Missing icons fall back to the label's initial. */
|
|
18
|
+
icon?: unknown;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
/** Tooltip text override; an EMPTY string suppresses the tooltip. */
|
|
21
|
+
tooltip?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* HkIconButtonGroup — the icon-only variant of the button-group family
|
|
26
|
+
* (the same centered strip grammar as HkTabs, minus the text).
|
|
27
|
+
*
|
|
28
|
+
* Three working modes:
|
|
29
|
+
* - "buttons" a plain action group (no selection state) — the auth
|
|
30
|
+
* card's OAuth provider row
|
|
31
|
+
* - "single" a radiogroup: one active key (v-model), re-clicking the
|
|
32
|
+
* active key is a no-op — radio semantics
|
|
33
|
+
* - "multiple" a toggle group: v-model is a string[] of active keys,
|
|
34
|
+
* every item carries aria-pressed
|
|
35
|
+
*
|
|
36
|
+
* Every item is icon-only and rides one size taller than a standard
|
|
37
|
+
* icon button (md = 44px vs the 32px icon button / 40px text button) —
|
|
38
|
+
* the Windows/macOS-style login chooser. Because an icon alone is
|
|
39
|
+
* unlabelable at a glance, each item wraps itself in an HkTooltip
|
|
40
|
+
* (text = the option's label unless overridden), so the hover reveal is
|
|
41
|
+
* built in rather than left to every consumer.
|
|
42
|
+
*
|
|
43
|
+
* Known a11y limitation (documented 2026-09-13): single mode uses a
|
|
44
|
+
* radiogroup role with every item tabbable, not the full WAI-APG roving
|
|
45
|
+
* tabindex. All-items-tabbable keeps keyboard reachability identical
|
|
46
|
+
* across the three modes; roving focus is a follow-up if a host needs
|
|
47
|
+
* strict APG conformance.
|
|
48
|
+
*/
|
|
49
|
+
export default defineComponent({
|
|
50
|
+
name: "HkIconButtonGroup",
|
|
51
|
+
inheritAttrs: false,
|
|
52
|
+
props: {
|
|
53
|
+
options: { type: Array as unknown as () => HkIconButtonGroupOption[], required: true },
|
|
54
|
+
/** Active key ("single") or keys ("multiple"); null when nothing is
|
|
55
|
+
* active — required/ignored by "buttons" mode. */
|
|
56
|
+
modelValue: { type: [String, Array] as PropType<string | string[] | null>, default: null },
|
|
57
|
+
mode: { type: String as PropType<"buttons" | "single" | "multiple">, default: "buttons" },
|
|
58
|
+
size: { type: String as PropType<"sm" | "md" | "lg">, default: "md" },
|
|
59
|
+
disabled: { type: Boolean, default: false },
|
|
60
|
+
tooltipPlacement: { type: String as PropType<TooltipPlacement>, default: "top" },
|
|
61
|
+
tooltipDelay: { type: Number, default: 300 },
|
|
62
|
+
},
|
|
63
|
+
emits: {
|
|
64
|
+
/** v-model for "single" (string) and "multiple" (string[]). */
|
|
65
|
+
"update:modelValue": (_value: string | string[]) => true,
|
|
66
|
+
/** An item was activated (or toggled). Never fires for a no-op
|
|
67
|
+
* re-click of the already-active key in "single" mode. */
|
|
68
|
+
select: (_key: string) => true,
|
|
69
|
+
},
|
|
70
|
+
setup(props, { emit, attrs }) {
|
|
71
|
+
const cls = computed(() => [
|
|
72
|
+
"hk-icon-group",
|
|
73
|
+
`hk-icon-group-${props.mode}`,
|
|
74
|
+
`hk-icon-group-${props.size}`,
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
const groupRole = computed(() => (props.mode === "single" ? "radiogroup" : "group"));
|
|
78
|
+
|
|
79
|
+
function isActive(key: string): boolean {
|
|
80
|
+
if (props.mode === "multiple") {
|
|
81
|
+
return Array.isArray(props.modelValue) && props.modelValue.includes(key);
|
|
82
|
+
}
|
|
83
|
+
if (props.mode === "single") {
|
|
84
|
+
return props.modelValue === key;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function onSelect(option: HkIconButtonGroupOption) {
|
|
90
|
+
if (props.disabled || option.disabled) return;
|
|
91
|
+
if (props.mode === "single") {
|
|
92
|
+
if (isActive(option.key)) return; // radio semantics: no re-fire
|
|
93
|
+
emit("update:modelValue", option.key);
|
|
94
|
+
} else if (props.mode === "multiple") {
|
|
95
|
+
const current = Array.isArray(props.modelValue) ? props.modelValue : [];
|
|
96
|
+
const next = current.includes(option.key)
|
|
97
|
+
? current.filter((k) => k !== option.key)
|
|
98
|
+
: [...current, option.key];
|
|
99
|
+
emit("update:modelValue", next);
|
|
100
|
+
}
|
|
101
|
+
emit("select", option.key);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return () => (
|
|
105
|
+
<div class={cls.value} role={groupRole.value} {...attrs}>
|
|
106
|
+
{props.options.map((option) => {
|
|
107
|
+
const tooltip = option.tooltip !== undefined ? option.tooltip : option.label;
|
|
108
|
+
const active = isActive(option.key);
|
|
109
|
+
const button = (
|
|
110
|
+
<button
|
|
111
|
+
key={option.key}
|
|
112
|
+
type="button"
|
|
113
|
+
class="hk-icon-group-item"
|
|
114
|
+
data-key={option.key}
|
|
115
|
+
data-active={active || undefined}
|
|
116
|
+
role={props.mode === "single" ? "radio" : undefined}
|
|
117
|
+
aria-checked={props.mode === "single" ? active : undefined}
|
|
118
|
+
aria-pressed={props.mode === "multiple" ? active : undefined}
|
|
119
|
+
aria-label={option.label}
|
|
120
|
+
title={undefined} // the HkTooltip popup owns the hover text
|
|
121
|
+
disabled={props.disabled || option.disabled}
|
|
122
|
+
onClick={() => onSelect(option)}
|
|
123
|
+
>
|
|
124
|
+
<span class="hk-icon-group-item-icon">
|
|
125
|
+
{option.icon != null
|
|
126
|
+
? option.icon
|
|
127
|
+
: (
|
|
128
|
+
<span class="hk-icon-group-item-initial" aria-hidden="true">
|
|
129
|
+
{option.label.charAt(0).toUpperCase()}
|
|
130
|
+
</span>
|
|
131
|
+
)}
|
|
132
|
+
</span>
|
|
133
|
+
</button>
|
|
134
|
+
);
|
|
135
|
+
// An empty tooltip override suppresses the wrapper entirely.
|
|
136
|
+
return tooltip
|
|
137
|
+
? (
|
|
138
|
+
<HkTooltip
|
|
139
|
+
key={option.key}
|
|
140
|
+
text={tooltip}
|
|
141
|
+
placement={props.tooltipPlacement}
|
|
142
|
+
delay={props.tooltipDelay}
|
|
143
|
+
>
|
|
144
|
+
{button}
|
|
145
|
+
</HkTooltip>
|
|
146
|
+
)
|
|
147
|
+
: button;
|
|
148
|
+
})}
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
},
|
|
152
|
+
});
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { computed, defineComponent, onBeforeUnmount, onMounted, ref, Teleport, type CSSProperties, type PropType } from "vue";
|
|
2
2
|
import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
|
|
3
|
-
import {
|
|
3
|
+
import { tooltipPositionStyle, type TooltipPlacement } from "../runtime/tooltipPosition";
|
|
4
4
|
import "./HkTooltip.scss";
|
|
5
5
|
|
|
6
6
|
export default defineComponent({
|
|
7
7
|
name: "HkTooltip",
|
|
8
8
|
props: {
|
|
9
9
|
text: { type: String, required: true },
|
|
10
|
-
placement: { type: String as PropType<
|
|
10
|
+
placement: { type: String as PropType<TooltipPlacement>, default: "top" },
|
|
11
11
|
delay: { type: Number, default: 300 },
|
|
12
12
|
maxWidth: { type: String, default: undefined },
|
|
13
13
|
},
|
|
@@ -33,46 +33,11 @@ export default defineComponent({
|
|
|
33
33
|
function updatePosition() {
|
|
34
34
|
if (!wrapperRef.value) return;
|
|
35
35
|
const rect = wrapperRef.value.getBoundingClientRect();
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
// rect values must be written divided by the cumulative zoom or
|
|
42
|
-
// the tooltip drifts zoom× off its trigger (chest's root-level
|
|
43
|
-
// manual DPI scale).
|
|
44
|
-
const z = ancestorZoom(document.body);
|
|
45
|
-
const local = (v: number) => `${v / z}px`;
|
|
46
|
-
const style: CSSProperties = {};
|
|
47
|
-
|
|
48
|
-
if (props.maxWidth) {
|
|
49
|
-
style.maxWidth = props.maxWidth;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
switch (props.placement) {
|
|
53
|
-
case "top":
|
|
54
|
-
style.top = local(rect.top - gap);
|
|
55
|
-
style.left = local(rect.left + rect.width / 2);
|
|
56
|
-
style.transform = "translate(-50%, -100%)";
|
|
57
|
-
break;
|
|
58
|
-
case "bottom":
|
|
59
|
-
style.top = local(rect.bottom + gap);
|
|
60
|
-
style.left = local(rect.left + rect.width / 2);
|
|
61
|
-
style.transform = "translate(-50%, 0)";
|
|
62
|
-
break;
|
|
63
|
-
case "left":
|
|
64
|
-
style.top = local(rect.top + rect.height / 2);
|
|
65
|
-
style.left = local(rect.left - gap);
|
|
66
|
-
style.transform = "translate(-100%, -50%)";
|
|
67
|
-
break;
|
|
68
|
-
case "right":
|
|
69
|
-
style.top = local(rect.top + rect.height / 2);
|
|
70
|
-
style.left = local(rect.right + gap);
|
|
71
|
-
style.transform = "translate(0, -50%)";
|
|
72
|
-
break;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
tooltipStyle.value = style;
|
|
36
|
+
// Placement geometry lives in the shared runtime helper (it also
|
|
37
|
+
// serves the document-level tooltip bridge) — including the
|
|
38
|
+
// ancestor-zoom division that keeps teleported popups pinned to
|
|
39
|
+
// their trigger inside scaled roots.
|
|
40
|
+
tooltipStyle.value = tooltipPositionStyle(rect, props.placement, props.maxWidth);
|
|
76
41
|
}
|
|
77
42
|
|
|
78
43
|
function show() {
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ export { default as HExpansionPanel } from "./components/HkExpansionPanel";
|
|
|
24
24
|
export { default as HFab, type HFabAction } from "./components/HkFab";
|
|
25
25
|
export { default as HIcon } from "./components/HkIcon";
|
|
26
26
|
export { default as HIconButton } from "./components/HkIconButton";
|
|
27
|
+
export { default as HIconButtonGroup, type HkIconButtonGroupOption } from "./components/HkIconButtonGroup";
|
|
27
28
|
export { default as HImageLightbox } from "./components/HkImageLightbox";
|
|
28
29
|
export { default as HImagePreview, type ImagePreviewObjectFit } from "./components/HkImagePreview";
|
|
29
30
|
export { default as HInput } from "./components/HkInput";
|
|
@@ -239,6 +240,10 @@ export {
|
|
|
239
240
|
type SafeAreaInsets,
|
|
240
241
|
type BackGuard,
|
|
241
242
|
type BackGuardOptions,
|
|
243
|
+
installHkTooltipBridge,
|
|
244
|
+
type HkTooltipBridgeOptions,
|
|
245
|
+
installHkImageFallback,
|
|
246
|
+
type HkImageFallbackOptions,
|
|
242
247
|
} from "./runtime";
|
|
243
248
|
|
|
244
249
|
// i18n
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/* installHkImageFallback — the `.hk-img-broken` treatment.
|
|
2
|
+
*
|
|
3
|
+
* The bundled placeholder SVG carries its own muted tile + glyph; this
|
|
4
|
+
* sheet only keeps the img box polite while it shows the placeholder
|
|
5
|
+
* (never stretches it, faint surface behind transparent areas) and
|
|
6
|
+
* neutralizes any fancy borders a consumer put on the healthy image. */
|
|
7
|
+
|
|
8
|
+
img.hk-img-broken {
|
|
9
|
+
object-fit: contain;
|
|
10
|
+
background: rgb(var(--color-surface, 255 255 255) / 30%);
|
|
11
|
+
border-radius: var(--radius-sm, 4px);
|
|
12
|
+
box-shadow: none;
|
|
13
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { installHkImageFallback } from "./imageFallback";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* installHkImageFallback contract tests:
|
|
7
|
+
* - a broken <img> (capture-phase error) gains `.hk-img-broken` and its
|
|
8
|
+
* src swaps to the bundled inline-SVG placeholder, exactly once
|
|
9
|
+
* (data flag — a failing fallback can never loop)
|
|
10
|
+
* - a load of REAL content after a breakage clears the state; the load
|
|
11
|
+
* OF the placeholder keeps it
|
|
12
|
+
* - escape hatches: [data-hk-img-native], selector/exclude options
|
|
13
|
+
* - custom fallbackSrc override; `""` marks without swapping
|
|
14
|
+
* - idempotent install; uninstall stops further marking
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const uninstalls: Array<() => void> = [];
|
|
18
|
+
|
|
19
|
+
function install(options?: Parameters<typeof installHkImageFallback>[0]) {
|
|
20
|
+
const uninstall = installHkImageFallback(options);
|
|
21
|
+
uninstalls.push(uninstall);
|
|
22
|
+
return uninstall;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function img(src = "https://example.invalid/pic.png"): HTMLImageElement {
|
|
26
|
+
const el = document.createElement("img");
|
|
27
|
+
el.src = src;
|
|
28
|
+
document.body.appendChild(el);
|
|
29
|
+
return el;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fail(el: HTMLImageElement) {
|
|
33
|
+
el.dispatchEvent(new Event("error"));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function load(el: HTMLImageElement) {
|
|
37
|
+
el.dispatchEvent(new Event("load"));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
for (const u of uninstalls.splice(0)) u();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("installHkImageFallback", () => {
|
|
45
|
+
it("marks a broken image and swaps in the placeholder once", () => {
|
|
46
|
+
install();
|
|
47
|
+
const el = img();
|
|
48
|
+
fail(el);
|
|
49
|
+
expect(el.classList.contains("hk-img-broken")).toBe(true);
|
|
50
|
+
expect(el.dataset.hkImgFallback).toBe("1");
|
|
51
|
+
expect(el.getAttribute("src")?.startsWith("data:image/svg+xml,")).toBe(true);
|
|
52
|
+
const swapped = el.getAttribute("src")!;
|
|
53
|
+
// The placeholder's own failure must not loop.
|
|
54
|
+
fail(el);
|
|
55
|
+
expect(el.getAttribute("src")).toBe(swapped);
|
|
56
|
+
expect(el.classList.contains("hk-img-broken")).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("clears the broken state when real content loads later", () => {
|
|
60
|
+
install();
|
|
61
|
+
const el = img();
|
|
62
|
+
fail(el);
|
|
63
|
+
expect(el.classList.contains("hk-img-broken")).toBe(true);
|
|
64
|
+
// A repaired src loads: recovery clears class AND the one-shot flag.
|
|
65
|
+
el.setAttribute("src", "https://example.invalid/fixed.png");
|
|
66
|
+
load(el);
|
|
67
|
+
expect(el.classList.contains("hk-img-broken")).toBe(false);
|
|
68
|
+
expect(el.dataset.hkImgFallback).toBeUndefined();
|
|
69
|
+
// So a SECOND breakage marks again.
|
|
70
|
+
fail(el);
|
|
71
|
+
expect(el.classList.contains("hk-img-broken")).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("keeps the treatment when the placeholder itself loads", () => {
|
|
75
|
+
install();
|
|
76
|
+
const el = img();
|
|
77
|
+
fail(el);
|
|
78
|
+
const placeholder = el.getAttribute("src")!;
|
|
79
|
+
load(el);
|
|
80
|
+
expect(el.getAttribute("src")).toBe(placeholder);
|
|
81
|
+
expect(el.classList.contains("hk-img-broken")).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("leaves native-escape-hatch images alone", () => {
|
|
85
|
+
install();
|
|
86
|
+
const el = img();
|
|
87
|
+
el.setAttribute("data-hk-img-native", "");
|
|
88
|
+
fail(el);
|
|
89
|
+
expect(el.classList.contains("hk-img-broken")).toBe(false);
|
|
90
|
+
expect(el.getAttribute("src")).toBe("https://example.invalid/pic.png");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("honors selector and exclude options", () => {
|
|
94
|
+
install({ selector: "img.content", exclude: ".keep" });
|
|
95
|
+
const wrong = img();
|
|
96
|
+
fail(wrong);
|
|
97
|
+
expect(wrong.classList.contains("hk-img-broken")).toBe(false);
|
|
98
|
+
const kept = img();
|
|
99
|
+
kept.className = "content keep";
|
|
100
|
+
fail(kept);
|
|
101
|
+
expect(kept.classList.contains("hk-img-broken")).toBe(false);
|
|
102
|
+
const covered = img();
|
|
103
|
+
covered.className = "content";
|
|
104
|
+
fail(covered);
|
|
105
|
+
expect(covered.classList.contains("hk-img-broken")).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("supports a custom fallbackSrc override", () => {
|
|
109
|
+
install({ fallbackSrc: "data:image/svg+xml,custom" });
|
|
110
|
+
const custom = img();
|
|
111
|
+
fail(custom);
|
|
112
|
+
expect(custom.getAttribute("src")).toBe("data:image/svg+xml,custom");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("supports an empty fallbackSrc that marks without swapping", () => {
|
|
116
|
+
install({ fallbackSrc: "" });
|
|
117
|
+
const marked = img();
|
|
118
|
+
fail(marked);
|
|
119
|
+
expect(marked.classList.contains("hk-img-broken")).toBe(true);
|
|
120
|
+
expect(marked.getAttribute("src")).toBe("https://example.invalid/pic.png");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("keeps the one-shot marker across repeat errors even without a src swap", () => {
|
|
124
|
+
// Mark-only mode: no src to dedupe against, so the dataset flag is the
|
|
125
|
+
// only once-guard — pin it (repeat errors must stay marked, and the
|
|
126
|
+
// flag must exist for the recovery path to clear).
|
|
127
|
+
install({ fallbackSrc: "" });
|
|
128
|
+
const el = img();
|
|
129
|
+
fail(el);
|
|
130
|
+
expect(el.classList.contains("hk-img-broken")).toBe(true);
|
|
131
|
+
expect(el.dataset.hkImgFallback).toBe("1");
|
|
132
|
+
fail(el);
|
|
133
|
+
fail(el);
|
|
134
|
+
expect(el.dataset.hkImgFallback).toBe("1");
|
|
135
|
+
expect(el.classList.contains("hk-img-broken")).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("is idempotent per document and uninstallable", () => {
|
|
139
|
+
install();
|
|
140
|
+
install(); // duplicate: silently reuses the first bridge
|
|
141
|
+
const el = img();
|
|
142
|
+
fail(el);
|
|
143
|
+
expect(el.dataset.hkImgFallback).toBe("1");
|
|
144
|
+
uninstalls.splice(0)[0]!();
|
|
145
|
+
const after = img();
|
|
146
|
+
fail(after);
|
|
147
|
+
expect(after.classList.contains("hk-img-broken")).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|