# 메뉴 사이드 바 개선해보기

현재 백오피스는 그나마 많이 레거시 코드를 청산하고 있지만, 아직도 처리해야할 것들이 많음

그 중에 하나가 유저 인터페이스가 정말 몇년 전 구닥다리 인터페이스라는 것인데,

이것들을 한번에 변화하기 보다는 점진적으로 변화해야하겠다고 마음 먹음

(한번에 하기에는 정말 양이 너무 많고, 그러다 비즈니스적인 이슈들을 제때 처리하지 못함)

## 우당탕탕 겁나 불편한 메뉴 구조

그 중에 메뉴가 점점 많아짐에 따라 기능 찾기가 어렵다는 생각이 들었음 (특히 슈퍼 관리자는 모든 메뉴를 볼 수 있음)

아래 사진을 보면 대메뉴들 아래 하위 메뉴들이 정말 많은데, 펼치면 펼칠수록 스크롤로 한땀한땀 찾아야함

![입사하자마자 개선하겠다고 애지중지 야금야금 가꿔오던 백오피스](https://upload.cafenono.com/image/slashpagePost/20260309/123842_JoNVh3oK1MpqYyN95U?q=80&s=1280x180&t=outside&f=webp)

그래서 아래와 같이 메뉴 구조의 사전에 정의해둔 메뉴 별 고유 `key` 값을 통해 자바스크립트로 찾는 로직을 구현해서 추가를 해두었음 (이 기능은 메뉴가 많은 슈퍼 관리자한테만 보이도록 하였고, 일반 사용자들은 안보임)

메뉴를 찾을 때는 사용자가 입력한 키워드에 형광펜 표시로 까막눈도 잘 보일 수 있도록 해놓았음

[Video](https://vz-127031db-d43.b-cdn.net/26f1a0cb-1cfc-4f60-a753-56b161328712/playlist.m3u8)

## 뭔가 3프로 부족한 느낌

5~6년전부터 존재해왔던 사이드바여서, 맨 하단의 사이드바를 접으면 아이콘만 나오는데 이 아이콘에는 툴팁도 없고 어떤 기능으로 쓰여지고 있는지 의문점이 들음.

그래서 과감히 하단의 사이드바 접기(아이콘만 볼 수 있는) 버튼을 없애고, 상단의 사이드바 전체 접기 버튼만 나올 수 있도록 레이아웃을 변경하고자했음.

또 하위 메뉴들에 아이콘이 노출되고 있지 않아 가독성이 떨어지는 부분도 해결을 하고 싶었음, 이 부분은 MDI에서 제공해주는 아이콘들이 있어서 이 아이콘들을 활용하여 작업을 하면 될 듯 함

추가로 나는 `Arc` 브라우저를 사용하는데, `Arc` 브라우저 처럼 `Ctrl + S` 를 누르면 오로지 커맨드로만 사이드바가 접히도록 구현하고 싶었음.

그래서 정리를 하자면 아래와 같은 기능들이 더 개선되어 추가될 예정이었고, 곧바로 추가하고자했음

- 메뉴의 불필요한 버튼 제거 및 기능 개선

- 메뉴 별 메뉴 컨셉에 맞는 아이콘 제공

- `Arc` 브라우저 처럼 사이드바 커맨드 제공

### 단축키 컴포저블 구현하기

소스코드에 단축키를 등록할 수 있는 관련 컴포저블이 존재하지 않아서, 라이브러리를 쓰려다가 그냥 이벤트 등록과 제거만 마운트랑 언마운트 시점에서 해주면 되기 때문에 직접 구현하고자 했음. 다른 서비스에서도 유용하게 사용될 수 있을 것 같아 나도 컴포저블 패키지로 하나 만들까 생각중인데 아래와 같이 구현을 진행했다.

```javascript
import {
  onBeforeUnmount, onMounted, unref, type Ref,
} from 'vue';

/**
* @description 핫키 조합에서 사용하는 수정 키 타입입니다.
*/
type ModifierKey = 'ctrl' | 'meta' | 'alt' | 'shift';

/**
* @description 핫키를 감지할 브라우저 키보드 이벤트 타입입니다.
*/
type HotKeyEventName = 'keydown' | 'keyup';

/**
* @description 핫키 이벤트를 등록할 수 있는 브라우저 타겟 타입입니다.
*/
type HotKeyTarget = Window | Document | HTMLElement;

/**
* @description 일반 값 또는 Vue Ref 값을 모두 받을 수 있도록 하는 유틸 타입입니다.
*/
type MaybeRef<T> = T | Ref<T>;

/**
* @description 핫키 정규화 시 수정 키의 고정 정렬 순서입니다.
*/
const MODIFIER_ORDER: ModifierKey[] = ['ctrl', 'meta', 'alt', 'shift'];

/**
* @description useHotKey 컴포저블의 옵션 타입입니다.
*/
export interface UseHotKeyOptions {
  /** @description 핫키 동작 활성화 여부입니다. */
  enabled?: MaybeRef<boolean>;
  /** @description 감지할 키보드 이벤트 타입입니다. */
  event?: HotKeyEventName;
  /** @description 핫키 매칭 시 브라우저 기본 동작을 막을지 여부입니다. */
  preventDefault?: boolean;
  /** @description 핫키 매칭 시 이벤트 전파를 중단할지 여부입니다. */
  stopPropagation?: boolean;
  /** @description 입력 요소 포커스 상태에서 핫키를 무시할지 여부입니다. */
  ignoreInput?: boolean;
  /** @description 수정 키 포함 여부까지 정확히 매칭할지 여부입니다. */
  exact?: boolean;
  /** @description 이벤트를 등록할 타겟(window/document/element)입니다. */
  target?: MaybeRef<HotKeyTarget | null | undefined>;
}

/**
* @description 전달된 문자열이 수정 키 타입인지 검사합니다.
*/
function isModifierKey(value: string): value is ModifierKey {
  return MODIFIER_ORDER.includes(value as ModifierKey);
}

/**
* @description 키 토큰(키보드 기준)의 별칭을 표준 키 이름으로 정규화합니다.
*/
function normalizeKeyToken(value: string) {
  const token = value.trim().toLowerCase();

  if (token === 'cmd' || token === 'command') return 'meta';
  if (token === 'control') return 'ctrl';
  if (token === 'option') return 'alt';
  if (token === 'esc') return 'escape';
  if (token === 'return') return 'enter';
  if (token === 'spacebar') return 'space';

  return token;
}

/**
* @description 핫키 문자열을 내부 비교용 표준 문자열로 변환합니다.
*/
function normalizeHotKey(value: string) {
  const tokens = value
    .split('+')
    .map(normalizeKeyToken)
    .filter(Boolean);

  const modifiers = new Set<ModifierKey>();
  let key = '';

  tokens.forEach((token) => {
    if (isModifierKey(token)) {
      modifiers.add(token);
      return;
    }

    key = token;
  });

  const modifierText = MODIFIER_ORDER.filter((modifier) => modifiers.has(modifier)).join('+');
  if (!key) return modifierText;
  if (!modifierText) return key;

  return `${modifierText}+${key}`;
}

/**
* @description KeyboardEvent의 key 값을 내부 표준 키 문자열로 변환합니다.
*/
function normalizeEventKey(event: KeyboardEvent) {
  let key = event.key.toLowerCase();

  if (key === ' ') key = 'space';
  if (key === 'arrowup') key = 'up';
  if (key === 'arrowdown') key = 'down';
  if (key === 'arrowleft') key = 'left';
  if (key === 'arrowright') key = 'right';

  key = normalizeKeyToken(key);

  if (isModifierKey(key)) return '';
  return key;
}

/**
* @description 이벤트 타겟이 입력 가능한 요소(input, textarea, select, editor)인지 여부를 확인합니다.
*/
function isEditableTarget(target: EventTarget | null) {
  if (!(target instanceof HTMLElement)) return false;

  const tagName = target.tagName.toLowerCase();
  if (target.isContentEditable) return true;
  if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') return true;

  return Boolean(target.closest('[contenteditable="true"]'));
}

/**
* @description KeyboardEvent를 핫키 비교 문자열로 변환합니다.
*/
function eventToHotKey(event: KeyboardEvent, exact: boolean) {
  const modifiers: ModifierKey[] = [];

  if (event.ctrlKey) modifiers.push('ctrl');
  if (event.metaKey) modifiers.push('meta');
  if (event.altKey) modifiers.push('alt');
  if (event.shiftKey) modifiers.push('shift');

  const key = normalizeEventKey(event);

  if (exact) {
    const modifierText = modifiers.join('+');
    if (!key) return modifierText;
    return modifierText ? `${modifierText}+${key}` : key;
  }

  const normalizedModifierText = MODIFIER_ORDER.filter((modifier) => modifiers.includes(modifier)).join('+');
  if (!key) return normalizedModifierText;
  return normalizedModifierText ? `${normalizedModifierText}+${key}` : key;
}

/**
* @description 이벤트 등록 타겟을 안전하게 해석합니다.
*/
function resolveTarget(target?: MaybeRef<HotKeyTarget | null | undefined>) {
  if (!target) return window;
  return unref(target) ?? window;
}

/**
* @description 전역/특정 타겟 핫키를 등록하고 컴포넌트 생명주기에 맞춰 자동 해제하는 컴포저블입니다.
*/
export default function useHotKey(
  hotKey: string | string[],
  callback: (event: KeyboardEvent) => void,
  options: UseHotKeyOptions = {},
) {
  const {
    enabled = true,
    event = 'keydown',
    preventDefault = false,
    stopPropagation = false,
    ignoreInput = true,
    exact = true,
    target,
  } = options;

  const hotKeySet = new Set((Array.isArray(hotKey) ? hotKey : [hotKey]).map(normalizeHotKey));
  let isBound = false;

  const handler = (keyboardEvent: KeyboardEvent) => {
    if (!unref(enabled)) return;
    if (ignoreInput && isEditableTarget(keyboardEvent.target)) return;

    const pressedHotKey = eventToHotKey(keyboardEvent, exact);
    if (!hotKeySet.has(pressedHotKey)) return;

    if (preventDefault) keyboardEvent.preventDefault();
    if (stopPropagation) keyboardEvent.stopPropagation();

    callback(keyboardEvent);
  };

  const activate = () => {
    if (isBound || typeof window === 'undefined') return;

    const eventTarget = resolveTarget(target);
    eventTarget.addEventListener(event, handler as EventListener);
    isBound = true;
  };

  const deactivate = () => {
    if (!isBound || typeof window === 'undefined') return;

    const eventTarget = resolveTarget(target);
    eventTarget.removeEventListener(event, handler as EventListener);
    isBound = false;
  };

  onMounted(activate);
  onBeforeUnmount(deactivate);

  return {
    activate,
    deactivate,
  };
}
```

```javascript
// Usage
useHotKey(['ctrl+s'], () => {
  ...
}, {
  preventDefault: true,
  stopPropagation: true
})
```

### 메뉴 별 아이콘 제공하기

사실 메뉴 별 아이콘은 `Vuetify` 의 `v-icon` 이 생각보다 유용하게 잘 쓰이고 있어서, `v-icon` 에 유용한 아이콘들을 `[prompt.md](https://prompt.md)` 로 정의해서 코파일럿에게 제공해서 정말 빠르게 해결했다. (초기에 코파일럿을 안쓰고 메뉴를 구성했던 적이 있는데 메뉴 구성과 더불어 아이콘 정의만 4시간 정도가 걸렸음)

## 완성

완전 구닥다리 백오피스에서 점점 예뻐지는 모습을 갖춰나가는 중, 내 자식 같은 프로덕트임 ^.^

![Image](https://upload.cafenono.com/image/slashpagePost/20260309/125458_XDW1lBBlA2usGwuYmI?q=80&s=1280x180&t=outside&f=webp)

[Video](https://vz-127031db-d43.b-cdn.net/1a01cd29-9d5d-4f84-a283-6b1a0ca54cc1/playlist.m3u8)

For the site tree, see the [root Markdown](https://slashpage.com/timmy.md).
