ARIA 속성 적용 패턴

// region + labelledby
<div role="region" aria-labelledby={titleId}>
  <h2 id={titleId}>제목</h2>
</div>

// list + listitem
<div role="list" aria-labelledby="list-title">
  <div role="listitem" aria-labelledby={`${label}-label ${label}-value`}>
    <span id={`${label}-label`}>{label}</span>
    <span id={`${label}-value`}>{value}</span>
  </div>
</div>
  • aria-labelledby - 연결된 요소의 텍스트를 레이블로
  • aria-describedby - 추가 설명이 필요할 때
  • 중복 제거: aria-label과 화면 텍스트 중복 시 aria-labelledby 사용
#324

전화번호 포맷팅

function formatPhoneNumber(phoneNumber) {
  const cleaned = phoneNumber.replace(/\D/g, '')

  if (cleaned.length === 11) {
    return cleaned.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3')
  } else if (cleaned.length === 10 && cleaned.startsWith('02')) {
    return cleaned.replace(/(\d{2})(\d{3})(\d{4})/, '$1-$2-$3')
  } else if (cleaned.length === 10) {
    return cleaned.replace(/(\d{3})(\d{3})(\d{4})/, '$1-$2-$3')
  } else if (cleaned.length === 9 && cleaned.startsWith('02')) {
    return cleaned.replace(/(\d{2})(\d{3})(\d{3})/, '$1-$2-$3')
  }
  return 'Invalid phone number'
}

formatPhoneNumber('01012341234') // 010-1234-1234
formatPhoneNumber('021234567') // 02-123-4567
#323

Map으로 localStorage 래핑

class LocalStorageMap {
  constructor(storageKey) {
    this.storageKey = storageKey
    this.map = this.loadFromStorage()
  }

  loadFromStorage() {
    const data = localStorage.getItem(this.storageKey)
    return data ? new Map(JSON.parse(data)) : new Map()
  }

  saveToStorage() {
    localStorage.setItem(this.storageKey, JSON.stringify([...this.map]))
  }

  set(key, value) {
    this.map.set(key, value)
    this.saveToStorage()
  }

  get(key) {
    return this.map.get(key)
  }

  delete(key) {
    const result = this.map.delete(key)
    this.saveToStorage()
    return result
  }

  clear() {
    this.map.clear()
    localStorage.removeItem(this.storageKey)
  }
}

CAUTION

  • localStorage는 문자열만 저장 → JSON 직렬화 필수
  • 용량 제한 5~10MB 주의
#322
const color =
  (isUnchecked && 'palette.label.alternative') ||
  (isChecked && 'palette.primary.normal') ||
  'palette.label.normal'

중첩 삼항/논리 연산자는 간결하지만 상태가 늘어나면 유지보수 어려움.

핵심: status 모델을 어떻게 정의하고 참조할 것인가?

class StatusManager {
  static Status = {
    UNCHECKED: 'UNCHECKED',
    CHECKED: 'CHECKED',
    DEFAULT: 'DEFAULT',
  } as const

  static getColor(status: keyof typeof this.Status) {
    switch (status) {
      case this.Status.UNCHECKED:
        return 'palette.label.alternative'
      case this.Status.CHECKED:
        return 'palette.primary.normal'
      default:
        return 'palette.label.normal'
    }
  }
}

const color = StatusManager.getColor(StatusManager.Status.CHECKED)

#321

변수의 prefix는 역할과 의미를 명확히 하기 위해 사용된다.

/**
 * 초기값을 나타내며, 상태나 값이 처음 설정될 때 사용된다.
 * 적합한 상황: 프로그램이 시작되거나 객체가 처음 생성될 때의 값을 정의한다.
 */
const initialCount = 0 // 카운터의 초기값
const initialState = { loggedIn: false, user: null } // 초기 상태
const initialPosition = { x: 0, y: 0 } // 초기 좌표

/**
 * 기본값으로 널리 사용되는 표준 값이다.
 * 적합한 상황: 값이 없을 경우 사용할 기본값을 정의한다.
 */
const defaultTheme = 'light' // 기본 테마
const defaultUser = { name: 'Guest', role: 'viewer' } // 기본 사용자
const defaultPageSize = 20 // 페이지당 기본 항목 수

/**
 * 기준값 또는 다른 값의 참조점이 되는 값이다.
 * 적합한 상황: 값을 계산하거나 파생할 때 기준이 되는 값을 정의한다.
 */
const baseSalary = 3000 // 기준 급여
const baseUrl = 'https://api.example.com' // API의 기준 URL
const baseColor = '#FFFFFF' // 기준 색상

/**
 * 변경되기 전 원래 상태 또는 초기 상태를 강조한다.
 * 적합한 상황: 데이터를 변경하기 전에 원래 값을 유지해야 할 때 사용한다.
 */
