@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.143",
3
+ "version": "0.55.144",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -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(-50%, -50%);
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(-50%, -50%) translateY(5%);
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(-50%, -50%);
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(-50%, -50%);
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(-50%, -50%) translateY(5%);
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";