@torch-ui/solid 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +166 -0
  2. package/package.json +67 -0
  3. package/src/components/actions/Button.tsx +612 -0
  4. package/src/components/actions/ButtonGroup.tsx +728 -0
  5. package/src/components/actions/Copy.tsx +98 -0
  6. package/src/components/actions/DarkModeToggle.tsx +80 -0
  7. package/src/components/actions/Link.tsx +37 -0
  8. package/src/components/actions/index.ts +19 -0
  9. package/src/components/actions/useCopyToClipboard.ts +90 -0
  10. package/src/components/charts/Chart.tsx +331 -0
  11. package/src/components/charts/Sparkline.tsx +156 -0
  12. package/src/components/charts/index.ts +13 -0
  13. package/src/components/data-display/Avatar.tsx +208 -0
  14. package/src/components/data-display/AvatarGroup.tsx +228 -0
  15. package/src/components/data-display/Badge.tsx +70 -0
  16. package/src/components/data-display/Carousel.tsx +214 -0
  17. package/src/components/data-display/ColorSwatch.tsx +56 -0
  18. package/src/components/data-display/DataTable.tsx +886 -0
  19. package/src/components/data-display/EmptyState.tsx +61 -0
  20. package/src/components/data-display/Image.tsx +277 -0
  21. package/src/components/data-display/Kbd.tsx +114 -0
  22. package/src/components/data-display/Persona.tsx +78 -0
  23. package/src/components/data-display/StatCard.tsx +338 -0
  24. package/src/components/data-display/Table.tsx +147 -0
  25. package/src/components/data-display/Tag.tsx +91 -0
  26. package/src/components/data-display/Timeline.tsx +200 -0
  27. package/src/components/data-display/TreeView.tsx +172 -0
  28. package/src/components/data-display/Video.tsx +95 -0
  29. package/src/components/data-display/avatar-utils.ts +32 -0
  30. package/src/components/data-display/index.ts +81 -0
  31. package/src/components/feedback/Loading.tsx +159 -0
  32. package/src/components/feedback/Progress.tsx +321 -0
  33. package/src/components/feedback/Skeleton.tsx +62 -0
  34. package/src/components/feedback/SkeletonBlocks.tsx +222 -0
  35. package/src/components/feedback/Toast.tsx +648 -0
  36. package/src/components/feedback/index.ts +44 -0
  37. package/src/components/feedback/password/PasswordStrengthIndicator.tsx +232 -0
  38. package/src/components/feedback/password/password-strength.ts +115 -0
  39. package/src/components/feedback/password/password-validation-data.ts +66 -0
  40. package/src/components/feedback/password/password-validation.ts +93 -0
  41. package/src/components/forms/Autocomplete.tsx +268 -0
  42. package/src/components/forms/Checkbox.tsx +155 -0
  43. package/src/components/forms/CodeInput.tsx +237 -0
  44. package/src/components/forms/ColorPicker/ColorPicker.tsx +469 -0
  45. package/src/components/forms/ColorPicker/color-utils.ts +75 -0
  46. package/src/components/forms/ColorPicker/index.ts +2 -0
  47. package/src/components/forms/DatePicker.tsx +516 -0
  48. package/src/components/forms/DateRangePicker.tsx +464 -0
  49. package/src/components/forms/FieldPicker.tsx +64 -0
  50. package/src/components/forms/FileUpload.tsx +614 -0
  51. package/src/components/forms/FilterBuilder/FilterGroupBlock.ts +6 -0
  52. package/src/components/forms/FilterBuilder.tsx +16 -0
  53. package/src/components/forms/FilterRuleRow.tsx +68 -0
  54. package/src/components/forms/Input.tsx +200 -0
  55. package/src/components/forms/MultiSelect.tsx +361 -0
  56. package/src/components/forms/NumberField.tsx +145 -0
  57. package/src/components/forms/RadioGroup.tsx +135 -0
  58. package/src/components/forms/RelativeDateDefaultInput.tsx +62 -0
  59. package/src/components/forms/ReorderableList.tsx +163 -0
  60. package/src/components/forms/Select.tsx +268 -0
  61. package/src/components/forms/Slider.tsx +260 -0
  62. package/src/components/forms/Switch.tsx +135 -0
  63. package/src/components/forms/TextArea.tsx +202 -0
  64. package/src/components/forms/ViewCustomizer.tsx +44 -0
  65. package/src/components/forms/index.ts +43 -0
  66. package/src/components/layout/Accordion.tsx +110 -0
  67. package/src/components/layout/Alert.tsx +156 -0
  68. package/src/components/layout/BlockQuote.tsx +70 -0
  69. package/src/components/layout/Card.tsx +166 -0
  70. package/src/components/layout/CodeBlock/CodeBlock.tsx +477 -0
  71. package/src/components/layout/CodeBlock/code-block-tokens.css +104 -0
  72. package/src/components/layout/CodeBlock/prism.ts +81 -0
  73. package/src/components/layout/Collapsible.tsx +84 -0
  74. package/src/components/layout/Container.tsx +55 -0
  75. package/src/components/layout/Divider.tsx +64 -0
  76. package/src/components/layout/Form.tsx +39 -0
  77. package/src/components/layout/FormActions.tsx +50 -0
  78. package/src/components/layout/Grid.tsx +53 -0
  79. package/src/components/layout/PageHeading.tsx +46 -0
  80. package/src/components/layout/PromptWithAction.tsx +49 -0
  81. package/src/components/layout/Section.tsx +60 -0
  82. package/src/components/layout/TablePanel.tsx +24 -0
  83. package/src/components/layout/TableView/TableView.tsx +1018 -0
  84. package/src/components/layout/TableView/index.ts +3 -0
  85. package/src/components/layout/TableView/types.ts +51 -0
  86. package/src/components/layout/WizardStep.tsx +40 -0
  87. package/src/components/layout/WizardStepper.tsx +173 -0
  88. package/src/components/layout/index.ts +96 -0
  89. package/src/components/navigation/Breadcrumbs.tsx +66 -0
  90. package/src/components/navigation/DropdownMenu.tsx +86 -0
  91. package/src/components/navigation/MegaMenu.tsx +480 -0
  92. package/src/components/navigation/NavigationMenu.tsx +305 -0
  93. package/src/components/navigation/Pagination.tsx +298 -0
  94. package/src/components/navigation/Sidebar.tsx +280 -0
  95. package/src/components/navigation/Tabs.tsx +122 -0
  96. package/src/components/navigation/ViewSwitcher.tsx +314 -0
  97. package/src/components/navigation/index.ts +66 -0
  98. package/src/components/overlays/AlertDialog.tsx +174 -0
  99. package/src/components/overlays/ContextMenu.tsx +65 -0
  100. package/src/components/overlays/Dialog.tsx +279 -0
  101. package/src/components/overlays/Drawer.tsx +370 -0
  102. package/src/components/overlays/HoverCard.tsx +107 -0
  103. package/src/components/overlays/Popover.tsx +73 -0
  104. package/src/components/overlays/Tooltip.tsx +31 -0
  105. package/src/components/overlays/index.ts +71 -0
  106. package/src/components/typography/Code.tsx +72 -0
  107. package/src/components/typography/Icon.tsx +36 -0
  108. package/src/components/typography/index.ts +10 -0
  109. package/src/env.d.ts +9 -0
  110. package/src/index.ts +13 -0
  111. package/src/styles/theme.css +226 -0
  112. package/src/types/avatar-types.ts +11 -0
  113. package/src/types/filter-types.ts +35 -0
  114. package/src/utilities/classNames.ts +6 -0
  115. package/src/utilities/componentSize.ts +46 -0
  116. package/src/utilities/i18n.tsx +60 -0
  117. package/src/utilities/mergeRefs.ts +12 -0
  118. package/src/utilities/relativeDateDefault.ts +14 -0
