@celestia-island/hikari 0.55.185 → 0.55.187

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.185",
3
+ "version": "0.55.187",
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",
@@ -115,4 +115,27 @@ describe("HkAdminTablePage", () => {
115
115
  expect(c.querySelector(".hk-spinner-wrapper")).toBeTruthy();
116
116
  expect(c.querySelector("table")).toBeNull();
117
117
  });
118
+
119
+ it("manageable renders the ⋯ column and forwards manage(row, opener)", () => {
120
+ const got: Array<[Record<string, unknown>, HTMLElement]> = [];
121
+ const rows = [{ id: "r1", name: "alpha" }];
122
+ const c = mount(
123
+ h(HkAdminTablePage, {
124
+ title: "T",
125
+ rows,
126
+ columns: COLS,
127
+ rowKey: "id",
128
+ manageable: true,
129
+ onManage: (row: Record<string, unknown>, opener: HTMLElement) =>
130
+ got.push([row, opener]),
131
+ }),
132
+ );
133
+ expect(c.querySelectorAll("thead th")).toHaveLength(COLS.length + 1);
134
+ const btn = c.querySelector(".hk-table-manage-cell button") as HTMLButtonElement;
135
+ expect(btn).not.toBeNull();
136
+ btn.click();
137
+ expect(got).toHaveLength(1);
138
+ expect(got[0]![0]).toBe(rows[0]);
139
+ expect(got[0]![1]).toBe(btn);
140
+ });
118
141
  });
@@ -1,6 +1,7 @@
1
1
  import { defineComponent, type Component, type PropType } from "vue";
2
2
  import { HAlert, HCard, HEmptyState, HSpinner, HTable, useI18n } from "@celestia-island/hikari";
3
3
  import { HkPageHeader } from "./HkPageHeader";
4
+ import type { HkRowManageGate } from "./HkTable";
4
5
 
5
6
 
6
7
  /** Column definition passed through to HTable. */
@@ -10,6 +11,8 @@ export interface HTableColumn {
10
11
  width?: string;
11
12
  sortable?: boolean;
12
13
  align?: "left" | "center" | "right";
14
+ /** Opt one column out of the click-to-copy layer (see HkTable). */
15
+ copyable?: boolean;
13
16
  }
14
17
 
15
18
  /**
@@ -53,9 +56,18 @@ export const HkAdminTablePage = defineComponent({
53
56
  /** Left grip column with drag-to-reorder (forwarded to HTable; the
54
57
  * `reorder` event is forwarded verbatim — indices into `rows`). */
55
58
  draggable: { type: Boolean, default: false },
59
+ /** Trailing row-actions column with the standard ⋯ trigger
60
+ * (forwarded to HTable; see HkTable's `manageable` — pass a
61
+ * HkRowManageGate to hide/disable the trigger per row). The
62
+ * `manage` event is forwarded verbatim — (row, opener button). */
63
+ manageable: {
64
+ type: [Boolean, Function] as PropType<HkRowManageGate>,
65
+ default: false,
66
+ },
56
67
  },
57
68
  emits: {
58
69
  reorder: (_fromIndex: number, _toIndex: number) => true,
70
+ manage: (_row: Record<string, unknown>, _opener: HTMLElement) => true,
59
71
  },
