@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.
Files changed (37) hide show
  1. package/CLAUDE.md +3 -3
  2. package/LICENSE +21 -0
  3. package/dist/actions/collapse.d.ts +1 -1
  4. package/dist/actions/collapse.js +16 -30
  5. package/dist/actions/listNav.d.ts +134 -0
  6. package/dist/actions/listNav.js +643 -0
  7. package/dist/actions/railNav.js +78 -89
  8. package/dist/components/InlineEdit.svelte +26 -1
  9. package/dist/components/InlineEdit.svelte.d.ts +13 -0
  10. package/dist/components/PhoneInput.svelte +70 -20
  11. package/dist/components/PhoneInput.svelte.d.ts +5 -2
  12. package/dist/components/ResizableDivider.svelte +148 -18
  13. package/dist/components/ResizableDivider.svelte.d.ts +14 -3
  14. package/dist/components/SignaturePad.svelte +13 -6
  15. package/dist/components/SignaturePad.svelte.d.ts +1 -0
  16. package/dist/components/ThemeSelector.svelte +61 -2
  17. package/dist/components/ThemeSelector.svelte.d.ts +4 -0
  18. package/dist/components/markup.js +59 -62
  19. package/dist/components/session-nav/transcriptModel.js +49 -66
  20. package/dist/data/emojis.js +4 -4
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.js +1 -0
  23. package/dist/stores/confirmDialog.svelte.js +5 -5
  24. package/dist/stores/inputDialog.svelte.js +5 -5
  25. package/dist/types/booking.js +4 -4
  26. package/dist/types/comments.js +2 -2
  27. package/dist/types/messaging.js +3 -3
  28. package/dist/types/pipeline.js +2 -2
  29. package/dist/utils/currency.js +2 -4
  30. package/dist/utils/daisyuiColors.js +25 -27
  31. package/dist/utils/dateFormatters.js +44 -44
  32. package/dist/utils/googleMapsLoader.js +46 -100
  33. package/dist/utils/mentionParser.js +30 -30
  34. package/dist/utils/phone.js +21 -27
  35. package/dist/utils/phoneFormat.js +9 -9
  36. package/dist/utils/taskUtils.js +1 -1
  37. package/package.json +11 -5
@@ -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
- var i = list.indexOf(current);
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
- var base = i === -1 ? -1 : i;
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
- var BUMP_DURATION_MS = 280;
96
- var RAIL_FOCUS_CLASS = 'rail-focus';
97
- var RAIL_CYCLED_CLASS = 'rail-cycled';
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
- var _a;
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
- var container = optsRef.current.container;
95
+ const { container } = optsRef.current;
108
96
  if (!container)
109
97
  return [];
110
- var attr = resolveAttr(optsRef.current);
111
- var order = resolveOrder(optsRef.current);
98
+ const attr = resolveAttr(optsRef.current);
99
+ const order = resolveOrder(optsRef.current);
112
100
  return order
113
- .map(function (id) { return container.querySelector("[".concat(attr, "=\"").concat(id, "\"]")); })
114
- .filter(function (el) { return !!el; });
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
- var inner = (_a = host.querySelector('[contenteditable="true"],input,select,textarea')) !== null && _a !== void 0 ? _a : host.querySelector('.sd-trigger,button');
135
- (inner !== null && inner !== void 0 ? inner : host).focus();
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
- var _currentRailId = '';
139
- var _lastFocusedHost = null;
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(function () { return host.classList.remove(RAIL_CYCLED_CLASS); }, BUMP_DURATION_MS);
148
+ setTimeout(() => host.classList.remove(RAIL_CYCLED_CLASS), BUMP_DURATION_MS);
155
149
  }
