basalt-ui 1.18.0 → 1.19.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/AGENTS.md +1 -1
- package/CHANGELOG.md +7 -0
- package/agent/rules/basalt-mantine.md +71 -6
- package/agent/rules/basalt-router.md +129 -30
- package/agent/rules/basalt-tokens.md +2 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/nav/types.d.ts +110 -0
- package/dist/nav/types.d.ts.map +1 -0
- package/dist/nav/types.js +0 -0
- package/dist/router-tanstack/index.d.ts +9 -2
- package/dist/router-tanstack/index.d.ts.map +1 -1
- package/dist/router-tanstack/index.js +7 -0
- package/dist/router-tanstack/nav.d.ts +163 -0
- package/dist/router-tanstack/nav.d.ts.map +1 -0
- package/dist/router-tanstack/nav.js +38 -0
- package/dist/router-tanstack/nav.test.d.ts +2 -0
- package/dist/router-tanstack/nav.test.d.ts.map +1 -0
- package/dist/router-tanstack/search-param-store.d.ts +19 -9
- package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/use-nav.d.ts +30 -0
- package/dist/router-tanstack/use-nav.d.ts.map +1 -0
- package/dist/router-tanstack/use-nav.js +71 -0
- package/dist/shell/app-breadcrumbs.d.ts +7 -9
- package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
- package/dist/shell/app-breadcrumbs.js +8 -4
- package/dist/shell/app-mobile-nav.d.ts +23 -24
- package/dist/shell/app-mobile-nav.d.ts.map +1 -1
- package/dist/shell/app-mobile-nav.js +290 -68
- package/dist/shell/app-mobile-nav.module.css +166 -11
- package/dist/shell/app-mobile-nav.module.css.d.ts +11 -2
- package/dist/shell/app-mobile-nav.test.d.ts +2 -0
- package/dist/shell/app-mobile-nav.test.d.ts.map +1 -0
- package/dist/shell/app-sidebar.d.ts +3 -28
- package/dist/shell/app-sidebar.d.ts.map +1 -1
- package/dist/shell/app-sidebar.js +40 -174
- package/dist/shell/app-sidebar.module.css +3 -2
- package/dist/shell/app-sidebar.module.css.d.ts +0 -2
- package/dist/shell/index.d.ts +22 -65
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +38 -76
- package/dist/shell/mobile-nav-model.d.ts +41 -0
- package/dist/shell/mobile-nav-model.d.ts.map +1 -0
- package/dist/shell/mobile-nav-model.js +244 -0
- package/dist/shell/mobile-nav-model.test.d.ts +2 -0
- package/dist/shell/mobile-nav-model.test.d.ts.map +1 -0
- package/dist/shell/nav-count-badge.d.ts +11 -0
- package/dist/shell/nav-count-badge.d.ts.map +1 -0
- package/dist/shell/nav-count-badge.js +29 -0
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.js +1 -1
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +8 -7
- package/dist/theme/nav-link.module.css +4 -2
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +2 -0
- package/dist/tokens/palette.d.ts +15 -0
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +13 -0
- package/dist/tokens.css +2 -0
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/index.ts +15 -5
- package/src/nav/types.ts +115 -0
- package/src/router-tanstack/index.ts +21 -2
- package/src/router-tanstack/nav.test.ts +111 -0
- package/src/router-tanstack/nav.ts +205 -0
- package/src/router-tanstack/search-param-store.ts +19 -9
- package/src/router-tanstack/use-nav.ts +146 -0
- package/src/shell/app-breadcrumbs.tsx +21 -19
- package/src/shell/app-mobile-nav.module.css +166 -11
- package/src/shell/app-mobile-nav.module.css.d.ts +11 -2
- package/src/shell/app-mobile-nav.test.tsx +369 -0
- package/src/shell/app-mobile-nav.tsx +436 -102
- package/src/shell/app-sidebar.module.css +3 -2
- package/src/shell/app-sidebar.module.css.d.ts +0 -2
- package/src/shell/app-sidebar.test.tsx +6 -10
- package/src/shell/app-sidebar.tsx +49 -206
- package/src/shell/index.test.tsx +62 -7
- package/src/shell/index.tsx +105 -164
- package/src/shell/mobile-nav-model.test.ts +296 -0
- package/src/shell/mobile-nav-model.ts +389 -0
- package/src/shell/nav-count-badge.tsx +39 -0
- package/src/surfaces.ts +1 -1
- package/src/theme/density-relations.test.ts +19 -1
- package/src/theme/index.ts +8 -7
- package/src/theme/nav-link.module.css +4 -2
- package/src/theme/spacing.test.ts +11 -0
- package/src/tokens/density.test.ts +2 -0
- package/src/tokens/index.ts +8 -5
- package/src/tokens/palette.ts +16 -0
|
@@ -49,16 +49,26 @@
|
|
|
49
49
|
* return <Charts data={data} range={range} />
|
|
50
50
|
* }
|
|
51
51
|
*
|
|
52
|
-
* **5. Nav links** — carry the param across sub-pages
|
|
53
|
-
*
|
|
52
|
+
* **5. Nav links** — carry the param across sub-pages, set per destination in
|
|
53
|
+
* the nav definition so only the dashboard sub-tree inherits it and every
|
|
54
|
+
* other link stays clean. Use a CLICK-TIME THUNK over `readStored`:
|
|
54
55
|
*
|
|
55
|
-
* const
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* {
|
|
60
|
-
*
|
|
61
|
-
*
|
|
56
|
+
* const dashSearch = () => ({ range: dashboardRange.readStored() ?? '30d' })
|
|
57
|
+
*
|
|
58
|
+
* navGroup({ id: 'dash', label: 'Dashboard' }, [
|
|
59
|
+
* { id: 'dash-overview', label: 'Overview', icon: <IconHome />,
|
|
60
|
+
* link: linkOptions({ to: '/dashboard', search: dashSearch }) },
|
|
61
|
+
* { id: 'dash-activity', label: 'Activity', icon: <IconPulse />,
|
|
62
|
+
* link: linkOptions({ to: '/dashboard/activity', search: dashSearch }) },
|
|
63
|
+
* ])
|
|
64
|
+
*
|
|
65
|
+
* NOT `search: true`. That flag means "keep the current search" and TanStack
|
|
66
|
+
* only offers it where the target's search is OPTIONAL — a route wired to a
|
|
67
|
+
* store's `validateSearch` always returns the param, so the router requires a
|
|
68
|
+
* value and `search: true` is a type error. The thunk is also the better
|
|
69
|
+
* behaviour: `<Link>` re-evaluates it at click time, so arriving from OUTSIDE
|
|
70
|
+
* the sub-tree restores the last selection instead of landing on the factory
|
|
71
|
+
* default, and a definition evaluated once at module scope never goes stale.
|
|
62
72
|
*/
|
|
63
73
|
|
|
64
74
|
import { createPersistedState, readPersistedValue } from '../state'
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useNav` — resolve a `defineNav` definition into the two props `BasaltShell` needs.
|
|
3
|
+
*
|
|
4
|
+
* One `defineNav([...])` in a leaf module produces the desktop sidebar AND the mobile bar: this
|
|
5
|
+
* hook resolves active state through the router and builds each destination's anchor, which is
|
|
6
|
+
* what deletes a consumer's `renderNavLink` / `renderBreadcrumbLink` / `NAV_TARGETS` table and
|
|
7
|
+
* every hand-written `useMatchRoute` call.
|
|
8
|
+
*
|
|
9
|
+
* No JSX — anchors are built with `createElement`, so `./router-tanstack` stays a headless,
|
|
10
|
+
* no-JSX `.ts` module per its own barrel contract.
|
|
11
|
+
*/
|
|
12
|
+
import { createElement, useMemo } from 'react'
|
|
13
|
+
import type { ElementType, ReactNode } from 'react'
|
|
14
|
+
import { Link, useMatchRoute, useRouterState } from '@tanstack/react-router'
|
|
15
|
+
import type { MobileNavConfig, NavAnchor, SidebarItem, SidebarSection } from '../nav/types'
|
|
16
|
+
import type { AnyNavGroup, AnyNavItem, NavConfig, NavItemId, NavTabId } from './nav'
|
|
17
|
+
|
|
18
|
+
export type UseNavOptions<G extends ReadonlyArray<AnyNavGroup>> = {
|
|
19
|
+
/**
|
|
20
|
+
* Live per-destination badges, keys typed to the definition's id union.
|
|
21
|
+
* A `number` becomes `item.count` (desktop `NavCountBadge` + mobile dot).
|
|
22
|
+
* Anything else becomes `item.badge` (desktop only).
|
|
23
|
+
*/
|
|
24
|
+
badges?: Partial<Record<NavItemId<G>, number | ReactNode>>
|
|
25
|
+
/** Override active detection entirely. */
|
|
26
|
+
isActive?: (item: AnyNavItem) => boolean
|
|
27
|
+
/** Passed straight through into the returned `mobileNav`. */
|
|
28
|
+
moreExtra?: ReactNode
|
|
29
|
+
getScrollElement?: () => HTMLElement | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Spread straight onto `<BasaltShell {...nav} />` — both keys are `BasaltShellProps` keys. */
|
|
33
|
+
export type NavBinding = { sections: SidebarSection[]; mobileNav: MobileNavConfig }
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve a nav definition against the live router.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* const nav = useNav(NAV, { badges: { calendar: 3 } })
|
|
40
|
+
* return <BasaltShell brand={brand} {...nav}>{children}</BasaltShell>
|
|
41
|
+
*/
|
|
42
|
+
export function useNav<
|
|
43
|
+
const G extends ReadonlyArray<AnyNavGroup>,
|
|
44
|
+
const T extends ReadonlyArray<NavTabId<G>>,
|
|
45
|
+
>(config: NavConfig<G, T>, opts?: UseNavOptions<G>): NavBinding {
|
|
46
|
+
// `useMatchRoute` is `useCallback(fn, [router])` — a STABLE identity across renders — and it
|
|
47
|
+
// subscribes internally to `router.stores.matchRouteDeps`, so the component still re-renders on
|
|
48
|
+
// navigation. That makes it safe as a memo dep, and makes the memo below actually hit.
|
|
49
|
+
const matchRoute = useMatchRoute()
|
|
50
|
+
// The change signal: one string that moves on every navigation, including search-param-only ones.
|
|
51
|
+
const href = useRouterState({ select: (s) => s.location.href })
|
|
52
|
+
|
|
53
|
+
const badges = opts?.badges
|
|
54
|
+
const isActiveOverride = opts?.isActive
|
|
55
|
+
|
|
56
|
+
const sections = useMemo<SidebarSection[]>(() => {
|
|
57
|
+
const badgeOf = (id: string): number | ReactNode =>
|
|
58
|
+
(badges as Record<string, number | ReactNode> | undefined)?.[id]
|
|
59
|
+
|
|
60
|
+
const isActive = (item: AnyNavItem): boolean =>
|
|
61
|
+
isActiveOverride?.(item) ??
|
|
62
|
+
Boolean(
|
|
63
|
+
matchRoute({
|
|
64
|
+
to: item.link.to,
|
|
65
|
+
...(item.link.params !== undefined && { params: item.link.params }),
|
|
66
|
+
// Prefix match by default so a parent stays lit on its children; `'/'` would then match
|
|
67
|
+
// everything, so it is always exact.
|
|
68
|
+
fuzzy: item.exact !== true && item.link.to !== '/',
|
|
69
|
+
}),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const toSidebarItem = (item: AnyNavItem): SidebarItem => {
|
|
73
|
+
// One anchor per destination. Basalt renders every pixel of chrome around it (desktop row,
|
|
74
|
+
// 56px slot, 44px sheet row) and only hosts this component, so `preload`, middle-click and
|
|
75
|
+
// back/forward all keep working. `Link as ElementType` is the single cast in the whole
|
|
76
|
+
// chain: the widened `AnyNavLink['search']` is `unknown`, which `LinkComponentProps` rejects.
|
|
77
|
+
// The consumer gets zero casts, which is the point.
|
|
78
|
+
const Anchor: NavAnchor = (props) =>
|
|
79
|
+
createElement(Link as ElementType, { ...item.link, ...props })
|
|
80
|
+
|
|
81
|
+
const badge = badgeOf(item.id)
|
|
82
|
+
const children = item.children?.map(toSidebarItem)
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
key: item.id,
|
|
86
|
+
label: item.label,
|
|
87
|
+
icon: item.icon,
|
|
88
|
+
Anchor,
|
|
89
|
+
// Per-destination active only. Rolling a child's active state up into its parent is the
|
|
90
|
+
// MOBILE SLOT's rule (§2.3.12) and belongs to `projectMobileNav`, which reads `children`
|
|
91
|
+
// anyway — doing it here too would also light a desktop parent row whose own route does
|
|
92
|
+
// not match.
|
|
93
|
+
active: isActive(item),
|
|
94
|
+
...(item.short !== undefined && { short: item.short }),
|
|
95
|
+
...(item.mobile !== undefined && { mobile: item.mobile }),
|
|
96
|
+
...(item.disabled !== undefined && { disabled: item.disabled }),
|
|
97
|
+
...(typeof badge === 'number'
|
|
98
|
+
? { count: badge }
|
|
99
|
+
: badge === undefined || badge === null
|
|
100
|
+
? {}
|
|
101
|
+
: { badge }),
|
|
102
|
+
...(children !== undefined && { children }),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return config.groups.map((group) => ({
|
|
107
|
+
label: group.label,
|
|
108
|
+
items: group.items.map(toSidebarItem),
|
|
109
|
+
...(group.icon !== undefined && { icon: group.icon }),
|
|
110
|
+
...(group.collapsible !== undefined && { collapsible: group.collapsible }),
|
|
111
|
+
...(group.defaultCollapsed !== undefined && { defaultCollapsed: group.defaultCollapsed }),
|
|
112
|
+
...(group.mobile !== undefined && { mobile: group.mobile }),
|
|
113
|
+
}))
|
|
114
|
+
// `href` is deliberately an unread dep, and the reason the memo is correct: `matchRoute` has a
|
|
115
|
+
// STABLE identity (`useCallback(fn, [router])`), so nothing else in this list moves on a
|
|
116
|
+
// navigation — while the `matchRoute` calls above read router state that just changed. `href`
|
|
117
|
+
// is the one value that always does move, so it is what re-derives `active`.
|
|
118
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
119
|
+
}, [config, matchRoute, href, badges, isActiveOverride])
|
|
120
|
+
|
|
121
|
+
const mobile = config.mobile
|
|
122
|
+
const moreExtra = opts?.moreExtra
|
|
123
|
+
const getScrollElement = opts?.getScrollElement
|
|
124
|
+
|
|
125
|
+
const mobileNav = useMemo<MobileNavConfig>(() => {
|
|
126
|
+
// `MobileNavConfig.tabs` names SIDEBAR keys, and a `SidebarSection` is identified by its
|
|
127
|
+
// `label` (it carries no key). A group id in `mobile.tabs` is therefore translated to that
|
|
128
|
+
// group's label here; item ids pass through verbatim because `SidebarItem.key` IS the id.
|
|
129
|
+
const groupLabelById = new Map(config.groups.map((g) => [g.id, g.label]))
|
|
130
|
+
const tabs = mobile?.tabs?.map((id) => {
|
|
131
|
+
const key = id as string
|
|
132
|
+
return groupLabelById.get(key) ?? key
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
...(tabs !== undefined && { tabs }),
|
|
137
|
+
...(mobile?.maxTabs !== undefined && { maxTabs: mobile.maxTabs }),
|
|
138
|
+
...(mobile?.menuMax !== undefined && { menuMax: mobile.menuMax }),
|
|
139
|
+
...(mobile?.moreLabel !== undefined && { moreLabel: mobile.moreLabel }),
|
|
140
|
+
...(moreExtra !== undefined && { moreExtra }),
|
|
141
|
+
...(getScrollElement !== undefined && { getScrollElement }),
|
|
142
|
+
}
|
|
143
|
+
}, [config, mobile, moreExtra, getScrollElement])
|
|
144
|
+
|
|
145
|
+
return { sections, mobileNav }
|
|
146
|
+
}
|
|
@@ -11,11 +11,10 @@
|
|
|
11
11
|
* a consumer's bundler.
|
|
12
12
|
*/
|
|
13
13
|
import { Anchor, Group, Text } from '@mantine/core'
|
|
14
|
-
import type { CSSProperties
|
|
14
|
+
import type { CSSProperties } from 'react'
|
|
15
|
+
import type { NavAnchor } from '../nav/types'
|
|
15
16
|
import { VX } from '../tokens'
|
|
16
17
|
|
|
17
|
-
export type BreadcrumbLinkRenderer = (href: string, label: string) => ReactNode
|
|
18
|
-
|
|
19
18
|
/** Parent/section crumbs — faint. */
|
|
20
19
|
const crumbStyle: CSSProperties = { fontSize: VX.text.md, color: 'var(--vx-faint)' }
|
|
21
20
|
|
|
@@ -34,21 +33,20 @@ const currentStyle: CSSProperties = {
|
|
|
34
33
|
export function AppBreadcrumbs({
|
|
35
34
|
section,
|
|
36
35
|
parent,
|
|
36
|
+
parentAnchor,
|
|
37
37
|
parentHref,
|
|
38
|
-
renderBreadcrumbLink,
|
|
39
38
|
page,
|
|
40
39
|
}: {
|
|
41
40
|
section?: string
|
|
42
41
|
/** Parent item label — shown when the active page is a nested child (e.g. "Dashboard"). */
|
|
43
42
|
parent?: string | undefined
|
|
44
|
-
/** Parent item href — when provided, the parent label renders as a clickable link. */
|
|
45
|
-
parentHref?: string | undefined
|
|
46
43
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* navigation (e.g. TanStack `<Link>`).
|
|
44
|
+
* The parent destination's router anchor (`SidebarItem.Anchor`), so the crumb navigates
|
|
45
|
+
* client-side. basalt still owns every pixel of the crumb — the anchor only hosts the label.
|
|
50
46
|
*/
|
|
51
|
-
|
|
47
|
+
parentAnchor?: NavAnchor | undefined
|
|
48
|
+
/** Parent item href — the no-router fallback; ignored when `parentAnchor` is present. */
|
|
49
|
+
parentHref?: string | undefined
|
|
52
50
|
page?: string
|
|
53
51
|
}) {
|
|
54
52
|
if (!page) return null
|
|
@@ -62,19 +60,23 @@ export function AppBreadcrumbs({
|
|
|
62
60
|
<Text style={separatorStyle}>/</Text>
|
|
63
61
|
</>
|
|
64
62
|
)}
|
|
65
|
-
{parent &&
|
|
63
|
+
{parent && parentAnchor && (
|
|
66
64
|
<>
|
|
67
|
-
{
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
65
|
+
<Anchor style={crumbStyle} underline="never" component={parentAnchor} truncate>
|
|
66
|
+
{parent}
|
|
67
|
+
</Anchor>
|
|
68
|
+
<Text style={separatorStyle}>/</Text>
|
|
69
|
+
</>
|
|
70
|
+
)}
|
|
71
|
+
{parent && !parentAnchor && parentHref && (
|
|
72
|
+
<>
|
|
73
|
+
<Anchor style={crumbStyle} underline="never" href={parentHref} truncate>
|
|
74
|
+
{parent}
|
|
75
|
+
</Anchor>
|
|
74
76
|
<Text style={separatorStyle}>/</Text>
|
|
75
77
|
</>
|
|
76
78
|
)}
|
|
77
|
-
{parent && !parentHref && (
|
|
79
|
+
{parent && !parentAnchor && !parentHref && (
|
|
78
80
|
<>
|
|
79
81
|
<Text style={crumbStyle} truncate>
|
|
80
82
|
{parent}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The mobile bottom bar. Slots are DESTINATIONS: a tap navigates, so the bar must read as a tab
|
|
3
|
+
* bar rather than as chrome — hence a pill behind the icon for the active slot instead of a
|
|
4
|
+
* full-tab fill, and a press that depresses the icon without moving the hit box.
|
|
5
|
+
*/
|
|
1
6
|
.bar {
|
|
2
7
|
display: flex;
|
|
3
8
|
align-items: stretch;
|
|
@@ -5,6 +10,21 @@
|
|
|
5
10
|
height: 100%;
|
|
6
11
|
background: var(--mantine-color-body);
|
|
7
12
|
border-top: 1px solid var(--mantine-color-default-border);
|
|
13
|
+
-webkit-tap-highlight-color: transparent;
|
|
14
|
+
/* NO padding-bottom / env() here: Mantine's own AppShell `.footer` rule already does
|
|
15
|
+
* `height: calc(var(--app-shell-footer-height) + env(safe-area-inset-bottom))` +
|
|
16
|
+
* `padding-bottom: env(safe-area-inset-bottom)`. Adding it here double-counts the inset. */
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* Mantine sets --app-shell-footer-OFFSET to the raw height WITHOUT the inset, so Main's
|
|
20
|
+
* padding-bottom is short by exactly one inset. This is the only real safe-area gap. */
|
|
21
|
+
@media (max-width: 47.99375em) {
|
|
22
|
+
.mainSafeArea {
|
|
23
|
+
padding-bottom: calc(
|
|
24
|
+
var(--app-shell-footer-offset, 0rem) + var(--app-shell-padding) +
|
|
25
|
+
env(safe-area-inset-bottom, 0px)
|
|
26
|
+
);
|
|
27
|
+
}
|
|
8
28
|
}
|
|
9
29
|
|
|
10
30
|
.tab {
|
|
@@ -16,18 +36,127 @@
|
|
|
16
36
|
justify-content: center;
|
|
17
37
|
gap: var(--vx-space-mobile-nav-tab-gap);
|
|
18
38
|
color: var(--mantine-color-dimmed);
|
|
39
|
+
/* Kills the 300ms double-tap-zoom delay and the text-selection flash on a fast repeat tap. */
|
|
40
|
+
touch-action: manipulation;
|
|
41
|
+
user-select: none;
|
|
19
42
|
}
|
|
20
43
|
|
|
21
|
-
|
|
44
|
+
.tabIcon {
|
|
45
|
+
position: relative;
|
|
46
|
+
display: inline-flex;
|
|
47
|
+
align-items: center;
|
|
48
|
+
justify-content: center;
|
|
49
|
+
/* Deliberately sub-scale: the active pill hugs a 24px glyph, so it is a shape inset rather than
|
|
50
|
+
* layout spacing — the xs..xl scale has no step this tight. */
|
|
51
|
+
padding: 2px 12px; /* theme-allow */
|
|
52
|
+
border-radius: var(--vx-radius-ctrl);
|
|
53
|
+
transition: transform 140ms cubic-bezier(0.32, 0.72, 0, 1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* A consumer's sidebar icons are typically 18px; the bar normalizes them in CSS so the same
|
|
57
|
+
* `SidebarItem.icon` renders at slot size with zero consumer change. */
|
|
58
|
+
.tabIcon > svg {
|
|
59
|
+
width: var(--vx-space-mobile-nav-icon-size);
|
|
60
|
+
height: var(--vx-space-mobile-nav-icon-size);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.tab:active .tabIcon {
|
|
64
|
+
transform: scale(0.9);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* Active = a neutral pill behind the ICON only, plus plain text color. A full-tab fill is what
|
|
68
|
+
* makes the bar read as chrome instead of a tab bar. Never the identity blue (DESIGN.md). */
|
|
22
69
|
.tab[data-active] {
|
|
23
70
|
color: var(--mantine-color-text);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.tab[data-active] .tabIcon {
|
|
24
74
|
background-color: color-mix(in srgb, var(--vx-ink) 10%, transparent);
|
|
25
75
|
}
|
|
26
76
|
|
|
77
|
+
.tab[data-active] .label {
|
|
78
|
+
font-weight: 600;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* Unread dot. This is a BADGE, not the active state — the "never the identity blue" rule above
|
|
82
|
+
* governs the active fill and is unchanged. A count GLYPH is unreadable in a 56px slot. */
|
|
83
|
+
.tabDot {
|
|
84
|
+
position: absolute;
|
|
85
|
+
top: 0;
|
|
86
|
+
inset-inline-end: 8px;
|
|
87
|
+
width: 8px;
|
|
88
|
+
height: 8px;
|
|
89
|
+
border-radius: 999px;
|
|
90
|
+
background-color: var(--vx-accent);
|
|
91
|
+
box-shadow: 0 0 0 2px var(--mantine-color-body);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* `text-overflow: clip`, never an ellipsis — a truncated word with a "…" in a 72px tab reads as
|
|
95
|
+
* broken rather than as abbreviated. `SidebarItem.short` is the fix, not the ellipsis. */
|
|
27
96
|
.label {
|
|
28
97
|
font-size: var(--vx-text-micro);
|
|
29
98
|
line-height: 1;
|
|
30
99
|
font-weight: 500;
|
|
100
|
+
max-width: 100%;
|
|
101
|
+
overflow: hidden;
|
|
102
|
+
text-overflow: clip;
|
|
103
|
+
white-space: nowrap;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* The menu pops UP out of its tab and is configured `flip: false` — flipping a bottom-anchored
|
|
107
|
+
* menu would put it under the footer. `menuMax` is what guarantees it fits (§2.2). */
|
|
108
|
+
.menuDropdown {
|
|
109
|
+
padding: 4px;
|
|
110
|
+
max-height: min(60dvh, 360px);
|
|
111
|
+
overflow-y: auto;
|
|
112
|
+
overscroll-behavior: contain;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* The theme's `Menu.extend` pins `styles.item` padding + font-size to the dense DESKTOP values
|
|
116
|
+
* INLINE, so a touch-sized mobile row needs `!important` — same cascade problem, same reason, as
|
|
117
|
+
* `.row` below. */
|
|
118
|
+
.menuItem {
|
|
119
|
+
min-height: var(--vx-space-mobile-nav-row-height) !important;
|
|
120
|
+
align-items: center;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.menuItem :global(.mantine-Menu-itemLabel) {
|
|
124
|
+
font-size: var(--mantine-font-size-md) !important;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* An active destination reads the same in the More surface as it does in the sheet. */
|
|
128
|
+
.menuItem[aria-current='page'] {
|
|
129
|
+
background-color: color-mix(in srgb, var(--vx-ink) 13%, transparent);
|
|
130
|
+
font-weight: 600;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/*
|
|
134
|
+
* A disabled destination still RENDERS in the menu (§2.3 rule 11) — it just must not be reachable.
|
|
135
|
+
* Mantine's `Menu.css` styles `[data-disabled]` with a dimmed color and nothing else, and the row
|
|
136
|
+
* is polymorphic (`component="a"`), so the native `disabled` attribute does not apply either: with
|
|
137
|
+
* no rule here the row stays fully clickable. `pointer-events: none` is the real guard; `menuRow`
|
|
138
|
+
* carries a matching handler guard for the paths pointers do not cover (keyboard, synthetic
|
|
139
|
+
* clicks, a consumer stylesheet that resets this).
|
|
140
|
+
*/
|
|
141
|
+
.menuItem[data-disabled] {
|
|
142
|
+
pointer-events: none;
|
|
143
|
+
cursor: not-allowed;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* Nesting survives to mobile — a child destination is an indented row, not a sibling of its own
|
|
147
|
+
* parent (which is what today's flattened projection makes it). */
|
|
148
|
+
.menuItemNested {
|
|
149
|
+
padding-inline-start: calc(var(--mantine-spacing-md) + 12px) !important;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* Overflow section heading inside the menu — inherits the theme's mono micro-label. */
|
|
153
|
+
.menuLabel {
|
|
154
|
+
font-family: var(--basalt-font-mono);
|
|
155
|
+
font-size: var(--vx-text-micro);
|
|
156
|
+
font-weight: 500;
|
|
157
|
+
text-transform: uppercase;
|
|
158
|
+
letter-spacing: 0.08em;
|
|
159
|
+
color: var(--vx-faint);
|
|
31
160
|
}
|
|
32
161
|
|
|
33
162
|
/*
|
|
@@ -39,6 +168,8 @@
|
|
|
39
168
|
box-shadow: var(--vx-shadow-card);
|
|
40
169
|
border-top-left-radius: var(--mantine-radius-lg);
|
|
41
170
|
border-top-right-radius: var(--mantine-radius-lg);
|
|
171
|
+
/* A Drawer is NOT an AppShell section, so Mantine adds no inset of its own here. */
|
|
172
|
+
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
42
173
|
}
|
|
43
174
|
|
|
44
175
|
/* Sheet title = a micro-label (docs/DESIGN-SPEC.md §3), matching the sidebar's `.sectionLabel`. */
|
|
@@ -51,7 +182,17 @@
|
|
|
51
182
|
color: var(--vx-faint);
|
|
52
183
|
}
|
|
53
184
|
|
|
54
|
-
/*
|
|
185
|
+
/* The dismiss affordance. There is no swipe-to-dismiss: Mantine ships no drag primitive and
|
|
186
|
+
* writing `transform` to Drawer.Content fights its own Transition on the same node. */
|
|
187
|
+
.grabber {
|
|
188
|
+
width: 36px;
|
|
189
|
+
height: 4px;
|
|
190
|
+
margin: 0 auto 8px;
|
|
191
|
+
border-radius: 999px;
|
|
192
|
+
background-color: color-mix(in srgb, var(--vx-ink) 18%, transparent);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/* Large tap rows inside the sheet. NavLink rows reuse the sidebar's neutral active fill.
|
|
55
196
|
* `padding` needs `!important`: the theme's `NavLink.extend` forces the dense desktop row padding
|
|
56
197
|
* (6px 10px) INLINE on every NavLink root, which would shrink these touch targets otherwise. */
|
|
57
198
|
.row {
|
|
@@ -59,13 +200,23 @@
|
|
|
59
200
|
align-items: center;
|
|
60
201
|
gap: var(--mantine-spacing-sm);
|
|
61
202
|
width: 100%;
|
|
203
|
+
min-height: var(--vx-space-mobile-nav-row-height);
|
|
62
204
|
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md) !important;
|
|
63
205
|
border-radius: var(--mantine-radius-sm);
|
|
64
206
|
color: var(--mantine-color-text);
|
|
207
|
+
touch-action: manipulation;
|
|
208
|
+
user-select: none;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* Hover must be gated: on touch an ungated :hover leaves the last-tapped row stuck lit. */
|
|
212
|
+
@media (hover: hover) {
|
|
213
|
+
.row:hover {
|
|
214
|
+
background-color: color-mix(in srgb, var(--vx-ink) 8%, transparent);
|
|
215
|
+
}
|
|
65
216
|
}
|
|
66
217
|
|
|
67
|
-
.row:
|
|
68
|
-
background-color: color-mix(in srgb, var(--vx-ink)
|
|
218
|
+
.row:active {
|
|
219
|
+
background-color: color-mix(in srgb, var(--vx-ink) 10%, transparent);
|
|
69
220
|
}
|
|
70
221
|
|
|
71
222
|
/*
|
|
@@ -76,8 +227,7 @@
|
|
|
76
227
|
* `classNames` are always injected, so the icon needs its own `!important` override here to hold
|
|
77
228
|
* the mobile-only "never accent" doctrine.
|
|
78
229
|
*/
|
|
79
|
-
.row[data-active]
|
|
80
|
-
.row[data-active]:hover {
|
|
230
|
+
.row[data-active] {
|
|
81
231
|
--nl-color: var(--mantine-color-text);
|
|
82
232
|
color: var(--mantine-color-text);
|
|
83
233
|
background-color: color-mix(in srgb, var(--vx-ink) 13%, transparent);
|
|
@@ -88,13 +238,18 @@
|
|
|
88
238
|
color: var(--mantine-color-text) !important;
|
|
89
239
|
}
|
|
90
240
|
|
|
91
|
-
.
|
|
92
|
-
|
|
93
|
-
line-height: 1.2;
|
|
241
|
+
.rowNested {
|
|
242
|
+
padding-inline-start: calc(var(--mantine-spacing-md) + 12px) !important;
|
|
94
243
|
}
|
|
95
244
|
|
|
96
|
-
/*
|
|
97
|
-
* INLINE (`styles.label`), so `!important` is required to restore
|
|
245
|
+
/* Sheet rows read at the mobile body size — the theme pins the label at the dense desktop 13px
|
|
246
|
+
* INLINE (`styles.label`), so `!important` is required to restore it. */
|
|
98
247
|
.row :global(.mantine-NavLink-label) {
|
|
99
248
|
font-size: var(--mantine-font-size-md) !important;
|
|
100
249
|
}
|
|
250
|
+
|
|
251
|
+
@media (prefers-reduced-motion: reduce) {
|
|
252
|
+
.tabIcon {
|
|
253
|
+
transition: none;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Typed CSS-module declaration (the S4 CSS-modules path). Named string props — not an index
|
|
3
|
-
* signature — so dot access stays verbatim under the package's strict flags.
|
|
3
|
+
* signature — so dot access stays verbatim under the package's strict flags. HAND-MAINTAINED:
|
|
4
|
+
* there is no generator, so a new class in `app-mobile-nav.module.css` must be added here too.
|
|
4
5
|
*/
|
|
5
6
|
declare const classes: {
|
|
6
7
|
readonly bar: string
|
|
8
|
+
readonly mainSafeArea: string
|
|
7
9
|
readonly tab: string
|
|
10
|
+
readonly tabIcon: string
|
|
11
|
+
readonly tabDot: string
|
|
8
12
|
readonly label: string
|
|
13
|
+
readonly menuDropdown: string
|
|
14
|
+
readonly menuItem: string
|
|
15
|
+
readonly menuItemNested: string
|
|
16
|
+
readonly menuLabel: string
|
|
9
17
|
readonly sheet: string
|
|
10
18
|
readonly sheetTitle: string
|
|
19
|
+
readonly grabber: string
|
|
11
20
|
readonly row: string
|
|
12
|
-
readonly
|
|
21
|
+
readonly rowNested: string
|
|
13
22
|
}
|
|
14
23
|
export default classes
|