@celestia-island/hikari 0.55.126 → 0.55.128

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.126",
3
+ "version": "0.55.128",
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",
@@ -93,6 +93,87 @@
93
93
  color: #fff;
94
94
  }
95
95
 
96
+ // ── Actions on a tinted surface ─────────────────────────────────────
97
+ // The card's surface IS the variant color, so the action row takes its
98
+ // ink from that surface instead of leaking the global accent: a plain
99
+ // primary button reads as a foreign brand on the tinted card (a pink
100
+ // confirm on the teal info card under a pink-themed consumer), and the
101
+ // danger confirm's own red dissolves into its red card. The confirm
102
+ // inverts to a filled white control labeled in a deepened shade of the
103
+ // variant color — the strongest move on every variant surface — and the
104
+ // cancel quiets to a translucent outline in the card's own ink (the
105
+ // house grammar for quiet controls on tinted toast surfaces).
106
+ .hk-blocking-toast-info {
107
+ --hk-blocking-toast-ink: var(--hi-color-info, #6abed6);
108
+ }
109
+
110
+ .hk-blocking-toast-warning {
111
+ --hk-blocking-toast-ink: var(--hi-color-warning, #eed677);
112
+ }
113
+
114
+ .hk-blocking-toast-danger {
115
+ --hk-blocking-toast-ink: var(--hi-color-danger, #f85149);
116
+ }
117
+
118
+ /* The confirm arrives as .hk-btn-primary (info/warning cards) or
119
+ .hk-btn-danger (danger card) — both recolor to the same inverted
120
+ control. Labels darken the variant ink (55% toward black) so every
121
+ variant stays readable on the white fill; svg rides the label color
122
+ so a themed glyph ink can't wash out against it. */
123
+ .hk-blocking-toast-actions .hk-btn-primary,
124
+ .hk-blocking-toast-actions .hk-btn-danger {
125
+ background: #fff;
126
+ border-color: transparent;
127
+ color: color-mix(in srgb, var(--hk-blocking-toast-ink, #6abed6) 55%, #000);
128
+
129
+ svg {
130
+ color: inherit;
131
+ }
132
+ }
133
+
134
+ .hk-blocking-toast-actions .hk-btn-primary:hover:not(:disabled):not(:focus-visible),
135
+ .hk-blocking-toast-actions .hk-btn-danger:hover:not(:disabled):not(:focus-visible) {
136
+ filter: none;
137
+ box-shadow: none;
138
+ background: rgb(255 255 255 / 88%);
139
+ }
140
+
141
+ .hk-blocking-toast-actions .hk-btn-primary:active:not(:disabled),
142
+ .hk-blocking-toast-actions .hk-btn-danger:active:not(:disabled) {
143
+ filter: none;
144
+ background: rgb(255 255 255 / 78%);
145
+ }
146
+
147
+ /* The secondary cancel stops being a heavy surface block (it rendered
148
+ as a white pill competing with the confirm on the tinted card) and
149
+ picks up the quiet outline in the card's currentColor — white on
150
+ info/danger, near-black on warning — so it reads on every variant
151
+ without its own per-variant table. */
152
+ .hk-blocking-toast-actions .hk-btn-secondary {
153
+ background: transparent;
154
+ border-color: color-mix(in srgb, currentColor 45%, transparent);
155
+ backdrop-filter: none;
156
+ color: inherit;
157
+ }
158
+
159
+ .hk-blocking-toast-actions .hk-btn-secondary:hover:not(:disabled) {
160
+ border-color: color-mix(in srgb, currentColor 70%, transparent);
161
+ background: color-mix(in srgb, currentColor 18%, transparent);
162
+ }
163
+
164
+ .hk-blocking-toast-actions .hk-btn-secondary:active:not(:disabled) {
165
+ filter: none;
166
+ background: color-mix(in srgb, currentColor 28%, transparent);
167
+ }
168
+
169
+ /* The keyboard ring follows the card ink too — the global primary ring
170
+ would leak the same foreign accent the fill just removed. */
171
+ .hk-blocking-toast-actions .hk-btn:focus-visible {
172
+ box-shadow:
173
+ 0 0 0 2px rgb(255 255 255 / 60%),
174
+ 0 0 0 4px currentColor;
175
+ }
176
+
96
177
  // Enter/leave mirrors the toast slide (settle, no bounce); leaving
97
178
  // cards go absolute so the remaining ones glide up (hk-toast pattern).
98
179
  .hk-blocking-toast-enter-active {
@@ -673,22 +673,85 @@ describe("HkTemplateField partially-grouped vocabulary", () => {
673
673
  });
674
674
 
675
675
  describe("HkTemplateField vocabulary semantics guards", () => {
676
- it("the rows containers carry no listbox role (menu/dialog context)", async () => {
676
+ it("container roles split by context: panel bare in the menu, editor a labeled listbox", async () => {
677
677
  const el = mountWithTokens([
678
678
  { name: "id", group: "identity" },
679
679
  { name: "md5_email", group: "email" },
680
680
  ]);
681
681
  await nextTick();
682
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).
683
685
  const panelRows = document.body.querySelector<HTMLElement>(".hk-tpl-rows")!;
684
686
  expect(panelRows.getAttribute("role")).toBeNull();
685
687
 
688
+ // The editor surface is a DIALOG — a listbox is the correct list
689
+ // semantics there (restored deliberately, with an accessible name).
686
690
  const el2 = mount({ modelValue: "a {{ username }} b" });
687
691
  await nextTick();
688
692
  editable(el2).querySelector<HTMLElement>(".hk-tpl-chip")!.click();
689
693
  await nextTick();
690
694
  const editorRows = document.body.querySelector<HTMLElement>(".hk-tpl-editor-rows")!;
691
- expect(editorRows.getAttribute("role")).toBeNull();
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");
692
755
  });
693
756
 
694
757
  it("the panel's active highlight follows the filtered index inside wrappers", async () => {
@@ -114,6 +114,9 @@ export const HkTemplateField = defineComponent({
114
114
  // association HkInput gives a bare `label` prop — label clicks
115
115
  // focus, screen readers announce the field by name).
116
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}`;
117
120
 
118
121
  const editRef = ref<HTMLElement | null>(null);
119
122
  /** The value the DOM currently represents. Authoritative while the
@@ -926,18 +929,31 @@ export const HkTemplateField = defineComponent({
926
929
  return out;
927
930
  }
928
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). */
929
937
  const renderTokenRow = (
930
938
  def: HkTemplateTokenDef,
931
939
  active: boolean,
932
940
  onPick: () => void,
933
- i?: number,
941
+ opts: {
942
+ i?: number;
943
+ role: "menuitem" | "option";
944
+ selected?: boolean;
945
+ id?: string;
946
+ },
934
947
  ) => (
935
948
  <button
936
949
  key={def.name}
937
950
  type="button"
938
951
  class="hk-tpl-row"
952
+ role={opts.role}
953
+ id={opts.id}
954
+ aria-selected={opts.role === "option" ? (opts.selected || false) : undefined}
939
955
  data-active={active || undefined}
940
- ref={i !== undefined ? setRowRef(i) : undefined}
956
+ ref={opts.i !== undefined ? setRowRef(opts.i) : undefined}
941
957
  onMousedown={(e: MouseEvent) => e.preventDefault()}
942
958
  onClick={(e: MouseEvent) => {
943
959
  e.stopPropagation();
@@ -966,6 +982,21 @@ export const HkTemplateField = defineComponent({
966
982
  contenteditable={!props.disabled}
967
983
  role="textbox"
968
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
+ }
969
1000
  id={fieldId}
970
1001
  spellcheck={false}
971
1002
  data-ph={props.placeholder}
@@ -1001,13 +1032,17 @@ export const HkTemplateField = defineComponent({
1001
1032
  title={t("hikari::templateField.suggestTitle", "Placeholders")}
1002
1033
  >
1003
1034
  {filteredTokens.value.length > 0 ? (
1004
- <div class="hk-tpl-rows">
1035
+ <div class="hk-tpl-rows" id={panelRowsId}>
1005
1036
  {renderGroupedRows(filteredTokens.value, ({ def, index }) =>
1006
1037
  renderTokenRow(
1007
1038
  def,
1008
1039
  index === Math.min(suggestActive.value, filteredTokens.value.length - 1),
1009
1040
  () => pickSuggestion(def),
1010
- index,
1041
+ {
1042
+ i: index,
1043
+ role: "menuitem",
1044
+ id: `${panelRowsId}-row-${index}`,
1045
+ },
1011
1046
  ),
1012
1047
  )}
1013
1048
  </div>
@@ -1074,9 +1109,17 @@ export const HkTemplateField = defineComponent({
1074
1109
  />
1075
1110
  </div>
1076
1111
  <div ref={editorRowsHostRef} class="hk-tpl-editor-scroll">
1077
- <div ref={editorRowsRef} class="hk-tpl-editor-rows">
1112
+ <div
1113
+ ref={editorRowsRef}
1114
+ class="hk-tpl-editor-rows"
1115
+ role="listbox"
1116
+ aria-label={t("hikari::templateField.chipEditorTitle", "Edit placeholder")}
1117
+ >
1078
1118
  {renderGroupedRows(editorRows.value, ({ def }) =>
1079
- renderTokenRow(def, def.name === editorToken.value, () => applyChipToken(def.name)),
1119
+ renderTokenRow(def, def.name === editorToken.value, () => applyChipToken(def.name), {
1120
+ role: "option",
1121
+ selected: def.name === editorToken.value,
1122
+ }),
1080
1123
  )}
1081
1124
  {editorRows.value.length === 0 && (
1082
1125
  <p class="hk-tpl-rows-empty">