const originalText = 'Hello World' // 변경 전 텍스트
const originalSettings = { theme: 'dark', notifications: true } // 원래 설정값
const originalImage = image.clone() // 원본 이미지 복사
  • initial* vs default* - initial은 초기 상태, 재설정 가능성 적음. default는 기본값, 교체 가능.
  • base* vs original* - base는 기준값(비교/계산용). original은 원래 상태 보존/복구용.
#320
💡

이 패턴이 “상태 머신”처럼 들린다면, 그리 놀랄 일도 아닙니다. 결국, 선택의 문제는 상태 머신을 구축할지 말지가 아니라, 그것을 암시적으로 구축할지 명시적으로 구축할지에 달려 있습니다.

stateDiagram-v2
  [*] --> Mounting

  Mounting --> AwaitingEmailInput
  Mounting --> AwaitingCodeInput

  AwaitingEmailInput --> SubmittingEmail

  SubmittingEmail --> AwaitingCodeInput
  SubmittingEmail --> AwaitingEmailInput

  AwaitingCodeInput --> SubmittingCode

  SubmittingCode --> Success
  SubmittingCode --> AwaitingCodeInput

  Success --> [*]

#319
💡

Blob URL 방식은 Firefox 58+에서 CSP 제한이 있는 페이지에서도 스크립트를 주입할 수 있는 방법입니다. 이 방식을 사용할 때, 비동기성 때문에 발생할 수 있는 문제를 방지하려면 초기화 코드가 준비된 후 스크립트를 전달해야 합니다.

const b = new Blob([script], { type: 'text/javascript' })
const u = URL.createObjectURL(b)
const s = document.createElement('script')
s.src = u
document.body.appendChild(s)
document.body.removeChild(s)
URL.revokeObjectURL(u)
#318

vitest에서 사용자 지정 assertion을 추가하여 Zod 스키마와 Response 객체를 비교하기

import { expect } from 'vitest'
import type { ZodTypeAny } from 'zod'

expect.extend({
  /**
   * @param received 테스트할 Response 객체
   * @param schema 검증할 Zod 스키마
   */
  async toMatchSchema(received: Response, schema: ZodTypeAny) {
    const response = await received.json()
    const result = await schema.safeParseAsync(response)

    return {
      message: () => '',
      pass: result.success,
    } satisfies ExpectationResult
  },
})

vitest.d.ts 파일에서 CustomMatchers 인터페이스를 확장하여 TypeScript와의 통합성을 유지.

import type { ZodTypeAny } from 'zod'

interface CustomMatchers<R = unknown> {
  toMatchSchema(schema: ZodTypeAny): Promise<R>
}

todoResponse의 응답 데이터가 todoSchema에 정의된 Zod 스키마와 일치하는지 확인한다.

test('todo', async () => {
  expect(todoResponse.ok).toBeTruthy()
  expect(todoResponse).toMatchSchema(todoSchema)
})

#317

button 또는 a 요소를 선택적으로 렌더링할 수 있는 ButtonOrLink 컴포넌트를 구현한 부분. props로 전달된 as 값에 따라 해당 태그를 렌더링하며, React.JSX.IntrinsicElements의 타입을 활용하여 각 태그에 맞는 props를 받을 수 있도록 한다.

import * as React from 'react'

type ComponentPropsWithAs<T extends keyof React.JSX.IntrinsicElements> = {
  as: T
} & React.ComponentProps<T>

type ButtonOrLinkProps =
  | ComponentPropsWithAs<'a'>
  | ComponentPropsWithAs<'button'>

export function ButtonOrLink(props: ButtonOrLinkProps) {
  switch (props.as) {
    case 'a':
      return <a {...props} />
    case 'button':
      return <button {...props} />
    default:
      return null
  }
}
#316

vitest와 jest에서 가짜 타이머를 사용하는 방법. 내부적으로는 @sinonjs/fake-timers를 사용. vitest와 jest 모두 시스템 시간을 조작할 수 있도록 하여 테스트가 일관되게 실행되도록 하고 다양한 시간과 날짜를 시뮬레이션하여 다양한 조건에서 코드가 예상대로 작동하는지 확인할 수 있음.

describe('isSameDate', () => {
  beforeEach(() => {
    vi.useFakeTimers()
    vi.setSystemTime('2024-07-20')
  })

  afterEach(() => {
    vi.useRealTimers()
  })

  it('현재 날짜와 동일한 날짜를 전달하면 true를 반환한다', () => {
    expect(isSameDate('2024-07-20')).toBe(true)
  })

  it('현재 날짜와 다른 날짜를 전달하면 false를 반환한다', () => {
    expect(isSameDate('2024-07-19')).toBe(false)
    expect(isSameDate('2024-07-21')).toBe(false)
  })
})

#315
25 중 10페이지