@lark-base-open/base-database-cli 1.3.7 → 1.3.9

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 (105) hide show
  1. package/CHANGELOG.md +42 -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 +16 -2
  8. package/dist/archive.d.ts.map +1 -1
  9. package/dist/archive.js +63 -13
  10. package/dist/archive.js.map +1 -1
  11. package/dist/deploy.d.ts +4 -0
  12. package/dist/deploy.d.ts.map +1 -1
  13. package/dist/deploy.js +26 -8
  14. package/dist/deploy.js.map +1 -1
  15. package/dist/function-description.d.ts +6 -0
  16. package/dist/function-description.d.ts.map +1 -0
  17. package/dist/function-description.js +29 -0
  18. package/dist/function-description.js.map +1 -0
  19. package/dist/init.d.ts +1 -1
  20. package/dist/init.d.ts.map +1 -1
  21. package/dist/init.js +4 -3
  22. package/dist/init.js.map +1 -1
  23. package/dist/lark-cli-error.d.ts +2 -0
  24. package/dist/lark-cli-error.d.ts.map +1 -1
  25. package/dist/lark-cli-error.js +42 -7
  26. package/dist/lark-cli-error.js.map +1 -1
  27. package/dist/lark-cli.d.ts.map +1 -1
  28. package/dist/lark-cli.js +16 -4
  29. package/dist/lark-cli.js.map +1 -1
  30. package/dist/mutation.d.ts +1 -1
  31. package/dist/mutation.d.ts.map +1 -1
  32. package/dist/mutation.js +5 -5
  33. package/dist/mutation.js.map +1 -1
  34. package/dist/program.d.ts.map +1 -1
  35. package/dist/program.js +7 -2
  36. package/dist/program.js.map +1 -1
  37. package/dist/project-context.d.ts.map +1 -1
  38. package/dist/project-context.js +42 -6
  39. package/dist/project-context.js.map +1 -1
  40. package/dist/public-sql-error.d.ts.map +1 -1
  41. package/dist/public-sql-error.js +2 -18
  42. package/dist/public-sql-error.js.map +1 -1
  43. package/dist/pull.d.ts +1 -0
  44. package/dist/pull.d.ts.map +1 -1
  45. package/dist/pull.js +13 -0
  46. package/dist/pull.js.map +1 -1
  47. package/package.json +1 -1
  48. package/skills/lark-base-database/SKILL.md +32 -12
  49. package/skills/lark-base-database/references/ai-function.md +19 -7
  50. package/skills/lark-base-database/references/base-capability-routing.md +4 -4
  51. package/skills/lark-base-database/references/base-sql-errors.generated.md +845 -0
  52. package/skills/lark-base-database/references/database-function-limitations.md +20 -37
  53. package/skills/lark-base-database/references/database-function.md +39 -42
  54. package/skills/lark-base-database/references/development.md +4 -2
  55. package/skills/lark-base-database/references/faas.md +9 -7
  56. package/skills/lark-base-database/references/file-transfer.md +1 -1
  57. package/skills/lark-base-database/references/frontend-design-guide.md +163 -33
  58. package/skills/lark-base-database/references/mutations.md +4 -4
  59. package/skills/lark-base-database/references/notifications.md +9 -9
  60. package/skills/lark-base-database/references/release.md +10 -10
  61. package/skills/lark-base-database/references/style-guide.md +2 -2
  62. package/skills/lark-base-database/references/verification.md +6 -4
  63. package/skills/lark-base-database/references/workspace.md +15 -13
  64. package/templates/database-starter/gitignore +7 -0
  65. package/templates/database-starter/mutations/README.md +1 -1
  66. package/templates/database-starter/page/package-lock.json +4 -4
  67. package/templates/database-starter/page/package.json +1 -1
  68. package/templates/database-starter/page/src/components/floating-button/assets/toast-close.svg +3 -0
  69. package/templates/database-starter/page/src/components/floating-button/assets/toast-info.svg +5 -0
  70. package/templates/database-starter/page/src/components/floating-button/assets/tooltip-caret.svg +3 -0
  71. package/templates/database-starter/page/src/components/floating-button/index.tsx +140 -25
  72. package/templates/database-starter/page/src/components/floating-button/utils/request-edit.test.ts +42 -0
  73. package/templates/database-starter/page/src/components/floating-button/utils/request-edit.ts +21 -0
  74. package/templates/database-starter/page/src/main.test.ts +28 -0
  75. package/templates/database-starter/page/src/main.tsx +2 -0
  76. package/skills/lark-base-database/references/design/components/actions-fields.md +0 -16
  77. package/skills/lark-base-database/references/design/components/code/controls.css +0 -122
  78. package/skills/lark-base-database/references/design/components/code/controls.tsx +0 -73
  79. package/skills/lark-base-database/references/design/components/code/date-picker.tsx +0 -42
  80. package/skills/lark-base-database/references/design/components/code/floating.tsx +0 -41
  81. package/skills/lark-base-database/references/design/components/code/overlays.tsx +0 -19
  82. package/skills/lark-base-database/references/design/components/code/select.tsx +0 -34
  83. package/skills/lark-base-database/references/design/components/code/styles.d.ts +0 -1
  84. package/skills/lark-base-database/references/design/components/code/theme.css +0 -49
  85. package/skills/lark-base-database/references/design/components/code/usage.tsx +0 -38
  86. package/skills/lark-base-database/references/design/components/date-time-number.md +0 -9
  87. package/skills/lark-base-database/references/design/components/feedback-navigation.md +0 -9
  88. package/skills/lark-base-database/references/design/components/index.md +0 -18
  89. package/skills/lark-base-database/references/design/components/overlays.md +0 -9
  90. package/skills/lark-base-database/references/design/components/patterns/flat-business.md +0 -7
  91. package/skills/lark-base-database/references/design/components/selection.md +0 -24
  92. package/skills/lark-base-database/references/design/components.md +0 -91
  93. package/skills/lark-base-database/references/design/composition.md +0 -90
  94. package/skills/lark-base-database/references/design/control-recipes.md +0 -107
  95. package/skills/lark-base-database/references/design/examples.md +0 -172
  96. package/skills/lark-base-database/references/design/foundations.md +0 -126
  97. package/skills/lark-base-database/references/design/review.md +0 -82
  98. package/skills/lark-base-database/references/design/scenes/analysis-report.md +0 -66
  99. package/skills/lark-base-database/references/design/scenes/brand-showcase.md +0 -70
  100. package/skills/lark-base-database/references/design/scenes/business-operations.md +0 -85
  101. package/skills/lark-base-database/references/design/scenes/campaign-report.md +0 -66
  102. package/skills/lark-base-database/references/design/scenes/dashboard.md +0 -77
  103. package/skills/lark-base-database/references/design/scenes/festive-campaign.md +0 -69
  104. package/skills/lark-base-database/references/design/theme.md +0 -27
  105. package/skills/lark-base-database/references/design/typography.md +0 -56
