@startsimpli/ui 0.4.50 → 0.4.52

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.
@@ -0,0 +1,192 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useState } from 'react'
5
+ import { ChevronDown } from 'lucide-react'
6
+ import { cn } from '../../lib/utils'
7
+
8
+ export interface GroupedNavLink {
9
+ href: string
10
+ label: string
11
+ icon?: React.ReactNode
12
+ /** Optional trailing slot (count pill, status dot…). */
13
+ badge?: React.ReactNode
14
+ }
15
+
16
+ export interface GroupedNavGroup {
17
+ label: string
18
+ icon?: React.ReactNode
19
+ items: GroupedNavLink[]
20
+ /** Start expanded (default true). Ignored while a child is active — then forced open. */
21
+ defaultOpen?: boolean
22
+ /** Show a chevron toggle (default true). false = a static section heading. */
23
+ collapsible?: boolean
24
+ }
25
+
26
+ /** A top-level link, or a named group of links. Discriminated by `items`. */
27
+ export type GroupedNavEntry = GroupedNavLink | GroupedNavGroup
28
+
29
+ export interface GroupedNavRenderLinkArgs {
30
+ link: GroupedNavLink
31
+ active: boolean
32
+ className: string
33
+ /** The pre-composed inner content (icon + label + badge). */
34
+ content: React.ReactNode
35
+ }
36
+
37
+ export interface GroupedNavProps {
38
+ items: GroupedNavEntry[]
39
+ /** The current path; drives active state. */
40
+ activeHref?: string
41
+ /** Override active matching (default: exact, or `activeHref` under `href/`). */
42
+ isActive?: (href: string, activeHref?: string) => boolean
43
+ /**
44
+ * Render a link — pass `next/link` here in a Next app. Default renders a plain
45
+ * `<a>` so the primitive stays framework-agnostic.
46
+ */
47
+ renderLink?: (args: GroupedNavRenderLinkArgs) => React.ReactNode
48
+ className?: string
49
+ }
50
+
51
+ function isGroup(entry: GroupedNavEntry): entry is GroupedNavGroup {
52
+ return Array.isArray((entry as GroupedNavGroup).items)
53
+ }
54
+
55
+ function defaultIsActive(href: string, activeHref?: string): boolean {
56
+ if (!activeHref) return false
57
+ if (href === '/') return activeHref === '/'
58
+ return activeHref === href || activeHref.startsWith(href + '/')
59
+ }
60
+
61
+ const defaultRenderLink = ({ link, active, className, content }: GroupedNavRenderLinkArgs) => (
62
+ <a href={link.href} className={className} aria-current={active ? 'page' : undefined}>
63
+ {content}
64
+ </a>
65
+ )
66
+
67
+ function linkClasses(active: boolean, indented: boolean) {
68
+ return cn(
69
+ 'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
70
+ indented && 'pl-9',
71
+ active
72
+ ? 'bg-primary-600 text-white'
73
+ : 'text-gray-700 hover:bg-gray-100 hover:text-gray-900',
74
+ )
75
+ }
76
+
77
+ /**
78
+ * A grouped sidebar navigation list: a mix of top-level links and collapsible
79
+ * groups (e.g. Dashboard + a "Content" group of the three content types). Purely
80
+ * the `<nav>` list — the app shell owns the logo, user footer, and chrome. Active
81
+ * state and link rendering are injectable so it drops into any router.
82
+ */
83
+ export function GroupedNav({
84
+ items,
85
+ activeHref,
86
+ isActive = defaultIsActive,
87
+ renderLink = defaultRenderLink,
88
+ className,
89
+ }: GroupedNavProps) {
90
+ return (
91
+ <nav className={cn('space-y-1', className)} aria-label="Primary">
92
+ {items.map((entry, idx) =>
93
+ isGroup(entry) ? (
94
+ <NavGroup
95
+ key={`group-${entry.label}-${idx}`}
96
+ group={entry}
97
+ activeHref={activeHref}
98
+ isActive={isActive}
99
+ renderLink={renderLink}
100
+ />
101
+ ) : (
102
+ <NavLinkRow
103
+ key={entry.href}
104
+ link={entry}
105
+ active={isActive(entry.href, activeHref)}
106
+ indented={false}
107
+ renderLink={renderLink}
108
+ />
109
+ ),
110
+ )}
111
+ </nav>
112
+ )
113
+ }
114
+
115
+ function NavLinkRow({
116
+ link,
117
+ active,
118
+ indented,
119
+ renderLink,
120
+ }: {
121
+ link: GroupedNavLink
122
+ active: boolean
123
+ indented: boolean
124
+ renderLink: NonNullable<GroupedNavProps['renderLink']>
125
+ }) {
126
+ const content = (
127
+ <>
128
+ {link.icon ? <span className="flex-shrink-0 w-5 h-5 flex items-center justify-center">{link.icon}</span> : null}
129
+ <span className="flex-1 truncate">{link.label}</span>
130
+ {link.badge ? <span className="flex-shrink-0">{link.badge}</span> : null}
131
+ </>
132
+ )
133
+ return <>{renderLink({ link, active, className: linkClasses(active, indented), content })}</>
134
+ }
135
+
136
+ function NavGroup({
137
+ group,
138
+ activeHref,
139
+ isActive,
140
+ renderLink,
141
+ }: {
142
+ group: GroupedNavGroup
143
+ activeHref?: string
144
+ isActive: NonNullable<GroupedNavProps['isActive']>
145
+ renderLink: NonNullable<GroupedNavProps['renderLink']>
146
+ }) {
147
+ const collapsible = group.collapsible ?? true
148
+ const hasActiveChild = group.items.some((l) => isActive(l.href, activeHref))
149
+ const [open, setOpen] = useState(group.defaultOpen ?? true)
150
+ const expanded = hasActiveChild || open
151
+
152
+ const links = expanded
153
+ ? group.items.map((link) => (
154
+ <NavLinkRow
155
+ key={link.href}
156
+ link={link}
157
+ active={isActive(link.href, activeHref)}
158
+ indented
159
+ renderLink={renderLink}
160
+ />
161
+ ))
162
+ : null
163
+
164
+ const heading = (
165
+ <span className="flex items-center gap-2 min-w-0">
166
+ {group.icon ? <span className="flex-shrink-0 w-5 h-5 flex items-center justify-center">{group.icon}</span> : null}
167
+ <span className="text-xs font-semibold text-gray-400 uppercase tracking-wider truncate">{group.label}</span>
168
+ </span>
169
+ )
170
+
171
+ return (
172
+ <div className="pt-3 first:pt-0">
173
+ {collapsible ? (
174
+ <button
175
+ type="button"
176
+ onClick={() => setOpen((o) => !o)}
177
+ aria-expanded={expanded}
178
+ className="w-full flex items-center justify-between gap-2 px-3 py-1.5 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500"
179
+ >
180
+ {heading}
181
+ <ChevronDown
182
+ className={cn('w-4 h-4 text-gray-400 transition-transform flex-shrink-0', expanded ? '' : '-rotate-90')}
183
+ aria-hidden="true"
184
+ />
185
+ </button>
186
+ ) : (
187
+ <div className="px-3 py-1.5">{heading}</div>
188
+ )}
189
+ {links && <div className="mt-1 space-y-1">{links}</div>}
190
+ </div>
191
+ )
192
+ }