@yeongseoksong/framework 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,7 +11,7 @@ pnpm add @yeongseoksong/framework
11
11
  피어 의존성 설치:
12
12
 
13
13
  ```bash
14
- pnpm add @mantine/core @mantine/hooks @mantine/carousel react react-dom next
14
+ pnpm add @mantine/core @mantine/hooks @mantine/carousel @mantine/notifications @mantine/dates dayjs zustand react react-dom next
15
15
  ```
16
16
 
17
17
  ## 설정
@@ -22,20 +22,27 @@ pnpm add @mantine/core @mantine/hooks @mantine/carousel react react-dom next
22
22
  // app/layout.tsx
23
23
  import '@mantine/core/styles.css'
24
24
  import '@mantine/carousel/styles.css'
25
+ import '@mantine/notifications/styles.css'
26
+ import '@mantine/dates/styles.css'
25
27
  import { MantineProvider } from '@mantine/core'
26
- import { theme } from '@yeongseoksong/framework/ui'
28
+ import { theme, SdToastProvider } from '@yeongseoksong/framework/ui'
27
29
 
28
30
  export default function RootLayout({ children }: { children: React.ReactNode }) {
29
31
  return (
30
32
  <html lang="ko">
31
33
  <body>
32
- <MantineProvider theme={theme}>{children}</MantineProvider>
34
+ <MantineProvider theme={theme}>
35
+ <SdToastProvider />
36
+ {children}
37
+ </MantineProvider>
33
38
  </body>
34
39
  </html>
35
40
  )
36
41
  }
