@leavepulse/ui 0.16.2 → 0.17.1
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/dist/chunks/LpAddressInput-ChT1JQg0.js +366 -0
- package/dist/chunks/{LpAppShell-CV194os6.js → LpAppShell-C6Mj82LS.js} +2 -2
- package/dist/chunks/{LpConfirmDialog-DnReh5pF.js → LpConfirmDialog-3_Qu4hnA.js} +1 -1
- package/dist/chunks/{LpDrawer-BHzCy-Aa.js → LpDrawer-Fxk7ViQ-.js} +1 -1
- package/dist/chunks/{LpFileTree-YOJCuIjJ.js → LpFileTree-CXg3KDth.js} +1 -1
- package/dist/chunks/{LpLogViewer-BvzRZPjs.js → LpLogViewer-D7DNEYaG.js} +1 -1
- package/dist/chunks/LpMapPicker-DFKrd1Ne.js +509 -0
- package/dist/chunks/{LpModal-CezA8eJ3.js → LpModal-J-KRPO7F.js} +1 -1
- package/dist/chunks/{LpNotificationBell-BtfRoL2i.js → LpNotificationBell-D8sdQLgu.js} +26 -13
- package/dist/chunks/{LpScrollArea-De9DLpa1.js → LpScrollArea-CKhOx0_l.js} +2 -2
- package/dist/chunks/{LpSidebar-B11IDRQi.js → LpSidebar-Bk7SrINq.js} +2 -2
- package/dist/chunks/{LpSidebarNav-TIUNHZX-.js → LpSidebarNav-Cpd-yHXb.js} +1 -1
- package/dist/chunks/{LpTable-CKGaUbEM.js → LpTable-X3ieZmbr.js} +1 -1
- package/dist/chunks/{LpThemeSwitcher-BgWwfkxs.js → LpThemeSwitcher-DC6QygnU.js} +11 -3
- package/dist/component-names.d.ts +1 -1
- package/dist/components/LpAddressInput.vue.d.ts +53 -0
- package/dist/components/LpAddressInput.vue.js +4 -0
- package/dist/components/LpAlert.vue.d.ts +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpCommandPalette.vue.js +1 -1
- package/dist/components/LpConfirmDialog.vue.js +1 -1
- package/dist/components/LpDrawer.vue.js +1 -1
- package/dist/components/LpFileTree.vue.d.ts +2 -2
- package/dist/components/LpFileTree.vue.js +1 -1
- package/dist/components/LpLightbox.vue.d.ts +1 -1
- package/dist/components/LpLogViewer.vue.js +1 -1
- package/dist/components/LpMapPicker.vue.d.ts +44 -0
- package/dist/components/LpMapPicker.vue.js +4 -0
- package/dist/components/LpModal.vue.js +1 -1
- package/dist/components/LpNotificationBell.vue.d.ts +22 -0
- package/dist/components/LpNotificationBell.vue.js +1 -1
- package/dist/components/LpScrollArea.vue.js +1 -1
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpSidebarNav.vue.js +1 -1
- package/dist/components/LpTable.vue.js +1 -1
- package/dist/components/LpThemeSwitcher.vue.d.ts +7 -0
- package/dist/components/LpThemeSwitcher.vue.js +1 -1
- package/dist/components/address.d.ts +107 -0
- package/dist/components/address.js +96 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +129 -117
- package/package.json +1 -1
- package/src/component-names.ts +2 -0
- package/src/components/LpAddressInput.vue +377 -0
- package/src/components/LpMapPicker.vue +665 -0
- package/src/components/LpNotificationBell.vue +47 -9
- package/src/components/LpScrollArea.vue +8 -2
- package/src/components/LpThemeSwitcher.vue +24 -2
- package/src/components/address.ts +250 -0
- package/src/index.ts +194 -169
- package/src/theme/presets.ts +8 -2
|
@@ -31,6 +31,22 @@ export interface NotificationItem {
|
|
|
31
31
|
read?: boolean
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Every user-facing string the bell renders on its own. Apps with i18n pass
|
|
36
|
+
* their translations in; the English defaults keep the component usable bare.
|
|
37
|
+
* Without this the header/row menus would be hardcoded English in every locale.
|
|
38
|
+
*/
|
|
39
|
+
export interface NotificationLabels {
|
|
40
|
+
title?: string
|
|
41
|
+
markAllRead?: string
|
|
42
|
+
markAsRead?: string
|
|
43
|
+
markAsUnread?: string
|
|
44
|
+
open?: string
|
|
45
|
+
dismiss?: string
|
|
46
|
+
empty?: string
|
|
47
|
+
ariaLabel?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
34
50
|
const props = withDefaults(
|
|
35
51
|
defineProps<{
|
|
36
52
|
items?: NotificationItem[]
|
|
@@ -43,10 +59,30 @@ const props = withDefaults(
|
|
|
43
59
|
maxBadge?: number
|
|
44
60
|
/** Right-click menu items; overrides the built-in quick actions. */
|
|
45
61
|
menuItems?: ContextMenuItemDef[]
|
|
62
|
+
/** Translations for the strings the bell renders itself. */
|
|
63
|
+
labels?: NotificationLabels
|
|
64
|
+
/**
|
|
65
|
+
* Formats `item.createdAt` for display. Defaults to a compact English
|
|
66
|
+
* relative time ("just now", "5m", "3h"); pass your own to localise it.
|
|
67
|
+
*/
|
|
68
|
+
formatTime?: (iso: string) => string
|
|
46
69
|
}>(),
|
|
47
70
|
{ items: () => [], maxBadge: 9 },
|
|
48
71
|
)
|
|
49
72
|
|
|
73
|
+
// Single lookup for every label, so the template never repeats the fallbacks.
|
|
74
|
+
// `title` stays a top-level prop for compatibility and wins over labels.title.
|
|
75
|
+
const l = computed(() => ({
|
|
76
|
+
title: props.title ?? props.labels?.title ?? "Notifications",
|
|
77
|
+
markAllRead: props.labels?.markAllRead ?? "Mark all read",
|
|
78
|
+
markAsRead: props.labels?.markAsRead ?? "Mark as read",
|
|
79
|
+
markAsUnread: props.labels?.markAsUnread ?? "Mark as unread",
|
|
80
|
+
open: props.labels?.open ?? "Open",
|
|
81
|
+
dismiss: props.labels?.dismiss ?? "Dismiss",
|
|
82
|
+
empty: props.emptyLabel ?? props.labels?.empty ?? "No notifications",
|
|
83
|
+
ariaLabel: props.labels?.ariaLabel ?? props.labels?.title ?? "Notifications",
|
|
84
|
+
}))
|
|
85
|
+
|
|
50
86
|
// Popover open state. A defineModel (not a plain `open` prop) gives a local
|
|
51
87
|
// fallback, so the bell still opens when the consumer only listens to
|
|
52
88
|
// @update:open without binding :open — otherwise the popover is stuck in
|
|
@@ -77,7 +113,7 @@ const hasUnread = computed(() => unread.value > 0)
|
|
|
77
113
|
const contextMenu = computed<ContextMenuItemDef[]>(() =>
|
|
78
114
|
props.menuItems ?? [
|
|
79
115
|
{
|
|
80
|
-
label:
|
|
116
|
+
label: l.value.markAllRead,
|
|
81
117
|
icon: "lucide:check-check",
|
|
82
118
|
disabled: !hasUnread.value,
|
|
83
119
|
onSelect: () => emit("markAllRead"),
|
|
@@ -97,26 +133,26 @@ function rowMenu(item: NotificationItem): ContextMenuItemDef[] {
|
|
|
97
133
|
const actions: ContextMenuItemDef[] = []
|
|
98
134
|
if (item.read) {
|
|
99
135
|
actions.push({
|
|
100
|
-
label:
|
|
136
|
+
label: l.value.markAsUnread,
|
|
101
137
|
icon: "lucide:dot",
|
|
102
138
|
onSelect: () => emit("markUnread", item.id),
|
|
103
139
|
})
|
|
104
140
|
} else {
|
|
105
141
|
actions.push({
|
|
106
|
-
label:
|
|
142
|
+
label: l.value.markAsRead,
|
|
107
143
|
icon: "lucide:check",
|
|
108
144
|
onSelect: () => emit("markRead", item.id),
|
|
109
145
|
})
|
|
110
146
|
}
|
|
111
147
|
if (item.link) {
|
|
112
148
|
actions.push({
|
|
113
|
-
label:
|
|
149
|
+
label: l.value.open,
|
|
114
150
|
icon: "lucide:external-link",
|
|
115
151
|
onSelect: () => emit("select", item),
|
|
116
152
|
})
|
|
117
153
|
}
|
|
118
154
|
actions.push({
|
|
119
|
-
label:
|
|
155
|
+
label: l.value.dismiss,
|
|
120
156
|
icon: "lucide:x",
|
|
121
157
|
danger: true,
|
|
122
158
|
separatorBefore: true,
|
|
@@ -126,8 +162,10 @@ function rowMenu(item: NotificationItem): ContextMenuItemDef[] {
|
|
|
126
162
|
}
|
|
127
163
|
|
|
128
164
|
// Compact relative time ("just now", "5m", "3h", "2d"); falls back to a date.
|
|
165
|
+
// A `formatTime` prop takes over entirely — that's the localisation seam.
|
|
129
166
|
function timeAgo(iso?: string): string {
|
|
130
167
|
if (!iso) return ""
|
|
168
|
+
if (props.formatTime) return props.formatTime(iso)
|
|
131
169
|
const then = new Date(iso).getTime()
|
|
132
170
|
if (Number.isNaN(then)) return ""
|
|
133
171
|
const secs = Math.max(0, Math.round((Date.now() - then) / 1000))
|
|
@@ -160,7 +198,7 @@ function timeAgo(iso?: string): string {
|
|
|
160
198
|
variant="ghost"
|
|
161
199
|
size="sm"
|
|
162
200
|
class="relative"
|
|
163
|
-
aria-label="
|
|
201
|
+
:aria-label="l.ariaLabel"
|
|
164
202
|
@click="open = !open"
|
|
165
203
|
>
|
|
166
204
|
<LpIcon name="lucide:bell" :size="18" />
|
|
@@ -176,14 +214,14 @@ function timeAgo(iso?: string): string {
|
|
|
176
214
|
</template>
|
|
177
215
|
|
|
178
216
|
<header class="flex items-center justify-between gap-2 px-3 py-2.5">
|
|
179
|
-
<span class="text-sm font-semibold">{{ title
|
|
217
|
+
<span class="text-sm font-semibold">{{ l.title }}</span>
|
|
180
218
|
<button
|
|
181
219
|
v-if="hasUnread"
|
|
182
220
|
type="button"
|
|
183
221
|
class="rounded-md px-1.5 py-0.5 text-xs font-medium text-brand outline-none transition-colors hover:bg-brand-soft focus-visible:bg-brand-soft"
|
|
184
222
|
@click="emit('markAllRead')"
|
|
185
223
|
>
|
|
186
|
-
|
|
224
|
+
{{ l.markAllRead }}
|
|
187
225
|
</button>
|
|
188
226
|
</header>
|
|
189
227
|
|
|
@@ -194,7 +232,7 @@ function timeAgo(iso?: string): string {
|
|
|
194
232
|
<LpEmptyState
|
|
195
233
|
v-else-if="!items.length"
|
|
196
234
|
icon="lucide:bell-off"
|
|
197
|
-
:title="
|
|
235
|
+
:title="l.empty"
|
|
198
236
|
class="px-3 py-10"
|
|
199
237
|
/>
|
|
200
238
|
|
|
@@ -59,7 +59,13 @@ const barFade =
|
|
|
59
59
|
</script>
|
|
60
60
|
|
|
61
61
|
<template>
|
|
62
|
-
|
|
62
|
+
<!-- A flex column, not a plain block: the viewport below sizes itself with
|
|
63
|
+
`flex-1 min-h-0` rather than `height:100%`. A percentage height resolves
|
|
64
|
+
against the root's own height, which in the common case — a root stretched
|
|
65
|
+
by `flex-1` inside a capped panel — is itself content-derived, so the
|
|
66
|
+
viewport grew to the full content height and scrolled nothing while the
|
|
67
|
+
root clipped the overflow. -->
|
|
68
|
+
<ScrollAreaRoot class="relative flex flex-col overflow-hidden" type="hover" :scroll-hide-delay="500">
|
|
63
69
|
<!-- The bar itself is a pointer target far wider than it looks: the visible
|
|
64
70
|
rail is the thumb, sized below, while the track pads it out to a
|
|
65
71
|
grabbable strip. A 4px-wide hit area is near-impossible to catch with a
|
|
@@ -94,7 +100,7 @@ const barFade =
|
|
|
94
100
|
|
|
95
101
|
<ScrollAreaViewport
|
|
96
102
|
ref="viewportRef"
|
|
97
|
-
class="
|
|
103
|
+
class="w-full min-h-0 min-w-0 flex-1 [&>div]:!block [&>div]:!min-w-0"
|
|
98
104
|
:class="[
|
|
99
105
|
instant ? '' : 'scroll-smooth motion-reduce:scroll-auto',
|
|
100
106
|
fade ? '[mask-image:linear-gradient(to_bottom,transparent_0,black_14px,black_calc(100%-14px),transparent_100%)]' : '',
|
|
@@ -24,6 +24,13 @@ const props = withDefaults(
|
|
|
24
24
|
modelValue?: string
|
|
25
25
|
/** Themes to offer. Defaults to all built-in presets. */
|
|
26
26
|
themes?: TokenSet[]
|
|
27
|
+
/**
|
|
28
|
+
* Themes the LEFT-click cycles through — names from `themes`, or TokenSets.
|
|
29
|
+
* Defaults to the full list (cycle everything). Pass two, e.g.
|
|
30
|
+
* `:cycle-themes="['Dark', 'Light']"`, for a quick dark/light toggle while
|
|
31
|
+
* the right-click menu still offers every theme.
|
|
32
|
+
*/
|
|
33
|
+
cycleThemes?: (string | TokenSet)[]
|
|
27
34
|
/**
|
|
28
35
|
* Trigger style:
|
|
29
36
|
* - "swatch" (default): a square showing the active theme's brand colour.
|
|
@@ -106,9 +113,24 @@ function applyTheme(t: TokenSet, ev?: MouseEvent) {
|
|
|
106
113
|
emit("change", t)
|
|
107
114
|
}
|
|
108
115
|
|
|
109
|
-
//
|
|
116
|
+
// The left-click rotation, which can be narrower than the offered `themes` —
|
|
117
|
+
// names resolve against that list. Falls back to the full list, so a switcher
|
|
118
|
+
// with no `cycleThemes` keeps cycling everything as before.
|
|
119
|
+
const cycleList = computed<TokenSet[]>(() => {
|
|
120
|
+
if (!props.cycleThemes?.length) return list.value
|
|
121
|
+
const resolved = props.cycleThemes
|
|
122
|
+
.map((t) =>
|
|
123
|
+
typeof t === "string" ? list.value.find((x) => x.name === t) : t,
|
|
124
|
+
)
|
|
125
|
+
.filter((t): t is TokenSet => Boolean(t))
|
|
126
|
+
return resolved.length ? resolved : list.value
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// Left-click → next theme in the rotation (wraps), revealing from the click
|
|
130
|
+
// point. When the active theme is outside the rotation (picked from the
|
|
131
|
+
// right-click menu), findIndex gives -1 and we land on its first entry.
|
|
110
132
|
function cycle(ev: MouseEvent) {
|
|
111
|
-
const arr =
|
|
133
|
+
const arr = cycleList.value
|
|
112
134
|
if (!arr.length) return
|
|
113
135
|
const i = arr.findIndex((t) => t.name === activeName.value)
|
|
114
136
|
const next = arr[(i + 1) % arr.length]
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Postal-address types, tile maths and the pluggable geocoder behind
|
|
3
|
+
* LpAddressInput / LpMapPicker.
|
|
4
|
+
*
|
|
5
|
+
* Nothing here is bound to a particular service: the component takes a
|
|
6
|
+
* `GeocodeProvider` and a `TileSource`, and the Nominatim/OSM pair below is
|
|
7
|
+
* only the default. Swap either for a self-hosted instance, a paid provider or
|
|
8
|
+
* a stub in tests without touching the components.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface LatLon {
|
|
12
|
+
lat: number
|
|
13
|
+
lon: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AddressValue {
|
|
17
|
+
/** Street and house number, e.g. "Baker Street 221B". */
|
|
18
|
+
line: string
|
|
19
|
+
/** Apartment, suite, floor — only shown when LpAddressInput asks for it. */
|
|
20
|
+
line2?: string
|
|
21
|
+
city: string
|
|
22
|
+
postalCode: string
|
|
23
|
+
/** ISO 3166-1 alpha-2, uppercase — same shape LpPhoneInput uses. */
|
|
24
|
+
country: string
|
|
25
|
+
/** Set once a point is picked on the map or a suggestion carries one. */
|
|
26
|
+
lat?: number
|
|
27
|
+
lon?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function emptyAddress(): AddressValue {
|
|
31
|
+
return { line: "", city: "", postalCode: "", country: "" }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AddressSuggestion {
|
|
35
|
+
/** Text to put in the field that asked for it. */
|
|
36
|
+
value: string
|
|
37
|
+
label: string
|
|
38
|
+
/** Fuller context — the whole formatted address, shown under the label. */
|
|
39
|
+
description?: string
|
|
40
|
+
/** Parsed parts, as much as the provider resolved. */
|
|
41
|
+
address: Partial<AddressValue>
|
|
42
|
+
lat?: number
|
|
43
|
+
lon?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Which field is being completed — providers can bias results accordingly. */
|
|
47
|
+
export type AddressField = "line" | "city" | "postalCode"
|
|
48
|
+
|
|
49
|
+
export interface GeocodeQuery {
|
|
50
|
+
/** Restrict to a country (ISO2) when the form already has one. */
|
|
51
|
+
country?: string
|
|
52
|
+
/** Preferred result language, as an Accept-Language value. */
|
|
53
|
+
language?: string
|
|
54
|
+
limit?: number
|
|
55
|
+
signal?: AbortSignal
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface GeocodeProvider {
|
|
59
|
+
search(term: string, field: AddressField, query?: GeocodeQuery): Promise<AddressSuggestion[]>
|
|
60
|
+
/** Point → address. Undefined when the point resolves to nothing. */
|
|
61
|
+
reverse(lat: number, lon: number, query?: GeocodeQuery): Promise<AddressSuggestion | undefined>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface NominatimOptions {
|
|
65
|
+
/** Base URL of a Nominatim instance, no trailing slash. */
|
|
66
|
+
endpoint?: string
|
|
67
|
+
language?: string
|
|
68
|
+
/**
|
|
69
|
+
* Public Nominatim asks for an identifying UA and rate-limits hard; browsers
|
|
70
|
+
* won't let us set User-Agent, so this rides along as `email` instead, which
|
|
71
|
+
* their usage policy accepts. Point `endpoint` at your own instance for
|
|
72
|
+
* anything busier than a settings form.
|
|
73
|
+
*/
|
|
74
|
+
email?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface NominatimAddress {
|
|
78
|
+
road?: string
|
|
79
|
+
house_number?: string
|
|
80
|
+
city?: string
|
|
81
|
+
town?: string
|
|
82
|
+
village?: string
|
|
83
|
+
municipality?: string
|
|
84
|
+
postcode?: string
|
|
85
|
+
country_code?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface NominatimResult {
|
|
89
|
+
display_name: string
|
|
90
|
+
name?: string
|
|
91
|
+
lat?: string
|
|
92
|
+
lon?: string
|
|
93
|
+
address?: NominatimAddress
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const NOMINATIM_ENDPOINT = "https://nominatim.openstreetmap.org"
|
|
97
|
+
|
|
98
|
+
export function createNominatimProvider(options: NominatimOptions = {}): GeocodeProvider {
|
|
99
|
+
const endpoint = options.endpoint ?? NOMINATIM_ENDPOINT
|
|
100
|
+
|
|
101
|
+
function common(query: GeocodeQuery | undefined, params: URLSearchParams) {
|
|
102
|
+
params.set("format", "jsonv2")
|
|
103
|
+
params.set("addressdetails", "1")
|
|
104
|
+
if (options.email) params.set("email", options.email)
|
|
105
|
+
if (query?.country) params.set("countrycodes", query.country.toLowerCase())
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function get<T>(path: string, params: URLSearchParams, query?: GeocodeQuery): Promise<T> {
|
|
109
|
+
const language = query?.language ?? options.language
|
|
110
|
+
const res = await fetch(`${endpoint}${path}?${params}`, {
|
|
111
|
+
signal: query?.signal,
|
|
112
|
+
headers: language ? { "Accept-Language": language } : undefined,
|
|
113
|
+
})
|
|
114
|
+
if (!res.ok) throw new Error(`geocoder responded ${res.status}`)
|
|
115
|
+
return (await res.json()) as T
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
async search(term, field, query) {
|
|
120
|
+
const params = new URLSearchParams({ q: term, limit: String(query?.limit ?? 6) })
|
|
121
|
+
common(query, params)
|
|
122
|
+
const results = await get<NominatimResult[]>("/search", params, query)
|
|
123
|
+
return results.map((result) => toSuggestion(result, field))
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
async reverse(lat, lon, query) {
|
|
127
|
+
const params = new URLSearchParams({ lat: String(lat), lon: String(lon) })
|
|
128
|
+
common(query, params)
|
|
129
|
+
// countrycodes is a /search filter; on /reverse it does nothing but is
|
|
130
|
+
// rejected by stricter instances, so drop it here.
|
|
131
|
+
params.delete("countrycodes")
|
|
132
|
+
const result = await get<NominatimResult | { error: unknown }>("/reverse", params, query)
|
|
133
|
+
if (!result || "error" in result) return undefined
|
|
134
|
+
return toSuggestion(result, "line")
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function toSuggestion(result: NominatimResult, field: AddressField): AddressSuggestion {
|
|
140
|
+
const parts = result.address ?? {}
|
|
141
|
+
const city = parts.city || parts.town || parts.village || parts.municipality || ""
|
|
142
|
+
const street = [parts.road, parts.house_number].filter(Boolean).join(" ")
|
|
143
|
+
const address: Partial<AddressValue> = {
|
|
144
|
+
line: street || undefined,
|
|
145
|
+
city: city || undefined,
|
|
146
|
+
postalCode: parts.postcode || undefined,
|
|
147
|
+
country: parts.country_code ? parts.country_code.toUpperCase() : undefined,
|
|
148
|
+
}
|
|
149
|
+
const lat = result.lat ? Number(result.lat) : undefined
|
|
150
|
+
const lon = result.lon ? Number(result.lon) : undefined
|
|
151
|
+
|
|
152
|
+
// The value is whatever the asking field wants; the full display name always
|
|
153
|
+
// rides along as context so two similar streets stay tellable apart.
|
|
154
|
+
const value =
|
|
155
|
+
field === "city"
|
|
156
|
+
? city || result.name || result.display_name
|
|
157
|
+
: field === "postalCode"
|
|
158
|
+
? parts.postcode || ""
|
|
159
|
+
: street || result.name || result.display_name
|
|
160
|
+
|
|
161
|
+
return { value, label: value || result.display_name, description: result.display_name, address, lat, lon }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Every user-facing string in LpAddressInput, so an app can translate it
|
|
166
|
+
* without the kit taking an i18n dependency. Pass a partial — anything absent
|
|
167
|
+
* keeps the English default.
|
|
168
|
+
*/
|
|
169
|
+
export interface AddressLabels {
|
|
170
|
+
line: string
|
|
171
|
+
linePlaceholder: string
|
|
172
|
+
secondLine: string
|
|
173
|
+
secondLinePlaceholder: string
|
|
174
|
+
city: string
|
|
175
|
+
cityPlaceholder: string
|
|
176
|
+
postalCode: string
|
|
177
|
+
postalCodePlaceholder: string
|
|
178
|
+
country: string
|
|
179
|
+
countryPlaceholder: string
|
|
180
|
+
countrySearch: string
|
|
181
|
+
pickOnMap: string
|
|
182
|
+
mapTitle: string
|
|
183
|
+
mapHint: string
|
|
184
|
+
use: string
|
|
185
|
+
cancel: string
|
|
186
|
+
searching: string
|
|
187
|
+
noSuggestions: string
|
|
188
|
+
nothingHere: string
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Formats an address the way a shipping label would read. */
|
|
192
|
+
export function formatAddress(value: AddressValue, countryName?: string): string {
|
|
193
|
+
const locality = [value.postalCode, value.city].filter(Boolean).join(" ")
|
|
194
|
+
return [value.line, locality, countryName ?? value.country].filter(Boolean).join(", ")
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface TileSource {
|
|
198
|
+
/** Template with {z}/{x}/{y}, plus optional {s} for a subdomain. */
|
|
199
|
+
url: string
|
|
200
|
+
/** Rendered under the map — most tile terms require crediting the source. */
|
|
201
|
+
attribution?: string
|
|
202
|
+
subdomains?: string[]
|
|
203
|
+
minZoom?: number
|
|
204
|
+
maxZoom?: number
|
|
205
|
+
/** Edge length of one tile in CSS pixels. Default 256. */
|
|
206
|
+
tileSize?: number
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export const OSM_TILES: TileSource = {
|
|
210
|
+
url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
211
|
+
attribution: "© OpenStreetMap contributors",
|
|
212
|
+
minZoom: 1,
|
|
213
|
+
maxZoom: 19,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function tileUrl(source: TileSource, z: number, x: number, y: number): string {
|
|
217
|
+
const subdomains = source.subdomains
|
|
218
|
+
const subdomain = subdomains?.length ? subdomains[Math.abs(x + y) % subdomains.length] : ""
|
|
219
|
+
return source.url
|
|
220
|
+
.replace("{s}", subdomain)
|
|
221
|
+
.replace("{z}", String(z))
|
|
222
|
+
.replace("{x}", String(x))
|
|
223
|
+
.replace("{y}", String(y))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/*
|
|
227
|
+
* Web Mercator, the projection every {z}/{x}/{y} tile scheme uses. `project`
|
|
228
|
+
* gives world pixels at a zoom level (world edge = tileSize * 2^zoom), which is
|
|
229
|
+
* the space the map does all its panning arithmetic in.
|
|
230
|
+
*/
|
|
231
|
+
export const MAX_LATITUDE = 85.05112878
|
|
232
|
+
|
|
233
|
+
export function project(lat: number, lon: number, zoom: number, tileSize = 256) {
|
|
234
|
+
const size = tileSize * 2 ** zoom
|
|
235
|
+
const clamped = Math.max(-MAX_LATITUDE, Math.min(MAX_LATITUDE, lat))
|
|
236
|
+
const sin = Math.sin((clamped * Math.PI) / 180)
|
|
237
|
+
return {
|
|
238
|
+
x: ((lon + 180) / 360) * size,
|
|
239
|
+
y: (0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)) * size,
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function unproject(x: number, y: number, zoom: number, tileSize = 256) {
|
|
244
|
+
const size = tileSize * 2 ** zoom
|
|
245
|
+
const n = Math.PI - (2 * Math.PI * y) / size
|
|
246
|
+
return {
|
|
247
|
+
lat: (180 / Math.PI) * Math.atan(Math.sinh(n)),
|
|
248
|
+
lon: (x / size) * 360 - 180,
|
|
249
|
+
}
|
|
250
|
+
}
|