@nordmanai/nif 0.2.4 → 0.2.8

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,532 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ComponentProps,
10
+ type CSSProperties,
11
+ type ReactElement,
12
+ type ReactNode,
13
+ } from 'react';
14
+ import { useRender } from '@base-ui/react/use-render';
15
+ import { nmClass } from '../../lib/nm-class.ts';
16
+ import {
17
+ NM_SIDEBAR_MOBILE_QUERY,
18
+ NM_SIDEBAR_PEEK_LEAVE_MS,
19
+ NM_SIDEBAR_SHORTCUT,
20
+ type NmSidebarButtonSize,
21
+ type NmSidebarButtonVariant,
22
+ type NmSidebarCollapsible,
23
+ type NmSidebarSide,
24
+ type NmSidebarState,
25
+ type NmSidebarVariant,
26
+ } from '../../config/nm-sidebar.ts';
27
+ import type { NmIconId } from '../../config/nm-icons.ts';
28
+ import { NmChip } from '../nm-chip/nm-chip.tsx';
29
+ import { NmDrawer, NmDrawerContent } from '../nm-drawer/nm-drawer.tsx';
30
+ import { NmIcon } from '../nm-icon/nm-icon.tsx';
31
+ import { NmInput } from '../nm-input/nm-input.tsx';
32
+ import { NmKbd } from '../nm-kbd/nm-kbd.tsx';
33
+ import { NmSeparator } from '../nm-separator/nm-separator.tsx';
34
+ import { NmSkeleton } from '../nm-skeleton/nm-skeleton.tsx';
35
+ import { NmSmallIconButton } from '../nm-small-icon-button/nm-small-icon-button.tsx';
36
+ import { NordmanIconButton } from '../nordman-icon-button/nordman-icon-button.tsx';
37
+ import { NmTooltip, NmTooltipContent, NmTooltipTrigger } from '../nm-tooltip/nm-tooltip.tsx';
38
+
39
+ /** Replace the element a part renders, as Base UI does: give it an `<a>` and the part is a link. */
40
+ type RenderProp = ReactElement | undefined;
41
+
42
+ type SidebarContextValue = {
43
+ state: NmSidebarState;
44
+ open: boolean;
45
+ setOpen: (open: boolean) => void;
46
+ openMobile: boolean;
47
+ setOpenMobile: (open: boolean) => void;
48
+ isMobile: boolean;
49
+ toggleSidebar: () => void;
50
+ /** The collapsed sidebar shown in full over the page while the pointer is on it or on the toggle. */
51
+ peek: boolean;
52
+ /** Starts a peek. Nothing happens when the sidebar is open, on a phone, or when peeking is off. */
53
+ peekIn: (event?: { clientX: number; clientY: number }) => void;
54
+ /** Ends a peek after a short grace, so the pointer can travel from the toggle into the sidebar. */
55
+ peekOut: () => void;
56
+ collapsible: NmSidebarCollapsible;
57
+ setCollapsible: (mode: NmSidebarCollapsible) => void;
58
+ };
59
+
60
+ const SidebarContext = createContext<SidebarContextValue | null>(null);
61
+
62
+ /** Where the pointer may rest without ending a peek: the sidebar, its toggle, a tooltip, or a menu opened from a row. */
63
+ const PEEK_KEEPS = '[data-slot="sidebar"], [data-slot="sidebar-trigger"], [data-slot="tooltip-content"], [role="menu"], [role="dialog"]';
64
+
65
+ /** The sidebar's state: expanded or collapsed, peeking, on a desktop or a phone, and the function that toggles it. */
66
+ export function useSidebar(): SidebarContextValue {
67
+ const context = useContext(SidebarContext);
68
+ if (!context) {
69
+ throw new Error('useSidebar must be used within a NmSidebarProvider.');
70
+ }
71
+ return context;
72
+ }
73
+
74
+ function useMediaQuery(query: string): boolean {
75
+ const [matches, setMatches] = useState(() => (typeof window === 'undefined' ? false : window.matchMedia(query).matches));
76
+ useEffect(() => {
77
+ const list = window.matchMedia(query);
78
+ const on = () => setMatches(list.matches);
79
+ on();
80
+ list.addEventListener('change', on);
81
+ return () => list.removeEventListener('change', on);
82
+ }, [query]);
83
+ return matches;
84
+ }
85
+
86
+ export type NmSidebarProviderProps = ComponentProps<'div'> & {
87
+ defaultOpen?: boolean;
88
+ open?: boolean;
89
+ onOpenChange?: (open: boolean) => void;
90
+ /** ⌘B / Ctrl+B toggles the sidebar. Off when several sidebars share a page. */
91
+ shortcut?: boolean;
92
+ /** On by default: resting the pointer on the toggle or on the collapsed sidebar shows it in full, over the page, until the pointer leaves. */
93
+ peek?: boolean;
94
+ /** `fill` takes the room it is given. `sm` and `lg` are fixed heights, for a sidebar shown inside a page. */
95
+ height?: 'fill' | 'sm' | 'lg';
96
+ };
97
+
98
+ /**
99
+ * Holds the open state and hands it to every part inside. Wrap the sidebar and the main area in it.
100
+ * The shadcn Sidebar composition, built from NIF primitives and styled by NIF tokens.
101
+ */
102
+ export function NmSidebarProvider({
103
+ defaultOpen = true,
104
+ open: openProp,
105
+ onOpenChange,
106
+ shortcut = true,
107
+ peek: peekEnabled = true,
108
+ height = 'fill',
109
+ className,
110
+ style,
111
+ children,
112
+ ...props
113
+ }: NmSidebarProviderProps): ReactNode {
114
+ const isMobile = useMediaQuery(NM_SIDEBAR_MOBILE_QUERY);
115
+ const [openMobile, setOpenMobile] = useState(false);
116
+ const [openState, setOpenState] = useState(defaultOpen);
117
+ const [peek, setPeek] = useState(false);
118
+ const [collapsible, setCollapsible] = useState<NmSidebarCollapsible>('offcanvas');
119
+ const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
120
+ const pointer = useRef<{ x: number; y: number } | null>(null);
121
+ const open = openProp ?? openState;
122
+ const setOpen = useCallback(
123
+ (next: boolean) => {
124
+ onOpenChange?.(next);
125
+ if (openProp == null) {
126
+ setOpenState(next);
127
+ }
128
+ setPeek(false);
129
+ },
130
+ [onOpenChange, openProp],
131
+ );
132
+ const toggleSidebar = useCallback(() => {
133
+ if (isMobile) {
134
+ setOpenMobile((value) => !value);
135
+ } else {
136
+ setOpen(!open);
137
+ }
138
+ }, [isMobile, open, setOpen]);
139
+ const clearLeave = () => {
140
+ if (leaveTimer.current != null) {
141
+ clearTimeout(leaveTimer.current);
142
+ leaveTimer.current = null;
143
+ }
144
+ };
145
+ const peekIn = useCallback(
146
+ (event?: { clientX: number; clientY: number }) => {
147
+ clearLeave();
148
+ if (event) {
149
+ pointer.current = { x: event.clientX, y: event.clientY };
150
+ }
151
+ if (peekEnabled && !open && !isMobile && collapsible !== 'none') {
152
+ setPeek(true);
153
+ }
154
+ },
155
+ [peekEnabled, open, isMobile, collapsible],
156
+ );
157
+ const pointerInside = () => {
158
+ const at = pointer.current;
159
+ if (at == null) {
160
+ return false;
161
+ }
162
+ return document.elementFromPoint(at.x, at.y)?.closest(PEEK_KEEPS) != null;
163
+ };
164
+ const peekOut = useCallback(() => {
165
+ clearLeave();
166
+ leaveTimer.current = setTimeout(() => {
167
+ leaveTimer.current = null;
168
+ if (!pointerInside()) {
169
+ setPeek(false);
170
+ }
171
+ }, NM_SIDEBAR_PEEK_LEAVE_MS);
172
+ }, []);
173
+ useEffect(() => clearLeave, []);
174
+ // While peeking, the pointer's position decides, not boundary events: the panel opening under a still pointer
175
+ // moves the toggle out from under it, and a menu opened from a row is portaled outside the sidebar.
176
+ useEffect(() => {
177
+ if (!peek) {
178
+ return;
179
+ }
180
+ const onMove = (event: PointerEvent) => {
181
+ pointer.current = { x: event.clientX, y: event.clientY };
182
+ if (pointerInside()) {
183
+ clearLeave();
184
+ } else if (leaveTimer.current == null) {
185
+ peekOut();
186
+ }
187
+ };
188
+ document.addEventListener('pointermove', onMove);
189
+ return () => document.removeEventListener('pointermove', onMove);
190
+ }, [peek, peekOut]);
191
+
192
+ useEffect(() => {
193
+ if (!shortcut) {
194
+ return;
195
+ }
196
+ const onKey = (event: KeyboardEvent) => {
197
+ if (event.key === NM_SIDEBAR_SHORTCUT && (event.metaKey || event.ctrlKey)) {
198
+ event.preventDefault();
199
+ toggleSidebar();
200
+ }
201
+ };
202
+ window.addEventListener('keydown', onKey);
203
+ return () => window.removeEventListener('keydown', onKey);
204
+ }, [shortcut, toggleSidebar]);
205
+
206
+ const state: NmSidebarState = open ? 'expanded' : 'collapsed';
207
+ const value = useMemo<SidebarContextValue>(
208
+ () => ({ state, open, setOpen, openMobile, setOpenMobile, isMobile, toggleSidebar, peek: peek && !open, peekIn, peekOut, collapsible, setCollapsible }),
209
+ [state, open, setOpen, openMobile, isMobile, toggleSidebar, peek, peekIn, peekOut, collapsible],
210
+ );
211
+ return (
212
+ <SidebarContext.Provider value={value}>
213
+ <div data-slot="sidebar-wrapper" data-height={height} className={nmClass('nm-sidebar-provider', className)} style={style} {...props}>
214
+ {children}
215
+ </div>
216
+ </SidebarContext.Provider>
217
+ );
218
+ }
219
+
220
+ export type NmSidebarProps = ComponentProps<'div'> & {
221
+ side?: NmSidebarSide;
222
+ variant?: NmSidebarVariant;
223
+ collapsible?: NmSidebarCollapsible;
224
+ /** The accessible name of the sidebar on a phone, where it opens over the page. */
225
+ label?: string;
226
+ };
227
+
228
+ /**
229
+ * The sidebar itself. Beside the main area on a desktop, where it slides out of view, collapses to a column
230
+ * of icons, or collapses to its toggle alone. Over the page on a phone, in a drawer.
231
+ */
232
+ export function NmSidebar({
233
+ side = 'left',
234
+ variant = 'sidebar',
235
+ collapsible = 'offcanvas',
236
+ label = 'Sidebar',
237
+ className,
238
+ children,
239
+ ...props
240
+ }: NmSidebarProps): ReactNode {
241
+ const { isMobile, state, openMobile, setOpenMobile, peek, peekIn, peekOut, setCollapsible } = useSidebar();
242
+ useEffect(() => setCollapsible(collapsible), [collapsible, setCollapsible]);
243
+
244
+ if (collapsible === 'none') {
245
+ return (
246
+ <div data-slot="sidebar" data-side={side} data-variant={variant} data-collapsible="none" className={nmClass('nm-sidebar', className)} {...props}>
247
+ <div data-slot="sidebar-inner" className="nm-sidebar__inner">{children}</div>
248
+ </div>
249
+ );
250
+ }
251
+
252
+ if (isMobile) {
253
+ return (
254
+ <NmDrawer open={openMobile} onOpenChange={setOpenMobile}>
255
+ <NmDrawerContent data-slot="sidebar" data-mobile="" side={side} aria-label={label} className={nmClass('nm-sidebar nm-sidebar__sheet', className)}>
256
+ <div data-slot="sidebar-inner" className="nm-sidebar__inner">{children}</div>
257
+ </NmDrawerContent>
258
+ </NmDrawer>
259
+ );
260
+ }
261
+
262
+ const collapsed = state === 'collapsed';
263
+ return (
264
+ <div
265
+ data-slot="sidebar"
266
+ data-state={state}
267
+ data-collapsible={collapsed && !peek ? collapsible : ''}
268
+ data-mode={collapsible}
269
+ data-peek={collapsed && peek ? '' : undefined}
270
+ data-variant={variant}
271
+ data-side={side}
272
+ className={nmClass('nm-sidebar', className)}
273
+ onPointerEnter={collapsed ? peekIn : undefined}
274
+ onPointerLeave={collapsed ? peekOut : undefined}
275
+ {...props}
276
+ >
277
+ <div data-slot="sidebar-inner" className="nm-sidebar__inner">{children}</div>
278
+ </div>
279
+ );
280
+ }
281
+
282
+ export type NmSidebarTriggerProps = Omit<ComponentProps<'button'>, 'aria-label'> & {
283
+ /** The accessible name. The tooltip says Open or Close by state. */
284
+ label?: string;
285
+ icon?: NmIconId;
286
+ /** On by default: a tooltip with the name and the ⌘B shortcut. */
287
+ tooltip?: boolean;
288
+ };
289
+
290
+ /** The button that opens and closes the sidebar. In the main area's bar, or in the sidebar's own header. Resting on it peeks the sidebar. */
291
+ export function NmSidebarTrigger({ className, label, icon = 'panel-left', tooltip = true, onClick, onPointerEnter, onPointerLeave, ...props }: NmSidebarTriggerProps): ReactNode {
292
+ const { open, isMobile, toggleSidebar, peekIn, peekOut } = useSidebar();
293
+ const name = label ?? (open || isMobile ? 'Close sidebar' : 'Open sidebar');
294
+ const button = (
295
+ <NordmanIconButton
296
+ data-slot="sidebar-trigger"
297
+ glyph={icon}
298
+ label={name}
299
+ tone="quiet"
300
+ className={nmClass('nm-sidebar__trigger', className)}
301
+ onClick={(event) => {
302
+ onClick?.(event);
303
+ toggleSidebar();
304
+ }}
305
+ onPointerEnter={(event) => {
306
+ onPointerEnter?.(event);
307
+ peekIn(event);
308
+ }}
309
+ onPointerLeave={(event) => {
310
+ onPointerLeave?.(event);
311
+ peekOut();
312
+ }}
313
+ {...props}
314
+ />
315
+ );
316
+ if (!tooltip) {
317
+ return button;
318
+ }
319
+ return (
320
+ <NmTooltip>
321
+ <NmTooltipTrigger render={button} />
322
+ <NmTooltipContent side="bottom">
323
+ {name}
324
+ <NmKbd>⌘B</NmKbd>
325
+ </NmTooltipContent>
326
+ </NmTooltip>
327
+ );
328
+ }
329
+
330
+ /** A thin strip on the sidebar's edge. Clicking it toggles the sidebar. */
331
+ export function NmSidebarRail({ className, label = 'Toggle sidebar', ...props }: ComponentProps<'button'> & { label?: string }): ReactNode {
332
+ const { toggleSidebar } = useSidebar();
333
+ return (
334
+ <button
335
+ type="button"
336
+ data-slot="sidebar-rail"
337
+ aria-label={label}
338
+ tabIndex={-1}
339
+ title={label}
340
+ className={nmClass('nm-sidebar__rail', className)}
341
+ onClick={toggleSidebar}
342
+ {...props}
343
+ />
344
+ );
345
+ }
346
+
347
+ /** The main area beside the sidebar. With the inset variant it is the raised card. */
348
+ export function NmSidebarInset({ className, ...props }: ComponentProps<'main'>): ReactNode {
349
+ return <main data-slot="sidebar-inset" className={nmClass('nm-sidebar__inset', className)} {...props} />;
350
+ }
351
+
352
+ /** A search field in the sidebar. The NIF Input. */
353
+ export function NmSidebarInput({ className, ...props }: ComponentProps<typeof NmInput>): ReactNode {
354
+ return <NmInput data-slot="sidebar-input" className={nmClass('nm-sidebar__input', className as string)} {...props} />;
355
+ }
356
+
357
+ /** Sticky at the top: a brand, a title or a workspace switcher, and the toggle when the sidebar collapses to it. */
358
+ export function NmSidebarHeader({ className, ...props }: ComponentProps<'div'>): ReactNode {
359
+ return <div data-slot="sidebar-header" className={nmClass('nm-sidebar__header', className)} {...props} />;
360
+ }
361
+
362
+ /** Sticky at the bottom: the person, settings or actions. */
363
+ export function NmSidebarFooter({ className, ...props }: ComponentProps<'div'>): ReactNode {
364
+ return <div data-slot="sidebar-footer" className={nmClass('nm-sidebar__footer', className)} {...props} />;
365
+ }
366
+
367
+ /** The NIF Separator, fitted to the sidebar's padding. */
368
+ export function NmSidebarSeparator({ className, ...props }: ComponentProps<typeof NmSeparator>): ReactNode {
369
+ return <NmSeparator data-slot="sidebar-separator" className={nmClass('nm-sidebar__separator', className as string)} {...props} />;
370
+ }
371
+
372
+ /** The region between the header and the footer. It scrolls. */
373
+ export function NmSidebarContent({ className, ...props }: ComponentProps<'div'>): ReactNode {
374
+ return <div data-slot="sidebar-content" className={nmClass('nm-sidebar__content', className)} {...props} />;
375
+ }
376
+
377
+ /** Related navigation, with a label, an action and content. */
378
+ export function NmSidebarGroup({ className, ...props }: ComponentProps<'div'>): ReactNode {
379
+ return <div data-slot="sidebar-group" className={nmClass('nm-sidebar__group', className)} {...props} />;
380
+ }
381
+
382
+ export function NmSidebarGroupLabel({ className, render, ref, ...props }: ComponentProps<'div'> & { render?: RenderProp }): ReactNode {
383
+ return useRender({
384
+ render,
385
+ ref,
386
+ defaultTagName: 'div',
387
+ props: { 'data-slot': 'sidebar-group-label', className: nmClass('nm-sidebar__group-label', className), ...props },
388
+ });
389
+ }
390
+
391
+ /** An icon button at the end of a group label, such as add. The NIF small icon button, quiet. */
392
+ export function NmSidebarGroupAction({ className, ...props }: ComponentProps<typeof NmSmallIconButton>): ReactNode {
393
+ return <NmSmallIconButton data-slot="sidebar-group-action" tone="quiet" className={nmClass('nm-sidebar__group-action', className)} {...props} />;
394
+ }
395
+
396
+ export function NmSidebarGroupContent({ className, ...props }: ComponentProps<'div'>): ReactNode {
397
+ return <div data-slot="sidebar-group-content" className={nmClass('nm-sidebar__group-content', className)} {...props} />;
398
+ }
399
+
400
+ export function NmSidebarMenu({ className, ...props }: ComponentProps<'ul'>): ReactNode {
401
+ return <ul data-slot="sidebar-menu" className={nmClass('nm-sidebar__menu', className)} {...props} />;
402
+ }
403
+
404
+ export function NmSidebarMenuItem({ className, ...props }: ComponentProps<'li'>): ReactNode {
405
+ return <li data-slot="sidebar-menu-item" className={nmClass('nm-sidebar__menu-item', className)} {...props} />;
406
+ }
407
+
408
+ export type NmSidebarMenuButtonProps = ComponentProps<'button'> & {
409
+ render?: RenderProp;
410
+ isActive?: boolean;
411
+ variant?: NmSidebarButtonVariant;
412
+ size?: NmSidebarButtonSize;
413
+ /** Shown beside the icon when the sidebar is collapsed to icons. A string, or the props of the tooltip content. */
414
+ tooltip?: string | ComponentProps<typeof NmTooltipContent>;
415
+ };
416
+
417
+ /** A row in the menu: an icon and a label, as a button or a link. Active, disabled, three sizes and two variants. */
418
+ export function NmSidebarMenuButton({
419
+ render,
420
+ isActive = false,
421
+ variant = 'default',
422
+ size = 'default',
423
+ tooltip,
424
+ className,
425
+ ref,
426
+ ...props
427
+ }: NmSidebarMenuButtonProps): ReactNode {
428
+ const { isMobile, state, peek } = useSidebar();
429
+ const button = useRender({
430
+ render,
431
+ ref,
432
+ defaultTagName: 'button',
433
+ props: {
434
+ type: render ? undefined : 'button',
435
+ 'data-slot': 'sidebar-menu-button',
436
+ 'data-size': size,
437
+ 'data-active': isActive ? '' : undefined,
438
+ 'data-variant': variant,
439
+ 'aria-current': isActive ? 'page' : undefined,
440
+ className: nmClass('nm-sidebar__menu-button', className),
441
+ ...props,
442
+ },
443
+ });
444
+ if (tooltip == null) {
445
+ return button;
446
+ }
447
+ const content = typeof tooltip === 'string' ? { children: tooltip } : tooltip;
448
+ // The tooltip exists only while the label is out of view: collapsed to icons, not peeking, not on a phone.
449
+ const shows = state === 'collapsed' && !peek && !isMobile;
450
+ return (
451
+ <NmTooltip>
452
+ <NmTooltipTrigger render={button} />
453
+ {shows ? <NmTooltipContent side="right" align="center" {...content} /> : null}
454
+ </NmTooltip>
455
+ );
456
+ }
457
+
458
+ /** An icon button at the end of a row, such as more. The NIF small icon button, quiet. `showOnHover` keeps it hidden until the row is hovered or focused. */
459
+ export function NmSidebarMenuAction({ className, showOnHover = false, ...props }: ComponentProps<typeof NmSmallIconButton> & { showOnHover?: boolean }): ReactNode {
460
+ return (
461
+ <NmSmallIconButton
462
+ data-slot="sidebar-menu-action"
463
+ data-show-on-hover={showOnHover ? '' : undefined}
464
+ tone="quiet"
465
+ className={nmClass('nm-sidebar__menu-action', className)}
466
+ {...props}
467
+ />
468
+ );
469
+ }
470
+
471
+ /** A count or a short word at the end of a row. The NIF Chip, small. */
472
+ export function NmSidebarMenuBadge({ className, ...props }: ComponentProps<typeof NmChip>): ReactNode {
473
+ return <NmChip data-slot="sidebar-menu-badge" size="sm" className={nmClass('nm-sidebar__menu-badge', className)} {...props} />;
474
+ }
475
+
476
+ /** Two lines in a row, such as a name and a plan in the footer. For a large menu button. */
477
+ export function NmSidebarMenuText({ title, description, className, ...props }: ComponentProps<'span'> & { title: string; description?: string }): ReactNode {
478
+ return (
479
+ <span data-slot="sidebar-menu-text" className={nmClass('nm-sidebar__text', className)} {...props}>
480
+ <span>{title}</span>
481
+ {description != null ? <span>{description}</span> : null}
482
+ </span>
483
+ );
484
+ }
485
+
486
+ /** A row's shape while its content loads. The icon and the text are random widths, so a list of them reads as a list. */
487
+ export function NmSidebarMenuSkeleton({ className, showIcon = false, ...props }: ComponentProps<'div'> & { showIcon?: boolean }): ReactNode {
488
+ const [width] = useState(() => `${Math.floor(Math.random() * 40) + 50}%`);
489
+ return (
490
+ <div data-slot="sidebar-menu-skeleton" className={nmClass('nm-sidebar__menu-skeleton', className)} {...props}>
491
+ {showIcon ? <NmSkeleton className="nm-sidebar__menu-skeleton-icon" /> : null}
492
+ <NmSkeleton className="nm-sidebar__menu-skeleton-text" style={{ ['--nm-sidebar-skeleton-width' as string]: width } as CSSProperties} />
493
+ </div>
494
+ );
495
+ }
496
+
497
+ /** Rows under a row, indented by a line. */
498
+ export function NmSidebarMenuSub({ className, ...props }: ComponentProps<'ul'>): ReactNode {
499
+ return <ul data-slot="sidebar-menu-sub" className={nmClass('nm-sidebar__menu-sub', className)} {...props} />;
500
+ }
501
+
502
+ export function NmSidebarMenuSubItem({ className, ...props }: ComponentProps<'li'>): ReactNode {
503
+ return <li data-slot="sidebar-menu-sub-item" className={nmClass('nm-sidebar__menu-sub-item', className)} {...props} />;
504
+ }
505
+
506
+ export function NmSidebarMenuSubButton({
507
+ render,
508
+ size = 'default',
509
+ isActive = false,
510
+ className,
511
+ ref,
512
+ ...props
513
+ }: ComponentProps<'a'> & { render?: RenderProp; size?: Exclude<NmSidebarButtonSize, 'lg'>; isActive?: boolean }): ReactNode {
514
+ return useRender({
515
+ render,
516
+ ref,
517
+ defaultTagName: 'a',
518
+ props: {
519
+ 'data-slot': 'sidebar-menu-sub-button',
520
+ 'data-size': size,
521
+ 'data-active': isActive ? '' : undefined,
522
+ 'aria-current': isActive ? 'page' : undefined,
523
+ className: nmClass('nm-sidebar__menu-sub-button', className),
524
+ ...props,
525
+ },
526
+ });
527
+ }
528
+
529
+ /** A chevron at the end of a row. In a collapsible row it points right and turns down when the row is open. With `menu` it points down, for a row that opens a menu. */
530
+ export function NmSidebarChevron({ className, menu = false, ...props }: Omit<ComponentProps<typeof NmIcon>, 'id'> & { menu?: boolean }): ReactNode {
531
+ return <NmIcon id="chevron" data-menu={menu ? '' : undefined} className={nmClass('nm-sidebar__chevron', className)} {...props} />;
532
+ }