@a4ui/core 0.37.0 → 0.38.0

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 (105) hide show
  1. package/README.md +8 -7
  2. package/dist/Collapse-Cr5cDqAl.js +375 -0
  3. package/dist/charts/AreaChart.d.ts +38 -0
  4. package/dist/charts/BarChart.d.ts +17 -1
  5. package/dist/charts/PieChart.d.ts +29 -0
  6. package/dist/charts/RadialBarChart.d.ts +33 -0
  7. package/dist/charts/index.d.ts +5 -0
  8. package/dist/charts/shared.d.ts +111 -0
  9. package/dist/charts.js +1493 -780
  10. package/dist/commerce.js +625 -634
  11. package/dist/elements.css +1076 -8
  12. package/dist/elements.iife.js +1 -6
  13. package/dist/elements.js +7774 -9133
  14. package/dist/full.css +1076 -8
  15. package/dist/index.d.ts +30 -3
  16. package/dist/index.js +17502 -15098
  17. package/dist/layout/Aurora.d.ts +16 -0
  18. package/dist/layout/EffectsToggle.d.ts +1 -1
  19. package/dist/layout/SpaceBackground.d.ts +1 -1
  20. package/dist/lib/anchored.d.ts +16 -0
  21. package/dist/motion-CTIBwMCA.js +104 -0
  22. package/dist/styles.css +110 -3
  23. package/dist/ui/AspectRatio.d.ts +19 -0
  24. package/dist/ui/Attachment.d.ts +70 -0
  25. package/dist/ui/ButtonGroup.d.ts +37 -0
  26. package/dist/ui/ChatMarker.d.ts +20 -0
  27. package/dist/ui/ChatThread.d.ts +32 -8
  28. package/dist/ui/ContactSection.d.ts +49 -0
  29. package/dist/ui/CtaBanner.d.ts +28 -0
  30. package/dist/ui/DateField.d.ts +2 -2
  31. package/dist/ui/Drawer.d.ts +5 -2
  32. package/dist/ui/Faq.d.ts +29 -0
  33. package/dist/ui/FeatureGrid.d.ts +35 -0
  34. package/dist/ui/Field.d.ts +148 -0
  35. package/dist/ui/Hero.d.ts +48 -0
  36. package/dist/ui/InputGroup.d.ts +93 -0
  37. package/dist/ui/Item.d.ts +63 -0
  38. package/dist/ui/Label.d.ts +23 -0
  39. package/dist/ui/Menubar.d.ts +83 -0
  40. package/dist/ui/Message.d.ts +51 -5
  41. package/dist/ui/Modal.d.ts +8 -2
  42. package/dist/ui/NativeSelect.d.ts +26 -0
  43. package/dist/ui/NavigationMenu.d.ts +85 -0
  44. package/dist/ui/PromptComposer.d.ts +5 -0
  45. package/dist/ui/Prose.d.ts +21 -0
  46. package/dist/ui/Questionnaire.d.ts +71 -0
  47. package/dist/ui/ScrollArea.d.ts +30 -0
  48. package/dist/ui/Sidebar.d.ts +229 -0
  49. package/dist/ui/SiteFooter.d.ts +39 -0
  50. package/dist/ui/SiteNav.d.ts +35 -0
  51. package/dist/ui/TeamGrid.d.ts +33 -0
  52. package/dist/ui/Testimonials.d.ts +37 -0
  53. package/dist/ui/internal/sectionKit.d.ts +22 -0
  54. package/dist/ui/internal/socialIcons.d.ts +9 -0
  55. package/package.json +38 -26
  56. package/preset.d.ts +23 -0
  57. package/src/charts/AreaChart.tsx +206 -0
  58. package/src/charts/BarChart.tsx +241 -5
  59. package/src/charts/DonutChart.tsx +8 -11
  60. package/src/charts/LineChart.tsx +2 -3
  61. package/src/charts/PieChart.tsx +145 -0
  62. package/src/charts/RadialBarChart.tsx +141 -0
  63. package/src/charts/index.ts +14 -0
  64. package/src/charts/shared.tsx +304 -0
  65. package/src/index.ts +192 -3
  66. package/src/layout/Aurora.tsx +36 -7
  67. package/src/layout/sceneEffects.ts +22 -3
  68. package/src/lib/anchored.ts +57 -0
  69. package/src/ui/AspectRatio.tsx +35 -0
  70. package/src/ui/Attachment.tsx +189 -0
  71. package/src/ui/ButtonGroup.tsx +89 -0
  72. package/src/ui/ChatMarker.tsx +58 -0
  73. package/src/ui/ChatThread.tsx +115 -23
  74. package/src/ui/ContactSection.tsx +199 -0
  75. package/src/ui/CtaBanner.tsx +62 -0
  76. package/src/ui/DateField.tsx +17 -7
  77. package/src/ui/DateRangePicker.tsx +15 -5
  78. package/src/ui/Drawer.tsx +37 -4
  79. package/src/ui/Faq.tsx +57 -0
  80. package/src/ui/FeatureGrid.tsx +77 -0
  81. package/src/ui/Field.tsx +374 -0
  82. package/src/ui/Hero.tsx +146 -0
  83. package/src/ui/InputGroup.tsx +219 -0
  84. package/src/ui/Item.tsx +185 -0
  85. package/src/ui/Label.tsx +50 -0
  86. package/src/ui/Menubar.tsx +212 -0
  87. package/src/ui/Message.tsx +139 -13
  88. package/src/ui/Modal.tsx +41 -5
  89. package/src/ui/MultiSelect.tsx +15 -5
  90. package/src/ui/NativeSelect.tsx +66 -0
  91. package/src/ui/NavigationMenu.tsx +262 -0
  92. package/src/ui/PromptComposer.tsx +11 -0
  93. package/src/ui/Prose.tsx +63 -0
  94. package/src/ui/Questionnaire.tsx +331 -0
  95. package/src/ui/ScrollArea.tsx +70 -0
  96. package/src/ui/Sidebar.tsx +753 -0
  97. package/src/ui/SiteFooter.tsx +112 -0
  98. package/src/ui/SiteNav.tsx +146 -0
  99. package/src/ui/TeamGrid.tsx +81 -0
  100. package/src/ui/Testimonials.tsx +103 -0
  101. package/src/ui/TimeField.tsx +15 -5
  102. package/src/ui/internal/sectionKit.tsx +72 -0
  103. package/src/ui/internal/socialIcons.tsx +41 -0
  104. package/dist/NumberInput-Bnh2u7pp.js +0 -381
  105. package/dist/motion-jYMWmZqB.js +0 -119
