@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.
Files changed (67) hide show
  1. package/dist/CardUnder.d.ts +10 -0
  2. package/dist/CardUnder.d.ts.map +1 -0
  3. package/dist/InlineChip.d.ts +2 -0
  4. package/dist/InlineChip.d.ts.map +1 -1
  5. package/dist/LinkCard.d.ts +17 -0
  6. package/dist/LinkCard.d.ts.map +1 -0
  7. package/dist/LinkChip.d.ts +8 -0
  8. package/dist/LinkChip.d.ts.map +1 -0
  9. package/dist/LinkParts.d.ts +45 -0
  10. package/dist/LinkParts.d.ts.map +1 -0
  11. package/dist/LinkPreview.d.ts +21 -0
  12. package/dist/LinkPreview.d.ts.map +1 -0
  13. package/dist/LinkRow.d.ts +24 -0
  14. package/dist/LinkRow.d.ts.map +1 -0
  15. package/dist/PartCard.d.ts +32 -0
  16. package/dist/PartCard.d.ts.map +1 -0
  17. package/dist/Skeleton.d.ts +12 -2
  18. package/dist/Skeleton.d.ts.map +1 -1
  19. package/dist/eslint/index.js +1 -0
  20. package/dist/eslint/index.js.map +2 -2
  21. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  22. package/dist/index.d.ts +8 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +480 -71
  25. package/dist/index.js.map +4 -4
  26. package/dist/linked.d.ts +168 -0
  27. package/dist/linked.d.ts.map +1 -0
  28. package/dist/linked.examples.d.ts +27 -0
  29. package/dist/linked.examples.d.ts.map +1 -0
  30. package/package.json +1 -1
  31. package/registry.json +784 -7
  32. package/src/CardUnder.mdx +53 -0
  33. package/src/CardUnder.stories.tsx +45 -0
  34. package/src/CardUnder.test.tsx +40 -0
  35. package/src/CardUnder.tsx +45 -0
  36. package/src/InlineChip.tsx +2 -0
  37. package/src/LinkCard.mdx +58 -0
  38. package/src/LinkCard.stories.tsx +68 -0
  39. package/src/LinkCard.test.tsx +103 -0
  40. package/src/LinkCard.tsx +209 -0
  41. package/src/LinkChip.mdx +51 -0
  42. package/src/LinkChip.stories.tsx +40 -0
  43. package/src/LinkChip.test.tsx +83 -0
  44. package/src/LinkChip.tsx +51 -0
  45. package/src/LinkParts.mdx +49 -0
  46. package/src/LinkParts.stories.tsx +61 -0
  47. package/src/LinkParts.tsx +147 -0
  48. package/src/LinkPreview.mdx +47 -0
  49. package/src/LinkPreview.stories.tsx +38 -0
  50. package/src/LinkPreview.tsx +55 -0
  51. package/src/LinkRow.mdx +47 -0
  52. package/src/LinkRow.stories.tsx +49 -0
  53. package/src/LinkRow.test.tsx +38 -0
  54. package/src/LinkRow.tsx +107 -0
  55. package/src/PartCard.mdx +43 -0
  56. package/src/PartCard.stories.tsx +35 -0
  57. package/src/PartCard.test.tsx +30 -0
  58. package/src/PartCard.tsx +59 -0
  59. package/src/Skeleton.tsx +7 -3
  60. package/src/eslint/no-native-title.test.ts +3 -2
  61. package/src/eslint/no-restyled-part.ts +1 -0
  62. package/src/index.ts +25 -0
  63. package/src/linked.examples.ts +107 -0
  64. package/src/linked.test.ts +58 -0
  65. package/src/linked.ts +217 -0
  66. package/src/registry/registry.test.ts +8 -8
  67. package/stories/Choosing.mdx +7 -0
