@tribepad/themis 1.6.12 → 1.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribepad/themis",
3
- "version": "1.6.12",
3
+ "version": "1.7.0",
4
4
  "description": "Accessible React component library built on React Aria primitives",
5
5
  "author": "Tribepad <mbasford@tribepad.com>",
6
6
  "license": "MIT",
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Timeline Component Storybook Stories
3
+ *
4
+ * Covers the six core variants (orientation × marker), the mobile collapse, server-fed
5
+ * "Show more" pagination, empty/loading states, an activity-log composition, and a
6
+ * filter-responsiveness pattern.
7
+ */
8
+
9
+ import type { Meta, StoryObj } from '@storybook/react-vite';
10
+ import { useDeferredValue, useState } from 'react';
11
+ import { FileText, MessageSquare, UserCheck, CheckCircle2 } from 'lucide-react';
12
+ import { Timeline } from './Timeline';
13
+ import { Avatar } from '../Avatar';
14
+ import { Badge } from '../Badge';
15
+
16
+ const meta = {
17
+ title: 'Elements/Timeline',
18
+ component: Timeline,
19
+ parameters: {
20
+ layout: 'padded',
21
+ docs: {
22
+ description: {
23
+ component:
24
+ 'Accessible, chronological feed of events (WCAG 2.2 AAA). Rendered as a semantic ' +
25
+ '`<ol>`; vertical or horizontal; dot / basic / icon markers. Horizontal collapses to ' +
26
+ 'vertical on narrow containers. Data enters via `<Timeline.Item>` children.',
27
+ },
28
+ },
29
+ },
30
+ tags: ['autodocs'],
31
+ argTypes: {
32
+ orientation: {
33
+ control: 'select',
34
+ options: ['vertical', 'horizontal'],
35
+ description: 'Layout direction. Horizontal collapses to vertical on narrow containers.',
36
+ },
37
+ marker: {
38
+ control: 'select',
39
+ options: ['dot', 'basic', 'icon'],
40
+ description: 'Marker style for each event.',
41
+ },
42
+ titleHeadingLevel: {
43
+ control: 'select',
44
+ options: ['p', 'h2', 'h3', 'h4', 'h5', 'h6'],
45
+ description: 'Heading level for item titles (default `p`).',
46
+ },
47
+ },
48
+ } satisfies Meta<typeof Timeline>;
49
+
50
+ export default meta;
51
+ type Story = StoryObj<typeof meta>;
52
+
53
+ const SUBTEXT = 'Timeline subtext — usually a longer piece of text for contextualising the timeline event.';
54
+
55
+ /** A small set of representative events. */
56
+ function basicItems() {
57
+ return (
58
+ <>
59
+ <Timeline.Item date="2025-02-01" title="Application received" description={SUBTEXT} />
60
+ <Timeline.Item date="2025-03-01" title="Screening" description={SUBTEXT} />
61
+ <Timeline.Item date="2025-04-01" title="Interview" description={SUBTEXT} />
62
+ <Timeline.Item date="2025-05-01" title="Offer" description={SUBTEXT} />
63
+ </>
64
+ );
65
+ }
66
+
67
+ export const VerticalDot: Story = {
68
+ args: { orientation: 'vertical', marker: 'dot', 'aria-label': 'Application history' },
69
+ render: (args) => <Timeline {...args}>{basicItems()}</Timeline>,
70
+ };
71
+
72
+ export const VerticalBasic: Story = {
73
+ args: { orientation: 'vertical', marker: 'basic', 'aria-label': 'Application history' },
74
+ render: (args) => <Timeline {...args}>{basicItems()}</Timeline>,
75
+ };
76
+
77
+ export const VerticalIcon: Story = {
78
+ args: { orientation: 'vertical', marker: 'icon', 'aria-label': 'Application history' },
79
+ render: (args) => (
80
+ <Timeline {...args}>
81
+ <Timeline.Item date="2025-02-01" title="Document uploaded" description={SUBTEXT} icon={<FileText />} />
82
+ <Timeline.Item date="2025-03-01" title="Message sent" description={SUBTEXT} icon={<MessageSquare />} />
83
+ <Timeline.Item date="2025-04-01" title="Approved" description={SUBTEXT} icon={<CheckCircle2 />} />
84
+ </Timeline>
85
+ ),
86
+ };
87
+
88
+ export const HorizontalDot: Story = {
89
+ args: { orientation: 'horizontal', marker: 'dot', 'aria-label': 'Process', collapseBelow: false },
90
+ render: (args) => <Timeline {...args}>{basicItems()}</Timeline>,
91
+ };
92
+
93
+ export const HorizontalBasic: Story = {
94
+ args: { orientation: 'horizontal', marker: 'basic', 'aria-label': 'Process', collapseBelow: false },
95
+ render: (args) => <Timeline {...args}>{basicItems()}</Timeline>,
96
+ };
97
+
98
+ export const HorizontalIcon: Story = {
99
+ args: { orientation: 'horizontal', marker: 'icon', 'aria-label': 'Process', collapseBelow: false },
100
+ render: (args) => (
101
+ <Timeline {...args}>
102
+ <Timeline.Item date="2025-02-01" title="Uploaded" description={SUBTEXT} icon={<FileText />} />
103
+ <Timeline.Item date="2025-03-01" title="Reviewed" description={SUBTEXT} icon={<UserCheck />} />
104
+ <Timeline.Item date="2025-04-01" title="Approved" description={SUBTEXT} icon={<CheckCircle2 />} />
105
+ </Timeline>
106
+ ),
107
+ };
108
+
109
+ /** Horizontal timelines collapse to vertical below `collapseBelow` (default 640px). Resize the frame. */
110
+ export const MobileCollapse: Story = {
111
+ args: { orientation: 'horizontal', marker: 'dot', 'aria-label': 'Process', collapseBelow: 640 },
112
+ render: (args) => (
113
+ <div style={{ maxWidth: 420, border: '1px dashed var(--border)', padding: 16 }}>
114
+ <Timeline {...args}>{basicItems()}</Timeline>
115
+ </div>
116
+ ),
117
+ };
118
+
119
+ /** Date granularity is controlled with `dateFormatOptions` (Intl.DateTimeFormatOptions). */
120
+ export const DateGranularity: Story = {
121
+ render: () => (
122
+ <div style={{ display: 'grid', gap: 32 }}>
123
+ <Timeline aria-label="Month + year" dateFormatOptions={{ year: 'numeric', month: 'long' }}>
124
+ <Timeline.Item date="2025-04-02T09:30:00" title="Month + year" description="{ year, month: 'long' }" />
125
+ </Timeline>
126
+ <Timeline aria-label="Full date" dateFormatOptions={{ dateStyle: 'full' }}>
127
+ <Timeline.Item date="2025-04-02T09:30:00" title="Full date" description="{ dateStyle: 'full' }" />
128
+ </Timeline>
129
+ <Timeline aria-label="Date + time" dateFormatOptions={{ dateStyle: 'medium', timeStyle: 'short' }}>
130
+ <Timeline.Item date="2025-04-02T09:30:00" title="Date + time" description="{ dateStyle, timeStyle }" />
131
+ </Timeline>
132
+ </div>
133
+ ),
134
+ };
135
+
136
+ /** Interactive items become links with a 44px target and a visible focus ring. */
137
+ export const InteractiveItems: Story = {
138
+ render: () => (
139
+ <Timeline aria-label="Recent activity">
140
+ <Timeline.Item href="#1" date="2025-04-01" title="Candidate applied" description={SUBTEXT} aria-label="View application" />
141
+ <Timeline.Item href="#2" date="2025-04-05" title="Recruiter responded" description={SUBTEXT} aria-label="View response" />
142
+ </Timeline>
143
+ ),
144
+ };
145
+
146
+ /** Server-fed pagination via `onLoadMore`/`hasMore`. Focus moves to the first new item on the final load. */
147
+ export const LoadMore: Story = {
148
+ render: () => {
149
+ const Demo = () => {
150
+ const [count, setCount] = useState(3);
151
+ const [loading, setLoading] = useState(false);
152
+ const max = 9;
153
+ const load = () => {
154
+ setLoading(true);
155
+ setTimeout(() => {
156
+ setCount((c) => Math.min(c + 3, max));
157
+ setLoading(false);
158
+ }, 600);
159
+ };
160
+ return (
161
+ <Timeline aria-label="Activity" hasMore={count < max} isLoadingMore={loading} onLoadMore={load}>
162
+ {Array.from({ length: count }, (_, i) => (
163
+ <Timeline.Item key={i} date={`2025-0${(i % 9) + 1}-01`} title={`Event ${i + 1}`} description={SUBTEXT} />
164
+ ))}
165
+ </Timeline>
166
+ );
167
+ };
168
+ return <Demo />;
169
+ },
170
+ };
171
+
172
+ /** Zero items render the `emptyState` slot — no orphaned connector rail. */
173
+ export const Empty: Story = {
174
+ render: () => (
175
+ <Timeline
176
+ aria-label="Activity"
177
+ emptyState={<p style={{ color: 'var(--muted-foreground)' }}>No activity yet.</p>}
178
+ />
179
+ ),
180
+ };
181
+
182
+ /**
183
+ * Activity-log composition — Timeline ships the 6 core variants; richer item content is
184
+ * composed via the `icon`/avatar marker and the `children` body slot (no special variant).
185
+ */
186
+ export const ActivityLogComposition: Story = {
187
+ render: () => (
188
+ <Timeline aria-label="Activity log" marker="icon">
189
+ <Timeline.Item date="2025-04-01T10:00:00" icon={<UserCheck />}>
190
+ <span className="text-sm">
191
+ <strong>Jordan Lee</strong> moved the candidate to <Badge>Interview</Badge>
192
+ </span>
193
+ </Timeline.Item>
194
+ <Timeline.Item date="2025-04-01T11:30:00" icon={<MessageSquare />}>
195
+ <div className="flex items-start gap-2">
196
+ <Avatar name="Sam Patel" size="sm" />
197
+ <div className="rounded-md bg-[var(--muted)] p-3 text-sm">
198
+ “Thanks — looking forward to the interview next week.”
199
+ </div>
200
+ </div>
201
+ </Timeline.Item>
202
+ </Timeline>
203
+ ),
204
+ };
205
+
206
+ /**
207
+ * Filtering large feeds: keep input responsive by deferring the filtered value
208
+ * (`useDeferredValue`) so typing isn't blocked by the re-render. Stable item ids keep
209
+ * reconciliation cheap.
210
+ */
211
+ export const FilterResponsiveness: Story = {
212
+ render: () => {
213
+ const Demo = () => {
214
+ const ALL = Array.from({ length: 40 }, (_, i) => ({
215
+ id: `e${i}`,
216
+ date: `2025-${String((i % 12) + 1).padStart(2, '0')}-01`,
217
+ title: `Event ${i + 1}`,
218
+ }));
219
+ const [query, setQuery] = useState('');
220
+ const deferred = useDeferredValue(query);
221
+ const filtered = ALL.filter((e) => e.title.toLowerCase().includes(deferred.toLowerCase()));
222
+ const isStale = query !== deferred;
223
+ return (
224
+ <div style={{ display: 'grid', gap: 12 }}>
225
+ <input
226
+ type="search"
227
+ placeholder="Filter events…"
228
+ value={query}
229
+ onChange={(e) => setQuery(e.target.value)}
230
+ style={{ padding: 8, border: '1px solid var(--input-border)', borderRadius: 6 }}
231
+ />
232
+ <div style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}>
233
+ <Timeline aria-label="Filtered events" marker="dot">
234
+ {filtered.map((e) => (
235
+ <Timeline.Item key={e.id} id={e.id} date={e.date} title={e.title} />
236
+ ))}
237
+ </Timeline>
238
+ </div>
239
+ </div>
240
+ );
241
+ };
242
+ return <Demo />;
243
+ },
244
+ };