@podoba/react 0.0.3 → 0.0.5
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/package.json +3 -3
- package/src/components/combobox.tsx +96 -0
- package/src/components/date-field.tsx +71 -0
- package/src/components/date-picker.tsx +111 -0
- package/src/components/dialog.tsx +4 -0
- package/src/components/file-upload.tsx +64 -0
- package/src/components/input.tsx +7 -4
- package/src/components/multiselect.tsx +173 -0
- package/src/components/number-field.tsx +60 -0
- package/src/components/rich-text-editor.tsx +158 -0
- package/src/components/search-field.tsx +66 -0
- package/src/components/select.tsx +13 -16
- package/src/components/slider.tsx +53 -0
- package/src/components/tag-group.tsx +66 -0
- package/src/components/textarea.tsx +8 -6
- package/src/index.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podoba/react",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "podoba React components — React Aria Components + Tailwind primitives + layout, built with uic.",
|
|
6
6
|
"repository": {
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"typecheck": "tsc --build"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@podoba/tokens": "^0.0.
|
|
29
|
-
"@podoba/tailwind": "^0.0.
|
|
28
|
+
"@podoba/tokens": "^0.0.5",
|
|
29
|
+
"@podoba/tailwind": "^0.0.5",
|
|
30
30
|
"react-aria-components": "1.18.0",
|
|
31
31
|
"class-variance-authority": "0.7.1",
|
|
32
32
|
"clsx": "2.1.1",
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Button as RACButton,
|
|
4
|
+
ComboBox as RACComboBox,
|
|
5
|
+
type ComboBoxProps as RACComboBoxProps,
|
|
6
|
+
FieldError,
|
|
7
|
+
Input as RACInput,
|
|
8
|
+
Label,
|
|
9
|
+
ListBox,
|
|
10
|
+
ListBoxItem,
|
|
11
|
+
type ListBoxItemProps,
|
|
12
|
+
Popover,
|
|
13
|
+
Text,
|
|
14
|
+
} from 'react-aria-components'
|
|
15
|
+
import { uic } from '../utils/uic'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* ComboBox — a filterable single-select: a text input that narrows a listbox as
|
|
19
|
+
* you type. Built on React Aria Components `ComboBox` (typeahead, keyboard nav,
|
|
20
|
+
* ARIA all handled). Styling mirrors our `Input` (white filled field) and shares
|
|
21
|
+
* `Select`'s cream dropdown, so the form controls stay visually consistent. Pass
|
|
22
|
+
* options as `ComboBoxItem` children.
|
|
23
|
+
*/
|
|
24
|
+
const Chevron = () => (
|
|
25
|
+
<svg width="9.5" height="9.5" viewBox="0 0 12 12" fill="none" aria-hidden="true" className="text-fg">
|
|
26
|
+
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
27
|
+
</svg>
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
// Same filled-field skin as input.tsx, with room on the right for the toggle.
|
|
31
|
+
// Invalid is owned by the ComboBox root, so it comes through `group-data-invalid`.
|
|
32
|
+
const ComboBoxInput = uic(RACInput, {
|
|
33
|
+
displayName: 'ComboBoxInput',
|
|
34
|
+
baseClass:
|
|
35
|
+
'h-12 w-full rounded-lg border border-border bg-surface pl-4 pr-11 text-sm text-fg ' +
|
|
36
|
+
'outline-none transition-colors placeholder:text-fg-muted ' +
|
|
37
|
+
'data-[hovered]:border-fg-subtle ' +
|
|
38
|
+
'data-[focused]:border-brand-green data-[focused]:ring-2 data-[focused]:ring-ring ' +
|
|
39
|
+
'group-data-[invalid]:border-danger group-data-[invalid]:ring-2 group-data-[invalid]:ring-danger ' +
|
|
40
|
+
'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60',
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
export const ComboBoxItem = uic(ListBoxItem, {
|
|
44
|
+
displayName: 'ComboBoxItem',
|
|
45
|
+
// Matches SelectItem so the two dropdowns are indistinguishable.
|
|
46
|
+
baseClass:
|
|
47
|
+
'flex cursor-pointer select-none items-center rounded-md px-3 py-2 text-sm text-fg outline-none ' +
|
|
48
|
+
'data-[hovered]:bg-surface-muted data-[focused]:bg-surface-muted data-[selected]:font-medium ' +
|
|
49
|
+
'data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
|
|
50
|
+
}) as (props: ListBoxItemProps) => ReactNode
|
|
51
|
+
|
|
52
|
+
export type ComboBoxProps<T extends object> = RACComboBoxProps<T> & {
|
|
53
|
+
/** Visible label (required for accessibility). */
|
|
54
|
+
label: ReactNode
|
|
55
|
+
description?: ReactNode
|
|
56
|
+
errorMessage?: string
|
|
57
|
+
/** Placeholder shown in the empty input. */
|
|
58
|
+
placeholder?: string
|
|
59
|
+
children: ReactNode
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const ComboBox = <T extends object>({
|
|
63
|
+
label,
|
|
64
|
+
description,
|
|
65
|
+
errorMessage,
|
|
66
|
+
placeholder,
|
|
67
|
+
children,
|
|
68
|
+
...props
|
|
69
|
+
}: ComboBoxProps<T>) => (
|
|
70
|
+
// `menuTrigger="focus"` opens the list on focus/click (not only on typing), so
|
|
71
|
+
// the options are always one interaction away. Consumers can override via props.
|
|
72
|
+
<RACComboBox menuTrigger="focus" {...props} className="group flex flex-col gap-2">
|
|
73
|
+
<Label className="text-heading5 font-medium text-fg">{label}</Label>
|
|
74
|
+
<div className="relative">
|
|
75
|
+
<ComboBoxInput placeholder={placeholder} />
|
|
76
|
+
{/* RAC uses this Button to toggle the listbox open/closed. */}
|
|
77
|
+
<RACButton className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg outline-none data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring">
|
|
78
|
+
<Chevron />
|
|
79
|
+
</RACButton>
|
|
80
|
+
</div>
|
|
81
|
+
{description ? (
|
|
82
|
+
<Text slot="description" className="text-xs text-fg-muted">
|
|
83
|
+
{description}
|
|
84
|
+
</Text>
|
|
85
|
+
) : null}
|
|
86
|
+
<FieldError className="text-xs text-danger">{errorMessage}</FieldError>
|
|
87
|
+
<Popover className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg">
|
|
88
|
+
<ListBox
|
|
89
|
+
className="flex max-h-64 flex-col gap-0.5 overflow-auto p-1 outline-none"
|
|
90
|
+
renderEmptyState={() => <div className="px-3 py-2 text-sm text-fg-muted">No results</div>}
|
|
91
|
+
>
|
|
92
|
+
{children}
|
|
93
|
+
</ListBox>
|
|
94
|
+
</Popover>
|
|
95
|
+
</RACComboBox>
|
|
96
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
DateField as RACDateField,
|
|
4
|
+
type DateFieldProps as RACDateFieldProps,
|
|
5
|
+
DateInput,
|
|
6
|
+
DateSegment,
|
|
7
|
+
type DateValue,
|
|
8
|
+
FieldError,
|
|
9
|
+
Label,
|
|
10
|
+
Text,
|
|
11
|
+
TimeField as RACTimeField,
|
|
12
|
+
type TimeFieldProps as RACTimeFieldProps,
|
|
13
|
+
type TimeValue,
|
|
14
|
+
} from 'react-aria-components'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* DateField / TimeField — segmented, keyboard-first date and time entry (type or
|
|
18
|
+
* arrow each segment; no free-text parsing). Built on React Aria Components, so
|
|
19
|
+
* they're locale- and timezone-aware. Pass `@internationalized/date` values for
|
|
20
|
+
* controlled use. For a calendar popover, use `DatePicker`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// Each editable segment; the focused one gets a brand-green highlight.
|
|
24
|
+
export const segmentClass =
|
|
25
|
+
'rounded px-0.5 tabular-nums text-fg caret-transparent outline-none ' +
|
|
26
|
+
'data-[placeholder]:text-fg-muted ' +
|
|
27
|
+
'data-[focused]:bg-brand-green data-[focused]:text-fg ' +
|
|
28
|
+
'data-[disabled]:opacity-50 data-[type=literal]:px-0 data-[type=literal]:text-fg-muted'
|
|
29
|
+
|
|
30
|
+
export const dateInputClass =
|
|
31
|
+
'flex h-12 w-full items-center gap-0.5 rounded-lg border border-border bg-surface px-4 text-sm text-fg transition-colors ' +
|
|
32
|
+
'hover:border-fg-subtle focus-within:border-brand-green focus-within:ring-2 focus-within:ring-ring ' +
|
|
33
|
+
'group-data-[invalid]:border-danger group-data-[invalid]:ring-2 group-data-[invalid]:ring-danger'
|
|
34
|
+
|
|
35
|
+
export type DateFieldProps<T extends DateValue> = RACDateFieldProps<T> & {
|
|
36
|
+
label: ReactNode
|
|
37
|
+
description?: ReactNode
|
|
38
|
+
errorMessage?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const DateField = <T extends DateValue>({ label, description, errorMessage, ...props }: DateFieldProps<T>) => (
|
|
42
|
+
<RACDateField {...props} className="group flex flex-col gap-2">
|
|
43
|
+
<Label className="text-heading5 font-medium text-fg">{label}</Label>
|
|
44
|
+
<DateInput className={dateInputClass}>{(segment) => <DateSegment segment={segment} className={segmentClass} />}</DateInput>
|
|
45
|
+
{description ? (
|
|
46
|
+
<Text slot="description" className="text-xs text-fg-muted">
|
|
47
|
+
{description}
|
|
48
|
+
</Text>
|
|
49
|
+
) : null}
|
|
50
|
+
<FieldError className="text-xs text-danger">{errorMessage}</FieldError>
|
|
51
|
+
</RACDateField>
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
export type TimeFieldProps<T extends TimeValue> = RACTimeFieldProps<T> & {
|
|
55
|
+
label: ReactNode
|
|
56
|
+
description?: ReactNode
|
|
57
|
+
errorMessage?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const TimeField = <T extends TimeValue>({ label, description, errorMessage, ...props }: TimeFieldProps<T>) => (
|
|
61
|
+
<RACTimeField {...props} className="group flex flex-col gap-2">
|
|
62
|
+
<Label className="text-heading5 font-medium text-fg">{label}</Label>
|
|
63
|
+
<DateInput className={dateInputClass}>{(segment) => <DateSegment segment={segment} className={segmentClass} />}</DateInput>
|
|
64
|
+
{description ? (
|
|
65
|
+
<Text slot="description" className="text-xs text-fg-muted">
|
|
66
|
+
{description}
|
|
67
|
+
</Text>
|
|
68
|
+
) : null}
|
|
69
|
+
<FieldError className="text-xs text-danger">{errorMessage}</FieldError>
|
|
70
|
+
</RACTimeField>
|
|
71
|
+
)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Button as RACButton,
|
|
4
|
+
Calendar,
|
|
5
|
+
CalendarCell,
|
|
6
|
+
CalendarGrid,
|
|
7
|
+
CalendarGridBody,
|
|
8
|
+
CalendarGridHeader,
|
|
9
|
+
CalendarHeaderCell,
|
|
10
|
+
DateInput,
|
|
11
|
+
DatePicker as RACDatePicker,
|
|
12
|
+
type DatePickerProps as RACDatePickerProps,
|
|
13
|
+
DateSegment,
|
|
14
|
+
type DateValue,
|
|
15
|
+
Dialog,
|
|
16
|
+
FieldError,
|
|
17
|
+
Group,
|
|
18
|
+
Heading,
|
|
19
|
+
Label,
|
|
20
|
+
Popover,
|
|
21
|
+
Text,
|
|
22
|
+
} from 'react-aria-components'
|
|
23
|
+
import { dateInputClass, segmentClass } from './date-field'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* DatePicker — a `DateField` with a calendar popover. Built on React Aria
|
|
27
|
+
* Components (locale/timezone-aware, full keyboard support). Uncontrolled by
|
|
28
|
+
* default; pass `@internationalized/date` values to control it. Set `granularity`
|
|
29
|
+
* to `"minute"` for a datetime picker (adds time segments to the field).
|
|
30
|
+
*/
|
|
31
|
+
const navButton =
|
|
32
|
+
'flex h-8 w-8 items-center justify-center rounded-md text-fg-muted outline-none transition-colors ' +
|
|
33
|
+
'hover:bg-surface-muted hover:text-fg data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[disabled]:opacity-40'
|
|
34
|
+
|
|
35
|
+
const Chevron = ({ dir }: { dir: 'left' | 'right' }) => (
|
|
36
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
37
|
+
<path d={dir === 'left' ? 'M15 6l-6 6 6 6' : 'M9 6l6 6-6 6'} />
|
|
38
|
+
</svg>
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
export type DatePickerProps<T extends DateValue> = RACDatePickerProps<T> & {
|
|
42
|
+
label: ReactNode
|
|
43
|
+
description?: ReactNode
|
|
44
|
+
errorMessage?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const DatePicker = <T extends DateValue>({ label, description, errorMessage, ...props }: DatePickerProps<T>) => (
|
|
48
|
+
<RACDatePicker {...props} className="group flex flex-col gap-2">
|
|
49
|
+
<Label className="text-heading5 font-medium text-fg">{label}</Label>
|
|
50
|
+
<Group className={`${dateInputClass} pr-2`}>
|
|
51
|
+
<DateInput className="flex flex-1 items-center gap-0.5">
|
|
52
|
+
{(segment) => <DateSegment segment={segment} className={segmentClass} />}
|
|
53
|
+
</DateInput>
|
|
54
|
+
<RACButton
|
|
55
|
+
aria-label="Open calendar"
|
|
56
|
+
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-fg-muted outline-none transition-colors hover:bg-surface-muted hover:text-fg data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring"
|
|
57
|
+
>
|
|
58
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
59
|
+
<rect x="3" y="4" width="18" height="18" rx="2" />
|
|
60
|
+
<path d="M16 2v4M8 2v4M3 10h18" />
|
|
61
|
+
</svg>
|
|
62
|
+
</RACButton>
|
|
63
|
+
</Group>
|
|
64
|
+
{description ? (
|
|
65
|
+
<Text slot="description" className="text-xs text-fg-muted">
|
|
66
|
+
{description}
|
|
67
|
+
</Text>
|
|
68
|
+
) : null}
|
|
69
|
+
<FieldError className="text-xs text-danger">{errorMessage}</FieldError>
|
|
70
|
+
<Popover className="rounded-lg border border-border bg-surface p-4 shadow-lg outline-none">
|
|
71
|
+
<Dialog className="outline-none">
|
|
72
|
+
<Calendar className="w-[17.5rem]">
|
|
73
|
+
<header className="flex items-center justify-between pb-3">
|
|
74
|
+
<RACButton slot="previous" className={navButton}>
|
|
75
|
+
<Chevron dir="left" />
|
|
76
|
+
</RACButton>
|
|
77
|
+
<Heading className="text-sm font-medium text-fg" />
|
|
78
|
+
<RACButton slot="next" className={navButton}>
|
|
79
|
+
<Chevron dir="right" />
|
|
80
|
+
</RACButton>
|
|
81
|
+
</header>
|
|
82
|
+
<CalendarGrid className="w-full border-collapse">
|
|
83
|
+
<CalendarGridHeader>
|
|
84
|
+
{(day) => (
|
|
85
|
+
<CalendarHeaderCell className="pb-1 text-micro font-medium uppercase tracking-wide text-fg-subtle">
|
|
86
|
+
{day}
|
|
87
|
+
</CalendarHeaderCell>
|
|
88
|
+
)}
|
|
89
|
+
</CalendarGridHeader>
|
|
90
|
+
<CalendarGridBody>
|
|
91
|
+
{(date) => (
|
|
92
|
+
<CalendarCell
|
|
93
|
+
date={date}
|
|
94
|
+
className={
|
|
95
|
+
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-sm text-fg outline-none transition-colors ' +
|
|
96
|
+
'data-[outside-month]:text-fg-subtle ' +
|
|
97
|
+
'data-[hovered]:bg-surface-muted ' +
|
|
98
|
+
'data-[selected]:bg-fg data-[selected]:text-fg-inverted data-[selected]:font-medium ' +
|
|
99
|
+
'data-[unavailable]:text-fg-subtle data-[unavailable]:line-through ' +
|
|
100
|
+
'data-[disabled]:opacity-40 ' +
|
|
101
|
+
'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring'
|
|
102
|
+
}
|
|
103
|
+
/>
|
|
104
|
+
)}
|
|
105
|
+
</CalendarGridBody>
|
|
106
|
+
</CalendarGrid>
|
|
107
|
+
</Calendar>
|
|
108
|
+
</Dialog>
|
|
109
|
+
</Popover>
|
|
110
|
+
</RACDatePicker>
|
|
111
|
+
)
|
|
@@ -127,6 +127,10 @@ export const Dialog = ({
|
|
|
127
127
|
// Either way the RACDialog render-prop `close` resolves against the active
|
|
128
128
|
// overlay state, so `close()` works in both modes.
|
|
129
129
|
<Overlay
|
|
130
|
+
// Full-screen is an immersive takeover: swap the light 2px scrim for a
|
|
131
|
+
// heavier backdrop blur so the page behind reads as clearly blurred around
|
|
132
|
+
// the near-fullscreen canvas. (tailwind-merge dedupes the base blur/scrim.)
|
|
133
|
+
className={isFlex ? "bg-black/25 backdrop-blur-lg" : undefined}
|
|
130
134
|
isOpen={isOpen}
|
|
131
135
|
defaultOpen={defaultOpen}
|
|
132
136
|
onOpenChange={onOpenChange}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type ReactNode, useState } from 'react'
|
|
2
|
+
import { DropZone, FileTrigger, Text } from 'react-aria-components'
|
|
3
|
+
import { Button } from './button'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* FileUpload — a drop zone + "choose file" trigger over React Aria Components
|
|
7
|
+
* `DropZone` + `FileTrigger` (keyboard-accessible, drag-and-drop highlight).
|
|
8
|
+
* Uncontrolled: it tracks selected file names for display and reports the raw
|
|
9
|
+
* `File[]` via `onFiles`. Wire `onFiles` to your own upload.
|
|
10
|
+
*/
|
|
11
|
+
export type FileUploadProps = {
|
|
12
|
+
label?: ReactNode
|
|
13
|
+
description?: ReactNode
|
|
14
|
+
/** Accepted MIME types / extensions, e.g. ["image/*"] or ["image/png", ".pdf"]. */
|
|
15
|
+
accept?: string[]
|
|
16
|
+
allowsMultiple?: boolean
|
|
17
|
+
/** Called with the chosen files (from either the picker or a drop). */
|
|
18
|
+
onFiles?: (files: File[]) => void
|
|
19
|
+
className?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const FileUpload = ({ label, description, accept, allowsMultiple, onFiles, className }: FileUploadProps) => {
|
|
23
|
+
const [names, setNames] = useState<string[]>([])
|
|
24
|
+
|
|
25
|
+
const handle = (files: File[]) => {
|
|
26
|
+
if (files.length === 0) return
|
|
27
|
+
setNames(files.map((f) => f.name))
|
|
28
|
+
onFiles?.(files)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className={className ? `flex flex-col gap-2 ${className}` : 'flex flex-col gap-2'}>
|
|
33
|
+
{label ? <span className="text-heading5 font-medium text-fg">{label}</span> : null}
|
|
34
|
+
<DropZone
|
|
35
|
+
onDrop={async (e) => {
|
|
36
|
+
// Narrow to file items via `'getFile' in i` (avoids importing FileDropItem).
|
|
37
|
+
const files = await Promise.all(e.items.flatMap((i) => ('getFile' in i ? [i.getFile()] : [])))
|
|
38
|
+
handle(allowsMultiple ? files : files.slice(0, 1))
|
|
39
|
+
}}
|
|
40
|
+
className={
|
|
41
|
+
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-surface p-6 text-center outline-none transition-colors ' +
|
|
42
|
+
'data-[hovered]:border-fg-subtle ' +
|
|
43
|
+
'data-[drop-target]:border-brand-green data-[drop-target]:bg-surface-card ' +
|
|
44
|
+
'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring'
|
|
45
|
+
}
|
|
46
|
+
>
|
|
47
|
+
<Text slot="label" className="text-sm text-fg-muted">
|
|
48
|
+
Drag & drop {allowsMultiple ? 'files' : 'a file'} here, or
|
|
49
|
+
</Text>
|
|
50
|
+
<FileTrigger
|
|
51
|
+
acceptedFileTypes={accept}
|
|
52
|
+
allowsMultiple={allowsMultiple}
|
|
53
|
+
onSelect={(list) => handle(list ? Array.from(list) : [])}
|
|
54
|
+
>
|
|
55
|
+
<Button variant="secondary" size="sm">
|
|
56
|
+
Choose {allowsMultiple ? 'files' : 'file'}
|
|
57
|
+
</Button>
|
|
58
|
+
</FileTrigger>
|
|
59
|
+
{names.length > 0 ? <span className="max-w-full truncate text-xs text-fg">{names.join(', ')}</span> : null}
|
|
60
|
+
</DropZone>
|
|
61
|
+
{description ? <span className="text-xs text-fg-muted">{description}</span> : null}
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
package/src/components/input.tsx
CHANGED
|
@@ -20,17 +20,20 @@ import { uic } from '../utils/uic'
|
|
|
20
20
|
*/
|
|
21
21
|
const StyledInput = uic(RACInput, {
|
|
22
22
|
displayName: 'InputControl',
|
|
23
|
-
//
|
|
24
|
-
// #
|
|
23
|
+
// White fill so the field reads as editable. The gs original used a cream
|
|
24
|
+
// (#f7f6f2 → surface-card) fill, but our Card surface is ALSO surface-card, so
|
|
25
|
+
// a filled field inside a card vanished and read as disabled. Inverted: active
|
|
26
|
+
// = white (surface), disabled = the muted cream. border #eceae1 → border ·
|
|
27
|
+
// hover #aba89c → fg-subtle · focus #75e7b8 → brand-green · error → danger.
|
|
25
28
|
// Identical to textarea.tsx's filled-field skin; `fieldSize` adds the
|
|
26
29
|
// single-line height (gs sizes the field via padding only).
|
|
27
30
|
baseClass:
|
|
28
|
-
'w-full rounded-lg border border-border bg-surface
|
|
31
|
+
'w-full rounded-lg border border-border bg-surface px-4 text-sm text-fg ' +
|
|
29
32
|
'outline-none transition-colors duration-200 placeholder:text-fg-muted ' +
|
|
30
33
|
'data-[hovered]:border-fg-subtle ' +
|
|
31
34
|
'data-[focused]:border-brand-green data-[focused]:ring-2 data-[focused]:ring-ring ' +
|
|
32
35
|
'data-[invalid]:border-danger data-[invalid]:ring-danger ' +
|
|
33
|
-
'data-[disabled]:opacity-
|
|
36
|
+
'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60 data-[disabled]:pointer-events-none',
|
|
34
37
|
variants: {
|
|
35
38
|
// `fieldSize` (not `size`) to avoid colliding with the native <input size>
|
|
36
39
|
// attribute, which RAC's Input inherits (a numeric prop).
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { type ReactNode, useId, useRef, useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Autocomplete,
|
|
4
|
+
Button as RACButton,
|
|
5
|
+
Input as RACInput,
|
|
6
|
+
ListBox,
|
|
7
|
+
ListBoxItem,
|
|
8
|
+
type ListBoxItemProps,
|
|
9
|
+
Popover,
|
|
10
|
+
SearchField,
|
|
11
|
+
type Selection,
|
|
12
|
+
useFilter,
|
|
13
|
+
} from 'react-aria-components'
|
|
14
|
+
import { clsx } from 'clsx'
|
|
15
|
+
import { uic } from '../utils/uic'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* MultiSelect — a dropdown that selects several options at once.
|
|
19
|
+
*
|
|
20
|
+
* React Aria Components ships no multi-select control at 1.x, so this composes
|
|
21
|
+
* one from primitives: a `Select`-styled trigger (white filled field) plus a
|
|
22
|
+
* controlled `Popover` + `ListBox selectionMode="multiple"` (RAC gives the
|
|
23
|
+
* listbox ARIA + keyboard multi-selection). The trigger summarises the choice —
|
|
24
|
+
* up to two labels, then an "N selected" count. Options come as `{ id, label }`
|
|
25
|
+
* data (not children) because the trigger needs the value→label map. Selection
|
|
26
|
+
* is uncontrolled by default; pass `selectedKeys` + `onChange` to control it.
|
|
27
|
+
*
|
|
28
|
+
* Pass `searchable` to make it a filterable "combobox multi": RAC `Autocomplete`
|
|
29
|
+
* wires a search field to the listbox (type to filter, arrow keys into the list,
|
|
30
|
+
* Enter/click to toggle). Handy once the option list gets long.
|
|
31
|
+
*/
|
|
32
|
+
const Chevron = () => (
|
|
33
|
+
<svg width="9.5" height="9.5" viewBox="0 0 12 12" fill="none" aria-hidden="true" className="shrink-0 text-fg">
|
|
34
|
+
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
35
|
+
</svg>
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const Check = () => (
|
|
39
|
+
<svg
|
|
40
|
+
viewBox="0 0 16 16"
|
|
41
|
+
fill="none"
|
|
42
|
+
aria-hidden="true"
|
|
43
|
+
className="h-4 w-4 shrink-0 text-fg opacity-0 group-data-[selected]:opacity-100"
|
|
44
|
+
>
|
|
45
|
+
<path d="M3.5 8.5l3 3 6-6.5" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
|
|
46
|
+
</svg>
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
const MultiSelectItem = uic(ListBoxItem, {
|
|
50
|
+
displayName: 'MultiSelectItem',
|
|
51
|
+
// `group` so the leading Check can react to this item's data-selected.
|
|
52
|
+
baseClass:
|
|
53
|
+
'group flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-sm text-fg outline-none ' +
|
|
54
|
+
'data-[hovered]:bg-surface-muted data-[focused]:bg-surface-muted data-[selected]:font-medium ' +
|
|
55
|
+
'data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
|
|
56
|
+
}) as (props: ListBoxItemProps) => ReactNode
|
|
57
|
+
|
|
58
|
+
export type MultiSelectOption = { id: string; label: string; isDisabled?: boolean }
|
|
59
|
+
|
|
60
|
+
export type MultiSelectProps = {
|
|
61
|
+
/** Visible label (required for accessibility). */
|
|
62
|
+
label: ReactNode
|
|
63
|
+
description?: ReactNode
|
|
64
|
+
errorMessage?: string
|
|
65
|
+
/** Shown in the trigger when nothing is selected. */
|
|
66
|
+
placeholder: string
|
|
67
|
+
options: MultiSelectOption[]
|
|
68
|
+
/** Controlled selected ids. Omit for uncontrolled (see `defaultSelectedKeys`). */
|
|
69
|
+
selectedKeys?: Set<string>
|
|
70
|
+
/** Initial selection when uncontrolled. */
|
|
71
|
+
defaultSelectedKeys?: Iterable<string>
|
|
72
|
+
/** Fired with the full set of selected ids after each toggle. */
|
|
73
|
+
onChange?: (ids: Set<string>) => void
|
|
74
|
+
/** Add a search field to filter options — a filterable "combobox multi". */
|
|
75
|
+
searchable?: boolean
|
|
76
|
+
isDisabled?: boolean
|
|
77
|
+
isInvalid?: boolean
|
|
78
|
+
className?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const MultiSelect = ({
|
|
82
|
+
label,
|
|
83
|
+
description,
|
|
84
|
+
errorMessage,
|
|
85
|
+
placeholder,
|
|
86
|
+
options,
|
|
87
|
+
selectedKeys,
|
|
88
|
+
defaultSelectedKeys,
|
|
89
|
+
onChange,
|
|
90
|
+
searchable,
|
|
91
|
+
isDisabled,
|
|
92
|
+
isInvalid,
|
|
93
|
+
className,
|
|
94
|
+
}: MultiSelectProps) => {
|
|
95
|
+
const labelId = useId()
|
|
96
|
+
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
97
|
+
const [open, setOpen] = useState(false)
|
|
98
|
+
const { contains } = useFilter({ sensitivity: 'base' })
|
|
99
|
+
const [internal, setInternal] = useState<Set<string>>(() => new Set(defaultSelectedKeys ?? []))
|
|
100
|
+
const selected = selectedKeys ?? internal
|
|
101
|
+
|
|
102
|
+
const handleChange = (keys: Selection) => {
|
|
103
|
+
const next = keys === 'all' ? new Set(options.map((o) => o.id)) : new Set([...keys].map(String))
|
|
104
|
+
if (selectedKeys === undefined) setInternal(next)
|
|
105
|
+
onChange?.(next)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const chosen = options.filter((o) => selected.has(o.id)).map((o) => o.label)
|
|
109
|
+
const summary = chosen.length === 0 ? placeholder : chosen.length <= 2 ? chosen.join(', ') : `${chosen.length} selected`
|
|
110
|
+
|
|
111
|
+
const list = (
|
|
112
|
+
<ListBox
|
|
113
|
+
aria-labelledby={labelId}
|
|
114
|
+
selectionMode="multiple"
|
|
115
|
+
selectedKeys={selected}
|
|
116
|
+
onSelectionChange={handleChange}
|
|
117
|
+
renderEmptyState={() => <div className="px-3 py-2 text-sm text-fg-muted">No matches</div>}
|
|
118
|
+
className="flex max-h-64 flex-col gap-0.5 overflow-auto p-1 outline-none"
|
|
119
|
+
>
|
|
120
|
+
{options.map((o) => (
|
|
121
|
+
<MultiSelectItem key={o.id} id={o.id} textValue={o.label} isDisabled={o.isDisabled}>
|
|
122
|
+
<Check />
|
|
123
|
+
<span>{o.label}</span>
|
|
124
|
+
</MultiSelectItem>
|
|
125
|
+
))}
|
|
126
|
+
</ListBox>
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div className={clsx('flex flex-col gap-2', className)}>
|
|
131
|
+
<span id={labelId} className="text-heading5 font-medium text-fg">
|
|
132
|
+
{label}
|
|
133
|
+
</span>
|
|
134
|
+
<RACButton
|
|
135
|
+
ref={triggerRef}
|
|
136
|
+
aria-labelledby={labelId}
|
|
137
|
+
isDisabled={isDisabled}
|
|
138
|
+
onPress={() => setOpen(true)}
|
|
139
|
+
className={clsx(
|
|
140
|
+
'flex h-12 w-full items-center justify-between gap-2.5 rounded-lg border bg-surface px-4 text-sm text-fg outline-none transition-colors',
|
|
141
|
+
'data-[hovered]:border-fg-subtle data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring',
|
|
142
|
+
'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60 data-[disabled]:pointer-events-none',
|
|
143
|
+
isInvalid ? 'border-danger ring-2 ring-danger' : 'border-border',
|
|
144
|
+
)}
|
|
145
|
+
>
|
|
146
|
+
<span className={clsx('truncate', chosen.length === 0 && 'text-fg-muted')}>{summary}</span>
|
|
147
|
+
<Chevron />
|
|
148
|
+
</RACButton>
|
|
149
|
+
{description ? <span className="text-xs text-fg-muted">{description}</span> : null}
|
|
150
|
+
{isInvalid && errorMessage ? <span className="text-xs text-danger">{errorMessage}</span> : null}
|
|
151
|
+
<Popover
|
|
152
|
+
triggerRef={triggerRef}
|
|
153
|
+
isOpen={open}
|
|
154
|
+
onOpenChange={setOpen}
|
|
155
|
+
className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg"
|
|
156
|
+
>
|
|
157
|
+
{searchable ? (
|
|
158
|
+
<Autocomplete filter={contains}>
|
|
159
|
+
<SearchField aria-label="Filter options" autoFocus className="border-b border-border p-1">
|
|
160
|
+
<RACInput
|
|
161
|
+
placeholder="Search…"
|
|
162
|
+
className="w-full rounded-md bg-surface px-3 py-2 text-sm text-fg outline-none placeholder:text-fg-muted"
|
|
163
|
+
/>
|
|
164
|
+
</SearchField>
|
|
165
|
+
{list}
|
|
166
|
+
</Autocomplete>
|
|
167
|
+
) : (
|
|
168
|
+
list
|
|
169
|
+
)}
|
|
170
|
+
</Popover>
|
|
171
|
+
</div>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Button as RACButton,
|
|
4
|
+
FieldError,
|
|
5
|
+
Group,
|
|
6
|
+
Input as RACInput,
|
|
7
|
+
Label,
|
|
8
|
+
NumberField as RACNumberField,
|
|
9
|
+
type NumberFieldProps as RACNumberFieldProps,
|
|
10
|
+
Text,
|
|
11
|
+
} from 'react-aria-components'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* NumberField — numeric input with steppers, min/max and locale-aware formatting
|
|
15
|
+
* (pass `formatOptions` for currency/percent/units). Built on React Aria
|
|
16
|
+
* Components `NumberField`; styled to match the other form fields.
|
|
17
|
+
*/
|
|
18
|
+
const stepper =
|
|
19
|
+
'flex h-full w-9 shrink-0 items-center justify-center text-base text-fg-muted outline-none transition-colors ' +
|
|
20
|
+
'hover:bg-surface-muted hover:text-fg data-[pressed]:bg-surface-muted data-[disabled]:opacity-40'
|
|
21
|
+
|
|
22
|
+
export type NumberFieldProps = RACNumberFieldProps & {
|
|
23
|
+
/** Visible label (required for accessibility). */
|
|
24
|
+
label: ReactNode
|
|
25
|
+
description?: ReactNode
|
|
26
|
+
errorMessage?: string
|
|
27
|
+
placeholder?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const NumberField = ({ label, description, errorMessage, placeholder, ...props }: NumberFieldProps) => (
|
|
31
|
+
<RACNumberField {...props} className="group flex flex-col gap-2">
|
|
32
|
+
<Label className="text-heading5 font-medium text-fg">{label}</Label>
|
|
33
|
+
<Group
|
|
34
|
+
className={
|
|
35
|
+
'flex h-12 w-full items-center overflow-hidden rounded-lg border border-border bg-surface text-sm text-fg transition-colors ' +
|
|
36
|
+
'data-[hovered]:border-fg-subtle ' +
|
|
37
|
+
'data-[focus-within]:border-brand-green data-[focus-within]:ring-2 data-[focus-within]:ring-ring ' +
|
|
38
|
+
'group-data-[invalid]:border-danger group-data-[invalid]:ring-2 group-data-[invalid]:ring-danger ' +
|
|
39
|
+
'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60'
|
|
40
|
+
}
|
|
41
|
+
>
|
|
42
|
+
<RACButton slot="decrement" className={`${stepper} border-r border-border`}>
|
|
43
|
+
–
|
|
44
|
+
</RACButton>
|
|
45
|
+
<RACInput
|
|
46
|
+
placeholder={placeholder}
|
|
47
|
+
className="min-w-0 flex-1 bg-transparent px-4 text-sm tabular-nums text-fg outline-none placeholder:text-fg-muted"
|
|
48
|
+
/>
|
|
49
|
+
<RACButton slot="increment" className={`${stepper} border-l border-border`}>
|
|
50
|
+
+
|
|
51
|
+
</RACButton>
|
|
52
|
+
</Group>
|
|
53
|
+
{description ? (
|
|
54
|
+
<Text slot="description" className="text-xs text-fg-muted">
|
|
55
|
+
{description}
|
|
56
|
+
</Text>
|
|
57
|
+
) : null}
|
|
58
|
+
<FieldError className="text-xs text-danger">{errorMessage}</FieldError>
|
|
59
|
+
</RACNumberField>
|
|
60
|
+
)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { type ClipboardEvent as ReactClipboardEvent, type ReactNode, useEffect, useRef } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* RichTextEditor — a dependency-free contentEditable WYSIWYG that emits an HTML
|
|
6
|
+
* string (round-trips with any `set:html` / `dangerouslySetInnerHTML` renderer,
|
|
7
|
+
* so no value migration and no bundled ProseMirror). Ported from pramen's
|
|
8
|
+
* cms-editor `RichText`; TipTap is the upgrade path if tables/embeds are needed.
|
|
9
|
+
*
|
|
10
|
+
* The `prose prose-sm` body relies on the @tailwindcss/typography plugin, which
|
|
11
|
+
* @podoba/tailwind's preset already registers.
|
|
12
|
+
*
|
|
13
|
+
* SECURITY: `scrubHtml` here is cosmetic paste/load cleaning, NOT an XSS boundary
|
|
14
|
+
* — an editor can POST any HTML straight to a server. Sanitise rich-text values
|
|
15
|
+
* on the SERVER on write. This just keeps obviously-unwanted markup out of the DOM.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const RT_TOOLS: Array<{ label: string; title: string; run: (exec: (c: string, a?: string) => void) => void }> = [
|
|
19
|
+
{ label: 'B', title: 'Bold', run: (x) => x('bold') },
|
|
20
|
+
{ label: 'I', title: 'Italic', run: (x) => x('italic') },
|
|
21
|
+
{ label: 'H2', title: 'Heading', run: (x) => x('formatBlock', 'H2') },
|
|
22
|
+
{ label: 'H3', title: 'Subheading', run: (x) => x('formatBlock', 'H3') },
|
|
23
|
+
{ label: '¶', title: 'Paragraph', run: (x) => x('formatBlock', 'P') },
|
|
24
|
+
{ label: '• List', title: 'Bulleted list', run: (x) => x('insertUnorderedList') },
|
|
25
|
+
{ label: '1. List', title: 'Numbered list', run: (x) => x('insertOrderedList') },
|
|
26
|
+
{
|
|
27
|
+
label: 'Link',
|
|
28
|
+
title: 'Add link',
|
|
29
|
+
run: (x) => {
|
|
30
|
+
const raw = window.prompt('Link URL (https://, mailto:, /path)', 'https://')
|
|
31
|
+
if (!raw) return
|
|
32
|
+
const url = safeLinkUrl(raw)
|
|
33
|
+
if (!url) {
|
|
34
|
+
window.alert('Only http(s), mailto, tel, or relative (/, #) links are allowed.')
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
x('createLink', url)
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{ label: 'Unlink', title: 'Remove link', run: (x) => x('unlink') },
|
|
41
|
+
{ label: 'Clear', title: 'Clear formatting', run: (x) => x('removeFormat') },
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
/** Allow-list for a link href: http(s), mailto, tel, or a relative/anchor path.
|
|
45
|
+
* The prefix allow-list inherently rejects `javascript:`/`data:`/`vbscript:`. */
|
|
46
|
+
function safeLinkUrl(raw: string): string | null {
|
|
47
|
+
const url = raw.trim()
|
|
48
|
+
return /^(https?:\/\/|mailto:|tel:|\/|#)/i.test(url) ? url : null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Cosmetic scrub (NOT a security boundary — sanitise on the server). */
|
|
52
|
+
function scrubHtml(html: string): string {
|
|
53
|
+
return html
|
|
54
|
+
.replace(/<\s*(script|style)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
|
|
55
|
+
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
|
|
56
|
+
.replace(/(href|src)\s*=\s*("javascript:[^"]*"|'javascript:[^']*')/gi, '$1="#"')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type RichTextEditorProps = {
|
|
60
|
+
/** Current HTML value (controlled). */
|
|
61
|
+
value: string
|
|
62
|
+
/** Fired with the scrubbed HTML on every edit. */
|
|
63
|
+
onChange: (html: string) => void
|
|
64
|
+
label?: ReactNode
|
|
65
|
+
description?: ReactNode
|
|
66
|
+
errorMessage?: string
|
|
67
|
+
/** Placeholder shown while the body is empty. */
|
|
68
|
+
placeholder?: string
|
|
69
|
+
/** Minimum body height in px (default 180). */
|
|
70
|
+
minHeight?: number
|
|
71
|
+
isInvalid?: boolean
|
|
72
|
+
className?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function RichTextEditor({
|
|
76
|
+
value,
|
|
77
|
+
onChange,
|
|
78
|
+
label,
|
|
79
|
+
description,
|
|
80
|
+
errorMessage,
|
|
81
|
+
placeholder = 'Write…',
|
|
82
|
+
minHeight = 180,
|
|
83
|
+
isInvalid,
|
|
84
|
+
className,
|
|
85
|
+
}: RichTextEditorProps) {
|
|
86
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
87
|
+
const last = useRef<string>('')
|
|
88
|
+
|
|
89
|
+
// Sync only EXTERNAL value changes into the DOM — never on our own keystrokes,
|
|
90
|
+
// or the caret would jump to the start on every character.
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
const el = ref.current
|
|
93
|
+
if (el && value !== last.current) {
|
|
94
|
+
el.innerHTML = scrubHtml(value || '')
|
|
95
|
+
last.current = value || ''
|
|
96
|
+
}
|
|
97
|
+
}, [value])
|
|
98
|
+
|
|
99
|
+
const emit = () => {
|
|
100
|
+
const html = scrubHtml(ref.current?.innerHTML ?? '')
|
|
101
|
+
last.current = html
|
|
102
|
+
onChange(html)
|
|
103
|
+
}
|
|
104
|
+
const exec = (command: string, arg?: string) => {
|
|
105
|
+
ref.current?.focus()
|
|
106
|
+
document.execCommand(command, false, arg)
|
|
107
|
+
emit()
|
|
108
|
+
}
|
|
109
|
+
// Paste as plain text — avoids importing Word/Docs style-junk into the HTML.
|
|
110
|
+
const onPaste = (e: ReactClipboardEvent<HTMLDivElement>) => {
|
|
111
|
+
e.preventDefault()
|
|
112
|
+
document.execCommand('insertText', false, e.clipboardData.getData('text/plain'))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className={clsx('flex flex-col gap-2', className)}>
|
|
117
|
+
{label ? <span className="text-heading5 font-medium text-fg">{label}</span> : null}
|
|
118
|
+
<div
|
|
119
|
+
className={clsx(
|
|
120
|
+
'overflow-hidden rounded-lg border bg-surface transition-colors',
|
|
121
|
+
'focus-within:ring-2 focus-within:ring-ring',
|
|
122
|
+
isInvalid
|
|
123
|
+
? 'border-danger focus-within:border-danger focus-within:ring-danger'
|
|
124
|
+
: 'border-border focus-within:border-brand-green',
|
|
125
|
+
)}
|
|
126
|
+
>
|
|
127
|
+
<div className="flex flex-wrap gap-0.5 border-b border-border bg-surface-muted px-2 py-1.5">
|
|
128
|
+
{RT_TOOLS.map((t) => (
|
|
129
|
+
// preventDefault on mousedown keeps the editor selection while the button is clicked.
|
|
130
|
+
<button
|
|
131
|
+
key={t.label}
|
|
132
|
+
type="button"
|
|
133
|
+
title={t.title}
|
|
134
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
135
|
+
onClick={() => t.run(exec)}
|
|
136
|
+
className="rounded border-0 bg-transparent px-2.5 py-0.5 text-xs text-fg-muted transition-colors hover:bg-surface hover:text-fg"
|
|
137
|
+
>
|
|
138
|
+
{t.label}
|
|
139
|
+
</button>
|
|
140
|
+
))}
|
|
141
|
+
</div>
|
|
142
|
+
<div
|
|
143
|
+
ref={ref}
|
|
144
|
+
className="prose prose-sm max-w-none px-4 py-3.5 text-sm leading-relaxed text-fg outline-none empty:before:text-fg-subtle empty:before:content-[attr(data-placeholder)]"
|
|
145
|
+
style={{ minHeight }}
|
|
146
|
+
contentEditable
|
|
147
|
+
suppressContentEditableWarning
|
|
148
|
+
data-placeholder={placeholder}
|
|
149
|
+
onInput={emit}
|
|
150
|
+
onBlur={emit}
|
|
151
|
+
onPaste={onPaste}
|
|
152
|
+
/>
|
|
153
|
+
</div>
|
|
154
|
+
{description ? <span className="text-xs text-fg-muted">{description}</span> : null}
|
|
155
|
+
{isInvalid && errorMessage ? <span className="text-xs text-danger">{errorMessage}</span> : null}
|
|
156
|
+
</div>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Button as RACButton,
|
|
4
|
+
Input as RACInput,
|
|
5
|
+
Label,
|
|
6
|
+
SearchField as RACSearchField,
|
|
7
|
+
type SearchFieldProps as RACSearchFieldProps,
|
|
8
|
+
Text,
|
|
9
|
+
} from 'react-aria-components'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* SearchField — a text input for search, with a clear (✕) button that appears
|
|
13
|
+
* once there's a value and a leading search glyph. Built on React Aria
|
|
14
|
+
* Components `SearchField` (Esc clears, `type=search` semantics). Styled to
|
|
15
|
+
* match the other form fields.
|
|
16
|
+
*/
|
|
17
|
+
const SearchGlyph = () => (
|
|
18
|
+
<svg
|
|
19
|
+
width="16"
|
|
20
|
+
height="16"
|
|
21
|
+
viewBox="0 0 24 24"
|
|
22
|
+
fill="none"
|
|
23
|
+
stroke="currentColor"
|
|
24
|
+
strokeWidth="2"
|
|
25
|
+
aria-hidden="true"
|
|
26
|
+
className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-fg-subtle"
|
|
27
|
+
>
|
|
28
|
+
<circle cx="11" cy="11" r="7" />
|
|
29
|
+
<path d="M21 21l-4.3-4.3" strokeLinecap="round" />
|
|
30
|
+
</svg>
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
export type SearchFieldProps = RACSearchFieldProps & {
|
|
34
|
+
label?: ReactNode
|
|
35
|
+
description?: ReactNode
|
|
36
|
+
placeholder?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const SearchField = ({ label, description, placeholder, ...props }: SearchFieldProps) => (
|
|
40
|
+
<RACSearchField {...props} className="group flex flex-col gap-2">
|
|
41
|
+
{label ? <Label className="text-heading5 font-medium text-fg">{label}</Label> : null}
|
|
42
|
+
<div className="relative flex items-center">
|
|
43
|
+
<SearchGlyph />
|
|
44
|
+
<RACInput
|
|
45
|
+
placeholder={placeholder}
|
|
46
|
+
className={
|
|
47
|
+
'h-12 w-full rounded-lg border border-border bg-surface pl-10 pr-10 text-sm text-fg outline-none transition-colors ' +
|
|
48
|
+
'placeholder:text-fg-muted data-[hovered]:border-fg-subtle ' +
|
|
49
|
+
'data-[focused]:border-brand-green data-[focused]:ring-2 data-[focused]:ring-ring ' +
|
|
50
|
+
'[&::-webkit-search-cancel-button]:hidden'
|
|
51
|
+
}
|
|
52
|
+
/>
|
|
53
|
+
{/* RAC hides this automatically when the field is empty. */}
|
|
54
|
+
<RACButton className="absolute right-2 flex h-7 w-7 items-center justify-center rounded-md text-fg-subtle outline-none transition-colors hover:bg-surface-muted hover:text-fg group-data-[empty]:hidden data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring">
|
|
55
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
|
56
|
+
<path d="M6 6l12 12M18 6 6 18" />
|
|
57
|
+
</svg>
|
|
58
|
+
</RACButton>
|
|
59
|
+
</div>
|
|
60
|
+
{description ? (
|
|
61
|
+
<Text slot="description" className="text-xs text-fg-muted">
|
|
62
|
+
{description}
|
|
63
|
+
</Text>
|
|
64
|
+
) : null}
|
|
65
|
+
</RACSearchField>
|
|
66
|
+
)
|
|
@@ -29,19 +29,17 @@ import { uic } from '../utils/uic'
|
|
|
29
29
|
*/
|
|
30
30
|
const SelectTrigger = uic(RACButton, {
|
|
31
31
|
displayName: 'SelectTrigger',
|
|
32
|
-
//
|
|
33
|
-
// 8px radius
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// (gs uses a box-shadow ring since the trigger has no border to colour); the
|
|
37
|
-
// ring is driven off the RACSelect root's `data-invalid` via `group-`.
|
|
32
|
+
// Sized to match the other form fields (Input / ComboBox): h-12, 16px side
|
|
33
|
+
// padding, 8px radius. White fill + border so the trigger doesn't vanish inside
|
|
34
|
+
// a Card (also surface-card) and read as disabled; hover darkens the border.
|
|
35
|
+
// Error → 2px danger ring driven off the RACSelect root's `data-invalid`.
|
|
38
36
|
baseClass:
|
|
39
|
-
'flex
|
|
37
|
+
'flex h-12 w-full items-center justify-between gap-2.5 rounded-lg border border-border bg-surface px-4 ' +
|
|
40
38
|
'text-sm text-fg outline-none transition-colors ' +
|
|
41
|
-
'data-[hovered]:
|
|
39
|
+
'data-[hovered]:border-fg-subtle ' +
|
|
42
40
|
'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring ' +
|
|
43
|
-
'group-data-[invalid]:ring-2 group-data-[invalid]:ring-danger ' +
|
|
44
|
-
'data-[disabled]:opacity-
|
|
41
|
+
'group-data-[invalid]:border-danger group-data-[invalid]:ring-2 group-data-[invalid]:ring-danger ' +
|
|
42
|
+
'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60 data-[disabled]:pointer-events-none',
|
|
45
43
|
})
|
|
46
44
|
|
|
47
45
|
export const SelectItem = uic(ListBoxItem, {
|
|
@@ -50,8 +48,8 @@ export const SelectItem = uic(ListBoxItem, {
|
|
|
50
48
|
// medium weight. We add a `surface-muted` focus background (gs highlights with
|
|
51
49
|
// weight only) so keyboard focus stays clearly visible on the cream content.
|
|
52
50
|
baseClass:
|
|
53
|
-
'flex cursor-pointer select-none items-center rounded-md px-
|
|
54
|
-
'data-[focused]:bg-surface-muted data-[selected]:font-medium ' +
|
|
51
|
+
'flex cursor-pointer select-none items-center rounded-md px-3 py-2 text-sm text-fg outline-none ' +
|
|
52
|
+
'data-[hovered]:bg-surface-muted data-[focused]:bg-surface-muted data-[selected]:font-medium ' +
|
|
55
53
|
'data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
|
|
56
54
|
}) as (props: ListBoxItemProps) => ReactNode
|
|
57
55
|
|
|
@@ -105,11 +103,10 @@ export const Select = <T extends object>({
|
|
|
105
103
|
</Text>
|
|
106
104
|
) : null}
|
|
107
105
|
<FieldError className="text-xs text-danger">{errorMessage}</FieldError>
|
|
108
|
-
{/*
|
|
109
|
-
|
|
110
|
-
12px gap between items. */}
|
|
106
|
+
{/* Cream fill, 8px radius, shadow-lg, NO border. 4px inset so each option's
|
|
107
|
+
highlight sits as a padded pill; small gap for an even list rhythm. */}
|
|
111
108
|
<Popover className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg">
|
|
112
|
-
<ListBox className="flex flex-col gap-
|
|
109
|
+
<ListBox className="flex flex-col gap-0.5 p-1 outline-none">{children}</ListBox>
|
|
113
110
|
</Popover>
|
|
114
111
|
</RACSelect>
|
|
115
112
|
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Label,
|
|
4
|
+
Slider as RACSlider,
|
|
5
|
+
type SliderProps as RACSliderProps,
|
|
6
|
+
SliderOutput,
|
|
7
|
+
SliderThumb,
|
|
8
|
+
SliderTrack,
|
|
9
|
+
} from 'react-aria-components'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Slider — a draggable value selector (single value or a range when `value`/
|
|
13
|
+
* `defaultValue` is a two-number array). Built on React Aria Components `Slider`
|
|
14
|
+
* (keyboard support, RTL, ARIA). The filled portion is brand-green.
|
|
15
|
+
*/
|
|
16
|
+
export type SliderProps<T extends number | number[]> = RACSliderProps<T> & {
|
|
17
|
+
/** Visible label (required for accessibility). */
|
|
18
|
+
label: ReactNode
|
|
19
|
+
/** Hide the numeric readout next to the label. */
|
|
20
|
+
hideOutput?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const Slider = <T extends number | number[]>({ label, hideOutput, ...props }: SliderProps<T>) => (
|
|
24
|
+
<RACSlider {...props} className="flex flex-col gap-2 data-[disabled]:opacity-50">
|
|
25
|
+
<div className="flex items-center justify-between">
|
|
26
|
+
<Label className="text-heading5 font-medium text-fg">{label}</Label>
|
|
27
|
+
{hideOutput ? null : <SliderOutput className="text-sm tabular-nums text-fg-muted" />}
|
|
28
|
+
</div>
|
|
29
|
+
<SliderTrack className="relative flex h-6 w-full items-center">
|
|
30
|
+
{({ state }) => {
|
|
31
|
+
const start = state.values.length > 1 ? state.getThumbPercent(0) : 0
|
|
32
|
+
const end = state.getThumbPercent(state.values.length - 1)
|
|
33
|
+
return (
|
|
34
|
+
<>
|
|
35
|
+
<div className="h-1.5 w-full rounded-full bg-surface-muted" />
|
|
36
|
+
<div
|
|
37
|
+
className="absolute h-1.5 rounded-full bg-brand-green"
|
|
38
|
+
style={{ left: `${start * 100}%`, width: `${(end - start) * 100}%` }}
|
|
39
|
+
/>
|
|
40
|
+
{state.values.map((_, i) => (
|
|
41
|
+
<SliderThumb
|
|
42
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: thumbs are positional and fixed-count
|
|
43
|
+
key={i}
|
|
44
|
+
index={i}
|
|
45
|
+
className="h-4 w-4 rounded-full border-2 border-fg bg-surface outline-none transition-transform data-[dragging]:scale-110 data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring"
|
|
46
|
+
/>
|
|
47
|
+
))}
|
|
48
|
+
</>
|
|
49
|
+
)
|
|
50
|
+
}}
|
|
51
|
+
</SliderTrack>
|
|
52
|
+
</RACSlider>
|
|
53
|
+
)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Button as RACButton,
|
|
4
|
+
Label,
|
|
5
|
+
Tag as RACTag,
|
|
6
|
+
type TagProps as RACTagProps,
|
|
7
|
+
TagGroup as RACTagGroup,
|
|
8
|
+
type TagGroupProps as RACTagGroupProps,
|
|
9
|
+
TagList,
|
|
10
|
+
Text,
|
|
11
|
+
} from 'react-aria-components'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* TagGroup + Tag — a set of chips (labels, filters, or a removable tag input).
|
|
15
|
+
* Built on React Aria Components `TagGroup` (roving focus, optional selection,
|
|
16
|
+
* removal via `onRemove` + a per-tag ✕). Pass `Tag`s as `TagList` children.
|
|
17
|
+
*/
|
|
18
|
+
export type TagGroupProps = Omit<RACTagGroupProps, 'children'> & {
|
|
19
|
+
label?: ReactNode
|
|
20
|
+
description?: ReactNode
|
|
21
|
+
children: ReactNode
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const TagGroup = ({ label, description, children, ...props }: TagGroupProps) => (
|
|
25
|
+
<RACTagGroup {...props} className="flex flex-col gap-2">
|
|
26
|
+
{label ? <Label className="text-heading5 font-medium text-fg">{label}</Label> : null}
|
|
27
|
+
<TagList className="flex flex-wrap gap-2 outline-none">{children}</TagList>
|
|
28
|
+
{description ? (
|
|
29
|
+
<Text slot="description" className="text-xs text-fg-muted">
|
|
30
|
+
{description}
|
|
31
|
+
</Text>
|
|
32
|
+
) : null}
|
|
33
|
+
</RACTagGroup>
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
export const Tag = ({ children, textValue, ...props }: RACTagProps) => (
|
|
37
|
+
<RACTag
|
|
38
|
+
{...props}
|
|
39
|
+
// We wrap children in a render fn (for the remove button), so RAC can't infer
|
|
40
|
+
// the tag's text — derive it from a string child for accessibility.
|
|
41
|
+
textValue={textValue ?? (typeof children === 'string' ? children : undefined)}
|
|
42
|
+
className={
|
|
43
|
+
'inline-flex cursor-default select-none items-center gap-1.5 rounded-full border border-border bg-surface-card px-3 py-1 text-sm text-fg outline-none transition-colors ' +
|
|
44
|
+
'data-[hovered]:border-fg-subtle ' +
|
|
45
|
+
'data-[selected]:border-fg data-[selected]:bg-surface-muted ' +
|
|
46
|
+
'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring ' +
|
|
47
|
+
'data-[disabled]:opacity-50'
|
|
48
|
+
}
|
|
49
|
+
>
|
|
50
|
+
{({ allowsRemoving }) => (
|
|
51
|
+
<>
|
|
52
|
+
{children as ReactNode}
|
|
53
|
+
{allowsRemoving ? (
|
|
54
|
+
<RACButton
|
|
55
|
+
slot="remove"
|
|
56
|
+
className="-mr-1 flex h-4 w-4 items-center justify-center rounded-full text-fg-subtle outline-none transition-colors hover:bg-surface-muted hover:text-fg data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring"
|
|
57
|
+
>
|
|
58
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" aria-hidden="true">
|
|
59
|
+
<path d="M6 6l12 12M18 6 6 18" />
|
|
60
|
+
</svg>
|
|
61
|
+
</RACButton>
|
|
62
|
+
) : null}
|
|
63
|
+
</>
|
|
64
|
+
)}
|
|
65
|
+
</RACTag>
|
|
66
|
+
)
|
|
@@ -20,17 +20,19 @@ import { uic } from '../utils/uic'
|
|
|
20
20
|
*/
|
|
21
21
|
const StyledTextArea = uic(RACTextArea, {
|
|
22
22
|
displayName: 'TextAreaControl',
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
23
|
+
// White fill so the field reads as editable — matches input.tsx. The gs
|
|
24
|
+
// original used cream (surface-card), but our Card surface is also surface-card,
|
|
25
|
+
// so a field inside a card vanished / looked disabled. Inverted: active = white
|
|
26
|
+
// (surface), disabled = muted cream. border #eceae1 → border · hover #aba89c →
|
|
27
|
+
// fg-subtle · focus #75e7b8 → brand-green · error → danger. min-h-[120px] is a
|
|
28
|
+
// control dimension (not a design token) — gs uses a literal 120px here too.
|
|
27
29
|
baseClass:
|
|
28
|
-
'min-h-[120px] w-full resize-y rounded-lg border border-border bg-surface
|
|
30
|
+
'min-h-[120px] w-full resize-y rounded-lg border border-border bg-surface px-4 py-3 text-sm text-fg ' +
|
|
29
31
|
'outline-none transition-colors duration-200 placeholder:text-fg-muted ' +
|
|
30
32
|
'data-[hovered]:border-fg-subtle ' +
|
|
31
33
|
'data-[focused]:border-brand-green data-[focused]:ring-2 data-[focused]:ring-ring ' +
|
|
32
34
|
'data-[invalid]:border-danger data-[invalid]:ring-danger ' +
|
|
33
|
-
'data-[disabled]:opacity-
|
|
35
|
+
'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60 data-[disabled]:pointer-events-none',
|
|
34
36
|
})
|
|
35
37
|
|
|
36
38
|
export type TextareaProps = TextFieldProps & {
|
package/src/index.ts
CHANGED
|
@@ -12,10 +12,20 @@ export { uic, uiconfig, type ConfigVariants, type NoInfer } from "./utils/uic";
|
|
|
12
12
|
export * from "./components/button";
|
|
13
13
|
export * from "./components/input";
|
|
14
14
|
export * from "./components/textarea";
|
|
15
|
+
export * from "./components/rich-text-editor";
|
|
15
16
|
export * from "./components/checkbox";
|
|
16
17
|
export * from "./components/radio";
|
|
17
18
|
export * from "./components/switch";
|
|
18
19
|
export * from "./components/select";
|
|
20
|
+
export * from "./components/combobox";
|
|
21
|
+
export * from "./components/multiselect";
|
|
22
|
+
export * from "./components/number-field";
|
|
23
|
+
export * from "./components/search-field";
|
|
24
|
+
export * from "./components/slider";
|
|
25
|
+
export * from "./components/tag-group";
|
|
26
|
+
export * from "./components/file-upload";
|
|
27
|
+
export * from "./components/date-field";
|
|
28
|
+
export * from "./components/date-picker";
|
|
19
29
|
export * from "./components/dialog";
|
|
20
30
|
export * from "./components/dropdown-menu";
|
|
21
31
|
export * from "./components/context-menu";
|