diff --git a/frontend/index.html b/frontend/index.html index 750dc9a..6cecb6a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -12,6 +12,13 @@ href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@300;400&family=Noto+Serif+JP:wght@200;300&display=swap" rel="stylesheet" /> + diff --git a/frontend/src/components/admin/ui/me-auth-guard.ts b/frontend/src/components/admin/ui/me-auth-guard.ts new file mode 100644 index 0000000..c16a548 --- /dev/null +++ b/frontend/src/components/admin/ui/me-auth-guard.ts @@ -0,0 +1,76 @@ +import { consume } from '@lit/context' +import { css, html, LitElement, nothing } from 'lit' +import { customElement } from 'lit/decorators.js' +import { authContext } from '../../../contexts/auth-context.js' +import { RepositoryObserver } from '../../../controllers/RepositoryObserver.js' +import type { IAuthRepository } from '../../../domain/AuthRepository.js' + +/** + * A declarative component that protects its content based on authentication status. + */ +@customElement('me-auth-guard') +export class MeAuthGuard extends LitElement { + @consume({ context: authContext, subscribe: true }) + set authRepo(repo: IAuthRepository) { + if (this._authRepo === repo) return + this._authRepo = repo + this._observer?.disconnect() + if (repo) this._observer = new RepositoryObserver(this, repo) + } + get authRepo() { + return this._authRepo + } + private _authRepo!: IAuthRepository + private _observer?: RepositoryObserver + + connectedCallback() { + super.connectedCallback() + this.checkSession() + } + + private async checkSession() { + if (this.authRepo?.status === 'unknown') { + await this.authRepo.refreshSession() + } + } + + render() { + const status = this.authRepo?.status + + if (status === 'checking' || status === 'unknown') { + return html` +
+

認証状態を確認しています...

