From 73ec1a03ab10a57f9dc48a2a2d2eb8f990599b62 Mon Sep 17 00:00:00 2001 From: "ditto-m[bot]" <296158794+ditto-m[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:49:42 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20sidebar=20structure=20=E2=80=94=20h?= =?UTF-8?q?eader-pinned=20collapse,=20rail=20parity,=20identity=20footer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump 0.42.1 -> 0.43.0. - Collapse toggle moves from a floating row below the brand into the sidebar header, pinned to the trailing edge; collapsed rail keeps it as the sole top-of-rail control. - Section captions collapse to a subtle divider instead of vanishing, and drop to a quieter font-weight/spacing treatment when expanded. - UserMenu variant="full" is the canonical identity footer (avatar + name + email), with new themeToggle and badges props — the theme toggle now lives inside the popover instead of a loose footer icon. - NotificationBell gets a variant="row" sidebar presentation (mounted via the new SidebarItem.render escape hatch) with a portal-based popover so it isn't clipped by the sidebar's own overflow, plus SidebarItem.badge for unread counts that degrade to a dot on the collapsed rail. Bell convention: nav row on desktop/rail, NavigationBar trailing on mobile — never the footer, never paired with the theme toggle. - Showcase dogfoods all of the above; e2e updated for the moved theme toggle and extended to cover the mobile bell + rail-collapsed badge dot and portaled popover. Closes etamong-playground/planning#1133 --- CHANGELOG.md | 90 +++++++ README.md | 170 +++++++++++-- e2e/showcase.spec.ts | 41 +++- package.json | 2 +- showcase/src/App.tsx | 84 ++++++- showcase/src/messages.ts | 2 + showcase/src/mockMe.ts | 17 ++ showcase/src/registry.ts | 7 + showcase/src/sections/ChromeSection.tsx | 65 +++-- .../src/sections/NotificationsSection.tsx | 39 ++- showcase/src/showcase.css | 9 - src/index.ts | 2 + src/notificationBell.tsx | 204 ++++++++++++---- src/sidebar.tsx | 154 +++++++----- src/styles.css | 225 ++++++++++++++++-- src/userMenu.tsx | 128 +++++++++- 16 files changed, 1054 insertions(+), 185 deletions(-) create mode 100644 showcase/src/mockMe.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a860a3..c86cbc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,95 @@ # Changelog +## 0.43.0 + +Sidebar structure pass (planning#1133) — the desktop collapse affordance, rail +parity, the identity footer, and where the notification bell/theme toggle live +all move to match the reference UI. Presentation + placement, not new state. + +### Collapse control + +- The rail-collapse chevrons button — previously a separate row floating + below the brand, reading as an afterthought — now lives in the sidebar + header row, pinned to the trailing edge and vertically aligned with the + app name/icon. Same `⌘/Ctrl+B` shortcut, same `useSidebarDrawer` behavior, + unchanged. +- Collapsed rail: the app name/icon hide and the toggle becomes the sole + visible header control — the top item of the rail, so re-expanding stays + discoverable without hovering. + +### Rail parity + +- Collapsed rail keeps the same item order/grouping/vertical rhythm as + expanded (unchanged from v0.37 — no code change needed, called out here + because it's now paired with the caption fix below). +- Section captions (`secondarySections` captions, `secondaryCaption`) + collapse to a subtle 1px divider instead of vanishing outright — without + it, a second-or-later `secondarySections` group (no border-top of its + own) lost all visual separation from the group above once collapsed. +- Quiet caption treatment: `--etu-fs-caption` / `--etu-text-subtle` + unchanged, but `font-weight` drops from `--etu-fw-semibold` to + `--etu-fw-medium` and the top padding/margin grows, so a caption reads as + a label, not a competing heading. + +### Identity footer + UserMenu + +- `` — a full-width avatar + name + email trigger, + opening the same popover as the existing avatar circle. This is now the + canonical `` control. +- `` — adds a light/dark row to the + popover, backed by `getTheme`/`setTheme`. The theme toggle's canonical + home now, not a loose footer icon. +- `` — role/permission pills under + the name, independent of the existing `admin` pill (`showAdminBadge`); + reuses the shared `.etu-badge` classes. +- All three are new optional props — existing `` usage (avatar + trigger, no badges, no theme row) is visually unchanged. + +### Bell and theme placement + +- `` — a full-width `.etu-sidebar-item` row + (icon + `label` + count), meant to be mounted via the new + `SidebarItem.render`. Reuses the same `.etu-sidebar-item*` classes + `` itself uses, so it inherits rail-collapse (icon-only, badge → + dot) for free. The desktop popover now renders through a portal to + `` (row variant only) so it isn't clipped by the sidebar's own + `overflow: auto` at any rail width, including the 64px collapsed column. + The existing standalone `variant="trigger"` (default) is unchanged. +- `SidebarItem.badge` — a trailing indicator (unread count, status dot) on + any plain nav row. Expanded: a pill after the label. Collapsed rail: + degrades to a small dot overlaid on the icon's corner via a pure CSS + swap (no JS branching on collapse state) instead of disappearing. +- `SidebarItem.render` — escape hatch that replaces a row's default + button/link markup entirely, for rows that need to own more than an + `onClick` (`NotificationBell`'s `"row"` variant is the reference + implementation). +- Convention: the bell is a nav row (desktop/rail) or + `` (mobile, since the sidebar is hidden below + 720px) — never the sidebar footer, and never paired with the theme + toggle. `NotificationBell` itself is unchanged/not deprecated, only the + footer-icon-cluster placement is retired; nothing in this package's own + showcase mounts it there. + +### Behavioral notes for 0.43 + +Visible changes an app might notice after bumping to 0.43, without any code +change on the app's side: + +- **Section captions are lighter.** `--etu-fw-semibold` → `--etu-fw-medium` + on `.etu-sidebar-caption` / `.etu-sidebar-section-caption`, with more space + above. Purely visual. +- **The rail toggle moved and shrank.** From a standalone 40×40 button + centered in its own row below the header, to a 32×32 button inside the + header row, pinned to the trailing edge. Apps that pass `appIcon` and/or + `appName` alongside `tabletMode="rail"` will see this shift automatically + — no prop change needed. +- **The app icon/name hide on collapsed rail** (previously the icon stayed + visible, centered). The toggle is now the sole top-of-rail control while + collapsed. +- **`.etu-sidebar-header-name` truncates with an ellipsis** instead of + wrapping/overflowing when the app name is long enough to compete with the + now-inline toggle button. + ## 0.42.1 Fix (planning#976) — `@playwright/test` and `msw` are no longer `peerDependencies` diff --git a/README.md b/README.md index 9ad19f6..bbb29b1 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ safe go-to shortcuts, **toast + dialog** notification primitives, and the `app-notifications`, `build-version-info`). Published to GitHub Packages; consumed by all app -frontends. **Current: v0.42.** Releasing + consuming are documented at the bottom. +frontends. **Current: v0.43.** Releasing + consuming are documented at the bottom. Works in both house stacks — Next.js (React 19) and Vite + apiserver (React 18). React/ReactDOM are peer deps. @@ -1357,6 +1357,38 @@ stand-alone ``: Pictures that fail to load fall back to the initial letter automatically. +### Full-width identity footer (v0.43) + +`variant="full"` swaps the avatar-circle trigger for a full-width row — +avatar + name + email stacked — opening the same popover. This is the +canonical `` control: + +```tsx + + } +/> +``` + +- **`themeToggle={{ appKey }}`** adds a light/dark row to the popover, backed + by `getTheme`/`setTheme` (same `appKey` as `noFlashThemeScript`). This is + the theme toggle's canonical home now — see "Bell and theme placement" + under Sidebar below for why it moved out of the footer as a loose icon. +- **`badges`** — role/permission pills (`{ label, tone? }`, tone matches + `.etu-badge--*`) rendered under the name, independent of the built-in + `admin` pill (`showAdminBadge`). +- The footer stays identity-only otherwise — don't drop unrelated icon + buttons (locale switch, command palette trigger, …) next to it; those + belong in `appHeaderExtra` or the app's own header/nav bar chrome. + ## Sidebar + MobileTabBar (fleet nav shell) `` is the desktop nav shell; `` is the mobile @@ -1366,13 +1398,18 @@ one source of truth, two renderers. Both are CSS-hidden at the opposite breakpoint, so mounting both unconditionally is correct. ```tsx -import { Sidebar, MobileTabBar, AppInfoSection, type SidebarItem } from "@etamong-playground/ui"; +import { Sidebar, MobileTabBar, NotificationBell, UserMenu, type SidebarItem } from "@etamong-playground/ui"; import { Home, Calendar, Users, Settings, ShieldCheck, MoreHorizontal } from "lucide-react"; const primary: SidebarItem[] = [ { id: "home", label: "홈", icon: , active: view === "home", onClick: () => go("home") }, { id: "schedule", label: "일정", icon: , active: view === "schedule", onClick: () => go("schedule") }, { id: "members", label: "구성원", icon: , active: view === "members", onClick: () => go("members") }, + // Owns its own trigger + anchored popover, so it opts out of the plain + // onClick/active row shape via `render` — see "Bell and theme placement" below. + { id: "notifications", label: "알림", render: () => ( + + ) }, ]; const secondary: SidebarItem[] = [ @@ -1388,16 +1425,19 @@ function Shell({ children }: { children: ReactNode }) { primary={primary} secondary={secondary} footer={ - <> - - - + } />
{children}
!it.render).slice(0, 4), { id: "more", label: "더보기", icon: , active: view === "more", onClick: () => go("more") }, ]} /> @@ -1413,11 +1453,53 @@ Notes: `/more` to find Settings, Logout, Admin, etc. Never put Settings on a tab; never show a header-dropdown `` on mobile. - **No `userMenu` prop on ``.** Identity + Logout live in - `footer`. Header dropdowns are the retired anti-pattern. + `footer` — the canonical shape (v0.43) is a single + ``, see "Full-width identity footer" above. + Header dropdowns are the retired anti-pattern. - **Active state is caller-computed.** Both components are router-agnostic and never read the URL. - **CSS variable `--etu-sidebar-w` overrides the 240px default width.** +### Bell and theme placement (v0.43) + +A bell + theme icon pair sitting loose in the sidebar footer crowds it and +collides once the rail collapses to 64px. The fleet convention instead: + +- **Notifications are a nav row, not a footer icon.** `` (see the NotificationBell section below) mounts via + `SidebarItem.render` — reusing `.etu-sidebar-item*` classes gives it + rail-collapse parity for free (icon-only, unread count → a dot) with no + extra wiring. +- **On mobile** the sidebar is hidden below 720px, so the bell moves to + `` instead — a single icon with room, colliding + with nothing. +- **Theme lives inside ``** (see "Full-width identity + footer" above), not as a loose footer icon either. +- Never pair the bell with the theme toggle in the same spot — that's + exactly the crowding this convention replaces. + +### `SidebarItem.badge` — unread counts and status dots + +Any plain nav row (not just the bell) can carry a trailing indicator: + +```tsx +{ id: "inbox", label: "받은편지함", icon: , badge: unread || undefined, onClick: () => go("inbox") } +``` + +Expanded: renders as a pill after the label. Collapsed rail: degrades to a +small dot overlaid on the icon's corner (pure CSS swap — no JS branching on +collapse state) instead of disappearing, since staying visible collapsed is +the point of an unread indicator. Requires `icon` — an icon-less item has +nowhere to anchor the dot. + +### `SidebarItem.render` — custom rows + +Escape hatch for a row that needs to own more than an `onClick` — +`NotificationBell`'s `"row"` variant is the reference implementation. When +`render` is set, every other field except `id` is ignored; reuse the +`.etu-sidebar-item*` classes in the returned markup to inherit rail-collapse +behavior (icon-only, label hidden, badge → dot) automatically. + ### Captioned secondary subsections (large apps) Once an app's secondary list grows past ~6 rows, swap the flat @@ -1463,12 +1545,13 @@ When both `secondary` and `secondarySections` are passed, `tabletMode="rail"` is an inline-collapsible sidebar: collapsed it is a 64px icon-only column, expanded it is the normal in-flow 240px sidebar pushing content — no overlay, no -scrim (the v0.35.0 overlay expansion is replaced). A chevrons button under the app-icon -header flips the state at **both** the tablet and desktop tiers, and so does the -**⌘/Ctrl+B** keyboard shortcut (v0.38.0; VS Code / shadcn convention — IME-safe, ignored -inside text inputs where ⌘B means bold). The default follows the tier — tablet starts -collapsed, desktop starts expanded — and re-derives when the viewport crosses 1024px. -Clicking nav items does not collapse the sidebar. +scrim (the v0.35.0 overlay expansion is replaced). A chevrons button pinned to the +header's trailing edge — vertically aligned with the app name/icon row (v0.43; previously +a separate row floating below the brand) — flips the state at **both** the tablet and +desktop tiers, and so does the **⌘/Ctrl+B** keyboard shortcut (v0.38.0; VS Code / shadcn +convention — IME-safe, ignored inside text inputs where ⌘B means bold). The default +follows the tier — tablet starts collapsed, desktop starts expanded — and re-derives +when the viewport crosses 1024px. Clicking nav items does not collapse the sidebar. ```tsx `) and a bottom sheet on mobile (backdrop + slide-up + +safe-area inset + body-scroll lock). Content-agnostic — pass an `items` +array with rendered `content` nodes and any inline actions: + +```tsx +import { NotificationBell } from "@etamong-playground/ui"; + + refetchItems()} // refresh on open + footer={모두 보기} +/> +``` + +Replaces per-app "inbox" tabs/routes: incoming notifications (access +requests, deploy completions, mentions) belong on a global bell, not the +primary nav. + +**Placement (v0.43):** `variant="row"` (default `"trigger"`) renders as a +full-width `.etu-sidebar-item` row — icon + `label` + count — meant to be +mounted via `SidebarItem.render` on tablet/desktop: + +```tsx +{ id: "notifications", render: () => ( + +) } +``` + +It reuses the same `.etu-sidebar-item*` classes `` itself uses, so +it inherits rail-collapse (icon-only, badge → dot) for free — no extra +wiring. The desktop popover renders through a portal to `` so it isn't +clipped by the sidebar's own `overflow: auto`, at any rail width including +the 64px collapsed column. + +On mobile the sidebar is hidden below 720px — mount the default +`variant="trigger"` in `` instead (see above). Never +the sidebar footer (crowds the identity control, collides in rail mode) and +never paired with the theme toggle (moved into ``) — +see "Bell and theme placement" under Sidebar. + ## NavigationBar + floating tab bar (iOS 26 Liquid Glass) v0.23.0 adds `` — an iOS-style small-title bar — as the default @@ -1511,6 +1646,13 @@ Props (see `NavigationBarProps`): shadow after the page scrolls past 24px. - `borderless` — drop the hairline border (for full-bleed transparent shells). +`trailing` is also the mobile home for `` (v0.43) — the sidebar +(and its nav-row bell, see "Bell and theme placement" above) is hidden below 720px: + +```tsx +} /> +``` + ### Android Chrome / Samsung Internet compatibility floor - Min hit area is **48px** (Material 3 floor — supersedes iOS 44pt). diff --git a/e2e/showcase.spec.ts b/e2e/showcase.spec.ts index b81f156..5ebc8ca 100644 --- a/e2e/showcase.spec.ts +++ b/e2e/showcase.spec.ts @@ -81,7 +81,8 @@ test("toast", async ({ page }) => { await expect(page.locator(".etu-toast")).toBeVisible(); }); -// f. Theme toggle persistence — toggle in sidebar, survives reload +// f. Theme toggle persistence — toggle inside the sidebar footer's UserMenu +// popover (planning#1133: moved out of the loose footer icon cluster), survives reload test("theme toggle persistence", async ({ page }) => { await page.goto("/#/overview"); await expect(page.locator(".etu-sidebar")).toBeVisible(); @@ -91,7 +92,8 @@ test("theme toggle persistence", async ({ page }) => { ); const expectedTheme = initialTheme === "dark" ? "light" : "dark"; - await page.locator(".etu-sidebar").getByRole("button", { name: /mode/ }).click(); + await page.locator(".etu-sidebar .etu-user-menu-trigger--full").click(); + await page.getByRole("menuitem", { name: /모드/ }).click(); await expect(page.locator("html")).toHaveAttribute("data-theme", expectedTheme); await page.reload(); @@ -108,6 +110,41 @@ test.describe("mobile tier", () => { await expect(page.locator(".etu-mobile-tab-bar")).toBeVisible(); await expect(page.locator(".etu-sidebar")).not.toBeVisible(); }); + + // g2. Mobile bell — the sidebar (and its nav-row bell) is hidden below + // 720px, so the bell rides on NavigationBar's trailing edge instead. + test("notification bell on navigation bar trailing edge", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/#/overview"); + const navBell = page.locator(".etu-navbar .etu-notif-bell-trigger"); + await expect(navBell).toBeVisible(); + await navBell.click(); + await expect(page.locator(".etu-notif-bell-sheet")).toBeVisible(); + }); +}); + +// g3. Rail collapse — the toggle now lives in the header row; collapsing it +// degrades the bell row's unread badge to a dot that stays visible (not +// hidden), and its popover (portaled to , escaping the sidebar's own +// `overflow: auto`) still opens correctly while the rail is collapsed. +test("rail collapse — bell badge dot and portaled popover", async ({ page }) => { + await page.goto("/#/overview"); + const sidebar = page.locator(".etu-sidebar"); + await expect(sidebar).toBeVisible(); + + await sidebar.locator(".etu-sidebar-rail-toggle").click(); + await expect(sidebar).toHaveAttribute("data-expanded", "false"); + + const bellRow = sidebar.getByRole("button", { name: /알림함/ }); + await expect(bellRow).toBeVisible(); + await expect(bellRow.locator(".etu-sidebar-item-badge-dot")).toBeVisible(); + + await bellRow.click(); + const popover = page.locator(".etu-notif-bell-popover"); + await expect(popover).toBeVisible(); + const box = await popover.boundingBox(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(1280 + 1); }); // h. Versions view — renders ≥5 version groups and a v0.28.0 heading diff --git a/package.json b/package.json index d5c6f33..ccc5593 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@etamong-playground/ui", - "version": "0.42.1", + "version": "0.43.0", "repository": { "type": "git", "url": "git+https://github.com/etamong-playground/ui.git" diff --git a/showcase/src/App.tsx b/showcase/src/App.tsx index ab2dfd7..ebcde2f 100644 --- a/showcase/src/App.tsx +++ b/showcase/src/App.tsx @@ -5,19 +5,24 @@ import { DialogHost, MobileTabBar, NavigationBar, + NotificationBell, Sidebar, Toaster, + UserMenu, crossLocaleKeywords, getTheme, setTheme, useGoToShortcuts, useLocale, useT, + useViewport, type CommandSection, type MobileTabBarItem, + type NotificationBellItem, type SidebarItem, } from "@etamong-playground/ui"; import { messages } from "./messages"; +import { mockAdmin } from "./mockMe"; import { Overview } from "./sections/Overview"; import { PaletteSection } from "./sections/PaletteSection"; import { NotificationsSection } from "./sections/NotificationsSection"; @@ -85,10 +90,14 @@ function IconPalette() { ); } -function IconBell() { +// Toast/dialog demo page — deliberately NOT a bell glyph. The new +// NotificationBell nav row (below, in `primary`) owns the bell icon; two +// near-identical bells stacked adjacently reads as a duplicate, not two +// distinct destinations. +function IconMessageSquare() { return ( - + ); } @@ -189,6 +198,30 @@ function LocaleToggleButton() { ); } +// Mock unread notifications — dogfoods the +// sidebar-composition convention (planning#1133): bell = nav row on +// tablet/desktop, NavigationBar trailing on mobile, never the footer. +const mockNotifItems: NotificationBellItem[] = [ + { + id: "1", + content: ( +
+ 배포 완료 +
ui-showcase build #482 · 3분 전
+
+ ), + }, + { + id: "2", + content: ( +
+ 리뷰 요청 +
sidebar-1133 PR 리뷰 대기 중 · 1시간 전
+
+ ), + }, +]; + export function App() { const t = useT(); const [hash, navigate] = useHash(); @@ -259,7 +292,7 @@ export function App() { const icons: Record = { overview: IconOverview, palette: IconPalette, - notifications: IconBell, + notifications: IconMessageSquare, chrome: IconLayers, data: IconTable, tokens: IconTokens, @@ -270,8 +303,8 @@ export function App() { }; const primary: SidebarItem[] = useMemo( - () => - ["overview", "palette", "notifications", "chrome", "data"].map((id) => { + () => [ + ...["overview", "palette", "notifications", "chrome", "data"].map((id) => { const Icon = icons[id as SectionId]; return { id, @@ -281,6 +314,23 @@ export function App() { onClick: () => go(id as SectionId), }; }), + // Bell owns its own trigger + anchored popover, so it opts out of the + // plain onClick/active row shape via `render`. Excluded from + // `mobileItems` below on purpose — mobile gets the bell via + // `` instead, see the render() body. + { + id: "notif-bell", + label: t("nav.notifBell"), + render: () => ( + + ), + }, + ], // eslint-disable-next-line react-hooks/exhaustive-deps [section, t, go], ); @@ -326,13 +376,21 @@ export function App() { }, [section, t, go]); const sectionTitle = t(`section.${section}`); + const viewport = useViewport(); + // Identity-only footer (planning#1133): a single full-width control that + // opens the same UserMenu popover as a header trigger would, with the + // theme toggle riding inside it — no loose icon cluster. const sidebarFooter = ( -
- - - -
+ alert("Signed out (demo)")} + /> ); return ( @@ -351,6 +409,7 @@ export function App() {
} primary={primary} secondary={secondary} secondaryCaption={t("sidebar.more")} @@ -362,6 +421,11 @@ export function App() { title={sectionTitle} trailing={
+ {/* Sidebar is hidden below 720px, so the bell rides here on + mobile instead of the (invisible) sidebar row above. */} + {viewport === "mobile" && ( + + )}
diff --git a/showcase/src/messages.ts b/showcase/src/messages.ts index f547d34..8682540 100644 --- a/showcase/src/messages.ts +++ b/showcase/src/messages.ts @@ -5,6 +5,7 @@ export const messages: MessageBundle = { "nav.overview": "개요", "nav.palette": "커맨드 팔레트", "nav.notifications": "알림", + "nav.notifBell": "알림함", "nav.chrome": "크롬", "nav.data": "데이터 & 시간", "nav.tokens": "디자인 토큰", @@ -43,6 +44,7 @@ export const messages: MessageBundle = { "nav.overview": "Overview", "nav.palette": "Command Palette", "nav.notifications": "Notifications", + "nav.notifBell": "Inbox", "nav.chrome": "Chrome", "nav.data": "Data & Time", "nav.tokens": "Design Tokens", diff --git a/showcase/src/mockMe.ts b/showcase/src/mockMe.ts new file mode 100644 index 0000000..a242975 --- /dev/null +++ b/showcase/src/mockMe.ts @@ -0,0 +1,17 @@ +import type { BaseMe } from "@etamong-playground/ui"; + +// Shared across App.tsx (live sidebar footer) and ChromeSection.tsx (UserMenu +// demo card) so both surfaces show the same demo identity. +export const mockMe: BaseMe = { + email: "demo@example.com", + preferred_username: "demo", + name: "Demo User", + is_admin: false, +}; + +export const mockAdmin: BaseMe = { + email: "admin@example.com", + preferred_username: "admin", + name: "Admin User", + is_admin: true, +}; diff --git a/showcase/src/registry.ts b/showcase/src/registry.ts index 998c59e..1343961 100644 --- a/showcase/src/registry.ts +++ b/showcase/src/registry.ts @@ -108,6 +108,12 @@ export const registry: Record = { since: "0.16.0", src: "src/userMenu.tsx", }, + "notification-bell": { + label: "Notification Bell", + exports: ["NotificationBell"], + since: "0.32.0", + src: "src/notificationBell.tsx", + }, "mobile-tab-bar": { label: "Mobile Tab Bar", exports: ["MobileTabBar"], @@ -191,6 +197,7 @@ export const featureRoute: Record = { "app-info-section": "#/appinfo", "rel-time": "#/data", "user-menu": "#/chrome", + "notification-bell": "#/chrome", "mobile-tab-bar": "#/chrome", "sidebar": "#/chrome", "navigation-bar": "#/chrome", diff --git a/showcase/src/sections/ChromeSection.tsx b/showcase/src/sections/ChromeSection.tsx index ce2acdc..09d2001 100644 --- a/showcase/src/sections/ChromeSection.tsx +++ b/showcase/src/sections/ChromeSection.tsx @@ -6,23 +6,9 @@ import { UserMenu, useInAppBack, useT, - type BaseMe, } from "@etamong-playground/ui"; import { FeatureTag } from "../FeatureTag"; - -const mockMe: BaseMe = { - email: "demo@example.com", - preferred_username: "demo", - name: "Demo User", - is_admin: false, -}; - -const mockAdmin: BaseMe = { - email: "admin@example.com", - preferred_username: "admin", - name: "Admin User", - is_admin: true, -}; +import { mockMe, mockAdmin } from "../mockMe"; export function ChromeSection({ navigate }: { navigate: (path: string) => void }) { const t = useT(); @@ -48,6 +34,7 @@ export function ChromeSection({ navigate }: { navigate: (path: string) => void } + @@ -55,10 +42,16 @@ export function ChromeSection({ navigate }: { navigate: (path: string) => void }

The Sidebar, NavigationBar, and{" "} MobileTabBar you see surrounding this content are all - live library components — no demo buttons needed. The{" "} - theme and i18n toggles in the sidebar - footer (desktop) and navigation bar (mobile) demonstrate{" "} - getTheme/setTheme and useLocale. + live library components — no demo buttons needed. The bell row near the + top of the sidebar (알림함 / Inbox) is{" "} + {''} mounted via{" "} + SidebarItem.render — collapse the rail to see its badge + degrade to a dot. Resize under 720px and the same bell moves to the + navigation bar's trailing edge instead — the sidebar is hidden there. + Identity (avatar + name + email) is the sidebar footer; opening it + reveals the theme toggle, moved out of the footer + itself. i18n stays in the navigation bar via{" "} + useLocale.

@@ -107,6 +100,40 @@ export function ChromeSection({ navigate }: { navigate: (path: string) => void } `} +
+
+ UserMenu — Sidebar Identity Footer + +
+

+ variant="full" — the canonical {""}{" "} + control (avatar + name + email, full width). The live sidebar's footer to the + left is this exact component; the button below is a static copy so it doesn't + shift with page scroll. badges adds role/permission pills;{" "} + themeToggle adds a light/dark row to the popover — the theme + toggle's canonical home now, not a loose footer icon. +

+
+ alert("Signed out (demo)")} + /> +
+
{``}
+
+
BackButton + useInAppBack diff --git a/showcase/src/sections/NotificationsSection.tsx b/showcase/src/sections/NotificationsSection.tsx index 3e3f7b3..b577071 100644 --- a/showcase/src/sections/NotificationsSection.tsx +++ b/showcase/src/sections/NotificationsSection.tsx @@ -1,7 +1,13 @@ import { useState } from "react"; -import { toast, uiConfirm, uiPrompt, useT } from "@etamong-playground/ui"; +import { NotificationBell, toast, uiConfirm, uiPrompt, useT } from "@etamong-playground/ui"; import { FeatureTag } from "../FeatureTag"; +const demoBellItems = [ + { id: "1", content:
배포 완료
3분 전
}, + { id: "2", content:
리뷰 요청
1시간 전
}, + { id: "3", content:
새 댓글
어제
}, +]; + export function NotificationsSection() { const t = useT(); const [confirmResult, setConfirmResult] = useState(null); @@ -19,6 +25,37 @@ export function NotificationsSection() {

+
+
+ NotificationBell + +
+

+ Bell trigger + unread badge; click opens a popover (desktop/tablet) or a + bottom sheet (mobile). Placement convention: a{" "} + {""} nav row (variant="row", see the live + sidebar to the left) on tablet/desktop, or{" "} + {""} on mobile — never the sidebar + footer, and never paired with the theme toggle (that lives in{" "} + {""} now). This standalone trigger below + is the same component in its default variant="trigger" form, + for a plain header/toolbar mount. +

+
+ +
+
{`// Header/toolbar — standalone trigger (default)
+
+
+// Sidebar row (desktop/rail) — mounted via SidebarItem.render
+{ id: "notifications", render: () => (
+    
+) }
+
+// Mobile — NavigationBar's trailing edge (sidebar is hidden < 720px)
+} />`}
+
+
toast() diff --git a/showcase/src/showcase.css b/showcase/src/showcase.css index bde78d0..6e15145 100644 --- a/showcase/src/showcase.css +++ b/showcase/src/showcase.css @@ -246,15 +246,6 @@ body { padding: 0 8px; } -/* ── Sidebar footer ───────────────────────────────────── */ - -.sc-sidebar-footer { - display: flex; - flex-direction: column; - gap: 8px; - padding: 8px 0; -} - /* ── Nav bar trailing ─────────────────────────────────── */ .sc-nav-trailing { diff --git a/src/index.ts b/src/index.ts index 20707eb..576e449 100644 --- a/src/index.ts +++ b/src/index.ts @@ -142,6 +142,8 @@ export { Avatar, type UserMenuProps, type UserMenuItem, + type UserMenuBadge, + type UserMenuThemeToggle, type AvatarProps, } from "./userMenu"; export { diff --git a/src/notificationBell.tsx b/src/notificationBell.tsx index 5c14225..ab35208 100644 --- a/src/notificationBell.tsx +++ b/src/notificationBell.tsx @@ -11,8 +11,15 @@ * (access requests, deploy completions, mentions) belong on a global bell, * not in the primary nav. The component is content-agnostic — consumers * render each item's body and any inline actions. + * + * **Placement convention (v0.43):** the bell is a `` nav row + * (`variant="row"`, mounted via `SidebarItem.render`) on tablet/desktop, or + * `` on mobile (the sidebar is hidden below 720px). + * Never the sidebar footer — that's identity-only now — and never paired + * with the theme toggle (which lives inside ``). */ import { useEffect, useId, useRef, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; import { useViewport } from "./viewport"; export interface NotificationBellItem { @@ -49,6 +56,28 @@ export interface NotificationBellProps { * as ``. Ignored on mobile (always bottom sheet). */ placement?: "bottom-right" | "bottom-left" | "top-right" | "top-left"; + /** + * Presentation. `"trigger"` (default) is the standalone icon-button, + * sized for a header/toolbar — unchanged from prior versions. `"row"` + * (v0.43) renders as a full-width `.etu-sidebar-item` row (icon + `label` + * + count) meant to be mounted via `SidebarItem.render`: + * + * ```tsx + * { id: "notifications", render: () => ( + * + * ) } + * ``` + * + * Reuses the same `.etu-sidebar-item*` classes `` itself uses, + * so it inherits rail-collapse (icon-only, badge → dot) for free. The + * desktop popover renders through a portal so it isn't clipped by the + * sidebar's own `overflow: auto` — same visual result as the trigger + * variant, just anchored via computed viewport coordinates instead of + * `position: absolute` relative to an ancestor. + */ + variant?: "trigger" | "row"; + /** Row-variant label. Ignored in `"trigger"` variant. Default: `"알림"`. */ + label?: ReactNode; } function BellIcon() { @@ -81,15 +110,30 @@ export function NotificationBell({ icon, className, placement = "bottom-right", + variant = "trigger", + label = "알림", }: NotificationBellProps) { const [open, setOpen] = useState(false); const [computedPlacement, setComputedPlacement] = useState(placement); + // Fixed-position coordinates for the row variant's portaled popover — it + // renders on , outside the sidebar's own `overflow: auto`, so it + // can't rely on `position: absolute` relative to an ancestor like the + // trigger variant does. + const [portalRect, setPortalRect] = useState<{ + top?: number; + bottom?: number; + left?: number; + right?: number; + } | null>(null); const rootRef = useRef(null); const triggerRef = useRef(null); + const panelRef = useRef(null); const panelId = useId(); const viewport = useViewport(); const isMobile = viewport === "mobile"; const badge = count ?? items.length; + const badgeText = badge > 99 ? "99+" : String(badge); + const isRow = variant === "row"; useEffect(() => { if (!open) return; @@ -102,7 +146,10 @@ export function NotificationBell({ useEffect(() => { if (!open) return; const onDocClick = (e: MouseEvent) => { - if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + const t = e.target as Node; + if (rootRef.current?.contains(t)) return; + if (panelRef.current?.contains(t)) return; + setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); @@ -141,6 +188,34 @@ export function NotificationBell({ setComputedPlacement(`${v}-${h}` as typeof placement); }, [open, placement, isMobile]); + // Row variant only: recompute the portaled popover's viewport-fixed + // coordinates from the trigger's rect whenever it might move. + useEffect(() => { + if (!isRow || !open || isMobile) { + setPortalRect(null); + return; + } + const trigger = triggerRef.current; + if (!trigger || typeof window === "undefined") return; + function recompute() { + const rect = trigger!.getBoundingClientRect(); + const [v, h] = computedPlacement.split("-") as ["top" | "bottom", "left" | "right"]; + const next: { top?: number; bottom?: number; left?: number; right?: number } = {}; + if (v === "top") next.bottom = window.innerHeight - rect.top + 8; + else next.top = rect.bottom + 8; + if (h === "right") next.right = Math.max(8, window.innerWidth - rect.right); + else next.left = rect.left; + setPortalRect(next); + } + recompute(); + window.addEventListener("resize", recompute); + window.addEventListener("scroll", recompute, true); + return () => { + window.removeEventListener("resize", recompute); + window.removeEventListener("scroll", recompute, true); + }; + }, [isRow, open, isMobile, computedPlacement]); + // Lock body scroll while the mobile sheet is open. useEffect(() => { if (!open || !isMobile || typeof document === "undefined") return; @@ -153,60 +228,63 @@ export function NotificationBell({ const close = () => setOpen(false); - return ( -
0 ? `${typeof label === "string" ? label : ariaLabel} (${badge})` : undefined + } + onClick={() => setOpen((o) => !o)} > - - {open && isMobile && ( - <> -