@@ -0,0 +1,53 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as CardUnderStories from './CardUnder.stories'
3
+
4
+ <Meta of={CardUnderStories} />
5
+
6
+ # CardUnder
7
+
8
+ The one card added under a piece of text, and the ✕ that removes it.
9
+
10
+ <Canvas of={CardUnderStories.WhileWriting} />
11
+
12
+ ## When
13
+
14
+ - **while writing** — under the box: the ✕ always shows.
15
+ - **after sending** — `revealOnHover`: the ✕ shows when the pointer is over the card or the ✕ has focus, and always on a screen that cannot hover, so a tap reaches it.
16
+ - **no ✕** — no `onRemove`: to anyone but who wrote it.
17
+
18
+ <Canvas of={CardUnderStories.AfterSending} />
19
+
20
+ ## When not
21
+
22
+ - A link alone on its line, with no card chosen → **LinkRow**.
23
+ - A part of something longer: it has no ✕ → **PartCard**.
24
+
25
+ ## How
26
+
27
+ ```tsx
28
+ import { CardUnder } from '@estiva-app/ui'
29
+
30
+ <CardUnder link={link} onRemove={isAuthor ? removeCard : undefined} revealOnHover={sent} />
31
+ ```
32
+
33
+ - **Which link gets the card is the app's**, by the rule every app reads by (`defaultCardOf` in `@estiva-app/protocol`). This draws the one it is given.
34
+ - `onRemove` is the app's: before sending it drops the card from what will be sent; after, it sends the edit that removes it.
35
+ - It is at most 480px wide.
36
+
37
+ ## What it owns
38
+
39
+ | It does | So you never write |
40
+ |---|---|
41
+ | Places the ✕ at the card's top corner | a remove button of your own |
42
+ | Reveals the ✕ on hover and focus, and always shows it on touch | a hover or touch check |
43
+
44
+ ## Keys
45
+
46
+ | Key | Does |
47
+ |---|---|
48
+ | Tab | Reaches the ✕, and shows it when it was waiting for hover |
49
+ | Enter, Space | Removes the card |
50
+
51
+ ## Props
52
+
53
+ <Controls of={CardUnderStories.NoRemove} />
@@ -0,0 +1,45 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+ import { Button } from './Button'
4
+ import { CardUnder } from './CardUnder'
5
+ import { ITEM, LOADING, ready, SAID, UNAVAILABLE } from './linked.examples'
6
+
7
+ const meta = {
8
+ title: 'Components/CardUnder',
9
+ component: CardUnder,
10
+ parameters: { layout: 'padded' },
11
+ args: { link: ready(ITEM) },
12
+ } satisfies Meta<typeof CardUnder>
13
+
14
+ export default meta
15
+ type Story = StoryObj<typeof meta>
16
+
17
+ /** Under a box being written in: the ✕ always shows. Press it to remove the card. */
18
+ export const WhileWriting: Story = {
19
+ render: (args) => {
20
+ const [removed, setRemoved] = useState(false)
21
+ if (removed) return <Button onClick={() => setRemoved(false)}>Put it back</Button>
22
+ return <CardUnder {...args} onRemove={() => setRemoved(true)} />
23
+ },
24
+ }
25
+
26
+ /** Under text already sent: the ✕ shows on hover or focus, and always on a screen that cannot hover. */
27
+ export const AfterSending: Story = {
28
+ render: (args) => {
29
+ const [removed, setRemoved] = useState(false)
30
+ if (removed) return <Button onClick={() => setRemoved(false)}>Put it back</Button>
31
+ return <CardUnder {...args} revealOnHover onRemove={() => setRemoved(true)} />
32
+ },
33
+ }
34
+
35
+ /** To anyone but who wrote it: no ✕. */
36
+ export const NoRemove: Story = {}
37
+
38
+ /** Words a person wrote, as the card. */
39
+ export const WordsAPersonWrote: Story = { args: { link: ready(SAID) }, render: (args) => <CardUnder {...args} onRemove={() => {}} /> }
40
+
41
+ /** Still being looked up: at the card's height, with its ✕. */
42
+ export const Loading: Story = { args: { link: LOADING }, render: (args) => <CardUnder {...args} onRemove={() => {}} /> }
43
+
44
+ /** Deleted, or not this reader's to see. */
45
+ export const NotAvailable: Story = { args: { link: UNAVAILABLE }, render: (args) => <CardUnder {...args} onRemove={() => {}} /> }
@@ -0,0 +1,40 @@
1
+ // @vitest-environment jsdom
2
+ /** What the CardUnder page claims, pinned: the ✕ removes the card, by pointer and by keyboard, and only where it is given. */
3
+ import { cleanup, render, screen } from '@testing-library/react'
4
+ import userEvent from '@testing-library/user-event'
5
+ import { afterEach, describe, expect, it, vi } from 'vitest'
6
+ import { CardUnder } from './CardUnder'
7
+ import { ITEM, ready } from './linked.examples'
8
+
9
+ afterEach(cleanup)
10
+
11
+ describe('CardUnder', () => {
12
+ it('draws the card it is given', () => {
13
+ render(<CardUnder link={ready(ITEM)} />)
14
+ expect(screen.getByRole('link', { name: ITEM.title })).toBeTruthy()
15
+ })
16
+
17
+ it('removes the card when the ✕ is pressed', async () => {
18
+ const user = userEvent.setup()
19
+ const onRemove = vi.fn()
20
+ render(<CardUnder link={ready(ITEM)} onRemove={onRemove} />)
21
+ await user.click(screen.getByRole('button', { name: 'Remove card' }))
22
+ expect(onRemove).toHaveBeenCalledTimes(1)
23
+ })
24
+
25
+ it('reaches the ✕ from the keyboard once sent, and removes the card with Enter', async () => {
26
+ const user = userEvent.setup()
27
+ const onRemove = vi.fn()
28
+ render(<CardUnder link={ready({ ...ITEM, conversation: undefined, status: undefined, owner: undefined })} onRemove={onRemove} revealOnHover />)
29
+ await user.tab()
30
+ await user.tab()
31
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Remove card' }))
32
+ await user.keyboard('{Enter}')
33
+ expect(onRemove).toHaveBeenCalledTimes(1)
34
+ })
35
+
36
+ it('has no ✕ for anyone but who wrote it', () => {
37
+ render(<CardUnder link={ready(ITEM)} />)
38
+ expect(screen.queryByRole('button', { name: 'Remove card' })).toBeNull()
39
+ })
40
+ })
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The one card added under a piece of text, and the ✕ that removes it (why:
3
+ * https://github.com/estiva-app/estiva-docs/blob/main/decisions/where-a-link-sits-and-which-file-gets-the-card.md).
4
+ *
5
+ * Under a box being written in, the ✕ always shows. Under text already sent
6
+ * (`revealOnHover`) it shows when the pointer is over the card or the ✕ has
7
+ * focus, and always on a screen that cannot hover, so a tap reaches it (Miky,
8
+ * 16 Oct, kickoff).
9
+ */
10
+ import { IconX } from '@tabler/icons-react'
11
+ import { cn } from './cn'
12
+ import { IconButton } from './IconButton'
13
+ import { LinkCard } from './LinkCard'
14
+ import type { LinkState } from './linked'
15
+
16
+ export interface CardUnderProps {
17
+ link: LinkState
18
+ /** Draws the ✕. Without it the card shows with none, as it does to anyone but the author. */
19
+ onRemove?: () => void
20
+ /** The ✕ waits for hover or focus: under text already sent. Always shown on a screen that cannot hover. */
21
+ revealOnHover?: boolean
22
+ }
23
+
24
+ export function CardUnder({ link, onRemove, revealOnHover = false }: CardUnderProps) {
25
+ return (
26
+ <div className="group/card flex max-w-[480px] items-start gap-1">
27
+ <div className="min-w-0 flex-1">
28
+ <LinkCard link={link} />
29
+ </div>
30
+ {onRemove && (
31
+ // At the card's top corner: halfway down a tall card a ✕ reads as belonging to nothing.
32
+ <div
33
+ className={cn(
34
+ 'flex shrink-0 self-start pt-2',
35
+ revealOnHover && 'opacity-0 transition-opacity group-hover/card:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100',
36
+ )}
37
+ >
38
+ <IconButton aria-label="Remove card" tooltip="Remove card" onClick={onRemove}>
39
+ <IconX size={16} />
40
+ </IconButton>
41
+ </div>
42
+ )}
43
+ </div>
44
+ )
45
+ }
@@ -45,6 +45,8 @@ export const INLINE_CHIP_TONE_CLASSES = {
45
45
  * copy of this chip still has the 16.8px box).
46
46
  */
47
47
  quiet: 'h-[19.6px] bg-bg-active text-text-muted font-mono text-caption',
48
+ /** A file this reader cannot open — deleted, or not theirs to see: said in words, quieter than a link. */
49
+ muted: 'bg-bg-active text-text-muted',
48
50
  } as const
