experiments in a post-browser web
10
fork

Configure Feed

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

feat(components): Native/Open UI aligned components - popover, tabs, details

+506 -3
+246 -3
app/components/README.md
··· 1 1 # Peek UI Components 2 2 3 - A lightweight, themeable web component library built on [Lit.js](https://lit.dev/). Components use Shadow DOM for style encapsulation and CSS custom properties for theming. 3 + A lightweight, themeable web component library with a "native-first" approach. Uses [Lit.js](https://lit.dev/) minimally for reactive rendering while maximizing native browser APIs and following [Open UI](https://open-ui.org/) specifications. 4 + 5 + **Native APIs Used:** 6 + - `<dialog>` - Dialogs 7 + - `<details>`/`<summary>` - Disclosure/accordion 8 + - Popover API - Floating content, dropdowns 9 + - CSS scroll-snap - Carousels 10 + - ARIA patterns - Accessibility 4 11 5 12 ## Quick Start 6 13 ··· 775 782 776 783 --- 777 784 785 + ## Native/Open UI Components 786 + 787 + ### `<peek-popover>` 788 + 789 + Native Popover API wrapper for tooltips, dropdowns, and floating content. 790 + 791 + #### Properties 792 + 793 + | Property | Type | Default | Description | 794 + |----------|------|---------|-------------| 795 + | `mode` | `'auto' \| 'manual'` | `'auto'` | 'auto' enables light-dismiss (click outside closes) | 796 + | `open` | `boolean` | `false` | Whether popover is open | 797 + | `position` | `'top' \| 'bottom' \| 'left' \| 'right'` | `'bottom'` | Position relative to trigger | 798 + | `offset` | `number` | `8` | Offset from anchor (px) | 799 + 800 + #### Slots 801 + 802 + | Slot | Description | 803 + |------|-------------| 804 + | `trigger` | Element that triggers the popover (auto-wired) | 805 + | (default) | Popover content | 806 + 807 + #### CSS Parts 808 + 809 + | Part | Description | 810 + |------|-------------| 811 + | `popover` | The popover container | 812 + 813 + #### Events 814 + 815 + | Event | Detail | Description | 816 + |-------|--------|-------------| 817 + | `toggle` | `{ open }` | When open state changes | 818 + 819 + #### Methods 820 + 821 + | Method | Description | 822 + |--------|-------------| 823 + | `show()` | Open the popover | 824 + | `hide()` | Close the popover | 825 + | `toggle()` | Toggle open state | 826 + 827 + #### Example 828 + 829 + ```html 830 + <!-- Basic popover --> 831 + <peek-popover> 832 + <peek-button slot="trigger">Open Menu</peek-button> 833 + <div>Popover content here</div> 834 + </peek-popover> 835 + 836 + <!-- Tooltip-style (top position) --> 837 + <peek-popover position="top" offset="4"> 838 + <span slot="trigger">Hover target</span> 839 + <span>Tooltip text</span> 840 + </peek-popover> 841 + 842 + <!-- Manual control (no light-dismiss) --> 843 + <peek-popover mode="manual" id="menuPopover"> 844 + <peek-button slot="trigger">Settings</peek-button> 845 + <peek-list> 846 + <peek-list-item @click=${() => menuPopover.hide()}>Option 1</peek-list-item> 847 + <peek-list-item @click=${() => menuPopover.hide()}>Option 2</peek-list-item> 848 + </peek-list> 849 + </peek-popover> 850 + ``` 851 + 852 + --- 853 + 854 + ### `<peek-tabs>`, `<peek-tab>`, `<peek-tab-panel>` 855 + 856 + Accessible tabs following Open UI tablist/tab/tabpanel pattern with full ARIA support. 857 + 858 + #### `<peek-tabs>` Properties 859 + 860 + | Property | Type | Default | Description | 861 + |----------|------|---------|-------------| 862 + | `selected` | `number` | `0` | Selected tab index | 863 + | `activation` | `'auto' \| 'manual'` | `'auto'` | 'auto' selects on arrow keys, 'manual' requires Enter | 864 + 865 + #### `<peek-tab>` Properties 866 + 867 + | Property | Type | Default | Description | 868 + |----------|------|---------|-------------| 869 + | `selected` | `boolean` | `false` | Whether tab is selected (managed by parent) | 870 + | `disabled` | `boolean` | `false` | Disable this tab | 871 + 872 + #### Slots 873 + 874 + **`<peek-tabs>`:** Contains `<peek-tab>` elements (in tablist) and `<peek-tab-panel>` elements 875 + 876 + **`<peek-tab>`:** Tab label content 877 + 878 + **`<peek-tab-panel>`:** Panel content 879 + 880 + #### CSS Parts 881 + 882 + | Part | Element | Description | 883 + |------|---------|-------------| 884 + | `tablist` | `<peek-tabs>` | The tablist container | 885 + | `tab` | `<peek-tab>` | Individual tab button | 886 + | `panel` | `<peek-tab-panel>` | Tab panel container | 887 + 888 + #### Events 889 + 890 + | Event | Detail | Description | 891 + |-------|--------|-------------| 892 + | `tab-change` | `{ index, tab, panel }` | When selected tab changes | 893 + 894 + #### Keyboard Navigation 895 + 896 + | Key | Action | 897 + |-----|--------| 898 + | `ArrowLeft` / `ArrowUp` | Previous tab | 899 + | `ArrowRight` / `ArrowDown` | Next tab | 900 + | `Home` | First tab | 901 + | `End` | Last tab | 902 + 903 + #### Methods 904 + 905 + | Method | Description | 906 + |--------|-------------| 907 + | `select(index)` | Select tab by index | 908 + 909 + #### Example 910 + 911 + ```html 912 + <peek-tabs @tab-change=${handleTabChange}> 913 + <peek-tab>General</peek-tab> 914 + <peek-tab>Advanced</peek-tab> 915 + <peek-tab disabled>Locked</peek-tab> 916 + 917 + <peek-tab-panel> 918 + <p>General settings content</p> 919 + </peek-tab-panel> 920 + <peek-tab-panel> 921 + <p>Advanced settings content</p> 922 + </peek-tab-panel> 923 + <peek-tab-panel> 924 + <p>This panel is not accessible</p> 925 + </peek-tab-panel> 926 + </peek-tabs> 927 + 928 + <!-- Manual activation (requires Enter to select) --> 929 + <peek-tabs activation="manual"> 930 + <peek-tab>Tab 1</peek-tab> 931 + <peek-tab>Tab 2</peek-tab> 932 + <peek-tab-panel>Content 1</peek-tab-panel> 933 + <peek-tab-panel>Content 2</peek-tab-panel> 934 + </peek-tabs> 935 + ``` 936 + 937 + --- 938 + 939 + ### `<peek-details>` 940 + 941 + Native `<details>`/`<summary>` wrapper with styling and accordion support. 942 + 943 + #### Properties 944 + 945 + | Property | Type | Default | Description | 946 + |----------|------|---------|-------------| 947 + | `open` | `boolean` | `false` | Whether expanded | 948 + | `name` | `string` | `null` | Accordion group name (native exclusive behavior) | 949 + 950 + #### Slots 951 + 952 + | Slot | Description | 953 + |------|-------------| 954 + | `summary` | Trigger/header content | 955 + | (default) | Expandable content | 956 + 957 + #### CSS Parts 958 + 959 + | Part | Description | 960 + |------|-------------| 961 + | `details` | The native details element | 962 + | `summary` | The summary/trigger | 963 + | `content` | The expandable content area | 964 + 965 + #### Events 966 + 967 + | Event | Detail | Description | 968 + |-------|--------|-------------| 969 + | `toggle` | `{ open }` | When open state changes | 970 + 971 + #### Methods 972 + 973 + | Method | Description | 974 + |--------|-------------| 975 + | `show()` | Expand the details | 976 + | `hide()` | Collapse the details | 977 + | `toggle()` | Toggle open state | 978 + 979 + #### Example 980 + 981 + ```html 982 + <!-- Single disclosure --> 983 + <peek-details> 984 + <span slot="summary">Click to expand</span> 985 + <p>Hidden content revealed when expanded.</p> 986 + </peek-details> 987 + 988 + <!-- Initially open --> 989 + <peek-details open> 990 + <span slot="summary">Already expanded</span> 991 + <p>This content is visible by default.</p> 992 + </peek-details> 993 + 994 + <!-- Exclusive accordion (native behavior) --> 995 + <peek-details name="faq"> 996 + <span slot="summary">Question 1</span> 997 + <p>Answer 1</p> 998 + </peek-details> 999 + <peek-details name="faq"> 1000 + <span slot="summary">Question 2</span> 1001 + <p>Answer 2</p> 1002 + </peek-details> 1003 + <peek-details name="faq"> 1004 + <span slot="summary">Question 3</span> 1005 + <p>Answer 3</p> 1006 + </peek-details> 1007 + ``` 1008 + 1009 + --- 1010 + 778 1011 ## Browser Support 779 1012 780 - Components use modern CSS features: 1013 + Components use modern CSS and HTML features: 1014 + 1015 + **CSS:** 781 1016 - CSS custom properties 782 1017 - `color-mix()` for color adjustments 783 1018 - `:focus-visible` for keyboard focus styles 784 1019 - CSS Grid and Flexbox 1020 + - CSS scroll-snap (carousels) 1021 + - `@starting-style` for entry animations 785 1022 786 - Supported in all modern browsers (Chrome, Firefox, Safari, Edge). 1023 + **Native APIs:** 1024 + - Popover API (`popover` attribute, `showPopover()`) - Chrome 114+, Safari 17+, Firefox 125+ 1025 + - `<dialog>` element - All modern browsers 1026 + - `<details>`/`<summary>` elements - All modern browsers 1027 + - `name` attribute for exclusive accordions - Chrome 120+, Safari 17.2+ 1028 + 1029 + Supported in all modern browsers (Chrome 120+, Firefox 125+, Safari 17.2+, Edge 120+).
+8
app/components/index.js
··· 46 46 export { PeekGrid, PeekGridItem } from './peek-grid.js'; 47 47 export { PeekDialog } from './peek-dialog.js'; 48 48 49 + // Components - Native/Open UI 50 + export { PeekPopover } from './peek-popover.js'; 51 + export { PeekTabs, PeekTab, PeekTabPanel } from './peek-tabs.js'; 52 + export { PeekDetails } from './peek-details.js'; 53 + 49 54 // Side-effect imports to register all components 50 55 import './peek-button.js'; 51 56 import './peek-card.js'; ··· 54 59 import './peek-input.js'; 55 60 import './peek-grid.js'; 56 61 import './peek-dialog.js'; 62 + import './peek-popover.js'; 63 + import './peek-tabs.js'; 64 + import './peek-details.js';
+48
app/components/peek-details.js
··· 1 + /** 2 + * Peek Details - Native <details>/<summary> wrapper 3 + * 4 + * @element peek-details 5 + * @prop {boolean} open - Whether expanded 6 + * @prop {string} name - Accordion group name (native exclusive) 7 + * @slot summary - Trigger content 8 + * @slot - Expandable content 9 + */ 10 + 11 + import { html, css } from 'lit'; 12 + import { PeekElement, sharedStyles } from './base.js'; 13 + 14 + export class PeekDetails extends PeekElement { 15 + static properties = { open: { type: Boolean, reflect: true }, name: { type: String, reflect: true } }; 16 + static styles = [sharedStyles, css` 17 + :host { display: block; } 18 + details { border: 1px solid var(--peek-details-border, var(--theme-border, #e0e0e0)); border-radius: var(--peek-details-radius, var(--peek-radius-md)); overflow: hidden; } 19 + summary { display: flex; align-items: center; gap: var(--peek-space-sm); padding: var(--peek-details-padding, var(--peek-space-md)); background: var(--theme-bg-secondary, #fff); cursor: pointer; user-select: none; font-weight: var(--peek-font-medium); list-style: none; } 20 + summary::-webkit-details-marker { display: none; } 21 + summary::before { content: ''; width: 0; height: 0; border-left: 5px solid currentColor; border-top: 4px solid transparent; border-bottom: 4px solid transparent; transition: transform var(--peek-transition-fast); } 22 + details[open] summary::before { transform: rotate(90deg); } 23 + summary:hover { background: var(--theme-bg-tertiary, #f5f5f5); } 24 + summary:focus-visible { outline: 2px solid var(--theme-accent); outline-offset: -2px; } 25 + .content { padding: var(--peek-details-padding, var(--peek-space-md)); padding-top: 0; color: var(--theme-text-secondary, #666); } 26 + :host([name]) + :host([name]) { margin-top: -1px; } 27 + :host([name]) details { border-radius: 0; } 28 + :host([name]):first-of-type details { border-top-left-radius: var(--peek-details-radius, var(--peek-radius-md)); border-top-right-radius: var(--peek-details-radius, var(--peek-radius-md)); } 29 + :host([name]):last-of-type details { border-bottom-left-radius: var(--peek-details-radius, var(--peek-radius-md)); border-bottom-right-radius: var(--peek-details-radius, var(--peek-radius-md)); } 30 + `]; 31 + 32 + constructor() { super(); this.open = false; this.name = null; } 33 + _handleToggle(e) { this.open = e.target.open; this.emit('toggle', { open: this.open }); } 34 + 35 + render() { 36 + return html`<details part="details" ?open=${this.open} name=${this.name || ''} @toggle=${this._handleToggle}> 37 + <summary part="summary"><slot name="summary">Details</slot></summary> 38 + <div part="content" class="content"><slot></slot></div> 39 + </details>`; 40 + } 41 + 42 + show() { this.open = true; } 43 + hide() { this.open = false; } 44 + toggle() { this.open = !this.open; } 45 + } 46 + 47 + customElements.define('peek-details', PeekDetails); 48 + export default PeekDetails;
+121
app/components/peek-popover.js
··· 1 + /** 2 + * Peek Popover Component 3 + * 4 + * Native Popover API wrapper for tooltips, dropdowns, and floating content. 5 + * 6 + * @element peek-popover 7 + * 8 + * @prop {string} mode - 'auto' (light-dismiss) | 'manual' 9 + * @prop {boolean} open - Whether popover is open 10 + * @prop {string} position - 'top' | 'bottom' | 'left' | 'right' 11 + * @prop {number} offset - Offset from anchor (px) 12 + * 13 + * @slot trigger - Trigger element (auto-wired with popovertarget) 14 + * @slot - Popover content 15 + * 16 + * @fires toggle - When open state changes 17 + */ 18 + 19 + import { html, css } from 'lit'; 20 + import { PeekElement, sharedStyles } from './base.js'; 21 + 22 + let popoverIdCounter = 0; 23 + 24 + export class PeekPopover extends PeekElement { 25 + static properties = { 26 + mode: { type: String, reflect: true }, 27 + open: { type: Boolean, reflect: true }, 28 + position: { type: String, reflect: true }, 29 + offset: { type: Number } 30 + }; 31 + 32 + static styles = [ 33 + sharedStyles, 34 + css` 35 + :host { display: inline-block; position: relative; } 36 + .trigger-wrapper { display: contents; } 37 + .popover { 38 + margin: 0; 39 + padding: var(--peek-popover-padding, var(--peek-space-md)); 40 + border: 1px solid var(--peek-popover-border, var(--theme-border, #e0e0e0)); 41 + border-radius: var(--peek-radius-lg); 42 + background: var(--peek-popover-bg, var(--theme-bg-secondary, #fff)); 43 + box-shadow: var(--peek-shadow-lg); 44 + max-width: var(--peek-popover-max-width, 320px); 45 + position: absolute; 46 + inset: unset; 47 + } 48 + .popover { opacity: 0; transform: scale(0.95); transition: opacity var(--peek-transition-fast), transform var(--peek-transition-fast); } 49 + .popover:popover-open { opacity: 1; transform: scale(1); } 50 + @starting-style { .popover:popover-open { opacity: 0; transform: scale(0.95); } } 51 + ` 52 + ]; 53 + 54 + constructor() { 55 + super(); 56 + this._popoverId = `peek-popover-${++popoverIdCounter}`; 57 + this.mode = 'auto'; 58 + this.open = false; 59 + this.position = 'bottom'; 60 + this.offset = 8; 61 + } 62 + 63 + get popoverElement() { return this.shadowRoot?.querySelector('.popover'); } 64 + get triggerElement() { 65 + const slot = this.shadowRoot?.querySelector('slot[name="trigger"]'); 66 + return slot?.assignedElements()?.[0] || null; 67 + } 68 + 69 + firstUpdated() { this._setupTrigger(); if (this.open) this.show(); } 70 + updated(changedProps) { if (changedProps.has('open')) this.open ? this.show() : this.hide(); } 71 + 72 + _setupTrigger() { 73 + const trigger = this.triggerElement; 74 + if (trigger) { 75 + trigger.setAttribute('popovertarget', this._popoverId); 76 + trigger.setAttribute('popovertargetaction', 'toggle'); 77 + } 78 + } 79 + 80 + _handleToggle(e) { 81 + const isOpen = e.newState === 'open'; 82 + this.open = isOpen; 83 + if (isOpen) this._updatePosition(); 84 + this.emit('toggle', { open: isOpen }); 85 + } 86 + 87 + _updatePosition() { 88 + const popover = this.popoverElement; 89 + const anchorEl = this.triggerElement || this; 90 + if (!popover || !anchorEl) return; 91 + const anchorRect = anchorEl.getBoundingClientRect(); 92 + const popoverRect = popover.getBoundingClientRect(); 93 + let top, left; 94 + switch (this.position) { 95 + case 'top': top = anchorRect.top - popoverRect.height - this.offset; left = anchorRect.left + (anchorRect.width - popoverRect.width) / 2; break; 96 + case 'bottom': top = anchorRect.bottom + this.offset; left = anchorRect.left + (anchorRect.width - popoverRect.width) / 2; break; 97 + case 'left': top = anchorRect.top + (anchorRect.height - popoverRect.height) / 2; left = anchorRect.left - popoverRect.width - this.offset; break; 98 + case 'right': top = anchorRect.top + (anchorRect.height - popoverRect.height) / 2; left = anchorRect.right + this.offset; break; 99 + default: top = anchorRect.bottom + this.offset; left = anchorRect.left; 100 + } 101 + const vw = window.innerWidth, vh = window.innerHeight; 102 + left = Math.max(8, Math.min(left, vw - popoverRect.width - 8)); 103 + top = Math.max(8, Math.min(top, vh - popoverRect.height - 8)); 104 + popover.style.top = `${top}px`; 105 + popover.style.left = `${left}px`; 106 + } 107 + 108 + render() { 109 + return html` 110 + <div class="trigger-wrapper"><slot name="trigger" @slotchange=${this._setupTrigger}></slot></div> 111 + <div id="${this._popoverId}" part="popover" class="popover" popover=${this.mode} @toggle=${this._handleToggle}><slot></slot></div> 112 + `; 113 + } 114 + 115 + show() { try { this.popoverElement?.showPopover(); } catch (e) {} } 116 + hide() { try { this.popoverElement?.hidePopover(); } catch (e) {} } 117 + toggle() { try { this.popoverElement?.togglePopover(); } catch (e) {} } 118 + } 119 + 120 + customElements.define('peek-popover', PeekPopover); 121 + export default PeekPopover;
+83
app/components/peek-tabs.js
··· 1 + /** 2 + * Peek Tabs - Open UI tablist/tab/tabpanel pattern 3 + * 4 + * @element peek-tabs 5 + * @prop {number} selected - Selected tab index 6 + * @prop {string} activation - 'auto' | 'manual' 7 + */ 8 + 9 + import { html, css } from 'lit'; 10 + import { PeekElement, sharedStyles } from './base.js'; 11 + 12 + let tabsIdCounter = 0; 13 + 14 + export class PeekTabs extends PeekElement { 15 + static properties = { selected: { type: Number, reflect: true }, activation: { type: String } }; 16 + static styles = [sharedStyles, css` 17 + :host { display: block; } 18 + .tablist { display: flex; gap: var(--peek-tabs-gap, var(--peek-space-xs)); border-bottom: 1px solid var(--peek-tabs-border, var(--theme-border, #e0e0e0)); margin-bottom: var(--peek-space-md); } 19 + `]; 20 + 21 + constructor() { super(); this._tabsId = `peek-tabs-${++tabsIdCounter}`; this.selected = 0; this.activation = 'auto'; } 22 + get tabs() { return Array.from(this.querySelectorAll('peek-tab')); } 23 + get panels() { return Array.from(this.querySelectorAll('peek-tab-panel')); } 24 + 25 + firstUpdated() { this._setupTabs(); this._updateSelection(); } 26 + updated(changedProps) { if (changedProps.has('selected')) this._updateSelection(); } 27 + 28 + _setupTabs() { 29 + this.tabs.forEach((tab, i) => { 30 + tab.id = `${this._tabsId}-tab-${i}`; tab._index = i; tab._tabsElement = this; 31 + if (this.panels[i]) { this.panels[i].id = `${this._tabsId}-panel-${i}`; tab.setAttribute('aria-controls', this.panels[i].id); this.panels[i].setAttribute('aria-labelledby', tab.id); } 32 + }); 33 + } 34 + 35 + _updateSelection() { 36 + this.tabs.forEach((tab, i) => { tab.selected = i === this.selected; tab.setAttribute('tabindex', i === this.selected ? '0' : '-1'); }); 37 + this.panels.forEach((panel, i) => { panel.hidden = i !== this.selected; }); 38 + } 39 + 40 + _handleKeydown(e) { 41 + const len = this.tabs.length; let idx = this.selected; 42 + if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); idx = idx > 0 ? idx - 1 : len - 1; } 43 + else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); idx = idx < len - 1 ? idx + 1 : 0; } 44 + else if (e.key === 'Home') { e.preventDefault(); idx = 0; } 45 + else if (e.key === 'End') { e.preventDefault(); idx = len - 1; } 46 + else return; 47 + if (this.activation === 'auto') this._selectTab(idx); 48 + this.tabs[idx]?.focus(); 49 + } 50 + 51 + _selectTab(i) { if (i !== this.selected) { this.selected = i; this.emit('tab-change', { index: i, tab: this.tabs[i], panel: this.panels[i] }); } } 52 + select(i) { this._selectTab(i); } 53 + 54 + render() { 55 + return html`<div part="tablist" class="tablist" role="tablist" @keydown=${this._handleKeydown}><slot @slotchange=${() => { this._setupTabs(); this._updateSelection(); }}></slot></div>`; 56 + } 57 + } 58 + 59 + export class PeekTab extends PeekElement { 60 + static properties = { selected: { type: Boolean, reflect: true }, disabled: { type: Boolean, reflect: true } }; 61 + static styles = [sharedStyles, css` 62 + :host { display: inline-block; } 63 + .tab { display: inline-flex; align-items: center; padding: var(--peek-tab-padding, var(--peek-space-sm) var(--peek-space-md)); background: transparent; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; font: inherit; font-weight: var(--peek-font-medium); color: var(--peek-tab-color, var(--theme-text-secondary, #666)); cursor: pointer; outline: none; } 64 + .tab:hover:not(:disabled) { color: var(--theme-text, #333); } 65 + .tab:focus-visible { outline: 2px solid var(--theme-accent); outline-offset: 2px; } 66 + :host([selected]) .tab { color: var(--theme-accent, #007aff); border-bottom-color: var(--theme-accent, #007aff); } 67 + :host([disabled]) .tab { opacity: 0.5; cursor: not-allowed; } 68 + `]; 69 + constructor() { super(); this.selected = false; this.disabled = false; this._index = 0; this._tabsElement = null; } 70 + _handleClick() { if (!this.disabled) this._tabsElement?._selectTab(this._index); } 71 + render() { return html`<button part="tab" class="tab" role="tab" aria-selected=${this.selected} ?disabled=${this.disabled} @click=${this._handleClick}><slot></slot></button>`; } 72 + focus() { this.shadowRoot?.querySelector('.tab')?.focus(); } 73 + } 74 + 75 + export class PeekTabPanel extends PeekElement { 76 + static styles = [sharedStyles, css`:host { display: block; } :host([hidden]) { display: none; }`]; 77 + render() { return html`<div part="panel" role="tabpanel"><slot></slot></div>`; } 78 + } 79 + 80 + customElements.define('peek-tabs', PeekTabs); 81 + customElements.define('peek-tab', PeekTab); 82 + customElements.define('peek-tab-panel', PeekTabPanel); 83 + export default PeekTabs;