From 3cd9fbe9435895722785226aa55dd04dd76f6b16 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 14 Jul 2026 15:27:18 +0200 Subject: [PATCH 1/3] #821 Implement reusable FormControlComponent --- src/components/combobox/Combobox.ts | 45 +++++-------------- src/components/input/Input.ts | 34 ++------------ src/components/login-modal/LoginModal.ts | 2 +- src/components/select/Select.ts | 33 ++------------ .../FormControlComponent.ts | 40 +++++++++++++++++ .../form-control-component/index.ts | 4 ++ src/lib/components/index.ts | 1 + src/lib/components/traits/FormControlTrait.ts | 13 +++--- test/unit/index.test.ts | 1 + 9 files changed, 72 insertions(+), 101 deletions(-) create mode 100644 src/lib/components/form-control-component/FormControlComponent.ts create mode 100644 src/lib/components/form-control-component/index.ts diff --git a/src/components/combobox/Combobox.ts b/src/components/combobox/Combobox.ts index 7c84a5fee..21787ac60 100644 --- a/src/components/combobox/Combobox.ts +++ b/src/components/combobox/Combobox.ts @@ -1,9 +1,8 @@ -import { customElement, WebComponent } from '@/lib/components' +import { customElement, FormControlComponent, FormControlValue } from '@/lib/components' import { Task } from '@lit/task' import { html, nothing, TemplateResult, type PropertyValues } from 'lit' import { property, query, state } from 'lit/decorators.js' import { debounce } from '@/lib/timing' -import FormControlTrait from '@/lib/components/traits/FormControlTrait' import type ComboboxOption from '@/components/combobox-option/ComboboxOption' import '~icons/lucide/chevron-down' @@ -15,7 +14,7 @@ import styles from './Combobox.styles.css' class AsyncOptionsInfo extends Error {} export type ComboboxOptionData = { - value: unknown; + value: FormControlValue; label: string; template?: TemplateResult; selectable?: boolean; @@ -30,24 +29,8 @@ export function defineAsyncComboboxOptionsProvider (this.filter = value)) private asyncOptionsTask?: Task @@ -95,13 +77,6 @@ export default class Combobox extends WebComponent { constructor () { super() - this.controlTrait = this.addTrait( - new FormControlTrait(this, { - getControlElement: () => this.inputElement, - getInternals: () => this.getInternals(), - }) - ) - this.listboxId = `listbox-${this.controlTrait.controlId}` } @@ -180,7 +155,7 @@ export default class Combobox extends WebComponent { .value=${this.displayValue} @keydown=${this.onInputKeyDown} @focus=${this.onInputFocus} - @input=${() => this.selectOnly ? this.updateDisplayValue(this.inputElement?.value ?? '') : this.controlTrait.onInput()} + @input=${() => this.selectOnly ? this.updateDisplayValue(this.controlElement?.value ?? '') : this.controlTrait.onInput()} /> @@ -288,7 +263,7 @@ export default class Combobox extends WebComponent { } private updateDisplayValue (value: unknown) { - this.displayValue = String(value) + this.displayValue = value ? String(value) : '' if (this.open) { const filter = this.displayValue.toLowerCase() @@ -386,7 +361,7 @@ export default class Combobox extends WebComponent { this.hide() this.controlTrait.setValue(option.value) - this.inputElement?.focus({ preventScroll: true }) + this.controlElement?.focus({ preventScroll: true }) this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true, detail: { option } })) if (previousValue === this.value) { @@ -455,12 +430,12 @@ export default class Combobox extends WebComponent { } private onAnchorMouseDown (event: MouseEvent) { - if (event.target === this.inputElement) { + if (event.target === this.controlElement) { return } event.preventDefault() - this.inputElement?.focus({ preventScroll: true }) + this.controlElement?.focus({ preventScroll: true }) } private onInputFocus () { @@ -511,7 +486,7 @@ export default class Combobox extends WebComponent { event.preventDefault() event.stopPropagation() this.hide() - this.inputElement?.focus({ preventScroll: true }) + this.controlElement?.focus({ preventScroll: true }) } break case 'Tab': diff --git a/src/components/input/Input.ts b/src/components/input/Input.ts index 9716e5476..9cfcf0b94 100644 --- a/src/components/input/Input.ts +++ b/src/components/input/Input.ts @@ -1,46 +1,18 @@ -import { customElement, WebComponent } from '@/lib/components' +import { customElement, FormControlComponent } from '@/lib/components' import { html } from 'lit' import { property, query } from 'lit/decorators.js' -import FormControlTrait from '@/lib/components/traits/FormControlTrait' import styles from './Input.styles.css' @customElement('solid-ui-input') -export default class Input extends WebComponent { +export default class Input extends FormControlComponent { static styles = styles - static formAssociated = true - - @property({ type: String, reflect: true }) - accessor label = ''; - - @property({ type: String, reflect: true }) - accessor name = ''; - - @property({ type: String }) - accessor value = ''; @property({ type: String, reflect: true }) accessor type = 'text'; - @property({ type: String, reflect: true }) - accessor placeholder = ''; - - @property({ type: Boolean, reflect: true }) - accessor required = false; - @query('input') - private accessor inputElement: HTMLInputElement | null = null; - - private controlTrait: FormControlTrait - - constructor () { - super() - - this.controlTrait = this.addTrait(new FormControlTrait(this, { - getControlElement: () => this.inputElement, - getInternals: () => this.getInternals(), - })) - } + protected accessor controlElement: HTMLInputElement | null = null; protected render () { return html` diff --git a/src/components/login-modal/LoginModal.ts b/src/components/login-modal/LoginModal.ts index 9d90a2567..4c906e818 100644 --- a/src/components/login-modal/LoginModal.ts +++ b/src/components/login-modal/LoginModal.ts @@ -80,7 +80,7 @@ export default class LoginModal extends WebComponent { } private onIssuerInputChange (e: Event) { - this.issuerInputValue = (e.target as Combobox).value + this.issuerInputValue = String((e.target as Combobox).value) } private async onSubmit (e: Event) { diff --git a/src/components/select/Select.ts b/src/components/select/Select.ts index 4c5d8b6a4..57806b4c8 100644 --- a/src/components/select/Select.ts +++ b/src/components/select/Select.ts @@ -1,8 +1,7 @@ -import { customElement, WebComponent } from '@/lib/components' +import { customElement, FormControlComponent } from '@/lib/components' import { html, nothing } from 'lit' import { property, query, state } from 'lit/decorators.js' import { isEmptyValue } from '@/lib/values' -import FormControlTrait from '@/lib/components/traits/FormControlTrait' import type SelectOption from '@/components/select-option/SelectOption' import '~icons/lucide/chevron-down' @@ -17,21 +16,8 @@ export type SelectOptionData = { export type SelectChangeEvent = CustomEvent<{ option: SelectOptionData }> @customElement('solid-ui-select') -export default class Select extends WebComponent { +export default class Select extends FormControlComponent { static styles = styles - static formAssociated = true - - @property({ type: String, reflect: true }) - accessor label = ''; - - @property({ type: String, reflect: true }) - accessor name = ''; - - @property({ type: String }) - accessor value = ''; - - @property({ type: Boolean, reflect: true }) - accessor required = false; @property({ type: Array }) set options (value: SelectOptionData[] | null) { @@ -54,22 +40,11 @@ export default class Select extends WebComponent { } @query('select') - accessor inputElement: HTMLSelectElement | null = null; + protected accessor controlElement: HTMLSelectElement | null = null; @state() private accessor _options: SelectOptionData[] | null = null - private controlTrait: FormControlTrait - - constructor () { - super() - - this.controlTrait = this.addTrait(new FormControlTrait(this, { - getControlElement: () => this.inputElement, - getInternals: () => this.getInternals(), - })) - } - protected render () { const defaultOption = this.options.some(option => isEmptyValue(option.value)) ? nothing @@ -102,7 +77,7 @@ export default class Select extends WebComponent { } private onChange () { - const value = this.inputElement?.value + const value = this.controlElement?.value ?? null const option = this.options.find((option) => option.value === value) this.controlTrait.setValue(value) diff --git a/src/lib/components/form-control-component/FormControlComponent.ts b/src/lib/components/form-control-component/FormControlComponent.ts new file mode 100644 index 000000000..9a5e0b4da --- /dev/null +++ b/src/lib/components/form-control-component/FormControlComponent.ts @@ -0,0 +1,40 @@ +import { WebComponent } from '@/lib/components/web-component' +import { property } from 'lit/decorators.js' +import FormControlTrait, { FormControlValue } from '@/lib/components/traits/FormControlTrait' + +export default abstract class FormControlComponent extends WebComponent { + static formAssociated = true + + @property({ type: String, reflect: true }) + accessor label = '' + + @property({ type: String, reflect: true }) + accessor name = '' + + @property() + accessor value: T | null = null + + @property({ type: String, reflect: true }) + accessor placeholder = '' + + @property({ type: Boolean, reflect: true }) + accessor required = false + + @property({ type: Boolean, reflect: true }) + accessor disabled = false + + protected controlTrait: FormControlTrait + + protected abstract controlElement: HTMLInputElement | HTMLSelectElement | null + + constructor () { + super() + + this.controlTrait = this.addTrait( + new FormControlTrait(this, { + getControlElement: () => this.controlElement, + getInternals: () => this.getInternals(), + }) + ) + } +} diff --git a/src/lib/components/form-control-component/index.ts b/src/lib/components/form-control-component/index.ts new file mode 100644 index 000000000..fb13ebd8d --- /dev/null +++ b/src/lib/components/form-control-component/index.ts @@ -0,0 +1,4 @@ +import FormControlComponent from './FormControlComponent' + +export { FormControlComponent } +export default FormControlComponent diff --git a/src/lib/components/index.ts b/src/lib/components/index.ts index 2ec105c37..2e8368dce 100644 --- a/src/lib/components/index.ts +++ b/src/lib/components/index.ts @@ -1,5 +1,6 @@ export * from './decorators' export * from './dialog-component' +export * from './form-control-component' export * from './ids' export * from './traits' export * from './web-component' diff --git a/src/lib/components/traits/FormControlTrait.ts b/src/lib/components/traits/FormControlTrait.ts index 0c61694c5..c7dc60883 100644 --- a/src/lib/components/traits/FormControlTrait.ts +++ b/src/lib/components/traits/FormControlTrait.ts @@ -5,11 +5,14 @@ import { generateId } from '@/lib/components' import type { WebComponentTrait } from './WebComponentTrait' +export type FormControlValue = string | File | FormData | null + export type FormControlTraitTarget = WebComponent & { name: string; label: string; required: boolean; - value: unknown; + disabled: boolean; + value: FormControlValue; } export interface FormControlTraitConfig { @@ -31,7 +34,7 @@ export default class FormControlTrait implements WebComponentTrait { } firstUpdated () { - this.config.getInternals().setFormValue(String(this.target.value ?? '')) + this.config.getInternals().setFormValue(this.target.value ?? '') this.updateValidity() } @@ -54,17 +57,17 @@ export default class FormControlTrait implements WebComponentTrait { } onInput () { - this.setValue(this.config.getControlElement()?.value) + this.setValue(this.config.getControlElement()?.value ?? null) } onSubmit () { this.config.getInternals().form?.requestSubmit() } - setValue (value: unknown) { + setValue (value: FormControlValue) { this.target.value = value - this.config.getInternals().setFormValue(String(this.target.value ?? '')) + this.config.getInternals().setFormValue(this.target.value ?? '') this.target.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true })) } diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index 307b4c9f4..c59fe2833 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -15,6 +15,7 @@ describe('Index', () => { 'Dialog', 'DialogComponent', 'DialogTrait', + 'FormControlComponent', 'FormControlTrait', 'NoopAuth', 'ShowDialogEvent', From 42e449f2d8011c0a87e5a23f343a9ac78131c08a Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Thu, 9 Jul 2026 10:47:50 +0200 Subject: [PATCH 2/3] #821 Migrate PhotoCapture component This is a first pass towards modernizing this component, but it can still be improved further to use newer Dialog System. The tests were removed because the new form handling relies on ElementInternals api (it was optional before), but it isn't availble in the test environment. Also removed some inline comments about attribution, leaving them here for documentation purposes: > 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. --- .../photo-capture/PhotoCapture.stories.ts | 20 + .../photo-capture/PhotoCapture.styles.css | 199 +++++ src/components/photo-capture/PhotoCapture.ts | 541 ++++++++++++ src/components/photo-capture/index.ts | 6 +- .../media/photoCapture/PhotoCapture.test.ts | 187 ---- .../media/photoCapture/PhotoCapture.ts | 825 ------------------ .../components/media/photoCapture/README.md | 80 -- src/v2/components/media/photoCapture/index.ts | 9 - 8 files changed, 765 insertions(+), 1102 deletions(-) create mode 100644 src/components/photo-capture/PhotoCapture.stories.ts create mode 100644 src/components/photo-capture/PhotoCapture.styles.css create mode 100644 src/components/photo-capture/PhotoCapture.ts delete mode 100644 src/v2/components/media/photoCapture/PhotoCapture.test.ts delete mode 100644 src/v2/components/media/photoCapture/PhotoCapture.ts delete mode 100644 src/v2/components/media/photoCapture/README.md delete mode 100644 src/v2/components/media/photoCapture/index.ts diff --git a/src/components/photo-capture/PhotoCapture.stories.ts b/src/components/photo-capture/PhotoCapture.stories.ts new file mode 100644 index 000000000..f26048f64 --- /dev/null +++ b/src/components/photo-capture/PhotoCapture.stories.ts @@ -0,0 +1,20 @@ +import { html } from 'lit' + +import './PhotoCapture' + +const meta = { + title: 'PhotoCapture', + render: () => html` + + `, +} as const + +export const Primary = {} + +export default meta diff --git a/src/components/photo-capture/PhotoCapture.styles.css b/src/components/photo-capture/PhotoCapture.styles.css new file mode 100644 index 000000000..7e6d7f389 --- /dev/null +++ b/src/components/photo-capture/PhotoCapture.styles.css @@ -0,0 +1,199 @@ +: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; +} diff --git a/src/components/photo-capture/PhotoCapture.ts b/src/components/photo-capture/PhotoCapture.ts new file mode 100644 index 000000000..2f54dfa9b --- /dev/null +++ b/src/components/photo-capture/PhotoCapture.ts @@ -0,0 +1,541 @@ +import { html, nothing, type PropertyValues } from 'lit' +import { property, state } from 'lit/decorators.js' +import { customElement, FormControlComponent } from '@/lib/components' + +import styles from './PhotoCapture.styles.css' + +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' + +@customElement('solid-ui-photo-capture') +export default class PhotoCapture extends FormControlComponent { + static styles = styles + + @property({ type: String, reflect: true }) + accessor label = 'Take Photo' + + @property({ type: String, reflect: true }) + accessor heading = 'Take a photo' + + @property({ type: String, attribute: 'capture-label', reflect: true }) + accessor captureLabel = 'Take Photo' + + @property({ type: String, attribute: 'confirm-label', reflect: true }) + accessor confirmLabel = 'Use Photo' + + @property({ type: String, attribute: 'retake-label', reflect: true }) + accessor retakeLabel = 'Retake' + + @property({ type: String, attribute: 'cancel-label', reflect: true }) + accessor cancelLabel = 'Cancel' + + @property({ type: String, reflect: true }) + accessor presentation: PresentationMode = 'inline' + + @property({ type: String, reflect: true }) + accessor theme: ThemeMode = 'light' + + @property({ type: String, attribute: 'facing-mode', reflect: true }) + accessor facingMode = 'user' + + @property({ type: String, reflect: true }) + accessor constraints = '' + + @property({ type: String, attribute: 'capture-format', reflect: true }) + accessor captureFormat = DEFAULT_CAPTURE_FORMAT + + @property({ type: Number, attribute: 'capture-quality' }) + accessor captureQuality: number | undefined = undefined + + @property({ type: Boolean, reflect: true }) + accessor open = false + + @property({ type: Boolean, attribute: 'show-trigger', reflect: true }) + accessor showTrigger = false + + @property({ type: Boolean, attribute: 'show-cancel-button', reflect: true }) + accessor showCancelButton = true + + @property({ type: Boolean, attribute: 'auto-close-on-capture' }) + accessor autoCloseOnCapture = false + + @property({ type: String, attribute: 'file-name-prefix', reflect: true }) + accessor fileNamePrefix = '' + + @property({ attribute: false }) + accessor mediaConstraints: MediaStreamConstraints | undefined = undefined + + @state() + accessor errorMessage = '' + + @state() + accessor previewUrl = '' + + @state() + accessor startingPreview = false + + protected controlElement = null + + private _stream: MediaStream | null = null + + private readonly _handleFormReset = () => { + this._clearValue({ emitEvents: false }) + if (this.open) { + this._queuePreviewStart() + } + } + + get form (): HTMLFormElement | null { + return this.internals?.form ?? null + } + + 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() + } + + disconnectedCallback () { + this._stopStream() + this._revokePreviewUrl() + super.disconnectedCallback() + } + + formResetCallback () { + this._handleFormReset() + } + + formDisabledCallback (disabled: boolean) { + this.disabled = disabled + } + + protected willUpdate (changed: PropertyValues) { + super.willUpdate(changed) + + if (changed.has('value')) { + const normalizedValue = this.value instanceof File ? this.value : null + if (normalizedValue !== this.value) { + this.value = normalizedValue + return + } + this._syncPreviewFromValue(normalizedValue) + } + } + + protected updated (changed: PropertyValues) { + 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 (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 _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 + })) + + this.controlTrait.setValue(this.value) + + 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/components/photo-capture/index.ts b/src/components/photo-capture/index.ts index f4e3ce427..0cc401649 100644 --- a/src/components/photo-capture/index.ts +++ b/src/components/photo-capture/index.ts @@ -1 +1,5 @@ -export * from '../../v2/components/media/photoCapture' +import PhotoCapture from './PhotoCapture' + +export * from './PhotoCapture' +export { PhotoCapture } +export default PhotoCapture diff --git a/src/v2/components/media/photoCapture/PhotoCapture.test.ts b/src/v2/components/media/photoCapture/PhotoCapture.test.ts deleted file mode 100644 index a607e6acf..000000000 --- a/src/v2/components/media/photoCapture/PhotoCapture.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PhotoCapture } from './PhotoCapture' -import './index' - -describe('SolidUIPhotoCapture', () => { - const stopTrack = vi.fn() - const getUserMedia: any = vi.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: vi.fn(() => Promise.resolve(undefined)) - }) - - Object.defineProperty(HTMLDialogElement.prototype, 'showModal', { - configurable: true, - value: vi.fn() - }) - - Object.defineProperty(HTMLDialogElement.prototype, 'close', { - configurable: true, - value: vi.fn() - }) - - Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { - configurable: true, - value: vi.fn(() => ({ drawImage: vi.fn() })) - }) - - Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', { - configurable: true, - value: vi.fn((callback: BlobCallback, type?: string) => { - callback(new Blob(['photo'], { type: type || 'image/png' })) - }) - }) - - Object.defineProperty(URL, 'createObjectURL', { - configurable: true, - value: vi.fn(() => 'blob:test-photo') - }) - - Object.defineProperty(URL, 'revokeObjectURL', { - configurable: true, - value: vi.fn() - }) - }) - - it('is defined as a custom element', () => { - expect(customElements.get('solid-ui-photo-capture')).toBe(PhotoCapture) - }) - - it('is closed by default and only starts the inline preview when opened', async () => { - const photoCapture = new PhotoCapture() - - document.body.appendChild(photoCapture) - await photoCapture.updateComplete - - expect(photoCapture.open).toBe(false) - expect(getUserMedia).not.toHaveBeenCalled() - - photoCapture.open = true - await photoCapture.updateComplete - await Promise.resolve() - await photoCapture.updateComplete - - expect(getUserMedia).toHaveBeenCalledWith({ - video: { - facingMode: { ideal: 'user' } - } - }) - }) - - it('accepts dialog presentation and custom constraints JSON', async () => { - const photoCapture = new PhotoCapture() - photoCapture.presentation = 'dialog' - photoCapture.constraints = JSON.stringify({ video: true, audio: false }) - - document.body.appendChild(photoCapture) - await photoCapture.updateComplete - - expect(photoCapture.open).toBe(false) - expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled() - - 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 = vi.fn() - const changed = vi.fn() - photoCapture.open = true - - 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.open = true - 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 deleted file mode 100644 index 87913166c..000000000 --- a/src/v2/components/media/photoCapture/PhotoCapture.ts +++ /dev/null @@ -1,825 +0,0 @@ -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 = 'user' - this.constraints = '' - this.captureFormat = DEFAULT_CAPTURE_FORMAT - this.captureQuality = undefined - this.open = false - 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 deleted file mode 100644 index eb4eeac18..000000000 --- a/src/v2/components/media/photoCapture/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# 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` | `false` | 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 presentation, and the panel starts closed until `open` is set or the trigger button is used. -- 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 deleted file mode 100644 index 8c23c459a..000000000 --- a/src/v2/components/media/photoCapture/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -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) -} From 6c70a7610d32b55f1a22fea6adfe4b9ea485fa91 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Thu, 16 Jul 2026 11:47:24 +0200 Subject: [PATCH 3/3] #821 Migrate PhotoCapture internals to Dialog System This change actually refactors the intended usage of the PhotoCapture component, and gets rid of many features that weren't used or necessary: - Now it only supports the "dialog" mode, this is no longer configurable. - All events have been removed in favour of the native `input` event. This should be fine since the component should be used as a standard FormControl. - Theme configuration has been removed, given that dark mode hasn't been implemented in the Design System yet. Even when it does, it probably should be something that can be changed with a property. - Replaced the standard trigger overriding with a standard trigger slot (also used in LoginButton, LogoutButton, etc.). - Removed some properties that are no longer applicable: `autoCloseOnCapture`, `showTrigger`, `theme`, `presentation`, and `open`. --- .../PhotoCaptureModal.styles.css | 32 ++ .../photo-capture-modal/PhotoCaptureModal.ts | 335 +++++++++++ src/components/photo-capture-modal/index.ts | 4 + .../photo-capture/PhotoCapture.stories.ts | 75 ++- .../photo-capture/PhotoCapture.styles.css | 199 ------- src/components/photo-capture/PhotoCapture.ts | 518 +----------------- .../PhotoCaptureSandbox.styles.css | 5 + .../PhotoCaptureSandbox.ts | 50 ++ .../docs/photo-capture-sandbox/index.ts | 4 + 9 files changed, 521 insertions(+), 701 deletions(-) create mode 100644 src/components/photo-capture-modal/PhotoCaptureModal.styles.css create mode 100644 src/components/photo-capture-modal/PhotoCaptureModal.ts create mode 100644 src/components/photo-capture-modal/index.ts delete mode 100644 src/components/photo-capture/PhotoCapture.styles.css create mode 100644 src/components/photo-capture/docs/photo-capture-sandbox/PhotoCaptureSandbox.styles.css create mode 100644 src/components/photo-capture/docs/photo-capture-sandbox/PhotoCaptureSandbox.ts create mode 100644 src/components/photo-capture/docs/photo-capture-sandbox/index.ts diff --git a/src/components/photo-capture-modal/PhotoCaptureModal.styles.css b/src/components/photo-capture-modal/PhotoCaptureModal.styles.css new file mode 100644 index 000000000..1821ca9b2 --- /dev/null +++ b/src/components/photo-capture-modal/PhotoCaptureModal.styles.css @@ -0,0 +1,32 @@ +.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, #ffffff 92%, #000 8%); +} + +.viewport video, +.viewport img { + display: block; + width: 100%; + max-width: 260px; + 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); +} diff --git a/src/components/photo-capture-modal/PhotoCaptureModal.ts b/src/components/photo-capture-modal/PhotoCaptureModal.ts new file mode 100644 index 000000000..f0a79be5a --- /dev/null +++ b/src/components/photo-capture-modal/PhotoCaptureModal.ts @@ -0,0 +1,335 @@ +import { customElement, DialogComponent } from '@/lib/components' +import { html, PropertyValues, nothing } from 'lit' +import { property, state } from 'lit/decorators.js' + +import '@/components/button' +import '@/components/dialog' +import '@/components/dialog-content' +import '@/components/dialog-footer' + +import styles from './PhotoCaptureModal.styles.css' + +@customElement('solid-ui-photo-capture-modal') +export default class PhotoCaptureModal extends DialogComponent { + static styles = styles + + @property({ type: String, reflect: true }) + accessor name = '' + + @property({ type: String, reflect: true }) + accessor heading = 'Take a photo' + + @property({ type: String, reflect: true }) + accessor captureLabel = 'Take Photo' + + @property({ type: String, reflect: true }) + accessor confirmLabel = 'Use Photo' + + @property({ type: String, reflect: true }) + accessor retakeLabel = 'Retake' + + @property({ type: String, attribute: 'cancel-label', reflect: true }) + accessor cancelLabel = 'Cancel' + + @property({ type: String, attribute: 'file-name-prefix', reflect: true }) + accessor fileNamePrefix = '' + + @property({ attribute: false }) + accessor mediaConstraints: MediaStreamConstraints | undefined = undefined + + @property({ type: String, reflect: true }) + accessor constraints = '' + + @property({ type: String, attribute: 'capture-format', reflect: true }) + accessor captureFormat = 'image/png' + + @property({ type: Number, attribute: 'capture-quality' }) + accessor captureQuality: number | undefined = undefined + + @property({ type: Boolean, attribute: 'show-cancel-button', reflect: true }) + accessor showCancelButton = true + + @property({ type: String, attribute: 'facing-mode', reflect: true }) + accessor facingMode = 'user' + + @state() + accessor errorMessage = '' + + @state() + accessor previewUrl = '' + + @state() + accessor startingPreview = false + + @state() + accessor value: File | null = null + + private stream: MediaStream | null = null + + disconnectedCallback () { + this.stopStream() + this.revokePreviewUrl() + super.disconnectedCallback() + } + + protected willUpdate (changed: PropertyValues) { + super.willUpdate(changed) + + if (changed.has('value')) { + const normalizedValue = this.value instanceof File ? this.value : null + + if (normalizedValue !== this.value) { + this.value = normalizedValue + + return + } + + this.syncPreviewFromValue(normalizedValue) + } + } + + protected updated (changed: PropertyValues) { + if ( + !this.value && + !this.stream && + !this.startingPreview && + (changed.has('previewUrl') || changed.has('value')) + ) { + this.queuePreviewStart() + } + + if (this.stream) { + const video = this.shadowRoot?.querySelector('video') + + if (video && video.srcObject !== this.stream) { + video.srcObject = this.stream + } + } + } + + protected render () { + return html` + + + ${this.renderViewport()} + ${this.renderStatus()} + + + ${this.showCancelButton + ? html` this.close()}>${this.cancelLabel}` + : nothing + } + + ${this.value + ? html` + ${this.retakeLabel} + ${this.confirmLabel} + ` + : html` + + ${this.captureLabel} + + ` + } + + + ` + } + + private renderViewport () { + const contents = this.previewUrl + ? html`Captured photo preview` + : html`` + + return html`
${contents}
` + } + + 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 onRetake () { + this.clearValue() + this.queuePreviewStart() + } + + private onConfirm () { + if (!this.value) { + return + } + + this.close(this.value) + } + + private async onCapture () { + const video = this.shadowRoot?.querySelector('video') + + 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.errorMessage = '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, this.captureQuality) + }) + + if (!blob) { + this.errorMessage = 'Unable to create an image from the current camera frame' + + return + } + + this.value = this.createFileFromBlob(blob) + this.errorMessage = '' + } + + private createFileFromBlob (blob: Blob): File { + const contentType = blob.type || this.captureFormat + 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 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 queuePreviewStart () { + this.startPreview().catch(() => undefined) + } + + private stopStream () { + if (!this.stream) { + return + } + + this.stream.getTracks().forEach(track => track.stop()) + this.stream = null + + const video = this.shadowRoot?.querySelector('video') + + if (video) { + video.srcObject = null + } + } + + private syncPreviewFromValue (file: File | null) { + this.revokePreviewUrl() + + if (!file) { + return + } + + this.stopStream() + this.previewUrl = URL.createObjectURL(file) + } + + private clearValue () { + this.value = null + this.errorMessage = '' + } + + private revokePreviewUrl () { + if (this.previewUrl) { + URL.revokeObjectURL(this.previewUrl) + } + + this.previewUrl = '' + } + + private async startPreview () { + if (this.value || this.startingPreview) { + return + } + + if (!navigator.mediaDevices?.getUserMedia) { + this.errorMessage = 'Camera access is not available in this browser' + + return + } + + this.startingPreview = true + this.errorMessage = '' + + try { + const stream = await navigator.mediaDevices.getUserMedia(this.resolveMediaConstraints()) + + this.stream = stream + this.requestUpdate() + + await this.updateComplete + + const video = this.shadowRoot?.querySelector('video') + + if (video) { + video.srcObject = stream + + await video.play?.().catch(() => undefined) + } + } catch (error) { + this.errorMessage = (error as Error)?.message || 'Unable to start the camera preview' + } finally { + this.startingPreview = false + } + } + + 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 + } + } +} diff --git a/src/components/photo-capture-modal/index.ts b/src/components/photo-capture-modal/index.ts new file mode 100644 index 000000000..7ca6fbb1d --- /dev/null +++ b/src/components/photo-capture-modal/index.ts @@ -0,0 +1,4 @@ +import PhotoCaptureModal from './PhotoCaptureModal' + +export { PhotoCaptureModal } +export default PhotoCaptureModal diff --git a/src/components/photo-capture/PhotoCapture.stories.ts b/src/components/photo-capture/PhotoCapture.stories.ts index f26048f64..137994853 100644 --- a/src/components/photo-capture/PhotoCapture.stories.ts +++ b/src/components/photo-capture/PhotoCapture.stories.ts @@ -1,20 +1,71 @@ import { html } from 'lit' -import './PhotoCapture' +import './docs/photo-capture-sandbox' const meta = { - title: 'PhotoCapture', - render: () => html` - - `, + title: 'Advanced/PhotoCapture', + parameters: { + docs: { + source: { + language: 'ts', + code: ` + import { customElement, WebComponent } from 'solid-ui' + import { html } from 'lit' + import { state, query } from 'lit/decorators.js' + + import PhotoCapture from 'solid-ui/components/photo-capture' + + import styles from './PhotoCaptureSandbox.styles.css' + + @customElement('my-photo-capture-sandbox') + export default class PhotoCaptureSandbox extends WebComponent { + static styles = styles + + @state() + private accessor imageUrl: string | null = null + + @query('solid-ui-photo-capture') + private accessor photoCapture: PhotoCapture | null = null + + protected render () { + const photoCapture = html\` + + \` + + if (this.imageUrl) { + return html\` + \${photoCapture} + +

The photo you captured:

+ Captured photo + \` + } + + return html\` +

Use the following button to capture a photo:

+ + \${photoCapture} + \` + } + + private onInput () { + const file = this.photoCapture?.value ?? null + + this.imageUrl = file ? URL.createObjectURL(file) : null + } + } + ` + } + } + } } as const -export const Primary = {} +export const Primary = { + render: () => html``, +} export default meta diff --git a/src/components/photo-capture/PhotoCapture.styles.css b/src/components/photo-capture/PhotoCapture.styles.css deleted file mode 100644 index 7e6d7f389..000000000 --- a/src/components/photo-capture/PhotoCapture.styles.css +++ /dev/null @@ -1,199 +0,0 @@ -: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; -} diff --git a/src/components/photo-capture/PhotoCapture.ts b/src/components/photo-capture/PhotoCapture.ts index 2f54dfa9b..0a951d668 100644 --- a/src/components/photo-capture/PhotoCapture.ts +++ b/src/components/photo-capture/PhotoCapture.ts @@ -1,38 +1,12 @@ -import { html, nothing, type PropertyValues } from 'lit' -import { property, state } from 'lit/decorators.js' +import { html } from 'lit' +import { property } from 'lit/decorators.js' import { customElement, FormControlComponent } from '@/lib/components' +import { showDialog } from '@/lib/dialogs' -import styles from './PhotoCapture.styles.css' - -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' +import PhotoCaptureModal from '@/components/photo-capture-modal' @customElement('solid-ui-photo-capture') export default class PhotoCapture extends FormControlComponent { - static styles = styles - @property({ type: String, reflect: true }) accessor label = 'Take Photo' @@ -51,12 +25,6 @@ export default class PhotoCapture extends FormControlComponent { @property({ type: String, attribute: 'cancel-label', reflect: true }) accessor cancelLabel = 'Cancel' - @property({ type: String, reflect: true }) - accessor presentation: PresentationMode = 'inline' - - @property({ type: String, reflect: true }) - accessor theme: ThemeMode = 'light' - @property({ type: String, attribute: 'facing-mode', reflect: true }) accessor facingMode = 'user' @@ -64,478 +32,48 @@ export default class PhotoCapture extends FormControlComponent { accessor constraints = '' @property({ type: String, attribute: 'capture-format', reflect: true }) - accessor captureFormat = DEFAULT_CAPTURE_FORMAT + accessor captureFormat = 'image/png' @property({ type: Number, attribute: 'capture-quality' }) accessor captureQuality: number | undefined = undefined - @property({ type: Boolean, reflect: true }) - accessor open = false - - @property({ type: Boolean, attribute: 'show-trigger', reflect: true }) - accessor showTrigger = false - @property({ type: Boolean, attribute: 'show-cancel-button', reflect: true }) accessor showCancelButton = true - @property({ type: Boolean, attribute: 'auto-close-on-capture' }) - accessor autoCloseOnCapture = false - @property({ type: String, attribute: 'file-name-prefix', reflect: true }) accessor fileNamePrefix = '' @property({ attribute: false }) accessor mediaConstraints: MediaStreamConstraints | undefined = undefined - @state() - accessor errorMessage = '' - - @state() - accessor previewUrl = '' - - @state() - accessor startingPreview = false - protected controlElement = null - private _stream: MediaStream | null = null - - private readonly _handleFormReset = () => { - this._clearValue({ emitEvents: false }) - if (this.open) { - this._queuePreviewStart() - } - } - - get form (): HTMLFormElement | null { - return this.internals?.form ?? null - } - - 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() - } - - disconnectedCallback () { - this._stopStream() - this._revokePreviewUrl() - super.disconnectedCallback() - } - - formResetCallback () { - this._handleFormReset() - } - - formDisabledCallback (disabled: boolean) { - this.disabled = disabled - } - - protected willUpdate (changed: PropertyValues) { - super.willUpdate(changed) - - if (changed.has('value')) { - const normalizedValue = this.value instanceof File ? this.value : null - if (normalizedValue !== this.value) { - this.value = normalizedValue - return - } - this._syncPreviewFromValue(normalizedValue) - } - } - - protected updated (changed: PropertyValues) { - 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 (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 _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 - })) - - this.controlTrait.setValue(this.value) - - 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 () { + protected render () { return html` -
-
-

