@joewinke/jatui 0.1.10 → 0.1.19
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/README.md +123 -0
- package/package.json +3 -1
- package/src/lib/actions/railNav.ts +473 -0
- package/src/lib/components/AnnotationLayer.svelte +108 -0
- package/src/lib/components/AnnotationPanel.svelte +319 -0
- package/src/lib/components/AudioWaveform.svelte +9 -5
- package/src/lib/components/AvailabilityModal.svelte +7 -3
- package/src/lib/components/AvatarUpload.svelte +27 -4
- package/src/lib/components/BookingForm.svelte +11 -9
- package/src/lib/components/BurndownChart.svelte +778 -0
- package/src/lib/components/Button.svelte +10 -1
- package/src/lib/components/CalendarPicker.svelte +3 -3
- package/src/lib/components/Card.svelte +2 -2
- package/src/lib/components/ChipInput.svelte +21 -15
- package/src/lib/components/ColorSelector.svelte +17 -13
- package/src/lib/components/CommentThread.svelte +773 -0
- package/src/lib/components/ConfirmDialog.svelte +348 -0
- package/src/lib/components/ConfirmModal.svelte +78 -11
- package/src/lib/components/ContextMenu.svelte +188 -0
- package/src/lib/components/CountdownTimer.svelte +1 -1
- package/src/lib/components/DateRangePicker.svelte +6 -4
- package/src/lib/components/Drawer.svelte +36 -3
- package/src/lib/components/EntityPreviewCard.svelte +104 -0
- package/src/lib/components/FileDropzone.svelte +493 -0
- package/src/lib/components/FilePicker.svelte +83 -14
- package/src/lib/components/FileThumbnail.svelte +80 -0
- package/src/lib/components/FilterDropdown.svelte +11 -11
- package/src/lib/components/HunkDiffView.svelte +348 -0
- package/src/lib/components/ImageLightbox.svelte +274 -0
- package/src/lib/components/ImageUpload.svelte +58 -9
- package/src/lib/components/InlineEdit.svelte +15 -9
- package/src/lib/components/InputDialog.svelte +327 -0
- package/src/lib/components/LazyImage.svelte +1 -0
- package/src/lib/components/LinkShortener.svelte +1 -1
- package/src/lib/components/LoadingSpinner.svelte +6 -2
- package/src/lib/components/MarkupEditor.svelte +485 -0
- package/src/lib/components/MarkupOverlay.svelte +55 -0
- package/src/lib/components/MediaWorkbench.svelte +871 -0
- package/src/lib/components/MilestoneCard.svelte +1 -1
- package/src/lib/components/MilestoneTimeline.svelte +1 -1
- package/src/lib/components/Modal.svelte +39 -4
- package/src/lib/components/PDFViewer.svelte +105 -0
- package/src/lib/components/PdfThumbnail.svelte +3 -1
- package/src/lib/components/PhoneInput.svelte +183 -63
- package/src/lib/components/ResizablePanel.svelte +4 -4
- package/src/lib/components/SearchDropdown.svelte +26 -13
- package/src/lib/components/SelectInput.svelte +26 -4
- package/src/lib/components/SidebarUserFooter.svelte +1 -1
- package/src/lib/components/SignaturePad.svelte +8 -4
- package/src/lib/components/SmartImageEditor.svelte +720 -0
- package/src/lib/components/SortDropdown.svelte +9 -3
- package/src/lib/components/Sparkline.svelte +9 -0
- package/src/lib/components/StatusBadge.svelte +20 -18
- package/src/lib/components/TextArea.svelte +24 -5
- package/src/lib/components/TextInput.svelte +29 -6
- package/src/lib/components/ThemeSelector.svelte +15 -4
- package/src/lib/components/TimeSlotPicker.svelte +7 -7
- package/src/lib/components/UserAvatar.svelte +14 -1
- package/src/lib/components/VariablePicker.svelte +170 -0
- package/src/lib/components/VoicePlayer.svelte +4 -3
- package/src/lib/components/markup.ts +287 -0
- package/src/lib/components/messaging/ChannelInfoModal.svelte +9 -9
- package/src/lib/components/messaging/ChannelList.svelte +1 -1
- package/src/lib/components/messaging/ChannelMembersModal.svelte +1 -1
- package/src/lib/components/messaging/CreateChannelModal.svelte +1 -1
- package/src/lib/components/messaging/DirectMessageList.svelte +1 -1
- package/src/lib/components/messaging/EmojiSelector.svelte +2 -1
- package/src/lib/components/messaging/MentionAutocomplete.svelte +1 -1
- package/src/lib/components/messaging/MessageAttachment.svelte +3 -3
- package/src/lib/components/messaging/MessageAttachmentUpload.svelte +3 -3
- package/src/lib/components/messaging/MessageInput.svelte +1 -1
- package/src/lib/components/messaging/MessageItem.svelte +6 -3
- package/src/lib/components/messaging/NotificationSettingsModal.svelte +1 -1
- package/src/lib/components/messaging/QuotedMessageDisplay.svelte +6 -1
- package/src/lib/components/messaging/StartDMModal.svelte +1 -1
- package/src/lib/components/pipeline/Pipeline.svelte +4 -4
- package/src/lib/components/pipeline/PipelineCard.svelte +1 -1
- package/src/lib/components/pipeline/PipelineColumn.svelte +8 -3
- package/src/lib/index.ts +105 -1
- package/src/lib/stores/confirmDialog.svelte.ts +48 -0
- package/src/lib/stores/inputDialog.svelte.ts +51 -0
- package/src/lib/styles/rail.css +63 -0
- package/src/lib/types/annotation.ts +38 -0
- package/src/lib/types/comments.ts +97 -0
- package/src/lib/types/entityPreview.ts +45 -0
- package/src/lib/types/filePicker.ts +2 -0
- package/src/lib/types/smartImageEditor.ts +39 -0
- package/src/lib/types/templateVars.ts +36 -0
- package/src/lib/utils/dateFormatters.ts +12 -10
- package/src/lib/utils/phone.ts +80 -0
- package/src/lib/utils/taskUtils.ts +21 -7
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
<div class="space-y-1">
|
|
36
36
|
<!-- Progress summary -->
|
|
37
|
-
<div class="flex items-center justify-between text-
|
|
37
|
+
<div class="flex items-center justify-between text-[0.9375rem] mb-4">
|
|
38
38
|
<span class="text-base-content/60">
|
|
39
39
|
{paidCount} of {sorted.length} milestones paid
|
|
40
40
|
</span>
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { onMount } from 'svelte';
|
|
10
|
+
import { fly, fade } from 'svelte/transition';
|
|
11
|
+
import { cubicOut } from 'svelte/easing';
|
|
10
12
|
|
|
11
13
|
interface Props {
|
|
12
14
|
open?: boolean;
|
|
@@ -22,6 +24,18 @@
|
|
|
22
24
|
ariaLabel?: string;
|
|
23
25
|
dismissible?: boolean;
|
|
24
26
|
zIndex?: number;
|
|
27
|
+
/**
|
|
28
|
+
* When true (default), keydown events fired inside the modal are stopped
|
|
29
|
+
* from propagating to window — preventing page-level list/keyboard
|
|
30
|
+
* navigation handlers (listNav-style) from receiving arrow keys, j/k,
|
|
31
|
+
* vim chords, digit counts, etc. while the modal is open.
|
|
32
|
+
*
|
|
33
|
+
* Free-text fields (INPUT, TEXTAREA, contentEditable) are intentionally
|
|
34
|
+
* excluded so normal typing still works. Set to false only when the
|
|
35
|
+
* consumer needs custom window-level key handling even while the modal
|
|
36
|
+
* is open.
|
|
37
|
+
*/
|
|
38
|
+
trapKeys?: boolean;
|
|
25
39
|
onclose?: () => void;
|
|
26
40
|
onopen?: () => void;
|
|
27
41
|
header?: import('svelte').Snippet;
|
|
@@ -43,6 +57,7 @@
|
|
|
43
57
|
ariaLabel = '',
|
|
44
58
|
dismissible = true,
|
|
45
59
|
zIndex = 1000,
|
|
60
|
+
trapKeys = true,
|
|
46
61
|
onclose,
|
|
47
62
|
onopen,
|
|
48
63
|
header,
|
|
@@ -53,6 +68,11 @@
|
|
|
53
68
|
let modalElement: HTMLDialogElement | undefined = $state();
|
|
54
69
|
let previousFocus: HTMLElement | null = null;
|
|
55
70
|
|
|
71
|
+
// Respect prefers-reduced-motion: skip the entrance/exit transitions.
|
|
72
|
+
const reducedMotion =
|
|
73
|
+
typeof window !== 'undefined' &&
|
|
74
|
+
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
75
|
+
|
|
56
76
|
const sizeClass = $derived(
|
|
57
77
|
{ sm: 'max-w-sm', md: 'max-w-md', lg: 'max-w-lg', xl: 'max-w-xl', full: 'max-w-full h-full' }[size]
|
|
58
78
|
);
|
|
@@ -98,6 +118,21 @@
|
|
|
98
118
|
}
|
|
99
119
|
|
|
100
120
|
function handleKeyDown(event: KeyboardEvent) {
|
|
121
|
+
// Stop keydown events from bubbling to window while the modal is open.
|
|
122
|
+
// This prevents page-level keyboard navigation (listNav-style handlers
|
|
123
|
+
// on window) from reacting to arrow keys, j/k, vim chords, digit counts,
|
|
124
|
+
// etc. typed inside the modal.
|
|
125
|
+
//
|
|
126
|
+
// Free-text fields are excluded: page-level handlers already ignore them,
|
|
127
|
+
// and we must not interfere with normal typing or textarea shortcuts.
|
|
128
|
+
if (trapKeys) {
|
|
129
|
+
const el = document.activeElement as HTMLElement | null;
|
|
130
|
+
const tag = el?.tagName;
|
|
131
|
+
const isTextField =
|
|
132
|
+
!!el && (tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable === true);
|
|
133
|
+
if (!isTextField) event.stopPropagation();
|
|
134
|
+
}
|
|
135
|
+
|
|
101
136
|
if (event.key === 'Tab') {
|
|
102
137
|
trapFocus(event);
|
|
103
138
|
}
|
|
@@ -157,8 +192,8 @@
|
|
|
157
192
|
>
|
|
158
193
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
159
194
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
160
|
-
<div class="modal-backdrop" onclick={handleBackdropClick}>
|
|
161
|
-
<div class={contentClasses}>
|
|
195
|
+
<div class="modal-backdrop" onclick={handleBackdropClick} transition:fade={{ duration: reducedMotion ? 0 : 200 }}>
|
|
196
|
+
<div class={contentClasses} in:fly={{ y: reducedMotion ? 0 : 12, duration: reducedMotion ? 0 : 220, easing: cubicOut }} out:fade={{ duration: reducedMotion ? 0 : 150 }}>
|
|
162
197
|
{#if showHeader && (title || showCloseButton || header)}
|
|
163
198
|
<div class="flex items-center justify-between mb-4">
|
|
164
199
|
{#if header}
|
|
@@ -168,7 +203,7 @@
|
|
|
168
203
|
{/if}
|
|
169
204
|
|
|
170
205
|
{#if showCloseButton && dismissible}
|
|
171
|
-
<button type="button" class="btn btn-sm btn-circle btn-
|
|
206
|
+
<button type="button" class="btn btn-sm btn-circle btn-ghost" onclick={close} aria-label="Close modal">
|
|
172
207
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
173
208
|
<path
|
|
174
209
|
stroke-linecap="round"
|
|
@@ -188,7 +223,7 @@
|
|
|
188
223
|
{/if}
|
|
189
224
|
</div>
|
|
190
225
|
|
|
191
|
-
{#if
|
|
226
|
+
{#if footer}
|
|
192
227
|
<div class="modal-action mt-6">
|
|
193
228
|
{@render footer()}
|
|
194
229
|
</div>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from "svelte"
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
fileId: string
|
|
6
|
+
fileName?: string
|
|
7
|
+
pdfUrl?: string
|
|
8
|
+
class?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let { fileId, fileName = "document.pdf", class: className = "" }: Props = $props()
|
|
12
|
+
|
|
13
|
+
let pageCount = $state(0)
|
|
14
|
+
let currentPage = $state(1)
|
|
15
|
+
let loading = $state(true)
|
|
16
|
+
let pageLoading = $state(false)
|
|
17
|
+
let error = $state<string | null>(null)
|
|
18
|
+
let imgSrc = $state<string | null>(null)
|
|
19
|
+
|
|
20
|
+
onMount(async () => {
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch(`/api/files/${fileId}/pdf-info`)
|
|
23
|
+
if (!res.ok) throw new Error(`Failed to load PDF info: ${res.status}`)
|
|
24
|
+
const data = await res.json()
|
|
25
|
+
pageCount = data.pageCount ?? 1
|
|
26
|
+
} catch (err) {
|
|
27
|
+
error = err instanceof Error ? err.message : "Failed to load PDF"
|
|
28
|
+
} finally {
|
|
29
|
+
loading = false
|
|
30
|
+
}
|
|
31
|
+
if (!error) loadPage(1)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
async function loadPage(page: number) {
|
|
35
|
+
pageLoading = true
|
|
36
|
+
imgSrc = null
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetch(`/api/files/${fileId}/pdf-page/${page}?scale=2`)
|
|
39
|
+
if (!res.ok) throw new Error(`Failed to render page ${page}`)
|
|
40
|
+
const blob = await res.blob()
|
|
41
|
+
imgSrc = URL.createObjectURL(blob)
|
|
42
|
+
currentPage = page
|
|
43
|
+
} catch (err) {
|
|
44
|
+
error = err instanceof Error ? err.message : "Failed to load page"
|
|
45
|
+
} finally {
|
|
46
|
+
pageLoading = false
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function prevPage() {
|
|
51
|
+
if (currentPage > 1) loadPage(currentPage - 1)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function nextPage() {
|
|
55
|
+
if (currentPage < pageCount) loadPage(currentPage + 1)
|
|
56
|
+
}
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<div class="flex flex-col h-full {className}">
|
|
60
|
+
{#if loading}
|
|
61
|
+
<div class="flex-1 flex items-center justify-center text-base-content/40 text-sm">
|
|
62
|
+
Loading PDF…
|
|
63
|
+
</div>
|
|
64
|
+
{:else if error}
|
|
65
|
+
<div class="flex-1 flex items-center justify-center">
|
|
66
|
+
<p class="text-error text-sm text-center px-4">{error}</p>
|
|
67
|
+
</div>
|
|
68
|
+
{:else}
|
|
69
|
+
<!-- Viewer area -->
|
|
70
|
+
<div class="flex-1 min-h-0 flex items-center justify-center bg-base-200/30 p-4 overflow-auto">
|
|
71
|
+
{#if pageLoading}
|
|
72
|
+
<div class="text-base-content/40 text-sm">Rendering page…</div>
|
|
73
|
+
{:else if imgSrc}
|
|
74
|
+
<img
|
|
75
|
+
src={imgSrc}
|
|
76
|
+
alt="Page {currentPage} of {fileName}"
|
|
77
|
+
class="max-w-full max-h-full object-contain shadow-md rounded"
|
|
78
|
+
/>
|
|
79
|
+
{/if}
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<!-- Pagination controls -->
|
|
83
|
+
{#if pageCount > 1}
|
|
84
|
+
<div class="flex items-center justify-center gap-3 py-2 border-t border-base-300 text-sm">
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
class="btn btn-ghost btn-xs"
|
|
88
|
+
onclick={prevPage}
|
|
89
|
+
disabled={currentPage <= 1}
|
|
90
|
+
aria-label="Previous page"
|
|
91
|
+
>‹</button>
|
|
92
|
+
<span class="text-base-content/60 tabular-nums">
|
|
93
|
+
{currentPage} / {pageCount}
|
|
94
|
+
</span>
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
class="btn btn-ghost btn-xs"
|
|
98
|
+
onclick={nextPage}
|
|
99
|
+
disabled={currentPage >= pageCount}
|
|
100
|
+
aria-label="Next page"
|
|
101
|
+
>›</button>
|
|
102
|
+
</div>
|
|
103
|
+
{/if}
|
|
104
|
+
{/if}
|
|
105
|
+
</div>
|
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
let { src, class: className = "" }: Props = $props()
|
|
12
|
-
let canvas
|
|
12
|
+
let canvas = $state<HTMLCanvasElement | null>(null)
|
|
13
13
|
let loading = $state(true)
|
|
14
14
|
let failed = $state(false)
|
|
15
15
|
|
|
16
16
|
onMount(async () => {
|
|
17
|
+
if (!canvas) return
|
|
17
18
|
try {
|
|
18
19
|
const pdfjsLib = await import("pdfjs-dist")
|
|
19
20
|
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
|
@@ -36,6 +37,7 @@
|
|
|
36
37
|
|
|
37
38
|
await page.render({
|
|
38
39
|
canvasContext: canvas.getContext("2d")!,
|
|
40
|
+
canvas,
|
|
39
41
|
viewport: scaled,
|
|
40
42
|
}).promise
|
|
41
43
|
|
|
@@ -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-200 border border-base-300 rounded-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>
|
|
@@ -271,13 +271,13 @@
|
|
|
271
271
|
width: 30px;
|
|
272
272
|
height: 4px;
|
|
273
273
|
border-radius: 2px;
|
|
274
|
-
background-color: var(--color-base-300,
|
|
274
|
+
background-color: var(--color-base-300, oklch(0.82 0.03 255));
|
|
275
275
|
transition: background-color 0.2s ease;
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
.drag-handle:hover .drag-indicator,
|
|
279
279
|
.dragging .drag-indicator {
|
|
280
|
-
background-color: var(--color-primary,
|
|
280
|
+
background-color: var(--color-primary, oklch(0.64 0.18 259));
|
|
281
281
|
}
|
|
282
282
|
|
|
283
283
|
.panel-content {
|
|
@@ -297,9 +297,9 @@
|
|
|
297
297
|
.drag-preview-inner {
|
|
298
298
|
width: 100px;
|
|
299
299
|
height: 60px;
|
|
300
|
-
background-color: var(--color-primary,
|
|
300
|
+
background-color: var(--color-primary, oklch(0.64 0.18 259));
|
|
301
301
|
opacity: 0.3;
|
|
302
302
|
border-radius: 4px;
|
|
303
|
-
border: 2px solid var(--color-primary,
|
|
303
|
+
border: 2px solid var(--color-primary, oklch(0.64 0.18 259));
|
|
304
304
|
}
|
|
305
305
|
</style>
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
|
|
41
41
|
let open = $state(false);
|
|
42
42
|
let searchQuery = $state('');
|
|
43
|
-
let searchInput
|
|
43
|
+
let searchInput = $state<HTMLInputElement | undefined>(undefined);
|
|
44
44
|
let containerRef: HTMLDivElement | undefined;
|
|
45
45
|
|
|
46
46
|
// Find the currently selected option across all groups
|
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
type="button"
|
|
105
105
|
class="sd-trigger"
|
|
106
106
|
class:sd-disabled={disabled}
|
|
107
|
-
style={activeColor ? `
|
|
107
|
+
style={activeColor ? `color: ${activeColor};` : ''}
|
|
108
108
|
onclick={() => { if (!disabled) open = !open; }}
|
|
109
109
|
{disabled}
|
|
110
110
|
>
|
|
@@ -169,7 +169,7 @@
|
|
|
169
169
|
onclick={() => select(option.value)}
|
|
170
170
|
class="sd-option"
|
|
171
171
|
class:sd-option-selected={value === option.value}
|
|
172
|
-
style={colorFn && colorFn(option.value) ? `
|
|
172
|
+
style={colorFn && colorFn(option.value) ? `color: ${colorFn(option.value)};` : ''}
|
|
173
173
|
>
|
|
174
174
|
{#if option.image}
|
|
175
175
|
<img src={option.image} alt="" class="sd-avatar" />
|
|
@@ -189,7 +189,12 @@
|
|
|
189
189
|
{/each}
|
|
190
190
|
{/each}
|
|
191
191
|
{:else}
|
|
192
|
-
<li class="sd-empty">
|
|
192
|
+
<li class="sd-empty">
|
|
193
|
+
<svg class="sd-empty-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
|
194
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 15.803 7.5 7.5 0 0015.803 15.803z" />
|
|
195
|
+
</svg>
|
|
196
|
+
No matches for "{searchQuery}"
|
|
197
|
+
</li>
|
|
193
198
|
{/if}
|
|
194
199
|
</ul>
|
|
195
200
|
{#if footer}
|
|
@@ -210,7 +215,7 @@
|
|
|
210
215
|
--sd-border: var(--sd-color-border, color-mix(in oklch, var(--color-base-content, CanvasText) 20%, transparent));
|
|
211
216
|
--sd-text: var(--sd-color-text, var(--color-base-content, CanvasText));
|
|
212
217
|
--sd-text-muted: var(--sd-color-text-muted, color-mix(in oklch, var(--color-base-content, CanvasText) 50%, transparent));
|
|
213
|
-
--sd-text-label: var(--sd-color-text-label, color-mix(in oklch, var(--color-
|
|
218
|
+
--sd-text-label: var(--sd-color-text-label, color-mix(in oklch, var(--color-base-content, CanvasText) 45%, transparent));
|
|
214
219
|
--sd-accent: var(--sd-color-accent, var(--color-primary, LinkText));
|
|
215
220
|
--sd-success: var(--sd-color-success, var(--color-success, green));
|
|
216
221
|
}
|
|
@@ -265,7 +270,7 @@
|
|
|
265
270
|
justify-content: center;
|
|
266
271
|
font-size: 0.6rem;
|
|
267
272
|
font-weight: 700;
|
|
268
|
-
color:
|
|
273
|
+
color: oklch(1 0 0);
|
|
269
274
|
line-height: 1;
|
|
270
275
|
}
|
|
271
276
|
|
|
@@ -347,11 +352,10 @@
|
|
|
347
352
|
padding: 0.375rem 0.75rem 0.125rem;
|
|
348
353
|
}
|
|
349
354
|
.sd-group-label span {
|
|
350
|
-
font-size: 0.
|
|
355
|
+
font-size: 0.8125rem;
|
|
351
356
|
font-family: inherit;
|
|
352
|
-
font-weight:
|
|
353
|
-
|
|
354
|
-
letter-spacing: 0.05em;
|
|
357
|
+
font-weight: 400;
|
|
358
|
+
letter-spacing: 0.005em;
|
|
355
359
|
color: var(--sd-text-label);
|
|
356
360
|
}
|
|
357
361
|
|
|
@@ -368,7 +372,6 @@
|
|
|
368
372
|
cursor: pointer;
|
|
369
373
|
background: transparent;
|
|
370
374
|
border: none;
|
|
371
|
-
border-left: 2px solid transparent;
|
|
372
375
|
color: var(--sd-text);
|
|
373
376
|
}
|
|
374
377
|
.sd-option:hover {
|
|
@@ -376,7 +379,7 @@
|
|
|
376
379
|
}
|
|
377
380
|
.sd-option-selected {
|
|
378
381
|
background: var(--sd-bg-selected);
|
|
379
|
-
|
|
382
|
+
font-weight: 600;
|
|
380
383
|
}
|
|
381
384
|
|
|
382
385
|
.sd-option-icon {
|
|
@@ -399,10 +402,20 @@
|
|
|
399
402
|
}
|
|
400
403
|
|
|
401
404
|
.sd-empty {
|
|
402
|
-
padding: 0.75rem;
|
|
405
|
+
padding: 1rem 0.75rem;
|
|
403
406
|
text-align: center;
|
|
404
407
|
font-size: inherit;
|
|
405
408
|
font-family: inherit;
|
|
406
409
|
color: var(--sd-text-muted);
|
|
410
|
+
display: flex;
|
|
411
|
+
flex-direction: column;
|
|
412
|
+
align-items: center;
|
|
413
|
+
gap: 0.375rem;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
.sd-empty-icon {
|
|
417
|
+
width: 1.25rem;
|
|
418
|
+
height: 1.25rem;
|
|
419
|
+
opacity: 0.35;
|
|
407
420
|
}
|
|
408
421
|
</style>
|