콘텐츠로 이동

tabs 탭 —— 내비게이션

탭은 “같은 영역의 콘텐츠 전환기”입니다: 설정 페이지의 “기본 / 보안 / 알림”, 브라우저의 탭. 여러 개의 패널을 같은 공간에 겹쳐 놓고 탭으로 표시/숨김을 전환합니다——세로 공간을 절약하는 대신 동시에 하나의 패널만 볼 수 있습니다. core/tabs가 관리하는 것은 활성 상태 해석, 탭 삭제 후 활성화 전략, roving tabindex 내비게이션입니다.

  • 각 단말에서 <LhTabs> / <LhTabPane> 구현: 설정 페이지, 상세 페이지의 영역 구분, 대시보드 패널 전환.
  • 콘텐츠가 상호 배타적이고 같은 레벨인 영역.
  • 사용하면 안 되는 경우: 흐름형 단계(steps 사용——tabs에는 순서 의미가 없음); 계층 내비게이션(menu 사용); 두 패널을 동시에 대조해야 하는 경우(grid로 나란히); 전환 시 폼 초안을 잃게 되는 경우에는 주의(UI 계층이 keep-alive로 보존 가능).
import {
getTabsClassNames,
resolveActiveName,
getNextActiveName,
getNameAfterRemove,
getTablistAria,
getTabAria,
getTabpanelAria,
} from '@lh-ui/share/core/tabs'
import type {
LhTabsType, LhTabsPosition, LhTabRecord, LhTabNavDirection,
} from '@lh-ui/share/core/tabs'

참값 단축이 아니라 != null: resolveActiveName은 modelValue를 판정할 때 modelValue != null을 사용합니다——탭 이름은 비즈니스 식별자이며 빈 문자열도 합법적인 값이기 때문입니다. 사용자가 name: ''인 탭에 명시적으로 바인딩했을 때, 참값 단축(if (modelValue && ...))은 빈 문자열을 “전달되지 않음”으로 취급해 무시하고 첫 번째 사용 가능한 탭을 활성화해, 원인 파악이 어려운 상태 어긋남을 만듭니다.

활성화 검증에는 disabled 포함: modelValue가 존재하지만 해당 탭이 비활성화되어 있으면 활성화하지 않고, 첫 번째 사용 가능 탭으로 폴백합니다——비활성화 탭은 선택 불가라는 강한 제약으로, 외부에서 강제로 설정하더라도 소용 없습니다.

getNameAfterRemove의 오른쪽 우선: 삭제된 것이 현재 활성 탭일 때, 우선 오른쪽의 가장 가까운 사용 가능 항목을 선택합니다(시선 위치 유지), 오른쪽이 없으면 왼쪽의 가장 가까운 항목을 선택합니다. 이 순서는 “삭제 후 포커스가 자연스럽게 오른쪽으로”라는 직관에 부합합니다. 삭제된 항목이 활성 항목이 아닐 때는 원래 활성 항목을 유지합니다.

getNextActiveName은 @deprecated 래퍼: 내비게이션 알고리즘은 이미 shared/keyboard.getNextListIndex로 수렴했으며, 이 함수는 예전 시그니처(‘next’/‘prev’/‘first’/’last’라는 의미화된 방향 이름)와 “현재값을 알 수 없거나 비활성화되었을 때 첫 항목으로 폴백”이라는 기존 동작을 유지합니다. 새 코드는 커널을 직접 사용하세요.

roving tabindex: 탭 바의 키보드 내비게이션 표준 패턴은 roving tabindex입니다——모든 탭은 tabindex="-1", 활성 탭만 tabindex="0". 방향키가 탭 사이에 포커스를 옮기고, Tab 키가 탭 바에서 패널 콘텐츠로 이동합니다. getNextActiveName은 타깃 탭 이름을 반환하며, UI 계층이 이에 따라 포커스를 옮기고 tabindex를 설정합니다.

양방향 aria 연결: 탭 aria-controls → 패널 id; 패널 aria-labelledby → 활성 탭 id. 양쪽을 모두 적어야 스크린 리더가 어느 쪽 끝에서든 다른 쪽으로 갈 수 있습니다. aria-selected는 활성일 때만 출력합니다(false는 출력하지 않음——비활성은 기본 상태이기 때문).

