@lark-base-open/base-database-cli 1.3.7 → 1.3.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 (87) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +2 -0
  3. package/dist/ai-functions.d.ts +11 -0
  4. package/dist/ai-functions.d.ts.map +1 -1
  5. package/dist/ai-functions.js +118 -9
  6. package/dist/ai-functions.js.map +1 -1
  7. package/dist/archive.d.ts.map +1 -1
  8. package/dist/archive.js +4 -0
  9. package/dist/archive.js.map +1 -1
  10. package/dist/deploy.d.ts +4 -0
  11. package/dist/deploy.d.ts.map +1 -1
  12. package/dist/deploy.js +26 -8
  13. package/dist/deploy.js.map +1 -1
  14. package/dist/init.d.ts +1 -1
  15. package/dist/init.d.ts.map +1 -1
  16. package/dist/init.js +4 -3
  17. package/dist/init.js.map +1 -1
  18. package/dist/lark-cli-error.d.ts +1 -0
  19. package/dist/lark-cli-error.d.ts.map +1 -1
  20. package/dist/lark-cli-error.js +37 -7
  21. package/dist/lark-cli-error.js.map +1 -1
  22. package/dist/lark-cli.d.ts.map +1 -1
  23. package/dist/lark-cli.js +16 -4
  24. package/dist/lark-cli.js.map +1 -1
  25. package/dist/program.d.ts.map +1 -1
  26. package/dist/program.js +7 -2
  27. package/dist/program.js.map +1 -1
  28. package/dist/public-sql-error.d.ts.map +1 -1
  29. package/dist/public-sql-error.js +2 -18
  30. package/dist/public-sql-error.js.map +1 -1
  31. package/dist/pull.d.ts +1 -0
  32. package/dist/pull.d.ts.map +1 -1
  33. package/dist/pull.js +13 -0
  34. package/dist/pull.js.map +1 -1
  35. package/package.json +1 -1
  36. package/skills/lark-base-database/SKILL.md +30 -12
  37. package/skills/lark-base-database/references/ai-function.md +12 -4
  38. package/skills/lark-base-database/references/base-capability-routing.md +4 -4
  39. package/skills/lark-base-database/references/base-sql-errors.generated.md +403 -0
  40. package/skills/lark-base-database/references/database-function-limitations.md +20 -37
  41. package/skills/lark-base-database/references/database-function.md +14 -40
  42. package/skills/lark-base-database/references/development.md +2 -2
  43. package/skills/lark-base-database/references/faas.md +6 -6
  44. package/skills/lark-base-database/references/file-transfer.md +1 -1
  45. package/skills/lark-base-database/references/frontend-design-guide.md +163 -33
  46. package/skills/lark-base-database/references/notifications.md +9 -9
  47. package/skills/lark-base-database/references/release.md +10 -10
  48. package/skills/lark-base-database/references/style-guide.md +2 -2
  49. package/skills/lark-base-database/references/verification.md +6 -4
  50. package/skills/lark-base-database/references/workspace.md +15 -13
  51. package/templates/database-starter/gitignore +7 -0
  52. package/templates/database-starter/page/package-lock.json +4 -4
  53. package/templates/database-starter/page/package.json +1 -1
  54. package/templates/database-starter/page/src/components/floating-button/assets/toast-close.svg +3 -0
  55. package/templates/database-starter/page/src/components/floating-button/assets/toast-info.svg +5 -0
  56. package/templates/database-starter/page/src/components/floating-button/assets/tooltip-caret.svg +3 -0
  57. package/templates/database-starter/page/src/components/floating-button/index.tsx +122 -5
  58. package/skills/lark-base-database/references/design/components/actions-fields.md +0 -16
  59. package/skills/lark-base-database/references/design/components/code/controls.css +0 -122
  60. package/skills/lark-base-database/references/design/components/code/controls.tsx +0 -73
  61. package/skills/lark-base-database/references/design/components/code/date-picker.tsx +0 -42
  62. package/skills/lark-base-database/references/design/components/code/floating.tsx +0 -41
  63. package/skills/lark-base-database/references/design/components/code/overlays.tsx +0 -19
  64. package/skills/lark-base-database/references/design/components/code/select.tsx +0 -34
  65. package/skills/lark-base-database/references/design/components/code/styles.d.ts +0 -1
  66. package/skills/lark-base-database/references/design/components/code/theme.css +0 -49
  67. package/skills/lark-base-database/references/design/components/code/usage.tsx +0 -38
  68. package/skills/lark-base-database/references/design/components/date-time-number.md +0 -9
  69. package/skills/lark-base-database/references/design/components/feedback-navigation.md +0 -9
  70. package/skills/lark-base-database/references/design/components/index.md +0 -18
  71. package/skills/lark-base-database/references/design/components/overlays.md +0 -9
  72. package/skills/lark-base-database/references/design/components/patterns/flat-business.md +0 -7
  73. package/skills/lark-base-database/references/design/components/selection.md +0 -24
  74. package/skills/lark-base-database/references/design/components.md +0 -91
  75. package/skills/lark-base-database/references/design/composition.md +0 -90
  76. package/skills/lark-base-database/references/design/control-recipes.md +0 -107
  77. package/skills/lark-base-database/references/design/examples.md +0 -172
  78. package/skills/lark-base-database/references/design/foundations.md +0 -126
  79. package/skills/lark-base-database/references/design/review.md +0 -82
  80. package/skills/lark-base-database/references/design/scenes/analysis-report.md +0 -66
  81. package/skills/lark-base-database/references/design/scenes/brand-showcase.md +0 -70
  82. package/skills/lark-base-database/references/design/scenes/business-operations.md +0 -85
  83. package/skills/lark-base-database/references/design/scenes/campaign-report.md +0 -66
  84. package/skills/lark-base-database/references/design/scenes/dashboard.md +0 -77
  85. package/skills/lark-base-database/references/design/scenes/festive-campaign.md +0 -69
  86. package/skills/lark-base-database/references/design/theme.md +0 -27
  87. package/skills/lark-base-database/references/design/typography.md +0 -56
@@ -1,3 +1,10 @@
1
1
  node_modules/
2
2
  dist/
3
3
  .base-database-cli/
4
+ .base-database-response-*/
5
+ .env
6
+ .env.*
7
+ .npmrc
8
+ .yarnrc
9
+ .yarnrc.yml
10
+ .pnpmrc
@@ -8,7 +8,7 @@
8
8
  "name": "page",
9
9
  "version": "0.0.0",
10
10
  "dependencies": {
11
- "@lark-base-open/base-site-channel": "0.1.3",
11
+ "@lark-base-open/base-site-channel": "0.1.4",
12
12
  "@radix-ui/react-slot": "^1.3.0",
13
13
  "class-variance-authority": "^0.7.1",
14
14
  "clsx": "^2.1.1",
@@ -119,9 +119,9 @@
119
119
  }
120
120
  },
121
121
  "node_modules/@lark-base-open/base-site-channel": {
122
- "version": "0.1.3",
123
- "resolved": "https://registry.npmjs.org/@lark-base-open/base-site-channel/-/base-site-channel-0.1.3.tgz",
124
- "integrity": "sha512-wdc8zRkAX2P7pwBkyoun7oHrUP95xYCgAhhwsq4cUOg7ozIf9Em1DQken7kUeMc58pzDM5ONroWavoa1/oQiEg==",
122
+ "version": "0.1.4",
123
+ "resolved": "https://registry.npmjs.org/@lark-base-open/base-site-channel/-/base-site-channel-0.1.4.tgz",
124
+ "integrity": "sha512-QBl60LoxLr2kNjCHxiuXtFxegSrMPA11P0IufjOkoonNEvZz3CfDtFwhO+EbkOZahANMRyxEq3DhdpFgEm5QDQ==",
125
125
  "dependencies": {
126
126
  "lodash-es": "^4.17.21"
127
127
  }
@@ -18,7 +18,7 @@
18
18
  "test": "vitest run"
19
19
  },
