@alfakit/react 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/dist/components/actions/action-button.d.ts.map +1 -1
- package/dist/components/actions/action-button.js +5 -4
- package/dist/components/actions/action-button.js.map +1 -1
- package/dist/components/actions/action-modals.d.ts.map +1 -1
- package/dist/components/actions/action-modals.js +2 -5
- package/dist/components/actions/action-modals.js.map +1 -1
- package/dist/components/fields/color-picker.js +1 -0
- package/dist/components/fields/color-picker.js.map +1 -1
- package/dist/components/fields/file-upload.js +11 -10
- package/dist/components/fields/file-upload.js.map +1 -1
- package/dist/components/fields/one-time-code-input.js +1 -1
- package/dist/components/fields/one-time-code-input.js.map +1 -1
- package/dist/components/fields/repeater-table.js +12 -1
- package/dist/components/fields/repeater-table.js.map +1 -1
- package/dist/components/fields/select.js +3 -3
- package/dist/components/fields/select.js.map +1 -1
- package/dist/components/file-manager/file-manager.js +1 -1
- package/dist/components/file-manager/file-manager.js.map +1 -1
- package/dist/components/form/form-block.d.ts.map +1 -1
- package/dist/components/form/form-block.js +13 -1
- package/dist/components/form/form-block.js.map +1 -1
- package/dist/components/form/form-context.js +6 -2
- package/dist/components/form/form-context.js.map +1 -1
- package/dist/components/layout/fused-group.js +1 -0
- package/dist/components/layout/fused-group.js.map +1 -1
- package/dist/components/layout/tabs.d.ts.map +1 -1
- package/dist/components/layout/tabs.js +54 -15
- package/dist/components/layout/tabs.js.map +1 -1
- package/dist/components/panel/global-search.js +1 -0
- package/dist/components/panel/global-search.js.map +1 -1
- package/dist/components/panel/shortcut-keys.js +1 -0
- package/dist/components/panel/shortcut-keys.js.map +1 -1
- package/dist/components/panel/tenant-menu.js +2 -2
- package/dist/components/panel/tenant-menu.js.map +1 -1
- package/dist/components/table/cells.js +9 -1
- package/dist/components/table/cells.js.map +1 -1
- package/dist/components/table/grouping.js +11 -1
- package/dist/components/table/grouping.js.map +1 -1
- package/dist/components.css +347 -0
- package/dist/eject-manifest.json +2 -2
- package/dist/standalone/alfakit-toaster.d.ts.map +1 -1
- package/dist/standalone/alfakit-toaster.js +8 -1
- package/dist/standalone/alfakit-toaster.js.map +1 -1
- package/dist/standalone.css +47 -0
- package/dist/starter-kit/widgets-loaded.js +1 -1
- package/dist/starter-kit/widgets-loaded.js.map +1 -1
- package/dist/starter-kit/widgets-triggers.js +1 -0
- package/dist/starter-kit/widgets-triggers.js.map +1 -1
- package/dist/themes.css +2 -334
- package/package.json +2 -2
- package/src/components/actions/action-button.tsx +7 -4
- package/src/components/actions/action-modals.tsx +3 -1
- package/src/components/fields/color-picker.tsx +1 -0
- package/src/components/fields/file-upload.tsx +21 -10
- package/src/components/fields/one-time-code-input.tsx +1 -1
- package/src/components/fields/repeater-table.tsx +12 -1
- package/src/components/fields/select.tsx +7 -3
- package/src/components/file-manager/file-manager.tsx +1 -1
- package/src/components/form/form-block.tsx +24 -0
- package/src/components/form/form-context.tsx +2 -5
- package/src/components/layout/fused-group.tsx +5 -1
- package/src/components/layout/tabs.tsx +72 -17
- package/src/components/panel/global-search.tsx +1 -1
- package/src/components/panel/shortcut-keys.tsx +4 -1
- package/src/components/panel/tenant-menu.tsx +2 -2
- package/src/components/table/cells.tsx +18 -3
- package/src/components/table/grouping.tsx +15 -1
- package/src/standalone/alfakit-toaster.tsx +21 -2
- package/src/starter-kit/widgets-loaded.tsx +1 -1
- package/src/starter-kit/widgets-triggers.tsx +1 -1
|
@@ -6,10 +6,10 @@ import {
|
|
|
6
6
|
editableImageName,
|
|
7
7
|
fetchEditableImage,
|
|
8
8
|
fieldMemory,
|
|
9
|
+
fileManagerSize,
|
|
9
10
|
filesFromClipboard,
|
|
10
11
|
fileUploadAccept,
|
|
11
12
|
fileUploadPaths,
|
|
12
|
-
formatBytes,
|
|
13
13
|
insertUploadedFile,
|
|
14
14
|
moveRepeaterItem,
|
|
15
15
|
panelUploadOptions,
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
XIcon,
|
|
30
30
|
} from 'lucide-react'
|
|
31
31
|
import { useRef, useState, type ClipboardEvent, type DragEvent } from 'react'
|
|
32
|
-
import { useChoice, usePanel, useT } from '../../context'
|
|
32
|
+
import { useChoice, useFormatter, usePanel, useT } from '../../context'
|
|
33
33
|
import { useStore } from '../../hooks/use-store'
|
|
34
34
|
import { cn } from '../../lib/utils'
|
|
35
35
|
import { useField, useFormStore } from '../form/form-context'
|
|
@@ -47,6 +47,7 @@ interface LocalFile extends UploadedFile {
|
|
|
47
47
|
|
|
48
48
|
export function FileUploadField({ component }: { component: FileUploadSchema }) {
|
|
49
49
|
const t = useT()
|
|
50
|
+
const formatter = useFormatter()
|
|
50
51
|
const choice = useChoice()
|
|
51
52
|
const panel = usePanel()
|
|
52
53
|
const { value, setValue, error, id, disabled, readOnly, aria } = useField<
|
|
@@ -375,7 +376,7 @@ export function FileUploadField({ component }: { component: FileUploadSchema })
|
|
|
375
376
|
variant={circle ? 'secondary' : 'ghost'}
|
|
376
377
|
size="icon-xs"
|
|
377
378
|
className={cn(circle && 'rounded-full shadow')}
|
|
378
|
-
aria-label={t('remove', {}, 'Remove')}
|
|
379
|
+
aria-label={t('file_upload.remove', { name: file.name }, 'Remove :name')}
|
|
379
380
|
onClick={() => {
|
|
380
381
|
if (file.error || file.progress !== undefined) {
|
|
381
382
|
releasePreview(file.preview)
|
|
@@ -467,7 +468,7 @@ export function FileUploadField({ component }: { component: FileUploadSchema })
|
|
|
467
468
|
: file.progress !== undefined && !file.error
|
|
468
469
|
? (component.uploadingMessage ?? t('form.uploading', {}, 'Uploading…'))
|
|
469
470
|
: file.size
|
|
470
|
-
?
|
|
471
|
+
? fileManagerSize(file.size, formatter)
|
|
471
472
|
: file.temporary
|
|
472
473
|
? t('form.uploading', {}, 'Pending')
|
|
473
474
|
: '')}
|
|
@@ -486,7 +487,7 @@ export function FileUploadField({ component }: { component: FileUploadSchema })
|
|
|
486
487
|
type="button"
|
|
487
488
|
variant="ghost"
|
|
488
489
|
size="icon-xs"
|
|
489
|
-
aria-label={t('
|
|
490
|
+
aria-label={t('file_upload.move_up', { name: file.name }, 'Move :name up')}
|
|
490
491
|
disabled={index === 0}
|
|
491
492
|
onClick={() => move(index, index - 1)}
|
|
492
493
|
>
|
|
@@ -496,7 +497,11 @@ export function FileUploadField({ component }: { component: FileUploadSchema })
|
|
|
496
497
|
type="button"
|
|
497
498
|
variant="ghost"
|
|
498
499
|
size="icon-xs"
|
|
499
|
-
aria-label={t(
|
|
500
|
+
aria-label={t(
|
|
501
|
+
'file_upload.move_down',
|
|
502
|
+
{ name: file.name },
|
|
503
|
+
'Move :name down',
|
|
504
|
+
)}
|
|
500
505
|
disabled={index === files.length - 1}
|
|
501
506
|
onClick={() => move(index, index + 1)}
|
|
502
507
|
>
|
|
@@ -510,7 +515,7 @@ export function FileUploadField({ component }: { component: FileUploadSchema })
|
|
|
510
515
|
variant={circle ? 'secondary' : 'ghost'}
|
|
511
516
|
size="icon-xs"
|
|
512
517
|
className={cn(circle && 'rounded-full shadow')}
|
|
513
|
-
aria-label={t('
|
|
518
|
+
aria-label={t('file_upload.edit', { name: file.name }, 'Edit :name')}
|
|
514
519
|
title={t('image_editor.title', {}, 'Edit image')}
|
|
515
520
|
data-action="edit-image"
|
|
516
521
|
onClick={() => void editExisting(file)}
|
|
@@ -524,7 +529,7 @@ export function FileUploadField({ component }: { component: FileUploadSchema })
|
|
|
524
529
|
href={uploadedFileLinks(file).open}
|
|
525
530
|
target="_blank"
|
|
526
531
|
rel="noopener noreferrer"
|
|
527
|
-
aria-label={`${t('open', {}, 'Open')} ${t('opens_in_new_tab', {}, '(opens in a new tab)')}`}
|
|
532
|
+
aria-label={`${t('file_upload.open', { name: file.name }, 'Open :name')} ${t('opens_in_new_tab', {}, '(opens in a new tab)')}`}
|
|
528
533
|
>
|
|
529
534
|
<ExternalLinkIcon />
|
|
530
535
|
</a>
|
|
@@ -535,7 +540,11 @@ export function FileUploadField({ component }: { component: FileUploadSchema })
|
|
|
535
540
|
<a
|
|
536
541
|
href={uploadedFileLinks(file).download}
|
|
537
542
|
download
|
|
538
|
-
aria-label={t(
|
|
543
|
+
aria-label={t(
|
|
544
|
+
'file_upload.download',
|
|
545
|
+
{ name: file.name },
|
|
546
|
+
'Download :name',
|
|
547
|
+
)}
|
|
539
548
|
>
|
|
540
549
|
<DownloadIcon />
|
|
541
550
|
</a>
|
|
@@ -596,7 +605,9 @@ export function FileUploadField({ component }: { component: FileUploadSchema })
|
|
|
596
605
|
<span className="text-muted-foreground text-xs">
|
|
597
606
|
{[
|
|
598
607
|
component.acceptedFileTypes?.join(', '),
|
|
599
|
-
component.maxSize
|
|
608
|
+
component.maxSize
|
|
609
|
+
? `≤ ${fileManagerSize(component.maxSize * 1024, formatter)}`
|
|
610
|
+
: null,
|
|
600
611
|
component.minFiles
|
|
601
612
|
? choice(
|
|
602
613
|
'form.min_files',
|
|
@@ -25,7 +25,6 @@ export function OneTimeCodeInputField({ component }: { component: OneTimeCodeInp
|
|
|
25
25
|
inputMode="numeric"
|
|
26
26
|
autoComplete="one-time-code"
|
|
27
27
|
pattern="[0-9]*"
|
|
28
|
-
maxLength={component.length}
|
|
29
28
|
value={value ?? ''}
|
|
30
29
|
onChange={(event) => {
|
|
31
30
|
const code = oneTimeCodeDigits(event.target.value, component.length)
|
|
@@ -45,6 +44,7 @@ export function OneTimeCodeInputField({ component }: { component: OneTimeCodeInp
|
|
|
45
44
|
required={component.required}
|
|
46
45
|
autoFocus={component.autofocus}
|
|
47
46
|
data-slot="one-time-code-input"
|
|
47
|
+
data-input=""
|
|
48
48
|
{...aria}
|
|
49
49
|
{...extraProps(
|
|
50
50
|
component.extraInputAttributes,
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
TableColumnSchema,
|
|
8
8
|
} from '@alfakit/core'
|
|
9
9
|
import { resolveItemAction, tableColumnAlignmentClass, tableColumnCells } from '@alfakit/core'
|
|
10
|
+
import type { CSSProperties } from 'react'
|
|
10
11
|
import { useT } from '../../context'
|
|
11
12
|
import type { VirtualListBinding } from '../../hooks/use-virtual-list'
|
|
12
13
|
import { cn } from '../../lib/utils'
|
|
@@ -41,6 +42,16 @@ export interface RepeaterTableProps {
|
|
|
41
42
|
extraActionsFor?: (index: number) => ActionSchema[] | undefined
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/**
|
|
46
|
+
* A fixed `width()` (`9rem`, `120px`) is also the column's minimum, so the table scrolls
|
|
47
|
+
* sideways instead of squeezing its fields; percentages stay flexible.
|
|
48
|
+
*/
|
|
49
|
+
function columnWidthStyle(width?: string | null): CSSProperties | undefined {
|
|
50
|
+
if (!width) return undefined
|
|
51
|
+
|
|
52
|
+
return width.trim().endsWith('%') ? { width } : { width, minWidth: width }
|
|
53
|
+
}
|
|
54
|
+
|
|
44
55
|
/** Header cells shared by the Repeater and RepeatableEntry tables. */
|
|
45
56
|
export function TableColumnHeaders({ columns }: { columns: TableColumnSchema[] }) {
|
|
46
57
|
return (
|
|
@@ -49,7 +60,7 @@ export function TableColumnHeaders({ columns }: { columns: TableColumnSchema[] }
|
|
|
49
60
|
<th
|
|
50
61
|
key={`${column.name ?? 'column'}-${index}`}
|
|
51
62
|
scope="col"
|
|
52
|
-
style={column.width
|
|
63
|
+
style={columnWidthStyle(column.width)}
|
|
53
64
|
className={cn(
|
|
54
65
|
'text-muted-foreground px-3 py-2 text-xs font-medium',
|
|
55
66
|
tableColumnAlignmentClass(column),
|
|
@@ -65,7 +65,9 @@ export function SelectField({ component }: { component: SelectSchema }) {
|
|
|
65
65
|
className="border-input dark:bg-input/30 focus-visible:ring-ring/50 h-9 w-full rounded-md border bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:ring-[3px]"
|
|
66
66
|
>
|
|
67
67
|
{component.selectablePlaceholder && (
|
|
68
|
-
<option value="">
|
|
68
|
+
<option value="">
|
|
69
|
+
{component.placeholder ?? t('select_option', {}, 'Select an option')}
|
|
70
|
+
</option>
|
|
69
71
|
)}
|
|
70
72
|
{component.options.map((option) =>
|
|
71
73
|
isOptionGroup(option) ? (
|
|
@@ -103,7 +105,9 @@ export function SelectField({ component }: { component: SelectSchema }) {
|
|
|
103
105
|
aria-readonly={readOnly || undefined}
|
|
104
106
|
className="w-full"
|
|
105
107
|
>
|
|
106
|
-
<SelectValue
|
|
108
|
+
<SelectValue
|
|
109
|
+
placeholder={component.placeholder ?? t('select_option', {}, 'Select an option')}
|
|
110
|
+
/>
|
|
107
111
|
</SelectTrigger>
|
|
108
112
|
<SelectContent>
|
|
109
113
|
{component.selectablePlaceholder &&
|
|
@@ -411,7 +415,7 @@ function ComboboxSelect({
|
|
|
411
415
|
error && 'border-destructive',
|
|
412
416
|
)}
|
|
413
417
|
>
|
|
414
|
-
<span className="flex flex-1 flex-wrap items-center gap-1 text-start">
|
|
418
|
+
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-1 text-start">
|
|
415
419
|
{selected.length === 0 && (
|
|
416
420
|
<span className="text-muted-foreground">
|
|
417
421
|
{component.placeholder ?? t('select_option', {}, 'Select an option')}
|
|
@@ -322,7 +322,7 @@ export function FileManagerView({ schema, onChoose, onCancel }: FileManagerViewP
|
|
|
322
322
|
)}
|
|
323
323
|
<nav
|
|
324
324
|
aria-label={tt('breadcrumb', {}, 'Folder path')}
|
|
325
|
-
className="min-w-0 flex-
|
|
325
|
+
className="min-w-0 flex-[1_1_12rem]"
|
|
326
326
|
data-slot="file-manager-breadcrumb"
|
|
327
327
|
>
|
|
328
328
|
<ol className="flex min-w-0 flex-wrap items-center gap-1 text-sm">
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
registerFormSubmitter,
|
|
14
14
|
registerPageForm,
|
|
15
15
|
resolvePartialProps,
|
|
16
|
+
shouldResyncForm,
|
|
16
17
|
wizardProgressStore,
|
|
17
18
|
} from '@alfakit/core'
|
|
18
19
|
import { router } from '@inertiajs/react'
|
|
@@ -93,6 +94,10 @@ export function FormBlock({ block }: { block: FormBlockSchema }) {
|
|
|
93
94
|
const latestBlock = useRef(block)
|
|
94
95
|
const submittedBlock = useRef<FormBlockSchema | null>(null)
|
|
95
96
|
const refillPending = useRef(false)
|
|
97
|
+
// Another action succeeded and its new block has not arrived yet.
|
|
98
|
+
const resyncPending = useRef(false)
|
|
99
|
+
// The block when the latest non-GET visit started, to tell whether its response arrived.
|
|
100
|
+
const visitBlock = useRef<FormBlockSchema | null>(null)
|
|
96
101
|
|
|
97
102
|
useEffect(() => {
|
|
98
103
|
latestBlock.current = block
|
|
@@ -101,6 +106,12 @@ export function FormBlock({ block }: { block: FormBlockSchema }) {
|
|
|
101
106
|
refillPending.current = false
|
|
102
107
|
store.fill(block)
|
|
103
108
|
}
|
|
109
|
+
|
|
110
|
+
if (resyncPending.current) {
|
|
111
|
+
resyncPending.current = false
|
|
112
|
+
|
|
113
|
+
if (!store.isDirty) store.fill(block)
|
|
114
|
+
}
|
|
104
115
|
}, [block, store])
|
|
105
116
|
|
|
106
117
|
const actions: ActionSchema[] = useMemo(
|
|
@@ -152,11 +163,24 @@ export function FormBlock({ block }: { block: FormBlockSchema }) {
|
|
|
152
163
|
if (submitting.current && !submitVisit.current && visit.method !== 'get') {
|
|
153
164
|
submitVisit.current = visit.id
|
|
154
165
|
}
|
|
166
|
+
|
|
167
|
+
if (visit.method !== 'get') visitBlock.current = latestBlock.current
|
|
155
168
|
})
|
|
156
169
|
|
|
157
170
|
const offSuccess = router.on('success', (event) => {
|
|
158
171
|
const id = submitVisit.current
|
|
159
172
|
|
|
173
|
+
// Another action changed the record: show the server's new form unless it holds edits.
|
|
174
|
+
if (!id && shouldResyncForm(pageFlash(event.detail.page), store)) {
|
|
175
|
+
if (latestBlock.current !== visitBlock.current) {
|
|
176
|
+
store.fill(latestBlock.current)
|
|
177
|
+
} else {
|
|
178
|
+
resyncPending.current = true
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
|
|
160
184
|
if (!id || (event.detail.visitId ?? id) !== id) return
|
|
161
185
|
|
|
162
186
|
// A halted action (rate limit, `halt()`) saved nothing: keep what the user typed.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { BaseField, FieldAriaAttributes, FormState, FormStore } from '@alfakit/core'
|
|
2
|
-
import { fieldAria } from '@alfakit/core'
|
|
2
|
+
import { fieldAria, fieldDomId } from '@alfakit/core'
|
|
3
3
|
import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'
|
|
4
4
|
import { useStore, useStoreSelector } from '../../hooks/use-store'
|
|
5
5
|
|
|
@@ -65,10 +65,7 @@ export function useField<T = unknown>(field: BaseField): FieldBinding<T> {
|
|
|
65
65
|
const setValue = useCallback((next: T) => store.setValue(path, next), [store, path])
|
|
66
66
|
const blur = useCallback(() => store.blur(path), [store, path])
|
|
67
67
|
|
|
68
|
-
const id = useMemo(
|
|
69
|
-
() => field.id ?? `alfakit-${path.replace(/[^a-zA-Z0-9_-]/g, '-')}`,
|
|
70
|
-
[field.id, path],
|
|
71
|
-
)
|
|
68
|
+
const id = useMemo(() => field.id ?? fieldDomId(store.idPrefix, path), [field.id, store, path])
|
|
72
69
|
|
|
73
70
|
const aria = useMemo(
|
|
74
71
|
() =>
|
|
@@ -25,7 +25,11 @@ export function FusedGroupComponent({ component }: { component: FusedGroupSchema
|
|
|
25
25
|
{...extraProps(component.extraAttributes, 'flex flex-col gap-1.5')}
|
|
26
26
|
>
|
|
27
27
|
{component.label && (
|
|
28
|
-
<span
|
|
28
|
+
<span
|
|
29
|
+
id={labelId}
|
|
30
|
+
data-slot="label"
|
|
31
|
+
className="text-sm leading-none font-medium select-none"
|
|
32
|
+
>
|
|
29
33
|
{component.label}
|
|
30
34
|
</span>
|
|
31
35
|
)}
|
|
@@ -1,30 +1,51 @@
|
|
|
1
1
|
import type { TabSchema, TabsSchema } from '@alfakit/core'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
componentHasErrors,
|
|
4
|
+
readPersisted,
|
|
5
|
+
schemaGapClass,
|
|
6
|
+
tabFromQuery,
|
|
7
|
+
tabQueryValue,
|
|
8
|
+
tabToReveal,
|
|
9
|
+
writePersisted,
|
|
10
|
+
} from '@alfakit/core'
|
|
3
11
|
import { usePage } from '@inertiajs/react'
|
|
4
12
|
import { useEffect, useState } from 'react'
|
|
13
|
+
import { useT } from '../../context'
|
|
5
14
|
import { useHydrated } from '../../hooks/use-hydrated'
|
|
15
|
+
import { useStoreSelector } from '../../hooks/use-store'
|
|
6
16
|
import { extraProps } from '../../lib/attributes'
|
|
7
17
|
import { badgeColorClasses, colorClass } from '../../lib/colors'
|
|
8
18
|
import { Icon } from '../../lib/icons'
|
|
9
19
|
import { cn } from '../../lib/utils'
|
|
20
|
+
import { useOptionalFormStore } from '../form/form-context'
|
|
10
21
|
import { SchemaRenderer } from '../schema/schema-renderer'
|
|
11
22
|
import { Badge } from '../ui/badge'
|
|
12
23
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'
|
|
13
24
|
import { DeferredSchema, useDeferredBadge } from './deferred'
|
|
14
25
|
|
|
26
|
+
// Used when tabs render outside a form. Module-level so the snapshot stays stable.
|
|
27
|
+
const NO_ERRORS: Record<string, string[]> = {}
|
|
28
|
+
const NO_FORM_STORE = {
|
|
29
|
+
get: () => ({ errors: NO_ERRORS }),
|
|
30
|
+
subscribe: () => () => {},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TAB_INVALID_CLASSES =
|
|
34
|
+
'text-destructive data-[state=active]:text-destructive dark:text-destructive dark:data-[state=active]:text-destructive'
|
|
35
|
+
|
|
15
36
|
export function TabsComponent({ component }: { component: TabsSchema }) {
|
|
16
37
|
const tabs = component.schema ?? []
|
|
17
38
|
// The page URL (not `window.location`) so the server renders the same tab as the browser.
|
|
18
39
|
const url = usePage().url
|
|
40
|
+
// The tab's readable value (`?tab=billing-address`), or its key in links from older versions.
|
|
19
41
|
const fromQuery = component.queryStringKey
|
|
20
|
-
?
|
|
42
|
+
? tabFromQuery(
|
|
43
|
+
tabs,
|
|
44
|
+
new URL(url, 'http://localhost').searchParams.get(component.queryStringKey),
|
|
45
|
+
)
|
|
21
46
|
: null
|
|
22
47
|
const [initial] = useState<string>(
|
|
23
|
-
() =>
|
|
24
|
-
tabs.find((tab) => tab.key === fromQuery)?.key ??
|
|
25
|
-
tabs[Math.max(0, component.activeTab - 1)]?.key ??
|
|
26
|
-
tabs[0]?.key ??
|
|
27
|
-
'',
|
|
48
|
+
() => fromQuery ?? tabs[Math.max(0, component.activeTab - 1)]?.key ?? tabs[0]?.key ?? '',
|
|
28
49
|
)
|
|
29
50
|
|
|
30
51
|
const persist = component.persistTab
|
|
@@ -36,6 +57,21 @@ export function TabsComponent({ component }: { component: TabsSchema }) {
|
|
|
36
57
|
hydrated && persist && !fromQuery ? readPersisted(persist.key, persist.storage) : null
|
|
37
58
|
const active = chosen ?? (stored && tabs.some((tab) => tab.key === stored) ? stored : initial)
|
|
38
59
|
|
|
60
|
+
// Tabs holding a field with a validation error are marked, and when a submit brings new
|
|
61
|
+
// errors while the open tab has none, the first tab with an error opens.
|
|
62
|
+
const t = useT()
|
|
63
|
+
const store = useOptionalFormStore()
|
|
64
|
+
const errors = useStoreSelector(store?.store ?? NO_FORM_STORE, (state) => state.errors)
|
|
65
|
+
const [seenErrors, setSeenErrors] = useState<Record<string, string[]>>({})
|
|
66
|
+
|
|
67
|
+
if (seenErrors !== errors) {
|
|
68
|
+
setSeenErrors(errors)
|
|
69
|
+
|
|
70
|
+
const reveal = tabToReveal(tabs, active, seenErrors, errors)
|
|
71
|
+
|
|
72
|
+
if (reveal !== null) setChosen(reveal)
|
|
73
|
+
}
|
|
74
|
+
|
|
39
75
|
const changeTab = (value: string) => {
|
|
40
76
|
setChosen(value)
|
|
41
77
|
|
|
@@ -45,10 +81,11 @@ export function TabsComponent({ component }: { component: TabsSchema }) {
|
|
|
45
81
|
useEffect(() => {
|
|
46
82
|
if (!component.queryStringKey || typeof window === 'undefined') return
|
|
47
83
|
|
|
84
|
+
const tab = tabs.find((item) => item.key === active)
|
|
48
85
|
const url = new URL(window.location.href)
|
|
49
|
-
url.searchParams.set(component.queryStringKey, active)
|
|
86
|
+
url.searchParams.set(component.queryStringKey, tab ? tabQueryValue(tab) : active)
|
|
50
87
|
window.history.replaceState(window.history.state, '', url)
|
|
51
|
-
}, [active, component.queryStringKey])
|
|
88
|
+
}, [active, component.queryStringKey, tabs])
|
|
52
89
|
|
|
53
90
|
return (
|
|
54
91
|
<Tabs
|
|
@@ -76,14 +113,32 @@ export function TabsComponent({ component }: { component: TabsSchema }) {
|
|
|
76
113
|
: 'relative max-w-full overflow-x-auto',
|
|
77
114
|
)}
|
|
78
115
|
>
|
|
79
|
-
{tabs.map((tab) =>
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
116
|
+
{tabs.map((tab) => {
|
|
117
|
+
const invalid = componentHasErrors(tab, errors)
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<TabsTrigger
|
|
121
|
+
key={tab.key}
|
|
122
|
+
value={tab.key}
|
|
123
|
+
data-invalid={invalid || undefined}
|
|
124
|
+
className={invalid ? TAB_INVALID_CLASSES : undefined}
|
|
125
|
+
>
|
|
126
|
+
{tab.icon && tab.iconPosition !== 'after' && <Icon name={tab.icon} />}
|
|
127
|
+
{tab.label}
|
|
128
|
+
{tab.icon && tab.iconPosition === 'after' && <Icon name={tab.icon} />}
|
|
129
|
+
<TabBadge tab={tab} />
|
|
130
|
+
{invalid && (
|
|
131
|
+
<>
|
|
132
|
+
<span
|
|
133
|
+
aria-hidden="true"
|
|
134
|
+
className="bg-destructive size-1.5 shrink-0 rounded-full"
|
|
135
|
+
/>
|
|
136
|
+
<span className="sr-only">({t('translatable.has_errors')})</span>
|
|
137
|
+
</>
|
|
138
|
+
)}
|
|
139
|
+
</TabsTrigger>
|
|
140
|
+
)
|
|
141
|
+
})}
|
|
87
142
|
</TabsList>
|
|
88
143
|
{tabs.map((tab) => (
|
|
89
144
|
<TabsContent
|
|
@@ -133,7 +133,7 @@ export function GlobalSearch({ variant = 'topbar' }: { variant?: 'topbar' | 'sid
|
|
|
133
133
|
</span>
|
|
134
134
|
)}
|
|
135
135
|
{hydrated && suffix?.type === 'keyBinding' && (
|
|
136
|
-
<kbd className="bg-muted hidden rounded px-1 font-mono text-[10px] sm:inline">
|
|
136
|
+
<kbd dir="ltr" className="bg-muted hidden rounded px-1 font-mono text-[10px] sm:inline">
|
|
137
137
|
{formatKeyBinding(suffix.keyBinding)}
|
|
138
138
|
</kbd>
|
|
139
139
|
)}
|
|
@@ -24,7 +24,10 @@ export function ShortcutKeys({ binding, className }: { binding: string; classNam
|
|
|
24
24
|
{t('shortcuts.then', {}, 'then')}
|
|
25
25
|
</span>
|
|
26
26
|
)}
|
|
27
|
-
<kbd
|
|
27
|
+
<kbd
|
|
28
|
+
dir="ltr"
|
|
29
|
+
className="bg-muted text-muted-foreground rounded border px-1.5 font-mono text-[10px] leading-5"
|
|
30
|
+
>
|
|
28
31
|
{step}
|
|
29
32
|
</kbd>
|
|
30
33
|
</span>
|
|
@@ -151,7 +151,7 @@ export function TenantMenu({ variant = 'sidebar' }: { variant?: 'sidebar' | 'top
|
|
|
151
151
|
<DropdownMenuItem asChild>
|
|
152
152
|
<Link href={tenancy.profileUrl} className="w-full">
|
|
153
153
|
<Icon name="alfakit::panels.tenant-menu.profile-button" fallback={SettingsIcon} />{' '}
|
|
154
|
-
{t('tenancy.profile', {}, 'Settings')}
|
|
154
|
+
{tenancy.profileLabel ?? t('tenancy.profile', {}, 'Settings')}
|
|
155
155
|
</Link>
|
|
156
156
|
</DropdownMenuItem>
|
|
157
157
|
)
|
|
@@ -164,7 +164,7 @@ export function TenantMenu({ variant = 'sidebar' }: { variant?: 'sidebar' | 'top
|
|
|
164
164
|
<DropdownMenuItem asChild>
|
|
165
165
|
<Link href={tenancy.registrationUrl} className="w-full">
|
|
166
166
|
<Icon name="alfakit::panels.tenant-menu.registration-button" fallback={PlusIcon} />{' '}
|
|
167
|
-
{t('tenancy.register', {}, 'New')}
|
|
167
|
+
{tenancy.registrationLabel ?? t('tenancy.register', {}, 'New')}
|
|
168
168
|
</Link>
|
|
169
169
|
</DropdownMenuItem>
|
|
170
170
|
)
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
} from '@alfakit/core'
|
|
27
27
|
import { router } from '@inertiajs/react'
|
|
28
28
|
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
|
|
29
|
-
import { createElement, useEffect, useRef, useState } from 'react'
|
|
29
|
+
import { createElement, useEffect, useId, useRef, useState } from 'react'
|
|
30
30
|
import { usePanel, useRegistry, useT } from '../../context'
|
|
31
31
|
import { extraProps } from '../../lib/attributes'
|
|
32
32
|
import {
|
|
@@ -587,6 +587,8 @@ function NativeSelectCell(props: CellProps) {
|
|
|
587
587
|
cell.state === null || cell.state === undefined ? '' : String(cell.state),
|
|
588
588
|
)
|
|
589
589
|
|
|
590
|
+
const errorId = useId()
|
|
591
|
+
|
|
590
592
|
return (
|
|
591
593
|
<span onClick={(event) => event.stopPropagation()}>
|
|
592
594
|
<select
|
|
@@ -596,7 +598,9 @@ function NativeSelectCell(props: CellProps) {
|
|
|
596
598
|
setValue(event.target.value)
|
|
597
599
|
void update(event.target.value === '' ? null : event.target.value)
|
|
598
600
|
}}
|
|
601
|
+
aria-label={column.label || column.name}
|
|
599
602
|
aria-invalid={error ? true : undefined}
|
|
603
|
+
aria-describedby={error ? errorId : undefined}
|
|
600
604
|
className={cn(
|
|
601
605
|
'border-input dark:bg-input/30 focus-visible:ring-ring/50 h-8 min-w-32 rounded-md border bg-transparent px-2 text-sm shadow-xs outline-none focus-visible:ring-[3px]',
|
|
602
606
|
error && 'border-destructive',
|
|
@@ -611,7 +615,11 @@ function NativeSelectCell(props: CellProps) {
|
|
|
611
615
|
</option>
|
|
612
616
|
))}
|
|
613
617
|
</select>
|
|
614
|
-
{error &&
|
|
618
|
+
{error && (
|
|
619
|
+
<span id={errorId} className="text-destructive block text-xs">
|
|
620
|
+
{error}
|
|
621
|
+
</span>
|
|
622
|
+
)}
|
|
615
623
|
</span>
|
|
616
624
|
)
|
|
617
625
|
}
|
|
@@ -626,6 +634,7 @@ function TextInputCell(props: CellProps) {
|
|
|
626
634
|
const initial = cell.state === null || cell.state === undefined ? '' : String(cell.state)
|
|
627
635
|
// `prefix()` / `suffix()`: text affixes around the input, like the TextInput field.
|
|
628
636
|
const grouped = Boolean(cell.prefix || cell.suffix)
|
|
637
|
+
const errorId = useId()
|
|
629
638
|
|
|
630
639
|
const field = (
|
|
631
640
|
<span className={cn('relative block', grouped && 'min-w-0 flex-1')}>
|
|
@@ -648,7 +657,9 @@ function TextInputCell(props: CellProps) {
|
|
|
648
657
|
step={schema.step}
|
|
649
658
|
inputMode={schema.inputMode as React.HTMLAttributes<HTMLInputElement>['inputMode']}
|
|
650
659
|
disabled={Boolean(cell.disabled) || saving}
|
|
660
|
+
aria-label={column.label || column.name}
|
|
651
661
|
aria-invalid={error ? true : undefined}
|
|
662
|
+
aria-describedby={error ? errorId : undefined}
|
|
652
663
|
className={cn(
|
|
653
664
|
'h-8 min-w-32',
|
|
654
665
|
schema.prefixIcon && 'ps-8',
|
|
@@ -706,7 +717,11 @@ function TextInputCell(props: CellProps) {
|
|
|
706
717
|
) : (
|
|
707
718
|
field
|
|
708
719
|
)}
|
|
709
|
-
{error &&
|
|
720
|
+
{error && (
|
|
721
|
+
<span id={errorId} className="text-destructive block text-xs">
|
|
722
|
+
{error}
|
|
723
|
+
</span>
|
|
724
|
+
)}
|
|
710
725
|
</span>
|
|
711
726
|
)
|
|
712
727
|
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { ChevronDownIcon, ChevronRightIcon, LayersIcon } from 'lucide-react'
|
|
15
15
|
import { useEffect, useState } from 'react'
|
|
16
16
|
import { useT } from '../../context'
|
|
17
|
+
import { colorClass, textColorClasses } from '../../lib/colors'
|
|
17
18
|
import { Icon } from '../../lib/icons'
|
|
18
19
|
import { cn } from '../../lib/utils'
|
|
19
20
|
import { Checkbox } from '../ui/checkbox'
|
|
@@ -183,7 +184,7 @@ export function GroupHeaderRow({
|
|
|
183
184
|
selection?: { checked: boolean | 'indeterminate'; onChange: (checked: boolean) => void }
|
|
184
185
|
}) {
|
|
185
186
|
const t = useT()
|
|
186
|
-
const
|
|
187
|
+
const text = (
|
|
187
188
|
<span className="flex flex-col items-start">
|
|
188
189
|
<span className="font-medium">{group.title}</span>
|
|
189
190
|
{group.description && (
|
|
@@ -191,6 +192,19 @@ export function GroupHeaderRow({
|
|
|
191
192
|
)}
|
|
192
193
|
</span>
|
|
193
194
|
)
|
|
195
|
+
// An enum group shows its `HasIcon` icon, in its `HasColor` colour, before the title.
|
|
196
|
+
const content = group.icon ? (
|
|
197
|
+
<span data-slot="table-group-title" className="flex items-center gap-2">
|
|
198
|
+
<Icon
|
|
199
|
+
name={group.icon}
|
|
200
|
+
className={cn('size-4 shrink-0', colorClass(textColorClasses, group.color, 'gray'))}
|
|
201
|
+
aria-hidden="true"
|
|
202
|
+
/>
|
|
203
|
+
{text}
|
|
204
|
+
</span>
|
|
205
|
+
) : (
|
|
206
|
+
text
|
|
207
|
+
)
|
|
194
208
|
|
|
195
209
|
return (
|
|
196
210
|
<TableRow data-slot="table-group" className="bg-muted/50 hover:bg-muted/50">
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { resolveToastPosition, type ToastPosition } from '@alfakit/core'
|
|
1
|
+
import { resolveToastPosition, STARTER_KIT_TOASTER_ID, type ToastPosition } from '@alfakit/core'
|
|
2
2
|
import { useEffect, useMemo } from 'react'
|
|
3
|
+
import { setToasterId } from '../components/notifications/flash-toasts'
|
|
3
4
|
import { Toaster } from '../components/ui/sonner'
|
|
4
5
|
import { useOptionalAlfakit, type AlfakitContextValue } from '../context'
|
|
5
6
|
import { claimToaster, createStandaloneContext, ensureFlashToasts } from './runtime'
|
|
@@ -46,7 +47,25 @@ export function AlfakitToaster({
|
|
|
46
47
|
ensureFlashToasts(context.registry)
|
|
47
48
|
}, [context])
|
|
48
49
|
|
|
50
|
+
// Our own toaster gets an id, so a `<Toaster />` the app's layout already mounts (the
|
|
51
|
+
// starter kits have one) doesn't show every Alfakit toast a second time.
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!toaster) return undefined
|
|
54
|
+
|
|
55
|
+
setToasterId(STARTER_KIT_TOASTER_ID)
|
|
56
|
+
|
|
57
|
+
return () => setToasterId(undefined)
|
|
58
|
+
}, [toaster])
|
|
59
|
+
|
|
49
60
|
if (!toaster) return null
|
|
50
61
|
|
|
51
|
-
return
|
|
62
|
+
return (
|
|
63
|
+
<Toaster
|
|
64
|
+
id={STARTER_KIT_TOASTER_ID}
|
|
65
|
+
position={resolveToastPosition(position, dir)}
|
|
66
|
+
dir={dir}
|
|
67
|
+
richColors
|
|
68
|
+
closeButton
|
|
69
|
+
/>
|
|
70
|
+
)
|
|
52
71
|
}
|
|
@@ -207,7 +207,7 @@ function TenantChooser({ panel }: { panel: PanelSchema }) {
|
|
|
207
207
|
{tenancy.tenants.length > 0 && <DropdownMenuSeparator />}
|
|
208
208
|
<DropdownMenuItem onSelect={() => router.visit(tenancy.registrationUrl!)}>
|
|
209
209
|
<Icon name="alfakit::panels.tenant-menu.registration-button" fallback={PlusIcon} />
|
|
210
|
-
{t('tenancy.register', {}, 'New')}
|
|
210
|
+
{tenancy.registrationLabel ?? t('tenancy.register', {}, 'New')}
|
|
211
211
|
</DropdownMenuItem>
|
|
212
212
|
</>
|
|
213
213
|
)}
|
|
@@ -49,7 +49,7 @@ export function SearchTrigger({ source, onLoad }: WidgetProps & { onLoad?: () =>
|
|
|
49
49
|
<SearchIcon className="size-4" aria-hidden="true" />
|
|
50
50
|
<span className="sr-only sm:not-sr-only sm:flex-1 sm:text-start">{placeholder}</span>
|
|
51
51
|
{hydrated && binding && (
|
|
52
|
-
<kbd className="bg-muted hidden rounded px-1 font-mono text-[10px] sm:inline">
|
|
52
|
+
<kbd dir="ltr" className="bg-muted hidden rounded px-1 font-mono text-[10px] sm:inline">
|
|
53
53
|
{formatKeyBinding(binding)}
|
|
54
54
|
</kbd>
|
|
55
55
|
)}
|