@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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' } }
@@ -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'