156
150
  function focus(id) {
157
- var container = optsRef.current.container;
151
+ const { container } = optsRef.current;
158
152
  if (!container)
159
153
  return;
160
- var attr = resolveAttr(optsRef.current);
161
- var host = container.querySelector("[".concat(attr, "=\"").concat(id, "\"]"));
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
- var _a;
173
- var hosts = resolveHosts(optsRef);
166
+ const hosts = resolveHosts(optsRef);
174
167
  if (hosts.length === 0)
175
168
  return;
176
- var attr = resolveAttr(optsRef.current);
177
- var ids = hosts.map(function (h) { return h.getAttribute(attr); });
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
- var active = document.activeElement;
181
- var activeFocused = (_a = (active === null || active === void 0 ? void 0 : active.closest("[".concat(attr, "]")))) === null || _a === void 0 ? void 0 : _a.getAttribute(attr);
182
- var fromId = activeFocused && ids.includes(activeFocused) ? activeFocused : _currentRailId;
183
- var pos = fromId ? ids.indexOf(fromId) : -1;
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
- var next = (((pos + dir) % hosts.length) + hosts.length) % hosts.length;
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
- var hosts = resolveHosts(optsRef);
195
- var attr = resolveAttr(optsRef.current);
196
- for (var _i = 0, hosts_1 = hosts; _i < hosts_1.length; _i++) {
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
- var _a;
206
- var attr = resolveAttr(optsRef.current);
207
- var order = resolveOrder(optsRef.current);
208
- var host = (_a = event.target) === null || _a === void 0 ? void 0 : _a.closest("[".concat(attr, "]"));
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
- var _a, _b, _c, _d, _e, _f, _g, _h;
220
- var opts = optsRef.current;
221
- var el = document.activeElement;
222
- var tag = el === null || el === void 0 ? void 0 : el.tagName;
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
- var isMultiline = !!el && (tag === 'TEXTAREA' || el.isContentEditable === true);
226
- var hasMod = event.metaKey || event.ctrlKey || event.altKey;
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
- var trap = opts.trap !== false;
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
- var popoverOpen_1 = (_b = (_a = opts.isPopoverOpen) === null || _a === void 0 ? void 0 : _a.call(opts)) !== null && _b !== void 0 ? _b : false;
239
- if (popoverOpen_1) {
228
+ const popoverOpen = opts.isPopoverOpen?.() ?? false;
229
+ if (popoverOpen) {
240
230
  event.preventDefault();
241
- (_c = opts.onPopoverEscape) === null || _c === void 0 ? void 0 : _c.call(opts);
231
+ opts.onPopoverEscape?.();
242
232
  return true;
243
233
  }
244
- var container = opts.container;
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
- (_d = opts.onEscape) === null || _d === void 0 ? void 0 : _d.call(opts);
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
- var popoverOpen = (_f = (_e = opts.isPopoverOpen) === null || _e === void 0 ? void 0 : _e.call(opts)) !== null && _f !== void 0 ? _f : false;
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
- var goDown = event.key === 'ArrowDown' || event.key === 'j';
261
- var goUp = event.key === 'ArrowUp' || event.key === 'k';
250
+ const goDown = event.key === 'ArrowDown' || event.key === 'j';
251
+ const goUp = event.key === 'ArrowUp' || event.key === 'k';
262
252
  if (goDown || goUp) {
263
- var isVimKey = event.key === 'j' || event.key === 'k';
264
- var blocked = isVimKey ? isTextField : isMultiline;
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
- var attr = resolveAttr(opts);
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
- var focusedHost = (_g = (el === null || el === void 0 ? void 0 : el.closest("[".concat(attr, "]")))) !== null && _g !== void 0 ? _g : (opts.container && _currentRailId
280
- ? opts.container.querySelector("[".concat(attr, "=\"").concat(_currentRailId, "\"]"))
281
- : null);
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
- var rungId = (_h = focusedHost.getAttribute(attr)) !== null && _h !== void 0 ? _h : '';
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: handleKeydown,
293
- handleFocusin: handleFocusin,
294
- moveRung: moveRung,
295
- focus: focus,
296
- refresh: refresh,
297
- currentId: function () { return _currentRailId; },
298
- bump: 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
- var ref = { current: options };
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 var railNav = function (node, initial) {
329
- if (initial === void 0) { initial = { order: [] }; }
330
- var ref = { current: __assign(__assign({}, initial), { container: node }) };
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: function (next) {
342
- if (next === void 0) { next = { order: [] }; }
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: function () {
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 { AsYouType, parsePhoneNumberFromString, type CountryCode } from "libphonenumber-js";
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
- value = parsed?.isValid() ? parsed.number : "";
105
+ setValue(parsed?.isValid() ? parsed.number : display || "");
74
106
  }
75
107
 
76
108
  function onInput(e: Event) {
77
- const raw = (e.target as HTMLInputElement).value;
109
+ const inputEl = e.target as HTMLInputElement;
110
+ const raw = inputEl.value;
78
111
  if (raw.startsWith("+")) {
79
- const parsed = parsePhoneNumberFromString(raw);
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
- display = parsed.formatNational();
83
- value = parsed.isValid() ? parsed.number : "";
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 = raw;
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(raw);
92
- updateE164(raw, country);
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 class="flex w-full {klass}">
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-60" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
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-200 border border-base-300 rounded-lg"
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/60 font-mono text-xs">{opt.dialCode}</span>
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/60 font-mono text-xs">{opt.dialCode}</span>
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/60">No matches</li>
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>;