@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.
Files changed (51) hide show
  1. package/dist/chunks/LpAddressInput-ChT1JQg0.js +366 -0
  2. package/dist/chunks/{LpAppShell-CV194os6.js → LpAppShell-C6Mj82LS.js} +2 -2
  3. package/dist/chunks/{LpConfirmDialog-DnReh5pF.js → LpConfirmDialog-3_Qu4hnA.js} +1 -1
  4. package/dist/chunks/{LpDrawer-BHzCy-Aa.js → LpDrawer-Fxk7ViQ-.js} +1 -1
  5. package/dist/chunks/{LpFileTree-YOJCuIjJ.js → LpFileTree-CXg3KDth.js} +1 -1
  6. package/dist/chunks/{LpLogViewer-BvzRZPjs.js → LpLogViewer-D7DNEYaG.js} +1 -1
  7. package/dist/chunks/LpMapPicker-DFKrd1Ne.js +509 -0
  8. package/dist/chunks/{LpModal-CezA8eJ3.js → LpModal-J-KRPO7F.js} +1 -1
  9. package/dist/chunks/{LpNotificationBell-BtfRoL2i.js → LpNotificationBell-D8sdQLgu.js} +26 -13
  10. package/dist/chunks/{LpScrollArea-De9DLpa1.js → LpScrollArea-CKhOx0_l.js} +2 -2
  11. package/dist/chunks/{LpSidebar-B11IDRQi.js → LpSidebar-Bk7SrINq.js} +2 -2
  12. package/dist/chunks/{LpSidebarNav-TIUNHZX-.js → LpSidebarNav-Cpd-yHXb.js} +1 -1
  13. package/dist/chunks/{LpTable-CKGaUbEM.js → LpTable-X3ieZmbr.js} +1 -1
  14. package/dist/chunks/{LpThemeSwitcher-BgWwfkxs.js → LpThemeSwitcher-DC6QygnU.js} +11 -3
  15. package/dist/component-names.d.ts +1 -1
  16. package/dist/components/LpAddressInput.vue.d.ts +53 -0
  17. package/dist/components/LpAddressInput.vue.js +4 -0
  18. package/dist/components/LpAlert.vue.d.ts +1 -1
  19. package/dist/components/LpAppShell.vue.js +1 -1
  20. package/dist/components/LpCommandPalette.vue.js +1 -1
  21. package/dist/components/LpConfirmDialog.vue.js +1 -1
  22. package/dist/components/LpDrawer.vue.js +1 -1
  23. package/dist/components/LpFileTree.vue.d.ts +2 -2
  24. package/dist/components/LpFileTree.vue.js +1 -1
  25. package/dist/components/LpLightbox.vue.d.ts +1 -1
  26. package/dist/components/LpLogViewer.vue.js +1 -1
  27. package/dist/components/LpMapPicker.vue.d.ts +44 -0
  28. package/dist/components/LpMapPicker.vue.js +4 -0
  29. package/dist/components/LpModal.vue.js +1 -1
  30. package/dist/components/LpNotificationBell.vue.d.ts +22 -0
  31. package/dist/components/LpNotificationBell.vue.js +1 -1
  32. package/dist/components/LpScrollArea.vue.js +1 -1
  33. package/dist/components/LpSidebar.vue.js +1 -1
  34. package/dist/components/LpSidebarNav.vue.js +1 -1
  35. package/dist/components/LpTable.vue.js +1 -1
  36. package/dist/components/LpThemeSwitcher.vue.d.ts +7 -0
  37. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  38. package/dist/components/address.d.ts +107 -0
  39. package/dist/components/address.js +96 -0
  40. package/dist/index.d.ts +5 -1
  41. package/dist/index.js +129 -117
  42. package/package.json +1 -1
  43. package/src/component-names.ts +2 -0
  44. package/src/components/LpAddressInput.vue +377 -0
  45. package/src/components/LpMapPicker.vue +665 -0
  46. package/src/components/LpNotificationBell.vue +47 -9
  47. package/src/components/LpScrollArea.vue +8 -2
  48. package/src/components/LpThemeSwitcher.vue +24 -2
  49. package/src/components/address.ts +250 -0
  50. package/src/index.ts +194 -169
  51. 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: "Mark all read",
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: "Mark as unread",
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: "Mark as read",
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: "Open",
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: "Dismiss",
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="Notifications"
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 ?? "Notifications" }}</span>
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
- Mark all read
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="emptyLabel ?? 'No notifications'"
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
- <ScrollAreaRoot class="relative overflow-hidden" type="hover" :scroll-hide-delay="500">
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="size-full min-w-0 [&>div]:!block [&>div]:!min-w-0"
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
- // Left-click → next theme in the list (wraps), revealing from the click point.
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 = list.value
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
+ }