mertani-web-toolkit 0.1.63 → 0.1.65

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.
@@ -59,35 +59,56 @@
59
59
  box-shadow 0.2s;
60
60
  }
61
61
 
62
- .select-trigger:not(:disabled) {
62
+ .select-trigger:not(.disabled) {
63
63
  background: var(--color-bg-surface);
64
64
  cursor: pointer;
65
65
  }
66
66
 
67
- .select-trigger:not(:disabled):hover {
67
+ .select-trigger:not(.disabled):hover {
68
68
  border-color: var(--color-border-form);
69
69
  }
70
70
 
71
- .select-trigger:not(:disabled):focus {
71
+ .select-trigger:not(.disabled):focus {
72
72
  outline: none;
73
73
  border-color: var(--color-bg-act-primary);
74
74
  box-shadow: 0 0 0 2px var(--color-bg-act-primary) 40;
75
75
  }
76
76
 
77
- .select-trigger:disabled {
77
+ .select-trigger.disabled {
78
78
  cursor: not-allowed;
79
79
  background: var(--color-bg-disabled);
80
80
  }
81
81
 
82
- .select-trigger.error:not(:disabled) {
82
+ .select-trigger.error:not(.disabled) {
83
83
  border-color: var(--color-text-error-ti);
84
84
  }
85
85
 
86
- .select-trigger.error:not(:disabled):focus {
86
+ .select-trigger.error:not(.disabled):focus {
87
87
  border-color: var(--color-text-error-ti);
88
88
  box-shadow: 0 0 0 2px var(--color-text-error-ti) 40;
89
89
  }
90
90
 
91
+ .select-clear-button {
92
+ display: flex;
93
+ align-items: center;
94
+ justify-content: center;
95
+ padding: 4px;
96
+ border-radius: 4px;
97
+ color: var(--color-text-tertiary);
98
+ background: transparent;
99
+ border: none;
100
+ cursor: pointer;
101
+ transition:
102
+ color 0.2s,
103
+ background-color 0.2s;
104
+ line-height: 0;
105
+ }
106
+
107
+ .select-clear-button:hover {
108
+ color: var(--color-text-primary);
109
+ background: var(--color-bg-disabled);
110
+ }
111
+
91
112
  .select-menu {
92
113
  position: absolute;
93
114
  right: 0;
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { onMount, onDestroy, tick, getContext } from 'svelte';
3
3
  import type { IFieldOption } from './SelectInput.js';
4
- import { Icon } from 'mertani-web-toolkit';
4
+ import { Icon } from '../../../index.js';
5
5
  import './SelectInput.css';
6
6
 
7
7
  interface Props {
@@ -41,6 +41,7 @@
41
41
  onOpen?: () => void;
42
42
  /** Dipanggil saat teks search berubah (debounce 300ms), hanya jika remoteSearch. */
43
43
  onSearchQueryChange?: (query: string) => void;
44
+ onClear?: () => void;
44
45
 
45
46
  // Validation
46
47
  isMandatory?: boolean;
@@ -56,6 +57,7 @@
56
57
  class?: string;
57
58
  style?: string;
58
59
  isShowChevron?: boolean;
60
+ isClearable?: boolean;
59
61
  }
60
62
 
61
63
  const {
@@ -92,6 +94,7 @@
92
94
  onSelect,
93
95
  onOpen,
94
96
  onSearchQueryChange,
97
+ onClear,
95
98
 
96
99
  // Validation
97
100
  isMandatory = false,
@@ -105,7 +108,8 @@
105
108
 
106
109
  class: className = '',
107
110
  style: customStyle = '',
108
- isShowChevron = true
111
+ isShowChevron = true,
112
+ isClearable = false
109
113
  }: Props = $props();
110
114
 
111
115
  // ===== Constants =====
@@ -123,7 +127,7 @@
123
127
  let open = $state(false);
124
128
  let search = $state('');
125
129
  let placement: 'bottom' | 'top' = $state('bottom');
126
- let triggerEl: HTMLButtonElement | null = $state(null);
130
+ let triggerEl: HTMLDivElement | null = $state(null);
127
131
  let menuEl: HTMLDivElement | null = $state(null);
128
132
  let resizeObs: ResizeObserver | null = $state(null);
129
133
  let unregisterDropdown: (() => void) | null = $state(null);
@@ -306,6 +310,13 @@
306
310
  close();
307
311
  }
308
312
 
313
+ function handleClear(e: MouseEvent) {
314
+ e.stopPropagation();
315
+ onSelect?.('');
316
+ onClear?.();
317
+ error = validateInput('');
318
+ }
319
+
309
320
  function validateInput(val: string): string {
310
321
  if (customValidation) {
311
322
  const customError = customValidation(val);
@@ -403,28 +414,42 @@
403
414
  </label>
404
415
  {/if}
405
416
  <div class="relative flex-1">
406
- <button
417
+ <div
407
418
  bind:this={triggerEl}
408
- type="button"
409
- class="select-trigger {className} {error ? 'error' : ''}"
410
- style={triggerStyles()}
419
+ role="combobox"
420
+ tabindex="0"
411
421
  aria-haspopup="listbox"
412
422
  aria-expanded={open}
423
+ aria-controls={open ? 'select-menu' : undefined}
424
+ class="select-trigger {className} {error ? 'error' : ''}"
425
+ style={triggerStyles()}
413
426
  onclick={toggle}
414
427
  onkeydown={handleKeydown}
415
428
  onfocus={handleFocus}
416
429
  onblur={handleBlur}
417
- disabled={disabled || isLoading}
418
430
  title={tooltip || ''}
431
+ class:disabled={disabled || isLoading}
419
432
  >
420
433
  <span class="truncate">{labelOf(value) || placeholder}</span>
421
- {#if isShowChevron}
422
- <Icon
423
- name="bs-chevron-down"
424
- style="transform-origin: center; transition: transform 0.25s; transform: rotate({chevronRotation});"
425
- />
426
- {/if}
427
- </button>
434
+ <div class="flex items-center gap-1">
435
+ {#if isClearable && value && !disabled && !isLoading}
436
+ <button
437
+ type="button"
438
+ class="select-clear-button"
439
+ onclick={handleClear}
440
+ aria-label="Clear selection"
441
+ >
442
+ <Icon name="bs-x-lg" />
443
+ </button>
444
+ {/if}
445
+ {#if isShowChevron}
446
+ <Icon
447
+ name="bs-chevron-down"
448
+ style="transform-origin: center; transition: transform 0.25s; transform: rotate({chevronRotation});"
449
+ />
450
+ {/if}
451
+ </div>
452
+ </div>
428
453
 
429
454
  {#if open}
430
455
  <div
@@ -28,6 +28,7 @@ interface Props {
28
28
  onOpen?: () => void;
29
29
  /** Dipanggil saat teks search berubah (debounce 300ms), hanya jika remoteSearch. */
30
30
  onSearchQueryChange?: (query: string) => void;
31
+ onClear?: () => void;
31
32
  isMandatory?: boolean;
32
33
  customValidation?: (value: string) => string | null;
33
34
  isLoading?: boolean;
@@ -37,6 +38,7 @@ interface Props {
37
38
  class?: string;
38
39
  style?: string;
39
40
  isShowChevron?: boolean;
41
+ isClearable?: boolean;
40
42
  }
41
43
  declare const SelectInput: import("svelte").Component<Props, {}, "">;
42
44
  type SelectInput = ReturnType<typeof SelectInput>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ "use strict";
@@ -0,0 +1,284 @@
1
+ <script lang="ts">
2
+ import { onDestroy, onMount } from 'svelte';
3
+ import type { Map as LMap, LatLngTuple, LatLngBounds, TileLayer, Marker, Layer } from 'leaflet';
4
+ import 'leaflet/dist/leaflet.css';
5
+ import { createMapContext } from '../../utils/mapContext.js';
6
+ import './map.css';
7
+ import Icon from '../Icon/Icon.svelte';
8
+
9
+ type LeafletNamespace = typeof import('leaflet');
10
+
11
+ interface Props {
12
+ center?: LatLngTuple;
13
+ zoom?: number;
14
+ minZoom?: number;
15
+ maxZoom?: number;
16
+ tileUrl?: string;
17
+ tileAttribution?: string;
18
+ scrollWheelZoom?: boolean;
19
+ zoomControl?: boolean;
20
+ autoZoom?: boolean;
21
+ class?: string;
22
+ }
23
+
24
+ let {
25
+ center,
26
+ zoom = 5,
27
+ minZoom,
28
+ maxZoom,
29
+ tileUrl = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
30
+ tileAttribution = '©2025 Developed by <b>Mertani</b>',
31
+ scrollWheelZoom = true,
32
+ autoZoom = true,
33
+ class: className = ''
34
+ }: Props = $props();
35
+
36
+ let el: HTMLDivElement | null = $state(null);
37
+ let map: LMap | null = null;
38
+ let tile: TileLayer | null = null;
39
+ let L: LeafletNamespace | null = null;
40
+ const mapStore = createMapContext();
41
+
42
+ function calculateBounds(): LatLngBounds | null {
43
+ if (!map || !L) return null;
44
+
45
+ const bounds = L.latLngBounds([]);
46
+ let hasBounds = false;
47
+
48
+ map.eachLayer((layer: Layer) => {
49
+ if (!L) return;
50
+
51
+ // Skip tile layers
52
+ if (layer instanceof L.TileLayer) return;
53
+
54
+ // Handle MarkerClusterGroup - get all child markers
55
+ if (
56
+ (layer as any).getAllChildMarkers &&
57
+ typeof (layer as any).getAllChildMarkers === 'function'
58
+ ) {
59
+ const clusterGroup = layer as any;
60
+ const markers = clusterGroup.getAllChildMarkers() as Marker[];
61
+ markers.forEach((marker: Marker) => {
62
+ const latlng = marker.getLatLng();
63
+ if (latlng) {
64
+ bounds.extend(latlng);
65
+ hasBounds = true;
66
+ }
67
+ });
68
+ }
69
+ // Handle individual markers
70
+ else if ('getLatLng' in layer && typeof layer.getLatLng === 'function') {
71
+ const latlng = (layer as Marker).getLatLng();
72
+ if (latlng) {
73
+ bounds.extend(latlng);
74
+ hasBounds = true;
75
+ }
76
+ }
77
+ // Handle layers with bounds (GeoJSON, TopoJSON, etc.)
78
+ else if ('getBounds' in layer && typeof layer.getBounds === 'function') {
79
+ const layerBounds = layer.getBounds() as LatLngBounds;
80
+ if (layerBounds && layerBounds.isValid()) {
81
+ bounds.extend(layerBounds);
82
+ hasBounds = true;
83
+ }
84
+ }
85
+ });
86
+
87
+ return hasBounds && bounds.isValid() ? bounds : null;
88
+ }
89
+
90
+ function autoZoomToMarkers() {
91
+ if (!map || !L || !autoZoom) return;
92
+
93
+ setTimeout(() => {
94
+ if (!map || !L) return;
95
+
96
+ const bounds = calculateBounds();
97
+ if (bounds) {
98
+ map.fitBounds(bounds, { padding: [30, 30] });
99
+ } else if (center) {
100
+ map.setView(center, zoom);
101
+ } else {
102
+ map.setView([-2.5489, 118.0149] as LatLngTuple, zoom);
103
+ }
104
+ }, 100);
105
+ }
106
+
107
+ async function init() {
108
+ if (!el) return;
109
+
110
+ const leafletMod = await import('leaflet');
111
+ L = (leafletMod as any).default ?? leafletMod;
112
+
113
+ if (!L) return;
114
+
115
+ map = L.map(el, {
116
+ zoomControl: false,
117
+ zoomAnimation: true,
118
+ fadeAnimation: true,
119
+ markerZoomAnimation: true,
120
+ scrollWheelZoom,
121
+ minZoom,
122
+ maxZoom
123
+ });
124
+
125
+ L.control.zoom({ position: 'bottomright' }).addTo(map);
126
+ tile = L.tileLayer(tileUrl, { attribution: tileAttribution });
127
+ tile.addTo(map);
128
+
129
+ if (center) {
130
+ map.setView(center, zoom);
131
+ } else {
132
+ map.setView([-2.5489, 118.0149] as LatLngTuple, zoom);
133
+ }
134
+
135
+ mapStore.set(map as any);
136
+
137
+ requestAnimationFrame(() => map?.invalidateSize());
138
+ setTimeout(() => map?.invalidateSize(), 50);
139
+
140
+ if (autoZoom) {
141
+ setTimeout(() => autoZoomToMarkers(), 200);
142
+ }
143
+ }
144
+
145
+ function updateTileLayer() {
146
+ if (!map || !L) return;
147
+
148
+ if (tile) {
149
+ map.removeLayer(tile);
150
+ }
151
+
152
+ tile = L.tileLayer(tileUrl, { attribution: tileAttribution });
153
+ tile.addTo(map);
154
+ }
155
+
156
+ $effect(() => {
157
+ if (autoZoom && map && L) {
158
+ autoZoomToMarkers();
159
+ }
160
+ });
161
+
162
+ export function zoomToAll() {
163
+ if (!map || !L) return;
164
+
165
+ setTimeout(() => {
166
+ if (!map || !L) return;
167
+
168
+ const bounds = calculateBounds();
169
+
170
+ if (bounds && bounds.isValid()) {
171
+ map.flyToBounds(bounds, {
172
+ padding: [50, 50],
173
+ maxZoom: 18
174
+ });
175
+ } else if (center) {
176
+ map.flyTo(center, zoom);
177
+ } else {
178
+ map.flyTo([-2.5489, 118.0149] as LatLngTuple, zoom);
179
+ }
180
+ }, 100);
181
+ }
182
+
183
+ $effect(() => {
184
+ if (!map || autoZoom) return;
185
+ if (center) {
186
+ map.setView(center, zoom, { animate: false });
187
+ }
188
+ });
189
+
190
+ $effect(() => {
191
+ tileUrl;
192
+ tileAttribution;
193
+ updateTileLayer();
194
+ });
195
+
196
+ $effect(() => {
197
+ if (!map || !L) return;
198
+ updateTileLayer();
199
+ });
200
+
201
+ onMount(() => {
202
+ init();
203
+ });
204
+
205
+ onDestroy(() => {
206
+ try {
207
+ mapStore.set(null);
208
+ tile?.remove?.();
209
+ map?.remove?.();
210
+ } finally {
211
+ tile = null;
212
+ map = null;
213
+ }
214
+ });
215
+ </script>
216
+
217
+ <div class={`mertani-map ${className}`} bind:this={el} role="application" aria-label="Map">
218
+ <slot />
219
+ <button class="crosshair-btn" onclick={zoomToAll} type="button" aria-label="Zoom to all">
220
+ <div class="info-icon">
221
+ <Icon name="bs-crosshair" />
222
+ </div>
223
+ </button>
224
+ </div>
225
+
226
+ <style>
227
+ .mertani-map {
228
+ position: relative;
229
+ width: 100%;
230
+ height: 100%;
231
+ min-height: 280px;
232
+ }
233
+
234
+ .crosshair-btn {
235
+ position: absolute;
236
+ bottom: 24px;
237
+ left: 24px;
238
+ z-index: 1001;
239
+ background-color: color-mix(in srgb, var(--color-bg-surface), transparent 20%) !important;
240
+ backdrop-filter: blur(2px);
241
+ padding: 4px;
242
+ border-radius: 8px;
243
+ border: none;
244
+ cursor: pointer;
245
+ transition: background-color 0.2s;
246
+ }
247
+
248
+ .crosshair-btn:hover {
249
+ background-color: color-mix(in srgb, var(--color-bg-surface), transparent 5%) !important;
250
+ backdrop-filter: blur(2px);
251
+ }
252
+
253
+ .crosshair-btn .info-icon {
254
+ padding: 8px;
255
+ border-radius: 6px;
256
+ display: flex;
257
+ align-items: center;
258
+ justify-content: center;
259
+ color: var(--color-bg-act-primary);
260
+ }
261
+
262
+ .crosshair-btn .info-icon :global(svg) {
263
+ width: 24px;
264
+ height: 24px;
265
+ }
266
+
267
+ @media (max-width: 768px) {
268
+ .crosshair-btn {
269
+ bottom: 12px;
270
+ left: 12px;
271
+ padding: 3px;
272
+ border-radius: 6px;
273
+ }
274
+
275
+ .crosshair-btn .info-icon {
276
+ padding: 6px;
277
+ }
278
+
279
+ .crosshair-btn :global(svg) {
280
+ width: 20px !important;
281
+ height: 20px !important;
282
+ }
283
+ }
284
+ </style>
@@ -0,0 +1,44 @@
1
+ import type { LatLngTuple } from 'leaflet';
2
+ import 'leaflet/dist/leaflet.css';
3
+ import './map.css';
4
+ interface Props {
5
+ center?: LatLngTuple;
6
+ zoom?: number;
7
+ minZoom?: number;
8
+ maxZoom?: number;
9
+ tileUrl?: string;
10
+ tileAttribution?: string;
11
+ scrollWheelZoom?: boolean;
12
+ zoomControl?: boolean;
13
+ autoZoom?: boolean;
14
+ class?: string;
15
+ }
16
+ interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
17
+ new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
18
+ $$bindings?: Bindings;
19
+ } & Exports;
20
+ (internal: unknown, props: Props & {
21
+ $$events?: Events;
22
+ $$slots?: Slots;
23
+ }): Exports & {
24
+ $set?: any;
25
+ $on?: any;
26
+ };
27
+ z_$$bindings?: Bindings;
28
+ }
29
+ type $$__sveltets_2_PropsWithChildren<Props, Slots> = Props & (Slots extends {
30
+ default: any;
31
+ } ? Props extends Record<string, never> ? any : {
32
+ children?: any;
33
+ } : {});
34
+ declare const Map: $$__sveltets_2_IsomorphicComponent<$$__sveltets_2_PropsWithChildren<Props, {
35
+ default: {};
36
+ }>, {
37
+ [evt: string]: CustomEvent<any>;
38
+ }, {
39
+ default: {};
40
+ }, {
41
+ zoomToAll: () => void;
42
+ }, "">;
43
+ type Map = InstanceType<typeof Map>;
44
+ export default Map;
@@ -0,0 +1,60 @@
1
+ <script lang="ts">
2
+ import { onDestroy } from 'svelte';
3
+ import { useMapContext } from '../../utils/mapContext.js';
4
+
5
+ let {
6
+ data,
7
+ style,
8
+ onEachFeature
9
+ }: {
10
+ data: any; // GeoJSON Feature/FeatureCollection
11
+ style?: any;
12
+ onEachFeature?: (feature: any, layer: any) => void;
13
+ } = $props();
14
+
15
+ const mapStore = useMapContext();
16
+ let map: any = null;
17
+ let layer: any = null;
18
+ let unsub: (() => void) | null = null;
19
+
20
+ async function rebuild() {
21
+ if (!map) return;
22
+ if (layer) {
23
+ layer.remove?.();
24
+ layer = null;
25
+ }
26
+ if (!data) return;
27
+
28
+ const leafletMod = await import('leaflet');
29
+ const L = (leafletMod as any).default ?? leafletMod;
30
+
31
+ layer = L.geoJSON(data, { style, onEachFeature });
32
+ layer.addTo(map);
33
+ }
34
+
35
+ $effect(() => {
36
+ data;
37
+ style;
38
+ onEachFeature;
39
+ rebuild();
40
+ });
41
+
42
+ unsub = (mapStore as any).subscribe((m: any) => {
43
+ map = m;
44
+ if (!map && layer) {
45
+ layer.remove?.();
46
+ layer = null;
47
+ }
48
+ if (map) rebuild();
49
+ });
50
+
51
+ onDestroy(() => {
52
+ try {
53
+ unsub?.();
54
+ } finally {
55
+ unsub = null;
56
+ layer?.remove?.();
57
+ layer = null;
58
+ }
59
+ });
60
+ </script>
@@ -0,0 +1,8 @@
1
+ type $$ComponentProps = {
2
+ data: any;
3
+ style?: any;
4
+ onEachFeature?: (feature: any, layer: any) => void;
5
+ };
6
+ declare const MapGeoJSON: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type MapGeoJSON = ReturnType<typeof MapGeoJSON>;
8
+ export default MapGeoJSON;