chore: initialize from frontend template 4dc033c
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Alert, type AlertProps } from "../../design-system/primitives/core.tsx";
|
||||
@@ -0,0 +1 @@
|
||||
export { Badge, type BadgeProps } from "../../design-system/primitives/core.tsx";
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
Button,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
} from "../../design-system/primitives/core.tsx";
|
||||
@@ -0,0 +1 @@
|
||||
export { Card, type CardProps } from "../../design-system/primitives/core.tsx";
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)]),
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user