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 4.1 components - select, dropdown, switch, drawer, tooltip, button-group

- peek-select: native/custom select with Popover API listbox
- peek-dropdown: action menus with peek-dropdown-item and divider
- peek-switch: toggle switch on native checkbox
- peek-drawer: slide-out panel using native dialog
- peek-tooltip: hover-triggered hints using Popover API
- peek-button-group: segmented controls with single/multiple selection
- Update research doc Phase 4 for framework-free approach

+2186 -5
+14
app/components/index.js
··· 51 51 export { PeekTabs, PeekTab, PeekTabPanel } from './peek-tabs.js'; 52 52 export { PeekDetails } from './peek-details.js'; 53 53 54 + // Components - Phase 4 55 + export { PeekSelect } from './peek-select.js'; 56 + export { PeekDropdown, PeekDropdownItem, PeekDropdownDivider } from './peek-dropdown.js'; 57 + export { PeekSwitch } from './peek-switch.js'; 58 + export { PeekDrawer } from './peek-drawer.js'; 59 + export { PeekTooltip } from './peek-tooltip.js'; 60 + export { PeekButtonGroup, PeekButtonGroupItem } from './peek-button-group.js'; 61 + 54 62 // Side-effect imports to register all components 55 63 import './peek-button.js'; 56 64 import './peek-card.js'; ··· 62 70 import './peek-popover.js'; 63 71 import './peek-tabs.js'; 64 72 import './peek-details.js'; 73 + import './peek-select.js'; 74 + import './peek-dropdown.js'; 75 + import './peek-switch.js'; 76 + import './peek-drawer.js'; 77 + import './peek-tooltip.js'; 78 + import './peek-button-group.js';
+410
app/components/peek-button-group.js
··· 1 + /** 2 + * Peek Button Group Component 3 + * 4 + * Segmented controls and tag sets with single/multiple selection. 5 + * 6 + * @element peek-button-group 7 + * 8 + * @prop {string} value - Selected value (single selection) 9 + * @prop {Array} values - Selected values (multiple selection) 10 + * @prop {string} selection - 'none' | 'single' | 'multiple' 11 + * @prop {string} variant - 'default' | 'outline' | 'ghost' 12 + * @prop {string} size - 'sm' | 'md' | 'lg' 13 + * @prop {boolean} disabled - Disable all buttons 14 + * 15 + * @slot - peek-button-group-item elements 16 + * 17 + * @csspart group - The button group container 18 + * 19 + * @fires change - When selection changes. Detail: { value, values } 20 + */ 21 + 22 + import { html, css } from 'lit'; 23 + import { PeekElement, sharedStyles } from './base.js'; 24 + 25 + export class PeekButtonGroup extends PeekElement { 26 + static properties = { 27 + value: { type: String }, 28 + values: { type: Array }, 29 + selection: { type: String, reflect: true }, 30 + variant: { type: String, reflect: true }, 31 + size: { type: String, reflect: true }, 32 + disabled: { type: Boolean, reflect: true } 33 + }; 34 + 35 + static styles = [ 36 + sharedStyles, 37 + css` 38 + :host { 39 + display: inline-flex; 40 + } 41 + 42 + .group { 43 + display: inline-flex; 44 + border-radius: var(--peek-radius-md); 45 + overflow: hidden; 46 + } 47 + 48 + /* Outline variant - connected buttons */ 49 + :host([variant="outline"]) .group, 50 + :host(:not([variant])) .group { 51 + border: 1px solid var(--peek-btn-group-border, var(--theme-border, #e0e0e0)); 52 + } 53 + 54 + /* Ghost variant - separated buttons */ 55 + :host([variant="ghost"]) .group { 56 + gap: var(--peek-space-xs); 57 + } 58 + 59 + :host([disabled]) { 60 + opacity: 0.5; 61 + pointer-events: none; 62 + } 63 + ` 64 + ]; 65 + 66 + constructor() { 67 + super(); 68 + this.value = ''; 69 + this.values = []; 70 + this.selection = 'single'; 71 + this.variant = 'outline'; 72 + this.size = 'md'; 73 + this.disabled = false; 74 + } 75 + 76 + get items() { 77 + return Array.from(this.querySelectorAll('peek-button-group-item')); 78 + } 79 + 80 + firstUpdated() { 81 + this._updateItems(); 82 + } 83 + 84 + updated(changedProps) { 85 + if (changedProps.has('value') || changedProps.has('values') || changedProps.has('disabled') || changedProps.has('size') || changedProps.has('variant')) { 86 + this._updateItems(); 87 + } 88 + } 89 + 90 + _updateItems() { 91 + const items = this.items; 92 + items.forEach((item, index) => { 93 + item._groupElement = this; 94 + item._index = index; 95 + item._first = index === 0; 96 + item._last = index === items.length - 1; 97 + item._variant = this.variant; 98 + item._size = this.size; 99 + item._groupDisabled = this.disabled; 100 + 101 + // Update selection state 102 + if (this.selection === 'single') { 103 + item._selected = item.value === this.value; 104 + } else if (this.selection === 'multiple') { 105 + item._selected = this.values.includes(item.value); 106 + } else { 107 + item._selected = false; 108 + } 109 + }); 110 + } 111 + 112 + _handleSlotChange() { 113 + this._updateItems(); 114 + } 115 + 116 + _selectItem(item) { 117 + if (this.disabled || item.disabled) return; 118 + 119 + if (this.selection === 'single') { 120 + this.value = item.value; 121 + this.emit('change', { value: this.value, values: [this.value] }); 122 + } else if (this.selection === 'multiple') { 123 + const idx = this.values.indexOf(item.value); 124 + if (idx >= 0) { 125 + this.values = this.values.filter(v => v !== item.value); 126 + } else { 127 + this.values = [...this.values, item.value]; 128 + } 129 + this.emit('change', { value: this.values[0] || '', values: this.values }); 130 + } 131 + 132 + this._updateItems(); 133 + } 134 + 135 + _handleKeydown(e) { 136 + const items = this.items.filter(item => !item.disabled); 137 + if (!items.length) return; 138 + 139 + const currentIndex = items.findIndex(item => item === document.activeElement || item.contains(document.activeElement)); 140 + let newIndex = currentIndex; 141 + 142 + switch (e.key) { 143 + case 'ArrowLeft': 144 + case 'ArrowUp': 145 + e.preventDefault(); 146 + newIndex = currentIndex <= 0 ? items.length - 1 : currentIndex - 1; 147 + break; 148 + case 'ArrowRight': 149 + case 'ArrowDown': 150 + e.preventDefault(); 151 + newIndex = currentIndex >= items.length - 1 ? 0 : currentIndex + 1; 152 + break; 153 + case 'Home': 154 + e.preventDefault(); 155 + newIndex = 0; 156 + break; 157 + case 'End': 158 + e.preventDefault(); 159 + newIndex = items.length - 1; 160 + break; 161 + default: 162 + return; 163 + } 164 + 165 + items[newIndex]?.focus(); 166 + } 167 + 168 + render() { 169 + return html` 170 + <div 171 + part="group" 172 + class="group" 173 + role="group" 174 + @keydown=${this._handleKeydown} 175 + > 176 + <slot @slotchange=${this._handleSlotChange}></slot> 177 + </div> 178 + `; 179 + } 180 + 181 + // Public API 182 + select(value) { 183 + if (this.selection === 'single') { 184 + this.value = value; 185 + } else if (this.selection === 'multiple') { 186 + if (!this.values.includes(value)) { 187 + this.values = [...this.values, value]; 188 + } 189 + } 190 + this._updateItems(); 191 + } 192 + 193 + deselect(value) { 194 + if (this.selection === 'single' && this.value === value) { 195 + this.value = ''; 196 + } else if (this.selection === 'multiple') { 197 + this.values = this.values.filter(v => v !== value); 198 + } 199 + this._updateItems(); 200 + } 201 + 202 + clear() { 203 + this.value = ''; 204 + this.values = []; 205 + this._updateItems(); 206 + } 207 + } 208 + 209 + /** 210 + * Peek Button Group Item 211 + * 212 + * @element peek-button-group-item 213 + * 214 + * @prop {string} value - Item value 215 + * @prop {boolean} disabled - Disable this item 216 + * 217 + * @slot prefix - Content before label 218 + * @slot - Item label 219 + * @slot suffix - Content after label 220 + * 221 + * @csspart button - The button element 222 + */ 223 + export class PeekButtonGroupItem extends PeekElement { 224 + static properties = { 225 + value: { type: String }, 226 + disabled: { type: Boolean, reflect: true }, 227 + _selected: { type: Boolean, state: true }, 228 + _first: { type: Boolean, state: true }, 229 + _last: { type: Boolean, state: true }, 230 + _variant: { type: String, state: true }, 231 + _size: { type: String, state: true }, 232 + _groupDisabled: { type: Boolean, state: true } 233 + }; 234 + 235 + static styles = [ 236 + sharedStyles, 237 + css` 238 + :host { 239 + display: contents; 240 + } 241 + 242 + .button { 243 + display: inline-flex; 244 + align-items: center; 245 + justify-content: center; 246 + gap: var(--peek-space-xs); 247 + padding: 0 var(--peek-space-md); 248 + height: var(--peek-btn-height-md, 36px); 249 + border: none; 250 + background: var(--peek-btn-group-bg, var(--theme-bg-secondary, #fff)); 251 + color: var(--peek-btn-group-text, var(--theme-text, #333)); 252 + font: inherit; 253 + font-size: var(--peek-font-md); 254 + font-weight: var(--peek-font-medium); 255 + cursor: pointer; 256 + outline: none; 257 + transition: background var(--peek-transition-fast), color var(--peek-transition-fast); 258 + white-space: nowrap; 259 + } 260 + 261 + /* Size variants */ 262 + :host([data-size="sm"]) .button { 263 + height: var(--peek-btn-height-sm, 28px); 264 + padding: 0 var(--peek-space-sm); 265 + font-size: var(--peek-font-sm); 266 + } 267 + 268 + :host([data-size="lg"]) .button { 269 + height: var(--peek-btn-height-lg, 44px); 270 + padding: 0 var(--peek-space-lg); 271 + font-size: var(--peek-font-lg); 272 + } 273 + 274 + /* Hover state */ 275 + .button:hover:not(:disabled) { 276 + background: var(--theme-bg-tertiary, #f5f5f5); 277 + } 278 + 279 + /* Focus state */ 280 + .button:focus-visible { 281 + outline: 2px solid var(--theme-accent, #007aff); 282 + outline-offset: -2px; 283 + z-index: 1; 284 + } 285 + 286 + /* Selected state */ 287 + :host([data-selected]) .button { 288 + background: var(--theme-accent, #007aff); 289 + color: #fff; 290 + } 291 + 292 + :host([data-selected]) .button:hover:not(:disabled) { 293 + background: var(--theme-accent-hover, #0056b3); 294 + } 295 + 296 + /* Disabled state */ 297 + .button:disabled { 298 + opacity: 0.5; 299 + cursor: not-allowed; 300 + } 301 + 302 + /* Outline variant borders */ 303 + :host([data-variant="outline"]) .button, 304 + :host(:not([data-variant])) .button { 305 + border-right: 1px solid var(--peek-btn-group-border, var(--theme-border, #e0e0e0)); 306 + } 307 + 308 + :host([data-variant="outline"][data-last]) .button, 309 + :host(:not([data-variant])[data-last]) .button { 310 + border-right: none; 311 + } 312 + 313 + /* Ghost variant */ 314 + :host([data-variant="ghost"]) .button { 315 + border-radius: var(--peek-radius-sm); 316 + background: transparent; 317 + } 318 + 319 + :host([data-variant="ghost"]) .button:hover:not(:disabled) { 320 + background: var(--theme-bg-tertiary, #f5f5f5); 321 + } 322 + 323 + :host([data-variant="ghost"][data-selected]) .button { 324 + background: var(--theme-accent, #007aff); 325 + } 326 + 327 + /* Border radius for first/last in connected variants */ 328 + :host([data-first][data-variant="outline"]) .button, 329 + :host([data-first]:not([data-variant])) .button { 330 + border-top-left-radius: calc(var(--peek-radius-md) - 1px); 331 + border-bottom-left-radius: calc(var(--peek-radius-md) - 1px); 332 + } 333 + 334 + :host([data-last][data-variant="outline"]) .button, 335 + :host([data-last]:not([data-variant])) .button { 336 + border-top-right-radius: calc(var(--peek-radius-md) - 1px); 337 + border-bottom-right-radius: calc(var(--peek-radius-md) - 1px); 338 + } 339 + 340 + ::slotted([slot="prefix"]), 341 + ::slotted([slot="suffix"]) { 342 + display: flex; 343 + } 344 + ` 345 + ]; 346 + 347 + constructor() { 348 + super(); 349 + this.value = ''; 350 + this.disabled = false; 351 + this._selected = false; 352 + this._first = false; 353 + this._last = false; 354 + this._variant = 'outline'; 355 + this._size = 'md'; 356 + this._groupDisabled = false; 357 + this._groupElement = null; 358 + } 359 + 360 + updated(changedProps) { 361 + // Update data attributes for CSS 362 + this.dataset.selected = this._selected ? '' : undefined; 363 + if (!this._selected) delete this.dataset.selected; 364 + else this.dataset.selected = ''; 365 + 366 + this.dataset.first = this._first ? '' : undefined; 367 + if (!this._first) delete this.dataset.first; 368 + else this.dataset.first = ''; 369 + 370 + this.dataset.last = this._last ? '' : undefined; 371 + if (!this._last) delete this.dataset.last; 372 + else this.dataset.last = ''; 373 + 374 + this.dataset.variant = this._variant; 375 + this.dataset.size = this._size; 376 + } 377 + 378 + _handleClick() { 379 + if (this.disabled || this._groupDisabled) return; 380 + this._groupElement?._selectItem(this); 381 + } 382 + 383 + render() { 384 + return html` 385 + <button 386 + part="button" 387 + class="button" 388 + type="button" 389 + role="radio" 390 + aria-checked=${this._selected} 391 + ?disabled=${this.disabled || this._groupDisabled} 392 + tabindex=${this._selected ? 0 : -1} 393 + @click=${this._handleClick} 394 + > 395 + <slot name="prefix"></slot> 396 + <slot></slot> 397 + <slot name="suffix"></slot> 398 + </button> 399 + `; 400 + } 401 + 402 + focus() { 403 + this.shadowRoot?.querySelector('.button')?.focus(); 404 + } 405 + } 406 + 407 + customElements.define('peek-button-group', PeekButtonGroup); 408 + customElements.define('peek-button-group-item', PeekButtonGroupItem); 409 + 410 + export default PeekButtonGroup;
+345
app/components/peek-drawer.js
··· 1 + /** 2 + * Peek Drawer Component 3 + * 4 + * Slide-out sidebar/panel using native <dialog> element. 5 + * 6 + * @element peek-drawer 7 + * 8 + * @prop {boolean} open - Whether drawer is open 9 + * @prop {string} position - 'left' | 'right' | 'top' | 'bottom' 10 + * @prop {string} size - Drawer size: 'sm' | 'md' | 'lg' | 'full' or CSS value 11 + * @prop {boolean} modal - Use modal mode (with backdrop) 12 + * @prop {boolean} closeOnBackdrop - Close when clicking backdrop 13 + * @prop {boolean} closeOnEscape - Close on Escape key 14 + * @prop {boolean} contained - Constrain to parent container instead of viewport 15 + * 16 + * @slot - Drawer content 17 + * @slot header - Drawer header 18 + * @slot footer - Drawer footer 19 + * 20 + * @csspart drawer - The drawer container 21 + * @csspart header - Header section 22 + * @csspart body - Body section 23 + * @csspart footer - Footer section 24 + * @csspart backdrop - Modal backdrop 25 + * 26 + * @fires open - When drawer opens 27 + * @fires close - When drawer closes. Detail: { reason } 28 + */ 29 + 30 + import { html, css, nothing } from 'lit'; 31 + import { PeekElement, sharedStyles } from './base.js'; 32 + 33 + export class PeekDrawer extends PeekElement { 34 + static properties = { 35 + open: { type: Boolean, reflect: true }, 36 + position: { type: String, reflect: true }, 37 + size: { type: String }, 38 + modal: { type: Boolean }, 39 + closeOnBackdrop: { type: Boolean, attribute: 'close-on-backdrop' }, 40 + closeOnEscape: { type: Boolean, attribute: 'close-on-escape' }, 41 + contained: { type: Boolean } 42 + }; 43 + 44 + static styles = [ 45 + sharedStyles, 46 + css` 47 + :host { 48 + display: contents; 49 + } 50 + 51 + dialog { 52 + position: fixed; 53 + margin: 0; 54 + padding: 0; 55 + border: none; 56 + background: var(--peek-drawer-bg, var(--theme-bg-secondary, #fff)); 57 + box-shadow: var(--peek-shadow-lg); 58 + max-width: none; 59 + max-height: none; 60 + overflow: hidden; 61 + display: flex; 62 + flex-direction: column; 63 + } 64 + 65 + :host([contained]) dialog { 66 + position: absolute; 67 + } 68 + 69 + /* Position variants */ 70 + :host([position="left"]) dialog, 71 + :host(:not([position])) dialog { 72 + top: 0; 73 + left: 0; 74 + height: 100%; 75 + width: var(--peek-drawer-size, 320px); 76 + border-right: 1px solid var(--peek-drawer-border, var(--theme-border, #e0e0e0)); 77 + } 78 + 79 + :host([position="right"]) dialog { 80 + top: 0; 81 + right: 0; 82 + height: 100%; 83 + width: var(--peek-drawer-size, 320px); 84 + border-left: 1px solid var(--peek-drawer-border, var(--theme-border, #e0e0e0)); 85 + } 86 + 87 + :host([position="top"]) dialog { 88 + top: 0; 89 + left: 0; 90 + width: 100%; 91 + height: var(--peek-drawer-size, 320px); 92 + border-bottom: 1px solid var(--peek-drawer-border, var(--theme-border, #e0e0e0)); 93 + } 94 + 95 + :host([position="bottom"]) dialog { 96 + bottom: 0; 97 + left: 0; 98 + width: 100%; 99 + height: var(--peek-drawer-size, 320px); 100 + border-top: 1px solid var(--peek-drawer-border, var(--theme-border, #e0e0e0)); 101 + } 102 + 103 + /* Size presets */ 104 + :host([size="sm"]) { --peek-drawer-size: 240px; } 105 + :host([size="md"]) { --peek-drawer-size: 320px; } 106 + :host([size="lg"]) { --peek-drawer-size: 480px; } 107 + :host([size="full"]) { --peek-drawer-size: 100%; } 108 + 109 + /* Animations */ 110 + dialog { 111 + opacity: 0; 112 + transition: opacity var(--peek-transition-normal), transform var(--peek-transition-normal); 113 + } 114 + 115 + :host([position="left"]) dialog, 116 + :host(:not([position])) dialog { 117 + transform: translateX(-100%); 118 + } 119 + 120 + :host([position="right"]) dialog { 121 + transform: translateX(100%); 122 + } 123 + 124 + :host([position="top"]) dialog { 125 + transform: translateY(-100%); 126 + } 127 + 128 + :host([position="bottom"]) dialog { 129 + transform: translateY(100%); 130 + } 131 + 132 + dialog[open] { 133 + opacity: 1; 134 + transform: translate(0, 0); 135 + } 136 + 137 + /* Backdrop */ 138 + dialog::backdrop { 139 + background: var(--peek-drawer-backdrop, rgba(0, 0, 0, 0.5)); 140 + opacity: 0; 141 + transition: opacity var(--peek-transition-normal); 142 + } 143 + 144 + dialog[open]::backdrop { 145 + opacity: 1; 146 + } 147 + 148 + /* Layout */ 149 + .header { 150 + display: flex; 151 + align-items: center; 152 + gap: var(--peek-space-md); 153 + padding: var(--peek-space-md) var(--peek-space-lg); 154 + border-bottom: 1px solid var(--peek-drawer-border, var(--theme-border, #e0e0e0)); 155 + flex-shrink: 0; 156 + } 157 + 158 + .header-content { 159 + flex: 1; 160 + font-weight: var(--peek-font-semibold); 161 + font-size: var(--peek-font-lg); 162 + } 163 + 164 + .close-btn { 165 + display: flex; 166 + align-items: center; 167 + justify-content: center; 168 + width: 32px; 169 + height: 32px; 170 + padding: 0; 171 + border: none; 172 + background: transparent; 173 + border-radius: var(--peek-radius-sm); 174 + color: var(--theme-text-muted, #999); 175 + cursor: pointer; 176 + transition: background var(--peek-transition-fast), color var(--peek-transition-fast); 177 + } 178 + 179 + .close-btn:hover { 180 + background: var(--theme-bg-tertiary, #f5f5f5); 181 + color: var(--theme-text, #333); 182 + } 183 + 184 + .close-btn:focus-visible { 185 + outline: 2px solid var(--theme-accent, #007aff); 186 + outline-offset: 2px; 187 + } 188 + 189 + .body { 190 + flex: 1; 191 + padding: var(--peek-space-lg); 192 + overflow-y: auto; 193 + } 194 + 195 + .footer { 196 + display: flex; 197 + align-items: center; 198 + gap: var(--peek-space-sm); 199 + padding: var(--peek-space-md) var(--peek-space-lg); 200 + border-top: 1px solid var(--peek-drawer-border, var(--theme-border, #e0e0e0)); 201 + flex-shrink: 0; 202 + } 203 + 204 + .footer:empty { 205 + display: none; 206 + } 207 + 208 + /* Hide header if no content */ 209 + .header:not(:has(slot[name="header"]::slotted(*))) .header-content { 210 + display: none; 211 + } 212 + ` 213 + ]; 214 + 215 + constructor() { 216 + super(); 217 + this.open = false; 218 + this.position = 'left'; 219 + this.size = 'md'; 220 + this.modal = true; 221 + this.closeOnBackdrop = true; 222 + this.closeOnEscape = true; 223 + this.contained = false; 224 + } 225 + 226 + get dialogElement() { 227 + return this.shadowRoot?.querySelector('dialog'); 228 + } 229 + 230 + updated(changedProps) { 231 + if (changedProps.has('open')) { 232 + this.open ? this._openDrawer() : this._closeDrawer(); 233 + } 234 + if (changedProps.has('size') && this.size && !['sm', 'md', 'lg', 'full'].includes(this.size)) { 235 + // Custom size value 236 + this.style.setProperty('--peek-drawer-size', this.size); 237 + } 238 + } 239 + 240 + _openDrawer() { 241 + const dialog = this.dialogElement; 242 + if (!dialog) return; 243 + 244 + if (this.modal) { 245 + dialog.showModal(); 246 + } else { 247 + dialog.show(); 248 + } 249 + 250 + this.emit('open'); 251 + } 252 + 253 + _closeDrawer(reason = 'api') { 254 + const dialog = this.dialogElement; 255 + if (!dialog) return; 256 + 257 + dialog.close(); 258 + this.emit('close', { reason }); 259 + } 260 + 261 + _handleDialogClick(e) { 262 + // Check if click was on backdrop (dialog itself, not children) 263 + if (e.target === this.dialogElement && this.closeOnBackdrop && this.modal) { 264 + this.open = false; 265 + this._closeDrawer('backdrop'); 266 + } 267 + } 268 + 269 + _handleKeydown(e) { 270 + if (e.key === 'Escape') { 271 + if (this.closeOnEscape) { 272 + e.preventDefault(); 273 + this.open = false; 274 + this._closeDrawer('escape'); 275 + } else { 276 + e.preventDefault(); // Prevent native dialog close 277 + } 278 + } 279 + } 280 + 281 + _handleClose() { 282 + this.open = false; 283 + this._closeDrawer('close'); 284 + } 285 + 286 + _handleCancel(e) { 287 + // Native cancel event (Escape key on dialog) 288 + if (!this.closeOnEscape) { 289 + e.preventDefault(); 290 + } else { 291 + this.open = false; 292 + } 293 + } 294 + 295 + render() { 296 + return html` 297 + <dialog 298 + part="drawer" 299 + @click=${this._handleDialogClick} 300 + @keydown=${this._handleKeydown} 301 + @cancel=${this._handleCancel} 302 + > 303 + <div part="header" class="header"> 304 + <div class="header-content"> 305 + <slot name="header"></slot> 306 + </div> 307 + <button 308 + class="close-btn" 309 + type="button" 310 + aria-label="Close drawer" 311 + @click=${this._handleClose} 312 + > 313 + <svg width="16" height="16" viewBox="0 0 16 16" fill="none"> 314 + <path d="M4 4L12 12M12 4L4 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> 315 + </svg> 316 + </button> 317 + </div> 318 + <div part="body" class="body"> 319 + <slot></slot> 320 + </div> 321 + <div part="footer" class="footer"> 322 + <slot name="footer"></slot> 323 + </div> 324 + </dialog> 325 + `; 326 + } 327 + 328 + // Public API 329 + show() { 330 + this.open = true; 331 + } 332 + 333 + showModal() { 334 + this.modal = true; 335 + this.open = true; 336 + } 337 + 338 + close() { 339 + this.open = false; 340 + this._closeDrawer('api'); 341 + } 342 + } 343 + 344 + customElements.define('peek-drawer', PeekDrawer); 345 + export default PeekDrawer;
+409
app/components/peek-dropdown.js
··· 1 + /** 2 + * Peek Dropdown Component 3 + * 4 + * Action menu / context menu using native Popover API. 5 + * 6 + * @element peek-dropdown 7 + * 8 + * @prop {boolean} open - Whether dropdown is open 9 + * @prop {string} position - 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' 10 + * @prop {boolean} disabled - Disable the trigger 11 + * 12 + * @slot trigger - Element that triggers the dropdown 13 + * @slot - Menu content (typically peek-dropdown-item elements) 14 + * 15 + * @csspart dropdown - The dropdown container 16 + * 17 + * @fires open - When dropdown opens 18 + * @fires close - When dropdown closes 19 + * @fires select - When item is selected. Detail: { value, item } 20 + */ 21 + 22 + import { html, css } from 'lit'; 23 + import { PeekElement, sharedStyles } from './base.js'; 24 + 25 + let dropdownIdCounter = 0; 26 + 27 + export class PeekDropdown extends PeekElement { 28 + static properties = { 29 + open: { type: Boolean, reflect: true }, 30 + position: { type: String }, 31 + disabled: { type: Boolean, reflect: true }, 32 + _highlightedIndex: { type: Number, state: true } 33 + }; 34 + 35 + static styles = [ 36 + sharedStyles, 37 + css` 38 + :host { 39 + display: inline-block; 40 + position: relative; 41 + } 42 + 43 + .trigger-wrapper { 44 + display: contents; 45 + } 46 + 47 + .dropdown { 48 + margin: 0; 49 + padding: var(--peek-space-xs) 0; 50 + border: 1px solid var(--peek-dropdown-border, var(--theme-border, #e0e0e0)); 51 + border-radius: var(--peek-radius-md); 52 + background: var(--peek-dropdown-bg, var(--theme-bg-secondary, #fff)); 53 + box-shadow: var(--peek-shadow-lg); 54 + min-width: var(--peek-dropdown-min-width, 160px); 55 + max-height: var(--peek-dropdown-max-height, 320px); 56 + overflow-y: auto; 57 + position: absolute; 58 + inset: unset; 59 + } 60 + 61 + :host([position="bottom-start"]) .dropdown, 62 + :host(:not([position])) .dropdown { 63 + top: 100%; 64 + left: 0; 65 + margin-top: var(--peek-space-xs); 66 + } 67 + 68 + :host([position="bottom-end"]) .dropdown { 69 + top: 100%; 70 + right: 0; 71 + margin-top: var(--peek-space-xs); 72 + } 73 + 74 + :host([position="top-start"]) .dropdown { 75 + bottom: 100%; 76 + left: 0; 77 + margin-bottom: var(--peek-space-xs); 78 + } 79 + 80 + :host([position="top-end"]) .dropdown { 81 + bottom: 100%; 82 + right: 0; 83 + margin-bottom: var(--peek-space-xs); 84 + } 85 + 86 + .dropdown { opacity: 0; transform: scale(0.95); transition: opacity var(--peek-transition-fast), transform var(--peek-transition-fast); } 87 + .dropdown:popover-open { opacity: 1; transform: scale(1); } 88 + @starting-style { .dropdown:popover-open { opacity: 0; transform: scale(0.95); } } 89 + ` 90 + ]; 91 + 92 + constructor() { 93 + super(); 94 + this._dropdownId = `peek-dropdown-${++dropdownIdCounter}`; 95 + this.open = false; 96 + this.position = 'bottom-start'; 97 + this.disabled = false; 98 + this._highlightedIndex = -1; 99 + } 100 + 101 + get dropdownElement() { return this.shadowRoot?.querySelector('.dropdown'); } 102 + get triggerElement() { 103 + const slot = this.shadowRoot?.querySelector('slot[name="trigger"]'); 104 + return slot?.assignedElements()?.[0] || null; 105 + } 106 + 107 + get items() { 108 + return Array.from(this.querySelectorAll('peek-dropdown-item:not([disabled])')); 109 + } 110 + 111 + firstUpdated() { 112 + this._setupTrigger(); 113 + } 114 + 115 + updated(changedProps) { 116 + if (changedProps.has('open')) { 117 + this.open ? this._openDropdown() : this._closeDropdown(); 118 + } 119 + } 120 + 121 + _setupTrigger() { 122 + const trigger = this.triggerElement; 123 + if (trigger) { 124 + trigger.setAttribute('aria-haspopup', 'menu'); 125 + trigger.setAttribute('aria-expanded', String(this.open)); 126 + trigger.setAttribute('aria-controls', this._dropdownId); 127 + trigger.addEventListener('click', () => this._handleTriggerClick()); 128 + trigger.addEventListener('keydown', (e) => this._handleTriggerKeydown(e)); 129 + } 130 + } 131 + 132 + _handleTriggerClick() { 133 + if (this.disabled) return; 134 + this.open = !this.open; 135 + } 136 + 137 + _handleTriggerKeydown(e) { 138 + if (this.disabled) return; 139 + 140 + switch (e.key) { 141 + case 'Enter': 142 + case ' ': 143 + case 'ArrowDown': 144 + e.preventDefault(); 145 + if (!this.open) { 146 + this.open = true; 147 + requestAnimationFrame(() => { 148 + this._highlightedIndex = 0; 149 + this._focusHighlighted(); 150 + }); 151 + } 152 + break; 153 + case 'ArrowUp': 154 + e.preventDefault(); 155 + if (!this.open) { 156 + this.open = true; 157 + requestAnimationFrame(() => { 158 + this._highlightedIndex = this.items.length - 1; 159 + this._focusHighlighted(); 160 + }); 161 + } 162 + break; 163 + } 164 + } 165 + 166 + _handleDropdownKeydown(e) { 167 + const items = this.items; 168 + if (!items.length) return; 169 + 170 + switch (e.key) { 171 + case 'ArrowDown': 172 + e.preventDefault(); 173 + this._highlightedIndex = (this._highlightedIndex + 1) % items.length; 174 + this._focusHighlighted(); 175 + break; 176 + case 'ArrowUp': 177 + e.preventDefault(); 178 + this._highlightedIndex = this._highlightedIndex <= 0 ? items.length - 1 : this._highlightedIndex - 1; 179 + this._focusHighlighted(); 180 + break; 181 + case 'Home': 182 + e.preventDefault(); 183 + this._highlightedIndex = 0; 184 + this._focusHighlighted(); 185 + break; 186 + case 'End': 187 + e.preventDefault(); 188 + this._highlightedIndex = items.length - 1; 189 + this._focusHighlighted(); 190 + break; 191 + case 'Enter': 192 + case ' ': 193 + e.preventDefault(); 194 + if (this._highlightedIndex >= 0 && items[this._highlightedIndex]) { 195 + this._selectItem(items[this._highlightedIndex]); 196 + } 197 + break; 198 + case 'Escape': 199 + case 'Tab': 200 + this.open = false; 201 + this.triggerElement?.focus(); 202 + break; 203 + } 204 + } 205 + 206 + _focusHighlighted() { 207 + const items = this.items; 208 + if (this._highlightedIndex >= 0 && items[this._highlightedIndex]) { 209 + items[this._highlightedIndex].focus(); 210 + } 211 + } 212 + 213 + _handleToggle(e) { 214 + const isOpen = e.newState === 'open'; 215 + if (this.open !== isOpen) { 216 + this.open = isOpen; 217 + } 218 + if (isOpen) { 219 + this._highlightedIndex = 0; 220 + this.emit('open'); 221 + } else { 222 + this._highlightedIndex = -1; 223 + this.emit('close'); 224 + } 225 + // Update trigger aria 226 + this.triggerElement?.setAttribute('aria-expanded', String(isOpen)); 227 + } 228 + 229 + _openDropdown() { 230 + try { this.dropdownElement?.showPopover(); } catch (e) {} 231 + } 232 + 233 + _closeDropdown() { 234 + try { this.dropdownElement?.hidePopover(); } catch (e) {} 235 + } 236 + 237 + _selectItem(item) { 238 + this.emit('select', { value: item.value, item }); 239 + this.open = false; 240 + this.triggerElement?.focus(); 241 + } 242 + 243 + _handleItemClick(e) { 244 + const item = e.target.closest('peek-dropdown-item'); 245 + if (item && !item.disabled) { 246 + this._selectItem(item); 247 + } 248 + } 249 + 250 + render() { 251 + return html` 252 + <div class="trigger-wrapper"> 253 + <slot name="trigger" @slotchange=${this._setupTrigger}></slot> 254 + </div> 255 + <div 256 + id="${this._dropdownId}" 257 + part="dropdown" 258 + class="dropdown" 259 + role="menu" 260 + popover="auto" 261 + @toggle=${this._handleToggle} 262 + @keydown=${this._handleDropdownKeydown} 263 + @click=${this._handleItemClick} 264 + > 265 + <slot></slot> 266 + </div> 267 + `; 268 + } 269 + 270 + // Public API 271 + show() { this.open = true; } 272 + hide() { this.open = false; } 273 + toggle() { this.open = !this.open; } 274 + } 275 + 276 + /** 277 + * Peek Dropdown Item 278 + * 279 + * @element peek-dropdown-item 280 + * 281 + * @prop {string} value - Item value 282 + * @prop {boolean} disabled - Disable the item 283 + * @prop {boolean} danger - Style as destructive action 284 + * 285 + * @slot prefix - Content before label (icon) 286 + * @slot - Item label 287 + * @slot suffix - Content after label (shortcut) 288 + */ 289 + export class PeekDropdownItem extends PeekElement { 290 + static properties = { 291 + value: { type: String }, 292 + disabled: { type: Boolean, reflect: true }, 293 + danger: { type: Boolean, reflect: true } 294 + }; 295 + 296 + static styles = [ 297 + sharedStyles, 298 + css` 299 + :host { 300 + display: block; 301 + } 302 + 303 + .item { 304 + display: flex; 305 + align-items: center; 306 + gap: var(--peek-space-sm); 307 + padding: var(--peek-space-sm) var(--peek-space-md); 308 + cursor: pointer; 309 + font-size: var(--peek-font-md); 310 + color: var(--theme-text, #333); 311 + outline: none; 312 + transition: background var(--peek-transition-fast); 313 + } 314 + 315 + .item:hover, 316 + .item:focus { 317 + background: var(--theme-bg-tertiary, #f5f5f5); 318 + } 319 + 320 + .item:focus-visible { 321 + outline: 2px solid var(--theme-accent, #007aff); 322 + outline-offset: -2px; 323 + } 324 + 325 + :host([disabled]) .item { 326 + opacity: 0.5; 327 + cursor: not-allowed; 328 + } 329 + 330 + :host([disabled]) .item:hover { 331 + background: transparent; 332 + } 333 + 334 + :host([danger]) .item { 335 + color: var(--theme-danger, #dc3545); 336 + } 337 + 338 + .label { 339 + flex: 1; 340 + } 341 + 342 + ::slotted([slot="prefix"]) { 343 + display: flex; 344 + color: var(--theme-text-muted, #999); 345 + } 346 + 347 + :host([danger]) ::slotted([slot="prefix"]) { 348 + color: var(--theme-danger, #dc3545); 349 + } 350 + 351 + ::slotted([slot="suffix"]) { 352 + font-size: var(--peek-font-sm); 353 + color: var(--theme-text-muted, #999); 354 + } 355 + ` 356 + ]; 357 + 358 + constructor() { 359 + super(); 360 + this.value = ''; 361 + this.disabled = false; 362 + this.danger = false; 363 + } 364 + 365 + render() { 366 + return html` 367 + <div 368 + class="item" 369 + role="menuitem" 370 + tabindex=${this.disabled ? -1 : 0} 371 + aria-disabled=${this.disabled} 372 + > 373 + <slot name="prefix"></slot> 374 + <span class="label"><slot></slot></span> 375 + <slot name="suffix"></slot> 376 + </div> 377 + `; 378 + } 379 + 380 + focus() { 381 + this.shadowRoot?.querySelector('.item')?.focus(); 382 + } 383 + } 384 + 385 + /** 386 + * Peek Dropdown Divider 387 + * 388 + * @element peek-dropdown-divider 389 + */ 390 + export class PeekDropdownDivider extends PeekElement { 391 + static styles = css` 392 + :host { 393 + display: block; 394 + height: 1px; 395 + margin: var(--peek-space-xs) 0; 396 + background: var(--theme-border, #e0e0e0); 397 + } 398 + `; 399 + 400 + render() { 401 + return html``; 402 + } 403 + } 404 + 405 + customElements.define('peek-dropdown', PeekDropdown); 406 + customElements.define('peek-dropdown-item', PeekDropdownItem); 407 + customElements.define('peek-dropdown-divider', PeekDropdownDivider); 408 + 409 + export default PeekDropdown;
+485
app/components/peek-select.js
··· 1 + /** 2 + * Peek Select Component 3 + * 4 + * A select/combobox component with two modes: 5 + * - 'native': Wraps native <select> for maximum accessibility 6 + * - 'custom': Uses Popover API + listbox for custom styling 7 + * 8 + * @element peek-select 9 + * 10 + * @prop {string} value - Current selected value 11 + * @prop {string} placeholder - Placeholder text when no selection 12 + * @prop {boolean} disabled - Disable the select 13 + * @prop {boolean} required - Mark as required 14 + * @prop {boolean} multiple - Allow multiple selection (native mode only) 15 + * @prop {string} mode - 'native' | 'custom' (default: 'native') 16 + * @prop {Array} options - Array of options: strings or { value, label, disabled } 17 + * @prop {string} name - Form field name 18 + * 19 + * @slot prefix - Content before select (custom mode) 20 + * @slot suffix - Content after select (custom mode) 21 + * 22 + * @csspart select - The native select element (native mode) 23 + * @csspart trigger - The trigger button (custom mode) 24 + * @csspart listbox - The options listbox (custom mode) 25 + * @csspart option - Individual option (custom mode) 26 + * 27 + * @fires change - When selection changes. Detail: { value, option } 28 + */ 29 + 30 + import { html, css, nothing } from 'lit'; 31 + import { PeekElement, sharedStyles } from './base.js'; 32 + 33 + let selectIdCounter = 0; 34 + 35 + export class PeekSelect extends PeekElement { 36 + static properties = { 37 + value: { type: String }, 38 + placeholder: { type: String }, 39 + disabled: { type: Boolean, reflect: true }, 40 + required: { type: Boolean }, 41 + multiple: { type: Boolean }, 42 + mode: { type: String, reflect: true }, 43 + options: { type: Array }, 44 + name: { type: String }, 45 + _open: { type: Boolean, state: true }, 46 + _highlightedIndex: { type: Number, state: true } 47 + }; 48 + 49 + static styles = [ 50 + sharedStyles, 51 + css` 52 + :host { 53 + display: inline-block; 54 + min-width: 150px; 55 + } 56 + 57 + /* Native mode styles */ 58 + .native-wrapper { 59 + position: relative; 60 + display: flex; 61 + align-items: center; 62 + } 63 + 64 + select { 65 + width: 100%; 66 + height: var(--peek-select-height, var(--peek-btn-height-md, 36px)); 67 + padding: 0 var(--peek-space-xl) 0 var(--peek-space-md); 68 + font: inherit; 69 + font-size: var(--peek-font-md); 70 + color: var(--theme-text, #333); 71 + background: var(--peek-select-bg, var(--theme-bg-secondary, #fff)); 72 + border: 1px solid var(--peek-select-border, var(--theme-border, #e0e0e0)); 73 + border-radius: var(--peek-radius-md); 74 + cursor: pointer; 75 + appearance: none; 76 + outline: none; 77 + transition: border-color var(--peek-transition-fast), box-shadow var(--peek-transition-fast); 78 + } 79 + 80 + select:focus { 81 + border-color: var(--theme-accent, #007aff); 82 + box-shadow: var(--peek-focus-ring); 83 + } 84 + 85 + select:disabled { 86 + opacity: 0.5; 87 + cursor: not-allowed; 88 + background: var(--theme-bg-tertiary, #f5f5f5); 89 + } 90 + 91 + .native-arrow { 92 + position: absolute; 93 + right: var(--peek-space-md); 94 + pointer-events: none; 95 + color: var(--theme-text-muted, #999); 96 + } 97 + 98 + /* Custom mode styles */ 99 + .custom-wrapper { 100 + position: relative; 101 + } 102 + 103 + .trigger { 104 + display: flex; 105 + align-items: center; 106 + justify-content: space-between; 107 + gap: var(--peek-space-sm); 108 + width: 100%; 109 + height: var(--peek-select-height, var(--peek-btn-height-md, 36px)); 110 + padding: 0 var(--peek-space-md); 111 + font: inherit; 112 + font-size: var(--peek-font-md); 113 + color: var(--theme-text, #333); 114 + background: var(--peek-select-bg, var(--theme-bg-secondary, #fff)); 115 + border: 1px solid var(--peek-select-border, var(--theme-border, #e0e0e0)); 116 + border-radius: var(--peek-radius-md); 117 + cursor: pointer; 118 + outline: none; 119 + text-align: left; 120 + transition: border-color var(--peek-transition-fast), box-shadow var(--peek-transition-fast); 121 + } 122 + 123 + .trigger:focus { 124 + border-color: var(--theme-accent, #007aff); 125 + box-shadow: var(--peek-focus-ring); 126 + } 127 + 128 + .trigger:disabled { 129 + opacity: 0.5; 130 + cursor: not-allowed; 131 + background: var(--theme-bg-tertiary, #f5f5f5); 132 + } 133 + 134 + .trigger-label { 135 + flex: 1; 136 + overflow: hidden; 137 + text-overflow: ellipsis; 138 + white-space: nowrap; 139 + } 140 + 141 + .trigger-label.placeholder { 142 + color: var(--theme-text-muted, #999); 143 + } 144 + 145 + .trigger-arrow { 146 + flex-shrink: 0; 147 + transition: transform var(--peek-transition-fast); 148 + } 149 + 150 + .trigger[aria-expanded="true"] .trigger-arrow { 151 + transform: rotate(180deg); 152 + } 153 + 154 + .listbox { 155 + margin: 0; 156 + padding: var(--peek-space-xs) 0; 157 + border: 1px solid var(--peek-select-border, var(--theme-border, #e0e0e0)); 158 + border-radius: var(--peek-radius-md); 159 + background: var(--peek-select-bg, var(--theme-bg-secondary, #fff)); 160 + box-shadow: var(--peek-shadow-lg); 161 + max-height: 240px; 162 + overflow-y: auto; 163 + min-width: 100%; 164 + position: absolute; 165 + inset: unset; 166 + top: 100%; 167 + left: 0; 168 + margin-top: var(--peek-space-xs); 169 + } 170 + 171 + .listbox { opacity: 0; transform: translateY(-4px); transition: opacity var(--peek-transition-fast), transform var(--peek-transition-fast); } 172 + .listbox:popover-open { opacity: 1; transform: translateY(0); } 173 + @starting-style { .listbox:popover-open { opacity: 0; transform: translateY(-4px); } } 174 + 175 + .option { 176 + display: flex; 177 + align-items: center; 178 + padding: var(--peek-space-sm) var(--peek-space-md); 179 + cursor: pointer; 180 + font-size: var(--peek-font-md); 181 + color: var(--theme-text, #333); 182 + transition: background var(--peek-transition-fast); 183 + } 184 + 185 + .option:hover, 186 + .option.highlighted { 187 + background: var(--theme-bg-tertiary, #f5f5f5); 188 + } 189 + 190 + .option.selected { 191 + color: var(--theme-accent, #007aff); 192 + font-weight: var(--peek-font-medium); 193 + } 194 + 195 + .option.disabled { 196 + opacity: 0.5; 197 + cursor: not-allowed; 198 + } 199 + 200 + .option-check { 201 + width: 16px; 202 + margin-right: var(--peek-space-sm); 203 + color: var(--theme-accent, #007aff); 204 + } 205 + 206 + .option:not(.selected) .option-check { 207 + visibility: hidden; 208 + } 209 + ` 210 + ]; 211 + 212 + constructor() { 213 + super(); 214 + this._selectId = `peek-select-${++selectIdCounter}`; 215 + this.value = ''; 216 + this.placeholder = 'Select...'; 217 + this.disabled = false; 218 + this.required = false; 219 + this.multiple = false; 220 + this.mode = 'native'; 221 + this.options = []; 222 + this.name = ''; 223 + this._open = false; 224 + this._highlightedIndex = -1; 225 + } 226 + 227 + get listboxElement() { return this.shadowRoot?.querySelector('.listbox'); } 228 + get triggerElement() { return this.shadowRoot?.querySelector('.trigger'); } 229 + 230 + get selectedOption() { 231 + return this._normalizedOptions.find(opt => opt.value === this.value); 232 + } 233 + 234 + get _normalizedOptions() { 235 + return this.options.map(opt => { 236 + if (typeof opt === 'string') { 237 + return { value: opt, label: opt, disabled: false }; 238 + } 239 + return { 240 + value: opt.value ?? opt.label ?? '', 241 + label: opt.label ?? opt.value ?? '', 242 + disabled: opt.disabled ?? false 243 + }; 244 + }); 245 + } 246 + 247 + updated(changedProps) { 248 + if (changedProps.has('_open') && this._open) { 249 + this._highlightedIndex = this._normalizedOptions.findIndex(opt => opt.value === this.value); 250 + if (this._highlightedIndex === -1) this._highlightedIndex = 0; 251 + } 252 + } 253 + 254 + _handleNativeChange(e) { 255 + this.value = e.target.value; 256 + const option = this._normalizedOptions.find(opt => opt.value === this.value); 257 + this.emit('change', { value: this.value, option }); 258 + } 259 + 260 + _handleTriggerClick() { 261 + if (this.disabled) return; 262 + this._open ? this._closeListbox() : this._openListbox(); 263 + } 264 + 265 + _handleTriggerKeydown(e) { 266 + if (this.disabled) return; 267 + 268 + switch (e.key) { 269 + case 'Enter': 270 + case ' ': 271 + e.preventDefault(); 272 + this._open ? this._closeListbox() : this._openListbox(); 273 + break; 274 + case 'ArrowDown': 275 + e.preventDefault(); 276 + if (!this._open) { 277 + this._openListbox(); 278 + } else { 279 + this._highlightNext(); 280 + } 281 + break; 282 + case 'ArrowUp': 283 + e.preventDefault(); 284 + if (!this._open) { 285 + this._openListbox(); 286 + } else { 287 + this._highlightPrev(); 288 + } 289 + break; 290 + case 'Home': 291 + if (this._open) { 292 + e.preventDefault(); 293 + this._highlightedIndex = this._findFirstEnabled(); 294 + } 295 + break; 296 + case 'End': 297 + if (this._open) { 298 + e.preventDefault(); 299 + this._highlightedIndex = this._findLastEnabled(); 300 + } 301 + break; 302 + case 'Escape': 303 + if (this._open) { 304 + e.preventDefault(); 305 + this._closeListbox(); 306 + } 307 + break; 308 + case 'Tab': 309 + if (this._open) { 310 + this._closeListbox(); 311 + } 312 + break; 313 + } 314 + } 315 + 316 + _handleListboxToggle(e) { 317 + this._open = e.newState === 'open'; 318 + } 319 + 320 + _openListbox() { 321 + this._open = true; 322 + try { this.listboxElement?.showPopover(); } catch (e) {} 323 + } 324 + 325 + _closeListbox() { 326 + this._open = false; 327 + try { this.listboxElement?.hidePopover(); } catch (e) {} 328 + this.triggerElement?.focus(); 329 + } 330 + 331 + _highlightNext() { 332 + const opts = this._normalizedOptions; 333 + let idx = this._highlightedIndex; 334 + do { 335 + idx = (idx + 1) % opts.length; 336 + } while (opts[idx]?.disabled && idx !== this._highlightedIndex); 337 + this._highlightedIndex = idx; 338 + this._scrollHighlightedIntoView(); 339 + } 340 + 341 + _highlightPrev() { 342 + const opts = this._normalizedOptions; 343 + let idx = this._highlightedIndex; 344 + do { 345 + idx = idx <= 0 ? opts.length - 1 : idx - 1; 346 + } while (opts[idx]?.disabled && idx !== this._highlightedIndex); 347 + this._highlightedIndex = idx; 348 + this._scrollHighlightedIntoView(); 349 + } 350 + 351 + _findFirstEnabled() { 352 + return this._normalizedOptions.findIndex(opt => !opt.disabled); 353 + } 354 + 355 + _findLastEnabled() { 356 + for (let i = this._normalizedOptions.length - 1; i >= 0; i--) { 357 + if (!this._normalizedOptions[i].disabled) return i; 358 + } 359 + return -1; 360 + } 361 + 362 + _scrollHighlightedIntoView() { 363 + requestAnimationFrame(() => { 364 + const highlighted = this.shadowRoot?.querySelector('.option.highlighted'); 365 + highlighted?.scrollIntoView({ block: 'nearest' }); 366 + }); 367 + } 368 + 369 + _selectOption(option, index) { 370 + if (option.disabled) return; 371 + this.value = option.value; 372 + this._closeListbox(); 373 + this.emit('change', { value: this.value, option }); 374 + } 375 + 376 + _renderNative() { 377 + return html` 378 + <div class="native-wrapper"> 379 + <select 380 + part="select" 381 + name=${this.name || nothing} 382 + ?disabled=${this.disabled} 383 + ?required=${this.required} 384 + ?multiple=${this.multiple} 385 + @change=${this._handleNativeChange} 386 + > 387 + ${this.placeholder && !this.value ? html` 388 + <option value="" disabled selected>${this.placeholder}</option> 389 + ` : nothing} 390 + ${this._normalizedOptions.map(opt => html` 391 + <option 392 + value=${opt.value} 393 + ?disabled=${opt.disabled} 394 + ?selected=${opt.value === this.value} 395 + >${opt.label}</option> 396 + `)} 397 + </select> 398 + <svg class="native-arrow" width="12" height="12" viewBox="0 0 12 12" fill="none"> 399 + <path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> 400 + </svg> 401 + </div> 402 + `; 403 + } 404 + 405 + _renderCustom() { 406 + const selectedLabel = this.selectedOption?.label || ''; 407 + const opts = this._normalizedOptions; 408 + 409 + return html` 410 + <div class="custom-wrapper"> 411 + <button 412 + part="trigger" 413 + class="trigger" 414 + type="button" 415 + role="combobox" 416 + aria-haspopup="listbox" 417 + aria-expanded=${this._open} 418 + aria-controls="${this._selectId}-listbox" 419 + ?disabled=${this.disabled} 420 + @click=${this._handleTriggerClick} 421 + @keydown=${this._handleTriggerKeydown} 422 + > 423 + <slot name="prefix"></slot> 424 + <span class="trigger-label ${!selectedLabel ? 'placeholder' : ''}"> 425 + ${selectedLabel || this.placeholder} 426 + </span> 427 + <svg class="trigger-arrow" width="12" height="12" viewBox="0 0 12 12" fill="none"> 428 + <path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> 429 + </svg> 430 + <slot name="suffix"></slot> 431 + </button> 432 + 433 + <div 434 + id="${this._selectId}-listbox" 435 + part="listbox" 436 + class="listbox" 437 + role="listbox" 438 + popover="auto" 439 + @toggle=${this._handleListboxToggle} 440 + > 441 + ${opts.map((opt, i) => html` 442 + <div 443 + part="option" 444 + class="option ${opt.value === this.value ? 'selected' : ''} ${i === this._highlightedIndex ? 'highlighted' : ''} ${opt.disabled ? 'disabled' : ''}" 445 + role="option" 446 + aria-selected=${opt.value === this.value} 447 + aria-disabled=${opt.disabled} 448 + @click=${() => this._selectOption(opt, i)} 449 + @mouseenter=${() => { if (!opt.disabled) this._highlightedIndex = i; }} 450 + > 451 + <svg class="option-check" width="16" height="16" viewBox="0 0 16 16" fill="none"> 452 + <path d="M3 8L6.5 11.5L13 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> 453 + </svg> 454 + ${opt.label} 455 + </div> 456 + `)} 457 + </div> 458 + </div> 459 + `; 460 + } 461 + 462 + render() { 463 + return this.mode === 'native' ? this._renderNative() : this._renderCustom(); 464 + } 465 + 466 + // Public API 467 + focus() { 468 + if (this.mode === 'native') { 469 + this.shadowRoot?.querySelector('select')?.focus(); 470 + } else { 471 + this.triggerElement?.focus(); 472 + } 473 + } 474 + 475 + open() { 476 + if (this.mode === 'custom') this._openListbox(); 477 + } 478 + 479 + close() { 480 + if (this.mode === 'custom') this._closeListbox(); 481 + } 482 + } 483 + 484 + customElements.define('peek-select', PeekSelect); 485 + export default PeekSelect;
+238
app/components/peek-switch.js
··· 1 + /** 2 + * Peek Switch Component 3 + * 4 + * Toggle switch built on native checkbox for accessibility. 5 + * 6 + * @element peek-switch 7 + * 8 + * @prop {boolean} checked - Whether switch is on 9 + * @prop {boolean} disabled - Disable the switch 10 + * @prop {string} name - Form field name 11 + * @prop {string} value - Form field value when checked 12 + * @prop {string} size - 'sm' | 'md' | 'lg' 13 + * 14 + * @slot - Label content 15 + * @slot on - Content shown when on (optional) 16 + * @slot off - Content shown when off (optional) 17 + * 18 + * @csspart switch - The switch track 19 + * @csspart thumb - The switch thumb/knob 20 + * @csspart label - The label wrapper 21 + * 22 + * @fires change - When checked state changes. Detail: { checked } 23 + */ 24 + 25 + import { html, css, nothing } from 'lit'; 26 + import { PeekElement, sharedStyles } from './base.js'; 27 + 28 + export class PeekSwitch extends PeekElement { 29 + static properties = { 30 + checked: { type: Boolean, reflect: true }, 31 + disabled: { type: Boolean, reflect: true }, 32 + name: { type: String }, 33 + value: { type: String }, 34 + size: { type: String, reflect: true } 35 + }; 36 + 37 + static styles = [ 38 + sharedStyles, 39 + css` 40 + :host { 41 + display: inline-flex; 42 + align-items: center; 43 + gap: var(--peek-space-sm); 44 + cursor: pointer; 45 + } 46 + 47 + :host([disabled]) { 48 + opacity: 0.5; 49 + cursor: not-allowed; 50 + } 51 + 52 + .wrapper { 53 + display: inline-flex; 54 + align-items: center; 55 + gap: var(--peek-space-sm); 56 + } 57 + 58 + /* Hidden native checkbox */ 59 + input { 60 + position: absolute; 61 + opacity: 0; 62 + width: 0; 63 + height: 0; 64 + margin: 0; 65 + } 66 + 67 + /* Switch track */ 68 + .switch { 69 + position: relative; 70 + display: inline-flex; 71 + align-items: center; 72 + width: var(--peek-switch-width, 44px); 73 + height: var(--peek-switch-height, 24px); 74 + background: var(--peek-switch-bg, var(--theme-border, #ccc)); 75 + border-radius: var(--peek-switch-radius, 999px); 76 + transition: background var(--peek-transition-normal); 77 + flex-shrink: 0; 78 + } 79 + 80 + /* Size variants */ 81 + :host([size="sm"]) .switch { 82 + --peek-switch-width: 36px; 83 + --peek-switch-height: 20px; 84 + --peek-switch-thumb-size: 16px; 85 + } 86 + 87 + :host([size="lg"]) .switch { 88 + --peek-switch-width: 52px; 89 + --peek-switch-height: 28px; 90 + --peek-switch-thumb-size: 24px; 91 + } 92 + 93 + /* Checked state */ 94 + :host([checked]) .switch { 95 + background: var(--peek-switch-checked-bg, var(--theme-accent, #007aff)); 96 + } 97 + 98 + /* Focus state */ 99 + input:focus-visible + .switch { 100 + outline: 2px solid var(--theme-accent, #007aff); 101 + outline-offset: 2px; 102 + } 103 + 104 + /* Thumb */ 105 + .thumb { 106 + position: absolute; 107 + left: 2px; 108 + width: var(--peek-switch-thumb-size, 20px); 109 + height: var(--peek-switch-thumb-size, 20px); 110 + background: var(--peek-switch-thumb-bg, #fff); 111 + border-radius: 50%; 112 + box-shadow: var(--peek-shadow-sm); 113 + transition: transform var(--peek-transition-normal); 114 + } 115 + 116 + :host([checked]) .thumb { 117 + transform: translateX(calc(var(--peek-switch-width, 44px) - var(--peek-switch-thumb-size, 20px) - 4px)); 118 + } 119 + 120 + /* Label */ 121 + .label { 122 + font-size: var(--peek-font-md); 123 + color: var(--theme-text, #333); 124 + user-select: none; 125 + } 126 + 127 + :host([disabled]) .label { 128 + color: var(--theme-text-muted, #999); 129 + } 130 + 131 + /* On/Off slots inside switch */ 132 + .switch-content { 133 + position: absolute; 134 + display: flex; 135 + align-items: center; 136 + justify-content: center; 137 + font-size: 10px; 138 + font-weight: var(--peek-font-medium); 139 + color: #fff; 140 + opacity: 0; 141 + transition: opacity var(--peek-transition-fast); 142 + } 143 + 144 + .switch-on { 145 + left: 6px; 146 + } 147 + 148 + .switch-off { 149 + right: 6px; 150 + color: var(--theme-text-muted, #999); 151 + } 152 + 153 + :host([checked]) .switch-on { 154 + opacity: 1; 155 + } 156 + 157 + :host(:not([checked])) .switch-off { 158 + opacity: 1; 159 + } 160 + ` 161 + ]; 162 + 163 + constructor() { 164 + super(); 165 + this.checked = false; 166 + this.disabled = false; 167 + this.name = ''; 168 + this.value = 'on'; 169 + this.size = 'md'; 170 + } 171 + 172 + get inputElement() { 173 + return this.shadowRoot?.querySelector('input'); 174 + } 175 + 176 + _handleChange(e) { 177 + if (this.disabled) return; 178 + this.checked = e.target.checked; 179 + this.emit('change', { checked: this.checked }); 180 + } 181 + 182 + _handleClick(e) { 183 + // Allow clicking on the whole component 184 + if (this.disabled) return; 185 + if (e.target !== this.inputElement) { 186 + this.checked = !this.checked; 187 + this.emit('change', { checked: this.checked }); 188 + } 189 + } 190 + 191 + _handleKeydown(e) { 192 + if (this.disabled) return; 193 + if (e.key === ' ' || e.key === 'Enter') { 194 + e.preventDefault(); 195 + this.checked = !this.checked; 196 + this.emit('change', { checked: this.checked }); 197 + } 198 + } 199 + 200 + render() { 201 + return html` 202 + <label class="wrapper" @click=${this._handleClick}> 203 + <input 204 + type="checkbox" 205 + role="switch" 206 + name=${this.name || nothing} 207 + value=${this.value} 208 + .checked=${this.checked} 209 + ?disabled=${this.disabled} 210 + aria-checked=${this.checked} 211 + @change=${this._handleChange} 212 + @keydown=${this._handleKeydown} 213 + > 214 + <span part="switch" class="switch"> 215 + <span class="switch-content switch-on"><slot name="on"></slot></span> 216 + <span class="switch-content switch-off"><slot name="off"></slot></span> 217 + <span part="thumb" class="thumb"></span> 218 + </span> 219 + <span part="label" class="label"><slot></slot></span> 220 + </label> 221 + `; 222 + } 223 + 224 + // Public API 225 + focus() { 226 + this.inputElement?.focus(); 227 + } 228 + 229 + toggle() { 230 + if (!this.disabled) { 231 + this.checked = !this.checked; 232 + this.emit('change', { checked: this.checked }); 233 + } 234 + } 235 + } 236 + 237 + customElements.define('peek-switch', PeekSwitch); 238 + export default PeekSwitch;
+267
app/components/peek-tooltip.js
··· 1 + /** 2 + * Peek Tooltip Component 3 + * 4 + * Hover-triggered tooltip using native Popover API. 5 + * Simpler than peek-popover - just displays text hints. 6 + * 7 + * @element peek-tooltip 8 + * 9 + * @prop {string} content - Tooltip text content 10 + * @prop {string} position - 'top' | 'bottom' | 'left' | 'right' 11 + * @prop {number} delay - Delay before showing (ms) 12 + * @prop {boolean} disabled - Disable the tooltip 13 + * 14 + * @slot - Target element to attach tooltip to 15 + * 16 + * @csspart tooltip - The tooltip container 17 + */ 18 + 19 + import { html, css } from 'lit'; 20 + import { PeekElement, sharedStyles } from './base.js'; 21 + 22 + let tooltipIdCounter = 0; 23 + 24 + export class PeekTooltip extends PeekElement { 25 + static properties = { 26 + content: { type: String }, 27 + position: { type: String, reflect: true }, 28 + delay: { type: Number }, 29 + disabled: { type: Boolean }, 30 + _visible: { type: Boolean, state: true } 31 + }; 32 + 33 + static styles = [ 34 + sharedStyles, 35 + css` 36 + :host { 37 + display: inline-block; 38 + position: relative; 39 + } 40 + 41 + .target { 42 + display: contents; 43 + } 44 + 45 + .tooltip { 46 + margin: 0; 47 + padding: var(--peek-tooltip-padding, var(--peek-space-xs) var(--peek-space-sm)); 48 + border: none; 49 + border-radius: var(--peek-radius-sm); 50 + background: var(--peek-tooltip-bg, var(--theme-text, #333)); 51 + color: var(--peek-tooltip-text, #fff); 52 + font-size: var(--peek-font-sm); 53 + line-height: 1.4; 54 + white-space: nowrap; 55 + max-width: var(--peek-tooltip-max-width, 250px); 56 + overflow-wrap: break-word; 57 + white-space: normal; 58 + box-shadow: var(--peek-shadow-md); 59 + z-index: 10000; 60 + position: absolute; 61 + inset: unset; 62 + pointer-events: none; 63 + } 64 + 65 + /* Arrow */ 66 + .tooltip::after { 67 + content: ''; 68 + position: absolute; 69 + border: 5px solid transparent; 70 + } 71 + 72 + /* Position: top (default) */ 73 + :host([position="top"]) .tooltip, 74 + :host(:not([position])) .tooltip { 75 + bottom: 100%; 76 + left: 50%; 77 + transform: translateX(-50%); 78 + margin-bottom: 8px; 79 + } 80 + 81 + :host([position="top"]) .tooltip::after, 82 + :host(:not([position])) .tooltip::after { 83 + top: 100%; 84 + left: 50%; 85 + transform: translateX(-50%); 86 + border-top-color: var(--peek-tooltip-bg, var(--theme-text, #333)); 87 + } 88 + 89 + /* Position: bottom */ 90 + :host([position="bottom"]) .tooltip { 91 + top: 100%; 92 + left: 50%; 93 + transform: translateX(-50%); 94 + margin-top: 8px; 95 + } 96 + 97 + :host([position="bottom"]) .tooltip::after { 98 + bottom: 100%; 99 + left: 50%; 100 + transform: translateX(-50%); 101 + border-bottom-color: var(--peek-tooltip-bg, var(--theme-text, #333)); 102 + } 103 + 104 + /* Position: left */ 105 + :host([position="left"]) .tooltip { 106 + right: 100%; 107 + top: 50%; 108 + transform: translateY(-50%); 109 + margin-right: 8px; 110 + } 111 + 112 + :host([position="left"]) .tooltip::after { 113 + left: 100%; 114 + top: 50%; 115 + transform: translateY(-50%); 116 + border-left-color: var(--peek-tooltip-bg, var(--theme-text, #333)); 117 + } 118 + 119 + /* Position: right */ 120 + :host([position="right"]) .tooltip { 121 + left: 100%; 122 + top: 50%; 123 + transform: translateY(-50%); 124 + margin-left: 8px; 125 + } 126 + 127 + :host([position="right"]) .tooltip::after { 128 + right: 100%; 129 + top: 50%; 130 + transform: translateY(-50%); 131 + border-right-color: var(--peek-tooltip-bg, var(--theme-text, #333)); 132 + } 133 + 134 + /* Animation */ 135 + .tooltip { 136 + opacity: 0; 137 + transition: opacity var(--peek-transition-fast); 138 + } 139 + 140 + .tooltip:popover-open { 141 + opacity: 1; 142 + } 143 + 144 + @starting-style { 145 + .tooltip:popover-open { 146 + opacity: 0; 147 + } 148 + } 149 + ` 150 + ]; 151 + 152 + constructor() { 153 + super(); 154 + this._tooltipId = `peek-tooltip-${++tooltipIdCounter}`; 155 + this.content = ''; 156 + this.position = 'top'; 157 + this.delay = 200; 158 + this.disabled = false; 159 + this._visible = false; 160 + this._showTimeout = null; 161 + this._hideTimeout = null; 162 + } 163 + 164 + get tooltipElement() { 165 + return this.shadowRoot?.querySelector('.tooltip'); 166 + } 167 + 168 + disconnectedCallback() { 169 + super.disconnectedCallback(); 170 + this._clearTimeouts(); 171 + } 172 + 173 + _clearTimeouts() { 174 + if (this._showTimeout) { 175 + clearTimeout(this._showTimeout); 176 + this._showTimeout = null; 177 + } 178 + if (this._hideTimeout) { 179 + clearTimeout(this._hideTimeout); 180 + this._hideTimeout = null; 181 + } 182 + } 183 + 184 + _handleMouseEnter() { 185 + if (this.disabled || !this.content) return; 186 + 187 + this._clearTimeouts(); 188 + this._showTimeout = setTimeout(() => { 189 + this._show(); 190 + }, this.delay); 191 + } 192 + 193 + _handleMouseLeave() { 194 + this._clearTimeouts(); 195 + this._hideTimeout = setTimeout(() => { 196 + this._hide(); 197 + }, 100); 198 + } 199 + 200 + _handleFocusIn() { 201 + if (this.disabled || !this.content) return; 202 + this._clearTimeouts(); 203 + this._show(); 204 + } 205 + 206 + _handleFocusOut() { 207 + this._clearTimeouts(); 208 + this._hide(); 209 + } 210 + 211 + _show() { 212 + this._visible = true; 213 + try { 214 + this.tooltipElement?.showPopover(); 215 + } catch (e) {} 216 + } 217 + 218 + _hide() { 219 + this._visible = false; 220 + try { 221 + this.tooltipElement?.hidePopover(); 222 + } catch (e) {} 223 + } 224 + 225 + _handleKeydown(e) { 226 + if (e.key === 'Escape' && this._visible) { 227 + this._hide(); 228 + } 229 + } 230 + 231 + render() { 232 + return html` 233 + <div 234 + class="target" 235 + @mouseenter=${this._handleMouseEnter} 236 + @mouseleave=${this._handleMouseLeave} 237 + @focusin=${this._handleFocusIn} 238 + @focusout=${this._handleFocusOut} 239 + @keydown=${this._handleKeydown} 240 + aria-describedby=${this._tooltipId} 241 + > 242 + <slot></slot> 243 + </div> 244 + <div 245 + id=${this._tooltipId} 246 + part="tooltip" 247 + class="tooltip" 248 + role="tooltip" 249 + popover="manual" 250 + >${this.content}</div> 251 + `; 252 + } 253 + 254 + // Public API 255 + show() { 256 + this._clearTimeouts(); 257 + this._show(); 258 + } 259 + 260 + hide() { 261 + this._clearTimeouts(); 262 + this._hide(); 263 + } 264 + } 265 + 266 + customElements.define('peek-tooltip', PeekTooltip); 267 + export default PeekTooltip;
+18 -5
notes/research-ui-componentry.md
··· 607 607 3. Grid with responsive CSS Grid 608 608 4. Dialog/modal with native `<dialog>` 609 609 610 - **Phase 4: Shoelace Integration** 611 - 1. Review which Shoelace components to bundle 612 - 2. Bundle minimal subset (button, input, select, etc.) 613 - 3. Create extension loader/theming system 614 - 4. Documentation for extension developers 610 + **Phase 4: Component Completion & Extension System** 611 + 1. Build remaining components (native-first, Lit-based): 612 + - `peek-select` / `peek-combobox` - full select with native `<select>` or listbox 613 + - `peek-dropdown` - action menus, context menus (using Popover API) 614 + - `peek-switch` - toggle switch (native checkbox-based) 615 + - `peek-drawer` - slide-out sidebar/panel (using native `<dialog>`) 616 + - `peek-tooltip` - hover-triggered hints (using Popover API) 617 + - `peek-button-group` - segmented controls, tag sets 618 + 2. Create extension loader/theming system: 619 + - Theme registration API 620 + - Dynamic theme switching 621 + - Token inheritance (extensions extend base tokens) 622 + - CSS injection helpers for content scripts 623 + 3. Documentation for extension developers: 624 + - Component API reference 625 + - Extension integration guide 626 + - Theming/customization patterns 627 + - Usage examples 615 628 616 629 **Phase 5: Extension Distribution** 617 630 1. Module Federation or shared `.js` bundle