Skip to content

[전수조사/I] UI kit(@devup-ui/components)·theme 런타임·reset-css #692

Open

Description

@owjs3901

사용자에게 배포되는 런타임 코드의 문제들입니다. 접근성 P0(Select, Toggle, Radio)를 이번에 함께 진행합니다.

전체 계획과 결정 사항은 추적 이슈(#682)에 있습니다. 조사 기준 커밋: 77daad74 (현재 main d0b84255).

발견 항목

심각도: P0 = 흔한 사용에서 조용히 틀리거나 크래시, P1 = 흔한 패턴이 명시적 에러로 막히거나 드물게 조용히 틀림, P2 = 드문 경우·도구/문서 불일치, P3 = 있으면 좋음.

필수 (4)

  • RT-01 (P0, WRONG) Select is not an accessible select/combobox
    • 근거: SelectTrigger only has aria-expanded/aria-label; the popup and options are generic divs with labels (Select/index.tsx:199-215,235-320,369-460). There are no role=combobox/listbox/option, aria-controls, active descendant, roving focus, key handlers, Escape handling, or focus restoration.
    • 수정 방향: Implement the listbox/combobox pattern with stable IDs, proper roles/states, active option management, arrows/Home/End/typeahead/Escape, and focus restoration.
  • RT-02 (P0, WRONG) Toggle is a mouse-only div, not a switch
    • 근거: Toggle/index.tsx:55-127 renders a clickable Box with only aria-disabled; it has no switch/checkbox role, aria-checked, tab stop, keyboard handler, visible focus state, form name, or disabled hidden input. The hidden input contains only a string value.
    • 수정 방향: Prefer a native checkbox input styled as a switch, forwarding input/form props; otherwise implement the complete switch pattern.
  • RT-03 (P0, WRONG) Radio button mode removes radios from keyboard/focus navigation
    • 근거: button-variant Radio uses display="none" on the native radio (Radio/index.tsx:70-81); RadioGroup supplies no shared name and no radiogroup role (RadioGroup/index.tsx:67-102). Default Radio's only focus rule compiles as invalid outline:1px sold ... (Radio/index.tsx:83-86; probe-outputs/Radio.txt contains sold).
    • 수정 방향: Visually hide rather than display:none, give each group a stable shared name/legend or ARIA group label, and fix solid/:focus-visible styling.
  • RT-04 (P0, WRONG) RadioGroup loses false/0 values and lies about callback types
    • 근거: controlled/default initialization uses truthiness (value ? String(value) : defaultValue ? ..., lines 57-60), so false, 0, and '' become unselected. handleChange always calls onChange(_value) with a string although its public type promises string | number | boolean (lines 7-18,62-65); tests explicitly expect '2' and 'false'.
    • 수정 방향: Track the original typed value, use value !== undefined, and make RadioGroup generic so options, value, and onChange share one type.

권장 (15)

  • RT-07 (P1, WRONG) Select controlled-open close paths silently bypass onOpenChange
    • 근거: outside click invokes raw setOpen(false) (Select/index.tsx:83-90) rather than handleOpenChange; when openProp is supplied, internal state changes are ignored, so the controlled popup cannot close and the owner is not notified.
    • 수정 방향: Route all open transitions through one setter and only mutate internal state when uncontrolled.
  • RT-08 (P1, WRONG) Select popup positioning can remain off-screen and leaks listeners on React 18
    • 근거: overflow is compared against document dimensions despite position:fixed (lines 251-257), and switching branches sets top or bottom without clearing the other (259-265). Cleanup is returned from a ref callback (236-280), which React only supports in React 19.
    • 수정 방향: Use an effect/layout-effect or positioning primitive, compare against innerWidth/innerHeight, clear opposite inset properties, and add collision tests.
  • RT-09 (P1, WRONG) SelectTrigger asChild suppresses opening when the child has onClick and drops ref composition
    • 근거: line 193 selects children.props.onClick ?? handleClick; a child handler therefore replaces, rather than composes with, select opening. The cloned element receives no merged trigger/child ref and ignores wrapper props.
    • 수정 방향: Clone the element with composed handlers/ref and deterministic prop precedence; honor prevented events.
  • RT-10 (P1, WRONG) Stepper freezes in callback-only uncontrolled usage
    • 근거: handleChange returns immediately whenever onValueChange exists (Stepper/index.tsx:51-58), even if no value prop is supplied. The displayed internal value never changes.
    • 수정 방향: Always notify; update internal state when valueProp === undefined.
  • RT-11 (P1, WRONG) Stepper safeguards can be overwritten and text mode emits input-only DOM props
    • 근거: consumer props spread after internal disabled, onClick, value, and onChange (lines 83-93,109-121,149-178), allowing bounds/state logic to be replaced. Text mode renders a div with onChange, readOnly, type="number", and value attributes and no value semantics.
    • 수정 방향: Compose handlers, put invariant props after spreads, and render a separate semantic text output path.
  • RT-12 (P1, WRONG) Forced generic aria-labels override visible/external labels
    • 근거: Button always defaults to aria-label="button", Input to "input", and Textarea to "textarea" (Button:161-163, Input:123, Textarea:70-72). Accessible names therefore ignore visible button text or associated <label> unless users manually override.
    • 수정 방향: Remove generic labels; expose optional user labels and keep icon-only defaults explicit at call sites.
  • RT-13 (P1, WRONG) Input clear emits a fabricated, incomplete ChangeEvent
    • 근거: Input/index.tsx:64-69 casts {target:{value:''}} to React.ChangeEvent; currentTarget, name/type, nativeEvent, preventDefault, and other standard event fields are absent. Controlled inputs also only clear if the parent synchronously accepts this fake event.
    • 수정 방향: Do not fabricate React events; use onClear/onValueChange, or dispatch a real input event after updating the DOM through a ref.
  • RT-14 (P1, WRONG) Input/Textarea errors are not associated with their controls
    • 근거: Input never sets aria-invalid; neither Input nor Textarea gives the error message an ID or applies aria-describedby/aria-errormessage to the control (Input:157-171, Textarea:70-124).
    • 수정 방향: Generate/accept message IDs, set aria-invalid, and merge user descriptions with aria-describedby or aria-errormessage.
  • RT-15 (P1, WRONG) Runtime color customization is scoped to the wrong DOM node
    • 근거: Input places all variables on the <input> (lines 140-150), but icon, clear button, and error text are siblings and cannot inherit them; CSS asks for unmatched names such as --inputBg, --inputDisabledText, --primaryBg, and --inputPlaceholder. Textarea's error sibling cannot see --error; Select declares --inputBackground but popup CSS reads --inputBg. Probes show…
    • 수정 방향: Put shared variables on the root container and align every public color key with the CSS custom-property name; add computed-style tests.
  • RT-16 (P1, WRONG) Loading Button remains actionable and does not announce busy state
    • 근거: loading only swaps in IconSpinner (Button/index.tsx:231-246); it does not disable the button, block duplicate clicks, set aria-busy, or provide loading status text.
    • 수정 방향: Add configurable disable-while-loading behavior, aria-busy, and an accessible loading label/status.
  • RT-17 (P1, SEC) ThemeScript cannot run under nonce-based CSP and can abort before setting a theme
    • 근거: ThemeScript props expose only auto and theme, and render a nonce-less inline script (ThemeScript.tsx:4-18). Its generated code calls localStorage.getItem and matchMedia without a guard; storage security errors abort initialization and produce FOUC.
    • 수정 방향: Accept/pass standard script props including nonce; wrap storage/media access with safe fallbacks while setting a deterministic default.
  • RT-18 (P1, CRASH) Theme helpers are browser-global APIs despite the RSC/SSR surface
    • 근거: getTheme, setTheme, and initTheme unconditionally access document, localStorage, and/or window; direct invocation during SSR throws. 'use client' is a bundler boundary, not a runtime guard.
    • 수정 방향: Guard browser globals and document server behavior, or separate browser-only exports so SSR imports cannot call them.
  • RT-27 (P1, WRONG) reset-css omits the clipping half of its accessible-hidden rule
    • 근거: source and probe output contain only position:absolute for :where([aria-hidden=false i][hidden]:not(:focus)) (reset-css/src/index.ts:140-142). Canonical sanitize.css also requires clip:rect(0,0,0,0), so revealed-for-AT hidden content can be visibly painted.
    • 수정 방향: Add the missing clip declaration and a full emitted-CSS parity test against the intended sanitize.css version.
  • RT-28 (P1, TEST) Tests achieve line coverage without covering user-facing contracts
    • 근거: bun test packages/components packages/react passes 194 tests/88 snapshots and reports component/runtime lines covered, but there are no React-18 runs, ref tests, keyboard/focus navigation tests, role/name/description assertions for Select/Toggle/RadioGroup, CSP/storage-failure tests, multi-tab/system-theme tests, ThemeProvider table/nesting/function-theme tests, Proxy enumera…
    • 수정 방향: Add a concern matrix and browser behavioral tests, plus React 18/19 compatibility jobs if the peer remains *.
  • RT-19 (P2, MISMATCH) Theme does not follow system changes or synchronize across tabs
    • 근거: initialization reads matchMedia and storage once; the store only observes data-theme mutations in the current document. There are no storage or media-query change listeners (theme-store.ts:8-42).
    • 수정 방향: Subscribe to matchMedia().change when in auto mode and window.storage; define explicit-vs-auto persistence semantics.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions