poe-svelte-ui-lib 1.29.0 → 1.30.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.
@@ -17,10 +17,17 @@
17
17
  }: IFileAttachProps = $props()
18
18
 
19
19
  let ID = $derived(`${id}-${crypto.randomUUID().slice(0, 6)}`)
20
- let selectedFile = $state<File | null>(null)
21
20
  let previewUrl = $derived(currentImage ? (currentImage.startsWith("data:") ? currentImage : `data:image/png;base64,${currentImage}`) : null)
22
21
  let fileName = $state("")
23
22
 
23
+ /* Blob-URL для мгновенного превью выбранного файла до того, как onChange/родитель
24
+ подтвердит загрузку и обновит currentImage. Предыдущий blob обязательно отзывается
25
+ перед созданием нового и при размонтировании — иначе течёт память при частой смене файла. */
26
+ let objectUrl: string | null = null
27
+ $effect(() => () => {
28
+ if (objectUrl) URL.revokeObjectURL(objectUrl)
29
+ })
30
+
24
31
  const handleFileChange = (event: Event) => {
25
32
  const input = event.target as HTMLInputElement
26
33
  if (!input.files || input.files.length === 0) {
@@ -29,10 +36,13 @@
29
36
  }
30
37
 
31
38
  const file = input.files[0]
32
- selectedFile = file
33
39
  fileName = file.name
34
40
 
35
- if (file.type.startsWith("image/")) previewUrl = URL.createObjectURL(file)
41
+ if (file.type.startsWith("image/")) {
42
+ if (objectUrl) URL.revokeObjectURL(objectUrl)
43
+ objectUrl = URL.createObjectURL(file)
44
+ previewUrl = objectUrl
45
+ }
36
46
 
37
47
  onChange(event, file)
38
48
  }
@@ -64,7 +74,7 @@
64
74
  >
