@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,98 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
TimeField as AriaTimeField,
|
|
4
|
+
type TimeFieldProps as AriaTimeFieldProps,
|
|
5
|
+
DateInput,
|
|
6
|
+
DateSegment,
|
|
7
|
+
FieldError,
|
|
8
|
+
Label,
|
|
9
|
+
Text,
|
|
10
|
+
type TimeValue,
|
|
11
|
+
} from 'react-aria-components'
|
|
12
|
+
import { cn } from '../../lib/cn'
|
|
13
|
+
import {
|
|
14
|
+
type FieldAppearance,
|
|
15
|
+
fieldControlStyles,
|
|
16
|
+
fieldErrorStyles,
|
|
17
|
+
type FieldSize,
|
|
18
|
+
fieldHintStyles,
|
|
19
|
+
fieldLabelStyles,
|
|
20
|
+
} from '../field/field'
|
|
21
|
+
|
|
22
|
+
export type TimeFieldProps<T extends TimeValue> = AriaTimeFieldProps<T> & {
|
|
23
|
+
label?: ReactNode
|
|
24
|
+
description?: ReactNode
|
|
25
|
+
errorMessage?: ReactNode
|
|
26
|
+
/** Field size. Defaults to `md`. */
|
|
27
|
+
size?: FieldSize
|
|
28
|
+
/** Chrome variant. Defaults to `field`. `cell` flattens the box. */
|
|
29
|
+
appearance?: FieldAppearance
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* Standalone time editor — backs the `time-picker` control (naive `HH:mm:ss`).
|
|
33
|
+
Just the segmented input: no calendar, no popover. Box chrome + content
|
|
34
|
+
padding both ride on the DateInput since there's no sibling trigger to share
|
|
35
|
+
the border with. */
|
|
36
|
+
export function TimeField<T extends TimeValue = TimeValue>({
|
|
37
|
+
label,
|
|
38
|
+
description,
|
|
39
|
+
errorMessage,
|
|
40
|
+
size,
|
|
41
|
+
appearance,
|
|
42
|
+
className,
|
|
43
|
+
...props
|
|
44
|
+
}: TimeFieldProps<T>) {
|
|
45
|
+
const styles = fieldControlStyles({ size, appearance })
|
|
46
|
+
const focusWithin =
|
|
47
|
+
appearance === 'cell'
|
|
48
|
+
? 'focus-within:border-accent focus-within:shadow-none'
|
|
49
|
+
: 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]'
|
|
50
|
+
return (
|
|
51
|
+
<AriaTimeField
|
|
52
|
+
{...props}
|
|
53
|
+
className={(renderProps) =>
|
|
54
|
+
cn(
|
|
55
|
+
'grid grid-cols-1 gap-1.5',
|
|
56
|
+
typeof className === 'function' ? className(renderProps) : className,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
>
|
|
60
|
+
{label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
|
|
61
|
+
|
|
62
|
+
<DateInput
|
|
63
|
+
className={cn(
|
|
64
|
+
styles.box(),
|
|
65
|
+
styles.content(),
|
|
66
|
+
'flex items-center min-w-0 text-ink',
|
|
67
|
+
// Tabular alignment for time segments — overrides content's
|
|
68
|
+
// font-sans on purpose.
|
|
69
|
+
'font-mono',
|
|
70
|
+
focusWithin,
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
{(segment) => (
|
|
74
|
+
<DateSegment
|
|
75
|
+
segment={segment}
|
|
76
|
+
className={cn(
|
|
77
|
+
'px-px tabular-nums outline-none select-none',
|
|
78
|
+
'data-[placeholder]:text-ink-4',
|
|
79
|
+
'data-[type=literal]:text-ink-4',
|
|
80
|
+
// segment focus treatment: indigo fill, paper text
|
|
81
|
+
'data-[focused]:bg-accent data-[focused]:text-[#f0efe8] data-[focused]:rounded-[3px]',
|
|
82
|
+
'data-[disabled]:cursor-not-allowed',
|
|
83
|
+
)}
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
86
|
+
</DateInput>
|
|
87
|
+
|
|
88
|
+
{description ? (
|
|
89
|
+
<Text slot="description" className={fieldHintStyles}>
|
|
90
|
+
{description}
|
|
91
|
+
</Text>
|
|
92
|
+
) : null}
|
|
93
|
+
{errorMessage && appearance !== 'cell' ? (
|
|
94
|
+
<FieldError className={fieldErrorStyles}>{errorMessage}</FieldError>
|
|
95
|
+
) : null}
|
|
96
|
+
</AriaTimeField>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
|
|
3
|
+
import { Button } from '@/components/button/button'
|
|
4
|
+
|
|
5
|
+
import { Tooltip, TooltipTrigger } from './tooltip'
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof TooltipTrigger> = {
|
|
8
|
+
title: 'Components/Tooltip',
|
|
9
|
+
component: TooltipTrigger,
|
|
10
|
+
tags: ['autodocs'],
|
|
11
|
+
parameters: { layout: 'padded' },
|
|
12
|
+
argTypes: {
|
|
13
|
+
delay: {
|
|
14
|
+
control: { type: 'number', min: 0, step: 50 },
|
|
15
|
+
description: 'Hover delay before the tooltip opens (ms). Focus ignores this.',
|
|
16
|
+
},
|
|
17
|
+
closeDelay: {
|
|
18
|
+
control: { type: 'number', min: 0, step: 50 },
|
|
19
|
+
description: 'Delay after pointer leaves before the tooltip closes (ms).',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default meta
|
|
25
|
+
type Story = StoryObj<typeof TooltipTrigger>
|
|
26
|
+
|
|
27
|
+
const Info = (
|
|
28
|
+
<svg viewBox="0 0 16 16" fill="none" aria-hidden>
|
|
29
|
+
<circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeWidth="1.2" />
|
|
30
|
+
<path d="M8 7.5v4M8 5v0.25" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
|
31
|
+
</svg>
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
export const Basic: Story = {
|
|
35
|
+
render: () => (
|
|
36
|
+
<TooltipTrigger>
|
|
37
|
+
<Button variant="ghost">Read manifesto</Button>
|
|
38
|
+
<Tooltip>Opens the one-pager on Proteos's design philosophy.</Tooltip>
|
|
39
|
+
</TooltipTrigger>
|
|
40
|
+
),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const OnIconButton: Story = {
|
|
44
|
+
render: () => (
|
|
45
|
+
<TooltipTrigger>
|
|
46
|
+
<Button size="icon-md" variant="ghost" aria-label="About">
|
|
47
|
+
{Info}
|
|
48
|
+
</Button>
|
|
49
|
+
<Tooltip>About Proteos</Tooltip>
|
|
50
|
+
</TooltipTrigger>
|
|
51
|
+
),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const WithShortcut: Story = {
|
|
55
|
+
render: () => (
|
|
56
|
+
<TooltipTrigger>
|
|
57
|
+
<Button variant="secondary">Compose</Button>
|
|
58
|
+
<Tooltip>
|
|
59
|
+
Compose system
|
|
60
|
+
<span className="ml-2 rounded-[3px] border border-rule bg-surface-2 px-1 py-px text-[10px] text-ink-3">
|
|
61
|
+
⌘ ↵
|
|
62
|
+
</span>
|
|
63
|
+
</Tooltip>
|
|
64
|
+
</TooltipTrigger>
|
|
65
|
+
),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* =========================================================================
|
|
69
|
+
Delay presets — hover times to compare against the default (500ms).
|
|
70
|
+
========================================================================= */
|
|
71
|
+
|
|
72
|
+
/** Opens the moment the pointer lands — good for icon buttons where the user
|
|
73
|
+
* is already hunting for the label. */
|
|
74
|
+
export const InstantDelay: Story = {
|
|
75
|
+
args: { delay: 0 },
|
|
76
|
+
render: (args) => (
|
|
77
|
+
<TooltipTrigger {...args}>
|
|
78
|
+
<Button size="icon-md" variant="ghost" aria-label="About">
|
|
79
|
+
{Info}
|
|
80
|
+
</Button>
|
|
81
|
+
<Tooltip>Opens with no delay — try hovering.</Tooltip>
|
|
82
|
+
</TooltipTrigger>
|
|
83
|
+
),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Snappy but not twitchy — the package default. Documented here so you can
|
|
87
|
+
* eyeball it against the instant/slow extremes. */
|
|
88
|
+
export const SnappyDelay: Story = {
|
|
89
|
+
args: { delay: 500 },
|
|
90
|
+
render: (args) => (
|
|
91
|
+
<TooltipTrigger {...args}>
|
|
92
|
+
<Button variant="ghost">Hover me (500 ms)</Button>
|
|
93
|
+
<Tooltip>Default hover delay — balanced for dense UIs.</Tooltip>
|
|
94
|
+
</TooltipTrigger>
|
|
95
|
+
),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Deliberately slow — mirrors RAC's out-of-the-box 1500ms. Useful when you
|
|
99
|
+
* want the tooltip to only show for users who *linger* on the target. */
|
|
100
|
+
export const SlowDelay: Story = {
|
|
101
|
+
args: { delay: 1500 },
|
|
102
|
+
render: (args) => (
|
|
103
|
+
<TooltipTrigger {...args}>
|
|
104
|
+
<Button variant="ghost">Hover me (1500 ms)</Button>
|
|
105
|
+
<Tooltip>Slow delay — only shows if you really pause on the target.</Tooltip>
|
|
106
|
+
</TooltipTrigger>
|
|
107
|
+
),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* =========================================================================
|
|
111
|
+
closeDelay in context — a row of adjacent targets. With `closeDelay` above
|
|
112
|
+
zero, moving between neighbours skips the re-open wait so the tooltips feel
|
|
113
|
+
like a chained sequence rather than four separate prompts.
|
|
114
|
+
========================================================================= */
|
|
115
|
+
|
|
116
|
+
export const ChainedWithCloseDelay: Story = {
|
|
117
|
+
args: { delay: 300, closeDelay: 300 },
|
|
118
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
119
|
+
render: (args) => (
|
|
120
|
+
<div className="flex items-center gap-1">
|
|
121
|
+
<TooltipTrigger {...args}>
|
|
122
|
+
<Button size="icon-md" variant="ghost" aria-label="Filter">
|
|
123
|
+
{Info}
|
|
124
|
+
</Button>
|
|
125
|
+
<Tooltip>Filter</Tooltip>
|
|
126
|
+
</TooltipTrigger>
|
|
127
|
+
<TooltipTrigger {...args}>
|
|
128
|
+
<Button size="icon-md" variant="ghost" aria-label="Group">
|
|
129
|
+
{Info}
|
|
130
|
+
</Button>
|
|
131
|
+
<Tooltip>Group</Tooltip>
|
|
132
|
+
</TooltipTrigger>
|
|
133
|
+
<TooltipTrigger {...args}>
|
|
134
|
+
<Button size="icon-md" variant="ghost" aria-label="Sort">
|
|
135
|
+
{Info}
|
|
136
|
+
</Button>
|
|
137
|
+
<Tooltip>Sort</Tooltip>
|
|
138
|
+
</TooltipTrigger>
|
|
139
|
+
<TooltipTrigger {...args}>
|
|
140
|
+
<Button size="icon-md" variant="ghost" aria-label="Export">
|
|
141
|
+
{Info}
|
|
142
|
+
</Button>
|
|
143
|
+
<Tooltip>Export</Tooltip>
|
|
144
|
+
</TooltipTrigger>
|
|
145
|
+
</div>
|
|
146
|
+
),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Opposite of the chain — each icon forces a fresh delay. Good for sparse
|
|
150
|
+
* toolbars where the tooltips aren't meant to read together. */
|
|
151
|
+
export const IsolatedNoCloseDelay: Story = {
|
|
152
|
+
args: { delay: 300, closeDelay: 0 },
|
|
153
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
154
|
+
render: (args) => (
|
|
155
|
+
<div className="flex items-center gap-1">
|
|
156
|
+
<TooltipTrigger {...args}>
|
|
157
|
+
<Button size="icon-md" variant="ghost" aria-label="Filter">
|
|
158
|
+
{Info}
|
|
159
|
+
</Button>
|
|
160
|
+
<Tooltip>Filter</Tooltip>
|
|
161
|
+
</TooltipTrigger>
|
|
162
|
+
<TooltipTrigger {...args}>
|
|
163
|
+
<Button size="icon-md" variant="ghost" aria-label="Group">
|
|
164
|
+
{Info}
|
|
165
|
+
</Button>
|
|
166
|
+
<Tooltip>Group</Tooltip>
|
|
167
|
+
</TooltipTrigger>
|
|
168
|
+
<TooltipTrigger {...args}>
|
|
169
|
+
<Button size="icon-md" variant="ghost" aria-label="Sort">
|
|
170
|
+
{Info}
|
|
171
|
+
</Button>
|
|
172
|
+
<Tooltip>Sort</Tooltip>
|
|
173
|
+
</TooltipTrigger>
|
|
174
|
+
</div>
|
|
175
|
+
),
|
|
176
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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 { Button } from '@/components/button/button'
|
|
6
|
+
|
|
7
|
+
import { Tooltip, TooltipTrigger } from './tooltip'
|
|
8
|
+
|
|
9
|
+
describe('Tooltip', () => {
|
|
10
|
+
it('is not visible by default', () => {
|
|
11
|
+
render(
|
|
12
|
+
<TooltipTrigger>
|
|
13
|
+
<Button>Info</Button>
|
|
14
|
+
<Tooltip>Helpful hint</Tooltip>
|
|
15
|
+
</TooltipTrigger>,
|
|
16
|
+
)
|
|
17
|
+
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('appears when the trigger receives keyboard focus', async () => {
|
|
21
|
+
const user = userEvent.setup()
|
|
22
|
+
render(
|
|
23
|
+
<TooltipTrigger>
|
|
24
|
+
<Button>Info</Button>
|
|
25
|
+
<Tooltip>Helpful hint</Tooltip>
|
|
26
|
+
</TooltipTrigger>,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
await user.tab()
|
|
30
|
+
expect(await screen.findByRole('tooltip')).toHaveTextContent('Helpful hint')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('hides when focus leaves the trigger', async () => {
|
|
34
|
+
const user = userEvent.setup()
|
|
35
|
+
render(
|
|
36
|
+
<>
|
|
37
|
+
<TooltipTrigger>
|
|
38
|
+
<Button>Info</Button>
|
|
39
|
+
<Tooltip>Helpful hint</Tooltip>
|
|
40
|
+
</TooltipTrigger>
|
|
41
|
+
<Button>Other</Button>
|
|
42
|
+
</>,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
await user.tab()
|
|
46
|
+
expect(await screen.findByRole('tooltip')).toBeInTheDocument()
|
|
47
|
+
|
|
48
|
+
await user.tab()
|
|
49
|
+
// Tooltip tears down once the trigger loses focus-visible.
|
|
50
|
+
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Tooltip as AriaTooltip,
|
|
3
|
+
type TooltipProps as AriaTooltipProps,
|
|
4
|
+
TooltipTrigger as AriaTooltipTrigger,
|
|
5
|
+
type TooltipTriggerComponentProps,
|
|
6
|
+
} from 'react-aria-components'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../../lib/cn'
|
|
9
|
+
|
|
10
|
+
/* =========================================================================
|
|
11
|
+
Tooltip — small mono label on hover/focus. Composition follows RAC:
|
|
12
|
+
<TooltipTrigger>
|
|
13
|
+
<Button>…</Button> // the hover/focus target
|
|
14
|
+
<Tooltip>Copy</Tooltip> // the floating label
|
|
15
|
+
</TooltipTrigger>
|
|
16
|
+
Use for icon-button labels, truncated text hints, keyboard shortcuts.
|
|
17
|
+
========================================================================= */
|
|
18
|
+
|
|
19
|
+
/** Stateful root. RAC handles hover delay + focus visibility.
|
|
20
|
+
*
|
|
21
|
+
* - `delay` — ms of hover before the tooltip opens. Keyboard focus opens
|
|
22
|
+
* instantly regardless. Default **500** (RAC's own default is 1500 which
|
|
23
|
+
* feels sluggish for a dense app). Set `0` for instant tooltips.
|
|
24
|
+
* - `closeDelay` — ms after the pointer leaves before the tooltip hides.
|
|
25
|
+
* Default **0** (close immediately). Raise it if adjacent targets share
|
|
26
|
+
* tooltips and you want them to chain without re-waiting.
|
|
27
|
+
*/
|
|
28
|
+
export function TooltipTrigger({
|
|
29
|
+
delay = 500,
|
|
30
|
+
closeDelay = 0,
|
|
31
|
+
...props
|
|
32
|
+
}: TooltipTriggerComponentProps) {
|
|
33
|
+
return <AriaTooltipTrigger delay={delay} closeDelay={closeDelay} {...props} />
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type TooltipProps = AriaTooltipProps
|
|
37
|
+
|
|
38
|
+
export function Tooltip({ className, offset = 6, ...props }: TooltipProps) {
|
|
39
|
+
return (
|
|
40
|
+
<AriaTooltip
|
|
41
|
+
{...props}
|
|
42
|
+
offset={offset}
|
|
43
|
+
className={(renderProps) =>
|
|
44
|
+
cn(
|
|
45
|
+
'z-50 max-w-xs',
|
|
46
|
+
'rounded-md border border-rule bg-surface shadow-[var(--shadow-raised)]',
|
|
47
|
+
'px-2.5 py-1.5',
|
|
48
|
+
'font-mono text-[11.5px] text-ink-2',
|
|
49
|
+
'entering:animate-in entering:fade-in-0 entering:slide-in-from-top-1 entering:duration-150',
|
|
50
|
+
'exiting:animate-out exiting:fade-out-0 exiting:duration-100',
|
|
51
|
+
typeof className === 'function' ? className(renderProps) : className,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
/>
|
|
55
|
+
)
|
|
56
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export * from './components/attachment-chip'
|
|
2
|
+
export * from './components/attribute-type-icon'
|
|
3
|
+
export * from './components/avatar'
|
|
4
|
+
export * from './components/avatar-group'
|
|
5
|
+
export * from './components/badge'
|
|
6
|
+
export * from './components/badge-multi-select'
|
|
7
|
+
export * from './components/breadcrumbs'
|
|
8
|
+
export * from './components/button'
|
|
9
|
+
export * from './components/card'
|
|
10
|
+
export * from './components/checkbox'
|
|
11
|
+
export * from './components/combobox'
|
|
12
|
+
export * from './components/command'
|
|
13
|
+
export * from './components/currency-input'
|
|
14
|
+
export * from './components/data-table'
|
|
15
|
+
export * from './components/date-picker'
|
|
16
|
+
export * from './components/date-time-picker'
|
|
17
|
+
export * from './components/empty-state'
|
|
18
|
+
export * from './components/error-animation'
|
|
19
|
+
export * from './components/field'
|
|
20
|
+
export * from './components/file-field'
|
|
21
|
+
export * from './components/file-viewer'
|
|
22
|
+
export * from './components/image-attachment'
|
|
23
|
+
export * from './components/image-lightbox'
|
|
24
|
+
export * from './components/input'
|
|
25
|
+
export * from './components/kbd'
|
|
26
|
+
export * from './components/menu'
|
|
27
|
+
export * from './components/modal'
|
|
28
|
+
export * from './components/multi-select'
|
|
29
|
+
export * from './components/nav'
|
|
30
|
+
export * from './components/popover'
|
|
31
|
+
export * from './components/progress-bar'
|
|
32
|
+
export * from './components/radio-group'
|
|
33
|
+
export * from './components/record-filter'
|
|
34
|
+
export * from './components/segmented-control'
|
|
35
|
+
export * from './components/select'
|
|
36
|
+
export * from './components/separator'
|
|
37
|
+
export * from './components/skeleton'
|
|
38
|
+
export * from './components/success-animation'
|
|
39
|
+
export * from './components/switch'
|
|
40
|
+
export * from './components/table'
|
|
41
|
+
export * from './components/tabs'
|
|
42
|
+
export * from './components/tag-input'
|
|
43
|
+
export * from './components/textarea'
|
|
44
|
+
export * from './components/time-field'
|
|
45
|
+
export * from './components/tooltip'
|
|
46
|
+
|
|
47
|
+
export { cn } from './lib/cn'
|
package/src/lib/cn.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { act, render, screen } from '@testing-library/react'
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
_getDiscoverySubscriberCount,
|
|
6
|
+
_resetDiscovery,
|
|
7
|
+
useDiscoveryActive,
|
|
8
|
+
} from './discovery-store'
|
|
9
|
+
import { _setPlatformForTests } from './platform'
|
|
10
|
+
|
|
11
|
+
function Probe() {
|
|
12
|
+
const active = useDiscoveryActive()
|
|
13
|
+
return <span data-testid="probe">{active ? 'on' : 'off'}</span>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function holdCmdShift() {
|
|
17
|
+
act(() => {
|
|
18
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift', shiftKey: true }))
|
|
19
|
+
window.dispatchEvent(
|
|
20
|
+
new KeyboardEvent('keydown', { key: 'Meta', shiftKey: true, metaKey: true }),
|
|
21
|
+
)
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function releaseMeta() {
|
|
26
|
+
act(() => {
|
|
27
|
+
window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Meta', shiftKey: true }))
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('useDiscoveryActive', () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
_setPlatformForTests('mac')
|
|
34
|
+
_resetDiscovery()
|
|
35
|
+
})
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
_setPlatformForTests(undefined)
|
|
38
|
+
_resetDiscovery()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('is false initially', () => {
|
|
42
|
+
render(<Probe />)
|
|
43
|
+
expect(screen.getByTestId('probe').textContent).toBe('off')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('flips on when Cmd+Shift are both held', () => {
|
|
47
|
+
render(<Probe />)
|
|
48
|
+
holdCmdShift()
|
|
49
|
+
expect(screen.getByTestId('probe').textContent).toBe('on')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('flips off when one modifier is released', () => {
|
|
53
|
+
render(<Probe />)
|
|
54
|
+
holdCmdShift()
|
|
55
|
+
releaseMeta()
|
|
56
|
+
expect(screen.getByTestId('probe').textContent).toBe('off')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('keeps a single subscriber per mounted Probe (one listener total at the module level)', () => {
|
|
60
|
+
const { unmount: u1 } = render(<Probe />)
|
|
61
|
+
const { unmount: u2 } = render(<Probe />)
|
|
62
|
+
expect(_getDiscoverySubscriberCount()).toBe(2)
|
|
63
|
+
u1()
|
|
64
|
+
u2()
|
|
65
|
+
expect(_getDiscoverySubscriberCount()).toBe(0)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('resets on blur (alt-tab scenario)', () => {
|
|
69
|
+
render(<Probe />)
|
|
70
|
+
holdCmdShift()
|
|
71
|
+
act(() => {
|
|
72
|
+
window.dispatchEvent(new Event('blur'))
|
|
73
|
+
})
|
|
74
|
+
expect(screen.getByTestId('probe').textContent).toBe('off')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/* Module-singleton store tracking the Cmd+Shift "discovery hold". One pair
|
|
2
|
+
of window listeners regardless of how many <Command> wrappers subscribe.
|
|
3
|
+
Subscribers re-render via useSyncExternalStore when the both-held flag
|
|
4
|
+
flips. Resyncs from event modifier flags on every key event so it stays
|
|
5
|
+
accurate even if the browser drops a modifier key event (focus changes,
|
|
6
|
+
alt-tab, etc.). */
|
|
7
|
+
|
|
8
|
+
import { useSyncExternalStore } from 'react'
|
|
9
|
+
|
|
10
|
+
import { getPlatform } from './platform'
|
|
11
|
+
|
|
12
|
+
type Listener = () => void
|
|
13
|
+
|
|
14
|
+
let cmdHeld = false
|
|
15
|
+
let shiftHeld = false
|
|
16
|
+
let initialized = false
|
|
17
|
+
const listeners = new Set<Listener>()
|
|
18
|
+
|
|
19
|
+
function getSnapshot(): boolean {
|
|
20
|
+
return cmdHeld && shiftHeld
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getServerSnapshot(): boolean {
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function emit(): void {
|
|
28
|
+
for (const l of listeners) l()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sync(cmdNow: boolean, shiftNow: boolean): void {
|
|
32
|
+
if (cmdNow !== cmdHeld || shiftNow !== shiftHeld) {
|
|
33
|
+
cmdHeld = cmdNow
|
|
34
|
+
shiftHeld = shiftNow
|
|
35
|
+
emit()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function onKeyDown(e: KeyboardEvent): void {
|
|
40
|
+
const platform = getPlatform()
|
|
41
|
+
const cmdFlag = platform === 'mac' ? e.metaKey : e.ctrlKey
|
|
42
|
+
// For the modifier key itself, trust the event type (the flag lags on press).
|
|
43
|
+
if (e.key === 'Meta' || e.key === 'Control') {
|
|
44
|
+
sync(true, e.shiftKey)
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
if (e.key === 'Shift') {
|
|
48
|
+
sync(cmdFlag, true)
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
sync(cmdFlag, e.shiftKey)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function onKeyUp(e: KeyboardEvent): void {
|
|
55
|
+
const platform = getPlatform()
|
|
56
|
+
const cmdFlag = platform === 'mac' ? e.metaKey : e.ctrlKey
|
|
57
|
+
if (e.key === 'Meta' || e.key === 'Control') {
|
|
58
|
+
sync(false, e.shiftKey)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
if (e.key === 'Shift') {
|
|
62
|
+
sync(cmdFlag, false)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
sync(cmdFlag, e.shiftKey)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function onBlur(): void {
|
|
69
|
+
// The window lost focus — assume the user released everything. Without
|
|
70
|
+
// this, holding Cmd+Shift then alt-tabbing would leave us stuck in
|
|
71
|
+
// discovery mode until the next keydown.
|
|
72
|
+
sync(false, false)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function ensureInitialized(): void {
|
|
76
|
+
if (initialized) return
|
|
77
|
+
if (typeof window === 'undefined') return
|
|
78
|
+
window.addEventListener('keydown', onKeyDown, { capture: true })
|
|
79
|
+
window.addEventListener('keyup', onKeyUp, { capture: true })
|
|
80
|
+
window.addEventListener('blur', onBlur)
|
|
81
|
+
initialized = true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function subscribe(listener: Listener): () => void {
|
|
85
|
+
ensureInitialized()
|
|
86
|
+
listeners.add(listener)
|
|
87
|
+
return () => {
|
|
88
|
+
listeners.delete(listener)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function useDiscoveryActive(): boolean {
|
|
93
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Test-only: number of installed listeners is the number of subscribers.
|
|
97
|
+
// The window-level listeners are installed at most once (initialized flag).
|
|
98
|
+
export function _getDiscoverySubscriberCount(): number {
|
|
99
|
+
return listeners.size
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function _resetDiscovery(): void {
|
|
103
|
+
cmdHeld = false
|
|
104
|
+
shiftHeld = false
|
|
105
|
+
listeners.clear()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function _isDiscoveryInitialized(): boolean {
|
|
109
|
+
return initialized
|
|
110
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type { KeyCombo } from './parse-combo'
|
|
2
|
+
export { parseCombo } from './parse-combo'
|
|
3
|
+
export { PROTECTED_COMBOS } from './protected-combos'
|
|
4
|
+
export { isEditableTarget } from './match-event'
|
|
5
|
+
export type { UseCommandOptions } from './use-command'
|
|
6
|
+
export { useCommand } from './use-command'
|
|
7
|
+
export type { UseEscapeKeyOptions } from './use-escape-key'
|
|
8
|
+
export { useEscapeKey } from './use-escape-key'
|
|
9
|
+
export { useDiscoveryActive } from './discovery-store'
|