initial commit
This commit is contained in:
168
fluxer_app/src/components/form/ColorPickerField.module.css
Normal file
168
fluxer_app/src/components/form/ColorPickerField.module.css
Normal file
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.fieldset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.labelContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.inputContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
display: flex;
|
||||
height: 2.75rem;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
transition-property: color, background-color, border-color;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.inputWrapper:focus-within {
|
||||
border-color: var(--background-modifier-accent-focus);
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex: 1 1 0%;
|
||||
appearance: none;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
padding: 0.625rem 1rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
outline: none;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.inputError {
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: auto;
|
||||
width: 1px;
|
||||
background-color: var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
.swatchButton {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 3rem;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.swatchButton:enabled {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.swatchButton:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.swatchIcon {
|
||||
filter: drop-shadow(0 4px 3px rgb(0 0 0 / 0.07)) drop-shadow(0 2px 2px rgb(0 0 0 / 0.06));
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
color: var(--status-danger);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.popover {
|
||||
z-index: 20000;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.popover[data-entering] {
|
||||
animation: popover-enter 150ms ease-out;
|
||||
}
|
||||
|
||||
.popover[data-exiting] {
|
||||
animation: popover-exit 100ms ease-in;
|
||||
}
|
||||
|
||||
@keyframes popover-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes popover-exit {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.dialog {
|
||||
outline: none;
|
||||
}
|
||||
281
fluxer_app/src/components/form/ColorPickerField.tsx
Normal file
281
fluxer_app/src/components/form/ColorPickerField.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {EyedropperIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import Color from 'colorjs.io';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import {Button, Dialog, DialogTrigger, Popover} from 'react-aria-components';
|
||||
import {ColorPickerPopout} from '~/components/popouts/ColorPickerPopout';
|
||||
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
|
||||
import styles from './ColorPickerField.module.css';
|
||||
import surfaceStyles from './FormSurface.module.css';
|
||||
|
||||
function clampByte(n: number) {
|
||||
return Math.max(0, Math.min(255, Math.round(n)));
|
||||
}
|
||||
|
||||
function rgbToHex(r: number, g: number, b: number) {
|
||||
return `#${clampByte(r).toString(16).padStart(2, '0')}${clampByte(g).toString(16).padStart(2, '0')}${clampByte(b).toString(16).padStart(2, '0')}`.toUpperCase();
|
||||
}
|
||||
|
||||
function hexToNumber(hex: string): number {
|
||||
const clean = hex.replace('#', '');
|
||||
return parseInt(clean.slice(0, 6), 16) >>> 0;
|
||||
}
|
||||
|
||||
function numberToHex(n: number): string {
|
||||
return `#${(n >>> 0).toString(16).padStart(6, '0').slice(-6)}`.toUpperCase();
|
||||
}
|
||||
|
||||
function expandShortHex(h: string) {
|
||||
if (h.length === 4 || h.length === 5) {
|
||||
const chars = h.slice(1).split('');
|
||||
const expanded = chars.map((c) => c + c).join('');
|
||||
return `#${expanded}`;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function parseColor(input: string): {hex: string; num: number} | null {
|
||||
const raw = (input || '').trim();
|
||||
|
||||
if (raw.startsWith('#')) {
|
||||
let h = raw.toUpperCase();
|
||||
h = expandShortHex(h);
|
||||
if (h.length === 9) h = h.slice(0, 7);
|
||||
if (/^#[0-9A-F]{6}$/.test(h)) return {hex: h, num: hexToNumber(h)};
|
||||
return null;
|
||||
}
|
||||
|
||||
{
|
||||
const ctx = document.createElement('canvas').getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.fillStyle = '#000';
|
||||
(ctx as any).fillStyle = raw as any;
|
||||
const parsedRaw = String(ctx.fillStyle);
|
||||
|
||||
ctx.fillStyle = '#123456';
|
||||
(ctx as any).fillStyle = raw as any;
|
||||
const secondRaw = String(ctx.fillStyle);
|
||||
|
||||
const looksValid = parsedRaw !== '#000000' || secondRaw !== '#123456';
|
||||
if (looksValid) {
|
||||
const m = /^rgba?\((\d+),\s*(\d+),\s*(\d+)/i.exec(parsedRaw);
|
||||
if (m) {
|
||||
const r = parseInt(m[1], 10);
|
||||
const g = parseInt(m[2], 10);
|
||||
const b = parseInt(m[3], 10);
|
||||
const hex = rgbToHex(r, g, b);
|
||||
return {hex, num: hexToNumber(hex)};
|
||||
}
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(parsedRaw)) {
|
||||
const hex = parsedRaw.toUpperCase();
|
||||
return {hex, num: hexToNumber(hex)};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function bestIconColorFor(bgColorCss: string): 'black' | 'white' {
|
||||
if (bgColorCss === 'var(--text-chat)') {
|
||||
const isLightTheme = document.documentElement.classList.contains('theme-light');
|
||||
return isLightTheme ? 'white' : 'black';
|
||||
}
|
||||
try {
|
||||
const bgColor = new Color(bgColorCss);
|
||||
const contrastWithWhite = Math.abs(bgColor.contrast('#FFFFFF', 'WCAG21'));
|
||||
const contrastWithBlack = Math.abs(bgColor.contrast('#000000', 'WCAG21'));
|
||||
return contrastWithWhite >= contrastWithBlack ? 'white' : 'black';
|
||||
} catch {
|
||||
return 'white';
|
||||
}
|
||||
}
|
||||
|
||||
interface ColorPickerFieldProps {
|
||||
label?: string;
|
||||
description?: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
defaultValue?: number;
|
||||
hideHelperText?: boolean;
|
||||
descriptionClassName?: string;
|
||||
}
|
||||
|
||||
export const ColorPickerField: React.FC<ColorPickerFieldProps> = observer((props) => {
|
||||
const {t} = useLingui();
|
||||
const {label, description, value, onChange, disabled, className, defaultValue, hideHelperText, descriptionClassName} =
|
||||
props;
|
||||
|
||||
const containerRef = React.useRef<HTMLFieldSetElement>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const getEffectiveValue = React.useCallback(() => {
|
||||
return value === 0 && defaultValue !== undefined ? defaultValue : value;
|
||||
}, [value, defaultValue]);
|
||||
|
||||
const [inputValue, setInputValue] = React.useState(() => numberToHex(getEffectiveValue()));
|
||||
const [showError, setShowError] = React.useState(false);
|
||||
const [popoutOpen, setPopoutOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!popoutOpen) {
|
||||
const effectiveValue = getEffectiveValue();
|
||||
setInputValue(numberToHex(effectiveValue));
|
||||
}
|
||||
}, [getEffectiveValue, popoutOpen]);
|
||||
|
||||
const commitFromText = React.useCallback(() => {
|
||||
const parsed = parseColor(inputValue);
|
||||
const effectiveValue = getEffectiveValue();
|
||||
if (!parsed) {
|
||||
setShowError(true);
|
||||
setInputValue(numberToHex(effectiveValue));
|
||||
return;
|
||||
}
|
||||
if (parsed.num !== effectiveValue) {
|
||||
onChange(parsed.num);
|
||||
}
|
||||
setInputValue(parsed.hex);
|
||||
setShowError(false);
|
||||
}, [inputValue, getEffectiveValue, onChange]);
|
||||
|
||||
const handleInputBlur = React.useCallback(() => {
|
||||
commitFromText();
|
||||
}, [commitFromText]);
|
||||
|
||||
const handleInputKeyDown: React.KeyboardEventHandler<HTMLInputElement> = React.useCallback(
|
||||
(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commitFromText();
|
||||
containerRef.current?.querySelector<HTMLButtonElement>('button[data-role="swatch"]')?.focus();
|
||||
}
|
||||
},
|
||||
[commitFromText],
|
||||
);
|
||||
|
||||
const handleInputChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value);
|
||||
setShowError(false);
|
||||
}, []);
|
||||
|
||||
const handleColorChange = React.useCallback(
|
||||
(colorHex: string) => {
|
||||
const parsed = parseColor(colorHex);
|
||||
if (parsed) {
|
||||
onChange(parsed.num);
|
||||
setInputValue(parsed.hex);
|
||||
setShowError(false);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
onChange(0);
|
||||
const resetHex = defaultValue !== undefined ? numberToHex(defaultValue) : '#000000';
|
||||
setInputValue(resetHex);
|
||||
setShowError(false);
|
||||
setPopoutOpen(false);
|
||||
}, [onChange, defaultValue]);
|
||||
|
||||
const effectiveValue = getEffectiveValue();
|
||||
const logicalHex = numberToHex(effectiveValue);
|
||||
const swatchBackgroundCss =
|
||||
value === 0 && defaultValue !== undefined ? logicalHex : value === 0 ? 'var(--text-chat)' : logicalHex;
|
||||
|
||||
const iconOnSwatch = bestIconColorFor(swatchBackgroundCss);
|
||||
|
||||
return (
|
||||
<FocusRing within={true} offset={-2} enabled={!disabled}>
|
||||
<fieldset ref={containerRef} className={clsx(styles.fieldset, className)}>
|
||||
{label && (
|
||||
<div className={styles.labelContainer}>
|
||||
<legend className={styles.label}>{label}</legend>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={clsx(styles.inputWrapper, surfaceStyles.surface)}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputBlur}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder="#000000, rgb(...), red"
|
||||
maxLength={64}
|
||||
disabled={disabled}
|
||||
className={clsx(styles.input, showError && styles.inputError)}
|
||||
aria-label={t`Color value`}
|
||||
aria-invalid={showError}
|
||||
/>
|
||||
<div className={styles.divider} />
|
||||
<DialogTrigger isOpen={popoutOpen} onOpenChange={setPopoutOpen}>
|
||||
<Button
|
||||
data-role="swatch"
|
||||
className={styles.swatchButton}
|
||||
style={{backgroundColor: swatchBackgroundCss}}
|
||||
aria-label={t`Open color picker`}
|
||||
isDisabled={disabled}
|
||||
>
|
||||
<EyedropperIcon
|
||||
size={18}
|
||||
weight="fill"
|
||||
style={{color: iconOnSwatch === 'white' ? '#FFFFFF' : '#000000'}}
|
||||
className={styles.swatchIcon}
|
||||
/>
|
||||
</Button>
|
||||
<Popover placement="bottom start" offset={8} className={styles.popover}>
|
||||
<Dialog className={styles.dialog} aria-label={t`Color picker`}>
|
||||
<ColorPickerPopout
|
||||
color={numberToHex(effectiveValue)}
|
||||
onChange={handleColorChange}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
|
||||
{(description || !hideHelperText) && (
|
||||
<p className={clsx(styles.description, descriptionClassName)}>
|
||||
{description ?? <Trans>Type a color (hex, rgb(), hsl, or name) — or use the picker.</Trans>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showError && (
|
||||
<p className={styles.errorText}>
|
||||
<Trans>That doesn't look like a valid color. Try hex, rgb(), hsl(), or a CSS color name.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</FocusRing>
|
||||
);
|
||||
});
|
||||
53
fluxer_app/src/components/form/Form.tsx
Normal file
53
fluxer_app/src/components/form/Form.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type {FieldValues, UseFormReturn} from 'react-hook-form';
|
||||
|
||||
type FormProps<T extends FieldValues> = Omit<React.HTMLAttributes<HTMLFormElement>, 'onSubmit'> & {
|
||||
form: UseFormReturn<T>;
|
||||
onSubmit: (values: T) => void;
|
||||
'aria-label'?: string;
|
||||
'aria-labelledby'?: string;
|
||||
};
|
||||
|
||||
export const Form = observer(
|
||||
<T extends FieldValues>({
|
||||
form,
|
||||
onSubmit,
|
||||
children,
|
||||
'aria-label': ariaLabel,
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
...props
|
||||
}: FormProps<T>) => (
|
||||
<form
|
||||
{...props}
|
||||
aria-label={ariaLabel || undefined}
|
||||
aria-labelledby={ariaLabelledBy || undefined}
|
||||
style={{display: 'contents', ...props.style}}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
form.clearErrors();
|
||||
form.handleSubmit(onSubmit)(event);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
),
|
||||
);
|
||||
23
fluxer_app/src/components/form/FormSurface.module.css
Normal file
23
fluxer_app/src/components/form/FormSurface.module.css
Normal file
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.surface {
|
||||
background-color: var(--form-surface-background);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
232
fluxer_app/src/components/form/Input.module.css
Normal file
232
fluxer_app/src/components/form/Input.module.css
Normal file
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
resize: none;
|
||||
appearance: none;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
transition-property: color, background-color, border-color;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
:global(.theme-light) .input {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input.minHeight {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.input.hasRightElement {
|
||||
padding-right: 3rem;
|
||||
}
|
||||
|
||||
.input.hasLeftIcon {
|
||||
padding-left: 2.25rem;
|
||||
}
|
||||
|
||||
.input.focusable:focus,
|
||||
.input.focusable:focus-within {
|
||||
border-color: var(--background-modifier-accent-focus);
|
||||
}
|
||||
|
||||
.input.error {
|
||||
border-color: var(--status-danger);
|
||||
}
|
||||
|
||||
.fieldset {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.labelContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin: 0;
|
||||
display: block;
|
||||
padding: 0;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.inputGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.inputContainer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.leftIcon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0.75rem;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.leftElement {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0.25rem;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.input.hasLeftElement {
|
||||
padding-left: 2.5rem;
|
||||
}
|
||||
|
||||
.rightIcon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.75rem;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.passwordToggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.75rem;
|
||||
transform: translateY(-50%);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.375rem;
|
||||
color: var(--text-tertiary);
|
||||
transition-property: color, background-color, border-color;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.passwordToggle:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.rightElement {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.5rem;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.textareaWrapper {
|
||||
display: flex;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
transition-property: color, background-color, border-color;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.textareaWrapper.focusable:focus-within {
|
||||
border-color: var(--background-modifier-accent-focus);
|
||||
}
|
||||
|
||||
.textareaWrapper.error {
|
||||
border-color: var(--status-danger);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
flex: 1 1 0%;
|
||||
resize: none;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.textarea::placeholder {
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.textareaActions {
|
||||
display: flex;
|
||||
min-width: 48px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.625rem 0.75rem;
|
||||
}
|
||||
|
||||
.characterCountContainer {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.characterCount {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
340
fluxer_app/src/components/form/Input.tsx
Normal file
340
fluxer_app/src/components/form/Input.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {EyeIcon, EyeSlashIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import lodash from 'lodash';
|
||||
import React, {useState} from 'react';
|
||||
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
|
||||
import type {TextareaAutosizeProps} from '~/lib/TextareaAutosize';
|
||||
import {TextareaAutosize} from '~/lib/TextareaAutosize';
|
||||
import scrollerStyles from '~/styles/Scroller.module.css';
|
||||
import surfaceStyles from './FormSurface.module.css';
|
||||
import styles from './Input.module.css';
|
||||
|
||||
type FieldSetProps = React.HTMLProps<HTMLFieldSetElement> & {
|
||||
children: React.ReactNode;
|
||||
error?: string;
|
||||
footer?: React.ReactNode;
|
||||
label?: string;
|
||||
labelRight?: React.ReactNode;
|
||||
htmlFor?: string;
|
||||
};
|
||||
|
||||
const FieldSet = React.forwardRef<HTMLFieldSetElement, FieldSetProps>(
|
||||
({label, labelRight, children, error, footer, htmlFor}, ref) => (
|
||||
<fieldset ref={ref} className={styles.fieldset}>
|
||||
{label && (
|
||||
<div className={styles.labelContainer}>
|
||||
<label htmlFor={htmlFor} className={styles.label}>
|
||||
{label}
|
||||
</label>
|
||||
{labelRight}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.inputGroup}>
|
||||
{children}
|
||||
{error && <span className={styles.errorText}>{error}</span>}
|
||||
</div>
|
||||
{footer}
|
||||
</fieldset>
|
||||
),
|
||||
);
|
||||
|
||||
FieldSet.displayName = 'FieldSet';
|
||||
|
||||
const assignRef = <T,>(ref: React.Ref<T> | undefined, value: T | null): void => {
|
||||
if (typeof ref === 'function') {
|
||||
ref(value);
|
||||
} else if (ref && typeof ref === 'object') {
|
||||
(ref as React.MutableRefObject<T | null>).current = value;
|
||||
}
|
||||
};
|
||||
|
||||
export interface RenderInputArgs {
|
||||
inputProps: React.InputHTMLAttributes<HTMLInputElement>;
|
||||
inputClassName: string;
|
||||
ref: React.Ref<HTMLInputElement>;
|
||||
defaultInput: React.ReactNode;
|
||||
}
|
||||
|
||||
type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
error?: string;
|
||||
footer?: React.ReactNode;
|
||||
label?: string;
|
||||
labelRight?: React.ReactNode;
|
||||
leftElement?: React.ReactNode;
|
||||
rightElement?: React.ReactNode;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
renderInput?: (args: RenderInputArgs) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{
|
||||
error,
|
||||
footer,
|
||||
label,
|
||||
labelRight,
|
||||
type,
|
||||
leftElement,
|
||||
rightElement,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
className,
|
||||
renderInput,
|
||||
disabled,
|
||||
readOnly,
|
||||
...props
|
||||
},
|
||||
forwardedRef,
|
||||
) => {
|
||||
const {t} = useLingui();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const isPasswordType = type === 'password';
|
||||
const resolveInputType = (): string | undefined => {
|
||||
if (!isPasswordType) return type;
|
||||
return showPassword ? 'text' : 'password';
|
||||
};
|
||||
const inputType = resolveInputType();
|
||||
const hasRightElement = isPasswordType || rightElement || rightIcon;
|
||||
const hasLeftElement = !!leftElement;
|
||||
const hasLeftIcon = !!leftIcon;
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const inputWrapperRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const setInputRefs = React.useCallback(
|
||||
(node: HTMLInputElement | null) => {
|
||||
inputRef.current = node;
|
||||
assignRef(forwardedRef, node);
|
||||
},
|
||||
[forwardedRef],
|
||||
);
|
||||
|
||||
const ariaInvalid = !!error;
|
||||
const hasControlledValue = props.value !== undefined;
|
||||
const shouldForceReadOnly = hasControlledValue && typeof props.onChange !== 'function';
|
||||
const normalizedReadOnly = readOnly ?? shouldForceReadOnly;
|
||||
const inputClassName = clsx(
|
||||
surfaceStyles.surface,
|
||||
styles.input,
|
||||
styles.minHeight,
|
||||
hasRightElement && styles.hasRightElement,
|
||||
(hasLeftIcon || hasLeftElement) && styles.hasLeftIcon,
|
||||
hasLeftElement && styles.hasLeftElement,
|
||||
error ? styles.error : styles.focusable,
|
||||
className,
|
||||
);
|
||||
|
||||
const inputProps: React.InputHTMLAttributes<HTMLInputElement> = {
|
||||
...props,
|
||||
disabled,
|
||||
readOnly: normalizedReadOnly,
|
||||
type: inputType,
|
||||
'aria-invalid': ariaInvalid || undefined,
|
||||
};
|
||||
|
||||
const defaultInput = <input {...inputProps} className={inputClassName} ref={setInputRefs} />;
|
||||
|
||||
const renderedInput = renderInput
|
||||
? renderInput({
|
||||
inputProps,
|
||||
inputClassName,
|
||||
ref: setInputRefs,
|
||||
defaultInput,
|
||||
})
|
||||
: defaultInput;
|
||||
|
||||
const inputContent = (
|
||||
<div ref={inputWrapperRef} className={styles.inputWrapper}>
|
||||
{leftElement && <div className={styles.leftElement}>{leftElement}</div>}
|
||||
{leftIcon && !leftElement && <div className={styles.leftIcon}>{leftIcon}</div>}
|
||||
{renderedInput}
|
||||
{isPasswordType && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.passwordToggle}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
aria-label={showPassword ? t`Hide password` : t`Show password`}
|
||||
>
|
||||
{showPassword ? <EyeSlashIcon size={18} weight="fill" /> : <EyeIcon size={18} weight="fill" />}
|
||||
</button>
|
||||
)}
|
||||
{!isPasswordType && rightIcon && <div className={styles.rightIcon}>{rightIcon}</div>}
|
||||
{!isPasswordType && rightElement && <div className={styles.rightElement}>{rightElement}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const focusDecoratedInput = (
|
||||
<FocusRing focusTarget={inputRef} ringTarget={inputWrapperRef} offset={-2} enabled={!disabled}>
|
||||
{inputContent}
|
||||
</FocusRing>
|
||||
);
|
||||
|
||||
if (!label) {
|
||||
return (
|
||||
<div className={styles.inputContainer}>
|
||||
{focusDecoratedInput}
|
||||
{error && <span className={styles.errorText}>{error}</span>}
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldSet
|
||||
error={error}
|
||||
footer={footer}
|
||||
label={label}
|
||||
labelRight={labelRight}
|
||||
htmlFor={props.id as string | undefined}
|
||||
>
|
||||
{focusDecoratedInput}
|
||||
</FieldSet>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
const BaseTextarea = React.forwardRef<HTMLTextAreaElement, TextareaAutosizeProps>(({className, ...rest}, ref) => (
|
||||
<TextareaAutosize
|
||||
{...lodash.omit(rest, 'style')}
|
||||
className={clsx(surfaceStyles.surface, styles.input, scrollerStyles.scroller, className)}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
|
||||
BaseTextarea.displayName = 'BaseTextarea';
|
||||
|
||||
type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
||||
error?: string;
|
||||
footer?: React.ReactNode;
|
||||
label: string;
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
showCharacterCount?: boolean;
|
||||
actionButton?: React.ReactNode;
|
||||
innerActionButton?: React.ReactNode;
|
||||
characterCountTooltip?: (remaining: number, total: number, current: number) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
(
|
||||
{
|
||||
error,
|
||||
footer,
|
||||
label,
|
||||
minRows = 2,
|
||||
maxRows = 10,
|
||||
showCharacterCount,
|
||||
maxLength,
|
||||
value,
|
||||
actionButton,
|
||||
innerActionButton,
|
||||
characterCountTooltip,
|
||||
disabled,
|
||||
id,
|
||||
...props
|
||||
},
|
||||
forwardedRef,
|
||||
) => {
|
||||
const currentValue = value || '';
|
||||
const currentLength = String(currentValue).length;
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||
const textareaWrapperRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const setTextareaRefs = React.useCallback(
|
||||
(node: HTMLTextAreaElement | null) => {
|
||||
textareaRef.current = node;
|
||||
assignRef(forwardedRef, node);
|
||||
},
|
||||
[forwardedRef],
|
||||
);
|
||||
|
||||
const sanitizedProps = lodash.omit(props, 'style');
|
||||
const textareaProps = {
|
||||
...sanitizedProps,
|
||||
id,
|
||||
'aria-invalid': !!error,
|
||||
maxRows,
|
||||
minRows,
|
||||
maxLength,
|
||||
value,
|
||||
disabled,
|
||||
};
|
||||
|
||||
const characterCounter = showCharacterCount && maxLength && (
|
||||
<span className={styles.characterCount}>
|
||||
{currentLength}/{maxLength}
|
||||
</span>
|
||||
);
|
||||
|
||||
const labelRight = (
|
||||
<div className={styles.labelContainer} style={{gap: '0.5rem'}}>
|
||||
{!innerActionButton && characterCounter}
|
||||
{actionButton}
|
||||
</div>
|
||||
);
|
||||
|
||||
const textareaWithActions = innerActionButton ? (
|
||||
<div
|
||||
ref={textareaWrapperRef}
|
||||
className={clsx(styles.textareaWrapper, surfaceStyles.surface, error ? styles.error : styles.focusable)}
|
||||
>
|
||||
<TextareaAutosize
|
||||
{...textareaProps}
|
||||
className={clsx(scrollerStyles.scroller, scrollerStyles.scrollerTextarea, styles.textarea)}
|
||||
ref={setTextareaRefs}
|
||||
/>
|
||||
<div className={styles.textareaActions}>
|
||||
{innerActionButton}
|
||||
{showCharacterCount && maxLength && characterCountTooltip && (
|
||||
<div className={styles.characterCountContainer}>
|
||||
{characterCountTooltip(maxLength - currentLength, maxLength, currentLength)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const simpleTextarea = !innerActionButton ? (
|
||||
<BaseTextarea
|
||||
{...textareaProps}
|
||||
className={clsx(error ? styles.error : styles.focusable)}
|
||||
ref={setTextareaRefs}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const ringTarget = innerActionButton ? textareaWrapperRef : textareaRef;
|
||||
const control = (innerActionButton ? textareaWithActions : simpleTextarea)!;
|
||||
|
||||
return (
|
||||
<FieldSet error={error} footer={footer} label={label} labelRight={labelRight} htmlFor={id}>
|
||||
<FocusRing focusTarget={textareaRef} ringTarget={ringTarget} offset={-2} enabled={!disabled}>
|
||||
{control}
|
||||
</FocusRing>
|
||||
</FieldSet>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Textarea.displayName = 'Textarea';
|
||||
51
fluxer_app/src/components/form/Select.module.css
Normal file
51
fluxer_app/src/components/form/Select.module.css
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.label.disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.description.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--status-danger);
|
||||
}
|
||||
388
fluxer_app/src/components/form/Select.tsx
Normal file
388
fluxer_app/src/components/form/Select.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import ReactSelect, {
|
||||
type ActionMeta,
|
||||
type ControlProps,
|
||||
type GroupBase,
|
||||
type InputProps,
|
||||
type MenuListProps,
|
||||
type MenuPlacement,
|
||||
type MultiValue,
|
||||
type OnChangeValue,
|
||||
type OptionProps,
|
||||
type Props as ReactSelectPropsConfig,
|
||||
components as reactSelectComponents,
|
||||
} from 'react-select';
|
||||
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
|
||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||
import {getSelectStyles} from '~/utils/SelectUtils';
|
||||
import styles from './Select.module.css';
|
||||
import {SelectBottomSheet} from './SelectBottomSheet';
|
||||
|
||||
type Primitive = string | number | null;
|
||||
|
||||
export type SelectOption<V extends Primitive = string> = {
|
||||
value: V;
|
||||
label: string;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
type SelectGroup<V extends Primitive> = GroupBase<SelectOption<V>>;
|
||||
type ReactSelectProps<V extends Primitive, IsMulti extends boolean> = ReactSelectPropsConfig<
|
||||
SelectOption<V>,
|
||||
IsMulti,
|
||||
SelectGroup<V>
|
||||
>;
|
||||
|
||||
interface SelectProps<
|
||||
V extends Primitive = string,
|
||||
IsMulti extends boolean = false,
|
||||
O extends SelectOption<V> = SelectOption<V>,
|
||||
> {
|
||||
label?: string;
|
||||
description?: string;
|
||||
value: IsMulti extends true ? Array<V> : V;
|
||||
options: ReadonlyArray<O>;
|
||||
onChange: (value: IsMulti extends true ? Array<V> : V) => void;
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
placeholder?: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
isSearchable?: boolean;
|
||||
tabIndex?: number;
|
||||
tabSelectsValue?: boolean;
|
||||
blurInputOnSelect?: boolean;
|
||||
openMenuOnFocus?: boolean;
|
||||
closeMenuOnSelect?: boolean;
|
||||
autoSelectExactMatch?: boolean;
|
||||
components?: ReactSelectProps<V, IsMulti>['components'];
|
||||
isLoading?: boolean;
|
||||
isClearable?: boolean;
|
||||
filterOption?: ReactSelectProps<V, IsMulti>['filterOption'];
|
||||
isMulti?: IsMulti;
|
||||
menuPlacement?: ReactSelectProps<V, IsMulti>['menuPlacement'];
|
||||
menuShouldScrollIntoView?: ReactSelectProps<V, IsMulti>['menuShouldScrollIntoView'];
|
||||
maxMenuHeight?: number;
|
||||
renderOption?: (option: O, isSelected: boolean) => React.ReactNode;
|
||||
renderValue?: (option: IsMulti extends true ? Array<O> : O | null) => React.ReactNode;
|
||||
}
|
||||
|
||||
const SelectDesktop = observer(function SelectDesktop<
|
||||
V extends Primitive = string,
|
||||
IsMulti extends boolean = false,
|
||||
O extends SelectOption<V> = SelectOption<V>,
|
||||
>({
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
disabled = false,
|
||||
error,
|
||||
placeholder,
|
||||
className,
|
||||
isSearchable = true,
|
||||
tabIndex,
|
||||
tabSelectsValue = false,
|
||||
blurInputOnSelect = true,
|
||||
openMenuOnFocus = false,
|
||||
closeMenuOnSelect = true,
|
||||
autoSelectExactMatch = false,
|
||||
components: componentsProp,
|
||||
isLoading,
|
||||
isClearable,
|
||||
filterOption,
|
||||
isMulti,
|
||||
menuPlacement: menuPlacementProp,
|
||||
menuShouldScrollIntoView,
|
||||
maxMenuHeight: maxMenuHeightProp,
|
||||
renderOption,
|
||||
renderValue,
|
||||
}: SelectProps<V, IsMulti, O>) {
|
||||
const generatedId = React.useId();
|
||||
const inputId = id ?? generatedId;
|
||||
const controlRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const menuListRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [calculatedMenuPlacement, setCalculatedMenuPlacement] = React.useState<MenuPlacement>('auto');
|
||||
const [calculatedMaxHeight, setCalculatedMaxHeight] = React.useState<number>(300);
|
||||
|
||||
const selectedOption = React.useMemo(() => {
|
||||
if (isMulti) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return options.filter((option) => (value as Array<V>).includes(option.value));
|
||||
}
|
||||
return options.find((option) => option.value === value) || null;
|
||||
}, [isMulti, options, value]);
|
||||
|
||||
const handleChange = (
|
||||
newValue: OnChangeValue<SelectOption<V>, IsMulti>,
|
||||
_actionMeta: ActionMeta<SelectOption<V>>,
|
||||
) => {
|
||||
if (isMulti) {
|
||||
const vals = (newValue as MultiValue<SelectOption<V>>).map((o) => o.value);
|
||||
(onChange as (value: Array<V>) => void)(vals);
|
||||
} else {
|
||||
if (newValue) {
|
||||
(onChange as (value: V) => void)((newValue as SelectOption<V>).value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (!autoSelectExactMatch || isMulti) return;
|
||||
|
||||
const raw = event.target.value ?? '';
|
||||
const inputValue = raw.trim();
|
||||
if (!inputValue) return;
|
||||
|
||||
const normalizeNumeric = (s: string) => {
|
||||
const m = s.match(/^\s*0*([0-9]+)\s*$/);
|
||||
return m ? m[1] : null;
|
||||
};
|
||||
|
||||
const lowered = inputValue.toLowerCase();
|
||||
const numeric = normalizeNumeric(inputValue);
|
||||
|
||||
const candidates = options.filter((option) => {
|
||||
const ovString = option.value == null ? '' : String(option.value);
|
||||
if (ovString === inputValue) return true;
|
||||
if (option.label.toLowerCase() === lowered) return true;
|
||||
if (numeric != null) {
|
||||
const ovNum = normalizeNumeric(ovString);
|
||||
if (ovNum != null && ovNum === numeric) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (candidates.length === 1) {
|
||||
(onChange as (value: V) => void)(candidates[0].value);
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultFilter = (label: string, input: string) => label.toLowerCase().includes(input.toLowerCase());
|
||||
|
||||
const filteredOptions = options.filter((option) => {
|
||||
if (filterOption) {
|
||||
const filterOptionWrapper = {
|
||||
label: option.label,
|
||||
value: String(option.value),
|
||||
data: option,
|
||||
};
|
||||
return filterOption(filterOptionWrapper, inputValue);
|
||||
}
|
||||
return defaultFilter(option.label, inputValue);
|
||||
});
|
||||
|
||||
if (filteredOptions.length > 0) {
|
||||
(onChange as (value: V) => void)(filteredOptions[0].value);
|
||||
}
|
||||
};
|
||||
|
||||
const mergedComponents = React.useMemo(() => {
|
||||
const Control = (controlProps: ControlProps<SelectOption<V>, IsMulti>) => (
|
||||
<reactSelectComponents.Control
|
||||
{...controlProps}
|
||||
innerRef={(node) => {
|
||||
controlRef.current = node;
|
||||
const ref = controlProps.innerRef;
|
||||
if (typeof ref === 'function') ref(node);
|
||||
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const Input = (inputProps: InputProps<SelectOption<V>, IsMulti>) => (
|
||||
<reactSelectComponents.Input
|
||||
{...inputProps}
|
||||
innerRef={(node) => {
|
||||
inputRef.current = node;
|
||||
const ref = inputProps.innerRef;
|
||||
if (typeof ref === 'function') ref(node);
|
||||
else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = node;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const MenuListComponent = componentsProp?.MenuList ?? reactSelectComponents.MenuList;
|
||||
const MenuList = (menuListProps: MenuListProps<SelectOption<V>, IsMulti, SelectGroup<V>>) => {
|
||||
const {innerRef, ...restProps} = menuListProps;
|
||||
return (
|
||||
<MenuListComponent
|
||||
{...restProps}
|
||||
innerRef={(node) => {
|
||||
menuListRef.current = node;
|
||||
if (typeof innerRef === 'function') {
|
||||
innerRef(node);
|
||||
} else if (innerRef) {
|
||||
(innerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const OptionComponent = componentsProp?.Option ?? reactSelectComponents.Option;
|
||||
const Option = (optionProps: OptionProps<SelectOption<V>, IsMulti, SelectGroup<V>>) => {
|
||||
const {innerProps, ...restProps} = optionProps;
|
||||
const {onMouseMove: _onMouseMove, onMouseOver: _onMouseOver, ...innerPropsWithoutHover} = innerProps;
|
||||
return <OptionComponent {...restProps} innerProps={innerPropsWithoutHover} />;
|
||||
};
|
||||
|
||||
return {
|
||||
...(componentsProp ?? {}),
|
||||
Control,
|
||||
Input,
|
||||
MenuList,
|
||||
Option,
|
||||
};
|
||||
}, [componentsProp]);
|
||||
|
||||
const updateMenuPlacement = React.useCallback(() => {
|
||||
const controlNode = controlRef.current;
|
||||
if (!controlNode) return;
|
||||
|
||||
const rect = controlNode.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
const spaceAbove = rect.top;
|
||||
const spaceBelow = viewportHeight - rect.bottom;
|
||||
|
||||
const prefersTop = spaceAbove > spaceBelow && spaceAbove > 200;
|
||||
const availableSpace = Math.max(prefersTop ? spaceAbove : spaceBelow, 180) - 12;
|
||||
|
||||
setCalculatedMenuPlacement(prefersTop ? 'top' : 'bottom');
|
||||
setCalculatedMaxHeight(Math.max(180, Math.min(availableSpace, 300)));
|
||||
}, []);
|
||||
|
||||
const handleDocumentScroll = React.useCallback(
|
||||
(event: Event) => {
|
||||
if (menuListRef.current && event.target instanceof Node && menuListRef.current.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
updateMenuPlacement();
|
||||
},
|
||||
[updateMenuPlacement],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
updateMenuPlacement();
|
||||
const handleResize = () => updateMenuPlacement();
|
||||
window.addEventListener('resize', handleResize);
|
||||
document.addEventListener('scroll', handleDocumentScroll, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
document.removeEventListener('scroll', handleDocumentScroll, true);
|
||||
};
|
||||
}, [updateMenuPlacement, handleDocumentScroll]);
|
||||
|
||||
const handleMenuOpen = React.useCallback(() => {
|
||||
updateMenuPlacement();
|
||||
}, [updateMenuPlacement]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{label && (
|
||||
<label htmlFor={inputId} className={clsx(styles.label, disabled && styles.disabled)}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className={className}>
|
||||
<FocusRing focusTarget={inputRef} ringTarget={controlRef} offset={-2} enabled={!disabled} within={true}>
|
||||
<ReactSelect<SelectOption<V>, IsMulti>
|
||||
inputId={inputId}
|
||||
value={selectedOption as SelectOption<V> | Array<SelectOption<V>> | null}
|
||||
options={options}
|
||||
onChange={handleChange}
|
||||
isDisabled={disabled}
|
||||
placeholder={placeholder}
|
||||
styles={getSelectStyles<SelectOption<V>, IsMulti>(!!error)}
|
||||
isSearchable={isSearchable}
|
||||
tabIndex={tabIndex}
|
||||
tabSelectsValue={tabSelectsValue}
|
||||
blurInputOnSelect={blurInputOnSelect}
|
||||
openMenuOnFocus={openMenuOnFocus}
|
||||
closeMenuOnSelect={closeMenuOnSelect}
|
||||
menuPortalTarget={document.body}
|
||||
menuPosition="fixed"
|
||||
menuPlacement={menuPlacementProp ?? calculatedMenuPlacement}
|
||||
menuShouldScrollIntoView={menuShouldScrollIntoView ?? false}
|
||||
maxMenuHeight={maxMenuHeightProp ?? calculatedMaxHeight}
|
||||
components={mergedComponents}
|
||||
isLoading={isLoading}
|
||||
isClearable={isClearable}
|
||||
filterOption={filterOption}
|
||||
onBlur={handleBlur}
|
||||
isMulti={isMulti}
|
||||
onMenuOpen={handleMenuOpen}
|
||||
formatOptionLabel={(option, {context}) => {
|
||||
const isSelected = Array.isArray(selectedOption)
|
||||
? selectedOption.some((o) => o.value === option.value)
|
||||
: selectedOption?.value === option.value;
|
||||
if (context === 'value' && renderValue && !isMulti) {
|
||||
return renderValue(option as IsMulti extends true ? Array<O> : O | null);
|
||||
}
|
||||
if (renderOption) {
|
||||
return renderOption(option as O, isSelected);
|
||||
}
|
||||
return option.label;
|
||||
}}
|
||||
/>
|
||||
</FocusRing>
|
||||
</div>
|
||||
{description && <p className={clsx(styles.description, disabled && styles.disabled)}>{description}</p>}
|
||||
{error && <span className={styles.errorText}>{error}</span>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const Select = observer(function Select<
|
||||
V extends Primitive = string,
|
||||
IsMulti extends boolean = false,
|
||||
O extends SelectOption<V> = SelectOption<V>,
|
||||
>({id, ...props}: SelectProps<V, IsMulti, O>) {
|
||||
const isMobileLayout = MobileLayoutStore.isMobileLayout();
|
||||
|
||||
if (isMobileLayout) {
|
||||
return (
|
||||
<SelectBottomSheet
|
||||
id={id}
|
||||
label={props.label}
|
||||
description={props.description}
|
||||
value={props.value}
|
||||
options={props.options}
|
||||
onChange={props.onChange}
|
||||
disabled={props.disabled}
|
||||
error={props.error}
|
||||
placeholder={props.placeholder}
|
||||
className={props.className}
|
||||
isMulti={props.isMulti}
|
||||
renderOption={props.renderOption}
|
||||
renderValue={props.renderValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <SelectDesktop id={id} {...props} />;
|
||||
});
|
||||
188
fluxer_app/src/components/form/SelectBottomSheet.module.css
Normal file
188
fluxer_app/src/components/form/SelectBottomSheet.module.css
Normal file
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.label.disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.description.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background-color: var(--form-surface-background);
|
||||
border: 1px solid var(--form-border-color, transparent);
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.triggerDisabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.triggerError {
|
||||
border-color: var(--status-danger);
|
||||
}
|
||||
|
||||
.triggerValue {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.triggerPlaceholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.triggerIcon {
|
||||
flex-shrink: 0;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.scrollContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.bottomSpacer {
|
||||
flex-shrink: 0;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.optionsContainer {
|
||||
overflow: hidden;
|
||||
border-radius: 0.75rem;
|
||||
background-color: var(--background-secondary-alt);
|
||||
}
|
||||
|
||||
.optionButton {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
transition: background-color 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.optionButton:active:not(:disabled) {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.optionButton:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.optionButtonSelected {
|
||||
background-color: color-mix(in srgb, var(--brand-primary-light) 10%, transparent);
|
||||
}
|
||||
|
||||
:global(.theme-light) .optionButtonSelected {
|
||||
background-color: color-mix(in srgb, var(--brand-primary) 10%, transparent);
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.optionButtonSelected:hover {
|
||||
background-color: color-mix(in srgb, var(--brand-primary-light) 15%, transparent);
|
||||
}
|
||||
|
||||
:global(.theme-light) .optionButtonSelected:hover {
|
||||
background-color: color-mix(in srgb, var(--brand-primary) 15%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.optionLabel {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.optionDisabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.checkIconContainer {
|
||||
display: flex;
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.checkIcon {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
color: var(--brand-primary-light);
|
||||
}
|
||||
|
||||
:global(.theme-light) .checkIcon {
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
height: 1px;
|
||||
background-color: var(--background-header-secondary);
|
||||
opacity: 0.3;
|
||||
}
|
||||
212
fluxer_app/src/components/form/SelectBottomSheet.tsx
Normal file
212
fluxer_app/src/components/form/SelectBottomSheet.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {CaretDownIcon, CheckIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import {BottomSheet} from '~/components/uikit/BottomSheet/BottomSheet';
|
||||
import type {SelectOption} from './Select';
|
||||
import styles from './SelectBottomSheet.module.css';
|
||||
|
||||
type Primitive = string | number | null;
|
||||
|
||||
interface SelectBottomSheetProps<
|
||||
V extends Primitive = string,
|
||||
IsMulti extends boolean = false,
|
||||
O extends SelectOption<V> = SelectOption<V>,
|
||||
> {
|
||||
label?: string;
|
||||
description?: string;
|
||||
value: IsMulti extends true ? Array<V> : V;
|
||||
options: ReadonlyArray<O>;
|
||||
onChange: (value: IsMulti extends true ? Array<V> : V) => void;
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
id?: string;
|
||||
isMulti?: IsMulti;
|
||||
renderOption?: (option: O, isSelected: boolean) => React.ReactNode;
|
||||
renderValue?: (option: IsMulti extends true ? Array<O> : O | null) => React.ReactNode;
|
||||
}
|
||||
|
||||
interface OptionItemProps<V extends Primitive, O extends SelectOption<V> = SelectOption<V>> {
|
||||
option: O;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
customContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
const OptionItem = <V extends Primitive, O extends SelectOption<V> = SelectOption<V>>({
|
||||
option,
|
||||
isSelected,
|
||||
onSelect,
|
||||
customContent,
|
||||
}: OptionItemProps<V, O>) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={clsx(styles.optionButton, isSelected && styles.optionButtonSelected)}
|
||||
disabled={option.isDisabled}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{customContent ?? (
|
||||
<span className={clsx(styles.optionLabel, option.isDisabled && styles.optionDisabled)}>{option.label}</span>
|
||||
)}
|
||||
{isSelected && (
|
||||
<div className={styles.checkIconContainer}>
|
||||
<CheckIcon weight="bold" className={styles.checkIcon} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
export const SelectBottomSheet = observer(function SelectBottomSheet<
|
||||
V extends Primitive = string,
|
||||
IsMulti extends boolean = false,
|
||||
O extends SelectOption<V> = SelectOption<V>,
|
||||
>({
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
disabled = false,
|
||||
error,
|
||||
placeholder,
|
||||
className,
|
||||
isMulti,
|
||||
renderOption,
|
||||
renderValue,
|
||||
}: SelectBottomSheetProps<V, IsMulti, O>) {
|
||||
const {t} = useLingui();
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
const selectedOption = React.useMemo(() => {
|
||||
if (isMulti) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return options.filter((option) => (value as Array<V>).includes(option.value));
|
||||
}
|
||||
return options.find((option) => option.value === value) || null;
|
||||
}, [isMulti, options, value]);
|
||||
|
||||
const displayValue = React.useMemo(() => {
|
||||
if (renderValue) {
|
||||
return renderValue(selectedOption as IsMulti extends true ? Array<O> : O | null);
|
||||
}
|
||||
if (isMulti) {
|
||||
const selected = selectedOption as Array<O>;
|
||||
if (selected.length === 0) return placeholder ?? t`Select...`;
|
||||
if (selected.length === 1) return selected[0].label;
|
||||
return t`${selected.length} selected`;
|
||||
}
|
||||
return (selectedOption as O | null)?.label ?? placeholder ?? t`Select...`;
|
||||
}, [isMulti, selectedOption, placeholder, renderValue]);
|
||||
|
||||
const handleOpen = () => {
|
||||
if (!disabled) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleSelect = (optionValue: V) => {
|
||||
if (isMulti) {
|
||||
const currentValues = Array.isArray(value) ? (value as Array<V>) : [];
|
||||
const newValues = currentValues.includes(optionValue)
|
||||
? currentValues.filter((v) => v !== optionValue)
|
||||
: [...currentValues, optionValue];
|
||||
(onChange as (value: Array<V>) => void)(newValues);
|
||||
} else {
|
||||
(onChange as (value: V) => void)(optionValue);
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const isOptionSelected = (optionValue: V): boolean => {
|
||||
if (isMulti) {
|
||||
return Array.isArray(value) && (value as Array<V>).includes(optionValue);
|
||||
}
|
||||
return value === optionValue;
|
||||
};
|
||||
|
||||
const generatedTriggerId = React.useId();
|
||||
const triggerId = id ?? generatedTriggerId;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{label && (
|
||||
<label htmlFor={triggerId} className={clsx(styles.label, disabled && styles.disabled)}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className={className}>
|
||||
<button
|
||||
id={triggerId}
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
disabled={disabled}
|
||||
className={clsx(styles.trigger, disabled && styles.triggerDisabled, error && styles.triggerError)}
|
||||
>
|
||||
<span className={clsx(styles.triggerValue, !selectedOption && styles.triggerPlaceholder)}>
|
||||
{displayValue}
|
||||
</span>
|
||||
<CaretDownIcon weight="bold" className={styles.triggerIcon} />
|
||||
</button>
|
||||
</div>
|
||||
{description && <p className={clsx(styles.description, disabled && styles.disabled)}>{description}</p>}
|
||||
{error && <span className={styles.errorText}>{error}</span>}
|
||||
|
||||
<BottomSheet
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
snapPoints={[0, 0.6, 1]}
|
||||
initialSnap={1}
|
||||
disableDefaultHeader
|
||||
zIndex={30000}
|
||||
>
|
||||
<div className={styles.scrollContainer}>
|
||||
<div className={styles.optionsContainer}>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = isOptionSelected(option.value);
|
||||
return (
|
||||
<React.Fragment key={String(option.value)}>
|
||||
<OptionItem
|
||||
option={option}
|
||||
isSelected={isSelected}
|
||||
onSelect={() => handleSelect(option.value)}
|
||||
customContent={renderOption?.(option, isSelected)}
|
||||
/>
|
||||
{index < options.length - 1 && <div className={styles.divider} />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.bottomSpacer} />
|
||||
</div>
|
||||
</BottomSheet>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
123
fluxer_app/src/components/form/Switch.module.css
Normal file
123
fluxer_app/src/components/form/Switch.module.css
Normal file
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.container.compact {
|
||||
min-height: unset;
|
||||
gap: 0.75rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.labelContainer {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1 1 0%;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.label.disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.labelContainer.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.description {
|
||||
padding-right: 1rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.switchRoot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
height: 1.5rem;
|
||||
width: 2.75rem;
|
||||
border-radius: 9999px;
|
||||
background-color: rgb(107 114 128);
|
||||
transition-property: color, background-color, border-color;
|
||||
transition-duration: 300ms;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switchRoot.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.switchRoot[data-state='checked'] {
|
||||
background-color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.switchThumb {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background-color: white;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgb(0 0 0 / 0.1),
|
||||
0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
transition-property: transform;
|
||||
transition-duration: 300ms;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: transform;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.switchThumb[data-state='checked'] {
|
||||
transform: translateX(22px);
|
||||
}
|
||||
|
||||
.iconChecked {
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.iconUnchecked {
|
||||
color: rgb(107 114 128);
|
||||
}
|
||||
143
fluxer_app/src/components/form/Switch.tsx
Normal file
143
fluxer_app/src/components/form/Switch.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
|
||||
import styles from './Switch.module.css';
|
||||
|
||||
interface SwitchProps {
|
||||
label?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
export const Switch = observer(
|
||||
({label, description, value, onChange, disabled, ariaLabel, className, compact}: SwitchProps) => {
|
||||
const baseId = React.useId();
|
||||
const labelId = `${baseId}-switch-label`;
|
||||
const descriptionId = `${baseId}-switch-description`;
|
||||
|
||||
const hasLabel = label !== undefined && label !== null && !(typeof label === 'string' && label.trim().length === 0);
|
||||
|
||||
const hasDescription =
|
||||
description !== undefined &&
|
||||
description !== null &&
|
||||
!(typeof description === 'string' && description.trim().length === 0);
|
||||
|
||||
const rootRef = React.useRef<React.ElementRef<typeof SwitchPrimitive.Root>>(null);
|
||||
|
||||
const valueChange = React.useCallback(
|
||||
(next: boolean) => {
|
||||
if (disabled) return;
|
||||
onChange(next);
|
||||
},
|
||||
[disabled, onChange],
|
||||
);
|
||||
|
||||
const handleLabelToggle = React.useCallback(() => {
|
||||
if (disabled) return;
|
||||
onChange(!value);
|
||||
rootRef.current?.focus();
|
||||
}, [disabled, onChange, value]);
|
||||
|
||||
const handleLabelKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleLabelToggle();
|
||||
}
|
||||
},
|
||||
[handleLabelToggle],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.container, compact && styles.compact, className)}>
|
||||
{(hasLabel || hasDescription) && (
|
||||
<div
|
||||
className={clsx(styles.labelContainer, !disabled && styles.clickable)}
|
||||
onClick={handleLabelToggle}
|
||||
onKeyDown={handleLabelKeyDown}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
role="button"
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
{hasLabel && (
|
||||
<span id={labelId} className={clsx(styles.label, disabled && styles.disabled)}>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{hasDescription && (
|
||||
<p id={descriptionId} className={styles.description}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FocusRing focusTarget={rootRef} ringTarget={rootRef} offset={-2}>
|
||||
<SwitchPrimitive.Root
|
||||
ref={rootRef}
|
||||
checked={value}
|
||||
onCheckedChange={valueChange}
|
||||
disabled={disabled}
|
||||
className={clsx(styles.switchRoot, disabled && styles.disabled)}
|
||||
aria-label={!hasLabel ? ariaLabel : undefined}
|
||||
aria-labelledby={hasLabel ? labelId : undefined}
|
||||
aria-describedby={hasDescription ? descriptionId : undefined}
|
||||
>
|
||||
<SwitchPrimitive.Thumb className={styles.switchThumb}>
|
||||
{value ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 256 256"
|
||||
className={styles.iconChecked}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M232.49,80.49l-128,128a12,12,0,0,1-17,0l-56-56a12,12,0,1,1,17-17L96,183,215.51,63.51a12,12,0,0,1,17,17Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="10"
|
||||
height="10"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 256 256"
|
||||
className={styles.iconUnchecked}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M208.49,191.51a12,12,0,0,1-17,17L128,145,64.49,208.49a12,12,0,0,1-17-17L111,128,47.51,64.49a12,12,0,0,1,17-17L128,111l63.51-63.52a12,12,0,0,1,17,17L145,128Z" />
|
||||
</svg>
|
||||
)}
|
||||
</SwitchPrimitive.Thumb>
|
||||
</SwitchPrimitive.Root>
|
||||
</FocusRing>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.rule {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.iconContainer {
|
||||
margin-top: 0.125rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.iconValid {
|
||||
color: var(--status-online);
|
||||
}
|
||||
|
||||
.iconInvalid {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.labelValid {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25;
|
||||
color: var(--status-online);
|
||||
}
|
||||
|
||||
.labelInvalid {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
86
fluxer_app/src/components/form/UsernameValidationRules.tsx
Normal file
86
fluxer_app/src/components/form/UsernameValidationRules.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {CheckIcon, XIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import styles from './UsernameValidationRules.module.css';
|
||||
|
||||
const FLUXER_TAG_REGEX = /^[a-zA-Z0-9_]+$/;
|
||||
|
||||
export interface UsernameValidationResult {
|
||||
validLength: boolean;
|
||||
validCharacters: boolean;
|
||||
allValid: boolean;
|
||||
}
|
||||
|
||||
function validateUsername(username: string): UsernameValidationResult {
|
||||
const trimmed = username.trim();
|
||||
|
||||
const validLength = trimmed.length >= 1 && trimmed.length <= 32;
|
||||
const validCharacters = trimmed.length === 0 || FLUXER_TAG_REGEX.test(trimmed);
|
||||
const allValid = validLength && validCharacters;
|
||||
|
||||
return {
|
||||
validLength,
|
||||
validCharacters,
|
||||
allValid,
|
||||
};
|
||||
}
|
||||
|
||||
interface UsernameValidationRulesProps {
|
||||
username: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const UsernameValidationRules: React.FC<UsernameValidationRulesProps> = observer(({username, className}) => {
|
||||
const validation = validateUsername(username);
|
||||
|
||||
const rules = [
|
||||
{
|
||||
key: 'length',
|
||||
valid: validation.validLength,
|
||||
label: <Trans>Between 1 and 32 characters</Trans>,
|
||||
},
|
||||
{
|
||||
key: 'characters',
|
||||
valid: validation.validCharacters,
|
||||
label: <Trans>Letters (a-z, A-Z), numbers (0-9), and underscores (_) only</Trans>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.container, className)}>
|
||||
{rules.map((rule) => (
|
||||
<div key={rule.key} className={styles.rule}>
|
||||
<div className={styles.iconContainer}>
|
||||
{rule.valid ? (
|
||||
<CheckIcon weight="bold" size={16} className={styles.iconValid} />
|
||||
) : (
|
||||
<XIcon weight="bold" size={16} className={styles.iconInvalid} />
|
||||
)}
|
||||
</div>
|
||||
<span className={rule.valid ? styles.labelValid : styles.labelInvalid}>{rule.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user