@celestia-island/hikari 0.55.183 → 0.55.185
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/HkDraggableList.scss +17 -0
- package/src/components/HkDraggableList.styles.test.ts +27 -0
- package/src/components/HkDraggableList.test.tsx +135 -0
- package/src/components/HkDraggableList.tsx +13 -0
- package/src/components/HkQuote.scss +98 -0
- package/src/components/HkQuote.test.tsx +131 -0
- package/src/components/HkQuote.tsx +85 -0
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -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,98 @@
|
|
|
1
|
+
/* HkQuote — the Markdown-blockquote-style info block.
|
|
2
|
+
*
|
|
3
|
+
* Two layers: the OUTER box (`.hk-quote`) only positions the block
|
|
4
|
+
* (centered in its host for fit="fill", a capped centered column for
|
|
5
|
+
* fit="page"); the INNER mark (`.hk-quote-mark`) is the blockquote
|
|
6
|
+
* itself — the 3px accent bar on the inline-start edge plus quiet
|
|
7
|
+
* prose, sized to its content like a real Markdown `>` quote. No
|
|
8
|
+
* surface of its own (no background, no radius, no shadow): the bar IS
|
|
9
|
+
* the visual. The tone class only selects the bar color; text colors
|
|
10
|
+
* stay in the standard primary/secondary text slots for every tone. */
|
|
11
|
+
|
|
12
|
+
.hk-quote {
|
|
13
|
+
--hk-quote-bar: var(--hi-color-primary, #7aa2f7);
|
|
14
|
+
|
|
15
|
+
display: flex;
|
|
16
|
+
box-sizing: border-box;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.hk-quote--neutral {
|
|
20
|
+
--hk-quote-bar: var(--hi-color-text-tertiary, #8b949e);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.hk-quote--danger {
|
|
24
|
+
--hk-quote-bar: var(--hi-color-danger, #f7768e);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.hk-quote--warning {
|
|
28
|
+
--hk-quote-bar: var(--hi-color-warning, #eed677);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.hk-quote--success {
|
|
32
|
+
--hk-quote-bar: var(--hi-color-success, #0eb840);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* fit="fill": the quote block centers itself inside its host (an empty
|
|
36
|
+
* panel body, a split view) — the host keeps its own size, the quote
|
|
37
|
+
* floats mid-host as an info block. */
|
|
38
|
+
.hk-quote--fill {
|
|
39
|
+
width: 100%;
|
|
40
|
+
height: 100%;
|
|
41
|
+
align-items: center;
|
|
42
|
+
justify-content: center;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* fit="page": a standalone block on a page — capped width, centered
|
|
46
|
+
* horizontally, never spanning the whole column. */
|
|
47
|
+
.hk-quote--page {
|
|
48
|
+
width: 100%;
|
|
49
|
+
margin-inline: auto;
|
|
50
|
+
justify-content: center;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@media (min-width: 48rem) {
|
|
54
|
+
.hk-quote--page {
|
|
55
|
+
max-width: clamp(20rem, 30vw, 35rem);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.hk-quote-mark {
|
|
60
|
+
display: flex;
|
|
61
|
+
flex-direction: column;
|
|
62
|
+
align-items: flex-start;
|
|
63
|
+
gap: var(--space-8, 0.5rem);
|
|
64
|
+
padding: 0.5rem 0.875rem;
|
|
65
|
+
border-inline-start: 3px solid var(--hk-quote-bar);
|
|
66
|
+
text-align: start;
|
|
67
|
+
max-width: 100%;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.hk-quote-body {
|
|
71
|
+
display: flex;
|
|
72
|
+
flex-direction: column;
|
|
73
|
+
gap: var(--space-4, 0.25rem);
|
|
74
|
+
max-width: 100%;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.hk-quote-title {
|
|
78
|
+
margin: 0;
|
|
79
|
+
font-size: var(--text-base, 0.875rem);
|
|
80
|
+
font-weight: 600;
|
|
81
|
+
line-height: 1.5;
|
|
82
|
+
color: var(--hi-color-text-primary, #1e1e1e);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.hk-quote-desc {
|
|
86
|
+
margin: 0;
|
|
87
|
+
font-size: var(--text-sm, 0.8125rem);
|
|
88
|
+
line-height: 1.55;
|
|
89
|
+
color: var(--hi-color-text-secondary, #666666);
|
|
90
|
+
max-width: 36rem;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.hk-quote-action {
|
|
94
|
+
display: flex;
|
|
95
|
+
flex-wrap: wrap;
|
|
96
|
+
align-items: center;
|
|
97
|
+
gap: var(--space-8, 0.5rem);
|
|
98
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h, type VNode } from "vue";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import HkQuote from "./HkQuote";
|
|
8
|
+
|
|
9
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
|
|
11
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
12
|
+
|
|
13
|
+
function mount(renderNode: () => VNode) {
|
|
14
|
+
const container = document.createElement("div");
|
|
15
|
+
document.body.appendChild(container);
|
|
16
|
+
const app = createApp({ render: renderNode });
|
|
17
|
+
app.mount(container);
|
|
18
|
+
mounts.push({ app, container });
|
|
19
|
+
return container;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
24
|
+
app.unmount();
|
|
25
|
+
container.remove();
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* HkQuote is the Markdown-blockquote grammar as a component: an accent
|
|
31
|
+
* bar on the inline-start edge (tone-colored), quiet prose, NO card
|
|
32
|
+
* chrome — no icon, no background, no boxed surface (user direction
|
|
33
|
+
* 2026-10-18: the no-content/error states must read as a quoted info
|
|
34
|
+
* block with a primary-color left bar, not an icon card).
|
|
35
|
+
*/
|
|
36
|
+
describe("HkQuote", () => {
|
|
37
|
+
it("renders the quoted title and description inside a note region", () => {
|
|
38
|
+
const c = mount(() =>
|
|
39
|
+
h(HkQuote, {
|
|
40
|
+
title: "This panel has no content yet",
|
|
41
|
+
description: "Configure the data source, or let the agent generate content.",
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
const root = c.querySelector(".hk-quote")!;
|
|
45
|
+
expect(root.getAttribute("role")).toBe("note");
|
|
46
|
+
expect(c.querySelector(".hk-quote-title")!.textContent).toBe("This panel has no content yet");
|
|
47
|
+
expect(c.querySelector(".hk-quote-desc")!.textContent).toContain("Configure the data source");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("defaults to the primary tone and fill fit; every tone has a class", () => {
|
|
51
|
+
const c = mount(() => h(HkQuote, { title: "t" }));
|
|
52
|
+
const root = c.querySelector(".hk-quote")!;
|
|
53
|
+
expect(root.classList.contains("hk-quote--primary")).toBe(true);
|
|
54
|
+
expect(root.classList.contains("hk-quote--fill")).toBe(true);
|
|
55
|
+
|
|
56
|
+
for (const tone of ["neutral", "danger", "warning", "success"] as const) {
|
|
57
|
+
const toneRoot = mount(() => h(HkQuote, { title: "t", tone })).querySelector(".hk-quote")!;
|
|
58
|
+
expect(toneRoot.classList.contains(`hk-quote--${tone}`)).toBe(true);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("centers itself for fill and renders a width-capped page block for page", () => {
|
|
63
|
+
const fill = mount(() => h(HkQuote, { title: "t" })).querySelector(".hk-quote")!;
|
|
64
|
+
expect(fill.classList.contains("hk-quote--fill")).toBe(true);
|
|
65
|
+
const page = mount(() => h(HkQuote, { title: "t", fit: "page" })).querySelector(".hk-quote")!;
|
|
66
|
+
expect(page.classList.contains("hk-quote--page")).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("renders the action row and the default body slot", () => {
|
|
70
|
+
const c = mount(() =>
|
|
71
|
+
h(HkQuote, { title: "t" }, {
|
|
72
|
+
default: () => h("p", { class: "extra" }, "extra line"),
|
|
73
|
+
action: () => h("button", { type: "button" }, "Edit panel"),
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
expect(c.querySelector(".hk-quote-body .extra")!.textContent).toBe("extra line");
|
|
77
|
+
const action = c.querySelector<HTMLButtonElement>(".hk-quote-action button")!;
|
|
78
|
+
expect(action.textContent).toBe("Edit panel");
|
|
79
|
+
// The action row is a SIBLING of the body inside the mark (pinned:
|
|
80
|
+
// nesting it would halve the desc→action gap to the body's --space-4).
|
|
81
|
+
expect(action.closest(".hk-quote-body")).toBeNull();
|
|
82
|
+
expect(action.closest(".hk-quote-mark")).not.toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("passes call-site classes onto the root (no card chrome of its own)", () => {
|
|
86
|
+
const c = mount(() => h(HkQuote, { title: "t", class: "host-skin" }));
|
|
87
|
+
const root = c.querySelector(".hk-quote")!;
|
|
88
|
+
expect(root.classList.contains("host-skin")).toBe(true);
|
|
89
|
+
// The mark is the blockquote itself — the component adds no icon,
|
|
90
|
+
// no boxed surface of its own (see the styles contract below).
|
|
91
|
+
expect(c.querySelector(".hk-empty-icon")).toBeNull();
|
|
92
|
+
expect(c.querySelector("svg")).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("lets the call site override the role (error tones announce as alert)", () => {
|
|
96
|
+
// Consumers rendering an error quote pass role="alert" — Vue's attr
|
|
97
|
+
// fallthrough must win over the rendered default (pinned because the
|
|
98
|
+
// chest error-state story depends on it).
|
|
99
|
+
const c = mount(() => h(HkQuote, { title: "Couldn't load", role: "alert" }));
|
|
100
|
+
expect(c.querySelector(".hk-quote")!.getAttribute("role")).toBe("alert");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/** The visual grammar contract: the bar IS the visual. The mark carries
|
|
105
|
+
* the accent bar (border-inline-start, tone variable); it must NOT
|
|
106
|
+
* paint a background, border-radius, or shadow — anything card-like
|
|
107
|
+
* would turn the quote back into the placeholder card the component
|
|
108
|
+
* exists to replace. */
|
|
109
|
+
describe("HkQuote styles contract", () => {
|
|
110
|
+
const scss = readFileSync(join(here, "HkQuote.scss"), "utf8");
|
|
111
|
+
|
|
112
|
+
it("draws the accent bar with the tone variable on the mark", () => {
|
|
113
|
+
expect(scss).toMatch(/\.hk-quote-mark[\s\S]*?border-inline-start:\s*3px solid var\(--hk-quote-bar\)/);
|
|
114
|
+
expect(scss).toMatch(/--hk-quote-bar:\s*var\(--hi-color-primary/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("maps every tone class onto a house color token", () => {
|
|
118
|
+
expect(scss).toMatch(/hk-quote--neutral[\s\S]*?--hi-color-text-tertiary/);
|
|
119
|
+
expect(scss).toMatch(/hk-quote--danger[\s\S]*?--hi-color-danger/);
|
|
120
|
+
expect(scss).toMatch(/hk-quote--warning[\s\S]*?--hi-color-warning/);
|
|
121
|
+
expect(scss).toMatch(/hk-quote--success[\s\S]*?--hi-color-success/);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("ships no card chrome on the mark (no background / radius / shadow)", () => {
|
|
125
|
+
const markBlock = scss.match(/\.hk-quote-mark \{[\s\S]*?\n\}/)?.[0] ?? "";
|
|
126
|
+
expect(markBlock, "the .hk-quote-mark rule exists").toMatch(/^\./);
|
|
127
|
+
expect(markBlock).not.toMatch(/background/);
|
|
128
|
+
expect(markBlock).not.toMatch(/border-radius/);
|
|
129
|
+
expect(markBlock).not.toMatch(/box-shadow/);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { defineComponent, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import "./HkQuote.scss";
|
|
4
|
+
|
|
5
|
+
/** The tone grammar of the quote's accent bar. */
|
|
6
|
+
export type HkQuoteTone = "primary" | "neutral" | "danger" | "warning" | "success";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* HkQuote — the Markdown-blockquote-style info block.
|
|
10
|
+
*
|
|
11
|
+
* The house grammar for "this surface has something to SAY, not
|
|
12
|
+
* something to show": a vertical accent bar in the tone color on the
|
|
13
|
+
* inline-start edge, the message set as quoted prose beside it — the
|
|
14
|
+
* same visual language a Markdown `>` blockquote renders with, promoted
|
|
15
|
+
* to a first-class component with tone and centering. The bar hugs the
|
|
16
|
+
* prose (the block sizes to its content, like a real blockquote); the
|
|
17
|
+
* OUTER box centers that block inside the host.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately NOT an empty-state card: there is no icon, no boxed
|
|
20
|
+
* surface, no chrome of its own — hosts that want the message centered
|
|
21
|
+
* in an otherwise empty body pass `fit="fill"` (the block centers
|
|
22
|
+
* itself in the host) or `fit="page"` (a standalone, width-capped,
|
|
23
|
+
* horizontally centered block). The optional `action` slot renders a
|
|
24
|
+
* quiet row of buttons under the prose (e.g. "Edit panel", "Retry") —
|
|
25
|
+
* affordances belong to the host's story, never chrome bolted onto the
|
|
26
|
+
* quote.
|
|
27
|
+
*
|
|
28
|
+
* ```tsx
|
|
29
|
+
* // A panel that was never generated: quote it, don't card it.
|
|
30
|
+
* <HkQuote tone="primary" fit="fill"
|
|
31
|
+
* title={t("viewPanel.empty.panelNoContent")}
|
|
32
|
+
* description={t("viewPanel.empty.panelNoContentDesc")}>
|
|
33
|
+
* {{ action: () => <HkButton size="sm" onClick={edit}>Edit panel</HkButton> }}
|
|
34
|
+
* </HkQuote>
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* A11y: the block is a `role="note"` region by default; pass
|
|
38
|
+
* `role="alert"` (or any other attrs) from the call site for error
|
|
39
|
+
* tones — attrs land on the root element.
|
|
40
|
+
*/
|
|
41
|
+
export default defineComponent({
|
|
42
|
+
name: "HkQuote",
|
|
43
|
+
props: {
|
|
44
|
+
/** Lead line — the quoted statement itself. Bold, primary text. */
|
|
45
|
+
title: { type: String, default: undefined },
|
|
46
|
+
/** Follow-up prose — context or the way out. Muted, smaller. */
|
|
47
|
+
description: { type: String, default: undefined },
|
|
48
|
+
/** The accent bar's color grammar. Default: the primary tone. */
|
|
49
|
+
tone: {
|
|
50
|
+
type: String as PropType<HkQuoteTone>,
|
|
51
|
+
default: "primary",
|
|
52
|
+
},
|
|
53
|
+
/** page = standalone, centered, width-capped block; fill = the
|
|
54
|
+
* block centers itself inside its host (panels, split views). */
|
|
55
|
+
fit: {
|
|
56
|
+
type: String as PropType<"page" | "fill">,
|
|
57
|
+
default: "fill",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
setup(props, { slots }) {
|
|
61
|
+
return () => (
|
|
62
|
+
<div
|
|
63
|
+
class={[
|
|
64
|
+
"hk-quote",
|
|
65
|
+
`hk-quote--${props.tone}`,
|
|
66
|
+
props.fit === "fill" ? "hk-quote--fill" : "hk-quote--page",
|
|
67
|
+
]}
|
|
68
|
+
role="note"
|
|
69
|
+
>
|
|
70
|
+
<div class="hk-quote-mark">
|
|
71
|
+
<div class="hk-quote-body">
|
|
72
|
+
{props.title ? <p class="hk-quote-title">{props.title}</p> : null}
|
|
73
|
+
{props.description ? (
|
|
74
|
+
<p class="hk-quote-desc">{props.description}</p>
|
|
75
|
+
) : null}
|
|
76
|
+
{slots.default?.()}
|
|
77
|
+
</div>
|
|
78
|
+
{slots.action ? (
|
|
79
|
+
<div class="hk-quote-action">{slots.action()}</div>
|
|
80
|
+
) : null}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
},
|
|
85
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -54,6 +54,7 @@ export { default as HPhaseTransition } from "./components/HkPhaseTransition";
|
|
|
54
54
|
export { default as HGaugeRing } from "./components/HkGaugeRing";
|
|
55
55
|
export { default as HProgressRing } from "./components/HkProgressRing";
|
|
56
56
|
export { default as HQrCode } from "./components/HkQrCode";
|
|
57
|
+
export { default as HQuote, type HkQuoteTone as HQuoteTone } from "./components/HkQuote";
|
|
57
58
|
export { default as HRollingNumber } from "./components/HkRollingNumber";
|
|
58
59
|
export { default as HLocalePickerPopup } from "./components/HkLocalePickerPopup";
|
|
59
60
|
export { default as HHoverRevealAction } from "./components/HkHoverRevealAction";
|
|
@@ -641,6 +642,8 @@ export { default as HkPhaseTransition } from "./components/HkPhaseTransition";
|
|
|
641
642
|
export { default as HkGaugeRing } from "./components/HkGaugeRing";
|
|
642
643
|
export { default as HkProgressRing } from "./components/HkProgressRing";
|
|
643
644
|
export { default as HkQrCode } from "./components/HkQrCode";
|
|
645
|
+
export { default as HkQuote } from "./components/HkQuote";
|
|
646
|
+
export type { HkQuoteTone } from "./components/HkQuote";
|
|
644
647
|
export { default as HkRollingNumber } from "./components/HkRollingNumber";
|
|
645
648
|
export { default as HkLocalePickerPopup } from "./components/HkLocalePickerPopup";
|
|
646
649
|
export { default as HkHoverRevealAction } from "./components/HkHoverRevealAction";
|