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..e9b9d2b45 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 ` + + + + +``` + +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. +- `brandLink`: URL string for the brand link (default: `#`). +- `layout`: `desktop` or `mobile`. Mobile layout hides the brand logo link and does not render the help menu. +- `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. +- `signUpAction`: object for the logged-out Sign Up action. The `label` field sets the button text and the `url` field (default: `https://solidproject.org/get_a_pod`) is the destination opened in a new tab when the button is clicked. +- `accountLabel`: label for the logged-in dropdown trigger (default: `Accounts`). +- `accountAvatar`: avatar URL used as the logged-in dropdown icon. +- `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. + +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..4d1e71f36 --- /dev/null +++ b/src/v2/components/header/header.test.ts @@ -0,0 +1,215 @@ +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' } + header.signUpAction = { label: 'Sign Up', url: '/signup' } + + 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(signUpLink.getAttribute('label')).toBe('Sign Up') + expect(signUpLink.getAttribute('signup-url')).toBe('/signup') + + loginButton.dispatchEvent(new CustomEvent('login-success', { bubbles: true, composed: true })) + + expect(authActionSelected).toHaveBeenCalledWith({ + role: 'login' + }) + }) + + 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.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') + + 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('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.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) + + 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..9dd3e5fa1 --- /dev/null +++ b/src/v2/components/loginButton/LoginButton.test.ts @@ -0,0 +1,46 @@ +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() + }) +}) diff --git a/src/v2/components/loginButton/LoginButton.ts b/src/v2/components/loginButton/LoginButton.ts new file mode 100644 index 000000000..65ab307eb --- /dev/null +++ b/src/v2/components/loginButton/LoginButton.ts @@ -0,0 +1,375 @@ +import { LitElement, html, css } from 'lit' +import { authSession, authn, getSuggestedIssuers, offlineTestID, solidLogicSingleton } from 'solid-logic' + +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 }, + _popupOpen: { state: true }, + _issuerInputValue: { state: true } + } + + static styles = css` + :host { // default theme + display: inline-block; + --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.2); + --issuer-input-background: var(--color-background, #F8F9FB); + --issuer-input-text: var(--color-text, #1A1A1A); + --issuer-input-border: var(--color-border, #E5E7EB); + --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, #5e546d); + --issuer-placeholder-color: var(--grey-purple-700, #5e546d);; + --error-text-color: var(--color-error, #B00020); + } + + :host([theme='dark']) { + display: inline-block; + --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.2); + --issuer-input-background: var(--color-background, #F8F9FB); + --issuer-input-text: var(--color-text, #1A1A1A); + --issuer-input-border: var(--color-border, #E5E7EB); + --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, #5e546d); + --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:active { + transform: translateY(1px); + } + + .popup-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + background: var(--popup-overlay-background); + } + + .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-sm, 0.2rem); + min-width: 400px; + padding: 10px; + 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; + } + + .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 { + border-bottom: 1px solid #DDD; + display: flex; + flex-direction: column; + padding-top: 10px; + padding-bottom: 10px; + } + + .issuer-text-label { + color: var(--issuer-label-color); + margin-bottom: 6px; + } + + .issuer-text-row { + display: flex; + flex-direction: row; + gap: 6px; + } + + .issuer-text-input { + flex: 1; + padding: 0.375rem 0.5rem; + border: 1px solid var(--issuer-input-border); + border-radius: var(--border-radius-sm, 0.2rem); + background: var(--issuer-input-background); + color: var(--issuer-input-text); + font: inherit; + } + + .issuer-text-input::placeholder { + color: var(--issuer-placeholder-color); + } + + .issuer-go-button { + padding: 0.375rem 0.75rem; + border: 1px solid var(--issuer-button-border); + border-radius: var(--border-radius-sm, 0.2rem); + background: var(--issuer-button-background); + color: var(--issuer-button-text); + cursor: pointer; + font: inherit; + } + + .issuer-go-button:hover { + background: var(--issuer-button-hover-background); + } + + .issuer-button-section { + display: flex; + flex-direction: column; + padding-top: 10px; + gap: 8px; + } + + .issuer-button-label { + color: var(--issuer-label-color); + } + + .issuer-button { + height: 38px; + border: 1px solid var(--issuer-button-border); + border-radius: var(--border-radius-sm, 0.2rem); + background: var(--issuer-button-background); + color: var(--issuer-button-text); + cursor: pointer; + font: inherit; + } + + .issuer-button:hover { + background: var(--issuer-button-hover-background); + } + + .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 _popupOpen: boolean + declare _issuerInputValue: string + + 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._popupOpen = false + this._issuerInputValue = '' + } + + connectedCallback () { + super.connectedCallback() + document.addEventListener('click', this._handleDocumentClick) + document.addEventListener('keydown', this._handleDocumentKeydown) + } + + disconnectedCallback () { + document.removeEventListener('click', this._handleDocumentClick) + document.removeEventListener('keydown', this._handleDocumentKeydown) + super.disconnectedCallback() + } + + private readonly _handleDocumentClick = (event: MouseEvent) => { + if (this._popupOpen && !event.composedPath().includes(this)) { + this._popupOpen = false + } + } + + private readonly _handleDocumentKeydown = (event: KeyboardEvent) => { + if (this._popupOpen && event.key === 'Escape') { + this._closePopup() + } + } + + 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 + } + + 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._loginToIssuer(this._issuerInputValue) + } + + 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..42a402de4 --- /dev/null +++ b/src/v2/components/loginButton/README.md @@ -0,0 +1,104 @@ +# 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. | +| `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 | +|-------------------------------|-------------------------------------|-------------| +| `--primary-royal-lavender` | `#7C4DFF` | Button background colour | +| `--login-button-text` | `--header-button-text` / `#0f172a` | Button text colour (light theme) | + +### 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. + +## Popup behaviour + +- Opens an IDP URL text input pre-filled from `localStorage.loginIssuer` or the `issuer-url` attribute. +- Lists suggested identity providers from `solid-logic`'s `getSuggestedIssuers()`. +- Closes on **Escape**, 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/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..4403e6f65 --- /dev/null +++ b/src/v2/components/signupButton/README.md @@ -0,0 +1,88 @@ +# 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. | +| `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/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..ba5dc4527 100644 --- a/webpack.config.mjs +++ b/webpack.config.mjs @@ -19,13 +19,30 @@ 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' + } + }, output: { path: path.resolve(process.cwd(), 'dist'), - library: { - name: 'UI', - type: 'umd' - }, globalObject: 'this', publicPath: '', iife: true, @@ -40,6 +57,9 @@ const common = { fallback: { path: false } }, devtool: 'source-map', + cache: { + type: 'filesystem' + }, module: { rules: [ { @@ -48,12 +68,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 +87,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 +104,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 +117,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 +147,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 +173,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 + })) +}