@joewinke/jatui 0.1.9 → 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.9",
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>
@@ -0,0 +1,54 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Stroke icon for a task issue_type value.
4
+ * Replaces the emoji returned by `typeIcon()`.
5
+ *
6
+ * Usage:
7
+ * <TaskTypeIcon type={task.issue_type} />
8
+ * <TaskTypeIcon type="bug" size={14} class="text-base-content/60" />
9
+ */
10
+
11
+ interface Props {
12
+ type: string | null | undefined
13
+ size?: number
14
+ class?: string
15
+ title?: string
16
+ }
17
+
18
+ let { type, size = 14, class: className = "", title }: Props = $props()
19
+
20
+ // Path data per type. Single-path lucide-style strokes so they inherit
21
+ // currentColor and size cleanly inside dense tables.
22
+ const paths: Record<string, string> = {
23
+ // Bug: body + antennae/legs
24
+ bug: "M12 4v2M8 6l1 2M16 6l-1 2M6 12H4M20 12h-2M6 16l-2 1M18 16l2 1M9 9h6v5a3 3 0 11-6 0V9z",
25
+ // Feature (spark): 4-point star
26
+ feature: "M12 3v4M12 17v4M3 12h4M17 12h4M5.5 5.5l2.5 2.5M16 16l2.5 2.5M5.5 18.5L8 16M16 8l2.5-2.5",
27
+ // Task: checklist
28
+ task: "M9 4h9a1 1 0 011 1v14a1 1 0 01-1 1H6a1 1 0 01-1-1V5a1 1 0 011-1h1M9 3h6v2H9zM8.5 11.5l1.5 1.5 3-3M8.5 16.5l1.5 1.5 3-3",
29
+ // Epic: mountain peaks
30
+ epic: "M3 19l5-8 4 6 3-4 6 6H3z",
31
+ // Default: document
32
+ document: "M13 3H6a1 1 0 00-1 1v16a1 1 0 001 1h12a1 1 0 001-1V9l-6-6zM13 3v6h6",
33
+ }
34
+
35
+ const d = $derived(paths[type ?? ""] ?? paths.document)
36
+ const label = $derived(title ?? (type ? `${type[0].toUpperCase()}${type.slice(1)}` : "Document"))
37
+ </script>
38
+
39
+ <svg
40
+ viewBox="0 0 24 24"
41
+ fill="none"
42
+ stroke="currentColor"
43
+ stroke-width="2"
44
+ stroke-linecap="round"
45
+ stroke-linejoin="round"
46
+ width={size}
47
+ height={size}
48
+ class={className}
49
+ role="img"
50
+ aria-label={label}
51
+ >
52
+ <title>{label}</title>
53
+ <path d={d} />
54
+ </svg>
package/src/lib/index.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  // Components — Universal
2
2
  export { default as UserAvatar } from './components/UserAvatar.svelte';
3
3
  export { default as SidebarUserFooter } from './components/SidebarUserFooter.svelte';
4
+ export { default as TaskTypeIcon } from './components/TaskTypeIcon.svelte';
4
5
 
5
6
  // Components — from JAT IDE
6
7
  export { default as SearchDropdown } from './components/SearchDropdown.svelte';
7
8
  export { default as FilterDropdown } from './components/FilterDropdown.svelte';
9
+ export { default as ContextMenu } from './components/ContextMenu.svelte';
8
10
  export { default as ChipInput } from './components/ChipInput.svelte';
9
11
  export { default as ResizableDivider } from './components/ResizableDivider.svelte';
10
12
  export { default as DateRangePicker } from './components/DateRangePicker.svelte';
@@ -75,6 +77,7 @@ export type { ShortenedLink } from './components/LinkShortener.svelte';
75
77
  export type { SearchDropdownOption, SearchDropdownGroup } from './components/SearchDropdown.svelte';
76
78
  export type { ChipSuggestion, SuggestionGroup, ChipInfo } from './components/ChipInput.svelte';
77
79
  export type { SortOption } from './components/SortDropdown.svelte';
80
+ export type { ContextMenuItem } from './components/ContextMenu.svelte';
78
81
  export type { DisplaySegment } from './components/InlineEdit.svelte';
79
82
  export type { ActionButton, ActionDropdown, DropdownItem } from './components/FloatingActionBar.svelte';
80
83
 
@@ -130,7 +133,7 @@ export {
130
133
  statusLabel,
131
134
  } from './utils/taskUtils';
132
135
 
133
- // Utilities — phone formatting
136
+ // Utilities — phone formatting (legacy US-only)
134
137
  export {
135
138
  stripPhoneNumber,
136
139
  formatPhoneNumber,
@@ -139,6 +142,17 @@ export {
139
142
  updatePhoneInput
140
143
  } from './utils/phoneFormat';
141
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
+
142
156
  // Utilities — currency
143
157
  export { formatCurrency } from './utils/currency';
144
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"];
@@ -37,7 +37,9 @@ export function priorityColor(priority: string | null | undefined): string {
37
37
  }
38
38
 
39
39
  /**
40
- * Emoji icon for a task issue_type value.
40
+ * @deprecated Prefer the `<TaskTypeIcon type={…} />` component — it renders a
41
+ * stroke SVG that inherits `currentColor`, scales precisely, and avoids
42
+ * cross-OS emoji rendering drift. Kept only for backwards compatibility.
41
43
  * Usage: {typeIcon(task.issue_type)} {task.title}
42
44
  */
43
45
  export function typeIcon(type: string): string {