@joewinke/jatui 0.1.19 → 0.1.21

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.
@@ -0,0 +1,296 @@
1
+ <script lang="ts">
2
+ /**
3
+ * KeyboardShortcutsOverlay — press `?` to toggle a modal listing the
4
+ * keyboard shortcuts available on the current page. Press `?` again or
5
+ * `Escape` to dismiss. Input/contenteditable fields and Ctrl/Meta/Alt
6
+ * combos are ignored so the shortcut never interferes with typing.
7
+ *
8
+ * Usage:
9
+ *
10
+ * <KeyboardShortcutsOverlay shortcuts={[
11
+ * { key: 'j / ↓', description: 'Focus next item' },
12
+ * { key: 'k / ↑', description: 'Focus previous item' },
13
+ * { key: 'Enter', description: 'Open detail' },
14
+ * ]} />
15
+ */
16
+
17
+ export interface KeyboardShortcut {
18
+ key: string;
19
+ description: string;
20
+ }
21
+
22
+ export interface SectionEntry {
23
+ key: string;
24
+ description: string;
25
+ /** Optional oklch color to tint the key badge — used for legend entries */
26
+ color?: string;
27
+ }
28
+
29
+ export interface ShortcutSection {
30
+ title: string;
31
+ shortcuts: SectionEntry[];
32
+ }
33
+
34
+ let {
35
+ shortcuts = [],
36
+ sections,
37
+ title = 'Keyboard Shortcuts',
38
+ open = $bindable(false)
39
+ }: {
40
+ shortcuts?: KeyboardShortcut[];
41
+ /**
42
+ * Optional grouped sections (e.g. mode-specific shortcut tables for
43
+ * /inbox: list / detail / compose). When provided, takes
44
+ * precedence over the flat `shortcuts` prop.
45
+ */
46
+ sections?: ShortcutSection[];
47
+ title?: string;
48
+ open?: boolean;
49
+ } = $props();
50
+
51
+ let isOpen = $state(open);
52
+
53
+ $effect(() => {
54
+ isOpen = open;
55
+ });
56
+ $effect(() => {
57
+ open = isOpen;
58
+ });
59
+
60
+ function isTypingTarget(target: EventTarget | null): boolean {
61
+ if (!(target instanceof HTMLElement)) return false;
62
+ const tag = target.tagName;
63
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
64
+ return target.isContentEditable;
65
+ }
66
+
67
+ function onKeydown(e: KeyboardEvent) {
68
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
69
+
70
+ if (e.key === '?') {
71
+ // `?` is always Shift+/. Don't trigger while typing — would swallow the char.
72
+ if (isTypingTarget(e.target)) return;
73
+ e.preventDefault();
74
+ isOpen = !isOpen;
75
+ return;
76
+ }
77
+
78
+ if (isOpen && e.key === 'Escape') {
79
+ e.preventDefault();
80
+ isOpen = false;
81
+ }
82
+ }
83
+
84
+ $effect(() => {
85
+ window.addEventListener('keydown', onKeydown);
86
+ return () => window.removeEventListener('keydown', onKeydown);
87
+ });
88
+ </script>
89
+
90
+ {#if isOpen}
91
+ <div
92
+ class="kso-backdrop"
93
+ role="presentation"
94
+ onclick={() => (isOpen = false)}
95
+ >
96
+ <div
97
+ class="kso-panel animate-scale-in"
98
+ role="dialog"
99
+ aria-modal="true"
100
+ aria-label={title}
101
+ onclick={(e) => e.stopPropagation()}
102
+ onkeydown={(e) => e.stopPropagation()}
103
+ tabindex="-1"
104
+ >
105
+ <div class="kso-header">
106
+ <h2 class="kso-title">{title}</h2>
107
+ <button
108
+ type="button"
109
+ class="kso-close"
110
+ onclick={() => (isOpen = false)}
111
+ aria-label="Close keyboard shortcuts"
112
+ >
113
+ ×
114
+ </button>
115
+ </div>
116
+ <div class="kso-list">
117
+ {#if sections && sections.length > 0}
118
+ {#each sections as section, sIdx (section.title + '::' + sIdx)}
119
+ <div class="kso-section-title">{section.title}</div>
120
+ {#each section.shortcuts as { key, description, color }, i (section.title + '::' + key + '::' + i)}
121
+ <div class="kso-row">
122
+ <kbd
123
+ class="kso-key"
124
+ style={color
125
+ ? `color:${color};border-color:color-mix(in oklch,${color} 50%,oklch(0.35 0.02 250));background:color-mix(in oklch,${color} 12%,oklch(0.25 0.02 250))`
126
+ : ''}
127
+ >
128
+ {key}
129
+ </kbd>
130
+ <span class="kso-description">{description}</span>
131
+ </div>
132
+ {/each}
133
+ {/each}
134
+ {:else}
135
+ {#each shortcuts as { key, description }, i (key + '::' + i)}
136
+ <div class="kso-row">
137
+ <kbd class="kso-key">{key}</kbd>
138
+ <span class="kso-description">{description}</span>
139
+ </div>
140
+ {/each}
141
+ {/if}
142
+ <div class="kso-row kso-row-meta">
143
+ <kbd class="kso-key">?</kbd>
144
+ <span class="kso-description">Toggle this overlay</span>
145
+ </div>
146
+ <div class="kso-row kso-row-meta">
147
+ <kbd class="kso-key">Esc</kbd>
148
+ <span class="kso-description">Close overlay</span>
149
+ </div>
150
+ </div>
151
+ </div>
152
+ </div>
153
+ {/if}
154
+
155
+ <style>
156
+ .kso-backdrop {
157
+ position: fixed;
158
+ inset: 0;
159
+ background: oklch(0 0 0 / 0.6);
160
+ backdrop-filter: blur(2px);
161
+ z-index: 60;
162
+ display: flex;
163
+ align-items: center;
164
+ justify-content: center;
165
+ padding: 1.5rem;
166
+ animation: kso-fade 0.15s ease-out;
167
+ }
168
+
169
+ .kso-panel {
170
+ background: oklch(0.18 0.02 250);
171
+ border: 1px solid oklch(0.30 0.03 250);
172
+ border-radius: 0.75rem;
173
+ box-shadow: 0 20px 60px oklch(0 0 0 / 0.6);
174
+ max-width: 34rem;
175
+ width: 100%;
176
+ max-height: 80vh;
177
+ overflow: hidden;
178
+ display: flex;
179
+ flex-direction: column;
180
+ }
181
+
182
+ .kso-header {
183
+ display: flex;
184
+ align-items: center;
185
+ justify-content: space-between;
186
+ padding: 0.9rem 1.25rem;
187
+ border-bottom: 1px solid oklch(0.25 0.02 250);
188
+ flex-shrink: 0;
189
+ }
190
+
191
+ .kso-title {
192
+ font-size: 0.95rem;
193
+ font-weight: 600;
194
+ color: oklch(0.92 0.02 250);
195
+ margin: 0;
196
+ letter-spacing: 0.01em;
197
+ }
198
+
199
+ .kso-close {
200
+ background: transparent;
201
+ border: none;
202
+ color: oklch(0.65 0.02 250);
203
+ font-size: 1.5rem;
204
+ line-height: 1;
205
+ cursor: pointer;
206
+ padding: 0.15rem 0.5rem;
207
+ border-radius: 0.35rem;
208
+ transition:
209
+ background 0.12s ease,
210
+ color 0.12s ease;
211
+ }
212
+
213
+ .kso-close:hover {
214
+ color: oklch(0.94 0.02 250);
215
+ background: oklch(0.25 0.02 250);
216
+ }
217
+
218
+ .kso-list {
219
+ padding: 0.75rem 1.25rem 1.25rem;
220
+ display: flex;
221
+ flex-direction: column;
222
+ gap: 0.3rem;
223
+ overflow-y: auto;
224
+ }
225
+
226
+ .kso-row {
227
+ display: flex;
228
+ align-items: center;
229
+ gap: 1rem;
230
+ padding: 0.4rem 0.25rem;
231
+ }
232
+
233
+ .kso-section-title {
234
+ font-size: 0.7rem;
235
+ text-transform: uppercase;
236
+ letter-spacing: 0.08em;
237
+ color: oklch(0.62 0.05 240);
238
+ padding: 0.55rem 0.25rem 0.2rem;
239
+ border-bottom: 1px solid oklch(0.25 0.02 250);
240
+ margin-top: 0.25rem;
241
+ }
242
+
243
+ .kso-section-title:first-of-type {
244
+ margin-top: 0;
245
+ padding-top: 0.1rem;
246
+ }
247
+
248
+ .kso-row-meta {
249
+ opacity: 0.7;
250
+ border-top: 1px dashed oklch(0.25 0.02 250);
251
+ margin-top: 0.25rem;
252
+ padding-top: 0.5rem;
253
+ }
254
+
255
+ .kso-row-meta:not(:first-of-type) {
256
+ border-top: none;
257
+ margin-top: 0;
258
+ }
259
+
260
+ .kso-key {
261
+ font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
262
+ font-size: 0.78rem;
263
+ background: oklch(0.25 0.02 250);
264
+ border: 1px solid oklch(0.35 0.02 250);
265
+ border-bottom-width: 2px;
266
+ border-radius: 0.35rem;
267
+ padding: 0.18rem 0.55rem;
268
+ min-width: 3.25rem;
269
+ text-align: center;
270
+ color: oklch(0.93 0.02 250);
271
+ flex-shrink: 0;
272
+ white-space: nowrap;
273
+ }
274
+
275
+ .kso-description {
276
+ font-size: 0.85rem;
277
+ color: oklch(0.78 0.02 250);
278
+ line-height: 1.35;
279
+ }
280
+
281
+ @keyframes kso-fade {
282
+ from {
283
+ opacity: 0;
284
+ }
285
+ to {
286
+ opacity: 1;
287
+ }
288
+ }
289
+
290
+ @media (prefers-reduced-motion: reduce) {
291
+ .kso-backdrop,
292
+ .kso-panel {
293
+ animation: none !important;
294
+ }
295
+ }
296
+ </style>
@@ -0,0 +1,186 @@
1
+ <script lang="ts">
2
+ /**
3
+ * LocationMap — single-location Google Maps embed with a pulsing dot marker.
4
+ *
5
+ * Shows the current GPS position on a map. While waiting for coords, renders
6
+ * a "Waiting for GPS signal…" placeholder.
7
+ *
8
+ * API key is passed explicitly — jatui has no $env dependency.
9
+ *
10
+ * Usage:
11
+ *
12
+ * <LocationMap
13
+ * apiKey={PUBLIC_GOOGLE_MAPS_API_KEY}
14
+ * latitude={coords.latitude}
15
+ * longitude={coords.longitude}
16
+ * accuracy={coords.accuracy}
17
+ * />
18
+ *
19
+ * Ported from flush technician/LocationMap (190L) — steelbridge fork capture (jst-e0d1u.4).
20
+ */
21
+ import { onMount, onDestroy } from 'svelte';
22
+ import { loadGoogleMapsAPI, isGoogleMapsLoaded } from '../utils/googleMapsLoader';
23
+
24
+ interface Props {
25
+ apiKey: string;
26
+ latitude?: number;
27
+ longitude?: number;
28
+ accuracy?: number;
29
+ showAccuracyCircle?: boolean;
30
+ height?: string;
31
+ }
32
+
33
+ let {
34
+ apiKey,
35
+ latitude,
36
+ longitude,
37
+ accuracy,
38
+ showAccuracyCircle = true,
39
+ height = '200px'
40
+ }: Props = $props();
41
+
42
+ let mapContainer: HTMLElement;
43
+ let map: google.maps.Map;
44
+ let marker: any;
45
+ let accuracyCircle: google.maps.Circle;
46
+ let AdvancedMarkerElement: any;
47
+
48
+ onMount(async () => {
49
+ if (!apiKey) {
50
+ console.warn('LocationMap: No Google Maps API key provided');
51
+ return;
52
+ }
53
+ try {
54
+ await loadGoogleMapsAPI({ apiKey, libraries: ['marker'] });
55
+ if (isGoogleMapsLoaded() && window.google?.maps?.marker) {
56
+ AdvancedMarkerElement = window.google.maps.marker.AdvancedMarkerElement;
57
+ initializeMap();
58
+ }
59
+ } catch (e) {
60
+ console.warn('LocationMap: Failed to load Google Maps API', e);
61
+ }
62
+ });
63
+
64
+ onDestroy(() => {
65
+ if (marker) marker.map = null;
66
+ if (accuracyCircle) accuracyCircle.setMap(null);
67
+ });
68
+
69
+ function initializeMap() {
70
+ const position = { lat: latitude ?? 26.3683, lng: longitude ?? -80.1289 };
71
+
72
+ map = new google.maps.Map(mapContainer, {
73
+ zoom: 16,
74
+ center: position,
75
+ mapId: 'jatui-location-map',
76
+ disableDefaultUI: true,
77
+ zoomControl: true,
78
+ mapTypeControl: false,
79
+ streetViewControl: false,
80
+ fullscreenControl: false
81
+ });
82
+
83
+ updateMarker();
84
+ }
85
+
86
+ function updateMarker() {
87
+ if (!map || !AdvancedMarkerElement) return;
88
+
89
+ const position = { lat: latitude ?? 26.3683, lng: longitude ?? -80.1289 };
90
+
91
+ if (marker) marker.map = null;
92
+
93
+ const el = document.createElement('div');
94
+ el.className = 'lm-current-location';
95
+ el.innerHTML = '<div class="lm-pulse-ring"></div><div class="lm-dot"></div>';
96
+
97
+ marker = new AdvancedMarkerElement({
98
+ position,
99
+ map,
100
+ content: el,
101
+ title: 'Current location'
102
+ });
103
+
104
+ if (accuracyCircle) accuracyCircle.setMap(null);
105
+
106
+ if (showAccuracyCircle && accuracy) {
107
+ accuracyCircle = new google.maps.Circle({
108
+ strokeColor: '#4285F4',
109
+ strokeOpacity: 0.8,
110
+ strokeWeight: 1,
111
+ fillColor: '#4285F4',
112
+ fillOpacity: 0.2,
113
+ map,
114
+ center: position,
115
+ radius: accuracy
116
+ });
117
+ }
118
+
119
+ map.setCenter(position);
120
+ }
121
+
122
+ $effect(() => {
123
+ if (map && latitude && longitude) updateMarker();
124
+ });
125
+ </script>
126
+
127
+ <div
128
+ class="relative rounded-lg overflow-hidden border border-base-300"
129
+ style="height: {height}"
130
+ >
131
+ <div bind:this={mapContainer} class="w-full h-full"></div>
132
+
133
+ {#if !latitude || !longitude}
134
+ <div class="absolute inset-0 flex items-center justify-center bg-base-200">
135
+ <div class="text-center">
136
+ <div class="loading loading-spinner loading-md text-primary"></div>
137
+ <p class="text-sm mt-2 text-base-content/70">Waiting for GPS signal…</p>
138
+ </div>
139
+ </div>
140
+ {/if}
141
+ </div>
142
+
143
+ <style>
144
+ :global(.lm-current-location) {
145
+ position: relative;
146
+ width: 20px;
147
+ height: 20px;
148
+ }
149
+
150
+ :global(.lm-dot) {
151
+ position: absolute;
152
+ top: 50%;
153
+ left: 50%;
154
+ transform: translate(-50%, -50%);
155
+ width: 12px;
156
+ height: 12px;
157
+ background-color: #4285f4;
158
+ border: 2px solid white;
159
+ border-radius: 50%;
160
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
161
+ }
162
+
163
+ :global(.lm-pulse-ring) {
164
+ position: absolute;
165
+ top: 50%;
166
+ left: 50%;
167
+ transform: translate(-50%, -50%);
168
+ width: 20px;
169
+ height: 20px;
170
+ background-color: #4285f4;
171
+ border-radius: 50%;
172
+ opacity: 0.3;
173
+ animation: lm-pulse 2s infinite;
174
+ }
175
+
176
+ @keyframes lm-pulse {
177
+ 0% {
178
+ transform: translate(-50%, -50%) scale(1);
179
+ opacity: 0.3;
180
+ }
181
+ 100% {
182
+ transform: translate(-50%, -50%) scale(3);
183
+ opacity: 0;
184
+ }
185
+ }
186
+ </style>