@milktop/react-ui 0.0.0-stage → 0.1.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 (250) hide show
  1. package/README.md +173 -2
  2. package/dist/accordion/Accordion.d.ts +65 -0
  3. package/dist/accordion/Accordion.js +61 -0
  4. package/dist/accordion/accordion.css +17 -0
  5. package/dist/action-bar/ActionBar.d.ts +33 -0
  6. package/dist/action-bar/ActionBar.js +48 -0
  7. package/dist/action-bar/action-bar.css +22 -0
  8. package/dist/alert/Alert.d.ts +28 -0
  9. package/dist/alert/Alert.js +12 -0
  10. package/dist/alert/alert.css +26 -0
  11. package/dist/app-shell/AppShell.d.ts +38 -0
  12. package/dist/app-shell/AppShell.js +92 -0
  13. package/dist/app-shell/Topbar.d.ts +20 -0
  14. package/dist/app-shell/Topbar.js +13 -0
  15. package/dist/app-shell/app-shell.css +41 -0
  16. package/dist/app-shell/shell.d.ts +9 -0
  17. package/dist/app-shell/shell.js +3 -0
  18. package/dist/attachments/Attachments.d.ts +84 -0
  19. package/dist/attachments/Attachments.js +235 -0
  20. package/dist/attachments/attachments.css +86 -0
  21. package/dist/avatar/Avatar.d.ts +31 -0
  22. package/dist/avatar/Avatar.js +41 -0
  23. package/dist/avatar/AvatarGroup.d.ts +41 -0
  24. package/dist/avatar/AvatarGroup.js +17 -0
  25. package/dist/avatar/avatar.css +73 -0
  26. package/dist/badge/Badge.d.ts +26 -0
  27. package/dist/badge/Badge.js +10 -0
  28. package/dist/badge/badge.css +24 -0
  29. package/dist/banner/Banner.d.ts +33 -0
  30. package/dist/banner/Banner.js +14 -0
  31. package/dist/banner/banner.css +25 -0
  32. package/dist/breadcrumbs/Breadcrumbs.d.ts +39 -0
  33. package/dist/breadcrumbs/Breadcrumbs.js +28 -0
  34. package/dist/breadcrumbs/breadcrumbs.css +18 -0
  35. package/dist/button/Button.d.ts +40 -0
  36. package/dist/button/Button.js +19 -0
  37. package/dist/button/button.css +63 -0
  38. package/dist/card/Card.d.ts +355 -0
  39. package/dist/card/Card.js +56 -0
  40. package/dist/card/card.css +43 -0
  41. package/dist/chart/AreaChart.d.ts +19 -0
  42. package/dist/chart/AreaChart.js +61 -0
  43. package/dist/chart/BarChart.d.ts +3 -0
  44. package/dist/chart/BarChart.js +60 -0
  45. package/dist/chart/ChartFrame.d.ts +25 -0
  46. package/dist/chart/ChartFrame.js +22 -0
  47. package/dist/chart/DonutChart.d.ts +20 -0
  48. package/dist/chart/DonutChart.js +41 -0
  49. package/dist/chart/Sparkline.d.ts +26 -0
  50. package/dist/chart/Sparkline.js +36 -0
  51. package/dist/chart/chart.css +51 -0
  52. package/dist/chart/chart.d.ts +23 -0
  53. package/dist/chart/chart.js +19 -0
  54. package/dist/checkbox/Checkbox.d.ts +50 -0
  55. package/dist/checkbox/Checkbox.js +19 -0
  56. package/dist/checkbox/CheckboxGroup.d.ts +44 -0
  57. package/dist/checkbox/CheckboxGroup.js +80 -0
  58. package/dist/checkbox/checkbox.css +49 -0
  59. package/dist/collapsible/Collapsible.d.ts +41 -0
  60. package/dist/collapsible/Collapsible.js +15 -0
  61. package/dist/collapsible/collapsible.css +20 -0
  62. package/dist/color-scheme.d.ts +18 -0
  63. package/dist/color-scheme.js +84 -0
  64. package/dist/command/Command.d.ts +64 -0
  65. package/dist/command/Command.js +162 -0
  66. package/dist/command/command.css +47 -0
  67. package/dist/confirm/confirm.css +5 -0
  68. package/dist/confirm/confirm.d.ts +42 -0
  69. package/dist/confirm/confirm.js +115 -0
  70. package/dist/copy-button/CopyButton.d.ts +30 -0
  71. package/dist/copy-button/CopyButton.js +23 -0
  72. package/dist/copy-button/copy-button.css +12 -0
  73. package/dist/css.d.ts +2 -0
  74. package/dist/data-list/DataList.d.ts +55 -0
  75. package/dist/data-list/DataList.js +43 -0
  76. package/dist/data-list/data-list.css +19 -0
  77. package/dist/date-picker/DatePicker.d.ts +95 -0
  78. package/dist/date-picker/DatePicker.js +237 -0
  79. package/dist/date-picker/date-picker.css +53 -0
  80. package/dist/dialog/Dialog.d.ts +98 -0
  81. package/dist/dialog/Dialog.js +92 -0
  82. package/dist/dialog/dialog.css +31 -0
  83. package/dist/divider/Divider.d.ts +27 -0
  84. package/dist/divider/Divider.js +8 -0
  85. package/dist/divider/divider.css +12 -0
  86. package/dist/editable/Editable.d.ts +47 -0
  87. package/dist/editable/Editable.js +51 -0
  88. package/dist/editable/editable.css +25 -0
  89. package/dist/editor/Editor.d.ts +59 -0
  90. package/dist/editor/Editor.js +220 -0
  91. package/dist/editor/editor.css +42 -0
  92. package/dist/editor/image.d.ts +1 -0
  93. package/dist/editor/image.js +23 -0
  94. package/dist/empty-state/EmptyState.d.ts +22 -0
  95. package/dist/empty-state/EmptyState.js +9 -0
  96. package/dist/empty-state/empty-state.css +14 -0
  97. package/dist/event-calendar/EventCalendar.d.ts +213 -0
  98. package/dist/event-calendar/EventCalendar.js +686 -0
  99. package/dist/event-calendar/dates.d.ts +8 -0
  100. package/dist/event-calendar/dates.js +21 -0
  101. package/dist/event-calendar/event-calendar.css +157 -0
  102. package/dist/field/Field.d.ts +66 -0
  103. package/dist/field/Field.js +23 -0
  104. package/dist/field/field.css +26 -0
  105. package/dist/file-upload/FileUpload.d.ts +60 -0
  106. package/dist/file-upload/FileUpload.js +102 -0
  107. package/dist/file-upload/file-upload.css +32 -0
  108. package/dist/format/FormatNumber.d.ts +42 -0
  109. package/dist/format/FormatNumber.js +59 -0
  110. package/dist/format/LocaleProvider.d.ts +33 -0
  111. package/dist/format/LocaleProvider.js +48 -0
  112. package/dist/format/format.d.ts +37 -0
  113. package/dist/format/format.js +74 -0
  114. package/dist/format/messages.d.ts +211 -0
  115. package/dist/format/messages.js +202 -0
  116. package/dist/highlight/Highlight.d.ts +11 -0
  117. package/dist/highlight/Highlight.js +14 -0
  118. package/dist/highlight/highlight.css +17 -0
  119. package/dist/hover-card/HoverCard.d.ts +47 -0
  120. package/dist/hover-card/HoverCard.js +18 -0
  121. package/dist/hover-card/hover-card.css +19 -0
  122. package/dist/index.d.ts +84 -0
  123. package/dist/index.js +85 -0
  124. package/dist/input/Input.d.ts +41 -0
  125. package/dist/input/Input.js +48 -0
  126. package/dist/input/input.css +25 -0
  127. package/dist/kbd/Kbd.d.ts +19 -0
  128. package/dist/kbd/Kbd.js +20 -0
  129. package/dist/kbd/kbd.css +8 -0
  130. package/dist/menu/Menu.d.ts +223 -0
  131. package/dist/menu/Menu.js +275 -0
  132. package/dist/menu/menu.css +38 -0
  133. package/dist/native-select/NativeSelect.d.ts +36 -0
  134. package/dist/native-select/NativeSelect.js +51 -0
  135. package/dist/native-select/native-select.css +16 -0
  136. package/dist/number-input/NumberInput.d.ts +56 -0
  137. package/dist/number-input/NumberInput.js +67 -0
  138. package/dist/number-input/number-input.css +21 -0
  139. package/dist/page/Page.d.ts +30 -0
  140. package/dist/page/Page.js +9 -0
  141. package/dist/page/page.css +17 -0
  142. package/dist/pagination/Pagination.d.ts +31 -0
  143. package/dist/pagination/Pagination.js +58 -0
  144. package/dist/pagination/pagination.css +15 -0
  145. package/dist/password-input/PasswordInput.d.ts +47 -0
  146. package/dist/password-input/PasswordInput.js +35 -0
  147. package/dist/password-input/password-input.css +18 -0
  148. package/dist/pin-input/PinInput.d.ts +45 -0
  149. package/dist/pin-input/PinInput.js +22 -0
  150. package/dist/pin-input/pin-input.css +13 -0
  151. package/dist/popover/Popover.d.ts +65 -0
  152. package/dist/popover/Popover.js +26 -0
  153. package/dist/popover/popover.css +16 -0
  154. package/dist/progress/Progress.d.ts +56 -0
  155. package/dist/progress/Progress.js +28 -0
  156. package/dist/progress/progress.css +24 -0
  157. package/dist/prose/Prose.d.ts +23 -0
  158. package/dist/prose/Prose.js +10 -0
  159. package/dist/prose/prose.css +29 -0
  160. package/dist/radio-group/RadioGroup.d.ts +59 -0
  161. package/dist/radio-group/RadioGroup.js +37 -0
  162. package/dist/radio-group/radio-group.css +30 -0
  163. package/dist/rolling-number/RollingNumber.d.ts +27 -0
  164. package/dist/rolling-number/RollingNumber.js +18 -0
  165. package/dist/rolling-number/rolling-number.css +10 -0
  166. package/dist/segment-group/Segmented.d.ts +68 -0
  167. package/dist/segment-group/Segmented.js +79 -0
  168. package/dist/segment-group/segment-group.css +25 -0
  169. package/dist/select/ListSelect.d.ts +5 -0
  170. package/dist/select/ListSelect.js +82 -0
  171. package/dist/select/SearchSelect.d.ts +43 -0
  172. package/dist/select/SearchSelect.js +219 -0
  173. package/dist/select/Select.d.ts +147 -0
  174. package/dist/select/Select.js +32 -0
  175. package/dist/select/items.d.ts +17 -0
  176. package/dist/select/items.js +14 -0
  177. package/dist/select/search-select.css +45 -0
  178. package/dist/select/select.css +52 -0
  179. package/dist/sheet/Sheet.d.ts +88 -0
  180. package/dist/sheet/Sheet.js +66 -0
  181. package/dist/sheet/sheet.css +44 -0
  182. package/dist/sidebar/NavGroup.d.ts +29 -0
  183. package/dist/sidebar/NavGroup.js +78 -0
  184. package/dist/sidebar/NavItem.d.ts +33 -0
  185. package/dist/sidebar/NavItem.js +50 -0
  186. package/dist/sidebar/NavSection.d.ts +20 -0
  187. package/dist/sidebar/NavSection.js +44 -0
  188. package/dist/sidebar/Sidebar.d.ts +65 -0
  189. package/dist/sidebar/Sidebar.js +59 -0
  190. package/dist/sidebar/SidebarUser.d.ts +31 -0
  191. package/dist/sidebar/SidebarUser.js +14 -0
  192. package/dist/sidebar/context.d.ts +13 -0
  193. package/dist/sidebar/context.js +6 -0
  194. package/dist/sidebar/sidebar.css +109 -0
  195. package/dist/skeleton/Skeleton.d.ts +25 -0
  196. package/dist/skeleton/Skeleton.js +8 -0
  197. package/dist/skeleton/skeleton.css +13 -0
  198. package/dist/slider/Slider.d.ts +70 -0
  199. package/dist/slider/Slider.js +65 -0
  200. package/dist/slider/slider.css +17 -0
  201. package/dist/sortable-list/SortableList.d.ts +46 -0
  202. package/dist/sortable-list/SortableList.js +85 -0
  203. package/dist/sortable-list/sortable-list.css +25 -0
  204. package/dist/spinner/Spinner.d.ts +18 -0
  205. package/dist/spinner/Spinner.js +11 -0
  206. package/dist/spinner/spinner.css +9 -0
  207. package/dist/stat/Stat.d.ts +42 -0
  208. package/dist/stat/Stat.js +21 -0
  209. package/dist/stat/Stats.d.ts +23 -0
  210. package/dist/stat/Stats.js +8 -0
  211. package/dist/stat/stat.css +34 -0
  212. package/dist/swatch-picker/SwatchPicker.d.ts +45 -0
  213. package/dist/swatch-picker/SwatchPicker.js +54 -0
  214. package/dist/swatch-picker/swatch-picker.css +38 -0
  215. package/dist/switch/Switch.d.ts +39 -0
  216. package/dist/switch/Switch.js +17 -0
  217. package/dist/switch/switch.css +13 -0
  218. package/dist/table/Table.d.ts +90 -0
  219. package/dist/table/Table.js +145 -0
  220. package/dist/table/table.css +44 -0
  221. package/dist/tabs/Tabs.d.ts +87 -0
  222. package/dist/tabs/Tabs.js +90 -0
  223. package/dist/tabs/tabs.css +53 -0
  224. package/dist/tags-input/TagsInput.d.ts +69 -0
  225. package/dist/tags-input/TagsInput.js +95 -0
  226. package/dist/tags-input/tags-input.css +37 -0
  227. package/dist/textarea/Textarea.d.ts +24 -0
  228. package/dist/textarea/Textarea.js +51 -0
  229. package/dist/textarea/textarea.css +14 -0
  230. package/dist/theme-toggle/ThemeToggle.d.ts +49 -0
  231. package/dist/theme-toggle/ThemeToggle.js +71 -0
  232. package/dist/theme-toggle/theme-toggle.css +23 -0
  233. package/dist/theme.css +119 -0
  234. package/dist/time-input/TimeInput.d.ts +25 -0
  235. package/dist/time-input/TimeInput.js +20 -0
  236. package/dist/timeline/Timeline.d.ts +50 -0
  237. package/dist/timeline/Timeline.js +15 -0
  238. package/dist/timeline/timeline.css +29 -0
  239. package/dist/toast/Toaster.d.ts +36 -0
  240. package/dist/toast/Toaster.js +25 -0
  241. package/dist/toast/toast.css +33 -0
  242. package/dist/tooltip/Tooltip.d.ts +55 -0
  243. package/dist/tooltip/Tooltip.js +48 -0
  244. package/dist/tooltip/tooltip.css +18 -0
  245. package/dist/tree-view/TreeView.d.ts +86 -0
  246. package/dist/tree-view/TreeView.js +74 -0
  247. package/dist/tree-view/tree-view.css +29 -0
  248. package/dist/utils.d.ts +19 -0
  249. package/dist/utils.js +59 -0
  250. package/package.json +99 -4
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The app's top bar: content on the left, `end` on the right. In an AppShell
3
+ * on a phone it starts with a button opening the sidebar drawer.
4
+ *
5
+ * <Topbar end={<Button icon="lucide:bell" aria-label="Notifications" />}>
6
+ * <Breadcrumbs items={trail} />
7
+ * </Topbar>
8
+ *
9
+ * - `end`: actions on the right
10
+ */
11
+ import type { HTMLAttributes, ReactNode } from 'react';
12
+ import 'iconify-icon';
13
+ import { type Classes } from '../utils.js';
14
+ import '../theme.css';
15
+ import './app-shell.css';
16
+ export type TopbarProps = HTMLAttributes<HTMLElement> & {
17
+ end?: ReactNode;
18
+ classes?: Classes<'root' | 'menu' | 'start' | 'end'>;
19
+ };
20
+ export declare function Topbar({ end, className, classes, children, ...rest }: TopbarProps): import("react").JSX.Element;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import 'iconify-icon';
3
+ import { cx, partsOf } from '../utils.js';
4
+ import { useMessages } from '../format/LocaleProvider.js';
5
+ import { useShell } from './shell.js';
6
+ import '../theme.css';
7
+ import './app-shell.css';
8
+ export function Topbar({ end = null, className, classes, children, ...rest }) {
9
+ const t = useMessages();
10
+ const shell = useShell();
11
+ const part = partsOf('topbar', classes);
12
+ return (_jsxs("header", { ...rest, ...part('root'), className: cx(className, classes?.root), children: [shell?.mobile && (_jsx("button", { ...part('menu'), type: "button", "aria-label": t.shell.openMenu, "aria-expanded": shell.drawerOpen, onClick: shell.toggle, children: _jsx("iconify-icon", { icon: "lucide:menu", "aria-hidden": "true" }) })), _jsx("div", { ...part('start'), children: children }), end && _jsx("div", { ...part('end'), children: end })] }));
13
+ }
@@ -0,0 +1,41 @@
1
+ /* The shell lays out its children by their data-scope: sidebar, topbar, page. */
2
+ @layer ui {
3
+ [data-scope=app-shell][data-part=root] {
4
+ display: grid; grid-template-columns: var(--ui-sidebar-width) minmax(0, 1fr); grid-template-rows: auto 1fr;
5
+ grid-template-areas: "sidebar topbar" "sidebar main"; min-height: 100dvh; transition: grid-template-columns 200ms ease;
6
+ background: var(--ui-canvas); color: var(--ui-text); font-family: var(--ui-font);
7
+
8
+ > [data-scope=sidebar] { grid-area: sidebar; }
9
+ &[data-rail] { grid-template-columns: var(--ui-sidebar-rail-width) minmax(0, 1fr); }
10
+ /* On phones the sidebar leaves the grid and slides in as a drawer. */
11
+ &[data-mobile] { grid-template-columns: minmax(0, 1fr); grid-template-areas: "topbar" "main"; }
12
+ &[data-inset][data-mobile] > [data-scope=page] { margin: 0 0.5rem 0.5rem; }
13
+ > [data-scope=topbar] { grid-area: topbar; }
14
+ > [data-scope=page] { grid-area: main; }
15
+
16
+ /* Inset: the page is a rounded panel on the frame, and the sidebar and top
17
+ bar sit flat around it. The grey sidebar's items go white. */
18
+ &[data-inset] { background: var(--ui-frame);
19
+ > [data-scope=page] { background: var(--ui-canvas); margin: 0 0.75rem 0.75rem; border-radius: calc(var(--ui-radius) + 10px); }
20
+ > [data-scope=topbar] { background: var(--ui-frame); border-bottom: none; }
21
+ > [data-scope=sidebar] { background: var(--ui-sidebar-bg); border-right: none;
22
+ --ui-sidebar-hover: var(--ui-sidebar-inset-hover);
23
+ --ui-sidebar-active: var(--ui-sidebar-inset-active);
24
+ --ui-sidebar-active-shadow: var(--ui-sidebar-inset-active-shadow); } }
25
+ }
26
+
27
+ [data-scope=topbar][data-part=root] { display: flex; align-items: center; gap: 0.75rem; height: 3.5rem; padding: 0 1rem;
28
+ box-sizing: border-box; min-width: 0; position: sticky; top: 0; z-index: 36;
29
+ background: var(--ui-surface); border-bottom: 1px solid var(--ui-border);
30
+ @media (min-width: 768px) { padding: 0 1.5rem; } }
31
+ [data-scope=topbar][data-part=start] { display: flex; align-items: center; gap: 0.75rem; flex-grow: 1; min-width: 0; }
32
+ [data-scope=topbar][data-part=end] { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
33
+ [data-scope=app-shell][data-part=backdrop] { position: fixed; inset: 0; z-index: 40; background: rgba(0, 0, 0, 0.4);
34
+ animation: ui-fade-in 200ms ease-out; }
35
+ [data-scope=topbar][data-part=menu] { display: grid; place-items: center; width: 2.25rem; height: 2.25rem; margin-left: -0.5rem;
36
+ flex-shrink: 0; padding: 0; border: none; background: transparent; border-radius: var(--ui-radius); color: var(--ui-text);
37
+ font-size: 18px; cursor: pointer;
38
+ &:hover { background: var(--ui-hover); }
39
+ &:focus-visible { outline: 2px solid var(--ui-ring-soft); } }
40
+ @keyframes ui-fade-in { from { opacity: 0; } }
41
+ }
@@ -0,0 +1,9 @@
1
+ export type Shell = {
2
+ rail: boolean;
3
+ mobile: boolean;
4
+ drawerOpen: boolean;
5
+ toggle: () => void;
6
+ close: () => void;
7
+ };
8
+ export declare const ShellContext: import("react").Context<Shell | null>;
9
+ export declare const useShell: () => Shell | null;
@@ -0,0 +1,3 @@
1
+ import { createContext, useContext } from 'react';
2
+ export const ShellContext = createContext(null);
3
+ export const useShell = () => useContext(ShellContext);
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The files added to a page or form, as a grid of tiles (image thumbnails, or
3
+ * an icon and the extension) or a compact list.
4
+ *
5
+ * <Attachments items={files} onItemsChange={setFiles} removable addable />
6
+ *
7
+ * - `items`, `defaultItems`; `onItemsChange`: called with the new list. Plain
8
+ * objects `{ name, size (bytes, or text), type (MIME), url, thumb }` or File
9
+ * objects (e.g. from FileUpload or an <input type=file>), which are
10
+ * previewed locally
11
+ * - `layout`, `defaultLayout`: 'grid' (default) or 'list'; `onLayoutChange`
12
+ * - `switchable`: a grid/list switch above the files, so people pick; with
13
+ * `persist` (a localStorage key) their choice is remembered
14
+ * - `columns`: the most tiles per row in the grid; fewer when tiles would get
15
+ * too small (without it, as many as fit)
16
+ * - `removable`: a remove button on each; `onRemove` gets the item
17
+ * - `addable`: an "Add files" tile that opens the file picker, and files can
18
+ * be dropped onto the attachments; `onAdd` gets the Files
19
+ * - `accept` (MIME types or extensions, e.g. 'image/*,.pdf'), `maxFiles` and
20
+ * `maxFileSize` (bytes) limit what's added; files that don't fit are listed
21
+ * with the reason, and `onReject` gets `[{ file, reason }]`
22
+ * - `addLabel`: the add tile's text ('Add files'); `emptyText`: shown when
23
+ * there are no files and it isn't `addable`
24
+ * - `label`: a label above it (skipped inside a Field, whose label, hint and
25
+ * error it takes, as the other controls do)
26
+ * - `name`: posts the new Files with a plain <form> (a hidden file input);
27
+ * `keptName` also posts the `id` of each saved item still in the list, so
28
+ * the server can tell what was removed
29
+ * - `disabled`: read only (no adding or removing); a disabled Field's too
30
+ *
31
+ * In a new-record form, start with no items; in an edit form, with the
32
+ * record's saved files as plain objects (with an `id`). Files added are
33
+ * Files, so on submit: new = items that are Files, kept = the rest, removed =
34
+ * the saved ones no longer there.
35
+ *
36
+ * Clicking an image opens it large; other files open their `url`, if any, in
37
+ * a new tab. Files with a `url` get a download button.
38
+ */
39
+ import { type HTMLAttributes, type ReactNode } from 'react';
40
+ import 'iconify-icon';
41
+ import { type Classes } from '../utils.js';
42
+ import '../theme.css';
43
+ import './attachments.css';
44
+ export type Attachment = File | {
45
+ id?: string | number;
46
+ name?: string;
47
+ size?: number | string;
48
+ type?: string;
49
+ url?: string;
50
+ thumb?: string;
51
+ };
52
+ export type AttachmentsLayout = 'grid' | 'list';
53
+ export type AttachmentRejection = {
54
+ file: File;
55
+ reason: string;
56
+ };
57
+ export type AttachmentsProps = Omit<HTMLAttributes<HTMLDivElement>, 'onDrop'> & {
58
+ items?: Attachment[];
59
+ defaultItems?: Attachment[];
60
+ onItemsChange?: (items: Attachment[]) => void;
61
+ layout?: AttachmentsLayout;
62
+ defaultLayout?: AttachmentsLayout;
63
+ onLayoutChange?: (layout: AttachmentsLayout) => void;
64
+ switchable?: boolean;
65
+ persist?: string | null;
66
+ columns?: number | null;
67
+ removable?: boolean;
68
+ addable?: boolean;
69
+ accept?: string | null;
70
+ maxFiles?: number | null;
71
+ maxFileSize?: number | null;
72
+ addLabel?: ReactNode;
73
+ emptyText?: ReactNode;
74
+ onRemove?: (item: Attachment) => void;
75
+ onAdd?: (files: File[]) => void;
76
+ onReject?: (rejected: AttachmentRejection[]) => void;
77
+ label?: ReactNode;
78
+ name?: string;
79
+ keptName?: string;
80
+ disabled?: boolean;
81
+ classes?: Classes<'root' | 'header' | 'label' | 'switch' | 'empty' | 'files' | 'file' | 'open' | 'thumb' | 'icon' | 'ext' | 'meta' | 'name' | 'size' | 'actions' | 'action' | 'addTile' | 'addInput' | 'addIcon' | 'addText' | 'addLabel' | 'limit' | 'rejected' | 'dropHint' | 'preview' | 'downloadLink'>;
82
+ };
83
+ export declare const formatSize: (bytes: number, locale?: string) => string;
84
+ export declare function Attachments({ items: itemsProp, defaultItems, onItemsChange, layout: layoutProp, defaultLayout, onLayoutChange, switchable, persist, columns, removable, addable, accept, maxFiles, maxFileSize, addLabel: addLabelProp, emptyText, onRemove, onAdd, onReject, label, name, keptName, disabled: disabledProp, className, classes, style, ...rest }: AttachmentsProps): import("react").JSX.Element;
@@ -0,0 +1,235 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * The files added to a page or form, as a grid of tiles (image thumbnails, or
4
+ * an icon and the extension) or a compact list.
5
+ *
6
+ * <Attachments items={files} onItemsChange={setFiles} removable addable />
7
+ *
8
+ * - `items`, `defaultItems`; `onItemsChange`: called with the new list. Plain
9
+ * objects `{ name, size (bytes, or text), type (MIME), url, thumb }` or File
10
+ * objects (e.g. from FileUpload or an <input type=file>), which are
11
+ * previewed locally
12
+ * - `layout`, `defaultLayout`: 'grid' (default) or 'list'; `onLayoutChange`
13
+ * - `switchable`: a grid/list switch above the files, so people pick; with
14
+ * `persist` (a localStorage key) their choice is remembered
15
+ * - `columns`: the most tiles per row in the grid; fewer when tiles would get
16
+ * too small (without it, as many as fit)
17
+ * - `removable`: a remove button on each; `onRemove` gets the item
18
+ * - `addable`: an "Add files" tile that opens the file picker, and files can
19
+ * be dropped onto the attachments; `onAdd` gets the Files
20
+ * - `accept` (MIME types or extensions, e.g. 'image/*,.pdf'), `maxFiles` and
21
+ * `maxFileSize` (bytes) limit what's added; files that don't fit are listed
22
+ * with the reason, and `onReject` gets `[{ file, reason }]`
23
+ * - `addLabel`: the add tile's text ('Add files'); `emptyText`: shown when
24
+ * there are no files and it isn't `addable`
25
+ * - `label`: a label above it (skipped inside a Field, whose label, hint and
26
+ * error it takes, as the other controls do)
27
+ * - `name`: posts the new Files with a plain <form> (a hidden file input);
28
+ * `keptName` also posts the `id` of each saved item still in the list, so
29
+ * the server can tell what was removed
30
+ * - `disabled`: read only (no adding or removing); a disabled Field's too
31
+ *
32
+ * In a new-record form, start with no items; in an edit form, with the
33
+ * record's saved files as plain objects (with an `id`). Files added are
34
+ * Files, so on submit: new = items that are Files, kept = the rest, removed =
35
+ * the saved ones no longer there.
36
+ *
37
+ * Clicking an image opens it large; other files open their `url`, if any, in
38
+ * a new tab. Files with a `url` get a download button.
39
+ */
40
+ import { useEffect, useId, useMemo, useRef, useState } from 'react';
41
+ import { createFileTypeValidator, getAcceptAttrString, getFileKey } from '@zag-js/file-utils';
42
+ import { useFieldContext } from '@ark-ui/react/field';
43
+ import 'iconify-icon';
44
+ import { Dialog } from '../dialog/Dialog.js';
45
+ import { formatBytes } from '../format/format.js';
46
+ import { useLocale, useMessages } from '../format/LocaleProvider.js';
47
+ import { Segmented } from '../segment-group/Segmented.js';
48
+ import { cx, partsOf, useControllable } from '../utils.js';
49
+ import '../theme.css';
50
+ import './attachments.css';
51
+ // A file's size ('2.4 MB'): formatBytes, kept under its old name.
52
+ export const formatSize = (bytes, locale) => formatBytes(bytes, { locale });
53
+ function iconFor(type, name) {
54
+ const n = name.toLowerCase();
55
+ if (type.startsWith('image/') || /\.(jpe?g|png|gif|webp|avif|svg)$/.test(n))
56
+ return 'lucide:image';
57
+ if (type.startsWith('video/') || /\.(mp4|mov|webm)$/.test(n))
58
+ return 'lucide:file-video';
59
+ if (type.startsWith('audio/') || /\.(mp3|wav|m4a)$/.test(n))
60
+ return 'lucide:file-audio';
61
+ if (/\.(xlsx?|csv|numbers)$/.test(n))
62
+ return 'lucide:file-spreadsheet';
63
+ if (/\.(zip|rar|7z|gz)$/.test(n))
64
+ return 'lucide:file-archive';
65
+ if (/\.(pdf|docx?|txt|md|pages|rtf)$/.test(n))
66
+ return 'lucide:file-text';
67
+ return 'lucide:file';
68
+ }
69
+ const fileKey = (file) => getFileKey(file);
70
+ function saved(key) {
71
+ try {
72
+ const value = key ? localStorage.getItem(key) : null;
73
+ return value === 'grid' || value === 'list' ? value : null;
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
79
+ // Local previews for Files, made once each.
80
+ const urls = new WeakMap();
81
+ function localUrl(file) {
82
+ if (!urls.has(file))
83
+ urls.set(file, URL.createObjectURL(file));
84
+ return urls.get(file);
85
+ }
86
+ // One shape for both plain objects and Files.
87
+ function info(item, locale) {
88
+ const file = typeof File !== 'undefined' && item instanceof File ? item : null;
89
+ const url = file ? localUrl(file) : item.url;
90
+ const type = item.type ?? '';
91
+ const name = item.name || 'Untitled';
92
+ const image = type.startsWith('image/') || /\.(jpe?g|png|gif|webp|avif|svg)$/i.test(name);
93
+ return {
94
+ item, name, url, image,
95
+ size: typeof item.size === 'number' ? formatSize(item.size, locale) : (item.size ?? ''),
96
+ thumb: (!file && item.thumb) || (image ? url ?? null : null),
97
+ ext: (name.match(/\.([a-z0-9]+)$/i)?.[1] ?? '').toUpperCase(),
98
+ icon: iconFor(type, name),
99
+ };
100
+ }
101
+ // Whether a file matches `accept` (MIME types, type/* or extensions):
102
+ // Zag's own check, the one FileUpload uses, so both take the same files.
103
+ const typeChecker = (accept) => {
104
+ const fits = createFileTypeValidator(getAcceptAttrString(accept ?? undefined));
105
+ return (file) => !accept || fits(file)[0];
106
+ };
107
+ export function Attachments({ items: itemsProp, defaultItems = [], onItemsChange, layout: layoutProp, defaultLayout = 'grid', onLayoutChange, switchable = false, persist = null, columns = null, removable = false, addable = false, accept = null, maxFiles = null, maxFileSize = null, addLabel: addLabelProp, emptyText = null, onRemove, onAdd, onReject, label = null, name, keptName, disabled: disabledProp = false, className, classes, style, ...rest }) {
108
+ const t = useMessages();
109
+ const addLabel = addLabelProp ?? t.attachments.add;
110
+ const layouts = [{ value: 'grid', label: t.attachments.grid, icon: 'lucide:layout-grid' }, { value: 'list', label: t.attachments.list, icon: 'lucide:list' }];
111
+ const part = partsOf('attachments', classes);
112
+ const field = useFieldContext();
113
+ const id = useId();
114
+ const labelId = field ? field.ids.label : label ? `${id}-label` : undefined;
115
+ const disabled = disabledProp || !!field?.disabled;
116
+ // Read only: nothing added or removed.
117
+ if (disabled) {
118
+ addable = false;
119
+ removable = false;
120
+ }
121
+ const [items, setItems] = useControllable(itemsProp, defaultItems, onItemsChange);
122
+ // The layout: remembered under `persist`, when there's one.
123
+ const [initialLayout] = useState(() => saved(persist) ?? defaultLayout);
124
+ const [layout, setLayoutValue] = useControllable(layoutProp, initialLayout, onLayoutChange);
125
+ function setLayout(next) {
126
+ setLayoutValue(next);
127
+ try {
128
+ if (persist)
129
+ localStorage.setItem(persist, next);
130
+ }
131
+ catch { /* private mode */ }
132
+ }
133
+ const locale = useLocale();
134
+ const list = items.map((item) => info(item, locale));
135
+ const count = items.length;
136
+ const atMax = !!maxFiles && count >= maxFiles;
137
+ function remove(entry) {
138
+ setItems(items.filter((item) => item !== entry.item));
139
+ onRemove?.(entry.item);
140
+ }
141
+ const fitsType = useMemo(() => typeChecker(accept), [accept]);
142
+ // For a plain <form>: the new Files, in a hidden file input. A DataTransfer
143
+ // is the only way to set an input's files.
144
+ const posted = useRef(null);
145
+ const newFiles = items.filter((item) => typeof File !== 'undefined' && item instanceof File);
146
+ useEffect(() => {
147
+ if (!posted.current)
148
+ return;
149
+ const transfer = new DataTransfer();
150
+ newFiles.forEach((file) => transfer.items.add(file));
151
+ posted.current.files = transfer.files;
152
+ }, [newFiles.map(fileKey).join('\u0000')]); // eslint-disable-line react-hooks/exhaustive-deps
153
+ const keptIds = items.filter((item) => !(typeof File !== 'undefined' && item instanceof File))
154
+ .map((item) => item.id).filter((id) => id != null);
155
+ // Adds what fits (type, size, count); the rest is rejected with a reason.
156
+ const [rejected, setRejected] = useState([]);
157
+ function addFiles(files) {
158
+ const ok = [];
159
+ const bad = [];
160
+ for (const file of files) {
161
+ if (!fitsType(file))
162
+ bad.push({ file, reason: t.attachments.notType });
163
+ else if (maxFileSize && file.size > maxFileSize)
164
+ bad.push({ file, reason: t.attachments.over(formatSize(maxFileSize, locale)) });
165
+ else if (maxFiles && count + ok.length >= maxFiles)
166
+ bad.push({ file, reason: t.attachments.limit(maxFiles) });
167
+ else
168
+ ok.push(file);
169
+ }
170
+ setRejected(bad);
171
+ if (bad.length)
172
+ onReject?.(bad);
173
+ if (!ok.length)
174
+ return;
175
+ setItems([...items, ...ok]);
176
+ onAdd?.(ok);
177
+ }
178
+ function added(e) {
179
+ const files = Array.from(e.target.files ?? []);
180
+ e.target.value = '';
181
+ if (files.length)
182
+ addFiles(files);
183
+ }
184
+ // Dropping files: only while addable, and only for files (not text).
185
+ const root = useRef(null);
186
+ const [dragging, setDragging] = useState(false);
187
+ const hasFiles = (e) => addable && Array.from(e.dataTransfer?.types ?? []).includes('Files');
188
+ function dragOver(e) {
189
+ if (!hasFiles(e))
190
+ return;
191
+ e.preventDefault();
192
+ e.dataTransfer.dropEffect = atMax ? 'none' : 'copy';
193
+ setDragging(true);
194
+ }
195
+ function dragLeave(e) {
196
+ if (!root.current?.contains(e.relatedTarget))
197
+ setDragging(false);
198
+ }
199
+ function dropped(e) {
200
+ if (!hasFiles(e))
201
+ return;
202
+ e.preventDefault();
203
+ setDragging(false);
204
+ addFiles(Array.from(e.dataTransfer.files ?? []));
205
+ }
206
+ // The add tile would start a row on its own when the files fill their rows;
207
+ // then it becomes a slim full-width row instead of a big empty tile. The
208
+ // column count is read off the grid, so it works for auto-fill grids too.
209
+ const filesEl = useRef(null);
210
+ const [gridColumns, setGridColumns] = useState(0);
211
+ useEffect(() => {
212
+ const el = filesEl.current;
213
+ if (!el)
214
+ return;
215
+ // The observer reports once on observe, then on every resize.
216
+ const observer = new ResizeObserver(() => {
217
+ const tracks = getComputedStyle(el).gridTemplateColumns;
218
+ setGridColumns(tracks === 'none' ? 0 : tracks.split(' ').filter(Boolean).length);
219
+ });
220
+ observer.observe(el);
221
+ return () => observer.disconnect();
222
+ }, [layout, columns, list.length]);
223
+ const addWide = layout === 'grid' && gridColumns > 0 && list.length > 0 && list.length % gridColumns === 0;
224
+ const [previewing, setPreviewing] = useState(null);
225
+ const [previewOpen, setPreviewOpen] = useState(false);
226
+ function open(entry) {
227
+ if (entry.image && entry.url) {
228
+ setPreviewing(entry);
229
+ setPreviewOpen(true);
230
+ }
231
+ else if (entry.url)
232
+ window.open(entry.url, '_blank', 'noopener');
233
+ }
234
+ return (_jsxs("div", { ...rest, ...part('root'), className: cx(className, classes?.root), ref: root, "data-layout": layout, "aria-labelledby": labelId, "aria-describedby": field?.ariaDescribedby, "aria-invalid": field?.invalid || undefined, "data-invalid": field?.invalid || undefined, "data-disabled": disabled || undefined, "data-empty": !list.length || undefined, "data-add-wide": addWide || undefined, "data-fixed": columns ? '' : undefined, style: { ...style, ...(columns && { '--columns': columns }) }, onDragEnter: dragOver, onDragOver: dragOver, onDragLeave: dragLeave, onDrop: dropped, role: "group", children: [((label && !field) || switchable) && (_jsxs("div", { ...part('header'), children: [label && !field && _jsx("span", { ...part('label'), id: labelId, children: label }), switchable && (_jsx(Segmented, { className: classes?.switch, size: "sm", iconOnly: true, "aria-label": t.attachments.layout, items: layouts, value: layout, onValueChange: (next) => next && setLayout(next) }))] })), name && _jsx("input", { ref: posted, type: "file", name: name, multiple: true, hidden: true, "aria-hidden": "true", tabIndex: -1 }), keptName && keptIds.map((kept) => _jsx("input", { type: "hidden", name: keptName, value: String(kept) }, String(kept))), !list.length && !addable && emptyText && _jsx("p", { ...part('empty'), children: emptyText }), _jsxs("ul", { ...part('files'), ref: filesEl, children: [list.map((entry, i) => (_jsxs("li", { ...part('file'), children: [_jsxs("button", { ...part('open'), type: "button", "aria-label": t.attachments.open(entry.name), onClick: () => open(entry), children: [_jsx("span", { ...part('thumb'), children: entry.thumb ? _jsx("img", { src: entry.thumb, alt: "", loading: "lazy" }) : _jsxs(_Fragment, { children: [_jsx("iconify-icon", { ...part('icon'), icon: entry.icon, "aria-hidden": "true" }), entry.ext && _jsx("span", { ...part('ext'), children: entry.ext })] }) }), _jsxs("span", { ...part('meta'), children: [_jsx("span", { ...part('name'), children: entry.name }), entry.size && _jsx("span", { ...part('size'), children: entry.size })] })] }), _jsxs("span", { ...part('actions'), children: [entry.url && (_jsx("a", { ...part('action'), href: entry.url, download: entry.name, "aria-label": t.attachments.downloadFile(entry.name), children: _jsx("iconify-icon", { icon: "lucide:download" }) })), removable && (_jsx("button", { ...part('action'), type: "button", "aria-label": t.common.remove(entry.name), onClick: () => remove(entry), children: _jsx("iconify-icon", { icon: "lucide:x" }) }))] })] }, i))), addable && !atMax && (_jsx("li", { ...part('file'), "data-add": "", children: _jsxs("label", { ...part('add-tile'), children: [_jsx("input", { ...part('add-input'), ...(field && { id: field.ids.control }), type: "file", multiple: maxFiles !== 1, ...(accept && { accept }), onChange: added }), _jsx("span", { ...part('add-icon'), children: _jsx("iconify-icon", { icon: "lucide:plus" }) }), _jsxs("span", { ...part('add-text'), children: [_jsx("span", { ...part('add-label'), children: addLabel }), !list.length && _jsx("span", { ...part('limit'), children: t.attachments.orDrop }), maxFiles && list.length > 0 && _jsx("span", { ...part('limit'), children: t.attachments.count(count, maxFiles) })] })] }) }))] }), rejected.length > 0 && (_jsx("ul", { ...part('rejected'), role: "alert", children: rejected.map((item, i) => _jsx("li", { children: t.attachments.notAdded(item.file.name, item.reason) }, i)) })), dragging && (_jsxs("div", { ...part('drop-hint'), "aria-hidden": "true", children: [_jsx("iconify-icon", { icon: "lucide:upload" }), _jsx("span", { children: atMax && maxFiles ? t.attachments.limit(maxFiles) : t.attachments.dropToAdd })] })), _jsx(Dialog, { size: "lg", heading: previewing?.name, open: previewOpen, onOpenChange: setPreviewOpen, footer: previewing && _jsx("a", { ...part('download-link'), href: previewing.url, download: previewing.name, children: t.attachments.download }), children: previewing && _jsx("img", { ...part('preview'), src: previewing.url, alt: previewing.name }) })] }));
235
+ }
@@ -0,0 +1,86 @@
1
+ @layer ui {
2
+ [data-scope=attachments][data-part=root] { display: block; position: relative; width: 100%; color: var(--ui-text); font-family: var(--ui-font); font-size: 14px;
3
+ --tile-radius: calc(var(--ui-radius) + 2px); }
4
+ [data-scope=attachments][data-part=empty] { margin: 0; color: var(--ui-muted); }
5
+ [data-scope=attachments][data-part=files] { margin: 0; padding: 0; list-style: none;
6
+ /* Grid: tiles with a 4:3 preview and the name under it. Rows share a height, so the add tile matches. */
7
+ [data-layout=grid] > & { display: grid; grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr)); grid-auto-rows: 1fr; gap: 0.75rem; }
8
+ /* With `columns`: at most that many, fewer when tiles would drop below 7rem. */
9
+ [data-layout=grid][data-fixed] > & { grid-template-columns: repeat(auto-fill, minmax(max(7rem, calc((100% - (var(--columns) - 1) * 0.75rem) / var(--columns))), 1fr)); }
10
+ [data-layout=grid][data-add-wide] > & { grid-auto-rows: auto; }
11
+ /* List: rows with a small preview, the name and size, and the actions. */
12
+ [data-layout=list] > & { display: flex; flex-direction: column; background: var(--ui-surface); border: 1px solid var(--ui-border);
13
+ border-radius: var(--tile-radius); box-shadow: var(--ui-card-shadow); overflow: hidden; } }
14
+ [data-scope=attachments][data-part=file] { position: relative; min-width: 0;
15
+ [data-layout=grid] & { background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: var(--tile-radius); overflow: hidden; box-shadow: var(--ui-card-shadow); }
16
+ [data-layout=grid] &[data-add] { border: none; background: transparent; box-shadow: none; overflow: visible; }
17
+ [data-layout=grid]:is([data-add-wide], [data-empty]) &[data-add] { grid-column: 1 / -1; }
18
+ [data-layout=list] & { display: flex; align-items: center; border-bottom: 1px solid var(--ui-border);
19
+ &:last-child, &[data-add] { border-bottom: none; } } }
20
+ [data-scope=attachments][data-part=open] { display: flex; width: 100%; padding: 0; border: none; background: transparent; color: inherit; font: inherit;
21
+ text-align: left; cursor: pointer; border-radius: inherit;
22
+ &:focus-visible { outline: 2px solid var(--ui-ring-soft); outline-offset: 2px; }
23
+ [data-layout=grid] & { flex-direction: column; height: 100%; }
24
+ [data-layout=list] & { align-items: center; gap: 0.75rem; padding: 0.625rem 0.75rem; flex-grow: 1; min-width: 0;
25
+ &:hover { background: color-mix(in srgb, var(--ui-hover) 60%, transparent); } } }
26
+ [data-scope=attachments][data-part=thumb] { position: relative; display: grid; place-items: center; overflow: hidden; background: var(--ui-hover); color: var(--ui-muted);
27
+ & img { display: block; width: 100%; height: 100%; object-fit: cover; }
28
+ [data-layout=grid] & { aspect-ratio: 4 / 3; width: 100%; }
29
+ [data-layout=list] & { width: 2.5rem; height: 2.5rem; flex-shrink: 0; border-radius: var(--ui-radius); } }
30
+ [data-scope=attachments][data-part=icon] { font-size: 26px; [data-layout=list] & { font-size: 18px; } }
31
+ [data-scope=attachments][data-part=ext] { position: absolute; bottom: 0.375rem; left: 50%; transform: translateX(-50%); padding: 0 0.375rem; border-radius: 4px;
32
+ background: var(--ui-surface); font-size: 10px; font-weight: 600; letter-spacing: 0.04em; color: var(--ui-muted);
33
+ [data-layout=list] & { display: none; } }
34
+ [data-scope=attachments][data-part=meta] { display: flex; flex-direction: column; min-width: 0; [data-layout=grid] & { padding: 0.5rem 0.625rem; } }
35
+ [data-scope=attachments][data-part=name] { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
36
+ [data-scope=attachments][data-part=size] { font-size: 12px; color: var(--ui-muted); }
37
+ /* Download and remove: over the tile's corner, shown on hover or focus. */
38
+ [data-scope=attachments][data-part=actions] { position: absolute; display: flex; gap: 0.25rem;
39
+ [data-layout=grid] & { top: 0.375rem; right: 0.375rem; opacity: 0; transition: opacity 120ms; }
40
+ [data-layout=grid] [data-part=file]:is(:hover, :focus-within) > & { opacity: 1; }
41
+ [data-layout=list] & { position: static; padding-right: 0.75rem; } }
42
+ [data-scope=attachments][data-part=action] { display: grid; place-items: center; width: 1.75rem; height: 1.75rem; padding: 0;
43
+ border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-muted); cursor: pointer;
44
+ text-decoration: none; box-shadow: var(--ui-card-shadow); font-size: 14px;
45
+ &:hover { color: var(--ui-text); }
46
+ &:focus-visible { outline: 2px solid var(--ui-ring-soft); }
47
+ [data-layout=list] & { border: none; box-shadow: none; background: transparent; &:hover { background: var(--ui-hover); } } }
48
+ [data-scope=attachments][data-part=label] { display: block; margin-bottom: 0.375rem; font-size: 14px; font-weight: 500; }
49
+ /* The label and the layout switch, above the files: the switch on the right. */
50
+ [data-scope=attachments][data-part=header] { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; margin-bottom: 0.5rem;
51
+ & > [data-part=label] { margin-bottom: 0; }
52
+ & > [data-scope=segment-group] { margin-left: auto; } }
53
+ /* In an invalid Field, the add tile's dashed border turns red. */
54
+ [data-scope=attachments][data-invalid] [data-part=add-tile] { border-color: var(--ui-danger); }
55
+ [data-scope=attachments][data-part=add-input] { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
56
+ [data-scope=attachments][data-part=add-tile] { display: flex; align-items: center; box-sizing: border-box; color: var(--ui-muted); cursor: pointer;
57
+ &:hover { color: var(--ui-text); }
58
+ &:focus-within { outline: 2px solid var(--ui-ring-soft); outline-offset: 2px; }
59
+ [data-layout=grid] & { flex-direction: column; justify-content: center; gap: 0.5rem; height: 100%; min-height: 6rem; padding: 0.75rem; text-align: center;
60
+ border: 1.5px dashed var(--ui-border); border-radius: var(--tile-radius);
61
+ &:hover { border-color: var(--ui-muted); background: var(--ui-surface); } }
62
+ [data-layout=grid][data-add-wide] & { flex-direction: row; min-height: 3rem; height: auto; padding-block: 0.625rem; gap: 0.75rem; }
63
+ [data-layout=grid][data-empty] & { min-height: 7rem; }
64
+ [data-layout=list] & { flex-grow: 1; gap: 0.75rem; padding: 0.625rem 0.75rem;
65
+ &:hover { background: color-mix(in srgb, var(--ui-hover) 60%, transparent); } } }
66
+ [data-scope=attachments][data-part=add-icon] { display: grid; place-items: center; flex-shrink: 0; font-size: 18px;
67
+ [data-layout=grid] & { width: 2.25rem; height: 2.25rem; border-radius: 9999px; background: var(--ui-hover); }
68
+ [data-layout=grid][data-add-wide] & { width: 1.75rem; height: 1.75rem; }
69
+ /* The add row lines its icon up with the files' previews. */
70
+ [data-layout=list] & { width: 2.5rem; height: 2.5rem; border-radius: var(--ui-radius); border: 1.5px dashed var(--ui-border); box-sizing: border-box; } }
71
+ [data-scope=attachments][data-part=add-text] { display: flex; flex-direction: column; min-width: 0;
72
+ [data-layout=grid] & { align-items: center; gap: 0.125rem; }
73
+ :is([data-layout=list], [data-add-wide]) & { flex-direction: row; gap: 0.5rem; align-items: baseline; } }
74
+ [data-scope=attachments][data-part=add-label] { font-weight: 500; }
75
+ [data-scope=attachments][data-part=limit] { font-size: 12px; color: var(--ui-muted); opacity: 0.8; }
76
+ [data-scope=attachments][data-part=rejected] { display: flex; flex-direction: column; gap: 0.125rem; margin: 0.5rem 0 0; padding: 0; list-style: none; font-size: 12px; color: var(--ui-danger); }
77
+ /* While files are dragged over: a tinted overlay saying what dropping does. */
78
+ [data-scope=attachments][data-part=drop-hint] { position: absolute; inset: -0.375rem; z-index: 5; display: flex; flex-direction: column; align-items: center;
79
+ justify-content: center; gap: 0.375rem; border-radius: calc(var(--ui-radius) + 4px); border: 2px dashed var(--ui-accent);
80
+ background: color-mix(in srgb, var(--ui-accent-soft) 85%, transparent); color: var(--ui-accent-soft-text); font-weight: 500; pointer-events: none; font-size: 20px;
81
+ & span { font-size: 14px; } }
82
+ [data-scope=attachments][data-part=preview] { display: block; max-width: 100%; max-height: 70vh; margin: 0 auto; border-radius: var(--ui-radius); }
83
+ [data-scope=attachments][data-part=download-link] { display: inline-flex; align-items: center; height: var(--ui-control-height-sm); padding: 0 0.75rem;
84
+ border-radius: var(--ui-radius); border: 1px solid var(--ui-border); color: var(--ui-text); text-decoration: none; font-size: 14px;
85
+ &:hover { background: var(--ui-hover); } }
86
+ }
@@ -0,0 +1,31 @@
1
+ import { Avatar as Ark } from '@ark-ui/react/avatar';
2
+ import 'iconify-icon';
3
+ import { type Classes } from '../utils.js';
4
+ import '../theme.css';
5
+ import './avatar.css';
6
+ export type AvatarColor = 'accent' | 'gray' | 'red' | 'orange' | 'amber' | 'green' | 'teal' | 'blue' | 'purple' | 'pink';
7
+ export type AvatarSize = 'sm' | 'md' | 'lg' | 'xl' | (string & {}) | number;
8
+ export type AvatarProps = Omit<Ark.RootProps, 'color'> & {
9
+ src?: string | null;
10
+ name?: string | null;
11
+ letters?: 1 | 2;
12
+ size?: AvatarSize;
13
+ square?: boolean;
14
+ color?: AvatarColor | 'auto';
15
+ tooltip?: boolean | string | null;
16
+ status?: 'online' | 'away' | 'busy' | 'offline' | (string & {}) | null;
17
+ statusLabel?: string;
18
+ classes?: Classes<'root' | 'image' | 'fallback' | 'person' | 'tooltipTarget' | 'status'>;
19
+ };
20
+ export declare const customSize: (size: AvatarSize) => string | undefined;
21
+ export declare function initialsOf(name: string | null | undefined, letters?: 1 | 2): string;
22
+ export declare function colorOf(name: string | null | undefined): AvatarColor;
23
+ declare function AvatarShortcut({ src, name, letters, size, square, color, tooltip, status, statusLabel, className, classes, style, ...rest }: AvatarProps): import("react").JSX.Element;
24
+ export declare const Avatar: typeof AvatarShortcut & {
25
+ Root: import("react").ForwardRefExoticComponent<Ark.RootProps & import("react").RefAttributes<HTMLDivElement>>;
26
+ RootProvider: import("react").ForwardRefExoticComponent<Ark.RootProviderProps & import("react").RefAttributes<HTMLDivElement>>;
27
+ Image: import("react").ForwardRefExoticComponent<Ark.ImageProps & import("react").RefAttributes<HTMLImageElement>>;
28
+ Fallback: import("react").ForwardRefExoticComponent<Ark.FallbackProps & import("react").RefAttributes<HTMLSpanElement>>;
29
+ Context: (props: Ark.ContextProps) => import("react").ReactNode;
30
+ };
31
+ export {};
@@ -0,0 +1,41 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Avatar as Ark } from '@ark-ui/react/avatar';
3
+ import 'iconify-icon';
4
+ import { Tooltip } from '../tooltip/Tooltip.js';
5
+ import { cx, partsOf, withParts } from '../utils.js';
6
+ import { useMessages } from '../format/LocaleProvider.js';
7
+ import '../theme.css';
8
+ import './avatar.css';
9
+ const named = ['sm', 'md', 'lg', 'xl'];
10
+ // A size that isn't one of the named ones, as a CSS length.
11
+ export const customSize = (size) => typeof size === 'number' ? `${size}px` : named.includes(size) ? undefined : size;
12
+ export function initialsOf(name, letters = 2) {
13
+ const words = String(name ?? '').trim().split(/\s+/).filter(Boolean);
14
+ if (!words.length)
15
+ return '';
16
+ if (letters === 1)
17
+ return words[0][0].toUpperCase();
18
+ return (words.length === 1 ? words[0].slice(0, 2) : words[0][0] + words[words.length - 1][0]).toUpperCase();
19
+ }
20
+ // FNV-1a, which spreads similar names well.
21
+ const autoColors = ['red', 'orange', 'amber', 'green', 'teal', 'blue', 'purple', 'pink'];
22
+ export function colorOf(name) {
23
+ const key = String(name ?? '').trim();
24
+ if (!key)
25
+ return 'accent';
26
+ let hash = 2166136261;
27
+ for (const ch of key)
28
+ hash = Math.imul(hash ^ ch.charCodeAt(0), 16777619) >>> 0;
29
+ return autoColors[hash % autoColors.length];
30
+ }
31
+ function AvatarShortcut({ src = null, name = null, letters = 2, size = 'md', square = false, color = 'accent', tooltip = null, status = null, statusLabel, className, classes, style, ...rest }) {
32
+ const t = useMessages();
33
+ const part = partsOf('avatar', classes);
34
+ const custom = customSize(size);
35
+ const initials = initialsOf(name, letters);
36
+ const tip = tooltip === true ? name : tooltip || null;
37
+ return (_jsxs(Ark.Root, { ...rest, ...part('root'), className: cx(className, classes?.root), "data-size": custom ? 'custom' : size, "data-square": square || undefined, "data-has-status": status ? '' : undefined, "data-color": color === 'auto' ? colorOf(name) : color, style: { ...style, ...(custom && { '--avatar-size': custom }) }, children: [src && _jsx(Ark.Image, { ...part('image'), src: src, alt: name ?? '' }), _jsx(Ark.Fallback, { ...part('fallback'), "aria-hidden": name ? undefined : 'true', children: initials || _jsx("iconify-icon", { ...part('person'), icon: "lucide:user" }) }), tip && _jsx(Tooltip, { content: tip, children: _jsx("span", { ...part('tooltip-target') }) }), status && (_jsx("span", { ...part('status'), "data-status": ['online', 'away', 'busy', 'offline'].includes(status) ? status : 'custom', role: "img", "aria-label": statusLabel ?? t.avatar.status[status] ?? status, style: ['online', 'away', 'busy', 'offline'].includes(status) ? undefined : { background: status } }))] }));
38
+ }
39
+ export const Avatar = withParts(AvatarShortcut, {
40
+ Root: Ark.Root, RootProvider: Ark.RootProvider, Image: Ark.Image, Fallback: Ark.Fallback, Context: Ark.Context,
41
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Avatars in an overlapping stack, each ringed in the page colour, with a
3
+ * "+3" for the rest.
4
+ *
5
+ * <AvatarGroup items={students} max={4} tooltip />
6
+ * <AvatarGroup size="sm"><Avatar name="Ada Lovelace" /><Avatar name="Alan Turing" /></AvatarGroup>
7
+ *
8
+ * - `items`: `{ name, src, color }` (or names), shown as Avatars; or put
9
+ * Avatars in it yourself
10
+ * - `max`: with `items`, show this many and a "+N" for the rest (its tooltip
11
+ * lists them)
12
+ * - `size`, `color`, `square`, `letters`: passed to each Avatar
13
+ * - `tooltip`: each name in a tooltip on hover
14
+ * - `spacing`: 'tight', 'normal' (default) or 'loose', how much they overlap
15
+ * - `label`: what the group is, for assistive tech ('3 students')
16
+ * - the ring is `--ui-avatar-ring` (the surface by default): set it to the
17
+ * background the group sits on
18
+ */
19
+ import type { HTMLAttributes } from 'react';
20
+ import { type AvatarColor, type AvatarSize } from './Avatar.js';
21
+ import { type Classes } from '../utils.js';
22
+ import '../theme.css';
23
+ import './avatar.css';
24
+ export type AvatarPerson = {
25
+ name?: string | null;
26
+ src?: string | null;
27
+ color?: AvatarColor | 'auto';
28
+ };
29
+ export type AvatarGroupProps = Omit<HTMLAttributes<HTMLDivElement>, 'color'> & {
30
+ items?: (AvatarPerson | string)[] | null;
31
+ max?: number | null;
32
+ size?: AvatarSize;
33
+ color?: AvatarColor | 'auto';
34
+ square?: boolean;
35
+ letters?: 1 | 2;
36
+ tooltip?: boolean;
37
+ spacing?: 'tight' | 'normal' | 'loose';
38
+ label?: string | null;
39
+ classes?: Classes<'root' | 'more'>;
40
+ };
41
+ export declare function AvatarGroup({ items, max, size, color, square, letters, tooltip, spacing, label, className, classes, children, ...rest }: AvatarGroupProps): import("react").JSX.Element;