@podoba/react 0.0.4 → 0.0.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podoba/react",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
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.4",
29
- "@podoba/tailwind": "^0.0.4",
28
+ "@podoba/tokens": "^0.0.6",
29
+ "@podoba/tailwind": "^0.0.6",
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-5 py-1 text-sm text-fg outline-none ' +
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-3 overflow-auto py-5 outline-none"
90
- renderEmptyState={() => <div className="px-5 py-1 text-sm text-fg-muted">No results</div>}
89
+ className="flex max-h-64 flex-col gap-0.5 overflow-auto overscroll-contain 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 &amp; 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
- Label,
5
+ Input as RACInput,
5
6
  ListBox,
6
7
  ListBoxItem,
7
8
  type ListBoxItemProps,
8
9
  Popover,
10
+ SearchField,
9
11
  type Selection,
10
- Text,
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-5 py-1 text-sm text-fg outline-none ' +
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 overscroll-contain 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
- <ListBox
130
- aria-labelledby={labelId}
131
- selectionMode="multiple"
132
- selectedKeys={selected}
133
- onSelectionChange={handleChange}
134
- className="flex max-h-64 flex-col gap-3 overflow-auto py-5 outline-none"
135
- >
136
- {options.map((o) => (
137
- <MultiSelectItem key={o.id} id={o.id} textValue={o.label} isDisabled={o.isDisabled}>
138
- <Check />
139
- <span>{o.label}</span>
140
- </MultiSelectItem>
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-5 py-1 text-sm text-fg outline-none ' +
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,12 @@ export const Select = <T extends object>({
103
103
  </Text>
104
104
  ) : null}
105
105
  <FieldError className="text-xs text-danger">{errorMessage}</FieldError>
106
- {/* gs content: cream fill, 8px radius, shadow-lg, NO border; viewport pads
107
- 20px vertical / 0 horizontal (items own their 20px side padding) with a
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-3 py-5 outline-none">{children}</ListBox>
109
+ <ListBox className="flex max-h-64 flex-col gap-0.5 overflow-auto overscroll-contain p-1 outline-none">
110
+ {children}
111
+ </ListBox>
111
112
  </Popover>
112
113
  </RACSelect>
113
114
  )
@@ -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-baseline justify-between gap-4">
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";