@akinon/akitable 1.6.2 → 1.7.0-260914-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.
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Navigation helpers behind this package's new-tab support.
3
+ *
4
+ * DUPLICATE — an identical copy lives in `@akinon/ui-breadcrumb` as
5
+ * `src/navigation.ts`. The two are kept apart on purpose: neither package
6
+ * should depend on the other, and a shared package would have to put them in
7
+ * its public API. `isSafeHref` is a security check, so any change here — a
8
+ * newly discovered bypass shape, a scheme added to the allow list — MUST be
9
+ * applied to the other copy as well.
10
+ */
11
+ /**
12
+ * Returns `true` when a mouse event expresses the user's intent to open the
13
+ * target in a new tab or window: middle click, `Cmd` + click (macOS) / `Ctrl` +
14
+ * click (Windows, Linux), or `Shift` + click.
15
+ *
16
+ * `Alt` + click is deliberately **not** included: it is a download gesture
17
+ * rather than a new-tab one, and it does nothing at all for in-app routes, so
18
+ * treating it as browser-handled would swallow the navigation entirely.
19
+ *
20
+ * Call sites must return early **without** calling `preventDefault()` when this
21
+ * returns `true`, so the browser's native anchor behavior takes over. This only
22
+ * works on elements that render as a real `<a href="...">`.
23
+ *
24
+ * The `button` check only matters for a call site that listens to
25
+ * `mousedown`/`auxclick`: a middle click dispatches `auxclick`, not `click`, so
26
+ * React's `onClick` never sees it.
27
+ */
28
+ export declare const isNewTabRequest: (event: Pick<MouseEvent, "button" | "metaKey" | "ctrlKey" | "shiftKey">) => boolean;
29
+ /**
30
+ * Returns `true` when `href` is safe to render as a real `<a href="...">`
31
+ * attribute.
32
+ *
33
+ * Relative URLs (`/products/1`, `products/1`, `#/products/1`, `?page=2`) and
34
+ * the `http`, `https`, `mailto` and `tel` schemes are allowed. Any other scheme
35
+ * — `javascript:` above all — is rejected, since following such a link executes
36
+ * script in the page's own origin. Note that an internal routing key shaped
37
+ * like `catalog:detail` parses as the scheme `catalog:` and is rejected as
38
+ * well.
39
+ *
40
+ * This matters because navigation targets are typically derived from server
41
+ * data: an `href` built from a record field is untrusted input, and a real
42
+ * anchor is followed natively on middle click, `Cmd`/`Ctrl` + click and through
43
+ * the right-click context menu, where no click handler runs to intercept it.
44
+ *
45
+ * An empty or whitespace-only value is rejected as well: `<a href="">` points at
46
+ * the current document and reloads the page.
47
+ *
48
+ * Protocol-relative URLs (`//evil.com`) are rejected too. They carry no scheme,
49
+ * so they read as relative, but the browser resolves them against the current
50
+ * one and navigates off-origin — and no in-app route ever needs that shape.
51
+ * Absolute `http`/`https` URLs stay allowed on purpose: a cross-origin target
52
+ * can be legitimate, so the caller owns that decision — including whether it
53
+ * can carry `rel="noopener"`, which is a per-call-site matter.
54
+ *
55
+ * A rejected value is dropped silently: nothing is logged, and the element
56
+ * renders without an anchor.
57
+ */
58
+ export declare const isSafeHref: (href: string | undefined | null) => boolean;
59
+ //# sourceMappingURL=navigation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"navigation.d.ts","sourceRoot":"","sources":["../../../src/common/navigation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAoCH;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,eAAe,GAC1B,OAAO,IAAI,CAAC,UAAU,EAAE,QAAQ,GAAG,SAAS,GAAG,SAAS,GAAG,UAAU,CAAC,KACrE,OAIa,CAAC;AAEjB;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,UAAU,GAAI,MAAM,MAAM,GAAG,SAAS,GAAG,IAAI,KAAG,OAkB5D,CAAC"}
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+ /**
3
+ * Navigation helpers behind this package's new-tab support.
4
+ *
5
+ * DUPLICATE — an identical copy lives in `@akinon/ui-breadcrumb` as
6
+ * `src/navigation.ts`. The two are kept apart on purpose: neither package
7
+ * should depend on the other, and a shared package would have to put them in
8
+ * its public API. `isSafeHref` is a security check, so any change here — a
9
+ * newly discovered bypass shape, a scheme added to the allow list — MUST be
10
+ * applied to the other copy as well.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.isSafeHref = exports.isNewTabRequest = void 0;
14
+ /** Mouse button number for the middle (wheel) button in `MouseEvent.button`. */
15
+ const MIDDLE_MOUSE_BUTTON = 1;
16
+ /**
17
+ * Control characters (tab and newline included) are stripped by browsers while
18
+ * parsing a URL, so `java\nscript:alert(1)` is a working `javascript:` URL and
19
+ * must not slip through the scheme check below.
20
+ *
21
+ * Deliberately wider than the browser, which strips tab/CR/LF anywhere but
22
+ * whitespace only from the ends. Widening can never let an unsupported scheme
23
+ * through — the stripped characters cannot spell one out — so it errs in the
24
+ * safe direction. The range covers whitespace as well, so no separate `trim()`
25
+ * is needed.
26
+ */
27
+ // eslint-disable-next-line no-control-regex
28
+ const CONTROL_CHARACTERS = /[\x00-\x20\x7f]/g;
29
+ /**
30
+ * Schemes that are safe to put into an `href` attribute. Everything else —
31
+ * most importantly `javascript:`, but also `data:` and `vbscript:` — can run
32
+ * script in the page's own origin when the link is followed.
33
+ */
34
+ const SAFE_SCHEMES = ['http:', 'https:', 'mailto:', 'tel:'];
35
+ /** Matches a leading URL scheme, e.g. `https:` or `javascript:`. */
36
+ const SCHEME_PATTERN = /^([a-z][a-z0-9+.-]*):/;
37
+ /**
38
+ * Matches the leading slashes of a protocol-relative URL. Backslashes are
39
+ * folded in because browsers treat `\` as `/` while parsing a URL, which makes
40
+ * `\\evil.com` and `/\evil.com` protocol-relative just like `//evil.com`.
41
+ */
42
+ const PROTOCOL_RELATIVE_PATTERN = /^[/\\]{2,}/;
43
+ /**
44
+ * Returns `true` when a mouse event expresses the user's intent to open the
45
+ * target in a new tab or window: middle click, `Cmd` + click (macOS) / `Ctrl` +
46
+ * click (Windows, Linux), or `Shift` + click.
47
+ *
48
+ * `Alt` + click is deliberately **not** included: it is a download gesture
49
+ * rather than a new-tab one, and it does nothing at all for in-app routes, so
50
+ * treating it as browser-handled would swallow the navigation entirely.
51
+ *
52
+ * Call sites must return early **without** calling `preventDefault()` when this
53
+ * returns `true`, so the browser's native anchor behavior takes over. This only
54
+ * works on elements that render as a real `<a href="...">`.
55
+ *
56
+ * The `button` check only matters for a call site that listens to
57
+ * `mousedown`/`auxclick`: a middle click dispatches `auxclick`, not `click`, so
58
+ * React's `onClick` never sees it.
59
+ */
60
+ const isNewTabRequest = (event) => event.button === MIDDLE_MOUSE_BUTTON ||
61
+ event.metaKey ||
62
+ event.ctrlKey ||
63
+ event.shiftKey;
64
+ exports.isNewTabRequest = isNewTabRequest;
65
+ /**
66
+ * Returns `true` when `href` is safe to render as a real `<a href="...">`
67
+ * attribute.
68
+ *
69
+ * Relative URLs (`/products/1`, `products/1`, `#/products/1`, `?page=2`) and
70
+ * the `http`, `https`, `mailto` and `tel` schemes are allowed. Any other scheme
71
+ * — `javascript:` above all — is rejected, since following such a link executes
72
+ * script in the page's own origin. Note that an internal routing key shaped
73
+ * like `catalog:detail` parses as the scheme `catalog:` and is rejected as
74
+ * well.
75
+ *
76
+ * This matters because navigation targets are typically derived from server
77
+ * data: an `href` built from a record field is untrusted input, and a real
78
+ * anchor is followed natively on middle click, `Cmd`/`Ctrl` + click and through
79
+ * the right-click context menu, where no click handler runs to intercept it.
80
+ *
81
+ * An empty or whitespace-only value is rejected as well: `<a href="">` points at
82
+ * the current document and reloads the page.
83
+ *
84
+ * Protocol-relative URLs (`//evil.com`) are rejected too. They carry no scheme,
85
+ * so they read as relative, but the browser resolves them against the current
86
+ * one and navigates off-origin — and no in-app route ever needs that shape.
87
+ * Absolute `http`/`https` URLs stay allowed on purpose: a cross-origin target
88
+ * can be legitimate, so the caller owns that decision — including whether it
89
+ * can carry `rel="noopener"`, which is a per-call-site matter.
90
+ *
91
+ * A rejected value is dropped silently: nothing is logged, and the element
92
+ * renders without an anchor.
93
+ */
94
+ const isSafeHref = (href) => {
95
+ var _a;
96
+ if (!href)
97
+ return false;
98
+ // Normalized the way the browser would parse it, so a value that only looks
99
+ // safe before the noise is stripped cannot get through.
100
+ const normalized = href.replace(CONTROL_CHARACTERS, '').toLowerCase();
101
+ if (!normalized)
102
+ return false;
103
+ // Checked before the scheme, since a protocol-relative URL carries none.
104
+ if (PROTOCOL_RELATIVE_PATTERN.test(normalized))
105
+ return false;
106
+ const scheme = (_a = SCHEME_PATTERN.exec(normalized)) === null || _a === void 0 ? void 0 : _a[1];
107
+ // No scheme at all — a relative URL, resolved against the current document.
108
+ if (!scheme)
109
+ return true;
110
+ return SAFE_SCHEMES.includes(`${scheme}:`);
111
+ };
112
+ exports.isSafeHref = isSafeHref;
@@ -167,3 +167,11 @@
167
167
  opacity: 1 !important;
