mertani-web-toolkit 0.1.56 → 0.1.57

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,199 @@
1
+ .dropdown-container {
2
+ display: flex;
3
+ flex-direction: column;
4
+ width: 100%;
5
+ }
6
+
7
+ .dropdown-container.dropdown-side {
8
+ flex-direction: row;
9
+ align-items: center;
10
+ gap: 12px;
11
+ }
12
+
13
+ .dropdown-label {
14
+ font-size: 14px;
15
+ font-weight: 400;
16
+ color: var(--color-text-primary);
17
+ margin-bottom: 4px;
18
+ display: block;
19
+ }
20
+
21
+ .dropdown-container.dropdown-side .dropdown-label {
22
+ margin-bottom: 0;
23
+ min-width: 120px;
24
+ }
25
+
26
+ .dropdown-label.label-left {
27
+ text-align: left;
28
+ }
29
+
30
+ .dropdown-label.label-right {
31
+ text-align: right;
32
+ }
33
+
34
+ .label-required {
35
+ color: var(--color-text-error-ti);
36
+ margin-left: 4px;
37
+ }
38
+
39
+ .label-subLabel {
40
+ font-weight: 400;
41
+ color: var(--color-text-tertiary);
42
+ margin-left: 4px;
43
+ }
44
+
45
+ .dropdown-trigger {
46
+ flex: 1;
47
+ display: flex;
48
+ width: 100%;
49
+ align-items: center;
50
+ justify-content: space-between;
51
+ gap: 0.5rem;
52
+ border-radius: 6px;
53
+ border: 1px solid var(--color-border-form);
54
+ padding: 0.625rem 1rem;
55
+ text-align: left;
56
+ font-size: 14px;
57
+ color: var(--color-text-primary);
58
+ transition:
59
+ border-color 0.2s,
60
+ box-shadow 0.2s;
61
+ }
62
+
63
+ .dropdown-trigger:not(:disabled) {
64
+ background: var(--color-bg-surface);
65
+ cursor: pointer;
66
+ }
67
+
68
+ .dropdown-trigger:not(:disabled):hover {
69
+ border-color: var(--color-border-form);
70
+ }
71
+
72
+ .dropdown-trigger:not(:disabled):focus,
73
+ .dropdown-trigger:not(:disabled).open {
74
+ outline: none;
75
+ border-color: var(--color-bg-act-primary);
76
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-bg-act-primary) 25%, transparent);
77
+ }
78
+
79
+ .dropdown-trigger:disabled {
80
+ cursor: not-allowed;
81
+ background: var(--color-bg-disabled);
82
+ }
83
+
84
+ .dropdown-trigger.error:not(:disabled) {
85
+ border-color: var(--color-text-error-ti);
86
+ }
87
+
88
+ .dropdown-trigger.error:not(:disabled):focus {
89
+ border-color: var(--color-text-error-ti);
90
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-text-error-ti) 25%, transparent);
91
+ }
92
+
93
+ .dropdown-value {
94
+ flex: 1;
95
+ min-width: 0;
96
+ white-space: nowrap;
97
+ overflow: hidden;
98
+ text-overflow: ellipsis;
99
+ text-align: left;
100
+ }
101
+
102
+ .dropdown-chevron {
103
+ flex-shrink: 0;
104
+ display: inline-flex;
105
+ align-items: center;
106
+ justify-content: center;
107
+ width: 16px;
108
+ height: 16px;
109
+ opacity: 0.7;
110
+ transition: transform 0.2s;
111
+ }
112
+
113
+ .dropdown-trigger.open .dropdown-chevron {
114
+ transform: rotate(180deg);
115
+ }
116
+
117
+ .dropdown-chevron :global(svg) {
118
+ width: 100%;
119
+ height: 100%;
120
+ }
121
+
122
+ .dropdown-menu {
123
+ position: absolute;
124
+ top: calc(100% + 4px);
125
+ left: 0;
126
+ right: 0;
127
+ min-width: 100%;
128
+ z-index: 50;
129
+ overflow: hidden;
130
+ border-radius: 6px;
131
+ background: var(--color-bg-surface);
132
+ border: 1px solid var(--color-border-form);
133
+ box-shadow:
134
+ 0 4px 6px -1px rgba(0, 0, 0, 0.1),
135
+ 0 2px 4px -1px rgba(0, 0, 0, 0.06);
136
+ }
137
+
138
+ .dropdown-options {
139
+ max-height: 14rem;
140
+ overflow-y: auto;
141
+ padding: 0.25rem 0;
142
+ }
143
+
144
+ .dropdown-option {
145
+ display: flex;
146
+ width: 100%;
147
+ align-items: center;
148
+ gap: 0.5rem;
149
+ padding: 0.5rem 0.75rem;
150
+ text-align: left;
151
+ font-size: 14px;
152
+ transition: background-color 0.15s;
153
+ color: var(--color-text-primary);
154
+ background: transparent;
155
+ border: none;
156
+ cursor: pointer;
157
+ }
158
+
159
+ .dropdown-option:hover:not(.selected) {
160
+ background: var(--color-bg-disabled);
161
+ }
162
+
163
+ .dropdown-option.selected {
164
+ color: var(--color-bg-act-primary);
165
+ font-weight: 500;
166
+ }
167
+
168
+ .dropdown-option-leading {
169
+ flex-shrink: 0;
170
+ display: inline-flex;
171
+ align-items: center;
172
+ justify-content: center;
173
+ }
174
+
175
+ .dropdown-empty {
176
+ padding: 0.5rem 0.75rem;
177
+ font-size: 12px;
178
+ color: var(--color-text-tertiary);
179
+ }
180
+
181
+ .error-message {
182
+ margin-top: 4px;
183
+ font-size: 12px;
184
+ color: var(--color-text-error-ti);
185
+ }
186
+
187
+ .relative {
188
+ position: relative;
189
+ }
190
+
191
+ .flex-1 {
192
+ flex: 1;
193
+ }
194
+
195
+ .truncate {
196
+ overflow: hidden;
197
+ text-overflow: ellipsis;
198
+ white-space: nowrap;
199
+ }
@@ -0,0 +1,316 @@
1
+ <script lang="ts">
2
+ import Icon from '../../Icon/Icon.svelte';
3
+ import type { Snippet } from 'svelte';
4
+ import './Dropdown.css';
5
+
6
+ export type DropdownOption = { value: string; label: string };
7
+
8
+ interface Props {
9
+ // ===Styles===
10
+ // Label
11
+ labelColor?: string;
12
+ aligment?: 'side' | 'top';
13
+ position?: 'left' | 'right';
14
+
15
+ // Field
16
+ size?: 48 | 40 | 32;
17
+ backgroundColor?: string;
18
+ borderColor?: string;
19
+ accentColor?: string;
20
+ textColor?: string;
21
+ errorColor?: string;
22
+ borderRadius?: number;
23
+ boxShadow?: string;
24
+ minWidth?: string;
25
+
26
+ // ===Properties===
27
+ // Data
28
+ id?: string;
29
+ label?: string;
30
+ subLabel?: string;
31
+ options?: DropdownOption[];
32
+ value?: string;
33
+ placeholder?: string;
34
+ emptyMessage?: string;
35
+ ariaLabel?: string;
36
+
37
+ // Events
38
+ onSelect?: (value: string) => void;
39
+
40
+ // Validation
41
+ isMandatory?: boolean;
42
+ customValidation?: (value: string) => string | null;
43
+
44
+ // Additional Actions
45
+ isLoading?: boolean;
46
+ isShow?: boolean;
47
+ disabled?: boolean;
48
+ tooltip?: string;
49
+
50
+ // Slots
51
+ optionLeading?: Snippet<[DropdownOption]>;
52
+
53
+ // Any
54
+ class?: string;
55
+ style?: string;
56
+ }
57
+
58
+ let {
59
+ // ===Styles===
60
+ labelColor = 'var(--color-text-primary)',
61
+ aligment = 'top',
62
+ position = 'left',
63
+
64
+ size = 40,
65
+ backgroundColor = 'var(--color-bg-surface)',
66
+ borderColor = 'var(--color-border-form)',
67
+ accentColor = 'var(--color-bg-act-primary)',
68
+ textColor = 'var(--color-text-primary)',
69
+ errorColor = 'var(--color-text-error-ti)',
70
+ borderRadius,
71
+ boxShadow = '',
72
+ minWidth = '160px',
73
+
74
+ // ===Properties===
75
+ id = '',
76
+ label = '',
77
+ subLabel = '',
78
+ options = [],
79
+ value = $bindable<string>(''),
80
+ placeholder = 'Pilih...',
81
+ emptyMessage = 'Tidak ada hasil',
82
+ ariaLabel = 'Pilih opsi',
83
+
84
+ // Events
85
+ onSelect,
86
+
87
+ // Validation
88
+ isMandatory = false,
89
+ customValidation,
90
+
91
+ // Additional Actions
92
+ isLoading = false,
93
+ isShow = true,
94
+ disabled = false,
95
+ tooltip = '',
96
+
97
+ // Slots
98
+ optionLeading,
99
+
100
+ // Any
101
+ class: className = '',
102
+ style: customStyle = ''
103
+ }: Props = $props();
104
+
105
+ let open = $state(false);
106
+ let triggerEl: HTMLButtonElement | null = $state(null);
107
+ let menuEl: HTMLDivElement | null = $state(null);
108
+ let isFocused = $state(false);
109
+
110
+ const error = $derived(validateInput(value));
111
+
112
+ const sizeConfig = $derived.by(() => {
113
+ switch (size) {
114
+ case 48:
115
+ return {
116
+ height: '48px',
117
+ labelFontSize: '16px',
118
+ fontSize: '16px',
119
+ borderRadius: 8,
120
+ padding: '8px 12px'
121
+ };
122
+ case 40:
123
+ return {
124
+ height: '40px',
125
+ labelFontSize: '14px',
126
+ fontSize: '14px',
127
+ borderRadius: 8,
128
+ padding: '6px 12px'
129
+ };
130
+ case 32:
131
+ return {
132
+ height: '32px',
133
+ labelFontSize: '14px',
134
+ fontSize: '14px',
135
+ borderRadius: 6,
136
+ padding: '6px 12px'
137
+ };
138
+ default:
139
+ return {
140
+ height: '40px',
141
+ labelFontSize: '14px',
142
+ fontSize: '14px',
143
+ borderRadius: 8,
144
+ padding: '6px 12px'
145
+ };
146
+ }
147
+ });
148
+
149
+ const labelStyles = $derived.by(() => {
150
+ const s: string[] = [`font-size: ${sizeConfig.labelFontSize};`];
151
+ if (labelColor) s.push(`color: ${labelColor};`);
152
+ return s.join(' ');
153
+ });
154
+
155
+ const triggerStyles = $derived.by(() => {
156
+ const s: string[] = [];
157
+ s.push(`height: ${sizeConfig.height};`);
158
+ s.push(`padding: ${sizeConfig.padding};`);
159
+ s.push(`font-size: ${sizeConfig.fontSize};`);
160
+ s.push(`color: ${textColor};`);
161
+ const radius = borderRadius ?? sizeConfig.borderRadius;
162
+ s.push(`border-radius: ${radius}px;`);
163
+ if (minWidth) s.push(`min-width: ${minWidth};`);
164
+ if (boxShadow) s.push(`box-shadow: ${boxShadow};`);
165
+ if (disabled || isLoading) {
166
+ s.push('background: var(--color-bg-disabled);');
167
+ } else if (backgroundColor) {
168
+ s.push(`background: ${backgroundColor};`);
169
+ }
170
+ if (error) {
171
+ s.push(`border-color: ${errorColor};`);
172
+ s.push(`box-shadow: 0 0 0 2px color-mix(in srgb, ${errorColor} 25%, transparent);`);
173
+ } else if (isFocused || open) {
174
+ s.push(`border-color: ${accentColor};`);
175
+ s.push(`box-shadow: 0 0 0 2px color-mix(in srgb, ${accentColor} 25%, transparent);`);
176
+ } else if (borderColor) {
177
+ s.push(`border-color: ${borderColor};`);
178
+ }
179
+ if (customStyle) s.push(customStyle);
180
+ return s.join(' ');
181
+ });
182
+
183
+ const currentLabel = $derived(options.find((o) => o.value === value)?.label ?? placeholder);
184
+
185
+ function validateInput(val: string): string {
186
+ if (customValidation) {
187
+ const err = customValidation(val);
188
+ if (err) return err;
189
+ }
190
+ if (isMandatory && !val) return 'Field ini wajib diisi';
191
+ return '';
192
+ }
193
+
194
+ function toggle() {
195
+ if (disabled || isLoading) return;
196
+ open = !open;
197
+ }
198
+
199
+ function select(option: DropdownOption) {
200
+ value = option.value;
201
+ open = false;
202
+ onSelect?.(option.value);
203
+ }
204
+
205
+ function handleClickOutside(e: MouseEvent) {
206
+ const target = e.target as Node;
207
+ if (triggerEl && menuEl && !triggerEl.contains(target) && !menuEl.contains(target)) {
208
+ open = false;
209
+ }
210
+ }
211
+
212
+ function handleKeydown(e: KeyboardEvent) {
213
+ if (e.key === 'Escape') {
214
+ open = false;
215
+ }
216
+ if (e.key === 'Enter' || e.key === ' ') {
217
+ e.preventDefault();
218
+ toggle();
219
+ }
220
+ }
221
+
222
+ function handleFocus() {
223
+ isFocused = true;
224
+ }
225
+
226
+ function handleBlur() {
227
+ isFocused = false;
228
+ }
229
+
230
+ $effect(() => {
231
+ if (open) {
232
+ document.addEventListener('click', handleClickOutside);
233
+ } else {
234
+ document.removeEventListener('click', handleClickOutside);
235
+ }
236
+ return () => document.removeEventListener('click', handleClickOutside);
237
+ });
238
+ </script>
239
+
240
+ {#if isShow}
241
+ <div class="dropdown-container" class:dropdown-side={aligment === 'side'}>
242
+ {#if label}
243
+ <label
244
+ for={id}
245
+ class="dropdown-label"
246
+ class:label-left={position === 'left'}
247
+ class:label-right={position === 'right'}
248
+ style={labelStyles}
249
+ >
250
+ {label}
251
+ {#if subLabel}
252
+ <span class="label-subLabel">{subLabel}</span>
253
+ {/if}
254
+ {#if isMandatory}
255
+ <span class="label-required">*</span>
256
+ {/if}
257
+ </label>
258
+ {/if}
259
+ <div class="relative flex-1" style="--dropdown-min-width: {minWidth}">
260
+ <button
261
+ bind:this={triggerEl}
262
+ type="button"
263
+ class="dropdown-trigger {className} {error ? 'error' : ''}"
264
+ class:open
265
+ style={triggerStyles}
266
+ aria-haspopup="listbox"
267
+ aria-expanded={open}
268
+ aria-label={ariaLabel}
269
+ onclick={toggle}
270
+ onkeydown={handleKeydown}
271
+ onfocus={handleFocus}
272
+ onblur={handleBlur}
273
+ disabled={disabled || isLoading}
274
+ title={tooltip || undefined}
275
+ >
276
+ <span class="dropdown-value">{currentLabel}</span>
277
+ <span class="dropdown-chevron">
278
+ <Icon name="bs-chevron-down" />
279
+ </span>
280
+ </button>
281
+
282
+ {#if open}
283
+ <div bind:this={menuEl} class="dropdown-menu" role="listbox">
284
+ <div class="dropdown-options">
285
+ {#if options.length}
286
+ {#each options as option (option.value)}
287
+ {@const selected = value === option.value}
288
+ <button
289
+ type="button"
290
+ class="dropdown-option"
291
+ class:selected
292
+ role="option"
293
+ aria-selected={selected}
294
+ onclick={() => select(option)}
295
+ title={option.label}
296
+ >
297
+ {#if optionLeading}
298
+ <span class="dropdown-option-leading">
299
+ {@render optionLeading(option)}
300
+ </span>
301
+ {/if}
302
+ <span class="truncate">{option.label}</span>
303
+ </button>
304
+ {/each}
305
+ {:else}
306
+ <div class="dropdown-empty">{emptyMessage}</div>
307
+ {/if}
308
+ </div>
309
+ </div>
310
+ {/if}
311
+ </div>
312
+ {#if error}
313
+ <p class="error-message" style={errorColor ? `color: ${errorColor};` : ''}>{error}</p>
314
+ {/if}
315
+ </div>
316
+ {/if}
@@ -0,0 +1,41 @@
1
+ import type { Snippet } from 'svelte';
2
+ import './Dropdown.css';
3
+ export type DropdownOption = {
4
+ value: string;
5
+ label: string;
6
+ };
7
+ interface Props {
8
+ labelColor?: string;
9
+ aligment?: 'side' | 'top';
10
+ position?: 'left' | 'right';
11
+ size?: 48 | 40 | 32;
12
+ backgroundColor?: string;
13
+ borderColor?: string;
14
+ accentColor?: string;
15
+ textColor?: string;
16
+ errorColor?: string;
17
+ borderRadius?: number;
18
+ boxShadow?: string;
19
+ minWidth?: string;
20
+ id?: string;
21
+ label?: string;
22
+ subLabel?: string;
23
+ options?: DropdownOption[];
24
+ value?: string;
25
+ placeholder?: string;
26
+ emptyMessage?: string;
27
+ ariaLabel?: string;
28
+ onSelect?: (value: string) => void;
29
+ isMandatory?: boolean;
30
+ customValidation?: (value: string) => string | null;
31
+ isLoading?: boolean;
32
+ isShow?: boolean;
33
+ disabled?: boolean;
34
+ tooltip?: string;
35
+ optionLeading?: Snippet<[DropdownOption]>;
36
+ class?: string;
37
+ style?: string;
38
+ }
39
+ declare const Dropdown: import("svelte").Component<Props, {}, "value">;
40
+ type Dropdown = ReturnType<typeof Dropdown>;
41
+ export default Dropdown;
@@ -131,6 +131,10 @@
131
131
  padding-right: 2.5rem;
132
132
  }
133
133
 
134
+ .input-wrapper:has(.input-clear):not(:has(.suffix-wrapper)) .input-field {
135
+ padding-right: 2.5rem;
136
+ }
137
+
134
138
  .input-wrapper:has(.input-icon-left):not(:has(.prefix-wrapper)) .input-field {
135
139
  padding-left: 2.5rem;
136
140
  }
@@ -165,6 +169,35 @@
165
169
  right: 12px;
166
170
  }
167
171
 
172
+ .input-clear {
173
+ position: absolute;
174
+ top: 50%;
175
+ right: 12px;
176
+ transform: translateY(-50%);
177
+ display: flex;
178
+ align-items: center;
179
+ justify-content: center;
180
+ padding: 0;
181
+ margin: 0;
182
+ border: none;
183
+ background: transparent;
184
+ cursor: pointer;
185
+ color: var(--color-text-tertiary);
186
+ border-radius: 4px;
187
+ transition: color 0.15s, background 0.15s;
188
+ }
189
+
190
+ .input-clear:hover {
191
+ color: var(--color-text-primary);
192
+ background: color-mix(in srgb, var(--color-border-form) 50%, transparent);
193
+ }
194
+
195
+ .input-clear:focus-visible {
196
+ outline: none;
197
+ color: var(--color-bg-act-primary);
198
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-bg-act-primary) 30%, transparent);
199
+ }
200
+
168
201
  .input-loader {
169
202
  position: absolute;
170
203
  top: 50%;
@@ -41,6 +41,7 @@
41
41
  onfocus?: (event: FocusEvent) => void;
42
42
  onblur?: (event: FocusEvent) => void;
43
43
  onkeydown?: (event: KeyboardEvent) => void;
44
+ onClear?: () => void;
44
45
 
45
46
  // Validation
46
47
  isMandatory?: boolean;
@@ -54,6 +55,7 @@
54
55
  isShow?: boolean;
55
56
  disabled?: boolean;
56
57
  readOnly?: boolean;
58
+ clearable?: boolean;
57
59
  tooltip?: string;
58
60
 
59
61
  // Any
@@ -98,6 +100,7 @@
98
100
  onfocus,
99
101
  onblur,
100
102
  onkeydown,
103
+ onClear,
101
104
 
102
105
  // Validation
103
106
  isMandatory = false,
@@ -111,6 +114,7 @@
111
114
  isShow = true,
112
115
  disabled = false,
113
116
  readOnly = false,
117
+ clearable = true,
114
118
  tooltip = '',
115
119
 
116
120
  class: className = '',
@@ -118,7 +122,7 @@
118
122
  ...props
119
123
  }: Props = $props();
120
124
 
121
- let inputValue = $state(value);
125
+ let inputValue = $derived(value);
122
126
  let errorMessage = $state('');
123
127
  let isFocused = $state(false);
124
128
 
@@ -335,6 +339,19 @@
335
339
  onclick(e);
336
340
  }
337
341
  }
342
+
343
+ function handleClear(e: MouseEvent) {
344
+ e.preventDefault();
345
+ e.stopPropagation();
346
+ if (disabled || readOnly || isLoading) return;
347
+ inputValue = '';
348
+ errorMessage = validateInput('');
349
+ onClear?.();
350
+ }
351
+
352
+ const showClear = $derived(
353
+ clearable && inputValue !== '' && !disabled && !readOnly && !isLoading && !suffix
354
+ );
338
355
  </script>
339
356
 
340
357
  {#if isShow}
@@ -388,7 +405,17 @@
388
405
  title={tooltip || ''}
389
406
  {...props}
390
407
  />
391
- {#if isLoading && iconPosition === 'right'}
408
+ {#if showClear}
409
+ <button
410
+ type="button"
411
+ class="input-clear input-icon-right"
412
+ onclick={handleClear}
413
+ aria-label="Hapus isian"
414
+ tabindex="-1"
415
+ >
416
+ <Icon name="bs-x-lg" color="currentColor" width={16} height={16} />
417
+ </button>
418
+ {:else if isLoading && iconPosition === 'right'}
392
419
  <div class="input-loader input-icon-right">
393
420
  <span class="loader-spinner"></span>
394
421
  </div>
@@ -29,6 +29,7 @@ interface Props extends HTMLInputAttributes {
29
29
  onfocus?: (event: FocusEvent) => void;
30
30
  onblur?: (event: FocusEvent) => void;
31
31
  onkeydown?: (event: KeyboardEvent) => void;
32
+ onClear?: () => void;
32
33
  isMandatory?: boolean;
33
34
  regex?: RegExp;
34
35
  minLength?: number;
@@ -38,6 +39,7 @@ interface Props extends HTMLInputAttributes {
38
39
  isShow?: boolean;
39
40
  disabled?: boolean;
40
41
  readOnly?: boolean;
42
+ clearable?: boolean;
41
43
  tooltip?: string;
42
44
  class?: string;
43
45
  style?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mertani-web-toolkit",
3
- "version": "0.1.56",
3
+ "version": "0.1.57",
4
4
  "homepage": "https://storybook.mertani.com/",
5
5
  "scripts": {
6
6
  "dev": "vite dev",