함수 시그니처 반환 역할
getTabsClassNames (state: { type, position }) => string[] 클래스명 외관 + 위치
resolveActiveName (records, modelValue) => string | null 활성 탭 이름 modelValue 검증 + 폴백
getNextActiveName ⚠️ (records, currentName, direction) => string | null 타깃 탭 이름 키보드 내비게이션
getNameAfterRemove (records, removedName, currentName) => string | null 권장 활성 항목 탭 삭제 후 오른쪽 우선
getTablistAria (id?) => LhAriaAttrs aria 패키지 role=tablist
getTabAria (options: { active, disabled?, panelId?, id? }) => LhAriaAttrs aria 패키지 role=tab
getTabpanelAria (options: { tabId?, id? }) => LhAriaAttrs aria 패키지 role=tabpanel

⚠️ @deprecated 호환 래퍼이며, 새 코드는 shared/keyboard의 getNextListIndex를 사용하세요.

export type LhTabsType = 'line' | 'card' // 밑줄 / 카드 외관
export type LhTabsPosition = 'top' | 'bottom' // 탭 위치
export type LhTabNavDirection = 'next' | 'prev' | 'first' | 'last'
export interface LhTabRecord {
name: string // 유일 식별자(안정적인 키, 생명주기에 따라 변하지 않음)
label: string // 탭 제목
disabled: boolean
}
getTabAria({ active: true, panelId: 'panel-1', id: 'tab-1' })
// { role: 'tab', id: 'tab-1', 'aria-controls': 'panel-1', 'aria-selected': true }
getTabAria({ active: false, disabled: true })
// { role: 'tab', 'aria-disabled': true }(비활성은 aria-selected 출력 안 함)
getTabpanelAria({ tabId: 'tab-1', id: 'panel-1' })
// { role: 'tabpanel', id: 'panel-1', 'aria-labelledby': 'tab-1' }
  • role=tab의 키보드 규약(APG): ←/→로 탭 전환(그리고 활성화는 포커스를 따름——포커스를 옮기면 전환되며, Enter를 또 누를 필요 없음); Home/End로 첫/마지막 탭으로 점프. Tab 키로 탭 바를 떠나 현재 패널에 진입합니다.
  • getNextActiveName의 first/last: direction은 ‘first’ / ’last’를 지원하며 Home/End 키에 대응합니다——직접 경계 판정을 또 작성하지 마세요.
  • 패널에 tabindex 필요: 패널 자체에는 보통 tabindex="0"을 부여해, 키보드 사용자가 Tab으로 패널 콘텐츠에 진입할 수 있게 합니다(APG 권장 사항). 이것은 core의 책임이 아니라 UI 계층이 구현합니다.
  • 패널을 숨기는 올바른 방법: 비활성 패널은 v-if/hidden으로 접근성 트리에서 제거합니다; visibility: hidden 이외의 수단으로 패널이 포커스 가능한 상태로 남게 하지 마세요. 보존이 필요한 경우(폼 초안)에는 display: none + 캐시 렌더링을 사용하세요.
  • aria-selected의 생략 의미: 비활성일 때 이 속성을 출력하지 않습니다(false를 출력하는 것이 아님)——APG는 tab의 aria-selected 기본값을 false로 규정하며, 명시적으로 false를 출력하는 것은 합법적이지만 불필요합니다. getTabAria는 shared/a11y의 거짓값 생략 규약을 따릅니다.
