@kahitsan/ksui 0.32.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -1
- package/src/components/base/AccountAvatar.tsx +1 -1
- package/src/components/base/AddAttachmentTile.tsx +4 -4
- package/src/components/base/BadgeSelect.tsx +10 -10
- package/src/components/base/Button.tsx +70 -38
- package/src/components/base/CameraCapture.tsx +8 -8
- package/src/components/base/ChartLegend.tsx +3 -3
- package/src/components/base/CopyButton.tsx +1 -1
- package/src/components/base/DataTable.tsx +68 -72
- package/src/components/base/DatePicker.tsx +77 -82
- package/src/components/base/DateTile.tsx +7 -7
- package/src/components/base/DetailRow.tsx +2 -2
- package/src/components/base/Dropdown.tsx +15 -15
- package/src/components/base/ExistingAttachmentTile.tsx +6 -6
- package/src/components/base/EyebrowBadge.tsx +15 -15
- package/src/components/base/FormErrorBanner.tsx +1 -1
- package/src/components/base/FormField.tsx +2 -2
- package/src/components/base/ImageCropper.tsx +19 -19
- package/src/components/base/ImageViewer.tsx +7 -11
- package/src/components/base/KpiCard.tsx +7 -7
- package/src/components/base/Modal.tsx +10 -14
- package/src/components/base/ProgressBar.tsx +63 -59
- package/src/components/base/RadioCardGroup.tsx +13 -6
- package/src/components/base/SectionHeading.tsx +4 -4
- package/src/components/base/SegmentedFilter.tsx +60 -4
- package/src/components/base/SocialLinksBar.tsx +3 -15
- package/src/components/base/StatusIndicator.tsx +11 -11
- package/src/components/base/StatusPill.tsx +15 -15
- package/src/components/base/TagPill.tsx +1 -1
- package/src/components/base/ThemeToggle.tsx +12 -20
- package/src/components/base/Tooltip.tsx +2 -2
- package/src/components/composite/AccountRadioPicker.tsx +8 -4
- package/src/components/composite/ComboBox.tsx +51 -44
- package/src/components/composite/CustomRenderer.tsx +6 -13
- package/src/components/composite/FileField.tsx +10 -17
- package/src/components/composite/FlowGraph.tsx +29 -36
- package/src/components/composite/FlowRunner.tsx +11 -18
- package/src/components/composite/FormAdvancedSection.tsx +31 -31
- package/src/components/composite/LiveTimer.tsx +3 -3
- package/src/components/composite/MarkdownNotes.tsx +11 -11
- package/src/components/composite/MentionTextarea.tsx +10 -10
- package/src/components/composite/NotFound.tsx +7 -7
- package/src/components/composite/PaymentAccountPicker.tsx +19 -19
- package/src/components/composite/SalesBodyEditor.tsx +26 -26
- package/src/components/composite/SearchableSelect.tsx +14 -14
- package/src/components/composite/SecretReveal.tsx +5 -5
- package/src/components/composite/TransactionAccountFields.tsx +78 -0
- package/src/components/composite/TransactionForm.tsx +83 -169
- package/src/components/composite/TransactionPayableFields.tsx +97 -0
- package/src/components/composite/TransferAccountsPicker.tsx +20 -20
- package/src/components/composite/TransferFeeChip.tsx +2 -2
- package/src/components/composite/VoucherPicker.tsx +25 -25
- package/src/components/composite/resource/ResourceForm.tsx +1 -1
- package/src/components/composite/resource/ResourcePage.tsx +8 -8
- package/src/components/composite/resource/cells.tsx +6 -3
- package/src/components/composite/resource/form-spec.ts +141 -0
- package/src/components/composite/resource/route-adapter.ts +63 -0
- package/src/components/composite/resource/route-spec.test.ts +125 -0
- package/src/components/composite/resource/route-spec.ts +158 -0
- package/src/index.ts +8 -0
- package/src/utils/INPUT_CLASS.ts +6 -3
- package/src/utils/account-icons.ts +29 -7
- package/src/utils/highlight.tsx +5 -6
- package/src/utils/inject-css.ts +13 -0
- package/tailwind.js +11 -7
|
@@ -40,11 +40,11 @@ function AccountTile(p: AccountTileProps): JSX.Element {
|
|
|
40
40
|
onClick={p.onEditRequest}
|
|
41
41
|
class={`group flex flex-1 min-w-0 items-center gap-3 border px-4 py-3 text-left transition-colors cursor-pointer ${p.clipClass}`}
|
|
42
42
|
classList={{
|
|
43
|
-
"border-
|
|
43
|
+
"border-[color-mix(in_srgb,var(--ks-info,#38bdf8)_40%,transparent)] bg-[color-mix(in_srgb,var(--ks-info,#38bdf8)_5%,transparent)] hover:bg-[color-mix(in_srgb,var(--ks-info,#38bdf8)_10%,transparent)]":
|
|
44
44
|
isFilled() && isFrom(),
|
|
45
|
-
"border-
|
|
45
|
+
"border-[color-mix(in_srgb,var(--ks-warning,#f59e0b)_40%,transparent)] bg-[color-mix(in_srgb,var(--ks-warning,#f59e0b)_5%,transparent)] hover:bg-[color-mix(in_srgb,var(--ks-warning,#f59e0b)_10%,transparent)]":
|
|
46
46
|
isFilled() && !isFrom(),
|
|
47
|
-
"border-dashed border-
|
|
47
|
+
"border-dashed border-[var(--ks-input-border,#3f3f46)] bg-[color-mix(in_srgb,var(--ks-overlay-surface,#18181b)_40%,transparent)] hover:border-[color-mix(in_srgb,var(--ks-warning,#f59e0b)_50%,transparent)] hover:bg-[color-mix(in_srgb,var(--ks-overlay-surface,#18181b)_60%,transparent)]":
|
|
48
48
|
!isFilled(),
|
|
49
49
|
}}
|
|
50
50
|
aria-label={
|
|
@@ -57,7 +57,7 @@ function AccountTile(p: AccountTileProps): JSX.Element {
|
|
|
57
57
|
when={p.account}
|
|
58
58
|
keyed
|
|
59
59
|
fallback={
|
|
60
|
-
<span class="flex h-8 w-8 items-center justify-center text-
|
|
60
|
+
<span class="flex h-8 w-8 items-center justify-center text-[var(--ks-fg-subtle,#71717a)]">
|
|
61
61
|
<Plus size={18} />
|
|
62
62
|
</span>
|
|
63
63
|
}
|
|
@@ -66,7 +66,7 @@ function AccountTile(p: AccountTileProps): JSX.Element {
|
|
|
66
66
|
<AccountAvatar
|
|
67
67
|
account={a}
|
|
68
68
|
size={28}
|
|
69
|
-
iconClass={isFrom() ? "text-
|
|
69
|
+
iconClass={isFrom() ? "text-[var(--ks-info,#38bdf8)]" : "text-[var(--ks-accent-hover,#fcd34d)]"}
|
|
70
70
|
/>
|
|
71
71
|
)}
|
|
72
72
|
</Show>
|
|
@@ -74,9 +74,9 @@ function AccountTile(p: AccountTileProps): JSX.Element {
|
|
|
74
74
|
<span
|
|
75
75
|
class="text-[10px] font-semibold uppercase tracking-widest"
|
|
76
76
|
classList={{
|
|
77
|
-
"text-
|
|
78
|
-
"text-
|
|
79
|
-
"text-
|
|
77
|
+
"text-[var(--ks-info,#38bdf8)]": isFilled() && isFrom(),
|
|
78
|
+
"text-[var(--ks-accent-hover,#fcd34d)]": isFilled() && !isFrom(),
|
|
79
|
+
"text-[var(--ks-fg-subtle,#71717a)]": !isFilled(),
|
|
80
80
|
}}
|
|
81
81
|
>
|
|
82
82
|
{isFrom() ? "From" : "To"}
|
|
@@ -85,23 +85,23 @@ function AccountTile(p: AccountTileProps): JSX.Element {
|
|
|
85
85
|
when={p.account}
|
|
86
86
|
keyed
|
|
87
87
|
fallback={
|
|
88
|
-
<span class="text-sm text-
|
|
88
|
+
<span class="text-sm text-[var(--ks-fg-subtle,#71717a)]">{p.emptyHint}</span>
|
|
89
89
|
}
|
|
90
90
|
>
|
|
91
91
|
{(a) => (
|
|
92
92
|
<>
|
|
93
|
-
<span class="truncate text-sm font-semibold text-
|
|
93
|
+
<span class="truncate text-sm font-semibold text-[var(--ks-fg,#ffffff)]">
|
|
94
94
|
{a.name}
|
|
95
95
|
</span>
|
|
96
96
|
<Show when={a.balance != null}>
|
|
97
|
-
<span class="flex items-baseline gap-1 text-[11px] tabular-nums text-
|
|
97
|
+
<span class="flex items-baseline gap-1 text-[11px] tabular-nums text-[var(--ks-fg-muted,#a1a1aa)]">
|
|
98
98
|
<span class="truncate">{formatPHP(a.balance!)}</span>
|
|
99
99
|
<Show when={p.delta !== 0}>
|
|
100
100
|
<span
|
|
101
101
|
class="whitespace-nowrap font-semibold"
|
|
102
102
|
classList={{
|
|
103
|
-
"text-
|
|
104
|
-
"text-
|
|
103
|
+
"text-[var(--ks-danger-fg,#f87171)]": p.delta < 0,
|
|
104
|
+
"text-[var(--ks-success-fg,#34d399)]": p.delta > 0,
|
|
105
105
|
}}
|
|
106
106
|
data-testid={`transactions-form-transfer-delta-${p.role}`}
|
|
107
107
|
>
|
|
@@ -168,7 +168,7 @@ export default function TransferAccountsPicker(
|
|
|
168
168
|
type="button"
|
|
169
169
|
data-testid="transactions-form-transfer-swap"
|
|
170
170
|
onClick={swap}
|
|
171
|
-
class="flex h-9 w-9 shrink-0 items-center justify-center self-center border border-
|
|
171
|
+
class="flex h-9 w-9 shrink-0 items-center justify-center self-center border border-[var(--ks-input-border,#3f3f46)] bg-[var(--ks-input-bg,#18181b)] text-[var(--ks-fg,#ffffff)] transition-colors hover:border-[color-mix(in_srgb,var(--ks-warning,#f59e0b)_60%,transparent)] hover:text-[var(--ks-accent-hover,#fcd34d)] cursor-pointer ks-hud-clip-button max-sm:rotate-90"
|
|
172
172
|
aria-label="Swap source and destination"
|
|
173
173
|
title="Swap source and destination"
|
|
174
174
|
>
|
|
@@ -193,8 +193,8 @@ export default function TransferAccountsPicker(
|
|
|
193
193
|
x2="56"
|
|
194
194
|
y2="7"
|
|
195
195
|
>
|
|
196
|
-
<stop offset="0" stop-color
|
|
197
|
-
<stop offset="1" stop-color
|
|
196
|
+
<stop offset="0" style={{ "stop-color": "var(--ks-info, #38bdf8)" }} />
|
|
197
|
+
<stop offset="1" style={{ "stop-color": "var(--ks-warning, #f59e0b)" }} />
|
|
198
198
|
</linearGradient>
|
|
199
199
|
</defs>
|
|
200
200
|
<line
|
|
@@ -206,7 +206,7 @@ export default function TransferAccountsPicker(
|
|
|
206
206
|
stroke-width="2"
|
|
207
207
|
class="fin-flow-dash"
|
|
208
208
|
/>
|
|
209
|
-
<path d="M44 1 L56 7 L44 13 Z" fill
|
|
209
|
+
<path d="M44 1 L56 7 L44 13 Z" style={{ fill: "var(--ks-warning, #f59e0b)" }} />
|
|
210
210
|
</svg>
|
|
211
211
|
<svg
|
|
212
212
|
viewBox="0 0 14 40"
|
|
@@ -221,8 +221,8 @@ export default function TransferAccountsPicker(
|
|
|
221
221
|
x2="7"
|
|
222
222
|
y2="40"
|
|
223
223
|
>
|
|
224
|
-
<stop offset="0" stop-color
|
|
225
|
-
<stop offset="1" stop-color
|
|
224
|
+
<stop offset="0" style={{ "stop-color": "var(--ks-info, #38bdf8)" }} />
|
|
225
|
+
<stop offset="1" style={{ "stop-color": "var(--ks-warning, #f59e0b)" }} />
|
|
226
226
|
</linearGradient>
|
|
227
227
|
</defs>
|
|
228
228
|
<line
|
|
@@ -234,7 +234,7 @@ export default function TransferAccountsPicker(
|
|
|
234
234
|
stroke-width="2"
|
|
235
235
|
class="fin-flow-dash"
|
|
236
236
|
/>
|
|
237
|
-
<path d="M1 30 L13 30 L7 40 Z" fill
|
|
237
|
+
<path d="M1 30 L13 30 L7 40 Z" style={{ fill: "var(--ks-warning, #f59e0b)" }} />
|
|
238
238
|
</svg>
|
|
239
239
|
</div>
|
|
240
240
|
</Show>
|
|
@@ -13,9 +13,9 @@ export default function TransferFeeChip(props: TransferFeeChipProps) {
|
|
|
13
13
|
onClick={props.onToggle}
|
|
14
14
|
class="flex items-center gap-1.5 self-center rounded-md border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider transition-colors cursor-pointer"
|
|
15
15
|
classList={{
|
|
16
|
-
"border-
|
|
16
|
+
"border-[color-mix(in_srgb,var(--ks-info,#38bdf8)_50%,transparent)] bg-[color-mix(in_srgb,var(--ks-info,#38bdf8)_15%,transparent)] text-[var(--ks-info-fg,#7dd3fc)] hover:bg-[color-mix(in_srgb,var(--ks-info,#38bdf8)_25%,transparent)]":
|
|
17
17
|
props.enabled,
|
|
18
|
-
"border-
|
|
18
|
+
"border-[var(--ks-input-border,#3f3f46)] bg-[color-mix(in_srgb,var(--ks-surface,#0f0f0f)_60%,transparent)] text-[var(--ks-fg-muted,#a1a1aa)] hover:border-[color-mix(in_srgb,var(--ks-info,#38bdf8)_40%,transparent)] hover:text-[var(--ks-info-fg,#7dd3fc)]":
|
|
19
19
|
!props.enabled,
|
|
20
20
|
}}
|
|
21
21
|
aria-pressed={props.enabled}
|
|
@@ -229,15 +229,15 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
|
|
|
229
229
|
onClick={() => !props.disabled && setOpen((o) => !o)}
|
|
230
230
|
class={`${props.compact ? "inline-flex" : "w-full flex"} items-center gap-2 ${
|
|
231
231
|
props.compact ? "px-2.5 py-2" : "px-3 py-2.5"
|
|
232
|
-
} rounded-lg bg-
|
|
232
|
+
} rounded-lg bg-[color-mix(in_srgb,var(--ks-border,rgba(39,39,42,0.5))_30%,transparent)] border border-[color-mix(in_srgb,var(--ks-border-strong,#3f3f46)_50%,transparent)] hover:border-[color-mix(in_srgb,var(--ks-primary,#c9a961)_40%,transparent)] hover:bg-[color-mix(in_srgb,var(--ks-primary,#c9a961)_5%,transparent)] transition-colors text-sm text-left cursor-pointer disabled:cursor-not-allowed disabled:opacity-60`}
|
|
233
233
|
aria-haspopup="listbox"
|
|
234
234
|
aria-expanded={open()}
|
|
235
235
|
>
|
|
236
|
-
<Ticket size={16} class="shrink-0 text-
|
|
237
|
-
<Show when={props.selected} fallback={<span class="text-
|
|
236
|
+
<Ticket size={16} class="shrink-0 text-[var(--ks-fg-muted,#a1a1aa)]" />
|
|
237
|
+
<Show when={props.selected} fallback={<span class="text-[var(--ks-fg-subtle,#71717a)] italic">No voucher</span>}>
|
|
238
238
|
<span class="flex-1 min-w-0">
|
|
239
|
-
<span class="block truncate text-
|
|
240
|
-
<span class="block truncate text-[11px] text-
|
|
239
|
+
<span class="block truncate text-[var(--ks-fg,#ffffff)] font-medium">{props.selected!.code}</span>
|
|
240
|
+
<span class="block truncate text-[11px] text-[var(--ks-success-fg,#34d399)]">
|
|
241
241
|
{formatVoucherDescription(props.selected!)}
|
|
242
242
|
<Show when={previewDiscount() > 0}> · {formatCurrency(previewDiscount())} off</Show>
|
|
243
243
|
</span>
|
|
@@ -246,7 +246,7 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
|
|
|
246
246
|
type="button"
|
|
247
247
|
data-testid="voucher-picker-clear"
|
|
248
248
|
onClick={clear}
|
|
249
|
-
class="shrink-0 p-1 rounded text-
|
|
249
|
+
class="shrink-0 p-1 rounded text-[var(--ks-fg-subtle,#71717a)] hover:text-[var(--ks-danger-fg,#f87171)] hover:bg-[color-mix(in_srgb,var(--ks-danger,#ef4444)_10%,transparent)] transition-colors cursor-pointer"
|
|
250
250
|
title="Remove voucher"
|
|
251
251
|
aria-label="Remove voucher"
|
|
252
252
|
>
|
|
@@ -260,14 +260,14 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
|
|
|
260
260
|
<div
|
|
261
261
|
ref={popupRef}
|
|
262
262
|
data-testid="voucher-picker-popup"
|
|
263
|
-
class="z-[100] rounded-md border border-
|
|
263
|
+
class="z-[100] rounded-md border border-[var(--ks-input-border,#3f3f46)] bg-[color-mix(in_srgb,var(--ks-overlay-surface,#18181b)_95%,transparent)] backdrop-blur shadow-xl overflow-hidden flex flex-col"
|
|
264
264
|
style={popupStyle()}
|
|
265
265
|
>
|
|
266
|
-
<div class="px-3 py-2 border-b border-
|
|
267
|
-
<Ticket size={14} class="text-
|
|
268
|
-
<span class="text-xs uppercase tracking-widest text-
|
|
266
|
+
<div class="px-3 py-2 border-b border-[var(--ks-border-strong,#3f3f46)] flex items-center gap-2">
|
|
267
|
+
<Ticket size={14} class="text-[var(--ks-fg-subtle,#71717a)] shrink-0" />
|
|
268
|
+
<span class="text-xs uppercase tracking-widest text-[var(--ks-fg-subtle,#71717a)] font-bold">Vouchers</span>
|
|
269
269
|
<Show when={loading()}>
|
|
270
|
-
<Loader2 size={14} class="animate-spin text-
|
|
270
|
+
<Loader2 size={14} class="animate-spin text-[var(--ks-fg-subtle,#71717a)] ml-auto shrink-0" />
|
|
271
271
|
</Show>
|
|
272
272
|
</div>
|
|
273
273
|
<ul
|
|
@@ -277,7 +277,7 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
|
|
|
277
277
|
>
|
|
278
278
|
<Show when={error()}>
|
|
279
279
|
<li>
|
|
280
|
-
<div role="status" class="px-3 py-2 text-xs text-
|
|
280
|
+
<div role="status" class="px-3 py-2 text-xs text-[var(--ks-danger-fg,#f87171)]">
|
|
281
281
|
{error()}
|
|
282
282
|
</div>
|
|
283
283
|
</li>
|
|
@@ -286,7 +286,7 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
|
|
|
286
286
|
when={!loading() && !error() && applicable().length === 0 && inapplicable().length === 0}
|
|
287
287
|
>
|
|
288
288
|
<li>
|
|
289
|
-
<div role="status" class="px-3 py-4 text-xs text-
|
|
289
|
+
<div role="status" class="px-3 py-4 text-xs text-[var(--ks-fg-subtle,#71717a)] text-center">
|
|
290
290
|
No vouchers available.
|
|
291
291
|
</div>
|
|
292
292
|
</li>
|
|
@@ -301,20 +301,20 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
|
|
|
301
301
|
type="button"
|
|
302
302
|
data-testid={`voucher-picker-result-${v.id}`}
|
|
303
303
|
onClick={() => select(v)}
|
|
304
|
-
class="w-full text-left px-3 py-2 hover:bg-
|
|
304
|
+
class="w-full text-left px-3 py-2 hover:bg-[color-mix(in_srgb,var(--ks-primary,#c9a961)_10%,transparent)] transition-colors flex items-start gap-2 cursor-pointer"
|
|
305
305
|
>
|
|
306
|
-
<Ticket size={14} class="shrink-0 mt-0.5 text-
|
|
306
|
+
<Ticket size={14} class="shrink-0 mt-0.5 text-[var(--ks-success-fg,#34d399)]" aria-hidden="true" />
|
|
307
307
|
<span class="flex-1 min-w-0">
|
|
308
|
-
<span class="block text-sm text-
|
|
309
|
-
<span class="block text-[11px] text-
|
|
308
|
+
<span class="block text-sm text-[var(--ks-fg,#ffffff)] truncate">{v.code}</span>
|
|
309
|
+
<span class="block text-[11px] text-[var(--ks-fg-subtle,#71717a)] truncate">
|
|
310
310
|
{formatVoucherDescription(v)}
|
|
311
311
|
</span>
|
|
312
312
|
</span>
|
|
313
|
-
<span class="text-xs text-
|
|
313
|
+
<span class="text-xs text-[var(--ks-success-fg,#34d399)] shrink-0 mt-0.5 font-mono">
|
|
314
314
|
{formatCurrency(discount())}
|
|
315
315
|
</span>
|
|
316
316
|
<Show when={selected()}>
|
|
317
|
-
<span class="text-
|
|
317
|
+
<span class="text-[var(--ks-accent,#fbbf24)] shrink-0 mt-0.5">✓</span>
|
|
318
318
|
</Show>
|
|
319
319
|
</button>
|
|
320
320
|
</li>
|
|
@@ -323,7 +323,7 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
|
|
|
323
323
|
</For>
|
|
324
324
|
<Show when={inapplicable().length > 0}>
|
|
325
325
|
<li>
|
|
326
|
-
<div class="px-3 pt-3 pb-1 text-[10px] uppercase tracking-widest text-
|
|
326
|
+
<div class="px-3 pt-3 pb-1 text-[10px] uppercase tracking-widest text-[var(--ks-fg-subtle,#71717a)] font-semibold border-t border-[var(--ks-border-strong,#3f3f46)] mt-1">
|
|
327
327
|
Not applicable to this cart
|
|
328
328
|
</div>
|
|
329
329
|
</li>
|
|
@@ -335,10 +335,10 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
|
|
|
335
335
|
class="w-full text-left px-3 py-2 flex items-start gap-2 opacity-50 cursor-not-allowed"
|
|
336
336
|
aria-disabled="true"
|
|
337
337
|
>
|
|
338
|
-
<Ticket size={14} class="shrink-0 mt-0.5 text-
|
|
338
|
+
<Ticket size={14} class="shrink-0 mt-0.5 text-[var(--ks-fg-subtle,#71717a)]" aria-hidden="true" />
|
|
339
339
|
<span class="flex-1 min-w-0">
|
|
340
|
-
<span class="block text-sm text-
|
|
341
|
-
<span class="block text-[11px] text-
|
|
340
|
+
<span class="block text-sm text-[var(--ks-fg,#ffffff)] truncate">{v.code}</span>
|
|
341
|
+
<span class="block text-[11px] text-[var(--ks-fg-subtle,#71717a)] truncate">
|
|
342
342
|
{formatVoucherDescription(v)}
|
|
343
343
|
</span>
|
|
344
344
|
</span>
|
|
@@ -349,12 +349,12 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
|
|
|
349
349
|
</Show>
|
|
350
350
|
</ul>
|
|
351
351
|
<Show when={props.selected}>
|
|
352
|
-
<div class="border-t border-
|
|
352
|
+
<div class="border-t border-[var(--ks-border-strong,#3f3f46)]">
|
|
353
353
|
<button
|
|
354
354
|
type="button"
|
|
355
355
|
data-testid="voucher-picker-clear-from-list"
|
|
356
356
|
onClick={() => select(null)}
|
|
357
|
-
class="w-full text-left px-3 py-2 text-xs text-
|
|
357
|
+
class="w-full text-left px-3 py-2 text-xs text-[var(--ks-danger-fg,#f87171)] hover:bg-[color-mix(in_srgb,var(--ks-danger,#ef4444)_10%,transparent)] transition-colors flex items-center gap-2 cursor-pointer"
|
|
358
358
|
>
|
|
359
359
|
<X size={12} />
|
|
360
360
|
<span>Remove voucher</span>
|
|
@@ -76,7 +76,7 @@ export function ResourceForm(props: ResourceFormProps) {
|
|
|
76
76
|
<Show when={props.error}>
|
|
77
77
|
<div
|
|
78
78
|
data-testid={`${props.spec.testIdPrefix}-form-error`}
|
|
79
|
-
class="rounded-lg border border-
|
|
79
|
+
class="rounded-lg border border-[color-mix(in_srgb,var(--ks-danger,#ef4444)_30%,transparent)] bg-[color-mix(in_srgb,var(--ks-danger,#ef4444)_10%,transparent)] px-3 py-2 text-sm text-[var(--ks-danger-fg,#f87171)]"
|
|
80
80
|
>
|
|
81
81
|
{props.error}
|
|
82
82
|
</div>
|
|
@@ -316,7 +316,7 @@ export function ResourcePage<T extends ResourceRow>(
|
|
|
316
316
|
onChange={(e) =>
|
|
317
317
|
setFilterState(f.param, e.currentTarget.value)
|
|
318
318
|
}
|
|
319
|
-
class="rounded-lg border border-
|
|
319
|
+
class="rounded-lg border border-[var(--ks-border,rgba(39,39,42,0.5))] bg-[var(--ks-input-bg,#18181b)] px-3 py-1.5 text-xs text-[var(--ks-fg-muted,#a1a1aa)] cursor-pointer"
|
|
320
320
|
>
|
|
321
321
|
<For each={f.options}>
|
|
322
322
|
{(o) => <option value={o.value}>{o.label}</option>}
|
|
@@ -344,7 +344,7 @@ export function ResourcePage<T extends ResourceRow>(
|
|
|
344
344
|
>
|
|
345
345
|
<div data-testid={`${spec.testIdPrefix}-create-modal`}>
|
|
346
346
|
<div class="flex items-center justify-between mb-6">
|
|
347
|
-
<h2 class="text-lg font-semibold text-
|
|
347
|
+
<h2 class="text-lg font-semibold text-[var(--ks-fg,#ffffff)]">
|
|
348
348
|
{spec.labels.createTitle}
|
|
349
349
|
</h2>
|
|
350
350
|
<button
|
|
@@ -352,7 +352,7 @@ export function ResourcePage<T extends ResourceRow>(
|
|
|
352
352
|
setCreateOpen(false);
|
|
353
353
|
resetForm();
|
|
354
354
|
}}
|
|
355
|
-
class="text-
|
|
355
|
+
class="text-[var(--ks-fg-subtle,#71717a)] hover:text-[var(--ks-fg,#ffffff)] cursor-pointer"
|
|
356
356
|
aria-label="Close"
|
|
357
357
|
>
|
|
358
358
|
<X size={20} />
|
|
@@ -387,7 +387,7 @@ export function ResourcePage<T extends ResourceRow>(
|
|
|
387
387
|
>
|
|
388
388
|
<div data-testid={`${spec.testIdPrefix}-detail-modal`}>
|
|
389
389
|
<div class="flex items-center justify-between mb-6">
|
|
390
|
-
<h2 class="text-lg font-semibold text-
|
|
390
|
+
<h2 class="text-lg font-semibold text-[var(--ks-fg,#ffffff)]">
|
|
391
391
|
{editing()
|
|
392
392
|
? spec.labels.editTitle
|
|
393
393
|
: String(row()[spec.labels.titleField] ?? "")}
|
|
@@ -396,7 +396,7 @@ export function ResourcePage<T extends ResourceRow>(
|
|
|
396
396
|
<Show when={!editing() && canEdit()}>
|
|
397
397
|
<button
|
|
398
398
|
onClick={startEdit}
|
|
399
|
-
class="text-
|
|
399
|
+
class="text-[var(--ks-fg-subtle,#71717a)] hover:text-[var(--ks-accent,#fbbf24)] cursor-pointer p-1"
|
|
400
400
|
title="Edit"
|
|
401
401
|
aria-label="Edit"
|
|
402
402
|
>
|
|
@@ -407,7 +407,7 @@ export function ResourcePage<T extends ResourceRow>(
|
|
|
407
407
|
{row()[spec.softDeleteField] ? (
|
|
408
408
|
<button
|
|
409
409
|
onClick={() => handleArchive(row().id)}
|
|
410
|
-
class="text-
|
|
410
|
+
class="text-[var(--ks-fg-subtle,#71717a)] hover:text-[var(--ks-danger-fg,#f87171)] cursor-pointer p-1"
|
|
411
411
|
title="Archive"
|
|
412
412
|
aria-label="Archive"
|
|
413
413
|
>
|
|
@@ -416,7 +416,7 @@ export function ResourcePage<T extends ResourceRow>(
|
|
|
416
416
|
) : (
|
|
417
417
|
<button
|
|
418
418
|
onClick={() => handleRestore(row().id)}
|
|
419
|
-
class="text-
|
|
419
|
+
class="text-[var(--ks-fg-subtle,#71717a)] hover:text-[var(--ks-success-fg,#34d399)] cursor-pointer p-1"
|
|
420
420
|
title="Restore"
|
|
421
421
|
aria-label="Restore"
|
|
422
422
|
>
|
|
@@ -429,7 +429,7 @@ export function ResourcePage<T extends ResourceRow>(
|
|
|
429
429
|
setDetailRow(null);
|
|
430
430
|
setEditing(false);
|
|
431
431
|
}}
|
|
432
|
-
class="text-
|
|
432
|
+
class="text-[var(--ks-fg-subtle,#71717a)] hover:text-[var(--ks-fg,#ffffff)] cursor-pointer p-1"
|
|
433
433
|
aria-label="Close"
|
|
434
434
|
>
|
|
435
435
|
<X size={20} />
|
|
@@ -18,7 +18,7 @@ export function renderCell(
|
|
|
18
18
|
return (
|
|
19
19
|
<button
|
|
20
20
|
data-testid={`${spec.testIdPrefix}-row-${row.id}`}
|
|
21
|
-
class="text-left text-
|
|
21
|
+
class="text-left text-[var(--ks-fg,#ffffff)] hover:text-[var(--ks-accent,#fbbf24)] transition-colors cursor-pointer"
|
|
22
22
|
onClick={() => onTitleClick(row.id)}
|
|
23
23
|
>
|
|
24
24
|
{String(raw ?? "")}
|
|
@@ -26,7 +26,7 @@ export function renderCell(
|
|
|
26
26
|
);
|
|
27
27
|
case "enum": {
|
|
28
28
|
const key = String(raw ?? "");
|
|
29
|
-
return <span class="text-
|
|
29
|
+
return <span class="text-[var(--ks-fg-muted,#a1a1aa)] text-sm capitalize">{r.labels[key] || key}</span>;
|
|
30
30
|
}
|
|
31
31
|
case "status": {
|
|
32
32
|
const active = Boolean(raw);
|
|
@@ -35,7 +35,10 @@ export function renderCell(
|
|
|
35
35
|
}
|
|
36
36
|
case "text":
|
|
37
37
|
default: {
|
|
38
|
-
const cls =
|
|
38
|
+
const cls =
|
|
39
|
+
r.type === "text" && r.muted
|
|
40
|
+
? "text-[var(--ks-fg-subtle,#71717a)] text-sm"
|
|
41
|
+
: "text-[var(--ks-fg-muted,#a1a1aa)] text-sm";
|
|
39
42
|
const text = raw === null || raw === undefined || raw === "" ? "—" : String(raw);
|
|
40
43
|
return <span class={cls}>{text}</span>;
|
|
41
44
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Pure builder for a declarative form descriptor. field.* helpers compile DOWN
|
|
2
|
+
// to the EXISTING UiField union, so ResourceForm renders them with zero new
|
|
3
|
+
// path. defineForm(...).fields plugs straight into the route adapter as
|
|
4
|
+
// spec.fields. No solid-js — data only.
|
|
5
|
+
import type { FieldTransform, UiField, UiFieldSelectOption } from "./spec.js";
|
|
6
|
+
|
|
7
|
+
// ---- field defs ------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
// FieldDef carries the lowered UiField body minus the key (supplied at the
|
|
10
|
+
// fields-record key) and label (supplied per builder). `kind` is the discriminant.
|
|
11
|
+
export interface FieldDefText {
|
|
12
|
+
readonly kind: "text";
|
|
13
|
+
readonly label: string;
|
|
14
|
+
readonly required?: boolean;
|
|
15
|
+
readonly transform: FieldTransform;
|
|
16
|
+
readonly placeholder?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface FieldDefTextarea {
|
|
19
|
+
readonly kind: "textarea";
|
|
20
|
+
readonly label: string;
|
|
21
|
+
readonly required?: boolean;
|
|
22
|
+
readonly transform: FieldTransform;
|
|
23
|
+
readonly placeholder?: string;
|
|
24
|
+
readonly rows?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface FieldDefSelect {
|
|
27
|
+
readonly kind: "select";
|
|
28
|
+
readonly label: string;
|
|
29
|
+
readonly required?: boolean;
|
|
30
|
+
readonly default?: string;
|
|
31
|
+
readonly options: readonly UiFieldSelectOption[];
|
|
32
|
+
}
|
|
33
|
+
// Declared-but-unwired kinds: present in the union so the contract is whole,
|
|
34
|
+
// but no field.* builder produces them — an unwired kind is a tsc error at the
|
|
35
|
+
// call site, never a silent runtime hole (the adapter also throws on it).
|
|
36
|
+
export interface FieldDefUnwired {
|
|
37
|
+
readonly kind: "currency" | "date" | "relation" | "file";
|
|
38
|
+
readonly label: string;
|
|
39
|
+
}
|
|
40
|
+
export type FieldDef = FieldDefText | FieldDefTextarea | FieldDefSelect | FieldDefUnwired;
|
|
41
|
+
|
|
42
|
+
export const field = {
|
|
43
|
+
text: (opts: {
|
|
44
|
+
label: string;
|
|
45
|
+
required?: boolean;
|
|
46
|
+
transform: FieldTransform;
|
|
47
|
+
placeholder?: string;
|
|
48
|
+
}): FieldDefText =>
|
|
49
|
+
Object.freeze({
|
|
50
|
+
kind: "text",
|
|
51
|
+
label: opts.label,
|
|
52
|
+
transform: opts.transform,
|
|
53
|
+
...(opts.required ? { required: opts.required } : {}),
|
|
54
|
+
...(opts.placeholder !== undefined ? { placeholder: opts.placeholder } : {}),
|
|
55
|
+
}),
|
|
56
|
+
textarea: (opts: {
|
|
57
|
+
label: string;
|
|
58
|
+
required?: boolean;
|
|
59
|
+
transform: FieldTransform;
|
|
60
|
+
placeholder?: string;
|
|
61
|
+
rows?: number;
|
|
62
|
+
}): FieldDefTextarea =>
|
|
63
|
+
Object.freeze({
|
|
64
|
+
kind: "textarea",
|
|
65
|
+
label: opts.label,
|
|
66
|
+
transform: opts.transform,
|
|
67
|
+
...(opts.required ? { required: opts.required } : {}),
|
|
68
|
+
...(opts.rows !== undefined ? { rows: opts.rows } : {}),
|
|
69
|
+
...(opts.placeholder !== undefined ? { placeholder: opts.placeholder } : {}),
|
|
70
|
+
}),
|
|
71
|
+
select: (opts: {
|
|
72
|
+
label: string;
|
|
73
|
+
required?: boolean;
|
|
74
|
+
default?: string;
|
|
75
|
+
options: readonly UiFieldSelectOption[];
|
|
76
|
+
}): FieldDefSelect =>
|
|
77
|
+
Object.freeze({
|
|
78
|
+
kind: "select",
|
|
79
|
+
label: opts.label,
|
|
80
|
+
options: opts.options,
|
|
81
|
+
...(opts.default !== undefined ? { default: opts.default } : {}),
|
|
82
|
+
...(opts.required ? { required: opts.required } : {}),
|
|
83
|
+
}),
|
|
84
|
+
} as const;
|
|
85
|
+
|
|
86
|
+
// ---- the form spec ---------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
export interface FormSpec {
|
|
89
|
+
readonly name?: string;
|
|
90
|
+
readonly title?: string;
|
|
91
|
+
readonly fields: Readonly<Record<string, FieldDef>>;
|
|
92
|
+
// Binding seam: STRING command ids, not hardcoded verbs, so a flow runtime
|
|
93
|
+
// can slot in without recontracting the form.
|
|
94
|
+
readonly submit: { readonly create?: string; readonly update?: string; readonly label?: string };
|
|
95
|
+
readonly layout?: unknown;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function defineForm(cfg: FormSpec): FormSpec {
|
|
99
|
+
return Object.freeze(cfg);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---- lowering (consumed by ./route-adapter) ---------------------------------
|
|
103
|
+
|
|
104
|
+
/** Fold one FieldDef + its key into the existing UiField. THROWS on an
|
|
105
|
+
* unwired kind so the wired subset fails loudly at build/test. */
|
|
106
|
+
export function fieldToUiField(key: string, def: FieldDef): UiField {
|
|
107
|
+
switch (def.kind) {
|
|
108
|
+
case "text":
|
|
109
|
+
return {
|
|
110
|
+
key,
|
|
111
|
+
label: def.label,
|
|
112
|
+
type: "text",
|
|
113
|
+
...(def.required ? { required: def.required } : {}),
|
|
114
|
+
transform: def.transform,
|
|
115
|
+
...(def.placeholder !== undefined ? { placeholder: def.placeholder } : {}),
|
|
116
|
+
};
|
|
117
|
+
case "textarea":
|
|
118
|
+
return {
|
|
119
|
+
key,
|
|
120
|
+
label: def.label,
|
|
121
|
+
type: "textarea",
|
|
122
|
+
...(def.required ? { required: def.required } : {}),
|
|
123
|
+
transform: def.transform,
|
|
124
|
+
...(def.rows !== undefined ? { rows: def.rows } : {}),
|
|
125
|
+
...(def.placeholder !== undefined ? { placeholder: def.placeholder } : {}),
|
|
126
|
+
};
|
|
127
|
+
case "select":
|
|
128
|
+
return {
|
|
129
|
+
key,
|
|
130
|
+
label: def.label,
|
|
131
|
+
type: "select",
|
|
132
|
+
...(def.default !== undefined ? { default: def.default } : {}),
|
|
133
|
+
options: def.options,
|
|
134
|
+
...(def.required ? { required: def.required } : {}),
|
|
135
|
+
};
|
|
136
|
+
default:
|
|
137
|
+
throw new Error(
|
|
138
|
+
`form-spec: field "${key}" uses unwired kind "${(def as { kind: string }).kind}" (only text/textarea/select are wired)`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The compile-down core: routeToResourceSpec folds a RouteSpec DOWN to the
|
|
2
|
+
// EXISTING ResourceUiSpec byte-identically, so ResourcePage (untouched) is
|
|
3
|
+
// the single render engine. THROWS on unrepresentable config (a 2nd view mode,
|
|
4
|
+
// a cards body, an unwired field kind) so the table-only subset is enforced at
|
|
5
|
+
// author/build time, never silently dropped. Pure — no solid-js.
|
|
6
|
+
import type { ResourceUiSpec, UiColumn, UiField } from "./spec.js";
|
|
7
|
+
import type { BodyView, RouteSpec } from "./route-spec.js";
|
|
8
|
+
import { fieldToUiField } from "./form-spec.js";
|
|
9
|
+
|
|
10
|
+
/** Resolve the single body view, rejecting the multi-view / cards cases the
|
|
11
|
+
* table-only subset does not render yet. */
|
|
12
|
+
function resolveTableView(view: RouteSpec["view"]): BodyView {
|
|
13
|
+
if ("views" in view) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
"route-adapter: multi-view routes are not supported (a second view mode is deferred)",
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
if (view.kind !== "table") {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`route-adapter: body view "${view.kind}" is not wired (only "table" lowers to ResourcePage)`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return view;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function routeColumns(view: BodyView): UiColumn[] {
|
|
27
|
+
if (view.kind !== "table") {
|
|
28
|
+
throw new Error(`route-adapter: cannot lower a "${view.kind}" body to columns`);
|
|
29
|
+
}
|
|
30
|
+
return view.columns.map((c) =>
|
|
31
|
+
c.orderable === undefined
|
|
32
|
+
? { key: c.key, title: c.title, render: c.render }
|
|
33
|
+
: { key: c.key, title: c.title, orderable: c.orderable, render: c.render },
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function routeFields(route: RouteSpec): UiField[] {
|
|
38
|
+
if (!route.form) return [];
|
|
39
|
+
return Object.entries(route.form.fields).map(([key, def]) => fieldToUiField(key, def));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function routeToResourceSpec(route: RouteSpec): ResourceUiSpec {
|
|
43
|
+
const view = resolveTableView(route.view);
|
|
44
|
+
|
|
45
|
+
const spec: ResourceUiSpec = {
|
|
46
|
+
basePath: route.basePath,
|
|
47
|
+
title: route.title,
|
|
48
|
+
...(route.subtitle !== undefined ? { subtitle: route.subtitle } : {}),
|
|
49
|
+
permissions: {
|
|
50
|
+
view: route.permissions.view,
|
|
51
|
+
edit: route.permissions.edit,
|
|
52
|
+
delete: route.permissions.delete,
|
|
53
|
+
},
|
|
54
|
+
softDeleteField: route.softDeleteField,
|
|
55
|
+
testIdPrefix: route.testIdPrefix,
|
|
56
|
+
columns: routeColumns(view),
|
|
57
|
+
fields: routeFields(route),
|
|
58
|
+
...(route.toolbar?.filters !== undefined ? { filters: route.toolbar.filters } : {}),
|
|
59
|
+
detail: route.detail,
|
|
60
|
+
labels: route.labels,
|
|
61
|
+
};
|
|
62
|
+
return spec;
|
|
63
|
+
}
|