@celestia-island/hikari 0.55.279 → 0.55.280
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
CHANGED
|
@@ -13,6 +13,12 @@ export type CornerBadgeVariant =
|
|
|
13
13
|
| "info"
|
|
14
14
|
| "muted";
|
|
15
15
|
|
|
16
|
+
/** Which corner of the anchor the mark hangs on. Exported so hosts that
|
|
17
|
+
* embed the shell (HkTabs' trigger badge) share the one vocabulary
|
|
18
|
+
* instead of re-spelling the union; HkIconButton's dot borrows the tone
|
|
19
|
+
* channels but is an inset span of its own, not a corner mark. */
|
|
20
|
+
export type CornerBadgeCorner = "top-right" | "top-left" | "bottom-right" | "bottom-left";
|
|
21
|
+
|
|
16
22
|
/**
|
|
17
23
|
* HkCornerBadge — the generic corner mark for ANY anchor (user
|
|
18
24
|
* direction 2026-10-15): a green/red/traffic dot, a notification count
|
|
@@ -44,7 +50,7 @@ export default defineComponent({
|
|
|
44
50
|
text: { type: String, default: undefined },
|
|
45
51
|
/** Which corner of the anchor. */
|
|
46
52
|
corner: {
|
|
47
|
-
type: String as PropType<
|
|
53
|
+
type: String as PropType<CornerBadgeCorner>,
|
|
48
54
|
default: "top-right",
|
|
49
55
|
},
|
|
50
56
|
/** Extra clearance from the corner in px (0 = sit ON the corner,
|
|
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
6
6
|
|
|
7
7
|
import HkTabs from "./HkTabs";
|
|
8
|
+
import type { TabItem } from "./HkTabs";
|
|
8
9
|
import { CROSSFADE_WATCHDOG_MS } from "./HkCrossfade";
|
|
9
10
|
import { DEFAULT_ANIMATION_DURATION_MS } from "../composables/useAnimationLoop";
|
|
10
11
|
|
|
@@ -661,3 +662,111 @@ describe("HkTabs panel morph (HkTransitionContainer consumption)", () => {
|
|
|
661
662
|
expect(container.querySelector(".hk-tabs-panel")).toBeNull();
|
|
662
663
|
});
|
|
663
664
|
});
|
|
665
|
+
|
|
666
|
+
describe("HkTabs trigger corner badge", () => {
|
|
667
|
+
/** Mount a two-tab strip with an optional badge on the first option. */
|
|
668
|
+
function mountBadge(badge: TabItem["badge"]): { container: HTMLElement } {
|
|
669
|
+
const active = ref("a");
|
|
670
|
+
const container = document.createElement("div");
|
|
671
|
+
document.body.appendChild(container);
|
|
672
|
+
containers.push(container);
|
|
673
|
+
const Wrapper = defineComponent({
|
|
674
|
+
setup() {
|
|
675
|
+
return () =>
|
|
676
|
+
h(HkTabs, {
|
|
677
|
+
variant: "segmented",
|
|
678
|
+
tabs: [
|
|
679
|
+
{ key: "a", label: "Alpha", badge },
|
|
680
|
+
{ key: "b", label: "Beta" },
|
|
681
|
+
],
|
|
682
|
+
renderPanels: false,
|
|
683
|
+
modelValue: active.value,
|
|
684
|
+
"onUpdate:modelValue": (v: string) => { active.value = v; },
|
|
685
|
+
});
|
|
686
|
+
},
|
|
687
|
+
});
|
|
688
|
+
const app = createApp(Wrapper);
|
|
689
|
+
mounts.push(app);
|
|
690
|
+
app.mount(container);
|
|
691
|
+
return { container };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
it("renders the count through HkCornerBadge inside the trigger (no hand-rolled suffix)", () => {
|
|
695
|
+
const { container } = mountBadge({ count: 3, label: "3 items" });
|
|
696
|
+
const trig = container.querySelectorAll<HTMLElement>(".hk-tabs-trigger")[0]!;
|
|
697
|
+
// The mark is the family's corner-badge pill, anchored bottom-right
|
|
698
|
+
// (the subscript corner) of the label, not a "(3)" text suffix.
|
|
699
|
+
const mark = trig.querySelector<HTMLElement>(".hk-corner-badge--pill")!;
|
|
700
|
+
expect(mark).not.toBeNull();
|
|
701
|
+
expect(mark.textContent).toBe("3");
|
|
702
|
+
expect(mark.dataset.corner).toBe("bottom-right");
|
|
703
|
+
expect(trig.textContent).not.toContain("(3)");
|
|
704
|
+
// Default clearance: the straddling pill must stay inside the
|
|
705
|
+
// scroller's 6px vertical clearance below the label (see
|
|
706
|
+
// TabItem.badge.offset) — the offset var pins that contract.
|
|
707
|
+
expect(mark.style.getPropertyValue("--hk-corner-badge-offset")).toBe("4px");
|
|
708
|
+
// The sibling trigger stays badge-free.
|
|
709
|
+
expect(container.querySelectorAll<HTMLElement>(".hk-tabs-trigger")[1]!.querySelector(".hk-corner-badge")).toBeNull();
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it("keeps zero visible (presence count, not notification noise)", () => {
|
|
713
|
+
const { container } = mountBadge({ count: 0, label: "0 items" });
|
|
714
|
+
const mark = container.querySelector<HTMLElement>(".hk-corner-badge--pill")!;
|
|
715
|
+
expect(mark.textContent).toBe("0");
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it("collapses past max to the N+ form and names the mark for assistive tech", () => {
|
|
719
|
+
const { container } = mountBadge({ count: 120, max: 99, label: "120 pending" });
|
|
720
|
+
const mark = container.querySelector<HTMLElement>(".hk-corner-badge--pill")!;
|
|
721
|
+
expect(mark.textContent).toBe("99+");
|
|
722
|
+
expect(mark.getAttribute("role")).toBe("img");
|
|
723
|
+
expect(mark.getAttribute("aria-label")).toBe("120 pending");
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it("follows a live count change without a remount (presence stays current)", async () => {
|
|
727
|
+
const active = ref("a");
|
|
728
|
+
const tabs = ref<TabItem[]>([{ key: "a", label: "Alpha", badge: { count: 1, label: "1 item" } }]);
|
|
729
|
+
const container = document.createElement("div");
|
|
730
|
+
document.body.appendChild(container);
|
|
731
|
+
containers.push(container);
|
|
732
|
+
const Wrapper = defineComponent({
|
|
733
|
+
setup() {
|
|
734
|
+
return () =>
|
|
735
|
+
h(HkTabs, {
|
|
736
|
+
variant: "segmented",
|
|
737
|
+
tabs: tabs.value,
|
|
738
|
+
renderPanels: false,
|
|
739
|
+
modelValue: active.value,
|
|
740
|
+
"onUpdate:modelValue": (v: string) => { active.value = v; },
|
|
741
|
+
});
|
|
742
|
+
},
|
|
743
|
+
});
|
|
744
|
+
const app = createApp(Wrapper);
|
|
745
|
+
mounts.push(app);
|
|
746
|
+
app.mount(container);
|
|
747
|
+
const mark = () => container.querySelector<HTMLElement>(".hk-corner-badge--pill")!;
|
|
748
|
+
expect(mark().textContent).toBe("1");
|
|
749
|
+
tabs.value = [{ key: "a", label: "Alpha", badge: { count: 12, label: "12 items" } }];
|
|
750
|
+
await nextTick();
|
|
751
|
+
expect(mark().textContent).toBe("12");
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it("warns in DEV when a badge lacks an accessible label", () => {
|
|
755
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
756
|
+
try {
|
|
757
|
+
mountBadge({ count: 3 });
|
|
758
|
+
const alarms = warn.mock.calls.filter((c) => String(c[0]).includes("badge without a label"));
|
|
759
|
+
expect(alarms.length, "the a11y drift alarm must fire").toBeGreaterThan(0);
|
|
760
|
+
expect(alarms[0]![1]).toBe("a");
|
|
761
|
+
} finally {
|
|
762
|
+
warn.mockRestore();
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it("leaves badgeless triggers free of the badge anchor (plain label span render)", () => {
|
|
767
|
+
const { container } = mountBadge(undefined);
|
|
768
|
+
expect(container.querySelector(".hk-corner-badge-anchor")).toBeNull();
|
|
769
|
+
const label = container.querySelectorAll<HTMLElement>(".hk-tabs-trigger")[0]!.querySelector("span")!;
|
|
770
|
+
expect(label.textContent).toBe("Alpha");
|
|
771
|
+
});
|
|
772
|
+
});
|
|
@@ -5,6 +5,8 @@ import HkScrollContainer from "./HkScrollContainer";
|
|
|
5
5
|
import HkHoverRevealAction from "./HkHoverRevealAction";
|
|
6
6
|
import HkIconButton from "./HkIconButton";
|
|
7
7
|
import HkTransitionContainer from "./HkTransitionContainer";
|
|
8
|
+
import HkCornerBadge from "./HkCornerBadge";
|
|
9
|
+
import type { CornerBadgeCorner, CornerBadgeVariant } from "./HkCornerBadge";
|
|
8
10
|
import { iconByName } from "../composables/iconRegistry";
|
|
9
11
|
import { useI18n } from "../i18n/context";
|
|
10
12
|
import { clearLeaveGeometry, pinLeaveGeometry, type LeaveBoxSnapshot } from "../utils/dom";
|
|
@@ -18,6 +20,39 @@ export interface TabItem {
|
|
|
18
20
|
/** Optional icon vnode rendered before the label (the `icon-${key}`
|
|
19
21
|
* slot wins when provided). */
|
|
20
22
|
icon?: unknown;
|
|
23
|
+
/** Corner-count badge rendered on the trigger through HkCornerBadge —
|
|
24
|
+
* the ONE sup/sub decoration shell (user direction 2026-11-05:
|
|
25
|
+
* hand-rolled "(n)" count suffixes baked into labels are being
|
|
26
|
+
* replaced by the standard corner mark, and triggers are badge hosts
|
|
27
|
+
* like every other button). Zero stays visible: a tab count is
|
|
28
|
+
* presence information (the "(0)" the inline suffix carried), not
|
|
29
|
+
* notification noise — pass no badge to hide it entirely. The badge
|
|
30
|
+
* anchors the LABEL, so a tab with an empty label renders no badge
|
|
31
|
+
* either; pass `label` (an accessible name like "{n} active") or the
|
|
32
|
+
* mark is aria-hidden decoration and the count is announced nowhere
|
|
33
|
+
* (a DEV warning fires — a silent a11y regression against the inline
|
|
34
|
+
* suffix it replaces must at least be loud). */
|
|
35
|
+
badge?: {
|
|
36
|
+
count: number;
|
|
37
|
+
/** "N+" ceiling (HkCornerBadge.max). */
|
|
38
|
+
max?: number;
|
|
39
|
+
/** Which corner of the LABEL the mark hangs on — the subscript
|
|
40
|
+
* corner (bottom-right) by default, per the same direction. */
|
|
41
|
+
corner?: CornerBadgeCorner;
|
|
42
|
+
/** Tone channel (HkCornerBadge.variant names, default "primary"). */
|
|
43
|
+
variant?: CornerBadgeVariant;
|
|
44
|
+
/** Corner clearance in px (HkCornerBadge.offset). Default 4: the
|
|
45
|
+
* pill protrudes half its 18px height past the label box, and the
|
|
46
|
+
* strip's vertical clearance below the label is trigger padding
|
|
47
|
+
* (4px) + list padding (2px) = 6px before the scroller viewport
|
|
48
|
+
* clips — offset 4 keeps the protrusion at 5px, inside that
|
|
49
|
+
* clearance. 0 sits ON the corner and clips ~3px on the default
|
|
50
|
+
* scrollable strip; raise it if your host adds no extra padding. */
|
|
51
|
+
offset?: number;
|
|
52
|
+
/** Accessible name for the mark — strongly expected (DEV-warned
|
|
53
|
+
* when missing). */
|
|
54
|
+
label?: string;
|
|
55
|
+
};
|
|
21
56
|
disabled?: boolean;
|
|
22
57
|
/**
|
|
23
58
|
* Slot-unit width for block (row-filling) strips: how many equal
|
|
@@ -202,6 +237,22 @@ export default defineComponent({
|
|
|
202
237
|
props.tabs.map((tb) => tb.key).join(", "),
|
|
203
238
|
);
|
|
204
239
|
}
|
|
240
|
+
if (import.meta.env.DEV) {
|
|
241
|
+
// A badge the strip renders as aria-hidden decoration is a
|
|
242
|
+
// SILENT a11y regression against the inline "(n)" suffix it
|
|
243
|
+
// replaces — the count was part of the accessible name before
|
|
244
|
+
// and is announced nowhere now. Same tier as the duplicate-key
|
|
245
|
+
// drift alarm above.
|
|
246
|
+
for (const tb of props.tabs) {
|
|
247
|
+
if (tb.badge && !tb.badge.label) {
|
|
248
|
+
// eslint-disable-next-line no-console -- dev-only drift alarm
|
|
249
|
+
console.warn(
|
|
250
|
+
"[HkTabs] tab badge without a label is aria-hidden — pass badge.label so the count is announced:",
|
|
251
|
+
tb.key,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
205
256
|
return list;
|
|
206
257
|
});
|
|
207
258
|
/** Merged keys actually present in `tabs`, in tab order. */
|
|
@@ -546,7 +597,28 @@ export default defineComponent({
|
|
|
546
597
|
}}
|
|
547
598
|
>
|
|
548
599
|
{icon}
|
|
549
|
-
{
|
|
600
|
+
{/* The badge anchors the LABEL (inside the trigger's own
|
|
601
|
+
* padding box) rather than the button box: the mark reads
|
|
602
|
+
* as part of the control's silhouette (the HkIconButton
|
|
603
|
+
* dot's 2026-10-28 lesson, count form). Clipping is kept
|
|
604
|
+
* honest by the offset default: the pill straddles the
|
|
605
|
+
* label's corner and the scroller viewport clips at
|
|
606
|
+
* 6px below the label — see TabItem.badge.offset. */}
|
|
607
|
+
{tab.label && (tab.badge ? (
|
|
608
|
+
<HkCornerBadge
|
|
609
|
+
count={tab.badge.count}
|
|
610
|
+
max={tab.badge.max}
|
|
611
|
+
showZero
|
|
612
|
+
corner={tab.badge.corner ?? "bottom-right"}
|
|
613
|
+
offset={tab.badge.offset ?? 4}
|
|
614
|
+
variant={tab.badge.variant ?? "primary"}
|
|
615
|
+
label={tab.badge.label}
|
|
616
|
+
>
|
|
617
|
+
<span>{tab.label}</span>
|
|
618
|
+
</HkCornerBadge>
|
|
619
|
+
) : (
|
|
620
|
+
<span>{tab.label}</span>
|
|
621
|
+
))}
|
|
550
622
|
</button>,
|
|
551
623
|
);
|
|
552
624
|
});
|