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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/meter-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@vapor-ui/core': minor
---

add new `Meter` component
50 changes: 48 additions & 2 deletions .claude/rules/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,17 @@ 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
export const title = componentStyle([typography({ style: 'subtitle1' }), { gridColumn: '1' }]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```

### `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.
Expand All @@ -86,6 +91,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
Expand Down
156 changes: 156 additions & 0 deletions apps/website/content/docs/components/(components)/meter.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
---
title: 'Meter'
site_name: 'Meter - Vapor Core'
description: 'Meter는 디스크 사용량이나 점수처럼 알려진 범위 안의 측정값을 표시합니다.'
---

<Demo name="meter/default-meter">
```json doc-gen:file
{
"file": "./src/components/demo/examples/meter/default-meter.tsx",
"codeblock": true
}
```
</Demo>

`Meter.Label`은 보조기술에 전달되는 이름이기도 합니다. `저장 공간`, `CPU 사용률`처럼 **무엇을 재는 값인지**를 문구에 담으세요. 화면에 이름을 두지 않을 때는 `Meter.Label` 대신 `Meter.Root`에 `aria-label`을 주세요 — 둘을 함께 주면 `aria-label`이 무시됩니다.

## Property

---

### Size

`size`로 트랙의 높이를 조절합니다.

<Demo name="meter/meter-size">
```json doc-gen:file
{
"file": "./src/components/demo/examples/meter/meter-size.tsx",
"codeblock": true
}
```
</Demo>

### Type

`type`으로 채워진 영역의 색상을 지정합니다. 색만으로는 위험 구간을 알 수 없으므로 레이블에도 함께 적어줍니다.

<Demo name="meter/meter-type">
```json doc-gen:file
{
"file": "./src/components/demo/examples/meter/meter-type.tsx",
"codeblock": true
}
```
</Demo>

### Range

`min`, `max`로 범위를, `format`으로 표시 형식을 지정합니다. 값 텍스트가 단위만 담고 있다면 `getAriaValueText`로 무엇을 재는 값인지 함께 읽히도록 합니다.

<Demo name="meter/meter-range">
```json doc-gen:file
{
"file": "./src/components/demo/examples/meter/meter-range.tsx",
"codeblock": true
}
```
</Demo>

## Examples

---

### Auto Update

값이 스스로 바뀌는 미터에는 갱신을 멈출 수단을 함께 둡니다. 갱신 간격은 초당 3회를 넘기지 않습니다.

<Demo name="meter/meter-auto-update">
```json doc-gen:file
{
"file": "./src/components/demo/examples/meter/meter-auto-update.tsx",
"codeblock": true
}
```
</Demo>

## 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

<ComponentPropsTable componentName="meter-root" />

### Meter.Label

<ComponentPropsTable componentName="meter-label" />

### Meter.Value

<ComponentPropsTable componentName="meter-value" />

### Meter.Track

<ComponentPropsTable componentName="meter-track" />

### Meter.TrackPrimitive

<ComponentPropsTable componentName="meter-track-primitive" />

### Meter.IndicatorPrimitive

<ComponentPropsTable componentName="meter-indicator-primitive" />
1 change: 1 addition & 0 deletions apps/website/content/docs/components/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions apps/website/docs-extractor.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,8 @@ export default defineConfig({
'button/button.tsx': {
include: ['nativeButton'],
},
'meter/meter.tsx': {
include: ['aria-valuetext'],
},
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "IndicatorPrimitive",
"displayName": "Meter.IndicatorPrimitive",
"description": "현재 값에 해당하는 만큼 트랙을 채웁니다. `<div>` 요소로 렌더링됩니다.",
"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` 또는 함수를 인수로 받습니다."
}
]
}
29 changes: 29 additions & 0 deletions apps/website/public/components/generated/meter-label.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "Label",
"displayName": "Meter.Label",
"description": "미터의 이름을 화면에 보여주고 보조기술에 전달합니다. `<span>` 요소로 렌더링됩니다.",
"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` 또는 함수를 인수로 받습니다."
}
]
}
Loading
Loading