Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,37 @@ function CheckoutPage() {
| `renderLink` | `(props: TabLinkProps) => ReactNode` | `<a>` tag |
| `ariaLabels` | `{ showNav?: string; mobileNav?: string }` | English |
| `dir` | `"ltr" \| "rtl" \| "auto"` | `"auto"` |
| `activeId` | `string` | — |
| `haptics` | `boolean` | `false` |
| `className` | `string` | `""` |

### Controlled active tab (`activeId`)

Instead of setting `isActive` on every tab, pass the active tab's `id` once — the
matching tab becomes active and the per-tab booleans can be dropped:

```tsx
const p = usePathname()
const activeId =
p === "/" ? "home" : p.startsWith("/store") ? "store" : "categories"

<MobileTabBar activeId={activeId} tabs={[
{ id: "home", label: "Home", href: "/", icon: { /* … */ } },
{ id: "store", label: "Store", href: "/store", icon: { /* … */ } },
]} />
```

`isActive` still works when `activeId` is omitted, so existing code is unaffected.

### Haptics

Pass `haptics` to fire a short `navigator.vibrate(10)` on tap (Android Chrome et
al.; a no-op where unsupported, e.g. iOS Safari):

```tsx
<MobileTabBar haptics tabs={tabs} />
```

### `DEFAULT_LABELS`

```ts
Expand All @@ -134,7 +163,7 @@ type TabItem = {
id: string
label: string
icon: { outline: ReactNode; filled: ReactNode }
isActive: boolean
isActive?: boolean // optional — or drive it with the activeId prop
badge?: number
} & ({ href: string } | { onClick: () => void })
```
Expand Down
47 changes: 46 additions & 1 deletion src/components/MobileTabBar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { act, render, screen, waitFor } from "@testing-library/react"
import { afterEach, describe, expect, it } from "vitest"
import userEvent from "@testing-library/user-event"
import { afterEach, describe, expect, it, vi } from "vitest"
import { MobileTabBar } from "./MobileTabBar"
import type { TabItem } from "../types"

Expand Down Expand Up @@ -71,3 +72,47 @@ describe("active semantics", () => {
expect(screen.getByRole("button", { name: "Home" })).toHaveAttribute("aria-pressed", "true")
})
})

describe("controlled activeId", () => {
it("activeId selects the active tab and overrides per-tab isActive", () => {
// Every tab claims isActive:false, but activeId="store" should win.
const tabs: TabItem[] = [
{ id: "home", label: "Home", href: "/", isActive: false, icon },
{ id: "store", label: "Store", href: "/store", isActive: false, icon },
]
render(<MobileTabBar tabs={tabs} dir="ltr" activeId="store" />)
expect(screen.getByRole("link", { name: "Store" })).toHaveAttribute("aria-current", "page")
expect(screen.getByRole("link", { name: "Home" })).not.toHaveAttribute("aria-current")
})

it("works without any per-tab isActive booleans", () => {
const tabs: TabItem[] = [
{ id: "home", label: "Home", href: "/", icon },
{ id: "store", label: "Store", href: "/store", icon },
]
render(<MobileTabBar tabs={tabs} dir="ltr" activeId="home" />)
expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute("aria-current", "page")
})
})

describe("haptics", () => {
it("vibrates on tap only when enabled", async () => {
const vibrate = vi.fn()
vi.stubGlobal("navigator", { ...navigator, vibrate })
const user = userEvent.setup()
const onClick = vi.fn()
const tabs: TabItem[] = [{ id: "cart", label: "Cart", onClick, isActive: false, icon }]

const { rerender } = render(<MobileTabBar tabs={tabs} dir="ltr" haptics />)
await user.click(screen.getByRole("button", { name: "Cart" }))
expect(vibrate).toHaveBeenCalledWith(10)
expect(onClick).toHaveBeenCalledTimes(1)

vibrate.mockClear()
rerender(<MobileTabBar tabs={tabs} dir="ltr" />) // haptics off
await user.click(screen.getByRole("button", { name: "Cart" }))
expect(vibrate).not.toHaveBeenCalled()

vi.unstubAllGlobals()
})
})
29 changes: 23 additions & 6 deletions src/components/MobileTabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,20 @@ const DefaultLink = ({ href, children, className, ...props }: TabLinkProps) => (
</a>
)

