diff --git a/package-lock.json b/package-lock.json index e20599efd..ec9099e59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1714,12 +1714,12 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.8.8", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.8.tgz", - "integrity": "sha512-YRsdVxq6OaLfmR9Hy816IMp33xOBjfyOgUd77ehqg96CFywxAPbDbXvAsuN2KVg2HOT8Eh6uAfU+l4WffwPVrQ==", + "version": "20.8.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.9.tgz", + "integrity": "sha512-UzykFsT3FhHb1h7yD4CA4YhBHq545JC0YnEz41xkipN88eKQtL6rSgocL5tbAP6Ola9Izm/Aw4Ora8He4x0BHg==", "dev": true, "dependencies": { - "undici-types": "~5.25.1" + "undici-types": "~5.26.4" } }, "node_modules/@types/semver": { @@ -2636,9 +2636,9 @@ } }, "node_modules/babel-plugin-jsx-dom-expressions": { - "version": "0.37.2", - "resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.37.2.tgz", - "integrity": "sha512-u3VKB+On86cYSLAbw9j0m0X8ZejL4MR7oG7TRlrMQ/y1mauR/ZpM2xkiOPZEUlzHLo1GYGlTdP9s5D3XuA6iSQ==", + "version": "0.37.8", + "resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.37.8.tgz", + "integrity": "sha512-nVHH6g7541aaAQJAsyWHvjH7GCXZ+8tuF3Qu4y9W9aKwonRbcJL+yyMatDJLvjC54iIuGowiiZM6Rm3AVJczGg==", "dev": true, "dependencies": { "@babel/helper-module-imports": "7.18.6", @@ -3333,9 +3333,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.566", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.566.tgz", - "integrity": "sha512-mv+fAy27uOmTVlUULy15U3DVJ+jg+8iyKH1bpwboCRhtDC69GKf1PPTZvEIhCyDr81RFqfxZJYrbgp933a1vtg==", + "version": "1.4.567", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.567.tgz", + "integrity": "sha512-8KR114CAYQ4/r5EIEsOmOMqQ9j0MRbJZR3aXD/KFA8RuKzyoUB4XrUCg+l8RUGqTVQgKNIgTpjaG8YHRPAbX2w==", "dev": true }, "node_modules/entities": { @@ -4906,9 +4906,9 @@ } }, "node_modules/is-what": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.15.tgz", - "integrity": "sha512-uKua1wfy3Yt+YqsD6mTUEa2zSi3G1oPlqTflgaPJ7z63vUGN5pxFpnQfeSLMFnJDEsdvOtkp1rUWkYjB4YfhgA==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", + "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", "dev": true, "engines": { "node": ">=12.13" @@ -6192,9 +6192,9 @@ "dev": true }, "node_modules/sass": { - "version": "1.69.4", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.69.4.tgz", - "integrity": "sha512-+qEreVhqAy8o++aQfCJwp0sklr2xyEzkm9Pp/Igu9wNPoe7EZEQ8X/MBvvXggI2ql607cxKg/RKOwDj6pp2XDA==", + "version": "1.69.5", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.69.5.tgz", + "integrity": "sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ==", "dev": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", @@ -6937,9 +6937,9 @@ } }, "node_modules/undici-types": { - "version": "5.25.3", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.25.3.tgz", - "integrity": "sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==", + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "dev": true }, "node_modules/universalify": { diff --git a/resources/js/components/felte-form/ValidationMessages.tsx b/resources/js/components/felte-form/ValidationMessages.tsx index faf37cd1a..fa1a108a3 100644 --- a/resources/js/components/felte-form/ValidationMessages.tsx +++ b/resources/js/components/felte-form/ValidationMessages.tsx @@ -1,5 +1,5 @@ import {ValidationMessage} from "@felte/reporter-solid"; -import {useFormContext} from "components/felte-form"; +import {useFormContextIfInForm} from "components/felte-form"; import {cx} from "components/utils"; import {Index, VoidComponent, createMemo, on} from "solid-js"; import {HideableSection} from "../ui/HideableSection"; @@ -20,7 +20,13 @@ export const ValidationMessages: VoidComponent = (props) => { {(messages) => {(message) =>
  • {message()}
  • }
    } ); - const {form} = useFormContext(); + const formContext = useFormContextIfInForm(); + if (!formContext) { + // Being inside a form or not is not something that can change dynamically, so it's fine to return early. + // eslint-disable-next-line solid/components-return-once + return undefined; + } + const {form} = formContext; const hasErrors = createMemo( // For some reason, the "on" part is required for reaction to errors and warnings change. // Depending directly on form.errors(props.fieldName) does not work reliably for some fields. diff --git a/resources/js/components/ui/InfoIcon.tsx b/resources/js/components/ui/InfoIcon.tsx new file mode 100644 index 000000000..f782e5f3e --- /dev/null +++ b/resources/js/components/ui/InfoIcon.tsx @@ -0,0 +1,50 @@ +import {A, AnchorProps} from "@solidjs/router"; +import {ImInfo} from "solid-icons/im"; +import {Match, Switch, VoidComponent} from "solid-js"; +import {htmlAttributes, useLangFunc} from "../utils"; +import {Button} from "./Button"; + +interface ButtonProps extends htmlAttributes.button { + href?: undefined; +} + +interface LinkProps extends AnchorProps { + href: string; +} + +type Props = ButtonProps | LinkProps; + +/** + * A tiny blue (i) icon providing more information to a control it is next to. + * It can be a button, or an internal or external link. + */ +export const InfoIcon: VoidComponent = (props) => { + const t = useLangFunc(); + const icon = ; + return ( + + + {(linkProps) => ( + { + // If the info icon is on an active element, we generally don't want to pass the click. + // TODO: Investigate why this doesn't work. + e.stopPropagation(); + }} + > + {icon} + + )} + + + {(buttonProps) => ( + + )} + + + ); +}; diff --git a/resources/js/components/ui/Table/Pagination.module.scss b/resources/js/components/ui/Table/Pagination.module.scss index 52b3d4dc4..d73d326d0 100644 --- a/resources/js/components/ui/Table/Pagination.module.scss +++ b/resources/js/components/ui/Table/Pagination.module.scss @@ -7,10 +7,13 @@ [data-part="next-trigger"], [data-part="item"], [data-part="ellipsis"] { - @apply px-1 border rounded flex justify-center items-center; + @apply px-1 border border-input-border rounded flex justify-center items-center; - &[data-disabled] svg { - @apply opacity-20; + &[data-disabled] { + @apply border-opacity-50; + svg { + @apply opacity-20; + } } } diff --git a/resources/js/components/ui/Table/TableColumnVisibilityController.tsx b/resources/js/components/ui/Table/TableColumnVisibilityController.tsx index 59c0c6eb7..9a2495fea 100644 --- a/resources/js/components/ui/Table/TableColumnVisibilityController.tsx +++ b/resources/js/components/ui/Table/TableColumnVisibilityController.tsx @@ -24,7 +24,11 @@ export const TableColumnVisibilityController: VoidComponent = () => { const api = createMemo(() => popover.connect(state, send, normalizeProps)); return (
    - diff --git a/resources/js/components/ui/Table/TableSearch.tsx b/resources/js/components/ui/Table/TableSearch.tsx index 72d1387e6..f8ad71fb4 100644 --- a/resources/js/components/ui/Table/TableSearch.tsx +++ b/resources/js/components/ui/Table/TableSearch.tsx @@ -15,7 +15,7 @@ export const TableSearch: VoidComponent> = (allProps) => { return (
    = (props) => {
    { if (lower()) { setUpper(lower()); diff --git a/resources/js/components/ui/Table/tquery_filters/TextualFilterControl.tsx b/resources/js/components/ui/Table/tquery_filters/TextualFilterControl.tsx index df06e7fd7..fefd416a9 100644 --- a/resources/js/components/ui/Table/tquery_filters/TextualFilterControl.tsx +++ b/resources/js/components/ui/Table/tquery_filters/TextualFilterControl.tsx @@ -1,5 +1,7 @@ -import {debouncedFilterTextAccessor} from "components/utils"; -import {Show, VoidComponent, createComputed, createSignal} from "solid-js"; +import {InfoIcon} from "components/ui/InfoIcon"; +import {Select, SelectItem} from "components/ui/form/Select"; +import {debouncedFilterTextAccessor, useLangFunc} from "components/utils"; +import {JSX, VoidComponent, createComputed, createSignal} from "solid-js"; import s from "./ColumnFilterController.module.scss"; import {buildFuzzyTextualColumnFilter} from "./fuzzy_filter"; import {FilterControlProps} from "./types"; @@ -13,6 +15,7 @@ type Mode = "~" | "=" | ".*"; export const TextualFilterControl: VoidComponent = (props) => { const [mode, setMode] = createSignal("~"); const [text, setText] = createSignal(""); + const t = useLangFunc(); createComputed(() => { if (!props.filter) { setMode("~"); @@ -35,20 +38,40 @@ export const TextualFilterControl: VoidComponent = (props) => return m satisfies never; } }); + const items = () => { + const items: SelectItem[] = []; + function addItem(mode: Mode, desc: JSX.Element, infoHref?: string) { + items.push({ + value: mode, + text: `${mode} ${desc}`, + label: () => {mode}, + labelOnList: () => ( +
    + {mode} + {desc} + {infoHref && } +
    + ), + }); + } + addItem("~", t("tables.filter.textual.fuzzy"), "/pomoc/dopasowanie"); + if (props.columnType === "string") { + addItem("=", t("tables.filter.textual.eq")); + } + addItem(".*", t("tables.filter.textual.regexp"), "https://support.google.com/a/answer/1371415?hl=pl"); + return items; + }; return (
    - + onValueChange={(value) => setMode(value as Mode)} + nullable={false} + small + />
    = (allProps) => { return langPrefixFunc && subKey ? getLangEntryFunc(langPrefixFunc, subKey) : undefined; }; return ( - - {props.wrapIn(<>{props.fallbackCode})} - - } - > - {(langFunc) => ( - {langFunc()()})}> - {(text) => props.wrapIn()} - - )} - - } - > - {(override) => ( - - {props.wrapIn(<>{override().value})} - - )} - + + + {(override) => ( + + {props.wrapIn(<>{override().value})} + + )} + + + {(langFunc) => ( + {langFunc()()})}> + {(text) => props.wrapIn()} + + )} + + {props.wrapIn(<>{props.fallbackCode})} + ); }; diff --git a/resources/js/components/ui/form/Checkbox.tsx b/resources/js/components/ui/form/Checkbox.tsx index 60216f0f7..759a0498a 100644 --- a/resources/js/components/ui/form/Checkbox.tsx +++ b/resources/js/components/ui/form/Checkbox.tsx @@ -26,7 +26,7 @@ export const Checkbox: VoidComponent = (props) => ( type="checkbox" id={props.name} {...htmlAttributes.merge(props, { - class: "border border-gray-400 p-2 aria-invalid:border-red-400", + class: "border border-input-border p-2 aria-invalid:border-red-400", })} aria-labelledby={labelIdForField(props.name)} />{" "} diff --git a/resources/js/components/ui/form/DictionarySelect.tsx b/resources/js/components/ui/form/DictionarySelect.tsx new file mode 100644 index 000000000..1ec5f048e --- /dev/null +++ b/resources/js/components/ui/form/DictionarySelect.tsx @@ -0,0 +1,57 @@ +import {NON_NULLABLE, htmlAttributes} from "components/utils"; +import {Position, useDictionaries} from "data-access/memo-api/dictionaries"; +import {VoidComponent, createMemo, mergeProps, splitProps} from "solid-js"; +import {MultipleSelectPropsPart, Select, SelectBaseProps, SelectItem, SingleSelectPropsPart} from "./Select"; +import {mergeSelectProps} from "./select_helper"; + +interface BaseProps + extends htmlAttributes.div, + Pick { + /** The id or name of the dictionary. */ + dictionary: string; + filterable?: boolean; + /** What to do with disabled dictionary positions. Default: hide. */ + disabledItemsMode?: "show" | "showAsActive" | "hide"; + /** A function creating the items. It can make use of the default item properties provided. */ + itemFunc?: (pos: Position, defItem: () => DefaultDictionarySelectItem) => SelectItem | undefined; +} + +type DefaultDictionarySelectItem = Required>; + +type Props = BaseProps & (SingleSelectPropsPart | MultipleSelectPropsPart); + +const DEFAULT_PROPS = { + filterable: true, + disabledItemsMode: "hide", + itemFunc: (pos: Position, defItem: () => DefaultDictionarySelectItem) => defItem(), +} satisfies Partial; + +export const DictionarySelect: VoidComponent = (allProps) => { + const defProps = mergeProps(DEFAULT_PROPS, allProps); + const [props, selectProps] = splitProps(defProps, ["dictionary", "filterable", "disabledItemsMode", "itemFunc"]); + const dictionaries = useDictionaries(); + const items = createMemo(() => { + const dicts = dictionaries(); + if (!dicts) { + return undefined; + } + const dict = dicts.get(props.dictionary); + const positions = props.disabledItemsMode === "hide" ? dict.activePositions : dict.allPositions; + return positions + .map((pos) => { + const defItem = (): DefaultDictionarySelectItem => ({ + value: pos.id, + text: pos.label, + disabled: props.disabledItemsMode === "show" && pos.disabled, + }); + return props.itemFunc(pos, defItem); + }) + .filter(NON_NULLABLE); + }); + const mergedSelectProps = mergeSelectProps<"items" | "isLoading" | "onFilterChange">(selectProps, { + items: () => items() || [], + isLoading: () => !items(), + onFilterChange: () => (props.filterable ? "internal" : undefined), + }); + return )} diff --git a/resources/js/components/ui/form/Select.module.scss b/resources/js/components/ui/form/Select.module.scss new file mode 100644 index 000000000..41c357d5c --- /dev/null +++ b/resources/js/components/ui/form/Select.module.scss @@ -0,0 +1,138 @@ +.select { + [data-part="control"] { + --bg-color: white; + &[data-disabled] { + --bg-color: theme("colors.disabled"); + } + + @apply w-full min-h-big-input border border-input-border rounded aria-invalid:border-red-400; + @apply relative; + background-color: var(--bg-color); + + // Calculate the correct padding for the contents, taking into account the buttons on the right. + --padding-x: 0.5rem; + // Place for the down arrow button. + --buttons-width: 16px; + &:has(.clearButton) { + --buttons-width: 34px; + } + --padding: 1px calc(var(--buttons-width) + var(--padding-x)) 1px var(--padding-x); + + .buttons { + @apply absolute top-0.5 bottom-0.5 right-0.5 flex items-center gap-0.5; + background-color: var(--bg-color); + + button { + width: 16px; + max-height: 20px; + } + } + + &[data-disabled] button svg { + @apply text-opacity-60; + } + } + + &.single [data-part="control"] { + // In the single mode, place .value and input in the same area using grid, and apply the computed + // --padding to both of them. This way the input's outer size is the same as that of the whole + // component, and it looks better when the input is active. + @apply w-full h-full grid; + + .value { + @apply wrapTextAnywhere; + @apply col-start-1 row-start-1 my-auto overflow-hidden; + padding: var(--padding); + } + + [data-part="input"] { + @apply col-start-1 row-start-1 min-w-0 rounded; + padding: var(--padding); + @apply bg-transparent; + } + } + + &.multiple [data-part="control"] { + // In the multiple mode, apply the computed --padding to the whole control, and use flex to place + // the values and the input. + --padding-x: 0.25rem; + + @apply w-full h-full overflow-hidden; + padding: var(--padding); + @apply flex flex-wrap items-center gap-0.5; + + .value { + @apply px-1 border border-gray-400 rounded flex gap-0.5; + + .label { + @apply wrapTextAnywhere; + } + .delete { + @apply px-0.5; + } + } + + [data-part="input"] { + @apply grow shrink basis-0; + @apply rounded px-1; + min-width: 2rem; + @apply outline-none; + } + } + + &.small { + [data-part="control"] { + @apply min-h-small-input; + --padding-x: 0.25rem; + } + + &.multiple [data-part="control"] { + [data-part="input"] { + @apply px-0; + } + } + } +} + +.selectPortal { + [data-part="content"] { + @apply bg-white border rounded shadow-xl; + @apply overflow-x-clip overflow-y-auto; + max-height: var(--available-height); + min-width: var(--reference-width); + max-width: var(--available-width); + } + + [data-part="item"] { + @apply p-1 wrapTextAnywhere overflow-x-clip cursor-pointer text-black; + + &[data-disabled] { + @apply text-opacity-60 cursor-default; + } + } + + &:not(.loading) [data-part="item"] { + &[data-highlighted] { + @apply bg-hover; + } + // The checked rule must be below the highlighted rule. + &[data-state="checked"] { + @apply bg-select; + } + } + + &.loading { + [data-part="content"] { + @apply bg-gray-200; + } + [data-part="item"] { + @apply cursor-default text-opacity-40; + } + } + + &.small { + [data-part="item"] { + @apply py-0.5; + } + } +} diff --git a/resources/js/components/ui/form/Select.tsx b/resources/js/components/ui/form/Select.tsx new file mode 100644 index 000000000..81d91d832 --- /dev/null +++ b/resources/js/components/ui/form/Select.tsx @@ -0,0 +1,450 @@ +import {Collection} from "@zag-js/collection"; +import * as combobox from "@zag-js/combobox"; +import {PropTypes, normalizeProps, useMachine} from "@zag-js/solid"; +import {cx, htmlAttributes, useLangFunc} from "components/utils"; +import {AiFillCaretDown} from "solid-icons/ai"; +import {FiDelete} from "solid-icons/fi"; +import {ImCross, ImSpinner2} from "solid-icons/im"; +import {RiSystemDeleteBin6Line} from "solid-icons/ri"; +import { + Accessor, + For, + JSX, + Match, + Show, + Switch, + VoidComponent, + VoidProps, + createComputed, + createEffect, + createMemo, + createUniqueId, + mergeProps, + splitProps, +} from "solid-js"; +import {Portal} from "solid-js/web"; +import {Button} from "../Button"; +import {FieldLabel} from "./FieldLabel"; +import s from "./Select.module.scss"; +import {on} from "solid-js"; + +export interface SelectBaseProps extends VoidProps { + name: string; + label?: string; + /** + * The items to show in this select. In the external filtering mode, the list should change + * when the filter changes. In the internal filtering mode, the list should not change, and will + * be filtered internally. + */ + items: SelectItem[]; + /** + * Filtering: + * - If missing, filtering is disabled (the default). + * - If `"internal"`, the component will filter the items internally by the `text` property of the items. + * - If a function, the function is called when the filter text changes, which typically results in the parent + * supplying a new list of items. + */ + onFilterChange?: "internal" | ((filterText: string | undefined) => void); + /** Whether the items are still loading. */ + isLoading?: boolean; + disabled?: boolean; + placeholder?: string; + /** Whether the control should be shown in the small version. */ + small?: boolean; +} + +export interface SingleSelectPropsPart { + multiple?: false; + value?: string | undefined; + onValueChange?: (value: string | undefined) => void; + /** + * Whether the value can be cleared from within the select control. + * Even with nullable set to false, it is possible to have no value, but it is not possible + * to set this value from within the select control. + */ + nullable: boolean; +} + +export interface MultipleSelectPropsPart { + multiple: true; + value?: string[]; + onValueChange?: (value: string[]) => void; + /** Whether to show the button to clear all of the selected values. Defaults to true. */ + showClearButton?: boolean; +} + +export type SingleSelectProps = SelectBaseProps & SingleSelectPropsPart; +export type MultipleSelectProps = SelectBaseProps & MultipleSelectPropsPart; +export type SelectProps = SingleSelectProps | MultipleSelectProps; + +export interface SelectItem { + /** The internal value of the item. Must be unique among the items. Must not be empty. */ + value: string; + /** + * The optional text, used only for internal filtering of the items (when onFilterChange is not specified). + * If missing in the internal filtering mode, the items are filtered by the value string. + */ + text?: string; + /** The item, as displayed in the component when selected. If not specified, the text (or the value) is used. */ + label?: () => JSX.Element; + /** The item, as displayed on the expanded list. If not specified, label is used. */ + labelOnList?: () => JSX.Element; + disabled?: boolean; +} + +function itemToString(item: SelectItem) { + return item.text || item.value; +} +function itemToLabel(item: SelectItem) { + return item.label ? item.label() : <>{itemToString(item)}; +} +function itemToLabelOnList(item: SelectItem) { + return item.labelOnList ? item.labelOnList() : itemToLabel(item); +} + +const DEFAULT_PROPS = { + isLoading: false, + small: false, + nullable: false, + showClearButton: true, +}; + +/** + * A select-like component for selecting a single item from a list of items. + * Supports searching using keyboard (the parent should provide the filtered list of items). + * + * TODO: Add support for placing the component in a form. Right now there is a hidden input with + * the selected value, but the component does not receive the value from the form controller. + * + * WARNING: The implementation has many workarounds and specific solutions, and the zag component + * it's based on is still in development and has bugs. It might also change in an incompatible way + * even between minor versions while it's still on the major version 0. Be very careful when updating + * the zag library. + */ +export const Select: VoidComponent = (allProps) => { + const defProps = mergeProps(DEFAULT_PROPS, allProps); + const [props, divProps] = splitProps(defProps, [ + "name", + "label", + "items", + "nullable", + "multiple", + "value", + "onValueChange", + "onFilterChange", + "showClearButton", + "disabled", + "isLoading", + "placeholder", + "small", + ]); + const t = useLangFunc(); + + // Temporarily assign an empty collection, and overwrite with the actual collection depending on + // the filtered items later. It's done like this because filtering needs api() which is not created yet. + let collection: Accessor> = () => combobox.collection.empty(); + + const [state, send] = useMachine( + combobox.machine({ + id: createUniqueId(), + // eslint-disable-next-line solid/reactivity + name: props.name, + // Needed but never used, the actual collection comes from the context below. + collection: combobox.collection.empty(), + positioning: { + offset: {mainAxis: 0}, + strategy: "absolute", + placement: "bottom-end", + overflowPadding: 20, + flip: true, + sameWidth: false, + }, + onInputValueChange: ({value}) => { + if (typeof props.onFilterChange === "function") { + props.onFilterChange(value); + } + }, + onValueChange: ({value}) => { + if (props.onValueChange) { + if (props.multiple) { + (props as MultipleSelectPropsPart).onValueChange!(value); + } else { + (props as SingleSelectPropsPart).onValueChange!(value[0]); + } + } else { + api().setValue(value); + } + // Clear the filtering, in case the user wants to select another item later. + if (typeof props.onFilterChange === "function") { + props.onFilterChange?.(undefined); + } + }, + // Keep the input empty when the value is selected. The selected value is displayed outside of + // the input. + selectionBehavior: "clear", + // We want the open on click behavior, but we need a custom implementation because we want to + // react not only on clicking the input. In particular, in the no filtering mode there is no input. + openOnClick: false, + // This option seems to be equivalent to `broken: false`, as selecting on blur breaks many things, + // especially in the multiple mode. + selectOnBlur: false, + loop: false, + // We want to clear the input when the user clicks outside of the component. This is the default, but + // there seems to be a bug - clicking the buttons inside of the select are also treated as clicking + // outside of the component. So instead set allowCustomValue and clear the input conditionally. + // This is a workaround. + allowCustomValue: true, + onInteractOutside: (e) => { + const {target} = e.detail.originalEvent; + const isReallyInside = target instanceof Node && (root?.contains(target) || portalRoot?.contains(target)); + if (!isReallyInside) { + api().setInputValue(""); + } + }, + }), + { + context: () => ({ + collection: collection(), + multiple: props.multiple, + disabled: props.disabled, + }), + }, + ); + const api = createMemo(() => combobox.connect(state, send, normalizeProps)); + createComputed( + on( + () => props.value, + (propsValue) => + api().setValue(Array.isArray(propsValue) ? propsValue : propsValue === undefined ? [] : [propsValue]), + ), + ); + + const isInternalFilteringMode = () => props.onFilterChange === "internal"; + // Wrap the input value in a memo to avoid an infinite loop of updates, where updating the filter changes the items, + // which updates the collection, and in consequence the api object, which unnecessarily triggers the filtering again. + const filterValue = createMemo(() => api().inputValue.toLocaleLowerCase()); + /** The items after filtering, regardless of the filtering mode. */ + const filteredItems = createMemo(() => { + if (isInternalFilteringMode()) { + const filter = filterValue(); + if (!filter) { + return props.items; + } + return props.items.filter((item) => itemToString(item).toLocaleLowerCase().includes(filter)); + } + return props.items; + }); + const itemsToShow = createMemo((): SelectItem[] => { + const filtered = filteredItems(); + if (filtered.length) { + return filtered; + } + if (props.isLoading) { + return [ + { + value: " _loading", + label: () => ( +
    + +
    + ), + disabled: true, + }, + ]; + } + return [ + { + value: " _noItems", + label: () => <>{t(api().isInputValueEmpty ? "select.no_items" : "select.no_matching_items")}, + disabled: true, + }, + ]; + }); + const collectionMemo = createMemo(() => + combobox.collection({ + items: itemsToShow(), + itemToValue: (item) => item.value, + // All the items present themselves as empty string because there is at least one bug + // in the zag component that causes the string representation of the selected item to + // appear in the input field. This is a workaround. + itemToString: () => "", + isItemDisabled: (item) => !!item.disabled, + }), + ); + collection = collectionMemo; + + /** + * A map for storing all the encountered items. This is needed to show the selected item(s) when + * a filter is present, because otherwise the items don't exist, and api().selectedItems doesn't + * return them, even though api().value has the corresponding entries. + */ + const itemsMap = new Map(); + createEffect(() => { + for (const item of filteredItems()) { + itemsMap.set(item.value, item); + } + }); + /** + * Returns the label for the specified value. If the value is unknown (not present in itemsMap), + * the value is removed from the selected values in api(), and undefined is returned. + */ + function getValueLabel(value: string) { + const item = itemsMap.get(value); + if (item) { + return itemToLabel(item); + } + api().clearValue(value); + return undefined; + } + + // Sometimes api().inputValue is correctly empty, but the input still contains some text, which is probably + // a bug in the zag component. This is a workaround. + let input: HTMLInputElement | undefined; + createEffect(() => { + if (input) { + input.value = api().inputValue; + } + }); + + let root: HTMLDivElement | undefined; + let portalRoot: HTMLDivElement | undefined; + + /** Whether the component is disabled, either directly or via a fieldset. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const isDisabled = () => (api().controlProps as any)["data-disabled"] !== undefined; + + return ( + <> +
    + )} + /> + {/* An input that can be consumed by the form controller. + It cannot be set by the form controller though (yet). */} + +
    api().open()}> + + + + {(value) => { + return ( +
    +
    {getValueLabel(value)}
    + +
    + ); + }} +
    +
    + + {/* The current value is displayed inside the input element, so only display it + when the input is empty (like a placeholder). */} + + {(value) =>
    {getValueLabel(value())}
    } +
    +
    +
    + +
    + {/* Display only one clear button at a time. */} + + + + + + + + + + + + +
    +
    +
    + +
    +
      + + {(item) =>
    • {itemToLabelOnList(item)}
    • } +
      +
    +
    +
    + + ); +}; diff --git a/resources/js/components/ui/form/SimpleSelect.tsx b/resources/js/components/ui/form/SimpleSelect.tsx index 7a9c848df..64e69effc 100644 --- a/resources/js/components/ui/form/SimpleSelect.tsx +++ b/resources/js/components/ui/form/SimpleSelect.tsx @@ -27,7 +27,7 @@ export const SimpleSelect: VoidComponent = (allProps) => { ; + */ +export function mergeSelectProps( + props: Omit, + other: {[k in K]: Accessor}, +): SelectProps { + const otherProps = {}; + for (const [k, v] of Object.entries(other) as [K, Accessor][]) { + Object.defineProperty(otherProps, k, {get: v}); + } + const mergedProps = mergeProps(props, otherProps); + return mergedProps as SelectProps; +} diff --git a/resources/js/data-access/memo-api/dictionaries.ts b/resources/js/data-access/memo-api/dictionaries.ts index e48dab947..bfa30ee40 100644 --- a/resources/js/data-access/memo-api/dictionaries.ts +++ b/resources/js/data-access/memo-api/dictionaries.ts @@ -29,9 +29,13 @@ export class Dictionaries { return new Dictionaries(byId, byName); } - /** Returns a dictionary by id, or a fixed dictionary by name. */ + /** Returns a dictionary by id, or a fixed dictionary by name. Throws an error if not found. */ get(idOrName: string) { - return this.byId.get(idOrName) || this.byName.get(idOrName); + const dictionary = this.byId.get(idOrName) || this.byName.get(idOrName); + if (!dictionary) { + throw new Error(`Dictionary ${idOrName} not found.`); + } + return dictionary; } /** Returns a subset of the dictionaries (and positions) accessible for the specified facility. */ diff --git a/resources/js/index.tsx b/resources/js/index.tsx index 682fc8608..08c27d091 100644 --- a/resources/js/index.tsx +++ b/resources/js/index.tsx @@ -5,7 +5,7 @@ import {Router} from "@solidjs/router"; import {InitializeTanstackQuery} from "components/utils"; import {Settings} from "luxon"; import {Show} from "solid-js"; -import {render} from "solid-js/web"; +import {DelegatedEvents, render} from "solid-js/web"; import {Toaster} from "solid-toast"; import App from "./App"; import {LoaderInPortal, MemoLoader} from "./components/ui/MemoLoader"; @@ -25,6 +25,9 @@ declare module "luxon" { } } +// Allow stopping propagation of events (see https://github.com/solidjs/solid/issues/1786#issuecomment-1694589801). +DelegatedEvents.clear(); + render(() => { return (