@morlay/dsh-client-ui-primitives 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +31 -6
  2. package/dist/client.d.mts +186 -11175
  3. package/dist/client.mjs +3730 -0
  4. package/locale/en.json +1 -1
  5. package/locale/zh.json +1 -1
  6. package/package.json +23 -34
  7. package/src/client/controls/Bubble.module.css +17 -0
  8. package/src/client/controls/Bubble.tsx +17 -0
  9. package/src/client/controls/Button.module.css +10 -0
  10. package/src/client/controls/Button.tsx +29 -0
  11. package/src/client/controls/DisclosureRow.module.css +22 -0
  12. package/src/client/controls/DisclosureRow.tsx +20 -0
  13. package/src/client/controls/IconButton.module.css +50 -0
  14. package/src/client/controls/IconButton.tsx +45 -0
  15. package/src/client/controls/ModelRouteList.module.css +100 -0
  16. package/src/client/controls/ModelRouteList.tsx +118 -0
  17. package/src/client/controls/MultilineField.module.css +26 -0
  18. package/src/client/controls/MultilineField.tsx +19 -0
  19. package/src/client/controls/SearchInput.module.css +11 -0
  20. package/src/client/controls/SearchInput.tsx +22 -0
  21. package/src/client/controls/SearchSelect.module.css +34 -0
  22. package/src/client/controls/SearchSelect.tsx +138 -0
  23. package/src/client/controls/SeatButton.module.css +53 -0
  24. package/src/client/controls/SeatButton.tsx +40 -0
  25. package/src/client/controls/Spinner.module.css +18 -0
  26. package/src/client/controls/Spinner.tsx +14 -0
  27. package/src/client/controls/Tabs.module.css +48 -0
  28. package/src/client/controls/Tabs.tsx +57 -0
  29. package/src/client/controls/TagInput.module.css +105 -0
  30. package/src/client/controls/TagInput.tsx +152 -0
  31. package/src/client/controls/TimeLabel.module.css +16 -0
  32. package/src/client/controls/TimeLabel.tsx +15 -0
  33. package/src/client/controls/VisuallyHidden.module.css +9 -0
  34. package/src/client/controls/VisuallyHidden.tsx +16 -0
  35. package/src/client/controls/classes.ts +7 -0
  36. package/src/client/controls/index.ts +30 -0
  37. package/src/client/controls/tags.ts +24 -0
  38. package/src/client/index.ts +17 -9
  39. package/src/client/layout/Row.tsx +86 -0
  40. package/src/client/layout/Stack.tsx +79 -0
  41. package/src/client/layout/Text.tsx +54 -0
  42. package/src/client/layout/index.ts +16 -0
  43. package/src/client/layout/layout.module.css +217 -0
  44. package/src/client/layout/scales.ts +91 -0
  45. package/src/client/schema-form/Editor.tsx +61 -70
  46. package/src/client/schema-form/SchemaForm.tsx +4 -4
  47. package/src/client/schema-form/TruncatedTooltip.tsx +60 -0
  48. package/src/client/schema-form/controller.ts +5 -2
  49. package/src/client/schema-form/geometry.ts +3 -0
  50. package/src/client/schema-form/index.ts +11 -0
  51. package/src/client/schema-form/schema-form.module.css +335 -0
  52. package/src/client/schema-form/testing/resize-observer-stub.ts +63 -0
  53. package/src/client/schema-form/value.tsx +18 -15
  54. package/src/client/settings-form/SettingsFieldRow.module.css +93 -0
  55. package/src/client/settings-form/SettingsFieldRow.tsx +103 -0
  56. package/src/client/settings-form/index.ts +5 -18
  57. package/src/css-modules.d.ts +6 -0
  58. package/dist/client.cjs +0 -17075
  59. package/dist/client.d.cts +0 -11656
  60. package/dist/index.d.cts +0 -5
  61. package/dist/reference-BX54bj6_.d.cts +0 -30
  62. package/src/client/schema-form/styles.ts +0 -298
  63. package/src/client/settings-form/SettingsForm.styles.ts +0 -58
  64. package/src/client/settings-form/SettingsForm.tsx +0 -68
  65. package/src/client/settings-form/fields.styles.ts +0 -158
  66. package/src/client/settings-form/fields.tsx +0 -184
  67. package/src/client/settings-form/form-model.ts +0 -339
  68. package/src/client/styling/css.ts +0 -27
  69. package/src/client/styling/focus.ts +0 -9
  70. package/src/client/styling/index.ts +0 -6
  71. package/src/client/styling/styled.tsx +0 -54
  72. package/src/client/styling/styling.ts +0 -137
  73. package/src/client/styling/token.ts +0 -275
  74. package/src/client/styling/toolkit.ts +0 -37
  75. package/src/client/theme.generated.ts +0 -833
  76. package/src/client/theme.ts +0 -5
