diff --git a/.changeset/progress-bar.md b/.changeset/progress-bar.md new file mode 100644 index 000000000..430a7d678 --- /dev/null +++ b/.changeset/progress-bar.md @@ -0,0 +1,5 @@ +--- +'@vapor-ui/core': minor +--- + +add new `ProgressBar` component diff --git a/.claude/rules/styling.md b/.claude/rules/styling.md index 2373faf87..de97cdf7d 100644 --- a/.claude/rules/styling.md +++ b/.claude/rules/styling.md @@ -122,6 +122,28 @@ const { variant, size, orientation } = useTabsContext(); Context is used when the same variant value must style multiple sub-parts simultaneously. The sub-part's Props type uses `Omit` to remove Context-managed props, preventing users from passing them directly. +#### Typing Root-owned variants + +Type the Root's variants from the recipe of the sub-part that actually consumes them. Do not declare a Root recipe just to host the type, and do not hand-write the union — the recipe is the single source and the type must follow it. + +Alias the derived type once in `*.tsx` and use that alias for both `Root.Props` and the Context: + +```ts +// dialog.tsx — Root has no recipe; Popup consumes the variants +type DialogVariants = DialogPopupVariants; +type DialogContext = DialogVariants; + +// tabs.tsx — variants span two sub-parts +type TabsVariants = ListVariants & ButtonVariants; + +// progress-bar.tsx — each sub-part contributes one key +type ProgressBarVariants = Pick & Pick; +``` + +Only when the Root element itself is styled by the recipe (`Toolbar`, `RadioGroup`, `SegmentedControl`) does the alias come from `RootVariants`. + +Recipe variant keys are optional. If Root fills the defaults before writing to Context, type the Context as `Required` so sub-parts read them without re-applying defaults. + ## CSS Variables — Component-scoped Tokens Use `createVar` to decouple color palette from visual variant within a single recipe. The palette variant sets the variable values; the visual variant consumes them — this avoids N×M `compoundVariants` for every palette × variant combination. diff --git a/apps/storybook/__tests__/screenshots/progressbar--test-bed-1-chrome-darwin-.png b/apps/storybook/__tests__/screenshots/progressbar--test-bed-1-chrome-darwin-.png new file mode 100644 index 000000000..da0bd8d49 Binary files /dev/null and b/apps/storybook/__tests__/screenshots/progressbar--test-bed-1-chrome-darwin-.png differ diff --git a/apps/storybook/__tests__/screenshots/progressbar--test-bed-1-edge-darwin-.png b/apps/storybook/__tests__/screenshots/progressbar--test-bed-1-edge-darwin-.png new file mode 100644 index 000000000..da0bd8d49 Binary files /dev/null and b/apps/storybook/__tests__/screenshots/progressbar--test-bed-1-edge-darwin-.png differ diff --git a/apps/storybook/__tests__/screenshots/progressbar--test-bed-1-firefox-darwin-.png b/apps/storybook/__tests__/screenshots/progressbar--test-bed-1-firefox-darwin-.png new file mode 100644 index 000000000..1fa227938 Binary files /dev/null and b/apps/storybook/__tests__/screenshots/progressbar--test-bed-1-firefox-darwin-.png differ diff --git a/apps/storybook/__tests__/screenshots/progressbar--test-bed-1-safari-darwin-.png b/apps/storybook/__tests__/screenshots/progressbar--test-bed-1-safari-darwin-.png new file mode 100644 index 000000000..14b920649 Binary files /dev/null and b/apps/storybook/__tests__/screenshots/progressbar--test-bed-1-safari-darwin-.png differ diff --git a/apps/website/content/docs/components/(components)/progress-bar.mdx b/apps/website/content/docs/components/(components)/progress-bar.mdx new file mode 100644 index 000000000..ed4681a3c --- /dev/null +++ b/apps/website/content/docs/components/(components)/progress-bar.mdx @@ -0,0 +1,133 @@ +--- +title: 'ProgressBar' +site_name: 'ProgressBar - Vapor Core' +description: 'ProgressBar는 시작과 끝이 있는 작업의 진행 정도를 보여줍니다. 디스크 사용량이나 점수처럼 고정된 척도의 측정값에는 Meter를 사용합니다.' +--- + + +```json doc-gen:file +{ + "file": "./src/components/demo/examples/progress-bar/default-progress-bar.tsx", + "codeblock": true +} +``` + + +## Property + +--- + +### Size + +Track의 높이를 설정합니다. + + +```json doc-gen:file +{ + "file": "./src/components/demo/examples/progress-bar/progress-bar-size.tsx", + "codeblock": true +} +``` + + +### Type + +작업의 상태를 나타냅니다. + + +```json doc-gen:file +{ + "file": "./src/components/demo/examples/progress-bar/progress-bar-type.tsx", + "codeblock": true +} +``` + + +### Value Text + +`ProgressBar.Value`는 선언한 범위를 기준으로 값을 백분율로 환산해 보여줍니다. + +백분율 대신 다른 표현이 필요하면 두 곳을 함께 바꿉니다. `getAriaValueText`는 보조기기가 듣는 `aria-valuetext`를, `ProgressBar.Value`의 `children` 함수는 화면 텍스트를 정합니다. 한쪽만 바꾸면 보는 사람과 듣는 사람이 다른 값을 받습니다. + + +```json doc-gen:file +{ + "file": "./src/components/demo/examples/progress-bar/progress-bar-value-text.tsx", + "codeblock": true +} +``` + + +### Indeterminate + +`value`가 `null`이면 진행률을 알 수 없는 상태입니다. 40% 폭의 세그먼트가 Track을 한 방향으로 훑고, `aria-valuenow`는 쓰이지 않습니다. + + +```json doc-gen:file +{ + "file": "./src/components/demo/examples/progress-bar/progress-bar-indeterminate.tsx", + "codeblock": true +} +``` + + +## Examples + +--- + +### Description + +`ProgressBar.Description`은 진행 상황을 말로 풀어 씁니다. 남은 용량, 실패 사유, 다음에 할 일 같은 내용입니다. + +`ProgressBar.Root`의 `type`이 `error`이면 이 텍스트가 danger 색이 되고 막대는 danger 색으로 끝까지 찹니다. + +단계마다 다른 문구가 필요하면 `render`나 `className` 함수의 두 번째 인수 `state.status`를 보세요. 값이 없으면 `indeterminate`, 최댓값에 닿으면 `complete`, 그 사이는 `progressing`입니다. + + +```json doc-gen:file +{ + "file": "./src/components/demo/examples/progress-bar/progress-bar-description.tsx", + "codeblock": true +} +``` + + +## Accessibility + +--- + +- `ProgressBar.Label`을 넣거나 `ProgressBar.Root`에 `aria-label` 또는 `aria-labelledby`를 주세요. 이름이 없으면 보조기기가 숫자만 읽습니다. 개발 모드에서는 콘솔 경고가 나옵니다. +- 실패 사유나 다음 행동은 `ProgressBar.Description`에 적으세요. **`type="error"`는 겉모습만 바꿉니다.** 실패했다는 사실은 문구가 직접 말해야 합니다. 색과 꽉 찬 막대만으로는 스크린 리더 사용자도, 빨강과 회색을 구별하지 못하는 사용자도 실패를 알 수 없습니다. +- 진행 상황을 막대 길이만으로 전달하지 마세요. `ProgressBar.Value`나 `ProgressBar.Description`으로 텍스트를 함께 두면 확대 화면이나 저시력 환경에서도 값을 읽을 수 있습니다. + +## Props Table + +--- + +### ProgressBar.Root + + + +### ProgressBar.Label + + + +### ProgressBar.Value + + + +### ProgressBar.Track + + + +### ProgressBar.TrackPrimitive + + + +### ProgressBar.IndicatorPrimitive + + + +### ProgressBar.Description + + diff --git a/apps/website/content/docs/components/meta.json b/apps/website/content/docs/components/meta.json index 022b5f9d7..b81bf5e5d 100644 --- a/apps/website/content/docs/components/meta.json +++ b/apps/website/content/docs/components/meta.json @@ -34,6 +34,7 @@ "(components)/navigation-menu.mdx", "(components)/pagination.mdx", "(components)/popover.mdx", + "(components)/progress-bar.mdx", "(components)/radio-card.mdx", "(components)/radio.mdx", "(components)/select.mdx", diff --git a/apps/website/public/components/generated/progress-bar-description.json b/apps/website/public/components/generated/progress-bar-description.json new file mode 100644 index 000000000..e288afcf8 --- /dev/null +++ b/apps/website/public/components/generated/progress-bar-description.json @@ -0,0 +1,23 @@ +{ + "name": "Description", + "displayName": "ProgressBar.Description", + "description": "진행 상황을 말로 풀어 씁니다. 실패 사유나 다음에 할 일 같은 내용입니다. `` 요소로 렌더링됩니다.", + "props": [ + { + "name": "className", + "type": [ + "string | ((state: ProgressBarDescription.State) => (string | undefined))" + ], + "required": false, + "description": "요소에 적용된 CSS 클래스 또는 컴포넌트의 상태에 따라 클래스를 반환하는 함수." + }, + { + "name": "render", + "type": [ + "ReactElement | ((props: HTMLProps, state: ProgressBarDescription.State) => ReactElement)" + ], + "required": false, + "description": "컴포넌트의 HTML 요소를 다른 태그로 대체하거나 다른 컴포넌트와 조합할 수 있습니다. 렌더링할 요소를 반환하는 `ReactElement` 또는 함수를 인수로 받습니다." + } + ] +} diff --git a/apps/website/public/components/generated/progress-bar-indicator-primitive.json b/apps/website/public/components/generated/progress-bar-indicator-primitive.json new file mode 100644 index 000000000..6a981730b --- /dev/null +++ b/apps/website/public/components/generated/progress-bar-indicator-primitive.json @@ -0,0 +1,31 @@ +{ + "name": "IndicatorPrimitive", + "displayName": "ProgressBar.IndicatorPrimitive", + "description": "Track에서 채워진 부분입니다. `type`이 `error`이면 Track을 끝까지 채웁니다. `
` 요소로 렌더링됩니다.", + "props": [ + { + "name": "className", + "type": [ + "string | ((state: ProgressBar.IndicatorPrimitive.State) => (string | undefined))" + ], + "required": false, + "description": "요소에 적용된 CSS 클래스 또는 컴포넌트의 상태에 따라 클래스를 반환하는 함수." + }, + { + "name": "style", + "type": [ + "React.CSSProperties | ((state: ProgressBar.IndicatorPrimitive.State) => (React.CSSProperties | undefined))" + ], + "required": false, + "description": "요소에 적용된 인라인 스타일 또는 컴포넌트의 상태에 따라 스타일 객체를 반환하는 함수." + }, + { + "name": "render", + "type": [ + "ReactElement | ((props: HTMLProps, state: ProgressBar.IndicatorPrimitive.State) => ReactElement)" + ], + "required": false, + "description": "컴포넌트의 HTML 요소를 다른 태그로 대체하거나 다른 컴포넌트와 조합할 수 있습니다. 렌더링할 요소를 반환하는 `ReactElement` 또는 함수를 인수로 받습니다." + } + ] +} diff --git a/apps/website/public/components/generated/progress-bar-label.json b/apps/website/public/components/generated/progress-bar-label.json new file mode 100644 index 000000000..49be44f96 --- /dev/null +++ b/apps/website/public/components/generated/progress-bar-label.json @@ -0,0 +1,31 @@ +{ + "name": "Label", + "displayName": "ProgressBar.Label", + "description": "진행 중인 작업의 이름입니다. 접근 가능한 이름이 됩니다. `` 요소로 렌더링됩니다.", + "props": [ + { + "name": "className", + "type": [ + "string | ((state: ProgressBar.Label.State) => (string | undefined))" + ], + "required": false, + "description": "요소에 적용된 CSS 클래스 또는 컴포넌트의 상태에 따라 클래스를 반환하는 함수." + }, + { + "name": "style", + "type": [ + "React.CSSProperties | ((state: ProgressBar.Label.State) => (React.CSSProperties | undefined))" + ], + "required": false, + "description": "요소에 적용된 인라인 스타일 또는 컴포넌트의 상태에 따라 스타일 객체를 반환하는 함수." + }, + { + "name": "render", + "type": [ + "ReactElement | ((props: HTMLProps, state: ProgressBar.Label.State) => ReactElement)" + ], + "required": false, + "description": "컴포넌트의 HTML 요소를 다른 태그로 대체하거나 다른 컴포넌트와 조합할 수 있습니다. 렌더링할 요소를 반환하는 `ReactElement` 또는 함수를 인수로 받습니다." + } + ] +} diff --git a/apps/website/public/components/generated/progress-bar-root.json b/apps/website/public/components/generated/progress-bar-root.json new file mode 100644 index 000000000..8ebb76bb7 --- /dev/null +++ b/apps/website/public/components/generated/progress-bar-root.json @@ -0,0 +1,103 @@ +{ + "name": "Root", + "displayName": "ProgressBar.Root", + "description": "시작과 끝이 있는 작업의 진행 정도를 보여줍니다. `
` 요소로 렌더링됩니다. 디스크 사용량이나 점수처럼 고정된 척도의 측정값에는 `Meter`를 사용합니다.", + "props": [ + { + "name": "value", + "type": [ + "number", + "null" + ], + "required": true, + "description": "현재 값입니다. `null`이면 진행률을 알 수 없는(indeterminate) 상태입니다." + }, + { + "name": "size", + "type": [ + "sm", + "md", + "lg" + ], + "required": false, + "description": "Track의 높이입니다.", + "defaultValue": "md" + }, + { + "name": "type", + "type": [ + "default", + "error" + ], + "required": false, + "description": "작업의 상태입니다. `error`는 Description을 danger 색으로 바꾸고 Indicator로 Track을 끝까지 채웁니다.", + "defaultValue": "default" + }, + { + "name": "className", + "type": [ + "string | ((state: ProgressBar.Root.State) => (string | undefined))" + ], + "required": false, + "description": "요소에 적용된 CSS 클래스 또는 컴포넌트의 상태에 따라 클래스를 반환하는 함수." + }, + { + "name": "getAriaValueText", + "type": [ + "(formattedValue: string, value: number | null) => string" + ], + "required": false, + "description": "보조기기가 읽을 값 텍스트를 반환합니다. 지정하지 않으면 `aria-valuetext`를 쓰지 않고 `aria-valuenow`만으로 값을 전달합니다.\n\n화면에 보이는 텍스트는 바뀌지 않으니 `ProgressBar.Value`의 `children` 함수로 같은 텍스트를 맞춰 주세요." + }, + { + "name": "format", + "type": [ + "Intl.NumberFormatOptions" + ], + "required": false, + "description": "값을 형식화하는 `Intl.NumberFormat` 옵션입니다." + }, + { + "name": "locale", + "type": [ + "Intl.LocalesArgument" + ], + "required": false, + "description": "값을 형식화할 때 `Intl.NumberFormat`이 사용하는 로케일입니다. 기본값은 사용자 런타임의 로케일입니다." + }, + { + "name": "max", + "type": [ + "number" + ], + "required": false, + "description": "최대값입니다.", + "defaultValue": "100" + }, + { + "name": "min", + "type": [ + "number" + ], + "required": false, + "description": "최소값입니다.", + "defaultValue": "0" + }, + { + "name": "style", + "type": [ + "React.CSSProperties | ((state: ProgressBar.Root.State) => (React.CSSProperties | undefined))" + ], + "required": false, + "description": "요소에 적용된 인라인 스타일 또는 컴포넌트의 상태에 따라 스타일 객체를 반환하는 함수." + }, + { + "name": "render", + "type": [ + "ReactElement | ((props: HTMLProps, state: ProgressBar.Root.State) => ReactElement)" + ], + "required": false, + "description": "컴포넌트의 HTML 요소를 다른 태그로 대체하거나 다른 컴포넌트와 조합할 수 있습니다. 렌더링할 요소를 반환하는 `ReactElement` 또는 함수를 인수로 받습니다." + } + ] +} diff --git a/apps/website/public/components/generated/progress-bar-track-primitive.json b/apps/website/public/components/generated/progress-bar-track-primitive.json new file mode 100644 index 000000000..0b8564cb8 --- /dev/null +++ b/apps/website/public/components/generated/progress-bar-track-primitive.json @@ -0,0 +1,31 @@ +{ + "name": "TrackPrimitive", + "displayName": "ProgressBar.TrackPrimitive", + "description": "작업의 전체 길이입니다. 안에 인디케이터를 포함하지 않습니다. `
` 요소로 렌더링됩니다.", + "props": [ + { + "name": "className", + "type": [ + "string | ((state: ProgressBar.TrackPrimitive.State) => (string | undefined))" + ], + "required": false, + "description": "요소에 적용된 CSS 클래스 또는 컴포넌트의 상태에 따라 클래스를 반환하는 함수." + }, + { + "name": "style", + "type": [ + "React.CSSProperties | ((state: ProgressBar.TrackPrimitive.State) => (React.CSSProperties | undefined))" + ], + "required": false, + "description": "요소에 적용된 인라인 스타일 또는 컴포넌트의 상태에 따라 스타일 객체를 반환하는 함수." + }, + { + "name": "render", + "type": [ + "ReactElement | ((props: HTMLProps, state: ProgressBar.TrackPrimitive.State) => ReactElement)" + ], + "required": false, + "description": "컴포넌트의 HTML 요소를 다른 태그로 대체하거나 다른 컴포넌트와 조합할 수 있습니다. 렌더링할 요소를 반환하는 `ReactElement` 또는 함수를 인수로 받습니다." + } + ] +} diff --git a/apps/website/public/components/generated/progress-bar-track.json b/apps/website/public/components/generated/progress-bar-track.json new file mode 100644 index 000000000..453225c1f --- /dev/null +++ b/apps/website/public/components/generated/progress-bar-track.json @@ -0,0 +1,39 @@ +{ + "name": "Track", + "displayName": "ProgressBar.Track", + "description": "작업의 전체 길이입니다. 내부에 `ProgressBar.IndicatorPrimitive`를 렌더링합니다. `
` 요소로 렌더링됩니다.", + "props": [ + { + "name": "className", + "type": [ + "string | ((state: ProgressBar.Track.State) => (string | undefined))" + ], + "required": false, + "description": "요소에 적용된 CSS 클래스 또는 컴포넌트의 상태에 따라 클래스를 반환하는 함수." + }, + { + "name": "style", + "type": [ + "React.CSSProperties | ((state: ProgressBar.Track.State) => (React.CSSProperties | undefined))" + ], + "required": false, + "description": "요소에 적용된 인라인 스타일 또는 컴포넌트의 상태에 따라 스타일 객체를 반환하는 함수." + }, + { + "name": "render", + "type": [ + "ReactElement | ((props: HTMLProps, state: ProgressBar.Track.State) => ReactElement)" + ], + "required": false, + "description": "컴포넌트의 HTML 요소를 다른 태그로 대체하거나 다른 컴포넌트와 조합할 수 있습니다. 렌더링할 요소를 반환하는 `ReactElement` 또는 함수를 인수로 받습니다." + }, + { + "name": "indicatorElement", + "type": [ + "ReactElement" + ], + "required": false, + "description": "커스텀 인디케이터 요소입니다. 기본값은 ``입니다." + } + ] +} diff --git a/apps/website/public/components/generated/progress-bar-value.json b/apps/website/public/components/generated/progress-bar-value.json new file mode 100644 index 000000000..a4d43ba7d --- /dev/null +++ b/apps/website/public/components/generated/progress-bar-value.json @@ -0,0 +1,39 @@ +{ + "name": "Value", + "displayName": "ProgressBar.Value", + "description": "화면에 보이는 값 텍스트입니다. `` 요소로 렌더링됩니다. `getAriaValueText`를 지정했다면 `children` 함수로 같은 텍스트를 보여 주세요.", + "props": [ + { + "name": "className", + "type": [ + "string | ((state: ProgressBar.Value.State) => (string | undefined))" + ], + "required": false, + "description": "요소에 적용된 CSS 클래스 또는 컴포넌트의 상태에 따라 클래스를 반환하는 함수." + }, + { + "name": "children", + "type": [ + "(formattedValue: string | null, value: number | null) => ReactNode" + ], + "required": false, + "description": "값 텍스트를 렌더링하는 함수입니다. 형식화된 값과 원래 값을 인수로 받습니다.\n\n생략하면 선언한 범위로 계산한 백분율을 보여 줍니다." + }, + { + "name": "style", + "type": [ + "React.CSSProperties | ((state: ProgressBar.Value.State) => (React.CSSProperties | undefined))" + ], + "required": false, + "description": "요소에 적용된 인라인 스타일 또는 컴포넌트의 상태에 따라 스타일 객체를 반환하는 함수." + }, + { + "name": "render", + "type": [ + "ReactElement | ((props: HTMLProps, state: ProgressBar.Value.State) => ReactElement)" + ], + "required": false, + "description": "컴포넌트의 HTML 요소를 다른 태그로 대체하거나 다른 컴포넌트와 조합할 수 있습니다. 렌더링할 요소를 반환하는 `ReactElement` 또는 함수를 인수로 받습니다." + } + ] +} diff --git a/apps/website/src/components/demo/examples/progress-bar/default-progress-bar.tsx b/apps/website/src/components/demo/examples/progress-bar/default-progress-bar.tsx new file mode 100644 index 000000000..fc1b63a92 --- /dev/null +++ b/apps/website/src/components/demo/examples/progress-bar/default-progress-bar.tsx @@ -0,0 +1,11 @@ +import { ProgressBar } from '@vapor-ui/core'; + +export default function DefaultProgressBar() { + return ( + + 파일 업로드 + + + + ); +} diff --git a/apps/website/src/components/demo/examples/progress-bar/progress-bar-description.tsx b/apps/website/src/components/demo/examples/progress-bar/progress-bar-description.tsx new file mode 100644 index 000000000..84cdcd2ed --- /dev/null +++ b/apps/website/src/components/demo/examples/progress-bar/progress-bar-description.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { useState } from 'react'; + +import { Button, ProgressBar, VStack } from '@vapor-ui/core'; + +export default function ProgressBarDescription() { + const [failed, setFailed] = useState(false); + + return ( + + + report.pdf 업로드 + + + + {failed + ? '업로드에 실패했습니다. 파일이 10MB를 넘습니다.' + : '10MB 중 4.2MB를 전송했습니다.'} + + + + + + ); +} diff --git a/apps/website/src/components/demo/examples/progress-bar/progress-bar-indeterminate.tsx b/apps/website/src/components/demo/examples/progress-bar/progress-bar-indeterminate.tsx new file mode 100644 index 000000000..e2eff2905 --- /dev/null +++ b/apps/website/src/components/demo/examples/progress-bar/progress-bar-indeterminate.tsx @@ -0,0 +1,11 @@ +import { ProgressBar } from '@vapor-ui/core'; + +export default function ProgressBarIndeterminate() { + return ( + + 서버 응답 대기 중 + {() => '처리 중'} + + + ); +} diff --git a/apps/website/src/components/demo/examples/progress-bar/progress-bar-size.tsx b/apps/website/src/components/demo/examples/progress-bar/progress-bar-size.tsx new file mode 100644 index 000000000..0d78c0259 --- /dev/null +++ b/apps/website/src/components/demo/examples/progress-bar/progress-bar-size.tsx @@ -0,0 +1,15 @@ +import { ProgressBar, VStack } from '@vapor-ui/core'; + +export default function ProgressBarSize() { + return ( + + {(['sm', 'md', 'lg'] as const).map((size) => ( + + {size} + + + + ))} + + ); +} diff --git a/apps/website/src/components/demo/examples/progress-bar/progress-bar-type.tsx b/apps/website/src/components/demo/examples/progress-bar/progress-bar-type.tsx new file mode 100644 index 000000000..f29106941 --- /dev/null +++ b/apps/website/src/components/demo/examples/progress-bar/progress-bar-type.tsx @@ -0,0 +1,23 @@ +import { ProgressBar, VStack } from '@vapor-ui/core'; + +export default function ProgressBarType() { + return ( + + + report.pdf 업로드 + + + 10MB 중 4.2MB를 전송했습니다. + + + + report.pdf 업로드 + + + + 업로드에 실패했습니다. 파일이 10MB를 넘습니다. + + + + ); +} diff --git a/apps/website/src/components/demo/examples/progress-bar/progress-bar-value-text.tsx b/apps/website/src/components/demo/examples/progress-bar/progress-bar-value-text.tsx new file mode 100644 index 000000000..4f964ef17 --- /dev/null +++ b/apps/website/src/components/demo/examples/progress-bar/progress-bar-value-text.tsx @@ -0,0 +1,23 @@ +import { ProgressBar, VStack } from '@vapor-ui/core'; + +export default function ProgressBarValueText() { + return ( + + + 디스크 검사 + + + + + `8개 중 ${value}개`} + > + 파일 업로드 + {(_, value) => `8개 중 ${value}개`} + + + + ); +} diff --git a/packages/core/src/components/progress-bar/index.parts.ts b/packages/core/src/components/progress-bar/index.parts.ts new file mode 100644 index 000000000..2872b6258 --- /dev/null +++ b/packages/core/src/components/progress-bar/index.parts.ts @@ -0,0 +1,9 @@ +export { + ProgressBarRoot as Root, + ProgressBarLabel as Label, + ProgressBarTrackPrimitive as TrackPrimitive, + ProgressBarTrack as Track, + ProgressBarIndicatorPrimitive as IndicatorPrimitive, + ProgressBarValue as Value, + ProgressBarDescription as Description, +} from './progress-bar'; diff --git a/packages/core/src/components/progress-bar/index.ts b/packages/core/src/components/progress-bar/index.ts new file mode 100644 index 000000000..0d94930ed --- /dev/null +++ b/packages/core/src/components/progress-bar/index.ts @@ -0,0 +1 @@ +export * as ProgressBar from './index.parts'; diff --git a/packages/core/src/components/progress-bar/progress-bar.css.ts b/packages/core/src/components/progress-bar/progress-bar.css.ts new file mode 100644 index 000000000..c38e639b7 --- /dev/null +++ b/packages/core/src/components/progress-bar/progress-bar.css.ts @@ -0,0 +1,165 @@ +import { keyframes } from '@vanilla-extract/css'; +import type { RecipeVariants } from '@vanilla-extract/recipes'; + +import { componentRecipe, componentStyle } from '~/styles/mixins/layer-style.css'; +import { typography } from '~/styles/mixins/typography.css'; +import { vars } from '~/styles/themes.css'; + +/** Width of the indeterminate segment, as a share of the track. */ +const SEGMENT_WIDTH = 40; +/** + * Where the segment's leading edge starts and ends a sweep, as a share of the track. It starts a + * tenth of the track clear of the left edge and ends well past the right, so each crossing is + * followed by a rest on an empty track. + */ +const SWEEP_FROM = -(SEGMENT_WIDTH + 10); +const SWEEP_TO = 220; +/** One sweep. Held constant so `SEGMENT_WIDTH` changes the distance covered, never the tempo. */ +const SWEEP_DURATION = '1.5s'; + +// `translateX` resolves against the segment's own width, so a track-relative offset has to be +// divided by that share to survive a change to `SEGMENT_WIDTH`. +const toSegment = (track: number) => `${Number(((track / SEGMENT_WIDTH) * 100).toFixed(2))}%`; + +const sweep = keyframes({ + '0%': { transform: `translateX(${toSegment(SWEEP_FROM)})` }, + '100%': { transform: `translateX(${toSegment(SWEEP_TO)})` }, +}); + +/** Resting position of the segment when motion is reduced: centred in the track. */ +const SEGMENT_REST = `${(100 - SEGMENT_WIDTH) / 2}%`; + +export const root = componentStyle({ + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) auto', + alignItems: 'center', + rowGap: 0, + columnGap: vars.size.space['050'], + width: '100%', +}); + +export const label = componentStyle([ + typography({ style: 'subtitle1' }), + { + color: vars.color.foreground['normal'], + }, +]); + +export const value = componentStyle([ + typography({ style: 'body2' }), + { + color: vars.color.foreground['hint'], + }, +]); + +export const description = componentRecipe({ + base: [ + typography({ style: 'subtitle1' }), + { + gridColumn: '1 / -1', + marginTop: vars.size.space['075'], + minWidth: 0, + }, + ], + + defaultVariants: { type: 'default' }, + variants: { + /** + * Tone of the description text. + * @default 'default' + */ + type: { + default: { color: vars.color.foreground['secondary'] }, + error: { color: vars.color.foreground['danger'] }, + }, + }, +}); + +export const track = componentRecipe({ + base: { + position: 'relative', + gridColumn: '1 / -1', + borderRadius: vars.size.borderRadius['900'], + backgroundColor: vars.color.background['secondary-200'], + width: '100%', + overflow: 'hidden', + + selectors: { + '&:not(:first-child)': { marginTop: vars.size.space['100'] }, + }, + }, + + defaultVariants: { size: 'md', type: 'default' }, + variants: { + /** + * Size of the track. Controls its height. + * @default 'md' + */ + size: { + sm: { height: vars.size.dimension['050'] }, + md: { height: vars.size.dimension['075'] }, + lg: { height: vars.size.dimension['150'] }, + }, + + /** + * Tone of the track. + * @default 'default' + */ + type: { + default: {}, + error: {}, + }, + }, +}); + +export const indicator = componentRecipe({ + base: { + borderRadius: vars.size.borderRadius['900'], + backgroundColor: vars.color.background['primary'], + height: 'inherit', + + selectors: { + '&[data-indeterminate]': { + position: 'absolute', + insetInlineStart: 0, + width: `${SEGMENT_WIDTH}%`, + animation: `${sweep} ${SWEEP_DURATION} linear infinite`, + }, + }, + + '@media': { + '(prefers-reduced-motion: reduce)': { + selectors: { + '&[data-indeterminate]': { + insetInlineStart: SEGMENT_REST, + animation: 'none', + }, + }, + }, + }, + }, + + defaultVariants: { type: 'default' }, + variants: { + /** + * Tone of the indicator. + * @default 'default' + */ + type: { + default: {}, + // base-ui writes `width` inline, so the fill rides on `min-width`, which outranks it + // without an `!important`. + error: { + backgroundColor: vars.color.background['danger'], + minWidth: '100%', + selectors: { + '&[data-indeterminate]': { insetInlineStart: 0, animation: 'none' }, + }, + }, + }, + }, +}); + +export type TrackVariants = NonNullable>; +export type DescriptionVariants = NonNullable>; +export type IndicatorVariants = NonNullable>; diff --git a/packages/core/src/components/progress-bar/progress-bar.stories.tsx b/packages/core/src/components/progress-bar/progress-bar.stories.tsx new file mode 100644 index 000000000..ec3d1fd87 --- /dev/null +++ b/packages/core/src/components/progress-bar/progress-bar.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { ProgressBar } from '.'; +import { VStack } from '../v-stack'; + +export default { + title: 'ProgressBar', + component: ProgressBar.Root, +} satisfies Meta; + +const Bar = (props: ProgressBar.Root.Props & { label: string; description?: string }) => { + const { label, description, ...rootProps } = props; + + return ( + + {label} + + + {description ? {description} : null} + + ); +}; + +export const Default: StoryObj = { + args: { value: 42 }, + render: (args) => ( +
+ +
+ ), +}; + +export const TestBed: StoryObj = { + render: () => ( + + + + + + + + + + + + ), +}; diff --git a/packages/core/src/components/progress-bar/progress-bar.test.tsx b/packages/core/src/components/progress-bar/progress-bar.test.tsx new file mode 100644 index 000000000..84266f23d --- /dev/null +++ b/packages/core/src/components/progress-bar/progress-bar.test.tsx @@ -0,0 +1,594 @@ +import type { ReactElement } from 'react'; + +import { cleanup, render, waitFor } from '@testing-library/react'; +import { axe } from 'vitest-axe'; + +import { ProgressBar } from '.'; + +const ProgressBarTest = ({ + label = '파일 업로드', + ...props +}: ProgressBar.Root.Props & { label?: string | null }) => ( + + {label === null ? null : {label}} + + + +); + +const getBar = (container: HTMLElement) => + container.querySelector('[role="progressbar"]') as HTMLElement; + +/** The Track is the only `
` child of the Root — Label and Value both render spans. */ +const getTrack = (container: HTMLElement) => + container.querySelector('[role="progressbar"] > div') as HTMLElement | null; + +const getIndicator = (container: HTMLElement) => + getTrack(container)?.firstElementChild as HTMLElement | null; + +describe('', () => { + afterEach(cleanup); + + it('should have no a11y violations', async () => { + const { container } = render(); + const results = await axe(container); + + expect(results).toHaveNoViolations(); + }); + + describe('base implementation contract', () => { + it('should expose role, name and the declared range', () => { + const { container } = render(); + const bar = getBar(container); + + expect(bar).toHaveAttribute('aria-valuenow', '15'); + expect(bar).toHaveAttribute('aria-valuemin', '10'); + expect(bar).toHaveAttribute('aria-valuemax', '20'); + expect(bar).toHaveAccessibleName('파일 업로드'); + }); + + it('should hide the visible value from assistive technology', () => { + const { getByText } = render(); + + expect(getByText('42%')).toHaveAttribute('aria-hidden', 'true'); + }); + + it('should give every instance a unique label id', () => { + const { container } = render( + <> + + + , + ); + const ids = Array.from(container.querySelectorAll('[role="progressbar"]')).map((n) => + n.getAttribute('aria-labelledby'), + ); + + expect(ids[0]).toBeTruthy(); + expect(ids[0]).not.toBe(ids[1]); + }); + + it('should forward a callback ref to the progressbar element', () => { + const ref = vi.fn(); + const { container } = render( + + + , + ); + + expect(ref).toHaveBeenCalledWith(getBar(container)); + }); + }); + + describe('value contract', () => { + it('should clamp a value above the range', () => { + const { container } = render(); + + expect(getBar(container)).toHaveAttribute('aria-valuenow', '100'); + }); + + it('should clamp a value below the range', () => { + const { container } = render(); + + expect(getBar(container)).toHaveAttribute('aria-valuenow', '0'); + }); + + it('should warn and pass an inverted range through untouched', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { container } = render(); + const bar = getBar(container); + + expect(bar).toHaveAttribute('aria-valuemin', '100'); + expect(bar).toHaveAttribute('aria-valuemax', '0'); + expect(warn).toHaveBeenCalledWith( + 'Vapor UI: ProgressBar received min={100} and max={0}. `min` must be less than `max` for the value to be meaningful.', + ); + + warn.mockRestore(); + }); + + it('should warn when the range is empty', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { container } = render(); + const bar = getBar(container); + + expect(bar).toHaveAttribute('aria-valuemin', '20'); + expect(bar).toHaveAttribute('aria-valuemax', '20'); + expect(warn).toHaveBeenCalledWith( + 'Vapor UI: ProgressBar received min={20} and max={20}. `min` must be less than `max` for the value to be meaningful.', + ); + + warn.mockRestore(); + }); + + it('should scale the value text by the declared range, not by 100', () => { + const { getByText } = render(); + + expect(getByText('50%')).toBeInTheDocument(); + }); + + it('should not write aria-valuetext by default', () => { + const { container } = render(); + + expect(getBar(container)).not.toHaveAttribute('aria-valuetext'); + }); + + it('should announce the supplied text and leave the visible value alone', () => { + const { container, getByText } = render( + '8개 중 3개'} />, + ); + + expect(getBar(container)).toHaveAttribute('aria-valuetext', '8개 중 3개'); + expect(getByText('38%')).toBeInTheDocument(); + }); + + it('should let a Value children function mirror the announced text', () => { + const { getByText } = render( + + {(_, value) => `8개 중 ${value}개`} + + , + ); + + expect(getByText('8개 중 3개')).toBeInTheDocument(); + }); + + it('should omit aria-valuenow and inject no English text when indeterminate', () => { + const { container } = render(); + const bar = getBar(container); + + expect(bar).not.toHaveAttribute('aria-valuenow'); + expect(bar).toHaveAttribute('data-indeterminate'); + expect(bar).not.toHaveAttribute('aria-valuetext'); + }); + + it('should show no value text at all when indeterminate', () => { + const { container } = render(); + + expect(container.querySelector('[aria-hidden="true"]')).toHaveTextContent(''); + }); + + it('should flag the indicator as indeterminate and give it no fill width', () => { + const { container } = render(); + const indicator = getIndicator(container)!; + + // The sweep is keyed off `data-indeterminate`; an inline width would read as a fill. + expect(indicator).toHaveAttribute('data-indeterminate'); + expect(indicator.style.width).toBe(''); + }); + }); + + describe('value text, visible text and fill agree', () => { + it.each([ + { value: 0, expected: '0%', fill: '0%' }, + { value: 42, expected: '42%', fill: '42%' }, + { value: 100, expected: '100%', fill: '100%' }, + ])('on the default range at value $value', ({ value, expected, fill }) => { + const { container, getByText } = render(); + + expect(getBar(container)).toHaveAttribute('aria-valuenow', String(value)); + expect(getByText(expected)).toBeInTheDocument(); + expect(getIndicator(container)).toHaveStyle({ width: fill }); + }); + + it.each([ + { value: 10, expected: '0%', fill: '0%' }, + { value: 15, expected: '50%', fill: '50%' }, + { value: 20, expected: '100%', fill: '100%' }, + ])('on a 10–20 range at value $value', ({ value, expected, fill }) => { + const { container, getByText } = render( + , + ); + + expect(getBar(container)).toHaveAttribute('aria-valuenow', String(value)); + expect(getByText(expected)).toBeInTheDocument(); + expect(getIndicator(container)).toHaveStyle({ width: fill }); + }); + }); + + describe('locale and format', () => { + it.each(['ko-KR', 'de-DE', 'ar-EG'])('should format the value text in %s', (locale) => { + const expected = new Intl.NumberFormat(locale, { style: 'percent' }).format(0.42); + const { container } = render(); + + // Not `getByText`: some locales separate the sign with a non-breaking space, + // which the default matcher normalises away. + expect(container.querySelector('[aria-hidden="true"]')?.textContent).toBe(expected); + // `aria-valuenow` still carries the raw number, so no `aria-valuetext` is needed. + expect(getBar(container)).not.toHaveAttribute('aria-valuetext'); + }); + + it('should let `format` replace the percentage entirely', () => { + const { container, getByText } = render( + , + ); + + expect(getByText('4,200')).toBeInTheDocument(); + expect(getBar(container)).toHaveAttribute('aria-valuenow', '4200'); + expect(getIndicator(container)).toHaveStyle({ width: '42%' }); + }); + + it('should announce the formatted text when a reader needs it spelled out', () => { + const { container, getByText } = render( + `8단계 중 ${formatted}`} + format={{ style: 'decimal' }} + />, + ); + + expect(getBar(container)).toHaveAttribute('aria-valuetext', '8단계 중 3'); + expect(getByText('3')).toBeInTheDocument(); + }); + }); + + describe('Track', () => { + it('should render an indicator inside `ProgressBar.Track` by default', () => { + const { container } = render(); + + expect(getIndicator(container)).toHaveStyle({ width: '42%' }); + }); + + it('should render the custom `indicatorElement` instead of the default indicator', () => { + const { getByTestId } = render( + + + } + /> + , + ); + + const track = getByTestId('track'); + expect(track.childElementCount).toBe(1); + expect(track.firstElementChild).toBe(getByTestId('indicator')); + expect(getByTestId('indicator')).toHaveStyle({ width: '42%' }); + }); + + it('should render no indicator when `ProgressBar.TrackPrimitive` is used directly', () => { + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('track')).toBeEmptyDOMElement(); + }); + }); + + describe('size', () => { + it('should default to md', () => { + const { container } = render(); + + expect(getTrack(container)!.className).toMatch(/size_md/); + }); + + it.each(['sm', 'md', 'lg'] as const)('should mark the track with size="%s"', (size) => { + const { container } = render(); + + expect(getTrack(container)!.className).toMatch(new RegExp(`size_${size}`)); + }); + }); + + describe('type="error"', () => { + it('should fill the whole track with the indicator', () => { + const { container } = render(); + + expect(getIndicator(container)!.className).toMatch(/type_error/); + }); + + it('should mark the track with the error variant', () => { + const { container, rerender } = render(); + const defaultClass = getTrack(container)!.className; + + rerender(); + + expect(getTrack(container)!.className).not.toBe(defaultClass); + expect(getTrack(container)!.className).toMatch(/type_error/); + }); + + it('should keep the value in the accessibility tree', () => { + const { container } = render(); + + expect(getBar(container)).toHaveAttribute('aria-valuenow', '42'); + }); + }); + + describe('accessible name', () => { + it('should warn when nothing names the progress bar', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render(); + + await waitFor(() => + expect(warn).toHaveBeenCalledWith( + 'Vapor UI: ProgressBar has no accessible name. Render a `ProgressBar.Label`, or pass `aria-label` / `aria-labelledby` to `ProgressBar.Root`.', + ), + ); + + warn.mockRestore(); + }); + + it('should not warn when a Label is rendered', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { container } = render(); + + await waitFor(() => expect(getBar(container)).toHaveAccessibleName('파일 업로드')); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('no accessible name')); + + warn.mockRestore(); + }); + + it('should take its name from the Label text', () => { + const { container } = render(); + + expect(getBar(container)).toHaveAccessibleName('백업 진행률'); + }); + + it('should keep the Label text as the name when aria-label is also supplied', () => { + const { container } = render( + , + ); + + expect(getBar(container)).toHaveAccessibleName('파일 업로드'); + }); + + it('should not warn when aria-labelledby points outside the component', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { container } = render( + <> + 파일 업로드 + + , + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(getBar(container)).toHaveAccessibleName('파일 업로드'); + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('no accessible name')); + + warn.mockRestore(); + }); + + it('should not warn when aria-label is supplied', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('no accessible name')); + + warn.mockRestore(); + }); + }); + + describe('Description', () => { + const Described = ( + props: Partial & { id?: string; description?: string }, + ) => { + const { + id, + description = '10MB 중 4.2MB 전송했습니다', + value = 42, + ...rootProps + } = props; + + return ( + + + {description} + + ); + }; + + it('should describe the bar with a generated id', async () => { + const { container } = render(); + + await waitFor(() => + expect(getBar(container)).toHaveAccessibleDescription('10MB 중 4.2MB 전송했습니다'), + ); + }); + + it('should prefer an id supplied by the consumer', async () => { + const { container, getByText } = render(); + + expect(getByText('10MB 중 4.2MB 전송했습니다')).toHaveAttribute('id', 'upload-hint'); + await waitFor(() => + expect(getBar(container)).toHaveAttribute( + 'aria-describedby', + expect.stringContaining('upload-hint'), + ), + ); + }); + + it('should append to a describedby the consumer wired up', async () => { + const { container } = render( + <> + 전송 중 + + , + ); + + await waitFor(() => + expect(getBar(container)).toHaveAttribute( + 'aria-describedby', + 'external upload-hint', + ), + ); + }); + + it('should keep every mounted Description and drop only the one that unmounts', async () => { + const Twice = ({ second = true }: { second?: boolean }) => ( + + + 첫 설명 + {second && ( + 둘째 설명 + )} + + ); + const { container, rerender } = render(); + + await waitFor(() => + expect(getBar(container)).toHaveAttribute('aria-describedby', 'first second'), + ); + + rerender(); + + await waitFor(() => + expect(getBar(container)).toHaveAttribute('aria-describedby', 'first'), + ); + }); + + it('should leave describedby off when no Description is rendered', () => { + const { container } = render(); + + expect(getBar(container)).not.toHaveAttribute('aria-describedby'); + }); + + it('should take its tone from the Root type', () => { + const { getByText, rerender } = render(); + const defaultClass = getByText('10MB 중 4.2MB 전송했습니다').className; + + rerender(); + + expect(getByText('10MB 중 4.2MB 전송했습니다').className).not.toBe(defaultClass); + }); + + it('should strip its role so a render swap cannot leak a heading into the bar', () => { + const { getByText } = render(); + + expect(getByText('10MB 중 4.2MB 전송했습니다')).toHaveAttribute('role', 'presentation'); + }); + + it.each([ + [null, 'indeterminate'], + [42, 'progressing'], + [100, 'complete'], + [150, 'complete'], + ])('should expose status to render when value is %s', (value, status) => { + const { getByTestId } = render( + + + ( + + {state.status} + + )} + /> + , + ); + + expect(getByTestId('desc')).toHaveTextContent(status); + }); + }); + + describe('Value', () => { + it('should hand the formatted and raw value to a `children` function', () => { + const children = vi.fn( + (formatted: string | null, value: number | null) => `${formatted} (${value})`, + ); + const { getByText } = render( + + {children} + + , + ); + + expect(children).toHaveBeenCalledWith('50%', 15); + expect(getByText('50% (15)')).toBeInTheDocument(); + }); + }); + + describe('shared props', () => { + const shared = { + 'data-testid': 'part', + className: 'custom', + style: { color: 'rgb(1, 2, 3)' }, + render:
, + }; + + const parts: Record ReactElement> = { + Root: () => ( + + + + ), + Label: () => ( + + 파일 업로드 + + + ), + Value: () => ( + + + + + ), + Track: () => ( + + + + ), + TrackPrimitive: () => ( + + + + ), + IndicatorPrimitive: () => ( + + } + /> + + ), + Description: () => ( + + + 설명 + + ), + }; + + it.each(Object.keys(parts))( + 'should pass className, style and render through `ProgressBar.%s`', + (name) => { + const { getByTestId } = render(parts[name]()); + const part = getByTestId('part'); + + expect(part.tagName).toBe('SECTION'); + expect(part).toHaveClass('custom'); + expect(part).toHaveStyle({ color: 'rgb(1, 2, 3)' }); + }, + ); + }); +}); diff --git a/packages/core/src/components/progress-bar/progress-bar.tsx b/packages/core/src/components/progress-bar/progress-bar.tsx new file mode 100644 index 000000000..90a09099c --- /dev/null +++ b/packages/core/src/components/progress-bar/progress-bar.tsx @@ -0,0 +1,322 @@ +'use client'; + +import type { ReactElement, RefObject } from 'react'; +import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { Progress as BaseProgress } from '@base-ui/react/progress'; + +import { useRenderElement } from '~/hooks/use-render-element'; +import { useVaporId } from '~/hooks/use-vapor-id'; +import { createContext } from '~/libs/create-context'; +import { cn } from '~/utils/cn'; +import { resolveStyles } from '~/utils/resolve-styles'; +import type { VaporUIComponentProps } from '~/utils/types'; +import { warn } from '~/utils/warn'; + +import type { DescriptionVariants, TrackVariants } from './progress-bar.css'; +import * as styles from './progress-bar.css'; + +type ProgressBarVariants = Pick & Pick; +type Status = BaseProgress.Root.State['status']; + +interface ProgressBarContext extends Required { + status: Status; + /** Registers a `ProgressBar.Description` id; the returned function unregisters it. */ + registerDescription: (id: string) => () => void; + /** Set by `ProgressBar.Label` so the root can warn when the bar has no accessible name. */ + hasLabelRef: RefObject; +} + +const [ProgressBarProvider, useProgressBarContext] = createContext({ + name: 'ProgressBarContext', + providerName: 'ProgressBarProvider', + hookName: 'useProgressBarContext', +}); + +/* -----------------------------------------------------------------------------------------------*/ + +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + +/** + * base-ui always writes `aria-valuetext`. APG asks for it only when `aria-valuenow` is not + * meaningful on its own, so the default is neutralised unless the consumer supplies one. + */ +const OMIT_ARIA_VALUE_TEXT = () => undefined as unknown as string; + +/* ------------------------------------------------------------------------------------------------- + * ProgressBar.Root + * -----------------------------------------------------------------------------------------------*/ + +/** + * Shows how far a task with a start and an end has progressed; for a reading on a fixed scale such as disk usage or a score, use `Meter` instead. Renders a `
` element. + */ +export const ProgressBarRoot = forwardRef((props, ref) => { + const { + className, + children, + size = 'md', + type = 'default', + value, + min = 0, + max = 100, + getAriaValueText, + 'aria-describedby': ariaDescribedBy, + ...componentProps + } = resolveStyles(props); + + const clampedValue = value == null ? null : clamp(value, min, max); + const status: Status = + clampedValue == null ? 'indeterminate' : clampedValue === max ? 'complete' : 'progressing'; + + const [descriptionIds, setDescriptionIds] = useState([]); + const registerDescription = useCallback((id: string) => { + setDescriptionIds((ids) => (ids.includes(id) ? ids : [...ids, id])); + return () => setDescriptionIds((ids) => ids.filter((each) => each !== id)); + }, []); + const hasLabelRef = useRef(false); + const contextValue = useMemo( + () => ({ size, type, status, registerDescription, hasLabelRef }), + [size, type, status, registerDescription], + ); + + const ariaLabel = componentProps['aria-label']; + const ariaLabelledBy = componentProps['aria-labelledby']; + + // base-ui renders a nameless progressbar without complaint, which announces as a bare number. + // `ProgressBar.Label` flips `hasLabelRef` in its own effect, which React runs before this one. + useEffect(() => { + if (process.env.NODE_ENV === 'production') return; + + if (min >= max) { + warn( + `ProgressBar received min={${min}} and max={${max}}. \`min\` must be less than \`max\` for the value to be meaningful.`, + ); + } + + if (!hasLabelRef.current && !ariaLabel && !ariaLabelledBy) { + warn( + 'ProgressBar has no accessible name. Render a `ProgressBar.Label`, or pass `aria-label` / `aria-labelledby` to `ProgressBar.Root`.', + ); + } + }, [min, max, ariaLabel, ariaLabelledBy]); + + return ( + + + {children} + + + ); +}); +ProgressBarRoot.displayName = 'ProgressBar.Root'; + +/* ------------------------------------------------------------------------------------------------- + * ProgressBar.Label + * -----------------------------------------------------------------------------------------------*/ + +/** + * Names the task that is in progress, and becomes the accessible name. Renders a `` element. + */ +export const ProgressBarLabel = forwardRef( + (props, ref) => { + const { className, ...componentProps } = resolveStyles(props); + const { hasLabelRef } = useProgressBarContext(); + + useEffect(() => { + hasLabelRef.current = true; + + return () => { + hasLabelRef.current = false; + }; + }, [hasLabelRef]); + + return ( + + ); + }, +); +ProgressBarLabel.displayName = 'ProgressBar.Label'; + +/* ------------------------------------------------------------------------------------------------- + * ProgressBar.TrackPrimitive + * -----------------------------------------------------------------------------------------------*/ + +/** + * The full length of the task without any indicator inside. Renders a `
` element. + */ +export const ProgressBarTrackPrimitive = forwardRef< + HTMLDivElement, + ProgressBarTrackPrimitive.Props +>((props, ref) => { + const { className, ...componentProps } = resolveStyles(props); + const { size, type } = useProgressBarContext(); + + return ( + + ); +}); +ProgressBarTrackPrimitive.displayName = 'ProgressBar.TrackPrimitive'; + +/* ------------------------------------------------------------------------------------------------- + * ProgressBar.IndicatorPrimitive + * -----------------------------------------------------------------------------------------------*/ + +/** + * The filled portion of the track, filling it end to end when `type` is `'error'`. Renders a `
` element. + */ +export const ProgressBarIndicatorPrimitive = forwardRef< + HTMLDivElement, + ProgressBarIndicatorPrimitive.Props +>((props, ref) => { + const { className, ...componentProps } = resolveStyles(props); + const { type } = useProgressBarContext(); + + return ( + + ); +}); +ProgressBarIndicatorPrimitive.displayName = 'ProgressBar.IndicatorPrimitive'; + +/* ------------------------------------------------------------------------------------------------- + * ProgressBar.Track + * -----------------------------------------------------------------------------------------------*/ + +/** + * The full length of the task, with `ProgressBar.IndicatorPrimitive` rendered inside. Renders a `
` element. + */ +export const ProgressBarTrack = forwardRef((props, ref) => { + const { indicatorElement, ...componentProps } = props; + + return ( + + {indicatorElement ?? } + + ); +}); +ProgressBarTrack.displayName = 'ProgressBar.Track'; + +/* ------------------------------------------------------------------------------------------------- + * ProgressBar.Value + * -----------------------------------------------------------------------------------------------*/ + +/** + * The value text shown on screen; pass a `children` function to match any custom `getAriaValueText`. Renders a `` element. + */ +export const ProgressBarValue = forwardRef( + (props, ref) => { + const { className, ...componentProps } = resolveStyles(props); + + return ( + + ); + }, +); +ProgressBarValue.displayName = 'ProgressBar.Value'; + +/* ------------------------------------------------------------------------------------------------- + * ProgressBar.Description + * -----------------------------------------------------------------------------------------------*/ + +/** + * Explains the progress in words, such as a failure reason or a next step, and reaches assistive technology through `aria-describedby`. Renders a `` element. + */ +export const ProgressBarDescription = forwardRef( + (props, ref) => { + const { className, render, id: idProp, ...componentProps } = resolveStyles(props); + const { type, status, registerDescription } = useProgressBarContext(); + + const id = useVaporId(idProp); + + useEffect(() => registerDescription(id), [id, registerDescription]); + + return useRenderElement({ + ref, + render, + defaultTagName: 'span', + state: { status }, + props: { + id, + // A progressbar's children are presentational; pinning the role keeps a `render` + // swap to `

` or `