@podoba/react 0.0.3 → 0.0.4

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.4",
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.4",
29
+ "@podoba/tailwind": "^0.0.4",
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-5 py-1 text-sm text-fg outline-none ' +
48
+ '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-3 overflow-auto py-5 outline-none"
90
+ renderEmptyState={() => <div className="px-5 py-1 text-sm text-fg-muted">No results</div>}
91
+ >
92
+ {children}
93
+ </ListBox>
94
+ </Popover>
95
+ </RACComboBox>
96
+ )
@@ -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}
@@ -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,146 @@
1
+ import { type ReactNode, useId, useRef, useState } from 'react'
2
+ import {
3
+ Button as RACButton,
4
+ Label,
5
+ ListBox,
6
+ ListBoxItem,
7
+ type ListBoxItemProps,
8
+ Popover,
9
+ type Selection,
10
+ Text,
11
+ } from 'react-aria-components'
12
+ import { clsx } from 'clsx'
13
+ import { uic } from '../utils/uic'
14
+
15
+ /**
16
+ * MultiSelect — a dropdown that selects several options at once.
17
+ *
18
+ * React Aria Components ships no multi-select control at 1.x, so this composes
19
+ * one from primitives: a `Select`-styled trigger (white filled field) plus a
20
+ * controlled `Popover` + `ListBox selectionMode="multiple"` (RAC gives the
21
+ * listbox ARIA + keyboard multi-selection). The trigger summarises the choice —
22
+ * up to two labels, then an "N selected" count. Options come as `{ id, label }`
23
+ * data (not children) because the trigger needs the value→label map. Selection
24
+ * is uncontrolled by default; pass `selectedKeys` + `onChange` to control it.
25
+ */
26
+ const Chevron = () => (
27
+ <svg width="9.5" height="9.5" viewBox="0 0 12 12" fill="none" aria-hidden="true" className="shrink-0 text-fg">
28
+ <path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
29
+ </svg>
30
+ )
31
+
32
+ const Check = () => (
33
+ <svg
34
+ viewBox="0 0 16 16"
35
+ fill="none"
36
+ aria-hidden="true"
37
+ className="h-4 w-4 shrink-0 text-fg opacity-0 group-data-[selected]:opacity-100"
38
+ >
39
+ <path d="M3.5 8.5l3 3 6-6.5" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
40
+ </svg>
41
+ )
42
+
43
+ const MultiSelectItem = uic(ListBoxItem, {
44
+ displayName: 'MultiSelectItem',
45
+ // `group` so the leading Check can react to this item's data-selected.
46
+ 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 ' +
49
+ 'data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
50
+ }) as (props: ListBoxItemProps) => ReactNode
51
+
52
+ export type MultiSelectOption = { id: string; label: string; isDisabled?: boolean }
53
+
54
+ export type MultiSelectProps = {
55
+ /** Visible label (required for accessibility). */
56
+ label: ReactNode
57
+ description?: ReactNode
58
+ errorMessage?: string
59
+ /** Shown in the trigger when nothing is selected. */
60
+ placeholder: string
61
+ options: MultiSelectOption[]
62
+ /** Controlled selected ids. Omit for uncontrolled (see `defaultSelectedKeys`). */
63
+ selectedKeys?: Set<string>
64
+ /** Initial selection when uncontrolled. */
65
+ defaultSelectedKeys?: Iterable<string>
66
+ /** Fired with the full set of selected ids after each toggle. */
67
+ onChange?: (ids: Set<string>) => void
68
+ isDisabled?: boolean
69
+ isInvalid?: boolean
70
+ className?: string
71
+ }
72
+
73
+ export const MultiSelect = ({
74
+ label,
75
+ description,
76
+ errorMessage,
77
+ placeholder,
78
+ options,
79
+ selectedKeys,
80
+ defaultSelectedKeys,
81
+ onChange,
82
+ isDisabled,
83
+ isInvalid,
84
+ className,
85
+ }: MultiSelectProps) => {
86
+ const labelId = useId()
87
+ const triggerRef = useRef<HTMLButtonElement>(null)
88
+ const [open, setOpen] = useState(false)
89
+ const [internal, setInternal] = useState<Set<string>>(() => new Set(defaultSelectedKeys ?? []))
90
+ const selected = selectedKeys ?? internal
91
+
92
+ const handleChange = (keys: Selection) => {
93
+ const next = keys === 'all' ? new Set(options.map((o) => o.id)) : new Set([...keys].map(String))
94
+ if (selectedKeys === undefined) setInternal(next)
95
+ onChange?.(next)
96
+ }
97
+
98
+ const chosen = options.filter((o) => selected.has(o.id)).map((o) => o.label)
99
+ const summary = chosen.length === 0 ? placeholder : chosen.length <= 2 ? chosen.join(', ') : `${chosen.length} selected`
100
+
101
+ return (
102
+ <div className={clsx('flex flex-col gap-2', className)}>
103
+ <span id={labelId} className="text-heading5 font-medium text-fg">
104
+ {label}
105
+ </span>
106
+ <RACButton
107
+ ref={triggerRef}
108
+ aria-labelledby={labelId}
109
+ isDisabled={isDisabled}
110
+ onPress={() => setOpen(true)}
111
+ className={clsx(
112
+ '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',
113
+ 'data-[hovered]:border-fg-subtle data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring',
114
+ 'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60 data-[disabled]:pointer-events-none',
115
+ isInvalid ? 'border-danger ring-2 ring-danger' : 'border-border',
116
+ )}
117
+ >
118
+ <span className={clsx('truncate', chosen.length === 0 && 'text-fg-muted')}>{summary}</span>
119
+ <Chevron />
120
+ </RACButton>
121
+ {description ? <span className="text-xs text-fg-muted">{description}</span> : null}
122
+ {isInvalid && errorMessage ? <span className="text-xs text-danger">{errorMessage}</span> : null}
123
+ <Popover
124
+ triggerRef={triggerRef}
125
+ isOpen={open}
126
+ onOpenChange={setOpen}
127
+ className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg"
128
+ >
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>
143
+ </Popover>
144
+ </div>
145
+ )
146
+ }
@@ -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,7 +48,7 @@ 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 ' +
51
+ 'flex cursor-pointer select-none items-center rounded-md px-5 py-1 text-sm text-fg outline-none ' +
54
52
  '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
@@ -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
@@ -16,6 +16,8 @@ export * from "./components/checkbox";
16
16
  export * from "./components/radio";
17
17
  export * from "./components/switch";
18
18
  export * from "./components/select";
19
+ export * from "./components/combobox";
20
+ export * from "./components/multiselect";
19
21
  export * from "./components/dialog";
20
22
  export * from "./components/dropdown-menu";
21
23
  export * from "./components/context-menu";