@podoba/react 0.0.4 → 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 +4 -4
- package/src/components/date-field.tsx +71 -0
- package/src/components/date-picker.tsx +111 -0
- package/src/components/file-upload.tsx +64 -0
- package/src/components/multiselect.tsx +45 -18
- 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 +5 -6
- package/src/components/slider.tsx +53 -0
- package/src/components/tag-group.tsx +66 -0
- package/src/index.ts +8 -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",
|
|
@@ -44,8 +44,8 @@ export const ComboBoxItem = uic(ListBoxItem, {
|
|
|
44
44
|
displayName: 'ComboBoxItem',
|
|
45
45
|
// Matches SelectItem so the two dropdowns are indistinguishable.
|
|
46
46
|
baseClass:
|
|
47
|
-
'flex cursor-pointer select-none items-center rounded-md px-
|
|
48
|
-
'data-[focused]:bg-surface-muted data-[selected]:font-medium ' +
|
|
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
49
|
'data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
|
|
50
50
|
}) as (props: ListBoxItemProps) => ReactNode
|
|
51
51
|
|
|
@@ -86,8 +86,8 @@ export const ComboBox = <T extends object>({
|
|
|
86
86
|
<FieldError className="text-xs text-danger">{errorMessage}</FieldError>
|
|
87
87
|
<Popover className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg">
|
|
88
88
|
<ListBox
|
|
89
|
-
className="flex max-h-64 flex-col gap-
|
|
90
|
-
renderEmptyState={() => <div className="px-
|
|
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
91
|
>
|
|
92
92
|
{children}
|
|
93
93
|
</ListBox>
|
|
@@ -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
|
+
)
|
|
@@ -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
|
+
}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { type ReactNode, useId, useRef, useState } from 'react'
|
|
2
2
|
import {
|
|
3
|
+
Autocomplete,
|
|
3
4
|
Button as RACButton,
|
|
4
|
-
|
|
5
|
+
Input as RACInput,
|
|
5
6
|
ListBox,
|
|
6
7
|
ListBoxItem,
|
|
7
8
|
type ListBoxItemProps,
|
|
8
9
|
Popover,
|
|
10
|
+
SearchField,
|
|
9
11
|
type Selection,
|
|
10
|
-
|
|
12
|
+
useFilter,
|
|
11
13
|
} from 'react-aria-components'
|
|
12
14
|
import { clsx } from 'clsx'
|
|
13
15
|
import { uic } from '../utils/uic'
|
|
@@ -22,6 +24,10 @@ import { uic } from '../utils/uic'
|
|
|
22
24
|
* up to two labels, then an "N selected" count. Options come as `{ id, label }`
|
|
23
25
|
* data (not children) because the trigger needs the value→label map. Selection
|
|
24
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.
|
|
25
31
|
*/
|
|
26
32
|
const Chevron = () => (
|
|
27
33
|
<svg width="9.5" height="9.5" viewBox="0 0 12 12" fill="none" aria-hidden="true" className="shrink-0 text-fg">
|
|
@@ -44,8 +50,8 @@ const MultiSelectItem = uic(ListBoxItem, {
|
|
|
44
50
|
displayName: 'MultiSelectItem',
|
|
45
51
|
// `group` so the leading Check can react to this item's data-selected.
|
|
46
52
|
baseClass:
|
|
47
|
-
'group flex cursor-pointer select-none items-center gap-2 rounded-md px-
|
|
48
|
-
'data-[focused]:bg-surface-muted data-[selected]:font-medium ' +
|
|
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 ' +
|
|
49
55
|
'data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
|
|
50
56
|
}) as (props: ListBoxItemProps) => ReactNode
|
|
51
57
|
|
|
@@ -65,6 +71,8 @@ export type MultiSelectProps = {
|
|
|
65
71
|
defaultSelectedKeys?: Iterable<string>
|
|
66
72
|
/** Fired with the full set of selected ids after each toggle. */
|
|
67
73
|
onChange?: (ids: Set<string>) => void
|
|
74
|
+
/** Add a search field to filter options — a filterable "combobox multi". */
|
|
75
|
+
searchable?: boolean
|
|
68
76
|
isDisabled?: boolean
|
|
69
77
|
isInvalid?: boolean
|
|
70
78
|
className?: string
|
|
@@ -79,6 +87,7 @@ export const MultiSelect = ({
|
|
|
79
87
|
selectedKeys,
|
|
80
88
|
defaultSelectedKeys,
|
|
81
89
|
onChange,
|
|
90
|
+
searchable,
|
|
82
91
|
isDisabled,
|
|
83
92
|
isInvalid,
|
|
84
93
|
className,
|
|
@@ -86,6 +95,7 @@ export const MultiSelect = ({
|
|
|
86
95
|
const labelId = useId()
|
|
87
96
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
88
97
|
const [open, setOpen] = useState(false)
|
|
98
|
+
const { contains } = useFilter({ sensitivity: 'base' })
|
|
89
99
|
const [internal, setInternal] = useState<Set<string>>(() => new Set(defaultSelectedKeys ?? []))
|
|
90
100
|
const selected = selectedKeys ?? internal
|
|
91
101
|
|
|
@@ -98,6 +108,24 @@ export const MultiSelect = ({
|
|
|
98
108
|
const chosen = options.filter((o) => selected.has(o.id)).map((o) => o.label)
|
|
99
109
|
const summary = chosen.length === 0 ? placeholder : chosen.length <= 2 ? chosen.join(', ') : `${chosen.length} selected`
|
|
100
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
|
+
|
|
101
129
|
return (
|
|
102
130
|
<div className={clsx('flex flex-col gap-2', className)}>
|
|
103
131
|
<span id={labelId} className="text-heading5 font-medium text-fg">
|
|
@@ -126,20 +154,19 @@ export const MultiSelect = ({
|
|
|
126
154
|
onOpenChange={setOpen}
|
|
127
155
|
className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg"
|
|
128
156
|
>
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
</ListBox>
|
|
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
|
+
)}
|
|
143
170
|
</Popover>
|
|
144
171
|
</div>
|
|
145
172
|
)
|
|
@@ -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
|
+
)
|
|
@@ -48,8 +48,8 @@ export const SelectItem = uic(ListBoxItem, {
|
|
|
48
48
|
// medium weight. We add a `surface-muted` focus background (gs highlights with
|
|
49
49
|
// weight only) so keyboard focus stays clearly visible on the cream content.
|
|
50
50
|
baseClass:
|
|
51
|
-
'flex cursor-pointer select-none items-center rounded-md px-
|
|
52
|
-
'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 ' +
|
|
53
53
|
'data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
|
|
54
54
|
}) as (props: ListBoxItemProps) => ReactNode
|
|
55
55
|
|
|
@@ -103,11 +103,10 @@ export const Select = <T extends object>({
|
|
|
103
103
|
</Text>
|
|
104
104
|
) : null}
|
|
105
105
|
<FieldError className="text-xs text-danger">{errorMessage}</FieldError>
|
|
106
|
-
{/*
|
|
107
|
-
|
|
108
|
-
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. */}
|
|
109
108
|
<Popover className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg">
|
|
110
|
-
<ListBox className="flex flex-col gap-
|
|
109
|
+
<ListBox className="flex flex-col gap-0.5 p-1 outline-none">{children}</ListBox>
|
|
111
110
|
</Popover>
|
|
112
111
|
</RACSelect>
|
|
113
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
|
+
)
|
package/src/index.ts
CHANGED
|
@@ -12,12 +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";
|
|
19
20
|
export * from "./components/combobox";
|
|
20
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";
|
|
21
29
|
export * from "./components/dialog";
|
|
22
30
|
export * from "./components/dropdown-menu";
|
|
23
31
|
export * from "./components/context-menu";
|