@estiva-app/ui 0.12.10 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AttachmentCard.d.ts +52 -0
- package/dist/AttachmentCard.d.ts.map +1 -0
- package/dist/Card.d.ts +54 -0
- package/dist/Card.d.ts.map +1 -0
- package/dist/InlineChip.d.ts +59 -0
- package/dist/InlineChip.d.ts.map +1 -0
- package/dist/Link.d.ts +37 -0
- package/dist/Link.d.ts.map +1 -0
- package/dist/ProgressBar.d.ts +32 -0
- package/dist/ProgressBar.d.ts.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +704 -355
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/AttachmentCard.mdx +62 -0
- package/src/AttachmentCard.stories.tsx +92 -0
- package/src/AttachmentCard.test.tsx +172 -0
- package/src/AttachmentCard.tsx +324 -0
- package/src/Card.mdx +74 -0
- package/src/Card.stories.tsx +112 -0
- package/src/Card.test.tsx +147 -0
- package/src/Card.tsx +133 -0
- package/src/EmptyState.mdx +14 -0
- package/src/EmptyState.stories.tsx +28 -7
- package/src/EmptyState.test.tsx +8 -0
- package/src/InlineChip.mdx +56 -0
- package/src/InlineChip.stories.tsx +59 -0
- package/src/InlineChip.test.tsx +81 -0
- package/src/InlineChip.tsx +90 -0
- package/src/Link.mdx +59 -0
- package/src/Link.stories.tsx +102 -0
- package/src/Link.test.tsx +100 -0
- package/src/Link.tsx +58 -0
- package/src/Person.stories.tsx +1 -1
- package/src/PersonTrigger.stories.tsx +1 -1
- package/src/ProgressBar.mdx +47 -0
- package/src/ProgressBar.stories.tsx +48 -0
- package/src/ProgressBar.test.tsx +62 -0
- package/src/ProgressBar.tsx +56 -0
- package/src/ReactionPicker.stories.tsx +1 -1
- package/src/index.ts +12 -0
- package/stories/Choosing.mdx +5 -0
package/package.json
CHANGED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as AttachmentCardStories from './AttachmentCard.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={AttachmentCardStories} />
|
|
5
|
+
|
|
6
|
+
# AttachmentCard
|
|
7
|
+
|
|
8
|
+
Something attached — a document or an image — drawn as a card: posted, or
|
|
9
|
+
waiting to be sent.
|
|
10
|
+
|
|
11
|
+
<Canvas of={AttachmentCardStories.SeveralTogether} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
The file decides the shape:
|
|
16
|
+
|
|
17
|
+
- **an image with a picture** (`src`) — a 180px thumbnail; `onOpen` is what a
|
|
18
|
+
click on the picture does.
|
|
19
|
+
- **anything else** — a 240px row: a type tile, the name, the type and size.
|
|
20
|
+
`href` opens it in a new tab; without one the row is dimmed.
|
|
21
|
+
- `onDownload` — a download control that fades in on hover or keyboard focus.
|
|
22
|
+
- `state="loading"` — an image on its way. `state="unreadable"` — it could not
|
|
23
|
+
be read: said, with a dashed hairline, never left as a gap.
|
|
24
|
+
- **`pending`** — waiting to be sent: a 200px row with a remove control on its
|
|
25
|
+
corner (`onRemove`). `state` says what is happening to it: `ready` (its size),
|
|
26
|
+
`uploading`, `failed` (the reason, in `note`), `warning` (something the sender
|
|
27
|
+
should know, in `note`, with the full words in `noteHint`).
|
|
28
|
+
|
|
29
|
+
<Canvas of={AttachmentCardStories.Pending} />
|
|
30
|
+
|
|
31
|
+
## When not
|
|
32
|
+
|
|
33
|
+
- A whole thing standing for itself, not a file → **Card**.
|
|
34
|
+
- A word in a sentence that names a file → **InlineChip**.
|
|
35
|
+
- A value picked into a field → **InputChip**, inside **ChipInput**.
|
|
36
|
+
|
|
37
|
+
## How
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
import { AttachmentCard } from '@estiva-app/ui'
|
|
41
|
+
|
|
42
|
+
// Posted: the app fetched the picture, and opens it full screen itself.
|
|
43
|
+
<AttachmentCard name={name} size={bytes} contentType={type} src={pictureUrl} onOpen={openFullScreen} onDownload={save} />
|
|
44
|
+
|
|
45
|
+
// Waiting to be sent.
|
|
46
|
+
<AttachmentCard pending name={name} size={bytes} state={uploading ? 'uploading' : 'ready'} src={localPreview} onRemove={remove} />
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- The card fetches nothing. Reading a file may need the reader's own permission,
|
|
50
|
+
and only the app has it: pass the result — `src`, `href`, `state`.
|
|
51
|
+
- A click on the picture, the download control and the remove control each stop
|
|
52
|
+
there, so they never also click the card or row the attachment sits in.
|
|
53
|
+
- The name truncates. When it is cut off, the full name is on hover; a name
|
|
54
|
+
that fits, and a size, show nothing extra. `noteHint` is always on hover, since
|
|
55
|
+
it says more than the line.
|
|
56
|
+
- The remove control is Peek's 20px round badge on the card's corner, a button
|
|
57
|
+
on Base UI's `Button` like InputChip's ✕ — not an `IconButton`, the 24px
|
|
58
|
+
square that fills on hover.
|
|
59
|
+
|
|
60
|
+
## Props
|
|
61
|
+
|
|
62
|
+
<Controls of={AttachmentCardStories.Document} />
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { AttachmentCard } from './AttachmentCard'
|
|
3
|
+
|
|
4
|
+
const svg = (w: number, h: number, body: string) =>
|
|
5
|
+
`data:image/svg+xml;utf8,${encodeURIComponent(
|
|
6
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}"><rect width="${w}" height="${h}" fill="#0f1720"/>${body}</svg>`,
|
|
7
|
+
)}`
|
|
8
|
+
|
|
9
|
+
/** Placeholder pictures — no real data in a story. */
|
|
10
|
+
const WIDE = svg(480, 270, `<circle cx="120" cy="90" r="52" fill="#56c8ff"/><rect x="196" y="150" width="220" height="70" rx="12" fill="#4ade8c"/>`)
|
|
11
|
+
const TALL = svg(270, 480, `<rect x="40" y="60" width="190" height="190" rx="16" fill="#b18cff"/><circle cx="135" cy="370" r="70" fill="#ffc94d"/>`)
|
|
12
|
+
const SHOT = svg(
|
|
13
|
+
480,
|
|
14
|
+
270,
|
|
15
|
+
`<rect x="24" y="24" width="200" height="80" rx="10" fill="#56c8ff"/><rect x="24" y="128" width="320" height="26" rx="8" fill="#39414f"/><rect x="24" y="172" width="240" height="26" rx="8" fill="#39414f"/><circle cx="404" cy="196" r="46" fill="#4ade8c"/>`,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
const noop = () => {}
|
|
19
|
+
|
|
20
|
+
const meta = {
|
|
21
|
+
title: 'Components/AttachmentCard',
|
|
22
|
+
component: AttachmentCard,
|
|
23
|
+
parameters: { layout: 'padded' },
|
|
24
|
+
args: { name: 'Q3-billing-summary.pdf', size: 2_412_000, href: 'https://example.com/f', onDownload: noop },
|
|
25
|
+
argTypes: {
|
|
26
|
+
state: { control: 'inline-radio', options: ['ready', 'loading', 'unreadable', 'uploading', 'failed', 'warning'] },
|
|
27
|
+
},
|
|
28
|
+
} satisfies Meta<typeof AttachmentCard>
|
|
29
|
+
|
|
30
|
+
export default meta
|
|
31
|
+
type Story = StoryObj<typeof meta>
|
|
32
|
+
|
|
33
|
+
/** A document: a type tile, the name, and what it is. Hover it for the download control. */
|
|
34
|
+
export const Document: Story = {}
|
|
35
|
+
|
|
36
|
+
/** An image: its own picture, 180px wide, opening full screen on a click. */
|
|
37
|
+
export const Image: Story = { args: { name: 'checkout-flow.png', size: 840_000, contentType: 'image/png', src: WIDE, onOpen: noop, href: undefined } }
|
|
38
|
+
|
|
39
|
+
/** A tall picture keeps the card's height: it is cropped to fit, not stretched. */
|
|
40
|
+
export const TallImage: Story = { args: { name: 'signup-screen.png', size: 1_180_000, contentType: 'image/png', src: TALL, onOpen: noop, href: undefined } }
|
|
41
|
+
|
|
42
|
+
/** Without an address it is dimmed and opens nothing. */
|
|
43
|
+
export const WithoutAnAddress: Story = { args: { name: 'annual-report.pdf', size: 4_800_000, href: undefined, onDownload: undefined } }
|
|
44
|
+
|
|
45
|
+
/** Tabler's file-type icons at stroke 1.5. A type with no icon of its own lands on the plain file glyph rather than a wrong one. */
|
|
46
|
+
export const EveryType: Story = {
|
|
47
|
+
parameters: { controls: { disable: true } },
|
|
48
|
+
render: () => (
|
|
49
|
+
<div className="flex flex-wrap items-start gap-2">
|
|
50
|
+
{['proposal.pdf', 'contract.docx', 'forecast.xlsx', 'kickoff.pptx', 'export.csv', 'notes.txt', 'README.md', 'manifest.json', 'assets.zip', 'photo.heic'].map((name) => (
|
|
51
|
+
<AttachmentCard key={name} name={name} size={300_000} href="https://example.com/f" onDownload={noop} />
|
|
52
|
+
))}
|
|
53
|
+
</div>
|
|
54
|
+
),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A name longer than the card truncates, so every card in a row keeps the same two lines. The full name is on hover, because it is cut off. */
|
|
58
|
+
export const LongName: Story = { args: { name: '2026-Q3-billing-reconciliation-and-invoice-summary-FINAL-v4.xlsx', size: 3_100_000 } }
|
|
59
|
+
|
|
60
|
+
/** Several together, in one flow: images and rows are different widths on purpose. */
|
|
61
|
+
export const SeveralTogether: Story = {
|
|
62
|
+
parameters: { controls: { disable: true } },
|
|
63
|
+
render: () => (
|
|
64
|
+
<div className="flex flex-wrap items-start gap-2">
|
|
65
|
+
<AttachmentCard name="checkout-flow.png" size={840_000} contentType="image/png" src={WIDE} onOpen={noop} onDownload={noop} />
|
|
66
|
+
<AttachmentCard name="Q3-billing-summary.pdf" size={2_412_000} href="https://example.com/f" onDownload={noop} />
|
|
67
|
+
<AttachmentCard name="raw-events.csv" size={96_000} href="https://example.com/f" onDownload={noop} />
|
|
68
|
+
</div>
|
|
69
|
+
),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** An image on its way. */
|
|
73
|
+
export const Loading: Story = { args: { name: 'checkout-flow.png', contentType: 'image/png', state: 'loading', href: undefined } }
|
|
74
|
+
|
|
75
|
+
/** It could not be read: said, not left as a gap, with the dashed hairline every unreadable card has. */
|
|
76
|
+
export const Unreadable: Story = { args: { name: 'checkout-flow.png', contentType: 'image/png', state: 'unreadable', href: undefined } }
|
|
77
|
+
|
|
78
|
+
/** Waiting to be sent, and ready: its own thumbnail and its size. Hover it for the remove control. */
|
|
79
|
+
export const Pending: Story = {
|
|
80
|
+
args: { pending: true, name: 'Screenshot 2026-09-07 14-32-05.png', size: 248_000, contentType: 'image/png', src: SHOT, onRemove: noop, href: undefined, onDownload: undefined },
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Waiting to be sent: a document shows its type in the tile. */
|
|
84
|
+
export const PendingDocument: Story = { args: { ...Pending.args, name: 'Q3-billing-summary.pdf', contentType: 'application/pdf', size: 2_412_000, src: undefined } }
|
|
85
|
+
|
|
86
|
+
export const Uploading: Story = { args: { ...Pending.args, state: 'uploading', src: undefined } }
|
|
87
|
+
|
|
88
|
+
/** It could not be attached: the reason, in place of the size. */
|
|
89
|
+
export const Failed: Story = { args: { ...Pending.args, state: 'failed', name: 'Screenshot 2026-09-07 14-32-05.tiff', note: "TIFF files aren't supported.", src: undefined } }
|
|
90
|
+
|
|
91
|
+
/** Attached, with something the sender should know: a warning, not an error — nothing failed. The full words are on hover. */
|
|
92
|
+
export const Warning: Story = { args: { ...Pending.args, state: 'warning', note: 'Not everyone can see this', noteHint: 'Only this app has a copy of this file.' } }
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the AttachmentCard page claims, pinned: the file decides the shape (an
|
|
4
|
+
* image with a picture is a thumbnail, anything else a row); a row with an href
|
|
5
|
+
* opens in a new tab and a row without one is dimmed; the picture hands its
|
|
6
|
+
* click to the app; download and remove are the app's too, and neither click
|
|
7
|
+
* reaches what the card sits in; a pending card says what is happening to it;
|
|
8
|
+
* and a file that could not be read is dashed.
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
11
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
12
|
+
import userEvent from '@testing-library/user-event'
|
|
13
|
+
import { AttachmentCard } from './AttachmentCard'
|
|
14
|
+
|
|
15
|
+
afterEach(cleanup)
|
|
16
|
+
|
|
17
|
+
const classesOf = (el: Element | null) => el?.getAttribute('class')?.split(' ').filter(Boolean) ?? []
|
|
18
|
+
const rootOf = (container: HTMLElement) => container.firstElementChild!
|
|
19
|
+
|
|
20
|
+
describe('AttachmentCard', () => {
|
|
21
|
+
it('a document is a row: its type, its name, and what it is', () => {
|
|
22
|
+
const { container } = render(<AttachmentCard name="report.pdf" size={2_412_000} href="https://example.com/r" />)
|
|
23
|
+
expect(classesOf(rootOf(container))).toContain('w-[240px]')
|
|
24
|
+
expect(screen.getByText('report.pdf')).toBeTruthy()
|
|
25
|
+
// 2,412,000 bytes is 2.3 MB by the 1024 steps Peek's own story shows.
|
|
26
|
+
expect(screen.getByText('PDF · 2.3 MB')).toBeTruthy()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('a row with an href opens it in a new tab, and its click stays out of what the card sits in', async () => {
|
|
30
|
+
const outside = vi.fn()
|
|
31
|
+
render(
|
|
32
|
+
<div onClick={outside}>
|
|
33
|
+
<AttachmentCard name="report.pdf" size={1000} href="https://example.com/r" />
|
|
34
|
+
</div>,
|
|
35
|
+
)
|
|
36
|
+
const link = screen.getByRole('link')
|
|
37
|
+
expect(link.getAttribute('href')).toBe('https://example.com/r')
|
|
38
|
+
expect(link.getAttribute('target')).toBe('_blank')
|
|
39
|
+
expect(link.getAttribute('rel')).toBe('noopener noreferrer')
|
|
40
|
+
link.addEventListener('click', (event) => event.preventDefault())
|
|
41
|
+
await userEvent.click(link)
|
|
42
|
+
expect(outside).not.toHaveBeenCalled()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('a row without an href is dimmed and opens nothing', () => {
|
|
46
|
+
const { container } = render(<AttachmentCard name="report.pdf" size={1000} />)
|
|
47
|
+
expect(classesOf(rootOf(container))).toContain('opacity-70')
|
|
48
|
+
expect(screen.queryByRole('link')).toBeNull()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('an image with a picture is a thumbnail, and its click is the app’s', async () => {
|
|
52
|
+
const onOpen = vi.fn()
|
|
53
|
+
const { container } = render(<AttachmentCard name="shot.png" src="data:image/png;base64,AA" onOpen={onOpen} />)
|
|
54
|
+
expect(classesOf(rootOf(container))).toContain('w-[180px]')
|
|
55
|
+
await userEvent.click(screen.getByRole('button', { name: 'Preview shot.png' }))
|
|
56
|
+
expect(onOpen).toHaveBeenCalledTimes(1)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('an image type wins over a name with no extension', () => {
|
|
60
|
+
const { container } = render(<AttachmentCard name="clipboard" contentType="image/png" src="data:image/png;base64,AA" />)
|
|
61
|
+
expect(classesOf(rootOf(container))).toContain('w-[180px]')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('download shows only when the app can do it, and its click is the app’s alone', async () => {
|
|
65
|
+
const onDownload = vi.fn()
|
|
66
|
+
const outside = vi.fn()
|
|
67
|
+
render(<AttachmentCard name="report.pdf" size={1000} href="#" />)
|
|
68
|
+
expect(screen.queryByRole('button', { name: 'Download report.pdf' })).toBeNull()
|
|
69
|
+
cleanup()
|
|
70
|
+
render(
|
|
71
|
+
<div onClick={outside}>
|
|
72
|
+
<AttachmentCard name="report.pdf" size={1000} href="#" onDownload={onDownload} />
|
|
73
|
+
</div>,
|
|
74
|
+
)
|
|
75
|
+
await userEvent.click(screen.getByRole('button', { name: 'Download report.pdf' }))
|
|
76
|
+
expect(onDownload).toHaveBeenCalledTimes(1)
|
|
77
|
+
expect(outside).not.toHaveBeenCalled()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('the full words on hover, only when they are cut off', () => {
|
|
81
|
+
// jsdom lays nothing out: a line is "cut off" here when its words are longer than 20 characters.
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) {
|
|
84
|
+
return (this.textContent?.length ?? 0) * 7
|
|
85
|
+
})
|
|
86
|
+
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(140)
|
|
87
|
+
})
|
|
88
|
+
afterEach(() => vi.restoreAllMocks())
|
|
89
|
+
// Longer than the tooltip's 300ms wait, so "nothing" means nothing opened.
|
|
90
|
+
const pastTheWait = () => new Promise((resolve) => setTimeout(resolve, 600))
|
|
91
|
+
|
|
92
|
+
it.each([
|
|
93
|
+
['a document', (name: string) => <AttachmentCard name={name} size={1000} href="#" />],
|
|
94
|
+
['a document without an address', (name: string) => <AttachmentCard name={name} size={1000} />],
|
|
95
|
+
['an image', (name: string) => <AttachmentCard name={`${name}.png`} src="data:image/png;base64,AA" />],
|
|
96
|
+
['a file that could not be read', (name: string) => <AttachmentCard name={`${name}.png`} state="unreadable" />],
|
|
97
|
+
['a pending card', (name: string) => <AttachmentCard pending name={`${name}.png`} size={1} />],
|
|
98
|
+
])('%s: a name that is cut off shows in full; one that fits shows nothing', async (_, card) => {
|
|
99
|
+
const user = userEvent.setup()
|
|
100
|
+
render(card('2026-Q3-billing-reconciliation-FINAL-v4.xlsx'))
|
|
101
|
+
const long = screen.getByText(/^2026-Q3-billing-reconciliation-FINAL-v4/)
|
|
102
|
+
expect(long.className).toContain('truncate')
|
|
103
|
+
await user.hover(long)
|
|
104
|
+
expect((await screen.findByRole('tooltip')).textContent).toBe(long.textContent)
|
|
105
|
+
cleanup()
|
|
106
|
+
|
|
107
|
+
render(card('notes'))
|
|
108
|
+
await user.hover(screen.getByText(/^notes/))
|
|
109
|
+
await pastTheWait()
|
|
110
|
+
expect(screen.queryByRole('tooltip')).toBeNull()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('the size never has one', async () => {
|
|
114
|
+
const user = userEvent.setup()
|
|
115
|
+
render(<AttachmentCard pending name="a.png" size={248_000} />)
|
|
116
|
+
await user.hover(screen.getByText('242 KB'))
|
|
117
|
+
await pastTheWait()
|
|
118
|
+
expect(screen.queryByRole('tooltip')).toBeNull()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('a note’s hint is always on hover: it says more than the line', async () => {
|
|
122
|
+
const user = userEvent.setup()
|
|
123
|
+
render(<AttachmentCard pending name="a.png" size={1} state="warning" note="Not shared" noteHint="Only this app has a copy of this file." />)
|
|
124
|
+
await user.hover(screen.getByText('Not shared'))
|
|
125
|
+
expect((await screen.findByRole('tooltip')).textContent).toBe('Only this app has a copy of this file.')
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('an image on its way is a busy placeholder, named as a status', () => {
|
|
130
|
+
const { container } = render(<AttachmentCard name="shot.png" state="loading" />)
|
|
131
|
+
const root = rootOf(container)
|
|
132
|
+
// A name needs a role to hang on: a plain div may not carry aria-label.
|
|
133
|
+
expect(screen.getByRole('status', { name: 'Loading shot.png' })).toBe(root)
|
|
134
|
+
expect(root.getAttribute('aria-busy')).toBe('true')
|
|
135
|
+
expect(classesOf(root)).toContain('animate-pulse')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('a file that could not be read says so, with a dashed hairline', () => {
|
|
139
|
+
const { container } = render(<AttachmentCard name="shot.png" state="unreadable" />)
|
|
140
|
+
expect(classesOf(rootOf(container))).toContain('border-dashed')
|
|
141
|
+
expect(screen.getByText('Could not be loaded')).toBeTruthy()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('pending: the size when ready, "Uploading…" while not, the reason when it failed, the warning when it is one', () => {
|
|
145
|
+
render(<AttachmentCard pending name="a.png" size={248_000} />)
|
|
146
|
+
expect(screen.getByText('242 KB')).toBeTruthy()
|
|
147
|
+
cleanup()
|
|
148
|
+
render(<AttachmentCard pending name="a.png" size={248_000} state="uploading" />)
|
|
149
|
+
expect(screen.getByText('Uploading…')).toBeTruthy()
|
|
150
|
+
cleanup()
|
|
151
|
+
const failed = render(<AttachmentCard pending name="a.tiff" size={1} state="failed" note="TIFF files aren't supported." />)
|
|
152
|
+
expect(screen.getByText("TIFF files aren't supported.").className).toContain('text-error-default')
|
|
153
|
+
expect(classesOf(rootOf(failed.container))).toContain('border-error-default')
|
|
154
|
+
cleanup()
|
|
155
|
+
const warning = render(<AttachmentCard pending name="a.png" size={1} state="warning" note="Not everyone can see this" />)
|
|
156
|
+
expect(screen.getByText('Not everyone can see this').className).toContain('text-warning-default')
|
|
157
|
+
expect(classesOf(rootOf(warning.container))).toContain('border-warning-default')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('a pending card is removed by the app, and the click stays out of what it sits in', async () => {
|
|
161
|
+
const onRemove = vi.fn()
|
|
162
|
+
const outside = vi.fn()
|
|
163
|
+
render(
|
|
164
|
+
<div onClick={outside}>
|
|
165
|
+
<AttachmentCard pending name="a.png" size={1} onRemove={onRemove} />
|
|
166
|
+
</div>,
|
|
167
|
+
)
|
|
168
|
+
await userEvent.click(screen.getByRole('button', { name: 'Remove a.png' }))
|
|
169
|
+
expect(onRemove).toHaveBeenCalledTimes(1)
|
|
170
|
+
expect(outside).not.toHaveBeenCalled()
|
|
171
|
+
})
|
|
172
|
+
})
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState, type ComponentPropsWithRef, type FC } from 'react'
|
|
2
|
+
import { Button as BaseButton } from '@base-ui/react/button'
|
|
3
|
+
import {
|
|
4
|
+
IconAlertCircle,
|
|
5
|
+
IconAlertTriangle,
|
|
6
|
+
IconDownload,
|
|
7
|
+
IconFile,
|
|
8
|
+
IconFileTypeCsv,
|
|
9
|
+
IconFileTypeDocx,
|
|
10
|
+
IconFileTypePdf,
|
|
11
|
+
IconFileTypePpt,
|
|
12
|
+
IconFileTypeTxt,
|
|
13
|
+
IconFileTypeXls,
|
|
14
|
+
IconFileTypeZip,
|
|
15
|
+
IconJson,
|
|
16
|
+
IconLoader2,
|
|
17
|
+
IconMarkdown,
|
|
18
|
+
IconX,
|
|
19
|
+
} from '@tabler/icons-react'
|
|
20
|
+
import { Card } from './Card'
|
|
21
|
+
import { IconButton } from './IconButton'
|
|
22
|
+
import { Link } from './Link'
|
|
23
|
+
import { WithTooltip } from './Tooltip'
|
|
24
|
+
import { cn } from './cn'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Something attached — a document or an image — drawn as a card.
|
|
28
|
+
*
|
|
29
|
+
* Peek's two attachment components, class for class (UIG-27, Katerina's ruling
|
|
30
|
+
* of 14 September that both apps draw one): `FileAttachmentCard`, the card on
|
|
31
|
+
* something already posted, and `PendingAttachmentChip`, the card in a composer
|
|
32
|
+
* waiting to go. What stays in each app is what only the app can do: fetch the
|
|
33
|
+
* bytes with the reader's own permission, open an image full screen, save a
|
|
34
|
+
* file. The card is told the result — a `src`, an `href`, a `state` — and hands
|
|
35
|
+
* a click back through `onOpen`, `onDownload` and `onRemove`.
|
|
36
|
+
*
|
|
37
|
+
* Two shapes, decided by the file: an **image** with a picture is a 180px
|
|
38
|
+
* thumbnail you can open; anything else is a 240px **row** — a type tile, the
|
|
39
|
+
* name, and what it is. Waiting to be sent (`pending`) it is a 200px row with a
|
|
40
|
+
* remove control on its corner.
|
|
41
|
+
*
|
|
42
|
+
* One change from Peek, by ruling: a file that could not be read has a dashed
|
|
43
|
+
* hairline, as every card that cannot be read does.
|
|
44
|
+
*/
|
|
45
|
+
export type AttachmentCardState = 'ready' | 'loading' | 'unreadable' | 'uploading' | 'failed' | 'warning'
|
|
46
|
+
|
|
47
|
+
const ICON_BY_EXTENSION: Record<string, FC<{ size?: number; stroke?: number; className?: string }>> = {
|
|
48
|
+
pdf: IconFileTypePdf,
|
|
49
|
+
doc: IconFileTypeDocx,
|
|
50
|
+
docx: IconFileTypeDocx,
|
|
51
|
+
xls: IconFileTypeXls,
|
|
52
|
+
xlsx: IconFileTypeXls,
|
|
53
|
+
ppt: IconFileTypePpt,
|
|
54
|
+
pptx: IconFileTypePpt,
|
|
55
|
+
csv: IconFileTypeCsv,
|
|
56
|
+
txt: IconFileTypeTxt,
|
|
57
|
+
rtf: IconFileTypeTxt,
|
|
58
|
+
zip: IconFileTypeZip,
|
|
59
|
+
json: IconJson,
|
|
60
|
+
md: IconMarkdown,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'heic', 'heif', 'svg'])
|
|
64
|
+
|
|
65
|
+
const extensionOf = (name: string) => {
|
|
66
|
+
const dot = name.lastIndexOf('.')
|
|
67
|
+
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ''
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const isImage = (name: string, contentType?: string) => IMAGE_EXTENSIONS.has(extensionOf(name)) || (contentType?.startsWith('image/') ?? false)
|
|
71
|
+
|
|
72
|
+
/** `'2.4 MB'`, `'812 KB'`, `'340 bytes'`. */
|
|
73
|
+
const formatBytes = (bytes: number) => {
|
|
74
|
+
if (bytes < 1024) return `${bytes} bytes`
|
|
75
|
+
const kb = bytes / 1024
|
|
76
|
+
if (kb < 1024) return `${Math.round(kb)} KB`
|
|
77
|
+
return `${(kb / 1024).toFixed(1)} MB`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `'PDF'`, `'ZIP'`, `'DOCX'`, `'FILE'`. */
|
|
81
|
+
const typeLabelOf = (name: string) => extensionOf(name).toUpperCase() || 'FILE'
|
|
82
|
+
|
|
83
|
+
const TILE = 'size-9 rounded-md bg-bg-active flex items-center justify-center shrink-0 text-text-secondary'
|
|
84
|
+
const NAME = 'text-[12px] font-medium leading-[1.3] text-text-primary truncate'
|
|
85
|
+
const NOTE = 'text-[10px] leading-[1.2] truncate'
|
|
86
|
+
|
|
87
|
+
function TypeIcon({ name }: { name: string }) {
|
|
88
|
+
const Icon = ICON_BY_EXTENSION[extensionOf(name)] ?? IconFile
|
|
89
|
+
return <Icon size={20} stroke={1.5} />
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A line that truncates, with its full words on hover only when they are cut off — a name that fits, or a size,
|
|
94
|
+
* shows nothing extra (Katerina, 2026-09-14; Breadcrumb's rule, 2026-09-01). `hint`, when given, is always on hover:
|
|
95
|
+
* it says more than the line, not the same words again.
|
|
96
|
+
*
|
|
97
|
+
* Measured like Breadcrumb's crumbs: on mount, when the line resizes, and when the fonts arrive — a name set in
|
|
98
|
+
* the fallback face can fit and then not, in a line whose box never changes. The wrapper is `flex-col` so the
|
|
99
|
+
* text stretches across it, as wide as it is without one.
|
|
100
|
+
*/
|
|
101
|
+
function Truncating({ text, hint, className, wrapperClassName }: { text: string; hint?: string; className: string; wrapperClassName?: string }) {
|
|
102
|
+
const ref = useRef<HTMLSpanElement>(null)
|
|
103
|
+
const [cut, setCut] = useState(false)
|
|
104
|
+
useLayoutEffect(() => {
|
|
105
|
+
const el = ref.current
|
|
106
|
+
if (!el) return
|
|
107
|
+
let live = true
|
|
108
|
+
const measure = () => {
|
|
109
|
+
if (live) setCut(el.scrollWidth > el.clientWidth)
|
|
110
|
+
}
|
|
111
|
+
measure()
|
|
112
|
+
void document.fonts?.ready.then(measure)
|
|
113
|
+
// jsdom has neither layout nor ResizeObserver; without this guard an app cannot render a card in its tests.
|
|
114
|
+
if (typeof ResizeObserver === 'undefined') return () => void (live = false)
|
|
115
|
+
const observer = new ResizeObserver(measure)
|
|
116
|
+
observer.observe(el)
|
|
117
|
+
return () => {
|
|
118
|
+
live = false
|
|
119
|
+
observer.disconnect()
|
|
120
|
+
}
|
|
121
|
+
// `cut` re-runs it: wrapping the line mounts a new element, and that one is the one to watch.
|
|
122
|
+
}, [text, cut])
|
|
123
|
+
const line = (
|
|
124
|
+
<span ref={ref} className={className}>
|
|
125
|
+
{text}
|
|
126
|
+
</span>
|
|
127
|
+
)
|
|
128
|
+
if (!hint && !cut) return line
|
|
129
|
+
return (
|
|
130
|
+
<WithTooltip label={hint ?? text} wrapperClassName={cn('min-w-0 flex-col', wrapperClassName)}>
|
|
131
|
+
{line}
|
|
132
|
+
</WithTooltip>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface AttachmentCardProps extends Omit<ComponentPropsWithRef<'div'>, 'children'> {
|
|
137
|
+
/** The name it was attached under, extension included: it decides the icon and the type label. */
|
|
138
|
+
name: string
|
|
139
|
+
/** In bytes. */
|
|
140
|
+
size?: number
|
|
141
|
+
/** Its MIME type, where known: an `image/…` type is an image whatever its name. */
|
|
142
|
+
contentType?: string
|
|
143
|
+
/** Where a document opens, in a new tab. Without it the row is dimmed and opens nothing. */
|
|
144
|
+
href?: string
|
|
145
|
+
/** The picture an image card draws. Without it an image is drawn as a row. */
|
|
146
|
+
src?: string
|
|
147
|
+
/** What a click on an image's picture does — the app's full-screen view. */
|
|
148
|
+
onOpen?: () => void
|
|
149
|
+
/** Shows a download control on hover or focus, and calls this. */
|
|
150
|
+
onDownload?: () => void
|
|
151
|
+
/** Waiting to be sent: the composer's 200px row, with a remove control on its corner. */
|
|
152
|
+
pending?: boolean
|
|
153
|
+
/** Removes a pending card. */
|
|
154
|
+
onRemove?: () => void
|
|
155
|
+
/**
|
|
156
|
+
* `ready` · `loading` (an image on its way) · `unreadable` (it could not be read) ·
|
|
157
|
+
* `uploading`, `failed`, `warning` (while pending). Default `ready`.
|
|
158
|
+
*/
|
|
159
|
+
state?: AttachmentCardState
|
|
160
|
+
/** The line under the name, in place of its type and size — why it failed, or what the warning is. */
|
|
161
|
+
note?: string
|
|
162
|
+
/** The note's full words, on hover, where the line is short. */
|
|
163
|
+
noteHint?: string
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function AttachmentCard({
|
|
167
|
+
name,
|
|
168
|
+
size,
|
|
169
|
+
contentType,
|
|
170
|
+
href,
|
|
171
|
+
src,
|
|
172
|
+
onOpen,
|
|
173
|
+
onDownload,
|
|
174
|
+
pending = false,
|
|
175
|
+
onRemove,
|
|
176
|
+
state = 'ready',
|
|
177
|
+
note,
|
|
178
|
+
noteHint,
|
|
179
|
+
className,
|
|
180
|
+
...props
|
|
181
|
+
}: AttachmentCardProps) {
|
|
182
|
+
const image = isImage(name, contentType)
|
|
183
|
+
const sizeText = size === undefined ? '' : formatBytes(size)
|
|
184
|
+
|
|
185
|
+
if (pending) {
|
|
186
|
+
const failed = state === 'failed'
|
|
187
|
+
const warning = state === 'warning'
|
|
188
|
+
return (
|
|
189
|
+
<Card
|
|
190
|
+
fill="elevated"
|
|
191
|
+
className={cn('group relative flex items-center gap-2 w-[200px] p-1.5 pr-3', failed ? 'border-error-default' : warning && 'border-warning-default', className)}
|
|
192
|
+
{...props}
|
|
193
|
+
>
|
|
194
|
+
<div className={cn(TILE, 'overflow-hidden')}>
|
|
195
|
+
{state === 'uploading' ? (
|
|
196
|
+
<IconLoader2 size={16} stroke={1.5} className="animate-spin" />
|
|
197
|
+
) : failed ? (
|
|
198
|
+
<IconAlertCircle size={16} stroke={1.5} className="text-error-default" />
|
|
199
|
+
) : warning ? (
|
|
200
|
+
<IconAlertTriangle size={16} stroke={1.5} className="text-warning-default" />
|
|
201
|
+
) : image && src ? (
|
|
202
|
+
<img src={src} alt={name} className="size-full object-cover" />
|
|
203
|
+
) : (
|
|
204
|
+
<span className="text-[9px] font-semibold">{typeLabelOf(name)}</span>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
<div className="flex flex-col gap-[1px] min-w-0">
|
|
208
|
+
<Truncating text={name} className={NAME} />
|
|
209
|
+
<Truncating
|
|
210
|
+
text={(failed || warning ? note : state === 'uploading' ? (note ?? 'Uploading…') : (note ?? sizeText)) ?? ''}
|
|
211
|
+
hint={noteHint}
|
|
212
|
+
className={cn(NOTE, failed ? 'text-error-default' : warning ? 'text-warning-default' : 'text-text-secondary')}
|
|
213
|
+
/>
|
|
214
|
+
</div>
|
|
215
|
+
{/* On Base UI's Button, as InputChip's ✕ is (Katerina, 2026-09-14): IconButton is a 24px square
|
|
216
|
+
that fills on hover, and this is Peek's 20px round badge on the card's corner. */}
|
|
217
|
+
{onRemove && (
|
|
218
|
+
<BaseButton
|
|
219
|
+
type="button"
|
|
220
|
+
aria-label={`Remove ${name}`}
|
|
221
|
+
className="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-bg-elevated border border-border-strong flex items-center justify-center text-text-secondary hover:text-text-primary opacity-0 group-hover:opacity-100 transition-opacity"
|
|
222
|
+
onClick={(event) => {
|
|
223
|
+
event.stopPropagation()
|
|
224
|
+
onRemove()
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
<IconX size={11} stroke={1.75} />
|
|
228
|
+
</BaseButton>
|
|
229
|
+
)}
|
|
230
|
+
</Card>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Fades in with the card's hover or keyboard focus, and keeps its slot when hidden, so revealing it never shifts the row.
|
|
235
|
+
const download = onDownload ? (
|
|
236
|
+
<IconButton
|
|
237
|
+
variant="muted"
|
|
238
|
+
tooltip="Download"
|
|
239
|
+
aria-label={`Download ${name}`}
|
|
240
|
+
className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 transition-opacity"
|
|
241
|
+
onClick={(event) => {
|
|
242
|
+
event.stopPropagation()
|
|
243
|
+
onDownload()
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
<IconDownload size={16} stroke={1.5} />
|
|
247
|
+
</IconButton>
|
|
248
|
+
) : null
|
|
249
|
+
|
|
250
|
+
if (state === 'loading') {
|
|
251
|
+
return (
|
|
252
|
+
// `role="status"`: a plain box may not carry a name (axe, aria-prohibited-attr), and a
|
|
253
|
+
// status says what it is doing — loading — to a reader that cannot see the pulse.
|
|
254
|
+
<Card role="status" fill="inset" aria-busy="true" aria-label={`Loading ${name}`} className={cn('w-[180px] h-28 animate-pulse', className)} {...props}>
|
|
255
|
+
{null}
|
|
256
|
+
</Card>
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (state === 'unreadable') {
|
|
261
|
+
return (
|
|
262
|
+
<Card fill="inset" unreadable className={cn('flex items-center gap-2 w-[240px] p-1.5 opacity-70', className)} {...props}>
|
|
263
|
+
<div className={TILE}>
|
|
264
|
+
<TypeIcon name={name} />
|
|
265
|
+
</div>
|
|
266
|
+
<div className="flex flex-col gap-[1px] min-w-0 text-left">
|
|
267
|
+
<Truncating text={name} className={NAME} />
|
|
268
|
+
<span className={cn(NOTE, 'text-text-secondary')}>{note ?? 'Could not be loaded'}</span>
|
|
269
|
+
</div>
|
|
270
|
+
</Card>
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (image && src) {
|
|
275
|
+
const picture = <img src={src} alt={name} className="w-full h-28 object-cover" />
|
|
276
|
+
return (
|
|
277
|
+
<Card fill="inset" hover="hairline" className={cn('group relative flex flex-col w-[180px] overflow-hidden', className)} {...props}>
|
|
278
|
+
{onOpen ? (
|
|
279
|
+
<BaseButton
|
|
280
|
+
type="button"
|
|
281
|
+
className="block w-full"
|
|
282
|
+
aria-label={`Preview ${name}`}
|
|
283
|
+
onClick={(event) => {
|
|
284
|
+
event.stopPropagation()
|
|
285
|
+
onOpen()
|
|
286
|
+
}}
|
|
287
|
+
>
|
|
288
|
+
{picture}
|
|
289
|
+
</BaseButton>
|
|
290
|
+
) : (
|
|
291
|
+
<div className="block w-full">{picture}</div>
|
|
292
|
+
)}
|
|
293
|
+
<div className="flex items-center gap-1 pl-2 pr-1 py-1 min-w-0">
|
|
294
|
+
<Truncating text={name} className="flex-1 text-[12px] leading-[1.3] text-text-primary truncate" wrapperClassName="flex-1" />
|
|
295
|
+
{download}
|
|
296
|
+
</div>
|
|
297
|
+
</Card>
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const body = (
|
|
302
|
+
<>
|
|
303
|
+
<div className={TILE}>
|
|
304
|
+
<TypeIcon name={name} />
|
|
305
|
+
</div>
|
|
306
|
+
<div className="flex flex-col gap-[1px] min-w-0 text-left">
|
|
307
|
+
<Truncating text={name} className={NAME} />
|
|
308
|
+
<span className={cn(NOTE, 'text-text-secondary')}>{note ?? `${typeLabelOf(name)} · ${sizeText}`}</span>
|
|
309
|
+
</div>
|
|
310
|
+
</>
|
|
311
|
+
)
|
|
312
|
+
return (
|
|
313
|
+
<Card fill="inset" hover={href ? 'hairline' : 'none'} className={cn('group flex items-center gap-2 w-[240px] p-1.5 pr-1', !href && 'opacity-70', className)} {...props}>
|
|
314
|
+
{href ? (
|
|
315
|
+
<Link href={href} external variant="plain" className="flex items-center gap-2 min-w-0 flex-1 cursor-pointer" onClick={(event) => event.stopPropagation()}>
|
|
316
|
+
{body}
|
|
317
|
+
</Link>
|
|
318
|
+
) : (
|
|
319
|
+
<div className="flex items-center gap-2 min-w-0 flex-1">{body}</div>
|
|
320
|
+
)}
|
|
321
|
+
{download}
|
|
322
|
+
</Card>
|
|
323
|
+
)
|
|
324
|
+
}
|