astro-component-kit 0.4.1 → 0.5.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.
@@ -0,0 +1,2513 @@
1
+ // src/data/registry/forms.ts
2
+ import type { ComponentDoc } from './types';
3
+
4
+ export const forms: ComponentDoc[] = [
5
+ {
6
+ slug: 'animated-input',
7
+ name: 'Animated Input',
8
+ description: 'Input with floating label animation.',
9
+ category: 'forms',
10
+ tags: ["input","animated"],
11
+ featured: true,
12
+ code: `---
13
+ /**
14
+ * AnimatedInput — A form input with a floating label animation and focus glow effect.
15
+ *
16
+ * @param {string} label - The visible indicator for the input field.
17
+ * @param {string} id - The HTML id and for attribute linkage.
18
+ * @param {string} type - Input element type. Default is 'text'.
19
+ * @param {string} placeholder - Passed placeholder. Default is ' '. (Space is needed for CSS :placeholder-shown to work).
20
+ * @param {boolean} required - HTML required attribute parameter. Default is false.
21
+ * @param {string} name - HTML input name binding.
22
+ * @param {string} autocomplete - HTML autocomplete hint (e.g. 'email', 'given-name'). Default is 'off'.
23
+ */
24
+ interface Props {
25
+ label: string;
26
+ id: string;
27
+ type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url';
28
+ placeholder?: string;
29
+ required?: boolean;
30
+ name?: string;
31
+ autocomplete?: string;
32
+ }
33
+
34
+ const { label, id, type = 'text', placeholder = ' ', required = false, name, autocomplete = 'off' } = Astro.props;
35
+ ---
36
+
37
+ <div class="anim-input-wrap">
38
+ <input {id} {name} {type} {placeholder} {required} class="anim-input" autocomplete={autocomplete} />
39
+ <label for={id} class="anim-input-label">{label}</label>
40
+ </div>
41
+
42
+ <style>
43
+ .anim-input-wrap { position: relative; width: 100%; }
44
+ .anim-input {
45
+ width: 100%;
46
+ padding: 1.1rem 1rem 0.5rem;
47
+ font-family: inherit; font-size: 0.95rem;
48
+ color: var(--c-text-1, #e2e8f0);
49
+ background: var(--c-bg-elev, rgba(255, 255, 255, 0.04));
50
+ border: 1px solid var(--c-border, rgba(255, 255, 255, 0.1));
51
+ border-radius: var(--r-md, 12px); box-sizing: border-box;
52
+ outline: none; transition: border-color 0.25s ease, background 0.25s ease;
53
+ }
54
+ .anim-input::placeholder { color: transparent; }
55
+ .anim-input:focus {
56
+ background: rgba(99, 102, 241, 0.06);
57
+ border-color: rgba(99, 102, 241, 0.5);
58
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12);
59
+ }
60
+ .anim-input-label {
61
+ position: absolute; top: 0.9rem; left: 1rem;
62
+ font-size: 0.9rem; color: var(--c-text-2, #64748b);
63
+ pointer-events: none; transition: top 0.2s ease, font-size 0.2s ease, color 0.2s ease;
64
+ }
65
+ .anim-input:focus ~ .anim-input-label,
66
+ .anim-input:not(:placeholder-shown) ~ .anim-input-label {
67
+ top: 0.28rem; font-size: 0.68rem;
68
+ color: var(--c-primary-light, #818cf8);
69
+ font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;
70
+ }
71
+ </style>
72
+ `,
73
+ usage: `---
74
+ import { AnimatedInput } from 'astro-component-kit';
75
+ ---
76
+
77
+ <form class="stack-v gap-6">
78
+ <AnimatedInput
79
+ id="email"
80
+ name="email"
81
+ label="Email Address"
82
+ type="email"
83
+ required
84
+ />
85
+
86
+ <AnimatedInput
87
+ id="user"
88
+ name="username"
89
+ label="Username"
90
+ required
91
+ />
92
+ </form>`,
93
+ },
94
+ {
95
+ slug: 'chip-input',
96
+ name: 'Chip/Tag Input',
97
+ description: 'Interactive tag management control.',
98
+ category: 'forms',
99
+ tags: ["input","chips"],
100
+ featured: true,
101
+ code: `---
102
+ /**
103
+ * ChipInput — An interactive input for managing a list of tags or chips.
104
+ *
105
+ * @param {string} label - Input labeling title.
106
+ * @param {string[]} initialChips - Optional array of strings to pre-populate.
107
+ * @param {string} name - HTML name for form submission.
108
+ * @param {string} placeholder - Placeholder text for the input area.
109
+ */
110
+ interface Props {
111
+ label?: string;
112
+ initialChips?: string[];
113
+ name?: string;
114
+ placeholder?: string;
115
+ }
116
+
117
+ const { label, initialChips = [], name, placeholder = "Add tag..." } = Astro.props;
118
+ ---
119
+
120
+ <div class="chip-input-container">
121
+ {label && <label class="chip-input-label">{label}</label>}
122
+ <div class="chip-input" data-chip-input>
123
+ <div class="chip-input__list" data-chip-list>
124
+ {initialChips.map(chip => (
125
+ <span class="chip">
126
+ {chip}
127
+ <button type="button" aria-label={\`Remove \${chip}\`} data-remove-chip>×</button>
128
+ <input type="hidden" name={\`\${name}[]\`} value={chip} />
129
+ </span>
130
+ ))}
131
+ </div>
132
+ <input
133
+ type="text"
134
+ class="chip-input__field"
135
+ placeholder={initialChips.length > 0 ? "" : placeholder}
136
+ data-chip-field
137
+ />
138
+ </div>
139
+ </div>
140
+
141
+ <style>
142
+ .chip-input-container { display: flex; flex-direction: column; gap: var(--sp-2, 0.5rem); width: 100%; }
143
+ .chip-input-label { font-size: 0.85rem; font-weight: 600; color: var(--c-text-2, #94a3b8); margin-left: 0.5rem; }
144
+
145
+ .chip-input {
146
+ display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.5rem;
147
+ background: var(--c-bg-elev, rgba(0,0,0,0.1));
148
+ border: 1px solid var(--c-border, rgba(255,255,255,0.1));
149
+ border-radius: var(--r-md, 10px);
150
+ transition: border-color 0.2s;
151
+ }
152
+ .chip-input:focus-within { border-color: var(--c-primary, #6366f1); }
153
+
154
+ .chip-input__list { display: flex; flex-wrap: wrap; gap: 0.5rem; }
155
+
156
+ .chip {
157
+ background: var(--c-primary, #6366f1);
158
+ color: #fff;
159
+ padding: 0.2rem 0.6rem;
160
+ border-radius: var(--r-sm, 6px);
161
+ font-size: 0.8rem;
162
+ display: flex; align-items: center; gap: 0.4rem;
163
+ }
164
+ .chip button {
165
+ background: none; border: none; color: #fff; cursor: pointer;
166
+ font-size: 1.1rem; line-height: 1; padding: 0;
167
+ opacity: 0.8; transition: opacity 0.2s;
168
+ }
169
+ .chip button:hover { opacity: 1; }
170
+
171
+ .chip-input__field {
172
+ background: none; border: none; outline: none;
173
+ color: var(--c-text-1, #fff); flex: 1; min-width: 100px;
174
+ padding: 0.2rem; font-family: inherit; font-size: 0.9rem;
175
+ }
176
+ </style>
177
+
178
+ <script>
179
+ // Simplified client-side logic for adding/removing chips
180
+ document.querySelectorAll('[data-chip-input]').forEach(container => {
181
+ const list = container.querySelector('[data-chip-list]');
182
+ const input = container.querySelector('[data-chip-field]') as HTMLInputElement;
183
+ const name = "tags"; // Should ideally be passed from props but hidden inputs solve this for now
184
+
185
+ const addChip = (value: string) => {
186
+ if (!value.trim() || !list) return;
187
+ const chip = document.createElement('span');
188
+ chip.className = 'chip';
189
+ chip.innerHTML = \`\${value}<button type="button" aria-label="Remove \${value}" data-remove-chip>×</button>\`;
190
+
191
+ const hidden = document.createElement('input');
192
+ hidden.type = 'hidden';
193
+ hidden.name = 'chips[]';
194
+ hidden.value = value;
195
+ chip.appendChild(hidden);
196
+
197
+ list.appendChild(chip);
198
+ input.value = '';
199
+ input.placeholder = '';
200
+ };
201
+
202
+ input?.addEventListener('keydown', (e) => {
203
+ if (e.key === 'Enter') {
204
+ e.preventDefault();
205
+ addChip(input.value);
206
+ }
207
+ if (e.key === 'Backspace' && !input.value && list?.lastElementChild) {
208
+ list.removeChild(list.lastElementChild);
209
+ if (list.children.length === 0) input.placeholder = "Add tag...";
210
+ }
211
+ });
212
+
213
+ container.addEventListener('click', (e) => {
214
+ const target = e.target as HTMLElement;
215
+ if (target.hasAttribute('data-remove-chip')) {
216
+ target.closest('.chip')?.remove();
217
+ if (list?.children.length === 0) input.placeholder = "Add tag...";
218
+ }
219
+ });
220
+ });
221
+ </script>
222
+ `,
223
+ usage: `<ChipInput label="Product Tags" initialChips={["New", "Sale"]} placeholder="Add tag..." />`,
224
+ },
225
+ {
226
+ slug: 'color-picker-input',
227
+ name: 'Color Picker',
228
+ description: 'Custom hex color selector with preview.',
229
+ category: 'forms',
230
+ tags: ["input","color"],
231
+ featured: true,
232
+ code: `---
233
+ /**
234
+ * ColorPickerInput — A specialized input for selecting hex colors with a circular preview.
235
+ *
236
+ * @param {string} label - Input labeling title.
237
+ * @param {string} id - HTML ID for the picker.
238
+ * @param {string} value - Default starting color value (hex). Default is '#6366f1'.
239
+ * @param {string} name - HTML name binding.
240
+ */
241
+ interface Props {
242
+ label: string;
243
+ id: string;
244
+ value?: string;
245
+ name?: string;
246
+ }
247
+
248
+ const { label, id, value = "#6366f1", name } = Astro.props;
249
+ ---
250
+
251
+ <div class="color-picker-wrap">
252
+ <div class="color-input-container">
253
+ <input type="color" class="color-input" {id} {name} {value} />
254
+ <label for={id} class="color-label">{label}</label>
255
+ </div>
256
+ <span class="color-value" data-color-value>{value}</span>
257
+ </div>
258
+
259
+ <style>
260
+ .color-picker-wrap { display: flex; align-items: center; justify-content: space-between; gap: 1rem; background: var(--c-bg-elev, rgba(255,255,255,0.03)); padding: var(--sp-3, 0.75rem) var(--sp-4, 1rem); border-radius: var(--r-md, 12px); border: 1px solid var(--c-border, rgba(255,255,255,0.1)); }
261
+ .color-input-container { display: flex; align-items: center; gap: 1rem; }
262
+
263
+ .color-input {
264
+ -webkit-appearance: none;
265
+ border: none;
266
+ width: 38px; height: 38px;
267
+ border-radius: var(--r-full, 50%);
268
+ cursor: pointer;
269
+ background: transparent;
270
+ padding: 0;
271
+ }
272
+ .color-input::-webkit-color-swatch-wrapper { padding: 0; }
273
+ .color-input::-webkit-color-swatch {
274
+ border: 2px solid rgba(255,255,255,0.2);
275
+ border-radius: 50%;
276
+ box-shadow: 0 4px 10px rgba(0,0,0,0.2);
277
+ }
278
+
279
+ .color-label { font-size: 0.9rem; color: var(--c-text-1, #e2e8f0); font-weight: 500; cursor: pointer; }
280
+ .color-value { font-size: 0.8rem; color: var(--c-text-2, #64748b); font-family: monospace; text-transform: uppercase; letter-spacing: 0.05em; }
281
+ </style>
282
+
283
+ <script>
284
+ document.querySelectorAll('.color-input').forEach(input => {
285
+ const parent = input.closest('.color-picker-wrap');
286
+ const display = parent?.querySelector('[data-color-value]');
287
+ input.addEventListener('input', (e) => {
288
+ if (display) display.textContent = (e.target as HTMLInputElement).value;
289
+ });
290
+ });
291
+ </script>
292
+ `,
293
+ usage: `<ColorPickerInput id="theme" label="Brand Color" value="#6366f1" />`,
294
+ },
295
+ {
296
+ slug: 'currency-input',
297
+ name: 'Currency Input',
298
+ description: 'Numeric input with currency prefix.',
299
+ category: 'forms',
300
+ tags: ["input","currency"],
301
+ featured: true,
302
+ code: `---
303
+ /**
304
+ * CurrencyInput — A numeric input prefixed with a currency symbol for financial data entry.
305
+ *
306
+ * @param {string} label - Input labeling title.
307
+ * @param {string} id - HTML ID for attribution.
308
+ * @param {string} symbol - Currency symbol to display (e.g. "$", "€"). Default is "$".
309
+ * @param {number} value - Pre-filled numeric value.
310
+ * @param {string} name - HTML name binding.
311
+ * @param {number} step - Numeric increment. Default is 0.01.
312
+ */
313
+ interface Props {
314
+ label: string;
315
+ id: string;
316
+ symbol?: string;
317
+ value?: number;
318
+ name?: string;
319
+ step?: number;
320
+ }
321
+
322
+ const { label, id, symbol = "$", value = 0.00, name, step = 0.01 } = Astro.props;
323
+ ---
324
+
325
+ <div class="curr-input-container">
326
+ <label for={id} class="curr-label">{label}</label>
327
+ <div class="curr-wrap">
328
+ <span class="curr-symbol">{symbol}</span>
329
+ <input type="number" {id} {name} {step} value={value.toFixed(2)} class="curr-field" />
330
+ </div>
331
+ </div>
332
+
333
+ <style>
334
+ .curr-input-container { display: flex; flex-direction: column; gap: var(--sp-2, 0.5rem); width: 100%; }
335
+ .curr-label { font-size: 0.85rem; font-weight: 600; color: var(--c-text-2, #94a3b8); margin-left: 0.5rem; }
336
+
337
+ .curr-wrap {
338
+ display: flex; align-items: center;
339
+ background: var(--c-bg-elev, rgba(255,255,255,0.05));
340
+ border-radius: var(--r-md, 12px);
341
+ border: 1px solid var(--c-border, rgba(255,255,255,0.1));
342
+ overflow: hidden;
343
+ transition: border-color 0.2s;
344
+ }
345
+ .curr-wrap:focus-within { border-color: var(--c-primary, #6366f1); }
346
+
347
+ .curr-symbol {
348
+ padding: 0.8rem 1rem;
349
+ background: rgba(255,255,255,0.05);
350
+ color: var(--c-text-2, #94a3b8);
351
+ font-weight: 700; border-right: 1px solid var(--c-border, rgba(255,255,255,0.1));
352
+ }
353
+
354
+ .curr-field {
355
+ background: none; border: none;
356
+ padding: 0.8rem 1rem;
357
+ color: var(--c-text-1, #fff);
358
+ font-size: 1.1rem; width: 100%; outline: none;
359
+ font-family: inherit; font-weight: 600;
360
+ }
361
+
362
+ /* Remove number spins */
363
+ .curr-field::-webkit-inner-spin-button,
364
+ .curr-field::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
365
+ </style>
366
+ `,
367
+ usage: `<CurrencyInput id="price" label="Product Price" symbol="$" value={29.99} />`,
368
+ },
369
+ {
370
+ slug: 'custom-radio-group',
371
+ name: 'Custom Radio Group',
372
+ description: 'Styled radio button collection.',
373
+ category: 'forms',
374
+ tags: ["radio","group"],
375
+ featured: true,
376
+ code: `---
377
+ /**
378
+ * CustomRadioGroup — A group of styled radio buttons for vertical or horizontal options.
379
+ *
380
+ * @param {string} label - Optional title for the fieldset.
381
+ * @param {string} name - Common HTML name for the radio group.
382
+ * @param {Array<{label: string, value: string, checked?: boolean}>} options - List of choices.
383
+ * @param {boolean} horizontal - Layout orientation. Default is false (vertical).
384
+ */
385
+ interface Props {
386
+ label?: string;
387
+ name: string;
388
+ options: Array<{ label: string; value: string; checked?: boolean }>;
389
+ horizontal?: boolean;
390
+ }
391
+
392
+ const { label, name, options = [], horizontal = false } = Astro.props;
393
+ ---
394
+
395
+ <fieldset class:list={["radio-group", { "radio-group--horizontal": horizontal }]}>
396
+ {label && <legend class="radio-group-legend">{label}</legend>}
397
+ <div class="radio-items">
398
+ {options.map((opt, i) => (
399
+ <label class="radio-item">
400
+ <input type="radio" name={name} value={opt.value} checked={opt.checked} />
401
+ <span class="radio-circle" aria-hidden="true"></span>
402
+ <span class="radio-label">{opt.label}</span>
403
+ </label>
404
+ ))}
405
+ </div>
406
+ </fieldset>
407
+
408
+ <style>
409
+ .radio-group { border: none; padding: 0; margin: 0; width: 100%; }
410
+ .radio-group-legend { font-size: 0.85rem; font-weight: 600; color: var(--c-text-2, #94a3b8); margin-bottom: var(--sp-3, 0.75rem); margin-left: 0.5rem; }
411
+
412
+ .radio-items { display: flex; flex-direction: column; gap: var(--sp-3, 0.8rem); }
413
+ .radio-group--horizontal .radio-items { flex-direction: row; flex-wrap: wrap; gap: var(--sp-6, 1.5rem); }
414
+
415
+ .radio-item { display: flex; align-items: center; gap: 0.75rem; cursor: pointer; color: var(--c-text-2, #94a3b8); transition: 0.2s; user-select: none; }
416
+
417
+ .radio-item input { position: absolute; opacity: 0; width: 1px; height: 1px; margin: 0; pointer-events: none; }
418
+ .radio-item { position: relative; }
419
+ .radio-item input:focus-visible + .radio-circle { outline: 2px solid var(--c-primary-light, #818cf8); outline-offset: 2px; }
420
+
421
+ .radio-circle {
422
+ width: 20px; height: 20px;
423
+ border: 2px solid var(--c-border, #334155);
424
+ border-radius: var(--r-full, 50%);
425
+ position: relative; cursor: pointer; transition: 0.2s;
426
+ background: var(--c-bg-elev, rgba(255,255,255,0.02));
427
+ }
428
+
429
+ .radio-item:hover .radio-circle { border-color: var(--c-primary-light, #818cf8); }
430
+
431
+ .radio-circle::after {
432
+ content: ''; position: absolute; inset: 4px;
433
+ background: var(--c-primary, #6366f1);
434
+ border-radius: 50%; opacity: 0; transform: scale(0.5); transition: 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
435
+ }
436
+
437
+ .radio-item input:checked + .radio-circle { border-color: var(--c-primary, #6366f1); box-shadow: 0 0 10px rgba(99, 102, 241, 0.2); }
438
+ .radio-item input:checked + .radio-circle::after { opacity: 1; transform: scale(1); }
439
+
440
+ .radio-label { font-size: 0.95rem; }
441
+ .radio-item input:checked ~ .radio-label { color: var(--c-text-1, #fff); font-weight: 600; }
442
+ </style>
443
+ `,
444
+ usage: `---
445
+ import { CustomRadioGroup } from 'astro-component-kit';
446
+ ---
447
+
448
+ <CustomRadioGroup
449
+ label="Select your billing cycle"
450
+ name="billing"
451
+ options={[
452
+ { label: "Monthly", value: "monthly" },
453
+ { label: "Yearly (Save 20%)", value: "yearly", checked: true },
454
+ { label: "Lifetime", value: "life" }
455
+ ]}
456
+ />`,
457
+ },
458
+ {
459
+ slug: 'cyberpunk-checkbox',
460
+ name: 'Cyberpunk Checkbox',
461
+ description: 'Neon themed bold checkbox.',
462
+ category: 'forms',
463
+ tags: ["checkbox","cyberpunk"],
464
+ featured: true,
465
+ code: `---
466
+ /**
467
+ * CyberpunkCheckbox — A bold, neon-themed checkbox with angular borders and glitch-toggle effect.
468
+ *
469
+ * @param {string} label - Text label displayed alongside the checkbox.
470
+ * @param {string} id - HTML ID for the checkbox.
471
+ * @param {string} name - HTML name binding.
472
+ * @param {boolean} checked - Initial toggle state.
473
+ */
474
+ interface Props {
475
+ label: string;
476
+ id: string;
477
+ name?: string;
478
+ checked?: boolean;
479
+ }
480
+
481
+ const { label, id, name, checked = false } = Astro.props;
482
+ ---
483
+
484
+ <label class="cyber-check" for={id}>
485
+ <input type="checkbox" {id} {name} {checked} />
486
+ <span class="checkmark" aria-hidden="true"></span>
487
+ <span class="cyber-text">{label}</span>
488
+ </label>
489
+
490
+ <style>
491
+ .cyber-check {
492
+ display: flex; align-items: center; gap: var(--sp-4, 1rem);
493
+ cursor: pointer;
494
+ color: var(--c-primary-light, #00e0ff);
495
+ font-family: 'Courier New', Courier, monospace;
496
+ font-weight: 800;
497
+ text-transform: uppercase;
498
+ user-select: none;
499
+ padding: var(--sp-2, 0.5rem);
500
+ transition: 0.2s;
501
+ }
502
+
503
+ .cyber-check:hover .checkmark { border-color: var(--c-primary-light); }
504
+
505
+ .cyber-check input { display: none; }
506
+
507
+ .checkmark {
508
+ width: 20px; height: 20px;
509
+ border: 2px solid var(--c-primary, #ff003c);
510
+ position: relative; transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
511
+ flex-shrink: 0;
512
+ clip-path: polygon(10% 0%, 100% 0%, 90% 100%, 0% 100%);
513
+ }
514
+
515
+ .cyber-check input:checked + .checkmark {
516
+ background: var(--c-primary, #ff003c);
517
+ box-shadow: 0 0 15px var(--c-primary, #ff003c);
518
+ transform: rotate(5deg) scale(1.1);
519
+ }
520
+
521
+ .checkmark::after {
522
+ content: ''; position: absolute; display: none;
523
+ left: 7px; top: 2px; width: 4px; height: 10px;
524
+ border: solid #fff; border-width: 0 3px 3px 0;
525
+ transform: rotate(45deg);
526
+ }
527
+
528
+ .cyber-check input:checked + .checkmark::after { display: block; }
529
+
530
+ .cyber-text { letter-spacing: 0.05em; font-size: 0.9rem; }
531
+
532
+ .cyber-check input:checked ~ .cyber-text {
533
+ text-shadow: 0 0 8px var(--c-primary-light);
534
+ }
535
+ </style>
536
+ `,
537
+ usage: `<CyberpunkCheckbox id="accept" label="Accept Protocols" checked />`,
538
+ },
539
+ {
540
+ slug: 'drag-drop-upload',
541
+ name: 'Drag & Drop Upload',
542
+ description: 'Large interactive file upload zone.',
543
+ category: 'forms',
544
+ tags: ["upload","file"],
545
+ featured: true,
546
+ code: `---
547
+ /**
548
+ * DragDropUpload — A prominent file upload zone with drag-and-drop support and visual feedback.
549
+ *
550
+ * @param {string} label - Primary instruction text. Default is "Drag files here or browse".
551
+ * @param {string} id - HTML ID for the file input.
552
+ * @param {string} name - HTML name binding.
553
+ * @param {string} accept - Allowed file types (e.g. "image/*").
554
+ * @param {boolean} multiple - Enable multiple file selection.
555
+ */
556
+ interface Props {
557
+ label?: string;
558
+ id: string;
559
+ name?: string;
560
+ accept?: string;
561
+ multiple?: boolean;
562
+ }
563
+
564
+ const { label = "Drag files here or browse", id, name, accept, multiple = false } = Astro.props;
565
+ ---
566
+
567
+ <div class="upload-zone" data-upload-zone>
568
+ <div class="upload-zone__icon" aria-hidden="true">
569
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
570
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
571
+ <polyline points="17 8 12 3 7 8"/>
572
+ <line x1="12" y1="3" x2="12" y2="15"/>
573
+ </svg>
574
+ </div>
575
+ <p class="upload-zone__text">
576
+ {label.split('browse').map((part, i) => (
577
+ i === 0 ? <span>{part} <span class="upload-zone__link">browse</span></span> : part
578
+ ))}
579
+ </p>
580
+ <input type="file" {id} {name} {accept} {multiple} hidden class="upload-zone__input" data-upload-input />
581
+ </div>
582
+
583
+ <style>
584
+ .upload-zone {
585
+ border: 2px dashed var(--c-border, rgba(255,255,255,0.1));
586
+ border-radius: var(--r-xl, 20px);
587
+ padding: var(--sp-12, 3rem);
588
+ text-align: center;
589
+ background: var(--c-bg-elev, rgba(255,255,255,0.02));
590
+ cursor: pointer;
591
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
592
+ color: var(--c-text-2, #94a3b8);
593
+ display: flex;
594
+ flex-direction: column;
595
+ align-items: center;
596
+ gap: var(--sp-4, 1rem);
597
+ }
598
+
599
+ .upload-zone:hover, .upload-zone--dragging {
600
+ border-color: var(--c-primary, #6366f1);
601
+ background: rgba(99, 102, 241, 0.05);
602
+ color: var(--c-text-1, #fff);
603
+ transform: translateY(-2px);
604
+ }
605
+
606
+ .upload-zone__icon { color: var(--c-primary, #6366f1); transition: transform 0.3s; }
607
+ .upload-zone:hover .upload-zone__icon { transform: translateY(-5px); }
608
+
609
+ .upload-zone__text { font-size: 1rem; margin: 0; }
610
+ .upload-zone__link { color: var(--c-primary, #6366f1); font-weight: 700; text-decoration: underline; text-underline-offset: 4px; }
611
+ </style>
612
+
613
+ <script>
614
+ document.querySelectorAll('[data-upload-zone]').forEach(zone => {
615
+ const input = zone.querySelector('[data-upload-input]') as HTMLInputElement;
616
+
617
+ zone.addEventListener('click', () => input.click());
618
+
619
+ zone.addEventListener('dragover', (e) => {
620
+ e.preventDefault();
621
+ zone.classList.add('upload-zone--dragging');
622
+ });
623
+
624
+ ['dragleave', 'drop'].forEach(evt => {
625
+ zone.addEventListener(evt, () => zone.classList.remove('upload-zone--dragging'));
626
+ });
627
+
628
+ zone.addEventListener('drop', (e) => {
629
+ e.preventDefault();
630
+ if ((e as DragEvent).dataTransfer?.files) {
631
+ input.files = (e as DragEvent).dataTransfer!.files;
632
+ // Trigger change
633
+ input.dispatchEvent(new Event('change'));
634
+ }
635
+ });
636
+ });
637
+ </script>
638
+ `,
639
+ usage: `<DragDropUpload id="files" accept="image/*" multiple />`,
640
+ },
641
+ {
642
+ slug: 'error-input',
643
+ name: 'Error Input',
644
+ description: 'Input styled for validation error states.',
645
+ category: 'forms',
646
+ tags: ["input","validation"],
647
+ featured: true,
648
+ code: `---
649
+ /**
650
+ * ErrorInput — A styled input strictly formatted to demonstrate error or invalid validation states.
651
+ *
652
+ * @param {string} id - The ID for the input form element.
653
+ * @param {string} errorMessage - The message text presented below the input field.
654
+ * @param {string} value - Default starting value mimicking failed data.
655
+ * @param {string} placeholder - Optional standard placeholder.
656
+ * @param {string} name - HTML name binding.
657
+ */
658
+ interface Props {
659
+ id: string;
660
+ errorMessage: string;
661
+ value?: string;
662
+ placeholder?: string;
663
+ name?: string;
664
+ }
665
+
666
+ const { id, errorMessage, value = '', placeholder, name } = Astro.props;
667
+ ---
668
+
669
+ <div class="error-wrap">
670
+ <input type="text" {id} {name} {value} {placeholder} class="input-error" aria-invalid="true" aria-describedby={\`\${id}-error\`} />
671
+ <span id={\`\${id}-error\`} class="error-msg" aria-live="polite">{errorMessage}</span>
672
+ </div>
673
+
674
+ <style>
675
+ .error-wrap { display: flex; flex-direction: column; gap: 0.4rem; width: 100%; box-sizing: border-box; }
676
+ .input-error {
677
+ border: 1px solid var(--c-error, #ef4444) !important;
678
+ background: rgba(239, 68, 68, 0.05) !important;
679
+ padding: var(--sp-3, 0.8rem);
680
+ color: var(--c-text-1, #fff);
681
+ border-radius: var(--r-sm, 10px); outline: none;
682
+ font-family: inherit; font-size: 0.95rem; box-sizing: border-box;
683
+ }
684
+ .error-msg { font-size: 0.75rem; color: #f87171; font-weight: 500; }
685
+ </style>
686
+ `,
687
+ usage: `<ErrorInput id="user" errorMessage="Username is already taken" value="konstantin" />`,
688
+ },
689
+ {
690
+ slug: 'floating-password',
691
+ name: 'Password Toggle',
692
+ description: 'Password field with reveal toggle.',
693
+ category: 'forms',
694
+ tags: ["input","password"],
695
+ featured: true,
696
+ code: `---
697
+ /**
698
+ * FloatingPassword — A password input field featuring a reveal/hide toggle inside the frame.
699
+ *
700
+ * @param {string} id - Unique identifier linking toggle state securely.
701
+ * @param {string} placeholder - Default text visible when empty. Default is "Password".
702
+ * @param {string} name - HTML name binding.
703
+ * @param {boolean} required - HTML required indicator.
704
+ */
705
+ interface Props {
706
+ id: string;
707
+ placeholder?: string;
708
+ name?: string;
709
+ required?: boolean;
710
+ }
711
+
712
+ const { id, placeholder = "Password", name, required = false } = Astro.props;
713
+ ---
714
+
715
+ <div class="pass-wrap">
716
+ <input type="password" {id} {name} {placeholder} {required} class="pass-input" data-pwd-input />
717
+ <button type="button" class="toggle-pass" aria-label="Toggle password visibility" data-pwd-toggle>👁️</button>
718
+ </div>
719
+
720
+ <style>
721
+ .pass-wrap { position: relative; width: 100%; }
722
+ .pass-input {
723
+ width: 100%; box-sizing: border-box;
724
+ padding: 0.8rem 3rem 0.8rem 1rem;
725
+ background: var(--c-bg-elev, rgba(0,0,0,0.2));
726
+ border: 1px solid var(--c-border, rgba(255,255,255,0.1));
727
+ border-radius: var(--r-sm, 10px);
728
+ color: var(--c-text-1, #fff); outline: none;
729
+ font-family: inherit; transition: 0.2s;
730
+ }
731
+ .pass-input:focus { border-color: var(--c-primary, #6366f1); }
732
+ .toggle-pass {
733
+ position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
734
+ background: none; border: none; cursor: pointer; opacity: 0.5; transition: 0.2s;
735
+ }
736
+ .toggle-pass:hover { opacity: 1; }
737
+ </style>
738
+
739
+ <script>
740
+ // Setup functional toggle without polluting global namespaces or clashing IDs
741
+ document.querySelectorAll('.pass-wrap').forEach(wrap => {
742
+ const btn = wrap.querySelector('[data-pwd-toggle]');
743
+ const pass = wrap.querySelector('[data-pwd-input]') as HTMLInputElement;
744
+
745
+ if (btn && pass) {
746
+ btn.addEventListener('click', () => {
747
+ const isPass = pass.type === 'password';
748
+ pass.type = isPass ? 'text' : 'password';
749
+ btn.textContent = isPass ? '🙈' : '👁️';
750
+ });
751
+ }
752
+ });
753
+ </script>
754
+ `,
755
+ usage: `<FloatingPassword id="pass" placeholder="Enter Secure Password" />`,
756
+ },
757
+ {
758
+ slug: 'form-group-vertical',
759
+ name: 'Form Group',
760
+ description: 'Wrapper for labeling and help text.',
761
+ category: 'forms',
762
+ tags: ["layout","group"],
763
+ featured: true,
764
+ code: `---
765
+ /**
766
+ * FormGroupVertical — A structural wrapper component for grouping an input with its label and validation messaging.
767
+ *
768
+ * @param {string} label - The descriptive title for the field.
769
+ * @param {string} forId - The target ID of the child input (for label association).
770
+ * @param {string} helpText - Optional secondary guidance text below the input.
771
+ * @param {string} error - Optional error message that applies error styling.
772
+ * @param {boolean} required - Displays a required indicator if true.
773
+ */
774
+ interface Props {
775
+ label: string;
776
+ forId: string;
777
+ helpText?: string;
778
+ error?: string;
779
+ required?: boolean;
780
+ }
781
+
782
+ const { label, forId, helpText, error, required = false } = Astro.props;
783
+ ---
784
+
785
+ <div class:list={["form-group", { "form-group--error": error }]}>
786
+ <div class="form-group__header">
787
+ <label for={forId} class="form-group__label">
788
+ {label},
789
+ {required && <span class="form-group__req" aria-hidden="true">*</span>}
790
+ </label>
791
+ </div>
792
+
793
+ <div class="form-group__content">
794
+ <slot />
795
+ </div>
796
+
797
+ {error ? (
798
+ <p class="form-group__error" id={\`\${forId}-error\`} role="alert">{error}</p>
799
+ ) : helpText ? (
800
+ <p class="form-group__help" id={\`\${forId}-help\`}>{helpText}</p>
801
+ ) : null}
802
+ </div>
803
+
804
+ <style>
805
+ .form-group { display: flex; flex-direction: column; gap: var(--sp-2, 0.5rem); width: 100%; margin-bottom: var(--sp-4, 1rem); }
806
+
807
+ .form-group__label {
808
+ font-size: 0.8rem; font-weight: 700;
809
+ color: var(--c-text-1, #e2e8f0);
810
+ text-transform: uppercase;
811
+ letter-spacing: 0.05em;
812
+ margin-left: 0.5rem;
813
+ cursor: pointer;
814
+ }
815
+
816
+ .form-group__req { color: var(--c-error, #f87171); margin-left: 2px; }
817
+
818
+ .form-group__content { width: 100%; }
819
+
820
+ .form-group__help { font-size: 0.75rem; color: var(--c-text-2, #64748b); margin: 0 0 0 0.5rem; }
821
+
822
+ .form-group__error { font-size: 0.75rem; color: var(--c-error, #f87171); font-weight: 600; margin: 0 0 0 0.5rem; }
823
+
824
+ /* Deep selector to style any input nested inside the group content if it's in an error state */
825
+ .form-group--error :global(input),
826
+ .form-group--error :global(textarea),
827
+ .form-group--error :global(select) {
828
+ border-color: var(--c-error, #ef4444) !important;
829
+ background-color: rgba(239, 68, 68, 0.03) !important;
830
+ }
831
+ </style>
832
+ `,
833
+ usage: `<FormGroupVertical label="Username" forId="u" helpText="Characters only."><input id="u" class="custom-input" /></FormGroupVertical>`,
834
+ },
835
+ {
836
+ slug: 'glass-slider',
837
+ name: 'Glass Slider',
838
+ description: 'Translucent range slider with glow.',
839
+ category: 'forms',
840
+ tags: ["input","range"],
841
+ featured: true,
842
+ code: `---
843
+ /**
844
+ * GlassSlider — A translucent range slider with a glowing thumb and backdrop-blur track.
845
+ *
846
+ * @param {string} label - Optional descriptive text title.
847
+ * @param {string} id - HTML ID for the range input.
848
+ * @param {string} name - HTML name binding.
849
+ * @param {number} min - Minimum value. Default is 0.
850
+ * @param {number} max - Maximum value. Default is 100.
851
+ * @param {number} value - Initial starting value. Default is 50.
852
+ * @param {number} step - Step increment. Default is 1.
853
+ */
854
+ interface Props {
855
+ label?: string;
856
+ id: string;
857
+ name?: string;
858
+ min?: number;
859
+ max?: number;
860
+ value?: number;
861
+ step?: number;
862
+ }
863
+
864
+ const { label, id, name, min = 0, max = 100, value = 50, step = 1 } = Astro.props;
865
+ ---
866
+
867
+ <div class="slider-container">
868
+ {label && <div class="slider-header"><label for={id}>{label}</label> <span data-slider-value>{value}</span></div>}
869
+ <input
870
+ type="range"
871
+ class="glass-slider"
872
+ {id},
873
+ {name}
874
+ {min}
875
+ {max}
876
+ {step}
877
+ {value}
878
+ data-slider-input
879
+ />
880
+ </div>
881
+
882
+ <style>
883
+ .slider-container { width: 100%; display: flex; flex-direction: column; gap: var(--sp-3, 0.75rem); }
884
+ .slider-header { display: flex; justify-content: space-between; font-size: 0.85rem; font-weight: 600; color: var(--c-text-2, #94a3b8); padding: 0 0.5rem; }
885
+
886
+ .glass-slider {
887
+ -webkit-appearance: none;
888
+ width: 100%; height: 8px;
889
+ background: var(--c-bg-elev, rgba(255,255,255,0.1));
890
+ border-radius: var(--r-full, 10px);
891
+ outline: none;
892
+ backdrop-filter: blur(5px);
893
+ border: 1px solid var(--c-border, rgba(255,255,255,0.05));
894
+ cursor: pointer;
895
+ }
896
+
897
+ /* Thumb Styling - Webkit */
898
+ .glass-slider::-webkit-slider-thumb {
899
+ -webkit-appearance: none;
900
+ width: 22px; height: 22px;
901
+ border-radius: 50%;
902
+ background: var(--c-primary, #6366f1);
903
+ cursor: pointer;
904
+ border: 3px solid rgba(255,255,255,0.6);
905
+ box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4);
906
+ transition: transform 0.2s;
907
+ }
908
+
909
+ .glass-slider::-webkit-slider-thumb:hover { transform: scale(1.15); }
910
+
911
+ /* Thumb Styling - Firefox */
912
+ .glass-slider::-moz-range-thumb {
913
+ width: 18px; height: 18px;
914
+ border-radius: 50%;
915
+ background: var(--c-primary, #6366f1);
916
+ cursor: pointer;
917
+ border: 3px solid rgba(255,255,255,0.6);
918
+ box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4);
919
+ }
920
+ </style>
921
+
922
+ <script>
923
+ document.querySelectorAll('[data-slider-input]').forEach(slider => {
924
+ const display = slider.closest('.slider-container')?.querySelector('[data-slider-value]');
925
+ slider.addEventListener('input', (e) => {
926
+ if (display) display.textContent = (e.target as HTMLInputElement).value;
927
+ });
928
+ });
929
+ </script>
930
+ `,
931
+ usage: `<GlassSlider id="volume" label="Speaker Volume" min={0} max={100} value={75} />`,
932
+ },
933
+ {
934
+ slug: 'glass-textarea',
935
+ name: 'Glass Textarea',
936
+ description: 'Glassmorphism styled multiline input.',
937
+ category: 'forms',
938
+ tags: ["textarea","glass"],
939
+ featured: true,
940
+ code: `---
941
+ /**
942
+ * GlassTextarea — A multiline text input with glassmorphism surface styling.
943
+ *
944
+ * @param {string} label - Input grouping label title.
945
+ * @param {string} id - The ID mapping the label to the textbox.
946
+ * @param {number} rows - Starting HTML rows count height. Default is 4.
947
+ * @param {string} placeholder - Displayed placeholder within the box when empty.
948
+ * @param {string} name - Form data extraction name binding.
949
+ * @param {boolean} required - HTML required indicator.
950
+ */
951
+ interface Props {
952
+ label: string;
953
+ id: string;
954
+ rows?: number;
955
+ placeholder?: string;
956
+ name?: string;
957
+ required?: boolean;
958
+ }
959
+
960
+ const { label, id, rows = 4, placeholder = "Start typing...", name, required = false } = Astro.props;
961
+ ---
962
+
963
+ <div class="glass-textarea-wrap">
964
+ <label for={id} class="glass-label">{label}</label>
965
+ <textarea {id} {name} {rows} {placeholder} {required} class="glass-textarea"></textarea>
966
+ </div>
967
+
968
+ <style>
969
+ .glass-textarea-wrap { display: flex; flex-direction: column; gap: 0.5rem; width: 100%; }
970
+ .glass-label { font-size: 0.85rem; font-weight: 600; color: var(--c-text-2, #94a3b8); margin-left: 0.5rem; }
971
+ .glass-textarea {
972
+ width: 100%; padding: var(--sp-4, 1rem);
973
+ background: var(--c-bg-elev, rgba(255, 255, 255, 0.03));
974
+ backdrop-filter: blur(10px);
975
+ border: 1px solid var(--c-border, rgba(255, 255, 255, 0.1));
976
+ border-radius: var(--r-md, 16px);
977
+ color: var(--c-text-1, #fff);
978
+ font-family: inherit; font-size: 0.9rem;
979
+ outline: none; transition: 0.3s; resize: vertical;
980
+ }
981
+ .glass-textarea:focus {
982
+ border-color: rgba(99, 102, 241, 0.4);
983
+ background: rgba(255, 255, 255, 0.06);
984
+ }
985
+ .glass-textarea::placeholder { color: var(--c-text-muted, #475569); }
986
+ </style>
987
+ `,
988
+ usage: `<GlassTextarea id="bio" label="Your Biography" rows={5} placeholder="Tell us about yourself..." />`,
989
+ },
990
+ {
991
+ slug: 'modern-select',
992
+ name: 'Modern Select',
993
+ description: 'Custom styled dropdown selector.',
994
+ category: 'forms',
995
+ tags: ["select","modern"],
996
+ featured: true,
997
+ code: `---
998
+ /**
999
+ * ModernSelect — A styled dropdown selector with a custom arrow and glassmorphism styling.
1000
+ *
1001
+ * @param {string} label - Input labeling title.
1002
+ * @param {string} id - HTML ID for the select element.
1003
+ * @param {string} name - HTML name binding.
1004
+ * @param {Array<{label: string, value: string, disabled?: boolean}>} options - List of select options.
1005
+ * @param {boolean} required - HTML required attribute.
1006
+ */
1007
+ interface Props {
1008
+ label: string;
1009
+ id: string;
1010
+ name?: string;
1011
+ options: Array<{ label: string; value: string; disabled?: boolean }>;
1012
+ required?: boolean;
1013
+ }
1014
+
1015
+ const { label, id, name, options = [], required = false } = Astro.props;
1016
+ ---
1017
+
1018
+ <div class="select-container">
1019
+ <label for={id} class="select-label">{label}</label>
1020
+ <div class="select-wrap">
1021
+ <select class="custom-select" {id} {name} {required}>
1022
+ {options.map(opt => (
1023
+ <option value={opt.value} disabled={opt.disabled}>{opt.label}</option>
1024
+ ))}
1025
+ </select>
1026
+ <div class="select-arrow" aria-hidden="true">
1027
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
1028
+ <path d="M6 9l6 6 6-6"/>
1029
+ </svg>
1030
+ </div>
1031
+ </div>
1032
+ </div>
1033
+
1034
+ <style>
1035
+ .select-container { display: flex; flex-direction: column; gap: var(--sp-2, 0.5rem); width: 100%; }
1036
+ .select-label { font-size: 0.85rem; font-weight: 600; color: var(--c-text-2, #94a3b8); margin-left: 0.5rem; }
1037
+
1038
+ .select-wrap { position: relative; width: 100%; }
1039
+
1040
+ .custom-select {
1041
+ width: 100%;
1042
+ padding: 0.8rem 2.5rem 0.8rem 1rem;
1043
+ background: var(--c-bg-elev, rgba(255,255,255,0.04));
1044
+ border: 1px solid var(--c-border, rgba(255,255,255,0.1));
1045
+ border-radius: var(--r-md, 12px);
1046
+ color: var(--c-text-1, #fff);
1047
+ appearance: none; cursor: pointer; outline: none;
1048
+ font-family: inherit; font-size: 0.95rem;
1049
+ transition: border-color 0.2s, background 0.2s;
1050
+ }
1051
+
1052
+ .custom-select:focus {
1053
+ border-color: var(--c-primary, #6366f1);
1054
+ background: rgba(255,255,255,0.06);
1055
+ }
1056
+
1057
+ .select-arrow {
1058
+ position: absolute; right: 1rem; top: 50%; transform: translateY(-50%);
1059
+ color: var(--c-text-2, #64748b);
1060
+ pointer-events: none; transition: transform 0.2s;
1061
+ display: flex; align-items: center; justify-content: center;
1062
+ }
1063
+
1064
+ .custom-select:focus + .select-arrow {
1065
+ transform: translateY(-50%) rotate(180deg);
1066
+ color: var(--c-primary, #6366f1);
1067
+ }
1068
+ </style>
1069
+ `,
1070
+ usage: `<ModernSelect id="role" label="Project Role" options={[{label: "Developer", value: "dev"}, {label: "Designer", value: "dsign"}]} />`,
1071
+ },
1072
+ {
1073
+ slug: 'neumorphic-switch',
1074
+ name: 'Neumorphic Switch',
1075
+ description: 'Soft-UI toggle switch.',
1076
+ category: 'forms',
1077
+ tags: ["switch","neumorphism"],
1078
+ featured: true,
1079
+ code: `---
1080
+ /**
1081
+ * NeumorphicSwitch — A soft-UI checkbox toggle with realistic inset and outset shadow states.
1082
+ *
1083
+ * @param {string} label - Optional descriptive text suffix.
1084
+ * @param {string} id - HTML ID for input linkage.
1085
+ * @param {string} name - HTML name binding.
1086
+ * @param {boolean} checked - Initial toggle state. Default is false.
1087
+ */
1088
+ interface Props {
1089
+ label?: string;
1090
+ id: string;
1091
+ name?: string;
1092
+ checked?: boolean;
1093
+ }
1094
+
1095
+ const { label, id, name, checked = false } = Astro.props;
1096
+ ---
1097
+
1098
+ <div class="neu-switch-wrapper">
1099
+ <label class="neu-switch">
1100
+ <input type="checkbox" {id} {name} {checked} />
1101
+ <span class="slider"></span>
1102
+ </label>
1103
+ {label && <label for={id} class="neu-label">{label}</label>}
1104
+ </div>
1105
+
1106
+ <style>
1107
+ .neu-switch-wrapper { display: flex; align-items: center; gap: var(--sp-3, 0.75rem); }
1108
+
1109
+ .neu-switch {
1110
+ position: relative;
1111
+ display: inline-block;
1112
+ width: 60px;
1113
+ height: 32px;
1114
+ flex-shrink: 0;
1115
+ }
1116
+
1117
+ .neu-label { font-size: 0.9rem; color: var(--c-text-2, #94a3b8); cursor: pointer; }
1118
+
1119
+ .neu-switch input {
1120
+ opacity: 0;
1121
+ width: 0;
1122
+ height: 0;
1123
+ }
1124
+
1125
+ .slider {
1126
+ position: absolute;
1127
+ cursor: pointer;
1128
+ inset: 0;
1129
+ background: var(--c-bg, #1a1f2e);
1130
+ transition: 0.4s cubic-bezier(0.4, 0, 0.2, 1);
1131
+ border-radius: 34px;
1132
+ border: 1px solid var(--c-border, rgba(255, 255, 255, 0.05));
1133
+ box-shadow:
1134
+ inset 2px 2px 5px rgba(0, 0, 0, 0.5),
1135
+ inset -2px -2px 5px rgba(255, 255, 255, 0.04);
1136
+ }
1137
+
1138
+ .slider:before {
1139
+ position: absolute;
1140
+ content: "";
1141
+ height: 24px;
1142
+ width: 24px;
1143
+ left: 4px;
1144
+ bottom: 3px;
1145
+ background: #374151;
1146
+ transition: 0.4s cubic-bezier(0.18, 0.89, 0.32, 1.28);
1147
+ border-radius: 50%;
1148
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
1149
+ }
1150
+
1151
+ input:checked + .slider {
1152
+ background: rgba(99, 102, 241, 0.1);
1153
+ border-color: rgba(99, 102, 241, 0.2);
1154
+ }
1155
+
1156
+ input:checked + .slider:before {
1157
+ transform: translateX(28px);
1158
+ background: var(--c-primary, #818cf8);
1159
+ box-shadow: 0 0 15px rgba(129, 140, 248, 0.5);
1160
+ }
1161
+ </style>
1162
+ `,
1163
+ usage: `<NeumorphicSwitch id="notify" label="Enable Notifications" checked />`,
1164
+ },
1165
+ {
1166
+ slug: 'otp-input',
1167
+ name: 'OTP Input',
1168
+ description: 'Professional form control.',
1169
+ category: 'forms',
1170
+ tags: ["form"],
1171
+ featured: true,
1172
+ code: `---
1173
+ /**
1174
+ * OTPInput — A specialized input group for One-Time Password verification codes.
1175
+ *
1176
+ * @param {string} label - Optional grouping label title.
1177
+ * @param {string} id - Base HTML ID for the inputs.
1178
+ * @param {number} length - Number of OTP digits. Default is 4.
1179
+ * @param {string} name - Base name for form submission (will be indexed).
1180
+ */
1181
+ interface Props {
1182
+ label?: string;
1183
+ id: string;
1184
+ length?: number;
1185
+ name?: string;
1186
+ }
1187
+
1188
+ const { label, id, length = 4, name = "otp" } = Astro.props;
1189
+ ---
1190
+
1191
+ <div class="otp-container">
1192
+ {label && <label class="otp-label">{label}</label>}
1193
+ <div class="otp-wrap" data-otp-group>
1194
+ {Array.from({ length }).map((_, i) => (
1195
+ <input
1196
+ type="text"
1197
+ inputmode="numeric"
1198
+ maxlength="1"
1199
+ pattern="[0-9]*"
1200
+ autocomplete="one-time-code"
1201
+ id={\`\${id}-\${i}\`}
1202
+ name={\`\${name}[]\`}
1203
+ class="otp-field"
1204
+ data-otp-index={i}
1205
+ />
1206
+ ))}
1207
+ </div>
1208
+ </div>
1209
+
1210
+ <style>
1211
+ .otp-container { display: flex; flex-direction: column; align-items: center; gap: var(--sp-4, 1rem); }
1212
+ .otp-label { font-size: 0.9rem; font-weight: 600; color: var(--c-text-2, #94a3b8); }
1213
+
1214
+ .otp-wrap { display: flex; gap: 0.75rem; justify-content: center; }
1215
+
1216
+ .otp-field {
1217
+ width: 50px; height: 60px;
1218
+ background: var(--c-bg-elev, rgba(255,255,255,0.05));
1219
+ border: 1px solid var(--c-border, rgba(255,255,255,0.1));
1220
+ border-radius: var(--r-md, 12px);
1221
+ text-align: center; color: var(--c-text-1, #fff);
1222
+ font-size: 1.75rem; font-weight: 800;
1223
+ outline: none; transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
1224
+ }
1225
+
1226
+ .otp-field:focus {
1227
+ border-color: var(--c-primary, #6366f1);
1228
+ background: rgba(99, 102, 241, 0.1);
1229
+ box-shadow: 0 0 15px rgba(99, 102, 241, 0.3), 0 0 0 3px rgba(99, 102, 241, 0.15);
1230
+ transform: translateY(-2px);
1231
+ }
1232
+ </style>
1233
+
1234
+ <script>
1235
+ document.querySelectorAll('[data-otp-group]').forEach(group => {
1236
+ const inputs = group.querySelectorAll('input') as NodeListOf<HTMLInputElement>;
1237
+
1238
+ inputs.forEach((input, idx) => {
1239
+ // Auto-advance
1240
+ input.addEventListener('input', (e) => {
1241
+ const val = (e.target as HTMLInputElement).value;
1242
+ if (val && idx < inputs.length - 1) {
1243
+ inputs[idx + 1].focus();
1244
+ }
1245
+ });
1246
+
1247
+ // Backspace logic
1248
+ input.addEventListener('keydown', (e) => {
1249
+ if (e.key === 'Backspace' && !input.value && idx > 0) {
1250
+ inputs[idx - 1].focus();
1251
+ }
1252
+ });
1253
+
1254
+ // Paste logic
1255
+ input.addEventListener('paste', (e) => {
1256
+ e.preventDefault();
1257
+ const data = e.clipboardData?.getData('text').slice(0, inputs.length).split('') || [];
1258
+ data.forEach((val, i) => {
1259
+ if (inputs[i]) inputs[i].value = val;
1260
+ });
1261
+ if (inputs[data.length - 1]) inputs[data.length - 1].focus();
1262
+ });
1263
+ });
1264
+ });
1265
+ </script>
1266
+ `,
1267
+ usage: `<OTPInput />`,
1268
+ },
1269
+ {
1270
+ slug: 'rating-stars',
1271
+ name: 'Star Rating',
1272
+ description: 'Classic star-based feedback input.',
1273
+ category: 'forms',
1274
+ tags: ["input","rating"],
1275
+ featured: true,
1276
+ code: `---
1277
+ /**
1278
+ * RatingStars — A classic star-rating input for user reviews and feedback.
1279
+ *
1280
+ * @param {string} name - HTML name for form submission. Default is "rating".
1281
+ * @param {number} maxRating - Maximum number of stars. Default is 5.
1282
+ * @param {string} idPrefix - Prefix used to generate unique IDs for radio buttons.
1283
+ * @param {number} initialValue - Currently selected rating.
1284
+ */
1285
+ interface Props {
1286
+ name?: string;
1287
+ maxRating?: number;
1288
+ idPrefix?: string;
1289
+ initialValue?: number;
1290
+ }
1291
+
1292
+ const { name = "rating", maxRating = 5, idPrefix = "star", initialValue = 0 } = Astro.props;
1293
+ ---
1294
+
1295
+ <div class="rating-stars" data-rating-group>
1296
+ {Array.from({ length: maxRating }).map((_, i) => {
1297
+ const val = maxRating - i;
1298
+ return (
1299
+ <>
1300
+ <input
1301
+ type="radio"
1302
+ name={name}
1303
+ id={\`\${idPrefix}-\${val}\`}
1304
+ value={val}
1305
+ checked={initialValue === val}
1306
+ class="rating-stars__input"
1307
+ />
1308
+ <label for={\`\${idPrefix}-\${val}\`} class="rating-stars__star" aria-label={\`\${val} stars\`}>
1309
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1310
+ <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
1311
+ </svg>
1312
+ </label>
1313
+ </>
1314
+ );
1315
+ })}
1316
+ </div>
1317
+
1318
+ <style>
1319
+ .rating-stars { display: flex; flex-direction: row-reverse; gap: 4px; width: fit-content; }
1320
+
1321
+ .rating-stars__input { display: none; }
1322
+
1323
+ .rating-stars__star {
1324
+ width: 32px; height: 32px;
1325
+ cursor: pointer; transition: all 0.2s ease;
1326
+ color: var(--c-border, #334155);
1327
+ }
1328
+
1329
+ .rating-stars__star svg { width: 100%; height: 100%; fill: transparent; transition: all 0.2s; }
1330
+
1331
+ /* Hover and check logic using sibling selectors (requires row-reverse) */
1332
+ .rating-stars__star:hover,
1333
+ .rating-stars__star:hover ~ .rating-stars__star,
1334
+ .rating-stars__input:checked ~ .rating-stars__star {
1335
+ color: #fbbf24;
1336
+ }
1337
+
1338
+ .rating-stars__star:hover svg,
1339
+ .rating-stars__star:hover ~ .rating-stars__star svg,
1340
+ .rating-stars__input:checked ~ .rating-stars__star svg {
1341
+ fill: #fbbf24;
1342
+ filter: drop-shadow(0 0 5px rgba(251, 191, 36, 0.4));
1343
+ }
1344
+
1345
+ .rating-stars__star:active { transform: scale(0.9); }
1346
+ </style>
1347
+ `,
1348
+ usage: `<RatingStars name="quality" initialValue={4} />`,
1349
+ },
1350
+ {
1351
+ slug: 'search-reveal',
1352
+ name: 'Search Reveal',
1353
+ description: 'Minimal search that expands on hover.',
1354
+ category: 'forms',
1355
+ tags: ["input","search"],
1356
+ featured: true,
1357
+ code: `---
1358
+ /**
1359
+ * SearchReveal — A dynamic minimal search pod that expands smoothly into a full input field on hover or focus.
1360
+ *
1361
+ * @param {string} id - Standard HTML ID property.
1362
+ * @param {string} placeholder - Default placeholder ("Search...").
1363
+ * @param {string} name - Standard HTML name grouping.
1364
+ */
1365
+ interface Props {
1366
+ id: string;
1367
+ placeholder?: string;
1368
+ name?: string;
1369
+ }
1370
+
1371
+ const { id, placeholder = "Search...", name } = Astro.props;
1372
+ ---
1373
+
1374
+ <div class="search-box">
1375
+ <input type="search" {id} {name} {placeholder} class="search-input" aria-label="Search content" />
1376
+ <button type="button" aria-label="Submit search" class="search-btn">
1377
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1378
+ <circle cx="11" cy="11" r="8"></circle>
1379
+ <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
1380
+ </svg>
1381
+ </button>
1382
+ </div>
1383
+
1384
+ <style>
1385
+ .search-box { position: relative; width: 44px; height: 44px; transition: 0.4s cubic-bezier(0.4, 0, 0.2, 1); }
1386
+ .search-box:hover, .search-box:focus-within { width: 250px; }
1387
+ .search-input {
1388
+ width: 100%; height: 100%; box-sizing: border-box;
1389
+ background: var(--c-bg-elev, #1e293b);
1390
+ border: 1px solid var(--c-border, rgba(255,255,255,0.1));
1391
+ border-radius: var(--r-full, 100px);
1392
+ padding: 0 45px 0 20px;
1393
+ color: var(--c-text-1, #fff); outline: none; opacity: 0; transition: 0.3s;
1394
+ }
1395
+ .search-box:hover .search-input, .search-box:focus-within .search-input { opacity: 1; }
1396
+ .search-input::placeholder { color: var(--c-text-muted, #64748b); }
1397
+
1398
+ .search-btn {
1399
+ position: absolute; right: 0; top: 0;
1400
+ width: 44px; height: 44px;
1401
+ background: var(--c-primary, #6366f1); border: none;
1402
+ border-radius: 50%; color: #fff; cursor: pointer;
1403
+ display: flex; align-items: center; justify-content: center;
1404
+ transition: background 0.2s;
1405
+ }
1406
+ .search-btn:hover { background: var(--c-primary-light, #818cf8); }
1407
+ </style>
1408
+ `,
1409
+ usage: `<SearchReveal id="site-search" placeholder="Search components..." />`,
1410
+ },
1411
+ {
1412
+ slug: 'segmented-control',
1413
+ name: 'Segmented Control',
1414
+ description: 'Radio-based mode switcher.',
1415
+ category: 'forms',
1416
+ tags: ["segmented","switch"],
1417
+ featured: true,
1418
+ code: `---
1419
+ /**
1420
+ * SegmentedControl — A horizontal group of button-like radio choices for switching views or modes.
1421
+ *
1422
+ * @param {string} name - Shared HTML name for the internal radio inputs.
1423
+ * @param {Array<{label: string, value: string, checked?: boolean}>} options - Selectable segments.
1424
+ * @param {string} idPrefix - Prefix for auto-generating input IDs.
1425
+ */
1426
+ interface Props {
1427
+ name: string;
1428
+ options: Array<{ label: string; value: string; checked?: boolean }>;
1429
+ idPrefix?: string;
1430
+ }
1431
+
1432
+ const { name, options = [], idPrefix = "seg" } = Astro.props;
1433
+ ---
1434
+
1435
+ <div class="segmented">
1436
+ {options.map((opt, i) => (
1437
+ <div class="segmented-item">
1438
+ <input
1439
+ type="radio"
1440
+ name={name}
1441
+ id={\`\${idPrefix}-\${i}\`}
1442
+ value={opt.value}
1443
+ checked={opt.checked}
1444
+ class="segmented-input"
1445
+ />
1446
+ <label for={\`\${idPrefix}-\${i}\`} class="segmented-label">{opt.label}</label>
1447
+ </div>
1448
+ ))}
1449
+ </div>
1450
+
1451
+ <style>
1452
+ .segmented {
1453
+ display: flex; position: relative;
1454
+ background: var(--c-bg-elev, rgba(255,255,255,0.05));
1455
+ padding: 4px; border-radius: var(--r-md, 12px);
1456
+ width: fit-content; border: 1px solid var(--c-border, rgba(255,255,255,0.05));
1457
+ box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
1458
+ }
1459
+
1460
+ .segmented-item { display: flex; }
1461
+
1462
+ .segmented-label {
1463
+ padding: 0.5rem 1.4rem; cursor: pointer; z-index: 1;
1464
+ font-size: 0.85rem; transition: all 0.25s ease;
1465
+ color: var(--c-text-2, #94a3b8);
1466
+ border-radius: var(--r-sm, 8px);
1467
+ font-weight: 500;
1468
+ user-select: none;
1469
+ }
1470
+
1471
+ .segmented-input { display: none; }
1472
+
1473
+ .segmented-input:checked + .segmented-label {
1474
+ color: var(--c-text-1, #fff);
1475
+ font-weight: 700;
1476
+ background: var(--c-primary, #6366f1);
1477
+ box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
1478
+ }
1479
+
1480
+ .segmented-label:hover:not(.segmented-input:checked + .segmented-label) {
1481
+ color: var(--c-text-1);
1482
+ background: rgba(255,255,255,0.03);
1483
+ }
1484
+ </style>
1485
+ `,
1486
+ usage: `<SegmentedControl name="view" options={[{label: "Day", value: "d", checked: true}, {label: "Week", value: "w"}]} />`,
1487
+ },
1488
+ {
1489
+ slug: 'simple-file-input',
1490
+ name: 'Simple File Input',
1491
+ description: 'Minimalist file upload button.',
1492
+ category: 'forms',
1493
+ tags: ["input","file"],
1494
+ featured: true,
1495
+ code: `---
1496
+ /**
1497
+ * SimpleFileInput — A minimalist file upload button that masks the native browser input.
1498
+ *
1499
+ * @param {string} label - The text displayed on the button. Default is "Choose File".
1500
+ * @param {string} id - HTML ID for the file input.
1501
+ * @param {string} name - HTML name binding.
1502
+ * @param {string} accept - Allowed file formats.
1503
+ */
1504
+ interface Props {
1505
+ label?: string;
1506
+ id: string;
1507
+ name?: string;
1508
+ accept?: string;
1509
+ }
1510
+
1511
+ const { label = "Choose File", id, name, accept } = Astro.props;
1512
+ ---
1513
+
1514
+ <label class="file-btn" for={id}>
1515
+ <input type="file" {id} {name} {accept} class="file-btn__input" />
1516
+ <span class="file-btn__label">{label}</span>
1517
+ </label>
1518
+
1519
+ <style>
1520
+ .file-btn {
1521
+ position: relative;
1522
+ overflow: hidden;
1523
+ display: inline-block;
1524
+ cursor: pointer;
1525
+ }
1526
+
1527
+ .file-btn__input {
1528
+ position: absolute;
1529
+ left: 0; top: 0;
1530
+ width: 100%; height: 100%;
1531
+ opacity: 0; cursor: pointer;
1532
+ font-size: 0;
1533
+ }
1534
+
1535
+ .file-btn__label {
1536
+ background: var(--c-primary, #6366f1);
1537
+ color: #fff;
1538
+ padding: 0.8rem 1.6rem;
1539
+ border-radius: var(--r-md, 10px);
1540
+ font-weight: 700;
1541
+ display: inline-block;
1542
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
1543
+ box-shadow: 0 4px 15px rgba(99, 102, 241, 0.2);
1544
+ font-size: 0.95rem;
1545
+ }
1546
+
1547
+ .file-btn:hover .file-btn__label {
1548
+ filter: brightness(1.1);
1549
+ transform: translateY(-2px);
1550
+ box-shadow: 0 6px 20px rgba(99, 102, 241, 0.3);
1551
+ }
1552
+
1553
+ .file-btn:active .file-btn__label {
1554
+ transform: translateY(0);
1555
+ }
1556
+ </style>
1557
+ `,
1558
+ usage: `<SimpleFileInput id="avatar" label="Upload Profile Picture" accept="image/*" />`,
1559
+ },
1560
+ {
1561
+ slug: 'stepper-input',
1562
+ name: 'Numeric Stepper',
1563
+ description: 'Input with +/- increment buttons.',
1564
+ category: 'forms',
1565
+ tags: ["input","stepper"],
1566
+ featured: true,
1567
+ code: `---
1568
+ /**
1569
+ * StepperInput — A numeric input with increment and decrement buttons for precise control.
1570
+ *
1571
+ * @param {string} label - Optional input label.
1572
+ * @param {string} id - HTML ID for the number input.
1573
+ * @param {string} name - HTML name binding.
1574
+ * @param {number} value - Initial default value. Default is 1.
1575
+ * @param {number} min - Minimum allowed value.
1576
+ * @param {number} max - Maximum allowed value.
1577
+ * @param {number} step - Numeric increment step. Default is 1.
1578
+ */
1579
+ interface Props {
1580
+ label?: string;
1581
+ id: string;
1582
+ name?: string;
1583
+ value?: number;
1584
+ min?: number;
1585
+ max?: number;
1586
+ step?: number;
1587
+ }
1588
+
1589
+ const { label, id, name, value = 1, min, max, step = 1 } = Astro.props;
1590
+ ---
1591
+
1592
+ <div class="stepper-container">
1593
+ {label && <label class="stepper-label" for={id}>{label}</label>}
1594
+ <div class="stepper" data-stepper>
1595
+ <button type="button" class="stepper-btn stepper-btn--minus" data-step-minus aria-label="Decrease value">−</button>
1596
+ <input
1597
+ type="number"
1598
+ {id},
1599
+ {name}
1600
+ {min}
1601
+ {max}
1602
+ {step}
1603
+ value={value}
1604
+ class="stepper-input"
1605
+ data-step-input
1606
+ />
1607
+ <button type="button" class="stepper-btn stepper-btn--plus" data-step-plus aria-label="Increase value">+</button>
1608
+ </div>
1609
+ </div>
1610
+
1611
+ <style>
1612
+ .stepper-container { display: flex; flex-direction: column; gap: var(--sp-2, 0.5rem); }
1613
+ .stepper-label { font-size: 0.85rem; font-weight: 600; color: var(--c-text-2, #94a3b8); margin-left: 0.5rem; }
1614
+
1615
+ .stepper {
1616
+ display: flex; align-items: center;
1617
+ background: var(--c-bg-elev, rgba(255,255,255,0.05));
1618
+ border-radius: var(--r-md, 12px);
1619
+ border: 1px solid var(--c-border, rgba(255,255,255,0.1));
1620
+ overflow: hidden;
1621
+ width: fit-content;
1622
+ }
1623
+
1624
+ .stepper-btn {
1625
+ width: 40px; height: 40px;
1626
+ background: var(--c-primary, #6366f1);
1627
+ border: none; color: #fff;
1628
+ font-size: 1.25rem; font-weight: 600;
1629
+ cursor: pointer; transition: all 0.2s;
1630
+ display: grid; place-items: center;
1631
+ }
1632
+
1633
+ .stepper-btn:hover { background: var(--c-primary-light, #818cf8); }
1634
+ .stepper-btn:active { transform: scale(0.95); }
1635
+
1636
+ .stepper-input {
1637
+ width: 60px; height: 40px;
1638
+ background: transparent;
1639
+ border: none; text-align: center;
1640
+ color: var(--c-text-1, #fff);
1641
+ font-size: 1rem; font-weight: 700;
1642
+ outline: none; -moz-appearance: textfield;
1643
+ font-family: inherit;
1644
+ }
1645
+
1646
+ .stepper-input::-webkit-inner-spin-button,
1647
+ .stepper-input::-webkit-outer-spin-button { display: none; }
1648
+ </style>
1649
+
1650
+ <script>
1651
+ document.querySelectorAll('[data-stepper]').forEach(wrapper => {
1652
+ const input = wrapper.querySelector('[data-step-input]') as HTMLInputElement;
1653
+ const minus = wrapper.querySelector('[data-step-minus]');
1654
+ const plus = wrapper.querySelector('[data-step-plus]');
1655
+
1656
+ if (!input || !minus || !plus) return;
1657
+
1658
+ const step = parseFloat(input.step) || 1;
1659
+ const min = input.min !== "" ? parseFloat(input.min) : -Infinity;
1660
+ const max = input.max !== "" ? parseFloat(input.max) : Infinity;
1661
+
1662
+ minus.addEventListener('click', () => {
1663
+ const current = parseFloat(input.value) || 0;
1664
+ if (current - step >= min) input.value = (current - step).toString();
1665
+ });
1666
+
1667
+ plus.addEventListener('click', () => {
1668
+ const current = parseFloat(input.value) || 0;
1669
+ if (current + step <= max) input.value = (current + step).toString();
1670
+ });
1671
+ });
1672
+ </script>
1673
+ `,
1674
+ usage: `<StepperInput id="qty" label="Quantity" value={1} min={1} max={10} />`,
1675
+ },
1676
+ {
1677
+ slug: 'password-strength-input',
1678
+ name: 'Password Strength Input',
1679
+ description: 'Interactive password input with dynamic real-time entropy calculation, strength meter bar, and visibility toggle.',
1680
+ category: 'forms',
1681
+ tags: ["form","input","password","security","validation"],
1682
+ featured: true,
1683
+ code: `---
1684
+ /**
1685
+ * PasswordStrengthInput — Secure password input field with visual strength estimator bar and eye toggle.
1686
+ *
1687
+ * @param {string} id - HTML ID for the password input.
1688
+ * @param {string} label - Input label text. Default is 'Password'.
1689
+ * @param {string} name - Form field name. Default is 'password'.
1690
+ * @param {string} placeholder - Placeholder text. Default is 'Enter secure password'.
1691
+ * @param {boolean} required - HTML required field attribute.
1692
+ */
1693
+ interface Props {
1694
+ id: string;
1695
+ label?: string;
1696
+ name?: string;
1697
+ placeholder?: string;
1698
+ required?: boolean;
1699
+ }
1700
+
1701
+ const {
1702
+ id,
1703
+ label = 'Password',
1704
+ name = 'password',
1705
+ placeholder = 'Enter secure password',
1706
+ required = false,
1707
+ } = Astro.props;
1708
+ ---
1709
+
1710
+ <div class="pwd-group" data-pwd-group>
1711
+ <div class="pwd-group__header">
1712
+ <label for={id} class="pwd-group__label">{label}</label>
1713
+ <span class="pwd-group__status" data-pwd-status>Too weak</span>
1714
+ </div>
1715
+
1716
+ <div class="pwd-group__field">
1717
+ <input
1718
+ type="password"
1719
+ id={id}
1720
+ name={name}
1721
+ placeholder={placeholder}
1722
+ required={required}
1723
+ class="pwd-group__input"
1724
+ data-pwd-input
1725
+ autocomplete="new-password"
1726
+ />
1727
+ <button
1728
+ type="button"
1729
+ class="pwd-group__toggle"
1730
+ aria-label="Toggle password visibility"
1731
+ data-pwd-toggle
1732
+ >
1733
+ <svg class="eye-open" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1734
+ <path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
1735
+ <circle cx="12" cy="12" r="3" />
1736
+ </svg>
1737
+ <svg class="eye-closed" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none;">
1738
+ <path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
1739
+ <path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" />
1740
+ <path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" />
1741
+ <line x1="2" x2="22" y1="2" y2="22" />
1742
+ </svg>
1743
+ </button>
1744
+ </div>
1745
+
1746
+ <!-- 4-segment strength meter -->
1747
+ <div class="pwd-group__meter" aria-hidden="true">
1748
+ <div class="pwd-group__segment" data-seg="1"></div>
1749
+ <div class="pwd-group__segment" data-seg="2"></div>
1750
+ <div class="pwd-group__segment" data-seg="3"></div>
1751
+ <div class="pwd-group__segment" data-seg="4"></div>
1752
+ </div>
1753
+ </div>
1754
+
1755
+ <style>
1756
+ .pwd-group {
1757
+ display: flex;
1758
+ flex-direction: column;
1759
+ gap: var(--sp-2, 0.5rem);
1760
+ width: 100%;
1761
+ max-width: 360px;
1762
+ font-family: var(--font-sans, system-ui, sans-serif);
1763
+ }
1764
+
1765
+ .pwd-group__header {
1766
+ display: flex;
1767
+ justify-content: space-between;
1768
+ align-items: center;
1769
+ padding: 0 0.2rem;
1770
+ }
1771
+
1772
+ .pwd-group__label {
1773
+ font-size: 0.85rem;
1774
+ font-weight: 600;
1775
+ color: var(--c-text-1, #ffffff);
1776
+ }
1777
+
1778
+ .pwd-group__status {
1779
+ font-size: 0.72rem;
1780
+ font-weight: 700;
1781
+ text-transform: uppercase;
1782
+ letter-spacing: 0.05em;
1783
+ color: #ef4444;
1784
+ transition: color 0.3s ease;
1785
+ }
1786
+
1787
+ .pwd-group__field {
1788
+ position: relative;
1789
+ width: 100%;
1790
+ }
1791
+
1792
+ .pwd-group__input {
1793
+ width: 100%;
1794
+ padding: 0.75rem 2.8rem 0.75rem 1rem;
1795
+ background: rgba(255, 255, 255, 0.03);
1796
+ backdrop-filter: blur(12px);
1797
+ border: 1px solid rgba(255, 255, 255, 0.1);
1798
+ border-radius: var(--r-md, 12px);
1799
+ color: var(--c-text-1, #ffffff);
1800
+ font-size: 0.92rem;
1801
+ outline: none;
1802
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
1803
+ }
1804
+
1805
+ .pwd-group__input:focus {
1806
+ border-color: rgba(184, 134, 11, 0.5);
1807
+ box-shadow: 0 0 0 3px rgba(184, 134, 11, 0.15);
1808
+ }
1809
+
1810
+ .pwd-group__input::placeholder {
1811
+ color: rgba(255, 255, 255, 0.3);
1812
+ }
1813
+
1814
+ .pwd-group__toggle {
1815
+ position: absolute;
1816
+ right: 0.75rem;
1817
+ top: 50%;
1818
+ transform: translateY(-50%);
1819
+ background: transparent;
1820
+ border: none;
1821
+ color: var(--c-text-3, #737373);
1822
+ cursor: pointer;
1823
+ padding: 0.3rem;
1824
+ display: flex;
1825
+ align-items: center;
1826
+ justify-content: center;
1827
+ border-radius: var(--r-sm, 6px);
1828
+ transition: color 0.2s ease;
1829
+ }
1830
+
1831
+ .pwd-group__toggle:hover {
1832
+ color: var(--c-text-1, #ffffff);
1833
+ }
1834
+
1835
+ .pwd-group__meter {
1836
+ display: grid;
1837
+ grid-template-columns: repeat(4, 1fr);
1838
+ gap: 6px;
1839
+ height: 4px;
1840
+ margin-top: 4px;
1841
+ }
1842
+
1843
+ .pwd-group__segment {
1844
+ height: 100%;
1845
+ border-radius: 4px;
1846
+ background: rgba(255, 255, 255, 0.08);
1847
+ transition: background 0.3s cubic-bezier(0.16, 1, 0.3, 1);
1848
+ }
1849
+
1850
+ /* Dynamic levels */
1851
+ .pwd-group[data-level="1"] .pwd-group__segment[data-seg="1"] {
1852
+ background: #ef4444;
1853
+ }
1854
+ .pwd-group[data-level="1"] .pwd-group__status {
1855
+ color: #ef4444;
1856
+ }
1857
+
1858
+ .pwd-group[data-level="2"] .pwd-group__segment[data-seg="1"],
1859
+ .pwd-group[data-level="2"] .pwd-group__segment[data-seg="2"] {
1860
+ background: #f59e0b;
1861
+ }
1862
+ .pwd-group[data-level="2"] .pwd-group__status {
1863
+ color: #f59e0b;
1864
+ }
1865
+
1866
+ .pwd-group[data-level="3"] .pwd-group__segment[data-seg="1"],
1867
+ .pwd-group[data-level="3"] .pwd-group__segment[data-seg="2"],
1868
+ .pwd-group[data-level="3"] .pwd-group__segment[data-seg="3"] {
1869
+ background: #3b82f6;
1870
+ }
1871
+ .pwd-group[data-level="3"] .pwd-group__status {
1872
+ color: #3b82f6;
1873
+ }
1874
+
1875
+ .pwd-group[data-level="4"] .pwd-group__segment {
1876
+ background: #10b981;
1877
+ box-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
1878
+ }
1879
+ .pwd-group[data-level="4"] .pwd-group__status {
1880
+ color: #10b981;
1881
+ }
1882
+ </style>
1883
+
1884
+ <script>
1885
+ function initPasswordGroups() {
1886
+ document.querySelectorAll('[data-pwd-group]').forEach((group) => {
1887
+ const input = group.querySelector('[data-pwd-input]') as HTMLInputElement;
1888
+ const toggle = group.querySelector('[data-pwd-toggle]') as HTMLButtonElement;
1889
+ const status = group.querySelector('[data-pwd-status]') as HTMLElement;
1890
+ if (!input || !toggle) return;
1891
+
1892
+ const eyeOpen = toggle.querySelector('.eye-open') as HTMLElement;
1893
+ const eyeClosed = toggle.querySelector('.eye-closed') as HTMLElement;
1894
+
1895
+ toggle.addEventListener('click', () => {
1896
+ const isPassword = input.type === 'password';
1897
+ input.type = isPassword ? 'text' : 'password';
1898
+ if (eyeOpen && eyeClosed) {
1899
+ eyeOpen.style.display = isPassword ? 'none' : 'block';
1900
+ eyeClosed.style.display = isPassword ? 'block' : 'none';
1901
+ }
1902
+ });
1903
+
1904
+ input.addEventListener('input', () => {
1905
+ const val = input.value;
1906
+ let score = 0;
1907
+ if (val.length >= 6) score++;
1908
+ if (val.length >= 10) score++;
1909
+ if (/[A-Z]/.test(val) && /[0-9]/.test(val)) score++;
1910
+ if (/[^A-Za-z0-9]/.test(val)) score++;
1911
+
1912
+ group.setAttribute('data-level', String(score));
1913
+
1914
+ const labels = ['Too weak', 'Weak', 'Fair', 'Strong', 'Secure & Robust'];
1915
+ if (status) {
1916
+ status.textContent = labels[score] || 'Too weak';
1917
+ }
1918
+ });
1919
+ });
1920
+ }
1921
+
1922
+ initPasswordGroups();
1923
+ document.addEventListener('astro:page-load', initPasswordGroups);
1924
+ </script>
1925
+ `,
1926
+ usage: `<PasswordStrengthInput id="user-password" label="Create Password" minLength={8} />`,
1927
+ },
1928
+ {
1929
+ slug: 'tags-autocomplete-input',
1930
+ name: 'Tags Autocomplete Input',
1931
+ description: 'Multi-tag pill input component with keyboard tag addition, pill removal, and suggestion dropdown.',
1932
+ category: 'forms',
1933
+ tags: ["form","tags","input","autocomplete","pills"],
1934
+ featured: true,
1935
+ code: `---
1936
+ /**
1937
+ * TagsAutocompleteInput — Tag entry field with pill badges, keyboard deletion, and suggestion dropdown.
1938
+ *
1939
+ * @param {string} id - HTML ID for the tag input.
1940
+ * @param {string} label - Input label. Default is 'Tags'.
1941
+ * @param {string[]} initialTags - Pre-populated list of tags. Default is ['Astro', 'TypeScript'].
1942
+ * @param {string[]} suggestions - Autocomplete candidate options.
1943
+ * @param {string} placeholder - Input placeholder. Default is 'Add tag & press Enter...'.
1944
+ */
1945
+ interface Props {
1946
+ id: string;
1947
+ label?: string;
1948
+ initialTags?: string[];
1949
+ suggestions?: string[];
1950
+ placeholder?: string;
1951
+ }
1952
+
1953
+ const {
1954
+ id,
1955
+ label = 'Tags',
1956
+ initialTags = ['Astro', 'TypeScript'],
1957
+ suggestions = ['React', 'Tailwind', 'Three.js', 'SSR', 'Vite', 'GraphQL'],
1958
+ placeholder = 'Add tag & press Enter...',
1959
+ } = Astro.props;
1960
+ ---
1961
+
1962
+ <div class="tags-field" data-tags-root>
1963
+ <label for={id} class="tags-field__label">{label}</label>
1964
+
1965
+ <div class="tags-field__box" data-tags-box>
1966
+ <div class="tags-field__list" data-tags-list>
1967
+ {initialTags.map((tag) => (
1968
+ <span class="tag-pill" data-tag={tag}>
1969
+ <span class="tag-pill__name">{tag}</span>
1970
+ <button type="button" class="tag-pill__remove" aria-label={\`Remove tag \${tag}\`} data-tag-remove>×</button>
1971
+ </span>
1972
+ ))}
1973
+ </div>
1974
+
1975
+ <input
1976
+ type="text"
1977
+ id={id}
1978
+ placeholder={placeholder}
1979
+ class="tags-field__input"
1980
+ data-tags-input
1981
+ autocomplete="off"
1982
+ />
1983
+ </div>
1984
+
1985
+ <div class="tags-field__suggestions" data-tags-suggestions hidden>
1986
+ {suggestions.map((sug) => (
1987
+ <button type="button" class="sug-item" data-sug-val={sug}>
1988
+ + {sug}
1989
+ </button>
1990
+ ))}
1991
+ </div>
1992
+ </div>
1993
+
1994
+ <style>
1995
+ .tags-field {
1996
+ display: flex;
1997
+ flex-direction: column;
1998
+ gap: var(--sp-2, 0.5rem);
1999
+ width: 100%;
2000
+ max-width: 440px;
2001
+ font-family: var(--font-sans, system-ui, sans-serif);
2002
+ position: relative;
2003
+ }
2004
+
2005
+ .tags-field__label {
2006
+ font-size: 0.85rem;
2007
+ font-weight: 600;
2008
+ color: var(--c-text-1, #ffffff);
2009
+ }
2010
+
2011
+ .tags-field__box {
2012
+ display: flex;
2013
+ flex-wrap: wrap;
2014
+ align-items: center;
2015
+ gap: 6px;
2016
+ padding: 0.5rem 0.75rem;
2017
+ background: rgba(255, 255, 255, 0.03);
2018
+ backdrop-filter: blur(12px);
2019
+ border: 1px solid rgba(255, 255, 255, 0.1);
2020
+ border-radius: var(--r-md, 12px);
2021
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
2022
+ min-height: 44px;
2023
+ }
2024
+
2025
+ .tags-field__box:focus-within {
2026
+ border-color: rgba(184, 134, 11, 0.5);
2027
+ box-shadow: 0 0 0 3px rgba(184, 134, 11, 0.15);
2028
+ }
2029
+
2030
+ .tags-field__list {
2031
+ display: inline-flex;
2032
+ flex-wrap: wrap;
2033
+ gap: 6px;
2034
+ }
2035
+
2036
+ .tag-pill {
2037
+ display: inline-flex;
2038
+ align-items: center;
2039
+ gap: 6px;
2040
+ padding: 0.25rem 0.65rem;
2041
+ background: rgba(184, 134, 11, 0.15);
2042
+ border: 1px solid rgba(184, 134, 11, 0.35);
2043
+ border-radius: var(--r-full, 9999px);
2044
+ font-size: 0.75rem;
2045
+ font-weight: 600;
2046
+ color: var(--c-accent, #fef3c7);
2047
+ user-select: none;
2048
+ animation: pill-pop 0.2s ease-out;
2049
+ }
2050
+
2051
+ @keyframes pill-pop {
2052
+ from { transform: scale(0.8); opacity: 0; }
2053
+ to { transform: scale(1); opacity: 1; }
2054
+ }
2055
+
2056
+ .tag-pill__remove {
2057
+ background: transparent;
2058
+ border: none;
2059
+ color: var(--c-accent, #fef3c7);
2060
+ font-size: 1rem;
2061
+ line-height: 1;
2062
+ cursor: pointer;
2063
+ padding: 0 2px;
2064
+ opacity: 0.7;
2065
+ transition: opacity 0.2s;
2066
+ }
2067
+
2068
+ .tag-pill__remove:hover {
2069
+ opacity: 1;
2070
+ color: #ef4444;
2071
+ }
2072
+
2073
+ .tags-field__input {
2074
+ flex: 1;
2075
+ min-width: 140px;
2076
+ background: transparent;
2077
+ border: none;
2078
+ color: var(--c-text-1, #ffffff);
2079
+ font-size: 0.88rem;
2080
+ outline: none;
2081
+ padding: 0.2rem 0;
2082
+ }
2083
+
2084
+ .tags-field__input::placeholder {
2085
+ color: rgba(255, 255, 255, 0.3);
2086
+ }
2087
+
2088
+ .tags-field__suggestions {
2089
+ display: flex;
2090
+ flex-wrap: wrap;
2091
+ gap: 6px;
2092
+ margin-top: 4px;
2093
+ padding: 0.4rem;
2094
+ background: rgba(12, 12, 16, 0.9);
2095
+ border: 1px solid rgba(255, 255, 255, 0.08);
2096
+ border-radius: var(--r-sm, 8px);
2097
+ }
2098
+
2099
+ .sug-item {
2100
+ background: rgba(255, 255, 255, 0.04);
2101
+ border: 1px solid rgba(255, 255, 255, 0.08);
2102
+ border-radius: var(--r-full, 9999px);
2103
+ color: var(--c-text-2, #a3a3a3);
2104
+ font-size: 0.72rem;
2105
+ font-weight: 600;
2106
+ padding: 0.2rem 0.6rem;
2107
+ cursor: pointer;
2108
+ transition: all 0.2s ease;
2109
+ }
2110
+
2111
+ .sug-item:hover {
2112
+ background: rgba(184, 134, 11, 0.2);
2113
+ border-color: rgba(184, 134, 11, 0.4);
2114
+ color: var(--c-text-1, #ffffff);
2115
+ }
2116
+ </style>
2117
+
2118
+ <script>
2119
+ function setupTagsFields() {
2120
+ document.querySelectorAll('[data-tags-root]').forEach((root) => {
2121
+ const input = root.querySelector('[data-tags-input]') as HTMLInputElement;
2122
+ const list = root.querySelector('[data-tags-list]') as HTMLElement;
2123
+ const suggestions = root.querySelector('[data-tags-suggestions]') as HTMLElement;
2124
+ if (!input || !list) return;
2125
+
2126
+ function addTag(text: string) {
2127
+ const clean = text.trim();
2128
+ if (!clean) return;
2129
+
2130
+ // Check duplicate
2131
+ const exists = Array.from(list.querySelectorAll('.tag-pill__name')).some(
2132
+ (el) => el.textContent?.toLowerCase() === clean.toLowerCase()
2133
+ );
2134
+ if (exists) return;
2135
+
2136
+ const pill = document.createElement('span');
2137
+ pill.className = 'tag-pill';
2138
+ pill.setAttribute('data-tag', clean);
2139
+ pill.innerHTML = \`
2140
+ <span class="tag-pill__name">\${clean}</span>
2141
+ <button type="button" class="tag-pill__remove" aria-label="Remove tag \${clean}" data-tag-remove>×</button>
2142
+ \`;
2143
+ list.appendChild(pill);
2144
+ input.value = '';
2145
+ }
2146
+
2147
+ list.addEventListener('click', (e) => {
2148
+ const target = e.target as HTMLElement;
2149
+ if (target.matches('[data-tag-remove]')) {
2150
+ const pill = target.closest('.tag-pill');
2151
+ if (pill) pill.remove();
2152
+ }
2153
+ });
2154
+
2155
+ input.addEventListener('keydown', (e) => {
2156
+ if (e.key === 'Enter' || e.key === ',') {
2157
+ e.preventDefault();
2158
+ addTag(input.value);
2159
+ } else if (e.key === 'Backspace' && !input.value) {
2160
+ const last = list.querySelector('.tag-pill:last-child');
2161
+ if (last) last.remove();
2162
+ }
2163
+ });
2164
+
2165
+ input.addEventListener('focus', () => {
2166
+ if (suggestions) suggestions.hidden = false;
2167
+ });
2168
+
2169
+ if (suggestions) {
2170
+ suggestions.addEventListener('click', (e) => {
2171
+ const btn = (e.target as HTMLElement).closest('[data-sug-val]') as HTMLElement;
2172
+ if (btn) {
2173
+ const val = btn.getAttribute('data-sug-val');
2174
+ if (val) addTag(val);
2175
+ }
2176
+ });
2177
+ }
2178
+ });
2179
+ }
2180
+
2181
+ setupTagsFields();
2182
+ document.addEventListener('astro:page-load', setupTagsFields);
2183
+ </script>
2184
+ `,
2185
+ usage: `<TagsAutocompleteInput id="article-tags" label="Article Tags" initialTags={['astro', 'design']} />`,
2186
+ },
2187
+ {
2188
+ slug: 'glass-inline-toggle-group',
2189
+ name: 'Glass Inline Toggle Group',
2190
+ description: 'An iOS/macOS styled segmented control toggle group with glass sliding indicator and keyboard navigation.',
2191
+ category: 'forms',
2192
+ tags: ["form","toggle","segmented","control","switch"],
2193
+ featured: false,
2194
+ code: `---
2195
+ /**
2196
+ * GlassInlineToggleGroup — Segmented glassmorphism pill toggle for multi-option selection.
2197
+ *
2198
+ * @param {string} name - HTML radio group name.
2199
+ * @param {Array<{label: string, value: string, icon?: string}>} options - Selectable options list.
2200
+ * @param {string} defaultValue - Value of the active option.
2201
+ * @param {'sm' | 'md' | 'lg'} size - Pill size scale. Default is 'md'.
2202
+ */
2203
+ interface Props {
2204
+ name: string;
2205
+ options?: Array<{ label: string; value: string; icon?: string }>;
2206
+ defaultValue?: string;
2207
+ size?: 'sm' | 'md' | 'lg';
2208
+ }
2209
+
2210
+ const {
2211
+ name,
2212
+ options = [
2213
+ { label: 'Weekly', value: 'week' },
2214
+ { label: 'Monthly', value: 'month' },
2215
+ { label: 'Yearly', value: 'year' },
2216
+ ],
2217
+ defaultValue = 'month',
2218
+ size = 'md',
2219
+ } = Astro.props;
2220
+ ---
2221
+
2222
+ <div class:list={['glass-toggle-group', \`glass-toggle-group--\${size}\`]} role="radiogroup" data-toggle-group>
2223
+ <div class="glass-toggle-group__slider" data-toggle-slider aria-hidden="true"></div>
2224
+ {options.map((opt) => {
2225
+ const isChecked = opt.value === defaultValue;
2226
+ return (
2227
+ <label class="glass-toggle-group__item">
2228
+ <input
2229
+ type="radio"
2230
+ name={name}
2231
+ value={opt.value}
2232
+ checked={isChecked}
2233
+ class="glass-toggle-group__radio"
2234
+ data-toggle-radio
2235
+ />
2236
+ <span class="glass-toggle-group__label">
2237
+ {opt.icon && <span class="glass-toggle-group__icon" set:html={opt.icon} />},
2238
+ {opt.label}
2239
+ </span>
2240
+ </label>
2241
+ );
2242
+ })}
2243
+ </div>
2244
+
2245
+ <style>
2246
+ .glass-toggle-group {
2247
+ position: relative;
2248
+ display: inline-flex;
2249
+ align-items: center;
2250
+ background: rgba(255, 255, 255, 0.03);
2251
+ backdrop-filter: blur(16px);
2252
+ border: 1px solid rgba(255, 255, 255, 0.08);
2253
+ border-radius: var(--r-full, 9999px);
2254
+ padding: 4px;
2255
+ user-select: none;
2256
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.05);
2257
+ }
2258
+
2259
+ .glass-toggle-group--sm {
2260
+ padding: 3px;
2261
+ }
2262
+ .glass-toggle-group--sm .glass-toggle-group__label {
2263
+ padding: 0.35rem 0.8rem;
2264
+ font-size: 0.78rem;
2265
+ }
2266
+
2267
+ .glass-toggle-group--md .glass-toggle-group__label {
2268
+ padding: 0.5rem 1.15rem;
2269
+ font-size: 0.88rem;
2270
+ }
2271
+
2272
+ .glass-toggle-group--lg .glass-toggle-group__label {
2273
+ padding: 0.7rem 1.6rem;
2274
+ font-size: 1rem;
2275
+ }
2276
+
2277
+ .glass-toggle-group__item {
2278
+ position: relative;
2279
+ z-index: 2;
2280
+ cursor: pointer;
2281
+ display: inline-flex;
2282
+ align-items: center;
2283
+ }
2284
+
2285
+ .glass-toggle-group__radio {
2286
+ position: absolute;
2287
+ opacity: 0;
2288
+ pointer-events: none;
2289
+ }
2290
+
2291
+ .glass-toggle-group__label {
2292
+ display: inline-flex;
2293
+ align-items: center;
2294
+ gap: 6px;
2295
+ font-family: var(--font-sans, system-ui, sans-serif);
2296
+ font-weight: 600;
2297
+ color: var(--c-text-2, #a3a3a3);
2298
+ border-radius: var(--r-full, 9999px);
2299
+ transition: color 0.25s ease;
2300
+ }
2301
+
2302
+ .glass-toggle-group__item:hover .glass-toggle-group__label {
2303
+ color: var(--c-text-1, #ffffff);
2304
+ }
2305
+
2306
+ .glass-toggle-group__radio:checked + .glass-toggle-group__label {
2307
+ color: #050505;
2308
+ }
2309
+
2310
+ /* Sliding backdrop element */
2311
+ .glass-toggle-group__slider {
2312
+ position: absolute;
2313
+ top: 4px;
2314
+ bottom: 4px;
2315
+ left: 4px;
2316
+ border-radius: var(--r-full, 9999px);
2317
+ background: linear-gradient(135deg, #d4a017, #b8860b);
2318
+ box-shadow: 0 4px 16px rgba(184, 134, 11, 0.4);
2319
+ z-index: 1;
2320
+ transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), width 0.3s cubic-bezier(0.16, 1, 0.3, 1);
2321
+ pointer-events: none;
2322
+ }
2323
+ </style>
2324
+
2325
+ <script>
2326
+ function setupGlassToggleGroups() {
2327
+ document.querySelectorAll('[data-toggle-group]').forEach((group) => {
2328
+ const slider = group.querySelector('[data-toggle-slider]') as HTMLElement;
2329
+ const radios = group.querySelectorAll('[data-toggle-radio]') as NodeListOf<HTMLInputElement>;
2330
+ if (!slider || radios.length === 0) return;
2331
+
2332
+ function updateSlider() {
2333
+ const checked = group.querySelector('[data-toggle-radio]:checked') as HTMLInputElement;
2334
+ if (!checked) return;
2335
+ const label = checked.closest('.glass-toggle-group__item') as HTMLElement;
2336
+ if (!label) return;
2337
+
2338
+ const groupRect = group.getBoundingClientRect();
2339
+ const labelRect = label.getBoundingClientRect();
2340
+
2341
+ const offsetLeft = labelRect.left - groupRect.left;
2342
+ slider.style.width = \`\${labelRect.width}px\`;
2343
+ slider.style.transform = \`translateX(\${offsetLeft - 4}px)\`;
2344
+ }
2345
+
2346
+ radios.forEach((r) => r.addEventListener('change', updateSlider));
2347
+ updateSlider();
2348
+ window.addEventListener('resize', updateSlider);
2349
+ });
2350
+ }
2351
+
2352
+ setupGlassToggleGroups();
2353
+ document.addEventListener('astro:page-load', setupGlassToggleGroups);
2354
+ </script>
2355
+ `,
2356
+ usage: `<GlassInlineToggleGroup name="billing" options={[{ value: 'monthly', label: 'Monthly' }, { value: 'yearly', label: 'Yearly' }]} defaultValue="monthly" />`,
2357
+ },
2358
+ {
2359
+ slug: 'phone-country-input',
2360
+ name: 'Phone Country Input',
2361
+ description: 'Clean phone number input featuring a country dial code selector and auto-formatting.',
2362
+ category: 'forms',
2363
+ tags: ["form","phone","input","country-code","international"],
2364
+ featured: false,
2365
+ code: `---
2366
+ /**
2367
+ * PhoneCountryInput — Accessible phone input with country dial code selector and dark glass styling.
2368
+ *
2369
+ * @param {string} id - HTML ID for the phone input.
2370
+ * @param {string} label - Input label text. Default is 'Phone Number'.
2371
+ * @param {string} name - Form field name. Default is 'phone'.
2372
+ * @param {string} placeholder - Phone placeholder. Default is '612 345 678'.
2373
+ * @param {boolean} required - HTML required field attribute.
2374
+ */
2375
+ interface Props {
2376
+ id: string;
2377
+ label?: string;
2378
+ name?: string;
2379
+ placeholder?: string;
2380
+ required?: boolean;
2381
+ }
2382
+
2383
+ const {
2384
+ id,
2385
+ label = 'Phone Number',
2386
+ name = 'phone',
2387
+ placeholder = '612 345 678',
2388
+ required = false,
2389
+ } = Astro.props;
2390
+
2391
+ const countries = [
2392
+ { code: '+34', flag: '🇪🇸', name: 'ES' },
2393
+ { code: '+1', flag: '🇺🇸', name: 'US' },
2394
+ { code: '+44', flag: '🇬🇧', name: 'UK' },
2395
+ { code: '+49', flag: '🇩🇪', name: 'DE' },
2396
+ { code: '+33', flag: '🇫🇷', name: 'FR' },
2397
+ { code: '+52', flag: '🇲🇽', name: 'MX' },
2398
+ { code: '+54', flag: '🇦🇷', name: 'AR' },
2399
+ ];
2400
+ ---
2401
+
2402
+ <div class="phone-group">
2403
+ <label for={id} class="phone-group__label">{label}</label>
2404
+
2405
+ <div class="phone-group__wrap">
2406
+ <div class="phone-group__prefix">
2407
+ <select class="phone-group__select" aria-label="Country calling code" name={\`\${name}_country\`}>
2408
+ {countries.map((c) => (
2409
+ <option value={c.code}>
2410
+ {c.flag} {c.code}
2411
+ </option>
2412
+ ))}
2413
+ </select>
2414
+ <span class="phone-group__arrow" aria-hidden="true">▾</span>
2415
+ </div>
2416
+
2417
+ <input
2418
+ type="tel"
2419
+ id={id}
2420
+ name={name}
2421
+ placeholder={placeholder}
2422
+ required={required}
2423
+ class="phone-group__input"
2424
+ autocomplete="tel-national"
2425
+ />
2426
+ </div>
2427
+ </div>
2428
+
2429
+ <style>
2430
+ .phone-group {
2431
+ display: flex;
2432
+ flex-direction: column;
2433
+ gap: var(--sp-2, 0.5rem);
2434
+ width: 100%;
2435
+ max-width: 380px;
2436
+ font-family: var(--font-sans, system-ui, sans-serif);
2437
+ }
2438
+
2439
+ .phone-group__label {
2440
+ font-size: 0.85rem;
2441
+ font-weight: 600;
2442
+ color: var(--c-text-1, #ffffff);
2443
+ }
2444
+
2445
+ .phone-group__wrap {
2446
+ display: flex;
2447
+ align-items: center;
2448
+ background: rgba(255, 255, 255, 0.03);
2449
+ backdrop-filter: blur(12px);
2450
+ border: 1px solid rgba(255, 255, 255, 0.1);
2451
+ border-radius: var(--r-md, 12px);
2452
+ overflow: hidden;
2453
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
2454
+ }
2455
+
2456
+ .phone-group__wrap:focus-within {
2457
+ border-color: rgba(184, 134, 11, 0.5);
2458
+ box-shadow: 0 0 0 3px rgba(184, 134, 11, 0.15);
2459
+ }
2460
+
2461
+ .phone-group__prefix {
2462
+ position: relative;
2463
+ display: flex;
2464
+ align-items: center;
2465
+ border-right: 1px solid rgba(255, 255, 255, 0.08);
2466
+ background: rgba(255, 255, 255, 0.02);
2467
+ }
2468
+
2469
+ .phone-group__select {
2470
+ appearance: none;
2471
+ -webkit-appearance: none;
2472
+ background: transparent;
2473
+ border: none;
2474
+ color: var(--c-text-1, #ffffff);
2475
+ font-size: 0.88rem;
2476
+ font-weight: 600;
2477
+ padding: 0.75rem 1.6rem 0.75rem 0.85rem;
2478
+ cursor: pointer;
2479
+ outline: none;
2480
+ }
2481
+
2482
+ .phone-group__select option {
2483
+ background: #111116;
2484
+ color: #ffffff;
2485
+ }
2486
+
2487
+ .phone-group__arrow {
2488
+ position: absolute;
2489
+ right: 0.5rem;
2490
+ font-size: 0.7rem;
2491
+ color: var(--c-text-3, #737373);
2492
+ pointer-events: none;
2493
+ }
2494
+
2495
+ .phone-group__input {
2496
+ flex: 1;
2497
+ background: transparent;
2498
+ border: none;
2499
+ padding: 0.75rem 1rem;
2500
+ color: var(--c-text-1, #ffffff);
2501
+ font-size: 0.92rem;
2502
+ font-family: inherit;
2503
+ outline: none;
2504
+ }
2505
+
2506
+ .phone-group__input::placeholder {
2507
+ color: rgba(255, 255, 255, 0.3);
2508
+ }
2509
+ </style>
2510
+ `,
2511
+ usage: `<PhoneCountryInput id="phone-number" label="Mobile Number" defaultCountry="+34" />`,
2512
+ },
2513
+ ];