@maestro-js/components 1.0.0-alpha.2 → 1.0.0-alpha.20

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.
Files changed (56) hide show
  1. package/commands/add.ts +16 -1
  2. package/dist/components.json +1 -1
  3. package/dist/index.js +14 -1
  4. package/package.json +5 -5
  5. package/registry.json +610 -802
  6. package/scripts/build.ts +11 -4
  7. package/src/components/alert-dialog.tsx +3 -3
  8. package/src/components/avatar.tsx +1 -1
  9. package/src/components/button-link.tsx +3 -0
  10. package/src/components/button.tsx +4 -0
  11. package/src/components/checkbox.tsx +7 -7
  12. package/src/components/chip.tsx +7 -7
  13. package/src/components/container.tsx +3 -3
  14. package/src/components/date-range.tsx +138 -0
  15. package/src/components/dialog.tsx +21 -7
  16. package/src/components/disclosure.tsx +1 -1
  17. package/src/components/drawer.tsx +19 -5
  18. package/src/components/form.tsx +27 -5
  19. package/src/components/helpers/form-field.tsx +7 -8
  20. package/src/components/helpers/get-button-classes.ts +23 -23
  21. package/src/components/helpers/headless-button.tsx +1 -1
  22. package/src/components/inline-alert.tsx +3 -3
  23. package/src/components/labeled-value.tsx +2 -2
  24. package/src/components/menu.tsx +60 -26
  25. package/src/components/month-day-input.tsx +2 -1
  26. package/src/components/multiselect.tsx +74 -70
  27. package/src/components/optional-link.tsx +15 -0
  28. package/src/components/radio.tsx +8 -8
  29. package/src/components/select.tsx +9 -8
  30. package/src/components/stepper.tsx +6 -6
  31. package/src/components/switch.tsx +7 -7
  32. package/src/components/table/headless-templated-row-table.ts +550 -0
  33. package/src/components/table/index.tsx +41 -0
  34. package/src/components/table/server-table.ts +143 -0
  35. package/src/components/table/table-actions.tsx +76 -0
  36. package/src/components/table/table-container.tsx +173 -0
  37. package/src/components/table/table-container.type-test.ts +155 -0
  38. package/src/components/table/table-filters/date-range-filter.tsx +227 -0
  39. package/src/components/table/table-filters/select-filter.tsx +211 -0
  40. package/src/components/table/table-filters/sort.tsx +85 -0
  41. package/src/components/table/table-primitives.tsx +226 -0
  42. package/src/components/table/table-toolbar.tsx +300 -0
  43. package/src/components/table/table-types.ts +61 -0
  44. package/src/components/table/use-client-table.ts +157 -0
  45. package/src/components/table/use-client-table.type-test.ts +217 -0
  46. package/src/components/table/use-server-table.ts +192 -0
  47. package/src/components/table/use-server-table.type-test.ts +174 -0
  48. package/src/components/tabs.tsx +1 -1
  49. package/src/components/tag-field.tsx +3 -3
  50. package/src/components/text-area.tsx +1 -1
  51. package/src/components/text-field.tsx +4 -1
  52. package/src/components/toast.tsx +6 -6
  53. package/src/components/toggle-button-group.tsx +2 -2
  54. package/src/utils/icons.d.ts +2 -1
  55. package/src/utils/use-query-state.ts +626 -0
  56. package/src/utils/use-tab-indicator.ts +9 -9
@@ -6,19 +6,19 @@ export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
6
6
  export type ButtonShape = 'rectangle' | 'circle' | 'oval'
7
7
 
8
8
  const base =
9
- 'relative inline-flex items-center justify-center gap-2 font-medium whitespace-nowrap transition-all cursor-default select-none [&_svg]:pointer-events-none [&_svg:not([class*=size-])]:size-4 [&_svg]:shrink-0 data-[disabled]:cursor-not-allowed'
9
+ 'relative inline-flex items-center justify-center gap-2 font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow,transform,opacity] duration-150 cursor-default select-none [&_svg]:pointer-events-none [&_svg:not([class*=size-])]:size-4 [&_svg]:shrink-0 data-[disabled]:cursor-not-allowed'
10
10
 
11
11
  const sizeClasses: Record<ButtonSize, string> = {
12
- xs: 'h-7 min-w-14 px-2.5 text-xs',
13
- sm: 'h-8 min-w-16 px-3 text-sm',
14
- md: 'h-9 min-w-20 px-4 text-sm',
15
- lg: 'h-10 min-w-24 px-5 text-base',
16
- xl: 'h-12 min-w-28 px-6 text-base'
12
+ xs: 'h-7 min-w-14 px-2 text-xs',
13
+ sm: 'h-8 min-w-16 px-2.5 text-sm',
14
+ md: 'h-9 min-w-20 px-3 text-sm',
15
+ lg: 'h-10 min-w-24 px-4 text-base',
16
+ xl: 'h-12 min-w-28 px-5 text-base'
17
17
  }
18
18
 
19
19
  const shapeClasses: Record<ButtonShape, string> = {
20
20
  rectangle: 'rounded',
21
- circle: 'rounded-full aspect-square px-0',
21
+ circle: 'rounded-full aspect-square px-0 min-w-0',
22
22
  oval: 'rounded-full'
23
23
  }