@@ -0,0 +1,212 @@
1
+ // Application menubar (File / Edit / View …) on Kobalte's Menubar primitive:
2
+ // roving focus across the top-level triggers, hover-to-switch once a menu is
3
+ // open, checkbox/radio items and nested submenus. Styled with the same glass
4
+ // overlay surface as the other menus.
5
+ import { Menubar as KMenubar } from '@kobalte/core/menubar'
6
+ import { Check, ChevronRight, Circle } from 'lucide-solid'
7
+ import type { JSX, ParentProps } from 'solid-js'
8
+ import { For, Show, splitProps } from 'solid-js'
9
+
10
+ import { cn } from '../lib/cn'
11
+ import { Kbd } from './Kbd'
12
+
13
+ const CONTENT =
14
+ 'bg-glass z-50 min-w-[12rem] overflow-hidden rounded-xl p-1 text-sm text-foreground shadow-xl outline-none'
15
+ const ITEM =
16
+ 'relative flex cursor-pointer select-none items-center gap-2 rounded-md py-1.5 pl-8 pr-2 outline-none transition-colors data-[highlighted]:bg-muted data-[expanded]:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus-visible:ring-2 focus-visible:ring-ring'
17
+
18
+ interface ClassProps extends ParentProps {
19
+ class?: string
20
+ }
21
+
22
+ /** Props of {@link Menubar}. */
23
+ export interface MenubarProps extends ClassProps {
24
+ /** Accessible name of the menubar. @default 'Application menu' */
25
+ label?: string
26
+ }
27
+
28
+ /**
29
+ * Horizontal bar of top-level menus. Arrow Left/Right moves between the
30
+ * triggers; once one menu is open, hovering another trigger switches to it.
31
+ *
32
+ * @example
33
+ * ```tsx
34
+ * <Menubar>
35
+ * <MenubarMenu label="File">
36
+ * <MenubarItem shortcut={['⌘', 'N']}>New project</MenubarItem>
37
+ * <MenubarSeparator />
38
+ * <MenubarItem>Quit</MenubarItem>
39
+ * </MenubarMenu>
40
+ * </Menubar>
41
+ * ```
42
+ */
43
+ export function Menubar(props: MenubarProps): JSX.Element {
44
+ return (
45
+ <KMenubar
46
+ aria-label={props.label ?? 'Application menu'}
47
+ class={cn(
48
+ 'inline-flex items-center gap-0.5 rounded-xl border border-border bg-card/60 p-1',
49
+ props.class,
50
+ )}
51
+ >
52
+ {props.children}
53
+ </KMenubar>
54
+ )
55
+ }
56
+
57
+ /** Props of {@link MenubarMenu}. */
58
+ export interface MenubarMenuProps extends ClassProps {
59
+ /** Text of the top-level trigger. */
60
+ label: string
61
+ /** Extra classes for the popover content. */
62
+ contentClass?: string
63
+ }
64
+
65
+ /** One top-level menu: a trigger button plus its popover of items. */
66
+ export function MenubarMenu(props: MenubarMenuProps): JSX.Element {
67
+ return (
68
+ <KMenubar.Menu>
69
+ <KMenubar.Trigger
70
+ class={cn(
71
+ 'rounded-lg px-3 py-1.5 text-sm font-medium text-foreground outline-none transition-colors hover:bg-muted data-[expanded]:bg-muted focus-visible:ring-2 focus-visible:ring-ring',
72
+ props.class,
73
+ )}
74
+ >
75
+ {props.label}
76
+ </KMenubar.Trigger>
77
+ <KMenubar.Portal>
78
+ <KMenubar.Content class={cn(CONTENT, props.contentClass)}>{props.children}</KMenubar.Content>
79
+ </KMenubar.Portal>
80
+ </KMenubar.Menu>
81
+ )
82
+ }
83
+
84
+ /** Props of {@link MenubarItem}. */
85
+ export interface MenubarItemProps extends ClassProps {
86
+ disabled?: boolean
87
+ /** Called when the item is chosen (click, Enter or Space). */
88
+ onSelect?: () => void
89
+ /** Keys shown right-aligned as keycaps, e.g. `['⌘', 'N']`. */
90
+ shortcut?: string[]
91
+ /** Styles the item as destructive (e.g. "Delete"). */
92
+ destructive?: boolean
93
+ }
94
+
95
+ function Shortcut(props: { keys?: string[] }): JSX.Element {
96
+ return (
97
+ <Show when={props.keys?.length}>
98
+ <span class="ml-auto flex gap-0.5 pl-6" aria-hidden="true">
99
+ <For each={props.keys}>{(k) => <Kbd class="min-w-[1.25rem] px-1 py-0 text-[11px]">{k}</Kbd>}</For>
100
+ </span>
101
+ </Show>
102
+ )
103
+ }
104
+
105
+ /** A plain action row, with an optional keyboard-shortcut hint. */
106
+ export function MenubarItem(props: MenubarItemProps): JSX.Element {
107
+ return (
108
+ <KMenubar.Item
109
+ class={cn(ITEM, props.destructive && 'text-destructive', props.class)}
110
+ disabled={props.disabled}
111
+ onSelect={() => props.onSelect?.()}
112
+ >
113
+ {props.children}
114
+ <Shortcut keys={props.shortcut} />
115
+ </KMenubar.Item>
116
+ )
117
+ }
118
+
119
+ /** Props of {@link MenubarCheckboxItem}. */
120
+ export interface MenubarCheckboxItemProps extends ClassProps {
121
+ checked?: boolean
122
+ onChange?: (checked: boolean) => void
123
+ disabled?: boolean
124
+ shortcut?: string[]
125
+ }
126
+
127
+ /** A toggle row exposing `role="menuitemcheckbox"` / `aria-checked`. */
128
+ export function MenubarCheckboxItem(props: MenubarCheckboxItemProps): JSX.Element {
129
+ return (
130
+ <KMenubar.CheckboxItem
131
+ class={cn(ITEM, props.class)}
132
+ checked={props.checked}
133
+ onChange={(v) => props.onChange?.(v)}
134
+ disabled={props.disabled}
135
+ >
136
+ <KMenubar.ItemIndicator class="absolute left-2 inline-flex items-center">
137
+ <Check class="h-3.5 w-3.5" />
138
+ </KMenubar.ItemIndicator>
139
+ {props.children}
140
+ <Shortcut keys={props.shortcut} />
141
+ </KMenubar.CheckboxItem>
142
+ )
143
+ }
144
+
145
+ /** Props of {@link MenubarRadioGroup}. */
146
+ export interface MenubarRadioGroupProps extends ParentProps {
147
+ value?: string
148
+ onChange?: (value: string) => void
149
+ }
150
+
151
+ /** Groups {@link MenubarRadioItem}s so exactly one is selected. */
152
+ export function MenubarRadioGroup(props: MenubarRadioGroupProps): JSX.Element {
153
+ return (
154
+ <KMenubar.RadioGroup value={props.value} onChange={(v) => props.onChange?.(v)}>
155
+ {props.children}
156
+ </KMenubar.RadioGroup>
157
+ )
158
+ }
159
+
160
+ /** Props of {@link MenubarRadioItem}. */
161
+ export interface MenubarRadioItemProps extends ClassProps {
162
+ value: string
163
+ disabled?: boolean
164
+ }
165
+
166
+ /** A single-choice row inside a {@link MenubarRadioGroup} (`role="menuitemradio"`). */
167
+ export function MenubarRadioItem(props: MenubarRadioItemProps): JSX.Element {
168
+ return (
169
+ <KMenubar.RadioItem class={cn(ITEM, props.class)} value={props.value} disabled={props.disabled}>
170
+ <KMenubar.ItemIndicator class="absolute left-2 inline-flex items-center">
171
+ <Circle class="h-2 w-2 fill-current" />
172
+ </KMenubar.ItemIndicator>
173
+ {props.children}
174
+ </KMenubar.RadioItem>
175
+ )
176
+ }
177
+
178
+ /** Props of {@link MenubarSub}. */
179
+ export interface MenubarSubProps extends ClassProps {
180
+ /** Text of the submenu trigger row. */
181
+ label: string
182
+ }
183
+
184
+ /** A nested submenu: opens with ArrowRight / hover, closes with ArrowLeft. */
185
+ export function MenubarSub(props: MenubarSubProps): JSX.Element {
186
+ return (
187
+ <KMenubar.Sub overlap gutter={4} shift={-5}>
188
+ <KMenubar.SubTrigger class={cn(ITEM, props.class)}>
189
+ {props.label}
190
+ <ChevronRight class="ml-auto h-3.5 w-3.5 text-muted-foreground" />
191
+ </KMenubar.SubTrigger>
192
+ <KMenubar.Portal>
193
+ <KMenubar.SubContent class={CONTENT}>{props.children}</KMenubar.SubContent>
194
+ </KMenubar.Portal>
195
+ </KMenubar.Sub>
196
+ )
197
+ }
198
+
199
+ /** Thin divider between groups of items. */
200
+ export function MenubarSeparator(props: { class?: string }): JSX.Element {
201
+ return <KMenubar.Separator class={cn('-mx-1 my-1 h-px border-0 bg-border', props.class)} />
202
+ }
203
+
204
+ /** Non-interactive section heading inside a menu. */
205
+ export function MenubarLabel(props: ClassProps): JSX.Element {
206
+ const [local] = splitProps(props, ['class', 'children'])
207
+ return (
208
+ <div class={cn('px-2 py-1.5 pl-8 text-xs font-semibold text-muted-foreground', local.class)}>
209
+ {local.children}
210
+ </div>
211
+ )
212
+ }
@@ -1,13 +1,21 @@
1
- // Message — one chat message row, full-width (no bubble). Rich content is
2
- // passed as children so the caller can render markdown, code blocks, lists,
1
+ // Message — one chat message row. By default full-width with no bubble; pass
2
+ // `variant` for a bubble style and `align` to push it to one side. Rich content
3
+ // is passed as children so the caller can render markdown, code blocks, lists,
3
4
  // etc. inside the readable prose block.
