@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podoba/react",
3
- "version": "0.0.3",
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.3",
29
- "@podoba/tailwind": "^0.0.3",
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 &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
+ }
@@ -20,17 +20,20 @@ import { uic } from '../utils/uic'
20
20
  */
21
21
  const StyledInput = uic(RACInput, {
22
22
  displayName: 'InputControl',
23
- // gs token map: bg #f7f6f2 surface-card · border #eceae1 border · hover
24
- // #aba89cfg-subtle · focus #75e7b8 brand-green · error danger.
23
+ // White fill so the field reads as editable. The gs original used a cream
24
+ // (#f7f6f2surface-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-card px-4 text-sm text-fg ' +
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-50 data-[disabled]:pointer-events-none',
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
- // gs trigger: min-h 58px, 20px padding (p-5), 10px text↔chevron gap (gap-2.5),
33
- // 8px radius, NO border (filled fill only). We keep a focus-visible ring for
34
- // WCAG (a borderless trigger that still shows a visible focus indicator is the
35
- // correct a11y adaptation of gs's borderless look). Error 2px danger ring
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 min-h-[58px] w-full items-center justify-between gap-2.5 rounded-lg bg-surface-card p-5 ' +
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]:bg-surface-muted ' +
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-50 data-[disabled]:pointer-events-none',
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-5 text-sm text-fg outline-none ' +
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
- {/* gs content: cream fill, 8px radius, shadow-lg, NO border; viewport pads
109
- 20px vertical / 0 horizontal (items own their 20px side padding) with a
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-3 py-5 outline-none">{children}</ListBox>
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
- // gs token map: bg #f7f6f2 surface-card · border #eceae1 border · hover
24
- // #aba89c fg-subtle · focus #75e7b8 brand-green · error → danger.
25
- // min-h-[120px] is a control dimension (not a design token) gs uses a literal
26
- // 120px here too; there is no spacing token for it.
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-card px-4 py-3 text-sm text-fg ' +
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-50 data-[disabled]:pointer-events-none',
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";