From ffda6ad73f6eb483a809967233e61f4fd4c58de8 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Tue, 10 Dec 2024 10:32:42 -0500 Subject: [PATCH 1/3] fix: api tree v2 --- package.json | 3 +- packages/ui/app/package.json | 4 +- .../api-reference/endpoints/AnchorIdParts.tsx | 33 ++ .../src/api-reference/endpoints/Endpoint.tsx | 17 +- .../endpoints/EndpointContentLeft.tsx | 272 ++++++------ .../api-reference/endpoints/EndpointError.tsx | 7 - .../endpoints/EndpointParameter.tsx | 95 ++--- .../endpoints/EndpointRequestSection.tsx | 232 +++++----- .../endpoints/EndpointResponseSection.tsx | 54 +-- .../endpoints/EndpointSection.tsx | 81 ++-- .../api-reference/endpoints/EndpointUrl.tsx | 58 ++- .../DiscriminatedUnionVariant.tsx | 26 +- .../types/object/ObjectProperty.tsx | 52 +-- .../InternalTypeDefinition.tsx | 87 ++-- .../type-definition/TypeDefinitionDetails.tsx | 2 +- .../createCollapsibleContent.tsx | 49 +-- .../InternalTypeReferenceDefinitions.tsx | 72 +--- .../TypeReferenceDefinitions.tsx | 7 - .../UndiscriminatedUnionVariant.tsx | 7 - .../api-reference/web-socket/WebSocket.tsx | 45 +- .../api-reference/webhooks/WebhookContent.tsx | 11 +- packages/ui/app/src/components/FernAnchor.tsx | 12 +- packages/ui/app/src/mdx/Markdown.tsx | 84 ++-- .../ui/app/src/mdx/components/html/index.tsx | 6 +- packages/ui/components/.storybook/styles.scss | 1 + packages/ui/components/package.json | 4 +- .../components/src/CopyToClipboardButton.tsx | 65 ++- packages/ui/components/src/FernButtonV2.tsx | 1 + packages/ui/components/src/FernCollapse.tsx | 7 - .../__test__/CopyToClipboardButton.test.tsx | 4 +- packages/ui/components/src/badges/badge.tsx | 11 +- packages/ui/components/src/badges/index.scss | 6 + .../ui/components/src/badges/variants.scss | 396 +++++++++++++----- .../ui/components/src/tree/disclosure.tsx | 252 +++++++++++ .../src/tree/parameter-description.tsx | 50 +++ .../ui/components/src/tree/tree.stories.tsx | 216 ++++++++++ packages/ui/components/src/tree/tree.tsx | 272 ++++++++++++ pnpm-lock.yaml | 345 +++------------ 38 files changed, 1837 insertions(+), 1109 deletions(-) create mode 100644 packages/ui/app/src/api-reference/endpoints/AnchorIdParts.tsx create mode 100644 packages/ui/components/src/tree/disclosure.tsx create mode 100644 packages/ui/components/src/tree/parameter-description.tsx create mode 100644 packages/ui/components/src/tree/tree.stories.tsx create mode 100644 packages/ui/components/src/tree/tree.tsx diff --git a/package.json b/package.json index e962b312d6..e3426e9bfd 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,8 @@ "elliptic": "6.6.0", "react": "18.3.1", "react-dom": "18.3.1", - "@babel/core": "7.26.0" + "@babel/core": "7.26.0", + "tailwindcss": "v4.0.0-beta.6" }, "dependenciesMeta": { "jsonc-parser@2.2.1": { diff --git a/packages/ui/app/package.json b/packages/ui/app/package.json index 56a5be6c10..976080b8bb 100644 --- a/packages/ui/app/package.json +++ b/packages/ui/app/package.json @@ -42,6 +42,7 @@ "@fern-ui/components": "workspace:*", "@fern-ui/fdr-utils": "workspace:*", "@fern-ui/fern-docs-mdx": "workspace:*", + "@fern-ui/fern-docs-search-ui": "workspace:*", "@fern-ui/fern-docs-server": "workspace:*", "@fern-ui/fern-docs-syntax-highlighter": "workspace:*", "@fern-ui/fern-docs-utils": "workspace:*", @@ -49,14 +50,15 @@ "@fern-ui/next-seo": "workspace:*", "@fern-ui/react-commons": "workspace:*", "@fern-ui/search-utils": "workspace:*", - "@fern-ui/fern-docs-search-ui": "workspace:*", "@inkeep/widgets": "^0.2.288", "@next/third-parties": "14.2.9", "@radix-ui/colors": "^3.0.0", "@radix-ui/react-accordion": "^1.2.1", "@radix-ui/react-collapsible": "^1.1.1", + "@radix-ui/react-compose-refs": "^1.1.0", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-primitive": "^2.0.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-tabs": "^1.1.1", diff --git a/packages/ui/app/src/api-reference/endpoints/AnchorIdParts.tsx b/packages/ui/app/src/api-reference/endpoints/AnchorIdParts.tsx new file mode 100644 index 0000000000..1dd39d02d4 --- /dev/null +++ b/packages/ui/app/src/api-reference/endpoints/AnchorIdParts.tsx @@ -0,0 +1,33 @@ +import { FernNavigation } from "@fern-api/fdr-sdk"; +import { useDeepCompareMemoize } from "@fern-ui/react-commons"; +import { PropsWithChildren, ReactElement, createContext, useContext } from "react"; +import { getAnchorId } from "../../util/anchor"; + +const SlugContext = createContext(FernNavigation.Slug("")); +const AnchorIdPartsContext = createContext([]); + +export function SlugProvider({ slug, children }: PropsWithChildren<{ slug: FernNavigation.Slug }>): ReactElement { + return {children}; +} + +export function AnchorProvider({ + children, + parts, +}: PropsWithChildren<{ parts: string | readonly string[] }>): ReactElement { + const parentParts = useContext(AnchorIdPartsContext); + const childParts = Array.isArray(parts) ? parts : [parts]; + return ( + + {children} + + ); +} + +export function useSlug(): FernNavigation.Slug { + return useContext(SlugContext); +} + +export function useAnchorId(): string { + const parts = useContext(AnchorIdPartsContext); + return getAnchorId(parts); +} diff --git a/packages/ui/app/src/api-reference/endpoints/Endpoint.tsx b/packages/ui/app/src/api-reference/endpoints/Endpoint.tsx index 7133c624a6..8cd2ff1256 100644 --- a/packages/ui/app/src/api-reference/endpoints/Endpoint.tsx +++ b/packages/ui/app/src/api-reference/endpoints/Endpoint.tsx @@ -1,6 +1,7 @@ import { createEndpointContext, type ApiDefinition } from "@fern-api/fdr-sdk/api-definition"; import type * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { memo, useMemo } from "react"; +import { SlugProvider } from "./AnchorIdParts"; import { EndpointContent } from "./EndpointContent"; export declare namespace Endpoint { @@ -31,13 +32,15 @@ const UnmemoizedEndpoint: React.FC = ({ } return ( - + + + ); }; diff --git a/packages/ui/app/src/api-reference/endpoints/EndpointContentLeft.tsx b/packages/ui/app/src/api-reference/endpoints/EndpointContentLeft.tsx index 70583e0119..733ebc0c88 100644 --- a/packages/ui/app/src/api-reference/endpoints/EndpointContentLeft.tsx +++ b/packages/ui/app/src/api-reference/endpoints/EndpointContentLeft.tsx @@ -8,11 +8,12 @@ import { useFeatureFlags } from "../../atoms"; import { Markdown } from "../../mdx/Markdown"; import { JsonPropertyPath } from "../examples/JsonPropertyPath"; import { TypeComponentSeparator } from "../types/TypeComponentSeparator"; +import { AnchorProvider } from "./AnchorIdParts"; import { EndpointError } from "./EndpointError"; import { EndpointParameter } from "./EndpointParameter"; import { EndpointRequestSection } from "./EndpointRequestSection"; import { EndpointResponseSection } from "./EndpointResponseSection"; -import { EndpointSection } from "./EndpointSection"; +import { EndpointSection, EndpointSectionTitle } from "./EndpointSection"; export interface HoveringProps { isHovering: boolean; @@ -32,17 +33,17 @@ export declare namespace EndpointContentLeft { } } -const REQUEST = ["request"]; -const RESPONSE = ["response"]; -const REQUEST_PATH = ["request", "path"]; -const REQUEST_QUERY = ["request", "query"]; -const REQUEST_HEADER = ["request", "header"]; -const REQUEST_BODY = ["request", "body"]; -const RESPONSE_BODY = ["response", "body"]; -const RESPONSE_ERROR = ["response", "error"]; +// const REQUEST = ["request"]; +// const RESPONSE = ["response"]; +// const REQUEST_PATH = ["request", "path"]; +// const REQUEST_QUERY = ["request", "query"]; +// const REQUEST_HEADER = ["request", "header"]; +// const REQUEST_BODY = ["request", "body"]; +// const RESPONSE_BODY = ["response", "body"]; +// const RESPONSE_ERROR = ["response", "error"]; const UnmemoizedEndpointContentLeft: React.FC = ({ - context: { node, endpoint, types, auth, globalHeaders }, + context: { endpoint, types, auth, globalHeaders }, example, showErrors, onHoverRequestProperty, @@ -157,55 +158,84 @@ const UnmemoizedEndpointContentLeft: React.FC = ({
{endpoint.pathParameters && endpoint.pathParameters.length > 0 && ( - -
- {endpoint.pathParameters.map((parameter) => ( -
- - -
- ))} -
-
+ + + Path parameters +
+ {endpoint.pathParameters.map((parameter) => ( + + + + + ))} +
+
+
)} {headers.length > 0 && ( - -
- {headers.map((parameter) => { - let isAuth = false; - if ( - (auth?.type === "header" && parameter.key === auth?.headerWireValue) || - parameter.key === "Authorization" - ) { - isAuth = true; - } + + + Headers +
+ {headers.map((parameter) => { + let isAuth = false; + if ( + (auth?.type === "header" && parameter.key === auth?.headerWireValue) || + parameter.key === "Authorization" + ) { + isAuth = true; + } - return ( -
- {isAuth && ( -
-
- Auth -
+ return ( + +
+ {isAuth && ( +
+
+ Auth +
+
+ )} + +
- )} +
+ ); + })} +
+ + + )} + {endpoint.queryParameters && endpoint.queryParameters.length > 0 && ( + + + Query parameters +
+ {endpoint.queryParameters.map((parameter) => ( + = ({ availability={parameter.availability} types={types} /> -
- ); - })} -
- - )} - {endpoint.queryParameters && endpoint.queryParameters.length > 0 && ( - -
- {endpoint.queryParameters.map((parameter) => ( -
- - -
- ))} -
-
+ + ))} +
+
+
)} {/* {endpoint.requestBody.length > 1 && ( @@ -270,10 +277,10 @@ const UnmemoizedEndpointContentLeft: React.FC = ({ + Request = ({ )} */} {endpoint.request && ( - - - + + + Request + + + + + )} {endpoint.response && ( - - - + + + Response + + + + + )} {showErrors && endpoint.errors && endpoint.errors.length > 0 && ( - -
- {sortBy(endpoint.errors, [(e) => e.statusCode, (e) => e.name]).map((error, idx) => { - return ( - { - event.stopPropagation(); - setSelectedError(error); - }} - onHoverProperty={onHoverResponseProperty} - anchorIdParts={[ - ...RESPONSE_ERROR, - `${convertNameToAnchorPart(error.name) ?? error.statusCode}`, - ]} - slug={node.slug} - availability={error.availability} - types={types} - /> - ); - })} -
-
+ + + Errors +
+ {sortBy(endpoint.errors, [(e) => e.statusCode, (e) => e.name]).map((error, idx) => { + return ( + + { + event.stopPropagation(); + setSelectedError(error); + }} + onHoverProperty={onHoverResponseProperty} + availability={error.availability} + types={types} + /> + + ); + })} +
+
+
)}
); diff --git a/packages/ui/app/src/api-reference/endpoints/EndpointError.tsx b/packages/ui/app/src/api-reference/endpoints/EndpointError.tsx index 94f306b637..9b0b36c877 100644 --- a/packages/ui/app/src/api-reference/endpoints/EndpointError.tsx +++ b/packages/ui/app/src/api-reference/endpoints/EndpointError.tsx @@ -1,6 +1,5 @@ import * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; import type { APIV1Read } from "@fern-api/fdr-sdk/client/types"; -import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { visitDiscriminatedUnion } from "@fern-api/ui-core-utils"; import { FernCollapse } from "@fern-ui/components"; import { AvailabilityBadge } from "@fern-ui/components/badges"; @@ -19,8 +18,6 @@ export declare namespace EndpointError { isSelected: boolean; onClick: MouseEventHandler; onHoverProperty?: (path: JsonPropertyPath, opts: { isHovering: boolean }) => void; - anchorIdParts: readonly string[]; - slug: FernNavigation.Slug; availability: APIV1Read.Availability | null | undefined; types: Record; } @@ -33,8 +30,6 @@ export const EndpointError = memo(function EndpointErrorUnm isSelected, onHoverProperty, onClick, - anchorIdParts, - slug, availability, types, }) { @@ -77,8 +72,6 @@ export const EndpointError = memo(function EndpointErrorUnm applyErrorStyles shape={error.shape} onHoverProperty={onHoverProperty} - anchorIdParts={anchorIdParts} - slug={slug} types={types} isResponse={true} /> diff --git a/packages/ui/app/src/api-reference/endpoints/EndpointParameter.tsx b/packages/ui/app/src/api-reference/endpoints/EndpointParameter.tsx index 5383cec85f..744e2e5461 100644 --- a/packages/ui/app/src/api-reference/endpoints/EndpointParameter.tsx +++ b/packages/ui/app/src/api-reference/endpoints/EndpointParameter.tsx @@ -1,27 +1,23 @@ import type * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; import type * as FernDocs from "@fern-api/fdr-sdk/docs"; -import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { EMPTY_ARRAY } from "@fern-api/ui-core-utils"; -import { AvailabilityBadge } from "@fern-ui/components/badges"; -import cn from "clsx"; +import { CopyToClipboardButton, cn } from "@fern-ui/components"; +import { AvailabilityBadge, Badge } from "@fern-ui/components/badges"; import { compact } from "es-toolkit/array"; -import { FC, PropsWithChildren, ReactNode, memo, useEffect, useRef, useState } from "react"; +import { FC, PropsWithChildren, ReactElement, ReactNode, useEffect, useRef, useState } from "react"; import { capturePosthogEvent } from "../../analytics/posthog"; import { useIsApiReferencePaginated, useRouteListener } from "../../atoms"; import { FernAnchor } from "../../components/FernAnchor"; -import { useHref } from "../../hooks/useHref"; import { Markdown } from "../../mdx/Markdown"; import { renderTypeShorthandRoot } from "../../type-shorthand"; -import { getAnchorId } from "../../util/anchor"; import { TypeReferenceDefinitions } from "../types/type-reference/TypeReferenceDefinitions"; +import { useAnchorId, useSlug } from "./AnchorIdParts"; export declare namespace EndpointParameter { export interface Props { name: string; description: FernDocs.MarkdownText | undefined; additionalDescriptions: FernDocs.MarkdownText[] | undefined; - anchorIdParts: readonly string[]; - slug: FernNavigation.Slug; availability: ApiDefinition.Availability | null | undefined; shape: ApiDefinition.TypeShape; types: Record; @@ -32,57 +28,46 @@ export declare namespace EndpointParameter { description: FernDocs.MarkdownText | undefined; additionalDescriptions: FernDocs.MarkdownText[] | undefined; typeShorthand: ReactNode; - anchorIdParts: readonly string[]; - slug: FernNavigation.Slug; availability: ApiDefinition.Availability | null | undefined; } } -export const EndpointParameter = memo( - ({ name, description, additionalDescriptions, anchorIdParts, slug, shape, availability, types }) => ( - - - - ), - (prev, next) => - prev.name === next.name && - prev.description === next.description && - prev.additionalDescriptions === next.additionalDescriptions && - prev.slug === next.slug && - prev.availability === next.availability && - prev.shape === next.shape && - prev.anchorIdParts.join("/") === next.anchorIdParts.join("/"), +export const EndpointParameter = ({ + name, + description, + additionalDescriptions, + shape, + availability, + types, +}: EndpointParameter.Props): ReactElement => ( + + + ); -EndpointParameter.displayName = "EndpointParameter"; - export const EndpointParameterContent: FC> = ({ name, - anchorIdParts, - slug, availability, description, additionalDescriptions = EMPTY_ARRAY, typeShorthand, children, }) => { - const anchorId = getAnchorId(anchorIdParts); + const slug = useSlug(); + const anchorId = useAnchorId(); + const ref = useRef(null); const [isActive, setIsActive] = useState(false); @@ -97,15 +82,13 @@ export const EndpointParameterContent: FC { if (descriptions.length > 0) { capturePosthogEvent("api_reference_multiple_descriptions", { name, - slug, - anchorIdParts, + href: String(new URL(`/${slug}#${anchorId}`, window.location.href)), count: descriptions.length, descriptions, }); @@ -116,14 +99,24 @@ export const EndpointParameterContent: FC - + - {name} + + + {name} + + {typeShorthand} {availability != null && } diff --git a/packages/ui/app/src/api-reference/endpoints/EndpointRequestSection.tsx b/packages/ui/app/src/api-reference/endpoints/EndpointRequestSection.tsx index 944246f640..114e9e83dc 100644 --- a/packages/ui/app/src/api-reference/endpoints/EndpointRequestSection.tsx +++ b/packages/ui/app/src/api-reference/endpoints/EndpointRequestSection.tsx @@ -1,129 +1,133 @@ import * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; -import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; -import { visitDiscriminatedUnion } from "@fern-api/ui-core-utils"; -import cn from "clsx"; +import { cn } from "@fern-ui/components"; import { Fragment, ReactNode } from "react"; import { Markdown } from "../../mdx/Markdown"; import { renderTypeShorthand } from "../../type-shorthand"; import { JsonPropertyPath } from "../examples/JsonPropertyPath"; import { TypeComponentSeparator } from "../types/TypeComponentSeparator"; import { TypeReferenceDefinitions } from "../types/type-reference/TypeReferenceDefinitions"; +import { AnchorProvider } from "./AnchorIdParts"; import { EndpointParameter, EndpointParameterContent } from "./EndpointParameter"; -export declare namespace EndpointRequestSection { - export interface Props { - request: ApiDefinition.HttpRequest; - onHoverProperty?: (path: JsonPropertyPath, opts: { isHovering: boolean }) => void; - anchorIdParts: readonly string[]; - slug: FernNavigation.Slug; - types: Record; - } -} - -export const EndpointRequestSection: React.FC = ({ +const EndpointRequestSection = ({ request, onHoverProperty, - anchorIdParts, - slug, types, -}) => { +}: { + request: ApiDefinition.HttpRequest; + onHoverProperty?: (path: JsonPropertyPath, opts: { isHovering: boolean }) => void; + types: Record; +}): ReactNode => { return ( -
+ <> ({ - formData: (formData) => { - const fileArrays = formData.fields.filter( - (p): p is ApiDefinition.FormDataField.Files => p.type === "files", - ); - const files = formData.fields.filter( - (p): p is ApiDefinition.FormDataField.File_ => p.type === "file", - ); - return `a multipart form${fileArrays.length > 0 || files.length > 1 ? " with multiple files" : files[0] != null ? ` containing ${files[0].isOptional ? "an optional" : "a"} file` : ""}`; - }, - bytes: (bytes) => `binary data${bytes.contentType != null ? ` of type ${bytes.contentType}` : ""}`, - object: (obj) => renderTypeShorthand(obj, { withArticle: true }, types), - alias: (alias) => renderTypeShorthand(alias, { withArticle: true }, types), - })}.`} + fallback={createRequestBodyDescriptionFallback(request.body, types)} /> - {visitDiscriminatedUnion(request.body)._visit({ - formData: (formData) => - formData.fields.map((p) => ( - - - {visitDiscriminatedUnion(p, "type")._visit({ - file: (file) => ( - - ), - files: (files) => ( - - ), - property: (property) => ( - - ), - _other: () => null, - })} - - )), - bytes: () => null, - object: (obj) => ( - - ), - alias: (alias) => ( - - ), - })} -
+ + ); }; +const EndpointRequestBody = ({ + body, + types, + onHoverProperty, +}: { + body: ApiDefinition.HttpRequestBodyShape; + types: Record; + onHoverProperty?: (path: JsonPropertyPath, opts: { isHovering: boolean }) => void; +}): ReactNode => { + switch (body.type) { + case "formData": + return ; + case "bytes": + return false; + case "alias": + case "object": + return ( + + ); + default: + return false; + } +}; + +const EndpointRequestBodyFormData = ({ + body, + types, +}: { + body: ApiDefinition.HttpRequestBodyShape.FormData; + types: Record; +}) => { + return ( + <> + {body.fields.map((field) => ( + + + + + + + ))} + + ); +}; + +const EndpointRequestBodyFormDataField = ({ + field, + types, +}: { + field: ApiDefinition.FormDataField; + types: Record; +}) => { + switch (field.type) { + case "file": + return ( + + ); + case "files": + return ( + + ); + case "property": + return ( + + ); + default: + return null; + } +}; + function renderTypeShorthandFormDataField( property: Exclude, ): ReactNode { @@ -134,3 +138,25 @@ function renderTypeShorthandFormDataField( ); } + +function createRequestBodyDescriptionFallback( + body: ApiDefinition.HttpRequestBodyShape, + types: Record, +): ReactNode { + switch (body.type) { + case "formData": { + const fileArrays = body.fields.filter((p): p is ApiDefinition.FormDataField.Files => p.type === "files"); + const files = body.fields.filter((p): p is ApiDefinition.FormDataField.File_ => p.type === "file"); + return `This endpoint expects a multipart form${fileArrays.length > 0 || files.length > 1 ? " with multiple files" : files[0] != null ? ` containing ${files[0].isOptional ? "an optional" : "a"} file` : ""}.`; + } + case "bytes": + return `This endpoint expects binary data${body.contentType != null ? ` of type ${body.contentType}` : ""}.`; + case "object": + case "alias": + return `This endpoint expects ${renderTypeShorthand(body, { withArticle: true }, types)}.`; + default: + return "This endpoint expects an unknown type."; + } +} + +export { EndpointRequestSection }; diff --git a/packages/ui/app/src/api-reference/endpoints/EndpointResponseSection.tsx b/packages/ui/app/src/api-reference/endpoints/EndpointResponseSection.tsx index f38b11c203..25a2e56b8d 100644 --- a/packages/ui/app/src/api-reference/endpoints/EndpointResponseSection.tsx +++ b/packages/ui/app/src/api-reference/endpoints/EndpointResponseSection.tsx @@ -1,34 +1,26 @@ import * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; -import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; +import { ReactNode } from "react"; import { useFeatureFlags } from "../../atoms"; import { Markdown } from "../../mdx/Markdown"; import { renderTypeShorthand } from "../../type-shorthand"; import { JsonPropertyPath } from "../examples/JsonPropertyPath"; import { TypeReferenceDefinitions } from "../types/type-reference/TypeReferenceDefinitions"; -export declare namespace EndpointResponseSection { - export interface Props { - response: ApiDefinition.HttpResponse; - exampleResponseBody: ApiDefinition.ExampleEndpointResponse | undefined; - onHoverProperty?: (path: JsonPropertyPath, opts: { isHovering: boolean }) => void; - anchorIdParts: readonly string[]; - slug: FernNavigation.Slug; - types: Record; - } -} - -export const EndpointResponseSection: React.FC = ({ +const EndpointResponseSection = ({ response, exampleResponseBody, onHoverProperty, - anchorIdParts, - slug, types, -}) => { +}: { + response: ApiDefinition.HttpResponse; + exampleResponseBody: ApiDefinition.ExampleEndpointResponse | undefined; + onHoverProperty?: (path: JsonPropertyPath, opts: { isHovering: boolean }) => void; + types: Record; +}): ReactNode => { const { isAudioFileDownloadSpanSummary } = useFeatureFlags(); return ( -
+ <> = isAudioFileDownloadSpanSummary, })} /> - -
+ + ); }; interface EndpointResponseSectionContentProps { body: ApiDefinition.HttpResponseBodyShape; onHoverProperty: ((path: JsonPropertyPath, opts: { isHovering: boolean }) => void) | undefined; - anchorIdParts: readonly string[]; - slug: FernNavigation.Slug; types: Record; } -function EndpointResponseSectionContent({ - body, - onHoverProperty, - anchorIdParts, - slug, - types, -}: EndpointResponseSectionContentProps) { +function EndpointResponseSectionContent({ body, onHoverProperty, types }: EndpointResponseSectionContentProps) { switch (body.type) { case "fileDownload": case "streamingText": - return null; + return false; case "stream": return ( import("../../mdx/Markdown").then(({ Markdown }) => Markdown), { ssr: true, }); -export declare namespace EndpointSection { - export type Props = React.PropsWithChildren<{ - headerType?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; - title: ReactNode; - description?: FernDocs.MarkdownText | undefined; - anchorIdParts: readonly string[]; - slug: FernNavigation.Slug; - }>; -} - -export const EndpointSection: React.FC = ({ - headerType = "h3", - title, - description, - anchorIdParts, - slug, - children, -}) => { - const ref = useRef(null); - const anchorId = getAnchorId(anchorIdParts); - const href = useHref(slug, anchorId); - return ( +const EndpointSection = forwardRef>( + ({ children, ...props }, forwardedRef) => ( -
- - {createElement(headerType, { className: "relative mt-0 flex items-center mb-3" }, title)} - - +
{children} -
+
+ ), +); + +EndpointSection.displayName = "EndpointSection"; + +const EndpointSectionTitle = forwardRef< + HTMLHeadingElement, + ComponentPropsWithoutRef<"h1"> & { + level?: 1 | 2 | 3 | 4 | 5 | 6; + } +>(({ level = 3, children, ...props }, forwardRef) => { + const anchorId = useAnchorId(); + return ( + + {createElement( + `h${level}`, + { ...props, className: cn("relative mt-0 flex items-center mb-3", props.className), ref: forwardRef }, + children, + )} + ); -}; +}); + +EndpointSectionTitle.displayName = "EndpointSectionTitle"; + +const EndpointSectionDescription = forwardRef< + HTMLDivElement, + Omit, "children"> & { + children: FernDocs.MarkdownText; + } +>(({ children, ...props }, forwardRef) => { + return ; +}); + +EndpointSectionDescription.displayName = "EndpointSectionDescription"; + +export { EndpointSection, EndpointSectionDescription, EndpointSectionTitle }; diff --git a/packages/ui/app/src/api-reference/endpoints/EndpointUrl.tsx b/packages/ui/app/src/api-reference/endpoints/EndpointUrl.tsx index 7e2a3c0fc1..f3f18c88dc 100644 --- a/packages/ui/app/src/api-reference/endpoints/EndpointUrl.tsx +++ b/packages/ui/app/src/api-reference/endpoints/EndpointUrl.tsx @@ -99,39 +99,35 @@ export const EndpointUrl = React.forwardRef - {(onClick) => ( - - )} + {showEnvironment && ( + + + + )} + {!showEnvironment && environmentBasepath && environmentBasepath !== "/" && ( + {environmentBasepath} + )} + {pathParts} + +
diff --git a/packages/ui/app/src/api-reference/types/discriminated-union/DiscriminatedUnionVariant.tsx b/packages/ui/app/src/api-reference/types/discriminated-union/DiscriminatedUnionVariant.tsx index 1393077637..8b009799ec 100644 --- a/packages/ui/app/src/api-reference/types/discriminated-union/DiscriminatedUnionVariant.tsx +++ b/packages/ui/app/src/api-reference/types/discriminated-union/DiscriminatedUnionVariant.tsx @@ -1,6 +1,5 @@ import * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; import type * as FernDocs from "@fern-api/fdr-sdk/docs"; -import type * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import titleCase from "@fern-api/ui-core-utils/titleCase"; import { AvailabilityBadge } from "@fern-ui/components/badges"; import cn from "clsx"; @@ -11,7 +10,7 @@ import { useIsApiReferencePaginated, useRouteListener } from "../../../atoms"; import { FernAnchor } from "../../../components/FernAnchor"; import { useHref } from "../../../hooks/useHref"; import { Markdown } from "../../../mdx/Markdown"; -import { getAnchorId } from "../../../util/anchor"; +import { useAnchorId, useSlug } from "../../endpoints/AnchorIdParts"; import { TypeDefinitionContext, TypeDefinitionContextValue, @@ -23,8 +22,6 @@ export declare namespace DiscriminatedUnionVariant { export interface Props { discriminant: ApiDefinition.PropertyKey; unionVariant: ApiDefinition.DiscriminatedUnionVariant; - anchorIdParts: readonly string[]; - slug: FernNavigation.Slug; types: Record; } } @@ -32,13 +29,12 @@ export declare namespace DiscriminatedUnionVariant { export const DiscriminatedUnionVariant: React.FC = ({ discriminant, unionVariant, - anchorIdParts, - slug, types, }) => { const { isRootTypeDefinition } = useTypeDefinitionContext(); - const anchorId = getAnchorId(anchorIdParts); + const slug = useSlug(); + const anchorId = useAnchorId(); const ref = useRef(null); const [isActive, setIsActive] = useState(false); @@ -88,10 +84,8 @@ export const DiscriminatedUnionVariant: React.FC { if (descriptions.length > 0) { capturePosthogEvent("api_reference_multiple_descriptions", { - slug, - anchorIdParts, - discriminant, - discriminantValue: unionVariant.discriminantValue, + name, + href: String(new URL(`/${slug}#${anchorId}`, window.location.href)), count: descriptions.length, descriptions, }); @@ -109,7 +103,7 @@ export const DiscriminatedUnionVariant: React.FC
- + {unionVariant.displayName ?? titleCase(unionVariant.discriminantValue)} @@ -122,13 +116,7 @@ export const DiscriminatedUnionVariant: React.FC - +
diff --git a/packages/ui/app/src/api-reference/types/object/ObjectProperty.tsx b/packages/ui/app/src/api-reference/types/object/ObjectProperty.tsx index 72aa4904b5..e8919c5c51 100644 --- a/packages/ui/app/src/api-reference/types/object/ObjectProperty.tsx +++ b/packages/ui/app/src/api-reference/types/object/ObjectProperty.tsx @@ -1,6 +1,6 @@ import * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; -import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; -import { AvailabilityBadge } from "@fern-ui/components/badges"; +import { CopyToClipboardButton } from "@fern-ui/components"; +import { AvailabilityBadge, Badge } from "@fern-ui/components/badges"; import cn from "clsx"; import { compact } from "es-toolkit/array"; import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -11,7 +11,7 @@ import { FernErrorBoundary } from "../../../components/FernErrorBoundary"; import { useHref } from "../../../hooks/useHref"; import { Markdown } from "../../../mdx/Markdown"; import { renderTypeShorthandRoot } from "../../../type-shorthand"; -import { getAnchorId } from "../../../util/anchor"; +import { useAnchorId, useSlug } from "../../endpoints/AnchorIdParts"; import { JsonPropertyPath } from "../../examples/JsonPropertyPath"; import { TypeDefinitionContext, @@ -27,16 +27,14 @@ import { export declare namespace ObjectProperty { export interface Props { property: ApiDefinition.ObjectProperty; - anchorIdParts: readonly string[]; - slug: FernNavigation.Slug; applyErrorStyles: boolean; types: Record; } } export const ObjectProperty: React.FC = (props) => { - const { slug, anchorIdParts } = props; - const anchorId = getAnchorId(anchorIdParts); + const slug = useSlug(); + const anchorId = useAnchorId(); const ref = useRef(null); const [isActive, setIsActive] = useState(false); @@ -51,16 +49,19 @@ export const ObjectProperty: React.FC = (props) => { } }); - return ; + return ; }; interface ObjectPropertyInternalProps extends ObjectProperty.Props { - anchorId: string; isActive: boolean; } const UnmemoizedObjectPropertyInternal = forwardRef((props, ref) => { - const { slug, property, applyErrorStyles, types, anchorIdParts, anchorId, isActive } = props; + const { property, applyErrorStyles, types, isActive } = props; + + const slug = useSlug(); + const anchorId = useAnchorId(); + const contextValue = useTypeDefinitionContext(); const jsonPropertyPath = useMemo( (): JsonPropertyPath => [ @@ -110,9 +111,8 @@ const UnmemoizedObjectPropertyInternal = forwardRef { if (descriptions.length > 0) { capturePosthogEvent("api_reference_multiple_descriptions", { - name: property.key, - slug, - anchorIdParts, + name, + href: String(new URL(`/${slug}#${anchorId}`, window.location.href)), count: descriptions.length, descriptions, }); @@ -130,14 +130,20 @@ const UnmemoizedObjectPropertyInternal = forwardRef
- - - {property.key} - + + + + {property.key} + + {renderTypeShorthandRoot(property.valueShape, types, contextValue.isResponse)} {property.availability != null && ( @@ -151,8 +157,6 @@ const UnmemoizedObjectPropertyInternal = forwardRef @@ -166,8 +170,6 @@ const UnmemoizedObjectPropertyInternal = forwardRef diff --git a/packages/ui/app/src/api-reference/types/type-definition/InternalTypeDefinition.tsx b/packages/ui/app/src/api-reference/types/type-definition/InternalTypeDefinition.tsx index c2ed8cca98..720995155d 100644 --- a/packages/ui/app/src/api-reference/types/type-definition/InternalTypeDefinition.tsx +++ b/packages/ui/app/src/api-reference/types/type-definition/InternalTypeDefinition.tsx @@ -1,12 +1,11 @@ import * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; -import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { FernTooltipProvider } from "@fern-ui/components"; import { useBooleanState, useIsHovering } from "@fern-ui/react-commons"; import cn from "clsx"; import { memo, useCallback, useMemo } from "react"; import { useRouteListener } from "../../../atoms"; import { FernErrorBoundary } from "../../../components/FernErrorBoundary"; -import { getAnchorId } from "../../../util/anchor"; +import { useAnchorId, useSlug } from "../../endpoints/AnchorIdParts"; import { TypeDefinitionContext, TypeDefinitionContextValue, @@ -21,8 +20,6 @@ export declare namespace InternalTypeDefinition { export interface Props { shape: ApiDefinition.TypeShapeOrReference; isCollapsible: boolean; - anchorIdParts: readonly string[]; - slug: FernNavigation.Slug; types: Record; } } @@ -30,20 +27,16 @@ export declare namespace InternalTypeDefinition { export const InternalTypeDefinition = memo(function InternalTypeDefinition({ shape, isCollapsible, - anchorIdParts, - slug, types, }) { - const collapsableContent = useMemo( - () => createCollapsibleContent(shape, types, anchorIdParts, slug), - [shape, types, anchorIdParts, slug], - ); + const slug = useSlug(); + const anchorId = useAnchorId(); + const collapsableContent = useMemo(() => createCollapsibleContent(shape, types), [shape, types]); - const anchorIdSoFar = getAnchorId(anchorIdParts); const { value: isCollapsed, toggleValue: toggleIsCollapsed, setValue: setCollapsed } = useBooleanState(true); useRouteListener(slug, (anchor) => { - const isActive = anchor?.startsWith(anchorIdSoFar + ".") ?? false; + const isActive = anchor?.startsWith(anchorId + ".") ?? false; if (isActive) { setCollapsed(false); } @@ -90,45 +83,41 @@ export const InternalTypeDefinition = memo(functio ? `Hide ${collapsableContent.elementNameSingular}` : `Hide ${collapsableContent.elements.length} ${collapsableContent.elementNamePlural}`; - const renderContent = () => ( -
- {collapsableContent.elementNameSingular !== "enum value" ? ( - collapsableContent.elements.length === 0 ? null : ( - - - - - - ) - ) : ( - - )} -
- ); - return ( - {renderContent()} +
+ {collapsableContent.elementNameSingular !== "enum value" ? ( + collapsableContent.elements.length === 0 ? null : ( + + + + + + ) + ) : ( + + )} +
); }); diff --git a/packages/ui/app/src/api-reference/types/type-definition/TypeDefinitionDetails.tsx b/packages/ui/app/src/api-reference/types/type-definition/TypeDefinitionDetails.tsx index 9d78e29588..9ed7543d5e 100644 --- a/packages/ui/app/src/api-reference/types/type-definition/TypeDefinitionDetails.tsx +++ b/packages/ui/app/src/api-reference/types/type-definition/TypeDefinitionDetails.tsx @@ -16,7 +16,7 @@ export const TypeDefinitionDetails: React.FC = ({ e separatorText != null ? (
-
{separatorText}
+
{separatorText}
) : ( diff --git a/packages/ui/app/src/api-reference/types/type-definition/createCollapsibleContent.tsx b/packages/ui/app/src/api-reference/types/type-definition/createCollapsibleContent.tsx index 284ed649fb..6a8479afd9 100644 --- a/packages/ui/app/src/api-reference/types/type-definition/createCollapsibleContent.tsx +++ b/packages/ui/app/src/api-reference/types/type-definition/createCollapsibleContent.tsx @@ -5,9 +5,9 @@ import { unwrapObjectType, unwrapReference, } from "@fern-api/fdr-sdk/api-definition"; -import { Slug } from "@fern-api/fdr-sdk/navigation"; import { ReactElement } from "react"; import { Chip } from "../../../components/Chip"; +import { AnchorProvider } from "../../endpoints/AnchorIdParts"; import { DiscriminatedUnionVariant } from "../discriminated-union/DiscriminatedUnionVariant"; import { ObjectProperty } from "../object/ObjectProperty"; import { UndiscriminatedUnionVariant } from "../undiscriminated-union/UndiscriminatedUnionVariant"; @@ -22,8 +22,8 @@ interface CollapsibleContent { export function createCollapsibleContent( shape: TypeShapeOrReference, types: Record, - anchorIdParts: readonly string[], - slug: Slug, + // anchorIdParts: readonly string[], + // slug: Slug, ): CollapsibleContent | undefined { const unwrapped = unwrapReference(shape, types); @@ -32,14 +32,13 @@ export function createCollapsibleContent( const union = unwrapped.shape; return { elements: union.variants.map((variant) => ( - + + + )), elementNameSingular: "variant", elementNamePlural: "variants", @@ -60,14 +59,9 @@ export function createCollapsibleContent( const { properties } = unwrapObjectType(unwrapped.shape, types); return { elements: properties.map((property) => ( - + + + )), elementNameSingular: "property", elementNamePlural: "properties", @@ -76,15 +70,14 @@ export function createCollapsibleContent( case "undiscriminatedUnion": { return { elements: unwrapped.shape.variants.map((variant, variantIdx) => ( - + + + )), elementNameSingular: "variant", elementNamePlural: "variants", diff --git a/packages/ui/app/src/api-reference/types/type-reference/InternalTypeReferenceDefinitions.tsx b/packages/ui/app/src/api-reference/types/type-reference/InternalTypeReferenceDefinitions.tsx index 40c3144635..1a1fdac37a 100644 --- a/packages/ui/app/src/api-reference/types/type-reference/InternalTypeReferenceDefinitions.tsx +++ b/packages/ui/app/src/api-reference/types/type-reference/InternalTypeReferenceDefinitions.tsx @@ -1,5 +1,4 @@ import * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; -import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { visitDiscriminatedUnion } from "@fern-api/ui-core-utils"; import React from "react"; import { UnreachableCaseError } from "ts-essentials"; @@ -12,8 +11,6 @@ export declare namespace InternalTypeReferenceDefinitions { applyErrorStyles: boolean; isCollapsible: boolean; className?: string; - anchorIdParts: readonly string[]; - slug: FernNavigation.Slug; shape: ApiDefinition.TypeShapeOrReference; types: Record; isResponse?: boolean; @@ -66,62 +63,19 @@ export const InternalTypeReferenceDefinitions: React.FC { const unwrapped = ApiDefinition.unwrapReference(shape, types); switch (unwrapped.shape.type) { - case "object": { - if (unwrapped.shape.extraProperties != null) { - // TODO: (rohin) Refactor this - return ( - - ); - } - return ( - - ); - } + case "object": case "enum": case "primitive": - case "undiscriminatedUnion": { - return ( - - ); - } - case "discriminatedUnion": { - const union = unwrapped.shape; - return ( - - ); - } + case "undiscriminatedUnion": + case "discriminatedUnion": + return ; + case "list": - case "set": { + case "set": return ( ); - } - case "map": { + + case "map": return ( ); - } + case "literal": case "unknown": - return null; + return false; default: throw new UnreachableCaseError(unwrapped.shape); } diff --git a/packages/ui/app/src/api-reference/types/type-reference/TypeReferenceDefinitions.tsx b/packages/ui/app/src/api-reference/types/type-reference/TypeReferenceDefinitions.tsx index 89449f0dd7..bca052bc44 100644 --- a/packages/ui/app/src/api-reference/types/type-reference/TypeReferenceDefinitions.tsx +++ b/packages/ui/app/src/api-reference/types/type-reference/TypeReferenceDefinitions.tsx @@ -1,5 +1,4 @@ import type * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; -import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { memo } from "react"; import { FernErrorBoundary } from "../../../components/FernErrorBoundary"; import { JsonPropertyPath } from "../../examples/JsonPropertyPath"; @@ -11,9 +10,7 @@ export declare namespace TypeReferenceDefinitions { applyErrorStyles: boolean; isCollapsible: boolean; onHoverProperty?: (path: JsonPropertyPath, opts: { isHovering: boolean }) => void; - anchorIdParts: readonly string[]; className?: string; - slug: FernNavigation.Slug; shape: ApiDefinition.TypeShapeOrReference; types: Record; isResponse?: boolean; @@ -25,9 +22,7 @@ export const TypeReferenceDefinitions = memo(fun isCollapsible, applyErrorStyles, onHoverProperty, - anchorIdParts, className, - slug, types, isResponse, }) { @@ -39,8 +34,6 @@ export const TypeReferenceDefinitions = memo(fun isCollapsible={isCollapsible} applyErrorStyles={applyErrorStyles} className={className} - anchorIdParts={anchorIdParts} - slug={slug} types={types} /> diff --git a/packages/ui/app/src/api-reference/types/undiscriminated-union/UndiscriminatedUnionVariant.tsx b/packages/ui/app/src/api-reference/types/undiscriminated-union/UndiscriminatedUnionVariant.tsx index c5f4b91d3c..7723df5189 100644 --- a/packages/ui/app/src/api-reference/types/undiscriminated-union/UndiscriminatedUnionVariant.tsx +++ b/packages/ui/app/src/api-reference/types/undiscriminated-union/UndiscriminatedUnionVariant.tsx @@ -1,5 +1,4 @@ import * as ApiDefinition from "@fern-api/fdr-sdk/api-definition"; -import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; import { visitDiscriminatedUnion } from "@fern-api/ui-core-utils"; import { AvailabilityBadge } from "@fern-ui/components/badges"; import cn from "clsx"; @@ -74,9 +73,7 @@ function getIconForTypeReference( export declare namespace UndiscriminatedUnionVariant { export interface Props { unionVariant: ApiDefinition.UndiscriminatedUnionVariant; - anchorIdParts: readonly string[]; applyErrorStyles: boolean; - slug: FernNavigation.Slug; idx: number; types: Record; } @@ -84,9 +81,7 @@ export declare namespace UndiscriminatedUnionVariant { export const UndiscriminatedUnionVariant: React.FC = ({ unionVariant, - anchorIdParts, applyErrorStyles, - slug, types, }) => { const { isRootTypeDefinition } = useTypeDefinitionContext(); @@ -122,10 +117,8 @@ export const UndiscriminatedUnionVariant: React.FC diff --git a/packages/ui/app/src/api-reference/web-socket/WebSocket.tsx b/packages/ui/app/src/api-reference/web-socket/WebSocket.tsx index 4bae775058..6a9566ac6f 100644 --- a/packages/ui/app/src/api-reference/web-socket/WebSocket.tsx +++ b/packages/ui/app/src/api-reference/web-socket/WebSocket.tsx @@ -15,7 +15,7 @@ import { PlaygroundButton } from "../../playground/PlaygroundButton"; import { usePlaygroundBaseUrl } from "../../playground/utils/select-environment"; import { getSlugFromChildren } from "../../util/getSlugFromText"; import { EndpointParameter } from "../endpoints/EndpointParameter"; -import { EndpointSection } from "../endpoints/EndpointSection"; +import { EndpointSection, EndpointSectionTitle } from "../endpoints/EndpointSection"; import { EndpointUrlWithOverflow } from "../endpoints/EndpointUrlWithOverflow"; import { TitledExample } from "../examples/TitledExample"; import { TypeComponentSeparator } from "../types/TypeComponentSeparator"; @@ -158,11 +158,8 @@ const WebhookContent: FC = ({ context, breadcrumb, last }) } > {headers && headers.length > 0 && ( - + + Headers
{headers.map((parameter) => (
@@ -186,11 +183,8 @@ const WebhookContent: FC = ({ context, breadcrumb, last }) )} {channel.pathParameters && channel.pathParameters.length > 0 && ( - + + Path parameters
{channel.pathParameters.map((parameter) => (
@@ -214,11 +208,8 @@ const WebhookContent: FC = ({ context, breadcrumb, last }) )} {channel.queryParameters && channel.queryParameters.length > 0 && ( - + + Query parameters
{channel.queryParameters.map((parameter) => { return ( @@ -248,19 +239,15 @@ const WebhookContent: FC = ({ context, breadcrumb, last }) {publishMessages.length > 0 && ( - + {"Send"} - } - anchorIdParts={["send"]} - slug={node.slug} - headerType="h2" - > + {/* = ({ context, breadcrumb, last }) )} {subscribeMessages.length > 0 && ( - + {"Receive"} - } - anchorIdParts={["receive"]} - slug={node.slug} - headerType="h2" - > + {/*
- +

{title}

{headingElement} diff --git a/packages/ui/app/src/api-reference/webhooks/WebhookContent.tsx b/packages/ui/app/src/api-reference/webhooks/WebhookContent.tsx index 068523955f..00e94a0a1c 100644 --- a/packages/ui/app/src/api-reference/webhooks/WebhookContent.tsx +++ b/packages/ui/app/src/api-reference/webhooks/WebhookContent.tsx @@ -6,7 +6,7 @@ import { FernBreadcrumbs } from "../../components/FernBreadcrumbs"; import { useHref } from "../../hooks/useHref"; import { Markdown } from "../../mdx/Markdown"; import { EndpointParameter } from "../endpoints/EndpointParameter"; -import { EndpointSection } from "../endpoints/EndpointSection"; +import { EndpointSection, EndpointSectionTitle } from "../endpoints/EndpointSection"; import { JsonPropertyPath } from "../examples/JsonPropertyPath"; import { TypeComponentSeparator } from "../types/TypeComponentSeparator"; import { useApiPageCenterElement } from "../useApiPageCenterElement"; @@ -63,7 +63,8 @@ export const WebhookContent = memo((props) => { {webhook.headers && webhook.headers.length > 0 && (
- + + Headers
{webhook.headers.map((parameter) => (
@@ -92,7 +93,8 @@ export const WebhookContent = memo((props) => { {webhook.payload && (
- + + Payload ((props) => {
- + + Response
diff --git a/packages/ui/app/src/components/FernAnchor.tsx b/packages/ui/app/src/components/FernAnchor.tsx index aa1dcb3490..ab85a12601 100644 --- a/packages/ui/app/src/components/FernAnchor.tsx +++ b/packages/ui/app/src/components/FernAnchor.tsx @@ -8,10 +8,16 @@ import { FernLink } from "./FernLink"; interface FernAnchorProps { href: string; sideOffset?: number; + asChild?: boolean; } -export function FernAnchor({ href, sideOffset = 12, children }: PropsWithChildren): ReactElement { - const { copyToClipboard, wasJustCopied } = useCopyToClipboard(() => new URL(href, window.location.href).toString()); +export function FernAnchor({ + href, + sideOffset = 12, + children, + asChild, +}: PropsWithChildren): ReactElement { + const { copyToClipboard, wasJustCopied } = useCopyToClipboard(() => String(new URL(href, window.location.href))); const [forceMount, setIsMounted] = useState(wasJustCopied ? true : undefined); useEffect(() => { @@ -27,7 +33,7 @@ export function FernAnchor({ href, sideOffset = 12, children }: PropsWithChildre return ( - {children} + {children} & { + title?: ReactNode; - // mdx: FernDocs.MarkdownText | FernDocs.MarkdownText[] | undefined; - mdx: FernDocs.MarkdownText | undefined; - className?: string; - size?: "xs" | "sm" | "lg"; + // mdx: FernDocs.MarkdownText | FernDocs.MarkdownText[] | undefined; + mdx: FernDocs.MarkdownText | undefined; + size?: "xs" | "sm" | "lg"; - /** - * Fallback content to render if the MDX is empty - */ - fallback?: ReactNode; - } -} + /** + * Fallback content to render if the MDX is empty + */ + fallback?: ReactNode; + } + >(({ title, mdx, size, fallback, ...props }, ref) => { + // If the MDX is empty, return null + if ( + !fallback && + (mdx == null || (typeof mdx === "string" ? mdx.trim().length === 0 : mdx.code.trim().length === 0)) + ) { + return null; + } -export const Markdown = memo(({ title, mdx, className, size, fallback }) => { - // If the MDX is empty, return null - if ( - !fallback && - (mdx == null || (typeof mdx === "string" ? mdx.trim().length === 0 : mdx.code.trim().length === 0)) - ) { - return null; - } - - return ( -
- {title} - -
- ); -}); + return ( +
+ {title} + +
+ ); + }), +); Markdown.displayName = "Markdown"; diff --git a/packages/ui/app/src/mdx/components/html/index.tsx b/packages/ui/app/src/mdx/components/html/index.tsx index e1d25304b9..36f80c0f80 100644 --- a/packages/ui/app/src/mdx/components/html/index.tsx +++ b/packages/ui/app/src/mdx/components/html/index.tsx @@ -20,7 +20,11 @@ import { FernLink } from "../../../components/FernLink"; import { useFrontmatter } from "../../../contexts/frontmatter"; export const HeadingRenderer = (level: number, props: ComponentProps<"h1">): ReactElement => { - return {createElement(`h${level}`, props)}; + return ( + + {createElement(`h${level}`, props)} + + ); }; export const P: FC<{ variant: "api" | "markdown" } & ComponentProps<"p">> = ({ variant, className, ...rest }) => { diff --git a/packages/ui/components/.storybook/styles.scss b/packages/ui/components/.storybook/styles.scss index b4516e58ae..245ea35fbf 100644 --- a/packages/ui/components/.storybook/styles.scss +++ b/packages/ui/components/.storybook/styles.scss @@ -3,3 +3,4 @@ @import "tailwindcss/components"; @import "tailwindcss/utilities"; @import "../src/index"; +@import "../src/fern-accent.scss"; diff --git a/packages/ui/components/package.json b/packages/ui/components/package.json index e9b1475c66..d2cef7f530 100644 --- a/packages/ui/components/package.json +++ b/packages/ui/components/package.json @@ -51,16 +51,18 @@ "@radix-ui/react-radio-group": "^1.2.1", "@radix-ui/react-scroll-area": "^1.2.1", "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", + "@radix-ui/react-use-controllable-state": "^1.1.0", "@storybook/client-api": "^7.6.17", "class-variance-authority": "^0.7.1", "clsx": "^2.1.0", "copyfiles": "^2.4.1", - "es-toolkit": "^1.24.0", + "es-toolkit": "^1.27.0", "iconoir-react": "^7.7.0", "lucide-react": "^0.460.0", "moment": "^2.30.1", diff --git a/packages/ui/components/src/CopyToClipboardButton.tsx b/packages/ui/components/src/CopyToClipboardButton.tsx index 073695be73..0925175ef2 100644 --- a/packages/ui/components/src/CopyToClipboardButton.tsx +++ b/packages/ui/components/src/CopyToClipboardButton.tsx @@ -1,31 +1,24 @@ import { useCopyToClipboard } from "@fern-ui/react-commons"; +import { composeEventHandlers } from "@radix-ui/primitive"; +import { Slot, Slottable } from "@radix-ui/react-slot"; import { Check, Copy } from "iconoir-react"; -import { FernButton } from "./FernButton"; +import { ComponentPropsWithoutRef, forwardRef } from "react"; +import { Button } from "./FernButtonV2"; import { FernTooltip, FernTooltipProvider } from "./FernTooltip"; import { cn } from "./cn"; -export declare namespace CopyToClipboardButton { - export interface Props { - className?: string; +export const CopyToClipboardButton = forwardRef< + HTMLButtonElement, + Omit, "content"> & { content?: string | (() => string | Promise); - testId?: string; - children?: (onClick: ((e: React.MouseEvent) => void) | undefined) => React.ReactNode; - onClick?: (e: React.MouseEvent) => void; + asChild?: boolean; + size?: ComponentPropsWithoutRef["size"]; + hideIcon?: boolean; } -} - -export const CopyToClipboardButton: React.FC = ({ - className, - content, - testId, - children, - onClick, -}) => { +>(({ className, content, children, asChild, hideIcon, ...props }, ref) => { const { copyToClipboard, wasJustCopied } = useCopyToClipboard(content); - if (content == null) { - return null; - } + const Comp = asChild ? Slot : Button; return ( @@ -33,26 +26,20 @@ export const CopyToClipboardButton: React.FC = ({ content={wasJustCopied ? "Copied!" : "Copy to clipboard"} open={wasJustCopied ? true : undefined} > - {children?.((e) => { - onClick?.(e); - copyToClipboard?.(); - }) ?? ( - { - onClick?.(e); - copyToClipboard?.(); - }} - data-testid={testId} - rounded={true} - icon={wasJustCopied ? : } - variant="minimal" - intent={wasJustCopied ? "success" : "none"} - disableAutomaticTooltip={true} - /> - )} + + {hideIcon ? null : wasJustCopied ? : } + {children} + ); -}; +}); + +CopyToClipboardButton.displayName = "CopyToClipboardButton"; diff --git a/packages/ui/components/src/FernButtonV2.tsx b/packages/ui/components/src/FernButtonV2.tsx index 614a765515..7a0e0ad870 100644 --- a/packages/ui/components/src/FernButtonV2.tsx +++ b/packages/ui/components/src/FernButtonV2.tsx @@ -6,6 +6,7 @@ import { cn } from "./cn"; const buttonVariants = cva( cn( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors hover:transition-none", + "box-border", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "disabled:pointer-events-none disabled:opacity-50", "[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", diff --git a/packages/ui/components/src/FernCollapse.tsx b/packages/ui/components/src/FernCollapse.tsx index 9497ca3941..fe2b9d7b3a 100644 --- a/packages/ui/components/src/FernCollapse.tsx +++ b/packages/ui/components/src/FernCollapse.tsx @@ -7,13 +7,6 @@ interface FernCollapseProps extends Collapsible.CollapsibleProps { trigger?: ReactNode; } -export enum AnimationStates { - OPEN_START, - OPEN, - CLOSING_START, - CLOSED, -} - export const FernCollapse: FC> = ({ children, trigger, ...props }) => { return ( diff --git a/packages/ui/components/src/__test__/CopyToClipboardButton.test.tsx b/packages/ui/components/src/__test__/CopyToClipboardButton.test.tsx index e402cf9d79..189f10ef21 100644 --- a/packages/ui/components/src/__test__/CopyToClipboardButton.test.tsx +++ b/packages/ui/components/src/__test__/CopyToClipboardButton.test.tsx @@ -19,13 +19,13 @@ afterEach(cleanup); describe("CopyToClipboardButton", () => { it("renders correctly", async () => { // eslint-disable-next-line deprecation/deprecation - const component = renderer.create(); + const component = renderer.create(); const tree = component.toJSON() as renderer.ReactTestRendererJSON; expect(tree).toMatchSnapshot(); }); it("changes content after click", () => { - const { getByTestId } = render(); + const { getByTestId } = render(); const innerHtmlBeforeClick = getByTestId("copy-btn").innerHTML; diff --git a/packages/ui/components/src/badges/badge.tsx b/packages/ui/components/src/badges/badge.tsx index 4d84d219ca..770abe9501 100644 --- a/packages/ui/components/src/badges/badge.tsx +++ b/packages/ui/components/src/badges/badge.tsx @@ -1,4 +1,4 @@ -import { Slot } from "@radix-ui/react-slot"; +import { Slot, Slottable } from "@radix-ui/react-slot"; import { VariantProps, cva } from "class-variance-authority"; import { ComponentPropsWithoutRef, forwardRef } from "react"; @@ -42,6 +42,7 @@ const badgeVariants = cva("fern-docs-badge", { solid: "solid", outlined: "outlined", "outlined-subtle": "outlined-subtle", + ghost: "ghost", }, rounded: { true: "rounded", @@ -129,7 +130,13 @@ export const Badge = forwardRef {...rest} className={badgeVariants({ size, variant, color, grayscale, rounded, interactive, className })} > - {skeleton ? {children} : children} + {skeleton ? ( + + {children} + + ) : ( + {children} + )} ); }); diff --git a/packages/ui/components/src/badges/index.scss b/packages/ui/components/src/badges/index.scss index 3c7a55c468..ef415947a2 100644 --- a/packages/ui/components/src/badges/index.scss +++ b/packages/ui/components/src/badges/index.scss @@ -69,6 +69,12 @@ & > svg { width: 1.25em; height: 1.25em; + pointer-events: none; + flex-shrink: 0; + + &:first-child { + margin-left: -0.25em; + } } } } diff --git a/packages/ui/components/src/badges/variants.scss b/packages/ui/components/src/badges/variants.scss index ff1b17cd4d..671727a99f 100644 --- a/packages/ui/components/src/badges/variants.scss +++ b/packages/ui/components/src/badges/variants.scss @@ -133,6 +133,138 @@ } } + .fern-docs-badge.ghost { + color: var(--grayscale-a12); + + &.accent { + color: var(--accent-a12, var(--grayscale-a12)); + } + + &.gray { + color: var(--gray-a12); + } + + &.mauve { + color: var(--mauve-a12); + } + + &.slate { + color: var(--slate-a12); + } + + &.sage { + color: var(--sage-a12); + } + + &.olive { + color: var(--olive-a12); + } + + &.sand { + color: var(--sand-a12); + } + + &.tomato { + color: var(--tomato-a12); + } + + &.red { + color: var(--red-a12); + } + + &.ruby { + color: var(--ruby-a12); + } + + &.crimson { + color: var(--crimson-a12); + } + + &.pink { + color: var(--pink-a12); + } + + &.plum { + color: var(--plum-a12); + } + + &.purple { + color: var(--purple-a12); + } + + &.violet { + color: var(--violet-a12); + } + + &.iris { + color: var(--iris-a12); + } + + &.indigo { + color: var(--indigo-a12); + } + + &.blue { + color: var(--blue-a12); + } + + &.cyan { + color: var(--cyan-a12); + } + + &.teal { + color: var(--teal-a12); + } + + &.jade { + color: var(--jade-a12); + } + + &.green { + color: var(--green-a12); + } + + &.grass { + color: var(--grass-a12); + } + + &.bronze { + color: var(--bronze-a12); + } + + &.gold { + color: var(--gold-a12); + } + + &.brown { + color: var(--brown-a12); + } + + &.orange { + color: var(--orange-a12); + } + + &.amber { + color: var(--amber-a12); + } + + &.yellow { + color: var(--yellow-a12); + } + + &.lime { + color: var(--lime-a12); + } + + &.mint { + color: var(--mint-a12); + } + + &.sky { + color: var(--sky-a12); + } + } + .fern-docs-badge.subtle, .fern-docs-badge.outlined { background-color: var(--grayscale-a3); @@ -431,299 +563,367 @@ } } - .fern-docs-badge.outlined-subtle { + .fern-docs-badge.outlined-subtle, + .fern-docs-badge.ghost { background-color: var(--grayscale-a1); + &.accent { + background-color: var(--accent-a1, var(--grayscale-a1)); + } + + &.gray { + background-color: var(--gray-a1); + } + + &.mauve { + background-color: var(--mauve-a1); + } + + &.slate { + background-color: var(--slate-a1); + } + + &.sage { + background-color: var(--sage-a1); + } + + &.olive { + background-color: var(--olive-a1); + } + + &.sand { + background-color: var(--sand-a1); + } + + &.tomato { + background-color: var(--tomato-a1); + } + + &.red { + background-color: var(--red-a1); + } + + &.ruby { + background-color: var(--ruby-a1); + } + + &.crimson { + background-color: var(--crimson-a1); + } + + &.pink { + background-color: var(--pink-a1); + } + + &.plum { + background-color: var(--plum-a1); + } + + &.purple { + background-color: var(--purple-a1); + } + + &.violet { + background-color: var(--violet-a1); + } + + &.iris { + background-color: var(--iris-a1); + } + + &.indigo { + background-color: var(--indigo-a1); + } + + &.blue { + background-color: var(--blue-a1); + } + + &.cyan { + background-color: var(--cyan-a1); + } + + &.teal { + background-color: var(--teal-a1); + } + + &.jade { + background-color: var(--jade-a1); + } + + &.green { + background-color: var(--green-a1); + } + + &.grass { + background-color: var(--grass-a1); + } + + &.bronze { + background-color: var(--bronze-a1); + } + + &.gold { + background-color: var(--gold-a1); + } + + &.brown { + background-color: var(--brown-a1); + } + + &.orange { + background-color: var(--orange-a1); + } + + &.amber { + background-color: var(--amber-a1); + } + + &.yellow { + background-color: var(--yellow-a1); + } + + &.lime { + background-color: var(--lime-a1); + } + + &.mint { + background-color: var(--mint-a1); + } + + &.sky { + background-color: var(--sky-a1); + } + } + + .fern-docs-badge.outlined-subtle, + .fern-docs-badge.ghost { &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--grayscale-a2); + background-color: var(--grayscale-a4); } &.accent { - background-color: var(--accent-a1, var(--grayscale-a1)); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--accent-a2, var(--grayscale-a2)); + background-color: var(--accent-a4, var(--grayscale-a4)); } } &.gray { - background-color: var(--gray-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--gray-a2); + background-color: var(--gray-a4); } } &.mauve { - background-color: var(--mauve-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--mauve-a2); + background-color: var(--mauve-a4); } } &.slate { - background-color: var(--slate-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--slate-a2); + background-color: var(--slate-a4); } } &.sage { - background-color: var(--sage-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--sage-a2); + background-color: var(--sage-a4); } } &.olive { - background-color: var(--olive-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--olive-a2); + background-color: var(--olive-a4); } } &.sand { - background-color: var(--sand-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--sand-a2); + background-color: var(--sand-a4); } } &.tomato { - background-color: var(--tomato-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--tomato-a2); + background-color: var(--tomato-a4); } } &.red { - background-color: var(--red-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--red-a2); + background-color: var(--red-a4); } } &.ruby { - background-color: var(--ruby-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--ruby-a2); + background-color: var(--ruby-a4); } } &.crimson { - background-color: var(--crimson-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--crimson-a2); + background-color: var(--crimson-a4); } } &.pink { - background-color: var(--pink-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--pink-a2); + background-color: var(--pink-a4); } } &.plum { - background-color: var(--plum-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--plum-a2); + background-color: var(--plum-a4); } } &.purple { - background-color: var(--purple-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--purple-a2); + background-color: var(--purple-a4); } } &.violet { - background-color: var(--violet-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--violet-a2); + background-color: var(--violet-a4); } } &.iris { - background-color: var(--iris-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--iris-a2); + background-color: var(--iris-a4); } } &.indigo { - background-color: var(--indigo-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--indigo-a2); + background-color: var(--indigo-a4); } } &.blue { - background-color: var(--blue-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--blue-a2); + background-color: var(--blue-a4); } } &.cyan { - background-color: var(--cyan-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--cyan-a2); + background-color: var(--cyan-a4); } } &.teal { - background-color: var(--teal-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--teal-a2); + background-color: var(--teal-a4); } } &.jade { - background-color: var(--jade-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--jade-a2); + background-color: var(--jade-a4); } } &.green { - background-color: var(--green-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--green-a2); + background-color: var(--green-a4); } } &.grass { - background-color: var(--grass-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--grass-a2); + background-color: var(--grass-a4); } } &.bronze { - background-color: var(--bronze-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--bronze-a2); + background-color: var(--bronze-a4); } } &.gold { - background-color: var(--gold-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--gold-a2); + background-color: var(--gold-a4); } } &.brown { - background-color: var(--brown-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--brown-a2); + background-color: var(--brown-a4); } } &.orange { - background-color: var(--orange-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--orange-a2); + background-color: var(--orange-a4); } } &.amber { - background-color: var(--amber-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--amber-a2); + background-color: var(--amber-a4); } } &.yellow { - background-color: var(--yellow-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--yellow-a2); + background-color: var(--yellow-a4); } } &.lime { - background-color: var(--lime-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--lime-a2); + background-color: var(--lime-a4); } } &.mint { - background-color: var(--mint-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--mint-a2); + background-color: var(--mint-a4); } } &.sky { - background-color: var(--sky-a1); - &.interactive:not(:disabled):hover, &[data-state="open"]:not(:disabled) { - background-color: var(--sky-a2); + background-color: var(--sky-a4); } } } diff --git a/packages/ui/components/src/tree/disclosure.tsx b/packages/ui/components/src/tree/disclosure.tsx new file mode 100644 index 0000000000..6491c854ac --- /dev/null +++ b/packages/ui/components/src/tree/disclosure.tsx @@ -0,0 +1,252 @@ +import { useDeepCompareMemoize } from "@fern-ui/react-commons"; +import { composeEventHandlers } from "@radix-ui/primitive"; +import { composeRefs } from "@radix-ui/react-compose-refs"; +import { Slot } from "@radix-ui/react-slot"; +import { noop } from "es-toolkit/function"; +import React, { + ComponentPropsWithoutRef, + ReactNode, + createContext, + forwardRef, + memo, + useContext, + useEffect, + useMemo, + useState, +} from "react"; + +export interface DisclosureSharedProps { + children: React.ReactNode; + className?: string; +} + +export interface DisclosureRootProps extends DisclosureSharedProps { + animationOptions?: OptionalEffectTiming; +} + +export interface DisclosureItemProps { + children: React.ReactNode | (({ isOpen }: { isOpen: boolean }) => React.ReactNode); + className?: string; +} + +enum AnimationState { + IDLE = "idle", + EXPANDING = "expanding", + SHRINKING = "shrinking", +} + +const defaultAnimationOptions = { + duration: 180, + easing: "cubic-bezier(0.25, 0.46, 0.45, 0.94)", +}; + +const DisclosureContext = createContext(defaultAnimationOptions); + +const DisclosureItemContext = createContext<{ + detailsEl: HTMLDetailsElement | null; + setDetailsEl: React.Dispatch>; + contentEl: HTMLElement | null; + setContentEl: React.Dispatch>; + setIsOpen: React.Dispatch>; +}>({ + detailsEl: null, + setDetailsEl: noop, + contentEl: null, + setContentEl: noop, + setIsOpen: noop, +}); + +const Disclosure = ({ children, animationOptions = defaultAnimationOptions }: DisclosureRootProps): React.ReactNode => { + return ( + + {children} + + ); +}; + +// Inspired by: https://css-tricks.com/how-to-animate-the-details-element-using-waapi/ +const DisclosureSummary = forwardRef< + HTMLElement, + ComponentPropsWithoutRef<"summary"> & { + asChild?: boolean; + } +>(({ children, asChild, ...props }, ref) => { + const summaryRef = React.useRef(null); + + const [animation, setAnimation] = useState(null); + const [animationState, setAnimationState] = useState(AnimationState.IDLE); + + const animationOptions = useContext(DisclosureContext); + const { detailsEl, contentEl, setIsOpen } = useContext(DisclosureItemContext); + + const handleAnimateHeight = ({ + animationState, + startHeight, + endHeight, + open, + }: { + animationState: AnimationState; + startHeight: number; + endHeight: number; + open: boolean; + }) => { + setAnimationState(animationState); + + if (animation) { + animation.cancel(); + } + + const _animation = detailsEl?.animate({ height: [`${startHeight}px`, `${endHeight}px`] }, animationOptions); + + setAnimation(_animation); + setIsOpen(open); + if (_animation) { + _animation.onfinish = () => onAnimationFinish(open); + _animation.oncancel = () => setAnimationState(AnimationState.IDLE); + } + }; + + const handleShrink = () => { + setAnimationState(AnimationState.SHRINKING); + + const startHeight = detailsEl?.offsetHeight ?? 0; + const endHeight = (summaryRef?.current && summaryRef.current.offsetHeight) || 0; + + handleAnimateHeight({ + animationState: AnimationState.SHRINKING, + startHeight, + endHeight, + open: false, + }); + }; + + const handleExpand = () => { + const startHeight = detailsEl?.offsetHeight ?? 0; + const endHeight = (summaryRef.current?.offsetHeight || 0) + (contentEl?.offsetHeight || 0); + + handleAnimateHeight({ + animationState: AnimationState.EXPANDING, + startHeight, + endHeight, + open: true, + }); + }; + + const onAnimationFinish = (open: boolean) => { + if (detailsEl) { + detailsEl.open = open; + detailsEl.style.height = ""; + detailsEl.style.overflow = ""; + } + + setAnimation(null); + setAnimationState(AnimationState.IDLE); + }; + + const handleOpen = () => { + if (detailsEl) { + detailsEl.style.height = `${detailsEl?.offsetHeight}px`; + detailsEl.open = true; + setIsOpen(true); + } + + requestAnimationFrame(() => handleExpand()); + }; + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + if (detailsEl) { + detailsEl.style.overflow = "hidden"; + + if (animationState === AnimationState.SHRINKING || !detailsEl.open) { + handleOpen(); + } else if (animationState === AnimationState.EXPANDING || detailsEl.open) { + handleShrink(); + } + } + }; + + const Comp = asChild ? Slot : "summary"; + + return ( + + {children} + + ); +}); + +DisclosureSummary.displayName = "DisclosureSummary"; + +const DisclosureDetails = forwardRef< + HTMLDetailsElement, + ComponentPropsWithoutRef<"details"> & { + children?: ReactNode | (({ isOpen }: { isOpen: boolean }) => ReactNode); + asChild?: boolean; + } +>(({ children, asChild, ...props }, ref) => { + const [isOpen, setIsOpen] = useState(false); + const [detailsEl, setDetailsEl] = useState(null); + const [contentEl, setContentEl] = useState(null); + + const detailsRef = React.useRef(null); + + useEffect(() => { + setDetailsEl(detailsRef.current); + }, []); + + const ctxValue = useMemo( + () => ({ + detailsEl, + setDetailsEl, + contentEl, + setContentEl, + setIsOpen, + }), + [detailsEl, contentEl, setIsOpen], + ); + + const Comp = asChild ? Slot : "details"; + + return ( + + + {typeof children === "function" ? children({ isOpen }) : children} + + + ); +}); + +DisclosureDetails.displayName = "DisclosureDetails"; + +const DisclosureContent = forwardRef & { asChild?: boolean }>( + ({ asChild, ...props }, ref) => { + const contentRef = React.useRef(null); + + const { setContentEl } = useContext(DisclosureItemContext); + + useEffect(() => { + setContentEl(contentRef.current); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const Comp = asChild ? Slot : "div"; + + return ; + }, +); + +DisclosureContent.displayName = "DisclosureContent"; + +Disclosure.Root = Disclosure; +Disclosure.Details = memo(DisclosureDetails); +Disclosure.Summary = DisclosureSummary; +Disclosure.Content = DisclosureContent; + +export default Disclosure; +export { Disclosure }; diff --git a/packages/ui/components/src/tree/parameter-description.tsx b/packages/ui/components/src/tree/parameter-description.tsx new file mode 100644 index 0000000000..d7283d83a7 --- /dev/null +++ b/packages/ui/components/src/tree/parameter-description.tsx @@ -0,0 +1,50 @@ +import { Separator } from "@radix-ui/react-separator"; +import { ComponentPropsWithoutRef, ReactNode, forwardRef } from "react"; +import { CopyToClipboardButton } from "../CopyToClipboardButton"; +import { Badge } from "../badges"; +import { cn } from "../cn"; +import { useDetailContext } from "./tree"; + +export const ParameterDescription = forwardRef< + HTMLDivElement, + ComponentPropsWithoutRef<"div"> & { + parameterName: string; + typeShorthand: ReactNode; + required?: boolean; + } +>(({ parameterName, typeShorthand, required, ...props }, ref) => { + const { setOpen } = useDetailContext(); + + return ( +
+ { + e.stopPropagation(); + setOpen(true); + }} + > + + {parameterName} + + + {typeShorthand} + + + {required ? "required" : "optional"} + +
+ ); +}); + +ParameterDescription.displayName = "ParameterDescription"; diff --git a/packages/ui/components/src/tree/tree.stories.tsx b/packages/ui/components/src/tree/tree.stories.tsx new file mode 100644 index 0000000000..96c1bcac5e --- /dev/null +++ b/packages/ui/components/src/tree/tree.stories.tsx @@ -0,0 +1,216 @@ +import { Meta, StoryObj } from "@storybook/react"; +import { ParameterDescription } from "./parameter-description"; +import { + Tree, + TreeDetailIndicator, + TreeItem, + TreeItemContent, + TreeItemSummary, + TreeItemSummaryTrigger, + TreeItemsContentAdditional, +} from "./tree"; + +const meta: Meta = { + title: "Tree", + component: Tree, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => { + return ( + + + + + + + +
+

A customer can make purchases in your store and manage their profile.

+
+
+ + + + + + + + +
+

The unique identifier for the customer.

+
+
+
+ + + + + + + +
+

The customer's email

+

+ Example: john.appleseed@example.com +

+
+
+
+ + + + + + + +
+

The customer's first name

+

+ Example: John +

+
+
+
+ + + + + + + +
+

The customer's last name

+

+ Example: Appleseed +

+
+
+
+
+ + + + + + + + +
+

+ The customer's groups. A group is a collection of customers that can be + used to manage customers. +

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ ); + }, +}; diff --git a/packages/ui/components/src/tree/tree.tsx b/packages/ui/components/src/tree/tree.tsx new file mode 100644 index 0000000000..b0b345994d --- /dev/null +++ b/packages/ui/components/src/tree/tree.tsx @@ -0,0 +1,272 @@ +import { composeEventHandlers } from "@radix-ui/primitive"; +import { Slot } from "@radix-ui/react-slot"; +import { useControllableState } from "@radix-ui/react-use-controllable-state"; +import { noop } from "es-toolkit/function"; +import { ChevronRight, Plus } from "lucide-react"; +import { + Children, + ComponentPropsWithoutRef, + Dispatch, + Fragment, + PropsWithChildren, + ReactNode, + createContext, + forwardRef, + isValidElement, + useContext, + useMemo, +} from "react"; +import { Badge } from "../badges"; +import { cn } from "../cn"; +import Disclosure from "./disclosure"; + +const ctx = createContext(0); + +function useIndent() { + return useContext(ctx); +} + +const Tree = forwardRef>(({ children, ...props }, ref) => { + return ( + +
+ {children} +
+
+ ); +}); + +Tree.displayName = "Tree"; + +const openCtx = createContext<{ + open: boolean; + expandable: boolean; + setOpen: Dispatch>; +}>({ + open: false, + expandable: false, + setOpen: noop, +}); + +function useDetailContext(): { + open: boolean; + expandable: boolean; + setOpen: Dispatch>; +} { + return useContext(openCtx); +} + +const TreeItem = forwardRef< + HTMLDetailsElement, + ComponentPropsWithoutRef<"details"> & { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + } +>(({ children, open: openProp, defaultOpen, onOpenChange, ...props }, ref) => { + const [open = false, setOpen] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen, + onChange: onOpenChange, + }); + + const childrenArray = Children.toArray(children); + + const summary = childrenArray.find((child) => isValidElement(child) && child.type === TreeItemSummary); + const other = childrenArray.filter((child) => isValidElement(child) && child.type !== TreeItemSummary); + + const indent = useIndent(); + const ctxValue = useMemo(() => ({ open, setOpen, expandable: other.length > 0 }), [open, setOpen, other.length]); + + return ( + + setOpen(e.currentTarget.open))} + data-level={indent} + > + {summary} + {other.length > 0 && ( + + + {other} + + + )} + + + ); +}); + +TreeItem.displayName = "TreeItem"; + +const TreeItemContent = ({ children }: PropsWithChildren): ReactNode => { + const { setOpen } = useDetailContext(); + const childrenArray = Children.toArray(children); + + if (childrenArray.length === 0) { + return false; + } + + return ( + <> + {childrenArray.map((child, i) => ( + + setOpen(false)} /> + {child} + + ))} + + ); +}; + +const TreeItemsContentAdditional = ({ + children, + open: openProp, + defaultOpen, + onOpenChange, +}: PropsWithChildren<{ + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +}>): ReactNode => { + const [open = false, setOpen] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen, + onChange: onOpenChange, + }); + + const childrenArray = Children.toArray(children); + + if (childrenArray.length === 0) { + return false; + } + + if (open) { + return ( + <> + {childrenArray.map((child, i) => ( + + setOpen(false)} /> + {child} + + ))} + + ); + } + + return ( + <> + +
+ setOpen(true)} className="-ml-2" variant="outlined-subtle"> + + {childrenArray.length} more attributes + +
+ + ); +}; + +const TreeItemSummary = forwardRef>(({ children, ...props }, ref) => { + const { setOpen, open, expandable } = useDetailContext(); + return ( + e.preventDefault())} + tabIndex={-1} + > + {children} + {!open && expandable && ( + setOpen(true)} className="mt-2" variant="outlined-subtle"> + + Show child attributes + + )} + + ); +}); + +TreeItemSummary.displayName = "TreeItemSummary"; + +const TreeDetailIndicator = forwardRef>( + ({ children, ...props }, ref) => { + const { open, expandable } = useDetailContext(); + if (!expandable) { + return false; + } + return ( +
+ +
+ ); + }, +); + +TreeDetailIndicator.displayName = "TreeDetailIndicator"; + +const TreeItemSummaryTrigger = forwardRef< + HTMLButtonElement, + ComponentPropsWithoutRef<"button"> & { + asChild?: boolean; + } +>(({ children, asChild, ...props }, ref) => { + const { setOpen } = useContext(openCtx); + const interactive = props.onClick != null; + const Comp = asChild ? Slot : "button"; + return ( + setOpen((prev) => !prev), { + checkForDefaultPrevented: true, + })} + className={cn("w-full", interactive ? "cursor-pointer" : "cursor-default", props.className)} + > + {children} + + ); +}); + +TreeItemSummaryTrigger.displayName = "TreeItemSummaryTrigger"; + +const TreeBranch = forwardRef>(({ ...props }, ref) => { + return ( +