20
20
  "dependencies": {
21
- "@lark-base-open/base-site-channel": "0.1.3",
21
+ "@lark-base-open/base-site-channel": "0.1.4",
22
22
  "@radix-ui/react-slot": "^1.3.0",
23
23
  "class-variance-authority": "^0.7.1",
24
24
  "clsx": "^2.1.1",
@@ -0,0 +1,3 @@
1
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M13.5043 13.5042C13.7646 13.2439 13.7665 12.8237 13.5062 12.5633L8.94298 8.00013L13.5061 3.43697C13.7665 3.17662 13.7646 2.7564 13.5043 2.49605C13.2439 2.2357 12.8237 2.23382 12.5633 2.49416C12.303 2.75451 8.00017 7.05732 8.00017 7.05732L3.43697 2.49413C3.17662 2.23378 2.7564 2.23566 2.49605 2.49601C2.2357 2.75636 2.23381 3.17658 2.49416 3.43693L7.05736 8.00013L2.49413 12.5634C2.23378 12.8237 2.23566 13.2439 2.49601 13.5043C2.75636 13.7646 3.17659 13.7665 3.43693 13.5062L8.00017 8.94294L12.5634 13.5061C12.8237 13.7665 13.2439 13.7646 13.5043 13.5042Z" fill="#646A73"/>
3
+ </svg>
@@ -0,0 +1,5 @@
1
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <circle cx="8.00008" cy="8.00033" r="7.33333" fill="#1456F0"/>
3
+ <path d="M8 5.33333C8.36819 5.33333 8.66667 5.03486 8.66667 4.66667C8.66667 4.29848 8.36819 4 8 4C7.63181 4 7.33333 4.29848 7.33333 4.66667C7.33333 5.03486 7.63181 5.33333 8 5.33333Z" fill="white"/>
4
+ <path d="M7.33333 6C6.96514 6 6.66667 6.29848 6.66667 6.66667C6.66667 7.03486 6.96514 7.33333 7.33333 7.33333V10H6.66667C6.29848 10 6 10.2985 6 10.6667C6 11.0349 6.29848 11.3333 6.66667 11.3333H9.33333C9.70152 11.3333 10 11.0349 10 10.6667C10 10.2985 9.70152 10 9.33333 10H8.66667V6.66667C8.66667 6.29848 8.36819 6 8 6H7.33333Z" fill="white"/>
5
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg width="24" height="6" viewBox="0 0 24 6" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M8.4383 1.99309L11.2526 5.15916C11.6504 5.60668 12.3496 5.60668 12.7474 5.15916L15.5617 1.99309C16.6886 0.725344 18.3038 0 20 0L12 0L4 0C5.69619 0 7.31142 0.725345 8.4383 1.99309Z" fill="#1F2329"/>
3
+ </svg>
@@ -1,6 +1,15 @@
1
- import { useCallback, useEffect, useState } from 'react'
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useLayoutEffect,
5
+ useRef,
6
+ useState,
7
+ } from 'react'
2
8
  import editDefaultIcon from './assets/edit-default.svg'
3
9
  import editActiveIcon from './assets/edit-active.svg'
10
+ import tooltipCaretIcon from './assets/tooltip-caret.svg'
11
+ import toastInfoIcon from './assets/toast-info.svg'
12
+ import toastCloseIcon from './assets/toast-close.svg'
4
13
  import { fetchProjectMeta, type ProjectMeta } from './utils/project-meta'
