select.tsx 5.4 KB

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