select.tsx 5.3 KB

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