@celestia-island/hikari 0.55.196 → 0.55.198
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/HkTabs.scss +7 -0
- package/src/components/HkTabs.test.ts +109 -1
- package/src/components/HkTabs.tsx +28 -10
- package/src/components/HkWindowControls.scss +75 -0
- package/src/components/HkWindowControls.test.tsx +93 -0
- package/src/components/HkWindowControls.tsx +144 -0
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -166,6 +166,13 @@
|
|
|
166
166
|
|
|
167
167
|
/* === Panel (pill / tablist mode) === */
|
|
168
168
|
|
|
169
|
+
/* The panel area is ONE HkTransitionContainer (`.hk-tabs-panels`,
|
|
170
|
+
aliasing `.hk-tc`) keyed on the active tab: HkCrossfade dissolves the
|
|
171
|
+
outgoing panel in a shared grid cell while the container's inline
|
|
172
|
+
height transition morphs to the incoming panel's natural size. Both
|
|
173
|
+
generations carry data-active through the dissolve, so the :not rule
|
|
174
|
+
below only ever hides a genuinely inactive render, never a
|
|
175
|
+
dissolving one. */
|
|
169
176
|
.hk-tabs-panel {
|
|
170
177
|
/* No outline suppression: the panel div carries no tabindex and can
|
|
171
178
|
never take focus, so there is nothing to suppress. */
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
6
6
|
|
|
7
7
|
import HkTabs from "./HkTabs";
|
|
8
|
+
import { CROSSFADE_WATCHDOG_MS } from "./HkCrossfade";
|
|
9
|
+
import { DEFAULT_ANIMATION_DURATION_MS } from "../composables/useAnimationLoop";
|
|
8
10
|
|
|
9
11
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
10
12
|
|
|
@@ -515,3 +517,109 @@ describe("HkTabs segmented variant", () => {
|
|
|
515
517
|
expect(itemRule![1]).not.toMatch(/\bflex:/);
|
|
516
518
|
});
|
|
517
519
|
});
|
|
520
|
+
|
|
521
|
+
describe("HkTabs panel morph (HkTransitionContainer consumption)", () => {
|
|
522
|
+
/** Pill mode with panels ON (renderPanels defaults true) and a slot
|
|
523
|
+
* per tab — the surface the morph container plugs into. */
|
|
524
|
+
function mountWithPanels(
|
|
525
|
+
extra: Record<string, unknown> = {},
|
|
526
|
+
slots?: Record<string, () => unknown>,
|
|
527
|
+
): { container: HTMLElement; active: { value: string } } {
|
|
528
|
+
const container = document.createElement("div");
|
|
529
|
+
document.body.appendChild(container);
|
|
530
|
+
containers.push(container);
|
|
531
|
+
const active = ref("a");
|
|
532
|
+
const panelSlots = slots ?? {
|
|
533
|
+
a: () => h("p", { class: "body" }, "panel-alpha"),
|
|
534
|
+
b: () => h("p", { class: "body" }, "panel-beta"),
|
|
535
|
+
c: () => h("p", { class: "body" }, "panel-gamma"),
|
|
536
|
+
};
|
|
537
|
+
const Wrapper = defineComponent({
|
|
538
|
+
setup() {
|
|
539
|
+
return () =>
|
|
540
|
+
h(HkTabs, {
|
|
541
|
+
tabs: [
|
|
542
|
+
{ key: "a", label: "Alpha" },
|
|
543
|
+
{ key: "b", label: "Beta" },
|
|
544
|
+
{ key: "c", label: "Gamma" },
|
|
545
|
+
],
|
|
546
|
+
...extra,
|
|
547
|
+
modelValue: active.value,
|
|
548
|
+
"onUpdate:modelValue": (v: string) => { active.value = v; },
|
|
549
|
+
}, panelSlots);
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
const app = createApp(Wrapper);
|
|
553
|
+
mounts.push(app);
|
|
554
|
+
app.mount(container);
|
|
555
|
+
return { container, active };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function freezeRaf(): void {
|
|
559
|
+
vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
|
|
560
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
afterEach(() => {
|
|
564
|
+
vi.useRealTimers();
|
|
565
|
+
vi.unstubAllGlobals();
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it("renders the active panel inside the height-morph container and swaps it on tab change", async () => {
|
|
569
|
+
vi.useFakeTimers();
|
|
570
|
+
const { container, active } = mountWithPanels();
|
|
571
|
+
// ONE morph host (`.hk-tabs-panels` aliasing `.hk-tc`) below the
|
|
572
|
+
// strip, carrying the family height-morph transition contract.
|
|
573
|
+
const host = container.querySelector<HTMLElement>(".hk-tabs-panels.hk-tc");
|
|
574
|
+
expect(host).not.toBeNull();
|
|
575
|
+
expect(host?.style.transitionProperty).toBe("height");
|
|
576
|
+
expect(host?.style.transitionDuration).toBe(`${DEFAULT_ANIMATION_DURATION_MS}ms`);
|
|
577
|
+
// It owns exactly ONE panel — the active tab's.
|
|
578
|
+
const panels = container.querySelectorAll<HTMLElement>(".hk-tabs-panel");
|
|
579
|
+
expect(panels).toHaveLength(1);
|
|
580
|
+
expect(panels[0].hasAttribute("data-active")).toBe(true);
|
|
581
|
+
expect(panels[0].getAttribute("role")).toBe("tabpanel");
|
|
582
|
+
expect(container.textContent).toContain("panel-alpha");
|
|
583
|
+
|
|
584
|
+
active.value = "b";
|
|
585
|
+
await nextTick();
|
|
586
|
+
// Past the watchdog budget: the dissolve settled on Beta's panel —
|
|
587
|
+
// content, crossfade key and single item all moved with the tab.
|
|
588
|
+
await vi.advanceTimersByTimeAsync(CROSSFADE_WATCHDOG_MS + 100);
|
|
589
|
+
const settled = container.querySelectorAll<HTMLElement>(".hk-tabs-panel");
|
|
590
|
+
expect(settled).toHaveLength(1);
|
|
591
|
+
expect(settled[0].textContent).toContain("panel-beta");
|
|
592
|
+
const item = container.querySelector<HTMLElement>(".hk-crossfade-item");
|
|
593
|
+
expect(item?.dataset.crossfadeKey).toBe("b");
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("keeps both panel generations data-active mid-dissolve (no display:none pop)", async () => {
|
|
597
|
+
vi.useFakeTimers();
|
|
598
|
+
freezeRaf();
|
|
599
|
+
const { container, active } = mountWithPanels();
|
|
600
|
+
active.value = "b";
|
|
601
|
+
await nextTick();
|
|
602
|
+
await vi.advanceTimersByTimeAsync(100); // inside the dissolve window
|
|
603
|
+
// Both generations on stage and BOTH marked active: the leaving
|
|
604
|
+
// panel keeps its last-rendered data-active, so the stylesheet's
|
|
605
|
+
// `:not([data-active])` hide never fires mid-dissolve — the swap
|
|
606
|
+
// is HkCrossfade's stacked-grid dissolve, not an instant toggle.
|
|
607
|
+
const mid = container.querySelectorAll<HTMLElement>(".hk-tabs-panel");
|
|
608
|
+
expect(mid).toHaveLength(2);
|
|
609
|
+
expect(mid[0].hasAttribute("data-active")).toBe(true);
|
|
610
|
+
expect(mid[1].hasAttribute("data-active")).toBe(true);
|
|
611
|
+
expect(mid[0].textContent).toContain("panel-alpha");
|
|
612
|
+
expect(mid[1].textContent).toContain("panel-beta");
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("drops the panel area entirely while the active option is merged away", () => {
|
|
616
|
+
const { container } = mountWithPanels(
|
|
617
|
+
{ mergeKeys: ["a"] },
|
|
618
|
+
{ a: () => h("p", "panel-alpha"), merged: () => h("span", "m") },
|
|
619
|
+
);
|
|
620
|
+
// Pill-mode merged contract: merged options' panels are not
|
|
621
|
+
// rendered while merged — no host, no panel.
|
|
622
|
+
expect(container.querySelector(".hk-tabs-panels")).toBeNull();
|
|
623
|
+
expect(container.querySelector(".hk-tabs-panel")).toBeNull();
|
|
624
|
+
});
|
|
625
|
+
});
|
|
@@ -4,6 +4,7 @@ import "./HkTabs.scss";
|
|
|
4
4
|
import HkScrollContainer from "./HkScrollContainer";
|
|
5
5
|
import HkHoverRevealAction from "./HkHoverRevealAction";
|
|
6
6
|
import HkIconButton from "./HkIconButton";
|
|
7
|
+
import HkTransitionContainer from "./HkTransitionContainer";
|
|
7
8
|
import { iconByName } from "../composables/iconRegistry";
|
|
8
9
|
import { useI18n } from "../i18n/context";
|
|
9
10
|
import { clearLeaveGeometry, pinLeaveGeometry, type LeaveBoxSnapshot } from "../utils/dom";
|
|
@@ -88,6 +89,12 @@ interface ScrollerExpose {
|
|
|
88
89
|
* the row is then divided into one equal `1fr` grid track per unit,
|
|
89
90
|
* so merging Light+Dark into the altitude strip swaps TWO one-unit
|
|
90
91
|
* slots for ONE two-unit cell and nothing else on the strip moves;
|
|
92
|
+
* - morphing tab panels (pill mode, `renderPanels`): the panel area is
|
|
93
|
+
* ONE HkTransitionContainer keyed on the active tab — switching tabs
|
|
94
|
+
* dissolves the outgoing panel through HkCrossfade while the
|
|
95
|
+
* container height-morphs to the incoming panel's natural size
|
|
96
|
+
* (panels never had switch CSS before — inactive ones were simply
|
|
97
|
+
* display:none'd, so nothing visual is layered away);
|
|
91
98
|
* - full arrow-key navigation (arrows/Home/End, wrapping, disabled tabs
|
|
92
99
|
* skipped, selection follows focus) and roving tabindex.
|
|
93
100
|
*/
|
|
@@ -435,6 +442,13 @@ export default defineComponent({
|
|
|
435
442
|
|
|
436
443
|
return () => {
|
|
437
444
|
const mergedHead = mergedRun.value[0];
|
|
445
|
+
/** The ONE panel on stage: the active tab's, when panels render
|
|
446
|
+
* (pill mode only) and the tab is not collapsed into the merged
|
|
447
|
+
* cell — merged options' panels are not rendered while merged
|
|
448
|
+
* (unchanged contract; the whole container drops out instead). */
|
|
449
|
+
const panelTab = props.renderPanels && !isSegmented.value
|
|
450
|
+
? props.tabs.find((tb) => tb.key === props.modelValue && !isMergedTab(tb))
|
|
451
|
+
: undefined;
|
|
438
452
|
const listChildren = [
|
|
439
453
|
<div
|
|
440
454
|
key="$indicator"
|
|
@@ -553,16 +567,20 @@ export default defineComponent({
|
|
|
553
567
|
</HkScrollContainer>
|
|
554
568
|
) : list}
|
|
555
569
|
|
|
556
|
-
{
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
570
|
+
{/* Panel area (pill mode, renderPanels): ONE morph container
|
|
571
|
+
keyed on the active tab (2026-10-22 animation direction) —
|
|
572
|
+
HkCrossfade dissolves the outgoing panel in the shared grid
|
|
573
|
+
cell while the container's inline-height transition rides the
|
|
574
|
+
incoming panel's natural size. The strip's own hk-tabs-swap
|
|
575
|
+
transitions are untouched; a merged-active strip renders no
|
|
576
|
+
panel at all, exactly as before. */}
|
|
577
|
+
{panelTab && (
|
|
578
|
+
<HkTransitionContainer swapKey={panelTab.key} class="hk-tabs-panels">
|
|
579
|
+
<div role="tabpanel" class="hk-tabs-panel" data-active>
|
|
580
|
+
{slots[panelTab.key]?.()}
|
|
581
|
+
</div>
|
|
582
|
+
</HkTransitionContainer>
|
|
583
|
+
)}
|
|
566
584
|
</div>
|
|
567
585
|
);
|
|
568
586
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// HkWindowControls — the two deliberate modes (2026-10-23 user ruling).
|
|
2
|
+
//
|
|
3
|
+
// standard: borderless ghost buttons at the header chrome's own size
|
|
4
|
+
// step (2rem boxes, 16px glyphs, 0.375rem gaps) — no borders, no
|
|
5
|
+
// dividers, no container chrome; the close hover is a small rounded
|
|
6
|
+
// red wash. Reads as one family with gear/search icon buttons.
|
|
7
|
+
//
|
|
8
|
+
// flush: edge-riding squares for thin captions — the host zeroes its
|
|
9
|
+
// own right padding (the s-header-bar--chrome contract pattern); the
|
|
10
|
+
// buttons stretch the host height, radius 0, wash-on-hover only.
|
|
11
|
+
|
|
12
|
+
.hk-window-controls {
|
|
13
|
+
display: flex;
|
|
14
|
+
align-items: center;
|
|
15
|
+
gap: 0.375rem;
|
|
16
|
+
align-self: center;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.hk-window-controls__btn {
|
|
20
|
+
display: flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
justify-content: center;
|
|
23
|
+
width: 2rem;
|
|
24
|
+
height: 2rem;
|
|
25
|
+
padding: 0;
|
|
26
|
+
border: none;
|
|
27
|
+
border-radius: 0.375rem;
|
|
28
|
+
background: transparent;
|
|
29
|
+
/* Inherit the host's foreground — a fixed gray literal is invisible
|
|
30
|
+
* on light floors and wrong in dark mode. */
|
|
31
|
+
color: inherit;
|
|
32
|
+
cursor: pointer;
|
|
33
|
+
transition: background 0.12s ease, color 0.12s ease;
|
|
34
|
+
|
|
35
|
+
&:hover {
|
|
36
|
+
background: rgb(var(--color-muted, 108 108 108) / 16%);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
&--close {
|
|
40
|
+
&:hover {
|
|
41
|
+
background: rgb(232 17 35);
|
|
42
|
+
color: #fff;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── flush (thin captions) ───────────────────────────────────────────
|
|
48
|
+
.hk-window-controls--flush {
|
|
49
|
+
gap: 0;
|
|
50
|
+
align-self: stretch;
|
|
51
|
+
margin-left: 0.25rem;
|
|
52
|
+
|
|
53
|
+
.hk-window-controls__btn {
|
|
54
|
+
width: 2.75rem;
|
|
55
|
+
height: auto;
|
|
56
|
+
min-height: 2.25rem;
|
|
57
|
+
border-radius: 0;
|
|
58
|
+
|
|
59
|
+
& + & {
|
|
60
|
+
/* No dividers in flush mode either — wash-on-hover only. */
|
|
61
|
+
border-left: none;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Phones: the cluster shrinks so the full action row fits.
|
|
67
|
+
@media (max-width: 767px) {
|
|
68
|
+
.hk-window-controls {
|
|
69
|
+
gap: 0.25rem;
|
|
70
|
+
}
|
|
71
|
+
.hk-window-controls__btn {
|
|
72
|
+
width: 1.75rem;
|
|
73
|
+
height: 1.75rem;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, type App } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkWindowControls, { isApplePlatform } from "./HkWindowControls";
|
|
5
|
+
|
|
6
|
+
const containers: HTMLElement[] = [];
|
|
7
|
+
|
|
8
|
+
function mountControls(props: Record<string, unknown> = {}): {
|
|
9
|
+
app: App;
|
|
10
|
+
container: HTMLElement;
|
|
11
|
+
} {
|
|
12
|
+
const container = document.createElement("div");
|
|
13
|
+
document.body.appendChild(container);
|
|
14
|
+
containers.push(container);
|
|
15
|
+
const Wrapper = defineComponent({
|
|
16
|
+
render: () => h(HkWindowControls as never, props),
|
|
17
|
+
});
|
|
18
|
+
const app = createApp(Wrapper);
|
|
19
|
+
app.mount(container);
|
|
20
|
+
return { app, container };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
for (const c of containers.splice(0)) c.remove();
|
|
25
|
+
vi.unstubAllGlobals();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** A fake __TAURI__ window API so the render gates pass off-desktop. */
|
|
29
|
+
function stubTauriWindow(): void {
|
|
30
|
+
vi.stubGlobal("__TAURI__", {
|
|
31
|
+
window: {
|
|
32
|
+
getCurrentWindow: () => ({
|
|
33
|
+
minimize: () => Promise.resolve(),
|
|
34
|
+
toggleMaximize: () => Promise.resolve(),
|
|
35
|
+
close: () => Promise.resolve(),
|
|
36
|
+
isMaximized: () => Promise.resolve(false),
|
|
37
|
+
onResized: () => Promise.resolve(() => {}),
|
|
38
|
+
}),
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("HkWindowControls (two deliberate modes, explicit None)", () => {
|
|
44
|
+
it("renders the three buttons in standard mode", () => {
|
|
45
|
+
stubTauriWindow();
|
|
46
|
+
const t = mountControls({ variant: "standard" });
|
|
47
|
+
const btns = t.container.querySelectorAll<HTMLButtonElement>(
|
|
48
|
+
".hk-window-controls__btn",
|
|
49
|
+
);
|
|
50
|
+
expect(btns).toHaveLength(3);
|
|
51
|
+
expect(t.container.querySelector(".hk-window-controls--flush")).toBeNull();
|
|
52
|
+
t.app.unmount();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("renders the flush modifier in flush mode", () => {
|
|
56
|
+
stubTauriWindow();
|
|
57
|
+
const t = mountControls({ variant: "flush" });
|
|
58
|
+
expect(t.container.querySelector(".hk-window-controls--flush")).not.toBeNull();
|
|
59
|
+
expect(
|
|
60
|
+
t.container.querySelectorAll(".hk-window-controls__btn"),
|
|
61
|
+
).toHaveLength(3);
|
|
62
|
+
t.app.unmount();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("renders NOTHING without a variant (the explicit-None contract)", () => {
|
|
66
|
+
stubTauriWindow();
|
|
67
|
+
const t = mountControls({});
|
|
68
|
+
expect(t.container.querySelector(".hk-window-controls")).toBeNull();
|
|
69
|
+
t.app.unmount();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("renders nothing for an invalid variant (validator escape)", () => {
|
|
73
|
+
stubTauriWindow();
|
|
74
|
+
const t = mountControls({ variant: "rounded" });
|
|
75
|
+
expect(t.container.querySelector(".hk-window-controls")).toBeNull();
|
|
76
|
+
t.app.unmount();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("renders nothing on Apple platforms (the OS owns the traffic lights)", () => {
|
|
80
|
+
stubTauriWindow();
|
|
81
|
+
vi.stubGlobal("navigator", { userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)" });
|
|
82
|
+
expect(isApplePlatform()).toBe(true);
|
|
83
|
+
const t = mountControls({ variant: "standard" });
|
|
84
|
+
expect(t.container.querySelector(".hk-window-controls")).toBeNull();
|
|
85
|
+
t.app.unmount();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("renders nothing without a Tauri window API (browser preview)", () => {
|
|
89
|
+
const t = mountControls({ variant: "standard" });
|
|
90
|
+
expect(t.container.querySelector(".hk-window-controls")).toBeNull();
|
|
91
|
+
t.app.unmount();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { defineComponent, onMounted, onUnmounted, ref, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import { Minus, Square, X, Copy } from "lucide-vue-next";
|
|
4
|
+
|
|
5
|
+
import "./HkWindowControls.scss";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* HkWindowControls — the minimize / maximize / close cluster for hosts
|
|
9
|
+
* that own their window chrome (Tauri custom titlebars, Electron
|
|
10
|
+
* frameless). Three modes, and the choice is DELIBERATELY explicit
|
|
11
|
+
* (user direction 2026-10-23):
|
|
12
|
+
*
|
|
13
|
+
* - `standard` — three borderless ghost icon buttons at the same size
|
|
14
|
+
* step as the header's other chrome (ghost/32, 16px glyphs), spaced
|
|
15
|
+
* with comfortable gaps. The whole top-right reads as one family of
|
|
16
|
+
* identical buttons; the close hover is a small rounded red wash.
|
|
17
|
+
* THE mode for content-height headers (3rem-class bars).
|
|
18
|
+
*
|
|
19
|
+
* - `flush` — edge-riding squares that fill the host's height (the
|
|
20
|
+
* host must zero its own right padding so the close button rides
|
|
21
|
+
* the window edge; wash-on-hover only, radius 0). THE mode for real
|
|
22
|
+
* thin captions (32px-class native-titlebar strips).
|
|
23
|
+
*
|
|
24
|
+
* - **omitted → nothing renders.** There is NO default variant: a
|
|
25
|
+
* forgetful host shows no control buttons (the app stays closable
|
|
26
|
+
* via the taskbar/OS), which fails loudly at design review instead
|
|
27
|
+
* of silently shipping the wrong chrome.
|
|
28
|
+
*
|
|
29
|
+
* macOS: the traffic lights belong to the OS titlebar — this component
|
|
30
|
+
* renders nothing on Apple platforms and the header degrades to its
|
|
31
|
+
* browser-face look automatically.
|
|
32
|
+
*
|
|
33
|
+
* The window API rides `__TAURI__.window.getCurrentWindow()` when
|
|
34
|
+
* present; on a non-Tauri host (browser preview) the buttons render
|
|
35
|
+
* but the calls no-op, keeping visual parity for storybook/tests.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
interface TauriWindowLike {
|
|
39
|
+
minimize(): Promise<void>;
|
|
40
|
+
toggleMaximize(): Promise<void>;
|
|
41
|
+
close(): Promise<void>;
|
|
42
|
+
isMaximized(): Promise<boolean>;
|
|
43
|
+
onResized(cb: () => void): Promise<() => void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function currentWindow(): TauriWindowLike | null {
|
|
47
|
+
const w = (
|
|
48
|
+
window as unknown as {
|
|
49
|
+
__TAURI__?: { window?: { getCurrentWindow?: () => TauriWindowLike } };
|
|
50
|
+
}
|
|
51
|
+
).__TAURI__?.window;
|
|
52
|
+
return w?.getCurrentWindow?.() ?? null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Apple platforms own their traffic lights — we never render there. */
|
|
56
|
+
export function isApplePlatform(): boolean {
|
|
57
|
+
return /Mac|iPhone|iPad|iPod/.test(
|
|
58
|
+
(typeof navigator !== "undefined" && navigator.userAgent) || "",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type WindowControlsVariant = "standard" | "flush";
|
|
63
|
+
|
|
64
|
+
export const HkWindowControls = defineComponent({
|
|
65
|
+
name: "HkWindowControls",
|
|
66
|
+
props: {
|
|
67
|
+
/** REQUIRED — no default. One of the two deliberate modes; omit it
|
|
68
|
+
* and the component renders nothing (the explicit-None contract). */
|
|
69
|
+
variant: {
|
|
70
|
+
type: String as PropType<WindowControlsVariant>,
|
|
71
|
+
required: true,
|
|
72
|
+
validator: (v: unknown) => v === "standard" || v === "flush",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
setup(props) {
|
|
76
|
+
const maximized = ref(false);
|
|
77
|
+
let unlisten: (() => void) | null = null;
|
|
78
|
+
|
|
79
|
+
onMounted(async () => {
|
|
80
|
+
const win = currentWindow();
|
|
81
|
+
if (!win) return;
|
|
82
|
+
try {
|
|
83
|
+
const refresh = () => void win.isMaximized().then((m) => (maximized.value = m));
|
|
84
|
+
refresh();
|
|
85
|
+
const off = await win.onResized(refresh);
|
|
86
|
+
unlisten = off;
|
|
87
|
+
} catch {
|
|
88
|
+
/* the window API is optional furniture */
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
onUnmounted(() => {
|
|
93
|
+
unlisten?.();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return () => {
|
|
97
|
+
// Two render gates before any chrome: Apple owns its traffic
|
|
98
|
+
// lights, and a host that forgot the variant gets NOTHING (the
|
|
99
|
+
// explicit-None contract — belt for runtime JS where TS cannot
|
|
100
|
+
// enforce the required prop).
|
|
101
|
+
if (isApplePlatform()) return null;
|
|
102
|
+
if (props.variant !== "standard" && props.variant !== "flush") return null;
|
|
103
|
+
const win = currentWindow();
|
|
104
|
+
if (!win) return null;
|
|
105
|
+
|
|
106
|
+
const flush = props.variant === "flush";
|
|
107
|
+
return (
|
|
108
|
+
<div
|
|
109
|
+
class={{ "hk-window-controls": true, "hk-window-controls--flush": flush }}
|
|
110
|
+
data-tauri-drag-region={false}
|
|
111
|
+
>
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
class="hk-window-controls__btn"
|
|
115
|
+
aria-label="Minimize"
|
|
116
|
+
onClick={() => void win.minimize()}
|
|
117
|
+
>
|
|
118
|
+
<Minus size={flush ? 14 : 16} />
|
|
119
|
+
</button>
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
class="hk-window-controls__btn"
|
|
123
|
+
aria-label={maximized.value ? "Restore" : "Maximize"}
|
|
124
|
+
onClick={() => void win.toggleMaximize()}
|
|
125
|
+
>
|
|
126
|
+
{maximized.value
|
|
127
|
+
? <Copy size={flush ? 12 : 14} style={{ transform: "rotate(180deg)" }} />
|
|
128
|
+
: <Square size={flush ? 12 : 14} />}
|
|
129
|
+
</button>
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
class="hk-window-controls__btn hk-window-controls__btn--close"
|
|
133
|
+
aria-label="Close"
|
|
134
|
+
onClick={() => void win.close()}
|
|
135
|
+
>
|
|
136
|
+
<X size={flush ? 14 : 16} />
|
|
137
|
+
</button>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
export default HkWindowControls;
|
package/src/index.ts
CHANGED
|
@@ -614,6 +614,8 @@ export { default as HkCopyBadge } from "./components/HkCopyBadge";
|
|
|
614
614
|
export { default as HkConfirmDialog } from "./components/HkConfirmDialog";
|
|
615
615
|
export { default as HkCrossfade } from "./components/HkCrossfade";
|
|
616
616
|
export { default as HkTransitionContainer } from "./components/HkTransitionContainer";
|
|
617
|
+
export { default as HkWindowControls, isApplePlatform } from "./components/HkWindowControls";
|
|
618
|
+
export type { WindowControlsVariant } from "./components/HkWindowControls";
|
|
617
619
|
export { default as HkDivider } from "./components/HkDivider";
|
|
618
620
|
export { default as HkDiffViewer } from "./components/HkDiffViewer";
|
|
619
621
|
export { parseUnifiedDiff, computeGaps } from "./components/diffParse";
|