@joewinke/jatui 0.1.10 → 0.1.11
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 +2 -1
- package/src/lib/components/ContextMenu.svelte +148 -0
- package/src/lib/components/PhoneInput.svelte +183 -63
- package/src/lib/index.ts +14 -1
- package/src/lib/utils/phone.ts +80 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joewinke/jatui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Shared Svelte 5 component library for JAT projects",
|
|
6
6
|
"type": "module",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"vite": "^6.0.0"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
+
"libphonenumber-js": "^1.12.41",
|
|
47
48
|
"pdfjs-dist": "^5.6.205",
|
|
48
49
|
"svelte-dnd-action": "^0.9.69"
|
|
49
50
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ContextMenu Component
|
|
4
|
+
*
|
|
5
|
+
* Floating right-click (or programmatic) menu positioned at a cursor point.
|
|
6
|
+
* Headless primitive that handles positioning, viewport clamping, and
|
|
7
|
+
* dismiss behavior (click-outside, Escape, right-click elsewhere).
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* let cm = $state({ open: false, x: 0, y: 0, target: null })
|
|
11
|
+
*
|
|
12
|
+
* <button oncontextmenu={(e) => {
|
|
13
|
+
* e.preventDefault()
|
|
14
|
+
* cm = { open: true, x: e.clientX, y: e.clientY, target: row }
|
|
15
|
+
* }}>Row</button>
|
|
16
|
+
*
|
|
17
|
+
* <ContextMenu
|
|
18
|
+
* bind:open={cm.open}
|
|
19
|
+
* x={cm.x}
|
|
20
|
+
* y={cm.y}
|
|
21
|
+
* header={cm.target?.name}
|
|
22
|
+
* items={[
|
|
23
|
+
* { label: "Preview", onSelect: () => preview(cm.target) },
|
|
24
|
+
* { label: "Copy URL", onSelect: () => copy(cm.target) },
|
|
25
|
+
* { divider: true },
|
|
26
|
+
* { label: "Delete", variant: "danger", onSelect: () => del(cm.target) },
|
|
27
|
+
* ]}
|
|
28
|
+
* />
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
type ActionItem = {
|
|
32
|
+
label: string;
|
|
33
|
+
onSelect: () => void;
|
|
34
|
+
variant?: 'default' | 'danger';
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
};
|
|
37
|
+
type DividerItem = { divider: true };
|
|
38
|
+
export type ContextMenuItem = ActionItem | DividerItem;
|
|
39
|
+
|
|
40
|
+
interface Props {
|
|
41
|
+
/** Whether the menu is currently shown. Bindable. */
|
|
42
|
+
open: boolean;
|
|
43
|
+
/** Viewport x coordinate to anchor the top-left corner to (e.g. event.clientX). */
|
|
44
|
+
x: number;
|
|
45
|
+
/** Viewport y coordinate to anchor the top-left corner to (e.g. event.clientY). */
|
|
46
|
+
y: number;
|
|
47
|
+
/** Ordered list of items and dividers to render. */
|
|
48
|
+
items: ContextMenuItem[];
|
|
49
|
+
/** Optional small italic label rendered above the items (e.g. a filename). */
|
|
50
|
+
header?: string;
|
|
51
|
+
/** Estimated menu width in px, used for viewport clamping. Default 200. */
|
|
52
|
+
estimatedWidth?: number;
|
|
53
|
+
/** Estimated menu height in px, used for viewport clamping. Default 200. */
|
|
54
|
+
estimatedHeight?: number;
|
|
55
|
+
/** Stacking context z-index. Default 70 (above DaisyUI modals at 50). */
|
|
56
|
+
zIndex?: number;
|
|
57
|
+
/** Additional CSS class for the menu element. */
|
|
58
|
+
class?: string;
|
|
59
|
+
/** Called after the menu closes, regardless of cause. */
|
|
60
|
+
onClose?: () => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let {
|
|
64
|
+
open = $bindable(false),
|
|
65
|
+
x,
|
|
66
|
+
y,
|
|
67
|
+
items,
|
|
68
|
+
header,
|
|
69
|
+
estimatedWidth = 200,
|
|
70
|
+
estimatedHeight = 200,
|
|
71
|
+
zIndex = 70,
|
|
72
|
+
class: className = '',
|
|
73
|
+
onClose
|
|
74
|
+
}: Props = $props();
|
|
75
|
+
|
|
76
|
+
function close() {
|
|
77
|
+
if (!open) return;
|
|
78
|
+
open = false;
|
|
79
|
+
onClose?.();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function handleSelect(item: ActionItem) {
|
|
83
|
+
if (item.disabled) return;
|
|
84
|
+
item.onSelect();
|
|
85
|
+
close();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function clamp(value: number, max: number, pad = 8) {
|
|
89
|
+
if (value + pad > max) return Math.max(pad, max - pad);
|
|
90
|
+
return Math.max(pad, value);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let clampedX = $derived(
|
|
94
|
+
typeof window === 'undefined' ? x : clamp(x, window.innerWidth - estimatedWidth)
|
|
95
|
+
);
|
|
96
|
+
let clampedY = $derived(
|
|
97
|
+
typeof window === 'undefined' ? y : clamp(y, window.innerHeight - estimatedHeight)
|
|
98
|
+
);
|
|
99
|
+
</script>
|
|
100
|
+
|
|
101
|
+
{#if open}
|
|
102
|
+
<!-- Invisible full-viewport overlay — captures click/right-click to dismiss -->
|
|
103
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
104
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
105
|
+
<div
|
|
106
|
+
class="fixed inset-0"
|
|
107
|
+
style="z-index: {zIndex - 1};"
|
|
108
|
+
onclick={close}
|
|
109
|
+
oncontextmenu={(e) => {
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
close();
|
|
112
|
+
}}
|
|
113
|
+
></div>
|
|
114
|
+
|
|
115
|
+
<div
|
|
116
|
+
role="menu"
|
|
117
|
+
tabindex="-1"
|
|
118
|
+
class="fixed min-w-[200px] bg-base-100 border border-base-300 rounded-md shadow-lg py-1 {className}"
|
|
119
|
+
style="left: {clampedX}px; top: {clampedY}px; z-index: {zIndex};"
|
|
120
|
+
onkeydown={(e) => e.key === 'Escape' && close()}
|
|
121
|
+
>
|
|
122
|
+
{#if header}
|
|
123
|
+
<p
|
|
124
|
+
class="px-3 py-1.5 text-[11px] italic text-base-content/60 truncate border-b border-base-200"
|
|
125
|
+
>
|
|
126
|
+
{header}
|
|
127
|
+
</p>
|
|
128
|
+
{/if}
|
|
129
|
+
|
|
130
|
+
{#each items as item}
|
|
131
|
+
{#if 'divider' in item}
|
|
132
|
+
<div class="border-t border-base-200 my-1"></div>
|
|
133
|
+
{:else}
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
role="menuitem"
|
|
137
|
+
class="w-full text-left px-3 py-1.5 text-sm transition-colors
|
|
138
|
+
{item.variant === 'danger' ? 'text-error hover:bg-error/10' : 'hover:bg-base-200'}
|
|
139
|
+
{item.disabled ? 'opacity-50 cursor-not-allowed' : ''}"
|
|
140
|
+
disabled={item.disabled}
|
|
141
|
+
onclick={() => handleSelect(item)}
|
|
142
|
+
>
|
|
143
|
+
{item.label}
|
|
144
|
+
</button>
|
|
145
|
+
{/if}
|
|
146
|
+
{/each}
|
|
147
|
+
</div>
|
|
148
|
+
{/if}
|
|
@@ -1,92 +1,212 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
* PhoneInput Component
|
|
3
|
+
* PhoneInput Component — International phone number input.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Country selector dropdown (flag + dial code + search) alongside a tel input.
|
|
6
|
+
* Formats numbers with libphonenumber-js AsYouType as the user types.
|
|
7
|
+
* Exposes the parsed E.164 value via `bind:value` and a hidden form field
|
|
8
|
+
* named `name` so it submits cleanly with native `<form>` actions.
|
|
9
|
+
*
|
|
10
|
+
* Auto-detects country when the user's input begins with a `+` prefix.
|
|
7
11
|
*/
|
|
12
|
+
import { AsYouType, parsePhoneNumberFromString, type CountryCode } from "libphonenumber-js";
|
|
13
|
+
import { listCountries, PRIORITY_COUNTRIES, type CountryOption } from "../utils/phone";
|
|
8
14
|
|
|
9
|
-
|
|
10
|
-
|
|
15
|
+
type Props = {
|
|
16
|
+
name?: string;
|
|
11
17
|
value?: string;
|
|
18
|
+
defaultCountry?: CountryCode;
|
|
12
19
|
required?: boolean;
|
|
13
|
-
disabled?: boolean;
|
|
14
|
-
error?: string;
|
|
15
|
-
helpText?: string;
|
|
16
20
|
placeholder?: string;
|
|
17
|
-
class?: string;
|
|
18
21
|
id?: string;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
class?: string;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
autocomplete?: HTMLInputElement["autocomplete"];
|
|
25
|
+
};
|
|
22
26
|
|
|
23
27
|
let {
|
|
24
|
-
|
|
25
|
-
value = $bindable(
|
|
28
|
+
name = "",
|
|
29
|
+
value = $bindable(""),
|
|
30
|
+
defaultCountry = "US",
|
|
26
31
|
required = false,
|
|
32
|
+
placeholder = "Phone number",
|
|
33
|
+
id,
|
|
34
|
+
class: klass = "",
|
|
27
35
|
disabled = false,
|
|
28
|
-
|
|
29
|
-
helpText = '',
|
|
30
|
-
placeholder = '',
|
|
31
|
-
class: className = '',
|
|
32
|
-
id = `phone-input-${Math.random().toString(36).substr(2, 9)}`,
|
|
33
|
-
name = '',
|
|
34
|
-
oninput
|
|
36
|
+
autocomplete = "tel" as HTMLInputElement["autocomplete"],
|
|
35
37
|
}: Props = $props();
|
|
36
38
|
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
39
|
+
const all = listCountries();
|
|
40
|
+
const priorityOptions = PRIORITY_COUNTRIES.map((c) => all.find((o) => o.code === c)).filter(
|
|
41
|
+
(o): o is CountryOption => !!o,
|
|
40
42
|
);
|
|
43
|
+
const otherOptions = all.filter((o) => !PRIORITY_COUNTRIES.includes(o.code));
|
|
41
44
|
|
|
42
|
-
function
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
function initialFrom(e164: string): { country: CountryCode; national: string } {
|
|
46
|
+
if (e164) {
|
|
47
|
+
const parsed = parsePhoneNumberFromString(e164);
|
|
48
|
+
if (parsed?.country) {
|
|
49
|
+
return { country: parsed.country, national: parsed.formatNational() };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { country: defaultCountry, national: "" };
|
|
46
53
|
}
|
|
54
|
+
|
|
55
|
+
const init = initialFrom(value ?? "");
|
|
56
|
+
let country = $state<CountryCode>(init.country);
|
|
57
|
+
let display = $state<string>(init.national);
|
|
58
|
+
let dropdownOpen = $state(false);
|
|
59
|
+
let search = $state("");
|
|
60
|
+
|
|
61
|
+
const current = $derived(all.find((o) => o.code === country) ?? all[0]);
|
|
62
|
+
|
|
63
|
+
const filteredOthers = $derived.by(() => {
|
|
64
|
+
const q = search.trim().toLowerCase();
|
|
65
|
+
if (!q) return otherOptions;
|
|
66
|
+
return otherOptions.filter(
|
|
67
|
+
(o) => o.name.toLowerCase().includes(q) || o.dialCode.includes(q) || o.code.toLowerCase().includes(q),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function updateE164(raw: string, cc: CountryCode) {
|
|
72
|
+
const parsed = parsePhoneNumberFromString(raw, cc);
|
|
73
|
+
value = parsed?.isValid() ? parsed.number : "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function onInput(e: Event) {
|
|
77
|
+
const raw = (e.target as HTMLInputElement).value;
|
|
78
|
+
if (raw.startsWith("+")) {
|
|
79
|
+
const parsed = parsePhoneNumberFromString(raw);
|
|
80
|
+
if (parsed?.country) {
|
|
81
|
+
country = parsed.country;
|
|
82
|
+
display = parsed.formatNational();
|
|
83
|
+
value = parsed.isValid() ? parsed.number : "";
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
display = raw;
|
|
87
|
+
value = "";
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const formatter = new AsYouType(country);
|
|
91
|
+
display = formatter.input(raw);
|
|
92
|
+
updateE164(raw, country);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function pickCountry(code: CountryCode) {
|
|
96
|
+
country = code;
|
|
97
|
+
dropdownOpen = false;
|
|
98
|
+
search = "";
|
|
99
|
+
updateE164(display, code);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function toggleDropdown() {
|
|
103
|
+
dropdownOpen = !dropdownOpen;
|
|
104
|
+
if (dropdownOpen) search = "";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function onBlur() {
|
|
108
|
+
setTimeout(() => (dropdownOpen = false), 150);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
$effect(() => {
|
|
112
|
+
if (!value) return;
|
|
113
|
+
const parsed = parsePhoneNumberFromString(value);
|
|
114
|
+
if (parsed?.country && parsed.country !== country) {
|
|
115
|
+
country = parsed.country;
|
|
116
|
+
display = parsed.formatNational();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
47
119
|
</script>
|
|
48
120
|
|
|
49
|
-
<div class="
|
|
50
|
-
<
|
|
51
|
-
<
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
{
|
|
56
|
-
|
|
57
|
-
|
|
121
|
+
<div class="flex w-full {klass}">
|
|
122
|
+
<div class="relative">
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
class="btn btn-outline rounded-r-none border-r-0 px-3 min-w-[5.5rem] justify-start"
|
|
126
|
+
onclick={toggleDropdown}
|
|
127
|
+
{disabled}
|
|
128
|
+
aria-label="Select country code"
|
|
129
|
+
aria-expanded={dropdownOpen}
|
|
130
|
+
aria-haspopup="listbox"
|
|
131
|
+
>
|
|
132
|
+
<span class="text-lg leading-none">{current?.flag}</span>
|
|
133
|
+
<span class="text-sm font-mono">{current?.dialCode}</span>
|
|
134
|
+
<svg class="w-3 h-3 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
135
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
|
136
|
+
</svg>
|
|
137
|
+
</button>
|
|
138
|
+
|
|
139
|
+
{#if dropdownOpen}
|
|
140
|
+
<div
|
|
141
|
+
class="absolute z-50 mt-1 w-72 max-h-80 overflow-y-auto bg-base-100 border border-base-300 rounded-lg shadow-lg"
|
|
142
|
+
role="listbox"
|
|
143
|
+
>
|
|
144
|
+
<div class="sticky top-0 bg-base-100 p-2 border-b border-base-300">
|
|
145
|
+
<input
|
|
146
|
+
type="text"
|
|
147
|
+
class="input input-sm input-bordered w-full"
|
|
148
|
+
placeholder="Search country…"
|
|
149
|
+
bind:value={search}
|
|
150
|
+
aria-label="Search country"
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
<ul class="py-1">
|
|
154
|
+
{#if !search}
|
|
155
|
+
{#each priorityOptions as opt (opt.code)}
|
|
156
|
+
<li>
|
|
157
|
+
<button
|
|
158
|
+
type="button"
|
|
159
|
+
class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-base-200 {opt.code === country ? 'bg-primary/10' : ''}"
|
|
160
|
+
onclick={() => pickCountry(opt.code)}
|
|
161
|
+
role="option"
|
|
162
|
+
aria-selected={opt.code === country}
|
|
163
|
+
>
|
|
164
|
+
<span class="text-lg leading-none">{opt.flag}</span>
|
|
165
|
+
<span class="flex-1 text-left">{opt.name}</span>
|
|
166
|
+
<span class="text-base-content/60 font-mono text-xs">{opt.dialCode}</span>
|
|
167
|
+
</button>
|
|
168
|
+
</li>
|
|
169
|
+
{/each}
|
|
170
|
+
<li><div class="border-t border-base-300 my-1"></div></li>
|
|
171
|
+
{/if}
|
|
172
|
+
{#each filteredOthers as opt (opt.code)}
|
|
173
|
+
<li>
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-base-200 {opt.code === country ? 'bg-primary/10' : ''}"
|
|
177
|
+
onclick={() => pickCountry(opt.code)}
|
|
178
|
+
role="option"
|
|
179
|
+
aria-selected={opt.code === country}
|
|
180
|
+
>
|
|
181
|
+
<span class="text-lg leading-none">{opt.flag}</span>
|
|
182
|
+
<span class="flex-1 text-left">{opt.name}</span>
|
|
183
|
+
<span class="text-base-content/60 font-mono text-xs">{opt.dialCode}</span>
|
|
184
|
+
</button>
|
|
185
|
+
</li>
|
|
186
|
+
{/each}
|
|
187
|
+
{#if filteredOthers.length === 0}
|
|
188
|
+
<li class="px-3 py-2 text-sm text-base-content/60">No matches</li>
|
|
189
|
+
{/if}
|
|
190
|
+
</ul>
|
|
191
|
+
</div>
|
|
192
|
+
{/if}
|
|
193
|
+
</div>
|
|
58
194
|
|
|
59
195
|
<input
|
|
60
|
-
{id}
|
|
61
|
-
{name}
|
|
62
196
|
type="tel"
|
|
197
|
+
class="input input-bordered rounded-l-none flex-1 min-w-0"
|
|
198
|
+
{id}
|
|
199
|
+
{placeholder}
|
|
63
200
|
{required}
|
|
64
201
|
{disabled}
|
|
65
|
-
|
|
66
|
-
{
|
|
67
|
-
oninput={
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
aria-describedby={error ? `${id}-error` : helpText ? `${id}-help` : undefined}
|
|
71
|
-
aria-invalid={error ? 'true' : 'false'}
|
|
72
|
-
autocomplete="tel"
|
|
202
|
+
{autocomplete}
|
|
203
|
+
value={display}
|
|
204
|
+
oninput={onInput}
|
|
205
|
+
onblur={onBlur}
|
|
206
|
+
aria-describedby={id ? `${id}-help` : undefined}
|
|
73
207
|
/>
|
|
74
208
|
|
|
75
|
-
{#if
|
|
76
|
-
<
|
|
77
|
-
<span id="{id}-error" class="label-text-alt text-error" role="alert">{error}</span>
|
|
78
|
-
</div>
|
|
79
|
-
{:else if helpText}
|
|
80
|
-
<div class="label">
|
|
81
|
-
<span id="{id}-help" class="label-text-alt text-base-content/70">{helpText}</span>
|
|
82
|
-
</div>
|
|
83
|
-
{/if}
|
|
84
|
-
|
|
85
|
-
{#if value && !error}
|
|
86
|
-
<div class="label">
|
|
87
|
-
<span class="label-text-alt {isValid ? 'text-success' : 'text-warning'}">
|
|
88
|
-
{isValid ? '✓ Valid phone number' : '⚠ Invalid phone number'}
|
|
89
|
-
</span>
|
|
90
|
-
</div>
|
|
209
|
+
{#if name}
|
|
210
|
+
<input type="hidden" {name} {value} />
|
|
91
211
|
{/if}
|
|
92
212
|
</div>
|
package/src/lib/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export { default as TaskTypeIcon } from './components/TaskTypeIcon.svelte';
|
|
|
6
6
|
// Components — from JAT IDE
|
|
7
7
|
export { default as SearchDropdown } from './components/SearchDropdown.svelte';
|
|
8
8
|
export { default as FilterDropdown } from './components/FilterDropdown.svelte';
|
|
9
|
+
export { default as ContextMenu } from './components/ContextMenu.svelte';
|
|
9
10
|
export { default as ChipInput } from './components/ChipInput.svelte';
|
|
10
11
|
export { default as ResizableDivider } from './components/ResizableDivider.svelte';
|
|
11
12
|
export { default as DateRangePicker } from './components/DateRangePicker.svelte';
|
|
@@ -76,6 +77,7 @@ export type { ShortenedLink } from './components/LinkShortener.svelte';
|
|
|
76
77
|
export type { SearchDropdownOption, SearchDropdownGroup } from './components/SearchDropdown.svelte';
|
|
77
78
|
export type { ChipSuggestion, SuggestionGroup, ChipInfo } from './components/ChipInput.svelte';
|
|
78
79
|
export type { SortOption } from './components/SortDropdown.svelte';
|
|
80
|
+
export type { ContextMenuItem } from './components/ContextMenu.svelte';
|
|
79
81
|
export type { DisplaySegment } from './components/InlineEdit.svelte';
|
|
80
82
|
export type { ActionButton, ActionDropdown, DropdownItem } from './components/FloatingActionBar.svelte';
|
|
81
83
|
|
|
@@ -131,7 +133,7 @@ export {
|
|
|
131
133
|
statusLabel,
|
|
132
134
|
} from './utils/taskUtils';
|
|
133
135
|
|
|
134
|
-
// Utilities — phone formatting
|
|
136
|
+
// Utilities — phone formatting (legacy US-only)
|
|
135
137
|
export {
|
|
136
138
|
stripPhoneNumber,
|
|
137
139
|
formatPhoneNumber,
|
|
@@ -140,6 +142,17 @@ export {
|
|
|
140
142
|
updatePhoneInput
|
|
141
143
|
} from './utils/phoneFormat';
|
|
142
144
|
|
|
145
|
+
// Utilities — international phone (E.164) — pairs with PhoneInput
|
|
146
|
+
export {
|
|
147
|
+
toE164,
|
|
148
|
+
formatPhoneForDisplay,
|
|
149
|
+
isValidPhone,
|
|
150
|
+
listCountries,
|
|
151
|
+
DEFAULT_COUNTRY,
|
|
152
|
+
PRIORITY_COUNTRIES,
|
|
153
|
+
type CountryOption,
|
|
154
|
+
} from './utils/phone';
|
|
155
|
+
|
|
143
156
|
// Utilities — currency
|
|
144
157
|
export { formatCurrency } from './utils/currency';
|
|
145
158
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parsePhoneNumberFromString,
|
|
3
|
+
type CountryCode,
|
|
4
|
+
getCountries,
|
|
5
|
+
getCountryCallingCode,
|
|
6
|
+
} from "libphonenumber-js";
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_COUNTRY: CountryCode = "US";
|
|
9
|
+
|
|
10
|
+
export function toE164(
|
|
11
|
+
raw: string | null | undefined,
|
|
12
|
+
defaultCountry: CountryCode = DEFAULT_COUNTRY,
|
|
13
|
+
): string | null {
|
|
14
|
+
if (!raw) return null;
|
|
15
|
+
const trimmed = raw.trim();
|
|
16
|
+
if (!trimmed) return null;
|
|
17
|
+
const parsed = parsePhoneNumberFromString(trimmed, defaultCountry);
|
|
18
|
+
if (parsed?.isValid()) return parsed.number;
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatPhoneForDisplay(e164: string | null | undefined): string {
|
|
23
|
+
if (!e164) return "";
|
|
24
|
+
const parsed = parsePhoneNumberFromString(e164);
|
|
25
|
+
if (!parsed) return e164;
|
|
26
|
+
return parsed.country === DEFAULT_COUNTRY ? parsed.formatNational() : parsed.formatInternational();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isValidPhone(
|
|
30
|
+
raw: string | null | undefined,
|
|
31
|
+
defaultCountry: CountryCode = DEFAULT_COUNTRY,
|
|
32
|
+
): boolean {
|
|
33
|
+
if (!raw) return false;
|
|
34
|
+
const parsed = parsePhoneNumberFromString(raw.trim(), defaultCountry);
|
|
35
|
+
return parsed?.isValid() ?? false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type CountryOption = {
|
|
39
|
+
code: CountryCode;
|
|
40
|
+
name: string;
|
|
41
|
+
dialCode: string;
|
|
42
|
+
flag: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const COUNTRY_NAMES: Record<string, string> = {
|
|
46
|
+
US: "United States", CA: "Canada", GB: "United Kingdom", AU: "Australia", NZ: "New Zealand",
|
|
47
|
+
IE: "Ireland", DE: "Germany", FR: "France", ES: "Spain", IT: "Italy", NL: "Netherlands",
|
|
48
|
+
BE: "Belgium", CH: "Switzerland", AT: "Austria", SE: "Sweden", NO: "Norway", DK: "Denmark",
|
|
49
|
+
FI: "Finland", PT: "Portugal", PL: "Poland", CZ: "Czechia", GR: "Greece", MX: "Mexico",
|
|
50
|
+
BR: "Brazil", AR: "Argentina", CL: "Chile", CO: "Colombia", PE: "Peru", JP: "Japan",
|
|
51
|
+
KR: "South Korea", CN: "China", HK: "Hong Kong", TW: "Taiwan", SG: "Singapore", MY: "Malaysia",
|
|
52
|
+
TH: "Thailand", PH: "Philippines", ID: "Indonesia", VN: "Vietnam", IN: "India", PK: "Pakistan",
|
|
53
|
+
BD: "Bangladesh", IL: "Israel", AE: "UAE", SA: "Saudi Arabia", TR: "Turkey", EG: "Egypt",
|
|
54
|
+
ZA: "South Africa", NG: "Nigeria", KE: "Kenya", RU: "Russia", UA: "Ukraine",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function flagEmoji(code: string): string {
|
|
58
|
+
return code
|
|
59
|
+
.toUpperCase()
|
|
60
|
+
.split("")
|
|
61
|
+
.map((c) => String.fromCodePoint(127397 + c.charCodeAt(0)))
|
|
62
|
+
.join("");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let countriesCache: CountryOption[] | null = null;
|
|
66
|
+
export function listCountries(): CountryOption[] {
|
|
67
|
+
if (countriesCache) return countriesCache;
|
|
68
|
+
const all = getCountries()
|
|
69
|
+
.map((code) => ({
|
|
70
|
+
code,
|
|
71
|
+
name: COUNTRY_NAMES[code] ?? code,
|
|
72
|
+
dialCode: `+${getCountryCallingCode(code)}`,
|
|
73
|
+
flag: flagEmoji(code),
|
|
74
|
+
}))
|
|
75
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
76
|
+
countriesCache = all;
|
|
77
|
+
return all;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const PRIORITY_COUNTRIES: CountryCode[] = ["US", "CA", "GB", "AU", "MX"];
|