@estiva-app/ui 0.6.0 → 0.7.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/Reaction.d.ts +54 -0
- package/dist/Reaction.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +50 -23
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/Reaction.mdx +49 -0
- package/src/Reaction.stories.tsx +49 -0
- package/src/Reaction.tsx +86 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
package/src/Reaction.mdx
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as ReactionStories from './Reaction.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={ReactionStories} />
|
|
5
|
+
|
|
6
|
+
# Reaction
|
|
7
|
+
|
|
8
|
+
An emoji, how many people chose it, and whether you are one of them. A
|
|
9
|
+
24px pill that toggles — Chip's shape at a control's height.
|
|
10
|
+
|
|
11
|
+
<Canvas of={ReactionStories.Row} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- Under a message, in a row of them. `pressed` is **you are one of the
|
|
16
|
+
reactors**: the accent's muted tint and an accent edge, so a glance
|
|
17
|
+
separates "two people, one of them me" from "two people".
|
|
18
|
+
- Any emoji at all. Another app may publish one outside your set, and a
|
|
19
|
+
reaction you cannot name is still one you must draw.
|
|
20
|
+
|
|
21
|
+
<Canvas of={ReactionStories.Pressed} />
|
|
22
|
+
|
|
23
|
+
## When not
|
|
24
|
+
|
|
25
|
+
- Standing information nobody can toggle — a status, a category → **Chip**
|
|
26
|
+
(a Chip is not a control).
|
|
27
|
+
- Adding a reaction that is not there yet: that is a picker, and a strip of
|
|
28
|
+
icon actions is a toolbar of **IconButton**s.
|
|
29
|
+
- An action with a verb for a label → **Button**.
|
|
30
|
+
|
|
31
|
+
## How
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
import { Reaction } from '@estiva-app/ui'
|
|
35
|
+
|
|
36
|
+
<Reaction emoji="👍" count={2} pressed aria-label="Makes sense, 2" onClick={toggle} />
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- **`aria-label` is required.** The emoji is `aria-hidden` — a glyph read
|
|
40
|
+
aloud is noise, and its spoken name differs per screen reader — so the
|
|
41
|
+
count is the only visible text and the control would otherwise be
|
|
42
|
+
announced as "2". Pass the meaning and the count.
|
|
43
|
+
- It is a real `<button>` with `aria-pressed`, so "mine" reaches assistive
|
|
44
|
+
tech as a toggle rather than as a colour. Every native prop passes
|
|
45
|
+
through; `type` defaults to `"button"`.
|
|
46
|
+
|
|
47
|
+
## Props
|
|
48
|
+
|
|
49
|
+
<Controls of={ReactionStories.Default} />
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { fn } from 'storybook/test'
|
|
3
|
+
import { Reaction } from './Reaction'
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Components/Reaction',
|
|
7
|
+
component: Reaction,
|
|
8
|
+
args: { emoji: '👍', count: 2, 'aria-label': 'Makes sense, 2', onClick: fn() },
|
|
9
|
+
} satisfies Meta<typeof Reaction>
|
|
10
|
+
|
|
11
|
+
export default meta
|
|
12
|
+
type Story = StoryObj<typeof meta>
|
|
13
|
+
|
|
14
|
+
/** Somebody else's reaction: the inset fill and a hairline, like a neutral Chip. */
|
|
15
|
+
export const Default: Story = {}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Yours.
|
|
19
|
+
*
|
|
20
|
+
* The state a reaction has and a chip does not — the accent's muted tint and an
|
|
21
|
+
* accent edge, so a glance separates "two people, one of them me" from "two
|
|
22
|
+
* people". `aria-pressed` carries the same fact to assistive tech.
|
|
23
|
+
*/
|
|
24
|
+
export const Pressed: Story = { args: { pressed: true } }
|
|
25
|
+
|
|
26
|
+
/** A row of them, as a message carries them. */
|
|
27
|
+
export const Row: Story = {
|
|
28
|
+
render: (args) => (
|
|
29
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
30
|
+
<Reaction {...args} emoji="👍" count={3} aria-label="Makes sense, 3" pressed />
|
|
31
|
+
<Reaction {...args} emoji="🎉" count={1} aria-label="Congrats, 1" />
|
|
32
|
+
<Reaction {...args} emoji="🚀" count={12} aria-label="Let's go!, 12" />
|
|
33
|
+
</div>
|
|
34
|
+
),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A count with two digits, so the pill grows rather than clipping. */
|
|
38
|
+
export const WideCount: Story = { args: { count: 128, 'aria-label': 'Makes sense, 128' } }
|
|
39
|
+
|
|
40
|
+
/** Momentarily unavailable — signing in, or a write already in flight. */
|
|
41
|
+
export const Disabled: Story = { args: { disabled: true } }
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* An emoji from outside any curated set.
|
|
45
|
+
*
|
|
46
|
+
* Another client may publish anything, and a reaction a consumer cannot name is
|
|
47
|
+
* still one it must draw rather than hide — so nothing here validates the glyph.
|
|
48
|
+
*/
|
|
49
|
+
export const UnknownEmoji: Story = { args: { emoji: '🦆', count: 1, 'aria-label': '🦆, 1' } }
|
package/src/Reaction.tsx
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A reaction: an emoji, how many people chose it, and whether you are one of
|
|
6
|
+
* them.
|
|
7
|
+
*
|
|
8
|
+
* **This exists because both apps had already built it, and neither could use
|
|
9
|
+
* the components that were here.** A reaction is a pill with a count that
|
|
10
|
+
* toggles — `Chip` is a pill and rules itself out for anything clickable,
|
|
11
|
+
* `Button` is clickable and is a 6px-radius rectangle, and `IconButton` has
|
|
12
|
+
* nowhere to put the count. So Peek re-typed Chip's class list with a border
|
|
13
|
+
* added, and Ship reached for a small Button with the count as its label. The
|
|
14
|
+
* two apps' reactions do not look alike today, and neither is what was drawn.
|
|
15
|
+
*
|
|
16
|
+
* ## The one thing it does that nothing else here does
|
|
17
|
+
*
|
|
18
|
+
* **It says the reaction is yours.** That is the state a reaction has and a
|
|
19
|
+
* chip does not: `pressed` fills it with the accent's muted tint and gives it
|
|
20
|
+
* an accent edge, so a glance separates "two people, one of them me" from "two
|
|
21
|
+
* people". Ship approximated it as `outlined` versus `muted` — measured at a
|
|
22
|
+
* 1px hairline against no border at all — which is legible and is not the
|
|
23
|
+
* signal Peek's accent fill gives.
|
|
24
|
+
*
|
|
25
|
+
* It is a real `<button>` with `aria-pressed`, so the state reaches assistive
|
|
26
|
+
* tech as a toggle rather than as a colour.
|
|
27
|
+
*
|
|
28
|
+
* ## `aria-label` is required, and the reason is specific
|
|
29
|
+
*
|
|
30
|
+
* The emoji is decorative here — it is `aria-hidden`, because a glyph read
|
|
31
|
+
* aloud is noise and its spoken name differs per screen reader — and the only
|
|
32
|
+
* visible text is the count. Without a label the control is announced as
|
|
33
|
+
* **"2"**, which is what Ship shipped and its own tests caught. Pass the
|
|
34
|
+
* meaning and the count: `"Makes sense, 2"`.
|
|
35
|
+
*
|
|
36
|
+
* ## Geometry
|
|
37
|
+
*
|
|
38
|
+
* Chip's pill, at a control's height: fully rounded, 24px to match `Button`
|
|
39
|
+
* `small`, the same 8px horizontal padding, the `chip` type token for the
|
|
40
|
+
* count so it sits at 11px/500 like every other count in the system.
|
|
41
|
+
*/
|
|
42
|
+
export interface ReactionProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
|
43
|
+
/** The emoji, drawn decoratively — name the control with `aria-label`. */
|
|
44
|
+
emoji: ReactNode
|
|
45
|
+
/** How many people reacted. Drawn as-is; `0` is not a reaction and is not drawn. */
|
|
46
|
+
count: number
|
|
47
|
+
/** You are one of them: the accent tint and edge, and `aria-pressed`. */
|
|
48
|
+
pressed?: boolean
|
|
49
|
+
/**
|
|
50
|
+
* Names the control. **Required** — the emoji is decorative and the count is
|
|
51
|
+
* the only visible text, so without this it is announced as a bare number.
|
|
52
|
+
*/
|
|
53
|
+
'aria-label': string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function Reaction({ emoji, count, pressed = false, className, type, ...props }: ReactionProps) {
|
|
57
|
+
return (
|
|
58
|
+
<button
|
|
59
|
+
type={type ?? 'button'}
|
|
60
|
+
aria-pressed={pressed}
|
|
61
|
+
className={cn(
|
|
62
|
+
// Chip's pill at a control's height, so a reaction and a status chip
|
|
63
|
+
// read as the same family — 24px matches Button `small`.
|
|
64
|
+
'inline-flex h-6 items-center justify-center gap-1.5 rounded-full px-2',
|
|
65
|
+
'border transition-colors',
|
|
66
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
67
|
+
pressed
|
|
68
|
+
? 'border-accent-primary bg-accent-muted text-accent-primary hover:border-accent-hover'
|
|
69
|
+
: 'border-border-default bg-bg-inset text-text-primary hover:border-border-strong hover:bg-bg-hover',
|
|
70
|
+
className,
|
|
71
|
+
)}
|
|
72
|
+
{...props}
|
|
73
|
+
>
|
|
74
|
+
{/* Decorative: the control is named by `aria-label`, and a glyph read
|
|
75
|
+
aloud is noise. 16px so the emoji is legible at chip scale. */}
|
|
76
|
+
<span aria-hidden="true" className="shrink-0 text-[16px] leading-none">
|
|
77
|
+
{emoji}
|
|
78
|
+
</span>
|
|
79
|
+
{/* `text-chip` is a plain class, never merged — the same guard Chip uses,
|
|
80
|
+
because tailwind-merge drops a token size that follows a text colour. */}
|
|
81
|
+
<span className="text-chip signal:font-mono signal:text-[10px] signal:font-semibold signal:tabular-nums">
|
|
82
|
+
{count}
|
|
83
|
+
</span>
|
|
84
|
+
</button>
|
|
85
|
+
)
|
|
86
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -42,6 +42,7 @@ export { Sidebar, type SidebarProps } from './Sidebar'
|
|
|
42
42
|
export { TopBar, type TopBarProps } from './TopBar'
|
|
43
43
|
export { PersonTrigger, type PersonTriggerProps } from './PersonTrigger'
|
|
44
44
|
export { Property, type PropertyProps } from './Property'
|
|
45
|
+
export { Reaction, type ReactionProps } from './Reaction'
|
|
45
46
|
export { SearchInput, type SearchInputProps } from './SearchInput'
|
|
46
47
|
export { SectionHeader, type SectionAction, type SectionHeaderProps } from './SectionHeader'
|
|
47
48
|
export { SectionLabel } from './SectionLabel'
|