49
51
 
50
52
  export type InlineChipTone = keyof typeof INLINE_CHIP_TONE_CLASSES
@@ -0,0 +1,58 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as LinkCardStories from './LinkCard.stories'
3
+
4
+ <Meta of={LinkCardStories} />
5
+
6
+ # LinkCard
7
+
8
+ A link's card: what a row cannot hold, and the changes the reader may make from it.
9
+
10
+ <Canvas of={LinkCardStories.Default} />
11
+
12
+ ## When
13
+
14
+ - **ready** — the title as the link (the icon opens it too), the key and what it is in, its summary, the items inside it, its newest conversation, and a footer: the pickers and the conversation count, or who wrote it and when. No summary, the title alone.
15
+ - **read only** — a picker the app passed no `onChange` for reads as words in the same place: the reader may not change it. `readOnly` makes every value words, as the hover preview does.
16
+ - **words a person wrote** — their face, their name, the time as the link, and the words.
17
+ - **loading** — the card it will become in bars, at its own height: `shape` from `loadingShapeOf(linked)` the last time it drew, or the kind's usual card. **LinkCardLoading**, from this file.
18
+ - **unavailable** — one line, as **LinkRow** draws it.
19
+
20
+ <Canvas of={LinkCardStories.ReadOnly} />
21
+ <Canvas of={LinkCardStories.Loading} />
22
+
23
+ ## When not
24
+
25
+ - A link alone on its line, where the card was not chosen → **LinkRow**.
26
+ - The one card under a piece of text, with its ✕ → **CardUnder**, which draws this.
27
+ - A part of something longer → **PartCard**.
28
+
29
+ ## How
30
+
31
+ ```tsx
32
+ import { LinkCard } from '@estiva-app/ui'
33
+
34
+ const linked = {
35
+ ...fromLookup,
36
+ status: { value: statusKey, options: statuses, onChange: mayChange ? setStatus : undefined },
37
+ owner: { value: ownerKey, options: people, onChange: mayChange ? setOwner : undefined },
38
+ ownerLabel: 'Owner',
39
+ }
40
+ <LinkCard link={{ state: 'ready', linked }} />
41
+ ```
42
+
43
+ - **Whether the reader may change it is the app's.** Pass `onChange` only to a reader who may; anyone else sees words.
44
+ - The picker offers only the options it is given: to let the reader set nobody as the owner, include an option for it.
45
+ - `linked.latest` is drawn whenever it is given: pass it only to a reader who may read it.
46
+ - `linked.conversation.unread` only for a member of it.
47
+
48
+ ## What it owns
49
+
50
+ | It does | So you never write |
51
+ |---|---|
52
+ | Draws the pickers, or the same values as words | a read-only variant of your own |
53
+ | Holds its own height while it loads | a placeholder height of your own |
54
+ | Opens each item inside it on its own | a row click handler |
55
+
56
+ ## Props
57
+
58
+ <Controls of={LinkCardStories.ReadOnly} />
@@ -0,0 +1,68 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+ import { LinkCard, LinkCardLoading } from './LinkCard'
4
+ import { loadingShapeOf, type Linked } from './linked'
5
+ import { GROUP, ITEM, ITEM_NO_SUMMARY, ITEM_READ_ONLY, ITEM_UNREAD, PAGE, PLACE, ready, SAID, UNAVAILABLE } from './linked.examples'
6
+
7
+ const meta = {
8
+ title: 'Components/LinkCard',
9
+ component: LinkCard,
10
+ parameters: { layout: 'padded' },
11
+ args: { link: ready(ITEM) },
12
+ decorators: [(Story) => <div className="max-w-[480px]">{Story()}</div>],
13
+ } satisfies Meta<typeof LinkCard>
14
+
15
+ export default meta
16
+ type Story = StoryObj<typeof meta>
17
+
18
+ /** Its status and owner change from the card: pick one. */
19
+ export const Default: Story = {
20
+ render: () => {
21
+ const [status, setStatus] = useState('doing')
22
+ const [owner, setOwner] = useState<string>('a')
23
+ const linked: Linked = { ...ITEM, status: { ...ITEM.status!, value: status, onChange: setStatus }, owner: { ...ITEM.owner!, value: owner, onChange: setOwner } }
24
+ return <LinkCard link={ready(linked)} />
25
+ },
26
+ }
27
+
28
+ /** To a reader who may not change it: the same two values, as words. */
29
+ export const ReadOnly: Story = { args: { link: ready(ITEM_READ_ONLY) } }
30
+
31
+ /** Something unread in its conversation, to a member: the count is brighter, with a dot. */
32
+ export const Unread: Story = { args: { link: ready(ITEM_UNREAD) } }
33
+
34
+ /** No summary: the title alone. */
35
+ export const NoSummary: Story = { args: { link: ready(ITEM_NO_SUMMARY) } }
36
+
37
+ /** A group: its progress, its lead, and each item inside, which opens on its own. */
38
+ export const WithItems: Story = { args: { link: ready(GROUP) } }
39
+
40
+ /** A place people talk: its members and its newest conversation. */
41
+ export const WithLatest: Story = { args: { link: ready(PLACE) } }
42
+
43
+ /** A page of text: who wrote it, and when. */
44
+ export const WithAuthors: Story = { args: { link: ready(PAGE) } }
45
+
46
+ /** Words a person wrote: their face, the time as the link, and the words. */
47
+ export const WordsAPersonWrote: Story = { args: { link: ready(SAID) } }
48
+
49
+ /** Still being looked up, beside the card it becomes: the same height. */
50
+ export const Loading: Story = {
51
+ render: () => (
52
+ <div className="flex flex-col gap-3">
53
+ {[ITEM, GROUP, PLACE, PAGE].map((linked) => (
54
+ // Each at a card's full width, as under a message: a narrower column wraps the summary.
55
+ <div key={linked.id} className="grid w-[968px] grid-cols-[480px_480px] items-start gap-2">
56
+ <LinkCardLoading kind={linked.kind} shape={loadingShapeOf(linked)} />
57
+ <LinkCard link={ready(linked)} />
58
+ </div>
59
+ ))}
60
+ </div>
61
+ ),
62
+ }
63
+
64
+ /** Nothing remembered of it: the kind's usual card. */
65
+ export const LoadingByKind: Story = { args: { link: { state: 'loading', kind: 'issue' } } }
66
+
67
+ /** Deleted, or not this reader's to see: one line. */
68
+ export const NotAvailable: Story = { args: { link: UNAVAILABLE } }
@@ -0,0 +1,103 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the LinkCard and LinkParts pages claim, pinned: the pickers for a
4
+ * reader who may change it and words for one who may not, the conversation
5
+ * count and its unread, what each kind draws, and the loading and
6
+ * not-available states.
7
+ */
8
+ import { cleanup, render, screen } from '@testing-library/react'
9
+ import userEvent from '@testing-library/user-event'
10
+ import { afterEach, describe, expect, it, vi } from 'vitest'
11
+ import { LinkCard } from './LinkCard'
12
+ import { GROUP, ITEM, ITEM_NO_SUMMARY, ITEM_READ_ONLY, ITEM_UNREAD, LOADING, PAGE, PLACE, ready, SAID, UNAVAILABLE } from './linked.examples'
13
+
14
+ afterEach(cleanup)
15
+
16
+ describe('LinkCard', () => {
17
+ it('opens what it links from its title', () => {
18
+ render(<LinkCard link={ready(ITEM)} />)
19
+ expect(screen.getByRole('link', { name: ITEM.title }).getAttribute('href')).toBe('#item')
20
+ })
21
+
22
+ it('lets a reader who may change it pick a new status and owner', async () => {
23
+ const user = userEvent.setup()
24
+ const onStatus = vi.fn()
25
+ const onOwner = vi.fn()
26
+ render(<LinkCard link={ready({ ...ITEM, status: { ...ITEM.status!, onChange: onStatus }, owner: { ...ITEM.owner!, onChange: onOwner } })} />)
27
+ await user.click(screen.getByRole('combobox', { name: 'Status' }))
28
+ await user.click(await screen.findByRole('option', { name: /Done/ }))
29
+ expect(onStatus).toHaveBeenCalledWith('done')
30
+ await user.click(screen.getByRole('combobox', { name: 'Owner' }))
31
+ await user.click(await screen.findByRole('option', { name: /Person three/ }))
32
+ expect(onOwner).toHaveBeenCalledWith('c')
33
+ })
34
+
35
+ it('shows the same values as words to a reader who may not change them', () => {
36
+ render(<LinkCard link={ready(ITEM_READ_ONLY)} />)
37
+ expect(screen.queryByRole('combobox')).toBeNull()
38
+ expect(screen.getByText('In progress')).toBeTruthy()
39
+ expect(screen.getByText('Person one')).toBeTruthy()
40
+ })
41
+
42
+ it('shows words, never pickers, when read only, even to a reader who could change it', () => {
43
+ render(<LinkCard link={ready(ITEM)} readOnly />)
44
+ expect(screen.queryByRole('combobox')).toBeNull()
45
+ })
46
+
47
+ it('says nobody owns it when nobody does', () => {
48
+ render(<LinkCard link={ready({ ...ITEM_READ_ONLY, owner: { value: undefined, options: [] } })} />)
49
+ expect(screen.getByText('Unassigned')).toBeTruthy()
50
+ })
51
+
52
+ it('shows the title alone when there is no summary', () => {
53
+ render(<LinkCard link={ready(ITEM_NO_SUMMARY)} />)
54
+ expect(screen.getByRole('link', { name: 'Item two' })).toBeTruthy()
55
+ expect(screen.queryByText(ITEM.summary!)).toBeNull()
56
+ })
57
+
58
+ it('opens the conversation from its count, and says when it holds something unread', () => {
59
+ const { rerender } = render(<LinkCard link={ready(ITEM)} />)
60
+ const count = screen.getByRole('link', { name: /comments — open the conversation/ })
61
+ expect(count.getAttribute('href')).toBe('#item-conversation')
62
+ expect(count.textContent).not.toContain('unread')
63
+ rerender(<LinkCard link={ready(ITEM_UNREAD)} />)
64
+ expect(screen.getByRole('link', { name: /comments, unread — open the conversation/ })).toBeTruthy()
65
+ })
66
+
67
+ it('opens each item inside it on its own', () => {
68
+ render(<LinkCard link={ready(GROUP)} />)
69
+ expect(screen.getByRole('link', { name: 'Item three' }).getAttribute('href')).toBe('#c3')
70
+ expect(screen.getByText('2/5')).toBeTruthy()
71
+ })
72
+
73
+ it('shows the newest conversation where it is given, and who wrote a page', () => {
74
+ render(
75
+ <>
76
+ <LinkCard link={ready(PLACE)} />
77
+ <LinkCard link={ready(PAGE)} />
78
+ </>,
79
+ )
80
+ expect(screen.getByText('Latest conversation')).toBeTruthy()
81
+ expect(screen.getByText(PLACE.latest!.body)).toBeTruthy()
82
+ expect(screen.getByText('Yesterday')).toBeTruthy()
83
+ })
84
+
85
+ it('draws a person’s words as theirs, with the time as the link', () => {
86
+ render(<LinkCard link={ready(SAID)} />)
87
+ expect(screen.getByText('Person one')).toBeTruthy()
88
+ expect(screen.getByText(SAID.body!)).toBeTruthy()
89
+ expect(screen.getByRole('link', { name: '10:42' }).getAttribute('href')).toBe('#said')
90
+ })
91
+
92
+ it('while it loads, is busy and shows nothing of it', () => {
93
+ const { container } = render(<LinkCard link={LOADING} />)
94
+ expect(container.querySelector('[aria-busy="true"]')).not.toBeNull()
95
+ expect(screen.queryByRole('link')).toBeNull()
96
+ })
97
+
98
+ it('says it is not available, and where it may still open', () => {
99
+ render(<LinkCard link={UNAVAILABLE} />)
100
+ expect(screen.getByText('Item — not available.')).toBeTruthy()
101
+ expect(screen.getByRole('link', { name: /Open in App one/ }).getAttribute('href')).toBe('#elsewhere')
102
+ })
103
+ })
@@ -0,0 +1,209 @@
1
+ /**
2
+ * A link's card: what a row cannot hold. The header is the row's line (icon
3
+ * and title as the link; a ring or faces in the corner), then the key and
4
+ * what it is in, its summary, the items inside it, the newest thing said in
5
+ * it, and a footer: the pickers and the conversation count, or who and when.
6
+ * Words a person wrote draw as themselves, with their face and the time.
7
+ *
8
+ * While it loads it holds the card's own height, so what is around it does
9
+ * not jump; not available is one line.
10
+ */
11
+ import { Avatar } from './Avatar'
12
+ import { AvatarGroup } from './AvatarGroup'
13
+ import { Card } from './Card'
14
+ import { Link } from './Link'
15
+ import type { Linked, LinkedKind, LinkState, LoadingShape } from './linked'
16
+ import { CommentCount, LinkedIcon, LinkKey, LinkPickers, ProgressRing, StatusMark } from './LinkParts'
17
+ import { LinkUnavailable } from './LinkRow'
18
+ import { SkeletonBar } from './Skeleton'
19
+
20
+ export interface LinkCardProps {
21
+ link: LinkState
22
+ /** The values read as words, never pickers: the hover preview. */
23
+ readOnly?: boolean
24
+ /** No frame of its own: inside a box that draws one, as the hover preview's. */
25
+ bare?: boolean
26
+ }
27
+
28
+ export function LinkCard({ link, readOnly = false, bare = false }: LinkCardProps) {
29
+ if (link.state === 'loading') return <LinkCardLoading kind={link.kind} shape={link.shape} />
30
+ if (link.state === 'unavailable') return <LinkUnavailable kind={link.kind} noun={link.noun} openIn={link.openIn} />
31
+ const { linked } = link
32
+ const body = linked.kind === 'message' && linked.author ? <SaidBody linked={linked} /> : <CardBody linked={linked} readOnly={readOnly} />
33
+ if (bare) return <div className="flex flex-col gap-2 p-3">{body}</div>
34
+ return (
35
+ <Card fill="inset" className="flex flex-col gap-2 p-3">
36
+ {body}
37
+ </Card>
38
+ )
39
+ }
40
+
41
+ function CardBody({ linked, readOnly }: { linked: Linked; readOnly: boolean }) {
42
+ return (
43
+ <>
44
+ <div className="flex min-h-[19.6px] items-center gap-2">
45
+ {/* The icon opens it too, so the target is larger than the words. */}
46
+ <span className="flex min-w-0 flex-1 text-body-2-strong text-text-primary">
47
+ <Link href={linked.href} variant="quiet" className="flex min-w-0 flex-1 items-center gap-2">
48
+ <LinkedIcon kind={linked.kind} />
49
+ <span className="truncate">{linked.title}</span>
50
+ </Link>
51
+ </span>
52
+ {linked.done && <ProgressRing done={linked.done.done} total={linked.done.total} />}
53
+ {linked.members && <AvatarGroup members={linked.members} size={16} />}
54
+ </div>
55
+ {(linked.key || linked.subtitle) && <p className="-mt-1 pl-6 text-caption text-text-secondary">{[linked.key, linked.subtitle].filter(Boolean).join(' · ')}</p>}
56
+ {linked.summary && <p className="line-clamp-2 text-body-2 text-text-secondary">{linked.summary}</p>}
57
+ {linked.children && linked.children.length > 0 && (
58
+ <div className="flex flex-col divide-y divide-border-subtle rounded-md border border-border-subtle leaf:divide-border-strong leaf:border-border-strong">
59
+ {linked.children.map((child) => (
60
+ <div key={child.id} className="relative flex h-8 items-center gap-2 px-2 hover:bg-bg-active">
61
+ {/* The status is the row's icon: a list needs no kind icon on every row. */}
62
+ <StatusMark status={child.status} />
63
+ {child.key && <LinkKey>{child.key}</LinkKey>}
64
+ {/* Each opens on its own; the link covers its row. */}
65
+ <span className="contents text-body-2 text-text-primary">
66
+ <Link href={child.href} variant="quiet" cover truncate className="min-w-0 flex-1">
67
+ {child.title}
68
+ </Link>
69
+ </span>
70
+ {child.owner ? <Avatar name={child.owner.name} src={child.owner.picture} size={16} /> : <span className="size-4 shrink-0" aria-hidden />}
71
+ </div>
72
+ ))}
73
+ </div>
74
+ )}
75
+ {linked.latest && (
76
+ <div className="flex flex-col gap-1">
77
+ {/* "Latest" in words: the card follows it, so this is not necessarily what the link was sent about. */}
78
+ <span className="text-caption text-text-muted">Latest conversation</span>
79
+ <div className="flex gap-2 rounded-md border border-border-subtle p-2 leaf:border-border-strong">
80
+ <Avatar name={linked.latest.author.name} src={linked.latest.author.picture} size={16} />
81
+ <div className="min-w-0 flex-1">
82
+ <div className="flex items-baseline gap-2 text-caption">
83
+ <span className="text-text-primary">{linked.latest.author.name}</span>
84
+ <span className="text-text-muted">{linked.latest.at}</span>
85
+ </div>
86
+ <p className="line-clamp-2 text-body-2 text-text-secondary">{linked.latest.body}</p>
87
+ </div>
88
+ </div>
89
+ </div>
90
+ )}
91
+ <div className="flex min-h-6 items-center gap-2 border-t border-border-subtle pt-2 leaf:border-border-strong">
92
+ {(linked.status || linked.owner) && <LinkPickers status={linked.status} owner={linked.owner} ownerLabel={linked.ownerLabel} readOnly={readOnly} />}
93
+ {/* Faces only: names would not fit once several people wrote it. */}
94
+ {linked.authors ? (
95
+ <span className="flex min-w-0 items-center gap-2 text-caption text-text-secondary">
96
+ <AvatarGroup members={linked.authors} size={16} />
97
+ {linked.updated && <span className="shrink-0">{linked.updated}</span>}
98
+ </span>
99
+ ) : (
100
+ !linked.status && !linked.owner && linked.updated && <span className="text-caption text-text-secondary">Updated {linked.updated}</span>
101
+ )}
102
+ {linked.conversation && (
103
+ <span className="ml-auto flex">
104
+ <CommentCount conversation={linked.conversation} noun={linked.conversation.noun} />
105
+ </span>
106
+ )}
107
+ </div>
108
+ </>
109
+ )
110
+ }
111
+
112
+ /** Words a person wrote, linked: their face, their name, the time as the link, and the words. */
113
+ function SaidBody({ linked }: { linked: Linked }) {
114
+ const author = linked.author!
115
+ return (
116
+ <>
117
+ <div className="flex items-center gap-2">
118
+ <Avatar name={author.name} src={author.picture} size={24} />
119
+ <span className="text-body-2-strong text-text-primary">{author.name}</span>
120
+ <span className="text-caption text-text-muted">
121
+ <Link href={linked.href} variant="quiet">
122
+ {linked.updated ?? 'Open'}
123
+ </Link>
124
+ </span>
125
+ </div>
126
+ <p className="line-clamp-4 text-body-2 text-text-primary">{linked.body ?? linked.title}</p>
127
+ </>
128
+ )
129
+ }
130
+
131
+ /** Each kind's usual card, for a placeholder when the app remembers nothing of it. */
132
+ const KIND_SHAPE: Record<LinkedKind, LoadingShape> = {
133
+ issue: { subtitle: true, summary: true, footer: 'pickers' },
134
+ project: { subtitle: false, summary: true, rows: 3, ring: true, footer: 'pickers' },
135
+ topic: { summary: true, latest: true, footer: 'line' },
136
+ page: { subtitle: true, summary: true, footer: 'people' },
137
+ file: { summary: false, footer: 'line' },
138
+ message: { summary: true, footer: 'none' },
139
+ }
140
+
141
+ export interface LinkCardLoadingProps {
142
+ kind: LinkedKind
143
+ /** What the card will hold (`loadingShapeOf` from the last time it drew); the kind's usual card otherwise. */
144
+ shape?: LoadingShape
145
+ }
146
+
147
+ /** The card it will become, drawn in bars at the card's own height. */
148
+ export function LinkCardLoading({ kind, shape }: LinkCardLoadingProps) {
149
+ const { subtitle = false, summary = true, rows = 0, latest = false, ring = false, footer = 'line' } = shape ?? KIND_SHAPE[kind]
150
+ return (
151
+ <div className="animate-skeleton-in" aria-busy>
152
+ <Card fill="inset" aria-hidden className="flex flex-col gap-2 p-3">
153
+ <div className="flex h-[19.6px] items-center gap-2">
154
+ <LinkedIcon kind={kind} />
155
+ <SkeletonBar on="inset" className="h-3.5 w-1/2" />
156
+ {ring && <SkeletonBar on="inset" shape="round" className="ml-auto size-4" />}
157
+ </div>
158
+ {subtitle && (
159
+ <div className="-mt-1 flex h-[14.4px] items-center pl-6">
160
+ <SkeletonBar on="inset" className="h-2.5 w-20" />
161
+ </div>
162
+ )}
163
+ {summary && (
164
+ <div className="flex h-[19.6px] items-center">
165
+ <SkeletonBar on="inset" className="h-3 w-4/5" />
166
+ </div>
167
+ )}
168
+ {rows > 0 && (
169
+ <div className="flex flex-col divide-y divide-border-subtle rounded-md border border-border-subtle leaf:divide-border-strong leaf:border-border-strong">
170
+ {Array.from({ length: rows }, (_, i) => (
171
+ <div key={i} className="flex h-8 items-center px-2">
172
+ <SkeletonBar on="inset" className="h-3 w-1/3" />
173
+ </div>
174
+ ))}
175
+ </div>
176
+ )}
177
+ {latest && (
178
+ <div className="flex flex-col gap-1">
179
+ <div className="flex h-[14.4px] items-center">
180
+ <SkeletonBar on="inset" className="h-2.5 w-28" />
181
+ </div>
182
+ <div className="flex h-[71.6px] flex-col justify-center gap-2 rounded-md border border-border-subtle p-2 leaf:border-border-strong">
183
+ <SkeletonBar on="inset" className="h-2.5 w-24" />
184
+ <SkeletonBar on="inset" className="h-3 w-full" />
185
+ <SkeletonBar on="inset" className="h-3 w-3/5" />
186
+ </div>
187
+ </div>
188
+ )}
189
+ {footer !== 'none' && (
190
+ <div className="flex min-h-6 items-center gap-2 border-t border-border-subtle pt-2 leaf:border-border-strong">
191
+ {footer === 'pickers' ? (
192
+ <>
193
+ <SkeletonBar on="inset" shape="control" className="h-6 w-24" />
194
+ <SkeletonBar on="inset" shape="control" className="h-6 w-28" />
195
+ </>
196
+ ) : footer === 'people' ? (
197
+ <>
198
+ <SkeletonBar on="inset" shape="round" className="size-4" />
199
+ <SkeletonBar on="inset" className="h-3 w-40" />
200
+ </>
201
+ ) : (
202
+ <SkeletonBar on="inset" className="h-3 w-40" />
203
+ )}
204
+ </div>
205
+ )}
206
+ </Card>
207
+ </div>
208
+ )
209
+ }