24
24
 
@@ -37,39 +37,39 @@ const variantColorClasses: Record<ButtonVariant, Record<ButtonColor, string>> =
37
37
  },
38
38
  outlined: {
39
39
  primary:
40
- 'bg-white ring-1 ring-inset ring-primary-300 text-primary-700 data-[hovered]:bg-primary-50 data-[hovered]:ring-primary-400 data-[pressed]:bg-primary-100 data-[pressed]:ring-primary-500 data-[disabled]:bg-neutral-50 data-[disabled]:text-neutral-400 data-[disabled]:ring-neutral-200 data-[disabled]:shadow-none',
40
+ 'bg-white ring-1 ring-inset ring-primary-300 text-primary-700 data-[hovered]:bg-primary-50 data-[hovered]:ring-primary-400 data-[pressed]:bg-primary-100 data-[pressed]:ring-primary-500 data-[disabled]:bg-neutral-200 data-[disabled]:text-neutral-400 data-[disabled]:ring-transparent',
41
41
  negative:
42
- 'bg-white ring-1 ring-inset ring-negative-300 text-negative-700 data-[hovered]:bg-negative-50 data-[hovered]:ring-negative-400 data-[pressed]:bg-negative-100 data-[pressed]:ring-negative-500 data-[disabled]:bg-neutral-50 data-[disabled]:text-neutral-400 data-[disabled]:ring-neutral-200 data-[disabled]:shadow-none',
42
+ 'bg-white ring-1 ring-inset ring-negative-300 text-negative-700 data-[hovered]:bg-negative-50 data-[hovered]:ring-negative-400 data-[pressed]:bg-negative-100 data-[pressed]:ring-negative-500 data-[disabled]:bg-neutral-200 data-[disabled]:text-neutral-400 data-[disabled]:ring-transparent',
43
43
  positive:
44
- 'bg-white ring-1 ring-inset ring-positive-300 text-positive-700 data-[hovered]:bg-positive-50 data-[hovered]:ring-positive-400 data-[pressed]:bg-positive-100 data-[pressed]:ring-positive-500 data-[disabled]:bg-neutral-50 data-[disabled]:text-neutral-400 data-[disabled]:ring-neutral-200 data-[disabled]:shadow-none',
44
+ 'bg-white ring-1 ring-inset ring-positive-300 text-positive-700 data-[hovered]:bg-positive-50 data-[hovered]:ring-positive-400 data-[pressed]:bg-positive-100 data-[pressed]:ring-positive-500 data-[disabled]:bg-neutral-200 data-[disabled]:text-neutral-400 data-[disabled]:ring-transparent',
45
45
  notice:
46
- 'bg-white ring-1 ring-inset ring-notice-300 text-notice-700 data-[hovered]:bg-notice-50 data-[hovered]:ring-notice-400 data-[pressed]:bg-notice-100 data-[pressed]:ring-notice-500 data-[disabled]:bg-neutral-50 data-[disabled]:text-neutral-400 data-[disabled]:ring-neutral-200 data-[disabled]:shadow-none',
46
+ 'bg-white ring-1 ring-inset ring-notice-300 text-notice-700 data-[hovered]:bg-notice-50 data-[hovered]:ring-notice-400 data-[pressed]:bg-notice-100 data-[pressed]:ring-notice-500 data-[disabled]:bg-neutral-200 data-[disabled]:text-neutral-400 data-[disabled]:ring-transparent',
47
47
  neutral:
48
- 'bg-white ring-1 ring-inset ring-neutral-300 text-neutral-700 data-[hovered]:bg-neutral-50 data-[hovered]:ring-neutral-400 data-[pressed]:bg-neutral-100 data-[pressed]:ring-neutral-500 data-[disabled]:bg-neutral-50 data-[disabled]:text-neutral-400 data-[disabled]:ring-neutral-200 data-[disabled]:shadow-none'
48
+ 'bg-white ring-1 ring-inset ring-neutral-300 text-neutral-700 data-[hovered]:bg-neutral-50 data-[hovered]:ring-neutral-400 data-[pressed]:bg-neutral-100 data-[pressed]:ring-neutral-500 data-[disabled]:bg-neutral-200 data-[disabled]:text-neutral-400 data-[disabled]:ring-transparent'
49
49
  },
