@teamofsilicons/silicon-ui 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +81 -2
- package/check.mjs +236 -0
- package/components/accordion/accordion.d.ts +13 -0
- package/components/accordion/accordion.js +35 -0
- package/components/accordion/accordion.module.css +15 -0
- package/components/action-button/action-button.d.ts +11 -0
- package/components/action-button/action-button.js +57 -0
- package/components/action-button/action-button.module.css +15 -0
- package/components/activity-heatmap/activity-heatmap.d.ts +37 -0
- package/components/activity-heatmap/activity-heatmap.js +303 -0
- package/components/activity-heatmap/activity-heatmap.module.css +83 -0
- package/components/alert/alert.d.ts +13 -0
- package/components/alert/alert.js +79 -0
- package/components/alert/alert.module.css +15 -0
- package/components/animated-counter/animated-counter.d.ts +13 -0
- package/components/animated-counter/animated-counter.js +73 -0
- package/components/animated-counter/animated-counter.module.css +12 -0
- package/components/announcement-bar/announcement-bar.d.ts +53 -0
- package/components/announcement-bar/announcement-bar.js +187 -0
- package/components/announcement-bar/announcement-bar.module.css +60 -0
- package/components/avatar/avatar.d.ts +8 -0
- package/components/avatar/avatar.js +18 -0
- package/components/avatar/avatar.module.css +60 -0
- package/components/avatar-group/avatar-group.d.ts +13 -0
- package/components/avatar-group/avatar-group.js +24 -0
- package/components/avatar-group/avatar-group.module.css +27 -0
- package/components/badge/badge.d.ts +10 -0
- package/components/badge/badge.js +66 -0
- package/components/badge/badge.module.css +52 -0
- package/components/bar-chart/bar-chart.d.ts +32 -0
- package/components/bar-chart/bar-chart.js +327 -0
- package/components/bar-chart/bar-chart.module.css +49 -0
- package/components/billing-toggle/billing-toggle.d.ts +35 -0
- package/components/billing-toggle/billing-toggle.js +83 -0
- package/components/billing-toggle/billing-toggle.module.css +139 -0
- package/components/bottom-sheet/bottom-sheet.d.ts +27 -0
- package/components/bottom-sheet/bottom-sheet.js +386 -0
- package/components/bottom-sheet/bottom-sheet.module.css +69 -0
- package/components/breadcrumb/breadcrumb.d.ts +13 -0
- package/components/breadcrumb/breadcrumb.js +19 -0
- package/components/breadcrumb/breadcrumb.module.css +12 -0
- package/components/brush-chart/brush-chart.d.ts +45 -0
- package/components/brush-chart/brush-chart.js +426 -0
- package/components/brush-chart/brush-chart.module.css +69 -0
- package/components/button/button.d.ts +10 -0
- package/components/button/button.js +97 -0
- package/components/button/button.module.css +39 -0
- package/components/button-group/button-group.d.ts +59 -0
- package/components/button-group/button-group.js +280 -0
- package/components/button-group/button-group.module.css +87 -0
- package/components/button-link/button-link.d.ts +24 -0
- package/components/button-link/button-link.js +11 -0
- package/components/button-link/button-link.module.css +7 -0
- package/components/calendar/calendar.d.ts +22 -0
- package/components/calendar/calendar.js +253 -0
- package/components/calendar/calendar.module.css +51 -0
- package/components/card/card.d.ts +19 -0
- package/components/card/card.js +108 -0
- package/components/card/card.module.css +51 -0
- package/components/card-stack/card-stack.d.ts +34 -0
- package/components/card-stack/card-stack.js +291 -0
- package/components/card-stack/card-stack.module.css +63 -0
- package/components/carousel/carousel.d.ts +28 -0
- package/components/carousel/carousel.js +332 -0
- package/components/carousel/carousel.module.css +42 -0
- package/components/chat-thread/chat-thread.d.ts +135 -0
- package/components/chat-thread/chat-thread.js +406 -0
- package/components/chat-thread/chat-thread.module.css +242 -0
- package/components/checkbox/checkbox.d.ts +7 -0
- package/components/checkbox/checkbox.js +23 -0
- package/components/checkbox/checkbox.module.css +15 -0
- package/components/chip-group/chip-group.d.ts +24 -0
- package/components/chip-group/chip-group.js +196 -0
- package/components/chip-group/chip-group.module.css +38 -0
- package/components/code-block/code-block.d.ts +11 -0
- package/components/code-block/code-block.js +133 -0
- package/components/code-block/code-block.module.css +43 -0
- package/components/color-picker/color-picker.d.ts +40 -0
- package/components/color-picker/color-picker.js +449 -0
- package/components/color-picker/color-picker.module.css +111 -0
- package/components/combobox/combobox.d.ts +19 -0
- package/components/combobox/combobox.js +167 -0
- package/components/combobox/combobox.module.css +28 -0
- package/components/command-palette/command-palette.d.ts +20 -0
- package/components/command-palette/command-palette.js +151 -0
- package/components/command-palette/command-palette.module.css +94 -0
- package/components/comment-thread/comment-thread.d.ts +120 -0
- package/components/comment-thread/comment-thread.js +343 -0
- package/components/comment-thread/comment-thread.module.css +195 -0
- package/components/confirm-morph/confirm-morph.d.ts +65 -0
- package/components/confirm-morph/confirm-morph.js +355 -0
- package/components/confirm-morph/confirm-morph.module.css +146 -0
- package/components/context-menu/context-menu.d.ts +18 -0
- package/components/context-menu/context-menu.js +148 -0
- package/components/context-menu/context-menu.module.css +17 -0
- package/components/copy-button/copy-button.d.ts +10 -0
- package/components/copy-button/copy-button.js +69 -0
- package/components/copy-button/copy-button.module.css +21 -0
- package/components/countdown/countdown.d.ts +31 -0
- package/components/countdown/countdown.js +177 -0
- package/components/countdown/countdown.module.css +49 -0
- package/components/date-picker/date-picker.d.ts +17 -0
- package/components/date-picker/date-picker.js +100 -0
- package/components/date-picker/date-picker.module.css +34 -0
- package/components/date-range-picker/date-range-picker.d.ts +44 -0
- package/components/date-range-picker/date-range-picker.js +534 -0
- package/components/date-range-picker/date-range-picker.module.css +214 -0
- package/components/dialog/dialog.d.ts +11 -0
- package/components/dialog/dialog.js +52 -0
- package/components/dialog/dialog.module.css +36 -0
- package/components/donut-chart/donut-chart.d.ts +46 -0
- package/components/donut-chart/donut-chart.js +382 -0
- package/components/donut-chart/donut-chart.module.css +48 -0
- package/components/drawer/drawer.d.ts +20 -0
- package/components/drawer/drawer.js +111 -0
- package/components/drawer/drawer.module.css +97 -0
- package/components/dropdown-menu/dropdown-menu.d.ts +16 -0
- package/components/dropdown-menu/dropdown-menu.js +39 -0
- package/components/dropdown-menu/dropdown-menu.module.css +38 -0
- package/components/elastic-slider/elastic-slider.d.ts +39 -0
- package/components/elastic-slider/elastic-slider.js +261 -0
- package/components/elastic-slider/elastic-slider.module.css +40 -0
- package/components/empty-state/empty-state.d.ts +12 -0
- package/components/empty-state/empty-state.js +69 -0
- package/components/empty-state/empty-state.module.css +13 -0
- package/components/expandable-card/expandable-card.d.ts +13 -0
- package/components/expandable-card/expandable-card.js +89 -0
- package/components/expandable-card/expandable-card.module.css +22 -0
- package/components/expanding-button-group/expanding-button-group.d.ts +41 -0
- package/components/expanding-button-group/expanding-button-group.js +258 -0
- package/components/expanding-button-group/expanding-button-group.module.css +44 -0
- package/components/expanding-search/expanding-search.d.ts +38 -0
- package/components/expanding-search/expanding-search.js +242 -0
- package/components/expanding-search/expanding-search.module.css +52 -0
- package/components/file-dropzone/file-dropzone.d.ts +44 -0
- package/components/file-dropzone/file-dropzone.js +379 -0
- package/components/file-dropzone/file-dropzone.module.css +115 -0
- package/components/file-upload/file-upload.d.ts +22 -0
- package/components/file-upload/file-upload.js +192 -0
- package/components/file-upload/file-upload.module.css +49 -0
- package/components/filter-toolbar/filter-toolbar.d.ts +48 -0
- package/components/filter-toolbar/filter-toolbar.js +446 -0
- package/components/filter-toolbar/filter-toolbar.module.css +70 -0
- package/components/floating-button-group/floating-button-group.d.ts +51 -0
- package/components/floating-button-group/floating-button-group.js +215 -0
- package/components/floating-button-group/floating-button-group.module.css +74 -0
- package/components/gauge/gauge.d.ts +18 -0
- package/components/gauge/gauge.js +101 -0
- package/components/gauge/gauge.module.css +22 -0
- package/components/hold-to-confirm/hold-to-confirm.d.ts +25 -0
- package/components/hold-to-confirm/hold-to-confirm.js +165 -0
- package/components/hold-to-confirm/hold-to-confirm.module.css +20 -0
- package/components/hover-card/hover-card.d.ts +38 -0
- package/components/hover-card/hover-card.js +145 -0
- package/components/hover-card/hover-card.module.css +20 -0
- package/components/image-compare/image-compare.d.ts +27 -0
- package/components/image-compare/image-compare.js +267 -0
- package/components/image-compare/image-compare.module.css +25 -0
- package/components/in-view-title/in-view-title.d.ts +17 -0
- package/components/in-view-title/in-view-title.js +70 -0
- package/components/in-view-title/in-view-title.module.css +13 -0
- package/components/inline-edit/inline-edit.d.ts +27 -0
- package/components/inline-edit/inline-edit.js +315 -0
- package/components/inline-edit/inline-edit.module.css +73 -0
- package/components/input/input.d.ts +7 -0
- package/components/input/input.js +60 -0
- package/components/input/input.module.css +22 -0
- package/components/json-viewer/json-viewer.d.ts +48 -0
- package/components/json-viewer/json-viewer.js +463 -0
- package/components/json-viewer/json-viewer.module.css +92 -0
- package/components/line-chart/line-chart.d.ts +58 -0
- package/components/line-chart/line-chart.js +383 -0
- package/components/line-chart/line-chart.module.css +60 -0
- package/components/mention-input/mention-input.d.ts +72 -0
- package/components/mention-input/mention-input.js +417 -0
- package/components/mention-input/mention-input.module.css +134 -0
- package/components/metric-card/metric-card.d.ts +8 -0
- package/components/metric-card/metric-card.js +44 -0
- package/components/metric-card/metric-card.module.css +18 -0
- package/components/money-input/money-input.d.ts +48 -0
- package/components/money-input/money-input.js +503 -0
- package/components/money-input/money-input.module.css +77 -0
- package/components/morph-select/morph-select.d.ts +50 -0
- package/components/morph-select/morph-select.js +395 -0
- package/components/morph-select/morph-select.module.css +88 -0
- package/components/multi-select/multi-select.d.ts +18 -0
- package/components/multi-select/multi-select.js +105 -0
- package/components/multi-select/multi-select.module.css +37 -0
- package/components/notification-center/notification-center.d.ts +24 -0
- package/components/notification-center/notification-center.js +146 -0
- package/components/notification-center/notification-center.module.css +79 -0
- package/components/number-field/number-field.d.ts +41 -0
- package/components/number-field/number-field.js +455 -0
- package/components/number-field/number-field.module.css +71 -0
- package/components/otp-input/otp-input.d.ts +13 -0
- package/components/otp-input/otp-input.js +129 -0
- package/components/otp-input/otp-input.module.css +28 -0
- package/components/pagination/pagination.d.ts +7 -0
- package/components/pagination/pagination.js +60 -0
- package/components/pagination/pagination.module.css +16 -0
- package/components/password-field/password-field.d.ts +14 -0
- package/components/password-field/password-field.js +51 -0
- package/components/password-field/password-field.module.css +27 -0
- package/components/password-strength/password-strength.d.ts +107 -0
- package/components/password-strength/password-strength.js +161 -0
- package/components/password-strength/password-strength.module.css +57 -0
- package/components/phone-input/phone-input.d.ts +73 -0
- package/components/phone-input/phone-input.js +612 -0
- package/components/phone-input/phone-input.module.css +98 -0
- package/components/popover/popover.d.ts +6 -0
- package/components/popover/popover.js +19 -0
- package/components/popover/popover.module.css +32 -0
- package/components/progress/progress.d.ts +8 -0
- package/components/progress/progress.js +42 -0
- package/components/progress/progress.module.css +1 -0
- package/components/radio-cards/radio-cards.d.ts +35 -0
- package/components/radio-cards/radio-cards.js +110 -0
- package/components/radio-cards/radio-cards.module.css +84 -0
- package/components/radio-group/radio-group.d.ts +13 -0
- package/components/radio-group/radio-group.js +55 -0
- package/components/radio-group/radio-group.module.css +30 -0
- package/components/resizable-panels/resizable-panels.d.ts +37 -0
- package/components/resizable-panels/resizable-panels.js +338 -0
- package/components/resizable-panels/resizable-panels.module.css +40 -0
- package/components/rich-text-editor/rich-text-editor.d.ts +55 -0
- package/components/rich-text-editor/rich-text-editor.js +1733 -0
- package/components/rich-text-editor/rich-text-editor.module.css +201 -0
- package/components/ridgeline/ridgeline.d.ts +37 -0
- package/components/ridgeline/ridgeline.js +346 -0
- package/components/ridgeline/ridgeline.module.css +58 -0
- package/components/scroll-area/scroll-area.d.ts +40 -0
- package/components/scroll-area/scroll-area.js +153 -0
- package/components/scroll-area/scroll-area.module.css +79 -0
- package/components/search-field/search-field.d.ts +7 -0
- package/components/search-field/search-field.js +20 -0
- package/components/search-field/search-field.module.css +17 -0
- package/components/segmented-control/segmented-control.d.ts +16 -0
- package/components/segmented-control/segmented-control.js +56 -0
- package/components/segmented-control/segmented-control.module.css +15 -0
- package/components/select/select.d.ts +15 -0
- package/components/select/select.js +36 -0
- package/components/select/select.module.css +35 -0
- package/components/shortcut-recorder/shortcut-recorder.d.ts +108 -0
- package/components/shortcut-recorder/shortcut-recorder.js +380 -0
- package/components/shortcut-recorder/shortcut-recorder.module.css +85 -0
- package/components/signature-pad/signature-pad.d.ts +72 -0
- package/components/signature-pad/signature-pad.js +450 -0
- package/components/signature-pad/signature-pad.module.css +84 -0
- package/components/skeleton/skeleton.d.ts +13 -0
- package/components/skeleton/skeleton.js +53 -0
- package/components/skeleton/skeleton.module.css +11 -0
- package/components/slider/slider.d.ts +45 -0
- package/components/slider/slider.js +347 -0
- package/components/slider/slider.module.css +45 -0
- package/components/slope-chart/slope-chart.d.ts +38 -0
- package/components/slope-chart/slope-chart.js +183 -0
- package/components/slope-chart/slope-chart.module.css +51 -0
- package/components/slot-text/slot-text.d.ts +26 -0
- package/components/slot-text/slot-text.js +119 -0
- package/components/slot-text/slot-text.module.css +19 -0
- package/components/sortable-data-table/sortable-data-table.d.ts +38 -0
- package/components/sortable-data-table/sortable-data-table.js +240 -0
- package/components/sortable-data-table/sortable-data-table.module.css +116 -0
- package/components/sparkline/sparkline.d.ts +19 -0
- package/components/sparkline/sparkline.js +239 -0
- package/components/sparkline/sparkline.module.css +26 -0
- package/components/split-button/split-button.d.ts +19 -0
- package/components/split-button/split-button.js +33 -0
- package/components/split-button/split-button.module.css +51 -0
- package/components/stepper/stepper.d.ts +36 -0
- package/components/stepper/stepper.js +108 -0
- package/components/stepper/stepper.module.css +74 -0
- package/components/streamgraph/streamgraph.d.ts +51 -0
- package/components/streamgraph/streamgraph.js +376 -0
- package/components/streamgraph/streamgraph.module.css +55 -0
- package/components/swipe-actions/swipe-actions.d.ts +35 -0
- package/components/swipe-actions/swipe-actions.js +330 -0
- package/components/swipe-actions/swipe-actions.module.css +64 -0
- package/components/switch/switch.d.ts +7 -0
- package/components/switch/switch.js +45 -0
- package/components/switch/switch.module.css +12 -0
- package/components/tabs/tabs.d.ts +8 -0
- package/components/tabs/tabs.js +138 -0
- package/components/tabs/tabs.module.css +27 -0
- package/components/tag-input/tag-input.d.ts +15 -0
- package/components/tag-input/tag-input.js +150 -0
- package/components/tag-input/tag-input.module.css +34 -0
- package/components/text-morph/text-morph.d.ts +13 -0
- package/components/text-morph/text-morph.js +69 -0
- package/components/text-morph/text-morph.module.css +8 -0
- package/components/text-reveal/text-reveal.d.ts +14 -0
- package/components/text-reveal/text-reveal.js +20 -0
- package/components/text-reveal/text-reveal.module.css +13 -0
- package/components/text-shimmer/text-shimmer.d.ts +18 -0
- package/components/text-shimmer/text-shimmer.js +90 -0
- package/components/text-shimmer/text-shimmer.module.css +9 -0
- package/components/textarea/textarea.d.ts +7 -0
- package/components/textarea/textarea.js +56 -0
- package/components/textarea/textarea.module.css +22 -0
- package/components/theme-switch/theme-switch.d.ts +11 -0
- package/components/theme-switch/theme-switch.js +37 -0
- package/components/theme-switch/theme-switch.module.css +31 -0
- package/components/time-picker/time-picker.d.ts +13 -0
- package/components/time-picker/time-picker.js +95 -0
- package/components/time-picker/time-picker.module.css +23 -0
- package/components/timeline/timeline.d.ts +47 -0
- package/components/timeline/timeline.js +171 -0
- package/components/timeline/timeline.module.css +51 -0
- package/components/toast/toast.d.ts +8 -0
- package/components/toast/toast.js +118 -0
- package/components/toast/toast.module.css +100 -0
- package/components/toast-stack/toast-stack.d.ts +57 -0
- package/components/toast-stack/toast-stack.js +399 -0
- package/components/toast-stack/toast-stack.module.css +61 -0
- package/components/tooltip/tooltip.d.ts +8 -0
- package/components/tooltip/tooltip.js +62 -0
- package/components/tooltip/tooltip.module.css +18 -0
- package/components/tree-view/tree-view.d.ts +17 -0
- package/components/tree-view/tree-view.js +147 -0
- package/components/tree-view/tree-view.module.css +22 -0
- package/components/treemap/treemap.d.ts +35 -0
- package/components/treemap/treemap.js +375 -0
- package/components/treemap/treemap.module.css +59 -0
- package/components/usage-meter/usage-meter.d.ts +24 -0
- package/components/usage-meter/usage-meter.js +296 -0
- package/components/usage-meter/usage-meter.module.css +61 -0
- package/components/user-menu/user-menu.d.ts +62 -0
- package/components/user-menu/user-menu.js +316 -0
- package/components/user-menu/user-menu.module.css +97 -0
- package/components/waffle-chart/waffle-chart.d.ts +39 -0
- package/components/waffle-chart/waffle-chart.js +244 -0
- package/components/waffle-chart/waffle-chart.module.css +52 -0
- package/components.json +1330 -0
- package/eslint.js +53 -0
- package/lib/escape.d.ts +19 -0
- package/lib/escape.js +50 -0
- package/lib/media.d.ts +353 -0
- package/lib/media.js +65 -0
- package/lib/menu-highlight.d.ts +28 -0
- package/lib/menu-highlight.js +43 -0
- package/lib/motion-tokens.d.ts +60 -0
- package/lib/motion-tokens.js +25 -0
- package/lib/return-focus.d.ts +9 -0
- package/lib/return-focus.js +53 -0
- package/lib/silicon-provider.d.ts +60 -0
- package/lib/silicon-provider.js +47 -0
- package/lib/use-copy-feedback.d.ts +8 -0
- package/lib/use-copy-feedback.js +36 -0
- package/lib/use-today.d.ts +2 -0
- package/lib/use-today.js +29 -0
- package/package.json +76 -4
- package/styles.css +218 -0
- package/styles.css.d.ts +1 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { forwardRef, useCallback, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react";
|
|
5
|
+
import { Hash } from "lucide-react";
|
|
6
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
7
|
+
import styles from "./mention-input.module.css";
|
|
8
|
+
const EMPTY = { text: "", mentions: [] };
|
|
9
|
+
const SYMBOL = { person: "@", channel: "#" };
|
|
10
|
+
const POPOVER_WIDTH = 272;
|
|
11
|
+
const { spring, duration, ease } = motionTokens;
|
|
12
|
+
const enter = [...ease.enter];
|
|
13
|
+
const standard = [...ease.standard];
|
|
14
|
+
const physical = (visualDuration, bounce) => {
|
|
15
|
+
const root = 2 * Math.PI / (visualDuration * 1.2);
|
|
16
|
+
return { type: "spring", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };
|
|
17
|
+
};
|
|
18
|
+
const GLIDE = physical(.3, .1), GROW = physical(spring.smooth.visualDuration, 0);
|
|
19
|
+
/** First placements. A jump made in the commit that mounts its element never reaches the DOM; an instant animation lands on the first frame. */
|
|
20
|
+
const INSTANT = { duration: 0 };
|
|
21
|
+
export const mentionText = (kind, label) => `${SYMBOL[kind]}${label}`;
|
|
22
|
+
/** Turns a value into storage friendly text, `<@id>` for people and `<#id>` for channels by default. */
|
|
23
|
+
export function serializeMentions(value, format = mention => `<${SYMBOL[mention.kind]}${mention.id}>`) {
|
|
24
|
+
let out = "", at = 0;
|
|
25
|
+
for (const mention of [...value.mentions].sort((a, b) => a.start - b.start)) {
|
|
26
|
+
out += value.text.slice(at, mention.start) + format(mention);
|
|
27
|
+
at = mention.end;
|
|
28
|
+
}
|
|
29
|
+
return out + value.text.slice(at);
|
|
30
|
+
}
|
|
31
|
+
/** Moves mentions along with an edit and drops any the edit touched. `hint` is the caret before the edit, which settles repeated characters. */
|
|
32
|
+
function reconcile(previous, text, hint) {
|
|
33
|
+
const a = previous.text;
|
|
34
|
+
let prefix = 0;
|
|
35
|
+
const maxPrefix = Math.min(a.length, text.length, Math.max(0, hint));
|
|
36
|
+
while (prefix < maxPrefix && a[prefix] === text[prefix])
|
|
37
|
+
prefix++;
|
|
38
|
+
let suffix = 0;
|
|
39
|
+
while (suffix < a.length - prefix && suffix < text.length - prefix && a[a.length - 1 - suffix] === text[text.length - 1 - suffix])
|
|
40
|
+
suffix++;
|
|
41
|
+
const oldEnd = a.length - suffix, delta = text.length - a.length;
|
|
42
|
+
return previous.mentions.flatMap(mention => {
|
|
43
|
+
if (mention.end <= prefix)
|
|
44
|
+
return [mention];
|
|
45
|
+
if (mention.start >= oldEnd)
|
|
46
|
+
return [{ ...mention, start: mention.start + delta, end: mention.end + delta }];
|
|
47
|
+
return [];
|
|
48
|
+
}).filter(mention => text.slice(mention.start, mention.end) === mentionText(mention.kind, mention.label));
|
|
49
|
+
}
|
|
50
|
+
function findTrigger(text, caret, mentions, kinds) {
|
|
51
|
+
for (let index = caret - 1; index >= 0 && index >= caret - 48; index--) {
|
|
52
|
+
const char = text[index];
|
|
53
|
+
if (char === "\n")
|
|
54
|
+
return null;
|
|
55
|
+
if (char !== "@" && char !== "#")
|
|
56
|
+
continue;
|
|
57
|
+
const kind = char === "@" ? "person" : "channel";
|
|
58
|
+
if (!kinds[kind])
|
|
59
|
+
return null;
|
|
60
|
+
if (index > 0 && !/[\s([{"']/.test(text[index - 1]))
|
|
61
|
+
return null;
|
|
62
|
+
if (mentions.some(mention => index >= mention.start && index < mention.end))
|
|
63
|
+
return null;
|
|
64
|
+
const query = text.slice(index + 1, caret);
|
|
65
|
+
const shape = kind === "person" ? /^[^\s@#]*( [^\s@#]*)?$/ : /^[^\s@#]*$/;
|
|
66
|
+
return shape.test(query) ? { kind, start: index, query } : null;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
function rank(label, extra, query) {
|
|
71
|
+
const name = label.toLowerCase();
|
|
72
|
+
if (!query)
|
|
73
|
+
return 1;
|
|
74
|
+
if (name.startsWith(query))
|
|
75
|
+
return 4;
|
|
76
|
+
if (name.split(/[\s\-_.]+/).some(word => word.startsWith(query)))
|
|
77
|
+
return 3;
|
|
78
|
+
if (name.includes(query))
|
|
79
|
+
return 2;
|
|
80
|
+
return extra.toLowerCase().includes(query) ? 1 : 0;
|
|
81
|
+
}
|
|
82
|
+
function initials(name) {
|
|
83
|
+
return name.split(/\s+/).filter(Boolean).slice(0, 2).map(part => part[0]?.toUpperCase()).join("");
|
|
84
|
+
}
|
|
85
|
+
/** The matched part of a label reads in full contrast; the rest stays quiet. */
|
|
86
|
+
function Highlight({ text, query }) {
|
|
87
|
+
const at = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1;
|
|
88
|
+
if (at < 0)
|
|
89
|
+
return _jsx(_Fragment, { children: text });
|
|
90
|
+
return _jsxs(_Fragment, { children: [text.slice(0, at), _jsx("mark", { className: styles.match, children: text.slice(at, at + query.length) }), text.slice(at + query.length)] });
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* A textarea with `@` people and `#` channel mentions. Mentions are atomic: the caret steps over them and one Backspace removes the whole token.
|
|
94
|
+
* The value carries plain text plus a structured `mentions` array with offsets, ready to store or notify.
|
|
95
|
+
*/
|
|
96
|
+
export const MentionInput = forwardRef(function MentionInput({ value: valueProp, defaultValue, onChange, people, channels, onMentionAdd, onSubmit, submitOnEnter = false, placeholder, minRows = 1, maxRows = 8, placement = "auto", maxSuggestions = 6, disabled, name, id, "aria-label": ariaLabel, "aria-describedby": describedBy, className, }, ref) {
|
|
97
|
+
const reduced = !!useReducedMotion();
|
|
98
|
+
const uid = useId();
|
|
99
|
+
const listId = `${uid}-list`;
|
|
100
|
+
const [inner, setInner] = useState(defaultValue ?? EMPTY);
|
|
101
|
+
const value = valueProp ?? inner;
|
|
102
|
+
const live = useRef(value);
|
|
103
|
+
useLayoutEffect(() => { live.current = value; });
|
|
104
|
+
const rootRef = useRef(null);
|
|
105
|
+
const fieldRef = useRef(null);
|
|
106
|
+
const areaRef = useRef(null);
|
|
107
|
+
const backdropRef = useRef(null);
|
|
108
|
+
const lastCaret = useRef(0);
|
|
109
|
+
const pending = useRef(null);
|
|
110
|
+
const [caret, setCaret] = useState(null);
|
|
111
|
+
const [focused, setFocused] = useState(false);
|
|
112
|
+
const [dismissed, setDismissed] = useState(null);
|
|
113
|
+
const [active, setActive] = useState(0);
|
|
114
|
+
const commit = useCallback((next) => {
|
|
115
|
+
if (valueProp === undefined)
|
|
116
|
+
setInner(next);
|
|
117
|
+
onChange?.(next);
|
|
118
|
+
}, [onChange, valueProp]);
|
|
119
|
+
const kinds = useMemo(() => ({ person: !!people?.length, channel: !!channels?.length }), [channels, people]);
|
|
120
|
+
const trigger = useMemo(() => caret === null ? null : findTrigger(value.text, caret, value.mentions, kinds), [caret, kinds, value.mentions, value.text]);
|
|
121
|
+
const suggestions = useMemo(() => {
|
|
122
|
+
if (!trigger)
|
|
123
|
+
return [];
|
|
124
|
+
const query = trigger.query.toLowerCase().trim();
|
|
125
|
+
const scored = trigger.kind === "person"
|
|
126
|
+
? (people ?? []).map(item => ({ entry: { kind: "person", item }, score: rank(item.name, item.role ?? "", query) }))
|
|
127
|
+
: (channels ?? []).map(item => ({ entry: { kind: "channel", item }, score: rank(item.name, item.description ?? "", query) }));
|
|
128
|
+
return scored.filter(entry => entry.score > 0).sort((a, b) => b.score - a.score).slice(0, maxSuggestions).map(entry => entry.entry);
|
|
129
|
+
}, [channels, maxSuggestions, people, trigger]);
|
|
130
|
+
// A sentence that simply continues after an unmatched name closes the list instead of showing an empty state.
|
|
131
|
+
const open = focused && !disabled && !!trigger && trigger.start !== dismissed && (suggestions.length > 0 || !trigger.query.includes(" "));
|
|
132
|
+
const activeIndex = Math.min(active, Math.max(0, suggestions.length - 1));
|
|
133
|
+
const queryKey = trigger ? `${trigger.start}:${trigger.query}` : "";
|
|
134
|
+
const [lastQueryKey, setLastQueryKey] = useState(queryKey);
|
|
135
|
+
if (lastQueryKey !== queryKey) {
|
|
136
|
+
setLastQueryKey(queryKey);
|
|
137
|
+
setActive(0);
|
|
138
|
+
}
|
|
139
|
+
if (dismissed !== null && trigger?.start !== dismissed)
|
|
140
|
+
setDismissed(null);
|
|
141
|
+
/* Selection: the caret never rests inside a token. Arrow keys hop over it; a click lands on the nearest edge. */
|
|
142
|
+
const onSelect = () => {
|
|
143
|
+
const area = areaRef.current;
|
|
144
|
+
if (!area)
|
|
145
|
+
return;
|
|
146
|
+
let start = area.selectionStart, end = area.selectionEnd;
|
|
147
|
+
const collapsed = start === end;
|
|
148
|
+
for (const mention of live.current.mentions) {
|
|
149
|
+
if (collapsed && start > mention.start && start < mention.end) {
|
|
150
|
+
const stepped = Math.abs(start - lastCaret.current) === 1;
|
|
151
|
+
const forward = start > lastCaret.current;
|
|
152
|
+
start = end = stepped ? (forward ? mention.end : mention.start) : (start - mention.start < mention.end - start ? mention.start : mention.end);
|
|
153
|
+
}
|
|
154
|
+
else if (!collapsed) {
|
|
155
|
+
if (start > mention.start && start < mention.end)
|
|
156
|
+
start = mention.start;
|
|
157
|
+
if (end > mention.start && end < mention.end)
|
|
158
|
+
end = mention.end;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (start !== area.selectionStart || end !== area.selectionEnd)
|
|
162
|
+
area.setSelectionRange(start, end, area.selectionDirection);
|
|
163
|
+
lastCaret.current = area.selectionStart;
|
|
164
|
+
setCaret(collapsed ? start : null);
|
|
165
|
+
};
|
|
166
|
+
const onInput = (event) => {
|
|
167
|
+
const text = event.target.value;
|
|
168
|
+
const mentions = reconcile(live.current, text, lastCaret.current);
|
|
169
|
+
const added = pending.current;
|
|
170
|
+
pending.current = null;
|
|
171
|
+
if (added && text.slice(added.start, added.end) === mentionText(added.kind, added.label)) {
|
|
172
|
+
mentions.push(added);
|
|
173
|
+
mentions.sort((a, b) => a.start - b.start);
|
|
174
|
+
}
|
|
175
|
+
const next = { text, mentions };
|
|
176
|
+
live.current = next;
|
|
177
|
+
commit(next);
|
|
178
|
+
if (added)
|
|
179
|
+
onMentionAdd?.(added);
|
|
180
|
+
lastCaret.current = event.target.selectionStart;
|
|
181
|
+
setCaret(event.target.selectionStart === event.target.selectionEnd ? event.target.selectionStart : null);
|
|
182
|
+
};
|
|
183
|
+
/** Types through the browser so the change joins the native undo stack; falls back to a direct edit. */
|
|
184
|
+
const typeText = useCallback((text, from, to) => {
|
|
185
|
+
const area = areaRef.current;
|
|
186
|
+
if (!area)
|
|
187
|
+
return;
|
|
188
|
+
area.focus({ preventScroll: true });
|
|
189
|
+
if (from !== undefined)
|
|
190
|
+
area.setSelectionRange(from, to ?? from);
|
|
191
|
+
lastCaret.current = area.selectionStart;
|
|
192
|
+
const typed = typeof document.execCommand === "function" && document.execCommand("insertText", false, text);
|
|
193
|
+
if (typed)
|
|
194
|
+
return;
|
|
195
|
+
const start = area.selectionStart, end = area.selectionEnd, current = live.current;
|
|
196
|
+
const nextText = current.text.slice(0, start) + text + current.text.slice(end);
|
|
197
|
+
const mentions = reconcile(current, nextText, start);
|
|
198
|
+
const added = pending.current;
|
|
199
|
+
pending.current = null;
|
|
200
|
+
if (added)
|
|
201
|
+
mentions.push(added);
|
|
202
|
+
mentions.sort((a, b) => a.start - b.start);
|
|
203
|
+
const next = { text: nextText, mentions };
|
|
204
|
+
live.current = next;
|
|
205
|
+
commit(next);
|
|
206
|
+
if (added)
|
|
207
|
+
onMentionAdd?.(added);
|
|
208
|
+
requestAnimationFrame(() => { area.setSelectionRange(start + text.length, start + text.length); setCaret(start + text.length); });
|
|
209
|
+
}, [commit, onMentionAdd]);
|
|
210
|
+
const choose = (suggestion) => {
|
|
211
|
+
if (!suggestion || !trigger || caret === null)
|
|
212
|
+
return;
|
|
213
|
+
const label = suggestion.item.name;
|
|
214
|
+
const token = mentionText(suggestion.kind, label);
|
|
215
|
+
pending.current = { kind: suggestion.kind, id: suggestion.item.id, label, start: trigger.start, end: trigger.start + token.length };
|
|
216
|
+
const after = value.text[caret];
|
|
217
|
+
typeText(after === undefined || !/\s/.test(after) ? `${token} ` : token, trigger.start, caret);
|
|
218
|
+
};
|
|
219
|
+
const onKeyDown = (event) => {
|
|
220
|
+
const area = event.currentTarget;
|
|
221
|
+
lastCaret.current = area.selectionStart;
|
|
222
|
+
if (open) {
|
|
223
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
224
|
+
event.preventDefault();
|
|
225
|
+
if (suggestions.length)
|
|
226
|
+
setActive((activeIndex + (event.key === "ArrowDown" ? 1 : -1) + suggestions.length) % suggestions.length);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if ((event.key === "Enter" || event.key === "Tab") && suggestions.length && !event.shiftKey) {
|
|
230
|
+
event.preventDefault();
|
|
231
|
+
choose(suggestions[activeIndex]);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (event.key === "Escape") {
|
|
235
|
+
event.preventDefault();
|
|
236
|
+
event.stopPropagation();
|
|
237
|
+
setDismissed(trigger?.start ?? null);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const start = area.selectionStart, end = area.selectionEnd;
|
|
242
|
+
if (start === end && !event.metaKey && (event.key === "Backspace" || event.key === "Delete")) {
|
|
243
|
+
// Select the whole token and let the native delete run, so one keystroke removes it and undo restores it.
|
|
244
|
+
const hit = live.current.mentions.find(mention => event.key === "Backspace" ? start > mention.start && start <= mention.end : start >= mention.start && start < mention.end);
|
|
245
|
+
if (hit)
|
|
246
|
+
area.setSelectionRange(hit.start, hit.end);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (event.key === "Enter" && submitOnEnter && !event.shiftKey && !event.nativeEvent.isComposing) {
|
|
250
|
+
event.preventDefault();
|
|
251
|
+
if (live.current.text.trim())
|
|
252
|
+
onSubmit?.(live.current);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
useImperativeHandle(ref, () => ({
|
|
256
|
+
focus: () => areaRef.current?.focus(),
|
|
257
|
+
insert: text => typeText(text),
|
|
258
|
+
openSuggestions: kind => {
|
|
259
|
+
const area = areaRef.current;
|
|
260
|
+
if (!area)
|
|
261
|
+
return;
|
|
262
|
+
const at = document.activeElement === area ? area.selectionStart : live.current.text.length;
|
|
263
|
+
const before = live.current.text[at - 1];
|
|
264
|
+
setDismissed(null);
|
|
265
|
+
typeText(`${before && !/\s/.test(before) ? " " : ""}${SYMBOL[kind]}`, at, document.activeElement === area ? area.selectionEnd : at);
|
|
266
|
+
},
|
|
267
|
+
clear: () => {
|
|
268
|
+
commit(EMPTY);
|
|
269
|
+
live.current = EMPTY;
|
|
270
|
+
setCaret(0);
|
|
271
|
+
},
|
|
272
|
+
get textarea() { return areaRef.current; },
|
|
273
|
+
}), [commit, typeText]);
|
|
274
|
+
/* Autosize: the backdrop mirrors the text, so its height is the content height. The field springs to it within the row limits. */
|
|
275
|
+
const height = useMotionValue("auto");
|
|
276
|
+
const [limits, setLimits] = useState({ min: 0, max: Infinity });
|
|
277
|
+
const measured = useRef(false);
|
|
278
|
+
useLayoutEffect(() => {
|
|
279
|
+
const backdrop = backdropRef.current;
|
|
280
|
+
if (!backdrop)
|
|
281
|
+
return;
|
|
282
|
+
const style = getComputedStyle(backdrop);
|
|
283
|
+
const line = parseFloat(style.lineHeight) || 24;
|
|
284
|
+
const pad = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
|
|
285
|
+
const next = { min: line * minRows + pad, max: line * Math.max(minRows, maxRows) + pad };
|
|
286
|
+
setLimits(current => current.min === next.min && current.max === next.max ? current : next);
|
|
287
|
+
}, [maxRows, minRows]);
|
|
288
|
+
useLayoutEffect(() => {
|
|
289
|
+
const backdrop = backdropRef.current;
|
|
290
|
+
if (!backdrop || !limits.min)
|
|
291
|
+
return;
|
|
292
|
+
const fit = () => {
|
|
293
|
+
const target = Math.min(limits.max, Math.max(limits.min, backdrop.offsetHeight));
|
|
294
|
+
const current = height.get();
|
|
295
|
+
if (!measured.current || reduced || typeof current !== "number") {
|
|
296
|
+
animate(height, target, INSTANT);
|
|
297
|
+
measured.current = true;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (Math.abs(current - target) > .5)
|
|
301
|
+
animate(height, target, GROW);
|
|
302
|
+
};
|
|
303
|
+
fit();
|
|
304
|
+
if (typeof ResizeObserver === "undefined")
|
|
305
|
+
return;
|
|
306
|
+
const observer = new ResizeObserver(fit);
|
|
307
|
+
observer.observe(backdrop);
|
|
308
|
+
return () => observer.disconnect();
|
|
309
|
+
}, [height, limits, reduced]);
|
|
310
|
+
/* The popover hangs from the trigger character, measured from a marker in the mirrored text. */
|
|
311
|
+
const [anchor, setAnchor] = useState(null);
|
|
312
|
+
const measureAnchor = useCallback(() => {
|
|
313
|
+
const marker = backdropRef.current?.querySelector("[data-anchor]");
|
|
314
|
+
const root = rootRef.current, field = fieldRef.current, area = areaRef.current;
|
|
315
|
+
if (!marker || !root || !field || !area) {
|
|
316
|
+
setAnchor(null);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const line = parseFloat(getComputedStyle(marker.parentElement ?? marker).lineHeight) || 24;
|
|
320
|
+
const x = Math.max(0, Math.min(field.offsetLeft + marker.offsetLeft - 10, root.offsetWidth - POPOVER_WIDTH));
|
|
321
|
+
const top = field.offsetTop + marker.offsetTop - area.scrollTop;
|
|
322
|
+
const box = root.getBoundingClientRect();
|
|
323
|
+
const roomBelow = window.innerHeight - (box.top + top + line);
|
|
324
|
+
const above = placement === "top" || (placement === "auto" && roomBelow < 300 && box.top + top > roomBelow);
|
|
325
|
+
const y = above ? top - 6 : top + line + 6;
|
|
326
|
+
setAnchor(current => current && current.x === x && current.y === y && current.above === above && current.line === line ? current : { x, y, line, above });
|
|
327
|
+
}, [placement]);
|
|
328
|
+
useLayoutEffect(() => { if (open)
|
|
329
|
+
measureAnchor(); }, [measureAnchor, open, trigger?.start, value.text]);
|
|
330
|
+
// The popover mounts one commit after `open`, once the anchor is measured; the effects below wait for it.
|
|
331
|
+
const shown = open && !!anchor;
|
|
332
|
+
const onScroll = (event) => {
|
|
333
|
+
const backdrop = backdropRef.current;
|
|
334
|
+
if (backdrop)
|
|
335
|
+
backdrop.style.transform = `translateY(${-event.currentTarget.scrollTop}px)`;
|
|
336
|
+
measureAnchor();
|
|
337
|
+
};
|
|
338
|
+
/* The list's highlight glides between rows; the panel springs its height as matches come and go. */
|
|
339
|
+
const listRef = useRef(null);
|
|
340
|
+
const hy = useMotionValue(0), hh = useMotionValue(0);
|
|
341
|
+
const placed = useRef(false);
|
|
342
|
+
const panelHeight = useMotionValue("auto");
|
|
343
|
+
useLayoutEffect(() => {
|
|
344
|
+
const row = listRef.current?.querySelector(`[data-index="${activeIndex}"]`);
|
|
345
|
+
if (!row)
|
|
346
|
+
return;
|
|
347
|
+
if (reduced || !placed.current) {
|
|
348
|
+
animate(hy, row.offsetTop, INSTANT);
|
|
349
|
+
animate(hh, row.offsetHeight, INSTANT);
|
|
350
|
+
placed.current = true;
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
animate(hy, row.offsetTop, GLIDE);
|
|
354
|
+
animate(hh, row.offsetHeight, GLIDE);
|
|
355
|
+
}, [activeIndex, hh, hy, reduced, shown, suggestions]);
|
|
356
|
+
const bodyRef = useRef(null);
|
|
357
|
+
const panelSized = useRef(false);
|
|
358
|
+
useLayoutEffect(() => {
|
|
359
|
+
// The leaving list keeps its highlight while it fades; the next open places it fresh.
|
|
360
|
+
if (!shown) {
|
|
361
|
+
panelSized.current = false;
|
|
362
|
+
placed.current = false;
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const body = bodyRef.current;
|
|
366
|
+
if (!body)
|
|
367
|
+
return;
|
|
368
|
+
const fit = () => {
|
|
369
|
+
const target = body.offsetHeight;
|
|
370
|
+
if (!panelSized.current || reduced) {
|
|
371
|
+
animate(panelHeight, target, INSTANT);
|
|
372
|
+
panelSized.current = true;
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
animate(panelHeight, target, GROW);
|
|
376
|
+
};
|
|
377
|
+
fit();
|
|
378
|
+
if (typeof ResizeObserver === "undefined")
|
|
379
|
+
return;
|
|
380
|
+
const observer = new ResizeObserver(fit);
|
|
381
|
+
observer.observe(body);
|
|
382
|
+
return () => observer.disconnect();
|
|
383
|
+
}, [panelHeight, reduced, shown, trigger?.start]);
|
|
384
|
+
/* Mirror: the same text as the textarea, with tokens drawn in place and a marker at the open trigger. */
|
|
385
|
+
const mirror = useMemo(() => {
|
|
386
|
+
const parts = [];
|
|
387
|
+
const markAt = open && trigger ? trigger.start : -1;
|
|
388
|
+
let at = 0;
|
|
389
|
+
const pushText = (from, to) => {
|
|
390
|
+
if (markAt >= from && markAt < to) {
|
|
391
|
+
parts.push(value.text.slice(from, markAt), _jsx("span", { "data-anchor": "" }, "anchor"), value.text.slice(markAt, to));
|
|
392
|
+
}
|
|
393
|
+
else
|
|
394
|
+
parts.push(value.text.slice(from, to));
|
|
395
|
+
};
|
|
396
|
+
for (const mention of value.mentions) {
|
|
397
|
+
pushText(at, mention.start);
|
|
398
|
+
parts.push(_jsx("span", { className: styles.token, "data-kind": mention.kind, children: value.text.slice(mention.start, mention.end) }, `${mention.start}-${mention.id}`));
|
|
399
|
+
at = mention.end;
|
|
400
|
+
}
|
|
401
|
+
pushText(at, value.text.length);
|
|
402
|
+
return parts;
|
|
403
|
+
}, [open, trigger, value.mentions, value.text]);
|
|
404
|
+
const optionId = (index) => `${uid}-option-${index}`;
|
|
405
|
+
const popIn = reduced ? { opacity: 0 } : { opacity: 0, scale: .96, y: anchor?.above ? 4 : -4 };
|
|
406
|
+
const countLabel = open ? (suggestions.length ? `${suggestions.length} ${trigger?.kind === "person" ? (suggestions.length === 1 ? "person" : "people") : (suggestions.length === 1 ? "channel" : "channels")}` : "No matches") : "";
|
|
407
|
+
return _jsxs("div", { ref: rootRef, className: [styles.root, className].filter(Boolean).join(" "), "data-disabled": disabled || undefined, children: [_jsxs(motion.div, { ref: fieldRef, className: styles.field, style: { height }, "data-focused": focused || undefined, children: [_jsxs("div", { ref: backdropRef, className: styles.backdrop, "aria-hidden": "true", children: [mirror, "\u200b"] }), _jsx("textarea", { ref: areaRef, id: id, name: name, className: styles.textarea, value: value.text, placeholder: placeholder, disabled: disabled, rows: minRows, spellCheck: true, role: "combobox", "aria-label": ariaLabel, "aria-describedby": describedBy, "aria-autocomplete": "list", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listId : undefined, "aria-activedescendant": open && suggestions.length ? optionId(activeIndex) : undefined, onChange: onInput, onSelect: onSelect, onKeyDown: onKeyDown, onScroll: onScroll, onFocus: () => setFocused(true), onBlur: () => { setFocused(false); setCaret(null); } })] }), _jsx("span", { className: styles.srOnly, "aria-live": "polite", children: countLabel }), _jsx(AnimatePresence, { children: open && anchor && _jsx(motion.div, { className: styles.popover, "data-above": anchor.above || undefined, style: { left: anchor.x, top: anchor.y, height: panelHeight, transformOrigin: anchor.above ? "14px 100%" : "14px 0" }, initial: popIn, animate: { opacity: 1, scale: 1, y: 0, transition: reduced ? { duration: duration.fast } : { ...spring.snappy, opacity: { duration: duration.fast, ease: enter } } }, exit: { ...popIn, transition: { duration: duration.exit * .7, ease: standard } }, onMouseDown: event => event.preventDefault(), children: _jsx("div", { ref: bodyRef, className: styles.popoverBody, children: suggestions.length ? _jsxs("ul", { ref: listRef, id: listId, role: "listbox", "aria-label": trigger?.kind === "person" ? "People" : "Channels", className: styles.list, children: [_jsx(motion.li, { "aria-hidden": "true", className: styles.highlight, style: { y: hy, height: hh } }), suggestions.map((suggestion, index) => _jsxs("li", { id: optionId(index), role: "option", "aria-selected": index === activeIndex, "data-index": index, className: styles.option, onPointerMove: () => { if (index !== activeIndex)
|
|
408
|
+
setActive(index); }, onClick: () => choose(suggestion), children: [suggestion.kind === "person"
|
|
409
|
+
? _jsx("span", { className: styles.avatar, "aria-hidden": "true", children: suggestion.item.avatar
|
|
410
|
+
// eslint-disable-next-line @next/next/no-img-element -- registry components stay framework agnostic
|
|
411
|
+
? _jsx("img", { src: suggestion.item.avatar, alt: "", width: 28, height: 28, loading: "lazy" })
|
|
412
|
+
: initials(suggestion.item.name) })
|
|
413
|
+
: _jsx("span", { className: styles.glyph, "aria-hidden": "true", children: _jsx(Hash, { size: 16, strokeWidth: 1.75 }) }), _jsxs("span", { className: styles.optionText, children: [_jsx("span", { className: styles.optionLabel, children: _jsx(Highlight, { text: suggestion.item.name, query: trigger?.query.trim() ?? "" }) }), suggestion.kind === "person"
|
|
414
|
+
? suggestion.item.role && _jsx("span", { className: styles.optionMeta, children: suggestion.item.role })
|
|
415
|
+
: suggestion.item.description && _jsx("span", { className: styles.optionMeta, children: suggestion.item.description })] }), suggestion.kind === "channel" && suggestion.item.members !== undefined && _jsx("span", { className: styles.count, children: suggestion.item.members })] }, `${suggestion.kind}-${suggestion.item.id}`))] }) : _jsxs("p", { className: styles.empty, children: ["No ", trigger?.kind === "person" ? "people" : "channels", " match \u201C", trigger?.query, "\u201D"] }) }) }, trigger?.start) })] });
|
|
416
|
+
});
|
|
417
|
+
export default MentionInput;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
position: relative;
|
|
3
|
+
width: 100%;
|
|
4
|
+
color: var(--foreground);
|
|
5
|
+
font-family: var(--font-body);
|
|
6
|
+
letter-spacing: var(--tracking-body);
|
|
7
|
+
}
|
|
8
|
+
.root[data-disabled] { opacity: .55; }
|
|
9
|
+
|
|
10
|
+
/* The field's height springs to its text. The mirror and the textarea share every metric so tokens sit exactly under their characters. */
|
|
11
|
+
.field {
|
|
12
|
+
position: relative;
|
|
13
|
+
box-sizing: content-box;
|
|
14
|
+
overflow: hidden;
|
|
15
|
+
border: 1px solid var(--border);
|
|
16
|
+
border-radius: var(--radius-control); corner-shape: var(--corner-shape);
|
|
17
|
+
background: var(--surface);
|
|
18
|
+
transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard);
|
|
19
|
+
}
|
|
20
|
+
.field[data-focused] { border-color: var(--border-strong); }
|
|
21
|
+
|
|
22
|
+
.backdrop,
|
|
23
|
+
.textarea {
|
|
24
|
+
box-sizing: border-box;
|
|
25
|
+
width: 100%;
|
|
26
|
+
margin: 0;
|
|
27
|
+
padding: 11px 14px;
|
|
28
|
+
border: 0;
|
|
29
|
+
font: inherit;
|
|
30
|
+
font-size: var(--text-base);
|
|
31
|
+
letter-spacing: var(--tracking-body);
|
|
32
|
+
line-height: 24px;
|
|
33
|
+
overflow-wrap: break-word;
|
|
34
|
+
white-space: pre-wrap;
|
|
35
|
+
word-break: break-word;
|
|
36
|
+
tab-size: 4;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.backdrop {
|
|
40
|
+
position: absolute;
|
|
41
|
+
top: 0;
|
|
42
|
+
left: 0;
|
|
43
|
+
color: var(--foreground);
|
|
44
|
+
pointer-events: none;
|
|
45
|
+
will-change: transform;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.textarea {
|
|
49
|
+
position: absolute;
|
|
50
|
+
inset: 0;
|
|
51
|
+
height: 100%;
|
|
52
|
+
display: block;
|
|
53
|
+
resize: none;
|
|
54
|
+
overflow-y: auto;
|
|
55
|
+
background: transparent;
|
|
56
|
+
color: transparent;
|
|
57
|
+
caret-color: var(--foreground);
|
|
58
|
+
outline: none;
|
|
59
|
+
scrollbar-width: none;
|
|
60
|
+
-webkit-text-fill-color: transparent;
|
|
61
|
+
}
|
|
62
|
+
.textarea::-webkit-scrollbar { display: none; }
|
|
63
|
+
.textarea::placeholder { color: var(--text-muted); -webkit-text-fill-color: var(--text-muted); opacity: 1; }
|
|
64
|
+
.textarea::selection { background: color-mix(in oklab, var(--accent) 24%, transparent); }
|
|
65
|
+
.textarea:disabled { cursor: not-allowed; }
|
|
66
|
+
|
|
67
|
+
/* A token is tinted around its own glyphs; the negative margin keeps every character where the textarea put it. */
|
|
68
|
+
.token {
|
|
69
|
+
margin: 0 -3px;
|
|
70
|
+
padding: 1px 3px;
|
|
71
|
+
border-radius: var(--radius-detail); corner-shape: var(--corner-shape);
|
|
72
|
+
background: var(--accent-subtle);
|
|
73
|
+
color: var(--accent-strong);
|
|
74
|
+
box-decoration-break: clone;
|
|
75
|
+
-webkit-box-decoration-break: clone;
|
|
76
|
+
}
|
|
77
|
+
.token[data-kind="channel"] { background: color-mix(in oklab, var(--foreground) 7%, transparent); color: var(--foreground); }
|
|
78
|
+
|
|
79
|
+
.popover {
|
|
80
|
+
position: absolute;
|
|
81
|
+
z-index: 30;
|
|
82
|
+
width: 272px;
|
|
83
|
+
max-width: 100%;
|
|
84
|
+
overflow: hidden;
|
|
85
|
+
border: 1px solid var(--border);
|
|
86
|
+
border-radius: var(--radius-control); corner-shape: var(--corner-shape);
|
|
87
|
+
background: var(--surface-raised);
|
|
88
|
+
box-shadow: var(--shadow-floating);
|
|
89
|
+
}
|
|
90
|
+
.popover[data-above] { translate: 0 -100%; }
|
|
91
|
+
.popoverBody { padding: 6px; }
|
|
92
|
+
|
|
93
|
+
.list { position: relative; display: grid; margin: 0; padding: 0; list-style: none; }
|
|
94
|
+
.highlight { position: absolute; top: 0; right: 0; left: 0; border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: color-mix(in oklab, var(--foreground) 6.5%, transparent); pointer-events: none; }
|
|
95
|
+
|
|
96
|
+
.option {
|
|
97
|
+
position: relative;
|
|
98
|
+
display: flex;
|
|
99
|
+
min-width: 0;
|
|
100
|
+
align-items: center;
|
|
101
|
+
gap: 10px;
|
|
102
|
+
padding: 7px 10px 7px 8px;
|
|
103
|
+
border-radius: var(--radius-control); corner-shape: var(--corner-shape);
|
|
104
|
+
cursor: pointer;
|
|
105
|
+
font-size: var(--text-sm);
|
|
106
|
+
-webkit-tap-highlight-color: transparent;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.avatar {
|
|
110
|
+
display: grid;
|
|
111
|
+
width: 28px;
|
|
112
|
+
height: 28px;
|
|
113
|
+
flex: none;
|
|
114
|
+
overflow: hidden;
|
|
115
|
+
place-items: center;
|
|
116
|
+
border-radius: 50%; corner-shape: round;
|
|
117
|
+
background: var(--surface-muted);
|
|
118
|
+
color: var(--text-secondary);
|
|
119
|
+
font-size: var(--text-xs);
|
|
120
|
+
font-weight: 500;
|
|
121
|
+
}
|
|
122
|
+
.avatar img { width: 100%; height: 100%; object-fit: cover; }
|
|
123
|
+
.glyph { display: grid; width: 28px; height: 28px; flex: none; place-items: center; color: var(--text-secondary); }
|
|
124
|
+
|
|
125
|
+
.optionText { display: grid; min-width: 0; flex: 1; }
|
|
126
|
+
.optionLabel { overflow: hidden; color: var(--text-secondary); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
|
|
127
|
+
.option[aria-selected="true"] .optionLabel { color: var(--foreground); }
|
|
128
|
+
.match { background: none; color: var(--foreground); }
|
|
129
|
+
.optionMeta { overflow: hidden; color: var(--text-muted); font-size: var(--text-xs); text-overflow: ellipsis; white-space: nowrap; }
|
|
130
|
+
.count { flex: none; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
|
|
131
|
+
|
|
132
|
+
.empty { margin: 0; padding: 10px 10px; color: var(--text-secondary); font-size: var(--text-sm); }
|
|
133
|
+
|
|
134
|
+
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react";
|
|
5
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
6
|
+
import { AnimatedCounter } from "../animated-counter/animated-counter.js";
|
|
7
|
+
import styles from "./metric-card.module.css";
|
|
8
|
+
/** Copy that holds a number enters from the side it moved toward: a larger value rises from below, a smaller one drops from above. */
|
|
9
|
+
const rise = { hidden: (direction) => ({ opacity: 0, y: `${.3 * direction}em`, filter: `blur(${motionTokens.blur.soft}px)` }), shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } }, gone: (direction) => ({ opacity: 0, y: `${-.3 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } }) };
|
|
10
|
+
const fade = { hidden: { opacity: 0, y: 0, filter: "blur(0px)" }, shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.instant } }, gone: { opacity: 0, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.instant } } };
|
|
11
|
+
const amountIn = (text) => Number(text.replace(/,/g, "").match(/-?\d+(?:\.\d+)?/)?.[0] ?? NaN);
|
|
12
|
+
/** New copy rises in while the old copy leaves; `morph` springs the wrapper to the new text's width instead of letting it snap. */
|
|
13
|
+
function Swap({ text, morph = false, block = false }) {
|
|
14
|
+
const reduceMotion = !!useReducedMotion();
|
|
15
|
+
const sizer = useRef(null);
|
|
16
|
+
const width = useMotionValue("auto");
|
|
17
|
+
const [shown, setShown] = useState({ text, direction: 1 });
|
|
18
|
+
if (shown.text !== text)
|
|
19
|
+
setShown({ text, direction: amountIn(text) < amountIn(shown.text) ? -1 : 1 });
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const node = sizer.current;
|
|
22
|
+
if (!node || typeof ResizeObserver === "undefined")
|
|
23
|
+
return;
|
|
24
|
+
let measured = null;
|
|
25
|
+
// Layout size, not the transformed rect, so a scaling parent never leaves the text clipped. Only a new text springs; font loads jump.
|
|
26
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
27
|
+
if (!entry)
|
|
28
|
+
return;
|
|
29
|
+
const next = entry.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth;
|
|
30
|
+
if (next && measured !== null && measured !== node.textContent && !reduceMotion)
|
|
31
|
+
animate(width, next, motionTokens.spring.morph);
|
|
32
|
+
else
|
|
33
|
+
width.jump(next || "auto");
|
|
34
|
+
measured = next ? node.textContent : null;
|
|
35
|
+
});
|
|
36
|
+
observer.observe(node);
|
|
37
|
+
return () => observer.disconnect();
|
|
38
|
+
}, [morph, reduceMotion, width]);
|
|
39
|
+
return _jsxs(motion.span, { className: block ? styles.swapBlock : styles.swap, style: morph ? { width } : undefined, children: [morph && _jsx("span", { ref: sizer, className: styles.sizer, "aria-hidden": "true", children: text }), _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: shown.direction, children: _jsx(motion.span, { className: styles.text, custom: shown.direction, variants: reduceMotion ? fade : rise, initial: "hidden", animate: "shown", exit: "gone", children: text }, text) })] });
|
|
40
|
+
}
|
|
41
|
+
export function MetricCard({ label, value, suffix, context, change }) {
|
|
42
|
+
const reduceMotion = !!useReducedMotion();
|
|
43
|
+
return _jsxs("article", { className: styles.card, children: [_jsxs("div", { className: styles.top, children: [_jsx("span", { children: _jsx(Swap, { text: label, block: true }) }), _jsx(AnimatePresence, { initial: false, children: change && _jsx(motion.small, { "data-trend": /^[+]/.test(change) ? "up" : /^[-−]/.test(change) ? "down" : undefined, initial: { opacity: 0, scale: reduceMotion ? 1 : .96 }, animate: { opacity: 1, scale: 1 }, exit: { opacity: 0, scale: reduceMotion ? 1 : .96, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } }, transition: reduceMotion ? { duration: 0 } : motionTokens.spring.snappy, children: _jsx(Swap, { text: change, morph: true }) }, "change") })] }), _jsx(AnimatedCounter, { value: value, suffix: suffix, animateOnView: true }), _jsx("p", { children: _jsx(Swap, { text: context, block: true }) })] });
|
|
44
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
.card { min-width: 0; border: 1px solid var(--border); border-radius: var(--radius-overlay); corner-shape: var(--corner-shape); padding: var(--space-section); background: var(--surface); box-shadow: var(--shadow-resting); }
|
|
2
|
+
.top { display: flex; align-items: center; justify-content: space-between; gap: var(--space-group); margin-bottom: var(--space-block); }
|
|
3
|
+
.top span { color: var(--text-secondary); font-size: var(--text-sm); }
|
|
4
|
+
.top small { display: inline-flex; flex: 0 0 auto; border: 1px solid var(--border); border-radius: var(--radius-full); corner-shape: round; padding: 3px 8px; color: var(--text-secondary); font-size: var(--text-sm); font-variant-numeric: tabular-nums; }
|
|
5
|
+
.card p { margin: var(--space-inset) 0 0; color: var(--text-muted); font-size: var(--text-sm); line-height: var(--leading-body); }
|
|
6
|
+
@media (max-width: 380px) { .card { padding: var(--space-inset); } .top { align-items: flex-start; flex-wrap: wrap; margin-bottom: var(--space-section); } }
|
|
7
|
+
/* Text swaps: the outgoing copy is popped out of flow so the wrapper never holds both widths. */
|
|
8
|
+
.swap, .swapBlock { position: relative; max-width: 100%; }
|
|
9
|
+
.swap { display: inline-flex; overflow-x: clip; white-space: nowrap; vertical-align: top; }
|
|
10
|
+
.swapBlock { display: block; }
|
|
11
|
+
.swap > .text { display: inline-block; }
|
|
12
|
+
.swapBlock > .text { display: block; }
|
|
13
|
+
.sizer { position: absolute; top: 0; left: 0; visibility: hidden; white-space: nowrap; pointer-events: none; }
|
|
14
|
+
/* A signed change reads its direction in color as well as sign: a faint tint behind, the color on the text. */
|
|
15
|
+
.top small[data-trend] span { color: inherit; }
|
|
16
|
+
.top small[data-trend] { border-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
|
|
17
|
+
.top small[data-trend="up"] { background: color-mix(in oklab, var(--success) 11%, transparent); color: var(--success); }
|
|
18
|
+
.top small[data-trend="down"] { background: color-mix(in oklab, var(--danger) 11%, transparent); color: var(--danger); }
|