@proteos/ui 0.31.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 +105 -0
- package/dist/chunk-IKAX76MB.js +113 -0
- package/dist/chunk-IKAX76MB.js.map +1 -0
- package/dist/chunk-MWAXK66H.cjs +119 -0
- package/dist/chunk-MWAXK66H.cjs.map +1 -0
- package/dist/index.cjs +6246 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2126 -0
- package/dist/index.d.ts +2126 -0
- package/dist/index.js +6069 -0
- package/dist/index.js.map +1 -0
- package/dist/pdf-view-3ZMMS6ML.cjs +95 -0
- package/dist/pdf-view-3ZMMS6ML.cjs.map +1 -0
- package/dist/pdf-view-BV5ZD6OC.js +93 -0
- package/dist/pdf-view-BV5ZD6OC.js.map +1 -0
- package/package.json +96 -0
- package/src/components/attachment-chip/attachment-chip.tsx +129 -0
- package/src/components/attachment-chip/index.ts +2 -0
- package/src/components/attribute-type-icon/attribute-type-icon.stories.tsx +70 -0
- package/src/components/attribute-type-icon/attribute-type-icon.tsx +125 -0
- package/src/components/attribute-type-icon/index.ts +8 -0
- package/src/components/avatar/avatar.stories.tsx +42 -0
- package/src/components/avatar/avatar.tsx +48 -0
- package/src/components/avatar/index.ts +2 -0
- package/src/components/avatar-group/avatar-group.test.tsx +38 -0
- package/src/components/avatar-group/avatar-group.tsx +60 -0
- package/src/components/avatar-group/index.ts +2 -0
- package/src/components/badge/badge.stories.tsx +62 -0
- package/src/components/badge/badge.test.tsx +36 -0
- package/src/components/badge/badge.tsx +54 -0
- package/src/components/badge/index.ts +2 -0
- package/src/components/badge-multi-select/badge-multi-select.stories.tsx +177 -0
- package/src/components/badge-multi-select/badge-multi-select.test.tsx +155 -0
- package/src/components/badge-multi-select/badge-multi-select.tsx +272 -0
- package/src/components/badge-multi-select/index.ts +2 -0
- package/src/components/breadcrumbs/breadcrumbs.stories.tsx +43 -0
- package/src/components/breadcrumbs/breadcrumbs.tsx +86 -0
- package/src/components/breadcrumbs/index.ts +2 -0
- package/src/components/button/button.stories.tsx +290 -0
- package/src/components/button/button.test.tsx +59 -0
- package/src/components/button/button.tsx +86 -0
- package/src/components/button/index.ts +2 -0
- package/src/components/card/card.stories.tsx +351 -0
- package/src/components/card/card.test.tsx +141 -0
- package/src/components/card/card.tsx +173 -0
- package/src/components/card/index.ts +13 -0
- package/src/components/checkbox/checkbox-group.tsx +52 -0
- package/src/components/checkbox/checkbox.stories.tsx +48 -0
- package/src/components/checkbox/checkbox.test.tsx +50 -0
- package/src/components/checkbox/checkbox.tsx +71 -0
- package/src/components/checkbox/index.ts +4 -0
- package/src/components/combobox/combobox.stories.tsx +97 -0
- package/src/components/combobox/combobox.test.tsx +123 -0
- package/src/components/combobox/combobox.tsx +239 -0
- package/src/components/combobox/index.ts +2 -0
- package/src/components/command/command-hint.tsx +46 -0
- package/src/components/command/command.stories.tsx +88 -0
- package/src/components/command/command.test.tsx +102 -0
- package/src/components/command/command.tsx +76 -0
- package/src/components/command/index.ts +16 -0
- package/src/components/currency-input/currency-input.tsx +192 -0
- package/src/components/currency-input/index.ts +6 -0
- package/src/components/data-table/cells.stories.tsx +120 -0
- package/src/components/data-table/cells.tsx +165 -0
- package/src/components/data-table/data-table.stories.tsx +371 -0
- package/src/components/data-table/data-table.tsx +556 -0
- package/src/components/data-table/index.ts +22 -0
- package/src/components/data-table/make-actions-column.ts +30 -0
- package/src/components/data-table/states/data-table-empty.tsx +40 -0
- package/src/components/data-table/states/data-table-error.tsx +30 -0
- package/src/components/data-table/states/data-table-skeleton.tsx +42 -0
- package/src/components/data-table/use-infinite-scroll-sentinel.ts +51 -0
- package/src/components/date-picker/calendar-internals.tsx +138 -0
- package/src/components/date-picker/date-picker.stories.tsx +78 -0
- package/src/components/date-picker/date-picker.test.tsx +71 -0
- package/src/components/date-picker/date-picker.tsx +140 -0
- package/src/components/date-picker/index.ts +2 -0
- package/src/components/date-time-picker/date-time-picker.stories.tsx +96 -0
- package/src/components/date-time-picker/date-time-picker.test.tsx +87 -0
- package/src/components/date-time-picker/date-time-picker.tsx +203 -0
- package/src/components/date-time-picker/index.ts +2 -0
- package/src/components/empty-state/empty-state.tsx +63 -0
- package/src/components/empty-state/index.ts +2 -0
- package/src/components/error-animation/error-animation.stories.tsx +50 -0
- package/src/components/error-animation/error-animation.test.tsx +22 -0
- package/src/components/error-animation/error-animation.tsx +26 -0
- package/src/components/error-animation/index.ts +2 -0
- package/src/components/field/field.tsx +142 -0
- package/src/components/field/index.ts +8 -0
- package/src/components/file-field/file-field.tsx +285 -0
- package/src/components/file-field/index.ts +2 -0
- package/src/components/file-viewer/fallback-card.tsx +68 -0
- package/src/components/file-viewer/file-viewer.stories.tsx +67 -0
- package/src/components/file-viewer/file-viewer.test.tsx +81 -0
- package/src/components/file-viewer/file-viewer.tsx +116 -0
- package/src/components/file-viewer/image-view.tsx +38 -0
- package/src/components/file-viewer/index.ts +7 -0
- package/src/components/file-viewer/pdf-view.tsx +122 -0
- package/src/components/file-viewer/video-view.tsx +16 -0
- package/src/components/image-attachment/image-attachment.stories.tsx +39 -0
- package/src/components/image-attachment/image-attachment.test.tsx +54 -0
- package/src/components/image-attachment/image-attachment.tsx +77 -0
- package/src/components/image-attachment/index.ts +3 -0
- package/src/components/image-attachment/is-previewable-image-name.ts +14 -0
- package/src/components/image-lightbox/image-lightbox.stories.tsx +38 -0
- package/src/components/image-lightbox/image-lightbox.test.tsx +72 -0
- package/src/components/image-lightbox/image-lightbox.tsx +92 -0
- package/src/components/image-lightbox/index.ts +2 -0
- package/src/components/input/index.ts +2 -0
- package/src/components/input/input.stories.tsx +71 -0
- package/src/components/input/input.test.tsx +36 -0
- package/src/components/input/input.tsx +76 -0
- package/src/components/kbd/index.ts +2 -0
- package/src/components/kbd/kbd.stories.tsx +34 -0
- package/src/components/kbd/kbd.tsx +27 -0
- package/src/components/menu/index.ts +2 -0
- package/src/components/menu/menu.tsx +121 -0
- package/src/components/modal/index.ts +10 -0
- package/src/components/modal/modal.stories.tsx +153 -0
- package/src/components/modal/modal.test.tsx +93 -0
- package/src/components/modal/modal.tsx +149 -0
- package/src/components/multi-select/index.ts +2 -0
- package/src/components/multi-select/multi-select.stories.tsx +211 -0
- package/src/components/multi-select/multi-select.test.tsx +151 -0
- package/src/components/multi-select/multi-select.tsx +339 -0
- package/src/components/nav/index.ts +7 -0
- package/src/components/nav/nav.stories.tsx +139 -0
- package/src/components/nav/nav.tsx +246 -0
- package/src/components/popover/index.ts +8 -0
- package/src/components/popover/popover.stories.tsx +92 -0
- package/src/components/popover/popover.tsx +135 -0
- package/src/components/progress-bar/index.ts +2 -0
- package/src/components/progress-bar/progress-bar.stories.tsx +75 -0
- package/src/components/progress-bar/progress-bar.test.tsx +29 -0
- package/src/components/progress-bar/progress-bar.tsx +57 -0
- package/src/components/radio-group/index.ts +2 -0
- package/src/components/radio-group/radio-group.stories.tsx +113 -0
- package/src/components/radio-group/radio-group.test.tsx +87 -0
- package/src/components/radio-group/radio-group.tsx +104 -0
- package/src/components/record-filter/attribute-picker.tsx +74 -0
- package/src/components/record-filter/chip-value.tsx +171 -0
- package/src/components/record-filter/filter-row.tsx +262 -0
- package/src/components/record-filter/filter-value-input.tsx +351 -0
- package/src/components/record-filter/group-add-controls.tsx +77 -0
- package/src/components/record-filter/group-body.tsx +173 -0
- package/src/components/record-filter/group-mutations.test.ts +104 -0
- package/src/components/record-filter/group-mutations.ts +116 -0
- package/src/components/record-filter/index.ts +58 -0
- package/src/components/record-filter/nested-group.tsx +138 -0
- package/src/components/record-filter/operator-picker.tsx +62 -0
- package/src/components/record-filter/operators.test.ts +105 -0
- package/src/components/record-filter/operators.ts +97 -0
- package/src/components/record-filter/record-filter-builder-panel.tsx +236 -0
- package/src/components/record-filter/record-filter-button.tsx +126 -0
- package/src/components/record-filter/record-filter-chips.tsx +527 -0
- package/src/components/record-filter/record-filter-editor.tsx +91 -0
- package/src/components/record-filter/record-filter.stories.tsx +196 -0
- package/src/components/record-filter/record-filter.test.tsx +437 -0
- package/src/components/record-filter/show-details-store.ts +72 -0
- package/src/components/record-filter/types.ts +150 -0
- package/src/components/segmented-control/index.ts +6 -0
- package/src/components/segmented-control/segmented-control.stories.tsx +67 -0
- package/src/components/segmented-control/segmented-control.test.tsx +47 -0
- package/src/components/segmented-control/segmented-control.tsx +105 -0
- package/src/components/select/index.ts +2 -0
- package/src/components/select/select.stories.tsx +76 -0
- package/src/components/select/select.test.tsx +56 -0
- package/src/components/select/select.tsx +159 -0
- package/src/components/separator/index.ts +2 -0
- package/src/components/separator/separator.stories.tsx +73 -0
- package/src/components/separator/separator.test.tsx +29 -0
- package/src/components/separator/separator.tsx +51 -0
- package/src/components/skeleton/index.ts +2 -0
- package/src/components/skeleton/skeleton.stories.tsx +68 -0
- package/src/components/skeleton/skeleton.test.tsx +38 -0
- package/src/components/skeleton/skeleton.tsx +43 -0
- package/src/components/success-animation/index.ts +2 -0
- package/src/components/success-animation/success-animation.stories.tsx +50 -0
- package/src/components/success-animation/success-animation.test.tsx +22 -0
- package/src/components/success-animation/success-animation.tsx +26 -0
- package/src/components/switch/index.ts +2 -0
- package/src/components/switch/switch.stories.tsx +60 -0
- package/src/components/switch/switch.test.tsx +50 -0
- package/src/components/switch/switch.tsx +49 -0
- package/src/components/table/index.ts +25 -0
- package/src/components/table/table.stories.tsx +306 -0
- package/src/components/table/table.test.tsx +115 -0
- package/src/components/table/table.tsx +543 -0
- package/src/components/tabs/index.ts +8 -0
- package/src/components/tabs/tabs.tsx +141 -0
- package/src/components/tag-input/index.ts +2 -0
- package/src/components/tag-input/tag-input.stories.tsx +73 -0
- package/src/components/tag-input/tag-input.test.tsx +80 -0
- package/src/components/tag-input/tag-input.tsx +193 -0
- package/src/components/textarea/index.ts +2 -0
- package/src/components/textarea/textarea.stories.tsx +75 -0
- package/src/components/textarea/textarea.test.tsx +41 -0
- package/src/components/textarea/textarea.tsx +86 -0
- package/src/components/time-field/index.ts +2 -0
- package/src/components/time-field/time-field.stories.tsx +76 -0
- package/src/components/time-field/time-field.test.tsx +51 -0
- package/src/components/time-field/time-field.tsx +98 -0
- package/src/components/tooltip/index.ts +2 -0
- package/src/components/tooltip/tooltip.stories.tsx +176 -0
- package/src/components/tooltip/tooltip.test.tsx +52 -0
- package/src/components/tooltip/tooltip.tsx +56 -0
- package/src/index.ts +47 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/commands/discovery-store.test.tsx +76 -0
- package/src/lib/commands/discovery-store.ts +110 -0
- package/src/lib/commands/index.ts +9 -0
- package/src/lib/commands/match-event.test.ts +99 -0
- package/src/lib/commands/match-event.ts +31 -0
- package/src/lib/commands/parse-combo.test.ts +65 -0
- package/src/lib/commands/parse-combo.ts +48 -0
- package/src/lib/commands/platform.ts +27 -0
- package/src/lib/commands/protected-combos.test.ts +18 -0
- package/src/lib/commands/protected-combos.ts +38 -0
- package/src/lib/commands/registry.test.ts +97 -0
- package/src/lib/commands/registry.ts +59 -0
- package/src/lib/commands/use-command.test.tsx +91 -0
- package/src/lib/commands/use-command.ts +32 -0
- package/src/lib/commands/use-escape-key.test.tsx +97 -0
- package/src/lib/commands/use-escape-key.ts +62 -0
- package/src/styles.css +321 -0
- package/src/test/setup.ts +9 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import { TagInput } from './tag-input'
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof TagInput> = {
|
|
7
|
+
title: 'Components/TagInput',
|
|
8
|
+
component: TagInput,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
decorators: [
|
|
11
|
+
(Story) => (
|
|
12
|
+
<div className="w-[440px]">
|
|
13
|
+
<Story />
|
|
14
|
+
</div>
|
|
15
|
+
),
|
|
16
|
+
],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default meta
|
|
20
|
+
type Story = StoryObj<typeof TagInput>
|
|
21
|
+
|
|
22
|
+
export const Empty: Story = {
|
|
23
|
+
render: () => {
|
|
24
|
+
const [value, setValue] = useState<string[]>([])
|
|
25
|
+
return (
|
|
26
|
+
<TagInput label="Tags" placeholder="Type, Enter to add" value={value} onChange={setValue} />
|
|
27
|
+
)
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const WithValues: Story = {
|
|
32
|
+
render: () => {
|
|
33
|
+
const [value, setValue] = useState<string[]>(['priority', 'beta', 'enterprise'])
|
|
34
|
+
return (
|
|
35
|
+
<TagInput
|
|
36
|
+
label="Tags"
|
|
37
|
+
description="Press Enter or ',' to add. Backspace on empty input removes the last tag."
|
|
38
|
+
value={value}
|
|
39
|
+
onChange={setValue}
|
|
40
|
+
/>
|
|
41
|
+
)
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const Numeric: Story = {
|
|
46
|
+
render: () => {
|
|
47
|
+
const [value, setValue] = useState<string[]>(['100', '200', '500'])
|
|
48
|
+
return (
|
|
49
|
+
<TagInput label="Threshold values" inputType="number" value={value} onChange={setValue} />
|
|
50
|
+
)
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const Invalid: Story = {
|
|
55
|
+
render: () => {
|
|
56
|
+
const [value, setValue] = useState<string[]>([])
|
|
57
|
+
return (
|
|
58
|
+
<TagInput
|
|
59
|
+
label="Tags"
|
|
60
|
+
value={value}
|
|
61
|
+
onChange={setValue}
|
|
62
|
+
isInvalid
|
|
63
|
+
errorMessage="Add at least one tag."
|
|
64
|
+
/>
|
|
65
|
+
)
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const Disabled: Story = {
|
|
70
|
+
render: () => (
|
|
71
|
+
<TagInput label="Tags" value={['locked', 'frozen']} onChange={() => {}} isDisabled />
|
|
72
|
+
),
|
|
73
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { TagInput } from './tag-input'
|
|
7
|
+
|
|
8
|
+
function Harness({
|
|
9
|
+
initial = [],
|
|
10
|
+
onChange,
|
|
11
|
+
}: {
|
|
12
|
+
initial?: string[]
|
|
13
|
+
onChange?: (next: string[]) => void
|
|
14
|
+
}) {
|
|
15
|
+
const [value, setValue] = useState<string[]>(initial)
|
|
16
|
+
return (
|
|
17
|
+
<TagInput
|
|
18
|
+
label="Tags"
|
|
19
|
+
placeholder="Type, Enter to add"
|
|
20
|
+
value={value}
|
|
21
|
+
onChange={(next) => {
|
|
22
|
+
setValue(next)
|
|
23
|
+
onChange?.(next)
|
|
24
|
+
}}
|
|
25
|
+
/>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('TagInput', () => {
|
|
30
|
+
it('commits a tag on Enter', async () => {
|
|
31
|
+
const user = userEvent.setup()
|
|
32
|
+
const onChange = vi.fn()
|
|
33
|
+
render(<Harness onChange={onChange} />)
|
|
34
|
+
|
|
35
|
+
const input = screen.getByPlaceholderText('Type, Enter to add')
|
|
36
|
+
await user.type(input, 'alpha{Enter}')
|
|
37
|
+
|
|
38
|
+
expect(onChange).toHaveBeenLastCalledWith(['alpha'])
|
|
39
|
+
expect(screen.getByText('alpha')).toBeInTheDocument()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('commits on comma', async () => {
|
|
43
|
+
const user = userEvent.setup()
|
|
44
|
+
const onChange = vi.fn()
|
|
45
|
+
render(<Harness onChange={onChange} />)
|
|
46
|
+
|
|
47
|
+
await user.type(screen.getByPlaceholderText('Type, Enter to add'), 'one,')
|
|
48
|
+
expect(onChange).toHaveBeenLastCalledWith(['one'])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('removes the last tag on Backspace when input is empty', async () => {
|
|
52
|
+
const user = userEvent.setup()
|
|
53
|
+
const onChange = vi.fn()
|
|
54
|
+
render(<Harness initial={['one', 'two']} onChange={onChange} />)
|
|
55
|
+
|
|
56
|
+
const input = screen.getByRole('textbox')
|
|
57
|
+
await user.click(input)
|
|
58
|
+
await user.keyboard('{Backspace}')
|
|
59
|
+
|
|
60
|
+
expect(onChange).toHaveBeenLastCalledWith(['one'])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('removes a tag via the × button', async () => {
|
|
64
|
+
const user = userEvent.setup()
|
|
65
|
+
const onChange = vi.fn()
|
|
66
|
+
render(<Harness initial={['alpha', 'beta']} onChange={onChange} />)
|
|
67
|
+
|
|
68
|
+
await user.click(screen.getByRole('button', { name: 'Remove alpha' }))
|
|
69
|
+
expect(onChange).toHaveBeenLastCalledWith(['beta'])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('does not commit duplicate tags', async () => {
|
|
73
|
+
const user = userEvent.setup()
|
|
74
|
+
const onChange = vi.fn()
|
|
75
|
+
render(<Harness initial={['alpha']} onChange={onChange} />)
|
|
76
|
+
|
|
77
|
+
await user.type(screen.getByRole('textbox'), 'alpha{Enter}')
|
|
78
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { type KeyboardEvent, type ReactNode, useEffect, useId, useRef, useState } from 'react'
|
|
2
|
+
import { cn } from '../../lib/cn'
|
|
3
|
+
import {
|
|
4
|
+
type FieldAppearance,
|
|
5
|
+
fieldChipPadding,
|
|
6
|
+
fieldControlStyles,
|
|
7
|
+
fieldErrorStyles,
|
|
8
|
+
fieldHintStyles,
|
|
9
|
+
fieldLabelStyles,
|
|
10
|
+
fieldMinHeight,
|
|
11
|
+
type FieldSize,
|
|
12
|
+
fieldTextStyles,
|
|
13
|
+
} from '../field/field'
|
|
14
|
+
|
|
15
|
+
/* =========================================================================
|
|
16
|
+
TagInput — free-form list of strings rendered as removable chips.
|
|
17
|
+
Used for filter `in`/`not_in` value entry on non-enum types where the
|
|
18
|
+
set of valid values isn't known up front.
|
|
19
|
+
|
|
20
|
+
Behaviour:
|
|
21
|
+
- Type into the trailing input → Enter or `,` commits as a tag.
|
|
22
|
+
- Backspace on empty input → removes the last tag.
|
|
23
|
+
- Click × on a chip → removes it.
|
|
24
|
+
|
|
25
|
+
Controlled API only — `value: string[]` + `onChange`.
|
|
26
|
+
========================================================================= */
|
|
27
|
+
|
|
28
|
+
export type TagInputProps = {
|
|
29
|
+
label?: ReactNode
|
|
30
|
+
description?: ReactNode
|
|
31
|
+
errorMessage?: ReactNode
|
|
32
|
+
/** Placeholder shown in the trailing input when there are no tags. */
|
|
33
|
+
placeholder?: string
|
|
34
|
+
/** Underlying `<input>` type. Use `'number'` to constrain numeric tags. */
|
|
35
|
+
inputType?: 'text' | 'number'
|
|
36
|
+
value: string[]
|
|
37
|
+
onChange: (next: string[]) => void
|
|
38
|
+
isDisabled?: boolean
|
|
39
|
+
isInvalid?: boolean
|
|
40
|
+
/** Field size. Defaults to `md`. */
|
|
41
|
+
size?: FieldSize
|
|
42
|
+
/** Chrome variant. Defaults to `field`. `cell` flattens the box. */
|
|
43
|
+
appearance?: FieldAppearance
|
|
44
|
+
/** Focus the trailing input on mount. Used by the filter-row chain so
|
|
45
|
+
* picking an operator with arity 'n' (and a non-enum field) lands focus
|
|
46
|
+
* directly on the tag-entry input. */
|
|
47
|
+
autoFocus?: boolean
|
|
48
|
+
/** Visual override for the field wrapper. */
|
|
49
|
+
className?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function TagInput({
|
|
53
|
+
label,
|
|
54
|
+
description,
|
|
55
|
+
errorMessage,
|
|
56
|
+
placeholder = 'Type, Enter to add',
|
|
57
|
+
inputType = 'text',
|
|
58
|
+
value,
|
|
59
|
+
onChange,
|
|
60
|
+
isDisabled = false,
|
|
61
|
+
isInvalid = false,
|
|
62
|
+
size = 'md',
|
|
63
|
+
appearance,
|
|
64
|
+
autoFocus = false,
|
|
65
|
+
className,
|
|
66
|
+
}: TagInputProps) {
|
|
67
|
+
const styles = fieldControlStyles({ size, invalid: isInvalid, appearance })
|
|
68
|
+
const focusWithin =
|
|
69
|
+
appearance === 'cell'
|
|
70
|
+
? 'focus-within:border-accent focus-within:shadow-none'
|
|
71
|
+
: 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]'
|
|
72
|
+
const minHeight = appearance === 'cell' ? 'min-h-[29px]' : fieldMinHeight[size]
|
|
73
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (autoFocus) inputRef.current?.focus()
|
|
77
|
+
// mount-only — autoFocus is a one-shot signal, not sustained.
|
|
78
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
79
|
+
}, [])
|
|
80
|
+
const inputId = useId()
|
|
81
|
+
const [draft, setDraft] = useState('')
|
|
82
|
+
|
|
83
|
+
const commit = () => {
|
|
84
|
+
const v = draft.trim()
|
|
85
|
+
if (!v) return
|
|
86
|
+
if (value.includes(v)) {
|
|
87
|
+
setDraft('')
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
onChange([...value, v])
|
|
91
|
+
setDraft('')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const remove = (idx: number) => {
|
|
95
|
+
onChange(value.filter((_, i) => i !== idx))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
99
|
+
if (e.key === 'Enter' || e.key === ',') {
|
|
100
|
+
e.preventDefault()
|
|
101
|
+
commit()
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
if (e.key === 'Backspace' && !draft && value.length > 0) {
|
|
105
|
+
e.preventDefault()
|
|
106
|
+
onChange(value.slice(0, -1))
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className={cn('grid grid-cols-1 gap-1.5', className)}>
|
|
112
|
+
{label ? (
|
|
113
|
+
<label htmlFor={inputId} className={fieldLabelStyles}>
|
|
114
|
+
{label}
|
|
115
|
+
</label>
|
|
116
|
+
) : null}
|
|
117
|
+
|
|
118
|
+
<div
|
|
119
|
+
data-invalid={isInvalid || undefined}
|
|
120
|
+
data-disabled={isDisabled || undefined}
|
|
121
|
+
className={cn(
|
|
122
|
+
styles.box(),
|
|
123
|
+
'flex flex-wrap items-center gap-1',
|
|
124
|
+
minHeight,
|
|
125
|
+
fieldChipPadding[size],
|
|
126
|
+
focusWithin,
|
|
127
|
+
)}
|
|
128
|
+
>
|
|
129
|
+
{value.map((tag, idx) => (
|
|
130
|
+
// tags are kept unique on commit, so the value alone is a stable key
|
|
131
|
+
<span
|
|
132
|
+
key={tag}
|
|
133
|
+
className={cn(
|
|
134
|
+
'inline-flex items-center gap-1',
|
|
135
|
+
'px-1.5 py-0.5 rounded-sm bg-surface-3',
|
|
136
|
+
'font-mono text-[12px] text-ink-2',
|
|
137
|
+
)}
|
|
138
|
+
>
|
|
139
|
+
<span className="max-w-[10rem] truncate">{tag}</span>
|
|
140
|
+
<button
|
|
141
|
+
type="button"
|
|
142
|
+
aria-label={`Remove ${tag}`}
|
|
143
|
+
disabled={isDisabled}
|
|
144
|
+
onClick={() => remove(idx)}
|
|
145
|
+
className={cn(
|
|
146
|
+
'inline-flex items-center justify-center',
|
|
147
|
+
'size-3.5 rounded-full text-ink-3 hover:text-ink hover:bg-surface-2',
|
|
148
|
+
'cursor-pointer outline-none',
|
|
149
|
+
'focus-visible:shadow-[var(--shadow-focus)]',
|
|
150
|
+
'disabled:opacity-45 disabled:cursor-not-allowed',
|
|
151
|
+
)}
|
|
152
|
+
>
|
|
153
|
+
<svg aria-hidden viewBox="0 0 10 10" className="size-2" fill="none">
|
|
154
|
+
<path
|
|
155
|
+
d="M2 2l6 6M8 2l-6 6"
|
|
156
|
+
stroke="currentColor"
|
|
157
|
+
strokeWidth="1.4"
|
|
158
|
+
strokeLinecap="round"
|
|
159
|
+
/>
|
|
160
|
+
</svg>
|
|
161
|
+
</button>
|
|
162
|
+
</span>
|
|
163
|
+
))}
|
|
164
|
+
|
|
165
|
+
<input
|
|
166
|
+
ref={inputRef}
|
|
167
|
+
id={inputId}
|
|
168
|
+
type={inputType}
|
|
169
|
+
value={draft}
|
|
170
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
171
|
+
onKeyDown={onKeyDown}
|
|
172
|
+
onBlur={commit}
|
|
173
|
+
disabled={isDisabled}
|
|
174
|
+
placeholder={value.length === 0 ? placeholder : ''}
|
|
175
|
+
className={cn(
|
|
176
|
+
'flex-1 min-w-[6rem] bg-transparent outline-none',
|
|
177
|
+
'font-sans text-ink placeholder:text-ink-4',
|
|
178
|
+
fieldTextStyles[size],
|
|
179
|
+
// No vertical padding — wrapper's p-1.5 + min-h-[size] govern the
|
|
180
|
+
// overall height so it matches Input/Select at the same size.
|
|
181
|
+
'px-1.5',
|
|
182
|
+
'disabled:cursor-not-allowed disabled:opacity-45',
|
|
183
|
+
)}
|
|
184
|
+
/>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
{description ? <p className={fieldHintStyles}>{description}</p> : null}
|
|
188
|
+
{errorMessage && appearance !== 'cell' ? (
|
|
189
|
+
<p className={fieldErrorStyles}>{errorMessage}</p>
|
|
190
|
+
) : null}
|
|
191
|
+
</div>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
|
|
3
|
+
import { Textarea } from './textarea'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Textarea> = {
|
|
6
|
+
title: 'Components/Textarea',
|
|
7
|
+
component: Textarea,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
args: {
|
|
10
|
+
label: 'Describe the system',
|
|
11
|
+
placeholder: 'A mid-size freight broker. Quotes, loads, carriers, lanes…',
|
|
12
|
+
},
|
|
13
|
+
argTypes: {
|
|
14
|
+
isDisabled: { control: { type: 'boolean' } },
|
|
15
|
+
isInvalid: { control: { type: 'boolean' } },
|
|
16
|
+
isReadOnly: { control: { type: 'boolean' } },
|
|
17
|
+
rows: { control: { type: 'number' } },
|
|
18
|
+
},
|
|
19
|
+
decorators: [
|
|
20
|
+
(Story) => (
|
|
21
|
+
<div className="w-[440px]">
|
|
22
|
+
<Story />
|
|
23
|
+
</div>
|
|
24
|
+
),
|
|
25
|
+
],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default meta
|
|
29
|
+
type Story = StoryObj<typeof Textarea>
|
|
30
|
+
|
|
31
|
+
export const Playground: Story = {}
|
|
32
|
+
|
|
33
|
+
export const WithDescription: Story = {
|
|
34
|
+
args: {
|
|
35
|
+
description: 'Plain language. Proteos will infer entities, workflows and reports.',
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const Invalid: Story = {
|
|
40
|
+
args: {
|
|
41
|
+
isInvalid: true,
|
|
42
|
+
errorMessage: 'Required — tell us what to build.',
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const Disabled: Story = {
|
|
47
|
+
args: {
|
|
48
|
+
defaultValue:
|
|
49
|
+
'A specialty lender. Applications, committee review, servicing, delinquency workflows.',
|
|
50
|
+
isDisabled: true,
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const Tall: Story = {
|
|
55
|
+
args: {
|
|
56
|
+
rows: 10,
|
|
57
|
+
description: 'Taller field for longer specs.',
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const AllStates: Story = {
|
|
62
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
63
|
+
render: () => (
|
|
64
|
+
<div className="grid gap-5 w-[440px]">
|
|
65
|
+
<Textarea label="Rest" placeholder="Hairline border, paper fill" />
|
|
66
|
+
<Textarea
|
|
67
|
+
label="Filled"
|
|
68
|
+
defaultValue="A specialty lender. Applications, committee review, servicing, delinquency workflows."
|
|
69
|
+
description="Hover or focus to see the indigo shift."
|
|
70
|
+
/>
|
|
71
|
+
<Textarea label="Invalid" isInvalid errorMessage="Required — tell us what to build." />
|
|
72
|
+
<Textarea label="Disabled" defaultValue="Can't touch this" isDisabled />
|
|
73
|
+
</div>
|
|
74
|
+
),
|
|
75
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { Textarea } from './textarea'
|
|
6
|
+
|
|
7
|
+
describe('Textarea', () => {
|
|
8
|
+
it('renders a labelled textarea', () => {
|
|
9
|
+
render(<Textarea label="Describe the system" placeholder="A mid-size freight broker…" />)
|
|
10
|
+
expect(screen.getByLabelText('Describe the system')).toBeInTheDocument()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('accepts multi-line user input', async () => {
|
|
14
|
+
const user = userEvent.setup()
|
|
15
|
+
render(<Textarea label="Describe the system" />)
|
|
16
|
+
|
|
17
|
+
const textarea = screen.getByLabelText('Describe the system')
|
|
18
|
+
await user.type(textarea, 'Line one{Enter}Line two')
|
|
19
|
+
expect(textarea).toHaveValue('Line one\nLine two')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('shows its description slot', () => {
|
|
23
|
+
render(<Textarea label="System" description="Plain language is fine." />)
|
|
24
|
+
expect(screen.getByText('Plain language is fine.')).toBeInTheDocument()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('renders the error message when invalid', () => {
|
|
28
|
+
render(<Textarea label="System" isInvalid errorMessage="Required — tell us what to build." />)
|
|
29
|
+
expect(screen.getByText('Required — tell us what to build.')).toBeInTheDocument()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('forwards the rows prop', () => {
|
|
33
|
+
render(<Textarea label="System" rows={8} />)
|
|
34
|
+
expect(screen.getByLabelText('System')).toHaveAttribute('rows', '8')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('forwards disabled state to the underlying textarea', () => {
|
|
38
|
+
render(<Textarea label="System" isDisabled />)
|
|
39
|
+
expect(screen.getByLabelText('System')).toBeDisabled()
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
TextArea as AriaTextArea,
|
|
4
|
+
type TextAreaProps as AriaTextAreaProps,
|
|
5
|
+
type TextFieldProps as AriaTextFieldProps,
|
|
6
|
+
FieldError,
|
|
7
|
+
Label,
|
|
8
|
+
Text,
|
|
9
|
+
TextField,
|
|
10
|
+
} from 'react-aria-components'
|
|
11
|
+
import { cn } from '../../lib/cn'
|
|
12
|
+
import {
|
|
13
|
+
type FieldAppearance,
|
|
14
|
+
fieldControlStyles,
|
|
15
|
+
fieldErrorStyles,
|
|
16
|
+
type FieldSize,
|
|
17
|
+
fieldHintStyles,
|
|
18
|
+
fieldLabelStyles,
|
|
19
|
+
} from '../field/field'
|
|
20
|
+
|
|
21
|
+
export type TextareaProps = AriaTextFieldProps & {
|
|
22
|
+
label?: ReactNode
|
|
23
|
+
description?: ReactNode
|
|
24
|
+
errorMessage?: ReactNode
|
|
25
|
+
placeholder?: string
|
|
26
|
+
/** Visible rows. Height can still grow via vertical resize (consumer). */
|
|
27
|
+
rows?: number
|
|
28
|
+
/** Field size. Defaults to `md`. */
|
|
29
|
+
size?: FieldSize
|
|
30
|
+
/** Chrome variant. Defaults to `field`. `cell` flattens the box. */
|
|
31
|
+
appearance?: FieldAppearance
|
|
32
|
+
/** Extra props forwarded to the underlying <textarea>. */
|
|
33
|
+
textareaProps?: Omit<AriaTextAreaProps, 'className'>
|
|
34
|
+
/** Visual override for the textarea control only. */
|
|
35
|
+
textareaClassName?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function Textarea({
|
|
39
|
+
label,
|
|
40
|
+
description,
|
|
41
|
+
errorMessage,
|
|
42
|
+
placeholder,
|
|
43
|
+
rows = 4,
|
|
44
|
+
size,
|
|
45
|
+
appearance,
|
|
46
|
+
textareaProps,
|
|
47
|
+
textareaClassName,
|
|
48
|
+
className,
|
|
49
|
+
...props
|
|
50
|
+
}: TextareaProps) {
|
|
51
|
+
const styles = fieldControlStyles({ size, appearance })
|
|
52
|
+
return (
|
|
53
|
+
<TextField
|
|
54
|
+
{...props}
|
|
55
|
+
className={(renderProps) =>
|
|
56
|
+
cn(
|
|
57
|
+
'grid grid-cols-1 gap-1.5',
|
|
58
|
+
typeof className === 'function' ? className(renderProps) : className,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
>
|
|
62
|
+
{label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
|
|
63
|
+
<AriaTextArea
|
|
64
|
+
{...textareaProps}
|
|
65
|
+
rows={rows}
|
|
66
|
+
placeholder={placeholder ?? ''}
|
|
67
|
+
className={cn(
|
|
68
|
+
// Same hairline chrome as Input, but allow vertical resize and set a
|
|
69
|
+
// sensible minimum height so short content doesn't collapse.
|
|
70
|
+
styles.box(),
|
|
71
|
+
styles.content(),
|
|
72
|
+
'min-h-[100px] resize-y',
|
|
73
|
+
textareaClassName,
|
|
74
|
+
)}
|
|
75
|
+
/>
|
|
76
|
+
{description ? (
|
|
77
|
+
<Text slot="description" className={fieldHintStyles}>
|
|
78
|
+
{description}
|
|
79
|
+
</Text>
|
|
80
|
+
) : null}
|
|
81
|
+
{errorMessage && appearance !== 'cell' ? (
|
|
82
|
+
<FieldError className={fieldErrorStyles}>{errorMessage}</FieldError>
|
|
83
|
+
) : null}
|
|
84
|
+
</TextField>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { parseTime } from '@internationalized/date'
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
3
|
+
|
|
4
|
+
import { TimeField } from './time-field'
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof TimeField> = {
|
|
7
|
+
title: 'Components/TimeField',
|
|
8
|
+
component: TimeField,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
args: {
|
|
11
|
+
label: 'Opens at',
|
|
12
|
+
},
|
|
13
|
+
argTypes: {
|
|
14
|
+
isDisabled: { control: { type: 'boolean' } },
|
|
15
|
+
isInvalid: { control: { type: 'boolean' } },
|
|
16
|
+
isReadOnly: { control: { type: 'boolean' } },
|
|
17
|
+
},
|
|
18
|
+
decorators: [
|
|
19
|
+
(Story) => (
|
|
20
|
+
<div className="w-[220px]">
|
|
21
|
+
<Story />
|
|
22
|
+
</div>
|
|
23
|
+
),
|
|
24
|
+
],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default meta
|
|
28
|
+
type Story = StoryObj<typeof TimeField>
|
|
29
|
+
|
|
30
|
+
export const Playground: Story = {}
|
|
31
|
+
|
|
32
|
+
export const WithValue: Story = {
|
|
33
|
+
args: {
|
|
34
|
+
defaultValue: parseTime('09:30'),
|
|
35
|
+
description: 'Pre-filled with 9:30 AM.',
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const TwentyFourHour: Story = {
|
|
40
|
+
args: {
|
|
41
|
+
defaultValue: parseTime('14:30'),
|
|
42
|
+
hourCycle: 24,
|
|
43
|
+
description: '24-hour clock.',
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const SecondPrecision: Story = {
|
|
48
|
+
args: {
|
|
49
|
+
defaultValue: parseTime('14:30:05'),
|
|
50
|
+
granularity: 'second',
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const Invalid: Story = {
|
|
55
|
+
args: {
|
|
56
|
+
isInvalid: true,
|
|
57
|
+
errorMessage: 'Pick a time during business hours.',
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const Disabled: Story = {
|
|
62
|
+
args: {
|
|
63
|
+
defaultValue: parseTime('09:30'),
|
|
64
|
+
isDisabled: true,
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const CellAppearance: Story = {
|
|
69
|
+
args: {
|
|
70
|
+
appearance: 'cell',
|
|
71
|
+
size: 'sm',
|
|
72
|
+
label: undefined,
|
|
73
|
+
'aria-label': 'Opens at',
|
|
74
|
+
defaultValue: parseTime('09:30'),
|
|
75
|
+
},
|
|
76
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { parseTime, type Time } from '@internationalized/date'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import userEvent from '@testing-library/user-event'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { TimeField } from './time-field'
|
|
7
|
+
|
|
8
|
+
describe('TimeField', () => {
|
|
9
|
+
it('renders a labelled time input', () => {
|
|
10
|
+
render(<TimeField label="Opens at" />)
|
|
11
|
+
expect(screen.getByText('Opens at')).toBeInTheDocument()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('renders hour and minute segments', () => {
|
|
15
|
+
render(<TimeField label="Opens at" defaultValue={parseTime('09:30')} />)
|
|
16
|
+
expect(screen.getByRole('spinbutton', { name: /hour/i })).toBeInTheDocument()
|
|
17
|
+
expect(screen.getByRole('spinbutton', { name: /minute/i })).toBeInTheDocument()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('fires onChange when a segment is incremented', async () => {
|
|
21
|
+
const user = userEvent.setup()
|
|
22
|
+
const onChange = vi.fn()
|
|
23
|
+
render(<TimeField label="Opens at" defaultValue={parseTime('09:30')} onChange={onChange} />)
|
|
24
|
+
|
|
25
|
+
const hour = screen.getByRole('spinbutton', { name: /hour/i })
|
|
26
|
+
hour.focus()
|
|
27
|
+
await user.keyboard('{ArrowUp}')
|
|
28
|
+
|
|
29
|
+
expect(onChange).toHaveBeenCalled()
|
|
30
|
+
const called = onChange.mock.calls.at(-1)?.[0] as Time | null
|
|
31
|
+
expect(called?.hour).toBe(10)
|
|
32
|
+
expect(called?.minute).toBe(30)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('renders the error message when invalid', () => {
|
|
36
|
+
render(<TimeField label="Opens at" isInvalid errorMessage="Out of hours." />)
|
|
37
|
+
expect(screen.getByText('Out of hours.')).toBeInTheDocument()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('disables the control when isDisabled is set', () => {
|
|
41
|
+
render(<TimeField label="Opens at" isDisabled defaultValue={parseTime('09:30')} />)
|
|
42
|
+
for (const seg of screen.getAllByRole('spinbutton')) {
|
|
43
|
+
expect(seg).toHaveAttribute('aria-disabled', 'true')
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('shows the description slot', () => {
|
|
48
|
+
render(<TimeField label="Opens at" description="Local time." />)
|
|
49
|
+
expect(screen.getByText('Local time.')).toBeInTheDocument()
|
|
50
|
+
})
|
|
51
|
+
})
|