chore: initialize from frontend template 4dc033c

This commit is contained in:
DongHyeonka
2026-08-13 18:23:26 +09:00
commit 40107eec84
897 changed files with 234824 additions and 0 deletions
@@ -0,0 +1,844 @@
import {
useCallback,
useEffect,
useMemo,
useState,
useSyncExternalStore,
} from "react";
import {
hashKey,
type QueryClient,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
deriveAsyncState,
type AsyncState,
} from "../../../application/view-models/async-state.ts";
import type { Result } from "../../../application/result.ts";
import {
createFailure,
normalizeUnknownFailure,
withFailureEffect,
type AppFailure,
} from "../../../contracts/errors.ts";
import type {
QueryInvalidationCoordinator,
QueryInvalidationTopic,
} from "../../../contracts/query-invalidation.ts";
import type { MutationIntent } from "../../../contracts/mutation-intent.ts";
import type { CacheScopeSnapshot } from "../../../contracts/server-state-scope.ts";
import {
admitQueryResult,
type BoundMutation,
type BoundQuery,
MUTATION_COORDINATOR_BOUNDS,
type MutationDuplicatePolicy,
} from "../../../contracts/server-state.ts";
import { runtimeIdentityToken } from "../../../contracts/query-keys.ts";
import { useQueryInvalidationCoordinator } from "./query-invalidation-provider.tsx";
import { useMutationIntentFactory } from "./mutation-intent-provider.tsx";
import {
createOptimisticLayerRuntime,
type OptimisticLayerLease,
} from "./optimistic-layer-runtime.ts";
export type ApplicationResult<Value> = Result<Value>;
class ApplicationQueryError extends Error {
readonly failure: AppFailure;
constructor(failure: AppFailure) {
super(failure.kind);
this.name = "ApplicationQueryError";
this.failure = failure;
}
}
export function useApplicationQuery<Value>(
options:
| BoundQuery<Value>
| Readonly<{
queryKey: readonly unknown[];
execute(context: Readonly<{ signal: AbortSignal }>): Promise<
ApplicationResult<Value>
>;
enabled?: boolean;
}>,
): Readonly<{
data: Value | undefined;
state: AsyncState;
retry(): Promise<void>;
}> {
const { queryKey, execute } = options;
const enabled = "enabled" in options ? options.enabled ?? true : true;
const profile = "profile" in options ? options.profile : undefined;
const scope = "scope" in options ? options.scope : undefined;
const identity = "identity" in options ? options.identity : undefined;
const measureResult =
"measureResult" in options ? options.measureResult : undefined;
const queryDefinitionId =
"definitionId" in options ? options.definitionId : "APPLICATION_QUERY";
const [staleFailure, setStaleFailure] = useState(false);
useEffect(() => {
identity?.acquire();
return () => identity?.release();
}, [identity]);
const query = useQuery<Value, ApplicationQueryError>({
queryKey,
enabled,
retry: false,
staleTime: profile?.staleTimeMs,
gcTime: profile?.gcTimeMs,
refetchOnMount: profile?.refetchOnMount,
refetchOnWindowFocus: profile?.refetchOnFocus,
refetchOnReconnect: profile?.refetchOnReconnect,
queryFn: async ({ signal }) => {
identity?.acquire();
try {
if (scope && !scope.isCurrent()) {
throw new ApplicationQueryError(
createFailure(
"SCOPE_GENERATION_CHANGED",
queryDefinitionId,
0,
{ code: "QUERY_SCOPE_STALE" },
),
);
}
const result = await execute({ signal });
if (scope && !scope.isCurrent()) {
throw new ApplicationQueryError(
createFailure(
"SCOPE_GENERATION_CHANGED",
queryDefinitionId,
0,
{ code: "QUERY_SCOPE_CHANGED" },
),
);
}
if (result.ok) {
if (profile && measureResult) {
const admission = admitQueryResult(
measureResult,
result.value,
profile,
);
if (!admission.ok) {
throw new ApplicationQueryError(
createFailure(
"RESULT_LIMIT_EXCEEDED",
queryDefinitionId,
0,
{ code: admission.code },
),
);
}
}
return result.value;
}
if (signal.aborted) {
throw new DOMException("Query cancelled", "AbortError");
}
throw new ApplicationQueryError(result.error);
} catch (error) {
if (signal.aborted) {
throw new DOMException("Query cancelled", "AbortError");
}
if (error instanceof ApplicationQueryError) throw error;
throw new ApplicationQueryError(
normalizeUnknownFailure(error, {
operationId: "APPLICATION_QUERY",
}),
);
} finally {
identity?.release();
}
},
});
const hasData = query.data !== undefined && query.data !== null;
useEffect(() => {
if (query.isError && hasData) {
setStaleFailure(true);
} else if (query.isSuccess && !query.isFetching) {
setStaleFailure(false);
}
}, [hasData, query.isError, query.isFetching, query.isSuccess]);
const retry = useCallback(async () => {
setStaleFailure(false);
await query.refetch();
}, [query]);
return Object.freeze({
data: query.data,
state: deriveAsyncState({
data: query.data,
isInitialLoading: query.isPending,
failure:
!hasData && query.error instanceof ApplicationQueryError
? query.error.failure
: undefined,
isFetching: query.isFetching && !query.isPending,
isStale: staleFailure,
isDegraded: staleFailure,
}),
retry,
});
}
type LegacyMutationBase<Input, Value> = Readonly<{
execute(input: Input): Promise<ApplicationResult<Value>>;
duplicatePolicy?: MutationDuplicatePolicy;
invalidate?: readonly QueryInvalidationTopic[];
currentData?: unknown;
}>;
type LegacyMutationOptions<Input, Value> = LegacyMutationBase<Input, Value> &
(
| Readonly<{
/** Stable logical identity required to retain an unknown effect across remounts. */
definitionId: string;
optimistic?: never;
}>
| Readonly<{
definitionId?: string;
optimistic: Readonly<{
queryKey: readonly unknown[];
update(previous: unknown, input: Input): unknown;
}>;
}>
);
type ApplicationMutationController<Input, Value> = Readonly<{
state: AsyncState;
submit(input: Input): Promise<ApplicationResult<Value>>;
resolveConflict(): Promise<void>;
reconcileUnknownEffect(
resolution: "APPLIED" | "NOT_APPLIED",
): Promise<void>;
}>;
type MutationExecution<Input> =
| Readonly<{ kind: "BOUND"; input: Input; intent: MutationIntent }>
| Readonly<{ kind: "LEGACY"; input: Input }>;
type MutationAdmission = {
sequence: number;
state: "ACTIVE" | "UNKNOWN" | "RECONCILING" | "SETTLED";
intent: MutationIntent | null;
scope: CacheScopeSnapshot | null;
optimisticLayer: OptimisticLayerLease | null;
optimisticQueryKey: readonly unknown[] | null;
readonly invalidate: readonly QueryInvalidationTopic[];
invalidationCoordinator: QueryInvalidationCoordinator | null;
};
type UnknownEffectChannel = {
readonly key: string;
readonly registry: UnknownEffectRegistry;
readonly owner: QueryClient;
readonly scope: CacheScopeSnapshot | null;
nextSequence: number;
admissions: MutationAdmission[];
version: number;
reconciliationInFlight: boolean;
readonly listeners: Set<() => void>;
disposeScopeListener: (() => void) | null;
};
type UnknownEffectRegistry = {
readonly channels: Map<string, UnknownEffectChannel>;
activeAdmissions: number;
};
const LEGACY_OPTIMISTIC_SCOPE = Object.freeze({
isCurrent: () => true,
});
export function useApplicationMutation<Input, Value>(
options: BoundMutation<Input, Value>,
): ApplicationMutationController<Input, Value>;
export function useApplicationMutation<Input, Value>(
options: LegacyMutationOptions<Input, Value>,
): ApplicationMutationController<Input, Value>;
export function useApplicationMutation<Input, Value>(
options: BoundMutation<Input, Value> | LegacyMutationOptions<Input, Value>,
): ApplicationMutationController<Input, Value> {
const queryClient = useQueryClient();
const invalidationCoordinator = useQueryInvalidationCoordinator();
const mutationIntentFactory = useMutationIntentFactory();
const invalidate = useMemo(
() => options.invalidate ?? [],
[options.invalidate],
);
const optimistic = "optimistic" in options ? options.optimistic : undefined;
const currentData = "currentData" in options ? options.currentData : undefined;
const definitionId =
"definitionId" in options
? options.definitionId ?? "LEGACY_MUTATION"
: "LEGACY_MUTATION";
const definitionVersion =
"definitionVersion" in options ? options.definitionVersion : 0;
const duplicatePolicy =
"duplicatePolicy" in options ? options.duplicatePolicy : "REJECT_WHILE_ACTIVE";
const [conflict, setConflict] = useState<AppFailure | null>(null);
const scope = "scope" in options ? options.scope : undefined;
const mutationOperationId =
"operationId" in options ? options.operationId : definitionId;
const requiresIdempotencyKey =
"requiresIdempotencyKey" in options
? options.requiresIdempotencyKey
: false;
const unknownEffectChannelKey = scope
? `bound:${scope.generation}:${scope.fingerprint}:${definitionId}:${definitionVersion}`
: `legacy:${definitionId}:${
optimistic
? hashKey(optimistic.queryKey)
: "non-optimistic"
}`;
const [acquiredUnknownEffectChannel, setAcquiredUnknownEffectChannel] =
useState<UnknownEffectChannel | null>(null);
const unknownEffectChannel =
acquiredUnknownEffectChannel?.owner === queryClient &&
acquiredUnknownEffectChannel.key === unknownEffectChannelKey &&
acquiredUnknownEffectChannel.scope === (scope ?? null)
? acquiredUnknownEffectChannel
: null;
useEffect(() => {
const acquired = acquireUnknownEffectChannel(
queryClient,
unknownEffectChannelKey,
scope,
);
setAcquiredUnknownEffectChannel(acquired);
return () => {
if (acquired) {
releaseUnknownEffectChannelIfUnused(queryClient, acquired);
}
};
}, [queryClient, scope, unknownEffectChannelKey]);
const subscribeToUnknownEffects = useCallback(
(listener: () => void) => {
if (!unknownEffectChannel) return () => {};
unknownEffectChannel.listeners.add(listener);
return () => {
unknownEffectChannel.listeners.delete(listener);
releaseUnknownEffectChannelIfUnused(
queryClient,
unknownEffectChannel,
);
};
},
[queryClient, unknownEffectChannel],
);
useSyncExternalStore(
subscribeToUnknownEffects,
() => unknownEffectChannel?.version ?? 0,
() => unknownEffectChannel?.version ?? 0,
);
const mutation = useMutation<
Value,
ApplicationQueryError,
MutationExecution<Input>
>({
retry: false,
mutationFn: async (execution) => {
const input = execution.input;
if (scope && !scope.isCurrent()) {
throw new ApplicationQueryError(
createFailure(
"SCOPE_GENERATION_CHANGED",
definitionId,
0,
{ code: "MUTATION_SCOPE_STALE", effect: "NOT_STARTED" },
),
);
}
const result =
"scope" in options
? await options.execute(input, {
signal: options.scope.signal,
intent:
execution.kind === "BOUND"
? execution.intent
: (() => {
throw new TypeError(
"Bound mutation execution requires an intent.",
);
})(),
})
: await options.execute(input);
if (scope && !scope.isCurrent()) {
const effect =
!result.ok && result.error.effect !== undefined
? result.error.effect
: "MAYBE_APPLIED";
throw new ApplicationQueryError(
createFailure(
"SCOPE_GENERATION_CHANGED",
definitionId,
0,
{ code: "MUTATION_SCOPE_CHANGED", effect },
),
);
}
if (result.ok) return result.value;
throw new ApplicationQueryError(result.error);
},
});
const submit = useCallback(
(input: Input): Promise<ApplicationResult<Value>> => {
let identity: string;
let identityLease: ReturnType<
NonNullable<typeof scope>["identities"]["intern"]
> | null = null;
try {
if (scope) {
if (!scope.isCurrent()) {
return Promise.resolve({
ok: false,
error: createFailure(
"SCOPE_GENERATION_CHANGED",
definitionId,
0,
{ code: "MUTATION_SCOPE_STALE", effect: "NOT_STARTED" },
),
});
}
identityLease = scope.identities.intern(input);
identityLease.acquire();
identity = `${scope.fingerprint}:${definitionId}:${identityLease.token}`;
} else {
identity = `${definitionId}:${runtimeIdentityToken(input)}`;
}
} catch (error) {
return Promise.resolve({
ok: false,
error: withFailureEffect(
normalizeUnknownFailure(error, {
operationId: definitionId,
}),
"NOT_STARTED",
),
});
}
const active = mutationExecutions(queryClient).get(identity) as
| Promise<ApplicationResult<Value>>
| undefined;
if (active && duplicatePolicy === "JOIN_IDENTICAL") {
identityLease?.release();
return active;
}
if (active && duplicatePolicy === "REJECT_WHILE_ACTIVE") {
identityLease?.release();
return Promise.resolve({
ok: false,
error: createFailure(
"DUPLICATE_IN_FLIGHT",
definitionId,
0,
{ code: "DUPLICATE_IN_FLIGHT", effect: "NOT_STARTED" },
),
});
}
if (
!unknownEffectChannel ||
unknownEffectChannel.registry.activeAdmissions >=
MUTATION_COORDINATOR_BOUNDS.activeIntentsTotal
) {
identityLease?.release();
return Promise.resolve({
ok: false,
error: createFailure(
"IDENTITY_INTERN_LIMIT_EXCEEDED",
definitionId,
0,
{
code: "UNKNOWN_EFFECT_CHANNEL_LIMIT_EXCEEDED",
effect: "NOT_STARTED",
},
),
});
}
const admission: MutationAdmission = {
sequence: unknownEffectChannel.nextSequence++,
state: "ACTIVE",
intent: null,
scope: scope ?? null,
optimisticLayer: null,
optimisticQueryKey: optimistic?.queryKey ?? null,
invalidate,
invalidationCoordinator: invalidationCoordinator ?? null,
};
unknownEffectChannel.admissions.push(admission);
unknownEffectChannel.registry.activeAdmissions += 1;
notifyUnknownEffectChannel(unknownEffectChannel);
setConflict(null);
mutation.reset();
const pending = (async (): Promise<ApplicationResult<Value>> => {
let execution: MutationExecution<Input>;
if (scope) {
const intent = mutationIntentFactory.create({
operationId: mutationOperationId,
canonicalInputIdentity: identity,
requiresIdempotencyKey,
});
admission.intent = intent;
execution = Object.freeze({ kind: "BOUND", input, intent });
} else {
execution = Object.freeze({ kind: "LEGACY", input });
}
const mutationLease =
invalidate.length === 0
? null
: invalidationCoordinator?.beginMutation(invalidate);
if (invalidate.length > 0 && !mutationLease) {
throw new Error("Query invalidation coordinator is not installed.");
}
try {
let optimisticLayer: OptimisticLayerLease | null = null;
if (optimistic) {
await queryClient.cancelQueries({
queryKey: optimistic.queryKey,
exact: true,
});
optimisticLayer = optimisticLayers(queryClient).begin(
optimistic.queryKey,
input,
optimistic.update,
scope ?? LEGACY_OPTIMISTIC_SCOPE,
);
admission.optimisticLayer = optimisticLayer;
}
let value: Value;
try {
value = await mutation.mutateAsync(execution);
} catch (error: unknown) {
const reportedFailure =
error instanceof ApplicationQueryError
? error.failure
: normalizeUnknownFailure(error, {
operationId: "APPLICATION_MUTATION",
});
const failure = mutationFailureWithEffect(reportedFailure);
switch (failure.effect) {
case "NOT_STARTED":
case "NOT_APPLIED":
optimisticLayer?.rollback();
if (failure.kind === "CONFLICT") setConflict(failure);
settleMutationAdmission(unknownEffectChannel, admission);
break;
case "APPLIED_CONFIRMED":
optimisticLayer?.commit();
if (!scope || scope.isCurrent()) {
try {
await invalidationCoordinator?.invalidate(invalidate);
} catch {
// A confirmed command remains committed if refresh fails.
}
}
settleMutationAdmission(unknownEffectChannel, admission);
break;
case "MAYBE_APPLIED":
optimisticLayer?.markUncertain();
if (!scope || scope.isCurrent()) {
admission.state = "UNKNOWN";
notifyUnknownEffectChannel(unknownEffectChannel);
} else {
settleMutationAdmission(unknownEffectChannel, admission);
}
break;
}
return { ok: false, error: failure };
}
optimisticLayer?.commit();
try {
await invalidationCoordinator?.invalidate(invalidate);
} catch {
// Cache refresh remains best effort after the server has committed.
}
settleMutationAdmission(unknownEffectChannel, admission);
return { ok: true, value };
} finally {
try {
await mutationLease?.release();
} catch {
// A cache coordination defect cannot change the committed command.
}
}
})()
.catch((error: unknown) => {
const failure = withFailureEffect(
normalizeUnknownFailure(error, {
operationId: "APPLICATION_MUTATION",
}),
"NOT_STARTED",
);
settleMutationAdmission(unknownEffectChannel, admission);
if (failure.kind === "CONFLICT") setConflict(failure);
return { ok: false as const, error: failure };
})
.finally(() => {
identityLease?.release();
if (mutationExecutions(queryClient).get(identity) === pending) {
mutationExecutions(queryClient).delete(identity);
}
});
if (duplicatePolicy !== "ALLOW_PARALLEL") {
mutationExecutions(queryClient).set(identity, pending);
}
return pending;
},
[
invalidate,
invalidationCoordinator,
mutation,
optimistic,
queryClient,
definitionId,
duplicatePolicy,
mutationIntentFactory,
mutationOperationId,
requiresIdempotencyKey,
scope,
unknownEffectChannel,
],
);
const resolveConflict = useCallback(async () => {
setConflict(null);
mutation.reset();
if (invalidate.length > 0 && !invalidationCoordinator) {
throw new Error("Query invalidation coordinator is not installed.");
}
await invalidationCoordinator?.invalidate(invalidate);
}, [invalidate, invalidationCoordinator, mutation]);
const reconcileUnknownEffect = useCallback(
async (resolution: "APPLIED" | "NOT_APPLIED") => {
if (
!unknownEffectChannel ||
unknownEffectChannel.reconciliationInFlight
) {
return;
}
const pending = unknownEffectChannel.admissions[0];
if (!pending || pending.state !== "UNKNOWN") return;
if (pending.scope && !pending.intent) return;
unknownEffectChannel.reconciliationInFlight = true;
pending.state = "RECONCILING";
notifyUnknownEffectChannel(unknownEffectChannel);
try {
pending.optimisticLayer?.reconcile(resolution);
if (
resolution === "APPLIED" &&
(!pending.scope || pending.scope.isCurrent())
) {
try {
await pending.invalidationCoordinator?.invalidate(
pending.invalidate,
);
} catch {
// Explicit reconciliation remains settled if refresh fails.
}
}
} finally {
if (pending.scope && !pending.scope.isCurrent()) {
removeAdmissionOptimisticQuery(queryClient, pending);
}
settleMutationAdmission(unknownEffectChannel, pending);
if (
!unknownEffectChannel.admissions.some(
(admission) => admission.state === "ACTIVE",
)
) {
mutation.reset();
}
// Keep the head intent locked through the current event turn so two
// clicks cannot consume two FIFO records when reconciliation itself
// has no asynchronous invalidation work.
await Promise.resolve();
unknownEffectChannel.reconciliationInFlight = false;
notifyUnknownEffectChannel(unknownEffectChannel);
}
},
[mutation, queryClient, unknownEffectChannel],
);
const unknownEffectHead = unknownEffectChannel?.admissions[0];
return Object.freeze({
state: deriveAsyncState({
data: currentData ?? true,
isMutationPending:
mutation.isPending ||
unknownEffectHead?.state === "ACTIVE" ||
unknownEffectHead?.state === "RECONCILING",
hasMutationEffectUnknown: unknownEffectHead?.state === "UNKNOWN",
hasMutationConflict: conflict !== null,
}),
submit,
resolveConflict,
reconcileUnknownEffect,
});
}
function mutationFailureWithEffect(failure: AppFailure): AppFailure {
switch (failure.effect) {
case "NOT_STARTED":
case "NOT_APPLIED":
case "APPLIED_CONFIRMED":
case "MAYBE_APPLIED":
return withFailureEffect(failure, failure.effect);
case "NOT_APPLICABLE":
case undefined:
return withFailureEffect(failure, "MAYBE_APPLIED");
}
}
const UNKNOWN_EFFECT_CHANNELS = new WeakMap<
object,
UnknownEffectRegistry
>();
function acquireUnknownEffectChannel(
queryClient: QueryClient,
key: string,
scope: CacheScopeSnapshot | undefined,
): UnknownEffectChannel | null {
let registry = UNKNOWN_EFFECT_CHANNELS.get(queryClient);
if (!registry) {
registry = { channels: new Map(), activeAdmissions: 0 };
UNKNOWN_EFFECT_CHANNELS.set(queryClient, registry);
}
const { channels } = registry;
const existing = channels.get(key);
if (existing) return existing.scope === (scope ?? null) ? existing : null;
if (
channels.size >= MUTATION_COORDINATOR_BOUNDS.activeDefinitionsPerRuntime
) {
return null;
}
const created: UnknownEffectChannel = {
key,
registry,
owner: queryClient,
scope: scope ?? null,
nextSequence: 1,
admissions: [],
version: 0,
reconciliationInFlight: false,
listeners: new Set(),
disposeScopeListener: null,
};
channels.set(key, created);
if (scope) {
const discardScope = () => {
for (const admission of created.admissions) {
removeAdmissionOptimisticQuery(queryClient, admission);
markMutationAdmissionSettled(created, admission);
}
created.admissions = [];
notifyUnknownEffectChannel(created);
releaseUnknownEffectChannelIfUnused(queryClient, created);
};
scope.signal.addEventListener("abort", discardScope, { once: true });
created.disposeScopeListener = () =>
scope.signal.removeEventListener("abort", discardScope);
if (scope.signal.aborted || !scope.isCurrent()) discardScope();
}
return created;
}
function releaseUnknownEffectChannelIfUnused(
queryClient: QueryClient,
channel: UnknownEffectChannel,
): void {
if (channel.admissions.length > 0 || channel.listeners.size > 0) return;
const registry = UNKNOWN_EFFECT_CHANNELS.get(queryClient);
const channels = registry?.channels;
if (channels?.get(channel.key) !== channel) return;
channel.disposeScopeListener?.();
channels.delete(channel.key);
if (channels.size === 0 && registry?.activeAdmissions === 0) {
UNKNOWN_EFFECT_CHANNELS.delete(queryClient);
}
}
function notifyUnknownEffectChannel(channel: UnknownEffectChannel): void {
channel.version += 1;
for (const listener of [...channel.listeners]) listener();
}
function settleMutationAdmission(
channel: UnknownEffectChannel,
admission: MutationAdmission,
): void {
markMutationAdmissionSettled(channel, admission);
while (channel.admissions[0]?.state === "SETTLED") {
channel.admissions.shift();
}
notifyUnknownEffectChannel(channel);
releaseUnknownEffectChannelIfUnused(channel.owner, channel);
}
function markMutationAdmissionSettled(
channel: UnknownEffectChannel,
admission: MutationAdmission,
): void {
if (admission.state === "SETTLED") return;
admission.state = "SETTLED";
channel.registry.activeAdmissions = Math.max(
0,
channel.registry.activeAdmissions - 1,
);
}
function removeAdmissionOptimisticQuery(
queryClient: QueryClient,
admission: MutationAdmission,
): void {
if (!admission.optimisticQueryKey) return;
queryClient.removeQueries({
queryKey: admission.optimisticQueryKey,
exact: true,
});
}
const RUNTIME_MUTATION_EXECUTIONS = new WeakMap<
object,
Map<string, Promise<ApplicationResult<unknown>>>
>();
function mutationExecutions(
owner: object,
): Map<string, Promise<ApplicationResult<unknown>>> {
const existing = RUNTIME_MUTATION_EXECUTIONS.get(owner);
if (existing) return existing;
const created = new Map<string, Promise<ApplicationResult<unknown>>>();
RUNTIME_MUTATION_EXECUTIONS.set(owner, created);
return created;
}
const OPTIMISTIC_LAYER_RUNTIMES = new WeakMap<
object,
ReturnType<typeof createOptimisticLayerRuntime>
>();
function optimisticLayers(
queryClient: Parameters<typeof createOptimisticLayerRuntime>[0],
): ReturnType<typeof createOptimisticLayerRuntime> {
const existing = OPTIMISTIC_LAYER_RUNTIMES.get(queryClient);
if (existing) return existing;
const created = createOptimisticLayerRuntime(queryClient);
OPTIMISTIC_LAYER_RUNTIMES.set(queryClient, created);
return created;
}
+9
View File
@@ -0,0 +1,9 @@
export {
useApplicationMutation,
useApplicationQuery,
type ApplicationResult,
} from "./application-query.ts";
export {
QueryInvalidationProvider,
useQueryInvalidationCoordinator,
} from "./query-invalidation-provider.tsx";
@@ -0,0 +1,35 @@
import {
createContext,
type ReactNode,
useContext,
} from "react";
import type { MutationIntentFactory } from "../../../application/ports/mutation-intent-factory.ts";
const unavailableMutationIntentFactory: MutationIntentFactory = Object.freeze({
create() {
throw new TypeError("Mutation intent factory is not installed.");
},
});
const MutationIntentFactoryContext = createContext<MutationIntentFactory>(
unavailableMutationIntentFactory,
);
export function MutationIntentProvider({
factory,
children,
}: Readonly<{
factory: MutationIntentFactory;
children: ReactNode;
}>) {
return (
<MutationIntentFactoryContext.Provider value={factory}>
{children}
</MutationIntentFactoryContext.Provider>
);
}
export function useMutationIntentFactory(): MutationIntentFactory {
return useContext(MutationIntentFactoryContext);
}
@@ -0,0 +1,246 @@
import { hashKey, type QueryClient } from "@tanstack/react-query";
import type { CacheScopeSnapshot } from "../../../contracts/server-state-scope.ts";
import { OPTIMISTIC_LAYER_BOUNDS } from "../../../contracts/server-state.ts";
export type OptimisticLayerLease = Readonly<{
commit(): void;
rollback(): void;
markUncertain(): void;
reconcile(resolution: "APPLIED" | "NOT_APPLIED"): void;
}>;
type Layer = {
id: number;
status: "pending" | "uncertain" | "committed";
apply(value: unknown): unknown;
};
type OptimisticLayerScope = Pick<CacheScopeSnapshot, "isCurrent"> &
Partial<Pick<CacheScopeSnapshot, "signal">>;
type EntryState = {
queryKey: readonly unknown[];
scope: OptimisticLayerScope;
base: unknown;
layers: Layer[];
disposeScopeListener: (() => void) | null;
};
export function createOptimisticLayerRuntime(queryClient: QueryClient) {
const entries = new Map<string, EntryState>();
let nextId = 1;
let writing = false;
function removeEntry(key: string, entry: EntryState): void {
if (entries.get(key) === entry) entries.delete(key);
entry.disposeScopeListener?.();
entry.disposeScopeListener = null;
}
function writeProjection(entry: EntryState, value: unknown): void {
writing = true;
try {
queryClient.setQueryData(entry.queryKey, value);
} finally {
writing = false;
}
}
queryClient.getQueryCache().subscribe((event) => {
if (
writing ||
event.type !== "updated" ||
!entries.has(event.query.queryHash)
) {
return;
}
const entry = entries.get(event.query.queryHash);
if (!entry) return;
entry.base = event.query.state.data;
project(event.query.queryHash, entry);
});
function project(key: string, entry: EntryState): void {
if (!entry.scope.isCurrent()) {
removeEntry(key, entry);
queryClient.removeQueries({ queryKey: entry.queryKey, exact: true });
return;
}
if (entry.base === undefined && entry.layers.length === 0) {
removeEntry(key, entry);
queryClient.removeQueries({ queryKey: entry.queryKey, exact: true });
return;
}
let value = entry.base;
try {
for (const layer of entry.layers) value = layer.apply(value);
} catch {
removeEntry(key, entry);
return;
}
writeProjection(entry, value);
}
function collapse(key: string, entry: EntryState): void {
while (entry.layers[0]?.status === "committed") {
const committed = entry.layers.shift();
if (!committed) break;
entry.base = committed.apply(entry.base);
}
project(key, entry);
if (entry.layers.length === 0) removeEntry(key, entry);
}
return Object.freeze({
begin<Input>(
queryKey: readonly unknown[],
input: Input,
update: (previous: unknown, input: Input) => unknown,
scope: OptimisticLayerScope,
): OptimisticLayerLease | null {
if (!scope.isCurrent()) return null;
const current = queryClient.getQueryData(queryKey);
const key = hashKey(queryKey);
let entry = entries.get(key);
if (!entry) {
entry = {
queryKey,
scope,
base: current,
layers: [],
disposeScopeListener: null,
};
entries.set(key, entry);
if (scope.signal) {
const selectedEntry = entry;
const discardScope = () => {
removeEntry(key, selectedEntry);
queryClient.removeQueries({ queryKey, exact: true });
};
scope.signal.addEventListener("abort", discardScope, { once: true });
entry.disposeScopeListener = () =>
scope.signal?.removeEventListener("abort", discardScope);
if (scope.signal.aborted || !scope.isCurrent()) {
discardScope();
return null;
}
}
} else if (entry.scope !== scope) {
return null;
}
// §11.5. Overflow falls back to pessimistic execution; an existing
// layer is never silently evicted to make room for a new one.
if (entry.layers.length >= OPTIMISTIC_LAYER_BOUNDS.maxLayersPerQueryKey) {
if (entry.layers.length === 0) removeEntry(key, entry);
return null;
}
const layer: Layer = {
id: nextId,
status: "pending",
apply: (value) => update(value, input),
};
let projected: unknown;
try {
projected = entry.base;
for (const existingLayer of entry.layers) {
projected = existingLayer.apply(projected);
}
projected = layer.apply(projected);
} catch (error) {
if (entry.layers.length > 0) return null;
removeEntry(key, entry);
throw error;
}
if (
estimateLayerBytes(projected) >
OPTIMISTIC_LAYER_BOUNDS.maxSingleLayerBytes
) {
if (entry.layers.length === 0) removeEntry(key, entry);
return null;
}
nextId += 1;
entry.layers.push(layer);
writeProjection(entry, projected);
let state: "pending" | "uncertain" | "settled" = "pending";
const selectedLayer = () => {
if (entries.get(key) !== entry) return undefined;
return entry.layers.find((candidate) => candidate.id === layer.id);
};
return Object.freeze({
commit() {
if (state !== "pending") return;
state = "settled";
const selected = selectedLayer();
if (!selected) return;
selected.status = "committed";
collapse(key, entry);
},
rollback() {
if (state !== "pending") return;
state = "settled";
if (!selectedLayer()) return;
entry.layers = entry.layers.filter(
(candidate) => candidate.id !== layer.id,
);
collapse(key, entry);
},
markUncertain() {
if (state !== "pending") return;
state = "uncertain";
const selected = selectedLayer();
if (!selected) return;
selected.status = "uncertain";
collapse(key, entry);
},
reconcile(resolution) {
if (state !== "uncertain") return;
state = "settled";
const selected = selectedLayer();
if (!selected) return;
if (resolution === "APPLIED") {
selected.status = "committed";
} else {
entry.layers = entry.layers.filter(
(candidate) => candidate.id !== layer.id,
);
}
collapse(key, entry);
},
});
},
});
}
const encoder = new TextEncoder();
/**
* A local, bounded estimate of one optimistic projection. This is not the
* §10.4 query result measurement: it only decides whether a rollback snapshot
* stays inside the layer budget, and it stops as soon as the budget is passed.
*/
function estimateLayerBytes(value: unknown): number {
let total = 0;
const stack: unknown[] = [value];
let visited = 0;
while (stack.length > 0) {
if (visited++ > 4_096) return Number.POSITIVE_INFINITY;
if (total > OPTIMISTIC_LAYER_BOUNDS.maxSingleLayerBytes) return total;
const current = stack.pop();
if (typeof current === "string") {
total += encoder.encode(current).byteLength;
} else if (typeof current === "number" || typeof current === "boolean") {
total += 8;
} else if (Array.isArray(current)) {
total += 8;
for (const item of current) stack.push(item);
} else if (current && typeof current === "object") {
total += 8;
for (const [key, item] of Object.entries(current)) {
total += encoder.encode(key).byteLength;
stack.push(item);
}
}
}
return total;
}
@@ -0,0 +1,30 @@
import {
createContext,
type ReactNode,
useContext,
} from "react";
import type { QueryInvalidationCoordinator } from "../../../contracts/query-invalidation.ts";
const QueryInvalidationContext =
createContext<QueryInvalidationCoordinator | null>(null);
export function QueryInvalidationProvider({
coordinator,
children,
}: Readonly<{
coordinator: QueryInvalidationCoordinator;
children: ReactNode;
}>) {
return (
<QueryInvalidationContext.Provider value={coordinator}>
{children}
</QueryInvalidationContext.Provider>
);
}
export function useQueryInvalidationCoordinator():
| QueryInvalidationCoordinator
| null {
return useContext(QueryInvalidationContext);
}
@@ -0,0 +1,58 @@
import {
QueryClientProvider,
type QueryClient,
} from "@tanstack/react-query";
import { type ReactNode, useSyncExternalStore } from "react";
import type { QueryInvalidationCoordinator } from "../../../contracts/query-invalidation.ts";
import type { ServerStateScopeRuntime } from "../../../contracts/server-state-scope.ts";
import type { MutationIntentFactory } from "../../../application/ports/mutation-intent-factory.ts";
import { MutationIntentProvider } from "./mutation-intent-provider.tsx";
import { QueryInvalidationProvider } from "./query-invalidation-provider.tsx";
import { ServerStateScopeProvider } from "./server-state-scope-provider.tsx";
export type ServerStateGenerationSource = Readonly<{
getSnapshot(): Readonly<{
generation: number;
queryClient: QueryClient;
queryInvalidation: QueryInvalidationCoordinator;
}>;
subscribe(listener: () => void): () => void;
}>;
export function ServerStateGenerationProvider({
store,
scope,
mutationIntentFactory,
children,
transitionFallback,
}: Readonly<{
store: ServerStateGenerationSource;
scope: ServerStateScopeRuntime;
mutationIntentFactory: MutationIntentFactory;
children: ReactNode;
transitionFallback?: ReactNode;
}>) {
const generation = useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getSnapshot,
);
return (
<MutationIntentProvider factory={mutationIntentFactory}>
<QueryClientProvider
key={generation.generation}
client={generation.queryClient}
>
<ServerStateScopeProvider
runtime={scope}
transitionFallback={transitionFallback}
>
<QueryInvalidationProvider coordinator={generation.queryInvalidation}>
{children}
</QueryInvalidationProvider>
</ServerStateScopeProvider>
</QueryClientProvider>
</MutationIntentProvider>
);
}
@@ -0,0 +1,52 @@
import {
createContext,
type ReactNode,
useContext,
useSyncExternalStore,
} from "react";
import type {
CacheScopeSnapshot,
ServerStateScopeRuntime,
} from "../../../contracts/server-state-scope.ts";
const ServerStateScopeContext =
createContext<ServerStateScopeRuntime | null>(null);
export function ServerStateScopeProvider({
runtime,
children,
transitionFallback = null,
}: Readonly<{
runtime: ServerStateScopeRuntime;
children: ReactNode;
transitionFallback?: ReactNode;
}>) {
const phase = useSyncExternalStore(
runtime.subscribe,
runtime.getPhase,
runtime.getPhase,
);
const content =
phase === "READY"
? children
: phase === "DISPOSED"
? null
: transitionFallback;
return (
<ServerStateScopeContext.Provider value={runtime}>
{content}
</ServerStateScopeContext.Provider>
);
}
export function useServerStateScope(): CacheScopeSnapshot {
const runtime = useContext(ServerStateScopeContext);
if (!runtime) throw new Error("ServerStateScopeProvider is required");
return useSyncExternalStore(
runtime.subscribe,
runtime.getSnapshot,
runtime.getSnapshot,
);
}
@@ -0,0 +1,54 @@
import { formatMessage } from "../i18n/index.ts";
export type BootErrorShellProps = Readonly<{
kind?: string;
code?: string;
buildId?: string;
configSchemaVersion?: string;
releaseId?: string;
supportReference: string;
}>;
export function BootErrorShell({
kind = "BOOT_CONFIG_FAILURE",
code = "BOOT_FAILED",
buildId,
configSchemaVersion,
releaseId,
supportReference,
}: BootErrorShellProps) {
return (
<main role="alert">
<h1>{formatMessage("ko-KR", "boot.failure.title")}</h1>
<dl>
<dt>{formatMessage("ko-KR", "boot.field.error")}</dt>
<dd>{kind}</dd>
<dt>{formatMessage("ko-KR", "boot.field.code")}</dt>
<dd>{code}</dd>
{buildId && (
<>
<dt>{formatMessage("ko-KR", "boot.field.build")}</dt>
<dd>{buildId}</dd>
</>
)}
{configSchemaVersion && (
<>
<dt>{formatMessage("ko-KR", "boot.field.configSchema")}</dt>
<dd>{configSchemaVersion}</dd>
</>
)}
{releaseId && (
<>
<dt>{formatMessage("ko-KR", "boot.field.release")}</dt>
<dd>{releaseId}</dd>
</>
)}
</dl>
<p>
{formatMessage("ko-KR", "boot.supportReference", {
reference: supportReference,
})}
</p>
</main>
);
}
@@ -0,0 +1,115 @@
import {
Component,
type ErrorInfo,
type ReactNode,
} from "react";
import { useLocale } from "../i18n/index.ts";
type RecoveryResult =
| Readonly<{ action: "reload-once"; releasePair: string }>
| Readonly<{ action: "support"; reason: string }>;
type Props = Readonly<{
children: ReactNode;
chunkId: string;
recover(input: Readonly<{
chunkId: string;
failureKind: "CHUNK_LOAD_FAILURE";
}>): Promise<RecoveryResult>;
}>;
type State = Readonly<{
error: unknown | null;
recovery: "idle" | "checking" | "reload-requested" | "support";
reason?: string;
}>;
export function isChunkLoadFailure(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const value = `${error.name} ${error.message}`.toLowerCase();
return (
value.includes("chunkloaderror") ||
value.includes("loading chunk") ||
value.includes("dynamically imported module") ||
value.includes("failed to fetch module script")
);
}
export class ChunkRecoveryBoundary extends Component<Props, State> {
state: State = { error: null, recovery: "idle" };
static getDerivedStateFromError(error: unknown): State {
return { error, recovery: "checking" };
}
componentDidCatch(error: unknown, _info: ErrorInfo) {
if (!isChunkLoadFailure(error)) return;
void this.props
.recover({
chunkId: this.props.chunkId,
failureKind: "CHUNK_LOAD_FAILURE",
})
.then((result) => {
this.setState({
error,
recovery:
result.action === "reload-once"
? "reload-requested"
: "support",
...(result.action === "support" ? { reason: result.reason } : {}),
});
})
.catch(() => {
this.setState({
error,
recovery: "support",
reason: "recovery-controller-failed",
});
});
}
render() {
const { error, recovery, reason } = this.state;
if (error && !isChunkLoadFailure(error)) throw error;
if (error && recovery === "checking") {
return <ChunkRecoverySurface recovery="checking" />;
}
if (error && recovery === "reload-requested") {
return <ChunkRecoverySurface recovery="reload-requested" />;
}
if (error && recovery === "support") {
return <ChunkRecoverySurface recovery="support" reason={reason} />;
}
return this.props.children;
}
}
function ChunkRecoverySurface({
recovery,
reason,
}: Readonly<{
recovery: Exclude<State["recovery"], "idle">;
reason?: string;
}>) {
const { message } = useLocale();
if (recovery === "checking") {
return (
<section className="ui-page" aria-live="polite" aria-busy="true">
{message("chunk.checking")}
</section>
);
}
if (recovery === "reload-requested") {
return (
<section className="ui-page" aria-live="polite">
{message("chunk.reloadOnce")}
</section>
);
}
return (
<section className="ui-page" role="alert" data-recovery-reason={reason}>
<h1>{message("chunk.failure.title")}</h1>
<p>{message("chunk.failure.description")}</p>
</section>
);
}
@@ -0,0 +1,77 @@
import { Component, type ReactNode } from "react";
import type { RenderFailureReport } from "../../application/ports/in/application-api.ts";
import { formatMessage } from "../i18n/index.ts";
export type RenderBoundaryProps = Readonly<{
children: ReactNode;
boundaryName: RenderFailureReport["boundaryName"];
routeId: string;
buildId: string;
resetKey?: string;
onRenderFailure?: (report: RenderFailureReport) => void;
fallback?: ReactNode;
}>;
type RenderBoundaryState = Readonly<{ hasError: boolean }>;
export class RenderErrorBoundary extends Component<
RenderBoundaryProps,
RenderBoundaryState
> {
state: RenderBoundaryState = { hasError: false };
static getDerivedStateFromError(): RenderBoundaryState {
return { hasError: true };
}
componentDidCatch(): void {
try {
this.props.onRenderFailure?.({
routeId: this.props.routeId,
buildId: this.props.buildId,
boundaryName: this.props.boundaryName,
});
} catch {
// Diagnostics must never recurse into another render failure.
}
}
componentDidUpdate(previous: Readonly<RenderBoundaryProps>): void {
if (this.state.hasError && previous.resetKey !== this.props.resetKey) {
this.setState({ hasError: false });
}
}
reset = (): void => {
this.setState({ hasError: false });
};
render(): ReactNode {
if (this.state.hasError) {
return (
this.props.fallback ?? (
<section role="alert">
<p>{formatMessage("ko-KR", "error.render_failure")}</p>
<button type="button" onClick={this.reset}>
{formatMessage("ko-KR", "action.retry")}
</button>
</section>
)
);
}
return this.props.children;
}
}
export function RouteBoundary(
props: Omit<RenderBoundaryProps, "boundaryName">,
) {
return <RenderErrorBoundary {...props} boundaryName="route" />;
}
export function FeatureBoundary(
props: Omit<RenderBoundaryProps, "boundaryName">,
) {
return <RenderErrorBoundary {...props} boundaryName="feature" />;
}
@@ -0,0 +1,165 @@
import { useId, type ReactNode } from "react";
import type { AsyncState } from "../../application/view-models/async-state.ts";
import type { AppFailure } from "../../contracts/errors.ts";
import { useLocale } from "../i18n/index.ts";
import { errorMessage } from "./error-copy.ts";
import { Button } from "./ui/button.ts";
export function LoadingSurface({ label }: Readonly<{ label?: string }>) {
const { message } = useLocale();
const accessibleLabel = label ?? message("async.loading");
return (
<section
className="state-surface state-surface--loading"
aria-busy="true"
aria-label={accessibleLabel}
aria-live="polite"
aria-atomic="true"
>
<div className="ui-skeleton" aria-hidden="true" />
<span className="visually-hidden">{accessibleLabel}</span>
</section>
);
}
export type EmptySurfaceProps = Readonly<{
title?: string;
description?: string;
action?: ReactNode;
}>;
export function EmptySurface({
title,
description,
action,
}: EmptySurfaceProps) {
const { message } = useLocale();
return (
<section className="ui-empty state-surface" aria-live="polite">
<h2>{title ?? message("async.empty")}</h2>
{description ? <p>{description}</p> : null}
{action}
</section>
);
}
export type TerminalErrorSurfaceProps = Readonly<{
userMessageKey: string;
action: AppFailure["action"];
onAction?: () => void;
}>;
export function TerminalErrorSurface({
userMessageKey,
action,
onAction,
}: TerminalErrorSurfaceProps) {
const { locale, message } = useLocale();
const messageId = useId();
const actionLabels: Readonly<
Record<Exclude<AppFailure["action"], "none">, string>
> = Object.freeze({
retry: message("action.retry"),
reauth: message("action.reauth"),
navigate: message("action.navigateSafe"),
"reload-once": message("action.reloadOnce"),
"contact-support": message("action.contactSupport"),
});
return (
<section
className="ui-terminal-error state-surface state-surface--danger"
role="alert"
aria-labelledby={messageId}
data-message-key={userMessageKey}
>
<h2 id={messageId}>{errorMessage(userMessageKey, locale)}</h2>
{action !== "none" && onAction && (
<Button onClick={onAction}>{actionLabels[action]}</Button>
)}
</section>
);
}
export type AsyncSurfaceProps = Readonly<{
state: AsyncState;
children?: ReactNode;
onAction?: () => void;
onRetry?: () => void;
onResolveConflict?: () => void;
onReconcileUnknownEffect?: (
resolution: "APPLIED" | "NOT_APPLIED",
) => void;
}>;
export function AsyncSurface({
state,
children,
onAction,
onRetry,
onResolveConflict,
onReconcileUnknownEffect,
}: AsyncSurfaceProps) {
const { message } = useLocale();
if (state.base === "initial-loading") return <LoadingSurface />;
if (state.base === "empty") return <EmptySurface />;
if (state.base === "terminal-error" && state.failure) {
return (
<TerminalErrorSurface
userMessageKey={state.failure.userMessageKey}
action={state.failure.action}
onAction={
state.failure.action === "retry"
? onRetry ?? onAction
: onAction
}
/>
);
}
return (
<section aria-busy={state.overlay.refreshing || state.overlay.mutationPending}>
{state.indicator ? (
<div role="status" aria-live="polite">
<span>
{message(
state.indicator === "stale-degraded"
? "async.staleDegraded"
: state.indicator === "mutation-effect-unknown"
? "async.mutationEffectUnknown"
: state.indicator === "mutation-conflict"
? "async.mutationConflict"
: state.indicator === "mutation-pending"
? "async.mutationPending"
: "async.refreshing",
)}
</span>
{state.indicator === "stale-degraded" && onRetry ? (
<Button onClick={onRetry}>{message("action.retry")}</Button>
) : null}
{state.indicator === "mutation-conflict" && onResolveConflict ? (
<Button onClick={onResolveConflict}>
{message("action.resolveConflict")}
</Button>
) : null}
{state.indicator === "mutation-effect-unknown" &&
onReconcileUnknownEffect ? (
<>
<Button
onClick={() => onReconcileUnknownEffect("APPLIED")}
>
{message("action.confirmMutationApplied")}
</Button>
<Button
onClick={() => onReconcileUnknownEffect("NOT_APPLIED")}
>
{message("action.confirmMutationNotApplied")}
</Button>
</>
) : null}
</div>
) : null}
{children}
</section>
);
}
@@ -0,0 +1,8 @@
import { resolveMessage } from "../i18n/index.ts";
export function errorMessage(
messageKey: string,
locale: string = "ko-KR",
): string {
return resolveMessage(locale, messageKey);
}
@@ -0,0 +1,40 @@
import { useEffect, useRef } from "react";
export type PageHeaderProps = Readonly<{
title: string;
description?: string;
eyebrow?: string;
}>;
export function PageHeader({
title,
description,
eyebrow,
}: PageHeaderProps) {
const headingRef = useRef<HTMLHeadingElement | null>(null);
useEffect(() => {
const activeElement = document.activeElement;
const main = document.getElementById("main-content");
const routeOwnsFocus =
activeElement === null ||
activeElement === document.body ||
activeElement === document.documentElement ||
activeElement === main;
if (routeOwnsFocus) {
headingRef.current?.focus();
}
}, [title]);
return (
<header className="page-header">
{eyebrow ? <p className="page-header__eyebrow">{eyebrow}</p> : null}
<h1 ref={headingRef} tabIndex={-1} data-route-heading>
{title}
</h1>
{description ? (
<p className="page-header__description">{description}</p>
) : null}
</header>
);
}
@@ -0,0 +1,75 @@
import { useLocale } from "../i18n/index.ts";
import { Button } from "./ui/button.ts";
type StateSurfaceProps = Readonly<{
eyebrow: string;
title: string;
description: string;
actionLabel?: string;
onAction?: () => void;
tone?: "neutral" | "danger" | "warning";
}>;
function StateSurface({
eyebrow,
title,
description,
actionLabel,
onAction,
tone = "neutral",
}: StateSurfaceProps) {
return (
<section className={`state-surface state-surface--${tone}`}>
<p className="state-surface__eyebrow">{eyebrow}</p>
<h2>{title}</h2>
<p>{description}</p>
{actionLabel ? <Button onClick={onAction}>{actionLabel}</Button> : null}
</section>
);
}
export function AuthRequiredSurface({
onSignIn,
}: Readonly<{ onSignIn?: () => void }>) {
const { message } = useLocale();
return (
<StateSurface
eyebrow={message("access.auth.eyebrow")}
title={message("access.auth.title")}
description={message("access.auth.description")}
actionLabel={message("action.signIn")}
onAction={onSignIn}
/>
);
}
export function ForbiddenSurface({
onNavigate,
}: Readonly<{ onNavigate?: () => void }>) {
const { message } = useLocale();
return (
<StateSurface
eyebrow={message("access.forbidden.eyebrow")}
title={message("access.forbidden.title")}
description={message("access.forbidden.description")}
actionLabel={message("action.navigateSafe")}
onAction={onNavigate}
tone="warning"
/>
);
}
export function NotFoundSurface({
onNavigate,
}: Readonly<{ onNavigate?: () => void }>) {
const { message } = useLocale();
return (
<StateSurface
eyebrow={message("access.notFound.eyebrow")}
title={message("access.notFound.title")}
description={message("access.notFound.description")}
actionLabel={message("action.goHome")}
onAction={onNavigate}
/>
);
}
+1
View File
@@ -0,0 +1 @@
export { Alert, type AlertProps } from "../../design-system/primitives/core.tsx";
+1
View File
@@ -0,0 +1 @@
export { Badge, type BadgeProps } from "../../design-system/primitives/core.tsx";
+6
View File
@@ -0,0 +1,6 @@
export {
Button,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
} from "../../design-system/primitives/core.tsx";
+1
View File
@@ -0,0 +1 @@
export { Card, type CardProps } from "../../design-system/primitives/core.tsx";
+1
View File
@@ -0,0 +1 @@
export { Dialog, type DialogProps } from "../../design-system/primitives/core.tsx";
@@ -0,0 +1,4 @@
export {
TextField,
type TextFieldProps,
} from "../../design-system/primitives/core.tsx";
@@ -0,0 +1,128 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { useState } from "react";
import {
Alert,
Badge,
Button,
Card,
Dialog,
Menu,
ProgressBar,
Skeleton,
Tabs,
TextArea,
TextField,
} from "./index.ts";
const meta = {
title: "Platform/Design System",
component: Button,
tags: ["autodocs"],
parameters: {
layout: "padded",
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primitives: Story = {
render: () => (
<div className="ui-stack">
<Card title="Actions and status">
<div className="ui-cluster">
<Button>Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="danger">Danger</Button>
<Button pending pendingLabel="Processing">
Save
</Button>
</div>
<div className="ui-cluster">
<Badge variant="neutral">Neutral</Badge>
<Badge variant="success">Success</Badge>
<Badge variant="warning">Warning</Badge>
<Badge variant="danger">Danger</Badge>
</div>
</Card>
<TextField
description="A stable accessible description"
label="Name"
placeholder="Example"
/>
<TextField error="Enter a name" label="Invalid name" value="" readOnly />
<TextArea
defaultValue="Long-form content"
label="Notes"
maxLength={100}
/>
<Alert title="Platform status" variant="info">
The component workshop uses the same tokens and providers as the app.
</Alert>
<ProgressBar label="Build readiness" value={72} />
<Skeleton label="Loading example" />
</div>
),
};
function OverlayExample() {
const [open, setOpen] = useState(false);
return (
<div className="ui-stack">
<Button onClick={() => setOpen(true)}>Open dialog</Button>
<Dialog
actions={<Button onClick={() => setOpen(false)}>Confirm</Button>}
onClose={() => setOpen(false)}
open={open}
title="Confirm platform action"
>
Keyboard dismissal must restore focus to the trigger.
</Dialog>
<Menu
items={[
{ id: "first", label: "First action", onSelect() {} },
{ id: "second", label: "Second action", onSelect() {} },
]}
triggerLabel="Open menu"
/>
<Tabs
defaultValue="one"
label="Example sections"
tabs={[
{ id: "one", label: "One", panel: "First panel" },
{ id: "two", label: "Two", panel: "Second panel" },
]}
/>
</div>
);
}
export const OverlayInteraction: Story = {
render: () => <OverlayExample />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = canvas.getByRole("button", { name: "Open dialog" });
await userEvent.click(trigger);
const dialog = within(document.body).getByRole("dialog", {
name: "Confirm platform action",
});
await expect(dialog).toBeVisible();
await userEvent.keyboard("{Escape}");
await expect(dialog).not.toBeVisible();
await expect(trigger).toHaveFocus();
},
};
export const LongPseudoLikeContent: Story = {
render: () => (
<Card title="[!! Ćømƥøñëñţ ţøķëñ åñđ ļøñğ ţëжţ vëŕïƒïćåţïøñ !!]">
<p>
[!! Ţhïš šţøŕÿ vëŕïƒïëš ţhåţ å ƥŕïmïţïvë ŕëmåïñš ŕëåđåɓļë
ïñ å ćømƥåćţ ćøñţåïñëŕ. !!]
</p>
<Button>[!! Ćøñţïñüë !!]</Button>
</Card>
),
};
@@ -0,0 +1,47 @@
import type { ComponentType, SVGProps } from "react";
import {
CloseGlyph,
ErrorGlyph,
InfoGlyph,
MenuGlyph,
NextGlyph,
PreviousGlyph,
SearchGlyph,
SuccessGlyph,
WarningGlyph,
} from "./vendors/lucide.tsx";
export type SemanticIconProps = Readonly<{
label?: string;
size?: "small" | "medium" | "large";
}>;
function createSemanticIcon(
Glyph: ComponentType<SVGProps<SVGSVGElement>>,
) {
return function SemanticIcon({
label,
size = "medium",
}: SemanticIconProps) {
return (
<Glyph
aria-hidden={label ? undefined : "true"}
aria-label={label}
className={`ui-icon ui-icon--${size}`}
focusable="false"
role={label ? "img" : undefined}
/>
);
};
}
export const MenuIcon = createSemanticIcon(MenuGlyph);
export const CloseIcon = createSemanticIcon(CloseGlyph);
export const WarningIcon = createSemanticIcon(WarningGlyph);
export const SuccessIcon = createSemanticIcon(SuccessGlyph);
export const ErrorIcon = createSemanticIcon(ErrorGlyph);
export const InfoIcon = createSemanticIcon(InfoGlyph);
export const SearchIcon = createSemanticIcon(SearchGlyph);
export const PreviousIcon = createSemanticIcon(PreviousGlyph);
export const NextIcon = createSemanticIcon(NextGlyph);
+21
View File
@@ -0,0 +1,21 @@
import {
AlertTriangle,
Check,
ChevronLeft,
ChevronRight,
CircleX,
Info,
Menu,
Search,
X,
} from "lucide-react";
export const MenuGlyph = Menu;
export const CloseGlyph = X;
export const WarningGlyph = AlertTriangle;
export const SuccessGlyph = Check;
export const ErrorGlyph = CircleX;
export const InfoGlyph = Info;
export const SearchGlyph = Search;
export const PreviousGlyph = ChevronLeft;
export const NextGlyph = ChevronRight;
+146
View File
@@ -0,0 +1,146 @@
export {
Alert,
Badge,
Button,
Card,
Dialog,
Field,
FocusRing,
IconButton,
LinkButton,
Portal,
Spinner,
TextField,
VisuallyHidden,
} from "./primitives/core.tsx";
export type {
AlertProps,
BadgeProps,
ButtonProps,
ButtonSize,
ButtonVariant,
CardProps,
DialogProps,
IconButtonProps,
LinkButtonProps,
SpinnerProps,
TextFieldProps,
} from "./primitives/core.tsx";
export {
Checkbox,
RadioGroup,
SearchField,
Select,
Switch,
TextArea,
} from "./primitives/forms.tsx";
export type {
CheckboxProps,
RadioGroupProps,
RadioOption,
SearchFieldProps,
SelectOption,
SelectProps,
SwitchProps,
TextAreaProps,
} from "./primitives/forms.tsx";
export {
ProgressBar,
Separator,
Skeleton,
} from "./primitives/feedback.tsx";
export type {
ProgressBarProps,
SkeletonProps,
} from "./primitives/feedback.tsx";
export {
ConfirmationDialog,
Drawer,
Menu,
Popover,
ToastProvider,
Tooltip,
useToast,
} from "./primitives/overlays.tsx";
export type {
DrawerProps,
MenuItemDefinition,
MenuProps,
PopoverProps,
TooltipProps,
} from "./primitives/overlays.tsx";
export {
Breadcrumbs,
Pagination,
Tabs,
} from "./primitives/navigation.tsx";
export type {
BreadcrumbItem,
TabDefinition,
} from "./primitives/navigation.tsx";
export {
CloseIcon,
ErrorIcon,
InfoIcon,
MenuIcon,
NextIcon,
PreviousIcon,
SearchIcon,
SuccessIcon,
WarningIcon,
} from "./icons/semantic-icons.tsx";
export type {
SemanticIconProps,
} from "./icons/semantic-icons.tsx";
export {
DESIGN_TOKEN_CONTRACT,
REQUIRED_COMPONENT_TOKENS,
REQUIRED_PRIMITIVE_TOKENS,
REQUIRED_SEMANTIC_TOKENS,
} from "./tokens/token-contract.ts";
export {
AccessSurface,
DataTable,
DisclosureGroup,
PaginationBar,
SearchFilterToolbar,
} from "./patterns/common-patterns.tsx";
export type {
AccessSurfaceProps,
DataTableColumn,
DisclosureDefinition,
} from "./patterns/common-patterns.tsx";
export {
AsyncSurface,
EmptySurface,
LoadingSurface,
TerminalErrorSurface,
} from "../components/async-surface.tsx";
export { PageHeader } from "../components/page-header.tsx";
export {
AuthRequiredSurface,
ForbiddenSurface,
NotFoundSurface,
} from "../components/state-surfaces.tsx";
export {
DetailPage,
CollectionPage,
FormPage,
StandardPage,
StatusPage,
} from "../templates/index.ts";
export {
DirtyNavigationDialog,
ErrorSummary,
Form,
FormActions,
FormField,
useAppForm,
useDirtyNavigationGuard,
} from "../forms/index.ts";
export type {
PageActionDefinition,
PageHeading,
} from "../templates/page-templates.tsx";
export type { FormResult } from "../forms/form-contracts.ts";
@@ -0,0 +1,139 @@
import { useId } from "react";
import {
AuthRequiredSurface,
ForbiddenSurface,
NotFoundSurface,
} from "../../components/state-surfaces.tsx";
import { Button } from "../primitives/core.tsx";
import { Pagination } from "../primitives/navigation.tsx";
export type DataTableColumn<Row> = Readonly<{
id: string;
header: string;
cell(row: Row): React.ReactNode;
}>;
export function DataTable<Row>({
caption,
columns,
rows,
rowKey,
empty,
}: Readonly<{
caption: string;
columns: readonly DataTableColumn<Row>[];
rows: readonly Row[];
rowKey(row: Row): string;
empty: React.ReactNode;
}>) {
if (rows.length === 0) return <>{empty}</>;
return (
<div className="ui-data-table__scroll" tabIndex={0}>
<table className="ui-data-table">
<caption>{caption}</caption>
<thead>
<tr>
{columns.map((column) => (
<th key={column.id} scope="col">
{column.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={rowKey(row)}>
{columns.map((column) => (
<td key={column.id}>{column.cell(row)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
export function SearchFilterToolbar({
label,
search,
filters,
resetLabel,
onReset,
resultCount,
}: Readonly<{
label: string;
search: React.ReactNode;
filters?: React.ReactNode;
resetLabel: string;
onReset(): void;
resultCount: React.ReactNode;
}>) {
return (
<section aria-label={label} className="ui-search-filter-toolbar">
<div>{search}</div>
{filters ? <div>{filters}</div> : null}
<Button onClick={onReset} variant="ghost">
{resetLabel}
</Button>
<output>{resultCount}</output>
</section>
);
}
export function PaginationBar({
range,
...pagination
}: React.ComponentProps<typeof Pagination> & Readonly<{ range: string }>) {
return (
<div className="ui-pagination-bar">
<p>{range}</p>
<Pagination {...pagination} />
</div>
);
}
export type DisclosureDefinition = Readonly<{
id: string;
title: string;
content: React.ReactNode;
}>;
export function DisclosureGroup({
label,
items,
}: Readonly<{
label: string;
items: readonly DisclosureDefinition[];
}>) {
const groupId = useId();
return (
<section aria-labelledby={groupId} className="ui-disclosure-group">
<h2 className="visually-hidden" id={groupId}>
{label}
</h2>
{items.map((item) => (
<details key={item.id}>
<summary>{item.title}</summary>
<div>{item.content}</div>
</details>
))}
</section>
);
}
export type AccessSurfaceProps =
| Readonly<{ kind: "auth-required"; onAction(): void }>
| Readonly<{ kind: "forbidden"; onAction(): void }>
| Readonly<{ kind: "not-found"; onAction(): void }>;
export function AccessSurface(props: AccessSurfaceProps) {
if (props.kind === "auth-required") {
return <AuthRequiredSurface onSignIn={props.onAction} />;
}
if (props.kind === "forbidden") {
return <ForbiddenSurface onNavigate={props.onAction} />;
}
return <NotFoundSurface onNavigate={props.onAction} />;
}
@@ -0,0 +1,471 @@
import {
forwardRef,
useEffect,
useId,
useImperativeHandle,
useRef,
} from "react";
import { createPortal } from "react-dom";
import { CloseIcon } from "../icons/semantic-icons.tsx";
import { useLocale } from "../../i18n/index.ts";
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
export type ButtonSize = "default" | "compact";
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
Readonly<{
variant?: ButtonVariant;
size?: ButtonSize;
pending?: boolean;
pendingLabel?: string;
}>;
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "default",
pending = false,
pendingLabel,
className = "",
type = "button",
children,
disabled,
...props
},
ref,
) {
const classes = [
"ui-button",
`ui-button--${variant}`,
size === "compact" ? "ui-button--compact" : "",
pending ? "ui-button--pending" : "",
className,
]
.filter(Boolean)
.join(" ");
return (
<button
{...props}
aria-busy={pending || undefined}
className={classes}
disabled={disabled}
ref={ref}
type={type}
>
{pending ? (
<>
<Spinner decorative />
{pendingLabel ?? children}
</>
) : (
children
)}
</button>
);
},
);
export type LinkButtonProps = React.AnchorHTMLAttributes<HTMLAnchorElement> &
Readonly<{
href: string;
variant?: ButtonVariant;
size?: ButtonSize;
}>;
export const LinkButton = forwardRef<HTMLAnchorElement, LinkButtonProps>(
function LinkButton(
{
href,
variant = "primary",
size = "default",
className = "",
...props
},
ref,
) {
return (
<a
{...props}
className={[
"ui-button",
`ui-button--${variant}`,
size === "compact" ? "ui-button--compact" : "",
className,
]
.filter(Boolean)
.join(" ")}
href={href}
ref={ref}
/>
);
},
);
export type IconButtonProps = Omit<ButtonProps, "aria-label" | "children"> &
Readonly<{
accessibleName: string;
children: React.ReactElement;
}>;
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
function IconButton({ accessibleName, className = "", ...props }, ref) {
return (
<Button
{...props}
aria-label={accessibleName}
className={`ui-icon-button ${className}`.trim()}
ref={ref}
/>
);
},
);
type FieldFrameProps = Readonly<{
id?: string;
label: string;
description?: string;
error?: string;
required?: boolean;
className?: string;
children(
contract: Readonly<{
controlId: string;
describedBy: string | undefined;
invalid: boolean;
}>,
): React.ReactNode;
}>;
export function Field({
id,
label,
description,
error,
required,
className = "",
children,
}: FieldFrameProps) {
const generatedId = useId();
const controlId = id ?? `field-${generatedId}`;
const descriptionId = description ? `${controlId}-description` : undefined;
const errorId = error ? `${controlId}-error` : undefined;
const describedBy = [descriptionId, errorId].filter(Boolean).join(" ");
return (
<div className={`ui-field ${className}`.trim()}>
<label className="ui-field__label" htmlFor={controlId}>
{label}
{required ? <span aria-hidden="true"> *</span> : null}
</label>
{description ? (
<p className="ui-field__description" id={descriptionId}>
{description}
</p>
) : null}
{children({
controlId,
describedBy: describedBy || undefined,
invalid: Boolean(error),
})}
{error ? (
<p className="ui-field__error" id={errorId}>
{error}
</p>
) : null}
</div>
);
}
export type TextFieldProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"id"
> &
Readonly<{
id?: string;
label: string;
description?: string;
error?: string;
}>;
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
function TextField(
{
id,
label,
description,
error,
className = "",
required,
...inputProps
},
ref,
) {
return (
<Field
className={className}
description={description}
error={error}
id={id}
label={label}
required={required}
>
{({ controlId, describedBy, invalid }) => (
<input
{...inputProps}
aria-describedby={describedBy}
aria-invalid={invalid || undefined}
className="ui-field__input"
id={controlId}
ref={ref}
required={required}
/>
)}
</Field>
);
},
);
export type CardProps = Readonly<{
title: string;
headingLevel?: 2 | 3 | 4;
description?: string;
children?: React.ReactNode;
footer?: React.ReactNode;
className?: string;
}>;
export function Card({
title,
headingLevel = 3,
description,
children,
footer,
className = "",
}: CardProps) {
const titleId = useId();
const Heading = `h${headingLevel}` as "h2" | "h3" | "h4";
return (
<article
aria-labelledby={titleId}
className={`ui-card ${className}`.trim()}
>
<div className="ui-card__header">
<Heading id={titleId}>{title}</Heading>
{description ? <p>{description}</p> : null}
</div>
{children ? <div className="ui-card__content">{children}</div> : null}
{footer ? <footer className="ui-card__footer">{footer}</footer> : null}
</article>
);
}
export type AlertProps = Readonly<{
title: string;
children?: React.ReactNode;
variant?: "info" | "success" | "warning" | "danger";
dismissLabel?: string;
onDismiss?: () => void;
}>;
export function Alert({
title,
children,
variant = "info",
dismissLabel,
onDismiss,
}: AlertProps) {
const { message } = useLocale();
return (
<section
className={`ui-alert ui-alert--${variant}`}
role={variant === "danger" ? "alert" : "status"}
>
<div>
<strong>{title}</strong>
{children ? <div className="ui-alert__content">{children}</div> : null}
</div>
{onDismiss ? (
<IconButton
accessibleName={
dismissLabel ?? message("action.alertCloseNamed", { title })
}
className="ui-alert__dismiss"
onClick={onDismiss}
variant="ghost"
>
<CloseIcon />
</IconButton>
) : null}
</section>
);
}
export type BadgeProps = Readonly<{
children: React.ReactNode;
variant?: "neutral" | "info" | "success" | "warning" | "danger";
}>;
export function Badge({ children, variant = "neutral" }: BadgeProps) {
return <span className={`ui-badge ui-badge--${variant}`}>{children}</span>;
}
export type DialogProps = Readonly<{
open: boolean;
onClose(): void;
title: string;
description?: string;
closeLabel?: string;
children?: React.ReactNode;
actions?: React.ReactNode;
className?: string;
returnFocusRef?: React.RefObject<HTMLElement | null>;
}>;
export const Dialog = forwardRef<HTMLDialogElement, DialogProps>(
function Dialog(
{
open,
onClose,
title,
description,
closeLabel,
children,
actions,
className = "",
returnFocusRef,
},
forwardedRef,
) {
const { message } = useLocale();
const dialogRef = useRef<HTMLDialogElement | null>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
const focusRestoreGenerationRef = useRef(0);
const titleId = useId();
const descriptionId = useId();
useImperativeHandle(forwardedRef, () => dialogRef.current!, []);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const generation = focusRestoreGenerationRef.current + 1;
focusRestoreGenerationRef.current = generation;
if (open) {
previousFocusRef.current =
returnFocusRef?.current ??
(document.activeElement instanceof HTMLElement
? document.activeElement
: null);
if (!dialog.open) {
if (typeof dialog.showModal === "function") dialog.showModal();
else dialog.setAttribute("open", "");
}
const firstFocusable = dialog.querySelector<HTMLElement>(
"[autofocus], button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])",
);
firstFocusable?.focus();
return;
}
if (dialog.open) {
if (typeof dialog.close === "function") dialog.close();
else dialog.removeAttribute("open");
}
const previousFocus = previousFocusRef.current;
previousFocusRef.current = null;
const restoreFocus = () => {
if (
focusRestoreGenerationRef.current === generation &&
previousFocus?.isConnected
) {
previousFocus.focus();
}
};
const timer = globalThis.setTimeout(restoreFocus, 0);
return () => {
globalThis.clearTimeout(timer);
};
}, [open, returnFocusRef]);
return (
<dialog
aria-describedby={description ? descriptionId : undefined}
aria-labelledby={titleId}
className={`ui-dialog ${className}`.trim()}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
onClick={(event) => {
if (event.target === event.currentTarget) onClose();
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
}
}}
ref={dialogRef}
>
<div className="ui-dialog__surface">
<header className="ui-dialog__header">
<div>
<h2 id={titleId}>{title}</h2>
{description ? <p id={descriptionId}>{description}</p> : null}
</div>
<IconButton
accessibleName={
closeLabel ?? message("action.closeNamed", { title })
}
className="ui-dialog__close"
onClick={onClose}
variant="ghost"
>
<CloseIcon />
</IconButton>
</header>
{children ? (
<div className="ui-dialog__content">{children}</div>
) : null}
{actions ? (
<footer className="ui-dialog__actions">{actions}</footer>
) : null}
</div>
</dialog>
);
},
);
export type SpinnerProps =
| Readonly<{ decorative: true; label?: never }>
| Readonly<{ decorative?: false; label: string }>;
export function Spinner(props: SpinnerProps) {
const accessibility = props.decorative
? { "aria-hidden": true as const }
: { "aria-label": props.label, role: "status" };
return <span {...accessibility} className="ui-spinner" />;
}
export function VisuallyHidden({
children,
}: Readonly<{ children: React.ReactNode }>) {
return <span className="visually-hidden">{children}</span>;
}
export function Portal({
children,
}: Readonly<{ children: React.ReactNode }>) {
if (typeof document === "undefined") return <>{children}</>;
return createPortal(children, document.body);
}
export function FocusRing({
children,
}: Readonly<{ children: React.ReactNode }>) {
return <span className="ui-focus-ring">{children}</span>;
}
@@ -0,0 +1,55 @@
export type ProgressBarProps = Readonly<{
label: string;
value?: number;
max?: number;
}>;
export function ProgressBar({ label, value, max = 100 }: ProgressBarProps) {
const determinate = typeof value === "number";
return (
<div className="ui-progress">
<div className="ui-progress__label">
<span>{label}</span>
{determinate ? <span>{Math.round((value / max) * 100)}%</span> : null}
</div>
<progress
aria-label={label}
className="ui-progress__bar"
max={max}
value={determinate ? Math.min(Math.max(value, 0), max) : undefined}
/>
</div>
);
}
export type SkeletonProps = Readonly<{
label?: string;
height?: "text" | "control" | "surface";
}>;
export function Skeleton({
label,
height = "surface",
}: SkeletonProps) {
return (
<span
aria-label={label}
aria-hidden={label ? undefined : true}
className={`ui-skeleton ui-skeleton--${height}`}
role={label ? "status" : undefined}
/>
);
}
export function Separator({
label,
decorative = true,
}: Readonly<{ label?: string; decorative?: boolean }>) {
return (
<hr
aria-label={decorative ? undefined : label}
aria-hidden={decorative || undefined}
className="ui-separator"
/>
);
}
@@ -0,0 +1,411 @@
import {
forwardRef,
useEffect,
useId,
useRef,
useState,
} from "react";
import { CloseIcon, SearchIcon } from "../icons/semantic-icons.tsx";
import { Field, IconButton } from "./core.tsx";
import type { TextFieldProps } from "./core.tsx";
import { useLocale } from "../../i18n/index.ts";
type FieldCopy = Readonly<{
id?: string;
label: string;
description?: string;
error?: string;
}>;
export type TextAreaProps = Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
"id"
> &
FieldCopy &
Readonly<{ maxLengthMessage?: (remaining: number) => string }>;
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
function TextArea(
{
id,
label,
description,
error,
required,
className = "",
maxLength,
maxLengthMessage,
value,
defaultValue,
...props
},
ref,
) {
const { message } = useLocale();
const [uncontrolledValue, setUncontrolledValue] = useState(
String(defaultValue ?? ""),
);
const currentValue =
value === undefined ? uncontrolledValue : String(value ?? "");
const remaining =
typeof maxLength === "number" ? maxLength - currentValue.length : null;
return (
<Field
className={className}
description={description}
error={error}
id={id}
label={label}
required={required}
>
{({ controlId, describedBy, invalid }) => (
<>
<textarea
{...props}
aria-describedby={describedBy}
aria-invalid={invalid || undefined}
className="ui-field__input ui-field__textarea"
defaultValue={value === undefined ? defaultValue : undefined}
id={controlId}
maxLength={maxLength}
onChange={(event) => {
if (value === undefined) setUncontrolledValue(event.currentTarget.value);
props.onChange?.(event);
}}
ref={ref}
required={required}
value={value}
/>
{remaining !== null ? (
<output className="ui-field__counter">
{maxLengthMessage
? maxLengthMessage(remaining)
: message("form.remaining", { count: remaining })}
</output>
) : null}
</>
)}
</Field>
);
},
);
export type SelectOption = Readonly<{
value: string;
label: string;
disabled?: boolean;
}>;
export type SelectProps = Omit<
React.SelectHTMLAttributes<HTMLSelectElement>,
"id" | "children"
> &
FieldCopy &
Readonly<{
options: readonly SelectOption[];
placeholder?: string;
}>;
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
function Select(
{
id,
label,
description,
error,
options,
placeholder,
required,
className = "",
...props
},
ref,
) {
return (
<Field
className={className}
description={description}
error={error}
id={id}
label={label}
required={required}
>
{({ controlId, describedBy, invalid }) => (
<select
{...props}
aria-describedby={describedBy}
aria-invalid={invalid || undefined}
className="ui-field__input ui-field__select"
id={controlId}
ref={ref}
required={required}
>
{placeholder ? (
<option disabled value="">
{placeholder}
</option>
) : null}
{options.map((option) => (
<option
disabled={option.disabled}
key={option.value}
value={option.value}
>
{option.label}
</option>
))}
</select>
)}
</Field>
);
},
);
export type CheckboxProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"type"
> &
Readonly<{
label: string;
description?: string;
error?: string;
indeterminate?: boolean;
}>;
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
function Checkbox(
{ label, description, error, indeterminate = false, id, ...props },
forwardedRef,
) {
const generatedId = useId();
const controlId = id ?? `checkbox-${generatedId}`;
const inputRef = useRef<HTMLInputElement | null>(null);
const descriptionId = description ? `${controlId}-description` : undefined;
const errorId = error ? `${controlId}-error` : undefined;
useEffect(() => {
if (inputRef.current) inputRef.current.indeterminate = indeterminate;
}, [indeterminate]);
return (
<div className="ui-choice-field">
<label className="ui-choice-field__control" htmlFor={controlId}>
<input
{...props}
aria-describedby={
[descriptionId, errorId].filter(Boolean).join(" ") || undefined
}
aria-invalid={error ? true : undefined}
id={controlId}
ref={(node) => {
inputRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
}}
type="checkbox"
/>
<span>{label}</span>
</label>
{description ? (
<p className="ui-field__description" id={descriptionId}>
{description}
</p>
) : null}
{error ? (
<p className="ui-field__error" id={errorId}>
{error}
</p>
) : null}
</div>
);
},
);
export type RadioOption = Readonly<{
value: string;
label: string;
description?: string;
disabled?: boolean;
}>;
export type RadioGroupProps = Readonly<{
name: string;
label: string;
options: readonly RadioOption[];
value?: string;
defaultValue?: string;
onChange?(value: string): void;
disabled?: boolean;
error?: string;
}>;
export function RadioGroup({
name,
label,
options,
value,
defaultValue,
onChange,
disabled,
error,
}: RadioGroupProps) {
const [internalValue, setInternalValue] = useState(defaultValue ?? "");
const selected = value ?? internalValue;
const errorId = useId();
const refs = useRef<Array<HTMLInputElement | null>>([]);
function choose(nextValue: string) {
if (value === undefined) setInternalValue(nextValue);
onChange?.(nextValue);
}
return (
<fieldset
aria-describedby={error ? errorId : undefined}
className="ui-radio-group"
disabled={disabled}
>
<legend>{label}</legend>
{options.map((option, index) => (
<label className="ui-choice-field__control" key={option.value}>
<input
checked={selected === option.value}
disabled={option.disabled}
name={name}
onChange={() => choose(option.value)}
onKeyDown={(event) => {
if (!["ArrowDown", "ArrowRight", "ArrowUp", "ArrowLeft"].includes(event.key)) {
return;
}
event.preventDefault();
const direction =
event.key === "ArrowDown" || event.key === "ArrowRight" ? 1 : -1;
let next = index;
do {
next = (next + direction + options.length) % options.length;
} while (options[next]?.disabled && next !== index);
const nextOption = options[next];
if (nextOption && !nextOption.disabled) {
choose(nextOption.value);
refs.current[next]?.focus();
}
}}
ref={(node) => {
refs.current[index] = node;
}}
type="radio"
value={option.value}
/>
<span>
{option.label}
{option.description ? <small>{option.description}</small> : null}
</span>
</label>
))}
{error ? (
<p className="ui-field__error" id={errorId}>
{error}
</p>
) : null}
</fieldset>
);
}
export type SwitchProps = Readonly<{
label: string;
checked: boolean;
onChange(checked: boolean): void;
disabled?: boolean;
description?: string;
}>;
export function Switch({
label,
checked,
onChange,
disabled,
description,
}: SwitchProps) {
const descriptionId = useId();
return (
<div className="ui-switch-field">
<button
aria-checked={checked}
aria-describedby={description ? descriptionId : undefined}
className="ui-switch"
disabled={disabled}
onClick={() => onChange(!checked)}
role="switch"
type="button"
>
<span aria-hidden="true" className="ui-switch__thumb" />
<span>{label}</span>
</button>
{description ? (
<p className="ui-field__description" id={descriptionId}>
{description}
</p>
) : null}
</div>
);
}
export type SearchFieldProps = Omit<TextFieldProps, "type"> &
Readonly<{
clearLabel: string;
onClear(): void;
}>;
export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(
function SearchField(
{
clearLabel,
onClear,
value,
className = "",
id,
label,
description,
error,
required,
...inputProps
},
ref,
) {
return (
<div className={`ui-search-field ${className}`.trim()}>
<SearchIcon />
<Field
description={description}
error={error}
id={id}
label={label}
required={required}
>
{({ controlId, describedBy, invalid }) => (
<input
{...inputProps}
aria-describedby={describedBy}
aria-invalid={invalid || undefined}
className="ui-field__input"
id={controlId}
ref={ref}
type="search"
value={value}
/>
)}
</Field>
{String(value ?? "").length > 0 ? (
<IconButton
accessibleName={clearLabel}
onClick={onClear}
variant="ghost"
>
<CloseIcon />
</IconButton>
) : null}
</div>
);
},
);
@@ -0,0 +1,199 @@
import { useId, useRef, useState } from "react";
import { useLocale } from "../../i18n/index.ts";
import { NextIcon, PreviousIcon } from "../icons/semantic-icons.tsx";
import { IconButton, LinkButton } from "./core.tsx";
export type BreadcrumbItem = Readonly<{
label: string;
href?: string;
}>;
export function Breadcrumbs({
label,
items,
}: Readonly<{ label: string; items: readonly BreadcrumbItem[] }>) {
return (
<nav aria-label={label} className="ui-breadcrumbs">
<ol>
{items.map((item, index) => {
const current = index === items.length - 1;
return (
<li key={`${item.label}-${index}`}>
{item.href && !current ? (
<a href={item.href}>{item.label}</a>
) : (
<span aria-current={current ? "page" : undefined}>
{item.label}
</span>
)}
</li>
);
})}
</ol>
</nav>
);
}
export type TabDefinition = Readonly<{
id: string;
label: string;
panel: React.ReactNode;
disabled?: boolean;
}>;
export function Tabs({
label,
tabs,
value,
defaultValue,
activation = "automatic",
onChange,
}: Readonly<{
label: string;
tabs: readonly TabDefinition[];
value?: string;
defaultValue?: string;
activation?: "automatic" | "manual";
onChange?(id: string): void;
}>) {
const fallback = tabs.find((tab) => !tab.disabled)?.id ?? "";
const [internalValue, setInternalValue] = useState(defaultValue ?? fallback);
const [focusValue, setFocusValue] = useState(value ?? internalValue);
const selected = value ?? internalValue;
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const baseId = useId();
const { direction } = useLocale();
function select(id: string) {
if (value === undefined) setInternalValue(id);
onChange?.(id);
}
function move(currentIndex: number, direction: 1 | -1) {
let next = currentIndex;
do {
next = (next + direction + tabs.length) % tabs.length;
} while (tabs[next]?.disabled && next !== currentIndex);
const nextTab = tabs[next];
if (!nextTab || nextTab.disabled) return;
setFocusValue(nextTab.id);
if (activation === "automatic") select(nextTab.id);
tabRefs.current[next]?.focus();
}
return (
<div className="ui-tabs">
<div aria-label={label} className="ui-tabs__list" role="tablist">
{tabs.map((tab, index) => (
<button
aria-controls={`${baseId}-${tab.id}-panel`}
aria-selected={selected === tab.id}
className="ui-tabs__tab"
disabled={tab.disabled}
id={`${baseId}-${tab.id}-tab`}
key={tab.id}
onClick={() => {
setFocusValue(tab.id);
select(tab.id);
}}
onKeyDown={(event) => {
if (event.key === "ArrowRight") {
event.preventDefault();
move(index, direction === "rtl" ? -1 : 1);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
move(index, direction === "rtl" ? 1 : -1);
} else if (
activation === "manual" &&
(event.key === "Enter" || event.key === " ")
) {
event.preventDefault();
select(tab.id);
}
}}
ref={(node) => {
tabRefs.current[index] = node;
}}
role="tab"
tabIndex={focusValue === tab.id ? 0 : -1}
type="button"
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab) => (
<div
aria-labelledby={`${baseId}-${tab.id}-tab`}
className="ui-tabs__panel"
hidden={selected !== tab.id}
id={`${baseId}-${tab.id}-panel`}
key={tab.id}
role="tabpanel"
tabIndex={0}
>
{tab.panel}
</div>
))}
</div>
);
}
export function Pagination({
label,
page,
pageCount,
previousLabel,
nextLabel,
pageLabel,
onChange,
}: Readonly<{
label: string;
page: number;
pageCount: number;
previousLabel: string;
nextLabel: string;
pageLabel(page: number): string;
onChange(page: number): void;
}>) {
const pages = Array.from({ length: pageCount }, (_, index) => index + 1);
const { direction } = useLocale();
const PreviousDirectionalIcon =
direction === "rtl" ? NextIcon : PreviousIcon;
const NextDirectionalIcon = direction === "rtl" ? PreviousIcon : NextIcon;
return (
<nav aria-label={label} className="ui-pagination">
<IconButton
accessibleName={previousLabel}
disabled={page <= 1}
onClick={() => onChange(page - 1)}
variant="secondary"
>
<PreviousDirectionalIcon />
</IconButton>
{pages.map((candidate) => (
<LinkButton
aria-current={candidate === page ? "page" : undefined}
href={`?page=${candidate}`}
key={candidate}
onClick={(event) => {
event.preventDefault();
onChange(candidate);
}}
variant={candidate === page ? "primary" : "ghost"}
>
{pageLabel(candidate)}
</LinkButton>
))}
<IconButton
accessibleName={nextLabel}
disabled={page >= pageCount}
onClick={() => onChange(page + 1)}
variant="secondary"
>
<NextDirectionalIcon />
</IconButton>
</nav>
);
}
@@ -0,0 +1,447 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { CloseIcon } from "../icons/semantic-icons.tsx";
import { Button, Dialog, IconButton } from "./core.tsx";
import { useLocale } from "../../i18n/index.ts";
export type DrawerProps = Readonly<{
open: boolean;
onClose(): void;
title: string;
closeLabel?: string;
placement?: "start" | "end";
returnFocusRef?: React.RefObject<HTMLElement | null>;
children: React.ReactNode;
}>;
export function Drawer({
open,
onClose,
title,
closeLabel,
placement = "start",
returnFocusRef,
children,
}: DrawerProps) {
return (
<Dialog
className={`ui-drawer ui-drawer--${placement}`}
closeLabel={closeLabel}
onClose={onClose}
open={open}
returnFocusRef={returnFocusRef}
title={title}
>
{children}
</Dialog>
);
}
export type PopoverProps = Readonly<{
triggerLabel: string;
children: React.ReactNode;
}>;
export function Popover({ triggerLabel, children }: PopoverProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const contentId = useId();
useEffect(() => {
if (!open) return;
function closeOutside(event: PointerEvent) {
if (
event.target instanceof Node &&
!rootRef.current?.contains(event.target)
) {
setOpen(false);
triggerRef.current?.focus();
}
}
function closeOnEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
setOpen(false);
triggerRef.current?.focus();
}
}
document.addEventListener("pointerdown", closeOutside);
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("pointerdown", closeOutside);
document.removeEventListener("keydown", closeOnEscape);
};
}, [open]);
return (
<div className="ui-popover" ref={rootRef}>
<Button
aria-controls={contentId}
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
ref={triggerRef}
variant="secondary"
>
{triggerLabel}
</Button>
{open ? (
<div className="ui-popover__content" id={contentId} role="dialog">
{children}
</div>
) : null}
</div>
);
}
export type TooltipProps = Readonly<{
content: string;
children: React.ReactElement;
}>;
export function Tooltip({ content, children }: TooltipProps) {
const tooltipId = useId();
return (
<span className="ui-tooltip">
<span aria-describedby={tooltipId} className="ui-tooltip__trigger">
{children}
</span>
<span className="ui-tooltip__content" id={tooltipId} role="tooltip">
{content}
</span>
</span>
);
}
export type MenuItemDefinition = Readonly<{
id: string;
label: string;
disabled?: boolean;
onSelect(): void;
}>;
export type MenuProps = Readonly<{
triggerLabel: string;
items: readonly MenuItemDefinition[];
}>;
export function Menu({ triggerLabel, items }: MenuProps) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const rootRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const searchRef = useRef("");
const resetSearchRef = useRef<number | undefined>(undefined);
const enabledIndexes = useMemo(
() => items.flatMap((item, index) => (item.disabled ? [] : [index])),
[items],
);
const focusIndex = useCallback(
(index: number) => {
setActiveIndex(index);
queueMicrotask(() => itemRefs.current[index]?.focus());
},
[],
);
function openAt(index: number) {
setOpen(true);
focusIndex(index);
}
function dismiss() {
setOpen(false);
queueMicrotask(() => triggerRef.current?.focus());
}
function move(direction: 1 | -1) {
const currentPosition = Math.max(enabledIndexes.indexOf(activeIndex), 0);
const nextPosition =
(currentPosition + direction + enabledIndexes.length) %
enabledIndexes.length;
const nextIndex = enabledIndexes[nextPosition];
if (nextIndex !== undefined) focusIndex(nextIndex);
}
useEffect(() => {
if (!open) return;
function closeOutside(event: PointerEvent) {
if (
event.target instanceof Node &&
!rootRef.current?.contains(event.target)
) {
setOpen(false);
queueMicrotask(() => triggerRef.current?.focus());
}
}
document.addEventListener("pointerdown", closeOutside);
return () => document.removeEventListener("pointerdown", closeOutside);
}, [open]);
return (
<div className="ui-menu" ref={rootRef}>
<Button
aria-expanded={open}
aria-haspopup="menu"
onClick={() => {
if (open) dismiss();
else openAt(enabledIndexes[0] ?? 0);
}}
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
openAt(enabledIndexes[0] ?? 0);
}
if (event.key === "ArrowUp") {
event.preventDefault();
openAt(enabledIndexes.at(-1) ?? 0);
}
}}
ref={triggerRef}
variant="secondary"
>
{triggerLabel}
</Button>
{open ? (
<div
className="ui-menu__content"
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
dismiss();
} else if (event.key === "ArrowDown") {
event.preventDefault();
move(1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
move(-1);
} else if (event.key === "Home") {
event.preventDefault();
focusIndex(enabledIndexes[0] ?? 0);
} else if (event.key === "End") {
event.preventDefault();
focusIndex(enabledIndexes.at(-1) ?? 0);
} else if (
event.key.length === 1 &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey
) {
searchRef.current += event.key.toLocaleLowerCase();
window.clearTimeout(resetSearchRef.current);
resetSearchRef.current = window.setTimeout(() => {
searchRef.current = "";
}, 500);
const match = items.findIndex(
(item) =>
!item.disabled &&
item.label
.toLocaleLowerCase()
.startsWith(searchRef.current),
);
if (match >= 0) focusIndex(match);
}
}}
role="menu"
>
{items.map((item, index) => (
<button
className="ui-menu__item"
disabled={item.disabled}
key={item.id}
onClick={() => {
item.onSelect();
dismiss();
}}
ref={(node) => {
itemRefs.current[index] = node;
}}
role="menuitem"
tabIndex={index === activeIndex ? 0 : -1}
type="button"
>
{item.label}
</button>
))}
</div>
) : null}
</div>
);
}
type ToastTone = "info" | "success" | "warning" | "danger";
type ToastInput = Readonly<{
id: string;
title: string;
description?: string;
tone?: ToastTone;
durationMs?: number;
}>;
type ToastEntry = ToastInput & Readonly<{ count: number }>;
type ToastContextValue = Readonly<{
push(toast: ToastInput): void;
dismiss(id: string): void;
}>;
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({
children,
limit = 3,
}: Readonly<{ children: React.ReactNode; limit?: number }>) {
const [toasts, setToasts] = useState<readonly ToastEntry[]>([]);
const dismiss = useCallback((id: string) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const push = useCallback(
(toast: ToastInput) => {
setToasts((current) => {
const existing = current.find((entry) => entry.id === toast.id);
if (existing) {
return current.map((entry) =>
entry.id === toast.id
? { ...entry, ...toast, count: entry.count + 1 }
: entry,
);
}
return [...current, { ...toast, count: 1 }].slice(-limit);
});
},
[limit],
);
const value = useMemo(() => ({ push, dismiss }), [dismiss, push]);
return (
<ToastContext.Provider value={value}>
{children}
<ToastRegion dismiss={dismiss} toasts={toasts} />
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error("ToastProvider is required.");
}
return context;
}
function ToastRegion({
toasts,
dismiss,
}: Readonly<{
toasts: readonly ToastEntry[];
dismiss(id: string): void;
}>) {
const { message } = useLocale();
return (
<section
aria-label={message("toast.region")}
aria-live="polite"
className="ui-toast-region"
>
{toasts.map((toast) => (
<ToastItem dismiss={dismiss} key={toast.id} toast={toast} />
))}
</section>
);
}
function ToastItem({
toast,
dismiss,
}: Readonly<{
toast: ToastEntry;
dismiss(id: string): void;
}>) {
const { message } = useLocale();
const [paused, setPaused] = useState(false);
useEffect(() => {
if (paused) return;
const timeout = window.setTimeout(
() => dismiss(toast.id),
toast.durationMs ?? 5000,
);
return () => window.clearTimeout(timeout);
}, [dismiss, paused, toast.durationMs, toast.id, toast.count]);
return (
<article
className={`ui-toast ui-toast--${toast.tone ?? "info"}`}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) setPaused(false);
}}
onFocus={() => setPaused(true)}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<div>
<strong>{toast.title}</strong>
{toast.count > 1 ? (
<span className="ui-toast__count"> ×{toast.count}</span>
) : null}
{toast.description ? <p>{toast.description}</p> : null}
</div>
<IconButton
accessibleName={message("action.closeNamed", { title: toast.title })}
onClick={() => dismiss(toast.id)}
variant="ghost"
>
<CloseIcon />
</IconButton>
</article>
);
}
export function ConfirmationDialog({
open,
title,
description,
confirmLabel,
cancelLabel,
danger = false,
onConfirm,
onCancel,
}: Readonly<{
open: boolean;
title: string;
description: string;
confirmLabel: string;
cancelLabel: string;
danger?: boolean;
onConfirm(): void;
onCancel(): void;
}>) {
return (
<Dialog
actions={
<>
<Button onClick={onCancel} variant="secondary">
{cancelLabel}
</Button>
<Button onClick={onConfirm} variant={danger ? "danger" : "primary"}>
{confirmLabel}
</Button>
</>
}
description={description}
onClose={onCancel}
open={open}
title={title}
/>
);
}
@@ -0,0 +1,26 @@
@theme {
--button-primary-background: var(--color-action);
--button-primary-content: var(--color-on-action);
--button-primary-hover: var(--color-action-hover);
--field-border: var(--color-border-strong);
--field-border-invalid: var(--color-danger);
--dialog-elevation: var(--elevation-dialog);
--navigation-active-background: color-mix(
in oklch,
var(--color-action) 14%,
var(--color-panel)
);
--drawer-width: min(20rem, 88vw);
--toast-width: min(24rem, calc(100vw - 2rem));
}
@media (forced-colors: active) {
:root {
--color-border: CanvasText;
--color-border-strong: CanvasText;
--color-focus: Highlight;
--color-action: LinkText;
--color-danger: MarkText;
--color-danger-surface: Mark;
}
}
@@ -0,0 +1,63 @@
@theme {
--palette-white: oklch(1 0 0);
--palette-slate-50: oklch(0.985 0.003 247);
--palette-slate-100: oklch(0.94 0.01 247);
--palette-slate-300: oklch(0.87 0.015 247);
--palette-slate-500: oklch(0.48 0.025 247);
--palette-slate-800: oklch(0.25 0.025 247);
--palette-slate-950: oklch(0.16 0.02 255);
--palette-blue-500: oklch(0.7 0.14 250);
--palette-blue-600: oklch(0.55 0.18 255);
--palette-blue-700: oklch(0.48 0.2 255);
--palette-red-500: oklch(0.68 0.19 25);
--palette-red-600: oklch(0.55 0.2 25);
--palette-red-700: oklch(0.47 0.2 25);
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-12: 3rem;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-md: 1rem;
--font-size-lg: 1.125rem;
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.7;
--font-weight-regular: 400;
--font-weight-semibold: 650;
--font-weight-bold: 750;
--radius-xs: 0.25rem;
--radius-sm: 0.5rem;
--radius-md: 0.75rem;
--radius-full: 999px;
--shadow-panel: 0 8px 28px rgb(15 23 42 / 8%);
--shadow-popover: 0 14px 40px rgb(15 23 42 / 16%);
--shadow-dialog: 0 24px 70px rgb(15 23 42 / 25%);
--duration-fast: 100ms;
--duration-normal: 180ms;
--duration-slow: 300ms;
--easing-standard: cubic-bezier(0.2, 0, 0, 1);
--easing-emphasized: cubic-bezier(0.2, 0, 0, 1.2);
--size-control-sm: 2.25rem;
--size-control-md: 2.75rem;
--size-touch-target: 2.75rem;
--size-icon-sm: 1rem;
--size-icon-md: 1.25rem;
--size-icon-lg: 1.5rem;
--icon-stroke-default: 2;
--breakpoint-compact: 48rem;
--breakpoint-medium: 64rem;
--breakpoint-wide: 80rem;
--container-content: 72rem;
}
@@ -0,0 +1,83 @@
@theme {
--color-surface: var(--palette-slate-50);
--color-surface-muted: var(--palette-slate-100);
--color-panel: var(--palette-white);
--color-surface-elevated: var(--palette-white);
--color-border: var(--palette-slate-300);
--color-border-strong: var(--palette-slate-500);
--color-content: var(--palette-slate-800);
--color-content-muted: var(--palette-slate-500);
--color-content-inverse: var(--palette-white);
--color-action: var(--palette-blue-600);
--color-action-hover: var(--palette-blue-700);
--color-action-pressed: var(--palette-blue-700);
--color-danger: var(--palette-red-600);
--color-danger-hover: var(--palette-red-700);
--color-on-action: var(--palette-white);
--color-focus: oklch(0.72 0.16 225);
--color-disabled-content: color-mix(in oklch, var(--color-content) 55%, transparent);
--color-disabled-surface: var(--color-surface-muted);
--color-info-content: oklch(0.38 0.16 255);
--color-info-surface: oklch(0.95 0.03 255);
--color-info-border: oklch(0.75 0.08 250);
--color-success-content: oklch(0.35 0.12 155);
--color-success-surface: oklch(0.95 0.04 155);
--color-success-border: oklch(0.72 0.1 155);
--color-warning-content: oklch(0.38 0.12 70);
--color-warning-surface: oklch(0.96 0.05 80);
--color-warning-border: oklch(0.75 0.12 80);
--color-danger-content: oklch(0.42 0.18 25);
--color-danger-surface: oklch(0.96 0.035 25);
--color-danger-border: oklch(0.72 0.12 25);
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif;
--spacing-page: var(--space-6);
--radius-control: var(--radius-sm);
--radius-surface: var(--radius-md);
--radius-modal: var(--radius-md);
--elevation-panel: var(--shadow-panel);
--elevation-popover: var(--shadow-popover);
--elevation-dialog: var(--shadow-dialog);
--layer-base: 0;
--layer-sticky: 30;
--layer-navigation: 50;
--layer-popover: 60;
--layer-modal: 70;
--layer-toast: 80;
--opacity-disabled: 0.55;
--opacity-scrim: 0.55;
--opacity-skeleton: 0.7;
}
:root[data-theme="dark"] {
--color-surface: var(--palette-slate-950);
--color-surface-muted: oklch(0.25 0.025 255);
--color-panel: oklch(0.205 0.022 255);
--color-surface-elevated: oklch(0.235 0.025 255);
--color-border: oklch(0.36 0.025 255);
--color-border-strong: oklch(0.58 0.025 255);
--color-content: oklch(0.94 0.012 255);
--color-content-muted: oklch(0.74 0.025 255);
--color-content-inverse: var(--palette-slate-950);
--color-action: var(--palette-blue-500);
--color-action-hover: oklch(0.79 0.12 245);
--color-action-pressed: oklch(0.83 0.1 245);
--color-danger: var(--palette-red-500);
--color-danger-hover: oklch(0.76 0.16 25);
--color-on-action: var(--palette-slate-950);
--color-focus: oklch(0.82 0.15 220);
--color-disabled-content: color-mix(in oklch, var(--color-content) 55%, transparent);
--color-disabled-surface: var(--color-surface-muted);
--color-info-content: oklch(0.83 0.09 250);
--color-info-surface: oklch(0.27 0.045 255);
--color-info-border: oklch(0.55 0.09 250);
--color-success-content: oklch(0.83 0.1 155);
--color-success-surface: oklch(0.27 0.045 155);
--color-success-border: oklch(0.53 0.1 155);
--color-warning-content: oklch(0.88 0.1 80);
--color-warning-surface: oklch(0.29 0.045 80);
--color-warning-border: oklch(0.58 0.11 80);
--color-danger-content: oklch(0.84 0.11 25);
--color-danger-surface: oklch(0.28 0.055 25);
--color-danger-border: oklch(0.56 0.13 25);
}
@@ -0,0 +1,62 @@
export const REQUIRED_PRIMITIVE_TOKENS = Object.freeze([
"--space-1",
"--space-2",
"--space-4",
"--font-size-sm",
"--line-height-normal",
"--radius-sm",
"--shadow-dialog",
"--duration-fast",
"--easing-standard",
"--size-control-md",
"--size-touch-target",
"--size-icon-md",
"--breakpoint-compact",
] as const);
export const REQUIRED_SEMANTIC_TOKENS = Object.freeze([
"--color-surface",
"--color-surface-muted",
"--color-surface-elevated",
"--color-content",
"--color-content-muted",
"--color-content-inverse",
"--color-border",
"--color-border-strong",
"--color-action",
"--color-action-hover",
"--color-action-pressed",
"--color-danger",
"--color-warning-content",
"--color-success-content",
"--color-info-content",
"--color-focus",
"--color-disabled-content",
"--color-disabled-surface",
"--elevation-panel",
"--layer-navigation",
"--layer-popover",
"--layer-modal",
"--layer-toast",
"--opacity-disabled",
"--opacity-scrim",
"--opacity-skeleton",
] as const);
export const REQUIRED_COMPONENT_TOKENS = Object.freeze([
"--button-primary-background",
"--button-primary-content",
"--button-primary-hover",
"--field-border",
"--field-border-invalid",
"--dialog-elevation",
"--navigation-active-background",
"--drawer-width",
"--toast-width",
] as const);
export const DESIGN_TOKEN_CONTRACT = Object.freeze({
primitive: REQUIRED_PRIMITIVE_TOKENS,
semantic: REQUIRED_SEMANTIC_TOKENS,
component: REQUIRED_COMPONENT_TOKENS,
});
@@ -0,0 +1,79 @@
import { useState } from "react";
import { useLocation } from "react-router-dom";
import { PageHeader } from "../design-system/index.ts";
import { useSession } from "../providers/session-provider.tsx";
export default function AuthExamplePage() {
const location = useLocation();
const { sessionState, beginSignIn, signOut, recover } = useSession();
const [pending, setPending] = useState(false);
const [failed, setFailed] = useState(false);
async function execute(action: () => Promise<unknown>): Promise<void> {
setPending(true);
setFailed(false);
try {
await action();
} catch {
setFailed(true);
} finally {
setPending(false);
}
}
return (
<section className="ui-page">
<PageHeader
eyebrow="예제"
title="인증 연동"
description="스켈레톤은 자격 증명을 소유하지 않고 외부 인증 구현이 연결될 포트와 화면 상태만 제공합니다."
/>
<section className="ui-panel auth-example" aria-labelledby="auth-state-title">
<div>
<h2 id="auth-state-title"> </h2>
<output className="session-status" data-state={sessionState}>
{sessionState}
</output>
</div>
<div className="button-row">
<button
className="ui-button"
type="button"
disabled={pending || sessionState === "integration-failed"}
onClick={() =>
void execute(() =>
beginSignIn(`${location.pathname}${location.search}`),
)
}
>
</button>
<button
className="ui-button ui-button--secondary"
type="button"
disabled={pending || sessionState !== "authenticated"}
onClick={() => void execute(signOut)}
>
</button>
<button
className="ui-button ui-button--secondary"
type="button"
disabled={pending || sessionState !== "recovery-pending"}
onClick={() => void execute(recover)}
>
</button>
</div>
{sessionState === "integration-failed" ? (
<p role="status">
.
.
</p>
) : null}
{failed ? <p role="alert"> .</p> : null}
</section>
</section>
);
}
@@ -0,0 +1,484 @@
import { useEffect, useState } from "react";
import { HTTP_EXECUTION_CEILINGS } from "../../contracts/external-contract-runtime.ts";
import { SERVER_STATE_PROFILES } from "../../contracts/server-state.ts";
import {
COMPOSED_CONTRACT_CONTRIBUTIONS,
EXPECTED_CONTRACT_SET_PACKAGES,
} from "../../features/installed-contract-contributions.ts";
import { ROUTE_REGISTRY } from "../../features/installed-feature-contracts.ts";
import type {
RuntimeCapabilityId,
RuntimeCapabilityStatus,
} from "../../contracts/runtime-capabilities.ts";
import {
Badge,
Card,
DataTable,
EmptySurface,
PageHeader,
type DataTableColumn,
} from "../design-system/index.ts";
import { useApplication } from "../providers/application-provider.tsx";
/**
* Every number and row on this page is read from an installed registry at
* render time. Nothing is transcribed by hand, so deleting a feature removes
* its rows and the page keeps describing what the repository actually is.
*/
function kilobytes(bytes: number): string {
if (bytes === 0) return "없음";
if (bytes >= 1_048_576) return `${bytes / 1_048_576} MiB`;
return `${bytes / 1024} KiB`;
}
function seconds(milliseconds: number): string {
return milliseconds < 1000
? `${milliseconds}ms`
: `${milliseconds / 1000}`;
}
function Metric({
label,
value,
hint,
}: Readonly<{ label: string; value: string; hint?: string }>) {
return (
<div className="platform-metric">
<dt>{label}</dt>
<dd>
<span className="platform-metric__value">{value}</span>
{hint ? <span className="platform-metric__hint">{hint}</span> : null}
</dd>
</div>
);
}
type RouteRow = (typeof ROUTE_REGISTRY)[keyof typeof ROUTE_REGISTRY];
const ROUTE_COLUMNS: readonly DataTableColumn<RouteRow>[] = Object.freeze([
{
id: "routeId",
header: "라우트",
cell: (row) => <code>{row.routeId}</code>,
},
{ id: "path", header: "경로", cell: (row) => <code>{row.path}</code> },
{
id: "access",
header: "접근",
cell: (row) => (
<Badge variant={row.access === "public" ? "success" : "warning"}>
{row.access}
</Badge>
),
},
{
id: "schemas",
header: "입력 스키마",
cell: (row) =>
[row.paramsSchema, row.searchSchema].filter(Boolean).join(" · ") || "없음",
},
{
id: "chunkId",
header: "청크",
cell: (row) => <code>{row.chunkId}</code>,
},
]);
type OperationRow = Readonly<{
operationId: string;
method: string;
pathTemplate: string;
retrySemantics: string;
retryBudget: number;
totalDeadlineMs: number;
requestByteLimit: number;
responseByteLimit: number;
effect: string;
recovery: string;
}>;
const OPERATION_COLUMNS: readonly DataTableColumn<OperationRow>[] =
Object.freeze([
{
id: "operationId",
header: "오퍼레이션",
cell: (row) => (
<>
<code>{row.operationId}</code>
<span className="platform-operation__path">
{row.method} {row.pathTemplate}
</span>
</>
),
},
{
id: "retry",
header: "재시도",
cell: (row) => (
<>
<Badge variant={row.retrySemantics === "SAFE" ? "success" : "warning"}>
{row.retrySemantics}
</Badge>
<span className="platform-operation__path">
{row.retryBudget}
</span>
</>
),
},
{
id: "effect",
header: "효과 확정성",
cell: (row) => row.effect,
},
{
id: "recovery",
header: "복구",
cell: (row) => row.recovery,
},
{
id: "budget",
header: "예산",
cell: (row) => (
<>
<span className="platform-operation__path">
{kilobytes(row.requestByteLimit)} · {" "}
{kilobytes(row.responseByteLimit)}
</span>
<span className="platform-operation__path">
{seconds(row.totalDeadlineMs)}
</span>
</>
),
},
]);
type ProfileRow = (typeof SERVER_STATE_PROFILES)[keyof typeof SERVER_STATE_PROFILES];
const PROFILE_COLUMNS: readonly DataTableColumn<ProfileRow>[] = Object.freeze([
{
id: "profileId",
header: "프로파일",
cell: (row) => <code>{row.profileId}</code>,
},
{ id: "stale", header: "stale", cell: (row) => seconds(row.staleTimeMs) },
{ id: "gc", header: "gc", cell: (row) => seconds(row.gcTimeMs) },
{
id: "refetch",
header: "재조회",
cell: (row) =>
[
row.refetchOnMount === "always"
? "mount(always)"
: row.refetchOnMount && "mount",
row.refetchOnFocus && "focus",
row.refetchOnReconnect && "reconnect",
]
.filter(Boolean)
.join(" · "),
},
{
id: "budget",
header: "결과 예산",
cell: (row) =>
`${row.maxResultItems}건 · ${kilobytes(row.maxEstimatedResultBytes)}`,
},
]);
const CAPABILITY_COPY: Readonly<
Record<RuntimeCapabilityId, Readonly<{ label: string; description: string }>>
> = Object.freeze({
REALTIME: Object.freeze({
label: "실시간 수신",
description:
"WebSocket, SSE, 경계 폴링 런타임은 구현되어 있습니다. 제품 기여물이 엔드포인트와 이벤트 서술자를 제공해야 설치됩니다.",
}),
WEB_WORKER: Object.freeze({
label: "웹 워커",
description:
"워커 실행 계약과 전용 타입 프로젝트가 준비되어 있습니다. 프로파일링으로 확인된 CPU 작업이 있어야 설치됩니다.",
}),
SERVICE_WORKER: Object.freeze({
label: "서비스 워커",
description:
"참조 런타임과 두 단계 빌드가 준비되어 있습니다. 설치하면 검증된 정적 자산 캐시와 등록 해제 경로가 함께 켜집니다.",
}),
OFFLINE_COMMANDS: Object.freeze({
label: "오프라인 명령",
description:
"명령 큐 상태 기계가 준비되어 있습니다. 복구 서술자를 가진 KEYED 오퍼레이션이 있어야 설치됩니다.",
}),
});
/**
* A capability that was never selected and one an operator switched off look
* identical if both are reported as "off". The snapshot separates them, and so
* does this badge.
*/
function capabilityBadge(
status: RuntimeCapabilityStatus,
): Readonly<{ text: string; variant: "success" | "warning" | "neutral" }> {
if (status.selected === 0) return { text: "미선택", variant: "neutral" };
if (status.active === 0) {
return { text: "운영자가 비활성화함", variant: "warning" };
}
return { text: `활성 (${status.active})`, variant: "success" };
}
function buildOperationRows(): readonly OperationRow[] {
return Object.freeze(
[...COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.values()].map(
(installed) => {
const { contract, frontend } = installed;
return Object.freeze({
operationId: contract.operationId,
method: contract.method,
pathTemplate: contract.pathTemplate,
retrySemantics: contract.retrySemantics,
retryBudget: frontend.retryBudget,
totalDeadlineMs: frontend.totalDeadlineMs,
requestByteLimit: frontend.requestByteLimit,
responseByteLimit: frontend.responseByteLimit,
effect:
contract.commandEffect === null
? "해당 없음"
: contract.commandEffect.successEffect,
recovery:
contract.commandRecovery === null
? "해당 없음"
: contract.commandRecovery.mode,
});
},
),
);
}
export default function PlatformOverviewPage() {
const { runtime } = useApplication();
const [release, setRelease] = useState<
Awaited<ReturnType<typeof runtime.getReleaseSummary>> | null
>(null);
useEffect(() => {
let active = true;
void runtime.getReleaseSummary().then((summary) => {
if (active) setRelease(summary);
});
return () => {
active = false;
};
}, [runtime]);
const routes = Object.values(ROUTE_REGISTRY);
const operations = buildOperationRows();
const capabilities = runtime.getCapabilitySnapshot();
const activeCapabilityCount = capabilities.filter(
(status) => status.active > 0,
).length;
const fixtureContributions =
COMPOSED_CONTRACT_CONTRIBUTIONS.contributions.filter(
(contribution) => contribution.source.kind === "TEMPLATE_FIXTURE",
).length;
return (
<section className="ui-page">
<PageHeader
eyebrow="예제"
title="플랫폼 구성"
description="이 화면의 모든 값은 설치된 레지스트리에서 렌더 시점에 읽습니다. 손으로 옮겨 적은 숫자가 없으므로 코드가 바뀌면 이 화면도 함께 바뀝니다."
/>
<section
className="gallery-section"
aria-labelledby="platform-release-title"
>
<header className="gallery-section__header">
<h2 id="platform-release-title"> </h2>
<p>
.
</p>
</header>
<dl className="platform-metric-grid" aria-live="polite">
{release ? (
<>
<Metric label="빌드" value={release.buildId} />
<Metric label="릴리스" value={release.releaseId} />
<Metric
label="설정 스키마"
value={release.configSchemaVersion}
hint={
release.apiContractVersion
? `레거시 계약 ${release.apiContractVersion}`
: "계약 집합 사용"
}
/>
<Metric
label="계약 집합 다이제스트"
value={
release.contractSetDigest
? `${release.contractSetDigest.slice(0, 20)}`
: "없음"
}
hint={
release.contractSetDigest
? "릴리스 매니페스트 V2"
: "릴리스 매니페스트 V1"
}
/>
</>
) : (
<Metric label="상태" value="런타임 정보를 확인하고 있습니다." />
)}
</dl>
</section>
<section
className="gallery-section"
aria-labelledby="platform-summary-title"
>
<header className="gallery-section__header">
<h2 id="platform-summary-title"> </h2>
<p> .</p>
</header>
<dl className="platform-metric-grid">
<Metric label="라우트" value={`${routes.length}`} />
<Metric label="HTTP 오퍼레이션" value={`${operations.length}`} />
<Metric
label="외부 계약 패키지"
value={`${EXPECTED_CONTRACT_SET_PACKAGES.length}`}
hint={`템플릿 픽스처 ${fixtureContributions}`}
/>
<Metric
label="선택적 런타임 능력"
value={`${activeCapabilityCount} / ${capabilities.length}`}
hint="런타임 오버라이드 반영"
/>
</dl>
</section>
<section
className="gallery-section"
aria-labelledby="platform-routes-title"
>
<header className="gallery-section__header">
<h2 id="platform-routes-title"> </h2>
<p>
. , ,
.
</p>
</header>
<DataTable
caption="설치된 라우트 목록"
columns={ROUTE_COLUMNS}
rows={routes}
rowKey={(row) => row.routeId}
empty={
<EmptySurface
title="설치된 라우트가 없습니다."
description="라우트 레지스트리가 비어 있습니다."
/>
}
/>
</section>
<section
className="gallery-section"
aria-labelledby="platform-contracts-title"
>
<header className="gallery-section__header">
<h2 id="platform-contracts-title"> HTTP </h2>
<p>
{EXPECTED_CONTRACT_SET_PACKAGES.length}
. 릿
. .
</p>
</header>
<DataTable
caption="설치된 HTTP 오퍼레이션"
columns={OPERATION_COLUMNS}
rows={operations}
rowKey={(row) => row.operationId}
empty={
<EmptySurface
title="설치된 HTTP 오퍼레이션이 없습니다."
description="계약 기여물을 추가하면 이 표에 나타납니다."
/>
}
/>
</section>
<section
className="gallery-section"
aria-labelledby="platform-server-state-title"
>
<header className="gallery-section__header">
<h2 id="platform-server-state-title"> </h2>
<p>
,
.
</p>
</header>
<DataTable
caption="서버 상태 프로파일"
columns={PROFILE_COLUMNS}
rows={Object.values(SERVER_STATE_PROFILES)}
rowKey={(row) => row.profileId}
empty={<EmptySurface title="프로파일이 없습니다." />}
/>
<dl className="platform-metric-grid">
<Metric
label="응답 상한"
value={kilobytes(HTTP_EXECUTION_CEILINGS.hardResponseBytes)}
hint={`기본 ${kilobytes(HTTP_EXECUTION_CEILINGS.defaultResponseBytes)}`}
/>
<Metric
label="요청 상한"
value={kilobytes(HTTP_EXECUTION_CEILINGS.hardRequestBytes)}
hint={`기본 ${kilobytes(HTTP_EXECUTION_CEILINGS.defaultRequestBytes)}`}
/>
<Metric
label="총 마감 상한"
value={seconds(HTTP_EXECUTION_CEILINGS.hardTotalDeadlineMs)}
hint={`기본 ${seconds(HTTP_EXECUTION_CEILINGS.defaultTotalDeadlineMs)}`}
/>
<Metric
label="재시도 상한"
value={`${HTTP_EXECUTION_CEILINGS.hardRetryCount}`}
hint="전송 실패에만 적용"
/>
</dl>
</section>
<section
className="gallery-section"
aria-labelledby="platform-capabilities-title"
>
<header className="gallery-section__header">
<h2 id="platform-capabilities-title"> </h2>
<p>
.
,
.
, .
</p>
</header>
<div className="component-grid component-grid--two">
{capabilities.map((status) => {
const copy = CAPABILITY_COPY[status.capabilityId];
const badge = capabilityBadge(status);
return (
<Card
key={status.capabilityId}
title={copy.label}
footer={<Badge variant={badge.variant}>{badge.text}</Badge>}
>
<p>{copy.description}</p>
</Card>
);
})}
</div>
</section>
</section>
);
}
@@ -0,0 +1,96 @@
import { useState } from "react";
import { deriveAsyncState } from "../../application/view-models/async-state.ts";
import { createFailure } from "../../contracts/errors.ts";
import {
AsyncSurface,
EmptySurface,
LoadingSurface,
TerminalErrorSurface,
AuthRequiredSurface,
ForbiddenSurface,
NotFoundSurface,
Button,
Card,
PageHeader,
} from "../design-system/index.ts";
export default function StateGalleryPage() {
const [lastAction, setLastAction] = useState(
"상태 화면의 작업을 선택하면 결과가 여기에 표시됩니다.",
);
const refreshingState = deriveAsyncState({
data: ["기존 데이터"],
isFetching: true,
});
return (
<section className="ui-page">
<PageHeader
eyebrow="예제"
title="화면 상태"
description="로딩, 빈 화면, 오류, 인증 필요와 권한 없음 상태가 다음 행동까지 일관되게 안내합니다."
/>
<section className="gallery-section" aria-labelledby="async-states-title">
<header className="gallery-section__header">
<h2 id="async-states-title"> </h2>
<p> .</p>
</header>
<div className="component-grid component-grid--two">
<Card title="초기 로딩">
<LoadingSurface label="예제 데이터를 불러오는 중" />
</Card>
<Card title="백그라운드 갱신">
<AsyncSurface state={refreshingState}>
<div className="state-preview-content"> .</div>
</AsyncSurface>
</Card>
<Card title="빈 화면">
<EmptySurface
title="아직 표시할 항목이 없습니다."
description="첫 항목을 추가하거나 필터를 초기화할 수 있습니다."
action={
<Button onClick={() => setLastAction("빈 화면 작업을 실행했습니다.")}>
</Button>
}
/>
</Card>
<Card title="복구 가능한 오류">
<TerminalErrorSurface
userMessageKey={
createFailure("NETWORK_UNREACHABLE", "EXAMPLE", 0)
.userMessageKey
}
action="retry"
onAction={() => setLastAction("오류 요청을 다시 시도했습니다.")}
/>
</Card>
</div>
</section>
<section className="gallery-section" aria-labelledby="access-states-title">
<header className="gallery-section__header">
<h2 id="access-states-title"> </h2>
<p> .</p>
</header>
<div className="component-grid component-grid--three">
<AuthRequiredSurface
onSignIn={() => setLastAction("로그인 연동 작업을 시작했습니다.")}
/>
<ForbiddenSurface
onNavigate={() => setLastAction("접근 가능한 화면으로 이동합니다.")}
/>
<NotFoundSurface
onNavigate={() => setLastAction("시작 화면으로 이동합니다.")}
/>
</div>
</section>
<output className="gallery-notice" aria-live="polite">
{lastAction}
</output>
</section>
);
}
@@ -0,0 +1,318 @@
import { useState, type FormEvent } from "react";
import {
Alert,
Badge,
Button,
Card,
Checkbox,
Dialog,
Menu,
PageHeader,
ProgressBar,
RadioGroup,
Select,
Switch,
Tabs,
TextArea,
TextField,
ToastProvider,
Tooltip,
useToast,
} from "../design-system/index.ts";
const COLOR_TOKENS = Object.freeze([
["Surface", "--color-surface"],
["Muted surface", "--color-surface-muted"],
["Content", "--color-content"],
["Muted content", "--color-content-muted"],
["Action", "--color-action"],
["Danger", "--color-danger"],
["Focus", "--color-focus"],
]);
export default function UiGalleryPage() {
return (
<ToastProvider>
<UiGalleryContent />
</ToastProvider>
);
}
function UiGalleryContent() {
const toast = useToast();
const [projectName, setProjectName] = useState("");
const [description, setDescription] = useState("");
const [template, setTemplate] = useState("application");
const [reviewed, setReviewed] = useState(false);
const [notifications, setNotifications] = useState(true);
const [density, setDensity] = useState("comfortable");
const [fieldTouched, setFieldTouched] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [notice, setNotice] = useState(
"구성요소를 조작하면 결과가 여기에 표시됩니다.",
);
const [alertVisible, setAlertVisible] = useState(true);
const fieldError =
fieldTouched && projectName.trim().length === 0
? "프로젝트 이름을 입력해 주세요."
: undefined;
function submitExample(event: FormEvent<HTMLFormElement>): void {
event.preventDefault();
setFieldTouched(true);
if (projectName.trim().length === 0) {
setNotice("입력값을 확인해 주세요.");
return;
}
setNotice(`${projectName.trim()}” 입력을 확인했습니다.`);
}
return (
<section className="ui-page">
<PageHeader
eyebrow="예제"
title="UI 구성요소"
description="제품 도메인과 독립적인 공통 컨트롤, 피드백, 표면과 디자인 토큰을 직접 조작할 수 있습니다."
/>
<section className="gallery-section" aria-labelledby="controls-title">
<header className="gallery-section__header">
<h2 id="controls-title"> </h2>
<p>, , .</p>
</header>
<div className="component-grid component-grid--two">
<Card title="버튼" description="의미와 위험도에 따라 변형을 선택합니다.">
<div className="button-row">
<Button onClick={() => setNotice("기본 작업을 실행했습니다.")}>
</Button>
<Button
variant="secondary"
onClick={() => setNotice("보조 작업을 실행했습니다.")}
>
</Button>
<Button
variant="danger"
onClick={() => setNotice("위험 작업 예제를 선택했습니다.")}
>
</Button>
<Button disabled> </Button>
</div>
</Card>
<Card title="입력창" description="레이블과 도움말, 오류가 입력에 연결됩니다.">
<form className="example-form" noValidate onSubmit={submitExample}>
<TextField
label="프로젝트 이름"
description="새 도메인을 연결할 때 사용할 중립적인 예제입니다."
error={fieldError}
value={projectName}
required
onChange={(event) => setProjectName(event.currentTarget.value)}
/>
<Button type="submit"> </Button>
</form>
</Card>
</div>
</section>
<section className="gallery-section" aria-labelledby="form-controls-title">
<header className="gallery-section__header">
<h2 id="form-controls-title"> </h2>
<p>native semantics, · controlled .</p>
</header>
<div className="component-grid component-grid--two">
<Card title="긴 입력과 선택">
<div className="component-stack">
<TextArea
label="설명"
maxLength={120}
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
/>
<Select
label="시작 템플릿"
options={[
{ value: "application", label: "Application" },
{ value: "library", label: "Library" },
]}
value={template}
onChange={(event) => setTemplate(event.currentTarget.value)}
/>
<Checkbox
checked={reviewed}
label="접근성 계약을 확인했습니다."
onChange={(event) => setReviewed(event.currentTarget.checked)}
/>
</div>
</Card>
<Card title="단일 선택과 설정">
<div className="component-stack">
<RadioGroup
label="화면 밀도"
name="density"
onChange={setDensity}
options={[
{ value: "comfortable", label: "여유롭게" },
{ value: "compact", label: "조밀하게" },
]}
value={density}
/>
<Switch
checked={notifications}
description="boolean 설정에만 switch를 사용합니다."
label="알림 사용"
onChange={setNotifications}
/>
<ProgressBar label="설정 준비도" max={4} value={3} />
</div>
</Card>
</div>
</section>
<section className="gallery-section" aria-labelledby="feedback-title">
<header className="gallery-section__header">
<h2 id="feedback-title"> </h2>
<p> , .</p>
</header>
<div className="component-grid component-grid--two">
<Card title="알림과 배지" description="짧은 상태와 문맥형 피드백입니다.">
<div className="component-stack">
{alertVisible ? (
<Alert
title="설정이 저장되었습니다."
variant="success"
onDismiss={() => setAlertVisible(false)}
>
<p> .</p>
</Alert>
) : (
<Button
variant="secondary"
onClick={() => setAlertVisible(true)}
>
</Button>
)}
<div className="badge-row" aria-label="배지 변형">
<Badge></Badge>
<Badge variant="info"></Badge>
<Badge variant="success"></Badge>
<Badge variant="warning"> </Badge>
<Badge variant="danger"></Badge>
</div>
</div>
</Card>
<Card title="모달" description="배경과 키보드 Esc로 닫고 포커스를 복원합니다.">
<div className="button-row">
<Button onClick={() => setDialogOpen(true)}> </Button>
<Menu
items={[
{
id: "inspect",
label: "상태 확인",
onSelect: () => setNotice("메뉴 작업을 실행했습니다."),
},
{
id: "notify",
label: "Toast 표시",
onSelect: () =>
toast.push({
id: "gallery-saved",
title: "예제가 저장되었습니다.",
tone: "success",
}),
},
]}
triggerLabel="작업 메뉴"
/>
<Tooltip content="이 설명은 필수 정보가 아닙니다.">
<Button variant="ghost"></Button>
</Tooltip>
</div>
<Dialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
title="연동 확인"
description="도메인 작업을 실행하기 전 확인 화면의 기본 구조입니다."
actions={
<>
<Button
variant="secondary"
onClick={() => setDialogOpen(false)}
>
</Button>
<Button
onClick={() => {
setNotice("모달의 확인 작업을 실행했습니다.");
setDialogOpen(false);
}}
>
</Button>
</>
}
>
<p> .</p>
</Dialog>
</Card>
</div>
</section>
<section className="gallery-section" aria-labelledby="navigation-title">
<header className="gallery-section__header">
<h2 id="navigation-title"> </h2>
<p> .</p>
</header>
<Tabs
activation="manual"
label="디자인 시스템 계층"
tabs={[
{
id: "tokens",
label: "토큰",
panel: <p> .</p>,
},
{
id: "primitives",
label: "프리미티브",
panel: <p>native semantics와 interaction을 .</p>,
},
{
id: "patterns",
label: "패턴",
panel: <p> .</p>,
},
]}
/>
</section>
<section className="gallery-section" aria-labelledby="tokens-title">
<header className="gallery-section__header">
<h2 id="tokens-title"> </h2>
<p> .</p>
</header>
<div className="token-grid">
{COLOR_TOKENS.map(([label, token]) => (
<article className="token-swatch" key={token}>
<span
className="token-swatch__color"
style={{ backgroundColor: `var(${token})` }}
aria-hidden="true"
/>
<strong>{label}</strong>
<code>{token}</code>
</article>
))}
</div>
</section>
<output className="gallery-notice" aria-live="polite">
{notice}
</output>
</section>
);
}
@@ -0,0 +1,92 @@
import { useId, type FormHTMLAttributes, type ReactNode } from "react";
import { TextField } from "../components/ui/text-field.ts";
import { useLocale } from "../i18n/index.ts";
import type {
FieldErrors,
FieldName,
FormValues,
} from "./form-contracts.ts";
export function Form(
props: FormHTMLAttributes<HTMLFormElement> & Readonly<{ pending?: boolean }>,
) {
const { pending = false, children, ...formProps } = props;
return (
<form {...formProps} noValidate aria-busy={pending || undefined}>
{children}
</form>
);
}
export function FormField(
props: React.ComponentProps<typeof TextField>,
) {
return <TextField {...props} />;
}
export function ErrorSummary<Values extends FormValues>(props: Readonly<{
fieldErrors: FieldErrors<Values>;
formErrors?: readonly string[];
fieldLabels: Readonly<Record<FieldName<Values>, string>>;
fieldId(name: FieldName<Values>): string;
onFocusField?(name: FieldName<Values>): void;
}>) {
const { message } = useLocale();
const {
fieldErrors,
formErrors = [],
fieldLabels,
fieldId,
onFocusField,
} = props;
const headingId = useId();
const entries = Object.entries(fieldErrors) as [
FieldName<Values>,
string,
][];
if (entries.length === 0 && formErrors.length === 0) return null;
return (
<section
className="form-error-summary"
role="alert"
aria-labelledby={headingId}
>
<h2 id={headingId}>{message("form.errorSummary")}</h2>
{entries.length > 0 ? (
<ul>
{entries.map(([name, message]) => (
<li key={name}>
<a
href={`#${fieldId(name)}`}
onClick={(event) => {
if (!onFocusField) return;
event.preventDefault();
onFocusField(name);
}}
>
{fieldLabels[name]}: {message}
</a>
</li>
))}
</ul>
) : null}
{formErrors.map((message) => (
<p key={message}>{message}</p>
))}
</section>
);
}
export function FormActions(props: Readonly<{
children: ReactNode;
sticky?: boolean;
}>) {
return (
<div
className={`form-actions${props.sticky ? " form-actions--sticky" : ""}`}
>
{props.children}
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
import type { Result } from "../../application/result.ts";
import type { AppFailure } from "../../contracts/errors.ts";
import {
formatMessage,
type ParameterlessMessageKey,
} from "../i18n/index.ts";
export type FormValues = Readonly<Record<string, unknown>>;
export type FieldName<Values extends FormValues> = Extract<keyof Values, string>;
export type FieldErrors<Values extends FormValues> = Readonly<
Partial<Record<FieldName<Values>, string>>
>;
export type FormResult<Value> = Result<Value>;
export type FormResultState =
| "idle"
| "success"
| "validation-error"
| "conflict"
| "effect-unknown"
| "unavailable";
export type MappedValidationFailure<Values extends FormValues> = Readonly<{
fieldErrors: FieldErrors<Values>;
formErrors: readonly string[];
}>;
const VALIDATION_COPY = Object.freeze({
REQUIRED: "form.validation.required",
too_small: "form.validation.tooSmall",
too_big: "form.validation.tooBig",
invalid_type: "form.validation.invalidType",
invalid_format: "form.validation.invalidFormat",
invalid_value: "form.validation.invalidValue",
});
type MessageResolver = (key: ParameterlessMessageKey) => string;
const defaultMessage: MessageResolver = (key) => formatMessage("ko-KR", key);
export function validationMessage(
code: string,
message: MessageResolver = defaultMessage,
): string {
return message(
VALIDATION_COPY[code as keyof typeof VALIDATION_COPY] ??
"form.validation.unknown",
);
}
export function mapValidationFailureToFields<Values extends FormValues>(
failure: AppFailure,
allowedFields: readonly FieldName<Values>[],
message: MessageResolver = defaultMessage,
): MappedValidationFailure<Values> {
if (failure.kind !== "VALIDATION_REJECTED") {
return Object.freeze({ fieldErrors: Object.freeze({}), formErrors: [] });
}
const allowed = new Set<string>(allowedFields);
const fieldErrors: Partial<Record<FieldName<Values>, string>> = {};
const formErrors: string[] = [];
const issues = failure.validationIssues ?? [];
if (issues.length === 0) {
formErrors.push(message("form.validation.retry"));
}
for (const issue of issues) {
const field = issue.path.split(".").at(0) ?? "";
if (allowed.has(field)) {
const name = field as FieldName<Values>;
fieldErrors[name] ??= validationMessage(issue.code, message);
} else {
formErrors.push(message("form.validation.unknownField"));
}
}
return Object.freeze({
fieldErrors: Object.freeze(fieldErrors),
formErrors: Object.freeze([...new Set(formErrors)]),
});
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./form-components.tsx";
export * from "./form-contracts.ts";
export * from "./use-app-form.ts";
export * from "./use-dirty-navigation-guard.tsx";
+292
View File
@@ -0,0 +1,292 @@
import {
useCallback,
useId,
useMemo,
useRef,
useState,
type ChangeEvent,
type FormEvent,
} from "react";
import type { ZodType, ZodIssue } from "zod";
import {
useLocale,
type ParameterlessMessageKey,
} from "../i18n/index.ts";
import {
mapValidationFailureToFields,
validationMessage,
type FieldErrors,
type FieldName,
type FormResult,
type FormResultState,
type FormValues,
} from "./form-contracts.ts";
type AppFormOptions<
Values extends FormValues,
Command,
Output,
> = Readonly<{
schema: ZodType<Values>;
defaultValues: Values;
allowedServerFields: readonly FieldName<Values>[];
mapToCommand(values: Values): Command;
submit(command: Command): Promise<FormResult<Output>>;
resetOnSuccess?: boolean;
}>;
export function useAppForm<
Values extends FormValues,
Command,
Output,
>(options: AppFormOptions<Values, Command, Output>) {
const { message } = useLocale();
const {
schema,
defaultValues,
allowedServerFields,
mapToCommand,
submit,
resetOnSuccess = true,
} = options;
const generatedId = useId().replaceAll(":", "");
const formId = `app-form-${generatedId}`;
const [values, setValues] = useState<Values>(defaultValues);
const [initialValues, setInitialValues] = useState<Values>(defaultValues);
const [touched, setTouched] = useState<ReadonlySet<FieldName<Values>>>(
() => new Set(),
);
const [fieldErrors, setFieldErrors] = useState<FieldErrors<Values>>(
() => ({} as FieldErrors<Values>),
);
const [formErrors, setFormErrors] = useState<readonly string[]>([]);
const [pending, setPending] = useState(false);
const [result, setResult] = useState<FormResultState>("idle");
const pendingRef = useRef<Promise<FormResult<Output>> | null>(null);
const unknownSubmissionRef = useRef<Values | null>(null);
const dirty = useMemo(
() => JSON.stringify(values) !== JSON.stringify(initialValues),
[initialValues, values],
);
const fieldId = useCallback(
(name: FieldName<Values>) => `${formId}-${name}`,
[formId],
);
const focusField = useCallback(
(name: FieldName<Values>) => {
const field = document.getElementById(fieldId(name));
if (field instanceof HTMLElement) field.focus();
},
[fieldId],
);
const focusFirstError = useCallback(
(errors: FieldErrors<Values>) => {
const first = allowedServerFields.find((name) => Boolean(errors[name]));
if (first) focusField(first);
},
[allowedServerFields, focusField],
);
const setValue = useCallback(
(name: FieldName<Values>, value: Values[FieldName<Values>]) => {
setValues((current) => ({ ...current, [name]: value }) as Values);
setFieldErrors((current) => {
if (!current[name]) return current;
const next = { ...current };
delete next[name];
return next;
});
setFormErrors([]);
setResult((current) =>
unknownSubmissionRef.current ? current : "idle",
);
},
[],
);
const field = useCallback(
(name: FieldName<Values>) => ({
id: fieldId(name),
name,
value: String(values[name] ?? ""),
onChange(event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
setValue(name, event.currentTarget.value as Values[FieldName<Values>]);
},
onBlur() {
setTouched((current) => new Set(current).add(name));
},
error: fieldErrors[name],
"aria-invalid": fieldErrors[name] ? ("true" as const) : undefined,
}),
[fieldErrors, fieldId, setValue, values],
);
const reset = useCallback(
(nextValues: Values = defaultValues) => {
unknownSubmissionRef.current = null;
setValues(nextValues);
setInitialValues(nextValues);
setTouched(new Set());
setFieldErrors({} as FieldErrors<Values>);
setFormErrors([]);
setResult("idle");
},
[defaultValues],
);
const settleSuccessfulValues = useCallback(
(settledValues: Values) => {
unknownSubmissionRef.current = null;
setFieldErrors({} as FieldErrors<Values>);
setFormErrors([]);
setResult("success");
if (resetOnSuccess) {
setValues(defaultValues);
setInitialValues(defaultValues);
setTouched(new Set());
} else {
setInitialValues(settledValues);
}
},
[defaultValues, resetOnSuccess],
);
const settleApplied = useCallback(() => {
const submittedValues = unknownSubmissionRef.current;
if (!submittedValues) return;
settleSuccessfulValues(submittedValues);
}, [settleSuccessfulValues]);
const settleNotApplied = useCallback(() => {
if (!unknownSubmissionRef.current) return;
unknownSubmissionRef.current = null;
setFieldErrors({} as FieldErrors<Values>);
setFormErrors([]);
setResult("idle");
}, []);
const submitForm = useCallback(
async (event?: FormEvent<HTMLFormElement>): Promise<FormResult<Output> | null> => {
event?.preventDefault();
if (pendingRef.current) return pendingRef.current;
if (unknownSubmissionRef.current) return null;
setFieldErrors({} as FieldErrors<Values>);
setFormErrors([]);
const parsed = await schema.safeParseAsync(values);
if (!parsed.success) {
const errors = issuesToFieldErrors<Values>(
parsed.error.issues,
allowedServerFields,
message,
);
setFieldErrors(errors);
setFormErrors(
parsed.error.issues.some(
(issue) => !allowedServerFields.includes(issue.path[0] as FieldName<Values>),
)
? [message("form.validation.configuration")]
: [],
);
setTouched(new Set(allowedServerFields));
setResult("validation-error");
focusFirstError(errors);
return null;
}
const command = mapToCommand(parsed.data);
setPending(true);
const execution = submit(command);
pendingRef.current = execution;
try {
const outcome = await execution;
if (outcome.ok) {
settleSuccessfulValues(parsed.data);
return outcome;
}
if (outcome.error.kind === "VALIDATION_REJECTED") {
unknownSubmissionRef.current = null;
const mapped = mapValidationFailureToFields<Values>(
outcome.error,
allowedServerFields,
message,
);
setFieldErrors(mapped.fieldErrors);
setFormErrors(mapped.formErrors);
setResult("validation-error");
focusFirstError(mapped.fieldErrors);
} else if (outcome.error.effect === "MAYBE_APPLIED") {
unknownSubmissionRef.current = parsed.data;
setFormErrors([]);
setResult("effect-unknown");
} else if (outcome.error.effect === "APPLIED_CONFIRMED") {
settleSuccessfulValues(parsed.data);
} else if (outcome.error.kind === "CONFLICT") {
unknownSubmissionRef.current = null;
setFormErrors([
message("form.conflict"),
]);
setResult("conflict");
} else {
unknownSubmissionRef.current = null;
setFormErrors([message("form.unavailable")]);
setResult("unavailable");
}
return outcome;
} finally {
pendingRef.current = null;
setPending(false);
}
},
[
allowedServerFields,
focusFirstError,
mapToCommand,
message,
schema,
settleSuccessfulValues,
submit,
values,
],
);
return Object.freeze({
formId,
values,
dirty,
touched,
fieldErrors,
formErrors,
pending,
result,
field,
fieldId,
focusField,
setValue,
submitForm,
reset,
settleApplied,
settleNotApplied,
});
}
function issuesToFieldErrors<Values extends FormValues>(
issues: readonly ZodIssue[],
allowedFields: readonly FieldName<Values>[],
message: (key: ParameterlessMessageKey) => string,
): FieldErrors<Values> {
const allowed = new Set<PropertyKey>(allowedFields);
const errors: Partial<Record<FieldName<Values>, string>> = {};
for (const issue of issues) {
const field = issue.path[0];
if (!allowed.has(field)) continue;
const name = field as FieldName<Values>;
errors[name] ??= validationMessage(issue.code, message);
}
return Object.freeze(errors);
}
@@ -0,0 +1,56 @@
import { useCallback } from "react";
import { useBeforeUnload, useBlocker } from "react-router-dom";
import { Button } from "../components/ui/button.ts";
import { Dialog } from "../components/ui/dialog.ts";
import { useLocale } from "../i18n/index.ts";
export function useDirtyNavigationGuard(when: boolean) {
const blocker = useBlocker(when);
useBeforeUnload(
useCallback(
(event) => {
if (!when) return;
event.preventDefault();
event.returnValue = "";
},
[when],
),
{ capture: true },
);
return Object.freeze({
blocked: blocker.state === "blocked",
stay() {
blocker.reset?.();
},
leave() {
blocker.proceed?.();
},
});
}
export function DirtyNavigationDialog(props: Readonly<{
guard: ReturnType<typeof useDirtyNavigationGuard>;
}>) {
const { message } = useLocale();
return (
<Dialog
open={props.guard.blocked}
onClose={props.guard.stay}
title={message("form.unsaved.title")}
description={message("form.unsaved.description")}
actions={
<>
<Button variant="secondary" onClick={props.guard.stay}>
{message("action.continueEditing")}
</Button>
<Button variant="danger" onClick={props.guard.leave}>
{message("action.discardAndLeave")}
</Button>
</>
}
/>
);
}
+314
View File
@@ -0,0 +1,314 @@
import { INSTALLED_MESSAGE_CATALOGS } from "../../features/installed-feature-messages.ts";
const PLATFORM_KO_MESSAGES = {
"common.unavailable": "요청한 문구를 표시할 수 없습니다.",
"common.processing": "처리 중…",
"common.appName": "Frontend Skeleton",
"common.noDisplayValue": "표시 정보 없음",
"action.retry": "다시 시도",
"action.reauth": "로그인",
"action.navigateSafe": "안전한 화면으로 이동",
"action.reloadOnce": "한 번 새로고침",
"action.contactSupport": "지원 정보 확인",
"action.resolveConflict": "충돌 해결",
"action.confirmMutationApplied": "변경됨으로 확인",
"action.confirmMutationNotApplied": "변경되지 않음으로 확인",
"action.continueEditing": "계속 작성",
"action.discardAndLeave": "변경 버리고 이동",
"action.signIn": "로그인 시작",
"action.recoverSession": "세션 복구",
"action.signOut": "로그아웃",
"action.goHome": "홈으로 이동",
"action.closeNamed": "{title} 닫기",
"action.alertCloseNamed": "{title} 알림 닫기",
"shell.skipToContent": "본문으로 건너뛰기",
"shell.menu": "메뉴",
"shell.closeMenu": "메뉴 닫기",
"shell.sidebar": "사이드바",
"shell.primaryNavigation": "주요 탐색",
"shell.theme": "색상 테마",
"shell.theme.system": "시스템 테마",
"shell.theme.light": "라이트 테마",
"shell.theme.dark": "다크 테마",
"shell.locale": "언어",
"shell.locale.ko": "한국어",
"shell.locale.en": "English",
"shell.locale.pseudo": "Pseudo",
"shell.locale.rtl": "RTL smoke",
"shell.session.authenticated": "인증됨",
"shell.session.unauthenticated": "로그인 전",
"shell.session.recoveryPending": "복구 대기",
"shell.session.integrationFailed": "연동 필요",
"shell.session.actionFailed": "세션 작업을 완료하지 못했습니다.",
"route.APP_HOME.navigation": "홈",
"route.APP_HOME.title": "Clean Architecture Frontend",
"route.EXAMPLES_PLATFORM.navigation": "플랫폼 구성",
"route.EXAMPLES_PLATFORM.title": "플랫폼 구성",
"route.EXAMPLES_UI.navigation": "UI 구성요소",
"route.EXAMPLES_UI.title": "UI 구성요소",
"route.EXAMPLES_STATES.navigation": "화면 상태",
"route.EXAMPLES_STATES.title": "화면 상태",
"route.EXAMPLES_AUTH.navigation": "인증 연동",
"route.EXAMPLES_AUTH.title": "인증 연동",
"route.NOT_FOUND.navigation": "찾을 수 없음",
"route.NOT_FOUND.title": "페이지를 찾을 수 없습니다.",
"async.loading": "불러오는 중",
"async.empty": "표시할 항목이 없습니다.",
"async.refreshing": "최신 정보를 확인하고 있습니다.",
"async.staleDegraded": "기존 정보를 표시하고 있습니다.",
"async.mutationPending": "변경 사항을 저장하고 있습니다.",
"async.mutationEffectUnknown": "변경 결과를 확인할 수 없습니다.",
"async.mutationConflict": "다른 변경과 충돌했습니다.",
"access.auth.eyebrow": "401 · 인증 필요",
"access.auth.title": "로그인이 필요합니다.",
"access.auth.description":
"세션을 시작한 뒤 이전 작업을 안전하게 계속할 수 있습니다.",
"access.forbidden.eyebrow": "403 · 권한 없음",
"access.forbidden.title": "접근할 수 없습니다.",
"access.forbidden.description":
"권한을 확인하거나 접근 가능한 화면으로 이동하세요.",
"access.notFound.eyebrow": "404 · 찾을 수 없음",
"access.notFound.title": "요청한 화면이 없습니다.",
"access.notFound.description":
"주소를 확인하거나 시작 화면으로 돌아가세요.",
"route.loading": "화면을 준비하고 있습니다.",
"route.loadingNamed": "{title} 로딩 중",
"route.failure.title": "화면을 표시하지 못했습니다.",
"route.failure.description":
"잠시 후 다시 시도해 주세요. 문제가 계속되면 운영 지원 참조 정보를 확인하세요.",
"route.invalid.title": "올바르지 않은 주소입니다.",
"route.invalid.description": "주소의 경로 또는 검색 조건을 확인해 주세요.",
"route.invalid.action": "안전한 탐색 링크를 사용해 주세요.",
"route.auth.integration.title": "로그인 연동이 필요합니다.",
"route.auth.integration.description":
"외부 인증 소유자가 연결되면 이 보호 라우트를 사용할 수 있습니다.",
"route.auth.recovering.title": "세션을 복구하고 있습니다.",
"route.auth.required.title": "세션이 필요합니다.",
"route.auth.recovering.description":
"기존 세션 확인을 계속하려면 복구를 실행하세요.",
"route.auth.required.description":
"이 화면은 인증 연동 지점을 확인하기 위한 보호 라우트입니다.",
"route.documentTitle": "{title} · {appName}",
"chunk.checking": "새 릴리스 정보를 확인하고 있습니다.",
"chunk.reloadOnce": "새 버전으로 한 번만 전환합니다.",
"chunk.failure.title": "화면 자산을 복구하지 못했습니다.",
"chunk.failure.description":
"문제가 계속되면 배포 상태와 지원 참조 정보를 확인해 주세요.",
"page.notFound.title": "페이지를 찾을 수 없습니다.",
"page.notFound.description":
"주소를 확인하거나 준비된 시작 화면으로 돌아가세요.",
"template.breadcrumb": "현재 위치",
"template.relatedInformation": "관련 정보",
"template.searchAndFilter": "검색과 필터",
"template.pagination": "페이지 탐색",
"template.summary": "요약 정보",
"template.dangerActions": "위험 작업",
"template.supportReference": "지원 참조: {reference}",
"form.errorSummary": "입력 내용을 확인해 주세요.",
"form.unsaved.title": "저장하지 않은 변경이 있습니다.",
"form.unsaved.description": "이 화면을 떠나면 입력한 내용이 사라집니다.",
"form.validation.required": "필수 입력값입니다.",
"form.validation.tooSmall": "입력값이 너무 짧습니다.",
"form.validation.tooBig": "입력값이 너무 깁니다.",
"form.validation.invalidType": "입력 형식을 확인해 주세요.",
"form.validation.invalidFormat": "입력 형식을 확인해 주세요.",
"form.validation.invalidValue": "허용된 값을 선택해 주세요.",
"form.validation.unknown": "입력값을 확인해 주세요.",
"form.validation.retry": "입력값을 다시 확인해 주세요.",
"form.validation.unknownField":
"서버가 확인하지 못한 입력 항목이 있습니다.",
"form.validation.configuration": "입력 구성을 다시 확인해 주세요.",
"form.conflict":
"다른 변경과 충돌했습니다. 입력은 유지되었으니 최신 상태를 확인해 주세요.",
"form.unavailable": "저장하지 못했습니다. 잠시 후 다시 시도해 주세요.",
"form.remaining": "{count}자 남음",
"toast.region": "알림",
"error.network_unreachable": "네트워크에 연결할 수 없습니다.",
"error.request_timeout": "요청 시간이 초과되었습니다.",
"error.auth_required": "계속하려면 로그인이 필요합니다.",
"error.auth_integration_failure": "로그인 연동을 사용할 수 없습니다.",
"error.forbidden": "이 작업을 수행할 권한이 없습니다.",
"error.not_found": "요청한 항목을 찾을 수 없습니다.",
"error.rate_limited": "요청이 많습니다. 잠시 후 다시 시도해 주세요.",
"error.server_failure": "요청을 완료하지 못했습니다.",
"error.chunk_load_failure": "새 화면 파일을 불러오지 못했습니다.",
"error.build_mismatch": "현재 화면과 활성 빌드가 일치하지 않습니다.",
"error.config_mismatch": "런타임 설정 버전이 현재 화면과 일치하지 않습니다.",
"error.api_contract_mismatch": "API 계약 버전이 현재 화면과 일치하지 않습니다.",
"error.release_mismatch": "현재 화면과 활성 릴리스가 일치하지 않습니다.",
"error.asset_mismatch": "화면 자산 구성이 현재 릴리스와 일치하지 않습니다.",
"error.render_failure": "화면을 표시하지 못했습니다.",
"error.unknown_failure": "예상하지 못한 문제가 발생했습니다.",
"boot.failure.title": "애플리케이션을 시작할 수 없습니다.",
"boot.field.error": "오류",
"boot.field.code": "코드",
"boot.field.build": "빌드",
"boot.field.configSchema": "설정 스키마",
"boot.field.release": "릴리스",
"boot.supportReference": "지원 참조: {reference}",
} as const;
export const KO_MESSAGES = {
...PLATFORM_KO_MESSAGES,
...INSTALLED_MESSAGE_CATALOGS["ko-KR"],
} as const;
export type MessageKey = keyof typeof KO_MESSAGES;
const PLATFORM_EN_MESSAGES = {
"common.unavailable": "This message is unavailable.",
"common.processing": "Processing…",
"common.appName": "Frontend Skeleton",
"common.noDisplayValue": "No display value",
"action.retry": "Try again",
"action.reauth": "Sign in",
"action.navigateSafe": "Go to an available page",
"action.reloadOnce": "Reload once",
"action.contactSupport": "View support information",
"action.resolveConflict": "Resolve conflict",
"action.confirmMutationApplied": "Confirm the change was applied",
"action.confirmMutationNotApplied": "Confirm the change was not applied",
"action.continueEditing": "Continue editing",
"action.discardAndLeave": "Discard and leave",
"action.signIn": "Start sign-in",
"action.recoverSession": "Recover session",
"action.signOut": "Sign out",
"action.goHome": "Go home",
"action.closeNamed": "Close {title}",
"action.alertCloseNamed": "Dismiss {title} alert",
"shell.skipToContent": "Skip to content",
"shell.menu": "Menu",
"shell.closeMenu": "Close menu",
"shell.sidebar": "Sidebar",
"shell.primaryNavigation": "Primary navigation",
"shell.theme": "Color theme",
"shell.theme.system": "System theme",
"shell.theme.light": "Light theme",
"shell.theme.dark": "Dark theme",
"shell.locale": "Language",
"shell.locale.ko": "한국어",
"shell.locale.en": "English",
"shell.locale.pseudo": "Pseudo",
"shell.locale.rtl": "RTL smoke",
"shell.session.authenticated": "Authenticated",
"shell.session.unauthenticated": "Signed out",
"shell.session.recoveryPending": "Recovery pending",
"shell.session.integrationFailed": "Integration required",
"shell.session.actionFailed": "The session action could not be completed.",
"route.APP_HOME.navigation": "Home",
"route.APP_HOME.title": "Clean Architecture Frontend",
"route.EXAMPLES_PLATFORM.navigation": "Platform composition",
"route.EXAMPLES_PLATFORM.title": "Platform composition",
"route.EXAMPLES_UI.navigation": "UI components",
"route.EXAMPLES_UI.title": "UI components",
"route.EXAMPLES_STATES.navigation": "Screen states",
"route.EXAMPLES_STATES.title": "Screen states",
"route.EXAMPLES_AUTH.navigation": "Authentication",
"route.EXAMPLES_AUTH.title": "Authentication",
"route.NOT_FOUND.navigation": "Not found",
"route.NOT_FOUND.title": "Page not found",
"async.loading": "Loading",
"async.empty": "There are no items to display.",
"async.refreshing": "Checking for the latest information.",
"async.staleDegraded": "Showing previously loaded information.",
"async.mutationPending": "Saving changes.",
"async.mutationEffectUnknown": "The result of the change is unknown.",
"async.mutationConflict": "The change conflicts with another update.",
"access.auth.eyebrow": "401 · Authentication required",
"access.auth.title": "Sign-in is required.",
"access.auth.description":
"Start a session to safely continue the previous task.",
"access.forbidden.eyebrow": "403 · Access denied",
"access.forbidden.title": "You cannot access this page.",
"access.forbidden.description":
"Check your permissions or go to an available page.",
"access.notFound.eyebrow": "404 · Not found",
"access.notFound.title": "The requested page does not exist.",
"access.notFound.description": "Check the address or return to the start page.",
"route.loading": "Preparing the page.",
"route.loadingNamed": "Loading {title}",
"route.failure.title": "The page could not be displayed.",
"route.failure.description":
"Try again shortly. If the problem continues, check the support reference.",
"route.invalid.title": "This address is invalid.",
"route.invalid.description": "Check the path and search parameters.",
"route.invalid.action": "Use a safe navigation link.",
"route.auth.integration.title": "Sign-in integration is required.",
"route.auth.integration.description":
"This protected route is available after an external authentication owner is connected.",
"route.auth.recovering.title": "Recovering the session.",
"route.auth.required.title": "A session is required.",
"route.auth.recovering.description":
"Run recovery to continue checking the existing session.",
"route.auth.required.description":
"This protected route demonstrates the authentication integration seam.",
"route.documentTitle": "{title} · {appName}",
"chunk.checking": "Checking the new release information.",
"chunk.reloadOnce": "Switching to the new version once.",
"chunk.failure.title": "The page assets could not be recovered.",
"chunk.failure.description":
"If the problem continues, check deployment status and support information.",
"page.notFound.title": "Page not found.",
"page.notFound.description": "Check the address or return to the start page.",
"template.breadcrumb": "Current location",
"template.relatedInformation": "Related information",
"template.searchAndFilter": "Search and filters",
"template.pagination": "Pagination",
"template.summary": "Summary information",
"template.dangerActions": "Dangerous actions",
"template.supportReference": "Support reference: {reference}",
"form.errorSummary": "Review the entered information.",
"form.unsaved.title": "You have unsaved changes.",
"form.unsaved.description":
"The entered information will be lost if you leave this page.",
"form.validation.required": "This field is required.",
"form.validation.tooSmall": "This value is too short.",
"form.validation.tooBig": "This value is too long.",
"form.validation.invalidType": "Check the input type.",
"form.validation.invalidFormat": "Check the input format.",
"form.validation.invalidValue": "Choose an allowed value.",
"form.validation.unknown": "Check the entered value.",
"form.validation.retry": "Review the entered values.",
"form.validation.unknownField":
"The server reported an unrecognized input field.",
"form.validation.configuration": "Review the input configuration.",
"form.conflict":
"Another update conflicts with this change. Your input has been preserved.",
"form.unavailable": "The change could not be saved. Try again shortly.",
"form.remaining": "{count} characters remaining",
"toast.region": "Notifications",
"error.network_unreachable": "The network is unavailable.",
"error.request_timeout": "The request timed out.",
"error.auth_required": "Sign in to continue.",
"error.auth_integration_failure": "Sign-in integration is unavailable.",
"error.forbidden": "You are not allowed to perform this action.",
"error.not_found": "The requested item could not be found.",
"error.rate_limited": "Too many requests were made. Try again shortly.",
"error.server_failure": "The request could not be completed.",
"error.chunk_load_failure": "The new page assets could not be loaded.",
"error.build_mismatch": "The active build does not match this page.",
"error.config_mismatch": "The runtime configuration does not match this page.",
"error.api_contract_mismatch": "The API contract does not match this page.",
"error.release_mismatch": "The active release does not match this page.",
"error.asset_mismatch": "The page assets do not match this release.",
"error.render_failure": "The page could not be displayed.",
"error.unknown_failure": "An unexpected problem occurred.",
"boot.failure.title": "The application could not start.",
"boot.field.error": "Error",
"boot.field.code": "Code",
"boot.field.build": "Build",
"boot.field.configSchema": "Configuration schema",
"boot.field.release": "Release",
"boot.supportReference": "Support reference: {reference}",
} as const satisfies Record<keyof typeof PLATFORM_KO_MESSAGES, string>;
export const EN_MESSAGES = {
...PLATFORM_EN_MESSAGES,
...INSTALLED_MESSAGE_CATALOGS["en-US"],
} as const satisfies Record<MessageKey, string>;
export const MESSAGE_CATALOGS = Object.freeze({
"ko-KR": KO_MESSAGES,
"en-US": EN_MESSAGES,
"ar-EG": EN_MESSAGES,
});
+96
View File
@@ -0,0 +1,96 @@
import { normalizeLocale, type SupportedLocale } from "./message-contract.ts";
const FORMAT_FALLBACK = "—";
export type DateFormatStyle = "short" | "medium" | "long";
export function formatDate(
locale: SupportedLocale,
value: Date | number,
options: Readonly<{
dateStyle?: DateFormatStyle;
timeZone?: string;
}> = {},
): string {
try {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return FORMAT_FALLBACK;
return new Intl.DateTimeFormat(normalizeLocale(locale), {
dateStyle: options.dateStyle ?? "medium",
timeZone: options.timeZone ?? "UTC",
}).format(date);
} catch {
return FORMAT_FALLBACK;
}
}
export function formatNumber(
locale: SupportedLocale,
value: number,
options: Readonly<{
style?: "decimal" | "percent";
maximumFractionDigits?: number;
}> = {},
): string {
try {
if (!Number.isFinite(value)) return FORMAT_FALLBACK;
return new Intl.NumberFormat(normalizeLocale(locale), options).format(value);
} catch {
return FORMAT_FALLBACK;
}
}
export function formatRelativeTime(
locale: SupportedLocale,
value: number,
unit: Intl.RelativeTimeFormatUnit,
): string {
try {
if (!Number.isFinite(value)) return FORMAT_FALLBACK;
return new Intl.RelativeTimeFormat(normalizeLocale(locale), {
numeric: "auto",
}).format(value, unit);
} catch {
return FORMAT_FALLBACK;
}
}
export function formatList(
locale: SupportedLocale,
values: readonly string[],
): string {
try {
return new Intl.ListFormat(normalizeLocale(locale), {
style: "long",
type: "conjunction",
}).format(values);
} catch {
return values.join(", ");
}
}
export function selectPlural(
locale: SupportedLocale,
value: number,
choices: Readonly<
Partial<Record<Intl.LDMLPluralRule, string>> & { other: string }
>,
): string {
try {
if (!Number.isFinite(value)) return choices.other;
return choices[
new Intl.PluralRules(normalizeLocale(locale)).select(value)
] ?? choices.other;
} catch {
return choices.other;
}
}
export function selectMessage(
value: string,
choices: Readonly<Record<string, string> & { other: string }>,
): string {
return choices[value] ?? choices.other;
}
export { FORMAT_FALLBACK };
+29
View File
@@ -0,0 +1,29 @@
export { LocaleProvider, useLocale } from "./locale-provider.tsx";
export {
catalogKeys,
fallbackMessage,
formatMessage,
localeDirection,
MESSAGE_KEY_ALIASES,
messagePlaceholders,
normalizeLocale,
resolveMessage,
SUPPORTED_LOCALES,
} from "./message-contract.ts";
export type {
MessageArguments,
MessageParameters,
ParameterlessMessageKey,
SupportedLocale,
TextDirection,
} from "./message-contract.ts";
export type { MessageKey } from "./catalog.ts";
export {
FORMAT_FALLBACK,
formatDate,
formatList,
formatNumber,
formatRelativeTime,
selectMessage,
selectPlural,
} from "./formatters.ts";
+118
View File
@@ -0,0 +1,118 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import {
formatDate,
formatList,
formatNumber,
formatRelativeTime,
selectMessage,
selectPlural,
} from "./formatters.ts";
import {
formatMessage,
localeDirection,
normalizeLocale,
resolveMessage,
type MessageArguments,
type SupportedLocale,
type TextDirection,
} from "./message-contract.ts";
import type { MessageKey } from "./catalog.ts";
type LocaleContextValue = Readonly<{
locale: SupportedLocale;
direction: TextDirection;
setLocale(locale: SupportedLocale): void;
message<Key extends MessageKey>(
key: Key,
...args: MessageArguments<Key>
): string;
resolve(key: string): string;
date(
value: Date | number,
options?: Parameters<typeof formatDate>[2],
): string;
number(value: number, options?: Parameters<typeof formatNumber>[2]): string;
relativeTime(
value: number,
unit: Intl.RelativeTimeFormatUnit,
): string;
list(values: readonly string[]): string;
plural(
value: number,
choices: Parameters<typeof selectPlural>[2],
): string;
select(
value: string,
choices: Readonly<Record<string, string> & { other: string }>,
): string;
}>;
const DEFAULT_LOCALE = "ko-KR" as const;
const DEFAULT_CONTEXT: LocaleContextValue = Object.freeze({
locale: DEFAULT_LOCALE,
direction: "ltr",
setLocale() {},
message: (key, ...args) => formatMessage(DEFAULT_LOCALE, key, ...args),
resolve: (key) => resolveMessage(DEFAULT_LOCALE, key),
date: (value, options) => formatDate(DEFAULT_LOCALE, value, options),
number: (value, options) => formatNumber(DEFAULT_LOCALE, value, options),
relativeTime: (value, unit) =>
formatRelativeTime(DEFAULT_LOCALE, value, unit),
list: (values) => formatList(DEFAULT_LOCALE, values),
plural: (value, choices) => selectPlural(DEFAULT_LOCALE, value, choices),
select: (value, choices) => selectMessage(value, choices),
});
const LocaleContext = createContext<LocaleContextValue>(DEFAULT_CONTEXT);
export function LocaleProvider({
children,
initialLocale = "ko-KR",
}: Readonly<{
children: React.ReactNode;
initialLocale?: SupportedLocale;
}>) {
const [locale, setLocaleState] = useState<SupportedLocale>(() =>
normalizeLocale(initialLocale),
);
const direction = localeDirection(locale);
useEffect(() => {
document.documentElement.lang = locale;
document.documentElement.dir = direction;
}, [direction, locale]);
const value = useMemo<LocaleContextValue>(
() => ({
locale,
direction,
setLocale: setLocaleState,
message: (key, ...args) => formatMessage(locale, key, ...args),
resolve: (key) => resolveMessage(locale, key),
date: (dateValue, options) => formatDate(locale, dateValue, options),
number: (numberValue, options) =>
formatNumber(locale, numberValue, options),
relativeTime: (relativeValue, unit) =>
formatRelativeTime(locale, relativeValue, unit),
list: (values) => formatList(locale, values),
plural: (pluralValue, choices) =>
selectPlural(locale, pluralValue, choices),
select: (selectValue, choices) => selectMessage(selectValue, choices),
}),
[direction, locale],
);
return (
<LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
);
}
export function useLocale(): LocaleContextValue {
return useContext(LocaleContext);
}
+135
View File
@@ -0,0 +1,135 @@
import {
EN_MESSAGES,
KO_MESSAGES,
MESSAGE_CATALOGS,
type MessageKey,
} from "./catalog.ts";
export const SUPPORTED_LOCALES = Object.freeze([
"ko-KR",
"en-US",
"en-XA",
"ar-EG",
] as const);
export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
export type TextDirection = "ltr" | "rtl";
export type MessageParameters = Readonly<{
"action.closeNamed": { title: string };
"action.alertCloseNamed": { title: string };
"route.loadingNamed": { title: string };
"route.documentTitle": { title: string; appName: string };
"template.supportReference": { reference: string };
"form.remaining": { count: number };
"boot.supportReference": { reference: string };
}>;
export type ParameterlessMessageKey = Exclude<
MessageKey,
keyof MessageParameters
>;
export type MessageArguments<Key extends MessageKey> =
Key extends keyof MessageParameters
? [parameters: MessageParameters[Key]]
: [parameters?: never];
const PLACEHOLDER_PATTERN = /\{([a-zA-Z][a-zA-Z0-9]*)\}/g;
const FALLBACK_LOCALE = "ko-KR" as const;
export const MESSAGE_KEY_ALIASES = Object.freeze({
"action.login": "action.reauth",
"async.pending": "common.processing",
} as const satisfies Readonly<Record<string, MessageKey>>);
export function normalizeLocale(locale: string): SupportedLocale {
if ((SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
return locale as SupportedLocale;
}
const language = locale.split("-")[0]?.toLowerCase();
if (language === "ko") return "ko-KR";
if (language === "ar") return "ar-EG";
if (language === "en") return "en-US";
return FALLBACK_LOCALE;
}
export function localeDirection(locale: SupportedLocale): TextDirection {
return locale === "ar-EG" ? "rtl" : "ltr";
}
export function messagePlaceholders(template: string): readonly string[] {
return Object.freeze(
[...template.matchAll(PLACEHOLDER_PATTERN)].map((match) => match[1] ?? ""),
);
}
export function resolveMessage(
locale: string,
key: string,
parameters?: Readonly<Record<string, string | number>>,
): string {
const normalized = normalizeLocale(locale);
const sourceLocale: keyof typeof MESSAGE_CATALOGS =
normalized === "en-XA" ? "en-US" : normalized;
const catalog =
MESSAGE_CATALOGS[sourceLocale] ??
MESSAGE_CATALOGS[FALLBACK_LOCALE];
const fallback = KO_MESSAGES["common.unavailable"];
const canonicalKey =
MESSAGE_KEY_ALIASES[key as keyof typeof MESSAGE_KEY_ALIASES] ?? key;
const template = catalog[canonicalKey as MessageKey] ?? fallback;
const placeholders = messagePlaceholders(template);
if (
placeholders.some(
(placeholder) =>
parameters?.[placeholder] === undefined ||
parameters?.[placeholder] === null,
)
) {
return fallback;
}
const formatted = template.replace(
PLACEHOLDER_PATTERN,
(_, placeholder: string) => String(parameters?.[placeholder] ?? ""),
);
return normalized === "en-XA" ? pseudoLocalize(formatted) : formatted;
}
export function formatMessage<Key extends MessageKey>(
locale: string,
key: Key,
...args: MessageArguments<Key>
): string {
return resolveMessage(
locale,
key,
args[0] as Readonly<Record<string, string | number>> | undefined,
);
}
function pseudoLocalize(value: string): string {
const expanded = value.replace(/[A-Za-z]/g, (character) => {
const replacements: Readonly<Record<string, string>> = {
a: "á",
e: "é",
i: "í",
o: "ó",
u: "ú",
A: "Á",
E: "É",
I: "Í",
O: "Ó",
U: "Ú",
};
return replacements[character] ?? character;
});
return `${expanded} ···]`;
}
export function catalogKeys(
locale: "ko-KR" | "en-US" | "ar-EG",
): MessageKey[] {
return Object.keys(MESSAGE_CATALOGS[locale]).sort() as MessageKey[];
}
export function fallbackMessage() {
return EN_MESSAGES["common.unavailable"];
}
+188
View File
@@ -0,0 +1,188 @@
import { useEffect, useRef, useState } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import type { SessionState } from "../../application/ports/in/application-api.ts";
import { normalizeColorSchemePreference } from "../../application/policies/color-scheme.ts";
import {
NAVIGATION_ROUTES,
routePath,
} from "../../features/installed-feature-contracts.ts";
import {
Button,
Drawer,
IconButton,
MenuIcon,
Select,
} from "../design-system/index.ts";
import {
normalizeLocale,
useLocale,
type MessageKey,
} from "../i18n/index.ts";
import { useSession } from "../providers/session-provider.tsx";
import { useTheme } from "../providers/theme-provider.tsx";
const SESSION_MESSAGE_KEYS = Object.freeze({
authenticated: "shell.session.authenticated",
unauthenticated: "shell.session.unauthenticated",
"recovery-pending": "shell.session.recoveryPending",
"integration-failed": "shell.session.integrationFailed",
} satisfies Readonly<Record<SessionState, MessageKey>>);
export function AppShell() {
const location = useLocation();
const { sessionState, beginSignIn, signOut, recover } = useSession();
const { preference, setPreference } = useTheme();
const { locale, setLocale, message } = useLocale();
const [navigationOpen, setNavigationOpen] = useState(false);
const navigationTriggerRef = useRef<HTMLButtonElement | null>(null);
const [sessionActionPending, setSessionActionPending] = useState(false);
const [sessionActionFailed, setSessionActionFailed] = useState(false);
useEffect(() => {
setNavigationOpen(false);
}, [location.pathname]);
async function runSessionAction() {
setSessionActionPending(true);
setSessionActionFailed(false);
try {
if (sessionState === "authenticated") {
await signOut();
} else if (sessionState === "recovery-pending") {
await recover();
} else {
await beginSignIn(
`${location.pathname}${location.search}${location.hash}`,
);
}
} catch {
setSessionActionFailed(true);
} finally {
setSessionActionPending(false);
}
}
const sessionActionLabel =
sessionState === "authenticated"
? message("action.signOut")
: sessionState === "recovery-pending"
? message("action.recoverSession")
: message("action.reauth");
const integrationAvailable = sessionState !== "integration-failed";
return (
<div className="app-shell">
<a className="skip-link" href="#main-content">
{message("shell.skipToContent")}
</a>
<header className="app-shell__header">
<IconButton
accessibleName={message("shell.menu")}
className="app-shell__menu-button"
aria-controls="mobile-primary-navigation"
aria-expanded={navigationOpen}
onClick={() => setNavigationOpen((open) => !open)}
ref={navigationTriggerRef}
variant="secondary"
>
<MenuIcon />
</IconButton>
<NavLink className="app-shell__brand" to={routePath("APP_HOME")}>
{message("common.appName")}
</NavLink>
<div className="app-shell__session">
<Select
className="theme-selector"
id="theme-preference"
label={message("shell.theme")}
options={[
{ value: "system", label: message("shell.theme.system") },
{ value: "light", label: message("shell.theme.light") },
{ value: "dark", label: message("shell.theme.dark") },
]}
value={preference}
onChange={(event) =>
setPreference(normalizeColorSchemePreference(event.currentTarget.value))
}
/>
<Select
className="locale-selector"
id="locale-preference"
label={message("shell.locale")}
options={[
{ value: "ko-KR", label: message("shell.locale.ko") },
{ value: "en-US", label: message("shell.locale.en") },
{ value: "en-XA", label: message("shell.locale.pseudo") },
{ value: "ar-EG", label: message("shell.locale.rtl") },
]}
value={locale}
onChange={(event) =>
setLocale(normalizeLocale(event.currentTarget.value))
}
/>
<span className="session-status" data-state={sessionState}>
{message(SESSION_MESSAGE_KEYS[sessionState])}
</span>
{integrationAvailable ? (
<Button
disabled={sessionActionPending}
onClick={() => void runSessionAction()}
size="compact"
>
{sessionActionPending
? message("common.processing")
: sessionActionLabel}
</Button>
) : null}
</div>
{sessionActionFailed ? (
<p className="app-shell__session-error" role="alert">
{message("shell.session.actionFailed")}
</p>
) : null}
</header>
<aside
className="app-shell__sidebar"
aria-label={message("shell.sidebar")}
>
<PrimaryNavigation id="primary-navigation" />
</aside>
<Drawer
closeLabel={message("shell.closeMenu")}
onClose={() => setNavigationOpen(false)}
open={navigationOpen}
returnFocusRef={navigationTriggerRef}
title={message("shell.menu")}
>
<PrimaryNavigation id="mobile-primary-navigation" />
</Drawer>
<main className="app-shell__content" id="main-content" tabIndex={-1}>
<Outlet />
</main>
</div>
);
}
function PrimaryNavigation({ id }: Readonly<{ id: string }>) {
const { resolve, message } = useLocale();
return (
<nav id={id} aria-label={message("shell.primaryNavigation")}>
<ul className="app-navigation">
{NAVIGATION_ROUTES.map((definition) => (
<li key={definition.routeId}>
<NavLink
className={({ isActive }) =>
`app-navigation__link${isActive ? " is-active" : ""}`
}
end={definition.path === "/"}
to={definition.path}
>
{resolve(`route.${definition.routeId}.navigation`)}
</NavLink>
</li>
))}
</ul>
</nav>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { routePath } from "../../features/installed-feature-contracts.ts";
import { PageHeader } from "../design-system/index.ts";
import { useApplication } from "../providers/application-provider.tsx";
const READINESS_ITEMS = Object.freeze([
{
title: "실행 계약",
description: "런타임 설정, 릴리스 정합성, 오류 경계가 마운트 전에 검증됩니다.",
},
{
title: "교체 가능한 연동",
description: "인증, HTTP, 캐시, 저장소, 텔레메트리가 포트 뒤에 분리되어 있습니다.",
},
{
title: "접근 가능한 화면",
description: "키보드 탐색, 포커스 이동, 반응형 앱 셸의 기본 동작이 준비되어 있습니다.",
},
]);
export default function HomePage() {
const { runtime } = useApplication();
const [release, setRelease] = useState<
Awaited<ReturnType<typeof runtime.getReleaseSummary>> | null
>(null);
useEffect(() => {
let active = true;
void runtime.getReleaseSummary().then((summary) => {
if (active) setRelease(summary);
});
return () => {
active = false;
};
}, [runtime]);
return (
<section className="ui-page">
<PageHeader
eyebrow="프로젝트 시작점"
title="Clean Architecture Frontend"
description="도메인을 추가하기 전에 실행 구조와 범용 사용자 경험을 확인할 수 있는 중립적인 스켈레톤입니다."
/>
<div className="readiness-grid" aria-label="구현 준비 상태">
{READINESS_ITEMS.map((item) => (
<article className="ui-panel" key={item.title}>
<h2>{item.title}</h2>
<p>{item.description}</p>
</article>
))}
</div>
<p className="ui-runtime-summary" aria-live="polite">
{release
? `빌드 ${release.buildId} · 릴리스 ${release.releaseId}`
: "검증된 런타임 정보를 확인하고 있습니다."}
</p>
<section className="ui-panel starter-actions" aria-labelledby="starter-title">
<div>
<h2 id="starter-title"> </h2>
<p>
, ,
.
</p>
</div>
<div className="button-row">
<Link className="ui-button" to={routePath("EXAMPLES_PLATFORM")}>
</Link>
<Link
className="ui-button ui-button--secondary"
to={routePath("EXAMPLES_UI")}
>
UI
</Link>
<Link
className="ui-button ui-button--secondary"
to={routePath("EXAMPLES_STATES")}
>
</Link>
</div>
</section>
</section>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { Link } from "react-router-dom";
import { routePath } from "../../features/installed-feature-contracts.ts";
import { PageHeader } from "../design-system/index.ts";
import { useLocale } from "../i18n/index.ts";
export default function NotFoundPage() {
const { message } = useLocale();
return (
<section className="ui-page">
<PageHeader
title={message("page.notFound.title")}
description={message("page.notFound.description")}
/>
<div>
<Link className="ui-button" to={routePath("APP_HOME")}>
{message("action.goHome")}
</Link>
</div>
</section>
);
}
@@ -0,0 +1,31 @@
import {
createContext,
type ReactNode,
useContext,
} from "react";
import type { ApplicationApi } from "../../application/create-application.ts";
const ApplicationContext = createContext<ApplicationApi | null>(null);
export function ApplicationProvider({
application,
children,
}: Readonly<{
application: ApplicationApi;
children: ReactNode;
}>) {
return (
<ApplicationContext.Provider value={application}>
{children}
</ApplicationContext.Provider>
);
}
export function useApplication(): ApplicationApi {
const application = useContext(ApplicationContext);
if (!application) {
throw new Error("ApplicationProvider is required");
}
return application;
}
@@ -0,0 +1,55 @@
import {
createContext,
useContext,
useMemo,
useSyncExternalStore,
type ReactNode,
} from "react";
import type {
ApplicationApi,
SessionState,
} from "../../application/ports/in/application-api.ts";
import { useApplication } from "./application-provider.tsx";
export type SessionContextValue = Readonly<{
sessionState: SessionState;
beginSignIn: ApplicationApi["session"]["beginSignIn"];
signOut: ApplicationApi["session"]["signOut"];
recover: ApplicationApi["session"]["recover"];
}>;
const SessionContext = createContext<SessionContextValue | null>(null);
export function SessionProvider({
children,
}: Readonly<{ children: ReactNode }>) {
const { session } = useApplication();
const sessionState = useSyncExternalStore(
session.subscribe,
session.getSnapshot,
session.getSnapshot,
);
const value = useMemo<SessionContextValue>(
() =>
Object.freeze({
sessionState,
beginSignIn: session.beginSignIn,
signOut: session.signOut,
recover: session.recover,
}),
[session, sessionState],
);
return (
<SessionContext.Provider value={value}>{children}</SessionContext.Provider>
);
}
export function useSession(): SessionContextValue {
const session = useContext(SessionContext);
if (!session) {
throw new Error("SessionProvider is required");
}
return session;
}
@@ -0,0 +1,80 @@
import {
createContext,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { ColorSchemePreference } from "../../application/ports/in/application-api.ts";
import {
normalizeColorSchemePreference,
resolveColorScheme,
} from "../../application/policies/color-scheme.ts";
import { useApplication } from "./application-provider.tsx";
export type ThemeContextValue = Readonly<{
preference: ColorSchemePreference;
resolvedTheme: "light" | "dark";
setPreference(preference: ColorSchemePreference): void;
}>;
const ThemeContext = createContext<ThemeContextValue | null>(null);
function systemPrefersDark(): boolean {
return (
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-color-scheme: dark)").matches
);
}
export function ThemeProvider({
children,
}: Readonly<{ children: ReactNode }>) {
const { preferences } = useApplication();
const [preference, updatePreference] = useState<ColorSchemePreference>(
preferences.getColorScheme,
);
const [darkSystemTheme, setDarkSystemTheme] = useState(systemPrefersDark);
const resolvedTheme = resolveColorScheme(preference, darkSystemTheme);
useEffect(() => {
if (typeof window.matchMedia !== "function") return undefined;
const query = window.matchMedia("(prefers-color-scheme: dark)");
const update = (event: MediaQueryListEvent) =>
setDarkSystemTheme(event.matches);
setDarkSystemTheme(query.matches);
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
useLayoutEffect(() => {
document.documentElement.dataset.theme = resolvedTheme;
document.documentElement.dataset.themePreference = preference;
document.documentElement.style.colorScheme = resolvedTheme;
}, [preference, resolvedTheme]);
const value = useMemo<ThemeContextValue>(
() =>
Object.freeze({
preference,
resolvedTheme,
setPreference(next: ColorSchemePreference) {
const normalized = normalizeColorSchemePreference(next);
updatePreference(normalized);
preferences.setColorScheme(normalized);
},
}),
[preference, preferences, resolvedTheme],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const theme = useContext(ThemeContext);
if (!theme) throw new Error("ThemeProvider is required");
return theme;
}
+341
View File
@@ -0,0 +1,341 @@
import {
type ReactNode,
Suspense,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
createBrowserRouter,
RouterProvider,
type RouteObject,
useLocation,
useNavigate,
useParams,
useSearchParams,
} from "react-router-dom";
import {
getRoute,
ROUTE_REGISTRY,
} from "../../features/installed-feature-contracts.ts";
import type { RouteDefinition } from "../../contracts/routes.ts";
import {
FeatureBoundary,
RouteBoundary,
} from "../boundaries/render-error-boundary.tsx";
import { ChunkRecoveryBoundary } from "../boundaries/chunk-recovery-boundary.tsx";
import { Button, PageHeader } from "../design-system/index.ts";
import { LocaleProvider, useLocale } from "../i18n/index.ts";
import { AppShell } from "../layouts/app-shell.tsx";
import { useApplication } from "../providers/application-provider.tsx";
import { SessionProvider, useSession } from "../providers/session-provider.tsx";
import { ThemeProvider } from "../providers/theme-provider.tsx";
import {
createRedirectLoopGuard,
decideRouteAccess,
} from "./navigation-policy.ts";
import {
buildRouteUrl,
parseRouteInput,
type RouteId,
} from "./route-codecs.ts";
import { ROUTE_RUNTIME } from "../../features/installed-feature-runtimes.tsx";
import type { ParsedRouteInput } from "./route-contract.ts";
import { RouteInputProvider } from "./route-input.tsx";
function RouteLoadingSurface({ definition }: { definition: RouteDefinition }) {
const { message, resolve } = useLocale();
const title = resolve(`route.${definition.routeId}.title`);
return (
<section
className="ui-page route-loading"
aria-live="polite"
aria-busy="true"
data-surface={definition.loadingSurface}
>
<div className="ui-skeleton" aria-hidden="true" />
<p>{message("route.loading")}</p>
<span className="visually-hidden">
{message("route.loadingNamed", { title })}
</span>
</section>
);
}
function RouteFailureSurface({
definition,
}: {
definition?: RouteDefinition;
}) {
const { message } = useLocale();
return (
<section
className="ui-page"
data-surface={definition?.errorSurface ?? "route-boundary"}
>
<PageHeader
title={message("route.failure.title")}
description={message("route.failure.description")}
/>
</section>
);
}
function InvalidRouteSurface({ code }: { code: string }) {
const { message } = useLocale();
return (
<section className="ui-page" data-surface="invalid-route">
<PageHeader
title={message("route.invalid.title")}
description={message("route.invalid.description")}
/>
<p data-route-error={code}>{message("route.invalid.action")}</p>
</section>
);
}
function RouteLifecycle({
definition,
buildId,
}: {
definition: RouteDefinition;
buildId: string;
}) {
const location = useLocation();
const { message, resolve } = useLocale();
const { diagnostics } = useApplication();
useEffect(() => {
document.title = message("route.documentTitle", {
title: resolve(`route.${definition.routeId}.title`),
appName: message("common.appName"),
});
const main = document.getElementById("main-content");
main?.focus({ preventScroll: true });
try {
if (!navigator.userAgent.toLowerCase().includes("jsdom")) {
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
}
} catch {
// Non-browser test hosts may not implement scrolling.
}
diagnostics.reportRouteChanged({
routeId: definition.routeId,
buildId,
});
}, [
buildId,
definition,
diagnostics,
location.key,
location.pathname,
message,
resolve,
]);
return null;
}
function CanonicalRouteRedirect({
input,
}: {
input: ParsedRouteInput;
}) {
const location = useLocation();
const navigate = useNavigate();
const guard = useRef(createRedirectLoopGuard(3));
useEffect(() => {
if (input.routeId === "NOT_FOUND") return;
const source = `${location.pathname}${location.search}`;
const target = buildRouteUrl(input.routeId, {
params: input.params,
search: input.search,
});
if (source === target) {
guard.current.reset();
} else if (guard.current.allow(source, target)) {
void navigate(target, { replace: true });
}
}, [input, location.pathname, location.search, navigate]);
return null;
}
function ProtectedRoute({
routeId,
children,
}: {
routeId: RouteId;
children: ReactNode;
}) {
const location = useLocation();
const { sessionState, beginSignIn, recover } = useSession();
const { message } = useLocale();
const [pending, setPending] = useState(false);
const [failed, setFailed] = useState(false);
const decision = decideRouteAccess(routeId, sessionState);
async function continueSession() {
setPending(true);
setFailed(false);
try {
if (decision.action === "wait-for-session") {
await recover();
} else {
await beginSignIn(
`${location.pathname}${location.search}${location.hash}`,
);
}
} catch {
setFailed(true);
} finally {
setPending(false);
}
}
if (decision.allowed) return children;
if (sessionState === "integration-failed") {
return (
<section className="ui-page" data-surface="auth-integration-required">
<PageHeader
title={message("route.auth.integration.title")}
description={message("route.auth.integration.description")}
/>
</section>
);
}
const recovering = decision.action === "wait-for-session";
return (
<section className="ui-page" data-surface="authentication-required">
<PageHeader
title={
recovering
? message("route.auth.recovering.title")
: message("route.auth.required.title")
}
description={
recovering
? message("route.auth.recovering.description")
: message("route.auth.required.description")
}
/>
<Button
disabled={pending}
onClick={() => void continueSession()}
>
{pending
? message("common.processing")
: recovering
? message("action.recoverSession")
: message("action.signIn")}
</Button>
{failed ? (
<p className="ui-terminal-error" role="alert">
{message("shell.session.actionFailed")}
</p>
) : null}
</section>
);
}
function RegisteredRoute({
routeId,
buildId,
}: {
routeId: RouteId;
buildId: string;
}) {
const definition = getRoute(routeId);
const runtime = ROUTE_RUNTIME[routeId];
const params = useParams();
const [search] = useSearchParams();
const location = useLocation();
const { diagnostics, recovery } = useApplication();
const parsed = parseRouteInput(routeId, params, search);
if (!parsed.success) return <InvalidRouteSurface code={parsed.code} />;
const content = (
<RouteInputProvider input={parsed.data}>
<CanonicalRouteRedirect input={parsed.data} />
<RouteLifecycle definition={definition} buildId={buildId} />
<Suspense fallback={<RouteLoadingSurface definition={definition} />}>
<ChunkRecoveryBoundary
chunkId={definition.chunkId}
recover={recovery.recoverChunk}
>
<runtime.Component />
</ChunkRecoveryBoundary>
</Suspense>
</RouteInputProvider>
);
const protectedContent =
definition.access === "public" ? (
content
) : (
<ProtectedRoute routeId={routeId}>{content}</ProtectedRoute>
);
const boundaryProps = {
routeId,
buildId,
resetKey: `${location.pathname}${location.search}`,
onRenderFailure: diagnostics.reportRenderFailure,
fallback: <RouteFailureSurface definition={definition} />,
children: protectedContent,
};
return definition.errorSurface === "feature-boundary" ? (
<FeatureBoundary {...boundaryProps} />
) : (
<RouteBoundary {...boundaryProps} />
);
}
function createRegisteredRoutes(buildId: string): RouteObject[] {
const children = Object.values(ROUTE_REGISTRY).map((definition) => {
const routeId = definition.routeId as RouteId;
if (definition.path === "/") {
return {
id: routeId,
index: true,
element: <RegisteredRoute routeId={routeId} buildId={buildId} />,
};
}
return {
id: routeId,
path:
definition.path === "*"
? "*"
: definition.path.replace(/^\//, ""),
element: <RegisteredRoute routeId={routeId} buildId={buildId} />,
};
});
return [
{
id: "APP_SHELL",
path: "/",
element: <AppShell />,
errorElement: <RouteFailureSurface />,
children,
},
];
}
export function AppRouter({
basename = "/",
buildId = "local-build",
}: Readonly<{ basename?: string; buildId?: string }>) {
const router = useMemo(
() =>
createBrowserRouter(createRegisteredRoutes(buildId), {
basename,
}),
[basename, buildId],
);
return (
<LocaleProvider>
<ThemeProvider>
<SessionProvider>
<RouterProvider router={router} />
</SessionProvider>
</ThemeProvider>
</LocaleProvider>
);
}
@@ -0,0 +1,48 @@
import type { SessionState } from "../../application/ports/in/application-api.ts";
import { getRoute } from "../../features/installed-feature-contracts.ts";
export type RouteAccessDecision =
| Readonly<{ allowed: true; action: "none" }>
| Readonly<{
allowed: false;
action: "wait-for-session" | "show-sign-in";
}>;
export function decideRouteAccess(
routeId: string,
sessionState: SessionState,
): RouteAccessDecision {
const route = getRoute(routeId);
if (route.access === "public") return { allowed: true, action: "none" };
if (sessionState === "authenticated") {
return { allowed: true, action: "none" };
}
if (sessionState === "recovery-pending") {
return { allowed: false, action: "wait-for-session" };
}
return { allowed: false, action: "show-sign-in" };
}
export function createRedirectLoopGuard(maxHops: number = 5) {
const visitedPairs = new Set<string>();
let hops = 0;
return Object.freeze({
allow(source: string, target: string): boolean {
const pair = `${source}->${target}`;
if (source === target || visitedPairs.has(pair) || hops >= maxHops) {
return false;
}
visitedPairs.add(pair);
hops += 1;
return true;
},
reset(): void {
visitedPairs.clear();
hops = 0;
},
get hopCount(): number {
return hops;
},
});
}
@@ -0,0 +1,6 @@
import { z } from "zod";
export const PLATFORM_ROUTE_CODECS = {
none: z.object({}).strict(),
NotFoundSplat: z.object({ "*": z.string().optional() }).strict(),
} as const;
+102
View File
@@ -0,0 +1,102 @@
import { getRoute, ROUTE_RUNTIME_CONTRACT } from "../../features/installed-feature-contracts.ts";
import { ROUTE_CODECS } from "../../features/installed-feature-runtimes.tsx";
import type {
ParsedRouteInput,
RouteId,
RouteInputResult,
} from "./route-contract.ts";
export type { ParsedRouteInput, RouteId, RouteInputResult };
function codecById(codecId: string) {
const codec = ROUTE_CODECS[codecId as keyof typeof ROUTE_CODECS];
if (!codec) throw new TypeError(`Unregistered route codec: ${codecId}`);
return codec;
}
export function parseRouteInput(
routeId: RouteId,
rawParams: Readonly<Record<string, string | undefined>>,
rawSearch: URLSearchParams,
): RouteInputResult {
const runtime = ROUTE_RUNTIME_CONTRACT[routeId];
const params = codecById(runtime.paramsCodec).safeParse(rawParams);
if (!params.success) {
return { success: false, code: "ROUTE_PARAMS_INVALID" };
}
const search = codecById(runtime.searchCodec).safeParse(
searchRecord(rawSearch),
);
if (!search.success) {
return { success: false, code: "ROUTE_SEARCH_INVALID" };
}
const parsedParams: Record<string, unknown> = { ...params.data };
const parsedSearch: Record<string, unknown> = { ...search.data };
return {
success: true,
data: Object.freeze({
routeId,
params: Object.freeze(parsedParams),
search: Object.freeze(parsedSearch),
}),
};
}
export function buildRouteUrl(
routeId: RouteId,
input: Readonly<{
params?: Readonly<Record<string, unknown>>;
search?: Readonly<Record<string, unknown>>;
}> = {},
): string {
const definition = getRoute(routeId);
if (definition.path === "*") {
throw new TypeError("The not-found route cannot build a canonical URL");
}
const runtime = ROUTE_RUNTIME_CONTRACT[routeId];
const params = codecById(runtime.paramsCodec).parse(input.params ?? {});
const search = codecById(runtime.searchCodec).parse(input.search ?? {});
const parsedParams: Record<string, unknown> = { ...params };
const parsedSearch: Record<string, unknown> = { ...search };
let path = definition.path;
path = path.replace(
/:([A-Za-z][A-Za-z0-9_]*)|\{([A-Za-z][A-Za-z0-9_]*)\}/g,
(
_token: string,
colonName: string | undefined,
braceName: string | undefined,
) => {
const name = colonName ?? braceName ?? "";
const value = parsedParams[name];
if (typeof value !== "string" && typeof value !== "number") {
throw new TypeError(`Missing route path parameter: ${name}`);
}
return encodeURIComponent(String(value));
},
);
const query = new URLSearchParams();
for (const key of Object.keys(parsedSearch).sort((left, right) =>
left.localeCompare(right),
)) {
const value = parsedSearch[key];
if (value === undefined || value === null) continue;
for (const item of Array.isArray(value) ? value : [value]) {
query.append(key, String(item));
}
}
const serialized = query.toString();
return serialized ? `${path}?${serialized}` : path;
}
function searchRecord(
search: URLSearchParams,
): Readonly<Record<string, string | readonly string[]>> {
const result: Record<string, string | readonly string[]> = {};
for (const key of [...new Set(search.keys())].sort((left, right) =>
left.localeCompare(right),
)) {
const values = search.getAll(key);
result[key] = values.length === 1 ? values[0] : values;
}
return result;
}
+16
View File
@@ -0,0 +1,16 @@
import { ROUTE_RUNTIME_CONTRACT } from "../../features/installed-feature-contracts.ts";
export type RouteId = keyof typeof ROUTE_RUNTIME_CONTRACT;
export type ParsedRouteInput = Readonly<{
routeId: RouteId;
params: Readonly<Record<string, unknown>>;
search: Readonly<Record<string, unknown>>;
}>;
export type RouteInputResult =
| Readonly<{ success: true; data: ParsedRouteInput }>
| Readonly<{
success: false;
code: "ROUTE_PARAMS_INVALID" | "ROUTE_SEARCH_INVALID";
}>;
+29
View File
@@ -0,0 +1,29 @@
import {
createContext,
type ReactNode,
useContext,
} from "react";
import type { ParsedRouteInput } from "./route-contract.ts";
const RouteInputContext = createContext<ParsedRouteInput | null>(null);
export function RouteInputProvider({
input,
children,
}: Readonly<{
input: ParsedRouteInput;
children: ReactNode;
}>) {
return (
<RouteInputContext.Provider value={input}>
{children}
</RouteInputContext.Provider>
);
}
export function useRouteInput(): ParsedRouteInput {
const input = useContext(RouteInputContext);
if (!input) throw new Error("Registered route input is required");
return input;
}
+47
View File
@@ -0,0 +1,47 @@
import {
lazy,
type ComponentType,
type LazyExoticComponent,
} from "react";
import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.ts";
type RouteModule = Readonly<{ default: ComponentType }>;
type RouteRuntime = Readonly<{
moduleId: string;
Component: LazyExoticComponent<ComponentType>;
}>;
function runtime(
routeId: keyof typeof PLATFORM_ROUTE_RUNTIME_CONTRACT,
load: () => Promise<RouteModule>,
): RouteRuntime {
return Object.freeze({
moduleId: PLATFORM_ROUTE_RUNTIME_CONTRACT[routeId].moduleId,
Component: lazy(load),
});
}
export const PLATFORM_ROUTE_RUNTIME = {
APP_HOME: runtime("APP_HOME", () => import("../pages/home-page.tsx")),
EXAMPLES_PLATFORM: runtime(
"EXAMPLES_PLATFORM",
() => import("../examples/platform-overview-page.tsx"),
),
EXAMPLES_UI: runtime(
"EXAMPLES_UI",
() => import("../examples/ui-gallery-page.tsx"),
),
EXAMPLES_STATES: runtime(
"EXAMPLES_STATES",
() => import("../examples/state-gallery-page.tsx"),
),
EXAMPLES_AUTH: runtime(
"EXAMPLES_AUTH",
() => import("../examples/auth-example-page.tsx"),
),
NOT_FOUND: runtime(
"NOT_FOUND",
() => import("../pages/not-found-page.tsx"),
),
} satisfies Record<keyof typeof PLATFORM_ROUTE_RUNTIME_CONTRACT, RouteRuntime>;
+7
View File
@@ -0,0 +1,7 @@
/**
* Untrusted content is rendered as a React text node. HTML interpretation is
* intentionally not offered by this template.
*/
export function SafeText({ value }: Readonly<{ value: unknown }>) {
return <span>{typeof value === "string" ? value : String(value ?? "")}</span>;
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
export * from "./page-templates.tsx";
@@ -0,0 +1,268 @@
import type { ReactNode } from "react";
import { PageHeader } from "../components/page-header.tsx";
import { Button } from "../components/ui/button.ts";
import { useLocale } from "../i18n/index.ts";
export type PageHeading = Readonly<{
title: string;
description?: string;
eyebrow?: string;
}>;
export type PageActionDefinition =
| Readonly<{
kind: "button";
label: string;
onAction(): void;
disabled?: boolean;
variant?: "primary" | "secondary" | "danger" | "ghost";
}>
| Readonly<{
kind: "link";
label: string;
href: string;
variant?: "primary" | "secondary" | "danger" | "ghost";
}>;
export type PageTemplateSlots = Readonly<{
heading: PageHeading;
breadcrumb?: ReactNode;
status?: ReactNode;
actions?: readonly PageActionDefinition[];
notices?: ReactNode;
children?: ReactNode;
aside?: ReactNode;
feedback?: ReactNode;
}>;
export function StandardPage(props: PageTemplateSlots) {
const { message } = useLocale();
return (
<article className="ui-page page-template page-template--standard">
{props.breadcrumb ? (
<nav
className="page-template__breadcrumb"
aria-label={message("template.breadcrumb")}
>
{props.breadcrumb}
</nav>
) : null}
<div className="page-template__heading">
<PageHeader {...props.heading} />
{props.status ? (
<div className="page-template__status">{props.status}</div>
) : null}
{props.actions?.length ? (
<PageActionBar actions={props.actions} />
) : null}
</div>
{props.notices ? (
<div className="page-template__notices">{props.notices}</div>
) : null}
{props.feedback ? (
<div className="page-template__feedback">{props.feedback}</div>
) : null}
<div
className="page-template__layout"
data-has-aside={props.aside ? "true" : "false"}
>
<div className="page-template__content">{props.children}</div>
{props.aside ? (
<aside
className="page-template__aside"
aria-label={message("template.relatedInformation")}
>
{props.aside}
</aside>
) : null}
</div>
</article>
);
}
export function CollectionPage(
props: PageTemplateSlots &
Readonly<{
toolbar?: ReactNode;
activeFilters?: ReactNode;
resultCount?: ReactNode;
bulkActions?: ReactNode;
pagination?: ReactNode;
}>,
) {
const { message } = useLocale();
return (
<StandardPage
{...props}
notices={
<>
{props.notices}
{props.toolbar ? (
<section
className="collection-page__toolbar"
aria-label={message("template.searchAndFilter")}
>
{props.toolbar}
</section>
) : null}
{props.activeFilters ? (
<div className="collection-page__active-filters">
{props.activeFilters}
</div>
) : null}
{props.resultCount ? (
<div className="collection-page__result-count" role="status">
{props.resultCount}
</div>
) : null}
{props.bulkActions ? (
<div className="collection-page__bulk-actions">
{props.bulkActions}
</div>
) : null}
</>
}
>
<div className="collection-page__results">{props.children}</div>
{props.pagination ? (
<nav
className="collection-page__pagination"
aria-label={message("template.pagination")}
>
{props.pagination}
</nav>
) : null}
</StandardPage>
);
}
export function DetailPage(
props: PageTemplateSlots &
Readonly<{
metadata?: ReactNode;
destructiveAction?: ReactNode;
}>,
) {
const { message } = useLocale();
return (
<StandardPage {...props}>
{props.metadata ? (
<section
className="detail-page__metadata"
aria-label={message("template.summary")}
>
{props.metadata}
</section>
) : null}
<div className="detail-page__sections">{props.children}</div>
{props.destructiveAction ? (
<section
className="detail-page__danger"
aria-label={message("template.dangerActions")}
>
{props.destructiveAction}
</section>
) : null}
</StandardPage>
);
}
export function FormPage(
props: PageTemplateSlots &
Readonly<{
errorSummary?: ReactNode;
fields?: ReactNode;
formActions?: ReactNode;
guard?: ReactNode;
}>,
) {
return (
<StandardPage {...props}>
{props.errorSummary ? (
<div className="form-page__error-summary">{props.errorSummary}</div>
) : null}
<div className="form-page__fields">{props.fields ?? props.children}</div>
{props.formActions ? (
<div className="form-page__actions">{props.formActions}</div>
) : null}
{props.guard}
</StandardPage>
);
}
export type StatusPageVariant =
| "unauthenticated"
| "forbidden"
| "not-found"
| "unavailable"
| "offline"
| "maintenance"
| "unexpected";
export function StatusPage(
props: Readonly<{
variant: StatusPageVariant;
heading: PageHeading;
primaryAction?: PageActionDefinition;
secondaryAction?: PageActionDefinition;
supportReference?: string;
}>,
) {
const { message } = useLocale();
return (
<section
className={`ui-page page-template status-page status-page--${props.variant}`}
data-status-variant={props.variant}
>
<PageHeader {...props.heading} />
{props.primaryAction || props.secondaryAction ? (
<PageActionBar
className="status-page__actions"
actions={
[props.primaryAction, props.secondaryAction].filter(
Boolean,
) as PageActionDefinition[]
}
/>
) : null}
{props.supportReference ? (
<p className="status-page__support">
{message("template.supportReference", {
reference: props.supportReference,
})}
</p>
) : null}
</section>
);
}
function PageActionBar(props: Readonly<{
actions: readonly PageActionDefinition[];
className?: string;
}>) {
return (
<div className={props.className ?? "page-template__actions"}>
{props.actions.map((action) =>
action.kind === "link" ? (
<a
className={`ui-button ui-button--${action.variant ?? "primary"}`}
href={action.href}
key={`${action.kind}:${action.label}`}
>
{action.label}
</a>
) : (
<Button
disabled={action.disabled}
key={`${action.kind}:${action.label}`}
onClick={action.onAction}
variant={action.variant}
>
{action.label}
</Button>
),
)}
</div>
);
}