@estiva-app/ui 0.74.0 → 0.75.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/CardUnder.d.ts +10 -0
- package/dist/CardUnder.d.ts.map +1 -0
- package/dist/CommentHighlights.d.ts.map +1 -1
- package/dist/InlineChip.d.ts +2 -0
- package/dist/InlineChip.d.ts.map +1 -1
- package/dist/LinkCard.d.ts +17 -0
- package/dist/LinkCard.d.ts.map +1 -0
- package/dist/LinkChip.d.ts +8 -0
- package/dist/LinkChip.d.ts.map +1 -0
- package/dist/LinkParts.d.ts +45 -0
- package/dist/LinkParts.d.ts.map +1 -0
- package/dist/LinkPreview.d.ts +21 -0
- package/dist/LinkPreview.d.ts.map +1 -0
- package/dist/LinkRow.d.ts +24 -0
- package/dist/LinkRow.d.ts.map +1 -0
- package/dist/PartCard.d.ts +32 -0
- package/dist/PartCard.d.ts.map +1 -0
- package/dist/Skeleton.d.ts +12 -2
- package/dist/Skeleton.d.ts.map +1 -1
- package/dist/editor.js +42 -17
- package/dist/editor.js.map +3 -3
- package/dist/eslint/index.js +1 -0
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +480 -71
- package/dist/index.js.map +4 -4
- package/dist/linked.d.ts +168 -0
- package/dist/linked.d.ts.map +1 -0
- package/dist/linked.examples.d.ts +27 -0
- package/dist/linked.examples.d.ts.map +1 -0
- package/package.json +1 -1
- package/registry.json +784 -7
- package/src/CardUnder.mdx +53 -0
- package/src/CardUnder.stories.tsx +45 -0
- package/src/CardUnder.test.tsx +40 -0
- package/src/CardUnder.tsx +45 -0
- package/src/CommentHighlights.test.ts +56 -2
- package/src/CommentHighlights.ts +51 -12
- package/src/InlineChip.tsx +2 -0
- package/src/LinkCard.mdx +58 -0
- package/src/LinkCard.stories.tsx +68 -0
- package/src/LinkCard.test.tsx +103 -0
- package/src/LinkCard.tsx +209 -0
- package/src/LinkChip.mdx +51 -0
- package/src/LinkChip.stories.tsx +40 -0
- package/src/LinkChip.test.tsx +83 -0
- package/src/LinkChip.tsx +51 -0
- package/src/LinkParts.mdx +49 -0
- package/src/LinkParts.stories.tsx +61 -0
- package/src/LinkParts.tsx +147 -0
- package/src/LinkPreview.mdx +47 -0
- package/src/LinkPreview.stories.tsx +38 -0
- package/src/LinkPreview.tsx +55 -0
- package/src/LinkRow.mdx +47 -0
- package/src/LinkRow.stories.tsx +49 -0
- package/src/LinkRow.test.tsx +38 -0
- package/src/LinkRow.tsx +107 -0
- package/src/PartCard.mdx +43 -0
- package/src/PartCard.stories.tsx +35 -0
- package/src/PartCard.test.tsx +30 -0
- package/src/PartCard.tsx +59 -0
- package/src/Skeleton.tsx +7 -3
- package/src/eslint/no-native-title.test.ts +3 -2
- package/src/eslint/no-restyled-part.ts +1 -0
- package/src/index.ts +25 -0
- package/src/linked.examples.ts +107 -0
- package/src/linked.test.ts +58 -0
- package/src/linked.ts +217 -0
- package/src/registry/registry.test.ts +8 -8
- package/stories/Choosing.mdx +7 -0
package/src/PartCard.tsx
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A part of something longer, shared live: its blocks as they are now, under
|
|
3
|
+
* the header of what it is part of (why:
|
|
4
|
+
* https://github.com/estiva-app/estiva-docs/blob/main/decisions/a-shared-part-of-a-page-is-a-selected-run-of-blocks.md).
|
|
5
|
+
* There is no row or card choice for a part: it draws itself. The app draws
|
|
6
|
+
* the blocks and passes them as `children`.
|
|
7
|
+
*/
|
|
8
|
+
import type { ReactNode } from 'react'
|
|
9
|
+
import { Card } from './Card'
|
|
10
|
+
import { Link } from './Link'
|
|
11
|
+
import { LinkCardLoading } from './LinkCard'
|
|
12
|
+
import type { LinkedKind } from './linked'
|
|
13
|
+
import { LinkedIcon, LinkKey } from './LinkParts'
|
|
14
|
+
import { LinkUnavailable } from './LinkRow'
|
|
15
|
+
|
|
16
|
+
export interface PartCardProps {
|
|
17
|
+
/** Where its lookup stands. Default `ready`. */
|
|
18
|
+
state?: 'ready' | 'loading' | 'unavailable'
|
|
19
|
+
/** What it is part of, by its icon. */
|
|
20
|
+
kind: LinkedKind
|
|
21
|
+
/** The title of what it is part of. */
|
|
22
|
+
heading?: string
|
|
23
|
+
/** Opens what it is part of, at the part. */
|
|
24
|
+
href?: string
|
|
25
|
+
/** The key of what it is part of: "ABC-2". */
|
|
26
|
+
partKey?: string
|
|
27
|
+
/** What it is part of, in a word, for "A part of this item — not available.". */
|
|
28
|
+
noun?: string
|
|
29
|
+
/** Where it may still open, for someone who can, when it is not available. */
|
|
30
|
+
openIn?: { app: string; href: string }
|
|
31
|
+
/** The part's blocks, drawn by the app. */
|
|
32
|
+
children?: ReactNode
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function PartCard({ state = 'ready', kind, heading, href, partKey, noun = 'Item', openIn, children }: PartCardProps) {
|
|
36
|
+
if (state === 'loading') return <LinkCardLoading kind={kind} shape={{ summary: true, rows: 0, footer: 'none' }} />
|
|
37
|
+
if (state === 'unavailable') return <LinkUnavailable kind={kind} noun={`A part of this ${noun.toLowerCase()}`} openIn={openIn} />
|
|
38
|
+
const header = (
|
|
39
|
+
<>
|
|
40
|
+
<LinkedIcon kind={kind} />
|
|
41
|
+
{partKey && <LinkKey>{partKey}</LinkKey>}
|
|
42
|
+
<span className="truncate">{heading}</span>
|
|
43
|
+
</>
|
|
44
|
+
)
|
|
45
|
+
return (
|
|
46
|
+
<Card fill="inset" className="flex flex-col gap-2 p-3">
|
|
47
|
+
<span className="flex min-w-0 items-center gap-2 text-body-2-strong text-text-primary">
|
|
48
|
+
{href ? (
|
|
49
|
+
<Link href={href} variant="quiet" className="flex min-w-0 items-center gap-2">
|
|
50
|
+
{header}
|
|
51
|
+
</Link>
|
|
52
|
+
) : (
|
|
53
|
+
header
|
|
54
|
+
)}
|
|
55
|
+
</span>
|
|
56
|
+
<div className="border-l-2 border-border-default pl-3">{children}</div>
|
|
57
|
+
</Card>
|
|
58
|
+
)
|
|
59
|
+
}
|
package/src/Skeleton.tsx
CHANGED
|
@@ -10,10 +10,14 @@ import { cn } from './cn'
|
|
|
10
10
|
* A list reveals after 150ms (`animate-skeleton-in`, in the preset) so a
|
|
11
11
|
* fast load never flashes a skeleton — Peek's rule.
|
|
12
12
|
*/
|
|
13
|
+
const SKELETON_SHAPE = { bar: 'rounded-sm', round: 'rounded-full', control: 'rounded-md' } as const
|
|
14
|
+
|
|
13
15
|
/** One pulsing bar at the inset colour: the piece every placeholder here is
|
|
14
|
-
* built from. It draws nothing of its own — give it a width and a height.
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
* built from. It draws nothing of its own — give it a width and a height.
|
|
17
|
+
* `on="inset"` on an inset card, where the inset colour would vanish;
|
|
18
|
+
* `shape` for a line of words, a face or a ring, or a control. */
|
|
19
|
+
export function SkeletonBar({ className, style, on = 'surface', shape = 'bar' }: { className?: string; style?: CSSProperties; on?: 'surface' | 'inset'; shape?: keyof typeof SKELETON_SHAPE }) {
|
|
20
|
+
return <div className={cn('animate-pulse', SKELETON_SHAPE[shape], on === 'inset' ? 'bg-bg-active' : 'bg-bg-inset', className)} style={style} />
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
const ROW_BAR_WIDTHS = [150, 100, 170, 120, 90, 140, 110, 160]
|
|
@@ -60,8 +60,9 @@ tester.run('no-native-title', noNativeTitle, {
|
|
|
60
60
|
|
|
61
61
|
describe('PARTS_WITH_A_TITLE', () => {
|
|
62
62
|
it('is every part the catalogue says takes a title of its own, and no other', () => {
|
|
63
|
-
const registry = JSON.parse(readFileSync(new URL('../../registry.json', import.meta.url), 'utf8')) as { entries: { name: string; props?: { name: string }[] | null }[] }
|
|
64
|
-
|
|
63
|
+
const registry = JSON.parse(readFileSync(new URL('../../registry.json', import.meta.url), 'utf8')) as { entries: { name: string; kind: string; props?: { name: string }[] | null }[] }
|
|
64
|
+
// A part is a component: a helper whose argument has a `title` field (`loadingShapeOf`) is not drawn with one.
|
|
65
|
+
const declared = registry.entries.filter((entry) => entry.kind === 'component' && entry.props?.some((prop) => prop.name === 'title')).map((entry) => entry.name).sort()
|
|
65
66
|
expect([...PARTS_WITH_A_TITLE].sort()).toEqual(declared)
|
|
66
67
|
})
|
|
67
68
|
})
|
package/src/index.ts
CHANGED
|
@@ -84,6 +84,31 @@ export { ShowMore, type ShowMoreProps } from './ShowMore'
|
|
|
84
84
|
export { ReactionPicker, type ReactionOption, type ReactionPickerProps } from './ReactionPicker'
|
|
85
85
|
export { Toolbar, ToolbarButton, ToolbarInput, ToolbarLink, ToolbarSeparator, type ToolbarProps, type ToolbarButtonProps, type ToolbarInputProps, type ToolbarLinkProps } from './Toolbar'
|
|
86
86
|
export { PreviewCard, type PreviewCardProps } from './PreviewCard'
|
|
87
|
+
// How a link draws, wherever it sits: one kit for every app (Widgets 2).
|
|
88
|
+
export {
|
|
89
|
+
chosenOf,
|
|
90
|
+
loadingShapeOf,
|
|
91
|
+
unavailableMemory,
|
|
92
|
+
type Linked,
|
|
93
|
+
type LinkedChild,
|
|
94
|
+
type LinkedConversation,
|
|
95
|
+
type LinkedKind,
|
|
96
|
+
type LinkedPerson,
|
|
97
|
+
type LinkedPicker,
|
|
98
|
+
type LinkedStatus,
|
|
99
|
+
type LinkState,
|
|
100
|
+
type LoadingShape,
|
|
101
|
+
type PickerOption,
|
|
102
|
+
type StatusColour,
|
|
103
|
+
type UnavailableMemory,
|
|
104
|
+
} from './linked'
|
|
105
|
+
export { CommentCount, LinkedIcon, LinkKey, LinkPickers, ProgressRing, StatusMark, type LinkPickersProps } from './LinkParts'
|
|
106
|
+
export { LinkChip, type LinkChipProps } from './LinkChip'
|
|
107
|
+
export { LinkRow, LinkUnavailable, type LinkRowProps, type LinkUnavailableProps } from './LinkRow'
|
|
108
|
+
export { LinkCard, LinkCardLoading, type LinkCardProps, type LinkCardLoadingProps } from './LinkCard'
|
|
109
|
+
export { LinkPreview, LinkPreviewOpen, useCanHover, type LinkPreviewProps } from './LinkPreview'
|
|
110
|
+
export { CardUnder, type CardUnderProps } from './CardUnder'
|
|
111
|
+
export { PartCard, type PartCardProps } from './PartCard'
|
|
87
112
|
export { Person, type PersonProps } from './Person'
|
|
88
113
|
export { Rail, type RailProps } from './Rail'
|
|
89
114
|
export { RailItem, type RailItemProps } from './RailItem'
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Placeholder links for the link widgets' stories and tests: neutral words,
|
|
3
|
+
* every field one of the widgets draws. Not exported from the package.
|
|
4
|
+
*/
|
|
5
|
+
import type { Linked, LinkedPicker, LinkState, PickerOption } from './linked'
|
|
6
|
+
|
|
7
|
+
const noop = () => {}
|
|
8
|
+
|
|
9
|
+
export const STATUS_OPTIONS: PickerOption[] = [
|
|
10
|
+
{ value: 'todo', label: 'Todo', colour: 'neutral' },
|
|
11
|
+
{ value: 'doing', label: 'In progress', colour: 'blue' },
|
|
12
|
+
{ value: 'done', label: 'Done', colour: 'green' },
|
|
13
|
+
{ value: 'dropped', label: 'Cancelled', colour: 'muted' },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
export const PEOPLE: PickerOption[] = [
|
|
17
|
+
{ value: 'a', label: 'Person one' },
|
|
18
|
+
{ value: 'b', label: 'Person two' },
|
|
19
|
+
{ value: 'c', label: 'Person three' },
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
const picker = (value: string | undefined, options: PickerOption[], mayChange = true): LinkedPicker => ({ value, options, onChange: mayChange ? noop : undefined })
|
|
23
|
+
|
|
24
|
+
const SUMMARY = 'A one-sentence summary of what this is for.'
|
|
25
|
+
|
|
26
|
+
/** A tracked item: a key, a status and an owner the reader may change, and a conversation. */
|
|
27
|
+
export const ITEM: Linked = {
|
|
28
|
+
id: 'example:item',
|
|
29
|
+
kind: 'issue',
|
|
30
|
+
title: 'Item one, with a title long enough to be cut short',
|
|
31
|
+
href: '#item',
|
|
32
|
+
key: 'ABC-2',
|
|
33
|
+
subtitle: 'Group one',
|
|
34
|
+
summary: SUMMARY,
|
|
35
|
+
status: picker('doing', STATUS_OPTIONS),
|
|
36
|
+
owner: picker('a', PEOPLE),
|
|
37
|
+
ownerLabel: 'Owner',
|
|
38
|
+
conversation: { count: 4, href: '#item-conversation' },
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The same item, to a reader who may not change it. */
|
|
42
|
+
export const ITEM_READ_ONLY: Linked = { ...ITEM, status: picker('doing', STATUS_OPTIONS, false), owner: picker('a', PEOPLE, false) }
|
|
43
|
+
|
|
44
|
+
/** The same item with something unread in its conversation, as a member sees it. */
|
|
45
|
+
export const ITEM_UNREAD: Linked = { ...ITEM, conversation: { count: 5, href: '#item-conversation', unread: true } }
|
|
46
|
+
|
|
47
|
+
/** An item with no summary: its card shows the title alone. */
|
|
48
|
+
export const ITEM_NO_SUMMARY: Linked = { ...ITEM, id: 'example:item-2', title: 'Item two', key: 'ABC-3', summary: undefined, owner: picker(undefined, PEOPLE) }
|
|
49
|
+
|
|
50
|
+
/** A group of items: its progress, its owner, and the items inside it. */
|
|
51
|
+
export const GROUP: Linked = {
|
|
52
|
+
id: 'example:group',
|
|
53
|
+
kind: 'project',
|
|
54
|
+
title: 'Group one',
|
|
55
|
+
href: '#group',
|
|
56
|
+
summary: SUMMARY,
|
|
57
|
+
status: picker('doing', STATUS_OPTIONS),
|
|
58
|
+
owner: picker('b', PEOPLE),
|
|
59
|
+
ownerLabel: 'Lead',
|
|
60
|
+
done: { done: 2, total: 5 },
|
|
61
|
+
conversation: { count: 2, href: '#group-conversation' },
|
|
62
|
+
children: [
|
|
63
|
+
{ id: 'example:c1', title: 'Item one', href: '#c1', key: 'ABC-1', status: { label: 'Done', colour: 'green' }, owner: { name: 'Person one' } },
|
|
64
|
+
{ id: 'example:c2', title: 'Item two', href: '#c2', key: 'ABC-2', status: { label: 'In progress', colour: 'blue' }, owner: { name: 'Person two' } },
|
|
65
|
+
{ id: 'example:c3', title: 'Item three', href: '#c3', key: 'ABC-3', status: { label: 'Todo', colour: 'neutral' } },
|
|
66
|
+
],
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A place people talk: its members, when it last moved, and its newest conversation. */
|
|
70
|
+
export const PLACE: Linked = {
|
|
71
|
+
id: 'example:place',
|
|
72
|
+
kind: 'topic',
|
|
73
|
+
title: 'Place one',
|
|
74
|
+
href: '#place',
|
|
75
|
+
summary: SUMMARY,
|
|
76
|
+
members: [{ name: 'Person one' }, { name: 'Person two' }, { name: 'Person three' }],
|
|
77
|
+
updated: '2h ago',
|
|
78
|
+
conversation: { count: 12, href: '#place-conversations', noun: 'conversations' },
|
|
79
|
+
latest: { author: { name: 'Person two' }, body: 'A few words someone wrote, long enough to need a second line before the card cuts them short.', at: '2h ago' },
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A page of text: who wrote it, and when it last changed. */
|
|
83
|
+
export const PAGE: Linked = {
|
|
84
|
+
id: 'example:page',
|
|
85
|
+
kind: 'page',
|
|
86
|
+
title: 'Page one',
|
|
87
|
+
href: '#page',
|
|
88
|
+
subtitle: 'Shelf one',
|
|
89
|
+
summary: SUMMARY,
|
|
90
|
+
authors: [{ name: 'Person one' }, { name: 'Person three' }],
|
|
91
|
+
updated: 'Yesterday',
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Words a person wrote, linked as themselves. */
|
|
95
|
+
export const SAID: Linked = {
|
|
96
|
+
id: 'example:said',
|
|
97
|
+
kind: 'message',
|
|
98
|
+
title: 'A few words someone wrote',
|
|
99
|
+
href: '#said',
|
|
100
|
+
author: { name: 'Person one' },
|
|
101
|
+
body: 'A few words someone wrote, linked from somewhere else. Long enough to wrap onto a second line.',
|
|
102
|
+
updated: '10:42',
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const ready = (linked: Linked): LinkState => ({ state: 'ready', linked })
|
|
106
|
+
export const LOADING: LinkState = { state: 'loading', kind: 'issue' }
|
|
107
|
+
export const UNAVAILABLE: LinkState = { state: 'unavailable', kind: 'issue', noun: 'Item', openIn: { app: 'App one', href: '#elsewhere' } }
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* The device's memory of links it could not show: kept across a reload, so
|
|
4
|
+
* the next open draws "not available." from the first paint, and forgotten
|
|
5
|
+
* when the link can be read again or at sign-out.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
8
|
+
import { unavailableMemory } from './linked'
|
|
9
|
+
|
|
10
|
+
afterEach(() => localStorage.clear())
|
|
11
|
+
|
|
12
|
+
describe('unavailableMemory', () => {
|
|
13
|
+
it('remembers a link it could not show across a reload', () => {
|
|
14
|
+
unavailableMemory('test.unavailable').note('a', false)
|
|
15
|
+
// A new page: a fresh memory reads what the last one wrote.
|
|
16
|
+
expect(unavailableMemory('test.unavailable').has('a')).toBe(true)
|
|
17
|
+
expect(unavailableMemory('test.unavailable').has('b')).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('forgets it once it can be read again', () => {
|
|
21
|
+
const memory = unavailableMemory('test.unavailable')
|
|
22
|
+
memory.note('a', false)
|
|
23
|
+
memory.note('a', true)
|
|
24
|
+
expect(unavailableMemory('test.unavailable').has('a')).toBe(false)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('keeps what another memory on the same key wrote since it was read', () => {
|
|
28
|
+
const one = unavailableMemory('test.unavailable')
|
|
29
|
+
const two = unavailableMemory('test.unavailable')
|
|
30
|
+
expect(two.has('a')).toBe(false)
|
|
31
|
+
one.note('a', false)
|
|
32
|
+
two.note('b', false)
|
|
33
|
+
const next = unavailableMemory('test.unavailable')
|
|
34
|
+
expect([next.has('a'), next.has('b')]).toEqual([true, true])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('keeps each app’s memory apart', () => {
|
|
38
|
+
unavailableMemory('one.unavailable').note('a', false)
|
|
39
|
+
expect(unavailableMemory('two.unavailable').has('a')).toBe(false)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('forgets everything at sign-out', () => {
|
|
43
|
+
const memory = unavailableMemory('test.unavailable')
|
|
44
|
+
memory.note('a', false)
|
|
45
|
+
memory.clear()
|
|
46
|
+
expect(memory.has('a')).toBe(false)
|
|
47
|
+
expect(unavailableMemory('test.unavailable').has('a')).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('keeps only the newest, up to its limit', () => {
|
|
51
|
+
const memory = unavailableMemory('test.unavailable', 2)
|
|
52
|
+
memory.note('a', false)
|
|
53
|
+
memory.note('b', false)
|
|
54
|
+
memory.note('c', false)
|
|
55
|
+
const next = unavailableMemory('test.unavailable', 2)
|
|
56
|
+
expect([next.has('a'), next.has('b'), next.has('c')]).toEqual([false, true, true])
|
|
57
|
+
})
|
|
58
|
+
})
|
package/src/linked.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a link stands for, as the link widgets draw it (LinkChip, LinkRow,
|
|
3
|
+
* LinkCard, LinkPreview, CardUnder). The app builds it from its own lookup —
|
|
4
|
+
* every Estiva app from `@estiva-app/interop`'s projection — and hands it in
|
|
5
|
+
* whole: the words, the addresses, and the changes the reader may make. The
|
|
6
|
+
* widgets look nothing up, and keep nothing but what a reader does to them.
|
|
7
|
+
*
|
|
8
|
+
* The shape follows the prototype Miky approved (Widgets 2, 15 Oct; why:
|
|
9
|
+
* https://github.com/estiva-app/estiva-docs/blob/main/decisions/where-a-link-sits-and-which-file-gets-the-card.md).
|
|
10
|
+
*/
|
|
11
|
+
import type { ThingKind } from './KindIcon'
|
|
12
|
+
|
|
13
|
+
/** What a link stands for, by its icon. `page` draws a page of text. */
|
|
14
|
+
export type LinkedKind = ThingKind | 'page'
|
|
15
|
+
|
|
16
|
+
/** The colours a status draws in, as an app's manifest names them. */
|
|
17
|
+
export type StatusColour = 'neutral' | 'blue' | 'green' | 'muted'
|
|
18
|
+
|
|
19
|
+
/** One value of a status, as the reader sees it. */
|
|
20
|
+
export interface LinkedStatus {
|
|
21
|
+
label: string
|
|
22
|
+
colour: StatusColour
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A person, by the name the app shows and, when it has one, their picture. */
|
|
26
|
+
export interface LinkedPerson {
|
|
27
|
+
name: string
|
|
28
|
+
picture?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One choice a picker offers. */
|
|
32
|
+
export interface PickerOption {
|
|
33
|
+
value: string
|
|
34
|
+
label: string
|
|
35
|
+
/** A status's colour: the option draws its shape. */
|
|
36
|
+
colour?: StatusColour
|
|
37
|
+
/** A person's picture: the option draws their face. */
|
|
38
|
+
picture?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A value the reader may be able to change from the card: a status, or who
|
|
43
|
+
* owns it. With `onChange` it is a picker; without it, the reader may not
|
|
44
|
+
* change it, and it reads as words in the same place.
|
|
45
|
+
*/
|
|
46
|
+
export interface LinkedPicker {
|
|
47
|
+
/** The chosen option's `value`; `undefined` is nobody, for an owner. */
|
|
48
|
+
value: string | undefined
|
|
49
|
+
options: PickerOption[]
|
|
50
|
+
onChange?: (value: string) => void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The count of what was said about it, and where that is read. */
|
|
54
|
+
export interface LinkedConversation {
|
|
55
|
+
count: number
|
|
56
|
+
/** Opens the conversation, at the first unread when there is one. */
|
|
57
|
+
href: string
|
|
58
|
+
/**
|
|
59
|
+
* The reader has something unread there. Only a member of it has unread
|
|
60
|
+
* (SPEC §11.8; why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/membership-replaces-following.md),
|
|
61
|
+
* so the app passes `true` only for one.
|
|
62
|
+
*/
|
|
63
|
+
unread?: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One item listed inside a card, such as a group's members, each opening on its own. */
|
|
67
|
+
export interface LinkedChild {
|
|
68
|
+
id: string
|
|
69
|
+
title: string
|
|
70
|
+
href: string
|
|
71
|
+
key?: string
|
|
72
|
+
status: LinkedStatus
|
|
73
|
+
owner?: LinkedPerson
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Everything a link's widgets may draw about what it stands for. Only `id`, `kind`, `title` and `href` are always there. */
|
|
77
|
+
export interface Linked {
|
|
78
|
+
/** Its address. Two `Linked` with the same `id` are the same thing (identity by value, not by object). */
|
|
79
|
+
id: string
|
|
80
|
+
kind: LinkedKind
|
|
81
|
+
title: string
|
|
82
|
+
/** Opens it. An address in this app goes through `AppProvider`'s `navigate`. */
|
|
83
|
+
href: string
|
|
84
|
+
/** What the reader calls it, quietly before the title: "ABC-2". */
|
|
85
|
+
key?: string
|
|
86
|
+
/** The line under a card's title: what it is in. Drawn after the key. */
|
|
87
|
+
subtitle?: string
|
|
88
|
+
/** Its short summary, a sentence or two. Without one a card shows the title alone. */
|
|
89
|
+
summary?: string
|
|
90
|
+
/** Its status: each option carries its `colour`. */
|
|
91
|
+
status?: LinkedPicker
|
|
92
|
+
/** Who owns it, by the word the app uses (`ownerLabel`: "Assignee", "Lead"). */
|
|
93
|
+
owner?: LinkedPicker
|
|
94
|
+
ownerLabel?: string
|
|
95
|
+
/** What was said about it. `noun` names it for a screen reader: "comments" by default. */
|
|
96
|
+
conversation?: LinkedConversation & { noun?: string }
|
|
97
|
+
/** Items inside it, drawn as rows in its card. */
|
|
98
|
+
children?: LinkedChild[]
|
|
99
|
+
/** How far along it is, drawn as a ring. */
|
|
100
|
+
done?: { done: number; total: number }
|
|
101
|
+
/** Who is in it, as faces. */
|
|
102
|
+
members?: LinkedPerson[]
|
|
103
|
+
/** Who wrote it, as faces. */
|
|
104
|
+
authors?: LinkedPerson[]
|
|
105
|
+
/** When it last changed, in the app's words: "2h ago". */
|
|
106
|
+
updated?: string
|
|
107
|
+
/** The newest thing said in it, drawn only when the reader may read it. */
|
|
108
|
+
latest?: { author: LinkedPerson; body: string; at: string }
|
|
109
|
+
/** Words a person wrote, linked as themselves: drawn with `author`'s face, not an icon. */
|
|
110
|
+
body?: string
|
|
111
|
+
author?: LinkedPerson
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** What a card's last line holds: pickers, faces, a line of words, or nothing. */
|
|
115
|
+
export type LoadingFooter = 'pickers' | 'people' | 'line' | 'none'
|
|
116
|
+
|
|
117
|
+
/** The parts of a card known before it loads, so its placeholder takes the card's height. */
|
|
118
|
+
export interface LoadingShape {
|
|
119
|
+
subtitle?: boolean
|
|
120
|
+
summary?: boolean
|
|
121
|
+
rows?: number
|
|
122
|
+
latest?: boolean
|
|
123
|
+
ring?: boolean
|
|
124
|
+
footer?: LoadingFooter
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Where a link's lookup stands. `loading` knows the kind from the address, and
|
|
129
|
+
* a shape when the app remembers one; `unavailable` is deleted or not the
|
|
130
|
+
* reader's to see, which read the same (Miky, 15 Oct).
|
|
131
|
+
*/
|
|
132
|
+
export type LinkState =
|
|
133
|
+
| { state: 'loading'; kind: LinkedKind; shape?: LoadingShape }
|
|
134
|
+
| {
|
|
135
|
+
state: 'unavailable'
|
|
136
|
+
kind: LinkedKind
|
|
137
|
+
/** What it is, in a word: "Item". */
|
|
138
|
+
noun: string
|
|
139
|
+
/** Where it may still open for someone who can: the app that owns it, and its address. */
|
|
140
|
+
openIn?: { app: string; href: string }
|
|
141
|
+
}
|
|
142
|
+
| { state: 'ready'; linked: Linked }
|
|
143
|
+
|
|
144
|
+
/** A picker's chosen option, or none. */
|
|
145
|
+
export function chosenOf(picker: LinkedPicker | undefined): PickerOption | undefined {
|
|
146
|
+
return picker?.value === undefined ? undefined : picker.options.find((option) => option.value === picker.value)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The shape `linked`'s card takes, for its placeholder the next time it loads. */
|
|
150
|
+
export function loadingShapeOf(linked: Linked): LoadingShape {
|
|
151
|
+
return {
|
|
152
|
+
subtitle: !!(linked.subtitle || linked.key),
|
|
153
|
+
summary: !!linked.summary,
|
|
154
|
+
rows: linked.children?.length ?? 0,
|
|
155
|
+
latest: !!linked.latest,
|
|
156
|
+
ring: !!linked.done,
|
|
157
|
+
footer: linked.status || linked.owner ? 'pickers' : linked.authors ? 'people' : 'line',
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* What this device last found it could not show, kept so the next open draws
|
|
163
|
+
* "not available." from the first paint instead of a card's height that then
|
|
164
|
+
* closes (Peek's OPT-10; Miky, 15 Oct). One per app, under its own key, in
|
|
165
|
+
* localStorage because the first paint reads it synchronously. The app clears
|
|
166
|
+
* it with the rest of the device's copy at sign-out.
|
|
167
|
+
*/
|
|
168
|
+
export interface UnavailableMemory {
|
|
169
|
+
/** Whether this device last found `id` not available. */
|
|
170
|
+
has: (id: string) => boolean
|
|
171
|
+
/** The lookup's answer: remembered when not available, forgotten when it is. */
|
|
172
|
+
note: (id: string, available: boolean) => void
|
|
173
|
+
/** Sign-out. */
|
|
174
|
+
clear: () => void
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The device's memory of links it could not show, under the app's own `storageKey`, keeping the newest `limit`. */
|
|
178
|
+
export function unavailableMemory(storageKey: string, limit = 300): UnavailableMemory {
|
|
179
|
+
// Read once per page: a screen of cards asks many times.
|
|
180
|
+
let held: Set<string> | undefined
|
|
181
|
+
const list = () => {
|
|
182
|
+
if (held) return held
|
|
183
|
+
try {
|
|
184
|
+
const stored: unknown = JSON.parse(localStorage.getItem(storageKey) ?? '[]')
|
|
185
|
+
held = new Set(Array.isArray(stored) ? stored.filter((item): item is string => typeof item === 'string') : [])
|
|
186
|
+
} catch {
|
|
187
|
+
held = new Set()
|
|
188
|
+
}
|
|
189
|
+
return held
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
has: (id) => list().has(id),
|
|
193
|
+
note: (id, available) => {
|
|
194
|
+
// Re-read before writing: another memory on this key, or another tab's sign-out, may have changed it since.
|
|
195
|
+
held = undefined
|
|
196
|
+
const current = list()
|
|
197
|
+
if (available !== current.has(id)) return
|
|
198
|
+
const next = new Set(current)
|
|
199
|
+
if (available) next.delete(id)
|
|
200
|
+
else next.add(id)
|
|
201
|
+
held = new Set([...next].slice(-limit))
|
|
202
|
+
try {
|
|
203
|
+
localStorage.setItem(storageKey, JSON.stringify([...held]))
|
|
204
|
+
} catch {
|
|
205
|
+
// Full or blocked storage: the next page draws the card as if never seen, which is only the first-open jump.
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
clear: () => {
|
|
209
|
+
held = undefined
|
|
210
|
+
try {
|
|
211
|
+
localStorage.removeItem(storageKey)
|
|
212
|
+
} catch {
|
|
213
|
+
// Nothing to clear where storage is blocked.
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -48,20 +48,20 @@ describe('the registry builds', () => {
|
|
|
48
48
|
expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
|
|
49
49
|
})
|
|
50
50
|
|
|
51
|
-
it('counts
|
|
51
|
+
it('counts 197 names over 92 files: 134 components, 60 helpers and 3 hooks (the link widgets — LinkChip, LinkRow, LinkCard, CardUnder, PartCard, LinkPreview and their parts, with useCanHover, chosenOf, loadingShapeOf and unavailableMemory — from the Widgets 2 prototype, on 16 October; InlineText, a title or the line under it edited where it stands (SUM-4), on 16 October; ComposeBox with ComposeKeys and its classes, CommentHighlights with its five helpers, and keepSelection and releaseSelection, from the Leaf prototype (LEAF-4), on 16 October;NavTree, NavTreeSection, the composer triggers and UnreadDot joined on 2 October; BlockHandle and EditorBlockHandle (RIC-18) on 2 October, moveBlock and blockDropCursor, the drop in the gutter, on 5 October, MembersPill and MembersDialog, moved from Peek (SHI-31), on 6 October, ShowMore on 7 October, AppProvider on 8 October, and KindIcon, thingKindOf and PastedImages with its two helpers, moved from Peek, on 8 October; CodeBlockColours, the coloured code block of the editor, on 9 October; Checklist, Callout, isChecklistTick and calloutLook (RIC-19) on 13 October; EditorTableControls and its four actions (RIC-21) on 13 October; ReferenceCards, useReferenceCards and its three helpers, the card a chip in a document asks for, on 14 October; LinkChips and its two helpers, a URL in a document read as its chip, on 14 October; ReferenceMenu, ReferenceMenuView and three helpers, the menu of a chip while writing, on 14 October; PastedMarkdown, markdown pasted as the blocks it spells (LEAF-10), on 15 October; turnIntoRows, turnListInto and turnQuoteInto, the block menu’s Turn into for a list and a quote (LEAF-11), on 15 October)', () => {
|
|
52
52
|
// The reconciliation GATES.md §24 explains. If this changes, that changes.
|
|
53
53
|
// The `EDITOR_EXTENSIONS` count as components by their names; they are
|
|
54
54
|
// editor extensions, documented on SelectionToolbar's, BlockNodes' and
|
|
55
55
|
// ComposerTriggers' pages.
|
|
56
56
|
const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
|
|
57
|
-
expect(kinds).toEqual({ component:
|
|
58
|
-
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(
|
|
57
|
+
expect(kinds).toEqual({ component: 134, helper: 60, hook: 3 })
|
|
58
|
+
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(92)
|
|
59
59
|
})
|
|
60
60
|
|
|
61
61
|
it('gives every entry a purpose, from its own page or from the comment above it', () => {
|
|
62
62
|
expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
|
|
63
|
-
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(
|
|
64
|
-
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(
|
|
63
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(86)
|
|
64
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(111)
|
|
65
65
|
})
|
|
66
66
|
|
|
67
67
|
it('takes a name with a page of its own from the page, and a name without one from the code', () => {
|
|
@@ -262,8 +262,8 @@ describe('the ids point at something', () => {
|
|
|
262
262
|
|
|
263
263
|
it('gives every component a page and a story, and only a helper none', () => {
|
|
264
264
|
const without = registry.entries.filter((one) => one.docsId === null || one.storyId === null)
|
|
265
|
-
// The helpers in a file with no page: cn, the shortcut names,
|
|
266
|
-
expect(without.map((one) => one.name).sort()).toEqual(['cn', 'isApplePlatform', 'isSuggestionActive', 'isSuggestionOpen', 'shortcutLabel', 'suggestionPopup'])
|
|
265
|
+
// The helpers in a file with no page: cn, the shortcut names, the editor's suggestion wiring (documented on SuggestionMenu's page), and the link widgets' data helpers (documented on LinkCard's and LinkChip's pages).
|
|
266
|
+
expect(without.map((one) => one.name).sort()).toEqual(['chosenOf', 'cn', 'isApplePlatform', 'isSuggestionActive', 'isSuggestionOpen', 'loadingShapeOf', 'shortcutLabel', 'suggestionPopup', 'unavailableMemory'])
|
|
267
267
|
expect(without.every((one) => one.kind !== 'component')).toBe(true)
|
|
268
268
|
})
|
|
269
269
|
|
|
@@ -299,7 +299,7 @@ describe('the ids point at something', () => {
|
|
|
299
299
|
describe('a second parser agrees', async () => {
|
|
300
300
|
const docgen = await import('react-docgen').catch(() => null)
|
|
301
301
|
|
|
302
|
-
it.skipIf(!docgen)('finds the same props on all
|
|
302
|
+
it.skipIf(!docgen)('finds the same props on all 86 components', async () => {
|
|
303
303
|
if (!docgen) return
|
|
304
304
|
const resolver = new docgen.builtinResolvers.FindExportedDefinitionsResolver({ limit: 0 })
|
|
305
305
|
const byName = new Map(registry.entries.map((one) => [one.name, one]))
|
package/stories/Choosing.mdx
CHANGED
|
@@ -106,6 +106,13 @@ fork freely, owe nothing back, mention it on the package's ticket.
|
|
|
106
106
|
| A URL in a document that reads as the chip of what it points at | **LinkChips** — from `@estiva-app/ui/editor`, beside `ReferenceNode`; the app says what each URL names |
|
|
107
107
|
| Open a chip in a document, or choose its card, while writing | **ReferenceMenu** — from `@estiva-app/ui/editor`, beside `ReferenceCards`; a click on the chip, or putting one in, opens it |
|
|
108
108
|
| A code block in an editor, coloured by its language as you type | **CodeBlockColours** — from `@estiva-app/ui/editor`, in place of the starter kit's code block |
|
|
109
|
+
| A link to something an app looked up, in a sentence | **LinkChip** — hover shows its card; loading and "not available" drawn for you. **LinkChips** (editor) turns a URL in a document into one |
|
|
110
|
+
| A link alone on its line | **LinkRow** — one 36px line for every kind; **LinkUnavailable** when it cannot be read |
|
|
111
|
+
| A link's card: what a row cannot hold, with the pickers the reader may use | **LinkCard** — `onChange` on a picker only for a reader who may change it; **LinkCardLoading** at its height |
|
|
112
|
+
| The one card under a piece of text, with the ✕ that removes it | **CardUnder** — `revealOnHover` once it is sent |
|
|
113
|
+
| A link's card on hover, read only | **LinkPreview** — LinkChip and LinkRow already wrap themselves in it |
|
|
114
|
+
| A part of something longer, shared live | **PartCard** — the app draws the blocks |
|
|
115
|
+
| A status and an owner changed in place, a conversation count with its unread dot, a ring | **LinkParts** — `LinkPickers`, `CommentCount`, `StatusMark`, `ProgressRing` |
|
|
109
116
|
| A person or a thing named inside a sentence | **InlineChip** — one line high, level with the words; `href` makes it a link |
|
|
110
117
|
| What kind of thing a name stands for — a topic, a project, an issue, a file, a message | **KindIcon** — before the name in a row, or inside an **InlineChip**; `thingKindOf` turns an event kind into one |
|
|
111
118
|
| Formatted text: headings, lists, a quote, code, a table, links | **RichText** — pass the parsed tree; `small` inside a card |
|