@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,46 @@
|
|
|
1
|
+
import { cn } from '../../lib/cn'
|
|
2
|
+
import { parseCombo } from '../../lib/commands/parse-combo'
|
|
3
|
+
import { getPlatform } from '../../lib/commands/platform'
|
|
4
|
+
import { Kbd } from '../kbd'
|
|
5
|
+
|
|
6
|
+
/* =========================================================================
|
|
7
|
+
CommandHint — visual representation of a `mod+<key>` combo. Renders a
|
|
8
|
+
single `<Kbd>` so it slots in where the cosmetic `<Kbd>⌘K</Kbd>` lived
|
|
9
|
+
before. On macOS the modifier is shown as `⌘` glued directly to the key
|
|
10
|
+
(`⌘K`); on other platforms it reads `Ctrl+K`. Pure presentation — no
|
|
11
|
+
binding, no listeners.
|
|
12
|
+
|
|
13
|
+
`tone="prominent"` flips the cap to a high-contrast surface for use on
|
|
14
|
+
the discovery overlay where it needs to stand out against the backdrop.
|
|
15
|
+
========================================================================= */
|
|
16
|
+
|
|
17
|
+
export type CommandHintTone = 'subtle' | 'prominent'
|
|
18
|
+
|
|
19
|
+
export type CommandHintProps = {
|
|
20
|
+
combo: string
|
|
21
|
+
tone?: CommandHintTone
|
|
22
|
+
className?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function CommandHint({ combo, tone = 'subtle', className }: CommandHintProps) {
|
|
26
|
+
const parsed = parseCombo(combo)
|
|
27
|
+
const platform = getPlatform()
|
|
28
|
+
const modSymbol = platform === 'mac' ? '⌘' : 'Ctrl'
|
|
29
|
+
const keyLabel = parsed.isLetter ? parsed.key.toUpperCase() : prettifyKey(parsed.key)
|
|
30
|
+
const label = platform === 'mac' ? `${modSymbol}${keyLabel}` : `${modSymbol}+${keyLabel}`
|
|
31
|
+
return (
|
|
32
|
+
<Kbd
|
|
33
|
+
className={cn(
|
|
34
|
+
tone === 'prominent' && 'bg-ink text-surface border-transparent',
|
|
35
|
+
className,
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
{label}
|
|
39
|
+
</Kbd>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function prettifyKey(key: string): string {
|
|
44
|
+
if (key.length === 0) return key
|
|
45
|
+
return key[0]!.toUpperCase() + key.slice(1)
|
|
46
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import { Button } from '../button'
|
|
5
|
+
|
|
6
|
+
import { Command } from './command'
|
|
7
|
+
import { CommandHint } from './command-hint'
|
|
8
|
+
|
|
9
|
+
const meta: Meta<typeof Command> = {
|
|
10
|
+
title: 'Components/Command',
|
|
11
|
+
component: Command,
|
|
12
|
+
tags: ['autodocs'],
|
|
13
|
+
parameters: {
|
|
14
|
+
docs: {
|
|
15
|
+
description: {
|
|
16
|
+
component: [
|
|
17
|
+
'Discovery-aware wrapper that binds a `mod+<key>` combo to a child',
|
|
18
|
+
'button and reveals the shortcut on Cmd+Shift hold. Note: browser',
|
|
19
|
+
'default combos like `mod+S` are technically overridable, but you',
|
|
20
|
+
'should make that a deliberate choice — most productivity apps only',
|
|
21
|
+
'override them inside document-editor contexts.',
|
|
22
|
+
].join(' '),
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
export default meta
|
|
28
|
+
type Story = StoryObj<typeof Command>
|
|
29
|
+
|
|
30
|
+
export const Basic: Story = {
|
|
31
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
32
|
+
render: () => {
|
|
33
|
+
function Demo() {
|
|
34
|
+
const [count, setCount] = useState(0)
|
|
35
|
+
return (
|
|
36
|
+
<div className="flex flex-col items-start gap-3">
|
|
37
|
+
<p className="text-[13px] text-ink-3">
|
|
38
|
+
Press <strong>Cmd+J</strong> (or hold <strong>Cmd+Shift</strong> to peek the
|
|
39
|
+
shortcut, then press <strong>J</strong>).
|
|
40
|
+
</p>
|
|
41
|
+
<Command combo="mod+j">
|
|
42
|
+
<Button onPress={() => setCount((c) => c + 1)}>Increment ({count})</Button>
|
|
43
|
+
</Command>
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
return <Demo />
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const DiscoveryFlow: Story = {
|
|
52
|
+
parameters: {
|
|
53
|
+
controls: { hideNoControlsWarning: true },
|
|
54
|
+
docs: {
|
|
55
|
+
description: {
|
|
56
|
+
story:
|
|
57
|
+
'Hold Cmd+Shift to reveal the bound shortcut as an overlay chip over each `<Command>` on the page. Press the bound key to fire the action.',
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
render: () => (
|
|
62
|
+
<div className="flex items-start gap-3 p-6 bg-surface-2 rounded-md">
|
|
63
|
+
<Command combo="mod+s">
|
|
64
|
+
<Button>Save</Button>
|
|
65
|
+
</Command>
|
|
66
|
+
<Command combo="mod+d">
|
|
67
|
+
<Button variant="subtle">Duplicate</Button>
|
|
68
|
+
</Command>
|
|
69
|
+
<Command combo="mod+e">
|
|
70
|
+
<Button variant="ghost">Export</Button>
|
|
71
|
+
</Command>
|
|
72
|
+
</div>
|
|
73
|
+
),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const Hint: Story = {
|
|
77
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
78
|
+
render: () => (
|
|
79
|
+
<div className="flex flex-col gap-2 text-[13px] text-ink-3">
|
|
80
|
+
<div className="flex items-center gap-2">
|
|
81
|
+
Open the palette: <CommandHint combo="mod+k" />
|
|
82
|
+
</div>
|
|
83
|
+
<div className="flex items-center gap-2">
|
|
84
|
+
Prominent variant: <CommandHint combo="mod+k" tone="prominent" />
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
),
|
|
88
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { act, render, screen } from '@testing-library/react'
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { Button } from '../button'
|
|
5
|
+
import { _resetDiscovery } from '../../lib/commands/discovery-store'
|
|
6
|
+
import { _setPlatformForTests } from '../../lib/commands/platform'
|
|
7
|
+
import { _resetRegistry } from '../../lib/commands/registry'
|
|
8
|
+
|
|
9
|
+
import { Command } from './command'
|
|
10
|
+
|
|
11
|
+
function dispatchModS() {
|
|
12
|
+
window.dispatchEvent(
|
|
13
|
+
new KeyboardEvent('keydown', {
|
|
14
|
+
bubbles: true,
|
|
15
|
+
cancelable: true,
|
|
16
|
+
metaKey: true,
|
|
17
|
+
code: 'KeyS',
|
|
18
|
+
key: 's',
|
|
19
|
+
}),
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function holdCmdShift() {
|
|
24
|
+
act(() => {
|
|
25
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift', shiftKey: true }))
|
|
26
|
+
window.dispatchEvent(
|
|
27
|
+
new KeyboardEvent('keydown', { key: 'Meta', shiftKey: true, metaKey: true }),
|
|
28
|
+
)
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('Command', () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
_setPlatformForTests('mac')
|
|
35
|
+
_resetRegistry()
|
|
36
|
+
_resetDiscovery()
|
|
37
|
+
})
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
_setPlatformForTests(undefined)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('fires the wrapped Button.onPress when the combo is pressed', () => {
|
|
43
|
+
const onPress = vi.fn()
|
|
44
|
+
render(
|
|
45
|
+
<Command combo="mod+s">
|
|
46
|
+
<Button onPress={onPress}>Save</Button>
|
|
47
|
+
</Command>,
|
|
48
|
+
)
|
|
49
|
+
dispatchModS()
|
|
50
|
+
expect(onPress).toHaveBeenCalledTimes(1)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('shows the discovery hint only while Cmd+Shift is held', () => {
|
|
54
|
+
render(
|
|
55
|
+
<Command combo="mod+s">
|
|
56
|
+
<Button>Save</Button>
|
|
57
|
+
</Command>,
|
|
58
|
+
)
|
|
59
|
+
expect(screen.queryByText('⌘S')).toBeNull()
|
|
60
|
+
holdCmdShift()
|
|
61
|
+
expect(screen.getByText('⌘S')).toBeInTheDocument()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('cleans up on unmount', () => {
|
|
65
|
+
const onPress = vi.fn()
|
|
66
|
+
const { unmount } = render(
|
|
67
|
+
<Command combo="mod+s">
|
|
68
|
+
<Button onPress={onPress}>Save</Button>
|
|
69
|
+
</Command>,
|
|
70
|
+
)
|
|
71
|
+
unmount()
|
|
72
|
+
dispatchModS()
|
|
73
|
+
expect(onPress).not.toHaveBeenCalled()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('targets a descendant when `target` is provided (used by RecordView for the filter)', () => {
|
|
77
|
+
const onPress = vi.fn()
|
|
78
|
+
render(
|
|
79
|
+
<Command combo="mod+s" target="[data-test-trigger]">
|
|
80
|
+
<span>
|
|
81
|
+
<Button>Decoy</Button>
|
|
82
|
+
<Button data-test-trigger onPress={onPress}>
|
|
83
|
+
Save
|
|
84
|
+
</Button>
|
|
85
|
+
</span>
|
|
86
|
+
</Command>,
|
|
87
|
+
)
|
|
88
|
+
dispatchModS()
|
|
89
|
+
expect(onPress).toHaveBeenCalledTimes(1)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('does nothing when `target` matches no descendant', () => {
|
|
93
|
+
const onPress = vi.fn()
|
|
94
|
+
render(
|
|
95
|
+
<Command combo="mod+s" target="[data-missing]">
|
|
96
|
+
<Button onPress={onPress}>Save</Button>
|
|
97
|
+
</Command>,
|
|
98
|
+
)
|
|
99
|
+
dispatchModS()
|
|
100
|
+
expect(onPress).not.toHaveBeenCalled()
|
|
101
|
+
})
|
|
102
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type ReactNode, useRef } from 'react'
|
|
4
|
+
|
|
5
|
+
import { cn } from '../../lib/cn'
|
|
6
|
+
import { useCommand } from '../../lib/commands/use-command'
|
|
7
|
+
import { useDiscoveryActive } from '../../lib/commands/discovery-store'
|
|
8
|
+
|
|
9
|
+
import { CommandHint } from './command-hint'
|
|
10
|
+
|
|
11
|
+
/* =========================================================================
|
|
12
|
+
Command — discovery-aware wrapper around an interactive element. Binds
|
|
13
|
+
`combo` to a synthetic click on the wrapped child (RAC `Button`'s
|
|
14
|
+
`onPress` listens for native click events, so this fires the user's
|
|
15
|
+
handler), and reveals a hint chip while Cmd+Shift is held so users can
|
|
16
|
+
learn the bound shortcut.
|
|
17
|
+
|
|
18
|
+
<Command combo="mod+s">
|
|
19
|
+
<Button onPress={onSave}>Save</Button>
|
|
20
|
+
</Command>
|
|
21
|
+
|
|
22
|
+
When the trigger isn't the immediate child (e.g. wrapped in a `<span>`
|
|
23
|
+
for popover anchoring, or buried inside a list), pass `target` — a CSS
|
|
24
|
+
selector resolved against the wrapper. The first matching descendant
|
|
25
|
+
receives the synthetic click.
|
|
26
|
+
|
|
27
|
+
<Command combo="mod+f" target="[data-filter-trigger]">
|
|
28
|
+
<RecordFilterButton … />
|
|
29
|
+
<RecordFilterChips … />
|
|
30
|
+
</Command>
|
|
31
|
+
|
|
32
|
+
`className` overrides the wrapper layout (default `inline-block`) for
|
|
33
|
+
call sites where the wrapper needs to participate in a flex/grid layout.
|
|
34
|
+
The wrapper stays `relative` either way so the discovery overlay can
|
|
35
|
+
absolute-position against it.
|
|
36
|
+
========================================================================= */
|
|
37
|
+
|
|
38
|
+
export type CommandProps = {
|
|
39
|
+
combo: string
|
|
40
|
+
/** CSS selector for the click target. Defaults to the wrapper's first
|
|
41
|
+
* element child — fine when that child IS the interactive control. */
|
|
42
|
+
target?: string
|
|
43
|
+
/** Layout overrides for the wrapper. `relative` is always applied. */
|
|
44
|
+
className?: string
|
|
45
|
+
children: ReactNode
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function Command({ combo, target, className, children }: CommandProps) {
|
|
49
|
+
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
50
|
+
const discoveryActive = useDiscoveryActive()
|
|
51
|
+
|
|
52
|
+
useCommand(combo, () => {
|
|
53
|
+
const root = wrapperRef.current
|
|
54
|
+
if (!root) return
|
|
55
|
+
const el = target
|
|
56
|
+
? root.querySelector<HTMLElement>(target)
|
|
57
|
+
: (root.firstElementChild as HTMLElement | null)
|
|
58
|
+
el?.click()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div ref={wrapperRef} className={cn('relative inline-block', className)}>
|
|
63
|
+
{children}
|
|
64
|
+
{discoveryActive ? (
|
|
65
|
+
<div
|
|
66
|
+
aria-hidden
|
|
67
|
+
className="absolute -inset-2 rounded-md bg-[color-mix(in_oklab,var(--color-ink)_28%,transparent)] pointer-events-none transition-opacity z-40"
|
|
68
|
+
>
|
|
69
|
+
<span className="absolute -top-2.5 -left-2 pointer-events-none z-50">
|
|
70
|
+
<CommandHint combo={combo} tone="prominent" />
|
|
71
|
+
</span>
|
|
72
|
+
</div>
|
|
73
|
+
) : null}
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type { CommandProps } from './command'
|
|
2
|
+
export { Command } from './command'
|
|
3
|
+
export type { CommandHintProps, CommandHintTone } from './command-hint'
|
|
4
|
+
export { CommandHint } from './command-hint'
|
|
5
|
+
|
|
6
|
+
// Re-export the command-system primitives so consumers can `import { useCommand,
|
|
7
|
+
// useEscapeKey, parseCombo, useDiscoveryActive } from '@proteos/ui'`.
|
|
8
|
+
export type { KeyCombo, UseCommandOptions, UseEscapeKeyOptions } from '../../lib/commands'
|
|
9
|
+
export {
|
|
10
|
+
isEditableTarget,
|
|
11
|
+
parseCombo,
|
|
12
|
+
PROTECTED_COMBOS,
|
|
13
|
+
useCommand,
|
|
14
|
+
useDiscoveryActive,
|
|
15
|
+
useEscapeKey,
|
|
16
|
+
} from '../../lib/commands'
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { formatAmount, parseAmount } from '@proteos/sdk'
|
|
2
|
+
import { ChevronDown } from 'lucide-react'
|
|
3
|
+
import { type ReactNode, useEffect, useState } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
Button as AriaSelectTrigger,
|
|
6
|
+
ListBox,
|
|
7
|
+
ListBoxItem,
|
|
8
|
+
Popover,
|
|
9
|
+
Select as AriaSelect,
|
|
10
|
+
} from 'react-aria-components'
|
|
11
|
+
|
|
12
|
+
import { cn } from '../../lib/cn'
|
|
13
|
+
import { type FieldAppearance, fieldControlStyles, fieldErrorStyles, type FieldSize } from '../field/field'
|
|
14
|
+
|
|
15
|
+
/* =========================================================================
|
|
16
|
+
CurrencyInput — a single money field: an amount input plus a trailing
|
|
17
|
+
currency-symbol button that opens a symbol+code dropdown (the same shape as
|
|
18
|
+
the date-picker's trailing calendar trigger).
|
|
19
|
+
|
|
20
|
+
The amount is held and emitted as a canonical decimal STRING (never
|
|
21
|
+
parseFloat'd) so precision and trailing zeros survive; it is *displayed* in
|
|
22
|
+
the viewer's locale (grouping + decimal separator) and parsed back from that
|
|
23
|
+
notation on input. The selectable currencies are passed in via
|
|
24
|
+
`currencyOptions` so the lib stays free of any ISO/Intl dataset; the locale
|
|
25
|
+
number helpers come from the SDK so there's one well-defined implementation.
|
|
26
|
+
========================================================================= */
|
|
27
|
+
|
|
28
|
+
export interface CurrencyInputValue {
|
|
29
|
+
amount: string
|
|
30
|
+
currency_code: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CurrencyInputOption {
|
|
34
|
+
code: string
|
|
35
|
+
/** The currency's symbol (e.g. "$", "€"); falls back to the code. */
|
|
36
|
+
symbol?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CurrencyInputProps {
|
|
40
|
+
value: CurrencyInputValue | null
|
|
41
|
+
onChange: (next: CurrencyInputValue | null) => void
|
|
42
|
+
currencyOptions: ReadonlyArray<CurrencyInputOption>
|
|
43
|
+
/** Locale for the amount's grouping + decimal separators. Defaults to runtime. */
|
|
44
|
+
locale?: string
|
|
45
|
+
isReadOnly?: boolean
|
|
46
|
+
isInvalid?: boolean
|
|
47
|
+
errorMessage?: ReactNode
|
|
48
|
+
size?: FieldSize
|
|
49
|
+
appearance?: FieldAppearance
|
|
50
|
+
placeholder?: string
|
|
51
|
+
autoFocus?: boolean
|
|
52
|
+
'aria-label'?: string
|
|
53
|
+
onBlur?: () => void
|
|
54
|
+
className?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function CurrencyInput({
|
|
58
|
+
value,
|
|
59
|
+
onChange,
|
|
60
|
+
currencyOptions,
|
|
61
|
+
locale,
|
|
62
|
+
isReadOnly,
|
|
63
|
+
isInvalid,
|
|
64
|
+
errorMessage,
|
|
65
|
+
size,
|
|
66
|
+
appearance,
|
|
67
|
+
placeholder,
|
|
68
|
+
autoFocus,
|
|
69
|
+
'aria-label': ariaLabel,
|
|
70
|
+
onBlur,
|
|
71
|
+
className,
|
|
72
|
+
}: CurrencyInputProps) {
|
|
73
|
+
const amount = value?.amount ?? ''
|
|
74
|
+
const code = value?.currency_code ?? ''
|
|
75
|
+
|
|
76
|
+
// `draft` is the text shown in the input. It's grouped (e.g. "1.234,56") when
|
|
77
|
+
// the field is idle and ungrouped ("1234,56") while editing, so grouping
|
|
78
|
+
// doesn't fight the caret mid-type. The canonical value still flows out via
|
|
79
|
+
// onChange on every keystroke.
|
|
80
|
+
const [draft, setDraft] = useState(() => formatAmount(amount, locale, { grouped: true }))
|
|
81
|
+
const [isEditing, setIsEditing] = useState(false)
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!isEditing) setDraft(formatAmount(amount, locale, { grouped: true }))
|
|
85
|
+
}, [amount, locale, isEditing])
|
|
86
|
+
|
|
87
|
+
const emit = (nextAmount: string, nextCode: string) => {
|
|
88
|
+
if (nextAmount === '' && nextCode === '') {
|
|
89
|
+
onChange(null)
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
onChange({ amount: nextAmount, currency_code: nextCode })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const selectedSymbol = currencyOptions.find((option) => option.code === code)?.symbol ?? code
|
|
96
|
+
|
|
97
|
+
const styles = fieldControlStyles({ size, appearance, ...(isInvalid ? { invalid: true } : {}) })
|
|
98
|
+
const focusWithin =
|
|
99
|
+
appearance === 'cell'
|
|
100
|
+
? 'focus-within:border-accent focus-within:shadow-none'
|
|
101
|
+
: 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]'
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div className={cn('grid grid-cols-1 gap-1.5', className)}>
|
|
105
|
+
<div className={cn(styles.box(), 'flex items-center gap-1 pr-1', focusWithin)}>
|
|
106
|
+
<input
|
|
107
|
+
className={cn(
|
|
108
|
+
'min-w-0 flex-1 bg-transparent text-ink outline-none tabular-nums',
|
|
109
|
+
styles.content(),
|
|
110
|
+
// Sit a touch lower than a standard field by trimming the vertical
|
|
111
|
+
// padding (cell mode keeps the fixed 29px row-editor height).
|
|
112
|
+
appearance === 'cell' ? '' : size === 'sm' ? 'py-1' : 'py-1.5',
|
|
113
|
+
)}
|
|
114
|
+
inputMode="decimal"
|
|
115
|
+
value={draft}
|
|
116
|
+
onChange={(event) => {
|
|
117
|
+
setDraft(event.target.value)
|
|
118
|
+
emit(parseAmount(event.target.value, locale), code)
|
|
119
|
+
}}
|
|
120
|
+
onFocus={() => {
|
|
121
|
+
setIsEditing(true)
|
|
122
|
+
setDraft(formatAmount(amount, locale, { grouped: false }))
|
|
123
|
+
}}
|
|
124
|
+
onBlur={() => {
|
|
125
|
+
setIsEditing(false)
|
|
126
|
+
const canonical = parseAmount(draft, locale)
|
|
127
|
+
setDraft(formatAmount(canonical, locale, { grouped: true }))
|
|
128
|
+
emit(canonical, code)
|
|
129
|
+
onBlur?.()
|
|
130
|
+
}}
|
|
131
|
+
readOnly={isReadOnly ?? false}
|
|
132
|
+
autoFocus={autoFocus ?? false}
|
|
133
|
+
placeholder={placeholder ?? ''}
|
|
134
|
+
aria-label={ariaLabel ? `${ariaLabel} amount` : 'Amount'}
|
|
135
|
+
/>
|
|
136
|
+
|
|
137
|
+
<AriaSelect
|
|
138
|
+
aria-label={ariaLabel ? `${ariaLabel} currency` : 'Currency'}
|
|
139
|
+
selectedKey={code === '' ? null : code}
|
|
140
|
+
onSelectionChange={(key) => emit(amount, key == null ? '' : String(key))}
|
|
141
|
+
isDisabled={isReadOnly ?? false}
|
|
142
|
+
>
|
|
143
|
+
<AriaSelectTrigger
|
|
144
|
+
className={cn(
|
|
145
|
+
'inline-flex h-6 shrink-0 items-center gap-0.5 rounded-md px-1.5',
|
|
146
|
+
'text-ink-2 outline-none hover:bg-surface-2 hover:text-ink',
|
|
147
|
+
'cursor-pointer focus-visible:shadow-[var(--shadow-focus)]',
|
|
148
|
+
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-45',
|
|
149
|
+
)}
|
|
150
|
+
>
|
|
151
|
+
<span className="min-w-[1ch] text-center tabular-nums">{selectedSymbol || '¤'}</span>
|
|
152
|
+
<ChevronDown className="size-3 opacity-60" aria-hidden />
|
|
153
|
+
</AriaSelectTrigger>
|
|
154
|
+
|
|
155
|
+
<Popover
|
|
156
|
+
offset={6}
|
|
157
|
+
className={cn(
|
|
158
|
+
'min-w-[9rem] rounded-md border border-rule bg-surface p-1',
|
|
159
|
+
'shadow-[var(--shadow-raised)]',
|
|
160
|
+
'entering:animate-in entering:fade-in-0 entering:slide-in-from-top-1',
|
|
161
|
+
'exiting:animate-out exiting:fade-out-0 exiting:slide-out-to-top-1',
|
|
162
|
+
)}
|
|
163
|
+
>
|
|
164
|
+
<ListBox className="max-h-72 overflow-auto outline-none">
|
|
165
|
+
{currencyOptions.map((option) => (
|
|
166
|
+
<ListBoxItem
|
|
167
|
+
key={option.code}
|
|
168
|
+
id={option.code}
|
|
169
|
+
textValue={`${option.symbol ?? ''} ${option.code}`}
|
|
170
|
+
className={cn(
|
|
171
|
+
'flex items-center gap-2 rounded-[6px] px-2.5 py-1.5 text-[14px] text-ink-2',
|
|
172
|
+
'cursor-pointer select-none outline-none',
|
|
173
|
+
'data-[hovered]:bg-surface-2 data-[hovered]:text-ink',
|
|
174
|
+
'data-[focused]:bg-surface-2 data-[focused]:text-ink',
|
|
175
|
+
'data-[selected]:bg-surface-3 data-[selected]:text-ink',
|
|
176
|
+
)}
|
|
177
|
+
>
|
|
178
|
+
<span className="w-5 shrink-0 text-ink-3 tabular-nums">{option.symbol ?? ''}</span>
|
|
179
|
+
<span className="font-medium">{option.code}</span>
|
|
180
|
+
</ListBoxItem>
|
|
181
|
+
))}
|
|
182
|
+
</ListBox>
|
|
183
|
+
</Popover>
|
|
184
|
+
</AriaSelect>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
{errorMessage && appearance !== 'cell' ? (
|
|
188
|
+
<div className={fieldErrorStyles}>{errorMessage}</div>
|
|
189
|
+
) : null}
|
|
190
|
+
</div>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Table,
|
|
5
|
+
TableBody,
|
|
6
|
+
TableCell,
|
|
7
|
+
TableHeader,
|
|
8
|
+
TableHeaderCell,
|
|
9
|
+
TableHeaderSpacer,
|
|
10
|
+
TableRow,
|
|
11
|
+
} from '../table'
|
|
12
|
+
import { BooleanCell, DateCell, DateTimeCell, LinkCell, TruncateCell } from './cells'
|
|
13
|
+
|
|
14
|
+
/* =========================================================================
|
|
15
|
+
Stories for the attribute-driven cell helpers. These are intentionally
|
|
16
|
+
small — they ship in the lib only because RecordTable dispatches on
|
|
17
|
+
AttributeType at runtime. Anything domain-specific (status pills, route
|
|
18
|
+
pairs, deltas, avatars+name) is consumer JSX inside `column.cell`.
|
|
19
|
+
========================================================================= */
|
|
20
|
+
|
|
21
|
+
const meta: Meta = {
|
|
22
|
+
title: 'Components/DataTable/Cells',
|
|
23
|
+
parameters: { layout: 'centered' },
|
|
24
|
+
tags: ['autodocs'],
|
|
25
|
+
}
|
|
26
|
+
export default meta
|
|
27
|
+
|
|
28
|
+
type Story = StoryObj
|
|
29
|
+
|
|
30
|
+
const COL_WIDTHS = { label: 180, value: 240 } as const
|
|
31
|
+
|
|
32
|
+
function Showcase({ rows }: { rows: ReadonlyArray<{ label: string; value: React.ReactNode }> }) {
|
|
33
|
+
return (
|
|
34
|
+
<div className="w-[480px]">
|
|
35
|
+
<Table aria-label="Cell helpers">
|
|
36
|
+
<TableHeader>
|
|
37
|
+
<TableHeaderCell width={COL_WIDTHS.label}>Cell</TableHeaderCell>
|
|
38
|
+
<TableHeaderCell width={COL_WIDTHS.value}>Renders</TableHeaderCell>
|
|
39
|
+
<TableHeaderSpacer />
|
|
40
|
+
</TableHeader>
|
|
41
|
+
<TableBody>
|
|
42
|
+
{rows.map((row, i) => (
|
|
43
|
+
<TableRow key={row.label} alt={i % 2 === 1} style={{ height: 36 }}>
|
|
44
|
+
<TableCell width={COL_WIDTHS.label} mono>
|
|
45
|
+
{row.label}
|
|
46
|
+
</TableCell>
|
|
47
|
+
<TableCell width={COL_WIDTHS.value}>{row.value}</TableCell>
|
|
48
|
+
</TableRow>
|
|
49
|
+
))}
|
|
50
|
+
</TableBody>
|
|
51
|
+
</Table>
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const DateCells: Story = {
|
|
57
|
+
render: () => (
|
|
58
|
+
<Showcase
|
|
59
|
+
rows={[
|
|
60
|
+
{ label: 'DateCell · ISO', value: <DateCell value="2026-04-28" /> },
|
|
61
|
+
{ label: 'DateCell · null', value: <DateCell value={null} /> },
|
|
62
|
+
{ label: 'DateTimeCell', value: <DateTimeCell value="2026-04-28T13:42:00Z" /> },
|
|
63
|
+
{ label: 'DateTimeCell · invalid', value: <DateTimeCell value="not-a-date" /> },
|
|
64
|
+
]}
|
|
65
|
+
/>
|
|
66
|
+
),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const Booleans: Story = {
|
|
70
|
+
render: () => (
|
|
71
|
+
<Showcase
|
|
72
|
+
rows={[
|
|
73
|
+
{ label: 'BooleanCell · true', value: <BooleanCell value={true} /> },
|
|
74
|
+
{ label: 'BooleanCell · false', value: <BooleanCell value={false} /> },
|
|
75
|
+
{ label: 'BooleanCell · null', value: <BooleanCell value={null} /> },
|
|
76
|
+
]}
|
|
77
|
+
/>
|
|
78
|
+
),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const Truncation: Story = {
|
|
82
|
+
render: () => (
|
|
83
|
+
<Showcase
|
|
84
|
+
rows={[
|
|
85
|
+
{
|
|
86
|
+
label: 'TruncateCell · short',
|
|
87
|
+
value: <TruncateCell value="Atlanta, GA" />,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
label: 'TruncateCell · long',
|
|
91
|
+
value: (
|
|
92
|
+
<TruncateCell value="Industrial chemicals, hazmat class 3, 18,420 lb, refrigeration not required" />
|
|
93
|
+
),
|
|
94
|
+
},
|
|
95
|
+
{ label: 'TruncateCell · empty', value: <TruncateCell value={null} /> },
|
|
96
|
+
]}
|
|
97
|
+
/>
|
|
98
|
+
),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const Links: Story = {
|
|
102
|
+
render: () => (
|
|
103
|
+
<Showcase
|
|
104
|
+
rows={[
|
|
105
|
+
{
|
|
106
|
+
label: 'LinkCell',
|
|
107
|
+
value: <LinkCell href="#shipment-10482">SH-10482</LinkCell>,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
label: 'LinkCell · external',
|
|
111
|
+
value: (
|
|
112
|
+
<LinkCell href="https://example.com/SH-10482" target="_blank" rel="noreferrer">
|
|
113
|
+
View on partner ↗
|
|
114
|
+
</LinkCell>
|
|
115
|
+
),
|
|
116
|
+
},
|
|
117
|
+
]}
|
|
118
|
+
/>
|
|
119
|
+
),
|
|
120
|
+
}
|