@@ -0,0 +1,145 @@
1
+ import type { JSX } from 'solid-js'
2
+ import { splitProps, Show } from 'solid-js'
3
+ import { NumberField as KobalteNumberField } from '@kobalte/core/number-field'
4
+ import { ChevronDown, ChevronUp } from 'lucide-solid'
5
+ import { cn } from '../../utilities/classNames'
6
+ import { type ComponentSize, inputSizeConfig } from '../../utilities/componentSize'
7
+
8
+ export interface NumberFieldProps {
9
+ /** Label above the input. */
10
+ label?: string
11
+ /** Error message and invalid styling. */
12
+ error?: string
13
+ /** Hint text below the input. */
14
+ helperText?: string
15
+ /** When true, never render label row or error/helper text (control only). */
16
+ bare?: boolean
17
+ /** When true, show required indicator on label. */
18
+ required?: boolean
19
+ /** When true, show "optional" on the label row when not required. Default false. */
20
+ optional?: boolean
21
+ /** Controlled value (number). */
22
+ value?: number
23
+ /** Called when value changes. */
24
+ onChange?: (value: number | undefined) => void
25
+ /** Minimum value. */
26
+ minValue?: number
27
+ /** Maximum value. */
28
+ maxValue?: number
29
+ /** Step for increment/decrement. */
30
+ step?: number
31
+ /** Disables the input and steppers. */
32
+ disabled?: boolean
33
+ /** Placeholder when empty. */
34
+ placeholder?: string
35
+ /** Input size. Controls height, text size, and padding. Default: md (36px). */
36
+ size?: ComponentSize
37
+ /** When true, show increment/decrement buttons. */
38
+ showStepper?: boolean
39
+ /** Additional class for the root. */
40
+ class?: string
41
+ /** Ref forwarded to the number input element. */
42
+ ref?: (el: HTMLInputElement) => void
43
+ }
44
+
45
+ export function NumberField(props: NumberFieldProps) {
46
+ const [local, others] = splitProps(props, [
47
+ 'label',
48
+ 'error',
49
+ 'helperText',
50
+ 'bare',
51
+ 'required',
52
+ 'optional',
53
+ 'value',
54
+ 'onChange',
55
+ 'minValue',
56
+ 'maxValue',
57
+ 'step',
58
+ 'disabled',
59
+ 'placeholder',
60
+ 'size',
61
+ 'showStepper',
62
+ 'class',
63
+ 'ref',
64
+ ])
65
+
66
+ const hasError = () => !!local.error
67
+ const sc = () => inputSizeConfig[local.size ?? 'md']
68
+
69
+ const handleRawValueChange = (v: number | undefined) => {
70
+ local.onChange?.(v)
71
+ }
72
+
73
+ return (
74
+ <div class={cn('w-full', local.class)}>
75
+ <KobalteNumberField
76
+ rawValue={local.value}
77
+ onRawValueChange={handleRawValueChange}
78
+ minValue={local.minValue}
79
+ maxValue={local.maxValue}
80
+ step={local.step}
81
+ disabled={local.disabled}
82
+ validationState={hasError() ? 'invalid' : undefined}
83
+ {...others}
84
+ >
85
+ <Show when={!local.bare && local.label}>
86
+ <div class="flex items-center justify-between mb-1.5">
87
+ <KobalteNumberField.Label
88
+ class={cn(
89
+ 'block text-md font-medium',
90
+ hasError() ? 'text-danger-600' : 'text-ink-700'
91
+ )}
92
+ >
93
+ {local.label}
94
+ <Show when={local.required}>
95
+ <span class="text-danger-500 ml-0.5" aria-hidden="true">*</span>
96
+ </Show>
97
+ </KobalteNumberField.Label>
98
+ <Show when={local.label && !local.required && local.optional}>
99
+ <span class="text-xs text-ink-500">optional</span>
100
+ </Show>
101
+ </div>
102
+ </Show>
103
+ <div class="relative flex items-stretch">
104
+ <KobalteNumberField.Input
105
+ ref={local.ref}
106
+ placeholder={local.placeholder}
107
+ class={cn(
108
+ 'w-full rounded-lg transition-all outline-none border text-ink-900 placeholder:text-ink-400 dark:placeholder:text-ink-500 bg-surface-raised',
109
+ sc().h, sc().py, sc().text,
110
+ local.showStepper ? sc().plAdorn : sc().pl, sc().pr,
111
+ hasError()
112
+ ? 'border-danger-500 focus:ring-2 focus:ring-inset focus:ring-danger-500 focus:border-transparent'
113
+ : 'border-ink-300 focus:ring-2 focus:ring-inset focus:ring-primary-500 focus:border-transparent',
114
+ 'disabled:bg-surface-base disabled:text-ink-500 dark:disabled:text-ink-500 disabled:cursor-not-allowed'
115
+ )}
116
+ />
117
+ <Show when={local.showStepper}>
118
+ <div class="absolute right-0 top-0 bottom-0 flex flex-col border-l border-ink-300 rounded-r-lg overflow-hidden">
119
+ <KobalteNumberField.IncrementTrigger
120
+ class="flex-1 flex items-center justify-center min-h-0 px-2 text-ink-500 hover:bg-ink-100 dark:hover:bg-ink-800 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500"
121
+ >
122
+ <ChevronUp class="h-4 w-4" />
123
+ </KobalteNumberField.IncrementTrigger>
124
+ <KobalteNumberField.DecrementTrigger
125
+ class="flex-1 flex items-center justify-center min-h-0 px-2 text-ink-500 hover:bg-ink-100 dark:hover:bg-ink-800 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500"
126
+ >
127
+ <ChevronDown class="h-4 w-4" />
128
+ </KobalteNumberField.DecrementTrigger>
129
+ </div>
130
+ </Show>
131
+ </div>
132
+ <Show when={!local.bare && !hasError() && local.helperText}>
133
+ <KobalteNumberField.Description class="mt-2 text-sm text-ink-500">
134
+ {local.helperText}
135
+ </KobalteNumberField.Description>
136
+ </Show>
137
+ <Show when={!local.bare && hasError()}>
138
+ <KobalteNumberField.ErrorMessage class="mt-2 text-sm text-danger-600">
139
+ {local.error}
140
+ </KobalteNumberField.ErrorMessage>
141
+ </Show>
142
+ </KobalteNumberField>
143
+ </div>
144
+ )
145
+ }
@@ -0,0 +1,135 @@
1
+ import { For, splitProps, Show } from 'solid-js'
2
+ import { RadioGroup as KobalteRadioGroup } from '@kobalte/core/radio-group'
3
+ import { cn } from '../../utilities/classNames'
4
+
5
+ export interface RadioGroupOption {
6
+ value: string
7
+ label: string
8
+ /** Optional description for the option. */
9
+ description?: string
10
+ }
11
+
12
+ export interface RadioGroupProps {
13
+ /** Group label (e.g. "Choose one"). */
14
+ label?: string
15
+ /** Optional description below the label. */
16
+ description?: string
17
+ /** Error message and invalid styling. */
18
+ error?: string
19
+ /** When true, show required indicator on label. */
20
+ required?: boolean
21
+ /** Options to display (value + label, optional description). */
22
+ options: RadioGroupOption[]
23
+ /** Selected value (controlled). */
24
+ value?: string
25
+ /** Called when selection changes. */
26
+ onChange?: (value: string) => void
27
+ /** Disables all options. */
28
+ disabled?: boolean
29
+ /** Form name. */
30
+ name?: string
31
+ /** Layout: vertical list (default) or horizontal. */
32
+ orientation?: 'vertical' | 'horizontal'
33
+ /** Additional class for the root. */
34
+ class?: string
35
+ }
36
+
37
+ export function RadioGroup(props: RadioGroupProps) {
38
+ const [local, others] = splitProps(props, [
39
+ 'label',
40
+ 'description',
41
+ 'error',
42
+ 'required',
43
+ 'options',
44
+ 'value',
45
+ 'onChange',
46
+ 'disabled',
47
+ 'name',
48
+ 'orientation',
49
+ 'class',
50
+ ])
51
+
52
+ const hasError = () => !!local.error
53
+
54
+ return (
55
+ <div class={cn('w-full', local.class)}>
56
+ <KobalteRadioGroup
57
+ value={local.value}
58
+ onChange={local.onChange}
59
+ disabled={local.disabled}
60
+ validationState={hasError() ? 'invalid' : undefined}
61
+ name={local.name}
62
+ orientation={local.orientation ?? 'vertical'}
63
+ {...others}
64
+ >
65
+ <Show when={local.label}>
66
+ <KobalteRadioGroup.Label
67
+ class={cn(
68
+ 'block text-md font-medium mb-2',
69
+ hasError() ? 'text-danger-600' : 'text-ink-700',
70
+ local.disabled && 'opacity-50'
71
+ )}
72
+ >
73
+ {local.label}
74
+ {local.required && <span class="text-danger-500 ml-0.5" aria-hidden="true">*</span>}
75
+ </KobalteRadioGroup.Label>
76
+ </Show>
77
+ <div
78
+ role="presentation"
79
+ class={cn(
80
+ 'flex gap-3',
81
+ local.orientation === 'horizontal' ? 'flex-row flex-wrap' : 'flex-col'
82
+ )}
83
+ >
84
+ <For each={local.options}>
85
+ {(opt) => (
86
+ <KobalteRadioGroup.Item
87
+ value={opt.value}
88
+ class={cn(
89
+ 'inline-flex items-start gap-3 cursor-pointer select-none rounded-lg border border-transparent p-3 transition-colors outline-none',
90
+ 'data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed',
91
+ 'data-[highlighted]:bg-ink-50 dark:data-[highlighted]:bg-ink-800/50',
92
+ 'focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2'
93
+ )}
94
+ >
95
+ <KobalteRadioGroup.ItemInput class="sr-only" />
96
+ <KobalteRadioGroup.ItemControl
97
+ class={cn(
98
+ 'mt-0.5 h-4 w-4 shrink-0 rounded-full flex items-center justify-center transition-colors box-border',
99
+ // Unselected: gray ring, transparent interior
100
+ 'border-2 border-ink-400 bg-transparent',
101
+ // Selected: primary ring, transparent interior; dot is the ItemIndicator
102
+ 'data-[checked]:border-primary-500 data-[checked]:bg-transparent',
103
+ 'data-[disabled]:opacity-50'
104
+ )}
105
+ >
106
+ <KobalteRadioGroup.ItemIndicator class="h-2 w-2 rounded-full bg-primary-500 shrink-0 pointer-events-none" />
107
+ </KobalteRadioGroup.ItemControl>
108
+ <span class="flex flex-col gap-0.5">
109
+ <KobalteRadioGroup.ItemLabel class="text-sm font-medium text-ink-900">
110
+ {opt.label}
111
+ </KobalteRadioGroup.ItemLabel>
112
+ <Show when={opt.description}>
113
+ <KobalteRadioGroup.ItemDescription class="text-xs text-ink-500">
114
+ {opt.description}
115
+ </KobalteRadioGroup.ItemDescription>
116
+ </Show>
117
+ </span>
118
+ </KobalteRadioGroup.Item>
119
+ )}
120
+ </For>
121
+ </div>
122
+ <Show when={local.description && !hasError()}>
123
+ <KobalteRadioGroup.Description class="mt-2 text-sm text-ink-500">
124
+ {local.description}
125
+ </KobalteRadioGroup.Description>
126
+ </Show>
127
+ <Show when={hasError()}>
128
+ <KobalteRadioGroup.ErrorMessage class="mt-2 text-sm text-danger-600">
129
+ {local.error}
130
+ </KobalteRadioGroup.ErrorMessage>
131
+ </Show>
132
+ </KobalteRadioGroup>
133
+ </div>
134
+ )
135
+ }
@@ -0,0 +1,62 @@
1
+ import { createMemo } from 'solid-js'
2
+ import { Input, Select } from './'
3
+ import { parseRelativeDateDefault, formatRelativeDateDefault } from '../../utilities/relativeDateDefault'
4
+ import { cn } from '../../utilities/classNames'
5
+
6
+ const SIGN_OPTIONS = [
7
+ { value: '+', label: '+' },
8
+ { value: '-', label: '-' },
9
+ ] as const
10
+
11
+ export interface RelativeDateDefaultInputProps {
12
+ value: string
13
+ onValueChange: (value: string) => void
14
+ /** Label before the controls (default "Today") */
15
+ prefixLabel?: string
16
+ /** Label after days (default "day(s)") */
17
+ suffixLabel?: string
18
+ class?: string
19
+ }
20
+
21
+ /** Today + sign (+/−) + integer days. Produces stored value like "today+0", "today-7". */
22
+ export function RelativeDateDefaultInput(props: RelativeDateDefaultInputProps) {
23
+ const parsed = createMemo(() => parseRelativeDateDefault(props.value))
24
+ const sign = () => parsed().sign
25
+ const days = () => parsed().days
26
+
27
+ const setSign = (s: string) => {
28
+ props.onValueChange(formatRelativeDateDefault((s as '+' | '-') || '+', days()))
29
+ }
30
+ const setDaysFromInput = (v: string) => {
31
+ const n = v === '' ? 0 : Math.max(0, parseInt(v, 10) || 0)
32
+ props.onValueChange(formatRelativeDateDefault(sign(), n))
33
+ }
34
+ const daysStr = () => String(days())
35
+
36
+ return (
37
+ <div class={cn('flex flex-nowrap items-center gap-2', props.class)}>
38
+ <span class="shrink-0 text-sm font-medium text-ink-700">
39
+ {props.prefixLabel ?? 'Today'}
40
+ </span>
41
+ <div class="flex shrink-0 items-center gap-2">
42
+ <Select
43
+ value={sign()}
44
+ onValueChange={setSign}
45
+ options={[...SIGN_OPTIONS]}
46
+ class="w-36 min-w-0 rounded-lg"
47
+ />
48
+ <Input
49
+ bare
50
+ type="number"
51
+ min={0}
52
+ step={1}
53
+ value={daysStr()}
54
+ onValueChange={setDaysFromInput}
55
+ placeholder="0"
56
+ class="w-24 rounded-lg pr-2"
57
+ />
58
+ </div>
59
+ <span class="shrink-0 text-sm text-ink-500">{props.suffixLabel ?? 'day(s)'}</span>
60
+ </div>
61
+ )
62
+ }
@@ -0,0 +1,163 @@
1
+ import { For, Show, type JSX, splitProps } from 'solid-js'
2
+ import { ChevronDown, ChevronUp, GripVertical, X } from 'lucide-solid'
3
+ import {
4
+ DragDropProvider,
5
+ DragDropSensors,
6
+ SortableProvider,
7
+ createSortable,
8
+ type DragEvent as DnDDragEvent,
9
+ } from '@thisbeyond/solid-dnd'
10
+ import { cn } from '../../utilities/classNames'
11
+
12
+ export interface ReorderableListItem {
13
+ id: string
14
+ label: string
15
+ }
16
+
17
+ export interface ReorderableListProps extends JSX.HTMLAttributes<HTMLDivElement> {
18
+ items: ReorderableListItem[]
19
+ onReorder: (ids: string[]) => void
20
+ /** When true, show up/down arrow buttons to reorder. Default false (drag only). */
21
+ showMoveButtons?: boolean
22
+ onRemove?: (id: string) => void
23
+ class?: string
24
+ }
25
+
26
+ function SortableItem(props: {
27
+ item: ReorderableListItem
28
+ index: number
29
+ count: number
30
+ showMoveButtons: boolean
31
+ onRemove?: (id: string) => void
32
+ move: (id: string, direction: 'up' | 'down') => void
33
+ }) {
34
+ const sortable = createSortable(props.item.id)
35
+
36
+ const canMoveUp = () => props.index > 0
37
+ const canMoveDown = () => props.index < props.count - 1
38
+
39
+ return (
40
+ <div
41
+ role="listitem"
42
+ ref={sortable.ref}
43
+ // @ts-expect-error solid-dnd directive not in JSX namespace
44
+ use:sortable
45
+ class={cn(
46
+ 'flex items-center justify-between rounded-lg border border-surface-border bg-surface-raised px-4 py-3 text-sm transition-shadow',
47
+ sortable.isActiveDraggable && 'z-50 shadow-lg',
48
+ )}
49
+ >
50
+ <div class="flex items-center gap-2 text-ink-700">
51
+ <button
52
+ type="button"
53
+ class="inline-flex h-7 w-7 shrink-0 cursor-grab items-center justify-center rounded-md text-ink-400 hover:bg-ink-100 active:cursor-grabbing dark:hover:bg-ink-700"
54
+ aria-label={`Drag to reorder ${props.item.label}`}
55
+ {...sortable.dragActivators}
56
+ >
57
+ <GripVertical class="h-4 w-4" aria-hidden="true" />
58
+ </button>
59
+ <span>{props.item.label}</span>
60
+ </div>
61
+ <div class="flex shrink-0 items-center gap-1">
62
+ <Show when={props.showMoveButtons}>
63
+ <button
64
+ type="button"
65
+ disabled={!canMoveUp()}
66
+ class={cn(
67
+ 'rounded-md p-1 text-ink-500',
68
+ canMoveUp() && 'hover:bg-ink-100 dark:hover:bg-ink-700',
69
+ !canMoveUp() && 'opacity-40 cursor-not-allowed',
70
+ )}
71
+ aria-label={`Move ${props.item.label} up`}
72
+ onClick={() => props.move(props.item.id, 'up')}
73
+ >
74
+ <ChevronUp class="h-4 w-4" aria-hidden="true" />
75
+ </button>
76
+ <button
77
+ type="button"
78
+ disabled={!canMoveDown()}
79
+ class={cn(
80
+ 'rounded-md p-1 text-ink-500',
81
+ canMoveDown() && 'hover:bg-ink-100 dark:hover:bg-ink-700',
82
+ !canMoveDown() && 'opacity-40 cursor-not-allowed',
83
+ )}
84
+ aria-label={`Move ${props.item.label} down`}
85
+ onClick={() => props.move(props.item.id, 'down')}
86
+ >
87
+ <ChevronDown class="h-4 w-4" aria-hidden="true" />
88
+ </button>
89
+ </Show>
90
+ <Show when={props.onRemove}>
91
+ <button
92
+ type="button"
93
+ class="rounded-md p-1 text-ink-500 hover:bg-ink-100 dark:hover:bg-ink-700"
94
+ aria-label={`Remove ${props.item.label}`}
95
+ onClick={() => props.onRemove?.(props.item.id)}
96
+ >
97
+ <X class="h-4 w-4" aria-hidden="true" />
98
+ </button>
99
+ </Show>
100
+ </div>
101
+ </div>
102
+ )
103
+ }
104
+
105
+ function ReorderableListInner(props: ReorderableListProps) {
106
+ const [local, others] = splitProps(props, ['items', 'onReorder', 'onRemove', 'showMoveButtons', 'class'])
107
+ const showMoveButtons = () => local.showMoveButtons === true
108
+
109
+ const move = (id: string, direction: 'up' | 'down') => {
110
+ const ids = local.items.map((item) => item.id)
111
+ const index = ids.indexOf(id)
112
+ const nextIndex = direction === 'up' ? index - 1 : index + 1
113
+ if (index === -1 || nextIndex < 0 || nextIndex >= ids.length) return
114
+ const next = [...ids]
115
+ const [moved] = next.splice(index, 1)
116
+ next.splice(nextIndex, 0, moved)
117
+ local.onReorder(next)
118
+ }
119
+
120
+ return (
121
+ <div role="list" class={cn('space-y-2', local.class)} {...others}>
122
+ <For each={local.items}>
123
+ {(item, index) => (
124
+ <SortableItem
125
+ item={item}
126
+ index={index()}
127
+ count={local.items.length}
128
+ showMoveButtons={showMoveButtons()}
129
+ onRemove={local.onRemove}
130
+ move={move}
131
+ />
132
+ )}
133
+ </For>
134
+ </div>
135
+ )
136
+ }
137
+
138
+ export function ReorderableList(props: ReorderableListProps) {
139
+ const ids = () => props.items.map((i) => i.id)
140
+
141
+ const handleDragEnd = (event: DnDDragEvent) => {
142
+ const { draggable, droppable } = event
143
+ if (!droppable || draggable.id === droppable.id) return
144
+ const order = props.items.map((i) => i.id)
145
+ const fromIndex = order.indexOf(String(draggable.id))
146
+ const toIndex = order.indexOf(String(droppable.id))
147
+ if (fromIndex === -1 || toIndex === -1) return
148
+ const next = [...order]
149
+ const [moved] = next.splice(fromIndex, 1)
150
+ next.splice(toIndex, 0, moved)
151
+ // Defer so Solid DnD can clear transforms before we re-render
152
+ queueMicrotask(() => props.onReorder(next))
153
+ }
154
+
155
+ return (
156
+ <DragDropProvider onDragEnd={handleDragEnd}>
157
+ <DragDropSensors />
158
+ <SortableProvider ids={ids()}>
159
+ <ReorderableListInner {...props} />
160
+ </SortableProvider>
161
+ </DragDropProvider>
162
+ )
163
+ }