@warkypublic/svelix 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/components/Boxer/Boxer.stories.d.ts +2 -0
  2. package/dist/components/Boxer/Boxer.stories.js +72 -0
  3. package/dist/components/Boxer/Boxer.svelte +50 -25
  4. package/dist/components/Boxer/BoxerResolveSpecAdapter.d.ts +17 -0
  5. package/dist/components/Boxer/BoxerResolveSpecAdapter.js +56 -0
  6. package/dist/components/Boxer/BoxerRestHeaderSpecAdapter.d.ts +17 -0
  7. package/dist/components/Boxer/BoxerRestHeaderSpecAdapter.js +56 -0
  8. package/dist/components/Boxer/index.d.ts +2 -0
  9. package/dist/components/Boxer/index.js +2 -0
  10. package/dist/components/Boxer/store.js +9 -0
  11. package/dist/components/Boxer/types.d.ts +36 -0
  12. package/dist/components/Former/Former.stories.js +31 -9
  13. package/dist/components/Former/Former.svelte +27 -3
  14. package/dist/components/Former/FormerButtonArea.svelte +18 -5
  15. package/dist/components/Former/FormerButtonArea.svelte.d.ts +1 -0
  16. package/dist/components/Former/FormerResolveSpecAPI.js +5 -2
  17. package/dist/components/Former/FormerRestApiPreview.svelte +204 -188
  18. package/dist/components/Former/FormerRestHeadSpecAPI.js +11 -5
  19. package/dist/components/GlobalStateStore/GlobalStateStore.utils.js +2 -2
  20. package/dist/components/Svark/Svark.svelte +24 -8
  21. package/dist/components/Svark/Svark.svelte.d.ts +5 -3
  22. package/dist/components/Svark/SvarkResolveSpecAdapter.d.ts +2 -1
  23. package/dist/components/Svark/SvarkResolveSpecAdapter.js +6 -19
  24. package/dist/components/Svark/SvarkRestHeaderSpecAdapter.d.ts +16 -0
  25. package/dist/components/Svark/SvarkRestHeaderSpecAdapter.js +52 -0
  26. package/dist/components/Svark/index.d.ts +1 -0
  27. package/dist/components/Svark/index.js +1 -0
  28. package/dist/components/Svark/types.d.ts +9 -6
  29. package/package.json +1 -1
@@ -17,3 +17,5 @@ export declare const Local: Story;
17
17
  export declare const MultiSelect: Story;
18
18
  export declare const Clearable: Story;
19
19
  export declare const WithSearch: Story;
20
+ export declare const WithResolveSpec: Story;
21
+ export declare const WithHeaderSpec: Story;
@@ -1,5 +1,7 @@
1
1
  import { expect, userEvent, within } from '@storybook/test';
2
2
  import Boxer from './Boxer.svelte';
3
+ import { BoxerResolveSpecAdapter } from './BoxerResolveSpecAdapter.js';
4
+ import { BoxerRestHeaderSpecAdapter } from './BoxerRestHeaderSpecAdapter.js';
3
5
  const meta = {
4
6
  title: 'Components/Boxer',
5
7
  component: Boxer,
@@ -100,3 +102,73 @@ export const WithSearch = {
100
102
  expect(canvas.queryByText('Banana')).toBeFalsy();
101
103
  },
102
104
  };
