[READ-ONLY] Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
6

Configure Feed

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

Refactor tab bars

Grace Kind (May 31, 2026, 10:56 PM -0500) eb6f2c5e c489f3a9

+376 -269
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.14.141", 3 + "version": "0.14.142", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf build && NODE_ENV=development eleventy --serve",
+9 -9
src/css/style.css
··· 887 887 } 888 888 } 889 889 890 - .tab-bar { 890 + tab-bar { 891 891 display: flex; 892 892 height: 46px; 893 893 } 894 894 895 - .tab-bar .tab-bar-button { 895 + tab-bar .tab-bar-button { 896 896 border: none; 897 897 position: relative; 898 898 background: none; ··· 908 908 justify-content: center; 909 909 } 910 910 911 - .tab-bar .tab-bar-button.active { 911 + tab-bar .tab-bar-button.active { 912 912 color: var(--text-color); 913 913 } 914 914 915 - .tab-bar .tab-bar-button .tab-bar-button-label { 915 + tab-bar .tab-bar-button .tab-bar-button-label { 916 916 display: inline-block; 917 917 min-width: 3em; 918 918 border-bottom: 3px solid transparent; 919 919 padding-bottom: 12px; 920 920 } 921 921 922 - .tab-bar .tab-bar-button.active .tab-bar-button-label { 922 + tab-bar .tab-bar-button.active .tab-bar-button-label { 923 923 border-bottom-color: var(--tab-bar-button-active-color); 924 924 } 925 925 926 926 @media (min-width: 800px) { 927 - .tab-bar .tab-bar-button:hover { 927 + tab-bar .tab-bar-button:hover { 928 928 background-color: var(--post-hover-color); 929 929 } 930 930 } 931 931 932 - .tab-bar-horizontal-scroll-container { 932 + tab-bar:not([full-width]) { 933 933 overflow-x: auto; 934 934 overflow-y: hidden; 935 935 scrollbar-width: none; ··· 7058 7058 cursor: not-allowed; 7059 7059 } 7060 7060 7061 - .tab-bar.tab-bar-full-width { 7061 + tab-bar[full-width] { 7062 7062 width: 100%; 7063 7063 } 7064 7064 7065 - .tab-bar.tab-bar-full-width .tab-bar-button { 7065 + tab-bar[full-width] .tab-bar-button { 7066 7066 flex: 1; 7067 7067 } 7068 7068
+79
src/js/components/tab-bar.js
··· 1 + import { html, render } from "/js/lib/lit-html.js"; 2 + import { Component } from "/js/components/component.js"; 3 + import { classnames } from "/js/utils.js"; 4 + 5 + class TabBar extends Component { 6 + static observedAttributes = ["active-tab", "full-width"]; 7 + 8 + connectedCallback() { 9 + if (this.initialized) return; 10 + this._tabs = this._tabs ?? []; 11 + this.lastScrolledTab = null; 12 + this.render(); 13 + this.initialized = true; 14 + } 15 + 16 + attributeChangedCallback() { 17 + if (!this.initialized) return; 18 + this.render(); 19 + } 20 + 21 + set tabs(tabs) { 22 + this._tabs = tabs ?? []; 23 + if (this.initialized) this.render(); 24 + } 25 + 26 + get tabs() { 27 + return this._tabs; 28 + } 29 + 30 + get activeTab() { 31 + return this.getAttribute("active-tab"); 32 + } 33 + 34 + get fullWidth() { 35 + return this.hasAttribute("full-width"); 36 + } 37 + 38 + render() { 39 + const activeTab = this.activeTab; 40 + render( 41 + html`${this._tabs.map( 42 + (tab) => 43 + html`<button 44 + class=${classnames("tab-bar-button", { 45 + active: activeTab === tab.value, 46 + })} 47 + data-testid="tab-${tab.value}" 48 + @click=${() => 49 + this.dispatchEvent( 50 + new CustomEvent("tab-click", { detail: tab.value }), 51 + )} 52 + > 53 + <span class="tab-bar-button-label">${tab.label}</span> 54 + </button>`, 55 + )}`, 56 + this, 57 + ); 58 + this.scrollActiveIntoView(); 59 + } 60 + 61 + scrollActiveIntoView() { 62 + if (this.fullWidth) return; 63 + const activeTab = this.activeTab; 64 + if (activeTab === this.lastScrolledTab) return; 65 + const activeButton = this.querySelector(".tab-bar-button.active"); 66 + if (!activeButton) return; 67 + const behavior = this.lastScrolledTab === null ? "instant" : "smooth"; 68 + this.lastScrolledTab = activeTab; 69 + requestAnimationFrame(() => { 70 + activeButton.scrollIntoView({ 71 + behavior, 72 + inline: "nearest", 73 + block: "nearest", 74 + }); 75 + }); 76 + } 77 + } 78 + 79 + TabBar.register();
-26
src/js/templates/tabBar.template.js
··· 1 - import { html } from "/js/lib/lit-html.js"; 2 - import { classnames } from "/js/utils.js"; 3 - 4 - export function tabBarTemplate({ 5 - tabs, 6 - activeTab, 7 - onTabClick, 8 - fullWidth = false, 9 - }) { 10 - return html` 11 - <div class=${classnames("tab-bar", { "tab-bar-full-width": fullWidth })}> 12 - ${tabs.map( 13 - (tab) => 14 - html`<button 15 - class=${classnames("tab-bar-button", { 16 - active: activeTab === tab.value, 17 - })} 18 - data-testid="tab-${tab.value}" 19 - @click=${() => onTabClick(tab.value)} 20 - > 21 - <span class="tab-bar-button-label">${tab.label}</span> 22 - </button>`, 23 - )} 24 - </div> 25 - `; 26 - }
+9 -8
src/js/views/hashtag.view.js
··· 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 5 import { auth } from "/js/auth.js"; 6 6 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 7 - import { tabBarTemplate } from "/js/templates/tabBar.template.js"; 7 + import "/js/components/tab-bar.js"; 8 8 import { HASHTAG_FEED_PAGE_SIZE } from "/js/config.js"; 9 9 import { pageEffect } from "/js/router.js"; 10 10 import { Signal } from "/js/signals.js"; ··· 93 93 children: html` <main> 94 94 ${headerTemplate({ 95 95 title: `#${hashtag}`, 96 - bottomItemTemplate: () => 97 - tabBarTemplate({ 98 - tabs: sortOptions, 99 - activeTab: currentSort, 100 - onTabClick: handleTabClick, 101 - fullWidth: true, 102 - }), 96 + bottomItemTemplate: () => html` 97 + <tab-bar 98 + .tabs=${sortOptions} 99 + active-tab=${currentSort} 100 + full-width 101 + @tab-click=${(event) => handleTabClick(event.detail)} 102 + ></tab-bar> 103 + `, 103 104 })} 104 105 ${sortOptions.map((sort) => { 105 106 const feed = dataLayer.derived.$hydratedHashtagFeeds.get(
+9 -38
src/js/views/home.view.js
··· 4 4 import { postFeedTemplate } from "/js/templates/postFeed.template.js"; 5 5 import { headerTemplate } from "/js/templates/header.template.js"; 6 6 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 7 - import { tabBarTemplate } from "/js/templates/tabBar.template.js"; 7 + import "/js/components/tab-bar.js"; 8 8 import { PostSeenObserver } from "/js/postSeenObserver.js"; 9 9 import { FEED_PAGE_SIZE, DISCOVER_FEED_URI } from "/js/config.js"; 10 10 import { bindToPage, pageEffect } from "/js/router.js"; ··· 203 203 leftButton: "menu", 204 204 onClickMenuButton: () => handleMenuClick(), 205 205 bottomItemTemplate: () => html` 206 - <div class="tab-bar-horizontal-scroll-container"> 207 - ${tabBarTemplate({ 208 - tabs: pinnedItems.map((item) => ({ 209 - value: item.uri, 210 - label: item.displayName, 211 - })), 212 - activeTab: currentFeedUri, 213 - onTabClick: handleTabClick, 214 - })} 215 - </div> 206 + <tab-bar 207 + .tabs=${pinnedItems.map((item) => ({ 208 + value: item.uri, 209 + label: item.displayName, 210 + }))} 211 + active-tab=${currentFeedUri} 212 + @tab-click=${(event) => handleTabClick(event.detail)} 213 + ></tab-bar> 216 214 `, 217 215 })} 218 216 <main> ··· 261 259 if (postSeenObserver) { 262 260 postSeenObserver.register(feedItem, postUri, feedContext); 263 261 } 264 - } 265 - }); 266 - }); 267 - 268 - let prevTabScrollFeedUri = null; 269 - 270 - // Scroll to active tab when current feed uri changes 271 - pageEffect(root, () => { 272 - const pinnedItems = dataLayer.derived.$hydratedPinnedItems.get(); 273 - if (!pinnedItems) return; 274 - const currentFeedUri = $currentFeedUri.get(); 275 - if (currentFeedUri === prevTabScrollFeedUri) return; 276 - const behavior = prevTabScrollFeedUri ? "smooth" : "instant"; 277 - prevTabScrollFeedUri = currentFeedUri; 278 - requestAnimationFrame(() => { 279 - const container = root.querySelector( 280 - ".tab-bar-horizontal-scroll-container", 281 - ); 282 - const activeTabButton = container?.querySelector( 283 - ".tab-bar-button.active", 284 - ); 285 - if (activeTabButton) { 286 - activeTabButton.scrollIntoView({ 287 - behavior, 288 - inline: "nearest", 289 - block: "nearest", 290 - }); 291 262 } 292 263 }); 293 264 });
+8 -8
src/js/views/listDetail.view.js
··· 7 7 import { auth } from "/js/auth.js"; 8 8 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 9 9 import { headerTemplate } from "/js/templates/header.template.js"; 10 - import { tabBarTemplate } from "/js/templates/tabBar.template.js"; 10 + import "/js/components/tab-bar.js"; 11 11 import { pinIconTemplate } from "/js/templates/icons/pinIcon.template.js"; 12 12 import { richTextTemplate } from "/js/templates/richText.template.js"; 13 13 import { pageEffect } from "/js/router.js"; ··· 196 196 class="list-detail-tab-bar" 197 197 data-scroll-lock-sticky 198 198 > 199 - ${tabBarTemplate({ 200 - tabs: [ 199 + <tab-bar 200 + .tabs=${[ 201 201 { value: "posts", label: "Posts" }, 202 202 { value: "people", label: "People" }, 203 - ], 204 - activeTab, 205 - onTabClick: (value) => $activeTab.set(value), 206 - fullWidth: true, 207 - })} 203 + ]} 204 + active-tab=${activeTab} 205 + full-width 206 + @tab-click=${(event) => $activeTab.set(event.detail)} 207 + ></tab-bar> 208 208 </div>` 209 209 : ""} 210 210 <div
+10 -9
src/js/views/notifications.view.js
··· 26 26 import { notificationsIconTemplate } from "/js/templates/icons/notificationsIcon.template.js"; 27 27 import { verifiedCheckIconTemplate } from "/js/templates/icons/verifiedCheckIcon.template.js"; 28 28 import { contactsIconTemplate } from "/js/templates/icons/contactsIcon.template.js"; 29 - import { tabBarTemplate } from "/js/templates/tabBar.template.js"; 29 + import "/js/components/tab-bar.js"; 30 30 import { NOTIFICATIONS_PAGE_SIZE } from "/js/config.js"; 31 31 import "/js/components/infinite-scroll-container.js"; 32 32 ··· 762 762 showLoadingSpinner: isLoading, 763 763 leftButton: "menu", 764 764 onClickMenuButton: handleMenuClick, 765 - bottomItemTemplate: () => 766 - tabBarTemplate({ 767 - tabs: [ 765 + bottomItemTemplate: () => html` 766 + <tab-bar 767 + .tabs=${[ 768 768 { value: "all", label: "All" }, 769 769 { value: "mentions", label: "Mentions" }, 770 - ], 771 - activeTab, 772 - onTabClick: handleTabClick, 773 - fullWidth: true, 774 - }), 770 + ]} 771 + active-tab=${activeTab} 772 + full-width 773 + @tab-click=${(event) => handleTabClick(event.detail)} 774 + ></tab-bar> 775 + `, 775 776 })} 776 777 <main> 777 778 <div class="notifications-feed" ?hidden=${activeTab !== "all"}>
+7 -7
src/js/views/profile.view.js
··· 15 15 import { pageEffect } from "/js/router.js"; 16 16 import { AUTHOR_FEED_PAGE_SIZE, BSKY_LABELER_DID } from "/js/config.js"; 17 17 import { showToast } from "/js/toasts.js"; 18 - import { tabBarTemplate } from "/js/templates/tabBar.template.js"; 18 + import "/js/components/tab-bar.js"; 19 19 import { feedGeneratorListItemTemplate } from "/js/templates/feedGeneratorListItem.template.js"; 20 20 import { feedGeneratorListItemSkeletonTemplate } from "/js/templates/feedGeneratorListItemSkeleton.template.js"; 21 21 import { linkToList } from "/js/navigation.js"; ··· 418 418 </div>` 419 419 : html` 420 420 <div class="profile-tab-bar" data-scroll-lock-sticky> 421 - ${tabBarTemplate({ 422 - tabs: [ 421 + <tab-bar 422 + .tabs=${[ 423 423 ...(isLabeler 424 424 ? [{ value: "labeler-settings", label: "Labels" }] 425 425 : []), ··· 427 427 value: feedInfo.feedType, 428 428 label: feedInfo.name, 429 429 })), 430 - ], 431 - activeTab, 432 - onTabClick: handleTabClick, 433 - })} 430 + ]} 431 + active-tab=${activeTab} 432 + @tab-click=${(event) => handleTabClick(event.detail)} 433 + ></tab-bar> 434 434 </div> 435 435 ${isLabeler 436 436 ? html`<div
+18 -15
src/js/views/search.view.js
··· 9 9 import { smallPostTemplate } from "/js/templates/smallPost.template.js"; 10 10 import { pageEffect } from "/js/router.js"; 11 11 import { pinIconTemplate } from "/js/templates/icons/pinIcon.template.js"; 12 - import { tabBarTemplate } from "/js/templates/tabBar.template.js"; 12 + import "/js/components/tab-bar.js"; 13 13 import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; 14 14 15 15 class SearchView extends View { ··· 378 378 ` 379 379 : ""} 380 380 ${showResults 381 - ? tabBarTemplate({ 382 - tabs: [ 383 - { value: "profiles", label: "Profiles" }, 384 - ...(isAuthenticated 385 - ? [ 386 - { value: "posts", label: "Posts" }, 387 - { value: "feeds", label: "Feeds" }, 388 - ] 389 - : []), 390 - ], 391 - activeTab, 392 - onTabClick: handleTabChange, 393 - fullWidth: true, 394 - }) 381 + ? html` 382 + <tab-bar 383 + .tabs=${[ 384 + { value: "profiles", label: "Profiles" }, 385 + ...(isAuthenticated 386 + ? [ 387 + { value: "posts", label: "Posts" }, 388 + { value: "feeds", label: "Feeds" }, 389 + ] 390 + : []), 391 + ]} 392 + active-tab=${activeTab} 393 + full-width 394 + @tab-click=${(event) => 395 + handleTabChange(event.detail)} 396 + ></tab-bar> 397 + ` 395 398 : ""} 396 399 </div> 397 400 `,
+2 -2
tests/e2e/specs/flows/likePost.test.js
··· 38 38 await page.goto(`/profile/${userProfile.did}`); 39 39 40 40 const profileView = page.locator("#profile-view"); 41 - const tabBar = profileView.locator(".tab-bar"); 41 + const tabBar = profileView.locator("tab-bar"); 42 42 await expect(tabBar.locator('[data-testid="tab-likes"]')).toBeVisible({ 43 43 timeout: 10000, 44 44 }); ··· 75 75 await page.goto(`/profile/${userProfile.did}`); 76 76 77 77 const profileView = page.locator("#profile-view"); 78 - const tabBar = profileView.locator(".tab-bar"); 78 + const tabBar = profileView.locator("tab-bar"); 79 79 await expect(tabBar.locator('[data-testid="tab-likes"]')).toBeVisible({ 80 80 timeout: 10000, 81 81 });
+2 -2
tests/e2e/specs/views/notifications.view.test.js
··· 926 926 await page.goto("/notifications"); 927 927 928 928 const view = page.locator("#notifications-view"); 929 - const tabBar = view.locator(".tab-bar"); 929 + const tabBar = view.locator("tab-bar"); 930 930 await expect(tabBar.locator(".tab-bar-button").nth(0)).toContainText( 931 931 "All", 932 932 { timeout: 10000 }, ··· 944 944 await page.goto("/notifications"); 945 945 946 946 const view = page.locator("#notifications-view"); 947 - const tabBar = view.locator(".tab-bar"); 947 + const tabBar = view.locator("tab-bar"); 948 948 await expect(tabBar.locator(".tab-bar-button").nth(0)).toHaveClass( 949 949 /active/, 950 950 { timeout: 10000 },
+18 -18
tests/e2e/specs/views/profile.view.test.js
··· 280 280 await page.goto(`/profile/${otherUser.did}`); 281 281 282 282 const view = page.locator("#profile-view"); 283 - const tabBar = view.locator(".tab-bar"); 283 + const tabBar = view.locator("tab-bar"); 284 284 await expect(tabBar.locator(".tab-bar-button")).toHaveCount(3, { 285 285 timeout: 10000, 286 286 }); ··· 305 305 await page.goto(`/profile/${otherUser.did}`); 306 306 307 307 const view = page.locator("#profile-view"); 308 - const tabBar = view.locator(".tab-bar"); 308 + const tabBar = view.locator("tab-bar"); 309 309 310 310 // Posts tab should be active by default 311 311 await expect(tabBar.locator('[data-testid="tab-posts"]')).toHaveClass( ··· 348 348 await page.goto(`/profile/${userProfile.did}`); 349 349 350 350 const view = page.locator("#profile-view"); 351 - const tabBar = view.locator(".tab-bar"); 351 + const tabBar = view.locator("tab-bar"); 352 352 await expect(tabBar.locator(".tab-bar-button")).toHaveCount(4, { 353 353 timeout: 10000, 354 354 }); ··· 579 579 await expect(view.locator('[data-testid="blocked-badge"]')).toBeVisible({ 580 580 timeout: 10000, 581 581 }); 582 - await expect(view.locator(".tab-bar")).not.toBeVisible(); 582 + await expect(view.locator("tab-bar")).not.toBeVisible(); 583 583 await expect(view.locator(".feed-end-message")).toContainText( 584 584 "Posts hidden", 585 585 ); ··· 1006 1006 await expect(view.locator(".feed-end-message")).toContainText( 1007 1007 "Posts hidden", 1008 1008 ); 1009 - await expect(view.locator(".tab-bar")).not.toBeVisible(); 1009 + await expect(view.locator("tab-bar")).not.toBeVisible(); 1010 1010 await expect( 1011 1011 view.locator('[data-testid="follow-button"]'), 1012 1012 ).not.toBeVisible(); ··· 1478 1478 await page.goto(`/profile/${labelerUser.did}`); 1479 1479 1480 1480 const view = page.locator("#profile-view"); 1481 - const tabBar = view.locator(".tab-bar"); 1481 + const tabBar = view.locator("tab-bar"); 1482 1482 await expect( 1483 1483 tabBar.locator('[data-testid="tab-labeler-settings"]'), 1484 1484 ).toBeVisible({ timeout: 10000 }); ··· 1758 1758 await page.goto(`/profile/${otherUser.did}`); 1759 1759 1760 1760 const view = page.locator("#profile-view"); 1761 - const tabBar = view.locator(".tab-bar"); 1761 + const tabBar = view.locator("tab-bar"); 1762 1762 await expect(tabBar.locator(".tab-bar-button")).toHaveCount(2, { 1763 1763 timeout: 10000, 1764 1764 }); ··· 1915 1915 await page.goto(`/profile/${userWithFeeds.did}`); 1916 1916 1917 1917 const view = page.locator("#profile-view"); 1918 - const tabBar = view.locator(".tab-bar"); 1918 + const tabBar = view.locator("tab-bar"); 1919 1919 await expect(tabBar.locator('[data-testid="tab-feeds"]')).toBeVisible({ 1920 1920 timeout: 10000, 1921 1921 }); ··· 1931 1931 await page.goto(`/profile/${otherUser.did}`); 1932 1932 1933 1933 const view = page.locator("#profile-view"); 1934 - const tabBar = view.locator(".tab-bar"); 1934 + const tabBar = view.locator("tab-bar"); 1935 1935 await expect(tabBar.locator(".tab-bar-button").first()).toBeVisible({ 1936 1936 timeout: 10000, 1937 1937 }); ··· 1951 1951 await page.goto(`/profile/${userWithFeeds.did}`); 1952 1952 1953 1953 const view = page.locator("#profile-view"); 1954 - const tabBar = view.locator(".tab-bar"); 1954 + const tabBar = view.locator("tab-bar"); 1955 1955 await expect(tabBar.locator('[data-testid="tab-feeds"]')).toBeVisible({ 1956 1956 timeout: 10000, 1957 1957 }); ··· 1982 1982 await page.goto(`/profile/${userWithFeeds.did}`); 1983 1983 1984 1984 const view = page.locator("#profile-view"); 1985 - const tabBar = view.locator(".tab-bar"); 1985 + const tabBar = view.locator("tab-bar"); 1986 1986 await expect(tabBar.locator('[data-testid="tab-feeds"]')).toBeVisible({ 1987 1987 timeout: 10000, 1988 1988 }); ··· 2085 2085 await page.goto(`/profile/${userProfile.did}`); 2086 2086 2087 2087 const view = page.locator("#profile-view"); 2088 - const tabBar = view.locator(".tab-bar"); 2088 + const tabBar = view.locator("tab-bar"); 2089 2089 await expect(tabBar.locator('[data-testid="tab-feeds"]')).toBeVisible({ 2090 2090 timeout: 10000, 2091 2091 }); ··· 2100 2100 await page.goto(`/profile/${userProfile.did}`); 2101 2101 2102 2102 const view = page.locator("#profile-view"); 2103 - const tabBar = view.locator(".tab-bar"); 2103 + const tabBar = view.locator("tab-bar"); 2104 2104 await expect(tabBar.locator(".tab-bar-button").first()).toBeVisible({ 2105 2105 timeout: 10000, 2106 2106 }); ··· 2142 2142 await page.goto(`/profile/${userWithLists.did}`); 2143 2143 2144 2144 const view = page.locator("#profile-view"); 2145 - const tabBar = view.locator(".tab-bar"); 2145 + const tabBar = view.locator("tab-bar"); 2146 2146 await expect(tabBar.locator('[data-testid="tab-lists"]')).toBeVisible({ 2147 2147 timeout: 10000, 2148 2148 }); ··· 2158 2158 await page.goto(`/profile/${otherUser.did}`); 2159 2159 2160 2160 const view = page.locator("#profile-view"); 2161 - const tabBar = view.locator(".tab-bar"); 2161 + const tabBar = view.locator("tab-bar"); 2162 2162 await expect(tabBar.locator(".tab-bar-button").first()).toBeVisible({ 2163 2163 timeout: 10000, 2164 2164 }); ··· 2176 2176 await page.goto(`/profile/${userWithLists.did}`); 2177 2177 2178 2178 const view = page.locator("#profile-view"); 2179 - const tabBar = view.locator(".tab-bar"); 2179 + const tabBar = view.locator("tab-bar"); 2180 2180 await expect(tabBar.locator('[data-testid="tab-lists"]')).toBeVisible({ 2181 2181 timeout: 10000, 2182 2182 }); ··· 2211 2211 await page.goto(`/profile/${userWithLists.did}`); 2212 2212 2213 2213 const view = page.locator("#profile-view"); 2214 - const tabBar = view.locator(".tab-bar"); 2214 + const tabBar = view.locator("tab-bar"); 2215 2215 await expect(tabBar.locator('[data-testid="tab-lists"]')).toBeVisible({ 2216 2216 timeout: 10000, 2217 2217 }); ··· 2256 2256 await page.goto(`/profile/${userProfile.did}`); 2257 2257 2258 2258 const view = page.locator("#profile-view"); 2259 - const tabBar = view.locator(".tab-bar"); 2259 + const tabBar = view.locator("tab-bar"); 2260 2260 await expect(tabBar.locator('[data-testid="tab-lists"]')).toBeVisible({ 2261 2261 timeout: 10000, 2262 2262 });
+204
tests/unit/specs/components/tab-bar.test.js
··· 1 + import { TestSuite } from "../../testSuite.js"; 2 + import { assert, assertEquals, mock } from "../../testHelpers.js"; 3 + import "/js/components/tab-bar.js"; 4 + 5 + const t = new TestSuite("TabBar"); 6 + 7 + function waitForAnimationFrame() { 8 + return new Promise((resolve) => setTimeout(resolve, 0)); 9 + } 10 + 11 + const sampleTabs = [ 12 + { value: "one", label: "One" }, 13 + { value: "two", label: "Two" }, 14 + { value: "three", label: "Three" }, 15 + ]; 16 + 17 + function createTabBar({ 18 + tabs = sampleTabs, 19 + activeTab = null, 20 + fullWidth = false, 21 + } = {}) { 22 + const element = document.createElement("tab-bar"); 23 + element.tabs = tabs; 24 + if (activeTab !== null) element.setAttribute("active-tab", activeTab); 25 + if (fullWidth) element.setAttribute("full-width", ""); 26 + return element; 27 + } 28 + 29 + let originalScrollIntoView; 30 + let scrollSpy; 31 + 32 + t.beforeEach(async () => { 33 + document.body.innerHTML = ""; 34 + // Drain any requestAnimationFrame callbacks queued by previous tests 35 + // before installing the spy, so we don't capture stale calls. 36 + await new Promise((resolve) => setTimeout(resolve, 0)); 37 + originalScrollIntoView = 38 + window.HTMLElement.prototype.scrollIntoView ?? function () {}; 39 + scrollSpy = mock(); 40 + window.HTMLElement.prototype.scrollIntoView = function (options) { 41 + scrollSpy(this, options); 42 + }; 43 + }); 44 + 45 + t.afterEach(() => { 46 + window.HTMLElement.prototype.scrollIntoView = originalScrollIntoView; 47 + }); 48 + 49 + t.describe("TabBar - rendering", (it) => { 50 + it("should render a button for each tab", () => { 51 + const element = createTabBar(); 52 + document.body.appendChild(element); 53 + const buttons = element.querySelectorAll(".tab-bar-button"); 54 + assertEquals(buttons.length, 3); 55 + }); 56 + 57 + it("should render tab labels", () => { 58 + const element = createTabBar(); 59 + document.body.appendChild(element); 60 + const buttons = element.querySelectorAll(".tab-bar-button"); 61 + assertEquals(buttons[0].textContent.trim(), "One"); 62 + assertEquals(buttons[1].textContent.trim(), "Two"); 63 + assertEquals(buttons[2].textContent.trim(), "Three"); 64 + }); 65 + 66 + it("should mark the active tab with the active class", () => { 67 + const element = createTabBar({ activeTab: "two" }); 68 + document.body.appendChild(element); 69 + const activeButtons = element.querySelectorAll(".tab-bar-button.active"); 70 + assertEquals(activeButtons.length, 1); 71 + assertEquals(activeButtons[0].textContent.trim(), "Two"); 72 + }); 73 + 74 + it("should re-render when tabs property changes", () => { 75 + const element = createTabBar(); 76 + document.body.appendChild(element); 77 + element.tabs = [{ value: "x", label: "X" }]; 78 + const buttons = element.querySelectorAll(".tab-bar-button"); 79 + assertEquals(buttons.length, 1); 80 + assertEquals(buttons[0].textContent.trim(), "X"); 81 + }); 82 + 83 + it("should re-render when active-tab attribute changes", () => { 84 + const element = createTabBar({ activeTab: "one" }); 85 + document.body.appendChild(element); 86 + element.setAttribute("active-tab", "three"); 87 + const activeButtons = element.querySelectorAll(".tab-bar-button.active"); 88 + assertEquals(activeButtons.length, 1); 89 + assertEquals(activeButtons[0].textContent.trim(), "Three"); 90 + }); 91 + }); 92 + 93 + t.describe("TabBar - tab-click events", (it) => { 94 + it("should dispatch tab-click with the tab value when a button is clicked", () => { 95 + const element = createTabBar(); 96 + document.body.appendChild(element); 97 + const handler = mock(); 98 + element.addEventListener("tab-click", (event) => handler(event.detail)); 99 + element.querySelectorAll(".tab-bar-button")[1].click(); 100 + assertEquals(handler.calls.length, 1); 101 + assertEquals(handler.calls[0][0], "two"); 102 + }); 103 + }); 104 + 105 + t.describe("TabBar - initial scroll", (it) => { 106 + it("should scroll the active tab into view on connect", async () => { 107 + const element = createTabBar({ activeTab: "two" }); 108 + document.body.appendChild(element); 109 + await waitForAnimationFrame(); 110 + assertEquals(scrollSpy.calls.length, 1); 111 + assertEquals(scrollSpy.calls[0][0].textContent.trim(), "Two"); 112 + }); 113 + 114 + it("should use 'instant' behavior on first scroll", async () => { 115 + const element = createTabBar({ activeTab: "two" }); 116 + document.body.appendChild(element); 117 + await waitForAnimationFrame(); 118 + assertEquals(scrollSpy.calls[0][1].behavior, "instant"); 119 + }); 120 + 121 + it("should not scroll if no active tab is present", async () => { 122 + const element = createTabBar(); 123 + document.body.appendChild(element); 124 + await waitForAnimationFrame(); 125 + assertEquals(scrollSpy.calls.length, 0); 126 + }); 127 + }); 128 + 129 + t.describe("TabBar - binding order", (it) => { 130 + it("should scroll instantly when tabs are set after active-tab", async () => { 131 + const element = document.createElement("tab-bar"); 132 + element.setAttribute("active-tab", "two"); 133 + document.body.appendChild(element); 134 + await waitForAnimationFrame(); 135 + assertEquals(scrollSpy.calls.length, 0); 136 + 137 + element.tabs = sampleTabs; 138 + await waitForAnimationFrame(); 139 + 140 + assertEquals(scrollSpy.calls.length, 1); 141 + assertEquals(scrollSpy.calls[0][1].behavior, "instant"); 142 + assertEquals(scrollSpy.calls[0][0].textContent.trim(), "Two"); 143 + }); 144 + }); 145 + 146 + t.describe("TabBar - active-tab attribute changes", (it) => { 147 + it("should scroll the new active tab into view", async () => { 148 + const element = createTabBar({ activeTab: "one" }); 149 + document.body.appendChild(element); 150 + await waitForAnimationFrame(); 151 + scrollSpy.calls.length = 0; 152 + 153 + element.setAttribute("active-tab", "three"); 154 + await waitForAnimationFrame(); 155 + 156 + assertEquals(scrollSpy.calls.length, 1); 157 + assertEquals(scrollSpy.calls[0][0].textContent.trim(), "Three"); 158 + }); 159 + 160 + it("should use 'smooth' behavior on subsequent scrolls", async () => { 161 + const element = createTabBar({ activeTab: "one" }); 162 + document.body.appendChild(element); 163 + await waitForAnimationFrame(); 164 + 165 + element.setAttribute("active-tab", "two"); 166 + await waitForAnimationFrame(); 167 + 168 + const lastCall = scrollSpy.calls[scrollSpy.calls.length - 1]; 169 + assertEquals(lastCall[1].behavior, "smooth"); 170 + }); 171 + }); 172 + 173 + t.describe("TabBar - full-width", (it) => { 174 + it("should not scroll on connect when full-width is set", async () => { 175 + const element = createTabBar({ activeTab: "two", fullWidth: true }); 176 + document.body.appendChild(element); 177 + await waitForAnimationFrame(); 178 + assertEquals(scrollSpy.calls.length, 0); 179 + }); 180 + 181 + it("should not scroll on active-tab change when full-width is set", async () => { 182 + const element = createTabBar({ activeTab: "one", fullWidth: true }); 183 + document.body.appendChild(element); 184 + await waitForAnimationFrame(); 185 + 186 + element.setAttribute("active-tab", "three"); 187 + await waitForAnimationFrame(); 188 + 189 + assertEquals(scrollSpy.calls.length, 0); 190 + }); 191 + }); 192 + 193 + t.describe("TabBar - reinitialization protection", (it) => { 194 + it("should not reinitialize when connectedCallback fires again", () => { 195 + const element = createTabBar({ activeTab: "one" }); 196 + document.body.appendChild(element); 197 + const initialButton = element.querySelector(".tab-bar-button"); 198 + element.connectedCallback(); 199 + const afterButton = element.querySelector(".tab-bar-button"); 200 + assert(initialButton === afterButton); 201 + }); 202 + }); 203 + 204 + await t.run();
-126
tests/unit/specs/templates/tabBar.template.test.js
··· 1 - import { TestSuite } from "../../testSuite.js"; 2 - import { assert, assertEquals, mock } from "../../testHelpers.js"; 3 - import { tabBarTemplate } from "/js/templates/tabBar.template.js"; 4 - import { render } from "/js/lib/lit-html.js"; 5 - 6 - const t = new TestSuite("tabBarTemplate"); 7 - 8 - const tabs = [ 9 - { value: "one", label: "One" }, 10 - { value: "two", label: "Two" }, 11 - { value: "three", label: "Three" }, 12 - ]; 13 - 14 - function renderTemplate(props) { 15 - const container = document.createElement("div"); 16 - render(tabBarTemplate(props), container); 17 - return container; 18 - } 19 - 20 - t.describe("rendering", (it) => { 21 - it("should render a tab-bar container", () => { 22 - const container = renderTemplate({ 23 - tabs, 24 - activeTab: "one", 25 - onTabClick: () => {}, 26 - }); 27 - assert(container.querySelector(".tab-bar") !== null); 28 - }); 29 - 30 - it("should render a button for each tab", () => { 31 - const container = renderTemplate({ 32 - tabs, 33 - activeTab: "one", 34 - onTabClick: () => {}, 35 - }); 36 - const buttons = container.querySelectorAll(".tab-bar-button"); 37 - assertEquals(buttons.length, 3); 38 - }); 39 - 40 - it("should render tab labels", () => { 41 - const container = renderTemplate({ 42 - tabs, 43 - activeTab: "one", 44 - onTabClick: () => {}, 45 - }); 46 - const buttons = container.querySelectorAll(".tab-bar-button"); 47 - assertEquals(buttons[0].textContent.trim(), "One"); 48 - assertEquals(buttons[1].textContent.trim(), "Two"); 49 - assertEquals(buttons[2].textContent.trim(), "Three"); 50 - }); 51 - 52 - it("should render no buttons when tabs is empty", () => { 53 - const container = renderTemplate({ 54 - tabs: [], 55 - activeTab: null, 56 - onTabClick: () => {}, 57 - }); 58 - const buttons = container.querySelectorAll(".tab-bar-button"); 59 - assertEquals(buttons.length, 0); 60 - }); 61 - }); 62 - 63 - t.describe("active state", (it) => { 64 - it("should mark the active tab with the active class", () => { 65 - const container = renderTemplate({ 66 - tabs, 67 - activeTab: "two", 68 - onTabClick: () => {}, 69 - }); 70 - const activeButtons = container.querySelectorAll(".tab-bar-button.active"); 71 - assertEquals(activeButtons.length, 1); 72 - assertEquals(activeButtons[0].textContent.trim(), "Two"); 73 - }); 74 - 75 - it("should not mark inactive tabs as active", () => { 76 - const container = renderTemplate({ 77 - tabs, 78 - activeTab: "one", 79 - onTabClick: () => {}, 80 - }); 81 - const buttons = container.querySelectorAll(".tab-bar-button"); 82 - assert(!buttons[1].classList.contains("active")); 83 - assert(!buttons[2].classList.contains("active")); 84 - }); 85 - 86 - it("should have no active tab when activeTab matches nothing", () => { 87 - const container = renderTemplate({ 88 - tabs, 89 - activeTab: "nonexistent", 90 - onTabClick: () => {}, 91 - }); 92 - const activeButtons = container.querySelectorAll(".tab-bar-button.active"); 93 - assertEquals(activeButtons.length, 0); 94 - }); 95 - }); 96 - 97 - t.describe("interaction", (it) => { 98 - it("should call onTabClick with the tab value when clicked", () => { 99 - const onTabClick = mock(); 100 - const container = renderTemplate({ 101 - tabs, 102 - activeTab: "one", 103 - onTabClick, 104 - }); 105 - const buttons = container.querySelectorAll(".tab-bar-button"); 106 - buttons[1].click(); 107 - assertEquals(onTabClick.calls.length, 1); 108 - assertEquals(onTabClick.calls[0][0], "two"); 109 - }); 110 - 111 - it("should call onTabClick with the correct value for each button", () => { 112 - const onTabClick = mock(); 113 - const container = renderTemplate({ 114 - tabs, 115 - activeTab: "one", 116 - onTabClick, 117 - }); 118 - const buttons = container.querySelectorAll(".tab-bar-button"); 119 - buttons[0].click(); 120 - buttons[2].click(); 121 - assertEquals(onTabClick.calls[0][0], "one"); 122 - assertEquals(onTabClick.calls[1][0], "three"); 123 - }); 124 - }); 125 - 126 - await t.run();