@celestia-island/hikari 0.55.134 → 0.55.135
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/HkMenu.tsx +13 -0
- package/src/components/HkSelectPanel.test.tsx +32 -0
- package/src/components/HkSelectPanel.tsx +24 -7
- package/src/components/HkTemplateField.scss +13 -1
- package/src/components/HkTemplateField.styles.test.ts +41 -1
- package/src/components/HkTemplateField.test.tsx +82 -0
- package/src/components/HkTemplateField.tsx +21 -11
package/package.json
CHANGED
|
@@ -157,6 +157,17 @@ export default defineComponent({
|
|
|
157
157
|
* length, applied on the `dvh`-capable branch, never sanitized.
|
|
158
158
|
*/
|
|
159
159
|
maxHeight: { type: String, default: undefined },
|
|
160
|
+
/**
|
|
161
|
+
* Forwarded to the desktop popout panels' `autoFlip` (HkSelectPanel):
|
|
162
|
+
* whether a panel whose placed side cannot host it migrates to the
|
|
163
|
+
* opposite vertical side. Default true — every existing consumer
|
|
164
|
+
* keeps the flip. Hosts that must keep the popup on its placed side
|
|
165
|
+
* (the fill-template field's vocabulary panel anchors DIRECTLY BELOW
|
|
166
|
+
* its field, user direction 2026-10-10) pass `false`; the viewport
|
|
167
|
+
* clamp then absorbs the overflow instead. Mobile sheets have no
|
|
168
|
+
* flip to make — the prop never reaches them.
|
|
169
|
+
*/
|
|
170
|
+
autoFlip: { type: Boolean, default: true },
|
|
160
171
|
/**
|
|
161
172
|
* Sidebar variant only: every collapsible group renders EXPANDED
|
|
162
173
|
* until the user collapses it — nav sidebars that read as a table of
|
|
@@ -606,6 +617,7 @@ export default defineComponent({
|
|
|
606
617
|
offset={cfg.offset}
|
|
607
618
|
matchAnchorWidth={cfg.match}
|
|
608
619
|
maxHeight={props.maxHeight}
|
|
620
|
+
autoFlip={props.autoFlip}
|
|
609
621
|
onUpdate:open={(v: boolean) => {
|
|
610
622
|
if (!v) onRootCloseRequest();
|
|
611
623
|
}}
|
|
@@ -634,6 +646,7 @@ export default defineComponent({
|
|
|
634
646
|
placement="bottom-start"
|
|
635
647
|
offset={0}
|
|
636
648
|
maxHeight={props.maxHeight}
|
|
649
|
+
autoFlip={props.autoFlip}
|
|
637
650
|
onUpdate:open={(v: boolean) => {
|
|
638
651
|
if (!v) onSubCloseRequest(level);
|
|
639
652
|
}}
|
|
@@ -431,6 +431,38 @@ describe("HkSelectPanel custom invocation", () => {
|
|
|
431
431
|
window.innerHeight = prevHeight;
|
|
432
432
|
}
|
|
433
433
|
});
|
|
434
|
+
|
|
435
|
+
it("autoFlip: false keeps a starved bottom popout below its anchor (clamped)", async () => {
|
|
436
|
+
// The below-first rule (HkTemplateField's vocabulary panel): an
|
|
437
|
+
// anchor near the viewport bottom with room only ABOVE must not
|
|
438
|
+
// migrate the panel above — with autoFlip off the placed side holds
|
|
439
|
+
// (data-side stays "bottom", so the pop motion still grows the panel
|
|
440
|
+
// out of the anchor's bottom edge) and the clamp slides the panel up
|
|
441
|
+
// only as far as full visibility requires. The default flips the
|
|
442
|
+
// same rig top-side — that direction is pinned by the tall-popout
|
|
443
|
+
// clamp test above.
|
|
444
|
+
const prevHeight = window.innerHeight;
|
|
445
|
+
window.innerHeight = 800;
|
|
446
|
+
try {
|
|
447
|
+
const { container, open } = mountPanel({ placement: "bottom-start", autoFlip: false });
|
|
448
|
+
await nextTick();
|
|
449
|
+
|
|
450
|
+
const anchor = container.querySelector<HTMLButtonElement>("button")!;
|
|
451
|
+
anchor.getBoundingClientRect = () =>
|
|
452
|
+
({ top: 760, bottom: 780, left: 40, right: 140, width: 100, height: 20 }) as DOMRect;
|
|
453
|
+
open.value = true;
|
|
454
|
+
await nextTick();
|
|
455
|
+
|
|
456
|
+
const host = document.body.querySelector<HTMLElement>(".hk-select-popout-host")!;
|
|
457
|
+
expect(host.dataset.side).toBe("bottom");
|
|
458
|
+
// Raw top 780 + offset 4 = 784 overflows on an 800px viewport
|
|
459
|
+
// (happy-dom measures the panel at the 200px fallback): the clamp
|
|
460
|
+
// pins it to 800 - 200 - 16 = 584 instead of flipping.
|
|
461
|
+
expect(Number.parseInt(host.style.top, 10)).toBe(584);
|
|
462
|
+
} finally {
|
|
463
|
+
window.innerHeight = prevHeight;
|
|
464
|
+
}
|
|
465
|
+
});
|
|
434
466
|
});
|
|
435
467
|
|
|
436
468
|
describe("HkSelectPanel back-guard (window-first back priority)", () => {
|
|
@@ -91,6 +91,19 @@ export default defineComponent({
|
|
|
91
91
|
type: String as PropType<SelectPanelPlacement>,
|
|
92
92
|
default: "bottom-start",
|
|
93
93
|
},
|
|
94
|
+
/**
|
|
95
|
+
* Auto-flip to the opposite vertical side when the placed side cannot
|
|
96
|
+
* host the panel. Default true — plain dropdowns read better flipped
|
|
97
|
+
* than clamped over their trigger. Surfaces whose popup must stay
|
|
98
|
+
* anchored BELOW their field regardless of how the viewport happens
|
|
99
|
+
* to be filled (the fill-template field's vocabulary panel: user
|
|
100
|
+
* direction 2026-10-10 — it opens in the flow under the input, never
|
|
101
|
+
* migrating above it) pass `false`: the panel keeps the placed side
|
|
102
|
+
* and the viewport clamp absorbs the overflow instead (an oversized
|
|
103
|
+
* panel slides up over the anchor only as far as full visibility
|
|
104
|
+
* requires).
|
|
105
|
+
*/
|
|
106
|
+
autoFlip: { type: Boolean, default: true },
|
|
94
107
|
/** Gap between anchor and popout, in px. */
|
|
95
108
|
offset: { type: Number, default: 4 },
|
|
96
109
|
/** Popout min-width follows the anchor width (select parity). */
|
|
@@ -557,13 +570,17 @@ export default defineComponent({
|
|
|
557
570
|
side === "top"
|
|
558
571
|
? r.top - props.offset - ph
|
|
559
572
|
: r.bottom + props.offset;
|
|
560
|
-
// Auto-flip when the chosen side cannot host the panel
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
573
|
+
// Auto-flip when the chosen side cannot host the panel (opt-out
|
|
574
|
+
// via autoFlip: false keeps the placed side; the clamp below then
|
|
575
|
+
// absorbs an overflow instead of migrating the panel).
|
|
576
|
+
if (props.autoFlip) {
|
|
577
|
+
if (side === "bottom" && top + ph > frame.y + frame.height - pad) {
|
|
578
|
+
side = "top";
|
|
579
|
+
top = r.top - props.offset - ph;
|
|
580
|
+
} else if (side === "top" && top < frame.y + pad) {
|
|
581
|
+
side = "bottom";
|
|
582
|
+
top = r.bottom + props.offset;
|
|
583
|
+
}
|
|
567
584
|
}
|
|
568
585
|
const align = props.placement.endsWith("-center")
|
|
569
586
|
? "center"
|
|
@@ -75,7 +75,19 @@
|
|
|
75
75
|
min-height: calc(1.5rem + var(--space-4, 4px));
|
|
76
76
|
display: flex;
|
|
77
77
|
flex-wrap: wrap;
|
|
78
|
-
|
|
78
|
+
// Baseline alignment (user direction 2026-10-10): the chip's smaller
|
|
79
|
+
// monospace text must share the literal text's BASELINE, not its box
|
|
80
|
+
// midpoint — `center` centered the chip's box, which floats the chip's
|
|
81
|
+
// baseline a fraction above the surrounding text's (smaller font,
|
|
82
|
+
// same-height line) and read as the chip riding high. Baseline is the
|
|
83
|
+
// flex-native fix: text runs and chips (inline-flex, whose baseline
|
|
84
|
+
// is its content text's) align on one shared baseline with no pixel
|
|
85
|
+
// nudges. `align-content: center` keeps the single-line read
|
|
86
|
+
// vertically centered inside the min-height box — the line sizes to
|
|
87
|
+
// its content and the LINE centers, where plain `baseline` alone
|
|
88
|
+
// would top-align it in the stretched line.
|
|
89
|
+
align-items: baseline;
|
|
90
|
+
align-content: center;
|
|
79
91
|
gap: 2px 3px;
|
|
80
92
|
font-size: var(--text-base, 0.875rem);
|
|
81
93
|
line-height: 1.5;
|
|
@@ -17,7 +17,12 @@ import { describe, expect, it } from "vitest";
|
|
|
17
17
|
* mobile sheet must keep the panel as the ONE scroll region;
|
|
18
18
|
* 2. the scroll host is a positioning context (`position: relative`)
|
|
19
19
|
* for the overlay rails;
|
|
20
|
-
* 3.
|
|
20
|
+
* 3. the edit's children align on the shared text BASELINE
|
|
21
|
+
* (`align-items: baseline` + `align-content: center`) and no chip
|
|
22
|
+
* rule carries a manual vertical nudge — the flex alignment IS the
|
|
23
|
+
* fix for the chip-rides-high report, a pixel hack would be a
|
|
24
|
+
* regression;
|
|
25
|
+
* 4. a curated class list checks TSX→CSS (no unstyled element) and a
|
|
21
26
|
* reverse scan flags any `hk-tpl-*`/`hk-template-*` selector in the
|
|
22
27
|
* sheet that no source file renders (no dead rule).
|
|
23
28
|
*/
|
|
@@ -109,6 +114,41 @@ describe("HkTemplateField stylesheet contract", () => {
|
|
|
109
114
|
expect(css).toMatch(/\.hk-tpl-editor-scroll\s*\{[^}]*position:\s*relative/s);
|
|
110
115
|
});
|
|
111
116
|
|
|
117
|
+
it("aligns the edit's children on the shared text baseline, never box centers", () => {
|
|
118
|
+
// The chip's smaller monospace text must share the literal text's
|
|
119
|
+
// BASELINE (flex-native `align-items: baseline`), not the box
|
|
120
|
+
// midpoint — centering the box floats the chip's baseline a fraction
|
|
121
|
+
// above the surrounding text's and reads as the chip riding high
|
|
122
|
+
// (user report 2026-10-10). Restoring `center` must fail here.
|
|
123
|
+
expect(css).toMatch(/\.hk-template-field-edit\s*\{[^}]*align-items:\s*baseline/s);
|
|
124
|
+
expect(css).not.toMatch(/\.hk-template-field-edit\s*\{[^}]*align-items:\s*center/s);
|
|
125
|
+
// The single-line read stays vertically centered in the min-height
|
|
126
|
+
// box via the LINE centering (align-content) — plain baseline alone
|
|
127
|
+
// would top-align it in the stretched line.
|
|
128
|
+
expect(css).toMatch(/\.hk-template-field-edit\s*\{[^}]*align-content:\s*center/s);
|
|
129
|
+
// The chip editor's current-token line (static chip + description)
|
|
130
|
+
// has been baseline-aligned since introduction — it predates the
|
|
131
|
+
// edit-container fix and stays that way (an R1 review round caught
|
|
132
|
+
// a mutation-recovery slip that had silently flipped it).
|
|
133
|
+
expect(css).toMatch(/\.hk-tpl-editor-current\s*\{[^}]*align-items:\s*baseline/s);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("aligns chips by flex baseline, never by pixel nudges", () => {
|
|
137
|
+
// The baseline contract is the alignment layout itself: no manual
|
|
138
|
+
// vertical offsets on the chip inside the edit (a margin/translate
|
|
139
|
+
// hack would re-introduce the drift the alignment fixes, tuned to
|
|
140
|
+
// one font size). The dark-mode and unknown variants ride the same
|
|
141
|
+
// box, so the whole chip block must stay offset-free.
|
|
142
|
+
const chipRules = [...css.matchAll(/([^{}]*\.hk-tpl-chip[^{}]*)\{([^}]*)\}/g)];
|
|
143
|
+
// Extraction sanity (never trust a 0-hit scan): the chip is styled.
|
|
144
|
+
expect(chipRules.length).toBeGreaterThan(0);
|
|
145
|
+
for (const [, selector, body] of chipRules) {
|
|
146
|
+
expect(body, `chip rule ${(selector ?? "").trim()} must not nudge vertically`).not.toMatch(
|
|
147
|
+
/margin-(top|bottom)|translate[XY]|\btop:|\bbottom:|vertical-align/,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
112
152
|
it("styles every editor class the TSX renders", () => {
|
|
113
153
|
const emitted = classTokensFrom(tsx);
|
|
114
154
|
for (const cls of ["hk-template-field", "hk-template-field-edit", "hk-template-field-box",
|
|
@@ -774,3 +774,85 @@ describe("HkTemplateField vocabulary semantics guards", () => {
|
|
|
774
774
|
expect(active[0]!.textContent).toContain("username");
|
|
775
775
|
});
|
|
776
776
|
});
|
|
777
|
+
|
|
778
|
+
describe("HkTemplateField popup direction (below-first)", () => {
|
|
779
|
+
/** Fixed visual rect (the rootzoom rig's shape). */
|
|
780
|
+
function rect(x: number, y: number, w: number, h: number): DOMRect {
|
|
781
|
+
return {
|
|
782
|
+
x, y, width: w, height: h, top: y, left: x, right: x + w, bottom: y + h,
|
|
783
|
+
toJSON: () => ({}),
|
|
784
|
+
} as DOMRect;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
async function flushFrames() {
|
|
788
|
+
await nextTick();
|
|
789
|
+
for (let i = 0; i < 3; i++) {
|
|
790
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
791
|
+
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
it("the vocabulary panel stays below a field anchored near the viewport bottom", async () => {
|
|
796
|
+
// Below-first (user direction 2026-10-10): a low field with room only
|
|
797
|
+
// ABOVE must not migrate the panel above the field — the panel keeps
|
|
798
|
+
// the placed bottom side (data-side drives the pop motion's origin)
|
|
799
|
+
// and the viewport clamp absorbs the overflow. The default-flip
|
|
800
|
+
// behavior for plain panels is pinned over in HkSelectPanel's own
|
|
801
|
+
// suite; this pins the field's opt-out through the HkMenu wiring.
|
|
802
|
+
const prevHeight = window.innerHeight;
|
|
803
|
+
window.innerHeight = 800;
|
|
804
|
+
try {
|
|
805
|
+
const el = mount({ modelValue: "" });
|
|
806
|
+
await nextTick();
|
|
807
|
+
const edit = editable(el);
|
|
808
|
+
edit.getBoundingClientRect = () => rect(40, 760, 300, 20);
|
|
809
|
+
await openTrigger(el, "{{ us");
|
|
810
|
+
|
|
811
|
+
const host = document.body.querySelector<HTMLElement>(".hk-select-popout-host")!;
|
|
812
|
+
expect(host).toBeTruthy();
|
|
813
|
+
expect(host.dataset.side).toBe("bottom");
|
|
814
|
+
// Raw top 780 + menu offset 6 = 786 overflows the 800px viewport
|
|
815
|
+
// (the panel measures at the 200px happy-dom fallback): clamped to
|
|
816
|
+
// 800 - 200 - 16 = 584 instead of flipping.
|
|
817
|
+
expect(Number.parseInt(host.style.top, 10)).toBe(584);
|
|
818
|
+
} finally {
|
|
819
|
+
window.innerHeight = prevHeight;
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
it("the chip editor popover stays below a chip anchored near the viewport bottom", async () => {
|
|
824
|
+
// Same below-first rule for the chip editor (autoFlip off on the
|
|
825
|
+
// HPopover): without it, a 300px-tall panel beside a chip with 20px
|
|
826
|
+
// of room below flips top-side — exactly the overlap the user
|
|
827
|
+
// reported. The panel's rect is faked at the prototype (the
|
|
828
|
+
// rootzoom rig's approach) so the flip comparison has real numbers.
|
|
829
|
+
const prevHeight = window.innerHeight;
|
|
830
|
+
window.innerHeight = 800;
|
|
831
|
+
const gBCR = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
|
|
832
|
+
function (this: HTMLElement) {
|
|
833
|
+
if (this.classList.contains("hk-tpl-chip")) return rect(40, 760, 120, 20);
|
|
834
|
+
if (this.classList.contains("hk-popover-panel")) return rect(0, 0, 220, 300);
|
|
835
|
+
return rect(0, 0, 0, 0);
|
|
836
|
+
},
|
|
837
|
+
);
|
|
838
|
+
try {
|
|
839
|
+
const el = mount({ modelValue: "https://g.com/{{username}}.png" });
|
|
840
|
+
await nextTick();
|
|
841
|
+
editable(el).querySelector<HTMLElement>(".hk-tpl-chip")!.click();
|
|
842
|
+
await flushFrames();
|
|
843
|
+
|
|
844
|
+
const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
|
|
845
|
+
expect(panel).toBeTruthy();
|
|
846
|
+
// No flip: the resolved placement keeps the bottom side.
|
|
847
|
+
expect(panel.className).toContain("hk-popover-bottom-start");
|
|
848
|
+
expect(panel.className).not.toContain("hk-popover-top-start");
|
|
849
|
+
// And the position: raw top 780 + offset 4 = 784, clamped to
|
|
850
|
+
// 800 - 300 - 16 = 484 (full visibility beats the anchor gap).
|
|
851
|
+
const host = panel.parentElement!;
|
|
852
|
+
expect(host.style.top).toBe("484px");
|
|
853
|
+
} finally {
|
|
854
|
+
gBCR.mockRestore();
|
|
855
|
+
window.innerHeight = prevHeight;
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
});
|
|
@@ -59,12 +59,14 @@ function isSubsequence(query: string, text: string): boolean {
|
|
|
59
59
|
* the styled form of the exact template it serializes to — Gutenberg
|
|
60
60
|
* style, without any block chrome:
|
|
61
61
|
*
|
|
62
|
-
* - typing `{{` opens the vocabulary panel (desktop: anchored
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
62
|
+
* - typing `{{` opens the vocabulary panel (desktop: anchored DIRECTLY
|
|
63
|
+
* BELOW the field — below-first by user direction 2026-10-10, the
|
|
64
|
+
* viewport clamp absorbs a short viewport instead of a flip; phone:
|
|
65
|
+
* the standard bottom sheet) and picking a row inserts the chip;
|
|
66
|
+
* typing a full `{{ name }}` by hand converts it just the same;
|
|
67
|
+
* - clicking a chip opens the chip editor (desktop: a popover directly
|
|
68
|
+
* below the chip, same below-first rule; phone: a bottom sheet) whose
|
|
69
|
+
* form owns the concrete
|
|
68
70
|
* editing — swap the placeholder for another vocabulary entry, read
|
|
69
71
|
* what it fills from, or remove it — and FOCUS MOVES INTO the form so
|
|
70
72
|
* the next keystroke lands there;
|
|
@@ -1015,9 +1017,13 @@ export const HkTemplateField = defineComponent({
|
|
|
1015
1017
|
{props.hint && !props.error && <p class="hk-template-field-hint">{props.hint}</p>}
|
|
1016
1018
|
{props.error && <p class="hk-template-field-error">{props.error}</p>}
|
|
1017
1019
|
|
|
1018
|
-
{/* Vocabulary panel — anchored
|
|
1019
|
-
|
|
1020
|
-
|
|
1020
|
+
{/* Vocabulary panel — anchored DIRECTLY BELOW the field on
|
|
1021
|
+
desktop (autoFlip off, user direction 2026-10-10: the panel
|
|
1022
|
+
opens in the flow under the input it completes; when the
|
|
1023
|
+
viewport below is too short the clamp slides it up only as
|
|
1024
|
+
far as full visibility requires), the standard bottom sheet
|
|
1025
|
+
on phone. The editable keeps focus; rows click through,
|
|
1026
|
+
arrows/Enter ride the editable keydown. */}
|
|
1021
1027
|
<HkMenu
|
|
1022
1028
|
variant="popup"
|
|
1023
1029
|
items={[]}
|
|
@@ -1029,6 +1035,7 @@ export const HkTemplateField = defineComponent({
|
|
|
1029
1035
|
placement="bottom-start"
|
|
1030
1036
|
matchAnchorWidth
|
|
1031
1037
|
maxHeight="min(18rem, 45dvh)"
|
|
1038
|
+
autoFlip={false}
|
|
1032
1039
|
title={t("hikari::templateField.suggestTitle", "Placeholders")}
|
|
1033
1040
|
>
|
|
1034
1041
|
{filteredTokens.value.length > 0 ? (
|
|
@@ -1053,8 +1060,10 @@ export const HkTemplateField = defineComponent({
|
|
|
1053
1060
|
)}
|
|
1054
1061
|
</HkMenu>
|
|
1055
1062
|
|
|
1056
|
-
{/* Chip editor — a popover
|
|
1057
|
-
|
|
1063
|
+
{/* Chip editor — a popover directly below the chip on desktop
|
|
1064
|
+
(autoFlip off, same below-first rule as the vocabulary panel:
|
|
1065
|
+
user direction 2026-10-10), a bottom sheet on phone; the form
|
|
1066
|
+
inside owns the concrete editing. */}
|
|
1058
1067
|
<HPopover
|
|
1059
1068
|
modelValue={editorOpen.value}
|
|
1060
1069
|
onUpdate:modelValue={(v: boolean) => {
|
|
@@ -1073,6 +1082,7 @@ export const HkTemplateField = defineComponent({
|
|
|
1073
1082
|
anchorRef={editorChip.value}
|
|
1074
1083
|
sheetOnMobile
|
|
1075
1084
|
placement="bottom-start"
|
|
1085
|
+
autoFlip={false}
|
|
1076
1086
|
title={t("hikari::templateField.chipEditorTitle", "Edit placeholder")}
|
|
1077
1087
|
>
|
|
1078
1088
|
<div class="hk-tpl-editor" onFocusin={() => { editorHoldsFocus.value = true; }}>
|