@@ -0,0 +1,138 @@
1
+ // 可搜索选择器(searchable):候选一多,"滚着找"就不够用了——打开后先给一行搜索框,按名字模糊过滤
2
+ // (官方 `rankByName`:前缀命中优先,其次对齐分数,最后保持来源顺序)。
3
+ //
4
+ // 它与标签输入(multi-input)共用同一套"搜索 + 候选"交互:TagInput 的「从候选里选」就是它的单值形态。
5
+
6
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
7
+ import {
8
+ Button,
9
+ IconCheckOutlineRegular,
10
+ Menu,
11
+ MenuItemButton,
12
+ rankByName,
13
+ } from "@deepseek-ai/dsh-client-ui-primitives";
14
+ import { classes } from "./classes.ts";
15
+ import css from "./SearchSelect.module.css";
16
+
17
+ // 一项候选:值是写进配置的那份,label 是给人看的(也是搜索的第二个键)。
18
+ export interface SearchSelectOption {
19
+ readonly value: string;
20
+ readonly label?: string | undefined;
21
+ }
22
+
23
+ export interface SearchSelectProps {
24
+ // 无障碍名(触发按钮的名字)。
25
+ label: string;
26
+ // 当前值;空串表示这一项没写(触发按钮显示 `emptyLabel`)。
27
+ value: string;
28
+ // 没写时触发按钮显示的那句话(例如"不写"),它也是候选里的第一项(选它 = 清掉)。
29
+ emptyLabel: string;
30
+ options: readonly SearchSelectOption[];
31
+ disabled?: boolean;
32
+ // 搜索框的占位与无障碍名。
33
+ searchLabel: string;
34
+ // 一个候选都没匹配上时的那句话。
35
+ noMatchLabel: string;
36
+ // 选中一项;空串 = 清掉这一项。
37
+ onSelect: (next: string) => void;
38
+ }
39
+
40
+ export function SearchSelect({
41
+ label,
42
+ value,
43
+ emptyLabel,
44
+ options,
45
+ disabled = false,
46
+ searchLabel,
47
+ noMatchLabel,
48
+ onSelect,
49
+ }: SearchSelectProps): ReactNode {
50
+ const [open, setOpen] = useState(false);
51
+ const [query, setQuery] = useState("");
52
+ const searchRef = useRef<HTMLInputElement | null>(null);
53
+ // 打开就落到搜索框上:候选多的时候,键盘直接开始打字。
54
+ useEffect(() => {
55
+ if (open) searchRef.current?.focus();
56
+ }, [open]);
57
+ const matches = useMemo(
58
+ () =>
59
+ rankByName(
60
+ // 搜索键是显示名(`name`)与它本身(`label`,缺省时就是值):`rankByName` 拿 name 与 label 双键匹配。
61
+ options.map((option) => ({
62
+ value: option.value,
63
+ name: option.label ?? option.value,
64
+ label: option.value,
65
+ })),
66
+ query,
67
+ ),
68
+ [options, query],
69
+ );
70
+ const current = options.find((option) => option.value === value);
71
+ const close = (): void => {
72
+ setOpen(false);
73
+ setQuery("");
74
+ };
75
+ const pick = (next: string): void => {
76
+ close();
77
+ onSelect(next);
78
+ };
79
+ return (
80
+ <Menu
81
+ open={open}
82
+ anchor={
83
+ <Button
84
+ variant="outline"
85
+ size="sm"
86
+ data-role="search-select"
87
+ className={classes(css.trigger)}
88
+ data-action="pick"
89
+ disabled={disabled}
90
+ aria-haspopup="menu"
91
+ aria-label={label}
92
+ onClick={() => {
93
+ if (open) close();
94
+ else setOpen(true);
95
+ }}
96
+ >
97
+ {value === "" ? emptyLabel : (current?.label ?? value)}
98
+ </Button>
99
+ }
100
+ selectedId={value}
101
+ onSelect={pick}
102
+ onClose={close}
103
+ >
104
+ <div className={css.searchRow}>
105
+ <input
106
+ className={css.searchInput}
107
+ ref={searchRef}
108
+ value={query}
109
+ aria-label={searchLabel}
110
+ placeholder={searchLabel}
111
+ onChange={(event) => {
112
+ setQuery(event.currentTarget.value);
113
+ }}
114
+ />
115
+ </div>
116
+ <MenuItemButton
117
+ icon={value === "" ? <IconCheckOutlineRegular /> : undefined}
118
+ onSelect={() => {
119
+ pick("");
120
+ }}
121
+ >
122
+ {emptyLabel}
123
+ </MenuItemButton>
124
+ {matches.map((option) => (
125
+ <MenuItemButton
126
+ key={option.value}
127
+ icon={option.value === value ? <IconCheckOutlineRegular /> : undefined}
128
+ onSelect={() => {
129
+ pick(option.value);
130
+ }}
131
+ >
132
+ {option.name}
133
+ </MenuItemButton>
134
+ ))}
135
+ {matches.length === 0 ? <p className={css.empty}>{noMatchLabel}</p> : null}
136
+ </Menu>
137
+ );
138
+ }
@@ -0,0 +1,53 @@
1
+ /* 座位按钮:胶囊形(16px 圆角、28px 高、8px 内距),标签以省略号收窄,图标与箭头贴住剩余文本。 */
2
+ .seat {
3
+ display: inline-flex;
4
+ align-items: center;
5
+ gap: 4px;
6
+ min-width: 0;
7
+ max-width: min(100%, 240px);
8
+ min-height: 28px;
9
+ padding: 0 8px;
10
+ border: none;
11
+ border-radius: var(--dsw-radius-sm);
12
+ background: transparent;
13
+ color: var(--dsw-alias-label-primary);
14
+ font-size: 13px;
15
+ line-height: 20px;
16
+ font-weight: 500;
17
+ white-space: nowrap;
18
+ cursor: pointer;
19
+ }
20
+
21
+ .seat:not(:disabled):hover,
22
+ .seat[aria-expanded='true'],
23
+ .expanded {
24
+ background: var(--dsw-alias-interactive-bg-hover);
25
+ }
26
+
27
+ .seat:disabled {
28
+ cursor: default;
29
+ color: var(--dsw-alias-label-quaternary);
30
+ }
31
+
32
+ /* 图标直接是 flex item(上游给图标自己挂类,不再套一层):套一层会让它在 inline-flex 里按基线对齐,视觉上偏。 */
33
+ .icon {
34
+ display: inline-flex;
35
+ align-items: center;
36
+ flex: none;
37
+ color: var(--dsw-alias-label-primary);
38
+ }
39
+
40
+ .label {
41
+ min-width: 0;
42
+ overflow: hidden;
43
+ text-overflow: ellipsis;
44
+ white-space: nowrap;
45
+ }
46
+
47
+ .chevron {
48
+ /* inline-flex 而不是 inline:inline 的座位会在 svg 下面预留 baseline descent,把箭头顶得不在 28px 的
49
+ 座位里居中(上游 `PermissionSelect.module.css` 的 `.chevron` 记着同一条)。 */
50
+ display: inline-flex;
51
+ flex: 0 0 auto;
52
+ color: var(--dsw-alias-label-caption);
53
+ }
@@ -0,0 +1,40 @@
1
+ // 座位按钮:工具行里的一个"座位"——图标 + 文本(+ 可展开的箭头),胶囊形,展开时保持底色。
2
+ // composer 里的模式座位、以及任何"点开是一份清单"的行内入口都用它。
3
+
4
+ import type { ButtonHTMLAttributes, ReactNode } from "react";
5
+ import { classes } from "./classes.ts";
6
+ import css from "./SeatButton.module.css";
7
+
8
+ export interface SeatButtonProps
9
+ extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
10
+ // 座位上的图标(16px 那一档)。
11
+ icon: ReactNode;
12
+ // 座位上的文本;放不下就以省略号收窄。
13
+ label: string;
14
+ // 展开态:菜单开着时保持底色(与 `aria-expanded` 同一件事,这里只管观感)。
15
+ expanded?: boolean;
16
+ // 尾部箭头(可展开的座位给)。
17
+ chevron?: ReactNode;
18
+ }
19
+
20
+ export function SeatButton({
21
+ icon,
22
+ label,
23
+ expanded = false,
24
+ chevron,
25
+ type = "button",
26
+ className,
27
+ ...rest
28
+ }: SeatButtonProps): ReactNode {
29
+ return (
30
+ <button
31
+ type={type}
32
+ className={classes(css.seat, expanded ? css.expanded : undefined, className)}
33
+ {...rest}
34
+ >
35
+ <span className={css.icon}>{icon}</span>
36
+ <span className={css.label}>{label}</span>
37
+ {chevron === undefined ? null : <span className={css.chevron}>{chevron}</span>}
38
+ </button>
39
+ );
40
+ }
@@ -0,0 +1,18 @@
1
+ /* 加载指示:一圈细描边 + 一段主色弧,转起来。 */
2
+ .spinner {
3
+ width: 28px;
4
+ height: 28px;
5
+ border: 2px solid var(--dsw-alias-border-l2);
6
+ border-top-color: var(--dsw-alias-label-primary);
7
+ border-radius: 50%;
8
+ animation: spin 900ms linear infinite;
9
+ }
10
+
11
+ @keyframes spin {
12
+ from {
13
+ transform: rotate(0deg);
14
+ }
15
+ to {
16
+ transform: rotate(360deg);
17
+ }
18
+ }
@@ -0,0 +1,14 @@
1
+ // 加载指示:官方那套没有这个原子(`StateDot` 是状态点、`TextShimmer` 是文字微光),
2
+ // 列表与设置面的「正在读」都用它。
3
+
4
+ import type { ReactNode } from "react";
5
+ import css from "./Spinner.module.css";
6
+
7
+ export interface SpinnerProps {
8
+ // 无障碍名(转圈本身没有文字)。
9
+ label: string;
10
+ }
11
+
12
+ export function Spinner({ label }: SpinnerProps): ReactNode {
13
+ return <span className={css.spinner} role="status" aria-label={label} />;
14
+ }
@@ -0,0 +1,48 @@
1
+ /* 页内的 tab 条:与官方会话头部同形(`ConversationRoot.module.css` 的 .tabs/.tab/.tabActive),
2
+ 选中态是品牌蓝文字 + 2px 下划线。 */
3
+ .tabs {
4
+ position: relative;
5
+ z-index: 1;
6
+ display: flex;
7
+ gap: 36px;
8
+ margin-top: 10px;
9
+ padding-left: 8px;
10
+ flex: none;
11
+ }
12
+
13
+ .tab {
14
+ position: relative;
15
+ padding: 0 0 9px;
16
+ border: none;
17
+ background: transparent;
18
+ font-size: 13px;
19
+ line-height: 16px;
20
+ font-weight: 500;
21
+ color: var(--dsw-alias-label-tertiary);
22
+ cursor: pointer;
23
+ }
24
+
25
+ /* 下划线始终占位(透明到选中),选中时只换颜色——文字因此不会跳。 */
26
+ .tab::after {
27
+ content: "";
28
+ position: absolute;
29
+ right: 0;
30
+ bottom: -1px;
31
+ left: 0;
32
+ height: 2px;
33
+ border-radius: 2px;
34
+ background: transparent;
35
+ }
36
+
37
+ .tabActive {
38
+ color: var(--dsw-alias-state-business-primary);
39
+ }
40
+
41
+ .tabActive::after {
42
+ background: var(--dsw-alias-state-business-primary);
43
+ }
44
+
45
+ .tab:disabled {
46
+ cursor: not-allowed;
47
+ opacity: 0.4;
48
+ }
@@ -0,0 +1,57 @@
1
+ // 页内的 tab 条(下划线那一档):与官方会话头部的 tab 条同形——选中是品牌蓝文字 + 2px 下划线。
2
+ // 官方没有这个原子(`SegmentedTabs` 是分段控件那一档),所以住这里。
3
+
4
+ import type { ReactNode } from "react";
5
+ import { classes } from "./classes.ts";
6
+ import css from "./Tabs.module.css";
7
+
8
+ export interface TabItem {
9
+ readonly value: string;
10
+ readonly label: string;
11
+ }
12
+
13
+ export interface TabsProps {
14
+ // 这一组的无障碍名。
15
+ label: string;
16
+ items: readonly TabItem[];
17
+ // 当前选中的那个。
18
+ value: string;
19
+ onChange: (value: string) => void;
20
+ // 无障碍角色:切视图用 `tablist`,切口径(时间范围这类)用 `radiogroup`。
21
+ role?: "tablist" | "radiogroup";
22
+ disabled?: boolean;
23
+ }
24
+
25
+ export function Tabs({
26
+ label,
27
+ items,
28
+ value,
29
+ onChange,
30
+ role = "tablist",
31
+ disabled = false,
32
+ }: TabsProps): ReactNode {
33
+ return (
34
+ <div className={css.tabs} role={role} aria-label={label}>
35
+ {items.map((item) => {
36
+ const active = item.value === value;
37
+ return (
38
+ <button
39
+ key={item.value}
40
+ type="button"
41
+ role={role === "tablist" ? "tab" : "radio"}
42
+ data-tab={item.value}
43
+ aria-selected={role === "tablist" ? active : undefined}
44
+ aria-checked={role === "tablist" ? undefined : active}
45
+ disabled={disabled}
46
+ className={classes(css.tab, active ? css.tabActive : undefined)}
47
+ onClick={() => {
48
+ onChange(item.value);
49
+ }}
50
+ >
51
+ {item.label}
52
+ </button>
53
+ );
54
+ })}
55
+ </div>
56
+ );
57
+ }
@@ -0,0 +1,105 @@
1
+ /* 标签输入与它右边的候选菜单:一行,顶部对齐(框会随标签换行变高)。 */
2
+ .row {
3
+ display: flex;
4
+ align-items: flex-start;
5
+ gap: 8px;
6
+ width: 100%;
7
+ }
8
+
9
+ /* 候选菜单包锚点的那层 wrapper:官方 `Menu.module.css` 的 `.root` 是 `display: inline-flex`(宽度按内容收缩),
10
+ 框因此只有内容那么宽。这一条把它撑满——框的宽度归外面那行,不归锚点自己的内容。 */
11
+ .anchor {
12
+ display: flex;
13
+ flex: 1 1 auto;
14
+ width: 100%;
15
+ min-width: 0;
16
+ }
17
+
18
+ /* 框:与官方输入框同一种边框 / 背景 / 高度,标签与内联输入都在框里,放不下就换行。 */
19
+ .box {
20
+ box-sizing: border-box;
21
+ display: flex;
22
+ flex-flow: row wrap;
23
+ align-items: center;
24
+ gap: 4px 6px;
25
+ width: 100%;
26
+ min-height: 32px;
27
+ padding: 4px 8px;
28
+ background: var(--dsw-alias-bg-layer-3);
29
+ border: 0.5px solid var(--dsw-alias-border-l4);
30
+ border-radius: var(--dsw-radius-md);
31
+ }
32
+
33
+ /* 框内的裸输入:边框、背景与焦点环都交给外面那个框。 */
34
+ .input {
35
+ flex: 1 1 120px;
36
+ min-width: 120px;
37
+ padding: 2px 0;
38
+ font-family: inherit;
39
+ font-size: 13px;
40
+ line-height: 1.5;
41
+ color: var(--dsw-alias-label-primary);
42
+ background: transparent;
43
+ border: none;
44
+ outline: none;
45
+ }
46
+
47
+ /* 可移除的胶囊:几何与配色照官方 `Tag` 的 neutral(999px 圆角、11px/17px、wt500、`bg-module-platform`
48
+ + 二级文本色),只是里面多一个「移除」按钮——官方 `Tag` 是只读的,嵌不进按钮。 */
49
+ .chip {
50
+ display: inline-flex;
51
+ align-items: center;
52
+ gap: 2px;
53
+ padding: 1px 4px 1px 8px;
54
+ border-radius: 999px;
55
+ corner-shape: round;
56
+ font-size: 11px;
57
+ line-height: 17px;
58
+ font-weight: 500;
59
+ white-space: nowrap;
60
+ color: var(--dsw-alias-label-secondary);
61
+ background: var(--dsw-alias-bg-module-platform);
62
+ }
63
+
64
+ /* 胶囊里的移除按钮:图标本身很小,给它一块贴着字号的点击区,不给可见的边框与背景。 */
65
+ .chipRemove {
66
+ box-sizing: border-box;
67
+ display: inline-flex;
68
+ align-items: center;
69
+ justify-content: center;
70
+ flex: none;
71
+ width: 14px;
72
+ height: 14px;
73
+ padding: 0;
74
+ color: var(--dsw-alias-label-tertiary);
75
+ background: transparent;
76
+ border: none;
77
+ border-radius: 999px;
78
+ cursor: pointer;
79
+ line-height: 1;
80
+ }
81
+
82
+ .chipRemove:hover:not(:disabled) {
83
+ color: var(--dsw-alias-label-primary);
84
+ background: var(--dsw-alias-interactive-bg-hover);
85
+ }
86
+
87
+ .chipRemove:disabled {
88
+ cursor: not-allowed;
89
+ opacity: 0.4;
90
+ }
91
+
92
+ .chipRemove:focus-visible {
93
+ outline: var(--dsw-focus-ring-width) solid
94
+ var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
95
+ outline-offset: 0;
96
+ }
97
+
98
+ /* 一个候选都没匹配上:在候选区里说一句,而不是给一张空菜单。 */
99
+ .empty {
100
+ margin: 0;
101
+ padding: 8px;
102
+ font-size: 12px;
103
+ line-height: 1.5;
104
+ color: var(--dsw-alias-label-tertiary);
105
+ }
@@ -0,0 +1,152 @@
1
+ // 标签输入(multi-input 的 searchable 形态):一个框包住所有标签与那一个内联输入框,**聚焦即整体下拉候选**,
2
+ // 输入同时过滤候选;回车确认一个(可以是候选里没有的自定义值),点候选也加一个。
3
+ //
4
+ // 已经在名单里的值不再出现在候选里(见 `remaining`);粘贴一串会拆成多个(见 `./tags.ts` 的分隔符口径)。
5
+
6
+ import { useMemo, useState, type ReactNode } from "react";
7
+ import {
8
+ IconCloseOutlineRegular,
9
+ Menu,
10
+ MenuItemButton,
11
+ rankByName,
12
+ } from "@deepseek-ai/dsh-client-ui-primitives";
13
+ import { type SearchSelectOption } from "./SearchSelect.tsx";
14
+ import { mergeTags, parseTagList } from "./tags.ts";
15
+ import css from "./TagInput.module.css";
16
+
17
+ export interface TagInputProps {
18
+ // 当前名单。
19
+ value: readonly string[];
20
+ // 整段替换(一次粘贴 / 回车 / 点候选 / 移除就是一次编辑动作)。
21
+ onChange: (next: string[]) => void;
22
+ // 候选(可空):不空时聚焦就下拉;已经在名单里的项不再列出。
23
+ options?: readonly SearchSelectOption[] | undefined;
24
+ // 空框时的提示。
25
+ placeholder: string;
26
+ // 框内输入框的无障碍名。
27
+ label: string;
28
+ disabled?: boolean;
29
+ // 候选都没匹配上时的那句话。
30
+ noMatchLabel?: string;
31
+ // 一个标签的移除按钮名(文案归调用方:它要带标签名)。
32
+ removeLabel: (name: string) => string;
33
+ }
34
+
35
+ export function TagInput({
36
+ value,
37
+ onChange,
38
+ options,
39
+ placeholder,
40
+ label,
41
+ disabled = false,
42
+ noMatchLabel = "",
43
+ removeLabel,
44
+ }: TagInputProps): ReactNode {
45
+ const [draft, setDraft] = useState("");
46
+ const [open, setOpen] = useState(false);
47
+ const commit = (text: string): void => {
48
+ const names = parseTagList(text);
49
+ if (names.length === 0) return;
50
+ onChange(mergeTags(value, names));
51
+ setDraft("");
52
+ };
53
+ // 没加进去的那些才出现在候选里(加过的、以及手输过的自定义值都不再重复出现)。
54
+ const remaining = useMemo(
55
+ () =>
56
+ (options ?? [])
57
+ .filter((option) => !value.includes(option.value))
58
+ .map((option) => ({
59
+ value: option.value,
60
+ name: option.label ?? option.value,
61
+ label: option.value,
62
+ })),
63
+ [options, value],
64
+ );
65
+ const matches = useMemo(() => rankByName(remaining, draft), [remaining, draft]);
66
+ return (
67
+ <div className={css.row} data-role="tag-input" data-control="tags">
68
+ <Menu
69
+ open={open && remaining.length > 0}
70
+ // 官方那份 wrapper 是 `inline-flex`:不撑满的话框只有内容那么宽(见 `TagInput.module.css` 的 `.anchor`)。
71
+ className={css.anchor}
72
+ anchor={
73
+ <div className={css.box}>
74
+ {value.map((name) => (
75
+ // 标签与里面的移除按钮是同一个整体:× 在框内,点它才移除。
76
+ <span key={name} className={css.chip} data-tag={name}>
77
+ {name}
78
+ <button
79
+ type="button"
80
+ className={css.chipRemove}
81
+ data-action="remove-tag"
82
+ disabled={disabled}
83
+ aria-label={removeLabel(name)}
84
+ onClick={() => {
85
+ onChange(value.filter((candidate) => candidate !== name));
86
+ }}
87
+ >
88
+ <IconCloseOutlineRegular size={10} />
89
+ </button>
90
+ </span>
91
+ ))}
92
+ {/* 裸输入:边框与背景归外面那个框,标签与它一起换行。 */}
93
+ <input
94
+ className={css.input}
95
+ value={draft}
96
+ placeholder={value.length === 0 ? placeholder : ""}
97
+ aria-label={label}
98
+ disabled={disabled}
99
+ onFocus={() => {
100
+ setOpen(true);
101
+ }}
102
+ onBlur={() => {
103
+ setOpen(false);
104
+ }}
105
+ onChange={(event) => {
106
+ setDraft(event.currentTarget.value);
107
+ }}
108
+ onKeyDown={(event) => {
109
+ if (event.key !== "Enter") return;
110
+ event.preventDefault();
111
+ commit(draft);
112
+ }}
113
+ onPaste={(event) => {
114
+ // 只有真的一串才拦:单个名字照旧走普通输入(粘进去还能接着改)。
115
+ const text = event.clipboardData.getData("text");
116
+ if (parseTagList(text).length < 2) return;
117
+ event.preventDefault();
118
+ commit(text);
119
+ }}
120
+ />
121
+ </div>
122
+ }
123
+ onClose={() => {
124
+ setOpen(false);
125
+ }}
126
+ >
127
+ {matches.map((candidate) => (
128
+ // 按下就保持输入框焦点(否则 blur 先把下拉收起来,点不到这一行),选中后继续留在框里加下一个。
129
+ <span
130
+ key={candidate.value}
131
+ onMouseDown={(event) => {
132
+ event.preventDefault();
133
+ }}
134
+ >
135
+ <MenuItemButton
136
+ onSelect={() => {
137
+ commit(candidate.value);
138
+ }}
139
+ >
140
+ {candidate.name}
141
+ </MenuItemButton>
142
+ </span>
143
+ ))}
144
+ {matches.length === 0 ? (
145
+ <p className={css.empty} data-role="tag-input-empty">
146
+ {noMatchLabel}
147
+ </p>
148
+ ) : null}
149
+ </Menu>
150
+ </div>
151
+ );
152
+ }
@@ -0,0 +1,16 @@
1
+ /* 悬停显形:所在行带 `data-time-hover-root` 时,指针悬停或焦点落在行内都露出来。
2
+ `:global()` 是因为这个选择器要读到行上的属性——它不属于本组件自己。 */
3
+ .label {
4
+ padding: 0;
5
+ font-size: 14px;
6
+ line-height: 24px;
7
+ color: var(--dsw-alias-label-tertiary);
8
+ white-space: nowrap;
9
+ opacity: 0;
10
+ transition: opacity 80ms ease;
11
+ }
12
+
13
+ :global([data-time-hover-root]:hover) .label,
14
+ :global([data-time-hover-root]:focus-within) .label {
15
+ opacity: 1;
16
+ }
@@ -0,0 +1,15 @@
1
+ // 对话里的时间标签:平时透明,指针悬停或用键盘进入所在那一行(`[data-time-hover-root]`)才显形。
2
+
3
+ import type { HTMLAttributes, ReactNode } from "react";
4
+ import { classes } from "./classes.ts";
5
+ import css from "./TimeLabel.module.css";
6
+
7
+ export type TimeLabelProps = HTMLAttributes<HTMLSpanElement>;
8
+
9
+ export function TimeLabel({ className, children, ...rest }: TimeLabelProps): ReactNode {
10
+ return (
11
+ <span className={classes(css.label, className)} {...rest}>
12
+ {children}
13
+ </span>
14
+ );
15
+ }
@@ -0,0 +1,9 @@
1
+ /* 视觉隐藏但仍可被辅助技术读到(不是 `display: none`,那会连读屏一起藏掉)。 */
2
+ .hidden {
3
+ position: absolute;
4
+ width: 1px;
5
+ height: 1px;
6
+ overflow: hidden;
7
+ clip-path: inset(50%);
8
+ white-space: nowrap;
9
+ }
@@ -0,0 +1,16 @@
1
+ // 无障碍隐藏文本:给屏幕阅读器的一句话,视觉上不占位(图标按钮旁边的状态说明就用它)。
2
+
3
+ import type { HTMLAttributes, ReactNode } from "react";
4
+ import css from "./VisuallyHidden.module.css";
5
+
6
+ export interface VisuallyHiddenProps extends HTMLAttributes<HTMLSpanElement> {
7
+ children: ReactNode;
8
+ }
9
+
10
+ export function VisuallyHidden({ children, ...rest }: VisuallyHiddenProps): ReactNode {
11
+ return (
12
+ <span className={css.hidden} {...rest}>
13
+ {children}
14
+ </span>
15
+ );
16
+ }