Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 1 addition & 73 deletions apps/www/src/content/docs/components/button/props.ts
Original file line number Diff line number Diff line change
@@ -1,73 +1 @@
import { ButtonHTMLAttributes } from 'react';

export type ButtonProps = {
/**
* Visual style variant
* @defaultValue "solid"
*/
variant?: 'solid' | 'outline' | 'ghost' | 'text';

/**
* Color theme
* @defaultValue "accent"
*/
color?: 'accent' | 'danger' | 'neutral' | 'success';

/**
* Size of the button
* @defaultValue "normal"
*/
size?: 'small' | 'normal';

/**
* Whether the button is disabled
* @defaultValue false
*/
disabled?: boolean;

/** Shows a loading spinner inside the button */
loading?: boolean;

/** Optional text to display next to the loading spinner */
loaderText?: React.ReactNode;

/** Icon element to display before button text */
leadingIcon?: React.ReactNode;

/** Icon element to display after button text */
trailingIcon?: React.ReactNode;

/**
* Whether the component renders a native <button> element when replacing it via the render prop. Set to false if the rendered element is not a button (e.g. <div>).
* Defaults to false when render prop is provided.
*/
nativeButton?: boolean;

/**
* Whether the button should be focusable when disabled.
* Defaults to true when loading is true.
*/
focusableWhenDisabled?: boolean;

/**
* Allows rendering the button as a different element.
* Accepts a React element or a function that receives props and returns an element.
*
* @remarks `ReactElement | function`
*/
render?:
| React.ReactElement<ButtonHTMLAttributes<HTMLButtonElement>>
| ((props: ButtonHTMLAttributes<HTMLButtonElement>) => React.ReactElement);

/** Additional CSS class names */
className?: string;

/** Inline styles for the button */
style?: React.CSSProperties;

/**
* Corner radius for this button only. Overrides the theme's `radius`.
* @defaultValue The theme's `radius`
*/
radius?: 'none' | 'small' | 'medium' | 'large' | 'full';
};
export type { ButtonProps } from '@raystack/apsara';
143 changes: 136 additions & 7 deletions apps/www/src/lib/type-table.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,60 @@
import * as path from 'node:path';
import { createGenerator, type GenerateOptions } from 'fumadocs-typescript';
import {
createGenerator,
type DocEntry,
type GenerateOptions,
type RawTag
} from 'fumadocs-typescript';

type Transform = NonNullable<GenerateOptions['transform']>;

