@robert-stackflow/oneui 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-10-28
4
+
5
+ - AppShell 新增 brandName、brandIcon、brand、homePath、basePath 和 showHeader;支持无顶部栏的移动导航,可省略设置入口及侧栏折叠回调。普通点击向宿主传递内部路径,链接和新标签页使用完整基础路径。
6
+ - 新增 createToaster 独立通知控制器,可由 React 组件外代码调用;ToastProvider 可接收 controller,保留原 useToast API 和单条通知替换行为。
7
+ - IconPicker 支持 valuePrefix、自定义图标目录与 React 图标内容,保留存储 ID;统一字段、只读、禁用和表单值行为。
8
+ - DatePicker 支持 timeZone、clearable、placeholder;指定时区影响今天/此刻及日历当天标记,输入的日历时间保持原值,不隐式转换为 UTC。
9
+ - React/React DOM peer 范围调整为 ^19.2.0,独立消费验证覆盖 React 19.3.0 + TypeScript 5.9.3 和现有 React 19.3.1。
10
+ - 两端示例增加前缀/自定义图标和指定时区日期示例;未迁移或修改 Moment 业务代码。
11
+ - 修复极简深色导航选中文字过暗的问题。
12
+
3
13
  ## 0.2.0 — 2026-10-28
4
14
 
5
15
  - 新增 MessageDialog、ConfirmDialog、FormDialog 与统一 LoadingState,复用表单校验、异步提交、错误反馈和焦点管理。
