@estiva-app/ui 0.74.1 → 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/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/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/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
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { GROUP, ITEM, ITEM_UNREAD, LOADING, PAGE, PLACE, ready, SAID, UNAVAILABLE } from './linked.examples'
|
|
3
|
+
import { LinkRow, LinkUnavailable } from './LinkRow'
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Components/LinkRow',
|
|
7
|
+
component: LinkRow,
|
|
8
|
+
parameters: { layout: 'padded' },
|
|
9
|
+
args: { link: ready(ITEM) },
|
|
10
|
+
decorators: [(Story) => <div className="max-w-[480px]">{Story()}</div>],
|
|
11
|
+
} satisfies Meta<typeof LinkRow>
|
|
12
|
+
|
|
13
|
+
export default meta
|
|
14
|
+
type Story = StoryObj<typeof meta>
|
|
15
|
+
|
|
16
|
+
/** A link alone on its line. Hover it for its card. */
|
|
17
|
+
export const Default: Story = {}
|
|
18
|
+
|
|
19
|
+
/** Every kind: the corner says what each one has. */
|
|
20
|
+
export const EachKind: Story = {
|
|
21
|
+
render: () => (
|
|
22
|
+
<div className="flex flex-col gap-2">
|
|
23
|
+
<LinkRow link={ready(ITEM)} />
|
|
24
|
+
<LinkRow link={ready(GROUP)} />
|
|
25
|
+
<LinkRow link={ready(PLACE)} />
|
|
26
|
+
<LinkRow link={ready(PAGE)} />
|
|
27
|
+
<LinkRow link={ready(SAID)} />
|
|
28
|
+
</div>
|
|
29
|
+
),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Something unread in its conversation, to a member: the count shows, with its dot. */
|
|
33
|
+
export const Unread: Story = { args: { link: ready(ITEM_UNREAD) } }
|
|
34
|
+
|
|
35
|
+
/** Still being looked up: the row in bars, at the row's height. */
|
|
36
|
+
export const Loading: Story = { args: { link: LOADING } }
|
|
37
|
+
|
|
38
|
+
/** Deleted, or not this reader's to see: one line, and where it may still open. */
|
|
39
|
+
export const NotAvailable: Story = { args: { link: UNAVAILABLE } }
|
|
40
|
+
|
|
41
|
+
/** The not-available line on its own, as a card or a part draws it too; without `openIn`, no way out. */
|
|
42
|
+
export const NotAvailableLine: Story = {
|
|
43
|
+
render: () => (
|
|
44
|
+
<div className="flex flex-col gap-2">
|
|
45
|
+
<LinkUnavailable kind="issue" noun="Item" openIn={{ app: 'App one', href: '#elsewhere' }} />
|
|
46
|
+
<LinkUnavailable kind="page" noun="Page" />
|
|
47
|
+
</div>
|
|
48
|
+
),
|
|
49
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/** What the LinkRow page claims, pinned: where the row and its count lead, and when the count shows. */
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { ITEM, ITEM_UNREAD, LOADING, PLACE, ready, UNAVAILABLE } from './linked.examples'
|
|
6
|
+
import { LinkRow } from './LinkRow'
|
|
7
|
+
|
|
8
|
+
afterEach(cleanup)
|
|
9
|
+
|
|
10
|
+
describe('LinkRow', () => {
|
|
11
|
+
it('opens what it links from the whole row', () => {
|
|
12
|
+
render(<LinkRow link={ready(ITEM)} preview={false} />)
|
|
13
|
+
expect(screen.getByRole('link', { name: ITEM.title }).getAttribute('href')).toBe('#item')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('keeps to one line: the count shows only when something in it is unread', () => {
|
|
17
|
+
const { rerender } = render(<LinkRow link={ready(ITEM)} preview={false} />)
|
|
18
|
+
expect(screen.queryByRole('link', { name: /open the conversation/ })).toBeNull()
|
|
19
|
+
rerender(<LinkRow link={ready(ITEM_UNREAD)} preview={false} />)
|
|
20
|
+
expect(screen.getByRole('link', { name: /unread — open the conversation/ }).getAttribute('href')).toBe('#item-conversation')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('always shows the count where conversations are what it holds', () => {
|
|
24
|
+
render(<LinkRow link={ready(PLACE)} preview={false} />)
|
|
25
|
+
expect(screen.getByRole('link', { name: /conversations — open the conversation/ })).toBeTruthy()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('while it loads, is busy and leads nowhere yet', () => {
|
|
29
|
+
const { container } = render(<LinkRow link={LOADING} />)
|
|
30
|
+
expect(container.querySelector('[aria-busy="true"]')).not.toBeNull()
|
|
31
|
+
expect(screen.queryByRole('link')).toBeNull()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('says it is not available in one line', () => {
|
|
35
|
+
render(<LinkRow link={UNAVAILABLE} />)
|
|
36
|
+
expect(screen.getByText('Item — not available.')).toBeTruthy()
|
|
37
|
+
})
|
|
38
|
+
})
|
package/src/LinkRow.tsx
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A link alone on its line: one 36px row for every kind. Hovering it shows its
|
|
3
|
+
* card; a tap opens it. Loading draws the row in bars; not available says so
|
|
4
|
+
* in one line of the same height.
|
|
5
|
+
*/
|
|
6
|
+
import { Avatar } from './Avatar'
|
|
7
|
+
import { AvatarGroup } from './AvatarGroup'
|
|
8
|
+
import { Card } from './Card'
|
|
9
|
+
import { Link } from './Link'
|
|
10
|
+
import { chosenOf, type LinkedKind, type LinkState } from './linked'
|
|
11
|
+
import { CommentCount, LinkedIcon, LinkKey, ProgressRing, StatusMark } from './LinkParts'
|
|
12
|
+
import { LinkPreview } from './LinkPreview'
|
|
13
|
+
import { SkeletonBar } from './Skeleton'
|
|
14
|
+
|
|
15
|
+
export interface LinkUnavailableProps {
|
|
16
|
+
kind: LinkedKind
|
|
17
|
+
/** What it is, in a word: "Item". Drawn as "Item — not available.". */
|
|
18
|
+
noun: string
|
|
19
|
+
/** Where it may still open, for someone who can: its app and address. */
|
|
20
|
+
openIn?: { app: string; href: string }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Not available: deleted, or not this reader's to see, which read the same.
|
|
25
|
+
* One line at a row's height, whether a row or a card was to draw. The way
|
|
26
|
+
* out stays, since the app it lives in may still open it for you.
|
|
27
|
+
*/
|
|
28
|
+
export function LinkUnavailable({ kind, noun, openIn }: LinkUnavailableProps) {
|
|
29
|
+
return (
|
|
30
|
+
<Card fill="none" unreadable className="flex h-9 items-center gap-2 px-3">
|
|
31
|
+
<LinkedIcon kind={kind} />
|
|
32
|
+
<span className="min-w-0 flex-1 truncate text-body-2 text-text-muted">{noun} — not available.</span>
|
|
33
|
+
{openIn && (
|
|
34
|
+
<span className="shrink-0 text-caption text-text-secondary">
|
|
35
|
+
<Link href={openIn.href} variant="quiet" external>
|
|
36
|
+
Open in {openIn.app} ↗
|
|
37
|
+
</Link>
|
|
38
|
+
</span>
|
|
39
|
+
)}
|
|
40
|
+
</Card>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface LinkRowProps {
|
|
45
|
+
link: LinkState
|
|
46
|
+
/** Hovering shows its card. */
|
|
47
|
+
preview?: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function LinkRow({ link, preview = true }: LinkRowProps) {
|
|
51
|
+
if (link.state === 'loading') {
|
|
52
|
+
return (
|
|
53
|
+
<div className="animate-skeleton-in" aria-busy>
|
|
54
|
+
<Card fill="inset" aria-hidden className="flex h-9 items-center gap-2 px-3">
|
|
55
|
+
<LinkedIcon kind={link.kind} />
|
|
56
|
+
<SkeletonBar on="inset" className="h-3 w-2/5" />
|
|
57
|
+
<SkeletonBar on="inset" shape="round" className="ml-auto size-4" />
|
|
58
|
+
</Card>
|
|
59
|
+
</div>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
if (link.state === 'unavailable') return <LinkUnavailable kind={link.kind} noun={link.noun} openIn={link.openIn} />
|
|
63
|
+
const { linked } = link
|
|
64
|
+
const status = chosenOf(linked.status)
|
|
65
|
+
const owner = chosenOf(linked.owner)
|
|
66
|
+
const said = linked.kind === 'message' && linked.author
|
|
67
|
+
const row = (
|
|
68
|
+
<Card fill="inset" hover="hairline" className="relative flex h-9 items-center gap-2 px-3">
|
|
69
|
+
{said ? <Avatar name={said.name} src={said.picture} size={16} /> : <LinkedIcon kind={linked.kind} />}
|
|
70
|
+
{said ? (
|
|
71
|
+
<span className="flex min-w-0 flex-1 items-baseline gap-2 text-body-2">
|
|
72
|
+
<span className="shrink-0 text-body-2-strong text-text-primary">{said.name}</span>
|
|
73
|
+
<span className="contents text-text-secondary">
|
|
74
|
+
<Link href={linked.href} variant="quiet" cover truncate>
|
|
75
|
+
{linked.body ?? linked.title}
|
|
76
|
+
</Link>
|
|
77
|
+
</span>
|
|
78
|
+
</span>
|
|
79
|
+
) : (
|
|
80
|
+
<>
|
|
81
|
+
{linked.key && <LinkKey>{linked.key}</LinkKey>}
|
|
82
|
+
<span className="contents text-body-2-strong text-text-primary">
|
|
83
|
+
<Link href={linked.href} variant="quiet" cover truncate className="min-w-0 flex-1">
|
|
84
|
+
{linked.title}
|
|
85
|
+
</Link>
|
|
86
|
+
</span>
|
|
87
|
+
</>
|
|
88
|
+
)}
|
|
89
|
+
{linked.updated && <span className="shrink-0 text-caption text-text-secondary">{linked.updated}</span>}
|
|
90
|
+
{/* A row keeps to one line: the count shows only when there is something unread in it, except where conversations are what it holds. */}
|
|
91
|
+
{linked.conversation && (linked.conversation.unread || linked.kind === 'topic') && <CommentCount conversation={linked.conversation} noun={linked.conversation.noun} />}
|
|
92
|
+
{/* A ring already says how far along it is. */}
|
|
93
|
+
{status?.colour && !linked.done && <StatusMark status={{ label: status.label, colour: status.colour }} />}
|
|
94
|
+
{linked.done && <ProgressRing done={linked.done.done} total={linked.done.total} />}
|
|
95
|
+
{owner && <Avatar name={owner.label} src={owner.picture} size={16} />}
|
|
96
|
+
{linked.members && <AvatarGroup members={linked.members} size={16} />}
|
|
97
|
+
{linked.authors && <AvatarGroup members={linked.authors} size={16} />}
|
|
98
|
+
</Card>
|
|
99
|
+
)
|
|
100
|
+
return preview ? (
|
|
101
|
+
<LinkPreview linked={linked} block>
|
|
102
|
+
{row}
|
|
103
|
+
</LinkPreview>
|
|
104
|
+
) : (
|
|
105
|
+
row
|
|
106
|
+
)
|
|
107
|
+
}
|
package/src/PartCard.mdx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as PartCardStories from './PartCard.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={PartCardStories} />
|
|
5
|
+
|
|
6
|
+
# PartCard
|
|
7
|
+
|
|
8
|
+
A part of something longer, shared live: its blocks as they are now, under the header of what it is part of.
|
|
9
|
+
|
|
10
|
+
<Canvas of={PartCardStories.Default} />
|
|
11
|
+
|
|
12
|
+
## When
|
|
13
|
+
|
|
14
|
+
- **ready** — the icon, the key and the title of what it is part of, as the link, and the blocks the app draws as `children`.
|
|
15
|
+
- **loading** — at a card's height.
|
|
16
|
+
- **unavailable** — one line: "A part of this item — not available.".
|
|
17
|
+
|
|
18
|
+
## When not
|
|
19
|
+
|
|
20
|
+
- A link to the whole thing → **LinkCard** or **CardUnder**.
|
|
21
|
+
|
|
22
|
+
## How
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
import { PartCard } from '@estiva-app/ui'
|
|
26
|
+
|
|
27
|
+
<PartCard state="ready" kind={kind} heading={title} partKey={key} href={hrefAtThePart}>
|
|
28
|
+
{blocksDrawnByTheApp}
|
|
29
|
+
</PartCard>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- The blocks are the app's to draw, with its own renderer.
|
|
33
|
+
- There is no ✕: to send without the part, the writer deletes the link.
|
|
34
|
+
|
|
35
|
+
## What it owns
|
|
36
|
+
|
|
37
|
+
| It does | So you never write |
|
|
38
|
+
|---|---|
|
|
39
|
+
| Marks the blocks as quoted from elsewhere | a border of your own |
|
|
40
|
+
|
|
41
|
+
## Props
|
|
42
|
+
|
|
43
|
+
<Controls of={PartCardStories.Default} />
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { PartCard } from './PartCard'
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/PartCard',
|
|
6
|
+
component: PartCard,
|
|
7
|
+
parameters: { layout: 'padded' },
|
|
8
|
+
decorators: [(Story) => <div className="max-w-[480px]">{Story()}</div>],
|
|
9
|
+
} satisfies Meta<typeof PartCard>
|
|
10
|
+
|
|
11
|
+
export default meta
|
|
12
|
+
type Story = StoryObj<typeof meta>
|
|
13
|
+
|
|
14
|
+
/** A part of something longer, under the header of what it is part of. */
|
|
15
|
+
export const Default: Story = {
|
|
16
|
+
args: {
|
|
17
|
+
state: 'ready',
|
|
18
|
+
kind: 'issue',
|
|
19
|
+
heading: 'Item one',
|
|
20
|
+
partKey: 'ABC-2',
|
|
21
|
+
href: '#part',
|
|
22
|
+
children: (
|
|
23
|
+
<>
|
|
24
|
+
<p className="text-body-2-strong text-text-primary">A heading in the part</p>
|
|
25
|
+
<p className="text-body-2 text-text-secondary">The blocks of the part, as they are now. They change when the text they came from changes.</p>
|
|
26
|
+
</>
|
|
27
|
+
),
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Still being looked up. */
|
|
32
|
+
export const Loading: Story = { args: { state: 'loading', kind: 'issue' } }
|
|
33
|
+
|
|
34
|
+
/** The part, or what it is part of, is gone or not this reader's to see. */
|
|
35
|
+
export const NotAvailable: Story = { args: { state: 'unavailable', kind: 'issue', noun: 'Item', openIn: { app: 'App one', href: '#elsewhere' } } }
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/** What the PartCard page claims, pinned: the header opens what it is part of, the blocks draw, and the states say so. */
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { PartCard } from './PartCard'
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup)
|
|
8
|
+
|
|
9
|
+
describe('PartCard', () => {
|
|
10
|
+
it('opens what it is part of from its header, and draws the blocks it is given', () => {
|
|
11
|
+
render(
|
|
12
|
+
<PartCard kind="issue" heading="Item one" partKey="ABC-2" href="#part">
|
|
13
|
+
<p>The blocks</p>
|
|
14
|
+
</PartCard>,
|
|
15
|
+
)
|
|
16
|
+
expect(screen.getByRole('link', { name: /ABC-2.*Item one/ }).getAttribute('href')).toBe('#part')
|
|
17
|
+
expect(screen.getByText('The blocks')).toBeTruthy()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('says a part is not available in words', () => {
|
|
21
|
+
render(<PartCard state="unavailable" kind="issue" noun="Item" />)
|
|
22
|
+
expect(screen.getByText('A part of this item — not available.')).toBeTruthy()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('while it loads, is busy and shows nothing of it', () => {
|
|
26
|
+
const { container } = render(<PartCard state="loading" kind="issue" />)
|
|
27
|
+
expect(container.querySelector('[aria-busy="true"]')).not.toBeNull()
|
|
28
|
+
expect(screen.queryByRole('link')).toBeNull()
|
|
29
|
+
})
|
|
30
|
+
})
|
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
|
+
})
|