@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,92 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* A modal dialog, rendered at the end of <body> so no ancestor's stacking or
|
|
4
|
+
* overflow can trap it.
|
|
5
|
+
*
|
|
6
|
+
* <Dialog heading="Delete lesson?" description="This cannot be undone."
|
|
7
|
+
* trigger={<Button>Delete</Button>}
|
|
8
|
+
* footer={<Button onClick={…}>Cancel</Button>}>
|
|
9
|
+
* …body…
|
|
10
|
+
* </Dialog>
|
|
11
|
+
*
|
|
12
|
+
* Focus moves in and is trapped while it is open, the page behind doesn't
|
|
13
|
+
* scroll, Escape or a click on the backdrop closes it, and focus returns to
|
|
14
|
+
* the trigger.
|
|
15
|
+
*
|
|
16
|
+
* - `open`, `defaultOpen`; `onOpenChange`: called with the new open state;
|
|
17
|
+
* `onExitComplete`: called once it has animated out
|
|
18
|
+
* - `trigger`: the element that opens it (optional; it gets the trigger's props,
|
|
19
|
+
* so a component must pass them and its ref on, as Button does)
|
|
20
|
+
* - `heading`, `description`: label and describe it for assistive tech
|
|
21
|
+
* - `footer`: buttons along the bottom
|
|
22
|
+
* - `alert`: an alertdialog, for confirmations; the backdrop doesn't close it
|
|
23
|
+
* - `size`: 'sm', 'md' (default) or 'lg'
|
|
24
|
+
* - `closable`: false hides the close button
|
|
25
|
+
*
|
|
26
|
+
* Or composed, with the overlay still done for you (backdrop, positioning,
|
|
27
|
+
* close button), from Header, Body and Footer; a Dialog.Trigger child opens it:
|
|
28
|
+
*
|
|
29
|
+
* <Dialog>
|
|
30
|
+
* <Dialog.Trigger asChild><Button>Edit</Button></Dialog.Trigger>
|
|
31
|
+
* <Dialog.Header heading="Edit student" description="Saved to their profile." />
|
|
32
|
+
* <Dialog.Body>…</Dialog.Body>
|
|
33
|
+
* <Dialog.Footer><Dialog.CloseTrigger asChild><Button>Cancel</Button></Dialog.CloseTrigger>…</Dialog.Footer>
|
|
34
|
+
* </Dialog>
|
|
35
|
+
*
|
|
36
|
+
* Fully by hand, with Ark's parts (and Header, Body and Footer for the layout):
|
|
37
|
+
*
|
|
38
|
+
* <Dialog.Root>
|
|
39
|
+
* <Dialog.Trigger asChild><Button>Open</Button></Dialog.Trigger>
|
|
40
|
+
* <Portal>
|
|
41
|
+
* <Dialog.Backdrop />
|
|
42
|
+
* <Dialog.Positioner>
|
|
43
|
+
* <Dialog.Content>
|
|
44
|
+
* <Dialog.Header><Dialog.Title>Title</Dialog.Title></Dialog.Header>
|
|
45
|
+
* <Dialog.Body>…</Dialog.Body>
|
|
46
|
+
* <Dialog.Footer><Dialog.CloseTrigger asChild><Button>Close</Button></Dialog.CloseTrigger></Dialog.Footer>
|
|
47
|
+
* </Dialog.Content>
|
|
48
|
+
* </Dialog.Positioner>
|
|
49
|
+
* </Portal>
|
|
50
|
+
* </Dialog.Root>
|
|
51
|
+
*/
|
|
52
|
+
import { Children, isValidElement } from 'react';
|
|
53
|
+
import { Dialog as Ark } from '@ark-ui/react/dialog';
|
|
54
|
+
import { Portal } from '@ark-ui/react/portal';
|
|
55
|
+
import 'iconify-icon';
|
|
56
|
+
import { cx, on, partOf, partsOf, withParts } from '../utils.js';
|
|
57
|
+
import { useMessages } from '../format/LocaleProvider.js';
|
|
58
|
+
import '../theme.css';
|
|
59
|
+
import './dialog.css';
|
|
60
|
+
function Header({ heading = null, description = null, children, ...rest }) {
|
|
61
|
+
return (_jsxs("div", { "data-scope": "dialog", "data-part": "header", ...rest, children: [heading && _jsx(Ark.Title, { children: heading }), description && _jsx(Ark.Description, { children: description }), children] }));
|
|
62
|
+
}
|
|
63
|
+
const Body = partOf('dialog', 'body');
|
|
64
|
+
const Footer = partOf('dialog', 'footer');
|
|
65
|
+
const layout = [Header, Body, Footer];
|
|
66
|
+
function DialogShortcut({ open, defaultOpen, onOpenChange, onExitComplete, heading = null, description = null, alert = false, size = 'md', closable = true, trigger = null, footer = null, className, classes, children, ...rest }) {
|
|
67
|
+
const t = useMessages();
|
|
68
|
+
const part = partsOf('dialog', classes);
|
|
69
|
+
// Composed from Dialog.Header, Body and Footer: they go straight into the
|
|
70
|
+
// dialog (not wrapped as its body), and a Dialog.Trigger goes before it.
|
|
71
|
+
const nodes = Children.toArray(children);
|
|
72
|
+
const composed = nodes.some((node) => isValidElement(node) && layout.includes(node.type));
|
|
73
|
+
const triggers = nodes.filter((node) => isValidElement(node) && node.type === Ark.Trigger);
|
|
74
|
+
const content = composed ? nodes.filter((node) => !triggers.includes(node)) : children;
|
|
75
|
+
// Left out when not given: Zag reads undefined as a value.
|
|
76
|
+
const state = { ...(open !== undefined && { open }), ...(defaultOpen !== undefined && { defaultOpen }) };
|
|
77
|
+
return (_jsxs(Ark.Root, { ...state, ...on('onOpenChange', onOpenChange, (details) => details.open), ...(onExitComplete && { onExitComplete }), role: alert ? 'alertdialog' : 'dialog', closeOnInteractOutside: !alert, lazyMount: true, unmountOnExit: true, children: [trigger && _jsx(Ark.Trigger, { asChild: true, children: trigger }), composed && triggers, _jsxs(Portal, { children: [_jsx(Ark.Backdrop, { ...part('backdrop') }), _jsx(Ark.Positioner, { ...part('positioner'), children: _jsxs(Ark.Content, { ...rest, ...part('content'), className: cx(className, classes?.content), "data-size": size, children: [closable && _jsx(Ark.CloseTrigger, { ...part('close-trigger'), "aria-label": t.common.close, children: _jsx("iconify-icon", { icon: "lucide:x" }) }), (heading || description) && (_jsxs("div", { ...part('header'), children: [heading && _jsx(Ark.Title, { ...part('title'), children: heading }), description && _jsx(Ark.Description, { ...part('description'), children: description })] })), composed ? content : children != null && _jsx("div", { ...part('body'), children: children }), footer && _jsx("div", { ...part('footer'), children: footer })] }) })] })] }));
|
|
78
|
+
}
|
|
79
|
+
export const Dialog = withParts(DialogShortcut, {
|
|
80
|
+
Root: Ark.Root,
|
|
81
|
+
Trigger: Ark.Trigger,
|
|
82
|
+
Backdrop: Ark.Backdrop,
|
|
83
|
+
Positioner: Ark.Positioner,
|
|
84
|
+
Content: Ark.Content,
|
|
85
|
+
CloseTrigger: Ark.CloseTrigger,
|
|
86
|
+
Title: Ark.Title,
|
|
87
|
+
Description: Ark.Description,
|
|
88
|
+
Context: Ark.Context,
|
|
89
|
+
Header,
|
|
90
|
+
Body,
|
|
91
|
+
Footer,
|
|
92
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
@layer ui {
|
|
2
|
+
[data-scope=dialog][data-part=backdrop] { position: fixed; inset: 0; z-index: 100; background: rgba(0, 0, 0, 0.45);
|
|
3
|
+
&[data-state=open] { animation: ui-fade-in 150ms ease-out; }
|
|
4
|
+
&[data-state=closed] { animation: ui-fade-out 120ms ease-in forwards; } }
|
|
5
|
+
/* Covers the screen to centre the panel, but lets clicks through to the backdrop. */
|
|
6
|
+
[data-scope=dialog][data-part=positioner] { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: 1rem; pointer-events: none; }
|
|
7
|
+
[data-scope=dialog][data-part=content] {
|
|
8
|
+
position: relative; display: flex; flex-direction: column; width: 100%; max-width: 32rem; max-height: calc(100vh - 32px); box-sizing: border-box;
|
|
9
|
+
background: var(--ui-surface); color: var(--ui-text); font-family: var(--ui-font); font-size: 14px;
|
|
10
|
+
border: 1px solid var(--ui-border); border-radius: calc(var(--ui-radius) + 4px); box-shadow: var(--ui-shadow); outline: none; pointer-events: auto;
|
|
11
|
+
&[data-size=sm] { max-width: 24rem; }
|
|
12
|
+
&[data-size=lg] { max-width: 44rem; }
|
|
13
|
+
&[data-state=open] { animation: ui-dialog-in 180ms ease-out; }
|
|
14
|
+
&[data-state=closed] { animation: ui-dialog-out 120ms ease-in forwards; }
|
|
15
|
+
}
|
|
16
|
+
[data-scope=dialog][data-part=header] { padding: 1.5rem 3rem 0 1.5rem; }
|
|
17
|
+
[data-scope=dialog][data-part=title] { margin: 0; font-size: 18px; font-weight: 600; }
|
|
18
|
+
[data-scope=dialog][data-part=description] { margin: 0.375rem 0 0; color: var(--ui-muted); }
|
|
19
|
+
[data-scope=dialog][data-part=body] { padding: 1rem 1.5rem; overflow-y: auto;
|
|
20
|
+
&:first-child, [data-part=close-trigger] + & { padding-top: 1.5rem; } }
|
|
21
|
+
[data-scope=dialog][data-part=footer] { display: flex; justify-content: flex-end; gap: 0.5rem; flex-wrap: wrap; padding: 0 1.5rem 1.5rem;
|
|
22
|
+
[data-part=header] + & { padding-top: 1.5rem; } }
|
|
23
|
+
[data-scope=dialog][data-part=close-trigger] { position: absolute; top: 1rem; right: 1rem; display: grid; place-items: center; width: 2rem; height: 2rem;
|
|
24
|
+
border: none; background: transparent; border-radius: 6px; color: var(--ui-muted); cursor: pointer; font-size: 16px;
|
|
25
|
+
&:hover { background: var(--ui-hover); color: var(--ui-text); }
|
|
26
|
+
&:focus-visible { outline: 2px solid var(--ui-ring-soft); } }
|
|
27
|
+
@keyframes ui-fade-in { from { opacity: 0; } }
|
|
28
|
+
@keyframes ui-fade-out { to { opacity: 0; } }
|
|
29
|
+
@keyframes ui-dialog-in { from { opacity: 0; transform: translateY(8px) scale(0.97); } }
|
|
30
|
+
@keyframes ui-dialog-out { to { opacity: 0; transform: translateY(8px) scale(0.97); } }
|
|
31
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A line between things: across (between sections) or up (between toolbar
|
|
3
|
+
* buttons), with an optional label in the middle ('or', 'Today').
|
|
4
|
+
*
|
|
5
|
+
* <Divider />
|
|
6
|
+
* <Divider label="or" />
|
|
7
|
+
* <Divider orientation="vertical" />
|
|
8
|
+
*
|
|
9
|
+
* - `label`: text (or anything) in the line; `align`: 'center' (default),
|
|
10
|
+
* 'start' or 'end'
|
|
11
|
+
* - `orientation`: 'horizontal' (default) or 'vertical' (as tall as its row)
|
|
12
|
+
* - `variant`: 'solid' (default), 'dashed' or 'dotted'
|
|
13
|
+
* - `spacing`: the room either side, a CSS length (0.75rem across, 0.5rem up)
|
|
14
|
+
*/
|
|
15
|
+
import type { HTMLAttributes, ReactNode } from 'react';
|
|
16
|
+
import { type Classes } from '../utils.js';
|
|
17
|
+
import '../theme.css';
|
|
18
|
+
import './divider.css';
|
|
19
|
+
export type DividerProps = HTMLAttributes<HTMLDivElement> & {
|
|
20
|
+
label?: ReactNode;
|
|
21
|
+
align?: 'start' | 'center' | 'end';
|
|
22
|
+
orientation?: 'horizontal' | 'vertical';
|
|
23
|
+
variant?: 'solid' | 'dashed' | 'dotted';
|
|
24
|
+
spacing?: string | null;
|
|
25
|
+
classes?: Classes<'root' | 'label'>;
|
|
26
|
+
};
|
|
27
|
+
export declare function Divider({ label, align, orientation, variant, spacing, className, classes, style, ...rest }: DividerProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cx, partsOf } from '../utils.js';
|
|
3
|
+
import '../theme.css';
|
|
4
|
+
import './divider.css';
|
|
5
|
+
export function Divider({ label = null, align = 'center', orientation = 'horizontal', variant = 'solid', spacing = null, className, classes, style, ...rest }) {
|
|
6
|
+
const part = partsOf('divider', classes);
|
|
7
|
+
return (_jsx("div", { ...rest, ...part('root'), className: cx(className, classes?.root), role: "separator", "aria-orientation": orientation, "data-orientation": orientation, "data-variant": variant, "data-align": align, "data-labelled": label ? '' : undefined, style: { ...style, ...(spacing && { '--spacing': spacing }) }, children: label && _jsx("span", { ...part('label'), children: label }) }));
|
|
8
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
@layer ui {
|
|
2
|
+
[data-scope=divider][data-part=root] { --line: 1px var(--style, solid) var(--ui-border); color: var(--ui-muted); font-family: var(--ui-font);
|
|
3
|
+
&[data-variant=dashed] { --style: dashed; }
|
|
4
|
+
&[data-variant=dotted] { --style: dotted; }
|
|
5
|
+
&[data-orientation=horizontal] { margin: var(--spacing, 0.75rem) 0; border-top: var(--line);
|
|
6
|
+
&[data-labelled] { display: flex; align-items: center; gap: 0.75rem; border-top: none;
|
|
7
|
+
&::before, &::after { content: ''; flex: 1; border-top: var(--line); }
|
|
8
|
+
&[data-align=start]::before { flex: 0 0 1rem; }
|
|
9
|
+
&[data-align=end]::after { flex: 0 0 1rem; } } }
|
|
10
|
+
&[data-orientation=vertical] { align-self: stretch; width: 0; min-height: 1em; margin: 0 var(--spacing, 0.5rem); border-left: var(--line); } }
|
|
11
|
+
[data-scope=divider][data-part=label] { flex-shrink: 0; font-size: 12px; font-weight: 500; white-space: nowrap; }
|
|
12
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text you click to edit in place, e.g. a pet's name or a price on a details
|
|
3
|
+
* page: it reads as text until clicked, then Enter saves and Escape cancels.
|
|
4
|
+
*
|
|
5
|
+
* <Editable value={pet.name} onValueChange={(name) => save({ name })} />
|
|
6
|
+
*
|
|
7
|
+
* - `value`, `defaultValue`: the saved text; `onValueChange`: called with it
|
|
8
|
+
* when an edit is saved (not on each key)
|
|
9
|
+
* - `placeholder`: shown when it's empty
|
|
10
|
+
* - `label`: a label above it (skipped inside a Field)
|
|
11
|
+
* - `activationMode`: 'click' (default), 'dblclick' or 'focus'
|
|
12
|
+
* - `controls`: ✓ and ✕ buttons while editing (true); `submitMode`: 'enter'
|
|
13
|
+
* (default), 'blur', 'both' or 'none' saves on those
|
|
14
|
+
* - `disabled`, `readOnly`
|
|
15
|
+
*
|
|
16
|
+
* By hand, Ark's parts are here too: Editable.Root, Area, Input, Preview,
|
|
17
|
+
* Control, EditTrigger, SubmitTrigger, CancelTrigger, Label.
|
|
18
|
+
*/
|
|
19
|
+
import { type ReactNode } from 'react';
|
|
20
|
+
import { Editable as Ark } from '@ark-ui/react/editable';
|
|
21
|
+
import 'iconify-icon';
|
|
22
|
+
import { type Classes } from '../utils.js';
|
|
23
|
+
import '../theme.css';
|
|
24
|
+
import './editable.css';
|
|
25
|
+
export type EditableProps = Omit<Ark.RootProps, 'value' | 'defaultValue' | 'onValueChange' | 'onValueCommit' | 'onValueRevert'> & {
|
|
26
|
+
value?: string;
|
|
27
|
+
defaultValue?: string;
|
|
28
|
+
onValueChange?: (value: string) => void;
|
|
29
|
+
label?: ReactNode;
|
|
30
|
+
controls?: boolean;
|
|
31
|
+
classes?: Classes<'root' | 'label' | 'area' | 'input' | 'preview' | 'control' | 'editTrigger' | 'submitTrigger' | 'cancelTrigger'>;
|
|
32
|
+
};
|
|
33
|
+
declare function EditableShortcut({ value, defaultValue, onValueChange, label, controls, activationMode, placeholder, className, classes, ...rest }: EditableProps): import("react").JSX.Element;
|
|
34
|
+
export declare const Editable: typeof EditableShortcut & {
|
|
35
|
+
Root: import("react").ForwardRefExoticComponent<Ark.RootProps & import("react").RefAttributes<HTMLDivElement>>;
|
|
36
|
+
RootProvider: import("react").ForwardRefExoticComponent<Ark.RootProviderProps & import("react").RefAttributes<HTMLDivElement>>;
|
|
37
|
+
Area: import("react").ForwardRefExoticComponent<Ark.AreaProps & import("react").RefAttributes<HTMLDivElement>>;
|
|
38
|
+
Input: import("react").ForwardRefExoticComponent<Ark.InputProps & import("react").RefAttributes<HTMLInputElement>>;
|
|
39
|
+
Preview: import("react").ForwardRefExoticComponent<Ark.PreviewProps & import("react").RefAttributes<HTMLSpanElement>>;
|
|
40
|
+
Control: import("react").ForwardRefExoticComponent<Ark.ControlProps & import("react").RefAttributes<HTMLDivElement>>;
|
|
41
|
+
EditTrigger: import("react").ForwardRefExoticComponent<Ark.EditTriggerProps & import("react").RefAttributes<HTMLButtonElement>>;
|
|
42
|
+
SubmitTrigger: import("react").ForwardRefExoticComponent<Ark.SubmitTriggerProps & import("react").RefAttributes<HTMLButtonElement>>;
|
|
43
|
+
CancelTrigger: import("react").ForwardRefExoticComponent<Ark.CancelTriggerProps & import("react").RefAttributes<HTMLButtonElement>>;
|
|
44
|
+
Label: import("react").ForwardRefExoticComponent<Ark.LabelProps & import("react").RefAttributes<HTMLLabelElement>>;
|
|
45
|
+
Context: (props: Ark.ContextProps) => ReactNode;
|
|
46
|
+
};
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Text you click to edit in place, e.g. a pet's name or a price on a details
|
|
4
|
+
* page: it reads as text until clicked, then Enter saves and Escape cancels.
|
|
5
|
+
*
|
|
6
|
+
* <Editable value={pet.name} onValueChange={(name) => save({ name })} />
|
|
7
|
+
*
|
|
8
|
+
* - `value`, `defaultValue`: the saved text; `onValueChange`: called with it
|
|
9
|
+
* when an edit is saved (not on each key)
|
|
10
|
+
* - `placeholder`: shown when it's empty
|
|
11
|
+
* - `label`: a label above it (skipped inside a Field)
|
|
12
|
+
* - `activationMode`: 'click' (default), 'dblclick' or 'focus'
|
|
13
|
+
* - `controls`: ✓ and ✕ buttons while editing (true); `submitMode`: 'enter'
|
|
14
|
+
* (default), 'blur', 'both' or 'none' saves on those
|
|
15
|
+
* - `disabled`, `readOnly`
|
|
16
|
+
*
|
|
17
|
+
* By hand, Ark's parts are here too: Editable.Root, Area, Input, Preview,
|
|
18
|
+
* Control, EditTrigger, SubmitTrigger, CancelTrigger, Label.
|
|
19
|
+
*/
|
|
20
|
+
import { useState } from 'react';
|
|
21
|
+
import { Editable as Ark } from '@ark-ui/react/editable';
|
|
22
|
+
import { useFieldContext } from '@ark-ui/react/field';
|
|
23
|
+
import 'iconify-icon';
|
|
24
|
+
import { cx, partsOf, withParts } from '../utils.js';
|
|
25
|
+
import { useMessages } from '../format/LocaleProvider.js';
|
|
26
|
+
import '../theme.css';
|
|
27
|
+
import './editable.css';
|
|
28
|
+
function EditableShortcut({ value, defaultValue = '', onValueChange, label = null, controls = true, activationMode = 'click', placeholder, className, classes, ...rest }) {
|
|
29
|
+
const t = useMessages();
|
|
30
|
+
const field = useFieldContext();
|
|
31
|
+
const part = partsOf('editable', classes);
|
|
32
|
+
// The saved text (ours, or the caller's), and the draft being typed.
|
|
33
|
+
const [own, setOwn] = useState(defaultValue);
|
|
34
|
+
const saved = value ?? own;
|
|
35
|
+
const [draft, setDraft] = useState(saved);
|
|
36
|
+
const [editing, setEditing] = useState(false);
|
|
37
|
+
// A new saved value from outside shows when not editing.
|
|
38
|
+
const [seen, setSeen] = useState(saved);
|
|
39
|
+
if (saved !== seen) {
|
|
40
|
+
setSeen(saved);
|
|
41
|
+
if (!editing)
|
|
42
|
+
setDraft(saved);
|
|
43
|
+
}
|
|
44
|
+
return (_jsxs(Ark.Root, { ...(t.zag.editable && { translations: t.zag.editable }), ...rest, ...part('root'), className: cx(className, classes?.root), activationMode: activationMode, ...(placeholder !== undefined && { placeholder }), value: draft, onValueChange: (d) => setDraft(d.value), edit: editing, onEditChange: (d) => setEditing(d.edit), onValueCommit: (d) => { if (value === undefined)
|
|
45
|
+
setOwn(d.value); if (d.value !== saved)
|
|
46
|
+
onValueChange?.(d.value); }, onValueRevert: () => setDraft(saved), children: [label && !field && _jsx(Ark.Label, { ...part('label'), children: label }), _jsxs("div", { ...part('row'), children: [_jsxs(Ark.Area, { ...part('area'), children: [_jsx(Ark.Input, { ...part('input') }), _jsx(Ark.Preview, { ...part('preview') })] }), controls && (_jsx(Ark.Control, { ...part('control'), children: editing ? _jsxs(_Fragment, { children: [_jsx(Ark.SubmitTrigger, { ...part('submit-trigger'), "aria-label": t.common.save, children: _jsx("iconify-icon", { icon: "lucide:check" }) }), _jsx(Ark.CancelTrigger, { ...part('cancel-trigger'), "aria-label": t.common.cancel, children: _jsx("iconify-icon", { icon: "lucide:x" }) })] }) : _jsx(Ark.EditTrigger, { ...part('edit-trigger'), "aria-label": t.common.edit, children: _jsx("iconify-icon", { icon: "lucide:pencil" }) }) }))] })] }));
|
|
47
|
+
}
|
|
48
|
+
export const Editable = withParts(EditableShortcut, {
|
|
49
|
+
Root: Ark.Root, RootProvider: Ark.RootProvider, Area: Ark.Area, Input: Ark.Input, Preview: Ark.Preview, Control: Ark.Control,
|
|
50
|
+
EditTrigger: Ark.EditTrigger, SubmitTrigger: Ark.SubmitTrigger, CancelTrigger: Ark.CancelTrigger, Label: Ark.Label, Context: Ark.Context,
|
|
51
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
@layer ui {
|
|
2
|
+
[data-scope=editable][data-part=root] { display: block; min-width: 0; color: var(--ui-text); font-family: var(--ui-font); font-size: 14px; }
|
|
3
|
+
[data-scope=editable][data-part=label] { display: block; margin-bottom: 0.25rem; font-size: 14px; font-weight: 500; }
|
|
4
|
+
[data-scope=editable][data-part=row] { display: flex; align-items: center; gap: 0.25rem; min-width: 0; }
|
|
5
|
+
[data-scope=editable][data-part=area] { flex: 1; min-width: 0; }
|
|
6
|
+
/* As text until clicked: a faint box on hover says it can be edited. */
|
|
7
|
+
[data-scope=editable][data-part=preview] { display: block; margin: 0 -0.375rem; padding: 0.25rem 0.375rem; border-radius: calc(var(--ui-radius) - 2px);
|
|
8
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: text;
|
|
9
|
+
&:hover { background: var(--ui-hover); }
|
|
10
|
+
&[data-placeholder-shown] { color: var(--ui-muted); }
|
|
11
|
+
[data-disabled] & { cursor: default; background: transparent; } }
|
|
12
|
+
[data-scope=editable][data-part=input] { width: 100%; box-sizing: border-box; margin: 0 -0.375rem; padding: 0.25rem 0.375rem;
|
|
13
|
+
border: 1px solid var(--ui-ring); border-radius: calc(var(--ui-radius) - 2px); outline: 2px solid var(--ui-ring-soft);
|
|
14
|
+
background: var(--ui-surface); color: inherit; font: inherit; }
|
|
15
|
+
[data-scope=editable][data-part=control] { display: flex; gap: 0.125rem; flex-shrink: 0; }
|
|
16
|
+
[data-scope=editable]:is([data-part=edit-trigger], [data-part=submit-trigger], [data-part=cancel-trigger]) {
|
|
17
|
+
display: grid; place-items: center; width: 1.75rem; height: 1.75rem; padding: 0; border: none; border-radius: calc(var(--ui-radius) - 2px);
|
|
18
|
+
background: transparent; color: var(--ui-muted); font-size: 14px; cursor: pointer;
|
|
19
|
+
&:hover { background: var(--ui-hover); color: var(--ui-text); }
|
|
20
|
+
&:focus-visible { outline: 2px solid var(--ui-ring-soft); } }
|
|
21
|
+
/* The pencil shows on hover (and when focused), to keep the text clean. */
|
|
22
|
+
[data-scope=editable][data-part=edit-trigger] { opacity: 0;
|
|
23
|
+
[data-part=root]:hover &, &:focus-visible { opacity: 1; } }
|
|
24
|
+
[data-scope=editable][data-part=submit-trigger] { color: var(--ui-accent); }
|
|
25
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A rich text editor on TipTap (ProseMirror): a toolbar over an editable area
|
|
3
|
+
* whose value is HTML.
|
|
4
|
+
*
|
|
5
|
+
* <Editor label="Notes" placeholder="Lesson notes…" value={notes} onValueChange={setNotes} />
|
|
6
|
+
*
|
|
7
|
+
* Inside a Field it takes the field's label, hint and error.
|
|
8
|
+
*
|
|
9
|
+
* - `value`, `defaultValue`: the HTML ('' when empty); `onValueChange`
|
|
10
|
+
* - `label`: a label above it (skipped inside a Field)
|
|
11
|
+
* - `tools`: which toolbar buttons, in order ('|' for a divider): bold,
|
|
12
|
+
* italic, underline, strike, code, h2, h3, bulletList, orderedList,
|
|
13
|
+
* blockquote, link, image, undo, redo
|
|
14
|
+
* - `toolbar`: false hides the toolbar
|
|
15
|
+
* - `placeholder`: shown while it's empty
|
|
16
|
+
* - `maxLength`: a character limit, with a counter
|
|
17
|
+
* - `minHeight`, `maxHeight`: CSS lengths; it scrolls past maxHeight
|
|
18
|
+
* - `disabled`: read only
|
|
19
|
+
* - `uploadImage`: an async function taking a File and returning its URL, or
|
|
20
|
+
* `{ src, alt, title, data-* }` (data-* kept on the <img>, e.g. a signed id
|
|
21
|
+
* for your server). With it, images can be dropped, pasted or picked with
|
|
22
|
+
* the image button, and are inserted once uploaded; without it, dropped
|
|
23
|
+
* and pasted files are ignored. A failed upload calls `onError`
|
|
24
|
+
* - `editorRef`: a ref given the TipTap editor, for anything more
|
|
25
|
+
*
|
|
26
|
+
* Keyboard shortcuts are TipTap's (⌘B, ⌘I, ⌘U, ⌘⇧7/8 for lists, ⌘Z…).
|
|
27
|
+
*/
|
|
28
|
+
import { type HTMLAttributes, type ReactNode, type RefObject } from 'react';
|
|
29
|
+
import { Editor as TipTap } from '@tiptap/core';
|
|
30
|
+
import 'iconify-icon';
|
|
31
|
+
import { type Classes } from '../utils.js';
|
|
32
|
+
import '../theme.css';
|
|
33
|
+
import './editor.css';
|
|
34
|
+
export type EditorTool = 'bold' | 'italic' | 'underline' | 'strike' | 'code' | 'h2' | 'h3' | 'bulletList' | 'orderedList' | 'blockquote' | 'link' | 'image' | 'undo' | 'redo' | '|';
|
|
35
|
+
export type UploadedImage = string | {
|
|
36
|
+
src: string;
|
|
37
|
+
alt?: string;
|
|
38
|
+
title?: string;
|
|
39
|
+
[data: `data-${string}`]: string;
|
|
40
|
+
};
|
|
41
|
+
export declare const defaultTools: EditorTool[];
|
|
42
|
+
export type EditorProps = Omit<HTMLAttributes<HTMLDivElement>, 'defaultValue' | 'onError'> & {
|
|
43
|
+
value?: string;
|
|
44
|
+
defaultValue?: string;
|
|
45
|
+
onValueChange?: (html: string) => void;
|
|
46
|
+
label?: ReactNode;
|
|
47
|
+
placeholder?: string;
|
|
48
|
+
tools?: EditorTool[];
|
|
49
|
+
toolbar?: boolean;
|
|
50
|
+
maxLength?: number | null;
|
|
51
|
+
minHeight?: string;
|
|
52
|
+
maxHeight?: string | null;
|
|
53
|
+
disabled?: boolean;
|
|
54
|
+
uploadImage?: ((file: File) => Promise<UploadedImage | null | undefined>) | null;
|
|
55
|
+
onError?: (error: unknown) => void;
|
|
56
|
+
editorRef?: RefObject<TipTap | null>;
|
|
57
|
+
classes?: Classes<'label' | 'root' | 'toolbar' | 'divider' | 'tool' | 'imageInput' | 'content' | 'uploading' | 'count' | 'linkForm' | 'linkActions'>;
|
|
58
|
+
};
|
|
59
|
+
export declare function Editor({ value, defaultValue, onValueChange, label, placeholder, tools, toolbar, maxLength, minHeight, maxHeight, disabled, uploadImage, onError, editorRef, className, classes, ...rest }: EditorProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* A rich text editor on TipTap (ProseMirror): a toolbar over an editable area
|
|
4
|
+
* whose value is HTML.
|
|
5
|
+
*
|
|
6
|
+
* <Editor label="Notes" placeholder="Lesson notes…" value={notes} onValueChange={setNotes} />
|
|
7
|
+
*
|
|
8
|
+
* Inside a Field it takes the field's label, hint and error.
|
|
9
|
+
*
|
|
10
|
+
* - `value`, `defaultValue`: the HTML ('' when empty); `onValueChange`
|
|
11
|
+
* - `label`: a label above it (skipped inside a Field)
|
|
12
|
+
* - `tools`: which toolbar buttons, in order ('|' for a divider): bold,
|
|
13
|
+
* italic, underline, strike, code, h2, h3, bulletList, orderedList,
|
|
14
|
+
* blockquote, link, image, undo, redo
|
|
15
|
+
* - `toolbar`: false hides the toolbar
|
|
16
|
+
* - `placeholder`: shown while it's empty
|
|
17
|
+
* - `maxLength`: a character limit, with a counter
|
|
18
|
+
* - `minHeight`, `maxHeight`: CSS lengths; it scrolls past maxHeight
|
|
19
|
+
* - `disabled`: read only
|
|
20
|
+
* - `uploadImage`: an async function taking a File and returning its URL, or
|
|
21
|
+
* `{ src, alt, title, data-* }` (data-* kept on the <img>, e.g. a signed id
|
|
22
|
+
* for your server). With it, images can be dropped, pasted or picked with
|
|
23
|
+
* the image button, and are inserted once uploaded; without it, dropped
|
|
24
|
+
* and pasted files are ignored. A failed upload calls `onError`
|
|
25
|
+
* - `editorRef`: a ref given the TipTap editor, for anything more
|
|
26
|
+
*
|
|
27
|
+
* Keyboard shortcuts are TipTap's (⌘B, ⌘I, ⌘U, ⌘⇧7/8 for lists, ⌘Z…).
|
|
28
|
+
*/
|
|
29
|
+
import { useEffect, useId, useReducer, useRef, useState } from 'react';
|
|
30
|
+
import { Editor as TipTap } from '@tiptap/core';
|
|
31
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
32
|
+
import { Placeholder, CharacterCount } from '@tiptap/extensions';
|
|
33
|
+
import { useFieldContext } from '@ark-ui/react/field';
|
|
34
|
+
import 'iconify-icon';
|
|
35
|
+
import { RichImage } from './image.js';
|
|
36
|
+
import { Popover } from '../popover/Popover.js';
|
|
37
|
+
import { Input } from '../input/Input.js';
|
|
38
|
+
import { Button } from '../button/Button.js';
|
|
39
|
+
import { cx, partsOf, useControllable } from '../utils.js';
|
|
40
|
+
import { useMessages } from '../format/LocaleProvider.js';
|
|
41
|
+
import '../theme.css';
|
|
42
|
+
import './editor.css';
|
|
43
|
+
export const defaultTools = ['bold', 'italic', 'underline', 'strike', '|', 'h2', 'h3', '|', 'bulletList', 'orderedList', 'blockquote', '|', 'link', 'image', '|', 'undo', 'redo'];
|
|
44
|
+
// Each tool: its icon and label, how it runs, and when it's active.
|
|
45
|
+
const toolDefs = {
|
|
46
|
+
bold: { icon: 'lucide:bold', label: 'Bold', run: (c) => c.toggleBold(), active: ['bold'] },
|
|
47
|
+
italic: { icon: 'lucide:italic', label: 'Italic', run: (c) => c.toggleItalic(), active: ['italic'] },
|
|
48
|
+
underline: { icon: 'lucide:underline', label: 'Underline', run: (c) => c.toggleUnderline(), active: ['underline'] },
|
|
49
|
+
strike: { icon: 'lucide:strikethrough', label: 'Strikethrough', run: (c) => c.toggleStrike(), active: ['strike'] },
|
|
50
|
+
code: { icon: 'lucide:code', label: 'Code', run: (c) => c.toggleCode(), active: ['code'] },
|
|
51
|
+
h2: { icon: 'lucide:heading-2', label: 'Heading', run: (c) => c.toggleHeading({ level: 2 }), active: ['heading', { level: 2 }] },
|
|
52
|
+
h3: { icon: 'lucide:heading-3', label: 'Subheading', run: (c) => c.toggleHeading({ level: 3 }), active: ['heading', { level: 3 }] },
|
|
53
|
+
bulletList: { icon: 'lucide:list', label: 'Bulleted list', run: (c) => c.toggleBulletList(), active: ['bulletList'] },
|
|
54
|
+
orderedList: { icon: 'lucide:list-ordered', label: 'Numbered list', run: (c) => c.toggleOrderedList(), active: ['orderedList'] },
|
|
55
|
+
blockquote: { icon: 'lucide:text-quote', label: 'Quote', run: (c) => c.toggleBlockquote(), active: ['blockquote'] },
|
|
56
|
+
undo: { icon: 'lucide:undo-2', label: 'Undo', run: (c) => c.undo(), can: 'undo' },
|
|
57
|
+
redo: { icon: 'lucide:redo-2', label: 'Redo', run: (c) => c.redo(), can: 'redo' },
|
|
58
|
+
};
|
|
59
|
+
// A URL, or { src, alt, title, data-*… }, as the image node's attributes.
|
|
60
|
+
function imageAttrs(result, file) {
|
|
61
|
+
if (!result)
|
|
62
|
+
return null;
|
|
63
|
+
if (typeof result === 'string')
|
|
64
|
+
return { src: result, alt: file.name };
|
|
65
|
+
const data = Object.fromEntries(Object.entries(result).filter(([key]) => key.startsWith('data-')));
|
|
66
|
+
return { src: result.src, alt: result.alt ?? file.name, title: result.title ?? null, data: Object.keys(data).length ? data : null };
|
|
67
|
+
}
|
|
68
|
+
export function Editor({ value, defaultValue = '', onValueChange, label = null, placeholder = '', tools = defaultTools, toolbar = true, maxLength = null, minHeight = '8rem', maxHeight = null, disabled = false, uploadImage = null, onError, editorRef, className, classes, ...rest }) {
|
|
69
|
+
const t = useMessages();
|
|
70
|
+
const field = useFieldContext();
|
|
71
|
+
const part = partsOf('editor', classes);
|
|
72
|
+
const id = useId();
|
|
73
|
+
const labelId = field ? field.ids.label : label ? `${id}-label` : undefined;
|
|
74
|
+
const [current, setCurrent] = useControllable(value, defaultValue, onValueChange);
|
|
75
|
+
const contentEl = useRef(null);
|
|
76
|
+
const [editor, setEditor] = useState(null);
|
|
77
|
+
const [focused, setFocused] = useState(false);
|
|
78
|
+
const [uploading, setUploading] = useState(0);
|
|
79
|
+
// Bumped on every transaction, so the toolbar's states re-read the editor.
|
|
80
|
+
const [, bump] = useReducer((n) => n + 1, 0);
|
|
81
|
+
// The HTML the editor last had, to tell its own changes from new values.
|
|
82
|
+
const html = useRef(current ?? '');
|
|
83
|
+
// The latest props, for the editor's callbacks (it's made once).
|
|
84
|
+
const latest = useRef({ setCurrent, placeholder, uploadImage, onError, labelId, field });
|
|
85
|
+
useEffect(() => { latest.current = { setCurrent, placeholder, uploadImage, onError, labelId, field }; });
|
|
86
|
+
// Uploads the images among `files` and inserts each at `pos` (or the cursor) as it arrives.
|
|
87
|
+
async function insertImages(instance, files, pos = null) {
|
|
88
|
+
const upload = latest.current.uploadImage;
|
|
89
|
+
if (!upload)
|
|
90
|
+
return;
|
|
91
|
+
for (const file of files.filter((f) => (f.type ?? '').startsWith('image/'))) {
|
|
92
|
+
setUploading((n) => n + 1);
|
|
93
|
+
try {
|
|
94
|
+
const attrs = imageAttrs(await upload(file), file);
|
|
95
|
+
if (attrs && !instance.isDestroyed)
|
|
96
|
+
instance.chain().focus().insertContentAt(pos ?? instance.state.selection.to, { type: 'image', attrs }).run();
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
latest.current.onError?.(error);
|
|
100
|
+
}
|
|
101
|
+
setUploading((n) => n - 1);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const insert = useRef(insertImages);
|
|
105
|
+
useEffect(() => { insert.current = insertImages; });
|
|
106
|
+
// Made once; the effects below keep it in step with the props.
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const instance = new TipTap({
|
|
109
|
+
element: contentEl.current,
|
|
110
|
+
content: html.current,
|
|
111
|
+
editable: !disabled,
|
|
112
|
+
extensions: [
|
|
113
|
+
StarterKit.configure({ heading: { levels: [2, 3] }, link: { openOnClick: false, autolink: true } }),
|
|
114
|
+
Placeholder.configure({ placeholder: () => latest.current.placeholder }),
|
|
115
|
+
CharacterCount.configure({ limit: maxLength }),
|
|
116
|
+
RichImage,
|
|
117
|
+
],
|
|
118
|
+
editorProps: {
|
|
119
|
+
// Files dropped or pasted: images go to `uploadImage`; anything else
|
|
120
|
+
// is ignored, so the browser doesn't open the file in the tab.
|
|
121
|
+
handleDrop(view, event, _slice, moved) {
|
|
122
|
+
const files = Array.from(event.dataTransfer?.files ?? []);
|
|
123
|
+
if (moved || !files.length)
|
|
124
|
+
return false;
|
|
125
|
+
event.preventDefault();
|
|
126
|
+
insert.current(instance, files, view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos ?? null);
|
|
127
|
+
return true;
|
|
128
|
+
},
|
|
129
|
+
handlePaste(_view, event) {
|
|
130
|
+
const files = Array.from(event.clipboardData?.files ?? []);
|
|
131
|
+
if (!files.length)
|
|
132
|
+
return false;
|
|
133
|
+
insert.current(instance, files);
|
|
134
|
+
return true;
|
|
135
|
+
},
|
|
136
|
+
// Read on every update, so the field's label and messages follow.
|
|
137
|
+
attributes: () => ({
|
|
138
|
+
role: 'textbox', 'aria-multiline': 'true',
|
|
139
|
+
'aria-labelledby': latest.current.labelId ?? '', 'aria-describedby': latest.current.field?.ariaDescribedby ?? '',
|
|
140
|
+
'aria-invalid': latest.current.field?.invalid ? 'true' : 'false',
|
|
141
|
+
}),
|
|
142
|
+
},
|
|
143
|
+
onCreate: () => setEditor(instance),
|
|
144
|
+
onUpdate: ({ editor }) => {
|
|
145
|
+
html.current = editor.isEmpty ? '' : editor.getHTML();
|
|
146
|
+
latest.current.setCurrent(html.current);
|
|
147
|
+
},
|
|
148
|
+
onTransaction: bump,
|
|
149
|
+
onFocus: () => setFocused(true),
|
|
150
|
+
onBlur: () => setFocused(false),
|
|
151
|
+
});
|
|
152
|
+
if (editorRef)
|
|
153
|
+
editorRef.current = instance;
|
|
154
|
+
return () => {
|
|
155
|
+
instance.destroy();
|
|
156
|
+
if (editorRef)
|
|
157
|
+
editorRef.current = null;
|
|
158
|
+
setEditor(null);
|
|
159
|
+
};
|
|
160
|
+
// Made once: maxLength and the rest are read when it's created.
|
|
161
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
162
|
+
// A new value from outside (not the editor's own) replaces the content.
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
if (editor && (current ?? '') !== html.current) {
|
|
165
|
+
html.current = current ?? '';
|
|
166
|
+
editor.commands.setContent(html.current, { emitUpdate: false });
|
|
167
|
+
}
|
|
168
|
+
}, [editor, current]);
|
|
169
|
+
useEffect(() => { if (editor && editor.isEditable === disabled)
|
|
170
|
+
editor.setEditable(!disabled); }, [editor, disabled]);
|
|
171
|
+
// ProseMirror reads its attributes on updates; refresh them when the field changes.
|
|
172
|
+
useEffect(() => { editor?.view.setProps({}); }, [editor, labelId, field?.ariaDescribedby, field?.invalid]);
|
|
173
|
+
const isActive = (tool) => !!editor && !!tool.active && editor.isActive(...tool.active);
|
|
174
|
+
const canRun = (tool) => !!editor && !disabled && (tool.can ? editor.can()[tool.can]() : true);
|
|
175
|
+
const run = (tool) => editor && tool.run(editor.chain().focus()).run();
|
|
176
|
+
const characters = editor ? editor.storage.characterCount.characters() : 0;
|
|
177
|
+
// The link popover: the current link's address, applied or removed.
|
|
178
|
+
const [linkOpen, setLinkOpen] = useState(false);
|
|
179
|
+
const [linkUrl, setLinkUrl] = useState('');
|
|
180
|
+
const linkActive = !!editor?.isActive('link');
|
|
181
|
+
function openLink(open) {
|
|
182
|
+
if (open)
|
|
183
|
+
setLinkUrl(editor?.getAttributes('link')?.href ?? '');
|
|
184
|
+
setLinkOpen(open);
|
|
185
|
+
}
|
|
186
|
+
function applyLink(e) {
|
|
187
|
+
e.preventDefault();
|
|
188
|
+
if (!editor)
|
|
189
|
+
return;
|
|
190
|
+
const url = linkUrl.trim();
|
|
191
|
+
const chain = editor.chain().focus().extendMarkRange('link');
|
|
192
|
+
if (url)
|
|
193
|
+
chain.setLink({ href: /^[a-z]+:/i.test(url) ? url : `https://${url}` }).run();
|
|
194
|
+
else
|
|
195
|
+
chain.unsetLink().run();
|
|
196
|
+
setLinkOpen(false);
|
|
197
|
+
}
|
|
198
|
+
function removeLink() {
|
|
199
|
+
editor?.chain().focus().extendMarkRange('link').unsetLink().run();
|
|
200
|
+
setLinkOpen(false);
|
|
201
|
+
}
|
|
202
|
+
function pickedImages(e) {
|
|
203
|
+
const files = Array.from(e.target.files ?? []);
|
|
204
|
+
e.target.value = '';
|
|
205
|
+
if (editor)
|
|
206
|
+
insertImages(editor, files);
|
|
207
|
+
}
|
|
208
|
+
// Toolbar buttons keep the editor's selection (no focus on mousedown).
|
|
209
|
+
const keepFocus = (e) => e.preventDefault();
|
|
210
|
+
return _jsxs(_Fragment, { children: [label && !field && _jsx("span", { ...part('label'), id: labelId, children: label }), _jsxs("div", { ...rest, ...part('root'), className: cx(className, classes?.root), "data-focused": focused || undefined, "data-disabled": disabled || undefined, "data-invalid": field?.invalid || undefined, children: [toolbar && !disabled && (_jsx("div", { ...part('toolbar'), role: "toolbar", "aria-label": t.editor.toolbar, children: tools.map((name, i) => {
|
|
211
|
+
if (name === '|')
|
|
212
|
+
return _jsx("span", { ...part('divider') }, `d${i}`);
|
|
213
|
+
if (name === 'link')
|
|
214
|
+
return (_jsx(Popover, { placement: "bottom-start", open: linkOpen, onOpenChange: openLink, trigger: _jsx("button", { ...part('tool'), type: "button", "aria-label": t.editor.link, "aria-pressed": linkActive, onMouseDown: keepFocus, children: _jsx("iconify-icon", { icon: "lucide:link", "aria-hidden": "true" }) }), children: _jsxs("form", { ...part('link-form'), onSubmit: applyLink, children: [_jsx(Input, { size: "sm", icon: "lucide:link", placeholder: t.editor.linkPlaceholder, value: linkUrl, onValueChange: setLinkUrl }), _jsxs("div", { ...part('link-actions'), children: [linkActive && _jsx(Button, { size: "sm", variant: "ghost", onClick: removeLink, children: t.editor.remove }), _jsx(Button, { size: "sm", variant: "primary", type: "submit", children: t.editor.apply })] })] }) }, name));
|
|
215
|
+
if (name === 'image')
|
|
216
|
+
return uploadImage && (_jsxs("label", { ...part('tool'), "aria-label": t.editor.image, onMouseDown: keepFocus, children: [_jsx("input", { ...part('image-input'), type: "file", accept: "image/*", multiple: true, onChange: pickedImages }), _jsx("iconify-icon", { icon: "lucide:image", "aria-hidden": "true" })] }, name));
|
|
217
|
+
const tool = toolDefs[name];
|
|
218
|
+
return tool && (_jsx("button", { ...part('tool'), type: "button", "aria-label": t.editor.tools[name] ?? tool.label, "aria-pressed": tool.active ? isActive(tool) : undefined, disabled: !canRun(tool), onMouseDown: keepFocus, onClick: () => run(tool), children: _jsx("iconify-icon", { icon: tool.icon, "aria-hidden": "true" }) }, name));
|
|
219
|
+
}) })), _jsx("div", { ...part('content'), ref: contentEl, style: { '--min-height': minHeight, '--max-height': maxHeight ?? 'none' } }), uploading > 0 && _jsx("div", { ...part('uploading'), children: t.editor.uploading }), maxLength && _jsxs("div", { ...part('count'), "data-over": characters >= maxLength || undefined, children: [characters, " / ", maxLength] })] })] });
|
|
220
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
@layer ui {
|
|
2
|
+
[data-scope=editor][data-part=label] { display: block; margin-bottom: 0.375rem; font-size: 14px; font-weight: 500; color: var(--ui-text); font-family: var(--ui-font); }
|
|
3
|
+
[data-scope=editor][data-part=root] { display: block; position: relative; width: 100%; min-width: 0; box-sizing: border-box;
|
|
4
|
+
background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: var(--ui-radius);
|
|
5
|
+
color: var(--ui-text); font-family: var(--ui-font); font-size: 14px;
|
|
6
|
+
&[data-focused] { border-color: var(--ui-ring); outline: 2px solid var(--ui-ring-soft); }
|
|
7
|
+
&[data-invalid] { border-color: var(--ui-danger); }
|
|
8
|
+
&[data-disabled] { background: var(--ui-hover); } }
|
|
9
|
+
[data-scope=editor][data-part=toolbar] { display: flex; align-items: center; flex-wrap: wrap; gap: 0.125rem; padding: 0.25rem; border-bottom: 1px solid var(--ui-border); }
|
|
10
|
+
[data-scope=editor][data-part=tool] { display: grid; place-items: center; width: 2rem; height: 2rem; padding: 0; border: none;
|
|
11
|
+
border-radius: calc(var(--ui-radius) - 2px); background: transparent; color: var(--ui-muted); font-size: 16px; cursor: pointer;
|
|
12
|
+
&:hover { color: var(--ui-text); background: var(--ui-hover); }
|
|
13
|
+
&:focus-visible, &:focus-within:has(:focus-visible) { outline: 2px solid var(--ui-ring-soft); }
|
|
14
|
+
&:disabled { opacity: 0.35; cursor: default; background: transparent; }
|
|
15
|
+
&[aria-pressed=true] { color: var(--ui-text); background: var(--ui-hover); } }
|
|
16
|
+
[data-scope=editor][data-part=divider] { width: 1px; height: 1.25rem; margin: 0 0.25rem; background: var(--ui-border); }
|
|
17
|
+
[data-scope=editor][data-part=image-input] { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
|
18
|
+
[data-scope=editor][data-part=uploading] { position: absolute; left: 0.75rem; bottom: 0.375rem; font-size: 12px; color: var(--ui-muted); }
|
|
19
|
+
[data-scope=editor][data-part=link-form] { display: flex; flex-direction: column; gap: 0.5rem; width: 16rem; }
|
|
20
|
+
[data-scope=editor][data-part=link-actions] { display: flex; justify-content: flex-end; gap: 0.5rem; }
|
|
21
|
+
/* The editable area (ProseMirror renders it). */
|
|
22
|
+
[data-scope=editor][data-part=content] {
|
|
23
|
+
& .ProseMirror { min-height: var(--min-height); max-height: var(--max-height); overflow-y: auto; padding: 0.625rem 0.75rem; outline: none;
|
|
24
|
+
line-height: 1.6; overflow-wrap: anywhere; }
|
|
25
|
+
& :is(p, ul, ol, blockquote, h2, h3) { margin: 0 0 0.5rem; }
|
|
26
|
+
& .ProseMirror > :last-child { margin-bottom: 0; }
|
|
27
|
+
& h2 { font-size: 18px; font-weight: 600; line-height: 1.3; margin-top: 0.75rem; }
|
|
28
|
+
& h3 { font-size: 16px; font-weight: 600; line-height: 1.3; margin-top: 0.625rem; }
|
|
29
|
+
& :is(ul, ol) { padding-left: 1.25rem; }
|
|
30
|
+
& ul { list-style: disc; }
|
|
31
|
+
& ol { list-style: decimal; }
|
|
32
|
+
& li > p { margin: 0; }
|
|
33
|
+
& blockquote { padding-left: 0.75rem; border-left: 3px solid var(--ui-border); color: var(--ui-muted); }
|
|
34
|
+
& code { padding: 0.125rem 0.25rem; border-radius: 4px; background: var(--ui-hover); font-family: ui-monospace, monospace; font-size: 0.9em; }
|
|
35
|
+
& a { color: var(--ui-accent); text-decoration: underline; text-underline-offset: 2px; }
|
|
36
|
+
& img { display: block; max-width: 100%; height: auto; margin: 0.5rem 0; border-radius: var(--ui-radius); }
|
|
37
|
+
& img.ProseMirror-selectednode { outline: 2px solid var(--ui-ring); }
|
|
38
|
+
/* TipTap's placeholder: on the first empty paragraph. */
|
|
39
|
+
& p.is-editor-empty:first-child::before { content: attr(data-placeholder); float: left; height: 0; color: var(--ui-muted); pointer-events: none; } }
|
|
40
|
+
[data-scope=editor][data-part=count] { position: absolute; right: 0.625rem; bottom: 0.375rem; font-size: 12px; color: var(--ui-muted); pointer-events: none;
|
|
41
|
+
&[data-over] { color: var(--ui-danger); } }
|
|
42
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const RichImage: import("@tiptap/core").Node<import("@tiptap/extension-image").ImageOptions, any>;
|