Blog

  • next js 어나운서

    import { useRef } from 'react';
    
    export function useA11yAnnouncer() {
      const timer = useRef<number | null>(null);
    
      const announce = async (message: string) => {
        const style = `border: 0; padding: 0; margin: 0; position: absolute !important;
          height: 1px; width: 1px; overflow: hidden;
          clip: rect(1px 1px 1px 1px); clip: rect(1px, 1px, 1px, 1px); clip-path: inset(50%); white-space: nowrap;`.replaceAll(
          /\n/g,
          ''
        );
        const appendAndAnnounce = async (element: Element, message: string) => {
          return new Promise(resolve => {
            if (!element.querySelector("[name='p_announceForAccessibility']")) {
              const div = document.createElement('div');
              div.setAttribute('name', 'div_announceForAccessibility');
              div.setAttribute('style', style);
              div.innerHTML = '<p aria-live="polite" name="p_announceForAccessibility"></p>';
              element.appendChild(div);
            }
            const pElement = element.querySelector<HTMLParagraphElement>("[name='p_announceForAccessibility']");
    
            if (!pElement) {
              return;
            }
    
            pElement.innerText = '';
            setTimeout(() => {
              pElement.innerText = message;
              resolve(void 0);
            }, 200);
          });
        };
        const announceMessages = async () => {
          if (typeof window !== 'undefined') {
            const bodyElement = document.body;
            const dialogElements = document.body.querySelectorAll<HTMLDialogElement>(
              '[role="dialog"][aria-modal="true"], dialog'
            );
            await appendAndAnnounce(bodyElement, message);
    
            dialogElements.forEach(element => {
              appendAndAnnounce(element, message);
            });
          }
        };
    
        await announceMessages();
    
        timer.current = window.setTimeout(removeAnnounce, 1000);
      };
    
      const removeAnnounce = () => {
        if (typeof window !== 'undefined') {
          const divElements = document.body.querySelectorAll<HTMLDivElement>("[name='div_announceForAccessibility']");
    
          if (!divElements || !timer.current) {
            return;
          }
    
          divElements.forEach(element => {
            element.parentNode?.removeChild(element);
          });
    
          clearTimeout(timer.current);
          timer.current = null;
        }
      };
      return [announce, removeAnnounce] as const;
    }
  • next js 포커스 매니저 컴포넌트

    interface FocusManagerProps {
      isOpen: boolean;
      containerId: string;
      children: React.ReactNode;
    }
    
    const FocusManager = ({ 
      isOpen, 
      containerId, 
      children 
    }: FocusManagerProps) => {
      const previousFocusRef = useRef<HTMLElement | null>(null);
    
      useEffect(() => {
        const container = document.getElementById(containerId);
        
        if (isOpen && container) {
          // 현재 포커스 저장
          previousFocusRef.current = document.activeElement as HTMLElement;
          
          // 포커스 이동 처리
          setTimeout(() => {
            const focusableElement = container.querySelector(
              'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
            ) as HTMLElement;
    
            if (focusableElement) {
              focusableElement.focus();
            } else {
              container.setAttribute('tabindex', '-1');
              container.focus();
            }
          }, 500);
          
        } else if (!isOpen && container) {
          // 이전 포커스로 복귀
          setTimeout(() => {
            if (previousFocusRef.current && previousFocusRef.current !== document.body) {
              previousFocusRef.current.focus();
            }
          }, 500);
        }
      }, [isOpen, containerId]);
    
      return <>{children}</>;
    };
    
    export default FocusManager;
  • next js 바텀 시트 접근성 적용

    interface BottomSheetAccessibilityManagerProps {
      isOpen: boolean;
      bottomSheetId: string;
      children: React.ReactNode;
      initialFocusElementId?: string;
    }
    
    const BottomSheetAccessibilityManager = ({ 
      isOpen, 
      bottomSheetId, 
      children,
      initialFocusElementId 
    }: BottomSheetAccessibilityManagerProps) => {
      const previousFocusRef = useRef<HTMLElement | null>(null);
    
      useEffect(() => {
        const bottomSheet = document.getElementById(bottomSheetId);
        
        if (isOpen && bottomSheet) {
          // 현재 포커스 저장
          previousFocusRef.current = document.activeElement as HTMLElement;
          
          // 형제 요소들 inert 처리
          const siblings = Array.from(bottomSheet.parentElement?.children || [])
            .filter(child => child !== bottomSheet);
          siblings.forEach(sibling => {
            sibling.setAttribute('inert', '');
          });
    
          // 포커스 이동 처리
          setTimeout(() => {
            // 페이지 로딩 시 자동 오픈이거나 이전 포커스가 없는 경우
            if (!previousFocusRef.current || previousFocusRef.current === document.body) {
              if (initialFocusElementId) {
                const initialFocusElement = document.getElementById(initialFocusElementId);
                if (initialFocusElement) {
                  initialFocusElement.focus();
                  return;
                }
              }
            }
    
            // 일반적인 포커스 이동 처리
            const focusableElement = bottomSheet.querySelector(
              'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
            ) as HTMLElement;
    
            if (focusableElement) {
              focusableElement.focus();
            } else {
              bottomSheet.setAttribute('tabindex', '-1');
              bottomSheet.focus();
            }
          }, 500);
          
        } else if (!isOpen && bottomSheet) {
          // inert 속성 제거
          const siblings = Array.from(bottomSheet.parentElement?.children || [])
            .filter(child => child !== bottomSheet);
          siblings.forEach(sibling => {
            sibling.removeAttribute('inert');
          });
    
          // 이전 포커스로 복귀
          // 이전 포커스가 유효한 경우에만 포커스 이동
          setTimeout(() => {
            if (previousFocusRef.current && previousFocusRef.current !== document.body) {
              previousFocusRef.current.focus();
            }
          }, 500);
        }
      }, [isOpen, bottomSheetId, initialFocusElementId]);
    
      return <>{children}</>;
    };
    
    export default BottomSheetAccessibilityManager;
  • UIView 하위의 모든 텍스트를 가져와서 삽입하는 코드

    class AccessibleButtonContainerView: UIView {
        override var accessibilityLabel: String? {
            get {
                let combinedLabels = subviews.compactMap { view -> String? in
                    if let label = view as? UILabel {
                        return label.text
                    }
                    return view.accessibilityLabel
                }.joined(separator: " ")
                
                return combinedLabels.isEmpty ? nil : combinedLabels
            }
            set {
                super.accessibilityLabel = newValue
            }
        }
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            setupAccessibility()
        }
        
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            setupAccessibility()
        }
        
        private func setupAccessibility() {
            isAccessibilityElement = true
            accessibilityTraits = .button
        }
    }