@stoker-platform/web-app 0.5.145 → 0.5.147

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @stoker-platform/web-app
2
2
 
3
+ ## 0.5.147
4
+
5
+ ### Patch Changes
6
+
7
+ - feat: increase relation picker item size
8
+
9
+ ## 0.5.146
10
+
11
+ ### Patch Changes
12
+
13
+ - fix: improve mobile relation picker
14
+
3
15
  ## 0.5.145
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stoker-platform/web-app",
3
- "version": "0.5.145",
3
+ "version": "0.5.147",
4
4
  "type": "module",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "scripts": {
package/src/Filters.tsx CHANGED
@@ -28,6 +28,9 @@ import { useRouteLoading } from "./providers/LoadingProvider"
28
28
  import { useLocation } from "react-router"
29
29
  import { useStokerState } from "./providers/StateProvider"
30
30
  import { Popover, PopoverContent, PopoverTrigger } from "./components/ui/popover"
31
+ import { Sheet, SheetContent, SheetTrigger } from "./components/ui/sheet"
32
+ import { useIsMobile } from "./hooks/use-mobile"
33
+ import { useKeyboardOffset } from "./hooks/use-keyboard-offset"
31
34
  import { Button } from "./components/ui/button"
32
35
  import { Check, ChevronsUpDown } from "lucide-react"
33
36
  import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "./components/ui/command"
@@ -80,6 +83,11 @@ export function Filters({ collection, excluded, relationList }: FiltersProps) {
80
83
 
81
84
  const preventChange = isRouteLoadingImmediate.has(location.pathname)
82
85
 
86
+ const isMobile = useIsMobile()
87
+ const someFilterOpen = Object.values(open).some(Boolean)
88
+ const { offset: keyboardOffset, viewportHeight } = useKeyboardOffset(isMobile && someFilterOpen)
89
+ const sheetHeight = Math.max(240, Math.min(viewportHeight - 16, viewportHeight * 0.9))
90
+
83
91
  const hasRelationFilterAccess = useCallback((filter: Filter) => {
84
92
  if (filter.type === "status" || filter.type === "range") return false
85
93
  const field = getField(fields, filter.field)
@@ -306,9 +314,10 @@ export function Filters({ collection, excluded, relationList }: FiltersProps) {
306
314
  if (isCollectionPreloadCacheEnabled && query) {
307
315
  const searchResults = localFullTextSearch(collectionSchema, query, data.records)
308
316
  const objectIds = searchResults.map((result) => result.id)
317
+ const numberOfResults = isMobile ? 20 : 10
309
318
  setData((prev) => ({
310
319
  ...prev,
311
- [field]: data.records.filter((doc) => objectIds.includes(doc.id)).slice(0, 10),
320
+ [field]: data.records.filter((doc) => objectIds.includes(doc.id)).slice(0, numberOfResults),
312
321
  }))
313
322
  } else {
314
323
  setData((prev) => ({
@@ -551,162 +560,173 @@ export function Filters({ collection, excluded, relationList }: FiltersProps) {
551
560
  if (window.innerHeight < 600) {
552
561
  popoverHeight = "h-48"
553
562
  }
563
+ const isFilterOpen = !!open[filter.field]
564
+ const handleFilterOpenChange = (nextOpen: boolean) => {
565
+ setOpen((prev) => ({ ...prev, [filter.field]: nextOpen }))
566
+ if (nextOpen) {
567
+ startTransition(() => {
568
+ getData(searchValue[filter.field], filter.field, field.collection, filter.constraints)
569
+ })
570
+ }
571
+ }
572
+ const filterTriggerButton = (
573
+ <Button
574
+ variant="outline"
575
+ role="combobox"
576
+ aria-expanded={isFilterOpen}
577
+ className="w-full justify-between"
578
+ disabled={disabled}
579
+ >
580
+ <span className="w-[150px] sm:w-[250px] overflow-hidden text-ellipsis whitespace-nowrap text-left">
581
+ {inputValue[filter.field] ? display[filter.field] : "----"}
582
+ </span>
583
+ <ChevronsUpDown className="opacity-50 h-4 w-4" />
584
+ </Button>
585
+ )
586
+ const filterCommandBody = (
587
+ <Command
588
+ filter={() => {
589
+ return 1
590
+ }}
591
+ className={isMobile ? "flex flex-col h-full" : undefined}
592
+ >
593
+ <CommandInput
594
+ placeholder={`Search ${collectionTitle[filter.field]}...`}
595
+ className="h-9"
596
+ value={searchValue[filter.field]}
597
+ onValueChange={(value) => {
598
+ setSearchValue((prev) => ({
599
+ ...prev,
600
+ [filter.field]: value,
601
+ }))
602
+ }}
603
+ />
604
+ <CommandList className={isMobile ? "flex-1 max-h-none" : undefined}>
605
+ <CommandEmpty>
606
+ {isFilterOpen &&
607
+ (isLoading[filter.field] ? (
608
+ <LoadingSpinner size={7} className="m-auto" />
609
+ ) : !isLoadingImmediate[filter.field] ? (
610
+ `No ${collectionTitle[filter.field]} found.`
611
+ ) : null)}
612
+ </CommandEmpty>
613
+ {(!isLoading[filter.field] || isCollectionPreloadCacheEnabled) && (
614
+ <CommandGroup>
615
+ {data[filter.field] && (
616
+ <CommandItem
617
+ className={cn(isMobile ? "p-3" : undefined)}
618
+ key="no_selection"
619
+ value="no_selection"
620
+ onSelect={(currentValue) => {
621
+ setOpen((prev) => {
622
+ return {
623
+ ...prev,
624
+ [filter.field]: false,
625
+ }
626
+ })
627
+ if (currentValue !== inputValue[filter.field]) {
628
+ setValue((prev) => {
629
+ return {
630
+ ...prev,
631
+ [filter.field]: currentValue,
632
+ }
633
+ })
634
+ setDisplay((prev) => {
635
+ return {
636
+ ...prev,
637
+ [filter.field]: "----",
638
+ }
639
+ })
640
+ startTransition(() => {
641
+ handleChange(filter, "no_selection", field.type)
642
+ })
643
+ }
644
+ }}
645
+ >
646
+ ----
647
+ </CommandItem>
648
+ )}
649
+ {data[filter.field]?.map((record: StokerRecord) => (
650
+ <CommandItem
651
+ className={cn(isMobile ? "p-3" : undefined)}
652
+ key={record.id}
653
+ value={record.id}
654
+ onSelect={(currentValue) => {
655
+ setOpen((prev) => {
656
+ return {
657
+ ...prev,
658
+ [filter.field]: false,
659
+ }
660
+ })
661
+ if (currentValue !== inputValue[filter.field]) {
662
+ setValue((prev) => {
663
+ return {
664
+ ...prev,
665
+ [filter.field]: currentValue,
666
+ }
667
+ })
668
+ setDisplay((prev) => {
669
+ return {
670
+ ...prev,
671
+ [filter.field]:
672
+ record[recordTitleField[filter.field] || "id"],
673
+ }
674
+ })
675
+ startTransition(() => {
676
+ handleChange(filter, record.id, field.type)
677
+ })
678
+ }
679
+ }}
680
+ >
681
+ {record[recordTitleField[filter.field] || "id"]}
682
+ <Check
683
+ className={cn(
684
+ "ml-auto",
685
+ inputValue[filter.field] === record.id
686
+ ? "opacity-100"
687
+ : "opacity-0",
688
+ )}
689
+ />
690
+ </CommandItem>
691
+ ))}
692
+ </CommandGroup>
693
+ )}
694
+ </CommandList>
695
+ </Command>
696
+ )
554
697
  return (
555
698
  <div key={filter.field}>
556
699
  <Label htmlFor={title}>{title}:</Label>
557
700
  <div className="mt-2">
558
- <Popover
559
- modal={true}
560
- open={!!open[filter.field]}
561
- onOpenChange={() => {
562
- setOpen({
563
- ...open,
564
- [filter.field]: !open[filter.field],
565
- })
566
- startTransition(() => {
567
- getData(
568
- searchValue[filter.field],
569
- filter.field,
570
- field.collection,
571
- filter.constraints,
572
- )
573
- })
574
- }}
575
- >
576
- <PopoverTrigger asChild>
577
- <Button
578
- variant="outline"
579
- role="combobox"
580
- aria-expanded={open[filter.field]}
581
- className="w-full justify-between"
582
- disabled={disabled}
583
- >
584
- <span className="w-[150px] sm:w-[250px] overflow-hidden text-ellipsis whitespace-nowrap text-left">
585
- {inputValue[filter.field] ? display[filter.field] : "----"}
586
- </span>
587
- <ChevronsUpDown className="opacity-50 h-4 w-4" />
588
- </Button>
589
- </PopoverTrigger>
590
- <PopoverContent
591
- className={cn(popoverHeight, "w-[200px]", "sm:w-[280px]", "p-0", "h-60")}
592
- align="start"
593
- >
594
- <Command
595
- filter={() => {
596
- return 1
701
+ {isMobile ? (
702
+ <Sheet open={isFilterOpen} onOpenChange={handleFilterOpenChange}>
703
+ <SheetTrigger asChild>{filterTriggerButton}</SheetTrigger>
704
+ <SheetContent
705
+ side="bottom"
706
+ className="p-0 pt-8 flex flex-col gap-0 rounded-t-lg"
707
+ style={{
708
+ bottom: keyboardOffset,
709
+ height: sheetHeight,
710
+ maxHeight: sheetHeight,
711
+ }}
712
+ onOpenAutoFocus={(event) => {
713
+ event.preventDefault()
597
714
  }}
598
715
  >
599
- <CommandInput
600
- placeholder={`Search ${collectionTitle[filter.field]}...`}
601
- className="h-9"
602
- value={searchValue[filter.field]}
603
- onValueChange={(value) => {
604
- setSearchValue((prev) => ({
605
- ...prev,
606
- [filter.field]: value,
607
- }))
608
- }}
609
- />
610
- <CommandList>
611
- <CommandEmpty>
612
- {open[filter.field] &&
613
- (isLoading[filter.field] ? (
614
- <LoadingSpinner size={7} className="m-auto" />
615
- ) : !isLoadingImmediate[filter.field] ? (
616
- `No ${collectionTitle[filter.field]} found.`
617
- ) : null)}
618
- </CommandEmpty>
619
- {(!isLoading[filter.field] || isCollectionPreloadCacheEnabled) && (
620
- <CommandGroup>
621
- {data[filter.field] && (
622
- <CommandItem
623
- key="no_selection"
624
- value="no_selection"
625
- onSelect={(currentValue) => {
626
- setOpen((prev) => {
627
- return {
628
- ...prev,
629
- [filter.field]: false,
630
- }
631
- })
632
- if (currentValue !== inputValue[filter.field]) {
633
- setValue((prev) => {
634
- return {
635
- ...prev,
636
- [filter.field]: currentValue,
637
- }
638
- })
639
- setDisplay((prev) => {
640
- return {
641
- ...prev,
642
- [filter.field]: "----",
643
- }
644
- })
645
- startTransition(() => {
646
- handleChange(
647
- filter,
648
- "no_selection",
649
- field.type,
650
- )
651
- })
652
- }
653
- }}
654
- >
655
- ----
656
- </CommandItem>
657
- )}
658
- {data[filter.field]?.map((record: StokerRecord) => (
659
- <CommandItem
660
- key={record.id}
661
- value={record.id}
662
- onSelect={(currentValue) => {
663
- setOpen((prev) => {
664
- return {
665
- ...prev,
666
- [filter.field]: false,
667
- }
668
- })
669
- if (currentValue !== inputValue[filter.field]) {
670
- setValue((prev) => {
671
- return {
672
- ...prev,
673
- [filter.field]: currentValue,
674
- }
675
- })
676
- setDisplay((prev) => {
677
- return {
678
- ...prev,
679
- [filter.field]:
680
- record[
681
- recordTitleField[
682
- filter.field
683
- ] || "id"
684
- ],
685
- }
686
- })
687
- startTransition(() => {
688
- handleChange(filter, record.id, field.type)
689
- })
690
- }
691
- }}
692
- >
693
- {record[recordTitleField[filter.field] || "id"]}
694
- <Check
695
- className={cn(
696
- "ml-auto",
697
- inputValue[filter.field] === record.id
698
- ? "opacity-100"
699
- : "opacity-0",
700
- )}
701
- />
702
- </CommandItem>
703
- ))}
704
- </CommandGroup>
705
- )}
706
- </CommandList>
707
- </Command>
708
- </PopoverContent>
709
- </Popover>
716
+ {filterCommandBody}
717
+ </SheetContent>
718
+ </Sheet>
719
+ ) : (
720
+ <Popover modal={true} open={isFilterOpen} onOpenChange={handleFilterOpenChange}>
721
+ <PopoverTrigger asChild>{filterTriggerButton}</PopoverTrigger>
722
+ <PopoverContent
723
+ className={cn(popoverHeight, "w-[200px]", "sm:w-[280px]", "p-0", "h-60")}
724
+ align="start"
725
+ >
726
+ {filterCommandBody}
727
+ </PopoverContent>
728
+ </Popover>
729
+ )}
710
730
  </div>
711
731
  </div>
712
732
  )
package/src/Form.tsx CHANGED
@@ -133,6 +133,9 @@ import { DateTime } from "luxon"
133
133
  import { useGoToRecord } from "./utils/goToRecord"
134
134
  import { preloadCacheEnabled } from "./utils/preloadCacheEnabled"
135
135
  import { Popover, PopoverContent, PopoverTrigger } from "./components/ui/popover"
136
+ import { Sheet, SheetContent, SheetTrigger } from "./components/ui/sheet"
137
+ import { useIsMobile } from "./hooks/use-mobile"
138
+ import { useKeyboardOffset } from "./hooks/use-keyboard-offset"
136
139
  import { getFilterDisjunctions } from "./utils/getFilterDisjunctions"
137
140
  import { performFullTextSearch } from "./utils/performFullTextSearch"
138
141
  import { localFullTextSearch } from "./utils/localFullTextSearch"
@@ -2022,9 +2025,12 @@ function RelationField({
2022
2025
  return !!fieldCustomization.admin?.filterResults?.(result, collection, record)
2023
2026
  })
2024
2027
  const objectIds = searchResults.map((result) => result.id)
2025
- orderData(data.records.filter((doc) => objectIds.includes(doc.id)).slice(0, 10)).then((data) => {
2026
- setData(data)
2027
- })
2028
+ const numberOfResults = isMobile ? 20 : 10
2029
+ orderData(data.records.filter((doc) => objectIds.includes(doc.id)).slice(0, numberOfResults)).then(
2030
+ (data) => {
2031
+ setData(data)
2032
+ },
2033
+ )
2028
2034
  } else {
2029
2035
  orderData(
2030
2036
  data.records
@@ -2108,6 +2114,127 @@ function RelationField({
2108
2114
  popoverHeight = "h-48"
2109
2115
  }
2110
2116
 
2117
+ const isMobile = useIsMobile()
2118
+ const { offset: keyboardOffset, viewportHeight } = useKeyboardOffset(isMobile && isOpen)
2119
+ const sheetHeight = Math.max(240, Math.min(viewportHeight - 16, viewportHeight * 0.9))
2120
+
2121
+ const handlePickerOpenChange = (nextOpen: boolean) => {
2122
+ setIsOpen(nextOpen)
2123
+ if (nextOpen) {
2124
+ startTransition(() => {
2125
+ getData(searchValue, (field as RelationFieldType).constraints)
2126
+ })
2127
+ }
2128
+ }
2129
+
2130
+ const triggerButton = (
2131
+ <Button
2132
+ variant="outline"
2133
+ role="combobox"
2134
+ aria-expanded={isOpen}
2135
+ className="w-full justify-between"
2136
+ disabled={isDisabled || readOnly}
2137
+ >
2138
+ <span className="break-all whitespace-pre-wrap line-clamp-1 text-left">{inputValue ? display : ""}</span>
2139
+ <ChevronsUpDown className="opacity-50 h-4 w-4" />
2140
+ </Button>
2141
+ )
2142
+
2143
+ const commandBody = (
2144
+ <Command
2145
+ filter={() => {
2146
+ return 1
2147
+ }}
2148
+ className={isMobile ? "flex flex-col h-full" : undefined}
2149
+ >
2150
+ <CommandInput
2151
+ placeholder={`Search ${collectionTitle}...`}
2152
+ className="h-9"
2153
+ value={searchValue}
2154
+ onValueChange={(value) => {
2155
+ setSearchValue(value)
2156
+ }}
2157
+ />
2158
+ <CommandList className={isMobile ? "flex-1 max-h-none" : undefined}>
2159
+ <CommandEmpty>
2160
+ {isOpen &&
2161
+ (isLoading ? (
2162
+ <LoadingSpinner size={7} className="m-auto" />
2163
+ ) : !isLoadingImmediate ? (
2164
+ `No ${collectionTitle} found.`
2165
+ ) : null)}
2166
+ </CommandEmpty>
2167
+ {(!isLoading || isCollectionPreloadCacheEnabled) && (
2168
+ <CommandGroup>
2169
+ {data && ["OneToOne", "OneToMany"].includes(field.type) && (
2170
+ <CommandItem
2171
+ className={cn(isMobile ? "p-3" : undefined)}
2172
+ key="no_selection"
2173
+ value="no_selection"
2174
+ onSelect={(currentValue: string) => {
2175
+ setIsOpen(false)
2176
+ if (currentValue !== inputValue) {
2177
+ setValue(currentValue)
2178
+ setDisplay("----")
2179
+ startTransition(() => {
2180
+ handleOneToChange()
2181
+ })
2182
+ }
2183
+ }}
2184
+ >
2185
+ ----
2186
+ </CommandItem>
2187
+ )}
2188
+ {data?.map((relationRecord: StokerRecord) => (
2189
+ <CommandItem
2190
+ className={cn(isMobile ? "p-3" : undefined)}
2191
+ key={relationRecord.id}
2192
+ value={relationRecord.id}
2193
+ onSelect={(currentValue) => {
2194
+ setIsOpen(false)
2195
+ if (currentValue !== inputValue) {
2196
+ if (["OneToOne", "OneToMany"].includes(field.type)) {
2197
+ setValue(currentValue)
2198
+ if (fieldCustomization.admin?.modifyResultTitle) {
2199
+ setDisplay(
2200
+ fieldCustomization.admin.modifyResultTitle(
2201
+ relationRecord,
2202
+ collection,
2203
+ record,
2204
+ ),
2205
+ )
2206
+ } else {
2207
+ setDisplay(relationRecord[recordTitleField || "id"])
2208
+ }
2209
+ }
2210
+ startTransition(() => {
2211
+ handleOneToChange(
2212
+ data?.find((relationRecord) => relationRecord.id === currentValue),
2213
+ )
2214
+ if (["ManyToOne", "ManyToMany"].includes(field.type)) {
2215
+ handleManyToChange(currentValue)
2216
+ }
2217
+ })
2218
+ }
2219
+ }}
2220
+ >
2221
+ {fieldCustomization.admin?.modifyResultTitle
2222
+ ? fieldCustomization.admin.modifyResultTitle(relationRecord, collection, record)
2223
+ : relationRecord[recordTitleField || "id"]}
2224
+ <Check
2225
+ className={cn(
2226
+ "ml-auto",
2227
+ inputValue === relationRecord.id ? "opacity-100" : "opacity-0",
2228
+ )}
2229
+ />
2230
+ </CommandItem>
2231
+ ))}
2232
+ </CommandGroup>
2233
+ )}
2234
+ </CommandList>
2235
+ </Command>
2236
+ )
2237
+
2111
2238
  return (
2112
2239
  <FormField
2113
2240
  control={form.control}
@@ -2124,148 +2251,37 @@ function RelationField({
2124
2251
  form={form}
2125
2252
  />
2126
2253
  <FormControl>
2127
- <Popover
2128
- modal={true}
2129
- open={isOpen}
2130
- onOpenChange={() => {
2131
- setIsOpen(!isOpen)
2132
- startTransition(() => {
2133
- getData(searchValue, (field as RelationFieldType).constraints)
2134
- })
2135
- }}
2136
- >
2137
- <div className="flex items-center gap-2">
2138
- <PopoverTrigger asChild>
2139
- <Button
2140
- variant="outline"
2141
- role="combobox"
2142
- aria-expanded={isOpen}
2143
- className="w-full justify-between"
2144
- disabled={isDisabled || readOnly}
2145
- >
2146
- <span className="break-all whitespace-pre-wrap line-clamp-1 text-left">
2147
- {inputValue ? display : ""}
2148
- </span>
2149
- <ChevronsUpDown className="opacity-50 h-4 w-4" />
2150
- </Button>
2151
- </PopoverTrigger>
2152
- </div>
2153
- <PopoverContent
2154
- className={cn(popoverHeight, "w-[200px]", "sm:w-[280px]", "p-0", "h-60")}
2155
- align="start"
2156
- >
2157
- <Command
2158
- filter={() => {
2159
- return 1
2254
+ {isMobile ? (
2255
+ <Sheet open={isOpen} onOpenChange={handlePickerOpenChange}>
2256
+ <SheetTrigger asChild>{triggerButton}</SheetTrigger>
2257
+ <SheetContent
2258
+ side="bottom"
2259
+ className="p-0 pt-8 flex flex-col gap-0 rounded-t-lg"
2260
+ style={{
2261
+ bottom: keyboardOffset,
2262
+ height: sheetHeight,
2263
+ maxHeight: sheetHeight,
2264
+ }}
2265
+ onOpenAutoFocus={(event) => {
2266
+ event.preventDefault()
2160
2267
  }}
2161
2268
  >
2162
- <CommandInput
2163
- placeholder={`Search ${collectionTitle}...`}
2164
- className="h-9"
2165
- value={searchValue}
2166
- onValueChange={(value) => {
2167
- setSearchValue(value)
2168
- }}
2169
- />
2170
- <CommandList>
2171
- <CommandEmpty>
2172
- {isOpen &&
2173
- (isLoading ? (
2174
- <LoadingSpinner size={7} className="m-auto" />
2175
- ) : !isLoadingImmediate ? (
2176
- `No ${collectionTitle} found.`
2177
- ) : null)}
2178
- </CommandEmpty>
2179
- {(!isLoading || isCollectionPreloadCacheEnabled) && (
2180
- <CommandGroup>
2181
- {data && ["OneToOne", "OneToMany"].includes(field.type) && (
2182
- <CommandItem
2183
- key="no_selection"
2184
- value="no_selection"
2185
- onSelect={(currentValue: string) => {
2186
- setIsOpen(false)
2187
- if (currentValue !== inputValue) {
2188
- setValue(currentValue)
2189
- setDisplay("----")
2190
- startTransition(() => {
2191
- handleOneToChange()
2192
- })
2193
- }
2194
- }}
2195
- >
2196
- ----
2197
- </CommandItem>
2198
- )}
2199
- {data?.map((relationRecord: StokerRecord) => (
2200
- <CommandItem
2201
- key={relationRecord.id}
2202
- value={relationRecord.id}
2203
- onSelect={(currentValue) => {
2204
- setIsOpen(false)
2205
- if (currentValue !== inputValue) {
2206
- if (
2207
- ["OneToOne", "OneToMany"].includes(field.type)
2208
- ) {
2209
- setValue(currentValue)
2210
- if (
2211
- fieldCustomization.admin?.modifyResultTitle
2212
- ) {
2213
- setDisplay(
2214
- fieldCustomization.admin.modifyResultTitle(
2215
- relationRecord,
2216
- collection,
2217
- record,
2218
- ),
2219
- )
2220
- } else {
2221
- setDisplay(
2222
- relationRecord[
2223
- recordTitleField || "id"
2224
- ],
2225
- )
2226
- }
2227
- }
2228
- startTransition(() => {
2229
- handleOneToChange(
2230
- data?.find(
2231
- (relationRecord) =>
2232
- relationRecord.id === currentValue,
2233
- ),
2234
- )
2235
- if (
2236
- ["ManyToOne", "ManyToMany"].includes(
2237
- field.type,
2238
- )
2239
- ) {
2240
- handleManyToChange(currentValue)
2241
- }
2242
- })
2243
- }
2244
- }}
2245
- >
2246
- {fieldCustomization.admin?.modifyResultTitle
2247
- ? fieldCustomization.admin.modifyResultTitle(
2248
- relationRecord,
2249
- collection,
2250
- record,
2251
- )
2252
- : relationRecord[recordTitleField || "id"]}
2253
- <Check
2254
- className={cn(
2255
- "ml-auto",
2256
- inputValue === relationRecord.id
2257
- ? "opacity-100"
2258
- : "opacity-0",
2259
- )}
2260
- />
2261
- </CommandItem>
2262
- ))}
2263
- </CommandGroup>
2264
- )}
2265
- </CommandList>
2266
- </Command>
2267
- </PopoverContent>
2268
- </Popover>
2269
+ {commandBody}
2270
+ </SheetContent>
2271
+ </Sheet>
2272
+ ) : (
2273
+ <Popover modal={true} open={isOpen} onOpenChange={handlePickerOpenChange}>
2274
+ <div className="flex items-center gap-2">
2275
+ <PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
2276
+ </div>
2277
+ <PopoverContent
2278
+ className={cn(popoverHeight, "w-[200px]", "sm:w-[280px]", "p-0", "h-60")}
2279
+ align="start"
2280
+ >
2281
+ {commandBody}
2282
+ </PopoverContent>
2283
+ </Popover>
2284
+ )}
2269
2285
  </FormControl>
2270
2286
  {description && <FormDescription>{description}</FormDescription>}
2271
2287
  <FormMessage className="bg-destructive p-4 rounded-md text-background dark:text-primary" />
@@ -8,7 +8,7 @@ import {
8
8
  } from "@stoker-platform/types"
9
9
  import { preloadCacheEnabled } from "./utils/preloadCacheEnabled"
10
10
  import { serverReadOnly } from "./utils/serverReadOnly"
11
- import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"
11
+ import { type ReactNode, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"
12
12
  import { QueryConstraint, where, WhereFilterOp } from "firebase/firestore"
13
13
  import { getFilterDisjunctions } from "./utils/getFilterDisjunctions"
14
14
  import { performFullTextSearch } from "./utils/performFullTextSearch"
@@ -16,6 +16,9 @@ import { localFullTextSearch } from "./utils/localFullTextSearch"
16
16
  import { getOne, getSome } from "@stoker-platform/web-client"
17
17
  import { FormControl, FormItem, FormLabel } from "./components/ui/form"
18
18
  import { Popover, PopoverContent, PopoverTrigger } from "./components/ui/popover"
19
+ import { Sheet, SheetContent, SheetTrigger } from "./components/ui/sheet"
20
+ import { useIsMobile } from "./hooks/use-mobile"
21
+ import { useKeyboardOffset } from "./hooks/use-keyboard-offset"
19
22
  import { Button } from "./components/ui/button"
20
23
  import { Check, ChevronsUpDown, Plus, XCircle } from "lucide-react"
21
24
  import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "./components/ui/command"
@@ -137,7 +140,8 @@ export const PermissionPicker = ({
137
140
  if (isCollectionPreloadCacheEnabled && query) {
138
141
  const searchResults = localFullTextSearch(collection, query, data.records)
139
142
  const objectIds = searchResults.map((result) => result.id)
140
- setData(data.records.filter((doc) => objectIds.includes(doc.id)).slice(0, 10))
143
+ const numberOfResults = isMobile ? 20 : 10
144
+ setData(data.records.filter((doc) => objectIds.includes(doc.id)).slice(0, numberOfResults))
141
145
  } else {
142
146
  setData(data.records.slice(0, 10))
143
147
  }
@@ -330,126 +334,157 @@ export const PermissionPicker = ({
330
334
  popoverHeight = "h-48"
331
335
  }
332
336
 
337
+ const isMobile = useIsMobile()
338
+ const { offset: keyboardOffset, viewportHeight } = useKeyboardOffset(isMobile && isOpen)
339
+ const sheetHeight = Math.max(240, Math.min(viewportHeight - 16, viewportHeight * 0.9))
340
+
341
+ const handlePickerOpenChange = (nextOpen: boolean) => {
342
+ setIsOpen(nextOpen)
343
+ if (nextOpen) {
344
+ startTransition(() => {
345
+ getData(searchValue, constraints)
346
+ })
347
+ }
348
+ }
349
+
350
+ const triggerButton = (
351
+ <Button
352
+ variant="outline"
353
+ role="combobox"
354
+ aria-expanded={isOpen}
355
+ className="w-full justify-between"
356
+ disabled={isDisabled}
357
+ >
358
+ <span className="break-all whitespace-pre-wrap line-clamp-1 text-left">{inputValue ? display : ""}</span>
359
+ <ChevronsUpDown className="opacity-50 h-4 w-4" />
360
+ </Button>
361
+ )
362
+
363
+ const commandBody = (
364
+ <Command
365
+ filter={() => {
366
+ return 1
367
+ }}
368
+ className={isMobile ? "flex flex-col h-full" : undefined}
369
+ >
370
+ <CommandInput
371
+ placeholder={`Search ${title}...`}
372
+ className="h-9"
373
+ value={searchValue}
374
+ onValueChange={(value) => {
375
+ setSearchValue(value)
376
+ }}
377
+ />
378
+ <CommandList className={isMobile ? "flex-1 max-h-none" : undefined}>
379
+ <CommandEmpty>
380
+ {isOpen &&
381
+ (isLoading ? (
382
+ <LoadingSpinner size={7} className="m-auto" />
383
+ ) : !isLoadingImmediate ? (
384
+ `No ${title} found.`
385
+ ) : null)}
386
+ </CommandEmpty>
387
+ {(!isLoading || isCollectionPreloadCacheEnabled) && (
388
+ <CommandGroup>
389
+ {data && (
390
+ <CommandItem
391
+ className={cn(isMobile ? "p-3" : undefined)}
392
+ key="no_selection"
393
+ value="no_selection"
394
+ onSelect={(currentValue) => {
395
+ setIsOpen(false)
396
+ if (currentValue !== inputValue) {
397
+ setValue(currentValue)
398
+ setDisplay("----")
399
+ }
400
+ }}
401
+ >
402
+ ----
403
+ </CommandItem>
404
+ )}
405
+ {data?.map((record: StokerRecord) => (
406
+ <CommandItem
407
+ className={cn(isMobile ? "p-3" : undefined)}
408
+ key={record.id}
409
+ value={record.id}
410
+ onSelect={(currentValue) => {
411
+ setIsOpen(false)
412
+ if (currentValue !== inputValue) {
413
+ setValue(currentValue)
414
+ setDisplay(record[recordTitleField || "id"])
415
+ }
416
+ }}
417
+ >
418
+ {record[recordTitleField || "id"]}
419
+ <Check
420
+ className={cn("ml-auto", inputValue === record.id ? "opacity-100" : "opacity-0")}
421
+ />
422
+ </CommandItem>
423
+ ))}
424
+ </CommandGroup>
425
+ )}
426
+ </CommandList>
427
+ </Command>
428
+ )
429
+
430
+ const renderPicker = (children: ReactNode) =>
431
+ isMobile ? (
432
+ <Sheet open={isOpen} onOpenChange={handlePickerOpenChange}>
433
+ <div className="flex items-center gap-2">
434
+ <SheetTrigger asChild>{triggerButton}</SheetTrigger>
435
+ {children}
436
+ </div>
437
+ <SheetContent
438
+ side="bottom"
439
+ className="p-0 pt-8 flex flex-col gap-0 rounded-t-lg"
440
+ style={{
441
+ bottom: keyboardOffset,
442
+ height: sheetHeight,
443
+ maxHeight: sheetHeight,
444
+ }}
445
+ onOpenAutoFocus={(event) => {
446
+ event.preventDefault()
447
+ }}
448
+ >
449
+ {commandBody}
450
+ </SheetContent>
451
+ </Sheet>
452
+ ) : (
453
+ <Popover modal={true} open={isOpen} onOpenChange={handlePickerOpenChange}>
454
+ <div className="flex items-center gap-2">
455
+ <PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
456
+ {children}
457
+ </div>
458
+ <PopoverContent className={cn(popoverHeight, "w-[200px]", "sm:w-[280px]", "p-0", "h-60")} align="start">
459
+ {commandBody}
460
+ </PopoverContent>
461
+ </Popover>
462
+ )
463
+
333
464
  if (type === "Parent_Property" && restriction) {
334
465
  return (
335
466
  <div className="w-full max-w-[750px]">
336
467
  <FormItem>
337
468
  <FormLabel>{`Accessible ${title}`}</FormLabel>
338
469
  <FormControl>
339
- <Popover
340
- modal={true}
341
- open={isOpen}
342
- onOpenChange={() => {
343
- setIsOpen(!isOpen)
344
- startTransition(() => {
345
- getData(searchValue, constraints)
346
- })
347
- }}
348
- >
349
- <div className="flex items-center gap-2">
350
- <PopoverTrigger asChild>
351
- <Button
352
- variant="outline"
353
- role="combobox"
354
- aria-expanded={isOpen}
355
- className="w-full justify-between"
356
- disabled={isDisabled}
357
- >
358
- <span className="break-all whitespace-pre-wrap line-clamp-1 text-left">
359
- {inputValue ? display : ""}
360
- </span>
361
- <ChevronsUpDown className="opacity-50 h-4 w-4" />
362
- </Button>
363
- </PopoverTrigger>
364
- {
365
- <Button
366
- type="button"
367
- disabled={isDisabled || !inputValue || inputValue === "no_selection"}
368
- variant="outline"
369
- size="icon"
370
- onClick={() => {
371
- if (hasUser) {
372
- form.setValue("operation", "update")
373
- } else {
374
- form.setValue("operation", "create")
375
- }
376
- handleAddEntity()
377
- }}
378
- >
379
- <Plus className="h-4 w-4" />
380
- </Button>
381
- }
382
- </div>
383
- <PopoverContent
384
- className={cn(popoverHeight, "w-[200px]", "sm:w-[280px]", "p-0", "h-60")}
385
- align="start"
470
+ {renderPicker(
471
+ <Button
472
+ type="button"
473
+ disabled={isDisabled || !inputValue || inputValue === "no_selection"}
474
+ variant="outline"
475
+ size="icon"
476
+ onClick={() => {
477
+ if (hasUser) {
478
+ form.setValue("operation", "update")
479
+ } else {
480
+ form.setValue("operation", "create")
481
+ }
482
+ handleAddEntity()
483
+ }}
386
484
  >
387
- <Command
388
- filter={() => {
389
- return 1
390
- }}
391
- >
392
- <CommandInput
393
- placeholder={`Search ${title}...`}
394
- className="h-9"
395
- value={searchValue}
396
- onValueChange={(value) => {
397
- setSearchValue(value)
398
- }}
399
- />
400
- <CommandList>
401
- <CommandEmpty>
402
- {isOpen &&
403
- (isLoading ? (
404
- <LoadingSpinner size={7} className="m-auto" />
405
- ) : !isLoadingImmediate ? (
406
- `No ${title} found.`
407
- ) : null)}
408
- </CommandEmpty>
409
- {(!isLoading || isCollectionPreloadCacheEnabled) && (
410
- <CommandGroup>
411
- {data && (
412
- <CommandItem
413
- key="no_selection"
414
- value="no_selection"
415
- onSelect={(currentValue) => {
416
- setIsOpen(false)
417
- if (currentValue !== inputValue) {
418
- setValue(currentValue)
419
- setDisplay("----")
420
- }
421
- }}
422
- >
423
- ----
424
- </CommandItem>
425
- )}
426
- {data?.map((record: StokerRecord) => (
427
- <CommandItem
428
- key={record.id}
429
- value={record.id}
430
- onSelect={(currentValue) => {
431
- setIsOpen(false)
432
- if (currentValue !== inputValue) {
433
- setValue(currentValue)
434
- setDisplay(record[recordTitleField || "id"])
435
- }
436
- }}
437
- >
438
- {record[recordTitleField || "id"]}
439
- <Check
440
- className={cn(
441
- "ml-auto",
442
- inputValue === record.id ? "opacity-100" : "opacity-0",
443
- )}
444
- />
445
- </CommandItem>
446
- ))}
447
- </CommandGroup>
448
- )}
449
- </CommandList>
450
- </Command>
451
- </PopoverContent>
452
- </Popover>
485
+ <Plus className="h-4 w-4" />
486
+ </Button>,
487
+ )}
453
488
  </FormControl>
454
489
  </FormItem>
455
490
  {selectedEntities.map((entity) => (
@@ -521,120 +556,24 @@ export const PermissionPicker = ({
521
556
  <FormItem>
522
557
  <FormLabel>{`Accessible ${title}`}</FormLabel>
523
558
  <FormControl>
524
- <Popover
525
- modal={true}
526
- open={isOpen}
527
- onOpenChange={() => {
528
- setIsOpen(!isOpen)
529
- startTransition(() => {
530
- getData(searchValue, constraints)
531
- })
532
- }}
533
- >
534
- <div className="flex items-center gap-2">
535
- <PopoverTrigger asChild>
536
- <Button
537
- variant="outline"
538
- role="combobox"
539
- aria-expanded={isOpen}
540
- className="w-full justify-between"
541
- disabled={isDisabled}
542
- >
543
- <span className="break-all whitespace-pre-wrap line-clamp-1 text-left">
544
- {inputValue ? display : ""}
545
- </span>
546
- <ChevronsUpDown className="opacity-50 h-4 w-4" />
547
- </Button>
548
- </PopoverTrigger>
549
- {
550
- <Button
551
- type="button"
552
- disabled={isDisabled || !inputValue || inputValue === "no_selection"}
553
- variant="outline"
554
- size="icon"
555
- onClick={() => {
556
- if (hasUser) {
557
- form.setValue("operation", "update")
558
- } else {
559
- form.setValue("operation", "create")
560
- }
561
- handleChange()
562
- }}
563
- >
564
- <Plus className="h-4 w-4" />
565
- </Button>
566
- }
567
- </div>
568
- <PopoverContent
569
- className={cn(popoverHeight, "w-[200px]", "sm:w-[280px]", "p-0", "h-60")}
570
- align="start"
559
+ {renderPicker(
560
+ <Button
561
+ type="button"
562
+ disabled={isDisabled || !inputValue || inputValue === "no_selection"}
563
+ variant="outline"
564
+ size="icon"
565
+ onClick={() => {
566
+ if (hasUser) {
567
+ form.setValue("operation", "update")
568
+ } else {
569
+ form.setValue("operation", "create")
570
+ }
571
+ handleChange()
572
+ }}
571
573
  >
572
- <Command
573
- filter={() => {
574
- return 1
575
- }}
576
- >
577
- <CommandInput
578
- placeholder={`Search ${title}...`}
579
- className="h-9"
580
- value={searchValue}
581
- onValueChange={(value) => {
582
- setSearchValue(value)
583
- }}
584
- />
585
- <CommandList>
586
- <CommandEmpty>
587
- {isOpen &&
588
- (isLoading ? (
589
- <LoadingSpinner size={7} className="m-auto" />
590
- ) : !isLoadingImmediate ? (
591
- `No ${title} found.`
592
- ) : null)}
593
- </CommandEmpty>
594
- {(!isLoading || isCollectionPreloadCacheEnabled) && (
595
- <CommandGroup>
596
- {data && (
597
- <CommandItem
598
- key="no_selection"
599
- value="no_selection"
600
- onSelect={(currentValue) => {
601
- setIsOpen(false)
602
- if (currentValue !== inputValue) {
603
- setValue(currentValue)
604
- setDisplay("----")
605
- }
606
- }}
607
- >
608
- ----
609
- </CommandItem>
610
- )}
611
- {data?.map((record: StokerRecord) => (
612
- <CommandItem
613
- key={record.id}
614
- value={record.id}
615
- onSelect={(currentValue) => {
616
- setIsOpen(false)
617
- if (currentValue !== inputValue) {
618
- setValue(currentValue)
619
- setDisplay(record[recordTitleField || "id"])
620
- }
621
- }}
622
- >
623
- {record[recordTitleField || "id"]}
624
- <Check
625
- className={cn(
626
- "ml-auto",
627
- inputValue === record.id ? "opacity-100" : "opacity-0",
628
- )}
629
- />
630
- </CommandItem>
631
- ))}
632
- </CommandGroup>
633
- )}
634
- </CommandList>
635
- </Command>
636
- </PopoverContent>
637
- </Popover>
574
+ <Plus className="h-4 w-4" />
575
+ </Button>,
576
+ )}
638
577
  </FormControl>
639
578
  </FormItem>
640
579
  <div className="mt-4">
@@ -0,0 +1,43 @@
1
+ import { useEffect, useState } from "react"
2
+
3
+ interface KeyboardViewport {
4
+ offset: number
5
+ viewportHeight: number
6
+ }
7
+
8
+ const getInitial = (): KeyboardViewport => {
9
+ if (typeof window === "undefined") return { offset: 0, viewportHeight: 0 }
10
+ const visualViewport = window.visualViewport
11
+ if (!visualViewport) return { offset: 0, viewportHeight: window.innerHeight }
12
+ return {
13
+ offset: Math.max(0, window.innerHeight - visualViewport.height - visualViewport.offsetTop),
14
+ viewportHeight: visualViewport.height,
15
+ }
16
+ }
17
+
18
+ export function useKeyboardOffset(enabled: boolean = true): KeyboardViewport {
19
+ const [state, setState] = useState<KeyboardViewport>(getInitial)
20
+
21
+ useEffect(() => {
22
+ if (!enabled) return
23
+ const visualViewport = window.visualViewport
24
+ if (!visualViewport) return
25
+
26
+ const update = () => {
27
+ setState({
28
+ offset: Math.max(0, window.innerHeight - visualViewport.height - visualViewport.offsetTop),
29
+ viewportHeight: visualViewport.height,
30
+ })
31
+ }
32
+ update()
33
+
34
+ visualViewport.addEventListener("resize", update)
35
+ visualViewport.addEventListener("scroll", update)
36
+ return () => {
37
+ visualViewport.removeEventListener("resize", update)
38
+ visualViewport.removeEventListener("scroll", update)
39
+ }
40
+ }, [enabled])
41
+
42
+ return state
43
+ }