@shadow-js/ui 0.0.0-stage → 1.0.1

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 (190) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +21 -0
  3. package/README.md +598 -2
  4. package/dist/cli.mjs +2281 -0
  5. package/dist/dev/cli/schema.d.ts +71 -0
  6. package/dist/dev/index.d.ts +6 -0
  7. package/dist/dev/index.js +5 -0
  8. package/dist/dev/locales/ar.d.ts +139 -0
  9. package/dist/dev/locales/ar.js +127 -0
  10. package/dist/dev/locales/ar.js.map +1 -0
  11. package/dist/dev/locales/en.d.ts +139 -0
  12. package/dist/dev/locales/en.js +127 -0
  13. package/dist/dev/locales/en.js.map +1 -0
  14. package/dist/dev/registry/utils/utils.d.ts +27 -0
  15. package/dist/dev/registry/utils/utils.js +26 -0
  16. package/dist/dev/registry/utils/utils.js.map +1 -0
  17. package/dist/dev/theme/theme-logic.d.ts +185 -0
  18. package/dist/dev/theme/theme-logic.js +183 -0
  19. package/dist/dev/theme/theme-logic.js.map +1 -0
  20. package/dist/dev/theme/theme-provider.d.ts +88 -0
  21. package/dist/dev/theme/theme-provider.js +149 -0
  22. package/dist/dev/theme/theme-provider.js.map +1 -0
  23. package/dist/prod/index.js +1 -0
  24. package/dist/prod/locales/ar.js +2 -0
  25. package/dist/prod/locales/ar.js.map +1 -0
  26. package/dist/prod/locales/en.js +2 -0
  27. package/dist/prod/locales/en.js.map +1 -0
  28. package/dist/prod/registry/utils/utils.js +2 -0
  29. package/dist/prod/registry/utils/utils.js.map +1 -0
  30. package/dist/prod/theme/theme-logic.js +2 -0
  31. package/dist/prod/theme/theme-logic.js.map +1 -0
  32. package/dist/prod/theme/theme-provider.js +2 -0
  33. package/dist/prod/theme/theme-provider.js.map +1 -0
  34. package/package.json +102 -4
  35. package/registry/accordion/accordion.tsx +210 -0
  36. package/registry/accordion/item.json +4 -0
  37. package/registry/alert/alert.tsx +200 -0
  38. package/registry/alert/item.json +4 -0
  39. package/registry/alert-dialog/alert-dialog.tsx +530 -0
  40. package/registry/alert-dialog/item.json +4 -0
  41. package/registry/aspect-ratio/aspect-ratio.tsx +71 -0
  42. package/registry/aspect-ratio/item.json +4 -0
  43. package/registry/attachment/attachment.tsx +459 -0
  44. package/registry/attachment/item.json +4 -0
  45. package/registry/avatar/avatar.tsx +385 -0
  46. package/registry/avatar/item.json +4 -0
  47. package/registry/badge/badge.tsx +108 -0
  48. package/registry/badge/item.json +4 -0
  49. package/registry/breadcrumb/breadcrumb.tsx +354 -0
  50. package/registry/breadcrumb/item.json +4 -0
  51. package/registry/bubble/bubble.tsx +260 -0
  52. package/registry/bubble/item.json +4 -0
  53. package/registry/button/button.tsx +125 -0
  54. package/registry/button/item.json +4 -0
  55. package/registry/button-group/button-group.tsx +189 -0
  56. package/registry/button-group/item.json +4 -0
  57. package/registry/calendar/calendar.tsx +348 -0
  58. package/registry/calendar/item.json +4 -0
  59. package/registry/card/card.tsx +311 -0
  60. package/registry/card/item.json +4 -0
  61. package/registry/carousel/carousel.tsx +851 -0
  62. package/registry/carousel/item.json +4 -0
  63. package/registry/chart/chart.tsx +1956 -0
  64. package/registry/chart/item.json +4 -0
  65. package/registry/chart-scale/chart-scale.ts +670 -0
  66. package/registry/chart-scale/item.json +5 -0
  67. package/registry/checkbox/checkbox.tsx +79 -0
  68. package/registry/checkbox/item.json +4 -0
  69. package/registry/code-block/code-block.tsx +470 -0
  70. package/registry/code-block/item.json +4 -0
  71. package/registry/collapsible/collapsible.tsx +117 -0
  72. package/registry/collapsible/item.json +4 -0
  73. package/registry/combobox/combobox.tsx +977 -0
  74. package/registry/combobox/item.json +4 -0
  75. package/registry/command/command.tsx +757 -0
  76. package/registry/command/item.json +4 -0
  77. package/registry/context-menu/context-menu.tsx +697 -0
  78. package/registry/context-menu/item.json +4 -0
  79. package/registry/data-table/data-table.tsx +437 -0
  80. package/registry/data-table/item.json +4 -0
  81. package/registry/date-picker/date-picker.tsx +349 -0
  82. package/registry/date-picker/item.json +4 -0
  83. package/registry/dialog/dialog.tsx +412 -0
  84. package/registry/dialog/item.json +4 -0
  85. package/registry/direction/direction.tsx +88 -0
  86. package/registry/direction/item.json +4 -0
  87. package/registry/drawer/drawer.tsx +787 -0
  88. package/registry/drawer/item.json +4 -0
  89. package/registry/dropdown-menu/dropdown-menu.tsx +692 -0
  90. package/registry/dropdown-menu/item.json +4 -0
  91. package/registry/empty/empty.tsx +240 -0
  92. package/registry/empty/item.json +4 -0
  93. package/registry/field/field.tsx +624 -0
  94. package/registry/field/item.json +4 -0
  95. package/registry/form/form.tsx +1112 -0
  96. package/registry/form/item.json +4 -0
  97. package/registry/hover-card/hover-card.tsx +144 -0
  98. package/registry/hover-card/item.json +4 -0
  99. package/registry/icons/icons.tsx +1691 -0
  100. package/registry/icons/item.json +4 -0
  101. package/registry/input/input.tsx +43 -0
  102. package/registry/input/item.json +4 -0
  103. package/registry/input-group/input-group.tsx +315 -0
  104. package/registry/input-group/item.json +4 -0
  105. package/registry/input-otp/input-otp.tsx +329 -0
  106. package/registry/input-otp/item.json +4 -0
  107. package/registry/item/item.json +4 -0
  108. package/registry/item/item.tsx +439 -0
  109. package/registry/kbd/item.json +4 -0
  110. package/registry/kbd/kbd.tsx +65 -0
  111. package/registry/label/item.json +4 -0
  112. package/registry/label/label.tsx +60 -0
  113. package/registry/marker/item.json +4 -0
  114. package/registry/marker/marker.tsx +175 -0
  115. package/registry/menubar/item.json +4 -0
  116. package/registry/menubar/menubar.tsx +694 -0
  117. package/registry/message/item.json +4 -0
  118. package/registry/message/message.tsx +280 -0
  119. package/registry/message-scroller/item.json +4 -0
  120. package/registry/message-scroller/message-scroller.tsx +262 -0
  121. package/registry/native-select/item.json +4 -0
  122. package/registry/native-select/native-select.tsx +113 -0
  123. package/registry/navigation-menu/item.json +4 -0
  124. package/registry/navigation-menu/navigation-menu.tsx +362 -0
  125. package/registry/pagination/item.json +4 -0
  126. package/registry/pagination/pagination.tsx +460 -0
  127. package/registry/password-input/item.json +5 -0
  128. package/registry/password-input/password-input.tsx +494 -0
  129. package/registry/popover/item.json +4 -0
  130. package/registry/popover/popover.tsx +284 -0
  131. package/registry/progress/item.json +4 -0
  132. package/registry/progress/progress.tsx +190 -0
  133. package/registry/questionnaire/item.json +4 -0
  134. package/registry/questionnaire/questionnaire.tsx +569 -0
  135. package/registry/radio-group/item.json +4 -0
  136. package/registry/radio-group/radio-group.tsx +101 -0
  137. package/registry/resizable/item.json +4 -0
  138. package/registry/resizable/resizable.tsx +455 -0
  139. package/registry/scroll-area/item.json +4 -0
  140. package/registry/scroll-area/scroll-area.tsx +149 -0
  141. package/registry/select/item.json +4 -0
  142. package/registry/select/select.tsx +571 -0
  143. package/registry/separator/item.json +4 -0
  144. package/registry/separator/separator.tsx +52 -0
  145. package/registry/sheet/item.json +4 -0
  146. package/registry/sheet/sheet.tsx +476 -0
  147. package/registry/sidebar/item.json +4 -0
  148. package/registry/sidebar/sidebar.tsx +1870 -0
  149. package/registry/skeleton/item.json +4 -0
  150. package/registry/skeleton/skeleton.tsx +47 -0
  151. package/registry/slider/item.json +4 -0
  152. package/registry/slider/slider.tsx +149 -0
  153. package/registry/spinner/item.json +4 -0
  154. package/registry/spinner/spinner.tsx +51 -0
  155. package/registry/switch/item.json +4 -0
  156. package/registry/switch/switch.tsx +93 -0
  157. package/registry/table/item.json +4 -0
  158. package/registry/table/table.tsx +373 -0
  159. package/registry/tabs/item.json +4 -0
  160. package/registry/tabs/tabs.tsx +209 -0
  161. package/registry/textarea/item.json +4 -0
  162. package/registry/textarea/textarea.tsx +51 -0
  163. package/registry/toast/item.json +4 -0
  164. package/registry/toast/toast.tsx +949 -0
  165. package/registry/toggle/item.json +4 -0
  166. package/registry/toggle/toggle.tsx +110 -0
  167. package/registry/toggle-group/item.json +4 -0
  168. package/registry/toggle-group/toggle-group.tsx +189 -0
  169. package/registry/tooltip/item.json +4 -0
  170. package/registry/tooltip/tooltip.tsx +169 -0
  171. package/registry/trace/item.json +4 -0
  172. package/registry/trace/trace.tsx +356 -0
  173. package/registry/ui-strings/item.json +5 -0
  174. package/registry/ui-strings/ui-strings.tsx +454 -0
  175. package/registry/use-highlighted-code/item.json +5 -0
  176. package/registry/use-highlighted-code/use-highlighted-code.tsx +145 -0
  177. package/registry/use-mobile/item.json +5 -0
  178. package/registry/use-mobile/use-mobile.ts +46 -0
  179. package/registry/utils/item.json +5 -0
  180. package/registry/utils/utils.ts +38 -0
  181. package/registry.json +3274 -0
  182. package/styles/animations.css +321 -0
  183. package/styles/theme-core.css +55 -0
  184. package/styles/theme.css +12 -0
  185. package/styles/themes/gray.css +69 -0
  186. package/styles/themes/neutral.css +69 -0
  187. package/styles/themes/slate.css +69 -0
  188. package/styles/themes/stone.css +69 -0
  189. package/styles/themes/zinc.css +69 -0
  190. package/styles/typeset.css +251 -0
