@startsimpli/ui 0.4.147 → 0.4.149

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.147",
3
+ "version": "0.4.149",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -55,9 +55,9 @@
55
55
  "@codemirror/language": "^6.0.0",
56
56
  "@codemirror/state": "^6.0.0",
57
57
  "@codemirror/view": "^6.0.0",
58
- "@startsimpli/i18n": "^0.1.3",
59
58
  "@startsimpli/api": "^0.5.66",
60
- "@startsimpli/auth": "^0.4.39"
59
+ "@startsimpli/auth": "^0.4.39",
60
+ "@startsimpli/i18n": "^0.1.3"
61
61
  },
62
62
  "peerDependenciesMeta": {
63
63
  "@startsimpli/api": {
@@ -0,0 +1,73 @@
1
+ /**
2
+ * NavDrawer — the phone-width home of an app's left rail (bd startsim-g2m75).
3
+ *
4
+ * At 390px a fixed 240px rail left the content column ~52px wide. Below the
5
+ * breakpoint the rail must become a menu button that opens the same nav as a
6
+ * modal drawer: focus trapped inside, Escape closes, choosing a link closes.
7
+ * jsdom has no viewport, so width is proven in Playwright; this pins behaviour.
8
+ */
9
+ import { useState } from 'react'
10
+ import { render, screen, fireEvent } from '@testing-library/react'
11
+ import { NavDrawer, NavDrawerButton } from '../nav-drawer'
12
+
13
+ function Harness() {
14
+ const [open, setOpen] = useState(false)
15
+ return (
16
+ <>
17
+ <NavDrawerButton open={open} onOpenChange={setOpen} />
18
+ <NavDrawer open={open} onOpenChange={setOpen} title="Navigation">
19
+ <a href="/t/topic" onClick={() => setOpen(false)}>
20
+ Topics
21
+ </a>
22
+ <button type="button">Log out</button>
23
+ </NavDrawer>
24
+ </>
25
+ )
26
+ }
27
+
28
+ describe('NavDrawer', () => {
29
+ it('is closed until the menu button is pressed, and says so', () => {
30
+ render(<Harness />)
31
+ const btn = screen.getByRole('button', { name: /open navigation/i })
32
+ expect(btn).toHaveAttribute('aria-expanded', 'false')
33
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
34
+ })
35
+
36
+ it('opens the nav as a labelled modal dialog', () => {
37
+ render(<Harness />)
38
+ fireEvent.click(screen.getByRole('button', { name: /open navigation/i }))
39
+ const dialog = screen.getByRole('dialog', { name: 'Navigation' })
40
+ expect(dialog).toContainElement(screen.getByRole('link', { name: 'Topics' }))
41
+ expect(screen.getByRole('button', { name: /open navigation/i, hidden: true })).toHaveAttribute(
42
+ 'aria-expanded',
43
+ 'true',
44
+ )
45
+ })
46
+
47
+ it('moves focus into the drawer when it opens', () => {
48
+ render(<Harness />)
49
+ fireEvent.click(screen.getByRole('button', { name: /open navigation/i }))
50
+ expect(screen.getByRole('dialog')).toContainElement(document.activeElement as HTMLElement)
51
+ })
52
+
53
+ it('closes on Escape', () => {
54
+ render(<Harness />)
55
+ fireEvent.click(screen.getByRole('button', { name: /open navigation/i }))
56
+ fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
57
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
58
+ })
59
+
60
+ it('closes from its own close button', () => {
61
+ render(<Harness />)
62
+ fireEvent.click(screen.getByRole('button', { name: /open navigation/i }))
63
+ fireEvent.click(screen.getByRole('button', { name: /close navigation/i }))
64
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
65
+ })
66
+
67
+ it('closes when a nav link is chosen', () => {
68
+ render(<Harness />)
69
+ fireEvent.click(screen.getByRole('button', { name: /open navigation/i }))
70
+ fireEvent.click(screen.getByRole('link', { name: 'Topics' }))
71
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
72
+ })
73
+ })
@@ -5,3 +5,4 @@ export * from './app-shell'
5
5
  export * from './grouped-nav'
6
6
  export * from './entity-switcher'
7
7
  export * from './breadcrumbs'
8
+ export * from './nav-drawer'
@@ -0,0 +1,98 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import * as DialogPrimitive from '@radix-ui/react-dialog';
5
+ import { Menu, X } from 'lucide-react';
6
+ import { cn } from '../../lib/utils';
7
+
8
+ /**
9
+ * The phone-width home of an app's left rail (bd startsim-g2m75).
10
+ *
11
+ * A fixed 240px rail beside the content works on a laptop and leaves ~52px of
12
+ * content at 390px. Below the app's breakpoint the rail should become a
13
+ * <NavDrawerButton/> that opens the SAME nav inside a <NavDrawer/>: a modal
14
+ * left drawer built on Radix Dialog, so focus is trapped, Escape and an outside
15
+ * tap close it, focus returns to the button, and the page behind can't scroll.
16
+ *
17
+ * The open state is the caller's client state — never a search param, since a
18
+ * Next layout doesn't re-render on soft navigation. Close it from the nav
19
+ * link's own onClick (it then also closes on a tap of the current page, which a
20
+ * pathname effect would miss).
21
+ *
22
+ * const [open, setOpen] = useState(false)
23
+ * <div className="md:hidden"><NavDrawerButton open={open} onOpenChange={setOpen} /></div>
24
+ * <NavDrawer open={open} onOpenChange={setOpen} title="Navigation">{nav}</NavDrawer>
25
+ */
26
+ export interface NavDrawerProps {
27
+ open: boolean;
28
+ onOpenChange: (open: boolean) => void;
29
+ /** Accessible name of the drawer (visually hidden). */
30
+ title?: string;
31
+ /** Classes for the drawer panel — e.g. a width or a dark rail background. */
32
+ className?: string;
33
+ children: ReactNode;
34
+ }
35
+
36
+ export function NavDrawer({
37
+ open,
38
+ onOpenChange,
39
+ title = 'Navigation',
40
+ className,
41
+ children,
42
+ }: NavDrawerProps) {
43
+ return (
44
+ <DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
45
+ <DialogPrimitive.Portal>
46
+ <DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/40" />
47
+ <DialogPrimitive.Content
48
+ aria-describedby={undefined}
49
+ className={cn(
50
+ 'fixed inset-y-0 left-0 z-50 flex w-72 max-w-[85vw] flex-col overflow-hidden bg-white shadow-xl focus:outline-none',
51
+ className,
52
+ )}
53
+ >
54
+ <DialogPrimitive.Title className="sr-only">{title}</DialogPrimitive.Title>
55
+ {children}
56
+ <DialogPrimitive.Close
57
+ aria-label="Close navigation"
58
+ className="absolute right-2 top-2 flex h-10 w-10 items-center justify-center rounded-md opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
59
+ >
60
+ <X className="h-5 w-5" aria-hidden />
61
+ </DialogPrimitive.Close>
62
+ </DialogPrimitive.Content>
63
+ </DialogPrimitive.Portal>
64
+ </DialogPrimitive.Root>
65
+ );
66
+ }
67
+
68
+ export interface NavDrawerButtonProps {
69
+ open: boolean;
70
+ onOpenChange: (open: boolean) => void;
71
+ className?: string;
72
+ /** Accessible label (default "Open navigation"). */
73
+ label?: string;
74
+ }
75
+
76
+ /** The menu button that opens a <NavDrawer/>. Hide it at the rail's breakpoint. */
77
+ export function NavDrawerButton({
78
+ open,
79
+ onOpenChange,
80
+ className,
81
+ label = 'Open navigation',
82
+ }: NavDrawerButtonProps) {
83
+ return (
84
+ <button
85
+ type="button"
86
+ aria-label={label}
87
+ aria-expanded={open}
88
+ aria-haspopup="dialog"
89
+ onClick={() => onOpenChange(!open)}
90
+ className={cn(
91
+ 'flex h-10 w-10 items-center justify-center rounded-md text-gray-700 transition hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary',
92
+ className,
93
+ )}
94
+ >
95
+ <Menu className="h-5 w-5" aria-hidden />
96
+ </button>
97
+ );
98
+ }
@@ -517,7 +517,17 @@ describe('createHttpTrackChangesClient', () => {
517
517
  await c.listComments!({ field: 'blog', status: 'all' })
518
518
  await c.roster!({})
519
519
  expect(f.mock.calls[0][0]).toBe('/api/v1/entities/d1/comments/?field=blog&status=all')
520
- expect(f.mock.calls[1][0]).toBe('/api/v1/org/roster/')
520
+ expect(f.mock.calls[1][0]).toBe('/api/v1/org/roster/?record=d1')
521
+ })
522
+
523
+ it("asks for the RECORD's team, so only people who can read it are offered (bd startsim-whwxd, mention roster = team)", async () => {
524
+ const f = fake(200, { results: [], next_cursor: null })
525
+ const c = createHttpTrackChangesClient('rec-42', { headers: () => ({}), url: (p) => p, fetch: f })
526
+ await c.roster!({ cursor: 'abc' })
527
+ const url = new URL(f.mock.calls[0][0], 'https://t.test')
528
+ expect(url.pathname).toBe('/api/v1/org/roster')
529
+ expect(url.searchParams.get('record')).toBe('rec-42')
530
+ expect(url.searchParams.get('cursor')).toBe('abc')
521
531
  })
522
532
 
523
533
  it('throws the status and body on a refusal', async () => {
@@ -104,6 +104,9 @@ export function createHttpTrackChangesClient(
104
104
  reply: (threadId, req) => call<CommentWire>(`${base}/comments/${threadId}/replies`, { method: 'POST', body: req }),
105
105
  resolveThread: (threadId) => call(`${base}/comments/${threadId}/resolve`, { method: 'POST', body: {} }),
106
106
  reopenThread: (threadId) => call(`${base}/comments/${threadId}/reopen`, { method: 'POST', body: {} }),
107
- roster: ({ cursor }) => call<RosterPageWire>(`/api/v1/org/roster`, { query: { cursor } }),
107
+ // The RECORD's team: the tenant keeps only colleagues who can read this
108
+ // record, so nobody is offered who could not open it (bd startsim-whwxd, mention roster = team).
109
+ roster: ({ cursor }) =>
110
+ call<RosterPageWire>(`/api/v1/org/roster`, { query: { record: String(recordId), cursor } }),
108
111
  };
109
112
  }