@miozu/jera 0.0.2 → 0.3.0

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 (53) hide show
  1. package/CLAUDE.md +443 -0
  2. package/README.md +211 -1
  3. package/llms.txt +64 -0
  4. package/package.json +44 -14
  5. package/src/actions/index.js +375 -0
  6. package/src/components/feedback/EmptyState.svelte +179 -0
  7. package/src/components/feedback/ProgressBar.svelte +116 -0
  8. package/src/components/feedback/Skeleton.svelte +107 -0
  9. package/src/components/feedback/Spinner.svelte +77 -0
  10. package/src/components/feedback/Toast.svelte +297 -0
  11. package/src/components/forms/Checkbox.svelte +147 -0
  12. package/src/components/forms/Dropzone.svelte +248 -0
  13. package/src/components/forms/FileUpload.svelte +266 -0
  14. package/src/components/forms/IconInput.svelte +184 -0
  15. package/src/components/forms/Input.svelte +121 -0
  16. package/src/components/forms/NumberInput.svelte +225 -0
  17. package/src/components/forms/PinInput.svelte +169 -0
  18. package/src/components/forms/Radio.svelte +143 -0
  19. package/src/components/forms/RadioGroup.svelte +62 -0
  20. package/src/components/forms/RangeSlider.svelte +212 -0
  21. package/src/components/forms/SearchInput.svelte +175 -0
  22. package/src/components/forms/Select.svelte +326 -0
  23. package/src/components/forms/Switch.svelte +159 -0
  24. package/src/components/forms/Textarea.svelte +122 -0
  25. package/src/components/navigation/Accordion.svelte +65 -0
  26. package/src/components/navigation/AccordionItem.svelte +146 -0
  27. package/src/components/navigation/Tabs.svelte +239 -0
  28. package/src/components/overlays/ConfirmDialog.svelte +272 -0
  29. package/src/components/overlays/Dropdown.svelte +153 -0
  30. package/src/components/overlays/DropdownDivider.svelte +23 -0
  31. package/src/components/overlays/DropdownItem.svelte +97 -0
  32. package/src/components/overlays/Modal.svelte +232 -0
  33. package/src/components/overlays/Popover.svelte +206 -0
  34. package/src/components/primitives/Avatar.svelte +132 -0
  35. package/src/components/primitives/Badge.svelte +118 -0
  36. package/src/components/primitives/Button.svelte +262 -0
  37. package/src/components/primitives/Card.svelte +104 -0
  38. package/src/components/primitives/Divider.svelte +105 -0
  39. package/src/components/primitives/LazyImage.svelte +104 -0
  40. package/src/components/primitives/Link.svelte +122 -0
  41. package/src/components/primitives/StatusBadge.svelte +122 -0
  42. package/src/index.js +128 -0
  43. package/src/tokens/colors.css +189 -0
  44. package/src/tokens/effects.css +128 -0
  45. package/src/tokens/index.css +81 -0
  46. package/src/tokens/spacing.css +49 -0
  47. package/src/tokens/typography.css +79 -0
  48. package/src/utils/cn.svelte.js +175 -0
  49. package/src/utils/index.js +17 -0
  50. package/src/utils/reactive.svelte.js +239 -0
  51. package/jera.js +0 -135
  52. package/www/components/jera/Input/Input.svelte +0 -63
  53. package/www/components/jera/Input/index.js +0 -1