168
168
  cursor: not-allowed;
169
169
  }
170
+
171
+ /* Declarative action column items (`actionColumn.items`). Keeps the buttons on
172
+ one line instead of letting them wrap in a narrow action column. */
173
+ .akitable-action-items {
174
+ display: inline-flex;
175
+ align-items: center;
176
+ gap: 0.25rem;
177
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"datatable.d.ts","sourceRoot":"","sources":["../../../src/components/datatable.tsx"],"names":[],"mappings":"AAAA,OAAO,iBAAiB,CAAC;AAQzB,OAAO,KAAK,MAAM,OAAO,CAAC;AAI1B,OAAO,EAIL,sBAAsB,EAIvB,MAAM,UAAU,CAAC;AAIlB,eAAO,MAAM,iBAAiB,GAAI,OAAO,sBAAsB,sBA+S9D,CAAC"}
1
+ {"version":3,"file":"datatable.d.ts","sourceRoot":"","sources":["../../../src/components/datatable.tsx"],"names":[],"mappings":"AAAA,OAAO,iBAAiB,CAAC;AAQzB,OAAO,KAAK,MAAM,OAAO,CAAC;AAK1B,OAAO,EAOL,sBAAsB,EAIvB,MAAM,UAAU,CAAC;AAKlB,eAAO,MAAM,iBAAiB,GAAI,OAAO,sBAAsB,sBA2hB9D,CAAC"}
@@ -1,4 +1,15 @@
1
1
  "use strict";
