@estiva-app/ui 0.1.0 → 0.3.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/README.md +12 -9
- package/base.css +43 -0
- package/dist/AppShell.d.ts +39 -0
- package/dist/AppShell.d.ts.map +1 -0
- package/dist/Avatar.d.ts +7 -2
- package/dist/Avatar.d.ts.map +1 -1
- package/dist/AvatarGroup.d.ts +23 -0
- package/dist/AvatarGroup.d.ts.map +1 -0
- package/dist/Banner.d.ts +19 -0
- package/dist/Banner.d.ts.map +1 -0
- package/dist/Breadcrumb.d.ts +28 -0
- package/dist/Breadcrumb.d.ts.map +1 -0
- package/dist/Checkbox.d.ts +18 -0
- package/dist/Checkbox.d.ts.map +1 -0
- package/dist/ChipInput.d.ts +57 -0
- package/dist/ChipInput.d.ts.map +1 -0
- package/dist/ConfirmDialog.d.ts +22 -0
- package/dist/ConfirmDialog.d.ts.map +1 -0
- package/dist/DialogShell.d.ts +9 -2
- package/dist/DialogShell.d.ts.map +1 -1
- package/dist/EditableText.d.ts +56 -0
- package/dist/EditableText.d.ts.map +1 -0
- package/dist/IdentityMenu.d.ts +58 -0
- package/dist/IdentityMenu.d.ts.map +1 -0
- package/dist/Menu.d.ts +82 -0
- package/dist/Menu.d.ts.map +1 -0
- package/dist/NavItem.d.ts +26 -0
- package/dist/NavItem.d.ts.map +1 -0
- package/dist/Person.d.ts +26 -0
- package/dist/Person.d.ts.map +1 -0
- package/dist/PersonTrigger.d.ts +23 -0
- package/dist/PersonTrigger.d.ts.map +1 -0
- package/dist/Property.d.ts +24 -0
- package/dist/Property.d.ts.map +1 -0
- package/dist/Rail.d.ts +18 -0
- package/dist/Rail.d.ts.map +1 -0
- package/dist/RailItem.d.ts +23 -0
- package/dist/RailItem.d.ts.map +1 -0
- package/dist/SearchInput.d.ts +21 -0
- package/dist/SearchInput.d.ts.map +1 -0
- package/dist/SectionHeader.d.ts +34 -0
- package/dist/SectionHeader.d.ts.map +1 -0
- package/dist/SectionLabel.d.ts +19 -0
- package/dist/SectionLabel.d.ts.map +1 -0
- package/dist/Select.d.ts +42 -5
- package/dist/Select.d.ts.map +1 -1
- package/dist/Sidebar.d.ts +18 -0
- package/dist/Sidebar.d.ts.map +1 -0
- package/dist/Tabs.d.ts +34 -0
- package/dist/Tabs.d.ts.map +1 -0
- package/dist/Toast.d.ts +48 -0
- package/dist/Toast.d.ts.map +1 -0
- package/dist/Tooltip.d.ts +3 -1
- package/dist/Tooltip.d.ts.map +1 -1
- package/dist/TopBar.d.ts +33 -0
- package/dist/TopBar.d.ts.map +1 -0
- package/dist/cn.d.ts +14 -4
- package/dist/cn.d.ts.map +1 -1
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1254 -106
- package/dist/index.js.map +4 -4
- package/package.json +5 -2
- package/src/AppShell.mdx +66 -0
- package/src/AppShell.stories.tsx +117 -0
- package/src/AppShell.tsx +77 -0
- package/src/Avatar.initials.test.ts +21 -0
- package/src/Avatar.mdx +49 -0
- package/src/Avatar.tsx +12 -9
- package/src/AvatarGroup.mdx +39 -0
- package/src/AvatarGroup.stories.tsx +56 -0
- package/src/AvatarGroup.tsx +42 -0
- package/src/Banner.mdx +45 -0
- package/src/Banner.stories.tsx +35 -0
- package/src/Banner.tsx +35 -0
- package/src/Breadcrumb.mdx +52 -0
- package/src/Breadcrumb.stories.tsx +42 -0
- package/src/Breadcrumb.tsx +106 -0
- package/src/Button.mdx +57 -0
- package/src/Checkbox.mdx +44 -0
- package/src/Checkbox.stories.tsx +49 -0
- package/src/Checkbox.tsx +46 -0
- package/src/Chip.mdx +43 -0
- package/src/ChipInput.mdx +68 -0
- package/src/ChipInput.stories.tsx +99 -0
- package/src/ChipInput.tsx +247 -0
- package/src/ConfirmDialog.mdx +51 -0
- package/src/ConfirmDialog.stories.tsx +63 -0
- package/src/ConfirmDialog.tsx +55 -0
- package/src/DialogShell.mdx +68 -0
- package/src/DialogShell.tsx +12 -5
- package/src/Divider.mdx +41 -0
- package/src/EditableText.mdx +59 -0
- package/src/EditableText.stories.tsx +63 -0
- package/src/EditableText.tsx +169 -0
- package/src/EmptyState.mdx +41 -0
- package/src/Field.mdx +39 -0
- package/src/IconButton.mdx +47 -0
- package/src/IdentityMenu.mdx +62 -0
- package/src/IdentityMenu.stories.tsx +52 -0
- package/src/IdentityMenu.tsx +159 -0
- package/src/Menu.mdx +111 -0
- package/src/Menu.stories.tsx +66 -0
- package/src/Menu.tsx +172 -0
- package/src/MenuItem.mdx +55 -0
- package/src/MenuItem.stories.tsx +98 -0
- package/src/NavItem.mdx +53 -0
- package/src/NavItem.stories.tsx +36 -0
- package/src/NavItem.tsx +53 -0
- package/src/Person.mdx +44 -0
- package/src/Person.stories.tsx +43 -0
- package/src/Person.tsx +39 -0
- package/src/PersonTrigger.mdx +51 -0
- package/src/PersonTrigger.stories.tsx +30 -0
- package/src/PersonTrigger.tsx +62 -0
- package/src/Property.mdx +51 -0
- package/src/Property.stories.tsx +91 -0
- package/src/Property.tsx +44 -0
- package/src/Rail.tsx +26 -0
- package/src/RailItem.mdx +50 -0
- package/src/RailItem.stories.tsx +37 -0
- package/src/RailItem.tsx +53 -0
- package/src/SearchInput.mdx +39 -0
- package/src/SearchInput.stories.tsx +24 -0
- package/src/SearchInput.tsx +45 -0
- package/src/SectionHeader.mdx +52 -0
- package/src/SectionHeader.stories.tsx +66 -0
- package/src/SectionHeader.tsx +85 -0
- package/src/SectionLabel.mdx +42 -0
- package/src/SectionLabel.stories.tsx +31 -0
- package/src/SectionLabel.tsx +28 -0
- package/src/Select.fit.test.ts +97 -0
- package/src/Select.mdx +71 -0
- package/src/Select.stories.tsx +95 -0
- package/src/Select.tsx +123 -15
- package/src/Sidebar.mdx +56 -0
- package/src/Sidebar.stories.tsx +50 -0
- package/src/Sidebar.tsx +32 -0
- package/src/Skeleton.mdx +39 -0
- package/src/Tabs.mdx +53 -0
- package/src/Tabs.stories.tsx +91 -0
- package/src/Tabs.tsx +73 -0
- package/src/TextInput.mdx +39 -0
- package/src/Textarea.mdx +36 -0
- package/src/Toast.mdx +54 -0
- package/src/Toast.stories.tsx +87 -0
- package/src/Toast.tsx +169 -0
- package/src/Tooltip.mdx +51 -0
- package/src/Tooltip.tsx +4 -2
- package/src/TopBar.mdx +62 -0
- package/src/TopBar.stories.tsx +96 -0
- package/src/TopBar.tsx +57 -0
- package/src/cn.test.ts +27 -0
- package/src/cn.ts +25 -5
- package/src/index.ts +23 -0
- package/tailwind-preset.js +4 -3
- package/tokens.css +4 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { EditableText } from './EditableText'
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Inputs/EditableText',
|
|
7
|
+
component: EditableText,
|
|
8
|
+
args: {
|
|
9
|
+
value: 'Team charter',
|
|
10
|
+
placeholder: 'Add a title',
|
|
11
|
+
label: 'Title',
|
|
12
|
+
onCommit: () => true as boolean,
|
|
13
|
+
className: 'text-[16px] leading-[1.4] font-medium',
|
|
14
|
+
},
|
|
15
|
+
argTypes: { onCommit: { control: false } },
|
|
16
|
+
decorators: [(Story) => <div className="w-80">{Story()}</div>],
|
|
17
|
+
} satisfies Meta<typeof EditableText>
|
|
18
|
+
|
|
19
|
+
export default meta
|
|
20
|
+
type Story = StoryObj<typeof meta>
|
|
21
|
+
|
|
22
|
+
/** Click the text: it becomes a field. Enter commits, Escape cancels, blur commits. */
|
|
23
|
+
export const Title: Story = {
|
|
24
|
+
parameters: { controls: { disable: true } },
|
|
25
|
+
render: (args) => {
|
|
26
|
+
const [value, setValue] = useState(args.value)
|
|
27
|
+
return (
|
|
28
|
+
<EditableText
|
|
29
|
+
{...args}
|
|
30
|
+
value={value}
|
|
31
|
+
onCommit={(next) => {
|
|
32
|
+
setValue(next)
|
|
33
|
+
return true
|
|
34
|
+
}}
|
|
35
|
+
/>
|
|
36
|
+
)
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Shift+Enter is a new line; Enter still commits. */
|
|
41
|
+
export const Multiline: Story = {
|
|
42
|
+
args: {
|
|
43
|
+
value: 'A longer piece of text.\nIt keeps its lines.',
|
|
44
|
+
placeholder: 'Add a description',
|
|
45
|
+
label: 'Description',
|
|
46
|
+
multiline: true,
|
|
47
|
+
className: 'text-[14px] leading-[1.4]',
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Empty shows the placeholder, muted — still clickable. */
|
|
52
|
+
export const Empty: Story = { args: { value: '' } }
|
|
53
|
+
|
|
54
|
+
/** For a reader who cannot write: the value alone, no edit affordance. */
|
|
55
|
+
export const ReadOnly: Story = { args: { readOnly: true } }
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A commit that resolves `false` keeps the field open with the text in it —
|
|
59
|
+
* an edit is never silently lost. Try committing anything here.
|
|
60
|
+
*/
|
|
61
|
+
export const RefusedCommit: Story = {
|
|
62
|
+
args: { onCommit: () => false },
|
|
63
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from 'react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Text you click to edit — Ship's EditableText (2026-09-01), verbatim. Its
|
|
6
|
+
* own comment always called it a package candidate: nothing here knows what
|
|
7
|
+
* is being edited.
|
|
8
|
+
*
|
|
9
|
+
* Reads as text until clicked; then it is a field. Enter commits (Shift+Enter
|
|
10
|
+
* is a new line when multiline), Escape cancels, blur commits. A commit that
|
|
11
|
+
* fails keeps the field open with the text in it, so an edit is never
|
|
12
|
+
* silently lost — the caller has already said why. An unchanged value is not
|
|
13
|
+
* committed at all. Empty shows the placeholder, muted.
|
|
14
|
+
*/
|
|
15
|
+
export interface EditableTextProps {
|
|
16
|
+
value: string
|
|
17
|
+
placeholder: string
|
|
18
|
+
/** Resolve `true` when saved; `false` (or throw) keeps the field open with the text. */
|
|
19
|
+
onCommit: (value: string) => Promise<boolean> | boolean
|
|
20
|
+
multiline?: boolean
|
|
21
|
+
/** Applied to both the text and the field, so they are the same size. */
|
|
22
|
+
className?: string
|
|
23
|
+
/** The accessible name of the field — "Title", "Description". */
|
|
24
|
+
label: string
|
|
25
|
+
/**
|
|
26
|
+
* What to *show* when not editing, when that differs from what is edited —
|
|
27
|
+
* a value carrying a raw reference that the caller renders as a live object
|
|
28
|
+
* below the text, say.
|
|
29
|
+
*
|
|
30
|
+
* It overrides the display branch only. `value` is still what the editor
|
|
31
|
+
* opens with and what a commit compares against, so nothing is silently lost
|
|
32
|
+
* — which is the failure a naive `value={stripped}` would cause.
|
|
33
|
+
*/
|
|
34
|
+
display?: string
|
|
35
|
+
/**
|
|
36
|
+
* What to *draw* when not editing, when the value is structured rather than
|
|
37
|
+
* a line of prose.
|
|
38
|
+
*
|
|
39
|
+
* A node, not a string, because a heading and a bullet are elements. Added
|
|
40
|
+
* for a rich text field (SPEC §13): the app renders the parsed body and
|
|
41
|
+
* hands the result in.
|
|
42
|
+
*
|
|
43
|
+
* `display` stays the **string**, and stays what decides emptiness — so a
|
|
44
|
+
* blank field still shows its placeholder rather than an empty element. Like
|
|
45
|
+
* `display`, this overrides the display branch only: `value` is what the
|
|
46
|
+
* editor opens with and what a commit compares against, so what is edited is
|
|
47
|
+
* unchanged.
|
|
48
|
+
*
|
|
49
|
+
* `whitespace-pre-wrap` is dropped when this is set. Structured content
|
|
50
|
+
* carries its own line breaks, and preserving the source's as well doubles
|
|
51
|
+
* every one of them.
|
|
52
|
+
*/
|
|
53
|
+
displayNode?: ReactNode
|
|
54
|
+
/** Show the value only — no edit affordance. For a reader who cannot write. */
|
|
55
|
+
readOnly?: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function EditableText({ value, display, displayNode, placeholder, onCommit, multiline = false, className, label, readOnly = false }: EditableTextProps) {
|
|
59
|
+
const [editing, setEditing] = useState(false)
|
|
60
|
+
const [draft, setDraft] = useState(value)
|
|
61
|
+
const [busy, setBusy] = useState(false)
|
|
62
|
+
const fieldRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (editing) {
|
|
66
|
+
fieldRef.current?.focus()
|
|
67
|
+
fieldRef.current?.select()
|
|
68
|
+
}
|
|
69
|
+
}, [editing])
|
|
70
|
+
|
|
71
|
+
const open = () => {
|
|
72
|
+
setDraft(value)
|
|
73
|
+
setEditing(true)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const cancel = () => {
|
|
77
|
+
setEditing(false)
|
|
78
|
+
setDraft(value)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const commit = async () => {
|
|
82
|
+
if (busy) return
|
|
83
|
+
const next = draft.trim()
|
|
84
|
+
if (next === value) {
|
|
85
|
+
setEditing(false)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
setBusy(true)
|
|
89
|
+
try {
|
|
90
|
+
const ok = await onCommit(next)
|
|
91
|
+
if (ok) setEditing(false)
|
|
92
|
+
} catch {
|
|
93
|
+
/* the caller has shown why; keep the field open */
|
|
94
|
+
} finally {
|
|
95
|
+
setBusy(false)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const onKeyDown = (event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
100
|
+
if (event.key === 'Escape') {
|
|
101
|
+
event.preventDefault()
|
|
102
|
+
cancel()
|
|
103
|
+
} else if (event.key === 'Enter' && !(multiline && event.shiftKey)) {
|
|
104
|
+
event.preventDefault()
|
|
105
|
+
void commit()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const fieldClass = cn(
|
|
110
|
+
'w-full rounded-md border border-border-strong bg-bg-inset px-2 py-1 text-text-primary outline-none',
|
|
111
|
+
multiline && 'resize-none',
|
|
112
|
+
className,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if (readOnly) {
|
|
116
|
+
return (
|
|
117
|
+
<div
|
|
118
|
+
aria-label={label}
|
|
119
|
+
className={cn('w-full px-2 py-1', multiline && !displayNode && 'whitespace-pre-wrap', (display ?? value) ? 'text-text-primary' : 'text-text-muted', className)}
|
|
120
|
+
>
|
|
121
|
+
{(display ?? value) ? (displayNode ?? (display ?? value)) : placeholder}
|
|
122
|
+
</div>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (editing) {
|
|
127
|
+
return multiline ? (
|
|
128
|
+
<textarea
|
|
129
|
+
ref={fieldRef as RefObject<HTMLTextAreaElement>}
|
|
130
|
+
aria-label={label}
|
|
131
|
+
value={draft}
|
|
132
|
+
rows={4}
|
|
133
|
+
disabled={busy}
|
|
134
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
135
|
+
onKeyDown={onKeyDown}
|
|
136
|
+
onBlur={() => void commit()}
|
|
137
|
+
className={fieldClass}
|
|
138
|
+
/>
|
|
139
|
+
) : (
|
|
140
|
+
<input
|
|
141
|
+
ref={fieldRef as RefObject<HTMLInputElement>}
|
|
142
|
+
aria-label={label}
|
|
143
|
+
value={draft}
|
|
144
|
+
disabled={busy}
|
|
145
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
146
|
+
onKeyDown={onKeyDown}
|
|
147
|
+
onBlur={() => void commit()}
|
|
148
|
+
className={fieldClass}
|
|
149
|
+
/>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
onClick={open}
|
|
157
|
+
title="Click to edit"
|
|
158
|
+
aria-label={`Edit ${label.toLowerCase()}`}
|
|
159
|
+
className={cn(
|
|
160
|
+
'w-full rounded-md border border-transparent px-2 py-1 text-left transition-colors hover:border-border-default',
|
|
161
|
+
multiline && !displayNode && 'whitespace-pre-wrap',
|
|
162
|
+
(display ?? value) ? 'text-text-primary' : 'text-text-muted',
|
|
163
|
+
className,
|
|
164
|
+
)}
|
|
165
|
+
>
|
|
166
|
+
{(display ?? value) ? (displayNode ?? (display ?? value)) : placeholder}
|
|
167
|
+
</button>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as EmptyStateStories from './EmptyState.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={EmptyStateStories} />
|
|
5
|
+
|
|
6
|
+
# EmptyState
|
|
7
|
+
|
|
8
|
+
A 16px icon over a centred line of secondary text, for the place where
|
|
9
|
+
content will be once there is some.
|
|
10
|
+
|
|
11
|
+
<Canvas of={EmptyStateStories.Default} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- A list, panel or view with nothing in it yet. The message says **what
|
|
16
|
+
would fill it** — "No comments yet. Start the thread." — not just that
|
|
17
|
+
it is empty.
|
|
18
|
+
- `icon` swaps the default speech bubble for one that matches the
|
|
19
|
+
subject.
|
|
20
|
+
|
|
21
|
+
## When not
|
|
22
|
+
|
|
23
|
+
- Data is still on its way → **Skeleton**; empty and loading are
|
|
24
|
+
different truths.
|
|
25
|
+
- Something went wrong → say that, in your surface's own error treatment
|
|
26
|
+
— an empty state that hides a failure misleads.
|
|
27
|
+
|
|
28
|
+
## How
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
import { EmptyState } from '@estiva-app/ui'
|
|
32
|
+
|
|
33
|
+
<EmptyState message="No documents yet. Create the first one." />
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The message is the caller's — a shared component has no words of its own
|
|
37
|
+
for what is missing.
|
|
38
|
+
|
|
39
|
+
## Props
|
|
40
|
+
|
|
41
|
+
<Controls of={EmptyStateStories.Default} />
|
package/src/Field.mdx
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as FieldStories from './Field.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={FieldStories} />
|
|
5
|
+
|
|
6
|
+
# Field
|
|
7
|
+
|
|
8
|
+
A label over a control — the form row. The label is the `input-label`
|
|
9
|
+
token; `required` marks it with the error-coloured asterisk.
|
|
10
|
+
|
|
11
|
+
<Canvas of={FieldStories.Required} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- Any labelled control in a form or dialog: a TextInput, a Textarea, a
|
|
16
|
+
Select, a ChipInput.
|
|
17
|
+
|
|
18
|
+
## When not
|
|
19
|
+
|
|
20
|
+
- Displaying a label–value pair → **Property**; Field is for editing.
|
|
21
|
+
- It carries no helper or error text of its own — validation display is
|
|
22
|
+
the caller's, under the control.
|
|
23
|
+
|
|
24
|
+
## How
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
import { Field, TextInput } from '@estiva-app/ui'
|
|
28
|
+
|
|
29
|
+
<Field label="Name" required>
|
|
30
|
+
<TextInput value={name} onChange={(e) => setName(e.target.value)} />
|
|
31
|
+
</Field>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The label is visual, not wired: also give the control its accessible name
|
|
35
|
+
(`aria-label`, or an `id`/`htmlFor` pair of your own) so the two agree.
|
|
36
|
+
|
|
37
|
+
## Props
|
|
38
|
+
|
|
39
|
+
<Controls of={FieldStories.Default} />
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as IconButtonStories from './IconButton.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={IconButtonStories} />
|
|
5
|
+
|
|
6
|
+
# IconButton
|
|
7
|
+
|
|
8
|
+
A square button around a 16px icon: 4px padding, 8px radius, three
|
|
9
|
+
variants. For the actions a surface repeats too often to label every time.
|
|
10
|
+
|
|
11
|
+
<Canvas of={IconButtonStories.AllVariants} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- Toolbars and row-hover actions — edit, copy, dismiss, more.
|
|
16
|
+
- A dialog's close.
|
|
17
|
+
- `tooltip` names it on hover; `primary` exists for the rare icon action
|
|
18
|
+
that is the point of its surface.
|
|
19
|
+
|
|
20
|
+
## When not
|
|
21
|
+
|
|
22
|
+
- The action deserves a visible label → **Button** (words are cheaper than
|
|
23
|
+
a guessed icon).
|
|
24
|
+
- Inside a menu → **MenuItem** with `leading`.
|
|
25
|
+
- A strip of these is a toolbar — right; but the moment rows toggle and
|
|
26
|
+
persist state, see **Checkbox**.
|
|
27
|
+
|
|
28
|
+
## How
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
import { IconButton } from '@estiva-app/ui'
|
|
32
|
+
import { IconPencil } from '@tabler/icons-react'
|
|
33
|
+
|
|
34
|
+
<IconButton aria-label="Edit" tooltip="Edit" onClick={edit}>
|
|
35
|
+
<IconPencil size={16} stroke={1.5} />
|
|
36
|
+
</IconButton>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- **`aria-label` is required** — nothing visible names an icon-only
|
|
40
|
+
control. The `tooltip` shows sighted hover users the same word; it does
|
|
41
|
+
not replace the label.
|
|
42
|
+
- The icon is 16px at stroke 1.5.
|
|
43
|
+
- `type` defaults to `"button"`; every native prop passes through.
|
|
44
|
+
|
|
45
|
+
## Props
|
|
46
|
+
|
|
47
|
+
<Controls of={IconButtonStories.Muted} />
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as IdentityMenuStories from './IdentityMenu.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={IdentityMenuStories} />
|
|
5
|
+
|
|
6
|
+
# IdentityMenu
|
|
7
|
+
|
|
8
|
+
Who you are, and where you are: the identity trigger for a top bar, and
|
|
9
|
+
the menu it opens — ready-made, because every app needs one and it is
|
|
10
|
+
built of nothing but shared parts.
|
|
11
|
+
|
|
12
|
+
<Canvas of={IdentityMenuStories.SignedIn} />
|
|
13
|
+
|
|
14
|
+
## When
|
|
15
|
+
|
|
16
|
+
- The account control of any app. The trigger is a face and a name —
|
|
17
|
+
never a key — or, `compact`, the face alone at 36px.
|
|
18
|
+
- Anonymous is the silhouette and the word "Anonymous": the truth of the
|
|
19
|
+
situation rather than hex posing as a name.
|
|
20
|
+
- **Every section hides when the app cannot fill it** — no `relayUrl`, no
|
|
21
|
+
workspace section; no `onCopyKey`, no copy item; no `idBase`, no
|
|
22
|
+
sign-in surface. Only offer actions that can succeed. The MinimalApp
|
|
23
|
+
story is the floor: identity alone.
|
|
24
|
+
|
|
25
|
+
<Canvas of={IdentityMenuStories.MinimalApp} />
|
|
26
|
+
|
|
27
|
+
## When not
|
|
28
|
+
|
|
29
|
+
- Any other menu → **Menu**.
|
|
30
|
+
- Showing a person who is not the viewer → **Person**.
|
|
31
|
+
- A trigger that opens something of your own → **PersonTrigger** plus
|
|
32
|
+
your menu.
|
|
33
|
+
|
|
34
|
+
## How
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
import { IdentityMenu } from '@estiva-app/ui'
|
|
38
|
+
|
|
39
|
+
<IdentityMenu
|
|
40
|
+
me={{ name: me.name, picture: me.picture, email: me.email }}
|
|
41
|
+
signedIn={signedIn}
|
|
42
|
+
relayUrl={relayUrl}
|
|
43
|
+
idBase={ID_BASE}
|
|
44
|
+
onSignOut={signOut}
|
|
45
|
+
/>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- App-specific rows go through `children`, drawn as their own group
|
|
49
|
+
between the workspace section and the actions. Pass a function to
|
|
50
|
+
receive `close`, so a row can shut the menu before opening what it
|
|
51
|
+
opens.
|
|
52
|
+
- Items are text only — no icons in this menu, by ruling.
|
|
53
|
+
- **IdentityPanel** is exported alone for a surface that wants the panel
|
|
54
|
+
without the trigger; the stories render it, so the designed artifact is
|
|
55
|
+
what you review.
|
|
56
|
+
- The trigger's toggle, `aria-expanded`, and the accessible-name rules
|
|
57
|
+
(the row is named by the person; only the bare compact face takes an
|
|
58
|
+
`aria-label`) are wired inside — bring nothing.
|
|
59
|
+
|
|
60
|
+
## Props
|
|
61
|
+
|
|
62
|
+
<Controls of={IdentityMenuStories.SignedIn} />
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IdentityPanel } from './IdentityMenu'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The menu itself, standing in flow (`static` beats the anchoring, as the
|
|
6
|
+
* Menu stories do it) — no trigger, no bar chrome: the trigger's shapes are
|
|
7
|
+
* PersonTrigger's own stories. In an app, `IdentityMenu` bundles that
|
|
8
|
+
* trigger with this panel.
|
|
9
|
+
*/
|
|
10
|
+
const meta = {
|
|
11
|
+
title: 'Navigation/IdentityMenu',
|
|
12
|
+
component: IdentityPanel,
|
|
13
|
+
args: {
|
|
14
|
+
me: {},
|
|
15
|
+
signedIn: false,
|
|
16
|
+
relayUrl: 'http://localhost:3000',
|
|
17
|
+
onCopyKey: () => {},
|
|
18
|
+
onSignOut: () => {},
|
|
19
|
+
onClose: () => {},
|
|
20
|
+
className: 'static',
|
|
21
|
+
},
|
|
22
|
+
argTypes: { onClose: { control: false }, className: { control: false } },
|
|
23
|
+
} satisfies Meta<typeof IdentityPanel>
|
|
24
|
+
|
|
25
|
+
export default meta
|
|
26
|
+
type Story = StoryObj<typeof meta>
|
|
27
|
+
|
|
28
|
+
/** A browser-held key: silhouette, "Acting as", and the honest sentence. */
|
|
29
|
+
export const Anonymous: Story = {}
|
|
30
|
+
|
|
31
|
+
/** Signed in through Estiva ID, in a build that offers sign-in. */
|
|
32
|
+
export const SignedIn: Story = {
|
|
33
|
+
args: {
|
|
34
|
+
me: { name: 'Ana Duarte', email: 'ana@example.com' },
|
|
35
|
+
signedIn: true,
|
|
36
|
+
idBase: 'https://id.estiva.app',
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* An app that cannot name a relay or supply a key: those pieces simply are
|
|
42
|
+
* not there — only offer actions that can succeed.
|
|
43
|
+
*/
|
|
44
|
+
export const MinimalApp: Story = {
|
|
45
|
+
args: {
|
|
46
|
+
me: { name: 'Ana Duarte' },
|
|
47
|
+
signedIn: true,
|
|
48
|
+
idBase: 'https://id.estiva.app',
|
|
49
|
+
relayUrl: undefined,
|
|
50
|
+
onCopyKey: undefined,
|
|
51
|
+
},
|
|
52
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { useState, type ReactNode } from 'react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
import { Divider } from './Divider'
|
|
4
|
+
import { Menu, MenuItem, MenuRow, MenuSection } from './Menu'
|
|
5
|
+
import { Person } from './Person'
|
|
6
|
+
import { PersonTrigger } from './PersonTrigger'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Who you are, and where you are — the top bar's identity trigger and the
|
|
10
|
+
* menu it opens. Ship's IdentityMenu (2026-09-01), moved in whole: every app
|
|
11
|
+
* needs one, and this one is already made of nothing but shared parts.
|
|
12
|
+
*
|
|
13
|
+
* The trigger is a face and a name, never a key (Ship's ruling A4) — or, with
|
|
14
|
+
* `compact`, the face alone, as Peek's top bar draws it. Anonymous is a
|
|
15
|
+
* silhouette and the word "Anonymous": the truth of the situation rather than
|
|
16
|
+
* eight hex characters posing as a name.
|
|
17
|
+
*
|
|
18
|
+
* The menu keeps the two honest sentences, and the workspace line names the
|
|
19
|
+
* relay because on this substrate the relay *is* the workspace. Every section
|
|
20
|
+
* hides when the app cannot fill it: no `relayUrl`, no workspace section; no
|
|
21
|
+
* `onCopyKey`, no copy item — only offer actions that can succeed. The one
|
|
22
|
+
* place a key appears is as the *result* of "Copy public key" — this
|
|
23
|
+
* component never receives the key, so it cannot show one; the caller
|
|
24
|
+
* copies. Items are text only, no icons (her ruling).
|
|
25
|
+
*/
|
|
26
|
+
export interface Identity {
|
|
27
|
+
/** From `kind:0`. Absent means anonymous. */
|
|
28
|
+
name?: string
|
|
29
|
+
picture?: string
|
|
30
|
+
/** Known to Estiva ID, never published to the relay. */
|
|
31
|
+
email?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface IdentityMenuProps {
|
|
35
|
+
me: Identity
|
|
36
|
+
/** Signed in through Estiva ID, as opposed to a browser-held key. */
|
|
37
|
+
signedIn: boolean
|
|
38
|
+
/** Names the workspace; absent hides the workspace section. */
|
|
39
|
+
relayUrl?: string
|
|
40
|
+
/** Estiva ID's base URL when this build offers sign-in; absent = anonymous-only build. */
|
|
41
|
+
idBase?: string
|
|
42
|
+
/** "Copy public key" appears only when the app can supply one. */
|
|
43
|
+
onCopyKey?: () => void
|
|
44
|
+
onSignOut?: () => void
|
|
45
|
+
/** Face-only trigger, 36px — no chevron, no name beside it. */
|
|
46
|
+
compact?: boolean
|
|
47
|
+
className?: string
|
|
48
|
+
/**
|
|
49
|
+
* App-specific rows, drawn as their own group between the workspace
|
|
50
|
+
* section and the actions. A function receives `close`, so a row can
|
|
51
|
+
* shut the menu before opening what it opens.
|
|
52
|
+
*/
|
|
53
|
+
children?: ReactNode | ((close: () => void) => ReactNode)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface IdentityPanelProps extends Omit<IdentityMenuProps, 'compact' | 'className'> {
|
|
57
|
+
onClose: () => void
|
|
58
|
+
/** On the Menu surface — the stories pass `static` to stand it in flow. */
|
|
59
|
+
className?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The menu alone — what `IdentityMenu` opens. Exported so the stories show
|
|
63
|
+
* the designed artifact rather than a closed trigger, and for any surface
|
|
64
|
+
* that wants the panel without the trigger. */
|
|
65
|
+
export function IdentityPanel({ me, signedIn, relayUrl, idBase, onCopyKey, onSignOut, onClose, className, children }: IdentityPanelProps) {
|
|
66
|
+
const act = (action: () => void) => () => {
|
|
67
|
+
onClose()
|
|
68
|
+
action()
|
|
69
|
+
}
|
|
70
|
+
const appRows = typeof children === 'function' ? children(onClose) : children
|
|
71
|
+
return (
|
|
72
|
+
<Menu onClose={onClose} className={cn('w-72', className)}>
|
|
73
|
+
<MenuSection label={signedIn ? 'Signed in as' : 'Acting as'}>
|
|
74
|
+
<MenuRow>
|
|
75
|
+
<Person name={me.name} picture={me.picture} fallback="Anonymous" size={28} className="text-body-2-strong" />
|
|
76
|
+
</MenuRow>
|
|
77
|
+
<Note>
|
|
78
|
+
{signedIn
|
|
79
|
+
? 'Your Estiva ID. The same person you are in every Estiva app.'
|
|
80
|
+
: 'A key held by this browser only. Nobody else knows who you are.'}
|
|
81
|
+
</Note>
|
|
82
|
+
{me.email && <Note>{me.email} · known to Estiva, never published to the relay</Note>}
|
|
83
|
+
</MenuSection>
|
|
84
|
+
|
|
85
|
+
{relayUrl && (
|
|
86
|
+
<>
|
|
87
|
+
<Divider className="mx-0 my-2" />
|
|
88
|
+
<MenuSection label="Workspace">
|
|
89
|
+
<MenuRow>
|
|
90
|
+
<span className="break-all font-mono text-caption text-text-primary">{relayUrl}</span>
|
|
91
|
+
</MenuRow>
|
|
92
|
+
<Note>Everyone on this relay shares this workspace, in every app.</Note>
|
|
93
|
+
</MenuSection>
|
|
94
|
+
</>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
{appRows && (
|
|
98
|
+
<>
|
|
99
|
+
<Divider className="mx-0 my-2" />
|
|
100
|
+
{appRows}
|
|
101
|
+
</>
|
|
102
|
+
)}
|
|
103
|
+
|
|
104
|
+
{(signedIn && idBase) || onCopyKey || (idBase && onSignOut) ? <Divider className="mx-0 my-2" /> : null}
|
|
105
|
+
|
|
106
|
+
{signedIn && idBase && (
|
|
107
|
+
<MenuItem
|
|
108
|
+
label="Edit your profile in Estiva ID"
|
|
109
|
+
onClick={act(() => window.open(idBase, '_blank', 'noopener,noreferrer'))}
|
|
110
|
+
/>
|
|
111
|
+
)}
|
|
112
|
+
{onCopyKey && <MenuItem label="Copy public key" onClick={act(onCopyKey)} />}
|
|
113
|
+
{idBase && onSignOut && <MenuItem label="Sign out" onClick={act(onSignOut)} />}
|
|
114
|
+
</Menu>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function IdentityMenu({ me, signedIn, relayUrl, idBase, onCopyKey, onSignOut, compact = false, className, children }: IdentityMenuProps) {
|
|
119
|
+
const [open, setOpen] = useState(false)
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div className={cn('relative', className)}>
|
|
123
|
+
<PersonTrigger
|
|
124
|
+
name={me.name}
|
|
125
|
+
picture={me.picture}
|
|
126
|
+
fallback="Anonymous"
|
|
127
|
+
compact={compact}
|
|
128
|
+
size={compact ? 36 : undefined}
|
|
129
|
+
open={open}
|
|
130
|
+
// Swallowed so the menu's outside-mousedown dismiss does not fire
|
|
131
|
+
// first and turn the toggle into a close-then-reopen flicker.
|
|
132
|
+
onMouseDown={(event) => event.stopPropagation()}
|
|
133
|
+
onClick={() => setOpen((value) => !value)}
|
|
134
|
+
// The row shape is named by its own text — the person. Only the bare
|
|
135
|
+
// face needs a label; naming the row would override the person's name
|
|
136
|
+
// as the accessible name (Ship's tests find the trigger by it).
|
|
137
|
+
aria-label={compact ? 'Account menu' : undefined}
|
|
138
|
+
/>
|
|
139
|
+
|
|
140
|
+
{open && (
|
|
141
|
+
<IdentityPanel
|
|
142
|
+
me={me}
|
|
143
|
+
signedIn={signedIn}
|
|
144
|
+
relayUrl={relayUrl}
|
|
145
|
+
idBase={idBase}
|
|
146
|
+
onCopyKey={onCopyKey}
|
|
147
|
+
onSignOut={onSignOut}
|
|
148
|
+
onClose={() => setOpen(false)}
|
|
149
|
+
>
|
|
150
|
+
{children}
|
|
151
|
+
</IdentityPanel>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function Note({ children }: { children: ReactNode }) {
|
|
158
|
+
return <span className="px-2 pb-1.5 text-caption text-text-secondary">{children}</span>
|
|
159
|
+
}
|