@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.
- package/README.md +173 -2
- package/dist/accordion/Accordion.d.ts +65 -0
- package/dist/accordion/Accordion.js +61 -0
- package/dist/accordion/accordion.css +17 -0
- package/dist/action-bar/ActionBar.d.ts +33 -0
- package/dist/action-bar/ActionBar.js +48 -0
- package/dist/action-bar/action-bar.css +22 -0
- package/dist/alert/Alert.d.ts +28 -0
- package/dist/alert/Alert.js +12 -0
- package/dist/alert/alert.css +26 -0
- package/dist/app-shell/AppShell.d.ts +38 -0
- package/dist/app-shell/AppShell.js +92 -0
- package/dist/app-shell/Topbar.d.ts +20 -0
- package/dist/app-shell/Topbar.js +13 -0
- package/dist/app-shell/app-shell.css +41 -0
- package/dist/app-shell/shell.d.ts +9 -0
- package/dist/app-shell/shell.js +3 -0
- package/dist/attachments/Attachments.d.ts +84 -0
- package/dist/attachments/Attachments.js +235 -0
- package/dist/attachments/attachments.css +86 -0
- package/dist/avatar/Avatar.d.ts +31 -0
- package/dist/avatar/Avatar.js +41 -0
- package/dist/avatar/AvatarGroup.d.ts +41 -0
- package/dist/avatar/AvatarGroup.js +17 -0
- package/dist/avatar/avatar.css +73 -0
- package/dist/badge/Badge.d.ts +26 -0
- package/dist/badge/Badge.js +10 -0
- package/dist/badge/badge.css +24 -0
- package/dist/banner/Banner.d.ts +33 -0
- package/dist/banner/Banner.js +14 -0
- package/dist/banner/banner.css +25 -0
- package/dist/breadcrumbs/Breadcrumbs.d.ts +39 -0
- package/dist/breadcrumbs/Breadcrumbs.js +28 -0
- package/dist/breadcrumbs/breadcrumbs.css +18 -0
- package/dist/button/Button.d.ts +40 -0
- package/dist/button/Button.js +19 -0
- package/dist/button/button.css +63 -0
- package/dist/card/Card.d.ts +355 -0
- package/dist/card/Card.js +56 -0
- package/dist/card/card.css +43 -0
- package/dist/chart/AreaChart.d.ts +19 -0
- package/dist/chart/AreaChart.js +61 -0
- package/dist/chart/BarChart.d.ts +3 -0
- package/dist/chart/BarChart.js +60 -0
- package/dist/chart/ChartFrame.d.ts +25 -0
- package/dist/chart/ChartFrame.js +22 -0
- package/dist/chart/DonutChart.d.ts +20 -0
- package/dist/chart/DonutChart.js +41 -0
- package/dist/chart/Sparkline.d.ts +26 -0
- package/dist/chart/Sparkline.js +36 -0
- package/dist/chart/chart.css +51 -0
- package/dist/chart/chart.d.ts +23 -0
- package/dist/chart/chart.js +19 -0
- package/dist/checkbox/Checkbox.d.ts +50 -0
- package/dist/checkbox/Checkbox.js +19 -0
- package/dist/checkbox/CheckboxGroup.d.ts +44 -0
- package/dist/checkbox/CheckboxGroup.js +80 -0
- package/dist/checkbox/checkbox.css +49 -0
- package/dist/collapsible/Collapsible.d.ts +41 -0
- package/dist/collapsible/Collapsible.js +15 -0
- package/dist/collapsible/collapsible.css +20 -0
- package/dist/color-scheme.d.ts +18 -0
- package/dist/color-scheme.js +84 -0
- package/dist/command/Command.d.ts +64 -0
- package/dist/command/Command.js +162 -0
- package/dist/command/command.css +47 -0
- package/dist/confirm/confirm.css +5 -0
- package/dist/confirm/confirm.d.ts +42 -0
- package/dist/confirm/confirm.js +115 -0
- package/dist/copy-button/CopyButton.d.ts +30 -0
- package/dist/copy-button/CopyButton.js +23 -0
- package/dist/copy-button/copy-button.css +12 -0
- package/dist/css.d.ts +2 -0
- package/dist/data-list/DataList.d.ts +55 -0
- package/dist/data-list/DataList.js +43 -0
- package/dist/data-list/data-list.css +19 -0
- package/dist/date-picker/DatePicker.d.ts +95 -0
- package/dist/date-picker/DatePicker.js +237 -0
- package/dist/date-picker/date-picker.css +53 -0
- package/dist/dialog/Dialog.d.ts +98 -0
- package/dist/dialog/Dialog.js +92 -0
- package/dist/dialog/dialog.css +31 -0
- package/dist/divider/Divider.d.ts +27 -0
- package/dist/divider/Divider.js +8 -0
- package/dist/divider/divider.css +12 -0
- package/dist/editable/Editable.d.ts +47 -0
- package/dist/editable/Editable.js +51 -0
- package/dist/editable/editable.css +25 -0
- package/dist/editor/Editor.d.ts +59 -0
- package/dist/editor/Editor.js +220 -0
- package/dist/editor/editor.css +42 -0
- package/dist/editor/image.d.ts +1 -0
- package/dist/editor/image.js +23 -0
- package/dist/empty-state/EmptyState.d.ts +22 -0
- package/dist/empty-state/EmptyState.js +9 -0
- package/dist/empty-state/empty-state.css +14 -0
- package/dist/event-calendar/EventCalendar.d.ts +213 -0
- package/dist/event-calendar/EventCalendar.js +686 -0
- package/dist/event-calendar/dates.d.ts +8 -0
- package/dist/event-calendar/dates.js +21 -0
- package/dist/event-calendar/event-calendar.css +157 -0
- package/dist/field/Field.d.ts +66 -0
- package/dist/field/Field.js +23 -0
- package/dist/field/field.css +26 -0
- package/dist/file-upload/FileUpload.d.ts +60 -0
- package/dist/file-upload/FileUpload.js +102 -0
- package/dist/file-upload/file-upload.css +32 -0
- package/dist/format/FormatNumber.d.ts +42 -0
- package/dist/format/FormatNumber.js +59 -0
- package/dist/format/LocaleProvider.d.ts +33 -0
- package/dist/format/LocaleProvider.js +48 -0
- package/dist/format/format.d.ts +37 -0
- package/dist/format/format.js +74 -0
- package/dist/format/messages.d.ts +211 -0
- package/dist/format/messages.js +202 -0
- package/dist/highlight/Highlight.d.ts +11 -0
- package/dist/highlight/Highlight.js +14 -0
- package/dist/highlight/highlight.css +17 -0
- package/dist/hover-card/HoverCard.d.ts +47 -0
- package/dist/hover-card/HoverCard.js +18 -0
- package/dist/hover-card/hover-card.css +19 -0
- package/dist/index.d.ts +84 -0
- package/dist/index.js +85 -0
- package/dist/input/Input.d.ts +41 -0
- package/dist/input/Input.js +48 -0
- package/dist/input/input.css +25 -0
- package/dist/kbd/Kbd.d.ts +19 -0
- package/dist/kbd/Kbd.js +20 -0
- package/dist/kbd/kbd.css +8 -0
- package/dist/menu/Menu.d.ts +223 -0
- package/dist/menu/Menu.js +275 -0
- package/dist/menu/menu.css +38 -0
- package/dist/native-select/NativeSelect.d.ts +36 -0
- package/dist/native-select/NativeSelect.js +51 -0
- package/dist/native-select/native-select.css +16 -0
- package/dist/number-input/NumberInput.d.ts +56 -0
- package/dist/number-input/NumberInput.js +67 -0
- package/dist/number-input/number-input.css +21 -0
- package/dist/page/Page.d.ts +30 -0
- package/dist/page/Page.js +9 -0
- package/dist/page/page.css +17 -0
- package/dist/pagination/Pagination.d.ts +31 -0
- package/dist/pagination/Pagination.js +58 -0
- package/dist/pagination/pagination.css +15 -0
- package/dist/password-input/PasswordInput.d.ts +47 -0
- package/dist/password-input/PasswordInput.js +35 -0
- package/dist/password-input/password-input.css +18 -0
- package/dist/pin-input/PinInput.d.ts +45 -0
- package/dist/pin-input/PinInput.js +22 -0
- package/dist/pin-input/pin-input.css +13 -0
- package/dist/popover/Popover.d.ts +65 -0
- package/dist/popover/Popover.js +26 -0
- package/dist/popover/popover.css +16 -0
- package/dist/progress/Progress.d.ts +56 -0
- package/dist/progress/Progress.js +28 -0
- package/dist/progress/progress.css +24 -0
- package/dist/prose/Prose.d.ts +23 -0
- package/dist/prose/Prose.js +10 -0
- package/dist/prose/prose.css +29 -0
- package/dist/radio-group/RadioGroup.d.ts +59 -0
- package/dist/radio-group/RadioGroup.js +37 -0
- package/dist/radio-group/radio-group.css +30 -0
- package/dist/rolling-number/RollingNumber.d.ts +27 -0
- package/dist/rolling-number/RollingNumber.js +18 -0
- package/dist/rolling-number/rolling-number.css +10 -0
- package/dist/segment-group/Segmented.d.ts +68 -0
- package/dist/segment-group/Segmented.js +79 -0
- package/dist/segment-group/segment-group.css +25 -0
- package/dist/select/ListSelect.d.ts +5 -0
- package/dist/select/ListSelect.js +82 -0
- package/dist/select/SearchSelect.d.ts +43 -0
- package/dist/select/SearchSelect.js +219 -0
- package/dist/select/Select.d.ts +147 -0
- package/dist/select/Select.js +32 -0
- package/dist/select/items.d.ts +17 -0
- package/dist/select/items.js +14 -0
- package/dist/select/search-select.css +45 -0
- package/dist/select/select.css +52 -0
- package/dist/sheet/Sheet.d.ts +88 -0
- package/dist/sheet/Sheet.js +66 -0
- package/dist/sheet/sheet.css +44 -0
- package/dist/sidebar/NavGroup.d.ts +29 -0
- package/dist/sidebar/NavGroup.js +78 -0
- package/dist/sidebar/NavItem.d.ts +33 -0
- package/dist/sidebar/NavItem.js +50 -0
- package/dist/sidebar/NavSection.d.ts +20 -0
- package/dist/sidebar/NavSection.js +44 -0
- package/dist/sidebar/Sidebar.d.ts +65 -0
- package/dist/sidebar/Sidebar.js +59 -0
- package/dist/sidebar/SidebarUser.d.ts +31 -0
- package/dist/sidebar/SidebarUser.js +14 -0
- package/dist/sidebar/context.d.ts +13 -0
- package/dist/sidebar/context.js +6 -0
- package/dist/sidebar/sidebar.css +109 -0
- package/dist/skeleton/Skeleton.d.ts +25 -0
- package/dist/skeleton/Skeleton.js +8 -0
- package/dist/skeleton/skeleton.css +13 -0
- package/dist/slider/Slider.d.ts +70 -0
- package/dist/slider/Slider.js +65 -0
- package/dist/slider/slider.css +17 -0
- package/dist/sortable-list/SortableList.d.ts +46 -0
- package/dist/sortable-list/SortableList.js +85 -0
- package/dist/sortable-list/sortable-list.css +25 -0
- package/dist/spinner/Spinner.d.ts +18 -0
- package/dist/spinner/Spinner.js +11 -0
- package/dist/spinner/spinner.css +9 -0
- package/dist/stat/Stat.d.ts +42 -0
- package/dist/stat/Stat.js +21 -0
- package/dist/stat/Stats.d.ts +23 -0
- package/dist/stat/Stats.js +8 -0
- package/dist/stat/stat.css +34 -0
- package/dist/swatch-picker/SwatchPicker.d.ts +45 -0
- package/dist/swatch-picker/SwatchPicker.js +54 -0
- package/dist/swatch-picker/swatch-picker.css +38 -0
- package/dist/switch/Switch.d.ts +39 -0
- package/dist/switch/Switch.js +17 -0
- package/dist/switch/switch.css +13 -0
- package/dist/table/Table.d.ts +90 -0
- package/dist/table/Table.js +145 -0
- package/dist/table/table.css +44 -0
- package/dist/tabs/Tabs.d.ts +87 -0
- package/dist/tabs/Tabs.js +90 -0
- package/dist/tabs/tabs.css +53 -0
- package/dist/tags-input/TagsInput.d.ts +69 -0
- package/dist/tags-input/TagsInput.js +95 -0
- package/dist/tags-input/tags-input.css +37 -0
- package/dist/textarea/Textarea.d.ts +24 -0
- package/dist/textarea/Textarea.js +51 -0
- package/dist/textarea/textarea.css +14 -0
- package/dist/theme-toggle/ThemeToggle.d.ts +49 -0
- package/dist/theme-toggle/ThemeToggle.js +71 -0
- package/dist/theme-toggle/theme-toggle.css +23 -0
- package/dist/theme.css +119 -0
- package/dist/time-input/TimeInput.d.ts +25 -0
- package/dist/time-input/TimeInput.js +20 -0
- package/dist/timeline/Timeline.d.ts +50 -0
- package/dist/timeline/Timeline.js +15 -0
- package/dist/timeline/timeline.css +29 -0
- package/dist/toast/Toaster.d.ts +36 -0
- package/dist/toast/Toaster.js +25 -0
- package/dist/toast/toast.css +33 -0
- package/dist/tooltip/Tooltip.d.ts +55 -0
- package/dist/tooltip/Tooltip.js +48 -0
- package/dist/tooltip/tooltip.css +18 -0
- package/dist/tree-view/TreeView.d.ts +86 -0
- package/dist/tree-view/TreeView.js +74 -0
- package/dist/tree-view/tree-view.css +29 -0
- package/dist/utils.d.ts +19 -0
- package/dist/utils.js +59 -0
- 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,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;
|