2
+ var __rest = (this && this.__rest) || function (s, e) {
3
+ var t = {};
4
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
5
+ t[p] = s[p];
6
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
7
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
8
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
9
+ t[p[i]] = s[p[i]];
10
+ }
11
+ return t;
12
+ };
2
13
  Object.defineProperty(exports, "__esModule", { value: true });
3
14
  exports.AkitableDataTable = void 0;
4
15
  require("./datatable.css");
@@ -10,6 +21,7 @@ const antd_1 = require("antd");
10
21
  const clsx_1 = require("clsx");
11
22
  const react_1 = require("react");
12
23
  const akitable_context_1 = require("../akitable-context");
24
+ const navigation_1 = require("../common/navigation");
13
25
  const i18n_1 = require("../i18n");
14
26
  const types_1 = require("../types");
15
27
  const EditableCell_1 = require("./EditableCell");
@@ -26,12 +38,46 @@ const AkitableDataTable = (props) => {
26
38
  if (!editingKey)
27
39
  setHasFormErrors(false);
28
40
  }, [editingKey]);
41
+ /** Keys of the misconfiguration reports already made by this instance. */
42
+ const warnedConfigKeysRef = react_1.default.useRef(new Set());
43
+ // Developer-facing misconfiguration reports, stripped from production builds
44
+ // the way antd's own `devUseWarning` is: the messages help nobody in a
45
+ // shipped app, and the guard lets a bundler drop the whole block.
46
+ //
47
+ // Re-checked when the props change rather than only on mount: an
48
+ // `actionColumn` is commonly built from data (permissions, an async config),
49
+ // so it can arrive after the first render. Each report is deduplicated per
50
+ // instance instead, since `actionColumn` is typically an inline object
51
+ // literal whose identity changes on every render.
29
52
  react_1.default.useEffect(() => {
53
+ var _a;
54
+ if (process.env.NODE_ENV === 'production')
55
+ return;
56
+ const warnOnce = (key, message) => {
57
+ if (warnedConfigKeysRef.current.has(key))
58
+ return;
59
+ warnedConfigKeysRef.current.add(key);
60
+ console.warn(message);
61
+ };
30
62
  if (onRowEdit && !actionColumn) {
31
- console.warn('[Akitable] onRowEdit is provided without actionColumn. ' +
63
+ warnOnce('onRowEdit-without-actionColumn', '[Akitable] onRowEdit is provided without actionColumn. ' +
32
64
  'Please migrate to the actionColumn prop to customize the action column.');
33
65
  }
34
- }, []);
66
+ if ((actionColumn === null || actionColumn === void 0 ? void 0 : actionColumn.render) && (actionColumn === null || actionColumn === void 0 ? void 0 : actionColumn.items)) {
67
+ warnOnce('render-and-items', '[Akitable] actionColumn.render and actionColumn.items are both provided. ' +
68
+ 'render takes precedence and items is ignored.');
69
+ }
70
+ if (actionColumn && !actionColumn.render && !actionColumn.items) {
71
+ warnOnce('actionColumn-without-actions', '[Akitable] actionColumn is provided without render or items. ' +
72
+ 'The column renders no actions — and it replaces the built-in edit ' +
73
+ 'column, so there is no way to enter edit mode either. Provide one ' +
74
+ 'of them.');
75
+ }
76
+ if (!onRowEdit && ((_a = actionColumn === null || actionColumn === void 0 ? void 0 : actionColumn.items) === null || _a === void 0 ? void 0 : _a.some(item => item.startEdit))) {
77
+ warnOnce('startEdit-without-onRowEdit', '[Akitable] actionColumn.items contains a startEdit item but onRowEdit ' +
78
+ 'is not provided. Saving the edited row would fail.');
79
+ }
80
+ }, [actionColumn, onRowEdit]);
35
81
  const [isOverflowing, setIsOverflowing] = react_1.default.useState(false);
