@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.
- package/dist/Divider.d.ts.map +1 -1
- package/dist/IconButton.d.ts +4 -1
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/Kbd.d.ts +27 -0
- package/dist/Kbd.d.ts.map +1 -0
- package/dist/Menu.d.ts +56 -5
- package/dist/Menu.d.ts.map +1 -1
- package/dist/Reaction.d.ts +54 -0
- package/dist/Reaction.d.ts.map +1 -0
- package/dist/SearchInput.d.ts +1 -1
- package/dist/SearchInput.d.ts.map +1 -1
- package/dist/Tooltip.d.ts +8 -2
- package/dist/Tooltip.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +228 -147
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/AppShell.stories.tsx +2 -2
- package/src/Divider.tsx +5 -1
- package/src/IconButton.tsx +5 -1
- package/src/Kbd.mdx +77 -0
- package/src/Kbd.stories.tsx +58 -0
- package/src/Kbd.tsx +42 -0
- package/src/Menu.mdx +8 -1
- package/src/Menu.stories.tsx +1 -1
- package/src/Menu.tsx +112 -18
- package/src/MenuItem.mdx +1 -1
- package/src/MenuItem.stories.tsx +5 -5
- package/src/Reaction.mdx +49 -0
- package/src/Reaction.stories.tsx +49 -0
- package/src/Reaction.tsx +86 -0
- package/src/SearchInput.mdx +2 -2
- package/src/SearchInput.stories.tsx +2 -2
- package/src/SearchInput.tsx +3 -6
- package/src/Tooltip.mdx +9 -0
- package/src/Tooltip.stories.tsx +15 -0
- package/src/Tooltip.tsx +12 -4
- package/src/TopBar.mdx +1 -1
- package/src/TopBar.stories.tsx +2 -2
- package/src/index.ts +3 -1
- package/tailwind-preset.js +9 -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/SearchInput.mdx
CHANGED
|
@@ -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 ("
|
|
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="
|
|
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: '
|
|
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: '
|
|
23
|
+
args: { placeholder: 'Search documents…', shortcut: 'Ctrl+K' },
|
|
24
24
|
}
|
package/src/SearchInput.tsx
CHANGED
|
@@ -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. "
|
|
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
|
|
package/src/Tooltip.stories.tsx
CHANGED
|
@@ -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="
|
|
47
|
+
search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />}
|
|
48
48
|
right={<IdentityMenu me={me} signedIn={signedIn} />}
|
|
49
49
|
/>
|
|
50
50
|
```
|
package/src/TopBar.stories.tsx
CHANGED
|
@@ -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="
|
|
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="
|
|
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'
|
package/tailwind-preset.js
CHANGED
|
@@ -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
|
-
|
|
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: {
|