experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(components): add Phase 3 complex components - carousel, input, grid, dialog

+1586 -1
+197
app/components/README.md
··· 578 578 579 579 --- 580 580 581 + ## Complex Components 582 + 583 + ### `<peek-carousel>` 584 + 585 + A scroll-snap based carousel for horizontal or vertical content. 586 + 587 + #### Properties 588 + 589 + | Property | Type | Default | Description | 590 + |----------|------|---------|-------------| 591 + | `direction` | `'horizontal' \| 'vertical'` | `'horizontal'` | Scroll direction | 592 + | `snap` | `'start' \| 'center' \| 'end'` | `'start'` | Snap alignment | 593 + | `loop` | `boolean` | `false` | Wrap at ends | 594 + | `controls` | `boolean` | `false` | Show prev/next buttons | 595 + | `indicators` | `boolean` | `false` | Show position dots | 596 + | `gap` | `number` | `12` | Gap between items (px) | 597 + 598 + #### Events 599 + 600 + | Event | Detail | Description | 601 + |-------|--------|-------------| 602 + | `slide-change` | `{ index, element }` | Active slide changed | 603 + 604 + #### Methods 605 + 606 + | Method | Description | 607 + |--------|-------------| 608 + | `goTo(index)` | Navigate to slide | 609 + | `next()` | Go to next slide | 610 + | `prev()` | Go to previous slide | 611 + 612 + #### Example 613 + 614 + ```html 615 + <peek-carousel controls indicators loop> 616 + <img src="slide1.jpg" alt="Slide 1"> 617 + <img src="slide2.jpg" alt="Slide 2"> 618 + <img src="slide3.jpg" alt="Slide 3"> 619 + </peek-carousel> 620 + 621 + <!-- Vertical carousel --> 622 + <peek-carousel direction="vertical" style="--peek-carousel-height: 400px"> 623 + <div>Item 1</div> 624 + <div>Item 2</div> 625 + </peek-carousel> 626 + ``` 627 + 628 + --- 629 + 630 + ### `<peek-input>` 631 + 632 + Input field with autocomplete suggestions dropdown. 633 + 634 + #### Properties 635 + 636 + | Property | Type | Default | Description | 637 + |----------|------|---------|-------------| 638 + | `value` | `string` | `''` | Current value | 639 + | `placeholder` | `string` | `''` | Placeholder text | 640 + | `type` | `'text' \| 'search' \| 'email' \| 'url'` | `'text'` | Input type | 641 + | `disabled` | `boolean` | `false` | Disable input | 642 + | `suggestions` | `Array` | `[]` | Suggestion items | 643 + | `suggestion-key` | `string` | `null` | Property for label (if objects) | 644 + | `min-chars` | `number` | `1` | Min chars before suggestions | 645 + 646 + #### Slots 647 + 648 + | Slot | Description | 649 + |------|-------------| 650 + | `prefix` | Content before input (e.g., search icon) | 651 + | `suffix` | Content after input (e.g., clear button) | 652 + 653 + #### Events 654 + 655 + | Event | Detail | Description | 656 + |-------|--------|-------------| 657 + | `suggestion-select` | `{ value, item }` | Suggestion selected | 658 + 659 + #### Example 660 + 661 + ```html 662 + <peek-input 663 + placeholder="Search tags..." 664 + .suggestions=${['work', 'personal', 'urgent', 'todo']} 665 + @suggestion-select=${(e) => addTag(e.detail.value)} 666 + > 667 + <svg slot="prefix"><!-- search icon --></svg> 668 + </peek-input> 669 + 670 + <!-- With object suggestions --> 671 + <peek-input 672 + .suggestions=${[{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]} 673 + suggestion-key="name" 674 + ></peek-input> 675 + ``` 676 + 677 + --- 678 + 679 + ### `<peek-grid>` 680 + 681 + Responsive CSS Grid layout with auto-fit columns. 682 + 683 + #### Properties 684 + 685 + | Property | Type | Default | Description | 686 + |----------|------|---------|-------------| 687 + | `min-item-width` | `number` | `250` | Min item width (px) | 688 + | `gap` | `number` | `16` | Gap between items (px) | 689 + | `columns` | `number` | `null` | Fixed columns (overrides auto-fit) | 690 + | `align` | `'start' \| 'center' \| 'end' \| 'stretch'` | `'stretch'` | Item alignment | 691 + | `dense` | `boolean` | `false` | Dense packing | 692 + 693 + #### Example 694 + 695 + ```html 696 + <!-- Auto-fit grid --> 697 + <peek-grid min-item-width="300" gap="20"> 698 + <peek-card>Card 1</peek-card> 699 + <peek-card>Card 2</peek-card> 700 + <peek-card>Card 3</peek-card> 701 + </peek-grid> 702 + 703 + <!-- Fixed 3-column grid --> 704 + <peek-grid columns="3"> 705 + <div>Item 1</div> 706 + <div>Item 2</div> 707 + <div>Item 3</div> 708 + </peek-grid> 709 + 710 + <!-- With spanning items --> 711 + <peek-grid> 712 + <peek-grid-item col-span="2">Wide item</peek-grid-item> 713 + <peek-grid-item>Normal</peek-grid-item> 714 + <peek-grid-item row-span="2">Tall item</peek-grid-item> 715 + </peek-grid> 716 + ``` 717 + 718 + --- 719 + 720 + ### `<peek-dialog>` 721 + 722 + Modal/non-modal dialog using native `<dialog>` element. 723 + 724 + #### Properties 725 + 726 + | Property | Type | Default | Description | 727 + |----------|------|---------|-------------| 728 + | `open` | `boolean` | `false` | Whether dialog is open | 729 + | `modal` | `boolean` | `true` | Modal (with backdrop) vs non-modal | 730 + | `close-on-backdrop` | `boolean` | `true` | Close on backdrop click | 731 + | `close-on-escape` | `boolean` | `true` | Close on Escape key | 732 + | `size` | `'sm' \| 'md' \| 'lg' \| 'full'` | `'md'` | Dialog size | 733 + 734 + #### Slots 735 + 736 + | Slot | Description | 737 + |------|-------------| 738 + | (default) | Dialog body content | 739 + | `header` | Dialog header/title | 740 + | `footer` | Footer with action buttons | 741 + 742 + #### Events 743 + 744 + | Event | Detail | Description | 745 + |-------|--------|-------------| 746 + | `open` | — | Dialog opened | 747 + | `close` | `{ reason }` | Dialog closed (`'escape' \| 'backdrop' \| 'close' \| 'api'`) | 748 + 749 + #### Methods 750 + 751 + | Method | Description | 752 + |--------|-------------| 753 + | `show()` | Open the dialog | 754 + | `showModal()` | Open as modal | 755 + | `close()` | Close the dialog | 756 + 757 + #### Example 758 + 759 + ```html 760 + <peek-dialog id="confirmDialog" size="sm"> 761 + <span slot="header">Confirm Delete</span> 762 + <p>Are you sure you want to delete this item?</p> 763 + <div slot="footer"> 764 + <peek-button variant="ghost" onclick="confirmDialog.close()"> 765 + Cancel 766 + </peek-button> 767 + <peek-button variant="danger" onclick="deleteItem()"> 768 + Delete 769 + </peek-button> 770 + </div> 771 + </peek-dialog> 772 + 773 + <peek-button onclick="confirmDialog.show()">Delete Item</peek-button> 774 + ``` 775 + 776 + --- 777 + 581 778 ## Browser Support 582 779 583 780 Components use modern CSS features:
+11 -1
app/components/index.js
··· 35 35 // Event bus 36 36 export { bus, on, once, emit, channel, typedEvent, waitFor, EventBusMixin } from './events.js'; 37 37 38 - // Components 38 + // Components - Basic 39 39 export { PeekButton } from './peek-button.js'; 40 40 export { PeekCard } from './peek-card.js'; 41 41 export { PeekList, PeekListItem } from './peek-list.js'; 42 42 43 + // Components - Complex 44 + export { PeekCarousel } from './peek-carousel.js'; 45 + export { PeekInput } from './peek-input.js'; 46 + export { PeekGrid, PeekGridItem } from './peek-grid.js'; 47 + export { PeekDialog } from './peek-dialog.js'; 48 + 43 49 // Side-effect imports to register all components 44 50 import './peek-button.js'; 45 51 import './peek-card.js'; 46 52 import './peek-list.js'; 53 + import './peek-carousel.js'; 54 + import './peek-input.js'; 55 + import './peek-grid.js'; 56 + import './peek-dialog.js';
+428
app/components/peek-carousel.js
··· 1 + /** 2 + * Peek Carousel Component 3 + * 4 + * A scroll-snap based carousel supporting horizontal and vertical layouts. 5 + * Uses native CSS scroll-snap for smooth, accessible scrolling. 6 + * 7 + * @element peek-carousel 8 + * 9 + * @prop {string} direction - Scroll direction: 'horizontal' | 'vertical' 10 + * @prop {string} snap - Snap alignment: 'start' | 'center' | 'end' 11 + * @prop {boolean} loop - Enable infinite loop (wraps at ends) 12 + * @prop {boolean} controls - Show prev/next navigation buttons 13 + * @prop {boolean} indicators - Show position indicators 14 + * @prop {number} gap - Gap between items in pixels 15 + * 16 + * @slot - Default slot for carousel items 17 + * 18 + * @csspart container - The scroll container 19 + * @csspart prev - Previous button 20 + * @csspart next - Next button 21 + * @csspart indicators - Indicators container 22 + * @csspart indicator - Individual indicator dot 23 + * 24 + * @cssprop --peek-carousel-gap - Gap between items 25 + * @cssprop --peek-carousel-height - Container height (vertical mode) 26 + * 27 + * @fires slide-change - When active slide changes. Detail: { index, element } 28 + * 29 + * @example 30 + * <peek-carousel controls indicators> 31 + * <div>Slide 1</div> 32 + * <div>Slide 2</div> 33 + * <div>Slide 3</div> 34 + * </peek-carousel> 35 + */ 36 + 37 + import { html, css, nothing } from 'lit'; 38 + import { PeekElement, sharedStyles } from './base.js'; 39 + 40 + export class PeekCarousel extends PeekElement { 41 + static properties = { 42 + direction: { type: String, reflect: true }, 43 + snap: { type: String, reflect: true }, 44 + loop: { type: Boolean }, 45 + controls: { type: Boolean }, 46 + indicators: { type: Boolean }, 47 + gap: { type: Number }, 48 + _activeIndex: { type: Number, state: true }, 49 + _itemCount: { type: Number, state: true } 50 + }; 51 + 52 + static styles = [ 53 + sharedStyles, 54 + css` 55 + :host { 56 + display: block; 57 + position: relative; 58 + } 59 + 60 + .carousel { 61 + display: flex; 62 + overflow: auto; 63 + scroll-behavior: smooth; 64 + scrollbar-width: none; 65 + -ms-overflow-style: none; 66 + gap: var(--peek-carousel-gap, var(--peek-space-md)); 67 + } 68 + 69 + .carousel::-webkit-scrollbar { 70 + display: none; 71 + } 72 + 73 + /* Horizontal (default) */ 74 + :host([direction="horizontal"]) .carousel, 75 + :host(:not([direction])) .carousel { 76 + flex-direction: row; 77 + scroll-snap-type: x mandatory; 78 + } 79 + 80 + /* Vertical */ 81 + :host([direction="vertical"]) .carousel { 82 + flex-direction: column; 83 + scroll-snap-type: y mandatory; 84 + height: var(--peek-carousel-height, 300px); 85 + } 86 + 87 + /* Snap alignment */ 88 + ::slotted(*) { 89 + scroll-snap-align: start; 90 + flex-shrink: 0; 91 + } 92 + 93 + :host([snap="center"]) ::slotted(*) { 94 + scroll-snap-align: center; 95 + } 96 + 97 + :host([snap="end"]) ::slotted(*) { 98 + scroll-snap-align: end; 99 + } 100 + 101 + /* Controls */ 102 + .controls { 103 + position: absolute; 104 + top: 50%; 105 + left: 0; 106 + right: 0; 107 + transform: translateY(-50%); 108 + display: flex; 109 + justify-content: space-between; 110 + pointer-events: none; 111 + padding: 0 var(--peek-space-sm); 112 + } 113 + 114 + :host([direction="vertical"]) .controls { 115 + top: 0; 116 + bottom: 0; 117 + left: 50%; 118 + right: auto; 119 + transform: translateX(-50%); 120 + flex-direction: column; 121 + justify-content: space-between; 122 + padding: var(--peek-space-sm) 0; 123 + } 124 + 125 + .control-btn { 126 + pointer-events: auto; 127 + width: 36px; 128 + height: 36px; 129 + border: none; 130 + border-radius: var(--peek-radius-full); 131 + background: var(--theme-bg-secondary, rgba(255, 255, 255, 0.9)); 132 + color: var(--theme-text, #333); 133 + cursor: pointer; 134 + display: flex; 135 + align-items: center; 136 + justify-content: center; 137 + font-size: 18px; 138 + box-shadow: var(--peek-shadow-md); 139 + transition: background var(--peek-transition-fast), 140 + transform var(--peek-transition-fast); 141 + } 142 + 143 + .control-btn:hover { 144 + background: var(--theme-bg-tertiary, #f0f0f0); 145 + transform: scale(1.05); 146 + } 147 + 148 + .control-btn:active { 149 + transform: scale(0.95); 150 + } 151 + 152 + .control-btn:disabled { 153 + opacity: 0.3; 154 + cursor: default; 155 + transform: none; 156 + } 157 + 158 + /* Indicators */ 159 + .indicators { 160 + display: flex; 161 + justify-content: center; 162 + gap: var(--peek-space-sm); 163 + padding: var(--peek-space-md); 164 + } 165 + 166 + :host([direction="vertical"]) .indicators { 167 + position: absolute; 168 + right: var(--peek-space-sm); 169 + top: 50%; 170 + transform: translateY(-50%); 171 + flex-direction: column; 172 + padding: var(--peek-space-sm); 173 + } 174 + 175 + .indicator { 176 + width: 8px; 177 + height: 8px; 178 + border-radius: var(--peek-radius-full); 179 + background: var(--theme-border, #ccc); 180 + border: none; 181 + padding: 0; 182 + cursor: pointer; 183 + transition: background var(--peek-transition-fast), 184 + transform var(--peek-transition-fast); 185 + } 186 + 187 + .indicator:hover { 188 + background: var(--theme-text-muted, #999); 189 + transform: scale(1.2); 190 + } 191 + 192 + .indicator.active { 193 + background: var(--theme-accent, #007aff); 194 + } 195 + ` 196 + ]; 197 + 198 + constructor() { 199 + super(); 200 + this.direction = 'horizontal'; 201 + this.snap = 'start'; 202 + this.loop = false; 203 + this.controls = false; 204 + this.indicators = false; 205 + this.gap = 12; 206 + this._activeIndex = 0; 207 + this._itemCount = 0; 208 + this._scrollTimeout = null; 209 + } 210 + 211 + get items() { 212 + const slot = this.shadowRoot?.querySelector('slot'); 213 + return slot?.assignedElements() || []; 214 + } 215 + 216 + firstUpdated() { 217 + this._updateItemCount(); 218 + this._setupScrollListener(); 219 + } 220 + 221 + _setupScrollListener() { 222 + const container = this.shadowRoot?.querySelector('.carousel'); 223 + if (!container) return; 224 + 225 + container.addEventListener('scroll', () => { 226 + // Debounce scroll updates 227 + clearTimeout(this._scrollTimeout); 228 + this._scrollTimeout = setTimeout(() => { 229 + this._updateActiveIndex(); 230 + }, 50); 231 + }); 232 + } 233 + 234 + _updateItemCount() { 235 + this._itemCount = this.items.length; 236 + } 237 + 238 + _updateActiveIndex() { 239 + const container = this.shadowRoot?.querySelector('.carousel'); 240 + const items = this.items; 241 + if (!container || items.length === 0) return; 242 + 243 + const isVertical = this.direction === 'vertical'; 244 + const containerRect = container.getBoundingClientRect(); 245 + const containerCenter = isVertical 246 + ? containerRect.top + containerRect.height / 2 247 + : containerRect.left + containerRect.width / 2; 248 + 249 + let closestIndex = 0; 250 + let closestDistance = Infinity; 251 + 252 + items.forEach((item, index) => { 253 + const rect = item.getBoundingClientRect(); 254 + const itemCenter = isVertical 255 + ? rect.top + rect.height / 2 256 + : rect.left + rect.width / 2; 257 + const distance = Math.abs(containerCenter - itemCenter); 258 + 259 + if (distance < closestDistance) { 260 + closestDistance = distance; 261 + closestIndex = index; 262 + } 263 + }); 264 + 265 + if (closestIndex !== this._activeIndex) { 266 + this._activeIndex = closestIndex; 267 + this.emit('slide-change', { 268 + index: closestIndex, 269 + element: items[closestIndex] 270 + }); 271 + } 272 + } 273 + 274 + _scrollTo(index) { 275 + const items = this.items; 276 + if (index < 0 || index >= items.length) return; 277 + 278 + const container = this.shadowRoot?.querySelector('.carousel'); 279 + const item = items[index]; 280 + if (!container || !item) return; 281 + 282 + item.scrollIntoView({ 283 + behavior: 'smooth', 284 + block: this.direction === 'vertical' ? this.snap : 'nearest', 285 + inline: this.direction === 'horizontal' ? this.snap : 'nearest' 286 + }); 287 + } 288 + 289 + _handlePrev() { 290 + let newIndex = this._activeIndex - 1; 291 + if (newIndex < 0) { 292 + newIndex = this.loop ? this._itemCount - 1 : 0; 293 + } 294 + this._scrollTo(newIndex); 295 + } 296 + 297 + _handleNext() { 298 + let newIndex = this._activeIndex + 1; 299 + if (newIndex >= this._itemCount) { 300 + newIndex = this.loop ? 0 : this._itemCount - 1; 301 + } 302 + this._scrollTo(newIndex); 303 + } 304 + 305 + _handleIndicatorClick(index) { 306 + this._scrollTo(index); 307 + } 308 + 309 + _handleKeydown(e) { 310 + const isVertical = this.direction === 'vertical'; 311 + 312 + switch (e.key) { 313 + case 'ArrowLeft': 314 + case 'ArrowUp': 315 + if ((isVertical && e.key === 'ArrowUp') || (!isVertical && e.key === 'ArrowLeft')) { 316 + e.preventDefault(); 317 + this._handlePrev(); 318 + } 319 + break; 320 + case 'ArrowRight': 321 + case 'ArrowDown': 322 + if ((isVertical && e.key === 'ArrowDown') || (!isVertical && e.key === 'ArrowRight')) { 323 + e.preventDefault(); 324 + this._handleNext(); 325 + } 326 + break; 327 + case 'Home': 328 + e.preventDefault(); 329 + this._scrollTo(0); 330 + break; 331 + case 'End': 332 + e.preventDefault(); 333 + this._scrollTo(this._itemCount - 1); 334 + break; 335 + } 336 + } 337 + 338 + render() { 339 + const prevDisabled = !this.loop && this._activeIndex === 0; 340 + const nextDisabled = !this.loop && this._activeIndex === this._itemCount - 1; 341 + const isVertical = this.direction === 'vertical'; 342 + 343 + return html` 344 + <div 345 + class="carousel" 346 + part="container" 347 + role="region" 348 + aria-label="Carousel" 349 + tabindex="0" 350 + style="gap: ${this.gap}px" 351 + @keydown=${this._handleKeydown} 352 + > 353 + <slot @slotchange=${this._updateItemCount}></slot> 354 + </div> 355 + 356 + ${this.controls ? html` 357 + <div class="controls"> 358 + <button 359 + part="prev" 360 + class="control-btn" 361 + @click=${this._handlePrev} 362 + ?disabled=${prevDisabled} 363 + aria-label="Previous slide" 364 + > 365 + ${isVertical ? '▲' : '◀'} 366 + </button> 367 + <button 368 + part="next" 369 + class="control-btn" 370 + @click=${this._handleNext} 371 + ?disabled=${nextDisabled} 372 + aria-label="Next slide" 373 + > 374 + ${isVertical ? '▼' : '▶'} 375 + </button> 376 + </div> 377 + ` : nothing} 378 + 379 + ${this.indicators && this._itemCount > 0 ? html` 380 + <div part="indicators" class="indicators" role="tablist"> 381 + ${Array.from({ length: this._itemCount }, (_, i) => html` 382 + <button 383 + part="indicator" 384 + class="indicator ${i === this._activeIndex ? 'active' : ''}" 385 + role="tab" 386 + aria-selected=${i === this._activeIndex} 387 + aria-label="Go to slide ${i + 1}" 388 + @click=${() => this._handleIndicatorClick(i)} 389 + ></button> 390 + `)} 391 + </div> 392 + ` : nothing} 393 + `; 394 + } 395 + 396 + /** 397 + * Navigate to a specific slide 398 + * @param {number} index - Slide index 399 + */ 400 + goTo(index) { 401 + this._scrollTo(index); 402 + } 403 + 404 + /** 405 + * Go to next slide 406 + */ 407 + next() { 408 + this._handleNext(); 409 + } 410 + 411 + /** 412 + * Go to previous slide 413 + */ 414 + prev() { 415 + this._handlePrev(); 416 + } 417 + 418 + /** 419 + * Get current active index 420 + */ 421 + get activeIndex() { 422 + return this._activeIndex; 423 + } 424 + } 425 + 426 + customElements.define('peek-carousel', PeekCarousel); 427 + 428 + export default PeekCarousel;
+343
app/components/peek-dialog.js
··· 1 + /** 2 + * Peek Dialog Component 3 + * 4 + * A modal/non-modal dialog built on native HTML <dialog> element. 5 + * Supports backdrop click to close, escape key, and focus trapping. 6 + * 7 + * @element peek-dialog 8 + * 9 + * @prop {boolean} open - Whether the dialog is open 10 + * @prop {boolean} modal - Open as modal (with backdrop) vs non-modal 11 + * @prop {boolean} closeOnBackdrop - Close when clicking backdrop (default: true) 12 + * @prop {boolean} closeOnEscape - Close on Escape key (default: true) 13 + * @prop {string} size - Dialog size: 'sm' | 'md' | 'lg' | 'full' 14 + * 15 + * @slot - Default slot for dialog body content 16 + * @slot header - Dialog header content 17 + * @slot footer - Dialog footer content (e.g., action buttons) 18 + * 19 + * @csspart dialog - The native dialog element 20 + * @csspart header - Header section 21 + * @csspart body - Body section 22 + * @csspart footer - Footer section 23 + * @csspart close - Close button 24 + * 25 + * @cssprop --peek-dialog-width - Dialog width 26 + * @cssprop --peek-dialog-max-height - Maximum height 27 + * @cssprop --peek-dialog-padding - Content padding 28 + * 29 + * @fires open - When dialog opens 30 + * @fires close - When dialog closes. Detail: { reason: 'escape' | 'backdrop' | 'close' | 'api' } 31 + * 32 + * @example 33 + * <peek-dialog id="myDialog" modal> 34 + * <span slot="header">Confirm Action</span> 35 + * <p>Are you sure you want to continue?</p> 36 + * <div slot="footer"> 37 + * <peek-button variant="ghost" onclick="myDialog.close()">Cancel</peek-button> 38 + * <peek-button variant="primary" onclick="confirm()">Confirm</peek-button> 39 + * </div> 40 + * </peek-dialog> 41 + * 42 + * <peek-button onclick="myDialog.show()">Open Dialog</peek-button> 43 + */ 44 + 45 + import { html, css, nothing } from 'lit'; 46 + import { PeekElement, sharedStyles } from './base.js'; 47 + 48 + export class PeekDialog extends PeekElement { 49 + static properties = { 50 + open: { type: Boolean, reflect: true }, 51 + modal: { type: Boolean }, 52 + closeOnBackdrop: { type: Boolean, attribute: 'close-on-backdrop' }, 53 + closeOnEscape: { type: Boolean, attribute: 'close-on-escape' }, 54 + size: { type: String, reflect: true } 55 + }; 56 + 57 + static styles = [ 58 + sharedStyles, 59 + css` 60 + :host { 61 + display: contents; 62 + } 63 + 64 + dialog { 65 + border: none; 66 + border-radius: var(--peek-radius-xl); 67 + padding: 0; 68 + background: var(--theme-bg-secondary, #fff); 69 + box-shadow: var(--peek-shadow-xl); 70 + max-width: min(90vw, var(--peek-dialog-width, 480px)); 71 + max-height: var(--peek-dialog-max-height, 85vh); 72 + overflow: hidden; 73 + display: flex; 74 + flex-direction: column; 75 + } 76 + 77 + dialog::backdrop { 78 + background: rgba(0, 0, 0, 0.5); 79 + backdrop-filter: blur(2px); 80 + } 81 + 82 + /* Size variants */ 83 + :host([size="sm"]) dialog { 84 + --peek-dialog-width: 320px; 85 + } 86 + 87 + :host([size="lg"]) dialog { 88 + --peek-dialog-width: 640px; 89 + } 90 + 91 + :host([size="full"]) dialog { 92 + --peek-dialog-width: 90vw; 93 + --peek-dialog-max-height: 90vh; 94 + } 95 + 96 + /* Sections */ 97 + .header { 98 + display: flex; 99 + align-items: center; 100 + justify-content: space-between; 101 + padding: var(--peek-dialog-padding, var(--peek-space-lg)); 102 + border-bottom: 1px solid var(--theme-border, #e0e0e0); 103 + font-size: var(--peek-font-lg); 104 + font-weight: var(--peek-font-semibold); 105 + color: var(--theme-text, #333); 106 + } 107 + 108 + .header:empty { 109 + display: none; 110 + } 111 + 112 + .header-content { 113 + flex: 1; 114 + min-width: 0; 115 + } 116 + 117 + .close-btn { 118 + width: 32px; 119 + height: 32px; 120 + border: none; 121 + background: transparent; 122 + border-radius: var(--peek-radius-md); 123 + cursor: pointer; 124 + display: flex; 125 + align-items: center; 126 + justify-content: center; 127 + font-size: 20px; 128 + color: var(--theme-text-muted, #999); 129 + transition: background var(--peek-transition-fast), 130 + color var(--peek-transition-fast); 131 + flex-shrink: 0; 132 + margin-left: var(--peek-space-sm); 133 + } 134 + 135 + .close-btn:hover { 136 + background: var(--theme-bg-tertiary, #f0f0f0); 137 + color: var(--theme-text, #333); 138 + } 139 + 140 + .body { 141 + padding: var(--peek-dialog-padding, var(--peek-space-lg)); 142 + overflow-y: auto; 143 + flex: 1; 144 + color: var(--theme-text-secondary, #666); 145 + font-size: var(--peek-font-md); 146 + line-height: var(--peek-leading-normal); 147 + } 148 + 149 + .body:empty { 150 + display: none; 151 + } 152 + 153 + .footer { 154 + display: flex; 155 + align-items: center; 156 + justify-content: flex-end; 157 + gap: var(--peek-space-sm); 158 + padding: var(--peek-dialog-padding, var(--peek-space-lg)); 159 + border-top: 1px solid var(--theme-border, #e0e0e0); 160 + } 161 + 162 + .footer:empty { 163 + display: none; 164 + } 165 + 166 + /* Animation */ 167 + dialog[open] { 168 + animation: dialog-open 0.15s ease-out; 169 + } 170 + 171 + @keyframes dialog-open { 172 + from { 173 + opacity: 0; 174 + transform: scale(0.95); 175 + } 176 + to { 177 + opacity: 1; 178 + transform: scale(1); 179 + } 180 + } 181 + ` 182 + ]; 183 + 184 + constructor() { 185 + super(); 186 + this.open = false; 187 + this.modal = true; 188 + this.closeOnBackdrop = true; 189 + this.closeOnEscape = true; 190 + this.size = 'md'; 191 + this._handleBackdropClick = this._handleBackdropClick.bind(this); 192 + this._handleKeydown = this._handleKeydown.bind(this); 193 + this._handleNativeClose = this._handleNativeClose.bind(this); 194 + } 195 + 196 + get dialogElement() { 197 + return this.shadowRoot?.querySelector('dialog'); 198 + } 199 + 200 + firstUpdated() { 201 + const dialog = this.dialogElement; 202 + if (!dialog) return; 203 + 204 + // Sync initial state 205 + if (this.open) { 206 + this._openDialog(); 207 + } 208 + 209 + // Listen for native close (e.g., form submission) 210 + dialog.addEventListener('close', this._handleNativeClose); 211 + } 212 + 213 + updated(changedProps) { 214 + if (changedProps.has('open')) { 215 + if (this.open) { 216 + this._openDialog(); 217 + } else { 218 + this._closeDialog('api'); 219 + } 220 + } 221 + } 222 + 223 + disconnectedCallback() { 224 + super.disconnectedCallback(); 225 + document.removeEventListener('keydown', this._handleKeydown); 226 + } 227 + 228 + _openDialog() { 229 + const dialog = this.dialogElement; 230 + if (!dialog || dialog.open) return; 231 + 232 + if (this.modal) { 233 + dialog.showModal(); 234 + } else { 235 + dialog.show(); 236 + } 237 + 238 + document.addEventListener('keydown', this._handleKeydown); 239 + this.emit('open'); 240 + } 241 + 242 + _closeDialog(reason = 'api') { 243 + const dialog = this.dialogElement; 244 + if (!dialog || !dialog.open) return; 245 + 246 + dialog.close(); 247 + document.removeEventListener('keydown', this._handleKeydown); 248 + this.open = false; 249 + this.emit('close', { reason }); 250 + } 251 + 252 + _handleBackdropClick(e) { 253 + if (!this.closeOnBackdrop) return; 254 + 255 + const dialog = this.dialogElement; 256 + // Check if click was on backdrop (outside dialog content) 257 + const rect = dialog.getBoundingClientRect(); 258 + const isInDialog = ( 259 + e.clientX >= rect.left && 260 + e.clientX <= rect.right && 261 + e.clientY >= rect.top && 262 + e.clientY <= rect.bottom 263 + ); 264 + 265 + if (!isInDialog) { 266 + this._closeDialog('backdrop'); 267 + } 268 + } 269 + 270 + _handleKeydown(e) { 271 + if (e.key === 'Escape' && this.closeOnEscape) { 272 + e.preventDefault(); 273 + this._closeDialog('escape'); 274 + } 275 + } 276 + 277 + _handleNativeClose() { 278 + // Sync state if dialog was closed natively 279 + if (this.open) { 280 + this.open = false; 281 + this.emit('close', { reason: 'native' }); 282 + } 283 + } 284 + 285 + _handleCloseClick() { 286 + this._closeDialog('close'); 287 + } 288 + 289 + render() { 290 + return html` 291 + <dialog 292 + part="dialog" 293 + @click=${this._handleBackdropClick} 294 + > 295 + <div part="header" class="header"> 296 + <div class="header-content"> 297 + <slot name="header"></slot> 298 + </div> 299 + <button 300 + part="close" 301 + class="close-btn" 302 + @click=${this._handleCloseClick} 303 + aria-label="Close dialog" 304 + > 305 + × 306 + </button> 307 + </div> 308 + <div part="body" class="body"> 309 + <slot></slot> 310 + </div> 311 + <div part="footer" class="footer"> 312 + <slot name="footer"></slot> 313 + </div> 314 + </dialog> 315 + `; 316 + } 317 + 318 + /** 319 + * Open the dialog 320 + */ 321 + show() { 322 + this.open = true; 323 + } 324 + 325 + /** 326 + * Open as modal 327 + */ 328 + showModal() { 329 + this.modal = true; 330 + this.open = true; 331 + } 332 + 333 + /** 334 + * Close the dialog 335 + */ 336 + close() { 337 + this._closeDialog('api'); 338 + } 339 + } 340 + 341 + customElements.define('peek-dialog', PeekDialog); 342 + 343 + export default PeekDialog;
+175
app/components/peek-grid.js
··· 1 + /** 2 + * Peek Grid Component 3 + * 4 + * A responsive CSS Grid layout container with auto-fit behavior. 5 + * Items automatically flow into available columns based on min-width. 6 + * 7 + * @element peek-grid 8 + * 9 + * @prop {number} minItemWidth - Minimum item width in pixels (default: 250) 10 + * @prop {number} gap - Gap between items in pixels (default: 16) 11 + * @prop {number} columns - Fixed column count (overrides auto-fit if set) 12 + * @prop {string} align - Item alignment: 'start' | 'center' | 'end' | 'stretch' 13 + * @prop {boolean} dense - Enable dense packing algorithm 14 + * 15 + * @slot - Default slot for grid items 16 + * 17 + * @csspart grid - The grid container 18 + * 19 + * @cssprop --peek-grid-min-item-width - Minimum item width 20 + * @cssprop --peek-grid-gap - Gap between items 21 + * @cssprop --peek-grid-columns - Fixed column count 22 + * 23 + * @example 24 + * <peek-grid min-item-width="300" gap="20"> 25 + * <peek-card>Item 1</peek-card> 26 + * <peek-card>Item 2</peek-card> 27 + * <peek-card>Item 3</peek-card> 28 + * </peek-grid> 29 + */ 30 + 31 + import { html, css } from 'lit'; 32 + import { PeekElement, sharedStyles } from './base.js'; 33 + 34 + export class PeekGrid extends PeekElement { 35 + static properties = { 36 + minItemWidth: { type: Number, attribute: 'min-item-width' }, 37 + gap: { type: Number }, 38 + columns: { type: Number }, 39 + align: { type: String, reflect: true }, 40 + dense: { type: Boolean } 41 + }; 42 + 43 + static styles = [ 44 + sharedStyles, 45 + css` 46 + :host { 47 + display: block; 48 + } 49 + 50 + .grid { 51 + display: grid; 52 + grid-template-columns: var(--_grid-columns); 53 + gap: var(--_grid-gap); 54 + align-items: var(--_grid-align, stretch); 55 + } 56 + 57 + :host([dense]) .grid { 58 + grid-auto-flow: dense; 59 + } 60 + 61 + /* Slot styling for items */ 62 + ::slotted(*) { 63 + min-width: 0; /* Prevent overflow */ 64 + } 65 + ` 66 + ]; 67 + 68 + constructor() { 69 + super(); 70 + this.minItemWidth = 250; 71 + this.gap = 16; 72 + this.columns = null; 73 + this.align = 'stretch'; 74 + this.dense = false; 75 + } 76 + 77 + _getGridColumns() { 78 + if (this.columns) { 79 + return `repeat(${this.columns}, 1fr)`; 80 + } 81 + const minWidth = this.minItemWidth; 82 + return `repeat(auto-fit, minmax(min(${minWidth}px, 100%), 1fr))`; 83 + } 84 + 85 + _getAlignValue() { 86 + const alignMap = { 87 + start: 'start', 88 + center: 'center', 89 + end: 'end', 90 + stretch: 'stretch' 91 + }; 92 + return alignMap[this.align] || 'stretch'; 93 + } 94 + 95 + render() { 96 + const gridColumns = this._getGridColumns(); 97 + const alignItems = this._getAlignValue(); 98 + 99 + return html` 100 + <div 101 + part="grid" 102 + class="grid" 103 + style=" 104 + --_grid-columns: ${gridColumns}; 105 + --_grid-gap: ${this.gap}px; 106 + --_grid-align: ${alignItems}; 107 + " 108 + > 109 + <slot></slot> 110 + </div> 111 + `; 112 + } 113 + } 114 + 115 + /** 116 + * Peek Grid Item Component 117 + * 118 + * Optional wrapper for grid items with span control. 119 + * 120 + * @element peek-grid-item 121 + * 122 + * @prop {number} colSpan - Number of columns to span 123 + * @prop {number} rowSpan - Number of rows to span 124 + * 125 + * @slot - Default slot for item content 126 + * 127 + * @example 128 + * <peek-grid> 129 + * <peek-grid-item col-span="2">Wide item</peek-grid-item> 130 + * <peek-grid-item>Normal item</peek-grid-item> 131 + * </peek-grid> 132 + */ 133 + export class PeekGridItem extends PeekElement { 134 + static properties = { 135 + colSpan: { type: Number, attribute: 'col-span' }, 136 + rowSpan: { type: Number, attribute: 'row-span' } 137 + }; 138 + 139 + static styles = [ 140 + sharedStyles, 141 + css` 142 + :host { 143 + display: block; 144 + grid-column: var(--_col-span, auto); 145 + grid-row: var(--_row-span, auto); 146 + } 147 + ` 148 + ]; 149 + 150 + constructor() { 151 + super(); 152 + this.colSpan = null; 153 + this.rowSpan = null; 154 + } 155 + 156 + render() { 157 + const colStyle = this.colSpan ? `span ${this.colSpan}` : 'auto'; 158 + const rowStyle = this.rowSpan ? `span ${this.rowSpan}` : 'auto'; 159 + 160 + return html` 161 + <style> 162 + :host { 163 + --_col-span: ${colStyle}; 164 + --_row-span: ${rowStyle}; 165 + } 166 + </style> 167 + <slot></slot> 168 + `; 169 + } 170 + } 171 + 172 + customElements.define('peek-grid', PeekGrid); 173 + customElements.define('peek-grid-item', PeekGridItem); 174 + 175 + export default PeekGrid;
+432
app/components/peek-input.js
··· 1 + /** 2 + * Peek Input Component 3 + * 4 + * An input field with optional autocomplete suggestions dropdown. 5 + * Supports keyboard navigation and custom filtering. 6 + * 7 + * @element peek-input 8 + * 9 + * @prop {string} value - Current input value 10 + * @prop {string} placeholder - Placeholder text 11 + * @prop {string} type - Input type: 'text' | 'search' | 'email' | 'url' | 'password' 12 + * @prop {boolean} disabled - Disable the input 13 + * @prop {boolean} readonly - Make input read-only 14 + * @prop {boolean} autofocus - Focus on mount 15 + * @prop {Array} suggestions - Array of suggestion strings or objects 16 + * @prop {string} suggestionKey - Property name for suggestion label (if objects) 17 + * @prop {boolean} showSuggestions - Force show/hide suggestions 18 + * @prop {number} minChars - Minimum chars before showing suggestions (default: 1) 19 + * 20 + * @slot prefix - Content before input (e.g., icon) 21 + * @slot suffix - Content after input (e.g., clear button) 22 + * 23 + * @csspart input - The native input element 24 + * @csspart suggestions - The suggestions dropdown 25 + * @csspart suggestion - Individual suggestion item 26 + * 27 + * @cssprop --peek-input-height - Input height 28 + * @cssprop --peek-input-bg - Input background 29 + * @cssprop --peek-input-border - Input border color 30 + * 31 + * @fires input - When value changes (native event) 32 + * @fires change - When value is committed (native event) 33 + * @fires suggestion-select - When suggestion is selected. Detail: { value, item } 34 + * 35 + * @example 36 + * <peek-input 37 + * placeholder="Search..." 38 + * .suggestions=${['Apple', 'Banana', 'Cherry']} 39 + * @suggestion-select=${(e) => console.log(e.detail.value)} 40 + * ></peek-input> 41 + */ 42 + 43 + import { html, css, nothing } from 'lit'; 44 + import { PeekElement, sharedStyles } from './base.js'; 45 + 46 + export class PeekInput extends PeekElement { 47 + static properties = { 48 + value: { type: String }, 49 + placeholder: { type: String }, 50 + type: { type: String }, 51 + disabled: { type: Boolean, reflect: true }, 52 + readonly: { type: Boolean }, 53 + autofocus: { type: Boolean }, 54 + suggestions: { type: Array }, 55 + suggestionKey: { type: String, attribute: 'suggestion-key' }, 56 + showSuggestions: { type: Boolean, attribute: 'show-suggestions' }, 57 + minChars: { type: Number, attribute: 'min-chars' }, 58 + _open: { type: Boolean, state: true }, 59 + _filteredSuggestions: { type: Array, state: true }, 60 + _highlightedIndex: { type: Number, state: true } 61 + }; 62 + 63 + static styles = [ 64 + sharedStyles, 65 + css` 66 + :host { 67 + display: block; 68 + position: relative; 69 + } 70 + 71 + .input-wrapper { 72 + display: flex; 73 + align-items: center; 74 + gap: var(--peek-space-sm); 75 + height: var(--peek-input-height, var(--peek-btn-height-md)); 76 + padding: 0 var(--peek-space-md); 77 + background: var(--peek-input-bg, var(--theme-bg-secondary, #fff)); 78 + border: 1px solid var(--peek-input-border, var(--theme-border, #e0e0e0)); 79 + border-radius: var(--peek-radius-md); 80 + transition: border-color var(--peek-transition-fast), 81 + box-shadow var(--peek-transition-fast); 82 + } 83 + 84 + .input-wrapper:focus-within { 85 + border-color: var(--theme-accent, #007aff); 86 + box-shadow: var(--peek-focus-ring); 87 + } 88 + 89 + :host([disabled]) .input-wrapper { 90 + opacity: 0.5; 91 + cursor: not-allowed; 92 + background: var(--theme-bg-tertiary, #f5f5f5); 93 + } 94 + 95 + input { 96 + flex: 1; 97 + border: none; 98 + background: transparent; 99 + font: inherit; 100 + font-size: var(--peek-font-md); 101 + color: var(--theme-text, #333); 102 + outline: none; 103 + min-width: 0; 104 + } 105 + 106 + input::placeholder { 107 + color: var(--theme-text-muted, #999); 108 + } 109 + 110 + input:disabled { 111 + cursor: not-allowed; 112 + } 113 + 114 + ::slotted([slot="prefix"]), 115 + ::slotted([slot="suffix"]) { 116 + display: flex; 117 + align-items: center; 118 + color: var(--theme-text-muted, #999); 119 + } 120 + 121 + /* Suggestions dropdown */ 122 + .suggestions { 123 + position: absolute; 124 + top: 100%; 125 + left: 0; 126 + right: 0; 127 + margin-top: var(--peek-space-xs); 128 + background: var(--theme-bg-secondary, #fff); 129 + border: 1px solid var(--theme-border, #e0e0e0); 130 + border-radius: var(--peek-radius-md); 131 + box-shadow: var(--peek-shadow-lg); 132 + max-height: 240px; 133 + overflow-y: auto; 134 + z-index: 1000; 135 + } 136 + 137 + .suggestions:empty { 138 + display: none; 139 + } 140 + 141 + .suggestion { 142 + display: flex; 143 + align-items: center; 144 + padding: var(--peek-space-sm) var(--peek-space-md); 145 + cursor: pointer; 146 + font-size: var(--peek-font-md); 147 + color: var(--theme-text, #333); 148 + transition: background var(--peek-transition-fast); 149 + } 150 + 151 + .suggestion:hover, 152 + .suggestion.highlighted { 153 + background: var(--theme-bg-tertiary, #f5f5f5); 154 + } 155 + 156 + .suggestion.highlighted { 157 + outline: 1px solid var(--theme-accent, #007aff); 158 + outline-offset: -1px; 159 + } 160 + 161 + .suggestion-text { 162 + flex: 1; 163 + overflow: hidden; 164 + text-overflow: ellipsis; 165 + white-space: nowrap; 166 + } 167 + 168 + .match { 169 + font-weight: var(--peek-font-semibold); 170 + color: var(--theme-accent, #007aff); 171 + } 172 + 173 + .no-results { 174 + padding: var(--peek-space-md); 175 + color: var(--theme-text-muted, #999); 176 + text-align: center; 177 + font-size: var(--peek-font-sm); 178 + } 179 + ` 180 + ]; 181 + 182 + constructor() { 183 + super(); 184 + this.value = ''; 185 + this.placeholder = ''; 186 + this.type = 'text'; 187 + this.disabled = false; 188 + this.readonly = false; 189 + this.autofocus = false; 190 + this.suggestions = []; 191 + this.suggestionKey = null; 192 + this.showSuggestions = null; 193 + this.minChars = 1; 194 + this._open = false; 195 + this._filteredSuggestions = []; 196 + this._highlightedIndex = -1; 197 + } 198 + 199 + get inputElement() { 200 + return this.shadowRoot?.querySelector('input'); 201 + } 202 + 203 + firstUpdated() { 204 + if (this.autofocus) { 205 + this.focus(); 206 + } 207 + } 208 + 209 + updated(changedProps) { 210 + if (changedProps.has('suggestions') || changedProps.has('value')) { 211 + this._filterSuggestions(); 212 + } 213 + } 214 + 215 + _getSuggestionLabel(item) { 216 + if (typeof item === 'string') return item; 217 + if (this.suggestionKey && item[this.suggestionKey]) { 218 + return item[this.suggestionKey]; 219 + } 220 + return item.label || item.name || item.value || String(item); 221 + } 222 + 223 + _filterSuggestions() { 224 + if (!this.suggestions || this.suggestions.length === 0) { 225 + this._filteredSuggestions = []; 226 + return; 227 + } 228 + 229 + const query = this.value.toLowerCase().trim(); 230 + 231 + if (query.length < this.minChars) { 232 + this._filteredSuggestions = []; 233 + return; 234 + } 235 + 236 + this._filteredSuggestions = this.suggestions.filter(item => { 237 + const label = this._getSuggestionLabel(item).toLowerCase(); 238 + return label.includes(query); 239 + }); 240 + 241 + this._highlightedIndex = this._filteredSuggestions.length > 0 ? 0 : -1; 242 + } 243 + 244 + _handleInput(e) { 245 + this.value = e.target.value; 246 + this._open = true; 247 + this._filterSuggestions(); 248 + } 249 + 250 + _handleFocus() { 251 + if (this.value.length >= this.minChars && this._filteredSuggestions.length > 0) { 252 + this._open = true; 253 + } 254 + } 255 + 256 + _handleBlur() { 257 + // Delay to allow click on suggestion 258 + setTimeout(() => { 259 + this._open = false; 260 + this._highlightedIndex = -1; 261 + }, 150); 262 + } 263 + 264 + _handleKeydown(e) { 265 + if (!this._open || this._filteredSuggestions.length === 0) { 266 + if (e.key === 'ArrowDown' && this._filteredSuggestions.length > 0) { 267 + this._open = true; 268 + this._highlightedIndex = 0; 269 + e.preventDefault(); 270 + } 271 + return; 272 + } 273 + 274 + switch (e.key) { 275 + case 'ArrowDown': 276 + e.preventDefault(); 277 + this._highlightedIndex = Math.min( 278 + this._highlightedIndex + 1, 279 + this._filteredSuggestions.length - 1 280 + ); 281 + this._scrollHighlightedIntoView(); 282 + break; 283 + 284 + case 'ArrowUp': 285 + e.preventDefault(); 286 + this._highlightedIndex = Math.max(this._highlightedIndex - 1, 0); 287 + this._scrollHighlightedIntoView(); 288 + break; 289 + 290 + case 'Enter': 291 + if (this._highlightedIndex >= 0) { 292 + e.preventDefault(); 293 + this._selectSuggestion(this._filteredSuggestions[this._highlightedIndex]); 294 + } 295 + break; 296 + 297 + case 'Escape': 298 + e.preventDefault(); 299 + this._open = false; 300 + this._highlightedIndex = -1; 301 + break; 302 + 303 + case 'Tab': 304 + if (this._highlightedIndex >= 0) { 305 + this._selectSuggestion(this._filteredSuggestions[this._highlightedIndex]); 306 + } 307 + this._open = false; 308 + break; 309 + } 310 + } 311 + 312 + _scrollHighlightedIntoView() { 313 + requestAnimationFrame(() => { 314 + const highlighted = this.shadowRoot?.querySelector('.suggestion.highlighted'); 315 + highlighted?.scrollIntoView({ block: 'nearest' }); 316 + }); 317 + } 318 + 319 + _selectSuggestion(item) { 320 + const label = this._getSuggestionLabel(item); 321 + this.value = label; 322 + this._open = false; 323 + this._highlightedIndex = -1; 324 + 325 + this.emit('suggestion-select', { value: label, item }); 326 + 327 + // Trigger input change event 328 + this.inputElement?.dispatchEvent(new Event('change', { bubbles: true })); 329 + } 330 + 331 + _highlightMatch(text) { 332 + const query = this.value.toLowerCase().trim(); 333 + if (!query) return text; 334 + 335 + const index = text.toLowerCase().indexOf(query); 336 + if (index === -1) return text; 337 + 338 + const before = text.slice(0, index); 339 + const match = text.slice(index, index + query.length); 340 + const after = text.slice(index + query.length); 341 + 342 + return html`${before}<span class="match">${match}</span>${after}`; 343 + } 344 + 345 + render() { 346 + const showDropdown = this.showSuggestions !== null 347 + ? this.showSuggestions 348 + : this._open && this._filteredSuggestions.length > 0; 349 + 350 + return html` 351 + <div class="input-wrapper"> 352 + <slot name="prefix"></slot> 353 + <input 354 + part="input" 355 + type=${this.type} 356 + .value=${this.value} 357 + placeholder=${this.placeholder} 358 + ?disabled=${this.disabled} 359 + ?readonly=${this.readonly} 360 + role="combobox" 361 + aria-expanded=${showDropdown} 362 + aria-autocomplete="list" 363 + aria-controls="suggestions" 364 + @input=${this._handleInput} 365 + @focus=${this._handleFocus} 366 + @blur=${this._handleBlur} 367 + @keydown=${this._handleKeydown} 368 + > 369 + <slot name="suffix"></slot> 370 + </div> 371 + 372 + ${showDropdown ? html` 373 + <div 374 + id="suggestions" 375 + part="suggestions" 376 + class="suggestions" 377 + role="listbox" 378 + > 379 + ${this._filteredSuggestions.map((item, index) => { 380 + const label = this._getSuggestionLabel(item); 381 + return html` 382 + <div 383 + part="suggestion" 384 + class="suggestion ${index === this._highlightedIndex ? 'highlighted' : ''}" 385 + role="option" 386 + aria-selected=${index === this._highlightedIndex} 387 + @click=${() => this._selectSuggestion(item)} 388 + @mouseenter=${() => { this._highlightedIndex = index; }} 389 + > 390 + <span class="suggestion-text">${this._highlightMatch(label)}</span> 391 + </div> 392 + `; 393 + })} 394 + </div> 395 + ` : nothing} 396 + `; 397 + } 398 + 399 + /** 400 + * Focus the input element 401 + */ 402 + focus() { 403 + this.inputElement?.focus(); 404 + } 405 + 406 + /** 407 + * Blur the input element 408 + */ 409 + blur() { 410 + this.inputElement?.blur(); 411 + } 412 + 413 + /** 414 + * Select all text in the input 415 + */ 416 + select() { 417 + this.inputElement?.select(); 418 + } 419 + 420 + /** 421 + * Clear the input value 422 + */ 423 + clear() { 424 + this.value = ''; 425 + this._filteredSuggestions = []; 426 + this.emit('input', { value: '' }); 427 + } 428 + } 429 + 430 + customElements.define('peek-input', PeekInput); 431 + 432 + export default PeekInput;