select.tsx 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. import { Select as Kobalte } from "@kobalte/core/select"
  2. import { createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js"
  3. import { pipe, groupBy, entries, map } from "remeda"
  4. import { Button, ButtonProps } from "./button"
  5. import { Icon } from "./icon"
  6. export type SelectProps<T> = Omit<ComponentProps<typeof Kobalte<T>>, "value" | "onSelect" | "children"> & {
  7. placeholder?: string
  8. options: T[]
  9. current?: T
  10. value?: (x: T) => string
  11. label?: (x: T) => string
  12. groupBy?: (x: T) => string
  13. onSelect?: (value: T | undefined) => void
  14. onHighlight?: (value: T | undefined) => (() => void) | void
  15. class?: ComponentProps<"div">["class"]
  16. classList?: ComponentProps<"div">["classList"]
  17. children?: (item: T | undefined) => JSX.Element
  18. }
  19. export function Select<T>(props: SelectProps<T> & ButtonProps) {
  20. const [local, others] = splitProps(props, [
  21. "class",
  22. "classList",
  23. "placeholder",
  24. "options",
  25. "current",
  26. "value",
  27. "label",
  28. "groupBy",
  29. "onSelect",
  30. "onHighlight",
  31. "onOpenChange",
  32. "children",
  33. ])
  34. const state = {
  35. key: undefined as string | undefined,
  36. cleanup: undefined as (() => void) | void,
  37. }
  38. const stop = () => {
  39. state.cleanup?.()
  40. state.cleanup = undefined
  41. state.key = undefined
  42. }
  43. const keyFor = (item: T) => (local.value ? local.value(item) : (item as string))
  44. const move = (item: T | undefined) => {
  45. if (!local.onHighlight) return
  46. if (!item) {
  47. stop()
  48. return
  49. }
  50. const key = keyFor(item)
  51. if (state.key === key) return
  52. state.cleanup?.()
  53. state.cleanup = local.onHighlight(item)
  54. state.key = key
  55. }
  56. onCleanup(stop)
  57. const grouped = createMemo(() => {
  58. const result = pipe(
  59. local.options,
  60. groupBy((x) => (local.groupBy ? local.groupBy(x) : "")),
  61. // mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
  62. entries(),
  63. map(([k, v]) => ({ category: k, options: v })),
  64. )
  65. return result
  66. })
  67. return (
  68. // @ts-ignore
  69. <Kobalte<T, { category: string; options: T[] }>
  70. {...others}
  71. data-component="select"
  72. placement="bottom-start"
  73. value={local.current}
  74. options={grouped()}
  75. optionValue={(x) => (local.value ? local.value(x) : (x as string))}
  76. optionTextValue={(x) => (local.label ? local.label(x) : (x as string))}
  77. optionGroupChildren="options"
  78. placeholder={local.placeholder}
  79. sectionComponent={(local) => (
  80. <Kobalte.Section data-slot="select-section">{local.section.rawValue.category}</Kobalte.Section>
  81. )}
  82. itemComponent={(itemProps) => (
  83. <Kobalte.Item
  84. {...itemProps}
  85. data-slot="select-select-item"
  86. classList={{
  87. ...(local.classList ?? {}),
  88. [local.class ?? ""]: !!local.class,
  89. }}
  90. onPointerEnter={() => move(itemProps.item.rawValue)}
  91. onPointerMove={() => move(itemProps.item.rawValue)}
  92. >
  93. <Kobalte.ItemLabel data-slot="select-select-item-label">
  94. {local.children
  95. ? local.children(itemProps.item.rawValue)
  96. : local.label
  97. ? local.label(itemProps.item.rawValue)
  98. : (itemProps.item.rawValue as string)}
  99. </Kobalte.ItemLabel>
  100. <Kobalte.ItemIndicator data-slot="select-select-item-indicator">
  101. <Icon name="check-small" size="small" />
  102. </Kobalte.ItemIndicator>
  103. </Kobalte.Item>
  104. )}
  105. onChange={(v) => {
  106. local.onSelect?.(v ?? undefined)
  107. stop()
  108. }}
  109. onOpenChange={(open) => {
  110. local.onOpenChange?.(open)
  111. if (!open) stop()
  112. }}
  113. >
  114. <Kobalte.Trigger
  115. disabled={props.disabled}
  116. data-slot="select-select-trigger"
  117. as={Button}
  118. size={props.size}
  119. variant={props.variant}
  120. classList={{
  121. ...(local.classList ?? {}),
  122. [local.class ?? ""]: !!local.class,
  123. }}
  124. >
  125. <Kobalte.Value<T> data-slot="select-select-trigger-value">
  126. {(state) => {
  127. const selected = state.selectedOption() ?? local.current
  128. if (!selected) return local.placeholder || ""
  129. if (local.label) return local.label(selected)
  130. return selected as string
  131. }}
  132. </Kobalte.Value>
  133. <Kobalte.Icon data-slot="select-select-trigger-icon">
  134. <Icon name="chevron-down" size="small" />
  135. </Kobalte.Icon>
  136. </Kobalte.Trigger>
  137. <Kobalte.Portal>
  138. <Kobalte.Content
  139. classList={{
  140. ...(local.classList ?? {}),
  141. [local.class ?? ""]: !!local.class,
  142. }}
  143. data-component="select-content"
  144. >
  145. <Kobalte.Listbox data-slot="select-select-content-list" />
  146. </Kobalte.Content>
  147. </Kobalte.Portal>
  148. </Kobalte>
  149. )
  150. }