@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,87 @@
|
|
|
1
|
+
import { type CalendarDateTime, parseDateTime } 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 { DateTimePicker } from './date-time-picker'
|
|
7
|
+
|
|
8
|
+
// Naive CalendarDateTime keeps the rendered month/day/time independent of the
|
|
9
|
+
// test runner's timezone (unlike a UTC-anchored ZonedDateTime).
|
|
10
|
+
const MAY_4 = '2026-05-04T14:30:00'
|
|
11
|
+
|
|
12
|
+
describe('DateTimePicker', () => {
|
|
13
|
+
it('renders a labelled input', () => {
|
|
14
|
+
render(<DateTimePicker label="Starts at" />)
|
|
15
|
+
expect(screen.getByText('Starts at')).toBeInTheDocument()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('renders inline time segments alongside the date segments', () => {
|
|
19
|
+
render(<DateTimePicker label="Starts at" defaultValue={parseDateTime(MAY_4)} />)
|
|
20
|
+
// Granularity 'minute' (the default) surfaces editable hour + minute
|
|
21
|
+
// segments in the inline DateInput — this is the in-cell time editor.
|
|
22
|
+
expect(screen.getByRole('spinbutton', { name: /hour/i })).toBeInTheDocument()
|
|
23
|
+
expect(screen.getByRole('spinbutton', { name: /minute/i })).toBeInTheDocument()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('opens a popover with both a calendar grid and a Time field', async () => {
|
|
27
|
+
const user = userEvent.setup()
|
|
28
|
+
render(<DateTimePicker label="Starts at" defaultValue={parseDateTime(MAY_4)} />)
|
|
29
|
+
|
|
30
|
+
// The calendar-glyph trigger is the only <button> in the field (segments
|
|
31
|
+
// are spinbuttons). When closed it's the last <button> in the DOM.
|
|
32
|
+
const buttons = screen.getAllByRole('button')
|
|
33
|
+
const trigger = buttons[buttons.length - 1]
|
|
34
|
+
if (!trigger) throw new Error('calendar trigger missing')
|
|
35
|
+
|
|
36
|
+
await user.click(trigger)
|
|
37
|
+
expect(await screen.findByRole('application')).toBeInTheDocument()
|
|
38
|
+
expect(screen.getByRole('grid')).toBeInTheDocument()
|
|
39
|
+
// The dedicated time editor inside the popover.
|
|
40
|
+
expect(screen.getByLabelText('Time')).toBeInTheDocument()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('selecting a calendar day fires onChange and preserves the time', async () => {
|
|
44
|
+
const user = userEvent.setup()
|
|
45
|
+
const onChange = vi.fn()
|
|
46
|
+
render(
|
|
47
|
+
<DateTimePicker
|
|
48
|
+
label="Starts at"
|
|
49
|
+
defaultValue={parseDateTime(MAY_4)}
|
|
50
|
+
onChange={onChange}
|
|
51
|
+
/>,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const buttons = screen.getAllByRole('button')
|
|
55
|
+
const trigger = buttons[buttons.length - 1]
|
|
56
|
+
if (!trigger) throw new Error('trigger missing')
|
|
57
|
+
await user.click(trigger)
|
|
58
|
+
|
|
59
|
+
// Pick the 15th in the current (May 2026) grid.
|
|
60
|
+
const fifteenth = await screen.findByRole('button', { name: /May 15, 2026/i })
|
|
61
|
+
await user.click(fifteenth)
|
|
62
|
+
|
|
63
|
+
expect(onChange).toHaveBeenCalled()
|
|
64
|
+
const called = onChange.mock.calls.at(-1)?.[0] as CalendarDateTime | null
|
|
65
|
+
expect(called?.day).toBe(15)
|
|
66
|
+
expect(called?.month).toBe(5)
|
|
67
|
+
expect(called?.hour).toBe(14)
|
|
68
|
+
expect(called?.minute).toBe(30)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('renders the error message when invalid', () => {
|
|
72
|
+
render(<DateTimePicker label="Starts at" isInvalid errorMessage="Pick a future time." />)
|
|
73
|
+
expect(screen.getByText('Pick a future time.')).toBeInTheDocument()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('disables the control when isDisabled is set', () => {
|
|
77
|
+
render(<DateTimePicker label="Starts at" isDisabled defaultValue={parseDateTime(MAY_4)} />)
|
|
78
|
+
for (const btn of screen.getAllByRole('button')) {
|
|
79
|
+
expect(btn).toBeDisabled()
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('shows the description slot', () => {
|
|
84
|
+
render(<DateTimePicker label="Starts at" description="When the shift begins." />)
|
|
85
|
+
expect(screen.getByText('When the shift begins.')).toBeInTheDocument()
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { type ReactNode, useContext } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
DatePicker as AriaDatePicker,
|
|
4
|
+
type DatePickerProps as AriaDatePickerProps,
|
|
5
|
+
Button as AriaDateTrigger,
|
|
6
|
+
TimeField as AriaTimeField,
|
|
7
|
+
DateInput,
|
|
8
|
+
DatePickerStateContext,
|
|
9
|
+
DateSegment,
|
|
10
|
+
type DateValue,
|
|
11
|
+
Dialog,
|
|
12
|
+
FieldError,
|
|
13
|
+
Group,
|
|
14
|
+
Label,
|
|
15
|
+
Popover,
|
|
16
|
+
Text,
|
|
17
|
+
} from 'react-aria-components'
|
|
18
|
+
import { cn } from '../../lib/cn'
|
|
19
|
+
import {
|
|
20
|
+
type FieldAppearance,
|
|
21
|
+
fieldControlStyles,
|
|
22
|
+
fieldErrorStyles,
|
|
23
|
+
type FieldSize,
|
|
24
|
+
fieldHintStyles,
|
|
25
|
+
fieldLabelStyles,
|
|
26
|
+
} from '../field/field'
|
|
27
|
+
import { CalendarGlyph, CalendarSurface } from '../date-picker/calendar-internals'
|
|
28
|
+
|
|
29
|
+
export type DateTimePickerProps<T extends DateValue> = AriaDatePickerProps<T> & {
|
|
30
|
+
label?: ReactNode
|
|
31
|
+
description?: ReactNode
|
|
32
|
+
errorMessage?: ReactNode
|
|
33
|
+
/** Field size. Defaults to `md`. */
|
|
34
|
+
size?: FieldSize
|
|
35
|
+
/** Chrome variant. Defaults to `field`. `cell` flattens the box. */
|
|
36
|
+
appearance?: FieldAppearance
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* Shared segment treatment for the inline DateInput + the popover TimeField,
|
|
40
|
+
so the two never drift. Matches DatePicker's date segments. */
|
|
41
|
+
const segmentStyles = cn(
|
|
42
|
+
'px-px tabular-nums outline-none select-none',
|
|
43
|
+
'data-[placeholder]:text-ink-4',
|
|
44
|
+
'data-[type=literal]:text-ink-4',
|
|
45
|
+
// segment focus treatment: indigo fill, paper text
|
|
46
|
+
'data-[focused]:bg-accent data-[focused]:text-[#f0efe8] data-[focused]:rounded-[3px]',
|
|
47
|
+
'data-[disabled]:cursor-not-allowed',
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
/* The popover's dedicated time editor. Reads the live DatePicker state so it
|
|
51
|
+
stays in sync with both the inline segments and the calendar selection:
|
|
52
|
+
`state.timeValue` / `state.setTimeValue` are RAC's split-out time portion of
|
|
53
|
+
the combined value. Renders nothing until the picker actually has a time
|
|
54
|
+
component (`hasTime` — i.e. granularity is finer than 'day'). */
|
|
55
|
+
function PopoverTimeField({
|
|
56
|
+
granularity,
|
|
57
|
+
hourCycle,
|
|
58
|
+
hideTimeZone,
|
|
59
|
+
}: {
|
|
60
|
+
granularity: 'hour' | 'minute' | 'second'
|
|
61
|
+
hourCycle?: 12 | 24
|
|
62
|
+
hideTimeZone?: boolean
|
|
63
|
+
}) {
|
|
64
|
+
const state = useContext(DatePickerStateContext)
|
|
65
|
+
if (!state?.hasTime) return null
|
|
66
|
+
return (
|
|
67
|
+
<div className="mt-3 pt-3 border-t border-rule flex items-center justify-between gap-3">
|
|
68
|
+
<span className={fieldLabelStyles}>Time</span>
|
|
69
|
+
<AriaTimeField
|
|
70
|
+
aria-label="Time"
|
|
71
|
+
value={state.timeValue}
|
|
72
|
+
onChange={(next) => {
|
|
73
|
+
if (next) state.setTimeValue(next)
|
|
74
|
+
}}
|
|
75
|
+
granularity={granularity}
|
|
76
|
+
{...(hourCycle !== undefined ? { hourCycle } : {})}
|
|
77
|
+
{...(hideTimeZone !== undefined ? { hideTimeZone } : {})}
|
|
78
|
+
>
|
|
79
|
+
<DateInput
|
|
80
|
+
className={cn(
|
|
81
|
+
'inline-flex items-center rounded-md border border-rule bg-surface px-2.5 py-1.5',
|
|
82
|
+
'font-mono text-[13px] text-ink',
|
|
83
|
+
'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]',
|
|
84
|
+
'transition-[border-color,box-shadow] duration-150',
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
{(segment) => <DateSegment segment={segment} className={segmentStyles} />}
|
|
88
|
+
</DateInput>
|
|
89
|
+
</AriaTimeField>
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function DateTimePicker<T extends DateValue>({
|
|
95
|
+
label,
|
|
96
|
+
description,
|
|
97
|
+
errorMessage,
|
|
98
|
+
size,
|
|
99
|
+
appearance,
|
|
100
|
+
className,
|
|
101
|
+
// Time-aware defaults: minute precision, locale hour cycle, no timezone
|
|
102
|
+
// segment, and keep the popover open after a calendar day-click so the user
|
|
103
|
+
// can still set a time.
|
|
104
|
+
granularity = 'minute',
|
|
105
|
+
hideTimeZone = true,
|
|
106
|
+
shouldCloseOnSelect = false,
|
|
107
|
+
...props
|
|
108
|
+
}: DateTimePickerProps<T>) {
|
|
109
|
+
const styles = fieldControlStyles({ size, appearance })
|
|
110
|
+
const focusWithin =
|
|
111
|
+
appearance === 'cell'
|
|
112
|
+
? 'focus-within:border-accent focus-within:shadow-none'
|
|
113
|
+
: 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]'
|
|
114
|
+
// `granularity` is never 'day' here (defaulted to 'minute'); narrow for the
|
|
115
|
+
// TimeField, which only accepts hour/minute/second.
|
|
116
|
+
const timeGranularity = granularity === 'day' ? 'minute' : granularity
|
|
117
|
+
const { hourCycle } = props
|
|
118
|
+
return (
|
|
119
|
+
<AriaDatePicker
|
|
120
|
+
{...props}
|
|
121
|
+
granularity={granularity}
|
|
122
|
+
hideTimeZone={hideTimeZone}
|
|
123
|
+
shouldCloseOnSelect={shouldCloseOnSelect}
|
|
124
|
+
className={(renderProps) =>
|
|
125
|
+
cn(
|
|
126
|
+
'grid grid-cols-1 gap-1.5',
|
|
127
|
+
typeof className === 'function' ? className(renderProps) : className,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
>
|
|
131
|
+
{label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
|
|
132
|
+
|
|
133
|
+
<Group
|
|
134
|
+
className={cn(
|
|
135
|
+
// Box chrome on the wrapper; the DateInput inside carries padding +
|
|
136
|
+
// font so the calendar trigger sits inside the same border + ring.
|
|
137
|
+
styles.box(),
|
|
138
|
+
'flex items-center gap-1 pr-1',
|
|
139
|
+
focusWithin,
|
|
140
|
+
)}
|
|
141
|
+
>
|
|
142
|
+
<DateInput
|
|
143
|
+
className={cn(
|
|
144
|
+
'flex-1 flex items-center min-w-0 text-ink',
|
|
145
|
+
styles.content(),
|
|
146
|
+
// Tabular alignment for date/time segments — overrides content's
|
|
147
|
+
// font-sans on purpose.
|
|
148
|
+
'font-mono',
|
|
149
|
+
)}
|
|
150
|
+
>
|
|
151
|
+
{(segment) => <DateSegment segment={segment} className={segmentStyles} />}
|
|
152
|
+
</DateInput>
|
|
153
|
+
|
|
154
|
+
<AriaDateTrigger
|
|
155
|
+
className={cn(
|
|
156
|
+
'shrink-0 inline-flex items-center justify-center',
|
|
157
|
+
'size-7 rounded-md',
|
|
158
|
+
'text-ink-3 hover:text-ink hover:bg-surface-2',
|
|
159
|
+
'outline-none cursor-pointer',
|
|
160
|
+
'focus-visible:shadow-[var(--shadow-focus)]',
|
|
161
|
+
'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
|
|
162
|
+
)}
|
|
163
|
+
>
|
|
164
|
+
<CalendarGlyph />
|
|
165
|
+
</AriaDateTrigger>
|
|
166
|
+
</Group>
|
|
167
|
+
|
|
168
|
+
{description ? (
|
|
169
|
+
<Text slot="description" className={fieldHintStyles}>
|
|
170
|
+
{description}
|
|
171
|
+
</Text>
|
|
172
|
+
) : null}
|
|
173
|
+
{errorMessage && appearance !== 'cell' ? (
|
|
174
|
+
<FieldError className={fieldErrorStyles}>{errorMessage}</FieldError>
|
|
175
|
+
) : null}
|
|
176
|
+
|
|
177
|
+
<Popover
|
|
178
|
+
offset={6}
|
|
179
|
+
className={cn(
|
|
180
|
+
// Match the anchoring field's width exactly. `--trigger-width` is
|
|
181
|
+
// set by RAC on the Popover when it's inside a DatePicker. Floor at
|
|
182
|
+
// 18rem so the calendar stays legible when the input is unusually
|
|
183
|
+
// narrow, and cap at 500px so it doesn't sprawl behind a very wide
|
|
184
|
+
// field.
|
|
185
|
+
'w-[var(--trigger-width)] min-w-[18rem] max-w-[500px]',
|
|
186
|
+
'rounded-md border border-rule bg-surface p-4',
|
|
187
|
+
'shadow-[var(--shadow-raised)]',
|
|
188
|
+
'entering:animate-in entering:fade-in-0 entering:slide-in-from-top-1',
|
|
189
|
+
'exiting:animate-out exiting:fade-out-0 exiting:slide-out-to-top-1',
|
|
190
|
+
)}
|
|
191
|
+
>
|
|
192
|
+
<Dialog className="outline-none">
|
|
193
|
+
<CalendarSurface />
|
|
194
|
+
<PopoverTimeField
|
|
195
|
+
granularity={timeGranularity}
|
|
196
|
+
hideTimeZone={hideTimeZone}
|
|
197
|
+
{...(hourCycle !== undefined ? { hourCycle } : {})}
|
|
198
|
+
/>
|
|
199
|
+
</Dialog>
|
|
200
|
+
</Popover>
|
|
201
|
+
</AriaDatePicker>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { tv, type VariantProps } from 'tailwind-variants'
|
|
3
|
+
|
|
4
|
+
import { cn } from '../../lib/cn'
|
|
5
|
+
|
|
6
|
+
/* =========================================================================
|
|
7
|
+
EmptyState — zero-state callout. Centred art tile + title + description
|
|
8
|
+
+ optional action. Used by the page-designer inspector's "Nothing
|
|
9
|
+
selected" panel and re-usable wherever a panel needs a friendly empty
|
|
10
|
+
state. Plan §7.2.
|
|
11
|
+
========================================================================= */
|
|
12
|
+
|
|
13
|
+
export const emptyStateStyles = tv({
|
|
14
|
+
slots: {
|
|
15
|
+
root: 'flex flex-col items-center text-center px-6',
|
|
16
|
+
art: 'inline-grid place-items-center rounded-[12px] bg-surface-2 text-ink-3 mb-3',
|
|
17
|
+
title: 'text-[14px] text-ink font-medium mb-1',
|
|
18
|
+
description: 'text-[12.5px] text-ink-3 leading-[1.5] max-w-[220px] mx-auto',
|
|
19
|
+
actions: 'mt-3',
|
|
20
|
+
},
|
|
21
|
+
variants: {
|
|
22
|
+
size: {
|
|
23
|
+
sm: {
|
|
24
|
+
root: 'py-6',
|
|
25
|
+
art: 'size-9',
|
|
26
|
+
},
|
|
27
|
+
md: {
|
|
28
|
+
root: 'py-9',
|
|
29
|
+
art: 'size-12',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
defaultVariants: { size: 'md' },
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export type EmptyStateVariants = VariantProps<typeof emptyStateStyles>
|
|
37
|
+
|
|
38
|
+
export interface EmptyStateProps extends EmptyStateVariants {
|
|
39
|
+
icon?: ReactNode
|
|
40
|
+
title: ReactNode
|
|
41
|
+
description?: ReactNode
|
|
42
|
+
action?: ReactNode
|
|
43
|
+
className?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function EmptyState({
|
|
47
|
+
icon,
|
|
48
|
+
title,
|
|
49
|
+
description,
|
|
50
|
+
action,
|
|
51
|
+
size,
|
|
52
|
+
className,
|
|
53
|
+
}: EmptyStateProps) {
|
|
54
|
+
const styles = emptyStateStyles({ size })
|
|
55
|
+
return (
|
|
56
|
+
<div className={cn(styles.root(), className)}>
|
|
57
|
+
{icon ? <div className={styles.art()}>{icon}</div> : null}
|
|
58
|
+
<div className={styles.title()}>{title}</div>
|
|
59
|
+
{description ? <div className={styles.description()}>{description}</div> : null}
|
|
60
|
+
{action ? <div className={styles.actions()}>{action}</div> : null}
|
|
61
|
+
</div>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import { Button } from '../button/button'
|
|
5
|
+
|
|
6
|
+
import { ErrorAnimation } from './error-animation'
|
|
7
|
+
|
|
8
|
+
const meta: Meta<typeof ErrorAnimation> = {
|
|
9
|
+
title: 'Components/ErrorAnimation',
|
|
10
|
+
component: ErrorAnimation,
|
|
11
|
+
tags: ['autodocs'],
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default meta
|
|
15
|
+
type Story = StoryObj<typeof ErrorAnimation>
|
|
16
|
+
|
|
17
|
+
export const Playground: Story = {
|
|
18
|
+
render: (args) => (
|
|
19
|
+
<div className="size-32">
|
|
20
|
+
<ErrorAnimation {...args} />
|
|
21
|
+
</div>
|
|
22
|
+
),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const Sizes: Story = {
|
|
26
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
27
|
+
render: () => (
|
|
28
|
+
<div className="flex items-end gap-6">
|
|
29
|
+
<ErrorAnimation className="size-16" />
|
|
30
|
+
<ErrorAnimation className="size-24" />
|
|
31
|
+
<ErrorAnimation className="size-40" />
|
|
32
|
+
</div>
|
|
33
|
+
),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Replay demo — remount the component to restart the one-shot animation.
|
|
37
|
+
export const Replay: Story = {
|
|
38
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
39
|
+
render: () => {
|
|
40
|
+
const [key, setKey] = useState(0)
|
|
41
|
+
return (
|
|
42
|
+
<div className="flex flex-col items-center gap-4">
|
|
43
|
+
<ErrorAnimation key={key} className="size-32" />
|
|
44
|
+
<Button size="sm" variant="ghost" onPress={() => setKey((k) => k + 1)}>
|
|
45
|
+
Replay
|
|
46
|
+
</Button>
|
|
47
|
+
</div>
|
|
48
|
+
)
|
|
49
|
+
},
|
|
50
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { render } from '@testing-library/react'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { ErrorAnimation } from './error-animation'
|
|
5
|
+
|
|
6
|
+
describe('ErrorAnimation', () => {
|
|
7
|
+
it('renders a decorative wrapper with default sizing and the danger color', () => {
|
|
8
|
+
const { container } = render(<ErrorAnimation />)
|
|
9
|
+
const wrapper = container.firstElementChild as HTMLElement | null
|
|
10
|
+
expect(wrapper).not.toBeNull()
|
|
11
|
+
expect(wrapper).toHaveAttribute('aria-hidden')
|
|
12
|
+
expect(wrapper?.className).toContain('size-24')
|
|
13
|
+
expect(wrapper?.className).toContain('text-danger')
|
|
14
|
+
expect(wrapper?.querySelector('svg')).not.toBeNull()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('forwards className to override the default size', () => {
|
|
18
|
+
const { container } = render(<ErrorAnimation className="size-40" />)
|
|
19
|
+
const wrapper = container.firstElementChild as HTMLElement | null
|
|
20
|
+
expect(wrapper?.className).toContain('size-40')
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { CircleX } from 'lucide-react'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../lib/cn'
|
|
4
|
+
|
|
5
|
+
/* =========================================================================
|
|
6
|
+
ErrorAnimation — failure indicator: a large red X in a circle (token
|
|
7
|
+
`--color-danger`), entering with the one-shot status-pop scale+fade
|
|
8
|
+
(suppressed under prefers-reduced-motion). Decorative: rendered with
|
|
9
|
+
`aria-hidden`, since the surrounding text region carries the
|
|
10
|
+
announcement. Size via className (default 96×96).
|
|
11
|
+
========================================================================= */
|
|
12
|
+
|
|
13
|
+
export type ErrorAnimationProps = {
|
|
14
|
+
className?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ErrorAnimation({ className }: ErrorAnimationProps) {
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
aria-hidden
|
|
21
|
+
className={cn('size-24 text-danger motion-safe:animate-status-pop', className)}
|
|
22
|
+
>
|
|
23
|
+
<CircleX className="size-full" strokeWidth={1.5} />
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { tv, type VariantProps } from 'tailwind-variants'
|
|
2
|
+
|
|
3
|
+
/* =========================================================================
|
|
4
|
+
Shared field chrome — one source of truth for the hairline box that wraps
|
|
5
|
+
Input, SelectTrigger, ComboboxInput, DatePicker, BadgeMultiSelect, etc.
|
|
6
|
+
|
|
7
|
+
Two slots:
|
|
8
|
+
- `box` — border + radius + bg + shadow + hover/focus/invalid/disabled.
|
|
9
|
+
The "field-ness" of the control. No padding, no font, no
|
|
10
|
+
text-size, so it can sit on either a single-element control
|
|
11
|
+
OR an outer wrapper of a compound control without dictating
|
|
12
|
+
internal spacing.
|
|
13
|
+
- `content` — font + leading + placeholder color + padding + text-size.
|
|
14
|
+
The "interior" of the field. Applied to the box itself for
|
|
15
|
+
single-element controls (Input, Textarea, Select trigger),
|
|
16
|
+
or to the inner editable element of compound controls
|
|
17
|
+
(Combobox <input>, DatePicker <DateInput>) so the box can
|
|
18
|
+
host a flex layout with siblings (chevron, calendar
|
|
19
|
+
trigger) sharing the border + focus ring.
|
|
20
|
+
|
|
21
|
+
`size` is the only knob that defines what "compact" vs "comfortable" means
|
|
22
|
+
for any field-like control in the library. Change it here, every consumer
|
|
23
|
+
shifts in lockstep.
|
|
24
|
+
========================================================================= */
|
|
25
|
+
|
|
26
|
+
export const fieldControlStyles = tv({
|
|
27
|
+
slots: {
|
|
28
|
+
box: [
|
|
29
|
+
'w-full rounded-md border bg-surface text-ink',
|
|
30
|
+
'outline-none shadow-[var(--shadow-inset)]',
|
|
31
|
+
'transition-[border-color,box-shadow] duration-150',
|
|
32
|
+
'hover:border-ink-4',
|
|
33
|
+
'focus:border-accent focus:shadow-[var(--shadow-focus)]',
|
|
34
|
+
// RAC forwards data-focused/data-hovered/data-invalid on its components.
|
|
35
|
+
// Keep both CSS :focus and RAC data-* states covered.
|
|
36
|
+
'data-[hovered]:border-ink-4',
|
|
37
|
+
'data-[focused]:border-accent data-[focused]:shadow-[var(--shadow-focus)]',
|
|
38
|
+
'data-[invalid]:border-danger data-[invalid]:shadow-[var(--shadow-focus-danger)]',
|
|
39
|
+
'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
|
|
40
|
+
],
|
|
41
|
+
content: 'font-sans leading-snug placeholder:text-ink-4',
|
|
42
|
+
},
|
|
43
|
+
variants: {
|
|
44
|
+
size: {
|
|
45
|
+
sm: { content: 'px-2.5 py-1.5 text-[13px]' },
|
|
46
|
+
md: { content: 'px-3.5 py-2.5 text-[15px]' },
|
|
47
|
+
},
|
|
48
|
+
invalid: {
|
|
49
|
+
true: { box: 'border-danger shadow-[var(--shadow-focus-danger)]' },
|
|
50
|
+
false: { box: 'border-rule-strong' },
|
|
51
|
+
},
|
|
52
|
+
/* `cell` — flat, spreadsheet-style chrome for inline table editing
|
|
53
|
+
(LUM-35). Squares the corners to 2px and removes BOTH the resting
|
|
54
|
+
inset shadow and the focus/invalid halo, leaving just a border that
|
|
55
|
+
turns accent on focus / danger on invalid. Declared after `invalid`
|
|
56
|
+
so its `shadow-none` wins the tailwind-merge order over the invalid
|
|
57
|
+
variant's danger halo; the compoundVariant below is belt-and-braces. */
|
|
58
|
+
appearance: {
|
|
59
|
+
field: {},
|
|
60
|
+
cell: {
|
|
61
|
+
// Fixed 29px editor: sits under the 32px table row so the cell's
|
|
62
|
+
// items-center leaves a hairline gap top/bottom and the border reads
|
|
63
|
+
// fully instead of merging with the row gridline. Explicit height is
|
|
64
|
+
// more deterministic than relying on line-height + padding math.
|
|
65
|
+
// `py-1` keeps the content box (≈19px) above the ~18px text line so
|
|
66
|
+
// nothing clips. Both win over `size`'s values via tailwind-merge
|
|
67
|
+
// order (appearance is declared after size).
|
|
68
|
+
box: 'h-[29px] rounded-[4px] shadow-none focus:shadow-none data-[focused]:shadow-none data-[invalid]:shadow-none',
|
|
69
|
+
content: 'py-1',
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
compoundVariants: [
|
|
74
|
+
{ appearance: 'cell', invalid: true, class: { box: 'shadow-none' } },
|
|
75
|
+
],
|
|
76
|
+
defaultVariants: { size: 'md', invalid: false, appearance: 'field' },
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
export type FieldControlVariants = VariantProps<typeof fieldControlStyles>
|
|
80
|
+
export type FieldSize = NonNullable<FieldControlVariants['size']>
|
|
81
|
+
export type FieldAppearance = NonNullable<FieldControlVariants['appearance']>
|
|
82
|
+
|
|
83
|
+
/* =========================================================================
|
|
84
|
+
Per-size lookups — for parts of compound controls that need *just* the
|
|
85
|
+
text-size or a coherent min-height (e.g. BadgeMultiSelect/MultiSelect's
|
|
86
|
+
chip wrapper, where the box has its own padding for the chip layout but
|
|
87
|
+
the placeholder line + overall height should still scale with `size`).
|
|
88
|
+
========================================================================= */
|
|
89
|
+
|
|
90
|
+
export const fieldTextStyles: Record<FieldSize, string> = {
|
|
91
|
+
sm: 'text-[13px]',
|
|
92
|
+
md: 'text-[15px]',
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const fieldMinHeight: Record<FieldSize, string> = {
|
|
96
|
+
sm: 'min-h-[32px]',
|
|
97
|
+
md: 'min-h-[42px]',
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* =========================================================================
|
|
101
|
+
Padding for chip-wrapper field controls (BadgeMultiSelect, MultiSelect,
|
|
102
|
+
TagInput) — these don't use `content`'s padding because the box hosts a
|
|
103
|
+
flex layout of chips. The padding still has to scale with `size` so the
|
|
104
|
+
total rendered height (wrapper-pad + chip-height) lines up with a
|
|
105
|
+
single-element field at the same size.
|
|
106
|
+
========================================================================= */
|
|
107
|
+
|
|
108
|
+
export const fieldChipPadding: Record<FieldSize, string> = {
|
|
109
|
+
sm: 'p-1',
|
|
110
|
+
md: 'p-1.5',
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* =========================================================================
|
|
114
|
+
Label / hint / error text styles — applied to RAC's Label / Text / FieldError
|
|
115
|
+
components inside each form control. Strings rather than components so the
|
|
116
|
+
existing monolithic APIs can keep their inline composition.
|
|
117
|
+
========================================================================= */
|
|
118
|
+
|
|
119
|
+
export const fieldLabelStyles = 'font-mono text-[11px] uppercase tracking-[0.08em] text-ink-4'
|
|
120
|
+
|
|
121
|
+
export const fieldHintStyles = 'font-mono text-[11px] text-ink-4'
|
|
122
|
+
|
|
123
|
+
export const fieldErrorStyles = 'font-mono text-[11px] text-danger'
|
|
124
|
+
|
|
125
|
+
/* =========================================================================
|
|
126
|
+
FieldCaret — the small down-chevron used in Select and Combobox triggers.
|
|
127
|
+
Decorative; consumer elements carry their own aria semantics.
|
|
128
|
+
========================================================================= */
|
|
129
|
+
|
|
130
|
+
export function FieldCaret() {
|
|
131
|
+
return (
|
|
132
|
+
<svg aria-hidden viewBox="0 0 10 10" className="size-2.5 shrink-0 text-ink-3" fill="none">
|
|
133
|
+
<path
|
|
134
|
+
d="M2 4l3 3 3-3"
|
|
135
|
+
stroke="currentColor"
|
|
136
|
+
strokeWidth="1.2"
|
|
137
|
+
strokeLinecap="round"
|
|
138
|
+
strokeLinejoin="round"
|
|
139
|
+
/>
|
|
140
|
+
</svg>
|
|
141
|
+
)
|
|
142
|
+
}
|