export const generator = createGenerator({
// The declaration nodes come from the TypeScript 7 in fumadocs-typescript.
// apps/www has TypeScript 5, so the transform reads them through these shapes.
interface SourceNode {
parent?: SourceNode;
name?: { text?: string };
properties?: SourceNode[];
initializer?: SourceNode;
type?: SourceNode;
jsDoc?: {
comment?: unknown;
tags?: { tagName: { text: string }; comment?: unknown }[];
}[];
getText(): string;
getSourceFile(): { fileName: string };
}

interface Checker {
getTypeAtLocation(node: SourceNode): unknown;
getPropertiesOfType(type: unknown): readonly { name: string }[];
}

// The transform cannot remove an entry, so it marks the entries to hide and
// the generator drops them.
const hidden = new WeakSet<DocEntry>();

const base = createGenerator({
tsconfigPath: path.resolve('tsconfig.props.json')
});

export const generator: typeof base = {
...base,
async generateDocumentation(...args) {
const docs = await base.generateDocumentation(...args);
return docs.map(doc => ({
...doc,
entries: doc.entries.filter(entry => !hidden.has(entry))
}));
}
};

const isExternal = (node: SourceNode) =>
node.getSourceFile().fileName.includes('/node_modules/');

/** A prop is shown when Apsara or Base UI declares it. */
const isShown = (file: string) =>
!file.includes('/node_modules/') || file.includes('/node_modules/@base-ui/');

const shortTypes: Record<string, string> = {
className: 'string | function',
style: 'CSSProperties | function',
Expand All @@ -20,23 +68,104 @@ const SHORT =
const withoutUndefined = (type: string) => type.replace(/ \| undefined$/, '');

/** The type as written in the source, which keeps the order of a union. */
function writtenType(node: unknown): string | undefined {
const type = (node as { type?: { getText(): string } } | undefined)?.type;
const text = type
function writtenType(node: SourceNode | undefined): string | undefined {
const text = node?.type
?.getText()
.replace(/\s+/g, ' ')
.replace(/^\| /, '')
.replace(/'([^']*)'/g, '"$1"');
return text && withoutUndefined(text);
}

/** Sets the short type of a table row. The expanded row shows the full type. */
function commentText(comment: unknown): string {
if (typeof comment === 'string') return comment;
if (!Array.isArray(comment)) return '';
return comment.map(part => (part as { text?: string }).text ?? '').join('');
}

/** The value of a cva variant in `defaultVariants`, for example `"solid"`. */
function cvaDefault(node: SourceNode, prop: string): string | undefined {
const variants = node.parent?.parent;
if (variants?.name?.text !== 'variants') return;
const defaults = variants.parent?.properties?.find(
property => property.name?.text === 'defaultVariants'
);
const value = defaults?.initializer?.properties?.find(
property => property.name?.text === prop
);
return value?.initializer?.getText().replace(/^'(.*)'$/, '"$1"');
}

/**
* Hides the props that a table leaves out, and sets the description, default
* and short type of the others.
*/
export const transform: Transform = function (entry, _type, prop) {
const declarations = (prop.declarations ?? []).flatMap(declaration => {
const node = declaration.resolve(this.program);
return node ? [node as unknown as SourceNode] : [];
});
const files = declarations.map(node => node.getSourceFile().fileName);
if (!files.some(isShown) || entry.tags.some(tag => tag.name === 'ignore')) {
hidden.add(entry);
return;
}

// A prop that Base UI also declares keeps only the Apsara JSDoc.
const own = declarations.filter(
node => !isExternal(node) && (node.jsDoc?.length ?? 0) > 0
);
if (own.length > 0 && own.length < declarations.length) {
const docs = own.flatMap(node => node.jsDoc ?? []);
entry.description = docs.map(doc => commentText(doc.comment)).join('\n');
entry.tags = docs.flatMap(doc =>
(doc.tags ?? []).map(
(tag): RawTag => ({
name: tag.tagName.text,
text: commentText(tag.comment)
})
)
);
entry.deprecated = entry.tags.some(tag => tag.name === 'deprecated');
}

const deprecation = entry.tags.find(tag => tag.name === 'deprecated')?.text;
if (deprecation)
entry.description = [entry.description, deprecation]
.filter(Boolean)
.join(' ');

// An Apsara declaration of the prop wins over one from Base UI.
let written = writtenType(
declarations.find(node => node.type && !isExternal(node)) ?? declarations[0]
);
// A cva variant has no written type, and cva adds `null` to it. Its values
// keep the order of the variant's keys.
if (!written) {
entry.type = entry.type.replace(/ \| null\b/, '');
const variant = declarations.find(node => node.initializer)?.initializer;
const checker = this.checker as unknown as Checker;
const keys = variant
? checker
.getPropertiesOfType(checker.getTypeAtLocation(variant))
.map(key => key.name)
: [];
if (keys.length > 0 && !keys.includes('true'))
written = keys
.map(key => (/^\d+$/.test(key) ? key : `"${key}"`))
.join(' | ');
}
if (!entry.tags.some(tag => ['default', 'defaultValue'].includes(tag.name))) {
const value = declarations
.map(node => cvaDefault(node, entry.name))
.find(Boolean);
if (value) entry.tags.push({ name: 'default', text: value });
}

// A `@remarks` type set by the author wins.
if (entry.tags.some(tag => tag.name === 'remarks' && tag.text[0] === '`'))
return;

const written = writtenType(prop.declarations?.[0]?.resolve(this.program));
const type = (written ?? withoutUndefined(entry.type)).replace(
/\bReact\./g,
''
Expand Down
7 changes: 6 additions & 1 deletion apps/www/tsconfig.props.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["react"]
"types": ["react"],
"paths": {
"react": ["./node_modules/@types/react"],
"@raystack/apsara": ["../../packages/react/index.tsx"],
"~/*": ["../../packages/react/*"]
}
}
}
16 changes: 16 additions & 0 deletions packages/react/components/button/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,29 @@ import styles from './button.module.css';
const button = cva(styles['button'], {
variants: {
...radiusVariants,
/** Visual style of the button. */
variant: {
solid: styles['button-solid'],
outline: styles['button-outline'],
ghost: styles['button-ghost'],
text: styles['button-text']
},
/** Size of the button. */
size: {
small: styles['button-small'],
normal: styles['button-normal']
},
/**
* Whether the button is disabled.
* @default false
*/
disabled: {
true: styles['button-disabled']
},
loading: {
true: styles['button-loading']
},
/** Color of the button. */
color: {
accent: styles['button-color-accent'],
danger: styles['button-color-danger'],
Expand Down Expand Up @@ -127,11 +134,20 @@ const getLoaderOnlyClass = (size: 'small' | 'normal' | null) =>

export type ButtonProps = VariantProps<typeof button> &
ButtonPrimitive.Props & {
/** Shows a loading spinner inside the button. */
loading?: boolean;
/** Text shown next to the loading spinner. */
loaderText?: ReactNode;
/** Icon shown before the button text. */
leadingIcon?: ReactNode;
/** Icon shown after the button text. */
trailingIcon?: ReactNode;
/** Inline styles for the button. */
style?: React.CSSProperties;
/** Whether the button renders a native `<button>`. Defaults to `false` when `render` is set, otherwise `true`. */
nativeButton?: boolean;
/** Whether the button stays focusable while disabled. Defaults to `true` while `loading`, otherwise `false`. */
focusableWhenDisabled?: boolean;
};

export const Button = ({
Expand Down
5 changes: 4 additions & 1 deletion packages/react/shared/radius/radius.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ export const radiusClasses = {
full: styles['radius-full']
} satisfies Record<Radius, string>;

export const radiusVariants = { radius: radiusClasses };
export const radiusVariants = {
/** Corner radius for this component only. Defaults to the theme's `radius`. */
radius: radiusClasses
};

/**
* The `radius` override alone, for surfaces with no cva of their own; `''` when
Expand Down
Loading