@@ -0,0 +1,455 @@
1
+ import { onMount, splitProps, useSignal } from "@shadow-js/core";
2
+ import {
3
+ createPartContext,
4
+ type Direction,
5
+ useDirectionAround,
6
+ useRefSignal,
7
+ } from "@shadow-js/primitives";
8
+ import type { ComponentProps, JSX } from "@shadow-js/web";
9
+ import { GripVerticalIcon } from "@/components/ui/icons";
10
+ import { useUiStrings } from "@/lib/ui-strings";
11
+ import { cn } from "@/lib/utils";
12
+
13
+ /**
14
+ * Moves `delta` percent of the space from panel `index` to panel `index + 1` (or back, for a negative
15
+ * delta), keeping every panel within its bounds and the total unchanged. It is the arithmetic behind
16
+ * the handles, as a pure function.
17
+ *
18
+ * @param sizes The panel sizes, in percent.
19
+ * @param index The panel on the left (or top) of the handle.
20
+ * @param delta The movement in percent; positive moves the handle toward the second panel.
21
+ * @param min The smallest size of each panel.
22
+ * @param max The largest size of each panel.
23
+ * @returns The new sizes, as a new array.
24
+ * @example
25
+ * import { resizeSizes } from "@/components/ui/resizable";
26
+ *
27
+ * resizeSizes([50, 50], 0, 10, [0, 0], [100, 100]); // [60, 40]
28
+ * @since 1.0.0 * @see {@link ResizableHandle}
29
+ */
30
+ export function resizeSizes(
31
+ sizes: readonly number[],
32
+ index: number,
33
+ delta: number,
34
+ min: readonly number[],
35
+ max: readonly number[],
36
+ ): number[] {
37
+ const first = sizes[index];
38
+ const second = sizes[index + 1];
39
+ if (first === undefined || second === undefined) return [...sizes];
40
+ const firstMin = min[index] ?? 0;
41
+ const secondMin = min[index + 1] ?? 0;
42
+ const firstMax = max[index] ?? 100;
43
+ const secondMax = max[index + 1] ?? 100;
44
+ // The first panel can grow to its max or shrink to its min; the second the other way round.
45
+ const low = Math.max(firstMin - first, second - secondMax);
46
+ const high = Math.min(firstMax - first, second - secondMin);
47
+ const step = Math.min(high, Math.max(low, delta));
48
+ const next = [...sizes];
49
+ next[index] = first + step;
50
+ next[index + 1] = second - step;
51
+ return next;
52
+ }
53
+
54
+ /** What the group shares with its panels and handles. @internal */
55
+ interface ResizableContextValue {
56
+ readonly direction: () => "horizontal" | "vertical";
57
+ readonly dir: () => Direction;
58
+ readonly sizes: () => number[];
59
+ readonly min: () => number[];
60
+ readonly max: () => number[];
61
+ readonly claimPanel: (defaultSize: number, minSize: number, maxSize: number) => number;
62
+ readonly move: (index: number, delta: number) => void;
63
+ readonly set: (index: number, size: number) => void;
64
+ /** How many panels have rendered so far: a handle sits after panel `n - 1`, so its index is `n - 1`. */
65
+ readonly panelsClaimed: () => number;
66
+ readonly storageKey: () => string | undefined;
67
+ }
68
+
69
+ /**
70
+ * Reports a storage problem. The layout keeps working without storage; the warning tells the
71
+ * developer why the sizes are not remembered.
72
+ */
73
+ function reportStorage(action: string, error: unknown): void {
74
+ console.warn(`[shadow-ui] resizable: ${action} failed; the sizes are not remembered`, error);
75
+ }
76
+
77
+ const resizableContext = createPartContext<ResizableContextValue>("ResizablePanelGroup");
78
+
79
+ /**
80
+ * Props of {@link ResizablePanelGroup}: the `div` attributes plus the layout options below.
81
+ * @since 1.0.0
82
+ * @see {@link ResizablePanelGroup}
83
+ */
84
+ export interface ResizablePanelGroupProps extends ComponentProps<"div"> {
85
+ /**
86
+ * The direction of the layout: `horizontal` (panels side by side) or `vertical` (stacked).
87
+ * @default "horizontal"
88
+ */
89
+ readonly direction?: "horizontal" | "vertical" | undefined;
90
+ /**
91
+ * The shadcn name for `direction` (the v4 prop). When both are given, `direction` wins.
92
+ * @default "horizontal"
93
+ * @see {@link ResizablePanelGroupProps}
94
+ */
95
+ readonly orientation?: "horizontal" | "vertical" | undefined;
96
+ /**
97
+ * Remembers the sizes in `localStorage`, under the key `shadow-ui:resizable:<id>`, and restores
98
+ * them after the group mounts, so the server markup and the first client render agree.
99
+ * @default none (the sizes are not remembered)
100
+ */
101
+ readonly autoSaveId?: string | undefined;
102
+ /**
103
+ * Called with the new sizes (percent of the group) after every change the user makes, by
104
+ * dragging a handle or with its keys. It is not called when a remembered layout is restored.
105
+ * @default none
106
+ */
107
+ readonly onLayout?: ((sizes: number[]) => void) | undefined;
108
+ /**
109
+ * The reading direction. `"rtl"` mirrors a horizontal group, so the first panel is on the right
110
+ * and the handle keys swap.
111
+ * @default none (inherited from the nearest `DirectionProvider`, then the document, then `"ltr"`)
112
+ */
113
+ readonly dir?: Direction | undefined;
114
+ }
115
+
116
+ /**
117
+ * Panels with draggable handles between them. The group keeps the sizes (percent of the group);
118
+ * a handle moves space from one panel to the next, and no handle takes a panel below its `minSize`
119
+ * or above its `maxSize`.
120
+ *
121
+ * Keyboard and ARIA: the group is a plain `div` with no role and no keys of its own. Its handles
122
+ * are the focusable parts; see {@link ResizableHandle}.
123
+ *
124
+ * @param props See {@link ResizablePanelGroupProps}. `class` adds to the group's classes; the rest
125
+ * are the `div` attributes.
126
+ * @returns The group `div` with its panels and handles inside.
127
+ * @example
128
+ * import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
129
+ *
130
+ * <ResizablePanelGroup direction="horizontal" autoSaveId="editor">
131
+ * <ResizablePanel defaultSize={30} minSize={15}>Files</ResizablePanel>
132
+ * <ResizableHandle withHandle />
133
+ * <ResizablePanel defaultSize={70}>Editor</ResizablePanel>
134
+ * </ResizablePanelGroup>
135
+ * @since 1.0.0
136
+ * @see {@link ResizablePanel}
137
+ */
138
+ function ResizablePanelGroup(props: ResizablePanelGroupProps): JSX.Element {
139
+ const [local, rest] = splitProps(props, [
140
+ "class",
141
+ "direction",
142
+ "orientation",
143
+ "autoSaveId",
144
+ "onLayout",
145
+ "dir",
146
+ "children",
147
+ ]);
148
+ const direction = (): "horizontal" | "vertical" =>
149
+ local.direction ?? local.orientation ?? "horizontal";
150
+ // A dir attribute above the group applies when there is no prop: found after mount, when the
151
+ // group has ancestors.
152
+ const [groupElement, setGroupElement] = useRefSignal<HTMLElement>();
153
+ let groupNode: HTMLElement | undefined;
154
+ const dir = useDirectionAround(() => local.dir as Direction | undefined, groupElement);
155
+ // The panels register their defaults while they render (plain arrays, no signal writes during a
156
+ // render). The sizes signal starts empty and is filled by the first change or the restore on mount.
157
+ const defaults: number[] = [];
158
+ const minimums: number[] = [];
159
+ const maximums: number[] = [];
160
+ const [sizes, setSizes] = useSignal<number[]>([]);
161
+ const currentSizes = (): number[] => {
162
+ const current = sizes();
163
+ return current.length > 0 ? current : [...defaults];
164
+ };
165
+ let claimed = 0;
166
+ const storageKey = (): string | undefined =>
167
+ local.autoSaveId === undefined
168
+ ? undefined
169
+ : `shadow-ui:resizable:${local.autoSaveId as string}`;
170
+
171
+ const commit = (next: number[]): void => {
172
+ setSizes(next);
173
+ (local.onLayout as ((sizes: number[]) => void) | undefined)?.(next);
174
+ const key = storageKey();
175
+ if (key === undefined) return;
176
+ try {
177
+ localStorage.setItem(key, JSON.stringify(next));
178
+ } catch (error) {
179
+ // Storage can be full or blocked: the layout still works, it is just not remembered.
180
+ reportStorage("saving the layout", error);
181
+ }
182
+ };
183
+
184
+ // Restores the remembered sizes after mount, so the server and the first client render agree.
185
+ onMount(() => {
186
+ setGroupElement(groupNode);
187
+ const key = storageKey();
188
+ if (key === undefined) return;
189
+ try {
190
+ const saved: unknown = JSON.parse(localStorage.getItem(key) ?? "null");
191
+ if (
192
+ Array.isArray(saved) &&
193
+ saved.length === defaults.length &&
194
+ saved.every((value) => typeof value === "number")
195
+ ) {
196
+ setSizes(saved as number[]);
197
+ }
198
+ } catch (error) {
199
+ // A value that is not JSON is ignored and the defaults apply; the mismatch is reported.
200
+ reportStorage("reading the saved layout", error);
201
+ }
202
+ });
203
+
204
+ const context: ResizableContextValue = {
205
+ direction,
206
+ dir,
207
+ sizes: currentSizes,
208
+ min: () => minimums,
209
+ max: () => maximums,
210
+ claimPanel(defaultSize, minSize, maxSize) {
211
+ const index = claimed;
212
+ claimed += 1;
213
+ defaults.push(defaultSize);
214
+ minimums.push(minSize);
215
+ maximums.push(maxSize);
216
+ return index;
217
+ },
218
+ move(index, delta) {
219
+ commit(resizeSizes(currentSizes(), index, delta, minimums, maximums));
220
+ },
221
+ set(index, size) {
222
+ const current = currentSizes();
223
+ const delta = size - (current[index] ?? 0);
224
+ commit(resizeSizes(current, index, delta, minimums, maximums));
225
+ },
226
+ panelsClaimed: () => defaults.length,
227
+ storageKey,
228
+ };
229
+
230
+ return (
231
+ <resizableContext.Provider value={context}>
232
+ <div
233
+ ref={(node: HTMLElement) => {
234
+ groupNode = node;
235
+ }}
236
+ data-slot="resizable-panel-group"
237
+ data-panel-group-direction={direction()}
238
+ data-orientation={direction()}
239
+ dir={local.dir as Direction | undefined}
240
+ class={cn(
241
+ "flex h-full w-full",
242
+ direction() === "vertical" ? "flex-col" : "flex-row",
243
+ local.class,
244
+ )}
245
+ {...rest}
246
+ >
247
+ {local.children as JSX.Element}
248
+ </div>
249
+ </resizableContext.Provider>
250
+ );
251
+ }
252
+
253
+ /**
254
+ * Props of {@link ResizablePanel}: the `div` attributes plus the size bounds below.
255
+ * @since 1.0.0
256
+ * @see {@link ResizablePanel}
257
+ */
258
+ export interface ResizablePanelProps extends ComponentProps<"div"> {
259
+ /**
260
+ * The starting size, in percent of the group.
261
+ * @default 50
262
+ */
263
+ readonly defaultSize?: number | undefined;
264
+ /**
265
+ * The smallest size a handle can leave this panel, in percent of the group.
266
+ * @default 10
267
+ */
268
+ readonly minSize?: number | undefined;
269
+ /**
270
+ * The largest size a handle can give this panel, in percent of the group.
271
+ * @default 100
272
+ */
273
+ readonly maxSize?: number | undefined;
274
+ }
275
+
276
+ /**
277
+ * One panel of a {@link ResizablePanelGroup}. It takes its share of the group's space: `defaultSize`
278
+ * until a handle moves it, within `minSize` and `maxSize`.
279
+ *
280
+ * Keyboard and ARIA: a plain `div` with no role and no keys; its size is changed by the handles.
281
+ *
282
+ * @param props See {@link ResizablePanelProps}. `class` adds to the panel's classes; the rest are the
283
+ * `div` attributes.
284
+ * @returns The panel `div`.
285
+ * @throws Error when rendered outside a {@link ResizablePanelGroup}.
286
+ * @example
287
+ * import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
288
+ *
289
+ * <ResizablePanelGroup>
290
+ * <ResizablePanel defaultSize={30} minSize={15}>Files</ResizablePanel>
291
+ * </ResizablePanelGroup>
292
+ * @since 1.0.0
293
+ * @see {@link ResizableHandle}
294
+ */
295
+ function ResizablePanel(props: ResizablePanelProps): JSX.Element {
296
+ const group = resizableContext.use("ResizablePanel");
297
+ const [local, rest] = splitProps(props, [
298
+ "class",
299
+ "defaultSize",
300
+ "minSize",
301
+ "maxSize",
302
+ "children",
303
+ ]);
304
+ const index = group.claimPanel(
305
+ (local.defaultSize as number | undefined) ?? 50,
306
+ (local.minSize as number | undefined) ?? 10,
307
+ (local.maxSize as number | undefined) ?? 100,
308
+ );
309
+ return (
310
+ <div
311
+ data-slot="resizable-panel"
312
+ data-panel-index={index}
313
+ style={{ flex: `${group.sizes()[index] ?? 0} 1 0%`, minWidth: 0, minHeight: 0 }}
314
+ class={cn("min-h-0 min-w-0 overflow-hidden", local.class)}
315
+ {...rest}
316
+ >
317
+ {local.children as JSX.Element}
318
+ </div>
319
+ );
320
+ }
321
+
322
+ /**
323
+ * Props of {@link ResizableHandle}: the `div` attributes plus `withHandle`.
324
+ * @since 1.0.0
325
+ * @see {@link ResizableHandle}
326
+ */
327
+ export interface ResizableHandleProps extends ComponentProps<"div"> {
328
+ /**
329
+ * Shows a grip in the middle of the handle, so the bar reads as draggable.
330
+ * @default false
331
+ */
332
+ readonly withHandle?: boolean | undefined;
333
+ }
334
+
335
+ /**
336
+ * The bar between two panels. Drag it with the pointer. Focus it and use the arrow keys to move it
337
+ * 5 percent a step (mirrored in right-to-left layouts), or Home and End to make the first panel as
338
+ * small or as large as its bounds allow.
339
+ *
340
+ * Keyboard and ARIA: a focusable `role="separator"` (`tabindex="0"`). Its `aria-orientation` is the
341
+ * handle's own axis (`vertical` in a horizontal group). `aria-valuenow`, `aria-valuemin` and
342
+ * `aria-valuemax` give the first panel's size and bounds, rounded. It is named "Resize panels" unless
343
+ * you pass your own `aria-label`. Left and Right move a handle in a horizontal group; Up and Down
344
+ * move a handle in a vertical group. Home and End set the first panel to its smallest and largest.
345
+ *
346
+ * @param props See {@link ResizableHandleProps}. `class` adds to the handle's classes; the rest are
347
+ * the `div` attributes.
348
+ * @returns The handle `div`.
349
+ * @throws Error when rendered outside a {@link ResizablePanelGroup}.
350
+ * @example
351
+ * import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
352
+ *
353
+ * <ResizablePanelGroup>
354
+ * <ResizablePanel>Files</ResizablePanel>
355
+ * <ResizableHandle withHandle />
356
+ * <ResizablePanel>Editor</ResizablePanel>
357
+ * </ResizablePanelGroup>
358
+ * @since 1.0.0
359
+ * @see {@link ResizablePanelGroup}
360
+ */
361
+ function ResizableHandle(props: ResizableHandleProps): JSX.Element {
362
+ const group = resizableContext.use("ResizableHandle");
363
+ const t = useUiStrings();
364
+ const [local, rest] = splitProps(props, ["class", "withHandle"]);
365
+ // A handle sits right after the panel before it, so its index follows from the panels rendered.
366
+ const handleIndex = Math.max(0, group.panelsClaimed() - 1);
367
+ const step = 5;
368
+ const vertical = (): boolean => group.direction() === "vertical";
369
+ let stopDrag: (() => void) | undefined;
370
+
371
+ const startDrag = (event: PointerEvent): void => {
372
+ if (event.button !== 0) return;
373
+ stopDrag?.();
374
+ event.preventDefault();
375
+ const axis = vertical() ? "clientY" : "clientX";
376
+ const start = event[axis];
377
+ // The group is the handle's parent: measured from the event, so no reference is needed.
378
+ const box = (event.currentTarget as HTMLElement | null)?.parentElement?.getBoundingClientRect();
379
+ const length = box === undefined ? 0 : vertical() ? box.height : box.width;
380
+ const mirror = !vertical() && group.dir() === "rtl" ? -1 : 1;
381
+ const startSize = group.sizes()[handleIndex] ?? 0;
382
+ // Only the pointer that pressed the handle moves it; another finger is not part of this drag.
383
+ const pointer = event.pointerId;
384
+ const move = (moveEvent: PointerEvent): void => {
385
+ if (moveEvent.pointerId !== pointer || length <= 0) return;
386
+ const percent = ((moveEvent[axis] - start) / length) * 100 * mirror;
387
+ group.set(handleIndex, startSize + percent);
388
+ };
389
+ const end = (finished: PointerEvent): void => {
390
+ if (finished.pointerId === pointer) stopDrag?.();
391
+ };
392
+ window.addEventListener("pointermove", move);
393
+ window.addEventListener("pointerup", end);
394
+ window.addEventListener("pointercancel", end);
395
+ stopDrag = () => {
396
+ window.removeEventListener("pointermove", move);
397
+ window.removeEventListener("pointerup", end);
398
+ window.removeEventListener("pointercancel", end);
399
+ stopDrag = undefined;
400
+ };
401
+ };
402
+
403
+ return (
404
+ <div
405
+ role="separator"
406
+ tabindex={0}
407
+ aria-orientation={vertical() ? "horizontal" : "vertical"}
408
+ aria-valuenow={Math.round(group.sizes()[handleIndex] ?? 0)}
409
+ aria-valuemin={Math.round(group.min()[handleIndex] ?? 0)}
410
+ aria-valuemax={Math.round(group.max()[handleIndex] ?? 100)}
411
+ aria-label={t("resizable.handle", "Resize panels")}
412
+ data-slot="resizable-handle"
413
+ data-orientation={vertical() ? "vertical" : "horizontal"}
414
+ class={cn(
415
+ // touch-none: a touch drag moves the handle; the browser must not pan or scroll instead.
416
+ "relative flex touch-none items-center justify-center bg-border outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 forced-colors:bg-[CanvasText] focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-transparent",
417
+ vertical()
418
+ ? "h-px w-full after:absolute after:inset-x-0 after:top-1/2 after:h-1 after:-translate-y-1/2 cursor-row-resize"
419
+ : "h-full w-px after:absolute after:inset-y-0 after:start-1/2 after:w-1 after:-translate-x-1/2 cursor-col-resize",
420
+ local.class,
421
+ )}
422
+ onPointerDown={(event: PointerEvent) => startDrag(event)}
423
+ onKeyDown={(event: KeyboardEvent) => {
424
+ const forward = vertical()
425
+ ? "ArrowDown"
426
+ : group.dir() === "rtl"
427
+ ? "ArrowLeft"
428
+ : "ArrowRight";
429
+ const back = vertical() ? "ArrowUp" : group.dir() === "rtl" ? "ArrowRight" : "ArrowLeft";
430
+ if (event.key === forward) {
431
+ event.preventDefault();
432
+ group.move(handleIndex, step);
433
+ } else if (event.key === back) {
434
+ event.preventDefault();
435
+ group.move(handleIndex, -step);
436
+ } else if (event.key === "Home") {
437
+ event.preventDefault();
438
+ group.set(handleIndex, group.min()[handleIndex] ?? 0);
439
+ } else if (event.key === "End") {
440
+ event.preventDefault();
441
+ group.set(handleIndex, group.max()[handleIndex] ?? 100);
442
+ }
443
+ }}
444
+ {...rest}
445
+ >
446
+ {local.withHandle === true && (
447
+ <div class="z-10 flex h-4 w-3 items-center justify-center rounded-xs border bg-border">
448
+ <GripVerticalIcon class={cn("size-2.5", vertical() && "rotate-90")} />
449
+ </div>
450
+ )}
451
+ </div>
452
+ );
453
+ }
454
+
455
+ export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
@@ -0,0 +1,4 @@
1
+ {
2
+ "title": "Scroll Area",
3
+ "description": "A scrollable region with thin custom scrollbars, a draggable thumb and keyboard scrolling."
4
+ }
@@ -0,0 +1,149 @@
1
+ import { splitProps } from "@shadow-js/core";
2
+ import {
3
+ ScrollArea as ScrollAreaPrimitive,
4
+ type ScrollAreaProps as ScrollAreaPrimitiveProps,
5
+ type ScrollAreaScrollbarProps as ScrollAreaScrollbarPrimitiveProps,
6
+ } from "@shadow-js/primitives";
7
+ import type { JSX } from "@shadow-js/web";
8
+ import { cn } from "@/lib/utils";
9
+
10
+ /**
11
+ * A scrollable region with thin, styled scrollbars. The scrollbars show on hover by default; set
12
+ * `type` to change that (`"auto"`, `"scroll"` or `"always"`). The thumb can be dragged, and a press
13
+ * on the track outside the thumb pages the content.
14
+ *
15
+ * Keyboard and ARIA: the viewport is the tab stop while the content overflows, and the browser's
16
+ * native scroll keys (arrow keys, Page Up, Page Down, Home and End) move it. The bars and thumbs
17
+ * take no focus and have no key handling. The component sets no role or name; the bars are
18
+ * `role="presentation"`. If the scrolled content needs a name, pass a `role` and an `aria-label`
19
+ * to the root (for example `role="region"`).
20
+ *
21
+ * shadcn/ui differences: `class` replaces `className`, and the viewport, both scrollbars, their
22
+ * thumbs and the corner are rendered for you, each with its `data-slot`.
23
+ *
24
+ * @example
25
+ * ```tsx
26
+ * import { ScrollArea } from "@/components/ui/scroll-area";
27
+ *
28
+ * export function Notes() {
29
+ * return (
30
+ * <ScrollArea class="h-72 w-48 rounded-md border">
31
+ * <div class="p-4">Long content goes here.</div>
32
+ * </ScrollArea>
33
+ * );
34
+ * }
35
+ * ```
36
+ * @since 1.0.0
37
+ * @see {@link ScrollArea}
38
+ */
39
+ export type ScrollAreaProps = ScrollAreaPrimitiveProps;
40
+
41
+ /**
42
+ * The scroll area: a viewport that scrolls, two scrollbars and a corner, styled. Pass the content
43
+ * as children; the component adds the rest.
44
+ *
45
+ * @param props - The props, see {@link ScrollAreaProps}.
46
+ * @returns The root `div` with the viewport, the scrollbars and the corner inside.
47
+ * @example
48
+ * ```tsx
49
+ * import { ScrollArea } from "@/components/ui/scroll-area";
50
+ *
51
+ * export function Feed() {
52
+ * return (
53
+ * <ScrollArea type="always" class="h-40 w-64">
54
+ * <p>Scrollable text.</p>
55
+ * </ScrollArea>
56
+ * );
57
+ * }
58
+ * ```
59
+ * @since 1.0.0
60
+ * @see {@link ScrollBar}
61
+ */
62
+ function ScrollArea(props: ScrollAreaProps): JSX.Element {
63
+ const [local, rest] = splitProps(
64
+ props as ScrollAreaProps & { readonly class?: unknown; readonly children?: unknown },
65
+ ["class", "children"],
66
+ );
67
+ return (
68
+ <ScrollAreaPrimitive
69
+ data-slot="scroll-area"
70
+ class={cn("relative", local.class as string | undefined)}
71
+ {...rest}
72
+ >
73
+ <ScrollAreaPrimitive.Viewport
74
+ data-slot="scroll-area-viewport"
75
+ class="ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 motion-reduce:transition-none"
76
+ >
77
+ {local.children as JSX.Element}
78
+ </ScrollAreaPrimitive.Viewport>
79
+ <ScrollBar orientation="vertical" />
80
+ <ScrollBar orientation="horizontal" />
81
+ <ScrollAreaPrimitive.Corner data-slot="scroll-area-corner" />
82
+ </ScrollAreaPrimitive>
83
+ );
84
+ }
85
+
86
+ /**
87
+ * Props of {@link ScrollBar}: the primitive's scrollbar props (`orientation` and `forceMount`) and
88
+ * the attributes of the track `div`.
89
+ * @since 1.0.0
90
+ * @see {@link ScrollBar}
91
+ */
92
+ export type ScrollBarProps = ScrollAreaScrollbarPrimitiveProps;
93
+
94
+ /**
95
+ * One scrollbar of a scroll area: a track along the viewport's end edge (vertical) or bottom edge
96
+ * (horizontal), with its thumb. {@link ScrollArea} already renders one of each, so you rarely need
97
+ * this component. It is exported for areas built from `@shadow-js/primitives` directly. Its
98
+ * `orientation` defaults to `"vertical"`.
99
+ *
100
+ * Keyboard and ARIA: the track is pointer only and has `role="presentation"`; it is hidden while the
101
+ * root's `type` says it should not show.
102
+ *
103
+ * @param props - The props, see {@link ScrollBarProps}.
104
+ * @returns The track element, with its thumb inside.
105
+ * @example
106
+ * ```tsx
107
+ * import { ScrollArea as ScrollAreaPrimitive } from "@shadow-js/primitives";
108
+ * import { ScrollBar } from "@/components/ui/scroll-area";
109
+ *
110
+ * export function WideList() {
111
+ * return (
112
+ * <ScrollAreaPrimitive class="relative h-40 w-64">
113
+ * <ScrollAreaPrimitive.Viewport class="size-full">
114
+ * <p class="w-[400px]">Wide content.</p>
115
+ * </ScrollAreaPrimitive.Viewport>
116
+ * <ScrollBar orientation="horizontal" />
117
+ * </ScrollAreaPrimitive>
118
+ * );
119
+ * }
120
+ * ```
121
+ * @since 1.0.0
122
+ * @see {@link ScrollArea}
123
+ */
124
+ function ScrollBar(props: ScrollBarProps): JSX.Element {
125
+ const [local, rest] = splitProps(
126
+ props as ScrollBarProps & { readonly class?: unknown; readonly orientation?: unknown },
127
+ ["class", "orientation"],
128
+ );
129
+ return (
130
+ <ScrollAreaPrimitive.Scrollbar
131
+ data-slot="scroll-area-scrollbar"
132
+ orientation={(local.orientation as "vertical" | "horizontal" | undefined) ?? "vertical"}
133
+ class={cn(
134
+ "flex touch-none p-px transition-colors select-none",
135
+ "data-[orientation=vertical]:absolute data-[orientation=vertical]:inset-y-0 data-[orientation=vertical]:end-0 data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2.5 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent",
136
+ "data-[orientation=horizontal]:absolute data-[orientation=horizontal]:inset-x-0 data-[orientation=horizontal]:bottom-0 data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent",
137
+ local.class as string | undefined,
138
+ )}
139
+ {...rest}
140
+ >
141
+ <ScrollAreaPrimitive.Thumb
142
+ data-slot="scroll-area-thumb"
143
+ class="relative flex-1 rounded-full bg-border forced-colors:bg-[CanvasText] motion-reduce:transition-none"
144
+ />
145
+ </ScrollAreaPrimitive.Scrollbar>
146
+ );
147
+ }
148
+
149
+ export { ScrollArea, ScrollBar };
@@ -0,0 +1,4 @@
1
+ {
2
+ "title": "Select",
3
+ "description": "Displays a list of options for the user to pick from, triggered by a button."
4
+ }