@nordmanai/nif 0.2.5 → 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
+ }
@@ -0,0 +1,3 @@
1
+ /** The edge a drawer slides in from. */
2
+ export const NM_DRAWER_SIDES = ['left', 'right'] as const;
3
+ export type NmDrawerSide = (typeof NM_DRAWER_SIDES)[number];
@@ -0,0 +1,37 @@
1
+ /** Which edge of the view the sidebar sits on. */
2
+ export const NM_SIDEBAR_SIDES = ['left', 'right'] as const;
3
+ export type NmSidebarSide = (typeof NM_SIDEBAR_SIDES)[number];
4
+
5
+ /** `sidebar` is flush with the edge. `floating` is a raised card inside a margin. `inset` sits in a margin and the main area is the raised card. */
6
+ export const NM_SIDEBAR_VARIANTS = ['sidebar', 'floating', 'inset'] as const;
7
+ export type NmSidebarVariant = (typeof NM_SIDEBAR_VARIANTS)[number];
8
+
9
+ /**
10
+ * How the sidebar collapses.
11
+ * `offcanvas` slides out of view; the trigger lives in the main area.
12
+ * `icon` keeps a column of icons, one per row.
13
+ * `toggle` keeps only the toggle button, in the sidebar's own header.
14
+ * `none` never collapses.
15
+ */
16
+ export const NM_SIDEBAR_COLLAPSIBLE = ['offcanvas', 'icon', 'toggle', 'none'] as const;
17
+ export type NmSidebarCollapsible = (typeof NM_SIDEBAR_COLLAPSIBLE)[number];
18
+
19
+ export const NM_SIDEBAR_STATES = ['expanded', 'collapsed'] as const;
20
+ export type NmSidebarState = (typeof NM_SIDEBAR_STATES)[number];
21
+
22
+ /** Row heights of a menu button: the small, default and large control heights. */
23
+ export const NM_SIDEBAR_BUTTON_SIZES = ['sm', 'default', 'lg'] as const;
24
+ export type NmSidebarButtonSize = (typeof NM_SIDEBAR_BUTTON_SIZES)[number];
25
+
26
+ /** `default` is a quiet row that fills on hover. `outline` draws the row's line and raises it. */
27
+ export const NM_SIDEBAR_BUTTON_VARIANTS = ['default', 'outline'] as const;
28
+ export type NmSidebarButtonVariant = (typeof NM_SIDEBAR_BUTTON_VARIANTS)[number];
29
+
30
+ /** The key that toggles the sidebar with the platform modifier: ⌘B on Mac, Ctrl+B elsewhere. */
31
+ export const NM_SIDEBAR_SHORTCUT = 'b';
32
+
33
+ /** How long the pointer may be between the toggle and the peeked sidebar before the peek closes, in ms. */
34
+ export const NM_SIDEBAR_PEEK_LEAVE_MS = 200;
35
+
36
+ /** Below this width the sidebar opens over the page instead of beside it. The same breakpoint as the tokens' phone step. */
37
+ export const NM_SIDEBAR_MOBILE_QUERY = '(max-width: 48rem)';
@@ -205,8 +205,17 @@
205
205
  --nm-width-tile: 9rem;
206
206
  --nm-width-panel-min: 16rem;
207
207
  --nm-width-panel-max: 80%;
208
+ /* The sidebar beside the main area: open, on a phone, and collapsed to icons (a square row inside the group padding). */
209
+ --nm-width-sidebar: 16rem;
210
+ --nm-width-sidebar-mobile: 18rem;
211
+ /* A drawer over the page from an edge. */
212
+ --nm-width-drawer: 20rem;
213
+ --nm-width-sidebar-icon: calc(var(--control-h) + var(--nm-space-8) * 2);
208
214
  --nm-height-workspace-sm: 20rem;
209
215
  --nm-height-workspace-lg: 36rem;
216
+ /* A sidebar shown inside a page. */
217
+ --nm-height-sidebar-sm: 26rem;
218
+ --nm-height-sidebar-lg: 42rem;
210
219
  /* Typography. Line height, tracking and weight come from here. The typeface itself is in assets/tokens.css. */
211
220
  /* Structural dividers and resize handles. Invisible at Contrast 0, and they appear as Contrast rises. */
212
221
  --nm-divider: var(--nm-canvas);
@@ -268,6 +277,7 @@
268
277
  --nm-code-gutter: 3ch;
269
278
  --nm-width-date: 11rem;
270
279
  --nm-width-dialog: 40rem;
280
+ --nm-width-alert-dialog: 26rem;
271
281
  /* A page column, such as a settings page: a share of the view, never narrower or wider than its bounds. */
272
282
  --nm-width-column: 90%;
273
283
  --nm-width-column-min: 20rem;