@xemahq/ui-kernel 0.1.12 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/biome-host/host-bridge.d.ts +2 -0
- package/dist/lib/biome-host/host-bridge.d.ts.map +1 -1
- package/dist/lib/biome-host/host-bridge.js.map +1 -1
- package/dist/lib/capabilities/capability-provider.d.ts +15 -0
- package/dist/lib/capabilities/capability-provider.d.ts.map +1 -0
- package/dist/lib/capabilities/capability-provider.js +36 -0
- package/dist/lib/capabilities/capability-provider.js.map +1 -0
- package/dist/lib/capabilities/index.d.ts +4 -0
- package/dist/lib/capabilities/index.d.ts.map +1 -0
- package/dist/lib/capabilities/index.js +20 -0
- package/dist/lib/capabilities/index.js.map +1 -0
- package/dist/lib/capabilities/types.d.ts +18 -0
- package/dist/lib/capabilities/types.d.ts.map +1 -0
- package/dist/lib/capabilities/types.js +3 -0
- package/dist/lib/capabilities/types.js.map +1 -0
- package/dist/lib/capabilities/use-capability.d.ts +18 -0
- package/dist/lib/capabilities/use-capability.d.ts.map +1 -0
- package/dist/lib/capabilities/use-capability.js +21 -0
- package/dist/lib/capabilities/use-capability.js.map +1 -0
- package/dist/ui/chrome/AsyncBoundary.d.ts +22 -0
- package/dist/ui/chrome/AsyncBoundary.d.ts.map +1 -0
- package/dist/ui/chrome/AsyncBoundary.js +23 -0
- package/dist/ui/chrome/AsyncBoundary.js.map +1 -0
- package/dist/ui/chrome/EmptyState.d.ts +34 -0
- package/dist/ui/chrome/EmptyState.d.ts.map +1 -0
- package/dist/ui/chrome/EmptyState.js +27 -0
- package/dist/ui/chrome/EmptyState.js.map +1 -0
- package/dist/ui/chrome/ErrorCard.d.ts +11 -0
- package/dist/ui/chrome/ErrorCard.d.ts.map +1 -0
- package/dist/ui/chrome/ErrorCard.js +21 -0
- package/dist/ui/chrome/ErrorCard.js.map +1 -0
- package/dist/ui/chrome/LoadingState.d.ts +10 -0
- package/dist/ui/chrome/LoadingState.d.ts.map +1 -0
- package/dist/ui/chrome/LoadingState.js +17 -0
- package/dist/ui/chrome/LoadingState.js.map +1 -0
- package/dist/ui/chrome/PageHeader.d.ts +20 -0
- package/dist/ui/chrome/PageHeader.d.ts.map +1 -0
- package/dist/ui/chrome/PageHeader.js +26 -0
- package/dist/ui/chrome/PageHeader.js.map +1 -0
- package/dist/ui/chrome/StateCard.d.ts +24 -0
- package/dist/ui/chrome/StateCard.d.ts.map +1 -0
- package/dist/ui/chrome/StateCard.js +17 -0
- package/dist/ui/chrome/StateCard.js.map +1 -0
- package/dist/ui/cn.d.ts +3 -0
- package/dist/ui/cn.d.ts.map +1 -0
- package/dist/ui/cn.js +18 -0
- package/dist/ui/cn.js.map +1 -0
- package/dist/ui/index.d.ts +33 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +61 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/primitives/alert-dialog.d.ts +21 -0
- package/dist/ui/primitives/alert-dialog.d.ts.map +1 -0
- package/dist/ui/primitives/alert-dialog.js +72 -0
- package/dist/ui/primitives/alert-dialog.js.map +1 -0
- package/dist/ui/primitives/badge.d.ts +10 -0
- package/dist/ui/primitives/badge.d.ts.map +1 -0
- package/dist/ui/primitives/badge.js +60 -0
- package/dist/ui/primitives/badge.js.map +1 -0
- package/dist/ui/primitives/button.d.ts +12 -0
- package/dist/ui/primitives/button.d.ts.map +1 -0
- package/dist/ui/primitives/button.js +71 -0
- package/dist/ui/primitives/button.js.map +1 -0
- package/dist/ui/primitives/card.d.ts +9 -0
- package/dist/ui/primitives/card.d.ts.map +1 -0
- package/dist/ui/primitives/card.js +58 -0
- package/dist/ui/primitives/card.js.map +1 -0
- package/dist/ui/primitives/checkbox.d.ts +5 -0
- package/dist/ui/primitives/checkbox.d.ts.map +1 -0
- package/dist/ui/primitives/checkbox.js +45 -0
- package/dist/ui/primitives/checkbox.js.map +1 -0
- package/dist/ui/primitives/collapsible.d.ts +6 -0
- package/dist/ui/primitives/collapsible.d.ts.map +1 -0
- package/dist/ui/primitives/collapsible.js +44 -0
- package/dist/ui/primitives/collapsible.js.map +1 -0
- package/dist/ui/primitives/dialog.d.ts +22 -0
- package/dist/ui/primitives/dialog.d.ts.map +1 -0
- package/dist/ui/primitives/dialog.js +68 -0
- package/dist/ui/primitives/dialog.js.map +1 -0
- package/dist/ui/primitives/dropdown-menu.d.ts +28 -0
- package/dist/ui/primitives/dropdown-menu.d.ts.map +1 -0
- package/dist/ui/primitives/dropdown-menu.js +83 -0
- package/dist/ui/primitives/dropdown-menu.js.map +1 -0
- package/dist/ui/primitives/input.d.ts +4 -0
- package/dist/ui/primitives/input.d.ts.map +1 -0
- package/dist/ui/primitives/input.js +45 -0
- package/dist/ui/primitives/input.js.map +1 -0
- package/dist/ui/primitives/label.d.ts +6 -0
- package/dist/ui/primitives/label.d.ts.map +1 -0
- package/dist/ui/primitives/label.js +46 -0
- package/dist/ui/primitives/label.js.map +1 -0
- package/dist/ui/primitives/overflow-tabs.d.ts +18 -0
- package/dist/ui/primitives/overflow-tabs.d.ts.map +1 -0
- package/dist/ui/primitives/overflow-tabs.js +84 -0
- package/dist/ui/primitives/overflow-tabs.js.map +1 -0
- package/dist/ui/primitives/popover.d.ts +9 -0
- package/dist/ui/primitives/popover.d.ts.map +1 -0
- package/dist/ui/primitives/popover.js +48 -0
- package/dist/ui/primitives/popover.js.map +1 -0
- package/dist/ui/primitives/radio-group.d.ts +6 -0
- package/dist/ui/primitives/radio-group.d.ts.map +1 -0
- package/dist/ui/primitives/radio-group.js +52 -0
- package/dist/ui/primitives/radio-group.js.map +1 -0
- package/dist/ui/primitives/resizable.d.ts +12 -0
- package/dist/ui/primitives/resizable.d.ts.map +1 -0
- package/dist/ui/primitives/resizable.js +18 -0
- package/dist/ui/primitives/resizable.js.map +1 -0
- package/dist/ui/primitives/scroll-area.d.ts +6 -0
- package/dist/ui/primitives/scroll-area.d.ts.map +1 -0
- package/dist/ui/primitives/scroll-area.js +47 -0
- package/dist/ui/primitives/scroll-area.js.map +1 -0
- package/dist/ui/primitives/select.d.ts +14 -0
- package/dist/ui/primitives/select.d.ts.map +1 -0
- package/dist/ui/primitives/select.js +71 -0
- package/dist/ui/primitives/select.js.map +1 -0
- package/dist/ui/primitives/separator.d.ts +5 -0
- package/dist/ui/primitives/separator.d.ts.map +1 -0
- package/dist/ui/primitives/separator.js +44 -0
- package/dist/ui/primitives/separator.js.map +1 -0
- package/dist/ui/primitives/sheet.d.ts +26 -0
- package/dist/ui/primitives/sheet.d.ts.map +1 -0
- package/dist/ui/primitives/sheet.js +82 -0
- package/dist/ui/primitives/sheet.js.map +1 -0
- package/dist/ui/primitives/skeleton.d.ts +13 -0
- package/dist/ui/primitives/skeleton.d.ts.map +1 -0
- package/dist/ui/primitives/skeleton.js +29 -0
- package/dist/ui/primitives/skeleton.js.map +1 -0
- package/dist/ui/primitives/switch.d.ts +5 -0
- package/dist/ui/primitives/switch.d.ts.map +1 -0
- package/dist/ui/primitives/switch.js +44 -0
- package/dist/ui/primitives/switch.js.map +1 -0
- package/dist/ui/primitives/table.d.ts +11 -0
- package/dist/ui/primitives/table.d.ts.map +1 -0
- package/dist/ui/primitives/table.js +64 -0
- package/dist/ui/primitives/table.js.map +1 -0
- package/dist/ui/primitives/tabs.d.ts +8 -0
- package/dist/ui/primitives/tabs.d.ts.map +1 -0
- package/dist/ui/primitives/tabs.js +52 -0
- package/dist/ui/primitives/tabs.js.map +1 -0
- package/dist/ui/primitives/tag-multi-select.d.ts +19 -0
- package/dist/ui/primitives/tag-multi-select.d.ts.map +1 -0
- package/dist/ui/primitives/tag-multi-select.js +92 -0
- package/dist/ui/primitives/tag-multi-select.js.map +1 -0
- package/dist/ui/primitives/textarea.d.ts +5 -0
- package/dist/ui/primitives/textarea.d.ts.map +1 -0
- package/dist/ui/primitives/textarea.js +45 -0
- package/dist/ui/primitives/textarea.js.map +1 -0
- package/dist/ui/primitives/tooltip.d.ts +8 -0
- package/dist/ui/primitives/tooltip.d.ts.map +1 -0
- package/dist/ui/primitives/tooltip.js +50 -0
- package/dist/ui/primitives/tooltip.js.map +1 -0
- package/package.json +24 -1
- package/src/index.ts +1 -0
- package/src/lib/biome-host/host-bridge.ts +10 -0
- package/src/lib/capabilities/capability-provider.tsx +95 -0
- package/src/lib/capabilities/index.ts +16 -0
- package/src/lib/capabilities/types.ts +69 -0
- package/src/lib/capabilities/use-capability.ts +72 -0
- package/src/ui/chrome/AsyncBoundary.tsx +66 -0
- package/src/ui/chrome/EmptyState.tsx +184 -0
- package/src/ui/chrome/ErrorCard.tsx +68 -0
- package/src/ui/chrome/LoadingState.tsx +61 -0
- package/src/ui/chrome/PageHeader.tsx +137 -0
- package/src/ui/chrome/StateCard.tsx +150 -0
- package/src/ui/cn.ts +32 -0
- package/src/ui/index.ts +53 -0
- package/src/ui/primitives/alert-dialog.tsx +104 -0
- package/src/ui/primitives/badge.tsx +32 -0
- package/src/ui/primitives/button.tsx +47 -0
- package/src/ui/primitives/card.tsx +43 -0
- package/src/ui/primitives/checkbox.tsx +26 -0
- package/src/ui/primitives/collapsible.tsx +9 -0
- package/src/ui/primitives/dialog.tsx +103 -0
- package/src/ui/primitives/dropdown-menu.tsx +179 -0
- package/src/ui/primitives/input.tsx +22 -0
- package/src/ui/primitives/label.tsx +17 -0
- package/src/ui/primitives/overflow-tabs.tsx +281 -0
- package/src/ui/primitives/popover.tsx +33 -0
- package/src/ui/primitives/radio-group.tsx +36 -0
- package/src/ui/primitives/resizable.tsx +67 -0
- package/src/ui/primitives/scroll-area.tsx +38 -0
- package/src/ui/primitives/select.tsx +143 -0
- package/src/ui/primitives/separator.tsx +20 -0
- package/src/ui/primitives/sheet.tsx +107 -0
- package/src/ui/primitives/skeleton.tsx +99 -0
- package/src/ui/primitives/switch.tsx +27 -0
- package/src/ui/primitives/table.tsx +72 -0
- package/src/ui/primitives/tabs.tsx +53 -0
- package/src/ui/primitives/tag-multi-select.tsx +241 -0
- package/src/ui/primitives/textarea.tsx +21 -0
- package/src/ui/primitives/tooltip.tsx +30 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ExecutionEnvironmentKind } from '@xemahq/kernel-contracts/execution-environment';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One server-authoritative capability the caller holds, mirroring the
|
|
5
|
+
* FROZEN `GET /bff/me/capabilities` wire entry.
|
|
6
|
+
*
|
|
7
|
+
* These types are defined LOCALLY in the kernel — deliberately NOT imported
|
|
8
|
+
* from a backend DTO or an Orval client — so `@xemahq/ui-kernel` stays
|
|
9
|
+
* host-agnostic (no fetch lib, no generated client). The concrete host
|
|
10
|
+
* (`submodules/xema-host-web`) maps its client response onto this shape when
|
|
11
|
+
* it implements {@link CapabilityPort}.
|
|
12
|
+
*/
|
|
13
|
+
export interface CapabilityEntry {
|
|
14
|
+
/**
|
|
15
|
+
* The capability reference (e.g. `capability:session.launch`). Matched
|
|
16
|
+
* verbatim by {@link useCapability}; the kernel does not parse it.
|
|
17
|
+
*/
|
|
18
|
+
readonly ref: string;
|
|
19
|
+
/**
|
|
20
|
+
* The execution environments in which the caller holds this capability.
|
|
21
|
+
* A gate that names an `environment` is allowed only if it appears here.
|
|
22
|
+
*/
|
|
23
|
+
readonly environments: ExecutionEnvironmentKind[];
|
|
24
|
+
/**
|
|
25
|
+
* When `true`, holding the capability still requires a human approval step
|
|
26
|
+
* before the side-effect runs — so a UI affordance should route through the
|
|
27
|
+
* approval flow rather than acting directly.
|
|
28
|
+
*/
|
|
29
|
+
readonly approvalRequired: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The caller's effective capability set for one active org, mirroring the
|
|
34
|
+
* FROZEN `MeCapabilitiesResponse` wire shape.
|
|
35
|
+
*/
|
|
36
|
+
export interface MeCapabilitiesSnapshot {
|
|
37
|
+
/** The subject (user) the snapshot was resolved for. */
|
|
38
|
+
readonly subjectId: string;
|
|
39
|
+
/** The org the snapshot is scoped to. */
|
|
40
|
+
readonly orgId: string;
|
|
41
|
+
/** Every capability the subject effectively holds in this org. */
|
|
42
|
+
readonly capabilities: CapabilityEntry[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Host-implemented port that fetches the caller's effective capability set.
|
|
47
|
+
*
|
|
48
|
+
* The kernel never calls an API directly — the HOST implements this against
|
|
49
|
+
* its Orval client (`GET /bff/me/capabilities`) and maps the response onto
|
|
50
|
+
* {@link MeCapabilitiesSnapshot}. The implementation is hung off
|
|
51
|
+
* {@link HostBridge.capabilities}; {@link CapabilityProvider} reads it from
|
|
52
|
+
* the bridge and uses the bridge's `QueryClient` for caching/refetch.
|
|
53
|
+
*/
|
|
54
|
+
export interface CapabilityPort {
|
|
55
|
+
/**
|
|
56
|
+
* Return the caller's effective capability set for the active org.
|
|
57
|
+
*
|
|
58
|
+
* @param options.orgId The org to resolve for. When omitted the host
|
|
59
|
+
* resolves against its currently-selected org.
|
|
60
|
+
* @param options.environment Optional environment hint the host MAY pass to
|
|
61
|
+
* the backend to narrow the set. Callers gate by environment client-side
|
|
62
|
+
* via {@link useCapability}, so a host MAY ignore this and return the full
|
|
63
|
+
* set; it exists so a host can request a pre-filtered snapshot.
|
|
64
|
+
*/
|
|
65
|
+
list(options?: {
|
|
66
|
+
orgId?: string;
|
|
67
|
+
environment?: ExecutionEnvironmentKind;
|
|
68
|
+
}): Promise<MeCapabilitiesSnapshot>;
|
|
69
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ExecutionEnvironmentKind } from '@xemahq/kernel-contracts/execution-environment';
|
|
2
|
+
|
|
3
|
+
import { useCapabilityContext, type CapabilityContextValue } from './capability-provider';
|
|
4
|
+
import type { MeCapabilitiesSnapshot } from './types';
|
|
5
|
+
|
|
6
|
+
export interface UseCapabilityOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Gate against a specific execution environment. When set, the capability
|
|
9
|
+
* counts only if the snapshot lists it for this environment.
|
|
10
|
+
*/
|
|
11
|
+
readonly environment?: ExecutionEnvironmentKind;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CapabilityDecision {
|
|
15
|
+
/**
|
|
16
|
+
* `true` only when the caller holds the capability, holds it in the named
|
|
17
|
+
* environment (if one was given), AND it does NOT require approval. A gate
|
|
18
|
+
* that should let the user act directly checks this.
|
|
19
|
+
*/
|
|
20
|
+
readonly allowed: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* `true` when the caller holds the capability (in the named environment, if
|
|
23
|
+
* given) but it requires a human approval step first — render the
|
|
24
|
+
* approval-routed affordance instead of acting directly.
|
|
25
|
+
*/
|
|
26
|
+
readonly approvalRequired: boolean;
|
|
27
|
+
/** The snapshot is still being fetched; treat as "unknown", not "denied". */
|
|
28
|
+
readonly isLoading: boolean;
|
|
29
|
+
/** The fetch failed; surfaced so callers never conflate failure with denial. */
|
|
30
|
+
readonly error: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Server-authoritative capability gate.
|
|
35
|
+
*
|
|
36
|
+
* Resolves `ref` against the cached {@link CapabilityProvider} snapshot:
|
|
37
|
+
* - `allowed` = entry exists AND (no environment asked OR listed for it) AND NOT approvalRequired.
|
|
38
|
+
* - `approvalRequired` = entry exists AND (no environment asked OR listed for it) AND approvalRequired.
|
|
39
|
+
*
|
|
40
|
+
* While the snapshot is loading, `allowed` is `false` and `isLoading` is
|
|
41
|
+
* `true` — callers gate on `isLoading` to distinguish "unknown" from "denied".
|
|
42
|
+
* On error, `allowed` is `false` and `error` is set; the hook never silently
|
|
43
|
+
* degrades a failed fetch into a confident denial.
|
|
44
|
+
*/
|
|
45
|
+
export function useCapability(ref: string, opts?: UseCapabilityOptions): CapabilityDecision {
|
|
46
|
+
const { snapshot, isLoading, error } = useCapabilityContext();
|
|
47
|
+
|
|
48
|
+
const entry = snapshot?.capabilities.find((c) => c.ref === ref);
|
|
49
|
+
const inEnvironment =
|
|
50
|
+
!!entry && (!opts?.environment || entry.environments.includes(opts.environment));
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
allowed: inEnvironment && !entry.approvalRequired,
|
|
54
|
+
approvalRequired: inEnvironment && entry.approvalRequired,
|
|
55
|
+
isLoading,
|
|
56
|
+
error,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Accessor for the raw capability snapshot plus its loading/error state.
|
|
62
|
+
* Use when a component needs to enumerate capabilities rather than gate on a
|
|
63
|
+
* single `ref`.
|
|
64
|
+
*/
|
|
65
|
+
export function useCapabilities(): {
|
|
66
|
+
snapshot: MeCapabilitiesSnapshot | undefined;
|
|
67
|
+
isLoading: boolean;
|
|
68
|
+
error: unknown;
|
|
69
|
+
} {
|
|
70
|
+
const ctx: CapabilityContextValue = useCapabilityContext();
|
|
71
|
+
return { snapshot: ctx.snapshot, isLoading: ctx.isLoading, error: ctx.error };
|
|
72
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { LucideIcon } from 'lucide-react';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import ErrorCard, { type ErrorMessageFormatter } from './ErrorCard';
|
|
5
|
+
import EmptyState from './EmptyState';
|
|
6
|
+
import LoadingState, { type LoadingStateVariant } from './LoadingState';
|
|
7
|
+
|
|
8
|
+
interface AsyncBoundaryEmpty {
|
|
9
|
+
icon: LucideIcon;
|
|
10
|
+
title: string;
|
|
11
|
+
description?: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface AsyncBoundaryProps {
|
|
15
|
+
/** Async in flight — renders the loading affordance. */
|
|
16
|
+
isLoading: boolean;
|
|
17
|
+
/** Resolved error, if any — renders an `ErrorCard`. */
|
|
18
|
+
error?: Error | string | unknown | null;
|
|
19
|
+
/**
|
|
20
|
+
* When true, the resolved data is empty — renders an `EmptyState`. Only
|
|
21
|
+
* checked when `empty` config is supplied; otherwise children render.
|
|
22
|
+
*/
|
|
23
|
+
isEmpty?: boolean;
|
|
24
|
+
/** Empty-state config; omit to never render an empty state. */
|
|
25
|
+
empty?: AsyncBoundaryEmpty;
|
|
26
|
+
/** Retry handler threaded to `ErrorCard`. */
|
|
27
|
+
onRetry?: () => void;
|
|
28
|
+
/** Loading affordance variant. */
|
|
29
|
+
loadingVariant?: LoadingStateVariant;
|
|
30
|
+
/** Host error decoder threaded to `ErrorCard`. */
|
|
31
|
+
formatError?: ErrorMessageFormatter;
|
|
32
|
+
children: ReactNode;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Declarative loading / error / empty wrapper. Collapses the
|
|
37
|
+
* `if (isLoading) … if (error) … if (empty) … else children` ladder that
|
|
38
|
+
* every biome list/detail surface re-implements into one component built on
|
|
39
|
+
* the shared `LoadingState` / `ErrorCard` / `EmptyState` primitives.
|
|
40
|
+
*
|
|
41
|
+
* Precedence: loading → error → empty → children.
|
|
42
|
+
*/
|
|
43
|
+
export default function AsyncBoundary({
|
|
44
|
+
isLoading,
|
|
45
|
+
error,
|
|
46
|
+
isEmpty,
|
|
47
|
+
empty,
|
|
48
|
+
onRetry,
|
|
49
|
+
loadingVariant,
|
|
50
|
+
formatError,
|
|
51
|
+
children,
|
|
52
|
+
}: Readonly<AsyncBoundaryProps>) {
|
|
53
|
+
if (isLoading) {
|
|
54
|
+
return <LoadingState variant={loadingVariant} />;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (error) {
|
|
58
|
+
return <ErrorCard error={error} onRetry={onRetry} formatError={formatError} />;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (isEmpty && empty) {
|
|
62
|
+
return <EmptyState icon={empty.icon} title={empty.title} description={empty.description} />;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return <>{children}</>;
|
|
66
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { LucideIcon } from 'lucide-react';
|
|
2
|
+
import type { ComponentType, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../cn';
|
|
5
|
+
import { Button } from '../primitives/button';
|
|
6
|
+
import { Card, CardContent } from '../primitives/card';
|
|
7
|
+
|
|
8
|
+
export type EmptyStateVariant = 'not-configured' | 'no-results' | 'disabled-context';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Visual density.
|
|
12
|
+
* - `comfortable` (default): hero empty state for full-page surfaces —
|
|
13
|
+
* big 64px icon, generous padding, hero copy. Use when the page has
|
|
14
|
+
* nothing else to render.
|
|
15
|
+
* - `dense`: inline empty state for surfaces nested inside other
|
|
16
|
+
* content (tabs, sub-panels). Smaller icon, tighter padding, no
|
|
17
|
+
* dashed card frame — reads as a calm placeholder, not a banner.
|
|
18
|
+
* - `inline`: single-line text-only message, no icon, no card. Use
|
|
19
|
+
* inside list sub-sections where even the dense frame is too much.
|
|
20
|
+
*/
|
|
21
|
+
export type EmptyStateSize = 'comfortable' | 'dense' | 'inline';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Polymorphic link element. The host shell renders Next.js `<Link>`;
|
|
25
|
+
* pass it via `linkComponent` to get client-side routing. Defaults to a
|
|
26
|
+
* plain anchor so the primitive stays host-framework-agnostic.
|
|
27
|
+
*/
|
|
28
|
+
export type EmptyStateLinkComponent = ComponentType<{
|
|
29
|
+
href: string;
|
|
30
|
+
children: ReactNode;
|
|
31
|
+
}>;
|
|
32
|
+
|
|
33
|
+
const DefaultLink: EmptyStateLinkComponent = ({ href, children }) => (
|
|
34
|
+
<a href={href}>{children}</a>
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
interface EmptyStateAction {
|
|
38
|
+
label: string;
|
|
39
|
+
onClick: () => void;
|
|
40
|
+
icon?: LucideIcon;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface EmptyStateSecondaryAction {
|
|
44
|
+
label: string;
|
|
45
|
+
href: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface EmptyStateProps {
|
|
49
|
+
icon: LucideIcon;
|
|
50
|
+
title: string;
|
|
51
|
+
description?: ReactNode;
|
|
52
|
+
/** Optional illustration or extra content above the title */
|
|
53
|
+
illustration?: ReactNode;
|
|
54
|
+
/** Visual variant — drives default iconBg/iconColor unless overridden. */
|
|
55
|
+
variant?: EmptyStateVariant;
|
|
56
|
+
size?: EmptyStateSize;
|
|
57
|
+
/** Icon tint — overrides variant defaults */
|
|
58
|
+
iconBg?: string;
|
|
59
|
+
iconColor?: string;
|
|
60
|
+
action?: EmptyStateAction;
|
|
61
|
+
secondaryAction?: EmptyStateSecondaryAction;
|
|
62
|
+
/** Host link component for `secondaryAction.href` (defaults to `<a>`). */
|
|
63
|
+
linkComponent?: EmptyStateLinkComponent;
|
|
64
|
+
className?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const VARIANT_DEFAULTS: Record<EmptyStateVariant, { iconBg: string; iconColor: string }> = {
|
|
68
|
+
'not-configured': { iconBg: 'bg-info/10', iconColor: 'text-info' },
|
|
69
|
+
'no-results': { iconBg: 'bg-warning/10', iconColor: 'text-warning' },
|
|
70
|
+
'disabled-context': { iconBg: 'bg-muted', iconColor: 'text-ink-3' },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export default function EmptyState({
|
|
74
|
+
icon: Icon,
|
|
75
|
+
title,
|
|
76
|
+
description,
|
|
77
|
+
illustration,
|
|
78
|
+
variant = 'not-configured',
|
|
79
|
+
size = 'comfortable',
|
|
80
|
+
iconBg,
|
|
81
|
+
iconColor,
|
|
82
|
+
action,
|
|
83
|
+
secondaryAction,
|
|
84
|
+
linkComponent,
|
|
85
|
+
className,
|
|
86
|
+
}: Readonly<EmptyStateProps>) {
|
|
87
|
+
const Link = linkComponent ?? DefaultLink;
|
|
88
|
+
const defaults = VARIANT_DEFAULTS[variant];
|
|
89
|
+
const resolvedIconBg = iconBg ?? defaults.iconBg;
|
|
90
|
+
const resolvedIconColor = iconColor ?? defaults.iconColor;
|
|
91
|
+
|
|
92
|
+
if (size === 'inline') {
|
|
93
|
+
return (
|
|
94
|
+
<div className={cn('flex items-center justify-center px-3 py-6 text-center text-body-2 text-ink-4', className)}>
|
|
95
|
+
<div className="max-w-sm space-y-1">
|
|
96
|
+
<p className="text-ink-3">{title}</p>
|
|
97
|
+
{description && <p className="text-ink-4">{description}</p>}
|
|
98
|
+
{(action || secondaryAction) && (
|
|
99
|
+
<div className="mt-3 flex items-center justify-center gap-2">
|
|
100
|
+
{action && (
|
|
101
|
+
<Button size="sm" variant="outline" onClick={action.onClick} className="h-7 gap-1.5 text-body-2">
|
|
102
|
+
{action.icon && <action.icon className="h-3.5 w-3.5" />}
|
|
103
|
+
{action.label}
|
|
104
|
+
</Button>
|
|
105
|
+
)}
|
|
106
|
+
{secondaryAction && (
|
|
107
|
+
<Button asChild size="sm" variant="ghost" className="h-7 px-2 text-body-2">
|
|
108
|
+
<Link href={secondaryAction.href}>{secondaryAction.label}</Link>
|
|
109
|
+
</Button>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (size === 'dense') {
|
|
119
|
+
return (
|
|
120
|
+
<div
|
|
121
|
+
className={cn(
|
|
122
|
+
'flex flex-col items-center justify-center px-4 py-8 text-center',
|
|
123
|
+
className,
|
|
124
|
+
)}
|
|
125
|
+
>
|
|
126
|
+
{illustration ?? (
|
|
127
|
+
<div className={cn('h-9 w-9 rounded-xl flex items-center justify-center mb-3', resolvedIconBg)}>
|
|
128
|
+
<Icon className={cn('h-4 w-4', resolvedIconColor)} />
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
<p className="text-body-1 font-semibold text-ink">{title}</p>
|
|
132
|
+
{description && (
|
|
133
|
+
<p className="mt-1 max-w-md text-body-2 leading-snug text-ink-3">{description}</p>
|
|
134
|
+
)}
|
|
135
|
+
{(action || secondaryAction) && (
|
|
136
|
+
<div className="mt-3 flex items-center gap-2">
|
|
137
|
+
{action && (
|
|
138
|
+
<Button size="sm" variant="outline" onClick={action.onClick} className="h-7 gap-1.5 text-body-2">
|
|
139
|
+
{action.icon && <action.icon className="h-3.5 w-3.5" />}
|
|
140
|
+
{action.label}
|
|
141
|
+
</Button>
|
|
142
|
+
)}
|
|
143
|
+
{secondaryAction && (
|
|
144
|
+
<Button asChild size="sm" variant="ghost" className="h-7 px-2 text-body-2">
|
|
145
|
+
<Link href={secondaryAction.href}>{secondaryAction.label}</Link>
|
|
146
|
+
</Button>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<Card className={cn('border-2 border-dashed border-border/50 rounded-xl', className)}>
|
|
156
|
+
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
|
157
|
+
{illustration ?? (
|
|
158
|
+
<div className={`h-16 w-16 rounded-2xl flex items-center justify-center mb-5 ${resolvedIconBg}`}>
|
|
159
|
+
<Icon className={`h-7 w-7 ${resolvedIconColor}`} />
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
<p className="text-subtitle font-semibold text-ink">{title}</p>
|
|
163
|
+
{description && (
|
|
164
|
+
<p className="text-body-1 mt-2 max-w-sm text-ink-3 leading-relaxed">{description}</p>
|
|
165
|
+
)}
|
|
166
|
+
{(action || secondaryAction) && (
|
|
167
|
+
<div className="mt-6 flex items-center gap-3">
|
|
168
|
+
{action && (
|
|
169
|
+
<Button size="sm" variant="outline" onClick={action.onClick} className="gap-2 h-10 px-5">
|
|
170
|
+
{action.icon && <action.icon className="h-4 w-4" />}
|
|
171
|
+
{action.label}
|
|
172
|
+
</Button>
|
|
173
|
+
)}
|
|
174
|
+
{secondaryAction && (
|
|
175
|
+
<Button asChild size="sm" variant="ghost" className="h-10 px-3">
|
|
176
|
+
<Link href={secondaryAction.href}>{secondaryAction.label}</Link>
|
|
177
|
+
</Button>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</CardContent>
|
|
182
|
+
</Card>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { AlertCircle, RefreshCw } from 'lucide-react';
|
|
2
|
+
|
|
3
|
+
import { Button } from '../primitives/button';
|
|
4
|
+
import { Card, CardContent } from '../primitives/card';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Host-agnostic error formatter. The host shell owns rich error decoding
|
|
8
|
+
* (Orval envelopes, workflow error codes, …); pass its formatter via
|
|
9
|
+
* `formatError` to preserve that behaviour. The default extracts a plain
|
|
10
|
+
* message and is safe in any consumer.
|
|
11
|
+
*/
|
|
12
|
+
export type ErrorMessageFormatter = (error: unknown, fallback: string) => string;
|
|
13
|
+
|
|
14
|
+
const defaultFormatError: ErrorMessageFormatter = (error, fallback) => {
|
|
15
|
+
if (typeof error === 'string') {
|
|
16
|
+
return error || fallback;
|
|
17
|
+
}
|
|
18
|
+
if (error instanceof Error && error.message) {
|
|
19
|
+
return error.message;
|
|
20
|
+
}
|
|
21
|
+
return fallback;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
interface ErrorCardProps {
|
|
25
|
+
error: Error | string | unknown;
|
|
26
|
+
title?: string;
|
|
27
|
+
onRetry?: () => void;
|
|
28
|
+
/**
|
|
29
|
+
* Optional host error-decoder. Defaults to a plain message extractor.
|
|
30
|
+
* The host shell should pass `getUserFacingErrorMessage` to keep its
|
|
31
|
+
* envelope-aware messaging.
|
|
32
|
+
*/
|
|
33
|
+
formatError?: ErrorMessageFormatter;
|
|
34
|
+
fallbackMessage?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default function ErrorCard({
|
|
38
|
+
error,
|
|
39
|
+
title = 'Something went wrong',
|
|
40
|
+
onRetry,
|
|
41
|
+
formatError = defaultFormatError,
|
|
42
|
+
fallbackMessage = 'Unable to load this content right now.',
|
|
43
|
+
}: ErrorCardProps) {
|
|
44
|
+
const message = formatError(error, fallbackMessage);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<Card className="border-destructive/25 bg-destructive/[0.03] rounded-xl">
|
|
48
|
+
<CardContent className="flex flex-col items-center py-14 text-center">
|
|
49
|
+
<div className="h-14 w-14 rounded-2xl bg-destructive/10 flex items-center justify-center mb-4">
|
|
50
|
+
<AlertCircle className="h-6 w-6 text-destructive" />
|
|
51
|
+
</div>
|
|
52
|
+
<p className="text-subtitle font-semibold text-ink">{title}</p>
|
|
53
|
+
<p className="text-body-1 text-ink-3 mt-2 max-w-md leading-relaxed">{message}</p>
|
|
54
|
+
{onRetry && (
|
|
55
|
+
<Button
|
|
56
|
+
size="sm"
|
|
57
|
+
variant="outline"
|
|
58
|
+
onClick={onRetry}
|
|
59
|
+
className="mt-5 gap-2 h-10 border-destructive/20 text-destructive hover:bg-destructive/5"
|
|
60
|
+
>
|
|
61
|
+
<RefreshCw className="h-4 w-4" />
|
|
62
|
+
Retry
|
|
63
|
+
</Button>
|
|
64
|
+
)}
|
|
65
|
+
</CardContent>
|
|
66
|
+
</Card>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Loader2 } from 'lucide-react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../cn';
|
|
4
|
+
import { Skeleton } from '../primitives/skeleton';
|
|
5
|
+
|
|
6
|
+
export type LoadingStateVariant = 'spinner' | 'skeleton-list' | 'skeleton-card';
|
|
7
|
+
|
|
8
|
+
interface LoadingStateProps {
|
|
9
|
+
/**
|
|
10
|
+
* `spinner` (default): centred spinner + optional label. `skeleton-list`:
|
|
11
|
+
* N skeleton rows. `skeleton-card`: a single card-shaped skeleton block.
|
|
12
|
+
*/
|
|
13
|
+
variant?: LoadingStateVariant;
|
|
14
|
+
/** Accessible label / caption (spinner variant). */
|
|
15
|
+
label?: string;
|
|
16
|
+
/** Row count for `skeleton-list` (default 4). */
|
|
17
|
+
rows?: number;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Canonical loading affordance shared across biomes. Replaces the per-biome
|
|
23
|
+
* ad-hoc spinners and inline skeleton stacks.
|
|
24
|
+
*/
|
|
25
|
+
export default function LoadingState({
|
|
26
|
+
variant = 'spinner',
|
|
27
|
+
label = 'Loading…',
|
|
28
|
+
rows = 4,
|
|
29
|
+
className,
|
|
30
|
+
}: Readonly<LoadingStateProps>) {
|
|
31
|
+
if (variant === 'skeleton-list') {
|
|
32
|
+
return (
|
|
33
|
+
<div className={cn('space-y-3', className)} role="status" aria-label={label}>
|
|
34
|
+
{Array.from({ length: rows }).map((_, i) => (
|
|
35
|
+
<Skeleton key={i} className="h-12 w-full rounded-xl" />
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (variant === 'skeleton-card') {
|
|
42
|
+
return (
|
|
43
|
+
<div className={cn('space-y-3', className)} role="status" aria-label={label}>
|
|
44
|
+
<Skeleton className="h-40 w-full rounded-2xl" />
|
|
45
|
+
<Skeleton className="h-4 w-2/3 rounded" />
|
|
46
|
+
<Skeleton className="h-4 w-1/2 rounded" />
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
className={cn('flex flex-col items-center justify-center py-14 text-center', className)}
|
|
54
|
+
role="status"
|
|
55
|
+
aria-label={label}
|
|
56
|
+
>
|
|
57
|
+
<Loader2 className="h-6 w-6 animate-spin text-ink-3" />
|
|
58
|
+
{label && <p className="mt-3 text-body-2 text-ink-3">{label}</p>}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// ── PageHeader — INVISIBLE topbar registration ──
|
|
3
|
+
//
|
|
4
|
+
// PageHeader does NOT render a visible header bar. It registers the page's
|
|
5
|
+
// title, description, eyebrow, help-actions, status pill, count, and
|
|
6
|
+
// primary action buttons into the shared page-meta store. The global
|
|
7
|
+
// `AppTopbar` reads from that store and renders them in the topbar
|
|
8
|
+
// (mac-style — the topbar IS the page chrome).
|
|
9
|
+
//
|
|
10
|
+
// Why invisible: stacking a second header row beneath the topbar wastes
|
|
11
|
+
// vertical space and competes with the topbar for attention. Pages get
|
|
12
|
+
// the canvas back; the topbar carries context.
|
|
13
|
+
//
|
|
14
|
+
// When you need a thin in-page row (filter dropdowns, search input,
|
|
15
|
+
// selection-mode toolbar), use `<PageToolbar>` from this same file —
|
|
16
|
+
// that's a real visible row but carries NO title (the topbar still owns
|
|
17
|
+
// it), so there's no visual duplication.
|
|
18
|
+
//
|
|
19
|
+
// Host-agnostic: reaches the page-meta store through the kernel `HostBridge`
|
|
20
|
+
// singleton (NOT a host import), so it bundles cleanly into any biome remote.
|
|
21
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
22
|
+
|
|
23
|
+
import { useMemo, type ReactNode } from 'react';
|
|
24
|
+
|
|
25
|
+
import { useHostBridge, type PageBackTarget } from '../../lib/biome-host';
|
|
26
|
+
import { cn } from '../cn';
|
|
27
|
+
|
|
28
|
+
interface PageHeaderProps {
|
|
29
|
+
readonly title: string;
|
|
30
|
+
/**
|
|
31
|
+
* Short secondary line. Renders in the topbar info popover (long-form
|
|
32
|
+
* `topbarDescription` wins for the popover if both are set) AND
|
|
33
|
+
* inline next to the title as `topbarMeta` when it's a string.
|
|
34
|
+
*/
|
|
35
|
+
readonly description?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Long-form text rendered ONLY in the topbar's info popover (the
|
|
38
|
+
* (i) button). Use when `description` is a counts line and you still
|
|
39
|
+
* want a richer popover blurb.
|
|
40
|
+
*/
|
|
41
|
+
readonly topbarDescription?: string;
|
|
42
|
+
/** Mono caption rendered before the title in the topbar info popover. */
|
|
43
|
+
readonly eyebrow?: string;
|
|
44
|
+
/** Bullet list of "what you can do here" shown in the topbar info popover. */
|
|
45
|
+
readonly helpActions?: readonly string[];
|
|
46
|
+
/**
|
|
47
|
+
* Right-aligned topbar action cluster. Use small h-7 buttons / icon
|
|
48
|
+
* buttons. Anything that needs more space (multi-input filter rows,
|
|
49
|
+
* search inputs) belongs in a `<PageToolbar>` inside the page body
|
|
50
|
+
* — not here.
|
|
51
|
+
*/
|
|
52
|
+
readonly actions?: ReactNode;
|
|
53
|
+
/**
|
|
54
|
+
* Leftmost back affordance. Renders as an icon-only button before
|
|
55
|
+
* the title in the topbar. Pages with a parent context (a detail
|
|
56
|
+
* page under a list, etc.) set this so the user can return to where
|
|
57
|
+
* they came from. See `app/page-meta.ts` for the full layout
|
|
58
|
+
* contract — back always lives on the left, never inside the page
|
|
59
|
+
* body.
|
|
60
|
+
*/
|
|
61
|
+
readonly backTo?: PageBackTarget;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Invisible primitive — registers page meta with the topbar. Renders
|
|
66
|
+
* nothing visible. Mount once at the top of a page component.
|
|
67
|
+
*/
|
|
68
|
+
export default function PageHeader({
|
|
69
|
+
title,
|
|
70
|
+
description,
|
|
71
|
+
topbarDescription,
|
|
72
|
+
eyebrow,
|
|
73
|
+
helpActions,
|
|
74
|
+
actions,
|
|
75
|
+
backTo,
|
|
76
|
+
}: Readonly<PageHeaderProps>) {
|
|
77
|
+
// `topbarActions` is a ReactNode — wrap in useMemo so React doesn't see
|
|
78
|
+
// a new identity every render and re-notify the store needlessly. Same
|
|
79
|
+
// for the meta line.
|
|
80
|
+
const topbarActions = useMemo(() => actions, [actions]);
|
|
81
|
+
const topbarMeta = useMemo<ReactNode>(
|
|
82
|
+
() => (description ? <span>{description}</span> : null),
|
|
83
|
+
[description],
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Reach the host page-meta store through the shared bridge singleton — NOT
|
|
87
|
+
// a module-level import — so this component bundles cleanly into an MF
|
|
88
|
+
// remote and still drives the one host topbar store.
|
|
89
|
+
useHostBridge().pageMeta.usePageMeta({
|
|
90
|
+
title,
|
|
91
|
+
eyebrow,
|
|
92
|
+
description: topbarDescription ?? description,
|
|
93
|
+
actions: helpActions,
|
|
94
|
+
topbarActions,
|
|
95
|
+
topbarMeta,
|
|
96
|
+
backTo,
|
|
97
|
+
});
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
102
|
+
// ── PageToolbar — thin in-page row for filters / search / sub-tabs ──
|
|
103
|
+
//
|
|
104
|
+
// Use when the page needs a horizontal row of controls that don't fit
|
|
105
|
+
// in the topbar (multi-select filter, search input, bulk-selection
|
|
106
|
+
// toolbar). NEVER include the page title — that's the topbar's job.
|
|
107
|
+
//
|
|
108
|
+
// Default height is `h-9` to match the topbar visual rhythm without
|
|
109
|
+
// looking like a duplicated header. Background is the same paper tone
|
|
110
|
+
// as the page so it reads as a tool strip, not a banner.
|
|
111
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
112
|
+
|
|
113
|
+
interface PageToolbarProps {
|
|
114
|
+
/** Left cluster — filters, search, etc. */
|
|
115
|
+
readonly children?: ReactNode;
|
|
116
|
+
/** Right cluster — usually `null`; the primary action lives in the topbar. */
|
|
117
|
+
readonly trailing?: ReactNode;
|
|
118
|
+
readonly className?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function PageToolbar({
|
|
122
|
+
children,
|
|
123
|
+
trailing,
|
|
124
|
+
className,
|
|
125
|
+
}: Readonly<PageToolbarProps>) {
|
|
126
|
+
return (
|
|
127
|
+
<div
|
|
128
|
+
className={cn(
|
|
129
|
+
'flex shrink-0 items-center gap-2 border-b border-rule/50 bg-paper px-4 py-1.5 sm:px-6',
|
|
130
|
+
className,
|
|
131
|
+
)}
|
|
132
|
+
>
|
|
133
|
+
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">{children}</div>
|
|
134
|
+
{trailing && <div className="flex shrink-0 items-center gap-1.5">{trailing}</div>}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|