@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,42 @@
|
|
|
1
|
+
import { Skeleton } from '../../skeleton'
|
|
2
|
+
import { TableBody, TableCell, TableRow } from '../../table'
|
|
3
|
+
|
|
4
|
+
export type DataTableSkeletonProps = {
|
|
5
|
+
rows?: number
|
|
6
|
+
columnWidths?: ReadonlyArray<number | string>
|
|
7
|
+
rowHeight?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Skeleton placeholder shown while the first page is loading.
|
|
12
|
+
* Renders inside the same TableBody scroller, so the layout doesn't jump
|
|
13
|
+
* when real rows replace it.
|
|
14
|
+
*/
|
|
15
|
+
export function DataTableSkeleton({
|
|
16
|
+
rows = 8,
|
|
17
|
+
columnWidths = [120, 240, 160, 120, 120],
|
|
18
|
+
rowHeight = 32,
|
|
19
|
+
}: DataTableSkeletonProps) {
|
|
20
|
+
return (
|
|
21
|
+
<TableBody role="rowgroup" aria-label="Loading rows">
|
|
22
|
+
{Array.from({ length: rows }).map((_, rowIdx) => (
|
|
23
|
+
<TableRow
|
|
24
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: deterministic placeholder rows
|
|
25
|
+
key={rowIdx}
|
|
26
|
+
alt={rowIdx % 2 === 1}
|
|
27
|
+
style={{ height: rowHeight }}
|
|
28
|
+
>
|
|
29
|
+
{columnWidths.map((w, colIdx) => (
|
|
30
|
+
<TableCell
|
|
31
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: deterministic placeholder cells
|
|
32
|
+
key={colIdx}
|
|
33
|
+
width={w}
|
|
34
|
+
>
|
|
35
|
+
<Skeleton shape="text" className="h-3 max-w-[60%]" />
|
|
36
|
+
</TableCell>
|
|
37
|
+
))}
|
|
38
|
+
</TableRow>
|
|
39
|
+
))}
|
|
40
|
+
</TableBody>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type RefObject, useEffect, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
export type UseInfiniteScrollSentinelOptions = {
|
|
4
|
+
onIntersect: () => void
|
|
5
|
+
/**
|
|
6
|
+
* IntersectionObserver `rootMargin`. Default `200px` so the next page
|
|
7
|
+
* starts fetching before the user scrolls all the way to the bottom.
|
|
8
|
+
*/
|
|
9
|
+
rootMargin?: string
|
|
10
|
+
/**
|
|
11
|
+
* Optional scroll-container ref (passed as the observer's `root`). When
|
|
12
|
+
* omitted, the observer uses the viewport.
|
|
13
|
+
*/
|
|
14
|
+
root?: Element | null
|
|
15
|
+
enabled?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Mounts a sentinel element ref. When the element scrolls into view (with
|
|
20
|
+
* the given rootMargin), `onIntersect` fires once per visible streak.
|
|
21
|
+
* Used by DataTable to drive `query.fetchNextPage()`.
|
|
22
|
+
*/
|
|
23
|
+
export function useInfiniteScrollSentinel<T extends Element = HTMLDivElement>({
|
|
24
|
+
onIntersect,
|
|
25
|
+
rootMargin = '200px',
|
|
26
|
+
root,
|
|
27
|
+
enabled = true,
|
|
28
|
+
}: UseInfiniteScrollSentinelOptions): RefObject<T | null> {
|
|
29
|
+
const ref = useRef<T | null>(null)
|
|
30
|
+
// Latest callback in a ref so the observer doesn't re-create on every render.
|
|
31
|
+
const cbRef = useRef(onIntersect)
|
|
32
|
+
cbRef.current = onIntersect
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!enabled) return
|
|
36
|
+
const node = ref.current
|
|
37
|
+
if (!node) return
|
|
38
|
+
const observer = new IntersectionObserver(
|
|
39
|
+
(entries) => {
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
if (entry.isIntersecting) cbRef.current()
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{ root: root ?? null, rootMargin, threshold: 0 },
|
|
45
|
+
)
|
|
46
|
+
observer.observe(node)
|
|
47
|
+
return () => observer.disconnect()
|
|
48
|
+
}, [enabled, rootMargin, root])
|
|
49
|
+
|
|
50
|
+
return ref
|
|
51
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button as AriaDateTrigger,
|
|
3
|
+
Calendar,
|
|
4
|
+
CalendarCell,
|
|
5
|
+
CalendarGrid,
|
|
6
|
+
CalendarGridBody,
|
|
7
|
+
CalendarGridHeader,
|
|
8
|
+
CalendarHeaderCell,
|
|
9
|
+
Heading,
|
|
10
|
+
} from 'react-aria-components'
|
|
11
|
+
import { cn } from '../../lib/cn'
|
|
12
|
+
|
|
13
|
+
/* Shared calendar chrome for DatePicker + DateTimePicker. Internal — NOT
|
|
14
|
+
barrelled from the package root. Both pickers render
|
|
15
|
+
`<Popover><Dialog><CalendarSurface/>…</Dialog></Popover>`; DateTimePicker
|
|
16
|
+
additionally renders a TimeField under the grid. Keeping the icons + grid
|
|
17
|
+
here means the two pickers can never visually drift apart. */
|
|
18
|
+
|
|
19
|
+
/* ----- icons (decorative) --------------------------------------------------- */
|
|
20
|
+
|
|
21
|
+
export function CalendarGlyph() {
|
|
22
|
+
return (
|
|
23
|
+
<svg aria-hidden viewBox="0 0 16 16" className="size-4" fill="none">
|
|
24
|
+
<rect
|
|
25
|
+
x="2.5"
|
|
26
|
+
y="3.5"
|
|
27
|
+
width="11"
|
|
28
|
+
height="10"
|
|
29
|
+
rx="1.5"
|
|
30
|
+
stroke="currentColor"
|
|
31
|
+
strokeWidth="1.2"
|
|
32
|
+
/>
|
|
33
|
+
<path d="M2.5 6.5H13.5" stroke="currentColor" strokeWidth="1.2" />
|
|
34
|
+
<path d="M5.5 2V5M10.5 2V5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
|
35
|
+
</svg>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ChevronLeft() {
|
|
40
|
+
return (
|
|
41
|
+
<svg aria-hidden viewBox="0 0 12 12" className="size-3" fill="none">
|
|
42
|
+
<path
|
|
43
|
+
d="M7.5 3L4.5 6L7.5 9"
|
|
44
|
+
stroke="currentColor"
|
|
45
|
+
strokeWidth="1.3"
|
|
46
|
+
strokeLinecap="round"
|
|
47
|
+
strokeLinejoin="round"
|
|
48
|
+
/>
|
|
49
|
+
</svg>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ChevronRight() {
|
|
54
|
+
return (
|
|
55
|
+
<svg aria-hidden viewBox="0 0 12 12" className="size-3" fill="none">
|
|
56
|
+
<path
|
|
57
|
+
d="M4.5 3L7.5 6L4.5 9"
|
|
58
|
+
stroke="currentColor"
|
|
59
|
+
strokeWidth="1.3"
|
|
60
|
+
strokeLinecap="round"
|
|
61
|
+
strokeLinejoin="round"
|
|
62
|
+
/>
|
|
63
|
+
</svg>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* --------------------------------------------------------------------------- */
|
|
68
|
+
|
|
69
|
+
/** The `<Calendar>` block — month header (prev/next + heading) plus the day
|
|
70
|
+
* grid. Rendered inside the picker's `<Dialog>`. Cosmetics-only; the
|
|
71
|
+
* surrounding Popover/Dialog and any TimeField are the consumer's job. */
|
|
72
|
+
export function CalendarSurface() {
|
|
73
|
+
return (
|
|
74
|
+
<Calendar>
|
|
75
|
+
<header className="flex items-center justify-between mb-3">
|
|
76
|
+
<AriaDateTrigger
|
|
77
|
+
slot="previous"
|
|
78
|
+
aria-label="Previous month"
|
|
79
|
+
className={cn(
|
|
80
|
+
'size-7 rounded-md inline-flex items-center justify-center',
|
|
81
|
+
'text-ink-3 hover:text-ink hover:bg-surface-2 cursor-pointer outline-none',
|
|
82
|
+
'focus-visible:shadow-[var(--shadow-focus)]',
|
|
83
|
+
)}
|
|
84
|
+
>
|
|
85
|
+
<ChevronLeft />
|
|
86
|
+
</AriaDateTrigger>
|
|
87
|
+
<Heading className="font-sans font-medium text-[14px] text-ink tracking-[-0.01em]" />
|
|
88
|
+
<AriaDateTrigger
|
|
89
|
+
slot="next"
|
|
90
|
+
aria-label="Next month"
|
|
91
|
+
className={cn(
|
|
92
|
+
'size-7 rounded-md inline-flex items-center justify-center',
|
|
93
|
+
'text-ink-3 hover:text-ink hover:bg-surface-2 cursor-pointer outline-none',
|
|
94
|
+
'focus-visible:shadow-[var(--shadow-focus)]',
|
|
95
|
+
)}
|
|
96
|
+
>
|
|
97
|
+
<ChevronRight />
|
|
98
|
+
</AriaDateTrigger>
|
|
99
|
+
</header>
|
|
100
|
+
|
|
101
|
+
<CalendarGrid className="w-full border-collapse table-fixed">
|
|
102
|
+
<CalendarGridHeader>
|
|
103
|
+
{(day) => (
|
|
104
|
+
<CalendarHeaderCell
|
|
105
|
+
className={cn(
|
|
106
|
+
'font-mono text-[10.5px] uppercase tracking-[0.08em] text-ink-4',
|
|
107
|
+
'pb-2 text-center',
|
|
108
|
+
)}
|
|
109
|
+
>
|
|
110
|
+
{day}
|
|
111
|
+
</CalendarHeaderCell>
|
|
112
|
+
)}
|
|
113
|
+
</CalendarGridHeader>
|
|
114
|
+
<CalendarGridBody>
|
|
115
|
+
{(date) => (
|
|
116
|
+
<CalendarCell
|
|
117
|
+
date={date}
|
|
118
|
+
className={cn(
|
|
119
|
+
// aspect-square + table-fixed makes each cell a perfect
|
|
120
|
+
// square that scales with popover width.
|
|
121
|
+
'aspect-square flex items-center justify-center cursor-pointer',
|
|
122
|
+
'font-mono text-[12.5px] text-ink-2 rounded-[6px] outline-none',
|
|
123
|
+
'transition-[background-color,color] duration-150',
|
|
124
|
+
'data-[outside-month]:text-ink-4',
|
|
125
|
+
'data-[hovered]:bg-surface-2 data-[hovered]:text-ink',
|
|
126
|
+
'data-[focus-visible]:shadow-[var(--shadow-focus)]',
|
|
127
|
+
'data-[selected]:bg-accent data-[selected]:text-[#f0efe8]',
|
|
128
|
+
'data-[selected]:data-[hovered]:bg-accent-2',
|
|
129
|
+
'data-[disabled]:opacity-30 data-[disabled]:cursor-not-allowed',
|
|
130
|
+
'data-[unavailable]:line-through data-[unavailable]:text-ink-4',
|
|
131
|
+
)}
|
|
132
|
+
/>
|
|
133
|
+
)}
|
|
134
|
+
</CalendarGridBody>
|
|
135
|
+
</CalendarGrid>
|
|
136
|
+
</Calendar>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { getLocalTimeZone, parseDate, today } from '@internationalized/date'
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
3
|
+
|
|
4
|
+
import { DatePicker } from './date-picker'
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof DatePicker> = {
|
|
7
|
+
title: 'Components/DatePicker',
|
|
8
|
+
component: DatePicker,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
args: {
|
|
11
|
+
label: 'Ship date',
|
|
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-[300px]">
|
|
21
|
+
<Story />
|
|
22
|
+
</div>
|
|
23
|
+
),
|
|
24
|
+
],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default meta
|
|
28
|
+
type Story = StoryObj<typeof DatePicker>
|
|
29
|
+
|
|
30
|
+
export const Playground: Story = {}
|
|
31
|
+
|
|
32
|
+
export const WithValue: Story = {
|
|
33
|
+
args: {
|
|
34
|
+
defaultValue: parseDate('2026-04-24'),
|
|
35
|
+
description: 'Pre-filled with Apr 24, 2026.',
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const WithDescription: Story = {
|
|
40
|
+
args: {
|
|
41
|
+
description: 'When the shipment leaves the yard.',
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const Invalid: Story = {
|
|
46
|
+
args: {
|
|
47
|
+
isInvalid: true,
|
|
48
|
+
errorMessage: 'Pick a future date.',
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const Disabled: Story = {
|
|
53
|
+
args: {
|
|
54
|
+
defaultValue: parseDate('2026-04-24'),
|
|
55
|
+
isDisabled: true,
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const MinMax: Story = {
|
|
60
|
+
args: {
|
|
61
|
+
defaultValue: today(getLocalTimeZone()),
|
|
62
|
+
minValue: today(getLocalTimeZone()),
|
|
63
|
+
maxValue: today(getLocalTimeZone()).add({ months: 3 }),
|
|
64
|
+
description: 'Limited to the next 3 months.',
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const AllStates: Story = {
|
|
69
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
70
|
+
render: () => (
|
|
71
|
+
<div className="grid gap-5 w-[300px]">
|
|
72
|
+
<DatePicker label="Rest" />
|
|
73
|
+
<DatePicker label="Selected" defaultValue={parseDate('2026-04-24')} />
|
|
74
|
+
<DatePicker label="Invalid" isInvalid errorMessage="Pick a future date." />
|
|
75
|
+
<DatePicker label="Disabled" defaultValue={parseDate('2026-04-24')} isDisabled />
|
|
76
|
+
</div>
|
|
77
|
+
),
|
|
78
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type CalendarDate, parseDate } 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 { DatePicker } from './date-picker'
|
|
7
|
+
|
|
8
|
+
describe('DatePicker', () => {
|
|
9
|
+
it('renders a labelled date input', () => {
|
|
10
|
+
render(<DatePicker label="Ship date" />)
|
|
11
|
+
expect(screen.getByText('Ship date')).toBeInTheDocument()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('opens the calendar popover when the trigger is clicked', async () => {
|
|
15
|
+
const user = userEvent.setup()
|
|
16
|
+
render(<DatePicker label="Ship date" />)
|
|
17
|
+
|
|
18
|
+
// The calendar-glyph trigger is the only <button> in the field (date
|
|
19
|
+
// segments are spinbuttons). When closed, it's the last <button> in the DOM.
|
|
20
|
+
const buttons = screen.getAllByRole('button')
|
|
21
|
+
const trigger = buttons[buttons.length - 1]
|
|
22
|
+
if (!trigger) throw new Error('calendar trigger missing')
|
|
23
|
+
|
|
24
|
+
await user.click(trigger)
|
|
25
|
+
expect(await screen.findByRole('application')).toBeInTheDocument()
|
|
26
|
+
expect(screen.getByRole('grid')).toBeInTheDocument()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('selects a date and fires onChange', async () => {
|
|
30
|
+
const user = userEvent.setup()
|
|
31
|
+
const onChange = vi.fn()
|
|
32
|
+
render(
|
|
33
|
+
<DatePicker label="Ship date" defaultValue={parseDate('2026-04-24')} onChange={onChange} />,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
// Open the calendar by pressing the first non-segment button.
|
|
37
|
+
const buttons = screen.getAllByRole('button')
|
|
38
|
+
const trigger = buttons[buttons.length - 1]
|
|
39
|
+
if (!trigger) throw new Error('trigger missing')
|
|
40
|
+
await user.click(trigger)
|
|
41
|
+
|
|
42
|
+
// Click a different cell in the current (April 2026) grid — the 15th.
|
|
43
|
+
const fifteenth = await screen.findByRole('button', { name: /April 15, 2026/i })
|
|
44
|
+
await user.click(fifteenth)
|
|
45
|
+
|
|
46
|
+
expect(onChange).toHaveBeenCalled()
|
|
47
|
+
const called = onChange.mock.calls[0]?.[0] as CalendarDate | null
|
|
48
|
+
expect(called?.day).toBe(15)
|
|
49
|
+
expect(called?.month).toBe(4)
|
|
50
|
+
expect(called?.year).toBe(2026)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('renders the error message when invalid', () => {
|
|
54
|
+
render(<DatePicker label="Ship date" isInvalid errorMessage="Pick a future date." />)
|
|
55
|
+
expect(screen.getByText('Pick a future date.')).toBeInTheDocument()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('disables the control when isDisabled is set', () => {
|
|
59
|
+
render(<DatePicker label="Ship date" isDisabled defaultValue={parseDate('2026-04-24')} />)
|
|
60
|
+
// Every visible button in the field should be disabled.
|
|
61
|
+
const buttons = screen.getAllByRole('button')
|
|
62
|
+
for (const btn of buttons) {
|
|
63
|
+
expect(btn).toBeDisabled()
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('shows the description slot', () => {
|
|
68
|
+
render(<DatePicker label="Ship date" description="When it leaves the yard." />)
|
|
69
|
+
expect(screen.getByText('When it leaves the yard.')).toBeInTheDocument()
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
DatePicker as AriaDatePicker,
|
|
4
|
+
type DatePickerProps as AriaDatePickerProps,
|
|
5
|
+
Button as AriaDateTrigger,
|
|
6
|
+
DateInput,
|
|
7
|
+
DateSegment,
|
|
8
|
+
type DateValue,
|
|
9
|
+
Dialog,
|
|
10
|
+
FieldError,
|
|
11
|
+
Group,
|
|
12
|
+
Label,
|
|
13
|
+
Popover,
|
|
14
|
+
Text,
|
|
15
|
+
} from 'react-aria-components'
|
|
16
|
+
import { cn } from '../../lib/cn'
|
|
17
|
+
import {
|
|
18
|
+
type FieldAppearance,
|
|
19
|
+
fieldControlStyles,
|
|
20
|
+
fieldErrorStyles,
|
|
21
|
+
type FieldSize,
|
|
22
|
+
fieldHintStyles,
|
|
23
|
+
fieldLabelStyles,
|
|
24
|
+
} from '../field/field'
|
|
25
|
+
import { CalendarGlyph, CalendarSurface } from './calendar-internals'
|
|
26
|
+
|
|
27
|
+
export type DatePickerProps<T extends DateValue> = AriaDatePickerProps<T> & {
|
|
28
|
+
label?: ReactNode
|
|
29
|
+
description?: ReactNode
|
|
30
|
+
errorMessage?: ReactNode
|
|
31
|
+
/** Field size. Defaults to `md`. */
|
|
32
|
+
size?: FieldSize
|
|
33
|
+
/** Chrome variant. Defaults to `field`. `cell` flattens the box. */
|
|
34
|
+
appearance?: FieldAppearance
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function DatePicker<T extends DateValue>({
|
|
38
|
+
label,
|
|
39
|
+
description,
|
|
40
|
+
errorMessage,
|
|
41
|
+
size,
|
|
42
|
+
appearance,
|
|
43
|
+
className,
|
|
44
|
+
...props
|
|
45
|
+
}: DatePickerProps<T>) {
|
|
46
|
+
const styles = fieldControlStyles({ size, appearance })
|
|
47
|
+
const focusWithin =
|
|
48
|
+
appearance === 'cell'
|
|
49
|
+
? 'focus-within:border-accent focus-within:shadow-none'
|
|
50
|
+
: 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]'
|
|
51
|
+
return (
|
|
52
|
+
<AriaDatePicker
|
|
53
|
+
{...props}
|
|
54
|
+
className={(renderProps) =>
|
|
55
|
+
cn(
|
|
56
|
+
'grid grid-cols-1 gap-1.5',
|
|
57
|
+
typeof className === 'function' ? className(renderProps) : className,
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
>
|
|
61
|
+
{label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
|
|
62
|
+
|
|
63
|
+
<Group
|
|
64
|
+
className={cn(
|
|
65
|
+
// Box chrome on the wrapper; the DateInput inside carries padding +
|
|
66
|
+
// font so the calendar trigger sits inside the same border + ring.
|
|
67
|
+
styles.box(),
|
|
68
|
+
'flex items-center gap-1 pr-1',
|
|
69
|
+
focusWithin,
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
<DateInput
|
|
73
|
+
className={cn(
|
|
74
|
+
'flex-1 flex items-center min-w-0 text-ink',
|
|
75
|
+
styles.content(),
|
|
76
|
+
// Tabular alignment for date segments — overrides content's
|
|
77
|
+
// font-sans on purpose.
|
|
78
|
+
'font-mono',
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
{(segment) => (
|
|
82
|
+
<DateSegment
|
|
83
|
+
segment={segment}
|
|
84
|
+
className={cn(
|
|
85
|
+
'px-px tabular-nums outline-none select-none',
|
|
86
|
+
'data-[placeholder]:text-ink-4',
|
|
87
|
+
'data-[type=literal]:text-ink-4',
|
|
88
|
+
// segment focus treatment: indigo fill, paper text
|
|
89
|
+
'data-[focused]:bg-accent data-[focused]:text-[#f0efe8] data-[focused]:rounded-[3px]',
|
|
90
|
+
'data-[disabled]:cursor-not-allowed',
|
|
91
|
+
)}
|
|
92
|
+
/>
|
|
93
|
+
)}
|
|
94
|
+
</DateInput>
|
|
95
|
+
|
|
96
|
+
<AriaDateTrigger
|
|
97
|
+
className={cn(
|
|
98
|
+
'shrink-0 inline-flex items-center justify-center',
|
|
99
|
+
'size-7 rounded-md',
|
|
100
|
+
'text-ink-3 hover:text-ink hover:bg-surface-2',
|
|
101
|
+
'outline-none cursor-pointer',
|
|
102
|
+
'focus-visible:shadow-[var(--shadow-focus)]',
|
|
103
|
+
'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
|
|
104
|
+
)}
|
|
105
|
+
>
|
|
106
|
+
<CalendarGlyph />
|
|
107
|
+
</AriaDateTrigger>
|
|
108
|
+
</Group>
|
|
109
|
+
|
|
110
|
+
{description ? (
|
|
111
|
+
<Text slot="description" className={fieldHintStyles}>
|
|
112
|
+
{description}
|
|
113
|
+
</Text>
|
|
114
|
+
) : null}
|
|
115
|
+
{errorMessage && appearance !== 'cell' ? (
|
|
116
|
+
<FieldError className={fieldErrorStyles}>{errorMessage}</FieldError>
|
|
117
|
+
) : null}
|
|
118
|
+
|
|
119
|
+
<Popover
|
|
120
|
+
offset={6}
|
|
121
|
+
className={cn(
|
|
122
|
+
// Match the anchoring field's width exactly. `--trigger-width` is
|
|
123
|
+
// set by RAC on the Popover when it's inside a DatePicker. Floor at
|
|
124
|
+
// 18rem so the calendar stays legible when the input is unusually
|
|
125
|
+
// narrow, and cap at 600px so it doesn't sprawl behind a very wide
|
|
126
|
+
// field.
|
|
127
|
+
'w-[var(--trigger-width)] min-w-[18rem] max-w-[500px]',
|
|
128
|
+
'rounded-md border border-rule bg-surface p-4',
|
|
129
|
+
'shadow-[var(--shadow-raised)]',
|
|
130
|
+
'entering:animate-in entering:fade-in-0 entering:slide-in-from-top-1',
|
|
131
|
+
'exiting:animate-out exiting:fade-out-0 exiting:slide-out-to-top-1',
|
|
132
|
+
)}
|
|
133
|
+
>
|
|
134
|
+
<Dialog className="outline-none">
|
|
135
|
+
<CalendarSurface />
|
|
136
|
+
</Dialog>
|
|
137
|
+
</Popover>
|
|
138
|
+
</AriaDatePicker>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { getLocalTimeZone, now, parseAbsoluteToLocal } from '@internationalized/date'
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
3
|
+
|
|
4
|
+
import { DateTimePicker } from './date-time-picker'
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof DateTimePicker> = {
|
|
7
|
+
title: 'Components/DateTimePicker',
|
|
8
|
+
component: DateTimePicker,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
args: {
|
|
11
|
+
label: 'Starts 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-[320px]">
|
|
21
|
+
<Story />
|
|
22
|
+
</div>
|
|
23
|
+
),
|
|
24
|
+
],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default meta
|
|
28
|
+
type Story = StoryObj<typeof DateTimePicker>
|
|
29
|
+
|
|
30
|
+
export const Playground: Story = {}
|
|
31
|
+
|
|
32
|
+
export const WithValue: Story = {
|
|
33
|
+
args: {
|
|
34
|
+
defaultValue: parseAbsoluteToLocal('2026-05-04T14:30:00Z'),
|
|
35
|
+
description: 'Pre-filled — edit the time inline, or pick it in the popover.',
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const TwentyFourHour: Story = {
|
|
40
|
+
args: {
|
|
41
|
+
defaultValue: parseAbsoluteToLocal('2026-05-04T14:30:00Z'),
|
|
42
|
+
hourCycle: 24,
|
|
43
|
+
description: '24-hour clock.',
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const SecondPrecision: Story = {
|
|
48
|
+
args: {
|
|
49
|
+
defaultValue: parseAbsoluteToLocal('2026-05-04T14:30:05Z'),
|
|
50
|
+
granularity: 'second',
|
|
51
|
+
description: 'Hour : minute : second.',
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const Invalid: Story = {
|
|
56
|
+
args: {
|
|
57
|
+
isInvalid: true,
|
|
58
|
+
errorMessage: 'Pick a future date and time.',
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const Disabled: Story = {
|
|
63
|
+
args: {
|
|
64
|
+
defaultValue: now(getLocalTimeZone()),
|
|
65
|
+
isDisabled: true,
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The flat, spreadsheet-style chrome used for inline table-cell editing.
|
|
70
|
+
* The time segments are part of the inline input, so the time is editable
|
|
71
|
+
* in-cell without opening the popover. */
|
|
72
|
+
export const CellAppearance: Story = {
|
|
73
|
+
args: {
|
|
74
|
+
appearance: 'cell',
|
|
75
|
+
size: 'sm',
|
|
76
|
+
label: undefined,
|
|
77
|
+
'aria-label': 'Starts at',
|
|
78
|
+
defaultValue: parseAbsoluteToLocal('2026-05-04T14:30:00Z'),
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const AllStates: Story = {
|
|
83
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
84
|
+
render: () => (
|
|
85
|
+
<div className="grid gap-5 w-[320px]">
|
|
86
|
+
<DateTimePicker label="Rest" />
|
|
87
|
+
<DateTimePicker label="Selected" defaultValue={parseAbsoluteToLocal('2026-05-04T14:30:00Z')} />
|
|
88
|
+
<DateTimePicker label="Invalid" isInvalid errorMessage="Pick a future date and time." />
|
|
89
|
+
<DateTimePicker
|
|
90
|
+
label="Disabled"
|
|
91
|
+
defaultValue={parseAbsoluteToLocal('2026-05-04T14:30:00Z')}
|
|
92
|
+
isDisabled
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
),
|
|
96
|
+
}
|