@celestia-island/hikari 0.55.114 → 0.55.116
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.tsx +14 -2
- package/src/components/HkBadge.scss +5 -0
- package/src/components/HkCopyBadge.scss +20 -0
- package/src/components/HkCopyBadge.test.tsx +172 -0
- package/src/components/HkCopyBadge.tsx +140 -0
- package/src/components/HkTable.drag.test.tsx +499 -0
- package/src/components/HkTable.scss +86 -0
- package/src/components/HkTable.tsx +203 -7
- package/src/i18n/locales/ar/components.json +2 -0
- package/src/i18n/locales/ar/platform.json +1 -0
- package/src/i18n/locales/de/components.json +2 -0
- package/src/i18n/locales/de/platform.json +1 -0
- package/src/i18n/locales/en/components.json +2 -0
- package/src/i18n/locales/en/platform.json +1 -0
- package/src/i18n/locales/es/components.json +2 -0
- package/src/i18n/locales/es/platform.json +1 -0
- package/src/i18n/locales/fr/components.json +2 -0
- package/src/i18n/locales/fr/platform.json +1 -0
- package/src/i18n/locales/ja/components.json +2 -0
- package/src/i18n/locales/ja/platform.json +1 -0
- package/src/i18n/locales/ko/components.json +2 -0
- package/src/i18n/locales/ko/platform.json +1 -0
- package/src/i18n/locales/pt/components.json +2 -0
- package/src/i18n/locales/pt/platform.json +1 -0
- package/src/i18n/locales/ru/components.json +2 -0
- package/src/i18n/locales/ru/platform.json +1 -0
- package/src/i18n/locales/zh-Hans/components.json +2 -0
- package/src/i18n/locales/zh-Hans/platform.json +1 -0
- package/src/i18n/locales/zh-Hant/components.json +2 -0
- package/src/i18n/locales/zh-Hant/platform.json +1 -0
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -50,8 +50,14 @@ export const HkAdminTablePage = defineComponent({
|
|
|
50
50
|
rowKey: { type: String, default: "id" },
|
|
51
51
|
emptyTitle: { type: String, default: "" },
|
|
52
52
|
emptyDescription: { type: String as PropType<string | undefined>, default: undefined },
|
|
53
|
+
/** Left grip column with drag-to-reorder (forwarded to HTable; the
|
|
54
|
+
* `reorder` event is forwarded verbatim — indices into `rows`). */
|
|
55
|
+
draggable: { type: Boolean, default: false },
|
|
53
56
|
},
|
|
54
|
-
|
|
57
|
+
emits: {
|
|
58
|
+
reorder: (_fromIndex: number, _toIndex: number) => true,
|
|
59
|
+
},
|
|
60
|
+
setup(props, { emit, slots }) {
|
|
55
61
|
|
|
56
62
|
return () => {
|
|
57
63
|
const { t } = useI18n();
|
|
@@ -96,7 +102,13 @@ export const HkAdminTablePage = defineComponent({
|
|
|
96
102
|
<div class="hk-admin-table-page-body">{slots.body()}</div>
|
|
97
103
|
) : (
|
|
98
104
|
<HCard padded={false}>
|
|
99
|
-
<HTable
|
|
105
|
+
<HTable
|
|
106
|
+
columns={props.columns}
|
|
107
|
+
rows={props.rows}
|
|
108
|
+
rowKey={props.rowKey}
|
|
109
|
+
draggable={props.draggable}
|
|
110
|
+
onReorder={(from: number, to: number) => emit("reorder", from, to)}
|
|
111
|
+
>
|
|
100
112
|
{slots}
|
|
101
113
|
</HTable>
|
|
102
114
|
</HCard>
|
|
@@ -27,6 +27,11 @@
|
|
|
27
27
|
color: var(--hk-badge-text);
|
|
28
28
|
line-height: 1.4;
|
|
29
29
|
white-space: nowrap;
|
|
30
|
+
/* Tags are labels, not prose: dragging across a row of pills must never
|
|
31
|
+
start a text selection (the workspace tag contract makes HkCopyBadge
|
|
32
|
+
the copy path instead). */
|
|
33
|
+
user-select: none;
|
|
34
|
+
-webkit-user-select: none;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
/* ---------- size ---------- */
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* HkCopyBadge — the interactive tag face. The badge itself stays pure;
|
|
3
|
+
* this sheet only adds the interactivity contract: pointer cursor and a
|
|
4
|
+
* pressed/keyboard-focus affordance that keeps the HkBadge geometry.
|
|
5
|
+
* (Text selection is banned on every badge at the `.hk-badge` base —
|
|
6
|
+
* tags are labels, not prose.)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
.hk-badge.hk-copy-badge {
|
|
10
|
+
cursor: pointer;
|
|
11
|
+
|
|
12
|
+
&:active {
|
|
13
|
+
filter: brightness(0.92);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
&:focus-visible {
|
|
17
|
+
outline: 2px solid rgb(var(--color-primary) / 60%);
|
|
18
|
+
outline-offset: 1px;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, h, defineComponent } from "vue";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import HkCopyBadge from "./HkCopyBadge";
|
|
8
|
+
import { useToast } from "../runtime/useToast";
|
|
9
|
+
|
|
10
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
11
|
+
|
|
12
|
+
function mount(renderNode: () => ReturnType<typeof h>) {
|
|
13
|
+
const container = document.createElement("div");
|
|
14
|
+
document.body.appendChild(container);
|
|
15
|
+
const app = createApp({ render: renderNode });
|
|
16
|
+
app.mount(container);
|
|
17
|
+
mounts.push({ app, container });
|
|
18
|
+
return container;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
23
|
+
app.unmount();
|
|
24
|
+
container.remove();
|
|
25
|
+
}
|
|
26
|
+
useToast().toasts.splice(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// `navigator.clipboard` does not exist under happy-dom — install a
|
|
30
|
+
// controllable stub and restore whatever was there before.
|
|
31
|
+
let writeText: ReturnType<typeof vi.fn>;
|
|
32
|
+
const originalClipboard = navigator.clipboard;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
writeText = vi.fn().mockResolvedValue(undefined);
|
|
36
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
37
|
+
value: { writeText },
|
|
38
|
+
configurable: true,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
44
|
+
value: originalClipboard,
|
|
45
|
+
configurable: true,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("HkCopyBadge", () => {
|
|
50
|
+
it("renders a HkBadge inside the tooltip wrapper with button semantics", () => {
|
|
51
|
+
const c = mount(() => h(HkCopyBadge, { variant: "muted", mono: true }, () => "#d.132"));
|
|
52
|
+
// The badge keeps its HkBadge identity (variant class, mono flag)…
|
|
53
|
+
const badge = c.querySelector(".hk-badge");
|
|
54
|
+
expect(badge).not.toBeNull();
|
|
55
|
+
expect(badge!.classList.contains("hk-badge-muted")).toBe(true);
|
|
56
|
+
expect(badge!.hasAttribute("data-mono")).toBe(true);
|
|
57
|
+
expect(badge!.textContent).toBe("#d.132");
|
|
58
|
+
// …wrapped by the house tooltip, with button semantics on the badge.
|
|
59
|
+
expect(c.querySelector(".hk-tooltip-wrapper")).not.toBeNull();
|
|
60
|
+
expect(badge!.getAttribute("role")).toBe("button");
|
|
61
|
+
expect(badge!.getAttribute("tabindex")).toBe("0");
|
|
62
|
+
expect(badge!.classList.contains("hk-copy-badge")).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("copies the rendered text on click and toasts the localized default", async () => {
|
|
66
|
+
const c = mount(() => h(HkCopyBadge, null, () => "#demiurge.132"));
|
|
67
|
+
(c.querySelector(".hk-badge") as HTMLElement).click();
|
|
68
|
+
await Promise.resolve();
|
|
69
|
+
await Promise.resolve();
|
|
70
|
+
|
|
71
|
+
expect(writeText).toHaveBeenCalledWith("#demiurge.132");
|
|
72
|
+
const success = useToast().toasts.find((t) => t.type === "success");
|
|
73
|
+
expect(success?.messages.some((m) => m.text === "Copied")).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("prefers the value prop over the rendered label as the copy payload", async () => {
|
|
77
|
+
const c = mount(() =>
|
|
78
|
+
h(HkCopyBadge, { value: "demiurge.132" }, () => "#d.132"),
|
|
79
|
+
);
|
|
80
|
+
(c.querySelector(".hk-badge") as HTMLElement).click();
|
|
81
|
+
await Promise.resolve();
|
|
82
|
+
await Promise.resolve();
|
|
83
|
+
|
|
84
|
+
expect(writeText).toHaveBeenCalledWith("demiurge.132");
|
|
85
|
+
expect(writeText).not.toHaveBeenCalledWith("#d.132");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("stops the click from reaching clickable ancestors", async () => {
|
|
89
|
+
const rowClick = vi.fn();
|
|
90
|
+
const Row = defineComponent({
|
|
91
|
+
setup() {
|
|
92
|
+
return () =>
|
|
93
|
+
h("div", { onClick: rowClick }, h(HkCopyBadge, null, () => "#d.132"));
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
const c = mount(() => h(Row));
|
|
97
|
+
(c.querySelector(".hk-badge") as HTMLElement).click();
|
|
98
|
+
await Promise.resolve();
|
|
99
|
+
|
|
100
|
+
expect(writeText).toHaveBeenCalled();
|
|
101
|
+
expect(rowClick).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("activates on Enter and Space but not other keys", async () => {
|
|
105
|
+
const c = mount(() => h(HkCopyBadge, null, () => "x"));
|
|
106
|
+
const badge = c.querySelector(".hk-badge") as HTMLElement;
|
|
107
|
+
|
|
108
|
+
badge.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
109
|
+
await Promise.resolve();
|
|
110
|
+
badge.dispatchEvent(new KeyboardEvent("keydown", { key: " ", bubbles: true }));
|
|
111
|
+
await Promise.resolve();
|
|
112
|
+
expect(writeText).toHaveBeenCalledTimes(2);
|
|
113
|
+
|
|
114
|
+
badge.dispatchEvent(new KeyboardEvent("keydown", { key: "a", bubbles: true }));
|
|
115
|
+
await Promise.resolve();
|
|
116
|
+
expect(writeText).toHaveBeenCalledTimes(2);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("swaps the click behavior when `action` is set", async () => {
|
|
120
|
+
const action = vi.fn();
|
|
121
|
+
const c = mount(() => h(HkCopyBadge, { action, tooltip: "Open details" }, () => "#d.132"));
|
|
122
|
+
(c.querySelector(".hk-badge") as HTMLElement).click();
|
|
123
|
+
await Promise.resolve();
|
|
124
|
+
|
|
125
|
+
expect(action).toHaveBeenCalledTimes(1);
|
|
126
|
+
expect(writeText).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("renders the custom tooltip text", () => {
|
|
130
|
+
const c = mount(() => h(HkCopyBadge, { tooltip: "Open details" }, () => "#d.132"));
|
|
131
|
+
const content = c.parentElement!.querySelector(".hk-tooltip-popup .hk-tooltip-content");
|
|
132
|
+
expect(content?.textContent).toBe("Open details");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("shows the localized click-to-copy tooltip by default", () => {
|
|
136
|
+
const c = mount(() => h(HkCopyBadge, null, () => "#d.132"));
|
|
137
|
+
const content = c.parentElement!.querySelector(".hk-tooltip-popup .hk-tooltip-content");
|
|
138
|
+
expect(content?.textContent).toBe("Click to copy");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("renders the inert badge face when disabled", () => {
|
|
142
|
+
const c = mount(() => h(HkCopyBadge, { disabled: true }, () => "#d.132"));
|
|
143
|
+
const badge = c.querySelector(".hk-badge")!;
|
|
144
|
+
expect(badge.getAttribute("role")).toBeNull();
|
|
145
|
+
expect(badge.getAttribute("tabindex")).toBeNull();
|
|
146
|
+
expect(badge.classList.contains("hk-copy-badge")).toBe(false);
|
|
147
|
+
// No tooltip wrapper at all.
|
|
148
|
+
expect(c.querySelector(".hk-tooltip-wrapper")).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("keeps the workspace tag contract: no badge text selection, ever", () => {
|
|
152
|
+
// Source contract on the HkBadge base sheet — every tag in every host
|
|
153
|
+
// inherits the no-selection rule, copyable or not.
|
|
154
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
155
|
+
const scss = readFileSync(join(here, "HkBadge.scss"), "utf-8");
|
|
156
|
+
expect(scss).toMatch(/user-select:\s*none/);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("lands consumer class on the badge element, not the tooltip wrapper", () => {
|
|
160
|
+
const c = mount(() => h(HkCopyBadge, { class: "s-kanban-card-tag" }, () => "#tag"));
|
|
161
|
+
const badge = c.querySelector(".hk-badge")!;
|
|
162
|
+
expect(badge.classList.contains("s-kanban-card-tag")).toBe(true);
|
|
163
|
+
expect(badge.classList.contains("hk-copy-badge")).toBe(true);
|
|
164
|
+
const wrapper = c.querySelector(".hk-tooltip-wrapper")!;
|
|
165
|
+
expect(wrapper.classList.contains("s-kanban-card-tag")).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("keeps consumer class when disabled", () => {
|
|
169
|
+
const c = mount(() => h(HkCopyBadge, { disabled: true, class: "x-pill" }, () => "#tag"));
|
|
170
|
+
expect(c.querySelector(".hk-badge")!.classList.contains("x-pill")).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { computed, defineComponent, ref, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import HBadge, { type BadgeVariant } from "./HkBadge";
|
|
4
|
+
import HTooltip from "./HkTooltip";
|
|
5
|
+
import { useI18n } from "../i18n/context";
|
|
6
|
+
import { useClipboardWithToast } from "../runtime/useClipboard";
|
|
7
|
+
import { useToast } from "../runtime/useToast";
|
|
8
|
+
import "./HkCopyBadge.scss";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* HkCopyBadge — the house standard for interactive TAGS: a HkBadge that
|
|
12
|
+
* never selects text and instead copies on click.
|
|
13
|
+
*
|
|
14
|
+
* The workspace-wide tag contract (user direction 2026-10-05) is that a
|
|
15
|
+
* label pill is not prose: text selection is always off (that half lives
|
|
16
|
+
* on `.hk-badge` itself), hover shows a "click to copy" tooltip, and a
|
|
17
|
+
* click runs the shared clipboard-with-toast path so every host confirms
|
|
18
|
+
* with the same localized toast. Rendered content is the default copy
|
|
19
|
+
* payload; pass `value` when the meaningful string differs from the
|
|
20
|
+
* label (a `#short` display id backed by a longer id, say).
|
|
21
|
+
*
|
|
22
|
+
* `action` swaps the click behavior outright (open a panel, jump …).
|
|
23
|
+
* That is only legitimate when the tooltip no longer claims "copy" —
|
|
24
|
+
* pass `tooltip` alongside, or the component warns in dev. `disabled`
|
|
25
|
+
* drops the interactivity entirely (no tooltip, no pointer cursor, no
|
|
26
|
+
* button semantics) and renders the plain badge face.
|
|
27
|
+
*
|
|
28
|
+
* A11y: the tag is a real button (role, tab order, Enter/Space), and the
|
|
29
|
+
* click is stopped from reaching clickable ancestors — a tag inside a
|
|
30
|
+
* selectable row copies without selecting the row. Consumer attrs
|
|
31
|
+
* (class/style/data-*) land on the badge element itself, so a call site
|
|
32
|
+
* skinning the pill keeps working; the tooltip wrapper stays clean.
|
|
33
|
+
*/
|
|
34
|
+
export default defineComponent({
|
|
35
|
+
name: "HkCopyBadge",
|
|
36
|
+
inheritAttrs: false,
|
|
37
|
+
props: {
|
|
38
|
+
/** What a click copies. Default: the rendered text content. */
|
|
39
|
+
value: { type: String, default: undefined },
|
|
40
|
+
/** Tooltip text. Default: the localized "click to copy". */
|
|
41
|
+
tooltip: { type: String, default: undefined },
|
|
42
|
+
/** Swap the click behavior (must come with a custom `tooltip`). */
|
|
43
|
+
action: { type: Function as PropType<() => void>, default: undefined },
|
|
44
|
+
/** Render the inert badge face: no tooltip, no click, no cursor. */
|
|
45
|
+
disabled: { type: Boolean, default: false },
|
|
46
|
+
// ── HkBadge passthrough ──────────────────────────────────────────
|
|
47
|
+
variant: { type: String as PropType<BadgeVariant>, default: "default" },
|
|
48
|
+
dot: { type: Boolean, default: false },
|
|
49
|
+
size: { type: String as PropType<"sm" | "md">, default: "md" },
|
|
50
|
+
mono: { type: Boolean, default: false },
|
|
51
|
+
uppercase: { type: Boolean, default: false },
|
|
52
|
+
pill: { type: Boolean, default: true },
|
|
53
|
+
color: { type: String, default: undefined },
|
|
54
|
+
bgColor: { type: String, default: undefined },
|
|
55
|
+
borderColor: { type: String, default: undefined },
|
|
56
|
+
},
|
|
57
|
+
setup(props, { slots, attrs }) {
|
|
58
|
+
const { t } = useI18n();
|
|
59
|
+
const clipboard = useClipboardWithToast(useToast());
|
|
60
|
+
|
|
61
|
+
// The badge root: HkBadge renders a single span, so the instance's
|
|
62
|
+
// $el carries the rendered label — the default copy payload.
|
|
63
|
+
const badgeRef = ref<InstanceType<typeof HBadge> | null>(null);
|
|
64
|
+
|
|
65
|
+
const tooltipText = computed(() =>
|
|
66
|
+
props.tooltip ?? t("hikari::clipboard.clickToCopy", "Click to copy"),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
if (import.meta.env?.DEV && props.action && !props.tooltip) {
|
|
70
|
+
console.warn(
|
|
71
|
+
"[HkCopyBadge] `action` replaces the copy behavior — pass `tooltip` " +
|
|
72
|
+
"so the hover hint stops claiming click-to-copy.",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function copyPayload(): string {
|
|
77
|
+
if (props.value !== undefined) return props.value;
|
|
78
|
+
const el = badgeRef.value?.$el as HTMLElement | undefined;
|
|
79
|
+
return el?.textContent?.trim() ?? "";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function activate() {
|
|
83
|
+
if (props.disabled) return;
|
|
84
|
+
if (props.action) {
|
|
85
|
+
props.action();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const text = copyPayload();
|
|
89
|
+
if (!text) return;
|
|
90
|
+
void clipboard.copy(text);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return () => {
|
|
94
|
+
// Everything the call site put on HkCopyBadge belongs to the pill
|
|
95
|
+
// itself; only the interactive contract below is ours to add.
|
|
96
|
+
const { class: attrClass, style: attrStyle, ...restAttrs } = attrs;
|
|
97
|
+
|
|
98
|
+
const badge = (
|
|
99
|
+
<HBadge
|
|
100
|
+
ref={badgeRef}
|
|
101
|
+
{...restAttrs}
|
|
102
|
+
class={props.disabled ? attrClass : ["hk-copy-badge", attrClass]}
|
|
103
|
+
style={attrStyle}
|
|
104
|
+
variant={props.variant}
|
|
105
|
+
dot={props.dot}
|
|
106
|
+
size={props.size}
|
|
107
|
+
mono={props.mono}
|
|
108
|
+
uppercase={props.uppercase}
|
|
109
|
+
pill={props.pill}
|
|
110
|
+
color={props.color}
|
|
111
|
+
bgColor={props.bgColor}
|
|
112
|
+
borderColor={props.borderColor}
|
|
113
|
+
{...(props.disabled
|
|
114
|
+
? {}
|
|
115
|
+
: {
|
|
116
|
+
role: "button",
|
|
117
|
+
tabindex: 0,
|
|
118
|
+
onClick: (e: MouseEvent) => {
|
|
119
|
+
// A tag inside a clickable row/card copies itself
|
|
120
|
+
// without triggering the row.
|
|
121
|
+
e.stopPropagation();
|
|
122
|
+
activate();
|
|
123
|
+
},
|
|
124
|
+
onKeydown: (e: KeyboardEvent) => {
|
|
125
|
+
if (e.key !== "Enter" && e.key !== " ") return;
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
e.stopPropagation();
|
|
128
|
+
activate();
|
|
129
|
+
},
|
|
130
|
+
})}
|
|
131
|
+
>
|
|
132
|
+
{slots.default?.()}
|
|
133
|
+
</HBadge>
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
if (props.disabled) return badge;
|
|
137
|
+
return <HTooltip text={tooltipText.value}>{badge}</HTooltip>;
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
});
|