@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.279",
3
+ "version": "0.55.280",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -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<"top-right" | "top-left" | "bottom-right" | "bottom-left">,
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
- {tab.label && <span>{tab.label}</span>}
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
  });