@ibobbyts/svelte-ui-utils 0.4.1 → 0.4.2

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/README.md CHANGED
@@ -845,3 +845,23 @@ bun add @ibobbyts/svelte-ui-utils@0.3.1 svelte
845
845
  ```
846
846
 
847
847
  Use it after each release when a Bun-based project will consume the package.
848
+
849
+ ## 分段日期输入(本地新增,尚未发布)
850
+
851
+ ```svelte
852
+ <script lang="ts">
853
+ import { SegmentedDateInput } from '@ibobbyts/svelte-ui-utils/segmented-date';
854
+ let dob = $state('');
855
+ </script>
856
+
857
+ <SegmentedDateInput name="dob" bind:value={dob} ariaLabel="生日"
858
+ yearLabel="年" monthLabel="月" dayLabel="日" autocomplete="bday" />
859
+ ```
860
+
861
+ 支持 `precision="year" | "month" | "day"`(默认 `day`)、数字输入、全角数字归一化、完整日期粘贴及月日失焦补零。`value` 可双向绑定;`onvalueinput` 接收用户输入后的组合值。日历有效性和业务范围由调用方校验。
862
+
863
+ 默认用隐藏字段提交组合值;`submitParts={true}` 改为提交 `${name}Year`、`${name}Month`、`${name}Day`(按精度决定字段)。支持 `required`、`readonly`、`disabled`、`id`(关联年输入框)和生日自动填充。`ariaLabel`、各段标签及占位符由调用方提供,以接入应用本地化。
864
+
865
+ 引入公共 `style.css` 后可独立使用,无需 Tailwind 或 DaisyUI。`class` 定制容器;`inputClass` 替换默认输入框装饰样式,布局仍由组件负责。MMS 通过该参数保留 DaisyUI 外观。
866
+
867
+ 纯函数和类型可从 `@ibobbyts/svelte-ui-utils/segmented-date/state` 引入:`splitSegmentedDate`、`composeSegmentedDate`、`parsePastedSegmentedDate`、`SegmentedDateParts`、`SegmentedDatePrecision`;此入口不加载 Svelte,适用于服务端和 Node。
@@ -563,10 +563,11 @@
563
563
  {#if group.label}
564
564
  <div class="suu-dropdown__group-label">{group.label}</div>
565
565
  {/if}
566
- {#each group.options as option}
566
+ {#each group.options as option, optionIndex}
567
567
  <button
568
568
  type="button"
569
569
  class="suu-dropdown__option"
570
+ class:suu-dropdown__option--group-first={Boolean(group.label) && optionIndex === 0}
570
571
  class:suu-dropdown__option--active={option.value === activeValue}
571
572
  class:suu-dropdown__option--disabled={option.disabled}
572
573
  role="option"
package/dist/index.d.ts CHANGED
@@ -9,4 +9,5 @@ export * from './ordered-list/index.js';
9
9
  export * from './sortable-table/index.js';
10
10
  export { getUiMessages, resolveUiLanguage, uiLanguages } from './i18n.js';
11
11
  export type { UiLanguage, UiMessages } from './i18n.js';
12
+ export * from './segmented-date/index.js';
12
13
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/lib/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,yBAAyB,CAAC;AACxC,cAAc,2BAA2B,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAC1E,YAAY,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/lib/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,yBAAyB,CAAC;AACxC,cAAc,2BAA2B,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAC1E,YAAY,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AACxD,cAAc,2BAA2B,CAAC"}
package/dist/index.js CHANGED
@@ -8,3 +8,4 @@ export * from './sortable-list/index.js';
8
8
  export * from './ordered-list/index.js';
9
9
  export * from './sortable-table/index.js';
10
10
  export { getUiMessages, resolveUiLanguage, uiLanguages } from './i18n.js';
11
+ export * from './segmented-date/index.js';
@@ -0,0 +1,193 @@
1
+ <script lang="ts">
2
+ import { untrack } from "svelte";
3
+ import {
4
+ composeSegmentedDate,
5
+ parsePastedSegmentedDate,
6
+ splitSegmentedDate,
7
+ type SegmentedDatePrecision,
8
+ } from "./state.js";
9
+
10
+ let {
11
+ value = $bindable(""),
12
+ precision = "day",
13
+ name,
14
+ id,
15
+ required = false,
16
+ readonly = false,
17
+ disabled = false,
18
+ ariaLabel,
19
+ yearLabel,
20
+ monthLabel,
21
+ dayLabel,
22
+ yearPlaceholder = "YYYY",
23
+ monthPlaceholder = "MM",
24
+ dayPlaceholder = "DD",
25
+ submitParts = false,
26
+ autocomplete,
27
+ class: className = "",
28
+ inputClass = "",
29
+ onvalueinput,
30
+ } = $props<{
31
+ value?: string;
32
+ precision?: SegmentedDatePrecision;
33
+ name: string;
34
+ id?: string;
35
+ required?: boolean;
36
+ readonly?: boolean;
37
+ disabled?: boolean;
38
+ ariaLabel: string;
39
+ yearLabel: string;
40
+ monthLabel?: string;
41
+ dayLabel?: string;
42
+ yearPlaceholder?: string;
43
+ monthPlaceholder?: string;
44
+ dayPlaceholder?: string;
45
+ submitParts?: boolean;
46
+ autocomplete?: "bday";
47
+ class?: string;
48
+ inputClass?: string;
49
+ onvalueinput?: (value: string) => void;
50
+ }>();
51
+
52
+ let year = $state("");
53
+ let month = $state("");
54
+ let day = $state("");
55
+
56
+ $effect(() => {
57
+ const parts = splitSegmentedDate(value, precision);
58
+ // Keep partially entered segments while the child is emitting an incomplete value.
59
+ const current = untrack(() => composeSegmentedDate({ year, month, day }, precision));
60
+ if (value !== current) {
61
+ year = parts.year;
62
+ month = parts.month;
63
+ day = parts.day;
64
+ }
65
+ });
66
+
67
+ function updateValue() {
68
+ const nextValue = composeSegmentedDate({ year, month, day }, precision);
69
+ if (nextValue === value) return;
70
+ value = nextValue;
71
+ onvalueinput?.(value);
72
+ }
73
+
74
+ function handleInput(part: SegmentedDatePrecision, event: Event) {
75
+ const input = event.currentTarget as HTMLInputElement;
76
+ const normalized = input.value.normalize("NFKC").replace(/\D/g, "")
77
+ .slice(0, part === "year" ? 4 : 2);
78
+ input.value = normalized;
79
+ if (part === "year") year = normalized;
80
+ if (part === "month") month = normalized;
81
+ if (part === "day") day = normalized;
82
+ updateValue();
83
+ }
84
+
85
+ function handleBlur(part: "month" | "day") {
86
+ if (part === "month" && /^\d$/.test(month)) month = month.padStart(2, "0");
87
+ if (part === "day" && /^\d$/.test(day)) day = day.padStart(2, "0");
88
+ updateValue();
89
+ }
90
+
91
+ function handlePaste(event: ClipboardEvent) {
92
+ const parts = parsePastedSegmentedDate(
93
+ event.clipboardData?.getData("text") ?? "",
94
+ precision,
95
+ );
96
+ if (!parts) return;
97
+ event.preventDefault();
98
+ year = parts.year;
99
+ month = parts.month;
100
+ day = parts.day;
101
+ updateValue();
102
+ }
103
+ </script>
104
+
105
+ {#if !submitParts}
106
+ <input type="hidden" {name} {value} {disabled} />
107
+ {/if}
108
+
109
+ <div
110
+ class={["suu-segmented-date", className]}
111
+ data-precision={precision}
112
+ role="group"
113
+ aria-label={ariaLabel}
114
+ >
115
+ <input
116
+ class={["suu-segmented-date__input", inputClass]}
117
+ class:suu-segmented-date__input--default={!inputClass}
118
+ type="text"
119
+ name={submitParts ? `${name}Year` : undefined}
120
+ {id}
121
+ bind:value={year}
122
+ oninput={(event) => handleInput("year", event)}
123
+ onpaste={handlePaste}
124
+ inputmode="numeric"
125
+ pattern="[0-9][0-9][0-9][0-9]"
126
+ maxlength="4"
127
+ placeholder={yearPlaceholder}
128
+ aria-label={yearLabel}
129
+ autocomplete={autocomplete ? "bday-year" : undefined}
130
+ {required}
131
+ {readonly}
132
+ {disabled}
133
+ />
134
+ {#if precision !== "year"}
135
+ <input
136
+ class={["suu-segmented-date__input", inputClass]}
137
+ class:suu-segmented-date__input--default={!inputClass}
138
+ type="text"
139
+ name={submitParts ? `${name}Month` : undefined}
140
+ bind:value={month}
141
+ oninput={(event) => handleInput("month", event)}
142
+ onblur={() => handleBlur("month")}
143
+ onpaste={handlePaste}
144
+ inputmode="numeric"
145
+ pattern="[0-9][0-9]?"
146
+ maxlength="2"
147
+ placeholder={monthPlaceholder}
148
+ aria-label={monthLabel}
149
+ autocomplete={autocomplete ? "bday-month" : undefined}
150
+ {required}
151
+ {readonly}
152
+ {disabled}
153
+ />
154
+ {/if}
155
+ {#if precision === "day"}
156
+ <input
157
+ class={["suu-segmented-date__input", inputClass]}
158
+ class:suu-segmented-date__input--default={!inputClass}
159
+ type="text"
160
+ name={submitParts ? `${name}Day` : undefined}
161
+ bind:value={day}
162
+ oninput={(event) => handleInput("day", event)}
163
+ onblur={() => handleBlur("day")}
164
+ onpaste={handlePaste}
165
+ inputmode="numeric"
166
+ pattern="[0-9][0-9]?"
167
+ maxlength="2"
168
+ placeholder={dayPlaceholder}
169
+ aria-label={dayLabel}
170
+ autocomplete={autocomplete ? "bday-day" : undefined}
171
+ {required}
172
+ {readonly}
173
+ {disabled}
174
+ />
175
+ {/if}
176
+ </div>
177
+
178
+ <style>
179
+ .suu-segmented-date { display: grid; min-width: 0; gap: 0.5rem; }
180
+ .suu-segmented-date[data-precision="year"] { grid-template-columns: minmax(0, 1fr); }
181
+ .suu-segmented-date[data-precision="month"] { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
182
+ .suu-segmented-date[data-precision="day"] { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr); }
183
+ .suu-segmented-date__input { min-width: 0; width: 100%; box-sizing: border-box; }
184
+ .suu-segmented-date__input--default {
185
+ padding: 0.5rem 0.75rem;
186
+ border: 1px solid var(--suu-color-border);
187
+ border-radius: var(--suu-radius);
188
+ background: var(--suu-color-bg);
189
+ color: var(--suu-color-text);
190
+ font: inherit;
191
+ }
192
+ .suu-segmented-date__input--default:focus-visible { outline: 2px solid var(--suu-color-accent); outline-offset: 2px; }
193
+ </style>
@@ -0,0 +1,26 @@
1
+ import { type SegmentedDatePrecision } from "./state.js";
2
+ type $$ComponentProps = {
3
+ value?: string;
4
+ precision?: SegmentedDatePrecision;
5
+ name: string;
6
+ id?: string;
7
+ required?: boolean;
8
+ readonly?: boolean;
9
+ disabled?: boolean;
10
+ ariaLabel: string;
11
+ yearLabel: string;
12
+ monthLabel?: string;
13
+ dayLabel?: string;
14
+ yearPlaceholder?: string;
15
+ monthPlaceholder?: string;
16
+ dayPlaceholder?: string;
17
+ submitParts?: boolean;
18
+ autocomplete?: "bday";
19
+ class?: string;
20
+ inputClass?: string;
21
+ onvalueinput?: (value: string) => void;
22
+ };
23
+ declare const SegmentedDateInput: import("svelte").Component<$$ComponentProps, {}, "value">;
24
+ type SegmentedDateInput = ReturnType<typeof SegmentedDateInput>;
25
+ export default SegmentedDateInput;
26
+ //# sourceMappingURL=SegmentedDateInput.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SegmentedDateInput.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/segmented-date/SegmentedDateInput.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,EAIC,KAAK,sBAAsB,EAC9B,MAAM,YAAY,CAAC;AAEvB,KAAK,gBAAgB,GAAG;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,sBAAsB,CAAC;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CAC1C,CAAC;AAkGN,QAAA,MAAM,kBAAkB,2DAAwC,CAAC;AACjE,KAAK,kBAAkB,GAAG,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAChE,eAAe,kBAAkB,CAAC"}
@@ -0,0 +1,3 @@
1
+ export { default as SegmentedDateInput } from './SegmentedDateInput.svelte';
2
+ export * from './state.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/segmented-date/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,kBAAkB,EAAE,MAAM,6BAA6B,CAAC;AAC5E,cAAc,YAAY,CAAC"}
@@ -0,0 +1,2 @@
1
+ export { default as SegmentedDateInput } from './SegmentedDateInput.svelte';
2
+ export * from './state.js';
@@ -0,0 +1,10 @@
1
+ export type SegmentedDatePrecision = "year" | "month" | "day";
2
+ export type SegmentedDateParts = {
3
+ year: string;
4
+ month: string;
5
+ day: string;
6
+ };
7
+ export declare function splitSegmentedDate(value: string, precision: SegmentedDatePrecision): SegmentedDateParts;
8
+ export declare function composeSegmentedDate(parts: SegmentedDateParts, precision: SegmentedDatePrecision): string;
9
+ export declare function parsePastedSegmentedDate(value: string, precision: SegmentedDatePrecision): SegmentedDateParts | null;
10
+ //# sourceMappingURL=state.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../src/lib/segmented-date/state.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,sBAAsB,GAAG,MAAM,GAAG,OAAO,GAAG,KAAK,CAAC;AAE9D,MAAM,MAAM,kBAAkB,GAAG;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;CACf,CAAC;AAQF,wBAAgB,kBAAkB,CAC9B,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,sBAAsB,GAClC,kBAAkB,CAuBpB;AAED,wBAAgB,oBAAoB,CAChC,KAAK,EAAE,kBAAkB,EACzB,SAAS,EAAE,sBAAsB,GAClC,MAAM,CAqBR;AAED,wBAAgB,wBAAwB,CACpC,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,sBAAsB,GAClC,kBAAkB,GAAG,IAAI,CAe3B"}
@@ -0,0 +1,67 @@
1
+ const EMPTY_PARTS = { year: "", month: "", day: "" };
2
+ function normalizePart(value) {
3
+ return value.normalize("NFKC").trim();
4
+ }
5
+ export function splitSegmentedDate(value, precision) {
6
+ const normalized = value.normalize("NFKC").trim();
7
+ const patterns = {
8
+ year: /^([^-]*)$/,
9
+ month: /^([^-]*)-([^-]*)$/,
10
+ day: /^([^-]*)-([^-]*)-([^-]*)$/,
11
+ };
12
+ const match = normalized.match(patterns[precision]);
13
+ if (!match) {
14
+ if (precision === "month" && /^\d{4}$/.test(normalized)) {
15
+ return { year: normalized, month: "", day: "" };
16
+ }
17
+ if (precision === "month" && /^\d{1,2}$/.test(normalized)) {
18
+ return { year: "", month: normalized, day: "" };
19
+ }
20
+ return { ...EMPTY_PARTS };
21
+ }
22
+ return {
23
+ year: match[1] ?? "",
24
+ month: precision === "year" ? "" : (match[2] ?? ""),
25
+ day: precision === "day" ? (match[3] ?? "") : "",
26
+ };
27
+ }
28
+ export function composeSegmentedDate(parts, precision) {
29
+ const year = normalizePart(parts.year);
30
+ const month = normalizePart(parts.month);
31
+ const day = normalizePart(parts.day);
32
+ if (!year && (precision === "year" || !month) && (precision !== "day" || !day)) {
33
+ return "";
34
+ }
35
+ if (precision === "year")
36
+ return year;
37
+ if (precision === "month") {
38
+ if (/^\d{4}$/.test(year) && /^\d{1,2}$/.test(month)) {
39
+ return `${year}-${month.padStart(2, "0")}`;
40
+ }
41
+ if (/^\d{4}$/.test(year) && !month)
42
+ return year;
43
+ if (!year && /^\d{1,2}$/.test(month))
44
+ return month.padStart(2, "0");
45
+ return `${year}-${month}`;
46
+ }
47
+ if (/^\d{4}$/.test(year) && /^\d{1,2}$/.test(month) && /^\d{1,2}$/.test(day)) {
48
+ return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
49
+ }
50
+ return `${year}-${month}-${day}`;
51
+ }
52
+ export function parsePastedSegmentedDate(value, precision) {
53
+ const normalized = value.normalize("NFKC").trim();
54
+ const patterns = {
55
+ year: /^(\d{4})$/,
56
+ month: /^(?:(\d{4})[-/.](\d{1,2})|(\d{1,2}))$/,
57
+ day: /^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/,
58
+ };
59
+ const match = normalized.match(patterns[precision]);
60
+ if (!match)
61
+ return null;
62
+ return {
63
+ year: match[1] ?? "",
64
+ month: precision === "year" ? "" : (match[2] ?? match[3]).padStart(2, "0"),
65
+ day: precision === "day" ? match[3].padStart(2, "0") : "",
66
+ };
67
+ }
package/dist/style.css CHANGED
@@ -1283,6 +1283,7 @@
1283
1283
  .suu-dropdown__panel {
1284
1284
  max-height: var(--suu-dropdown-panel-max-height, 100vh);
1285
1285
  overflow-y: auto;
1286
+ overflow-x: hidden;
1286
1287
  background: var(--suu-color-bg);
1287
1288
  overscroll-behavior: contain;
1288
1289
  }
@@ -1313,6 +1314,10 @@
1313
1314
  text-align: left;
1314
1315
  }
1315
1316
 
1317
+ .suu-dropdown__option--group-first {
1318
+ border-top: 1px solid var(--suu-color-border);
1319
+ }
1320
+
1316
1321
  .suu-dropdown--multiselect .suu-dropdown__option {
1317
1322
  display: flex;
1318
1323
  gap: 10px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ibobbyts/svelte-ui-utils",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Reusable Svelte 5 UI utilities for dialogs, toast notifications, dropdown search, and data tables.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -77,7 +77,16 @@
77
77
  "svelte": "./dist/sortable-table/index.js",
78
78
  "default": "./dist/sortable-table/index.js"
79
79
  },
80
- "./style.css": "./dist/style.css"
80
+ "./style.css": "./dist/style.css",
81
+ "./segmented-date": {
82
+ "types": "./dist/segmented-date/index.d.ts",
83
+ "svelte": "./dist/segmented-date/index.js",
84
+ "default": "./dist/segmented-date/index.js"
85
+ },
86
+ "./segmented-date/state": {
87
+ "types": "./dist/segmented-date/state.d.ts",
88
+ "default": "./dist/segmented-date/state.js"
89
+ }
81
90
  },
82
91
  "publishConfig": {
83
92
  "access": "public",