36
82
  const wrapperRef = react_1.default.useRef(null);
37
83
  const hasActions = actions && actions.length > 0;
@@ -61,6 +107,15 @@ const AkitableDataTable = (props) => {
61
107
  e === null || e === void 0 ? void 0 : e.stopPropagation();
62
108
  },
63
109
  handleSaveEditingItem: async () => {
110
+ // Without `onRowEdit` there is nowhere to save to. Keep the row editable
111
+ // rather than throwing and discarding what was typed, and report the
112
+ // misconfiguration outside production builds.
113
+ if (!onRowEdit) {
114
+ if (process.env.NODE_ENV !== 'production') {
115
+ console.error('[Akitable] Cannot save the edited row because onRowEdit is not provided.');
116
+ }
117
+ return;
118
+ }
64
119
  try {
65
120
  setIsLoadingSaveButton(true);
66
121
  await onRowEdit(record, form.getFieldsValue());
@@ -73,7 +128,121 @@ const AkitableDataTable = (props) => {
73
128
  },
74
129
  handleCancelEditingItem: () => setEditingKey(null)
75
130
  });
76
- const buildActionColumn = (col) => (Object.assign(Object.assign({}, col), { key: '__akitable_action__', render: (record) => col.render(Object.assign({ record, isEditing: checkIsEditingRow(getRowKeyData(record)), isEditLoading: isLoadingSaveButton, hasEditFormError: hasFormErrors }, buildActionHandlers(record))) }));
131
+ /**
132
+ * The save/cancel pair shown while a row is being edited. Shared by the
133
+ * default edit column and the declarative `items` action column so both
134
+ * render an identical editing state.
135
+ */
136
+ const renderEditingActions = ({ handleSaveEditingItem, handleCancelEditingItem }) => (react_1.default.createElement("span", null,
137
+ react_1.default.createElement(ui_button_1.Button, { size: "small", onClick: handleSaveEditingItem, loading: isLoadingSaveButton, disabled: hasFormErrors, className: (0, clsx_1.default)({
138
+ 'akitable-save-button--has-errors': hasFormErrors
139
+ }) }, t('save')),
140
+ react_1.default.createElement(ui_typography_1.Text, Object.assign({ type: "danger", disabled: isLoadingSaveButton, className: "cursor-pointer ml-2" }, (!isLoadingSaveButton && { onClick: handleCancelEditingItem })), t('cancel'))));
141
+ const resolveActionItemValue = (value, record) => typeof value === 'function'
142
+ ? value(record)
143
+ : value;
144
+ const renderActionItem = (item, index, onNavigate, params) => {
145
+ const { record } = params;
146
+ const { key, label, startEdit, href: hrefValue, hidden, disabled: disabledValue, onClick: onItemClick } = item, buttonProps = __rest(item, ["key", "label", "startEdit", "href", "hidden", "disabled", "onClick"]);
147
+ if (resolveActionItemValue(hidden, record))
148
+ return null;
149
+ const resolvedHref = resolveActionItemValue(hrefValue, record);
150
+ const disabled = resolveActionItemValue(disabledValue, record);
151
+ // An `href` is usually derived from record data, so it is validated rather
152
+ // than trusted — see `isSafeHref` for which values are accepted and why
153
+ // rendering a real anchor makes that necessary.
154
+ //
155
+ // A rejected value is dropped silently: it never reaches the anchor, the
156
+ // item degrades to a plain button and nothing is logged. A builder slip
157
+ // like `record => record.detailUrl ?? ''` therefore shows up as a button
158
+ // where a link was expected, not as a console message.
159
+ const href = (0, navigation_1.isSafeHref)(resolvedHref) ? resolvedHref : undefined;
160
+ const handleClick = (event) => {
161
+ // Action buttons live inside a row that may have its own `onRowClick`.
162
+ // The wrapper in `renderActionItems` stops the same propagation for the
163
+ // clicks antd swallows before this handler ever runs; this one covers the
164
+ // ordinary path.
165
+ event.stopPropagation();
166
+ if (startEdit) {
167
+ // A `startEdit` item never becomes an anchor (see the `href` prop
168
+ // below), so there is no default navigation to prevent here.
169
+ params.handleStartEdit(event);
170
+ return;
171
+ }
172
+ if (!href) {
173
+ onItemClick === null || onItemClick === void 0 ? void 0 : onItemClick(record, event);
174
+ return;
175
+ }
176
+ // New tab request (middle click, Cmd/Ctrl/Shift + click) — hand over to
177
+ // the browser. Never `preventDefault()` here, otherwise the native
178
+ // anchor behavior is destroyed.
179
+ if ((0, navigation_1.isNewTabRequest)(event) || event.defaultPrevented)
180
+ return;
181
+ // A per-item `onClick` wins over the column level `onNavigate` so a
182
+ // single item can route itself.
183
+ const navigate = onItemClick
184
+ ? () => onItemClick(record, event)
185
+ : onNavigate
186
+ ? () => onNavigate(href, record, event)
187
+ : undefined;
188
+ // With no handler there is nothing to intercept: let the browser follow
189
+ // the `href` instead of leaving a dead link behind.
190
+ if (!navigate)
191
+ return;
192
+ event.preventDefault();
193
+ navigate();
194
+ };
195
+ return (react_1.default.createElement(ui_button_1.Button, Object.assign({}, buttonProps, { key: key !== null && key !== void 0 ? key : index,
196
+ // An `href` makes `@akinon/ui-button` render a real <a href>, which is
197
+ // what enables the browser's native new-tab gestures.
198
+ //
199
+ // Neither a disabled, a loading nor a `startEdit` item may become an
200
+ // anchor. antd swallows the click handler of a disabled Button, so
201
+ // `stopPropagation` would never run and the click would bubble up to
202
+ // `onRowClick`; a real <button disabled> dispatches no click at all.
203
+ //
204
+ // A loading Button is swallowed the same way but is *not* marked
205
+ // disabled in the DOM, so an anchor left in place would take a click
206
+ // that reaches neither the item nor `preventDefault` — antd calls that
207
+ // itself — leaving a dead left click whose event still bubbles to
208
+ // `onRowClick`, while a middle click would keep opening a new tab. That
209
+ // is exactly the "two gestures, two targets" split this API exists to
210
+ // avoid. `loading` is read as truthy, so the `{ delay }` form drops the
211
+ // anchor from the start rather than once the delay elapses.
212
+ //
213
+ // And a `startEdit` item is not a navigation target, so an anchor would
214
+ // be followed by the browser on a middle click or through the context
215
+ // menu — gestures that dispatch `auxclick` or no event at all, leaving
216
+ // no handler to stop.
217
+ href: disabled || startEdit || item.loading ? undefined : href, disabled: disabled, size: "small", onClick: handleClick }), label));
218
+ };
219
+ const renderActionItems = (items, onNavigate, params) => {
220
+ if (params.isEditing)
221
+ return renderEditingActions(params);
222
+ return (react_1.default.createElement("span", { className: "akitable-action-items",
223
+ // Safety net for the row's own `onRowClick`: antd never calls a
224
+ // Button's `onClick` while it is loading or disabled, so an item's own
225
+ // `stopPropagation` cannot be relied on to keep the click from bubbling
226
+ // up to the row.
227
+ onClick: event => event.stopPropagation() }, items.map((item, index) => renderActionItem(item, index, onNavigate, params))));
228
+ };
229
+ const buildActionColumn = (col) => {
230
+ // `items`/`onNavigate` are Akinon-only fields; keep them out of the object
231
+ // that is handed to antd's `Table` as a column definition.
232
+ const { items, onNavigate, render } = col, columnProps = __rest(col, ["items", "onNavigate", "render"]);
233
+ return Object.assign(Object.assign({}, columnProps), { key: '__akitable_action__',
234
+ // rc-table passes the whole record as the first argument when the column
235
+ // has no `dataIndex`.
236
+ render: (record) => {
237
+ const params = Object.assign({ record, isEditing: checkIsEditingRow(getRowKeyData(record)), isEditLoading: isLoadingSaveButton, hasEditFormError: hasFormErrors }, buildActionHandlers(record));
238
+ // `render` wins over `items` so existing consumers are untouched.
239
+ if (render)
240
+ return render(params);
241
+ if (items)
242
+ return renderActionItems(items, onNavigate, params);
243
+ return null;
244
+ } });
245
+ };
77
246
  const handleFormFieldsChange = (_, allFields) => {
78
247
  setHasFormErrors(allFields.some(f => { var _a, _b; return ((_b = (_a = f.errors) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) > 0; }));
79
248
  };
@@ -84,11 +253,10 @@ const AkitableDataTable = (props) => {
84
253
  render: (record) => {
85
254
  const isRowEditing = checkIsEditingRow(getRowKeyData(record));
86
255
  const { handleStartEdit, handleSaveEditingItem, handleCancelEditingItem } = buildActionHandlers(record);
87
- return isRowEditing ? (react_1.default.createElement("span", null,
88
- react_1.default.createElement(ui_button_1.Button, { size: "small", onClick: handleSaveEditingItem, loading: isLoadingSaveButton, disabled: hasFormErrors, className: (0, clsx_1.default)({
89
- 'akitable-save-button--has-errors': hasFormErrors
90
- }) }, t('save')),
91
- react_1.default.createElement(ui_typography_1.Text, Object.assign({ type: "danger", disabled: isLoadingSaveButton, className: "cursor-pointer ml-2" }, (!isLoadingSaveButton && { onClick: handleCancelEditingItem })), t('cancel')))) : (react_1.default.createElement(ui_button_1.Button, { type: "primary", size: "small", onClick: handleStartEdit, className: (0, clsx_1.default)({
256
+ return isRowEditing ? (renderEditingActions({
257
+ handleSaveEditingItem,
258
+ handleCancelEditingItem
259
+ })) : (react_1.default.createElement(ui_button_1.Button, { type: "primary", size: "small", onClick: handleStartEdit, className: (0, clsx_1.default)({
92
260
  'akinon-table-edit-button--disable': isLoadingSaveButton
93
261
  }) }, t('edit')));
94
262
  },
@@ -1 +1 @@
1
- {"version":3,"file":"pagination.d.ts","sourceRoot":"","sources":["../../../src/components/pagination.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,MAAM,OAAO,CAAC;AAO1B,eAAO,MAAM,kBAAkB,yBA4C9B,CAAC"}
1
+ {"version":3,"file":"pagination.d.ts","sourceRoot":"","sources":["../../../src/components/pagination.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,MAAM,OAAO,CAAC;AA6B1B,eAAO,MAAM,kBAAkB,yBA8C9B,CAAC"}
@@ -4,12 +4,25 @@ exports.AkitablePagination = void 0;
4
4
  const ui_pagination_1 = require("@akinon/ui-pagination");
5
5
  const react_1 = require("react");
6
6
  const akitable_context_1 = require("../akitable-context");
7
+ const constants_1 = require("../constants");
7
8
  const i18n_1 = require("../i18n");
8
- const PAGE_SIZE_OPTIONS = [20, 50, 100, 250];
9
+ const resolvePageSizeOptions = (requestedOptions) => [
10
+ ...new Set((requestedOptions === null || requestedOptions === void 0 ? void 0 : requestedOptions.length)
11
+ ? requestedOptions
12
+ : constants_1.AKITABLE_DEFAULT_PAGE_SIZE_OPTIONS)
13
+ ];
14
+ const resolvePageSize = (requestedSize, pageSizeOptions) => {
15
+ const isPositiveInteger = typeof requestedSize === 'number' &&
16
+ Number.isInteger(requestedSize) &&
17
+ requestedSize > 0;
18
+ return isPositiveInteger ? requestedSize : pageSizeOptions[0];
19
+ };
9
20
  const AkitablePagination = () => {
10
21
  const { isLoading, pagination, onPaginationChanged, paginationShowTotal } = (0, akitable_context_1.useAkitableContext)();
11
22
  const { t } = i18n_1.i18n;
12
23
  const totalPagination = (pagination === null || pagination === void 0 ? void 0 : pagination.total) || 0;
24
+ const pageSizeOptions = resolvePageSizeOptions(pagination === null || pagination === void 0 ? void 0 : pagination.pageSizeOptions);
25
+ const pageSize = resolvePageSize(pagination === null || pagination === void 0 ? void 0 : pagination.size, pageSizeOptions);
13
26
  const hasItems = totalPagination > 0;
14
27
  const locale = {
15
28
  items_per_page: t('itemsPerPage')
@@ -29,6 +42,6 @@ const AkitablePagination = () => {
29
42
  return null;
30
43
  };
31
44
  return (react_1.default.createElement("div", { className: "akitable-pagination" },
32
- react_1.default.createElement(ui_pagination_1.Pagination, { disabled: isLoading || !hasItems, current: pagination === null || pagination === void 0 ? void 0 : pagination.page, pageSize: pagination === null || pagination === void 0 ? void 0 : pagination.size, pageSizeOptions: PAGE_SIZE_OPTIONS, total: totalPagination, showTotal: handleShowTotal, showSizeChanger: true, onChange: handleOnChange, locale: locale, theme: "light" })));
45
+ react_1.default.createElement(ui_pagination_1.Pagination, { disabled: isLoading || !hasItems, current: pagination === null || pagination === void 0 ? void 0 : pagination.page, pageSize: pageSize, pageSizeOptions: pageSizeOptions, total: totalPagination, showTotal: handleShowTotal, showSizeChanger: true, onChange: handleOnChange, locale: locale, theme: "light" })));
33
46
  };
34
47
  exports.AkitablePagination = AkitablePagination;
@@ -0,0 +1,3 @@
1
+ import type { AkitablePageSizes } from './types';
2
+ export declare const AKITABLE_DEFAULT_PAGE_SIZE_OPTIONS: readonly AkitablePageSizes[];
3
+ //# sourceMappingURL=constants.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../src/constants.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAEjD,eAAO,MAAM,kCAAkC,EAAE,SAAS,iBAAiB,EACxC,CAAC"}
@@ -0,0 +1,4 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AKITABLE_DEFAULT_PAGE_SIZE_OPTIONS = void 0;
4
+ exports.AKITABLE_DEFAULT_PAGE_SIZE_OPTIONS = Object.freeze([20, 50, 100, 250]);
@@ -1,3 +1,4 @@
1
1
  export * from './akitable';
2
+ export * from './constants';
2
3
  export * from './types';
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,SAAS,CAAC"}
package/dist/cjs/index.js CHANGED
@@ -15,4 +15,5 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./akitable"), exports);
18
+ __exportStar(require("./constants"), exports);
18
19
  __exportStar(require("./types"), exports);
@@ -1,3 +1,4 @@
1
+ import type { IButtonProps } from '@akinon/ui-button';
1
2
  import type { InputProps } from '@akinon/ui-input';
2
3
  import { FormItemProps, TableColumnType } from 'antd';
3
4
  import React, { ReactNode } from 'react';
@@ -30,6 +31,63 @@ export interface AkitableActionColumnRenderParams {
30
31
  /** Cancels the current edit and exits edit mode without saving. */
31
32
  handleCancelEditingItem: () => void;
32
33
  }
34
+ /** A value that is either static or derived from the row it belongs to. */
35
+ export type AkitableActionItemValue<T> = T | ((record: AkitableData) => T);
36
+ /**
37
+ * A single declarative action rendered in the action column. Use it instead of
38
+ * a custom `render` when you want navigating actions to open in a new tab on
39
+ * middle click, `Cmd`/`Ctrl` + click or through the right-click context menu.
40
+ *
41
+ * Every `@akinon/ui-button` prop (`icon`, `iconSize`, `type`, `danger`,
42
+ * `tooltip`, `loading`, …) is accepted and forwarded to the rendered button.
43
+ * The exceptions are the ones this API redefines below, plus `size`, which the
44
+ * action column fixes to `small`.
45
+ */
46
+ export interface AkitableActionItem extends Omit<IButtonProps, 'children' | 'disabled' | 'hidden' | 'href' | 'onClick' | 'size'> {
47
+ /** Stable key for the item. Falls back to the array index. */
48
+ key?: string;
49
+ /** Button label. Omit for icon-only buttons. */
50
+ label?: React.ReactNode;
51
+ /**
52
+ * Navigation target. When provided the item renders as a real `<a href>`, so
53
+ * middle click, `Cmd`/`Ctrl` + click and the right-click context menu open it
54
+ * in a new tab through native browser behavior.
55
+ *
56
+ * A plain left click is intercepted and delegated to this item's `onClick`
57
+ * if it has one, otherwise to the column's `onNavigate`. When neither is
58
+ * defined, the click is left alone and the browser follows the `href` as a
59
+ * regular link.
60
+ *
61
+ * No `target` is set on the anchor — that would make a plain left click open
62
+ * a new tab as well. The value must be a URL the browser can resolve on its
63
+ * own: a relative URL (`/products/1`, `products/1`, `#/products/1`, `?page=2`)
64
+ * or an `http`, `https`, `mailto` or `tel` one. Anything else is rejected —
65
+ * `javascript:` above all, but also a protocol-relative URL (`//evil.com`),
66
+ * an empty string, and an internal routing key shaped like `catalog:detail`,
67
+ * which parses as the scheme `catalog:`.
68
+ *
69
+ * A rejected value is dropped silently — nothing is logged and the item
70
+ * renders as a plain button — so check the values a builder can produce
71
+ * rather than waiting for a console message. An `href` is ignored altogether
72
+ * for a `startEdit` item.
73
+ */
74
+ href?: AkitableActionItemValue<string>;
75
+ /**
76
+ * Click handler. For a non-navigating action (delete, toggles, …) this is the
77
+ * action itself. For an item that also has an `href` it is the per-item
78
+ * navigation handler and takes precedence over the column's `onNavigate`.
79
+ */
80
+ onClick?: (record: AkitableData, event: React.MouseEvent) => void;
81
+ /**
82
+ * Puts the row into edit mode. Shorthand for `handleStartEdit`. Requires the
83
+ * table's `onRowEdit` to be defined, since that is what saves the row.
84
+ */
85
+ startEdit?: boolean;
86
+ /** Whether the button is disabled. */
87
+ disabled?: AkitableActionItemValue<boolean>;
88
+ /** When `true` the item is not rendered for that row. */
89
+ hidden?: AkitableActionItemValue<boolean>;
90
+ }
33
91
  export interface AkitableActionColumn {
34
92
  /** Column header content. */
35
93
  title?: React.ReactNode;
@@ -41,8 +99,35 @@ export interface AkitableActionColumn {
41
99
  className?: string;
42
100
  /** Text alignment within the column. */
43
101
  align?: 'left' | 'right' | 'center';
44
- /** Render function receiving row edit state and handler functions. */
45
- render: (params: AkitableActionColumnRenderParams) => React.ReactNode;
102
+ /**
103
+ * Render function receiving row edit state and handler functions.
104
+ *
105
+ * Takes precedence over `items` when both are provided, so existing usages
106
+ * keep working unchanged. Note that anything rendered here is opaque to
107
+ * Akitable: to get new-tab support for navigating actions, either use `items`
108
+ * or render your own real `<a href>` elements.
109
+ */
110
+ render?: (params: AkitableActionColumnRenderParams) => React.ReactNode;
111
+ /**
112
+ * Declarative action items. Items with an `href` render as real anchors, so
113
+ * middle click, `Cmd`/`Ctrl` + click and the right-click context menu open
114
+ * them in a new tab.
115
+ *
116
+ * Ignored when `render` is also provided.
117
+ */
118
+ items?: AkitableActionItem[];
119
+ /**
120
+ * Called on a plain left click of an item that has an `href`, after
121
+ * `preventDefault()`. Perform the client-side navigation here (e.g.
122
+ * react-router's `navigate`). Not called for new-tab requests, nor for an
123
+ * item that defines its own `onClick`.
124
+ *
125
+ * The `href` is not necessarily an in-app route: an absolute `http(s)` URL
126
+ * and the `mailto:` / `tel:` schemes are accepted as well. Handing such a
127
+ * value straight to a client-side router would break the navigation, so
128
+ * check for them here before routing.
129
+ */
130
+ onNavigate?: (href: string, record: AkitableData, event: React.MouseEvent) => void;
46
131
  }
47
132
  export interface AkitableProps {
48
133
  /**
@@ -247,7 +332,12 @@ export interface AkitableEmptyStateProps {
247
332
  */
248
333
  description?: ReactNode;
249
334
  }
250
- export type AkitablePageSizes = 20 | 50 | 100 | 250;
335
+ /**
336
+ * Number of rows per page. The default options are kept as literals so editors
337
+ * still suggest them, but any number is accepted so apps can supply their own
338
+ * `pageSizeOptions`.
339
+ */
340
+ export type AkitablePageSizes = 20 | 50 | 100 | 250 | (number & NonNullable<unknown>);
251
341
  export interface AkitablePaginationProps {
252
342
  /**
253
343
  * Current page number.
@@ -257,6 +347,12 @@ export interface AkitablePaginationProps {
257
347
  * Number of rows per page.
258
348
  */
259
349
  size: AkitablePageSizes;
350
+ /**
351
+ * Page sizes offered by the "items per page" dropdown. Defaults to
352
+ * `AKITABLE_DEFAULT_PAGE_SIZE_OPTIONS` ([20, 50, 100, 250]) when omitted or
353
+ * empty, so existing tables keep their current options.
354
+ */
355
+ pageSizeOptions?: readonly AkitablePageSizes[];
260
356
  }
261
357
  export interface CustomEditRenderParams {
262
358
  /** The data record for the current row. */