@syscore/ui-library 1.15.4 → 1.17.0

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,351 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import {
3
+ MobileNav,
4
+ MobileNavPanel,
5
+ MobileNavBar,
6
+ MobileNavTrigger,
7
+ } from "../components/ui/mobile-nav";
8
+ import {
9
+ Workflow,
10
+ PenLine,
11
+ MessageSquare,
12
+ TriangleAlert,
13
+ X,
14
+ } from "lucide-react";
15
+ import { useState } from "react";
16
+ import { UtilityText } from "../components/icons/UtilityText";
17
+ import { UtilityCompare } from "../components/icons/UtilityCompare";
18
+ import { UtilityRevisionsShow } from "../components/icons/UtilityRevisionsShow";
19
+ import { UtilityFeedback } from "../components/icons/UtilityFeedback";
20
+
21
+ const meta = {
22
+ title: "Review/MobileNav",
23
+ component: MobileNav,
24
+ tags: ["autodocs"],
25
+ parameters: {
26
+ layout: "fullscreen",
27
+ docs: {
28
+ description: {
29
+ component: `
30
+ Mobile navigation with a spring-animated slide-up panel. Built as a **compound component** — state is managed internally via React context, so consumers never need \`useState\` or handlers.
31
+
32
+ ## Compound Components
33
+
34
+ | Component | Role |
35
+ |---|---|
36
+ | \`MobileNav\` | Root — holds state, provides context |
37
+ | \`MobileNavPanel\` | Slide-up panel with 3 discrete states: **initial** (content height, max 70%), **full** (viewport), **closed** |
38
+ | \`MobileNavBar\` | Bottom nav bar wrapper |
39
+ | \`MobileNavTrigger\` | Individual nav button — toggles the panel or fires a custom action |
40
+
41
+ ## Basic Usage
42
+
43
+ \`\`\`tsx
44
+ <MobileNav className="h-screen">
45
+ <MobileNavPanel>
46
+ {(activeKey) => <MyContent tab={activeKey} />}
47
+ </MobileNavPanel>
48
+
49
+ <MobileNavBar>
50
+ <MobileNavTrigger value="home" label="Home">
51
+ <HomeIcon className="size-6" />
52
+ </MobileNavTrigger>
53
+ <MobileNavTrigger value="search" label="Search">
54
+ <SearchIcon className="size-6" />
55
+ </MobileNavTrigger>
56
+ </MobileNavBar>
57
+ </MobileNav>
58
+ \`\`\`
59
+
60
+ ## Styling Active State
61
+
62
+ \`MobileNavTrigger\` renders a \`<button>\` with the \`group\` class and a \`data-active\` attribute when selected. Use Tailwind's \`group-data-active:\` modifier on children to style based on active state:
63
+
64
+ \`\`\`tsx
65
+ <MobileNavTrigger value="home" label="Home">
66
+ <HomeIcon className="size-6 text-gray-400 group-data-active:text-gray-900" />
67
+ <span className="text-xs hidden group-data-active:block">Home</span>
68
+ </MobileNavTrigger>
69
+ \`\`\`
70
+
71
+ ## Custom Actions (onAction)
72
+
73
+ Use \`onAction\` on a trigger to fire a custom callback instead of opening the panel:
74
+
75
+ \`\`\`tsx
76
+ <MobileNavTrigger value="add" label="Add" onAction={() => openModal()}>
77
+ <PlusIcon className="size-6" />
78
+ </MobileNavTrigger>
79
+ \`\`\`
80
+
81
+ ## Panel Gestures
82
+
83
+ The drag handle supports swipe gestures that snap between discrete states:
84
+
85
+ - **Swipe up** from initial → full screen
86
+ - **Swipe down** from full → initial height
87
+ - **Swipe down** from initial → close
88
+ `,
89
+ },
90
+ },
91
+ },
92
+ } satisfies Meta<typeof MobileNav>;
93
+
94
+ export default meta;
95
+
96
+ type Story = StoryObj<typeof meta>;
97
+
98
+ type TabKey = "list" | "workflow" | "edit" | "comments";
99
+
100
+ const tabContent: Record<
101
+ TabKey,
102
+ { title: string; items: { label: string; description: string }[] }
103
+ > = {
104
+ list: {
105
+ title: "Changes",
106
+ items: [
107
+ {
108
+ label: "Reduced scope",
109
+ description:
110
+ "Requirement 2 no longer includes sulfide as a testing parameter.",
111
+ },
112
+ {
113
+ label: "Clarity edits",
114
+ description: "Requirement 2 has been re-written to maximize clarity.",
115
+ },
116
+ {
117
+ label: "New threshold",
118
+ description:
119
+ "Requirement 3 adds a 0.05 mg/L threshold for lead in drinking water.",
120
+ },
121
+ ],
122
+ },
123
+ workflow: {
124
+ title: "Workflow",
125
+ items: [
126
+ {
127
+ label: "Review pending",
128
+ description: "3 items awaiting team review before finalization.",
129
+ },
130
+ {
131
+ label: "Approved",
132
+ description: "12 strategies have been approved this cycle.",
133
+ },
134
+ ],
135
+ },
136
+ edit: {
137
+ title: "Edits",
138
+ items: [
139
+ {
140
+ label: "Draft update",
141
+ description: "Strategy C8.2 has been revised with new language.",
142
+ },
143
+ {
144
+ label: "Formatting fix",
145
+ description:
146
+ "Table headers in Requirement 1 corrected for consistency.",
147
+ },
148
+ ],
149
+ },
150
+ comments: {
151
+ title: "Comments",
152
+ items: [
153
+ {
154
+ label: "Team feedback",
155
+ description:
156
+ 'Sarah noted: "Consider adding a grace period for compliance."',
157
+ },
158
+ {
159
+ label: "Compliance window",
160
+ description:
161
+ "Suggest extending the compliance window from 30 to 60 days.",
162
+ },
163
+ {
164
+ label: "Testing methodology",
165
+ description:
166
+ "Lab results should include both grab and composite samples.",
167
+ },
168
+ {
169
+ label: "Documentation gap",
170
+ description:
171
+ "Section 4.2 is missing referenced appendix for field procedures.",
172
+ },
173
+ {
174
+ label: "Stakeholder input",
175
+ description:
176
+ "Community advisory board requested public comment period extension.",
177
+ },
178
+ {
179
+ label: "Budget concern",
180
+ description:
181
+ "Monitoring costs may exceed allocated budget by 15% in Q3.",
182
+ },
183
+ {
184
+ label: "Reviewer note",
185
+ description:
186
+ "External reviewer flagged Requirement 4 for further clarification.",
187
+ },
188
+ ],
189
+ },
190
+ };
191
+
192
+ const PanelContent = ({ activeKey }: { activeKey: string }) => {
193
+ const content = tabContent[activeKey as TabKey];
194
+ if (!content) return null;
195
+
196
+ return (
197
+ <div className="mx-auto w-full max-w-md">
198
+ <div className="px-4 pb-2">
199
+ <h2 className="text-lg font-semibold tracking-tight">
200
+ {content.title}
201
+ </h2>
202
+ </div>
203
+ <div className="px-4 pb-4 space-y-3">
204
+ {content.items.map((item) => (
205
+ <div
206
+ key={item.label}
207
+ className="flex items-start justify-between gap-3 rounded-lg border border-gray-100 bg-white p-4"
208
+ >
209
+ <div className="space-y-1">
210
+ <p className="text-sm font-semibold text-gray-900">
211
+ {item.label}
212
+ </p>
213
+ <p className="text-sm text-gray-500">{item.description}</p>
214
+ </div>
215
+ <TriangleAlert className="mt-0.5 size-5 shrink-0 text-teal-400" />
216
+ </div>
217
+ ))}
218
+ </div>
219
+ </div>
220
+ );
221
+ };
222
+
223
+ export const Default: Story = {
224
+ args: { children: null },
225
+
226
+ render: () => (
227
+ <MobileNav className="h-screen w-full bg-gray-50">
228
+ {/* Main content area */}
229
+ <div className="flex-1 p-6 overflow-y-auto">
230
+ <p className="text-sm text-gray-400">
231
+ Tap an icon below to open the panel.
232
+ </p>
233
+ </div>
234
+
235
+ {/* Panel slides up above the nav */}
236
+ <MobileNavPanel>
237
+ {(activeKey) => <PanelContent activeKey={activeKey} />}
238
+ </MobileNavPanel>
239
+
240
+ {/* Nav — always visible */}
241
+ <MobileNavBar>
242
+ <MobileNavTrigger value="list" label="Changes">
243
+ <UtilityText className="size-6 text-gray-500 group-data-active:text-gray-900" />
244
+ </MobileNavTrigger>
245
+ <MobileNavTrigger value="workflow" label="Workflow">
246
+ <UtilityCompare className="size-6 text-gray-500 group-data-active:text-gray-900" />
247
+ </MobileNavTrigger>
248
+ <MobileNavTrigger value="edit" label="Edits">
249
+ <UtilityRevisionsShow className="size-6 text-gray-500 group-data-active:text-gray-900" />
250
+ </MobileNavTrigger>
251
+ <MobileNavTrigger value="comments" label="Comments">
252
+ <UtilityFeedback className="size-6 text-gray-500 group-data-active:text-gray-900" />
253
+ </MobileNavTrigger>
254
+ </MobileNavBar>
255
+ </MobileNav>
256
+ ),
257
+ };
258
+
259
+ const WithCustomActionRender = () => {
260
+ const [modalOpen, setModalOpen] = useState(false);
261
+
262
+ return (
263
+ <MobileNav className="relative h-screen w-full bg-gray-50">
264
+ <div className="flex-1 p-6 overflow-y-auto">
265
+ <p className="text-sm text-gray-400">
266
+ The &ldquo;Edits&rdquo; trigger opens a modal instead of the panel.
267
+ </p>
268
+ </div>
269
+
270
+ <MobileNavPanel>
271
+ {(activeKey) => <PanelContent activeKey={activeKey} />}
272
+ </MobileNavPanel>
273
+
274
+ <MobileNavBar>
275
+ <MobileNavTrigger value="list" label="Changes">
276
+ <UtilityText className="size-6 text-gray-500 group-data-active:text-gray-900" />
277
+ </MobileNavTrigger>
278
+ <MobileNavTrigger value="workflow" label="Workflow">
279
+ <UtilityCompare className="size-8 text-gray-500 group-data-active:text-gray-900" />
280
+ </MobileNavTrigger>
281
+ <MobileNavTrigger
282
+ value="edit"
283
+ label="Edits"
284
+ onAction={() => setModalOpen(true)}
285
+ >
286
+ <UtilityRevisionsShow className="size-6 text-gray-500" />
287
+ </MobileNavTrigger>
288
+ <MobileNavTrigger value="comments" label="Comments">
289
+ <UtilityFeedback className="size-6 text-gray-500 group-data-active:text-gray-900" />
290
+ </MobileNavTrigger>
291
+ </MobileNavBar>
292
+
293
+ {modalOpen && (
294
+ <div className="absolute inset-0 z-50 flex items-center justify-center bg-black/20">
295
+ <div className="relative rounded-xl bg-white p-6 shadow-lg w-72">
296
+ <button
297
+ onClick={() => setModalOpen(false)}
298
+ className="absolute top-3 right-3 text-gray-400 hover:text-gray-600"
299
+ aria-label="Close"
300
+ >
301
+ <X className="size-4" />
302
+ </button>
303
+ <p className="text-sm font-semibold text-gray-900">Edit mode</p>
304
+ <p className="mt-1 text-sm text-gray-500">
305
+ This modal was opened via onAction instead of the panel.
306
+ </p>
307
+ </div>
308
+ </div>
309
+ )}
310
+ </MobileNav>
311
+ );
312
+ };
313
+
314
+ export const WithCustomAction: Story = {
315
+ args: { children: null },
316
+ render: () => <WithCustomActionRender />,
317
+ };
318
+
319
+ export const WithDisabledTrigger: Story = {
320
+ args: { children: null },
321
+
322
+ render: () => (
323
+ <MobileNav className="h-screen w-full bg-gray-50">
324
+ <div className="flex-1 p-6 overflow-y-auto">
325
+ <p className="text-sm text-gray-400">
326
+ The &ldquo;Comments&rdquo; trigger is disabled because there are no
327
+ comments.
328
+ </p>
329
+ </div>
330
+
331
+ <MobileNavPanel>
332
+ {(activeKey) => <PanelContent activeKey={activeKey} />}
333
+ </MobileNavPanel>
334
+
335
+ <MobileNavBar>
336
+ <MobileNavTrigger value="list" label="Changes">
337
+ <UtilityText className="size-6 text-gray-500 group-data-active:text-gray-900" />
338
+ </MobileNavTrigger>
339
+ <MobileNavTrigger value="workflow" label="Workflow">
340
+ <UtilityCompare className="size-6 text-gray-500 group-data-active:text-gray-900" />
341
+ </MobileNavTrigger>
342
+ <MobileNavTrigger value="edit" label="Edits">
343
+ <UtilityRevisionsShow className="size-6 text-gray-500 group-data-active:text-gray-900" />
344
+ </MobileNavTrigger>
345
+ <MobileNavTrigger value="comments" label="Comments" disabled>
346
+ <UtilityFeedback className="size-6 text-gray-500 group-data-active:text-gray-900" />
347
+ </MobileNavTrigger>
348
+ </MobileNavBar>
349
+ </MobileNav>
350
+ ),
351
+ };