50
50
  soft: {
51
51
  primary:
52
- 'bg-primary-100 text-primary-800 data-[hovered]:bg-primary-200 data-[pressed]:bg-primary-300 data-[disabled]:bg-neutral-50 data-[disabled]:text-neutral-400',
52
+ 'bg-primary-100 text-primary-800 data-[hovered]:bg-primary-200 data-[pressed]:bg-primary-300 data-[disabled]:bg-neutral-200 data-[disabled]:text-neutral-400',
53
53
  negative:
54
- 'bg-negative-100 text-negative-800 data-[hovered]:bg-negative-200 data-[pressed]:bg-negative-300 data-[disabled]:bg-neutral-50 data-[disabled]:text-neutral-400',
54
+ 'bg-negative-100 text-negative-800 data-[hovered]:bg-negative-200 data-[pressed]:bg-negative-300 data-[disabled]:bg-neutral-200 data-[disabled]:text-neutral-400',
55
55
  positive:
56
- 'bg-positive-100 text-positive-800 data-[hovered]:bg-positive-200 data-[pressed]:bg-positive-300 data-[disabled]:bg-neutral-50 data-[disabled]:text-neutral-400',
56
+ 'bg-positive-100 text-positive-800 data-[hovered]:bg-positive-200 data-[pressed]:bg-positive-300 data-[disabled]:bg-neutral-200 data-[disabled]:text-neutral-400',
57
57
  notice:
58
- 'bg-notice-100 text-notice-800 data-[hovered]:bg-notice-200 data-[pressed]:bg-notice-300 data-[disabled]:bg-neutral-50 data-[disabled]:text-neutral-400',
58
+ 'bg-notice-100 text-notice-800 data-[hovered]:bg-notice-200 data-[pressed]:bg-notice-300 data-[disabled]:bg-neutral-200 data-[disabled]:text-neutral-400',
59
59
  neutral:
60
- 'bg-neutral-200 text-neutral-800 data-[hovered]:bg-neutral-300 data-[pressed]:bg-neutral-400 data-[disabled]:bg-neutral-50 data-[disabled]:text-neutral-400'
60
+ 'bg-neutral-200 text-neutral-800 data-[hovered]:bg-neutral-300 data-[pressed]:bg-neutral-400 data-[disabled]:bg-neutral-200 data-[disabled]:text-neutral-400'
61
61
  },
62
62
  plain: {
63
63
  primary:
64
- 'bg-transparent text-primary-700 data-[hovered]:bg-primary-50 data-[pressed]:bg-primary-100 data-[disabled]:bg-transparent data-[disabled]:text-neutral-400',
64
+ 'bg-transparent text-primary-700 data-[hovered]:bg-primary-50 data-[pressed]:bg-primary-100 data-[disabled]:bg-neutral-200 data-[disabled]:text-neutral-400',
65
65
  negative:
66
- 'bg-transparent text-negative-700 data-[hovered]:bg-negative-50 data-[pressed]:bg-negative-100 data-[disabled]:bg-transparent data-[disabled]:text-neutral-400',
66
+ 'bg-transparent text-negative-700 data-[hovered]:bg-negative-50 data-[pressed]:bg-negative-100 data-[disabled]:bg-neutral-200 data-[disabled]:text-neutral-400',
67
67
  positive:
68
- 'bg-transparent text-positive-700 data-[hovered]:bg-positive-50 data-[pressed]:bg-positive-100 data-[disabled]:bg-transparent data-[disabled]:text-neutral-400',
68
+ 'bg-transparent text-positive-700 data-[hovered]:bg-positive-50 data-[pressed]:bg-positive-100 data-[disabled]:bg-neutral-200 data-[disabled]:text-neutral-400',
69
69
  notice:
70
- 'bg-transparent text-notice-700 data-[hovered]:bg-notice-50 data-[pressed]:bg-notice-100 data-[disabled]:bg-transparent data-[disabled]:text-neutral-400',
70
+ 'bg-transparent text-notice-700 data-[hovered]:bg-notice-50 data-[pressed]:bg-notice-100 data-[disabled]:bg-neutral-200 data-[disabled]:text-neutral-400',
71
71
  neutral:
72
- 'bg-transparent text-neutral-700 data-[hovered]:bg-neutral-50 data-[pressed]:bg-neutral-100 data-[disabled]:bg-transparent data-[disabled]:text-neutral-400'
72
+ 'bg-transparent text-neutral-700 data-[hovered]:bg-neutral-50 data-[pressed]:bg-neutral-100 data-[disabled]:bg-neutral-200 data-[disabled]:text-neutral-400'
73
73
  }
74
74
  }
75
75
 
@@ -105,7 +105,7 @@ const toggledVariantColorClasses: Record<ButtonVariant, Record<ButtonColor, stri
105
105
  }
106
106
 
107
107
  const focusClasses =
108
- 'data-[focus-visible]:outline-2 data-[focus-visible]:outline-offset-2 data-[focus-visible]:outline-primary-600'
108
+ 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 data-[focus-visible]:outline-2 data-[focus-visible]:outline-offset-2 data-[focus-visible]:outline-primary-600'
109
109
 
