@celestia-island/hikari 0.40.0 → 0.40.2
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 +2 -2
- package/src/components/HkAffixPicker.scss +19 -2
- package/src/components/HkAffixPicker.test.tsx +24 -0
- package/src/components/HkAffixPicker.tsx +84 -3
- package/src/components/HkCaptchaModal.tsx +2 -2
- package/src/components/HkLogWindow.tsx +2 -2
- package/src/components/HkModal.tsx +81 -2
- package/src/components/HkModal.width.test.tsx +114 -0
- package/src/components/HkPhoneInput.scss +5 -15
- package/src/components/HkPhoneInput.test.tsx +7 -1
- package/src/components/HkPhoneInput.tsx +8 -14
- package/src/components/HkProtocolModal.tsx +3 -3
- package/src/index.ts +5 -0
package/package.json
CHANGED
|
@@ -38,7 +38,7 @@ import { useBreakpoint } from "../runtime/useBreakpoint";
|
|
|
38
38
|
import "./HkAdaptiveDialog.scss";
|
|
39
39
|
import HButton from "./HkButton";
|
|
40
40
|
import HDrawer from "./HkDrawer";
|
|
41
|
-
import HModal, { type ModalAction } from "./HkModal";
|
|
41
|
+
import HModal, { type ModalAction, type ModalWidth } from "./HkModal";
|
|
42
42
|
|
|
43
43
|
export default defineComponent({
|
|
44
44
|
name: "HkAdaptiveDialog",
|
|
@@ -47,7 +47,7 @@ export default defineComponent({
|
|
|
47
47
|
title: { type: String, default: undefined },
|
|
48
48
|
closable: { type: Boolean, default: true },
|
|
49
49
|
/** Desktop modal width (HkModal `width`). */
|
|
50
|
-
width: { type: String
|
|
50
|
+
width: { type: String as PropType<ModalWidth>, default: "32rem" },
|
|
51
51
|
/** Mobile drawer height (HkDrawer `size`). HkDrawer's bottom-side CSS
|
|
52
52
|
* clamps panels at maxHeight 70vh, so the default equals the cap;
|
|
53
53
|
* pass `panelClass` with a custom max-height for taller sheets. */
|
|
@@ -167,15 +167,32 @@
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
/* ── option rows ────────────────────────────────────────────────────── */
|
|
170
|
+
/* Positioned host for the overlay scrollbar tracks: wraps ONLY the
|
|
171
|
+
* scrolling list viewport (the menu body also contains the header/
|
|
172
|
+
* search band), carries the popup's width constraints so the geometry
|
|
173
|
+
* is unchanged by the wrapper. */
|
|
174
|
+
.hk-affix-scroll {
|
|
175
|
+
position: relative;
|
|
176
|
+
display: flex;
|
|
177
|
+
flex-direction: column;
|
|
178
|
+
min-width: 13rem;
|
|
179
|
+
max-width: min(19rem, calc(100vw - 2rem));
|
|
180
|
+
}
|
|
181
|
+
|
|
170
182
|
.hk-affix-list {
|
|
171
183
|
display: flex;
|
|
172
184
|
flex-direction: column;
|
|
173
185
|
gap: 1px;
|
|
174
186
|
padding: 4px 6px 8px;
|
|
175
|
-
min-width: 13rem;
|
|
176
|
-
max-width: min(19rem, calc(100vw - 2rem));
|
|
177
187
|
max-height: 17rem;
|
|
178
188
|
overflow-y: auto;
|
|
189
|
+
/* Overlay scrollbar (useOverlayScrollbar) — the native chrome is
|
|
190
|
+
* always hidden, never styled. */
|
|
191
|
+
scrollbar-width: none;
|
|
192
|
+
|
|
193
|
+
&::-webkit-scrollbar {
|
|
194
|
+
display: none;
|
|
195
|
+
}
|
|
179
196
|
}
|
|
180
197
|
|
|
181
198
|
.hk-affix-row {
|
|
@@ -176,6 +176,30 @@ describe("HkAffixPicker", () => {
|
|
|
176
176
|
expect(document.querySelector(".hk-affix-empty")?.textContent).toContain("No matches");
|
|
177
177
|
});
|
|
178
178
|
|
|
179
|
+
it("re-attaches the row list when the empty-state query is cleared", async () => {
|
|
180
|
+
const { container } = mountPicker();
|
|
181
|
+
await openPopup(container);
|
|
182
|
+
// A no-match query swaps the default slot to the empty branch —
|
|
183
|
+
// the scrolling list (and its overlay-scrollbar host) unmounts.
|
|
184
|
+
await typeQuery("zzz-none");
|
|
185
|
+
expect(document.querySelector(".hk-affix-empty")).toBeTruthy();
|
|
186
|
+
expect(document.querySelector(".hk-affix-scroll")).toBeNull();
|
|
187
|
+
// Clearing the query remounts a FRESH list — the row list must come
|
|
188
|
+
// back and the overlay host must be remounted for the scrollbar.
|
|
189
|
+
await typeQuery("");
|
|
190
|
+
expect(document.querySelector(".hk-affix-empty")).toBeNull();
|
|
191
|
+
expect(rows().map((r) => r.textContent)).toEqual([
|
|
192
|
+
expect.stringContaining("China"),
|
|
193
|
+
expect.stringContaining("Japan"),
|
|
194
|
+
expect.stringContaining("United States"),
|
|
195
|
+
]);
|
|
196
|
+
expect(document.querySelector(".hk-affix-scroll")).toBeTruthy();
|
|
197
|
+
expect(document.querySelector(".hk-affix-list")).toBeTruthy();
|
|
198
|
+
// Re-filtering after the remount keeps working on the live list.
|
|
199
|
+
await typeQuery("+86");
|
|
200
|
+
expect(rows().map((r) => r.textContent)).toEqual([expect.stringContaining("China")]);
|
|
201
|
+
});
|
|
202
|
+
|
|
179
203
|
it("multi mode renders selected tags and offers only the remaining rows", async () => {
|
|
180
204
|
const { container } = mountPicker({ mode: "multi", selected: ["cn", "jp"], activeKey: "cn" });
|
|
181
205
|
await openPopup(container);
|
|
@@ -1,8 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
defineComponent,
|
|
4
|
+
nextTick,
|
|
5
|
+
onBeforeUnmount,
|
|
6
|
+
ref,
|
|
7
|
+
watch,
|
|
8
|
+
type PropType,
|
|
9
|
+
type SlotsType,
|
|
10
|
+
} from "vue";
|
|
2
11
|
|
|
3
12
|
import { ChevronDown, Plus, Search, X } from "lucide-vue-next";
|
|
4
13
|
|
|
5
14
|
import { useI18n } from "../i18n/context";
|
|
15
|
+
import {
|
|
16
|
+
attachOverlayScrollbars,
|
|
17
|
+
type OverlayScrollbarHandle,
|
|
18
|
+
} from "../composables/useOverlayScrollbar";
|
|
6
19
|
|
|
7
20
|
import HkInput from "./HkInput";
|
|
8
21
|
import HkListTransition from "./HkListTransition";
|
|
@@ -116,15 +129,58 @@ export const HkAffixPicker = defineComponent({
|
|
|
116
129
|
* are outside THIS popup, and the panel's outside-close must not
|
|
117
130
|
* tear the tag list down mid-decision. */
|
|
118
131
|
const confirmHeld = ref(false);
|
|
132
|
+
/** The scrolling option list and its overlay-scrollbar host (see the
|
|
133
|
+
* default slot — the host wraps ONLY the list viewport, not the
|
|
134
|
+
* header/search band). */
|
|
135
|
+
const listRef = ref<HTMLElement | null>(null);
|
|
136
|
+
const scrollHostRef = ref<HTMLElement | null>(null);
|
|
137
|
+
/** Live overlay-scrollbar handle for the open popup; null when the
|
|
138
|
+
* popup is closed (content not mounted). */
|
|
139
|
+
let scrollbar: OverlayScrollbarHandle | null = null;
|
|
140
|
+
/** The viewport element `scrollbar` is currently attached to, so a
|
|
141
|
+
* remounted list (the empty-state swap) can be told apart from the
|
|
142
|
+
* same in-flight list across content-size updates. */
|
|
143
|
+
let scrollbarViewport: HTMLElement | null = null;
|
|
144
|
+
|
|
145
|
+
function detachScrollbar() {
|
|
146
|
+
scrollbar?.detach();
|
|
147
|
+
scrollbar = null;
|
|
148
|
+
scrollbarViewport = null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function attachScrollbar() {
|
|
152
|
+
detachScrollbar();
|
|
153
|
+
if (listRef.value && scrollHostRef.value) {
|
|
154
|
+
scrollbarViewport = listRef.value;
|
|
155
|
+
scrollbar = attachOverlayScrollbars(listRef.value, {
|
|
156
|
+
axis: "vertical",
|
|
157
|
+
host: scrollHostRef.value,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
119
161
|
|
|
120
162
|
// A fresh open starts calm: empty filter.
|
|
121
163
|
watch(open, (v) => {
|
|
122
164
|
if (!v) {
|
|
123
165
|
query.value = "";
|
|
124
166
|
}
|
|
167
|
+
if (v) {
|
|
168
|
+
// The list mounts on this very render — attach the overlay
|
|
169
|
+
// scrollbar once the DOM has landed. A same-tick open→close
|
|
170
|
+
// must not arm it on the leaving popup (the close branch
|
|
171
|
+
// already detached it).
|
|
172
|
+
void nextTick(() => {
|
|
173
|
+
if (!open.value) return;
|
|
174
|
+
attachScrollbar();
|
|
175
|
+
});
|
|
176
|
+
} else {
|
|
177
|
+
detachScrollbar();
|
|
178
|
+
}
|
|
125
179
|
emit("update:open", v);
|
|
126
180
|
});
|
|
127
181
|
|
|
182
|
+
onBeforeUnmount(detachScrollbar);
|
|
183
|
+
|
|
128
184
|
const selectedKeys = computed<readonly string[]>(() =>
|
|
129
185
|
Array.isArray(props.selected) ? props.selected : props.selected ? [props.selected] : [],
|
|
130
186
|
);
|
|
@@ -150,6 +206,29 @@ export const HkAffixPicker = defineComponent({
|
|
|
150
206
|
});
|
|
151
207
|
});
|
|
152
208
|
|
|
209
|
+
// Content-size changes from the search filter change the thumb
|
|
210
|
+
// geometry without resizing the viewport — keep it in sync on the
|
|
211
|
+
// live scrollbar (no-op while the popup is closed). Post-flush so
|
|
212
|
+
// the DOM (esp. a remounted list after the empty-state swap) has
|
|
213
|
+
// landed and the template refs point at the live nodes before we
|
|
214
|
+
// decide whether to attach, re-attach or update.
|
|
215
|
+
watch(
|
|
216
|
+
[filteredRows, query],
|
|
217
|
+
() => {
|
|
218
|
+
if (!open.value) return;
|
|
219
|
+
if (!listRef.value) {
|
|
220
|
+
detachScrollbar();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (listRef.value !== scrollbarViewport) {
|
|
224
|
+
attachScrollbar();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
scrollbar?.update();
|
|
228
|
+
},
|
|
229
|
+
{ flush: "post" },
|
|
230
|
+
);
|
|
231
|
+
|
|
153
232
|
/** Exact label match suppresses the custom row while the user is
|
|
154
233
|
* simply re-typing an existing entry. */
|
|
155
234
|
const exactMatch = computed(
|
|
@@ -394,8 +473,9 @@ export const HkAffixPicker = defineComponent({
|
|
|
394
473
|
),
|
|
395
474
|
default: () =>
|
|
396
475
|
rows.length > 0 || customVisible.value ? (
|
|
397
|
-
<div class="hk-affix-
|
|
398
|
-
|
|
476
|
+
<div class="hk-affix-scroll" ref={scrollHostRef}>
|
|
477
|
+
<div class="hk-affix-list" ref={listRef}>
|
|
478
|
+
{rows.map((option) => {
|
|
399
479
|
const active =
|
|
400
480
|
props.mode === "single"
|
|
401
481
|
? selectedKeys.value.includes(option.key)
|
|
@@ -443,6 +523,7 @@ export const HkAffixPicker = defineComponent({
|
|
|
443
523
|
</span>
|
|
444
524
|
</button>
|
|
445
525
|
)}
|
|
526
|
+
</div>
|
|
446
527
|
</div>
|
|
447
528
|
) : (
|
|
448
529
|
<div class="hk-affix-empty">{emptyText}</div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineComponent, type PropType } from "vue";
|
|
2
|
-
import { HModal } from "@celestia-island/hikari";
|
|
2
|
+
import { HModal, type ModalWidth } from "@celestia-island/hikari";
|
|
3
3
|
|
|
4
4
|
import { useI18n } from "../i18n/context";
|
|
5
5
|
|
|
@@ -23,7 +23,7 @@ export const HkCaptchaModal = defineComponent({
|
|
|
23
23
|
scriptUrl: { type: String, default: undefined },
|
|
24
24
|
attempt: { type: Number, default: 0 },
|
|
25
25
|
title: { type: String, default: undefined },
|
|
26
|
-
width: { type: String
|
|
26
|
+
width: { type: String as PropType<ModalWidth>, default: "30rem" },
|
|
27
27
|
},
|
|
28
28
|
emits: {
|
|
29
29
|
"update:modelValue": (_v: boolean) => true,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { computed, defineComponent, nextTick, ref, watch, type PropType } from "vue";
|
|
2
2
|
import { Copy, Eraser, Pause, Play, ScrollText } from "lucide-vue-next";
|
|
3
|
-
import { HModal, HScrollContainer, useClipboard } from "@celestia-island/hikari";
|
|
3
|
+
import { HModal, HScrollContainer, useClipboard, type ModalWidth } from "@celestia-island/hikari";
|
|
4
4
|
|
|
5
5
|
import { useI18n } from "../i18n/context";
|
|
6
6
|
|
|
@@ -47,7 +47,7 @@ export const HkLogWindow = defineComponent({
|
|
|
47
47
|
/** Height of the log body (CSS length, e.g. "55vh"). */
|
|
48
48
|
height: { type: String, default: "55vh" },
|
|
49
49
|
title: { type: String, default: undefined },
|
|
50
|
-
width: { type: String
|
|
50
|
+
width: { type: String as PropType<ModalWidth>, default: "60rem" },
|
|
51
51
|
},
|
|
52
52
|
emits: {
|
|
53
53
|
"update:modelValue": (_v: boolean) => true,
|
|
@@ -34,6 +34,84 @@ export interface ModalAction {
|
|
|
34
34
|
onClick?: () => void;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Named width presets for the `width` prop. Callers kept reaching for
|
|
39
|
+
* semantic sizes (`width="sm"`) which, passed through as raw CSS
|
|
40
|
+
* `max-width`, are silently dropped by the browser — the frame fell back
|
|
41
|
+
* to `width: 100%` and spanned the whole viewport (chest #662-era change
|
|
42
|
+
* password modal rendered 2048px wide). Named values now resolve here;
|
|
43
|
+
* anything else keeps passing through as a CSS max-width value.
|
|
44
|
+
*/
|
|
45
|
+
export const MODAL_WIDTH_PRESETS = {
|
|
46
|
+
xs: "20rem",
|
|
47
|
+
sm: "32rem",
|
|
48
|
+
md: "40rem",
|
|
49
|
+
lg: "56rem",
|
|
50
|
+
xl: "72rem",
|
|
51
|
+
} as const;
|
|
52
|
+
|
|
53
|
+
export type ModalWidthPreset = keyof typeof MODAL_WIDTH_PRESETS;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `width` accepts a named preset or any CSS max-width value. The
|
|
57
|
+
* `(string & {})` arm keeps preset autocomplete in TSX while still
|
|
58
|
+
* allowing arbitrary lengths ("32rem", "560px", "50%").
|
|
59
|
+
*/
|
|
60
|
+
export type ModalWidth = ModalWidthPreset | (string & {});
|
|
61
|
+
|
|
62
|
+
const warnedWidths = new Set<string>();
|
|
63
|
+
/** Dev-only warn-once memory cap: a session feeding dynamically generated
|
|
64
|
+
* garbage widths must not grow the set forever. Prod never reaches this
|
|
65
|
+
* code (DEV is statically false there). */
|
|
66
|
+
const MAX_WARNED_WIDTHS = 50;
|
|
67
|
+
|
|
68
|
+
/** Valid digit-free CSS max-width keywords: the browser honors these, so
|
|
69
|
+
* the "would span the viewport" dev warn must stay silent for them. */
|
|
70
|
+
const WIDTH_KEYWORDS = new Set([
|
|
71
|
+
"auto",
|
|
72
|
+
"none",
|
|
73
|
+
"min-content",
|
|
74
|
+
"max-content",
|
|
75
|
+
"fit-content",
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve the `width` prop to a concrete CSS max-width value: presets map
|
|
80
|
+
* through `MODAL_WIDTH_PRESETS`, everything else passes through untouched.
|
|
81
|
+
* In dev, a value that is neither a preset, a keyword, nor a length (no
|
|
82
|
+
* digit anywhere — e.g. a stray token like `"wide"`) warns once: as a raw
|
|
83
|
+
* max-width it would be dropped by the browser and the modal would span
|
|
84
|
+
* the viewport.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveModalWidth(width: string): string {
|
|
87
|
+
// Coerce defensively: a JS consumer passing width={560} skips the prop
|
|
88
|
+
// type check at runtime and Vue would have rendered the number as px.
|
|
89
|
+
const value = String(width);
|
|
90
|
+
// hasOwnProperty.call instead of Object.hasOwn (ES2022): the package
|
|
91
|
+
// ships TS source, so consumers' bundlers will not polyfill new
|
|
92
|
+
// built-ins — this must not raise the runtime floor. The own-property
|
|
93
|
+
// guard is what keeps inherited members ("constructor", "toString")
|
|
94
|
+
// from resolving as presets past the dev warn.
|
|
95
|
+
const key = value.trim();
|
|
96
|
+
const preset = Object.prototype.hasOwnProperty.call(MODAL_WIDTH_PRESETS, key)
|
|
97
|
+
? MODAL_WIDTH_PRESETS[key as ModalWidthPreset]
|
|
98
|
+
: undefined;
|
|
99
|
+
if (preset !== undefined) return preset;
|
|
100
|
+
if (
|
|
101
|
+
import.meta.env?.DEV &&
|
|
102
|
+
!/\d/.test(value) &&
|
|
103
|
+
!WIDTH_KEYWORDS.has(key) &&
|
|
104
|
+
warnedWidths.size < MAX_WARNED_WIDTHS &&
|
|
105
|
+
!warnedWidths.has(value)
|
|
106
|
+
) {
|
|
107
|
+
warnedWidths.add(value);
|
|
108
|
+
console.warn(
|
|
109
|
+
`[HkModal] width "${value}" is neither a named preset (${Object.keys(MODAL_WIDTH_PRESETS).join("/")}) nor a CSS length — the browser would drop it and the modal would span the viewport.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
|
|
37
115
|
const FOCUSABLE_SELECTOR =
|
|
38
116
|
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
39
117
|
|
|
@@ -44,7 +122,8 @@ export default defineComponent({
|
|
|
44
122
|
modelValue: { type: Boolean, required: true },
|
|
45
123
|
title: { type: String, default: undefined },
|
|
46
124
|
closable: { type: Boolean, default: true },
|
|
47
|
-
|
|
125
|
+
/** Named preset ("xs"…"xl") or any CSS max-width value (see ModalWidth). */
|
|
126
|
+
width: { type: String as PropType<ModalWidth>, default: "32rem" },
|
|
48
127
|
/**
|
|
49
128
|
* Escape hatch: extra class appended to the .hk-modal-content frame
|
|
50
129
|
* so hosts can restyle the surface without forking the modal (e.g.
|
|
@@ -114,7 +193,7 @@ export default defineComponent({
|
|
|
114
193
|
|
|
115
194
|
const overlayZ = computed(() => handle.value?.zIndex ?? 0);
|
|
116
195
|
const contentZ = computed(() => (handle.value?.zIndex ?? 0) + 1);
|
|
117
|
-
const resolvedWidth = computed(() => props.width);
|
|
196
|
+
const resolvedWidth = computed(() => resolveModalWidth(props.width));
|
|
118
197
|
// Layer/dialog name: the explicit surface name wins over the header
|
|
119
198
|
// title (which header-less surfaces never pass).
|
|
120
199
|
const resolvedSurfaceName = computed(() => props.surfaceTitle ?? props.title);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkModal, { MODAL_WIDTH_PRESETS, resolveModalWidth } from "./HkModal";
|
|
5
|
+
|
|
6
|
+
const mounts: ReturnType<typeof createApp>[] = [];
|
|
7
|
+
const containers: HTMLElement[] = [];
|
|
8
|
+
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
for (const app of mounts.splice(0)) app.unmount();
|
|
11
|
+
for (const el of containers.splice(0)) el.remove();
|
|
12
|
+
vi.restoreAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("resolveModalWidth", () => {
|
|
16
|
+
it("maps every named preset to its CSS max-width value", () => {
|
|
17
|
+
for (const [name, css] of Object.entries(MODAL_WIDTH_PRESETS)) {
|
|
18
|
+
expect(resolveModalWidth(name)).toBe(css);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("keeps the default-sized presets bounded and ordered", () => {
|
|
23
|
+
// Sanity on the scale itself: xs < sm(default) < md < lg < xl.
|
|
24
|
+
expect(MODAL_WIDTH_PRESETS.xs).toBe("20rem");
|
|
25
|
+
expect(MODAL_WIDTH_PRESETS.sm).toBe("32rem");
|
|
26
|
+
expect(MODAL_WIDTH_PRESETS.md).toBe("40rem");
|
|
27
|
+
expect(MODAL_WIDTH_PRESETS.lg).toBe("56rem");
|
|
28
|
+
expect(MODAL_WIDTH_PRESETS.xl).toBe("72rem");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("passes arbitrary CSS max-width values through untouched", () => {
|
|
32
|
+
expect(resolveModalWidth("32rem")).toBe("32rem");
|
|
33
|
+
expect(resolveModalWidth("560px")).toBe("560px");
|
|
34
|
+
expect(resolveModalWidth("50%")).toBe("50%");
|
|
35
|
+
expect(resolveModalWidth("min(90vw, 48rem)")).toBe("min(90vw, 48rem)");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("passes digit-free max-width keywords through without warning", () => {
|
|
39
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
40
|
+
for (const keyword of ["auto", "none", "min-content", "max-content", "fit-content"]) {
|
|
41
|
+
expect(resolveModalWidth(keyword)).toBe(keyword);
|
|
42
|
+
}
|
|
43
|
+
expect(warn).not.toHaveBeenCalled();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("coerces a numeric width from JS consumers instead of throwing", () => {
|
|
47
|
+
expect(resolveModalWidth(560 as unknown as string)).toBe("560");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("does not inherit Object.prototype members as presets", () => {
|
|
51
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
52
|
+
// A bare index lookup would resolve "constructor"/"toString" to
|
|
53
|
+
// inherited functions and early-return them past the warn — the
|
|
54
|
+
// guard must treat them as unknown values instead.
|
|
55
|
+
for (const hostile of ["constructor", "toString", "valueOf"]) {
|
|
56
|
+
expect(resolveModalWidth(hostile)).toBe(hostile);
|
|
57
|
+
}
|
|
58
|
+
expect(warn).toHaveBeenCalledTimes(3);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("trims surrounding whitespace before the preset lookup", () => {
|
|
62
|
+
expect(resolveModalWidth(" sm ")).toBe("32rem");
|
|
63
|
+
expect(resolveModalWidth(" 560px ")).toBe(" 560px ");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("warns once in dev for a value that is neither a preset nor a length", () => {
|
|
67
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
68
|
+
// A unique digit-free token per run: the warn-once set is module
|
|
69
|
+
// state, and any digit would classify the value as a length.
|
|
70
|
+
const stray = `wide-${Math.random().toString(36).slice(2).replace(/\d/g, "") || "wide"}`;
|
|
71
|
+
expect(resolveModalWidth(stray)).toBe(stray);
|
|
72
|
+
expect(resolveModalWidth(stray)).toBe(stray);
|
|
73
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
74
|
+
expect(warn.mock.calls[0]?.[0]).toContain(stray);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("HkModal width rendering", () => {
|
|
79
|
+
async function mountModal(width: string): Promise<HTMLElement> {
|
|
80
|
+
const container = document.createElement("div");
|
|
81
|
+
document.body.appendChild(container);
|
|
82
|
+
containers.push(container);
|
|
83
|
+
|
|
84
|
+
const open = ref(true);
|
|
85
|
+
const Wrapper = defineComponent({
|
|
86
|
+
setup() {
|
|
87
|
+
return () =>
|
|
88
|
+
h(HkModal, {
|
|
89
|
+
modelValue: open.value,
|
|
90
|
+
width,
|
|
91
|
+
}, { default: () => h("div", "content") });
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
const app = createApp(Wrapper);
|
|
95
|
+
mounts.push(app);
|
|
96
|
+
app.mount(container);
|
|
97
|
+
await nextTick();
|
|
98
|
+
|
|
99
|
+
// HkModal Teleports to document.body — the mount container stays empty.
|
|
100
|
+
const content = document.body.querySelector<HTMLElement>(".hk-modal-content");
|
|
101
|
+
expect(content).toBeTruthy();
|
|
102
|
+
return content!;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
it("renders a named preset as the frame's max-width", async () => {
|
|
106
|
+
const content = await mountModal("sm");
|
|
107
|
+
expect(content.style.maxWidth).toBe("32rem");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("renders an arbitrary CSS length as the frame's max-width", async () => {
|
|
111
|
+
const content = await mountModal("560px");
|
|
112
|
+
expect(content.style.maxWidth).toBe("560px");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -4,25 +4,15 @@
|
|
|
4
4
|
width: 100%;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
/* The dial-code chip riding the field's LEFT edge —
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
/* The dial-code chip riding the field's LEFT edge — dial code + caret,
|
|
8
|
+
* layered on the shared .hk-affix-chip base (hover, disabled, focus
|
|
9
|
+
* handling live there). A button so it is focusable and AT-reachable;
|
|
10
|
+
* mousedown is suppressed in the shared picker so clicking it never
|
|
11
|
+
* steals focus from the number field. */
|
|
12
12
|
.hk-phone-chip {
|
|
13
13
|
max-width: 11rem;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
.hk-phone-chip-flag {
|
|
17
|
-
flex: none;
|
|
18
|
-
font-size: calc(var(--text-sm, 14px) * 1.05);
|
|
19
|
-
line-height: 1;
|
|
20
|
-
/* Windows has no color flag font — the regional-indicator letters it
|
|
21
|
-
* substitutes are tiny; nudge them to the visible size. */
|
|
22
|
-
font-family: "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji",
|
|
23
|
-
sans-serif;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
16
|
.hk-phone-chip-dial {
|
|
27
17
|
font-weight: 600;
|
|
28
18
|
font-size: var(--text-sm, 14px);
|
|
@@ -101,7 +101,7 @@ describe("HkPhoneInput", () => {
|
|
|
101
101
|
expect(events.change.at(-1)).toBe("+8613812345678");
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
-
it("lists catalog rows with
|
|
104
|
+
it("lists catalog rows with names and codes when opened", async () => {
|
|
105
105
|
const { container } = mountPhone();
|
|
106
106
|
await openPicker(container);
|
|
107
107
|
const rows = pickerRows();
|
|
@@ -109,6 +109,12 @@ describe("HkPhoneInput", () => {
|
|
|
109
109
|
const first = rows[0];
|
|
110
110
|
expect(first.textContent).toContain("China");
|
|
111
111
|
expect(first.textContent).toContain("+86");
|
|
112
|
+
// Flags are deliberately absent — Windows has no color flag font, so
|
|
113
|
+
// the emoji would degrade to bare regional-indicator letters.
|
|
114
|
+
expect(first.querySelector(".hk-affix-row-flag")).toBeNull();
|
|
115
|
+
for (const row of rows) {
|
|
116
|
+
expect(row.querySelector(".hk-affix-row-flag")).toBeNull();
|
|
117
|
+
}
|
|
112
118
|
});
|
|
113
119
|
|
|
114
120
|
it("picks a country from the list and refocuses the number field", async () => {
|
|
@@ -7,7 +7,6 @@ import { useI18n } from "../i18n/context";
|
|
|
7
7
|
import {
|
|
8
8
|
DIAL_CODES,
|
|
9
9
|
dialCodeName,
|
|
10
|
-
flagEmoji,
|
|
11
10
|
formatE164,
|
|
12
11
|
normalizeDial,
|
|
13
12
|
resolveDial,
|
|
@@ -21,20 +20,19 @@ import "./HkPhoneInput.scss";
|
|
|
21
20
|
/**
|
|
22
21
|
* HkPhoneInput — phone-number field with a country dial-code picker.
|
|
23
22
|
*
|
|
24
|
-
* A leading chip inside the field (
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* it.
|
|
23
|
+
* A leading chip inside the field (dial code + caret) opens the shared
|
|
24
|
+
* HkAffixPicker (single-select, searchable): country name + dial code
|
|
25
|
+
* per row, live filter on top. The chip rides the LEFT edge of the
|
|
26
|
+
* field — the natural reading order for "which country, then which
|
|
27
|
+
* number" — while the number itself is typed after it.
|
|
30
28
|
*
|
|
31
29
|
* - `modelValue` is the NATIONAL number only ("13812345678"); the
|
|
32
30
|
* country selection lives in `dialCode` ("+86" shape, normalized on
|
|
33
31
|
* emit). Splitting the two keeps callers free to store whatever
|
|
34
32
|
* they already store (a bare "86" works too — matched by dial).
|
|
35
|
-
* - The chip shows the resolved country's
|
|
36
|
-
*
|
|
37
|
-
*
|
|
33
|
+
* - The chip shows the resolved country's dial code in canonical "+…"
|
|
34
|
+
* shape. Unknown dial codes fall back to showing the normalized
|
|
35
|
+
* code alone.
|
|
38
36
|
* - Picking a row emits `update:dialCode` ("+…"), `dialchange`
|
|
39
37
|
* (same value) and refocuses the number field. Blurring the field
|
|
40
38
|
* emits `change` with the composed E.164 — use it to validate or
|
|
@@ -111,7 +109,6 @@ export const HkPhoneInput = defineComponent({
|
|
|
111
109
|
key: c.iso,
|
|
112
110
|
label: dialCodeName(c, locale),
|
|
113
111
|
meta: `+${c.dial}`,
|
|
114
|
-
flag: flagEmoji(c.iso),
|
|
115
112
|
keywords: `${c.en} ${c.zh} ${c.iso} +${c.dial} 00${c.dial}`,
|
|
116
113
|
})),
|
|
117
114
|
);
|
|
@@ -203,9 +200,6 @@ export const HkPhoneInput = defineComponent({
|
|
|
203
200
|
{{
|
|
204
201
|
chip: () => (
|
|
205
202
|
<>
|
|
206
|
-
<span class="hk-phone-chip-flag" aria-hidden="true">
|
|
207
|
-
{active ? flagEmoji(active.iso) : ""}
|
|
208
|
-
</span>
|
|
209
203
|
<span class="hk-phone-chip-dial">{chipLabel()}</span>
|
|
210
204
|
<ChevronDown size={12} class="hk-phone-chip-caret" aria-hidden="true" />
|
|
211
205
|
</>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { computed, defineComponent, onBeforeUnmount, ref, watch } from "vue";
|
|
2
|
-
import { HMarkdownRenderer, HModal, useClipboard, type ModalAction } from "@celestia-island/hikari";
|
|
1
|
+
import { computed, defineComponent, onBeforeUnmount, ref, watch, type PropType } from "vue";
|
|
2
|
+
import { HMarkdownRenderer, HModal, useClipboard, type ModalAction, type ModalWidth } from "@celestia-island/hikari";
|
|
3
3
|
|
|
4
4
|
import { useI18n } from "../i18n/context";
|
|
5
5
|
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
@@ -29,7 +29,7 @@ export const HkProtocolModal = defineComponent({
|
|
|
29
29
|
declineLabel: { type: String, default: undefined },
|
|
30
30
|
/** Allow dismissing without a decision (overlay/ESC/X). Default true. */
|
|
31
31
|
closable: { type: Boolean, default: true },
|
|
32
|
-
width: { type: String
|
|
32
|
+
width: { type: String as PropType<ModalWidth>, default: "48rem" },
|
|
33
33
|
/** Cap the scroll body height (e.g. "60vh"). */
|
|
34
34
|
bodyHeight: { type: String, default: undefined },
|
|
35
35
|
},
|
package/src/index.ts
CHANGED
|
@@ -115,6 +115,11 @@ export { type BoardNodeInput, type BoardEdgeInput } from "./components/HkBoard";
|
|
|
115
115
|
export { type BoardAnchorMode, type BoardEdgeStyle, type BoardPoint } from "./utils/boardEdges";
|
|
116
116
|
export { type BoardCamera, type BoardRect, type BoardViewport } from "./utils/boardCamera";
|
|
117
117
|
export { type ModalAction } from "./components/HkModal";
|
|
118
|
+
export {
|
|
119
|
+
type ModalWidth,
|
|
120
|
+
type ModalWidthPreset,
|
|
121
|
+
resolveModalWidth,
|
|
122
|
+
} from "./components/HkModal";
|
|
118
123
|
export { type TreeNode, type TreeSize, type TreeRowScope } from "./components/HkTree";
|
|
119
124
|
export { type DragListItem } from "./components/HkDraggableList";
|
|
120
125
|
export { type GridItem } from "./components/HkDraggableGrid";
|