@estiva-app/ui 0.5.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.
@@ -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
+ }
@@ -15,7 +15,7 @@ optional keyboard hint at the right edge.
15
15
  - Filtering a list, live, above the list it filters.
16
16
  - As a **launcher affordance**: keep the input `pointer-events-none` and
17
17
  open your command surface from a click on the surround — the component
18
- is the same either way, and `shortcut` shows the way in ("K").
18
+ is the same either way, and `shortcut` shows the way in ("Ctrl+K").
19
19
 
20
20
  ## When not
21
21
 
@@ -27,7 +27,7 @@ optional keyboard hint at the right edge.
27
27
  ```tsx
28
28
  import { SearchInput } from '@estiva-app/ui'
29
29
 
30
- <SearchInput value={query} onChange={(e) => setQuery(e.target.value)} shortcut="K" />
30
+ <SearchInput value={query} onChange={(e) => setQuery(e.target.value)} shortcut="Ctrl+K" />
31
31
  ```
32
32
 
33
33
  - The default placeholder is "Search…" — override it with your own.
@@ -15,10 +15,10 @@ export const Default: Story = {}
15
15
 
16
16
  /** The keyboard hint at the right edge. */
17
17
  export const WithShortcut: Story = {
18
- args: { shortcut: 'K' },
18
+ args: { shortcut: 'Ctrl+K' },
19
19
  }
20
20
 
21
21
  /** The default placeholder says only "Search…" — the app names what is searched. */
22
22
  export const OwnPlaceholder: Story = {
23
- args: { placeholder: 'Search documents…', shortcut: 'K' },
23
+ args: { placeholder: 'Search documents…', shortcut: 'Ctrl+K' },
24
24
  }
@@ -1,5 +1,6 @@
1
1
  import { type InputHTMLAttributes } from 'react'
2
2
  import { cn } from './cn'
3
+ import { Kbd } from './Kbd'
3
4
 
4
5
  /**
5
6
  * Peek's SearchInput (2026-09-01), verbatim: an inset field with a hairline
@@ -15,7 +16,7 @@ import { cn } from './cn'
15
16
  * command launcher. The component is the same either way.
16
17
  */
17
18
  export interface SearchInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'className'> {
18
- /** A keyboard hint drawn at the right edge, e.g. "K". */
19
+ /** A keyboard hint drawn at the right edge, e.g. "Ctrl+K". */
19
20
  shortcut?: string
20
21
  className?: string
21
22
  }
@@ -35,11 +36,7 @@ export function SearchInput({ shortcut, className, placeholder = 'Search…', ..
35
36
  placeholder={placeholder}
36
37
  {...props}
37
38
  />
38
- {shortcut && (
39
- <div className="flex items-center justify-center px-1 py-px rounded-sm bg-bg-inset border border-border-strong shrink-0 signal:bg-[rgba(255,255,255,.05)] signal:border-b-2">
40
- <span className="text-caption text-text-secondary whitespace-nowrap signal:font-mono signal:text-[10px]">{shortcut}</span>
41
- </div>
42
- )}
39
+ {shortcut && <Kbd>{shortcut}</Kbd>}
43
40
  </div>
44
41
  )
45
42
  }
package/src/Tooltip.mdx CHANGED
@@ -19,6 +19,11 @@ viewport, kept 8px inside its edges.
19
19
  - The word for an icon-only control — alongside its `aria-label`, never
20
20
  instead of it (IconButton wires this for you via its `tooltip` prop).
21
21
  - The full text behind a truncation.
22
+ - **The key that does the same thing** — pass `shortcut` and it is drawn as
23
+ the `Kbd` chip after the label. For an icon-only control whose only other
24
+ affordance is a keyboard shortcut, the tooltip is the only place to say so.
25
+
26
+ <Canvas of={TooltipStories.ShortcutComparison} />
22
27
 
23
28
  <Canvas of={TooltipStories.OnADisabledControl} />
24
29
 
@@ -45,6 +50,10 @@ import { WithTooltip } from '@estiva-app/ui'
45
50
  `wrapperClassName="min-w-0 shrink"`.
46
51
  - It shows on **hover only** — there is no focus or touch trigger. Don't
47
52
  put anything behind it that a keyboard user must reach.
53
+ - `shortcut` renders, it does not format. A modifier is called Cmd on Apple
54
+ platforms and Ctrl elsewhere, and only the caller knows which it is
55
+ looking at — so pass the finished label. `IconButton` forwards its own
56
+ `tooltipShortcut` here.
48
57
 
