Compare commits

...

4 Commits

Author SHA1 Message Date
Jake Howell 64536563a8 fix: add ARIA progressbar attrs and remove width transition
Addresses review feedback:
- Add role="progressbar", aria-valuemin, aria-valuemax, and
  aria-valuenow (determinate only) to restore accessibility semantics
  lost when replacing MUI's LinearProgress with a plain div.
- Remove transition-[width] duration-200 from the determinate bar to
  avoid jitter/interpolation artifacts caused by the requestAnimationFrame
  update loop in WorkspaceBuildProgress.
2026-04-12 14:57:10 +00:00
Jake Howell 0dbe5a00d6 fix: remove dead mui code 2026-04-12 14:23:23 +00:00
Jake Howell 970f3a818b fix: remove dead code 2026-04-12 14:21:29 +00:00
Jake Howell abe3ef8cdb feat: demui the <LinearProgress /> dependency 2026-04-12 14:18:53 +00:00
5 changed files with 182 additions and 40 deletions
@@ -0,0 +1,80 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useEffect, useState } from "react";
import LinearProgress from "./LinearProgress";
const meta: Meta<typeof LinearProgress> = {
title: "Components/LinearProgress",
component: LinearProgress,
args: {
variant: "determinate",
value: 40,
},
argTypes: {
variant: {
control: "inline-radio",
options: ["determinate", "indeterminate"],
},
value: {
control: { type: "range", min: 0, max: 100, step: 1 },
if: { arg: "variant", eq: "determinate" },
},
},
};
export default meta;
type Story = StoryObj<typeof LinearProgress>;
export const Default: Story = {};
export const Indeterminate: Story = {
args: {
variant: "indeterminate",
value: 0,
},
parameters: {
chromatic: { disable: true },
},
};
export const Determinate: Story = {
args: {
variant: "determinate",
value: 62,
},
};
export const DeterminateSamples: Story = {
render: () => (
<div className="flex w-full max-w-md flex-col gap-4">
{([0, 25, 50, 75, 100] as const).map((value) => (
<div key={value} className="flex flex-col gap-1">
<span className="text-content-secondary text-xs">{value}%</span>
<LinearProgress variant="determinate" value={value} />
</div>
))}
</div>
),
};
export const ControlledDeterminate: Story = {
render: function ControlledDeterminateRender() {
const [value, setValue] = useState(0);
useEffect(() => {
const id = window.setInterval(() => {
setValue((previous) => (previous >= 100 ? 0 : previous + 2));
}, 120);
return () => window.clearInterval(id);
}, []);
return (
<div className="flex w-full max-w-md flex-col gap-2">
<span className="text-content-secondary text-xs tabular-nums">
{value}%
</span>
<LinearProgress variant="determinate" value={value} />
</div>
);
},
parameters: {
chromatic: { disable: true },
},
};
@@ -0,0 +1,56 @@
import type React from "react";
import type { FC } from "react";
import { cn } from "#/utils/cn";
type LinearProgressProps = React.ComponentProps<"div"> & {
value: number;
variant: "determinate" | "indeterminate";
};
const LinearProgress: FC<LinearProgressProps> = ({
value,
className,
variant,
...props
}) => {
const isDeterminate = variant === "determinate";
return (
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
{...(isDeterminate ? { "aria-valuenow": Math.round(value) } : {})}
className={cn(
"w-full h-1 bg-surface-sky rounded-full relative",
"overflow-hidden block",
className,
)}
{...props}
>
{!isDeterminate ? (
<>
<div
className={cn(
"absolute inset-y-0 w-auto origin-left rounded-full bg-highlight-sky",
"animate-bar-indeterminate",
)}
/>
<div
className={cn(
"absolute inset-y-0 w-auto origin-left rounded-full bg-highlight-sky",
"animate-bar-indeterminate-2",
)}
/>
</>
) : (
<div
className="h-full rounded-full bg-highlight-sky"
style={{ width: `${value}%` }}
/>
)}
</div>
);
};
export default LinearProgress;
@@ -1,6 +1,3 @@
import { css } from "@emotion/css";
import type { Interpolation, Theme } from "@emotion/react";
import LinearProgress from "@mui/material/LinearProgress";
import dayjs, { type Dayjs } from "dayjs";
import duration from "dayjs/plugin/duration";
import capitalize from "lodash/capitalize";
@@ -10,6 +7,7 @@ import type {
TransitionStats,
Workspace,
} from "#/api/typesGenerated";
import LinearProgress from "#/components/LinearProgress/LinearProgress";
dayjs.extend(duration);
@@ -124,10 +122,13 @@ export const WorkspaceBuildProgress: FC<WorkspaceBuildProgressProps> = ({
return null;
}
return (
<div css={styles.stack}>
<div className="px-0.5">
{variant === "task" && (
<div className="mb-1 text-center">
<div css={styles.label} data-chromatic="ignore">
<div
className="block text-xs font-semibold text-content-secondary"
data-chromatic="ignore"
>
{progressText}
</div>
</div>
@@ -144,22 +145,16 @@ export const WorkspaceBuildProgress: FC<WorkspaceBuildProgressProps> = ({
? "determinate"
: "indeterminate"
}
classes={{
// If a transition is set, there is a moment on new load where the bar
// accelerates to progressValue and then rapidly decelerates, which is
// not indicative of true progress.
bar: classNames.bar,
// With the "task" variant, the progress bar is fullscreen, so remove
// the border radius.
root: variant === "task" ? classNames.root : undefined,
}}
/>
{variant !== "task" && (
<div className="flex mt-1 justify-between">
<div css={styles.label}>
<div className="flex mt-2.5 justify-between">
<div className="block text-xs font-semibold text-content-secondary">
{capitalize(workspace.latest_build.status)} workspace...
</div>
<div css={styles.label} data-chromatic="ignore">
<div
className="block text-xs font-semibold text-content-secondary"
data-chromatic="ignore"
>
{progressText}
</div>
</div>
@@ -167,25 +162,3 @@ export const WorkspaceBuildProgress: FC<WorkspaceBuildProgressProps> = ({
</div>
);
};
const classNames = {
bar: css`
transition: none;
`,
root: css`
border-radius: 0;
`,
};
const styles = {
stack: {
paddingLeft: 2,
paddingRight: 2,
},
label: (theme) => ({
fontSize: 12,
display: "block",
fontWeight: 600,
color: theme.palette.text.secondary,
}),
} satisfies Record<string, Interpolation<Theme>>;
+34
View File
@@ -124,12 +124,46 @@ module.exports = {
"30%": { left: "0%", width: "40%" },
"100%": { left: "100%", width: "0%" },
},
// Matches MUI LinearProgress bar1/bar2 indeterminate keyframes; two
// staggered bars are required so one is visible while the other resets.
"bar-indeterminate": {
"0%": {
left: "-35%",
right: "100%",
},
"60%": {
left: "100%",
right: "-90%",
},
"100%": {
left: "100%",
right: "-90%",
},
},
"bar-indeterminate-2": {
"0%": {
left: "-200%",
right: "100%",
},
"60%": {
left: "107%",
right: "-8%",
},
"100%": {
left: "107%",
right: "-8%",
},
},
},
animation: {
loading: "loading 2s ease-in-out infinite alternate",
"caret-scan": "caret-scan 3s ease-in-out infinite",
"spin-once": "spin 1s cubic-bezier(0.4, 0, 0.2, 1)",
"zip-right": "zip-right 1s cubic-bezier(0.4, 0, 0.2, 1)",
"bar-indeterminate":
"bar-indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite",
"bar-indeterminate-2":
"bar-indeterminate-2 2.1s cubic-bezier(0.165, 0.84, 0.44, 1) 1.15s infinite",
},
},
},
-1
View File
@@ -182,7 +182,6 @@ export default defineConfig({
"@mui/material/FormLabel",
"@mui/material/InputAdornment",
"@mui/material/InputBase",
"@mui/material/LinearProgress",
"@mui/material/Link",
"@mui/material/List",
"@mui/material/ListItem",