@celestia-island/hikari 0.55.184 → 0.55.186

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.184",
3
+ "version": "0.55.186",
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>
@@ -108,6 +108,23 @@
108
108
  }
109
109
  }
110
110
 
111
+ /* The per-row actions lane: renders only when the host supplies the
112
+ * `actions` slot (render-side guard), as a SIBLING of the content lane
113
+ * inside the row's flex layout — never a child of it, so the content
114
+ * lane's `> :only-child` fill above keeps stretching single-root rows
115
+ * even when actions ride along. Pinning is structural: the content lane
116
+ * keeps flex: 1 and this lane only refuses to shrink, so the actions
117
+ * land on the row's far edge — no margin-left:auto patch (it would
118
+ * paper over a content-lane regression instead of letting the fill rule
119
+ * fail loudly) and no directional property, so the lane follows the
120
+ * row's inline direction under RTL too. */
121
+ .hk-draggable-list-actions {
122
+ display: flex;
123
+ align-items: center;
124
+ gap: var(--space-8, 0.5rem);
125
+ flex-shrink: 0;
126
+ }
127
+
111
128
  .hk-draggable-list-ghost {
112
129
  position: fixed;
113
130
  top: 0;
@@ -81,3 +81,30 @@ describe("HkDraggableList content-lane fill sheet", () => {
81
81
  expect(fillRules[0]).toContain(":only-child");
82
82
  });
83
83
  });
84
+
85
+ describe("HkDraggableList actions-lane sheet", () => {
86
+ const css = listCss();
87
+
88
+ it("keeps the actions lane fixed at its natural width", () => {
89
+ const body = ruleBody(css, ".hk-draggable-list-actions");
90
+ expect(body, "the actions-lane rule must exist").not.toBeNull();
91
+ expect(body).toContain("display: flex");
92
+ expect(body).toContain("align-items: center");
93
+ expect(body).toContain("flex-shrink: 0");
94
+ // Multiple host buttons in one lane need the lane's own gap so they
95
+ // never fuse into one cluster against the row edge.
96
+ expect(body).toContain("gap:");
97
+ });
98
+
99
+ // The right-edge pinning is STRUCTURAL, not a margin patch: the item
100
+ // row is a flex row and the content lane keeps flex: 1, so the lane
101
+ // needs nothing beyond shrink-resistance. A margin-left: auto here
102
+ // would paper over a future content-lane regression instead of
103
+ // letting the fill sheet fail loudly — pin by ownership, not by patch.
104
+ it("pins by the content lane's flex: 1, not a margin patch", () => {
105
+ const body = ruleBody(css, ".hk-draggable-list-actions");
106
+ expect(body).not.toContain("margin-left: auto");
107
+ const content = ruleBody(css, ".hk-draggable-list-content");
108
+ expect(content).toContain("flex: 1");
109
+ });
110
+ });
@@ -390,3 +390,138 @@ describe("HkDraggableList press ergonomics (clickable rows)", () => {
390
390
  }
391
391
  });
392
392
  });
