@celestia-island/hikari 0.55.125 → 0.55.126

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.125",
3
+ "version": "0.55.126",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -161,7 +161,18 @@
161
161
  padding: var(--space-4, 4px);
162
162
  }
163
163
 
164
+ // The group is a role="group" WRAPPER carrying the accessible name in
165
+ // aria-label; the visible heading text is the inner label band (hidden
166
+ // from the a11y tree — the wrapper already announced the name).
164
167
  .hk-tpl-group {
168
+ // Flex like the sibling rows list, so the parent's row gap keeps
169
+ // applying INSIDE the wrapper (a bare block would drop it to 0).
170
+ display: flex;
171
+ flex-direction: column;
172
+ gap: 2px;
173
+ }
174
+
175
+ .hk-tpl-group-label {
165
176
  padding: var(--space-6, 6px) var(--space-8, 8px) var(--space-2, 2px);
166
177
  font-size: var(--text-2xs, 0.625rem);
167
178
  font-weight: 600;
@@ -92,6 +92,14 @@ describe("HkTemplateField stylesheet contract", () => {
92
92
  }
93
93
  });
94
94
 
95
+ it("keeps the heading band on the label class, not the group wrapper", () => {
96
+ // The wrapper is a bare layout box; carrying the band's chrome
97
+ // (padding/font-weight) there would pad every row run.
98
+ expect(css).toMatch(/\.hk-tpl-group-label\s*\{[^}]*font-weight:\s*600/s);
99
+ expect(css).not.toMatch(/\.hk-tpl-group\s*\{[^}]*font-weight/s);
100
+ expect(css).not.toMatch(/\.hk-tpl-group\s*\{[^}]*padding/s);
101
+ });
102
+
95
103
  it("keeps group headings free of text-transform (the host owns casing)", () => {
96
104
  // R2 M13: re-adding `text-transform: uppercase` used to stay green.
97
105
  expect(css).not.toMatch(/\.hk-tpl-group[^{]*\{[^}]*text-transform/s);
@@ -415,30 +415,44 @@ describe("HkTemplateField grouped vocabulary", () => {
415
415
  { name: "sha256_email", group: "email" },
416
416
  ];
417
417
 
418
- it("renders one heading per group run in the panel", async () => {
418
+ it("renders one labeled group wrapper per run in the panel", async () => {
419
419
  const el = mountWithTokens(GROUPED);
420
420
  await nextTick();
421
421
  await openTrigger(el, "{{ ");
422
- const headings = Array.from(document.body.querySelectorAll<HTMLElement>(".hk-tpl-group"));
423
- expect(headings.map((h) => h.textContent)).toEqual(["identity", "email"]);
424
- // Headings are presentational, not options.
425
- expect(headings.every((h) => h.getAttribute("role") === "presentation")).toBe(true);
422
+ const groups = Array.from(document.body.querySelectorAll<HTMLElement>(".hk-tpl-group"));
423
+ // The accessible name rides on the wrapper (announced on entry);
424
+ // the visible text lives in the inner label band.
425
+ expect(groups.map((g) => g.getAttribute("aria-label"))).toEqual(["identity", "email"]);
426
+ expect(groups.every((g) => g.getAttribute("role") === "group")).toBe(true);
427
+ const labels = Array.from(document.body.querySelectorAll<HTMLElement>(".hk-tpl-group-label"));
428
+ expect(labels.map((l) => l.textContent)).toEqual(["identity", "email"]);
429
+ // The visible band is hidden from the a11y tree — the wrapper's
430
+ // aria-label already announced the name (no double reading).
431
+ expect(labels.every((l) => l.getAttribute("aria-hidden") === "true")).toBe(true);
432
+ // Rows ride INSIDE their group wrapper.
433
+ expect(groups[0]!.querySelectorAll(".hk-tpl-row")).toHaveLength(2);
434
+ expect(groups[1]!.querySelectorAll(".hk-tpl-row")).toHaveLength(2);
426
435
  });
427
436
 
428
437
  it("maps group keys through groupLabel", async () => {
429
438
  const el = mountWithTokens(GROUPED, { groupLabel: (g) => g.toUpperCase() });
430
439
  await nextTick();
431
440
  await openTrigger(el, "{{ ");
432
- const headings = Array.from(document.body.querySelectorAll<HTMLElement>(".hk-tpl-group"));
433
- expect(headings.map((h) => h.textContent)).toEqual(["IDENTITY", "EMAIL"]);
441
+ const groups = Array.from(document.body.querySelectorAll<HTMLElement>(".hk-tpl-group"));
442
+ expect(groups.map((g) => g.getAttribute("aria-label"))).toEqual(["IDENTITY", "EMAIL"]);
443
+ expect(groups.map((g) => g.querySelector(".hk-tpl-group-label")?.textContent)).toEqual([
444
+ "IDENTITY",
445
+ "EMAIL",
446
+ ]);
434
447
  });
435
448
 
436
449
  it("filters groups out with their members (no stray heading)", async () => {
437
450
  const el = mountWithTokens(GROUPED);
438
451
  await nextTick();
439
452
  await openTrigger(el, "{{ md5");
440
- const headings = Array.from(document.body.querySelectorAll<HTMLElement>(".hk-tpl-group"));
441
- expect(headings.map((h) => h.textContent)).toEqual(["email"]);
453
+ const groups = Array.from(document.body.querySelectorAll<HTMLElement>(".hk-tpl-group"));
454
+ expect(groups.map((g) => g.getAttribute("aria-label"))).toEqual(["email"]);
455
+ expect(groups[0]!.querySelectorAll(".hk-tpl-row")).toHaveLength(1);
442
456
  });
443
457
 
444
458
  it("ungrouped vocabularies render exactly as before (no headings)", async () => {
@@ -454,10 +468,10 @@ describe("HkTemplateField grouped vocabulary", () => {
454
468
  await nextTick();
455
469
  editable(el).querySelector<HTMLElement>(".hk-tpl-chip")!.click();
456
470
  await nextTick();
457
- const headings = Array.from(
471
+ const groups = Array.from(
458
472
  document.body.querySelectorAll<HTMLElement>(".hk-tpl-editor .hk-tpl-group"),
459
473
  );
460
- expect(headings.map((h) => h.textContent)).toEqual(["identity", "email"]);
474
+ expect(groups.map((g) => g.getAttribute("aria-label"))).toEqual(["identity", "email"]);
461
475
  });
462
476
  });
463
477
 
@@ -647,10 +661,53 @@ describe("HkTemplateField partially-grouped vocabulary", () => {
647
661
  ]);
648
662
  await nextTick();
649
663
  await openTrigger(el, "{{ ");
650
- const headings = Array.from(document.body.querySelectorAll<HTMLElement>(".hk-tpl-group"));
651
- // Two headings for two group STARTS; the trailing ungrouped tokens
652
- // stay under the last heading (see the groupLabel prop doc).
653
- expect(headings.map((h) => h.textContent)).toEqual(["identity", "email"]);
664
+ const groups = Array.from(document.body.querySelectorAll<HTMLElement>(".hk-tpl-group"));
665
+ // Two wrappers for two group runs; the ungrouped tokens ride inside
666
+ // the open run (see the groupLabel prop doc) — free_a/b inside
667
+ // identity, free_c inside email.
668
+ expect(groups.map((g) => g.getAttribute("aria-label"))).toEqual(["identity", "email"]);
669
+ expect(groups[0]!.querySelectorAll(".hk-tpl-row")).toHaveLength(3);
670
+ expect(groups[1]!.querySelectorAll(".hk-tpl-row")).toHaveLength(2);
654
671
  expect(document.body.querySelectorAll(".hk-tpl-row")).toHaveLength(5);
655
672
  });
656
673
  });
674
+
675
+ describe("HkTemplateField vocabulary semantics guards", () => {
676
+ it("the rows containers carry no listbox role (menu/dialog context)", async () => {
677
+ const el = mountWithTokens([
678
+ { name: "id", group: "identity" },
679
+ { name: "md5_email", group: "email" },
680
+ ]);
681
+ await nextTick();
682
+ await openTrigger(el, "{{ ");
683
+ const panelRows = document.body.querySelector<HTMLElement>(".hk-tpl-rows")!;
684
+ expect(panelRows.getAttribute("role")).toBeNull();
685
+
686
+ const el2 = mount({ modelValue: "a {{ username }} b" });
687
+ await nextTick();
688
+ editable(el2).querySelector<HTMLElement>(".hk-tpl-chip")!.click();
689
+ await nextTick();
690
+ const editorRows = document.body.querySelector<HTMLElement>(".hk-tpl-editor-rows")!;
691
+ expect(editorRows.getAttribute("role")).toBeNull();
692
+ });
693
+
694
+ it("the panel's active highlight follows the filtered index inside wrappers", async () => {
695
+ const el = mountWithTokens([
696
+ { name: "id", group: "identity" },
697
+ { name: "username", group: "identity" },
698
+ { name: "md5_email", group: "email" },
699
+ { name: "sha256_email", group: "email" },
700
+ ]);
701
+ await nextTick();
702
+ await openTrigger(el, "{{ ");
703
+ editable(el).dispatchEvent(
704
+ new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }),
705
+ );
706
+ await nextTick();
707
+ const active = Array.from(document.body.querySelectorAll<HTMLElement>(".hk-tpl-row[data-active]"));
708
+ expect(active).toHaveLength(1);
709
+ // Index 1 across the WHOLE filtered list (username) — the highlight
710
+ // must not restart inside each group wrapper.
711
+ expect(active[0]!.textContent).toContain("username");
712
+ });
713
+ });
@@ -28,6 +28,12 @@ import {
28
28
  } from "./templateGrammar";
29
29
  import "./HkTemplateField.scss";
30
30
 
31
+ /** What renderGroupedRows hands its row callback. */
32
+ interface HkTemplateFieldRow {
33
+ def: HkTemplateTokenDef;
34
+ index: number;
35
+ }
36
+
31
37
  /** Matches an UNFINISHED `{{ query` the caret is typing inside — the
32
38
  * autocomplete trigger. Identifiers only; anything else (a filter pipe,
33
39
  * a space) drops out of trigger state and closes the panel. */
@@ -875,34 +881,48 @@ export const HkTemplateField = defineComponent({
875
881
  };
876
882
  }
877
883
 
878
- /** Rows with non-interactive group headings: a heading lands
879
- * wherever the (filtered) vocabulary's group changes, so a family
880
- * whose every member was filtered out never shows a stray header.
881
- * Vocabulary order is the contract — consumers list each group's
882
- * tokens contiguously. */
884
+ /** Grouped rows as labeled WRAPPERS, not sibling headings: a
885
+ * `role="group"` box carrying the group's name in `aria-label` is
886
+ * the pattern screen readers announce on entry (a bare
887
+ * role="presentation" heading band was invisible to them — the
888
+ * R3 P3 finding), and it is the sanctioned way to segment choices
889
+ * inside HkMenu's role="menu" surface. The visible heading text
890
+ * lives in the inner .hk-tpl-group-label band. */
883
891
  function renderGroupedRows(
884
892
  defs: readonly HkTemplateTokenDef[],
885
- renderRow: (def: HkTemplateTokenDef, index: number) => unknown,
893
+ renderRow: (row: HkTemplateFieldRow) => unknown,
886
894
  ): unknown[] {
887
895
  const out: unknown[] = [];
888
- let lastGroup: string | undefined;
896
+ let run: { label: string; rows: unknown[] } | null = null;
897
+ const flush = () => {
898
+ if (!run) return;
899
+ out.push(
900
+ <div class="hk-tpl-group" role="group" aria-label={run.label}>
901
+ <div class="hk-tpl-group-label" aria-hidden="true">{run.label}</div>
902
+ {run.rows}
903
+ </div>,
904
+ );
905
+ run = null;
906
+ };
889
907
  for (let i = 0; i < defs.length; i++) {
890
908
  const def = defs[i]!;
891
909
  if (def.group !== undefined) {
892
- if (def.group !== lastGroup) {
893
- out.push(
894
- <div class="hk-tpl-group" role="presentation">
895
- {props.groupLabel ? props.groupLabel(def.group) : def.group}
896
- </div>,
897
- );
910
+ const label = props.groupLabel ? props.groupLabel(def.group) : def.group;
911
+ if (!run || run.label !== label) {
912
+ flush();
913
+ run = { label, rows: [] };
898
914
  }
899
- lastGroup = def.group;
900
915
  }
901
916
  // Tokens without a group simply continue under the previous
902
- // heading's visual section — the documented contract for
903
- // partially-grouped vocabularies (see the prop doc).
904
- out.push(renderRow(def, i));
917
+ // group's visual section — the documented contract for
918
+ // partially-grouped vocabularies (see the prop doc). When the
919
+ // run is open they ride inside it; a leading ungrouped run
920
+ // renders bare, exactly as an ungrouped vocabulary does.
921
+ const row = renderRow({ def, index: i });
922
+ if (run) run.rows.push(row);
923
+ else out.push(row);
905
924
  }
925
+ flush();
906
926
  return out;
907
927
  }
908
928
 
@@ -981,13 +1001,13 @@ export const HkTemplateField = defineComponent({
981
1001
  title={t("hikari::templateField.suggestTitle", "Placeholders")}
982
1002
  >
983
1003
  {filteredTokens.value.length > 0 ? (
984
- <div class="hk-tpl-rows" role="listbox">
985
- {renderGroupedRows(filteredTokens.value, (def, i) =>
1004
+ <div class="hk-tpl-rows">
1005
+ {renderGroupedRows(filteredTokens.value, ({ def, index }) =>
986
1006
  renderTokenRow(
987
1007
  def,
988
- i === Math.min(suggestActive.value, filteredTokens.value.length - 1),
1008
+ index === Math.min(suggestActive.value, filteredTokens.value.length - 1),
989
1009
  () => pickSuggestion(def),
990
- i,
1010
+ index,
991
1011
  ),
992
1012
  )}
993
1013
  </div>
@@ -1054,8 +1074,8 @@ export const HkTemplateField = defineComponent({
1054
1074
  />
1055
1075
  </div>
1056
1076
  <div ref={editorRowsHostRef} class="hk-tpl-editor-scroll">
1057
- <div ref={editorRowsRef} class="hk-tpl-editor-rows" role="listbox">
1058
- {renderGroupedRows(editorRows.value, (def) =>
1077
+ <div ref={editorRowsRef} class="hk-tpl-editor-rows">
1078
+ {renderGroupedRows(editorRows.value, ({ def }) =>
1059
1079
  renderTokenRow(def, def.name === editorToken.value, () => applyChipToken(def.name)),
1060
1080
  )}
1061
1081
  {editorRows.value.length === 0 && (