initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View 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;
}

View 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>
);
});

View 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>
),
);

View 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;
}

View 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;
}

View 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';

View 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);
}

View 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} />;
});

View 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;
}

View 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>
);
});

View 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);
}

View 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>
);
},
);

View File

@@ -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);
}

View 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>
);
});