@nordmanai/nif 0.2.17 → 0.2.19

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.
@@ -29,6 +29,12 @@ type SidebarContextValue = {
29
29
  peekOut: () => void;
30
30
  collapsible: NmSidebarCollapsible;
31
31
  setCollapsible: (mode: NmSidebarCollapsible) => void;
32
+ /** The width the user resized the sidebar to, in px. Null until resized: the sidebar then takes its token width. */
33
+ width: number | null;
34
+ /** Sets the resized width, or null to go back to the token width. Clamped by the min and max tokens in the CSS. */
35
+ setWidth: (width: number | null) => void;
36
+ resizing: boolean;
37
+ setResizing: (resizing: boolean) => void;
32
38
  };
33
39
  /** The sidebar's state: expanded or collapsed, peeking, on a desktop or a phone, and the function that toggles it. */
34
40
  /** Whether the sidebar is in view: open or peeking on a desktop, the drawer open on a phone. */
@@ -46,12 +52,18 @@ export type NmSidebarProviderProps = ComponentProps<'div'> & {
46
52
  peek?: boolean;
47
53
  /** `fill` takes the room it is given. `sm` and `lg` are fixed heights, for a sidebar shown inside a page. */
48
54
  height?: 'fill' | 'sm' | 'lg';
55
+ /** The resized width in px, when the app holds it. */
56
+ width?: number | null;
57
+ /** The starting resized width in px. Leave out for the token width. */
58
+ defaultWidth?: number;
59
+ /** Called while the sidebar is resized, with the new width, or null on reset. Store it to remember the width. */
60
+ onWidthChange?: (width: number | null) => void;
49
61
  };
50
62
  /**
51
63
  * Holds the open state and hands it to every part inside. Wrap the sidebar and the main area in it.
52
64
  * The shadcn Sidebar composition, built from NIF primitives and styled by NIF tokens.
53
65
  */
