diff --git a/.changeset/meter-component.md b/.changeset/meter-component.md new file mode 100644 index 000000000..cd24de274 --- /dev/null +++ b/.changeset/meter-component.md @@ -0,0 +1,5 @@ +--- +'@vapor-ui/core': minor +--- + +add new `Meter` component diff --git a/.claude/rules/styling.md b/.claude/rules/styling.md index 2373faf87..b66b4b9a3 100644 --- a/.claude/rules/styling.md +++ b/.claude/rules/styling.md @@ -67,12 +67,19 @@ Use when a component part has no variants — a single fixed className is genera import { componentStyle } from '~/styles/mixins/layer-style.css'; export const title = componentStyle({ - fontSize: vars.typography.fontSize['200'], - fontWeight: vars.typography.fontWeight['700'], + paddingInline: vars.size.space['150'], color: vars.color.foreground.normal[200], }); ``` +`componentStyle` also accepts an array — pass mixin classNames alongside the style rule to compose them: + +```ts +import { typography } from '~/styles/mixins/typography.css'; + +export const title = componentStyle([typography({ style: 'subtitle1' }), { gridColumn: '1' }]); +``` + ### `interaction()` mixin — interactive states Include in `base` for any component that responds to hover / active / focus. Implements a `::before` pseudo-element overlay so interaction states work without touching background-color directly. @@ -86,6 +93,47 @@ base: [ ] ``` +### `typography()` mixin — text styles + +Never set `fontSize` / `fontWeight` / `lineHeight` / `letterSpacing` by hand, and never spread `typographyVariants`. Call the `typography()` recipe with one of the named styles (`display1`–`display4`, `heading1`–`heading6`, `subtitle1`–`subtitle2`, `body1`–`body4`, `code1`–`code2`) so every component picks the same four tokens together. + +```ts +import { typography } from '~/styles/mixins/typography.css'; + +// ✅ static part +export const label = componentStyle([typography({ style: 'subtitle1' }), { gridColumn: '1' }]); + +// ✅ inside a recipe — base or a variant value +export const button = componentRecipe({ + base: [typography({ style: 'subtitle1' }), { display: 'inline-flex' }], + variants: { + size: { + sm: [typography({ style: 'subtitle2' }), { height: vars.size.space['300'] }], + md: [typography({ style: 'subtitle1' }), { height: vars.size.space['400'] }], + }, + }, +}); +``` + +```ts +// ❌ raw typography tokens — drifts from the scale +export const label = componentStyle({ + fontSize: vars.typography.fontSize['075'], + fontWeight: vars.typography.fontWeight[500], +}); + +// ❌ spreading the variant map — it exists to feed `variants`, not to style with +export const label = componentStyle({ ...typographyVariants.subtitle1 }); +``` + +`typographyVariants` is the raw style map that backs the recipe. Its only job is to be handed to a `variants` group so a component can expose a `typography` prop (see `Text`, `Field`): + +```ts +export const root = componentRecipe({ + variants: { typography: typographyVariants }, +}); +``` + ## Passing Variants to `recipe` — Two Patterns ### Direct — standalone components or parts that own their variants diff --git a/apps/storybook/__tests__/screenshots/meter--test-bed-1-chrome-darwin-.png b/apps/storybook/__tests__/screenshots/meter--test-bed-1-chrome-darwin-.png new file mode 100644 index 000000000..b3db171c2 Binary files /dev/null and b/apps/storybook/__tests__/screenshots/meter--test-bed-1-chrome-darwin-.png differ diff --git a/apps/storybook/__tests__/screenshots/meter--test-bed-1-edge-darwin-.png b/apps/storybook/__tests__/screenshots/meter--test-bed-1-edge-darwin-.png new file mode 100644 index 000000000..b3db171c2 Binary files /dev/null and b/apps/storybook/__tests__/screenshots/meter--test-bed-1-edge-darwin-.png differ diff --git a/apps/storybook/__tests__/screenshots/meter--test-bed-1-firefox-darwin-.png b/apps/storybook/__tests__/screenshots/meter--test-bed-1-firefox-darwin-.png new file mode 100644 index 000000000..dee0d8381 Binary files /dev/null and b/apps/storybook/__tests__/screenshots/meter--test-bed-1-firefox-darwin-.png differ diff --git a/apps/storybook/__tests__/screenshots/meter--test-bed-1-safari-darwin-.png b/apps/storybook/__tests__/screenshots/meter--test-bed-1-safari-darwin-.png new file mode 100644 index 000000000..087f52cea Binary files /dev/null and b/apps/storybook/__tests__/screenshots/meter--test-bed-1-safari-darwin-.png differ diff --git a/apps/website/content/docs/components/(components)/meter.mdx b/apps/website/content/docs/components/(components)/meter.mdx new file mode 100644 index 000000000..900dcdbca --- /dev/null +++ b/apps/website/content/docs/components/(components)/meter.mdx @@ -0,0 +1,156 @@ +--- +title: 'Meter' +site_name: 'Meter - Vapor Core' +description: 'Meter는 디스크 사용량이나 점수처럼 알려진 범위 안의 측정값을 표시합니다.' +--- + + +```json doc-gen:file +{ + "file": "./src/components/demo/examples/meter/default-meter.tsx", + "codeblock": true +} +``` + + +`Meter.Label`은 보조기술에 전달되는 이름이기도 합니다. `저장 공간`, `CPU 사용률`처럼 **무엇을 재는 값인지**를 문구에 담으세요. 화면에 이름을 두지 않을 때는 `Meter.Label` 대신 `Meter.Root`에 `aria-label`을 주세요 — 둘을 함께 주면 `aria-label`이 무시됩니다. + +## Property + +--- + +### Size + +`size`로 트랙의 높이를 조절합니다. + + +```json doc-gen:file +{ + "file": "./src/components/demo/examples/meter/meter-size.tsx", + "codeblock": true +} +``` + + +### Type + +`type`으로 채워진 영역의 색상을 지정합니다. 색만으로는 위험 구간을 알 수 없으므로 레이블에도 함께 적어줍니다. + + +```json doc-gen:file +{ + "file": "./src/components/demo/examples/meter/meter-type.tsx", + "codeblock": true +} +``` + + +### Range + +`min`, `max`로 범위를, `format`으로 표시 형식을 지정합니다. 값 텍스트가 단위만 담고 있다면 `getAriaValueText`로 무엇을 재는 값인지 함께 읽히도록 합니다. + + +```json doc-gen:file +{ + "file": "./src/components/demo/examples/meter/meter-range.tsx", + "codeblock": true +} +``` + + +## Examples + +--- + +### Auto Update + +값이 스스로 바뀌는 미터에는 갱신을 멈출 수단을 함께 둡니다. 갱신 간격은 초당 3회를 넘기지 않습니다. + + +```json doc-gen:file +{ + "file": "./src/components/demo/examples/meter/meter-auto-update.tsx", + "codeblock": true +} +``` + + +## Accessibility + +--- + +`role="meter"`와 `aria-valuenow` / `aria-valuemin` / `aria-valuemax`는 `Meter`가 알아서 붙입니다. 나머지는 컴포넌트가 대신 지킬 수 없으니 **쓰는 쪽에서 챙겨야 합니다.** + +### 진행률에는 쓰지 마세요 + +`Meter`가 다루는 값은 **범위가 정해진 지금의 측정값**입니다. 디스크 사용량, 점수, 잔량이 여기 해당합니다. 작업이 얼마나 끝났는지 보여주는 진행률이나 상한이 없는 값은 대상이 아닙니다. + +진행률에 `role="meter"`가 붙으면 보조기술은 "완료를 향해 가는 값"이 아니라 "지금 재고 있는 값"으로 읽습니다. `render`로 역할만 갈아끼우지 말고 진행률에 맞는 컴포넌트를 쓰세요. + +### 이름은 하나만 주세요 + +`Meter.Label`을 렌더하면 `Meter.Root`에 준 `aria-label`은 이름 계산에서 밀려납니다. 오류도 경고도 없이 조용히 사라지니 둘 중 하나만 쓰세요. + +- 이름을 화면에 보여준다 → `Meter.Label` +- 화면에 이름을 두지 않는다 → `Meter.Root`의 `aria-label` + +### 같은 값은 같은 문구·같은 형식으로 + +한 측정값이 여러 화면에 나온다면 레이블 문구와 `format`을 맞추세요. 여기서는 `저장 공간 4.2GB`, 저기서는 `디스크 52%`라면 읽는 사람은 둘이 같은 값인지 알 길이 없습니다. + +### 자동으로 갱신된다면 멈출 수단을 두세요 + +값이 5초를 넘겨 저절로 바뀐다면 사용자가 일시정지·정지·숨김 중 하나를 고를 수 있어야 합니다. 갱신 간격은 **초당 3회 이하**로 잡으세요. 그보다 빠르면 화면이 깜빡이는 것처럼 보입니다. 위 [Auto Update](#auto-update)가 그 예입니다. + +### 색을 바꿀 때는 대비를 확인하세요 + +`className`이나 `$css`로 색을 갈아끼우면 기본 색이 맞춰 둔 대비가 무너집니다. + +- `Meter.Label` / `Meter.Value` 텍스트 대 배경 — **4.5:1** 이상 +- `Meter.IndicatorPrimitive` 대 `Meter.Track`, `Meter.Track` 대 페이지 배경 — **3:1** 이상 + +`type="warning"`처럼 색으로 상태를 알린다면 그 상태를 레이블 문구에도 적으세요. 색을 구분하지 못하는 사용자에게는 문구가 유일한 단서입니다. + +### 미터를 가리킬 때 모양·색·위치로만 말하지 마세요 + +"오른쪽 초록 막대를 확인하세요" 같은 안내는 그 색과 위치를 알아채는 사람에게만 통합니다. 미터를 가리킬 때는 레이블 문구를 부르세요. + +### 페이지와 언어가 다르면 표시하세요 + +레이블이 페이지 언어와 다른 언어로 적혀 있다면 그 요소에 `lang`을 주세요. 값 형식도 그 언어를 따라야 한다면 `locale`까지 맞춥니다. + +### 폭을 고정하지 마세요 + +`Meter.Root`는 기본이 `width: 100%`입니다. `width`를 고정하는 대신 `max-width`를 쓰면 320px 폭에서도, 200% 확대에서도 내용이 잘리지 않습니다. + +### 값은 텍스트로 보여주세요 + +`42%` 같은 숫자를 그림으로 만들어 넣지 마세요. `Meter.Value`가 내는 텍스트여야 사용자가 글자 크기나 자간을 바꿨을 때 값도 함께 바뀝니다. + +## Props Table + +--- + +### Meter.Root + + + +### Meter.Label + + + +### Meter.Value + + + +### Meter.Track + + + +### Meter.TrackPrimitive + + + +### Meter.IndicatorPrimitive + + diff --git a/apps/website/content/docs/components/meta.json b/apps/website/content/docs/components/meta.json index 022b5f9d7..6319cbecc 100644 --- a/apps/website/content/docs/components/meta.json +++ b/apps/website/content/docs/components/meta.json @@ -30,6 +30,7 @@ "(components)/icon-button.mdx", "(components)/input-group.mdx", "(components)/menu.mdx", + "(components)/meter.mdx", "(components)/multi-select.mdx", "(components)/navigation-menu.mdx", "(components)/pagination.mdx", diff --git a/apps/website/docs-extractor.config.mjs b/apps/website/docs-extractor.config.mjs index f98638287..57c783b62 100644 --- a/apps/website/docs-extractor.config.mjs +++ b/apps/website/docs-extractor.config.mjs @@ -13,5 +13,8 @@ export default defineConfig({ 'button/button.tsx': { include: ['nativeButton'], }, + 'meter/meter.tsx': { + include: ['aria-valuetext'], + }, }, }); diff --git a/apps/website/public/components/generated/meter-indicator-primitive.json b/apps/website/public/components/generated/meter-indicator-primitive.json new file mode 100644 index 000000000..ce25e3d17 --- /dev/null +++ b/apps/website/public/components/generated/meter-indicator-primitive.json @@ -0,0 +1,31 @@ +{ + "name": "IndicatorPrimitive", + "displayName": "Meter.IndicatorPrimitive", + "description": "현재 값에 해당하는 만큼 트랙을 채웁니다. `
` 요소로 렌더링됩니다.", + "props": [ + { + "name": "className", + "type": [ + "string | ((state: Meter.IndicatorPrimitive.State) => (string | undefined))" + ], + "required": false, + "description": "요소에 적용된 CSS 클래스 또는 컴포넌트의 상태에 따라 클래스를 반환하는 함수." + }, + { + "name": "style", + "type": [ + "React.CSSProperties | ((state: Meter.IndicatorPrimitive.State) => (React.CSSProperties | undefined))" + ], + "required": false, + "description": "요소에 적용된 인라인 스타일 또는 컴포넌트의 상태에 따라 스타일 객체를 반환하는 함수." + }, + { + "name": "render", + "type": [ + "ReactElement | ((props: HTMLProps, state: Meter.IndicatorPrimitive.State) => ReactElement)" + ], + "required": false, + "description": "컴포넌트의 HTML 요소를 다른 태그로 대체하거나 다른 컴포넌트와 조합할 수 있습니다. 렌더링할 요소를 반환하는 `ReactElement` 또는 함수를 인수로 받습니다." + } + ] +} diff --git a/apps/website/public/components/generated/meter-label.json b/apps/website/public/components/generated/meter-label.json new file mode 100644 index 000000000..95f1e1a05 --- /dev/null +++ b/apps/website/public/components/generated/meter-label.json @@ -0,0 +1,29 @@ +{ + "name": "Label", + "displayName": "Meter.Label", + "description": "미터의 이름을 화면에 보여주고 보조기술에 전달합니다. `` 요소로 렌더링됩니다.", + "props": [ + { + "name": "className", + "type": ["string | ((state: Meter.Label.State) => (string | undefined))"], + "required": false, + "description": "요소에 적용된 CSS 클래스 또는 컴포넌트의 상태에 따라 클래스를 반환하는 함수." + }, + { + "name": "style", + "type": [ + "React.CSSProperties | ((state: Meter.Label.State) => (React.CSSProperties | undefined))" + ], + "required": false, + "description": "요소에 적용된 인라인 스타일 또는 컴포넌트의 상태에 따라 스타일 객체를 반환하는 함수." + }, + { + "name": "render", + "type": [ + "ReactElement | ((props: HTMLProps, state: Meter.Label.State) => ReactElement)" + ], + "required": false, + "description": "컴포넌트의 HTML 요소를 다른 태그로 대체하거나 다른 컴포넌트와 조합할 수 있습니다. 렌더링할 요소를 반환하는 `ReactElement` 또는 함수를 인수로 받습니다." + } + ] +} diff --git a/apps/website/public/components/generated/meter-root.json b/apps/website/public/components/generated/meter-root.json new file mode 100644 index 000000000..da31199c0 --- /dev/null +++ b/apps/website/public/components/generated/meter-root.json @@ -0,0 +1,110 @@ +{ + "name": "Root", + "displayName": "Meter.Root", + "description": "알려진 범위 안에서 측정값을 표시하는 컴포넌트입니다. 디스크 사용량이나 점수처럼 값 자체의 크기를 보여줄 때 사용합니다. `
` 요소로 렌더링됩니다.", + "props": [ + { + "name": "value", + "type": [ + "number" + ], + "required": true, + "description": "미터의 현재 값" + }, + { + "name": "size", + "type": [ + "sm", + "md", + "lg" + ], + "required": false, + "description": "미터의 크기. 트랙의 높이를 결정합니다.", + "defaultValue": "md" + }, + { + "name": "type", + "type": [ + "default", + "warning" + ], + "required": false, + "description": "채워진 영역의 색상. 색만으로 상태를 알리지 않도록 레이블 문구에도 함께 적어 주세요.", + "defaultValue": "default" + }, + { + "name": "className", + "type": [ + "string | ((state: Meter.Root.State) => (string | undefined))" + ], + "required": false, + "description": "요소에 적용된 CSS 클래스 또는 컴포넌트의 상태에 따라 클래스를 반환하는 함수." + }, + { + "name": "aria-valuetext", + "type": [ + "string" + ], + "required": false, + "description": "현재 값을 대신해 보조기술에 전달할 텍스트. 값에 따라 달라지는 텍스트가 필요하면 `getAriaValueText`를 사용하세요." + }, + { + "name": "format", + "type": [ + "Intl.NumberFormatOptions" + ], + "required": false, + "description": "값의 표시 형식을 지정하는 옵션" + }, + { + "name": "getAriaValueText", + "type": [ + "(formattedValue: string, value: number) => string" + ], + "required": false, + "description": "`aria-valuenow`(미터의 현재 값)를 대신할, 사람이 읽을 수 있는 텍스트를 반환하는 함수" + }, + { + "name": "locale", + "type": [ + "Intl.LocalesArgument" + ], + "required": false, + "description": "값을 형식화할 때 `Intl.NumberFormat`이 사용할 로케일\n\n기본값은 사용자 런타임의 로케일입니다." + }, + { + "name": "max", + "type": [ + "number" + ], + "required": false, + "description": "미터의 최대값", + "defaultValue": "100" + }, + { + "name": "min", + "type": [ + "number" + ], + "required": false, + "description": "미터의 최소값", + "defaultValue": "0" + }, + { + "name": "style", + "type": [ + "React.CSSProperties | ((state: Meter.Root.State) => (React.CSSProperties | undefined))" + ], + "required": false, + "description": "요소에 적용된 인라인 스타일 또는 컴포넌트의 상태에 따라 스타일 객체를 반환하는 함수." + }, + { + "name": "render", + "type": [ + "ReactElement | ((props: HTMLProps, state: Meter.Root.State) => ReactElement)" + ], + "required": false, + "description": "컴포넌트의 HTML 요소를 다른 태그로 대체하거나 다른 컴포넌트와 조합할 수 있습니다. 렌더링할 요소를 반환하는 `ReactElement` 또는 함수를 인수로 받습니다." + } + ] +} diff --git a/apps/website/public/components/generated/meter-track-primitive.json b/apps/website/public/components/generated/meter-track-primitive.json new file mode 100644 index 000000000..c8777e92f --- /dev/null +++ b/apps/website/public/components/generated/meter-track-primitive.json @@ -0,0 +1,31 @@ +{ + "name": "TrackPrimitive", + "displayName": "Meter.TrackPrimitive", + "description": "미터의 전체 범위를 나타내며, 내부에 인디케이터를 포함하지 않습니다. `
` 요소로 렌더링됩니다.", + "props": [ + { + "name": "className", + "type": [ + "string | ((state: Meter.TrackPrimitive.State) => (string | undefined))" + ], + "required": false, + "description": "요소에 적용된 CSS 클래스 또는 컴포넌트의 상태에 따라 클래스를 반환하는 함수." + }, + { + "name": "style", + "type": [ + "React.CSSProperties | ((state: Meter.TrackPrimitive.State) => (React.CSSProperties | undefined))" + ], + "required": false, + "description": "요소에 적용된 인라인 스타일 또는 컴포넌트의 상태에 따라 스타일 객체를 반환하는 함수." + }, + { + "name": "render", + "type": [ + "ReactElement | ((props: HTMLProps, state: Meter.TrackPrimitive.State) => ReactElement)" + ], + "required": false, + "description": "컴포넌트의 HTML 요소를 다른 태그로 대체하거나 다른 컴포넌트와 조합할 수 있습니다. 렌더링할 요소를 반환하는 `ReactElement` 또는 함수를 인수로 받습니다." + } + ] +} diff --git a/apps/website/public/components/generated/meter-track.json b/apps/website/public/components/generated/meter-track.json new file mode 100644 index 000000000..ee0efb244 --- /dev/null +++ b/apps/website/public/components/generated/meter-track.json @@ -0,0 +1,39 @@ +{ + "name": "Track", + "displayName": "Meter.Track", + "description": "미터의 전체 범위를 나타내며, 내부적으로 `Meter.IndicatorPrimitive`를 포함합니다. `
` 요소로 렌더링됩니다.", + "props": [ + { + "name": "className", + "type": [ + "string | ((state: Meter.Track.State) => (string | undefined))" + ], + "required": false, + "description": "요소에 적용된 CSS 클래스 또는 컴포넌트의 상태에 따라 클래스를 반환하는 함수." + }, + { + "name": "style", + "type": [ + "React.CSSProperties | ((state: Meter.Track.State) => (React.CSSProperties | undefined))" + ], + "required": false, + "description": "요소에 적용된 인라인 스타일 또는 컴포넌트의 상태에 따라 스타일 객체를 반환하는 함수." + }, + { + "name": "render", + "type": [ + "ReactElement | ((props: HTMLProps, state: Meter.Track.State) => ReactElement)" + ], + "required": false, + "description": "컴포넌트의 HTML 요소를 다른 태그로 대체하거나 다른 컴포넌트와 조합할 수 있습니다. 렌더링할 요소를 반환하는 `ReactElement` 또는 함수를 인수로 받습니다." + }, + { + "name": "indicatorElement", + "type": [ + "ReactElement" + ], + "required": false, + "description": "커스텀 인디케이터 요소입니다. 기본값은 ``입니다." + } + ] +} diff --git a/apps/website/public/components/generated/meter-value.json b/apps/website/public/components/generated/meter-value.json new file mode 100644 index 000000000..e07825c7c --- /dev/null +++ b/apps/website/public/components/generated/meter-value.json @@ -0,0 +1,35 @@ +{ + "name": "Value", + "displayName": "Meter.Value", + "description": "형식화된 값을 텍스트로 표시합니다. 중복 안내를 막기 위해 보조기술에서는 숨겨집니다. `` 요소로 렌더링됩니다.", + "props": [ + { + "name": "children", + "type": ["(formattedValue: string, value: number) => ReactNode"], + "required": false, + "description": "형식화된 값과 원본 값을 받아 표시할 내용을 반환하는 함수" + }, + { + "name": "className", + "type": ["string | ((state: Meter.Value.State) => (string | undefined))"], + "required": false, + "description": "요소에 적용된 CSS 클래스 또는 컴포넌트의 상태에 따라 클래스를 반환하는 함수." + }, + { + "name": "style", + "type": [ + "React.CSSProperties | ((state: Meter.Value.State) => (React.CSSProperties | undefined))" + ], + "required": false, + "description": "요소에 적용된 인라인 스타일 또는 컴포넌트의 상태에 따라 스타일 객체를 반환하는 함수." + }, + { + "name": "render", + "type": [ + "ReactElement | ((props: HTMLProps, state: Meter.Value.State) => ReactElement)" + ], + "required": false, + "description": "컴포넌트의 HTML 요소를 다른 태그로 대체하거나 다른 컴포넌트와 조합할 수 있습니다. 렌더링할 요소를 반환하는 `ReactElement` 또는 함수를 인수로 받습니다." + } + ] +} diff --git a/apps/website/src/components/demo/examples/meter/default-meter.tsx b/apps/website/src/components/demo/examples/meter/default-meter.tsx new file mode 100644 index 000000000..d45e3deba --- /dev/null +++ b/apps/website/src/components/demo/examples/meter/default-meter.tsx @@ -0,0 +1,11 @@ +import { Meter } from '@vapor-ui/core'; + +export default function DefaultMeter() { + return ( + + 저장 공간 + + + + ); +} diff --git a/apps/website/src/components/demo/examples/meter/meter-auto-update.tsx b/apps/website/src/components/demo/examples/meter/meter-auto-update.tsx new file mode 100644 index 000000000..5117a2079 --- /dev/null +++ b/apps/website/src/components/demo/examples/meter/meter-auto-update.tsx @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react'; + +import { Button, Meter, VStack } from '@vapor-ui/core'; + +export default function MeterAutoUpdate() { + const [value, setValue] = useState(42); + const [running, setRunning] = useState(true); + + useEffect(() => { + if (!running) return; + + const id = setInterval(() => setValue((prev) => (prev + 7) % 101), 1000); + + return () => clearInterval(id); + }, [running]); + + return ( + + + CPU 사용률 + + + + + + + ); +} diff --git a/apps/website/src/components/demo/examples/meter/meter-range.tsx b/apps/website/src/components/demo/examples/meter/meter-range.tsx new file mode 100644 index 000000000..033feb95d --- /dev/null +++ b/apps/website/src/components/demo/examples/meter/meter-range.tsx @@ -0,0 +1,17 @@ +import { Meter } from '@vapor-ui/core'; + +export default function MeterRange() { + return ( + `8GB 중 ${formattedValue} 사용`} + $css={{ maxWidth: '20rem' }} + > + 저장 공간 + + + + ); +} diff --git a/apps/website/src/components/demo/examples/meter/meter-size.tsx b/apps/website/src/components/demo/examples/meter/meter-size.tsx new file mode 100644 index 000000000..199363144 --- /dev/null +++ b/apps/website/src/components/demo/examples/meter/meter-size.tsx @@ -0,0 +1,15 @@ +import { Meter, VStack } from '@vapor-ui/core'; + +export default function MeterSize() { + return ( + + {(['sm', 'md', 'lg'] as const).map((size) => ( + + 저장 공간 ({size}) + + + + ))} + + ); +} diff --git a/apps/website/src/components/demo/examples/meter/meter-type.tsx b/apps/website/src/components/demo/examples/meter/meter-type.tsx new file mode 100644 index 000000000..1665a92c2 --- /dev/null +++ b/apps/website/src/components/demo/examples/meter/meter-type.tsx @@ -0,0 +1,19 @@ +import { Meter, VStack } from '@vapor-ui/core'; + +export default function MeterType() { + return ( + + + 저장 공간 + + + + + + 저장 공간 부족 + + + + + ); +} diff --git a/packages/core/src/components/meter/index.parts.ts b/packages/core/src/components/meter/index.parts.ts new file mode 100644 index 000000000..149c3dc50 --- /dev/null +++ b/packages/core/src/components/meter/index.parts.ts @@ -0,0 +1,8 @@ +export { + MeterRoot as Root, + MeterLabel as Label, + MeterTrackPrimitive as TrackPrimitive, + MeterTrack as Track, + MeterIndicatorPrimitive as IndicatorPrimitive, + MeterValue as Value, +} from './meter'; diff --git a/packages/core/src/components/meter/index.ts b/packages/core/src/components/meter/index.ts new file mode 100644 index 000000000..170d463dd --- /dev/null +++ b/packages/core/src/components/meter/index.ts @@ -0,0 +1 @@ +export * as Meter from './index.parts'; diff --git a/packages/core/src/components/meter/meter.css.ts b/packages/core/src/components/meter/meter.css.ts new file mode 100644 index 000000000..01e71aacc --- /dev/null +++ b/packages/core/src/components/meter/meter.css.ts @@ -0,0 +1,69 @@ +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'; + +export const root = componentStyle({ + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) auto', + alignItems: 'center', + rowGap: vars.size.space['100'], + width: '100%', +}); + +export const track = componentRecipe({ + base: { + gridColumn: '1 / -1', + borderRadius: vars.size.borderRadius['900'], + backgroundColor: vars.color.background['secondary'], + overflow: 'hidden', + }, + + defaultVariants: { size: 'md' }, + variants: { + /** + * Size of the meter. Controls the height of the track. + */ + size: { + sm: { height: vars.size.dimension['050'] }, + md: { height: vars.size.dimension['075'] }, + lg: { height: vars.size.dimension['150'] }, + }, + }, +}); + +export const indicator = componentRecipe({ + base: { + display: 'block', + borderRadius: 'inherit', + }, + + defaultVariants: { type: 'default' }, + variants: { + /** + * Color of the filled portion of the meter. + */ + type: { + default: { backgroundColor: vars.color.background['primary'] }, + warning: { backgroundColor: vars.color.background['warning'] }, + }, + }, +}); + +export const label = componentStyle([ + typography({ style: 'subtitle1' }), + { + color: vars.color.foreground['normal'], + }, +]); + +export const value = componentStyle([ + typography({ style: 'subtitle1' }), + { + color: vars.color.foreground['secondary'], + }, +]); + +export type TrackVariants = NonNullable>; +export type IndicatorVariants = NonNullable>; diff --git a/packages/core/src/components/meter/meter.stories.tsx b/packages/core/src/components/meter/meter.stories.tsx new file mode 100644 index 000000000..627873031 --- /dev/null +++ b/packages/core/src/components/meter/meter.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { Meter } from '.'; +import { VStack } from '../v-stack'; + +export default { + title: 'Meter', + component: Meter.Root, + argTypes: { + size: { control: 'inline-radio', options: ['sm', 'md', 'lg'] }, + type: { control: 'inline-radio', options: ['default', 'warning'] }, + value: { control: { type: 'range', min: 0, max: 100 } }, + }, +} as Meta; + +type Story = StoryObj; + +const MeterExample = ({ + label = 'Storage used', + ...args +}: Meter.Root.Props & { label?: string }) => ( + + {label} + + + +); + +export const Default: Story = { + args: { value: 42 }, + render: (args) => ( +
+ +
+ ), +}; +export const TestBed: Story = { + render: () => ( + + {(['sm', 'md', 'lg'] as const).map((size) => ( + + {(['default', 'warning'] as const).map((type) => ( + + ))} + + ))} + + ), +}; diff --git a/packages/core/src/components/meter/meter.test.tsx b/packages/core/src/components/meter/meter.test.tsx new file mode 100644 index 000000000..8b005485e --- /dev/null +++ b/packages/core/src/components/meter/meter.test.tsx @@ -0,0 +1,258 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { axe } from 'vitest-axe'; + +import { Meter } from '.'; +import * as styles from './meter.css'; + +const LABEL_TEXT = 'Storage used'; + +const MeterTest = ({ children, ...props }: Partial) => ( + + {children ?? ( + <> + {LABEL_TEXT} + + + + )} + +); + +describe('Meter', () => { + afterEach(cleanup); + + it('should keep the value-text path open on the root', () => { + expectTypeOf().toEqualTypeOf< + Intl.LocalesArgument | undefined + >(); + expectTypeOf().toEqualTypeOf< + Intl.NumberFormatOptions | undefined + >(); + expectTypeOf().toHaveProperty('getAriaValueText'); + expectTypeOf().toHaveProperty('aria-valuetext'); + }); + + it('should have no a11y violations', async () => { + const rendered = render(); + const result = await axe(rendered.container); + + expect(result).toHaveNoViolations(); + }); + + it('should expose role="meter" with the value range', () => { + render(); + const meter = screen.getByRole('meter'); + + expect(meter).toHaveAttribute('aria-valuemin', '0'); + expect(meter).toHaveAttribute('aria-valuemax', '100'); + expect(meter).toHaveAttribute('aria-valuenow', '42'); + }); + + it('should clamp values outside the range', () => { + const { unmount } = render(); + expect(screen.getByRole('meter')).toHaveAttribute('aria-valuenow', '100'); + unmount(); + + render(); + expect(screen.getByRole('meter')).toHaveAttribute('aria-valuenow', '0'); + }); + + it('should use the label as the accessible name', () => { + render(); + + expect(screen.getByRole('meter')).toHaveAccessibleName(LABEL_TEXT); + }); + + it('should format `aria-valuetext` with the given `format` instead of a percentage', () => { + const { unmount } = render(); + expect(screen.getByRole('meter')).toHaveAttribute('aria-valuetext', '42%'); + unmount(); + + render(); + expect(screen.getByRole('meter')).toHaveAttribute('aria-valuetext', '4.2 GB'); + }); + + it('should let `getAriaValueText` replace the generated value text', () => { + render( + `${formatted} of 20 GB (${value})`} + />, + ); + + expect(screen.getByRole('meter')).toHaveAttribute( + 'aria-valuetext', + '4.2 GB of 20 GB (4.2)', + ); + }); + + it('should let `aria-valuetext` override the generated value text', () => { + render(); + + expect(screen.getByRole('meter')).toHaveAttribute( + 'aria-valuetext', + '42GB used out of 100GB', + ); + }); + + it('should format the value with the given `locale`', () => { + render( + , + ); + + expect(screen.getByRole('meter')).toHaveAttribute('aria-valuetext', '4.2GB'); + }); + + it('should hide the value text from assistive technology', () => { + render(); + + expect(screen.getByText('42%')).toHaveAttribute('aria-hidden', 'true'); + }); + + it('should not set `aria-labelledby` when no label is rendered', () => { + render( + + + , + ); + + expect(screen.getByRole('meter')).not.toHaveAttribute('aria-labelledby'); + }); + + it('should point `aria-labelledby` at the rendered label', () => { + const { container } = render(); + const labelledBy = screen.getByRole('meter').getAttribute('aria-labelledby'); + + expect(labelledBy).toBeTruthy(); + expect(container.querySelector(`#${labelledBy}`)).toHaveTextContent(LABEL_TEXT); + }); + + it('should give each instance its own label id', () => { + const { container } = render( + <> + + + + , + ); + + const ids = screen + .getAllByRole('meter') + .map((meter) => meter.getAttribute('aria-labelledby')); + + expect(new Set(ids).size).toBe(ids.length); + ids.forEach((id) => { + expect(container.querySelector(`#${id}`)).toBeInTheDocument(); + }); + }); + + it('should use `aria-label` as the accessible name', () => { + render( + + + , + ); + + expect(screen.getByRole('meter')).toHaveAccessibleName('Disk usage'); + }); + + it('should use `aria-labelledby` as the accessible name', () => { + render( + <> +