37
42
  ```
38
43
 
44
+ `SdToastProvider`는 토스트가 실제로 그려지는 자리입니다. 넣지 않으면 `SdToast.*` 호출이 조용히 아무 일도 하지 않습니다(아래 SdToast 항목 참고).
45
+
39
46
  ### 2. 환경변수 설정
40
47
 
41
48
  소비자별 상수는 환경변수로 주입합니다. 앱 루트에 `.env.local`을 만드세요.
@@ -108,7 +115,8 @@ import { appTheme } from './theme'
108
115
  | `secondary` | 보조 강조 |
109
116
  | `slate` | 중립 전반 — 모든 `SdText`/`SdTitle` 본문색, 보더, 표 헤더. `dark` 별칭 |
110
117
  | `red` | `SdText.Error`, `SdButton.Delete` |
111
- | `green` | `SdButton.Excel` |
118
+ | `green` | `SdButton.Excel`, `SdToast.Success`, `SdResult.Success` |
119
+ | `amber` | `SdBadge.Warning`, `SdToast.Warning` |
112
120
 
113
121
  10단계 램프를 손으로 만들기 번거로우면 [`@mantine/colors-generator`](https://mantine.dev/colors-generator/)의 `generateColors('#0b5ed7')`로 hex 하나에서 뽑을 수 있습니다(별도 설치 필요).
114
122
 
@@ -117,7 +125,8 @@ import { appTheme } from './theme'
117
125
  | 경로 | 내용 |
118
126
  | -------------------------------- | --------------------------------------------- |
119
127
  | `@yeongseoksong/framework/ui` | UI 컴포넌트 전체 + `theme` (`"use client"`) |
120
- | `@yeongseoksong/framework/util` | `t()`, `COMPANY_NAME`, `LOGO_SRC`, `LOGO_ALT` |
128
+ | `@yeongseoksong/framework/store` | Zustand 스토어 — `useAuthStore` · `useUiStore` · `useSdForm` (`"use client"`) |
129
+ | `@yeongseoksong/framework/util` | `t()`, 한글 조사(`josa` · `withJosa` · `fixJosa`), `runFinalizers`, `filterAndSort`, `COMPANY_NAME`, `LOGO_SRC`, `LOGO_ALT` |
121
130
  | `@yeongseoksong/framework/types` | 공유 인터페이스 |
122
131
 
123
132
  ---
@@ -145,7 +154,7 @@ import { SdTextBody } from '@yeongseoksong/framework/ui'
145
154
  | `SdTitle` | `SdTitleDisplay` `SdTitleSection` `SdTitleCard` `SdTitleSub` |
146
155
  | `SdButton` | `SdButtonPrimary` `SdButtonSecondary` `SdButtonOutline` `SdButtonGhost` `SdButtonWhite` `SdButtonSubmit` `SdButtonDelete` `SdButtonCancel` `SdButtonExcel` `SdButtonDownload` |
147
156
  | `SdBadge` | `SdBadgeDefault` `SdBadgePrimary` `SdBadgeSuccess` `SdBadgeWarning` |
148
- | `SdInput` | `SdInputText` `SdInputEmail` `SdInputPassword` `SdInputTextarea` `SdInputSelect` |
157
+ | `SdInput` | `SdInputText` `SdInputEmail` `SdInputPassword` `SdInputTextarea` `SdInputJson` `SdInputNumber` `SdInputSlider` `SdInputRating` `SdInputPinCode` `SdInputSelect` `SdInputNativeSelect` `SdInputMultiSelect` `SdInputAutocomplete` `SdInputTags` `SdInputRadioGroup` `SdInputSegmented` `SdInputCheckbox` `SdInputSwitch` `SdInputFile` `SdInputColor` `SdInputDate` `SdInputDateRange` `SdInputTime` |
149
158
  | `SdLink` | `SdLinkStrong` `SdLinkBody` `SdLinkSub` `SdLinkHint` |
150
159
  | `SdQuote` | `SdQuotePlain` `SdQuoteCard` |
151
160
  | `SdTable` | `SdTableSpec` |
@@ -162,6 +171,10 @@ import { SdTextBody } from '@yeongseoksong/framework/ui'
162
171
  | `SdClients` | `SdClientsGrid` `SdClientsMarquee` |
163
172
  | `SdMap` | `SdMapSingle` `SdMapTabs` |
164
173
  | `SdErrorView` | `SdErrorViewPage` `SdErrorViewNotFound` |
174
+ | `SdLoginView` | `SdLoginViewCard` `SdLoginViewSplit` |
175
+ | `SdResult` | `SdResultSuccess` `SdResultError` |
176
+ | `SdToast` | `SdToastSuccess` `SdToastError` `SdToastWarning` `SdToastInfo` `SdToastLoading` `SdToastUpdate` `SdToastHide` `SdToastClean` |
177
+ | `SdHeader` | `SdHeaderMega` `SdHeaderSimple` (`SdHeader` 자체는 `Mega`와 동일) |
165
178
 
166
179
  **클라이언트 컴포넌트에서는 네임스페이스 형태(`SdText.Body`)를 그대로 써도 됩니다.** `SdModal`은 `opened`/`onClose` 상태가 필요해 애초에 클라이언트 전용이므로 flat export가 없습니다.
167
180
 
@@ -470,9 +483,241 @@ const policyLinks: NavItem[] = [
470
483
  `parentId`로 묶인 하위 링크가 각 상위 항목 **바로 아래 컬럼**으로 동시에 노출됩니다. 하위 항목이 하나도 없으면 확장이 일어나지 않습니다.
471
484
  상위 항목의 `href`를 비우면 링크 대신 그룹 제목으로 렌더됩니다. 모바일(`< sm`)에서는 버거 드로어의 중첩 아코디언으로 전환됩니다.
472
485
 
486
+ 헤더 바 높이를 60px로 고정하고 싶다면 `SdHeader.Simple`(서버 컴포넌트에서는 `SdHeaderSimple`)을 씁니다.
487
+ 하위 항목을 가진 상위 항목마다 Mantine `Menu`가 하나씩 붙어 개별 드롭다운으로 열리며(마우스 hover · 클릭 · 키보드 모두 지원),
488
+ 드롭다운은 포털로 렌더되므로 `overflow: hidden` 컨테이너 안에서도 잘리지 않습니다. 모바일 드로어는 기본 변형과 동일합니다.
489
+
490
+ ```tsx
491
+ <SdHeader.Simple navItems={navItems} loginFlag />
492
+ ```
493
+
473
494
  `navItems`는 같은 `parentId` 구조로 푸터 링크 컬럼도 만들고, 구분선 아래 하단 바에 카피라이트 · `policyLinks` · `company.socials` 아이콘이 놓입니다.
474
495
  `socials.platform`은 `x | youtube | instagram | facebook | linkedin | github | blog`를 지원합니다.
475
496
 
497
+ ### SdLoginView
498
+
499
+ 로그인 화면 전체를 담당하는 organism입니다. `Card`(중앙 정렬 카드, 기본)와 `Split`(좌측 브랜드 패널 + 우측 폼) 두 변형이 있습니다.
500
+
501
+ ```tsx
502
+ 'use client'
503
+ import { SdLoginView } from '@yeongseoksong/framework/ui'
504
+
505
+ export default function LoginPage() {
506
+ return (
507
+ <SdLoginView.Card
508
+ findPasswordHref="/find-password"
509
+ signUpHref="/signup"
510
+ socials={[
511
+ { provider: 'google', onClick: () => signIn('google') },
512
+ { provider: 'kakao', onClick: () => signIn('kakao') },
513
+ ]}
514
+ onSubmit={({ email, password, remember }) => login(email, password, remember)}
515
+ />
516
+ )
517
+ }
518
+ ```
519
+
520
+ 폼은 **비제어**(`FormData` 기반)입니다 — `@mantine/form`을 의존성으로 들이지 않고도 `onSubmit`이 `{ email, password, remember }`를 그대로 넘겨주므로, 인증 호출과 검증은 소비자가 담당합니다.
521
+ `loading`으로 제출 버튼의 로딩 상태를, `error`로 폼 상단 오류 메시지를 제어합니다. `withRemember={false}`면 자동 로그인 체크박스가 사라지고, `findPasswordHref`/`signUpHref`/`socials`는 넘기지 않으면 해당 영역(구분선 포함)이 렌더되지 않습니다. 폼 아래 약관 안내 같은 추가 내용은 `children`으로 넣습니다.
522
+
523
+ `socials[].provider`는 `google | kakao | naver | apple | github`을 지원하며, 아이콘과 기본 라벨("구글로 로그인" 등)이 함께 고정됩니다. `label`로 라벨만 덮어쓸 수 있습니다.
524
+
525
+ `Split`은 `brandTitle`/`brandDescription`으로 좌측 패널 문구를 받고(기본값 `%c`), 브랜드 면은 `PageLayout.Brand` 히어로와 같은 배경(`ui/surface.ts`)을 씁니다. 좌측 패널은 `md` 미만에서 숨겨져 폼만 남습니다.
526
+ 전체 화면이 기본(`mih="100svh"`)이므로, 좁은 영역에 끼워 넣을 때만 `mih`를 줄입니다.
527
+
528
+ ### 상태 관리 — useAuthStore / useUiStore
529
+
530
+ 전역 클라이언트 상태는 **Zustand** 스토어로 **`@yeongseoksong/framework/store`** 경로에서 제공됩니다. Provider가 없으므로 어디서든 훅으로 바로 읽고 씁니다.
531
+
532
+ ```tsx
533
+ 'use client'
534
+ import { useAuthStore, useAuthHydrated } from '@yeongseoksong/framework/store'
535
+
536
+ function UserMenu() {
537
+ const hydrated = useAuthHydrated()
538
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
539
+ const user = useAuthStore((s) => s.user)
540
+ const logout = useAuthStore((s) => s.logout)
541
+
542
+ // 복원 전에는 로그아웃 상태로 그린다 — 아래 설명 참고
543
+ if (!hydrated || !isAuthenticated) return <LoginButton />
544
+ return <button onClick={logout}>{user?.name} 로그아웃</button>
545
+ }
546
+ ```
547
+
548
+ 로그인 성공 시 `login()`에 사용자 정보를 넣습니다. `SdLoginView`의 `onSubmit`과 바로 이어집니다.
549
+
550
+ ```tsx
551
+ <SdLoginView.Card
552
+ onSubmit={async ({ email, password }) => {
553
+ const user = await api.login(email, password) // 토큰은 httpOnly 쿠키로
554
+ useAuthStore.getState().login({ id: user.id, email: user.email, name: user.name })
555
+ SdToast.Success('로그인했습니다.')
556
+ }}
557
+ />
558
+ ```
559
+
560
+ > **액세스 토큰을 스토어에 넣지 마세요.** `partialize`가 사용자 프로필만 localStorage(`sd-auth` 키)에 저장하도록 막아 두었습니다. 토큰을 localStorage에 담으면 XSS 한 번에 그대로 노출됩니다 — 세션 토큰은 httpOnly 쿠키가 맡습니다.
561
+
562
+ **`useAuthHydrated()`가 필요한 이유**: 저장된 세션 복원을 이펙트로 미룹니다(`skipHydration: true`). 자동 복원은 모듈 평가 시점에 일어나 첫 클라이언트 렌더가 이미 로그인 상태가 되는데, 서버가 만든 HTML은 항상 로그아웃 상태라 하이드레이션이 어긋납니다. 이 훅이 `false`를 주는 동안에는 **로그아웃 상태로** 그리세요. 복원은 훅을 여러 곳에서 써도 앱당 한 번만 실행됩니다.
563
+
564
+ `useUiStore`는 화면 간 공유가 필요한 UI 상태를 담습니다 — `globalLoading`(전역 로딩 오버레이)과 `sideNavOpened`(사이드 내비) + 토글 액션.
565
+
566
+ ```tsx
567
+ const globalLoading = useUiStore((s) => s.globalLoading)
568
+ const setGlobalLoading = useUiStore((s) => s.setGlobalLoading)
569
+ ```
570
+
571
+ `SdHeader`의 모바일 드로어는 일부러 이 스토어를 쓰지 않습니다 — 한 페이지에 헤더가 둘 이상 있으면 드로어가 함께 열리므로 인스턴스 로컬 상태로 남겨 두었습니다.
572
+
573
+ ### 폼 — useSdForm
574
+
575
+ 모든 폼이 같은 방식으로 값·검증·제출을 다루도록 훅 하나로 통일했습니다.
576
+
577
+ ```tsx
578
+ 'use client'
579
+ import { useSdForm, formRules } from '@yeongseoksong/framework/store'
580
+ import { SdInput, SdButton } from '@yeongseoksong/framework/ui'
581
+
582
+ function ContactForm() {
583
+ const form = useSdForm({
584
+ id: 'contact', // 스토어에서 이 폼이 쓸 칸
585
+ initialValues: { name: '', email: '', agree: false },
586
+ rules: {
587
+ name: formRules.required(),
588
+ email: formRules.email(),
589
+ agree: formRules.checked('개인정보 수집에 동의해야 합니다.'),
590
+ },
591
+ successMessage: '문의를 접수했습니다.',
592
+ resetOnSuccess: true,
593
+ onSubmit: async (values) => {
594
+ await api.contact(values) // 예외를 던지면 실패 경로로 간다
595
+ },
596
+ })
597
+
598
+ return (
599
+ <form onSubmit={form.onSubmit}>
600
+ <SdInput.Text label="이름" {...form.getInputProps('name')} />
601
+ <SdInput.Email label="이메일" {...form.getInputProps('email')} />
602
+ <SdInput.Select label="유형" data={['도입', '지원']} {...form.getInputProps('type')} />
603
+ <SdInput.Date label="희망일" {...form.getInputProps('startDate')} />
604
+ <Checkbox label="동의합니다" {...form.getInputProps('agree', { type: 'checkbox' })} />
605
+ <SdButton.Submit type="submit" loading={form.submitting} />
606
+ </form>
607
+ )
608
+ }
609
+ ```
610
+
611
+ 제출은 항상 같은 순서로 흐릅니다.
612
+
613
+ 1. `rules` 검증 → 실패하면 필드 아래 메시지를 붙이고 멈춥니다(값을 고치면 그 필드 에러만 즉시 사라집니다).
614
+ 2. `submitting`을 켜고 — **이 동안 재제출은 무시됩니다** — `onSubmit(values)`를 부릅니다.
615
+ 3. 성공: `SdToast.Success`(끄려면 `successMessage: false`) → `resetOnSuccess`면 초기화 → `onSuccess(values)`.
616
+ 4. 실패(`onSubmit`이 던진 예외): `SdToast.Error` + `form.error`에 메시지 + `onError(error)`. 문구는 `errorMessage(error)`로 바꿉니다.
617
+ 5. `finalize` — 성공·실패와 무관하게 **항상** 실행됩니다(`finally`에 해당). 아래 항목 참고.
618
+
619
+ `getInputProps`는 **어느 입력이든 같은 한 줄**입니다. Mantine 입력은 `onChange`로 이벤트를 주는 것(`TextInput`·`Checkbox`)과 값을 그대로 주는 것(`Select`·`NumberInput`·`Slider`·`DateInput`)으로 갈리는데, 훅이 그 차이를 흡수합니다. 값을 `checked`로 받아야 하는 체크박스·스위치만 `{ type: 'checkbox' }`를 붙이세요.
620
+
621
+ ### finalize — 끝나면 항상 도는 뒷정리
622
+
623
+ `Finalizer`는 폼 전용 타입이 아닙니다. 라우팅·모달 닫기·목록 새로고침처럼 "작업이 끝나면 정리한다"가 필요한 곳이면 어느 스토어에서든 같은 타입을 씁니다.
624
+
625
+ ```ts
626
+ import { runFinalizers, type Finalizers } from '@yeongseoksong/framework/util'
627
+
628
+ type Finalizer = () => unknown | Promise<unknown> // 인자 없음
629
+ type Finalizers = Finalizer | Finalizer[] // 여러 개면 순서대로
630
+ ```
631
+
632
+ 인자를 받지 않으므로 **기존 함수를 그대로 꽂습니다.**
633
+
634
+ ```tsx
635
+ useSdForm({
636
+ id: 'edit-user',
637
+ initialValues,
638
+ onSubmit: (values) => api.save(values),
639
+ finalize: closeModal, // 하나
640
+ // finalize: [closeModal, refetchList, () => router.push('/users')], // 여러 개
641
+ })
642
+ ```
643
+
644
+ 결과에 따라 갈라져야 하는 일은 `onSuccess(values)` / `onError(error)`에 둡니다 — `finalize`는 결과를 보지 않습니다.
645
+
646
+ 프로미스를 돌려주면 기다렸다가 끝냅니다. `submitting`이 내려간 **뒤에** 실행되므로 여기서 `reset()`이나 다음 제출을 불러도 막히지 않고, `finalize`에서 예외가 나도 작업 결과는 뒤집히지 않습니다(콘솔에만 남습니다). 검증에서 걸려 전송을 하지 않은 경우에는 호출되지 않습니다.
647
+
648
+ 직접 만든 비동기 액션에도 같은 규약을 붙일 수 있습니다.
649
+
650
+ ```ts
651
+ async function deleteUser(id: string, finalize?: Finalizers) {
652
+ try {
653
+ await api.delete(id)
654
+ SdToast.Success('삭제했습니다.')
655
+ } finally {
656
+ await runFinalizers(finalize, 'deleteUser') // label은 콘솔 메시지에만 쓰입니다
657
+ }
658
+ }
659
+ ```
660
+
661
+ `form`이 돌려주는 것: `values` · `errors` · `submitting` · `error` · `setValue` · `setValues` · `reset` · `getInputProps` · `onSubmit`.
662
+
663
+ 상태는 `formId`로 칸을 나눠 스토어에 있으므로, **같은 id를 쓰면 서로 다른 컴포넌트가 같은 폼을 공유**합니다 — 마법사처럼 단계를 여러 컴포넌트로 쪼개거나, 페이지를 오간 뒤 입력값을 복원할 때 그대로 씁니다. 값까지 지우려면 `useFormStore.getState().removeForm(id)`를 부르세요.
664
+
665
+ `formRules`: `required()` · `email()` · `minLength(n)` · `checked()` · `sameAs('password')`. 규칙은 `(value, values) => string | null` 형태라 직접 만들어 섞어도 됩니다.
666
+
667
+ ### SdToast — 순간 피드백
668
+
669
+ 저장·삭제·로그인처럼 **잠깐 알리고 사라져야 하는** 결과에 씁니다. 컴포넌트가 아니라 호출하는 함수입니다.
670
+
671
+ ```tsx
672
+ 'use client'
673
+ import { SdToast } from '@yeongseoksong/framework/ui'
674
+
675
+ await save()
676
+ SdToast.Success('저장했습니다.')
677
+ SdToast.Error('저장하지 못했습니다.', { title: '네트워크 오류' })
678
+ ```
679
+
680
+ 변형마다 색·아이콘·기본 제목이 고정됩니다 — `Success`(green ✓ "완료") · `Error`(red ✕ "오류") · `Warning`(amber ⚠ "주의") · `Info`(primary ⓘ "안내") · `Loading`(스피너, 자동으로 닫히지 않음 "처리 중").
681
+ 두 번째 인자로 Mantine `NotificationData`를 그대로 넘겨 제목·색·`autoClose`를 덮어쓸 수 있습니다.
682
+
683
+ 오래 걸리는 작업은 `Loading`으로 띄운 뒤 반환된 id로 결과 변형으로 **교체**합니다.
684
+
685
+ ```tsx
686
+ const id = SdToast.Loading('업로드하는 중입니다…')
687
+ try {
688
+ await upload(file)
689
+ SdToast.Update(id, 'Success', '업로드를 마쳤습니다.')
690
+ } catch {
691
+ SdToast.Update(id, 'Error', '업로드에 실패했습니다.')
692
+ }
693
+ ```
694
+
695
+ `SdToast.Hide(id)`로 하나, `SdToast.Clean()`으로 전부 닫습니다.
696
+
697
+ > 동작 조건 두 가지: 앱 레이아웃에 **`<SdToastProvider />`가 한 번 렌더**되어 있어야 하고, **`@mantine/notifications/styles.css`를 임포트**해야 합니다(위 설정 항목 참고). 위치·자동 닫힘·동시 표시 개수는 `SdToastProvider`가 `top-right` · 4초 · 3개로 고정하며, prop으로 덮어쓸 수 있습니다.
698
+
699
+ ### SdResult — 결과 화면
700
+
701
+ 가입 완료·결제 실패처럼 **페이지 전체가 결과**인 경우에 씁니다. 원형 아이콘 + 제목 + 설명 + 액션 버튼 구성입니다.
702
+
703
+ ```tsx
704
+ 'use client'
705
+ import { SdResult } from '@yeongseoksong/framework/ui'
706
+
707
+ <SdResult.Success
708
+ title="가입이 완료되었습니다"
709
+ description="입력하신 이메일로 인증 메일을 보냈습니다."
710
+ primaryAction={{ label: '시작하기', onClick: () => router.push('/') }}
711
+ secondaryAction={{ label: '홈으로', onClick: () => router.push('/') }}
712
+ />
713
+ ```
714
+
715
+ `Success`(green ✓) / `Error`(red ✕) 두 변형이 있고, 제목은 변형별 기본값(`'완료되었습니다'` / `'문제가 발생했습니다'`)을 씁니다.
716
+ 액션은 `SdButton`이 `component` prop을 받지 못하므로 `href`가 아니라 `onClick`만 받습니다 — 라우팅은 호출부에서 처리하세요.
717
+ 주문번호 요약 같은 상세 정보는 `children`으로 넣고, 좁은 영역에 담을 때만 `mih`(기본 `'60vh'`)를 줄입니다.
718
+
719
+ 서버 오류(500)·404처럼 **화면 자체가 오류**인 경우는 `SdResult.Error`가 아니라 `SdErrorView`를 씁니다.
720
+
476
721
  ### MainLayout
477
722
 
478
723
  헤더 + 본문 + 푸터가 포함된 전체 레이아웃입니다.
@@ -489,6 +734,14 @@ export default function Page() {
489
734
  }
490
735
  ```
