@estiva-app/ui 0.71.0 → 0.72.0-widgets2.1
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/ReferenceMenu.d.ts.map +1 -1
- package/dist/editor.js.map +2 -2
- package/dist/widgets-2/fixtures.d.ts +102 -0
- package/dist/widgets-2/fixtures.d.ts.map +1 -0
- package/dist/widgets-2/places.d.ts +99 -0
- package/dist/widgets-2/places.d.ts.map +1 -0
- package/dist/widgets-2/proposed.d.ts +130 -0
- package/dist/widgets-2/proposed.d.ts.map +1 -0
- package/dist/widgets-2.d.ts +12 -0
- package/dist/widgets-2.d.ts.map +1 -0
- package/dist/widgets-2.js +584 -0
- package/dist/widgets-2.js.map +7 -0
- package/package.json +5 -1
- package/registry.json +1 -1
- package/src/ReferenceMenu.tsx +2 -0
- package/src/gates/gates.test.ts +2 -1
- package/src/widgets-2/fixtures.ts +161 -0
- package/src/widgets-2/places.tsx +309 -0
- package/src/widgets-2/proposed.tsx +596 -0
- package/src/widgets-2.ts +11 -0
- package/stories/widgets-2/ReadMe.mdx +60 -0
- package/stories/widgets-2/Widgets.stories.tsx +504 -0
- package/stories/widgets-2/today.tsx +352 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a link sits decides how it draws (Miky, 15 Oct, on the brief "Where a
|
|
3
|
+
* Link Sits"; why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/where-a-link-sits-decides-how-it-draws.md),
|
|
4
|
+
* drawn once here so Peek, Ship and Leaf place the same widgets the same way
|
|
5
|
+
* (WID-3). Each app's own stories put these inside its own frames.
|
|
6
|
+
*
|
|
7
|
+
* - A link in a sentence is a chip; a link alone on its line is a row.
|
|
8
|
+
* - In a message: the one file it links gets its card under it, added by
|
|
9
|
+
* default and removed with ✕; a message that is only one link shows the card
|
|
10
|
+
* instead of the row; two or more files, no card. No layout choice.
|
|
11
|
+
* - In a document: no card is ever added. The chip menu on a lone-line link
|
|
12
|
+
* offers Open and its card, and the card takes that line's place.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
IconCheck,
|
|
16
|
+
IconExternalLink,
|
|
17
|
+
IconH1,
|
|
18
|
+
IconLetterT,
|
|
19
|
+
IconLink,
|
|
20
|
+
IconListCheck,
|
|
21
|
+
IconPaperclip,
|
|
22
|
+
IconTable,
|
|
23
|
+
IconX,
|
|
24
|
+
} from '@tabler/icons-react'
|
|
25
|
+
import { useState, type ReactNode } from 'react'
|
|
26
|
+
import { cn } from '../cn'
|
|
27
|
+
import { IconButton } from '../IconButton'
|
|
28
|
+
import { MenuItem, MenuPanel, MenuSection, MenuSeparator } from '../Menu'
|
|
29
|
+
import type { Thing } from './fixtures'
|
|
30
|
+
import { CardLoading, CardUnavailable, CompactCard, DetailedCard, loadingShapeOf, MessageCard, ReferenceChip, ThingIcon } from './proposed'
|
|
31
|
+
|
|
32
|
+
/** A piece of a sentence: words, or a link to a file. */
|
|
33
|
+
export type Piece = string | Thing
|
|
34
|
+
/** One line of text: a link alone on its line, or a sentence of words and links. */
|
|
35
|
+
export type Line = Thing | Piece[]
|
|
36
|
+
|
|
37
|
+
const isLink = (piece: Piece | Line): piece is Thing => typeof piece === 'object' && !Array.isArray(piece)
|
|
38
|
+
|
|
39
|
+
/** Every file the lines link to, each once. */
|
|
40
|
+
export function linksIn(lines: Line[]): Thing[] {
|
|
41
|
+
return [...new Set(lines.flatMap((line) => (isLink(line) ? [line] : line.filter(isLink))))]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The file whose card goes under a message: the one it links. None when it links two or more. */
|
|
45
|
+
export function cardFileOf(lines: Line[]): Thing | undefined {
|
|
46
|
+
const files = linksIn(lines)
|
|
47
|
+
return files.length === 1 ? files[0] : undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A message that is nothing but one link: it shows the card, not the row. */
|
|
51
|
+
export function isOnlyOneLink(lines: Line[]): boolean {
|
|
52
|
+
return lines.length === 1 && isLink(lines[0])
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A file's card: a linked message draws as a message, every other file as its detailed card; loading and not available as the proposed kit draws them. */
|
|
56
|
+
export function CardOf({ thing }: { thing: Thing }) {
|
|
57
|
+
if (thing.state === 'loading') return <CardLoading kind={thing.kind} layout="detailed" shape={loadingShapeOf(thing)} />
|
|
58
|
+
if (thing.state === 'unavailable') return <CardUnavailable kind={thing.kind} noun={thing.noun} app={thing.app} />
|
|
59
|
+
return thing.kind === 'message' ? <MessageCard thing={thing} /> : <DetailedCard thing={thing} />
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A link alone on its line: its row, or loading, or not available. */
|
|
63
|
+
function Row({ thing, preview = false }: { thing: Thing; preview?: boolean }) {
|
|
64
|
+
if (thing.state === 'loading') return <CardLoading kind={thing.kind} layout="compact" />
|
|
65
|
+
if (thing.state === 'unavailable') return <CardUnavailable kind={thing.kind} noun={thing.noun} app={thing.app} />
|
|
66
|
+
return <CompactCard thing={thing} preview={preview} />
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A sentence: its words, and a chip for each link in it. */
|
|
70
|
+
function Sentence({ pieces }: { pieces: Piece[] }) {
|
|
71
|
+
return (
|
|
72
|
+
<p className="text-body-2 text-text-primary">
|
|
73
|
+
{pieces.map((piece, i) => (typeof piece === 'string' ? <span key={i}>{piece}</span> : <ReferenceChip key={i} thing={piece} state={piece.state} preview={!piece.state} />))}
|
|
74
|
+
</p>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface PlacedLinesProps {
|
|
79
|
+
lines: Line[]
|
|
80
|
+
/** In a document: the lone-line links switched to their card, by line index. */
|
|
81
|
+
cards?: number[]
|
|
82
|
+
/** In a document: a click on a lone-line link, which opens its chip menu. */
|
|
83
|
+
onLink?: (index: number) => void
|
|
84
|
+
/** Drawn under a line, by line index: the chip menu, open. */
|
|
85
|
+
under?: (index: number) => ReactNode
|
|
86
|
+
className?: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Text whose links draw by where they sit: a chip in a sentence, a row alone on its line, or (in a document) the card chosen for that line. */
|
|
90
|
+
export function PlacedLines({ lines, cards = [], onLink, under, className }: PlacedLinesProps) {
|
|
91
|
+
return (
|
|
92
|
+
<div className={cn('flex flex-col gap-2', className)}>
|
|
93
|
+
{lines.map((line, i) => (
|
|
94
|
+
<div key={i} className="relative flex flex-col">
|
|
95
|
+
{isLink(line) ? (
|
|
96
|
+
onLink ? (
|
|
97
|
+
// While writing, a click on the link opens its menu instead of following it (the record clicking-a-chip-while-editing-opens-a-menu).
|
|
98
|
+
<div
|
|
99
|
+
role="button"
|
|
100
|
+
tabIndex={0}
|
|
101
|
+
aria-haspopup="menu"
|
|
102
|
+
className="w-full min-w-0 cursor-pointer"
|
|
103
|
+
onClickCapture={(event) => {
|
|
104
|
+
event.preventDefault()
|
|
105
|
+
event.stopPropagation()
|
|
106
|
+
onLink(i)
|
|
107
|
+
}}
|
|
108
|
+
onKeyDown={(event) => {
|
|
109
|
+
if (event.key === 'Enter') onLink(i)
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
{cards.includes(i) ? <CardOf thing={line} /> : <Row thing={line} />}
|
|
113
|
+
</div>
|
|
114
|
+
) : cards.includes(i) ? (
|
|
115
|
+
<CardOf thing={line} />
|
|
116
|
+
) : (
|
|
117
|
+
<Row thing={line} preview />
|
|
118
|
+
)
|
|
119
|
+
) : (
|
|
120
|
+
<Sentence pieces={line} />
|
|
121
|
+
)}
|
|
122
|
+
{under?.(i)}
|
|
123
|
+
</div>
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface CardUnderProps {
|
|
130
|
+
thing: Thing
|
|
131
|
+
/** The ✕ that removes it: under the message box before sending, or the author's own on a sent message. */
|
|
132
|
+
onRemove?: () => void
|
|
133
|
+
/** On a sent message the ✕ shows on hover; under the box it always shows. */
|
|
134
|
+
removeOnHover?: boolean
|
|
135
|
+
className?: string
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The one file's card under a message, and the ✕ that removes it. */
|
|
139
|
+
export function CardUnder({ thing, onRemove, removeOnHover = false, className }: CardUnderProps) {
|
|
140
|
+
return (
|
|
141
|
+
<div className={cn('group/card flex items-start gap-1', className)}>
|
|
142
|
+
<div className="min-w-0 flex-1">
|
|
143
|
+
<CardOf thing={thing} />
|
|
144
|
+
</div>
|
|
145
|
+
{onRemove && (
|
|
146
|
+
// At the card's top corner, not its middle: a tall card's ✕ halfway down reads as belonging to nothing.
|
|
147
|
+
<div className="flex shrink-0 self-start pt-2">
|
|
148
|
+
<IconButton aria-label="Remove card" tooltip="Remove card" onClick={onRemove} className={cn(removeOnHover && 'opacity-0 group-hover/card:opacity-100 focus-visible:opacity-100')}>
|
|
149
|
+
<IconX size={16} />
|
|
150
|
+
</IconButton>
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface PlacedMessageProps {
|
|
158
|
+
lines: Line[]
|
|
159
|
+
/** The card was removed with ✕: before sending, or by the author afterwards. */
|
|
160
|
+
removed?: boolean
|
|
161
|
+
/** Draws the ✕; without it the card shows with none, as to anyone but the author. */
|
|
162
|
+
onRemove?: () => void
|
|
163
|
+
removeOnHover?: boolean
|
|
164
|
+
/**
|
|
165
|
+
* An old message, sent before Widgets 2 with a `compact` tag for each of
|
|
166
|
+
* these: no card was added to it, and each still draws as a row under it.
|
|
167
|
+
*/
|
|
168
|
+
oldCompact?: Thing[]
|
|
169
|
+
className?: string
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A message's text, and under it the one file's card. A file with its card
|
|
174
|
+
* under the message draws no row in it (a chip in a sentence still draws), so
|
|
175
|
+
* a message that is only one link shows its card alone.
|
|
176
|
+
*/
|
|
177
|
+
export function PlacedMessage({ lines, removed = false, onRemove, removeOnHover, oldCompact, className }: PlacedMessageProps) {
|
|
178
|
+
const file = removed || oldCompact ? undefined : cardFileOf(lines)
|
|
179
|
+
const shown = file ? lines.filter((line) => line !== file) : lines
|
|
180
|
+
return (
|
|
181
|
+
<div className={cn('flex flex-col gap-2', className)}>
|
|
182
|
+
{shown.length > 0 && <PlacedLines lines={shown} />}
|
|
183
|
+
{file && <CardUnder thing={file} onRemove={onRemove} removeOnHover={removeOnHover} className="max-w-[480px]" />}
|
|
184
|
+
{oldCompact?.map((thing, i) => (
|
|
185
|
+
<CompactCard key={i} thing={thing} preview />
|
|
186
|
+
))}
|
|
187
|
+
</div>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A sent message whose author can remove its card with ✕, live: try it. */
|
|
192
|
+
export function SentMessage({ lines, author = true, className }: { lines: Line[]; author?: boolean; className?: string }) {
|
|
193
|
+
const [removed, setRemoved] = useState(false)
|
|
194
|
+
return <PlacedMessage lines={lines} removed={removed} onRemove={author ? () => setRemoved(true) : undefined} removeOnHover className={className} />
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface ChipMenuProps {
|
|
198
|
+
thing: Thing
|
|
199
|
+
/**
|
|
200
|
+
* A lone-line link offers its card; a chip in a sentence offers Open only,
|
|
201
|
+
* and so does a link to what has no card worth choosing (a Folder, a file
|
|
202
|
+
* that is not a picture).
|
|
203
|
+
*/
|
|
204
|
+
alone?: boolean
|
|
205
|
+
shownAs?: 'row' | 'card'
|
|
206
|
+
onShowAs?: (as: 'row' | 'card') => void
|
|
207
|
+
onOpen?: () => void
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** What a link in a document offers its writer: Open, and on a lone-line link of a kind that has one, its card in that line's place. */
|
|
211
|
+
export function ChipMenu({ thing, alone = true, shownAs = 'row', onShowAs, onOpen }: ChipMenuProps) {
|
|
212
|
+
return (
|
|
213
|
+
<MenuPanel className="w-[200px]">
|
|
214
|
+
<div role="menu" aria-label="Link" className="flex flex-col gap-0.5">
|
|
215
|
+
<MenuItem role="menuitem" label="Open" leading={<IconExternalLink size={16} />} onClick={onOpen} />
|
|
216
|
+
{alone && (
|
|
217
|
+
<>
|
|
218
|
+
<MenuSeparator />
|
|
219
|
+
<div role="group" aria-label="Show as">
|
|
220
|
+
<MenuSection label="Show as">
|
|
221
|
+
{(['row', 'card'] as const).map((as) => (
|
|
222
|
+
<MenuItem
|
|
223
|
+
key={as}
|
|
224
|
+
role="menuitemradio"
|
|
225
|
+
aria-checked={shownAs === as}
|
|
226
|
+
label={as === 'row' ? 'Row' : 'Card'}
|
|
227
|
+
trailing={shownAs === as ? <IconCheck size={16} aria-hidden /> : undefined}
|
|
228
|
+
onClick={() => onShowAs?.(as)}
|
|
229
|
+
/>
|
|
230
|
+
))}
|
|
231
|
+
</MenuSection>
|
|
232
|
+
</div>
|
|
233
|
+
</>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
</MenuPanel>
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** A document's text with its chip menu, live: click a lone-line link, then choose Row or Card. */
|
|
241
|
+
export function PlacedDocument({ lines, initialCards = [], className }: { lines: Line[]; initialCards?: number[]; className?: string }) {
|
|
242
|
+
const [cards, setCards] = useState(initialCards)
|
|
243
|
+
const [open, setOpen] = useState<number | null>(null)
|
|
244
|
+
return (
|
|
245
|
+
<PlacedLines
|
|
246
|
+
lines={lines}
|
|
247
|
+
cards={cards}
|
|
248
|
+
className={className}
|
|
249
|
+
onLink={(i) => setOpen(open === i ? null : i)}
|
|
250
|
+
under={(i) => {
|
|
251
|
+
const line = lines[i]
|
|
252
|
+
if (open !== i || !isLink(line)) return null
|
|
253
|
+
return (
|
|
254
|
+
<div className="absolute left-0 top-full z-10 mt-1">
|
|
255
|
+
<ChipMenu
|
|
256
|
+
thing={line}
|
|
257
|
+
shownAs={cards.includes(i) ? 'card' : 'row'}
|
|
258
|
+
onOpen={() => setOpen(null)}
|
|
259
|
+
onShowAs={(as) => {
|
|
260
|
+
setCards(as === 'card' ? [...cards, i] : cards.filter((c) => c !== i))
|
|
261
|
+
setOpen(null)
|
|
262
|
+
}}
|
|
263
|
+
/>
|
|
264
|
+
</div>
|
|
265
|
+
)
|
|
266
|
+
}}
|
|
267
|
+
/>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* A description's and a page's `/` menu with the widget entry (Miky's QA note,
|
|
273
|
+
* 15 Oct: "`/` section where you can use `[` to insert a widget"). Format rows
|
|
274
|
+
* shortened to three; Insert as the app has it, plus the link.
|
|
275
|
+
*/
|
|
276
|
+
export function SlashMenuWithLink({ file = true, table = true }: { file?: boolean; table?: boolean }) {
|
|
277
|
+
return (
|
|
278
|
+
<MenuPanel className="w-[260px]">
|
|
279
|
+
<div role="menu" aria-label="Insert" className="flex flex-col gap-0.5">
|
|
280
|
+
<MenuSection label="Format">
|
|
281
|
+
<MenuItem label="Text" leading={<IconLetterT size={16} />} />
|
|
282
|
+
<MenuItem label="Heading" leading={<IconH1 size={16} />} shortcut="#" />
|
|
283
|
+
<MenuItem label="Checklist" leading={<IconListCheck size={16} />} shortcut="[]" />
|
|
284
|
+
</MenuSection>
|
|
285
|
+
<MenuSeparator />
|
|
286
|
+
<MenuSection label="Insert">
|
|
287
|
+
{table && <MenuItem label="Table" leading={<IconTable size={16} />} />}
|
|
288
|
+
{file && <MenuItem label="File" leading={<IconPaperclip size={16} />} />}
|
|
289
|
+
<MenuItem label="Link to a file" leading={<IconLink size={16} />} shortcut="[" selected />
|
|
290
|
+
</MenuSection>
|
|
291
|
+
</div>
|
|
292
|
+
</MenuPanel>
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The `[` list the widget entry opens: the files to link, each led by its kind's icon. */
|
|
297
|
+
export function LinkPicker({ files, query = '' }: { files: Thing[]; query?: string }) {
|
|
298
|
+
return (
|
|
299
|
+
<MenuPanel className="w-[320px]">
|
|
300
|
+
<div role="listbox" aria-label="Link to a file" className="flex flex-col gap-0.5">
|
|
301
|
+
<MenuSection label={query ? `Files matching “${query}”` : 'Recent files'}>
|
|
302
|
+
{files.map((thing, i) => (
|
|
303
|
+
<MenuItem key={i} role="option" label={thing.kind === 'message' ? `${thing.author}: ${thing.body}` : thing.title} leading={<ThingIcon kind={thing.kind} />} hint={thing.key && <span className="text-caption text-text-secondary">{thing.key}</span>} selected={i === 0} />
|
|
304
|
+
))}
|
|
305
|
+
</MenuSection>
|
|
306
|
+
</div>
|
|
307
|
+
</MenuPanel>
|
|
308
|
+
)
|
|
309
|
+
}
|