diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index a201914c4..000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,16 +0,0 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file - -version: 2 -updates: - - package-ecosystem: "npm" # See documentation for possible values - directory: "/" # Location of package manifests - schedule: - interval: "weekly" - - package-ecosystem: "github-actions" - directory: "/" - schedule: - # Check for updates to GitHub Actions every week - interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40ac428d2..56278952d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,14 +1,14 @@ name: CI -permissions: - contents: write - pull-requests: write + on: push: branches: - main + - staging pull_request: branches: - main + - staging workflow_dispatch: jobs: @@ -16,9 +16,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: - - 20.x - - 22.x + node-version: [22, 24] steps: - uses: actions/checkout@v6 - name: Use Node.js ${{ matrix.node-version }} @@ -30,8 +28,8 @@ jobs: - run: npm run build - run: npm run doc - name: Save build - if: matrix.node-version == '20.x' - uses: actions/upload-artifact@v6 + if: matrix.node-version == 22 + uses: actions/upload-artifact@v7 with: name: build path: | @@ -42,9 +40,9 @@ jobs: gh-pages: needs: build runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/staging' steps: - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 with: name: build - uses: peaceiris/actions-gh-pages@v4 @@ -57,6 +55,9 @@ jobs: needs: build # After the E2E and build jobs, if one of them fails, it won't merge the PR. runs-on: ubuntu-latest if: ${{ github.actor == 'dependabot[bot]' && github.event_name == 'pull_request'}} # Detect that the PR author is dependabot + permissions: + contents: write + pull-requests: write steps: - name: Enable auto-merge for Dependabot PRs run: gh pr merge --auto --merge "$PR_URL" # Use Github CLI to merge automatically the PR @@ -64,42 +65,60 @@ jobs: PR_URL: ${{github.event.pull_request.html_url}} GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - npm-publish-build: + npm-publish-dev: needs: build + uses: SolidOS/solidos/.github/workflows/publish-prerelease.yml@main + with: + node_version: 22 + + npm-publish-latest: + needs: [build] runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + permissions: + id-token: write # Required for OIDC + contents: read steps: - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v8 with: name: build - uses: actions/setup-node@v6 with: - node-version: 20.x - - uses: rlespinasse/github-slug-action@v4.x - - name: Append commit hash to package version - run: 'sed -i -E "s/(\"version\": *\"[^\"]+)/\1-${GITHUB_SHA_SHORT}/" package.json' + node-version: 22 # required for OIDC npm@latest + registry-url: 'https://registry.npmjs.org' + - name: Update npm to latest (required for OIDC) + run: npm install -g npm@latest - name: Disable pre- and post-publish actions run: 'sed -i -E "s/\"((pre|post)publish)/\"ignore:\1/" package.json' - - uses: JS-DevTools/npm-publish@v4.1.0 + - name: Publish to npm if: github.actor != 'dependabot[bot]' && github.actor != 'dependabot-preview[bot]' - with: - token: ${{ secrets.NPM_TOKEN }} - tag: ${{ env.GITHUB_REF_SLUG }} - - npm-publish-latest: - needs: [build, npm-publish-build] + run: npm publish --tag latest + + github-release: + needs: [npm-publish-latest] runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + permissions: + contents: write steps: - - uses: actions/download-artifact@v7 - with: - name: build - - uses: actions/setup-node@v6 - with: - node-version: 20.x - - name: Disable pre- and post-publish actions - run: 'sed -i -E "s/\"((pre|post)publish)/\"ignore:\1/" package.json' - - uses: JS-DevTools/npm-publish@v4.1.0 - if: github.actor != 'dependabot[bot]' && github.actor != 'dependabot-preview[bot]' + - uses: actions/checkout@v6 with: - token: ${{ secrets.NPM_TOKEN }} - tag: latest + fetch-depth: 0 + - name: Create GitHub release with generated notes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="v$(node -p 'require("./package.json").version')" + + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG already exists. Skipping." + exit 0 + fi + + if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists on origin. Creating release from existing tag." + gh release create "$TAG" --verify-tag --generate-notes + else + echo "Creating tag and release $TAG from commit $GITHUB_SHA." + gh release create "$TAG" --target "$GITHUB_SHA" --generate-notes + fi 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/.nvmrc b/.nvmrc index 1d898f1fe..42a1c98ac 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.19.2 +v22.22.0 diff --git a/.storybook/main.js b/.storybook/main.js index 4afc6c0c8..a80bd7872 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -1,10 +1,11 @@ export default { - stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], addons: [ '@storybook/addon-links', - '@storybook/addon-actions', - '@storybook/addon-essentials' + '@storybook/addon-essentials', + '@storybook/addon-mdx-gfm', + '@storybook/addon-webpack5-compiler-swc' ], framework: { diff --git a/.storybook/preview.js b/.storybook/preview.js index e01c288ab..996229f9d 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -8,6 +8,4 @@ if (typeof window !== 'undefined') { window.SolidLogic = solidLogic } -export const parameters = { - actions: { argTypesRegex: '^on[A-Z].*' }, -} +export const parameters = {} diff --git a/README.md b/README.md index 252a1b484..c34a97fb8 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ User Interface widgets and utilities for Solid (solid-ui) These are HTML5 widgets which connect to a solid store. Building blocks for solid-based apps. -Vanilla JS. Includes large widgets like chat, table, matrix, form fields, and small widgets. +Vanilla JS. Includes large widgets like chat, table, matrix, form fields, and small widgets. See [Solid-Ui Storybook](http://solidos.github.io/solid-ui/examples/storybook/) for UI widgets. See [Solid-UI API](https://solidos.github.io/solid-ui/docs/api/) for UI functions. @@ -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 ` +``` + +## 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. | +| `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. | + +### 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`. When the `layout` attribute is set to `mobile`, the button renders without a border for a cleaner compact mobile/header presentation. + +## 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/auth/loginButton/downArrow.ts b/src/v2/components/auth/loginButton/downArrow.ts new file mode 100644 index 000000000..0578ecbe6 --- /dev/null +++ b/src/v2/components/auth/loginButton/downArrow.ts @@ -0,0 +1,10 @@ +import { html } from 'lit-html' + +export const phoneIcon = html` + + + +` diff --git a/src/v2/components/auth/loginButton/index.ts b/src/v2/components/auth/loginButton/index.ts new file mode 100644 index 000000000..0dff76088 --- /dev/null +++ b/src/v2/components/auth/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/auth/signupButton/README.md b/src/v2/components/auth/signupButton/README.md new file mode 100644 index 000000000..a26b37eb7 --- /dev/null +++ b/src/v2/components/auth/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/auth/signupButton/index.ts b/src/v2/components/auth/signupButton/index.ts new file mode 100644 index 000000000..e3ea30c71 --- /dev/null +++ b/src/v2/components/auth/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/v2/components/forms/combobox/Combobox.test.ts b/src/v2/components/forms/combobox/Combobox.test.ts new file mode 100644 index 000000000..cf792666f --- /dev/null +++ b/src/v2/components/forms/combobox/Combobox.test.ts @@ -0,0 +1,247 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { Combobox } from './Combobox' +import './index' + +function getPortalRoot () { + const portalHost = document.querySelector('[data-solid-ui-combobox-portal]') as HTMLDivElement | null + return portalHost?.shadowRoot ?? null +} + +async function flushUpdates () { + await Promise.resolve() + await Promise.resolve() +} + +describe('SolidUICombobox', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('is defined as a custom element', () => { + expect(customElements.get('solid-ui-combobox')).toBe(Combobox) + }) + + it('renders the input with label and placeholder', async () => { + const combobox = new Combobox() + combobox.label = 'Person' + combobox.placeholder = 'Search people' + + document.body.appendChild(combobox) + await combobox.updateComplete + + const label = combobox.shadowRoot?.querySelector('label.text-label') as HTMLLabelElement + const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement + const toggle = combobox.shadowRoot?.querySelector('button.dropdown-toggle') as HTMLButtonElement + + expect(label).not.toBeNull() + expect(label.textContent).toContain('Person') + expect(input).not.toBeNull() + expect(input.placeholder).toBe('Search people') + expect(input.getAttribute('part')).toBe('input') + expect(input.getAttribute('role')).toBe('combobox') + expect(input.getAttribute('aria-expanded')).toBe('false') + expect(toggle).not.toBeNull() + }) + + it('loads suggestions from suggestionProvider and emits input events', async () => { + const combobox = new Combobox() + const inputEvents = jest.fn() + const suggestionProvider = jest.fn(async (query: string) => [ + { label: `Alice ${query}`, value: 'alice' }, + { label: `Bob ${query}`, value: 'bob' } + ]) + + combobox.suggestionProvider = suggestionProvider + combobox.addEventListener('input', (event: Event) => { + inputEvents((event as CustomEvent).detail) + }) + + document.body.appendChild(combobox) + await combobox.updateComplete + + const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement + input.value = 'al' + input.dispatchEvent(new Event('input', { bubbles: true, composed: true })) + + await flushUpdates() + await combobox.updateComplete + + const portalRoot = getPortalRoot() + const options = Array.from(portalRoot?.querySelectorAll('[role="option"]') as NodeListOf) + + expect(suggestionProvider).toHaveBeenCalledWith('al') + expect(inputEvents).toHaveBeenCalledWith({ value: 'al' }) + expect(combobox.inputValue).toBe('al') + expect(options).toHaveLength(2) + expect(options[0].textContent).toContain('Alice al') + }) + + it('renders the selected option first in the popup', async () => { + const combobox = new Combobox() + combobox.options = [ + { label: 'English', value: 'en' }, + { label: 'French', value: 'fr' }, + { label: 'Spanish', value: 'es' } + ] + combobox.value = 'fr' + + document.body.appendChild(combobox) + await combobox.updateComplete + + const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement + input.dispatchEvent(new Event('focus')) + await combobox.updateComplete + + const portalRoot = getPortalRoot() + const options = Array.from(portalRoot?.querySelectorAll('[role="option"]') as NodeListOf) + + expect(options).toHaveLength(3) + expect(options[0].textContent).toContain('French') + expect(options[0].getAttribute('aria-selected')).toBe('true') + }) + + it('updates value and emits change when an option is clicked', async () => { + const combobox = new Combobox() + const changed = jest.fn() + + combobox.options = [ + { label: 'Alice', value: 'alice', publicId: 'https://example.com/alice' }, + { label: 'Bob', value: 'bob' } + ] + + combobox.addEventListener('change', (event: Event) => { + changed((event as CustomEvent).detail) + }) + + document.body.appendChild(combobox) + await combobox.updateComplete + + const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement + input.dispatchEvent(new Event('focus')) + await combobox.updateComplete + + const portalRoot = getPortalRoot() + const options = portalRoot?.querySelectorAll('[role="option"]') as NodeListOf + options[1].click() + await combobox.updateComplete + + expect(combobox.value).toBe('bob') + expect(combobox.inputValue).toBe('Bob') + expect(input.getAttribute('aria-expanded')).toBe('false') + expect(changed).toHaveBeenCalledWith({ + value: 'bob', + label: 'Bob', + option: { label: 'Bob', value: 'bob' } + }) + }) + + it('opens the popup when clicking the dropdown toggle button', async () => { + const combobox = new Combobox() + combobox.options = [ + { label: 'Alice', value: 'alice' }, + { label: 'Bob', value: 'bob' } + ] + + document.body.appendChild(combobox) + await combobox.updateComplete + + const toggle = combobox.shadowRoot?.querySelector('button.dropdown-toggle') as HTMLButtonElement + + expect(toggle).not.toBeNull() + + toggle.click() + await combobox.updateComplete + + const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement + expect(input.getAttribute('aria-expanded')).toBe('true') + expect(getPortalRoot()?.querySelector('[role="listbox"]')).not.toBeNull() + }) + + it('supports keyboard selection from the input', async () => { + const combobox = new Combobox() + const changed = jest.fn() + + combobox.options = [ + { label: 'Alice', value: 'alice' }, + { label: 'Bob', value: 'bob' }, + { label: 'Carol', value: 'carol' } + ] + + combobox.addEventListener('change', (event: Event) => { + changed((event as CustomEvent).detail) + }) + + document.body.appendChild(combobox) + await combobox.updateComplete + + const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + await combobox.updateComplete + + expect(input.getAttribute('aria-expanded')).toBe('true') + expect(input.getAttribute('aria-activedescendant')).toBeTruthy() + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + await combobox.updateComplete + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + await combobox.updateComplete + + expect(combobox.value).toBe('bob') + expect(combobox.inputValue).toBe('Bob') + expect(changed).toHaveBeenCalledWith({ + value: 'bob', + label: 'Bob', + option: { label: 'Bob', value: 'bob' } + }) + }) + + it('does not treat space as a selection while typing', async () => { + const combobox = new Combobox() + + combobox.options = [ + { label: 'Self Employed', value: 'self-employed' }, + { label: 'Microsoft', value: 'microsoft' } + ] + + document.body.appendChild(combobox) + await combobox.updateComplete + + const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + await combobox.updateComplete + + const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }) + input.dispatchEvent(event) + await combobox.updateComplete + + expect(event.defaultPrevented).toBe(false) + expect(combobox.value).toBe('') + expect(input.getAttribute('aria-expanded')).toBe('true') + }) + + it('closes the popup when clicking outside the component', async () => { + const combobox = new Combobox() + combobox.options = [ + { label: 'Alice', value: 'alice' }, + { label: 'Bob', value: 'bob' } + ] + + document.body.appendChild(combobox) + await combobox.updateComplete + + const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement + input.dispatchEvent(new Event('focus')) + await combobox.updateComplete + + expect(input.getAttribute('aria-expanded')).toBe('true') + expect(getPortalRoot()).not.toBeNull() + + document.body.dispatchEvent(new Event('pointerdown', { bubbles: true, composed: true })) + await combobox.updateComplete + + expect(input.getAttribute('aria-expanded')).toBe('false') + }) +}) diff --git a/src/v2/components/forms/combobox/Combobox.ts b/src/v2/components/forms/combobox/Combobox.ts new file mode 100644 index 000000000..13cb262d2 --- /dev/null +++ b/src/v2/components/forms/combobox/Combobox.ts @@ -0,0 +1,583 @@ +import { LitElement, html, css, nothing } from 'lit' +import { render as renderPortal } from 'lit/html.js' +import { downArrowIcon } from '../shared/downArrow' +import { listboxStyles } from '../shared/listboxStyles' +import { findOptionIndexByValue, getFirstEnabledIndex, getLastEnabledIndex, getListboxActionFromKey, getNextEnabledIndex } from '../shared/keyboard' +import { ComboboxSuggestion } from './comboboxTypes' +import { renderListbox } from '../shared/listboxTemplate' + +export class Combobox extends LitElement { + private static _nextId = 0 + private _popupPortalHost: HTMLDivElement | null = null + private _popupPortalRoot: ShadowRoot | null = null + private _popupPortalContainer: Element | null = null + private readonly _handleDocumentPointerDown = (event: Event) => { + const eventTarget = event.target + + if (!this._popupOpen || !(eventTarget instanceof Node)) { + return + } + + const eventPath = + 'composedPath' in event + ? (event as Event & { composedPath: () => EventTarget[] }).composedPath() + : [] + + if (eventPath.includes(this)) { + return + } + + if ( + (this._popupPortalHost && eventPath.includes(this._popupPortalHost)) || + (this._popupPortalRoot && eventPath.includes(this._popupPortalRoot)) + ) { + return + } + + if (!this.contains(eventTarget)) { + this._closePopup() + } + } + + private readonly _handleViewportChange = () => { + if (!this._popupOpen) return + this._updatePopupPosition() + } + + suggestionProvider?: (query: string) => Promise + + static properties = { + label: { type: String, reflect: true }, + placeholder: { type: String, reflect: true }, + theme: { type: String, reflect: true }, + layout: { type: String, reflect: true }, + value: { type: String, reflect: true }, + inputValue: { type: String }, + options: { type: Array, attribute: false }, + _popupOpen: { state: true }, + _activeIndex: { state: true } + } + + static styles = [ + listboxStyles, + css` + :host { + /* default theme */ + display: block; + width: 100%; + min-width: 0; + max-width: 100%; + position: relative; + box-sizing: border-box; + --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)); + --input-background: var(--color-background, #F8F9FB); + --input-text: var(--color-text, #1A1A1A); + --input-border: var(--color-border-button-hover, var(--gray-400, #99A1AF)); + --label-color: var(--grey-purple-700, #1A1A1A); + --placeholder-color: var(--grey-purple-700, #5e546d); + --combobox-input-height: var(--select-trigger-height, var(--min-touch-target, 44px)); + --combobox-input-inline-padding: var(--select-trigger-inline-padding, var(--spacing-2xs, 0.625rem)); + --combobox-input-block-padding: var(--spacing-xxs, 0.3125rem); + } + + :host([theme='dark']) { + display: block; + width: 100%; + min-width: 0; + max-width: 100%; + position: relative; + box-sizing: border-box; + --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)); + --input-background: var(--color-background, #F8F9FB); + --input-text: var(--color-text, #1A1A1A); + --input-border: var(--color-border-button-hover, var(--gray-400, #99A1AF)); + --label-color: var(--grey-purple-700, #1A1A1A); + --placeholder-color: var(--grey-purple-700, #5e546d); + --combobox-input-height: var(--select-trigger-height, var(--min-touch-target, 44px)); + --combobox-input-inline-padding: var(--select-trigger-inline-padding, var(--spacing-2xs, 0.625rem)); + --combobox-input-block-padding: var(--spacing-xxs, 0.3125rem); + } + + .popup-box { + position: absolute; + top: 0; + left: 0; + width: 100%; + 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: 100%; + overflow: hidden; + box-sizing: border-box; + isolation: isolate; + } + + .select-options-section { + position: relative; + background: var(--popup-background); + border-radius: inherit; + isolation: isolate; + } + + .combobox-root { + display: flex; + flex-direction: column; + gap: 6px; + } + + .text-label { + color: var(--label-color); + margin-bottom: 6px; + } + + .input-field-row { + display: flex; + flex-direction: row; + position: relative; + width: 100%; + min-width: 0; + } + + .text-input { + display: block; + flex: 1; + width: 100%; + min-width: 0; + min-height: var(--combobox-input-height); + height: var(--combobox-input-height); + padding: var(--combobox-input-block-padding) calc(26px + (var(--combobox-input-inline-padding) * 2) + 6px) var(--combobox-input-block-padding) var(--combobox-input-inline-padding); + border: 1px solid var(--input-border); + border-radius: var(--border-radius-base, 0.3125rem); + background: var(--input-background); + color: var(--input-text); + font: inherit; + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-md, 500); + line-height: normal; + appearance: none; + -webkit-appearance: none; + box-sizing: border-box; + } + + .text-input::placeholder { + color: var(--placeholder-color); + } + + .text-input:focus-visible { + outline: 2px solid var(--color-focus-ring, var(--color-primary, #7C4DFF)); + outline-offset: 2px; + } + + .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); + } + + .dropdown-toggle:hover { + background: var(--color-header-menu-item-hover, #e6dcff); + } + + .input-field-row:focus-within .dropdown-toggle { + background: var(--color-header-menu-item-hover, #e6dcff); + } + + .dropdown-toggle:focus-visible { + outline: 2px solid var(--color-focus-ring, var(--color-primary, #7C4DFF)); + outline-offset: 2px; + background: var(--color-header-menu-item-hover, #e6dcff); + } + + .dropdown-toggle svg { + width: 14px; + height: 14px; + display: block; + } + } + ` + ] + + declare label: string + declare placeholder: string + declare theme: 'light' | 'dark' + declare options: Array + declare layout: 'desktop' | 'mobile' + declare value: string + declare inputValue: string + declare _popupOpen: boolean + declare _activeIndex: number + + private readonly _inputId = `solid-ui-combobox-input-${Combobox._nextId++}` + private readonly _listboxId = `solid-ui-combobox-listbox-${Combobox._nextId++}` + private _suggestionRequestId = 0 + + constructor () { + super() + this.label = 'Select an option' + this.placeholder = 'Type to search' + this.theme = 'light' + this.layout = 'desktop' + this.options = [] + this.value = '' + this.inputValue = '' + this._popupOpen = false + this._activeIndex = -1 + } + + connectedCallback () { + super.connectedCallback() + document.addEventListener('pointerdown', this._handleDocumentPointerDown) + window.addEventListener('resize', this._handleViewportChange) + window.addEventListener('scroll', this._handleViewportChange, true) + } + + disconnectedCallback () { + this._detachPopupPortal() + document.removeEventListener('pointerdown', this._handleDocumentPointerDown) + window.removeEventListener('resize', this._handleViewportChange) + window.removeEventListener('scroll', this._handleViewportChange, true) + super.disconnectedCallback() + } + + private _getPopupPortalContainer () { + return this.closest('dialog[open]') || document.body + } + + private _ensurePopupPortal () { + const nextContainer = this._getPopupPortalContainer() + + if ( + this._popupPortalHost && + this._popupPortalRoot && + this._popupPortalContainer === nextContainer + ) { + return + } + + this._detachPopupPortal() + + this._popupPortalHost = document.createElement('div') + this._popupPortalHost.setAttribute('data-solid-ui-combobox-portal', '') + this._popupPortalHost.style.position = 'fixed' + this._popupPortalHost.style.inset = '0 auto auto 0' + this._popupPortalHost.style.zIndex = '2147483647' + this._popupPortalHost.style.pointerEvents = 'none' + this._popupPortalHost.style.boxSizing = 'border-box' + + this._popupPortalRoot = this._popupPortalHost.attachShadow({ mode: 'open' }) + const styleSheets = (Array.isArray(Combobox.styles) ? Combobox.styles : [Combobox.styles]) + .map((style) => style?.styleSheet) + .filter((styleSheet): styleSheet is CSSStyleSheet => Boolean(styleSheet)) + + if (styleSheets.length > 0) { + this._popupPortalRoot.adoptedStyleSheets = styleSheets + } + + nextContainer.appendChild(this._popupPortalHost) + this._popupPortalContainer = nextContainer + } + + private _detachPopupPortal () { + if (this._popupPortalRoot) { + renderPortal(null, this._popupPortalRoot) + } + + if (this._popupPortalHost?.parentNode) { + this._popupPortalHost.parentNode.removeChild(this._popupPortalHost) + } + + this._popupPortalHost = null + this._popupPortalRoot = null + this._popupPortalContainer = null + } + + private _updatePopupPosition () { + this._ensurePopupPortal() + + const rect = this.getBoundingClientRect() + const maxHeight = Math.min(288, Math.max(120, window.innerHeight - rect.bottom - 12)) + + if (this._popupPortalHost) { + this._popupPortalHost.style.top = `${Math.round(rect.bottom + 2)}px` + this._popupPortalHost.style.left = `${Math.round(rect.left)}px` + this._popupPortalHost.style.width = `${Math.round(rect.width)}px` + this._popupPortalHost.style.maxHeight = `${Math.round(maxHeight)}px` + this._popupPortalHost.style.height = '0px' + } + } + + private _openPopup () { + const popupOptions = this._getDisplayedOptions() + + this._popupOpen = true + this._updatePopupPosition() + this._activeIndex = findOptionIndexByValue(popupOptions, this.value) + + if (this._activeIndex < 0) { + this._activeIndex = getFirstEnabledIndex(popupOptions) + } + } + + private _closePopup () { + this._popupOpen = false + if (this._popupPortalRoot) { + renderPortal(null, this._popupPortalRoot) + } + } + + protected updated (changedProperties: Map) { + if (this._popupOpen) { + this._updatePopupPosition() + if (this._popupPortalRoot) { + renderPortal(this._renderPopup(), this._popupPortalRoot) + } + } else if (this._popupPortalRoot) { + renderPortal(null, this._popupPortalRoot) + } + + if ((changedProperties.has('value') || changedProperties.has('options')) && this.value) { + const selectedOption = this.options.find((option) => option.value === this.value) + if (selectedOption && this.inputValue !== selectedOption.label) { + this.inputValue = selectedOption.label + } + } + } + + private _getSelectedIndex () { + return findOptionIndexByValue(this.options, this.value) + } + + private _getSelectedOption () { + const selectedIndex = this._getSelectedIndex() + + if (selectedIndex >= 0) { + return this.options[selectedIndex] + } + + return this.options[0] + } + + private _getDisplayedOptions () { + const selectedOption = this._getSelectedOption() + + if (!selectedOption) { + return this.options + } + + return [ + selectedOption, + ...this.options.filter((option) => option.value !== selectedOption.value) + ] + } + + private _getActiveOption () { + const popupOptions = this._getDisplayedOptions() + + if (this._activeIndex < 0) { + return undefined + } + + return popupOptions[this._activeIndex] + } + + private async _loadSuggestions (query: string) { + if (!this.suggestionProvider) { + this._openPopup() + return + } + + const requestId = ++this._suggestionRequestId + const suggestions = await this.suggestionProvider(query) + + if (requestId !== this._suggestionRequestId) { + return + } + + this.options = suggestions + this._openPopup() + } + + private async _handleInputChange (e: Event) { + const query = (e.target as HTMLInputElement).value + + this.inputValue = query + this.value = '' + this.dispatchEvent(new CustomEvent('input', { + detail: { value: query }, + bubbles: true, + composed: true + })) + await this._loadSuggestions(query) + } + + private _handleInputKeydown (e: KeyboardEvent) { + if (e.key === ' ' || e.key === 'Spacebar') { + return + } + + const popupOptions = this._getDisplayedOptions() + const action = getListboxActionFromKey(e.key) + + if (action === 'none') { + return + } + + e.preventDefault() + + switch (action) { + case 'close': + this._closePopup() + break + case 'first': + if (!this._popupOpen) { + this._openPopup() + } + this._activeIndex = getFirstEnabledIndex(popupOptions) + break + case 'last': + if (!this._popupOpen) { + this._openPopup() + } + this._activeIndex = getLastEnabledIndex(popupOptions) + break + case 'next': + if (!this._popupOpen) { + this._openPopup() + break + } + this._activeIndex = getNextEnabledIndex(this._activeIndex, popupOptions, 1) + break + case 'previous': + if (!this._popupOpen) { + this._openPopup() + break + } + this._activeIndex = getNextEnabledIndex(this._activeIndex, popupOptions, -1) + break + case 'select': + if (!this._popupOpen) { + this._openPopup() + break + } + this._selectActiveOption() + break + default: + break + } + } + + private _getOptionId (option: ComboboxSuggestion, index: number) { + return `${this._listboxId}-option-${index}-${option.value}` + } + + private _selectValueFromDropdown (value: string) { + const selectedOption = this.options.find(option => option.value === value) + + this.value = value + this.inputValue = selectedOption?.label ?? value + this.dispatchEvent(new CustomEvent('change', { + detail: { + value, + label: this.inputValue, + option: selectedOption + }, + bubbles: true, + composed: true + })) + this._closePopup() + } + + private _selectActiveOption () { + const activeOption = this._getActiveOption() + + if (activeOption && !activeOption.disabled) { + this._selectValueFromDropdown(activeOption.value) + } + } + + private _renderPopup () { + const popupOptions = this._getDisplayedOptions() + const selectedOption = this._getSelectedOption() + const activeOption = this._activeIndex >= 0 ? popupOptions[this._activeIndex] : undefined + + return html` + + ` + } + + render () { + const activeOption = this._getActiveOption() + const activeDescendant = this._popupOpen && activeOption + ? this._getOptionId(activeOption, this._activeIndex) + : undefined + const ariaLabel = this.label ? nothing : (this.getAttribute('aria-label') || this.placeholder || 'Combobox') + + return html` +
+ ${this.label + ? html`` + : null} +
+ + +
+
+ ` + } +} diff --git a/src/v2/components/forms/combobox/README.md b/src/v2/components/forms/combobox/README.md new file mode 100644 index 000000000..5a585bf62 --- /dev/null +++ b/src/v2/components/forms/combobox/README.md @@ -0,0 +1,221 @@ +# solid-ui-combobox component + +A Lit-based custom element that renders a styled combobox with a text input and a custom popup listbox. It supports async suggestion loading through a consumer-provided `suggestionProvider`, keyboard navigation, `input` and `change` events, and keeps the currently selected option at the top of the popup when opened. + +## Installation + +```bash +npm install solid-ui +``` + +## Usage in a bundled project (webpack, Vite, Rollup, etc.) + +```javascript +import { Combobox } from 'solid-ui/components/forms/combobox' +``` + +The legacy flat import path `solid-ui/components/combobox` still works, but the grouped `forms/combobox` path is the preferred long-term entrypoint. + +```html + + + +``` + +## Usage in a plain HTML page (CDN / script tag) + +```html + + + + + +``` + +## TypeScript + +```typescript +import { Combobox } from 'solid-ui/components/forms/combobox' + +const combobox = document.querySelector('solid-ui-combobox') as Combobox + +combobox.suggestionProvider = async (query) => { + return [ + { label: `Result for ${query}`, value: query.toLowerCase() } + ] +} + +combobox.addEventListener( + 'change', + (e: CustomEvent<{ value: string; label: string; option?: { label: string; value: string } }>) => { + console.log(e.detail.value) + } +) +``` + +The component works with suggestion objects shaped like: + +```typescript +type ComboboxSuggestion = { + label: string + value: string + disabled?: boolean + publicId?: string + meta?: Record +} +``` + +## API + +### Properties / attributes + +| Property | Attribute | Type | Default | Description | +|----------|-----------|------|---------|-------------| +| `label` | `label` | `string` | `Select an option` | Visible label rendered above the input. If omitted, provide an `aria-label` for accessibility. | +| `placeholder` | `placeholder` | `string` | `Type to search` | Placeholder text shown inside the input when it is empty. | +| `theme` | `theme` | `'light' \| 'dark'` | `'light'` | Sets the colour theme. | +| `options` | `options` | `ComboboxSuggestion[]` | `[]` | Current list of suggestions shown in the popup. In practice this should be set as a property from JavaScript rather than as an HTML attribute. | +| `layout` | `layout` | `'desktop' \| 'mobile'` | `'desktop'` | Layout mode reserved for integration with other responsive components. | +| `value` | `value` | `string` | `''` | The currently selected suggestion value. If it matches a suggestion, that suggestion is shown in the input and moved to the top of the popup when opened. | +| `inputValue` | none | `string` | `''` | Current raw text shown in the input field. This updates as the user types. | +| `suggestionProvider` | none | `(query: string) => Promise` | `undefined` | Optional async function supplied by the consumer. It receives the current input text and returns normalized suggestions for the popup. | + +### Events + +| Event | Detail | Description | +|-------|--------|-------------| +| `input` | `{ value: string }` | Fired when the user types in the input. Useful when the consumer wants to observe free text in addition to providing a `suggestionProvider`. | +| `change` | `{ value: string; label: string; option?: ComboboxSuggestion }` | Fired when the user selects a suggestion from the popup or confirms a keyboard selection. | + +### CSS custom properties + +These can be set on `solid-ui-combobox`, on a container element, or on `:root`. + +| Variable | Fallback | Description | +|----------|----------|-------------| +| `--popup-background` | `--color-background` | Popup surface background. | +| `--popup-text` | `--color-text` | Popup text colour. | +| `--popup-border` | `--color-border` / `#E5E7EB` | Popup border colour. | +| `--popup-shadow` | `--box-shadow-sm` / `0 1px 4px ...` | Popup shadow. | +| `--input-background` | `--color-background` | Input and popup background. | +| `--input-text` | `--color-text` | Input text colour. | +| `--input-border` | `--color-text` | Input border colour. | +| `--label-color` | `--grey-purple-700` | Label text colour. | +| `--placeholder-color` | `--grey-purple-700` | Placeholder text colour. | +| `--item-text` | `--color-text` | Option text colour. | +| `--item-selected-text` | `--color-primary` / `#7c4dff` | Active option text colour. | +| `--item-hover-background` | `--lavender-300` / `#e6dcff` | Hover background for option rows. | +| `--item-selected-background` | `--lavender-400` / `#cbb9ff` | Active option background. | + +The component also inherits common design-system tokens such as `--border-radius-base`, `--border-radius-md`, `--color-background`, `--color-border`, `--color-text`, `--color-primary`, `--box-shadow-sm`, `--lavender-300`, and `--lavender-400`. + +### CSS shadow parts + +These parts can be styled from a consuming repo using `::part(...)`. + +| Part | Description | +|------|-------------| +| `input` | The text input inside the combobox field. | +| `listbox` | The `