@estiva-app/ui 0.1.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/LICENSE +21 -0
- package/README.md +124 -0
- package/dist/Avatar.d.ts +16 -0
- package/dist/Avatar.d.ts.map +1 -0
- package/dist/Button.d.ts +28 -0
- package/dist/Button.d.ts.map +1 -0
- package/dist/Chip.d.ts +21 -0
- package/dist/Chip.d.ts.map +1 -0
- package/dist/DialogShell.d.ts +19 -0
- package/dist/DialogShell.d.ts.map +1 -0
- package/dist/Divider.d.ts +12 -0
- package/dist/Divider.d.ts.map +1 -0
- package/dist/EmptyState.d.ts +14 -0
- package/dist/EmptyState.d.ts.map +1 -0
- package/dist/Field.d.ts +13 -0
- package/dist/Field.d.ts.map +1 -0
- package/dist/IconButton.d.ts +17 -0
- package/dist/IconButton.d.ts.map +1 -0
- package/dist/Select.d.ts +30 -0
- package/dist/Select.d.ts.map +1 -0
- package/dist/Skeleton.d.ts +24 -0
- package/dist/Skeleton.d.ts.map +1 -0
- package/dist/TextInput.d.ts +11 -0
- package/dist/TextInput.d.ts.map +1 -0
- package/dist/Textarea.d.ts +8 -0
- package/dist/Textarea.d.ts.map +1 -0
- package/dist/Tooltip.d.ts +22 -0
- package/dist/Tooltip.d.ts.map +1 -0
- package/dist/cn.d.ts +10 -0
- package/dist/cn.d.ts.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +486 -0
- package/dist/index.js.map +7 -0
- package/package.json +74 -0
- package/src/Avatar.stories.tsx +55 -0
- package/src/Avatar.tsx +81 -0
- package/src/Button.stories.tsx +53 -0
- package/src/Button.tsx +67 -0
- package/src/Chip.stories.tsx +37 -0
- package/src/Chip.tsx +43 -0
- package/src/DialogShell.stories.tsx +97 -0
- package/src/DialogShell.tsx +65 -0
- package/src/Divider.stories.tsx +44 -0
- package/src/Divider.tsx +22 -0
- package/src/EmptyState.stories.tsx +18 -0
- package/src/EmptyState.tsx +24 -0
- package/src/Field.stories.tsx +22 -0
- package/src/Field.tsx +24 -0
- package/src/IconButton.stories.tsx +44 -0
- package/src/IconButton.tsx +60 -0
- package/src/Select.stories.tsx +107 -0
- package/src/Select.tsx +195 -0
- package/src/Skeleton.stories.tsx +33 -0
- package/src/Skeleton.tsx +38 -0
- package/src/TextInput.stories.tsx +18 -0
- package/src/TextInput.tsx +29 -0
- package/src/Textarea.stories.tsx +17 -0
- package/src/Textarea.tsx +25 -0
- package/src/Tooltip.stories.tsx +47 -0
- package/src/Tooltip.tsx +70 -0
- package/src/cn.ts +13 -0
- package/src/index.ts +25 -0
- package/tailwind-preset.js +162 -0
- package/tokens.css +225 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { IconMessage2 } from '@tabler/icons-react'
|
|
3
|
+
import { cn } from './cn'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Peek's EmptyState (2026-08-28), verbatim: a 16px icon over a centred line
|
|
7
|
+
* of secondary text. The message is the caller's — a shared component has no
|
|
8
|
+
* words of its own for what is missing.
|
|
9
|
+
*/
|
|
10
|
+
export interface EmptyStateProps {
|
|
11
|
+
/** 16px, stroke 1.5. A speech bubble when absent. */
|
|
12
|
+
icon?: ReactNode
|
|
13
|
+
message: string
|
|
14
|
+
className?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function EmptyState({ icon, message, className }: EmptyStateProps) {
|
|
18
|
+
return (
|
|
19
|
+
<div className={cn('flex flex-col gap-2 items-center', className)}>
|
|
20
|
+
<span className="text-text-secondary">{icon ?? <IconMessage2 size={16} stroke={1.5} />}</span>
|
|
21
|
+
<p className="text-body-2 text-text-secondary text-center">{message}</p>
|
|
22
|
+
</div>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { Field } from './Field'
|
|
3
|
+
import { TextInput } from './TextInput'
|
|
4
|
+
import { Textarea } from './Textarea'
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Inputs/Field',
|
|
8
|
+
component: Field,
|
|
9
|
+
parameters: { layout: 'padded' },
|
|
10
|
+
args: { label: 'Title', required: false },
|
|
11
|
+
argTypes: { children: { control: false } },
|
|
12
|
+
decorators: [(Story) => <div className="w-[360px]"><Story /></div>],
|
|
13
|
+
} satisfies Meta<typeof Field>
|
|
14
|
+
|
|
15
|
+
export default meta
|
|
16
|
+
type Story = StoryObj<typeof meta>
|
|
17
|
+
|
|
18
|
+
export const Default: Story = { args: { children: <TextInput placeholder="What is this about?" /> } }
|
|
19
|
+
export const Required: Story = { args: { label: 'Title', required: true, children: <TextInput placeholder="What is this about?" /> } }
|
|
20
|
+
export const WithTextarea: Story = {
|
|
21
|
+
args: { label: 'Resolution message (optional)', children: <Textarea placeholder="Summarise the outcome…" className="h-[109px]" /> },
|
|
22
|
+
}
|
package/src/Field.tsx
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Peek's Field (2026-08-28), verbatim: a label over a control, 8px apart,
|
|
5
|
+
* with a red asterisk when required. The label is the `input-label` type
|
|
6
|
+
* token as a plain class, never merged.
|
|
7
|
+
*/
|
|
8
|
+
export interface FieldProps {
|
|
9
|
+
label: string
|
|
10
|
+
required?: boolean
|
|
11
|
+
children: ReactNode
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function Field({ label, required = false, children }: FieldProps) {
|
|
15
|
+
return (
|
|
16
|
+
<div className="flex flex-col gap-2">
|
|
17
|
+
<label className={`text-input-label text-text-primary${required ? ' flex items-center' : ''}`}>
|
|
18
|
+
{label}
|
|
19
|
+
{required && <span className="text-error-default ml-0.5">*</span>}
|
|
20
|
+
</label>
|
|
21
|
+
{children}
|
|
22
|
+
</div>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconSettings } from '@tabler/icons-react'
|
|
3
|
+
import { IconButton } from './IconButton'
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Primitives/IconButton',
|
|
7
|
+
component: IconButton,
|
|
8
|
+
args: { variant: 'muted', disabled: false, children: <IconSettings className="size-4" stroke={1.5} /> },
|
|
9
|
+
argTypes: {
|
|
10
|
+
variant: { control: 'inline-radio', options: ['muted', 'outlined', 'primary'] },
|
|
11
|
+
tooltipPlacement: { control: 'inline-radio', options: ['top', 'bottom'] },
|
|
12
|
+
children: { control: false },
|
|
13
|
+
},
|
|
14
|
+
} satisfies Meta<typeof IconButton>
|
|
15
|
+
|
|
16
|
+
export default meta
|
|
17
|
+
type Story = StoryObj<typeof meta>
|
|
18
|
+
|
|
19
|
+
export const Muted: Story = {}
|
|
20
|
+
export const Outlined: Story = { args: { variant: 'outlined' } }
|
|
21
|
+
export const Primary: Story = { args: { variant: 'primary' } }
|
|
22
|
+
export const Disabled: Story = { args: { variant: 'primary', disabled: true } }
|
|
23
|
+
/** Hover to see the portalled tooltip (top or bottom placement). */
|
|
24
|
+
export const WithTooltip: Story = { args: { tooltip: 'Settings', tooltipPlacement: 'top' } }
|
|
25
|
+
|
|
26
|
+
/** Every variant × enabled/disabled. */
|
|
27
|
+
export const AllVariants: Story = {
|
|
28
|
+
parameters: { controls: { disable: true } },
|
|
29
|
+
render: () => (
|
|
30
|
+
<div className="flex flex-col gap-3">
|
|
31
|
+
{(['muted', 'outlined', 'primary'] as const).map((variant) => (
|
|
32
|
+
<div key={variant} className="flex items-center gap-2">
|
|
33
|
+
<IconButton variant={variant}>
|
|
34
|
+
<IconSettings className="size-4" stroke={1.5} />
|
|
35
|
+
</IconButton>
|
|
36
|
+
<IconButton variant={variant} disabled>
|
|
37
|
+
<IconSettings className="size-4" stroke={1.5} />
|
|
38
|
+
</IconButton>
|
|
39
|
+
<span className="text-[12px] leading-[120%] text-text-muted">{variant}</span>
|
|
40
|
+
</div>
|
|
41
|
+
))}
|
|
42
|
+
</div>
|
|
43
|
+
),
|
|
44
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
import { WithTooltip } from './Tooltip'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Peek's IconButton (2026-08-28), verbatim: a square 4px-padded button
|
|
7
|
+
* around a 16px icon, 8px radius, three variants, an optional tooltip that
|
|
8
|
+
* portals above or below. Plus `type="button"` by default (Ship's addition),
|
|
9
|
+
* so it never submits a form by accident.
|
|
10
|
+
*/
|
|
11
|
+
export type IconButtonVariant = 'muted' | 'outlined' | 'primary'
|
|
12
|
+
|
|
13
|
+
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
14
|
+
variant?: IconButtonVariant
|
|
15
|
+
tooltip?: string
|
|
16
|
+
tooltipPlacement?: 'top' | 'bottom'
|
|
17
|
+
/** The icon: 16px, stroke 1.5. */
|
|
18
|
+
children: ReactNode
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function IconButton({
|
|
22
|
+
variant = 'muted',
|
|
23
|
+
className,
|
|
24
|
+
children,
|
|
25
|
+
disabled,
|
|
26
|
+
tooltip,
|
|
27
|
+
tooltipPlacement,
|
|
28
|
+
type = 'button',
|
|
29
|
+
...props
|
|
30
|
+
}: IconButtonProps) {
|
|
31
|
+
const button = (
|
|
32
|
+
<button
|
|
33
|
+
type={type}
|
|
34
|
+
className={cn(
|
|
35
|
+
'flex items-center justify-center p-1 rounded-lg transition-colors shrink-0 cursor-pointer',
|
|
36
|
+
!disabled && variant === 'primary' && 'bg-accent-primary hover:bg-accent-hover text-text-inverse',
|
|
37
|
+
!disabled && variant === 'muted' && 'text-text-secondary hover:bg-bg-hover hover:text-text-primary',
|
|
38
|
+
!disabled && variant === 'outlined' && 'border border-border-default hover:bg-bg-hover text-text-secondary',
|
|
39
|
+
disabled && variant === 'primary' && 'bg-bg-disabled text-text-disabled',
|
|
40
|
+
disabled && variant === 'muted' && 'text-text-disabled',
|
|
41
|
+
disabled && variant === 'outlined' && 'border border-border-default text-text-disabled',
|
|
42
|
+
disabled && 'pointer-events-none cursor-not-allowed',
|
|
43
|
+
className,
|
|
44
|
+
)}
|
|
45
|
+
disabled={disabled}
|
|
46
|
+
{...props}
|
|
47
|
+
>
|
|
48
|
+
{children}
|
|
49
|
+
</button>
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if (tooltip) {
|
|
53
|
+
return (
|
|
54
|
+
<WithTooltip label={tooltip} placement={tooltipPlacement}>
|
|
55
|
+
{button}
|
|
56
|
+
</WithTooltip>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
return button
|
|
60
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useState, type ComponentProps } from 'react'
|
|
3
|
+
import { Avatar } from './Avatar'
|
|
4
|
+
import { Field } from './Field'
|
|
5
|
+
import { Select } from './Select'
|
|
6
|
+
import { TextInput } from './TextInput'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A select drawn by the app rather than by the operating system.
|
|
10
|
+
*
|
|
11
|
+
* The trigger is `TextInput`'s field, so a select and a text field sitting in
|
|
12
|
+
* the same form are the same control with different contents.
|
|
13
|
+
*
|
|
14
|
+
* Open one and try the keyboard: arrows move, Home and End jump, Enter and Space
|
|
15
|
+
* pick, Escape closes and puts focus back on the trigger. That is what a native
|
|
16
|
+
* `<select>` gives away for free, and what has to be written by hand the moment
|
|
17
|
+
* you stop using one.
|
|
18
|
+
*/
|
|
19
|
+
const meta = {
|
|
20
|
+
title: 'Inputs/Select',
|
|
21
|
+
component: Select,
|
|
22
|
+
parameters: { layout: 'padded' },
|
|
23
|
+
decorators: [(Story) => <div className="w-[360px]"><Story /></div>],
|
|
24
|
+
args: {
|
|
25
|
+
value: 'in_progress',
|
|
26
|
+
onChange: () => {},
|
|
27
|
+
options: [
|
|
28
|
+
{ value: 'todo', label: 'Todo' },
|
|
29
|
+
{ value: 'in_progress', label: 'In Progress' },
|
|
30
|
+
{ value: 'done', label: 'Done' },
|
|
31
|
+
{ value: 'cancelled', label: 'Cancelled' },
|
|
32
|
+
],
|
|
33
|
+
ariaLabel: 'Status',
|
|
34
|
+
},
|
|
35
|
+
argTypes: { size: { control: 'inline-radio', options: ['default', 'small'] } },
|
|
36
|
+
} satisfies Meta<typeof Select>
|
|
37
|
+
|
|
38
|
+
export default meta
|
|
39
|
+
type Story = StoryObj<typeof meta>
|
|
40
|
+
|
|
41
|
+
/** Controlled, so the tick and the trigger follow what you pick. */
|
|
42
|
+
function Demo(props: Omit<ComponentProps<typeof Select>, 'value' | 'onChange'> & { initial?: string }) {
|
|
43
|
+
const { initial = '', ...rest } = props
|
|
44
|
+
const [value, setValue] = useState(initial)
|
|
45
|
+
return <Select {...rest} value={value} onChange={setValue} />
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Field height, matching `TextInput` — the size for a form. */
|
|
49
|
+
export const Default: Story = { render: (args) => <Demo {...args} initial="in_progress" /> }
|
|
50
|
+
|
|
51
|
+
/** Nothing chosen yet: the placeholder is muted, so an empty select does not read as one already holding a value. */
|
|
52
|
+
export const Empty: Story = { render: (args) => <Demo {...args} initial="" placeholder="Choose a status…" /> }
|
|
53
|
+
|
|
54
|
+
/** The dense size, matching `Button`'s small — for property rows, where a full-height field would dominate the label beside it. */
|
|
55
|
+
export const Small: Story = { render: (args) => <Demo {...args} size="small" initial="todo" /> }
|
|
56
|
+
|
|
57
|
+
/** An option may carry a `leading` node — a face beside a person's name — shown in the trigger and in the list. Ship's addition. */
|
|
58
|
+
export const WithLeading: Story = {
|
|
59
|
+
render: (args) => (
|
|
60
|
+
<Demo
|
|
61
|
+
{...args}
|
|
62
|
+
ariaLabel="Assignee"
|
|
63
|
+
initial="ana"
|
|
64
|
+
size="small"
|
|
65
|
+
options={[
|
|
66
|
+
{ value: '', label: 'Unassigned' },
|
|
67
|
+
...['Ana Duarte', 'Ravi Mehta', 'Mara Sato'].map((name) => ({ value: name.split(' ')[0].toLowerCase(), label: name, leading: <Avatar name={name} size={16} /> })),
|
|
68
|
+
]}
|
|
69
|
+
/>
|
|
70
|
+
),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** In a `Field`, which is how a select appears in a form. */
|
|
74
|
+
export const InAField: Story = {
|
|
75
|
+
render: (args) => (
|
|
76
|
+
<div className="flex flex-col gap-4">
|
|
77
|
+
<Field label="Status">
|
|
78
|
+
<Demo {...args} initial="todo" />
|
|
79
|
+
</Field>
|
|
80
|
+
<Field label="Title" required>
|
|
81
|
+
<TextInput placeholder="What is this about?" />
|
|
82
|
+
</Field>
|
|
83
|
+
</div>
|
|
84
|
+
),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Unavailable — the same disabled treatment as every other control. Say why, with a tooltip around it. */
|
|
88
|
+
export const Disabled: Story = { render: (args) => <Demo {...args} initial="done" disabled /> }
|
|
89
|
+
|
|
90
|
+
/** Longer labels than the trigger is wide: the trigger truncates rather than growing; the menu takes the trigger's width as a minimum and grows past it. */
|
|
91
|
+
export const LongLabels: Story = {
|
|
92
|
+
render: (args) => (
|
|
93
|
+
<Demo
|
|
94
|
+
{...args}
|
|
95
|
+
initial="a"
|
|
96
|
+
options={[
|
|
97
|
+
{ value: 'a', label: 'Everything that has ever happened in this workspace' },
|
|
98
|
+
{ value: 'b', label: 'Only the things an administrator did' },
|
|
99
|
+
]}
|
|
100
|
+
/>
|
|
101
|
+
),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Enough options to scroll — the menu caps its height rather than running off. */
|
|
105
|
+
export const ManyOptions: Story = {
|
|
106
|
+
render: (args) => <Demo {...args} initial="p3" options={Array.from({ length: 20 }, (_, i) => ({ value: `p${i}`, label: `Project ${i + 1}` }))} />,
|
|
107
|
+
}
|
package/src/Select.tsx
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { IconCheck, IconChevronDown } from '@tabler/icons-react'
|
|
2
|
+
import { useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
|
|
3
|
+
import { createPortal } from 'react-dom'
|
|
4
|
+
import { cn } from './cn'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Peek's Select (2026-08-28), verbatim, plus what Ship added: an option may
|
|
8
|
+
* carry a `leading` node — an avatar beside a person's name — shown in the
|
|
9
|
+
* trigger and in the list.
|
|
10
|
+
*
|
|
11
|
+
* A button that opens a portalled listbox under itself: arrows move, Home
|
|
12
|
+
* and End jump, Enter and Space pick, Escape closes and returns focus to the
|
|
13
|
+
* trigger, Tab closes, a click outside closes, a scroll or resize closes
|
|
14
|
+
* (the list is fixed to where the trigger was). Two sizes; `disabled`
|
|
15
|
+
* explains nothing by itself — wrap it in a tooltip that does.
|
|
16
|
+
*/
|
|
17
|
+
export interface SelectOption {
|
|
18
|
+
value: string
|
|
19
|
+
label: string
|
|
20
|
+
/** Drawn before the label, 16px — an Avatar, an icon. */
|
|
21
|
+
leading?: ReactNode
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SelectProps {
|
|
25
|
+
value: string
|
|
26
|
+
onChange: (value: string) => void
|
|
27
|
+
options: SelectOption[]
|
|
28
|
+
size?: 'default' | 'small'
|
|
29
|
+
ariaLabel?: string
|
|
30
|
+
placeholder?: string
|
|
31
|
+
disabled?: boolean
|
|
32
|
+
className?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function Select({ value, onChange, options, size = 'default', ariaLabel, placeholder = 'Select…', disabled, className }: SelectProps) {
|
|
36
|
+
const id = useId()
|
|
37
|
+
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
38
|
+
const menuRef = useRef<HTMLDivElement>(null)
|
|
39
|
+
const [rect, setRect] = useState<DOMRect | null>(null)
|
|
40
|
+
const open = rect !== null
|
|
41
|
+
|
|
42
|
+
const selectedIndex = useMemo(() => Math.max(0, options.findIndex((o) => o.value === value)), [options, value])
|
|
43
|
+
const [activeIndex, setActiveIndex] = useState(selectedIndex)
|
|
44
|
+
|
|
45
|
+
const close = useCallback((refocus: boolean) => {
|
|
46
|
+
setRect(null)
|
|
47
|
+
if (refocus) triggerRef.current?.focus()
|
|
48
|
+
}, [])
|
|
49
|
+
|
|
50
|
+
const openMenu = useCallback(() => {
|
|
51
|
+
setActiveIndex(selectedIndex)
|
|
52
|
+
setRect(triggerRef.current?.getBoundingClientRect() ?? null)
|
|
53
|
+
}, [selectedIndex])
|
|
54
|
+
|
|
55
|
+
// Outside click and Escape — the two exits every menu has. `mousedown`
|
|
56
|
+
// rather than `click`, so a press that starts outside dismisses at once.
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!open) return
|
|
59
|
+
const onDown = (e: MouseEvent) => {
|
|
60
|
+
if (menuRef.current?.contains(e.target as Node)) return
|
|
61
|
+
if (triggerRef.current?.contains(e.target as Node)) return
|
|
62
|
+
setRect(null)
|
|
63
|
+
}
|
|
64
|
+
const onMove = () => setRect(null)
|
|
65
|
+
document.addEventListener('mousedown', onDown)
|
|
66
|
+
window.addEventListener('resize', onMove)
|
|
67
|
+
window.addEventListener('scroll', onMove, true)
|
|
68
|
+
return () => {
|
|
69
|
+
document.removeEventListener('mousedown', onDown)
|
|
70
|
+
window.removeEventListener('resize', onMove)
|
|
71
|
+
window.removeEventListener('scroll', onMove, true)
|
|
72
|
+
}
|
|
73
|
+
}, [open])
|
|
74
|
+
|
|
75
|
+
const pick = (index: number) => {
|
|
76
|
+
const option = options[index]
|
|
77
|
+
if (!option) return
|
|
78
|
+
onChange(option.value)
|
|
79
|
+
close(true)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
83
|
+
if (!open) {
|
|
84
|
+
if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) {
|
|
85
|
+
e.preventDefault()
|
|
86
|
+
openMenu()
|
|
87
|
+
}
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
switch (e.key) {
|
|
91
|
+
case 'Escape':
|
|
92
|
+
e.preventDefault()
|
|
93
|
+
close(true)
|
|
94
|
+
break
|
|
95
|
+
case 'ArrowDown':
|
|
96
|
+
e.preventDefault()
|
|
97
|
+
setActiveIndex((i) => Math.min(options.length - 1, i + 1))
|
|
98
|
+
break
|
|
99
|
+
case 'ArrowUp':
|
|
100
|
+
e.preventDefault()
|
|
101
|
+
setActiveIndex((i) => Math.max(0, i - 1))
|
|
102
|
+
break
|
|
103
|
+
case 'Home':
|
|
104
|
+
e.preventDefault()
|
|
105
|
+
setActiveIndex(0)
|
|
106
|
+
break
|
|
107
|
+
case 'End':
|
|
108
|
+
e.preventDefault()
|
|
109
|
+
setActiveIndex(options.length - 1)
|
|
110
|
+
break
|
|
111
|
+
case 'Enter':
|
|
112
|
+
case ' ':
|
|
113
|
+
e.preventDefault()
|
|
114
|
+
pick(activeIndex)
|
|
115
|
+
break
|
|
116
|
+
case 'Tab':
|
|
117
|
+
close(false)
|
|
118
|
+
break
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const selected = options.find((o) => o.value === value)
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<>
|
|
126
|
+
<button
|
|
127
|
+
ref={triggerRef}
|
|
128
|
+
type="button"
|
|
129
|
+
disabled={disabled}
|
|
130
|
+
aria-haspopup="listbox"
|
|
131
|
+
aria-expanded={open}
|
|
132
|
+
aria-label={ariaLabel}
|
|
133
|
+
onClick={() => (open ? close(false) : openMenu())}
|
|
134
|
+
onKeyDown={onKeyDown}
|
|
135
|
+
className={cn(
|
|
136
|
+
'flex w-full items-center justify-between gap-2 rounded-lg border bg-bg-inset text-left',
|
|
137
|
+
'border-border-default text-text-primary outline-none transition-colors',
|
|
138
|
+
'hover:border-border-strong disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled',
|
|
139
|
+
'focus-visible:border-border-focus aria-expanded:border-border-focus',
|
|
140
|
+
'signal:transition-shadow signal:focus-visible:shadow-focus-ring',
|
|
141
|
+
size === 'default' && 'px-3 py-2 text-[14px] leading-[1.4] font-normal',
|
|
142
|
+
size === 'small' && 'h-6 px-2 text-[12px] leading-[1.4] font-normal',
|
|
143
|
+
className,
|
|
144
|
+
)}
|
|
145
|
+
>
|
|
146
|
+
<span className={cn('flex min-w-0 items-center gap-2', !selected && 'text-text-muted')}>
|
|
147
|
+
{selected?.leading && <span className="flex shrink-0 items-center">{selected.leading}</span>}
|
|
148
|
+
<span className="truncate">{selected?.label ?? placeholder}</span>
|
|
149
|
+
</span>
|
|
150
|
+
<IconChevronDown size={size === 'small' ? 14 : 16} stroke={1.5} className="shrink-0 text-text-secondary" />
|
|
151
|
+
</button>
|
|
152
|
+
|
|
153
|
+
{open &&
|
|
154
|
+
rect &&
|
|
155
|
+
createPortal(
|
|
156
|
+
<div
|
|
157
|
+
ref={menuRef}
|
|
158
|
+
role="listbox"
|
|
159
|
+
aria-activedescendant={`${id}-${activeIndex}`}
|
|
160
|
+
tabIndex={-1}
|
|
161
|
+
style={{ top: rect.bottom + 4, left: rect.left, minWidth: rect.width }}
|
|
162
|
+
className="fixed z-50 max-h-72 overflow-y-auto rounded-lg border border-border-default bg-bg-elevated p-1 shadow-lg"
|
|
163
|
+
>
|
|
164
|
+
{options.map((option, index) => (
|
|
165
|
+
<div
|
|
166
|
+
key={option.value}
|
|
167
|
+
id={`${id}-${index}`}
|
|
168
|
+
role="option"
|
|
169
|
+
aria-selected={option.value === value}
|
|
170
|
+
onMouseEnter={() => setActiveIndex(index)}
|
|
171
|
+
onMouseDown={(e) => {
|
|
172
|
+
// The document-level dismiss also listens on mousedown.
|
|
173
|
+
e.preventDefault()
|
|
174
|
+
pick(index)
|
|
175
|
+
}}
|
|
176
|
+
className={cn(
|
|
177
|
+
'flex h-9 cursor-pointer items-center justify-between gap-2 rounded-lg px-3 transition-colors',
|
|
178
|
+
'text-[14px] font-normal leading-[1.4] text-text-primary',
|
|
179
|
+
index === activeIndex && 'bg-bg-hover',
|
|
180
|
+
option.value === value && 'font-medium',
|
|
181
|
+
)}
|
|
182
|
+
>
|
|
183
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
184
|
+
{option.leading && <span className="flex shrink-0 items-center">{option.leading}</span>}
|
|
185
|
+
<span className="truncate">{option.label}</span>
|
|
186
|
+
</span>
|
|
187
|
+
{option.value === value && <IconCheck size={16} stroke={1.5} className="shrink-0 text-text-secondary" />}
|
|
188
|
+
</div>
|
|
189
|
+
))}
|
|
190
|
+
</div>,
|
|
191
|
+
document.body,
|
|
192
|
+
)}
|
|
193
|
+
</>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { SkeletonBar, SkeletonList, SkeletonRow } from './Skeleton'
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Feedback/Skeleton',
|
|
6
|
+
component: SkeletonList,
|
|
7
|
+
} satisfies Meta<typeof SkeletonList>
|
|
8
|
+
|
|
9
|
+
export default meta
|
|
10
|
+
type Story = StoryObj<typeof meta>
|
|
11
|
+
|
|
12
|
+
/** A list placeholder — reveals after a 150ms delay so fast loads never flash it. */
|
|
13
|
+
export const List: Story = { decorators: [(Story) => <div className="w-[266px]"><Story /></div>] }
|
|
14
|
+
|
|
15
|
+
/** A single row-shaped placeholder. */
|
|
16
|
+
export const Row: Story = {
|
|
17
|
+
render: () => (
|
|
18
|
+
<div className="w-[266px]">
|
|
19
|
+
<SkeletonRow barWidth={140} />
|
|
20
|
+
</div>
|
|
21
|
+
),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The raw building block — size it per use. */
|
|
25
|
+
export const Bar: Story = {
|
|
26
|
+
render: () => (
|
|
27
|
+
<div className="flex w-[266px] flex-col gap-2">
|
|
28
|
+
<SkeletonBar className="h-3.5 w-40" />
|
|
29
|
+
<SkeletonBar className="h-3 w-24" />
|
|
30
|
+
<SkeletonBar className="h-6 w-6" />
|
|
31
|
+
</div>
|
|
32
|
+
),
|
|
33
|
+
}
|
package/src/Skeleton.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Peek's Skeleton (2026-08-28), the generic parts only: the bar, a row
|
|
6
|
+
* shaped like a list row, and a list of them. Peek's placeholders shaped
|
|
7
|
+
* like a conversation card or a huddle card know what those are and stay
|
|
8
|
+
* in Peek, built from the bar.
|
|
9
|
+
*
|
|
10
|
+
* A list reveals after 150ms (`animate-skeleton-in`, in the preset) so a
|
|
11
|
+
* fast load never flashes a skeleton — Peek's rule.
|
|
12
|
+
*/
|
|
13
|
+
export function SkeletonBar({ className, style }: { className?: string; style?: CSSProperties }) {
|
|
14
|
+
return <div className={cn('bg-bg-inset rounded-sm animate-pulse', className)} style={style} />
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ROW_BAR_WIDTHS = [150, 100, 170, 120, 90, 140, 110, 160]
|
|
18
|
+
|
|
19
|
+
/** A 32px row: a 16px square and a bar, like a row with a face and a name. */
|
|
20
|
+
export function SkeletonRow({ barWidth = 130 }: { barWidth?: number }) {
|
|
21
|
+
return (
|
|
22
|
+
<div className="flex items-center gap-2 px-2 h-[32px] rounded-lg">
|
|
23
|
+
<SkeletonBar className="w-4 h-4 shrink-0" />
|
|
24
|
+
<SkeletonBar className="h-3.5" style={{ width: barWidth }} />
|
|
25
|
+
</div>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Rows of varied widths, so the placeholder does not read as a pattern. */
|
|
30
|
+
export function SkeletonList({ rows = 8, className }: { rows?: number; className?: string }) {
|
|
31
|
+
return (
|
|
32
|
+
<div className={cn('flex flex-col gap-0.5 animate-skeleton-in', className)} aria-hidden>
|
|
33
|
+
{Array.from({ length: rows }, (_, i) => (
|
|
34
|
+
<SkeletonRow key={i} barWidth={ROW_BAR_WIDTHS[i % ROW_BAR_WIDTHS.length]} />
|
|
35
|
+
))}
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { TextInput } from './TextInput'
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Inputs/TextInput',
|
|
6
|
+
component: TextInput,
|
|
7
|
+
parameters: { layout: 'padded' },
|
|
8
|
+
args: { placeholder: 'What is this about?' },
|
|
9
|
+
// Inputs stretch to fill their container (as inside a Field).
|
|
10
|
+
decorators: [(Story) => <div className="flex w-[360px] flex-col"><Story /></div>],
|
|
11
|
+
} satisfies Meta<typeof TextInput>
|
|
12
|
+
|
|
13
|
+
export default meta
|
|
14
|
+
type Story = StoryObj<typeof meta>
|
|
15
|
+
|
|
16
|
+
export const Empty: Story = {}
|
|
17
|
+
export const Filled: Story = { args: { defaultValue: 'Q3 launch planning' } }
|
|
18
|
+
export const Disabled: Story = { args: { defaultValue: 'Read only', disabled: true } }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { forwardRef, type InputHTMLAttributes } from 'react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Peek's TextInput (2026-08-28): the inset field with a 8px radius, 14px
|
|
6
|
+
* text, the focus border in every theme (Katerina, 2026-08-28: Ship's inputs
|
|
7
|
+
* focus like Signal's) — and Signal's glow on top.
|
|
8
|
+
* Plus a disabled look (Ship's addition): the disabled surface and text,
|
|
9
|
+
* no pointer.
|
|
10
|
+
*/
|
|
11
|
+
export type TextInputProps = InputHTMLAttributes<HTMLInputElement>
|
|
12
|
+
|
|
13
|
+
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput({ className, type = 'text', ...props }, ref) {
|
|
14
|
+
return (
|
|
15
|
+
<input
|
|
16
|
+
ref={ref}
|
|
17
|
+
type={type}
|
|
18
|
+
className={cn(
|
|
19
|
+
'bg-bg-inset border border-border-default focus:border-border-focus rounded-lg px-3 py-2',
|
|
20
|
+
'text-[14px] leading-[1.4] font-normal text-text-primary placeholder:text-text-muted',
|
|
21
|
+
'outline-none transition-colors',
|
|
22
|
+
'disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled',
|
|
23
|
+
'signal:transition-shadow signal:focus:shadow-focus-ring',
|
|
24
|
+
className,
|
|
25
|
+
)}
|
|
26
|
+
{...props}
|
|
27
|
+
/>
|
|
28
|
+
)
|
|
29
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { Textarea } from './Textarea'
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Inputs/Textarea',
|
|
6
|
+
component: Textarea,
|
|
7
|
+
parameters: { layout: 'padded' },
|
|
8
|
+
args: { placeholder: 'Summarise the outcome or decision…', className: 'h-[109px]' },
|
|
9
|
+
decorators: [(Story) => <div className="flex w-[360px] flex-col"><Story /></div>],
|
|
10
|
+
} satisfies Meta<typeof Textarea>
|
|
11
|
+
|
|
12
|
+
export default meta
|
|
13
|
+
type Story = StoryObj<typeof meta>
|
|
14
|
+
|
|
15
|
+
export const Empty: Story = {}
|
|
16
|
+
export const Filled: Story = { args: { defaultValue: 'Shipped the fix in build #482; verified with QA on staging.' } }
|
|
17
|
+
export const Disabled: Story = { args: { defaultValue: 'Read only', disabled: true } }
|
package/src/Textarea.tsx
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { forwardRef, type TextareaHTMLAttributes } from 'react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Peek's Textarea (2026-08-28), verbatim: TextInput's look on a textarea
|
|
6
|
+
* that does not resize. Plus a disabled look (Ship's addition).
|
|
7
|
+
*/
|
|
8
|
+
export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>
|
|
9
|
+
|
|
10
|
+
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea({ className, ...props }, ref) {
|
|
11
|
+
return (
|
|
12
|
+
<textarea
|
|
13
|
+
ref={ref}
|
|
14
|
+
className={cn(
|
|
15
|
+
'bg-bg-inset border border-border-default focus:border-border-focus rounded-lg px-3 py-2',
|
|
16
|
+
'text-[14px] leading-[1.4] font-normal text-text-primary placeholder:text-text-muted',
|
|
17
|
+
'resize-none outline-none transition-colors',
|
|
18
|
+
'disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled',
|
|
19
|
+
'signal:transition-shadow signal:focus:shadow-focus-ring',
|
|
20
|
+
className,
|
|
21
|
+
)}
|
|
22
|
+
{...props}
|
|
23
|
+
/>
|
|
24
|
+
)
|
|
25
|
+
})
|