491
736
 
737
+ `headerVariant`로 어떤 헤더를 쓸지 고릅니다 — `mega`(기본, hover 시 확장되는 메가 메뉴) 또는 `simple`(바 높이 고정 + 항목별 드롭다운).
738
+
739
+ ```tsx
740
+ <MainLayout navItems={navItems} companyInfo={company} headerVariant="simple" loginFlag>
741
+ <main>페이지 내용</main>
742
+ </MainLayout>
743
+ ```
744
+
492
745
  ### t() — 회사명 치환
493
746
 
494
747
  `NEXT_PUBLIC_COMPANY_NAME=내 회사` 일 때:
@@ -502,6 +755,32 @@ t('%c에 오신 것을 환영합니다') // → '내 회사에 오신 것을 환
502
755
 
503
756
  `SdText`/`SdTitle`은 문자열 children에 `t()`를 자동으로 적용하므로 직접 호출할 일은 드뭅니다.
504
757
 
758
+ ### 한글 조사 — josa / withJosa / fixJosa
759
+
760
+ 회사명·사용자 이름·품목명처럼 **런타임에 정해지는 값** 뒤에 조사가 붙을 때, 받침에 맞는 형태를 골라 줍니다. React·DOM에 의존하지 않는 순수 함수라 서버·클라이언트 어디서든 씁니다.
761
+
762
+ ```ts
763
+ import { josa, withJosa, fixJosa, hasFinalConsonant } from '@yeongseoksong/framework/util'
764
+
765
+ josa('가나전자', '은/는') // '는'
766
+ josa('한빛', '은/는') // '은'
767
+ withJosa('한빛', '을/를') // '한빛을'
768
+ hasFinalConsonant('수박') // true
769
+ ```
770
+
771
+ 지원 쌍: `은/는` `이/가` `을/를` `과/와` `으로/로` `아/야` `이라/라` `이나/나` `이란/란` `이여/여`. 표기는 항상 **'받침 있을 때/없을 때'** 순서입니다.
772
+
773
+ 판단 기준은 마지막 소리입니다 — 한글 음절은 종성으로, **숫자는 읽는 소리로**(`3` 삼 → 받침 있음, `2` 이 → 없음), **알파벳도 읽는 소리로**(`URL`의 L=엘 → 받침 있음, `API`의 I=아이 → 없음) 봅니다. 괄호·문장부호로 끝나면 그 앞의 실제 글자까지 거슬러 올라갑니다(`가나(주)` → '주' 기준). `으로/로`만 예외로 **ㄹ 받침을 받침 없음처럼** 다룹니다(`서울로`, `7로`).
774
+
775
+ 원고에 두 형태를 병기해 두고 마지막에 한 번 정리하는 방식도 됩니다.
776
+
777
+ ```ts
778
+ fixJosa(t('%c은(는) 이렇게 일합니다')) // → '가나전자는 이렇게 일합니다'
779
+ fixJosa('서울(으)로 이전했습니다') // → '서울로 이전했습니다'
780
+ ```
781
+
782
+ `은(는)` `이(가)` `을(를)` `과(와)`(역순 표기 포함)와 `(으)로` `(이)라` `(이)나` `(이)란` `(이)여`를 알아봅니다. 그 외의 괄호(`(주)가나`)는 건드리지 않습니다. `t()`는 `fixJosa`를 자동으로 부르지 **않습니다** — 필요한 문자열에서만 감싸 쓰세요.
783
+
505
784
  > `setCompanyName()`은 **deprecated이며 2.0.0에서 제거됩니다.** tsup이 `ui`와 `util`을 별개 번들로 빌드하면서 `text.util`이 `dist/ui`에 인라인 복사되기 때문에, 이 함수로 값을 바꿔도 `t()`를 실제로 호출하는 `SdText`/`SdTitle`은 다른 사본을 읽습니다. 즉 처음부터 동작하지 않았습니다. 환경변수는 번들러가 양쪽 번들에 동일한 리터럴을 박아넣으므로 이 문제가 없습니다.
506
785
 
507
786
  ---
@@ -535,6 +814,9 @@ import type {
535
814
  | `@mantine/core` | ^9.2.2 |
536
815
  | `@mantine/hooks` | ^9.2.2 |
537
816
  | `@mantine/carousel` | ^9.2.2 |
817
+ | `@mantine/notifications` | ^9.2.2 |
818
+ | `@mantine/dates` | ^9.2.2 (dayjs 필요) |
819
+ | `zustand` | ^5.0.14 |
538
820
  | `next` | 16.2.2 |
539
821
  | `react` | 19.2.4 |
540
822
  | `react-dom` | 19.2.4 |