diff --git a/CHANGELOG.md b/CHANGELOG.md index 34331fb86..1a081c699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- `@base-ui/react/slider` 기반의 `Slider` 컴포넌트를 UI 패키지에 추가하고, 해당 컴포넌트에 대한 렌더링 테스트 및 스토리북(`Slider.stories.tsx`) 설정을 반영했습니다. + - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/apps/desktop/src/components/ui/slider.stories.tsx b/apps/desktop/src/components/ui/slider.stories.tsx new file mode 100644 index 000000000..6c514f836 --- /dev/null +++ b/apps/desktop/src/components/ui/slider.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { Slider } from "./slider" + +const meta = { + title: "UI/Slider", + component: Slider, + parameters: { layout: "centered" }, + args: { + defaultValue: 50, + "aria-label": "Volume", + className: "w-[60vw]" + }, +} satisfies Meta + +/** Storybook metadata for the reusable BandScope Slider primitive. */ +export default meta +type Story = StoryObj + +/** Default single-thumb horizontal Slider example with an accessible name. */ +export const Default: Story = {} diff --git a/apps/desktop/src/components/ui/slider.test.tsx b/apps/desktop/src/components/ui/slider.test.tsx new file mode 100644 index 000000000..fcb742d70 --- /dev/null +++ b/apps/desktop/src/components/ui/slider.test.tsx @@ -0,0 +1,51 @@ +import type { ComponentProps } from "react" +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { Slider } from "./slider" + +type SliderProps = ComponentProps + +describe("Slider accessibility contract", () => { + it("exposes one horizontal scalar thumb rather than unsupported range or vertical geometry", () => { + const scalarProps = { + defaultValue: 50, + "aria-label": "Volume", + } satisfies SliderProps + expect(scalarProps.defaultValue).toBe(50) + + // @ts-expect-error BandScope's wrapper intentionally exposes one horizontal scalar thumb. + const rangeProps: SliderProps = { defaultValue: [25, 75], "aria-label": "Range" } + // @ts-expect-error Vertical geometry is not part of the BandScope slider contract. + const verticalProps: SliderProps = { orientation: "vertical", defaultValue: 50 } + expect([rangeProps, verticalProps]).toHaveLength(2) + }) + + it("names the actual range input and anchors the thumb inside the track", () => { + const { container } = render( + + ) + + const input = screen.getByRole("slider", { name: "Volume" }) + expect(input.getAttribute("aria-describedby")).toBe("volume-help") + + const track = container.querySelector('[data-slot="slider-track"]') + const thumb = container.querySelector('[data-slot="slider-thumb"]') + expect(track).toBeTruthy() + expect(thumb).toBeTruthy() + expect(track?.contains(thumb)).toBe(true) + }) + + it("keeps a 24 CSS px pointer target and Base UI disabled/focus state selectors", () => { + const { container } = render( + + ) + const control = container.querySelector('[data-slot="slider-control"]') + const thumb = container.querySelector('[data-slot="slider-thumb"]') + + expect(control?.className).toContain("min-h-6") + expect(thumb?.className).toContain("size-6") + expect(thumb?.className).toContain("data-[disabled]:pointer-events-none") + expect(thumb?.className).toContain("has(input:focus-visible)") + }) +}) diff --git a/apps/desktop/src/components/ui/slider.tsx b/apps/desktop/src/components/ui/slider.tsx new file mode 100644 index 000000000..341710d60 --- /dev/null +++ b/apps/desktop/src/components/ui/slider.tsx @@ -0,0 +1,65 @@ +import * as React from "react" +import { Slider as SliderPrimitive } from "@base-ui/react/slider" + +import { cn } from "@/lib/utils" + +type SliderRootProps = React.ComponentProps +type SliderProps = Omit< + SliderRootProps, + | "defaultValue" + | "value" + | "orientation" + | "aria-label" + | "aria-labelledby" + | "aria-describedby" +> & { + defaultValue?: number + value?: number + "aria-label"?: string + "aria-labelledby"?: string + "aria-describedby"?: string +} + +/** Render BandScope's single-thumb horizontal slider with the accessible name on its range input. */ +function Slider({ + className, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledby, + "aria-describedby": ariaDescribedby, + ...props +}: SliderProps) { + return ( + + + + + + + + + ) +} + +export { Slider } diff --git a/apps/desktop/src/components/ui/ui-added.test.tsx b/apps/desktop/src/components/ui/ui-added.test.tsx index 18a0ab314..8779d072b 100644 --- a/apps/desktop/src/components/ui/ui-added.test.tsx +++ b/apps/desktop/src/components/ui/ui-added.test.tsx @@ -1,6 +1,7 @@ import { render, screen, waitFor } from "@testing-library/react" import { describe, expect, it } from "vitest" +import { Slider } from "./slider" import { Table, TableBody, @@ -246,3 +247,13 @@ describe("added ui primitives (runtime render)", () => { expect(await screen.findByText("분석 준비 완료")).toBeTruthy() }) }) + +describe("Slider component", () => { + it("Slider mounts with track, indicator and thumb", () => { + const { container } = render() + expect(container.querySelector('[data-slot="slider"]')).toBeTruthy() + expect(container.querySelector('[data-slot="slider-track"]')).toBeTruthy() + expect(container.querySelector('[data-slot="slider-indicator"]')).toBeTruthy() + expect(container.querySelector('[data-slot="slider-thumb"]')).toBeTruthy() + }) +})