60
72
  setup(props, { emit, slots }) {
61
73
 
@@ -107,7 +119,11 @@ export const HkAdminTablePage = defineComponent({
107
119
  rows={props.rows}
108
120
  rowKey={props.rowKey}
109
121
  draggable={props.draggable}
122
+ manageable={props.manageable}
110
123
  onReorder={(from: number, to: number) => emit("reorder", from, to)}
124
+ onManage={(row: Record<string, unknown>, opener: HTMLElement) =>
125
+ emit("manage", row, opener)
126
+ }
111
127
  >
112
128
  {slots}
113
129
  </HTable>
@@ -9,7 +9,10 @@
9
9
  min-width: 0;
10
10
  }
11
11
 
12
- .hk-header-picker-trigger {
12
+ /* Double-class on purpose: the ghost chrome must beat the base
13
+ * `.hk-select-trigger` field chrome regardless of stylesheet import
14
+ * order (both would otherwise sit at one class of specificity). */
15
+ .hk-select-trigger.hk-header-picker-trigger {
13
16
  width: auto;
14
17
  /* Header-band compact: the button group beside it runs ~28px; the
15
18
  * trigger stays a touch taller for its label but must not drive the
@@ -20,6 +23,28 @@
20
23
  font-size: var(--text-sm);
21
24
  font-weight: 600;
22
25
  max-width: 16rem;
26
+ /* Ghost chrome (2026-10-18 user direction: the top-centre dropdown
27
+ * must read like the bottom-centre platform pill — no field border,
28
+ * hover tint only). Mirrors HkButton's ghost variant. */
29
+ background: transparent;
30
+ border-color: transparent;
31
+ }
32
+
33
+ .hk-select-trigger.hk-header-picker-trigger:hover:not([data-disabled]) {
34
+ background: var(--c-primary-light);
35
+ border-color: transparent;
36
+ }
37
+
38
+ .hk-select-trigger.hk-header-picker-trigger:active:not([data-disabled]) {
39
+ filter: brightness(0.95);
40
+ }
41
+
42
+ /* Keyboard / open ring survives — the ring rides the existing
43
+ * --shadow-focus; only the field border stays gone. */
44
+ .hk-select-trigger.hk-header-picker-trigger:focus-visible,
45
+ .hk-select-trigger.hk-header-picker-trigger[data-state="open"] {
46
+ background: var(--c-primary-light);
47
+ border-color: rgb(var(--color-focused-border));
23
48
  }
24
49
 
25
50
  .hk-header-picker-trigger .hk-select-value {
@@ -1,8 +1,13 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { createApp, defineComponent, h, ref } from "vue";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
3
6
 
4
7
  import HkHeaderPicker, { type HkPickerOption } from "./HkHeaderPicker";
5
8
 
9
+ const here = dirname(fileURLToPath(import.meta.url));
10
+
6
11
  /**
7
12
  * HkHeaderPicker — the header-centre selector (the button group's
8
13
  * sibling). These pins hold the standardized contract: trigger chrome,
@@ -122,13 +127,30 @@ describe("HkHeaderPicker", () => {
122
127
  expect(document.querySelectorAll('.hk-draggable-list-handle[data-hidden]').length).toBe(1);
123
128
  });
124
129
 
125
- it("declares the placement prop with the top-down default (mirror-of-bottom contract)", () => {
130
+ it("declares the placement prop with the centered-down default (mirror-of-footer contract)", () => {
126
131
  // The panel is teleported to body, so the through-wiring is pinned at
127
132
  // the contract level: the prop exists, defaults to the header's
128
- // downward growth, and the render passes it straight through
133
+ // downward growth CENTERED on the trigger (2026-10-18 user
134
+ // direction — the footer platform pill's symmetric pop, mirrored
135
+ // up top), and the render passes it straight through
129
136
  // (`placement={props.placement}` in HkHeaderPicker.tsx).
130
137
  expect(HkHeaderPicker.props!.placement).toBeDefined();
131
- expect((HkHeaderPicker.props!.placement as { default: string }).default).toBe("bottom-start");
138
+ expect((HkHeaderPicker.props!.placement as { default: string }).default).toBe("bottom-center");
139
+ });
140
+
141
+ it("keeps the popout content-sized and the trigger chrome ghost", () => {
142
+ // 2026-10-18 user direction: the header-centre dropdown must read
143
+ // like the bottom-centre platform pill — no field border, hover
144
+ // tint only, and a compact centered menu (not a stretched field
145
+ // panel). Pinned at source level like the popout-width contract.
146
+ const tsx = readFileSync(join(here, "HkHeaderPicker.tsx"), "utf-8");
147
+ expect(tsx).toContain("matchAnchorWidth={false}");
148
+ const scss = readFileSync(join(here, "HkHeaderPicker.scss"), "utf-8");
149
+ // The ghost override must be double-classed to beat the base
150
+ // `.hk-select-trigger` chrome regardless of import order.
151
+ expect(scss).toContain(".hk-select-trigger.hk-header-picker-trigger");
152
+ expect(scss).toContain("border-color: transparent;");
153
+ expect(scss).toContain("background: var(--c-primary-light);");
132
154
  });
133
155
 
134
156
  it("disables the trigger when disabled", () => {
@@ -75,10 +75,15 @@ export default defineComponent({
75
75
  /** Desktop-popout side relative to the trigger. The header sits at
76
76
  * the viewport's top, so the popout grows DOWNWARD (bottom-*) —
77
77
  * the mirror of bottom-pinned pickers; auto-flip still rescues a
78
- * viewport that cannot fit it. */
78
+ * viewport that cannot fit it. CENTERED on the trigger by default
79
+ * (2026-10-18 user direction): the header pill mirrors the
80
+ * footer's platform pill, whose menu pops symmetrically on the
81
+ * trigger's midpoint — a start-anchored menu reads lopsided off a
82
+ * narrow pill. Callers with edge-pinned triggers may still pin a
83
+ * side explicitly. */
79
84
  placement: {
80
85
  type: String as PropType<SelectPanelPlacement>,
81
- default: "bottom-start",
86
+ default: "bottom-center",
82
87
  },
83
88
  /** Accessible name of the trigger itself. */
84
89
  ariaLabel: { type: String, default: undefined },
@@ -302,6 +307,10 @@ export default defineComponent({
302
307
  title={props.panelTitle ?? ""}
303
308
  placement={props.placement}
304
309
  offset={4}
310
+ // The popout hugs its rows (not the trigger's width): the
311
+ // header pill is narrow, and the mirror-of-the-footer look
312
+ // means a compact centered menu, not a stretched field panel.
313
+ matchAnchorWidth={false}
305
314
  sheetOnMobile
306
315
  onKeydown={onPopoutKeydown}
307
316
  >
@@ -0,0 +1,86 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, h } from "vue";
3
+
4
+ import HkRowActions from "./HkRowActions";
5
+ import type { HkMenuItem } from "./HkMenu";
6
+
7
+ /**
8
+ * HkRowActions contract (2026-10-18 row-actions standard):
9
+ * - trigger mode: one ghost kebab button, localized default label, and
10
+ * a click emits `open` with the trigger itself as the opener element
11
+ * (the focus-restore anchor a row manage window reuses)
12
+ * - menu mode (`items`): the trigger hosts an HkMenu popup —
13
+ * aria-haspopup/expanded flip, `open` never fires, `select` fires
14
+ * - the click never reaches clickable ancestors
15
+ */
16
+
17
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
18
+
19
+ function mount(node: ReturnType<typeof h>) {
20
+ const container = document.createElement("div");
21
+ document.body.appendChild(container);
22
+ const app = createApp({ render: () => node });
23
+ app.mount(container);
24
+ mounts.push({ app, container });
25
+ return container;
26
+ }
27
+
28
+ afterEach(() => {
29
+ for (const { app, container } of mounts.splice(0)) {
30
+ app.unmount();
31
+ container.remove();
32
+ }
33
+ document.body.querySelectorAll(".hk-select-panel").forEach((n) => n.remove());
34
+ });
35
+
36
+ describe("HkRowActions", () => {
37
+ it("trigger mode: click emits open with the trigger button as opener", async () => {
38
+ const opened: HTMLElement[] = [];
39
+ const c = mount(h(HkRowActions, { onOpen: (el: HTMLElement) => opened.push(el) }));
40
+ const btn = c.querySelector("button.hk-icon-button") as HTMLButtonElement;
41
+ expect(btn).not.toBeNull();
42
+ expect(btn.getAttribute("aria-label")).toBe("Row actions");
43
+ btn.click();
44
+ await Promise.resolve();
45
+ expect(opened).toHaveLength(1);
46
+ expect(opened[0]).toBe(btn);
47
+ });
48
+
49
+ it("menu mode: aria announces the popup, open never fires", async () => {
50
+ const onOpen = vi.fn();
51
+ const items: HkMenuItem[] = [
52
+ { key: "edit", label: "Edit" },
53
+ { key: "delete", label: "Delete", danger: true },
54
+ ];
55
+ const c = mount(h(HkRowActions, { items, onOpen }));
56
+ const btn = c.querySelector("button.hk-icon-button") as HTMLButtonElement;
57
+ expect(btn.getAttribute("aria-haspopup")).toBe("menu");
58
+ expect(btn.getAttribute("aria-expanded")).toBe("false");
59
+ btn.click();
60
+ await Promise.resolve();
61
+ await Promise.resolve();
62
+ expect(onOpen).not.toHaveBeenCalled();
63
+ expect(btn.getAttribute("aria-expanded")).toBe("true");
64
+ // A second press toggles the menu shut (the hand-rolled kebab+menu
65
+ // pairs this component replaces closed on re-click).
66
+ btn.click();
67
+ await Promise.resolve();
68
+ await Promise.resolve();
69
+ expect(btn.getAttribute("aria-expanded")).toBe("false");
70
+ });
71
+
72
+ it("stops the click from reaching clickable ancestors", () => {
73
+ const outer = vi.fn();
74
+ const c = mount(
75
+ h("div", { onClick: outer }, h(HkRowActions)),
76
+ );
77
+ (c.querySelector("button.hk-icon-button") as HTMLButtonElement).click();
78
+ expect(outer).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it("custom label overrides the localized default", () => {
82
+ const c = mount(h(HkRowActions, { label: "Manage row" }));
83
+ const btn = c.querySelector("button.hk-icon-button") as HTMLButtonElement;
84
+ expect(btn.getAttribute("aria-label")).toBe("Manage row");
85
+ });
86
+ });
@@ -0,0 +1,128 @@
1
+ import { computed, defineComponent, ref, type PropType } from "vue";
2
+ import { MoreHorizontal } from "lucide-vue-next";
3
+
4
+ import { useI18n } from "../i18n/context";
5
+ import HkIconButton from "./HkIconButton";
6
+ import HkMenu, { type HkMenuItem } from "./HkMenu";
7
+
8
+ /**
9
+ * HkRowActions — the house standard for the rightmost ⋯ trigger on a
10
+ * data row (2026-10-18 user direction: call sites stop hand-rolling an
11
+ * HkIconButton + MoreHorizontal in every view).
12
+ *
13
+ * Two modes, one component:
14
+ * - Trigger mode (default): a ghost kebab button that emits `open` with
15
+ * ITSELF as the opener element — the anchor a row manage window
16
+ * (chest's useRowManageWindow pattern) restores focus to on close.
17
+ * The consumer decides what opens; this component only standardizes
18
+ * the affordance (glyph, size step, aria label, click isolation).
19
+ * - Menu mode (`items`): the same button hosts an HkMenu popup
20
+ * (desktop popout / mobile sheet via the shared HkSelectPanel
21
+ * surface) and emits `select(item)`; the menu closes on selection.
22
+ *
23
+ * The click is stopped from reaching ancestors — a trigger inside a
24
+ * clickable cell opens only itself, exactly like HkCopyBadge's tag
25
+ * contract, and HkTable's cell copy layer additionally ignores presses
26
+ * that land on interactive controls.
27
+ */
28
+ export default defineComponent({
29
+ name: "HkRowActions",
30
+ props: {
31
+ /** Accessible name / tooltip for the trigger. Defaults to the
32
+ * localized "Row actions" (hikari::table.rowActions). */
33
+ label: { type: String, default: undefined },
34
+ /** Button size step (HkIconButton's ladder). 28 is the roster step
35
+ * the row manage pattern standardized on (2026-10-12 ruling). */
36
+ size: {
37
+ type: Number as PropType<16 | 24 | 28 | 32 | 36 | 40>,
38
+ default: 28,
39
+ },
40
+ disabled: { type: Boolean, default: false },
41
+ /** Menu mode: data-driven HkMenu items. Providing items swaps the
42
+ * click behavior from `open` to hosting the popup. */
43
+ items: { type: Array as PropType<HkMenuItem[]>, default: undefined },
44
+ /** Desktop popout placement (menu mode). Rows sit at the table's
45
+ * trailing edge, so bottom-end keeps the panel on the screen. */
46
+ placement: {
47
+ type: String as PropType<
48
+ "bottom-start" | "bottom-end" | "top-start" | "top-end" | "right-start" | "left-start"
49
+ >,
50
+ default: "bottom-end",
51
+ },
52
+ },
53
+ emits: {
54
+ /** Trigger mode: the opener element (this button) for focus-restore
55
+ * anchoring. */
56
+ open: (_opener: HTMLElement) => true,
57
+ /** Menu mode: the chosen item; the popup closes itself first. */
58
+ select: (_item: HkMenuItem) => true,
59
+ },
60
+ setup(props, { emit }) {
61
+ const { t } = useI18n();
62
+ const open = ref(false);
63
+ const triggerRef = ref<InstanceType<typeof HkIconButton> | null>(null);
64
+
65
+ const label = computed(
66
+ () => props.label ?? t("hikari::table.rowActions", "Row actions"),
67
+ );
68
+
69
+ /** The trigger's real <button> (HkIconButton renders a single
70
+ * button root, so the instance's $el is the element). */
71
+ function openerEl(): HTMLElement | null {
72
+ const el = triggerRef.value?.$el as HTMLElement | undefined;
73
+ return el && el.nodeType === 1 ? el : null;
74
+ }
75
+
76
+ function onTrigger(e: MouseEvent) {
77
+ // A row-action press never reaches the surrounding cell or row
78
+ // handlers — the trigger owns its click outright.
79
+ e.stopPropagation();
80
+ if (props.disabled) return;
81
+ if (props.items) {
82
+ // A second press on the ⋯ toggles the menu shut — the hand-rolled
83
+ // kebab+menu pairs this component replaces closed on re-click
84
+ // (rowMenu.toggle), and that habit carries over. The anchor hit
85
+ // containment in HkSelectPanel would otherwise swallow the press.
86
+ open.value = !open.value;
87
+ return;
88
+ }
89
+ emit("open", openerEl() ?? (e.currentTarget as HTMLElement));
90
+ }
91
+
92
+ function onSelect(item: HkMenuItem) {
93
+ open.value = false;
94
+ emit("select", item);
95
+ }
96
+
97
+ return () => (
98
+ <>
99
+ <HkIconButton
100
+ ref={triggerRef}
101
+ variant="ghost"
102
+ size={props.size}
103
+ disabled={props.disabled}
104
+ aria-label={label.value}
105
+ aria-haspopup={props.items ? "menu" : undefined}
106
+ aria-expanded={props.items ? open.value : undefined}
107
+ onClick={onTrigger}
108
+ >
109
+ <MoreHorizontal size={props.size >= 32 ? 16 : 14} />
110
+ </HkIconButton>
111
+ {props.items && (
112
+ <HkMenu
113
+ variant="popup"
114
+ items={props.items}
115
+ open={open.value}
116
+ anchorRef={openerEl()}
117
+ placement={props.placement}
118
+ title={label.value}
119
+ onUpdate:open={(v: boolean) => {
120
+ open.value = v;
121
+ }}
122
+ onSelect={onSelect}
123
+ />
124
+ )}
125
+ </>
126
+ );
127
+ },
128
+ });
@@ -0,0 +1,307 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, h } from "vue";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import HTable from "./HkTable";
8
+ import { useToast } from "../runtime/useToast";
9
+
10
+ /**
11
+ * HkTable interaction contract (2026-10-18 user direction, on top of the
12
+ * 2026-10-05 tag contract and the 2026-10-12 row-manage ruling):
13
+ * - plain text in a cell copies on click through the shared
14
+ * clipboard-with-toast path (same runtime as HkCopyBadge) — no bare
15
+ * selection, no drag mass-select (user-select: none, scoped by
16
+ * `data-copy-cells`)
17
+ * - interactive descendants (controls, button-role elements, popup
18
+ * owners, `[data-no-copy]`) own their clicks; the copy layer never
19
+ * swallows them
20
+ * - only TEXT LEAVES copy — layout wrappers and cell padding never
21
+ * sweep up descendant text, and per the 2026-10-18 ruling a cell
22
+ * body click does NOT open row actions (the ⋯ trigger stays the only
23
+ * row-action affordance)
24
+ * - `Column.copyable: false` and the table-wide `copy-cells=false` opt
25
+ * out wholesale
26
+ * - `manageable` renders the trailing ⋯ column (HkRowActions) and
27
+ * emits `manage(row, opener)`; the trigger click never copies
28
+ */
29
+
30
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
31
+
32
+ function mount(node: ReturnType<typeof h>) {
33
+ const container = document.createElement("div");
34
+ document.body.appendChild(container);
35
+ const app = createApp({ render: () => node });
36
+ app.mount(container);
37
+ mounts.push({ app, container });
38
+ return container;
39
+ }
40
+
41
+ afterEach(() => {
42
+ for (const { app, container } of mounts.splice(0)) {
43
+ app.unmount();
44
+ container.remove();
45
+ }
46
+ useToast().toasts.splice(0);
47
+ });
48
+
49
+ // `navigator.clipboard` does not exist under happy-dom — install a
50
+ // controllable stub (house pattern: HkCopyBadge.test.tsx).
51
+ let writeText: ReturnType<typeof vi.fn>;
52
+ const originalClipboard = navigator.clipboard;
53
+
54
+ beforeEach(() => {
55
+ writeText = vi.fn().mockResolvedValue(undefined);
56
+ Object.defineProperty(navigator, "clipboard", {
57
+ value: { writeText },
58
+ configurable: true,
59
+ });
60
+ });
61
+
62
+ afterEach(() => {
63
+ Object.defineProperty(navigator, "clipboard", {
64
+ value: originalClipboard,
65
+ configurable: true,
66
+ });
67
+ });
68
+
69
+ const COLS = [
70
+ { key: "name", title: "Name" },
71
+ { key: "endpoint", title: "Endpoint" },
72
+ ];
73
+
74
+ async function settle() {
75
+ await Promise.resolve();
76
+ await Promise.resolve();
77
+ }
78
+
79
+ function bodyCells(c: HTMLElement): HTMLElement[] {
80
+ return Array.from(c.querySelectorAll<HTMLElement>("tbody .hk-table-cell"));
81
+ }
82
+
83
+ describe("HkTable cell copy layer", () => {
84
+ it("clicking a default text cell copies its text and toasts success", async () => {
85
+ const c = mount(
86
+ h(HTable, { columns: COLS, rows: [{ name: "alpha", endpoint: "https://x.example" }] }),
87
+ );
88
+ bodyCells(c)[0]!.click();
89
+ await settle();
90
+ expect(writeText).toHaveBeenCalledWith("alpha");
91
+ const success = useToast().toasts.find((t) => t.type === "success");
92
+ expect(success?.messages.some((m) => m.text === "Copied")).toBe(true);
93
+ });
94
+
95
+ it("a slot cell's plain text leaf copies that leaf, not the whole cell", async () => {
96
+ const c = mount(
97
+ h(HTable, { columns: COLS, rows: [{ name: "alpha", endpoint: "" }] }, {
98
+ "cell-endpoint": () =>
99
+ h("div", { class: "probe-endpoint" }, [
100
+ h("span", { class: "probe-url" }, "https://a.example"),
101
+ h("span", { class: "probe-via" }, "via gw-1"),
102
+ ]),
103
+ }),
104
+ );
105
+ (c.querySelector(".probe-url") as HTMLElement).click();
106
+ await settle();
107
+ expect(writeText).toHaveBeenCalledWith("https://a.example");
108
+ expect(writeText).not.toHaveBeenCalledWith("https://a.examplevia gw-1");
109
+ });
110
+
111
+ it("interactive descendants own their clicks — no copy, no toast", async () => {
112
+ const onToggle = vi.fn();
113
+ const c = mount(
114
+ h(HTable, { columns: COLS, rows: [{ name: "alpha", endpoint: "" }] }, {
115
+ "cell-endpoint": () =>
116
+ h("div", null, [
117
+ h("span", { role: "button", onClick: onToggle }, "toggle-me"),
118
+ h("span", { class: "probe-rest" }, "plain"),
119
+ ]),
120
+ }),
121
+ );
122
+ const toggle = Array.from(c.querySelectorAll<HTMLElement>('[role="button"]'))[0]!;
123
+ toggle.click();
124
+ await settle();
125
+ expect(onToggle).toHaveBeenCalledTimes(1);
126
+ expect(writeText).not.toHaveBeenCalled();
127
+ });
128
+
129
+ it("a data-no-copy marker opts its subtree out of the copy layer", async () => {
130
+ const c = mount(
131
+ h(HTable, { columns: COLS, rows: [{ name: "alpha", endpoint: "" }] }, {
132
+ "cell-endpoint": () =>
133
+ h("div", { "data-no-copy": "" }, h("span", { class: "probe-nope" }, "do-not-copy")),
134
+ }),
135
+ );
136
+ (c.querySelector(".probe-nope") as HTMLElement).click();
137
+ await settle();
138
+ expect(writeText).not.toHaveBeenCalled();
139
+ });
140
+
141
+ it("clicking a layout wrapper (gaps between chips) copies nothing", async () => {
142
+ const c = mount(
143
+ h(HTable, { columns: COLS, rows: [{ name: "alpha", endpoint: "" }] }, {
144
+ "cell-endpoint": () =>
145
+ h("div", { class: "probe-composite" }, [
146
+ h("span", null, "one"),
147
+ h("span", null, "two"),
148
+ ]),
149
+ }),
150
+ );
151
+ (c.querySelector(".probe-composite") as HTMLElement).click();
152
+ await settle();
153
+ expect(writeText).not.toHaveBeenCalled();
154
+ });
155
+
156
+ it("cell padding around slot content does nothing (body clicks never open row actions)", async () => {
157
+ const onManage = vi.fn();
158
+ const c = mount(
159
+ h(HTable, {
160
+ columns: COLS,
161
+ rows: [{ name: "alpha", endpoint: "" }],
162
+ manageable: true,
163
+ onManage,
164
+ }, {
165
+ "cell-endpoint": () => h("div", { class: "probe-slot" }, "slot-text"),
166
+ }),
167
+ );
168
+ const endpointTd = Array.from(c.querySelectorAll<HTMLElement>("tbody td")).find(
169
+ (td) => td.querySelector(".probe-slot"),
170
+ )!;
171
+ endpointTd.click();
172
+ await settle();
173
+ expect(writeText).not.toHaveBeenCalled();
174
+ expect(onManage).not.toHaveBeenCalled();
175
+ });
176
+
177
+ it("column copyable:false opts that column out", async () => {
178
+ const c = mount(
179
+ h(HTable, {
180
+ columns: [...COLS, { key: "secret", title: "Secret", copyable: false }],
181
+ rows: [{ name: "alpha", endpoint: "", secret: "nope-token" }],
182
+ }),
183
+ );
184
+ const tds = bodyCells(c);
185
+ tds[0]!.click();
186
+ await settle();
187
+ expect(writeText).toHaveBeenCalledTimes(1);
188
+ tds[2]!.click();
189
+ await settle();
190
+ expect(writeText).toHaveBeenCalledTimes(1);
191
+ expect(writeText).not.toHaveBeenCalledWith("nope-token");
192
+ });
193
+
194
+ it("copy-cells=false disables the layer and the scoping attribute", async () => {
195
+ const c = mount(
196
+ h(HTable, { columns: COLS, rows: [{ name: "alpha", endpoint: "" }], copyCells: false }),
197
+ );
198
+ expect(c.querySelector("table.hk-table")!.hasAttribute("data-copy-cells")).toBe(false);
199
+ bodyCells(c)[0]!.click();
200
+ await settle();
201
+ expect(writeText).not.toHaveBeenCalled();
202
+ });
203
+
204
+ it("default-rendered copyable cells advertise the click-to-copy hint", () => {
205
+ const c = mount(
206
+ h(HTable, { columns: COLS, rows: [{ name: "alpha", endpoint: "https://x.example" }] }),
207
+ );
208
+ const td = bodyCells(c)[0]!;
209
+ expect(td.getAttribute("title")).toBe("Click to copy");
210
+ });
211
+ });
212
+
213
+ describe("HkTable manageable column", () => {
214
+ it("renders the trailing ⋯ column and emits manage(row, opener)", async () => {
215
+ const rows = [
216
+ { name: "alpha", endpoint: "" },
217
+ { name: "beta", endpoint: "" },
218
+ ];
219
+ const emitted: Array<[Record<string, unknown>, HTMLElement]> = [];
220
+ const c = mount(
221
+ h(HTable, {
222
+ columns: COLS,
223
+ rows,
224
+ rowKey: "name",
225
+ manageable: true,
226
+ onManage: (row: Record<string, unknown>, opener: HTMLElement) =>
227
+ emitted.push([row, opener]),
228
+ }),
229
+ );
230
+ // Header gained one trailing column; every row carries the trigger.
231
+ expect(c.querySelectorAll("thead th")).toHaveLength(COLS.length + 1);
232
+ const triggers = Array.from(
233
+ c.querySelectorAll<HTMLElement>(".hk-table-manage-cell button"),
234
+ );
235
+ expect(triggers).toHaveLength(2);
236
+ expect(triggers[0]!.getAttribute("aria-label")).toBe("Row actions");
237
+ triggers[0]!.click();
238
+ await settle();
239
+ expect(emitted).toHaveLength(1);
240
+ expect(emitted[0]![0]).toBe(rows[0]);
241
+ expect(emitted[0]![1]).toBe(triggers[0]);
242
+ // The trigger press never leaks into the copy layer.
243
+ expect(writeText).not.toHaveBeenCalled();
244
+ });
245
+
246
+ it("manageable=false (default) renders no actions column", () => {
247
+ const c = mount(h(HTable, { columns: COLS, rows: [{ name: "a", endpoint: "" }] }));
248
+ expect(c.querySelector(".hk-table-manage-cell")).toBeNull();
249
+ expect(c.querySelectorAll("thead th")).toHaveLength(COLS.length);
250
+ });
251
+
252
+ it("manage gate: rows hide the trigger or render it disabled", async () => {
253
+ const rows = [
254
+ { name: "alpha", endpoint: "", state: "on" },
255
+ { name: "beta", endpoint: "", state: "no-perm" },
256
+ { name: "gamma", endpoint: "", state: "readonly" },
257
+ ];
258
+ const emitted: Array<[Record<string, unknown>, HTMLElement]> = [];
259
+ const c = mount(
260
+ h(HTable, {
261
+ columns: COLS,
262
+ rows,
263
+ rowKey: "name",
264
+ manageable: (row: Record<string, unknown>) =>
265
+ row.state === "readonly" ? "disabled" : row.state === "on",
266
+ onManage: (row: Record<string, unknown>, opener: HTMLElement) =>
267
+ emitted.push([row, opener]),
268
+ }),
269
+ );
270
+ const cells = Array.from(c.querySelectorAll<HTMLElement>(".hk-table-manage-cell"));
271
+ expect(cells).toHaveLength(3);
272
+ // A gated-off row renders an EMPTY cell — no ⋯ at all, not a dead
273
+ // button (roster convention: permission gates hide the affordance).
274
+ expect(cells[1]!.querySelector("button")).toBeNull();
275
+ // A read-only row keeps the affordance but refuses activation.
276
+ const readonlyBtn = cells[2]!.querySelector("button") as HTMLButtonElement;
277
+ expect(readonlyBtn.disabled).toBe(true);
278
+ readonlyBtn.click();
279
+ await settle();
280
+ expect(emitted).toHaveLength(0);
281
+ // The on row runs the normal path.
282
+ const onBtn = cells[0]!.querySelector("button") as HTMLButtonElement;
283
+ onBtn.click();
284
+ await settle();
285
+ expect(emitted).toHaveLength(1);
286
+ expect(emitted[0]![0]).toBe(rows[0]);
287
+ });
288
+ });
289
+
290
+ describe("HkTable interaction stylesheet", () => {
291
+ const here = dirname(fileURLToPath(import.meta.url));
292
+ const scss = readFileSync(join(here, "HkTable.scss"), "utf-8");
293
+
294
+ it("scopes the selection kill + copy cursor under data-copy-cells", () => {
295
+ expect(scss).toContain(".hk-table[data-copy-cells]");
296
+ const block = scss.slice(scss.indexOf(".hk-table[data-copy-cells]"));
297
+ expect(block).toContain("user-select: none");
298
+ expect(block).toContain("-webkit-user-select: none");
299
+ expect(block).toContain("cursor: copy");
300
+ });
301
+
302
+ it("the manage cell is a control track, not a copy surface", () => {
303
+ const block = scss.slice(scss.indexOf(".hk-table[data-copy-cells]"));
304
+ expect(block).toContain(".hk-table-manage-cell");
305
+ expect(block.indexOf("cursor: default")).toBeGreaterThan(-1);
306
+ });
307
+ });
@@ -399,3 +399,31 @@
399
399
  transition: none;
400
400
  }
401
401
  }
402
+
403
+ /* ── Table interaction contract (2026-10-18 user direction) ───────────
404
+ Table content is not prose. While the copy layer is enabled
405
+ (`copy-cells`, the default), body cells kill drag mass-selection
406
+ (user-select: none) and advertise the click-to-copy affordance with
407
+ the `copy` cursor — text clicks run the shared clipboard-with-toast
408
+ path (HkTable.tsx). Both halves scope under `data-copy-cells`, so a
409
+ consumer that opts out gets plain selectable text back wholesale. */
410
+ .hk-table[data-copy-cells] {
411
+ .hk-table-cell {
412
+ user-select: none;
413
+ -webkit-user-select: none;
414
+ cursor: copy;
415
+ }
416
+
417
+ /* The row-actions column is a control, not text — no copy cursor. */
418
+ .hk-table-manage-cell {
419
+ cursor: default;
420
+ }
421
+ }
422
+
423
+ /* The standard trailing row-actions column (HkRowActions trigger):
424
+ fixed narrow track, centered, never wrapping. */
425
+ .hk-table-manage-cell {
426
+ width: 36px;
427
+ text-align: center;
428
+ white-space: nowrap;
429
+ }
@@ -7,6 +7,9 @@ import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composa
7
7
  import { onceFrame } from "../runtime/animationBus";
8
8
  import { scheduleCronAfter, type CronHandle } from "../runtime/cronBus";
9
9
  import { ancestorZoom } from "../runtime/cssZoom";
10
+ import { useClipboardWithToast } from "../runtime/useClipboard";
11
+ import { useToast } from "../runtime/useToast";
12
+ import HkRowActions from "./HkRowActions";
10
13
  import "./HkTable.scss";
11
14
 
12
15
  interface Column {
@@ -15,8 +18,31 @@ interface Column {
15
18
  width?: string;
16
19
  sortable?: boolean;
17
20
  align?: "left" | "center" | "right";
21
+ /** 2026-10-18 table-interaction contract: cells are not prose — a
22
+ * click on a cell's plain text copies it (shared clipboard-with-toast
23
+ * path). Set `copyable: false` to opt one column out (controls-only
24
+ * columns, editable previews); the copy click layer never fires
25
+ * there. Default true. */
26
+ copyable?: boolean;
18
27
  }
19
28
 
29
+ /** Per-row gate for the manage column: `true` renders the trigger;
30
+ * `false` renders an empty cell (permission gates, rows without
31
+ * actions — the roster convention is no ⋯ at all, not a dead button);
32
+ * `"disabled"` keeps the affordance but refuses activation (read-only
33
+ * rows that must still show where actions would live). */
34
+ export type HkRowManageGate =
35
+ | boolean
36
+ | ((row: Record<string, unknown>) => boolean | "disabled");
37
+
38
+ /** Elements whose clicks mean their own action — the cell copy layer
39
+ * must never swallow them. Matches the HkCopyBadge interactive guard:
40
+ * real controls, button-role elements, popup owners, and the explicit
41
+ * `[data-no-copy]` escape hatch for slot content that wants its own
42
+ * click semantics without becoming a control. */
43
+ const INTERACTIVE_SELECTOR =
44
+ 'button, a, input, select, textarea, label, [role="button"], [aria-haspopup], [data-no-copy]';
45
+
20
46
  /** Duration of every drag motion this component paints: the arrangement
21
47
  * shift (placeholder onto the resolved slot, neighbours gliding across)
22
48
  * and the ghost's settle fade on release. One number so the transition
@@ -44,11 +70,34 @@ export default defineComponent({
44
70
  * faithfully encode the visual move: reordering is INERT until the
45
71
  * sort is cleared (the handles render dimmed and refuse presses). */
46
72
  draggable: { type: Boolean, default: false },
73
+ /** 2026-10-18 table-interaction contract: table content is not
74
+ * prose. When on (the default for every roster/admin table), plain
75
+ * text in a cell copies on click through the shared
76
+ * clipboard-with-toast path, drag mass-selection is dead
77
+ * (user-select: none, scoped by `data-copy-cells`), and per-column
78
+ * opt-out rides `Column.copyable`. Turn off only for exotic embeds
79
+ * that genuinely need selectable text. */
80
+ copyCells: { type: Boolean, default: true },
81
+ /** Render the trailing row-actions column (HkRowActions trigger).
82
+ * This is the 2026-10-12 row-manage ruling's ⋯ button, promoted
83
+ * into the table so call sites stop hand-rolling it: enable the
84
+ * column, handle `manage`, done. The button — not cell bodies —
85
+ * remains the ONLY row-action affordance (2026-10-18 ruling).
86
+ * Pass a gate function (HkRowManageGate) to hide the trigger per
87
+ * row (no permission, no actions for that row) or render it
88
+ * disabled (read-only rows that keep the affordance). */
89
+ manageable: {
90
+ type: [Boolean, Function] as PropType<HkRowManageGate>,
91
+ default: false,
92
+ },
47
93
  emptyText: { type: String, default: "" },
48
94
  },
49
95
  emits: {
50
96
  "update:selectedRows": (_rows: Record<string, unknown>[]) => true,
51
97
  reorder: (_fromIndex: number, _toIndex: number) => true,
98
+ /** `manageable`: the row's ⋯ trigger was pressed. The opener is the
99
+ * trigger button (focus-restore anchor for a manage window). */
100
+ manage: (_row: Record<string, unknown>, _opener: HTMLElement) => true,
52
101
  },
53
102
  setup(props, { emit, slots }) {
54
103
  const { t } = useI18n();
@@ -56,6 +105,74 @@ export default defineComponent({
56
105
  const sortDirection = ref<"asc" | "desc">("asc");
57
106
  const selectedRowKeys = ref<Set<string>>(new Set());
58
107
 
108
+ // The cell copy layer shares the house clipboard-with-toast path
109
+ // (same runtime HkCopyBadge uses), so the confirmation toast —
110
+ // top-right band, localized defaults — is identical everywhere.
111
+ const clipboard = useClipboardWithToast(useToast());
112
+ const copyHint = computed(() => t("hikari::clipboard.clickToCopy", "Click to copy"));
113
+
114
+ /** Copy the text of a TEXT LEAF — an element with no element
115
+ * children. Layout wrappers (rows of chips, badges beside prose)
116
+ * never become a copy payload: a click there copies nothing rather
117
+ * than sweeping up every descendant's text into one string. */
118
+ function copyLeafText(el: HTMLElement): void {
119
+ if (el.firstElementChild) return;
120
+ const text = (el.textContent ?? "").trim();
121
+ if (text) void clipboard.copy(text);
122
+ }
123
+
124
+ /** The td's copy click layer. Partition, in order:
125
+ * 1. Interactive descendants own their clicks outright (toggles,
126
+ * badges, the ⋯ trigger) — return.
127
+ * 2. A click on the td itself is a text click ONLY when the cell
128
+ * is pure text (no element children); a click on the padding
129
+ * AROUND slot content does nothing — per the 2026-10-18 ruling,
130
+ * cell bodies do NOT open row actions.
131
+ * 3. Otherwise the target must be a text leaf; layout containers
132
+ * (gaps between chips) do nothing. */
133
+ function onCellClick(e: MouseEvent, col: Column, cellEl: HTMLElement): void {
134
+ if (!props.copyCells || col.copyable === false) return;
135
+ const target = e.target as HTMLElement | null;
136
+ if (!target) return;
137
+ if (target !== cellEl) {
138
+ if (target.closest(INTERACTIVE_SELECTOR)) return;
139
+ copyLeafText(target);
140
+ return;
141
+ }
142
+ if (cellEl.firstElementChild) return;
143
+ copyLeafText(cellEl);
144
+ }
145
+
146
+ /** Resolve the row's manage mode from the gate: the column renders
147
+ * whenever `manageable` is set, but the TRIGGER obeys the per-row
148
+ * verdict — off (empty cell, roster convention: no permission, no
149
+ * ⋯), disabled (read-only rows keep the affordance), or on. */
150
+ function rowManageMode(row: Record<string, unknown>): "off" | "disabled" | "on" {
151
+ const gate = props.manageable;
152
+ if (!gate) return "off";
153
+ if (typeof gate === "function") {
154
+ const verdict = gate(row);
155
+ if (verdict === "disabled") return "disabled";
156
+ return verdict ? "on" : "off";
157
+ }
158
+ return "on";
159
+ }
160
+
161
+ function renderManageCell(row: Record<string, unknown>) {
162
+ const mode = rowManageMode(row);
163
+ return (
164
+ <td class="hk-table-cell hk-table-manage-cell">
165
+ {mode !== "off" ? (
166
+ <HkRowActions
167
+ size={28}
168
+ disabled={mode === "disabled"}
169
+ onOpen={(opener: HTMLElement) => emit("manage", row, opener)}
170
+ />
171
+ ) : null}
172
+ </td>
173
+ );
174
+ }
175
+
59
176
  // Wide tables scroll horizontally in the wrapper — attach the shared
60
177
  // overlay scrollbar (horizontal track) once on mount.
61
178
  const wrapperRef = ref<HTMLElement>();
@@ -587,14 +704,22 @@ export default defineComponent({
587
704
  }));
588
705
 
589
706
  const totalCols = computed(
590
- () => props.columns.length + (props.selectable ? 1 : 0) + (props.draggable ? 1 : 0)
707
+ () =>
708
+ props.columns.length +
709
+ (props.selectable ? 1 : 0) +
710
+ (props.draggable ? 1 : 0) +
711
+ (props.manageable ? 1 : 0)
591
712
  );
592
713
 
593
714
  return () => {
594
715
  return (
595
716
  <div ref={wrapperHostRef} class="hk-table-host">
596
717
  <div ref={wrapperRef} class="hk-table-wrapper">
597
- <table class={tableCls.value} {...dragScope.value}>
718
+ <table
719
+ class={tableCls.value}
720
+ {...dragScope.value}
721
+ data-copy-cells={props.copyCells ? "" : undefined}
722
+ >
598
723
  {props.caption && <caption class="hk-table-sr-only">{props.caption}</caption>}
599
724
  <thead>
600
725
  <tr class="hk-table-header-row">
@@ -668,6 +793,11 @@ export default defineComponent({
668
793
  </th>
669
794
  );
670
795
  })}
796
+ {props.manageable && (
797
+ <th class="hk-table-header-cell hk-table-manage-head" style={{ width: "36px" }}>
798
+ <span class="hk-table-sr-only">{t("hikari::table.rowActions", "Row actions")}</span>
799
+ </th>
800
+ )}
671
801
  </tr>
672
802
  </thead>
673
803
  <tbody class="hk-table-body">
@@ -761,16 +891,32 @@ export default defineComponent({
761
891
  </label>
762
892
  </td>
763
893
  )}
764
- {props.columns.map((col) => (
765
- <td
766
- key={col.key}
767
- class={["hk-table-cell", col.align ? `hk-text-${col.align}` : ""]}
768
- >
769
- {slots[`cell-${col.key}`]
770
- ? slots[`cell-${col.key}`]!({ row, value: row[col.key], index })
771
- : row[col.key]}
772
- </td>
773
- ))}
894
+ {props.columns.map((col) => {
895
+ const isSlotCell = Boolean(slots[`cell-${col.key}`]);
896
+ const cellCopyable =
897
+ props.copyCells && col.copyable !== false;
898
+ return (
899
+ <td
900
+ key={col.key}
901
+ class={["hk-table-cell", col.align ? `hk-text-${col.align}` : ""]}
902
+ // The hover affordance rides only on
903
+ // default-rendered text cells — slot cells own
904
+ // their hints (HkCopyBadge carries the house
905
+ // tooltip already).
906
+ title={
907
+ cellCopyable && !isSlotCell ? copyHint.value : undefined
908
+ }
909
+ onClick={(e: MouseEvent) =>
910
+ onCellClick(e, col, e.currentTarget as HTMLElement)
911
+ }
912
+ >
913
+ {isSlotCell
914
+ ? slots[`cell-${col.key}`]!({ row, value: row[col.key], index })
915
+ : row[col.key]}
916
+ </td>
917
+ );
918
+ })}
919
+ {props.manageable && renderManageCell(row)}
774
920
  </tr>
775
921
  );
776
922
  })
@@ -169,6 +169,7 @@
169
169
  "hikari::statusBar.tier.ws": "WebSocket",
170
170
  "hikari::statusPill.ms": " مللي ثانية",
171
171
  "hikari::table.noData": "لا توجد بيانات",
172
+ "hikari::table.rowActions": "إجراءات الصف",
172
173
  "hikari::table.dragHandle": "اسحب لإعادة الترتيب",
173
174
  "hikari::table.dragHandleRow": "إعادة ترتيب {label}",
174
175
  "hikari::tablePage.emptyTitle": "لا توجد بيانات",
@@ -169,6 +169,7 @@
169
169
  "hikari::statusBar.tier.ws": "WebSocket",
170
170
  "hikari::statusPill.ms": " ms",
171
171
  "hikari::table.noData": "Keine Daten",
172
+ "hikari::table.rowActions": "Zeilenaktionen",
172
173
  "hikari::table.dragHandle": "Zum Neuanordnen ziehen",
173
174
  "hikari::table.dragHandleRow": "„{label}“ neu anordnen",
174
175
  "hikari::tablePage.emptyTitle": "Keine Daten",
@@ -169,6 +169,7 @@
169
169
  "hikari::statusBar.tier.ws": "WebSocket",
170
170
  "hikari::statusPill.ms": " ms",
171
171
  "hikari::table.noData": "No data",
172
+ "hikari::table.rowActions": "Row actions",
172
173
  "hikari::table.dragHandle": "Drag to reorder",
173
174
  "hikari::table.dragHandleRow": "Reorder {label}",
174
175
  "hikari::tablePage.emptyTitle": "No data",
@@ -169,6 +169,7 @@
169
169
  "hikari::statusBar.tier.ws": "WebSocket",
170
170
  "hikari::statusPill.ms": " ms",
171
171
  "hikari::table.noData": "Sin datos",
172
+ "hikari::table.rowActions": "Acciones de fila",
172
173
  "hikari::table.dragHandle": "Arrastra para reordenar",
173
174
  "hikari::table.dragHandleRow": "Reordenar «{label}»",
174
175
  "hikari::tablePage.emptyTitle": "Sin datos",
@@ -169,6 +169,7 @@
169
169
  "hikari::statusBar.tier.ws": "WebSocket",
170
170
  "hikari::statusPill.ms": " ms",
171
171
  "hikari::table.noData": "Aucune donnée",
172
+ "hikari::table.rowActions": "Actions de ligne",
172
173
  "hikari::table.dragHandle": "Glisser pour réordonner",
173
174
  "hikari::table.dragHandleRow": "Réordonner « {label} »",
174
175
  "hikari::tablePage.emptyTitle": "Aucune donnée",
@@ -169,6 +169,7 @@
169
169
  "hikari::statusBar.tier.ws": "WebSocket",
170
170
  "hikari::statusPill.ms": " ミリ秒",
171
171
  "hikari::table.noData": "データなし",
172
+ "hikari::table.rowActions": "行の操作",
172
173
  "hikari::table.dragHandle": "ドラッグで並べ替え",
173
174
  "hikari::table.dragHandleRow": "「{label}」の順序を変更",
174
175
  "hikari::tablePage.emptyTitle": "データがありません",
@@ -169,6 +169,7 @@
169
169
  "hikari::statusBar.tier.ws": "WebSocket",
170
170
  "hikari::statusPill.ms": " 밀리초",
171
171
  "hikari::table.noData": "데이터 없음",
172
+ "hikari::table.rowActions": "행 작업",
172
173
  "hikari::table.dragHandle": "드래그하여 순서 변경",
173
174
  "hikari::table.dragHandleRow": "'{label}' 순서 변경",
174
175
  "hikari::tablePage.emptyTitle": "데이터 없음",
@@ -169,6 +169,7 @@
169
169
  "hikari::statusBar.tier.ws": "WebSocket",
170
170
  "hikari::statusPill.ms": " ms",
171
171
  "hikari::table.noData": "Sem dados",
172
+ "hikari::table.rowActions": "Ações de linha",
172
173
  "hikari::table.dragHandle": "Arraste para reordenar",
173
174
  "hikari::table.dragHandleRow": "Reordenar «{label}»",
174
175
  "hikari::tablePage.emptyTitle": "Sem dados",
@@ -169,6 +169,7 @@
169
169
  "hikari::statusBar.tier.ws": "WebSocket",
170
170
  "hikari::statusPill.ms": " мс",
171
171
  "hikari::table.noData": "Нет данных",
172
+ "hikari::table.rowActions": "Действия со строкой",
172
173
  "hikari::table.dragHandle": "Перетащите, чтобы изменить порядок",
173
174
  "hikari::table.dragHandleRow": "Изменить порядок: {label}",
174
175
  "hikari::tablePage.emptyTitle": "Нет данных",
@@ -169,6 +169,7 @@
169
169
  "hikari::statusBar.tier.ws": "WebSocket",
170
170
  "hikari::statusPill.ms": " 毫秒",
171
171
  "hikari::table.noData": "暂无数据",
172
+ "hikari::table.rowActions": "更多操作",
172
173
  "hikari::table.dragHandle": "拖动以调整顺序",
173
174
  "hikari::table.dragHandleRow": "调整「{label}」的顺序",
174
175
  "hikari::tablePage.emptyTitle": "暂无数据",
@@ -169,6 +169,7 @@
169
169
  "hikari::statusBar.tier.ws": "WebSocket",
170
170
  "hikari::statusPill.ms": " 毫秒",
171
171
  "hikari::table.noData": "暫無數據",
172
+ "hikari::table.rowActions": "更多操作",
172
173
  "hikari::table.dragHandle": "拖曳以調整順序",
173
174
  "hikari::table.dragHandleRow": "調整「{label}」的順序",
174
175
  "hikari::tablePage.emptyTitle": "暫無資料",
package/src/index.ts CHANGED
@@ -96,6 +96,7 @@ export { default as HSlider } from "./components/HkSlider";
96
96
  export { default as HSpinner } from "./components/HkSpinner";
97
97
  export { default as HSliderField } from "./components/HkSliderField";
98
98
  export { default as HSwitch } from "./components/HkSwitch";
99
+ export { default as HRowActions } from "./components/HkRowActions";
99
100
  export { default as HTable } from "./components/HkTable";
100
101
  export { default as HTabs } from "./components/HkTabs";
101
102
  export { default as HTag } from "./components/HkTag";
@@ -655,6 +656,7 @@ export { default as HkContextMenuProvider } from "./components/HkContextMenuProv
655
656
  export { default as HkMenuPanel } from "./components/HkMenuPanel";
656
657
  export { default as HkMenuActionItem } from "./components/HkMenuActionItem";
657
658
  export { default as HkMenuIdentityItem } from "./components/HkMenuIdentityItem";
659
+ export { default as HkRowActions } from "./components/HkRowActions";
658
660
  export { default as HkPopupSelect } from "./components/HkPopupSelect";
659
661
  export { default as HkProgressBar } from "./components/HkProgressBar";
660
662
  export { default as HkProgressDialog } from "./components/HkProgressDialog";
@@ -673,7 +675,7 @@ export { default as HkTokenInput, compactTokenCount, type TokenUnit } from "./co
673
675
  export { default as HkSliderField } from "./components/HkSliderField";
674
676
  export { default as HkSpinner } from "./components/HkSpinner";
675
677
  export { default as HkSwitch } from "./components/HkSwitch";
676
- export { default as HkTable } from "./components/HkTable";
678
+ export { default as HkTable, type HkRowManageGate } from "./components/HkTable";
677
679
  export { default as HkTabs } from "./components/HkTabs";
678
680
  export { default as HkTag } from "./components/HkTag";
679
681
  export { default as HkTextarea } from "./components/HkTextarea";