@celestia-island/hikari 0.55.143 → 0.55.144
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/HkAdaptiveDialog.tsx +4 -0
- package/src/components/HkModal.draggable.styles.test.ts +19 -0
- package/src/components/HkModal.scss +34 -5
- package/src/components/HkModal.tsx +79 -0
- package/src/components/HkTokenField.scss +105 -0
- package/src/components/HkTokenField.test.tsx +403 -0
- package/src/components/HkTokenField.tsx +357 -0
- package/src/i18n/locales/ar/components.json +3 -1
- package/src/i18n/locales/de/components.json +3 -1
- package/src/i18n/locales/en/components.json +3 -1
- package/src/i18n/locales/es/components.json +3 -1
- package/src/i18n/locales/fr/components.json +3 -1
- package/src/i18n/locales/ja/components.json +3 -1
- package/src/i18n/locales/ko/components.json +3 -1
- package/src/i18n/locales/pt/components.json +3 -1
- package/src/i18n/locales/ru/components.json +3 -1
- package/src/i18n/locales/zh-Hans/components.json +3 -1
- package/src/i18n/locales/zh-Hant/components.json +3 -1
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -48,6 +48,9 @@ export default defineComponent({
|
|
|
48
48
|
closable: { type: Boolean, default: true },
|
|
49
49
|
/** Desktop modal width (HkModal `width`). */
|
|
50
50
|
width: { type: String as PropType<ModalWidth>, default: "32rem" },
|
|
51
|
+
/** Desktop-only: the modal header becomes a drag handle (see
|
|
52
|
+
* HkModal `draggable`); the mobile sheet never drags. */
|
|
53
|
+
draggable: { type: Boolean, default: false },
|
|
51
54
|
/** Mobile drawer height (HkDrawer `size`). HkDrawer's bottom-side CSS
|
|
52
55
|
* clamps panels at maxHeight 70vh, so the default equals the cap;
|
|
53
56
|
* pass `panelClass` with a custom max-height for taller sheets. */
|
|
@@ -153,6 +156,7 @@ export default defineComponent({
|
|
|
153
156
|
title={props.title}
|
|
154
157
|
closable={props.closable}
|
|
155
158
|
width={props.width}
|
|
159
|
+
draggable={props.draggable}
|
|
156
160
|
footerActions={props.footerActions}
|
|
157
161
|
>
|
|
158
162
|
{{
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
/** The drag affordance contract: the grab cursor/touch lock MUST ride
|
|
7
|
+
* the frame selector — an earlier iteration targeted `.hk-modal[...]`
|
|
8
|
+
* (a class no element carries) and shipped dead CSS (2026-10-11 R2
|
|
9
|
+
* mutation M1 caught it with zero red). */
|
|
10
|
+
describe("HkModal draggable affordance (styles contract)", () => {
|
|
11
|
+
it("scopes the grab handle to the frame's data attribute", () => {
|
|
12
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const scss = readFileSync(join(here, "HkModal.scss"), "utf-8");
|
|
14
|
+
const block = scss.match(/\.hk-modal-content\[data-draggable\]\s+\.hk-modal-header\s*{[^}]*}/)?.[0] ?? "";
|
|
15
|
+
expect(block, "the live drag-affordance selector").toContain("cursor: grab");
|
|
16
|
+
expect(block).toContain("touch-action: none");
|
|
17
|
+
expect(scss).not.toMatch(/\.hk-modal\[data-draggable\]/);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -59,7 +59,10 @@
|
|
|
59
59
|
// The centering translate is part of EVERY transform state — the
|
|
60
60
|
// enter/leave choreography scales on top of it and must never re-seat
|
|
61
61
|
// the frame.
|
|
62
|
-
transform: translate(
|
|
62
|
+
transform: translate(
|
|
63
|
+
calc(-50% + var(--hk-modal-shift-x, 0px)),
|
|
64
|
+
calc(-50% + var(--hk-modal-shift-y, 0px))
|
|
65
|
+
);
|
|
63
66
|
width: 100%;
|
|
64
67
|
// Size changes animate instead of snapping: `height: auto` growth
|
|
65
68
|
// cannot fire a CSS transition (verified in Chromium — neither auto
|
|
@@ -147,21 +150,31 @@
|
|
|
147
150
|
|
|
148
151
|
.hk-modal-content-enter-from {
|
|
149
152
|
opacity: 0;
|
|
150
|
-
transform: translate(
|
|
153
|
+
transform: translate(
|
|
154
|
+
calc(-50% + var(--hk-modal-shift-x, 0px)),
|
|
155
|
+
calc(-50% + var(--hk-modal-shift-y, 0px))
|
|
156
|
+
)
|
|
157
|
+
translateY(5%);
|
|
151
158
|
clip-path: inset(0 0 10% 0 round 0 0 var(--hk-modal-radius, var(--radius-lg, 12px))
|
|
152
159
|
var(--hk-modal-radius, var(--radius-lg, 12px)));
|
|
153
160
|
}
|
|
154
161
|
|
|
155
162
|
.hk-modal-content-enter-to {
|
|
156
163
|
opacity: 1;
|
|
157
|
-
transform: translate(
|
|
164
|
+
transform: translate(
|
|
165
|
+
calc(-50% + var(--hk-modal-shift-x, 0px)),
|
|
166
|
+
calc(-50% + var(--hk-modal-shift-y, 0px))
|
|
167
|
+
);
|
|
158
168
|
clip-path: inset(0 0 0 0 round 0 0 var(--hk-modal-radius, var(--radius-lg, 12px))
|
|
159
169
|
var(--hk-modal-radius, var(--radius-lg, 12px)));
|
|
160
170
|
}
|
|
161
171
|
|
|
162
172
|
.hk-modal-content-leave-from {
|
|
163
173
|
opacity: 1;
|
|
164
|
-
transform: translate(
|
|
174
|
+
transform: translate(
|
|
175
|
+
calc(-50% + var(--hk-modal-shift-x, 0px)),
|
|
176
|
+
calc(-50% + var(--hk-modal-shift-y, 0px))
|
|
177
|
+
);
|
|
165
178
|
// The pinned identity clip: identical to enter-to so the leave's
|
|
166
179
|
// clip interpolates (never jumps) — see the close note above.
|
|
167
180
|
clip-path: inset(0 0 0 0 round 0 0 var(--hk-modal-radius, var(--radius-lg, 12px))
|
|
@@ -173,7 +186,11 @@
|
|
|
173
186
|
// Mirror of enter-from: shifted +5% with the bottom 10% clipped, the
|
|
174
187
|
// visible box is 90% and centered — both edges ease toward the
|
|
175
188
|
// center line, exactly reversing the unveil.
|
|
176
|
-
transform: translate(
|
|
189
|
+
transform: translate(
|
|
190
|
+
calc(-50% + var(--hk-modal-shift-x, 0px)),
|
|
191
|
+
calc(-50% + var(--hk-modal-shift-y, 0px))
|
|
192
|
+
)
|
|
193
|
+
translateY(5%);
|
|
177
194
|
clip-path: inset(0 0 10% 0 round 0 0 var(--hk-modal-radius, var(--radius-lg, 12px))
|
|
178
195
|
var(--hk-modal-radius, var(--radius-lg, 12px)));
|
|
179
196
|
}
|
|
@@ -647,3 +664,15 @@
|
|
|
647
664
|
transition: none;
|
|
648
665
|
}
|
|
649
666
|
}
|
|
667
|
+
|
|
668
|
+
/* ── Draggable desktop windows (opt-in `draggable` prop) ──────────── */
|
|
669
|
+
@media (min-width: 768px) {
|
|
670
|
+
.hk-modal-content[data-draggable] .hk-modal-header {
|
|
671
|
+
cursor: grab;
|
|
672
|
+
touch-action: none;
|
|
673
|
+
user-select: none;
|
|
674
|
+
}
|
|
675
|
+
.hk-modal-content[data-draggable] .hk-modal-header:active {
|
|
676
|
+
cursor: grabbing;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
@@ -173,6 +173,14 @@ export default defineComponent({
|
|
|
173
173
|
* own history entries.
|
|
174
174
|
*/
|
|
175
175
|
backGuard: { type: Boolean, default: true },
|
|
176
|
+
/**
|
|
177
|
+
* Desktop-only drag: the header becomes a grab handle that moves
|
|
178
|
+
* the centered frame (composited into the transform via the
|
|
179
|
+
* --hk-modal-shift-x/y vars, so the enter/leave choreography is
|
|
180
|
+
* untouched). The mobile bottom sheet never drags. Reopening
|
|
181
|
+
* resets the shift.
|
|
182
|
+
*/
|
|
183
|
+
draggable: { type: Boolean, default: false },
|
|
176
184
|
},
|
|
177
185
|
emits: {
|
|
178
186
|
"update:modelValue": (_value: boolean) => true,
|
|
@@ -197,6 +205,72 @@ export default defineComponent({
|
|
|
197
205
|
const handle = ref<{ id: string; zIndex: number } | null>(null);
|
|
198
206
|
const bodyRef = ref<HTMLElement>();
|
|
199
207
|
const contentRef = ref<HTMLElement>();
|
|
208
|
+
/** Live drag shift in px (null = not dragged); mirrored onto the
|
|
209
|
+
* frame as CSS vars so the class-driven transforms stay intact. */
|
|
210
|
+
const dragShift = ref<{ x: number; y: number } | null>(null);
|
|
211
|
+
let dragStart: { px: number; py: number; x: number; y: number } | null = null;
|
|
212
|
+
|
|
213
|
+
function applyShift() {
|
|
214
|
+
const el = contentRef.value;
|
|
215
|
+
if (!el) return;
|
|
216
|
+
if (dragShift.value) {
|
|
217
|
+
el.style.setProperty("--hk-modal-shift-x", `${dragShift.value.x}px`);
|
|
218
|
+
el.style.setProperty("--hk-modal-shift-y", `${dragShift.value.y}px`);
|
|
219
|
+
} else {
|
|
220
|
+
el.style.removeProperty("--hk-modal-shift-x");
|
|
221
|
+
el.style.removeProperty("--hk-modal-shift-y");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function onHeaderPointerDown(e: PointerEvent) {
|
|
226
|
+
// Desktop handle only: the sheet branch (<768px) never consumes
|
|
227
|
+
// the vars, and touch drag would fight the sheet's own gestures.
|
|
228
|
+
if (!props.draggable || e.button !== 0) return;
|
|
229
|
+
if (window.matchMedia("(max-width: 767px)").matches) return;
|
|
230
|
+
// Interactive chrome inside the header (the close button) keeps
|
|
231
|
+
// its own pointer semantics.
|
|
232
|
+
if ((e.target as HTMLElement).closest("button, a, input, select, textarea")) return;
|
|
233
|
+
const el = contentRef.value;
|
|
234
|
+
if (!el) return;
|
|
235
|
+
e.preventDefault();
|
|
236
|
+
const base = dragShift.value ?? { x: 0, y: 0 };
|
|
237
|
+
dragStart = { px: e.clientX, py: e.clientY, x: base.x, y: base.y };
|
|
238
|
+
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function onHeaderPointerMove(e: PointerEvent) {
|
|
242
|
+
if (!dragStart) return;
|
|
243
|
+
// Re-check the guards: `draggable` may have been toggled off
|
|
244
|
+
// mid-drag, and a hover with no button pressed must never move
|
|
245
|
+
// the frame (a stale dragStart after a mid-drag close would
|
|
246
|
+
// otherwise ghost-drag on the next open).
|
|
247
|
+
if (!props.draggable || e.buttons === 0) {
|
|
248
|
+
dragStart = null;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
dragShift.value = {
|
|
252
|
+
x: dragStart.x + (e.clientX - dragStart.px),
|
|
253
|
+
y: dragStart.y + (e.clientY - dragStart.py),
|
|
254
|
+
};
|
|
255
|
+
applyShift();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function onHeaderPointerUp() {
|
|
259
|
+
dragStart = null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Reopening reseats the frame at center — and drops any stale
|
|
263
|
+
// dragStart (a close mid-capture never fires pointerup).
|
|
264
|
+
watch(
|
|
265
|
+
() => props.modelValue,
|
|
266
|
+
(open) => {
|
|
267
|
+
if (open) {
|
|
268
|
+
dragStart = null;
|
|
269
|
+
dragShift.value = null;
|
|
270
|
+
applyShift();
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
);
|
|
200
274
|
/** Natural-height probe inside the scroll container: the body's
|
|
201
275
|
* content wrapper, whose height is the content's intrinsic height
|
|
202
276
|
* regardless of scroll — the size morph measures this (see
|
|
@@ -972,6 +1046,7 @@ export default defineComponent({
|
|
|
972
1046
|
role="dialog"
|
|
973
1047
|
aria-modal="true"
|
|
974
1048
|
aria-label={resolvedSurfaceName.value}
|
|
1049
|
+
data-draggable={props.draggable || undefined}
|
|
975
1050
|
style={{
|
|
976
1051
|
maxWidth: resolvedWidth.value,
|
|
977
1052
|
zIndex: contentZ.value,
|
|
@@ -991,6 +1066,10 @@ export default defineComponent({
|
|
|
991
1066
|
"hk-modal-header",
|
|
992
1067
|
slots.headerLead ? "hk-modal-header--lead" : "",
|
|
993
1068
|
]}
|
|
1069
|
+
onPointerdown={onHeaderPointerDown}
|
|
1070
|
+
onPointermove={onHeaderPointerMove}
|
|
1071
|
+
onPointerup={onHeaderPointerUp}
|
|
1072
|
+
onPointercancel={onHeaderPointerUp}
|
|
994
1073
|
>
|
|
995
1074
|
{slots.headerLead && (
|
|
996
1075
|
<div class="hk-modal-header-lead">{slots.headerLead()}</div>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/* HkTokenField — one box: slider ladder (major) + value + unit suffix.
|
|
2
|
+
* The track flexes; the value and the unit keep their content width so
|
|
3
|
+
* the slider takes whatever the row can spare (the "left bigger part"
|
|
4
|
+
* layout the model editor specified). */
|
|
5
|
+
.hk-token-field {
|
|
6
|
+
display: flex;
|
|
7
|
+
align-items: center;
|
|
8
|
+
gap: var(--space-8, 8px);
|
|
9
|
+
min-width: 0;
|
|
10
|
+
padding: var(--space-4, 4px) var(--space-8, 8px);
|
|
11
|
+
border: 1px solid color-mix(in srgb, rgb(var(--color-text)) 14%, transparent);
|
|
12
|
+
border-radius: var(--radius-sm);
|
|
13
|
+
background: rgb(var(--color-surface));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.hk-token-field.is-disabled {
|
|
17
|
+
opacity: 0.55;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.hk-token-field-slider {
|
|
21
|
+
flex: 1 1 auto;
|
|
22
|
+
min-width: 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.hk-token-field-value {
|
|
26
|
+
flex: 0 0 auto;
|
|
27
|
+
min-width: 3.25rem;
|
|
28
|
+
padding: 2px var(--space-8, 8px);
|
|
29
|
+
border: none;
|
|
30
|
+
border-radius: var(--radius-sm);
|
|
31
|
+
background: rgb(var(--color-muted) / 12%);
|
|
32
|
+
color: rgb(var(--color-text));
|
|
33
|
+
font-size: 0.8125rem;
|
|
34
|
+
font-variant-numeric: tabular-nums;
|
|
35
|
+
text-align: center;
|
|
36
|
+
cursor: pointer;
|
|
37
|
+
font-family: inherit;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.hk-token-field-value:hover:not(:disabled) {
|
|
41
|
+
background: rgb(var(--color-muted) / 20%);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.hk-token-field-value:focus-visible {
|
|
45
|
+
outline: none;
|
|
46
|
+
box-shadow: 0 0 0 2px rgb(var(--color-primary) / 40%);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* An off-ladder (typed) value gets the primary tint — the thumb rests
|
|
50
|
+
* at the nearest stop, the exact budget is what stands. */
|
|
51
|
+
.hk-token-field[data-off-ladder] .hk-token-field-value {
|
|
52
|
+
background: rgb(var(--color-primary) / 14%);
|
|
53
|
+
color: var(--c-primary-strong, rgb(var(--color-primary)));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.hk-token-field-unit {
|
|
57
|
+
flex: 0 0 auto;
|
|
58
|
+
width: 4.25rem;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* The precision editor body: value input (major) + unit (minor). */
|
|
62
|
+
.hk-token-field-editor-body {
|
|
63
|
+
display: flex;
|
|
64
|
+
align-items: flex-start;
|
|
65
|
+
gap: var(--space-8, 8px);
|
|
66
|
+
padding: var(--space-8, 8px) 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.hk-token-editor {
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
gap: var(--space-8, 8px);
|
|
73
|
+
flex: 1 1 auto;
|
|
74
|
+
min-width: 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.hk-token-editor-input {
|
|
78
|
+
flex: 1 1 auto;
|
|
79
|
+
min-width: 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.hk-token-editor-unit {
|
|
83
|
+
flex: 0 0 auto;
|
|
84
|
+
width: 5rem;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.hk-token-field-clear {
|
|
88
|
+
flex: 0 0 auto;
|
|
89
|
+
display: inline-flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
justify-content: center;
|
|
92
|
+
margin-top: var(--space-4, 4px);
|
|
93
|
+
width: 26px;
|
|
94
|
+
height: 26px;
|
|
95
|
+
border: none;
|
|
96
|
+
border-radius: var(--radius-sm);
|
|
97
|
+
background: transparent;
|
|
98
|
+
color: rgb(var(--color-muted));
|
|
99
|
+
cursor: pointer;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.hk-token-field-clear:hover {
|
|
103
|
+
background: rgb(var(--color-error) / 10%);
|
|
104
|
+
color: rgb(var(--color-error));
|
|
105
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkTokenField, { compactTokens, DEFAULT_TOKEN_STOPS, HkTokenValueEditor } from "./HkTokenField";
|
|
5
|
+
import HkModalCtor from "./HkModal";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* HkTokenField contract tests (repo convention: raw createApp +
|
|
9
|
+
* document queries, no @vue/test-utils):
|
|
10
|
+
* - the doubling ladder drives the slider in INDEX space (drags commit
|
|
11
|
+
* the stop's token count)
|
|
12
|
+
* - an off-ladder value keeps the exact budget, resting the thumb at
|
|
13
|
+
* the nearest stop (data-off-ladder marks the value segment)
|
|
14
|
+
* - the value segment opens the precision editor (a DRAGGABLE desktop
|
|
15
|
+
* window through HkAdaptiveDialog) whose numeric input commits
|
|
16
|
+
* unit-scaled absolutes and whose clear unsets
|
|
17
|
+
* - the suffix unit dropdown switches the display unit; unit flips
|
|
18
|
+
* inside the editor re-express the same absolute value
|
|
19
|
+
* - an #editor slot replaces the built-in editor (the composition
|
|
20
|
+
* contract the model editor asked for)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
24
|
+
|
|
25
|
+
const setWidth = (w: number) => {
|
|
26
|
+
Object.defineProperty(window, "innerWidth", {
|
|
27
|
+
configurable: true,
|
|
28
|
+
writable: true,
|
|
29
|
+
value: w,
|
|
30
|
+
});
|
|
31
|
+
window.dispatchEvent(new Event("resize"));
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
interface FieldHarness {
|
|
35
|
+
container: HTMLElement;
|
|
36
|
+
value: ReturnType<typeof ref<number | null>>;
|
|
37
|
+
unit: ReturnType<typeof ref<string>>;
|
|
38
|
+
emitted: Array<number | null>;
|
|
39
|
+
unitEmitted: Array<string>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function mountField(
|
|
43
|
+
props: Record<string, unknown> = {},
|
|
44
|
+
slots: Record<string, unknown> = {},
|
|
45
|
+
): FieldHarness {
|
|
46
|
+
setWidth(1280);
|
|
47
|
+
const container = document.createElement("div");
|
|
48
|
+
document.body.appendChild(container);
|
|
49
|
+
// Seeded from props so the controlled flow round-trips: the ref
|
|
50
|
+
// feeds back whatever the field emits.
|
|
51
|
+
const value = ref<number | null>((props.modelValue as number | null) ?? null);
|
|
52
|
+
const unit = ref<string>("auto");
|
|
53
|
+
const emitted: Array<number | null> = [];
|
|
54
|
+
const unitEmitted: Array<string> = [];
|
|
55
|
+
const Wrapper = defineComponent({
|
|
56
|
+
setup() {
|
|
57
|
+
return () =>
|
|
58
|
+
h(
|
|
59
|
+
HkTokenField,
|
|
60
|
+
{
|
|
61
|
+
...props,
|
|
62
|
+
modelValue: value.value as number | null,
|
|
63
|
+
unit: unit.value as "auto" | "K" | "M" | "B",
|
|
64
|
+
"onUpdate:modelValue": (v: number | null) => {
|
|
65
|
+
value.value = v;
|
|
66
|
+
emitted.push(v);
|
|
67
|
+
},
|
|
68
|
+
"onUpdate:unit": (u: string) => {
|
|
69
|
+
unit.value = u;
|
|
70
|
+
unitEmitted.push(u);
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
slots.editor ? { editor: () => (slots.editor as () => unknown)() } : {},
|
|
74
|
+
);
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
const app = createApp(Wrapper);
|
|
78
|
+
app.mount(container);
|
|
79
|
+
mounts.push({ app, container });
|
|
80
|
+
return { container, value, unit, emitted, unitEmitted };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Pin a 288px-wide, left-0 rect so index-space drag math is exact
|
|
84
|
+
* (9 stops × 36px per rung). */
|
|
85
|
+
function pinRect(el: HTMLElement) {
|
|
86
|
+
Object.defineProperty(el, "getBoundingClientRect", {
|
|
87
|
+
configurable: true,
|
|
88
|
+
value: () =>
|
|
89
|
+
({ left: 0, width: 288, height: 18, top: 0, right: 288, bottom: 18, x: 0, y: 0 }) as DOMRect,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function pointer(type: string, clientX: number) {
|
|
94
|
+
return new PointerEvent(type, { bubbles: true, clientX, pointerId: 1, pointerType: "touch" });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function dragSlider(h: FieldHarness, clientX: number) {
|
|
98
|
+
const slider = h.container.querySelector(".hk-slider") as HTMLElement;
|
|
99
|
+
pinRect(slider);
|
|
100
|
+
slider.dispatchEvent(pointer("pointerdown", clientX));
|
|
101
|
+
window.dispatchEvent(pointer("pointerup", clientX));
|
|
102
|
+
await nextTick();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function fieldSelects(h: FieldHarness): HTMLButtonElement[] {
|
|
106
|
+
return [...h.container.querySelectorAll<HTMLButtonElement>(".hk-select-trigger")];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function pickOption(selectTrigger: HTMLButtonElement, label: string) {
|
|
110
|
+
selectTrigger.click();
|
|
111
|
+
await nextTick();
|
|
112
|
+
const popout = document.querySelector<HTMLElement>(".hk-select-popout");
|
|
113
|
+
expect(popout, "the select popout opened").toBeTruthy();
|
|
114
|
+
const row = [...popout!.querySelectorAll<HTMLElement>(".hk-select-option")].find(
|
|
115
|
+
(o) => o.textContent?.trim() === label,
|
|
116
|
+
);
|
|
117
|
+
expect(row, `the ${label} option`).toBeTruthy();
|
|
118
|
+
row!.click();
|
|
119
|
+
await nextTick();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
afterEach(() => {
|
|
123
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
124
|
+
app.unmount();
|
|
125
|
+
container.remove();
|
|
126
|
+
}
|
|
127
|
+
// Teleported popups (dialogs, popouts) unmount with their apps, but
|
|
128
|
+
// any stragglers must not leak into the next case's queries.
|
|
129
|
+
for (const el of [...document.querySelectorAll(".hk-modal-root, .hk-select-popout")]) {
|
|
130
|
+
el.remove();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("HkTokenField", () => {
|
|
135
|
+
it("renders the default ladder in index space and commits a drag's stop", async () => {
|
|
136
|
+
const h = mountField({ modelValue: 16_000 });
|
|
137
|
+
const slider = h.container.querySelector(".hk-slider") as HTMLElement;
|
|
138
|
+
expect(slider.getAttribute("aria-valuemin")).toBe("0");
|
|
139
|
+
expect(slider.getAttribute("aria-valuemax")).toBe(String(DEFAULT_TOKEN_STOPS.length - 1));
|
|
140
|
+
// Press at 144/288 = index 4 = 256K.
|
|
141
|
+
await dragSlider(h, 144);
|
|
142
|
+
expect(h.emitted.at(-1)).toBe(256_000);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("rests the thumb at the NEAREST stop for an off-ladder value, keeping the exact budget", () => {
|
|
146
|
+
// 300K sits between 256K (idx 4) and 512K (idx 5), nearer the former.
|
|
147
|
+
const h = mountField({ modelValue: 300_000 });
|
|
148
|
+
const slider = h.container.querySelector(".hk-slider") as HTMLElement;
|
|
149
|
+
expect(slider.getAttribute("aria-valuenow")).toBe("4");
|
|
150
|
+
expect(h.container.querySelector(".hk-token-field")!.getAttribute("data-off-ladder")).toBeTruthy();
|
|
151
|
+
expect(h.container.querySelector(".hk-token-field-value")!.textContent).toBe("300K");
|
|
152
|
+
// Nothing was emitted — the exact value stands until a drag.
|
|
153
|
+
expect(h.emitted).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("a drag to another rung replaces an off-ladder value with that stop", async () => {
|
|
157
|
+
const h = mountField({ modelValue: 300_000 });
|
|
158
|
+
// The thumb already rests at index 4 (256K); press index 5 (512K).
|
|
159
|
+
await dragSlider(h, 180);
|
|
160
|
+
expect(h.emitted.at(-1)).toBe(512_000);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("pressing the rung the thumb already rests on keeps the exact value", async () => {
|
|
164
|
+
const h = mountField({ modelValue: 300_000 });
|
|
165
|
+
await dragSlider(h, 144); // index 4 — already there
|
|
166
|
+
expect(h.emitted).toEqual([]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("opens a draggable precision editor from the value segment", async () => {
|
|
170
|
+
const h = mountField({ modelValue: 128_000, editorTitle: "Context" });
|
|
171
|
+
(h.container.querySelector(".hk-token-field-value") as HTMLButtonElement).click();
|
|
172
|
+
await nextTick();
|
|
173
|
+
const frame = document.querySelector<HTMLElement>(".hk-modal-content[data-draggable]");
|
|
174
|
+
expect(frame, "the draggable desktop window").toBeTruthy();
|
|
175
|
+
const input = frame!.querySelector("input") as HTMLInputElement;
|
|
176
|
+
// Seeded at the auto-compacted magnitude (K) — 128.
|
|
177
|
+
expect(input.value).toBe("128");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("the editor commits unit-scaled absolutes live", async () => {
|
|
181
|
+
const h = mountField({ modelValue: 1_500_000 });
|
|
182
|
+
(h.container.querySelector(".hk-token-field-value") as HTMLButtonElement).click();
|
|
183
|
+
await nextTick();
|
|
184
|
+
const input = document.querySelector(".hk-modal-content input") as HTMLInputElement;
|
|
185
|
+
expect(input.value).toBe("1.5");
|
|
186
|
+
input.value = "2";
|
|
187
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
188
|
+
await nextTick();
|
|
189
|
+
expect(h.emitted.at(-1)).toBe(2_000_000);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("clearing in the editor unsets the value", async () => {
|
|
193
|
+
const h = mountField({ modelValue: 128_000 });
|
|
194
|
+
(h.container.querySelector(".hk-token-field-value") as HTMLButtonElement).click();
|
|
195
|
+
await nextTick();
|
|
196
|
+
(document.querySelector(".hk-token-field-clear") as HTMLButtonElement).click();
|
|
197
|
+
await nextTick();
|
|
198
|
+
expect(h.emitted.at(-1)).toBe(null);
|
|
199
|
+
// The window folds out through its leave transition.
|
|
200
|
+
await vi.waitFor(() => {
|
|
201
|
+
expect(document.querySelector(".hk-modal-content")).toBeNull();
|
|
202
|
+
}, { timeout: 1500 });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("the suffix dropdown switches the display unit", async () => {
|
|
206
|
+
const h = mountField({ modelValue: 2_000_000 });
|
|
207
|
+
expect(h.container.querySelector(".hk-token-field-value")!.textContent).toBe("2M");
|
|
208
|
+
// Fixed K re-expresses the SAME budget (not the auto form).
|
|
209
|
+
await pickOption(fieldSelects(h)[0]!, "K");
|
|
210
|
+
expect(h.unitEmitted.at(-1)).toBe("K");
|
|
211
|
+
await nextTick();
|
|
212
|
+
expect(h.container.querySelector(".hk-token-field-value")!.textContent).toBe("2000K");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("unit flips inside the editor re-express, never rescale, the budget", async () => {
|
|
216
|
+
const h = mountField({ modelValue: 1_500_000 });
|
|
217
|
+
(h.container.querySelector(".hk-token-field-value") as HTMLButtonElement).click();
|
|
218
|
+
await nextTick();
|
|
219
|
+
const editorSelect = document.querySelector<HTMLButtonElement>(
|
|
220
|
+
".hk-modal-content .hk-token-editor .hk-select-trigger",
|
|
221
|
+
)!;
|
|
222
|
+
const input = document.querySelector<HTMLInputElement>(".hk-modal-content input")!;
|
|
223
|
+
expect(input.value).toBe("1.5");
|
|
224
|
+
await pickOption(editorSelect, "K");
|
|
225
|
+
expect(input.value).toBe("1500");
|
|
226
|
+
expect(h.emitted).toEqual([]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("an #editor slot replaces the built-in editor", async () => {
|
|
230
|
+
// NB: the harness is `f` here — `h` stays Vue's hyperscript.
|
|
231
|
+
const f = mountField(
|
|
232
|
+
{ modelValue: 64_000, editorTitle: "Edit" },
|
|
233
|
+
{ editor: () => h("button", { class: "custom-editor" }, "custom") },
|
|
234
|
+
);
|
|
235
|
+
(f.container.querySelector(".hk-token-field-value") as HTMLButtonElement).click();
|
|
236
|
+
await nextTick();
|
|
237
|
+
expect(document.querySelector(".custom-editor")).toBeTruthy();
|
|
238
|
+
expect(document.querySelector(".hk-token-editor")).toBeNull();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("disables every part together", () => {
|
|
242
|
+
const h = mountField({ modelValue: 16_000, disabled: true });
|
|
243
|
+
expect((h.container.querySelector(".hk-slider") as HTMLElement).classList.contains("is-disabled")).toBe(true);
|
|
244
|
+
expect((h.container.querySelector(".hk-token-field-value") as HTMLButtonElement).disabled).toBe(true);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("HkTokenValueEditor (standalone)", () => {
|
|
249
|
+
it("seeds in the entry unit and commits scaled absolutes", async () => {
|
|
250
|
+
setWidth(1280);
|
|
251
|
+
const container = document.createElement("div");
|
|
252
|
+
document.body.appendChild(container);
|
|
253
|
+
const seen: Array<number | null> = [];
|
|
254
|
+
const app = createApp({
|
|
255
|
+
setup: () => () =>
|
|
256
|
+
h(HkTokenValueEditor, {
|
|
257
|
+
modelValue: 128_000,
|
|
258
|
+
unit: "K",
|
|
259
|
+
"onUpdate:modelValue": (v: number | null) => seen.push(v),
|
|
260
|
+
}),
|
|
261
|
+
});
|
|
262
|
+
app.mount(container);
|
|
263
|
+
mounts.push({ app, container });
|
|
264
|
+
const input = container.querySelector("input") as HTMLInputElement;
|
|
265
|
+
expect(input.value).toBe("128");
|
|
266
|
+
input.value = "64";
|
|
267
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
268
|
+
await nextTick();
|
|
269
|
+
expect(seen.at(-1)).toBe(64_000);
|
|
270
|
+
// Empty unsets rather than zeroing.
|
|
271
|
+
input.value = "";
|
|
272
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
273
|
+
await nextTick();
|
|
274
|
+
expect(seen.at(-1)).toBe(null);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("compactTokens", () => {
|
|
279
|
+
it("compacts decimal magnitudes", () => {
|
|
280
|
+
expect(compactTokens(750)).toBe("750");
|
|
281
|
+
expect(compactTokens(16_000)).toBe("16K");
|
|
282
|
+
expect(compactTokens(128_000)).toBe("128K");
|
|
283
|
+
expect(compactTokens(1_500_000)).toBe("1.5M");
|
|
284
|
+
expect(compactTokens(3_000_000_000)).toBe("3B");
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe("HkTokenField — typing survives round-trips (R1 P2 pins)", () => {
|
|
289
|
+
it("select-all + typing 0 over 128K keeps the keystroke (no transient null)", async () => {
|
|
290
|
+
const h = mountField({ modelValue: 128_000 });
|
|
291
|
+
(h.container.querySelector(".hk-token-field-value") as HTMLButtonElement).click();
|
|
292
|
+
await nextTick();
|
|
293
|
+
const input = document.querySelector<HTMLInputElement>(".hk-modal-content input")!;
|
|
294
|
+
input.value = "0";
|
|
295
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
296
|
+
await nextTick();
|
|
297
|
+
// Nothing committed (0 is not a valid budget) but the TEXT stays.
|
|
298
|
+
expect(input.value).toBe("0");
|
|
299
|
+
expect(h.emitted.filter((v) => v === null)).toEqual([]);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("a transiently unparseable intermediate (1e) commits nothing", async () => {
|
|
303
|
+
const h = mountField({ modelValue: 128_000 });
|
|
304
|
+
(h.container.querySelector(".hk-token-field-value") as HTMLButtonElement).click();
|
|
305
|
+
await nextTick();
|
|
306
|
+
const input = document.querySelector<HTMLInputElement>(".hk-modal-content input")!;
|
|
307
|
+
input.value = "1e";
|
|
308
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
309
|
+
await nextTick();
|
|
310
|
+
expect(input.value).toBe("1e");
|
|
311
|
+
expect(h.emitted.filter((v) => v === null)).toEqual([]);
|
|
312
|
+
// Completing the exponent commits the parsed absolute (K-seeded:
|
|
313
|
+
// 1e3 = 1000 in-unit = 1,000,000 tokens).
|
|
314
|
+
input.value = "1e3";
|
|
315
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
316
|
+
await nextTick();
|
|
317
|
+
expect(h.emitted.at(-1)).toBe(1_000_000);
|
|
318
|
+
// The echo round-trip must NOT clobber non-canonical text: the
|
|
319
|
+
// field still reads "1e3" (the echo guard holds).
|
|
320
|
+
expect(input.value).toBe("1e3");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("a unit flip with unparseable text clears rather than skewing the scale", async () => {
|
|
324
|
+
const h = mountField({ modelValue: 128_000 });
|
|
325
|
+
(h.container.querySelector(".hk-token-field-value") as HTMLButtonElement).click();
|
|
326
|
+
await nextTick();
|
|
327
|
+
const input = document.querySelector<HTMLInputElement>(".hk-modal-content input")!;
|
|
328
|
+
const editorSelect = document.querySelector<HTMLButtonElement>(
|
|
329
|
+
".hk-modal-content .hk-token-editor .hk-select-trigger",
|
|
330
|
+
)!;
|
|
331
|
+
input.value = "1e";
|
|
332
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
333
|
+
await nextTick();
|
|
334
|
+
await pickOption(editorSelect, "M");
|
|
335
|
+
expect(input.value).toBe("");
|
|
336
|
+
expect(h.emitted.filter((v) => v === null)).toEqual([null]);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe("HkModal draggable (R1 pins)", () => {
|
|
341
|
+
async function mountDraggableModal() {
|
|
342
|
+
setWidth(1280);
|
|
343
|
+
const container = document.createElement("div");
|
|
344
|
+
document.body.appendChild(container);
|
|
345
|
+
const open = ref(true);
|
|
346
|
+
const HkModal = HkModalCtor;
|
|
347
|
+
const app = createApp({
|
|
348
|
+
setup: () => () =>
|
|
349
|
+
h(
|
|
350
|
+
HkModal,
|
|
351
|
+
{
|
|
352
|
+
modelValue: open.value,
|
|
353
|
+
title: "T",
|
|
354
|
+
draggable: true,
|
|
355
|
+
"onUpdate:modelValue": (v: boolean) => (open.value = v),
|
|
356
|
+
},
|
|
357
|
+
{ default: () => "body" },
|
|
358
|
+
),
|
|
359
|
+
});
|
|
360
|
+
app.mount(container);
|
|
361
|
+
mounts.push({ app, container });
|
|
362
|
+
await nextTick();
|
|
363
|
+
return { container, open };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
it("a close mid-drag never ghosts into the next open", async () => {
|
|
367
|
+
const { container, open } = await mountDraggableModal();
|
|
368
|
+
const frame = document.querySelector<HTMLElement>(".hk-modal-content")!;
|
|
369
|
+
const header = frame.querySelector<HTMLElement>(".hk-modal-header")!;
|
|
370
|
+
const d = (t: string, x: number, opts: Partial<PointerEventInit> = {}) =>
|
|
371
|
+
new PointerEvent(t, { bubbles: true, clientX: x, pointerId: 7, buttons: 1, ...opts });
|
|
372
|
+
// Press, then close while captured (no pointerup ever fires).
|
|
373
|
+
header.dispatchEvent(d("pointerdown", 100));
|
|
374
|
+
open.value = false;
|
|
375
|
+
await nextTick();
|
|
376
|
+
open.value = true;
|
|
377
|
+
await nextTick();
|
|
378
|
+
const frame2 = document.querySelector<HTMLElement>(".hk-modal-content")!;
|
|
379
|
+
const header2 = frame2.querySelector<HTMLElement>(".hk-modal-header")!;
|
|
380
|
+
// A button-held move WITHOUT a fresh press must be inert.
|
|
381
|
+
header2.dispatchEvent(d("pointermove", 300));
|
|
382
|
+
expect(frame2.style.getPropertyValue("--hk-modal-shift-x")).toBe("");
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("drags via pointer and writes the shift vars; hover alone never moves the frame", async () => {
|
|
386
|
+
const { container } = await mountDraggableModal();
|
|
387
|
+
const frame = document.querySelector<HTMLElement>(".hk-modal-content")!;
|
|
388
|
+
const header = frame.querySelector<HTMLElement>(".hk-modal-header")!;
|
|
389
|
+
expect(frame.getAttribute("data-draggable")).toBeTruthy();
|
|
390
|
+
const d = (t: string, x: number, opts: Partial<PointerEventInit> = {}) =>
|
|
391
|
+
new PointerEvent(t, { bubbles: true, clientX: x, pointerId: 7, buttons: 1, ...opts });
|
|
392
|
+
header.dispatchEvent(d("pointerdown", 100));
|
|
393
|
+
// With the header holding pointer capture, moves retarget to the
|
|
394
|
+
// header — even far outside its box.
|
|
395
|
+
header.dispatchEvent(d("pointermove", 260));
|
|
396
|
+
expect(frame.style.getPropertyValue("--hk-modal-shift-x")).toBe("160px");
|
|
397
|
+
// A subsequent press whose moves carry no button (pure hover)
|
|
398
|
+
// never moves the frame.
|
|
399
|
+
header.dispatchEvent(d("pointerdown", 100));
|
|
400
|
+
header.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: 400, pointerId: 7, buttons: 0 }));
|
|
401
|
+
expect(frame.style.getPropertyValue("--hk-modal-shift-x")).toBe("160px");
|
|
402
|
+
});
|
|
403
|
+
});
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { computed, defineComponent, ref, watch, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import { useI18n } from "../i18n/context";
|
|
4
|
+
|
|
5
|
+
import HkAdaptiveDialog from "./HkAdaptiveDialog";
|
|
6
|
+
import HkInput from "./HkInput";
|
|
7
|
+
import HkSelect from "./HkSelect";
|
|
8
|
+
import HkSlider from "./HkSlider";
|
|
9
|
+
import { X } from "lucide-vue-next";
|
|
10
|
+
|
|
11
|
+
import "./HkTokenField.scss";
|
|
12
|
+
|
|
13
|
+
/** The ladder the field snaps to: each stop doubles the previous one,
|
|
14
|
+
* 16K → 4M (the curation the model editor asked for — token budgets
|
|
15
|
+
* cluster on powers of two). Hosts may pass their own. */
|
|
16
|
+
export const DEFAULT_TOKEN_STOPS = [
|
|
17
|
+
16_000, 32_000, 64_000, 128_000, 256_000, 512_000, 1_000_000, 2_000_000, 4_000_000,
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export type TokenUnit = "K" | "M" | "B";
|
|
21
|
+
|
|
22
|
+
const UNIT_SCALE: Record<TokenUnit, number> = { K: 1e3, M: 1e6, B: 1e9 };
|
|
23
|
+
|
|
24
|
+
/** Decimal token compaction — 1.5M, 128K, 750 (plain under 1K). */
|
|
25
|
+
export function compactTokens(value: number): string {
|
|
26
|
+
if (!Number.isFinite(value)) return "";
|
|
27
|
+
if (value >= 1e9) return `${trim(value / 1e9)}B`;
|
|
28
|
+
if (value >= 1e6) return `${trim(value / 1e6)}M`;
|
|
29
|
+
if (value >= 1e3) return `${trim(value / 1e3)}K`;
|
|
30
|
+
return String(Math.round(value));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function trim(n: number): string {
|
|
34
|
+
const r = Math.round(n * 100) / 100;
|
|
35
|
+
return String(r);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The default precision editor body: a big numeric input (left) plus
|
|
39
|
+
* a unit picker (right) — both live-commit. Exposed for hosts that
|
|
40
|
+
* mount it inside their own `#editor` slot chrome. */
|
|
41
|
+
export const HkTokenValueEditor = defineComponent({
|
|
42
|
+
name: "HkTokenValueEditor",
|
|
43
|
+
props: {
|
|
44
|
+
/** Absolute token count; null = unset. */
|
|
45
|
+
modelValue: { type: Number as () => number | null, default: null },
|
|
46
|
+
/** Entry unit for the numeric input (K = the input reads
|
|
47
|
+
* thousands). Hosts may preselect; defaults from the value. */
|
|
48
|
+
unit: { type: String as () => TokenUnit, default: "K" },
|
|
49
|
+
},
|
|
50
|
+
emits: {
|
|
51
|
+
"update:modelValue": (_value: number | null) => true,
|
|
52
|
+
"update:unit": (_unit: TokenUnit) => true,
|
|
53
|
+
},
|
|
54
|
+
setup(props, { emit }) {
|
|
55
|
+
/** The numeric text in the CURRENT unit; rescaled when the unit
|
|
56
|
+
* flips so the absolute value never moves underneath. */
|
|
57
|
+
const text = ref("");
|
|
58
|
+
/** The absolute we last emitted — a round-tripped prop equal to it
|
|
59
|
+
* is OUR OWN commit echoing back, not an external change: skip
|
|
60
|
+
* the rescale or typing gets clobbered mid-edit (select-all + "0"
|
|
61
|
+
* used to swallow the keystroke; "1e" cleared the field). */
|
|
62
|
+
let lastEmitted: number | null = null;
|
|
63
|
+
|
|
64
|
+
function rescale(value: number | null, unit: TokenUnit) {
|
|
65
|
+
if (value == null || !Number.isFinite(value) || value <= 0) {
|
|
66
|
+
text.value = "";
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const scaled = value / UNIT_SCALE[unit];
|
|
70
|
+
text.value = String(Math.round(scaled * 100) / 100);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
watch(
|
|
74
|
+
() => [props.modelValue, props.unit] as const,
|
|
75
|
+
([value, unit], prev) => {
|
|
76
|
+
// The immediate first run has no previous pair — seed.
|
|
77
|
+
if (!prev) {
|
|
78
|
+
rescale(value, unit);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const [prevValue, prevUnit] = prev;
|
|
82
|
+
if (value !== prevValue && value === lastEmitted) return;
|
|
83
|
+
if (value === prevValue && unit !== prevUnit) {
|
|
84
|
+
// A pure unit flip (the host round-tripped update:unit):
|
|
85
|
+
// re-express our own text — switchUnit already did.
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// An external change: whatever we last emitted is stale as an
|
|
89
|
+
// echo marker (a later external set back to that same value
|
|
90
|
+
// must still rescale, not be skipped).
|
|
91
|
+
lastEmitted = null;
|
|
92
|
+
rescale(value, unit);
|
|
93
|
+
},
|
|
94
|
+
{ immediate: true },
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
function commit() {
|
|
98
|
+
const raw = text.value.trim();
|
|
99
|
+
// Only a deliberately emptied field unsets. A transiently
|
|
100
|
+
// unparseable intermediate ("1e", "-", "") mid-typing commits
|
|
101
|
+
// NOTHING — live consumers never see a flicker of null.
|
|
102
|
+
if (!raw) {
|
|
103
|
+
lastEmitted = null;
|
|
104
|
+
emit("update:modelValue", null);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const n = Number(raw.replace(/[,_\s]/g, ""));
|
|
108
|
+
if (!Number.isFinite(n) || n <= 0) return;
|
|
109
|
+
lastEmitted = Math.round(n * UNIT_SCALE[props.unit]);
|
|
110
|
+
emit("update:modelValue", lastEmitted);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const unitOptions = computed(() => [
|
|
114
|
+
{ value: "K", label: "K" },
|
|
115
|
+
{ value: "M", label: "M" },
|
|
116
|
+
{ value: "B", label: "B" },
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
function switchUnit(next: string) {
|
|
120
|
+
const unit = next as TokenUnit;
|
|
121
|
+
if (unit === props.unit) return;
|
|
122
|
+
// Re-express the SAME absolute value in the new unit first, so a
|
|
123
|
+
// flip never multiplies the budget by 1000. Transiently
|
|
124
|
+
// unparseable text cannot be re-expressed — clearing it is the
|
|
125
|
+
// honest move (a stale number under a new scale would commit a
|
|
126
|
+
// 1000x jump on the next keystroke).
|
|
127
|
+
const n = Number(text.value.replace(/[,_\s]/g, ""));
|
|
128
|
+
if (Number.isFinite(n) && n > 0) {
|
|
129
|
+
const absolute = n * UNIT_SCALE[props.unit];
|
|
130
|
+
text.value = String(Math.round((absolute / UNIT_SCALE[unit]) * 100) / 100);
|
|
131
|
+
} else if (text.value.trim()) {
|
|
132
|
+
text.value = "";
|
|
133
|
+
lastEmitted = null;
|
|
134
|
+
emit("update:modelValue", null);
|
|
135
|
+
}
|
|
136
|
+
emit("update:unit", unit);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return () => (
|
|
140
|
+
<div class="hk-token-editor">
|
|
141
|
+
<HkInput
|
|
142
|
+
class="hk-token-editor-input"
|
|
143
|
+
modelValue={text.value}
|
|
144
|
+
onUpdate:modelValue={(v: string) => {
|
|
145
|
+
text.value = v;
|
|
146
|
+
commit();
|
|
147
|
+
}}
|
|
148
|
+
/>
|
|
149
|
+
<HkSelect
|
|
150
|
+
class="hk-token-editor-unit"
|
|
151
|
+
modelValue={props.unit}
|
|
152
|
+
options={unitOptions.value}
|
|
153
|
+
onUpdate:modelValue={switchUnit}
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* HkTokenField — one integrated box for token-scale budgets: the left
|
|
162
|
+
* (major) part is a stepped slider over a doubling ladder (16K → 4M by
|
|
163
|
+
* default); the right part is the exact value, clickable to open a
|
|
164
|
+
* precision editor (a draggable desktop window / a slide-up mobile
|
|
165
|
+
* sheet via HkAdaptiveDialog) where any value may be typed and the
|
|
166
|
+
* unit switched (K/M/B). The box's unit suffix is itself a dropdown —
|
|
167
|
+
* the same K/M/B choice, switching the display and the editor's entry
|
|
168
|
+
* unit. Values off the ladder survive: the slider thumb rests at the
|
|
169
|
+
* nearest stop while the exact value stands until the user drags.
|
|
170
|
+
*
|
|
171
|
+
* Composition: hosts may replace the built-in editor entirely through
|
|
172
|
+
* the `#editor` slot (the two-business-component strategy the model
|
|
173
|
+
* editor asked for) — the slot receives `{ value, unit, close }`.
|
|
174
|
+
*/
|
|
175
|
+
export default defineComponent({
|
|
176
|
+
name: "HkTokenField",
|
|
177
|
+
props: {
|
|
178
|
+
/** Absolute token count; null = unset (nothing chosen yet). */
|
|
179
|
+
modelValue: { type: Number as () => number | null, default: null },
|
|
180
|
+
/** Ascending stop ladder; the slider works in index space so the
|
|
181
|
+
* geometric rungs sit evenly. */
|
|
182
|
+
stops: {
|
|
183
|
+
type: Array as () => number[],
|
|
184
|
+
default: () => [...DEFAULT_TOKEN_STOPS],
|
|
185
|
+
},
|
|
186
|
+
/** Display/entry unit; "auto" compacts (128K, 1.5M). */
|
|
187
|
+
unit: { type: String as () => "auto" | TokenUnit, default: "auto" },
|
|
188
|
+
disabled: { type: Boolean, default: false },
|
|
189
|
+
ariaLabel: { type: String, default: undefined },
|
|
190
|
+
/** The precision editor's title. */
|
|
191
|
+
editorTitle: { type: String, default: undefined },
|
|
192
|
+
},
|
|
193
|
+
emits: {
|
|
194
|
+
"update:modelValue": (_value: number | null) => true,
|
|
195
|
+
"update:unit": (_unit: "auto" | TokenUnit) => true,
|
|
196
|
+
},
|
|
197
|
+
setup(props, { emit, slots }) {
|
|
198
|
+
const { t } = useI18n();
|
|
199
|
+
const editorOpen = ref(false);
|
|
200
|
+
/** The unit the EDITOR works in — seeded from the display unit
|
|
201
|
+
* (auto compacts to the value's natural magnitude). */
|
|
202
|
+
const editorUnit = ref<TokenUnit>("K");
|
|
203
|
+
/** The popup contract wants every window named; hosts that don't
|
|
204
|
+
* care still get the stock string (never the dev warning). */
|
|
205
|
+
const resolvedEditorTitle = computed(
|
|
206
|
+
() => props.editorTitle ?? t("hikari::tokenField.editValue", "Edit value"),
|
|
207
|
+
);
|
|
208
|
+
const clearLabel = computed(() => t("hikari::tokenField.clear", "Clear"));
|
|
209
|
+
|
|
210
|
+
const stops = computed(() =>
|
|
211
|
+
[...props.stops].filter((n) => Number.isFinite(n) && n > 0).sort((a, b) => a - b),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
/** Index of the nearest stop (ties → lower). */
|
|
215
|
+
const stopIndex = computed(() => {
|
|
216
|
+
const list = stops.value;
|
|
217
|
+
if (!list.length) return 0;
|
|
218
|
+
const v = props.modelValue;
|
|
219
|
+
if (v == null) return 0;
|
|
220
|
+
let best = 0;
|
|
221
|
+
let bestDist = Infinity;
|
|
222
|
+
for (let i = 0; i < list.length; i++) {
|
|
223
|
+
const d = Math.abs(list[i]! - v);
|
|
224
|
+
if (d < bestDist) {
|
|
225
|
+
bestDist = d;
|
|
226
|
+
best = i;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return best;
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const offLadder = computed(() => {
|
|
233
|
+
const v = props.modelValue;
|
|
234
|
+
return v != null && !stops.value.includes(v);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
function display(value: number | null): string {
|
|
238
|
+
if (value == null) return "—";
|
|
239
|
+
if (props.unit !== "auto") {
|
|
240
|
+
const scaled = value / UNIT_SCALE[props.unit];
|
|
241
|
+
return `${trim(scaled)}${props.unit}`;
|
|
242
|
+
}
|
|
243
|
+
return compactTokens(value);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function pickStop(i: number) {
|
|
247
|
+
const list = stops.value;
|
|
248
|
+
if (list[i] != null) emit("update:modelValue", list[i]!);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function seedEditorUnit() {
|
|
252
|
+
if (props.unit !== "auto") {
|
|
253
|
+
editorUnit.value = props.unit;
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const v = props.modelValue;
|
|
257
|
+
editorUnit.value = v == null ? "K" : v >= 1e9 ? "B" : v >= 1e6 ? "M" : "K";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function openEditor() {
|
|
261
|
+
if (props.disabled) return;
|
|
262
|
+
seedEditorUnit();
|
|
263
|
+
editorOpen.value = true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const unitOptions = computed(() => [
|
|
267
|
+
{ value: "auto", label: "auto" },
|
|
268
|
+
{ value: "K", label: "K" },
|
|
269
|
+
{ value: "M", label: "M" },
|
|
270
|
+
{ value: "B", label: "B" },
|
|
271
|
+
]);
|
|
272
|
+
|
|
273
|
+
function switchSuffixUnit(next: string) {
|
|
274
|
+
emit("update:unit", next as "auto" | TokenUnit);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function clearValue() {
|
|
278
|
+
emit("update:modelValue", null);
|
|
279
|
+
editorOpen.value = false;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return () => (
|
|
283
|
+
<div
|
|
284
|
+
class={["hk-token-field", props.disabled && "is-disabled"].filter(Boolean).join(" ")}
|
|
285
|
+
role="group"
|
|
286
|
+
aria-label={props.ariaLabel}
|
|
287
|
+
data-off-ladder={offLadder.value || undefined}
|
|
288
|
+
>
|
|
289
|
+
<HkSlider
|
|
290
|
+
class="hk-token-field-slider"
|
|
291
|
+
modelValue={stopIndex.value}
|
|
292
|
+
min={0}
|
|
293
|
+
max={Math.max(stops.value.length - 1, 0)}
|
|
294
|
+
step={1}
|
|
295
|
+
showTicks={stops.value.length > 1}
|
|
296
|
+
disabled={props.disabled}
|
|
297
|
+
ariaLabel={props.ariaLabel}
|
|
298
|
+
formatValue={(i: number) => display(stops.value[i] ?? null)}
|
|
299
|
+
onUpdate:modelValue={pickStop}
|
|
300
|
+
/>
|
|
301
|
+
<button
|
|
302
|
+
type="button"
|
|
303
|
+
class="hk-token-field-value"
|
|
304
|
+
disabled={props.disabled}
|
|
305
|
+
aria-haspopup="dialog"
|
|
306
|
+
aria-label={`${resolvedEditorTitle.value}: ${display(props.modelValue)}`}
|
|
307
|
+
onClick={openEditor}
|
|
308
|
+
title={resolvedEditorTitle.value}
|
|
309
|
+
>
|
|
310
|
+
{display(props.modelValue)}
|
|
311
|
+
</button>
|
|
312
|
+
<HkSelect
|
|
313
|
+
class="hk-token-field-unit"
|
|
314
|
+
modelValue={props.unit}
|
|
315
|
+
options={unitOptions.value}
|
|
316
|
+
disabled={props.disabled}
|
|
317
|
+
onUpdate:modelValue={switchSuffixUnit}
|
|
318
|
+
/>
|
|
319
|
+
<HkAdaptiveDialog
|
|
320
|
+
modelValue={editorOpen.value}
|
|
321
|
+
onUpdate:modelValue={(v: boolean) => (editorOpen.value = v)}
|
|
322
|
+
title={resolvedEditorTitle.value}
|
|
323
|
+
width="22rem"
|
|
324
|
+
draggable
|
|
325
|
+
>
|
|
326
|
+
{{
|
|
327
|
+
default: () =>
|
|
328
|
+
slots.editor
|
|
329
|
+
? slots.editor({
|
|
330
|
+
value: props.modelValue,
|
|
331
|
+
unit: editorUnit.value,
|
|
332
|
+
close: () => (editorOpen.value = false),
|
|
333
|
+
})
|
|
334
|
+
: (
|
|
335
|
+
<div class="hk-token-field-editor-body">
|
|
336
|
+
<HkTokenValueEditor
|
|
337
|
+
modelValue={props.modelValue}
|
|
338
|
+
unit={editorUnit.value}
|
|
339
|
+
onUpdate:modelValue={(v: number | null) => emit("update:modelValue", v)}
|
|
340
|
+
onUpdate:unit={(u: TokenUnit) => (editorUnit.value = u)}
|
|
341
|
+
/>
|
|
342
|
+
<button
|
|
343
|
+
type="button"
|
|
344
|
+
class="hk-token-field-clear"
|
|
345
|
+
aria-label={clearLabel.value}
|
|
346
|
+
onClick={clearValue}
|
|
347
|
+
>
|
|
348
|
+
<X size={13} />
|
|
349
|
+
</button>
|
|
350
|
+
</div>
|
|
351
|
+
),
|
|
352
|
+
}}
|
|
353
|
+
</HkAdaptiveDialog>
|
|
354
|
+
</div>
|
|
355
|
+
);
|
|
356
|
+
},
|
|
357
|
+
});
|
|
@@ -272,5 +272,7 @@
|
|
|
272
272
|
"hikari::userPicker.add": "اختيار المستخدمين…",
|
|
273
273
|
"hikari::userPicker.remove": "إزالة",
|
|
274
274
|
"hikari::userPicker.confirm": "تأكيد",
|
|
275
|
-
"hikari::userPicker.selectedCount": "تم اختيار {count}"
|
|
275
|
+
"hikari::userPicker.selectedCount": "تم اختيار {count}",
|
|
276
|
+
"hikari::tokenField.editValue": "تحرير القيمة",
|
|
277
|
+
"hikari::tokenField.clear": "مسح"
|
|
276
278
|
}
|
|
@@ -272,5 +272,7 @@
|
|
|
272
272
|
"hikari::userPicker.add": "Benutzer auswählen…",
|
|
273
273
|
"hikari::userPicker.remove": "Entfernen",
|
|
274
274
|
"hikari::userPicker.confirm": "Bestätigen",
|
|
275
|
-
"hikari::userPicker.selectedCount": "{count} ausgewählt"
|
|
275
|
+
"hikari::userPicker.selectedCount": "{count} ausgewählt",
|
|
276
|
+
"hikari::tokenField.editValue": "Wert bearbeiten",
|
|
277
|
+
"hikari::tokenField.clear": "Leeren"
|
|
276
278
|
}
|
|
@@ -272,5 +272,7 @@
|
|
|
272
272
|
"hikari::userPicker.add": "Select users…",
|
|
273
273
|
"hikari::userPicker.remove": "Remove",
|
|
274
274
|
"hikari::userPicker.confirm": "Confirm",
|
|
275
|
-
"hikari::userPicker.selectedCount": "{count} selected"
|
|
275
|
+
"hikari::userPicker.selectedCount": "{count} selected",
|
|
276
|
+
"hikari::tokenField.editValue": "Edit value",
|
|
277
|
+
"hikari::tokenField.clear": "Clear"
|
|
276
278
|
}
|
|
@@ -272,5 +272,7 @@
|
|
|
272
272
|
"hikari::userPicker.add": "Seleccionar usuarios…",
|
|
273
273
|
"hikari::userPicker.remove": "Quitar",
|
|
274
274
|
"hikari::userPicker.confirm": "Confirmar",
|
|
275
|
-
"hikari::userPicker.selectedCount": "{count} seleccionados"
|
|
275
|
+
"hikari::userPicker.selectedCount": "{count} seleccionados",
|
|
276
|
+
"hikari::tokenField.editValue": "Editar el valor",
|
|
277
|
+
"hikari::tokenField.clear": "Borrar"
|
|
276
278
|
}
|
|
@@ -272,5 +272,7 @@
|
|
|
272
272
|
"hikari::userPicker.add": "Sélectionner des utilisateurs…",
|
|
273
273
|
"hikari::userPicker.remove": "Retirer",
|
|
274
274
|
"hikari::userPicker.confirm": "Confirmer",
|
|
275
|
-
"hikari::userPicker.selectedCount": "{count} sélectionnés"
|
|
275
|
+
"hikari::userPicker.selectedCount": "{count} sélectionnés",
|
|
276
|
+
"hikari::tokenField.editValue": "Modifier la valeur",
|
|
277
|
+
"hikari::tokenField.clear": "Effacer"
|
|
276
278
|
}
|
|
@@ -272,5 +272,7 @@
|
|
|
272
272
|
"hikari::userPicker.add": "ユーザーを選択…",
|
|
273
273
|
"hikari::userPicker.remove": "削除",
|
|
274
274
|
"hikari::userPicker.confirm": "確認",
|
|
275
|
-
"hikari::userPicker.selectedCount": "{count} 件選択中"
|
|
275
|
+
"hikari::userPicker.selectedCount": "{count} 件選択中",
|
|
276
|
+
"hikari::tokenField.editValue": "値を編集",
|
|
277
|
+
"hikari::tokenField.clear": "クリア"
|
|
276
278
|
}
|
|
@@ -272,5 +272,7 @@
|
|
|
272
272
|
"hikari::userPicker.add": "사용자 선택…",
|
|
273
273
|
"hikari::userPicker.remove": "제거",
|
|
274
274
|
"hikari::userPicker.confirm": "확인",
|
|
275
|
-
"hikari::userPicker.selectedCount": "{count}명 선택됨"
|
|
275
|
+
"hikari::userPicker.selectedCount": "{count}명 선택됨",
|
|
276
|
+
"hikari::tokenField.editValue": "값 편집",
|
|
277
|
+
"hikari::tokenField.clear": "지우기"
|
|
276
278
|
}
|
|
@@ -272,5 +272,7 @@
|
|
|
272
272
|
"hikari::userPicker.add": "Selecionar usuários…",
|
|
273
273
|
"hikari::userPicker.remove": "Remover",
|
|
274
274
|
"hikari::userPicker.confirm": "Confirmar",
|
|
275
|
-
"hikari::userPicker.selectedCount": "{count} selecionados"
|
|
275
|
+
"hikari::userPicker.selectedCount": "{count} selecionados",
|
|
276
|
+
"hikari::tokenField.editValue": "Editar o valor",
|
|
277
|
+
"hikari::tokenField.clear": "Limpar"
|
|
276
278
|
}
|
|
@@ -272,5 +272,7 @@
|
|
|
272
272
|
"hikari::userPicker.add": "Выбрать пользователей…",
|
|
273
273
|
"hikari::userPicker.remove": "Убрать",
|
|
274
274
|
"hikari::userPicker.confirm": "Подтвердить",
|
|
275
|
-
"hikari::userPicker.selectedCount": "Выбрано: {count}"
|
|
275
|
+
"hikari::userPicker.selectedCount": "Выбрано: {count}",
|
|
276
|
+
"hikari::tokenField.editValue": "Изменить значение",
|
|
277
|
+
"hikari::tokenField.clear": "Очистить"
|
|
276
278
|
}
|
|
@@ -276,5 +276,7 @@
|
|
|
276
276
|
"hikari::userPicker.add": "选择用户…",
|
|
277
277
|
"hikari::userPicker.remove": "移除",
|
|
278
278
|
"hikari::userPicker.confirm": "确认",
|
|
279
|
-
"hikari::userPicker.selectedCount": "已选 {count} 人"
|
|
279
|
+
"hikari::userPicker.selectedCount": "已选 {count} 人",
|
|
280
|
+
"hikari::tokenField.editValue": "编辑数值",
|
|
281
|
+
"hikari::tokenField.clear": "清除"
|
|
280
282
|
}
|
|
@@ -276,5 +276,7 @@
|
|
|
276
276
|
"hikari::userPicker.add": "選擇使用者…",
|
|
277
277
|
"hikari::userPicker.remove": "移除",
|
|
278
278
|
"hikari::userPicker.confirm": "確認",
|
|
279
|
-
"hikari::userPicker.selectedCount": "已選 {count} 人"
|
|
279
|
+
"hikari::userPicker.selectedCount": "已選 {count} 人",
|
|
280
|
+
"hikari::tokenField.editValue": "編輯數值",
|
|
281
|
+
"hikari::tokenField.clear": "清除"
|
|
280
282
|
}
|
package/src/index.ts
CHANGED
|
@@ -92,6 +92,7 @@ export { default as HSkeleton } from "./components/HkSkeleton";
|
|
|
92
92
|
export { default as HSkeletonList } from "./components/HkSkeletonList";
|
|
93
93
|
export { default as HSlider } from "./components/HkSlider";
|
|
94
94
|
export { default as HSpinner } from "./components/HkSpinner";
|
|
95
|
+
export { default as HTokenField } from "./components/HkTokenField";
|
|
95
96
|
export { default as HSwitch } from "./components/HkSwitch";
|
|
96
97
|
export { default as HTable } from "./components/HkTable";
|
|
97
98
|
export { default as HTabs } from "./components/HkTabs";
|
|
@@ -746,6 +747,7 @@ export { default as HkSidebar } from "./components/HkSidebar";
|
|
|
746
747
|
export { default as HkSkeleton } from "./components/HkSkeleton";
|
|
747
748
|
export { default as HkSkeletonList } from "./components/HkSkeletonList";
|
|
748
749
|
export { default as HkSlider } from "./components/HkSlider";
|
|
750
|
+
export { default as HkTokenField, HkTokenValueEditor, compactTokens as compactTokenCount, DEFAULT_TOKEN_STOPS, type TokenUnit } from "./components/HkTokenField";
|
|
749
751
|
export { default as HkSpinner } from "./components/HkSpinner";
|
|
750
752
|
export { default as HkSwitch } from "./components/HkSwitch";
|
|
751
753
|
export { default as HkTable } from "./components/HkTable";
|