diff --git a/.gitignore b/.gitignore index f6815e3ba..9b1a4436b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ docs/api examples/storybook .history/ docs/form-examples/solid-ui.js +.tsbuildinfo diff --git a/README.md b/README.md index b5642385b..6d2e8d53c 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,12 @@ See [Forms introduction](./docs/FormsReadme.md) for UI vocabulary implementation - [Use Directly in Browser](#use-directly-in-a-browser) - [UMD Bundle](#umd-bundle-global-variable) - [ESM Bundle](#esm-bundle-import-as-module) -- [Development](#development-new-components) +- [Web Components](#web-components) + - [solid-ui-header](#solid-ui-header) +- [Development](#development) - [Testing](#adding-tests) - [Further Documentation](#further-documentation) +- [Generative AI usage](#generative-ai-usage) ## Getting started @@ -66,6 +69,8 @@ Solid-UI provides both **UMD** and **ESM** bundles for direct browser usage. Bot ### UMD Bundle (Global Variable) +If you use the legacy UMD bundle (`solid-ui.js` / `solid-ui.min.js`), `rdflib` must define `window.$rdf` before `solid-ui` loads. If `rdflib` is missing, `solid-ui` will throw `ReferenceError: $rdf is not defined`. + Load via ` + + + + +``` + +## TypeScript + +Types are included. Import the exported element class: + +```typescript +import { Footer } from 'solid-ui/components/footer' + +const footer = document.querySelector('solid-ui-footer') as Footer +footer.position = 'fixed' +footer.bottom = '1rem' +``` + +## API + +Properties / attributes: + +- `position`: `static`, `absolute`, `relative`, `fixed`, or `sticky`. +- `top`: CSS offset for the top edge when `position` is not `static`. +- `right`: CSS offset for the right edge when `position` is not `static`. +- `bottom`: CSS offset for the bottom edge when `position` is not `static`. +- `left`: CSS offset for the left edge when `position` is not `static`. +- `store`: an `rdflib` store instance used to resolve the logged-in user name from the current Solid session. + +## Display behavior + +- When no user is logged in, the footer displays a public-view message. +- When a user is logged in, the footer displays a logged-in message and links the current profile name to the user profile URI. + +## Styling + +Customize the footer using CSS variables: + +- `--footer-bg` — background color (default: `#e6e6e6`). +- `--footer-text` — text color (default: `#4f4f4f`). +- `--footer-border-radius` — corner radius (default: `1rem`). +- `--footer-box-shadow` — box shadow. +- `--footer-link` — link color. + +## Example + +```html + +``` + +```typescript +import { Footer } from 'solid-ui/components/footer' +import type { LiveStore } from 'rdflib' + +const footer = document.querySelector('solid-ui-footer') as Footer +footer.position = 'fixed' +footer.bottom = '1rem' +footer.left = '1rem' +footer.right = '1rem' +footer.store = myRdflibStore as LiveStore +``` + +## Testing + +The component is covered by unit tests under `src/v2/components/footer/Footer.test.ts`. diff --git a/src/v2/components/footer/index.ts b/src/v2/components/footer/index.ts new file mode 100644 index 000000000..7df3d81d0 --- /dev/null +++ b/src/v2/components/footer/index.ts @@ -0,0 +1,9 @@ +import { Footer } from './Footer' + +export { Footer } + +const FOOTER_TAG_NAME = 'solid-ui-footer' + +if (!customElements.get(FOOTER_TAG_NAME)) { + customElements.define(FOOTER_TAG_NAME, Footer) +} diff --git a/src/v2/components/header/Header.ts b/src/v2/components/header/Header.ts new file mode 100644 index 000000000..8760c4622 --- /dev/null +++ b/src/v2/components/header/Header.ts @@ -0,0 +1,899 @@ +import { LitElement, html, css } from 'lit' +import { icons } from '../../../iconBase' +import { authSession } from 'solid-logic' +import '../loginButton/index' +import '../signupButton/index' +import { ifDefined } from 'lit/directives/if-defined.js' + +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' + +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 }, + accountLabel: { type: String, attribute: 'account-label', 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 } + } + + 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-label { + white-space: nowrap; + } + + .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 accountLabel: 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 + + 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.accountLabel = 'Accounts' + this.accountAvatar = '' + this.accountAvatarFallback = '' + this.loginIcon = '' + this.signUpIcon = '' + this.helpMenuList = [] + this.accountMenuOpen = false + this.helpMenuOpen = false + this.hasSlottedAccountMenu = false + this.hasSlottedHelpMenu = false + } + + connectedCallback () { + super.connectedCallback() + document.addEventListener('click', this.handleDocumentClick) + window.addEventListener('keydown', this.handleWindowKeydown) + } + + disconnectedCallback () { + document.removeEventListener('click', this.handleDocumentClick) + window.removeEventListener('keydown', this.handleWindowKeydown) + super.disconnectedCallback() + } + + 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`${imageAlt}` + } + + return html` + + ` + } + + private renderLoggedOutActions () { + return html` +
+ + + + + + +
+ ` + } + + private handleLoginSuccess () { + this.authState = 'logged-in' + this.dispatchEvent(new CustomEvent('auth-action-select', { + detail: { role: 'login' }, + bubbles: true, + composed: true + })) + } + + private async handleLogout () { + this.accountMenuOpen = false + try { + await authSession.logout() + } catch (_err) { + // logout errors are non-fatal — proceed to clear state + } + this.authState = 'logged-out' + 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')} + + ${item.label} + ${item.webid && this.layout !== 'mobile' ? html`${item.webid}` : ''} + + ` + + if (item.url) { + return html` + + ${content} + + ` + } + + return html` + + ` + } + + private renderLoggedInActions () { + return html` +
+ + + + + +
+ ` + } + + private renderUserArea () { + 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/header/README.md b/src/v2/components/header/README.md new file mode 100644 index 000000000..a7ae3900f --- /dev/null +++ b/src/v2/components/header/README.md @@ -0,0 +1,208 @@ +# 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/loginButton/README.md`](../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. +- `accountLabel`: label for the logged-in dropdown trigger (default: `Accounts`). +- `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/header/header.test.ts` + +Run tests: + +```bash +npm test -- --runInBand --testPathPatterns=src/v2/components/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/header/header.test.ts b/src/v2/components/header/header.test.ts new file mode 100644 index 000000000..653da907f --- /dev/null +++ b/src/v2/components/header/header.test.ts @@ -0,0 +1,307 @@ +import { Header } from './Header' +import './index' + +describe('SolidUIHeaderElement', () => { + beforeEach(() => { + document.body.innerHTML = '' + Object.defineProperty(window, 'open', { + configurable: true, + writable: true, + value: jest.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.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 = jest.fn() + + header.authState = 'logged-out' + 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 })) + + 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.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() + + header.authState = 'logged-in' + 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 = jest.fn() + + header.authState = 'logged-in' + header.accountLabel = 'Accounts' + 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.textContent).toContain('Accounts') + 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() + header.layout = 'mobile' + header.authState = 'logged-in' + 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() + header.layout = 'mobile' + header.authState = 'logged-in' + 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() + + const helpMenuClicked = jest.fn() + + header.authState = 'logged-in' + 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 = jest.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 + }) +}) diff --git a/src/v2/components/header/index.ts b/src/v2/components/header/index.ts new file mode 100644 index 000000000..be138e144 --- /dev/null +++ b/src/v2/components/header/index.ts @@ -0,0 +1,14 @@ +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/src/v2/components/loginButton/LoginButton.test.ts b/src/v2/components/loginButton/LoginButton.test.ts new file mode 100644 index 000000000..0fb0d4e3d --- /dev/null +++ b/src/v2/components/loginButton/LoginButton.test.ts @@ -0,0 +1,57 @@ +import { LoginButton } from './LoginButton' +import './index' + +jest.mock('solid-logic', () => ({ + authSession: { login: jest.fn() }, + authn: { saveUser: jest.fn() }, + getSuggestedIssuers: jest.fn(() => []), + offlineTestID: jest.fn(() => false), + solidLogicSingleton: { store: { updater: { flagAuthorizationMetadata: jest.fn() } } } +})) + +describe('SolidUILoginButton', () => { + beforeEach(() => { + document.body.innerHTML = '' + Object.defineProperty(window, 'open', { + configurable: true, + writable: true, + value: jest.fn() + }) + localStorage.clear() + }) + + it('is defined as a custom element', () => { + expect(customElements.get('solid-ui-login-button')).toBe(LoginButton) + }) + + it('renders the login button and opens a popup with an associated label and input', async () => { + const loginButton = new LoginButton() + document.body.appendChild(loginButton) + await loginButton.updateComplete + + const button = loginButton.shadowRoot?.querySelector('button.login-button') as HTMLButtonElement + expect(button).not.toBeNull() + expect(button.textContent?.trim()).toBe('Log In') + + button.click() + await loginButton.updateComplete + + const label = loginButton.shadowRoot?.querySelector('label.issuer-text-label') as HTMLLabelElement + const input = loginButton.shadowRoot?.querySelector('input.issuer-text-input') as HTMLInputElement + expect(label).not.toBeNull() + expect(input).not.toBeNull() + expect(label?.getAttribute('for')).toBe(input?.id) + expect(input?.id).toBeTruthy() + }) + + it('renders an icon when the icon property is set', async () => { + const loginButton = new LoginButton() + loginButton.icon = 'https://example.com/login-icon.svg' + document.body.appendChild(loginButton) + await loginButton.updateComplete + + const icon = loginButton.shadowRoot?.querySelector('img.login-button-icon') as HTMLImageElement + expect(icon).not.toBeNull() + expect(icon.src).toContain('https://example.com/login-icon.svg') + }) +}) diff --git a/src/v2/components/loginButton/LoginButton.ts b/src/v2/components/loginButton/LoginButton.ts new file mode 100644 index 000000000..ebe5f33b8 --- /dev/null +++ b/src/v2/components/loginButton/LoginButton.ts @@ -0,0 +1,511 @@ +import { LitElement, html, css } from 'lit' +import { authSession, authn, getSuggestedIssuers, offlineTestID, solidLogicSingleton } from 'solid-logic' +import { phoneIcon as downArrowIcon } from './downArrow' + +export class LoginButton extends LitElement { + static properties = { + label: { type: String, reflect: true }, + theme: { type: String, reflect: true }, + issuerUrl: { type: String, attribute: 'issuer-url', reflect: true }, + icon: { type: String, reflect: true }, + _popupOpen: { state: true }, + _issuerInputValue: { state: true }, + _dropdownOpen: { state: true } + } + + static styles = css` + :host { // default theme + display: inline-block; + position: relative; + z-index: 400; + --login-button-background: var(--lavender-900, #7c4cff); + --login-button-text: var(--color-header-text, #ffffff); + --popup-background: var(--color-background, #F8F9FB); + --popup-text: var(--color-text, #1A1A1A); + --popup-border: var(--color-border, #E5E7EB); + --popup-shadow: var(--box-shadow-sm, 0 1px 4px rgba(124,77,255,0.12)); + --popup-overlay-background: rgba(0, 0, 0, 0.6); + --issuer-input-background: var(--color-background, #F8F9FB); + --issuer-input-text: var(--color-text, #1A1A1A); + --issuer-input-border: var(--color-text, #1A1A1A); + --issuer-button-background: var(--color-background, #F8F9FB); + --issuer-button-text: var(--color-text, #1A1A1A); + --issuer-button-border: var(--color-border, #E5E7EB); + --issuer-button-hover-background: var(--lavender-900, #7c4cff); + --issuer-label-color: var(--grey-purple-700, #1A1A1A); + --issuer-placeholder-color: var(--grey-purple-700, #5e546d);; + --error-text-color: var(--color-error, #B00020); + } + + :host([theme='dark']) { + display: inline-block; + position: relative; + z-index: 900; + --login-button-background: var(--lavender-900, #7c4cff); + --login-button-text: var(--color-header-text, #ffffff); + --popup-background: var(--color-background, #F8F9FB); + --popup-text: var(--color-text, #1A1A1A); + --popup-border: var(--color-border, #E5E7EB); + --popup-shadow: var(--box-shadow-sm, 0 1px 4px rgba(124,77,255,0.12)); + --popup-overlay-background: rgba(0, 0, 0, 0.6); + --issuer-input-background: var(--color-background, #F8F9FB); + --issuer-input-text: var(--color-text, #1A1A1A); + --issuer-input-border: var(--color-text, #1A1A1A); + --issuer-button-background: var(--color-background, #F8F9FB); + --issuer-button-text: var(--color-text, #1A1A1A); + --issuer-button-border: var(--color-text, #1A1A1A); + --issuer-button-hover-background: var(--lavender-900, #7c4cff); + --issuer-label-color: var(--grey-purple-700, #1A1A1A); + --issuer-placeholder-color: var(--grey-purple-700, #5e546d);; + --error-text-color: var(--color-error, #B00020); + } + + .login-button { + display: flex; + height: 35px; + padding: var(--spacing-xxs, 0.3125rem) var(--spacing-xs, 0.75rem); + align-items: center; + gap: var(--spacing-xxs, 0.3125rem); + border-radius: var(--border-radius-base, 0.3125rem); + background: var(--login-button-background); + border: none; + color: var(--login-button-text); + cursor: pointer; + font: inherit; + line-height: 1; + white-space: nowrap; + text-decoration: none; + box-sizing: border-box; + transition: transform 0.2s ease; + } + + .login-button-icon { + width: 16px; + height: 16px; + display: inline-block; + object-fit: contain; + } + + .login-button:active { + transform: translateY(1px); + } + + .popup-dialog { + border: none; + padding: 0; + background: transparent; + outline: none; + overflow: visible; + max-height: none; + max-width: none; + } + + .popup-dialog::backdrop { + background: var(--popup-overlay-background, rgba(0, 0, 0, 0.6)); + } + + .popup-box { + background: var(--popup-background); + color: var(--popup-text); + box-shadow: var(--popup-shadow); + border: 1px solid var(--popup-border); + border-radius: var(--border-radius-md, 0.5rem); + min-width: 480px; + z-index: 1001; + } + + .popup-top-menu { + border-bottom: 1px solid #DDD; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding-bottom: 8px; + margin-bottom: 8px; + padding: 1rem; + background: var(--gray-200, #E5E7EB); + } + + .popup-title { + font-weight: 800; + } + + .popup-close { + background: transparent; + border: none; + cursor: pointer; + font-size: 1.25rem; + line-height: 1; + padding: 0 0.25rem; + } + + .issuer-text-section { + display: flex; + flex-direction: column; + padding: 1rem 1rem 1.75rem; + } + + .issuer-text-label { + color: var(--issuer-label-color); + margin-bottom: 6px; + } + + .issuer-text-row { + display: flex; + flex-direction: row; + gap: 6px; + align-items: flex-start; + } + + .issuer-input-wrapper { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + position: relative; + } + + .issuer-input-field-row { + display: flex; + flex-direction: row; + position: relative; + } + + .issuer-text-input { + flex: 1; + padding: 0.375rem 2.75rem 0.375rem 0.5rem; + border: 1px solid var(--issuer-input-border); + border-radius: var(--border-radius-base, 0.3125rem); + background: var(--issuer-input-background); + color: var(--issuer-input-text); + font: inherit; + min-width: 0; + } + + .issuer-text-input::placeholder { + color: var(--issuer-placeholder-color); + } + + .issuer-dropdown-toggle { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + width: 26px; + height: 26px; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border-radius: var(--border-radius-base, 0.3125rem); + } + + .issuer-dropdown-toggle:hover { + background: var(--color-header-menu-item-hover, #e6dcff); + } + + .issuer-dropdown-toggle svg { + width: 14px; + height: 14px; + display: block; + } + + .issuer-dropdown-list { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + border: 1px solid var(--color-border, #E5E7EB); + border-top: none; + border-radius: 0 0 var(--border-radius-base, 0.3125rem) var(--border-radius-base, 0.3125rem); + background: var(--issuer-input-background); + overflow: visible; + z-index: 10; + box-shadow: 0 4px 12px rgba(124, 77, 255, 0.12); + } + + .issuer-dropdown-item { + display: block; + width: 100%; + padding: 0.625rem 0.75rem; + border: none; + border-bottom: 1px solid var(--color-border, #E5E7EB); + background: transparent; + color: var(--issuer-button-text); + cursor: pointer; + font: inherit; + text-align: left; + box-sizing: border-box; + } + + .issuer-dropdown-item:last-child { + border-bottom: none; + } + + .issuer-dropdown-item:hover { + background: var(--color-header-menu-item-hover, #e6dcff); + border-radius: var(--border-radius-base-md, 0.5rem); + } + + .popup-footer { + display: flex; + flex-direction: row; + justify-content: center; + gap: 8px; + padding: 0.75rem 1rem 1rem; + } + + .popup-footer-hr { + margin: 0; + border: none; + border-top: 1px solid var(--popup-border, #E5E7EB); + } + + .popup-cancel-button { + padding: 0.5rem 1.25rem; + border: 1px solid #C0BFC7; + border-radius: var(--border-radius-base, 0.3125rem); + background: var(--popup-background); + color: #314158; + cursor: pointer; + font: inherit; + } + + .popup-cancel-button:hover { + background: #D1D5DB; + } + + .popup-login-button { + padding: 0.5rem 1.25rem; + border: none; + border-radius: var(--border-radius-base, 0.3125rem); + background: var(--lavender-900, #7c4cff); + color: #ffffff; + cursor: pointer; + font: inherit; + } + + .popup-login-button:hover { + background: #6a3de8; + } + + .popup-login-button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .error-msg { + color: var(--error-text-color); + font-size: 0.875rem; + margin-top: 8px; + } + ` + + declare label: string + declare theme: 'light' | 'dark' + declare issuerUrl: string + declare icon: string + declare _popupOpen: boolean + declare _issuerInputValue: string + declare _dropdownOpen: boolean + + private _issuerInputId = `issuer-url-input-${Math.random().toString(36).slice(2, 10)}` + private _errorMsg = '' + + constructor () { + super() + this.label = 'Log In' + this.theme = 'light' + this.issuerUrl = '' + this.icon = '' + this._popupOpen = false + this._issuerInputValue = '' + this._dropdownOpen = false + } + + connectedCallback () { + super.connectedCallback() + } + + disconnectedCallback () { + super.disconnectedCallback() + } + + private _openPopup () { + const offline = offlineTestID() + if (offline) { + this._loginComplete(offline.uri) + return + } + this._issuerInputValue = (typeof localStorage !== 'undefined' && localStorage.getItem('loginIssuer')) || this.issuerUrl || '' + this._errorMsg = '' + this._popupOpen = true + } + + private _closePopup () { + this._popupOpen = false + } + + updated () { + const dialog = this.shadowRoot?.querySelector('dialog') as HTMLDialogElement | null + if (!dialog) return + if (this._popupOpen && !dialog.open) { + dialog.showModal() + } else if (!this._popupOpen && dialog.open) { + dialog.close() + } + } + + private async _loginToIssuer (issuerUri: string) { + if (!issuerUri) return + try { + // clear authorization metadata from store + ;(solidLogicSingleton.store.updater as any).flagAuthorizationMetadata() + + const preLoginRedirectHash = new URL(window.location.href).hash + if (preLoginRedirectHash) { + window.localStorage.setItem('preLoginRedirectHash', preLoginRedirectHash) + } + window.localStorage.setItem('loginIssuer', issuerUri) + + const locationUrl = new URL(window.location.href) + locationUrl.hash = '' + await authSession.login({ + redirectUrl: locationUrl.href, + oidcIssuer: issuerUri + }) + } catch (err: any) { + this._errorMsg = err.message || String(err) + this.requestUpdate() + } + } + + private _loginComplete (webIdUri: string) { + authn.saveUser(webIdUri) + this.dispatchEvent(new CustomEvent('login-success', { + detail: { webId: webIdUri }, + bubbles: true, + composed: true + })) + } + + private _handleGoClick () { + this._dropdownOpen = false + this._loginToIssuer(this._issuerInputValue) + } + + private _toggleDropdown () { + this._dropdownOpen = !this._dropdownOpen + } + + private _selectIssuerFromDropdown (uri: string) { + this._issuerInputValue = uri + this._dropdownOpen = false + } + + private _handleInputChange (e: Event) { + this._issuerInputValue = (e.target as HTMLInputElement).value + } + + private _handleInputKeydown (e: KeyboardEvent) { + if (e.key === 'Enter') { + this._loginToIssuer(this._issuerInputValue) + } + if (e.key === 'Escape') { + this._closePopup() + } + } + + private _renderPopup () { + const suggestedIssuers = getSuggestedIssuers() + return html` + + ` + } + + render () { + return html` + + + + ${this._popupOpen ? this._renderPopup() : ''} + + ` + } +} diff --git a/src/v2/components/loginButton/README.md b/src/v2/components/loginButton/README.md new file mode 100644 index 000000000..82b58fffe --- /dev/null +++ b/src/v2/components/loginButton/README.md @@ -0,0 +1,120 @@ +# solid-ui-login-button component + +A Lit-based custom element that encapsulates the full Solid OIDC login flow. It renders a styled button that opens an identity provider (IDP) selection popup, handles the OIDC redirect, and emits a `login-success` event when the user is authenticated. + +Used automatically by `` when `auth-state="logged-out"` — see the [Header README](../header/README.md). + +## Installation + +```bash +npm install solid-ui +``` + +## Usage in a bundled project (webpack, Vite, Rollup, etc.) + +```javascript +import { LoginButton } from 'solid-ui/components/login-button' +``` + +```html + + + +``` + +## Usage in a plain HTML page (CDN / script tag) + +```html + + + +``` + +## TypeScript + +```typescript +import { LoginButton } from 'solid-ui/components/login-button' + +const btn = document.querySelector('solid-ui-login-button') as LoginButton +btn.label = 'Sign in to Solid' +btn.addEventListener('login-success', (e: CustomEvent) => { + const { webId } = e.detail +}) +``` + +## API + +### Properties / attributes + +| Property | Attribute | Type | Default | Description | +|-------------|---------------|--------------------|----------|-------------| +| `label` | `label` | `string` | `Log In` | Button text. Overridable via the default slot. | +| `issuerUrl` | `issuer-url` | `string` | `''` | Pre-fills the IDP URL input in the popup. If `localStorage.loginIssuer` is set it takes precedence. | +| `icon` | `icon` | `string` | `''` | URL of a decorative icon displayed on the left side of the button text. When used inside ``, the header suppresses the icon. | +| `theme` | `theme` | `'light' \| 'dark'` | `'light'` | Sets the colour theme. Use `'dark'` when placing the button on a dark background. | + +### Events + +| Event | Detail | Description | +|-----------------|-------------------------|-------------| +| `login-success` | `{ webId: string }` | Fired after a successful OIDC login. `webId` is the authenticated user's WebID URI. | + +### Slots + +| Slot | Description | +|-----------|-------------| +| (default) | Replaces the button label text. | + +### CSS custom properties + +The component inherits Header CSS variables automatically when used inside ``. When used standalone, these can be set on a parent or on `:root`: + +| Variable | Fallback | Description | +|-----------------------------------|-------------------------------------|-------------| +| `--login-button-background` | `--lavender-900` / `#7c4cff` | Login button background colour | +| `--login-button-text` | `--color-header-text` / `#ffffff` | Login button text colour | +| `--popup-background` | `--color-background` / `#F8F9FB` | Popup background colour | +| `--popup-text` | `--color-text` / `#1A1A1A` | Popup text colour | +| `--popup-border` | `--color-border` / `#E5E7EB` | Popup border colour | +| `--popup-shadow` | `--box-shadow-sm` / `0 1px 4px …` | Popup box shadow | +| `--popup-overlay-background` | `rgba(0, 0, 0, 0.6)` | Modal backdrop colour | +| `--issuer-input-background` | `--color-background` / `#F8F9FB` | IDP input background | +| `--issuer-input-text` | `--color-text` / `#1A1A1A` | IDP input text colour | +| `--issuer-input-border` | `--color-text` / `#1A1A1A` | IDP input border colour | +| `--issuer-button-hover-background`| `--lavender-900` / `#7c4cff` | Dropdown item hover background | +| `--issuer-label-color` | `--grey-purple-700` / `#1A1A1A` | IDP label text colour | +| `--issuer-placeholder-color` | `--grey-purple-700` / `#5e546d` | IDP input placeholder colour | +| `--error-text-color` | `--color-error` / `#B00020` | Validation error text colour | + +### Theming + +Set `theme="dark"` for dark backgrounds. The button background (`--primary-royal-lavender`) stays the same; the text colour switches to white. + +```html + +``` + +When used inside ``, the theme attribute is forwarded automatically. When the header is in `mobile` layout, its built-in login button suppresses the `icon`. + +## Popup behaviour + +- Opens a native `` via `showModal()`, placing it in the browser's **top layer** so it always renders above all other page content regardless of z-index stacking contexts. +- The backdrop is styled via `::backdrop`. +- Contains a text input pre-filled from `localStorage.loginIssuer` or the `issuer-url` attribute. +- If `solid-logic`'s `getSuggestedIssuers()` returns entries, a **▼ arrow button** inside the input reveals a dropdown list of suggested identity providers below the field. Selecting one fills the input. +- Footer row with **Cancel** (closes the popup) and **Login** (initiates the OIDC redirect) buttons centered at the bottom. The Login button is disabled while the input is empty. +- Closes on **Escape**, clicking the **Cancel** button, clicking the ✕ button, or clicking the backdrop. +- Saves the chosen issuer to `localStorage.loginIssuer` for future visits. +- Uses `offlineTestID()` from `solid-logic` for offline test environments — the popup is bypassed and `login-success` fires immediately. + +## Build + +```bash +npm run build +``` + +Webpack emits bundles to `dist/components/loginButton/index.*`. diff --git a/src/v2/components/loginButton/downArrow.ts b/src/v2/components/loginButton/downArrow.ts new file mode 100644 index 000000000..8fed85a52 --- /dev/null +++ b/src/v2/components/loginButton/downArrow.ts @@ -0,0 +1,10 @@ +import { html } from 'lit-html' + +export const phoneIcon = html` + + + +` \ No newline at end of file diff --git a/src/v2/components/loginButton/index.ts b/src/v2/components/loginButton/index.ts new file mode 100644 index 000000000..0dff76088 --- /dev/null +++ b/src/v2/components/loginButton/index.ts @@ -0,0 +1,9 @@ +import { LoginButton } from './LoginButton' + +export { LoginButton } + +const LOGIN_BUTTON_TAG_NAME = 'solid-ui-login-button' + +if (!customElements.get(LOGIN_BUTTON_TAG_NAME)) { + customElements.define(LOGIN_BUTTON_TAG_NAME, LoginButton) +} diff --git a/src/v2/components/signupButton/README.md b/src/v2/components/signupButton/README.md new file mode 100644 index 000000000..a26b37eb7 --- /dev/null +++ b/src/v2/components/signupButton/README.md @@ -0,0 +1,91 @@ +# solid-ui-signup-button component + +A Lit-based custom element that renders a styled button which opens a Solid Pod signup page in a new browser tab. + +## Installation + +```bash +npm install solid-ui +``` + +## Usage in a bundled project (webpack, Vite, Rollup, etc.) + +```javascript +import { SignupButton } from 'solid-ui/components/signup-button' +``` + +```html + +``` +## Usage in a plain HTML page (CDN / script tag) + +```html + + + +``` + +## TypeScript + +```typescript +import { SignupButton } from 'solid-ui/components/signup-button' + +const btn = document.querySelector('solid-ui-signup-button') as SignupButton +btn.label = 'Create a Pod' +btn.signupUrl = 'https://solidproject.org/get_a_pod' +``` + +## API + +### Properties / attributes + +| Property | Attribute | Type | Default | Description | +|-------------|--------------|---------------------|--------------------------------------|-------------| +| `label` | `label` | `string` | `Sign Up` | Button text. Overridable via the default slot. | +| `signupUrl` | `signup-url` | `string` | `https://solidproject.org/get_a_pod` | URL opened in a new tab when the button is clicked. | +| `icon` | `icon` | `string` | `''` | URL of a decorative icon displayed on the left side of the label. | +| `layout` | `layout` | `'desktop' \| 'mobile'` | `'desktop'` | When set to `mobile`, removes the button border for a compact header appearance. | +| `theme` | `theme` | `'light' \| 'dark'` | `'light'` | Sets the colour theme. Use `'dark'` when placing the button on a dark background. | + +### Slots + +| Slot | Description | +|-----------|-------------| +| (default) | Replaces the button label text. | + +### CSS shadow parts + +| Part | Description | +|-----------------|-------------| +| `signup-button` | The inner ` + ` + } +} diff --git a/src/v2/components/signupButton/index.ts b/src/v2/components/signupButton/index.ts new file mode 100644 index 000000000..e3ea30c71 --- /dev/null +++ b/src/v2/components/signupButton/index.ts @@ -0,0 +1,9 @@ +import { SignupButton } from './SignupButton' + +export { SignupButton } + +const SIGNUP_BUTTON_TAG_NAME = 'solid-ui-signup-button' + +if (!customElements.get(SIGNUP_BUTTON_TAG_NAME)) { + customElements.define(SIGNUP_BUTTON_TAG_NAME, SignupButton) +} diff --git a/src/widgets/buttons.ts b/src/widgets/buttons.ts index c51039bae..3b7756a33 100644 --- a/src/widgets/buttons.ts +++ b/src/widgets/buttons.ts @@ -9,6 +9,7 @@ import { info } from '../log' import { uploadFiles, makeDraggable, makeDropTarget } from './dragAndDrop' import { store } from 'solid-logic' import * as utils from '../utils' +import newPersonIconDataURI from '../newperson' import { errorMessageBlock } from './error' import { addClickListenerToElement, createImageDiv, wrapDivInATR } from './widgetHelpers' import { linkIcon, createLinkForURI } from './buttons/iconLinks' @@ -49,6 +50,11 @@ export type RenderAsDivOptions = { wrapInATR?: boolean } +export type AttachmentSupportingInfo = string | HTMLElement | null | undefined +export type RenderSupportingInfo = (target: NamedNode, dom: HTMLDocument) => AttachmentSupportingInfo +export type AttachmentInlineInfo = string | HTMLElement | null | undefined +export type RenderNameSuffix = (target: NamedNode, dom: HTMLDocument) => AttachmentInlineInfo + function getStatusArea (context?: StatusAreaContext) { let box = (context && context.statusArea) || (context && context.div) || null if (box) return box @@ -247,9 +253,10 @@ export const iconForClass = { 'solid:Pod': 'noun_Cabinet_1434380.svg', 'vcard:Group': 'noun_339237.svg', 'vcard:Organization': 'noun_143899.svg', - 'vcard:Individual': 'noun_15059.svg', - 'schema:Person': 'noun_15059.svg', - 'foaf:Person': 'noun_15059.svg', + // TEMP HACK: Use locally bundled icon; switch to solid-assets icon mapping soon. + 'vcard:Individual': newPersonIconDataURI, + 'schema:Person': newPersonIconDataURI, + 'foaf:Person': newPersonIconDataURI, 'foaf:Agent': 'noun_98053.svg', 'acl:AuthenticatedAgent': 'noun_99101.svg', 'prov:SoftwareAgent': 'noun_Robot_849764.svg', // Bot @@ -406,7 +413,13 @@ export function setImage (element: HTMLElement, thing: NamedNode) { // 20191230a const pref = k.split(':')[0] const id = k.split(':')[1] const theClass = ns[pref](id) - iconForClassMap[theClass.uri] = uri.join(iconForClass[k], iconBase) + const iconRef = iconForClass[k] + /* Temporary hack for a new design icon */ + if (iconRef.startsWith('data:')) { + iconForClassMap[theClass.uri] = iconRef + } else { + iconForClassMap[theClass.uri] = uri.join(iconRef, iconBase) + } } const happy = trySetImage(element, thing, iconForClassMap) @@ -721,10 +734,38 @@ export function renderAsRow (dom: HTMLDocument, pred: NamedNode, obj: NamedNode, td3.setAttribute('style', 'vertical-align: middle; width:2em; padding:0.5em; height: 4em;') td1.appendChild(image) + const nameLine = td2.appendChild(dom.createElement('div')) + const nameText = nameLine.appendChild(dom.createElement('span')) if (options.title) { - td2.textContent = options.title + nameText.textContent = options.title } else { - setName(td2, obj) // This is async + setName(nameText, obj) // This is async + } + + if (typeof options.renderNameSuffix === 'function') { + const inlineInfo = options.renderNameSuffix(obj, dom) + if (inlineInfo) { + const nameSuffix = nameLine.appendChild(dom.createElement('span')) + nameSuffix.setAttribute('style', 'margin-left: 0.4em; opacity: 0.8;') + if (typeof inlineInfo === 'string') { + nameSuffix.textContent = inlineInfo + } else { + nameSuffix.appendChild(inlineInfo) + } + } + } + + if (typeof options.renderSupportingInfo === 'function') { + const supportingInfo = options.renderSupportingInfo(obj, dom) + if (supportingInfo) { + const supportingLine = td2.appendChild(dom.createElement('div')) + supportingLine.setAttribute('style', 'font-size: 90%; opacity: 0.8;') + if (typeof supportingInfo === 'string') { + supportingLine.textContent = supportingInfo + } else { + supportingLine.appendChild(supportingInfo) + } + } } if (options.deleteFunction) { @@ -826,6 +867,10 @@ export function refreshTree (root: any): void { /** * Options argument for [[attachmentList]] function */ +/* The following options renderSupportingInfo and renderNameSuffix + were generated by AI Model: GPT-5.3-Codex */ +/* Prompt: Add two options one renderSupportingInfo to allow the caller to add some additional information + to be displayed below the name. Also add renderNameSuffix to allow the caller to add pronouns or other suffixes to the name. */ export type attachmentListOptions = { doc?: NamedNode modify?: boolean @@ -833,6 +878,8 @@ export type attachmentListOptions = { predicate?: NamedNode uploadFolder?: NamedNode noun?: string + renderSupportingInfo?: RenderSupportingInfo + renderNameSuffix?: RenderNameSuffix } /** @@ -843,6 +890,8 @@ export type attachmentListOptions = { */ export function attachmentList (dom: HTMLDocument, subject: NamedNode, div: HTMLElement, options: attachmentListOptions = {}) { // options = options || {} + const docsWaitingForRowRefresh = new Set() + const hasAsyncEnrichedRowOptions = !!(options.renderSupportingInfo || options.renderNameSuffix) const deleteAttachment = function (target) { if (!kb.updater) { @@ -866,6 +915,23 @@ export function attachmentList (dom: HTMLDocument, subject: NamedNode, div: HTML function createNewRow (target) { const theTarget = target const opt: any = { noun } + opt.renderSupportingInfo = options.renderSupportingInfo + opt.renderNameSuffix = options.renderNameSuffix + + if (hasAsyncEnrichedRowOptions && target?.uri && kb.fetcher) { + const targetDoc = target.doc() + const requestState = targetDoc?.uri ? kb.fetcher.requested?.[targetDoc.uri] : undefined + const shouldWaitForFetch = requestState !== 'done' && requestState !== 'failed' + if (targetDoc?.uri && shouldWaitForFetch && !docsWaitingForRowRefresh.has(targetDoc.uri)) { + docsWaitingForRowRefresh.add(targetDoc.uri) + // Root fix: these row options can depend on async profile data, so rerender once fetch completes. + kb.fetcher.nowOrWhenFetched(targetDoc, undefined, () => { + docsWaitingForRowRefresh.delete(targetDoc.uri) + refresh() + }) + } + } + if (modify) { opt.deleteFunction = function () { deleteAttachment(theTarget) @@ -877,7 +943,18 @@ export function attachmentList (dom: HTMLDocument, subject: NamedNode, div: HTML const refresh = function () { const things = kb.each(subject, predicate) things.sort() - utils.syncTableToArray(attachmentTable, things, createNewRow) + utils.syncTableToArray( + attachmentTable, + things, + createNewRow, + hasAsyncEnrichedRowOptions + ? function (row, thing) { + // When row content depends on async profile data, recreate matched rows on refresh. + const replacement = createNewRow(thing) + return replacement + } + : undefined + ) } function droppedURIHandler (uris) { diff --git a/test/helpers/setup.ts b/test/helpers/setup.ts index e87a158b3..9cbbd669a 100644 --- a/test/helpers/setup.ts +++ b/test/helpers/setup.ts @@ -21,7 +21,6 @@ global.WritableStream = WritableStream // Node provides MessagePort via worker_threads; jsdom/undici expects it in global scope try { - // eslint-disable-next-line @typescript-eslint/no-var-requires const { MessageChannel, MessagePort } = require('worker_threads') global.MessageChannel = MessageChannel global.MessagePort = MessagePort diff --git a/tsconfig.json b/tsconfig.json index c31e05022..20ab8849e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,8 @@ "outDir": "dist" /* Redirect output structure to the directory. */, "rootDir": "src/", + "incremental": true, + "tsBuildInfoFile": "./.tsbuildinfo", // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ // "composite": true, /* Enable project compilation */ diff --git a/tsconfig.test.json b/tsconfig.test.json index b863b49d9..e2937be7c 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -1,6 +1,6 @@ { "extends": "./tsconfig.json", - "include": ["test/**/*"], + "include": ["test/**/*", "src/**/*.test.ts"], "compilerOptions": { "rootDir": ".", "noEmit": true diff --git a/webpack.config.mjs b/webpack.config.mjs index c015aa931..c41f5ff84 100644 --- a/webpack.config.mjs +++ b/webpack.config.mjs @@ -19,13 +19,33 @@ const esmExternals = { } const common = { - entry: './src/index.ts', + entry: { + // Keep the legacy UMD global export for the main bundle only. + // Component entrypoints should build as standalone scripts without assigning + // a shared global like window.UI, so they do not clobber the main bundle + // or each other when loaded via script tags. + main: { + import: './src/index.ts', + library: { + name: 'UI', + type: 'umd' + } + }, + header: { + import: './src/v2/components/header/index.ts' + }, + loginButton: { + import: './src/v2/components/loginButton/index.ts' + }, + signupButton: { + import: './src/v2/components/signupButton/index.ts' + }, + footer: { + import: './src/v2/components/footer/index.ts' + } + }, output: { path: path.resolve(process.cwd(), 'dist'), - library: { - name: 'UI', - type: 'umd' - }, globalObject: 'this', publicPath: '', iife: true, @@ -40,6 +60,9 @@ const common = { fallback: { path: false } }, devtool: 'source-map', + cache: { + type: 'filesystem' + }, module: { rules: [ { @@ -48,12 +71,7 @@ const common = { use: { loader: 'babel-loader', options: { - presets: [ - ['@babel/preset-env', { - modules: false // Preserve ES modules for webpack - }], - '@babel/preset-typescript' - ] + cacheDirectory: true } } }, { @@ -72,7 +90,9 @@ const minified = { mode: 'production', output: { ...common.output, - filename: 'solid-ui.min.js' + filename: pathData => pathData.chunk.name === 'main' + ? 'solid-ui.min.js' + : 'components/[name]/index.min.js' }, externals: externalsBase, optimization: { @@ -87,7 +107,9 @@ const unminified = { mode: 'production', output: { ...common.output, - filename: 'solid-ui.js' + filename: pathData => pathData.chunk.name === 'main' + ? 'solid-ui.js' + : 'components/[name]/index.js' }, externals: externalsBase, optimization: { @@ -98,9 +120,15 @@ const unminified = { // ESM minified, rdflib external const esmMinified = { ...common, + entry: { + ...common.entry, + main: './src/index.ts' + }, output: { path: path.resolve(process.cwd(), 'dist'), - filename: 'solid-ui.esm.min.js', + filename: pathData => pathData.chunk.name === 'main' + ? 'solid-ui.esm.min.js' + : 'components/[name]/index.esm.min.js', library: { type: 'module' }, @@ -122,9 +150,15 @@ const esmMinified = { // ESM unminified, rdflib external const esmUnminified = { ...common, + entry: { + ...common.entry, + main: './src/index.ts' + }, output: { path: path.resolve(process.cwd(), 'dist'), - filename: 'solid-ui.esm.js', + filename: pathData => pathData.chunk.name === 'main' + ? 'solid-ui.esm.js' + : 'components/[name]/index.esm.js', library: { type: 'module' }, @@ -142,9 +176,11 @@ const esmUnminified = { } } -export default [ - minified, - unminified, - esmMinified, - esmUnminified -] +export default (env, argv) => { + const isDev = argv?.mode === 'development' + const devtool = isDev ? 'eval-cheap-module-source-map' : 'source-map' + return [minified, unminified, esmMinified, esmUnminified].map(config => ({ + ...config, + devtool + })) +}