4
- import { type JSX, Match, Show, Switch } from 'solid-js'
5
+ import { createContext, type JSX, Match, Show, Switch, useContext } from 'solid-js'
5
6
 
6
7
  import { cn } from '../lib/cn'
7
8
 
8
9
  /** Who sent a {@link Message}; drives its subtle visual treatment. */
9
10
  export type ChatRole = 'user' | 'assistant' | 'system'
10
11
 
12
+ /** Bubble style of a {@link Message}. */
13
+ export type MessageVariant =
14
+ 'default' | 'secondary' | 'muted' | 'tinted' | 'outline' | 'ghost' | 'destructive'
15
+
16
+ /** Which side of the thread a message sits on. */
17
+ export type MessageAlign = 'start' | 'end'
18
+
11
19
  export interface MessageProps {
12
20
  role: ChatRole
13
21
  children: JSX.Element
@@ -17,40 +25,85 @@ export interface MessageProps {
17
25
  avatar?: JSX.Element
18
26
  /** Preformatted timestamp string, e.g. `'2:45 PM'`. */
19
27
  timestamp?: string
28
+ /** Stable id, rendered as `data-message-id` so `ChatThread.scrollToMessage` can find it. */
29
+ id?: string
30
+ /**
31
+ * Bubble style. When omitted the legacy role-based treatment applies
32
+ * (`assistant` faint panel, `user` faint tint).
33
+ */
34
+ variant?: MessageVariant
35
+ /** Push the message to one side and shrink it to its content (max 85% wide). */
36
+ align?: MessageAlign
37
+ /** Slot under the content, e.g. a row of reaction chips. */
38
+ reactions?: JSX.Element
39
+ /** Slot under the content for `Attachment`s (wrap several in `AttachmentGroup`). */
40
+ attachments?: JSX.Element
20
41
  class?: string
21
42
  }
22
43
 
44
+ const VARIANT: Record<MessageVariant, string> = {
45
+ default: 'border border-border bg-card text-card-foreground',
46
+ secondary: 'bg-secondary text-secondary-foreground',
47
+ muted: 'bg-muted/60 text-foreground',
48
+ tinted: 'border border-primary/20 bg-primary/10 text-foreground',
49
+ outline: 'border border-border bg-transparent text-foreground',
50
+ ghost: 'bg-transparent text-foreground',
51
+ destructive: 'border border-destructive/30 bg-destructive/10 text-destructive',
52
+ }
53
+
54
+ interface MessageGroupContext {
55
+ align?: MessageAlign
56
+ variant?: MessageVariant
57
+ }
58
+
59
+ const GroupContext = createContext<MessageGroupContext>()
60
+
23
61
  /**
24
- * Full-width chat message row with no bubble: an optional avatar/author
25
- * header line followed by a prose content block. Role is differentiated with
26
- * subtle, token-based treatment only — `assistant` gets a faint muted panel,
27
- * `user` stays plain, `system` is a small centered note.
62
+ * One chat message: an optional avatar/author header followed by a prose
63
+ * content block, with optional `reactions` and `attachments` slots. Role is
64
+ * differentiated with subtle, token-based treatment — or choose a bubble
65
+ * `variant` and an `align` side. Inside a {@link MessageGroup} the header is
66
+ * suppressed (the group shows the sender once).
28
67
  *
29
68
  * @example
30
69
  * ```tsx
31
- * <Message role="assistant" author="Nova" timestamp="2:45 PM">
70
+ * <Message role="assistant" author="Nova" timestamp="2:45 PM" variant="secondary" align="start">
32
71
  * <p>Here's the summary you asked for.</p>
33
72
  * </Message>
34
73
  * ```
35
74
  */
36
75
  export function Message(props: MessageProps): JSX.Element {
76
+ const group = useContext(GroupContext)
77
+ const variant = (): MessageVariant | undefined => props.variant ?? group?.variant
78
+ const align = (): MessageAlign | undefined => props.align ?? group?.align
37
79
  return (
38
80
  <Switch>
39
81
  <Match when={props.role === 'system'}>
40
- <div class={cn('text-center text-xs text-muted-foreground italic', props.class)}>
82
+ <div
83
+ data-message-id={props.id}
84
+ class={cn('text-center text-xs text-muted-foreground italic', props.class)}
85
+ >
41
86
  {props.children}
42
87
  </div>
43
88
  </Match>
44
89
  <Match when={true}>
45
90
  <div
91
+ data-message-id={props.id}
92
+ data-variant={variant()}
93
+ data-align={align()}
46
94
  class={cn(
47
95
  'flex flex-col gap-1',
48
- props.role === 'assistant' && 'rounded-xl bg-muted/40 p-3',
49
- props.role === 'user' && 'rounded-xl bg-primary/5 p-3',
96
+ variant() ? 'rounded-2xl px-3.5 py-2.5' : 'rounded-xl',
97
+ variant() ? VARIANT[variant()!] : 'text-foreground',
98
+ !variant() && props.role === 'assistant' && 'bg-muted/40 p-3',
99
+ !variant() && props.role === 'user' && 'bg-primary/5 p-3',
100
+ align() && 'w-fit max-w-[85%]',
101
+ align() === 'end' && 'self-end',
102
+ align() === 'start' && 'self-start',
50
103
  props.class,
51
104
  )}
52
105
  >
53
- <Show when={props.author || props.avatar || props.timestamp}>
106
+ <Show when={!group && (props.author || props.avatar || props.timestamp)}>
54
107
  <div class="flex items-center gap-2">
55
108
  {props.avatar}
56
109
  <Show when={props.author}>
@@ -61,9 +114,82 @@ export function Message(props: MessageProps): JSX.Element {
61
114
  </Show>
62
115
  </div>
63
116
  </Show>
64
- <div class="space-y-2 text-sm leading-relaxed text-foreground">{props.children}</div>
117
+ <div class="space-y-2 text-sm leading-relaxed">{props.children}</div>
118
+ <Show when={props.attachments}>
119
+ <div data-slot="message-attachments" class="mt-1 max-w-full">
120
+ {props.attachments}
121
+ </div>
122
+ </Show>
123
+ <Show when={props.reactions}>
124
+ <div data-slot="message-reactions" class="mt-1 flex flex-wrap items-center gap-1">
125
+ {props.reactions}
126
+ </div>
127
+ </Show>
65
128
  </div>
66
129
  </Match>
67
130
  </Switch>
68
131
  )
69
132
  }
133
+
134
+ export interface MessageGroupProps {
135
+ /** Sender name, shown once above the group. */
136
+ author?: string
137
+ /** Sender avatar, shown once next to the name. */
138
+ avatar?: JSX.Element
139
+ /** Timestamp of the first message, shown once. */
140
+ timestamp?: string
141
+ /** Side for every message in the group (a message can still override it). */
142
+ align?: MessageAlign
143
+ /** Bubble style for every message in the group (a message can still override it). */
144
+ variant?: MessageVariant
145
+ /** The consecutive `Message`s from this sender. */
146
+ children: JSX.Element
147
+ class?: string
148
+ }
149
+
150
+ /**
151
+ * Visually groups consecutive messages from one sender: avatar, name and time
152
+ * appear once, and the bubbles sit closer together than separate messages.
153
+ *
154
+ * @example
155
+ * ```tsx
156
+ * <MessageGroup author="Nova" avatar={<Avatar name="Nova" size="sm" />} variant="secondary" align="start">
157
+ * <Message role="assistant">First thought.</Message>
158
+ * <Message role="assistant">And a follow-up.</Message>
159
+ * </MessageGroup>
160
+ * ```
161
+ */
162
+ export function MessageGroup(props: MessageGroupProps): JSX.Element {
163
+ return (
164
+ <GroupContext.Provider
165
+ value={{
166
+ get align() {
167
+ return props.align
168
+ },
169
+ get variant() {
170
+ return props.variant
171
+ },
172
+ }}
173
+ >
174
+ <div
175
+ role="group"
176
+ aria-label={props.author ? `Messages from ${props.author}` : undefined}
177
+ data-slot="message-group"
178
+ class={cn('flex flex-col gap-1', props.class)}
179
+ >
180
+ <Show when={props.author || props.avatar || props.timestamp}>
181
+ <div class={cn('flex items-center gap-2', props.align === 'end' && 'flex-row-reverse')}>
182
+ {props.avatar}
183
+ <Show when={props.author}>
184
+ <span class="text-sm font-medium text-foreground">{props.author}</span>
185
+ </Show>
186
+ <Show when={props.timestamp}>
187
+ <span class="text-xs text-muted-foreground">{props.timestamp}</span>
188
+ </Show>
189
+ </div>
190
+ </Show>
191
+ {props.children}
192
+ </div>
193
+ </GroupContext.Provider>
194
+ )
195
+ }
package/src/ui/Modal.tsx CHANGED
@@ -12,7 +12,7 @@ import { Show } from 'solid-js'
12
12
 
13
13
  import { cn } from '../lib/cn'
14
14
 
15
- interface ModalProps extends ParentProps {
15
+ export interface ModalProps extends ParentProps {
16
16
  open: boolean
17
17
  onOpenChange: (open: boolean) => void
18
18
  /** Header title; omitting it hides the whole header bar. */
@@ -24,11 +24,41 @@ interface ModalProps extends ParentProps {
24
24
  * @default 'drawer'
25
25
  */
26
26
  variant?: 'drawer' | 'center'
27
+ /**
28
+ * Width of the dialog: `'sm'` 24rem, `'md'` 32rem, `'lg'` 42rem, `'xl'` 56rem,
29
+ * `'full'` the whole viewport minus margin. Omitted keeps the historical width
30
+ * (`variant="center"`: 32rem, `variant="drawer"`: 520px). Content taller than
31
+ * the viewport scrolls inside the dialog; the header stays pinned.
32
+ */
33
+ size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
27
34
  class?: string
28
35
  /** Accessible label for the close button. @default 'Close' */
29
36
  closeLabel?: string
30
37
  }
31
38
 
39
+ const CENTER_WIDTH = {
40
+ sm: 'max-w-sm',
41
+ md: 'max-w-lg',
42
+ lg: 'max-w-2xl',
43
+ xl: 'max-w-4xl',
44
+ full: 'max-w-full',
45
+ } as const
46
+ const DRAWER_WIDTH = {
47
+ sm: 'max-w-sm',
48
+ md: 'max-w-[520px]',
49
+ lg: 'max-w-2xl',
50
+ xl: 'max-w-4xl',
51
+ full: 'max-w-full',
52
+ } as const
53
+
54
+ // Popovers inside the dialog (MultiSelect, ...) are portaled to <body>, so
55
+ // Kobalte sees a click on them as "outside" and would close the dialog. Those
56
+ // panels carry `data-a4-floating`; interacting with one is not a dismissal.
57
+ function keepForFloating(e: Event): void {
58
+ const target = (e as CustomEvent<{ originalEvent?: Event }>).detail?.originalEvent?.target ?? e.target
59
+ if (target instanceof Element && target.closest('[data-a4-floating]')) e.preventDefault()
60
+ }
61
+
32
62
  /**
33
63
  * Dialog on Kobalte's `Dialog` primitive (focus trap, portal, presence handled
34
64
  * for you), rendered as either a slide-over drawer or a centered modal depending
@@ -53,8 +83,10 @@ export function Modal(props: ModalProps): JSX.Element {
53
83
  fallback={
54
84
  <div class="fixed inset-0 z-50 flex justify-end">
55
85
  <Dialog.Content
86
+ onInteractOutside={keepForFloating}
56
87
  class={cn(
57
- 'drawer-content bg-glass flex h-full w-full max-w-[520px] flex-col overflow-y-auto border-l border-border text-card-foreground shadow-2xl',
88
+ 'drawer-content bg-glass flex h-full w-full flex-col overflow-y-auto border-l border-border text-card-foreground shadow-2xl',
89
+ DRAWER_WIDTH[props.size ?? 'md'],
58
90
  props.class,
59
91
  )}
60
92
  >
@@ -78,13 +110,15 @@ export function Modal(props: ModalProps): JSX.Element {
78
110
  >
79
111
  <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
80
112
  <Dialog.Content
113
+ onInteractOutside={keepForFloating}
81
114
  class={cn(
82
- 'modal-content w-full max-w-lg rounded-xl border border-border bg-card text-card-foreground shadow-sm',
115
+ 'modal-content flex max-h-[calc(100dvh-2rem)] w-full flex-col rounded-xl border border-border bg-card text-card-foreground shadow-sm',
116
+ CENTER_WIDTH[props.size ?? 'md'],
83
117
  props.class,
84
118
  )}
85
119
  >
86
120
  <Show when={props.title}>
87
- <div class="flex items-center justify-between border-b border-border p-6 pb-4">
121
+ <div class="flex shrink-0 items-center justify-between border-b border-border p-6 pb-4">
88
122
  <Dialog.Title class="text-lg font-semibold leading-none tracking-tight">
89
123
  {props.title}
90
124
  </Dialog.Title>
@@ -96,7 +130,9 @@ export function Modal(props: ModalProps): JSX.Element {
96
130
  </Dialog.CloseButton>
97
131
  </div>
98
132
  </Show>
99
- <div class="p-6 pt-4">{props.children}</div>
133
+ <div data-modal-body class="min-h-0 flex-1 overflow-y-auto overscroll-contain p-6 pt-4">
134
+ {props.children}
135
+ </div>
100
136
  </Dialog.Content>
101
137
  </div>
102
138
  </Show>
@@ -8,6 +8,7 @@ import type { JSX } from 'solid-js'
8
8
  import { For, Show, createEffect, createMemo, createSignal, onCleanup } from 'solid-js'
9
9
  import { Portal } from 'solid-js/web'
10
10
 
11
+ import { createAnchorReflow } from '../lib/anchored'
11
12
  import { cn } from '../lib/cn'
12
13
 
13
14
  /** One selectable option: a stable `value` and its display `label`. */
@@ -89,17 +90,25 @@ export function MultiSelect(props: MultiSelectProps): JSX.Element {
89
90
 
90
91
  // Wire/tear-down the global listeners strictly around the open state. Scroll/
91
92
  // resize close the popover (its fixed position would otherwise drift).
93
+ // Follow the trigger rather than closing. The panel is `fixed` at the
94
+ // trigger's rect, so a scroll makes that rect stale — it does not make the
95
+ // panel unwanted. Closing on any captured scroll made this unusable on a
96
+ // scrolling page: clicking a trigger below the fold scrolls it into view,
97
+ // and that scroll arrived right after the panel opened and shut it in the
98
+ // same gesture. See `createAnchorReflow`.
99
+ const reflow = createAnchorReflow({ anchor: () => btnRef, place, close })
100
+
92
101
  const toggleListeners = (isOpen: boolean) => {
93
102
  if (isOpen) {
94
103
  document.addEventListener('mousedown', onDocPointer)
95
104
  document.addEventListener('keydown', onKey)
96
- window.addEventListener('scroll', close, true)
97
- window.addEventListener('resize', close)
105
+ window.addEventListener('scroll', reflow, true)
106
+ window.addEventListener('resize', reflow)
98
107
  } else {
99
108
  document.removeEventListener('mousedown', onDocPointer)
100
109
  document.removeEventListener('keydown', onKey)
101
- window.removeEventListener('scroll', close, true)
102
- window.removeEventListener('resize', close)
110
+ window.removeEventListener('scroll', reflow, true)
111
+ window.removeEventListener('resize', reflow)
103
112
  }
104
113
  }
105
114
  // Reactively attach when open flips; always clean up on unmount.
@@ -179,7 +188,8 @@ export function MultiSelect(props: MultiSelectProps): JSX.Element {
179
188
  left: `${pos().left}px`,
180
189
  width: `${pos().width}px`,
181
190
  }}
182
- class="z-50 min-w-56 rounded-lg border border-border bg-card p-1 text-card-foreground shadow-lg"
191
+ data-a4-floating
192
+ class="pointer-events-auto z-50 min-w-56 rounded-lg border border-border bg-card p-1 text-card-foreground shadow-lg"
183
193
  >
184
194
  <Show when={props.searchable ?? true}>
185
195
  <input
@@ -0,0 +1,66 @@
1
+ import { ChevronDown } from 'lucide-solid'
2
+ import { createEffect, splitProps, type JSX } from 'solid-js'
3
+
4
+ import { cn } from '../lib/cn'
5
+
6
+ export type NativeSelectSize = 'sm' | 'default'
7
+
8
+ interface NativeSelectProps extends Omit<JSX.SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
9
+ /** @default 'default' */
10
+ size?: NativeSelectSize
11
+ /** Marks the field invalid (destructive border + `aria-invalid`). */
12
+ invalid?: boolean
13
+ /** Class for the `<select>`; use `wrapperClass` for the outer box. */
14
+ class?: string
15
+ wrapperClass?: string
16
+ }
17
+
18
+ export type { NativeSelectProps }
19
+
20
+ const SIZE: Record<NativeSelectSize, string> = {
21
+ default: 'py-2 text-sm',
22
+ sm: 'py-1 text-xs',
23
+ }
24
+
25
+ /**
26
+ * Styled native `<select>` — gets the OS picker on mobile. Takes `<option>` and
27
+ * `<optgroup>` children; `value`, `onChange` and every other select attribute
28
+ * pass straight through.
29
+ *
30
+ * @example
31
+ * ```tsx
32
+ * <NativeSelect value={plan()} onChange={(e) => setPlan(e.currentTarget.value)} aria-label="Plan">
33
+ * <option value="free">Free</option>
34
+ * <option value="pro">Pro</option>
35
+ * </NativeSelect>
36
+ * ```
37
+ */
38
+ export function NativeSelect(props: NativeSelectProps): JSX.Element {
39
+ const [local, rest] = splitProps(props, ['size', 'invalid', 'class', 'wrapperClass', 'children', 'value'])
40
+ let select: HTMLSelectElement | undefined
41
+ // Set after mount: a `value` attribute applied before the <option> children exist is ignored.
42
+ createEffect(() => {
43
+ if (select && local.value !== undefined) select.value = String(local.value)
44
+ })
45
+ return (
46
+ <div class={cn('relative w-full', local.wrapperClass)}>
47
+ <select
48
+ ref={select}
49
+ aria-invalid={local.invalid ? 'true' : undefined}
50
+ class={cn(
51
+ 'peer w-full appearance-none rounded-md border bg-background pl-3 pr-9 text-foreground outline-none transition-colors a4-field disabled:cursor-not-allowed disabled:opacity-50',
52
+ local.invalid ? 'border-destructive' : 'border-input',
53
+ SIZE[local.size ?? 'default'],
54
+ local.class,
55
+ )}
56
+ {...rest}
57
+ >
58
+ {local.children}
59
+ </select>
60
+ <ChevronDown
61
+ aria-hidden="true"
62
+ class="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground peer-disabled:opacity-50"
63
+ />
64
+ </div>
65
+ )
66
+ }