54
- export declare function NmSidebarProvider({ defaultOpen, open: openProp, onOpenChange, shortcut, peek: peekEnabled, height, className, style, children, ...props }: NmSidebarProviderProps): ReactNode;
66
+ export declare function NmSidebarProvider({ defaultOpen, open: openProp, onOpenChange, shortcut, peek: peekEnabled, height, width: widthProp, defaultWidth, onWidthChange, className, style, children, ...props }: NmSidebarProviderProps): ReactNode;
55
67
  export type NmSidebarProps = ComponentProps<'div'> & {
56
68
  side?: NmSidebarSide;
57
69
  variant?: NmSidebarVariant;
@@ -129,6 +141,18 @@ export declare function NmSidebarMenuActions({ className, showOnHover, ...props
129
141
  }): ReactNode;
130
142
  /** A row of controls in the header or footer, such as a primary button and an icon button, spaced apart. */
131
143
  export declare function NmSidebarBar({ className, ...props }: ComponentProps<'div'>): ReactNode;
144
+ /** The product name at the top of the sidebar. Static: it has a row's geometry so it lines up with the rows below, but no hover or press. */
145
+ export declare function NmSidebarBrand({ className, ...props }: ComponentProps<'div'>): ReactNode;
146
+ export type NmSidebarResizerProps = Omit<ComponentProps<'div'>, 'role' | 'onKeyDown' | 'onDoubleClick' | 'onPointerDown'> & {
147
+ /** The accessible name. */
148
+ label?: string;
149
+ };
150
+ /**
151
+ * The edge of the sidebar you drag to resize it. Put it inside `NmSidebar`. The width stays between the
152
+ * `--nm-width-sidebar-min` and `--nm-width-sidebar-max` tokens. Arrow keys resize, Home and End jump to the
153
+ * bounds, a double click or Enter goes back to the default width. Only shown while the sidebar is open.
154
+ */
155
+ export declare function NmSidebarResizer({ className, label, ...props }: NmSidebarResizerProps): ReactNode;
132
156
  /** A count or a short word at the end of a row. The NIF Chip, small. */
133
157
  export declare function NmSidebarMenuBadge({ className, ...props }: ComponentProps<typeof NmChip>): ReactNode;
134
158
  /** Two lines in a row, such as a name and a plan in the footer. For a large menu button. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nordmanai/nif",
3
- "version": "0.2.17",
3
+ "version": "0.2.19",
4
4
  "description": "Nordman Interface Framework: the one UI and UX system for every Nordman product. Base UI, in the shadcn composition, styled by NIF tokens.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.2.16",
2
+ "version": "0.2.18",
3
3
  "versions": {
4
4
  "@animate-ui/react": "^2.4.2",
5
5
  "@base-ui/react": "^1.9.0",
@@ -1,6 +1,7 @@
1
1
  /* The provider holds the sidebar and the main area side by side. */
2
2
  .nm-sidebar-provider {
3
- --nm-sidebar-width: var(--nm-width-sidebar);
3
+ /* The token width, or the width the user dragged to, always between the min and max tokens. */
4
+ --nm-sidebar-width: clamp(var(--nm-width-sidebar-min), var(--nm-sidebar-resized, var(--nm-width-sidebar)), var(--nm-width-sidebar-max));
4
5
  --nm-sidebar-width-icon: var(--nm-width-sidebar-icon);
5
6
  /* The sidebar shares the page's background; only its divider separates it. Floating and inset variants keep their raised card. */
6
7
  --nm-sidebar-bg: var(--nm-canvas);
@@ -50,15 +51,56 @@
50
51
  order: 1;
51
52
  }
52
53
  .nm-sidebar[data-variant='sidebar'][data-side='left'] {
53
- border-inline-end: var(--nm-rule) solid var(--nm-border-divider);
54
+ border-inline-end: var(--nm-rule) solid var(--nm-border-strong);
54
55
  }
55
56
  .nm-sidebar[data-variant='sidebar'][data-side='right'] {
56
- border-inline-start: var(--nm-rule) solid var(--nm-border-divider);
57
+ border-inline-start: var(--nm-rule) solid var(--nm-border-strong);
57
58
  }
58
59
  .nm-sidebar[data-collapsible='offcanvas'] {
59
60
  width: 0;
60
61
  border-width: 0;
61
62
  }
63
+ /* The drag edge: a strip along the sidebar's end, over its border, with a line that shows on hover, focus and drag. */
64
+ .nm-sidebar__resizer {
65
+ position: absolute;
66
+ inset-block: 0;
67
+ inset-inline-end: 0;
68
+ z-index: 1;
69
+ width: var(--nm-space-8);
70
+ cursor: col-resize;
71
+ touch-action: none;
72
+ outline: none;
73
+ }
74
+ .nm-sidebar[data-side='right'] .nm-sidebar__resizer {
75
+ inset-inline-end: auto;
76
+ inset-inline-start: 0;
77
+ }
78
+ .nm-sidebar__resizer::after {
79
+ content: '';
80
+ position: absolute;
81
+ inset-block: 0;
82
+ inset-inline-end: 0;
83
+ width: calc(var(--nm-rule) * 2);
84
+ background: transparent;
85
+ transition: background-color var(--reveal) var(--ease);
86
+ }
87
+ .nm-sidebar[data-side='right'] .nm-sidebar__resizer::after {
88
+ inset-inline-end: auto;
89
+ inset-inline-start: 0;
90
+ }
91
+ .nm-sidebar__resizer:hover::after,
92
+ .nm-sidebar__resizer:focus-visible::after,
93
+ .nm-sidebar-provider[data-resizing] .nm-sidebar__resizer::after {
94
+ background: var(--nm-accent);
95
+ }
96
+ /* While dragging, the width follows the pointer with no easing and nothing gets selected. */
97
+ .nm-sidebar-provider[data-resizing] {
98
+ cursor: col-resize;
99
+ user-select: none;
100
+ }
101
+ .nm-sidebar-provider[data-resizing] .nm-sidebar {
102
+ transition: none;
103
+ }
62
104
  .nm-sidebar[data-collapsible='icon'],
63
105
  .nm-sidebar[data-collapsible='toggle'] {
64
106
  width: var(--nm-sidebar-width-icon);
@@ -177,18 +219,37 @@
177
219
  .nm-sidebar-provider > .nm-sidebar__trigger--anchored {
178
220
  position: absolute;
179
221
  top: var(--nm-space-12);
180
- inset-inline-start: var(--nm-space-12);
222
+ /* Its icon sits where a row's icon sits: the group padding, the row border, then the row's icon padding. */
223
+ inset-inline-start: calc(var(--nm-space-8) + var(--nm-rule));
181
224
  z-index: calc(var(--nm-rail-z) + 1);
182
225
  }
183
226
  /* The header leaves the anchored toggle's room and lines its first row up with it. */
184
227
  .nm-sidebar-provider:has(> .nm-sidebar__trigger--anchored) .nm-sidebar__header {
185
228
  padding-block-start: var(--nm-space-12);
186
- padding-inline-start: calc(var(--nm-space-12) + var(--nm-icon-control) + var(--nm-space-8));
229
+ /* The first row's text starts where a row's text starts: after the icon column. */
230
+ padding-inline-start: calc(var(--nm-space-8) + var(--nm-icon-button) + var(--nm-space-8));
187
231
  min-height: calc(var(--nm-space-12) + var(--nm-icon-control) + var(--nm-space-8));
188
232
  }
189
- .nm-sidebar-provider:has(> .nm-sidebar__trigger--anchored) .nm-sidebar__header > .nm-sidebar__menu > .nm-sidebar__menu-item:first-child > .nm-sidebar__menu-button {
233
+ .nm-sidebar-provider:has(> .nm-sidebar__trigger--anchored) .nm-sidebar__header > .nm-sidebar__menu > .nm-sidebar__menu-item:first-child > .nm-sidebar__menu-button,
234
+ .nm-sidebar-provider:has(> .nm-sidebar__trigger--anchored) .nm-sidebar__header > .nm-sidebar__brand {
190
235
  min-height: var(--nm-icon-control);
191
236
  }
237
+ /* The product name: a row's geometry and type, semibold, with nothing to hover or press. */
238
+ .nm-sidebar__brand {
239
+ display: flex;
240
+ align-items: center;
241
+ gap: var(--nm-space-8);
242
+ box-sizing: border-box;
243
+ width: 100%;
244
+ height: var(--control-h);
245
+ padding: 0 var(--nm-icon-pad);
246
+ border: var(--nm-rule) solid transparent;
247
+ color: var(--nm-fg);
248
+ font: inherit;
249
+ font-size: var(--nm-type-ui);
250
+ font-weight: var(--nm-weight-semibold);
251
+ white-space: nowrap;
252
+ }
192
253
  /* Holds the anchored toggle's place in a bar while the sidebar is closed. */
193
254
  .nm-sidebar__trigger-space {
194
255
  display: inline-block;
@@ -8,6 +8,8 @@ import {
8
8
  useState,
9
9
  type ComponentProps,
10
10
  type CSSProperties,
11
+ type KeyboardEvent as ReactKeyboardEvent,
12
+ type PointerEvent as ReactPointerEvent,
11
13
  type ReactElement,
12
14
  type ReactNode,
13
15
  } from 'react';
@@ -56,6 +58,12 @@ type SidebarContextValue = {
56
58
  peekOut: () => void;
57
59
  collapsible: NmSidebarCollapsible;
58
60
  setCollapsible: (mode: NmSidebarCollapsible) => void;
61
+ /** The width the user resized the sidebar to, in px. Null until resized: the sidebar then takes its token width. */
62
+ width: number | null;
63
+ /** Sets the resized width, or null to go back to the token width. Clamped by the min and max tokens in the CSS. */
64
+ setWidth: (width: number | null) => void;
65
+ resizing: boolean;
66
+ setResizing: (resizing: boolean) => void;
59
67
  };
60
68
 
61
69
  const SidebarContext = createContext<SidebarContextValue | null>(null);
@@ -103,8 +111,13 @@ export type NmSidebarProviderProps = ComponentProps<'div'> & {
103
111
  peek?: boolean;
104
112
  /** `fill` takes the room it is given. `sm` and `lg` are fixed heights, for a sidebar shown inside a page. */
105
113
  height?: 'fill' | 'sm' | 'lg';
114
+ /** The resized width in px, when the app holds it. */
115
+ width?: number | null;
116
+ /** The starting resized width in px. Leave out for the token width. */
117
+ defaultWidth?: number;
118
+ /** Called while the sidebar is resized, with the new width, or null on reset. Store it to remember the width. */
119
+ onWidthChange?: (width: number | null) => void;
106
120
  };
107
-
108
121
  /**
109
122
  * Holds the open state and hands it to every part inside. Wrap the sidebar and the main area in it.
110
123
  * The shadcn Sidebar composition, built from NIF primitives and styled by NIF tokens.
@@ -116,6 +129,9 @@ export function NmSidebarProvider({
116
129
  shortcut = true,
117
130
  peek: peekEnabled = true,
118
131
  height = 'fill',
132
+ width: widthProp,
133
+ defaultWidth,
134
+ onWidthChange,
119
135
  className,
120
136
  style,
121
137
  children,
@@ -125,6 +141,18 @@ export function NmSidebarProvider({
125
141
  const [openMobile, setOpenMobile] = useState(false);
126
142
  const [openState, setOpenState] = useState(defaultOpen);
127
143
  const [peek, setPeek] = useState(false);
144
+ const [widthState, setWidthState] = useState<number | null>(defaultWidth ?? null);
145
+ const [resizing, setResizing] = useState(false);
146
+ const width = widthProp === undefined ? widthState : widthProp;
147
+ const setWidth = useCallback(
148
+ (next: number | null) => {
149
+ onWidthChange?.(next);
150
+ if (widthProp === undefined) {
151
+ setWidthState(next);
152
+ }
153
+ },
154
+ [onWidthChange, widthProp],
155
+ );
128
156
  const [collapsible, setCollapsible] = useState<NmSidebarCollapsible>('offcanvas');
129
157
  const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
130
158
  const pointer = useRef<{ x: number; y: number } | null>(null);
@@ -215,12 +243,19 @@ export function NmSidebarProvider({
215
243
 
216
244
  const state: NmSidebarState = open ? 'expanded' : 'collapsed';
217
245
  const value = useMemo<SidebarContextValue>(
218
- () => ({ state, open, setOpen, openMobile, setOpenMobile, isMobile, toggleSidebar, peek: peek && !open, peekIn, peekOut, collapsible, setCollapsible }),
219
- [state, open, setOpen, openMobile, isMobile, toggleSidebar, peek, peekIn, peekOut, collapsible],
246
+ () => ({ state, open, setOpen, openMobile, setOpenMobile, isMobile, toggleSidebar, peek: peek && !open, peekIn, peekOut, collapsible, setCollapsible, width, setWidth, resizing, setResizing }),
247
+ [state, open, setOpen, openMobile, isMobile, toggleSidebar, peek, peekIn, peekOut, collapsible, width, setWidth, resizing],
220
248
  );
221
249
  return (
222
250
  <SidebarContext.Provider value={value}>
223
- <div data-slot="sidebar-wrapper" data-height={height} className={nmClass('nm-sidebar-provider', className)} style={style} {...props}>
251
+ <div
252
+ data-slot="sidebar-wrapper"
253
+ data-height={height}
254
+ data-resizing={resizing ? '' : undefined}
255
+ className={nmClass('nm-sidebar-provider', className)}
256
+ style={width == null ? style : ({ ...style, ['--nm-sidebar-resized' as string]: `${width}px` } as CSSProperties)}
257
+ {...props}
258
+ >
224
259
  {children}
225
260
  </div>
226
261
  </SidebarContext.Provider>
@@ -503,6 +538,136 @@ export function NmSidebarMenuActions({ className, showOnHover = false, ...props
503
538
  export function NmSidebarBar({ className, ...props }: ComponentProps<'div'>): ReactNode {
504
539
  return <div data-slot="sidebar-bar" className={nmClass('nm-sidebar__bar', className)} {...props} />;
505
540
  }
541
+ /** The product name at the top of the sidebar. Static: it has a row's geometry so it lines up with the rows below, but no hover or press. */
542
+ export function NmSidebarBrand({ className, ...props }: ComponentProps<'div'>): ReactNode {
543
+ return <div data-slot="sidebar-brand" className={nmClass('nm-sidebar__brand', className)} {...props} />;
544
+ }
545
+ export type NmSidebarResizerProps = Omit<ComponentProps<'div'>, 'role' | 'onKeyDown' | 'onDoubleClick' | 'onPointerDown'> & {
546
+ /** The accessible name. */
547
+ label?: string;
548
+ };
549
+ /** A token length in px. The sidebar bounds are plain rem tokens. */
550
+ function tokenPx(value: string): number {
551
+ const amount = parseFloat(value);
552
+ return value.trim().endsWith('rem') ? amount * parseFloat(getComputedStyle(document.documentElement).fontSize) : amount;
553
+ }
554
+ function clampWidth(value: number, min: number, max: number): number {
555
+ return Math.min(max, Math.max(min, value));
556
+ }
557
+ /**
558
+ * The edge of the sidebar you drag to resize it. Put it inside `NmSidebar`. The width stays between the
559
+ * `--nm-width-sidebar-min` and `--nm-width-sidebar-max` tokens. Arrow keys resize, Home and End jump to the
560
+ * bounds, a double click or Enter goes back to the default width. Only shown while the sidebar is open.
561
+ */
562
+ export function NmSidebarResizer({ className, label = 'Resize sidebar', ...props }: NmSidebarResizerProps): ReactNode {
563
+ const { open, isMobile, setWidth, setResizing } = useSidebar();
564
+ const ref = useRef<HTMLDivElement>(null);
565
+ const [range, setRange] = useState({ min: 0, max: 0, now: 0 });
566
+ const measure = useCallback(() => {
567
+ const element = ref.current;
568
+ const host = element?.closest<HTMLElement>('.nm-sidebar-provider');
569
+ const sidebar = element?.closest<HTMLElement>('.nm-sidebar');
570
+ if (!element || !host || !sidebar) {
571
+ return null;
572
+ }
573
+ const style = getComputedStyle(host);
574
+ return {
575
+ host,
576
+ sidebar,
577
+ min: tokenPx(style.getPropertyValue('--nm-width-sidebar-min')),
578
+ max: tokenPx(style.getPropertyValue('--nm-width-sidebar-max')),
579
+ now: sidebar.getBoundingClientRect().width,
580
+ sign: (sidebar.getAttribute('data-side') === 'right') !== (style.direction === 'rtl') ? -1 : 1,
581
+ };
582
+ }, []);
583
+ useEffect(() => {
584
+ const info = measure();
585
+ if (!info) {
586
+ return undefined;
587
+ }
588
+ const update = () => {
589
+ const next = measure();
590
+ if (next) {
591
+ setRange((prev) => (prev.min === next.min && prev.max === next.max && prev.now === next.now ? prev : { min: next.min, max: next.max, now: next.now }));
592
+ }
593
+ };
594
+ update();
595
+ const observer = new ResizeObserver(update);
596
+ observer.observe(info.sidebar);
597
+ return () => observer.disconnect();
598
+ }, [open, isMobile, measure]);
599
+ useEffect(() => () => setResizing(false), [setResizing]);
600
+ if (!open || isMobile) {
601
+ return null;
602
+ }
603
+ const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
604
+ if (event.button !== 0) {
605
+ return;
606
+ }
607
+ const info = measure();
608
+ if (!info) {
609
+ return;
610
+ }
611
+ event.preventDefault();
612
+ const target = event.currentTarget;
613
+ const startX = event.clientX;
614
+ const startWidth = info.now;
615
+ target.setPointerCapture(event.pointerId);
616
+ target.focus();
617
+ setResizing(true);
618
+ const move = (moveEvent: PointerEvent) => {
619
+ setWidth(clampWidth(startWidth + info.sign * (moveEvent.clientX - startX), info.min, info.max));
620
+ };
621
+ const end = () => {
622
+ setResizing(false);
623
+ target.removeEventListener('pointermove', move);
624
+ target.removeEventListener('pointerup', end);
625
+ target.removeEventListener('pointercancel', end);
626
+ };
627
+ target.addEventListener('pointermove', move);
628
+ target.addEventListener('pointerup', end);
629
+ target.addEventListener('pointercancel', end);
630
+ };
631
+ const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
632
+ const info = measure();
633
+ if (!info) {
634
+ return;
635
+ }
636
+ const step = (info.max - info.min) / 10;
637
+ const grow = event.key === 'ArrowRight' ? info.sign : event.key === 'ArrowLeft' ? -info.sign : 0;
638
+ if (grow !== 0) {
639
+ event.preventDefault();
640
+ setWidth(clampWidth(info.now + grow * step, info.min, info.max));
641
+ } else if (event.key === 'Home') {
642
+ event.preventDefault();
643
+ setWidth(info.min);
644
+ } else if (event.key === 'End') {
645
+ event.preventDefault();
646
+ setWidth(info.max);
647
+ } else if (event.key === 'Enter') {
648
+ event.preventDefault();
649
+ setWidth(null);
650
+ }
651
+ };
652
+ return (
653
+ <div
654
+ ref={ref}
655
+ role="separator"
656
+ aria-orientation="vertical"
657
+ aria-label={label}
658
+ aria-valuemin={Math.round(range.min)}
659
+ aria-valuemax={Math.round(range.max)}
660
+ aria-valuenow={Math.round(range.now)}
661
+ tabIndex={0}
662
+ data-slot="sidebar-resizer"
663
+ className={nmClass('nm-sidebar__resizer', className)}
664
+ onPointerDown={onPointerDown}
665
+ onKeyDown={onKeyDown}
666
+ onDoubleClick={() => setWidth(null)}
667
+ {...props}
668
+ />
669
+ );
670
+ }
506
671
  /** A count or a short word at the end of a row. The NIF Chip, small. */
507
672
  export function NmSidebarMenuBadge({ className, ...props }: ComponentProps<typeof NmChip>): ReactNode {
508
673
  return <NmChip data-slot="sidebar-menu-badge" size="sm" className={nmClass('nm-sidebar__menu-badge', className)} {...props} />;
@@ -209,6 +209,8 @@
209
209
  --nm-width-panel-max: 80%;
210
210
  /* The sidebar beside the main area: open, on a phone, and collapsed to icons (a square row inside the group padding). */
211
211
  --nm-width-sidebar: 16rem;
212
+ --nm-width-sidebar-min: 13rem;
213
+ --nm-width-sidebar-max: 28rem;
212
214
  --nm-width-sidebar-mobile: 18rem;
213
215
  /* A drawer over the page from an edge. */
214
216
  --nm-width-drawer: 20rem;