@@ -1,21 +1,37 @@
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
- import {
6
- getDatabaseContext,
7
- resolveDatabaseContext,
8
- siteRuntime,
9
- } from '../../lib/database-runtime'
14
+ import { getDatabaseContext } from '../../lib/database-runtime'
15
+ import { requestEditOrOpenManagement } from './utils/request-edit'
10
16
  import { useUiLanguage, type UiLanguage } from '../../lib/ui-language'
11
17
 
12
18
  interface FloatingButtonI18n {
19
+ openFailed: string
13
20
  editSite: string
14
21
  hidePanel: string
22
+ hiddenConfirmation: string
23
+ dismissNotification: string
24
+ }
25
+
26
+ interface TooltipPosition {
27
+ left: number
28
+ top: number
29
+ arrowLeft: number
15
30
  }
16
31
 
17
- const BASE_DATABASE_OVERVIEW_URL = 'https://feishu.cn/base/site/detail/overview'
18
32
  const FLOATING_BUTTON_PAGE_GAP = 24
33
+ const TOOLTIP_VIEWPORT_GAP = 8
34
+ const HIDDEN_CONFIRMATION_MS = 3000
19
35
  const EMPTY_PROJECT_META: ProjectMeta = {
20
36
  baseBindings: [],
21
37
  description: '',
@@ -26,16 +42,26 @@ const EMPTY_PROJECT_META: ProjectMeta = {
26
42
  }
27
43
  const FLOATING_BUTTON_I18N: Record<UiLanguage, FloatingButtonI18n> = {
28
44
  en: {
45
+ openFailed: 'Unable to open the management page. Please try again.',
29
46
  editSite: 'Edit and manage your apps',
30
47
  hidePanel: 'Hide until next visit',
48
+ hiddenConfirmation: 'Edit button hidden until you reopen the app',
49
+ dismissNotification: 'Dismiss notification',
31
50
  },
32
51
  zh: {
52
+ openFailed: '无法打开管理页面,请重试',
33
53
  editSite: '编辑与管理应用',
34
54
  hidePanel: '隐藏面板',
55
+ hiddenConfirmation: '编辑按钮已隐藏,直至下次重新打开应用',
56
+ dismissNotification: '关闭提示',
35
57
  },
36
58
  ja: {
59
+ openFailed: '管理ページを開けませんでした。もう一度お試しください。',
37
60
  editSite: 'アプリを編集・管理',
38
61
  hidePanel: '次回のアクセスまで非表示',
62
+ hiddenConfirmation:
63
+ '編集ボタンを非表示にしました。アプリを再度開くと表示されます',
64
+ dismissNotification: '通知を閉じる',
39
65
  },
40
66
  }
41
67
 
@@ -44,15 +70,61 @@ const FLOATING_BUTTON_I18N: Record<UiLanguage, FloatingButtonI18n> = {
44
70
  */
45
71
  export function FloatingButton() {
46
72
  const [projectMeta, setProjectMeta] = useState<ProjectMeta | null>(null)
73
+ const [linkFailed, setLinkFailed] = useState(false)
47
74
  const [isHidden, setIsHidden] = useState(false)
75
+ const [showHiddenConfirmation, setShowHiddenConfirmation] = useState(false)
48
76
  const [activeTooltip, setActiveTooltip] = useState<'edit' | 'hide' | null>(
49
77
  null,
50
78
  )
79
+ const [tooltipPosition, setTooltipPosition] =
80
+ useState<TooltipPosition | null>(null)
81
+ const editButtonRef = useRef<HTMLButtonElement>(null)
82
+ const hideButtonRef = useRef<HTMLButtonElement>(null)
83
+ const tooltipRef = useRef<HTMLDivElement>(null)
51
84
  // Preview 不展示返回编辑态入口;按钮显隐不替代宿主点击时的鉴权。
52
85
  const shouldShowEditButton =
53
86
  projectMeta?.allowEdit === true && getDatabaseContext().env === 'prod'
54
87
  const i18n = FLOATING_BUTTON_I18N[useUiLanguage()]
55
88
 
89
+ useLayoutEffect(() => {
90
+ if (!activeTooltip) return
91
+
92
+ const updateTooltipPosition = () => {
93
+ const trigger =
94
+ activeTooltip === 'edit' ? editButtonRef.current : hideButtonRef.current
95
+ const tooltip = tooltipRef.current
96
+ if (!trigger || !tooltip) return
97
+
98
+ const triggerRect = trigger.getBoundingClientRect()
99
+ const tooltipWidth = tooltip.offsetWidth
100
+ const left = Math.max(
101
+ TOOLTIP_VIEWPORT_GAP,
102
+ Math.min(
103
+ triggerRect.left + triggerRect.width / 2 - tooltipWidth / 2,
104
+ window.innerWidth - tooltipWidth - TOOLTIP_VIEWPORT_GAP,
105
+ ),
106
+ )
107
+ setTooltipPosition({
108
+ left,
109
+ top: triggerRect.top - tooltip.offsetHeight - 14,
110
+ arrowLeft: triggerRect.left + triggerRect.width / 2 - left,
111
+ })
112
+ }
113
+
114
+ updateTooltipPosition()
115
+ window.addEventListener('resize', updateTooltipPosition)
116
+ return () => window.removeEventListener('resize', updateTooltipPosition)
117
+ }, [activeTooltip, i18n])
118
+
119
+ useEffect(() => {
120
+ if (!showHiddenConfirmation) return
121
+ const timeout = window.setTimeout(
122
+ () => setShowHiddenConfirmation(false),
123
+ HIDDEN_CONFIRMATION_MS,
124
+ )
125
+ return () => window.clearTimeout(timeout)
126
+ }, [showHiddenConfirmation])
127
+
56
128
  useEffect(() => {
57
129
  let isCurrentRequest = true
58
130
 
@@ -77,32 +149,76 @@ export function FloatingButton() {
77
149
  }, [])
78
150
 
79
151
  // 编辑按钮走 site.v1 契约:应用发 request-edit,宿主切编辑态后广播 mode-changed。
80
- // 应用被独立访问(无宿主)时事件无人消费,回落为打开 Site 管理页。
152
+ // 切换失败时管理页也经 OpenLink;宿主不可用或安全检查失败时显示错误。
81
153
  const requestEdit = useCallback(() => {
82
- void siteRuntime.requestEdit().catch(async () => {
83
- const { token } = await resolveDatabaseContext()
84
- const url = new URL(BASE_DATABASE_OVERVIEW_URL)
85
- url.searchParams.set('token', token)
86
- openExternalUrl(url.toString())
87
- })
154
+ setLinkFailed(false)
155
+ void requestEditOrOpenManagement().catch(() => setLinkFailed(true))
88
156
  }, [])
89
157
 
90
- if (!shouldShowEditButton || isHidden) {
158
+ if (!shouldShowEditButton) {
91
159
  return null
92
160
  }
93
161
 
162
+ if (isHidden) {
163
+ return showHiddenConfirmation ? (
164
+ <div
165
+ role="status"
166
+ 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]"
167
+ style={{
168
+ // Figma 的内描边与三层阴影需合并,避免 ring 被行内 box-shadow 覆盖。
169
+ boxShadow:
170
+ '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%)',
171
+ }}
172
+ >
173
+ <img src={toastInfoIcon} alt="" className="size-4 shrink-0" />
174
+ <span className="min-w-0 flex-1 pl-1 pr-2">
175
+ {i18n.hiddenConfirmation}
176
+ </span>
177
+ <button
178
+ type="button"
179
+ aria-label={i18n.dismissNotification}
180
+ className="flex size-4 shrink-0 cursor-pointer items-center justify-center"
181
+ onClick={() => setShowHiddenConfirmation(false)}
182
+ >
183
+ <img src={toastCloseIcon} alt="" className="size-4" />
184
+ </button>
185
+ </div>
186
+ ) : null
187
+ }
188
+
94
189
  return (
95
190
  <div
96
191
  className="group fixed bottom-10 z-40 flex items-center"
97
192
  style={{ right: FLOATING_BUTTON_PAGE_GAP }}
98
193
  >
194
+ {linkFailed ? (
195
+ <div
196
+ role="alert"
197
+ className="absolute bottom-full right-0 mb-2 w-64 rounded bg-white p-3 text-sm text-red-700 shadow"
198
+ >
199
+ {i18n.openFailed}
200
+ </div>
201
+ ) : null}
99
202
  {activeTooltip ? (
100
203
  <div
204
+ ref={tooltipRef}
101
205
  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"
206
+ 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"
207
+ style={{
208
+ boxShadow:
209
+ '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%)',
210
+ ...(tooltipPosition
211
+ ? { left: tooltipPosition.left, top: tooltipPosition.top }
212
+ : { visibility: 'hidden' as const }),
213
+ }}
103
214
  >
104
215
  {activeTooltip === 'edit' ? i18n.editSite : i18n.hidePanel}
105
- <span className="absolute -bottom-1 right-[22px] size-2 rotate-45 bg-[#1f2329]" />
216
+ <img
217
+ src={tooltipCaretIcon}
218
+ alt=""
219
+ className="pointer-events-none absolute top-full h-1.5 w-6 -translate-x-1/2"
220
+ style={{ left: tooltipPosition?.arrowLeft }}
221
+ />
106
222
  </div>
107
223
  ) : null}
108
224
  <div
@@ -110,6 +226,7 @@ export function FloatingButton() {
110
226
  data-floating-controls="true"
111
227
  >
112
228
  <button
229
+ ref={editButtonRef}
113
230
  type="button"
114
231
  aria-label={i18n.editSite}
115
232
  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 +248,7 @@ export function FloatingButton() {
131
248
  />
132
249
  </button>
133
250
  <button
251
+ ref={hideButtonRef}
134
252
  type="button"
135
253
  aria-label={i18n.hidePanel}
136
254
  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 +256,11 @@ export function FloatingButton() {
138
256
  onMouseLeave={() => setActiveTooltip(null)}
139
257
  onFocus={() => setActiveTooltip('hide')}
140
258
  onBlur={() => setActiveTooltip(null)}
141
- onClick={() => setIsHidden(true)}
259
+ onClick={() => {
260
+ setIsHidden(true)
261
+ setActiveTooltip(null)
262
+ setShowHiddenConfirmation(true)
263
+ }}
142
264
  >
143
265
  <span aria-hidden="true" className="text-[14px] leading-none">
144
266
  ×
@@ -149,11 +271,4 @@ export function FloatingButton() {
149
271
  )
150
272
  }
151
273
 
152
- /** 使用新标签页打开外部链接。
153
- * @param url 待打开的外部链接。
154
- */
155
- function openExternalUrl(url: string): void {
156
- window.open(url, '_blank', 'noopener,noreferrer')
157
- }
158
-
159
274
  export default FloatingButton
@@ -0,0 +1,42 @@
1
+ import { beforeEach, expect, it, vi } from 'vitest'
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ requestEdit: vi.fn(),
5
+ resolveDatabaseContext: vi.fn(),
6
+ openLink: vi.fn(),
7
+ }))
8
+ vi.mock('@lark-base-open/base-site-channel/runtime', () => ({
9
+ openLink: mocks.openLink,
10
+ }))
11
+ vi.mock('../../../lib/database-runtime', () => ({
12
+ siteRuntime: { requestEdit: mocks.requestEdit },
13
+ resolveDatabaseContext: mocks.resolveDatabaseContext,
14
+ }))
15
+ import { requestEditOrOpenManagement } from './request-edit'
16
+
17
+ beforeEach(() => {
18
+ vi.resetAllMocks()
19
+ mocks.requestEdit.mockResolvedValue(undefined)
20
+ mocks.resolveDatabaseContext.mockResolvedValue({ token: 'site-example' })
21
+ mocks.openLink.mockResolvedValue({ opened: true })
22
+ })
23
+ it('switches edit mode without opening a new page', async () => {
24
+ await requestEditOrOpenManagement()
25
+ expect(mocks.openLink).not.toHaveBeenCalled()
26
+ })
27
+ it('routes management fallback through OpenLink', async () => {
28
+ mocks.requestEdit.mockRejectedValue(new Error('unavailable'))
29
+ await requestEditOrOpenManagement()
30
+ expect(mocks.openLink).toHaveBeenCalledWith(
31
+ 'https://feishu.cn/base/site/detail/overview?token=site-example',
32
+ )
33
+ })
34
+ it.each([false, 'reject'])(
35
+ 'propagates %s to visible component feedback',
36
+ async (outcome) => {
37
+ mocks.requestEdit.mockRejectedValue(new Error('unavailable'))
38
+ if (outcome === false) mocks.openLink.mockResolvedValue({ opened: false })
39
+ else mocks.openLink.mockRejectedValue(new Error('security unavailable'))
40
+ await expect(requestEditOrOpenManagement()).rejects.toThrow()
41
+ },
42
+ )
@@ -0,0 +1,21 @@
1
+ import { openLink } from '@lark-base-open/base-site-channel/runtime'
2
+ import {
3
+ resolveDatabaseContext,
4
+ siteRuntime,
5
+ } from '../../../lib/database-runtime'
6
+
7
+ const BASE_DATABASE_OVERVIEW_URL = 'https://feishu.cn/base/site/detail/overview'
8
+
9
+ /** 优先请求宿主切换编辑态;管理页链接同样交给宿主安全检查。 */
10
+ export async function requestEditOrOpenManagement(): Promise<void> {
11
+ try {
12
+ await siteRuntime.requestEdit()
13
+ return
14
+ } catch {
15
+ const { token } = await resolveDatabaseContext()
16
+ const url = new URL(BASE_DATABASE_OVERVIEW_URL)
17
+ url.searchParams.set('token', token)
18
+ const result = await openLink(url.toString())
19
+ if (!result.opened) throw new Error('Management page was not opened')
20
+ }
21
+ }
@@ -0,0 +1,28 @@
1
+ import { afterEach, expect, it, vi } from 'vitest'
2
+
3
+ const calls = vi.hoisted(() => [] as string[])
4
+ vi.mock('@lark-base-open/base-site-channel/runtime', () => ({
5
+ installLinkGuard: () => {
6
+ calls.push('guard')
7
+ return () => {}
8
+ },
9
+ }))
10
+ vi.mock('react-dom/client', () => ({
11
+ createRoot: () => ({ render: () => calls.push('render') }),
12
+ }))
13
+ vi.mock('./App', () => ({ default: () => null }))
14
+ vi.mock('./components/database-runtime-ready', () => ({
15
+ DatabaseRuntimeReady: () => null,
16
+ }))
17
+ vi.mock('./lib/database-runtime', () => ({
18
+ siteRuntime: { reportLoadFailed: vi.fn() },
19
+ }))
20
+
21
+ afterEach(() => {
22
+ vi.unstubAllGlobals()
23
+ })
24
+ it('installs the link guard before rendering the page', async () => {
25
+ vi.stubGlobal('document', { getElementById: () => ({}) })
26
+ await import('./main')
27
+ expect(calls).toEqual(['guard', 'render'])
28
+ })
@@ -1,3 +1,4 @@
1
+ import { installLinkGuard } from '@lark-base-open/base-site-channel/runtime'
1
2
  import { StrictMode } from 'react'
2
3
  import { createRoot } from 'react-dom/client'
3
4
  import App from './App'
@@ -24,6 +25,7 @@ function bootstrapDatabaseApp(): void {
24
25
  }
25
26
 
26
27
  try {
28
+ installLinkGuard()
27
29
  bootstrapDatabaseApp()
28
30
  } catch (error) {
29
31
  // ready 之前的启动失败必须显式上报,否则宿主只能等到 ready 超时才展示错误态。
@@ -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
- }