@celestia-island/hikari 0.41.8 → 0.42.1
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/DemoApp.tsx +35 -0
- package/src/components/HkButton.scss +56 -0
- package/src/components/HkButton.test.tsx +81 -0
- package/src/components/HkButton.tsx +42 -28
- package/src/components/HkIconButton.test.tsx +15 -0
- package/src/components/HkIconButton.tsx +18 -5
- package/src/components/HkLoadMore.scss +53 -0
- package/src/components/HkLoadMore.test.tsx +65 -0
- package/src/components/HkLoadMore.tsx +72 -0
- package/src/components/HkScrollContainer.test.ts +44 -1
- package/src/components/HkScrollContainer.tsx +63 -2
- package/src/components/HkTag.test.tsx +76 -0
- package/src/components/HkTag.tsx +8 -0
- package/src/components/HkTagInput.scss +307 -0
- package/src/components/HkTagInput.singleScroll.contract.test.ts +73 -0
- package/src/components/HkTagInput.test.tsx +1122 -0
- package/src/components/HkTagInput.tsx +761 -0
- package/src/composables/iconRegistry.test.ts +57 -0
- package/src/composables/iconRegistry.ts +14 -11
- package/src/composables/useApproachEnd.test.ts +261 -0
- package/src/composables/useApproachEnd.ts +169 -0
- package/src/i18n/locales/ar/components.json +9 -1
- package/src/i18n/locales/de/components.json +9 -1
- package/src/i18n/locales/en/components.json +9 -1
- package/src/i18n/locales/es/components.json +9 -1
- package/src/i18n/locales/fr/components.json +9 -1
- package/src/i18n/locales/ja/components.json +9 -1
- package/src/i18n/locales/ko/components.json +9 -1
- package/src/i18n/locales/pt/components.json +9 -1
- package/src/i18n/locales/ru/components.json +9 -1
- package/src/i18n/locales/zh-Hans/components.json +9 -1
- package/src/i18n/locales/zh-Hant/components.json +9 -1
- package/src/index.ts +4 -0
package/package.json
CHANGED
package/src/DemoApp.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
HSkeleton, HSkeletonList, HAvatar, HKbd, HDivider,
|
|
17
17
|
HAlert, HEmptyState, HExpansionPanel,
|
|
18
18
|
HTabs, HCard, HTable, HTimeline,
|
|
19
|
+
HScrollContainer, HLoadMore,
|
|
19
20
|
HMediaSlider, HMediaPlayer, HImageViewer,
|
|
20
21
|
HImagePreview,
|
|
21
22
|
HZoomToolbar, HMinimap, HTrendChart,
|
|
@@ -197,6 +198,24 @@ export default defineComponent({
|
|
|
197
198
|
syncZoom();
|
|
198
199
|
}
|
|
199
200
|
|
|
201
|
+
// HLoadMore + HScrollContainer approachEnd — the canonical dynamic
|
|
202
|
+
// loading pair: the container emits `approachEnd` when the content
|
|
203
|
+
// end nears (including the initial under-filled pass), the demo
|
|
204
|
+
// "fetches" a page, and the indicator morphs button → spinner → end.
|
|
205
|
+
const pagedRows = ref(Array.from({ length: 10 }, (_, i) => `Row ${i + 1}`));
|
|
206
|
+
const pagedState = ref<"idle" | "loading" | "end">("idle");
|
|
207
|
+
function loadPagedRows() {
|
|
208
|
+
if (pagedState.value !== "idle") return;
|
|
209
|
+
pagedState.value = "loading";
|
|
210
|
+
setTimeout(() => {
|
|
211
|
+
pagedRows.value = [
|
|
212
|
+
...pagedRows.value,
|
|
213
|
+
...Array.from({ length: 10 }, (_, i) => `Row ${pagedRows.value.length + i + 1}`),
|
|
214
|
+
];
|
|
215
|
+
pagedState.value = pagedRows.value.length >= 60 ? "end" : "idle";
|
|
216
|
+
}, 500);
|
|
217
|
+
}
|
|
218
|
+
|
|
200
219
|
return () => (
|
|
201
220
|
<div class="demo">
|
|
202
221
|
<header class="demo-header">
|
|
@@ -225,6 +244,22 @@ export default defineComponent({
|
|
|
225
244
|
</div>
|
|
226
245
|
</section>
|
|
227
246
|
|
|
247
|
+
<section>
|
|
248
|
+
<h2>HScrollContainer + HLoadMore</h2>
|
|
249
|
+
<HScrollContainer
|
|
250
|
+
style={{ height: "320px" }}
|
|
251
|
+
approachEnd
|
|
252
|
+
onApproachEnd={loadPagedRows}
|
|
253
|
+
>
|
|
254
|
+
<div style={{ display: "flex", flexDirection: "column" }}>
|
|
255
|
+
{pagedRows.value.map((r) => (
|
|
256
|
+
<div key={r} style={{ padding: "6px 12px", borderBottom: "1px solid rgb(var(--color-muted, 108 108 108) / 18%)" }}>{r}</div>
|
|
257
|
+
))}
|
|
258
|
+
<HLoadMore state={pagedState.value} shown={pagedRows.value.length} total={60} onLoadMore={loadPagedRows} />
|
|
259
|
+
</div>
|
|
260
|
+
</HScrollContainer>
|
|
261
|
+
</section>
|
|
262
|
+
|
|
228
263
|
<section>
|
|
229
264
|
<h2>HIconButton</h2>
|
|
230
265
|
<div class="row">
|
|
@@ -64,6 +64,62 @@
|
|
|
64
64
|
min-height: 2.75rem;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/* ---- Prefix / suffix icon boxes ---- */
|
|
68
|
+
/* Standard-part contract (2026-09-10 user direction): a button glyph
|
|
69
|
+
renders inside a FIXED SQUARE box — flex:none so host flex rows can
|
|
70
|
+
never squeeze it into a squat sliver, centered on both axes so the
|
|
71
|
+
icon rides the button's vertical midline at any button height, and
|
|
72
|
+
the box (not the bare svg) owns the rhythm so the glyph keeps a
|
|
73
|
+
consistent margin from the adjacent label via the button's own gap.
|
|
74
|
+
The 1rem box matches the 16px glyph HkButton renders for the
|
|
75
|
+
icon/suffix props. */
|
|
76
|
+
.hk-btn-icon,
|
|
77
|
+
.hk-btn-suffix {
|
|
78
|
+
display: inline-flex;
|
|
79
|
+
align-items: center;
|
|
80
|
+
justify-content: center;
|
|
81
|
+
flex: none;
|
|
82
|
+
inline-size: 1rem;
|
|
83
|
+
block-size: 1rem;
|
|
84
|
+
line-height: 0;
|
|
85
|
+
|
|
86
|
+
svg {
|
|
87
|
+
display: block;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ---- Icon-only square ---- */
|
|
92
|
+
/* An icon-only HkButton (glyph, no label) occupies a SQUARE footprint
|
|
93
|
+
by default: padding collapses fully and width locks to the size
|
|
94
|
+
variant's min-height, so it reads as the same standard square the
|
|
95
|
+
HIconButton family guarantees. box-sizing is self-declared (house
|
|
96
|
+
convention — geometry rules never rely on a host-side reset reaching
|
|
97
|
+
the component): with zero padding the min-height governs the height
|
|
98
|
+
on every size, an exact square under border-box AND content-box.
|
|
99
|
+
Hosts may still override with an explicit width when a design
|
|
100
|
+
genuinely calls for another shape. */
|
|
101
|
+
.hk-btn-icon-only {
|
|
102
|
+
padding: 0;
|
|
103
|
+
box-sizing: border-box;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.hk-btn-sm.hk-btn-icon-only {
|
|
107
|
+
width: 1.75rem;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.hk-btn-md.hk-btn-icon-only {
|
|
111
|
+
width: 2.5rem;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.hk-btn-lg.hk-btn-icon-only {
|
|
115
|
+
width: 2.75rem;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* The first-class block prop wins over the square width. */
|
|
119
|
+
.hk-btn-block.hk-btn-icon-only {
|
|
120
|
+
width: 100%;
|
|
121
|
+
}
|
|
122
|
+
|
|
67
123
|
/* ---- Primary ---- */
|
|
68
124
|
|
|
69
125
|
.hk-btn-primary {
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from "vitest";
|
|
2
2
|
import { createApp, h } from "vue";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
3
6
|
|
|
4
7
|
import HkButton from "./HkButton";
|
|
5
8
|
|
|
9
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
|
|
6
11
|
/**
|
|
7
12
|
* HkButton contract tests:
|
|
8
13
|
* - variant / size / block / loading class mapping on the root <button>
|
|
@@ -142,6 +147,82 @@ describe("HkButton content slots", () => {
|
|
|
142
147
|
});
|
|
143
148
|
});
|
|
144
149
|
|
|
150
|
+
describe("HkButton icon-only square contract", () => {
|
|
151
|
+
it("flags the icon-only square class for a glyph with no label", () => {
|
|
152
|
+
const iconOnly = mount(h(HkButton, { icon: "close", ariaLabel: "Close" }));
|
|
153
|
+
const btn = button(iconOnly);
|
|
154
|
+
expect(btn.className).toContain("hk-btn-icon-only");
|
|
155
|
+
expect(iconOnly.querySelector(".hk-btn-icon")).not.toBeNull();
|
|
156
|
+
|
|
157
|
+
const suffixOnly = mount(h(HkButton, { suffix: "close", ariaLabel: "Next" }));
|
|
158
|
+
expect(button(suffixOnly).className).toContain("hk-btn-icon-only");
|
|
159
|
+
|
|
160
|
+
// Glyph + text stays a normal text button — no square collapse.
|
|
161
|
+
const withText = mount(h(HkButton, { icon: "close" }, () => "Close"));
|
|
162
|
+
expect(button(withText).className).not.toContain("hk-btn-icon-only");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("keeps the square footprint while an icon-only button loads", () => {
|
|
166
|
+
const c = mount(h(HkButton, { icon: "close", loading: true, ariaLabel: "Busy" }));
|
|
167
|
+
// The spinner replaces the glyph, but the button still carries no
|
|
168
|
+
// label — the square footprint must hold through the swap.
|
|
169
|
+
expect(button(c).className).toContain("hk-btn-icon-only");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("keeps the block class alongside the icon-only square class", () => {
|
|
173
|
+
// CSS precedence lives in the SCSS contract test below; this pins the
|
|
174
|
+
// class composition so both rules can actually apply.
|
|
175
|
+
const c = mount(h(HkButton, { icon: "close", block: true, ariaLabel: "Go" }));
|
|
176
|
+
const cls = button(c).className;
|
|
177
|
+
expect(cls).toContain("hk-btn-block");
|
|
178
|
+
expect(cls).toContain("hk-btn-icon-only");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("does not square-collapse when a shortcut chip rides along", () => {
|
|
182
|
+
// The HKbd chip is an extra flex child — the fixed square width would
|
|
183
|
+
// visibly clip it, so icon + shortcut stays a padded button.
|
|
184
|
+
const c = mount(h(HkButton, { icon: "close", shortcut: "Ctrl+W", ariaLabel: "Close" }));
|
|
185
|
+
expect(button(c).className).not.toContain("hk-btn-icon-only");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// SCSS geometry contract (house pattern, cf. HkNumberInput.affix.test.ts):
|
|
190
|
+
// the square is a CSS fact — padding fully collapsed + self-declared
|
|
191
|
+
// border-box (survives hosts without a reset) + the block prop winning
|
|
192
|
+
// over the square width. Textual assertions because happy-dom does not
|
|
193
|
+
// lay out.
|
|
194
|
+
describe("HkButton icon-only SCSS contract", () => {
|
|
195
|
+
const scss = readFileSync(join(here, "HkButton.scss"), "utf-8");
|
|
196
|
+
|
|
197
|
+
it("self-declares the collapsed square geometry", () => {
|
|
198
|
+
const rule = scss.match(/\.hk-btn-icon-only\s*{[^}]*}/)?.[0] ?? "";
|
|
199
|
+
expect(rule, "icon-only rule must exist").toBeTruthy();
|
|
200
|
+
expect(rule).toContain("padding: 0");
|
|
201
|
+
expect(rule).toContain("box-sizing: border-box");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("lets the block prop win over the square width", () => {
|
|
205
|
+
const rule = scss.match(/\.hk-btn-block\.hk-btn-icon-only\s*{[^}]*}/)?.[0] ?? "";
|
|
206
|
+
expect(rule, "block override rule must exist").toBeTruthy();
|
|
207
|
+
expect(rule).toContain("width: 100%");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("locks each size variant width to its min-height", () => {
|
|
211
|
+
const cases: Array<[string, string]> = [
|
|
212
|
+
["sm", "1.75rem"],
|
|
213
|
+
["md", "2.5rem"],
|
|
214
|
+
["lg", "2.75rem"],
|
|
215
|
+
];
|
|
216
|
+
for (const [size, width] of cases) {
|
|
217
|
+
const rule = scss.match(new RegExp(`\\.hk-btn-${size}\\.hk-btn-icon-only\\s*{[^}]*}`))?.[0] ?? "";
|
|
218
|
+
expect(rule, `${size} icon-only rule must exist`).toBeTruthy();
|
|
219
|
+
expect(rule).toContain(`width: ${width}`);
|
|
220
|
+
const minH = scss.match(new RegExp(`\\.hk-btn-${size}\\s*{[^}]*}`))?.[0] ?? "";
|
|
221
|
+
expect(minH).toContain(`min-height: ${width}`);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
145
226
|
describe("HkButton attr fallthrough (inheritAttrs: false)", () => {
|
|
146
227
|
it("spreads extra attrs and honors an explicit type", () => {
|
|
147
228
|
const c = mount(h(HkButton, { type: "submit", "data-test": "probe" }, () => "Save"));
|
|
@@ -42,33 +42,47 @@ export default defineComponent({
|
|
|
42
42
|
props.shortcut ? "hk-btn-has-shortcut" : "",
|
|
43
43
|
]);
|
|
44
44
|
|
|
45
|
-
return () =>
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
45
|
+
return () => {
|
|
46
|
+
// Icon-only contract (2026-09-10 user direction): a button carrying
|
|
47
|
+
// just a prefix/suffix glyph and no text collapses to the same
|
|
48
|
+
// square footprint the HIconButton family guarantees — a bare icon
|
|
49
|
+
// in a padded text button reads as a squat rectangle otherwise.
|
|
50
|
+
// Re-evaluated per render, NOT a computed: slots.default is not
|
|
51
|
+
// reactive, so a cached flag would go stale when a parent toggles
|
|
52
|
+
// the label slot without touching the icon props. A shortcut chip
|
|
53
|
+
// is excluded: it renders as an extra flex child the fixed square
|
|
54
|
+
// width would visibly clip.
|
|
55
|
+
const iconOnly =
|
|
56
|
+
!slots.default && Boolean(props.icon || props.suffix) && !props.shortcut;
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<button
|
|
60
|
+
{...attrs}
|
|
61
|
+
type={(attrs.type as "button" | "submit" | "reset") || "button"}
|
|
62
|
+
disabled={props.disabled || props.loading}
|
|
63
|
+
class={[buttonClass.value, iconOnly ? "hk-btn-icon-only" : "", attrs.class]}
|
|
64
|
+
style={attrs.style || undefined}
|
|
65
|
+
aria-label={props.ariaLabel}
|
|
66
|
+
aria-busy={props.loading || undefined}
|
|
67
|
+
onClick={(e) => emit("click", e)}
|
|
68
|
+
>
|
|
69
|
+
{props.loading ? <HSpinner size="xs" tone="current" /> : null}
|
|
70
|
+
{!props.loading && props.icon ? (
|
|
71
|
+
<span class="hk-btn-icon">
|
|
72
|
+
<HIcon name={props.icon} size={16} />
|
|
73
|
+
</span>
|
|
74
|
+
) : null}
|
|
75
|
+
{slots.default?.()}
|
|
76
|
+
{props.suffix ? (
|
|
77
|
+
<span class="hk-btn-suffix">
|
|
78
|
+
<HIcon name={props.suffix} size={16} />
|
|
79
|
+
</span>
|
|
80
|
+
) : null}
|
|
81
|
+
{props.shortcut ? (
|
|
82
|
+
<HKbd keys={props.shortcut!} size="sm" />
|
|
83
|
+
) : null}
|
|
84
|
+
</button>
|
|
85
|
+
);
|
|
86
|
+
};
|
|
73
87
|
},
|
|
74
88
|
});
|
|
@@ -102,4 +102,19 @@ describe("HkIconButton", () => {
|
|
|
102
102
|
const c = mountComp(() => h(HkIconButton, { "aria-label": "probe" }));
|
|
103
103
|
expect(c.querySelector("circle")).not.toBeNull();
|
|
104
104
|
});
|
|
105
|
+
|
|
106
|
+
// Named-icon contract (2026-09-10): the icon prop resolves through HIcon
|
|
107
|
+
// (functional keys ride the host material pack) — slot content still
|
|
108
|
+
// wins over it, and the placeholder circle only shows with neither.
|
|
109
|
+
it("renders the icon prop through HIcon, below slot precedence", () => {
|
|
110
|
+
const propOnly = mountComp(() => h(HkIconButton, { icon: "close", "aria-label": "close" }));
|
|
111
|
+
expect(propOnly.querySelector(".hk-icon")).not.toBeNull();
|
|
112
|
+
expect(propOnly.querySelector("circle")).toBeNull();
|
|
113
|
+
|
|
114
|
+
const propAndSlot = mountComp(() =>
|
|
115
|
+
h(HkIconButton, { icon: "close", "aria-label": "close" }, { icon: () => h("span", { class: "slot-probe" }) }),
|
|
116
|
+
);
|
|
117
|
+
expect(propAndSlot.querySelector(".slot-probe")).not.toBeNull();
|
|
118
|
+
expect(propAndSlot.querySelector(".hk-icon")).toBeNull();
|
|
119
|
+
});
|
|
105
120
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { computed, defineComponent, type PropType } from "vue";
|
|
2
2
|
import "./HkIconButton.scss";
|
|
3
3
|
import "./HkIconButtonVars.scss";
|
|
4
|
+
import HIcon from "./HkIcon";
|
|
4
5
|
|
|
5
6
|
export default defineComponent({
|
|
6
7
|
name: "HkIconButton",
|
|
@@ -21,6 +22,14 @@ export default defineComponent({
|
|
|
21
22
|
`hk-icon-button-${props.variant}`,
|
|
22
23
|
]);
|
|
23
24
|
|
|
25
|
+
// Named-icon contract (2026-09-10 user direction): the icon prop is a
|
|
26
|
+
// first-class way to draw the glyph — it resolves through HIcon so
|
|
27
|
+
// functional keys ("close", "back") keep riding the host material-pack
|
|
28
|
+
// family exactly like the slot composition does. The rendered size is
|
|
29
|
+
// pinned by --hi-icon-button-icon-size; the HIcon size prop only picks
|
|
30
|
+
// the matching box class. Slot content still wins over the prop.
|
|
31
|
+
const glyphSize = computed(() => (props.size >= 32 ? 20 : 16));
|
|
32
|
+
|
|
24
33
|
return () => (
|
|
25
34
|
<button
|
|
26
35
|
class={cls.value}
|
|
@@ -35,11 +44,15 @@ export default defineComponent({
|
|
|
35
44
|
? // Natural children usage: <HIconButton><HIcon .../></HIconButton>
|
|
36
45
|
// renders the child as the icon (unified window-close wave).
|
|
37
46
|
slots.default()
|
|
38
|
-
:
|
|
39
|
-
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
: props.icon
|
|
48
|
+
? (
|
|
49
|
+
<HIcon name={props.icon} size={glyphSize.value} />
|
|
50
|
+
)
|
|
51
|
+
: (
|
|
52
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
53
|
+
<circle cx="12" cy="12" r="10" />
|
|
54
|
+
</svg>
|
|
55
|
+
)}
|
|
43
56
|
</span>
|
|
44
57
|
</button>
|
|
45
58
|
);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* HkLoadMore — the unified bottom indicator for dynamically loaded
|
|
3
|
+
* lists. Hairline rules flank the control so the indicator reads as
|
|
4
|
+
* "the list pauses here" rather than a stray button; everything is
|
|
5
|
+
* token-driven (no hardcoded colors) so themes restyle it for free.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
.hk-load-more {
|
|
9
|
+
display: flex;
|
|
10
|
+
align-items: center;
|
|
11
|
+
gap: var(--space-12);
|
|
12
|
+
padding: var(--space-12) 0;
|
|
13
|
+
color: rgb(var(--color-text-tertiary));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* Hairline rules: transparent at the outer edge, muted towards the
|
|
17
|
+
* control; the end rule mirrors the gradient. */
|
|
18
|
+
.hk-load-more-rule {
|
|
19
|
+
flex: 1;
|
|
20
|
+
height: 1px;
|
|
21
|
+
background: linear-gradient(to right, transparent, rgb(var(--color-muted) / 35%));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.hk-load-more-rule-end {
|
|
25
|
+
background: linear-gradient(to left, transparent, rgb(var(--color-muted) / 35%));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.hk-load-more-core {
|
|
29
|
+
flex: none;
|
|
30
|
+
display: inline-flex;
|
|
31
|
+
align-items: center;
|
|
32
|
+
gap: var(--space-10);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* Delivery progress ("3 / 19"): mono, tabular so the digits don't
|
|
36
|
+
* shimmer while counting up. */
|
|
37
|
+
.hk-load-more-count {
|
|
38
|
+
font-family: var(--font-mono);
|
|
39
|
+
font-size: var(--text-2xs);
|
|
40
|
+
font-variant-numeric: tabular-nums;
|
|
41
|
+
letter-spacing: 0.04em;
|
|
42
|
+
color: rgb(var(--color-text-tertiary));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.hk-load-more[data-state="loading"] .hk-load-more-count {
|
|
46
|
+
opacity: 0.6;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.hk-load-more-end {
|
|
50
|
+
font-size: var(--text-2xs);
|
|
51
|
+
letter-spacing: 0.08em;
|
|
52
|
+
color: rgb(var(--color-text-tertiary));
|
|
53
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkLoadMore from "./HkLoadMore";
|
|
5
|
+
|
|
6
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
7
|
+
|
|
8
|
+
function mount(node: ReturnType<typeof h>) {
|
|
9
|
+
const container = document.createElement("div");
|
|
10
|
+
document.body.appendChild(container);
|
|
11
|
+
const app = createApp({ render: () => node });
|
|
12
|
+
app.mount(container);
|
|
13
|
+
mounts.push({ app, container });
|
|
14
|
+
return container;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
19
|
+
app.unmount();
|
|
20
|
+
container.remove();
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** The en bundle loads eagerly, so the two-arg t() fallbacks double as
|
|
25
|
+
* the assertions' expected strings — no i18n test setup needed. */
|
|
26
|
+
describe("HkLoadMore", () => {
|
|
27
|
+
it("idle renders the load-more button and the shown/total counter", () => {
|
|
28
|
+
const c = mount(h(HkLoadMore, { shown: 3, total: 19 }));
|
|
29
|
+
const button = c.querySelector("button.hk-btn");
|
|
30
|
+
expect(button).not.toBeNull();
|
|
31
|
+
expect(button!.textContent).toContain("Load more");
|
|
32
|
+
expect(c.querySelector(".hk-load-more-count")!.textContent).toBe("3 / 19");
|
|
33
|
+
expect(c.querySelector(".hk-load-more")!.getAttribute("data-state")).toBe("idle");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("clicking the button emits load-more", () => {
|
|
37
|
+
const fired: string[] = [];
|
|
38
|
+
const c = mount(h(HkLoadMore, { onLoadMore: () => fired.push("more") }));
|
|
39
|
+
(c.querySelector("button.hk-btn") as HTMLButtonElement).click();
|
|
40
|
+
expect(fired).toEqual(["more"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("loading state disables the button with aria-busy and the loading label", () => {
|
|
44
|
+
const c = mount(h(HkLoadMore, { state: "loading", shown: 3, total: 19 }));
|
|
45
|
+
const button = c.querySelector("button.hk-btn") as HTMLButtonElement;
|
|
46
|
+
expect(button.disabled).toBe(true);
|
|
47
|
+
expect(button.getAttribute("aria-busy")).toBe("true");
|
|
48
|
+
expect(button.textContent).toContain("Loading…");
|
|
49
|
+
expect(c.querySelector(".hk-spinner")).not.toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("end state swaps the button for the reached-the-end status note", () => {
|
|
53
|
+
const c = mount(h(HkLoadMore, { state: "end", shown: 19, total: 19 }));
|
|
54
|
+
expect(c.querySelector("button.hk-btn")).toBeNull();
|
|
55
|
+
const note = c.querySelector('[role="status"]');
|
|
56
|
+
expect(note).not.toBeNull();
|
|
57
|
+
expect(note!.textContent).toContain("You've reached the end");
|
|
58
|
+
expect(c.querySelector(".hk-load-more-count")!.textContent).toBe("19 / 19");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("omits the counter entirely when shown/total are absent", () => {
|
|
62
|
+
const c = mount(h(HkLoadMore));
|
|
63
|
+
expect(c.querySelector(".hk-load-more-count")).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { defineComponent, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import "./HkLoadMore.scss";
|
|
4
|
+
import { useI18n } from "../i18n/context";
|
|
5
|
+
import HButton from "./HkButton";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The unified bottom indicator for dynamically loaded lists — one look
|
|
9
|
+
* for every consumer (cruise panels, tables, feeds), three states:
|
|
10
|
+
*
|
|
11
|
+
* - `idle`: a ghost load-more button; clicking emits `loadMore`.
|
|
12
|
+
* - `loading`: the SAME button with its built-in spinner (HkButton
|
|
13
|
+
* swaps in an HSpinner while loading) — no second spinner here, the
|
|
14
|
+
* affordance morphs in place at a stable height.
|
|
15
|
+
* - `end`: a muted "reached the end" note replacing the button.
|
|
16
|
+
*
|
|
17
|
+
* Optional `shown`/`total` render a tabular mono counter next to the
|
|
18
|
+
* control in every state ("3 / 19"), giving users the delivery
|
|
19
|
+
* progress without each app inventing its own wording.
|
|
20
|
+
*/
|
|
21
|
+
export default defineComponent({
|
|
22
|
+
name: "HkLoadMore",
|
|
23
|
+
props: {
|
|
24
|
+
state: { type: String as PropType<"idle" | "loading" | "end">, default: "idle" },
|
|
25
|
+
/** Delivered item count (with `total`, renders the progress counter). */
|
|
26
|
+
shown: { type: Number, default: undefined },
|
|
27
|
+
/** Full item count behind the windowed list. */
|
|
28
|
+
total: { type: Number, default: undefined },
|
|
29
|
+
},
|
|
30
|
+
emits: ["loadMore"],
|
|
31
|
+
setup(props, { emit }) {
|
|
32
|
+
const { t } = useI18n();
|
|
33
|
+
return () => {
|
|
34
|
+
const hasCount =
|
|
35
|
+
typeof props.shown === "number" && typeof props.total === "number";
|
|
36
|
+
const loading = props.state === "loading";
|
|
37
|
+
return (
|
|
38
|
+
<div class="hk-load-more" data-state={props.state}>
|
|
39
|
+
<span class="hk-load-more-rule" aria-hidden="true" />
|
|
40
|
+
<span class="hk-load-more-core">
|
|
41
|
+
{props.state === "end" ? (
|
|
42
|
+
<span class="hk-load-more-end" role="status">
|
|
43
|
+
{t("hikari::loadMore.end", "You've reached the end")}
|
|
44
|
+
</span>
|
|
45
|
+
) : (
|
|
46
|
+
<HButton
|
|
47
|
+
variant="ghost"
|
|
48
|
+
size="sm"
|
|
49
|
+
icon="ChevronDown"
|
|
50
|
+
loading={loading}
|
|
51
|
+
aria-label={
|
|
52
|
+
loading
|
|
53
|
+
? t("hikari::loadMore.loading", "Loading…")
|
|
54
|
+
: t("hikari::loadMore.more", "Load more")
|
|
55
|
+
}
|
|
56
|
+
onClick={() => emit("loadMore")}
|
|
57
|
+
>
|
|
58
|
+
{loading
|
|
59
|
+
? t("hikari::loadMore.loading", "Loading…")
|
|
60
|
+
: t("hikari::loadMore.more", "Load more")}
|
|
61
|
+
</HButton>
|
|
62
|
+
)}
|
|
63
|
+
{hasCount ? (
|
|
64
|
+
<span class="hk-load-more-count">{props.shown} / {props.total}</span>
|
|
65
|
+
) : null}
|
|
66
|
+
</span>
|
|
67
|
+
<span class="hk-load-more-rule hk-load-more-rule-end" aria-hidden="true" />
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
});
|
|
@@ -11,6 +11,8 @@ interface Mounted {
|
|
|
11
11
|
instance: {
|
|
12
12
|
refresh: () => void;
|
|
13
13
|
getOverflow: () => { horizontal: string; vertical: string };
|
|
14
|
+
isNearEnd?: () => boolean;
|
|
15
|
+
recheck?: () => void;
|
|
14
16
|
} | null;
|
|
15
17
|
}
|
|
16
18
|
|
|
@@ -47,7 +49,7 @@ function mountScroller(props: Record<string, unknown> = {}, slotText = "content"
|
|
|
47
49
|
/** happy-dom performs no layout, so scroll geometry is stubbed on the
|
|
48
50
|
* viewport instance (shadowing the prototype getters) before a
|
|
49
51
|
* refresh() pass re-senses the overflow. */
|
|
50
|
-
function stubGeometry(el: HTMLElement, geom: { scrollWidth?: number; clientWidth?: number; scrollLeft?: number }) {
|
|
52
|
+
function stubGeometry(el: HTMLElement, geom: { scrollWidth?: number; clientHeight?: number; clientWidth?: number; scrollLeft?: number; scrollHeight?: number; scrollTop?: number }) {
|
|
51
53
|
const desc: PropertyDescriptorMap = {};
|
|
52
54
|
for (const [key, value] of Object.entries(geom)) {
|
|
53
55
|
desc[key] = { configurable: true, get: () => value };
|
|
@@ -158,3 +160,44 @@ describe("HkScrollContainer scrollbar reactivity", () => {
|
|
|
158
160
|
expect(root.querySelectorAll(".hk-scrollbar-track").length).toBe(0);
|
|
159
161
|
});
|
|
160
162
|
});
|
|
163
|
+
|
|
164
|
+
describe("HkScrollContainer approachEnd", () => {
|
|
165
|
+
/** Two awaited frames: scheduleFrame's scheduling rAF + the composable's
|
|
166
|
+
* onceFrame initial sensing pass. */
|
|
167
|
+
async function flushFrames(): Promise<void> {
|
|
168
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()));
|
|
169
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
it("emits approachEnd for under-filled content and exposes isNearEnd", async () => {
|
|
173
|
+
const emissions: number[] = [];
|
|
174
|
+
const m = mountScroller({ approachEnd: true, onApproachEnd: () => emissions.push(1) });
|
|
175
|
+
// happy-dom has no layout; a real under-filled DOM reports the
|
|
176
|
+
// CSSOM clamp (scrollHeight == clientHeight), so stub THAT.
|
|
177
|
+
stubGeometry(m.viewport, { scrollHeight: 300, clientHeight: 300, scrollTop: 0 });
|
|
178
|
+
await flushFrames();
|
|
179
|
+
expect(emissions.length).toBe(1);
|
|
180
|
+
expect(m.instance?.isNearEnd?.()).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("does not emit when the prop is off", async () => {
|
|
184
|
+
const emissions: number[] = [];
|
|
185
|
+
mountScroller({ onApproachEnd: () => emissions.push(1) });
|
|
186
|
+
await flushFrames();
|
|
187
|
+
expect(emissions.length).toBe(0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("exposes recheck and honours it at the container level", async () => {
|
|
191
|
+
const emissions: number[] = [];
|
|
192
|
+
const m = mountScroller({ approachEnd: true, onApproachEnd: () => emissions.push(1) });
|
|
193
|
+
stubGeometry(m.viewport, { scrollHeight: 300, clientHeight: 300, scrollTop: 0 });
|
|
194
|
+
await flushFrames();
|
|
195
|
+
expect(emissions.length).toBe(1);
|
|
196
|
+
// Pin recheck on the exposed surface: dropping it from expose() must
|
|
197
|
+
// fail here, not just at the composable level.
|
|
198
|
+
expect(typeof m.instance?.recheck).toBe("function");
|
|
199
|
+
m.instance?.recheck?.();
|
|
200
|
+
await flushFrames();
|
|
201
|
+
expect(emissions.length).toBe(2);
|
|
202
|
+
});
|
|
203
|
+
});
|