@celestia-island/hikari 0.55.125 → 0.55.127

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.127",
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,116 @@ 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("container roles split by context: panel bare in the menu, editor a labeled listbox", 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
+ // The panel surface is HkMenu's role="menu" — the rows container
684
+ // must not re-role it (listbox-in-menu was the illegal nesting).
685
+ const panelRows = document.body.querySelector<HTMLElement>(".hk-tpl-rows")!;
686
+ expect(panelRows.getAttribute("role")).toBeNull();
687
+
688
+ // The editor surface is a DIALOG — a listbox is the correct list
689
+ // semantics there (restored deliberately, with an accessible name).
690
+ const el2 = mount({ modelValue: "a {{ username }} b" });
691
+ await nextTick();
692
+ editable(el2).querySelector<HTMLElement>(".hk-tpl-chip")!.click();
693
+ await nextTick();
694
+ const editorRows = document.body.querySelector<HTMLElement>(".hk-tpl-editor-rows")!;
695
+ expect(editorRows.getAttribute("role")).toBe("listbox");
696
+ expect(editorRows.getAttribute("aria-label")).toBeTruthy();
697
+ });
698
+
699
+ it("panel rows are menuitems and the editable announces the active one", async () => {
700
+ const el = mountWithTokens([
701
+ { name: "id", group: "identity" },
702
+ { name: "username", group: "identity" },
703
+ ]);
704
+ await nextTick();
705
+ const edit = editable(el);
706
+ await openTrigger(el, "{{ ");
707
+ // Combobox wiring on the editable: expanded + owns the rows.
708
+ expect(edit.getAttribute("aria-haspopup")).toBe("menu");
709
+ expect(edit.getAttribute("aria-expanded")).toBe("true");
710
+ const rowsId = edit.getAttribute("aria-owns")!;
711
+ expect(rowsId).toBeTruthy();
712
+ expect(document.getElementById(rowsId)?.classList.contains("hk-tpl-rows")).toBe(true);
713
+ // Rows are menuitems with stable ids.
714
+ const rows = Array.from(document.body.querySelectorAll<HTMLElement>(".hk-tpl-rows .hk-tpl-row"));
715
+ expect(rows.every((r) => r.getAttribute("role") === "menuitem")).toBe(true);
716
+ expect(rows[0]!.id).toBe(`${rowsId}-row-0`);
717
+ // The activedescendant starts at the first row and follows ArrowDown.
718
+ expect(edit.getAttribute("aria-activedescendant")).toBe(`${rowsId}-row-0`);
719
+ edit.dispatchEvent(
720
+ new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }),
721
+ );
722
+ await nextTick();
723
+ expect(edit.getAttribute("aria-activedescendant")).toBe(`${rowsId}-row-1`);
724
+
725
+ // Zero matches while the panel is open: the rows container does
726
+ // not render, so aria-owns must not dangle (R1 finding).
727
+ await openTrigger(el, "{{ idzzz");
728
+ expect(edit.getAttribute("aria-owns")).toBeNull();
729
+ expect(edit.getAttribute("aria-activedescendant")).toBeNull();
730
+ expect(edit.getAttribute("aria-expanded")).toBe("true");
731
+
732
+ // Collapsed state: no expansion attributes dangle.
733
+ edit.dispatchEvent(
734
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
735
+ );
736
+ await nextTick();
737
+ expect(edit.getAttribute("aria-expanded")).toBeNull();
738
+ expect(edit.getAttribute("aria-owns")).toBeNull();
739
+ expect(edit.getAttribute("aria-activedescendant")).toBeNull();
740
+ });
741
+
742
+ it("editor rows are options with the current token selected", async () => {
743
+ const el = mount({ modelValue: "a {{ username }} b" });
744
+ await nextTick();
745
+ editable(el).querySelector<HTMLElement>(".hk-tpl-chip")!.click();
746
+ await nextTick();
747
+ const options = Array.from(
748
+ document.body.querySelectorAll<HTMLElement>(".hk-tpl-editor-rows .hk-tpl-row"),
749
+ );
750
+ expect(options.length).toBeGreaterThan(1);
751
+ expect(options.every((o) => o.getAttribute("role") === "option")).toBe(true);
752
+ const selected = options.filter((o) => o.getAttribute("aria-selected") === "true");
753
+ expect(selected).toHaveLength(1);
754
+ expect(selected[0]!.textContent).toContain("username");
755
+ });
756
+
757
+ it("the panel's active highlight follows the filtered index inside wrappers", async () => {
758
+ const el = mountWithTokens([
759
+ { name: "id", group: "identity" },
760
+ { name: "username", group: "identity" },
761
+ { name: "md5_email", group: "email" },
762
+ { name: "sha256_email", group: "email" },
763
+ ]);
764
+ await nextTick();
765
+ await openTrigger(el, "{{ ");
766
+ editable(el).dispatchEvent(
767
+ new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }),
768
+ );
769
+ await nextTick();
770
+ const active = Array.from(document.body.querySelectorAll<HTMLElement>(".hk-tpl-row[data-active]"));
771
+ expect(active).toHaveLength(1);
772
+ // Index 1 across the WHOLE filtered list (username) — the highlight
773
+ // must not restart inside each group wrapper.
774
+ expect(active[0]!.textContent).toContain("username");
775
+ });
776
+ });
@@ -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. */
@@ -108,6 +114,9 @@ export const HkTemplateField = defineComponent({
108
114
  // association HkInput gives a bare `label` prop — label clicks
109
115
  // focus, screen readers announce the field by name).
110
116
  const fieldId = useId();
117
+ /** Stable id for the panel rows container + its row ids (combobox
118
+ * aria-owns/activedescendant targets). */
119
+ const panelRowsId = `hk-tpl-panel-${fieldId}`;
111
120
 
112
121
  const editRef = ref<HTMLElement | null>(null);
113
122
  /** The value the DOM currently represents. Authoritative while the
@@ -875,49 +884,76 @@ export const HkTemplateField = defineComponent({
875
884
  };
876
885
  }
877
886
 
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. */
887
+ /** Grouped rows as labeled WRAPPERS, not sibling headings: a
888
+ * `role="group"` box carrying the group's name in `aria-label` is
889
+ * the pattern screen readers announce on entry (a bare
890
+ * role="presentation" heading band was invisible to them — the
891
+ * R3 P3 finding), and it is the sanctioned way to segment choices
892
+ * inside HkMenu's role="menu" surface. The visible heading text
893
+ * lives in the inner .hk-tpl-group-label band. */
883
894
  function renderGroupedRows(
884
895
  defs: readonly HkTemplateTokenDef[],
885
- renderRow: (def: HkTemplateTokenDef, index: number) => unknown,
896
+ renderRow: (row: HkTemplateFieldRow) => unknown,
886
897
  ): unknown[] {
887
898
  const out: unknown[] = [];
888
- let lastGroup: string | undefined;
899
+ let run: { label: string; rows: unknown[] } | null = null;
900
+ const flush = () => {
901
+ if (!run) return;
902
+ out.push(
903
+ <div class="hk-tpl-group" role="group" aria-label={run.label}>
904
+ <div class="hk-tpl-group-label" aria-hidden="true">{run.label}</div>
905
+ {run.rows}
906
+ </div>,
907
+ );
908
+ run = null;
909
+ };
889
910
  for (let i = 0; i < defs.length; i++) {
890
911
  const def = defs[i]!;
891
912
  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
- );
913
+ const label = props.groupLabel ? props.groupLabel(def.group) : def.group;
914
+ if (!run || run.label !== label) {
915
+ flush();
916
+ run = { label, rows: [] };
898
917
  }
899
- lastGroup = def.group;
900
918
  }
901
919
  // 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));
920
+ // group's visual section — the documented contract for
921
+ // partially-grouped vocabularies (see the prop doc). When the
922
+ // run is open they ride inside it; a leading ungrouped run
923
+ // renders bare, exactly as an ungrouped vocabulary does.
924
+ const row = renderRow({ def, index: i });
925
+ if (run) run.rows.push(row);
926
+ else out.push(row);
905
927
  }
928
+ flush();
906
929
  return out;
907
930
  }