393
+
394
+ describe("HkDraggableList actions lane", () => {
395
+ /** The standard row-actions contract: hosts inject trailing buttons
396
+ * through the scoped `actions` slot instead of hand-rolling
397
+ * ml-auto/flex-1 spacers inside the row content (chest's pricing
398
+ * ladder shipped exactly such a hand-rolled group and its buttons
399
+ * hugged the row's text mid-card). The lane renders as a SIBLING of
400
+ * the content lane — never inside it — so the content sheet's
401
+ * `> :only-child` fill keeps matching single-root rows even when
402
+ * actions ride along (sheet twin: HkDraggableList.styles.test.ts). */
403
+
404
+ function mountWithActions(items: { key: string }[], opts?: { lockedKeys?: string[] }): HTMLElement {
405
+ const container = document.createElement("div");
406
+ document.body.appendChild(container);
407
+ const app = createApp({
408
+ render: () =>
409
+ h(
410
+ HDraggableList,
411
+ { items, lockedKeys: opts?.lockedKeys ?? [] },
412
+ {
413
+ item: ({ item }: { item: { key: string } }) =>
414
+ h("span", { class: "row-body" }, item.key),
415
+ actions: ({
416
+ item,
417
+ index,
418
+ locked,
419
+ }: {
420
+ item: { key: string };
421
+ index: number;
422
+ locked: boolean;
423
+ }) =>
424
+ h("span", {
425
+ class: "row-actions",
426
+ "data-key": item.key,
427
+ "data-index": index,
428
+ "data-locked": locked,
429
+ }),
430
+ },
431
+ ),
432
+ });
433
+ app.mount(container);
434
+ mounts.push({ app, container });
435
+ return container.querySelector(".hk-draggable-list") as HTMLElement;
436
+ }
437
+
438
+ it("renders a right-lane sibling per row only when the slot is provided", () => {
439
+ const root = mountWithActions([{ key: "a" }, { key: "b" }]);
440
+
441
+ const lanes = Array.from(root.querySelectorAll(".hk-draggable-list-actions"));
442
+ expect(lanes).toHaveLength(2);
443
+ for (const lane of lanes) {
444
+ // Sibling of the content lane, not a child of it — the :only-child
445
+ // fill contract above must keep matching the row body.
446
+ expect(lane.closest(".hk-draggable-list-content")).toBeNull();
447
+ expect(lane.previousElementSibling?.classList.contains("hk-draggable-list-content")).toBe(
448
+ true,
449
+ );
450
+ }
451
+ });
452
+
453
+ it("keeps the content lane's :only-child fill intact alongside the lane", () => {
454
+ const root = mountWithActions([{ key: "a" }]);
455
+
456
+ const content = root.querySelector(".hk-draggable-list-content") as HTMLElement;
457
+ const elementChildren = Array.from(content.children);
458
+ expect(elementChildren).toHaveLength(1);
459
+ expect(elementChildren[0]!.classList.contains("row-body")).toBe(true);
460
+ });
461
+
462
+ it("scopes the slot with item, index, and locked-ness", () => {
463
+ const root = mountWithActions([{ key: "a" }, { key: "b" }], { lockedKeys: ["b"] });
464
+
465
+ const marks = Array.from(root.querySelectorAll(".row-actions")) as HTMLElement[];
466
+ expect(marks).toHaveLength(2);
467
+ expect(marks.map((m) => `${m.dataset.key}:${m.dataset.index}:${m.dataset.locked}`).join(",")).toBe(
468
+ "a:0:false,b:1:true",
469
+ );
470
+ });
471
+
472
+ it("renders no lane for hosts that do not adopt the slot", () => {
473
+ const root = mountList(["a"], []);
474
+
475
+ expect(root.querySelectorAll(".hk-draggable-list-actions")).toHaveLength(0);
476
+ });
477
+
478
+ it("keeps a press on a lane button from arming a drag — the handle still drags", async () => {
479
+ /** The lane exists to host buttons, so the row's interactive-content
480
+ * exclusion (button, a, input, textarea, select, [contenteditable])
481
+ * must own every press that starts on one — and a lane FULL of
482
+ * buttons must leave the handle's drag path untouched. */
483
+ const PointerEventCtor: typeof MouseEvent =
484
+ (globalThis as { PointerEvent?: typeof MouseEvent }).PointerEvent ?? MouseEvent;
485
+ const fire = (el: Element, type: string, x = 0, y = 0) =>
486
+ el.dispatchEvent(
487
+ new PointerEventCtor(type, { bubbles: true, cancelable: true, button: 0, clientX: x, clientY: y }),
488
+ );
489
+ const fireWin = (type: string, x = 0, y = 0) =>
490
+ window.dispatchEvent(new PointerEventCtor(type, { bubbles: true, cancelable: true, clientX: x, clientY: y }));
491
+
492
+ const container = document.createElement("div");
493
+ document.body.appendChild(container);
494
+ const app = createApp({
495
+ render: () =>
496
+ h(HDraggableList, { items: [{ key: "a" }] }, {
497
+ item: ({ item }: { item: { key: string } }) => h("span", { class: "row-body" }, item.key),
498
+ actions: () => [
499
+ h("button", { class: "lane-button", type: "button" }, "✏"),
500
+ h("button", { class: "lane-button", type: "button" }, "⋯"),
501
+ ],
502
+ }),
503
+ });
504
+ app.mount(container);
505
+ mounts.push({ app, container });
506
+ const root = container.querySelector(".hk-draggable-list") as HTMLElement;
507
+ const row = root.querySelector(".hk-draggable-list-item") as HTMLElement;
508
+ const button = row.querySelector(".lane-button") as HTMLElement;
509
+ const handle = row.querySelector(".hk-draggable-list-handle") as HTMLElement;
510
+
511
+ // Press ON the button (target = button, bubbling to the row) and
512
+ // travel far past the arm threshold: no drag may publish.
513
+ fire(button, "pointerdown", 10, 10);
514
+ fireWin("pointermove", 30, 40);
515
+ await nextTick();
516
+ expect(row.hasAttribute("data-dragging")).toBe(false);
517
+
518
+ // The same gesture on the handle still arms — a button-filled lane
519
+ // never breaks the drag path.
520
+ fire(handle, "pointerdown", 10, 10);
521
+ fireWin("pointermove", 30, 40);
522
+ await nextTick();
523
+ expect(row.getAttribute("data-dragging")).not.toBeNull();
524
+
525
+ fireWin("pointerup");
526
+ });
527
+ });
@@ -334,6 +334,19 @@ export default defineComponent({
334
334
  <div class="hk-draggable-list-content">
335
335
  {slots.item?.({ item, index, locked })}
336
336
  </div>
337
+ {/* The standard row-actions lane: when the host provides
338
+ * an `actions` slot, every row renders this right-pinned
339
+ * lane (the content lane keeps flex: 1, so the buttons
340
+ * land on the row's far edge structurally — hosts stop
341
+ * hand-rolling ml-auto/flex-1 spacers). The lane is
342
+ * host-decided content: locked rows may still carry
343
+ * actions (chest's pricing ladder), so locked-ness is
344
+ * only reported through the slot scope. */}
345
+ {slots.actions ? (
346
+ <div class="hk-draggable-list-actions">
347
+ {slots.actions({ item, index, locked })}
348
+ </div>
349
+ ) : null}
337
350
  </div>
338
351
  );
339
352
  })}
@@ -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";