+
+ ` + } + + if (status === 'authenticated') { + return html`` + } + + // Guest or failed - render nothing, parent orchestrator will handle redirect + return nothing + } + + static styles = css` + :host { + display: contents; + } + + .guard-status { + min-height: 60dvh; + display: grid; + place-items: center; + color: var(--color-text-secondary); + font-size: 15px; + letter-spacing: var(--tracking-wide); + } + ` +} + +declare global { + interface HTMLElementTagNameMap { + 'me-auth-guard': MeAuthGuard + } +} diff --git a/frontend/src/components/admin/ui/me-select.ts b/frontend/src/components/admin/ui/me-select.ts index a5d1eea..62f9295 100644 --- a/frontend/src/components/admin/ui/me-select.ts +++ b/frontend/src/components/admin/ui/me-select.ts @@ -1,16 +1,15 @@ import { css, html, LitElement } from 'lit' import { customElement, property } from 'lit/decorators.js' -import { classMap } from 'lit/directives/class-map.js' @customElement('me-select') export class MeSelect extends LitElement { static formAssociated = true - @property() label = '' - @property() name = '' - @property() value = '' - @property({ type: Boolean }) disabled = false - @property({ type: Boolean }) required = false + @property({ reflect: true }) label = '' + @property({ reflect: true }) name = '' + @property({ reflect: true }) value = '' + @property({ type: Boolean, reflect: true }) disabled = false + @property({ type: Boolean, reflect: true }) required = false private _internals: ElementInternals private _inputId = `me-input-${Math.random().toString(36).slice(2, 9)}` @@ -20,9 +19,13 @@ export class MeSelect extends LitElement { this._internals = this.attachInternals() } + protected createRenderRoot() { + return this.attachShadow({ mode: 'open', delegatesFocus: true }) + } + formResetCallback() { this.value = '' - this._internals.setFormValue('') + this._syncInternals() } formDisabledCallback(disabled: boolean) { @@ -32,7 +35,7 @@ export class MeSelect extends LitElement { private _onChange(e: Event) { const select = e.target as HTMLSelectElement this.value = select.value - this._internals.setFormValue(select.value) + this._syncInternals() this.dispatchEvent( new CustomEvent('change', { @@ -43,23 +46,38 @@ export class MeSelect extends LitElement { ) } - updated(changedProperties: Map) { + protected updated(changedProperties: Map) { if (changedProperties.has('value')) { - this._internals.setFormValue(this.value ?? '') + this._syncInternals() + } + } + + private _syncInternals() { + this._internals.setFormValue(this.value ?? '') + const select = this.shadowRoot?.querySelector('select') + if (select) { + this._internals.setValidity( + select.validity, + select.validationMessage, + select, + ) + this._internals.ariaInvalid = select.checkValidity() ? 'false' : 'true' + this._internals.ariaRequired = this.required ? 'true' : 'false' } } render() { return html` -
+
${ this.label - ? html`` + ? html`` : null }
tag. - */ protected createRenderRoot() { return this.attachShadow({ mode: 'open', delegatesFocus: true }) } @@ -47,29 +43,18 @@ export class MeTextInput extends LitElement { formResetCallback() { this.value = '' - this._internals.setFormValue('') + this._syncInternals() } formDisabledCallback(disabled: boolean) { this.disabled = disabled } - // --- Validation --- - - checkValidity() { - return this._internals.checkValidity() - } - - reportValidity() { - return this._internals.reportValidity() - } - private _onInput(e: Event) { const input = e.target as HTMLInputElement this.value = input.value this._syncInternals() - // Bubbles the event as a standard 'change' event for parent listeners this.dispatchEvent( new CustomEvent('change', { detail: input.value, @@ -89,20 +74,25 @@ export class MeTextInput extends LitElement { const val = String(this.value ?? '') this._internals.setFormValue(val) - // Simple native validation sync const input = this.shadowRoot?.querySelector('input') if (input) { + // Synchronize native validation to the component's internal state + const isValid = input.checkValidity() this._internals.setValidity( input.validity, input.validationMessage, input, ) + + // Update ARIA attributes via internals + this._internals.ariaInvalid = isValid ? 'false' : 'true' + this._internals.ariaRequired = this.required ? 'true' : 'false' } } render() { return html` -
+
${ this.label ? html`` @@ -161,7 +151,6 @@ export class MeTextInput extends LitElement { box-shadow: 0 0 0 1px var(--admin-accent); } - /* Standard states via CSS classes or attributes */ :host([disabled]) input { background: var(--color-bg-deep); color: var(--color-text-tertiary); diff --git a/frontend/src/components/admin/ui/me-textarea.ts b/frontend/src/components/admin/ui/me-textarea.ts index 5bc9adf..6b027ae 100644 --- a/frontend/src/components/admin/ui/me-textarea.ts +++ b/frontend/src/components/admin/ui/me-textarea.ts @@ -30,21 +30,13 @@ export class MeTextarea extends LitElement { formResetCallback() { this.value = '' - this._internals.setFormValue('') + this._syncInternals() } formDisabledCallback(disabled: boolean) { this.disabled = disabled } - checkValidity() { - return this._internals.checkValidity() - } - - reportValidity() { - return this._internals.reportValidity() - } - private _onInput(e: Event) { const input = e.target as HTMLTextAreaElement this.value = input.value @@ -74,12 +66,14 @@ export class MeTextarea extends LitElement { input.validationMessage, input, ) + this._internals.ariaInvalid = input.checkValidity() ? 'false' : 'true' + this._internals.ariaRequired = this.required ? 'true' : 'false' } } render() { return html` -
+
${ this.label ? html`` diff --git a/frontend/src/components/app-admin-shell.ts b/frontend/src/components/app-admin-shell.ts index e92da03..4998b63 100644 --- a/frontend/src/components/app-admin-shell.ts +++ b/frontend/src/components/app-admin-shell.ts @@ -1,40 +1,25 @@ -import { consume } from '@lit/context' import { css, html, LitElement } from 'lit' import { customElement, property } from 'lit/decorators.js' import { classMap } from 'lit/directives/class-map.js' -import { authContext } from '../contexts/auth-context.js' -import { RepositoryObserver } from '../controllers/RepositoryObserver.js' -import type { IAuthRepository } from '../domain/AuthRepository.js' import type { RouteShellElement } from './route-shell.js' import { playLeaveTransition, routeShellStyles } from './route-shell.js' @customElement('app-admin-shell') export class AppAdminShell extends LitElement implements RouteShellElement { - @consume({ context: authContext, subscribe: true }) - set authRepo(repo: IAuthRepository | undefined) { - if (this._authRepo === repo) return - this._authRepo = repo - if (this._observer) this._observer.disconnect() - if (repo) this._observer = new RepositoryObserver(this, repo) - } - get authRepo() { - return this._authRepo - } - private _authRepo?: IAuthRepository - private _observer?: RepositoryObserver + @property({ type: Boolean }) + authenticated = false @property() currentPath = '/admin' @property({ type: Boolean }) - busy = false + isChecking = false render() { - const authenticated = this.authRepo?.status === 'authenticated' return html` -
+
${ - authenticated + this.authenticated ? html`