908
931
 
932
+ /** Row chrome shared by both surfaces. The ROLE rides the surface:
933
+ * the panel lives inside HkMenu's role="menu" (menuitem — the
934
+ * HkMenuActionItem family pattern), the editor lives in a dialog
935
+ * and is a proper listbox (option + aria-selected on the token
936
+ * being edited). */
909
937
  const renderTokenRow = (
910
938
  def: HkTemplateTokenDef,
911
939
  active: boolean,
912
940
  onPick: () => void,
913
- i?: number,
941
+ opts: {
942
+ i?: number;
943
+ role: "menuitem" | "option";
944
+ selected?: boolean;
945
+ id?: string;
946
+ },
914
947
  ) => (
915
948
  <button
916
949
  key={def.name}
917
950
  type="button"
918
951
  class="hk-tpl-row"
952
+ role={opts.role}
953
+ id={opts.id}
954
+ aria-selected={opts.role === "option" ? (opts.selected || false) : undefined}
919
955
  data-active={active || undefined}
920
- ref={i !== undefined ? setRowRef(i) : undefined}
956
+ ref={opts.i !== undefined ? setRowRef(opts.i) : undefined}
921
957
  onMousedown={(e: MouseEvent) => e.preventDefault()}
922
958
  onClick={(e: MouseEvent) => {
923
959
  e.stopPropagation();
@@ -946,6 +982,21 @@ export const HkTemplateField = defineComponent({
946
982
  contenteditable={!props.disabled}
947
983
  role="textbox"
948
984
  aria-multiline="false"
985
+ // Combobox announcement wiring: the editable OWNS the popup
986
+ // (aria-owns makes the teleported rows virtual descendants)
987
+ // and points aria-activedescendant at the highlighted row,
988
+ // so arrow-key navigation is announced without moving DOM
989
+ // focus out of the field.
990
+ aria-haspopup="menu"
991
+ aria-expanded={suggestOpen.value || undefined}
992
+ aria-owns={
993
+ suggestOpen.value && filteredTokens.value.length > 0 ? panelRowsId : undefined
994
+ }
995
+ aria-activedescendant={
996
+ suggestOpen.value && filteredTokens.value.length > 0
997
+ ? `${panelRowsId}-row-${Math.min(suggestActive.value, filteredTokens.value.length - 1)}`
998
+ : undefined
999
+ }
949
1000
  id={fieldId}
950
1001
  spellcheck={false}
951
1002
  data-ph={props.placeholder}
@@ -981,13 +1032,17 @@ export const HkTemplateField = defineComponent({
981
1032
  title={t("hikari::templateField.suggestTitle", "Placeholders")}
982
1033
  >
983
1034
  {filteredTokens.value.length > 0 ? (
984
- <div class="hk-tpl-rows" role="listbox">
985
- {renderGroupedRows(filteredTokens.value, (def, i) =>
1035
+ <div class="hk-tpl-rows" id={panelRowsId}>
1036
+ {renderGroupedRows(filteredTokens.value, ({ def, index }) =>
986
1037
  renderTokenRow(
987
1038
  def,
988
- i === Math.min(suggestActive.value, filteredTokens.value.length - 1),
1039
+ index === Math.min(suggestActive.value, filteredTokens.value.length - 1),
989
1040
  () => pickSuggestion(def),
990
- i,
1041
+ {
1042
+ i: index,
1043
+ role: "menuitem",
1044
+ id: `${panelRowsId}-row-${index}`,
1045
+ },
991
1046
  ),
992
1047
  )}
993
1048
  </div>
@@ -1054,9 +1109,17 @@ export const HkTemplateField = defineComponent({
1054
1109
  />
1055
1110
  </div>
1056
1111
  <div ref={editorRowsHostRef} class="hk-tpl-editor-scroll">
1057
- <div ref={editorRowsRef} class="hk-tpl-editor-rows" role="listbox">
1058
- {renderGroupedRows(editorRows.value, (def) =>
1059
- renderTokenRow(def, def.name === editorToken.value, () => applyChipToken(def.name)),
1112
+ <div
1113
+ ref={editorRowsRef}
1114
+ class="hk-tpl-editor-rows"
1115
+ role="listbox"
1116
+ aria-label={t("hikari::templateField.chipEditorTitle", "Edit placeholder")}
1117
+ >
1118
+ {renderGroupedRows(editorRows.value, ({ def }) =>
1119
+ renderTokenRow(def, def.name === editorToken.value, () => applyChipToken(def.name), {
1120
+ role: "option",
1121
+ selected: def.name === editorToken.value,
1122
+ }),
1060
1123
  )}
1061
1124
  {editorRows.value.length === 0 && (
1062
1125
  <p class="hk-tpl-rows-empty">