diff --git a/README.md b/README.md index 6d2e8d53..cbef9bf3 100644 --- a/README.md +++ b/README.md @@ -322,9 +322,13 @@ import { SignupButton } from 'solid-ui/components/signup-button' Web components use a two-stage build to produce a clean public runtime layout while keeping internal TypeScript artifacts separate: -1. **webpack** (`npm run build-dist`) bundles each component entrypoint and emits the runtime files to `dist/components//index.js` and `dist/components//index.esm.js`. -2. **tsc** (`npm run build-js`) emits internal declaration and JS artifacts mirroring the source tree under `dist/v2/components//`. -3. **`scripts/build-component-dts.mjs`** (runs automatically after tsc as part of `postbuild-js`) writes thin public declaration wrappers at `dist/components//index.d.ts`, re-exporting from the internal `dist/v2/components//` output. +1. **`scripts/component-manifest.mjs`** is the source of truth for v2 web components. It defines the component entrypoints used by webpack and the public subpath names exposed from the package. +2. **webpack** (`npm run build-dist`) bundles each component entrypoint from the manifest and emits the runtime files to `dist/components//index.js` and `dist/components//index.esm.js`. +3. **tsc** (`npm run build-js`) emits internal declaration and JS artifacts mirroring the source tree under `dist/v2/components//`. +4. **`scripts/build-component-dts.mjs`** (runs automatically after tsc as part of `postbuild-js`) writes thin public declaration wrappers at `dist/components//index.d.ts`, re-exporting from the internal `dist/v2/components//` output. +5. **`scripts/sync-component-exports.mjs`** keeps the `package.json` `exports` map aligned with the manifest. It runs automatically as part of `npm run build` and `npm version` workflows. + +The legacy main bundle remains a special case. In [webpack.config.mjs](webpack.config.mjs) only the `main` entry keeps the UMD `UI` global export; component entries are generated from the manifest and built as standalone scripts so they do not clobber one another when loaded directly. This keeps the `package.json` subpath export fully aligned while exposing only the public `dist/components/...` layout: @@ -338,6 +342,16 @@ This keeps the `package.json` subpath export fully aligned while exposing only t Consumers never import from `dist/v2/components/...`; that path is an internal build artifact only. +### Adding a new web component + +When adding a new v2 component: + +1. Create the component folder under `src/v2/components//` with its `index.ts` entrypoint. +2. Add one record to `scripts/component-manifest.mjs`. +3. Run `npm run sync-component-exports` if you want to update `package.json` immediately, or just run `npm run build` and let the build do it automatically. + +You should not need to hand-edit the webpack component entry list or the `package.json` component export map anymore. + ## Development When developing a component in solid-ui you can test it in isolation using storybook @@ -411,4 +425,8 @@ You are logged in as nameOfLoggedIn user. * Raptor mini: add a readme to the Footer component with example. -* Claude Sonnet 4.6: Make the dop down as a list under the input field and entlarge the pop up, make it higher, adjustable to fit the drop down. And make the drop down arrow area larger \ No newline at end of file +* Claude Sonnet 4.6: Make the dop down as a list under the input field and entlarge the pop up, make it higher, adjustable to fit the drop down. And make the drop down arrow area larger + +* GPT-5.4 Model: can you wire up the keyboard interactions and aria attributes for Select. + +* GPT-5.4 Model: Take the code from /Users/sharon/2025Dev/solid-ui/src/media/media-capture.ts and make it a web component. Make it work in forms as well as not. Make it configurable and follow LoginButton. diff --git a/package.json b/package.json index 1550fc74..fb61cada 100644 --- a/package.json +++ b/package.json @@ -15,20 +15,85 @@ "import": "./dist/components/header/index.esm.js", "require": "./dist/components/header/index.js" }, + "./components/layout/header": { + "types": "./dist/components/header/index.d.ts", + "import": "./dist/components/header/index.esm.js", + "require": "./dist/components/header/index.js" + }, + "./components/loginButton": { + "types": "./dist/components/loginButton/index.d.ts", + "import": "./dist/components/loginButton/index.esm.js", + "require": "./dist/components/loginButton/index.js" + }, "./components/login-button": { "types": "./dist/components/loginButton/index.d.ts", "import": "./dist/components/loginButton/index.esm.js", "require": "./dist/components/loginButton/index.js" }, + "./components/auth/login-button": { + "types": "./dist/components/loginButton/index.d.ts", + "import": "./dist/components/loginButton/index.esm.js", + "require": "./dist/components/loginButton/index.js" + }, + "./components/auth/signup-button": { + "types": "./dist/components/signupButton/index.d.ts", + "import": "./dist/components/signupButton/index.esm.js", + "require": "./dist/components/signupButton/index.js" + }, "./components/signup-button": { "types": "./dist/components/signupButton/index.d.ts", "import": "./dist/components/signupButton/index.esm.js", "require": "./dist/components/signupButton/index.js" }, + "./components/media/photo-capture": { + "types": "./dist/components/photoCapture/index.d.ts", + "import": "./dist/components/photoCapture/index.esm.js", + "require": "./dist/components/photoCapture/index.js" + }, + "./components/photo-capture": { + "types": "./dist/components/photoCapture/index.d.ts", + "import": "./dist/components/photoCapture/index.esm.js", + "require": "./dist/components/photoCapture/index.js" + }, + "./components/actions/button": { + "types": "./dist/components/button/index.d.ts", + "import": "./dist/components/button/index.esm.js", + "require": "./dist/components/button/index.js" + }, + "./components/button": { + "types": "./dist/components/button/index.d.ts", + "import": "./dist/components/button/index.esm.js", + "require": "./dist/components/button/index.js" + }, "./components/footer": { "types": "./dist/components/footer/index.d.ts", "import": "./dist/components/footer/index.esm.js", "require": "./dist/components/footer/index.js" + }, + "./components/layout/footer": { + "types": "./dist/components/footer/index.d.ts", + "import": "./dist/components/footer/index.esm.js", + "require": "./dist/components/footer/index.js" + }, + "./components/forms/select": { + "types": "./dist/components/select/index.d.ts", + "import": "./dist/components/select/index.esm.js", + "require": "./dist/components/select/index.js" + }, + "./components/select": { + "types": "./dist/components/select/index.d.ts", + "import": "./dist/components/select/index.esm.js", + "require": "./dist/components/select/index.js" + }, + "./components/forms/combobox": { + "types": "./dist/components/combobox/index.d.ts", + "import": "./dist/components/combobox/index.esm.js", + "require": "./dist/components/combobox/index.js" + }, + "./components/combobox": { + "types": "./dist/components/combobox/index.d.ts", + "import": "./dist/components/combobox/index.esm.js", + "require": "./dist/components/combobox/index.js" } }, "files": [ @@ -38,10 +103,11 @@ ], "scripts": { "clean": "rm -rf ./dist ./src/versionInfo.ts ./docs/api .tsbuildinfo", - "build": "npm run clean && npm run typecheck && npm run build-version && npm run build-dist && npm run build-js && npm run postbuild-js && npm run build-storybook", + "build": "npm run clean && npm run sync-component-exports && npm run typecheck && npm run build-version && npm run build-dist && npm run build-js && npm run postbuild-js && npm run build-storybook", "build-version": "sh ./timestamp.sh > src/versionInfo.ts && eslint 'src/versionInfo.ts' --fix", "prebuild-js": "rm -f .tsbuildinfo", "build-js": "tsc", + "sync-component-exports": "node scripts/sync-component-exports.mjs", "postbuild-js": "rm -f dist/versionInfo.d.ts dist/versionInfo.d.ts.map && node scripts/build-component-dts.mjs", "build-dist": "webpack --progress", "build-form-examples": "npm run build-js && npm run build-version && npm run build-dist && cp ./dist/solid-ui.js ./docs/form-examples/", @@ -54,12 +120,13 @@ "test-debug": "node --inspect-brk ./node_modules/jest/bin/jest.js --runInBand --watch", "watch:js": "tsc --watch --preserveWatchOutput", "watch:component-dts": "node scripts/watch-component-dts.mjs", + "watch:component-exports": "node scripts/watch-component-exports.mjs", "watch:dist": "webpack --watch --mode development", - "watch": "npm run build-version && npm run build-js && npm run postbuild-js && sh -c 'npm run watch:js & npm run watch:component-dts & npm run watch:dist & wait'", - "dev": "npm run build-version && sh -c 'npm run watch:js & npm run watch:component-dts & npm run watch:dist & wait'", + "watch": "npm run sync-component-exports && npm run build-version && npm run build-js && npm run postbuild-js && sh -c 'npm run watch:js & npm run watch:component-dts & npm run watch:component-exports & npm run watch:dist & wait'", + "dev": "npm run sync-component-exports && npm run build-version && sh -c 'npm run watch:js & npm run watch:component-dts & npm run watch:component-exports & npm run watch:dist & wait'", "doc": "typedoc --out ./docs/api/ ./src/ --excludeInternal", "prepublishOnly": "npm run build && npm run lint && npm test && npm run doc", - "preversion": "npm run lint && npm run typecheck && npm test", + "preversion": "npm run sync-component-exports && npm run lint && npm run typecheck && npm test", "postpublish": "git push origin main --follow-tags", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build --output-dir ./examples/storybook" diff --git a/scripts/build-component-dts.mjs b/scripts/build-component-dts.mjs index c2e4b0fc..333a299e 100644 --- a/scripts/build-component-dts.mjs +++ b/scripts/build-component-dts.mjs @@ -1,5 +1,6 @@ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'fs' import path from 'path' +import { v2Components } from './component-manifest.mjs' const distDir = path.resolve(process.cwd(), 'dist') const v2ComponentsDir = path.join(distDir, 'v2', 'components') @@ -9,21 +10,31 @@ if (!existsSync(v2ComponentsDir)) { throw new Error(`Missing expected directory: ${v2ComponentsDir}`) } -const componentDirs = readdirSync(v2ComponentsDir).filter(name => { +const manifestComponents = v2Components.map(({ sourceDir, sourcePath = sourceDir }) => ({ + publicDir: sourceDir, + sourcePath +})) + +const fallbackComponentDirs = readdirSync(v2ComponentsDir).filter(name => { const fullPath = path.join(v2ComponentsDir, name) return statSync(fullPath).isDirectory() -}) +}).map(name => ({ + publicDir: name, + sourcePath: name +})) + +const componentDirs = manifestComponents.length > 0 ? manifestComponents : fallbackComponentDirs -for (const componentDir of componentDirs) { - const sourceIndex = path.join(v2ComponentsDir, componentDir, 'index.d.ts') +for (const { publicDir, sourcePath } of componentDirs) { + const sourceIndex = path.join(v2ComponentsDir, sourcePath, 'index.d.ts') if (!existsSync(sourceIndex)) { continue } - const outputDir = path.join(publicComponentsDir, componentDir) + const outputDir = path.join(publicComponentsDir, publicDir) mkdirSync(outputDir, { recursive: true }) - const relativePath = path.relative(outputDir, path.join(v2ComponentsDir, componentDir, 'index.d.ts')) + const relativePath = path.relative(outputDir, sourceIndex) .replace(/\\/g, '/') .replace(/\.d\.ts$/, '') diff --git a/scripts/component-manifest.mjs b/scripts/component-manifest.mjs new file mode 100644 index 00000000..7cee7d31 --- /dev/null +++ b/scripts/component-manifest.mjs @@ -0,0 +1,64 @@ +export const v2Components = [ + { + sourceDir: 'header', + sourcePath: 'layout/header', + exportNames: ['header', 'layout/header'] + }, + { + sourceDir: 'loginButton', + sourcePath: 'auth/loginButton', + exportNames: ['loginButton', 'login-button', 'auth/login-button'] + }, + { + sourceDir: 'signupButton', + sourcePath: 'auth/signupButton', + exportNames: ['auth/signup-button', 'signup-button'] + }, + { + sourceDir: 'photoCapture', + sourcePath: 'media/photoCapture', + exportNames: ['media/photo-capture', 'photo-capture'] + }, + { + sourceDir: 'button', + sourcePath: 'actions/button', + exportNames: ['actions/button', 'button'] + }, + { + sourceDir: 'footer', + sourcePath: 'layout/footer', + exportNames: ['footer', 'layout/footer'] + }, + { + sourceDir: 'select', + sourcePath: 'forms/select', + exportNames: ['forms/select', 'select'] + }, + { + sourceDir: 'combobox', + sourcePath: 'forms/combobox', + exportNames: ['forms/combobox', 'combobox'] + } +] + +export const componentEntries = Object.fromEntries( + v2Components.map(({ sourceDir, sourcePath = sourceDir }) => [ + sourceDir, + { + import: `./src/v2/components/${sourcePath}/index.ts` + } + ]) +) + +export const componentExports = Object.fromEntries( + v2Components.flatMap(({ sourceDir, exportNames }) => + exportNames.map(exportName => [ + `./components/${exportName}`, + { + types: `./dist/components/${sourceDir}/index.d.ts`, + import: `./dist/components/${sourceDir}/index.esm.js`, + require: `./dist/components/${sourceDir}/index.js` + } + ]) + ) +) diff --git a/scripts/sync-component-exports.mjs b/scripts/sync-component-exports.mjs new file mode 100644 index 00000000..8d4d583c --- /dev/null +++ b/scripts/sync-component-exports.mjs @@ -0,0 +1,17 @@ +import { readFileSync, writeFileSync } from 'fs' +import path from 'path' +import { componentExports } from './component-manifest.mjs' + +const packageJsonPath = path.resolve(process.cwd(), 'package.json') +const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + +const preservedExports = Object.fromEntries( + Object.entries(packageJson.exports || {}).filter(([subpath]) => !subpath.startsWith('./components/')) +) + +packageJson.exports = { + ...preservedExports, + ...componentExports +} + +writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`) diff --git a/scripts/watch-component-exports.mjs b/scripts/watch-component-exports.mjs new file mode 100644 index 00000000..516cf681 --- /dev/null +++ b/scripts/watch-component-exports.mjs @@ -0,0 +1,58 @@ +import { spawn } from 'child_process' +import { existsSync, watch } from 'fs' +import path from 'path' + +const scriptsDir = path.resolve(process.cwd(), 'scripts') +const manifestFile = 'component-manifest.mjs' +const syncScript = path.resolve(scriptsDir, 'sync-component-exports.mjs') +let syncTimer = null +let running = false +let rerunRequested = false + +const runSync = () => { + if (running) { + rerunRequested = true + return + } + + running = true + rerunRequested = false + + const child = spawn(process.execPath, [syncScript], { + stdio: 'inherit' + }) + + child.on('exit', code => { + running = false + + if (code !== 0) { + console.error(`sync-component-exports exited with code ${code}`) + } + + if (rerunRequested) { + runSync() + } + }) +} + +const scheduleSync = () => { + clearTimeout(syncTimer) + syncTimer = setTimeout(() => { + runSync() + }, 150) +} + +if (!existsSync(scriptsDir)) { + throw new Error(`Missing expected directory: ${scriptsDir}`) +} + +console.log(`Watching ${path.join(scriptsDir, manifestFile)} for export manifest changes...`) + +runSync() + +watch(scriptsDir, (eventType, filename) => { + if (!filename) return + if (filename === manifestFile) { + scheduleSync() + } +}) diff --git a/src/v2/components/actions/button/Button.test.ts b/src/v2/components/actions/button/Button.test.ts new file mode 100644 index 00000000..416e7af7 --- /dev/null +++ b/src/v2/components/actions/button/Button.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { Button } from './Button' +import './index' + +describe('SolidUIButton', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('is defined as a custom element', () => { + expect(customElements.get('solid-ui-button')).toBe(Button) + }) + + it('renders a secondary button by default', async () => { + const button = new Button() + button.label = 'Upload' + + document.body.appendChild(button) + await button.updateComplete + + const nativeButton = button.shadowRoot?.querySelector( + 'button' + ) as HTMLButtonElement + + expect(button.variant).toBe('secondary') + expect(nativeButton.type).toBe('button') + expect(nativeButton.textContent?.trim()).toBe('Upload') + }) + + it('supports a selected state without forcing toggle semantics', async () => { + const button = new Button() + button.selected = true + + document.body.appendChild(button) + await button.updateComplete + + const nativeButton = button.shadowRoot?.querySelector( + 'button' + ) as HTMLButtonElement + + expect(button.hasAttribute('selected')).toBe(true) + expect(nativeButton.hasAttribute('aria-pressed')).toBe(false) + expect(nativeButton.hasAttribute('aria-selected')).toBe(false) + }) + + it('calls the callback property and still emits the native click event', async () => { + const button = new Button() + const handleClick = jest.fn() + const clickListener = jest.fn() + button.handleClick = handleClick + button.addEventListener('click', clickListener) + + document.body.appendChild(button) + await button.updateComplete + + const nativeButton = button.shadowRoot?.querySelector( + 'button' + ) as HTMLButtonElement + nativeButton.click() + + expect(handleClick).toHaveBeenCalledTimes(1) + expect(clickListener).toHaveBeenCalledTimes(1) + }) + + it('renders an image icon when the icon property is provided', async () => { + const button = new Button() + button.icon = 'data:image/svg+xml,%3Csvg%3E%3C/svg%3E' + + document.body.appendChild(button) + await button.updateComplete + + const icon = button.shadowRoot?.querySelector( + '.button__icon-image' + ) as HTMLImageElement + expect(icon.getAttribute('src')).toBe(button.icon) + }) + + it('supports an icon-only variant without rendering the label text', async () => { + const button = new Button() + button.variant = 'icon' + button.icon = 'data:image/svg+xml,%3Csvg%3E%3C/svg%3E' + button.label = 'Settings' + + document.body.appendChild(button) + await button.updateComplete + + const label = button.shadowRoot?.querySelector( + '.button__label' + ) as HTMLSpanElement + const icon = button.shadowRoot?.querySelector( + '.button__icon-image' + ) as HTMLImageElement + + expect(button.variant).toBe('icon') + expect(label).not.toBeNull() + expect(icon.getAttribute('src')).toBe(button.icon) + }) + + it('prefers slotted icon content over the icon property fallback', async () => { + const button = document.createElement('solid-ui-button') as Button + button.icon = 'data:image/svg+xml,%3Csvg%3E%3C/svg%3E' + + const slottedIcon = document.createElement('span') + slottedIcon.slot = 'icon' + slottedIcon.textContent = 'icon' + button.appendChild(slottedIcon) + + document.body.appendChild(button) + await button.updateComplete + await Promise.resolve() + await button.updateComplete + + expect(button.shadowRoot?.querySelector('slot[name="icon"]')).not.toBeNull() + expect(button.shadowRoot?.querySelector('.button__icon-image')).toBeNull() + }) + + it('renders slotted icon content without requiring an icon fallback property', async () => { + const button = document.createElement('solid-ui-button') as Button + + const slottedIcon = document.createElement('span') + slottedIcon.slot = 'icon' + slottedIcon.textContent = 'icon' + button.appendChild(slottedIcon) + + document.body.appendChild(button) + await button.updateComplete + await Promise.resolve() + await button.updateComplete + + expect(button.shadowRoot?.querySelector('slot[name="icon"]')).not.toBeNull() + expect(button.shadowRoot?.querySelector('.button__icon')).not.toBeNull() + expect(button.shadowRoot?.querySelector('.button__icon-image')).toBeNull() + }) + + it('applies layout styling hooks exposed through CSS custom properties', async () => { + const stylesheetText = Array.isArray(Button.styles) + ? Button.styles.map((styleSheet) => styleSheet.toString()).join('\n') + : Button.styles.toString() + + expect(stylesheetText).toContain( + '--button-padding-sm: 0 var(--button-padding-x-sm);' + ) + expect(stylesheetText).toContain('--button-border-width: 1px;') + expect(stylesheetText).toContain('padding: var(--button-padding-md);') + expect(stylesheetText).toContain( + 'border: var(--button-border-width) solid var(--button-border);' + ) + expect(stylesheetText).toContain( + 'border-radius: var(--button-border-radius);' + ) + expect(stylesheetText).toContain('font-weight: var(--button-font-weight);') + expect(stylesheetText).toContain('line-height: var(--button-line-height);') + expect(stylesheetText).toContain( + 'justify-content: var(--button-justify-content);' + ) + expect(stylesheetText).toContain( + 'box-shadow: var(--button-hover-box-shadow, var(--button-box-shadow));' + ) + expect(stylesheetText).toContain('outline: var(--button-focus-outline);') + expect(stylesheetText).toContain( + 'transform: var(--button-active-transform);' + ) + }) +}) diff --git a/src/v2/components/actions/button/Button.ts b/src/v2/components/actions/button/Button.ts new file mode 100644 index 00000000..b18e249b --- /dev/null +++ b/src/v2/components/actions/button/Button.ts @@ -0,0 +1,326 @@ +import { LitElement, html, css, nothing } from 'lit' + +export class Button extends LitElement { + static properties = { + label: { type: String, reflect: true }, + type: { type: String, reflect: true }, + disabled: { type: Boolean, reflect: true }, + selected: { type: Boolean, reflect: true }, + ariaLabel: { type: String, attribute: 'aria-label' }, + name: { type: String, reflect: true }, + value: { type: String, reflect: true }, + variant: { type: String, reflect: true }, + size: { type: String, reflect: true }, + theme: { type: String, reflect: true }, + fullWidth: { type: Boolean, attribute: 'full-width', reflect: true }, + icon: { type: String, reflect: true }, + iconPosition: { type: String, attribute: 'icon-position', reflect: true }, + handleClick: { attribute: false }, + _hasSlottedIcon: { state: true } + } + + static styles = css` + :host { + display: inline-flex; + align-items: center; + justify-content: center; + --button-background: var(--color-background, #f8f9fb); + --button-text: var(--color-text-subheading, #101828); + --button-border: var(--color-border-button, var(--gray-300, #D1D5DC)); + --button-hover-background: var(--color-surface-subtle, rgba(15, 23, 43, 0.04)); + --button-hover-border: var(--color-border-button-hover, var(--gray-400, #99A1AF)); + --button-hover-text: var(--color-text-subheading, #101828); + --button-selected-background: var(--color-surface-selected, var(--color-surface-action, var(--color-primary, #7C4DFF))); + --button-selected-text: var(--color-text-selected, var(--color-text-on-action, var(--white, #FFF))); + --button-selected-border: var(--color-border-selected, var(--color-border-action, var(--color-primary, #7C4DFF))); + --button-icon-color: currentColor; + --button-focus-ring: var(--color-focus-ring, var(--color-primary, #7C4DFF)); + --button-height-sm: 1.875rem; + --button-height-md: var(--min-touch-target, 44px); + --button-height-lg: calc(var(--min-touch-target, 44px) + 0.5rem); + --button-padding-x-sm: var(--spacing-xs, 0.75rem); + --button-padding-x-md: var(--spacing-sm, 0.9375rem); + --button-padding-x-lg: var(--spacing-md, 1.25rem); + --button-font-size-sm: var(--font-size-sm, 0.875rem); + --button-font-size-md: var(--font-size-md, 1rem); + --button-font-size-lg: var(--font-size-lg, 1.125rem); + --button-icon-size-sm: var(--icon-xxxs, 0.75rem); + --button-icon-size-md: var(--icon-xxs, 1rem); + --button-icon-size-lg: var(--icon-xxs, 1rem); + --button-padding-sm: 0 var(--button-padding-x-sm); + --button-padding-md: 0 var(--button-padding-x-md); + --button-padding-lg: 0 var(--button-padding-x-lg); + --button-border-width: 1px; + --button-border-radius: var(--border-radius-base, 0.3125rem); + --button-font-weight: var(--font-weight-bold, 600); + --button-line-height: 1; + --button-justify-content: center; + --button-box-shadow: none; + --button-hover-box-shadow: var(--button-box-shadow); + --button-active-box-shadow: var(--button-hover-box-shadow); + --button-active-transform: translateY(1px); + --button-focus-outline: 2px solid var(--button-focus-ring); + --button-focus-outline-offset: 2px; + --button-focus-box-shadow: none; + --button-transition: transform 0.2s ease, background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease; + } + + :host([theme='dark']) { + --button-background: var(--color-background, #242a31); + --button-text: var(--color-text-subheading, #f8f9fb); + --button-border: var(--color-border, #46515b); + --button-hover-background: var(--color-surface-subtle, rgba(15, 23, 43, 0.04)); + --button-hover-border: var(--color-border, #46515b); + --button-hover-text: var(--color-text-subheading, #f8f9fb); + } + + :host([variant='primary']) { + --button-background: var(--color-surface-action, var(--color-primary, #7C4DFF)); + --button-text: var(--color-text-on-action, var(--white, #FFF)); + --button-border: var(--color-border-action, var(--color-primary, #7C4DFF)); + --button-hover-background: var(--color-surface-action-hover, #6d3cf2); + --button-hover-border: var(--color-border-action, var(--color-primary, #7C4DFF)); + --button-hover-text: var(--color-text-on-action, var(--white, #FFF)); + } + + :host([variant='icon']) { + --button-padding-x-sm: var(--spacing-xxs, 0.3125rem); + --button-padding-x-md: var(--spacing-base, 0.5rem); + --button-padding-x-lg: var(--spacing-2xs, 0.625rem); + } + + :host([full-width]) { + width: 100%; + } + + :host([selected]) { + --button-background: var(--button-selected-background); + --button-text: var(--button-selected-text); + --button-border: var(--button-selected-border); + --button-hover-background: var(--button-selected-background); + --button-hover-border: var(--button-selected-border); + --button-hover-text: var(--button-selected-text); + } + + .button { + display: inline-flex; + width: 100%; + min-height: var(--button-height-md); + padding: var(--button-padding-md); + align-items: center; + justify-content: var(--button-justify-content); + gap: var(--spacing-xxs, 0.375rem); + border-radius: var(--button-border-radius); + background: var(--button-background); + border: var(--button-border-width) solid var(--button-border); + box-shadow: var(--button-box-shadow); + color: var(--button-text); + cursor: pointer; + font: inherit; + font-size: var(--button-font-size-md); + font-weight: var(--button-font-weight); + line-height: var(--button-line-height); + white-space: nowrap; + text-decoration: none; + box-sizing: border-box; + transition: var(--button-transition); + } + + :host([size='sm']) .button { + min-height: var(--button-height-sm); + padding: var(--button-padding-sm); + font-size: var(--button-font-size-sm); + } + + :host([size='lg']) .button { + min-height: var(--button-height-lg); + padding: var(--button-padding-lg); + font-size: var(--button-font-size-lg); + } + + .button:hover:not(:disabled) { + background: var(--button-hover-background); + border-color: var(--button-hover-border, var(--button-border)); + box-shadow: var(--button-hover-box-shadow, var(--button-box-shadow)); + color: var(--button-hover-text); + } + + .button:focus-visible { + outline: var(--button-focus-outline); + outline-offset: var(--button-focus-outline-offset); + box-shadow: var(--button-focus-box-shadow); + } + + .button:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + } + + .button__content { + display: inline-flex; + align-items: center; + justify-content: center; + gap: inherit; + width: 100%; + } + + :host([icon-position='end']) .button__content { + flex-direction: row-reverse; + } + + .button__icon { + width: var(--button-icon-size-md); + height: var(--button-icon-size-md); + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--button-icon-color); + flex-shrink: 0; + } + + :host([size='sm']) .button__icon { + width: var(--button-icon-size-sm); + height: var(--button-icon-size-sm); + } + + :host([size='lg']) .button__icon { + width: var(--button-icon-size-lg); + height: var(--button-icon-size-lg); + } + + .button__icon ::slotted(*) { + width: 100%; + height: 100%; + display: block; + } + + .button__icon-image { + width: 100%; + height: 100%; + object-fit: contain; + } + + .button__label { + display: inline-flex; + align-items: center; + min-width: 0; + } + + :host([variant='icon']) .button__label { + display: none; + } + + .button:active { + transform: var(--button-active-transform); + box-shadow: var(--button-active-box-shadow, var(--button-hover-box-shadow, var(--button-box-shadow))); + } + ` + + declare label: string + declare type: 'button' | 'submit' | 'reset' + declare disabled: boolean + declare selected: boolean + declare ariaLabel: string + declare name: string + declare value: string + declare variant: 'primary' | 'secondary' | 'icon' + declare size: 'sm' | 'md' | 'lg' + declare theme: 'light' | 'dark' + declare fullWidth: boolean + declare icon: string + declare iconPosition: 'start' | 'end' + declare handleClick?: (event: MouseEvent) => void + declare _hasSlottedIcon: boolean + private _iconSlotObserver?: MutationObserver + + constructor () { + super() + this.label = '' + this.type = 'button' + this.disabled = false + this.selected = false + this.ariaLabel = '' + this.name = '' + this.value = '' + this.variant = 'secondary' + this.size = 'md' + this.theme = 'light' + this.fullWidth = false + this.icon = '' + this.iconPosition = 'start' + this.handleClick = undefined + this._hasSlottedIcon = false + } + + connectedCallback () { + super.connectedCallback() + this._syncSlottedIconPresence() + + this._iconSlotObserver = new MutationObserver(() => { + this._syncSlottedIconPresence() + }) + + this._iconSlotObserver.observe(this, { + childList: true, + attributes: true, + attributeFilter: ['slot'] + }) + } + + disconnectedCallback () { + this._iconSlotObserver?.disconnect() + this._iconSlotObserver = undefined + super.disconnectedCallback() + } + + private _handleButtonClick (event: MouseEvent) { + this.handleClick?.(event) + } + + private _handleIconSlotChange (event: Event) { + const slot = event.target as HTMLSlotElement + this._hasSlottedIcon = slot.assignedNodes({ flatten: true }).length > 0 + } + + private _syncSlottedIconPresence () { + this._hasSlottedIcon = this.querySelector('[slot="icon"]') !== null + } + + private _renderIcon () { + if (!this._hasSlottedIcon && !this.icon) { + return nothing + } + + return html` + + + ${!this._hasSlottedIcon && this.icon + ? html`` + : nothing} + + ` + } + + render () { + return html` + + ` + } +} diff --git a/src/v2/components/actions/button/README.md b/src/v2/components/actions/button/README.md new file mode 100644 index 00000000..3fcc9b86 --- /dev/null +++ b/src/v2/components/actions/button/README.md @@ -0,0 +1,178 @@ +# solid-ui-button component + +A Lit-based base button component for shared actions across panes and apps. It stays semantic by rendering a native ` + + + ` + } +} diff --git a/src/v2/components/forms/combobox/README.md b/src/v2/components/forms/combobox/README.md new file mode 100644 index 00000000..85659ff7 --- /dev/null +++ b/src/v2/components/forms/combobox/README.md @@ -0,0 +1,220 @@ +# 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 | +|------|-------------| +| `listbox` | The `
    ` element that contains the suggestions. | +| `option` | Every suggestion row. | +| `selected-option` | Added to the currently selected suggestion row. | +| `active-option` | Added to the currently keyboard-active suggestion row. | +| `disabled-option` | Added to disabled suggestion rows. | + +## Theming + +Set `theme="dark"` when placing the combobox on a dark background. + +```html + +``` + +In dark theme, the component switches its background and text fallbacks to dark-surface values while keeping the same public styling hooks. + +## Popup behaviour + +- Opens a popup listbox under the combobox input. +- Keeps the currently selected option at the top of the popup when opened. +- Supports keyboard navigation with `ArrowUp`, `ArrowDown`, `Home`, `End`, `Enter`, `Space`, and `Escape`. +- Emits an `input` event as the user types and a `change` event when the user selects a suggestion. +- Closes when clicking outside the component or the popup. +- Skips disabled suggestions during selection and keyboard navigation. +- Renders the popup through a portal so it can escape clipping and stacking issues from surrounding form layouts. + +## Styling from a consuming repo + +Use CSS custom properties on the host element for most theming: + +```css +solid-ui-combobox { + width: 100%; + --input-background: #ffffff; + --input-border: #c7ced8; + --input-text: #101828; + --popup-background: #ffffff; + --popup-shadow: 0 8px 24px rgba(16, 24, 40, 0.12); + --item-hover-background: #eee7ff; + --item-selected-background: #d9c8ff; + --border-radius-md: 0.5rem; +} +``` + +Use `::part(...)` when you need to target exposed listbox elements directly: + +```css +solid-ui-combobox::part(listbox) { + max-height: 16rem; +} + +solid-ui-combobox::part(option) { + letter-spacing: 0.01em; +} + +solid-ui-combobox::part(selected-option) { + font-weight: 700; +} +``` + +## Build + +```bash +npm run build +``` + +Webpack emits bundles to `dist/components/combobox/index.*`. diff --git a/src/v2/components/forms/combobox/comboboxTypes.ts b/src/v2/components/forms/combobox/comboboxTypes.ts new file mode 100644 index 00000000..c3e480ed --- /dev/null +++ b/src/v2/components/forms/combobox/comboboxTypes.ts @@ -0,0 +1,6 @@ +import { SelectOption } from '../shared/optionTypes' + +export interface ComboboxSuggestion extends SelectOption { + publicId?: string + meta?: Record +} diff --git a/src/v2/components/forms/combobox/index.ts b/src/v2/components/forms/combobox/index.ts new file mode 100644 index 00000000..9630a0e6 --- /dev/null +++ b/src/v2/components/forms/combobox/index.ts @@ -0,0 +1,9 @@ +import { Combobox } from './Combobox' + +export { Combobox } + +const COMBOBOX_TAG_NAME = 'solid-ui-combobox' + +if (!customElements.get(COMBOBOX_TAG_NAME)) { + customElements.define(COMBOBOX_TAG_NAME, Combobox) +} diff --git a/src/v2/components/forms/select/README.md b/src/v2/components/forms/select/README.md new file mode 100644 index 00000000..483f014a --- /dev/null +++ b/src/v2/components/forms/select/README.md @@ -0,0 +1,204 @@ +# solid-ui-select component + +A Lit-based custom element that renders a styled select control with a custom popup listbox. It supports keyboard navigation, emits a `change` event when the selected value changes, 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 { Select } from 'solid-ui/components/forms/select' +``` + +The legacy flat import path `solid-ui/components/select` still works, but the grouped `forms/select` path is the preferred long-term entrypoint. + +```html + + + +``` + +## Usage in a plain HTML page (CDN / script tag) + +```html + + + + + +``` + +## TypeScript + +```typescript +import { Select } from 'solid-ui/components/forms/select' + +const select = document.querySelector('solid-ui-select') as Select +select.options = [ + { label: 'English', value: 'en' }, + { label: 'French', value: 'fr', disabled: false } +] + +select.addEventListener('change', (e: CustomEvent<{ value: string }>) => { + console.log(e.detail.value) +}) +``` + +`options` expects an array of: + +```typescript +type SelectOption = { + label: string + value: string + disabled?: boolean +} +``` + +## API + +### Properties / attributes + +| Property | Attribute | Type | Default | Description | +|----------|-----------|------|---------|-------------| +| `label` | `label` | `string` | `Select an option` | Fallback label shown when there is no selected value and no options are available. | +| `theme` | `theme` | `'light' \| 'dark'` | `'light'` | Sets the colour theme. | +| `options` | `options` | `SelectOption[]` | `[]` | Array of selectable options. 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 option value. If it matches an option, that option is shown in the trigger and moved to the top of the popup when opened. | + +### Events + +| Event | Detail | Description | +|-------|--------|-------------| +| `change` | `{ value: string }` | Fired when the user selects an option from the popup or confirms a keyboard selection. | + +### CSS custom properties + +These can be set on `solid-ui-select`, on a container element, or on `:root`. + +| Variable | Fallback | Description | +|----------|----------|-------------| +| `--select-z-index` | `400` / `900` in dark theme | Base host stacking level before the popup opens. | +| `--select-open-z-index` | `1000` | Host stacking level while the popup is open. | +| `--select-popup-z-index` | `1001` | Popup stacking level inside the open host. | +| `--select-popup-extra-width` | `2px` | Extra popup width beyond the trigger width. | +| `--select-popup-width` | `100%` | Base popup width before extra width is applied. | +| `--select-popup-background` | `--color-background` | Popup surface background. | +| `--select-trigger-background` | `--color-background` | Trigger background. | +| `--select-trigger-border` | `1px solid var(--gray-400, #99A1AF)` | Trigger border. | +| `--select-trigger-text` | `--color-text-subheading` | Trigger text colour. | +| `--select-trigger-height` | `--min-touch-target` / `44px` | Height of the trigger and option rows. | +| `--popup-border` | `--color-border` / `#E5E7EB` | Popup border colour. | +| `--popup-text` | `--color-text` | Popup text colour. | +| `--popup-shadow` | `--box-shadow-sm` / `0 1px 4px ...` | Popup shadow. | +| `--input-background` | `--color-background` | Listbox and option row background. | +| `--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-sm`, `--spacing-xxs`, `--spacing-xs`, `--font-size-sm`, `--font-weight-md`, `--font-weight-bold`, `--gray-400`, `--color-background`, `--color-text`, `--color-text-subheading`, `--color-border`, `--color-primary`, `--lavender-300`, `--lavender-400`, `--box-shadow-sm`, and `--min-touch-target`. + +### CSS shadow parts + +These parts can be styled from a consuming repo using `::part(...)`. + +| Part | Description | +|------|-------------| +| `select-trigger` | The trigger button. | +| `trigger-label` | The text label inside the trigger. | +| `trigger-icon` | The down-arrow icon wrapper inside the trigger. | +| `popup-box` | The popup container that wraps the listbox. | +| `listbox` | The `
      ` element that contains the options. | +| `option` | Every option row. | +| `selected-option` | Added to the currently selected option row. | +| `active-option` | Added to the currently keyboard-active option row. | +| `disabled-option` | Added to disabled option rows. | + +## Theming + +Set `theme="dark"` when placing the select on a dark background. + +```html + +``` + +In dark theme, the component switches its background and text fallbacks to dark-surface values while keeping the same public styling hooks. + +## Popup behaviour + +- Opens a popup listbox directly under the trigger. +- Keeps the currently selected option at the top of the popup when opened. +- Supports keyboard navigation with `ArrowUp`, `ArrowDown`, `Home`, `End`, `Enter`, `Space`, and `Escape`. +- Emits a `change` event with the selected value when the user picks an option. +- Closes when clicking outside the component. +- Skips disabled options during selection and keyboard navigation. + +## Styling from a consuming repo + +Use CSS custom properties on the host element for most theming: + +```css +solid-ui-select { + width: 100%; + --select-trigger-height: 48px; + --select-trigger-background: #ffffff; + --select-trigger-border: 1px solid #c7ced8; + --select-trigger-text: #101828; + --select-popup-background: #ffffff; + --select-popup-extra-width: 0px; + --item-hover-background: #eee7ff; + --item-selected-background: #d9c8ff; + --border-radius-sm: 0.2rem; +} +``` + +Use `::part(...)` when you need to target exposed internal elements directly: + +```css +solid-ui-select::part(select-trigger) { + box-shadow: none; +} + +solid-ui-select::part(popup-box) { + box-shadow: 0 8px 24px rgba(16, 24, 40, 0.12); +} + +solid-ui-select::part(option) { + letter-spacing: 0.01em; +} + +solid-ui-select::part(selected-option) { + font-weight: 700; +} +``` + +## Build + +```bash +npm run build +``` + +Webpack emits bundles to `dist/components/select/index.*`. diff --git a/src/v2/components/forms/select/Select.test.ts b/src/v2/components/forms/select/Select.test.ts new file mode 100644 index 00000000..b5665524 --- /dev/null +++ b/src/v2/components/forms/select/Select.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { Select } from './Select' +import './index' + +describe('SolidUISelect', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('is defined as a custom element', () => { + expect(customElements.get('solid-ui-select')).toBe(Select) + }) + + it('renders the trigger with the first option label by default', async () => { + const select = new Select() + select.label = 'Language' + select.options = [ + { label: 'English', value: 'en' }, + { label: 'French', value: 'fr' } + ] + + document.body.appendChild(select) + await select.updateComplete + + const trigger = select.shadowRoot?.querySelector('button.select-trigger') as HTMLButtonElement + const triggerIcon = select.shadowRoot?.querySelector('.select-trigger-icon svg') as SVGElement + + expect(trigger).not.toBeNull() + expect(triggerIcon).not.toBeNull() + expect(trigger.getAttribute('aria-haspopup')).toBe('listbox') + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(trigger.textContent).toContain('English') + }) + + it('opens the popup and updates the value when an option is clicked', async () => { + const select = new Select() + const changed = jest.fn() + + select.label = 'Language' + select.options = [ + { label: 'English', value: 'en' }, + { label: 'French', value: 'fr' } + ] + + select.addEventListener('change', (event: Event) => { + changed((event as CustomEvent).detail) + }) + + document.body.appendChild(select) + await select.updateComplete + + const trigger = select.shadowRoot?.querySelector('button.select-trigger') as HTMLButtonElement + trigger.click() + await select.updateComplete + + const listbox = select.shadowRoot?.querySelector('[role="listbox"]') as HTMLElement + const options = select.shadowRoot?.querySelectorAll('[role="option"]') as NodeListOf + + expect(listbox).not.toBeNull() + expect(options).toHaveLength(2) + + options[1].click() + await select.updateComplete + + expect(select.value).toBe('fr') + expect(trigger.textContent).toContain('French') + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(changed).toHaveBeenCalledWith({ value: 'fr' }) + }) + + it('renders the selected option first in the popup', async () => { + const select = new Select() + select.options = [ + { label: 'English', value: 'en' }, + { label: 'French', value: 'fr' }, + { label: 'Spanish', value: 'es' } + ] + select.value = 'fr' + + document.body.appendChild(select) + await select.updateComplete + + const trigger = select.shadowRoot?.querySelector('button.select-trigger') as HTMLButtonElement + trigger.click() + await select.updateComplete + + const options = Array.from(select.shadowRoot?.querySelectorAll('[role="option"]') as NodeListOf) + + expect(options).toHaveLength(3) + expect(options[0].textContent).toContain('French') + expect(options[0].getAttribute('aria-selected')).toBe('true') + }) + it('supports keyboard selection from the trigger', async () => { + const select = new Select() + const changed = jest.fn() + + select.label = 'Language' + select.options = [ + { label: 'English', value: 'en' }, + { label: 'French', value: 'fr' }, + { label: 'Spanish', value: 'es' } + ] + + select.addEventListener('change', (event: Event) => { + changed((event as CustomEvent).detail) + }) + + document.body.appendChild(select) + await select.updateComplete + + const trigger = select.shadowRoot?.querySelector('button.select-trigger') as HTMLButtonElement + + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + await select.updateComplete + + expect(trigger.getAttribute('aria-expanded')).toBe('true') + expect(trigger.getAttribute('aria-activedescendant')).toBeTruthy() + + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + await select.updateComplete + + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + await select.updateComplete + + expect(select.value).toBe('fr') + expect(trigger.textContent).toContain('French') + expect(changed).toHaveBeenCalledWith({ value: 'fr' }) + }) + + it('closes the popup when clicking outside the component', async () => { + const select = new Select() + select.options = [ + { label: 'English', value: 'en' }, + { label: 'French', value: 'fr' } + ] + + document.body.appendChild(select) + await select.updateComplete + + const trigger = select.shadowRoot?.querySelector('button.select-trigger') as HTMLButtonElement + trigger.click() + await select.updateComplete + + expect(trigger.getAttribute('aria-expanded')).toBe('true') + + document.body.dispatchEvent(new Event('pointerdown', { bubbles: true })) + await select.updateComplete + + expect(trigger.getAttribute('aria-expanded')).toBe('false') + }) +}) diff --git a/src/v2/components/forms/select/Select.ts b/src/v2/components/forms/select/Select.ts new file mode 100644 index 00000000..6c7cff36 --- /dev/null +++ b/src/v2/components/forms/select/Select.ts @@ -0,0 +1,463 @@ +import { css, html, LitElement } from 'lit' +import { downArrowIcon } from '../shared/downArrow' +import { renderListbox } from '../shared/listboxTemplate' +import { SelectOption } from '../shared/optionTypes' +import { listboxStyles } from '../shared/listboxStyles' +import { + findOptionIndexByValue, + getFirstEnabledIndex, + getLastEnabledIndex, + getListboxActionFromKey, + getNextEnabledIndex +} from '../shared/keyboard' + +/* The following keyboard navigation and ARIA support for Select + were generated by AI Model: GPT-5.4 */ +/* Prompt: can you wire up the keyboard interactions and aria attributes for Select */ +export class Select extends LitElement { + private static _nextId = 0 + 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.contains(eventTarget)) { + this._closePopup() + } + } + + static properties = { + label: { type: String, reflect: true }, + theme: { type: String, reflect: true }, + options: { type: Array, reflect: true }, + layout: { type: String, reflect: true }, + value: { type: String, reflect: true }, + _popupOpen: { state: true }, + _activeIndex: { state: true } + } + + static styles = [ + listboxStyles, + css` + :host { + // default theme + display: inline-block; + position: relative; + z-index: var(--select-z-index, 400); + box-sizing: border-box; + --select-open-z-index: 1000; + --select-popup-z-index: 1001; + --select-popup-extra-width: 2px; + --select-popup-background: var(--color-background, #f8f9fb); + --select-trigger-background: var(--color-background, #f8f9fb); + --select-trigger-hover-background: var( + --color-surface-subtle, + rgba(15, 23, 43, 0.04) + ); + --select-trigger-border: 1px solid var(--gray-400, #99a1af); + --select-trigger-hover-border: 1px solid var(--gray-400, #99a1af); + --select-trigger-text: var(--color-text-subheading, #101828); + --select-trigger-hover-text: var(--color-text-subheading, #101828); + --select-popup-width: 100%; + --popup-background: var(--select-popup-background); + --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) + ); + } + + :host([theme='dark']) { + display: inline-block; + position: relative; + z-index: var(--select-z-index, 900); + box-sizing: border-box; + --select-open-z-index: 1000; + --select-popup-z-index: 1001; + --select-popup-extra-width: 2px; + --select-popup-background: var(--color-background, #1a1a1a); + --select-trigger-background: var(--color-background, #1a1a1a); + --select-trigger-hover-background: var( + --color-surface-subtle, + rgba(15, 23, 43, 0.04) + ); + --select-trigger-border: 1px solid var(--gray-400, #99a1af); + --select-trigger-hover-border: 1px solid var(--gray-400, #99a1af); + --select-trigger-text: var(--color-text-subheading, #f8f9fb); + --select-trigger-hover-text: var(--color-text-subheading, #f8f9fb); + --select-popup-width: 100%; + --popup-background: var(--select-popup-background); + --popup-text: var(--color-text, #f8f9fb); + --popup-border: var(--color-border, #e5e7eb); + --popup-shadow: var( + --box-shadow-sm, + 0 1px 4px rgba(124, 77, 255, 0.12) + ); + } + + :host([popup-open]) { + z-index: var(--select-open-z-index); + } + + .select-trigger { + display: flex; + width: 100%; + min-height: var(--select-trigger-height, var(--min-touch-target, 44px)); + height: var(--select-trigger-height, var(--min-touch-target, 44px)); + padding: var(--spacing-xxs, 0.3125rem) + var(--select-trigger-inline-padding, var(--spacing-2xs, 0.625rem)); + align-items: center; + justify-content: space-between; + gap: var(--spacing-xxs, 0.3125rem); + border-radius: var(--border-radius-base, 0.3125rem); + background: var(--select-trigger-background); + border: var( + --select-trigger-border, + 1px solid var(--gray-400, #99a1af) + ); + color: var(--select-trigger-text); + cursor: pointer; + font-family: inherit; + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-md, 500); + line-height: normal; + text-align: left; + white-space: nowrap; + text-decoration: none; + box-sizing: border-box; + transition: transform 0.2s ease; + } + + .select-trigger:active { + transform: translateY(1px); + } + + .select-trigger:hover { + background: var( + --select-trigger-hover-background, + var(--select-trigger-background) + ); + border: var( + --select-trigger-hover-border, + var(--select-trigger-border) + ); + color: var(--select-trigger-hover-text, var(--select-trigger-text)); + } + + .select-trigger:focus-visible { + outline: 2px solid var(--color-focus-ring, var(--color-primary, #7C4DFF)); + outline-offset: 2px; + background: var( + --select-trigger-hover-background, + var(--select-trigger-background) + ); + border: var( + --select-trigger-hover-border, + var(--select-trigger-border) + ); + color: var(--select-trigger-hover-text, var(--select-trigger-text)); + } + + .select-trigger-label { + flex: 1 1 auto; + min-width: 0; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + } + + .select-trigger-icon { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 0.75rem; + height: 0.4375rem; + pointer-events: none; + } + + .select-trigger-icon svg { + width: 100%; + height: 100%; + display: block; + } + + .select-options-section { + position: relative; + background: var(--popup-background); + border-radius: inherit; + isolation: isolate; + } + + .popup-box { + position: absolute; + top: calc(100% - 1px); + left: calc(var(--select-popup-extra-width) / -2); + width: calc( + var(--select-popup-width) + var(--select-popup-extra-width) + ); + min-width: calc( + var(--select-popup-width) + var(--select-popup-extra-width) + ); + background: var(--popup-background); + opacity: 1; + overflow: hidden; + color: var(--popup-text); + box-shadow: var(--popup-shadow); + border: 1px solid var(--popup-border); + border-radius: var(--border-radius-sm, 0.2rem); + box-sizing: border-box; + isolation: isolate; + z-index: var(--select-popup-z-index); + } + ` + ] + + declare label: string + declare theme: 'light' | 'dark' + declare options: Array + declare layout: 'desktop' | 'mobile' + declare value: string + declare _popupOpen: boolean + declare _activeIndex: number + + private readonly _listboxId = `solid-ui-select-listbox-${Select._nextId++}` + + constructor () { + super() + this.label = 'Select an option' + this.theme = 'light' + this.layout = 'desktop' + this.value = '' + this._popupOpen = false + this._activeIndex = -1 + } + + connectedCallback () { + super.connectedCallback() + document.addEventListener('pointerdown', this._handleDocumentPointerDown) + } + + disconnectedCallback () { + document.removeEventListener('pointerdown', this._handleDocumentPointerDown) + super.disconnectedCallback() + } + + protected updated () { + this.toggleAttribute('popup-open', this._popupOpen) + } + + private _closePopup () { + this._popupOpen = false + this._activeIndex = -1 + } + + 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 _selectValueFromDropdown (uri: string) { + this.value = uri + this.dispatchEvent( + new CustomEvent('change', { + detail: { value: uri }, + bubbles: true, + composed: true + }) + ) + this._closePopup() + } + + private _selectActiveOption () { + const activeOption = this._getActiveOption() + + if (activeOption && !activeOption.disabled) { + this._selectValueFromDropdown(activeOption.value) + } + } + + private _openPopup () { + const popupOptions = this._getDisplayedOptions() + + this._popupOpen = true + this._activeIndex = findOptionIndexByValue(popupOptions, this.value) + + if (this._activeIndex < 0) { + this._activeIndex = getFirstEnabledIndex(popupOptions) + } + } + + private _handleTriggerKeydown (event: KeyboardEvent) { + const popupOptions = this._getDisplayedOptions() + const action = getListboxActionFromKey(event.key) + + if (action === 'none') { + return + } + + event.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: SelectOption, index: number) { + return `${this._listboxId}-option-${index}-${option.value}` + } + + private _renderPopup () { + const popupOptions = this._getDisplayedOptions() + const selectedOption = this._getSelectedOption() + const activeOption = this._getActiveOption() + + return html` + + ` + } + + render () { + const selectedOption = this._getSelectedOption() + const activeOption = this._getActiveOption() + const triggerLabel = selectedOption?.label ?? this.label + const activeDescendant = + this._popupOpen && activeOption + ? this._getOptionId(activeOption, this._activeIndex) + : undefined + + return html` + + +
      + ${this._popupOpen ? this._renderPopup() : ''} +
      + ` + } +} diff --git a/src/v2/components/forms/select/index.ts b/src/v2/components/forms/select/index.ts new file mode 100644 index 00000000..258a5f29 --- /dev/null +++ b/src/v2/components/forms/select/index.ts @@ -0,0 +1,9 @@ +import { Select } from './Select' + +export { Select } + +const SELECT_TAG_NAME = 'solid-ui-select' + +if (!customElements.get(SELECT_TAG_NAME)) { + customElements.define(SELECT_TAG_NAME, Select) +} diff --git a/src/v2/components/forms/shared/downArrow.ts b/src/v2/components/forms/shared/downArrow.ts new file mode 100644 index 00000000..fdd8ae8e --- /dev/null +++ b/src/v2/components/forms/shared/downArrow.ts @@ -0,0 +1,10 @@ +import { html } from 'lit-html' + +export const downArrowIcon = html` + + + +` diff --git a/src/v2/components/forms/shared/keyboard.ts b/src/v2/components/forms/shared/keyboard.ts new file mode 100644 index 00000000..d2d06072 --- /dev/null +++ b/src/v2/components/forms/shared/keyboard.ts @@ -0,0 +1,82 @@ +import { SelectOption } from './optionTypes' + +/* Move up or down, skip disabled options */ +export function getNextEnabledIndex ( + currentIndex: number, + options: SelectOption[], + direction: 1 | -1 +): number { + if (!options.length) { + return -1 + } + + if (options.every(option => option.disabled)) { + return -1 + } + + const optionsCount = options.length + let nextIndex = currentIndex + + do { + nextIndex = (nextIndex + direction + optionsCount) % optionsCount + } while (options[nextIndex].disabled) + + return nextIndex +} + +/* Handle 'Home' and 'End' keys and initial highlight */ +export function getFirstEnabledIndex (options: SelectOption[]): number { + if (!options.length) { + return -1 + } + + return getNextEnabledIndex(-1, options, 1) +} + +export function getLastEnabledIndex (options: SelectOption[]): number { + if (!options.length) { + return -1 + } + + return getNextEnabledIndex(options.length, options, -1) +} + +/* Sync current value to active index */ +export function findOptionIndexByValue ( + options: SelectOption[], + value?: string +): number { + if (value === undefined) { + return -1 + } + return options.findIndex(option => option.value === value) +} + +/* Map keyboard events to actions */ +export function getListboxActionFromKey (key: string): + | 'open' + | 'close' + | 'next' + | 'previous' + | 'first' + | 'last' + | 'select' + | 'none' { + switch (key) { + case 'ArrowDown': + return 'next' + case 'ArrowUp': + return 'previous' + case 'Home': + return 'first' + case 'End': + return 'last' + case 'Enter': + case ' ': + return 'select' + case 'Escape': + return 'close' + default: + return 'none' + } +} diff --git a/src/v2/components/forms/shared/listboxStyles.ts b/src/v2/components/forms/shared/listboxStyles.ts new file mode 100644 index 00000000..7ed20bd7 --- /dev/null +++ b/src/v2/components/forms/shared/listboxStyles.ts @@ -0,0 +1,86 @@ +import { css } from 'lit' + +export const listboxStyles = css` + :host { // default theme + --input-background: var(--color-background, #F8F9FB); + --item-text: var(--color-text, #1A1A1A); + --item-selected-text: var(--color-primary, #7c4dff); + --item-hover-background: var(--lavender-300, #e6dcff); + --item-selected-background: var(--lavender-400, #cbb9ff); + --listbox-z-index: 1; + } + + :host([theme='dark']) { + --input-background: var(--color-background, #1A1A1A); + --item-text: var(--color-text, #F8F9FB); + --item-selected-text: var(--color-primary, #7c4dff); + --item-hover-background: var(--lavender-300, #e6dcff); + --item-selected-background: var(--lavender-400, #cbb9ff); + --listbox-z-index: 1; + } + + .listbox { + position: relative; + top: 0; + left: 0; + right: 0; + margin: 0; + padding: 0; + list-style: none; + border: none; + border-radius: inherit; + background: var(--input-background); + background-color: var(--input-background); + opacity: 1; + overflow: hidden; + z-index: var(--listbox-z-index); + box-shadow: 0 4px 12px rgba(124, 77, 255, 0.12); + } + + .listbox-item { + display: flex; + align-items: center; + width: 100%; + min-height: var(--select-trigger-height, var(--min-touch-target, 44px)); + padding: var(--spacing-xxs, 0.3125rem) var(--spacing-xs, 0.75rem); + border: none; + border-bottom: 1px solid var(--color-border, #E5E7EB); + background: var(--input-background); + color: var(--item-text); + cursor: pointer; + font: inherit; + line-height: normal; + text-align: left; + box-sizing: border-box; + } + + .listbox-item:last-child { + border-bottom: none; + } + + .listbox-item:hover { + background: var(--item-hover-background); + border-radius: var(--border-radius-sm, 0.2rem); + } + + .listbox-item-active { + background: var(--item-selected-background); + color: var(--item-selected-text); + border-radius: var(--border-radius-sm, 0.2rem); + outline: none; + } + + .listbox-item-selected { + font-weight: var(--font-weight-bold, 600); + } + + .listbox-item-disabled { + opacity: 0.55; + cursor: not-allowed; + } + + .listbox-item-disabled:hover { + background: transparent; + border-radius: 0; + } +` diff --git a/src/v2/components/forms/shared/listboxTemplate.ts b/src/v2/components/forms/shared/listboxTemplate.ts new file mode 100644 index 00000000..38d2ddfa --- /dev/null +++ b/src/v2/components/forms/shared/listboxTemplate.ts @@ -0,0 +1,56 @@ +import { html } from 'lit' +import type { SelectOption } from './optionTypes' + +export interface RenderListboxArgs { + options: SelectOption[] + selectedOption?: SelectOption + activeOption?: SelectOption + listboxId?: string + getOptionId?: (option: SelectOption, index: number) => string + onOptionSelect: (option: SelectOption) => void +} + +export function renderListbox (args: RenderListboxArgs) { + const { + options, + selectedOption, + activeOption, + listboxId, + getOptionId, + onOptionSelect + } = args + + return html` +
        + ${options.map((option, index) => { + const isSelected = option.value === selectedOption?.value + const isActive = option.value === activeOption?.value + const optionId = getOptionId?.(option, index) + + return html` +
      • + ${option.label} +
      • + ` + })} +
      + ` +} diff --git a/src/v2/components/forms/shared/optionTypes.ts b/src/v2/components/forms/shared/optionTypes.ts new file mode 100644 index 00000000..e0c76398 --- /dev/null +++ b/src/v2/components/forms/shared/optionTypes.ts @@ -0,0 +1,5 @@ +export interface SelectOption { + label: string + value: string + disabled?: boolean +} diff --git a/src/v2/components/footer/Footer.test.ts b/src/v2/components/layout/footer/Footer.test.ts similarity index 100% rename from src/v2/components/footer/Footer.test.ts rename to src/v2/components/layout/footer/Footer.test.ts diff --git a/src/v2/components/footer/Footer.ts b/src/v2/components/layout/footer/Footer.ts similarity index 98% rename from src/v2/components/footer/Footer.ts rename to src/v2/components/layout/footer/Footer.ts index e3277dc5..2d0158fc 100644 --- a/src/v2/components/footer/Footer.ts +++ b/src/v2/components/layout/footer/Footer.ts @@ -1,7 +1,7 @@ import { LitElement, html, css } from 'lit' import type { LiveStore, NamedNode } from 'rdflib' import { authSession, authn } from 'solid-logic' -import { getName } from '../../../utils/headerFooterHelpers' +import { getName } from '../../../../utils/headerFooterHelpers' export class Footer extends LitElement { static properties = { diff --git a/src/v2/components/footer/README.md b/src/v2/components/layout/footer/README.md similarity index 99% rename from src/v2/components/footer/README.md rename to src/v2/components/layout/footer/README.md index 2b8335a0..c868dba6 100644 --- a/src/v2/components/footer/README.md +++ b/src/v2/components/layout/footer/README.md @@ -102,4 +102,4 @@ footer.store = myRdflibStore as LiveStore ## Testing -The component is covered by unit tests under `src/v2/components/footer/Footer.test.ts`. +The component is covered by unit tests under `src/v2/components/layout/footer/Footer.test.ts`. diff --git a/src/v2/components/footer/index.ts b/src/v2/components/layout/footer/index.ts similarity index 100% rename from src/v2/components/footer/index.ts rename to src/v2/components/layout/footer/index.ts diff --git a/src/v2/components/header/Header.ts b/src/v2/components/layout/header/Header.ts similarity index 99% rename from src/v2/components/header/Header.ts rename to src/v2/components/layout/header/Header.ts index 0f1774e6..e6ccb1c3 100644 --- a/src/v2/components/header/Header.ts +++ b/src/v2/components/layout/header/Header.ts @@ -1,8 +1,8 @@ import { LitElement, html, css } from 'lit' -import { icons } from '../../../iconBase' +import { icons } from '../../../../iconBase' import { authSession } from 'solid-logic' -import '../loginButton/index' -import '../signupButton/index' +import '../../auth/loginButton/index' +import '../../auth/signupButton/index' import { ifDefined } from 'lit/directives/if-defined.js' const DEFAULT_HELP_MENU_ICON = '' diff --git a/src/v2/components/header/README.md b/src/v2/components/layout/header/README.md similarity index 97% rename from src/v2/components/header/README.md rename to src/v2/components/layout/header/README.md index 3d67d415..f252e999 100644 --- a/src/v2/components/header/README.md +++ b/src/v2/components/layout/header/README.md @@ -68,7 +68,7 @@ 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 login button is a self-contained component with its own README: [`src/v2/components/auth/loginButton/README.md`](../../auth/loginButton/README.md). The header automatically imports and registers it — no separate import is needed. @@ -185,12 +185,12 @@ The help menu trigger and dropdown are only rendered when the incoming `layout` ## Testing -Unit test file: `src/v2/components/header/header.test.ts` +Unit test file: `src/v2/components/layout/header/header.test.ts` Run tests: ```bash -npm test -- --runInBand --testPathPatterns=src/v2/components/header/header.test.ts +npm test -- --runInBand --testPathPatterns=src/v2/components/layout/header/header.test.ts ``` Run full suite: diff --git a/src/v2/components/header/header.test.ts b/src/v2/components/layout/header/header.test.ts similarity index 100% rename from src/v2/components/header/header.test.ts rename to src/v2/components/layout/header/header.test.ts diff --git a/src/v2/components/header/index.ts b/src/v2/components/layout/header/index.ts similarity index 100% rename from src/v2/components/header/index.ts rename to src/v2/components/layout/header/index.ts diff --git a/src/v2/components/media/photoCapture/PhotoCapture.test.ts b/src/v2/components/media/photoCapture/PhotoCapture.test.ts new file mode 100644 index 00000000..099fbba5 --- /dev/null +++ b/src/v2/components/media/photoCapture/PhotoCapture.test.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { PhotoCapture } from './PhotoCapture' +import './index' + +describe('SolidUIPhotoCapture', () => { + const stopTrack = jest.fn() + const getUserMedia: any = jest.fn() + + beforeEach(() => { + document.body.innerHTML = '' + stopTrack.mockReset() + getUserMedia.mockReset() + getUserMedia.mockResolvedValue({ + getTracks: () => [{ stop: stopTrack }], + getVideoTracks: () => [{ stop: stopTrack }] + }) + + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { getUserMedia } + }) + + Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', { + configurable: true, + get () { + return (this as HTMLMediaElement & { __srcObject?: MediaStream | null }).__srcObject ?? null + }, + set (value) { + ;(this as HTMLMediaElement & { __srcObject?: MediaStream | null }).__srcObject = value as MediaStream | null + } + }) + + Object.defineProperty(HTMLMediaElement.prototype, 'play', { + configurable: true, + value: jest.fn(() => Promise.resolve(undefined)) + }) + + Object.defineProperty(HTMLDialogElement.prototype, 'showModal', { + configurable: true, + value: jest.fn() + }) + + Object.defineProperty(HTMLDialogElement.prototype, 'close', { + configurable: true, + value: jest.fn() + }) + + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + configurable: true, + value: jest.fn(() => ({ drawImage: jest.fn() })) + }) + + Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', { + configurable: true, + value: jest.fn((callback: BlobCallback, type?: string) => { + callback(new Blob(['photo'], { type: type || 'image/png' })) + }) + }) + + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: jest.fn(() => 'blob:test-photo') + }) + + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: jest.fn() + }) + }) + + it('is defined as a custom element', () => { + expect(customElements.get('solid-ui-photo-capture')).toBe(PhotoCapture) + }) + + it('starts the preview inline using the default environment-facing video constraint', async () => { + const photoCapture = new PhotoCapture() + + document.body.appendChild(photoCapture) + await photoCapture.updateComplete + await Promise.resolve() + await photoCapture.updateComplete + + expect(getUserMedia).toHaveBeenCalledWith({ + video: { + facingMode: { ideal: 'environment' } + } + }) + }) + + it('accepts dialog presentation and custom constraints JSON', async () => { + const photoCapture = new PhotoCapture() + photoCapture.presentation = 'dialog' + photoCapture.open = false + photoCapture.constraints = JSON.stringify({ video: true, audio: false }) + + document.body.appendChild(photoCapture) + await photoCapture.updateComplete + + const trigger = photoCapture.shadowRoot?.querySelector('button.trigger-button') as HTMLButtonElement + trigger.click() + await photoCapture.updateComplete + await Promise.resolve() + await photoCapture.updateComplete + + expect(photoCapture.open).toBe(true) + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalled() + expect(getUserMedia).toHaveBeenCalledWith({ video: true, audio: false }) + }) + + it('dispatches a photo-captured event with the confirmed blob', async () => { + const photoCapture = new PhotoCapture() + const captured = jest.fn() + const changed = jest.fn() + + photoCapture.addEventListener('photo-captured', (event: Event) => { + captured((event as CustomEvent).detail) + }) + photoCapture.addEventListener('change', (event: Event) => { + changed((event as CustomEvent).detail) + }) + + document.body.appendChild(photoCapture) + await photoCapture.updateComplete + await Promise.resolve() + await photoCapture.updateComplete + + const video = photoCapture.shadowRoot?.querySelector('video.capture-preview') as HTMLVideoElement + Object.defineProperty(video, 'videoWidth', { configurable: true, value: 320 }) + Object.defineProperty(video, 'videoHeight', { configurable: true, value: 240 }) + + await (photoCapture as any)._captureSnapshot() + await photoCapture.updateComplete + + const confirmButton = photoCapture.shadowRoot?.querySelector('[part="confirm-button"]') as HTMLButtonElement + confirmButton.click() + + expect(captured).toHaveBeenCalledWith({ + file: expect.any(File), + blob: expect.any(Blob), + objectUrl: 'blob:test-photo', + contentType: 'image/png' + }) + expect(photoCapture.value).toBeInstanceOf(File) + expect(changed).toHaveBeenCalledWith({ value: photoCapture.value }) + }) + + it('can participate in a form-like submission while still exposing a value property', async () => { + const form = document.createElement('form') + const photoCapture = new PhotoCapture() + photoCapture.name = 'avatar' + form.appendChild(photoCapture) + document.body.appendChild(form) + + await photoCapture.updateComplete + await Promise.resolve() + await photoCapture.updateComplete + + const video = photoCapture.shadowRoot?.querySelector('video.capture-preview') as HTMLVideoElement + Object.defineProperty(video, 'videoWidth', { configurable: true, value: 320 }) + Object.defineProperty(video, 'videoHeight', { configurable: true, value: 240 }) + + await (photoCapture as any)._captureSnapshot() + await photoCapture.updateComplete + ;(photoCapture as any)._confirmPhoto() + + expect(photoCapture.value).toBeInstanceOf(File) + + const formData = new FormData() + const formDataEvent = new Event('formdata') as Event & { formData: FormData } + formDataEvent.formData = formData + form.dispatchEvent(formDataEvent) + + const submitted = formData.get('avatar') + expect(submitted).toBeInstanceOf(File) + expect((submitted as File).name).toContain('avatar-') + }) +}) diff --git a/src/v2/components/media/photoCapture/PhotoCapture.ts b/src/v2/components/media/photoCapture/PhotoCapture.ts new file mode 100644 index 00000000..c57b7dfb --- /dev/null +++ b/src/v2/components/media/photoCapture/PhotoCapture.ts @@ -0,0 +1,825 @@ +import { LitElement, css, html, nothing, type PropertyValues } from 'lit' +/* The original code was written by Sir Tim Berners-Lee. It was made into a +web component by AI Model GPT-5.4 +Prompt: Take the code from src/media/media-capture.ts and make it a +web component. Make it work in forms as well as not. Make it +configurable and follow LoginButton. */ +export interface PhotoCapturedDetail { + file: File + blob: Blob + objectUrl: string + contentType: string +} + +export interface PhotoCaptureErrorDetail { + error: unknown + message: string +} + +export interface PhotoCaptureOpenChangeDetail { + open: boolean +} + +export interface PhotoCaptureValueDetail { + value: File | null +} + +type PresentationMode = 'inline' | 'dialog' +type ThemeMode = 'light' | 'dark' + +const DEFAULT_CAPTURE_FORMAT = 'image/png' + +export class PhotoCapture extends LitElement { + static formAssociated = true + + static properties = { + label: { type: String, reflect: true }, + heading: { type: String, reflect: true }, + captureLabel: { type: String, attribute: 'capture-label', reflect: true }, + confirmLabel: { type: String, attribute: 'confirm-label', reflect: true }, + retakeLabel: { type: String, attribute: 'retake-label', reflect: true }, + cancelLabel: { type: String, attribute: 'cancel-label', reflect: true }, + presentation: { type: String, reflect: true }, + theme: { type: String, reflect: true }, + facingMode: { type: String, attribute: 'facing-mode', reflect: true }, + constraints: { type: String, reflect: true }, + captureFormat: { type: String, attribute: 'capture-format', reflect: true }, + captureQuality: { type: Number, attribute: 'capture-quality' }, + open: { type: Boolean, reflect: true }, + disabled: { type: Boolean, reflect: true }, + name: { type: String, reflect: true }, + required: { type: Boolean, reflect: true }, + showTrigger: { type: Boolean, attribute: 'show-trigger', reflect: true }, + showCancelButton: { type: Boolean, attribute: 'show-cancel-button', reflect: true }, + autoCloseOnCapture: { type: Boolean, attribute: 'auto-close-on-capture' }, + fileNamePrefix: { type: String, attribute: 'file-name-prefix', reflect: true }, + value: { attribute: false }, + mediaConstraints: { attribute: false }, + _errorMessage: { state: true }, + _previewUrl: { state: true }, + _startingPreview: { state: true } + } + + static styles = css` + :host { + display: block; + --photo-capture-trigger-background: var(--lavender-900, #7c4cff); + --photo-capture-trigger-text: var(--color-header-text, #ffffff); + --photo-capture-surface: var(--color-background, #ffffff); + --photo-capture-text: var(--gray-900, #101828); + --photo-capture-muted-text: var(--gray-600, #4a5565); + --photo-capture-border: var(--gray-200, #e5e7eb); + --photo-capture-hover: var(--gray-100, #f3f4f6); + --photo-capture-shadow: var(--box-shadow-sm, 0 1px 4px rgba(0, 0, 0, 0.12)); + --photo-capture-overlay: rgba(0, 0, 0, 0.6); + --photo-capture-frame-max-width: 260px; + --photo-capture-radius: 8px; + --photo-capture-button-radius: var(--border-radius-base, 0.3125rem); + --photo-capture-gap: var(--spacing-2xs, 0.625rem); + color: var(--photo-capture-text); + box-sizing: border-box; + } + + :host([theme='dark']) { + --photo-capture-surface: var(--gray-900, #111827); + --photo-capture-text: var(--white, #ffffff); + --photo-capture-muted-text: var(--gray-300, #d1d5dc); + --photo-capture-border: var(--gray-700, #364153); + --photo-capture-hover: rgba(255, 255, 255, 0.08); + --photo-capture-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); + } + + *, *::before, *::after { + box-sizing: border-box; + } + + .trigger-button, + .action-button, + .cancel-button, + .close-button { + font: inherit; + cursor: pointer; + } + + .trigger-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 35px; + padding: 0.5rem 0.9rem; + border: none; + border-radius: var(--photo-capture-button-radius); + background: var(--photo-capture-trigger-background); + color: var(--photo-capture-trigger-text); + transition: transform 0.2s ease; + } + + .trigger-button:active { + transform: translateY(1px); + } + + .trigger-button:disabled, + .action-button:disabled, + .cancel-button:disabled { + opacity: 0.55; + cursor: not-allowed; + } + + .inline-root[hidden] { + display: none; + } + + .dialog { + border: none; + padding: 0; + background: transparent; + outline: none; + overflow: visible; + max-width: none; + max-height: none; + } + + .dialog::backdrop { + background: var(--photo-capture-overlay); + } + + .panel { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--photo-capture-gap); + width: min(100%, 340px); + padding: 1rem; + border: 1px solid var(--photo-capture-border); + border-radius: var(--photo-capture-radius); + background: var(--photo-capture-surface); + color: var(--photo-capture-text); + box-shadow: var(--photo-capture-shadow); + } + + .panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + width: 100%; + } + + .panel-heading { + margin: 0; + font-size: 1rem; + font-weight: 700; + line-height: 1.4; + } + + .close-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + padding: 0; + border: none; + border-radius: 999px; + background: transparent; + color: var(--photo-capture-muted-text); + font-size: 1.125rem; + line-height: 1; + } + + .close-button:hover, + .close-button:focus-visible, + .action-button:hover, + .action-button:focus-visible, + .cancel-button:hover, + .cancel-button:focus-visible { + background: var(--photo-capture-hover); + } + + .viewport { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 200px; + border-radius: 0.5rem; + overflow: hidden; + background: color-mix(in srgb, var(--photo-capture-surface) 92%, #000 8%); + } + + .viewport video, + .viewport img { + display: block; + width: 100%; + max-width: var(--photo-capture-frame-max-width); + height: auto; + border-radius: 0.5rem; + margin: 0 auto; + object-fit: cover; + } + + .status { + width: 100%; + text-align: center; + color: var(--photo-capture-muted-text); + font-size: 0.875rem; + } + + .status.error { + color: var(--color-error, #b00020); + } + + .actions { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: var(--photo-capture-gap); + width: 100%; + } + + .action-button, + .cancel-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.25rem; + padding: 0.45rem 0.85rem; + border-radius: var(--photo-capture-button-radius); + border: 1px solid var(--photo-capture-border); + background: var(--photo-capture-surface); + color: var(--photo-capture-text); + font-size: var(--font-size-xxs, 0.75rem); + font-weight: var(--font-weight-xbold, 700); + line-height: 1.5; + } + + .action-button--primary { + background: var(--photo-capture-trigger-background); + color: var(--photo-capture-trigger-text); + border-color: transparent; + } + ` + + declare label: string + declare heading: string + declare captureLabel: string + declare confirmLabel: string + declare retakeLabel: string + declare cancelLabel: string + declare presentation: PresentationMode + declare theme: ThemeMode + declare facingMode: string + declare constraints: string + declare captureFormat: string + declare captureQuality?: number + declare open: boolean + declare disabled: boolean + declare name: string + declare required: boolean + declare showTrigger: boolean + declare showCancelButton: boolean + declare autoCloseOnCapture: boolean + declare fileNamePrefix: string + declare mediaConstraints?: MediaStreamConstraints + declare _errorMessage: string + declare _previewUrl: string + declare _startingPreview: boolean + + private _value: File | null = null + private _stream: MediaStream | null = null + private readonly _internals: ElementInternals | null + private _associatedForm: HTMLFormElement | null = null + private readonly _handleFormData = (event: Event) => { + const formData = (event as Event & { formData?: FormData }).formData + if (!formData || !this.name || !this.value || this.disabled) return + formData.append(this.name, this.value, this.value.name) + } + + private readonly _handleFormReset = () => { + this._clearValue({ emitEvents: false }) + if (this.open) { + this._queuePreviewStart() + } + } + + private get _supportsFormInternals (): boolean { + return !!this._internals && typeof this._internals.setFormValue === 'function' + } + + constructor () { + super() + this.label = 'Take Photo' + this.heading = 'Take a photo' + this.captureLabel = 'Take Photo' + this.confirmLabel = 'Use Photo' + this.retakeLabel = 'Retake' + this.cancelLabel = 'Cancel' + this.presentation = 'inline' + this.theme = 'light' + this.facingMode = 'environment' + this.constraints = '' + this.captureFormat = DEFAULT_CAPTURE_FORMAT + this.captureQuality = undefined + this.open = true + this.disabled = false + this.name = '' + this.required = false + this.showTrigger = false + this.showCancelButton = true + this.autoCloseOnCapture = false + this.fileNamePrefix = '' + this.mediaConstraints = undefined + this._errorMessage = '' + this._previewUrl = '' + this._startingPreview = false + this._internals = typeof this.attachInternals === 'function' ? this.attachInternals() : null + } + + get value (): File | null { + return this._value + } + + set value (nextValue: File | null) { + const normalizedValue = nextValue instanceof File ? nextValue : null + const previousValue = this._value + if (previousValue === normalizedValue) return + + this._value = normalizedValue + this._syncPreviewFromValue(normalizedValue) + this._syncFormValue() + this._syncValidity() + this.requestUpdate('value', previousValue) + } + + get form (): HTMLFormElement | null { + return (this._supportsFormInternals ? this._internals?.form : null) ?? this._associatedForm + } + + get validationMessage (): string { + return (typeof this._internals?.validationMessage === 'string' ? this._internals.validationMessage : '') || (this.required && !this.value ? 'Please capture a photo.' : '') + } + + get willValidate (): boolean { + return typeof this._internals?.willValidate === 'boolean' ? this._internals.willValidate : !this.disabled + } + + checkValidity (): boolean { + if (this._internals && typeof this._internals.checkValidity === 'function') { + return this._internals.checkValidity() + } + return !(this.required && !this.value) + } + + reportValidity (): boolean { + if (this._internals && typeof this._internals.reportValidity === 'function') { + return this._internals.reportValidity() + } + return this.checkValidity() + } + + connectedCallback () { + super.connectedCallback() + this._syncAssociatedForm() + this._syncFormValue() + this._syncValidity() + } + + disconnectedCallback () { + this._syncAssociatedForm(null) + this._stopStream() + this._revokePreviewUrl() + super.disconnectedCallback() + } + + formResetCallback () { + this._handleFormReset() + } + + formDisabledCallback (disabled: boolean) { + this.disabled = disabled + } + + protected updated (changed: PropertyValues) { + this._syncAssociatedForm() + + if (this.presentation === 'dialog') { + const dialog = this.shadowRoot?.querySelector('dialog') as HTMLDialogElement | null + if (dialog) { + if (this.open && !dialog.open) { + dialog.showModal() + } else if (!this.open && dialog.open) { + dialog.close() + } + } + } + + if (changed.has('open') && !this.open) { + this._stopStream() + } + + if ( + this.open && + !this.value && + !this._stream && + !this._startingPreview && + (changed.has('open') || changed.has('presentation') || changed.has('_previewUrl') || changed.has('value')) + ) { + this._queuePreviewStart() + } + + if (changed.has('name') || changed.has('disabled') || changed.has('value')) { + this._syncFormValue() + } + + if (changed.has('required') || changed.has('disabled') || changed.has('value')) { + this._syncValidity() + } + + if (this._stream) { + const video = this.shadowRoot?.querySelector('video.capture-preview') as HTMLVideoElement | null + if (video && video.srcObject !== this._stream) { + video.srcObject = this._stream + } + } + } + + private _setOpen (open: boolean) { + if (this.open === open) return + this.open = open + this.dispatchEvent(new CustomEvent('open-change', { + detail: { open }, + bubbles: true, + composed: true + })) + } + + private _emitError (error: unknown, message = 'Unable to access the camera') { + this._errorMessage = message + this.dispatchEvent(new CustomEvent('error', { + detail: { error, message }, + bubbles: true, + composed: true + })) + } + + private _syncAssociatedForm (nextForm = this.closest('form') as HTMLFormElement | null) { + if (this._associatedForm === nextForm) return + + if (this._associatedForm) { + this._associatedForm.removeEventListener('formdata', this._handleFormData) + this._associatedForm.removeEventListener('reset', this._handleFormReset) + } + + this._associatedForm = nextForm + + if (this._associatedForm && !this._supportsFormInternals) { + this._associatedForm.addEventListener('formdata', this._handleFormData) + this._associatedForm.addEventListener('reset', this._handleFormReset) + } + } + + private _syncFormValue () { + if (!this._supportsFormInternals) return + const internals = this._internals + if (!internals) return + if (this.disabled || !this.name || !this.value) { + internals.setFormValue(null) + return + } + internals.setFormValue(this.value) + } + + private _syncValidity () { + if (!this._internals || !this._supportsFormInternals || typeof this._internals.setValidity !== 'function') return + if (this.disabled || !this.required || this.value) { + this._internals.setValidity({}) + return + } + this._internals.setValidity({ valueMissing: true }, 'Please capture a photo.') + } + + private _syncPreviewFromValue (file: File | null) { + this._revokePreviewUrl() + if (!file) return + this._stopStream() + this._previewUrl = URL.createObjectURL(file) + } + + private _clearValue (options: { emitEvents: boolean }) { + this.value = null + this._errorMessage = '' + if (options.emitEvents) { + this._dispatchValueEvents() + } + } + + private _dispatchValueEvents () { + const detail = { value: this.value } + this.dispatchEvent(new CustomEvent('input', { + detail, + bubbles: true, + composed: true + })) + this.dispatchEvent(new CustomEvent('change', { + detail, + bubbles: true, + composed: true + })) + } + + private _fileExtensionForMimeType (mimeType: string): string { + switch (mimeType) { + case 'image/jpeg': + return 'jpg' + case 'image/webp': + return 'webp' + case 'image/gif': + return 'gif' + default: + return 'png' + } + } + + private _createFileFromBlob (blob: Blob): File { + const contentType = blob.type || this.captureFormat || DEFAULT_CAPTURE_FORMAT + const extension = this._fileExtensionForMimeType(contentType) + const safePrefix = (this.fileNamePrefix || this.name || 'photo').trim() || 'photo' + return new File([blob], `${safePrefix}-${Date.now()}.${extension}`, { type: contentType }) + } + + private _queuePreviewStart () { + this._startPreview().catch(() => undefined) + } + + private _resolveMediaConstraints (): MediaStreamConstraints { + if (this.mediaConstraints) { + return this.mediaConstraints + } + if (this.constraints) { + try { + return JSON.parse(this.constraints) as MediaStreamConstraints + } catch (error) { + throw new Error(`Invalid constraints JSON: ${(error as Error).message}`) + } + } + + return { + video: this.facingMode + ? { facingMode: { ideal: this.facingMode } } + : true + } + } + + private async _startPreview () { + if (!this.open || this.value || this._startingPreview) return + if (!navigator.mediaDevices?.getUserMedia) { + this._emitError(new Error('navigator.mediaDevices.getUserMedia not available'), 'Camera access is not available in this browser') + return + } + + this._startingPreview = true + this._errorMessage = '' + + try { + const stream = await navigator.mediaDevices.getUserMedia(this._resolveMediaConstraints()) + if (!this.open) { + stream.getTracks().forEach(track => track.stop()) + return + } + this._stream = stream + this.requestUpdate() + await this.updateComplete + const video = this.shadowRoot?.querySelector('video.capture-preview') as HTMLVideoElement | null + if (video) { + video.srcObject = stream + await video.play?.().catch(() => undefined) + } + } catch (error) { + this._emitError(error, (error as Error)?.message || 'Unable to start the camera preview') + } finally { + this._startingPreview = false + } + } + + private _stopStream () { + if (!this._stream) return + this._stream.getTracks().forEach(track => track.stop()) + this._stream = null + const video = this.shadowRoot?.querySelector('video.capture-preview') as HTMLVideoElement | null + if (video) { + video.srcObject = null + } + } + + private _revokePreviewUrl () { + if (this._previewUrl) { + URL.revokeObjectURL(this._previewUrl) + } + this._previewUrl = '' + } + + private async _captureSnapshot () { + const video = this.shadowRoot?.querySelector('video.capture-preview') as HTMLVideoElement | null + if (!video) return + + const width = video.videoWidth || video.clientWidth || 640 + const height = video.videoHeight || video.clientHeight || 480 + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + + const context = canvas.getContext('2d') + if (!context) { + this._emitError(new Error('Canvas 2D context unavailable'), 'Unable to capture a photo in this browser') + return + } + + context.drawImage(video, 0, 0, width, height) + + const blob = await new Promise(resolve => { + canvas.toBlob(resolve, this.captureFormat || DEFAULT_CAPTURE_FORMAT, this.captureQuality) + }) + + if (!blob) { + this._emitError(new Error('Camera snapshot failed'), 'Unable to create an image from the current camera frame') + return + } + + this.value = this._createFileFromBlob(blob) + this._errorMessage = '' + } + + private async _retakePhoto () { + this._clearValue({ emitEvents: true }) + await this._startPreview() + } + + private _confirmPhoto () { + if (!this.value || !this._previewUrl) return + + this._dispatchValueEvents() + + this.dispatchEvent(new CustomEvent('photo-captured', { + detail: { + file: this.value, + blob: this.value, + objectUrl: this._previewUrl, + contentType: this.value.type || this.captureFormat || DEFAULT_CAPTURE_FORMAT + }, + bubbles: true, + composed: true + })) + + if (this.autoCloseOnCapture) { + this._setOpen(false) + } + } + + private _handleCancel () { + this._stopStream() + this._clearValue({ emitEvents: false }) + this._setOpen(false) + this.dispatchEvent(new CustomEvent('cancel', { + bubbles: true, + composed: true + })) + } + + private _openCapture () { + if (this.disabled) return + this._setOpen(true) + } + + private _renderViewport () { + if (this._previewUrl) { + return html`Captured photo preview` + } + + return html`` + } + + private _renderStatus () { + if (this._errorMessage) { + return html`
      ${this._errorMessage}
      ` + } + + if (this._startingPreview) { + return html`
      Opening camera…
      ` + } + + if (!this.value) { + return html`
      Preview the camera and take a photo when ready.
      ` + } + + return html`
      Review the photo before confirming it.
      ` + } + + private _renderActions () { + return html` +
      + ${this.showCancelButton + ? html` + + ` + : nothing} + + ${this.value + ? html` + + + ` + : html` + + `} +
      + ` + } + + private _renderPanel () { + return html` +
      +
      +

      ${this.heading}

      + ${this.showCancelButton + ? html` + + ` + : nothing} +
      +
      ${this._renderViewport()}
      + ${this._renderStatus()} + ${this._renderActions()} +
      + ` + } + + render () { + const trigger = this.showTrigger || this.presentation === 'dialog' + + return html` + ${trigger + ? html` + + ` + : nothing} + + ${this.presentation === 'dialog' + ? html` + + ${this.open ? this._renderPanel() : nothing} + + ` + : html` +
      + ${this.open ? this._renderPanel() : nothing} +
      + `} + ` + } +} diff --git a/src/v2/components/media/photoCapture/README.md b/src/v2/components/media/photoCapture/README.md new file mode 100644 index 00000000..43511652 --- /dev/null +++ b/src/v2/components/media/photoCapture/README.md @@ -0,0 +1,80 @@ +# solid-ui-photo-capture component + +A Lit-based camera capture web component that can render inline on a page or inside a modal dialog. It opens the device camera, lets the user take a photo, review it, retake it, and then exposes the confirmed image both as a form-like `value` and as browser events. + +## Installation + +```bash +npm install solid-ui +``` + +## Usage + +```javascript +import { PhotoCapture } from 'solid-ui/components/media/photo-capture' +``` + +The legacy flat import path `solid-ui/components/photo-capture` still works, but the grouped `media/photo-capture` path is the preferred long-term entrypoint. + +```html + + + +``` + +## API + +### Properties / attributes + +| Property | Attribute | Type | Default | Description | +|---|---|---|---|---| +| `label` | `label` | `string` | `Take Photo` | Trigger button label. Ignored when `show-trigger` is false and `presentation="inline"`. | +| `heading` | `heading` | `string` | `Take a photo` | Panel heading. | +| `presentation` | `presentation` | `'inline' \| 'dialog'` | `'inline'` | Controls whether the capture UI sits in-page or inside a native dialog. | +| `open` | `open` | `boolean` | `true` | Controls whether the capture panel is visible. | +| `name` | `name` | `string` | `''` | Form field name used when the component participates in form submission. | +| `required` | `required` | `boolean` | `false` | Marks the control as required for form validation. | +| `value` | none | `File \| null` | `null` | The current captured file. Settable from JavaScript. | +| `showTrigger` | `show-trigger` | `boolean` | `false` | Shows a trigger button that opens the capture UI. | +| `showCancelButton` | `show-cancel-button` | `boolean` | `true` | Shows the cancel and close controls. | +| `facingMode` | `facing-mode` | `string` | `environment` | Convenience control for camera selection when custom constraints are not provided. | +| `constraints` | `constraints` | `string` | `''` | JSON string for full `MediaStreamConstraints`, for example `{ "video": true }`. | +| `mediaConstraints` | none | `MediaStreamConstraints` | `undefined` | JS-only property for passing constraints directly. Overrides `constraints`. | +| `captureFormat` | `capture-format` | `string` | `image/png` | Output MIME type used for `canvas.toBlob()`. | +| `captureQuality` | `capture-quality` | `number` | `undefined` | Optional quality value for formats that support it, such as JPEG or WebP. | +| `fileNamePrefix` | `file-name-prefix` | `string` | `''` | Prefix used when the component generates a `File` name for the captured image. If omitted, the component falls back to `name` and then `photo`. | +| `autoCloseOnCapture` | `auto-close-on-capture` | `boolean` | `false` | Closes the component after the user confirms a photo. | + +### Events + +| Event | Detail | Description | +|---|---|---| +| `input` | `{ value: File \| null }` | Fired when the component updates its current file value. | +| `change` | `{ value: File \| null }` | Fired when the user confirms or clears the current file value. | +| `photo-captured` | `{ file, blob, objectUrl, contentType }` | Fired when the user confirms the captured photo. | +| `open-change` | `{ open }` | Fired whenever the component opens or closes itself. | +| `cancel` | none | Fired when the user cancels the capture flow. | +| `error` | `{ error, message }` | Fired when camera access or capture fails. | + +### Slots + +| Slot | Description | +|---|---| +| default | Replaces the trigger button label. | +| `heading` | Replaces the panel heading. | + +## Notes + +- Inline mode is the default, so the component can be embedded directly inside a page or form. +- Dialog mode uses the native `` element and is useful when the capture flow should float above the current page. +- The component does not upload the photo itself. Consumers can persist it by reading `value`, listening for `change`, or handling `photo-captured`. +- When form-associated custom elements are supported, the component uses `ElementInternals`. Otherwise it still supports form-style submission via the form's `formdata` event. diff --git a/src/v2/components/media/photoCapture/index.ts b/src/v2/components/media/photoCapture/index.ts new file mode 100644 index 00000000..8c23c459 --- /dev/null +++ b/src/v2/components/media/photoCapture/index.ts @@ -0,0 +1,9 @@ +import { PhotoCapture } from './PhotoCapture' + +export { PhotoCapture } + +const PHOTO_CAPTURE_TAG_NAME = 'solid-ui-photo-capture' + +if (!customElements.get(PHOTO_CAPTURE_TAG_NAME)) { + customElements.define(PHOTO_CAPTURE_TAG_NAME, PhotoCapture) +} diff --git a/webpack.config.mjs b/webpack.config.mjs index c41f5ff8..fb61a25c 100644 --- a/webpack.config.mjs +++ b/webpack.config.mjs @@ -1,5 +1,6 @@ import path from 'path' import TerserPlugin from 'terser-webpack-plugin' +import { componentEntries } from './scripts/component-manifest.mjs' const externalsBase = { fs: 'null', @@ -31,18 +32,7 @@ const common = { 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' - } + ...componentEntries }, output: { path: path.resolve(process.cwd(), 'dist'),