105
+ export const WithResolveSpec = {
106
+ argTypes: {
107
+ apiUrl: { control: 'text', description: 'ResolveSpec API base URL' },
108
+ authToken: { control: 'text', description: 'Bearer token for ResolveSpec authentication' },
109
+ schema: { control: 'text', description: 'Database schema' },
110
+ entity: { control: 'text', description: 'Entity / table name' },
111
+ labelField: { control: 'text', description: 'Field to display as the label' },
112
+ valueField: { control: 'text', description: 'Field to use as the selected value' },
113
+ },
114
+ args: {
115
+ apiUrl: "https://utils.btsys.tech/api/v2",
116
+ authToken: 'A684939F-BA4F-4CC1-9F5F-7050A2637171',
117
+ schema: 'public',
118
+ entity: "process",
119
+ labelField: "process",
120
+ valueField: "id_process",
121
+ label: 'ResolveSpec Select',
122
+ placeholder: 'Search...',
123
+ searchable: true,
124
+ },
125
+ render: ({ apiUrl, authToken, schema, entity, labelField, valueField, ...rest }) => ({
126
+ Component: Boxer,
127
+ props: {
128
+ ...rest,
129
+ adapter: new BoxerResolveSpecAdapter({
130
+ baseUrl: apiUrl,
131
+ token: authToken,
132
+ schema,
133
+ entity,
134
+ labelField,
135
+ valueField,
136
+ }),
137
+ },
138
+ }),
139
+ };
140
+ export const WithHeaderSpec = {
141
+ argTypes: {
142
+ apiUrl: { control: 'text', description: 'HeaderSpec API base URL' },
143
+ authToken: { control: 'text', description: 'Bearer token for HeaderSpec authentication' },
144
+ schema: { control: 'text', description: 'Database schema' },
145
+ entity: { control: 'text', description: 'Entity / table name' },
146
+ labelField: { control: 'text', description: 'Field to display as the label' },
147
+ valueField: { control: 'text', description: 'Field to use as the selected value' },
148
+ },
149
+ args: {
150
+ apiUrl: 'https://api.example.com',
151
+ authToken: 'your-token-here',
152
+ schema: 'public',
153
+ entity: 'items',
154
+ labelField: 'name',
155
+ valueField: 'id',
156
+ label: 'HeaderSpec Select',
157
+ placeholder: 'Search...',
158
+ searchable: true,
159
+ },
160
+ render: ({ apiUrl, authToken, schema, entity, labelField, valueField, ...rest }) => ({
161
+ Component: Boxer,
162
+ props: {
163
+ ...rest,
164
+ adapter: new BoxerRestHeaderSpecAdapter({
165
+ baseUrl: apiUrl,
166
+ token: authToken,
167
+ schema,
168
+ entity,
169
+ labelField,
170
+ valueField,
171
+ }),
172
+ },
173
+ }),
174
+ };
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
3
+ import { untrack } from "svelte";
3
4
  import {
4
5
  createVirtualizer,
5
6
  type SvelteVirtualizer,
@@ -7,19 +8,19 @@
7
8
  import type { BoxerItem, BoxerProps } from "./types";
8
9
  import { createBoxerStore } from "./store";
9
10
  import BoxerTarget from "./BoxerTarget.svelte";
10
- import Portal from "../Portal/Portal.svelte";
11
11
 
12
12
  let {
13
+ adapter,
13
14
  clearable = true,
14
15
  data = [],
15
- dataSource = "local",
16
+ dataSource: dataSourceProp = undefined,
16
17
  disabled,
17
18
  error,
18
19
  label,
19
20
  leftSection,
20
21
  mah = 200,
21
22
  multiSelect,
22
- onAPICall,
23
+ onAPICall: onAPICallProp = undefined,
23
24
  onBufferChange,
24
25
  onChange,
25
26
  openOnClear,
@@ -31,6 +32,15 @@
31
32
  value = $bindable<any>(undefined),
32
33
  }: BoxerProps = $props();
33
34
 
35
+ // Derive effective dataSource and onAPICall from adapter when not explicitly provided
36
+ const dataSource = dataSourceProp ?? (adapter ? "server" : "local");
37
+ const onAPICall =
38
+ onAPICallProp ??
39
+ (adapter
40
+ ? (params: { page: number; pageSize: number; search?: string }) =>
41
+ adapter!.fetch(params)
42
+ : undefined);
43
+
34
44
  // Create store once with initial props
35
45
  const store = createBoxerStore({
36
46
  clearable,
@@ -52,8 +62,13 @@
52
62
  value,
53
63
  });
54
64
 
55
- // Use $store auto-subscribe (Svelte 5 runes mode supports $store syntax)
56
- // This correctly triggers re-renders when the store updates from external calls (e.g. open())
65
+ // Fine-grained derived values so $effect blocks only re-run when the
66
+ // specific field they care about actually changes (not on every store update).
67
+ const storeSearch = $derived($store.search);
68
+ const storeOpened = $derived($store.opened);
69
+ const storeBoxerData = $derived($store.boxerData);
70
+ const storeInput = $derived($store.input);
71
+ const boxerDataLength = $derived($store.boxerData.length);
57
72
 
58
73
  // Sync value prop changes into store
59
74
  $effect(() => {
@@ -63,31 +78,45 @@
63
78
  // Virtualizer
64
79
  let parentEl = $state<HTMLDivElement | undefined>(undefined);
65
80
  let targetRef = $state<{ focus: () => void } | undefined>(undefined);
66
- let virtualizerInstance = $state<SvelteVirtualizer<
81
+ // Plain variable — NOT $state to avoid deep proxy on the complex virtualizer object.
82
+ let rawVirtualizer: SvelteVirtualizer<
67
83
  HTMLDivElement,
68
84
  HTMLDivElement
69
- > | null>(null);
85
+ > | null = null;
86
+ // Virtualizer outputs as $state — updated directly by the subscribe callback.
87
+ let virtualItems = $state.raw<
88
+ ReturnType<
89
+ SvelteVirtualizer<HTMLDivElement, HTMLDivElement>["getVirtualItems"]
90
+ >
91
+ >([]);
92
+ let totalSize = $state(0);
70
93
 
94
+ // Create virtualizer only when parentEl changes (dropdown mount/unmount).
71
95
  $effect(() => {
72
96
  if (!parentEl) {
73
- virtualizerInstance = null;
97
+ rawVirtualizer = null;
98
+ virtualItems = [];
99
+ totalSize = 0;
74
100
  return;
75
101
  }
102
+ const initialCount = untrack(() => boxerDataLength);
76
103
  const v = createVirtualizer<HTMLDivElement, HTMLDivElement>({
77
- count: $store.boxerData.length,
104
+ count: initialCount,
78
105
  estimateSize: () => 36,
79
106
  getScrollElement: () => parentEl!,
80
107
  });
81
108
  return v.subscribe((instance) => {
82
- virtualizerInstance = instance;
109
+ rawVirtualizer = instance;
110
+ virtualItems = instance.getVirtualItems();
111
+ totalSize = instance.getTotalSize();
83
112
  });
84
113
  });
85
114
 
86
- // Update virtualizer count when data changes
115
+ // Update virtualizer count when data length actually changes (preserves scroll).
87
116
  $effect(() => {
88
- const count = $store.boxerData.length;
89
- if (virtualizerInstance && parentEl) {
90
- virtualizerInstance.setOptions({
117
+ const count = boxerDataLength;
118
+ if (rawVirtualizer && parentEl) {
119
+ rawVirtualizer.setOptions({
91
120
  count,
92
121
  estimateSize: () => 36,
93
122
  getScrollElement: () => parentEl!,
@@ -95,18 +124,14 @@
95
124
  }
96
125
  });
97
126
 
98
- const virtualItems = $derived(virtualizerInstance?.getVirtualItems() ?? []);
99
- const totalSize = $derived(virtualizerInstance?.getTotalSize() ?? 0);
100
-
101
- // Debounced search
127
+ // Debounced search only reacts to actual search text changes, not unrelated store updates.
102
128
  let searchTimeout: ReturnType<typeof setTimeout> | undefined;
103
129
 
104
130
  $effect(() => {
105
- const search = $store.search;
106
- const opened = $store.opened;
131
+ const search = storeSearch;
107
132
  clearTimeout(searchTimeout);
108
133
  searchTimeout = setTimeout(() => {
109
- if (search !== undefined && opened) {
134
+ if (search !== undefined && storeOpened) {
110
135
  store.fetchData(search, true);
111
136
  }
112
137
  }, 300);
@@ -120,9 +145,9 @@
120
145
 
121
146
  // Sync value -> input label
122
147
  $effect(() => {
123
- const boxerData = $store.boxerData;
124
- const opened = $store.opened;
125
- const currentInput = $store.input;
148
+ const boxerData = storeBoxerData;
149
+ const opened = storeOpened;
150
+ const currentInput = storeInput;
126
151
 
127
152
  if (multiSelect) {
128
153
  const labels = boxerData
@@ -159,7 +184,7 @@
159
184
 
160
185
  // Select first
161
186
  $effect(() => {
162
- const boxerData = $store.boxerData;
187
+ const boxerData = storeBoxerData;
163
188
  if (selectFirst && boxerData.length > 0 && !multiSelect && !value) {
164
189
  onOptionSubmit(0);
165
190
  }
@@ -0,0 +1,17 @@
1
+ import type { BoxerAdapterConfig, BoxerItem, BoxerServerAdapter } from './types.js';
2
+ export declare class BoxerResolveSpecAdapter implements BoxerServerAdapter {
3
+ private readonly client;
4
+ private readonly config;
5
+ constructor(config: BoxerAdapterConfig);
6
+ fetch({ page, pageSize, search }: {
7
+ page: number;
8
+ pageSize: number;
9
+ search?: string;
10
+ }): Promise<{
11
+ data: BoxerItem[];
12
+ total: number;
13
+ }>;
14
+ private resolveColumns;
15
+ private buildSearchFilters;
16
+ private mapRow;
17
+ }
@@ -0,0 +1,56 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { ResolveSpecClient } from '@warkypublic/resolvespec-js';
3
+ export class BoxerResolveSpecAdapter {
4
+ client;
5
+ config;
6
+ constructor(config) {
7
+ this.client = new ResolveSpecClient({ baseUrl: config.baseUrl, token: config.token });
8
+ this.config = {
9
+ labelField: 'label',
10
+ valueField: 'id',
11
+ searchOperator: 'ilike',
12
+ ...config,
13
+ };
14
+ }
15
+ async fetch({ page, pageSize, search }) {
16
+ const { schema, entity, labelField, valueField, columns, sort, filters, searchColumns, searchOperator, mapItem } = this.config;
17
+ const cols = this.resolveColumns(columns, labelField, valueField);
18
+ const searchFilters = this.buildSearchFilters(search, labelField, searchColumns, searchOperator);
19
+ const options = {
20
+ columns: cols,
21
+ sort: sort ?? [],
22
+ filters: [...(filters ?? []), ...searchFilters],
23
+ limit: pageSize,
24
+ offset: page * pageSize,
25
+ };
26
+ const response = await this.client.read(schema, entity, undefined, options);
27
+ const rows = Array.isArray(response?.data) ? response.data : (Array.isArray(response) ? response : []);
28
+ const total = response?.metadata?.total ?? rows.length;
29
+ return {
30
+ data: rows.map((row) => this.mapRow(row, labelField, valueField, mapItem)),
31
+ total,
32
+ };
33
+ }
34
+ resolveColumns(columns, labelField, valueField) {
35
+ if (!columns)
36
+ return undefined;
37
+ return [...new Set([...columns, labelField, valueField])];
38
+ }
39
+ buildSearchFilters(search, labelField, searchColumns, searchOperator) {
40
+ const trimmed = search?.trim();
41
+ if (!trimmed)
42
+ return [];
43
+ const cols = searchColumns?.length ? searchColumns : [labelField];
44
+ return cols.map((column, index) => ({
45
+ column,
46
+ operator: searchOperator,
47
+ value: `%${trimmed}%`,
48
+ logic_operator: index === 0 ? 'AND' : 'OR',
49
+ }));
50
+ }
51
+ mapRow(row, labelField, valueField, mapItem) {
52
+ if (mapItem)
53
+ return mapItem(row);
54
+ return { ...row, label: String(row[labelField] ?? ''), value: row[valueField] };
55
+ }
56
+ }
@@ -0,0 +1,17 @@
1
+ import type { BoxerAdapterConfig, BoxerItem, BoxerServerAdapter } from './types.js';
2
+ export declare class BoxerRestHeaderSpecAdapter implements BoxerServerAdapter {
3
+ private readonly client;
4
+ private readonly config;
5
+ constructor(config: BoxerAdapterConfig);
6
+ fetch({ page, pageSize, search }: {
7
+ page: number;
8
+ pageSize: number;
9
+ search?: string;
10
+ }): Promise<{
11
+ data: BoxerItem[];
12
+ total: number;
13
+ }>;
14
+ private resolveColumns;
15
+ private buildSearchFilters;
16
+ private mapRow;
17
+ }
@@ -0,0 +1,56 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { HeaderSpecClient } from '@warkypublic/resolvespec-js';
3
+ export class BoxerRestHeaderSpecAdapter {
4
+ client;
5
+ config;
6
+ constructor(config) {
7
+ this.client = new HeaderSpecClient({ baseUrl: config.baseUrl, token: config.token });
8
+ this.config = {
9
+ labelField: 'label',
10
+ valueField: 'id',
11
+ searchOperator: 'ilike',
12
+ ...config,
13
+ };
14
+ }
15
+ async fetch({ page, pageSize, search }) {
16
+ const { schema, entity, labelField, valueField, columns, sort, filters, searchColumns, searchOperator, mapItem } = this.config;
17
+ const cols = this.resolveColumns(columns, labelField, valueField);
18
+ const searchFilters = this.buildSearchFilters(search, labelField, searchColumns, searchOperator);
19
+ const options = {
20
+ columns: cols,
21
+ sort: sort ?? [],
22
+ filters: [...(filters ?? []), ...searchFilters],
23
+ limit: pageSize,
24
+ offset: page * pageSize,
25
+ };
26
+ const response = await this.client.read(schema, entity, undefined, options);
27
+ const rows = Array.isArray(response?.data) ? response.data : (Array.isArray(response) ? response : []);
28
+ const total = response?.metadata?.total ?? rows.length;
29
+ return {
30
+ data: rows.map((row) => this.mapRow(row, labelField, valueField, mapItem)),
31
+ total,
32
+ };
33
+ }
34
+ resolveColumns(columns, labelField, valueField) {
35
+ if (!columns)
36
+ return undefined;
37
+ return [...new Set([...columns, labelField, valueField])];
38
+ }
39
+ buildSearchFilters(search, labelField, searchColumns, searchOperator) {
40
+ const trimmed = search?.trim();
41
+ if (!trimmed)
42
+ return [];
43
+ const cols = searchColumns?.length ? searchColumns : [labelField];
44
+ return cols.map((column, index) => ({
45
+ column,
46
+ operator: searchOperator,
47
+ value: `%${trimmed}%`,
48
+ logic_operator: index === 0 ? 'AND' : 'OR',
49
+ }));
50
+ }
51
+ mapRow(row, labelField, valueField, mapItem) {
52
+ if (mapItem)
53
+ return mapItem(row);
54
+ return { ...row, label: String(row[labelField] ?? ''), value: row[valueField] };
55
+ }
56
+ }
@@ -1,4 +1,6 @@
1
1
  export { default as Boxer } from './Boxer.svelte';
2
2
  export { default as BoxerTarget } from './BoxerTarget.svelte';
3
+ export { BoxerResolveSpecAdapter } from './BoxerResolveSpecAdapter.js';
4
+ export { BoxerRestHeaderSpecAdapter } from './BoxerRestHeaderSpecAdapter.js';
3
5
  export { createBoxerStore } from './store';
4
6
  export * from './types';
@@ -1,4 +1,6 @@
1
1
  export { default as Boxer } from './Boxer.svelte';
2
2
  export { default as BoxerTarget } from './BoxerTarget.svelte';
3
+ export { BoxerResolveSpecAdapter } from './BoxerResolveSpecAdapter.js';
4
+ export { BoxerRestHeaderSpecAdapter } from './BoxerRestHeaderSpecAdapter.js';
3
5
  export { createBoxerStore } from './store';
4
6
  export * from './types';
@@ -22,6 +22,7 @@ export function createBoxerStore(initialProps) {
22
22
  subscribe((s) => (current = s))();
23
23
  return current;
24
24
  }
25
+ let lastResetSearch;
25
26
  async function fetchData(search, reset) {
26
27
  const state = getState();
27
28
  if (state.dataSource === 'local' || !state.onAPICall) {
@@ -35,7 +36,15 @@ export function createBoxerStore(initialProps) {
35
36
  return;
36
37
  }
37
38
  if (state.onAPICall) {
39
+ // Prevent concurrent fetches
40
+ if (state.isFetching)
41
+ return;
42
+ // Prevent duplicate reset fetches for the same search term
43
+ if (reset && search === lastResetSearch && state.boxerData.length > 0)
44
+ return;
38
45
  try {
46
+ if (reset)
47
+ lastResetSearch = search;
39
48
  update((s) => ({ ...s, isFetching: true }));
40
49
  const currentPage = reset ? 0 : state.page;
41
50
  const result = await state.onAPICall({ page: currentPage, pageSize: state.pageSize ?? 50, search });
@@ -1,11 +1,47 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import type { FilterOption, SortOption } from '@warkypublic/resolvespec-js';
2
3
  export type BoxerDataSource = 'local' | 'server';
4
+ export type BoxerClientType = 'body' | 'header';
5
+ export interface BoxerAdapterConfig {
6
+ baseUrl: string;
7
+ token?: string;
8
+ schema: string;
9
+ entity: string;
10
+ /** Field to display as the label. Defaults to `'label'`. */
11
+ labelField?: string;
12
+ /** Field to use as the selected value. Defaults to `'id'`. */
13
+ valueField?: string;
14
+ /** Columns to fetch. When omitted, all columns are fetched. `labelField` and `valueField` are always included. */
15
+ columns?: string[];
16
+ /** Default sort applied to every request. */
17
+ sort?: SortOption[];
18
+ /** Base filters always applied to every request. */
19
+ filters?: FilterOption[];
20
+ /** Columns to search against. Defaults to `[labelField]`. */
21
+ searchColumns?: string[];
22
+ /** Filter operator used for search. Defaults to `'ilike'`. */
23
+ searchOperator?: string;
24
+ /** Custom row-to-BoxerItem mapping. When omitted, `labelField` and `valueField` are mapped automatically. */
25
+ mapItem?: (row: any) => BoxerItem;
26
+ }
27
+ export interface BoxerServerAdapter {
28
+ fetch(params: {
29
+ page: number;
30
+ pageSize: number;
31
+ search?: string;
32
+ }): Promise<{
33
+ data: BoxerItem[];
34
+ total: number;
35
+ }>;
36
+ }
3
37
  export type BoxerItem = {
4
38
  [key: string]: any;
5
39
  label: string;
6
40
  value: any;
7
41
  };
8
42
  export interface BoxerProps {
43
+ /** Server adapter (ResolveSpec or HeaderSpec). Automatically sets `dataSource` to `'server'` when provided. */
44
+ adapter?: BoxerServerAdapter;
9
45
  clearable?: boolean;
10
46
  data?: Array<BoxerItem>;
11
47
  dataSource?: BoxerDataSource;
@@ -51,10 +51,17 @@ export const DeleteUser = {
51
51
  render: () => ({ Component: FormerPreview, props: { request: 'delete' } }),
52
52
  play: async ({ canvasElement }) => {
53
53
  const canvas = within(canvasElement);
54
- // Save button in delete mode should be present and not disabled
55
- const saveBtn = canvas.getByRole('button', { name: /save/i });
56
- await expect(saveBtn).toBeVisible();
57
- await expect(saveBtn).not.toBeDisabled();
54
+ // Form fields should be disabled in delete mode
55
+ const textInputs = canvas.getAllByRole('textbox');
56
+ await expect(textInputs[0]).toBeDisabled();
57
+ // Delete button should be disabled until confirmation is checked
58
+ const deleteBtn = canvas.getByRole('button', { name: /delete/i });
59
+ await expect(deleteBtn).toBeDisabled();
60
+ // Check the confirmation checkbox
61
+ const confirmCheckbox = canvas.getByRole('checkbox', { name: /confirm.*delete/i });
62
+ await userEvent.click(confirmCheckbox);
63
+ // Delete button should now be enabled
64
+ await expect(deleteBtn).not.toBeDisabled();
58
65
  },
59
66
  };
60
67
  export const ViewUser = {
@@ -148,9 +155,16 @@ export const ModalDelete = {
148
155
  await userEvent.click(canvas.getByRole('button', { name: /open form/i }));
149
156
  const body = within(document.body);
150
157
  await expect(body.getByRole('heading', { name: /delete record/i })).toBeVisible();
151
- // Save is always enabled in delete mode
152
- const saveBtn = body.getByRole('button', { name: /save/i });
153
- await expect(saveBtn).not.toBeDisabled();
158
+ // Form fields should be disabled
159
+ const textInputs = body.getAllByRole('textbox');
160
+ await expect(textInputs[0]).toBeDisabled();
161
+ // Delete button should be disabled until confirmation is checked
162
+ const deleteBtn = body.getByRole('button', { name: /delete/i });
163
+ await expect(deleteBtn).toBeDisabled();
164
+ // Check the confirmation checkbox
165
+ const confirmCheckbox = body.getByRole('checkbox', { name: /confirm.*delete/i });
166
+ await userEvent.click(confirmCheckbox);
167
+ await expect(deleteBtn).not.toBeDisabled();
154
168
  },
155
169
  };
156
170
  // ── Drawer layout ─────────────────────────────────────────────────────────────
@@ -188,8 +202,16 @@ export const DrawerDelete = {
188
202
  await userEvent.click(canvas.getByRole('button', { name: /open drawer/i }));
189
203
  const body = within(document.body);
190
204
  await expect(body.getByRole('heading', { name: /delete record/i })).toBeVisible();
191
- const saveBtn = body.getByRole('button', { name: /save/i });
192
- await expect(saveBtn).not.toBeDisabled();
205
+ // Form fields should be disabled
206
+ const textInputs = body.getAllByRole('textbox');
207
+ await expect(textInputs[0]).toBeDisabled();
208
+ // Delete button should be disabled until confirmation is checked
209
+ const deleteBtn = body.getByRole('button', { name: /delete/i });
210
+ await expect(deleteBtn).toBeDisabled();
211
+ // Check the confirmation checkbox
212
+ const confirmCheckbox = body.getByRole('checkbox', { name: /confirm.*delete/i });
213
+ await userEvent.click(confirmCheckbox);
214
+ await expect(deleteBtn).not.toBeDisabled();
193
215
  },
194
216
  };
195
217
  // ── REST API live example ─────────────────────────────────────────────────────
@@ -77,6 +77,8 @@
77
77
  // Reset when form closes so re-opens start fresh.
78
78
  initialValues = undefined;
79
79
  dirty = false;
80
+ deleteConfirmed = false;
81
+ error = undefined;
80
82
  }
81
83
  });
82
84
 
@@ -153,6 +155,7 @@
153
155
  async function load(reset?: boolean): Promise<void> {
154
156
  try {
155
157
  loading = true;
158
+ error = undefined;
156
159
 
157
160
  // Base data for "load" comes from existing values or primeData.
158
161
  // If `beforeGet` is provided, it can normalize/augment this data even
@@ -192,6 +195,7 @@
192
195
  async function save(): Promise<any> {
193
196
  try {
194
197
  loading = true;
198
+ error = undefined;
195
199
 
196
200
  let data = values ? { ...values } : {};
197
201
 
@@ -226,6 +230,7 @@
226
230
  values = clearedData;
227
231
  initialValues = JSON.parse(JSON.stringify(clearedData));
228
232
  dirty = false;
233
+ deleteConfirmed = false;
229
234
  onChange?.(clearedData, getAllState());
230
235
  return newData;
231
236
  }
@@ -355,6 +360,7 @@
355
360
  {:else}
356
361
  <FormerButtonArea
357
362
  closeButtonTitle={layout?.closeButtonTitle}
363
+ {deleteConfirmed}
358
364
  {dirty}
359
365
  {keepOpen}
360
366
  {request}
@@ -395,10 +401,27 @@
395
401
  save();
396
402
  }}
397
403
  >
398
- {#if children}
399
- {@render children(formerState)}
400
- {/if}
404
+ <fieldset disabled={request === 'delete'} class="contents">
405
+ {#if children}
406
+ {@render children(formerState)}
407
+ {/if}
408
+ </fieldset>
401
409
  </form>
410
+
411
+ {#if request === 'delete'}
412
+ <div class="mt-3 p-3 rounded border border-error-500 bg-error-50 dark:bg-error-950 text-sm space-y-2">
413
+ <p class="font-semibold text-error-700 dark:text-error-300">⚠ This action cannot be undone</p>
414
+ <label class="flex items-center gap-2 cursor-pointer">
415
+ <input
416
+ type="checkbox"
417
+ class="checkbox"
418
+ checked={deleteConfirmed}
419
+ onchange={(e) => (deleteConfirmed = e.currentTarget.checked)}
420
+ />
421
+ <span>I confirm I want to permanently delete this record</span>
422
+ </label>
423
+ </div>
424
+ {/if}
402
425
  </div>
403
426
 
404
427
  <!-- Bottom button area -->
@@ -408,6 +431,7 @@
408
431
  {:else if layout?.buttonArea !== 'none'}
409
432
  <FormerButtonArea
410
433
  closeButtonTitle={layout?.closeButtonTitle}
434
+ {deleteConfirmed}
411
435
  {dirty}
412
436
  {keepOpen}
413
437
  {request}