49
58
  ## Props
50
59
 
@@ -14,6 +14,21 @@ type Story = StoryObj<typeof meta>
14
14
  /** The static tooltip surface. */
15
15
  export const Default: Story = {}
16
16
 
17
+ /** With a key hint — drawn as the `Kbd` chip after the label. */
18
+ export const WithShortcut: Story = { args: { label: 'Bold', shortcut: 'Cmd+B' } }
19
+
20
+ /** With and without, so the difference is one glance. */
21
+ export const ShortcutComparison: Story = {
22
+ parameters: { controls: { disable: true } },
23
+ render: () => (
24
+ <div className="flex flex-col items-start gap-2">
25
+ <Tooltip label="Comment" />
26
+ <Tooltip label="Bold" shortcut="Cmd+B" />
27
+ <Tooltip label="Heading" shortcut="Ctrl+Alt+1" />
28
+ </div>
29
+ ),
30
+ }
31
+
17
32
  /** Hover the button — WithTooltip portals the tooltip above the trigger. */
18
33
  export const OnHoverTop: Story = {
19
34
  parameters: { controls: { disable: true } },
package/src/Tooltip.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { cn } from './cn'
4
+ import { Kbd } from './Kbd'
4
5
 
5
6
  /**
6
7
  * Peek's Tooltip and WithTooltip (2026-08-28), verbatim, in one file.
@@ -13,19 +14,26 @@ import { cn } from './cn'
13
14
  */
14
15
  export interface TooltipProps {
15
16
  label: string
17
+ /** The key that does the same thing, drawn as the `Kbd` chip after the label.
18
+ * Pass it already formatted for the platform — this renders, it does not
19
+ * decide whether the modifier is a glyph or a word. */
20
+ shortcut?: string
16
21
  className?: string
17
22
  }
18
23
 
19
- export function Tooltip({ label, className }: TooltipProps) {
24
+ export function Tooltip({ label, shortcut, className }: TooltipProps) {
20
25
  return (
21
- <div role="tooltip" className={cn('bg-bg-elevated border border-border-default rounded-lg h-[30px] flex items-center justify-center px-2 shadow-lg', className)}>
26
+ <div role="tooltip" className={cn('bg-bg-elevated border border-border-default rounded-lg h-[30px] flex items-center justify-center gap-1.5 px-2 shadow-lg', className)}>
22
27
  <span className="text-caption text-text-primary whitespace-nowrap">{label}</span>
28
+ {shortcut && <Kbd>{shortcut}</Kbd>}
23
29
  </div>
24
30
  )
25
31
  }
26
32
 
27
33
  export interface WithTooltipProps {
28
34
  label: string
35
+ /** Passed straight to the surface — see `TooltipProps.shortcut`. */
36
+ shortcut?: string
29
37
  placement?: 'top' | 'bottom'
30
38
  /** Extra classes on the wrapper — e.g. `min-w-0 shrink` so a truncating label keeps truncating inside it. */
31
39
  wrapperClassName?: string
@@ -35,7 +43,7 @@ export interface WithTooltipProps {
35
43
  const GAP = 6
36
44
  const VIEWPORT_PAD = 8
37
45
 
38
- export function WithTooltip({ label, placement = 'top', wrapperClassName, children }: WithTooltipProps) {
46
+ export function WithTooltip({ label, shortcut, placement = 'top', wrapperClassName, children }: WithTooltipProps) {
39
47
  const [show, setShow] = useState(false)
40
48
  const ref = useRef<HTMLDivElement>(null)
41
49
  const tooltipRef = useRef<HTMLDivElement>(null)
@@ -69,7 +77,7 @@ export function WithTooltip({ label, placement = 'top', wrapperClassName, childr
69
77
  {show &&
70
78
  createPortal(
71
79
  <div ref={tooltipRef} style={style}>
72
- <Tooltip label={label} />
80
+ <Tooltip label={label} shortcut={shortcut} />
73
81
  </div>,
74
82
  document.body,
75
83
  )}
package/src/TopBar.mdx CHANGED
@@ -44,7 +44,7 @@ import { TopBar, IdentityMenu, SearchInput } from '@estiva-app/ui'
44
44
 
45
45
  <TopBar
46
46
  logo="Estiva"
47
- search={<SearchInput shortcut="K" className="w-[290px]" />}
47
+ search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />}
48
48
  right={<IdentityMenu me={me} signedIn={signedIn} />}
49
49
  />
50
50
  ```
@@ -54,7 +54,7 @@ const Behind = ({ children }: { children: ReactNode }) => (
54
54
  export const Solid: Story = {
55
55
  render: (args) => (
56
56
  <Below>
57
- <TopBar {...args} logo="Estiva" search={<SearchInput shortcut="K" className="w-[290px]" />} right={face} />
57
+ <TopBar {...args} logo="Estiva" search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />} right={face} />
58
58
  </Below>
59
59
  ),
60
60
  }
@@ -72,7 +72,7 @@ export const SolidNoSearch: Story = {
72
72
  export const Floating: Story = {
73
73
  render: (args) => (
74
74
  <Behind>
75
- <TopBar {...args} variant="floating" menu={menuButton} search={<SearchInput shortcut="K" className="w-[290px]" />} right={face} />
75
+ <TopBar {...args} variant="floating" menu={menuButton} search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />} right={face} />
76
76
  </Behind>
77
77
  ),
78
78
  }
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ export { Checkbox, type CheckboxProps } from './Checkbox'
18
18
  export { Chip, type ChipProps, type ChipType } from './Chip'
19
19
  export { ChipInput, InputChip, type ChipInputOption, type ChipInputProps, type InputChipProps } from './ChipInput'
20
20
  export { IconButton, type IconButtonProps, type IconButtonVariant } from './IconButton'
21
+ export { Kbd, type KbdProps } from './Kbd'
21
22
  export { IdentityMenu, IdentityPanel, type Identity, type IdentityMenuProps, type IdentityPanelProps } from './IdentityMenu'
22
23
  export { Tooltip, WithTooltip, type TooltipProps, type WithTooltipProps } from './Tooltip'
23
24
  export { Field, useFieldControlId, type FieldProps } from './Field'
@@ -31,7 +32,7 @@ export { EditableText, type EditableTextProps } from './EditableText'
31
32
  export { EmptyState, type EmptyStateProps } from './EmptyState'
32
33
  export { SkeletonBar, SkeletonList, SkeletonRow } from './Skeleton'
33
34
  export { Breadcrumb, type BreadcrumbProps, type Crumb } from './Breadcrumb'
34
- export { EnterHint, Menu, MenuItem, MenuRow, MenuSection, MenuSub, type MenuItemProps, type MenuProps, type MenuSubProps } from './Menu'
35
+ export { EnterHint, Menu, MenuItem, MenuPanel, MenuRow, MenuSection, MenuSub, type MenuItemProps, type MenuPanelProps, type MenuProps, type MenuSubProps } from './Menu'
35
36
  export { clampBox, fitMenu, fitSubmenu } from './fit'
36
37
  export { NavItem, type NavItemProps } from './NavItem'
37
38
  export { Person, type PersonProps } from './Person'
@@ -41,6 +42,7 @@ export { Sidebar, type SidebarProps } from './Sidebar'
41
42
  export { TopBar, type TopBarProps } from './TopBar'
42
43
  export { PersonTrigger, type PersonTriggerProps } from './PersonTrigger'
43
44
  export { Property, type PropertyProps } from './Property'
45
+ export { Reaction, type ReactionProps } from './Reaction'
44
46
  export { SearchInput, type SearchInputProps } from './SearchInput'
45
47
  export { SectionHeader, type SectionAction, type SectionHeaderProps } from './SectionHeader'
46
48
  export { SectionLabel } from './SectionLabel'
@@ -63,7 +63,15 @@ export const estivaContent = [own('./dist/*.js'), own('./src/*.{ts,tsx}')]
63
63
  export default {
64
64
 
65
65
  darkMode: 'class',
66
- plugins: [plugin(({ addVariant }) => addVariant('signal', '.signal &'))],
66
+ // `signal` is Peek's shipped theme (a class); `ship` is Ship's (an attribute,
67
+ // see tokens.css). Both exist so a treatment can be given to the two apps
68
+ // without changing the plain light/dark themes the docs render in.
69
+ plugins: [
70
+ plugin(({ addVariant }) => {
71
+ addVariant('signal', '.signal &')
72
+ addVariant('ship', "[data-theme='ship'] &")
73
+ }),
74
+ ],
67
75
  theme: {
68
76
  extend: {
69
77
  fontFamily: {