5
14
  import {
6
15
  getDatabaseContext,
@@ -12,10 +21,20 @@ import { useUiLanguage, type UiLanguage } from '../../lib/ui-language'
12
21
  interface FloatingButtonI18n {
13
22
  editSite: string
14
23
  hidePanel: string
24
+ hiddenConfirmation: string
25
+ dismissNotification: string
26
+ }
27
+
28
+ interface TooltipPosition {
29
+ left: number
30
+ top: number
31
+ arrowLeft: number
15
32
  }
16
33
 
17
34
  const BASE_DATABASE_OVERVIEW_URL = 'https://feishu.cn/base/site/detail/overview'
18
35
  const FLOATING_BUTTON_PAGE_GAP = 24
36
+ const TOOLTIP_VIEWPORT_GAP = 8
37
+ const HIDDEN_CONFIRMATION_MS = 3000
19
38
  const EMPTY_PROJECT_META: ProjectMeta = {
20
39
  baseBindings: [],
21
40
  description: '',
@@ -28,14 +47,21 @@ const FLOATING_BUTTON_I18N: Record<UiLanguage, FloatingButtonI18n> = {
28
47
  en: {
29
48
  editSite: 'Edit and manage your apps',
30
49
  hidePanel: 'Hide until next visit',
50
+ hiddenConfirmation: 'Edit button hidden until you reopen the app',
51
+ dismissNotification: 'Dismiss notification',
31
52
  },
32
53
  zh: {
33
54
  editSite: '编辑与管理应用',
34
55
  hidePanel: '隐藏面板',
56
+ hiddenConfirmation: '编辑按钮已隐藏,直至下次重新打开应用',
57
+ dismissNotification: '关闭提示',
35
58
  },
36
59
  ja: {
37
60
  editSite: 'アプリを編集・管理',
38
61
  hidePanel: '次回のアクセスまで非表示',
62
+ hiddenConfirmation:
63
+ '編集ボタンを非表示にしました。アプリを再度開くと表示されます',
64
+ dismissNotification: '通知を閉じる',
39
65
  },
40
66
  }
41
67
 
@@ -45,14 +71,59 @@ const FLOATING_BUTTON_I18N: Record<UiLanguage, FloatingButtonI18n> = {
45
71
  export function FloatingButton() {
46
72
  const [projectMeta, setProjectMeta] = useState<ProjectMeta | null>(null)
47
73
  const [isHidden, setIsHidden] = useState(false)
74
+ const [showHiddenConfirmation, setShowHiddenConfirmation] = useState(false)
48
75
  const [activeTooltip, setActiveTooltip] = useState<'edit' | 'hide' | null>(
49
76
  null,
50
77
  )
78
+ const [tooltipPosition, setTooltipPosition] =
79
+ useState<TooltipPosition | null>(null)
80
+ const editButtonRef = useRef<HTMLButtonElement>(null)
81
+ const hideButtonRef = useRef<HTMLButtonElement>(null)
82
+ const tooltipRef = useRef<HTMLDivElement>(null)
51
83
  // Preview 不展示返回编辑态入口;按钮显隐不替代宿主点击时的鉴权。
52
84
  const shouldShowEditButton =
53
85
  projectMeta?.allowEdit === true && getDatabaseContext().env === 'prod'
54
86
  const i18n = FLOATING_BUTTON_I18N[useUiLanguage()]
55
87
 
88
+ useLayoutEffect(() => {
89
+ if (!activeTooltip) return
90
+
91
+ const updateTooltipPosition = () => {
92
+ const trigger =
93
+ activeTooltip === 'edit' ? editButtonRef.current : hideButtonRef.current
94
+ const tooltip = tooltipRef.current
95
+ if (!trigger || !tooltip) return
96
+
97
+ const triggerRect = trigger.getBoundingClientRect()
98
+ const tooltipWidth = tooltip.offsetWidth
99
+ const left = Math.max(
100
+ TOOLTIP_VIEWPORT_GAP,
101
+ Math.min(
102
+ triggerRect.left + triggerRect.width / 2 - tooltipWidth / 2,
103
+ window.innerWidth - tooltipWidth - TOOLTIP_VIEWPORT_GAP,
104
+ ),
105
+ )
106
+ setTooltipPosition({
107
+ left,
108
+ top: triggerRect.top - tooltip.offsetHeight - 14,
109
+ arrowLeft: triggerRect.left + triggerRect.width / 2 - left,
110
+ })
111
+ }
112
+
113
+ updateTooltipPosition()
114
+ window.addEventListener('resize', updateTooltipPosition)
115
+ return () => window.removeEventListener('resize', updateTooltipPosition)
116
+ }, [activeTooltip, i18n])
117
+
118
+ useEffect(() => {
119
+ if (!showHiddenConfirmation) return
120
+ const timeout = window.setTimeout(
121
+ () => setShowHiddenConfirmation(false),
122
+ HIDDEN_CONFIRMATION_MS,
123
+ )
124
+ return () => window.clearTimeout(timeout)
125
+ }, [showHiddenConfirmation])
126
+
56
127
  useEffect(() => {
57
128
  let isCurrentRequest = true
58
129
 
@@ -87,10 +158,37 @@ export function FloatingButton() {
87
158
  })
88
159
  }, [])
89
160
 
90
- if (!shouldShowEditButton || isHidden) {
161
+ if (!shouldShowEditButton) {
91
162
  return null
92
163
  }
93
164
 
165
+ if (isHidden) {
166
+ return showHiddenConfirmation ? (
167
+ <div
168
+ role="status"
169
+ className="fixed top-8 left-1/2 z-50 flex min-h-[54px] w-[352px] max-w-[calc(100vw-32px)] -translate-x-1/2 items-center gap-2 rounded-md bg-[#f0f4ff] px-5 py-4 text-sm leading-[22px] font-medium text-[#1f2329]"
170
+ style={{
171
+ // Figma 的内描边与三层阴影需合并,避免 ring 被行内 box-shadow 覆盖。
172
+ boxShadow:
173
+ 'inset 0 0 0 1px #1456f0, 0 8px 24px 8px rgb(31 35 41 / 4%), 0 6px 12px rgb(31 35 41 / 4%), 0 4px 8px -8px rgb(31 35 41 / 6%)',
174
+ }}
175
+ >
176
+ <img src={toastInfoIcon} alt="" className="size-4 shrink-0" />
177
+ <span className="min-w-0 flex-1 pl-1 pr-2">
178
+ {i18n.hiddenConfirmation}
179
+ </span>
180
+ <button
181
+ type="button"
182
+ aria-label={i18n.dismissNotification}
183
+ className="flex size-4 shrink-0 cursor-pointer items-center justify-center"
184
+ onClick={() => setShowHiddenConfirmation(false)}
185
+ >
186
+ <img src={toastCloseIcon} alt="" className="size-4" />
187
+ </button>
188
+ </div>
189
+ ) : null
190
+ }
191
+
94
192
  return (
95
193
  <div
96
194
  className="group fixed bottom-10 z-40 flex items-center"
@@ -98,11 +196,24 @@ export function FloatingButton() {
98
196
  >
99
197
  {activeTooltip ? (
100
198
  <div
199
+ ref={tooltipRef}
101
200
  role="tooltip"
102
- className="pointer-events-none absolute right-0 bottom-[calc(100%+14px)] z-10 whitespace-nowrap rounded-lg bg-[#1f2329] px-3 py-2 text-sm text-white shadow-lg"
201
+ className="pointer-events-none fixed z-50 w-max max-w-[calc(100vw-16px)] rounded-lg bg-[#1f2329] px-3 py-2 text-xs leading-5 whitespace-normal text-white"
202
+ style={{
203
+ boxShadow:
204
+ '0 8px 24px 8px rgb(31 35 41 / 4%), 0 6px 12px rgb(31 35 41 / 4%), 0 4px 8px -8px rgb(31 35 41 / 6%)',
205
+ ...(tooltipPosition
206
+ ? { left: tooltipPosition.left, top: tooltipPosition.top }
207
+ : { visibility: 'hidden' as const }),
208
+ }}
103
209
  >
104
210
  {activeTooltip === 'edit' ? i18n.editSite : i18n.hidePanel}
105
- <span className="absolute -bottom-1 right-[22px] size-2 rotate-45 bg-[#1f2329]" />
211
+ <img
212
+ src={tooltipCaretIcon}
213
+ alt=""
214
+ className="pointer-events-none absolute top-full h-1.5 w-6 -translate-x-1/2"
215
+ style={{ left: tooltipPosition?.arrowLeft }}
216
+ />
106
217
  </div>
107
218
  ) : null}
108
219
  <div
@@ -110,6 +221,7 @@ export function FloatingButton() {
110
221
  data-floating-controls="true"
111
222
  >
112
223
  <button
224
+ ref={editButtonRef}
113
225
  type="button"
114
226
  aria-label={i18n.editSite}
115
227
  className="group/edit flex size-12 cursor-pointer items-center justify-center rounded-full bg-transparent outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
@@ -131,6 +243,7 @@ export function FloatingButton() {
131
243
  />
132
244
  </button>
133
245
  <button
246
+ ref={hideButtonRef}
134
247
  type="button"
135
248
  aria-label={i18n.hidePanel}
136
249
  className="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full bg-[#c8c9cc] text-[#646a73] opacity-0 transition-opacity group-hover:opacity-100 hover:bg-[#b7b9bd] focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-[#1456f0]"
@@ -138,7 +251,11 @@ export function FloatingButton() {
138
251
  onMouseLeave={() => setActiveTooltip(null)}
139
252
  onFocus={() => setActiveTooltip('hide')}
140
253
  onBlur={() => setActiveTooltip(null)}
141
- onClick={() => setIsHidden(true)}
254
+ onClick={() => {
255
+ setIsHidden(true)
256
+ setActiveTooltip(null)
257
+ setShowHiddenConfirmation(true)
258
+ }}
142
259
  >
143
260
  <span aria-hidden="true" className="text-[14px] leading-none">
144
261
  ×
@@ -1,16 +0,0 @@
1
- # 按钮与字段
2
-
3
- 适用于主/次/文字/危险/图标按钮、单行输入、搜索、标签、错误、帮助与多行输入。基础几何从[配方](../control-recipes.md)取得,颜色从[主题](../theme.md)取得;[组件代码](code/controls.tsx)和[共享样式](code/controls.css)展示可适配实现。
4
-
5
- | 区域 | A / B / C 共用要求 |
6
- | --- | --- |
7
- | 普通按钮 | 文字行高 20px、图标视觉 16px;高度、水平内边距、圆角和间隙用配方值。主动作实底,次动作轻填充,低优先级动作透明;不叠灰底、灰边和阴影 |
8
- | 图标按钮 | 宽度可等于当前按钮高度;必须有可访问名称,图标垂直与水平居中。触控场景另扩展命中区 |
9
- | 单行输入与搜索 | 同配方高度和字形;值字号可读,placeholder 仅提供例子,不替代 label。A 用单层轻边界,B/C 用轻填充并保留透明 1px 边界占位 |
10
- | 输入焦点 | 原边界位置变为一层 1px 主题焦点色,不叠 outline/ring/内外阴影;正常输入和错误同时存在时只画一个边界层 |
11
- | 禁用/加载 | 禁用不可操作,loading 仅用于真实异步状态;大小不跳动,必要时阻止重复提交 |
12
- | 帮助与错误 | 只在必要时显示。错误靠近字段并通过 `aria-describedby` 关联;校验恢复后移除错误及关联,不补“已填写”或空白帮助行 |
13
-
14
- Textarea 从约三行起步,`resize: none` 去掉右下角拖拽斜线;随输入、初值、预填、粘贴和容器尺寸增高。达到任务合适上限时在字段内部滚动,预览中的 220px 是可改参考值,不是业务限制。不能把输入截断或固定矮框。参考实现的 `ResizeObserver` 只处理控件自身尺寸,不要求普通搭建启动浏览器巡检。
15
-
16
- 按钮文案说明实际操作;保存成功只能来自真实业务返回或用户明确的模拟流程。页面头与按钮已经表达的功能不再写副标题。选择主按钮不自动在其外层生成背景卡片。
@@ -1,122 +0,0 @@
1
- .ds-theme, .ds-theme * { box-sizing: border-box; }
2
- .ds-control { font: inherit; font-size: var(--ds-font); line-height: 20px; color: var(--ds-text); }
3
- .ds-button, .ds-field, .ds-trigger, .ds-segment button, .ds-option, .ds-page-button { font: inherit; font-size: var(--ds-font); }
4
- .ds-button { display: inline-flex; align-items: center; justify-content: center; gap: var(--ds-gap); min-height: var(--ds-height); padding: 0 var(--ds-pad); border: 1px solid transparent; border-radius: var(--ds-radius); background: transparent; color: var(--ds-text); font-weight: 500; line-height: 20px; box-shadow: none; cursor: pointer; white-space: nowrap; }
5
- .ds-button[data-variant="primary"] { background: var(--ds-action); color: var(--ds-on-action); }
6
- .ds-button[data-variant="primary"]:hover:not(:disabled) { background: var(--ds-action-hover); }
7
- .ds-button[data-variant="secondary"] { background: var(--ds-action-soft); color: var(--ds-action); }
8
- .ds-button[data-variant="secondary"]:hover:not(:disabled) { background: var(--ds-action-soft-hover); }
9
- .ds-button[data-variant="quiet"]:hover:not(:disabled) { background: var(--ds-hover); }
10
- .ds-button[data-variant="danger"] { color: var(--ds-danger); }
11
- .ds-button[data-variant="danger"]:hover:not(:disabled) { background: var(--ds-error-soft); }
12
- .ds-button[data-icon-only="true"] { width: var(--ds-height); padding: 0; flex: none; }
13
- .ds-button:disabled { opacity: .48; cursor: not-allowed; }
14
- .ds-button:focus-visible { outline: 2px solid var(--ds-focus); outline-offset: 2px; }
15
- .ds-spinner { width: 14px; height: 14px; border: 1.5px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: ds-spin .8s linear infinite; }
16
- @keyframes ds-spin { to { transform: rotate(360deg); } }
17
- .ds-field-wrap { display: grid; gap: 7px; min-width: 0; }
18
- .ds-label { font-size: var(--ds-font); font-weight: 500; line-height: 20px; }
19
- .ds-help, .ds-error { font-size: 12px; line-height: 18px; margin: 0; }
20
- .ds-help { color: var(--ds-muted); }
21
- .ds-error { color: var(--ds-danger); }
22
- .ds-field, .ds-trigger, .ds-number, .ds-affix { width: 100%; min-width: 0; min-height: var(--ds-height); padding: 0 var(--ds-pad); border: 1px solid var(--ds-field-border); border-radius: var(--ds-radius); background: var(--ds-field-fill); color: var(--ds-text); box-shadow: none; outline: 0; font-weight: 400; line-height: 20px; }
23
- .ds-field::placeholder { color: var(--ds-placeholder); opacity: 1; }
24
- .ds-field:hover:not(:disabled):not(:focus), .ds-trigger:hover:not(:disabled), .ds-number:hover, .ds-affix:hover { background: var(--ds-field-hover); }
25
- .ds-field:focus, .ds-trigger:focus-visible, .ds-number:focus-within, .ds-affix:focus-within { border-color: var(--ds-focus); outline: 0; box-shadow: none; }
26
- .ds-field[aria-invalid="true"], .ds-trigger[aria-invalid="true"], .ds-number[data-invalid="true"], .ds-affix[data-invalid="true"] { border-color: var(--ds-danger); background: var(--ds-error-soft); }
27
- .ds-field:disabled, .ds-trigger:disabled { opacity: .5; cursor: not-allowed; }
28
- .ds-textarea { padding: 10px var(--ds-pad); min-height: 92px; max-height: var(--ds-textarea-max, 220px); resize: none; line-height: 1.55; overflow-y: auto; }
29
- .ds-trigger { display: flex; align-items: center; justify-content: space-between; gap: 8px; text-align: left; cursor: pointer; }
30
- .ds-trigger-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
31
- .ds-trigger-caret { flex: none; color: var(--ds-muted); }
32
- .ds-trigger[aria-expanded="true"]:not(:focus-visible) { background: var(--ds-action-soft); }
33
- .ds-popover { position: fixed; z-index: 60; overflow: auto; padding: 6px; border: 0; border-radius: calc(var(--ds-radius) + 2px); background: var(--ds-popover); color: var(--ds-text); box-shadow: 0 6px 28px var(--ds-shadow); font-size: var(--ds-font); }
34
- .ds-option-list { max-height: 236px; overflow-y: auto; }
35
- .ds-option { width: 100%; min-height: var(--ds-height); display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 10px; border: 0; border-radius: var(--ds-radius); background: transparent; color: var(--ds-text); text-align: left; line-height: 20px; cursor: pointer; }
36
- .ds-option:hover, .ds-option:focus-visible { background: var(--ds-hover); outline: 0; }
37
- .ds-option:focus-visible { box-shadow: inset 2px 0 var(--ds-focus); }
38
- .ds-option[aria-selected="true"], .ds-option[aria-checked="true"] { background: var(--ds-action-soft); color: var(--ds-action); font-weight: 500; }
39
- .ds-option[data-danger="true"] { color: var(--ds-danger); }
40
- .ds-option[data-danger="true"]:hover { background: var(--ds-error-soft); }
41
- .ds-popup-search { display: flex; align-items: center; gap: 8px; position: relative; padding: 4px 8px 8px; color: var(--ds-muted); }
42
- .ds-popup-search input { min-width: 0; width: 100%; padding: 7px 4px; border: 0; border-radius: 0; background: transparent; color: var(--ds-text); font: inherit; outline: 0; box-shadow: none; caret-color: var(--ds-text); }
43
- .ds-popup-search input:focus, .ds-popup-search input:focus-visible { border: 0; outline: 0; background: transparent; box-shadow: none; }
44
- .ds-popup-search[data-keyboard-focus="true"]:focus-within::before { content: ""; position: absolute; left: 0; top: 50%; width: 2px; height: 14px; border-radius: 1px; background: var(--ds-focus); transform: translateY(-50%); }
45
- .ds-popup-empty { padding: 18px 8px; color: var(--ds-muted); text-align: center; font-size: 13px; }
46
- .ds-popup-footer { display: flex; justify-content: space-between; gap: 8px; padding: 6px 2px 0; border-top: 1px solid var(--ds-border); }
47
- .ds-calendar-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; font-weight: 600; }
48
- .ds-calendar-week { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 2px; text-align: center; }
49
- .ds-calendar-weekdays { color: var(--ds-muted); font-size: 11px; line-height: 28px; }
50
- .ds-calendar-day { width: 100%; height: 34px; border: 0; border-radius: var(--ds-radius); background: transparent; color: var(--ds-text); font: inherit; font-size: 13px; cursor: pointer; }
51
- .ds-calendar-day:hover, .ds-calendar-day:focus-visible { background: var(--ds-hover); outline: 0; }
52
- .ds-calendar-day:focus-visible { outline: 2px solid var(--ds-focus); outline-offset: -2px; }
53
- .ds-calendar-day[data-outside="true"] { color: var(--ds-muted); }
54
- .ds-calendar-day[data-today="true"]:not([aria-selected="true"]) { color: var(--ds-action); font-weight: 700; text-decoration: underline; text-underline-offset: 4px; }
55
- .ds-calendar-day[aria-selected="true"] { background: var(--ds-action); color: var(--ds-on-action); }
56
- .ds-calendar-day[aria-selected="true"]:focus-visible { outline-color: var(--ds-on-action); outline-offset: -4px; }
57
- .ds-calendar-day:disabled { opacity: .35; cursor: not-allowed; }
58
- .ds-check, .ds-radio, .ds-switch { display: inline-flex; align-items: center; gap: 7px; min-height: 32px; font-size: var(--ds-font); cursor: pointer; }
59
- .ds-check input, .ds-radio input { appearance: none; display: grid; place-content: center; width: 16px; height: 16px; margin: 0; border: 1px solid var(--ds-border); background: var(--ds-field); }
60
- .ds-check input { border-radius: 3px; }
61
- .ds-check input:checked { background: var(--ds-action); border-color: var(--ds-action); }
62
- .ds-check input:checked::after { content: ""; width: 7px; height: 4px; border: solid var(--ds-on-action); border-width: 0 0 1.7px 1.7px; transform: translateY(-1px) rotate(-45deg); }
63
- .ds-radio input { border-radius: 50%; }
64
- .ds-radio input:checked { border-color: var(--ds-action); }
65
- .ds-radio input:checked::after { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--ds-action); }
66
- .ds-switch input { position: absolute; opacity: 0; width: 30px; height: 18px; margin: 0; }
67
- .ds-switch-track { width: 30px; height: 18px; position: relative; border-radius: 20px; background: var(--ds-border); }
68
- .ds-switch-track::after { content: ""; position: absolute; top: 3px; left: 3px; width: 12px; height: 12px; border-radius: 50%; background: var(--ds-surface); transition: transform .15s; }
69
- .ds-switch input:checked + .ds-switch-track { background: var(--ds-action); }
70
- .ds-switch input:checked + .ds-switch-track::after { background: var(--ds-on-action); }
71
- .ds-switch input:checked + .ds-switch-track::after { transform: translateX(12px); }
72
- .ds-switch input:focus-visible + .ds-switch-track, .ds-check input:focus-visible, .ds-radio input:focus-visible { outline: 2px solid var(--ds-focus); outline-offset: 2px; }
73
- .ds-segment, .ds-tabs { display: inline-flex; align-items: center; gap: 3px; }
74
- .ds-segment { padding: 3px; border-radius: calc(var(--ds-radius) + 2px); background: var(--ds-hover); }
75
- .ds-segment button, .ds-tabs button { padding: 4px 12px; border: 0; border-radius: var(--ds-radius); background: transparent; color: var(--ds-muted); line-height: 20px; cursor: pointer; }
76
- .ds-segment button[aria-pressed="true"] { background: var(--ds-surface); color: var(--ds-action); }
77
- .ds-tabs button { border-radius: 0; border-bottom: 2px solid transparent; }
78
- .ds-tabs button[aria-selected="true"] { border-bottom-color: var(--ds-action); color: var(--ds-action); }
79
- .ds-segment button:focus-visible, .ds-tabs button:focus-visible { outline: 2px solid var(--ds-focus); outline-offset: 1px; }
80
- .ds-number, .ds-affix { display: flex; align-items: center; gap: 4px; padding: 0 var(--ds-pad); }
81
- .ds-number { padding: 0; }
82
- .ds-number input, .ds-affix input { flex: 1; min-width: 0; height: calc(var(--ds-height) - 2px); padding: 0 6px; border: 0; background: transparent; color: var(--ds-text); font: inherit; font-size: var(--ds-font); outline: 0; }
83
- .ds-number input { text-align: center; font-variant-numeric: tabular-nums; }
84
- .ds-number button { width: calc(var(--ds-height) - 2px); height: calc(var(--ds-height) - 2px); border: 0; border-radius: var(--ds-radius); background: transparent; color: var(--ds-muted); cursor: pointer; }
85
- .ds-number button:hover, .ds-number button:focus-visible { background: var(--ds-action-soft); color: var(--ds-action); outline: 0; }
86
- .ds-affix > span { white-space: nowrap; color: var(--ds-muted); font-size: 12px; }
87
- .ds-status { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; line-height: 20px; color: var(--ds-muted); }
88
- .ds-status::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
89
- .ds-status[data-tone="success"] { color: var(--ds-success); }
90
- .ds-status[data-tone="warning"] { color: var(--ds-warning); }
91
- .ds-status[data-tone="danger"] { color: var(--ds-danger); }
92
- .ds-tag { display: inline-flex; align-items: center; gap: 6px; padding: 3px 6px 3px 9px; border-radius: var(--ds-radius); background: var(--ds-hover); font-size: 12px; }
93
- .ds-tag button { width: 22px; height: 22px; border: 0; border-radius: 3px; background: transparent; color: var(--ds-muted); cursor: pointer; }
94
- .ds-tag button:hover, .ds-tag button:focus-visible { background: var(--ds-action-soft); color: var(--ds-action); outline: 0; }
95
- .ds-slider { appearance: none; display: block; width: 100%; height: 20px; margin: 10px 0; background: transparent; cursor: pointer; }
96
- .ds-slider::-webkit-slider-runnable-track { height: 4px; border-radius: 4px; background: linear-gradient(to right, var(--ds-action) 0 var(--ds-slider-progress), var(--ds-hover) var(--ds-slider-progress) 100%); }
97
- .ds-slider::-webkit-slider-thumb { appearance: none; width: 14px; height: 14px; margin-top: -5px; border: 3px solid var(--ds-action); border-radius: 50%; background: var(--ds-surface); }
98
- .ds-slider::-moz-range-track { height: 4px; border-radius: 4px; background: var(--ds-hover); }
99
- .ds-slider::-moz-range-progress { height: 4px; background: var(--ds-action); }
100
- .ds-slider::-moz-range-thumb { width: 10px; height: 10px; border: 3px solid var(--ds-action); border-radius: 50%; background: var(--ds-surface); }
101
- .ds-slider:focus-visible { outline: 2px solid var(--ds-focus); outline-offset: 3px; }
102
- .ds-progress { width: 100%; height: 4px; appearance: none; border: 0; border-radius: 4px; overflow: hidden; background: var(--ds-hover); }
103
- .ds-progress::-webkit-progress-bar { background: var(--ds-hover); }
104
- .ds-progress::-webkit-progress-value { background: var(--ds-action); }
105
- .ds-progress::-moz-progress-bar { background: var(--ds-action); }
106
- .ds-pagination { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
107
- .ds-page-button[aria-current="page"] { background: var(--ds-action-soft); color: var(--ds-action); }
108
- .ds-dialog { width: min(480px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); padding: 28px; border: 0; border-radius: calc(var(--ds-radius) + 4px); background: var(--ds-popover); color: var(--ds-text); box-shadow: 0 20px 80px var(--ds-shadow); overflow: auto; }
109
- .ds-dialog::backdrop { background: var(--ds-scrim); }
110
- .ds-dialog[data-drawer="true"] { width: min(440px, calc(100vw - 20px)); height: 100dvh; max-height: 100dvh; margin: 0 0 0 auto; border-radius: 0; }
111
- .ds-dialog-head, .ds-dialog-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
112
- .ds-dialog-head { margin-bottom: 24px; }
113
- .ds-dialog-head h2 { margin: 0; font-size: 20px; line-height: 28px; font-weight: 600; }
114
- .ds-dialog-actions { justify-content: flex-end; margin-top: 24px; }
115
- .ds-toast { position: fixed; z-index: 90; bottom: 24px; left: 50%; transform: translateX(-50%); padding: 10px 16px; border-radius: var(--ds-radius); background: var(--ds-text); color: var(--ds-surface); box-shadow: 0 6px 24px var(--ds-shadow); font-size: 13px; }
116
- .ds-flat-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
117
- .ds-flat-toolbar .ds-field-wrap { flex: 0 1 180px; }
118
- .ds-flat-toolbar .ds-field-wrap:first-of-type { flex-basis: 240px; }
119
- .ds-flat-form { display: grid; gap: 22px 20px; grid-template-columns: repeat(2, minmax(0, 1fr)); max-width: 920px; }
120
- .ds-flat-form [data-full="true"] { grid-column: 1 / -1; }
121
- @media (max-width: 640px) { .ds-flat-form { grid-template-columns: 1fr; } .ds-flat-form [data-full="true"] { grid-column: auto; } .ds-dialog { padding: 22px; } }
122
- @media (prefers-reduced-motion: reduce) { .ds-spinner { animation: none; } .ds-switch-track::after { transition: none; } }
@@ -1,73 +0,0 @@
1
- import { forwardRef, useEffect, useId, useRef, useState, type ButtonHTMLAttributes, type CSSProperties, type InputHTMLAttributes, type ReactNode, type TextareaHTMLAttributes } from 'react';
2
-
3
- export type ControlVariant = 'primary' | 'secondary' | 'quiet' | 'danger';
4
- export type NamedOption<T extends string = string> = { value: T; label: string; disabled?: boolean };
5
-
6
- export const Button = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ControlVariant; loading?: boolean; iconOnly?: boolean }>(function Button({ variant = 'quiet', loading = false, iconOnly = false, children, disabled, ...props }, ref) {
7
- return <button {...props} ref={ref} type={props.type ?? 'button'} className={`ds-button ${props.className ?? ''}`} data-variant={variant} data-icon-only={iconOnly} disabled={disabled || loading} aria-busy={loading || undefined}>
8
- {loading && <span className="ds-spinner" aria-hidden="true" />}{children}
9
- </button>;
10
- });
11
-
12
- export function Field({ label, help, error, id, children }: { label: string; help?: string; error?: string; id: string; children: ReactNode }) {
13
- return <div className="ds-field-wrap"><label className="ds-label" htmlFor={id}>{label}</label>{children}
14
- {error ? <p className="ds-error" id={`${id}-message`}>{error}</p> : help ? <p className="ds-help" id={`${id}-help`}>{help}</p> : null}
15
- </div>;
16
- }
17
-
18
- export const TextField = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement> & { error?: string; help?: string; label: string }>(function TextField({ id, label, error, help, className = '', ...props }, ref) {
19
- const generated = useId(); const fieldId = id ?? generated;
20
- return <Field id={fieldId} label={label} help={help} error={error}><input {...props} id={fieldId} ref={ref} className={`ds-field ${className}`} aria-label={label} aria-invalid={!!error || undefined} aria-describedby={error ? `${fieldId}-message` : help ? `${fieldId}-help` : undefined} /></Field>;
21
- });
22
-
23
- export const Textarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement> & { error?: string; help?: string; label: string }>(function Textarea({ id, label, error, help, className = '', onInput, value, defaultValue, ...props }, outerRef) {
24
- const generated = useId(); const fieldId = id ?? generated; const localRef = useRef<HTMLTextAreaElement>(null);
25
- const resize = () => { const el = localRef.current; if (!el) return; el.style.height = 'auto'; const cap = Number.parseFloat(getComputedStyle(el).maxHeight) || 220; el.style.height = `${Math.min(el.scrollHeight, cap)}px`; el.style.overflowY = el.scrollHeight > cap ? 'auto' : 'hidden'; };
26
- useEffect(() => { resize(); const el = localRef.current; if (!el) return; const observer = new ResizeObserver(resize); observer.observe(el); return () => observer.disconnect(); }, [value, defaultValue]);
27
- return <Field id={fieldId} label={label} help={help} error={error}><textarea {...props} id={fieldId} value={value} defaultValue={defaultValue} rows={props.rows ?? 3} className={`ds-field ds-textarea ${className}`} aria-label={label} aria-invalid={!!error || undefined} aria-describedby={error ? `${fieldId}-message` : help ? `${fieldId}-help` : undefined} ref={node => { localRef.current = node; if (typeof outerRef === 'function') outerRef(node); else if (outerRef) outerRef.current = node; }} onInput={event => { resize(); onInput?.(event); }} /></Field>;
28
- });
29
-
30
- export function Checkbox({ label, ...props }: InputHTMLAttributes<HTMLInputElement> & { label: string }) {
31
- return <label className="ds-check"><input {...props} type="checkbox" />{label}</label>;
32
- }
33
-
34
- export function RadioGroup<T extends string>({ label, name, options, value, onChange }: { label: string; name: string; options: NamedOption<T>[]; value: T; onChange: (value: T) => void }) {
35
- return <fieldset style={{ border: 0, padding: 0, margin: 0 }}><legend className="ds-label">{label}</legend><div style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}>{options.map(option => <label className="ds-radio" key={option.value}><input name={name} type="radio" checked={value === option.value} disabled={option.disabled} onChange={() => onChange(option.value)} />{option.label}</label>)}</div></fieldset>;
36
- }
37
-
38
- export function Switch({ label, checked, onChange, disabled = false }: { label: string; checked: boolean; onChange: (checked: boolean) => void; disabled?: boolean }) {
39
- return <label className="ds-switch"><input type="checkbox" role="switch" checked={checked} onChange={event => onChange(event.target.checked)} disabled={disabled} /><span className="ds-switch-track" aria-hidden="true" />{label}</label>;
40
- }
41
-
42
- export function Segmented<T extends string>({ label, options, value, onChange }: { label: string; options: NamedOption<T>[]; value: T; onChange: (value: T) => void }) {
43
- return <div className="ds-segment" role="group" aria-label={label}>{options.map(option => <button key={option.value} type="button" aria-pressed={value === option.value} disabled={option.disabled} onClick={() => onChange(option.value)}>{option.label}</button>)}</div>;
44
- }
45
-
46
- export function Tabs<T extends string>({ label, options, value, onChange, children }: { label: string; options: NamedOption<T>[]; value: T; onChange: (value: T) => void; children: ReactNode }) {
47
- const id = useId(); const enabled = options.filter(option => !option.disabled);
48
- return <><div className="ds-tabs" role="tablist" aria-label={label}>{options.map(option => <button key={option.value} id={`${id}-${option.value}`} type="button" role="tab" aria-controls={`${id}-panel`} aria-selected={value === option.value} tabIndex={value === option.value ? 0 : -1} disabled={option.disabled} onClick={() => onChange(option.value)} onKeyDown={event => { if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; event.preventDefault(); const index = enabled.findIndex(item => item.value === option.value); const next = event.key === 'Home' ? 0 : event.key === 'End' ? enabled.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + enabled.length) % enabled.length; onChange(enabled[next].value); requestAnimationFrame(() => document.getElementById(`${id}-${enabled[next].value}`)?.focus()); }}>{option.label}</button>)}</div><div role="tabpanel" id={`${id}-panel`} aria-labelledby={`${id}-${value}`}>{children}</div></>;
49
- }
50
-
51
- export function NumberStepper({ label, value, onChange, min, max, step = 1, error }: { label: string; value: number; onChange: (value: number) => void; min?: number; max?: number; step?: number; error?: string }) {
52
- const id = useId(), [draft, setDraft] = useState(String(value));
53
- useEffect(() => setDraft(String(value)), [value]);
54
- const clamp = (n: number) => Math.min(max ?? Infinity, Math.max(min ?? -Infinity, n));
55
- const adjust = (direction: number) => { const places = Math.max((String(step).split('.')[1] ?? '').length, (String(value).split('.')[1] ?? '').length); onChange(clamp(Number((value + direction * step).toFixed(places)))); };
56
- const commit = () => { const n = Number(draft); const next = draft.trim() && Number.isFinite(n) ? clamp(n) : value; onChange(next); setDraft(String(next)); };
57
- return <Field id={id} label={label} error={error}><div className="ds-number" data-invalid={!!error}><button type="button" aria-label={`减少${label}`} disabled={min !== undefined && value <= min} onClick={() => adjust(-1)}>−</button><input id={id} type="text" inputMode="decimal" role="spinbutton" value={draft} aria-label={label} aria-valuenow={value} aria-valuemin={min} aria-valuemax={max} aria-invalid={!!error || undefined} aria-describedby={error ? `${id}-message` : undefined} onChange={event => { const text = event.target.value; setDraft(text); if (text.trim() && Number.isFinite(Number(text))) onChange(Number(text)); }} onBlur={commit} onKeyDown={event => { if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { event.preventDefault(); adjust(event.key === 'ArrowUp' ? 1 : -1); } }} /><button type="button" aria-label={`增加${label}`} disabled={max !== undefined && value >= max} onClick={() => adjust(1)}>+</button></div></Field>;
58
- }
59
-
60
- export function AffixInput({ label, prefix, suffix, error, ...props }: InputHTMLAttributes<HTMLInputElement> & { label: string; prefix?: string; suffix?: string; error?: string }) {
61
- const id = useId(); return <Field id={id} label={label} error={error}><div className="ds-affix" data-invalid={!!error}>{prefix && <span aria-hidden="true">{prefix}</span>}<input {...props} id={id} aria-label={label} aria-invalid={!!error || undefined} aria-describedby={error ? `${id}-message` : undefined} />{suffix && <span aria-hidden="true">{suffix}</span>}</div></Field>;
62
- }
63
-
64
- export function Status({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'neutral' | 'success' | 'warning' | 'danger' }) { return <span className="ds-status" data-tone={tone}>{children}</span>; }
65
- export function Tag({ children, onRemove, removeLabel }: { children: ReactNode; onRemove?: () => void; removeLabel?: string }) { return <span className="ds-tag">{children}{onRemove && <button type="button" aria-label={removeLabel ?? '移除标签'} onClick={onRemove}>×</button>}</span>; }
66
- export function Slider({ label, value, onChange, min = 0, max = 100, step = 1 }: { label: string; value: number; onChange: (value: number) => void; min?: number; max?: number; step?: number }) {
67
- const id = useId(); const pct = max > min ? Math.max(0, Math.min(100, (value - min) / (max - min) * 100)) : 0; return <div className="ds-field-wrap"><div style={{ display: 'flex', justifyContent: 'space-between' }}><label htmlFor={id}>{label}</label><output htmlFor={id}>{value}</output></div><input className="ds-slider" id={id} type="range" value={value} min={min} max={max} step={step} style={{ '--ds-slider-progress': `${pct}%` } as CSSProperties} onChange={event => onChange(Number(event.target.value))} /></div>;
68
- }
69
- export function Progress({ label, value, max = 100 }: { label: string; value?: number; max?: number }) { return <progress className="ds-progress" aria-label={label} value={value} max={max} />; }
70
- export function Pagination({ page, pageCount, onPageChange, label = '分页' }: { page: number; pageCount: number; onPageChange: (page: number) => void; label?: string }) {
71
- const pages = Array.from({ length: Math.min(pageCount, 5) }, (_, i) => Math.min(Math.max(1, page - 2), Math.max(1, pageCount - 4)) + i);
72
- return <nav className="ds-pagination" aria-label={label}><Button iconOnly aria-label="上一页" disabled={page <= 1} onClick={() => onPageChange(page - 1)}>‹</Button>{pages.map(n => <Button key={n} className="ds-page-button" aria-current={n === page ? 'page' : undefined} onClick={() => onPageChange(n)}>{n}</Button>)}<Button iconOnly aria-label="下一页" disabled={page >= pageCount} onClick={() => onPageChange(page + 1)}>›</Button></nav>;
73
- }
@@ -1,42 +0,0 @@
1
- import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
2
- import { Button, Field } from './controls';
3
- import { Floating } from './floating';
4
-
5
- const iso = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
6
- const parse = (value: string) => { const [y, m, d] = value.split('-').map(Number); return new Date(y, m - 1, d); };
7
- const shift = (value: string, days: number) => { const d = parse(value); d.setDate(d.getDate() + days); return iso(d); };
8
- const monthShift = (value: string, months: number) => { const d = parse(value), day = d.getDate(); const target = new Date(d.getFullYear(), d.getMonth() + months, 1); target.setDate(Math.min(day, new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate())); return iso(target); };
9
-
10
- export function DatePicker({ label, value, onChange, min, max, disabledDate, error, help, disabled = false }: { label: string; value: string | null; onChange: (value: string | null) => void; min?: string; max?: string; disabledDate?: (date: string) => boolean; error?: string; help?: string; disabled?: boolean }) {
11
- const id = useId(), ref = useRef<HTMLButtonElement>(null), [open, setOpen] = useState(false);
12
- const [month, setMonth] = useState(() => value ? parse(value) : new Date());
13
- const [active, setActive] = useState(() => value ?? iso(new Date()));
14
- const today = iso(new Date());
15
- const start = new Date(month.getFullYear(), month.getMonth(), 1);
16
- start.setDate(1 - (start.getDay() + 6) % 7);
17
- const days = Array.from({ length: 42 }, (_, i) => { const d = new Date(start); d.setDate(start.getDate() + i); return iso(d); });
18
- const allowed = (date: string) => (!min || date >= min) && (!max || date <= max) && !disabledDate?.(date);
19
- const close = () => setOpen(false);
20
- const focusDay = (next: string) => { const direction = next >= active ? 1 : -1; let target = next; for (let i = 0; i < 42 && !allowed(target); i++) target = shift(target, direction); if (!allowed(target)) return; setActive(target); setMonth(parse(target)); requestAnimationFrame(() => document.getElementById(`${id}-${target}`)?.focus()); };
21
- useEffect(() => { if (open) requestAnimationFrame(() => document.getElementById(`${id}-${active}`)?.focus()); }, [open]);
22
- const onCalendarKey = (event: KeyboardEvent<HTMLDivElement>) => {
23
- if (!(event.target instanceof HTMLButtonElement) || !event.target.dataset.date) return;
24
- const current = event.target.dataset.date;
25
- const offsets: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
26
- let next = event.key in offsets ? shift(current, offsets[event.key]) : null;
27
- if (event.key === 'Home') next = shift(current, -((parse(current).getDay() + 6) % 7));
28
- if (event.key === 'End') next = shift(current, 6 - ((parse(current).getDay() + 6) % 7));
29
- if (event.key === 'PageUp' || event.key === 'PageDown') next = monthShift(current, (event.key === 'PageUp' ? -1 : 1) * (event.shiftKey ? 12 : 1));
30
- if (next) { event.preventDefault(); focusDay(next); }
31
- };
32
- return <Field id={id} label={label} error={error} help={help}>
33
- <button id={id} ref={ref} type="button" className="ds-trigger" aria-label={`${label}:${value ?? '选择日期'}`} aria-haspopup="dialog" aria-expanded={open} aria-invalid={!!error || undefined} aria-describedby={error ? `${id}-message` : help ? `${id}-help` : undefined} disabled={disabled} onClick={() => { if (!open) { setMonth(value ? parse(value) : new Date()); setActive(value ?? today); } setOpen(!open); }}><span className="ds-trigger-value">{value ? value.replaceAll('-', ' / ') : '选择日期'}</span><span className="ds-trigger-caret" aria-hidden="true">▦</span></button>
34
- <Floating triggerRef={ref} open={open} onClose={close} ariaLabel={`${label}选择器`} width={304}>
35
- <div onKeyDown={onCalendarKey}><div className="ds-calendar-head"><Button iconOnly aria-label="上个月" onClick={() => focusDay(iso(new Date(month.getFullYear(), month.getMonth() - 1, 1)))}>‹</Button><span aria-live="polite">{month.getFullYear()} 年 {month.getMonth() + 1} 月</span><Button iconOnly aria-label="下个月" onClick={() => focusDay(iso(new Date(month.getFullYear(), month.getMonth() + 1, 1)))}>›</Button></div>
36
- <div role="grid" aria-label={`${month.getFullYear()}年${month.getMonth() + 1}月`}><div role="row" className="ds-calendar-week ds-calendar-weekdays">{['一','二','三','四','五','六','日'].map(name => <span role="columnheader" key={name}>{name}</span>)}</div>
37
- {Array.from({ length: 6 }, (_, row) => <div role="row" className="ds-calendar-week" key={row}>{days.slice(row * 7, row * 7 + 7).map(date => <div role="gridcell" aria-selected={date === value} key={date}><button id={`${id}-${date}`} className="ds-calendar-day" type="button" data-date={date} data-outside={parse(date).getMonth() !== month.getMonth()} data-today={date === today} aria-current={date === today ? 'date' : undefined} aria-selected={date === value} aria-label={`${parse(date).getFullYear()}年${parse(date).getMonth() + 1}月${parse(date).getDate()}日`} tabIndex={date === active ? 0 : -1} disabled={!allowed(date)} onClick={() => { onChange(date); close(); ref.current?.focus(); }}>{parse(date).getDate()}</button></div>)}</div>)}</div>
38
- <div className="ds-popup-footer"><Button onClick={() => { onChange(null); close(); ref.current?.focus(); }}>清空</Button><Button variant="secondary" disabled={!allowed(today)} onClick={() => { onChange(today); close(); ref.current?.focus(); }}>今天</Button></div>
39
- </div>
40
- </Floating>
41
- </Field>;
42
- }
@@ -1,41 +0,0 @@
1
- import { createPortal } from 'react-dom';
2
- import { useCallback, useLayoutEffect, useRef, type CSSProperties, type ReactNode, type RefObject } from 'react';
3
-
4
- const roles = ['page','surface','popover','text','muted','placeholder','action','on-action','focus','border','field','danger','warning','success','shadow','scrim'];
5
- export function portalTheme(trigger: HTMLElement | null): { recipe: string; vars: CSSProperties } {
6
- const root = trigger?.closest<HTMLElement>('.ds-theme');
7
- const css = root ? getComputedStyle(root) : null;
8
- const vars: Record<string, string> = {};
9
- for (const role of roles) { const value = css?.getPropertyValue(`--ds-${role}`).trim(); if (value) vars[`--ds-${role}`] = value; }
10
- return { recipe: root?.dataset.dsRecipe ?? 'a', vars: vars as CSSProperties };
11
- }
12
-
13
- export function Floating({ triggerRef, open, onClose, children, ariaLabel, role = 'dialog', width }: { triggerRef: RefObject<HTMLElement | null>; open: boolean; onClose: () => void; children: ReactNode; ariaLabel: string; role?: 'dialog' | 'menu'; width?: number }) {
14
- const panelRef = useRef<HTMLDivElement>(null);
15
- const position = useCallback(() => {
16
- const trigger = triggerRef.current, panel = panelRef.current;
17
- if (!trigger || !panel) return;
18
- const rect = trigger.getBoundingClientRect(), margin = 12;
19
- const targetWidth = Math.min(window.innerWidth - margin * 2, width ?? Math.max(rect.width, 240));
20
- panel.style.width = `${targetWidth}px`;
21
- panel.style.maxHeight = `${Math.max(0, window.innerHeight - margin * 2)}px`;
22
- const height = panel.getBoundingClientRect().height;
23
- const below = window.innerHeight - rect.bottom - margin, above = rect.top - margin;
24
- const rawTop = below >= height || below >= above ? rect.bottom + 6 : rect.top - height - 6;
25
- panel.style.top = `${Math.max(margin, Math.min(rawTop, window.innerHeight - height - margin))}px`;
26
- panel.style.left = `${Math.max(margin, Math.min(rect.left, window.innerWidth - targetWidth - margin))}px`;
27
- }, [triggerRef, width]);
28
- useLayoutEffect(() => {
29
- if (!open) return;
30
- position();
31
- const observer = new ResizeObserver(position); if (panelRef.current) observer.observe(panelRef.current);
32
- const handlePointer = (event: PointerEvent) => { const node = event.target as Node; if (!panelRef.current?.contains(node) && !triggerRef.current?.contains(node)) onClose(); };
33
- const handleKey = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); onClose(); triggerRef.current?.focus(); } };
34
- document.addEventListener('pointerdown', handlePointer); document.addEventListener('keydown', handleKey, true);
35
- window.addEventListener('resize', position); window.addEventListener('scroll', position, true);
36
- return () => { observer.disconnect(); document.removeEventListener('pointerdown', handlePointer); document.removeEventListener('keydown', handleKey, true); window.removeEventListener('resize', position); window.removeEventListener('scroll', position, true); };
37
- }, [open, onClose, position, triggerRef]);
38
- if (!open || typeof document === 'undefined') return null;
39
- const theme = portalTheme(triggerRef.current);
40
- return createPortal(<div ref={panelRef} className="ds-theme ds-popover" data-ds-recipe={theme.recipe} style={theme.vars} role={role} aria-label={ariaLabel}>{children}</div>, document.body);
41
- }
@@ -1,19 +0,0 @@
1
- import { useEffect, useRef, useState, type ReactNode } from 'react';
2
- import { Button } from './controls';
3
- import { Floating } from './floating';
4
-
5
- export function Menu({ label, items }: { label: string; items: { id: string; label: string; disabled?: boolean; danger?: boolean; onSelect: () => void }[] }) {
6
- const ref = useRef<HTMLButtonElement>(null), [open, setOpen] = useState(false);
7
- useEffect(() => { if (open) requestAnimationFrame(() => document.querySelector<HTMLButtonElement>('[data-ds-menu] [role="menuitem"]:not(:disabled)')?.focus()); }, [open]);
8
- return <><Button ref={ref} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(!open)} onKeyDown={event => { if (['ArrowDown', 'ArrowUp'].includes(event.key)) { event.preventDefault(); setOpen(true); } }}>{label} ⌄</Button>
9
- <Floating triggerRef={ref} open={open} onClose={() => setOpen(false)} ariaLabel={label} role="menu"><div data-ds-menu onKeyDown={event => { const nodes = [...event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not(:disabled)')]; const current = nodes.indexOf(document.activeElement as HTMLButtonElement); if (!['ArrowDown','ArrowUp','Home','End'].includes(event.key) || !nodes.length) return; event.preventDefault(); const next = event.key === 'Home' ? 0 : event.key === 'End' ? nodes.length - 1 : event.key === 'ArrowDown' ? (current + 1) % nodes.length : (current - 1 + nodes.length) % nodes.length; nodes[next].focus(); }}>{items.map(item => <button className="ds-option" type="button" role="menuitem" key={item.id} data-danger={item.danger} disabled={item.disabled} onClick={() => { item.onSelect(); setOpen(false); ref.current?.focus(); }}>{item.label}</button>)}</div></Floating>
10
- </>;
11
- }
12
-
13
- export function Dialog({ title, open, onClose, children, actions, drawer = false }: { title: string; open: boolean; onClose: () => void; children: ReactNode; actions?: ReactNode; drawer?: boolean }) {
14
- const ref = useRef<HTMLDialogElement>(null);
15
- useEffect(() => { const el = ref.current; if (!el) return; if (open && !el.open) el.showModal(); else if (!open && el.open) el.close(); }, [open]);
16
- return <dialog ref={ref} className="ds-dialog" data-drawer={drawer} aria-label={title} onClose={onClose}><div className="ds-dialog-head"><h2>{title}</h2><Button iconOnly aria-label="关闭" onClick={() => { ref.current?.close(); onClose(); }}>×</Button></div>{children}{actions && <div className="ds-dialog-actions">{actions}</div>}</dialog>;
17
- }
18
- export function Drawer(props: Omit<Parameters<typeof Dialog>[0], 'drawer'>) { return <Dialog {...props} drawer />; }
19
- export function Toast({ message }: { message: string | null }) { return message ? <div className="ds-toast" role="status">{message}</div> : null; }