@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 +1 -1
- package/src/components/HkAdminTablePage.test.tsx +23 -0
- package/src/components/HkAdminTablePage.tsx +16 -0
- package/src/components/HkHeaderPicker.scss +26 -1
- package/src/components/HkHeaderPicker.test.tsx +25 -3
- package/src/components/HkHeaderPicker.tsx +11 -2
- package/src/components/HkRowActions.test.tsx +86 -0
- package/src/components/HkRowActions.tsx +128 -0
- package/src/components/HkTable.interactions.test.tsx +307 -0
- package/src/components/HkTable.scss +28 -0
- package/src/components/HkTable.tsx +158 -12
- package/src/i18n/locales/ar/components.json +1 -0
- package/src/i18n/locales/de/components.json +1 -0
- package/src/i18n/locales/en/components.json +1 -0
- package/src/i18n/locales/es/components.json +1 -0
- package/src/i18n/locales/fr/components.json +1 -0
- package/src/i18n/locales/ja/components.json +1 -0
- package/src/i18n/locales/ko/components.json +1 -0
- package/src/i18n/locales/pt/components.json +1 -0
- package/src/i18n/locales/ru/components.json +1 -0
- package/src/i18n/locales/zh-Hans/components.json +1 -0
- package/src/i18n/locales/zh-Hant/components.json +1 -0
- package/src/index.ts +3 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
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
|
-
() =>
|
|
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
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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";
|