@estiva-app/ui 0.72.0-widgets2.1 → 0.72.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/dist/CommentHighlights.d.ts +100 -0
- package/dist/CommentHighlights.d.ts.map +1 -0
- package/dist/ComposeBox.d.ts +55 -0
- package/dist/ComposeBox.d.ts.map +1 -0
- package/dist/ComposerTriggers.d.ts.map +1 -1
- package/dist/SelectionToolbar.d.ts +17 -1
- package/dist/SelectionToolbar.d.ts.map +1 -1
- package/dist/editor.d.ts +3 -1
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +618 -217
- package/dist/editor.js.map +4 -4
- package/dist/index.js.map +1 -1
- package/dist/looks.d.ts +6 -0
- package/dist/looks.d.ts.map +1 -1
- package/package.json +1 -5
- package/registry.json +309 -3
- package/src/CommentHighlights.mdx +61 -0
- package/src/CommentHighlights.stories.tsx +72 -0
- package/src/CommentHighlights.test.ts +100 -0
- package/src/CommentHighlights.ts +413 -0
- package/src/ComposeBox.mdx +58 -0
- package/src/ComposeBox.stories.tsx +81 -0
- package/src/ComposeBox.tsx +183 -0
- package/src/ComposerTriggers.tsx +9 -3
- package/src/SelectionToolbar.actions.test.tsx +44 -0
- package/src/SelectionToolbar.mdx +1 -0
- package/src/SelectionToolbar.tsx +36 -2
- package/src/editor.ts +15 -1
- package/src/looks.ts +8 -0
- package/src/registry/app.test.ts +7 -1
- package/src/registry/registry.test.ts +6 -6
- package/stories/Choosing.mdx +2 -0
- package/dist/widgets-2/fixtures.d.ts +0 -102
- package/dist/widgets-2/fixtures.d.ts.map +0 -1
- package/dist/widgets-2/places.d.ts +0 -99
- package/dist/widgets-2/places.d.ts.map +0 -1
- package/dist/widgets-2/proposed.d.ts +0 -130
- package/dist/widgets-2/proposed.d.ts.map +0 -1
- package/dist/widgets-2.d.ts +0 -12
- package/dist/widgets-2.d.ts.map +0 -1
- package/dist/widgets-2.js +0 -584
- package/dist/widgets-2.js.map +0 -7
- package/src/widgets-2/fixtures.ts +0 -161
- package/src/widgets-2/places.tsx +0 -309
- package/src/widgets-2/proposed.tsx +0 -596
- package/src/widgets-2.ts +0 -11
- package/stories/widgets-2/ReadMe.mdx +0 -60
- package/stories/widgets-2/Widgets.stories.tsx +0 -504
- package/stories/widgets-2/today.tsx +0 -352
|
@@ -1,596 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Widgets 2 prototype: the proposed shared kit, one drawing of each widget for
|
|
3
|
-
* Peek, Ship and Leaf alike. Written as the package would export it: no app
|
|
4
|
-
* name and no product noun in a name or a prop. The kind's icon, the key and
|
|
5
|
-
* every field arrive as data from the app's manifest.
|
|
6
|
-
*
|
|
7
|
-
* In `src/` on `concept/widgets-2` only, so the apps' own Storybooks can place
|
|
8
|
-
* it (WID-3): `@estiva-app/ui/widgets-2`, an unlisted entry published only as a
|
|
9
|
-
* `concept` prerelease. The build project shapes the real exports from it once
|
|
10
|
-
* Miky and Katerina approve it.
|
|
11
|
-
*
|
|
12
|
-
* Second iteration, from Miky's notes on WID-2 (15 Oct):
|
|
13
|
-
* - no noun in a card's corner and no "Open in …": the icon says what it is,
|
|
14
|
-
* and the title is the link;
|
|
15
|
-
* - an issue shows its key; the only status and assignee are the actionable ones;
|
|
16
|
-
* - detailed shows the file's short summary, not the opening of its description;
|
|
17
|
-
* - hovering a chip or a compact card shows the detailed card;
|
|
18
|
-
* - a project shows its progress as a ring, its status and lead as actions,
|
|
19
|
-
* its comments, and each issue's assignee;
|
|
20
|
-
* - a topic shows its conversations, its last update and its members, and
|
|
21
|
-
* detailed adds the latest conversation;
|
|
22
|
-
* - a message is drawn with its author's face; a page with its authors and
|
|
23
|
-
* its last edit.
|
|
24
|
-
*/
|
|
25
|
-
import { IconCircle, IconCircleCheck, IconCircleDashed, IconCircleHalf2, IconCircleX, IconFileText, IconMessage2, IconPaperclip } from '@tabler/icons-react'
|
|
26
|
-
import type { ReactNode } from 'react'
|
|
27
|
-
import { Avatar } from '../Avatar'
|
|
28
|
-
import { AvatarGroup } from '../AvatarGroup'
|
|
29
|
-
import { Card } from '../Card'
|
|
30
|
-
import { cn } from '../cn'
|
|
31
|
-
import { InlineChip } from '../InlineChip'
|
|
32
|
-
import { KindIcon } from '../KindIcon'
|
|
33
|
-
import { Link } from '../Link'
|
|
34
|
-
import { MenuPanel } from '../Menu'
|
|
35
|
-
import { PreviewCard } from '../PreviewCard'
|
|
36
|
-
import { Select } from '../Select'
|
|
37
|
-
import { SkeletonBar } from '../Skeleton'
|
|
38
|
-
import { UnreadDot } from '../UnreadDot'
|
|
39
|
-
import type { Status, StatusColour, Thing } from './fixtures'
|
|
40
|
-
|
|
41
|
-
const HREF = '#'
|
|
42
|
-
const noop = () => {}
|
|
43
|
-
|
|
44
|
-
/* ------------------------------------------------------------ the parts */
|
|
45
|
-
|
|
46
|
-
const STATUS_ICON: Record<StatusColour, { Icon: typeof IconCircle; tone: string }> = {
|
|
47
|
-
neutral: { Icon: IconCircleDashed, tone: 'text-text-secondary' },
|
|
48
|
-
blue: { Icon: IconCircleHalf2, tone: 'text-info-default' },
|
|
49
|
-
green: { Icon: IconCircleCheck, tone: 'text-success-default' },
|
|
50
|
-
muted: { Icon: IconCircleX, tone: 'text-text-muted' },
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** A status as its shape: Ship's icons, which read without colour. */
|
|
54
|
-
export function StatusMark({ status }: { status: Status }) {
|
|
55
|
-
return (
|
|
56
|
-
<span className="inline-flex shrink-0 items-center" title={status.label}>
|
|
57
|
-
<StatusIcon status={status} />
|
|
58
|
-
<span className="sr-only">{status.label}</span>
|
|
59
|
-
</span>
|
|
60
|
-
)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** The status's shape alone, beside words that already name it. */
|
|
64
|
-
function StatusIcon({ status }: { status: Status }) {
|
|
65
|
-
const { Icon, tone } = STATUS_ICON[status.colour]
|
|
66
|
-
return <Icon size={14} stroke={1.5} className={cn('shrink-0', tone)} aria-hidden />
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** The icon for a kind. A page gets its own (proposed): today every app draws it as "another app's file". */
|
|
70
|
-
export function ThingIcon({ kind, size = 16, tone = 'secondary' }: { kind: Thing['kind']; size?: 14 | 16; tone?: 'secondary' | 'inherit' }) {
|
|
71
|
-
if (kind === 'page') return <IconFileText size={size} stroke={1.5} className={cn('shrink-0', tone === 'secondary' && 'text-text-secondary')} aria-hidden />
|
|
72
|
-
return <KindIcon kind={kind} size={size} tone={tone} />
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** A key, the way people say it ("ABC-2"): before the title, quieter than it. */
|
|
76
|
-
function Key({ children }: { children: ReactNode }) {
|
|
77
|
-
return <span className="shrink-0 text-caption tabular-nums text-text-muted">{children}</span>
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Progress as a small ring with the count beside it as a label (Miky, 15 Oct,
|
|
82
|
-
* second round: the ring with the count inside was too big).
|
|
83
|
-
*/
|
|
84
|
-
export function ProgressRing({ done, total }: { done: number; total: number }) {
|
|
85
|
-
const size = 16
|
|
86
|
-
const stroke = 2
|
|
87
|
-
const r = (size - stroke) / 2
|
|
88
|
-
const c = 2 * Math.PI * r
|
|
89
|
-
return (
|
|
90
|
-
<span className="inline-flex shrink-0 items-center gap-1 text-caption tabular-nums text-text-secondary" title={`${done} of ${total} done`}>
|
|
91
|
-
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="shrink-0 -rotate-90" aria-hidden>
|
|
92
|
-
<circle cx={size / 2} cy={size / 2} r={r} fill="none" strokeWidth={stroke} className="stroke-border-default" />
|
|
93
|
-
<circle cx={size / 2} cy={size / 2} r={r} fill="none" strokeWidth={stroke} strokeLinecap="round" strokeDasharray={`${(c * done) / total} ${c}`} className="stroke-success-default" />
|
|
94
|
-
</svg>
|
|
95
|
-
{done}/{total}
|
|
96
|
-
</span>
|
|
97
|
-
)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/*
|
|
101
|
-
The unread dot after a count is the package's UnreadDot: the reader has
|
|
102
|
-
something unread in this conversation. Only a member has unread (SPEC §11.8),
|
|
103
|
-
so a reader who is not one sees the count alone.
|
|
104
|
-
*/
|
|
105
|
-
|
|
106
|
-
/** Where the count leads: the file's conversation, at the first unread when there is one. */
|
|
107
|
-
const CONVERSATION_HREF = '#conversation'
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* The comment count, the words brighter and a dot after it when the reader
|
|
111
|
-
* has unread there. It is a link to the conversation (Miky, fourth round:
|
|
112
|
-
* "especially needed when you have unread messages"), and it sits above a
|
|
113
|
-
* compact card's covering link, so the card opens the file and the count
|
|
114
|
-
* opens its conversation.
|
|
115
|
-
*/
|
|
116
|
-
function Comments({ count, unread = false, noun = 'comments' }: { count: number; unread?: boolean; noun?: string }) {
|
|
117
|
-
return (
|
|
118
|
-
<Link
|
|
119
|
-
href={CONVERSATION_HREF}
|
|
120
|
-
variant="plain"
|
|
121
|
-
className={cn(
|
|
122
|
-
'relative z-10 -mx-1 inline-flex shrink-0 items-center gap-1 rounded-sm px-1 text-caption tabular-nums hover:bg-bg-active',
|
|
123
|
-
unread ? 'font-medium text-text-primary' : 'text-text-secondary',
|
|
124
|
-
)}
|
|
125
|
-
>
|
|
126
|
-
<IconMessage2 size={14} stroke={1.5} aria-hidden />
|
|
127
|
-
{count}
|
|
128
|
-
<span className="sr-only">{unread ? `${noun}, unread — open the conversation` : `${noun} — open the conversation`}</span>
|
|
129
|
-
{unread && <UnreadDot className="h-auto w-auto" />}
|
|
130
|
-
</Link>
|
|
131
|
-
)
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* A topic's conversations: the same icon and count as every other file's
|
|
136
|
-
* comments (Miky, third round), after when the last one moved.
|
|
137
|
-
*/
|
|
138
|
-
function Conversations({ thing }: { thing: Thing }) {
|
|
139
|
-
return (
|
|
140
|
-
<span className="inline-flex shrink-0 items-center gap-2">
|
|
141
|
-
<span className="text-caption text-text-secondary">{thing.updated}</span>
|
|
142
|
-
<Comments count={thing.conversations ?? 0} unread={thing.unread} noun="conversations" />
|
|
143
|
-
</span>
|
|
144
|
-
)
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const people = (names: string[] = []) => names.map((name) => ({ name }))
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* The kind's own pickers, the only place its status and owner show in detailed.
|
|
151
|
-
* Read only, for a reader who may not change the file and in the hover preview
|
|
152
|
-
* (Miky, 15 Oct: no actions on hover), they are the same two values as words,
|
|
153
|
-
* in the same place.
|
|
154
|
-
*/
|
|
155
|
-
function Pickers({ thing, readOnly = false }: { thing: Thing; readOnly?: boolean }) {
|
|
156
|
-
if (!thing.status) return null
|
|
157
|
-
if (readOnly) {
|
|
158
|
-
return (
|
|
159
|
-
<span className="flex h-6 min-w-0 items-center gap-3 text-caption text-text-secondary">
|
|
160
|
-
<span className="inline-flex shrink-0 items-center gap-1">
|
|
161
|
-
<StatusIcon status={thing.status} />
|
|
162
|
-
{thing.status.label}
|
|
163
|
-
</span>
|
|
164
|
-
<span className="inline-flex min-w-0 items-center gap-1">
|
|
165
|
-
<Avatar name={thing.assignee} size={16} />
|
|
166
|
-
<span className="truncate">{thing.assignee ?? 'Unassigned'}</span>
|
|
167
|
-
</span>
|
|
168
|
-
</span>
|
|
169
|
-
)
|
|
170
|
-
}
|
|
171
|
-
return (
|
|
172
|
-
<>
|
|
173
|
-
<Select size="small" className="w-auto" value="s" onChange={noop} ariaLabel="Status" options={[{ value: 's', label: thing.status.label, leading: <StatusIcon status={thing.status} /> }]} />
|
|
174
|
-
<Select
|
|
175
|
-
size="small"
|
|
176
|
-
className="w-auto"
|
|
177
|
-
value="a"
|
|
178
|
-
onChange={noop}
|
|
179
|
-
ariaLabel={thing.kind === 'project' ? 'Lead' : 'Assignee'}
|
|
180
|
-
options={[{ value: 'a', label: thing.assignee ?? 'Unassigned', leading: <Avatar name={thing.assignee} size={16} /> }]}
|
|
181
|
-
/>
|
|
182
|
-
</>
|
|
183
|
-
)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/* ----------------------------------------------------------------- chip */
|
|
187
|
-
|
|
188
|
-
export interface ReferenceChipProps {
|
|
189
|
-
thing: Thing
|
|
190
|
-
state?: 'ready' | 'loading' | 'unavailable'
|
|
191
|
-
/** Hovering the chip shows the detailed card. */
|
|
192
|
-
preview?: boolean
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** What leads a chip and a compact card: the author's face for a message, the kind's icon otherwise. */
|
|
196
|
-
function Lead({ thing, chip = false }: { thing: Thing; chip?: boolean }) {
|
|
197
|
-
if (thing.kind === 'message') return <Avatar name={thing.author} size={16} />
|
|
198
|
-
return <ThingIcon kind={thing.kind} size={chip ? 14 : 16} tone={chip ? 'inherit' : 'secondary'} />
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* A link in running text: the icon (or the author's face), the key, the
|
|
203
|
-
* title cut at 24ch, the status as its shape. Loading keeps the icon (the
|
|
204
|
-
* kind is known from the address); not available says so in words.
|
|
205
|
-
*/
|
|
206
|
-
export function ReferenceChip({ thing, state = 'ready', preview = false }: ReferenceChipProps) {
|
|
207
|
-
if (state === 'loading') {
|
|
208
|
-
return (
|
|
209
|
-
<InlineChip icon={<ThingIcon kind={thing.kind} size={14} tone="inherit" />}>
|
|
210
|
-
<SkeletonBar className="h-2.5 w-16" />
|
|
211
|
-
</InlineChip>
|
|
212
|
-
)
|
|
213
|
-
}
|
|
214
|
-
if (state === 'unavailable') {
|
|
215
|
-
return (
|
|
216
|
-
<InlineChip icon={<ThingIcon kind={thing.kind} size={14} tone="inherit" />} className="text-text-muted">
|
|
217
|
-
{thing.noun}, not available
|
|
218
|
-
</InlineChip>
|
|
219
|
-
)
|
|
220
|
-
}
|
|
221
|
-
const chip = (
|
|
222
|
-
<InlineChip icon={<Lead thing={thing} chip />} href={HREF} className="max-w-[28ch]">
|
|
223
|
-
{thing.key && <Key>{thing.key}</Key>}
|
|
224
|
-
<span className="truncate">{thing.kind === 'message' ? `${thing.author}: ${thing.body}` : thing.title}</span>
|
|
225
|
-
{thing.status && <StatusMark status={thing.status} />}
|
|
226
|
-
</InlineChip>
|
|
227
|
-
)
|
|
228
|
-
return preview ? <HoverPreview thing={thing}>{chip}</HoverPreview> : chip
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/* ---------------------------------------------------------------- cards */
|
|
232
|
-
|
|
233
|
-
export type CardLayout = 'compact' | 'detailed'
|
|
234
|
-
|
|
235
|
-
/** Compact: one 36px line. Every kind, every app. Hovering it shows the detailed card. */
|
|
236
|
-
export function CompactCard({ thing, preview = false }: { thing: Thing; preview?: boolean }) {
|
|
237
|
-
const card = (
|
|
238
|
-
<Card fill="inset" hover="hairline" className="relative flex h-9 items-center gap-2 px-3">
|
|
239
|
-
<Lead thing={thing} />
|
|
240
|
-
{thing.kind === 'message' ? (
|
|
241
|
-
<span className="flex min-w-0 flex-1 items-baseline gap-2 text-body-2">
|
|
242
|
-
<span className="shrink-0 text-body-2-strong text-text-primary">{thing.author}</span>
|
|
243
|
-
<Link href={HREF} variant="quiet" cover truncate className="text-text-secondary">
|
|
244
|
-
{thing.body}
|
|
245
|
-
</Link>
|
|
246
|
-
</span>
|
|
247
|
-
) : (
|
|
248
|
-
<>
|
|
249
|
-
{thing.key && <Key>{thing.key}</Key>}
|
|
250
|
-
<span className="contents text-body-2-strong text-text-primary">
|
|
251
|
-
<Link href={HREF} variant="quiet" cover truncate className="min-w-0 flex-1">
|
|
252
|
-
{thing.title}
|
|
253
|
-
</Link>
|
|
254
|
-
</span>
|
|
255
|
-
</>
|
|
256
|
-
)}
|
|
257
|
-
{thing.kind === 'topic' && <Conversations thing={thing} />}
|
|
258
|
-
{thing.kind === 'page' && <span className="shrink-0 text-caption text-text-secondary">{thing.updated}</span>}
|
|
259
|
-
{thing.kind === 'message' && <span className="shrink-0 text-caption text-text-muted">{thing.updated}</span>}
|
|
260
|
-
{/* Compact keeps to one line: the comment count shows only when there is something unread in it. */}
|
|
261
|
-
{thing.unread && thing.comments !== undefined && <Comments count={thing.comments} unread />}
|
|
262
|
-
{thing.status && thing.kind === 'issue' && <StatusMark status={thing.status} />}
|
|
263
|
-
{thing.done && <ProgressRing done={thing.done.done} total={thing.done.total} />}
|
|
264
|
-
{thing.assignee && <Avatar name={thing.assignee} size={16} />}
|
|
265
|
-
{thing.members && <AvatarGroup members={people(thing.members)} size={16} />}
|
|
266
|
-
{thing.authors && <AvatarGroup members={people(thing.authors)} size={16} />}
|
|
267
|
-
</Card>
|
|
268
|
-
)
|
|
269
|
-
return preview ? (
|
|
270
|
-
<HoverPreview thing={thing} block>
|
|
271
|
-
{card}
|
|
272
|
-
</HoverPreview>
|
|
273
|
-
) : (
|
|
274
|
-
card
|
|
275
|
-
)
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* Detailed: what compact cannot hold. The header is compact's line (icon, key,
|
|
280
|
-
* title as the link; a project's ring or a topic's members in the corner),
|
|
281
|
-
* then the summary, the kind's own extras (a project's issues, a topic's
|
|
282
|
-
* latest conversation), and a footer: the pickers and the comments for an
|
|
283
|
-
* issue or a project, who and when for a page and a topic.
|
|
284
|
-
*
|
|
285
|
-
* `bare` drops the card's frame, for the hover preview, which draws the
|
|
286
|
-
* package's one elevated box around it.
|
|
287
|
-
*/
|
|
288
|
-
export function DetailedCard({ thing, bare = false, readOnly = bare }: { thing: Thing; bare?: boolean; readOnly?: boolean }) {
|
|
289
|
-
const body = (
|
|
290
|
-
<>
|
|
291
|
-
<div className="flex min-h-[19.6px] items-center gap-2">
|
|
292
|
-
{/* The icon opens the file too, so the target is larger than the words (Miky, third round). */}
|
|
293
|
-
<Link href={HREF} variant="quiet" className="flex min-w-0 flex-1 items-center gap-2 text-body-2-strong text-text-primary">
|
|
294
|
-
<ThingIcon kind={thing.kind} />
|
|
295
|
-
<span className="truncate">{thing.title}</span>
|
|
296
|
-
</Link>
|
|
297
|
-
{thing.done && <ProgressRing done={thing.done.done} total={thing.done.total} />}
|
|
298
|
-
{thing.members && <AvatarGroup members={people(thing.members)} size={16} />}
|
|
299
|
-
</div>
|
|
300
|
-
{/* The key sits with the project under the title: "ABC-2 · Group one" (Miky, second round). */}
|
|
301
|
-
{(thing.key || thing.subtitle) && <p className="-mt-1 pl-6 text-caption text-text-secondary">{[thing.key, thing.subtitle].filter(Boolean).join(' · ')}</p>}
|
|
302
|
-
{thing.summary && <p className="line-clamp-2 text-body-2 text-text-secondary">{thing.summary}</p>}
|
|
303
|
-
{thing.children && (
|
|
304
|
-
<div className="flex flex-col divide-y divide-border-subtle rounded-md border border-border-subtle">
|
|
305
|
-
{thing.children.map((child) => (
|
|
306
|
-
<div key={child.key} className="relative flex h-8 items-center gap-2 px-2 hover:bg-bg-active">
|
|
307
|
-
{/* The status is the row's icon: a list of issues needs no issue icon on every row (Miky, second round). */}
|
|
308
|
-
<StatusMark status={child.status} />
|
|
309
|
-
<Key>{child.key}</Key>
|
|
310
|
-
{/* Each issue opens on its own; the link covers its row (Miky, third round). */}
|
|
311
|
-
<span className="contents text-body-2 text-text-primary">
|
|
312
|
-
<Link href={HREF} variant="quiet" cover truncate className="min-w-0 flex-1">
|
|
313
|
-
{child.title}
|
|
314
|
-
</Link>
|
|
315
|
-
</span>
|
|
316
|
-
{child.assignee ? <Avatar name={child.assignee} size={16} /> : <span className="size-4 shrink-0" aria-hidden />}
|
|
317
|
-
</div>
|
|
318
|
-
))}
|
|
319
|
-
</div>
|
|
320
|
-
)}
|
|
321
|
-
{thing.latest && (
|
|
322
|
-
<div className="flex flex-col gap-1">
|
|
323
|
-
{/*
|
|
324
|
-
"Latest" in words: the card follows the topic, so it is not the conversation the link was sent about.
|
|
325
|
-
The label names the whole box, so it sits above it, not inside (Miky, fourth round).
|
|
326
|
-
*/}
|
|
327
|
-
<span className="text-caption text-text-muted">Latest conversation</span>
|
|
328
|
-
<div className="flex gap-2 rounded-md border border-border-subtle p-2">
|
|
329
|
-
<Avatar name={thing.latest.author} size={16} />
|
|
330
|
-
<div className="min-w-0 flex-1">
|
|
331
|
-
<div className="flex items-baseline gap-2 text-caption">
|
|
332
|
-
<span className="text-text-primary">{thing.latest.author}</span>
|
|
333
|
-
<span className="text-text-muted">{thing.latest.at}</span>
|
|
334
|
-
</div>
|
|
335
|
-
<p className="line-clamp-2 text-body-2 text-text-secondary">{thing.latest.body}</p>
|
|
336
|
-
</div>
|
|
337
|
-
</div>
|
|
338
|
-
</div>
|
|
339
|
-
)}
|
|
340
|
-
<div className="flex min-h-6 items-center gap-2 border-t border-border-subtle pt-2">
|
|
341
|
-
{(thing.kind === 'issue' || thing.kind === 'project') && <Pickers thing={thing} readOnly={readOnly} />}
|
|
342
|
-
{thing.kind === 'topic' && (
|
|
343
|
-
<span className="flex items-center gap-2">
|
|
344
|
-
<span className="text-caption text-text-secondary">Updated {thing.updated}</span>
|
|
345
|
-
</span>
|
|
346
|
-
)}
|
|
347
|
-
{/* Faces only, as every other widget: names would not fit once several people wrote it (Miky, third round). */}
|
|
348
|
-
{thing.authors && (
|
|
349
|
-
<span className="flex min-w-0 items-center gap-2 text-caption text-text-secondary">
|
|
350
|
-
<AvatarGroup members={people(thing.authors)} size={16} />
|
|
351
|
-
<span className="shrink-0">{thing.updated}</span>
|
|
352
|
-
</span>
|
|
353
|
-
)}
|
|
354
|
-
{thing.kind === 'topic' && (
|
|
355
|
-
<span className="ml-auto flex">
|
|
356
|
-
<Comments count={thing.conversations ?? 0} unread={thing.unread} noun="conversations" />
|
|
357
|
-
</span>
|
|
358
|
-
)}
|
|
359
|
-
{thing.comments !== undefined && (
|
|
360
|
-
<span className="ml-auto flex">
|
|
361
|
-
<Comments count={thing.comments} unread={thing.unread} />
|
|
362
|
-
</span>
|
|
363
|
-
)}
|
|
364
|
-
</div>
|
|
365
|
-
</>
|
|
366
|
-
)
|
|
367
|
-
if (bare) return <div className="flex flex-col gap-2 p-3">{body}</div>
|
|
368
|
-
return (
|
|
369
|
-
<Card fill="inset" data-card="detailed" className="flex flex-col gap-2 p-3">
|
|
370
|
-
{body}
|
|
371
|
-
</Card>
|
|
372
|
-
)
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/** The detailed card, on hover over a chip or a compact card: the package's preview card. */
|
|
376
|
-
export function HoverPreview({ thing, block = false, children }: { thing: Thing; block?: boolean; children: ReactNode }) {
|
|
377
|
-
// A compact card fills its column; PreviewCard's own trigger is inline and would shrink it to its words.
|
|
378
|
-
return (
|
|
379
|
-
<PreviewCard side="bottom" content={<DetailedCard thing={thing} bare />} wrapperClassName={block ? 'grid w-full min-w-0 grid-cols-[minmax(0,1fr)]' : 'max-w-full'}>
|
|
380
|
-
{children}
|
|
381
|
-
</PreviewCard>
|
|
382
|
-
)
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/** The hover preview drawn open, for a still picture of it: the same box PreviewCard draws. */
|
|
386
|
-
export function HoverPreviewOpen({ thing }: { thing: Thing }) {
|
|
387
|
-
return (
|
|
388
|
-
<MenuPanel className="w-[360px] p-0">
|
|
389
|
-
<DetailedCard thing={thing} bare />
|
|
390
|
-
</MenuPanel>
|
|
391
|
-
)
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* A linked message, detailed: a card, the frame every other detailed card has
|
|
396
|
-
* (Miky, 15 Oct: "card it is", over a quote). The time is the link.
|
|
397
|
-
*/
|
|
398
|
-
export function MessageCard({ thing }: { thing: Thing }) {
|
|
399
|
-
return (
|
|
400
|
-
<Card fill="inset" className="flex flex-col gap-2 p-3">
|
|
401
|
-
<div className="flex items-center gap-2">
|
|
402
|
-
<Avatar name={thing.author} size={24} />
|
|
403
|
-
<span className="text-body-2-strong text-text-primary">{thing.author}</span>
|
|
404
|
-
<Link href={HREF} variant="quiet" className="text-caption text-text-muted">
|
|
405
|
-
{thing.updated}
|
|
406
|
-
</Link>
|
|
407
|
-
</div>
|
|
408
|
-
<p className="line-clamp-4 text-body-2 text-text-primary">{thing.body}</p>
|
|
409
|
-
</Card>
|
|
410
|
-
)
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/* ------------------------------------------------- loading, not available */
|
|
414
|
-
|
|
415
|
-
/** What a kind's detailed card will hold, known before it loads (the kind is in the address). */
|
|
416
|
-
export interface LoadingShape {
|
|
417
|
-
subtitle?: boolean
|
|
418
|
-
summary?: boolean
|
|
419
|
-
rows?: number
|
|
420
|
-
preview?: boolean
|
|
421
|
-
ring?: boolean
|
|
422
|
-
footer?: 'pickers' | 'people' | 'line' | 'none'
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/** The shape a thing's detailed card takes, for its placeholder. */
|
|
426
|
-
export function loadingShapeOf(thing: Thing): LoadingShape {
|
|
427
|
-
return {
|
|
428
|
-
subtitle: !!(thing.subtitle || thing.key),
|
|
429
|
-
summary: !!thing.summary,
|
|
430
|
-
rows: thing.children?.length ?? 0,
|
|
431
|
-
preview: !!thing.latest,
|
|
432
|
-
ring: !!thing.done,
|
|
433
|
-
footer: thing.kind === 'issue' || thing.kind === 'project' ? 'pickers' : thing.authors ? 'people' : 'line',
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/** A bar that shows on an inset card: the package's bar is the inset colour, and vanishes on one. */
|
|
438
|
-
function Bar({ className }: { className?: string }) {
|
|
439
|
-
return <SkeletonBar className={cn('bg-bg-active', className)} />
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
/**
|
|
443
|
-
* Loading: the card it will become, drawn in bars, at the card's own height,
|
|
444
|
-
* so the conversation does not jump when it arrives.
|
|
445
|
-
*/
|
|
446
|
-
export function CardLoading({ kind, layout, shape = {} }: { kind: Thing['kind']; layout: CardLayout; shape?: LoadingShape }) {
|
|
447
|
-
if (layout === 'compact') {
|
|
448
|
-
return (
|
|
449
|
-
<Card fill="inset" aria-hidden data-card="loading" className="flex h-9 items-center gap-2 px-3 animate-skeleton-in">
|
|
450
|
-
<ThingIcon kind={kind} />
|
|
451
|
-
<Bar className="h-3 w-2/5" />
|
|
452
|
-
<Bar className="ml-auto size-4 rounded-full" />
|
|
453
|
-
</Card>
|
|
454
|
-
)
|
|
455
|
-
}
|
|
456
|
-
const { subtitle = false, summary = true, rows = 0, preview = false, ring = false, footer = 'line' } = shape
|
|
457
|
-
return (
|
|
458
|
-
<Card fill="inset" aria-hidden data-card="loading" className="flex flex-col gap-2 p-3 animate-skeleton-in">
|
|
459
|
-
<div className="flex h-[19.6px] items-center gap-2">
|
|
460
|
-
<ThingIcon kind={kind} />
|
|
461
|
-
<Bar className="h-3.5 w-1/2" />
|
|
462
|
-
{ring && <Bar className="ml-auto size-4 rounded-full" />}
|
|
463
|
-
</div>
|
|
464
|
-
{subtitle && (
|
|
465
|
-
<div className="-mt-1 flex h-[14.4px] items-center pl-6">
|
|
466
|
-
<Bar className="h-2.5 w-20" />
|
|
467
|
-
</div>
|
|
468
|
-
)}
|
|
469
|
-
{summary && (
|
|
470
|
-
<div className="flex h-[19.6px] items-center">
|
|
471
|
-
<Bar className="h-3 w-4/5" />
|
|
472
|
-
</div>
|
|
473
|
-
)}
|
|
474
|
-
{rows > 0 && (
|
|
475
|
-
<div className="flex flex-col divide-y divide-border-subtle rounded-md border border-border-subtle">
|
|
476
|
-
{Array.from({ length: rows }, (_, i) => (
|
|
477
|
-
<div key={i} className="flex h-8 items-center px-2">
|
|
478
|
-
<Bar className="h-3 w-1/3" />
|
|
479
|
-
</div>
|
|
480
|
-
))}
|
|
481
|
-
</div>
|
|
482
|
-
)}
|
|
483
|
-
{preview && (
|
|
484
|
-
<div className="flex flex-col gap-1">
|
|
485
|
-
<div className="flex h-[14.4px] items-center">
|
|
486
|
-
<Bar className="h-2.5 w-28" />
|
|
487
|
-
</div>
|
|
488
|
-
<div className="flex h-[71.6px] flex-col justify-center gap-2 rounded-md border border-border-subtle p-2">
|
|
489
|
-
<Bar className="h-2.5 w-24" />
|
|
490
|
-
<Bar className="h-3 w-full" />
|
|
491
|
-
<Bar className="h-3 w-3/5" />
|
|
492
|
-
</div>
|
|
493
|
-
</div>
|
|
494
|
-
)}
|
|
495
|
-
{footer !== 'none' && (
|
|
496
|
-
<div className="flex min-h-6 items-center gap-2 border-t border-border-subtle pt-2">
|
|
497
|
-
{footer === 'pickers' ? (
|
|
498
|
-
<>
|
|
499
|
-
<Bar className="h-6 w-24 rounded-md" />
|
|
500
|
-
<Bar className="h-6 w-28 rounded-md" />
|
|
501
|
-
</>
|
|
502
|
-
) : footer === 'people' ? (
|
|
503
|
-
<>
|
|
504
|
-
<Bar className="size-4 rounded-full" />
|
|
505
|
-
<Bar className="h-3 w-40" />
|
|
506
|
-
</>
|
|
507
|
-
) : (
|
|
508
|
-
<Bar className="h-3 w-40" />
|
|
509
|
-
)}
|
|
510
|
-
</div>
|
|
511
|
-
)}
|
|
512
|
-
</Card>
|
|
513
|
-
)
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
/**
|
|
517
|
-
* Not available: one line, at compact's height, whichever layout was chosen.
|
|
518
|
-
* Deleted and unreadable read the same (Widgets 2, Not in scope). Here the
|
|
519
|
-
* way out stays: there is no title to click, and the app it lives in may
|
|
520
|
-
* still open it for you.
|
|
521
|
-
*/
|
|
522
|
-
export function CardUnavailable({ kind, noun, app }: { kind: Thing['kind']; noun: string; app: string }) {
|
|
523
|
-
return (
|
|
524
|
-
<Card fill="none" unreadable className="flex h-9 items-center gap-2 px-3">
|
|
525
|
-
<ThingIcon kind={kind} />
|
|
526
|
-
<span className="min-w-0 flex-1 truncate text-body-2 text-text-muted">{noun} — not available.</span>
|
|
527
|
-
<span className="shrink-0 text-caption">
|
|
528
|
-
<Link href={HREF} variant="quiet" external className="text-text-secondary">
|
|
529
|
-
Open in {app} ↗
|
|
530
|
-
</Link>
|
|
531
|
-
</span>
|
|
532
|
-
</Card>
|
|
533
|
-
)
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
/* ------------------------------------------------------------ the part */
|
|
537
|
-
|
|
538
|
-
/**
|
|
539
|
-
* A block of a description, shared live (a part). No compact or detailed:
|
|
540
|
-
* a pasted block link inserts the part itself (the-author-chooses-a-links-card,
|
|
541
|
-
* Accepted). The same header as a detailed card: the key and the title of
|
|
542
|
-
* the file it is part of, as the link.
|
|
543
|
-
*/
|
|
544
|
-
export function PartCard({ kind, heading, partKey, children, state = 'ready', noun, app }: { kind: Thing['kind']; heading: string; partKey?: string; children?: ReactNode; state?: 'ready' | 'loading' | 'unavailable'; noun: string; app: string }) {
|
|
545
|
-
if (state === 'loading') return <CardLoading kind={kind} layout="detailed" shape={{ summary: true, rows: 0, footer: 'none' }} />
|
|
546
|
-
if (state === 'unavailable') return <CardUnavailable kind={kind} noun={`A part of this ${noun.toLowerCase()}`} app={app} />
|
|
547
|
-
return (
|
|
548
|
-
<Card fill="inset" className="flex flex-col gap-2 p-3">
|
|
549
|
-
<Link href={HREF} variant="quiet" className="flex min-w-0 items-center gap-2 text-body-2-strong text-text-primary">
|
|
550
|
-
<ThingIcon kind={kind} />
|
|
551
|
-
{partKey && <Key>{partKey}</Key>}
|
|
552
|
-
<span className="truncate">{heading}</span>
|
|
553
|
-
</Link>
|
|
554
|
-
<div className="border-l-2 border-border-default pl-3">{children}</div>
|
|
555
|
-
</Card>
|
|
556
|
-
)
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
/**
|
|
560
|
-
* A comment's anchor: the part it is about, quoted above the comment.
|
|
561
|
-
* Unchanged from today (the two apps already agree), with the deleted line in
|
|
562
|
-
* the same muted voice as "not available.".
|
|
563
|
-
*/
|
|
564
|
-
export function CommentAnchor({ text }: { text?: string }) {
|
|
565
|
-
if (text === undefined) return <div className="border-l-2 border-border-default pl-2 text-caption text-text-muted">The part this is about was deleted.</div>
|
|
566
|
-
return (
|
|
567
|
-
<div className="border-l-2 border-border-strong pl-2 text-caption text-text-muted">
|
|
568
|
-
<span className="line-clamp-2 italic">{text}</span>
|
|
569
|
-
</div>
|
|
570
|
-
)
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
/* ----------------------------------------------------------- attachment */
|
|
574
|
-
|
|
575
|
-
/** An attachment's chip: a paperclip and the file's name. */
|
|
576
|
-
export function AttachmentChip({ name, state = 'ready' }: { name: string; state?: 'ready' | 'loading' | 'unavailable' }) {
|
|
577
|
-
if (state === 'loading') {
|
|
578
|
-
return (
|
|
579
|
-
<InlineChip icon={<IconPaperclip size={14} stroke={1.5} aria-hidden />}>
|
|
580
|
-
<SkeletonBar className="h-2.5 w-16" />
|
|
581
|
-
</InlineChip>
|
|
582
|
-
)
|
|
583
|
-
}
|
|
584
|
-
if (state === 'unavailable') {
|
|
585
|
-
return (
|
|
586
|
-
<InlineChip icon={<IconPaperclip size={14} stroke={1.5} aria-hidden />} className="text-text-muted">
|
|
587
|
-
File, not available
|
|
588
|
-
</InlineChip>
|
|
589
|
-
)
|
|
590
|
-
}
|
|
591
|
-
return (
|
|
592
|
-
<InlineChip icon={<IconPaperclip size={14} stroke={1.5} aria-hidden />} href={HREF} className="max-w-[24ch]">
|
|
593
|
-
<span className="truncate">{name}</span>
|
|
594
|
-
</InlineChip>
|
|
595
|
-
)
|
|
596
|
-
}
|
package/src/widgets-2.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `@estiva-app/ui/widgets-2` — the Widgets 2 prototype, unlisted (WID-3).
|
|
3
|
-
*
|
|
4
|
-
* Published only as a `concept` prerelease from `concept/widgets-2`, so Peek,
|
|
5
|
-
* Ship and Leaf can draw every place a widget appears in their own Storybooks
|
|
6
|
-
* from one copy, the one Katerina's look pass changes. Never in a release: the
|
|
7
|
-
* build project shapes the real exports once the stories are approved.
|
|
8
|
-
*/
|
|
9
|
-
export * from './widgets-2/proposed'
|
|
10
|
-
export * from './widgets-2/fixtures'
|
|
11
|
-
export * from './widgets-2/places'
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { Meta } from '@storybook/addon-docs/blocks'
|
|
2
|
-
|
|
3
|
-
<Meta title="Widgets 2/Read me" />
|
|
4
|
-
|
|
5
|
-
# Widgets 2: every widget
|
|
6
|
-
|
|
7
|
-
A prototype, on the `concept/widgets-2` branch only. It draws every kind of file a link can point to as a **chip**, a **compact** card and a **detailed** card, **loading**, and **not available**. Each kind's story puts the proposed drawing first, then how Peek, Ship and Leaf draw it today, redrawn class for class from each app's `main` (15 Oct).
|
|
8
|
-
|
|
9
|
-
Nothing here is wired to anything, and none of it ships from this branch. Once Miky and Katerina approve it, the project *Widgets 2: build* is shaped from these stories, and the three apps switch to one kit.
|
|
10
|
-
|
|
11
|
-
## How to read it
|
|
12
|
-
|
|
13
|
-
- **Every widget → Overview** is the proposed kit on one sheet. Start there.
|
|
14
|
-
- Each kind has its own story: Issue, Project, Topic, Message, Page, Part of a description, Attachment, and A file you can't read.
|
|
15
|
-
- The theme in the toolbar themes the whole sheet: `signal` is Peek's, `ship` is Ship's, `leaf` is Leaf's.
|
|
16
|
-
|
|
17
|
-
## What the proposal changes
|
|
18
|
-
|
|
19
|
-
| Today | Proposed |
|
|
20
|
-
| --- | --- |
|
|
21
|
-
| Three chips: Peek shows the status in coloured words, Ship as an icon and words, Leaf not at all | One chip: the kind's icon, the title cut at 24 characters, the status as its shape only |
|
|
22
|
-
| An issue's compact card keeps the status and assignee pickers in Peek, and differs from detailed by 8px in Ship | Compact is one 36px line with no actions, for every kind. The pickers live only in detailed |
|
|
23
|
-
| Loading: an empty box held at a fixed height in Peek, one 64px bar in Ship, one 48px bar in Leaf | The card it becomes, drawn in bars, at that card's exact height |
|
|
24
|
-
| Not available: "— not available." in Peek, "You may not have access…" in Ship, nothing in Leaf | One line everywhere: "Issue — not available.", with "Open in Ship ↗" |
|
|
25
|
-
| A Leaf page draws as another app's file | A page has its own icon |
|
|
26
|
-
|
|
27
|
-
### Second iteration, from Miky's notes on WID-2 (15 Oct)
|
|
28
|
-
|
|
29
|
-
- No noun in a card's corner and no "Open in …": the icon says what it is, the title is the link. Only "not available." keeps "Open in …", because it has no title to click.
|
|
30
|
-
- Detailed shows the file's **short summary**, not the opening of its long description. No file carries a summary yet; that is its own project.
|
|
31
|
-
- Hovering a chip or a compact card shows the detailed card (**On hover**).
|
|
32
|
-
- **Issue:** its key before the title (chip, compact, detailed); in detailed, status and assignee only as the pickers that change them, and the comment count.
|
|
33
|
-
- **Project:** progress as a ring (3/8 inside it on detailed), status and lead as pickers, the comment count, and each issue with its key, status and assignee.
|
|
34
|
-
- **Topic:** conversations, last update and members; detailed is now offered and adds the latest conversation.
|
|
35
|
-
- **Message:** drawn with its author's face; no noun, no "Go to message" (the time is the link).
|
|
36
|
-
- **Page:** its authors and its last edit.
|
|
37
|
-
|
|
38
|
-
### Third iteration, from Miky's second round (15 Oct)
|
|
39
|
-
|
|
40
|
-
- **Unread:** a comment count (or a topic's conversation count) gets the package's unread dot when you have something unread there. Only a member has unread (SPEC §11.8), so anyone else sees the count alone. Compact shows the comment count only when there is something unread in it.
|
|
41
|
-
- **Issue, detailed:** the key moves under the title with the project: "ABC-2 · Group one".
|
|
42
|
-
- **Project:** a small ring with "3/8" beside it; each issue is led by its status icon instead of the issue icon.
|
|
43
|
-
- **Page:** its comment count.
|
|
44
|
-
- **Topic, detailed:** the preview says "Latest conversation", because it follows the topic.
|
|
45
|
-
- **Message:** the card, chosen over the quote.
|
|
46
|
-
|
|
47
|
-
### Fourth iteration, from Miky's third round (15 Oct)
|
|
48
|
-
|
|
49
|
-
- **The icon opens the file too**, with the title, so the target is larger. A compact card already opens from anywhere on its line.
|
|
50
|
-
- **Topic:** its conversations are counted with the same icon as every file's comments.
|
|
51
|
-
- **Page, detailed:** its authors' faces only, no names.
|
|
52
|
-
- **Project, detailed:** each issue's row opens that issue.
|
|
53
|
-
|
|
54
|
-
Following the decisions of 15 Oct: where detailed has nothing to add, it is not offered (a file that is not a picture); a Folder has no card; a deleted file and one you can't read both say "not available.".
|
|
55
|
-
|
|
56
|
-
## For Miky and Katerina
|
|
57
|
-
|
|
58
|
-
- **A linked message is a card** (Miky, 15 Oct), so it needs nothing new on the wire.
|
|
59
|
-
- **Katerina's look pass:** the loading picture, "not available.", the chip, and whether an issue's compact and detailed now read as two different things.
|
|
60
|
-
- Where a widget appears (in a message, in the message box and the comment box, in a description, under "Mentioned in", and the chip menu) is the next prototype, *Prototype every place a widget appears*.
|