Disk usage

+ + + + , + ); + + expect(screen.getByRole('meter')).toHaveAccessibleName('Disk usage'); + }); + + it('should apply `size` to the track and `type` to the indicator', () => { + render( + + } + /> + , + ); + + expect(screen.getByTestId('track')).toHaveClass(styles.track({ size: 'lg' })); + expect(screen.getByTestId('indicator')).toHaveClass(styles.indicator({ type: 'warning' })); + expect(screen.getByTestId('indicator')).not.toHaveClass( + styles.indicator({ type: 'default' }), + ); + }); + + it('should render an indicator inside `Meter.Track` by default', () => { + render( + + + , + ); + + const indicator = screen.getByTestId('track').firstElementChild; + + expect(indicator).toHaveClass(styles.indicator({ type: 'warning' })); + }); + + it('should render no indicator when `Meter.TrackPrimitive` is used directly', () => { + render( + + + , + ); + + expect(screen.getByTestId('track')).toBeEmptyDOMElement(); + }); + + describe('development warnings', () => { + let warn: ReturnType; + + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warn.mockRestore(); + }); + + it('should warn when `min` is not less than `max`', () => { + render(); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('`min` must be less than')); + }); + + it('should warn when the meter has no accessible name', () => { + render( + + + , + ); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Meter has no accessible name'), + ); + }); + + it('should stay silent when `Meter.Label` names the meter', () => { + render(); + + expect(warn).not.toHaveBeenCalled(); + }); + + it('should stay silent when only `aria-label` is given', () => { + render( + + + , + ); + + expect(warn).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/components/meter/meter.tsx b/packages/core/src/components/meter/meter.tsx new file mode 100644 index 000000000..efa2284d7 --- /dev/null +++ b/packages/core/src/components/meter/meter.tsx @@ -0,0 +1,222 @@ +'use client'; + +import type { ReactElement, RefObject } from 'react'; +import { forwardRef, useEffect, useRef } from 'react'; + +import { Meter as BaseMeter } from '@base-ui/react/meter'; + +import { createContext } from '~/libs/create-context'; +import { cn } from '~/utils/cn'; +import { createSplitProps } from '~/utils/create-split-props'; +import { resolveStyles } from '~/utils/resolve-styles'; +import type { VaporUIComponentProps } from '~/utils/types'; +import { warn } from '~/utils/warn'; + +import type { IndicatorVariants, TrackVariants } from './meter.css'; +import * as styles from './meter.css'; + +type MeterVariants = TrackVariants & IndicatorVariants; +type MeterSharedProps = MeterVariants; + +type MeterContext = MeterSharedProps & { + /** Set by `Meter.Label` so the root can warn when the meter has no accessible name. */ + hasLabelRef: RefObject; +}; + +const [MeterProvider, useMeterContext] = createContext({ + name: 'Meter', + hookName: 'useMeterContext', + providerName: 'MeterProvider', +}); + +/* ------------------------------------------------------------------------------------------------- + * Meter.Root + * -----------------------------------------------------------------------------------------------*/ + +/** + * Displays a measurement inside a known range, such as disk usage or a score. Renders a `
` element. + */ +export const MeterRoot = forwardRef((props, ref) => { + const { className, min = 0, max = 100, ...componentProps } = resolveStyles(props); + const [variantProps, otherProps] = createSplitProps()(componentProps, [ + 'type', + 'size', + ]); + + const hasLabelRef = useRef(false); + const ariaLabel = otherProps['aria-label']; + const ariaLabelledBy = otherProps['aria-labelledby']; + + useEffect(() => { + if (process.env.NODE_ENV === 'production') return; + + if (min >= max) { + warn( + `Meter received min={${min}} and max={${max}}. \`min\` must be less than \`max\` for the value to be meaningful.`, + ); + } + + if (!hasLabelRef.current && !ariaLabel && !ariaLabelledBy) { + warn( + 'Meter has no accessible name. Render a `Meter.Label`, or pass `aria-label` / `aria-labelledby` to `Meter.Root`.', + ); + } + }, [min, max, ariaLabel, ariaLabelledBy]); + + return ( + + + + ); +}); +MeterRoot.displayName = 'Meter.Root'; + +/* ------------------------------------------------------------------------------------------------- + * Meter.Label + * -----------------------------------------------------------------------------------------------*/ + +/** + * Names the meter for assistive technology and shows that name on screen. Renders a `` element. + */ +export const MeterLabel = forwardRef((props, ref) => { + const { className, ...componentProps } = resolveStyles(props); + const { hasLabelRef } = useMeterContext(); + + useEffect(() => { + hasLabelRef.current = true; + + return () => { + hasLabelRef.current = false; + }; + }, [hasLabelRef]); + + return ( + + ); +}); +MeterLabel.displayName = 'Meter.Label'; + +/* ------------------------------------------------------------------------------------------------- + * Meter.TrackPrimitive + * -----------------------------------------------------------------------------------------------*/ + +/** + * Represents the full range of the meter without any indicator inside. Renders a `
` element. + */ +export const MeterTrackPrimitive = forwardRef( + (props, ref) => { + const { className, ...componentProps } = resolveStyles(props); + const { size } = useMeterContext(); + + return ( + + ); + }, +); +MeterTrackPrimitive.displayName = 'Meter.TrackPrimitive'; + +/* ------------------------------------------------------------------------------------------------- + * Meter.IndicatorPrimitive + * -----------------------------------------------------------------------------------------------*/ + +/** + * Fills the portion of the track that corresponds to the current value. Renders a `
` element. + */ +export const MeterIndicatorPrimitive = forwardRef( + (props, ref) => { + const { className, ...componentProps } = resolveStyles(props); + const { type } = useMeterContext(); + + return ( + + ); + }, +); +MeterIndicatorPrimitive.displayName = 'Meter.IndicatorPrimitive'; + +/* ------------------------------------------------------------------------------------------------- + * Meter.Track + * -----------------------------------------------------------------------------------------------*/ + +/** + * Represents the full range of the meter and renders `Meter.IndicatorPrimitive` inside. Renders a `
` element. + */ +export const MeterTrack = forwardRef((props, ref) => { + const { indicatorElement, ...componentProps } = props; + + return ( + + {indicatorElement ?? } + + ); +}); +MeterTrack.displayName = 'Meter.Track'; + +/* ------------------------------------------------------------------------------------------------- + * Meter.Value + * -----------------------------------------------------------------------------------------------*/ + +/** + * Shows the formatted value as text, hidden from assistive technology to avoid a duplicate announcement. Renders a `` element. + */ +export const MeterValue = forwardRef((props, ref) => { + const { className, ...componentProps } = resolveStyles(props); + + return ( + + ); +}); +MeterValue.displayName = 'Meter.Value'; + +/* -----------------------------------------------------------------------------------------------*/ + +export namespace MeterRoot { + export type State = BaseMeter.Root.State; + export type Props = VaporUIComponentProps & MeterSharedProps; +} + +export namespace MeterLabel { + export type State = BaseMeter.Label.State; + export type Props = VaporUIComponentProps; +} + +export namespace MeterTrackPrimitive { + export type State = BaseMeter.Track.State; + export type Props = VaporUIComponentProps; +} + +export namespace MeterIndicatorPrimitive { + export type State = BaseMeter.Indicator.State; + export type Props = VaporUIComponentProps; +} + +export interface MeterTrackProps extends Omit { + /** + * A Custom element for Meter.IndicatorPrimitive. If not provided, the default Meter.IndicatorPrimitive will be rendered. + */ + indicatorElement?: ReactElement; +} + +export namespace MeterTrack { + export type State = MeterTrackPrimitive.State; + export type Props = MeterTrackProps; +} + +export namespace MeterValue { + export type State = BaseMeter.Value.State; + export type Props = VaporUIComponentProps; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a2bec0c73..c1f5d1757 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,6 +18,7 @@ export * from './components/h-stack'; export * from './components/icon-button'; export * from './components/input-group'; export * from './components/menu'; +export * from './components/meter'; export * from './components/multi-select'; export * from './components/navigation-menu'; export * from './components/pagination'; diff --git a/packages/core/src/utils/warn.test.ts b/packages/core/src/utils/warn.test.ts new file mode 100644 index 000000000..4e09ef9ba --- /dev/null +++ b/packages/core/src/utils/warn.test.ts @@ -0,0 +1,28 @@ +import { warn } from './warn'; + +describe('warn', () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('warns once per message', () => { + warn('same message'); + warn('same message'); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith('Vapor UI: same message'); + }); + + it('warns for each distinct message', () => { + warn('first message'); + warn('second message'); + + expect(warnSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/core/src/utils/warn.ts b/packages/core/src/utils/warn.ts new file mode 100644 index 000000000..8686d2e8e --- /dev/null +++ b/packages/core/src/utils/warn.ts @@ -0,0 +1,14 @@ +const warned = new Set(); + +/** + * Logs a development-only warning, at most once per distinct message. + * + * Guard the call site with `process.env.NODE_ENV !== 'production'` so bundlers + * can drop the message construction from production builds. + */ +export const warn = (message: string) => { + if (process.env.NODE_ENV === 'production' || warned.has(message)) return; + + warned.add(message); + console.warn(`Vapor UI: ${message}`); +};