/** Short tactile pulse on tap, where the platform supports it. */
const vibrate = () => {
if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
navigator.vibrate(10)
}
}

export const MobileTabBar = ({
tabs,
renderLink,
ariaLabels,
dir = "auto",
activeId,
haptics = false,
className = "",
}: MobileTabBarProps) => {
const showNavLabel = ariaLabels?.showNav ?? "Show navigation"
Expand Down Expand Up @@ -95,15 +104,17 @@ export const MobileTabBar = ({
const LinkRenderer = renderLink ?? DefaultLink

const renderTab = (tab: MobileTabBarProps["tabs"][number]): ReactNode => {
// Controlled `activeId` wins when set; otherwise fall back to per-tab isActive.
const isActive = activeId != null ? tab.id === activeId : !!tab.isActive
const badgeCount = tab.badge ?? 0
const badgeText = badgeCount > 99 ? "99+" : String(badgeCount)
// When there's a badge, give the control an accessible name that includes the
// count (e.g. "Cart, 3") instead of a bare number floating in the icon.
const accessibleLabel = badgeCount > 0 ? `${tab.label}, ${badgeText}` : undefined

const icon = (
<span className={`${ICON_WRAP} ${tab.isActive ? "scale-110" : "scale-100"}`}>
{tab.isActive ? tab.icon.filled : tab.icon.outline}
<span className={`${ICON_WRAP} ${isActive ? "scale-110" : "scale-100"}`}>
{isActive ? tab.icon.filled : tab.icon.outline}
{badgeCount > 0 && (
<span
aria-hidden="true"
Expand All @@ -123,15 +134,19 @@ export const MobileTabBar = ({
</>
)

const tabClass = `${TAB_BASE} ${tab.isActive ? TAB_ACTIVE : ""}`
const tabClass = `${TAB_BASE} ${isActive ? TAB_ACTIVE : ""}`

if (tab.onClick) {
const onClick = tab.onClick
return (
<button
key={tab.id}
type="button"
onClick={tab.onClick}
aria-pressed={tab.isActive}
onClick={() => {
if (haptics) vibrate()
onClick()
}}
aria-pressed={isActive}
aria-label={accessibleLabel}
className={tabClass}
>
Expand All @@ -146,8 +161,10 @@ export const MobileTabBar = ({
href: tab.href as string,
children: inner,
className: tabClass,
"aria-current": tab.isActive ? "page" : undefined,
"aria-current": isActive ? "page" : undefined,
"aria-label": accessibleLabel,
// Reinforce the tap; navigation still proceeds (no preventDefault).
onClick: haptics ? vibrate : undefined,
})}
</Fragment>
)
Expand Down
19 changes: 17 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export type TabLinkProps = {
"aria-current"?: "page" | undefined
/** Accessible name override (used to fold a badge count into the link name). */
"aria-label"?: string
/** Present only when `haptics` is enabled; fires the tap pulse. */
onClick?: () => void
[key: string]: unknown
}

Expand All @@ -16,7 +18,8 @@ export type TabItem =
id: string
label: string
icon: { outline: ReactNode; filled: ReactNode }
isActive: boolean
/** Active state. Optional — omit and use the `activeId` prop instead. */
isActive?: boolean
badge?: number
href: string
onClick?: never
Expand All @@ -25,7 +28,8 @@ export type TabItem =
id: string
label: string
icon: { outline: ReactNode; filled: ReactNode }
isActive: boolean
/** Active state. Optional — omit and use the `activeId` prop instead. */
isActive?: boolean
badge?: number
href?: never
onClick: () => void
Expand Down Expand Up @@ -60,6 +64,17 @@ export type MobileTabBarProps = {
* - `"ltr"` / `"rtl"`: force a direction regardless of the document.
*/
dir?: "ltr" | "rtl" | "auto"
/**
* Controlled active tab. When set, the tab whose `id` matches is active and
* each tab's own `isActive` is ignored — so you can drop the per-tab booleans.
* Omit to keep using per-tab `isActive`.
*/
activeId?: string
/**
* Fire a short `navigator.vibrate(10)` on tap where supported (Android Chrome
* et al.). No-op on unsupported platforms. Defaults to `false`.
*/
haptics?: boolean
/** Extra class names on the outer <nav> wrapper */
className?: string
}
Loading