65
75
  {#if previewUrl || currentImage}
66
76
  <img
67
- src={previewUrl ?? (currentImage?.startsWith("data:") ? currentImage : `data:image/png;base64,${currentImage}`)}
77
+ src={previewUrl}
68
78
  alt="Preview"
69
79
  class={`
70
80
  h-full w-full
@@ -97,15 +97,14 @@
97
97
 
98
98
  ### Реактивность
99
99
 
100
- - Переменная `selectedFile` хранит выбранный файл
101
- - Переменная `previewUrl` вычисляет URL для предварительного просмотра изображения
100
+ - Переменная `previewUrl` вычисляет URL для предварительного просмотра изображения (из `currentImage` или из только что выбранного файла)
102
101
  - Переменная `fileName` хранит имя выбранного файла
103
102
  - Объект `ID` генерирует уникальный идентификатор для элемента ввода
104
103
 
105
104
  ### Сторы и зависимости
106
105
 
107
106
  - Используется стор `twMerge` для объединения Tailwind CSS классов
108
- - Используется `URL.createObjectURL` для создания URL предварительного просмотра изображения
107
+ - Используется `URL.createObjectURL` для мгновенного превью выбранного файла — предыдущий blob-URL отзывается через `URL.revokeObjectURL` перед созданием нового и при размонтировании компонента
109
108
 
110
109
  ### Директивы
111
110
 
@@ -93,8 +93,9 @@
93
93
  wrapperClass="min-w-28 flex-1"
94
94
  value={progress.name}
95
95
  onUpdate={(value) => {
96
- const progresses = [...(component.properties?.items || [])]
97
- progresses[index]["name"] = value
96
+ const progresses = (component.properties?.items || []).map((p: { name?: string; class?: string }, i: number) =>
97
+ i === index ? { ...p, name: value } : p,
98
+ )
98
99
  updateProperty("items", progresses, component, onPropertyChange)
99
100
  }}
100
101
  />
@@ -106,8 +107,9 @@
106
107
  options={$optionsStore.COLOR_OPTIONS.filter((option) => option.value !== "bg-max")}
107
108
  value={findColorOption($optionsStore.COLOR_OPTIONS, progress.class)}
108
109
  onUpdate={(value) => {
109
- const progresses = [...(component.properties?.items || [])]
110
- progresses[index]["class"] = (value as UI.IOption).value
110
+ const progresses = (component.properties?.items || []).map((p: { name?: string; class?: string }, i: number) =>
111
+ i === index ? { ...p, class: (value as UI.IOption).value } : p,
112
+ )
111
113
  updateProperty("items", progresses, component, onPropertyChange)
112
114
  }}
113
115
  />
@@ -118,16 +120,13 @@
118
120
  value={(component.properties.value || [])[index]?.Value}
119
121
  type="number"
120
122
  onUpdate={(value) => {
121
- const progresses = [
122
- ...(component.properties?.value ||
123
- component.properties?.items.map((item: { name?: string; class?: string }, i: number) => {
124
- return {
125
- Name: item.name,
126
- Value: i == index ? value : 0,
127
- }
128
- })),
129
- ]
130
- progresses[index].Value = value
123
+ const base =
124
+ component.properties?.value ||
125
+ component.properties?.items.map((item: { name?: string; class?: string }, i: number) => ({
126
+ Name: item.name,
127
+ Value: i == index ? value : 0,
128
+ }))
129
+ const progresses = base.map((p: { Name?: string; Value?: number }, i: number) => (i === index ? { ...p, Value: value } : p))
131
130
  updateProperty("value", progresses, component, onPropertyChange)
132
131
  }}
133
132
  />
@@ -36,7 +36,7 @@
36
36
  <script>
37
37
  import * as UI from "poe-svelte-ui-lib"
38
38
 
39
- let progressBarValue = 40
39
+ let progressBarValue = [{ Value: 40 }]
40
40
  </script>
41
41
 
42
42
  <UI.ProgressBar value={progressBarValue} wrapperClass="bg-red" />
@@ -48,7 +48,7 @@
48
48
  <script>
49
49
  import * as UI from "poe-svelte-ui-lib"
50
50
 
51
- let progressBarValue = 65
51
+ let progressBarValue = [{ Value: 65 }]
52
52
  </script>
53
53
 
54
54
  <UI.ProgressBar value={progressBarValue} type="vertical" wrapperClass="bg-red h-50" />
@@ -18,7 +18,7 @@
18
18
  | `label.captionLeft` | `string` | `""` | Левая подпись для горизонтального типа (кликабельна, устанавливает значение 0 без `bitMode`) |
19
19
  | `label.captionRight` | `string` | `""` | Правая подпись для горизонтального типа (кликабельна, устанавливает значение 1 без `bitMode`) |
20
20
  | `hiddenInfo` | `string` | `""` | Текст всплывающей подсказки для режима `checkbox`: отображается при наведении на чекбокс |
21
- | `options` | `IOption<number>[]` | `[]` | Массив опций для переключателя |
21
+ | `options` | `IOption<number>[]` | `[]` | Массив опций для переключателя; для `type="checkbox"` можно не передавать (используется как простой булев тумблер) |
22
22
  | `bitMode` | `boolean` | `false` | Режим битовых флагов: при `true` значение интерпретируется как битовая маска, каждая опция управляет отдельным битом через побитовые операции; при `false` — простой переключатель 0/1 |
23
23
  | `type` | `"horizontal" \| "vertical" \| "checkbox"` | `"horizontal"` | Тип отображения: `horizontal` — горизонтальный слайдер, `vertical` — вертикальный слайдер, `checkbox` — стандартный чекбокс с галочкой и поддержкой `hiddenInfo` |
24
24
  | `value` | `number` | `undefined` | Текущее значение переключателя: в обычном режиме — 0 или 1, в `bitMode` — битовая маска; поддерживает двустороннее связывание (`$bindable`) |
@@ -39,13 +39,13 @@
39
39
  })
40
40
 
41
41
  const handleToggle = (index: number) => {
42
- if (localOptions[index].disabled) return
43
- value = ((value ?? 0) ^ (1 << (bitMode ? (localOptions[index].value ?? 0) : 0))) >>> 0
42
+ if (localOptions[index]?.disabled) return
43
+ value = ((value ?? 0) ^ (1 << (bitMode ? (localOptions[index]?.value ?? 0) : 0))) >>> 0
44
44
  onChange(value)
45
45
  }
46
46
 
47
47
  const handleCaptionClick = (newValue: number) => {
48
- if (localOptions[0].disabled || value === newValue) return
48
+ if (localOptions[0]?.disabled || value === newValue) return
49
49
  value = newValue
50
50
  onChange(newValue)
51
51
  }
@@ -134,14 +134,14 @@
134
134
  class={twMerge(
135
135
  "relative flex items-center justify-center gap-2",
136
136
  wrapperClass,
137
- options[0].class?.split(" ").find((cl) => cl.startsWith("bg-")),
137
+ options[0]?.class?.split(" ").find((cl) => cl.startsWith("bg-")),
138
138
  )}
139
139
  >
140
140
  <input
141
141
  id={ID}
142
142
  type="checkbox"
143
143
  checked={checkedOptions[0]}
144
- disabled={localOptions[0].disabled ?? false}
144
+ disabled={localOptions[0]?.disabled ?? false}
145
145
  class="
146
146
  relative size-5 shrink-0 cursor-pointer appearance-none rounded-[6px] border border-(--border-color)
147
147
  bg-(--field-color) transition-colors duration-150 after:origin-center after:opacity-0
@@ -164,7 +164,7 @@
164
164
  />
165
165
  <Tooltip show={showInfo} text={hiddenInfo} side="top" />
166
166
  {#if label.name}
167
- <label for={ID} class={twMerge(`text-[13px] select-none ${options[0].disabled ? "text-(--faint-color)" : "cursor-pointer"}`, label.class)}>
167
+ <label for={ID} class={twMerge(`text-[13px] select-none ${options[0]?.disabled ? "text-(--faint-color)" : "cursor-pointer"}`, label.class)}>
168
168
  {label.name}
169
169
  </label>
170
170
  {/if}
@@ -21,7 +21,7 @@
21
21
  }>()
22
22
  const DeviceVariables = getContext<{ id: string; value: string; name: string }[]>("DeviceVariables")
23
23
  let VARIABLE_OPTIONS = $derived(DeviceVariables && Array.isArray(DeviceVariables) ? DeviceVariables : [])
24
- let initialColor = $derived(findColorOption($optionsStore.COLOR_OPTIONS, component.properties.options[0].class))
24
+ let initialColor = $derived(findColorOption($optionsStore.COLOR_OPTIONS, component.properties.options?.[0]?.class))
25
25
  const initialAlign = $derived(
26
26
  $optionsStore.TEXT_ALIGN_OPTIONS.find((a) =>
27
27
  (a.value as string).includes(component.properties.label?.class?.split(" ").find((cls: string) => cls.startsWith("text-"))),
@@ -156,15 +156,17 @@
156
156
  }}
157
157
  />
158
158
  </div>
159
- <UI.Button
160
- wrapperClass="w-8 shrink-0"
161
- content={{ icon: ButtonDelete }}
162
- onClick={() => {
163
- const options = [...(component.properties?.options || [])]
164
- options.splice(index, 1)
165
- updateProperty("options", options, component, onPropertyChange)
166
- }}
167
- />
159
+ {#if (component.properties.options?.length ?? 0) > 1}
160
+ <UI.Button
161
+ wrapperClass="w-8 shrink-0"
162
+ content={{ icon: ButtonDelete }}
163
+ onClick={() => {
164
+ const options = [...(component.properties?.options || [])]
165
+ options.splice(index, 1)
166
+ updateProperty("options", options, component, onPropertyChange)
167
+ }}
168
+ />
169
+ {/if}
168
170
  </div>
169
171
  {/each}
170
172
  </PropsGroup>
@@ -127,51 +127,31 @@
127
127
  }
128
128
 
129
129
  const columns = [
130
- { label: { name: "ID" }, key: "id", width: "5%", sortable: true, align: "center" },
130
+ { label: { name: "ID" }, width: "5%", align: "center", content: [{ type: "text", data: { key: "id", sortable: true } }] },
131
131
  {
132
132
  label: { name: "Image" },
133
- key: "imageUrl",
134
133
  width: "20%",
135
- image: {
136
- src: (row: ITableRow) => row.imageUrl,
137
- alt: "Image",
138
- width: "5rem",
139
- height: "5rem",
140
- },
134
+ content: [{ type: "image", data: { src: (row: ITableRow) => row.imageUrl, alt: "Image", width: "5rem", height: "5rem" } }],
141
135
  },
142
- { label: { name: "Name" }, key: "name", width: "12%", sortable: true },
136
+ { label: { name: "Name" }, width: "12%", content: [{ type: "text", data: { key: "name", sortable: true } }] },
143
137
  {
144
138
  label: { name: "Status" },
145
- key: "status",
146
139
  width: "10%",
147
- overflow: {
148
- truncated: true,
149
- formatting: (text) => {
150
- if (text === "online") {
151
- return "1"
152
- } else return "2"
153
- },
154
- },
140
+ content: [{ type: "text", data: { key: "status", truncated: true, formatting: (text) => (text === "online" ? "🟢" : "⚪") } }],
155
141
  },
156
142
  {
157
143
  label: { name: "Last Active" },
158
- key: "lastActive",
159
144
  width: "auto",
160
- sortable: true,
161
- overflow: { truncated: true, copy: true, modal: true },
145
+ content: [{ type: "text", data: { key: "lastActive", sortable: true, truncated: true, copy: true, modal: true } }],
162
146
  },
163
147
  {
164
148
  label: { name: "Actions" },
165
- key: "action",
166
149
  width: "15%",
167
150
  align: "center",
168
- action: {
169
- type: "buttons",
170
- buttons: [
171
- { name: "Action 1 (id)", class: "bg-blue", eventHandler: { Header: "SET", Argument: "save", Variables: ["name"] } },
172
- { name: "Action 2 (name)", class: "bg-green", onClick: (row) => console.log(row.name) },
173
- ],
174
- },
151
+ content: [
152
+ { type: "button", data: { name: "Action 1", class: "bg-blue", eventHandler: { Header: "SET", Argument: "save", Variables: ["name"] } } },
153
+ { type: "button", data: { name: "Action 2", class: "bg-green", onClick: (row) => console.log(row.name) } },
154
+ ],
175
155
  },
176
156
  ]
177
157
 
@@ -30,6 +30,8 @@
30
30
  let isAutoscroll = $state(true)
31
31
  let container: HTMLElement | null = $state(null)
32
32
  let buffer: any[] = $state([])
33
+ /* body типизирован как T[] | T | null — нормализуем в массив один раз для рендера */
34
+ let bodyArray = $derived(body == null ? [] : Array.isArray(body) ? body : [body])
33
35
 
34
36
  let isDropdownOpen: { x: number; y: number; index: number } | null = $state(null)
35
37
  let selectSlideDuration: number = $state(250)
@@ -290,8 +292,8 @@
290
292
  </button>
291
293
  {/if}
292
294
 
293
- {#if body || buffer}
294
- {@const rows = dataBuffer.stashData ? buffer.slice(-(dataBuffer.bufferSize ?? 10)) : body.filter((row: any) => Object.entries(row).length != 0)}
295
+ {#if bodyArray.length > 0 || buffer.length > 0}
296
+ {@const rows = dataBuffer.stashData ? buffer.slice(-(dataBuffer.bufferSize ?? 10)) : bodyArray.filter((row: any) => Object.entries(row).length != 0)}
295
297
  <!-- Table Body с прокруткой -->
296
298
  <div class="relative flex-1 overflow-y-auto bg-(--back-color)" bind:this={container} onscroll={handleScroll}>
297
299
  <div class="min-w-0" style={`height: ${dataBuffer.visibleRows && tableHeight && rows.length > dataBuffer.visibleRows ? `${tableHeight}px` : ""};`}>
@@ -6,24 +6,24 @@
6
6
 
7
7
  ## Пропсы
8
8
 
9
- | Название | Тип | По умолчанию | Описание |
10
- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
11
- | `id` | `string` | `crypto.randomUUID()` | Уникальный идентификатор компонента |
12
- | `wrapperClass` | `string` | `"bg-blue"` | Дополнительные CSS-классы для контейнера вкладок: влияет на фон и стилизацию активной вкладки (автоматически подбирается контрастный цвет текста) |
13
- | `size` | `object` | `-` | Размеры сетки содержимого активной вкладки |
14
- | `size.width` | `number` | `12` | Количество колонок в сетке содержимого |
15
- | `size.height` | `number` | `6` | Количество строк в сетке содержимого |
16
- | `activeTab` | `number` | `0` | Индекс активной вкладки: изменение значения переключает вкладку программно |
17
- | `items` | `{ name?: string; icon?: ConstructorOfATypedSvelteComponent \| string; class?: string; children?: Snippet; onClick?: () => {}; disabled?: boolean }[]` | `[{ name: "tab 1", icon: "", class: "" }, { name: "tab 2", icon: "", class: "" }]` | Массив элементов вкладок |
18
- | `items[].name` | `string` | `undefined` | Название (заголовок) вкладки |
19
- | `items[].icon` | `ConstructorOfATypedSvelteComponent \| string` | `undefined` | Иконка вкладки (SVG строка или Svelte-компонент) |
20
- | `items[].class` | `string` | `undefined` | CSS-классы для стилизации вкладки; поддерживает `flex-col` для вертикальной компоновки |
21
- | `items[].children` | `Snippet` | `undefined` | Слот для содержимого конкретной вкладки: используется, если не переданы общий `children` или `Components` |
22
- | `items[].onClick` | `() => {}` | `undefined` | Обработчик клика по вкладке |
23
- | `items[].disabled` | `boolean` | `undefined` | Состояние отключения вкладки |
24
- | `children` | `Snippet<[any]>` | `undefined` | Слот для динамического рендеринга контента активной вкладки; принимает `items[currentTabIndex]` |
25
- | `apiArray` | `UIComponent[]` | `[]` | Массив данных компонентов для отображения через `Components` |
26
- | `Components` | `Snippet<[component: UIComponent, fixedHeight: boolean]>` | `undefined` | Сниппет для отображения динамических компонентов из `apiArray` |
9
+ | Название | Тип | По умолчанию | Описание |
10
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
+ | `id` | `string` | `crypto.randomUUID()` | Уникальный идентификатор компонента |
12
+ | `wrapperClass` | `string` | `""` | Дополнительные CSS-классы для внутренней обёртки полосы вкладок (например `justify-*`) |
13
+ | `size` | `object` | `-` | Размеры сетки содержимого активной вкладки |
14
+ | `size.width` | `number` | `12` | Количество колонок в сетке содержимого |
15
+ | `size.height` | `number` | `6` | Количество строк в сетке содержимого |
16
+ | `activeTab` | `number` | `0` | Индекс активной вкладки: изменение значения переключает вкладку программно |
17
+ | `items` | `{ name?: string; icon?: ConstructorOfATypedSvelteComponent \| string; class?: string; children?: Snippet; onClick?: () => {}; disabled?: boolean }[]` | `[{ name: "tab 1", icon: "", class: "" }, { name: "tab 2", icon: "", class: "" }]` | Массив элементов вкладок |
18
+ | `items[].name` | `string` | `undefined` | Название (заголовок) вкладки |
19
+ | `items[].icon` | `ConstructorOfATypedSvelteComponent \| string` | `undefined` | Иконка вкладки (SVG строка или Svelte-компонент) |
20
+ | `items[].class` | `string` | `undefined` | CSS-классы для стилизации вкладки; `flex-row`/`flex-col`/`flex-row-reverse`/`flex-col-reverse` — позиция иконки относительно текста; `flex-1` — вкладка растягивается на равную долю ширины (учитывает отступы между кнопками), `w-auto` — ширина по контенту |
21
+ | `items[].children` | `Snippet` | `undefined` | Слот для содержимого конкретной вкладки: используется, если не переданы общий `children` или `Components` |
22
+ | `items[].onClick` | `() => {}` | `undefined` | Обработчик клика по вкладке |
23
+ | `items[].disabled` | `boolean` | `undefined` | Состояние отключения вкладки |
24
+ | `children` | `Snippet<[any]>` | `undefined` | Слот для динамического рендеринга контента активной вкладки; принимает `items[currentTabIndex]` |
25
+ | `apiArray` | `UIComponent[]` | `[]` | Массив данных компонентов для отображения через `Components` |
26
+ | `Components` | `Snippet<[component: UIComponent, fixedHeight: boolean]>` | `undefined` | Сниппет для отображения динамических компонентов из `apiArray` |
27
27
 
28
28
  ## События
29
29
 
@@ -78,7 +78,7 @@
78
78
  {/snippet}
79
79
  ```
80
80
 
81
- ### Вкладки с настраиваемыми классами
81
+ ### Вкладки равной ширины
82
82
 
83
83
  ```svelte
84
84
  <script>
@@ -87,10 +87,10 @@
87
87
 
88
88
  <UI.Tabs
89
89
  items={[
90
- { name: "Tab1", class: "w-1/4" },
91
- { name: "Tab2", class: "w-1/4" },
92
- { name: "Tab3", class: "w-1/4" },
93
- { name: "Tab4", class: "w-1/4" },
90
+ { name: "Tab1", class: "flex-1" },
91
+ { name: "Tab2", class: "flex-1" },
92
+ { name: "Tab3", class: "flex-1" },
93
+ { name: "Tab4", class: "flex-1" },
94
94
  ]}
95
95
  children={Tab}
96
96
  size={{ height: 1, width: 1 }}
@@ -52,11 +52,6 @@
52
52
  item.class,
53
53
  )}
54
54
  disabled={item.disabled}
55
- style="width: {item.class
56
- ?.split(' ')
57
- .find((cls: string) => cls.startsWith('w-'))
58
- ?.replace('w-[', '')
59
- .slice(0, -1)};"
60
55
  onclick={() => {
61
56
  currentTabIndex = index
62
57
  if (item.onClick) item.onClick()
@@ -93,7 +88,7 @@
93
88
  style="grid-template-columns: repeat({size.width || 1}, minmax(0, 1fr)); grid-template-rows: repeat({size.height || 1}, auto);"
94
89
  >
95
90
  {#if Components}
96
- {#each (apiArray ?? []).filter((c) => c.id.endsWith(`${currentTabIndex}`)) as comp}
91
+ {#each (apiArray ?? []).filter((c) => c.id.match(/(\d+)$/)?.[1] === `${currentTabIndex}`) as comp}
97
92
  {@render Components(comp, false)}
98
93
  {/each}
99
94
  {:else if children}
@@ -25,24 +25,23 @@
25
25
 
26
26
  const initialColor = $derived(findColorOption($optionsStore.COLOR_OPTIONS, component.properties.wrapperClass))
27
27
 
28
- const initialPosition = $derived(
29
- $optionsStore.ICON_TEXT_POSITION_OPTIONS.find((c) =>
30
- (c.value as string).includes(
31
- component.properties.items
32
- .find((item: IOption) => item.class?.includes("flex-"))
33
- .class.split(" ")
34
- .find((cls: string) => cls.startsWith("flex-")),
35
- ),
36
- ),
37
- )
38
-
39
- const initialWidth = $derived(() => {
40
- let width = component.properties.items.find((item: IOption) => item.class?.split(" ").find((cls: string) => cls.startsWith("w-")))
41
- if (!width) {
42
- return 0
43
- } else if (width.class.includes("w-auto")) return 0
44
- else return 1
28
+ const initialPosition = $derived.by(() => {
29
+ const flexClass = component.properties.items
30
+ ?.find((item: IOption) => item.class?.includes("flex-"))
31
+ ?.class?.split(" ")
32
+ .find((cls: string) => cls.startsWith("flex-"))
33
+ if (!flexClass) return undefined
34
+ return $optionsStore.ICON_TEXT_POSITION_OPTIONS.find((c) => (c.value as string).includes(flexClass))
45
35
  })
36
+
37
+ const initialWidth = $derived(() => (component.properties.items?.some((item: IOption) => item.class?.split(" ").includes("flex-1")) ? 1 : 0))
38
+
39
+ /* "Равные" — flex-1 (flexbox сам поровну распределяет место с учётом gap между кнопками);
40
+ "Авто" — w-auto (ширина по контенту). Раньше "Равные" считалось как w-[X%] через inline
41
+ style в Tabs.svelte — не учитывало gap-6 между кнопками и создавало паразитный горизонтальный
42
+ скролл. flex-1/w-auto — обычные статические Tailwind-классы, отдельный inline style не нужен. */
43
+ const setTabWidthClass = (existingClass: string | undefined, equal: boolean): string =>
44
+ twMerge((existingClass ?? "").replace(/\bflex-1\b|\bw-auto\b/g, "").trim(), equal ? "flex-1" : "w-auto")
46
45
  </script>
47
46
 
48
47
  {#snippet TabsIconPosition()}
@@ -74,7 +73,7 @@
74
73
  onChange={(value) => {
75
74
  component.properties.items.forEach((_item: any, index: number) => {
76
75
  const items = [...(component.properties?.items || [])]
77
- items[index]["class"] = twMerge(items[index].class, value ? `w-[${(1 / items.length) * 100}%]` : "w-auto")
76
+ items[index]["class"] = setTabWidthClass(items[index].class, Boolean(value))
78
77
  updateProperty("items", items, component, onPropertyChange)
79
78
  })
80
79
  }}
@@ -98,7 +97,7 @@
98
97
  }
99
98
  const items = [...(component.properties?.items || []), newItem]
100
99
  items.forEach((_item: any, index: number) => {
101
- items[index]["class"] = twMerge(items[index].class, initialWidth() ? `w-[${(1 / items.length) * 100}%]` : "w-auto")
100
+ items[index]["class"] = setTabWidthClass(items[index].class, Boolean(initialWidth()))
102
101
  updateProperty("items", items, component, onPropertyChange)
103
102
  })
104
103
  }}
@@ -170,8 +169,7 @@
170
169
  const items = [...(component.properties?.items || [])]
171
170
  items.splice(index, 1)
172
171
  items.forEach((_item: any, index: number) => {
173
- items[index]["class"] = twMerge(items[index].class, initialWidth() ? `w-[${(1 / items.length) * 100}%]` : "w-auto")
174
- updateProperty("items", items, component, onPropertyChange)
172
+ items[index]["class"] = setTabWidthClass(items[index].class, Boolean(initialWidth()))
175
173
  })
176
174
  updateProperty("items", items, component, onPropertyChange)
177
175
  }}
@@ -22,7 +22,7 @@
22
22
 
23
23
  let currentValue = $derived(value)
24
24
 
25
- const minNumber = $derived(settings.number?.minNum ?? 0 + 1)
25
+ const minNumber = $derived(settings.number?.minNum ?? 0)
26
26
  const maxNumber = $derived(settings.number?.maxNum ?? 0)
27
27
 
28
28
  $effect(() => {
@@ -4,6 +4,7 @@
4
4
  DevSN/DevID/DevFW/RunCnt — только чтение; DevName/HostName/WebUser/WebPsw — сохраняются
5
5
  одним пакетом. Ничего не знает про DeviceStore/WebSocket — value/onSave/onRestart. -->
6
6
  <script lang="ts">
7
+ import { untrack } from "svelte"
7
8
  import { slide } from "svelte/transition"
8
9
  import { twMerge } from "tailwind-merge"
9
10
  import * as UI from ".."
@@ -57,8 +58,20 @@
57
58
  $effect(() => writePersistedCollapsed(persistKey, collapsed))
58
59
 
59
60
  /* Поля редактируются локально (как Argument: NoSend в реальной GUI) — отправка одним пакетом по кнопке */
60
- let savedSnapshot = $state(JSON.stringify(info))
61
+ let savedSnapshot = $state(untrack(() => JSON.stringify(info)))
61
62
  let isDirty = $derived(JSON.stringify(info) !== savedSnapshot)
63
+
64
+ /* value — живой поток данных с устройства (readonly-поля StaIP/DevFW/RunCnt и т.п. должны
65
+ обновляться постоянно), а не однократный дефолт. Подхватываем свежие данные, только если
66
+ у пользователя нет несохранённых правок редактируемых полей (DevName/HostName/...) —
67
+ иначе входящее значение затёрло бы то, что он ещё не успел сохранить. */
68
+ $effect(() => {
69
+ if (value !== undefined && !untrack(() => isDirty)) {
70
+ info = value
71
+ savedSnapshot = JSON.stringify(info)
72
+ }
73
+ })
74
+
62
75
  const save = () => {
63
76
  onSave?.($state.snapshot(info))
64
77
  savedSnapshot = JSON.stringify(info)
@@ -5,6 +5,7 @@
5
5
  WiFiMode / StaSSID/StaPSK/StaticIP/StaIP/StaMS/StaGW / ApSSID/ApPSK/ApIP/ApMS/ApGW.
6
6
  Ничего не знает про DeviceStore/WebSocket — value/onSave, вся отправка на стороне вызывающего. -->
7
7
  <script lang="ts">
8
+ import { untrack } from "svelte"
8
9
  import { slide, fade, scale } from "svelte/transition"
9
10
  import { twMerge } from "tailwind-merge"
10
11
  import * as UI from ".."
@@ -103,8 +104,21 @@
103
104
  $effect(() => writePersistedCollapsed(persistKey, collapsed))
104
105
 
105
106
  /* Поля редактируются локально (как Argument: NoSend в реальной GUI) — отправка одним пакетом по кнопке */
106
- let savedSnapshot = $state(JSON.stringify(cfg))
107
+ let savedSnapshot = $state(untrack(() => JSON.stringify(cfg)))
107
108
  let isDirty = $derived(JSON.stringify(cfg) !== savedSnapshot)
109
+
110
+ /* value — не однократный дефолт, а живой поток данных с устройства (GUIPreview.svelte
111
+ пересчитывает его на каждое обновление DeviceStore). Подхватываем свежие данные, только
112
+ если у пользователя нет несохранённых правок — иначе входящее значение затирало бы то,
113
+ что он ещё не успел сохранить. untrack(isDirty) — чтобы этот эффект не перезапускался на
114
+ каждое изменение cfg от самого пользователя, только на смену value. */
115
+ $effect(() => {
116
+ if (value !== undefined && !untrack(() => isDirty)) {
117
+ cfg = value
118
+ savedSnapshot = JSON.stringify(cfg)
119
+ }
120
+ })
121
+
108
122
  const commitSave = () => {
109
123
  onSave?.($state.snapshot(cfg))
110
124
  savedSnapshot = JSON.stringify(cfg)
@@ -18,9 +18,39 @@ const translations = {
18
18
  "📶 该组件使用设备 `CFG` 分组下的一组固定字段——无需手动绑定变量。\n\n**📡 客户端模式(STA)**\n- `WiFiMode`、`StaSSID`、`StaPSK`\n- `StaticIP`、`StaIP`、`StaMS`、`StaGW`\n\n**📡 接入点(AP)**\n- `ApSSID`、`ApPSK`\n- `ApIP`、`ApMS`、`ApGW`\n\n🔍 扫描网络会发送 `Header: GET`、`Argument: APsList`——设备应在 `CFG.StaSSID-S` 字段中返回结果。\n\n⚙️ 如果设备使用的分组名称不是 `CFG`,请在组件设置中修改前缀(ID)。",
19
19
  ],
20
20
  "component.info.WidgetDeviceInfo": [
21
- "ℹ️ Виджет работает с фиксированным набором полей группы `CFG` на устройстве — привязка переменных вручную не нужна.\n\n**🔒 Только чтение**\n- `DevSN`, `DevID`, `DevFW`, `RunCnt`\n\n**✏️ Редактируемые** (сохраняются одним пакетом по кнопке «Сохранить»)\n- `DevName`, `HostName`, `WebUser`, `WebPsw`\n\n🔌 Кнопка «Перезагрузить» отправляет `Argument: Restart`.\n\n⚙️ Если на устройстве используется другое имя группы вместо `CFG`, поменяйте префикс (ID) в настройках виджета.",
22
- "ℹ️ The widget works with a fixed set of fields in the device's `CFG` group — no manual variable binding needed.\n\n**🔒 Read-only**\n- `DevSN`, `DevID`, `DevFW`, `RunCnt`\n\n**✏️ Editable** (saved as one packet via the \"Save\" button)\n- `DevName`, `HostName`, `WebUser`, `WebPsw`\n\n🔌 The \"Restart\" button sends `Argument: Restart`.\n\n⚙️ If the device uses a different group name instead of `CFG`, change the prefix (ID) in the widget settings.",
23
- "ℹ️ 该组件使用设备 `CFG` 分组下的一组固定字段——无需手动绑定变量。\n\n**🔒 只读字段**\n- `DevSN`、`DevID`、`DevFW`、`RunCnt`\n\n**✏️ 可编辑字段**(通过「保存」按钮一次性发送)\n- `DevName`、`HostName`、`WebUser`、`WebPsw`\n\n🔌 「重启」按钮会发送 `Argument: Restart`。\n\n⚙️ 如果设备使用的分组名称不是 `CFG`,请在组件设置中修改前缀(ID)。",
21
+ "ℹ️ Виджет работает с фиксированным набором полей группы `CFG` на устройстве — привязка переменных вручную не нужна.\n\n**🔒 Только чтение**\n- `DevSN`, `DevID`, `DevFW`, `RunCnt`\n- `StaIP`, `ApIP` — текущие IP-адреса в режимах STA/AP\n\n**✏️ Редактируемые** (сохраняются одним пакетом по кнопке «Сохранить»)\n- `DevName`, `HostName`, `WebUser`, `WebPsw`\n\n🔌 Кнопка «Перезагрузить» отправляет `Argument: Restart`.\n\n⚙️ Если на устройстве используется другое имя группы вместо `CFG`, поменяйте префикс (ID) в настройках виджета.",
22
+ 'ℹ️ The widget works with a fixed set of fields in the device\'s `CFG` group — no manual variable binding needed.\n\n**🔒 Read-only**\n- `DevSN`, `DevID`, `DevFW`, `RunCnt`\n- `StaIP`, `ApIP` — current IP addresses in STA/AP mode\n\n**✏️ Editable** (saved as one packet via the "Save" button)\n- `DevName`, `HostName`, `WebUser`, `WebPsw`\n\n🔌 The "Restart" button sends `Argument: Restart`.\n\n⚙️ If the device uses a different group name instead of `CFG`, change the prefix (ID) in the widget settings.',
23
+ "ℹ️ 该组件使用设备 `CFG` 分组下的一组固定字段——无需手动绑定变量。\n\n**🔒 只读字段**\n- `DevSN`、`DevID`、`DevFW`、`RunCnt`\n- `StaIP`、`ApIP` —— STA/AP 模式下的当前 IP 地址\n\n**✏️ 可编辑字段**(通过「保存」按钮一次性发送)\n- `DevName`、`HostName`、`WebUser`、`WebPsw`\n\n🔌 「重启」按钮会发送 `Argument: Restart`。\n\n⚙️ 如果设备使用的分组名称不是 `CFG`,请在组件设置中修改前缀(ID)。",
24
+ ],
25
+ "component.info.Tabs": [
26
+ "📑 Ширина и позиция иконки задаются через служебные CSS-классы каждой вкладки, а не отдельными полями.\n\n**↔️ Ширина кнопок** (переключатель «Авто/Равные»)\n- `w-auto` — по содержимому\n- `flex-1` — поровну между всеми вкладками\n\n**🔤 Позиция иконки**\n- `flex-row` / `flex-col` / `flex-row-reverse` / `flex-col-reverse`\n\n⚠️ Максимум 10 вкладок.",
27
+ '📑 Tab width and icon position are set via each tab\'s internal CSS classes, not separate fields.\n\n**↔️ Button width** ("Auto/Equal" toggle)\n- `w-auto` — sized to content\n- `flex-1` — equal share among all tabs\n\n**🔤 Icon position**\n- `flex-row` / `flex-col` / `flex-row-reverse` / `flex-col-reverse`\n\n⚠️ Maximum 10 tabs.',
28
+ "📑 每个标签页的宽度和图标位置通过内部 CSS 类设置,而非独立字段。\n\n**↔️ 按钮宽度**(「自动/均等」切换)\n- `w-auto` —— 按内容自适应\n- `flex-1` —— 在所有标签页间均分\n\n**🔤 图标位置**\n- `flex-row` / `flex-col` / `flex-row-reverse` / `flex-col-reverse`\n\n⚠️ 最多 10 个标签页。",
29
+ ],
30
+ "component.info.Switch": [
31
+ "🔘 Один и тот же компонент под тремя разными задачами — тип определяет, что означает `options`.\n\n**↔️/↕️ horizontal / vertical** — обычный слайдер 0/1, `options[0]` задаёт мастер-цвет.\n\n**☑️ checkbox** — простой булев тумблер, `options` можно не задавать.\n\n**🔢 bitMode** — `value` становится битовой маской, каждый элемент `options` управляет одним битом через свою `value` (номер бита 0-31); недоступен вместе с `checkbox`.",
32
+ "🔘 One component, three different jobs — `type` decides what `options` means.\n\n**↔️/↕️ horizontal / vertical** — a plain 0/1 slider, `options[0]` sets the master color.\n\n**☑️ checkbox** — a simple boolean toggle, `options` may be omitted.\n\n**🔢 bitMode** — `value` becomes a bitmask, each `options` entry controls one bit via its `value` (bit number 0-31); not available together with `checkbox`.",
33
+ "🔘 同一个组件承担三种不同职责——`type` 决定 `options` 的含义。\n\n**↔️/↕️ horizontal / vertical** —— 普通的 0/1 滑块,`options[0]` 设置主色。\n\n**☑️ checkbox** —— 简单的布尔开关,可省略 `options`。\n\n**🔢 bitMode** —— `value` 变为位掩码,每个 `options` 项通过自己的 `value`(位号 0-31)控制一个比特位;不能与 `checkbox` 同时使用。",
34
+ ],
35
+ "component.info.Table": [
36
+ "📊 Тип `type` определяет поведение буфера строк.\n\n**📋 table** — статическая таблица, `body` заменяется целиком при каждом обновлении.\n\n**📜 logger** — `dataBuffer.stashData` копит входящие строки в буфер (`dataBuffer.bufferSize` — сколько хранить), новые строки добавляются сверху, старые вытесняются.\n\n⚙️ `dataBuffer.visibleRows` ограничивает видимую высоту прокруткой, не количество данных.",
37
+ "📊 The `type` field determines row-buffer behavior.\n\n**📋 table** — a static table, `body` is fully replaced on every update.\n\n**📜 logger** — `dataBuffer.stashData` accumulates incoming rows in a buffer (`dataBuffer.bufferSize` — how many to keep), new rows are added on top and old ones are evicted.\n\n⚙️ `dataBuffer.visibleRows` limits the visible scroll height, not the amount of data.",
38
+ "📊 `type` 字段决定行缓冲区的行为。\n\n**📋 table** —— 静态表格,每次更新时整体替换 `body`。\n\n**📜 logger** —— `dataBuffer.stashData` 将传入的行累积到缓冲区(`dataBuffer.bufferSize` —— 保留多少条),新行添加在顶部,旧行被淘汰。\n\n⚙️ `dataBuffer.visibleRows` 限制可视滚动高度,而非数据量。",
39
+ ],
40
+ "component.info.ProgressBar": [
41
+ "📶 `items` и `value` — два независимых массива, связанные позиционно (по индексу, не по имени).\n\n- `items[i]` — статическое оформление полосы: `name` (подпись) и `class` (цвет через `bg-*`)\n- `value[i].Value` — текущее числовое значение этой же полосы (0-100 или в границах `number.minNum`/`maxNum`)\n\n⚠️ Порядок элементов в обоих массивах должен совпадать.",
42
+ "📶 `items` and `value` are two independent arrays linked positionally (by index, not by name).\n\n- `items[i]` — the bar's static appearance: `name` (label) and `class` (color via `bg-*`)\n- `value[i].Value` — the current numeric value of that same bar (0-100 or within `number.minNum`/`maxNum`)\n\n⚠️ The order of elements in both arrays must match.",
43
+ "📶 `items` 和 `value` 是两个通过位置(索引,而非名称)关联的独立数组。\n\n- `items[i]` —— 该进度条的静态外观:`name`(标签)和 `class`(通过 `bg-*` 设置颜色)\n- `value[i].Value` —— 同一进度条的当前数值(0-100 或在 `number.minNum`/`maxNum` 范围内)\n\n⚠️ 两个数组中元素的顺序必须一致。",
44
+ ],
45
+ "component.info.FileAttach": [
46
+ "📎 `type` меняет не только внешний вид, но и формат `currentImage`.\n\n**📄 file** — обычная кнопка выбора файла, показывает только имя.\n\n**🖼️ image** — превью-кнопка; `currentImage` ожидается как data-URI (`data:image/...`) или голая base64-строка (тогда PNG-префикс подставляется автоматически). После выбора нового файла показывается его локальное превью — до того, как `onChange` обработает загрузку и обновит `currentImage`.",
47
+ "📎 `type` changes not only the appearance but also the `currentImage` format.\n\n**📄 file** — a plain file-picker button, shows only the file name.\n\n**🖼️ image** — a preview button; `currentImage` is expected to be a data URI (`data:image/...`) or a bare base64 string (the PNG prefix is then added automatically). After picking a new file, its local preview is shown right away — before `onChange` finishes the upload and updates `currentImage`.",
48
+ "📎 `type` 不仅改变外观,还改变 `currentImage` 的格式。\n\n**📄 file** —— 普通文件选择按钮,仅显示文件名。\n\n**🖼️ image** —— 预览按钮;`currentImage` 应为 data URI(`data:image/...`)或纯 base64 字符串(此时会自动添加 PNG 前缀)。选择新文件后会立即显示本地预览——早于 `onChange` 完成上传并更新 `currentImage` 之前。",
49
+ ],
50
+ "component.info.Widget": [
51
+ "🔢 Диапазон значения задаётся `settings.number.minNum`/`maxNum` (по умолчанию 0/0). Иконки в `icons.array` распределяются по этому диапазону равномерно — при `icons.cycling: true` перебираются автоматически по таймеру вместо ручного выбора.",
52
+ "🔢 The value range is set by `settings.number.minNum`/`maxNum` (defaults to 0/0). Icons in `icons.array` are spread evenly across this range — with `icons.cycling: true` they cycle automatically on a timer instead of manual selection.",
53
+ "🔢 数值范围由 `settings.number.minNum`/`maxNum` 设置(默认 0/0)。`icons.array` 中的图标在此范围内均匀分布——当 `icons.cycling: true` 时,图标按计时器自动循环,而非手动选择。",
24
54
  ],
25
55
  /* Опции редактора свойств */
26
56
  "constructor.props.action.update": ["Обновить", "Update", "更新"],
@@ -170,6 +200,10 @@ const translations = {
170
200
  "constructor.props.regexp.colorhex": ["Цвет HEX", "HEX color", "HEX 颜色"],
171
201
  "constructor.props.regexp.devid": ["ID устройства", "Device ID", "设备 ID"],
172
202
  "constructor.props.regexp.serialnum": ["Серийный номер устройства", "Device serial number", "设备序列号"],
203
+ "constructor.props.regexp.phone": ["Телефон (РФ)", "Phone number (RU)", "电话号码(俄罗斯)"],
204
+ "constructor.props.regexp.mac": ["MAC-адрес", "MAC address", "MAC 地址"],
205
+ "constructor.props.regexp.username": ["Логин (3-16 символов)", "Username (3-16 characters)", "用户名(3-16 个字符)"],
206
+ "constructor.props.regexp.password": ["Пароль (мин. 8, буквы и цифры)", "Password (min. 8, letters and digits)", "密码(至少 8 位,字母和数字)"],
173
207
  "constructor.props.min": ["Мин.", "Min.", "最小值"],
174
208
  "constructor.props.max": ["Макс.", "Max.", "最大值"],
175
209
  "constructor.props.maxnum": ["Максимальное число", "Maximum number", "最大数值"],
package/dist/options.js CHANGED
@@ -218,14 +218,18 @@ export const optionsStore = derived(T, ($T) => {
218
218
  name: $T("constructor.props.regexp.ipv4"),
219
219
  },
220
220
  { id: id(), value: "/((^|:)([0-9a-fA-F]{0,4})){1,8}$/", name: $T("constructor.props.regexp.ipv6") },
221
- { id: id(), value: "/(0[1-9]|[12][0-9]|3[01])[.](0[1-9]|1[012])[.](19|20)\\d\\d/", name: $T("constructor.props.regexp.date") },
221
+ { id: id(), value: "/^(0[1-9]|[12][0-9]|3[01])[.](0[1-9]|1[012])[.](19|20)\\d\\d$/", name: $T("constructor.props.regexp.date") },
222
222
  { id: id(), value: "/^([01]?[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$/", name: $T("constructor.props.regexp.time") },
223
- { id: id(), value: "/^[a-zA-Z0-9.!#$%&'*+/=?^_{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)\\*$/", name: $T("constructor.props.regexp.email") },
224
- { id: id(), value: "/(https?):((//)|(\\\\))+[\\w\\d:#@%/$()~_?\\+-=\\\\\\.&]*/", name: $T("constructor.props.regexp.url") },
223
+ { id: id(), value: "/^[a-zA-Z0-9.!#$%&'*+/=?^_{|}~-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)+$/", name: $T("constructor.props.regexp.email") },
224
+ { id: id(), value: "/^https?:\\/\\/[\\w.-]+(?:\\.[a-zA-Z]{2,})+(?:[/?#]\\S*)?$/", name: $T("constructor.props.regexp.url") },
225
225
  { id: id(), value: "/^-?\\d+(\\.\\d+)?$/", name: $T("constructor.props.regexp.numbers") },
226
226
  { id: id(), value: "/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$/", name: $T("constructor.props.regexp.colorhex") },
227
227
  { id: id(), value: "/^[0-9A-F]{0,4}$/", name: $T("constructor.props.regexp.devid") },
228
228
  { id: id(), value: "/^[0-9a-fA-F]{4}-[0-9a-fA-F]{24}[:][0-9a-fA-F]{2}$|^$/", name: $T("constructor.props.regexp.serialnum") },
229
+ { id: id(), value: "/^(\\+7|8)[\\s-]?\\(?\\d{3}\\)?[\\s-]?\\d{3}[\\s-]?\\d{2}[\\s-]?\\d{2}$/", name: $T("constructor.props.regexp.phone") },
230
+ { id: id(), value: "/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/", name: $T("constructor.props.regexp.mac") },
231
+ { id: id(), value: "/^[a-zA-Z0-9_]{3,16}$/", name: $T("constructor.props.regexp.username") },
232
+ { id: id(), value: "/^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d!@#$%^&*]{8,}$/", name: $T("constructor.props.regexp.password") },
229
233
  ],
230
234
  AUTOCOMPLETE_CONSTRUCTOR_OPTIONS: [
231
235
  { id: id(), value: "on", name: $T("constructor.props.autocomplete.on") },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poe-svelte-ui-lib",
3
- "version": "1.29.0",
3
+ "version": "1.30.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {