forked from baron/baron-sso
7.5 KiB
7.5 KiB
UI 버튼 위치 및 정렬 정책 (UI Button Placement Policy)
본 문서는 Baron SSO 프로젝트 내 모든 프론트엔드 애플리케이션(userfront, devfront, adminfront)에서 일관된 사용자 경험(UX)을 제공하기 위한 UI 버튼 배치 및 정렬 가이드라인을 정의합니다. (관련 이슈: #308)
1. 버튼 종류별 위치 (Button Placement by Type)
버튼의 성격에 따라 다음과 같이 배치합니다.
- Primary Action (주요 동작)
- 예시: 저장, 확인, 제출, 생성 등
- 위치: 우측 하단 (Bottom Right) 또는 모달/다이얼로그의 우측 끝에 배치합니다. 사용자의 시선 흐름(좌에서 우, 위에서 아래)에 따라 최종 액션을 우측 하단에서 마무리하도록 유도합니다.
- Secondary Action (보조 동작)
- 예시: 취소, 닫기, 이전으로 등
- 위치: Primary 버튼의 바로 좌측에 배치합니다.
- Destructive Action (파괴적 동작)
- 예시: 삭제, 초기화, 권한 해제 등
- 위치 및 스타일: 붉은색(Red/Destructive) 스타일을 적용하여 시각적으로 명확히 구분합니다. Primary/Secondary 그룹과 물리적으로 분리하거나 (예: 좌측 끝 배치), Secondary 액션 위치에 두되 색상으로 강력한 경고를 줍니다.
2. 정렬 기준 (Alignment Rules)
- 폼(Form) 하단 버튼 그룹
- 기본 정렬: 우측 정렬 (Right-aligned). "취소"는 왼쪽, "저장"은 오른쪽에 위치합니다.
[ 취소 ] [ 저장 ]
- 기본 정렬: 우측 정렬 (Right-aligned). "취소"는 왼쪽, "저장"은 오른쪽에 위치합니다.
- 리스트 아이템 내부 액션 버튼
- 기본 정렬: 리스트/테이블의 각 행(Row) 우측 끝에 배치합니다.
- 버튼 개수가 많을 경우 (3개 이상), 툴팁이나 Dropdown 메뉴(예: 햄버거 버튼 또는 "더보기" 아이콘)로 숨겨 UI 복잡도를 낮춥니다.
3. 반응형 고려 (Responsive Design)
- 모바일 환경 (Mobile / Small Screens)
- 화면 너비가 좁은 모바일 기기(예:
userfront앱 환경,devfront/adminfront의 모바일 뷰)에서는 버튼 그룹을 **Full Width (화면 가득 채움)**로 변경하여 터치 영역을 확보합니다. - 여러 개의 버튼이 있는 경우 세로로 스택(Stack)하며, Primary Action을 맨 위에, Secondary Action을 그 아래에 배치합니다.
- 데스크탑:
[ 취소 ] [ 확인 ] - 모바일:
[ 확인 ] [ 취소 ]
- 화면 너비가 좁은 모바일 기기(예:
4. 로딩 및 피드백 (Loading & Feedback)
- 중복 제출 방지: 폼 전송이나 API 호출을 발생시키는 버튼을 클릭하면 즉각적으로 버튼을 비활성화(Disabled) 상태로 변경하여 다중 클릭을 방지합니다.
- 로딩 스피너: 버튼 내부에 로딩 스피너(Spinner)를 표시하여 사용자에게 진행 상황을 시각적으로 알립니다.
- 스켈레톤 로딩(Skeleton Loading): 화면 진입 시 전체 데이터를 로딩해야 하는 경우, 무의미한 빈 화면(빈 공간) 대신 스켈레톤 UI를 사용하여 로딩 중임을 직관적으로 알리고 체감 대기 시간을 줄입니다.
- 작업 결과 안내: 성공, 실패 등의 결과는 Toast 메시지 (혹은 스낵바)를 통해 화면 하단/상단에 일시적으로 노출하여 사용자가 흐름을 끊지 않고도 인지할 수 있게 돕습니다.
5. 빈 상태 처리 (Empty State)
- 빈 목록 안내: 테이블이나 리스트에 표시할 항목이 없는 경우 단순히 빈 화면으로 두지 않고 중앙 정렬된 아이콘이나 일러스트와 함께 "데이터가 없습니다." 등의 명확한 문구를 표시합니다.
- 콜 투 액션(Call to Action): 데이터가 비어 있는 경우 생성 버튼(Primary Action)을 빈 상태 안내 영역 아래에 배치하여 사용자가 즉시 데이터를 추가할 수 있도록 유도합니다.
6. 오류 표시 (Error Handling)
- 인라인(Inline) 오류: 폼(Form)의 유효성 검사에서 실패한 경우, 각 입력 필드 바로 아래에 붉은색 텍스트로 실패 원인을 명확하게 표시합니다.
- 포커스 이동: 제출 버튼 클릭 시 오류가 있는 첫 번째 입력 필드로 자동 스크롤 하거나 포커스(Focus)를 이동시켜 수정이 용이하게 합니다.
7. 접근성 (Accessibility - a11y)
- 포커스 링(Focus Ring): 키보드를 통해 탐색(Tab)하는 사용자를 위해 버튼, 텍스트 입력창 등에 포커스가 갈 경우 외곽선을 명확히 렌더링(예: 파란색 테두리 등)해야 합니다.
outline: none을 무분별하게 사용하지 않습니다. - 대체 텍스트: 텍스트 없이 아이콘만 존재하는 버튼(예: X 형태의 닫기 버튼)의 경우 반드시
aria-label속성(또는 Flutter의Semantics)을 사용하여 스크린 리더 사용자가 해당 버튼의 역할을 알 수 있게 해야 합니다.
8. 프론트엔드 환경별 구현 가이드 (Implementation Guide)
현재 운영 중인 프론트엔드 환경에 맞춘 구현 가이드라인입니다.
8.1. React 환경 (devfront, adminfront)
Tailwind CSS 기반의 컴포넌트를 사용하여 아래와 같이 구현합니다.
- 버튼 그룹 우측 정렬 (데스크탑):
flex justify-end gap-2 - 반응형 (모바일 세로 배치, 데스크탑 가로 배치):
flex flex-col-reverse sm:flex-row sm:justify-end gap-2(참고:flex-col-reverse를 사용하면 코드 상 먼저 작성된 취소 버튼이 모바일에서는 아래로, 나중에 작성된 확인 버튼이 위로 올라가게 배치할 수 있습니다.) - 코드 예시:
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 mt-4"> <Button variant="outline" onClick={onCancel} disabled={isLoading}>취소</Button> <Button variant="default" onClick={onSave} disabled={isLoading}> {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} 저장 </Button> </div>
8.2. Flutter 환경 (userfront)
Flutter 프레임워크를 사용하는 환경에서는 화면 너비에 따라 위젯 구성을 동적으로 처리해야 합니다.
- 폼 하단 정렬:
Row위젯과MainAxisAlignment.end사용. - 반응형 대응: 화면 너비(MediaQuery)에 따라
Row를 전체 너비를 채우는Column으로 스위칭하거나,OverflowBar위젯 등을 활용할 수 있습니다. - 코드 예시:
// 데스크탑/태블릿용 (우측 정렬) Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton(onPressed: isLoading ? null : onCancel, child: const Text('취소')), const SizedBox(width: 8), ElevatedButton( onPressed: isLoading ? null : onSave, child: isLoading ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('확인') ), ], ) // 모바일용 (전체 너비 세로 배치) Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ElevatedButton( onPressed: isLoading ? null : onSave, child: isLoading ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('확인') ), const SizedBox(height: 8), TextButton(onPressed: isLoading ? null : onCancel, child: const Text('취소')), ], )