| null = null
-
- private readonly handleAuthSessionChange = () => {
- this.refreshAuthStateFromSession().catch(() => {
- // Keep auth event handling resilient on transient refresh failures.
- })
- }
-
- constructor () {
- super()
- this.logo = DEFAULT_SOLID_ICON_URL
- this.helpIcon = DEFAULT_HELP_MENU_ICON
- this.layout = 'desktop'
- this.theme = 'light'
- this.brandLink = '#'
- this.authState = 'logged-out'
- this.loginAction = { label: 'Log In', action: 'login' }
- this.signUpAction = { label: 'Sign Up', action: 'sign-up', url: DEFAULT_SIGNUP_URL }
- this.accountMenu = []
- this.logoutLabel = 'Log Out'
- this.logoutIcon = ''
- this.accountIcon = '▼'
- this.accountAvatar = ''
- this.accountAvatarFallback = ''
- this.loginIcon = ''
- this.signUpIcon = ''
- this.helpMenuList = []
- this.accountMenuOpen = false
- this.helpMenuOpen = false
- this.hasSlottedAccountMenu = false
- this.hasSlottedHelpMenu = false
- this.authResolved = false
- }
-
- connectedCallback () {
- super.connectedCallback()
- document.addEventListener('click', this.handleDocumentClick)
- window.addEventListener('keydown', this.handleWindowKeydown)
- if (typeof authSession.events?.on === 'function') {
- authSession.events.on('login', this.handleAuthSessionChange)
- authSession.events.on('logout', this.handleAuthSessionChange)
- authSession.events.on('sessionRestore', this.handleAuthSessionChange)
- }
- this.refreshAuthStateFromSession().catch(() => {
- // Keep initial header render resilient on transient refresh failures.
- })
- }
-
- disconnectedCallback () {
- document.removeEventListener('click', this.handleDocumentClick)
- window.removeEventListener('keydown', this.handleWindowKeydown)
- if (typeof authSession.events?.off === 'function') {
- authSession.events.off('login', this.handleAuthSessionChange)
- authSession.events.off('logout', this.handleAuthSessionChange)
- authSession.events.off('sessionRestore', this.handleAuthSessionChange)
- }
- super.disconnectedCallback()
- }
-
- private async refreshAuthStateFromSession () {
- if (!this._refreshPromise) {
- this._refreshPromise = (async () => {
- try {
- await authn.checkUser()
- // Some auth stacks resolve session state asynchronously after first check.
- if (!authn.currentUser()) {
- await authn.checkUser()
- }
- } catch (_err) {
- // Keep rendering even if session refresh cannot complete.
- }
- })()
- }
-
- try {
- await this._refreshPromise
- } finally {
- this._refreshPromise = null
- }
-
- this.authState = authn.currentUser() ? 'logged-in' : 'logged-out'
- this.authResolved = true
- }
-
- private handleHelpMenuClick (item: HeaderMenuItem, event: MouseEvent) {
- event.preventDefault()
- this.helpMenuOpen = false
- this.dispatchEvent(new CustomEvent('help-menu-select', {
- detail: item,
- bubbles: true,
- composed: true
- }))
- if (item.url) {
- const target = item.target || '_blank'
- const features = target === '_blank' ? 'noopener,noreferrer' : undefined
- window.open(item.url, target, features)
- }
- }
-
- private handleAccountMenuClick (item: HeaderAccountMenuItem) {
- this.accountMenuOpen = false
- this.dispatchEvent(new CustomEvent('account-menu-select', {
- detail: item,
- bubbles: true,
- composed: true
- }))
- }
-
- private readonly handleDocumentClick = (event: MouseEvent) => {
- if (!event.composedPath().includes(this)) {
- this.accountMenuOpen = false
- this.helpMenuOpen = false
- }
- }
-
- private readonly handleWindowKeydown = (event: KeyboardEvent) => {
- if (event.key === 'Escape' && (this.accountMenuOpen || this.helpMenuOpen)) {
- this.accountMenuOpen = false
- this.helpMenuOpen = false
- }
- }
-
- private handleAccountSlotChange (event: Event) {
- const slot = event.target as HTMLSlotElement
- this.hasSlottedAccountMenu = slot.assignedElements({ flatten: true }).length > 0
- }
-
- private handleHelpSlotChange (event: Event) {
- const slot = event.target as HTMLSlotElement
- this.hasSlottedHelpMenu = slot.assignedElements({ flatten: true }).length > 0
- }
-
- private toggleAccountMenu (event: MouseEvent) {
- event.preventDefault()
- if (!this.hasAccountMenuItems()) return
- this.helpMenuOpen = false
- this.accountMenuOpen = !this.accountMenuOpen
- }
-
- private toggleHelpMenu (event: MouseEvent) {
- event.preventDefault()
- if (!this.hasHelpMenuItems()) return
- this.accountMenuOpen = false
- this.helpMenuOpen = !this.helpMenuOpen
- }
-
- private hasAccountMenuItems () {
- return Boolean(this.accountMenu?.length || this.hasSlottedAccountMenu || this.logoutLabel)
- }
-
- private hasHelpMenuItems () {
- return Boolean(this.helpMenuList?.length || this.hasSlottedHelpMenu)
- }
-
- private shouldRenderHelpMenu () {
- return this.layout !== 'mobile' && this.hasHelpMenuItems()
- }
-
- private renderLoggedInAvatar (avatar?: string, wrapperClass = 'account-avatar') {
- const hasAvatar = Boolean(avatar)
- const imageSrc = hasAvatar ? avatar : this.accountAvatarFallback || DEFAULT_LOGGEDIN_MENU_BUTTON_AVATAR
- const imageAlt = hasAvatar ? 'Profile Avatar' : 'Default Avatar'
-
- if (this.layout === 'mobile' && wrapperClass === 'account-avatar') {
- return html`
`
- }
-
- return html`
-
-
-
- `
- }
-
- private renderLoggedOutActions () {
- return html`
-
-
- this.handleLoginSuccess()}"
- >
-
-
-
-
-
- `
- }
-
- private async handleLoginSuccess () {
- await this.refreshAuthStateFromSession()
- this.dispatchEvent(new CustomEvent('auth-action-select', {
- detail: { role: 'login' },
- bubbles: true,
- composed: true
- }))
- }
-
- private async handleLogout () {
- this.accountMenuOpen = false
- const issuer = window.localStorage.getItem('loginIssuer') || ''
-
- try {
- await authSession.logout()
- } catch (_err) {
- // logout errors are non-fatal — proceed to clear state
- }
-
- await clearPersistedAuthState()
-
- const redirectedToServerLogout = await performServerSideLogout({
- issuer,
- postLogoutRedirectPath: '/'
- })
- if (redirectedToServerLogout) {
- return
- }
-
- await this.refreshAuthStateFromSession()
- this.dispatchEvent(new CustomEvent('logout-select', {
- detail: { role: 'logout' },
- bubbles: true,
- composed: true
- }))
- }
-
- private renderAccountMenuItem (item: HeaderAccountMenuItem) {
- const content = html`
- ${this.renderLoggedInAvatar(item.avatar, 'account-menu-avatar')}
-
- `
-
- if (item.url) {
- return html`
-
- `
- }
-
- return html`
-
- `
- }
-
- private renderLoggedInActions () {
- return html`
-
- `
- }
-
- private renderUserArea () {
- if (Features.DESIGN_SYSTEM_HEADER_ACCOUNT) {
- return html`
-
-
-
- `
- }
-
- if (!this.authResolved) {
- return html``
- }
-
- if (this.authState === 'logged-out') {
- return this.renderLoggedOutActions()
- }
-
- return this.renderLoggedInActions()
- }
-
- protected firstUpdated () {
- const brandLink = this.shadowRoot?.getElementById('brandLink')
- if (brandLink) {
- brandLink.classList.toggle('brand-not-displayed', this.layout === 'mobile')
- }
- }
-
- protected updated (changedProperties: Map) {
- if (changedProperties.has('layout')) {
- const brandLink = this.shadowRoot?.getElementById('brandLink')
- if (brandLink) {
- brandLink.classList.toggle('brand-not-displayed', this.layout === 'mobile')
- }
- }
- }
-
- render () {
- return html`
-
- `
- }
-}
diff --git a/src/v2/components/layout/header/README.md b/src/v2/components/layout/header/README.md
deleted file mode 100644
index f252e999a..000000000
--- a/src/v2/components/layout/header/README.md
+++ /dev/null
@@ -1,208 +0,0 @@
-# solid-ui-header component
-
-A Lit-based custom element that renders the Solid application header, including branding, auth actions, account management, and a help menu.
-
-When `layout="mobile"`, the header hides the help menu entirely, even if `helpMenuList` items or `help-menu` slotted content are provided. In this mode the header also omits icons from the built-in login/sign-up buttons, hides the account webid text in the account dropdown, and hides the logout icon.
-
-When `auth-state="logged-out"`, the header renders a `` as the login action. The login button opens a Solid IDP selection popup and handles the full OIDC login flow via `solid-logic`. On success it emits a `login-success` event and the header transitions to `logged-in` state automatically.
-
-## Installation
-
-```bash
-npm install solid-ui
-```
-
-## Usage in a bundled project (webpack, Vite, Rollup, etc.)
-
-Import once to register the custom element and get access to the types:
-
-```javascript
-import { Header } from 'solid-ui/components/header'
-```
-
-The header automatically imports and registers `` — no separate import is needed.
-
-Then use the element in HTML or in your framework templates:
-
-```html
-
- Help
-
-```
-
-## Usage in a plain HTML page (CDN / script tag)
-
-The UMD/standalone bundles externalize `rdflib` and `solid-logic`, and `` renders `` when `auth-state="logged-out"`. Load those dependencies first so the login button works correctly.
-
-```html
-
-
-
-
-
-```
-
-Or via a CDN that supports npm packages:
-
-```html
-
-
-
-
-
-```
-
-## TypeScript
-
-Types are included. Import the exported interfaces alongside the element class:
-
-```typescript
-import { Header } from 'solid-ui/components/header'
-import type { HeaderMenuItem, HeaderAccountMenuItem, HeaderAuthState } from 'solid-ui/components/header'
-
-const header = document.querySelector('solid-ui-header') as Header
-header.authState = 'logged-in' satisfies HeaderAuthState
-```
-
-## solid-ui-login-button
-
-The login button is a self-contained component with its own README: [`src/v2/components/auth/loginButton/README.md`](../../auth/loginButton/README.md).
-
-The header automatically imports and registers it — no separate import is needed.
-
----
-
-## API
-
-Properties/attributes:
-
-- `logo`: URL string for the brand image (default: Solid emblem URL).
-- `helpIcon`: URL string for the help icon, default from icons asset. If `help-icon` is empty or not provided, the help trigger renders the text `Help` instead.
-- `brandLink`: URL string for the brand link (default: `#`).
-- `layout`: `desktop` or `mobile`. Mobile layout hides the brand logo link, does not render the help menu, omits icons from the built-in login and sign-up buttons, hides the account webid text in the dropdown, and hides the logout icon.
-- `theme`: `light` or `dark`.
-- `authState`: `logged-out` or `logged-in`.
-- `loginAction`: object with a `label` for the login button. When `authState` is `logged-out` this is rendered as a `` which handles the full OIDC flow; supplying a `url` instead opts out of the built-in flow and renders a plain link. The optional `icon` field supplies a left-aligned icon URL for the rendered login button, but icons are hidden on mobile layout.
-- `signUpAction`: object for the logged-out Sign Up action. The `label` field sets the button text, the `url` field (default: `https://solidproject.org/get_a_pod`) is the destination opened in a new tab when the button is clicked, and the optional `icon` field supplies a left-aligned icon URL for the rendered signup button, but icons are hidden on mobile layout.
-- `accountIcon`: URL string for the icon displayed in the logged-in dropdown trigger button (default: `▼`). Always rendered as an `
` element. Hidden on mobile layout.
-- `accountAvatar`: avatar URL used as the logged-in dropdown icon.
-- `accountAvatarFallback`: avatar URL used when `accountAvatar` is not provided. This replaces the internal default profile placeholder image.
-- `loginIcon`: optional URL string for a left-aligned icon on the login action button, taking precedence over `loginAction.icon`. Icons are still hidden on mobile layout.
-- `signUpIcon`: optional URL string for a left-aligned icon on the sign-up action button, taking precedence over `signUpAction.icon`. Icons are still hidden on mobile layout.
-- `accountMenu`: array of account entries for the logged-in dropdown.
-- `logoutLabel`: string label for the logout button at the bottom of the logged-in dropdown (default: `Log out`). Set to `null` to hide it.
-- `logoutIcon`: URL string for a left-aligned icon displayed in the logout menu item. Hidden on mobile layout.
-
-Slots:
-
-- `title` (default content is `Solid`).
-- `login-action` to override the logged-out Log in action.
-- `sign-up-action` to override the logged-out Sign Up action.
-- `account-trigger` to override the logged-in Accounts trigger.
-- `account-menu` for custom logged-in account entries.
-- `help-menu` for help related actions rendered inside the help icon dropdown on desktop layout.
-
-The `helpMenuList` property also renders inside the same help icon dropdown menu on desktop layout.
-
-## Auth Modes
-
-### Logged-out (with built-in login flow)
-
-Use `auth-state="logged-out"` to render the `` and a Sign Up action. The login button opens an IDP selection popup and drives the full OIDC login flow without any extra wiring. On a successful login the header automatically sets `auth-state="logged-in"` and emits `auth-action-select`:
-
-```html
-
-
-```
-
-If you want a fully custom login UI you can override the slot:
-
-```html
-
-
-
-```
-
-### Logged-in
-
-```html
-
-
-```
-
-The built-in logout button automatically transitions the header from `logged-in` to `logged-out`, and emits a bubbling, composed `logout-select` event with `detail: { role: 'logout' }`.
-
-The component also dispatches `auth-action-select` for logged-out actions and `account-menu-select` for logged-in account choices.
-When `authState` is `logged-in`, the dropdown always renders the configured `logoutLabel` as the last item.
-
-## Styles
-
-Customization is supported through CSS variables:
-- `--header-bg`, `--header-text`, `--header-border`, etc.
-
-The brand logo link is only rendered when the incoming `layout` is `desktop`.
-The help menu trigger and dropdown are only rendered when the incoming `layout` is `desktop`.
-
-## Testing
-
-Unit test file: `src/v2/components/layout/header/header.test.ts`
-
-Run tests:
-
-```bash
-npm test -- --runInBand --testPathPatterns=src/v2/components/layout/header/header.test.ts
-```
-
-Run full suite:
-
-```bash
-npm test
-```
-
-## Build
-
-```bash
-npm run build
-```
-
-Webpack emits the runtime bundles to `dist/components/header/index.*`. A post-build script generates `dist/components/header/index.d.ts` as a thin re-export wrapper so that the public package layout does not expose internal source paths.
diff --git a/src/v2/components/layout/header/header.test.ts b/src/v2/components/layout/header/header.test.ts
deleted file mode 100644
index 457336c6b..000000000
--- a/src/v2/components/layout/header/header.test.ts
+++ /dev/null
@@ -1,416 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import Features from '../../../../lib/features'
-import { Header } from './Header'
-import './index'
-import { authn, authSession } from 'solid-logic'
-
-type Listener = () => void
-
-const { mockSessionListeners } = vi.hoisted(() => {
- const mockSessionListeners = new Map>()
- return { mockSessionListeners }
-})
-
-vi.mock('solid-logic', () => ({
- authn: {
- checkUser: vi.fn(async () => null),
- currentUser: vi.fn(() => null)
- },
- performServerSideLogout: vi.fn(async () => false),
- authSession: {
- logout: vi.fn(async () => undefined),
- events: {
- on: vi.fn((event: string, handler: Listener) => {
- if (!mockSessionListeners.has(event)) mockSessionListeners.set(event, new Set())
- mockSessionListeners.get(event)?.add(handler)
- }),
- off: vi.fn((event: string, handler: Listener) => {
- mockSessionListeners.get(event)?.delete(handler)
- }),
- emit: vi.fn((event: string) => {
- mockSessionListeners.get(event)?.forEach(handler => handler())
- })
- }
- }
-}))
-
-const aliceWebId = { uri: 'https://alice.example/profile/card#me' } as ReturnType
-
-describe('SolidUIHeaderElement', () => {
- async function waitForAuthRefresh (header: Header): Promise {
- await Promise.resolve()
- await Promise.resolve()
- await header.updateComplete
- }
-
- beforeEach(() => {
- Features.DESIGN_SYSTEM_HEADER_ACCOUNT = false
- document.body.innerHTML = ''
- vi.clearAllMocks()
- mockSessionListeners.clear()
- vi.mocked(authn.currentUser).mockReturnValue(null)
- vi.mocked(authn.checkUser).mockResolvedValue(null)
- Object.defineProperty(window, 'open', {
- configurable: true,
- writable: true,
- value: vi.fn()
- })
- })
-
- it('is defined as a custom element', () => {
- const defined = customElements.get('solid-ui-header')
- expect(defined).toBe(Header)
- })
-
- it('renders a header with logo and menu slots', async () => {
- const header = new Header()
- header.setAttribute('logo', 'https://example.com/logo.png')
- header.setAttribute('help-icon', 'https://example.com/help.png')
- header.setAttribute('brand-link', '/home')
- header.authState = 'logged-out'
- header.authResolved = true
- header.helpMenuList = [{ label: 'Help', action: 'open-help' }]
- header.innerHTML = ''
-
- document.body.appendChild(header)
- await header.updateComplete
-
- const shadow = header.shadowRoot
- expect(shadow).not.toBeNull()
-
- const brandImg = shadow?.getElementById('brandImg') as HTMLImageElement
- const helpIcon = shadow?.getElementById('helpIcon') as HTMLImageElement
- const brandLink = shadow?.getElementById('brandLink') as HTMLAnchorElement
-
- expect(brandImg?.src).toContain('https://example.com/logo.png')
- expect(helpIcon?.src).toContain('https://example.com/help.png')
- expect(brandLink?.href).toContain('/home')
-
- expect(shadow?.querySelector('solid-ui-login-button')).not.toBeNull()
- expect(shadow?.querySelector('solid-ui-signup-button')).not.toBeNull()
-
- const helpMenuSlot = shadow?.querySelector('slot[name="help-menu"]')
- expect(helpMenuSlot).not.toBeNull()
- expect(header.querySelector('#helpBtn')).not.toBeNull()
- })
-
- it('renders login and sign up actions when logged out', async () => {
- const header = new Header()
- const authActionSelected = vi.fn()
-
- header.authState = 'logged-out'
- header.authResolved = true
- header.loginAction = { label: 'Log in', action: 'login', icon: 'https://example.com/login-icon.svg' }
- header.signUpAction = { label: 'Sign Up', url: '/signup', icon: 'https://example.com/signup-icon.svg' }
- header.loginIcon = 'https://example.com/login-icon-top.svg'
- header.signUpIcon = 'https://example.com/signup-icon-top.svg'
-
- header.addEventListener('auth-action-select', (event: Event) => {
- authActionSelected((event as CustomEvent).detail)
- })
-
- document.body.appendChild(header)
- await header.updateComplete
-
- const shadow = header.shadowRoot
- const loginButton = shadow?.querySelector('solid-ui-login-button') as HTMLElement
- const signUpLink = shadow?.querySelector('solid-ui-signup-button') as HTMLElement
-
- expect(loginButton).not.toBeNull()
- expect(signUpLink).not.toBeNull()
- expect(loginButton.getAttribute('label')).toBe('Log in')
- expect(loginButton.getAttribute('icon')).toBe('https://example.com/login-icon-top.svg')
- expect(signUpLink.getAttribute('label')).toBe('Sign Up')
- expect(signUpLink.getAttribute('signup-url')).toBe('/signup')
- expect(signUpLink.getAttribute('icon')).toBe('https://example.com/signup-icon-top.svg')
-
- loginButton.dispatchEvent(new CustomEvent('login-success', { bubbles: true, composed: true }))
- await waitForAuthRefresh(header)
-
- expect(authActionSelected).toHaveBeenCalledWith({
- role: 'login'
- })
- })
-
- it('does not show login or signup icons on mobile layout', async () => {
- const header = new Header()
- header.authState = 'logged-out'
- header.authResolved = true
- header.layout = 'mobile'
- header.loginAction = { label: 'Log in', action: 'login', icon: 'https://example.com/login-icon.svg' }
- header.signUpAction = { label: 'Sign Up', url: '/signup', icon: 'https://example.com/signup-icon.svg' }
- header.loginIcon = 'https://example.com/login-icon-top.svg'
- header.signUpIcon = 'https://example.com/signup-icon-top.svg'
-
- document.body.appendChild(header)
- await header.updateComplete
-
- const shadow = header.shadowRoot
- const loginButton = shadow?.querySelector('solid-ui-login-button') as HTMLElement
- const signUpButton = shadow?.querySelector('solid-ui-signup-button') as HTMLElement
-
- expect(loginButton?.shadowRoot?.querySelector('.login-button-icon')).toBeNull()
- expect(signUpButton?.shadowRoot?.querySelector('.signup-button-icon')).toBeNull()
- })
-
- it('uses a custom fallback avatar when no accountAvatar is configured', async () => {
- const header = new Header()
- vi.mocked(authn.currentUser).mockReturnValue(aliceWebId)
-
- header.authState = 'logged-in'
- header.authResolved = true
- header.accountAvatar = ''
- header.accountAvatarFallback = 'https://example.com/fallback-avatar.png'
-
- document.body.appendChild(header)
- await header.updateComplete
-
- const shadow = header.shadowRoot
- const avatarImg = shadow?.querySelector('.account-avatar img') as HTMLImageElement
-
- expect(avatarImg).not.toBeNull()
- expect(avatarImg.src).toContain('https://example.com/fallback-avatar.png')
- })
-
- it('renders an accounts dropdown with avatar when logged in', async () => {
- const header = new Header()
- const accountMenuSelected = vi.fn()
- vi.mocked(authn.currentUser).mockReturnValue(aliceWebId)
-
- header.authState = 'logged-in'
- header.authResolved = true
- header.accountIcon = 'https://example.com/account-icon.svg'
- header.accountAvatar = 'https://example.com/avatar.png'
- header.logoutIcon = 'https://example.com/logout-icon.svg'
- header.accountMenu = [
- { label: 'Personal Pod', webid: 'https://pod.example/profile/card#me', action: 'switch-personal' },
- { label: 'Work Pod', webid: 'https://work.example/profile/card#me', url: '/work' }
- ]
-
- header.addEventListener('account-menu-select', (event: Event) => {
- accountMenuSelected((event as CustomEvent).detail)
- })
-
- document.body.appendChild(header)
- await header.updateComplete
-
- const shadow = header.shadowRoot
- const trigger = shadow?.getElementById('accountMenuTrigger') as HTMLButtonElement
-
- expect(trigger).not.toBeNull()
- expect((trigger.querySelector('img.account-menu-trigger-icon') as HTMLImageElement)?.src).toContain('https://example.com/account-icon.svg')
- expect((shadow?.querySelector('.account-avatar img') as HTMLImageElement)?.src).toContain('https://example.com/avatar.png')
-
- trigger.click()
- await header.updateComplete
-
- const dropdown = shadow?.getElementById('accountMenu') as HTMLElement
- const accountButtons = shadow?.querySelectorAll('.account-menu-item-button') as NodeListOf
- const firstItem = accountButtons[0]
- const lastItem = accountButtons[accountButtons.length - 1]
-
- expect(dropdown.hidden).toBe(false)
- expect(firstItem.textContent).toContain('Personal Pod')
- expect(lastItem.textContent).toContain('Log Out')
- expect((lastItem.querySelector('img.logout-action-icon') as HTMLImageElement)?.src).toContain('https://example.com/logout-icon.svg')
-
- firstItem.click()
-
- expect(accountMenuSelected).toHaveBeenCalledWith({
- label: 'Personal Pod',
- webid: 'https://pod.example/profile/card#me',
- action: 'switch-personal'
- })
-
- expect(lastItem.textContent?.trim()).toBe('Log Out')
- })
-
- it('does not render the logout icon on mobile layout', async () => {
- const header = new Header()
- vi.mocked(authn.currentUser).mockReturnValue(aliceWebId)
- header.layout = 'mobile'
- header.authState = 'logged-in'
- header.authResolved = true
- header.logoutIcon = 'https://example.com/logout-icon.svg'
- header.logoutLabel = 'Log Out'
-
- document.body.appendChild(header)
- await header.updateComplete
-
- const shadow = header.shadowRoot
- const trigger = shadow?.getElementById('accountMenuTrigger') as HTMLButtonElement
- expect(trigger).not.toBeNull()
-
- trigger.click()
- await header.updateComplete
-
- const lastItem = shadow?.querySelectorAll('.account-menu-item-button')[0] as HTMLButtonElement
- expect(lastItem).not.toBeNull()
- expect(lastItem.querySelector('img.logout-action-icon')).toBeNull()
- expect(lastItem.textContent?.trim()).toBe('Log Out')
- })
-
- it('does not render account webid on mobile layout', async () => {
- const header = new Header()
- vi.mocked(authn.currentUser).mockReturnValue(aliceWebId)
- header.layout = 'mobile'
- header.authState = 'logged-in'
- header.authResolved = true
- header.accountMenu = [
- { label: 'Personal Pod', webid: 'https://pod.example/profile/card#me', action: 'switch-personal' }
- ]
-
- document.body.appendChild(header)
- await header.updateComplete
-
- const shadow = header.shadowRoot
- const trigger = shadow?.getElementById('accountMenuTrigger') as HTMLButtonElement
- expect(trigger).not.toBeNull()
-
- trigger.click()
- await header.updateComplete
-
- const firstItem = shadow?.querySelector('.account-menu-item-button') as HTMLButtonElement
- expect(firstItem).not.toBeNull()
- expect(firstItem.querySelector('.account-menu-webid')).toBeNull()
- expect(firstItem.textContent?.trim()).toBe('Personal Pod')
- })
-
- it('supports theme and layout attributes', async () => {
- const header = new Header()
- header.setAttribute('theme', 'dark')
- header.setAttribute('layout', 'mobile')
- document.body.appendChild(header)
- await header.updateComplete
-
- expect(header.getAttribute('theme')).toBe('dark')
- expect(header.getAttribute('layout')).toBe('mobile')
-
- const shadow = header.shadowRoot
- expect(shadow?.querySelector('.headerInner')).not.toBeNull()
- expect(shadow?.getElementById('brandLink')?.classList.contains('brand-not-displayed')).toBe(true)
- expect(header.getAttribute('theme')).toBe('dark')
- expect(header.getAttribute('layout')).toBe('mobile')
- })
-
- it('toggles the brand link visibility class by layout', async () => {
- const header = new Header()
- header.setAttribute('brand-link', '/home')
-
- document.body.appendChild(header)
- await header.updateComplete
-
- expect(header.layout).toBe('desktop')
- expect(header.shadowRoot?.getElementById('brandLink')).not.toBeNull()
- expect(header.shadowRoot?.getElementById('brandLink')?.classList.contains('brand-not-displayed')).toBe(false)
-
- header.layout = 'mobile'
- await header.updateComplete
-
- expect(header.layout).toBe('mobile')
- expect(header.shadowRoot?.getElementById('brandLink')).not.toBeNull()
- expect(header.shadowRoot?.getElementById('brandLink')?.classList.contains('brand-not-displayed')).toBe(true)
-
- header.layout = 'desktop'
- await header.updateComplete
-
- expect(header.layout).toBe('desktop')
- expect(header.shadowRoot?.getElementById('brandLink')).not.toBeNull()
- expect(header.shadowRoot?.getElementById('brandLink')?.classList.contains('brand-not-displayed')).toBe(false)
- })
-
- it('renders helpMenuList inside the help dropdown and dispatches events', async () => {
- const header = new Header()
- vi.mocked(authn.currentUser).mockReturnValue(aliceWebId)
-
- const helpMenuClicked = vi.fn()
-
- header.authState = 'logged-in'
- header.authResolved = true
- header.helpIcon = ''
- header.helpMenuList = [{ label: 'Docs', url: 'https://example.com/docs', target: '_blank' }]
-
- header.addEventListener('help-menu-select', (event: Event) => {
- helpMenuClicked((event as CustomEvent).detail)
- })
-
- document.body.appendChild(header)
- await header.updateComplete
-
- const shadow = header.shadowRoot
- const helpTrigger = shadow?.getElementById('helpMenuTrigger') as HTMLButtonElement
-
- expect(helpTrigger?.disabled).toBe(false)
- expect(helpTrigger?.textContent?.trim()).toBe('Help')
-
- helpTrigger?.click()
- await header.updateComplete
-
- const helpMenu = shadow?.getElementById('helpMenu') as HTMLElement
- const helpLink = shadow?.querySelector('a[part="help-menu-item"]') as HTMLAnchorElement
-
- expect(helpMenu?.hidden).toBe(false)
- expect(helpLink?.textContent?.trim()).toBe('Docs')
-
- const originalWindowOpen = window.open
- window.open = vi.fn()
-
- expect(helpLink?.getAttribute('rel')).toBe('noopener noreferrer')
-
- helpLink?.click()
-
- expect(helpMenuClicked).toHaveBeenCalledWith({ label: 'Docs', url: 'https://example.com/docs', target: '_blank' })
- expect(window.open).toHaveBeenCalledWith('https://example.com/docs', '_blank', 'noopener,noreferrer')
-
- window.open = originalWindowOpen
- })
-
- it('derives auth state from session on connect', async () => {
- const header = new Header()
- vi.mocked(authn.currentUser).mockReturnValue(aliceWebId)
-
- document.body.appendChild(header)
- await header.updateComplete
- await waitForAuthRefresh(header)
-
- expect(authn.checkUser).toHaveBeenCalled()
- expect(header.authState).toBe('logged-in')
- })
-
- it('retries session resolution once before settling logged-out state', async () => {
- const header = new Header()
- let callCount = 0
- vi.mocked(authn.currentUser).mockImplementation(() => {
- return callCount >= 2 ? aliceWebId : null
- })
- vi.mocked(authn.checkUser).mockImplementation(async () => {
- callCount += 1
- return callCount >= 2 ? aliceWebId : null
- })
-
- document.body.appendChild(header)
- await header.updateComplete
- await Promise.resolve()
- await header.updateComplete
-
- expect(authn.checkUser).toHaveBeenCalledTimes(2)
- expect(header.authResolved).toBe(true)
- expect(header.authState).toBe('logged-in')
- })
-
- it('refreshes auth state when session events fire', async () => {
- const header = new Header()
- document.body.appendChild(header)
- await header.updateComplete
-
- vi.mocked(authn.currentUser).mockReturnValue(aliceWebId)
- ;(authSession.events as any).emit('login')
- await waitForAuthRefresh(header)
- expect(header.authState).toBe('logged-in')
-
- vi.mocked(authn.currentUser).mockReturnValue(null)
- ;(authSession.events as any).emit('logout')
- await waitForAuthRefresh(header)
- expect(header.authState).toBe('logged-out')
- })
-})
diff --git a/src/v2/components/layout/header/index.ts b/src/v2/components/layout/header/index.ts
deleted file mode 100644
index be138e144..000000000
--- a/src/v2/components/layout/header/index.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { Header } from './Header'
-
-export { Header }
-export type {
- HeaderAccountMenuItem,
- HeaderAuthState,
- HeaderMenuItem
-} from './Header'
-
-const HEADER_TAG_NAME = 'solid-ui-header'
-
-if (!customElements.get(HEADER_TAG_NAME)) {
- customElements.define(HEADER_TAG_NAME, Header)
-}
diff --git a/vite-config/cdn.ts b/vite-config/cdn.ts
index 8ee5221dc..6b469cb7b 100644
--- a/vite-config/cdn.ts
+++ b/vite-config/cdn.ts
@@ -1,7 +1,8 @@
import { join, resolve } from 'node:path'
-import { babel } from 'solidos-toolkit/vite'
+import { babel, cssConfig } from 'solidos-toolkit/vite'
import type { PluginOption, UserConfig } from 'vite'
+import resolveConfig from './resolve'
import { componentsSrcDir, discoverComponents, litDecoratorPaths } from './components'
const projectRoot = resolve(import.meta.dirname, '..')
@@ -19,9 +20,11 @@ function cdnSharedConfig(options: { basePlugins: PluginOption[]; globals?: Recor
const externals = Object.keys(globals)
return {
+ css: cssConfig(),
resolve: {
- tsconfigPaths: true,
+ ...resolveConfig,
alias: {
+ ...resolveConfig.alias,
path: 'path-browserify',
},
},
diff --git a/vite-config/icons.ts b/vite-config/icons.ts
deleted file mode 100644
index 0a7baab12..000000000
--- a/vite-config/icons.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import Icons from 'unplugin-icons/vite'
-import type { Options } from 'unplugin-icons'
-
-const compiler: Options['compiler'] = {
- compiler(svg, collection, icon) {
- const id = `icon-${collection}-${icon}`
- const className = id.replace(/-/g, '')
-
- return `
- export default class ${className} extends HTMLElement {
- constructor() {
- super()
- this.attachShadow({ mode: 'open' }).innerHTML = ${JSON.stringify('' + svg)}
- }
- }
-
- if (!customElements.get('${id}')) {
- customElements.define('${id}', ${className})
- }
- `
- },
-}
-
-export default function () {
- return Icons({
- scale: 1,
- compiler,
- iconCustomizer(_, __, props) {
- props.width = '100%'
- props.height = '100%'
- }
- })
-}
diff --git a/vite-config/resolve.ts b/vite-config/resolve.ts
new file mode 100644
index 000000000..3014a62d6
--- /dev/null
+++ b/vite-config/resolve.ts
@@ -0,0 +1,9 @@
+import { resolve } from 'node:path'
+import type { UserConfig } from 'vite'
+
+export default {
+ tsconfigPaths: true,
+ alias: {
+ '@': resolve(import.meta.dirname, '../src'),
+ },
+} as const satisfies UserConfig['resolve']
diff --git a/vite-config/styles.ts b/vite-config/styles.ts
index 6409555b5..49cf097b8 100644
--- a/vite-config/styles.ts
+++ b/vite-config/styles.ts
@@ -1,8 +1,12 @@
+import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
-import tailwindcss from '@tailwindcss/vite'
import type { Plugin, UserConfig } from 'vite'
const projectRoot = resolve(import.meta.dirname, '..')
+const breakpointsCss = readFileSync(
+ resolve(projectRoot, 'src/styles/breakpoints.css'),
+ 'utf8',
+)
function styleDeclarations(): Plugin {
return {
@@ -10,14 +14,22 @@ function styleDeclarations(): Plugin {
generateBundle() {
this.emitFile({
type: 'asset',
- fileName: 'theme.css.d.ts',
- source: 'export {}\n',
+ fileName: 'breakpoints.css',
+ source: breakpointsCss,
})
+
+ for (const name of ['breakpoints', 'theme']) {
+ this.emitFile({
+ type: 'asset',
+ fileName: `${name}.css.d.ts`,
+ source: `export {}\n`,
+ })
+ }
},
}
}
-export function stylesConfig(): UserConfig {
+export default function(): UserConfig {
return {
build: {
outDir: 'dist',
@@ -30,6 +42,6 @@ export function stylesConfig(): UserConfig {
},
},
},
- plugins: [tailwindcss(), styleDeclarations()],
+ plugins: [styleDeclarations()],
}
}
diff --git a/vite.config.mts b/vite.config.mts
index 238f38169..553392981 100644
--- a/vite.config.mts
+++ b/vite.config.mts
@@ -1,14 +1,14 @@
import dts from 'unplugin-dts/vite'
import { isAbsolute } from 'node:path'
import { defineConfig } from 'vitest/config'
-import { babel } from 'solidos-toolkit/vite'
+import { babel, icons, cssConfig } from 'solidos-toolkit/vite'
import type { UserConfig } from 'vite'
import css from './vite-config/css'
-import icons from './vite-config/icons'
+import resolveConfig from './vite-config/resolve'
+import stylesConfig from './vite-config/styles'
import { cdnLegacyConfig, cdnConfig } from './vite-config/cdn'
import { discoverComponents, litDecoratorPaths } from './vite-config/components'
-import { stylesConfig } from './vite-config/styles'
const basePlugins = [
css(),
@@ -17,9 +17,8 @@ const basePlugins = [
function defaultConfig(): UserConfig {
return {
- resolve: {
- tsconfigPaths: true,
- },
+ css: cssConfig(),
+ resolve: resolveConfig,
plugins: [
...basePlugins,
babel({ litDecoratorPaths }),