@@ -0,0 +1,326 @@
1
+ <!--
2
+ @component Select
3
+
4
+ An accessible dropdown select component with keyboard navigation.
5
+ Demonstrates advanced Svelte 5 patterns:
6
+
7
+ - $state for local UI state
8
+ - $derived for computed values
9
+ - $effect for side effects with cleanup
10
+ - $bindable for two-way value binding
11
+ - Actions for click-outside behavior
12
+ - Keyboard navigation (ArrowUp/Down, Enter, Escape)
13
+ - ARIA attributes for accessibility
14
+
15
+ @example
16
+ <Select
17
+ options={[
18
+ { value: 'opt1', label: 'Option 1' },
19
+ { value: 'opt2', label: 'Option 2' }
20
+ ]}
21
+ bind:value={selected}
22
+ placeholder="Select an option"
23
+ />
24
+
25
+ // With custom keys
26
+ <Select
27
+ options={users}
28
+ bind:value={selectedUserId}
29
+ labelKey="name"
30
+ valueKey="id"
31
+ />
32
+ -->
33
+ <script>
34
+ import { cn, cv } from '../../utils/cn.svelte.js';
35
+ import { clickOutside } from '../../actions/index.js';
36
+ import { generateId } from '../../utils/reactive.svelte.js';
37
+
38
+ /**
39
+ * @typedef {{ [key: string]: any }} Option
40
+ */
41
+
42
+ let {
43
+ options = [],
44
+ value = $bindable(null),
45
+ placeholder = 'Select...',
46
+ labelKey = 'label',
47
+ valueKey = 'value',
48
+ disabled = false,
49
+ error = false,
50
+ class: className = '',
51
+ onchange
52
+ } = $props();
53
+
54
+ // Generate unique IDs for accessibility
55
+ const triggerId = generateId();
56
+ const listboxId = generateId();
57
+
58
+ // Local state
59
+ let isOpen = $state(false);
60
+ let highlightedIndex = $state(-1);
61
+ let triggerRef = $state(null);
62
+ let listRef = $state(null);
63
+
64
+ // Derived values
65
+ const selectedOption = $derived(
66
+ options.find(opt => opt[valueKey] === value) ?? null
67
+ );
68
+
69
+ const selectedLabel = $derived(
70
+ selectedOption ? selectedOption[labelKey] : placeholder
71
+ );
72
+
73
+ const hasValue = $derived(value !== null && value !== undefined);
74
+
75
+ // Keyboard navigation effect
76
+ $effect(() => {
77
+ if (!isOpen) {
78
+ highlightedIndex = -1;
79
+ return;
80
+ }
81
+
82
+ // Focus the listbox when opened
83
+ listRef?.focus();
84
+
85
+ // Set initial highlight to selected option
86
+ const selectedIdx = options.findIndex(opt => opt[valueKey] === value);
87
+ highlightedIndex = selectedIdx >= 0 ? selectedIdx : 0;
88
+ });
89
+
90
+ // Scroll highlighted option into view
91
+ $effect(() => {
92
+ if (!isOpen || highlightedIndex < 0) return;
93
+
94
+ const listbox = listRef;
95
+ const highlighted = listbox?.querySelector(`[data-index="${highlightedIndex}"]`);
96
+ highlighted?.scrollIntoView({ block: 'nearest' });
97
+ });
98
+
99
+ // Handlers
100
+ function toggle() {
101
+ if (disabled) return;
102
+ isOpen = !isOpen;
103
+ }
104
+
105
+ function close() {
106
+ isOpen = false;
107
+ }
108
+
109
+ function select(option) {
110
+ value = option[valueKey];
111
+ onchange?.(option);
112
+ close();
113
+ triggerRef?.focus();
114
+ }
115
+
116
+ function handleKeydown(event) {
117
+ if (disabled) return;
118
+
119
+ switch (event.key) {
120
+ case 'ArrowDown':
121
+ event.preventDefault();
122
+ if (!isOpen) {
123
+ isOpen = true;
124
+ } else {
125
+ highlightedIndex = Math.min(highlightedIndex + 1, options.length - 1);
126
+ }
127
+ break;
128
+
129
+ case 'ArrowUp':
130
+ event.preventDefault();
131
+ if (!isOpen) {
132
+ isOpen = true;
133
+ } else {
134
+ highlightedIndex = Math.max(highlightedIndex - 1, 0);
135
+ }
136
+ break;
137
+
138
+ case 'Enter':
139
+ case ' ':
140
+ event.preventDefault();
141
+ if (isOpen && highlightedIndex >= 0) {
142
+ select(options[highlightedIndex]);
143
+ } else {
144
+ toggle();
145
+ }
146
+ break;
147
+
148
+ case 'Escape':
149
+ event.preventDefault();
150
+ close();
151
+ triggerRef?.focus();
152
+ break;
153
+
154
+ case 'Home':
155
+ event.preventDefault();
156
+ highlightedIndex = 0;
157
+ break;
158
+
159
+ case 'End':
160
+ event.preventDefault();
161
+ highlightedIndex = options.length - 1;
162
+ break;
163
+
164
+ case 'Tab':
165
+ close();
166
+ break;
167
+ }
168
+ }
169
+
170
+ // Styles
171
+ const triggerStyles = cv({
172
+ base: [
173
+ 'relative w-full flex items-center justify-between gap-2',
174
+ 'h-10 px-3 rounded-lg border',
175
+ 'text-sm text-left',
176
+ 'transition-colors duration-150',
177
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
178
+ 'disabled:opacity-50 disabled:cursor-not-allowed'
179
+ ].join(' '),
180
+
181
+ variants: {
182
+ error: {
183
+ true: 'border-error focus-visible:ring-error/50',
184
+ false: 'border-border hover:border-muted focus-visible:ring-primary/50'
185
+ },
186
+ open: {
187
+ true: 'border-primary ring-2 ring-primary/20',
188
+ false: ''
189
+ },
190
+ hasValue: {
191
+ true: 'text-text-strong',
192
+ false: 'text-muted'
193
+ }
194
+ },
195
+
196
+ defaults: {
197
+ error: false,
198
+ open: false,
199
+ hasValue: false
200
+ }
201
+ });
202
+ </script>
203
+
204
+ <div
205
+ class={cn('relative', className)}
206
+ use:clickOutside={close}
207
+ >
208
+ <!-- Trigger Button -->
209
+ <button
210
+ bind:this={triggerRef}
211
+ id={triggerId}
212
+ type="button"
213
+ class={triggerStyles({ error, open: isOpen, hasValue })}
214
+ {disabled}
215
+ aria-haspopup="listbox"
216
+ aria-expanded={isOpen}
217
+ aria-controls={listboxId}
218
+ aria-invalid={error || undefined}
219
+ onclick={toggle}
220
+ onkeydown={handleKeydown}
221
+ >
222
+ <span class="truncate flex-1">
223
+ {selectedLabel}
224
+ </span>
225
+
226
+ <!-- Chevron Icon -->
227
+ <svg
228
+ class={cn(
229
+ 'w-4 h-4 shrink-0 text-muted transition-transform duration-200',
230
+ isOpen && 'rotate-180'
231
+ )}
232
+ viewBox="0 0 24 24"
233
+ fill="none"
234
+ stroke="currentColor"
235
+ stroke-width="2"
236
+ stroke-linecap="round"
237
+ stroke-linejoin="round"
238
+ >
239
+ <path d="m6 9 6 6 6-6" />
240
+ </svg>
241
+ </button>
242
+
243
+ <!-- Dropdown Listbox -->
244
+ {#if isOpen}
245
+ <div
246
+ bind:this={listRef}
247
+ id={listboxId}
248
+ role="listbox"
249
+ tabindex="-1"
250
+ aria-labelledby={triggerId}
251
+ aria-activedescendant={highlightedIndex >= 0 ? `${listboxId}-option-${highlightedIndex}` : undefined}
252
+ class={cn(
253
+ 'absolute z-50 w-full mt-1',
254
+ 'max-h-60 overflow-auto',
255
+ 'bg-surface border border-border rounded-lg shadow-lg',
256
+ 'py-1',
257
+ 'animate-in fade-in slide-in-from-top-2 duration-150'
258
+ )}
259
+ onkeydown={handleKeydown}
260
+ >
261
+ {#if options.length === 0}
262
+ <div class="px-3 py-2 text-sm text-muted text-center">
263
+ No options available
264
+ </div>
265
+ {:else}
266
+ {#each options as option, index (option[valueKey])}
267
+ {@const isSelected = option[valueKey] === value}
268
+ {@const isHighlighted = index === highlightedIndex}
269
+
270
+ <button
271
+ id="{listboxId}-option-{index}"
272
+ type="button"
273
+ role="option"
274
+ data-index={index}
275
+ aria-selected={isSelected}
276
+ class={cn(
277
+ 'w-full flex items-center gap-2 px-3 py-2',
278
+ 'text-sm text-left',
279
+ 'transition-colors duration-75',
280
+ 'focus:outline-none',
281
+ isHighlighted && 'bg-hover',
282
+ isSelected && 'text-primary font-medium',
283
+ !isSelected && 'text-text'
284
+ )}
285
+ onclick={() => select(option)}
286
+ onmouseenter={() => highlightedIndex = index}
287
+ >
288
+ <!-- Check Icon for Selected -->
289
+ <span class={cn('w-4 h-4 shrink-0', !isSelected && 'invisible')}>
290
+ <svg
291
+ viewBox="0 0 24 24"
292
+ fill="none"
293
+ stroke="currentColor"
294
+ stroke-width="2.5"
295
+ stroke-linecap="round"
296
+ stroke-linejoin="round"
297
+ >
298
+ <path d="M20 6 9 17l-5-5" />
299
+ </svg>
300
+ </span>
301
+
302
+ <span class="truncate flex-1">
303
+ {option[labelKey]}
304
+ </span>
305
+ </button>
306
+ {/each}
307
+ {/if}
308
+ </div>
309
+ {/if}
310
+ </div>
311
+
312
+ <style>
313
+ @keyframes fade-in {
314
+ from { opacity: 0; }
315
+ to { opacity: 1; }
316
+ }
317
+
318
+ @keyframes slide-in-from-top-2 {
319
+ from { transform: translateY(-0.5rem); }
320
+ to { transform: translateY(0); }
321
+ }
322
+
323
+ .animate-in {
324
+ animation: fade-in 150ms ease-out, slide-in-from-top-2 150ms ease-out;
325
+ }
326
+ </style>
@@ -0,0 +1,159 @@
1
+ <!--
2
+ @component Switch
3
+
4
+ An accessible toggle switch for boolean values.
5
+
6
+ @example
7
+ <Switch bind:checked={enabled}>Enable notifications</Switch>
8
+
9
+ @example
10
+ <Switch bind:checked={darkMode} size="lg" />
11
+ -->
12
+ <script>
13
+ import { cn } from '../../utils/cn.svelte.js';
14
+ import { generateId } from '../../utils/reactive.svelte.js';
15
+
16
+ let {
17
+ checked = $bindable(false),
18
+ disabled = false,
19
+ size = 'md',
20
+ name = '',
21
+ id,
22
+ class: className = '',
23
+ children,
24
+ onchange,
25
+ ...rest
26
+ } = $props();
27
+
28
+ const inputId = id || generateId();
29
+
30
+ // Size classes map to CSS classes defined below
31
+ const sizeClass = $derived(`switch-${size}`);
32
+ </script>
33
+
34
+ <label
35
+ class={cn('switch-wrapper', disabled && 'switch-disabled', className)}
36
+ for={inputId}
37
+ >
38
+ <input
39
+ type="checkbox"
40
+ role="switch"
41
+ id={inputId}
42
+ {name}
43
+ {disabled}
44
+ bind:checked
45
+ {onchange}
46
+ class="switch-input"
47
+ aria-checked={checked}
48
+ {...rest}
49
+ />
50
+
51
+ <span class={cn('switch-track', sizeClass, checked && 'switch-track-checked')}>
52
+ <span class={cn('switch-thumb', checked && 'switch-thumb-checked')} />
53
+ </span>
54
+
55
+ {#if children}
56
+ <span class="switch-label">
57
+ {@render children()}
58
+ </span>
59
+ {/if}
60
+ </label>
61
+
62
+ <style>
63
+ .switch-wrapper {
64
+ display: inline-flex;
65
+ align-items: center;
66
+ gap: var(--space-2);
67
+ cursor: pointer;
68
+ user-select: none;
69
+ }
70
+
71
+ .switch-disabled {
72
+ opacity: 0.5;
73
+ cursor: not-allowed;
74
+ }
75
+
76
+ .switch-input {
77
+ position: absolute;
78
+ width: 1px;
79
+ height: 1px;
80
+ padding: 0;
81
+ margin: -1px;
82
+ overflow: hidden;
83
+ clip: rect(0, 0, 0, 0);
84
+ white-space: nowrap;
85
+ border: 0;
86
+ }
87
+
88
+ .switch-track {
89
+ position: relative;
90
+ display: inline-flex;
91
+ align-items: center;
92
+ flex-shrink: 0;
93
+ border-radius: var(--radius-full);
94
+ background-color: var(--color-border);
95
+ transition: var(--transition-colors);
96
+ }
97
+
98
+ .switch-track-checked {
99
+ background-color: var(--color-primary);
100
+ }
101
+
102
+ .switch-input:focus-visible + .switch-track {
103
+ outline: 2px solid var(--color-primary);
104
+ outline-offset: 2px;
105
+ }
106
+
107
+ .switch-thumb {
108
+ position: absolute;
109
+ left: 2px;
110
+ border-radius: var(--radius-full);
111
+ background-color: white;
112
+ box-shadow: var(--shadow-sm);
113
+ transition: transform var(--duration-fast) var(--ease-out);
114
+ }
115
+
116
+ .switch-label {
117
+ font-size: var(--text-sm);
118
+ color: var(--color-text);
119
+ }
120
+
121
+ /* Size variants - Small */
122
+ .switch-sm {
123
+ width: 2rem;
124
+ height: 1rem;
125
+ }
126
+ .switch-sm .switch-thumb {
127
+ width: 0.75rem;
128
+ height: 0.75rem;
129
+ }
130
+ .switch-sm .switch-thumb-checked {
131
+ transform: translateX(1rem);
132
+ }
133
+
134
+ /* Size variants - Medium (default) */
135
+ .switch-md {
136
+ width: 2.5rem;
137
+ height: 1.25rem;
138
+ }
139
+ .switch-md .switch-thumb {
140
+ width: 1rem;
141
+ height: 1rem;
142
+ }
143
+ .switch-md .switch-thumb-checked {
144
+ transform: translateX(1.25rem);
145
+ }
146
+
147
+ /* Size variants - Large */
148
+ .switch-lg {
149
+ width: 3rem;
150
+ height: 1.5rem;
151
+ }
152
+ .switch-lg .switch-thumb {
153
+ width: 1.25rem;
154
+ height: 1.25rem;
155
+ }
156
+ .switch-lg .switch-thumb-checked {
157
+ transform: translateX(1.5rem);
158
+ }
159
+ </style>
@@ -0,0 +1,122 @@
1
+ <!--
2
+ @component Textarea
3
+
4
+ A multi-line text input component.
5
+
6
+ @example
7
+ <Textarea bind:value={description} placeholder="Enter description" rows={4} />
8
+
9
+ @example
10
+ // Auto-resize based on content
11
+ <Textarea bind:value={notes} autoResize />
12
+ -->
13
+ <script>
14
+ import { cn } from '../../utils/cn.svelte.js';
15
+
16
+ let {
17
+ value = $bindable(''),
18
+ ref = $bindable(),
19
+ placeholder = '',
20
+ disabled = false,
21
+ required = false,
22
+ name = '',
23
+ id = '',
24
+ rows = 3,
25
+ maxlength,
26
+ minlength,
27
+ autoResize = false,
28
+ class: className = '',
29
+ unstyled = false,
30
+ error = false,
31
+ oninput,
32
+ onchange,
33
+ onkeydown,
34
+ onfocus,
35
+ onblur,
36
+ ...rest
37
+ } = $props();
38
+
39
+ const textareaClass = $derived(
40
+ unstyled ? className : cn(
41
+ 'textarea-base',
42
+ autoResize && 'textarea-auto-resize',
43
+ error && 'textarea-error',
44
+ className
45
+ )
46
+ );
47
+
48
+ function handleInput(e) {
49
+ if (autoResize && ref) {
50
+ ref.style.height = 'auto';
51
+ ref.style.height = ref.scrollHeight + 'px';
52
+ }
53
+ oninput?.(e);
54
+ }
55
+ </script>
56
+
57
+ <textarea
58
+ class={textareaClass}
59
+ bind:this={ref}
60
+ bind:value
61
+ {id}
62
+ {name}
63
+ {rows}
64
+ {placeholder}
65
+ {disabled}
66
+ {required}
67
+ {maxlength}
68
+ {minlength}
69
+ aria-invalid={error || undefined}
70
+ oninput={handleInput}
71
+ {onchange}
72
+ {onkeydown}
73
+ {onfocus}
74
+ {onblur}
75
+ {...rest}
76
+ ></textarea>
77
+
78
+ <style>
79
+ .textarea-base {
80
+ width: 100%;
81
+ padding: var(--space-2) var(--space-3);
82
+ font-size: var(--text-sm);
83
+ line-height: 1.5;
84
+ color: var(--color-text-strong);
85
+ background-color: var(--color-bg);
86
+ border: 1px solid var(--color-border-muted);
87
+ border-radius: var(--radius-lg);
88
+ transition: var(--transition-colors);
89
+ resize: vertical;
90
+ font-family: inherit;
91
+ }
92
+
93
+ .textarea-base::placeholder {
94
+ color: var(--color-text-muted);
95
+ }
96
+
97
+ .textarea-base:focus {
98
+ outline: none;
99
+ border-color: var(--color-primary);
100
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);
101
+ }
102
+
103
+ .textarea-base:disabled {
104
+ opacity: 0.5;
105
+ cursor: not-allowed;
106
+ resize: none;
107
+ }
108
+
109
+ .textarea-auto-resize {
110
+ resize: none;
111
+ overflow: hidden;
112
+ }
113
+
114
+ .textarea-error {
115
+ border-color: var(--color-error);
116
+ }
117
+
118
+ .textarea-error:focus {
119
+ border-color: var(--color-error);
120
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-error) 20%, transparent);
121
+ }
122
+ </style>
@@ -0,0 +1,65 @@
1
+ <!--
2
+ @component Accordion
3
+
4
+ Collapsible content sections.
5
+
6
+ @example Single item
7
+ <Accordion>
8
+ <AccordionItem title="Section 1">
9
+ Content for section 1
10
+ </AccordionItem>
11
+ <AccordionItem title="Section 2">
12
+ Content for section 2
13
+ </AccordionItem>
14
+ </Accordion>
15
+
16
+ @example Controlled
17
+ <Accordion bind:expanded={openSections} multiple>
18
+ ...
19
+ </Accordion>
20
+ -->
21
+ <script>
22
+ import { setContext } from 'svelte';
23
+
24
+ let {
25
+ expanded = $bindable([]),
26
+ multiple = false,
27
+ children,
28
+ class: className = ''
29
+ } = $props();
30
+
31
+ function toggle(id) {
32
+ if (multiple) {
33
+ if (expanded.includes(id)) {
34
+ expanded = expanded.filter(i => i !== id);
35
+ } else {
36
+ expanded = [...expanded, id];
37
+ }
38
+ } else {
39
+ expanded = expanded.includes(id) ? [] : [id];
40
+ }
41
+ }
42
+
43
+ function isExpanded(id) {
44
+ return expanded.includes(id);
45
+ }
46
+
47
+ setContext('accordion', {
48
+ toggle,
49
+ isExpanded: (id) => expanded.includes(id)
50
+ });
51
+ </script>
52
+
53
+ <div class="accordion {className}">
54
+ {@render children?.()}
55
+ </div>
56
+
57
+ <style>
58
+ .accordion {
59
+ display: flex;
60
+ flex-direction: column;
61
+ border: 1px solid var(--color-border);
62
+ border-radius: 0.5rem;
63
+ overflow: hidden;
64
+ }
65
+ </style>