@celestia-island/hikari 0.55.318 → 0.55.320
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/HkScrollContainer.tsx +7 -1
- package/src/components/HkScrollProgress.test.tsx +19 -0
- package/src/components/HkScrollProgress.tsx +6 -0
- package/src/components/HkTable.interactions.test.tsx +102 -5
- package/src/components/HkTable.scss +85 -14
- package/src/components/HkTable.sections.test.tsx +764 -0
- package/src/components/HkTable.tsx +440 -152
- package/src/index.ts +1 -1
- package/src/styles/selectionSheet.test.ts +47 -30
package/package.json
CHANGED
|
@@ -453,7 +453,13 @@ export default defineComponent({
|
|
|
453
453
|
}
|
|
454
454
|
const band = vp.getBoundingClientRect();
|
|
455
455
|
const active = pickActiveProgressRoot(band);
|
|
456
|
-
|
|
456
|
+
// A list may own the row grammar (HkTable's section bands are body
|
|
457
|
+
// `<tr>`s that are not data rows); its `getRows` wins over the
|
|
458
|
+
// container selector. No `getRows` (or an undefined return) keeps the
|
|
459
|
+
// existing selector path byte-for-byte.
|
|
460
|
+
const rows = active
|
|
461
|
+
? active.handle.getRows?.() ?? active.root.querySelectorAll(props.scrollProgressSelector)
|
|
462
|
+
: [];
|
|
457
463
|
if (!active || rows.length === 0 || vp.scrollHeight <= vp.clientHeight + 1) {
|
|
458
464
|
setProgressHidden();
|
|
459
465
|
return;
|
|
@@ -11,6 +11,7 @@ import { createApp, defineComponent, h, inject, nextTick, onMounted, onUnmounted
|
|
|
11
11
|
|
|
12
12
|
import HkScrollContainer from "./HkScrollContainer";
|
|
13
13
|
import { HkAdminTablePage } from "./HkAdminTablePage";
|
|
14
|
+
import HTable from "./HkTable";
|
|
14
15
|
import { HK_SCROLL_PROGRESS_REGISTRAR, type HkScrollProgressRegistrar } from "./HkScrollProgress";
|
|
15
16
|
|
|
16
17
|
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
@@ -219,6 +220,24 @@ describe("HkScrollProgress", () => {
|
|
|
219
220
|
expect(pill.textContent).toBe("11 / 340");
|
|
220
221
|
});
|
|
221
222
|
|
|
223
|
+
it("counts only a sectioned HkTable's data rows, not its bands", async () => {
|
|
224
|
+
// The band is a `<tr>` in the section body but not a row. The table's
|
|
225
|
+
// handle `getRows` wins over the container's `tbody tr` selector, so
|
|
226
|
+
// the pill reports 2 sections x 2 rows = 4, not 6.
|
|
227
|
+
const sections = [
|
|
228
|
+
{ key: "panel-a", label: "Panel A", rows: [{ name: "a0" }, { name: "a1" }] },
|
|
229
|
+
{ key: "panel-b", label: "Panel B", rows: [{ name: "b0" }, { name: "b1" }] },
|
|
230
|
+
];
|
|
231
|
+
const scroller = mountScroller(() =>
|
|
232
|
+
h(HTable, { columns: [{ key: "name", title: "Name" }], sections }),
|
|
233
|
+
);
|
|
234
|
+
stubRootRect(scroller.container, ".hk-table-host", -100, 1900);
|
|
235
|
+
stubRows(scroller.container, 6, 0);
|
|
236
|
+
scroller.refresh();
|
|
237
|
+
await nextTick();
|
|
238
|
+
expect(scroller.pill()!.textContent).toBe("1 / 4");
|
|
239
|
+
});
|
|
240
|
+
|
|
222
241
|
it("falls back to the DOM row count when the total is absent", async () => {
|
|
223
242
|
const List = makeList({ rows: ROWS });
|
|
224
243
|
const scroller = mountScroller(() => h(List));
|
|
@@ -24,6 +24,12 @@ import "./HkScrollProgress.scss";
|
|
|
24
24
|
export interface HkScrollProgressHandle {
|
|
25
25
|
getRoot(): HTMLElement | null;
|
|
26
26
|
getTotal(): number | undefined;
|
|
27
|
+
/** The list's own row elements in display order — wins over the
|
|
28
|
+
* container's `scrollProgressSelector` when present. This is for a
|
|
29
|
+
* list whose DOM rows are not all data rows (HkTable's section bands
|
|
30
|
+
* are `<tr>`s in the body, but they are not rows). Return `undefined`
|
|
31
|
+
* to fall back to the container's selector. */
|
|
32
|
+
getRows?(): readonly Element[] | undefined;
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
/** Provided by every HkScrollContainer instance whose `scrollProgress`
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { createApp, h } from "vue";
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
|
|
7
|
+
import * as sass from "sass";
|
|
8
|
+
|
|
7
9
|
import HTable from "./HkTable";
|
|
8
10
|
import { useToast } from "../runtime/useToast";
|
|
9
11
|
|
|
@@ -23,10 +25,11 @@ import { useToast } from "../runtime/useToast";
|
|
|
23
25
|
* row-action affordance)
|
|
24
26
|
* - `Column.copyable: false` and the table-wide `copy-cells=false` opt
|
|
25
27
|
* out wholesale
|
|
26
|
-
* - the
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
28
|
+
* - the click cursor (`pointer`; the copy SEMANTICS live in the `title`
|
|
29
|
+
* hint) and the hover hint ride `[data-copy-cell]` — the default text
|
|
30
|
+
* cells, in a copyable column, whose value still renders text — never
|
|
31
|
+
* every cell, so the cursor describes what a background click really
|
|
32
|
+
* does. Slot cells' inner leaves still copy, so their
|
|
30
33
|
* plain arrow is a deliberate under-promise (2026-11-07 user
|
|
31
34
|
* direction)
|
|
32
35
|
* - `manageable` renders the trailing ⋯ column (HkRowActions) and
|
|
@@ -77,6 +80,34 @@ const COLS = [
|
|
|
77
80
|
{ key: "endpoint", title: "Endpoint" },
|
|
78
81
|
];
|
|
79
82
|
|
|
83
|
+
// The compiled sheet, injected into the document so `getComputedStyle` sees
|
|
84
|
+
// the REAL cascade. Selector-text assertions cannot catch a rule that reaches
|
|
85
|
+
// an unmarked cell through inheritance, an element selector (`td`) or a media
|
|
86
|
+
// block — the cascade can.
|
|
87
|
+
//
|
|
88
|
+
// `style: "expanded"` is load-bearing: the compressed writer emits
|
|
89
|
+
// `@media(min-width:600px)` (no space after `@media`) and happy-dom then
|
|
90
|
+
// DROPS the whole block (cssRules stays empty), which would make a
|
|
91
|
+
// media-wrapped widening rule invisible to this suite.
|
|
92
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
93
|
+
const SRC = resolve(HERE, "..");
|
|
94
|
+
const SHEET_STYLE = "expanded" as const;
|
|
95
|
+
const SHEET_CSS = sass.compile(resolve(HERE, "HkTable.scss"), {
|
|
96
|
+
loadPaths: [SRC, join(SRC, "components"), join(SRC, "styles"), join(SRC, "styles", "theme")],
|
|
97
|
+
style: SHEET_STYLE,
|
|
98
|
+
silenceDeprecations: ["import", "global-builtin", "legacy-js-api"],
|
|
99
|
+
}).css;
|
|
100
|
+
// The parser probe below is compiled with the SAME style on purpose: if a
|
|
101
|
+
// future edit flips SHEET_STYLE back to "compressed", the probe loses its
|
|
102
|
+
// media block too and this suite goes red. That keeps the "fixture must stay
|
|
103
|
+
// `expanded`" premise under its own tripwire instead of silently disarming
|
|
104
|
+
// the cascade assertion.
|
|
105
|
+
const PROBE_CSS = sass.compileString("@media (min-width: 600px) { .hk-probe { color: red } }", {
|
|
106
|
+
style: SHEET_STYLE,
|
|
107
|
+
}).css;
|
|
108
|
+
// happy-dom reports an unset cursor as "" (a browser reports "auto").
|
|
109
|
+
const isAuto = (value: string): boolean => value === "" || value === "auto";
|
|
110
|
+
|
|
80
111
|
async function settle() {
|
|
81
112
|
await Promise.resolve();
|
|
82
113
|
await Promise.resolve();
|
|
@@ -416,3 +447,69 @@ describe("HkTable interaction stylesheet", () => {
|
|
|
416
447
|
expect(block.indexOf("cursor: default")).toBeGreaterThan(-1);
|
|
417
448
|
});
|
|
418
449
|
});
|
|
450
|
+
|
|
451
|
+
describe("HkTable click-cursor cascade", () => {
|
|
452
|
+
let sheet: HTMLStyleElement;
|
|
453
|
+
beforeEach(() => {
|
|
454
|
+
sheet = document.createElement("style");
|
|
455
|
+
sheet.textContent = SHEET_CSS;
|
|
456
|
+
document.head.appendChild(sheet);
|
|
457
|
+
});
|
|
458
|
+
afterEach(() => {
|
|
459
|
+
sheet.remove();
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("parses the injected sheet (a blind sheet would make this suite vacuous)", () => {
|
|
463
|
+
expect(sheet.sheet?.cssRules?.length ?? 0).toBeGreaterThan(0);
|
|
464
|
+
// Probe the PARSER, not this sheet: the next test can only catch a
|
|
465
|
+
// media-wrapped widening rule if a media block survives parsing. The
|
|
466
|
+
// probe CSS is synthetic (so unrelated sheet edits, e.g. dropping the
|
|
467
|
+
// reduced-motion block, can never turn this suite red) but compiled with
|
|
468
|
+
// the SAME style as the fixture (so a fixture-style regression does).
|
|
469
|
+
const probe = document.createElement("style");
|
|
470
|
+
probe.textContent = PROBE_CSS;
|
|
471
|
+
document.head.appendChild(probe);
|
|
472
|
+
try {
|
|
473
|
+
const rules = [...(probe.sheet?.cssRules ?? [])];
|
|
474
|
+
expect(rules.some((rule) => typeof (rule as { media?: unknown }).media !== "undefined")).toBe(
|
|
475
|
+
true,
|
|
476
|
+
);
|
|
477
|
+
} finally {
|
|
478
|
+
probe.remove();
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("reaches the marked cell only — computed cascade, not selector text", () => {
|
|
483
|
+
const c = mount(
|
|
484
|
+
h(
|
|
485
|
+
HTable,
|
|
486
|
+
{
|
|
487
|
+
columns: [...COLS, { key: "secret", title: "Secret", copyable: false }],
|
|
488
|
+
rows: [{ name: "alpha", endpoint: "https://x.example", secret: "nope-token" }],
|
|
489
|
+
draggable: true,
|
|
490
|
+
selectable: true,
|
|
491
|
+
manageable: true,
|
|
492
|
+
},
|
|
493
|
+
{ "cell-endpoint": () => "pure-text-slot" },
|
|
494
|
+
),
|
|
495
|
+
);
|
|
496
|
+
// drag grip, selection, name, endpoint (slot), secret (opted out), row actions
|
|
497
|
+
const tds = Array.from(c.querySelectorAll<HTMLElement>("tbody td"));
|
|
498
|
+
expect(tds).toHaveLength(6);
|
|
499
|
+
const cursor = (index: number): string => getComputedStyle(tds[index]!).cursor;
|
|
500
|
+
expect(cursor(2)).toBe("pointer");
|
|
501
|
+
for (const index of [0, 1, 3, 4]) {
|
|
502
|
+
expect(isAuto(cursor(index))).toBe(true);
|
|
503
|
+
}
|
|
504
|
+
expect(cursor(5)).toBe("default");
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("an opted-out table hands every cell the ordinary cursor back", () => {
|
|
508
|
+
const c = mount(
|
|
509
|
+
h(HTable, { columns: COLS, rows: [{ name: "alpha", endpoint: "x" }], copyCells: false }),
|
|
510
|
+
);
|
|
511
|
+
for (const td of Array.from(c.querySelectorAll<HTMLElement>("tbody td"))) {
|
|
512
|
+
expect(isAuto(getComputedStyle(td).cursor)).toBe(true);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
});
|
|
@@ -95,6 +95,26 @@
|
|
|
95
95
|
background: var(--hi-color-bg-subtle, rgba(255, 255, 255, 0.03));
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/* A section's band is the first child of its body, so the base zebra rule
|
|
99
|
+
would shade each section's FIRST data row (child 2). Restart the phase
|
|
100
|
+
after the band: even data children are unshaded, odd ones carry the
|
|
101
|
+
stripe — the same first-row-unshaded phase an unsectioned body has. */
|
|
102
|
+
.hk-table-striped .hk-table-section-body .hk-table-row:nth-child(even) {
|
|
103
|
+
background: none;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.hk-table-striped .hk-table-section-body .hk-table-row:nth-child(odd) {
|
|
107
|
+
background: var(--hi-color-bg-subtle, rgba(255, 255, 255, 0.03));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* The sectioned zebra pair above is (0,4,0) — it would out-specify the
|
|
111
|
+
shared `.hk-table-hover .hk-table-row:hover` rule (0,3,0) and kill hover
|
|
112
|
+
feedback on EVERY row of a striped sectioned table. Keep the pointer
|
|
113
|
+
feedback by re-stating it at a spec that beats the pair. */
|
|
114
|
+
.hk-table-striped.hk-table-hover .hk-table-section-body .hk-table-row:hover {
|
|
115
|
+
background: var(--hi-color-bg-subtle, rgba(255, 255, 255, 0.04));
|
|
116
|
+
}
|
|
117
|
+
|
|
98
118
|
.hk-table-bordered {
|
|
99
119
|
border: 1px solid var(--hi-color-border, rgba(100,100,100,0.1));
|
|
100
120
|
border-radius: var(--radius-md, 8px);
|
|
@@ -153,6 +173,51 @@
|
|
|
153
173
|
color: var(--hi-color-text-secondary, #666666);
|
|
154
174
|
}
|
|
155
175
|
|
|
176
|
+
/* ── Section bands (grouped tables) ───────────────────────────────────
|
|
177
|
+
One `<tbody>` per section, each led by a band row. The band is a
|
|
178
|
+
rowgroup header, not a data row: it never carries `.hk-table-row`, so
|
|
179
|
+
the copy layer (which marks and cursor-styles cells via
|
|
180
|
+
`[data-copy-cell]`), the hover paint, the striped rule, the drag strip
|
|
181
|
+
and the selection/⋯ tracks all ignore it by construction. The band cell
|
|
182
|
+
carries no `data-copy-cell`, so it needs no copy-cursor override either.
|
|
183
|
+
|
|
184
|
+
The cell itself is TYPE-NEUTRAL for consumer markup: the `section` slot
|
|
185
|
+
carries arbitrary content (badges, panel names, status chips), so only
|
|
186
|
+
the track is styled here (padding / hairline / background / start
|
|
187
|
+
alignment). `font-weight: inherit` neutralizes the UA `th { font-weight:
|
|
188
|
+
bold }`; nothing here sets `white-space` (long panel names may wrap) or
|
|
189
|
+
`cursor` (selectable band text keeps its I-beam). Sizing is delivered by
|
|
190
|
+
the consumer's own classes; the plain `label` fallback gets its own span
|
|
191
|
+
below. The hk-table-sm/lg ladder steps ONLY the band's padding —
|
|
192
|
+
typography deliberately does not scale with the table size, because the
|
|
193
|
+
consumer's slot content owns its type. */
|
|
194
|
+
.hk-table-section-cell {
|
|
195
|
+
padding: var(--space-8, 8px) var(--space-12, 12px);
|
|
196
|
+
border-bottom: 1px solid var(--hi-color-border, rgba(100,100,100,0.08));
|
|
197
|
+
text-align: start;
|
|
198
|
+
background: var(--hi-color-bg-subtle, rgba(255, 255, 255, 0.03));
|
|
199
|
+
font-weight: inherit;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* The default `label` branch only (the `section` slot bypasses this span
|
|
203
|
+
entirely): the previous text-sm / font-medium band look. */
|
|
204
|
+
.hk-table-section-label {
|
|
205
|
+
font-size: var(--text-sm, 0.875rem);
|
|
206
|
+
font-weight: 500;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.hk-table-sm .hk-table-section-cell {
|
|
210
|
+
padding: var(--space-4, 4px) var(--space-8, 8px);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.hk-table-lg .hk-table-section-cell {
|
|
214
|
+
padding: var(--space-16, 16px) var(--space-24, 24px);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.hk-table-bordered .hk-table-section-cell {
|
|
218
|
+
border: 1px solid var(--hi-color-border, rgba(100,100,100,0.06));
|
|
219
|
+
}
|
|
220
|
+
|
|
156
221
|
/* ── Drag-to-reorder (draggable) ─────────────────────────────────────── */
|
|
157
222
|
|
|
158
223
|
.hk-table-drag-handle {
|
|
@@ -406,32 +471,38 @@
|
|
|
406
471
|
Table content is not prose. While the copy layer is enabled
|
|
407
472
|
(`copy-cells`, the default), body cells kill drag mass-selection
|
|
408
473
|
(user-select: none) and the cells whose background click copies
|
|
409
|
-
advertise the click-to-copy affordance with the `
|
|
410
|
-
clicks run the shared clipboard-with-toast path (HkTable.tsx).
|
|
411
|
-
halves scope under `data-copy-cells`, so a consumer that opts
|
|
412
|
-
gets plain selectable text back wholesale.
|
|
474
|
+
advertise the click-to-copy affordance with the `pointer` cursor —
|
|
475
|
+
text clicks run the shared clipboard-with-toast path (HkTable.tsx).
|
|
476
|
+
Both halves scope under `data-copy-cells`, so a consumer that opts
|
|
477
|
+
out gets plain selectable text back wholesale.
|
|
413
478
|
|
|
414
|
-
The `
|
|
479
|
+
The `pointer` cursor rides `[data-copy-cell]`, NOT every cell
|
|
415
480
|
(2026-11-07 user direction): the marker is the same predicate as the
|
|
416
481
|
`title` hint and as a DEFAULT text cell's background click — a
|
|
417
482
|
default-rendered cell, in a `copyable` column, whose value still
|
|
418
483
|
renders text. Slot cells (chips/badges/layout wrappers) never take
|
|
419
484
|
the click on their td SURFACE — only their inner text leaves copy —
|
|
420
485
|
and `copyable: false` columns copy nothing at all, so both keep the
|
|
421
|
-
ordinary arrow; a
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
486
|
+
ordinary arrow; a copy affordance there promised something the
|
|
487
|
+
surface click does not deliver. Two deliberate under-promises
|
|
488
|
+
follow: an inner leaf that copies still shows the arrow, and a slot
|
|
489
|
+
that renders BARE text (no wrapper) makes the td's own surface copy
|
|
490
|
+
— yet stays unmarked because the slot owns its hints. Under-
|
|
491
|
+
promising is the point; over-promising is the bug this rule removes.
|
|
492
|
+
|
|
493
|
+
Why `pointer`, not `copy` (2026-11-07 user direction, second turn):
|
|
494
|
+
the OS paints CSS `copy` with the legacy arrow+plus bitmap (no
|
|
495
|
+
themed cursor exists for it), which reads as a 90s artifact next to
|
|
496
|
+
the modern arrow. The COPY semantics live in the `title` hint
|
|
497
|
+
("Click to copy"); the cursor only has to say "this text reacts to a
|
|
498
|
+
click", which is exactly what `pointer` says. */
|
|
428
499
|
.hk-table[data-copy-cells] {
|
|
429
500
|
.hk-table-cell {
|
|
430
501
|
@include user-select(none);
|
|
431
502
|
}
|
|
432
503
|
|
|
433
504
|
.hk-table-cell[data-copy-cell] {
|
|
434
|
-
cursor:
|
|
505
|
+
cursor: pointer;
|
|
435
506
|
}
|
|
436
507
|
|
|
437
508
|
/* Defense in depth: the row-actions track is a control and never
|
|
@@ -439,7 +510,7 @@
|
|
|
439
510
|
`.hk-table-cell` on purpose: that keeps its specificity equal to the
|
|
440
511
|
marker rule above (`(0,4,0)`) and, declared after it, it still wins
|
|
441
512
|
if a future widening ever hands the marker to the ⋯ trigger. A bare
|
|
442
|
-
`.hk-table-manage-cell` (`(0,3,0)`) would
|
|
513
|
+
`.hk-table-manage-cell` (`(0,3,0)`) would lose that tie outright. */
|
|
443
514
|
.hk-table-cell.hk-table-manage-cell {
|
|
444
515
|
cursor: default;
|
|
445
516
|
}
|