Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
52 changes: 50 additions & 2 deletions .claude/rules/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' }]);
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 +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
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