package/README.md CHANGED
@@ -45,7 +45,7 @@ export function App() {
45
45
  - 页面直接复用 Form / FormField / useFormState 的校验与提交状态、Alert 的错误反馈和 ConfirmDialog 的确认流程;避免另写原生表单、按钮样式或错误提示。
46
46
  - Button 支持 `href`,渲染标准链接并沿用按钮样式;禁用或加载时阻止导航。Card 传入 `onClick` 后渲染可通过键盘激活的按钮卡片。导航与业务回调由消费应用提供。
47
47
  - CSS Modules 随组件分发,由消费项目的构建工具处理。图标/字符目录通过标准 `new URL(..., import.meta.url)` 加载随包资源。
48
- - React 和 React DOM 为 peer dependencies,不打入组件包。当前验证版本为 React 19.3.1,浏览器验证使用 Chromium。
48
+ - React 和 React DOM 为 peer dependencies(^19.2.0),不打入组件包。独立消费验证覆盖 React 19.3.1,以及 React 19.3.0 + TypeScript 5.9.3;浏览器验证使用 Chromium。
49
49
 
50
50
  ## 弹窗与加载状态
51
51
 
@@ -66,6 +66,35 @@ export function App() {
66
66
 
67
67
  菜单项间距统一为 `--menu-item-spacing: 1px`,菜单快捷键为靠右的纯文字提示,不预留空的指示器位置。Desktop 与 Web 共用示例:`/pickers?view=hierarchy`、`/forms?view=advanced`、`/data?view=hierarchy`;层级表格中的增删改仅更新示例内存数据。以上扩展从 0.2.0 起提供。
68
68
 
69
+ ## 接入现有应用(0.3.0)
70
+
71
+ `AppShell` 默认仍显示 OneUI 品牌和顶部栏。可通过 `brandName`、`brandIcon` 设置品牌,或用 `brand` 替换完整品牌内容;`homePath` 指定宿主内部首页,`basePath="/admin"` 为实际链接添加基础路径。`onNavigate` 收到未加基础路径的内部路径,适合 React Router basename;Ctrl/Cmd 点击和新标签页使用完整 href。`showHeader={false}` 保留移动端浮动导航按钮。`settingsItem` 和 `onCollapsedChange` 可省略。自定义完整品牌内容由宿主负责紧凑侧栏适配。
72
+
73
+ ```tsx
74
+ <AppShell brandName="Moment" brandIcon={<Brand />} homePath="/workbench"
75
+ basePath="/admin" showHeader={false} navigation={navigation}
76
+ active={active} title={title} onNavigate={navigate}>
77
+ {children}
78
+ </AppShell>
79
+ ```
80
+
81
+ 组件外通知使用独立服务,并将同一实例交给 Provider;不需要在 API 模块调用 Hook。未挂载时保留最新消息;`notify` 返回该消息的关闭函数,旧消息的关闭函数不会关闭新消息。`dismiss` 触发关闭动画,`clear` 立即清除。默认仍只显示最新一条通知,多个控制器相互隔离;服务端渲染时应为每个应用/请求创建实例。
82
+
83
+ ```tsx
84
+ // notifications.ts
85
+ export const notifications = createToaster();
86
+ export const notifyError = (error: Error) =>
87
+ notifications.notify(error.message, { tone: 'error' });
88
+ // application root
89
+ <ToastProvider controller={notifications}>{children}</ToastProvider>
90
+ ```
91
+
92
+ `IconPicker valuePrefix="lucide:"` 将内置图标名转换为带前缀的存储值;`options` 可提供 `{ value, label, icon, keywords?, category? }` 自定义目录,选择后原样返回 value。自定义目录不加载内置图标资源,设置 options 时不应用 valuePrefix。`IconPickerProps`、`IconPickerOption` 可从根入口或组件子路径导入。
93
+
94
+ `DatePicker mode="datetime" timeZone="Asia/Shanghai" seconds clearable` 使用指定 IANA 时区的今天/此刻,并支持清空及 placeholder。value 始终是 `YYYY-MM-DDTHH:mm[:ss]` 日历字段字符串;时区参数不把输入值变成时间戳,也不校验夏令时缺口/重复时间。定时发布、UTC 换算和时区歧义规则由宿主业务负责。无 timeZone 时继续使用设备本地时间。
95
+
96
+ 分类迁移时,如原应用父子分类独立选择,使用 `TreeSelect multiple checkStrictly`;图片上传的 EXIF/GPS、接口结果转换、串行策略(concurrency=1)与未保存状态属于宿主逻辑。
97
+
69
98
  ## 构建与打包
70
99
 
71
100
  在仓库根目录运行 `npm run pack:ui`。源码工作区供两端即时开发使用;发布内容单独生成到本目录 `dist`,含 ESM、TypeScript 声明、CSS 和资源。构建使用干净的临时目录并检查所有包内引用,成功后替换 dist,避免旧文件混入。`dist/package.json` 只导出生成内容。
@@ -14,17 +14,24 @@ export declare function ShellHeader({ title, collapsed, onToggle, onMenu, action
14
14
  windowControls?: ReactNode;
15
15
  draggable?: boolean;
16
16
  }): import("react").JSX.Element;
17
- export declare function AppShell({ navigation, settingsItem, active, title, collapsed, onCollapsedChange, onNavigate, children, headerActions, sidebarFooter, windowControls, draggable, }: {
17
+ export interface AppShellProps {
18
18
  navigation: readonly NavigationItem[];
19
- settingsItem: NavigationItem;
19
+ settingsItem?: NavigationItem;
20
20
  active: string;
21
21
  title: string;
22
22
  collapsed?: boolean;
23
- onCollapsedChange(value: boolean): void;
23
+ onCollapsedChange?(value: boolean): void;
24
24
  onNavigate(path: string): void;
25
25
  children: ReactNode;
26
26
  headerActions?: ReactNode;
27
27
  sidebarFooter?: ReactNode;
28
28
  windowControls?: ReactNode;
29
29
  draggable?: boolean;
30
- }): import("react").JSX.Element;
30
+ brandName?: string;
31
+ brandIcon?: ReactNode;
32
+ brand?: ReactNode;
33
+ homePath?: string;
34
+ basePath?: string;
35
+ showHeader?: boolean;
36
+ }
37
+ export declare function AppShell({ navigation, settingsItem, active, title, collapsed, onCollapsedChange, onNavigate, children, headerActions, sidebarFooter, windowControls, draggable, brandName, brandIcon, brand, homePath, basePath, showHeader, }: AppShellProps): import("react").JSX.Element;
@@ -1,5 +1,9 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
- import { useEffect, useRef, useState } from "react";
2
+ import {
3
+ useEffect,
4
+ useRef,
5
+ useState
6
+ } from "react";
3
7
  import { Layers2, Menu, PanelLeftClose, PanelLeftOpen } from "lucide-react";
4
8
  import { Drawer } from "./Drawer.js";
5
9
  import { IconButton } from "./IconButton.js";
@@ -63,10 +67,24 @@ function AppShell({
63
67
  headerActions,
64
68
  sidebarFooter,
65
69
  windowControls,
66
- draggable = false
70
+ draggable = false,
71
+ brandName = "OneUI",
72
+ brandIcon = /* @__PURE__ */ jsx(Layers2, { size: 20 }),
73
+ brand,
74
+ homePath = "/",
75
+ basePath = "",
76
+ showHeader = true
67
77
  }) {
68
78
  const [mobile, setMobile] = useState(false);
69
79
  const scroll = useRef(null);
80
+ const base = basePath.replace(/^\/+|\/+$/g, ""), href = (path) => (base ? "/" + base : "") + (path.startsWith("/") ? path : "/" + path);
81
+ const navigate = (event, path) => {
82
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
83
+ return;
84
+ event.preventDefault();
85
+ onNavigate(path);
86
+ setMobile(false);
87
+ };
70
88
  useEffect(() => {
71
89
  setMobile(false);
72
90
  scroll.current?.scrollTo({ top: 0 });
@@ -74,16 +92,10 @@ function AppShell({
74
92
  const item = (n) => /* @__PURE__ */ jsxs(
75
93
  "a",
76
94
  {
77
- href: n.path,
95
+ href: href(n.path),
78
96
  "aria-current": active === n.id ? "page" : void 0,
79
97
  title: collapsed ? n.label : void 0,
80
- onClick: (e) => {
81
- if (e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) {
82
- e.preventDefault();
83
- onNavigate(n.path);
84
- setMobile(false);
85
- }
86
- },
98
+ onClick: (event) => navigate(event, n.path),
87
99
  children: [
88
100
  n.icon,
89
101
  /* @__PURE__ */ jsx("span", { children: n.label })
@@ -92,59 +104,72 @@ function AppShell({
92
104
  n.id
93
105
  );
94
106
  const nav = /* @__PURE__ */ jsxs(Fragment, { children: [
95
- /* @__PURE__ */ jsxs(
107
+ /* @__PURE__ */ jsx(
96
108
  "a",
97
109
  {
98
- href: "/",
110
+ href: href(homePath),
99
111
  className: s.brand,
100
- "aria-label": "OneUI \u9996\u9875",
101
- onClick: (e) => {
102
- e.preventDefault();
103
- onNavigate("/");
104
- setMobile(false);
105
- },
106
- children: [
107
- /* @__PURE__ */ jsx("span", { className: s.mark, children: /* @__PURE__ */ jsx(Layers2, { size: 20 }) }),
108
- /* @__PURE__ */ jsx("strong", { children: "OneUI" })
109
- ]
112
+ "aria-label": brandName + " \u9996\u9875",
113
+ onClick: (event) => navigate(event, homePath),
114
+ children: brand ?? /* @__PURE__ */ jsxs(Fragment, { children: [
115
+ /* @__PURE__ */ jsx("span", { className: s.mark, children: brandIcon }),
116
+ /* @__PURE__ */ jsx("strong", { children: brandName })
117
+ ] })
110
118
  }
111
119
  ),
112
120
  /* @__PURE__ */ jsx("nav", { "aria-label": "\u4E3B\u5BFC\u822A", className: s.navigation, children: navigation.map(item) }),
113
- /* @__PURE__ */ jsxs("div", { className: s.bottom, children: [
114
- /* @__PURE__ */ jsx("nav", { "aria-label": "\u8BBE\u7F6E", className: s.navigation, children: item(settingsItem) }),
121
+ (settingsItem || sidebarFooter) && /* @__PURE__ */ jsxs("div", { className: s.bottom, children: [
122
+ settingsItem && /* @__PURE__ */ jsx("nav", { "aria-label": "\u8BBE\u7F6E", className: s.navigation, children: item(settingsItem) }),
115
123
  sidebarFooter
116
124
  ] })
117
125
  ] });
118
- return /* @__PURE__ */ jsxs("div", { className: s.shell, "data-collapsed": collapsed, children: [
119
- /* @__PURE__ */ jsx("a", { className: s.skip, href: "#main", children: "\u8DF3\u8F6C\u5230\u5185\u5BB9" }),
120
- /* @__PURE__ */ jsx("aside", { className: s.sidebar, children: nav }),
121
- /* @__PURE__ */ jsx(
122
- Drawer,
123
- {
124
- placement: "left",
125
- size: 280,
126
- open: mobile,
127
- onClose: () => setMobile(false),
128
- title: "\u5BFC\u822A",
129
- children: /* @__PURE__ */ jsx("div", { className: s.mobileNav, children: nav })
130
- }
131
- ),
132
- /* @__PURE__ */ jsxs("div", { className: s.workspace, children: [
133
- /* @__PURE__ */ jsx(
134
- ShellHeader,
135
- {
136
- title,
137
- collapsed,
138
- onToggle: () => onCollapsedChange(!collapsed),
139
- onMenu: () => setMobile(true),
140
- actions: headerActions,
141
- windowControls,
142
- draggable
143
- }
144
- ),
145
- /* @__PURE__ */ jsx("main", { ref: scroll, id: "main", tabIndex: -1, className: s.main, children: /* @__PURE__ */ jsx("div", { className: s.page, children }) })
146
- ] })
147
- ] });
126
+ return /* @__PURE__ */ jsxs(
127
+ "div",
128
+ {
129
+ className: s.shell,
130
+ "data-collapsed": collapsed,
131
+ "data-header": showHeader,
132
+ children: [
133
+ /* @__PURE__ */ jsx("a", { className: s.skip, href: "#main", children: "\u8DF3\u8F6C\u5230\u5185\u5BB9" }),
134
+ /* @__PURE__ */ jsx("aside", { className: s.sidebar, children: nav }),
135
+ /* @__PURE__ */ jsx(
136
+ Drawer,
137
+ {
138
+ placement: "left",
139
+ size: 280,
140
+ open: mobile,
141
+ onClose: () => setMobile(false),
142
+ title: "\u5BFC\u822A",
143
+ children: /* @__PURE__ */ jsx("div", { className: s.mobileNav, children: nav })
144
+ }
145
+ ),
146
+ /* @__PURE__ */ jsxs("div", { className: s.workspace, children: [
147
+ showHeader ? /* @__PURE__ */ jsx(
148
+ ShellHeader,
149
+ {
150
+ title,
151
+ collapsed,
152
+ onToggle: onCollapsedChange ? () => onCollapsedChange(!collapsed) : void 0,
153
+ onMenu: () => setMobile(true),
154
+ actions: headerActions,
155
+ windowControls,
156
+ draggable
157
+ }
158
+ ) : /* @__PURE__ */ jsx(
159
+ IconButton,
160
+ {
161
+ className: `${s.mobileToggle} ${s.floatingNavigation}`,
162
+ label: "\u6253\u5F00\u5BFC\u822A",
163
+ variant: "ghost",
164
+ onClick: () => setMobile(true),
165
+ children: /* @__PURE__ */ jsx(Menu, { size: 18 })
166
+ }
167
+ ),
168
+ /* @__PURE__ */ jsx("main", { ref: scroll, id: "main", tabIndex: -1, className: s.main, children: /* @__PURE__ */ jsx("div", { className: s.page, children }) })
169
+ ] })
170
+ ]
171
+ }
172
+ );
148
173
  }
149
174
  export {
150
175
  AppShell,
@@ -78,7 +78,7 @@
78
78
  }
79
79
  .navigation a[aria-current="page"] {
80
80
  background: var(--selected);
81
- color: var(--accent);
81
+ color: var(--navigation-selected-color, var(--accent));
82
82
  }
83
83
  .bottom {
84
84
  display: flex;
@@ -172,6 +172,15 @@
172
172
  .mobileToggle {
173
173
  display: none;
174
174
  }
175
+ .floatingNavigation {
176
+ position: fixed;
177
+ z-index: 50;
178
+ top: 12px;
179
+ left: 12px;
180
+ background: var(--surface);
181
+ border: 1px solid var(--line);
182
+ box-shadow: var(--shadow-sm);
183
+ }
175
184
  .mobileNav {
176
185
  display: flex;
177
186
  flex-direction: column;
@@ -219,6 +228,9 @@
219
228
  .page {
220
229
  padding: 28px 24px 40px;
221
230
  }
231
+ .shell[data-header="false"] .page {
232
+ padding-top: 64px;
233
+ }
222
234
  }
223
235
  @media (max-width: 540px) {
224
236
  .page {
@@ -6,5 +6,6 @@ export type CalendarProps = DateConstraints & {
6
6
  range?: DateRange;
7
7
  preview?: string;
8
8
  onHover?(value: string): void;
9
+ today?: string;
9
10
  };
10
- export declare function Calendar({ value, onChange, range, preview, onHover, minDate, maxDate, disabledDate, }: CalendarProps): import("react").JSX.Element;
11
+ export declare function Calendar({ value, onChange, range, preview, onHover, minDate, maxDate, disabledDate, today, }: CalendarProps): import("react").JSX.Element;
@@ -20,14 +20,15 @@ function Calendar({
20
20
  onHover,
21
21
  minDate,
22
22
  maxDate,
23
- disabledDate
23
+ disabledDate,
24
+ today = dateKey(/* @__PURE__ */ new Date())
24
25
  }) {
25
26
  const constraints = { minDate, maxDate, disabledDate };
26
27
  const [month, setMonth] = useState(
27
- () => readDate(boundedDate(value, constraints))
28
+ () => readDate(boundedDate(value || today, constraints))
28
29
  ), [view, setView] = useState("days"), [focused, setFocused] = useState(value);
29
30
  const grid = useRef(null), choices = useRef(null), pending = useRef(null), pendingView = useRef(false);
30
- const year = month.getFullYear(), index = month.getMonth(), start = (calendarDate(year, index).getDay() + 6) % 7, base = Math.floor(year / 12) * 12, today = dateKey(/* @__PURE__ */ new Date());
31
+ const year = month.getFullYear(), index = month.getMonth(), start = (calendarDate(year, index).getDay() + 6) % 7, base = Math.floor(year / 12) * 12;
31
32
  const allowed = (key) => isDateAllowed(key, constraints);
32
33
  const monthAllowed = (y, m) => y >= 1 && y <= 9999 && !(minDate && parseDate(minDate) && dateKey(calendarDate(y, m + 1, 0)) < minDate) && !(maxDate && parseDate(maxDate) && dateKey(calendarDate(y, m)) > maxDate);
33
34
  const periodAllowed = (amount) => {
@@ -8,6 +8,9 @@ export type SingleDatePickerProps = FieldOptions & DateConstraints & {
8
8
  label: string;
9
9
  mode?: Exclude<DatePickerMode, "range">;
10
10
  seconds?: boolean;
11
+ timeZone?: string;
12
+ clearable?: boolean;
13
+ placeholder?: string;
11
14
  };
12
15
  export type DatePickerProps = SingleDatePickerProps | (DateRangePickerProps & {
13
16
  mode: "range";
@@ -5,7 +5,7 @@ import { useId, useState } from "react";
5
5
  import { CalendarDays, Clock3 } from "lucide-react";
6
6
  import { Popover } from "./Popover.js";
7
7
  import { usePopover } from "./usePopover.js";
8
- import { Calendar, dateKey } from "./Calendar.js";
8
+ import { Calendar } from "./Calendar.js";
9
9
  import { TimeColumns } from "./TimeColumns.js";
10
10
  import { Button } from "./Button.js";
11
11
  import s from "./DatePicker.module.css";
@@ -14,6 +14,7 @@ import {
14
14
  isDateAllowed
15
15
  } from "./dateConstraints.js";
16
16
  import { DateRangePicker } from "./DateRangePicker.js";
17
+ import { calendarNow } from "./calendarNow.js";
17
18
  function DatePicker(props) {
18
19
  return props.mode === "range" ? /* @__PURE__ */ jsx(DateRangePicker, { ...props }) : /* @__PURE__ */ jsx(SingleDatePicker, { ...props });
19
20
  }
@@ -23,6 +24,9 @@ function SingleDatePicker({
23
24
  label,
24
25
  mode = "date",
25
26
  seconds = false,
27
+ timeZone,
28
+ clearable = false,
29
+ placeholder = "\u8BF7\u9009\u62E9",
26
30
  disabled,
27
31
  readOnly,
28
32
  name,
@@ -47,7 +51,8 @@ function SingleDatePicker({
47
51
  hint,
48
52
  ...aria
49
53
  });
50
- const pop = usePopover(field.disabled || field.readOnly), id = useId(), [date, setDate] = useState(value.slice(0, 10) || dateKey(/* @__PURE__ */ new Date())), [time, setTime] = useState("00:00");
54
+ const now = () => calendarNow(timeZone), today = now().date;
55
+ const pop = usePopover(field.disabled || field.readOnly), id = useId(), [date, setDate] = useState(value.slice(0, 10) || today), [time, setTime] = useState("00:00");
51
56
  const close = pop.closeAndFocus;
52
57
  const show = () => {
53
58
  if (field.disabled || field.readOnly) return;
@@ -56,7 +61,7 @@ function SingleDatePicker({
56
61
  return;
57
62
  }
58
63
  setDate(
59
- mode === "time" ? dateKey(/* @__PURE__ */ new Date()) : boundedDate(value.slice(0, 10), constraints)
64
+ mode === "time" ? now().date : boundedDate(value.slice(0, 10) || now().date, constraints)
60
65
  );
61
66
  setTime(
62
67
  (mode === "time" ? value : value.split(/[T ]/)[1]) || (seconds ? "00:00:00" : "00:00")
@@ -82,6 +87,7 @@ function SingleDatePicker({
82
87
  "aria-expanded": pop.open,
83
88
  "aria-haspopup": "dialog",
84
89
  "aria-controls": id,
90
+ "data-placeholder": !value || void 0,
85
91
  onClick: show,
86
92
  onKeyDown: (e) => {
87
93
  if (e.key === "ArrowDown" && !pop.open) {
@@ -90,7 +96,7 @@ function SingleDatePicker({
90
96
  }
91
97
  },
92
98
  children: [
93
- /* @__PURE__ */ jsx("span", { children: value.replace("T", " ") || "\u8BF7\u9009\u62E9" }),
99
+ /* @__PURE__ */ jsx("span", { children: value.replace("T", " ") || placeholder }),
94
100
  mode === "time" ? /* @__PURE__ */ jsx(Clock3, {}) : /* @__PURE__ */ jsx(CalendarDays, {})
95
101
  ]
96
102
  }
@@ -119,6 +125,7 @@ function SingleDatePicker({
119
125
  Calendar,
120
126
  {
121
127
  ...constraints,
128
+ today,
122
129
  key: pop.open ? "open" : "closed",
123
130
  value: date,
124
131
  onChange: (next) => {
@@ -143,16 +150,14 @@ function SingleDatePicker({
143
150
  ] }),
144
151
  /* @__PURE__ */ jsxs("footer", { className: s.footer, children: [
145
152
  /* @__PURE__ */ jsx(
146
- "button",
153
+ Button,
147
154
  {
155
+ variant: "ghost",
156
+ size: "small",
148
157
  type: "button",
149
- disabled: mode !== "time" && !isDateAllowed(dateKey(/* @__PURE__ */ new Date()), constraints),
158
+ disabled: mode !== "time" && !isDateAllowed(today, constraints),
150
159
  onClick: () => {
151
- const now = /* @__PURE__ */ new Date(), nextDate = dateKey(now), nextTime = [
152
- now.getHours(),
153
- now.getMinutes(),
154
- ...seconds ? [now.getSeconds()] : []
155
- ].map((v) => String(v).padStart(2, "0")).join(":");
160
+ const current = now(), nextDate = current.date, nextTime = current.time.slice(0, seconds ? 8 : 5);
156
161
  if (mode !== "time" && !isDateAllowed(nextDate, constraints))
157
162
  return;
158
163
  setDate(nextDate);
@@ -165,16 +170,31 @@ function SingleDatePicker({
165
170
  children: mode === "date" ? "\u4ECA\u5929" : "\u6B64\u523B"
166
171
  }
167
172
  ),
168
- mode !== "date" && /* @__PURE__ */ jsx(
169
- Button,
170
- {
171
- variant: "primary",
172
- size: "small",
173
- onClick: commit,
174
- disabled: mode !== "time" && !isDateAllowed(date, constraints),
175
- children: "\u786E\u5B9A"
176
- }
177
- )
173
+ (clearable || mode !== "date") && /* @__PURE__ */ jsxs("div", { className: s.rangeActions, children: [
174
+ clearable && /* @__PURE__ */ jsx(
175
+ Button,
176
+ {
177
+ size: "small",
178
+ variant: "ghost",
179
+ disabled: !value,
180
+ onClick: () => {
181
+ onChange("");
182
+ close();
183
+ },
184
+ children: "\u6E05\u7A7A"
185
+ }
186
+ ),
187
+ mode !== "date" && /* @__PURE__ */ jsx(
188
+ Button,
189
+ {
190
+ variant: "primary",
191
+ size: "small",
192
+ onClick: commit,
193
+ disabled: mode !== "time" && !isDateAllowed(date, constraints),
194
+ children: "\u786E\u5B9A"
195
+ }
196
+ )
197
+ ] })
178
198
  ] })
179
199
  ]
180
200
  }
@@ -12,6 +12,9 @@
12
12
  height: 15px;
13
13
  color: var(--muted);
14
14
  }
15
+ .trigger[data-placeholder="true"] > span {
16
+ color: var(--placeholder);
17
+ }
15
18
  .body {
16
19
  display: flex;
17
20
  }
@@ -1,6 +1,16 @@
1
- export declare function IconPicker({ value, onChange, label, disabled, }: {
1
+ import { type ReactNode } from "react";
2
+ import { type FieldOptions } from "./field";
3
+ export type IconPickerOption = {
4
+ value: string;
5
+ label: string;
6
+ icon: ReactNode;
7
+ keywords?: string[];
8
+ category?: string;
9
+ };
10
+ export type IconPickerProps = FieldOptions & {
2
11
  value: string;
3
12
  onChange(value: string): void;
4
- label?: string;
5
- disabled?: boolean;
6
- }): import("react").JSX.Element;
13
+ options?: IconPickerOption[];
14
+ valuePrefix?: string;
15
+ };
16
+ export declare function IconPicker({ value, onChange, label, options, valuePrefix, ...props }: IconPickerProps): import("react").JSX.Element;
@@ -1,5 +1,5 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
- import { useCallback, useMemo, useRef, useState } from "react";
2
+ import { useCallback, useMemo, useState } from "react";
3
3
  import { Popover } from "./Popover.js";
4
4
  import { SearchField } from "./SearchField.js";
5
5
  import { CatalogIcon } from "./CatalogIcon.js";
@@ -10,16 +10,48 @@ import {
10
10
  VirtualPickerList
11
11
  } from "./VirtualPickerList.js";
12
12
  import { useIconCatalog, iconCategories } from "./iconCatalog.js";
13
+ import { usePopover } from "./usePopover.js";
14
+ import { useFieldControl, fieldTriggerProps } from "./field.js";
13
15
  import s from "./Picker.module.css";
14
16
  function IconPicker({
15
17
  value,
16
18
  onChange,
17
- label = "\u56FE\u6807",
18
- disabled = false
19
+ label,
20
+ options,
21
+ valuePrefix = "",
22
+ ...props
19
23
  }) {
20
- const anchor = useRef(null), [open, setOpen] = useState(false), [query, setQuery] = useState("");
21
- const { items, error, retry } = useIconCatalog(open);
24
+ const field = useFieldControl({ ...props, label }), pickerLabel = field.label || "\u56FE\u6807", blocked = field.disabled || field.readOnly, pop = usePopover(blocked), [query, setQuery] = useState("");
25
+ const name = valuePrefix && value.startsWith(valuePrefix) ? value.slice(valuePrefix.length) : value;
26
+ const selected = options?.find((option) => option.value === value);
27
+ const { items, error, retry } = useIconCatalog(
28
+ pop.open && options === void 0
29
+ );
22
30
  const groups = useMemo(() => {
31
+ if (options) {
32
+ const categories = /* @__PURE__ */ new Map();
33
+ for (const option of options) {
34
+ const category = option.category || "\u5168\u90E8";
35
+ if (!categories.has(category))
36
+ categories.set(category, {
37
+ id: category,
38
+ label: category,
39
+ items: []
40
+ });
41
+ categories.get(category).items.push({
42
+ id: option.value,
43
+ label: option.label,
44
+ content: option.icon,
45
+ search: [
46
+ option.value,
47
+ option.label,
48
+ category,
49
+ ...option.keywords || []
50
+ ].join(" ").toLowerCase()
51
+ });
52
+ }
53
+ return [...categories.values()];
54
+ }
23
55
  if (!items) return [];
24
56
  const indexed = items.map((icon) => ({
25
57
  id: icon.name,
@@ -40,43 +72,53 @@ function IconPicker({
40
72
  )
41
73
  })
42
74
  ).filter((group) => group.items.length);
43
- }, [items]);
75
+ }, [items, options]);
44
76
  const choose = useCallback(
45
77
  (item) => {
46
- onChange(item.id);
47
- setOpen(false);
48
- anchor.current?.focus();
78
+ if (blocked) return;
79
+ onChange(options ? item.id : valuePrefix + item.id);
80
+ pop.closeAndFocus();
49
81
  },
50
- [onChange]
82
+ [onChange, blocked, options, valuePrefix, pop.closeAndFocus]
51
83
  );
52
84
  return /* @__PURE__ */ jsxs(Fragment, { children: [
53
85
  /* @__PURE__ */ jsx(
54
86
  SelectTrigger,
55
87
  {
56
- ref: anchor,
57
- expanded: open,
58
- disabled,
59
- "aria-label": label,
88
+ ...fieldTriggerProps(field),
89
+ "aria-label": pickerLabel,
90
+ ref: pop.anchor,
91
+ expanded: pop.open,
92
+ "data-readonly": field.readOnly || void 0,
60
93
  "aria-haspopup": "dialog",
61
- onClick: () => setOpen((x) => !x),
94
+ onClick: pop.toggle,
62
95
  onKeyDown: (e) => {
63
96
  if (e.key === "ArrowDown" || e.key === "ArrowUp") {
64
97
  e.preventDefault();
65
- setOpen(true);
98
+ pop.show();
66
99
  }
67
100
  },
68
- icon: /* @__PURE__ */ jsx(CatalogIcon, { name: value }),
69
- children: /* @__PURE__ */ jsx("span", { children: value || "\u9009\u62E9\u56FE\u6807" })
101
+ icon: options ? selected?.icon : /* @__PURE__ */ jsx(CatalogIcon, { name }),
102
+ children: /* @__PURE__ */ jsx("span", { children: selected?.label || name || "\u9009\u62E9\u56FE\u6807" })
103
+ }
104
+ ),
105
+ field.name && /* @__PURE__ */ jsx(
106
+ "input",
107
+ {
108
+ type: "hidden",
109
+ name: field.name,
110
+ value,
111
+ disabled: field.disabled
70
112
  }
71
113
  ),
72
114
  /* @__PURE__ */ jsx(
73
115
  Popover,
74
116
  {
75
- open,
76
- onClose: () => setOpen(false),
77
- anchor,
117
+ open: pop.open,
118
+ onClose: pop.close,
119
+ anchor: pop.anchor,
78
120
  width: 470,
79
- label: label + "\u9009\u62E9\u5668",
121
+ label: pickerLabel + "\u9009\u62E9\u5668",
80
122
  children: /* @__PURE__ */ jsxs("div", { className: s.panel, children: [
81
123
  /* @__PURE__ */ jsx(
82
124
  SearchField,
@@ -87,18 +129,18 @@ function IconPicker({
87
129
  onChange: setQuery
88
130
  }
89
131
  ),
90
- error ? /* @__PURE__ */ jsx(Button, { onClick: retry, children: "\u91CD\u65B0\u52A0\u8F7D" }) : !items ? /* @__PURE__ */ jsx("div", { className: s.loading, children: /* @__PURE__ */ jsx(Spinner, { label: "\u52A0\u8F7D\u56FE\u6807" }) }) : /* @__PURE__ */ jsx(
132
+ options === void 0 && error ? /* @__PURE__ */ jsx(Button, { onClick: retry, children: "\u91CD\u65B0\u52A0\u8F7D" }) : options === void 0 && !items ? /* @__PURE__ */ jsx("div", { className: s.loading, children: /* @__PURE__ */ jsx(Spinner, { label: "\u52A0\u8F7D\u56FE\u6807" }) }) : /* @__PURE__ */ jsx(
91
133
  VirtualPickerList,
92
134
  {
93
135
  groups,
94
136
  query,
95
- value,
137
+ value: options ? value : name,
96
138
  label: "\u56FE\u6807",
97
139
  onChoose: choose
98
140
  }
99
141
  ),
100
142
  /* @__PURE__ */ jsx("div", { className: s.footer, children: /* @__PURE__ */ jsxs("span", { children: [
101
- items?.length || 0,
143
+ options?.length ?? items?.length ?? 0,
102
144
  " \u4E2A\u56FE\u6807"
103
145
  ] }) })
104
146
  ] })
@@ -1,4 +1,5 @@
1
1
  import { type ReactNode } from "react";
2
+ import { type ToastController, type ToastNotify } from "./createToaster";
2
3
  export type ToastPosition = "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right";
3
4
  export type ToastTone = "neutral" | "success" | "info" | "warning" | "error";
4
5
  export type ToastAction = {
@@ -27,9 +28,8 @@ export type ToastMessage = {
27
28
  options?: ToastOptions;
28
29
  closing?: boolean;
29
30
  };
30
- type Notify = (message: string, options?: ToastOptions | ToastTone) => () => void;
31
- export declare function ToastProvider({ children }: {
31
+ export declare function ToastProvider({ children, controller, }: {
32
32
  children: ReactNode;
33
+ controller?: ToastController;
33
34
  }): import("react").JSX.Element;
34
- export declare const useToast: () => Notify;
35
- export {};
35
+ export declare const useToast: () => ToastNotify;
@@ -1,42 +1,39 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import {
3
3
  createContext,
4
- useCallback,
5
4
  useContext,
6
- useRef,
7
- useState
5
+ useState,
6
+ useSyncExternalStore
8
7
  } from "react";
9
8
  import { useUIConfig } from "./UIProvider.js";
10
9
  import { Toast } from "./Toast.js";
10
+ import {
11
+ createToaster
12
+ } from "./createToaster.js";
11
13
  const Context = createContext(() => () => {
12
14
  });
13
- function ToastProvider({ children }) {
15
+ const serverSnapshot = () => null;
16
+ function ToastProvider({
17
+ children,
18
+ controller
19
+ }) {
14
20
  const config = useUIConfig();
15
- const [toast, setToast] = useState(null), counter = useRef(0);
16
- const notify = useCallback((message, options) => {
17
- const id = ++counter.current;
18
- setToast({
19
- message,
20
- id,
21
- options: typeof options === "string" ? { tone: options } : options
22
- });
23
- return () => setToast(
24
- (current) => current?.id === id ? { ...current, closing: true } : current
25
- );
26
- }, []);
27
- const clear = useCallback(
28
- (id) => setToast((current) => current?.id === id ? null : current),
29
- []
21
+ const [local] = useState(createToaster), source = controller || local;
22
+ const toast = useSyncExternalStore(
23
+ source.subscribe,
24
+ source.getSnapshot,
25
+ serverSnapshot
30
26
  );
31
- return /* @__PURE__ */ jsxs(Context, { value: notify, children: [
27
+ return /* @__PURE__ */ jsxs(Context, { value: source.notify, children: [
32
28
  children,
33
29
  /* @__PURE__ */ jsx(
34
30
  Toast,
35
31
  {
36
32
  toast,
37
33
  position: toast?.options?.position || config.toastPosition,
38
- onDismiss: clear
39
- }
34
+ onDismiss: source.clear
35
+ },
36
+ source.id
40
37
  )
41
38
  ] });
42
39
  }
@@ -1,3 +1,4 @@
1
+ import { type ReactNode } from "react";
1
2
  import { type LucideIconData } from "lucide-react";
2
3
  export type PickerCatalogItem = {
3
4
  id: string;
@@ -5,6 +6,7 @@ export type PickerCatalogItem = {
5
6
  search: string;
6
7
  glyph?: string;
7
8
  icon?: LucideIconData;
9
+ content?: ReactNode;
8
10
  variants?: {
9
11
  glyph: string;
10
12
  label: string;
@@ -44,7 +44,7 @@ const Cell = memo(function Cell2({
44
44
  }
45
45
  },
46
46
  children: [
47
- item.icon ? /* @__PURE__ */ jsx(Icon, { icon: item.icon }) : item.glyph,
47
+ item.content !== void 0 ? /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: item.content }) : item.icon ? /* @__PURE__ */ jsx(Icon, { icon: item.icon }) : item.glyph,
48
48
  item.variants && item.variants.length > 1 && /* @__PURE__ */ jsx("span", { className: s.variantMark, "aria-hidden": "true" })
49
49
  ]
50
50
  }
@@ -0,0 +1,5 @@
1
+ /** Calendar fields, not an instant conversion; keep entered wall-clock values intact. */
2
+ export declare function calendarNow(timeZone?: string, instant?: Date): {
3
+ date: string;
4
+ time: string;
5
+ };
@@ -0,0 +1,27 @@
1
+ import { dateKey } from "./dateConstraints.js";
2
+ function calendarNow(timeZone, instant = /* @__PURE__ */ new Date()) {
3
+ if (!timeZone)
4
+ return {
5
+ date: dateKey(instant),
6
+ time: [instant.getHours(), instant.getMinutes(), instant.getSeconds()].map((part2) => String(part2).padStart(2, "0")).join(":")
7
+ };
8
+ const parts = new Intl.DateTimeFormat("en-CA", {
9
+ timeZone,
10
+ numberingSystem: "latn",
11
+ year: "numeric",
12
+ month: "2-digit",
13
+ day: "2-digit",
14
+ hour: "2-digit",
15
+ minute: "2-digit",
16
+ second: "2-digit",
17
+ hourCycle: "h23"
18
+ }).formatToParts(instant);
19
+ const part = (type) => parts.find((item) => item.type === type).value;
20
+ return {
21
+ date: part("year").padStart(4, "0") + "-" + part("month") + "-" + part("day"),
22
+ time: part("hour") + ":" + part("minute") + ":" + part("second")
23
+ };
24
+ }
25
+ export {
26
+ calendarNow
27
+ };
@@ -0,0 +1,12 @@
1
+ import type { ToastMessage, ToastOptions, ToastTone } from "./ToastProvider";
2
+ export type ToastNotify = (message: string, options?: ToastOptions | ToastTone) => () => void;
3
+ export interface ToastController {
4
+ readonly id: string;
5
+ notify: ToastNotify;
6
+ dismiss(id?: number): void;
7
+ clear(id?: number): void;
8
+ subscribe(listener: () => void): () => void;
9
+ getSnapshot(): ToastMessage | null;
10
+ }
11
+ /** An isolated notification service; no React or document access is required. */
12
+ export declare function createToaster(): ToastController;
@@ -0,0 +1,40 @@
1
+ let nextController = 0;
2
+ function createToaster() {
3
+ let current = null, counter = 0;
4
+ const listeners = /* @__PURE__ */ new Set();
5
+ const publish = (message) => {
6
+ if (current === message) return;
7
+ current = message;
8
+ listeners.forEach((listener) => listener());
9
+ };
10
+ const dismiss = (id = current?.id) => {
11
+ if (current && current.id === id && !current.closing)
12
+ publish({ ...current, closing: true });
13
+ };
14
+ return {
15
+ id: "oneui-toast-" + ++nextController,
16
+ notify(message, options) {
17
+ const id = ++counter;
18
+ publish({
19
+ message,
20
+ id,
21
+ options: typeof options === "string" ? { tone: options } : options
22
+ });
23
+ return () => dismiss(id);
24
+ },
25
+ dismiss,
26
+ clear(id = current?.id) {
27
+ if (current?.id === id) publish(null);
28
+ },
29
+ subscribe(listener) {
30
+ listeners.add(listener);
31
+ return () => {
32
+ listeners.delete(listener);
33
+ };
34
+ },
35
+ getSnapshot: () => current
36
+ };
37
+ }
38
+ export {
39
+ createToaster
40
+ };
@@ -47,7 +47,7 @@ export { CharacterPicker } from "./CharacterPicker";
47
47
  export { DataTable } from "./DataTable";
48
48
  export { EmojiPicker } from "./EmojiPicker";
49
49
  export { FileTree } from "./FileTree";
50
- export { IconPicker } from "./IconPicker";
50
+ export { IconPicker, type IconPickerProps, type IconPickerOption, } from "./IconPicker";
51
51
  export { LoadingOverlay } from "./LoadingOverlay";
52
52
  export { LoadingState } from "./LoadingState";
53
53
  export { Pagination } from "./Pagination";
@@ -143,10 +143,11 @@ export { ThemeProvider } from "./ThemeProvider";
143
143
  export type { UITheme, ColorMode } from "./ThemeProvider";
144
144
  export { ThemeStylePicker } from "./ThemeStylePicker";
145
145
  export { AppShell, ShellHeader } from "./AppShell";
146
- export type { NavigationItem } from "./AppShell";
146
+ export type { NavigationItem, AppShellProps } from "./AppShell";
147
147
  export { SettingsLayout, SettingsCard } from "./SettingsLayout";
148
148
  export { AppearanceSettings } from "./AppearanceSettings";
149
149
  export { appearanceDefaults, normalizeAppearance } from "./appearance";
150
150
  export type { AppearancePreferences } from "./appearance";
151
151
  export { MessageDialog, ConfirmDialog, FormDialog } from "./DialogVariants";
152
152
  export { TreeTable, type TreeTableProps } from "./TreeTable";
153
+ export { createToaster, type ToastController, type ToastNotify, } from "./createToaster";
@@ -43,7 +43,9 @@ import { CharacterPicker } from "./CharacterPicker.js";
43
43
  import { DataTable } from "./DataTable.js";
44
44
  import { EmojiPicker } from "./EmojiPicker.js";
45
45
  import { FileTree } from "./FileTree.js";
46
- import { IconPicker } from "./IconPicker.js";
46
+ import {
47
+ IconPicker
48
+ } from "./IconPicker.js";
47
49
  import { LoadingOverlay } from "./LoadingOverlay.js";
48
50
  import { LoadingState } from "./LoadingState.js";
49
51
  import { Pagination } from "./Pagination.js";
@@ -106,6 +108,9 @@ import { AppearanceSettings } from "./AppearanceSettings.js";
106
108
  import { appearanceDefaults, normalizeAppearance } from "./appearance.js";
107
109
  import { MessageDialog, ConfirmDialog, FormDialog } from "./DialogVariants.js";
108
110
  import { TreeTable } from "./TreeTable.js";
111
+ import {
112
+ createToaster
113
+ } from "./createToaster.js";
109
114
  export {
110
115
  Accordion,
111
116
  Alert,
@@ -216,6 +221,7 @@ export {
216
221
  UploadList,
217
222
  VirtualPickerList,
218
223
  appearanceDefaults,
224
+ createToaster,
219
225
  formatFileSize,
220
226
  normalizeAppearance,
221
227
  useFormState,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robert-stackflow/oneui",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "OneUI React components, application shell and portable visual themes",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -789,7 +789,12 @@
789
789
  "import": "./components/TreeTable.js",
790
790
  "default": "./components/TreeTable.js"
791
791
  },
792
- "./TreeTable.module.css": "./components/TreeTable.module.css"
792
+ "./TreeTable.module.css": "./components/TreeTable.module.css",
793
+ "./createToaster": {
794
+ "types": "./components/createToaster.d.ts",
795
+ "import": "./components/createToaster.js",
796
+ "default": "./components/createToaster.js"
797
+ }
793
798
  },
794
799
  "files": [
795
800
  "components",
@@ -799,8 +804,8 @@
799
804
  "CHANGELOG.md"
800
805
  ],
801
806
  "peerDependencies": {
802
- "react": "19.3.1",
803
- "react-dom": "19.3.1"
807
+ "react": "^19.2.0",
808
+ "react-dom": "^19.2.0"
804
809
  },
805
810
  "dependencies": {
806
811
  "lucide-react": "1.59.0"
@@ -68,6 +68,7 @@
68
68
  --hover: #303030;
69
69
  --selected: #3a3a3a;
70
70
  --navigation: #181819;
71
+ --navigation-selected-color: var(--fg);
71
72
  --canvas: #202021;
72
73
  --field: #252526;
73
74
  --disabled: #686868;