110
110
  export function getButtonClasses({
111
111
  variant = 'contained',
@@ -27,7 +27,7 @@ export const HeadlessButton = React.forwardRef<
27
27
  {...props}
28
28
  {...(props.disabled || props['data-disabled'] ? { ['data-disabled']: true } : {})}
29
29
  {...((isPressed && !props.disabled) || props['data-pressed'] ? { ['data-pressed']: true } : {})}
30
- aria-pressed={isPressed && !props.disabled}
30
+ aria-pressed={props['aria-pressed'] ?? (isPressed && !props.disabled)}
31
31
  ref={ref}
32
32
  />
33
33
  )
@@ -13,11 +13,11 @@ export type InlineAlertProps = {
13
13
  }
14
14
 
15
15
  const variantClasses = {
16
- info: 'bg-info-500/10 text-info-500 border-info-500/20',
16
+ info: 'bg-info-50 text-info-800 border-info-200',
17
17
  positive: 'bg-positive-50 text-positive-800 border-positive-200',
18
18
  notice: 'bg-notice-50 text-notice-800 border-notice-200',
19
19
  negative: 'bg-negative-50 text-negative-800 border-negative-200',
20
- neutral: 'bg-neutral-100 text-neutral-700 border-neutral-200'
20
+ neutral: 'bg-neutral-50 text-neutral-700 border-neutral-200'
21
21
  }
22
22
 
23
23
  const defaultIconNames = {
@@ -34,7 +34,7 @@ export function InlineAlert({ children, actions, variant, icon, className, title
34
34
  return (
35
35
  <div
36
36
  className={tw(
37
- 'rounded-md border px-4 py-3 flex gap-3 items-start justify-between text-left',
37
+ 'rounded-lg border px-4 py-3 flex gap-3 items-start justify-between text-left',
38
38
  variantClasses[variant],
39
39
  className
40
40
  )}
@@ -158,7 +158,7 @@ export const LabeledValue = React.forwardRef<HTMLDivElement, LabeledValueProps>(
158
158
  <LabeledValueLabel
159
159
  id={actualLabelId}
160
160
  className={tw(
161
- orientation === 'horizontal' ? 'flex-shrink-0' : 'mb-1',
161
+ orientation === 'horizontal' ? 'shrink-0' : 'mb-1',
162
162
  labelAlign === 'end' && 'text-right',
163
163
  labelClassName
164
164
  )}
@@ -474,7 +474,7 @@ export function LabeledValueLabel(props: React.ComponentProps<'label'>) {
474
474
  return (
475
475
  <label
476
476
  {...props}
477
- className={tw('block uppercase text-xs tracking-widest font-normal text-neutral-400', props.className)}
477
+ className={tw('block uppercase text-xs tracking-wider font-medium text-neutral-500', props.className)}
478
478
  />
479
479
  )
480
480
  }
@@ -7,6 +7,7 @@ import {
7
7
  Pressable,
8
8
  Separator as AriaSeparator
9
9
  } from 'react-aria-components'
10
+ import { Link, useHref, type RelativeRoutingType, type To } from 'react-router'
10
11
  import { AnimatedPopover } from './helpers/animated-popover'
11
12
  import { tw } from '../utils/tw'
12
13
 
@@ -18,20 +19,26 @@ export type MenuProps = {
18
19
  children?: React.ReactNode
19
20
  className?: string
20
21
  containerClassName?: string
21
- placement?: 'bottom' | 'bottom start' | 'bottom end' | 'top' | 'top start' | 'top end'
22
+ placement?: 'bottom' | 'bottom start' | 'bottom end' | 'top' | 'top start' | 'top end' | 'left' | 'right'
22
23
  }
23
24
 
24
25
  export type MenuItemProps = {
25
26
  children?: React.ReactNode
26
27
  className?: string
27
28
  isDisabled?: boolean
28
- onAction?: () => void
29
29
  id?: string
30
30
  textValue?: string
31
+ type?: 'button' | 'submit'
32
+ onAction?: () => void
33
+ onClick?: ((e: React.MouseEvent<Element & HTMLOrSVGElement, MouseEvent>) => void) | undefined
34
+ form?: string
35
+ name?: string
36
+ value?: string
31
37
  }
32
38
 
33
39
  export type MenuLinkItemProps = {
34
- href: string
40
+ to: To
41
+ relative?: RelativeRoutingType
35
42
  children?: React.ReactNode
36
43
  className?: string
37
44
  isDisabled?: boolean
@@ -45,6 +52,17 @@ export type MenuGroupProps = {
45
52
  children?: React.ReactNode
46
53
  }
47
54
 
55
+ // ── Helpers ──────────────────────────────────────────────────────────────────
56
+
57
+ function itemClassName({ isFocused, isDisabled }: { isFocused: boolean; isDisabled: boolean }, className?: string) {
58
+ return tw(
59
+ isFocused ? 'bg-neutral-50 text-neutral-900' : 'text-neutral-700',
60
+ 'group flex w-full items-center px-4 py-2 text-sm gap-2 outline-none',
61
+ isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
62
+ className
63
+ )
64
+ }
65
+
48
66
  // ── MenuMain ──────────────────────────────────────────────────────────────────
49
67
 
50
68
  function MenuMain({ Button: ButtonProp, children, className, containerClassName, placement = 'bottom end' }: MenuProps) {
@@ -67,7 +85,7 @@ function MenuMain({ Button: ButtonProp, children, className, containerClassName,
67
85
  <AnimatedPopover placement={placement} offset={8}>
68
86
  <AriaMenu
69
87
  className={tw(
70
- 'overflow-hidden min-w-56 w-max rounded bg-white py-1 shadow-lg ring-1 ring-black/5 focus:outline-none',
88
+ 'overflow-hidden min-w-56 w-max rounded-lg bg-white py-1 shadow-lg ring-1 ring-black/5 focus:outline-none',
71
89
  className
72
90
  )}
73
91
  autoFocus="first"
@@ -84,46 +102,62 @@ MenuMain.displayName = 'Menu'
84
102
 
85
103
  // ── MenuItem ──────────────────────────────────────────────────────────────────
86
104
 
87
- function Item({ children, className, isDisabled, onAction, id, textValue }: MenuItemProps) {
105
+ function Item({
106
+ children,
107
+ className,
108
+ isDisabled,
109
+ id,
110
+ textValue,
111
+ type,
112
+ onAction,
113
+ onClick,
114
+ form,
115
+ name,
116
+ value
117
+ }: MenuItemProps) {
118
+ const itemRef = React.useRef<HTMLDivElement>(null)
119
+ const isSubmit = type === 'submit'
120
+
88
121
  return (
89
122
  <AriaMenuItem
90
123
  id={id}
124
+ ref={isSubmit ? itemRef : undefined}
125
+ onClick={onClick}
91
126
  textValue={textValue ?? (typeof children === 'string' ? children : undefined)}
92
- onAction={onAction}
93
- isDisabled={isDisabled}
94
- className={({ isFocused, isDisabled }) =>
95
- tw(
96
- isFocused ? 'bg-neutral-100 text-neutral-900' : 'text-neutral-700',
97
- 'group flex w-full items-center px-4 py-2 text-sm outline-none',
98
- isDisabled ? 'cursor-not-allowed opacity-50 pointer-events-none' : 'cursor-pointer',
99
- className
100
- )
127
+ onAction={
128
+ isSubmit
129
+ ? () => {
130
+ onAction?.()
131
+ const targetForm = form
132
+ ? (document.getElementById(form) as HTMLFormElement | null)
133
+ : (itemRef.current?.closest('form') ?? null)
134
+ if (!targetForm) return
135
+ targetForm.requestSubmit()
136
+ }
137
+ : onAction
101
138
  }
139
+ isDisabled={isDisabled}
140
+ className={(renderProps) => itemClassName(renderProps, className)}
102
141
  >
103
142
  {children}
143
+ {isSubmit && <input type="submit" className="sr-only" name={name} value={value ?? ''} form={form} />}
104
144
  </AriaMenuItem>
105
145
  )
106
146
  }
107
147
 
108
148
  // ── LinkItem ──────────────────────────────────────────────────────────────────
109
149
 
110
- function LinkItem({ href, children, className, isDisabled, target, rel, id, textValue }: MenuLinkItemProps) {
150
+ function LinkItem({ to, children, className, relative, isDisabled, target, rel, id, textValue }: MenuLinkItemProps) {
111
151
  return (
112
152
  <AriaMenuItem
113
153
  id={id}
114
- textValue={textValue ?? (typeof children === 'string' ? children : undefined)}
115
- href={href}
154
+ href={useHref(to, { relative })}
116
155
  target={target}
117
156
  rel={rel}
157
+ textValue={textValue ?? (typeof children === 'string' ? children : undefined)}
118
158
  isDisabled={isDisabled}
119
- className={({ isFocused, isDisabled }) =>
120
- tw(
121
- isFocused ? 'bg-neutral-100 text-neutral-900' : 'text-neutral-700',
122
- 'group flex items-center px-4 py-2 text-sm gap-2 outline-none',
123
- isDisabled ? 'cursor-not-allowed opacity-50 pointer-events-none' : 'cursor-pointer',
124
- className
125
- )
126
- }
159
+ className={(renderProps) => itemClassName(renderProps, className)}
160
+ render={(props) => <Link {...(props as React.AnchorHTMLAttributes<HTMLAnchorElement>)} relative={relative} to={to} />}
127
161
  >
128
162
  {children}
129
163
  </AriaMenuItem>
@@ -139,7 +173,7 @@ function Group({ children }: MenuGroupProps) {
139
173
  // ── Separator ─────────────────────────────────────────────────────────────────
140
174
 
141
175
  function MenuSeparator() {
142
- return <AriaSeparator className="border-t border-neutral-100" />
176
+ return <AriaSeparator className="border-t border-neutral-950/5" />
143
177
  }
144
178
 
145
179
  // ── Export ─────────────────────────────────────────────────────────────────────
@@ -165,7 +165,8 @@ export function MonthDayInput(props: MonthDayInputProps) {
165
165
  function parseMonthDay(monthDay: Iso.MonthDay): CalendarDate | null {
166
166
  if (!monthDayFns.isValid(monthDay)) return null
167
167
  // Use a leap-year placeholder so Feb 29 is valid
168
- return new CalendarDate(1972, monthDayFns.getMonth(monthDay), monthDayFns.getDay(monthDay))
168
+ const { month, day } = monthDayFns.getFields(monthDay)
169
+ return new CalendarDate(1972, month, day)
169
170
  }
170
171
 
171
172
  function formatMonthDay(date: CalendarDate | null): Iso.MonthDay | null {
@@ -1,6 +1,5 @@
1
1
  import * as React from 'react'
2
2
  import { ListBox, ListBoxItem } from 'react-aria-components'
3
- import { AnimatedPopover } from './helpers/animated-popover'
4
3
  import type { Selection } from 'react-aria-components'
5
4
  import { HeadlessForm } from '@maestro-js/form'
6
5
  import type { ValidationFunction, ValidationResult } from '@maestro-js/form'
@@ -58,6 +57,7 @@ export type MultiselectProps<T extends MultiselectOption = MultiselectOption> =
58
57
  export function Multiselect<T extends MultiselectOption = MultiselectOption>(props: MultiselectProps<T>) {
59
58
  const hiddenInputRef = React.useRef<HTMLInputElement>(null)
60
59
  const buttonRef = React.useRef<HTMLButtonElement>(null)
60
+ const popoverContentRef = React.useRef<HTMLDivElement>(null)
61
61
  const [containerRef, setContainerRef] = React.useState<HTMLButtonElement | null>(null)
62
62
  const helpTextId = React.useId()
63
63
  const labelId = React.useId()
@@ -89,6 +89,19 @@ export function Multiselect<T extends MultiselectOption = MultiselectOption>(pro
89
89
  hiddenInputRef
90
90
  )
91
91
 
92
+ // Close dropdown on outside click
93
+ React.useEffect(() => {
94
+ if (!isOpen) return
95
+ const handlePointerDown = (e: PointerEvent) => {
96
+ const target = e.target as Node
97
+ if (popoverContentRef.current?.contains(target) || buttonRef.current?.contains(target)) return
98
+ setIsOpen(false)
99
+ commitValidation()
100
+ }
101
+ document.addEventListener('pointerdown', handlePointerDown)
102
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
103
+ })
104
+
92
105
  const isInvalid = props.isInvalid || validationInvalid
93
106
  const hasWarning = Boolean(props.warningMessage) && !isInvalid
94
107
 
@@ -235,82 +248,73 @@ export function Multiselect<T extends MultiselectOption = MultiselectOption>(pro
235
248
  </button>
236
249
 
237
250
  {/* Options dropdown */}
238
- <AnimatedPopover
239
- triggerRef={buttonRef}
240
- isNonModal
241
- isOpen={isOpen && !props.isDisabled}
242
- onOpenChange={(open) => {
243
- setIsOpen(open)
244
- if (!open) commitValidation()
245
- }}
246
- placement="bottom start"
247
- offset={4}
248
- style={{ width: containerSize.width, minWidth: containerSize.width }}
249
- >
250
- <div
251
- className={tw(
252
- 'overflow-hidden rounded bg-white border border-neutral-200 shadow-lg text-sm w-full focus:outline-none'
253
- )}
254
- >
255
- {props.showSelectAll && (
256
- <button
257
- type="button"
258
- onClick={handleSelectAll}
259
- className="w-full px-3 py-2 text-left hover:bg-neutral-50 border-b border-neutral-100 text-sm font-medium"
260
- >
261
- {selectedValues.length === props.options.filter((o) => !o.isDisabled).length ? 'Deselect All' : 'Select All'}
262
- </button>
263
- )}
264
- <ListBox
265
- aria-label={props.label ? String(props.label) : 'Multiselect'}
266
- selectionMode="multiple"
267
- selectedKeys={selectedKeys}
268
- onSelectionChange={handleSelectionChange}
269
- className="max-h-60 overflow-y-auto focus:outline-none"
270
- items={props.options.map((option, index) => ({ ...option, index, id: String(option.value) }))}
271
- renderEmptyState={() => <div className="px-3 py-2 text-neutral-500 text-sm">No options available</div>}
251
+ {isOpen && !props.isDisabled && (
252
+ <div
253
+ ref={popoverContentRef}
254
+ className={tw(
255
+ 'absolute z-50 mt-1 overflow-hidden rounded bg-white border border-neutral-200 shadow-lg text-sm w-full focus:outline-none'
256
+ )}
257
+ style={{ width: containerSize.width, minWidth: containerSize.width }}
258
+ >
259
+ {props.showSelectAll && (
260
+ <button
261
+ type="button"
262
+ onClick={handleSelectAll}
263
+ className="w-full px-3 py-2 text-left hover:bg-neutral-50 border-b border-neutral-100 text-sm font-medium"
272
264
  >
273
- {(item) => {
274
- const option = item as T & { index: number; id: string }
275
- const color = props.colored ? getColor(stringToNumber(String(option.value))) : null
276
-
277
- if (props.renderOption) {
278
- return (
279
- <ListBoxItem
280
- id={option.id}
281
- textValue={option.label}
282
- isDisabled={option.isDisabled}
283
- className="outline-none"
284
- >
285
- {({ isSelected }) => (
286
- <>
287
- {props.renderOption!({
288
- option,
289
- selected: isSelected,
290
- colored: props.colored ?? false,
291
- index: option.index
292
- })}
293
- </>
294
- )}
295
- </ListBoxItem>
296
- )
297
- }
265
+ {selectedValues.length === props.options.filter((o) => !o.isDisabled).length ? 'Deselect All' : 'Select All'}
266
+ </button>
267
+ )}
268
+ <ListBox
269
+ aria-label={props.label ? String(props.label) : 'Multiselect'}
270
+ selectionMode="multiple"
271
+ selectedKeys={selectedKeys}
272
+ onSelectionChange={handleSelectionChange}
273
+ className="max-h-60 overflow-y-auto focus:outline-none"
274
+ items={props.options.map((option, index) => ({ ...option, index, id: String(option.value) }))}
275
+ renderEmptyState={() => <div className="px-3 py-2 text-neutral-500 text-sm">No options available</div>}
276
+ >
277
+ {(item) => {
278
+ const option = item as T & { index: number; id: string }
279
+ const color = props.colored ? getColor(stringToNumber(String(option.value))) : null
298
280
 
281
+ if (props.renderOption) {
299
282
  return (
300
- <FormFieldComponents.DropdownItem
283
+ <ListBoxItem
301
284
  id={option.id}
302
285
  textValue={option.label}
303
- label={option.label}
304
- secondary={option.secondary}
305
286
  isDisabled={option.isDisabled}
306
- color={color}
307
- className="px-3"
308
- />
287
+ className="outline-none"
288
+ >
289
+ {({ isSelected }) => (
290
+ <>
291
+ {props.renderOption!({
292
+ option,
293
+ selected: isSelected,
294
+ colored: props.colored ?? false,
295
+ index: option.index
296
+ })}
297
+ </>
298
+ )}
299
+ </ListBoxItem>
309
300
  )
310
- }}
311
- </ListBox>
312
- </div>
313
- </AnimatedPopover>
301
+ }
302
+
303
+ return (
304
+ <FormFieldComponents.DropdownItem
305
+ id={option.id}
306
+ textValue={option.label}
307
+ label={option.label}
308
+ secondary={option.secondary}
309
+ isDisabled={option.isDisabled}
310
+ color={color}
311
+ className="px-3"
312
+ />
313
+ )
314
+ }}
315
+ </ListBox>
316
+ </div>
317
+ )}
314
318
 
315
319
  <FormFieldComponents.FormFieldHelpText
316
320
  id={helpTextId}
@@ -0,0 +1,15 @@
1
+ import { type LinkProps, Link } from 'react-router'
2
+ import React from 'react'
3
+
4
+ export type OptionalLinkProps = Omit<LinkProps, 'to'> & { to?: LinkProps['to']; disabled?: boolean }
5
+
6
+ export const OptionalLink = React.forwardRef<HTMLAnchorElement, OptionalLinkProps>(
7
+ ({ to, onClick, disabled, ...props }, ref) => {
8
+ if (to && !disabled) {
9
+ return <Link to={to} onClick={onClick} {...props} ref={ref} />
10
+ } else {
11
+ return <span {...props} ref={ref} />
12
+ }
13
+ }
14
+ )
15
+ OptionalLink.displayName = 'OptionalLink'
@@ -25,7 +25,7 @@ export function PlainRadio(props: PlainRadioProps) {
25
25
  value={props.value}
26
26
  isDisabled={props.isDisabled}
27
27
  aria-label={props['aria-label']}
28
- className="group inline-flex items-center cursor-pointer data-[disabled]:cursor-not-allowed focus:outline-none [&>div]:focus-visible:ring-2 [&>div]:focus-visible:ring-primary-500/20 [&>div]:focus-visible:ring-offset-2"
28
+ className="group inline-flex items-center cursor-pointer data-[disabled]:cursor-not-allowed focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600"
29
29
  >
30
30
  {({ isSelected, isDisabled }) => <RadioCircle isSelected={isSelected} isDisabled={isDisabled} />}
31
31
  </AriaRadio>
@@ -41,11 +41,11 @@ export function LabeledRadio(props: LabeledRadioProps) {
41
41
  <AriaRadio
42
42
  value={props.value}
43
43
  isDisabled={props.isDisabled}
44
- className="group flex items-start gap-3 cursor-pointer data-[disabled]:cursor-not-allowed select-none focus:outline-none [&>div:first-child>div]:focus-visible:ring-2 [&>div:first-child>div]:focus-visible:ring-primary-500/20 [&>div:first-child>div]:focus-visible:ring-offset-2"
44
+ className="group flex items-start gap-3 cursor-pointer data-[disabled]:cursor-not-allowed select-none focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600"
45
45
  >
46
46
  {({ isSelected, isDisabled }) => (
47
47
  <>
48
- <div className="flex-shrink-0">
48
+ <div className="shrink-0">
49
49
  <RadioCircle isSelected={isSelected} isDisabled={isDisabled} />
50
50
  </div>
51
51
  {props.label && (
@@ -74,16 +74,16 @@ function RadioCircle({ isSelected, isDisabled }: { isSelected: boolean; isDisabl
74
74
  return (
75
75
  <div
76
76
  className={tw(
77
- 'h-5 w-5 rounded-full border-2 transition-all duration-50 flex items-center justify-center',
78
- isSelected ? 'border-primary-500 bg-primary-500' : 'border-neutral-300 bg-white',
77
+ 'size-5 rounded-full border-2 transition-colors duration-200 flex items-center justify-center',
78
+ isSelected ? 'border-primary-600 bg-primary-600' : 'border-neutral-300 bg-white',
79
79
  !isDisabled && !isSelected && 'group-hovered:border-neutral-400',
80
- isDisabled && 'opacity-50 cursor-not-allowed'
80
+ isDisabled && 'cursor-not-allowed border-neutral-300 bg-neutral-100'
81
81
  )}
82
82
  >
83
83
  <div
84
84
  className={tw(
85
- 'h-2 w-2 rounded-full bg-white transition-all duration-50',
86
- isSelected ? 'scale-100 opacity-100' : 'scale-0 opacity-0'
85
+ 'size-2 rounded-full bg-white transition-transform duration-200',
86
+ isSelected ? 'scale-100' : 'scale-0'
87
87
  )}
88
88
  />
89
89
  </div>
@@ -114,18 +114,19 @@ export function Select<T extends SelectOption = SelectOption>(props: SelectProps
114
114
  <Button
115
115
  ref={buttonRef}
116
116
  className={tw(
117
- 'relative overflow-hidden border transition-all duration-200 max-w-96',
117
+ 'relative overflow-hidden ring-1 shadow-sm transition-all duration-200 max-w-96',
118
118
  props.shape === 'oval' ? 'rounded-full' : 'rounded',
119
- 'focus:outline-none focus:ring-1',
120
- (props.isDisabled || props.isReadonly) && 'border-neutral-100 bg-neutral-50 text-neutral-400 cursor-not-allowed',
121
- isInvalid && 'border-negative-200 bg-negative-50 focus:border-negative-500 focus:ring-negative-500/20',
122
- hasWarning && 'border-notice-200 bg-notice-50 focus:border-notice-500 focus:ring-notice-500/20',
119
+ 'focus:outline-2 focus:-outline-offset-1',
120
+ (props.isDisabled || props.isReadonly) &&
121
+ 'ring-neutral-200 bg-neutral-50 text-neutral-400 shadow-none cursor-not-allowed',
122
+ isInvalid && 'ring-negative-300 bg-negative-50 focus:outline-negative-500',
123
+ hasWarning && 'ring-notice-300 bg-notice-50 focus:outline-notice-500',
123
124
  !isInvalid &&
124
125
  !hasWarning &&
125
126
  !props.isDisabled &&
126
127
  !props.isReadonly &&
127
- 'border-neutral-200 hover:border-neutral-300 focus:border-primary-500 focus:ring-primary-500/20',
128
- 'w-full cursor-default py-2 pl-3 pr-10 text-left placeholder:text-neutral-400'
128
+ 'ring-black/10 hover:ring-black/20 focus:outline-primary-500',
129
+ 'w-full cursor-default py-2 pl-3 pr-10 text-left text-sm max-sm:text-base placeholder:text-neutral-400'
129
130
  )}
130
131
  aria-invalid={isInvalid || undefined}
131
132
  aria-describedby={helpTextId}
@@ -139,7 +140,7 @@ export function Select<T extends SelectOption = SelectOption>(props: SelectProps
139
140
  {selectedOption ? (
140
141
  <span className="block truncate text-sm min-h-5">{selectedOption.label}</span>
141
142
  ) : (
142
- <span className="block truncate text-sm min-h-5 text-neutral-300 italic">
143
+ <span className="block truncate text-sm min-h-5 text-neutral-400 italic">
143
144
  {props.placeholder ?? 'Select an option'}
144
145
  </span>
145
146
  )}
@@ -100,12 +100,12 @@ function Step({
100
100
  <div className={tw('flex flex-row items-start flex-1 relative', className)}>
101
101
  {index !== 0 ? (
102
102
  <div className="flex flex-1 items-center pt-4 w-1/2 absolute left-0">
103
- <div className={tw('h-[2px] w-full transition-colors', firstConnectorColor)} />
103
+ <div className={tw('h-0.5 w-full transition-colors', firstConnectorColor)} />
104
104
  </div>
105
105
  ) : null}
106
106
  {!isLast ? (
107
107
  <div className="flex flex-1 items-center pt-4 w-1/2 absolute right-0">
108
- <div className={tw('h-[2px] w-full transition-colors', lastConnectorColor)} />
108
+ <div className={tw('h-0.5 w-full transition-colors', lastConnectorColor)} />
109
109
  </div>
110
110
  ) : null}
111
111
  <div className="flex flex-col items-center w-full relative">{children}</div>
@@ -119,12 +119,12 @@ function Step({
119
119
  <div className={tw('relative flex flex-col min-h-10', className)}>
120
120
  {index !== 0 ? (
121
121
  <div className="flex flex-1 justify-center pl-4 h-1/2 absolute top-0">
122
- <div className={tw('w-[2px] h-full transition-colors', firstConnectorColor)} />
122
+ <div className={tw('w-0.5 h-full transition-colors', firstConnectorColor)} />
123
123
  </div>
124
124
  ) : null}
125
125
  {!isLast ? (
126
126
  <div className="flex flex-1 items-center pl-4 h-1/2 absolute bottom-0">
127
- <div className={tw('w-[2px] h-full transition-colors', lastConnectorColor)} />
127
+ <div className={tw('w-0.5 h-full transition-colors', lastConnectorColor)} />
128
128
  </div>
129
129
  ) : null}
130
130
  <div className="flex flex-row w-full relative">{children}</div>
@@ -154,11 +154,11 @@ function StepLabel({ children, className }: StepLabelProps) {
154
154
  return (
155
155
  <div className={tw('flex items-center', orientation === 'horizontal' ? 'flex-col text-center' : 'gap-3', className)}>
156
156
  <div
157
- className={tw('flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2 transition-colors', iconBg)}
157
+ className={tw('flex size-8 shrink-0 items-center justify-center rounded-full border-2 transition-colors', iconBg)}
158
158
  aria-current={active ? 'step' : undefined}
159
159
  >
160
160
  {completed ? (
161
- <Icon name="check" className="h-4 w-4" aria-hidden="true" />
161
+ <Icon name="check" className="size-4" aria-hidden="true" />
162
162
  ) : (
163
163
  <span className="text-sm font-semibold">{index + 1}</span>
164
164
  )}