@estiva-app/ui 0.12.9 → 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/Menu.d.ts +12 -2
- package/dist/Menu.d.ts.map +1 -1
- package/dist/Popover.d.ts +17 -2
- package/dist/Popover.d.ts.map +1 -1
- 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 +697 -358
- 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/Menu.test.tsx +20 -0
- package/src/Menu.tsx +17 -5
- package/src/Person.stories.tsx +1 -1
- package/src/PersonTrigger.stories.tsx +1 -1
- package/src/Popover.mdx +4 -2
- package/src/Popover.stories.tsx +11 -9
- package/src/Popover.test.tsx +38 -0
- package/src/Popover.tsx +20 -4
- 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.mdx +1 -1
- package/src/ReactionPicker.stories.tsx +3 -2
- package/src/Toolbar.stories.tsx +2 -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/Menu.test.tsx
CHANGED
|
@@ -326,3 +326,23 @@ describe('rows on a bare MenuPanel', () => {
|
|
|
326
326
|
expect(screen.queryByRole('group')).toBeNull()
|
|
327
327
|
})
|
|
328
328
|
})
|
|
329
|
+
|
|
330
|
+
/** As `Popover`: the padding is the content's, and a caller sets it there
|
|
331
|
+
* (PLAN Finding 60 — Peek's Later menu asked for `p-1` on `className` and got
|
|
332
|
+
* 12px from 0.12.6 on). */
|
|
333
|
+
describe('Menu, padding', () => {
|
|
334
|
+
it('takes a caller’s padding on contentClassName, instead of the 8px', async () => {
|
|
335
|
+
render(
|
|
336
|
+
<Menu trigger={<Button>Open</Button>} contentClassName="p-1">
|
|
337
|
+
<MenuItem label="Item one" onClick={() => {}} />
|
|
338
|
+
</Menu>,
|
|
339
|
+
)
|
|
340
|
+
await userEvent.click(screen.getByRole('button', { name: 'Open' }))
|
|
341
|
+
const row = await screen.findByRole('menuitem', { name: 'Item one' })
|
|
342
|
+
// The scrolling content is the box that carries the separator rule.
|
|
343
|
+
const content = row.closest('[class*="role=separator"]') as HTMLElement
|
|
344
|
+
const classes = content.className.split(/\s+/)
|
|
345
|
+
expect(classes).toContain('p-1')
|
|
346
|
+
expect(classes).not.toContain('p-2')
|
|
347
|
+
})
|
|
348
|
+
})
|
package/src/Menu.tsx
CHANGED
|
@@ -142,8 +142,18 @@ export interface MenuProps {
|
|
|
142
142
|
* closes the menu by itself, so this is only for content that is not one. */
|
|
143
143
|
actionsRef?: RefObject<{ close: () => void; unmount: () => void } | null>
|
|
144
144
|
children: ReactNode
|
|
145
|
-
/** On the menu's surface — its width
|
|
145
|
+
/** On the menu's surface — its width. **Not its padding**: see
|
|
146
|
+
* `contentClassName`. */
|
|
146
147
|
className?: string
|
|
148
|
+
/**
|
|
149
|
+
* The padding around the rows, as a class. Default `p-2`, 8px.
|
|
150
|
+
*
|
|
151
|
+
* It is on the scrolling content, not on the panel, so the scrollbar hugs the
|
|
152
|
+
* panel's edge (D63) — and so a padding class on `className` adds to it
|
|
153
|
+
* rather than replacing it. Peek's Later menu asked for `p-1` there and got
|
|
154
|
+
* 12px from `0.12.6` on (PLAN Finding 60). Set it here.
|
|
155
|
+
*/
|
|
156
|
+
contentClassName?: string
|
|
147
157
|
}
|
|
148
158
|
|
|
149
159
|
/**
|
|
@@ -163,7 +173,7 @@ const VIEWPORT_PAD = 8
|
|
|
163
173
|
const HOVER_OPEN_DELAY = 0
|
|
164
174
|
const HOVER_CLOSE_DELAY = 150
|
|
165
175
|
|
|
166
|
-
export function Menu({ trigger, align = 'left', openOnHover = false, open, onOpenChange, actionsRef, children, className }: MenuProps) {
|
|
176
|
+
export function Menu({ trigger, align = 'left', openOnHover = false, open, onOpenChange, actionsRef, children, className, contentClassName }: MenuProps) {
|
|
167
177
|
return (
|
|
168
178
|
<BaseMenu.Root
|
|
169
179
|
open={open}
|
|
@@ -227,9 +237,11 @@ export function Menu({ trigger, align = 'left', openOnHover = false, open, onOpe
|
|
|
227
237
|
* as tall as Floating UI allowed.
|
|
228
238
|
*
|
|
229
239
|
* A caller's `className` still lands on the panel, so a caller
|
|
230
|
-
* asking for different padding needs `contentClassName
|
|
231
|
-
*
|
|
232
|
-
|
|
240
|
+
* asking for different padding needs `contentClassName`. That
|
|
241
|
+
* sentence was written at 0.12.6 and the prop was not: a `p-1` on
|
|
242
|
+
* `className` added 4px to these 8px instead of replacing them
|
|
243
|
+
* (PLAN Finding 60). The prop exists now. */}
|
|
244
|
+
<ScrollArea viewportClassName="max-h-[var(--available-height)]" contentClassName={cn('flex flex-col p-2 [&>[role=separator]]:mx-0', contentClassName)}>
|
|
233
245
|
<MenuContext.Provider value={{ openOnHover }}>{children}</MenuContext.Provider>
|
|
234
246
|
</ScrollArea>
|
|
235
247
|
</BaseMenu.Popup>
|
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>
|
package/src/Popover.mdx
CHANGED
|
@@ -64,8 +64,10 @@ import { Popover } from '@estiva-app/ui'
|
|
|
64
64
|
force it. Leave them off and the panel keeps its own state.
|
|
65
65
|
- `ariaLabel` names the panel. A panel with a visible heading can point at it
|
|
66
66
|
with `aria-labelledby` instead.
|
|
67
|
-
- Width
|
|
68
|
-
|
|
67
|
+
- **Width goes on `className`, padding on `contentClassName`.** The padding
|
|
68
|
+
sits on the scrolling content so the scrollbar hugs the panel's edge, and a
|
|
69
|
+
padding class on `className` adds to it rather than replacing it. Default
|
|
70
|
+
8px; **a toolbar asks for `contentClassName="p-1"`**.
|
|
69
71
|
|
|
70
72
|
<Canvas of={PopoverStories.FromATrigger} />
|
|
71
73
|
|
package/src/Popover.stories.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
2
|
import { IconBold, IconItalic, IconLink } from '@tabler/icons-react'
|
|
3
|
-
import { useRef, useState, type KeyboardEvent
|
|
3
|
+
import { useRef, useState, type KeyboardEvent } from 'react'
|
|
4
4
|
import { Button } from './Button'
|
|
5
5
|
import { MenuPanel } from './Menu'
|
|
6
6
|
import { Popover } from './Popover'
|
|
@@ -105,7 +105,8 @@ export const AToolbar: Story = {
|
|
|
105
105
|
when there is no room, which is the reason the placement is its job
|
|
106
106
|
and not ours. */
|
|
107
107
|
side="top"
|
|
108
|
-
className="w-auto min-w-0
|
|
108
|
+
className="w-auto min-w-0"
|
|
109
|
+
contentClassName="p-1"
|
|
109
110
|
>
|
|
110
111
|
{/* The strip is a `Toolbar`, so the whole row is ONE Tab stop and the
|
|
111
112
|
arrow keys walk it — four stops before, one after. */}
|
|
@@ -234,17 +235,18 @@ export const Capped: Story = {
|
|
|
234
235
|
parameters: { controls: { disable: true }, layout: 'fullscreen' },
|
|
235
236
|
render: function Capped() {
|
|
236
237
|
/* Anchored and open, so the cap is the thing you see rather than a button
|
|
237
|
-
you have to press first.
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
238
|
+
you have to press first. Anchored on the ELEMENT, held in state from a
|
|
239
|
+
callback ref: an element is re-measured. It used to be a rect read once
|
|
240
|
+
in the ref, and arriving at this story from another one read it before
|
|
241
|
+
the canvas was laid out — 0 × 0 at the corner, a 0px panel, only the
|
|
242
|
+
line of text showing (PLAN Finding 61). */
|
|
243
|
+
const [marker, setMarker] = useState<HTMLDivElement | null>(null)
|
|
242
244
|
return (
|
|
243
245
|
<div className="flex h-[420px] w-full items-center justify-center">
|
|
244
|
-
<div ref={
|
|
246
|
+
<div ref={setMarker} className="text-body-2 text-text-secondary">
|
|
245
247
|
twenty rows, capped at 160px
|
|
246
248
|
</div>
|
|
247
|
-
<Popover anchor={
|
|
249
|
+
<Popover anchor={marker} open ariaLabel="A long panel" className="w-[240px]" maxHeight="max-h-[160px]">
|
|
248
250
|
{Array.from({ length: 20 }, (_, i) => (
|
|
249
251
|
<span key={i} className="text-body-2 text-text-primary py-1">
|
|
250
252
|
Row {i + 1}
|