import { resolveActiveName, getNameAfterRemove, getNextActiveName } from '@lh-ui/share/core/tabs'
const records = [
{ name: 'a', label: '기본', disabled: false },
{ name: 'b', label: '보안', disabled: false },
{ name: 'c', label: '알림', disabled: true },
]
resolveActiveName(records, 'b') // 'b'
resolveActiveName(records, 'c') // 'a'(c는 비활성, 첫 사용 가능 항목으로 폴백)
resolveActiveName(records, '') // 'a'(빈 문자열 != null로 검증 분기에 진입하지만 name이 ''인 탭이 없음 → 폴백)
resolveActiveName(records, undefined) // 'a'
resolveActiveName([], undefined) // null(사용 가능 탭 없음)
getNextActiveName(records, 'a', 'next') // 'b'(a에서 next 시 비활성 c를 건너뛰어 b)
getNextActiveName(records, 'b', 'next') // 'a'(a/b만 사용 가능, 순환)
getNextActiveName(records, 'unknown', 'next') // 'a'(알 수 없으면 첫 항목으로 폴백)
getNameAfterRemove(records, 'b', 'b') // 'a'(삭제된 것이 활성 항목 b: 오른쪽에 사용 가능 항목 없음 → 왼쪽 가장 가까운 항목)
getNameAfterRemove(records, 'a', 'b') // 'b'(삭제된 것이 활성 항목이 아님, b 유지)
getNameAfterRemove([{ name: 'a', disabled: true }], 'a', 'a') // null(사용 가능 항목 없음)
// 제어형 활성(UI 계층)
const activeName = ref<string | null>(null)
const records = ref<LhTabRecord[]>([])
function onTabClick(name: string) {
activeName.value = name
}
function onRemove(name: string) {
const next = getNameAfterRemove(records.value, name, activeName.value ?? '')
records.value = records.value.filter((r) => r.name !== name)
activeName.value = next
}
watch(records, () => {
activeName.value = resolveActiveName(records.value, activeName.value)
}, { immediate: true })
<!-- Vue3 -->
<template>
<LhTabs v-model="activeName" type="card">
<LhTabPane name="basic" label="기본 설정">…</LhTabPane>
<LhTabPane name="security" label="보안 설정">…</LhTabPane>
</LhTabs>
</template>
// React: records는 TabPane 자식 컴포넌트가 등록(useEffect + context)하거나 props로 직접 전달;
// 키보드 내비게이션은 onKeyDown에서 getNextActiveName + focus()
// uniapp: <view> 탭 + scroll-view 가로 스크롤; 활성 밑줄은 transform으로 위치 지정
클래스명 의미
.lh-tabs 루트 컨테이너
.lh-tabs--{line|card} 외관
.lh-tabs--{top|bottom} 탭 위치(밑줄/카드 테두리가 이에 따라 반전)
.lh-tabs__header / __nav / __nav-wrap 탭 바 / 내비게이션 / 스크롤 래퍼(스크롤 가능한 탭 바)
.lh-tabs__item / --active / --disabled 탭
.lh-tabs__active-bar 활성 밑줄(line 타입, transform 위치 지정)
.lh-tabs__content / __pane 패널 영역

활성 텍스트 색상 --lh-tabs-active-color, 밑줄도 같은 색상; card 타입은 --lh-border-color 테두리 + 활성 상태 배경 --lh-bg-color로 층위를 높임.

  • 탭 바 스크롤: 탭이 넘쳐나면 좌우 스크롤 버튼이나 스와이프 제스처가 필요합니다——__nav-wrap의 overflow: hidden + __nav의 transform 이동이 흔한 구현이며, 스크롤량은 UI 계층이 계산합니다(core은 관여하지 않음).
  • type: card의 테두리 이어 붙이기: 카드 탭의 활성 상태는 콘텐츠 영역과 겹치는 테두리 한 줄을 제거해야(top 위치에서는 아래쪽 테두리, bottom 위치에서는 위쪽 테두리), 그렇지 않으면 이중 테두리가 나타납니다——이것이 position 클래스명이 존재하는 이유입니다.
  • 동적 탭 추가/삭제: 추가/삭제 후 resolveActiveName을 다시 실행해야 합니다(records 감시). 탭 삭제에는 getNameAfterRemove를 사용해 활성 상태가 뜬 상태가 되지 않게 합니다.
  • 탭 안의 닫기 버튼: closable 탭의 ✕ 버튼은 @click.stop해야(그렇지 않으면 탭 전환도 함께 트리거), aria-label도 필요합니다.
  • keep-alive와 성능: 잦은 전환이 일어나는 무거운 패널(차트, 테이블)은 파괴/재생성보다 보존을 권장합니다——Vue는 <keep-alive>, React는 CSS 숨김 + 언마운트 전략. core은 인지하지 못합니다.
  • getNextActiveName의 예전 시그니처: direction은 커널의 1/-1이 아닌 의미화된 이름(‘next’/‘prev’)을 사용하며, 현재값을 알 수 없을 때 방향과 무관하게 첫 항목으로 폴백합니다(방향을 따라가지 않음)——이것은 예전 동작의 유지이며, 마이그레이션 시 주의하세요.
  • 빈 문자열 탭 이름의 함정: resolveActiveName(records, '')는 records 안에서 name === ''인 탭을 찾습니다——비즈니스에서 빈 문자열을 “미선택”을 나타내는 데 사용하면, 합법적인 빈 문자열 탭 이름과 충돌합니다. “미선택”은 ''가 아니라 null로 나타내세요.
@lh-ui/share/core/tabs → getNameAfterRemove( )
返回值
(填好参数后点运行)

Copyright © 2024-2026 FengMo WenXue