@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.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Shared Svelte 5 component library for JAT projects",
|
|
6
6
|
"type": "module",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"vite": "^6.0.0"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
+
"libphonenumber-js": "^1.12.41",
|
|
47
48
|
"pdfjs-dist": "^5.6.205",
|
|
48
49
|
"svelte-dnd-action": "^0.9.69"
|
|
49
50
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ContextMenu Component
|
|
4
|
+
*
|
|
5
|
+
* Floating right-click (or programmatic) menu positioned at a cursor point.
|
|
6
|
+
* Headless primitive that handles positioning, viewport clamping, and
|
|
7
|
+
* dismiss behavior (click-outside, Escape, right-click elsewhere).
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* let cm = $state({ open: false, x: 0, y: 0, target: null })
|
|
11
|
+
*
|
|
12
|
+
* <button oncontextmenu={(e) => {
|
|
13
|
+
* e.preventDefault()
|
|
14
|
+
* cm = { open: true, x: e.clientX, y: e.clientY, target: row }
|
|
15
|
+
* }}>Row</button>
|
|
16
|
+
*
|
|
17
|
+
* <ContextMenu
|
|
18
|
+
* bind:open={cm.open}
|
|
19
|
+
* x={cm.x}
|
|
20
|
+
* y={cm.y}
|
|
21
|
+
* header={cm.target?.name}
|
|
22
|
+
* items={[
|
|
23
|
+
* { label: "Preview", onSelect: () => preview(cm.target) },
|
|
24
|
+
* { label: "Copy URL", onSelect: () => copy(cm.target) },
|
|
25
|
+
* { divider: true },
|
|
26
|
+
* { label: "Delete", variant: "danger", onSelect: () => del(cm.target) },
|
|
27
|
+
* ]}
|
|
28
|
+
* />
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
type ActionItem = {
|
|
32
|
+
label: string;
|
|
33
|
+
onSelect: () => void;
|
|
34
|
+
variant?: 'default' | 'danger';
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
};
|
|
37
|
+
type DividerItem = { divider: true };
|
|
38
|
+
export type ContextMenuItem = ActionItem | DividerItem;
|
|
39
|
+
|
|
40
|
+
interface Props {
|
|
41
|
+
/** Whether the menu is currently shown. Bindable. */
|
|
42
|
+
open: boolean;
|
|
43
|
+
/** Viewport x coordinate to anchor the top-left corner to (e.g. event.clientX). */
|
|
44
|
+
x: number;
|
|
45
|
+
/** Viewport y coordinate to anchor the top-left corner to (e.g. event.clientY). */
|
|
46
|
+
y: number;
|
|
47
|
+
/** Ordered list of items and dividers to render. */
|
|
48
|
+
items: ContextMenuItem[];
|
|
49
|
+
/** Optional small italic label rendered above the items (e.g. a filename). */
|
|
50
|
+
header?: string;
|
|
51
|
+
/** Estimated menu width in px, used for viewport clamping. Default 200. */
|
|
52
|
+
estimatedWidth?: number;
|
|
53
|
+
/** Estimated menu height in px, used for viewport clamping. Default 200. */
|
|
54
|
+
estimatedHeight?: number;
|
|
55
|
+
/** Stacking context z-index. Default 70 (above DaisyUI modals at 50). */
|
|
56
|
+
zIndex?: number;
|
|
57
|
+
/** Additional CSS class for the menu element. */
|
|
58
|
+
class?: string;
|
|
59
|
+
/** Called after the menu closes, regardless of cause. */
|
|
60
|
+
onClose?: () => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let {
|
|
64
|
+
open = $bindable(false),
|
|
65
|
+
x,
|
|
66
|
+
y,
|
|
67
|
+
items,
|
|
68
|
+
header,
|
|
69
|
+
estimatedWidth = 200,
|
|
70
|
+
estimatedHeight = 200,
|
|
71
|
+
zIndex = 70,
|
|
72
|
+
class: className = '',
|
|
73
|
+
onClose
|
|
74
|
+
}: Props = $props();
|
|
75
|
+
|
|
76
|
+
function close() {
|
|
77
|
+
if (!open) return;
|
|
78
|
+
open = false;
|
|
79
|
+
onClose?.();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function handleSelect(item: ActionItem) {
|
|
83
|
+
if (item.disabled) return;
|
|
84
|
+
item.onSelect();
|
|
85
|
+
close();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function clamp(value: number, max: number, pad = 8) {
|
|
89
|
+
if (value + pad > max) return Math.max(pad, max - pad);
|
|
90
|
+
return Math.max(pad, value);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let clampedX = $derived(
|
|
94
|
+
typeof window === 'undefined' ? x : clamp(x, window.innerWidth - estimatedWidth)
|
|
95
|
+
);
|
|
96
|
+
let clampedY = $derived(
|
|
97
|
+
typeof window === 'undefined' ? y : clamp(y, window.innerHeight - estimatedHeight)
|
|
98
|
+
);
|
|
99
|
+
</script>
|
|
100
|
+
|
|
101
|
+
{#if open}
|
|
102
|
+
<!-- Invisible full-viewport overlay — captures click/right-click to dismiss -->
|
|
103
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
104
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
105
|
+
<div
|
|
106
|
+
class="fixed inset-0"
|
|
107
|
+
style="z-index: {zIndex - 1};"
|
|
108
|
+
onclick={close}
|
|
109
|
+
oncontextmenu={(e) => {
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
close();
|
|
112
|
+
}}
|
|
113
|
+
></div>
|
|
114
|
+
|
|
115
|
+
<div
|
|
116
|
+
role="menu"
|
|
117
|
+
tabindex="-1"
|
|
118
|
+
class="fixed min-w-[200px] bg-base-100 border border-base-300 rounded-md shadow-lg py-1 {className}"
|
|
119
|
+
style="left: {clampedX}px; top: {clampedY}px; z-index: {zIndex};"
|
|
120
|
+
onkeydown={(e) => e.key === 'Escape' && close()}
|
|
121
|
+
>
|
|
122
|
+
{#if header}
|
|
123
|
+
<p
|
|
124
|
+
class="px-3 py-1.5 text-[11px] italic text-base-content/60 truncate border-b border-base-200"
|
|
125
|
+
>
|
|
126
|
+
{header}
|
|
127
|
+
</p>
|
|
128
|
+
{/if}
|
|
129
|
+
|
|
130
|
+
{#each items as item}
|
|
131
|
+
{#if 'divider' in item}
|
|
132
|
+
<div class="border-t border-base-200 my-1"></div>
|
|
133
|
+
{:else}
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
role="menuitem"
|
|
137
|
+
class="w-full text-left px-3 py-1.5 text-sm transition-colors
|
|
138
|
+
{item.variant === 'danger' ? 'text-error hover:bg-error/10' : 'hover:bg-base-200'}
|
|
139
|
+
{item.disabled ? 'opacity-50 cursor-not-allowed' : ''}"
|
|
140
|
+
disabled={item.disabled}
|
|
141
|
+
onclick={() => handleSelect(item)}
|
|
142
|
+
>
|
|
143
|
+
{item.label}
|
|
144
|
+
</button>
|
|
145
|
+
{/if}
|
|
146
|
+
{/each}
|
|
147
|
+
</div>
|
|
148
|
+
{/if}
|
|
@@ -1,92 +1,212 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
* PhoneInput Component
|
|
3
|
+
* PhoneInput Component — International phone number input.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Country selector dropdown (flag + dial code + search) alongside a tel input.
|
|
6
|
+
* Formats numbers with libphonenumber-js AsYouType as the user types.
|
|
7
|
+
* Exposes the parsed E.164 value via `bind:value` and a hidden form field
|
|
8
|
+
* named `name` so it submits cleanly with native `<form>` actions.
|
|
9
|
+
*
|
|
10
|
+
* Auto-detects country when the user's input begins with a `+` prefix.
|
|
7
11
|
*/
|
|
12
|
+
import { AsYouType, parsePhoneNumberFromString, type CountryCode } from "libphonenumber-js";
|
|
13
|
+
import { listCountries, PRIORITY_COUNTRIES, type CountryOption } from "../utils/phone";
|
|
8
14
|
|
|
9
|
-
|
|
10
|
-
|
|
15
|
+
type Props = {
|
|
16
|
+
name?: string;
|
|
11
17
|
value?: string;
|
|
18
|
+
defaultCountry?: CountryCode;
|
|
12
19
|
required?: boolean;
|
|
13
|
-
disabled?: boolean;
|
|
14
|
-
error?: string;
|
|
15
|
-
helpText?: string;
|
|
16
20
|
placeholder?: string;
|
|
17
|
-
class?: string;
|
|
18
21
|
id?: string;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
class?: string;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
autocomplete?: HTMLInputElement["autocomplete"];
|
|
25
|
+
};
|
|
22
26
|
|
|
23
27
|
let {
|
|
24
|
-
|
|
25
|
-
value = $bindable(
|
|
28
|
+
name = "",
|
|
29
|
+
value = $bindable(""),
|
|
30
|
+
defaultCountry = "US",
|
|
26
31
|
required = false,
|
|
32
|
+
placeholder = "Phone number",
|
|
33
|
+
id,
|
|
34
|
+
class: klass = "",
|
|
27
35
|
disabled = false,
|
|
28
|
-
|
|
29
|
-
helpText = '',
|
|
30
|
-
placeholder = '',
|
|
31
|
-
class: className = '',
|
|
32
|
-
id = `phone-input-${Math.random().toString(36).substr(2, 9)}`,
|
|
33
|
-
name = '',
|
|
34
|
-
oninput
|
|
36
|
+
autocomplete = "tel" as HTMLInputElement["autocomplete"],
|
|
35
37
|
}: Props = $props();
|
|
36
38
|
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
39
|
+
const all = listCountries();
|
|
40
|
+
const priorityOptions = PRIORITY_COUNTRIES.map((c) => all.find((o) => o.code === c)).filter(
|
|
41
|
+
(o): o is CountryOption => !!o,
|
|
40
42
|
);
|
|
43
|
+
const otherOptions = all.filter((o) => !PRIORITY_COUNTRIES.includes(o.code));
|
|
41
44
|
|
|
42
|
-
function
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
function initialFrom(e164: string): { country: CountryCode; national: string } {
|
|
46
|
+
if (e164) {
|
|
47
|
+
const parsed = parsePhoneNumberFromString(e164);
|
|
48
|
+
if (parsed?.country) {
|
|
49
|
+
return { country: parsed.country, national: parsed.formatNational() };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { country: defaultCountry, national: "" };
|
|
46
53
|
}
|
|
54
|
+
|
|
55
|
+
const init = initialFrom(value ?? "");
|
|
56
|
+
let country = $state<CountryCode>(init.country);
|
|
57
|
+
let display = $state<string>(init.national);
|
|
58
|
+
let dropdownOpen = $state(false);
|
|
59
|
+
let search = $state("");
|
|
60
|
+
|
|
61
|
+
const current = $derived(all.find((o) => o.code === country) ?? all[0]);
|
|
62
|
+
|
|
63
|
+
const filteredOthers = $derived.by(() => {
|
|
64
|
+
const q = search.trim().toLowerCase();
|
|
65
|
+
if (!q) return otherOptions;
|
|
66
|
+
return otherOptions.filter(
|
|
67
|
+
(o) => o.name.toLowerCase().includes(q) || o.dialCode.includes(q) || o.code.toLowerCase().includes(q),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function updateE164(raw: string, cc: CountryCode) {
|
|
72
|
+
const parsed = parsePhoneNumberFromString(raw, cc);
|
|
73
|
+
value = parsed?.isValid() ? parsed.number : "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function onInput(e: Event) {
|
|
77
|
+
const raw = (e.target as HTMLInputElement).value;
|
|
78
|
+
if (raw.startsWith("+")) {
|
|
79
|
+
const parsed = parsePhoneNumberFromString(raw);
|
|
80
|
+
if (parsed?.country) {
|
|
81
|
+
country = parsed.country;
|
|
82
|
+
display = parsed.formatNational();
|
|
83
|
+
value = parsed.isValid() ? parsed.number : "";
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
display = raw;
|
|
87
|
+
value = "";
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const formatter = new AsYouType(country);
|
|
91
|
+
display = formatter.input(raw);
|
|
92
|
+
updateE164(raw, country);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function pickCountry(code: CountryCode) {
|
|
96
|
+
country = code;
|
|
97
|
+
dropdownOpen = false;
|
|
98
|
+
search = "";
|
|
99
|
+
updateE164(display, code);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function toggleDropdown() {
|
|
103
|
+
dropdownOpen = !dropdownOpen;
|
|
104
|
+
if (dropdownOpen) search = "";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function onBlur() {
|
|
108
|
+
setTimeout(() => (dropdownOpen = false), 150);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
$effect(() => {
|
|
112
|
+
if (!value) return;
|
|
113
|
+
const parsed = parsePhoneNumberFromString(value);
|
|
114
|
+
if (parsed?.country && parsed.country !== country) {
|
|
115
|
+
country = parsed.country;
|
|
116
|
+
display = parsed.formatNational();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
47
119
|
</script>
|
|
48
120
|
|
|
49
|
-
<div class="
|
|
50
|
-
<
|
|
51
|
-
<
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
{
|
|
56
|
-
|
|
57
|
-
|
|
121
|
+
<div class="flex w-full {klass}">
|
|
122
|
+
<div class="relative">
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
class="btn btn-outline rounded-r-none border-r-0 px-3 min-w-[5.5rem] justify-start"
|
|
126
|
+
onclick={toggleDropdown}
|
|
127
|
+
{disabled}
|
|
128
|
+
aria-label="Select country code"
|
|
129
|
+
aria-expanded={dropdownOpen}
|
|
130
|
+
aria-haspopup="listbox"
|
|
131
|
+
>
|
|
132
|
+
<span class="text-lg leading-none">{current?.flag}</span>
|
|
133
|
+
<span class="text-sm font-mono">{current?.dialCode}</span>
|
|
134
|
+
<svg class="w-3 h-3 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
135
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
|
136
|
+
</svg>
|
|
137
|
+
</button>
|
|
138
|
+
|
|
139
|
+
{#if dropdownOpen}
|
|
140
|
+
<div
|
|
141
|
+
class="absolute z-50 mt-1 w-72 max-h-80 overflow-y-auto bg-base-100 border border-base-300 rounded-lg shadow-lg"
|
|
142
|
+
role="listbox"
|
|
143
|
+
>
|
|
144
|
+
<div class="sticky top-0 bg-base-100 p-2 border-b border-base-300">
|
|
145
|
+
<input
|
|
146
|
+
type="text"
|
|
147
|
+
class="input input-sm input-bordered w-full"
|
|
148
|
+
placeholder="Search country…"
|
|
149
|
+
bind:value={search}
|
|
150
|
+
aria-label="Search country"
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
<ul class="py-1">
|
|
154
|
+
{#if !search}
|
|
155
|
+
{#each priorityOptions as opt (opt.code)}
|
|
156
|
+
<li>
|
|
157
|
+
<button
|
|
158
|
+
type="button"
|
|
159
|
+
class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-base-200 {opt.code === country ? 'bg-primary/10' : ''}"
|
|
160
|
+
onclick={() => pickCountry(opt.code)}
|
|
161
|
+
role="option"
|
|
162
|
+
aria-selected={opt.code === country}
|
|
163
|
+
>
|
|
164
|
+
<span class="text-lg leading-none">{opt.flag}</span>
|
|
165
|
+
<span class="flex-1 text-left">{opt.name}</span>
|
|
166
|
+
<span class="text-base-content/60 font-mono text-xs">{opt.dialCode}</span>
|
|
167
|
+
</button>
|
|
168
|
+
</li>
|
|
169
|
+
{/each}
|
|
170
|
+
<li><div class="border-t border-base-300 my-1"></div></li>
|
|
171
|
+
{/if}
|
|
172
|
+
{#each filteredOthers as opt (opt.code)}
|
|
173
|
+
<li>
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-base-200 {opt.code === country ? 'bg-primary/10' : ''}"
|
|
177
|
+
onclick={() => pickCountry(opt.code)}
|
|
178
|
+
role="option"
|
|
179
|
+
aria-selected={opt.code === country}
|
|
180
|
+
>
|
|
181
|
+
<span class="text-lg leading-none">{opt.flag}</span>
|
|
182
|
+
<span class="flex-1 text-left">{opt.name}</span>
|
|
183
|
+
<span class="text-base-content/60 font-mono text-xs">{opt.dialCode}</span>
|
|
184
|
+
</button>
|
|
185
|
+
</li>
|
|
186
|
+
{/each}
|
|
187
|
+
{#if filteredOthers.length === 0}
|
|
188
|
+
<li class="px-3 py-2 text-sm text-base-content/60">No matches</li>
|
|
189
|
+
{/if}
|
|
190
|
+
</ul>
|
|
191
|
+
</div>
|
|
192
|
+
{/if}
|
|
193
|
+
</div>
|
|
58
194
|
|
|
59
195
|
<input
|
|
60
|
-
{id}
|
|
61
|
-
{name}
|
|
62
196
|
type="tel"
|
|
197
|
+
class="input input-bordered rounded-l-none flex-1 min-w-0"
|
|
198
|
+
{id}
|
|
199
|
+
{placeholder}
|
|
63
200
|
{required}
|
|
64
201
|
{disabled}
|
|
65
|
-
|
|
66
|
-
{
|
|
67
|
-
oninput={
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
aria-describedby={error ? `${id}-error` : helpText ? `${id}-help` : undefined}
|
|
71
|
-
aria-invalid={error ? 'true' : 'false'}
|
|
72
|
-
autocomplete="tel"
|
|
202
|
+
{autocomplete}
|
|
203
|
+
value={display}
|
|
204
|
+
oninput={onInput}
|
|
205
|
+
onblur={onBlur}
|
|
206
|
+
aria-describedby={id ? `${id}-help` : undefined}
|
|
73
207
|
/>
|
|
74
208
|
|
|
75
|
-
{#if
|
|
76
|
-
<
|
|
77
|
-
<span id="{id}-error" class="label-text-alt text-error" role="alert">{error}</span>
|
|
78
|
-
</div>
|
|
79
|
-
{:else if helpText}
|
|
80
|
-
<div class="label">
|
|
81
|
-
<span id="{id}-help" class="label-text-alt text-base-content/70">{helpText}</span>
|
|
82
|
-
</div>
|
|
83
|
-
{/if}
|
|
84
|
-
|
|
85
|
-
{#if value && !error}
|
|
86
|
-
<div class="label">
|
|
87
|
-
<span class="label-text-alt {isValid ? 'text-success' : 'text-warning'}">
|
|
88
|
-
{isValid ? '✓ Valid phone number' : '⚠ Invalid phone number'}
|
|
89
|
-
</span>
|
|
90
|
-
</div>
|
|
209
|
+
{#if name}
|
|
210
|
+
<input type="hidden" {name} {value} />
|
|
91
211
|
{/if}
|
|
92
212
|
</div>
|
|
@@ -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
|
-
*
|
|
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 {
|