@joewinke/jatui 0.1.27 → 0.1.29
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/CLAUDE.md +3 -3
- package/LICENSE +21 -0
- package/dist/actions/collapse.d.ts +1 -1
- package/dist/actions/collapse.js +16 -30
- package/dist/actions/listNav.d.ts +134 -0
- package/dist/actions/listNav.js +643 -0
- package/dist/actions/railNav.js +78 -89
- package/dist/components/InlineEdit.svelte +26 -1
- package/dist/components/InlineEdit.svelte.d.ts +13 -0
- package/dist/components/PhoneInput.svelte +70 -20
- package/dist/components/PhoneInput.svelte.d.ts +5 -2
- package/dist/components/ResizableDivider.svelte +148 -18
- package/dist/components/ResizableDivider.svelte.d.ts +14 -3
- package/dist/components/SignaturePad.svelte +13 -6
- package/dist/components/SignaturePad.svelte.d.ts +1 -0
- package/dist/components/ThemeSelector.svelte +61 -2
- package/dist/components/ThemeSelector.svelte.d.ts +4 -0
- package/dist/components/markup.js +59 -62
- package/dist/components/session-nav/transcriptModel.js +49 -66
- package/dist/data/emojis.js +4 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/stores/confirmDialog.svelte.js +5 -5
- package/dist/stores/inputDialog.svelte.js +5 -5
- package/dist/types/booking.js +4 -4
- package/dist/types/comments.js +2 -2
- package/dist/types/messaging.js +3 -3
- package/dist/types/pipeline.js +2 -2
- package/dist/utils/currency.js +2 -4
- package/dist/utils/daisyuiColors.js +25 -27
- package/dist/utils/dateFormatters.js +44 -44
- package/dist/utils/googleMapsLoader.js +46 -100
- package/dist/utils/mentionParser.js +30 -30
- package/dist/utils/phone.js +21 -27
- package/dist/utils/phoneFormat.js +9 -9
- package/dist/utils/taskUtils.js +1 -1
- package/package.json +11 -5
package/dist/actions/railNav.js
CHANGED
|
@@ -60,17 +60,6 @@
|
|
|
60
60
|
* 3. Handle Left/Right in `onAction` and call `rail.bump(host)` on change:
|
|
61
61
|
* onAction: (id, dir, host) => { formData[id] = cycle(opts[id], …, dir); rail.bump(host); }
|
|
62
62
|
*/
|
|
63
|
-
var __assign = (this && this.__assign) || function () {
|
|
64
|
-
__assign = Object.assign || function(t) {
|
|
65
|
-
for (var s, i = 1, n = arguments.length; i < n; i++) {
|
|
66
|
-
s = arguments[i];
|
|
67
|
-
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
|
|
68
|
-
t[p] = s[p];
|
|
69
|
-
}
|
|
70
|
-
return t;
|
|
71
|
-
};
|
|
72
|
-
return __assign.apply(this, arguments);
|
|
73
|
-
};
|
|
74
63
|
// ─── Exported helpers ──────────────────────────────────────────────────────────
|
|
75
64
|
/**
|
|
76
65
|
* Cycle through `list`, returning the item `dir` steps from `current`.
|
|
@@ -84,34 +73,39 @@ var __assign = (this && this.__assign) || function () {
|
|
|
84
73
|
export function cycle(list, current, dir) {
|
|
85
74
|
if (list.length === 0)
|
|
86
75
|
return current;
|
|
87
|
-
|
|
76
|
+
const i = list.indexOf(current);
|
|
88
77
|
// When `current` is not in the list, treat it as if we're at position -1
|
|
89
78
|
// so that `dir=1` lands at index 0 (the first item) and `dir=-1` lands at
|
|
90
79
|
// the last item — matching the "not found → start from the beginning" idiom.
|
|
91
|
-
|
|
80
|
+
const base = i === -1 ? -1 : i;
|
|
92
81
|
return list[(((base + dir) % list.length) + list.length) % list.length];
|
|
93
82
|
}
|
|
94
83
|
// ─── Internals ─────────────────────────────────────────────────────────────────
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
84
|
+
const BUMP_DURATION_MS = 280;
|
|
85
|
+
const RAIL_FOCUS_CLASS = 'rail-focus';
|
|
86
|
+
const RAIL_CYCLED_CLASS = 'rail-cycled';
|
|
98
87
|
function resolveOrder(opts) {
|
|
99
88
|
return typeof opts.order === 'function' ? opts.order() : opts.order;
|
|
100
89
|
}
|
|
101
90
|
function resolveAttr(opts) {
|
|
102
|
-
|
|
103
|
-
return (_a = opts.attr) !== null && _a !== void 0 ? _a : 'data-rail-id';
|
|
91
|
+
return opts.attr ?? 'data-rail-id';
|
|
104
92
|
}
|
|
105
93
|
/** Return all currently-rendered rung hosts in the rail order. */
|
|
106
94
|
function resolveHosts(optsRef) {
|
|
107
|
-
|
|
95
|
+
const { container } = optsRef.current;
|
|
108
96
|
if (!container)
|
|
109
97
|
return [];
|
|
110
|
-
|
|
111
|
-
|
|
98
|
+
const attr = resolveAttr(optsRef.current);
|
|
99
|
+
const order = resolveOrder(optsRef.current);
|
|
112
100
|
return order
|
|
113
|
-
.map(
|
|
114
|
-
|
|
101
|
+
.map((id) => container.querySelector(`[${attr}="${id}"]`))
|
|
102
|
+
// checkVisibility() correctly returns false for elements inside a closed <details>
|
|
103
|
+
// (browsers keep offsetParent non-null for those, so that check alone is insufficient).
|
|
104
|
+
// Where checkVisibility is unavailable (jsdom, older engines) treat the rung as
|
|
105
|
+
// visible — a rendered element in `order` is navigable by default; only the
|
|
106
|
+
// closed-<details> case needs the extra exclusion, and that only exists in a
|
|
107
|
+
// real layout engine.
|
|
108
|
+
.filter((el) => !!el && (el.checkVisibility?.() ?? true));
|
|
115
109
|
}
|
|
116
110
|
/**
|
|
117
111
|
* Focus the most appropriate control inside a rung host:
|
|
@@ -126,17 +120,17 @@ function resolveHosts(optsRef) {
|
|
|
126
120
|
* 4. Last resort: focus the host itself (requires tabindex on the host).
|
|
127
121
|
*/
|
|
128
122
|
function focusRailHost(host) {
|
|
129
|
-
var _a;
|
|
130
123
|
if (host.matches('input,select,textarea,button,[contenteditable="true"],[tabindex]')) {
|
|
131
124
|
host.focus();
|
|
132
125
|
return;
|
|
133
126
|
}
|
|
134
|
-
|
|
135
|
-
|
|
127
|
+
const inner = host.querySelector('[contenteditable="true"],input,select,textarea') ??
|
|
128
|
+
host.querySelector('.sd-trigger,button');
|
|
129
|
+
(inner ?? host).focus();
|
|
136
130
|
}
|
|
137
131
|
function makeController(optsRef) {
|
|
138
|
-
|
|
139
|
-
|
|
132
|
+
let _currentRailId = '';
|
|
133
|
+
let _lastFocusedHost = null;
|
|
140
134
|
function applyFocusClass(host) {
|
|
141
135
|
if (_lastFocusedHost && _lastFocusedHost !== host) {
|
|
142
136
|
_lastFocusedHost.classList.remove(RAIL_FOCUS_CLASS);
|
|
@@ -151,14 +145,14 @@ function makeController(optsRef) {
|
|
|
151
145
|
host.classList.remove(RAIL_CYCLED_CLASS);
|
|
152
146
|
void host.offsetWidth; // reflow so the animation restarts on rapid presses
|
|
153
147
|
host.classList.add(RAIL_CYCLED_CLASS);
|
|
154
|
-
setTimeout(
|
|
148
|
+
setTimeout(() => host.classList.remove(RAIL_CYCLED_CLASS), BUMP_DURATION_MS);
|
|
155
149
|
}
|
|
156
150
|
function focus(id) {
|
|
157
|
-
|
|
151
|
+
const { container } = optsRef.current;
|
|
158
152
|
if (!container)
|
|
159
153
|
return;
|
|
160
|
-
|
|
161
|
-
|
|
154
|
+
const attr = resolveAttr(optsRef.current);
|
|
155
|
+
const host = container.querySelector(`[${attr}="${id}"]`);
|
|
162
156
|
if (host) {
|
|
163
157
|
focusRailHost(host);
|
|
164
158
|
applyFocusClass(host);
|
|
@@ -169,21 +163,20 @@ function makeController(optsRef) {
|
|
|
169
163
|
}
|
|
170
164
|
}
|
|
171
165
|
function moveRung(dir) {
|
|
172
|
-
|
|
173
|
-
var hosts = resolveHosts(optsRef);
|
|
166
|
+
const hosts = resolveHosts(optsRef);
|
|
174
167
|
if (hosts.length === 0)
|
|
175
168
|
return;
|
|
176
|
-
|
|
177
|
-
|
|
169
|
+
const attr = resolveAttr(optsRef.current);
|
|
170
|
+
const ids = hosts.map((h) => h.getAttribute(attr));
|
|
178
171
|
// Anchor on the actually-focused rung first. Fall back to the last tracked
|
|
179
172
|
// rung (covers focus parked on the container after an Escape pop-out).
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
173
|
+
const active = document.activeElement;
|
|
174
|
+
const activeFocused = (active?.closest(`[${attr}]`))?.getAttribute(attr);
|
|
175
|
+
const fromId = activeFocused && ids.includes(activeFocused) ? activeFocused : _currentRailId;
|
|
176
|
+
let pos = fromId ? ids.indexOf(fromId) : -1;
|
|
184
177
|
if (pos === -1)
|
|
185
178
|
pos = dir === 1 ? -1 : hosts.length;
|
|
186
|
-
|
|
179
|
+
const next = (((pos + dir) % hosts.length) + hosts.length) % hosts.length;
|
|
187
180
|
focusRailHost(hosts[next]);
|
|
188
181
|
applyFocusClass(hosts[next]);
|
|
189
182
|
_currentRailId = ids[next];
|
|
@@ -191,10 +184,9 @@ function makeController(optsRef) {
|
|
|
191
184
|
function refresh() {
|
|
192
185
|
// Re-derive hosts; re-apply focus class if the current rung is still rendered
|
|
193
186
|
// (DOM may have been reconstructed by Svelte's reactive updates).
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
for (
|
|
197
|
-
var h = hosts_1[_i];
|
|
187
|
+
const hosts = resolveHosts(optsRef);
|
|
188
|
+
const attr = resolveAttr(optsRef.current);
|
|
189
|
+
for (const h of hosts) {
|
|
198
190
|
if (h.getAttribute(attr) === _currentRailId) {
|
|
199
191
|
applyFocusClass(h);
|
|
200
192
|
return;
|
|
@@ -202,11 +194,10 @@ function makeController(optsRef) {
|
|
|
202
194
|
}
|
|
203
195
|
}
|
|
204
196
|
function handleFocusin(event) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
var id = host === null || host === void 0 ? void 0 : host.getAttribute(attr);
|
|
197
|
+
const attr = resolveAttr(optsRef.current);
|
|
198
|
+
const order = resolveOrder(optsRef.current);
|
|
199
|
+
const host = event.target?.closest(`[${attr}]`);
|
|
200
|
+
const id = host?.getAttribute(attr);
|
|
210
201
|
// Only track rungs that are in the rail order. Tabbing into an off-rail
|
|
211
202
|
// control (e.g. a description textarea) leaves the last real rung intact
|
|
212
203
|
// so Up / Down can resume from it after the user tabs back out.
|
|
@@ -216,18 +207,17 @@ function makeController(optsRef) {
|
|
|
216
207
|
}
|
|
217
208
|
}
|
|
218
209
|
function handleKeydown(event) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
var isTextField = !!el &&
|
|
210
|
+
const opts = optsRef.current;
|
|
211
|
+
const el = document.activeElement;
|
|
212
|
+
const tag = el?.tagName;
|
|
213
|
+
const isTextField = !!el &&
|
|
224
214
|
(tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable === true);
|
|
225
|
-
|
|
226
|
-
|
|
215
|
+
const isMultiline = !!el && (tag === 'TEXTAREA' || el.isContentEditable === true);
|
|
216
|
+
const hasMod = event.metaKey || event.ctrlKey || event.altKey;
|
|
227
217
|
// Modal trap — stop non-text keystrokes from bubbling to window-level page
|
|
228
218
|
// listeners (e.g., a listNav attached to `window`). Text-field keystrokes
|
|
229
219
|
// propagate normally so typing in the drawer doesn't interfere.
|
|
230
|
-
|
|
220
|
+
const trap = opts.trap !== false;
|
|
231
221
|
if (trap && !isTextField)
|
|
232
222
|
event.stopPropagation();
|
|
233
223
|
// Escape cascade:
|
|
@@ -235,33 +225,33 @@ function makeController(optsRef) {
|
|
|
235
225
|
// 2. focus inside a descendant control → pop back to container
|
|
236
226
|
// 3. focus already on the container (or just popped) → call onEscape
|
|
237
227
|
if (event.key === 'Escape' && !hasMod) {
|
|
238
|
-
|
|
239
|
-
if (
|
|
228
|
+
const popoverOpen = opts.isPopoverOpen?.() ?? false;
|
|
229
|
+
if (popoverOpen) {
|
|
240
230
|
event.preventDefault();
|
|
241
|
-
|
|
231
|
+
opts.onPopoverEscape?.();
|
|
242
232
|
return true;
|
|
243
233
|
}
|
|
244
|
-
|
|
234
|
+
const { container } = opts;
|
|
245
235
|
if (container && el && el !== container && container.contains(el)) {
|
|
246
236
|
event.preventDefault();
|
|
247
237
|
container.focus();
|
|
248
238
|
return true;
|
|
249
239
|
}
|
|
250
240
|
event.preventDefault();
|
|
251
|
-
|
|
241
|
+
opts.onEscape?.();
|
|
252
242
|
return true;
|
|
253
243
|
}
|
|
254
244
|
// Rail motion — suppressed when a popover is open or a modifier is held.
|
|
255
|
-
|
|
245
|
+
const popoverOpen = opts.isPopoverOpen?.() ?? false;
|
|
256
246
|
if (!hasMod && !popoverOpen) {
|
|
257
247
|
// Vertical: ArrowDown / j step down; ArrowUp / k step up.
|
|
258
248
|
// j / k are blocked in ANY text field; arrows are blocked only inside
|
|
259
249
|
// multiline fields so the caret can still move line-by-line.
|
|
260
|
-
|
|
261
|
-
|
|
250
|
+
const goDown = event.key === 'ArrowDown' || event.key === 'j';
|
|
251
|
+
const goUp = event.key === 'ArrowUp' || event.key === 'k';
|
|
262
252
|
if (goDown || goUp) {
|
|
263
|
-
|
|
264
|
-
|
|
253
|
+
const isVimKey = event.key === 'j' || event.key === 'k';
|
|
254
|
+
const blocked = isVimKey ? isTextField : isMultiline;
|
|
265
255
|
if (!blocked) {
|
|
266
256
|
event.preventDefault();
|
|
267
257
|
moveRung(goDown ? 1 : -1);
|
|
@@ -273,14 +263,15 @@ function makeController(optsRef) {
|
|
|
273
263
|
if ((event.key === 'ArrowLeft' || event.key === 'ArrowRight') &&
|
|
274
264
|
!isTextField) {
|
|
275
265
|
event.preventDefault();
|
|
276
|
-
|
|
266
|
+
const attr = resolveAttr(opts);
|
|
277
267
|
// Prefer the element that actually has DOM focus, then fall back to
|
|
278
268
|
// the last tracked rung (focus parked on the container after pop-out).
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
269
|
+
const focusedHost = (el?.closest(`[${attr}]`)) ??
|
|
270
|
+
(opts.container && _currentRailId
|
|
271
|
+
? opts.container.querySelector(`[${attr}="${_currentRailId}"]`)
|
|
272
|
+
: null);
|
|
282
273
|
if (focusedHost && opts.onAction) {
|
|
283
|
-
|
|
274
|
+
const rungId = focusedHost.getAttribute(attr) ?? '';
|
|
284
275
|
opts.onAction(rungId, event.key === 'ArrowRight' ? 1 : -1, focusedHost);
|
|
285
276
|
}
|
|
286
277
|
return true;
|
|
@@ -289,13 +280,13 @@ function makeController(optsRef) {
|
|
|
289
280
|
return false;
|
|
290
281
|
}
|
|
291
282
|
return {
|
|
292
|
-
handleKeydown
|
|
293
|
-
handleFocusin
|
|
294
|
-
moveRung
|
|
295
|
-
focus
|
|
296
|
-
refresh
|
|
297
|
-
currentId:
|
|
298
|
-
bump
|
|
283
|
+
handleKeydown,
|
|
284
|
+
handleFocusin,
|
|
285
|
+
moveRung,
|
|
286
|
+
focus,
|
|
287
|
+
refresh,
|
|
288
|
+
currentId: () => _currentRailId,
|
|
289
|
+
bump,
|
|
299
290
|
};
|
|
300
291
|
}
|
|
301
292
|
// ─── Composable ────────────────────────────────────────────────────────────────
|
|
@@ -310,7 +301,7 @@ function makeController(optsRef) {
|
|
|
310
301
|
* panel.addEventListener('focusin', (e) => rail.handleFocusin(e));
|
|
311
302
|
*/
|
|
312
303
|
export function createRailNav(options) {
|
|
313
|
-
|
|
304
|
+
const ref = { current: options };
|
|
314
305
|
return makeController(ref);
|
|
315
306
|
}
|
|
316
307
|
// ─── Action ────────────────────────────────────────────────────────────────────
|
|
@@ -325,10 +316,9 @@ export function createRailNav(options) {
|
|
|
325
316
|
* <div data-rail-id="priority" tabindex="-1">…</div>
|
|
326
317
|
* </div>
|
|
327
318
|
*/
|
|
328
|
-
export
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
var controller = makeController(ref);
|
|
319
|
+
export const railNav = (node, initial = { order: [] }) => {
|
|
320
|
+
const ref = { current: { ...initial, container: node } };
|
|
321
|
+
const controller = makeController(ref);
|
|
332
322
|
function onKeydown(e) {
|
|
333
323
|
controller.handleKeydown(e);
|
|
334
324
|
}
|
|
@@ -338,12 +328,11 @@ export var railNav = function (node, initial) {
|
|
|
338
328
|
node.addEventListener('keydown', onKeydown);
|
|
339
329
|
node.addEventListener('focusin', onFocusin);
|
|
340
330
|
return {
|
|
341
|
-
update:
|
|
342
|
-
|
|
343
|
-
ref.current = __assign(__assign({}, next), { container: node });
|
|
331
|
+
update(next = { order: [] }) {
|
|
332
|
+
ref.current = { ...next, container: node };
|
|
344
333
|
controller.refresh();
|
|
345
334
|
},
|
|
346
|
-
destroy
|
|
335
|
+
destroy() {
|
|
347
336
|
node.removeEventListener('keydown', onKeydown);
|
|
348
337
|
node.removeEventListener('focusin', onFocusin);
|
|
349
338
|
},
|
|
@@ -46,6 +46,17 @@
|
|
|
46
46
|
truncate?: boolean;
|
|
47
47
|
/** Optional display segments for formula-aware rendering */
|
|
48
48
|
displaySegments?: DisplaySegment[];
|
|
49
|
+
/**
|
|
50
|
+
* Optional Svelte action applied to the textarea while editing. Lets the
|
|
51
|
+
* host inject richer paste/undo handling without this library depending on
|
|
52
|
+
* it. No-op by default.
|
|
53
|
+
*/
|
|
54
|
+
pasteAction?: (node: HTMLTextAreaElement) => { destroy?: () => void } | void;
|
|
55
|
+
/**
|
|
56
|
+
* Optional guard consulted on blur: return true to keep the editor open
|
|
57
|
+
* (e.g. a paste-undo toast is still showing). Receives the textarea node.
|
|
58
|
+
*/
|
|
59
|
+
keepOpenOnBlur?: (node: HTMLTextAreaElement) => boolean;
|
|
49
60
|
}
|
|
50
61
|
|
|
51
62
|
let {
|
|
@@ -58,9 +69,14 @@
|
|
|
58
69
|
rows = 3,
|
|
59
70
|
showButtons = false,
|
|
60
71
|
truncate = false,
|
|
61
|
-
displaySegments = []
|
|
72
|
+
displaySegments = [],
|
|
73
|
+
pasteAction,
|
|
74
|
+
keepOpenOnBlur
|
|
62
75
|
}: Props = $props();
|
|
63
76
|
|
|
77
|
+
// No-op action so `use:` always has a valid target.
|
|
78
|
+
const applyPaste = (node: HTMLTextAreaElement) => pasteAction?.(node);
|
|
79
|
+
|
|
64
80
|
// --- Formula rendering ---
|
|
65
81
|
let hasFormulas = $derived(displaySegments.length > 0);
|
|
66
82
|
|
|
@@ -127,6 +143,14 @@
|
|
|
127
143
|
// Handle blur (auto-save when not using buttons)
|
|
128
144
|
function handleBlur() {
|
|
129
145
|
if (!showButtons) {
|
|
146
|
+
// Host may veto auto-save (e.g. a paste-undo toast is still available).
|
|
147
|
+
if (
|
|
148
|
+
type === 'textarea' &&
|
|
149
|
+
inputElement instanceof HTMLTextAreaElement &&
|
|
150
|
+
keepOpenOnBlur?.(inputElement)
|
|
151
|
+
) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
130
154
|
saveValue();
|
|
131
155
|
}
|
|
132
156
|
}
|
|
@@ -178,6 +202,7 @@
|
|
|
178
202
|
onblur={handleBlur}
|
|
179
203
|
use:autofocus
|
|
180
204
|
use:autoresize
|
|
205
|
+
use:applyPaste
|
|
181
206
|
></textarea>
|
|
182
207
|
{:else}
|
|
183
208
|
<input
|
|
@@ -39,6 +39,19 @@ interface Props {
|
|
|
39
39
|
truncate?: boolean;
|
|
40
40
|
/** Optional display segments for formula-aware rendering */
|
|
41
41
|
displaySegments?: DisplaySegment[];
|
|
42
|
+
/**
|
|
43
|
+
* Optional Svelte action applied to the textarea while editing. Lets the
|
|
44
|
+
* host inject richer paste/undo handling without this library depending on
|
|
45
|
+
* it. No-op by default.
|
|
46
|
+
*/
|
|
47
|
+
pasteAction?: (node: HTMLTextAreaElement) => {
|
|
48
|
+
destroy?: () => void;
|
|
49
|
+
} | void;
|
|
50
|
+
/**
|
|
51
|
+
* Optional guard consulted on blur: return true to keep the editor open
|
|
52
|
+
* (e.g. a paste-undo toast is still showing). Receives the textarea node.
|
|
53
|
+
*/
|
|
54
|
+
keepOpenOnBlur?: (node: HTMLTextAreaElement) => boolean;
|
|
42
55
|
}
|
|
43
56
|
declare const InlineEdit: import("svelte").Component<Props, {}, "">;
|
|
44
57
|
type InlineEdit = ReturnType<typeof InlineEdit>;
|
|
@@ -3,13 +3,22 @@
|
|
|
3
3
|
* PhoneInput Component — International phone number input.
|
|
4
4
|
*
|
|
5
5
|
* Country selector dropdown (flag + dial code + search) alongside a tel input.
|
|
6
|
-
* Formats numbers with libphonenumber-js AsYouType as the user types
|
|
6
|
+
* Formats numbers with libphonenumber-js AsYouType as the user types, and caps
|
|
7
|
+
* national digits at the country's example-number length so users can't overtype.
|
|
7
8
|
* 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
|
+
* named `name` so it submits cleanly with native `<form>` actions. Also fires the
|
|
10
|
+
* optional `onChange(value)` callback on every change.
|
|
9
11
|
*
|
|
10
12
|
* Auto-detects country when the user's input begins with a `+` prefix.
|
|
11
13
|
*/
|
|
12
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
AsYouType,
|
|
16
|
+
parsePhoneNumberFromString,
|
|
17
|
+
getCountryCallingCode,
|
|
18
|
+
getExampleNumber,
|
|
19
|
+
type CountryCode,
|
|
20
|
+
} from "libphonenumber-js";
|
|
21
|
+
import examples from "libphonenumber-js/examples.mobile.json";
|
|
13
22
|
import { listCountries, PRIORITY_COUNTRIES, type CountryOption } from "../utils/phone";
|
|
14
23
|
|
|
15
24
|
type Props = {
|
|
@@ -22,6 +31,7 @@
|
|
|
22
31
|
class?: string;
|
|
23
32
|
disabled?: boolean;
|
|
24
33
|
autocomplete?: HTMLInputElement["autocomplete"];
|
|
34
|
+
onChange?: (value: string) => void;
|
|
25
35
|
};
|
|
26
36
|
|
|
27
37
|
let {
|
|
@@ -34,8 +44,25 @@
|
|
|
34
44
|
class: klass = "",
|
|
35
45
|
disabled = false,
|
|
36
46
|
autocomplete = "tel" as HTMLInputElement["autocomplete"],
|
|
47
|
+
onChange,
|
|
37
48
|
}: Props = $props();
|
|
38
49
|
|
|
50
|
+
function maxNationalDigits(cc: CountryCode): number {
|
|
51
|
+
const example = getExampleNumber(cc, examples);
|
|
52
|
+
return example?.nationalNumber.length ?? 15;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function capNationalDigits(raw: string, cc: CountryCode, max: number): string {
|
|
56
|
+
const allDigits = raw.replace(/\D/g, "");
|
|
57
|
+
if (!allDigits) return "";
|
|
58
|
+
const callingCode = getCountryCallingCode(cc);
|
|
59
|
+
const stripped =
|
|
60
|
+
allDigits.length > max && allDigits.startsWith(callingCode)
|
|
61
|
+
? allDigits.slice(callingCode.length)
|
|
62
|
+
: allDigits;
|
|
63
|
+
return stripped.slice(0, max);
|
|
64
|
+
}
|
|
65
|
+
|
|
39
66
|
const all = listCountries();
|
|
40
67
|
const priorityOptions = PRIORITY_COUNTRIES.map((c) => all.find((o) => o.code === c)).filter(
|
|
41
68
|
(o): o is CountryOption => !!o,
|
|
@@ -68,28 +95,49 @@
|
|
|
68
95
|
);
|
|
69
96
|
});
|
|
70
97
|
|
|
98
|
+
function setValue(next: string) {
|
|
99
|
+
value = next;
|
|
100
|
+
onChange?.(next);
|
|
101
|
+
}
|
|
102
|
+
|
|
71
103
|
function updateE164(raw: string, cc: CountryCode) {
|
|
72
104
|
const parsed = parsePhoneNumberFromString(raw, cc);
|
|
73
|
-
|
|
105
|
+
setValue(parsed?.isValid() ? parsed.number : display || "");
|
|
74
106
|
}
|
|
75
107
|
|
|
76
108
|
function onInput(e: Event) {
|
|
77
|
-
const
|
|
109
|
+
const inputEl = e.target as HTMLInputElement;
|
|
110
|
+
const raw = inputEl.value;
|
|
78
111
|
if (raw.startsWith("+")) {
|
|
79
|
-
const
|
|
112
|
+
const allDigits = raw.replace(/\D/g, "");
|
|
113
|
+
let parsed = parsePhoneNumberFromString(`+${allDigits}`);
|
|
114
|
+
for (let len = allDigits.length - 1; !parsed?.country && len >= 1; len--) {
|
|
115
|
+
parsed = parsePhoneNumberFromString(`+${allDigits.slice(0, len)}`);
|
|
116
|
+
}
|
|
80
117
|
if (parsed?.country) {
|
|
81
118
|
country = parsed.country;
|
|
82
|
-
|
|
83
|
-
|
|
119
|
+
const max = maxNationalDigits(parsed.country);
|
|
120
|
+
const cc = getCountryCallingCode(parsed.country);
|
|
121
|
+
const national = allDigits.startsWith(cc) ? allDigits.slice(cc.length) : allDigits;
|
|
122
|
+
const capped = national.slice(0, max);
|
|
123
|
+
const formatter = new AsYouType();
|
|
124
|
+
display = formatter.input(`+${cc}${capped}`);
|
|
125
|
+
inputEl.value = display;
|
|
126
|
+
const reparsed = parsePhoneNumberFromString(`+${cc}${capped}`);
|
|
127
|
+
setValue(reparsed?.isValid() ? reparsed.number : display || "");
|
|
84
128
|
return;
|
|
85
129
|
}
|
|
86
|
-
display =
|
|
87
|
-
value =
|
|
130
|
+
display = `+${allDigits.slice(0, 15)}`;
|
|
131
|
+
inputEl.value = display;
|
|
132
|
+
setValue("");
|
|
88
133
|
return;
|
|
89
134
|
}
|
|
135
|
+
const max = maxNationalDigits(country);
|
|
136
|
+
const nationalDigits = capNationalDigits(raw, country, max);
|
|
90
137
|
const formatter = new AsYouType(country);
|
|
91
|
-
display = formatter.input(
|
|
92
|
-
|
|
138
|
+
display = formatter.input(nationalDigits);
|
|
139
|
+
inputEl.value = display;
|
|
140
|
+
updateE164(nationalDigits, country);
|
|
93
141
|
}
|
|
94
142
|
|
|
95
143
|
function pickCountry(code: CountryCode) {
|
|
@@ -118,11 +166,13 @@
|
|
|
118
166
|
});
|
|
119
167
|
</script>
|
|
120
168
|
|
|
121
|
-
<div
|
|
169
|
+
<div
|
|
170
|
+
class="flex w-full rounded-field has-[input:focus]:ring-2 has-[input:focus]:ring-primary/40 transition-shadow {klass}"
|
|
171
|
+
>
|
|
122
172
|
<div class="relative">
|
|
123
173
|
<button
|
|
124
174
|
type="button"
|
|
125
|
-
class="btn btn-outline rounded-r-none border-r-0 px-3 min-w-[5.5rem] justify-start"
|
|
175
|
+
class="btn btn-outline rounded-r-none border-r-0 px-3 min-w-[5.5rem] justify-start focus-visible:outline-0"
|
|
126
176
|
onclick={toggleDropdown}
|
|
127
177
|
{disabled}
|
|
128
178
|
aria-label="Select country code"
|
|
@@ -131,14 +181,14 @@
|
|
|
131
181
|
>
|
|
132
182
|
<span class="text-lg leading-none">{current?.flag}</span>
|
|
133
183
|
<span class="text-sm font-mono">{current?.dialCode}</span>
|
|
134
|
-
<svg class="w-3 h-3 opacity-
|
|
184
|
+
<svg class="w-3 h-3 opacity-45" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
135
185
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
|
136
186
|
</svg>
|
|
137
187
|
</button>
|
|
138
188
|
|
|
139
189
|
{#if dropdownOpen}
|
|
140
190
|
<div
|
|
141
|
-
class="absolute z-50 mt-1 w-72 max-h-80 overflow-y-auto bg-base-
|
|
191
|
+
class="absolute z-50 mt-1 w-72 max-h-80 overflow-y-auto bg-base-100 border border-base-300 rounded-box shadow-lg"
|
|
142
192
|
role="listbox"
|
|
143
193
|
>
|
|
144
194
|
<div class="sticky top-0 bg-base-100 p-2 border-b border-base-300">
|
|
@@ -163,7 +213,7 @@
|
|
|
163
213
|
>
|
|
164
214
|
<span class="text-lg leading-none">{opt.flag}</span>
|
|
165
215
|
<span class="flex-1 text-left">{opt.name}</span>
|
|
166
|
-
<span class="text-base-content/
|
|
216
|
+
<span class="text-base-content/45 font-mono text-xs">{opt.dialCode}</span>
|
|
167
217
|
</button>
|
|
168
218
|
</li>
|
|
169
219
|
{/each}
|
|
@@ -180,12 +230,12 @@
|
|
|
180
230
|
>
|
|
181
231
|
<span class="text-lg leading-none">{opt.flag}</span>
|
|
182
232
|
<span class="flex-1 text-left">{opt.name}</span>
|
|
183
|
-
<span class="text-base-content/
|
|
233
|
+
<span class="text-base-content/45 font-mono text-xs">{opt.dialCode}</span>
|
|
184
234
|
</button>
|
|
185
235
|
</li>
|
|
186
236
|
{/each}
|
|
187
237
|
{#if filteredOthers.length === 0}
|
|
188
|
-
<li class="px-3 py-2 text-sm text-base-content/
|
|
238
|
+
<li class="px-3 py-2 text-sm text-base-content/45">No matches</li>
|
|
189
239
|
{/if}
|
|
190
240
|
</ul>
|
|
191
241
|
</div>
|
|
@@ -194,7 +244,7 @@
|
|
|
194
244
|
|
|
195
245
|
<input
|
|
196
246
|
type="tel"
|
|
197
|
-
class="input input-bordered rounded-l-none flex-1 min-w-0"
|
|
247
|
+
class="input input-bordered rounded-l-none flex-1 min-w-0 focus:outline-0"
|
|
198
248
|
{id}
|
|
199
249
|
{placeholder}
|
|
200
250
|
{required}
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
* PhoneInput Component — International phone number input.
|
|
3
3
|
*
|
|
4
4
|
* Country selector dropdown (flag + dial code + search) alongside a tel input.
|
|
5
|
-
* Formats numbers with libphonenumber-js AsYouType as the user types
|
|
5
|
+
* Formats numbers with libphonenumber-js AsYouType as the user types, and caps
|
|
6
|
+
* national digits at the country's example-number length so users can't overtype.
|
|
6
7
|
* Exposes the parsed E.164 value via `bind:value` and a hidden form field
|
|
7
|
-
* named `name` so it submits cleanly with native `<form>` actions.
|
|
8
|
+
* named `name` so it submits cleanly with native `<form>` actions. Also fires the
|
|
9
|
+
* optional `onChange(value)` callback on every change.
|
|
8
10
|
*
|
|
9
11
|
* Auto-detects country when the user's input begins with a `+` prefix.
|
|
10
12
|
*/
|
|
@@ -19,6 +21,7 @@ type Props = {
|
|
|
19
21
|
class?: string;
|
|
20
22
|
disabled?: boolean;
|
|
21
23
|
autocomplete?: HTMLInputElement["autocomplete"];
|
|
24
|
+
onChange?: (value: string) => void;
|
|
22
25
|
};
|
|
23
26
|
declare const PhoneInput: import("svelte").Component<Props, {}, "value">;
|
|
24
27
|
type PhoneInput = ReturnType<typeof PhoneInput>;
|