${this.heading}

- ${this.showCancelButton - ? html` - - ` - : nothing} -
-
${this._renderViewport()}
- ${this._renderStatus()} - ${this._renderActions()} -
+ + ${this.label} + ` } - 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} -
- `} - ` + private onClick () { + showDialog(PhotoCaptureModal, { + props: { + name: this.name, + heading: this.heading, + captureLabel: this.captureLabel, + confirmLabel: this.confirmLabel, + retakeLabel: this.retakeLabel, + cancelLabel: this.cancelLabel, + fileNamePrefix: this.fileNamePrefix, + mediaConstraints: this.mediaConstraints, + constraints: this.constraints, + captureFormat: this.captureFormat, + captureQuality: this.captureQuality, + showCancelButton: this.showCancelButton, + facingMode: this.facingMode, + }, + onClose: value => this.controlTrait.setValue(value ?? null) + }) } } diff --git a/src/components/photo-capture/docs/photo-capture-sandbox/PhotoCaptureSandbox.styles.css b/src/components/photo-capture/docs/photo-capture-sandbox/PhotoCaptureSandbox.styles.css new file mode 100644 index 000000000..ce8b96eac --- /dev/null +++ b/src/components/photo-capture/docs/photo-capture-sandbox/PhotoCaptureSandbox.styles.css @@ -0,0 +1,5 @@ +:host { + display: flex; + flex-direction: column; + gap: 6px; +} diff --git a/src/components/photo-capture/docs/photo-capture-sandbox/PhotoCaptureSandbox.ts b/src/components/photo-capture/docs/photo-capture-sandbox/PhotoCaptureSandbox.ts new file mode 100644 index 000000000..f4118cd61 --- /dev/null +++ b/src/components/photo-capture/docs/photo-capture-sandbox/PhotoCaptureSandbox.ts @@ -0,0 +1,50 @@ +import { customElement, WebComponent } from '@/lib/components' +import { html } from 'lit' +import { state, query } from 'lit/decorators.js' +import type PhotoCapture from '@/components/photo-capture' + +import '@/components/photo-capture' + +import styles from './PhotoCaptureSandbox.styles.css' + +@customElement('solid-ui-photo-capture-sandbox') +export default class PhotoCaptureSandbox extends WebComponent { + static styles = styles + + @state() + private accessor imageUrl: string | null = null + + @query('solid-ui-photo-capture') + private accessor photoCapture: PhotoCapture | null = null + + protected render () { + const photoCapture = html` + + ` + + if (this.imageUrl) { + return html` + ${photoCapture} + +

The photo you captured:

+ Captured photo + ` + } + + return html` +

Use the following button to capture a photo:

+ + ${photoCapture} + ` + } + + private onInput () { + const file = this.photoCapture?.value ?? null + + this.imageUrl = file ? URL.createObjectURL(file) : null + } +} diff --git a/src/components/photo-capture/docs/photo-capture-sandbox/index.ts b/src/components/photo-capture/docs/photo-capture-sandbox/index.ts new file mode 100644 index 000000000..4b12479b2 --- /dev/null +++ b/src/components/photo-capture/docs/photo-capture-sandbox/index.ts @@ -0,0 +1,4 @@ +import PhotoCaptureSandbox from './PhotoCaptureSandbox' + +export { PhotoCaptureSandbox } +export default PhotoCaptureSandbox