@nordmanai/nif 0.2.18 → 0.2.20

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;
@@ -131,6 +143,16 @@ export declare function NmSidebarMenuActions({ className, showOnHover, ...props
131
143
  export declare function NmSidebarBar({ className, ...props }: ComponentProps<'div'>): ReactNode;
132
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. */
133
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;
134
156
  /** A count or a short word at the end of a row. The NIF Chip, small. */
135
157
  export declare function NmSidebarMenuBadge({ className, ...props }: ComponentProps<typeof NmChip>): ReactNode;
136
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.18",
3
+ "version": "0.2.20",
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.17",
2
+ "version": "0.2.19",
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);
@@ -173,6 +215,13 @@
173
215
  .nm-sidebar__trigger {
174
216
  flex-shrink: 0;
175
217
  }
218
+ /* The toggle is a bare icon: no fill on hover, focus or while its peek is open. Focus keeps its ring. */
219
+ .nm-icon-button.nm-sidebar__trigger:hover:not(:disabled):not([aria-disabled='true']),
220
+ .nm-icon-button.nm-sidebar__trigger:focus-visible:not(:disabled):not([aria-disabled='true']),
221
+ .nm-icon-button.nm-sidebar__trigger[aria-expanded='true']:not(:disabled):not([aria-disabled='true']),
222
+ .nm-icon-button.nm-sidebar__trigger[aria-pressed='true']:not(:disabled):not([aria-disabled='true']) {
223
+ background: transparent;
224
+ }
176
225
  /* Anchored: one toggle at the provider's top start corner, above the sidebar, that never moves. */
177
226
  .nm-sidebar-provider > .nm-sidebar__trigger--anchored {
178
227
  position: absolute;
@@ -523,7 +572,25 @@
523
572
  height: var(--nm-icon-sm);
524
573
  color: var(--nm-fg-subtle);
525
574
  transform: rotate(-90deg);
526
- transition: transform var(--reveal) var(--ease);
575
+ /* Hidden until the row is hovered, focused or open. */
576
+ opacity: 0;
577
+ transition:
578
+ transform var(--reveal) var(--ease),
579
+ opacity var(--reveal) var(--ease);
580
+ }
581
+ .nm-sidebar__menu-button:hover > .nm-sidebar__chevron,
582
+ .nm-sidebar__menu-button:focus-visible > .nm-sidebar__chevron,
583
+ .nm-sidebar__menu-button[data-popup-open] > .nm-sidebar__chevron,
584
+ .nm-sidebar__menu-button[aria-expanded='true'] > .nm-sidebar__chevron,
585
+ .nm-sidebar__menu-button[data-panel-open] > .nm-sidebar__chevron,
586
+ .nm-sidebar__menu-button[data-open] > .nm-sidebar__chevron {
587
+ opacity: 1;
588
+ }
589
+ /* Nothing to hover on a touch screen: the chevrons stay. */
590
+ @media (hover: none) {
591
+ .nm-sidebar__chevron {
592
+ opacity: 1;
593
+ }
527
594
  }
528
595
  [aria-expanded='true'] > .nm-sidebar__chevron,
529
596
  [data-panel-open] > .nm-sidebar__chevron,
@@ -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>
@@ -507,6 +542,132 @@ export function NmSidebarBar({ className, ...props }: ComponentProps<'div'>): Re
507
542
  export function NmSidebarBrand({ className, ...props }: ComponentProps<'div'>): ReactNode {
508
543
  return <div data-slot="sidebar-brand" className={nmClass('nm-sidebar__brand', className)} {...props} />;
509
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
+ }
510
671
  /** A count or a short word at the end of a row. The NIF Chip, small. */
511
672
  export function NmSidebarMenuBadge({ className, ...props }: ComponentProps<typeof NmChip>): ReactNode {
512
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;