diff --git a/src/components/atoms/time/Time.stories.tsx b/src/components/atoms/time/Time.stories.tsx new file mode 100644 index 00000000..7c42ede7 --- /dev/null +++ b/src/components/atoms/time/Time.stories.tsx @@ -0,0 +1,179 @@ +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; +import Time from './Time'; + +const meta: Meta = { + title: 'Atoms/Time', + component: Time, + parameters: { + docs: { + autodocs: true, + description: { + component: + 'A time input component with individually editable segments (hour, minute, optional second). ' + + 'Segments are keyboard-navigable and support arrow key increment/decrement. ' + + 'Includes stepper buttons for pointer-based control. ' + + 'Supports 12h/24h formats, all Input variants, hint states, and rounded style.' + } + } + }, + tags: ['autodocs'] +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + id: 'time-default', + label: 'Time', + onChange: action('time-change') + } +}; + +export const Disabled: Story = { + args: { + ...Default.args, + id: 'time-disabled', + disabled: true + } +}; + +export const Required: Story = { + args: { + ...Default.args, + id: 'time-required', + isRequired: true + } +}; + +export const WithHintInfo: Story = { + args: { + ...Default.args, + id: 'time-hint-info', + hint: { type: 'info', message: 'Enter the start time for the event' } + } +}; + +export const WithHintError: Story = { + args: { + ...Default.args, + id: 'time-hint-error', + hint: { type: 'error', message: 'Please enter a valid time' } + } +}; + +export const WithHintWarning: Story = { + args: { + ...Default.args, + id: 'time-hint-warning', + hint: { type: 'warning', message: 'This time slot may conflict with another event' } + } +}; + +export const WithHintSuccess: Story = { + args: { + ...Default.args, + id: 'time-hint-success', + hint: { type: 'success', message: 'Time slot is available' } + } +}; + +export const BorderedVariant: Story = { + args: { + ...Default.args, + id: 'time-bordered', + variant: 'bordered' + } +}; + +export const UnderlinedVariant: Story = { + args: { + ...Default.args, + id: 'time-underlined', + variant: 'underlined' + } +}; + +export const LineVariant: Story = { + args: { + ...Default.args, + id: 'time-line', + variant: 'line' + } +}; + +export const Rounded: Story = { + args: { + ...Default.args, + id: 'time-rounded', + rounded: true + } +}; + +export const Sizes: Story = { + render: () => ( +
+
+ ) +}; + +export const WithSeconds: Story = { + args: { + ...Default.args, + id: 'time-seconds', + granularity: 'second', + hint: { type: 'info', message: 'Includes hour, minute, and second segments' } + } +}; + +export const TwelveHourFormat: Story = { + args: { + ...Default.args, + id: 'time-12h', + hourCycle: 12, + hint: { type: 'info', message: '12-hour format with AM/PM segment' } + } +}; + +export const WithClockIcon: Story = { + args: { + ...Default.args, + id: 'time-clock-icon', + showClockIcon: true + } +}; + +export const FullWidth: Story = { + args: { + ...Default.args, + id: 'time-fullwidth', + isFullWidth: true + } +}; + +export const WithSteppers: Story = { + args: { + ...Default.args, + id: 'time-steppers', + showSteppers: true, + hint: { type: 'info', message: 'Use the arrows or keyboard to adjust the time' } + } +}; + +export const CombinedStates: Story = { + args: { + ...Default.args, + id: 'time-combined', + label: 'Meeting Time', + variant: 'bordered', + size: 'md', + isRequired: true, + showSteppers: true, + showClockIcon: true + } +}; diff --git a/src/components/atoms/time/Time.test.tsx b/src/components/atoms/time/Time.test.tsx new file mode 100644 index 00000000..aaf6e05e --- /dev/null +++ b/src/components/atoms/time/Time.test.tsx @@ -0,0 +1,212 @@ +/** + * Time.test.tsx — Tests for Stack-and-Flow Design System Time component + * + * STRATEGY: + * - Hook (useTime): tested with renderHook → pure logic, no DOM rendering + * - Component (Time): tested with render + screen + userEvent → observable behavior + * + * WHAT we test: segment state, navigation, ARIA attrs, disabled states, keyboard behavior, hint system + * WHAT we do NOT test: specific CSS class strings, internal refs + */ + +import { render, renderHook, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +// --- Mocks (declared before component import) --- + +vi.mock('lucide-react/dynamic', () => ({ + // biome-ignore lint/style/useNamingConvention: must match library export name + DynamicIcon: () => null +})); + +vi.mock('spinners-react', () => ({ + // biome-ignore lint/style/useNamingConvention: must match library export name + SpinnerCircular: () => null +})); + +// --- Imports after mocks --- + +import Time from './Time'; +import { useTime } from './useTime'; + +// ───────────────────────────────────────────── +// HOOK TESTS — useTime +// ───────────────────────────────────────────── + +describe('useTime — logic', () => { + it('returns disabled: false by default', () => { + const { result } = renderHook(() => useTime({ id: 'test-time' })); + expect(result.current.disabled).toBe(false); + }); + + it('returns isInvalid: false by default', () => { + const { result } = renderHook(() => useTime({ id: 'test-time' })); + expect(result.current.isInvalid).toBe(false); + }); + + it('returns isInvalid: true when hint type is error', () => { + const { result } = renderHook(() => useTime({ id: 'test-time', hint: { type: 'error', message: 'Error' } })); + expect(result.current.isInvalid).toBe(true); + }); + + it('returns disabled: true when disabled prop is true', () => { + const { result } = renderHook(() => useTime({ id: 'test-time', disabled: true })); + expect(result.current.disabled).toBe(true); + }); + + it('returns default granularity as minute', () => { + const { result } = renderHook(() => useTime({ id: 'test-time' })); + expect(result.current.granularity).toBe('minute'); + }); + + it('returns default hourCycle as 24', () => { + const { result } = renderHook(() => useTime({ id: 'test-time' })); + expect(result.current.hourCycle).toBe(24); + }); + + it('passes id correctly to return value', () => { + const { result } = renderHook(() => useTime({ id: 'appointment-time' })); + expect(result.current.id).toBe('appointment-time'); + }); + + it('returns segments with empty values by default', () => { + const { result } = renderHook(() => useTime({ id: 'test-time' })); + expect(result.current.segments.hour).toBe(''); + expect(result.current.segments.minute).toBe(''); + }); + + it('returns hasHint: false when no hint is provided', () => { + const { result } = renderHook(() => useTime({ id: 'test-time' })); + expect(result.current.hasHint).toBe(false); + }); + + it('returns hasHint: true when hint is provided', () => { + const { result } = renderHook(() => useTime({ id: 'test-time', hint: { type: 'info', message: 'Enter time' } })); + expect(result.current.hasHint).toBe(true); + }); +}); + +// ───────────────────────────────────────────── +// COMPONENT TESTS — Time +// ───────────────────────────────────────────── + +describe('Time — component behavior', () => { + it('renders a time input group in the DOM', () => { + render(