@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.1.10",
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
- * Tel input with basic validation and format indicator.
6
- * Converted from Svelte 4 to Svelte 5 runes.
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
- interface Props {
10
- label: string;
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
- name?: string;
20
- oninput?: (value: string, isValid: boolean) => void;
21
- }
22
+ class?: string;
23
+ disabled?: boolean;
24
+ autocomplete?: HTMLInputElement["autocomplete"];
25
+ };
22
26
 
23
27
  let {
24
- label,
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
- error = '',
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 displayPlaceholder = $derived(placeholder || '(555) 123-4567');
38
- const isValid = $derived(
39
- /^[\d\s\-\(\)\.+]{10,}$/.test(value) && value.replace(/\D/g, '').length === 10
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 handleInput(event: Event) {
43
- const target = event.target as HTMLInputElement;
44
- value = target.value;
45
- oninput?.(value, isValid);
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="form-control w-full">
50
- <label for={id} class="label">
51
- <span class="label-text font-medium">
52
- {label}
53
- {#if required}
54
- <span class="text-error ml-1">*</span>
55
- {/if}
56
- </span>
57
- </label>
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
- placeholder={displayPlaceholder}
66
- {value}
67
- oninput={handleInput}
68
- pattern="[0-9\s\-\(\)\.+]*"
69
- class="input input-bordered w-full {error ? 'input-error' : ''} {className}"
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 error}
76
- <div class="label">
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"];