Merge branch 'backstage:master' into feature/catalog-export

This commit is contained in:
1337
2026-04-20 20:14:38 +02:00
committed by GitHub
80 changed files with 726 additions and 303 deletions
@@ -150,13 +150,13 @@ describe('PluginTaskManagerImpl', () => {
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
initialDelay: Duration.fromObject({ years: 1 }),
initialDelay: Duration.fromObject({ seconds: 60 }),
fn,
scope: 'global',
});
await manager.triggerTask('task1');
jest.advanceTimersByTime(5000);
await jest.advanceTimersByTimeAsync(65_000);
await promise;
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
@@ -56,6 +56,29 @@ describe('util', () => {
await promise;
expect(true).toBe(true);
}, 1_000);
it('handles durations longer than 2^31 ms by chunking setTimeout calls', async () => {
jest.useFakeTimers();
try {
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
let resolved = false;
const promise = sleep(Duration.fromMillis(thirtyDaysMs)).then(() => {
resolved = true;
});
expect(jest.getTimerCount()).toBe(1);
await jest.advanceTimersByTimeAsync(2 ** 30);
expect(resolved).toBe(false);
expect(jest.getTimerCount()).toBe(1);
await jest.advanceTimersByTimeAsync(thirtyDaysMs - 2 ** 30);
await promise;
expect(resolved).toBe(true);
} finally {
jest.useRealTimers();
}
});
});
describe('delegateAbortController', () => {
@@ -54,6 +54,11 @@ export function nowPlus(duration: Duration | undefined, knex: Knex) {
return knex.raw(`now() + interval '${seconds} seconds'`);
}
// Node.js setTimeout uses a 32-bit signed integer internally, so timeouts
// longer than 2^31-1 ms (~24.8 days) fire immediately. We cap each individual
// wait at 2^30 ms (~12.4 days) and loop until the full duration has elapsed.
const MAX_TIMEOUT_MS = 2 ** 30;
/**
* Sleep for the given duration, but return sooner if the abort signal
* triggers.
@@ -69,19 +74,34 @@ export async function sleep(
return;
}
await new Promise<void>(resolve => {
let timeoutHandle: NodeJS.Timeout | undefined = undefined;
let remaining = duration.as('milliseconds');
if (!Number.isFinite(remaining) || remaining <= 0) {
return;
}
const done = () => {
await new Promise<void>(resolve => {
let timeoutHandle: NodeJS.Timeout | undefined;
const finish = () => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
abortSignal?.removeEventListener('abort', done);
abortSignal?.removeEventListener('abort', finish);
resolve();
};
timeoutHandle = setTimeout(done, duration.as('milliseconds'));
abortSignal?.addEventListener('abort', done);
const tick = () => {
if (remaining <= 0) {
finish();
return;
}
const chunk = Math.min(remaining, MAX_TIMEOUT_MS);
remaining -= chunk;
timeoutHandle = setTimeout(tick, chunk);
};
abortSignal?.addEventListener('abort', finish);
tick();
});
}
@@ -81,9 +81,8 @@ export class AwsCodeCommitIntegration implements ScmIntegration {
*
* @param url - The original URL
* @param type - The desired type, e.g. 'blob', 'edit'
* @public
*/
export function replaceCodeCommitUrlType(
function replaceCodeCommitUrlType(
url: string,
repositoryName: string,
type: 'browse' | 'edit',
+1 -4
View File
@@ -209,11 +209,8 @@ export function buildGerritGitilesArchiveUrlFromLocation(
* be used.
*
* @param config - A Gerrit provider config.
* @public
*/
export function getAuthenticationPrefix(
config: GerritIntegrationConfig,
): string {
function getAuthenticationPrefix(config: GerritIntegrationConfig): string {
return config.password ? '/a/' : '/';
}
+1 -1
View File
@@ -63,7 +63,7 @@ export function getGithubFileFetchUrl(
}
}
export function chooseEndpoint(
function chooseEndpoint(
config: GithubIntegrationConfig,
credentials: GithubCredentials,
): 'api' | 'raw' {
@@ -138,6 +138,7 @@ export class GitLabIntegration implements ScmIntegration {
}
}
/** @internal */
export async function sleep(
durationMs: number,
abortSignal: AbortSignal | null | undefined,
+1 -1
View File
@@ -75,7 +75,7 @@ export function getGitLabRequestOptions(
// Converts
// from: https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath
// to: https://gitlab.com/api/v4/projects/groupA%2Fteams%2FteamA%2FsubgroupA%2FrepoA/repository/files/filepath/raw?ref=branch
export function buildProjectUrl(
function buildProjectUrl(
target: string,
projectPathOrID: string | Number,
config: GitLabIntegrationConfig,
+43 -1
View File
@@ -222,6 +222,9 @@ export const AlertDefinition: {
readonly dataAttribute: true;
readonly default: 'info';
};
readonly isPending: {
readonly dataAttribute: true;
};
readonly loading: {
readonly dataAttribute: true;
};
@@ -239,6 +242,7 @@ export const AlertDefinition: {
export type AlertOwnProps = {
status?: Responsive<'info' | 'success' | 'warning' | 'danger'>;
icon?: boolean | ReactElement;
isPending?: boolean;
loading?: boolean;
customActions?: ReactNode;
title?: ReactNode;
@@ -431,6 +435,9 @@ export const BoxDefinition: {
'py',
'position',
'display',
'grow',
'shrink',
'basis',
'width',
'minWidth',
'maxWidth',
@@ -452,6 +459,7 @@ export type BoxOwnProps = {
// @public (undocumented)
export interface BoxProps
extends SpaceProps,
FlexItemProps,
BoxOwnProps,
BoxUtilityProps,
Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {}
@@ -510,6 +518,9 @@ export const ButtonDefinition: {
readonly destructive: {
readonly dataAttribute: true;
};
readonly isPending: {
readonly dataAttribute: true;
};
readonly loading: {
readonly dataAttribute: true;
};
@@ -545,6 +556,9 @@ export const ButtonIconDefinition: {
readonly dataAttribute: true;
readonly default: 'primary';
};
readonly isPending: {
readonly dataAttribute: true;
};
readonly loading: {
readonly dataAttribute: true;
};
@@ -558,6 +572,7 @@ export type ButtonIconOwnProps = {
size?: Responsive<'small' | 'medium'>;
variant?: Responsive<'primary' | 'secondary' | 'tertiary'>;
icon?: ReactElement;
isPending?: boolean;
loading?: boolean;
className?: string;
};
@@ -623,6 +638,7 @@ export type ButtonOwnProps = {
destructive?: boolean;
iconStart?: ReactElement;
iconEnd?: ReactElement;
isPending?: boolean;
loading?: boolean;
children?: ReactNode;
className?: string;
@@ -642,6 +658,7 @@ export const Card: ForwardRefExoticComponent<
export type CardBaseProps = {
children?: ReactNode;
className?: string;
style?: CSSProperties;
};
// @public
@@ -702,7 +719,9 @@ export const CardDefinition: {
readonly target: {};
readonly rel: {};
readonly download: {};
readonly style: {};
};
readonly utilityProps: readonly ['grow', 'shrink', 'basis'];
};
// @public
@@ -786,10 +805,12 @@ export type CardOwnProps = Pick<
| 'target'
| 'rel'
| 'download'
| 'style'
>;
// @public
export type CardProps = CardBaseProps &
FlexItemProps &
Omit<React.HTMLAttributes<HTMLDivElement>, 'onClick'> &
(CardButtonVariant | CardLinkVariant | CardStaticVariant);
@@ -1315,12 +1336,22 @@ export const FlexDefinition: {
'align',
'justify',
'direction',
'grow',
'shrink',
'basis',
];
};
// @public (undocumented)
export type FlexDirection = 'row' | 'column';
// @public
export interface FlexItemProps {
basis?: Responsive<CSSProperties['flexBasis']>;
grow?: Responsive<number | boolean>;
shrink?: Responsive<number | boolean>;
}
// @public (undocumented)
export type FlexOwnProps = {
children: ReactNode;
@@ -1332,6 +1363,7 @@ export type FlexOwnProps = {
// @public (undocumented)
export interface FlexProps
extends SpaceProps,
FlexItemProps,
FlexOwnProps,
Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
// (undocumented)
@@ -1422,6 +1454,9 @@ export const GridDefinition: {
'pt',
'px',
'py',
'grow',
'shrink',
'basis',
];
};
@@ -1478,6 +1513,7 @@ export type GridOwnProps = {
// @public (undocumented)
export interface GridProps
extends SpaceProps,
FlexItemProps,
GridOwnProps,
Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
// (undocumented)
@@ -2717,6 +2753,9 @@ export const TableDefinition: {
readonly stale: {
readonly dataAttribute: true;
};
readonly isPending: {
readonly dataAttribute: true;
};
readonly loading: {
readonly dataAttribute: true;
};
@@ -2820,8 +2859,10 @@ export interface TableProps<T extends TableItem> {
// (undocumented)
error?: Error;
// (undocumented)
isStale?: boolean;
isPending?: boolean;
// (undocumented)
isStale?: boolean;
// @deprecated (undocumented)
loading?: boolean;
// (undocumented)
pagination: TablePaginationType;
@@ -2843,6 +2884,7 @@ export const TableRoot: (props: TableRootProps) => JSX_2.Element;
// @public (undocumented)
export type TableRootOwnProps = {
stale?: boolean;
isPending?: boolean;
loading?: boolean;
};
@@ -32,7 +32,7 @@ const meta = preview.meta({
icon: {
control: 'boolean',
},
loading: {
isPending: {
control: 'boolean',
},
},
@@ -208,25 +208,25 @@ export const WithActionsAndDescriptions = WithActions.extend({
},
});
export const LoadingVariants = meta.story({
export const PendingVariants = meta.story({
render: () => (
<Flex direction="column" gap="4">
<Text>Info</Text>
<Alert
status="info"
icon={true}
loading
isPending
title="Processing your request..."
/>
<Text>Success</Text>
<Alert status="success" icon={true} loading title="Saving changes..." />
<Alert status="success" icon={true} isPending title="Saving changes..." />
<Text>Warning</Text>
<Alert
status="warning"
icon={true}
loading
isPending
title="Checking for issues..."
/>
@@ -234,27 +234,27 @@ export const LoadingVariants = meta.story({
<Alert
status="danger"
icon={true}
loading
isPending
title="Attempting recovery..."
/>
</Flex>
),
});
export const LoadingWithDescription = meta.story({
export const PendingWithDescription = meta.story({
render: () => (
<Flex direction="column" gap="4">
<Alert
status="info"
icon={true}
loading
isPending
title="Processing your request"
description="This may take a few moments. Please do not close this window."
/>
<Alert
status="success"
icon={true}
loading
isPending
title="Deployment in Progress"
description="Your application is being deployed to production. You'll receive a notification when complete."
/>
+14 -6
View File
@@ -32,7 +32,7 @@ import { AlertDefinition } from './definition';
*
* @remarks
* The Alert component supports multiple status variants (info, success, warning, danger)
* and can display icons, loading states, and custom actions. It automatically handles
* and can display icons, pending states, and custom actions. It automatically handles
* icon selection based on status when the icon prop is set to true.
*
* @example
@@ -53,14 +53,14 @@ import { AlertDefinition } from './definition';
* ```
*
* @example
* With custom actions and loading state:
* With custom actions and pending state:
* ```tsx
* <Alert
* status="success"
* icon={true}
* title="Operation completed"
* description="Your changes have been saved successfully."
* loading={isProcessing}
* isPending={isProcessing}
* customActions={
* <>
* <Button size="small" variant="tertiary">Dismiss</Button>
@@ -76,13 +76,21 @@ export const Alert = forwardRef(
(props: AlertProps, ref: Ref<HTMLDivElement>) => {
const { ownProps, restProps, dataAttributes, utilityStyle } = useDefinition(
AlertDefinition,
props,
// Merge deprecated `loading` into `isPending` so data attributes and
// internal logic only need to check a single prop.
{
...props,
isPending:
props.isPending || props.loading
? true
: props.isPending ?? props.loading,
},
);
const {
classes,
status,
icon,
loading,
isPending,
customActions,
title,
description,
@@ -132,7 +140,7 @@ export const Alert = forwardRef(
data-has-description={description ? 'true' : 'false'}
>
<div className={classes.contentWrapper}>
{loading ? (
{isPending ? (
<div className={classes.icon}>
<ProgressBar
aria-label="Loading"
@@ -36,6 +36,7 @@ export const AlertDefinition = defineComponent<AlertOwnProps>()({
},
propDefs: {
status: { dataAttribute: true, default: 'info' },
isPending: { dataAttribute: true },
loading: { dataAttribute: true },
icon: {},
customActions: {},
@@ -21,6 +21,8 @@ import type { Responsive, MarginProps } from '../../types';
export type AlertOwnProps = {
status?: Responsive<'info' | 'success' | 'warning' | 'danger'>;
icon?: boolean | ReactElement;
isPending?: boolean;
/** @deprecated Use `isPending` instead. */
loading?: boolean;
customActions?: ReactNode;
title?: ReactNode;
+1 -1
View File
@@ -36,7 +36,7 @@ export const Box = forwardRef<HTMLDivElement, BoxProps>((props, ref) => {
{
ref,
className: classes.root,
style: { ...ownProps.style, ...utilityStyle },
style: { ...utilityStyle, ...ownProps.style },
...dataAttributes,
...restProps,
},
@@ -52,6 +52,9 @@ export const BoxDefinition = defineComponent<BoxOwnProps>()({
'py',
'position',
'display',
'grow',
'shrink',
'basis',
'width',
'minWidth',
'maxWidth',
+7 -1
View File
@@ -15,7 +15,12 @@
*/
import type { ReactNode, CSSProperties } from 'react';
import type { Responsive, ProviderBg, SpaceProps } from '../../types';
import type {
Responsive,
ProviderBg,
SpaceProps,
FlexItemProps,
} from '../../types';
/** @public */
export type BoxOwnProps = {
@@ -43,6 +48,7 @@ export type BoxUtilityProps = {
/** @public */
export interface BoxProps
extends SpaceProps,
FlexItemProps,
BoxOwnProps,
BoxUtilityProps,
Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {}
@@ -54,7 +54,7 @@
cursor: not-allowed;
}
&[data-loading='true'] {
&[data-ispending='true'] {
cursor: wait;
}
}
@@ -66,7 +66,7 @@
--fg: var(--bui-fg-solid);
&[data-disabled='true'],
&[data-loading='true'] {
&[data-ispending='true'] {
--bg: var(--bui-bg-solid-disabled);
--bg-hover: var(--bui-bg-solid-disabled);
--bg-active: var(--bui-bg-solid-disabled);
@@ -100,7 +100,7 @@
--fg: var(--fg-solid-danger);
&[data-disabled='true'],
&[data-loading='true'] {
&[data-ispending='true'] {
--bg: var(--bg-solid-danger-disabled);
--bg-hover: var(--bg-solid-danger-disabled);
--bg-active: var(--bg-solid-danger-disabled);
@@ -137,7 +137,7 @@
}
&[data-disabled='true'],
&[data-loading='true'] {
&[data-ispending='true'] {
--bg-hover: var(--bg);
--bg-active: var(--bg);
--fg: var(--bui-fg-disabled);
@@ -166,7 +166,7 @@
--fg: var(--bui-fg-danger);
&[data-disabled='true'],
&[data-loading='true'] {
&[data-ispending='true'] {
--bg-hover: var(--bg);
--bg-active: var(--bg);
--fg: var(--bui-fg-disabled);
@@ -198,7 +198,7 @@
}
&[data-disabled='true'],
&[data-loading='true'] {
&[data-ispending='true'] {
--bg-hover: var(--bg);
--bg-active: var(--bg);
--fg: var(--bui-fg-disabled);
@@ -226,7 +226,7 @@
--fg: var(--bui-fg-danger);
&[data-disabled='true'],
&[data-loading='true'] {
&[data-ispending='true'] {
--bg-hover: var(--bg);
--bg-active: var(--bg);
--fg: var(--bui-fg-disabled);
@@ -268,7 +268,7 @@
width: 100%;
transition: opacity var(--loading-duration) ease-out;
.bui-Button[data-loading='true'] & {
.bui-Button[data-ispending='true'] & {
opacity: 0;
}
}
@@ -282,7 +282,7 @@
opacity: 0;
transition: opacity var(--loading-duration) ease-in;
.bui-Button[data-loading='true'] & {
.bui-Button[data-ispending='true'] & {
opacity: 1;
}
@@ -107,7 +107,7 @@ export const Destructive = meta.story({
<Button variant="primary" destructive isDisabled>
Disabled
</Button>
<Button variant="primary" destructive loading>
<Button variant="primary" destructive isPending>
Loading
</Button>
</Flex>
@@ -124,7 +124,7 @@ export const Destructive = meta.story({
<Button variant="secondary" destructive isDisabled>
Disabled
</Button>
<Button variant="secondary" destructive loading>
<Button variant="secondary" destructive isPending>
Loading
</Button>
</Flex>
@@ -141,7 +141,7 @@ export const Destructive = meta.story({
<Button variant="tertiary" destructive isDisabled>
Disabled
</Button>
<Button variant="tertiary" destructive loading>
<Button variant="tertiary" destructive isPending>
Loading
</Button>
</Flex>
@@ -254,94 +254,94 @@ export const Responsive = meta.story({
},
});
export const Loading = meta.story({
export const Pending = meta.story({
render: () => {
const [isLoading, setIsLoading] = useState(false);
const [isPending, setIsPending] = useState(false);
const handleClick = () => {
setIsLoading(true);
setIsPending(true);
setTimeout(() => {
setIsLoading(false);
setIsPending(false);
}, 3000);
};
return (
<Button variant="primary" loading={isLoading} onPress={handleClick}>
<Button variant="primary" isPending={isPending} onPress={handleClick}>
Load more items
</Button>
);
},
});
export const LoadingVariants = meta.story({
export const PendingVariants = meta.story({
render: () => (
<Flex direction="column" gap="4">
<Text>Primary</Text>
<Flex align="center" gap="4">
<Button variant="primary" size="small" loading>
<Button variant="primary" size="small" isPending>
Small Loading
</Button>
<Button variant="primary" size="medium" loading>
<Button variant="primary" size="medium" isPending>
Medium Loading
</Button>
<Button variant="primary" loading iconStart={<RiCloudLine />}>
<Button variant="primary" isPending iconStart={<RiCloudLine />}>
With Icon
</Button>
</Flex>
<Text>Secondary</Text>
<Flex align="center" gap="4">
<Button variant="secondary" size="small" loading>
<Button variant="secondary" size="small" isPending>
Small Loading
</Button>
<Button variant="secondary" size="medium" loading>
<Button variant="secondary" size="medium" isPending>
Medium Loading
</Button>
<Button variant="secondary" loading iconStart={<RiCloudLine />}>
<Button variant="secondary" isPending iconStart={<RiCloudLine />}>
With Icon
</Button>
</Flex>
<Text>Tertiary</Text>
<Flex align="center" gap="4">
<Button variant="tertiary" size="small" loading>
<Button variant="tertiary" size="small" isPending>
Small Loading
</Button>
<Button variant="tertiary" size="medium" loading>
<Button variant="tertiary" size="medium" isPending>
Medium Loading
</Button>
<Button variant="tertiary" loading iconStart={<RiCloudLine />}>
<Button variant="tertiary" isPending iconStart={<RiCloudLine />}>
With Icon
</Button>
</Flex>
<Text>Primary Destructive</Text>
<Flex align="center" gap="4">
<Button variant="primary" destructive size="small" loading>
<Button variant="primary" destructive size="small" isPending>
Small Loading
</Button>
<Button variant="primary" destructive size="medium" loading>
<Button variant="primary" destructive size="medium" isPending>
Medium Loading
</Button>
<Button
variant="primary"
destructive
loading
isPending
iconStart={<RiCloudLine />}
>
With Icon
</Button>
</Flex>
<Text>Loading vs Disabled</Text>
<Text>Pending vs Disabled</Text>
<Flex align="center" gap="4">
<Button variant="primary" loading>
<Button variant="primary" isPending>
Loading
</Button>
<Button variant="primary" isDisabled>
Disabled
</Button>
<Button variant="primary" loading isDisabled>
<Button variant="primary" isPending isDisabled>
Both (Disabled Wins)
</Button>
</Flex>
+12 -4
View File
@@ -43,7 +43,7 @@ import { ButtonDefinition } from './definition';
* variant="primary"
* size="medium"
* iconStart={<IconComponent />}
* loading={isSubmitting}
* isPending={isSubmitting}
* >
* Submit
* </Button>
@@ -55,15 +55,23 @@ export const Button = forwardRef(
(props: ButtonProps, ref: Ref<HTMLButtonElement>) => {
const { ownProps, restProps, dataAttributes } = useDefinition(
ButtonDefinition,
props,
// Merge deprecated `loading` into `isPending` so data attributes and
// internal logic only need to check a single prop.
{
...props,
isPending:
props.isPending || props.loading
? true
: props.isPending ?? props.loading,
},
);
const { classes, iconStart, iconEnd, loading, children } = ownProps;
const { classes, iconStart, iconEnd, isPending, children } = ownProps;
return (
<RAButton
className={classes.root}
ref={ref}
isPending={loading}
isPending={isPending}
{...dataAttributes}
{...restProps}
>
@@ -34,6 +34,7 @@ export const ButtonDefinition = defineComponent<ButtonOwnProps>()({
size: { dataAttribute: true, default: 'small' },
variant: { dataAttribute: true, default: 'primary' },
destructive: { dataAttribute: true },
isPending: { dataAttribute: true },
loading: { dataAttribute: true },
iconStart: {},
iconEnd: {},
@@ -25,6 +25,8 @@ export type ButtonOwnProps = {
destructive?: boolean;
iconStart?: ReactElement;
iconEnd?: ReactElement;
isPending?: boolean;
/** @deprecated Use `isPending` instead. */
loading?: boolean;
children?: ReactNode;
className?: string;
@@ -54,7 +54,7 @@
cursor: not-allowed;
}
&[data-loading='true'] {
&[data-ispending='true'] {
cursor: wait;
}
}
@@ -66,7 +66,7 @@
--fg: var(--bui-fg-solid);
&[data-disabled='true'],
&[data-loading='true'] {
&[data-ispending='true'] {
--bg: var(--bui-bg-solid-disabled);
--bg-hover: var(--bui-bg-solid-disabled);
--bg-active: var(--bui-bg-solid-disabled);
@@ -104,7 +104,7 @@
}
&[data-disabled='true'],
&[data-loading='true'] {
&[data-ispending='true'] {
--bg-hover: var(--bg);
--bg-active: var(--bg);
--fg: var(--bui-fg-disabled);
@@ -138,7 +138,7 @@
}
&[data-disabled='true'],
&[data-loading='true'] {
&[data-ispending='true'] {
--bg-hover: var(--bg);
--bg-active: var(--bg);
--fg: var(--bui-fg-disabled);
@@ -179,7 +179,7 @@
width: 100%;
transition: opacity var(--loading-duration) ease-out;
.bui-ButtonIcon[data-loading='true'] & {
.bui-ButtonIcon[data-ispending='true'] & {
opacity: 0;
}
}
@@ -193,7 +193,7 @@
opacity: 0;
transition: opacity var(--loading-duration) ease-in;
.bui-ButtonIcon[data-loading='true'] & {
.bui-ButtonIcon[data-ispending='true'] & {
opacity: 1;
}
@@ -82,14 +82,14 @@ export const Responsive = meta.story({
render: args => <ButtonIcon {...args} icon={<RiCloudLine />} />,
});
export const Loading = meta.story({
export const Pending = meta.story({
render: () => {
const [isLoading, setIsLoading] = useState(false);
const [isPending, setIsPending] = useState(false);
const handleClick = () => {
setIsLoading(true);
setIsPending(true);
setTimeout(() => {
setIsLoading(false);
setIsPending(false);
}, 3000);
};
@@ -97,14 +97,14 @@ export const Loading = meta.story({
<ButtonIcon
variant="primary"
icon={<RiCloudLine />}
loading={isLoading}
isPending={isPending}
onPress={handleClick}
/>
);
},
});
export const LoadingVariants = meta.story({
export const PendingVariants = meta.story({
render: () => (
<Flex direction="column" gap="4">
<Text>Primary</Text>
@@ -113,13 +113,13 @@ export const LoadingVariants = meta.story({
variant="primary"
size="small"
icon={<RiCloudLine />}
loading
isPending
/>
<ButtonIcon
variant="primary"
size="medium"
icon={<RiCloudLine />}
loading
isPending
/>
</Flex>
@@ -129,13 +129,13 @@ export const LoadingVariants = meta.story({
variant="secondary"
size="small"
icon={<RiCloudLine />}
loading
isPending
/>
<ButtonIcon
variant="secondary"
size="medium"
icon={<RiCloudLine />}
loading
isPending
/>
</Flex>
@@ -145,24 +145,24 @@ export const LoadingVariants = meta.story({
variant="tertiary"
size="small"
icon={<RiCloudLine />}
loading
isPending
/>
<ButtonIcon
variant="tertiary"
size="medium"
icon={<RiCloudLine />}
loading
isPending
/>
</Flex>
<Text>Loading vs Disabled</Text>
<Text>Pending vs Disabled</Text>
<Flex align="center" gap="4">
<ButtonIcon variant="primary" icon={<RiCloudLine />} loading />
<ButtonIcon variant="primary" icon={<RiCloudLine />} isPending />
<ButtonIcon variant="primary" icon={<RiCloudLine />} isDisabled />
<ButtonIcon
variant="primary"
icon={<RiCloudLine />}
loading
isPending
isDisabled
/>
</Flex>
@@ -30,15 +30,23 @@ export const ButtonIcon = forwardRef(
(props: ButtonIconProps, ref: Ref<HTMLButtonElement>) => {
const { ownProps, restProps, dataAttributes } = useDefinition(
ButtonIconDefinition,
props,
// Merge deprecated `loading` into `isPending` so data attributes and
// internal logic only need to check a single prop.
{
...props,
isPending:
props.isPending || props.loading
? true
: props.isPending ?? props.loading,
},
);
const { classes, icon, loading } = ownProps;
const { classes, icon, isPending } = ownProps;
return (
<RAButton
className={classes.root}
ref={ref}
isPending={loading}
isPending={isPending}
{...dataAttributes}
{...restProps}
>
@@ -33,6 +33,7 @@ export const ButtonIconDefinition = defineComponent<ButtonIconOwnProps>()({
propDefs: {
size: { dataAttribute: true, default: 'small' },
variant: { dataAttribute: true, default: 'primary' },
isPending: { dataAttribute: true },
loading: { dataAttribute: true },
icon: {},
className: {},
@@ -23,6 +23,8 @@ export type ButtonIconOwnProps = {
size?: Responsive<'small' | 'medium'>;
variant?: Responsive<'primary' | 'secondary' | 'tertiary'>;
icon?: ReactElement;
isPending?: boolean;
/** @deprecated Use `isPending` instead. */
loading?: boolean;
className?: string;
};
+2 -1
View File
@@ -41,7 +41,7 @@ const INTERACTIVE_ELEMENT_SELECTOR =
* @public
*/
export const Card = forwardRef<HTMLDivElement, CardProps>((props, ref) => {
const { ownProps, restProps, dataAttributes } = useDefinition(
const { ownProps, restProps, dataAttributes, utilityStyle } = useDefinition(
CardDefinition,
props,
);
@@ -98,6 +98,7 @@ export const Card = forwardRef<HTMLDivElement, CardProps>((props, ref) => {
{...dataAttributes}
{...restProps}
onClick={isInteractive ? handleClick : undefined}
style={{ ...utilityStyle, ...ownProps.style }}
>
{href && (
<Link
@@ -42,7 +42,9 @@ export const CardDefinition = defineComponent<CardOwnProps>()({
target: {},
rel: {},
download: {},
style: {},
},
utilityProps: ['grow', 'shrink', 'basis'],
});
/**
+9 -2
View File
@@ -14,11 +14,16 @@
* limitations under the License.
*/
import type { ReactNode } from 'react';
import type { ReactNode, CSSProperties } from 'react';
import type { ButtonProps as RAButtonProps } from 'react-aria-components';
import type { FlexItemProps } from '../../types';
/** @public */
export type CardBaseProps = { children?: ReactNode; className?: string };
export type CardBaseProps = {
children?: ReactNode;
className?: string;
style?: CSSProperties;
};
/** @public */
export type CardButtonVariant = {
@@ -63,6 +68,7 @@ export type CardStaticVariant = {
* @public
*/
export type CardProps = CardBaseProps &
FlexItemProps &
Omit<React.HTMLAttributes<HTMLDivElement>, 'onClick'> &
(CardButtonVariant | CardLinkVariant | CardStaticVariant);
@@ -81,6 +87,7 @@ export type CardOwnProps = Pick<
| 'target'
| 'rel'
| 'download'
| 'style'
>;
/** @public */
@@ -16,7 +16,9 @@
import preview from '../../../../../.storybook/preview';
import { Flex } from './Flex';
import { Text } from '../Text';
import { Box } from '../Box';
import { Box, BoxProps } from '../Box';
import { Card, CardHeader, CardBody, CardFooter } from '../Card';
import { Grid } from '../Grid';
const meta = preview.meta({
title: 'Backstage UI/Flex',
@@ -41,10 +43,9 @@ const meta = preview.meta({
const DecorativeBox = ({
width = '48px',
height = '48px',
}: {
width?: string;
height?: string;
}) => {
style,
...props
}: Omit<BoxProps, 'children'>) => {
const diagonalStripePattern = (() => {
const svg = `
<svg width="6" height="6" viewBox="0 0 6 6" xmlns="http://www.w3.org/2000/svg">
@@ -58,9 +59,11 @@ const DecorativeBox = ({
return (
<Box
{...props}
width={width}
height={height}
style={{
...style,
background: '#eaf2fd',
borderRadius: '4px',
border: '1px solid #2563eb',
@@ -199,6 +202,122 @@ export const ResponsiveAlign = meta.story({
),
});
export const FlexItems = meta.story({
args: {
component: 'Box',
grow: 1,
shrink: 0,
basis: 'auto',
},
argTypes: {
component: {
control: { type: 'select' },
options: ['Box', 'Card', 'Grid', 'Flex'],
mapping: {
Box: props => <DecorativeBox height="100%" width="256px" {...props} />,
Card: props => (
<Card style={{ height: '100%', width: '256px' }} {...props}>
<CardHeader>
<Text>Header</Text>
</CardHeader>
<CardBody>
<Text>
This is the first paragraph of a long body text that
demonstrates how the Card component handles extensive content.
The card should adjust accordingly to display all the text
properly while maintaining its structure.
</Text>
<Text>
Here's a second paragraph that adds more content to our card
body. Having multiple paragraphs helps to visualize how spacing
works within the card component.
</Text>
<Text>
This third paragraph continues to add more text to ensure we
have a proper demonstration of a card with significant content.
This makes it easier to test scrolling behavior and overall
layout when content exceeds the initial view.
</Text>
</CardBody>
<CardFooter>
<Text>Footer</Text>
</CardFooter>
</Card>
),
Grid: props => (
<Grid.Root
{...props}
height="128px"
style={{ width: '256px' }}
columns="3"
>
<Grid.Item colSpan="1" rowSpan="2">
<DecorativeBox height="100%" width="100%" />
</Grid.Item>
<Grid.Item colSpan="2">
<DecorativeBox height="100%" width="100%" />
</Grid.Item>
<Grid.Item colSpan="2">
<DecorativeBox height="100%" width="100%" />
</Grid.Item>
</Grid.Root>
),
Flex: props => (
<Flex
{...props}
height="128px"
style={{ width: '256px' }}
justify="between"
>
<DecorativeBox height="100%" />
<DecorativeBox height="100%" />
<DecorativeBox height="100%" />
</Flex>
),
},
},
grow: {
control: 'radio',
options: [undefined, 0, 1, false, true],
},
shrink: {
control: 'radio',
options: [undefined, 0, 1, false, true],
},
basis: {
control: 'radio',
options: [undefined, '0%', '25%', '50%', '100%', 100, '250px', 'auto'],
},
},
render: ({ component: Component, ...args }) => {
return (
<Flex style={{ width: '100%', height: '256px' }}>
<div
style={{
width: '256px',
flex: '1 1 auto',
background:
'repeating-linear-gradient(-45deg, transparent 0px, transparent 5px, #e8e8e8 5px, #e8e8e8 10px)',
borderRadius: '12px',
}}
/>
<Component {...args} />
<div
style={{
width: '256px',
flex: '1 1 auto',
background:
'repeating-linear-gradient(-45deg, transparent 0px, transparent 5px, #e8e8e8 5px, #e8e8e8 10px)',
borderRadius: '12px',
}}
/>
</Flex>
);
},
});
export const ResponsiveGap = meta.story({
args: {
gap: { xs: '4', md: '8', lg: '12' },
@@ -53,5 +53,8 @@ export const FlexDefinition = defineComponent<FlexOwnProps>()({
'align',
'justify',
'direction',
'grow',
'shrink',
'basis',
],
});
+8 -1
View File
@@ -15,7 +15,13 @@
*/
import type { ReactNode, CSSProperties } from 'react';
import type { Responsive, Space, SpaceProps, ProviderBg } from '../../types';
import type {
Responsive,
Space,
SpaceProps,
ProviderBg,
FlexItemProps,
} from '../../types';
/** @public */
export type FlexOwnProps = {
@@ -28,6 +34,7 @@ export type FlexOwnProps = {
/** @public */
export interface FlexProps
extends SpaceProps,
FlexItemProps,
FlexOwnProps,
Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
gap?: Responsive<Space>;
@@ -51,6 +51,9 @@ export const GridDefinition = defineComponent<GridOwnProps>()({
'pt',
'px',
'py',
'grow',
'shrink',
'basis',
],
});
+2
View File
@@ -21,6 +21,7 @@ import type {
Responsive,
Columns,
ProviderBg,
FlexItemProps,
} from '../../types';
/** @public */
@@ -34,6 +35,7 @@ export type GridOwnProps = {
/** @public */
export interface GridProps
extends SpaceProps,
FlexItemProps,
GridOwnProps,
Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
columns?: Responsive<Columns>;
@@ -41,7 +41,7 @@
min-height: 0;
&[data-stale='true'],
&[data-loading='true'] {
&[data-ispending='true'] {
opacity: 0.6;
}
}
@@ -107,6 +107,7 @@ function useLiveRegionLabel(
export function Table<T extends TableItem>({
columnConfig,
data,
isPending = false,
loading = false,
isStale = false,
error,
@@ -119,6 +120,7 @@ export function Table<T extends TableItem>({
style,
virtualized,
}: TableProps<T>) {
const pending = isPending || loading;
const {
ownProps: { classes },
} = useDefinition(TableWrapperDefinition, { className });
@@ -137,7 +139,7 @@ export function Table<T extends TableItem>({
onSelectionChange,
} = selection || {};
const isInitialLoading = loading && !data;
const isInitialLoading = pending && !data;
if (error) {
return (
@@ -202,7 +204,7 @@ export function Table<T extends TableItem>({
onSortChange={sort?.onSortChange}
disabledKeys={disabledRows}
stale={isStale}
loading={isInitialLoading}
isPending={isInitialLoading}
aria-describedby={liveRegionId}
>
<TableHeader columns={visibleColumns}>
@@ -28,14 +28,22 @@ import { TableRootProps } from '../types';
export const TableRoot = (props: TableRootProps) => {
const { ownProps, restProps, dataAttributes } = useDefinition(
TableDefinition,
props,
// Merge deprecated `loading` into `isPending` so data attributes and
// internal logic only need to check a single prop.
{
...props,
isPending:
props.isPending || props.loading
? true
: props.isPending ?? props.loading,
},
);
return (
<ReactAriaTable
className={ownProps.classes.root}
aria-label="Data table"
aria-busy={ownProps.stale || ownProps.loading}
aria-busy={ownProps.stale || ownProps.isPending}
{...dataAttributes}
{...restProps}
/>
@@ -52,6 +52,7 @@ export const TableDefinition = defineComponent<TableRootOwnProps>()({
},
propDefs: {
stale: { dataAttribute: true },
isPending: { dataAttribute: true },
loading: { dataAttribute: true },
},
});
@@ -158,7 +158,7 @@ export interface UseTableResult<T extends TableItem, TFilter = unknown> {
/** @internal */
export interface PaginationResult<T> {
data: T[] | undefined;
loading: boolean;
isPending: boolean;
error: Error | undefined;
totalCount: number | undefined;
offset?: number;
@@ -48,7 +48,7 @@ export function useCompletePagination<T extends TableItem, TFilter>(
const { sort, filter, search } = query;
const [items, setItems] = useState<T[] | undefined>(undefined);
const [isLoading, setIsLoading] = useState(!data);
const [isPending, setIsPending] = useState(!data);
const [error, setError] = useState<Error | undefined>(undefined);
const [loadCount, setLoadCount] = useState(0);
@@ -64,7 +64,7 @@ export function useCompletePagination<T extends TableItem, TFilter>(
// Load data on mount and when loadCount changes (reload trigger)
useEffect(() => {
if (data) {
setIsLoading(false);
setIsPending(false);
return;
}
@@ -73,7 +73,7 @@ export function useCompletePagination<T extends TableItem, TFilter>(
}
let cancelled = false;
setIsLoading(true);
setIsPending(true);
setError(undefined);
(async () => {
@@ -82,12 +82,12 @@ export function useCompletePagination<T extends TableItem, TFilter>(
const resolvedData = result instanceof Promise ? await result : result;
if (!cancelled) {
setItems(resolvedData);
setIsLoading(false);
setIsPending(false);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err : new Error(String(err)));
setIsLoading(false);
setIsPending(false);
}
}
})();
@@ -164,7 +164,7 @@ export function useCompletePagination<T extends TableItem, TFilter>(
return {
data: paginatedData,
loading: isLoading,
isPending: isPending,
error,
totalCount,
offset,
@@ -78,7 +78,7 @@ export function useCursorPagination<T extends TableItem, TFilter>(
return {
data: cache.data,
loading: cache.loading,
isPending: cache.isPending,
error: cache.error,
totalCount: cache.totalCount,
offset: undefined,
@@ -91,7 +91,7 @@ export function useOffsetPagination<T extends TableItem, TFilter>(
return {
data: cache.data,
loading: cache.loading,
isPending: cache.isPending,
error: cache.error,
totalCount: cache.totalCount,
offset: cache.currentCursor ?? 0,
@@ -48,7 +48,7 @@ export interface UsePageCacheOptions<T, TCursor extends CursorType = string> {
/** @internal */
export interface UsePageCacheResult<T, TCursor extends CursorType = string> {
loading: boolean;
isPending: boolean;
error: Error | undefined;
data: T[] | undefined;
totalCount: number | undefined;
@@ -149,7 +149,7 @@ export function usePageCache<T, TCursor extends CursorType = string>(
const cacheStore = useRef(new PageCacheStore<T, TCursor>()).current;
const [loading, setLoading] = useState(true);
const [isPending, setIsPending] = useState(true);
const [error, setError] = useState<Error | undefined>(undefined);
const [totalCount, setTotalCount] = useState<number | undefined>(undefined);
@@ -189,7 +189,7 @@ export function usePageCache<T, TCursor extends CursorType = string>(
const abortController = new AbortController();
abortControllerRef.current = abortController;
setLoading(true);
setIsPending(true);
setError(undefined);
try {
@@ -215,14 +215,14 @@ export function usePageCache<T, TCursor extends CursorType = string>(
setTotalCount(result.totalCount);
}
setLoading(false);
setIsPending(false);
} catch (err) {
if (abortController.signal.aborted) {
return;
}
setError(err instanceof Error ? err : new Error(String(err)));
setLoading(false);
setIsPending(false);
}
},
[getData, initialCurrentCursor, currentCursor, cacheStore],
@@ -239,18 +239,18 @@ export function usePageCache<T, TCursor extends CursorType = string>(
}, []);
const onNextPage = useCallback(() => {
if (loading) return;
if (isPending) return;
const page = cacheStore.get(currentCursor);
if (!page?.nextCursor) return;
goToPage('next');
}, [loading, currentCursor, goToPage, cacheStore]);
}, [isPending, currentCursor, goToPage, cacheStore]);
const onPreviousPage = useCallback(() => {
if (loading) return;
if (isPending) return;
const page = cacheStore.get(currentCursor);
if (!page?.prevCursor) return;
goToPage('prev');
}, [loading, currentCursor, goToPage, cacheStore]);
}, [isPending, currentCursor, goToPage, cacheStore]);
const reload = useCallback(
(reloadOptions?: { keepCurrentCursor?: boolean }) => {
@@ -266,7 +266,7 @@ export function usePageCache<T, TCursor extends CursorType = string>(
);
return {
loading,
isPending,
error,
data,
totalCount,
@@ -52,7 +52,7 @@ function useTableProps<T extends TableItem>(
}
const displayData = paginationResult.data ?? previousDataRef.current;
const isStale = paginationResult.loading && displayData !== undefined;
const isStale = paginationResult.isPending && displayData !== undefined;
const pagination = useMemo(() => {
if (paginationOptions.type === 'none') {
@@ -104,7 +104,8 @@ function useTableProps<T extends TableItem>(
return useMemo(
() => ({
data: displayData,
loading: paginationResult.loading,
isPending: paginationResult.isPending,
loading: paginationResult.isPending,
isStale,
error: paginationResult.error,
pagination,
@@ -112,7 +113,7 @@ function useTableProps<T extends TableItem>(
}),
[
displayData,
paginationResult.loading,
paginationResult.isPending,
isStale,
paginationResult.error,
pagination,
@@ -274,7 +274,7 @@ export const LoadingState: Story = {
<Table
columnConfig={columns}
data={undefined}
loading={true}
isPending
pagination={{ type: 'none' }}
/>
);
@@ -42,6 +42,8 @@ export interface SortState {
/** @public */
export type TableRootOwnProps = {
stale?: boolean;
isPending?: boolean;
/** @deprecated Use `isPending` instead. */
loading?: boolean;
};
@@ -263,6 +265,8 @@ export type VirtualizedProp =
export interface TableProps<T extends TableItem> {
columnConfig: readonly ColumnConfig<T>[];
data: T[] | undefined;
isPending?: boolean;
/** @deprecated Use `isPending` instead. */
loading?: boolean;
isStale?: boolean;
error?: Error;
+72
View File
@@ -67,6 +67,18 @@
flex-direction: column-reverse;
}
.bui-grow {
flex-grow: var(--grow);
}
.bui-shrink {
flex-shrink: var(--shrink);
}
.bui-basis {
flex-basis: var(--basis);
}
/* Breakpoint xs */
@media (min-width: 640px) {
.xs\:bui-align-start {
@@ -116,6 +128,18 @@
.xs\:bui-fd-column-reverse {
flex-direction: column-reverse;
}
.xs\:bui-grow {
flex-grow: var(--grow-xs);
}
.xs\:bui-shrink {
flex-shrink: var(--shrink-xs);
}
.xs\:bui-basis {
flex-basis: var(--basis-xs);
}
}
/* Breakpoint sm */
@@ -167,6 +191,18 @@
.sm\:bui-fd-column-reverse {
flex-direction: column-reverse;
}
.sm\:bui-grow {
flex-grow: var(--grow-sm);
}
.sm\:bui-shrink {
flex-shrink: var(--shrink-sm);
}
.sm\:bui-basis {
flex-basis: var(--basis-sm);
}
}
/* Breakpoint md */
@@ -218,6 +254,18 @@
.md\:bui-fd-column-reverse {
flex-direction: column-reverse;
}
.md\:bui-grow {
flex-grow: var(--grow-md);
}
.md\:bui-shrink {
flex-shrink: var(--shrink-md);
}
.md\:bui-basis {
flex-basis: var(--basis-md);
}
}
/* Breakpoint lg */
@@ -269,6 +317,18 @@
.lg\:bui-fd-column-reverse {
flex-direction: column-reverse;
}
.lg\:bui-grow {
flex-grow: var(--grow-lg);
}
.lg\:bui-shrink {
flex-shrink: var(--shrink-lg);
}
.lg\:bui-basis {
flex-basis: var(--basis-lg);
}
}
/* Breakpoint xl */
@@ -320,5 +380,17 @@
.xl\:bui-fd-column-reverse {
flex-direction: column-reverse;
}
.xl\:bui-grow {
flex-grow: var(--grow-xl);
}
.xl\:bui-shrink {
flex-shrink: var(--shrink-xl);
}
.xl\:bui-basis {
flex-basis: var(--basis-xl);
}
}
}
@@ -102,7 +102,7 @@ export function processUtilityProps<Keys extends string>(
const handleUtilityValue = (
key: string,
val: unknown,
inputVal: unknown,
prefix: string = '',
) => {
// Get utility class configuration for this key
@@ -113,6 +113,11 @@ export function processUtilityProps<Keys extends string>(
return;
}
const val =
'transform' in utilityConfig
? utilityConfig.transform(inputVal)
: inputVal;
// Check if value is in the list of valid values for this utility
const values = utilityConfig.values as readonly (string | number)[];
if (values.length > 0 && values.includes(val as string | number)) {
+16
View File
@@ -14,6 +14,8 @@
* limitations under the License.
*/
import type { CSSProperties } from 'react';
/** @public */
export type Breakpoint = 'initial' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
@@ -120,6 +122,20 @@ export interface PaddingProps {
/** @public */
export interface SpaceProps extends MarginProps, PaddingProps {}
/**
* Flex item properties.
*
* @public
*/
export interface FlexItemProps {
/** Controls the flex-grow property. Values of `true` or `false` are converted to `1` or `0` respectively. */
grow?: Responsive<number | boolean>;
/** Controls the flex-shrink property. Values of `true` or `false` are converted to `1` or `0` respectively. */
shrink?: Responsive<number | boolean>;
/** Controls the flex-basis property. */
basis?: Responsive<CSSProperties['flexBasis']>;
}
/** @public */
export type TextVariants =
| 'title-large'
+24 -1
View File
@@ -196,7 +196,30 @@ export const utilityClassMap = {
class: 'bui-row-span',
values: columnsValues,
},
grow: {
class: 'bui-grow',
cssVar: '--grow',
values: [],
transform: input => (typeof input === 'boolean' ? Number(input) : input),
},
shrink: {
class: 'bui-shrink',
cssVar: '--shrink',
values: [],
transform: input => (typeof input === 'boolean' ? Number(input) : input),
},
basis: {
class: 'bui-basis',
cssVar: '--basis',
values: [],
transform: input => (typeof input === 'number' ? `${input}px` : input),
},
} as const satisfies Record<
string,
{ class: string; cssVar?: string; values: readonly (string | number)[] }
{
class: string;
cssVar?: string;
values: readonly (string | number)[];
transform?: (input: unknown) => unknown;
}
>;