@estiva-app/ui 0.12.10 → 0.13.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/AttachmentCard.d.ts +52 -0
- package/dist/AttachmentCard.d.ts.map +1 -0
- package/dist/Card.d.ts +48 -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 +694 -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 +68 -0
- package/src/Card.stories.tsx +109 -0
- package/src/Card.test.tsx +106 -0
- package/src/Card.tsx +115 -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
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the InlineChip page claims, pinned: without `href` it is a label, with
|
|
4
|
+
* one it is a link a router can take; every tone keeps the shape and its own
|
|
5
|
+
* size and colour through `cn()`; the icon sits in its 16px box; and the class
|
|
6
|
+
* function a string-only renderer uses gives the same classes as the component.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
9
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
10
|
+
import userEvent from '@testing-library/user-event'
|
|
11
|
+
import { INLINE_CHIP_CLASSES, INLINE_CHIP_TONE_CLASSES, InlineChip, inlineChipClassName } from './InlineChip'
|
|
12
|
+
|
|
13
|
+
afterEach(cleanup)
|
|
14
|
+
|
|
15
|
+
const classesOf = (el: Element) => el.getAttribute('class')?.split(' ').filter(Boolean) ?? []
|
|
16
|
+
|
|
17
|
+
describe('InlineChip', () => {
|
|
18
|
+
it('without href it is a label, not a link', () => {
|
|
19
|
+
render(<InlineChip>Label</InlineChip>)
|
|
20
|
+
const chip = screen.getByText('Label')
|
|
21
|
+
expect(chip.tagName).toBe('SPAN')
|
|
22
|
+
expect(screen.queryByRole('link')).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('with href it is a link, and a router app takes the click with the href intact', async () => {
|
|
26
|
+
const onClick = vi.fn((event: { preventDefault: () => void }) => event.preventDefault())
|
|
27
|
+
render(
|
|
28
|
+
<InlineChip href="/somewhere" onClick={onClick}>
|
|
29
|
+
Label
|
|
30
|
+
</InlineChip>,
|
|
31
|
+
)
|
|
32
|
+
const link = screen.getByRole('link', { name: 'Label' })
|
|
33
|
+
await userEvent.click(link)
|
|
34
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
35
|
+
expect(link.getAttribute('href')).toBe('/somewhere')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('is one line high and pinned to the top of the line, in every tone', () => {
|
|
39
|
+
for (const tone of Object.keys(INLINE_CHIP_TONE_CLASSES) as (keyof typeof INLINE_CHIP_TONE_CLASSES)[]) {
|
|
40
|
+
const classes = inlineChipClassName(tone).split(' ')
|
|
41
|
+
expect(classes).toContain('align-top')
|
|
42
|
+
expect(classes).toContain('mx-0.5')
|
|
43
|
+
// One height only: 1.4em of the body size, or the same 19.6px written out where the text is smaller.
|
|
44
|
+
const heights = classes.filter((c) => c.startsWith('h-'))
|
|
45
|
+
expect(heights).toEqual([tone === 'quiet' ? 'h-[19.6px]' : 'h-[1.4em]'])
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('each tone keeps its size and its colour through cn()', () => {
|
|
50
|
+
const neutral = inlineChipClassName('neutral').split(' ')
|
|
51
|
+
expect(neutral).toContain('text-body-2')
|
|
52
|
+
expect(neutral).toContain('text-text-primary')
|
|
53
|
+
const person = inlineChipClassName('person').split(' ')
|
|
54
|
+
expect(person).toContain('text-body-2')
|
|
55
|
+
expect(person).toContain('text-accent-primary')
|
|
56
|
+
const urgent = inlineChipClassName('urgent').split(' ')
|
|
57
|
+
expect(urgent).toContain('text-warning-default')
|
|
58
|
+
// quiet is smaller: its caption size replaces the body size, and its colour survives beside it
|
|
59
|
+
const quiet = inlineChipClassName('quiet').split(' ')
|
|
60
|
+
expect(quiet).toContain('text-caption')
|
|
61
|
+
expect(quiet).not.toContain('text-body-2')
|
|
62
|
+
expect(quiet).toContain('text-text-muted')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('the component and the class function give the same classes', () => {
|
|
66
|
+
render(
|
|
67
|
+
<InlineChip tone="person" className="max-w-[24ch]">
|
|
68
|
+
Label
|
|
69
|
+
</InlineChip>,
|
|
70
|
+
)
|
|
71
|
+
expect(classesOf(screen.getByText('Label'))).toEqual(inlineChipClassName('person', 'max-w-[24ch]').split(' '))
|
|
72
|
+
expect(INLINE_CHIP_CLASSES.split(' ').every((c) => classesOf(screen.getByText('Label')).includes(c))).toBe(true)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('draws the icon first, in its 16px box', () => {
|
|
76
|
+
render(<InlineChip icon={<svg data-testid="icon" />}>Label</InlineChip>)
|
|
77
|
+
const box = screen.getByTestId('icon').parentElement!
|
|
78
|
+
expect(classesOf(box)).toEqual(expect.arrayContaining(['size-4', 'shrink-0']))
|
|
79
|
+
expect(box.parentElement!.firstElementChild).toBe(box)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ComponentPropsWithRef, ReactNode } from 'react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A word in a sentence that stands for something: a person, a place, a thing
|
|
6
|
+
* written into running text. One line high, level with the words around it.
|
|
7
|
+
*
|
|
8
|
+
* The shape is Peek's inline chip (D67, Peek PR #206, 2026-09-13), class for
|
|
9
|
+
* class, moved into the package so both apps draw one (UIG-27). Three things
|
|
10
|
+
* changed on the way, all ruled by Katerina:
|
|
11
|
+
*
|
|
12
|
+
* - the person tone is `person`, not `mention` — every chip here is a
|
|
13
|
+
* mention of something;
|
|
14
|
+
* - the alignment is a class (`align-top h-[1.4em]`), not a `style` object,
|
|
15
|
+
* so it is part of the class list like every other size in the package;
|
|
16
|
+
* - a chip that leads somewhere takes `href` and `onClick`, the way `NavItem`
|
|
17
|
+
* does: the package draws an anchor, the app's router takes the click.
|
|
18
|
+
*
|
|
19
|
+
* **Why `align-top` and `1.4em`.** `1.4em` is the body text's line height,
|
|
20
|
+
* so the chip is exactly one line tall; `vertical-align: top` pins it to the
|
|
21
|
+
* line box, which keeps the line at 19.6px and the chip's words level with
|
|
22
|
+
* the sentence's, with or without an icon. Measured in Peek on 2026-09-13:
|
|
23
|
+
* `text-bottom` made the line 21.2px and lifted the chip's words 1.6px;
|
|
24
|
+
* `baseline` lifted them 3.8px once an icon came first.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The shape every tone shares. Exported for a rich-text editor that renders chips from strings. */
|
|
28
|
+
export const INLINE_CHIP_CLASSES =
|
|
29
|
+
'inline-flex h-[1.4em] items-center gap-1 rounded-sm px-1 mx-0.5 align-top text-body-2 font-normal select-none'
|
|
30
|
+
|
|
31
|
+
/** What a chip is made of, by what it stands for. */
|
|
32
|
+
export const INLINE_CHIP_TONE_CLASSES = {
|
|
33
|
+
/** A thing or a place — anything that is not a person. */
|
|
34
|
+
neutral: 'bg-bg-active text-text-primary',
|
|
35
|
+
/** A person. */
|
|
36
|
+
person: 'bg-accent-muted text-accent-primary',
|
|
37
|
+
/** A person, called urgently. */
|
|
38
|
+
urgent: 'bg-warning-muted text-warning-default',
|
|
39
|
+
/**
|
|
40
|
+
* A reference nobody could resolve — still a mention, just an anonymous one.
|
|
41
|
+
* Its height is the body line's 19.6px written out, because `1.4em` here is
|
|
42
|
+
* the caption's em: 16.8px, with the letters 1.8px above the sentence's
|
|
43
|
+
* baseline. At 19.6px they sit 0.4px off it (measured 2026-09-14; Peek's
|
|
44
|
+
* copy of this chip still has the 16.8px box).
|
|
45
|
+
*/
|
|
46
|
+
quiet: 'h-[19.6px] bg-bg-active text-text-muted font-mono text-caption',
|
|
47
|
+
} as const
|
|
48
|
+
|
|
49
|
+
export type InlineChipTone = keyof typeof INLINE_CHIP_TONE_CLASSES
|
|
50
|
+
|
|
51
|
+
/** The chip's classes for a tone, with anything the caller adds. For renderers that cannot use the component. */
|
|
52
|
+
export function inlineChipClassName(tone: InlineChipTone, className?: string) {
|
|
53
|
+
return cn(INLINE_CHIP_CLASSES, INLINE_CHIP_TONE_CLASSES[tone], className)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface InlineChipProps extends Omit<ComponentPropsWithRef<'a'>, 'children'> {
|
|
57
|
+
/** What the chip stands for. Default `neutral`. */
|
|
58
|
+
tone?: InlineChipTone
|
|
59
|
+
/** Drawn before the label in a 16px box: a 16px icon, or a 14px one it centres. */
|
|
60
|
+
icon?: ReactNode
|
|
61
|
+
/**
|
|
62
|
+
* Where the chip leads. With it the chip is an anchor; without it, a span.
|
|
63
|
+
* A router app passes `onClick` too, and navigates there itself.
|
|
64
|
+
*/
|
|
65
|
+
href?: string
|
|
66
|
+
children: ReactNode
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function InlineChip({ tone = 'neutral', icon, href, className, children, ...props }: InlineChipProps) {
|
|
70
|
+
const body = (
|
|
71
|
+
<>
|
|
72
|
+
{icon && <span className="flex size-4 shrink-0 items-center justify-center text-text-secondary">{icon}</span>}
|
|
73
|
+
{children}
|
|
74
|
+
</>
|
|
75
|
+
)
|
|
76
|
+
const classes = inlineChipClassName(tone, className)
|
|
77
|
+
// Without somewhere to go it is a label, and a label is not a link.
|
|
78
|
+
if (href === undefined) {
|
|
79
|
+
return (
|
|
80
|
+
<span className={classes} {...(props as ComponentPropsWithRef<'span'>)}>
|
|
81
|
+
{body}
|
|
82
|
+
</span>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
return (
|
|
86
|
+
<a href={href} className={classes} {...props}>
|
|
87
|
+
{body}
|
|
88
|
+
</a>
|
|
89
|
+
)
|
|
90
|
+
}
|
package/src/Link.mdx
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as LinkStories from './Link.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={LinkStories} />
|
|
5
|
+
|
|
6
|
+
# Link
|
|
7
|
+
|
|
8
|
+
A link, in one of four looks. It is a real anchor with a real address, and
|
|
9
|
+
the app decides how it navigates.
|
|
10
|
+
|
|
11
|
+
<Canvas of={LinkStories.AllVariants} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- **`text`** — a link written inside text: the info colour, always
|
|
16
|
+
underlined, dimming on hover.
|
|
17
|
+
- **`quiet`** — a title or a time that is also a link. It takes the colour and
|
|
18
|
+
size of the text it sits in, and underlines on hover.
|
|
19
|
+
- **`underlined`** — a short "open it there" beside a note. It takes the
|
|
20
|
+
note's colour, is always underlined, and brightens on hover.
|
|
21
|
+
- **`plain`** — a box or a row that is a link as a whole. No look of its own:
|
|
22
|
+
what it wraps draws itself.
|
|
23
|
+
- `external` — another site or app: it opens in a new tab, with
|
|
24
|
+
`noopener noreferrer`.
|
|
25
|
+
|
|
26
|
+
## When not
|
|
27
|
+
|
|
28
|
+
- An action — something happens, nothing is navigated to → **Button**.
|
|
29
|
+
- A row of a sidebar → **NavItem**. A tile of a rail → **RailItem**.
|
|
30
|
+
- The trail back up → **Breadcrumb**.
|
|
31
|
+
- A word standing for a person or a thing, drawn in a small box → **InlineChip**
|
|
32
|
+
with `href`.
|
|
33
|
+
|
|
34
|
+
## How
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
import { Link } from '@estiva-app/ui'
|
|
38
|
+
|
|
39
|
+
<p className="text-body-2">
|
|
40
|
+
Read <Link href="https://example.com" external>the guide</Link> first.
|
|
41
|
+
</p>
|
|
42
|
+
|
|
43
|
+
<h3 className="text-body-2 font-semibold text-text-primary">
|
|
44
|
+
<Link href={itemUrl} variant="quiet" onClick={navigate}>Item one</Link>
|
|
45
|
+
</h3>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- It renders a plain anchor and passes every anchor prop through. A router app
|
|
49
|
+
passes `onClick`, prevents the default and navigates; the `href` stays a real
|
|
50
|
+
address, so a modified click or a new tab still works.
|
|
51
|
+
- `quiet` and `underlined` set no size and no colour at rest: put them inside
|
|
52
|
+
the text whose look they should take.
|
|
53
|
+
- `plain` wraps a block: give the Link `className="block"` and let the child
|
|
54
|
+
draw the border, the fill and the hover.
|
|
55
|
+
- If your router also exports a `Link`, rename one of them on import.
|
|
56
|
+
|
|
57
|
+
## Props
|
|
58
|
+
|
|
59
|
+
<Controls of={LinkStories.Text} />
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { Link } from './Link'
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Navigation/Link',
|
|
6
|
+
component: Link,
|
|
7
|
+
args: { href: '#', variant: 'text', external: false, children: 'a link' },
|
|
8
|
+
argTypes: {
|
|
9
|
+
variant: { control: 'inline-radio', options: ['text', 'quiet', 'underlined', 'plain'] },
|
|
10
|
+
},
|
|
11
|
+
} satisfies Meta<typeof Link>
|
|
12
|
+
|
|
13
|
+
export default meta
|
|
14
|
+
type Story = StoryObj<typeof meta>
|
|
15
|
+
|
|
16
|
+
/** Inside written text: the info colour, always underlined. Hover dims it. */
|
|
17
|
+
export const Text: Story = {
|
|
18
|
+
render: (args) => (
|
|
19
|
+
<p className="max-w-[480px] text-body-2 text-text-primary">
|
|
20
|
+
A sentence can hold <Link {...args} /> in the middle of it, and the words around it read on.
|
|
21
|
+
</p>
|
|
22
|
+
),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A title, or a small time, that is also a link: it keeps its text's colour and size, and underlines on hover. */
|
|
26
|
+
export const Quiet: Story = {
|
|
27
|
+
args: { variant: 'quiet', children: 'Item one' },
|
|
28
|
+
render: (args) => (
|
|
29
|
+
<div className="flex flex-col gap-2">
|
|
30
|
+
<p className="text-body-2 font-semibold text-text-primary">
|
|
31
|
+
<Link {...args} />
|
|
32
|
+
</p>
|
|
33
|
+
<p className="text-caption text-text-secondary">
|
|
34
|
+
<Link {...args}>2:14 PM</Link>
|
|
35
|
+
</p>
|
|
36
|
+
</div>
|
|
37
|
+
),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Beside a note, a short way to open the thing somewhere else: the note's colour, always underlined. Hover brightens it. */
|
|
41
|
+
export const Underlined: Story = {
|
|
42
|
+
args: { variant: 'underlined', external: true, children: 'Open it there ↗' },
|
|
43
|
+
render: (args) => (
|
|
44
|
+
<p className="max-w-[480px] text-caption text-text-secondary">
|
|
45
|
+
This can’t be shown here. <Link {...args} />
|
|
46
|
+
</p>
|
|
47
|
+
),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** No look of its own: what it wraps draws itself — a box, a row. */
|
|
51
|
+
export const Plain: Story = {
|
|
52
|
+
args: { variant: 'plain', children: undefined },
|
|
53
|
+
render: (args) => (
|
|
54
|
+
<Link {...args} className="block w-[280px]">
|
|
55
|
+
<div className="rounded-lg border border-border-default bg-bg-surface p-3 text-body-2 text-text-primary transition-colors hover:border-border-strong">
|
|
56
|
+
Item one
|
|
57
|
+
</div>
|
|
58
|
+
</Link>
|
|
59
|
+
),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Another site or app: a new tab, and `noopener noreferrer`. It looks the same; the difference is where it opens. */
|
|
63
|
+
export const External: Story = {
|
|
64
|
+
args: { external: true, children: 'a page elsewhere' },
|
|
65
|
+
render: (args) => (
|
|
66
|
+
<p className="max-w-[480px] text-body-2 text-text-primary">
|
|
67
|
+
Read <Link {...args} /> in a new tab.
|
|
68
|
+
</p>
|
|
69
|
+
),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The four looks, each where it belongs. */
|
|
73
|
+
export const AllVariants: Story = {
|
|
74
|
+
parameters: { controls: { disable: true } },
|
|
75
|
+
render: () => (
|
|
76
|
+
<div className="flex max-w-[480px] flex-col gap-4">
|
|
77
|
+
<p className="text-body-2 text-text-primary">
|
|
78
|
+
<span className="text-text-secondary">text — </span>a sentence with <Link href="#">a link</Link> in it.
|
|
79
|
+
</p>
|
|
80
|
+
<p className="text-body-2 font-semibold text-text-primary">
|
|
81
|
+
<span className="font-normal text-text-secondary">quiet — </span>
|
|
82
|
+
<Link href="#" variant="quiet">
|
|
83
|
+
Item one
|
|
84
|
+
</Link>
|
|
85
|
+
</p>
|
|
86
|
+
<p className="text-caption text-text-secondary">
|
|
87
|
+
underlined — This can’t be shown here.{' '}
|
|
88
|
+
<Link href="#" variant="underlined" external>
|
|
89
|
+
Open it there ↗
|
|
90
|
+
</Link>
|
|
91
|
+
</p>
|
|
92
|
+
<div className="flex flex-col gap-1">
|
|
93
|
+
<span className="text-body-2 text-text-secondary">plain —</span>
|
|
94
|
+
<Link href="#" variant="plain" className="block w-[280px]">
|
|
95
|
+
<div className="rounded-lg border border-border-default bg-bg-surface p-3 text-body-2 text-text-primary transition-colors hover:border-border-strong">
|
|
96
|
+
Item one
|
|
97
|
+
</div>
|
|
98
|
+
</Link>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
),
|
|
102
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the Link page claims, pinned: it is a real anchor with a real address;
|
|
4
|
+
* `external` opens a new tab that cannot reach back; a router app takes the
|
|
5
|
+
* click through `onClick` and the address survives it; each look is its own
|
|
6
|
+
* class list and `plain` carries none; a ref reaches the anchor.
|
|
7
|
+
*/
|
|
8
|
+
import { createRef } from 'react'
|
|
9
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
10
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
11
|
+
import userEvent from '@testing-library/user-event'
|
|
12
|
+
import { Link } from './Link'
|
|
13
|
+
|
|
14
|
+
afterEach(cleanup)
|
|
15
|
+
|
|
16
|
+
const classesOf = (el: Element) => el.getAttribute('class')?.split(' ').filter(Boolean) ?? []
|
|
17
|
+
|
|
18
|
+
describe('Link', () => {
|
|
19
|
+
it('is an anchor with its address', () => {
|
|
20
|
+
render(<Link href="/documents">Documents</Link>)
|
|
21
|
+
const link = screen.getByRole('link', { name: 'Documents' })
|
|
22
|
+
expect(link.tagName).toBe('A')
|
|
23
|
+
expect(link.getAttribute('href')).toBe('/documents')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('stays in the same tab unless it is external', () => {
|
|
27
|
+
render(<Link href="/documents">Documents</Link>)
|
|
28
|
+
const link = screen.getByRole('link', { name: 'Documents' })
|
|
29
|
+
expect(link.getAttribute('target')).toBeNull()
|
|
30
|
+
expect(link.getAttribute('rel')).toBeNull()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('external opens a new tab that cannot reach back into this page', () => {
|
|
34
|
+
render(
|
|
35
|
+
<Link href="https://example.com" external>
|
|
36
|
+
Example
|
|
37
|
+
</Link>,
|
|
38
|
+
)
|
|
39
|
+
const link = screen.getByRole('link', { name: 'Example' })
|
|
40
|
+
expect(link.getAttribute('target')).toBe('_blank')
|
|
41
|
+
expect(link.getAttribute('rel')).toBe('noopener noreferrer')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('a router app takes the click through onClick, and the href stays a real address', async () => {
|
|
45
|
+
const onClick = vi.fn((event: { preventDefault: () => void }) => event.preventDefault())
|
|
46
|
+
render(
|
|
47
|
+
<Link href="/documents" onClick={onClick}>
|
|
48
|
+
Documents
|
|
49
|
+
</Link>,
|
|
50
|
+
)
|
|
51
|
+
await userEvent.click(screen.getByRole('link', { name: 'Documents' }))
|
|
52
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
53
|
+
expect(screen.getByRole('link', { name: 'Documents' }).getAttribute('href')).toBe('/documents')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('text is the info colour, always underlined, and is the default', () => {
|
|
57
|
+
render(<Link href="#">Label</Link>)
|
|
58
|
+
const classes = classesOf(screen.getByRole('link'))
|
|
59
|
+
expect(classes).toContain('text-info-default')
|
|
60
|
+
expect(classes).toContain('underline')
|
|
61
|
+
expect(classes).toContain('underline-offset-2')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('quiet sets no colour and no size of its own, and underlines on hover', () => {
|
|
65
|
+
render(
|
|
66
|
+
<Link href="#" variant="quiet">
|
|
67
|
+
Label
|
|
68
|
+
</Link>,
|
|
69
|
+
)
|
|
70
|
+
expect(classesOf(screen.getByRole('link'))).toEqual(['hover:underline'])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('underlined sets no colour at rest, is always underlined, and brightens on hover', () => {
|
|
74
|
+
render(
|
|
75
|
+
<Link href="#" variant="underlined">
|
|
76
|
+
Label
|
|
77
|
+
</Link>,
|
|
78
|
+
)
|
|
79
|
+
expect(classesOf(screen.getByRole('link'))).toEqual(['underline', 'underline-offset-2', 'hover:text-text-primary'])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('plain carries no class at all, so what it wraps draws itself', () => {
|
|
83
|
+
render(
|
|
84
|
+
<Link href="#" variant="plain">
|
|
85
|
+
Label
|
|
86
|
+
</Link>,
|
|
87
|
+
)
|
|
88
|
+
expect(classesOf(screen.getByRole('link'))).toEqual([])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('a ref reaches the anchor', () => {
|
|
92
|
+
const ref = createRef<HTMLAnchorElement>()
|
|
93
|
+
render(
|
|
94
|
+
<Link href="#" ref={ref}>
|
|
95
|
+
Label
|
|
96
|
+
</Link>,
|
|
97
|
+
)
|
|
98
|
+
expect(ref.current?.tagName).toBe('A')
|
|
99
|
+
})
|
|
100
|
+
})
|
package/src/Link.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ComponentPropsWithRef, ReactNode } from 'react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A link: an anchor with one of four looks, and nothing else of its own.
|
|
6
|
+
*
|
|
7
|
+
* It exists because both apps hand-wrote every link they have — 18 raw `<a>`
|
|
8
|
+
* on 13 September 2026, in three jobs (UIG-27). The looks are the ones those
|
|
9
|
+
* links already had, reduced by Katerina's ruling:
|
|
10
|
+
*
|
|
11
|
+
* - `text` — a link inside written text: the info colour, always underlined,
|
|
12
|
+
* dimming on hover. The look a link in a body of text already had.
|
|
13
|
+
* - `quiet` — a title or a timestamp that is also a link. It takes the colour
|
|
14
|
+
* and size of the text it sits in and underlines on hover. Two looks that
|
|
15
|
+
* differed only in their text became this one (her ruling, 13 September).
|
|
16
|
+
* - `underlined` — a short "open it elsewhere" beside a note. It takes the
|
|
17
|
+
* note's colour, is always underlined, and brightens on hover. A dotted
|
|
18
|
+
* and a solid version became this one, solid (her ruling, 13 September).
|
|
19
|
+
* - `plain` — no look at all: a card or a row that is a link draws itself.
|
|
20
|
+
*
|
|
21
|
+
* **Navigation is the app's.** The package cannot know a router, so like
|
|
22
|
+
* `NavItem` it renders a real anchor and passes every anchor prop through: a
|
|
23
|
+
* router app hands in `onClick`, prevents the default and navigates, and the
|
|
24
|
+
* `href` stays a real address for a modified click or a new tab.
|
|
25
|
+
*/
|
|
26
|
+
export type LinkVariant = 'text' | 'quiet' | 'underlined' | 'plain'
|
|
27
|
+
|
|
28
|
+
const VARIANT_CLASSES: Record<LinkVariant, string> = {
|
|
29
|
+
text: 'text-info-default underline underline-offset-2 hover:opacity-80',
|
|
30
|
+
quiet: 'hover:underline',
|
|
31
|
+
underlined: 'underline underline-offset-2 hover:text-text-primary',
|
|
32
|
+
plain: '',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface LinkProps extends ComponentPropsWithRef<'a'> {
|
|
36
|
+
href: string
|
|
37
|
+
/** Default `text`. */
|
|
38
|
+
variant?: LinkVariant
|
|
39
|
+
/**
|
|
40
|
+
* Another site, or another app: opens in a new tab, with `noopener
|
|
41
|
+
* noreferrer` so the page it opens cannot reach back into this one.
|
|
42
|
+
*/
|
|
43
|
+
external?: boolean
|
|
44
|
+
children: ReactNode
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function Link({ href, variant = 'text', external = false, className, children, ...props }: LinkProps) {
|
|
48
|
+
return (
|
|
49
|
+
<a
|
|
50
|
+
href={href}
|
|
51
|
+
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
|
52
|
+
className={cn(VARIANT_CLASSES[variant], className)}
|
|
53
|
+
{...props}
|
|
54
|
+
>
|
|
55
|
+
{children}
|
|
56
|
+
</a>
|
|
57
|
+
)
|
|
58
|
+
}
|
package/src/Person.stories.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { PersonTrigger } from './PersonTrigger'
|
|
|
3
3
|
|
|
4
4
|
/** The person, as the button that opens the account menu. The menu itself stays in the app. */
|
|
5
5
|
const meta = {
|
|
6
|
-
title: '
|
|
6
|
+
title: 'Components/PersonTrigger',
|
|
7
7
|
component: PersonTrigger,
|
|
8
8
|
args: { name: 'Ana Duarte', open: false, compact: false },
|
|
9
9
|
} satisfies Meta<typeof PersonTrigger>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as ProgressBarStories from './ProgressBar.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={ProgressBarStories} />
|
|
5
|
+
|
|
6
|
+
# ProgressBar
|
|
7
|
+
|
|
8
|
+
How much of something is done: a thin rounded track with a success-coloured
|
|
9
|
+
fill.
|
|
10
|
+
|
|
11
|
+
<Canvas of={ProgressBarStories.WithWords} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- **`default`** — 6px, the success colour: a bar someone reads, with the words
|
|
16
|
+
printed beside it.
|
|
17
|
+
- **`quiet`** — 4px, the muted success colour: a glance on the same line as a
|
|
18
|
+
count.
|
|
19
|
+
|
|
20
|
+
<Canvas of={ProgressBarStories.Quiet} />
|
|
21
|
+
|
|
22
|
+
## When not
|
|
23
|
+
|
|
24
|
+
- Something still loading, with no amount to show → **Skeleton**.
|
|
25
|
+
- A state with a verdict rather than an amount → **Chip**.
|
|
26
|
+
|
|
27
|
+
## How
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
import { ProgressBar } from '@estiva-app/ui'
|
|
31
|
+
|
|
32
|
+
<ProgressBar value={done} max={total} label="Items done" />
|
|
33
|
+
<span className="text-caption text-text-muted">{done} of {total} done</span>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- `label` is required: it is the bar's accessible name. A screen reader hears
|
|
37
|
+
the name and the share as a percentage; print the counts beside the bar for
|
|
38
|
+
everyone else.
|
|
39
|
+
- A value above `max` fills the bar and no further. `max={0}` is an empty bar.
|
|
40
|
+
- It is built on Base UI's `Progress`, which owns the `progressbar` role and
|
|
41
|
+
its numbers. Do not nest it inside a button: the bar's name would become part
|
|
42
|
+
of the button's.
|
|
43
|
+
- `className` is for placement: a width, a margin, `flex-1` in a row.
|
|
44
|
+
|
|
45
|
+
## Props
|
|
46
|
+
|
|
47
|
+
<Controls of={ProgressBarStories.Default} />
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { ProgressBar } from './ProgressBar'
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Feedback/ProgressBar',
|
|
6
|
+
component: ProgressBar,
|
|
7
|
+
args: { value: 10, max: 14, label: 'Items done', variant: 'default' },
|
|
8
|
+
argTypes: { variant: { control: 'inline-radio', options: ['default', 'quiet'] } },
|
|
9
|
+
decorators: [(Story) => <div className="w-[346px]">{Story()}</div>],
|
|
10
|
+
} satisfies Meta<typeof ProgressBar>
|
|
11
|
+
|
|
12
|
+
export default meta
|
|
13
|
+
type Story = StoryObj<typeof meta>
|
|
14
|
+
|
|
15
|
+
/** 6px, the success colour: a bar someone reads. */
|
|
16
|
+
export const Default: Story = {}
|
|
17
|
+
|
|
18
|
+
/** The bar, then the words that say it. */
|
|
19
|
+
export const WithWords: Story = {
|
|
20
|
+
render: (args) => (
|
|
21
|
+
<div className="flex flex-col gap-2">
|
|
22
|
+
<ProgressBar {...args} />
|
|
23
|
+
<span className="text-caption tabular-nums text-text-muted">
|
|
24
|
+
{args.value} of {args.max} done
|
|
25
|
+
</span>
|
|
26
|
+
</div>
|
|
27
|
+
),
|
|
28
|
+
// axe color-contrast is off here until PLAN.md stage 0.10 is ruled: the words are
|
|
29
|
+
// muted caption text, 3.94:1 in signal (computed 2026-09-14; AA 4.5:1) — the same
|
|
30
|
+
// number NavItem's count is excepted for.
|
|
31
|
+
parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 4px, the muted success colour: a glance, on the same line as a count — a caption label, 12px before the bar. */
|
|
35
|
+
export const Quiet: Story = {
|
|
36
|
+
args: { variant: 'quiet', value: 3, max: 4 },
|
|
37
|
+
render: (args) => (
|
|
38
|
+
<div className="flex items-center gap-3">
|
|
39
|
+
<span className="shrink-0 text-caption text-text-secondary">
|
|
40
|
+
Items ({args.value}/{args.max})
|
|
41
|
+
</span>
|
|
42
|
+
<ProgressBar {...args} className="flex-1" />
|
|
43
|
+
</div>
|
|
44
|
+
),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const Empty: Story = { args: { value: 0, max: 14 } }
|
|
48
|
+
export const Complete: Story = { args: { value: 14, max: 14 } }
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the ProgressBar page claims, pinned: it is a named progressbar carrying
|
|
4
|
+
* its numbers; the fill is the done share of the track, capped at full; an
|
|
5
|
+
* empty set is an empty bar, not a division by zero; each look is its own
|
|
6
|
+
* class list; and the caller's className lands on the outside, for placement.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
9
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
10
|
+
import { ProgressBar } from './ProgressBar'
|
|
11
|
+
|
|
12
|
+
afterEach(cleanup)
|
|
13
|
+
|
|
14
|
+
const classesOf = (el: Element) => el.getAttribute('class')?.split(' ').filter(Boolean) ?? []
|
|
15
|
+
const fillOf = (bar: HTMLElement) => bar.firstElementChild!.firstElementChild as HTMLElement
|
|
16
|
+
|
|
17
|
+
describe('ProgressBar', () => {
|
|
18
|
+
it('is a progressbar named by its label, carrying its numbers', () => {
|
|
19
|
+
render(<ProgressBar value={10} max={14} label="Items done" />)
|
|
20
|
+
const bar = screen.getByRole('progressbar', { name: 'Items done' })
|
|
21
|
+
expect(bar.getAttribute('aria-valuenow')).toBe('10')
|
|
22
|
+
expect(bar.getAttribute('aria-valuemin')).toBe('0')
|
|
23
|
+
expect(bar.getAttribute('aria-valuemax')).toBe('14')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('fills the done share of the track', () => {
|
|
27
|
+
render(<ProgressBar value={7} max={14} label="Items done" />)
|
|
28
|
+
expect(fillOf(screen.getByRole('progressbar')).style.width).toBe('50%')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('never fills past full', () => {
|
|
32
|
+
render(<ProgressBar value={20} max={14} label="Items done" />)
|
|
33
|
+
expect(fillOf(screen.getByRole('progressbar')).style.width).toBe('100%')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('an empty set is an empty bar, and still says its max is 0', () => {
|
|
37
|
+
render(<ProgressBar value={0} max={0} label="Items done" />)
|
|
38
|
+
const bar = screen.getByRole('progressbar')
|
|
39
|
+
expect(fillOf(bar).style.width).toBe('0%')
|
|
40
|
+
expect(bar.getAttribute('aria-valuemax')).toBe('0')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('default is 6px with the success colour; quiet is 4px with the muted one', () => {
|
|
44
|
+
render(
|
|
45
|
+
<>
|
|
46
|
+
<ProgressBar value={1} max={2} label="Default" />
|
|
47
|
+
<ProgressBar value={1} max={2} label="Quiet" variant="quiet" />
|
|
48
|
+
</>,
|
|
49
|
+
)
|
|
50
|
+
const standard = screen.getByRole('progressbar', { name: 'Default' })
|
|
51
|
+
expect(classesOf(standard.firstElementChild!)).toContain('h-1.5')
|
|
52
|
+
expect(classesOf(fillOf(standard))).toContain('bg-success-default')
|
|
53
|
+
const quiet = screen.getByRole('progressbar', { name: 'Quiet' })
|
|
54
|
+
expect(classesOf(quiet.firstElementChild!)).toContain('h-1')
|
|
55
|
+
expect(classesOf(fillOf(quiet))).toContain('bg-success-muted')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("the caller's className lands on the outside", () => {
|
|
59
|
+
render(<ProgressBar value={1} max={2} label="Items done" className="flex-1" />)
|
|
60
|
+
expect(classesOf(screen.getByRole('progressbar'))).toContain('flex-1')
|
|
61
|
+
})
|
|
62
|
+
})
|