From a303ed3e9409f015de1e989cd4e012ca51a0b5e5 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Wed, 1 Jul 2026 16:40:56 +0200 Subject: [PATCH 1/5] Extract icons plugin to toolkit --- package-lock.json | 7 ++++--- vite-config/icons.ts | 33 --------------------------------- vite.config.mts | 3 +-- 3 files changed, 5 insertions(+), 38 deletions(-) delete mode 100644 vite-config/icons.ts diff --git a/package-lock.json b/package-lock.json index 6d272cd2f..a64c3556a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11488,9 +11488,9 @@ "license": "MIT" }, "node_modules/solidos-toolkit": { - "version": "0.0.0-dev.8855b548fe1b7c26a797a8d454e12aad6d96e04f", - "resolved": "https://registry.npmjs.org/solidos-toolkit/-/solidos-toolkit-0.0.0-dev.8855b548fe1b7c26a797a8d454e12aad6d96e04f.tgz", - "integrity": "sha512-1XqJ18xe14Hj7/jthFAx8EWTFlI2fPks01+q40r5HdlNNbS3oSbCW4tsYKP6AvUucAiJfUfpPiXKwsFrjHOPJg==", + "version": "0.0.0-dev.c10f4a56fdff825429b4c22b82be09e14afd8fe8", + "resolved": "https://registry.npmjs.org/solidos-toolkit/-/solidos-toolkit-0.0.0-dev.c10f4a56fdff825429b4c22b82be09e14afd8fe8.tgz", + "integrity": "sha512-NrU4Lv9xNWj272N+CH9Iakm3WjpwWOyCVErK4X8tYe51EGaJtxZxezSn8RjqMUhe+aYNg8zM2IB+6YoKWV6Iew==", "dev": true, "dependencies": { "@babel/core": "^7.29.0", @@ -11504,6 +11504,7 @@ "@rolldown/plugin-babel": "^0.2.3", "postcss-custom-media": "^12.0.1", "unplugin-dts": "^1.0.2", + "unplugin-icons": "^23.0.1", "vite-plugin-css-injected-by-js": "^3.5.2", "vite-plugin-lit-css": "^3.0.0" }, 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.mts b/vite.config.mts index 238f38169..b834684f9 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -1,11 +1,10 @@ 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 } from 'solidos-toolkit/vite' import type { UserConfig } from 'vite' import css from './vite-config/css' -import icons from './vite-config/icons' import { cdnLegacyConfig, cdnConfig } from './vite-config/cdn' import { discoverComponents, litDecoratorPaths } from './vite-config/components' import { stylesConfig } from './vite-config/styles' From 98fcdf51b25a41b358e9cad40572e4e449583d7d Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Wed, 1 Jul 2026 16:42:34 +0200 Subject: [PATCH 2/5] #769 Implement SolidEmblem component - SVG obtained from solidproject.org and cleaned up with svgomg.net - We're wrapping this custom icon as a normal component because it's easier, if we end up with a lot of these icon components we should consider moving them to some specialized solid-ui/icons/ export paths. --- src/assets/icons/solid-emblem.svg | 1 + .../solid-emblem/SolidEmblem.styles.css | 3 +++ src/components/solid-emblem/SolidEmblem.ts | 15 +++++++++++++++ src/components/solid-emblem/index.ts | 4 ++++ 4 files changed, 23 insertions(+) create mode 100644 src/assets/icons/solid-emblem.svg create mode 100644 src/components/solid-emblem/SolidEmblem.styles.css create mode 100644 src/components/solid-emblem/SolidEmblem.ts create mode 100644 src/components/solid-emblem/index.ts diff --git a/src/assets/icons/solid-emblem.svg b/src/assets/icons/solid-emblem.svg new file mode 100644 index 000000000..2f5a86b58 --- /dev/null +++ b/src/assets/icons/solid-emblem.svg @@ -0,0 +1 @@ + diff --git a/src/components/solid-emblem/SolidEmblem.styles.css b/src/components/solid-emblem/SolidEmblem.styles.css new file mode 100644 index 000000000..da29a29a5 --- /dev/null +++ b/src/components/solid-emblem/SolidEmblem.styles.css @@ -0,0 +1,3 @@ +:host { + display: inline-flex; +} diff --git a/src/components/solid-emblem/SolidEmblem.ts b/src/components/solid-emblem/SolidEmblem.ts new file mode 100644 index 000000000..41acd85ab --- /dev/null +++ b/src/components/solid-emblem/SolidEmblem.ts @@ -0,0 +1,15 @@ +import { customElement, WebComponent } from '@/lib/components' +import { html } from 'lit' + +import '~icons/app/solid-emblem' + +import styles from './SolidEmblem.styles.css' + +@customElement('solid-ui-solid-emblem') +export default class SolidEmblem extends WebComponent { + static styles = styles + + protected render () { + return html`` + } +} diff --git a/src/components/solid-emblem/index.ts b/src/components/solid-emblem/index.ts new file mode 100644 index 000000000..0f1fe3d45 --- /dev/null +++ b/src/components/solid-emblem/index.ts @@ -0,0 +1,4 @@ +import SolidEmblem from './SolidEmblem' + +export { SolidEmblem } +export default SolidEmblem From 8b30f1f59265f80994325b94e67e50ac0a766b56 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Wed, 1 Jul 2026 16:45:21 +0200 Subject: [PATCH 3/5] #769 Prepare utils for Header component - Header component will be implemented in solid-panes instead - Implement customizable items menu in Account component - Add new design tokens - Remove v2 header and feature flags --- src/components/account/Account.stories.ts | 2 +- src/components/account/Account.ts | 17 +- src/components/account/index.ts | 4 +- src/components/header/index.ts | 1 - src/components/menu-item/MenuItem.styles.css | 25 +- src/components/menu-item/MenuItem.ts | 8 +- src/lib/features.ts | 9 - src/styles/theme.css | 1 + src/v2/components/layout/header/Header.ts | 1013 ----------------- src/v2/components/layout/header/README.md | 208 ---- .../components/layout/header/header.test.ts | 416 ------- src/v2/components/layout/header/index.ts | 14 - 12 files changed, 39 insertions(+), 1679 deletions(-) delete mode 100644 src/components/header/index.ts delete mode 100644 src/lib/features.ts delete mode 100644 src/v2/components/layout/header/Header.ts delete mode 100644 src/v2/components/layout/header/README.md delete mode 100644 src/v2/components/layout/header/header.test.ts delete mode 100644 src/v2/components/layout/header/index.ts diff --git a/src/components/account/Account.stories.ts b/src/components/account/Account.stories.ts index 15cf70254..7e0f2977b 100644 --- a/src/components/account/Account.stories.ts +++ b/src/components/account/Account.stories.ts @@ -13,7 +13,7 @@ const meta = { } } as const -const render = defineAuthStoryRender(() => html``) +const render = defineAuthStoryRender(() => html``) export default meta diff --git a/src/components/account/Account.ts b/src/components/account/Account.ts index a891084e4..a2069f070 100644 --- a/src/components/account/Account.ts +++ b/src/components/account/Account.ts @@ -1,5 +1,6 @@ import { consume } from '@lit/context' -import { html } from 'lit' +import { html, nothing, TemplateResult } from 'lit' +import { property } from 'lit/decorators.js' import { customElement, WebComponent } from '@/lib/components' import { authContext, AuthContext, DEFAULT_AUTH_CONTEXT } from '@/lib/auth' @@ -17,6 +18,12 @@ import '~icons/lucide/user' import styles from './Account.styles.css' +export interface AccountMenuItem { + label: string | TemplateResult + href?: string + onSelected?(): void +} + @customElement('solid-ui-account') export default class Account extends WebComponent { static styles = styles @@ -24,6 +31,9 @@ export default class Account extends WebComponent { loggedIn: (component: Account) => !!component.auth.account, } + @property({ type: Array }) + accessor menuItems: AccountMenuItem[] = [] + @consume({ context: authContext, subscribe: true }) private accessor auth: AuthContext = DEFAULT_AUTH_CONTEXT @@ -66,6 +76,11 @@ export default class Account extends WebComponent { + ${this.menuItems.map(menuItem => html` + menuItem.onSelected?.()}> + ${menuItem.label} + + `)} diff --git a/src/components/account/index.ts b/src/components/account/index.ts index eb9eac530..521d88068 100644 --- a/src/components/account/index.ts +++ b/src/components/account/index.ts @@ -1,4 +1,4 @@ -import Account from './Account' +import Account, { type AccountMenuItem } from './Account' -export { Account } +export { Account, type AccountMenuItem } export default Account diff --git a/src/components/header/index.ts b/src/components/header/index.ts deleted file mode 100644 index aa8f1a16e..000000000 --- a/src/components/header/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../../v2/components/layout/header' diff --git a/src/components/menu-item/MenuItem.styles.css b/src/components/menu-item/MenuItem.styles.css index de38e1ed8..d4b256023 100644 --- a/src/components/menu-item/MenuItem.styles.css +++ b/src/components/menu-item/MenuItem.styles.css @@ -1,15 +1,18 @@ :host { - width: 100%; - display: flex; - position: relative; - justify-content: flex-start; - align-items: center; - gap: 5px; - padding: 10px; - border-radius: 5px; - color: var(--solid-ui-color-gray-600); - font-weight: 500; - font-size: var(--solid-ui-font-size-md); + & > a, + & > div { + width: 100%; + display: flex; + position: relative; + justify-content: flex-start; + align-items: center; + gap: 5px; + padding: 10px; + border-radius: 5px; + color: var(--solid-ui-color-gray-600); + font-weight: 500; + font-size: var(--solid-ui-font-size-md); + } &:hover { background-color: var(--solid-ui-color-slate-200); diff --git a/src/components/menu-item/MenuItem.ts b/src/components/menu-item/MenuItem.ts index 6feb0f438..824c14298 100644 --- a/src/components/menu-item/MenuItem.ts +++ b/src/components/menu-item/MenuItem.ts @@ -26,9 +26,11 @@ export default class MenuItem extends WebComponent { } return html` - - - +
+ + + +
` } diff --git a/src/lib/features.ts b/src/lib/features.ts deleted file mode 100644 index f1bbc610a..000000000 --- a/src/lib/features.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * This file is used to enable or disable experimental features using feature flags. - */ - -const Features = { - DESIGN_SYSTEM_HEADER_ACCOUNT: true, -} - -export default Features diff --git a/src/styles/theme.css b/src/styles/theme.css index 5ebb46b1f..0a0599b87 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -17,6 +17,7 @@ --solid-ui-color-slate-50: #f8fafc; --solid-ui-color-slate-200: #e2e8f0; --solid-ui-color-slate-400: #90a1b9; + --solid-ui-color-slate-800: #1d293d; --solid-ui-color-red-500: #fb2c36; diff --git a/src/v2/components/layout/header/Header.ts b/src/v2/components/layout/header/Header.ts deleted file mode 100644 index 98bd25b8c..000000000 --- a/src/v2/components/layout/header/Header.ts +++ /dev/null @@ -1,1013 +0,0 @@ -import { LitElement, html, css } from 'lit' -import { icons } from '../../../../lib/iconBase' -import { authSession, authn, performServerSideLogout } from 'solid-logic' -import '../../auth/loginButton/index' -import '../../auth/signupButton/index' -import { ifDefined } from 'lit/directives/if-defined.js' - -import '@/components/account' -import '@/components/provider' -import Features from '../../../../lib/features' - -const DEFAULT_HELP_MENU_ICON = '' -const DEFAULT_SOLID_ICON_URL = 'https://solidproject.org/assets/img/solid-emblem.svg' -const DEFAULT_SIGNUP_URL = 'https://solidproject.org/get_a_pod' -const DEFAULT_LOGGEDIN_MENU_BUTTON_AVATAR = icons.iconBase + 'emptyProfileAvatar.png' - -async function clearPersistedAuthState (): Promise { - if (typeof window === 'undefined') { - return - } - - const explicitKeys = ['loginIssuer', 'preLoginRedirectHash'] - for (const key of explicitKeys) { - window.localStorage.removeItem(key) - window.sessionStorage.removeItem(key) - } - - if (typeof indexedDB === 'undefined') { - return - } - - const databases = ['soidc', 'solid-client-authn-store', 'solid-client-authn'] - for (const dbName of databases) { - await new Promise((resolve) => { - try { - const request = indexedDB.deleteDatabase(dbName) - request.onsuccess = () => resolve() - request.onerror = () => resolve() - request.onblocked = () => resolve() - } catch (_err) { - resolve() - } - }) - } -} - -export type HeaderAuthState = 'logged-out' | 'logged-in' - -export type HeaderMenuItem = { - label: string - url?: string - target?: string - action?: string - icon?: string -} - -export type HeaderAccountMenuItem = HeaderMenuItem & { - avatar?: string - webid?: string -} - -export class Header extends LitElement { - static properties = { - logo: { type: String, reflect: true }, - helpIcon: { type: String, attribute: 'help-icon', reflect: true }, - layout: { type: String, reflect: true }, - theme: { type: String, reflect: true }, - brandLink: { type: String, attribute: 'brand-link', reflect: true }, - authState: { type: String, attribute: 'auth-state', reflect: true }, - loginAction: { type: Object, attribute: false }, - signUpAction: { type: Object, attribute: false }, - accountMenu: { type: Array, attribute: false }, - logoutLabel: { type: String, attribute: 'logout-label', reflect: true }, - logoutIcon: { type: String, attribute: 'logout-icon', reflect: true }, - accountIcon: { type: String, attribute: 'account-icon', reflect: true }, - accountAvatar: { type: String, attribute: 'account-avatar', reflect: true }, - accountAvatarFallback: { type: String, attribute: 'account-avatar-fallback', reflect: true }, - loginIcon: { type: String, attribute: 'login-icon', reflect: true }, - signUpIcon: { type: String, attribute: 'sign-up-icon', reflect: true }, - helpMenuList: { type: Array }, - accountMenuOpen: { state: true }, - helpMenuOpen: { state: true }, - hasSlottedAccountMenu: { state: true }, - hasSlottedHelpMenu: { state: true }, - authResolved: { state: true } - } - - static styles = css` - :host { /* default theme */ - display: block; - --header-bg: var(--color-header-row-bg, #332746); - --header-text: var(--color-header-text, #ffffff); - --header-border: var(--color-border, #efecf3); - --header-line: var(--color-header-menu-separator-line, #5e546d); - --header-link: var(--color-text-heading, #000000); - --header-menu-item-hover: var(--color-header-menu-item-hover, #e6dcff); - --header-menu-item-selected: var(--color-header-menu-item-selected, #cbb9ff); - --header-menu-bg: var(--color-menu-bg, #f6f5f9); - --header-menu-loggedin-bg: var(--color-header-menu-loggedin-bg, #5e546d); - --header-menu-text: var(--color-menu-item-text, #654d6c); - --header-border-radius: var(--border-radius-sm, 0.2rem); - --header-button-bg: var(--color-menu-bg, #ffffff); - --header-button-text: var(--color-header-button-text, #0F172B); - --header-button-detail-text: var(--color-header-button-detail-text, #99A1AF); - --header-shadow: var(--color-header-shadow, 2px 6px 10px 0 rgba(0, 0, 0, 0.4), 2px 8px 24px 0 rgba(0, 0, 0, 0.19)); - font-family: var(--font-family-base, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif); - } - - /* for now light and dark are the same */ - :host([theme='dark']) { - display: block; - --header-bg: var(--color-header-row-bg, #332746); - --header-text: var(--color-header-text, #ffffff); - --header-border: var(--color-border, #efecf3); - --header-line: var(--color-header-menu-separator-line, #5e546d); - --header-link: var(--color-text-heading, #000000); - --header-menu-item-hover: var(--color-header-menu-item-hover, #e6dcff); - --header-menu-item-selected: var(--color-header-menu-item-selected, #cbb9ff); - --header-menu-bg: var(--color-menu-bg, #f6f5f9); - --header-menu-loggedin-bg: var(--color-header-menu-loggedin-bg, #5e546d); - --header-menu-text: var(--color-menu-item-text, #654d6c); - --header-border-radius: var(--border-radius-sm, 0.2rem); - --header-button-bg: var(--color-menu-bg, #ffffff); - --header-button-text: var(--color-header-button-text, #0f172a); - --header-button-detail-text: var(--color-header-button-detail-text, #878192); - --header-icon-filter: invert(1) brightness(1.3); /* special way to invert SVG color of icons, from white to black */ - --header-shadow: var(--color-header-shadow, 2px 6px 10px 0 rgba(0, 0, 0, 0.4), 2px 8px 24px 0 rgba(0, 0, 0, 0.19)); - font-family: var(--font-family-base, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif); - } - - :host([layout='mobile']) .headerInner { - flex-wrap: wrap; - text-align: center; - gap: 0.5rem; - } - - .headerInner { - display: flex; - align-items: center; - justify-content: space-between; - background: var(--header-bg); - color: var(--header-text); - padding: 0 1.5rem; - height: 3.75rem; - } - - ::slotted([slot='navigation-toggle']) { - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - margin-right: 0.75rem; - } - - :host([layout='desktop']) ::slotted([slot='navigation-toggle']) { - display: none !important; - } - - .brand { - display: flex; - align-items: center; - gap: 0.5rem; - text-decoration: none; - color: inherit; - } - - .brand-not-displayed { - display: none; - } - - .brand img { - height: 50px; - width: 55px; - object-fit: contain; - } - - .menu { - display: flex; - align-items: center; - gap: 0.625rem; - margin-left: auto; - } - - .auth-actions { - display: flex; - align-items: center; - gap: 0.625rem; - } - - .auth-button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - min-height: 2.25rem; - padding: 0.5rem 0.875rem; - border: 1px solid var(--header-border); - border-radius: 999px; - background: var(--header-menu-bg); - color: var(--header-button-text); - cursor: pointer; - font: inherit; - line-height: 1; - text-decoration: none; - box-sizing: border-box; - transition: border-color 0.2s ease, transform 0.2s ease; - } - - .auth-button:hover { - border-color: var(--header-menu-item-hover); - } - - .auth-button:active { - transform: translateY(1px); - } - - .auth-button-sign-up { - background: color-mix(in srgb, var(--header-menu-bg) 78%, var(--header-menu-item-selected) 22%); - } - - .header-menu-separator { - background: var(--header-line); - width: 1px; - height: 2.3rem; - } - - .account-menu-container { - position: relative; - display: flex; - align-items: center; - } - - .account-menu-trigger { - display: inline-flex; - align-items: center; - gap: 0.625rem; - min-height: 2.5rem; - border: 1px solid var(--header-menu-loggedin-bg); - border-radius: 999px; - background: var(--header-menu-loggedin-bg); - color: var(--header-button-text); - cursor: pointer; - font: inherit; - line-height: 1; - } - - :host([layout='mobile']) .account-menu-trigger { - gap: 0; - min-height: auto; - padding: 0; - border: 1.5px solid var(--header-border); - background: var(--header-menu-loggedin-bg); - } - - :host([layout='mobile']) .account-menu-trigger-label { - display: none; - } - - .account-menu-trigger:disabled { - cursor: default; - opacity: 0.7; - } - - .account-menu-trigger-icon { - width: 1rem; - height: 1rem; - object-fit: contain; - flex-shrink: 0; - } - - .account-avatar, - .account-menu-avatar { - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - overflow: hidden; - background: color-mix(in srgb, var(--header-bg) 18%, #ded8e7 82%); - color: var(--header-button-text); - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - } - - .account-avatar { - width: 1.75rem; - height: 1.75rem; - border-radius: 999px; - border: 1.5px solid var(--header-border); - } - - .account-menu-avatar { - width: 2rem; - height: 2rem; - border-radius: 0.5rem; - } - - .account-avatar img, - .account-menu-avatar img { - width: 100%; - height: 100%; - object-fit: cover; - } - - .account-avatar-img { - width: 1.75rem; - height: 1.75rem; - border-radius: 999px; - object-fit: cover; - background-color: var(--header-border); - } - - .account-dropdown { - position: absolute; - top: calc(100% + 0.9rem); - right: 0; - min-width: 15rem; - padding: 0.5rem; - background: var(--header-button-bg); - border: 1px solid var(--header-border); - border-radius: var(--header-border-radius); - box-shadow: var(--header-shadow); - z-index: 10; - } - - .account-dropdown[hidden] { - display: none; - } - - .account-menu-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.25rem; - } - - .account-menu-item-link, - .account-menu-item-button, - ::slotted([slot='account-menu']) { - display: flex; - align-items: center; - gap: 0.625rem; - width: 100%; - box-sizing: border-box; - color: var(--header-link); - text-decoration: none; - background: transparent; - border: 1px solid transparent; - border-radius: 10px; - padding: 0.5rem; - cursor: pointer; - font: inherit; - text-align: left; - } - - .account-menu-item-link:hover, - .account-menu-item-button:hover { - color: var(--header-link); - background: var(--header-menu-item-hover); - border-color: var(--header-menu-item-hover); - } - - .account-menu-item-link:active, - .account-menu-item-button:active { - color: var(--header-link); - background: var(--header-menu-item-selected); - border-color: var(--header-menu-item-selected); - transform: translateY(1px); - } - - .account-switch { - display: block; - width: 100%; - color: var(--header-menu-text); - text-align: left; - text-transform: uppercase; - font-size: 80%; - } - - .dropdown-menu-separator { - display: block; - width: calc(100% + 1rem); - margin: 0.5rem -0.5rem; - border: 0; - border-top: 1px solid var(--header-border); - } - - .account-menu-copy { - display: flex; - flex-direction: column; - min-width: 0; - } - - .account-menu-label { - color: var(--header-button-text); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .account-menu-webid { - color: var(--header-button-detail-text); - font-size: 0.5rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .help-menu-container { - position: relative; - display: flex; - align-items: center; - background: transparent; - } - - .help-menu-trigger { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0; - border: 0; - background: transparent; - cursor: pointer; - } - - .help-menu-trigger:disabled { - cursor: default; - } - - .help-dropdown { - position: absolute; - top: calc(100% + 0.9rem); - right: 0; - min-width: 12rem; - padding: 0.5rem; - background: var(--header-button-bg); - border: 1px solid var(--header-border); - border-radius: var(--header-border-radius); - box-shadow: var(--header-shadow); - z-index: 10; - } - - .help-dropdown[hidden] { - display: none; - } - - .help-dropdown-content { - display: flex; - flex-direction: column; - gap: 0.25rem; - } - - .help-menu-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.25rem; - } - - .help-menu-list a, - .help-menu-list button, - ::slotted([slot='help-menu']) { - display: block; - width: 100%; - box-sizing: border-box; - color: var(--header-link); - text-decoration: none; - background: transparent; - border: 1px solid transparent; - border-radius: 4px; - padding: 0.375rem 0.5rem; - cursor: pointer; - font: inherit; - text-align: left; - } - - .help-menu-list a:hover, - .help-menu-list button:hover { - color: var(--header-link); - background: var(--header-menu-item-hover); - border-color: var(--header-menu-item-hover); - } - - .help-menu-list a:active, - .help-menu-list button:active { - color: var(--header-link); - background: var(--header-menu-item-selected); - border-color: var(--header-menu-item-selected); - transform: translateY(1px); - } - - ::slotted(a), ::slotted(button) { - color: var(--header-link); - text-decoration: none; - background: var(--header-button-bg); - border: 1px solid var(--header-border); - border-radius: 4px; - padding: 0.25rem 0.5rem; - cursor: pointer; - font: inherit; - } - - .help-icon { - width: 35px; - height: 35px; - cursor: pointer; - } - - .help-text { - color: var(--header-text, #ffffff); - font: inherit; - } - - .logout-action-icon { - width: 16px; - height: 16px; - display: inline-block; - object-fit: contain; - margin-right: 0.5rem; - } - ` - - declare logo: string - declare helpIcon: string - declare layout: 'desktop' | 'mobile' - declare theme: 'light' | 'dark' - declare brandLink: string - declare authState: HeaderAuthState - declare loginAction: HeaderMenuItem - declare signUpAction: HeaderMenuItem - declare accountMenu: HeaderAccountMenuItem[] - declare logoutLabel: string | null - declare logoutIcon: string - declare accountIcon: string - declare accountAvatar: string - declare accountAvatarFallback: string - declare loginIcon: string - declare signUpIcon: string - declare helpMenuList: HeaderMenuItem[] - declare accountMenuOpen: boolean - declare helpMenuOpen: boolean - declare hasSlottedAccountMenu: boolean - declare hasSlottedHelpMenu: boolean - declare authResolved: boolean - private _refreshPromise: Promise | 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` -
- - - - - - -
- ` - } - - 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` -
- - - Logo - - - - ` - } -} 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) -} From 9d29a1cd7b0fc3f9ee7f1ef7b27dfbfe49d20bfa Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Mon, 29 Jun 2026 16:17:10 +0200 Subject: [PATCH 4/5] #769 Configure breakpoints - Defined breakpoints (only mobile for now) - Exported breakpoints.css for library consumers --- package-lock.json | 29 ----------------------------- package.json | 7 ++++++- src/styles/breakpoints.css | 1 + vite-config/cdn.ts | 7 +++++-- vite-config/resolve.ts | 9 +++++++++ vite-config/styles.ts | 22 +++++++++++++++++----- vite.config.mts | 10 +++++----- 7 files changed, 43 insertions(+), 42 deletions(-) create mode 100644 src/styles/breakpoints.css create mode 100644 vite-config/resolve.ts diff --git a/package-lock.json b/package-lock.json index a64c3556a..25a177808 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,6 @@ "@storybook/addon-docs": "10.4.2", "@storybook/addon-links": "10.4.2", "@storybook/web-components-vite": "10.4.2", - "@tailwindcss/postcss": "^4.3.0", "@tailwindcss/vite": "^4.3.0", "@testing-library/dom": "^10.4.1", "@testing-library/user-event": "^13.5.0", @@ -89,17 +88,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@antfu/install-pkg": { "version": "1.1.0", "dev": true, @@ -4526,23 +4514,6 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/postcss": { - "version": "4.3.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.3.1", - "@tailwindcss/oxide": "4.3.1", - "postcss": "8.5.15", - "tailwindcss": "4.3.1" - } - }, - "node_modules/@tailwindcss/postcss/node_modules/tailwindcss": { - "version": "4.3.1", - "dev": true, - "license": "MIT" - }, "node_modules/@tailwindcss/vite": { "version": "4.3.1", "dev": true, diff --git a/package.json b/package.json index 5a4bcfbe3..e3d048944 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,12 @@ "types": "./dist/theme.css.d.ts", "import": "./dist/theme.css", "require": "./dist/theme.css" + }, + "./breakpoints.css": { + "style": "./dist/breakpoints.css", + "types": "./dist/breakpoints.css.d.ts", + "import": "./dist/breakpoints.css", + "require": "./dist/breakpoints.css" } }, "files": [ @@ -115,7 +121,6 @@ "@storybook/addon-docs": "10.4.2", "@storybook/addon-links": "10.4.2", "@storybook/web-components-vite": "10.4.2", - "@tailwindcss/postcss": "^4.3.0", "@tailwindcss/vite": "^4.3.0", "@testing-library/dom": "^10.4.1", "@testing-library/user-event": "^13.5.0", diff --git a/src/styles/breakpoints.css b/src/styles/breakpoints.css new file mode 100644 index 000000000..988ac1edf --- /dev/null +++ b/src/styles/breakpoints.css @@ -0,0 +1 @@ +@custom-media --solid-ui-mobile (max-width: 48rem); /* 768px */ 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/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 b834684f9..553392981 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -1,13 +1,14 @@ import dts from 'unplugin-dts/vite' import { isAbsolute } from 'node:path' import { defineConfig } from 'vitest/config' -import { babel, icons } from 'solidos-toolkit/vite' +import { babel, icons, cssConfig } from 'solidos-toolkit/vite' import type { UserConfig } from 'vite' import css from './vite-config/css' +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(), @@ -16,9 +17,8 @@ const basePlugins = [ function defaultConfig(): UserConfig { return { - resolve: { - tsconfigPaths: true, - }, + css: cssConfig(), + resolve: resolveConfig, plugins: [ ...basePlugins, babel({ litDecoratorPaths }), From 41f4897eb0084cb7da592e1cc1b21486a58f1741 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Thu, 2 Jul 2026 10:18:49 +0200 Subject: [PATCH 5/5] 3.1.3-8 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 25a177808..a78dcda6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "solid-ui", - "version": "3.1.3-7", + "version": "3.1.3-8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "solid-ui", - "version": "3.1.3-7", + "version": "3.1.3-8", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index e3d048944..1f11bc34c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-ui", - "version": "3.1.3-7", + "version": "3.1.3-8", "description": "UI library for Solid applications", "main": "dist/index.cjs.js", "types": "dist/index.d.ts",