@lark-base-open/base-database-cli 1.1.1 → 1.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +74 -0
- package/LICENSE +6 -0
- package/README.md +45 -27
- package/dist/ai-functions.d.ts +42 -0
- package/dist/ai-functions.d.ts.map +1 -0
- package/dist/ai-functions.js +327 -0
- package/dist/ai-functions.js.map +1 -0
- package/dist/archive.d.ts +21 -0
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +65 -10
- package/dist/archive.js.map +1 -1
- package/dist/auth.d.ts +5 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +26 -3
- package/dist/auth.js.map +1 -1
- package/dist/check.d.ts +74 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +283 -0
- package/dist/check.js.map +1 -0
- package/dist/dependency-policy.d.ts +10 -0
- package/dist/dependency-policy.d.ts.map +1 -0
- package/dist/dependency-policy.js +162 -0
- package/dist/dependency-policy.js.map +1 -0
- package/dist/deploy.d.ts +1 -0
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +122 -175
- package/dist/deploy.js.map +1 -1
- package/dist/environment-variables.d.ts +52 -0
- package/dist/environment-variables.d.ts.map +1 -0
- package/dist/environment-variables.js +563 -0
- package/dist/environment-variables.js.map +1 -0
- package/dist/environment.d.ts +1 -1
- package/dist/environment.js +1 -1
- package/dist/environment.js.map +1 -1
- package/dist/function-input-types.d.ts +10 -0
- package/dist/function-input-types.d.ts.map +1 -0
- package/dist/function-input-types.js +160 -0
- package/dist/function-input-types.js.map +1 -0
- package/dist/function-schema.d.ts.map +1 -1
- package/dist/function-schema.js +89 -36
- package/dist/function-schema.js.map +1 -1
- package/dist/init-field-retry.d.ts +17 -0
- package/dist/init-field-retry.d.ts.map +1 -0
- package/dist/init-field-retry.js +127 -0
- package/dist/init-field-retry.js.map +1 -0
- package/dist/init-journal.d.ts +98 -0
- package/dist/init-journal.d.ts.map +1 -0
- package/dist/init-journal.js +492 -0
- package/dist/init-journal.js.map +1 -0
- package/dist/init-schema.d.ts +8 -0
- package/dist/init-schema.d.ts.map +1 -0
- package/dist/init-schema.js +423 -0
- package/dist/init-schema.js.map +1 -0
- package/dist/init.d.ts +14 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +883 -261
- package/dist/init.js.map +1 -1
- package/dist/lark-cli-error.d.ts +46 -0
- package/dist/lark-cli-error.d.ts.map +1 -0
- package/dist/lark-cli-error.js +171 -0
- package/dist/lark-cli-error.js.map +1 -0
- package/dist/lark-cli.d.ts +1 -1
- package/dist/lark-cli.d.ts.map +1 -1
- package/dist/lark-cli.js +66 -18
- package/dist/lark-cli.js.map +1 -1
- package/dist/mutation-schema.d.ts +146 -0
- package/dist/mutation-schema.d.ts.map +1 -0
- package/dist/mutation-schema.js +153 -0
- package/dist/mutation-schema.js.map +1 -0
- package/dist/mutation.d.ts +10 -0
- package/dist/mutation.d.ts.map +1 -0
- package/dist/mutation.js +131 -0
- package/dist/mutation.js.map +1 -0
- package/dist/npm.d.ts +1 -0
- package/dist/npm.d.ts.map +1 -1
- package/dist/npm.js +2 -2
- package/dist/npm.js.map +1 -1
- package/dist/preview-receipt.d.ts.map +1 -1
- package/dist/preview-receipt.js +15 -5
- package/dist/preview-receipt.js.map +1 -1
- package/dist/process.d.ts +3 -1
- package/dist/process.d.ts.map +1 -1
- package/dist/process.js +16 -4
- package/dist/process.js.map +1 -1
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +240 -41
- package/dist/program.js.map +1 -1
- package/dist/project-build.d.ts +25 -0
- package/dist/project-build.d.ts.map +1 -0
- package/dist/project-build.js +229 -0
- package/dist/project-build.js.map +1 -0
- package/dist/project-context.d.ts +344 -0
- package/dist/project-context.d.ts.map +1 -0
- package/dist/project-context.js +485 -0
- package/dist/project-context.js.map +1 -0
- package/dist/project-layout.d.ts +5 -0
- package/dist/project-layout.d.ts.map +1 -0
- package/dist/project-layout.js +24 -0
- package/dist/project-layout.js.map +1 -0
- package/dist/public-sql-error.d.ts +25 -0
- package/dist/public-sql-error.d.ts.map +1 -0
- package/dist/public-sql-error.js +81 -0
- package/dist/public-sql-error.js.map +1 -0
- package/dist/pull.d.ts.map +1 -1
- package/dist/pull.js +54 -15
- package/dist/pull.js.map +1 -1
- package/dist/site-readme.d.ts +3 -0
- package/dist/site-readme.d.ts.map +1 -0
- package/dist/site-readme.js +83 -0
- package/dist/site-readme.js.map +1 -0
- package/dist/workspace-fingerprint.d.ts +21 -1
- package/dist/workspace-fingerprint.d.ts.map +1 -1
- package/dist/workspace-fingerprint.js +81 -2
- package/dist/workspace-fingerprint.js.map +1 -1
- package/package.json +2 -1
- package/skills/lark-base-database/SKILL.md +66 -50
- package/skills/lark-base-database/references/ai-function.md +471 -0
- package/skills/lark-base-database/references/base-capability-routing.md +3 -3
- package/skills/lark-base-database/references/database-function-limitations.md +46 -0
- package/skills/lark-base-database/references/database-function.md +349 -0
- package/skills/lark-base-database/references/design/components/actions-fields.md +16 -0
- package/skills/lark-base-database/references/design/components/code/controls.css +122 -0
- package/skills/lark-base-database/references/design/components/code/controls.tsx +73 -0
- package/skills/lark-base-database/references/design/components/code/date-picker.tsx +42 -0
- package/skills/lark-base-database/references/design/components/code/floating.tsx +41 -0
- package/skills/lark-base-database/references/design/components/code/overlays.tsx +19 -0
- package/skills/lark-base-database/references/design/components/code/select.tsx +34 -0
- package/skills/lark-base-database/references/design/components/code/styles.d.ts +1 -0
- package/skills/lark-base-database/references/design/components/code/theme.css +49 -0
- package/skills/lark-base-database/references/design/components/code/usage.tsx +38 -0
- package/skills/lark-base-database/references/design/components/date-time-number.md +9 -0
- package/skills/lark-base-database/references/design/components/feedback-navigation.md +9 -0
- package/skills/lark-base-database/references/design/components/index.md +18 -0
- package/skills/lark-base-database/references/design/components/overlays.md +9 -0
- package/skills/lark-base-database/references/design/components/patterns/flat-business.md +7 -0
- package/skills/lark-base-database/references/design/components/selection.md +24 -0
- package/skills/lark-base-database/references/design/components.md +91 -0
- package/skills/lark-base-database/references/design/composition.md +90 -0
- package/skills/lark-base-database/references/design/control-recipes.md +107 -0
- package/skills/lark-base-database/references/design/examples.md +172 -0
- package/skills/lark-base-database/references/design/foundations.md +126 -0
- package/skills/lark-base-database/references/design/review.md +82 -0
- package/skills/lark-base-database/references/design/scenes/analysis-report.md +66 -0
- package/skills/lark-base-database/references/design/scenes/brand-showcase.md +70 -0
- package/skills/lark-base-database/references/design/scenes/business-operations.md +85 -0
- package/skills/lark-base-database/references/design/scenes/campaign-report.md +66 -0
- package/skills/lark-base-database/references/design/scenes/dashboard.md +77 -0
- package/skills/lark-base-database/references/design/scenes/festive-campaign.md +69 -0
- package/skills/lark-base-database/references/design/theme.md +27 -0
- package/skills/lark-base-database/references/design/typography.md +56 -0
- package/skills/lark-base-database/references/development.md +165 -32
- package/skills/lark-base-database/references/environment-variables.md +55 -0
- package/skills/lark-base-database/references/faas.md +41 -48
- package/skills/lark-base-database/references/file-transfer.md +25 -25
- package/skills/lark-base-database/references/frontend-design-guide.md +33 -77
- package/skills/lark-base-database/references/mutations.md +295 -0
- package/skills/lark-base-database/references/permission-design.md +24 -24
- package/skills/lark-base-database/references/release.md +51 -24
- package/skills/lark-base-database/references/style-guide.md +10 -67
- package/skills/lark-base-database/references/verification.md +15 -11
- package/skills/lark-base-database/references/workspace.md +46 -8
- package/templates/database-starter/README.md +34 -4
- package/templates/database-starter/database-functions/README.md +8 -0
- package/templates/database-starter/mutations/README.md +19 -0
- package/templates/database-starter/page/README.md +25 -19
- package/templates/database-starter/page/package-lock.json +8 -16
- package/templates/database-starter/page/package.json +2 -3
- package/templates/database-starter/page/src/App.tsx +23 -3
- package/templates/database-starter/page/src/components/UserSelectorEditor/index.tsx +32 -4
- package/templates/database-starter/page/src/components/UserSelectorPanel/index.tsx +104 -34
- package/templates/database-starter/page/src/components/UserSelectorPanel/user-search.test.ts +55 -11
- package/templates/database-starter/page/src/components/UserSelectorPanel/user-search.ts +68 -25
- package/templates/database-starter/page/src/components/database-runtime-ready.tsx +1 -1
- package/templates/database-starter/page/src/components/database-user-menu/index.tsx +4 -7
- package/templates/database-starter/page/src/components/feedback/page-state.tsx +40 -5
- package/templates/database-starter/page/src/components/floating-button/assets/edit-active.svg +3 -0
- package/templates/database-starter/page/src/components/floating-button/assets/edit-default.svg +3 -0
- package/templates/database-starter/page/src/components/floating-button/index.tsx +75 -112
- package/templates/database-starter/page/src/components/floating-button/utils/project-meta.test.ts +107 -37
- package/templates/database-starter/page/src/components/floating-button/utils/project-meta.ts +13 -50
- package/templates/database-starter/page/src/components/ui-language.test.tsx +101 -0
- package/templates/database-starter/page/src/examples/ai-function/ImageTaskOperation.tsx +202 -0
- package/templates/database-starter/page/src/examples/ai-function/README.md +14 -0
- package/templates/database-starter/page/src/examples/ai-function/TextStreamOperation.tsx +184 -0
- package/templates/database-starter/page/src/examples/ai-function/ai-copy.ts +173 -0
- package/templates/database-starter/page/src/examples/ai-function/ai-delta.test.ts +30 -0
- package/templates/database-starter/page/src/examples/ai-function/ai-delta.ts +26 -0
- package/templates/database-starter/page/src/examples/ai-function/ai-language.test.tsx +86 -0
- package/templates/database-starter/page/src/examples/ai-function/ai-media-panel.test.ts +201 -0
- package/templates/database-starter/page/src/examples/ai-function/ai-media-panel.tsx +382 -0
- package/templates/database-starter/page/src/lib/attachment-image.tsx +144 -0
- package/templates/database-starter/page/src/lib/database-function-inputs.generated.ts +4 -0
- package/templates/database-starter/page/src/lib/database-runtime-attachment.test.ts +180 -76
- package/templates/database-starter/page/src/lib/database-runtime.test.ts +130 -10
- package/templates/database-starter/page/src/lib/database-runtime.ts +484 -51
- package/templates/database-starter/page/src/lib/date-time.ts +61 -0
- package/templates/database-starter/page/src/lib/ui-language.ts +86 -0
- package/templates/database-starter/page/src/main.tsx +1 -1
- package/templates/database-starter/page/tsconfig.json +2 -2
- package/templates/database-starter/page/vite.config.ts +1 -2
- package/templates/database-starter/prettierignore +1 -1
- package/templates/database-starter/server-functions/README.md +2 -2
- package/templates/database-starter/server-functions/package-lock.json +4 -4
- package/templates/database-starter/server-functions/package.json +1 -1
- package/dist/agent-guide.d.ts +0 -19
- package/dist/agent-guide.d.ts.map +0 -1
- package/dist/agent-guide.js +0 -31
- package/dist/agent-guide.js.map +0 -1
- package/dist/database-skill.d.ts +0 -24
- package/dist/database-skill.d.ts.map +0 -1
- package/dist/database-skill.js +0 -148
- package/dist/database-skill.js.map +0 -1
- package/skills/lark-base-database/references/base-sql-unsupported.md +0 -42
- package/skills/lark-base-database/references/database-capability-skill.md +0 -40
- package/skills/lark-base-database/references/sql-function.md +0 -289
- package/templates/database-capability/SKILL.md.tmpl +0 -58
- package/templates/database-starter/page/src/components/floating-button/chatbot-entry.test.ts +0 -23
- package/templates/database-starter/page/src/components/floating-button/chatbot-entry.tsx +0 -873
- package/templates/database-starter/page/src/components/floating-button/local-chat-panel.test.ts +0 -25
- package/templates/database-starter/page/src/components/floating-button/local-chat-panel.tsx +0 -197
- package/templates/database-starter/page/src/components/floating-button/utils/env.ts +0 -18
- package/templates/database-starter/sql/README.md +0 -8
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
# Database Function 专项
|
|
2
|
+
|
|
3
|
+
Database Function 通过固定查询语句读取 Base 数据,定义位于 `database-functions/<functionId>/`。
|
|
4
|
+
|
|
5
|
+
Database Function 只支持本文明确列出的只读查询语法和函数,不从通用关系型数据库的语法或函数推断其能力。
|
|
6
|
+
|
|
7
|
+
## 阅读路径
|
|
8
|
+
|
|
9
|
+
1. [Catalog](#catalog)
|
|
10
|
+
2. [查询类型与结果值](#查询类型与结果值)
|
|
11
|
+
3. [Function-文件与参数](#function-文件与参数)
|
|
12
|
+
4. [查询约束](#查询约束)
|
|
13
|
+
5. [调用与结果](#调用与结果)
|
|
14
|
+
6. [校验](#校验)
|
|
15
|
+
|
|
16
|
+
## Catalog
|
|
17
|
+
|
|
18
|
+
`catalog.json` 是已绑定 Base 的查询 schema。查询语句中的表名、字段名、OPTION 值和关联关系只从这里读取:
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"tables": [
|
|
23
|
+
{
|
|
24
|
+
"baseToken": "basxxx",
|
|
25
|
+
"id": "tblTasks",
|
|
26
|
+
"name": "任务",
|
|
27
|
+
"fields": {
|
|
28
|
+
"状态": {
|
|
29
|
+
"id": "fldStatus",
|
|
30
|
+
"name": "状态",
|
|
31
|
+
"baseType": "SingleSelect",
|
|
32
|
+
"sqlType": {
|
|
33
|
+
"kind": "OPTION",
|
|
34
|
+
"options": [{ "id": "optTodo", "name": "待处理" }]
|
|
35
|
+
},
|
|
36
|
+
"nullable": true
|
|
37
|
+
},
|
|
38
|
+
"主客户": {
|
|
39
|
+
"id": "fldPrimaryCustomer",
|
|
40
|
+
"name": "主客户",
|
|
41
|
+
"baseType": "Link",
|
|
42
|
+
"sqlType": { "kind": "TEXT" },
|
|
43
|
+
"nullable": true,
|
|
44
|
+
"reference": {
|
|
45
|
+
"baseToken": "basxxx",
|
|
46
|
+
"tableId": "tblCustomers"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"协作客户": {
|
|
50
|
+
"id": "fldCustomers",
|
|
51
|
+
"name": "协作客户",
|
|
52
|
+
"baseType": "DuplexLink",
|
|
53
|
+
"sqlType": {
|
|
54
|
+
"kind": "ARRAY",
|
|
55
|
+
"element": { "kind": "TEXT" }
|
|
56
|
+
},
|
|
57
|
+
"nullable": true,
|
|
58
|
+
"reference": {
|
|
59
|
+
"baseToken": "basxxx",
|
|
60
|
+
"tableId": "tblCustomers"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- 查询语句使用 `tables[].name` 和 `fields[key].name`;中文、空格或标点名称用双引号。
|
|
70
|
+
- `fields[key].sqlType` 决定可查询类型;`UNSUPPORTED` 字段不查询。
|
|
71
|
+
- OPTION 是 `{id, name}` UDT。按展示名筛选时显式写 `(option_field).name`,按稳定标识筛选时显式写 `(option_field).id`;不要把整个 OPTION 与 TEXT 隐式比较。
|
|
72
|
+
- `ARRAY` 的元素类型在 `sqlType.element`。
|
|
73
|
+
- `Link` / `DuplexLink` 的单向或双向语义不决定 SQL 是否为数组;以 `sqlType` 为准:单值关联是 `TEXT`,多值关联是 `ARRAY<TEXT>`。不要只凭 `baseType`、字段名或关联方向猜单多值。
|
|
74
|
+
- 关联目标由 `reference` 定位,JOIN 使用目标表名和其 `rec_id`。单值关联使用 `link_field = target.rec_id`,多值关联使用 `link_field @> ARRAY[target.rec_id]`。
|
|
75
|
+
- 每张表自动提供 `rec_id`,类型为 `TEXT`。Base、table、field ID 不写进查询语句。
|
|
76
|
+
|
|
77
|
+
## 查询类型与结果值
|
|
78
|
+
|
|
79
|
+
`sqlType` 是递归结构:标量为 `{"kind":"TEXT"}`,数组为 `{"kind":"ARRAY","element":{...}}`,OPTION 额外带 `options`。查询返回的行会序列化成普通 JSON:
|
|
80
|
+
|
|
81
|
+
| 查询类型 | ResultJSON 中的值 |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `TEXT` | string |
|
|
84
|
+
| `OPTION` | object;`{id, name}` |
|
|
85
|
+
| `INT` | JavaScript safe integer number |
|
|
86
|
+
| `DOUBLE` | number |
|
|
87
|
+
| `BOOL` | boolean |
|
|
88
|
+
| `TIMESTAMP` | Base 执行时区下的无偏移时间字符串;固定为 `YYYY-MM-DD HH:mm:ss.SSS` |
|
|
89
|
+
| `INTERVAL` | 以毫秒为单位的固定时长字符串;值为 `1` / `-1` 时用 `millisecond`,其余用 `milliseconds`(聚合结果可含小数) |
|
|
90
|
+
| `AUTONUMBER` | object;`{sequence, number}`,`.sequence` 用于数值排序/计算,`.number` 用于展示 |
|
|
91
|
+
| `USER` | object;`{id, name}`,查询读取 `.id`、`.name` |
|
|
92
|
+
| `CHAT` | object;`{id, name}`,查询读取 `.id`、`.name` |
|
|
93
|
+
| `LOCATION` | object;`{address, lng, lat}` |
|
|
94
|
+
| `URL` | object;`{text, link}`,比较或投影文本时显式读取 `.text` 或 `.link` |
|
|
95
|
+
| `FILE` | object;`{token, name, mime_type, size}`,其中 `token` 对应附件的 `attachmentToken`、`size` 为整数;展示走[文件传输](file-transfer.md) |
|
|
96
|
+
| `ARRAY<T>` | `T[]`;例如 `OPTION[]` 的元素为 `{id, name}` |
|
|
97
|
+
| nullable field | 原类型或 `null` |
|
|
98
|
+
|
|
99
|
+
执行时区按显式请求时区、Base 设置、Base 默认 `Asia/Shanghai` 的优先级确定;未设置时区的 Base 也按默认时区解析 TIMESTAMP 参数并渲染返回值。`TO_CHAR` 和时间投影使用同一生效时区。
|
|
100
|
+
|
|
101
|
+
日期表单回填与时间展示按[日期与时区](development.md#日期与时区)处理。
|
|
102
|
+
|
|
103
|
+
## Function 文件与参数
|
|
104
|
+
|
|
105
|
+
```text
|
|
106
|
+
database-functions/<functionId>/
|
|
107
|
+
├── main.sql
|
|
108
|
+
└── meta.json
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`main.sql` 只包含一条固定 `SELECT`。`meta.json` 包含非空 `name`、可选 `description` 和参数声明。
|
|
112
|
+
|
|
113
|
+
查询使用位置参数 `$1`、`$2`……;`meta.params` 按相同顺序声明:
|
|
114
|
+
|
|
115
|
+
```sql
|
|
116
|
+
SELECT rec_id AS record_id, "任务名称" AS title, ("状态").name AS status
|
|
117
|
+
FROM "任务"
|
|
118
|
+
WHERE "任务名称" LIKE $1 AND ("状态").name = $2
|
|
119
|
+
ORDER BY rec_id DESC
|
|
120
|
+
LIMIT $3
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"name": "搜索任务",
|
|
126
|
+
"description": "按关键词和状态搜索任务",
|
|
127
|
+
"meta": {
|
|
128
|
+
"params": [
|
|
129
|
+
{ "kind": "TEXT", "description": "$1,已包含 % 的关键词" },
|
|
130
|
+
{ "kind": "TEXT", "description": "$2,任务状态选项名" },
|
|
131
|
+
{ "kind": "INT", "description": "$3,已由调用方限制上限的返回条数" }
|
|
132
|
+
]
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`$n`、`meta.params[n-1]` 和调用 `params[n-1]` 必须连续、一一对应。每项只写 `kind` 和可选的 `description`。
|
|
138
|
+
|
|
139
|
+
参数 `kind` 只使用 `TEXT`、`INT`、`DOUBLE`、`BOOL`、`TIMESTAMP`、`INTERVAL`,或为其中任一类型追加一个 `[]` 表示一维数组,例如 `TEXT[]`。标量、整个数组及数组元素都可传 `null`;不支持多维数组,也不接受 `OPTION`、`USER`、`CHAT`、`FILE`、`LOCATION`、`URL`、`AUTONUMBER` 等对象类型。SingleSelect 的动态展示名参数仍声明为 `TEXT`,但 SQL 必须显式写 `(option_field).name = $n`;按 ID 筛选则写 `(option_field).id = $n`。
|
|
140
|
+
|
|
141
|
+
`TIMESTAMP` 参数接受 RFC 3339 字符串、Base 执行时区下的无偏移 `YYYY-MM-DD HH:mm:ss.SSS` 字符串,或 JavaScript safe integer 范围内的 Unix epoch 毫秒整数。无偏移字符串遇到夏令时不存在或重复的当地时间会被拒绝,此时改传带偏移的 RFC 3339 或 epoch 毫秒。`INTERVAL` 参数只接受一个数字和一个单位组成的固定时长字符串。单位支持 `ms`、`millisecond(s)`、`second(s)`、`sec(s)`、`minute(s)`、`min(s)`、`hour(s)`、`day(s)`、`week(s)`,不区分大小写;数字可带符号或小数,但结果必须能无损换算为整数毫秒,例如 `250 ms`、`1.5 seconds`、`2 hours`。不接受复合形式 `1 day 02:03:04`、时钟形式 `02:03:04`,也不支持月、年等日历单位。数组参数的每个非 `null` 元素遵循相同规则。`INT` 必须处于 JavaScript safe integer 范围。CLI 不做类型推断,以 `meta.params[].kind` 为唯一声明。
|
|
142
|
+
|
|
143
|
+
`INT` 参数可用于支持集内的数值表达式,也可用于 `LIMIT` 或 `OFFSET`。页面输入不能直接决定返回规模;确需动态条数时,先在 Page/FaaS 校验为非负整数并限制最大值,再传入参数。正常列表优先使用代码中固定的有界 `LIMIT`。
|
|
144
|
+
|
|
145
|
+
按文本数组参数筛选标量文本字段,使用 `= ANY($1)`:
|
|
146
|
+
|
|
147
|
+
```sql
|
|
148
|
+
SELECT f.rec_id AS record_id, f."分组" AS group_name
|
|
149
|
+
FROM "跟进记录" AS f
|
|
150
|
+
WHERE f."分组" = ANY($1)
|
|
151
|
+
LIMIT 30
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
对应参数声明为 `{"kind":"TEXT[]"}`,例如调用 `params: [["重点", "普通"]]`。左侧必须是 `TEXT`,右侧必须是一维 `TEXT[]` 位置参数;其他元素类型的参数化 `ANY` 尚不支持。空数组不会选中记录;整个数组为 `null`,或没有匹配且数组含 `null` 时,表达式为 `null`,`WHERE` 不选中该行。不要把 `IN ($1)` 当作数组展开,也不要对标量字段直接使用 `<@`。显式 `ARRAY[字段] <@ $1` 是数组包含判断,与 `ANY` 在含 `null` 时的布尔结果不同。
|
|
155
|
+
|
|
156
|
+
无参数 Function 可以省略 `meta` 或 `meta.params`;归档统一补成 `{"params":[]}`。
|
|
157
|
+
|
|
158
|
+
Database Function 的 `inputSchema` 由 CLI 根据参数顺序与 kind 生成固定长度数组 Schema,不在 Database Function 的 `meta.json` 中手写。FaaS 则必须显式声明,见[FaaS 开发](faas.md#faas-开发)。
|
|
159
|
+
|
|
160
|
+
## 查询约束
|
|
161
|
+
|
|
162
|
+
只编写只读 `SELECT`,页面输入只能进入位置参数。查询使用以下支持集:
|
|
163
|
+
|
|
164
|
+
- 每条查询的最终查询计划必须引用至少一个 Catalog 中的真实表,用于 Project 执行时选择可路由的表依赖。根 `FROM` 可以是带别名的 `FROM (SELECT ...) AS alias` 派生表,但该派生表或它依赖的 CTE 必须读取真实表。
|
|
165
|
+
- 不要用 Database Function 实现纯常量、纯参数、仅 `FROM (SELECT ...)`、仅 CTE 常量或仅 `UNNEST(...)` 的查询;这些查询没有真实表依赖,会在绑定 Base 的 Validate 或 Execute 阶段失败。与 Base 数据无关的常量在 Page/FaaS 中用代码返回。
|
|
166
|
+
- 查询结构使用显式投影、`WHERE`、`GROUP BY`、`HAVING`、`ORDER BY`;不使用 `SELECT *` 或 `table.*`。`GROUP BY` 最多 5 项。
|
|
167
|
+
- 连接使用 `INNER JOIN`、`LEFT JOIN`、`CROSS JOIN` 或逗号连接。每个 JOIN 的右侧保持为一个表或一个带别名的派生表;`UNNEST` 只能写成逗号连接或 `CROSS JOIN UNNEST(...)`,禁止生成 `LEFT JOIN UNNEST` 或 `INNER JOIN UNNEST`。不要把含 `GROUP BY`、`COUNT`、`SUM`、`AVG`、`MIN` 或 `MAX` 的聚合派生表再 `JOIN` 回主表。单条查询的 JOIN 总数不超过 10。
|
|
168
|
+
- 非递归顶层 CTE 可用;正常建站优先用带别名的 `FROM (SELECT ...) AS alias`,不要写递归 CTE 或嵌套 CTE。
|
|
169
|
+
- 子查询优先作为带别名的 `FROM` 派生表使用,外层通过派生表输出列名引用结果。成员判断可以直接写 `WHERE x IN (SELECT ...)`、`NOT IN`、`EXISTS`、`NOT EXISTS`;相关子查询只使用等值关联(`inner.k = outer.k`)。投影、`ORDER BY`、`OR` 或 `CASE` 之下的标量子查询和 `EXISTS` 默认不生成,需要时拆成派生表或单独查询。
|
|
170
|
+
- 列表使用有界 `LIMIT` 和稳定排序;排序值可能重复时最后按 `rec_id` 排序。不把截断结果的前端分页视为完整分页或全量导出。
|
|
171
|
+
- 返回字段逐列列出并使用 `AS snake_case`。
|
|
172
|
+
- 搜索写 `field LIKE $1`,由参数携带 `%`。
|
|
173
|
+
- `IN (..., NULL)` / `NOT IN (..., NULL)` 保留三值逻辑:未命中但列表含 NULL 时结果为 NULL,WHERE 不选中该行。字符串 `a || b` 任一侧为 NULL 时结果为 NULL;`CONCAT(a,b)` 则跳过 NULL。
|
|
174
|
+
- UDT 只与相同 UDT 类型直接比较;UDT 与成员标量之间没有隐式比较。OPTION 展示名筛选写 `(option_field).name = $n` / `IN (...)`,ID 筛选写 `(option_field).id = $n`;不要写 `option_field = $n`,也不要把 OPTION 或其成员与 `''` 比较。
|
|
175
|
+
- 当前访问者使用 `CURRENT_USER_ID()`,不由页面传入用户 ID。
|
|
176
|
+
- UDT 列成员统一使用 PostgreSQL 复合类型括号语法:无表别名写 `("负责人").id`、`("链接").link`,带表别名写 `(t."负责人").id`。FILE 可写 `("附件").token`、`.name`、`.mime_type`、`.size`;不再使用 `"字段".member` 或 `t."字段".member`。多值对象需要逐元素读取时写 `FROM 表 AS t, UNNEST(t."人员") AS u`,range alias 成员仍写 `u.id`。
|
|
177
|
+
- 不把 `USER`、`CHAT`、`FILE`、`URL` 完整对象投影到派生表后再 JOIN;需要身份过滤时在对象字段所在的真实表查询中直接比较标量子字段。权限关系的实现边界按[权限设计](permission-design.md#读路径database-function-就是策略面)。
|
|
178
|
+
- 集合字段只使用 PostgreSQL 数组运算符:`@>`(包含)、`<@`(被包含)、`&&`(有交集)。不要生成 `ARRAY_CONTAINS` 或 `ARRAY_OVERLAP`。单值关联的 `sqlType` 是 `TEXT`,按文本标量使用 `=`、`IN`、`NOT IN`、`LIKE`、`NOT LIKE`,不要对它使用数组运算符。只有业务本来就是 INNER/逐元素明细语义时,才通过逗号连接或 `CROSS JOIN UNNEST(...)` 展开;展开会过滤空集合主表行,不能替代 `LEFT JOIN UNNEST`。
|
|
179
|
+
- 条件分支:`CASE WHEN bool_expr THEN value ... ELSE value END` 与 `CASE expr WHEN value THEN ... END` 都可用,各分支结果类型一致(`INT` 与 `DOUBLE` 会统一为 `DOUBLE`)。空值兜底可用 `COALESCE(x, fallback)` / `NULLIF`,参数类型必须完全相同,`INT` 与 `DOUBLE` 混用时先写成同类型,例如 `COALESCE(x, 0.0)`。
|
|
180
|
+
- 时间条件使用 Catalog 中确认为 `TIMESTAMP` 的字段和 `TIMESTAMP` 位置参数;当前时间使用 `CURRENT_TIMESTAMP`。查询结果固定为 Base 执行时区下的 `YYYY-MM-DD HH:mm:ss.SSS` 字符串。`EXTRACT(EPOCH FROM timestamp_expr)` 返回保留小数秒的 `DOUBLE`;明确只需整数秒时再写 `::bigint`。不要对 `TEXT` 日期字符串使用顺序比较;若业务必须做日期范围筛选,把 Base 字段建成 DateTime/TIMESTAMP。
|
|
181
|
+
- 时间格式化与分桶:按月、按天展示写 `TO_CHAR(ts, 'YYYY-MM')`,格式只使用 `YYYY`、`MM`、`DD`、`HH24`、`MI`、`SS` 及分隔符;按天、周、月、季、年分组也可写 `DATE_TRUNC('month', ts)`,其结果只用于投影、`GROUP BY`、`ORDER BY`,不再参与比较或运算。时间偏移使用固定时长的 `INTERVAL`(毫秒到周),不使用月、年单位;需要按自然月计算的范围由 Page/FaaS 算好边界后作为 `TIMESTAMP` 参数传入。`EXTRACT` / `DATE_PART` 返回 `DOUBLE`。
|
|
182
|
+
- 聚合函数使用 `COUNT(*)`、`COUNT(expr)`、`SUM(NUMBER)`、`AVG(NUMBER)`、`MIN(scalar)`、`MAX(scalar)`;`ARRAY_AGG(expr)` 可用但默认少生成。聚合结果需要回填到列表行时,拆成列表查询、聚合查询和必要的明细查询,在 Page/FaaS 按稳定业务键合并。
|
|
183
|
+
- 条件计数、通过率等聚合写 `SUM(CASE WHEN cond THEN 1.0 ELSE 0.0 END)`;不要生成 `SUM/AVG(CASE WHEN cond THEN 1 ELSE 0 END)`。行数统计用 `COUNT(*)` / `COUNT(expr)`。
|
|
184
|
+
- 窗口函数可用:`ROW_NUMBER`、`RANK`、`DENSE_RANK`、`PERCENT_RANK`、`CUME_DIST`、`NTILE(n)`、`LAG` / `LEAD`、`FIRST_VALUE`,以及 `COUNT` / `SUM` / `AVG` / `MIN` / `MAX` 作为整个分区的汇总。“每组最新一条”写派生表 `ROW_NUMBER() OVER (PARTITION BY k ORDER BY t DESC, rec_id DESC) AS rn`,外层 `WHERE rn = 1`。窗口只支持整个分区的计算范围:带 `ORDER BY` 的聚合窗口(逐行累计)、`ROWS/RANGE BETWEEN ... CURRENT ROW` 等范围不支持,累计值在 Page/FaaS 中计算;`LAST_VALUE` 只能配合 `ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING`。
|
|
185
|
+
- 字符串函数使用 `LOWER(TEXT)`、`UPPER(TEXT)`、`CONCAT(TEXT, ...)`、`CONCAT_WS(sep, TEXT, ...)`、`REPLACE`、`STARTS_WITH`,拼接也可写 `||`(两侧都是 `TEXT`)。包含判断写 `STRPOS(text, 'x') > 0`,结尾匹配写 `text LIKE '%x'`;`STRPOS` 只能与 0 或 1 比较,不能取具体位置。`LENGTH`、`LEFT`、`RIGHT`、`SUBSTRING`、`LPAD`、`RPAD`、`TRIM` / `LTRIM` / `RTRIM` 和正则函数不支持,长度、截取、去空白、拆分在 Page/FaaS 中处理。
|
|
186
|
+
- 数字计算使用 `+`、`-`、`*`、`/`、`%` / `MOD`(仅整数)、`ABS`、`CEIL`、`FLOOR`、`ROUND(x)`;保留 n 位小数写 `ROUND(x::numeric, n)`,不写 `ROUND(x, n)`。两个整数相除按 PostgreSQL 做整数除法(`7 / 2 = 3`,`COUNT(a) / COUNT(*)` 也是整数除法),比例、通过率写 `COUNT(a)::float8 / COUNT(*)` 或 `SUM(CASE WHEN cond THEN 1.0 ELSE 0.0 END) / COUNT(*)`。
|
|
187
|
+
|
|
188
|
+
字段筛选使用以下支持形态:
|
|
189
|
+
|
|
190
|
+
| 字段类型 | 支持的筛选形态 |
|
|
191
|
+
|---|---|
|
|
192
|
+
| `rec_id`、`TEXT`、Phone | `=`、`IN`、`NOT IN`、`LIKE`、`NOT LIKE` |
|
|
193
|
+
| `INT`、`DOUBLE` | `=`、`IN`、`NOT IN`、`<`、`<=`、`>`、`>=`、`BETWEEN` |
|
|
194
|
+
| `BOOL` | `= TRUE`、`= FALSE` |
|
|
195
|
+
| `TIMESTAMP` | 与 `TIMESTAMP` 参数使用 `=`、`!=`、`<`、`<=`、`>`、`>=`、`BETWEEN` |
|
|
196
|
+
| `OPTION` | 显式比较 `.name` 或 `.id`;整个 OPTION 只与 OPTION 比较 |
|
|
197
|
+
| `AUTONUMBER` | `.sequence` 做数值比较、排序和计算;`.number` 做文本展示或等值筛选 |
|
|
198
|
+
| 单值关联(Catalog `sqlType.kind = TEXT`) | 按 `TEXT` 使用 `=`、`IN`、`NOT IN`、`LIKE`、`NOT LIKE` |
|
|
199
|
+
| 多选、多值关联等集合(Catalog `sqlType.kind = ARRAY`) | 只使用 `@>`、`<@`、`&&`;UDT 数组先显式访问 `.id` / `.name` 成员数组 |
|
|
200
|
+
| `USER`、`CHAT`、`FILE`、`URL` | 使用括号语法比较已声明的标量子字段;FILE 为 `.token/.name/.mime_type/.size`,URL 为 `.text/.link`;多值对象只有逐元素明细语义才展开 |
|
|
201
|
+
|
|
202
|
+
条件聚合示例:
|
|
203
|
+
|
|
204
|
+
```sql
|
|
205
|
+
SELECT
|
|
206
|
+
COUNT(s.rec_id) AS submitted_count,
|
|
207
|
+
SUM(CASE WHEN s."是否通过" = TRUE THEN 1.0 ELSE 0.0 END) AS passed_count,
|
|
208
|
+
ROUND(AVG(CASE WHEN s."得分" IS NULL THEN 0.0 ELSE s."得分" END)::numeric, 1) AS average_score
|
|
209
|
+
FROM "在线考试系统 - 答卷记录" AS s
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
路由表依赖:
|
|
213
|
+
|
|
214
|
+
```sql
|
|
215
|
+
-- 错误:只读取派生常量,没有 Catalog 真实表依赖
|
|
216
|
+
SELECT anchor.ok AS ok
|
|
217
|
+
FROM (SELECT TRUE AS ok) AS anchor
|
|
218
|
+
LIMIT 1
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
```sql
|
|
222
|
+
-- 正确:从真实表读取或聚合,让执行器可以选择路由表
|
|
223
|
+
SELECT COUNT(t.rec_id) AS task_count
|
|
224
|
+
FROM "任务" AS t
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
如果业务只是返回前端常量、默认配置或入参回显,不创建 Database Function;直接在 Page 状态或 FaaS handler 中构造结果。只有结果必须由 Base 数据决定时才编写查询语句,并选择业务相关的真实表作为查询锚点。
|
|
228
|
+
|
|
229
|
+
状态排序等固定映射也按上述写法:
|
|
230
|
+
|
|
231
|
+
```sql
|
|
232
|
+
CASE
|
|
233
|
+
WHEN (c."客户状态").name = '线索' THEN 1
|
|
234
|
+
WHEN (c."客户状态").name = '潜在客户' THEN 2
|
|
235
|
+
WHEN (c."客户状态").name = '意向客户' THEN 3
|
|
236
|
+
WHEN (c."客户状态").name = '成交客户' THEN 4
|
|
237
|
+
WHEN (c."客户状态").name = '流失' THEN 5
|
|
238
|
+
ELSE 6
|
|
239
|
+
END AS sort_order
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
集合筛选:
|
|
243
|
+
|
|
244
|
+
```sql
|
|
245
|
+
SELECT f.rec_id AS followup_id, f."跟进时间" AS followed_at
|
|
246
|
+
FROM "跟进记录" AS f
|
|
247
|
+
WHERE f."关联线索" @> ARRAY[$1]
|
|
248
|
+
ORDER BY f."跟进时间" DESC, f.rec_id DESC
|
|
249
|
+
LIMIT 30
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
单值关联按 record ID 标量筛选:
|
|
253
|
+
|
|
254
|
+
```sql
|
|
255
|
+
SELECT o.rec_id AS order_id, o."订单号" AS order_no
|
|
256
|
+
FROM "订单" AS o
|
|
257
|
+
WHERE o."主客户" = $1
|
|
258
|
+
ORDER BY o."订单号" DESC, o.rec_id DESC
|
|
259
|
+
LIMIT 30
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
读取关联表字段且需要保留主表空关联行时,`LEFT JOIN` 真实目标表,不要 `LEFT JOIN UNNEST`:
|
|
263
|
+
|
|
264
|
+
```sql
|
|
265
|
+
SELECT
|
|
266
|
+
o.rec_id AS order_id,
|
|
267
|
+
o."订单号" AS order_no,
|
|
268
|
+
c."客户名称" AS customer_name
|
|
269
|
+
FROM "订单" AS o
|
|
270
|
+
LEFT JOIN "客户" AS c ON o."客户" @> ARRAY[c.rec_id]
|
|
271
|
+
WHERE o."订单号" LIKE $1
|
|
272
|
+
ORDER BY o."订单号" DESC, o.rec_id DESC
|
|
273
|
+
LIMIT 20
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
上例是多值关联。若 Catalog 中关联字段为 `TEXT`,左连接改用标量等值:
|
|
277
|
+
|
|
278
|
+
```sql
|
|
279
|
+
SELECT
|
|
280
|
+
o.rec_id AS order_id,
|
|
281
|
+
o."订单号" AS order_no,
|
|
282
|
+
c."客户名称" AS customer_name
|
|
283
|
+
FROM "订单" AS o
|
|
284
|
+
LEFT JOIN "客户" AS c ON o."主客户" = c.rec_id
|
|
285
|
+
ORDER BY o."订单号" DESC, o.rec_id DESC
|
|
286
|
+
LIMIT 20
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
如果没有真实目标表可 JOIN,又必须保留空集合主表行,拆成主表查询与明细展开查询,并在 Page/FaaS 里按主表 `rec_id` 左合并。拆分后的输入若在开始时已经确定,应并发调用;不要用 `CROSS JOIN UNNEST` 冒充 LEFT 语义。
|
|
290
|
+
|
|
291
|
+
搜索、筛选、关联和聚合尽量在查询中完成。没有关系、不同步消费或需要隔离失败语义的结果集可以拆分;拆分后的输入若在开始时已经确定,应并发调用。
|
|
292
|
+
|
|
293
|
+
## 调用与结果
|
|
294
|
+
|
|
295
|
+
Database Function 的业务参数在两条链路上都是数组,但调用入口不同。Page 使用 starter 的 runtime client,不传 projectId:
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
import { invokeDatabaseFunction } from '../lib/database-runtime'
|
|
299
|
+
|
|
300
|
+
await invokeDatabaseFunction<{ rows: unknown[] }>(
|
|
301
|
+
'searchTasks',
|
|
302
|
+
['%评审%', '待处理', 20],
|
|
303
|
+
)
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
FaaS handler 内部使用 context,projectId 为当前 应用 的工程常量:
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
await context.invokeFunction({
|
|
310
|
+
projectId: PROJECT_ID,
|
|
311
|
+
functionId: 'searchTasks',
|
|
312
|
+
params: ['%评审%', '待处理', 20],
|
|
313
|
+
})
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
公开 HTTP body:
|
|
317
|
+
|
|
318
|
+
```json
|
|
319
|
+
{ "params": ["%评审%", "待处理", 20] }
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
返回是 ResultJSON,不是 rows 数组:
|
|
323
|
+
|
|
324
|
+
```json
|
|
325
|
+
{
|
|
326
|
+
"data": {
|
|
327
|
+
"rows": [
|
|
328
|
+
{
|
|
329
|
+
"record_id": "recxxx",
|
|
330
|
+
"title": "准备评审",
|
|
331
|
+
"status": "待处理",
|
|
332
|
+
"due_at": 1784311200000
|
|
333
|
+
}
|
|
334
|
+
],
|
|
335
|
+
"columns": [],
|
|
336
|
+
"row_count": 1,
|
|
337
|
+
"fxdb_version": "...",
|
|
338
|
+
"log_id": "..."
|
|
339
|
+
},
|
|
340
|
+
"code": 0,
|
|
341
|
+
"msg": "success"
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
先检查 `code===0` 和 `data`,再验证 `data.rows` 以及每一行的数据结构。
|
|
346
|
+
|
|
347
|
+
## 校验
|
|
348
|
+
|
|
349
|
+
`base-database-cli deploy` 统一调用服务端查询校验。编译成功仅证明编译阶段;实际读取按[发布与交付](release.md)或用户要求的[专项验收](verification.md)执行。失败时保留具体错误节点和 LogID,按 Catalog、参数与支持边界修复,不猜测响应 envelope 绕过。
|
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
按钮文案说明实际操作;保存成功只能来自真实业务返回或用户明确的模拟流程。页面头与按钮已经表达的功能不再写副标题。选择主按钮不自动在其外层生成背景卡片。
|
|
@@ -0,0 +1,122 @@
|
|
|
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; } }
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
}
|